浏览器工具自制指南·对话导出(3):消息池设计

前言

在上一篇文章中,我们完成了两项基础工作:一是搭建了一个干净、可复现的 Edge 浏览器开发环境,二是设计了以 tool- 为前缀、零第三方依赖的项目骨架。至此,项目有了稳固的地基和清晰的蓝图。

本篇的目标是为这个骨架注入第一层血肉:数据层。

具体来说,我们需要让脚本能够理解元宝对话页面的底层数据结构,并将原始的网络请求响应,转化为结构清晰、便于消费的内部模型。这涉及三个核心内容:

  1. 接口探测与数据结构分析:找到目标 API,摸清它的脾气。
  2. 消息池(MessagePool):一个专门负责存储、去重、排序和筛选消息的内存容器。
  3. 元解析器(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 对象当然可以,但会给后续的使用带来麻烦------每次读取都需要重复解析字段、处理兜底、转换时间格式。更好的做法是在数据入库时就完成一次归一化,对外暴露统一的数据结构。

消息池的核心设计原则有三条:

  1. 扁平化存储 :将嵌套的 conv → speechesV2 → content 三层结构拍平为一层,每条消息只包含 id、role、blocks 等核心字段,业务代码无需关心原始层级。
  2. 保留 raw 原始数据 :归一化后的消息对象中保留 raw 字段,指向原始的 conv 对象。这样既保证了常规使用的便捷性,也为将来可能需要访问原始字段的场景留下了后路。
  3. 用 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 中读取对应的字段,做好空值守卫,必要时进行格式转换(时间戳秒转毫秒)。每一行都在做同一件事------把原始接口字段映射为内部统一的属性名。

这里有三个值得关注的细节:

  1. 空值守卫优先 :函数的第一行就是 if (!rawData) 判断。如果传入 null 或 undefined,直接返回一个全为空值的默认对象。这是从踩坑中得来的教训------在早期的测试中,parseConversationMeta(null) 直接抛出了 Cannot read properties of null 错误。
  2. 标题的三级降级 :rawData.title → rawData.sessionTitle → '未命名对话',与消息池中用户消息的兜底逻辑一脉相承,确保标题在任何情况下都不会缺失。
  3. 时间戳的统一处理 :接口返回的 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 个方面:

  1. 数据加载:验证测试 JSON 文件能被正确解析
  2. 数据灌入 :验证 addBatch 后消息数量与原始数组一致
  3. 元信息验证 :确认 pool.meta 中的会话标题、模型等信息不为空
  4. 正序排序 :验证 getAll() 返回的消息按 index 递增排列
  5. 倒序排序 :验证 getAllDesc() 返回的消息按 index 递减排列,并与正序首尾对应
  6. 角色筛选 :验证 getByRole 能正确区分 user 和 assistant 消息,且两者数量之和等于总数
  7. 内容完整性 :随机抽取一条中间消息,检查其 id、index、timestamp、blocks 等字段的完整性和类型
  8. 用户消息兜底 :验证用户消息的 blocks 中至少有一个非空的 text block
  9. 导出 JSON :验证 toExportJSON() 返回的结构包含 meta 和 messages,且数量一致
  10. 清空功能 :验证 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 数据作为本地文件导入测试。这样做的好处是:

  1. 排除网络因素的干扰。 测试失败时,可以确定是逻辑问题而非网络问题。
  2. 测试环境纯净。 不需要启动浏览器、不需要登录态、不需要等待接口响应。
  3. 可重复性强。 同一份测试数据在不同机器、不同时间运行,结果完全一致。

等到后续行为层开发完成后,再引入网络拦截机制进行端到端的集成测试。分阶段引入复杂度,避免一次性面对太多变量。

5.5 单元测试零第三方依赖的选择

在单元测试中,我们没有引入 Jest、Mocha 或任何一个测试框架,而是自己写了不到 20 行的 assert 和 assertEqual 工具函数。

这个选择看起来有些"原始",但背后有明确的考量:

零第三方依赖原则 : 从项目骨架设计之初,我们就确定了 tool- 前缀的脚本不依赖任何 npm 包。单元测试作为项目的一部分,理应遵循同样的原则。

自制工具更轻量 : 测试框架带来的 setup/teardown、mock、覆盖率报告等能力,在当前阶段都用不上。我们需要的就是简单的断言和计数,自制工具 20 行代码搞定,而引入 Jest 意味着 node_modules 膨胀数百个文件。

未来可以平滑迁移 : 如果项目规模增长到需要测试框架的程度,现有的测试用例结构(assert + assertEqual + 分组注释)与 Jest 的 describe/it/expect 模式高度相似,迁移成本极低。届时只需要全局替换断言函数,不改动测试逻辑本身。

总结

本篇为项目注入了第一层血肉:数据层。我们从零开始,完成了以下工作:

  1. 接口探测 :通过开发者工具定位了 conversation/v1/detail 接口,摸清了返回数据的真实结构。
  2. 消息池设计 :实现了 MessagePool 类,完成了从原始 conv 到归一化消息对象的转换,并提供了排序、筛选、导出等核心方法。
  3. 元解析器分离 :将会话元信息的提取独立为 parseConversationMeta 函数,与消息池各司其职。
  4. 单元测试落地 :在 Node.js 环境下编写了两套测试用例,覆盖正常路径和边界情况,建立了快速反馈的安全网。

整个过程中贯穿了几个关键的决策思路:网页版 + 开发者工具 的真实数据优先于猜测文档,变动隔离 的设计让消息池能够从容应对接口变化,零第三方依赖的测试策略在轻量与可维护之间找到了平衡。

消息池已经就绪,但里面的 blocks 还是原始状态。

下一篇将继续留在数据层,完成这项精加工工作:解析 content block,组装为结构化 Markdown。


本文首发于个人博客「阶进遐的技术随笔」

博客中配有完整专栏目录、可复制配置与长期更新,适合沉浸式阅读。

欢迎来博客逛逛,这边有任何疑问也可直接评论区交流。

相关推荐
SameX1 小时前
HarmonyOS 用 relationalStore 做阅读统计 App —— 5 张表背后的 5 个 schema 决策
前端
LV技术派1 小时前
【成为 AI Native Builder · 02】什么是 AI Native Builder:一个人 + AI,交付完整闭环
前端·程序员·ai编程
#麻辣小龙虾#2 小时前
网页布局css中flex+margin的组合布局技巧
开发语言·前端·javascript
叼烟扛炮2 小时前
Linux 学习笔记(四十五):Web 安全基石——Cookie、Session 与 HTTPS 原理解析
linux·前端·网络·笔记·学习·tcp/ip·安全
环境栈笔记2 小时前
指纹浏览器安卓怎么用?从运行位置到功能验收,先把三种方案分清
前端·人工智能·后端·自动化
计算机魔术师2 小时前
Anthropic IPO 招股文件披露近 9000 亿美元隐性债务
前端
chi11302 小时前
【无标题】
前端·javascript·python
IT_陈寒3 小时前
JavaScript实战技巧总结:深挖数组遍历的性能陷阱与实战解法
前端·人工智能·后端
步行cgn3 小时前
为什么既要 web.xml 又要 springmvc.xml?能不能只配一个?
xml·前端·hive