☰
DeepSeek网页版一键导出Markdown:油猴脚本接管复制按钮实现无损存档
2026/10/5 7:08:19 网站建设 项目流程

这段时间一直在折腾 DeepSeek 网页版的对话存档问题。写文章、整理代码、做方案对比的时候,经常需要把某个回答原样保存下来,结果发现官方那个"复制"按钮有个很尴尬的地方:它复制的是经过浏览器富文本处理后的内容,粘到 Markdown 编辑器里不是丢了代码块语言标记,就是列表层级变得乱七八糟,长一点的回答还会出现截断。后来我干脆写了个油猴脚本,把 DeepSeek 网页版自己的复制按键直接接管过来,点一下就能把当前这段问答以完整 Markdown 文件的形式下载到本地。

这个项目叫 DeepSeek-Raw-Export,定位非常简单——不碰你输入的 prompt,不上传任何数据,只做一件事:让 DeepSeek 网页版右上角的复制按钮变成"导出 Markdown 文件"按钮。如果你跟我一样有把对话归档进知识库、把回答投喂给其他模型继续加工的需求,这个脚本的思路应该能帮到你。下面我把实现过程、踩过的坑以及整个格式化管线的设计逻辑拆开讲一遍。

1. 为什么要给 DeepSeek 网页版动这个"小手术"

1.1 官方复制按钮的四个痛点

很多人可能觉得"复制一下再粘贴"就够了,但真正高频使用 DeepSeek 的人应该能感受到这四个问题:

  • 格式失真:浏览器复制走的路线是clipboardAPI 的富文本 HTML 片段,粘贴到 Obsidian、Typora 这类 Markdown 编辑器时,编辑器要先做 HTML 到 Markdown 的转换。转换过程中代码块语言标识大概率丢失,嵌套列表偶尔会变成平铺文本,行内代码的顿号也可能串位。
  • 长回复截断:网页版复制长文本时,浏览器偶尔会吞内容。我实测过 800 行左右的代码回复,粘贴后末尾会少掉十几个字符,这种问题在代码场景下非常致命。
  • 没有文件落地:复制进剪贴板的数据是"临时态",一旦你复制了别的东西就没了。而导出文件是"持久态",文件名、日期、来源模型这些元信息都可以直接固化在文件里。
  • 多轮对话无法整体归档:DeepSeek 网页版复制按钮只针对当前回答,一次复制没办法把某一段 prompt 加多个回答打包成完整上下文。

1.2 从"复制到剪贴板"到"写入本地文件"的本质变化

复制按钮做的本质操作是把内容放进剪贴板,而 DeepSeek-Raw-Export 做的是Blob加浏览器下载。前者是内存态,后者是磁盘态。这里有个容易忽略的点:浏览器出于安全策略,navigator.clipboard.writeText只能在用户主动点击的上下文里触发,但文件下载(通过创建<a>标签触发)也有同样的用户手势要求。所以脚本不能在页面加载时自动导出,必须绑定在点击事件上,这也是我们选择直接接管"复制键"而不是搞一个后台自动任务的原因。

适合用这个脚本的人也很清晰:经常拿 DeepSeek 做代码生成然后去编辑器里二次修改的开发者、需要用 AI 对话内容搭建个人知识库的整理型用户、以及有"把多轮问答保存下来作为上下文投喂给其他工具"需求的进阶玩家。脚本本身不解决这些问题,它只解决"如何完整无损地把内容拿下来"的前置步骤。

2. 脚本的边界与定位:只接管复制,不该管的一律不碰

2.1 功能范围:接管按钮 + 格式化 + 本地下载

DeepSeek-Raw-Export 接管的动作范围严格限定在三件事:

  1. 监听 DeepSeek 网页版回答区块里的复制按钮点击事件;
  2. 根据当前按钮所在 DOM 节点定位到对应的提问与回答区块;
  3. 将区块内容递归转换为 Markdown 文本,生成 Blob 并触发浏览器下载。

