---
title: "浏览器工具自制指南·对话导出（3）：消息池设计 - Angryecho"
description: "前言 在上一篇文章中，我们完成了两项基础工作：一是搭建了一个干净、可复现的 Edge 浏览器开发环境，二是设计了以 tool- 为前缀、零第三方依赖的项目骨架。至此，项目有了稳固的地基和清晰的蓝图。 本篇的目标是为这个骨架注入第一层血肉：数据层。"
canonical_url: "https://www.angryecho.com/c/qian_duan_she_qu/p/P2TBC40fnxErFQComRF6B"
meta:
  "og:description": "前言 在上一篇文章中，我们完成了两项基础工作：一是搭建了一个干净、可复现的 Edge 浏览器开发环境，二是设计了以 tool- 为前缀、零第三方依赖的项目骨架。至此，项目有了稳固的地基和清晰的蓝图。 本篇的目标是为这个骨架注入第一层血肉：数据层。"
  "og:image": "https://www.angryecho.com/_og/d/c_Post%2Ctitle_~5ZCQ5qe9572R%2Cdescription_~5ZCQ5qe95LiA5LiL77yM5bCx5LiK5ZCQ5qe9572R44CC%2CpostId_P2TBC40fnxErFQComRF6B%2Crevision_~MjAyNi0xMS0wM1QwNzowMDozNS42NDg5NjBa%2Cp_Ii9jL3FpYW5fZHVhbl9zaGVfcXUvcC9QMlRCQzQwZm54RXJGUUNvbVJGNkIi%2Cs_IWL2bZ-QBBiYIT-Y.png"
  "og:image:height": "630"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": 浏览器工具自制指南·对话导出（3）：消息池设计
---

> Index of every page on this site: [llms.txt](https://www.angryecho.com/llms.txt)

# **浏览器工具自制指南·对话导出（3）：消息池设计**

## 前言

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

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

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

1. **接口探测与数据结构分析**：找到目标 API，摸清它的脾气。
2. **消息池（MessagePool）**：一个专门负责存储、去重、排序和筛选消息的内存容器。
3. **元解析器（ConversationParser）**：负责从原始数据中提取出会话的元信息（ID、标题、模型、时间戳等）。

接下来就让我们逐一实现并验证它们。

---

注：专栏配套代码仓库正在同步建设中，本文内容对应仓库的 chat-export-3 标签 地址： [gitee.com/jiejinx/jie…](https://link.juejin.cn/?target=https%3A%2F%2Fgitee.com%2Fjiejinx%2Fjiejinx-browser-tools.git "https://gitee.com/jiejinx/jiejinx-browser-tools.git")  
如需参考完整代码，请按以下命令签出：

```
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` 浏览器 打开 [元宝网页版](https://link.juejin.cn/?target=https%3A%2F%2Fyuanbao.tencent.com%2F "https://yuanbao.tencent.com/") ，这是腾讯旗下的 `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` | 会话 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` 字段又是一个包含多种类型的块结构：

```
"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` 字段。因此，提取用户消息时需要依次检查这三个字段：

```
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`

```
_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` 完成：

```
_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` 中读取对应的字段，做好空值守卫，必要时进行格式转换（时间戳秒转毫秒）。每一行都在做同一件事——**把原始接口字段映射为内部统一的属性名**。

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

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`，搜索终端，点击打开）：

```
# 进入项目目录
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 组测试值得特别说明。它没有选择第一条或最后一条消息，而是取了中间位置的消息作为样本：

```
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 数据作为本地文件导入测试。这样做的好处是：

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**。

---

> 本文首发于个人博客「 [阶进遐的技术随笔](https://link.juejin.cn/?target=https%3A%2F%2Fjiejinx.cn%2Farticles%2Fself-app%2Fai-chat-export%2F3 "https://jiejinx.cn/articles/self-app/ai-chat-export/3")」  
> 博客中配有完整专栏目录、可复制配置与长期更新，适合沉浸式阅读。  
> 欢迎来博客逛逛，这边有任何疑问也可直接评论区交流。

排序方式 :

### **成为第一条评论！**

Nobody's responded to this post yet. Add your thoughts and get the conversation going.

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

## Sitemap

See the full [sitemap](https://www.angryecho.com/sitemap.md) for all pages.
