题记: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×8881280×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×9604 格,1568×8887 格,1280×816(⌊(960−800)/24⌋+1)
3840×204049 格,1568×88852 格,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 放大、多花一两步。这次的判断是:精度可以靠放大镜补,被风控识别出来的账号补不回来。