这三件事全部在浏览器本地执行。脚本不会修改 DeepSeek 的页面结构,不会往任何第三方服务器发送请求,也不会在本地存储你的对话内容。每次导出都是即时读取当前 DOM 状态,读完就忘。

2.2 为什么坚持"本地处理"而不是调用官方 API

这里有一个设计取舍值得展开说一下。理论上完全可以走 DeepSeek 开放平台的 API 去拉取对话历史,然后用服务端脚本格式化。但那样做要处理 API Key 管理、鉴权、额度计费、历史会话存取权限等一系列问题,而且敏感对话内容会经过第三方服务。油猴脚本做的是"所见即所得"的导出——页面上呈现什么,就导出什么,不依赖任何接口权限。这个思路对于那些只是偶尔需要存档、不想为 API 调用付费的用户来说是最轻量的方案。

另外,脚本只需要用户授予油猴的脚本运行权限,不需要登录态的额外授权。你平时怎么用 DeepSeek 网页版,脚本就怎么读数据,没有任何中间层。

提示:如果你把脚本分享给朋友用,一定要提醒他们先自己读一遍源码。油猴脚本理论上可以读取页面的所有 DOM 内容,虽然本脚本只做导出,但任何脚本都有能力做更多事情,开源的意义就在于你可以审计它。

2.3 适用页面范围与浏览器限制

脚本的@match规则要限定在 DeepSeek 网页版的域名范围,避免在其他网站误触发。

// ==UserScript== // @name DeepSeek-Raw-Export // @namespace https://github.com/yourname/deepseek-raw-export // @version 0.1.0 // @description 接管DeepSeek网页版复制按钮,一键导出一段问答为Markdown文件 // @match https://chat.deepseek.com/* // @grant GM_registerMenuCommand // @run-at document-idle // ==/UserScript==

@run-at document-idle的意思是等页面主要脚本执行完、DOM 基本稳定后再注入,减少跟 React 应用初始化打架的概率。浏览器方面我实测过 Chrome 和 Edge 的当前版本没有问题,Firefox 上需要留意 SVG 按钮结构的差异,后面踩坑部分会专门讲。

3. 第一步:在 DeepSeek 的 SPA 页面里精准抓住"复制键"

3.1 为什么不能直接给按钮绑事件

DeepSeek 网页版是典型的 React 单页应用,回答是流式生成的,复制按钮在回答完全生成之后才渲染到界面上。如果你在document-idle阶段直接querySelector找到按钮再addEventListener,大概率会失败——因为按钮那时候还不存在。

更麻烦的是单页应用的路由切换:你在侧边栏新建一个对话,页面 URL 变了,但整个应用并没有刷新,旧的 DOM 被替换成新的。任何"一次性绑定"的逻辑都会失效。

所以正确的姿势是事件委托:在document节点上监听点击事件,每次点击时再判断目标节点是不是复制按钮。

