不和虚拟列表较劲,也能导出豆包长对话

有些页面看起来像一条长河,鼠标滚轮一动,水面上就浮出新的文字。人容易相信自己看见的是全部,浏览器却未必这么诚实。许多聊天页面采用虚拟列表,屏幕之外的消息并不一定还在 DOM 里。所谓“整页复制”,常常只是把当前视口附近的碎片捞上来。

这次要保存的是豆包网页端的一段长对话,并且只保留指定时间之后的消息。最初的问题很朴素:把页面里的对话历史下载成 Markdown。真正麻烦的地方也很朴素:页面太长,消息太多,还要按时间切开。

最后可行的办法不是继续滚屏,而是接管一个已登录的浏览器窗口,直接复用网页背后的消息接口。核心判断也很简单:聊天记录属于数据,不属于版面。要保存数据,就不要和版面搏斗。

失败的捷径

最容易想到的是在 Console 里直接读页面文字:

1
document.body.innerText

这只适合静态页面。豆包对话页使用虚拟列表,DOM 中通常只保留当前视口附近的一小段消息。页面看上去很长,不等于这些消息都同时存在于页面结构里。长对话场景下,这种方法会漏掉大部分内容。

另一个捷径是把提取出的文字写进剪贴板:

1
navigator.clipboard.writeText(text)

这也不稳定。浏览器可能报:

1
NotAllowedError: Failed to execute 'writeText' on 'Clipboard': Document is not focused.

这个错误不是文字提取失败,而是剪贴板权限要求当前页面聚焦。它提醒我们,抓取逻辑不应依赖剪贴板。更稳的做法是由自动化脚本直接写入本地文件。

继续滚动页面也不是好路。可以找到类似这样的滚动容器:

1
document.querySelector('.v_list_scroller-BxcoIX')

但虚拟列表会在滚动时动态加载和卸载消息。逐屏采集容易重复、漏抓,滚动高度变成几万像素以后,效率也很低。此路像在沙地里抄碑文,碑还会自己移动。

换一条路:接管浏览器

日常正在使用的 Edge 窗口通常没有开启调试端口,Playwright 不能直接接管。比较干净的办法是另开一个独立的 Edge Profile,并带上 CDP 调试端口:

1
2
3
4
5
6
7
8
$profileDir = "D:\path\to\workspace\.playwright\doubao-edge-profile"
New-Item -ItemType Directory -Force -Path $profileDir | Out-Null
Start-Process -FilePath "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" -ArgumentList @(
  "--remote-debugging-port=9222",
  "--user-data-dir=$profileDir",
  "--new-window",
  "https://www.doubao.com/chat/"
)

确认端口可用:

1
Invoke-RestMethod http://127.0.0.1:9222/json/version

在这个新窗口里登录豆包并打开目标对话。随后用 Playwright 通过 CDP 连接:

1
2
3
4
5
6
7
const { chromium } = require('playwright');

const browser = await chromium.connectOverCDP('http://127.0.0.1:9222');
const page = browser
  .contexts()
  .flatMap(context => context.pages())
  .find(page => page.url().includes('/chat/<conversation_id>'));

这种做法的好处是边界清楚。它不动用户日常浏览器,也不需要重新实现登录流程。网页已经登录,自动化脚本只是借用这个状态。

真正的入口在消息接口

滚动对话页面时,网络面板里可以看到豆包请求真实消息接口:

1
POST https://www.doubao.com/im/chain/single

请求体的核心形状如下,敏感字段用占位符替代:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
{
  "cmd": 3100,
  "uplink_body": {
    "pull_singe_chain_uplink_body": {
      "conversation_id": "<conversation_id>",
      "anchor_index": 0,
      "conversation_type": 3,
      "direction": 3,
      "limit": 50,
      "ext": {},
      "filter": {
        "index_list": []
      },
      "evaluate_ab_params": "",
      "evaluate_common_params": ""
    }
  },
  "sequence_id": "<uuid>",
  "channel": 2,
  "version": "1"
}

这里有几个字段最关键。

conversation_id 来自对话 URL,但公开文章里不应写真实编号。conversation_type 在这类对话里是 3direction 决定拉取方向,3 表示从最新消息开始拉取,1 表示以锚点继续向更早消息翻页。anchor_index 是翻页锚点,对应消息里的 index_in_convlimit 是每页拉取数量。

