DeepSeek Chat Exporter (Markdown & PDF & PNG - English improved version)

Export DeepSeek chat history to Markdown, PDF and PNG formats

// ==UserScript==
// @name         DeepSeek Chat Exporter (Markdown & PDF & PNG - English improved version)
// @namespace    http://tampermonkey.net/
// @version      1.8.2
// @description  Export DeepSeek chat history to Markdown, PDF and PNG formats
// @author       HSyuf/Blueberrycongee/endolith
// @match        https://chat.deepseek.com/*
// @grant        GM_addStyle
// @grant        GM_download
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @license      MIT
// @require      https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js
// ==/UserScript==

(function () {
  'use strict';

  // =====================
  // Configuration
  // =====================
  const config = {
      chatContainerSelector: '.dad65929', // Chat container
      userMessageSelector: '._9663006 > .fbb737a4',  // Direct selector for user message content
      aiClassPrefix: '_4f9bf79',           // AI message related class prefix
      aiReplyContainer: '_43c05b5',        // Main container for AI replies
      searchHintSelector: '._58a6d71._19db599', // Search/thinking time
      thinkingChainSelector: '.e1675d8b',  // Thinking chain
      finalAnswerSelector: 'div.ds-markdown.ds-markdown--block', // Final answer
      titleSelector: '.d8ed659a',          // Chat title selector
      exportFileName: 'DeepSeek',          // Changed from DeepSeek_Chat_Export
      // Header strings used in exports
      userHeader: 'User',
      assistantHeader: 'Assistant',
      thoughtsHeader: 'Thought Process',
  };

  // User preferences with defaults
  const preferences = {
      convertLatexDelimiters: GM_getValue('convertLatexDelimiters', true),
  };

  // Register menu command for toggling LaTeX delimiter conversion
  GM_registerMenuCommand('Toggle LaTeX Delimiter Conversion', () => {
      preferences.convertLatexDelimiters = !preferences.convertLatexDelimiters;
      GM_setValue('convertLatexDelimiters', preferences.convertLatexDelimiters);
      alert(`LaTeX delimiter conversion is now ${preferences.convertLatexDelimiters ? 'enabled' : 'disabled'}`);
  });

  let __exportPNGLock = false;  // Global lock to prevent duplicate clicks

  // =====================
  // Tool functions
  // =====================
  /**
   * Gets the message content if the node contains a user message, null otherwise
   * @param {HTMLElement} node - The DOM node to check
   * @returns {string|null} The user message content if found, null otherwise
   */
  function getUserMessage(node) {
      const messageDiv = node.querySelector(config.userMessageSelector);
      return messageDiv ? messageDiv.firstChild.textContent.trim() : null;
  }

  /**
   * Checks if a DOM node represents an AI message
   * @param {HTMLElement} node - The DOM node to check
   * @returns {boolean} True if the node is an AI message
   */
  function isAIMessage(node) {
      return node.classList.contains(config.aiClassPrefix);
  }

  /**
   * Extracts search or thinking time information from a node
   * @param {HTMLElement} node - The DOM node to extract from
   * @returns {string|null} Markdown formatted search/thinking info or null if not found
   */
  function extractSearchOrThinking(node) {
      const hintNode = node.querySelector(config.searchHintSelector);
      return hintNode ? `**${hintNode.textContent.trim()}**` : null;
  }

  /**
   * Extracts and formats the AI's thinking chain as blockquotes
   * @param {HTMLElement} node - The DOM node containing the thinking chain
   * @returns {string|null} Markdown formatted thinking chain with header or null if not found
   */
  function extractThinkingChain(node) {
      // Get the parent container first - this is the main AI reply container
      const containerNode = node;  // Node is already the thinking chain container
      if (!containerNode) {
          console.debug('Could not find aiReplyContainer parent container');
          return null;
      }

      // Get its React fiber - this connects the DOM to React's internal tree
      const fiberKey = Object.keys(containerNode).find(key => key.startsWith('__reactFiber$'));
      if (!fiberKey) return null;

      // Navigate the React fiber tree to find the content:
      let current = containerNode[fiberKey];                // Start at container div
      current = current.child;                             // First child: Empty div._9ecc93a
      current = current.sibling;                          // Sibling: Anonymous component
      current = current.child;                            // Child: Component with content prop

      // Check if we found the content
      if (!current?.memoizedProps?.content) {
          console.debug('Could not find markdown content in Memo');
          return null;
      }

      return `### ${config.thoughtsHeader}\n\n> ${current.memoizedProps.content.split('\n').join('\n> ')}`;
  }

  /**
   * Extracts the final answer content from React fiber's memoizedProps
   * @param {HTMLElement} node - The DOM node containing the answer
   * @returns {string|null} Raw markdown content or null if not found
   */
  function extractFinalAnswer(node) {
      const answerNode = node.querySelector(config.finalAnswerSelector);
      if (!answerNode) {
          console.debug('No answer node found');
          return null;
      }

      // Get React fiber
      const fiberKey = Object.keys(answerNode).find(key => key.startsWith('__reactFiber$'));
      if (!fiberKey) {
          console.error('React fiber not found');
          return null;
      }

      // Navigate directly to the markdown component (2 levels up)
      const fiber = answerNode[fiberKey];           // Start at div
      const level1 = fiber.return;                  // First parent
      const markdownComponent = level1?.return;     // Second parent (has markdown)

      // If any navigation step failed or the component doesn't have markdown, return null
      if (!markdownComponent?.memoizedProps?.markdown) {
          console.error('Could not find markdown at expected location in React tree');
          return null;
      }

      return markdownComponent.memoizedProps.markdown;
  }

  /**
   * Collects and formats all messages in the chat in chronological order
   * @returns {string[]} Array of markdown formatted messages
   */
  function getOrderedMessages() {
      const messages = [];
      const chatContainer = document.querySelector(config.chatContainerSelector);
      if (!chatContainer) {
          console.error('Chat container not found');
          return messages;
      }

      for (const node of chatContainer.children) {
          const userMessage = getUserMessage(node);
          if (userMessage) {
              messages.push(`## ${config.userHeader}\n\n${userMessage}`);
          } else if (isAIMessage(node)) {
              let output = '';
              const searchHint = extractSearchOrThinking(node);
              if (searchHint) output += `${searchHint}\n\n`;

              const thinkingChainNode = node.querySelector(config.thinkingChainSelector);
              if (thinkingChainNode) {
                  const thinkingChain = extractThinkingChain(thinkingChainNode);
                  if (thinkingChain) output += `${thinkingChain}\n\n`;
              }

              const finalAnswer = extractFinalAnswer(node);
              if (finalAnswer) output += `${finalAnswer}\n\n`;
              if (output.trim()) {
                  messages.push(`## ${config.assistantHeader}\n\n${output.trim()}`);
              }
          }
      }
      return messages;
  }

  /**
   * Extracts the chat title from the page
   * @returns {string|null} The chat title if found, null otherwise
   */
  function getChatTitle() {
      const titleElement = document.querySelector(config.titleSelector);
      return titleElement ? titleElement.textContent.trim() : null;
  }

  /**
   * Generates the complete markdown content from all messages
   * @returns {string} Complete markdown formatted chat history
   */
  function generateMdContent() {
      const messages = getOrderedMessages();
      const title = getChatTitle();
      let content = title ? `# ${title}\n\n` : '';
      content += messages.length ? messages.join('\n\n---\n\n') : '';

      // Convert LaTeX formats only if enabled
      if (preferences.convertLatexDelimiters) {
          // Use replacement functions to properly handle newlines and whitespace
          content = content
              // Inline math: \( ... \) → $ ... $
              .replace(/\\\(\s*(.*?)\s*\\\)/g, (match, group) => `$${group}$`)

              // Display math: \[ ... \] → $$ ... $$
              .replace(/\\\[([\s\S]*?)\\\]/g, (match, group) => `$$${group}$$`);
      }

      return content;
  }

  /**
   * Creates a filename-safe version of a string
   * @param {string} str - The string to make filename-safe
   * @param {number} maxLength - Maximum length of the resulting string
   * @returns {string} A filename-safe version of the input string
   */
  function makeFilenameSafe(str, maxLength = 50) {
      if (!str) return '';
      return str
          .replace(/[^a-zA-Z0-9-_\s]/g, '') // Remove special characters
          .replace(/\s+/g, '_')             // Replace spaces with underscores
          .slice(0, maxLength)              // Truncate to maxLength
          .replace(/_+$/, '')               // Remove trailing underscores
          .trim();
  }

  /**
   * Generates a filename-safe ISO 8601 timestamp
   * @returns {string} Formatted timestamp YYYY-MM-DD_HH_MM_SS
   */
  function getFormattedTimestamp() {
      const now = new Date();
      return now.toISOString()
          .replace(/[T:]/g, '_')  // Replace T and : with _
          .replace(/\..+/, '');   // Remove milliseconds and timezone
  }

  // =====================
  // Export functions
  // =====================
  /**
   * Exports the chat history as a markdown file
   * Handles math expressions and creates a downloadable .md file
   */
  function exportMarkdown() {
      const mdContent = generateMdContent();
      if (!mdContent) {
          alert("No chat history found!");
          return;
      }

      const title = getChatTitle();
      const safeTitle = makeFilenameSafe(title, 30);
      const titlePart = safeTitle ? `_${safeTitle}` : '';

      const blob = new Blob([mdContent], { type: 'text/markdown' });
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = `${config.exportFileName}${titlePart}_${getFormattedTimestamp()}.md`;
      a.click();
      setTimeout(() => URL.revokeObjectURL(url), 5000);
  }

  /**
   * Exports the chat history as a PDF
   * Creates a styled HTML version and opens the browser's print dialog
   */
  function exportPDF() {
      const mdContent = generateMdContent();
      if (!mdContent) return;

      const printContent = `
          <html>
              <head>
                  <title>DeepSeek Chat Export</title>
                  <style>
                      body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; line-height: 1.6; padding: 20px; max-width: 800px; margin: 0 auto; }
                      h2 { color: #2c3e50; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
                      h3 { color: #555; margin-top: 15px; }
                      .ai-answer { color: #1a7f37; margin: 15px 0; }
                      .ai-chain { color: #666; font-style: italic; margin: 10px 0; padding-left: 15px; border-left: 3px solid #ddd; }
                      hr { border: 0; border-top: 1px solid #eee; margin: 25px 0; }
                      blockquote { border-left: 3px solid #ddd; margin: 0 0 20px; padding-left: 15px; color: #666; font-style: italic; }
                  </style>
              </head>
              <body>
                  ${mdContent.replace(new RegExp(`## ${config.userHeader}\\n\\n`, 'g'), `<h2>${config.userHeader}</h2><div class="user-question">`)
                      .replace(new RegExp(`## ${config.assistantHeader}\\n\\n`, 'g'), `<h2>${config.assistantHeader}</h2><div class="ai-answer">`)
                      .replace(new RegExp(`### ${config.thoughtsHeader}\\n`, 'g'), `<h3>${config.thoughtsHeader}</h3><blockquote class="ai-chain">`)
                      .replace(/>\s/g, '') // Remove the blockquote markers for HTML
                      .replace(/\n/g, '<br>')
                      .replace(/---/g, '</blockquote></div><hr>')}
              </body>
          </html>
      `;

      const printWindow = window.open("", "_blank");
      printWindow.document.write(printContent);
      printWindow.document.close();
      setTimeout(() => { printWindow.print(); printWindow.close(); }, 500);
  }

  /**
   * Exports the chat history as a PNG image
   * Creates a high-resolution screenshot of the chat content
   */
  function exportPNG() {
      if (__exportPNGLock) return;  // Skip if currently exporting
      __exportPNGLock = true;

      const chatContainer = document.querySelector(config.chatContainerSelector);
      if (!chatContainer) {
          alert("Chat container not found!");
          __exportPNGLock = false;
          return;
      }

      // Create sandbox container
      const sandbox = document.createElement('iframe');
      sandbox.style.cssText = `
          position: fixed;
          left: -9999px;
          top: 0;
          width: 800px;
          height: ${window.innerHeight}px;
          border: 0;
          visibility: hidden;
      `;
      document.body.appendChild(sandbox);

      // Deep clone and style processing
      const cloneNode = chatContainer.cloneNode(true);
      cloneNode.style.cssText = `
          width: 800px !important;
          transform: none !important;
          overflow: visible !important;
          position: static !important;
          background: white !important;
          max-height: none !important;
          padding: 20px !important;
          margin: 0 !important;
          box-sizing: border-box !important;
      `;

      // Clean up interfering elements, exclude icons
      ['button', 'input', '.ds-message-feedback-container', '.eb23581b.dfa60d66'].forEach(selector => {
          cloneNode.querySelectorAll(selector).forEach(el => el.remove());
      });

      // Math formula fix
      cloneNode.querySelectorAll('.katex-display').forEach(mathEl => {
          mathEl.style.transform = 'none !important';
          mathEl.style.position = 'relative !important';
      });

      // Inject sandbox
      sandbox.contentDocument.body.appendChild(cloneNode);
      sandbox.contentDocument.body.style.background = 'white';

      // Wait for resources to load
      const waitReady = () => Promise.all([document.fonts.ready, new Promise(resolve => setTimeout(resolve, 300))]);

      waitReady().then(() => {
          return html2canvas(cloneNode, {
              scale: 2,
              useCORS: true,
              logging: true,
              backgroundColor: "#FFFFFF"
          });
      }).then(canvas => {
          canvas.toBlob(blob => {
              const url = URL.createObjectURL(blob);
              const a = document.createElement('a');
              a.href = url;
              a.download = `${config.exportFileName}_${getFormattedTimestamp()}.png`;
              a.click();
              setTimeout(() => {
                  URL.revokeObjectURL(url);
                  sandbox.remove();
              }, 1000);
          }, 'image/png');
      }).catch(err => {
          console.error('Screenshot failed:', err);
          alert(`Export failed: ${err.message}`);
      }).finally(() => {
          __exportPNGLock = false;
      });
  }

  // =====================
  // Create Export Menu
  // =====================
  /**
   * Creates and attaches the export menu buttons to the page
   */
  function createExportMenu() {
      // Create main menu
      const menu = document.createElement("div");
      menu.className = "ds-exporter-menu";
      menu.innerHTML = `
          <button class="export-btn" id="md-btn" title="Export as Markdown">➡️📁</button>
          <button class="export-btn" id="pdf-btn" title="Export as PDF">➡️📄</button>
          <button class="export-btn" id="png-btn" title="Export as Image">➡️🖼️</button>
          <button class="settings-btn" id="settings-btn" title="Settings">⚙️</button>
      `;

      // Create settings panel
      const settingsPanel = document.createElement("div");
      settingsPanel.className = "ds-settings-panel";
      settingsPanel.innerHTML = `
          <div class="ds-settings-row">
              <label class="switch">
                  <input type="checkbox" id="latex-toggle" ${preferences.convertLatexDelimiters ? 'checked' : ''}>
                  <span class="slider"></span>
              </label>
              <span>Convert to $ LaTeX Delimiters</span>
          </div>
      `;

      // Add event listeners
      menu.querySelector("#md-btn").addEventListener("click", exportMarkdown);
      menu.querySelector("#pdf-btn").addEventListener("click", exportPDF);
      menu.querySelector("#png-btn").addEventListener("click", exportPNG);

      // Settings button toggle
      menu.querySelector("#settings-btn").addEventListener("click", () => {
          settingsPanel.classList.toggle("visible");
      });

      // LaTeX toggle switch
      settingsPanel.querySelector("#latex-toggle").addEventListener("change", (e) => {
          preferences.convertLatexDelimiters = e.target.checked;
          GM_setValue('convertLatexDelimiters', e.target.checked);
      });

      // Close settings when clicking outside
      document.addEventListener("click", (e) => {
          if (!settingsPanel.contains(e.target) &&
              !menu.querySelector("#settings-btn").contains(e.target)) {
              settingsPanel.classList.remove("visible");
          }
      });

      document.body.appendChild(menu);
      document.body.appendChild(settingsPanel);
  }

  // =====================
  // Styles
  // =====================
  GM_addStyle(`
  .ds-exporter-menu {
      position: fixed;
      top: 10px;
      right: 25px;
      z-index: 999999;
      background: #ffffff;
      border: 1px solid #ddd;
      border-radius: 4px;
      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
      padding: 4px;
      display: flex;
      flex-direction: column;
      gap: 2px;
  }

  .export-btn {
      background: #f8f9fa;
      color: #333;
      border: 1px solid #dee2e6;
      border-radius: 4px;
      padding: 4px 8px;
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
      font-size: 14px;
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
      transition: background-color 0.2s;
      min-width: 45px;
  }

  .export-btn:hover {
      background: #e9ecef;
  }

  .export-btn:active {
      background: #dee2e6;
  }

  /* Settings panel styles */
  .ds-settings-panel {
      position: fixed;
      top: 10px;
      right: 95px;
      z-index: 999998;
      background: #ffffff;
      border: 1px solid #ddd;
      border-radius: 4px;
      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
      padding: 12px;
      display: none;
      color: #333;
      min-width: 200px;
  }

  .ds-settings-panel.visible {
      display: block;
  }

  .ds-settings-row {
      display: flex;
      align-items: center;
      gap: 12px;
      margin: 4px 0;
      color: #333;
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
      font-size: 14px;
      white-space: nowrap;
  }

  /* Toggle switch styles */
  .switch {
      position: relative;
      display: inline-block;
      width: 40px;
      height: 20px;
  }

  .switch input {
      opacity: 0;
      width: 0;
      height: 0;
  }

  .slider {
      position: absolute;
      cursor: pointer;
      top: 0;
      left: 0;
      right: 0;
      bottom: 0;
      background-color: #ccc;
      transition: .4s;
      border-radius: 20px;
  }

  .slider:before {
      position: absolute;
      content: "";
      height: 16px;
      width: 16px;
      left: 2px;
      bottom: 2px;
      background-color: white;
      transition: .4s;
      border-radius: 50%;
  }

  input:checked + .slider {
      background-color: #2196F3;
  }

  input:checked + .slider:before {
      transform: translateX(20px);
  }

  .settings-btn {
      background: none;
      border: none;
      cursor: pointer;
      padding: 4px;
      font-size: 16px;
      color: #666;
  }

  .settings-btn:hover {
      color: #333;
  }
`);

  // =====================
  // Initialize
  // =====================
  /**
   * Initializes the exporter by waiting for the chat container to be ready
   * and then creating the export menu
   */
  function init() {
      const checkInterval = setInterval(() => {
          if (document.querySelector(config.chatContainerSelector)) {
              clearInterval(checkInterval);
              createExportMenu();
          }
      }, 500);
  }

  init();
})();