跳到主要内容

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:11INTERACTIVE_ROLES)。
  • CONTENT_ROLES(内容)→ 不发 ref,只渲染文本。 heading、cell、listitem、article、main、navigation(snapshot.rsCONTENT_ROLES)。
  • STRUCTURAL_ROLES(结构容器)→ 一般折叠掉。 generic、group、list、table、row……(snapshot.rsSTRUCTURAL_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:368 附近 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_idrole/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.21.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:529[ref=eN] 标记定位插入点)。agent 看到的是一棵连贯的树,跨 iframe 透明。
  • 跨进程 iframe(OOPIF)用独立 CDP session。 RefEntry.frame_id 配合 iframe_sessions 映射,让落在子框架里的 ref 解析时切到对的 session(第 03 章详述)。
  • link 的 href 单独补抓。 AX 树不带 href,代码对 role == "link" 且有 ref 的节点单独跑 Runtime.callFunctionOnthis.href(snapshot.rs:454,链接节点过滤在 :420),让 agent 不点也能知道链接去哪。

1.7 边界与坑

  • 跨域 iframe 可能取不到。 递归快照子框架时错误被静默忽略(snapshot.rs:505 附近注释「cross-origin iframes」),所以某些第三方嵌入内容不会出现在树里。
  • 依赖网站的无障碍质量。 若网站 ARIA 标注差(全是裸 <div>),AX 树就稀;光标可点的兜底能救一部分,但不是万能。

1.8 代码地图

主题文件路径关键符号
快照主函数cli/src/native/snapshot.rstake_snapshot
角色分类cli/src/native/snapshot.rsINTERACTIVE_ROLESCONTENT_ROLESSTRUCTURAL_ROLES
ref 编号计数cli/src/native/snapshot.rsnext_ref_numset_next_ref_num
ref 映射表cli/src/native/element.rsRefMapRefEntry
引用号解析cli/src/native/element.rsparse_ref