浏览器工具自制指南·对话导出(3):消息池设计
前言
在上一篇文章中,我们完成了两项基础工作:一是搭建了一个干净、可复现的 Edge 浏览器开发环境,二是设计了以 tool- 为前缀、零第三方依赖的项目骨架。至此,项目有了稳固的地基和清晰的蓝图。
本篇的目标是为这个骨架注入第一层血肉:数据层。
具体来说,我们需要让脚本能够理解元宝对话页面的底层数据结构,并将原始的网络请求响应,转化为结构清晰、便于消费的内部模型。这涉及三个核心内容:
接口探测与数据结构分析:找到目标 API,摸清它的脾气。
消息池(MessagePool):一个专门负责存储、去重、排序和筛选消息的内存容器。
元解析器(ConversationParser):负责从原始数据中提取出会话的元信息(ID、标题、模型、时间戳等)。
接下来就让我们逐一实现并验证它们。
注:专栏配套代码仓库正在同步建设中,本文内容对应仓库的 chat-export-3 标签
地址: gitee.com/jiejinx/jie…
如需参考完整代码,请按以下命令签出:
git clone https://gitee.com/jiejinx/jiejinx-browser-tools.git
cd jiejinx-browser-tools
git checkout chat-export-3一、接口探测与数据结构分析
1.1 定位接口
1.1.1 打开目标网页
首先,用 Edge 浏览器 打开 元宝网页版 ,这是腾讯旗下的 AI 应用,微信扫描登录后,点开一个有较多条对话记录的聊天页面,作为接口定位的基础。
1.1.2 打开开发者工具
准备好聊天页面后,下一步打开浏览器的开发者工具。
打开方式:点击浏览器右上角
...(更多) → 更多工具 → 开发人员工具快捷键:
F12(部分网页可能屏蔽此快捷键) 或Ctrl + shift + I
打开后,开发者工具面板会在浏览器内部的右侧弹开。
1.1.3 接口探测
开发者工具面板顶部 找到 Network(网络/WIFI信号图标)点击进入面板,点击 Network 面板左上角的 🚫 清除按钮(或按 Ctrl + L)清空之前的请求记录后,点击任意一条历史会话。
几秒钟内,Network 面板里刷出了一排请求——有图片、有字体、有 JS 文件,还有几个 XHR 请求。
我们需要找到那个携带了所有对话数据的接口。一个实用的技巧是:按数据大小排序。因为对话的主要内容(文本、搜索结果等)数据量很大,而 CSS、JS 文件相对较小,所以承载核心数据的 API 响应体通常是最大的。
点击两下 Size(大小) 列,倒序排序后,排在最前面的几个请求很快锁定了目标——一个名为 detail 的接口,路径为:
POST https://yuanbao.tencent.com/api/user/agent/conversation/v1/detail点击这条记录,再点击新弹面板里的 Preview(预览) 面板展开后,映入眼帘的是一个典型的 JSON 结构:
{
"id": "CONVERSATION_ID_001",
"userId": "USER_ID_001",
"convs": [...],
"stopGenerating": false,
"sensitive": false,
...
}convs 数组,直觉告诉我们,这就是存放对话消息的地方。
Preview(预览) 面板中可以右键复制,将JSON数据保存至本地的文本文件里。
将 JSON 数据提供给 AI,便可快速对数据结构进行初步分析。
1.2 真实数据解剖
将 convs 数组中的一条完整记录复制出来,格式化后仔细观察。字段不少,但对我们有用的主要集中在以下几个层面。
顶层会话信息:
原始字段 | 含义 | 用途 |
|---|---|---|
| 会话 ID | 唯一标识一次会话 |
| 会话标题 | 导出文件名、展示 |
| 消息轮次数组 | 核心数据来源 |
| 默认模型 | 元信息记录 |
| 时间戳 | 会话时间范围 |
单条 conv(一轮对话):
原始字段 | 映射建议 | 说明 |
|---|---|---|
|
| 如 |
|
| 取值 |
| 排序依据 | 注意:接口返回是倒序, |
| 时间戳 | 秒级,需转为毫秒 |
| 内容块数组 | AI 回复可能包含多段内容 |
| 用户消息兜底文本 | human 侧的主要文本来源 |
| 用户消息旧字段兜底 | 当 |
1.3 关键发现:一条 conv ≠ 一条消息
当我们把目光投向 speechesV2 字段时,事情变得有趣起来。它并非简单的字符串,而是一个对象数组,其中每个元素的 content 字段又是一个包含多种类型的块结构:
"speechesV2": [
{
"content": [
{ "type": "text", "msg": "这是第一段文字" },
{ "type": "search_query", "query": "...", "result": [...] },
{ "type": "think", "thought": "..." },
{ "type": "text", "msg": "这是第二段文字" }
]
}
]不同类型的 content 块需要不同的处理策略:
| 处理策略 |
|---|---|
| 主体 Markdown 文本( |
| 思考过程,可折叠或转为引用块 |
| 搜索引导:标题 + |
带 | 附件(图片/文件) |
这意味着我们不能简单地将一条 conv 视为“一条消息”,而应该把它看作一个 turn(轮次),其中包含若干个 speech,每个 speech 又包含若干个 content block。
另一个值得注意的点是用户消息的兜底逻辑。在实测中发现,用户的提问并不总是直接出现在 speechesV2 中,有时存放在 displayPrompt 字段里,极少数情况下需要回退到 speech 字段。因此,提取用户消息时需要依次检查这三个字段:
displayPrompt → speech → speechesV2[0]?.content[0]?.msg至此,我们已经完成了接口的定位和数据结构的摸底。
下一步,就是基于这些发现来设计消息池的内部模型。
二、消息池的设计与实现
消息池就像一个大水池,我们把从河里打来的水(原始数据)先过滤、净化(归一化),然后再有序地储存起来。
2.1 归一化模型设计
有了第一节对数据结构的摸底,我们现在可以回答一个核心问题:消息池内部应该存什么?
直接存储原始的 conv 对象当然可以,但会给后续的使用带来麻烦——每次读取都需要重复解析字段、处理兜底、转换时间格式。更好的做法是在数据入库时就完成一次归一化,对外暴露统一的数据结构。
消息池的核心设计原则有三条:
扁平化存储:将嵌套的
conv → speechesV2 → content三层结构拍平为一层,每条消息只包含id、role、blocks等核心字段,业务代码无需关心原始层级。保留 raw 原始数据:归一化后的消息对象中保留
raw字段,指向原始的conv对象。这样既保证了常规使用的便捷性,也为将来可能需要访问原始字段的场景留下了后路。用
conv.id去重,用index排序:接口返回的数据可能存在重复(例如多次触发同一请求),因此消息池必须具备去重能力。conv.id天然适合作为唯一键。而排序方面,由于接口返回是倒序的,我们不能依赖插入顺序,必须使用index字段作为排序依据。
基于这三条原则,_normalizeTurn 方法完成了从原始 conv 到归一化消息对象的转换:
以下是
MessagePool类中的一个核心私有方法,完整类结构请参见配套代码仓库的tool-chat-exporter/shared/messagePool.js
_normalizeTurn(conv) {
const role = conv.speaker === 'ai' ? 'assistant' : 'user';
const baseTime = (conv.createTime || 0) * 1000;
// 提取 speechesV2 中的所有 content block
const blocks = [];
const speeches = conv.speechesV2 || [];
for (const sp of speeches) {
for (const block of (sp.content || [])) {
blocks.push(block);
}
}
// 用户消息兜底:当 speechesV2 为空时,尝试 displayPrompt / speech
if (role === 'user' && blocks.length === 0) {
blocks.push({ type: 'text', msg: conv.displayPrompt || conv.speech || '' });
}
return {
id: conv.id,
turnId: conv.id,
role,
index: conv.index,
timestamp: baseTime,
model: speeches[0]?.chatModelId || '',
plugin: speeches[0]?.pluginId || '',
traceId: speeches[0]?.traceID || '',
blocks,
raw: conv,
};
}这里有几个值得注意的设计细节:
时间戳转换:接口返回的
createTime是秒级,而 JavaScript 习惯使用毫秒级时间戳,因此在入库时统一乘以 1000。模型信息的提取:
chatModelId存在于speechesV2[0]层级而非conv层级,需要在遍历时顺便取出。用户兜底的三级回退:
displayPrompt → speech → 空字符串,确保用户消息在任何情况下都不会缺失。
2.2 API 设计
消息池对外暴露的方法应当足够简洁,让使用者无需关心内部存储结构。最终设计的 API 如下:
方法 | 说明 |
|---|---|
| 单条数据格式化入池,自动去重 |
| 批量入池,内部循环调用 |
| 按 |
| 按 |
| 按角色筛选( |
| 清空消息池 |
| 导出包含元信息和消息列表的 JSON 对象 |
其中 getAll() 和 getAllDesc() 的拆分值得一提。最初的设想是提供一个带参数的 getAll({ sort: 'asc' }),但很快就意识到这种方式存在两个问题:一是参数名和取值需要额外约定,二是调用方每次都要记得传参,容易遗漏。拆分为两个独立方法后,语义一目了然,调用方也不需要记忆参数规则。
排序的具体实现由 _sortMessages 完成:
_sortMessages(order = 'asc') {
const arr = [...this._map.values()];
arr.sort((a, b) => {
if (a.index !== b.index)
return order === 'asc' ? a.index - b.index : b.index - a.index;
return a.timestamp - b.timestamp;
});
return arr;
}注意这里的二级排序:当 index 相同时,按 timestamp 排序。这是为了防止极端情况下同一次会话中出现相同 index 的消息导致顺序不稳定。
2.3 边界情况处理
消息池在实际使用中会遇到各种边界情况,代码层面需要一一覆盖:
空池查询:当 _map 为空时,getAll() 返回空数组,size 返回 0,toExportJSON() 返回包含空消息列表的对象。这些行为由数据结构和逻辑自然保证,无需额外判断。
重复灌入:add 方法内部通过 _map.has(msg.id) 检测重复。如果检测到相同 ID 的消息已经存在,会执行合并更新而非忽略,确保后续数据能覆盖先前的脏数据:
if (this._map.has(msg.id)) {
this._map.set(msg.id, { ...this._map.get(msg.id), ...msg });
return;
}异常数据:当传入的 conv 对象缺少 id 字段时,_normalizeTurn 返回的 msg.id 为空字符串,add 方法会直接 return,不会污染消息池。同理,addBatch 传入 null 或 undefined 时,forEach 不会执行,也不会报错。
元信息缺失:构造函数接收的 conversationMeta 可以为空对象,此时各字段会使用默认值(如 title 默认为 '未命名对话'),确保消息池在任何状态下都能正常工作。
至此,消息池的核心实现已经完成。但还有一个模块尚未提及——负责从原始数据中提取会话元信息的解析器。它将被独立出来,放在下一节讨论。
三、元解析器的分离
3.1 parseConversationMeta 的职责
消息池负责管理消息本体,但会话的元信息——会话 ID、标题、模型、创建时间等——同样需要被提取和使用。这些信息不属于某一条具体的消息,而是附着在整个会话层面的属性。
parseConversationMeta 的职责就是从 detail 接口的原始响应中提取这些元信息,并返回一个结构化的对象:
export function parseConversationMeta(rawData) {
if (!rawData) {
return {
conversationId: '',
title: '未命名对话',
model: '',
createdAt: 0,
updatedAt: 0
};
}
return {
conversationId: rawData.id || '',
title: rawData.title || rawData.sessionTitle || '未命名对话',
model: rawData.chatModelId || '',
createdAt: (rawData.createTime || 0) * 1000,
updatedAt: (rawData.updateTime || 0) * 1000
};
}函数的逻辑非常直观:从 rawData 中读取对应的字段,做好空值守卫,必要时进行格式转换(时间戳秒转毫秒)。每一行都在做同一件事——把原始接口字段映射为内部统一的属性名。
这里有三个值得关注的细节:
空值守卫优先:函数的第一行就是
if (!rawData)判断。如果传入null或undefined,直接返回一个全为空值的默认对象。这是从踩坑中得来的教训——在早期的测试中,parseConversationMeta(null)直接抛出了Cannot read properties of null错误。标题的三级降级:
rawData.title → rawData.sessionTitle → '未命名对话',与消息池中用户消息的兜底逻辑一脉相承,确保标题在任何情况下都不会缺失。时间戳的统一处理:接口返回的
createTime和updateTime是秒级时间戳,统一乘以 1000 转为毫秒级,与消息池中_normalizeTurn的处理保持一致。
3.2 独立成模块的原因
将 parseConversationMeta 独立于 MessagePool 之外,是基于单一职责原则的考量。具体原因将在 5.3 节决策复盘中展开论述。
四、测试验证
4.1 浏览器测试 VS 单元测试
在开发初期,验证消息池的正确性只能通过一种方式:把脚本安装到 Tampermonkey 中,打开元宝对话页面,查看控制台输出。每次修改代码都需要刷新页面、等待数据加载、手动比对结果,这种方式不仅效率低下,而且难以覆盖边界情况。
我们需要一种更快的反馈回路。Node.js 环境下的单元测试正好满足这个需求:它可以脱离浏览器独立运行,毫秒级反馈,并且可以轻松构造各种边界数据。
4.2 测试数据准备
测试的第一步是准备真实的接口数据。从第一节中定位到的 detail 接口,我们通过开发者工具的 Preview 面板右键复制了一份完整的 JSON 响应,保存为 test/messagePool.data.json。
这份数据包含了真实会话的所有字段:顶层元信息、完整的 convs 数组、以及各种类型的 content block。用真实数据而非手工构造的 mock 数据进行测试,可以尽早暴露实际运行中才会遇到的问题。
4.3 元解析器测试
test/conversationParser.test.js 针对 parseConversationMeta 函数编写了 5 组测试用例:
测试组 | 场景 | 验证重点 |
|---|---|---|
1 | 正常数据 | 字段映射正确、时间戳转换正确 |
2 | 无 title,有 sessionTitle | 降级逻辑生效 |
3 | 无 title 也无 sessionTitle | 默认值生效 |
4 | 空对象 | 所有字段返回空值/默认值 |
5 | null / undefined | 空值守卫不抛异常 |
每组测试用例都围绕一个核心场景展开,覆盖了正常路径和所有已知的边界情况。测试工具函数 assert 和 assertEqual 提供了简洁的断言能力,并通过 passed / failed 计数器汇总结果。
运行方式也非常简单,打开命令行窗口输入(Win + S,搜索终端,点击打开):
# 进入项目目录
cd tool-chat-exporter
# 运行测试
node test/conversationParser.test.js4.4 消息池测试
test/messagePool.test.js 的测试范围更广,涵盖了消息池的 10 个方面:
数据加载:验证测试 JSON 文件能被正确解析
数据灌入:验证
addBatch后消息数量与原始数组一致元信息验证:确认
pool.meta中的会话标题、模型等信息不为空正序排序:验证
getAll()返回的消息按index递增排列倒序排序:验证
getAllDesc()返回的消息按index递减排列,并与正序首尾对应角色筛选:验证
getByRole能正确区分 user 和 assistant 消息,且两者数量之和等于总数内容完整性:随机抽取一条中间消息,检查其
id、index、timestamp、blocks等字段的完整性和类型用户消息兜底:验证用户消息的
blocks中至少有一个非空的 text block导出 JSON:验证
toExportJSON()返回的结构包含meta和messages,且数量一致清空功能:验证
clear()后size归零,重新灌入后数据可恢复
第 7 组测试值得特别说明。它没有选择第一条或最后一条消息,而是取了中间位置的消息作为样本:
const sampleMsg = allAsc[Math.floor(allAsc.length / 2)];这是因为第一条和最后一条消息往往是对话的开场白或结束语,结构可能比较简单。中间的消息更有可能包含多种类型的 content block,更能代表真实情况。
运行方式同理:
cd tool-chat-exporter
node test/messagePool.test.js注意:首次运行前需手动创建 test/messagePool.data.json 文件,具体使用情况见仓库内文件的前置说明。
4.5 测试结果解读
当所有测试通过时,控制台输出如下:
========== 消息池单元测试 ==========
[1] 加载测试数据
✅ JSON 文件解析成功
[2] 创建消息池并灌入数据
✅ 消息池中有数据
✅ 消息数与 convs 数组长度一致
...
========== 测试完成:10/10 通过 ==========10 个测试全部通过,意味着消息池在正常数据、边界情况、空值守卫、排序逻辑、角色筛选、导出格式等多个维度上都符合预期。
这套测试体系的价值在于:后续任何时候修改消息池或元解析器的代码,只需运行 node test/messagePool.test.js 和 node test/conversationParser.test.js,几秒钟内就能知道改动是否破坏了既有功能。它为后续的重构和扩展提供了坚实的安全网。
五、决策复盘
本节记录实操过程中遇到的几个重要抉择及其背后的思路,供读者参考。
5.1 技术路线确认
关于数据接口,我们没有文档,也不打算去猜。
网页版 + 开发者工具的组合拳,优势在于所见即所得。Network 面板里抓到的是什么数据,我们就用什么数据,不用专门去找接口文档。而且这种方式对所有同类项目都通用 —— 不管目标平台有没有公开文档,只要它跑在浏览器里,就能抓包分析。而借助 AI,还能进一步提高分析的效率。
5.2 消息池变动隔离设计
消息池中最核心的方法是 _normalizeTurn,它将原始接口数据归一化为内部统一格式。这个设计的隐含价值在于变动隔离。
未来如果接口字段发生变化——比如某个字段改名、新增了一种 content block 类型、时间戳格式调整——绝大多数情况下只需要在 _normalizeTurn 内部微调映射逻辑即可。消息池的存储、排序、筛选、导出等核心功能完全不受影响。如果遇到更大的结构变动(例如接口的整个顶层数据的层级关系重构),则由 parseConversationMeta 元解析器去消化。
消息池本身始终只关心一件事:我已确立原始消息归一化的标准,接下来怎么存、怎么取。
5.3 元解析模块拆分
将 parseConversationMeta 拆分为独立函数,而非挂在 MessagePool 上作为静态方法,是出于以下考量:
职责边界不同。 MessagePool 管理的是消息的生命周期——添加、存储、排序、筛选、导出。parseConversationMeta 处理的是会话级别的元信息提取。前者面向消息集合,后者面向单个响应对象。把两者耦合在一起,意味着消息池既要管消息又要管元信息,违背了单一职责原则。
变化频率和方向可能不同。 接口可能在一次升级中调整了元信息的字段名,但消息的结构纹丝不动;也可能反过来。分开之后,各自可以独立演进,互不影响。
可测试性更优。 独立的函数可以在 Node.js 环境中轻松测试,不需要实例化消息池,不需要准备完整的 conv 数组,只需要一个模拟的 rawData 对象即可。这也是我们在第四章中看到的测试模式。
5.4 验证方式独立
在测试策略上,我们做了一个明确的取舍:不提前介入网络数据拦截。
这意味着在当前的开发阶段,我们不使用 Tampermonkey 的 GM_xmlhttpRequest 或其他网络拦截手段来模拟接口请求,而是直接将真实抓取的 JSON 数据作为本地文件导入测试。这样做的好处是:
排除网络因素的干扰。 测试失败时,可以确定是逻辑问题而非网络问题。
测试环境纯净。 不需要启动浏览器、不需要登录态、不需要等待接口响应。
可重复性强。 同一份测试数据在不同机器、不同时间运行,结果完全一致。
等到后续行为层开发完成后,再引入网络拦截机制进行端到端的集成测试。分阶段引入复杂度,避免一次性面对太多变量。
5.5 单元测试零第三方依赖的选择
在单元测试中,我们没有引入 Jest、Mocha 或任何一个测试框架,而是自己写了不到 20 行的 assert 和 assertEqual 工具函数。
这个选择看起来有些“原始”,但背后有明确的考量:
零第三方依赖原则: 从项目骨架设计之初,我们就确定了 tool- 前缀的脚本不依赖任何 npm 包。单元测试作为项目的一部分,理应遵循同样的原则。
自制工具更轻量: 测试框架带来的 setup/teardown、mock、覆盖率报告等能力,在当前阶段都用不上。我们需要的就是简单的断言和计数,自制工具 20 行代码搞定,而引入 Jest 意味着 node_modules 膨胀数百个文件。
未来可以平滑迁移: 如果项目规模增长到需要测试框架的程度,现有的测试用例结构(assert + assertEqual + 分组注释)与 Jest 的 describe/it/expect 模式高度相似,迁移成本极低。届时只需要全局替换断言函数,不改动测试逻辑本身。
总结
本篇为项目注入了第一层血肉:数据层。我们从零开始,完成了以下工作:
接口探测:通过开发者工具定位了
conversation/v1/detail接口,摸清了返回数据的真实结构。消息池设计:实现了
MessagePool类,完成了从原始conv到归一化消息对象的转换,并提供了排序、筛选、导出等核心方法。元解析器分离:将会话元信息的提取独立为
parseConversationMeta函数,与消息池各司其职。单元测试落地:在
Node.js环境下编写了两套测试用例,覆盖正常路径和边界情况,建立了快速反馈的安全网。
整个过程中贯穿了几个关键的决策思路:网页版 + 开发者工具的真实数据优先于猜测文档,变动隔离的设计让消息池能够从容应对接口变化,零第三方依赖的测试策略在轻量与可维护之间找到了平衡。
消息池已经就绪,但里面的 blocks 还是原始状态。
下一篇将继续留在数据层,完成这项精加工工作:解析 content block,组装为结构化 Markdown。
本文首发于个人博客「阶进遐的技术随笔」
博客中配有完整专栏目录、可复制配置与长期更新,适合沉浸式阅读。
欢迎来博客逛逛,这边有任何疑问也可直接评论区交流。

成为第一条评论!
Nobody's responded to this post yet. Add your thoughts and get the conversation going.