接口返回的消息里有两个字段比页面文字更可靠:

1
2
3
4
{
  "index_in_conv": "2377",
  "create_time": "1780084567"
}

create_time 是 Unix 秒级时间戳。按北京时间转换后,就能精确决定哪些消息该保留。index_in_conv 是对话中的消息序号,最后按它升序排列,能恢复原始阅读顺序。

按时间切开对话

导出逻辑可以分成三步:从最新消息开始拉取,向更早消息翻页,遇到早于截止时间的页面后停止。随后过滤掉截止时间之前的消息,再按 index_in_conv 排序输出 Markdown。

核心伪代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
const cutoff = Date.UTC(2026, 4, 29, 4, 0, 0) / 1000; // 2026-05-29 12:00:00 +08:00

let batch = await pull(0, 3, 50);

while (true) {
  save(batch.messages);

  const times = batch.messages.map(message => Number(message.create_time));
  const indexes = batch.messages.map(message => Number(message.index_in_conv));
  const minTime = Math.min(...times);
  const minIndex = Math.min(...indexes);

  if (!batch.has_more || minTime < cutoff) {
    break;
  }

  batch = await pull(minIndex, 1, 50);
}

const rows = allMessages
  .filter(message => Number(message.create_time) >= cutoff)
  .sort((a, b) => Number(a.index_in_conv) - Number(b.index_in_conv));

这样做的优点是不会被页面是否渲染、滚动条是否到底、DOM 是否卸载牵着走。边界由原始消息时间决定,顺序由原始消息索引决定。页面负责展示,接口负责事实。

提取正文

豆包消息正文不一定只放在一个字段里。比较稳的提取顺序是优先读 content_block,再兼容普通 contenttts_content

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function msgText(message) {
  const parts = [];

  for (const block of message.content_block || []) {
    const content = block.content || {};
    const text =
      content.text_block?.text ||
      content.markdown_block?.markdown ||
      content.code_block?.code ||
      '';

    if (text) {
      parts.push(text);
    }
  }

  if (!parts.length && message.content) {
    parts.push(message.content);
  }

  if (!parts.length && message.tts_content) {
    parts.push(message.tts_content);
  }

  return parts.join('\n\n').trim();
}

输出 Markdown 时,可以用消息角色、北京时间、消息索引作为标题信息。这样回看时既能保留对话顺序,也能知道每条消息发生在什么时间。

Windows 上的编码小坑

如果在 PowerShell 里通过管道临时传递含中文的 Node 脚本,中文有时会被转成问号。这个问题和豆包无关,是脚本进入 Node 前已经被壳层处理坏了。

需要稳定保留中文时,有两种做法更可靠。可以在脚本里使用 Unicode 转义,也可以把脚本保存为 UTF-8 文件后再运行。对于一次性调试,前者够用。对于可复用工具,后者更清楚。

确认本机 Playwright 能否被 Node 找到时,可以用:

1
2
$env:NODE_PATH='<npm-global-modules-path>'
node -e "console.log(require.resolve('playwright'))"

查看当前 Edge 进程是否带调试端口:

1
2
3
Get-CimInstance Win32_Process -Filter "name='msedge.exe'" |
  Select-Object ProcessId,CommandLine |
  Select-String -Pattern "remote-debugging"

留下来的经验

聊天记录、时间线、无限滚动列表,都不要先迷信 document.body.innerText。它像一盏手电,只照见当前区域。数据真正住在哪里,要看网络请求和前端状态。

需要按时间导出时,必须找到原始时间戳。页面上的时间文本可能省略日期,也可能因相对时间而变形。接口里的 create_time 才适合作为边界。

需要完整排序时,要找消息序号或游标。滚屏复制得到的是视觉顺序,不一定是全量顺序。index_in_conv 这类字段比滚动位置可靠得多。

需要复用登录态时,CDP 接管独立浏览器 Profile 是一条折中的路。它不必碰用户的日常浏览器,也不必从零处理登录。它像借一间已经点着灯的屋子办公,走的时候把门关上即可。

这件事的教训并不新。古人说“得鱼忘筌”,可在浏览器里,许多时候我们连鱼都没摸到,只抱着筌不放。DOM、剪贴板、滚动条都是筌。真正要保存的是消息数据本身。