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 实战

Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