前言
在上一篇文章中,我们完成了两项基础工作:一是搭建了一个干净、可复现的 Edge 浏览器开发环境,二是设计了以 tool- 为前缀、零第三方依赖的项目骨架。至此,项目有了稳固的地基和清晰的蓝图。
本篇的目标是为这个骨架注入第一层血肉:数据层。
具体来说,我们需要让脚本能够理解元宝对话页面的底层数据结构,并将原始的网络请求响应,转化为结构清晰、便于消费的内部模型。这涉及三个核心内容:
- 接口探测与数据结构分析:找到目标 API,摸清它的脾气。
- 消息池(MessagePool):一个专门负责存储、去重、排序和筛选消息的内存容器。
- 元解析器(ConversationParser):负责从原始数据中提取出会话的元信息(ID、标题、模型、时间戳等)。
接下来就让我们逐一实现并验证它们。
注:专栏配套代码仓库正在同步建设中,本文内容对应仓库的 chat-export-3 标签 地址: gitee.com/jiejinx/jie...
如需参考完整代码,请按以下命令签出:
bash
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 的接口,路径为:
ruby
POST https://yuanbao.tencent.com/api/user/agent/conversation/v1/detail
点击这条记录,再点击新弹面板里的 Preview(预览) 面板展开后,映入眼帘的是一个典型的 JSON 结构:
json
{
"id": "CONVERSATION_ID_001",
"userId": "USER_ID_001",
"convs": [...],
"stopGenerating": false,
"sensitive": false,
...
}
convs 数组,直觉告诉我们,这就是存放对话消息的地方。
Preview(预览) 面板中可以右键复制,将JSON数据保存至本地的文本文件里。将 JSON 数据提供给 AI,便可快速对数据结构进行初步分析。
1.2 真实数据解剖
将 convs 数组中的一条完整记录复制出来,格式化后仔细观察。字段不少,但对我们有用的主要集中在以下几个层面。
顶层会话信息:
| 原始字段 | 含义 | 用途 |
|---|---|---|
id |
会话 ID | 唯一标识一次会话 |
title / sessionTitle |
会话标题 | 导出文件名、展示 |
convs |
消息轮次数组 | 核心数据来源 |
chatModelId |
默认模型 | 元信息记录 |
createTime / lastRepliedAt |
时间戳 | 会话时间范围 |
单条 conv(一轮对话):
| 原始字段 | 映射建议 | 说明 |
|---|---|---|
id |
turnId |
如 0R3XGriEwt0_218,用于去重 |
speaker |
role |
取值 human / ai |
index |
排序依据 | 注意:接口返回是倒序 ,index 大的在前 |
createTime |
时间戳 | 秒级,需转为毫秒 |
speechesV2 |
内容块数组 | AI 回复可能包含多段内容 |
displayPrompt |
用户消息兜底文本 | human 侧的主要文本来源 |
speech |
用户消息旧字段兜底 | 当 displayPrompt 不存在时的备选 |
1.3 关键发现:一条 conv ≠ 一条消息
当我们把目光投向 speechesV2 字段时,事情变得有趣起来。它并非简单的字符串,而是一个对象数组,其中每个元素的 content 字段又是一个包含多种类型的块结构:
json
"speechesV2": [
{
"content": [
{ "type": "text", "msg": "这是第一段文字" },
{ "type": "search_query", "query": "...", "result": [...] },
{ "type": "think", "thought": "..." },
{ "type": "text", "msg": "这是第二段文字" }
]
}
]
不同类型的 content 块需要不同的处理策略:
type |
处理策略 |
|---|---|
text |
主体 Markdown 文本(block.msg) |
think |
思考过程,可折叠或转为引用块 |
searchGuid |
搜索引导:标题 + docs[](引用链接) |
带 fileName/url 的非 text |
附件(图片/文件) |
这意味着我们不能简单地将一条 conv 视为"一条消息",而应该把它看作一个 turn(轮次),其中包含若干个 speech,每个 speech 又包含若干个 content block。
另一个值得注意的点是用户消息的兜底逻辑。在实测中发现,用户的提问并不总是直接出现在 speechesV2 中,有时存放在 displayPrompt 字段里,极少数情况下需要回退到 speech 字段。因此,提取用户消息时需要依次检查这三个字段:
css
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
javascript
_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 如下:
| 方法 | 说明 |
|---|---|
add(conv) |
单条数据格式化入池,自动去重 |
addBatch(convs) |
批量入池,内部循环调用 add |
getAll() |
按 index 正序返回所有消息 |
getAllDesc() |
按 index 倒序返回所有消息 |
getByRole(role) |
按角色筛选(user / assistant) |
clear() |
清空消息池 |
toExportJSON() |
导出包含元信息和消息列表的 JSON 对象 |
其中 getAll() 和 getAllDesc() 的拆分值得一提。最初的设想是提供一个带参数的 getAll({ sort: 'asc' }),但很快就意识到这种方式存在两个问题:一是参数名和取值需要额外约定,二是调用方每次都要记得传参,容易遗漏。拆分为两个独立方法后,语义一目了然,调用方也不需要记忆参数规则。
排序的具体实现由 _sortMessages 完成:
javascript
_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 的消息已经存在,会执行合并更新而非忽略,确保后续数据能覆盖先前的脏数据:
javascript
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 接口的原始响应中提取这些元信息,并返回一个结构化的对象:
javascript
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,搜索终端,点击打开):
bash
# 进入项目目录
cd tool-chat-exporter
# 运行测试
node test/conversationParser.test.js
4.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 组测试值得特别说明。它没有选择第一条或最后一条消息,而是取了中间位置的消息作为样本:
javascript
const sampleMsg = allAsc[Math.floor(allAsc.length / 2)];
这是因为第一条和最后一条消息往往是对话的开场白或结束语,结构可能比较简单。中间的消息更有可能包含多种类型的 content block,更能代表真实情况。
运行方式同理:
bash
cd tool-chat-exporter
node test/messagePool.test.js
注意:首次运行前需手动创建 test/messagePool.data.json 文件,具体使用情况见仓库内文件的前置说明。
4.5 测试结果解读
当所有测试通过时,控制台输出如下:
csharp
========== 消息池单元测试 ==========
[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。
本文首发于个人博客「阶进遐的技术随笔」
博客中配有完整专栏目录、可复制配置与长期更新,适合沉浸式阅读。
欢迎来博客逛逛,这边有任何疑问也可直接评论区交流。