跳到主要内容

03 — 动作落地与遮挡校验

本章讲什么: 一个 @e2 怎么变成屏幕上的真实点击。重点是两个 agent 友好的设计:stale ref 容错重查点击前遮挡校验——这两点决定了 agent 操作的鲁棒性。

3.1 从引用到坐标:resolve_element_center

动作层最核心的解析函数是 resolve_element_center(cli/src/native/element.rs:299),它把「用户给的目标」变成 (x, y, session_id)。输入可能是 @e2(ref)也可能是 #email(CSS 选择器),两条路:

resolve_element_center("@e2" 或 "#email")

├─ parse_ref 命中(是 @eN)
│ ├─ RefMap.get → RefEntry
│ ├─ 切到正确的 frame session(iframe 用)
│ ├─ 快路径: 有 backend_node_id
│ │ → scrollIntoView → DOM.getBoxModel → 算中心点
│ │ → check_node_interception(遮挡校验)
│ └─ 快路径失败(stale) → 慢路径: 按 role+name+nth 重查节点

└─ 不是 ref → 当 CSS 选择器
├─ 有活动 frame 选择 → 在该 frame 内 querySelector
└─ 否则文档根 querySelector

快路径:缓存的 backendNodeId

// element.rs:313 附近(节选)
if let Some(backend_node_id) = entry.backend_node_id {
scroll_node_into_view(client, effective_session_id, backend_node_id).await;
let result = client.send_command_typed("DOM.getBoxModel", ...).await;
if let Ok(r) = result {
let (x, y) = box_model_center(&r.model);
check_node_interception(client, ..., x, y).await?; // 遮挡校验
return Ok((x, y, effective_session_id.to_string()));
}
// backend_node_id is stale; 落到下面按 role/name 重查
}

DOM.getBoxModel 返回的盒模型坐标是视口相对的,所以先 scrollIntoView 把元素滚进可见区——否则坐标落在视口外,输入事件打不中(scroll_node_into_view 的注释说得很清楚)。

3.2 容错亮点:stale ref 的 role+name 重查

网页是活的:一次 snapshot 后页面可能重渲染,backendNodeId 失效。很多自动化工具到这就直接报「element not found」。agent-browser 不:它用 RefEntry 里冗余存的 role/name/nth 重新在无障碍树里找回这个元素:

// element.rs:344 附近(节选)
// Fallback: re-query the accessibility tree to find a fresh node by role/name
let fresh_id = find_node_id_by_role_name(
client, session_id,
&entry.role, &entry.name, entry.nth,
entry.frame_id.as_deref(), iframe_sessions,
).await?;
scroll_node_into_view(client, effective_session_id, fresh_id).await;
// 再 getBoxModel + 遮挡校验,和快路径一样

这正是第 01 章「为什么 RefEntry 既存 backendNodeId 又存 role/name」的回报:快路径用 id(快),失效了用语义(稳)。对 agent 来说,@e2 在页面小变动后依然可用,大幅减少「重新 snapshot 再重试」的回合。

3.3 鲁棒性亮点:点击前的遮挡校验

这是 README 开头就强调的特性:

Clicks fail early when another element covers the target's click point, for example a consent banner or modal.

实现是 check_node_interception(element.rs:400 附近)。拿到目标坐标 (x, y) 后,先别急着点,而是在页面里跑一段 JS 做命中测试:

// element.rs:430 附近,注入页面执行的 JS(节选)
function(x, y) {
let topDoc = this.ownerDocument || document;
// 一路向上穿过 iframe 到顶层文档(坐标是顶层视口空间)
while (topDoc.defaultView && topDoc.defaultView.frameElement) {
topDoc = topDoc.defaultView.frameElement.ownerDocument;
}
const blockerAt = /* BLOCKER_AT_JS */;
return blockerAt(topDoc, this, x, y); // 返回遮挡者描述,或空
}

逻辑直觉:「如果我现在在 (x,y) 点一下,真正接到这一下的是不是我想点的元素?」 blockerAt 内部本质是 document.elementFromPoint(x, y),看命中的是不是目标本身(或其后代)。若命中的是别的东西(cookie 横幅、模态遮罩),就返回那个遮挡者:

// element.rs:455 附近(节选)
if let Some(blocker) = value.get("result").and_then(|r| r.get("value")).and_then(|v| v.as_str()) {
return Err(intercepted_error(target, blocker)); // 提前报错,指名遮挡者
}

为什么这对 agent 价值大: 盲点一个被遮挡的按钮,通常会「假成功」——点到了横幅,页面没反应,agent 却以为点中了,后续全错。提前报错并告诉 agent 是谁挡着,agent 就能先去关横幅(click @遮挡者 或 dismiss),再重试原目标。错误信息把「下一步该怎么办」也交代了。

best-effort 设计: 命中测试若自身解析失败(DOM.resolveNode 出错等),代码选择跳过校验而非阻断点击(check_node_interception 多处 return Ok(()))。宁可漏报也不误杀。

3.4 派发真实输入事件

拿到干净的 (x, y) 后,真正的点击通过 CDP Input.dispatchMouseEvent 在该坐标派发(交互细节在 cli/src/native/interaction.rs)。这是真实输入事件,不是 element.click() JS 调用——所以能触发 hover 态、:active、原生表单行为,跟人手点的几乎一致。

类似地,fill / type 经键盘事件,drag 经一连串鼠标按下/移动/松开。所有这些都从同一个「解析目标 → 坐标 → 派发事件」骨架出发。

3.5 跨进程 iframe(OOPIF)的 session 切换

现代 Chrome 把跨域 iframe 放进独立渲染进程(out-of-process iframe),它们有各自的 CDP session。resolve_element_centerRefEntry.frame_id 配合 iframe_sessions: HashMap<frame_id, session_id> 切到正确 session:

// element.rs:311 附近(节选)
let effective_session_id =
resolve_frame_session(entry.frame_id.as_deref(), session_id, iframe_sessions);

注释还点出一个微妙处:盒模型坐标在顶层视口空间,所以遮挡校验的命中测试要从顶层文档起跳(见 3.3 的 frameElement 向上走);但对 OOPIF,frameElement 在进程边界就停了,而此时该框架自己的文档和 session 局部坐标已经一致——恰好对得上(element.rs:425 附近注释)。

3.6 代码地图

主题文件路径关键符号
ref/选择器 → 坐标cli/src/native/element.rsresolve_element_center
ref → objectId(用于 JS 调用)cli/src/native/element.rsresolve_element_object_id
stale ref 重查cli/src/native/element.rsfind_node_id_by_role_name
遮挡命中测试cli/src/native/element.rscheck_node_interceptionintercepted_error
滚动进视口cli/src/native/element.rsscroll_node_into_view
iframe session 切换cli/src/native/element.rsresolve_frame_session
真实输入派发cli/src/native/interaction.rsInput.dispatchMouseEvent(CDP)