DeepSeek 对话导出

将 Deepseek 对话导出与复制的工具

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==UserScript==
// @name         DeepSeek 对话导出
// @name:en      DeepSeek Chat Export
// @namespace    http://tampermonkey.net/
// @version      1.25.0313
// @description  将 Deepseek 对话导出与复制的工具
// @description:en  The tool for exporting and copying dialogues in Deepseek
// @author       木炭
// @copyright	 © 2025 木炭
// @license      MIT
// @supportURL   https://github.com/woodcoal/deepseek-chat-export
// @homeUrl      https://www.mutan.vip/
// @lastmodified 2025-02-27
// @match        https://chat.deepseek.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=deepseek.com
// @grant        GM_addStyle
// @grant        GM_setClipboard
// @run-at       document-body
// ==/UserScript==

(function () {
	('use strict');
	const BUTTON_ID = 'DS_MarkdownExport';
	let isProcessing = false;

	GM_addStyle(`
        #${BUTTON_ID}-container {
            position: fixed !important;
            top: 20px !important;
            right: 20px !important;
            z-index: 2147483647 !important;
            display: flex !important;
            gap: 8px !important;
        }
        #${BUTTON_ID}, #${BUTTON_ID}-copy {
            padding: 4px !important;
            cursor: pointer !important;
            transition: all 0.2s ease !important;
            opacity: 0.3 !important;
            background: none !important;
            border: none !important;
            font-size: 20px !important;
            position: relative !important;
        }
        #${BUTTON_ID}:hover, #${BUTTON_ID}-copy:hover {
            opacity: 1 !important;
            transform: scale(1.1) !important;
        }
        #${BUTTON_ID}:hover::after, #${BUTTON_ID}-copy:hover::after {
            content: attr(title) !important;
            position: absolute !important;
            top: 100% !important;
            left: 50% !important;
            transform: translateX(-50%) !important;
            background: rgba(0, 0, 0, 0.8) !important;
            color: white !important;
            padding: 4px 8px !important;
            border-radius: 4px !important;
            font-size: 12px !important;
            white-space: nowrap !important;
            z-index: 1000 !important;
        }
        .ds-toast {
            position: fixed !important;
            top: 20px !important;
            left: 50% !important;
            transform: translateX(-50%) !important;
            color: white !important;
            padding: 8px 16px !important;
            border-radius: 4px !important;
            font-size: 14px !important;
            z-index: 2147483647 !important;
            animation: toast-in-out 2s ease !important;
        }
        .ds-toast.error {
            background: rgba(255, 0, 0, 0.8) !important;
        }
        .ds-toast.success {
            background: rgba(0, 100, 255, 0.8) !important;
        }
        @keyframes toast-in-out {
            0% { opacity: 0; transform: translate(-50%, -20px); }
            20% { opacity: 1; transform: translate(-50%, 0); }
            80% { opacity: 1; transform: translate(-50%, 0); }
            100% { opacity: 0; transform: translate(-50%, 20px); }
        }
    `);

	const SELECTORS = {
		MESSAGE: 'dad65929', // 消息内容区域
		USER_PROMPT: 'fa81', // 用户提问
		AI_ANSWER: 'f9bf7997', // AI回答区域
		AI_THINKING: 'e1675d8b', // 思考区域
		AI_RESPONSE: 'ds-markdown', // 回答内容区域
		TITLE: 'd8ed659a' // 标题
	};

	function createUI() {
		if (document.getElementById(BUTTON_ID)) return;

		// 检查当前是否为首页
		if (isHomePage()) {
			// 如果是首页,移除已存在的按钮
			const existingContainer = document.getElementById(`${BUTTON_ID}-container`);
			if (existingContainer) {
				existingContainer.remove();
			}
			return;
		}

		const container = document.createElement('div');
		container.id = `${BUTTON_ID}-container`;

		const copyBtn = document.createElement('button');
		copyBtn.id = `${BUTTON_ID}-copy`;
		copyBtn.textContent = '📋';
		copyBtn.title = '复制到剪贴板';
		copyBtn.onclick = () => handleExport('clipboard');

		const exportBtn = document.createElement('button');
		exportBtn.id = BUTTON_ID;
		exportBtn.textContent = '💾';
		exportBtn.title = '导出对话';
		exportBtn.onclick = () => handleExport('file');

		container.append(copyBtn, exportBtn);
		document.body.append(container);
	}

	// 添加判断是否为首页的函数
	function isHomePage() {
		// 检查URL是否为首页
		if (
			window.location.pathname === '/' ||
			window.location.href === 'https://chat.deepseek.com/'
		) {
			return true;
		}

		// 检查是否存在对话内容元素
		const hasConversation = !!document.querySelector(`.${SELECTORS.MESSAGE}`);
		return !hasConversation;
	}

	async function handleExport(mode) {
		if (isProcessing) return;
		isProcessing = true;

		try {
			const conversations = await extractConversations();
			if (!conversations.length) {
				showToast('未检测到有效对话内容', true);
				return;
			}

			const content = formatMarkdown(conversations);

			if (mode === 'file') {
				downloadMarkdown(content);
			} else {
				GM_setClipboard(content, 'text');
				showToast('对话内容已复制到剪贴板');
			}
		} catch (error) {
			console.error('[导出错误]', error);
			showToast(`操作失败: ${error.message}`, true);
		} finally {
			isProcessing = false;
		}
	}

	function extractConversations() {
		return new Promise((resolve) => {
			requestAnimationFrame(() => {
				const conversations = [];
				const blocks = document.querySelector(`.${SELECTORS.MESSAGE}`)?.childNodes;

				blocks.forEach((block) => {
					try {
						if (block.classList.contains(SELECTORS.USER_PROMPT)) {
							conversations.push({
								content: cleanContent(block, 'prompt'),
								type: 'user'
							});
						} else if (block.classList.contains(SELECTORS.AI_ANSWER)) {
							const thinkingNode = block.querySelector(`.${SELECTORS.AI_THINKING}`);
							const responseNode = block.querySelector(`.${SELECTORS.AI_RESPONSE}`);
							conversations.push({
								content: {
									thinking: thinkingNode
										? cleanContent(thinkingNode, 'thinking')
										: '',
									response: responseNode
										? cleanContent(responseNode, 'response')
										: ''
								},
								type: 'ai'
							});
						}
					} catch (e) {
						console.warn('[对话解析错误]', e);
					}
				});

				resolve(conversations);
			});
		});
	}

	function cleanContent(node, type) {
		const clone = node.cloneNode(true);
		clone
			.querySelectorAll('button, .ds-flex, .ds-icon, .ds-icon-button, .ds-button,svg')
			.forEach((el) => el.remove());

		switch (type) {
			case 'prompt':
				var content = clone.textContent.replace(/\n{2,}/g, '\n').trim();

				// 转义 HTML 代码
				return content.replace(/[<>&]/g, function (match) {
					const escapeMap = {
						'<': '&lt;',
						'>': '&gt;',
						'&': '&amp;'
					};
					return escapeMap[match];
				});

			case 'thinking':
				return clone.innerHTML
					.replace(/<\/p>/gi, '\n')
					.replace(/<br\s*\/?>/gi, '\n')
					.replace(/<\/?[^>]+(>|$)/g, '')
					.replace(/\n+/g, '\n')
					.trim();
			case 'response':
				return clone.innerHTML;
			default:
				return clone.textContent.trim();
		}
	}

	function formatMarkdown(conversations) {
		// 获取页面标题
		const titleElement = document.querySelector(`.${SELECTORS.TITLE}`);
		const title = titleElement ? titleElement.textContent.trim() : 'DeepSeek对话';

		let md = `# ${title}\n\n`;

		conversations.forEach((conv, idx) => {
			if (conv.type === 'user') {
				if (idx > 0) md += '\n---\n';
				// md += `## 第 *${idx + 1}#* 轮对话\n`;

				let ask = conv.content.split('\n').join('\n> ');
				md += `\n> [!info] 提问\n> ${ask}\n\n`;
			}

			if (conv.type === 'ai' && conv.content) {
				if (conv.content.thinking) {
					let thinking = conv.content.thinking.split('\n').join('\n> ');

					md += `\n> [!success] 思考\n${thinking}\n`;
				}

				if (conv.content.response) {
					md += `\n${enhancedHtmlToMarkdown(conv.content.response)}\n`;
				}
			}
		});

		return md;
	}

	function enhancedHtmlToMarkdown(html) {
		const tempDiv = document.createElement('div');
		tempDiv.innerHTML = html;

		// 预处理代码块
		tempDiv.querySelectorAll('.md-code-block').forEach((codeBlock) => {
			const lang =
				codeBlock.querySelector('.md-code-block-infostring')?.textContent?.trim() || '';
			const codeContent = codeBlock.querySelector('pre')?.textContent || '';
			codeBlock.replaceWith(`[_code_:]${lang}\n${codeContent}[:_code_]`);
		});

		// 预处理数学公式
		tempDiv.querySelectorAll('.math-inline').forEach((math) => {
			math.replaceWith(`$${math.textContent}$`);
		});
		tempDiv.querySelectorAll('.math-display').forEach((math) => {
			math.replaceWith(`\n$$\n${math.textContent}\n$$\n`);
		});

		return Array.from(tempDiv.childNodes)
			.map((node) => convertNodeToMarkdown(node))
			.join('')
			.replace(/\[_code_\:\]/g, '\n```')
			.replace(/\[\:_code_\]/g, '\n```\n')
			.trim();
	}

	function convertNodeToMarkdown(node, level = 0, processedNodes = new WeakSet()) {
		if (!node || processedNodes.has(node)) return '';
		processedNodes.add(node);

		const handlers = {
			P: (n) => {
				const text = processInlineElements(n);
				return text ? `${text}\n` : '';
			},
			STRONG: (n) => `**${n.textContent}**`,
			EM: (n) => `*${n.textContent}*`,
			HR: () => '\n---\n',
			BR: () => '\n',
			A: (n) => processLinkElement(n),
			IMG: (n) => processImageElement(n),
			BLOCKQUOTE: (n) => {
				const content = Array.from(n.childNodes)
					.map((child) => convertNodeToMarkdown(child, level, processedNodes))
					.join('')
					.split('\n')
					.filter((line) => line.trim())
					.map((line) => `> ${line}`)
					.join('\n');
				return `\n${content}\n`;
			},
			UL: (n) => processListItems(n, level, '-'),
			OL: (n) => processListItems(n, level, null, n.getAttribute('start') || 1),
			PRE: (n) => `[_code_:]${n.textContent.trim()}[:_code_]`,
			CODE: (n) => `\`${n.textContent.trim()}\``,
			H1: (n) => `# ${processInlineElements(n)}\n`,
			H2: (n) => `## ${processInlineElements(n)}\n`,
			H3: (n) => `### ${processInlineElements(n)}\n`,
			H4: (n) => `#### ${processInlineElements(n)}\n`,
			H5: (n) => `##### ${processInlineElements(n)}\n`,
			H6: (n) => `###### ${processInlineElements(n)}\n`,
			TABLE: processTable,
			DIV: (n) =>
				Array.from(n.childNodes)
					.map((child) => convertNodeToMarkdown(child, level, processedNodes))
					.join(''),
			'#text': (n) => n.textContent.trim(),
			_default: (n) =>
				Array.from(n.childNodes)
					.map((child) => convertNodeToMarkdown(child, level, processedNodes))
					.join('')
		};

		return handlers[node.nodeName]?.(node) || handlers._default(node);
	}

	function processInlineElements(node) {
		return Array.from(node.childNodes)
			.map((child) => {
				if (child.nodeType === 3) return child.textContent.trim();
				if (child.nodeType === 1) {
					if (child.matches('strong')) return `**${child.textContent}**`;
					if (child.matches('em')) return `*${child.textContent}*`;
					if (child.matches('code')) return `\`${child.textContent}\``;
					if (child.matches('a')) return processLinkElement(child);
					if (child.matches('img')) return processImageElement(child);
				}
				return child.textContent;
			})
			.join('');
	}

	function processImageElement(node) {
		const alt = node.getAttribute('alt') || '';
		const title = node.getAttribute('title') || '';
		const src = node.getAttribute('src') || '';
		return title ? `![${alt}](${src} "${title}")` : `![${alt}](${src})`;
	}

	function processLinkElement(node) {
		const href = node.getAttribute('href') || '';
		const title = node.getAttribute('title') || '';
		const content = Array.from(node.childNodes)
			.map((child) => convertNodeToMarkdown(child))
			.join('');
		return title ? `[${content}](${href} "${title}")` : `[${content}](${href})`;
	}

	function processListItems(node, level, marker, start = null) {
		let result = '';
		const indent = '  '.repeat(level);
		Array.from(node.children).forEach((li, idx) => {
			const prefix = marker ? `${marker} ` : `${parseInt(start) + idx}. `;
			// 先处理li节点的直接文本内容
			const mainContent = Array.from(li.childNodes)
				.filter((child) => child.nodeType === 1 && !child.matches('ul, ol'))
				.map((child) => convertNodeToMarkdown(child, level))
				.join('')
				.trim();

			if (mainContent) {
				result += `${indent}${prefix}${mainContent}\n`;
			}

			// 单独处理嵌套列表
			const nestedLists = li.querySelectorAll(':scope > ul, :scope > ol');
			nestedLists.forEach((list) => {
				result += convertNodeToMarkdown(list, level + 1);
			});
		});
		return result;
	}

	function processTable(node) {
		const rows = Array.from(node.querySelectorAll('tr'));
		if (!rows.length) return '';

		const headers = Array.from(rows[0].querySelectorAll('th,td')).map((cell) =>
			cell.textContent.trim()
		);

		let markdown = `\n| ${headers.join(' | ')} |\n| ${headers
			.map(() => '---')
			.join(' | ')} |\n`;

		for (let i = 1; i < rows.length; i++) {
			const cells = Array.from(rows[i].querySelectorAll('td')).map((cell) =>
				processInlineElements(cell)
			);
			markdown += `| ${cells.join(' | ')} |\n`;
		}

		return markdown + '\n';
	}

	function downloadMarkdown(content) {
		const titleElement = document.querySelector(`.${SELECTORS.TITLE}`);
		const title = titleElement ? titleElement.textContent.trim() : 'DeepSeek对话';
		const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
		const link = document.createElement('a');
		link.href = URL.createObjectURL(blob);
		link.download = `${title}.md`;
		link.style.display = 'none';
		document.body.appendChild(link);
		link.click();
		setTimeout(() => {
			document.body.removeChild(link);
			URL.revokeObjectURL(link.href);
		}, 1000);
	}

	function showToast(message, isError = false) {
		const toast = document.createElement('div');
		toast.className = `ds-toast ${isError ? 'error' : 'success'}`;
		toast.textContent = message;
		document.body.appendChild(toast);

		toast.addEventListener('animationend', () => {
			document.body.removeChild(toast);
		});
	}

	// 添加 URL 变化监听
	function setupUrlChangeListener() {
		let lastUrl = window.location.href;

		// 监听 URL 变化
		setInterval(() => {
			if (lastUrl !== window.location.href) {
				lastUrl = window.location.href;
				const existingContainer = document.getElementById(`${BUTTON_ID}-container`);
				if (existingContainer) {
					existingContainer.remove();
				}
				createUI();
			}
		}, 1000);

		// 监听 history 变化
		const pushState = history.pushState;
		history.pushState = function () {
			pushState.apply(history, arguments);
			const existingContainer = document.getElementById(`${BUTTON_ID}-container`);
			if (existingContainer) {
				existingContainer.remove();
			}
			createUI();
		};
	}

	const observer = new MutationObserver(() => createUI());
	observer.observe(document, { childList: true, subtree: true });
	// window.addEventListener('load', createUI);
	// setInterval(createUI, 3000);
	window.addEventListener('load', () => {
		createUI();
		setupUrlChangeListener();
	});
})();