ChatGPT Message PDF Downloader

 avatar
unknown
javascript
3 days ago
9.1 kB
2
No Index
// ==UserScript==
// @name         ChatGPT Message PDF Downloader (v3.3 - Final Layout Fix)
// @namespace    https://github.com/
// @version      3.3
// @description  Simple, reliable PDF export for ChatGPT messages with fixes for duplication and page-breaks.
// @author       Your Name
// @match        https://chatgpt.com/*
// @grant        none
// @require      https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  const pdfIconSvg = `
    <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" class="icon">
      <path d="M11.5 2.5H5.5C4.94772 2.5 4.5 2.94772 4.5 3.5V16.5C4.5 17.0523 4.94772 17.5 5.5 17.5H14.5C15.0523 17.5 15.5 17.0523 15.5 16.5V6.5L11.5 2.5Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
      <path d="M11.5 2.5V6.5H15.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
      <path d="M7.5 12.5L10 15L12.5 12.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
      <path d="M10 15V9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
    </svg>
  `;

  function addDownloadButton(turnElement) {
    const copyButton = turnElement.querySelector('button[data-testid*="copy"]');
    if (!copyButton) return;

    const buttonContainer = copyButton.parentElement;
    if (!buttonContainer || buttonContainer.querySelector('.download-pdf-button')) return;

    const downloadBtn = document.createElement('button');
    downloadBtn.className = 'text-token-text-secondary hover:bg-token-bg-secondary rounded-lg download-pdf-button';
    downloadBtn.title = 'Download message as PDF';
    downloadBtn.innerHTML = `<span class="touch:w-10 flex h-8 w-8 items-center justify-center">${pdfIconSvg}</span>`;
    downloadBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      generatePdfForMessage(turnElement);
    });
    copyButton.insertAdjacentElement('afterend', downloadBtn);
  }

  function sanitizeFilename(name) {
    return name.replace(/[/\\?%*:|"<>]/g, '_');
  }

  function extractAndCleanContent(turnElement) {
    const container = document.createElement('div');
    container.style.cssText = `
      max-width: 700px;
      margin: 0 auto;
      padding: 20px;
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      line-height: 1.6;
      color: #333;
      background: white;
    `;

    const messageContent = turnElement.querySelector('[data-message-author-role]') || turnElement;

    function processNode(node, targetParent) {
      if (node.nodeType === Node.TEXT_NODE) {
        targetParent.appendChild(document.createTextNode(node.textContent));
        return;
      }

      if (node.nodeType !== Node.ELEMENT_NODE) return;
      if (node.tagName === 'BUTTON' || node.getAttribute('role') === 'button' || node.closest('[class*="react-scroll-to-bottom"]')) return;

      const tagName = node.tagName.toLowerCase();
      let newElement;

      if (tagName === 'pre' || node.className.includes('language-')) {
        newElement = document.createElement('pre');
        newElement.style.cssText = `
          background: #f5f5f5; border: 1px solid #ddd; border-radius: 6px; padding: 12px;
          margin: 12px 0; font-family: 'Courier New', Consolas, monospace; font-size: 11px;
          line-height: 1.4; white-space: pre-wrap; word-break: break-all; overflow-wrap: anywhere;
          max-width: 100%; box-sizing: border-box;
        `;
        const codeText = node.textContent || '';
        const wrappedText = codeText.replace(/(.{80})/g, '$1\n');
        newElement.textContent = wrappedText;
      } else if (tagName === 'code' && node.parentElement.tagName !== 'PRE') {
        newElement = document.createElement('code');
        newElement.style.cssText = `
          background: #f0f0f0; padding: 2px 6px; border-radius: 4px;
          font-family: 'Courier New', Consolas, monospace; font-size: 11px; word-break: break-all;
        `;
        newElement.textContent = node.textContent || '';
      } else if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tagName)) {
        newElement = document.createElement(tagName);
        newElement.style.cssText = `margin: 16px 0 8px 0; font-weight: bold; color: #222;`;
      } else if (tagName === 'p') {
        newElement = document.createElement('p');
        newElement.style.margin = '12px 0';
      } else if (['ul', 'ol'].includes(tagName)) {
        newElement = document.createElement(tagName);
        newElement.style.cssText = `margin: 12px 0; padding-left: 20px;`;
      } else if (tagName === 'li') {
        newElement = document.createElement('li');
        newElement.style.margin = '4px 0';
      } else if (tagName === 'strong' || tagName === 'b') {
        newElement = document.createElement('strong');
        newElement.style.fontWeight = 'bold';
      } else if (tagName === 'em' || tagName === 'i') {
        newElement = document.createElement('em');
        newElement.style.fontStyle = 'italic';
      } else {
        newElement = document.createElement('div');
      }

      targetParent.appendChild(newElement);

      if (tagName === 'pre' || node.className.includes('language-')) {
        return;
      }

      for (const child of node.childNodes) {
        processNode(child, newElement);
      }
    }

    processNode(messageContent, container);
    return container;
  }

  async function generatePdfForMessage(turnElement) {
    try {
      if (typeof html2pdf === 'undefined') {
        alert('PDF library not loaded. Please refresh the page.');
        return;
      }

      const cleanContent = extractAndCleanContent(turnElement);
      const htmlDoc = document.createElement('html');
      const head = document.createElement('head');
      const body = document.createElement('body');
      
      const style = document.createElement('style');
      style.textContent = `
        @page { margin: 20mm; }
        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 20px; }
        h1, h2, h3, h4, h5, h6 { page-break-after: avoid !important; page-break-inside: avoid !important; }
        pre { background: #f8f8f8 !important; border: 1px solid #e0e0e0 !important; border-radius: 6px !important; padding: 12px !important; margin: 12px 0 !important; font-family: 'Courier New', Consolas, monospace !important; font-size: 11px !important; line-height: 1.4 !important; white-space: pre-wrap !important; word-break: break-all !important; overflow-wrap: anywhere !important; page-break-inside: avoid !important; }
        code { font-family: 'Courier New', Consolas, monospace !important; word-break: break-all !important; }
      `;
      
      head.appendChild(style);
      body.appendChild(cleanContent);
      htmlDoc.appendChild(head);
      htmlDoc.appendChild(body);

      const chatTitle = (document.title || 'ChatGPT').trim();
      const firstLine = (turnElement.textContent || '').trim().split('\n')[0].slice(0, 60);
      const filename = sanitizeFilename(`${chatTitle} - ${firstLine}.pdf`);

      const opt = {
        margin: 0,
        filename,
        image: { type: 'jpeg', quality: 0.95 },
        html2canvas: { scale: 1.5, useCORS: true, logging: false, backgroundColor: 'white' },
        jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
        pagebreak: { mode: ['css', 'legacy'], avoid: ['pre', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'] }
      };

      await html2pdf().from(htmlDoc).set(opt).save();

    } catch (error) {
      console.error('Error in generatePdfForMessage:', error);
      alert(`An error occurred: ${error.message || 'Unknown error'}`);
    }
  }

  function processAllMessages() {
    document.querySelectorAll('article[data-testid^="conversation-turn-"]').forEach(addDownloadButton);
  }

  let debounceTimer;
  const observer = new MutationObserver(() => {
    clearTimeout(debounceTimer);
    debounceTimer = setTimeout(processAllMessages, 200);
  });

  function initialize() {
    if (typeof html2pdf === 'undefined') {
      console.error('html2pdf library not loaded. Check if the @require URL is accessible.');
      setTimeout(initialize, 1000);
      return;
    }
    const mainContentArea = document.querySelector('main');
    if (mainContentArea) {
      processAllMessages();
      observer.observe(mainContentArea, { childList: true, subtree: true });
    } else {
      setTimeout(initialize, 500);
    }
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', initialize);
  } else {
    initialize();
  }
})();
Editor is loading...