Nanobrowser 源代码解析:可点击元素检测与哈希去重——智能体怎么「看」页面
从源码看 Nanobrowser 如何遍历 DOM 树、用分支路径哈希识别元素、用迭代栈替代递归防栈溢出。爬虫开发者从中可以学到页面元素定位的核心机制。
引言:智能体需要知道页面上什么可以点
对于 AI 浏览器智能体来说,读取页面内容只是第一步。更关键的能力是:知道页面上哪些元素可以交互,并且能精确地定位到它们。
Nanobrowser 通过三个步骤解决这个问题:
- DOM 构建——把原始 HTML 转成一棵可遍历的 DOMElementNode 树
- 可点击元素检测——遍历 DOM 树,找出所有可以交互的元素
- 元素哈希去重——给每个元素一个唯一的指纹,避免重复操作
这三个步骤分别实现在 browser/dom/service.ts、browser/dom/clickable/service.ts 和 browser/dom/views.ts 中。
步骤一:DOM 树构建
当智能体需要获取页面状态时,调用 _buildDomTree。
browser/dom/service.ts:133-280
async function _buildDomTree(
tabId: number,
url: string,
showHighlightElements = true,
focusElement = -1,
viewportExpansion = 0,
debugMode = false,
): Promise<[DOMElementNode, Map<number, DOMElementNode>]> {
// about:blank 或 chrome:// 页面返回最小 DOM 树
if (isNewTabPage(url) || url.startsWith('chrome://')) {
const elementTree = new DOMElementNode({
tagName: 'body',
xpath: '', attributes: {}, children: [],
isVisible: false, isInteractive: false,
isTopElement: false, isInViewport: false,
parent: null,
});
return [elementTree, new Map()];
}
// 注入 buildDomTree 到页面
await injectBuildDomTreeScripts(tabId);
// 在主 frame 中执行 buildDomTree
const mainFrameResult = await chrome.scripting.executeScript({
target: { tabId },
func: args => window.buildDomTree(args),
args: [{ showHighlightElements, focusHighlightIndex: focusElement,
viewportExpansion, startId: 0, startHighlightIndex: 0, debugMode }],
});
// ... 后续用 _constructDomTree 将原始节点转为 DOMElementNode 树
}关键细节:
-
脚本注入检查——
injectBuildDomTreeScripts先检查目标页面是否已经注入了buildDomTree函数,避免重复注入。这个检查遍历所有 frame,只注入缺少脚本的 frame(service.ts:340-380)。 -
多 frame 拼接——主 frame 构建完 DOM 树后,
constructFrameTree处理 iframe 中的内容。它通过匹配 iframe 的宽高、src、name 来定位应该拼接的位置。如果某个 iframe 加载失败(跨域策略等),它会被标记为失败但不阻塞整体构建(service.ts:175-240)。 -
两阶段构建——
_constructDomTree先创建所有节点(不关联父子关系),再建立树结构。这避免了循环引用和未定义节点问题(service.ts:285-320)。
步骤二:可点击元素检测
源码位置
browser/dom/clickable/service.ts:17-48
export function getClickableElements(
domElement: DOMElementNode
): DOMElementNode[] {
const clickableElements: DOMElementNode[] = [];
const stack: DOMElementNode[] = [];
// 根节点的所有子节点逆序入栈
for (let i = domElement.children.length - 1; i >= 0; i--) {
const child = domElement.children[i];
if (child instanceof DOMElementNode) {
stack.push(child);
}
}
while (stack.length > 0) {
const node = stack.pop() as DOMElementNode;
// 当前节点有 highlightIndex → 是可点击元素
if (node.highlightIndex !== null) {
clickableElements.push(node);
}
// 子节点逆序入栈(保持文档顺序)
for (let i = node.children.length - 1; i >= 0; i--) {
const child = node.children[i];
if (child instanceof DOMElementNode) {
stack.push(child);
}
}
}
return clickableElements;
}这个函数把一个 DOMElementNode 树中所有 highlightIndex !== null 的节点收集起来。
为什么用迭代栈替代递归
注释写得很清楚:to avoid "Maximum call stack size exceeded" errors on deep DOMs。
DOM 树的深度在某些页面中可以达到数千层(例如被大量嵌套 div 包裹的页面)。递归遍历会撑爆 JavaScript 调用栈。迭代栈方案使用显式的数组作为栈,不受调用栈大小限制。
两种方式的复杂度一致——都是 O(n),但迭代栈的空间稳定性远好于递归。
highlightIndex 从哪里来
highlightIndex 是在 buildDomTree.js 中设置的。这个 JavaScript 文件被注入到页面中,执行时对每个 DOM 元素判断:
- 元素是否可见(
offsetParent !== null、宽高 > 0) - 元素是否可交互(
button,a,input,select,textarea,[role="button"]等) - 元素是否在视口中(根据
viewportExpansion参数扩展范围)
满足条件的元素被分配一个递增的 highlightIndex,并在页面上渲染一个可见的高亮编号。这就是在 Nanobrowser 的侧边栏中看到的页面元素编号。
步骤三:元素哈希去重
源码位置
browser/dom/clickable/service.ts:50-80
export async function hashDomElement(
domElement: DOMElementNode
): Promise<string> {
const parentBranchPath = _getParentBranchPath(domElement);
// 并行计算三层哈希
const [branchPathHash, attributesHash, xpathHash] = await Promise.all([
_parentBranchPathHash(parentBranchPath),
_attributesHash(domElement.attributes),
_xpathHash(domElement.xpath),
]);
return _hashString(
`${branchPathHash}-${attributesHash}-${xpathHash}`
);
}三层哈希的含义
| 哈希层 | 计算内容 | 作用 |
|---|---|---|
| 分支路径 | 从根到当前元素的路径上,每个父元素在兄弟节点中的索引 | 定位元素在树中的位置 |
| 属性 | tagName + 关键属性(详见 DEFAULT_INCLUDE_ATTRIBUTES) | 识别元素的特征 |
| XPath | 元素的 XPath 路径 | 精确定位到 DOM 节点 |
三重哈希合在一起,形成一个 branchPathHash-attributesHash-xpathHash 格式的指纹。
分支路径哈希的计算
function _getParentBranchPath(
domElement: DOMElementNode
): string[] {
const parents: DOMElementNode[] = [];
let currentElement: DOMElementNode | null = domElement;
while (currentElement?.parent !== null) {
parents.push(currentElement);
currentElement = currentElement.parent;
}
parents.reverse();
// ... 生成每条路径的索引签名
}分支路径从根节点开始,到当前元素结束,记录每个父节点在兄弟元素中的索引。这样,即使元素的 class 名变了(CSS 选择器就失效了),只要元素在树中的位置不变,分支路径哈希就不变。
属性哈希绑定到的属性列表
在 views.ts 中定义了 DEFAULT_INCLUDE_ATTRIBUTES:
export const DEFAULT_INCLUDE_ATTRIBUTES = [
'title', 'type', 'checked', 'name', 'role',
'value', 'placeholder', 'data-date-format', 'data-state',
'alt', 'aria-checked', 'aria-label', 'aria-expanded', 'href',
];这些属性是判断元素身份的关键。注意 class 和 id 不在列表中——因为它们经常变化,不适合作为元素识别的依据。
从点击元素检测到 LLM 可用的上下文
这个流程最终产出一个 DOMState 对象:
export interface DOMState {
elementTree: DOMElementNode;
selectorMap: Map<number, DOMElementNode>;
}Navigator 智能体拿到这个状态后,会格式化为 LLM 可以理解的文本形式:
[33]<div>User form</div>
\t[35]<button aria-label='Submit form'>Submit</button>带 * 标记的元素是自上次操作后新增的。缩进(\t)表示父子关系。这个格式在 prompts/templates/navigator.ts 中定义。
对爬虫开发者的启示
点击元素检测可以用于采集
你可以复用同样的机制来识别页面上的"下一页"按钮、"加载更多"链接——不只是 Nanobrowser 要用,爬虫也需要:
# 爬虫伪代码:找到页面上的"下一页"按钮
elements = await page.evaluate("""
() => {
const buttons = document.querySelectorAll('a, button, [role="button"]');
return Array.from(buttons)
.filter(el => el.offsetParent !== null) // 可见
.filter(el => el.textContent.includes('下一页') ||
el.textContent.includes('Next'))
.map(el => ({ text: el.textContent.trim(), href: el.href }));
}
""")分支路径哈希优于 CSS 选择器
Nanobrowser 选择分支路径哈希而不是 CSS 选择器作为元素定位的依据,因为:
CSS 选择器:.product-card .add-to-cart
页面改版 → class 名变化 → 失效
分支路径哈希:3-1-5-2
页面改版 → 元素在树中的位置不变 → 仍然有效对于爬虫来说,这意味着如果你的目标元素位置相对稳定,使用基于位置的定位策略比基于 class/id 的策略更鲁棒。
总结
Nanobrowser 的可点击元素检测系统由三部分组成:
- buildDomTree——注入到页面中的 JavaScript,构建交互元素的 DOM 树
- getClickableElements——用迭代栈遍历 DOM 树,提取所有可交互元素
- hashDomElement——用三层哈希给每个元素唯一指纹,用于去重和操作追踪
三篇文章中,这一篇最直接地展示了 Nanobrowser 如何把"页面"变成"元素列表"——这是所有后续智能体决策的基础。
下一篇文章分析 Nanobrowser 的多智能体采集循环——Executor 如何驱动 Planner 和 Navigator 完成多步骤页面采集任务。
需要企业代理方案?
我们可根据目标站点、并发规模与稳定性目标提供定制方案。