这段时间一直在折腾 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 接管的动作范围严格限定在三件事监听 DeepSeek 网页版回答区块里的复制按钮点击事件根据当前按钮所在 DOM 节点定位到对应的提问与回答区块将区块内容递归转换为 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 // /UserScriptrun-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-testidcopy-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-testidcopy-button], div[rolebutton][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开头的猜 Pythonfunction开头的猜 JavaScript虽然不完美但对大多数场景够用了。第二个坑是代码内容里的反引号。Markdown 代码块用三个反引号包裹如果代码里恰好有三连反引号导出的文件就会提前闭合导致后面所有内容都被当成代码。我的处理方式是如果检测到代码内容里包含连续三个反引号就把代码块的围栏字符从三连反引号加长到四个function codeFence(content) { return content.includes() ? : ; }数学公式方面DeepSeek 的渲染用的是 KaTeXDOM 结构极其复杂。直接读innerText会把公式拆成一堆乱码比如x^2变成x 2。最稳妥的方案是不从渲染后的 DOM 反解析公式而是在回答流式生成的过程中缓存一份未渲染的文本。这意味着脚本需要在回答生成阶段挂在 MutationObserver 上观察流式文本节点的增量更新把纯文本存到内存里。导出时优先使用内存中的纯文本DOM 只是备份。这种方式实现成本稍高但效果最接近原汁原味。如果你只是临时用一下也可以放弃数学公式还原把公式区域替换成[公式内容已省略]占位符毕竟大多数导出场景更关注代码和文字内容。4.4 表格转换和特殊字符避让表格在转换时容易出问题因为单元格内部可能出现竖线|和换行符而 Markdown 表格语法对这两种字符敏感。我写的表格转换逻辑分三步提取表头行所有单元格的文本提取每个数据行的所有单元格文本同时把单元格内的竖线替换成\|生成对齐行默认左对齐统一用| --- |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 长文本导出的性能问题如果你导出一个包含多段代码、大段引用、几十个列表项的长回答用字符串拼接会创建大量中间字符串浏览器会明显卡顿。正确做法是用数组收集片段最后一次性joinexport 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 全局快捷键兼顾鼠标党与键盘流对于高强度使用者来说每次移动鼠标到按钮上再点一下也烦。所以我加了一个全局快捷键默认是CtrlShiftD按下时自动定位到当前正在显示的最后一条回答并导出。这里有个细节快捷键触发时要避免在输入框里生效。如果你正在 prompt 输入框里打字按CtrlShiftD应该是文本编辑操作而不是导出操作。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-iconcopy]); }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 对话内容难以沉淀而烦恼我建议你先从接手这个脚本的复制按钮开始把每次高质量的对话完整地留在硬盘上。