Nanobrowser 源代码解析:可点击元素检测与哈希去重——智能体怎么「看」页面

从源码看 Nanobrowser 如何遍历 DOM 树、用分支路径哈希识别元素、用迭代栈替代递归防栈溢出。爬虫开发者从中可以学到页面元素定位的核心机制。

亿牛云技术团队2026年4月10日5 分钟阅读

引言:智能体需要知道页面上什么可以点

对于 AI 浏览器智能体来说,读取页面内容只是第一步。更关键的能力是:知道页面上哪些元素可以交互,并且能精确地定位到它们。

Nanobrowser 通过三个步骤解决这个问题:

  1. DOM 构建——把原始 HTML 转成一棵可遍历的 DOMElementNode 树
  2. 可点击元素检测——遍历 DOM 树,找出所有可以交互的元素
  3. 元素哈希去重——给每个元素一个唯一的指纹,避免重复操作

这三个步骤分别实现在 browser/dom/service.tsbrowser/dom/clickable/service.tsbrowser/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 树
}

关键细节:

  1. 脚本注入检查——injectBuildDomTreeScripts 先检查目标页面是否已经注入了 buildDomTree 函数,避免重复注入。这个检查遍历所有 frame,只注入缺少脚本的 frame(service.ts:340-380)。

  2. 多 frame 拼接——主 frame 构建完 DOM 树后,constructFrameTree 处理 iframe 中的内容。它通过匹配 iframe 的宽高、src、name 来定位应该拼接的位置。如果某个 iframe 加载失败(跨域策略等),它会被标记为失败但不阻塞整体构建(service.ts:175-240)。

  3. 两阶段构建——_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 元素判断:

  1. 元素是否可见(offsetParent !== null、宽高 > 0)
  2. 元素是否可交互(button, a, input, select, textarea, [role="button"] 等)
  3. 元素是否在视口中(根据 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',
];

这些属性是判断元素身份的关键。注意 classid 不在列表中——因为它们经常变化,不适合作为元素识别的依据。

从点击元素检测到 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 的可点击元素检测系统由三部分组成:

  1. buildDomTree——注入到页面中的 JavaScript,构建交互元素的 DOM 树
  2. getClickableElements——用迭代栈遍历 DOM 树,提取所有可交互元素
  3. hashDomElement——用三层哈希给每个元素唯一指纹,用于去重和操作追踪

三篇文章中,这一篇最直接地展示了 Nanobrowser 如何把"页面"变成"元素列表"——这是所有后续智能体决策的基础。

下一篇文章分析 Nanobrowser 的多智能体采集循环——Executor 如何驱动 Planner 和 Navigator 完成多步骤页面采集任务。

需要企业代理方案?

我们可根据目标站点、并发规模与稳定性目标提供定制方案。