上个月我想翻出一段三个月前讨论过的接口重试方案印象里当时聊得很透连退避曲线都算过。结果在那个工具里翻了二十多分钟滚动条拉到底又拉回来最后靠模糊的标题关键词才勉强摸到。那天晚上我就把手里攒的几百条对话全部导出成了 Markdown再批量转成 Word 和 PDF按主题分目录存好。现在再要找什么本地全文搜索三秒钟出结果比在网页里翻靠谱一百倍。这篇就聊透一件事GPT 对话的导出。从单条对话怎么导出、批量怎么导、导出成 Word 或 PDF 怎么保住排版到官方数据包里那些 JSON 字段到底是什么意思、转换脚本怎么写、哪些坑会让你导出来的东西读不了。内容偏实操适合两类人看一类是需要把对话内容沉淀成文档、报告、知识库的从业者另一类是被对话记录找不回来折磨过、想一次性把归档问题解决掉的人。哪怕你完全不会写代码前面几节的手动方案也够用后面几节给了可复现的脚本跟着抄作业就行。1. 先分清你要的是留个纪念还是能用的资产动手之前先把需求掰开揉碎这一步很多人跳过结果导出方式选错白忙一场。我见过太多人一上来就问怎么批量导出实际上他只需要把三条对话发给同事直接复制粘贴完事压根犯不上折腾数据包。反过来有人拿浏览器打印存了三百个 PDF等到要做全文检索时才发现 PDF 里的文字顺序全乱了。1.1 三种典型场景对应的导出粒度我把实际遇到的需求归成三类每类对应的最优方案完全不同场景典型诉求推荐粒度推荐方式临时分享把一段结论或代码发给同事单条、单个片段直接复制粘贴资料留档某次方案讨论、某篇长文需要保存单条完整对话打印为 PDF / 存为单文件网页 / 转 Markdown知识沉淀几百条对话要能检索、能分类、能复用全量批量官方数据导出 脚本拆分 二次转换表格看着简单但选择错误的代价不小。比如你把资料留档当成临时分享来做复制粘贴的损失是上下文结构全没了——谁说的、回复的哪一段、代码属于哪个函数全糊成一坨。等过两个月你自己回头看大概率认不出这段内容在讲什么。而如果你把知识沉淀当成资料留档来做一条一条打印 PDF三百条对话就是三百次点击中途还得处理文件名、目录、重复对话人工成本高到你会直接放弃。所以先想清楚目的再选路径这一步省不了。1.2 为什么全选复制往往是最差的选择网页上 CtrlA 全选再复制看起来最省事实际上是最容易翻车的做法。原因有三个都是实测踩出来的第一个侧边栏和按钮文字会被一起选中。对话列表、模型选择器、点赞按钮、复制代码这些界面元素的文本会混进正文粘到 Word 里变成一堆无意义的碎片。你得一处处手动删比逐条复制还慢。第二个代码块的格式会崩。网页里的代码块通常有语法高亮用的是嵌套的 span 标签。复制到 Word 里运气好保留等宽字体和缩进运气不好所有换行消失、缩进变成连续空格一段 Python 直接没法看。第三个长对话会被截断。浏览器对剪贴板内容有大小限制对话特别长的时候尤其是那种连续几小时的技术讨论全选复制可能只拿到一部分你还不知道丢的是哪一段。提示如果只是想快速拿到某一段回答正确的做法是点那条消息下方的复制按钮它拿到的通常是干净的 Markdown 原文比全选复制强得多。代码块右上角还有一个单独的复制按钮只复制代码本身非常实用。所以我的建议是单条对话认真导出多条对话走批量中间的灰色地带比如一晚上聊了五六条手工处理别偷懒。2. 单条对话的四种导出路径与实测取舍单条对话是最常见的需求可选路径有四条保真度、成本、可检索性各不相同。我把它们放在一起对比你可以直接对号入座。2.1 打印为 PDF成本最低、保真度中等浏览器自带的打印功能在打印对话框里把目标改成另存为 PDF是普通人最容易上手的方式。它最大的好处是所见即所得——屏幕上什么样PDF 就是什么样代码块的高亮、表格的边框、列表的缩进都能保住。但有几个细节必须提前处理不然导出的 PDF 会很丑打印前先把手动折叠的长消息全部展开否则 PDF 里只有一行省略号。关掉侧边栏或者在打印预览的更多设置里调整缩放避免左边一大片空白。纸张方向选纵向、边距选最小否则代码块会频繁折行读起来很痛苦。如果有夜间模式先切回浅色别问我怎么知道的。打印出来的 PDF 是图片化文本吗不是现代浏览器生成的是矢量文本可以选中、可以搜索。但问题是跨页断行——一段代码正好卡在分页处上半页半行、下半页半行读起来极其难受。这个没法靠打印设置完全解决只能事后用 PDF 编辑器手动调或者干脆换方案。2.2 保存为单文件网页保真度最高的手动方案比打印更好的选择是浏览器的网页另存为。注意存储格式选单文件网页.mht 或 .html 单文件而不是网页全部——后者会生成一个文件夹塞满 js、css 和图片拷给别人就散了。单文件网页的好处很实在文本可选中、结构完整、体积小一条长对话通常几百 KB、离线能打开。缺点是不同浏览器兼容性参差某些浏览器打开别人存的单文件网页会丢样式。所以如果你打算长期归档还是建议转成通用格式。我个人的习惯是临时存档用单文件网页长期归档一律转 Markdown。Markdown 是纯文本二十年后还能打开不依赖任何软件。2.3 浏览器扩展的边界在哪里市面上有一批浏览器扩展专门做这件事装上之后对话页面会多出一个导出按钮支持一键存成 PDF、Markdown、图片甚至 JSON。这类工具确实省事但用之前得清楚它们的边界边界一权限。这类扩展通常需要读取你在该站点的全部页面内容。装之前看一眼权限声明能接受再装。企业环境里更要注意很多公司的终端管理策略会直接拦掉这类扩展。边界二维护周期。网页结构一改扩展的按钮可能就失效了或者导出内容错位。我遇到过导出后所有问答顺序被打乱的情况原因是页面改版导致选择器失配。所以用扩展导出的内容必须抽查一两条别导完就扔进归档目录再也不看。边界三长对话性能。几百轮的对话扩展在浏览器里跑一遍 DOM 解析可能卡住十几秒甚至直接崩掉标签页。这种情况还是老老实实走官方数据导出更稳。2.4 分享链接不等于导出别拿它当归档很多人图省事点分享生成一个链接存进笔记软件就完事了。这个做法有明确风险我得说清楚分享链接指向的是服务端的一份快照你本地没有副本。链接能不能长期访问取决于服务方的策略链接是否公开可访问取决于分享设置——默认往往是任何拿到链接的人都能看。如果你的对话里包含内部接口地址、客户名称、还没发布的产品信息把链接随手发出去等于把内容公开了。另外分享出去的页面通常是只读的精简版模型名、时间戳、部分元信息会被隐去。做技术复盘时这些信息往往有用。注意分享链接适合让对方看一眼不适合我要长期保存。归档一定要落到本地文件这一点没有商量余地。3. 官方数据导出包到底给了你什么批量导出绕不开官方的数据导出功能。它藏在账号设置里入口不算显眼点完还要等一段时间邮件里会给你一个下载链接。很多人拿到压缩包之后一脸懵——里面是一堆 JSON根本打不开。这一节把包里的东西拆开讲清楚。3.1 申请流程与等待时长流程本身很简单账号设置 → 数据控制 → 导出数据 → 确认。确认之后系统会在后台打包完成后通过邮件或站内通知给你下载地址。等待时长从几分钟到一两天不等取决于你账号里的数据量。链接一般有有效期通常是几天到两周过期要重新申请。有几个实操细节值得提前知道导出的是全量数据不能只导某几条也没法按时间范围筛选。所以如果你只想要最近三个月的内容拿到包之后得自己过滤。导出包里的对话是生成时刻的快照导出之后新产生的对话不会自动追加。想要增量只能定期重新导出。下载链接通常只能下载有限次数别反复点。拿到包先存本地再解压。3.2 压缩包内部结构逐层拆解解开压缩包你会看到一个目录结构大致长这样不同时期可能略有差异但主体稳定导出包/ ├── conversations.json # 全部对话最核心的文件 ├── chat.html # 一个可在浏览器打开的浏览版 ├── user.json # 账号基本信息 ├── model_comparisons.json # 模型对比记录 ├── message_feedback.json # 点赞点踩记录 └── 其他业务数据文件...conversations.json是真正的宝藏所有对话都在里面结构化的。chat.html是个便利品直接用浏览器打开能看到对话列表但它通常不支持搜索、不支持导出单条长列表加载还慢我不建议依赖它。3.3 conversations.json 的字段语义这个文件是一个数组数组里每一项就是一条对话。单条对话的结构简化之后是这样{ title: 接口幂等设计讨论, create_time: 1712300000.123, update_time: 1712303600.456, conversation_id: xxxx-xxxx, current_node: node-5, mapping: { node-1: { id: node-1, parent: null, children: [node-2], message: null }, node-2: { id: node-2, parent: node-1, children: [node-3], message: { author: { role: user }, create_time: 1712300001.0, content: { content_type: text, parts: [帮我看看这个幂等方案有没有漏洞……] } } } } }几个关键点必须理解到位mapping 是一个字典不是数组。键是节点 ID值是节点。这意味着一开始你拿不到顺序必须顺着parent指针往上爬或者从根往下走。为什么是树不是链。因为对话支持重新生成和编辑提问。你改了一次提问旧的分支还在 mapping 里新分支挂在同一个父节点下。所以 mapping 是一棵带分支的树而不是一条直线。current_node 是导航灯。它指向当前显示的那条分支的末端节点。从它出发沿 parent 回溯到根反转之后就是你要的那条线性对话。如果不看这个字段自己遍历很容易把已经废弃的旧分支也导出出现同一个问题有三条不同回答的混乱。message 可能是 null。根节点通常没有消息直接跳过即可。另外还有些系统节点author.role是system也不该出现在导出的正文里。content.parts 可能是字符串数组也可能含对象。纯文本对话里 parts 就是字符串列表涉及图片、附件时parts 里会出现字典里面有资源指针而不是文本。这些字典要单独处理否则脚本会直接报错。content_type 不只是 text。常见的还有code代码块会带language字段、multimodal_text多模态、execution_output执行结果。不做区分的脚本导出后代码块的格式会全丢。理解了这几个字段后面的脚本才有意义。不理解就抄代码出了问题根本没法调。4. 把 JSON 变成可读文档一条可复现的转换链路这一节是全文的核心给一条从原始 JSON 到 Word/PDF 的完整链路。链路设计成三段JSON → Markdown → DOCX/PDF。中间为什么要插一层 Markdown后面会讲。4.1 环境与依赖准备需要的东西不多Python 3.8 以上我用 3.10 测的标准库就够json、os、re、datetime不需要额外装包后续转换用 Pandoc这是一个命令行文档转换工具官网下个安装包双击装好或者用系统包管理器装# 检查环境 python --version pandoc --versionPandoc 装好之后转 Word 和 HTML 都是开箱即用。转 PDF 还需要一个渲染引擎两条路可选装小巧的 PDF 引擎需要配中文字体或者用 HTML 中转再调系统打印能力。我推荐后者原因在 4.4 节展开。4.2 解析脚本从树状映射还原线性对话先写文本提取函数处理不同 content_typeimport json import os import re from datetime import datetime def extract_text(content): 从 content 字段提取可读文本兼容多模态与代码块 if not content: return parts content.get(parts) or [] buf [] for p in parts: if isinstance(p, str): buf.append(p) elif isinstance(p, dict): # 图片、附件等资源指针导出包里不含原始文件 if p.get(content_type) image_asset_pointer: buf.append(_[图片导出包内不含原图需回原会话查看]_) elif text in p: buf.append(p[text]) text \n\n.join(x for x in buf if x).strip() # 代码块补上围栏保留语言标记 if content.get(content_type) code and text: lang (content.get(language) or ).strip() text f{lang}\n{text}\n return text再写回溯函数把树还原成一条线def linearize(conv): 沿 current_node 回溯到根返回正向顺序的节点列表 mapping conv.get(mapping) or {} cur conv.get(current_node) # 兜底current_node 缺失时找一条最深的分支 if cur not in mapping: cur None depth_best 0 for nid, node in mapping.items(): depth, p 0, node.get(parent) seen set() while p and p in mapping and p not in seen: seen.add(p) p mapping[p].get(parent) depth 1 if depth depth_best: depth_best, cur depth, nid chain, guard [], 0 while cur and guard 100000: node mapping.get(cur) if node is None: break chain.append(node) cur node.get(parent) guard 1 chain.reverse() return chain第三段拼装成 Markdown并做文件名清洗def safe_name(s, limit60): s re.sub(r[\\/:*?|\r\n\t], _, s or ) s re.sub(r\s, , s).strip() return (s[:limit] or untitled) def to_markdown(conv, chain): title conv.get(title) or 未命名对话 out [f# {title}\n] ts conv.get(create_time) if isinstance(ts, (int, float)): out.append(f 创建时间{datetime.fromtimestamp(ts):%Y-%m-%d %H:%M}\n) cid conv.get(conversation_id) if cid: out.append(f 会话 ID{cid}\n) for node in chain: msg node.get(message) if not msg: continue role (msg.get(author) or {}).get(role) if role not in (user, assistant): continue text extract_text(msg.get(content)) if not text: continue label 提问 if role user else 回答 out.append(f## {label}\n\n{text}\n) return \n.join(out)主函数负责批量跑并且处理重名标题——这是最容易忽略的地方很多人的对话标题都是默认生成的新对话直接按标题命名会互相覆盖def main(src, outdir): os.makedirs(outdir, exist_okTrue) with open(src, encodingutf-8) as f: data json.load(f) if isinstance(data, dict): data data.get(conversations, []) used, ok, skip {}, 0, 0 for conv in data: try: chain linearize(conv) md to_markdown(conv, chain) except Exception as e: skip 1 print(跳过一条, e) continue base safe_name(conv.get(title)) n used.get(base, 0) 1 used[base] n fname f{base}.md if n 1 else f{base}-{n}.md with open(os.path.join(outdir, fname), w, encodingutf-8) as w: w.write(md) ok 1 print(f完成成功 {ok} 条跳过 {skip} 条标题去重 {sum(v-1 for v in used.values())} 次) if __name__ __main__: import sys main(sys.argv[1], sys.argv[2] if len(sys.argv) 2 else ./out)跑起来就一行命令python split_conversations.py conversations.json ./out导出几百条对话通常几秒钟到十几秒视数据量而定。跑完打开./out目录你会看到一堆.md文件每个文件名就是对话标题。4.3 中间为什么非要插一层 Markdown有人会问直接生成 Word 不行吗干嘛多一道工序。我的理由有三条都是踩过坑之后总结的第一Markdown 是纯文本永不过期。Word 的格式十年后可能打不开完整样式PDF 想改一个字得用专门工具而 Markdown 用记事本就能开、就能改。你的归档要放五年十年可编辑性比排版精美重要得多。第二Markdown 是转换枢纽。同一份.mdPandoc 能转 Word、转 PDF、转 HTML、转电子书。哪天你想换个格式不用重新解析原始 JSON一行命令的事。第三Markdown 天然支持版本管理。如果你用 Git 管理归档目录纯文本的 diff 一目了然能看到某条对话后来被改动过什么——虽然对话本身不会变但你可以在文件后面追加人工笔记和结论这些增量改动是可追踪的。4.4 从 Markdown 到 DOCX 和 PDF 的两条命令Word 转换最省心# 单个文件 pandoc ./out/接口幂等设计讨论.md -o ./docx/接口幂等设计讨论.docx # 批量转换 mkdir -p docx for f in ./out/*.md; do pandoc $f -o docx/$(basename ${f%.md}).docx donePDF 我建议走 HTML 中转而不是直接用 LaTeX 引擎pandoc ./out/接口幂等设计讨论.md -o tmp.html -s --metadata title对话归档 # 再用系统能力把 html 转 pdfweasyprint、浏览器无头模式等均可理由很直接直接用 LaTeX 引擎处理中文字体配置能把新手劝退稍微复杂的表格和代码块还可能报错。而 HTML 中转有几个实在的好处中文字体不用折腾HTML 里用系统已有字体就行CSS 可控性强你能精确控制分页、代码块不许断行、页边距表格和代码块渲染稳Pandoc 的 HTML 输出对这些元素处理得相当规整如果想要代码块不被分页切断在生成的 HTML 里加一段样式pre, table, blockquote { page-break-inside: avoid; } h1, h2, h3 { page-break-after: avoid; }这四行 CSS 能把 PDF 的可读性提升一个档次尤其是技术类对话代码块被拦腰截断是重灾区。5. 长对话、公式、代码块三个最容易翻车的细节前面的流程跑通之后你会发现大部分对话导出来是好的但总有那么几条不对劲。这一节专治这些疑难杂症。5.1 分支对话被重复导出症状很明确某条对话导出后同一个提问下面出现了三条不同的回答内容还不一样。原因就是前面说过的——你编辑过提问或者点过重新生成旧分支还留在 mapping 里而脚本遍历方式不对把所有叶子节点都抓了一遍。判断方法很简单导出后的文件里如果出现连续多个提问中间没有回答或者同一段提问文字重复出现基本就是这个问题。修复方案就是 4.2 节里写的current_node回溯。这个字段指向的就是当前界面上显示的那一条分支从它出发沿 parent 往上爬爬出来的路径就是唯一的、线性的、和你屏幕上看到的一致的对话。提示如果你确实想保留所有废弃分支有时候旧版本的答案反而更有参考价值可以在脚本里改成从每个叶子节点各回溯一条链然后在文件名里加上分支序号。但必须清楚地知道这样导出的内容比屏幕上看到的多别到时候以为数据坏了。5.2 数学公式和代码块的保真问题数学公式是最容易丢的。网页上的公式通常用某种标记语言渲染复制成纯文本时有的是原始标记可读但需要渲染才能看懂有的直接变成一串乱码字符。我的处理原则是导出后逐条检查包含公式的对话如果公式全乱就在 Markdown 文件里保留原始标记形式用支持公式渲染的编辑器打开看。不要指望 PDF 里一定是对的。代码块的问题集中在两处一是缩进二是语言标记。缩进丢失通常发生在你走复制粘贴到 Word这条路上纯文本的 Markdown 转换反而不会出问题——因为围栏代码块里的空格是原样保留的。语言标记则影响语法高亮脚本里从content.language字段取出来填进围栏即可没有就留空。还有一个隐藏问题代码块里包含了三个反引号。虽然少见但确实有比如你在讨论 Markdown 语法本身。这种情况下围栏要加长成四个反引号否则代码块会提前闭合后面的内容全变成普通文本。稳妥的做法是在脚本里检测一下内容里最长的连续反引号长度围栏长度取它加一。5.3 中文与特殊字符的编码坑编码问题表现得很直观打开文件是乱码或者中文正常但某些符号变成问号。最常见的原因是写入时没指定编码。Windows 环境下Python 默认编码可能是系统本地编码而不是 UTF-8写中文文件就会出问题。所以脚本里所有open都必须显式写encodingutf-8这一点不能省。第二个坑是文件名。中文标题直接当文件名在部分系统上会有兼容性问题而且标题里出现:/?*这些字符会直接导致创建文件失败。4.2 节的safe_name函数就是干这个的把所有非法字符替换成下划线同时把长度截断——有些对话的自动标题特别长超过系统文件名长度限制会报错。第三个坑是表情符号和特殊 Unicode 字符。有些系统字体渲染不出来PDF 里会显示成方块。这不是数据问题是字体问题。如果你的归档里这类字符很多可以统一替换成文字描述或者干脆在转换 PDF 前过滤掉。6. 归档之后命名、检索与脱敏导出只是第一步导出来一堆散落文件却不管理等于把网页里的混乱搬到了本地硬盘。这一节聊怎么让归档真正可用。6.1 目录结构和命名规范我的目录结构按年份/月份两级分文件名用日期-标题归档/ ├── 2024/ │ ├── 01/ │ │ ├── 20240115-接口幂等设计讨论.md │ │ └── 20240118-日志采集方案对比.md │ └── 02/ └── 2025/按时间分的好处是增量导出好合并。每次重新导出全量数据新文件自然落到对应月份跟旧文件不冲突。如果你按主题分每次导出都要人工判断新对话归到哪个目录一百条能累断手。文件名加日期前缀的好处是天然排序目录里一看就是时间线。如果你觉得目录太深可以只用一级年份 文件名带日期也能达到同样效果。6.2 轻量全文检索的三种做法文件存好了怎么找三个层次按投入递增第一层系统自带搜索。macOS 的聚焦、Windows 的索引搜索直接搜目录就能命中文件内容速度够用零成本。缺点是正则、模糊匹配、跨文件结果高亮这些高级功能没有。对个人归档来说这一层已经解决了 80% 的问题。第二层专用命令行工具。ripgrep这类工具一条命令能把整个归档目录扫一遍输出文件名和行号rg -n -i 幂等 ./归档速度极快几百个文件毫秒级返回。配合编辑器打开对应文件体验很顺。第三层本地文档管理系统。如果你要管理成千上万份文档还想要标签、摘要、关联推荐那就要上专门的系统了。但说实话个人级别用到这一步的不多除非你把它当知识库产品来运营。我自己停在第二层够用、够快、不折腾。6.3 导出前的脱敏清单这一点特别重要尤其是你要把归档分享给团队或者上传到内部知识库的时候。数据导出包里包含的是你账号下的全部对话包括那些你早就忘了内容的可能夹着接口地址、访问凭证片段、客户名称、内部项目代号。分享前过一遍这张清单检查项具体内容处理方式凭证类疑似密钥、访问令牌、连接串直接删除不要只做替换内部标识内网地址、服务名、项目代号替换为通用描述个人信息姓名、联系方式、住址全部脱敏商业信息客户名称、未公开的报价、合同条款移除或改写图片附件截图里可能带界面信息逐张检查后决定去留关键原则是脱敏要做在导出之后、分享之前。别指望导出工具帮你过滤它不知道什么算敏感。用ripgrep扫一遍关键词比如key、token、password、secret把命中的文件挨个打开看比通读全部内容效率高得多rg -l -i api[_-]?key|token|secret|password ./归档这一步花的时间比事后补救的代价小得多。7. 我在实际操作中踩过的两个小坑最后说两个细节都是实际操作中撞出来的文档里不会写。第一个是时间戳时区。脚本里用的datetime.fromtimestamp是按系统本地时区转换的如果你在多台时区不同的机器上跑同一份数据生成的时间会不一样。归档场景下影响不大但如果你要用时间戳做排序和去重建议统一转成 UTC 再存避免混乱。改法很简单换成datetime.utcfromtimestamp或者带时区参数的写法就行。第二个是重复导出会重名。前面脚本里做了计数去重但如果你分两次导出、两次都跑到同一个目录第二次的文件会带上-2后缀导致同一条对话存在两份。我的做法是每次重新导出时先跑一个比对脚本用conversation_id做键跳过已经存在的只处理新增的。这个比对逻辑几十行代码就能写上比事后去重省事得多。如果暂时不想写代码用文件名加conversation_id前缀也能凑合——ID 是唯一的重名文件会被自动覆盖虽然粗暴但不会产生重复。