本节摘要:本节打开 act 管道里「候选发现与目标选择」的黑盒,讲 DOM 流派的定位体系。第一层是选择器——精确但脆,适合写死在代码里的稳定结构;第二层是文本与角色匹配——语义定位(get_by_role、get_by_label 一族),把「用户能看见什么」变成定位依据;第三层是模型选择——把候选列表连同上下文交给模型挑,元素标注与编号点击是它的工程形态:从 DOM 枚举可交互元素、过滤可见性、打上编号,模型只需回答「选几号」,这正是《深入理解 AI Agent》第 9 章讲的 Set-of-Mark 思想在 Web 上的结构化实现,也是 browser-use 的核心机制。本节末尾给出定位失败的三种典型(找不到、找到多个、点不动)与各自的对策。读完本节,「框架怎么替你找元素」不再神秘。
| 层 | 手段 | 定位依据 | 精确度 | 脆性 | 适用 |
|---|---|---|---|---|---|
| 一 | CSS / XPath 选择器 | 结构与属性 | 高 | 高 | 结构稳定的自家页面、固化路径(第 3.3 节) |
| 二 | 文本与角色匹配 | 语义(角色、名称、标签) | 中 | 低 | 面向用户的可见文本稳定的页面 |
| 三 | 模型选择 | 候选清单加任务上下文 | 概率 | 最低 | 动态页面、无法预写定位的通用任务 |
三层不是替代关系而是漏斗关系:候选发现先用一、二层把「可能相关」的元素圈出来,第三层把最终裁决交给模型。Playwright 官方定位建议本身就是这个次序——优先角色与文本,选择器兜底;浏览器智能体框架只是把第三层制度化(browser-use 的索引、Stagehand 的指令定位、Playwright MCP 的 ref,都是第三层的变体,第 4.2 节对照)。
理解脆性差异的关键在「页面改版改的是什么」:改版常改结构(嵌套、类名、顺序),少改可见文本与语义角色(「添加」按钮改版后通常还叫「添加」)。因此第二层天然比第一层活得久,第三层甚至能吸收掉一部分语义改写。
候选发现的第一步是枚举「页面上能操作的东西」。判定标准取自 HTML 的交互语义:链接、按钮、输入框、下拉选择、可编辑区域等角色,再加上可见性过滤(在视口外或被隐藏的元素先剔除)。用 Playwright 手工做一遍,就能看见框架替你做的事:
# list_interactive.py —— 枚举本地测试页的可交互元素(语义定位演示) import asyncio from playwright.async_api import async_playwright async def main() -> None: async with async_playwright() as p: browser = await p.chromium.launch(headless=True) page = await browser.new_page() await page.goto("http://127.0.0.1:8080/demo_todo.html") print("页面标题:", await page.title()) buttons = page.get_by_role("button") print("按钮数量:", await buttons.count()) for i in range(await buttons.count()): print(" 按钮:", await buttons.nth(i).inner_text()) links = page.get_by_role("link") print("链接数量:", await links.count()) for i in range(await links.count()): print(" 链接:", await links.nth(i).inner_text()) box = page.get_by_label("新待办") print("输入框可见:", await box.is_visible()) await browser.close() asyncio.run(main())
对第 0.2 节的靶场页面,期望输出是:一个按钮「添加」、一个链接「关于」、一个可见的「新待办」输入框。TypeScript 栈的等价写法(Playwright 原生接口,Stagehand 的 page 同样可用,因它兼容 Playwright 页面对象):
// list_interactive.mjs —— 枚举可交互元素的 TS 栈写法(写法示意) import { chromium } from "playwright"; const browser = await chromium.launch({ headless: true }); const page = await browser.newPage(); await page.goto("http://127.0.0.1:8080/demo_todo.html"); const buttons = page.getByRole("button"); console.log("按钮数量:", await buttons.count()); const links = page.getByRole("link"); console.log("链接数量:", await links.count()); const box = page.getByLabel("新待办"); console.log("输入框可见:", await box.isVisible()); await browser.close();
注意这里全程没有出现任何 CSS 选择器——role 与 label 就是第二层定位的日常形态,也是浏览器无障碍能力的直接消费方(第 4 章的无障碍树正是同一机制的协议化)。两个栈的 API 命名风格不同(Python 侧下划线方法名、JS 侧驼峰方法名),语义一一对应:count 对 count、is_visible 对 isVisible。
可见性过滤不只是省 token:被隐藏的元素(折叠面板里、对话框后)点了会报错或点错,先剔除能让候选清单与「用户视角」对齐——用户看不见的,智能体也不该去点,这条原则在第 8 章反检测语境下还有合规意义。
第三层的工程形态:给候选元素编号,让模型用编号下指令。完整流程五步:
① DOM 枚举:找出全部可交互元素(上一节的机制) ② 可见性过滤:剔除隐藏与视口外元素 ③ 标注编号:给每个幸存元素打上 [1][2][3] 式索引 ④ 序列化:编号 + 角色 + 可见文本 + 关键属性 → 文本清单 ⑤ 模型裁决:模型按任务上下文输出「对 N 号执行什么动作」 │ ▼ 框架把「N 号」还原成真实元素引用,驱动浏览器执行
喂给模型的候选清单长这样(示意,对应靶场页面):
# candidates_demo.txt —— 元素标注编号清单示意(序列化形态节选) [1] 按钮 「添加」 [2] 输入框 标签「新待办」 当前值:空 [3] 链接 「关于」 [4] 标题 「今天要做的事」 任务上下文:添加待办 买牛奶
模型此时要输出的只是「往 2 号填入买牛奶,点击 1 号」。《深入理解 AI Agent》第 9 章把这套做法讲成 Set-of-Mark 思想的 Web 结构化实现——与其让模型在像素里猜按钮,不如直接问页面「你有哪些可交互元素」,并指出 browser-use 是其代表实现;该章还配了一个 browser-use 入门实验(实验 9-7),可与本书第 0.2 节的最小任务互参。
编号点击为什么能成为 DOM 流派的主流解,三条理由:上下文省——序列化清单比整棵 DOM 小一到两个数量级,比截图更小(第 4.1 节有对照);误点率低——候选已被一、二层筛过,模型只在「相关项」里挑,不会点中广告或页脚;执行确定——模型只出编号不出坐标,编号到元素的映射由框架持有,点击落点永远精确到元素。它的代价是对页面语义质量的依赖:候选清单里全是无角色无文本的元素时,第三层退化为瞎猜——这正是视觉流派的存在理由(第 5.1 节)。
| 典型 | 现象 | 根因 | 对策 |
|---|---|---|---|
| 找不到 | 候选为空或目标缺席 | 元素未渲染(懒加载、异步)、在视口外、在 iframe 里 | 等待策略;滚动进视口;iframe 切换 |
| 找到多个 | 同名候选若干,模型随机挑 | 文本或角色歧义 | 指令加约束(区域、序号、邻近元素);用更具体的语义锚 |
| 找到点不动 | 命中目标但点击无效 | 遮挡层、动画未结束、事件绑定晚于点击 | 等待可交互状态;重试;换触发方式(键盘) |
前两种在 act 一步内多能自愈(框架的重试与等待兜着),真正的风险是第三种与「静默错点」——点了 A 以为点了 B,无报错无异常。这类问题无法靠定位层解决,只能靠结果验证捕获,那是第 6 章的主场;本节能给出的最好习惯是:调试任何定位问题时先 observe 一遍(第 1.1 节的干跑),让候选清单自己说话。
等待策略补一句:隐式等待(全局超时内轮询元素出现)适合「元素迟早出现」的场景;显式等待(等特定状态:可见、可编辑、网络空闲)适合时序敏感的页面。原则是显式优先——隐式等待把「等多久」藏起来,排障时最费人。
机制讲完,该回到靶场把三原语完整跑一遍了。下一节用本地测试页串起 observe、act、extract 三步,每一步的中间产物都摊开给你看,并附 Stagehand 指令式与 Playwright 代码式两版对照。