观察不该惊动页面:我们删掉了 agent 的元素清单

题记:agent 看一眼网页,网页不该知道它被看了。这次落地把「看」压缩成一次窗口截图,顺手删了 126 行净代码。
1. 先说删了什么⌗
MaxClaw 的网页操作走的是 desktop 工具:每个 agent 在本机 Firefox 里独占一个窗口,动手用系统级的真鼠标、真键盘,看靠窗口截图。这次改动之前,「看」其实是两件事并发:
之前的一次观察
├─ screencapture -l<窗口id> → 一张图
└─ 扩展 page.visibleElements → 视口内可交互元素清单
(tabs.executeScript 注入内容脚本,逐个 hit-test)
[12] button "提交" (420,118 80×30)
[13] link "下一页" (...)
...
click 可以传 element: 12,按清单里的矩形点中心。为了让编号在页面继续加载时不漂移,serializer 里还维护了一套「文档内单调递增、永不复用」的编号:WeakMap 元素 → 编号、全局计数器、每份文档一个随机 __mcDocId,外加一个真窗口真鼠标的 e2e 专门测编号稳定性。
现在:
| 之前 | 之后 | |
|---|---|---|
| 一次观察对页面做了什么 | 注入脚本 + 全视口 hit-test | 什么都没做 |
| 观察的返回 | 图 + 元素清单 | 图;或 as:'text' 的文字行 |
click 怎么定位 | {x,y} 或 {element} | 只有 {x,y}(截图像素) |
浏览器窗口的 snapshot | 有(只要清单不要图) | 删除,调用即报错 |
| 编号稳定性机制 | WeakMap + 计数器 + docId + e2e | 删除,serializer 回到旧语义 |
| 默认窗口 | 1568×888 | 1280×800(下文解释) |
29 个文件,+476 / −602。新增的是 OCR 那一条路;删掉的是整套清单。
测试把新约束写成了规格,最直接的一条在 browser-actions.test.ts:
test('an observation asks the page nothing: one local capture, no element query', async () => {
// 普通截图、文字截图、navigate 各来一次
...
expect(h.log.filter((l) => l.startsWith('bridge page.'))).toEqual(['bridge page.navigate'])
})
三次观察下来,发往页面的扩展调用只剩 navigate 本身。
2. 观察为什么必须对页面零动作⌗
2.1 第一个理由:检测面⌗
主流的浏览器 agent(Playwright 系、各种 MCP 浏览器服务、带 a11y 树的 computer-use 框架)观察页面的方式都是问页面:拿 DOM、拿无障碍树、在页面里跑一段脚本给元素编号。这对调试自己的页面没有问题。对一个登录着真实账号的第三方网站,每一次「问」都是页面那一侧可能感知的额外动作——内容脚本的注入、密集的 elementFromPoint、读遍整棵树的布局查询,都是人类用户不会产生的痕迹。
MaxClaw 的立场一直是:动手用真鼠标键盘(isTrusted=true 的事件,没有合成事件),看也应该和人一样只用眼睛。之前的清单是个例外——手是真的,眼睛却在页面里翻抽屉。删掉之后,一次观察在页面一侧的足迹就是:
页面能感知到的 页面感知不到的
───────────────────────────── ──────────────────────────
真实的滚动、真实的点击和按键 窗口截图(窗口服务器层面)
一次导航(如果是 navigate) 本机 OCR
tabs.list 取 url/title(扩展 API,不进页面)
url / title 原来顺手从清单里取,现在改从扩展的 tabs.list 拿——那是浏览器层面的 API,不在页面里执行任何代码。
2.2 第二个理由:观察不能被页面拖住⌗
清单还有一个更现实的毛病:它要页面配合才能回来。
设计文档里记过一次根因分析:页面卡在一个阻塞解析的 <script src> 上,文档停在 readyState=loading;扩展的 tabs.executeScript 当时用的是 runAt: "document_idle",要等文档解析完才执行,于是清单查询整段挂到 bridge 的 35 秒超时。复现很简单:本地 http 页面 <head> 里放一个永远不返回的脚本请求,document_idle 下 35001ms 超时,改成 document_start 后 7ms。
那次修完之后又加了一层兜底:清单 6 秒没回来就只返回图,结果里多一栏 elementsUnavailable 写原因,click{element} 遇到没有清单的观察要另外报错。也就是说,为了一个「锦上添花」的清单,观察路径上长出了超时、降级分支、两种错误文案。
截图没有这个问题。screencapture -l 截的是窗口服务器里的像素,页面主线程忙不忙、脚本加载完没完,都不影响它返回。现在 observe() 是一条直线:
await deps.mac.capture(win.id, path)
const native = pngSize(await readFile(path))
// 宽于 1568 才缩
...
deps.observations.set(rec.windowId, { geo, bounds })
if (p.as === 'text') return { ..., text: formatTextLines(await deps.recognizeText(path)) }
return await screenshotResult(png, metrics, p, meta)
Observation 类型也从七个字段缩到两个:图的几何(截图尺寸、原生尺寸)和窗口 bounds。后续点击只需要这两样就能把截图像素换算成屏幕点。
3. 不靠清单,怎么点准⌗
删清单最直觉的担心是精度。设计文档给的实测:在 1080p 截图上按坐标点击,Claude 的落点误差约 4px,DeepSeek 约 20px。对正常大小的按钮,4px 足够;对小目标,工具给了放大镜:
desktop({ action:'screenshot', windowId, region:{x,y,w,h}, grid:true })
// 返回放大图,格线上标的是「原截图坐标」,读出来直接用
desktop({ action:'click', windowId, x, y }) // 永远按原截图坐标
文档里特意写了「不要凭放大图里的像素直接点」——放大图只用来看,坐标系始终只有一套。
第二个配套改动是窗口尺寸。之前窗口宽 1568,是为了等于截图长边上限、截图不缩放。这次改成 1280:
- 页面布局只看 CSS 视口宽度,≥1280 就是完整的桌面布局,常见的响应式断点都在它以下,多出来的 288px 对页面长什么样没有影响;
- 1280×800 在 dpr 1 的屏上截图与窗口 1:1,不缩放,坐标不会因为缩放丢精度;
- 窗口变小,阶梯格位变多。
格位算法 agentWindowLayout 没变:窗口宽高固定,屏幕剩下的高度全部变成 24px 的台阶。几何测试里的数字:
| 可用区 | 之前 | 之后 |
|---|---|---|
| 1920×960 | 4 格,1568×888 | 7 格,1280×816(⌊(960−800)/24⌋+1) |
| 3840×2040 | 49 格,1568×888 | 52 格,1280×816(⌊(2040−800)/24⌋+1) |
每屏截图的像素也少了。按 Anthropic 公开的图像 token 估算(≈ 宽×高 / 750,这是推断,不是本次实测):1568×888 约 1.86k token,1280×800 约 1.37k。
再对比清单本身的成本。设计文档记了一个链接密集的页面:清单 118 条、6682 字符,按 4 字符 ≈ 1 token 约 1.7k,和同屏图片同一量级。之前每次观察是「图 + 清单」两份钱,现在是一份,而且更小。
4. as:'text':只要字,在本机认⌗
只看图有个现实问题:读一篇长文章,每屏一张图,模型会嫌贵,然后倾向于转去用调试通道 browser_debugging 的 read——那正好是在页面里跑脚本,回到了第 2 节的问题。所以这次给了一个「只要字」的选项,而且它在页面一侧和普通截图完全一样:
screencapture -l<id> ──► ≤1568 的 PNG ──┬──► as:'image' → 返回图
│
└──► as:'text' → apple-vision-ocr --lines
→ 阅读顺序排序
→ "(x,y w×h) 文字" 逐行返回,无图
几个设计点:
同一张图,同一套坐标。 OCR 识别的是 observe 里已经缩放好的那张 PNG,所以行框坐标就是这次观察的坐标系。文字截图照样登记进 observations,之后 click{x,y} 按它定位,测试 a text screenshot is still the observation later clicks are aimed by 规定了这一点。模型读到 (420,118 80×30) 提交,可以直接点 (460,133)。
坐标换算在 Swift 那一侧做完。 Apple Vision 的 boundingBox 是归一化、左下原点。helper 新增 --lines 模式,输出前就换成图像素、左上原点:
"x": Int((box.minX * width).rounded(.down)),
"y": Int(((1 - box.maxY) * height).rounded(.down)),
"w": Int((box.width * width).rounded(.up)),
"h": Int((box.height * height).rounded(.up)),
不带 --lines 的输出保持原样,另一个用同一个二进制的 OCR 功能不受影响。TS 侧 vision-lines.ts 只做一次边界解析:JSON 解析失败就报「helper 是旧版,不认 --lines」并指向构建脚本。
阅读顺序很朴素。 inReadingOrder 按行中心排序,中心相差不超过行高一半就算同一行,行内从左到右。测试里专门有一条「大标题和旁边的小字,只有中心接近才同行」,还有一条「只在竖直方向稍微重叠的两行仍然分开」。不做分栏检测:多栏页面会按行交错,但每行都带 x 坐标,模型自己能分。为假想的复杂版面写一个栏检测器,不如把坐标交出去。
失败就失败,不退回图片。 helper 缺失、识别报错,as:'text' 直接 fail-loud;as:'text' 和 savePath / grid / region 同用、用在非 screenshot 动作上、用在非浏览器窗口上,全部报错。测试名写得很直白:a failing recognizer fails the step loudly instead of returning a picture。调用方要的是字,悄悄塞回一张图等于让它以为自己拿到了字。
实测数据(设计文档,中英混排测试页,1568×888 窗口截图):
| 指标 | 数值 |
|---|---|
| 单次识别耗时(含进程启动) | ≈ 0.35–0.5 s |
| 字符准确率(去空白 563 字符,9 处编辑) | 98.4% |
| 按钮文字行位置与真实中心偏差 | ≤ 4 px |
| 典型错误 | 0→O、S→s、x→×、0O1lI 混淆、浏览器 chrome 小字繁简混淆 |
错误全是字形级的,中文段落、URL、报错路径基本全对。所以文档的使用规则是:读内容、找按钮位置用 as:'text';Key、Token、恢复码这种一个字符都不能错的东西,用网页自己的复制按钮或 cmd+c,不要照着 OCR 抄。
一个侧面证据:原来测复制钩子的真窗口 e2e 是靠元素编号找按钮的,现在改成先 as:'text' 拿到文字行,按标签在行内的位置比例算出点击点,再用坐标点击——e2e 本身就成了「文字观察 + 坐标点击能替代清单」的验证。
5. 还剩哪些口子⌗
说「观察对页面零动作」是准确的,说「整个系统对页面零痕迹」就夸大了。现状如下:
| 仍然存在的页面侧动作 | 为什么还在 |
|---|---|
对外发送筛查:点击落在通讯/社交类登记站点上时,点击前查一次 page.visibleElements | 要知道点击点下的控件是不是「发送 / 提交」,以便在以用户身份发出去之前停下等批准;只在登记站点、只在点击前、只查一次 |
滚动结束判定读 page.viewMetrics | 平滑滚动停稳之前页面位置还在变,要等它稳定再返回 |
| 扩展的页面复制钩子,所有 http(s) 页面无条件挂载 | 网页自己的「复制」按钮写入的内容要能被 agent 接住 |
前两项是「为了安全或正确性,在确定的时刻问一次」,和「每次看都问一遍」是两回事。第三项是一个真实的检测面,这次改动没有动它。
还要认个小错:README.md 的 2.8.1 节里有一句「同一次 screencapture -l,同一次元素清单查询」,是改稿时没删干净的旧说法,和同一份文档 2.8 节、和代码、和测试都矛盾。以测试为准:观察不发元素查询。
6. 和 DOM / 无障碍树驱动比⌗
| 维度 | DOM / a11y 树驱动 | 只看像素 + 本地 OCR |
|---|---|---|
| 观察的页面足迹 | 每次都有(脚本、树遍历) | 无 |
| 页面加载卡住时 | 观察跟着卡或要超时降级 | 照常返回当前画面 |
| 定位精度 | 元素级,天然准 | 依赖模型视觉,强模型 ~4px,小目标需放大 |
| 看得到的信息 | 包括 role、aria 等人看不到的属性 | 只有人看得到的 |
| iframe、canvas、浏览器自己的弹层 | 要逐个特殊处理 | 截图里都有,一视同仁 |
| 维护成本 | 编号稳定性、跨 frame、清单超时 | 一个 OCR helper + 一个排序函数 |
| 适合 | 调试自己的页面、批量抓取 | 用真实账号操作第三方网站 |
这不是说 DOM 路线错了——调试和抓包时 MaxClaw 仍然用它,只是放在单独的 browser_debugging 工具里,并明确它会被网站识别。区别在于默认值:在一个登录着真实账号的窗口里,默认的眼睛应该是像素。
代价也很清楚:这条路线把定位精度押在模型的视觉能力上。4px 和 20px 的差距意味着弱一些的模型会更依赖 region 放大、多花一两步。这次的判断是:精度可以靠放大镜补,被风控识别出来的账号补不回来。