// ==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();
})();