数据截至 (上游 commit fd0b7e1d9ed9)
第 5 章:自动等待与数据收集
这章讲什么: 为什么这个项目里几乎没有
sleep,以及"自上次导航以来的控制台消息"这句话是怎么做到的。
5.1 问题:点完之后要等多久
三种错误做法
| 做法 | 问题 |
|---|---|
| 不等,立刻返回 | 模型看到的是动作前的旧页面 |
| 固定 sleep 1 秒 | 快页面浪费时间,慢页面还是不够 |
等 networkidle | 有心跳/轮询/长连接的页面永远等不到 |
本项目的判据
分成两问:
问题一:这次动作触发导航了吗?
→ 有,就等导航完成
问题二:DOM 还在变吗?
→ 等它"连续 100ms 没有任何变动"
实现全在 WaitForHelper(src/utils/WaitForHelper.ts:13),入口是 waitForEventsAfterAction(:112)。
5.2 时序:监听必须先于动作
完整流程
waitForEventsAfterAction(action)
│
│ ── 动作之前先埋三个监听 ──
├─ ① page.on('dialog') 检测/按策略自动处理对话框
├─ ② CDP 'Page.frameStartedNavigating' 导航起跑探针
├─ ③ page.waitForNavigation() ★ 必须现在启动
│
├─ ④ await action() 真正的点击/输入
│ 抛错 → abort 全部,向上抛
│
├─ ⑤ 等 100ms(×cpu 倍率):导航起跑了吗?
│ 起跑 → 等 ③ 完成
│ 没起跑 → 跳过
│
├─ ⑥ 期间检测到对话框?→ 直接返回,不等 DOM
│
├─ ⑦ waitForStableDom()
│
└─ finally: abortController.abort() 清干净所有监听
第 ③ 步为什么不能推后
源码注释说得很直接(:196-199):
Puppeteer's
waitForNavigationmust be started before the action runs so that it captures the pre-action loader ID. If started after the action triggers navigation, it risks recording the new loader ID and hanging until timeout.
waitForNavigation 判断"导航完成"靠的是 loaderId 变化。 动作之后才启动,它可能已经读到新 loaderId,于是永远等不到"变化",只能超时。
第 ⑤ 步:100ms 是在赌什么
const navigationStarted = await Promise.race([
navigationStartedResolvers.promise,
this.timeout(expectNavigationIn).then(() => { …; return false; }),
]);
expectNavigationIn 默认 100ms × cpu 倍率(:33)。含义是:一个动作如果要触发导航,导航请求应该在 100ms 内起跑。
没起跑就认定不会导航,于是不去 await 那个 waitForNavigation,让它随后被 abort 掉。这样"点了个不跳转的 按钮"就不会白等 3 秒导航超时。
探针本身还过滤掉了两类事件(:170-176):非主框架的导航、以及 sameDocument/historySameDocument 类型——单页应用的路由跳转不算导航,它不换文档,等 DOM 稳定就够了。
5.3 DOM 稳定:静默窗口
思路
不问"加载完了吗",问"最近还有人改 DOM 吗"。
DOM 变动: ──X──X─X────X──────────────────────────→
└─ 100ms 无变动 ─┘
✓ 判定稳定
└────────── 上限 3 秒 ──────────┘
实现
waitForStableDom(:44)在页面里种一个 MutationObserver,回调是"清掉旧定时器,重开一个 100ms 定时器";定时器烧完就 resolve。观察 document.body 的 childList + subtree + attributes。
有个易漏的细节:回调先被主动调用一次(:64-65),注释写明原因——DOM 可能根本不会变,不先起一次定时器就会一直等到 3 秒上限。
一处真实的死锁修复
// src/utils/WaitForHelper.ts:46-48
// Bound the setup evaluation against the stable-DOM timeout. Without this
// cap a paused renderer (e.g. an open dialog) would make evaluateHandle
// hang until protocolTimeout (default 180s) while the tool mutex is held.
using stableDomObserver = await Promise.race([
this.#page.evaluateHandle(…),
this.timeout(this.#stableDomTimeout) as Promise<undefined>,
]).catch(() => undefined);
连"种观察者"这一步本身都被限时了。 因为 alert() 会暂停渲染进程,此时任何 evaluate 都不会返回,而这时工具互斥锁还握在手里——整个 server 会静默卡死 180 秒。
这也是第 ⑥ 步存在的原因:一旦 #dialogDetected,直接返回结果,根本不进 DOM 稳定等待(:246-248)。
对话框的两种处理
调用方传了 handleDialog?
├─ 传了 'accept' / 'dismiss' / 字符串(prompt 的回答)
│ → 自动处理,并在结果里标 dialogHandled: true
└─ 没传
→ 只标记 #dialogDetected,把对话框留给 handle_dialog 工具
handleDialog 还支持按类型分别配置(:136-140),例如 navigate_page 就只自动处理 beforeunload,其余留给用户决定(src/tools/pages.ts:271)。
节流放大
#stableDomTimeout = 3000 × cpu 倍率
#stableDomFor = 100 × cpu 倍率
#expectNavigationIn = 100 × cpu 倍率
#navigationTimeout = 3000 × 网络倍率
CPU 慢 → DOM 变动之间的间隔拉长;网络慢 → 导航更久。 两个倍率分别作用在该作用的地方,而不是笼统乘一个系数。
一次性对象
if (this.#abortController.signal.aborted) {
throw new Error("Can't re-use a WaitForHelper");
}
每次动作 McpPage.waitForEventsAfterAction(src/McpPage.ts:416)都 new 一个新的。abort 之后所有监听都摘干净了,复用只会得到一个哑对象——直接抛错,比静默失效好。
5.4 按导航分段的数据收集
它要解决的小问题
list_console_messages 的描述是 "since the last navigation"。要做到这句话,收集器必须知道"上一次导航在哪"。
数据结构
PageCollector(src/collectors/PageCollector.ts:48):
storage: [
[ … ], ← index 0:当前这次导航(最新)
[ … ], ← index 1:上一次
[ … ], ← index 2:上上次
] 最多 maxNavigationSaved = 3 段
新数据永远进 storage[0];主框架 framenavigated 触发 splitAfterNavigation()(:107),往前 unshift 一个空段并截断。
getData(includePreservedData)(:113):不要历史就只给 storage[0],要就从最旧往最新拼。对应工具参数 includePreservedRequests / includePreservedMessages。
稳定 id 怎么发
// src/collectors/PageCollector.ts:67-72
const idGenerator = createIdGenerator();
const listenerMap = listeners(value => {
const withId = value as WithSymbolId<T>;
withId[stableIdSymbol] = idGenerator();
this.storage[0].push(withId);
…
});
id 在收集那一刻就打在对象上,不是渲染时按数组下标算的。所以分页、过滤都不会让 reqid 漂移——模型上一轮看到的 reqid=42,下一轮还是同一条请求。
网络收集器的特殊切分
NetworkCollector 覆盖了 splitAfterNavigation(:313):
普通收集器:导航 → 开一个全新的空段
网络收集器:导航 → 从后往前找最后一条"主框架的导航请求",
把它及其之后的请求整体搬进新段
原因很实在:导航请求本身是在 framenavigated 之前发出的。按普通规则切,那条最关键的文档请求会被留在"上一次导航"里,而 list_network_requests 默认只看当前段——用户就看不到自己刚触发的那次请求。
另外它有条数上限 MAX_REQUESTS_PER_NAVIGATION = 1000(:296),超了从头部丢弃。一个无限轮询的页面不会把内存吃光。
5.5 控制台:三种来源合成一路
来源
ConsoleCollector 收三类事件(src/McpPage.ts:164-176):
| 事件 | 来源 | 类型 |
|---|---|---|
console | Puppeteer 原生 | ConsoleMessage |
uncaughtError | CDP Runtime.exceptionThrown | UncaughtError |
devtoolsAggregatedIssue | DevTools 的 issue 聚合器 | DevTools.AggregatedIssue |
后两类由 PageEventSubscriber(src/collectors/PageCollector.ts:178)合成后用 page.emit 打回页面事件总线——这样三路可以走同一个收集器,不用给收集器写三套逻辑。
issue 的两级去重
CDP inspector issue
│
├─ DevTools.isIssueCodeSupported(code)? 不支持就丢
├─ issue.primaryKey() 见过? 见过就丢 ← 第一级
▼
交给 IssueAggregator 聚合
│
└─ AGGREGATED_ISSUE_UPDATED 事件
└─ #seenIssues 见过这个聚合对象? 见过就丢 ← 第二级
聚合器会反复对同一个聚合对象发更新事件(比如同类问题又来了一条),第二级去重挡的就是这个。
导航时两级缓存都清空并重建聚合器(:249-257)——新页面的 issue 与旧页面无关。
5.6 wait_for:等文本出现
waitFor 工具(src/tools/snapshot.ts:48)接一个文本数组,"任一出现即返回"。实现是 waitForTextOnPage(src/McpPage.ts:843):
let locator = this.#locatorClass.race(
frames.flatMap(frame =>
text.flatMap(value => [
frame.locator(`aria/${value}`), // 无障碍名匹配
frame.locator(`text/${value}`), // 文本内容匹配
]),
),
);
每个 frame × 每个文本 × 两种匹配方式,全部并行竞速。 一个 3 文本 2 iframe 的页面会同时跑 12 个 locator,谁先命中谁赢。
成功后自动附一份快照(:74),模型立刻拿到新的 uid。
5.7 关键细节与坑
click也可能不等待。waitForEventsAfterAction的waitForStableDom可以传 false,evaluate_script就把它开放成了工具参数——"只读数据的脚本不必等 DOM"(src/tools/script.ts:55-60)。drag中间硬编码了 50ms。await fromHandle.drag(toHandle)后 sleep 50ms 再 drop(src/tools/input.ts:376)。这是全项目少见的固定延时。press_key的修饰键一定会被松开。finally里按相反顺序keyboard.up,注释指明了对应 issue:"Otherwise a failed press leaves modifiers logically held down in the browser (see #2309)"(src/tools/input.ts:524-531)。一次失败的组合键会让后续所有输入都带着 Ctrl。fill_form是逐个填的。 循环里每个元素各自waitForEventsAfterAction(src/tools/input.ts:415-424),只把最后一次的结果附给响应。它的收益是"一次工具调用而非多次往返",不是浏览器侧的并行。
5.8 代码地图
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 动作后等待 | src/utils/WaitForHelper.ts | WaitForHelper、waitForEventsAfterAction、waitForStableDom、getNetworkMultiplierFromString |
| 页面侧入口 | src/McpPage.ts | waitForEventsAfterAction、createWaitForHelper、waitForTextOnPage |
| 分段收集 | src/collectors/PageCollector.ts | PageCollector、splitAfterNavigation、getData、getById |
| 网络收集 | src/collectors/PageCollector.ts | NetworkCollector、MAX_REQUESTS_PER_NAVIGATION |
| 控制台合成与去重 | src/collectors/PageCollector.ts | ConsoleCollector、PageEventSubscriber、UncaughtError |
| service worker 控制台 | src/collectors/ServiceWorkerCollector.ts | ServiceWorkerConsoleCollector |
| 等文本工具 | src/tools/snapshot.ts | waitFor |