// ==UserScript== // @name DeepSeek Chat Exporter (Markdown & PDF & PNG - English improved version) // @namespace http://tampermonkey.net/ // @version 1.8.4 // @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 // @downloadURL https://update.greasyfork.icu/scripts/529946/DeepSeek%20Chat%20Exporter%20%28Markdown%20%20PDF%20%20PNG%20-%20English%20improved%20version%29.user.js // @updateURL https://update.greasyfork.icu/scripts/529946/DeepSeek%20Chat%20Exporter%20%28Markdown%20%20PDF%20%20PNG%20-%20English%20improved%20version%29.meta.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: '._5255ff8._4d41763', // Search/thinking time thinkingChainSelector: '.e1675d8b', // Thinking chain finalAnswerSelector: 'div.ds-markdown', // Final answer titleSelector: '.afa34042.e37a04e4.e0a1edb7', // Chat title // Fiber navigation paths discovered via __scanReact($0, prop) // Update these when the site changes; they allow deterministic extraction answerMarkdownPath: '$0.return.return.return', // memoizedProps.markdown thinkingContentPath: '$0.child.child.child.return.return.return.return.return.return.return', // memoizedProps.content exportFileName: 'DeepSeek', // Changed from DeepSeek_Chat_Export // Header strings used in exports userHeader: 'User', assistantHeader: 'Assistant', thoughtsHeader: 'Thought Process', }; // For future maintainers: see BREAK_FIX_GUIDE.md for step-by-step recovery // when DOM classes or React fiber structure change. // 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; } /** * Navigate a React fiber from a DOM element using a path string * Path format mirrors React DevTools output from __scanReact, e.g. "$0.return.child.sibling" * Returns the fiber located at the end of the path, or null. */ function navigateFiberPathFromElement(element, pathString) { if (!element || !pathString) return null; const fiberKey = Object.keys(element).find(k => k.startsWith('__reactFiber$')); if (!fiberKey) return null; let fiber = element[fiberKey]; // Normalize path: drop leading "$0." or "$0" const cleaned = pathString.replace(/^\$0\.*/, ''); if (!cleaned) return fiber; const steps = cleaned.split('.'); for (const step of steps) { if (!step) continue; fiber = fiber ? fiber[step] : null; if (!fiber) return null; } return fiber; } /** * 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 * * CRITICAL: This function MUST extract the raw markdown from React's internal state. * Converting HTML to markdown is fundamentally broken and loses formatting, LaTeX, * code blocks, and other essential content. The entire purpose of this script is * to get the original markdown before it's rendered to HTML. */ function extractThinkingChain(node) { // Prefer the inner ds-markdown within the thinking container as the base const markdownEl = node.querySelector('div.ds-markdown'); const baseEl = markdownEl || node; const navFiber = navigateFiberPathFromElement(baseEl, config.thinkingContentPath); if (!navFiber || !navFiber.memoizedProps || !navFiber.memoizedProps.content) { console.error('THINKING CHAIN BROKEN: Could not find memoizedProps.content at configured path'); console.error('Please update config.thinkingContentPath using the BREAK_FIX_GUIDE.md'); alert('DeepSeek Exporter Error: Thinking chain extraction broken!\nDeepSeek may have updated their website. Check console for details.'); return null; } const content = navFiber.memoizedProps.content; return `### ${config.thoughtsHeader}\n\n> ${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 * * CRITICAL: This function MUST extract the raw markdown from React's internal state. * Converting HTML to markdown is fundamentally broken and loses formatting, LaTeX, * code blocks, and other essential content. The entire purpose of this script is * to get the original markdown before it's rendered to HTML. */ function extractFinalAnswer(node) { // Choose ds-markdown that is NOT inside the thinking container let answerNode = null; const candidates = node.querySelectorAll('div.ds-markdown'); for (const el of candidates) { if (!el.closest(config.thinkingChainSelector)) { answerNode = el; break; } } if (!answerNode) { // Fallback to first ds-markdown answerNode = node.querySelector(config.finalAnswerSelector); } if (!answerNode) { console.debug('No answer node found'); return null; } const navFiber = navigateFiberPathFromElement(answerNode, config.answerMarkdownPath); if (!navFiber || !navFiber.memoizedProps || !navFiber.memoizedProps.markdown) { console.error('FINAL ANSWER BROKEN: Could not find memoizedProps.markdown at configured path'); console.error('Please update config.answerMarkdownPath using the BREAK_FIX_GUIDE.md'); alert('DeepSeek Exporter Error: Final answer extraction broken!\nDeepSeek may have updated their website. Check console for details.'); return null; } return navFiber.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 = `
`) .replace(/>\s/g, '') // Remove the blockquote markers for HTML .replace(/\n/g, '
') .replace(/---/g, '