数据截至 (上游 commit 548b159b30ee)
01 — 快照与 @e 引用
本章讲什么: agent-browser 存在的根本理由。为什么用无障碍树而不是 DOM;
snapshot怎么把树渲染成带@e2的文本;RefMap怎么把这些编号绑到真实节点,让后续的click @e2能找回去。
1.1 它要解决的小问题
AI 操作网页,最脆的一环是**「指认元素」**。让模型生成 #submit-btn 这种选择器,有三个死穴:
- 类名 / id 随构建变化,模型记的是旧的;
- 同一个按钮在不同状态下选择器不同;
- 被 cookie 横幅、广告、模态框遮挡时,选择器还在但点不到。
agent-browser 的答案:别让 agent 猜,先把页面真实可交互结构喂给它,再让它引用编号。
1.2 思路:为什么用「无障碍树」而不是 DOM
DOM 有几千个节点,大半是 <div> 套 <span> 的装饰。无障碍树(accessibility tree) 是浏览器为屏幕阅读器算出来的「语义骨架」——只有有意义的节点,每个带 role(按钮/文本框/链接)和 name(可见标签)。
用 AX 树当 agent 的「视野」有三个好处:
| 好处 | 为什么 |
|---|---|
| 稳定 | role+name 来自语义,不随 CSS class 变化 |
| 干净 | 不可见 / aria-hidden 节点天然不在树里 |
| 省 token | 几千 DOM 节点压成几十行,模型读得起 |
一句话直觉:无障碍树就是「盲人版网页」——把视觉噪音全滤掉,只留下「这是一个叫 Sign in 的按钮」这种语义。 这恰好是 agent 需要的视野。
1.3 哪些元素配引用号:三类角色
不是每个 AX 节点都发 @e 号。snapshot.rs 把 role 分成三类:
- INTERACTIVE_ROLES(可交互)→ 一定发 ref。 button、link、textbox、checkbox、radio、combobox、menuitem、tab、slider、Iframe……(
cli/src/native/snapshot.rs:11的INTERACTIVE_ROLES)。 - CONTENT_ROLES(内容)→ 不发 ref,只渲染文本。 heading、cell、listitem、article、main、navigation(
snapshot.rs的CONTENT_ROLES)。 - STRUCTURAL_ROLES(结构容器)→ 一般折叠掉。 generic、group、list、table、row……(
snapshot.rs的STRUCTURAL_ROLES)。
判定逻辑在 take_snapshot 里:
// snapshot.rs 真实片段(节选)
let mut should_ref = if INTERACTIVE_ROLES.contains(&role) {
true // 交互角色一定发 ref
} else if CONTENT_ROLES.contains(&role) {
!node.name.is_empty() // 内容角色:有可见名才发 ref(供按名定位)
} else {
false // 结构容器默认不发
};
注意 CONTENT_ROLES 分支:内容角色本身不可交互,但只要带非空 name 也会拿到一个 ref,好让 agent 能按名引用一段标题 / 单元格做读取类操作——并非「只有交互角色才发 ref」。
额外一条:即使 role 不在交互表里,如果元素鼠标光标是 pointer(说明设计上可点),也会补发 ref——snapshot.rs:383 附近 should_ref = true,对应注释「ref elements that are cursor-interactive」。这抓住了那些用 <div onclick> 假装按钮的网站。
1.4 引用号怎么和真实节点绑定:RefMap
这是本章技术核心。@e2 只是个字符串,要能「点回去」,必须存一张编号 → 真实节点的表。这张表就是 RefMap。
表里存什么
每个引用号对应一个 RefEntry(cli/src/native/element.rs:8):
// element.rs:8 真实定义
pub struct RefEntry {
pub backend_node_id: Option<i64>, // CDP 里节点的稳定 id(主路径)
pub role: String, // 角色(用于 stale 后按 role+name 重查)
pub name: String, // 可见名
pub nth: Option<usize>, // 第几个同名元素
pub selector: Option<String>, // 某些来源用 CSS 选择器兜底
pub frame_id: Option<String>, // 在哪个 iframe(跨进程 iframe 关键)
}
为什么同时存 backend_node_id 和 role/name? backendNodeId 是 CDP 给 DOM 节点的稳定 id,是快路径;但页面一变它可能失效(stale)。这时 role+name+nth 就是「重新找到这个元素」的兜底依据——见第 03 章的容错重查。
引用号怎么解析
parse_ref(element.rs:124)把用户输入的 @e2 / ref=e2 归一成内部 key e2:
// element.rs:124 真实片段(节选)
pub fn parse_ref(input: &str) -> Option<String> {
let trimmed = input.trim();
if let Some(stripped) = trimmed.strip_prefix('@') {
// @e2 → 要求 e 后面全是数字
if stripped.starts_with('e') && stripped[1..].chars().all(|c| c.is_ascii_digit()) {
return Some(stripped.to_string());
}
}
// 也接受 ref=e2 写法 ...
}
严格校验「e + 纯数字」很重要:它让 @e2 和真实 CSS 选择器(如 @media、#email)能被无歧义区分——不是合法 ref 的就当普通选择器走。
1.5 一次快照的完整数据流
把 1.2–1.4 串起来,take_snapshot(snapshot.rs:216)端到端做这些事:
1. DOM.enable + Accessibility.enable ← 打开 CDP 的两个域
2. (可选) 若给了 CSS selector,先 querySelector ← 只快照某子树
解析出该子树所有 backendNodeId 当作根
3. Accessibility.getFullAXTree ← 一把抓整棵无障碍树
4. 遍历树:对每个节点判 should_ref
├─ 是交互/光标可点 → 分配 e{N},写进 RefMap
└─ 累加 next_ref 计数器(跨快照不重置,见下)
5. 对 link 节点额外抓 href(Runtime.callFunctionOn)
6. 渲染成缩进文本,交互节点行尾打 [ref=eN]
7. 遇到 Iframe 节点 → 递归快照子框架,结果插到该行下
第 4 步的「分配编号」用一个单调递增计数器 next_ref:
// snapshot.rs 真实片段(节选)
let mut next_ref: usize = ref_map.next_ref_num();
...
let ref_id = format!("e{}", next_ref);
next_ref += 1;
...
ref_map.set_next_ref_num(next_ref);
这意味着引用号在 RefMap 生命周期内不复用:连拍两次快照,第二次的编号接着第一次往后排,避免 @e2 在两次快照间指向不同元素的混乱。
1.6 巧妙之处
- iframe 递归 + 行内插入。 子框架的快照不是另起一段,而是插在父级 Iframe 行的正下方、缩进一级(
snapshot.rs:544找[ref=eN]标记定位插入点)。agent 看到的是一棵连贯的树,跨 iframe 透明。 - 跨进程 iframe(OOPIF)用独立 CDP session。
RefEntry.frame_id配合iframe_sessions映射,让落在子框架里的 ref 解析时切到对的 session(第 03 章详述)。 - link 的 href 单独补抓。 AX 树不带 href,代码对
role == "link"且有 ref 的节点单独跑Runtime.callFunctionOn取this.href(snapshot.rs:469,链接节点过滤在:420),让 agent 不点也能知道链接去哪。
1.7 边界与坑
- 跨域 iframe 可能取不到。 递归快照子框架时错误被静默忽略(
snapshot.rs:520附近注释「cross-origin iframes」),所以某些第三方嵌入内容不会出现在树里。 - 依赖网站的无障碍质量。 若网站 ARIA 标注差(全是裸
<div>),AX 树就稀;光标可点的兜底能救一部分,但不是万能。
1.8 代码地图
| 主题 | 文件路径 | 关键符号 |
|---|---|---|
| 快照主函数 | cli/src/native/snapshot.rs | take_snapshot |
| 角色分类 | cli/src/native/snapshot.rs | INTERACTIVE_ROLES、CONTENT_ROLES、STRUCTURAL_ROLES |
| ref 编号计数 | cli/src/native/snapshot.rs | next_ref_num、set_next_ref_num |
| ref 映射表 | cli/src/native/element.rs | RefMap、RefEntry |
| 引用号解析 | cli/src/native/element.rs | parse_ref |