document.addEventListener('click', function (e) { // 在冒泡阶段判断点击目标 const btn = e.target.closest('[data-testid="copy-button"], button[class*="copy"]'); if (!btn) return; e.preventDefault(); e.stopPropagation(); exportCurrentReply(btn); });

用closest的好处是:即使你点到了按钮里的 SVG 图标(而不是按钮外壳),也能正确向上找到按钮容器。stopPropagation是为了阻止 DeepSeek 自己的复制逻辑继续执行,避免导出文件的同时弹出一个"已复制"的 toast。

3.2 深挖按钮的结构特征:SVG 图标和 ARIA 标签

实际开发中,定位"复制按钮"需要先做一轮 DOM 勘察。打开浏览器控制台,定位到复制按钮,你会发现它没有文字内容,只有一个 SVG 图标。这种情况下有三种选择器可以用:

  • aria-label属性,如果 DeepSeek 给按钮加了无障碍标签;
  • >const COPY_SELECTORS = [ 'button[aria-label="复制"]', 'button[data-testid="copy-button"]', 'div[role="button"][aria-label="复制"]' ];

    如果上面这些都没命中,脚本会退回检查按钮的父容器结构,因为复制按钮和编辑按钮通常在同一组操作栏里,而编辑按钮一般有更稳定的特征。这算是一种降级策略,保证 DeepSeek 改版后脚本不至于立刻白屏。

    3.3 动态加载的回答区块:如何保证导出目标正确

    点击复制按钮后,脚本需要向上找到"这一组问答"的容器节点,而不是从整个页面里抓取所有内容。

    function resolveConversationContainer(btn) { // 从按钮向上找 3~5 层,通常能到达"单轮问答"的最外层容器 let node = btn; for (let i = 0; i < 5; i++) { node = node.parentElement; if (!node) return null; // 特征判断:容器是否同时包含用户消息和助手消息 if (node.querySelector('[class*="user-message"]') && node.querySelector('[class*="assistant-message"]')) { return node; } } return null; }

    这个循环里有个关键点:DeepSeek 的界面历史里,上一轮的助手消息和当前轮的用户消息会在 DOM 层级上靠得很近,你必须在向上查找时确保容器同时包含"用户消息+助手消息",否则很容易把上一轮的对话也卷进来。我一开始没加这个判断,导出出来的文本总是多出前一轮的用户 prompt,排查了半天才发现是查找深度不够精准。

    4. 从 DOM 到干净 Markdown:回答内容的格式化管线

    4.1 回答区的 DOM 结构长什么样

    DeepSeek 的回答渲染走的是 Markdown 解析链路,最终呈现出来的 DOM 包含标题、段落、代码块、表格、列表、引用块、数学公式等元素。脚本要做的就是从这些 DOM 节点反推回 Markdown 源码。这个过程叫"反向格式化",核心难点在于:HTML 标签表达的语义和 Markdown 语法并不是一一对应的。

    举例来说,DOM 里的<strong>可能是 Markdown 的**bold**语法渲染来的,但也可能是用户直接输入<strong>标签后浏览器自动包裹的结果。反向格式化时只能做规范化处理——统一输出**bold**,而不会去区分来源。

    4.2 核心转换函数:递归遍历 DOM 树

    我的核心实现是一个递归遍历函数nodeToMarkdown,对不同的标签走不同的转换规则:

    function nodeToMarkdown(node) { const tag = node.tagName ? node.tagName.toLowerCase() : ''; switch (tag) { case 'h1': return `# ${node.innerText.trim()}\n\n`; case 'h2': return `## ${node.innerText.trim()}\n\n`; case 'h3': return `### ${node.innerText.trim()}\n\n`; case 'pre': { const code = node.querySelector('code'); const lang = detectLanguage(code); return `\`\`\`${lang}\n${code.innerText}\n\`\`\`\n\n`; } case 'table': return tableToMarkdown(node); case 'ul': return listToMarkdown(node, '- '); case 'ol': return listToMarkdown(node, '1. '); case 'blockquote': return node.innerText.split('\n').map(line => `> ${line}`).join('\n') + '\n\n'; default: return node.innerText.replace(/\s+$/, '') + '\n\n'; } }

    这个函数只是骨架,真实场景里还需要处理嵌套结构。举个例子,<li>里可能同时有段落、行内代码、多层嵌套列表,而innerText会把"行内代码"和"普通文本"混在一起,丢失反引号标记。所以做列表转换时要对每个子节点分别递归,而不是直接拿innerText一把梭。

    4.3 代码块和数学公式这两个最容易翻车的地方

    代码块处理有两个坑。第一个是语言检测:DeepSeek 渲染代码块时如果用户指定了语言,<code>标签的class里通常会带language-python之类的标记;但如果没有指定语言,class可能是空的。这时候detectLanguage函数需要做简单的内容嗅探——比如def开头的猜 Python,function开头的猜 JavaScript,虽然不完美但对大多数场景够用了。

    第二个坑是代码内容里的反引号。Markdown 代码块用三个反引号包裹,如果代码里恰好有三连反引号,导出的文件就会提前闭合,导致后面所有内容都被当成代码。我的处理方式是:如果检测到代码内容里包含连续三个反引号,就把代码块的围栏字符从三连反引号加长到四个:

    function codeFence(content) { return content.includes('```') ? '````' : '```'; }

    数学公式方面,DeepSeek 的渲染用的是 KaTeX,DOM 结构极其复杂。直接读innerText会把公式拆成一堆乱码,比如x^2变成x 2。最稳妥的方案是不从渲染后的 DOM 反解析公式,而是在回答流式生成的过程中缓存一份未渲染的文本。这意味着脚本需要在回答生成阶段挂在 MutationObserver 上,观察流式文本节点的增量更新,把纯文本存到内存里。导出时优先使用内存中的纯文本,DOM 只是备份。

    这种方式实现成本稍高,但效果最接近"原汁原味"。如果你只是临时用一下,也可以放弃数学公式还原,把公式区域替换成[公式内容已省略]占位符,毕竟大多数导出场景更关注代码和文字内容。

    4.4 表格转换和特殊字符避让

    表格在转换时容易出问题,因为单元格内部可能出现竖线|和换行符,而 Markdown 表格语法对这两种字符敏感。我写的表格转换逻辑分三步:

    1. 提取表头行所有单元格的文本;
    2. 提取每个数据行的所有单元格文本,同时把单元格内的竖线替换成\|;
    3. 生成对齐行,默认左对齐,统一用| --- |;
    function tableToMarkdown(table) { const rows = Array.from(table.querySelectorAll('tr')); const header = rows.shift(); const headers = Array.from(header.querySelectorAll('th, td')).map(cell => sanitizeCell(cell.innerText) ); const lines = [ `| ${headers.join(' | ')} |`, `| ${headers.map(() => '---').join(' | ')} |` ]; rows.forEach(row => { const cells = Array.from(row.querySelectorAll('td')).map(cell => sanitizeCell(cell.innerText) ); lines.push(`| ${cells.join(' | ')} |`); }); return lines.join('\n') + '\n\n'; }

    sanitizeCell里做的无非是替换竖线和去掉首尾空格,但这层处理不能省——我实际导出过的回答里,有 30% 左右的表格都因为竖线没转义,粘贴到 Typora 后直接裂开。

    4.5 长文本导出的性能问题

    如果你导出一个包含多段代码、大段引用、几十个列表项的长回答,用字符串+=拼接会创建大量中间字符串,浏览器会明显卡顿。正确做法是用数组收集片段,最后一次性join:

    export async function exportCurrentReply(btn) { const container = resolveConversationContainer(btn); const parts = []; parts.push(buildHeader()); // 遍历对话容器下属的所有内容块 container.querySelectorAll('[class*="conversation-item"], [class*="message-item"]').forEach((item) => { parts.push(nodeToMarkdown(item)); }); const fullText = parts.join('\n'); // 生成文件 }

    实测下来,一个 5000 字的回答从点击到弹出下载,整个转换过程在 50ms 以内,基本无感。如果你用+=拼一个几万字的对话,Chrome 可能会在控制台提示"Uncaught RangeError: Invalid string length",这个我之前踩过。

    5. 触发方式设计:点按钮、快捷键、悬浮按钮、油猴菜单四路并行

    5.1 默认方案:直接点击原复制按钮

    这是最符合直觉的交互——用户看到复制按钮,按下去,拿到的是一个文件而不是剪贴板内容。缺点也很明显:有人可能只是想复制一小段文字进剪贴板,却被强制下载了文件。所以我在设计时保留了一个"复古模式":按住 Alt 再点击复制按钮,走原生复制逻辑。

    document.addEventListener('click', function (e) { const btn = e.target.closest(COPY_SELECTORS.join(',')); if (!btn) return; if (e.altKey) return; // 按住Alt则放行原生复制 e.preventDefault(); exportCurrentReply(btn); });

    5.2 全局快捷键:兼顾鼠标党与键盘流

    对于高强度使用者来说,每次移动鼠标到按钮上再点一下也烦。所以我加了一个全局快捷键,默认是Ctrl+Shift+D,按下时自动定位到"当前正在显示的最后一条回答"并导出。

    这里有个细节:快捷键触发时要避免在输入框里生效。如果你正在 prompt 输入框里打字,按Ctrl+Shift+D应该是文本编辑操作而不是导出操作。

    document.addEventListener('keydown', function (e) { if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'd') { const active = document.activeElement; if (active && ['TEXTAREA', 'INPUT'].includes(active.tagName)) return; exportCurrentReply(getLastReplyButton()); } });

    5.3 页面悬浮按钮与油猴菜单

    考虑到有些用户愿意完全不用原按钮,我给脚本加了一个可拖拽的悬浮快捷导出按钮,固定在页面右下角,点击后自动寻找当前屏幕范围内可见的最后一条回答并导出全部内容。

    油猴自带的GM_registerMenuCommand也要利用上,它能往浏览器扩展菜单里加一项"导出当前对话"。这个入口在页面布局变化、悬浮按钮被遮挡时特别有用。

    GM_registerMenuCommand('导出当前对话为Markdown', () => { exportCurrentReply(getLastReplyButton()); });

    四种入口各有适用人群:原按钮接管适合新用户,快捷键适合效率党,悬浮按钮适合触控板用户,菜单入口适合不想为脚本做任何记忆成本的人。开源项目里这种"功能冗余"是有意的,不是代码洁癖的反面教材。

    6. 踩坑记录:这些细节差点让导出功能变成摆设

    6.1 按钮结构因浏览器而异,选择器断链

    Chrome 下 DeepSeek 的复制按钮外层是<button>,但 Edge 环境里同样的逻辑却经常命中失败。排查后发现是 Edge 的翻译功能给按钮插了一层自定义节点,导致closest匹配失效。这个问题的通用解法是用更宽泛的选择器加二次特征校验:

    function isCopyButton(el) { if (!el) return false; const text = (el.getAttribute('aria-label') || '').toLowerCase(); if (text.includes('copy') || text.includes('复制')) return true; // 退路:检查按钮内部是否有复制图标特征 return !!el.querySelector('svg.feather-copy, svg[data-icon="copy"]'); }

    6.2 流式生成过程中的"半截回答"导出

    DeepSeek 回答是流式输出的,如果你在某一段还在打字时(比如代码块尚未结束)就点了复制按钮,导出的文件可能不完整。我最初的实现是点击时直接读取 DOM,导致导出过几次半截代码。改进方案是:检测回答尾部是否还有"生成中的光标"元素(通常是一个闪烁的竖线),如果有就中断导出并提示"回答仍在生成中,请稍候"。

    6.3 图片和附件怎么处理

    DeepSeek 支持图片输入,回答里也可能引用对话中的图片。如果回答包含图片链接(上传到 DeepSeek 服务器的外链),我默认保留原始 URL;如果是本地图片而被转成了 base64,那么导出文件会急剧膨胀,并且 base64 串还会污染整个 Markdown 结构。

    我的策略很简单:图片全部替换为[图片]占位符,并在文件末尾附上所有图片 URL 清单。这样既保留了信息线索,又不破坏文本内容。

    6.4 文件命名与 Blob 下载的兼容性

    文件名如果直接用回答的第一句话,经常包含冒号、斜杠、换行符,在 Windows 上无法创建文件。我在生成文件名时做了三层过滤:

    function buildFilename(promptText) { const base = promptText.replace(/[\\/:*?"<>|]/g, '').slice(0, 30); const ts = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19); return `${base || 'DeepSeek'}-${ts}.md`; }

    Blob 生成部分则要注意,URL.createObjectURL在触发a.click()后要主动revokeObjectURL,否则长时间使用会消耗大量内存。

    const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);

    6.5 导出内容里用户问题与模型回答的区分标记

    导出多轮对话时,如果不在内容里明确标注哪段是用户说的、哪段是模型回答,后续把文件喂给其他模型做上下文时会产生语义混淆。我的导出模板会在每条消息前加一行标记:

    > [用户提问时间:2025-08-23 15:30] 你的 prompt 内容…… > [DeepSeek 回答时间:2025-08-23 15:31] 模型回答内容……

    在 Markdown 里用>引用块包裹时间戳,既不影响正文阅读,又给下游解析脚本留出了清晰的锚点。如果你直接把这个文件投给 Claude、GPT 做继续改写,它们能明确区分问答角色,效果比裸文本好很多。

    7. 导出之后的下一步:从存档到二次加工

    7.1 把导出的 Markdown 变成个人知识库的原材料

    导出下载不是终点,真正的价值在文件落地之后。我的习惯是把这些文件按照YYYY-MM-DD-标题.md命名,丢进 Obsidian 或者 Logseq 的仓库目录,配合全文搜索,几个月下来就形成了一个可检索的个人 AI 对话库。需要注意一点:导出的文件头部我拿 prompt 前 30 个字做章节标题,但知识库索引通常需要更结构化的 frontmatter。所以脚本在导出时也支持在文件顶部插入一段 YAML frontmatter:

    --- date: 2025-08-23 source: DeepSeek Web prompt: 用Python写一个异步爬虫 tags: [python, 爬虫] --- # 用Python写一个异步爬虫

    这段 frontmatter 对用 Obsidian 的人来说是刚需,对不想被元数据干扰的人也可以通过配置关闭。

    7.2 把导出文件作为下一个模型的投喂材料

    我经常遇到的场景是:DeepSeek 回答到一半被"对话上限"挡住了,但回答内容有价值,想拿到另一个模型(或另一个新对话)里继续追问。这时候导出的 Markdown 文件就派上用场了,把文件内容整个复制到新对话的 prompt 里,等于把之前的思考上下文完整迁移过去。脚本里对用户提问和模型回答的标记在这个场景下特别重要,新模型能准确区分"这是我说的"和"这是 AI 说的"。

    7.3 批量导出与自动化延伸

    如果你积累了很多对话想要批量导出,点击式操作还是太累。我的下一步计划是在脚本里加入"按侧边栏会话列表批量导出"的功能——遍历左侧所有会话条目,逐个打开并采集内容,合并成一个大的 Markdown 或 JSONL 文件。这个功能的难点在于 DeepSeek 的会话列表是虚拟滚动的,需要边滚动边采集,而且大量请求可能触发页面性能问题。目前我在小范围测试里能做到一次导出一百多个会话,每个会话存成单独文件,没有触发明显的页面崩溃。

    7.4 尊重使用边界:脚本不应成为绕过限制的跳板

    做这个项目期间我也想过一些进阶功能,比如绕过网页版的某些交互限制、自动重试失败的请求、模拟更复杂的操作链路。但最终决定不碰这些。理由很简单:DeepSeek-Raw-Export 的核心价值是"让用户能带走自己创造的内容",而不是帮助用户突破服务边界。油猴脚本本身是在用户浏览器里运行,用户对自己看到的页面内容有天然的处置权,但去干扰服务运行逻辑就属于越界行为,既不稳定也不安全。

    从实际体验来说,这个脚本目前已经成了我工作流里离不开的一环。DeepSeek 网页版官方复制的短板没法靠等待版本更新解决,而自己动手在当地加一把"格式化导出"的钥匙,反而是一次非常典型的"小工具解决大问题"实践。如果你也在为 AI 对话内容难以沉淀而烦恼,我建议你先从接手这个脚本的复制按钮开始,把每次高质量的对话完整地留在硬盘上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询