agent-browser 源码分析(三):Accessibility Tree 快照
Accessibility Tree 快照原理:如何让 AI 看懂网页
本文是「agent-browser 代码原理」系列第 3 篇,深入剖析 agent-browser 的 snapshot 功能实现。
一、问题的提出
传统浏览器自动化工具(如 Playwright、Selenium)让 LLM 操作网页时面临一个核心问题:
LLM 看不懂原始 HTML。
原始 HTML 充斥着 <div>、<span>、CSS class、内联样式,对 LLM 来说是噪音远大于信号。让 LLM 从中找到"登录按钮"或"搜索框",就像让人从一团乱麻中找一根特定的线。
agent-browser 的解决方案是:输出 Accessibility Tree(无障碍树)。
二、什么是 Accessibility Tree
Accessibility Tree 是浏览器为辅助技术(如屏幕阅读器)构建的页面语义化表示。它只包含对用户体验有意义的元素:
[3] link "登录"
[7] textbox "用户名"
[12] button "提交"
而不是:
<div class="sc-1q2ggg2-0 fPQcwb">
<div class="sc-1q2ggg2-1 jKwXKX">
<button class="btn btn-primary">...</button>
</div>
</div>
2.1 CDP 的 Accessibility Domain
Chrome 通过 Accessibility.getFullAXTree 命令返回完整的 Accessibility Tree:
{
"method": "Accessibility.getFullAXTree",
"params": {},
"id": 1
}
响应示例:
{
"id": 1,
"result": {
"nodes": [
{
"nodeId": "1",
"ignored": false,
"role": {"type": "RootWebArea"},
"name": {"type": "computedString", "value": "Example Domain"},
"childIds": ["2", "3"]
},
{
"nodeId": "2",
"ignored": false,
"role": {"type": "link"},
"name": {"type": "computedString", "value": "More information..."},
"childIds": []
}
]
}
}
关键洞察:AX 节点是扁平数组,通过 childIds 重建层级关系。
三、Snapshot 的整体流程
snapshot.rs(1586 行)实现了完整的 snapshot 功能。整体流程如下:
1. 启用 Accessibility Domain
2. 获取完整 AX Tree(扁平数组)
3. 重建树结构(通过 childIds)
4. 过滤节点(按角色类型)
5. 生成文本表示(缩进层级)
6. 分配 @ref(可交互元素映射)
7. 返回 snapshot + refs 映射表
3.1 入口函数
pub async fn take_snapshot(
client: &CdpClient,
session_id: &str,
options: &SnapshotOptions,
ref_map: &mut RefMap,
frame_id: Option<&str>,
iframe_sessions: &HashMap<String, String>,
) -> Result<String, String> {
// 1. 启用必要 Domain
client.send_command_no_params("DOM.enable", Some(session_id)).await?;
client.send_command_no_params("Accessibility.enable", Some(session_id)).await?;
// 2. 处理 CSS selector(如果用户指定了)
let selector_backend_ids = if let Some(ref selector) = options.selector {
resolve_selector_backend_ids(client, session_id, selector).await?
} else {
None
};
// 3. 获取 AX Tree
let (ax_params, effective_session_id) =
resolve_ax_session(frame_id, session_id, iframe_sessions);
let ax_tree: GetFullAXTreeResult = client
.send_command_typed("Accessibility.getFullAXTree", &ax_params, Some(effective_session_id))
.await?;
// 4. 构建树
let (mut tree_nodes, root_indices) = build_tree(&ax_tree.nodes);
// 5. 处理 selector 过滤
let effective_roots = if let Some(ref id_set) = selector_backend_ids {
filter_roots_by_selector(&tree_nodes, root_indices, id_set)
} else {
root_indices
};
// 6. 生成文本表示
let snapshot = format_snapshot(&tree_nodes, &effective_roots, options, ref_map)?;
Ok(snapshot)
}
四、树重建:从扁平到层级
4.1 核心数据结构
pub struct AXNode {
pub node_id: String,
pub ignored: bool,
pub role: AXValue,
pub name: AXValue,
pub description: Option<AXValue>,
pub value: Option<AXValue>,
pub child_ids: Option<Vec<String>>,
pub backend_node_id: Option<i64>,
// ... 其他字段
}
4.2 重建算法
fn build_tree(nodes: &[AXNode]) -> (Vec<TreeNode>, Vec<usize>) {
// Step 1: 创建 node_id → index 的映射
let mut id_to_idx: HashMap<String, usize> = HashMap::new();
for (i, node) in nodes.iter().enumerate() {
id_to_idx.insert(node.node_id.clone(), i);
}
// Step 2: 创建 TreeNode(增加 parent 和 children 字段)
let mut tree_nodes: Vec<TreeNode> = nodes.iter().map(|n| TreeNode {
ax_node: n.clone(),
parent: None,
children: Vec::new(),
}).collect();
// Step 3: 建立父子关系
let mut root_indices = Vec::new();
for (i, node) in nodes.iter().enumerate() {
if let Some(ref child_ids) = node.child_ids {
for child_id in child_ids {
if let Some(&child_idx) = id_to_idx.get(child_id) {
tree_nodes[child_idx].parent = Some(i);
tree_nodes[i].children.push(child_idx);
}
}
}
// 没有 parent 的节点是根节点
if tree_nodes[i].parent.is_none() {
root_indices.push(i);
}
}
(tree_nodes, root_indices)
}
时间复杂度:O(n),每个节点只被处理一次。
五、节点过滤与角色分类
不是所有 AX 节点都应该显示在 snapshot 中。agent-browser 将角色分为三类:
const INTERACTIVE_ROLES: &[&str] = &[
"button", "link", "textbox", "checkbox", "radio", "combobox",
"listbox", "menuitem", "slider", "switch", "tab", "treeitem",
// ... 可交互角色
];
const CONTENT_ROLES: &[&str] = &[
"heading", "cell", "gridcell", "listitem", "article", "region",
// ... 内容角色
];
const STRUCTURAL_ROLES: &[&str] = &[
"generic", "group", "list", "table", "row", "WebArea", "RootWebArea",
// ... 结构角色
];
5.1 默认过滤策略
fn should_include_node(node: &TreeNode, options: &SnapshotOptions) -> bool {
let role = get_role_name(&node.ax_node.role);
// 默认只包含可交互和内容角色
if !INTERACTIVE_ROLES.contains(&role.as_str())
&& !CONTENT_ROLES.contains(&role.as_str()) {
return false;
}
// interactive 模式:只显示可交互元素
if options.interactive && !INTERACTIVE_ROLES.contains(&role.as_str()) {
return false;
}
// 忽略无文本内容的节点
let name = get_computed_name(&node.ax_node.name);
if name.trim().is_empty() && role != "textbox" && role != "combobox" {
return false;
}
true
}
六、Ref 映射系统
6.1 为什么需要 @ref?
Accessibility Tree 输出后,用户(或 LLM)需要操作特定元素。但元素的 nodeId 是动态分配的,每次 snapshot 都可能变化。
agent-browser 的解决方案:分配稳定的 @ref。例如:
[3] link "登录"
用户可以说 click @3,agent-browser 通过 RefMap 找到对应的 DOM 元素并点击。
6.2 RefMap 数据结构
pub struct RefEntry {
pub backend_node_id: Option<i64>, // CDP backendNodeId(稳定标识)
pub role: String, // 角色
pub name: String, // 名称
pub nth: Option<usize>, // 同名元素的序号
pub selector: Option<String>, // CSS 选择器(备用)
pub frame_id: Option<String>, // 所属 iframe
}
pub struct RefMap {
map: HashMap<String, RefEntry>,
next_ref: usize,
}
6.3 处理同名元素
当多个元素具有相同的 role 和 name 时,通过 nth 区分:
[7] button "提交" (第一个"提交"按钮)
[8] button "提交" (第二个"提交"按钮)
实现逻辑:
impl RefMap {
pub fn add_with_frame(
&mut self,
ref_id: String,
backend_node_id: Option<i64>,
role: &str,
name: &str,
nth: Option<usize>,
frame_id: Option<&str>,
) {
self.map.insert(
ref_id,
RefEntry {
backend_node_id,
role: role.to_string(),
name: name.to_string(),
nth,
selector: None,
frame_id: frame_id.map(|s| s.to_string()),
},
);
}
}
七、iframe 穿透
现代网页大量使用 iframe(如嵌入的地图、广告、支付表单)。agent-browser 的 snapshot 支持跨 iframe 的 Accessibility Tree 获取。
7.1 iframe Session 管理
每个 iframe 在 CDP 中是一个独立的 Target,需要 attach 后才能操作:
// 当检测到 iframe 时
let attach_result: AttachToTargetResult = client
.send_command_typed(
"Target.attachToTarget",
&AttachToTargetParams {
target_id: frame_id.to_string(),
flatten: Some(true),
},
Some(session_id),
).await?;
// 存储 iframe session ID
iframe_sessions.insert(frame_id.to_string(), attach_result.session_id);
7.2 解析元素时的 session 选择
pub async fn resolve_ax_session(
frame_id: Option<&str>,
session_id: &str,
iframe_sessions: &HashMap<String, String>,
) -> (GetFullAXTreeParams, String) {
if let Some(frame_id) = frame_id {
if let Some(iframe_session) = iframe_sessions.get(frame_id) {
return (
GetFullAXTreeParams { frame_id: Some(frame_id.to_string()) },
iframe_session.clone(),
);
}
}
(
GetFullAXTreeParams { frame_id: None },
session_id.to_string(),
)
}
八、文本格式化输出
8.1 输出格式
fn format_node(node: &TreeNode, depth: usize, ref_id: Option<&str>) -> String {
let indent = " ".repeat(depth);
let role = get_role_name(&node.ax_node.role);
let name = get_computed_name(&node.ax_node.name);
if let Some(ref_id) = ref_id {
format!("{}[{}] {} \"{}\"", indent, ref_id, role, name)
} else {
format!("{}{} \"{}\"", indent, role, name)
}
}
输出示例:
- generic
- link [ref=e4]
- image
- generic
- link "消息" [ref=e6]
- StaticText "消息"
- generic
- link "CSDN首页" [ref=e1]
- image "CSDN首页"
- link "创作中心" [ref=e2]
- button "CSDN同步助手" [ref=e3]
8.2 compact 模式
当 options.compact = true 时,只显示可交互元素:
[1] link "CSDN首页"
[2] link "创作中心"
[3] button "CSDN同步助手"
[6] link "消息"
九、性能优化
9.1 缓存策略
agent-browser 不会缓存 Accessibility Tree,因为页面可能随时变化。但每次 snapshot 调用都会重新获取完整的 AX Tree,这在复杂页面上可能较慢。
优化方向(代码中已部分实现): - 使用 selector 参数只获取部分子树 - maxDepth 限制遍历深度
9.2 并发获取 iframe 树
对于包含多个 iframe 的页面,可以并发获取每个 iframe 的 AX Tree:
let mut iframe_futures = Vec::new();
for (frame_id, iframe_session) in iframe_sessions {
iframe_futures.push(async move {
take_snapshot(client, iframe_session, options, ref_map, Some(frame_id), iframe_sessions).await
});
}
let iframe_snapshots = futures::future::join_all(iframe_futures).await;
十、总结
agent-browser 的 snapshot 功能将 Chrome 的 Accessibility Tree 转化为 AI 友好的结构化表示:
| 步骤 | 技术要点 | 文件 |
|---|---|---|
| 获取 AX Tree | Accessibility.getFullAXTree |
snapshot.rs |
| 重建层级 | childIds → parent/children |
snapshot.rs |
| 过滤节点 | 按 role 类型分类 | snapshot.rs |
| 分配 @ref | RefMap + backend_node_id |
element.rs |
| iframe 穿透 | Target.attachToTarget |
cdp/client.rs |
| 格式化输出 | 缩进文本 + ref 标记 | snapshot.rs |
这套设计使得 LLM 可以像人类一样"阅读"网页结构,而不需要处理复杂的 DOM。
系列文章: 1. agent-browser 架构概览:从 CLI 到 CDP 的分层设计 2. CDP WebSocket 客户端实现:命令/响应匹配与事件广播 3. Accessibility Tree 快照原理:如何让 AI 看懂网页 ← 本文 4. Chrome 进程管理与多 Backend 架构 5. Network 拦截与路由:Fetch Domain 实战
更多推荐



所有评论(0)