不和虚拟列表较劲,也能导出豆包长对话
有些页面看起来像一条长河,鼠标滚轮一动,水面上就浮出新的文字。人容易相信自己看见的是全部,浏览器却未必这么诚实。许多聊天页面采用虚拟列表,屏幕之外的消息并不一定还在 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 在这类对话里是 3。direction 决定拉取方向,3 表示从最新消息开始拉取,1 表示以锚点继续向更早消息翻页。anchor_index 是翻页锚点,对应消息里的 index_in_conv。limit 是每页拉取数量。
接口返回的消息里有两个字段比页面文字更可靠:
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,再兼容普通 content 和 tts_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、剪贴板、滚动条都是筌。真正要保存的是消息数据本身。