DeepSeek 对话导出

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

  1. // ==UserScript==
  2. // @name DeepSeek 对话导出
  3. // @name:en DeepSeek Chat Export
  4. // @namespace http://tampermonkey.net/
  5. // @version 1.25.0313
  6. // @description 将 Deepseek 对话导出与复制的工具
  7. // @description:en The tool for exporting and copying dialogues in Deepseek
  8. // @author 木炭
  9. // @copyright © 2025 木炭
  10. // @license MIT
  11. // @supportURL https://github.com/woodcoal/deepseek-chat-export
  12. // @homeUrl https://www.mutan.vip/
  13. // @lastmodified 2025-02-27
  14. // @match https://chat.deepseek.com/*
  15. // @icon https://www.google.com/s2/favicons?sz=64&domain=deepseek.com
  16. // @grant GM_addStyle
  17. // @grant GM_setClipboard
  18. // @run-at document-body
  19. // ==/UserScript==
  20.  
  21. (function () {
  22. ('use strict');
  23. const BUTTON_ID = 'DS_MarkdownExport';
  24. let isProcessing = false;
  25.  
  26. GM_addStyle(`
  27. #${BUTTON_ID}-container {
  28. position: fixed !important;
  29. top: 20px !important;
  30. right: 20px !important;
  31. z-index: 2147483647 !important;
  32. display: flex !important;
  33. gap: 8px !important;
  34. }
  35. #${BUTTON_ID}, #${BUTTON_ID}-copy {
  36. padding: 4px !important;
  37. cursor: pointer !important;
  38. transition: all 0.2s ease !important;
  39. opacity: 0.3 !important;
  40. background: none !important;
  41. border: none !important;
  42. font-size: 20px !important;
  43. position: relative !important;
  44. }
  45. #${BUTTON_ID}:hover, #${BUTTON_ID}-copy:hover {
  46. opacity: 1 !important;
  47. transform: scale(1.1) !important;
  48. }
  49. #${BUTTON_ID}:hover::after, #${BUTTON_ID}-copy:hover::after {
  50. content: attr(title) !important;
  51. position: absolute !important;
  52. top: 100% !important;
  53. left: 50% !important;
  54. transform: translateX(-50%) !important;
  55. background: rgba(0, 0, 0, 0.8) !important;
  56. color: white !important;
  57. padding: 4px 8px !important;
  58. border-radius: 4px !important;
  59. font-size: 12px !important;
  60. white-space: nowrap !important;
  61. z-index: 1000 !important;
  62. }
  63. .ds-toast {
  64. position: fixed !important;
  65. top: 20px !important;
  66. left: 50% !important;
  67. transform: translateX(-50%) !important;
  68. color: white !important;
  69. padding: 8px 16px !important;
  70. border-radius: 4px !important;
  71. font-size: 14px !important;
  72. z-index: 2147483647 !important;
  73. animation: toast-in-out 2s ease !important;
  74. }
  75. .ds-toast.error {
  76. background: rgba(255, 0, 0, 0.8) !important;
  77. }
  78. .ds-toast.success {
  79. background: rgba(0, 100, 255, 0.8) !important;
  80. }
  81. @keyframes toast-in-out {
  82. 0% { opacity: 0; transform: translate(-50%, -20px); }
  83. 20% { opacity: 1; transform: translate(-50%, 0); }
  84. 80% { opacity: 1; transform: translate(-50%, 0); }
  85. 100% { opacity: 0; transform: translate(-50%, 20px); }
  86. }
  87. `);
  88.  
  89. const SELECTORS = {
  90. MESSAGE: 'dad65929', // 消息内容区域
  91. USER_PROMPT: 'fa81', // 用户提问
  92. AI_ANSWER: 'f9bf7997', // AI回答区域
  93. AI_THINKING: 'e1675d8b', // 思考区域
  94. AI_RESPONSE: 'ds-markdown', // 回答内容区域
  95. TITLE: 'd8ed659a' // 标题
  96. };
  97.  
  98. function createUI() {
  99. if (document.getElementById(BUTTON_ID)) return;
  100.  
  101. // 检查当前是否为首页
  102. if (isHomePage()) {
  103. // 如果是首页,移除已存在的按钮
  104. const existingContainer = document.getElementById(`${BUTTON_ID}-container`);
  105. if (existingContainer) {
  106. existingContainer.remove();
  107. }
  108. return;
  109. }
  110.  
  111. const container = document.createElement('div');
  112. container.id = `${BUTTON_ID}-container`;
  113.  
  114. const copyBtn = document.createElement('button');
  115. copyBtn.id = `${BUTTON_ID}-copy`;
  116. copyBtn.textContent = '📋';
  117. copyBtn.title = '复制到剪贴板';
  118. copyBtn.onclick = () => handleExport('clipboard');
  119.  
  120. const exportBtn = document.createElement('button');
  121. exportBtn.id = BUTTON_ID;
  122. exportBtn.textContent = '💾';
  123. exportBtn.title = '导出对话';
  124. exportBtn.onclick = () => handleExport('file');
  125.  
  126. container.append(copyBtn, exportBtn);
  127. document.body.append(container);
  128. }
  129.  
  130. // 添加判断是否为首页的函数
  131. function isHomePage() {
  132. // 检查URL是否为首页
  133. if (
  134. window.location.pathname === '/' ||
  135. window.location.href === 'https://chat.deepseek.com/'
  136. ) {
  137. return true;
  138. }
  139.  
  140. // 检查是否存在对话内容元素
  141. const hasConversation = !!document.querySelector(`.${SELECTORS.MESSAGE}`);
  142. return !hasConversation;
  143. }
  144.  
  145. async function handleExport(mode) {
  146. if (isProcessing) return;
  147. isProcessing = true;
  148.  
  149. try {
  150. const conversations = await extractConversations();
  151. if (!conversations.length) {
  152. showToast('未检测到有效对话内容', true);
  153. return;
  154. }
  155.  
  156. const content = formatMarkdown(conversations);
  157.  
  158. if (mode === 'file') {
  159. downloadMarkdown(content);
  160. } else {
  161. GM_setClipboard(content, 'text');
  162. showToast('对话内容已复制到剪贴板');
  163. }
  164. } catch (error) {
  165. console.error('[导出错误]', error);
  166. showToast(`操作失败: ${error.message}`, true);
  167. } finally {
  168. isProcessing = false;
  169. }
  170. }
  171.  
  172. function extractConversations() {
  173. return new Promise((resolve) => {
  174. requestAnimationFrame(() => {
  175. const conversations = [];
  176. const blocks = document.querySelector(`.${SELECTORS.MESSAGE}`)?.childNodes;
  177.  
  178. blocks.forEach((block) => {
  179. try {
  180. if (block.classList.contains(SELECTORS.USER_PROMPT)) {
  181. conversations.push({
  182. content: cleanContent(block, 'prompt'),
  183. type: 'user'
  184. });
  185. } else if (block.classList.contains(SELECTORS.AI_ANSWER)) {
  186. const thinkingNode = block.querySelector(`.${SELECTORS.AI_THINKING}`);
  187. const responseNode = block.querySelector(`.${SELECTORS.AI_RESPONSE}`);
  188. conversations.push({
  189. content: {
  190. thinking: thinkingNode
  191. ? cleanContent(thinkingNode, 'thinking')
  192. : '',
  193. response: responseNode
  194. ? cleanContent(responseNode, 'response')
  195. : ''
  196. },
  197. type: 'ai'
  198. });
  199. }
  200. } catch (e) {
  201. console.warn('[对话解析错误]', e);
  202. }
  203. });
  204.  
  205. resolve(conversations);
  206. });
  207. });
  208. }
  209.  
  210. function cleanContent(node, type) {
  211. const clone = node.cloneNode(true);
  212. clone
  213. .querySelectorAll('button, .ds-flex, .ds-icon, .ds-icon-button, .ds-button,svg')
  214. .forEach((el) => el.remove());
  215.  
  216. switch (type) {
  217. case 'prompt':
  218. var content = clone.textContent.replace(/\n{2,}/g, '\n').trim();
  219.  
  220. // 转义 HTML 代码
  221. return content.replace(/[<>&]/g, function (match) {
  222. const escapeMap = {
  223. '<': '&lt;',
  224. '>': '&gt;',
  225. '&': '&amp;'
  226. };
  227. return escapeMap[match];
  228. });
  229.  
  230. case 'thinking':
  231. return clone.innerHTML
  232. .replace(/<\/p>/gi, '\n')
  233. .replace(/<br\s*\/?>/gi, '\n')
  234. .replace(/<\/?[^>]+(>|$)/g, '')
  235. .replace(/\n+/g, '\n')
  236. .trim();
  237. case 'response':
  238. return clone.innerHTML;
  239. default:
  240. return clone.textContent.trim();
  241. }
  242. }
  243.  
  244. function formatMarkdown(conversations) {
  245. // 获取页面标题
  246. const titleElement = document.querySelector(`.${SELECTORS.TITLE}`);
  247. const title = titleElement ? titleElement.textContent.trim() : 'DeepSeek对话';
  248.  
  249. let md = `# ${title}\n\n`;
  250.  
  251. conversations.forEach((conv, idx) => {
  252. if (conv.type === 'user') {
  253. if (idx > 0) md += '\n---\n';
  254. // md += `## 第 *${idx + 1}#* 轮对话\n`;
  255.  
  256. let ask = conv.content.split('\n').join('\n> ');
  257. md += `\n> [!info] 提问\n> ${ask}\n\n`;
  258. }
  259.  
  260. if (conv.type === 'ai' && conv.content) {
  261. if (conv.content.thinking) {
  262. let thinking = conv.content.thinking.split('\n').join('\n> ');
  263.  
  264. md += `\n> [!success] 思考\n${thinking}\n`;
  265. }
  266.  
  267. if (conv.content.response) {
  268. md += `\n${enhancedHtmlToMarkdown(conv.content.response)}\n`;
  269. }
  270. }
  271. });
  272.  
  273. return md;
  274. }
  275.  
  276. function enhancedHtmlToMarkdown(html) {
  277. const tempDiv = document.createElement('div');
  278. tempDiv.innerHTML = html;
  279.  
  280. // 预处理代码块
  281. tempDiv.querySelectorAll('.md-code-block').forEach((codeBlock) => {
  282. const lang =
  283. codeBlock.querySelector('.md-code-block-infostring')?.textContent?.trim() || '';
  284. const codeContent = codeBlock.querySelector('pre')?.textContent || '';
  285. codeBlock.replaceWith(`[_code_:]${lang}\n${codeContent}[:_code_]`);
  286. });
  287.  
  288. // 预处理数学公式
  289. tempDiv.querySelectorAll('.math-inline').forEach((math) => {
  290. math.replaceWith(`$${math.textContent}$`);
  291. });
  292. tempDiv.querySelectorAll('.math-display').forEach((math) => {
  293. math.replaceWith(`\n$$\n${math.textContent}\n$$\n`);
  294. });
  295.  
  296. return Array.from(tempDiv.childNodes)
  297. .map((node) => convertNodeToMarkdown(node))
  298. .join('')
  299. .replace(/\[_code_\:\]/g, '\n```')
  300. .replace(/\[\:_code_\]/g, '\n```\n')
  301. .trim();
  302. }
  303.  
  304. function convertNodeToMarkdown(node, level = 0, processedNodes = new WeakSet()) {
  305. if (!node || processedNodes.has(node)) return '';
  306. processedNodes.add(node);
  307.  
  308. const handlers = {
  309. P: (n) => {
  310. const text = processInlineElements(n);
  311. return text ? `${text}\n` : '';
  312. },
  313. STRONG: (n) => `**${n.textContent}**`,
  314. EM: (n) => `*${n.textContent}*`,
  315. HR: () => '\n---\n',
  316. BR: () => '\n',
  317. A: (n) => processLinkElement(n),
  318. IMG: (n) => processImageElement(n),
  319. BLOCKQUOTE: (n) => {
  320. const content = Array.from(n.childNodes)
  321. .map((child) => convertNodeToMarkdown(child, level, processedNodes))
  322. .join('')
  323. .split('\n')
  324. .filter((line) => line.trim())
  325. .map((line) => `> ${line}`)
  326. .join('\n');
  327. return `\n${content}\n`;
  328. },
  329. UL: (n) => processListItems(n, level, '-'),
  330. OL: (n) => processListItems(n, level, null, n.getAttribute('start') || 1),
  331. PRE: (n) => `[_code_:]${n.textContent.trim()}[:_code_]`,
  332. CODE: (n) => `\`${n.textContent.trim()}\``,
  333. H1: (n) => `# ${processInlineElements(n)}\n`,
  334. H2: (n) => `## ${processInlineElements(n)}\n`,
  335. H3: (n) => `### ${processInlineElements(n)}\n`,
  336. H4: (n) => `#### ${processInlineElements(n)}\n`,
  337. H5: (n) => `##### ${processInlineElements(n)}\n`,
  338. H6: (n) => `###### ${processInlineElements(n)}\n`,
  339. TABLE: processTable,
  340. DIV: (n) =>
  341. Array.from(n.childNodes)
  342. .map((child) => convertNodeToMarkdown(child, level, processedNodes))
  343. .join(''),
  344. '#text': (n) => n.textContent.trim(),
  345. _default: (n) =>
  346. Array.from(n.childNodes)
  347. .map((child) => convertNodeToMarkdown(child, level, processedNodes))
  348. .join('')
  349. };
  350.  
  351. return handlers[node.nodeName]?.(node) || handlers._default(node);
  352. }
  353.  
  354. function processInlineElements(node) {
  355. return Array.from(node.childNodes)
  356. .map((child) => {
  357. if (child.nodeType === 3) return child.textContent.trim();
  358. if (child.nodeType === 1) {
  359. if (child.matches('strong')) return `**${child.textContent}**`;
  360. if (child.matches('em')) return `*${child.textContent}*`;
  361. if (child.matches('code')) return `\`${child.textContent}\``;
  362. if (child.matches('a')) return processLinkElement(child);
  363. if (child.matches('img')) return processImageElement(child);
  364. }
  365. return child.textContent;
  366. })
  367. .join('');
  368. }
  369.  
  370. function processImageElement(node) {
  371. const alt = node.getAttribute('alt') || '';
  372. const title = node.getAttribute('title') || '';
  373. const src = node.getAttribute('src') || '';
  374. return title ? `![${alt}](${src} "${title}")` : `![${alt}](${src})`;
  375. }
  376.  
  377. function processLinkElement(node) {
  378. const href = node.getAttribute('href') || '';
  379. const title = node.getAttribute('title') || '';
  380. const content = Array.from(node.childNodes)
  381. .map((child) => convertNodeToMarkdown(child))
  382. .join('');
  383. return title ? `[${content}](${href} "${title}")` : `[${content}](${href})`;
  384. }
  385.  
  386. function processListItems(node, level, marker, start = null) {
  387. let result = '';
  388. const indent = ' '.repeat(level);
  389. Array.from(node.children).forEach((li, idx) => {
  390. const prefix = marker ? `${marker} ` : `${parseInt(start) + idx}. `;
  391. // 先处理li节点的直接文本内容
  392. const mainContent = Array.from(li.childNodes)
  393. .filter((child) => child.nodeType === 1 && !child.matches('ul, ol'))
  394. .map((child) => convertNodeToMarkdown(child, level))
  395. .join('')
  396. .trim();
  397.  
  398. if (mainContent) {
  399. result += `${indent}${prefix}${mainContent}\n`;
  400. }
  401.  
  402. // 单独处理嵌套列表
  403. const nestedLists = li.querySelectorAll(':scope > ul, :scope > ol');
  404. nestedLists.forEach((list) => {
  405. result += convertNodeToMarkdown(list, level + 1);
  406. });
  407. });
  408. return result;
  409. }
  410.  
  411. function processTable(node) {
  412. const rows = Array.from(node.querySelectorAll('tr'));
  413. if (!rows.length) return '';
  414.  
  415. const headers = Array.from(rows[0].querySelectorAll('th,td')).map((cell) =>
  416. cell.textContent.trim()
  417. );
  418.  
  419. let markdown = `\n| ${headers.join(' | ')} |\n| ${headers
  420. .map(() => '---')
  421. .join(' | ')} |\n`;
  422.  
  423. for (let i = 1; i < rows.length; i++) {
  424. const cells = Array.from(rows[i].querySelectorAll('td')).map((cell) =>
  425. processInlineElements(cell)
  426. );
  427. markdown += `| ${cells.join(' | ')} |\n`;
  428. }
  429.  
  430. return markdown + '\n';
  431. }
  432.  
  433. function downloadMarkdown(content) {
  434. const titleElement = document.querySelector(`.${SELECTORS.TITLE}`);
  435. const title = titleElement ? titleElement.textContent.trim() : 'DeepSeek对话';
  436. const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
  437. const link = document.createElement('a');
  438. link.href = URL.createObjectURL(blob);
  439. link.download = `${title}.md`;
  440. link.style.display = 'none';
  441. document.body.appendChild(link);
  442. link.click();
  443. setTimeout(() => {
  444. document.body.removeChild(link);
  445. URL.revokeObjectURL(link.href);
  446. }, 1000);
  447. }
  448.  
  449. function showToast(message, isError = false) {
  450. const toast = document.createElement('div');
  451. toast.className = `ds-toast ${isError ? 'error' : 'success'}`;
  452. toast.textContent = message;
  453. document.body.appendChild(toast);
  454.  
  455. toast.addEventListener('animationend', () => {
  456. document.body.removeChild(toast);
  457. });
  458. }
  459.  
  460. // 添加 URL 变化监听
  461. function setupUrlChangeListener() {
  462. let lastUrl = window.location.href;
  463.  
  464. // 监听 URL 变化
  465. setInterval(() => {
  466. if (lastUrl !== window.location.href) {
  467. lastUrl = window.location.href;
  468. const existingContainer = document.getElementById(`${BUTTON_ID}-container`);
  469. if (existingContainer) {
  470. existingContainer.remove();
  471. }
  472. createUI();
  473. }
  474. }, 1000);
  475.  
  476. // 监听 history 变化
  477. const pushState = history.pushState;
  478. history.pushState = function () {
  479. pushState.apply(history, arguments);
  480. const existingContainer = document.getElementById(`${BUTTON_ID}-container`);
  481. if (existingContainer) {
  482. existingContainer.remove();
  483. }
  484. createUI();
  485. };
  486. }
  487.  
  488. const observer = new MutationObserver(() => createUI());
  489. observer.observe(document, { childList: true, subtree: true });
  490. // window.addEventListener('load', createUI);
  491. // setInterval(createUI, 3000);
  492. window.addEventListener('load', () => {
  493. createUI();
  494. setupUrlChangeListener();
  495. });
  496. })();