Published on

解构 browser-use:如何用 1400 行 JS 代码让 AI 精准识别网页交互元素

Authors
  • avatar
    Name
    青雲
    Twitter

前言

最近在研究 AI 自动化浏览器操作,browser-use 这个开源项目是绕不开的。它能让 AI 精准识别网页上的按钮、链接等可交互元素,准确率高得吓人。深入源码后发现,核心逻辑竟然浓缩在一个 1400 行的 index.js 文件里。今天就来拆解这背后的"黑魔法",看看它是如何解决 「AI 怎么知道哪些元素可以点击」 这个核心问题的。

一、痛点复现:AI 识别网页交互元素的三大噩梦

在开发 AI 浏览器自动化工具时,我们经常遇到这些让人头疼的问题:

假阳性问题:不该点的被标记为可点击

// 反面教材:简单粗暴的选择器
document.querySelectorAll('div, span, p').forEach((el) => {
  if (el.onclick || el.classList.contains('clickable')) {
    markAsInteractive(el) // 💀 误把装饰性元素当成按钮
  }
})

漏检问题:真正的交互元素被忽略

<!-- 这种自定义组件经常被漏掉 -->
<div role="button" aria-haspopup="true" style="cursor: pointer;">
    下拉菜单
</div>

性能问题

// 反面教材:每个元素都计算样式
elements.forEach((el) => {
  const style = window.getComputedStyle(el) // 💀 性能杀手
  const rect = el.getBoundingClientRect() // 💀 布局抖动
})

二、browser-use 的解决方案:层层递进的精准识别策略

核心思路:四重检测机制

browser-use 采用了一个四重检测机制,每一层都在过滤和精确化:

// 核心检测流程
function buildDomTree(node, parentIframe = null, isParentHighlighted = false) {
  // 1️⃣ 快速排除:基础过滤
  if (!isElementAccepted(node)) return null

  // 2️⃣ 可见性检测:DOM 层面
  nodeData.isVisible = isElementVisible(node)
  if (!nodeData.isVisible) return null

  // 3️⃣ 层级检测:避免被遮挡
  nodeData.isTopElement = isTopElement(node)
  if (!nodeData.isTopElement) return null

  // 4️⃣ 交互性检测:真正的核心
  nodeData.isInteractive = isInteractiveElement(node)
}

第一重:基础过滤 - 快速排除无关元素

function isElementAccepted(element) {
  // 永远接受的容器元素
  const alwaysAccept = new Set([
    'body',
    'div',
    'main',
    'article',
    'section',
    'nav',
    'header',
    'footer',
  ])

  // 永远排除的元素
  const leafElementDenyList = new Set([
    'svg',
    'script',
    'style',
    'link',
    'meta',
    'noscript',
    'template',
  ])

  const tagName = element.tagName.toLowerCase()
  return alwaysAccept.has(tagName) || !leafElementDenyList.has(tagName)
}

🎯 实战经验:这层过滤能排除 60% 的无关元素,大大提升后续检测性能。

第二重:可见性检测 - 避免隐藏元素

function isElementVisible(element) {
  const style = getCachedComputedStyle(element) // 注意:这里用了缓存!
  return (
    element.offsetWidth > 0 &&
    element.offsetHeight > 0 &&
    style?.visibility !== 'hidden' &&
    style?.display !== 'none'
  )
}

💡 性能优化亮点:使用 WeakMap 缓存计算样式

const DOM_CACHE = {
  computedStyles: new WeakMap(),
  // ...
}

function getCachedComputedStyle(element) {
  if (DOM_CACHE.computedStyles.has(element)) {
    return DOM_CACHE.computedStyles.get(element)
  }

  const style = window.getComputedStyle(element)
  DOM_CACHE.computedStyles.set(element, style)
  return style
}

第三重:层级检测 - 确保元素在最顶层

function isTopElement(element) {
  // 特殊情况:扩展视窗为 -1 时,认为所有元素都在顶层
  if (viewportExpansion === -1) return true

  const rects = getCachedClientRects(element)
  if (!rects || rects.length === 0) return false

  // 检查元素中心点是否被其他元素遮挡
  const centerX = rects[0].left + rects[0].width / 2
  const centerY = rects[0].top + rects[0].height / 2
  try {
    const topEl = document.elementFromPoint(centerX, centerY)
    if (!topEl) return false
    // 检查当前元素是否是点击位置的顶层元素或其祖先
    let current = topEl
    while (current && current !== document.documentElement) {
      if (current === element) return true
      current = current.parentElement
    }
    return false
  } catch (e) {
    return true // 发生异常时保守处理
  }
}

🔥 避坑指南elementFromPoint 在某些边界情况下会抛异常,一定要用 try-catch 包裹!

第四重:交互性检测 - 核心中的核心

这是整个系统最精彩的部分,browser-use 团队经过大量实战总结出的「cursor 检测大法」:

function isInteractiveElement(element) {
  const style = getCachedComputedStyle(element)

  // 🎯 核心发现:cursor 样式是最可靠的交互性指示器
  const interactiveCursors = new Set([
    'pointer', // 最常见的可点击指示器
    'move', // 可拖拽元素
    'text', // 可编辑文本
    'grab', // 可抓取元素
    'grabbing', // 正在抓取
    // ... 还有 20+ 种交互 cursor
  ])

  // 🚫 明确的非交互 cursor
  const nonInteractiveCursors = new Set(['not-allowed', 'no-drop', 'wait', 'progress'])

  // 第一优先级:cursor 检测
  if (style?.cursor && interactiveCursors.has(style.cursor)) {
    return true // 💡 "Genius fix for almost all interactive elements"
  }

  // 第二优先级:语义化标签
  const interactiveElements = new Set([
    'a',
    'button',
    'input',
    'select',
    'textarea',
    'details',
    'summary',
    'label',
  ])

  if (interactiveElements.has(element.tagName.toLowerCase())) {
    // 但要排除被禁用的元素
    if (style?.cursor && nonInteractiveCursors.has(style.cursor)) {
      return false
    }
    return !element.disabled && !element.readOnly
  }

  // 第三优先级:ARIA 角色和属性
  const role = element.getAttribute('role')
  const interactiveRoles = new Set([
    'button',
    'menuitemradio',
    'checkbox',
    'tab',
    'switch',
    // ...
  ])

  if (role && interactiveRoles.has(role)) return true

  // 第四优先级:事件监听器检测(最后的保险)
  try {
    // 1. Chrome DevTools 环境下的 getEventListeners
    if (typeof getEventListeners === 'function') {
      const listeners = getEventListeners(element)
      const mouseEvents = ['click', 'mousedown', 'mouseup', 'dblclick']
      for (const eventType of mouseEvents) {
        if (listeners[eventType] && listeners[eventType].length > 0) {
          return true
        }
      }
    }

    // 2. 兼容性更强的 getEventListenersForNode
    const getEventListenersForNode =
      element?.ownerDocument?.defaultView?.getEventListenersForNode ||
      window.getEventListenersForNode
    if (typeof getEventListenersForNode === 'function') {
      const listeners = getEventListenersForNode(element)
      const interactionEvents = [
        'click',
        'mousedown',
        'mouseup',
        'keydown',
        'keyup',
        'submit',
        'change',
        'input',
        'focus',
        'blur',
      ]
      for (const eventType of interactionEvents) {
        for (const listener of listeners) {
          if (listener.type === eventType) {
            return true
          }
        }
      }
    }

    // 3. 兜底:检查常见事件属性(HTML 属性或 DOM 绑定)
    const commonMouseAttrs = ['onclick', 'onmousedown', 'onmouseup', 'ondblclick']
    for (const attr of commonMouseAttrs) {
      if (element.hasAttribute(attr) || typeof element[attr] === 'function') {
        return true
      }
    }
  } catch (e) {
    // 检查事件监听器失败时,直接忽略,靠其他检测手段兜底
  }

  return false
}

💎 实战精华:团队在注释中直接说了 "Genius fix for almost all interactive elements" —— cursor 检测确实是最可靠的方法!

三、高亮显示:让 AI 看得见的视觉反馈系统

识别出交互元素后,browser-use 还实现了一套动态高亮系统,让每个可交互元素都有编号标记:

function highlightElement(element, index, parentIframe = null) {
  // 创建高亮容器(全局单例)
  let container = document.getElementById(HIGHLIGHT_CONTAINER_ID)
  if (!container) {
    container = document.createElement('div')
    container.style.position = 'fixed'
    container.style.pointerEvents = 'none'
    container.style.zIndex = '2147483647' // 最大 z-index
    document.body.appendChild(container)
  }

  // 获取元素的所有矩形区域(处理换行文本等复杂情况)
  const rects = element.getClientRects()

  // 为每个矩形创建高亮框
  for (const rect of rects) {
    if (rect.width === 0 || rect.height === 0) continue

    const overlay = document.createElement('div')
    overlay.style.position = 'fixed'
    overlay.style.border = `2px solid ${baseColor}`
    overlay.style.backgroundColor = backgroundColor
    overlay.style.top = `${rect.top}px`
    overlay.style.left = `${rect.left}px`
    overlay.style.width = `${rect.width}px`
    overlay.style.height = `${rect.height}px`

    container.appendChild(overlay)
  }

  // 添加编号标签
  const label = document.createElement('div')
  label.textContent = index.toString()
  // 智能定位逻辑...
}

🎨 UX 细节:高亮框会跟随页面滚动实时更新位置,用了节流函数避免性能问题:

const throttledUpdatePositions = throttleFunction(updatePositions, 16) // ~60fps
window.addEventListener('scroll', throttledUpdatePositions, true)

四、去重逻辑:避免父子元素重复标记

这是最容易被忽略但极其重要的一环。想象一下,一个按钮里嵌套了图标和文字,不处理好会被标记 3 次!

function handleHighlighting(nodeData, node, parentIframe, isParentHighlighted) {
  if (!nodeData.isInteractive) return false

  let shouldHighlight = false
  if (!isParentHighlighted) {
    // 父元素没被高亮,这个交互元素可以高亮
    shouldHighlight = true
  } else {
    // 父元素已被高亮,只有在代表不同交互时才高亮子元素
    if (isElementDistinctInteraction(node)) {
      shouldHighlight = true
    }
  }

  return shouldHighlight
}

function isElementDistinctInteraction(element) {
  const tagName = element.tagName.toLowerCase()

  // 明确的独立交互元素
  const distinctTags = new Set(['a', 'button', 'input', 'select', 'textarea', 'iframe'])

  if (distinctTags.has(tagName)) return true

  // 有独立事件处理器的元素
  if (element.hasAttribute('onclick')) return true

  // 测试属性(通常代表独立组件)
  if (element.hasAttribute('data-testid')) return true

  return false
}

五、性能优化:三大缓存策略

WeakMap 缓存计算结果

const DOM_CACHE = {
  boundingRects: new WeakMap(),
  clientRects: new WeakMap(),
  computedStyles: new WeakMap(),
}

💡 为什么用 WeakMap?

  • 自动垃圾回收:DOM 元素删除时,缓存自动清理
  • 避免内存泄漏:不会阻止 DOM 元素被回收

XPath 缓存

const xpathCache = new WeakMap()

function getXPathTree(element) {
  if (xpathCache.has(element)) return xpathCache.get(element)

  // 计算 XPath...
  const result = segments.join('/')
  xpathCache.set(element, result)
  return result
}

视窗检测优化

// 早期视窗检测:快速排除明显不在视窗内的元素
if (viewportExpansion !== -1 && !node.shadowRoot) {
  const rect = getCachedBoundingRect(node)

  if (rect.bottom < -viewportExpansion || rect.top > window.innerHeight + viewportExpansion) {
    return null // 直接跳过,节省后续计算
  }
}

六、边界情况处理:那些让人头疼的特殊场景

iframe 跨域处理

if (tagName === 'iframe') {
  try {
    const iframeDoc = node.contentDocument || node.contentWindow?.document
    if (iframeDoc) {
      // 递归处理 iframe 内容
      for (const child of iframeDoc.childNodes) {
        const domElement = buildDomTree(child, node, false)
        if (domElement) nodeData.children.push(domElement)
      }
    }
  } catch (e) {
    console.warn('Unable to access iframe:', e) // 跨域时静默失败
  }
}

Shadow DOM 支持

if (node.shadowRoot) {
  nodeData.shadowRoot = true
  for (const child of node.shadowRoot.childNodes) {
    const domElement = buildDomTree(child, parentIframe, nodeWasHighlighted)
    if (domElement) nodeData.children.push(domElement)
  }
}

富文本编辑器适配

// TinyMCE、CKEditor 等富文本编辑器的特殊处理
if (
  node.isContentEditable ||
  node.id === 'tinymce' ||
  node.classList.contains('mce-content-body')
) {
  // 处理所有子节点以捕获格式化文本
  for (const child of node.childNodes) {
    const domElement = buildDomTree(child, parentIframe, nodeWasHighlighted)
    if (domElement) nodeData.children.push(domElement)
  }
}

七、落地使用模板

如果你想在自己的项目中使用这套逻辑,这里是精简版的实现:

// 核心检测函数 - 可直接复制使用
function detectInteractiveElements() {
  const interactiveElements = []
  let index = 0

  function traverse(node) {
    if (!node || node.nodeType !== Node.ELEMENT_NODE) return

    // 基础过滤
    const tagName = node.tagName.toLowerCase()
    if (['script', 'style', 'meta'].includes(tagName)) return

    // 可见性检测
    if (node.offsetWidth === 0 || node.offsetHeight === 0) return

    // 交互性检测
    if (isInteractive(node)) {
      interactiveElements.push({
        element: node,
        index: index++,
        tagName: tagName,
        xpath: getSimpleXPath(node),
      })
    }

    // 递归子元素
    for (const child of node.children) {
      traverse(child)
    }
  }

  function isInteractive(element) {
    const style = window.getComputedStyle(element)

    // cursor 检测
    if (style.cursor === 'pointer') return true

    // 语义化标签
    const tags = ['a', 'button', 'input', 'select', 'textarea']
    if (tags.includes(element.tagName.toLowerCase())) return true

    // 事件监听器
    if (element.onclick || element.hasAttribute('onclick')) return true

    return false
  }

  traverse(document.body)
  return interactiveElements
}

function getSimpleXPath(el) {
  if (el.id) return `//*[@id="${el.id}"]`
  const parts = []
  while (el && el.nodeType === Node.ELEMENT_NODE) {
    let index = 1
    let sibling = el.previousElementSibling
    while (sibling) {
      if (sibling.tagName === el.tagName) index++
      sibling = sibling.previousElementSibling
    }
    parts.unshift(`${el.tagName.toLowerCase()}[${index}]`)
    el = el.parentElement
  }
  return '/' + parts.join('/')
}

// 使用示例
const elements = detectInteractiveElements()
console.log(`检测到 ${elements.length} 个交互元素`)

八、踩坑总结

getComputedStyle 性能陷阱

问题:频繁调用 getComputedStyle 会导致浏览器重排重绘
解决:使用 WeakMap 缓存,一次计算多次使用

getClientRects vs getBoundingClientRect

问题:换行文本用 getBoundingClientRect 只能得到包围盒
解决:优先使用 getClientRects 获取每行的精确位置

事件监听器检测兼容性

问题getEventListeners 只在 Chrome DevTools 中可用
解决:降级到属性检测 + try-catch 保护

iframe 跨域访问

问题:跨域 iframe 内容无法访问,直接报错
解决:用 try-catch 静默处理,避免整个流程中断

九、总结:1400 行代码背后的工程智慧

browser-use 的 index.js 虽然只有 1400 行,但解决的是一个极其复杂的工程问题。它的成功在于:

  1. 分层检测:四重过滤机制,每层都有明确职责
  2. 性能优先:三级缓存策略,处理大页面不卡顿
  3. 实战导向:cursor 检测等都是踩坑后的经验总结
  4. 边界完善:处理了 iframe、Shadow DOM、富文本等复杂场景
  5. 代码健壮:大量 try-catch 和容错处理

如果你正在开发 AI 浏览器自动化工具,这套代码绝对值得深入研究。它不仅解决了技术问题,更重要的是体现了「工程化思维」—— 不追求完美的算法,而是在复杂现实中找到最可行的解决方案。

🔗 完整源码地址https://github.com/browser-use/browser-use/blob/main/browser_use/dom/dom_tree/index.js

📚 扩展阅读:如果你想了解这套系统如何与 Python 后端集成,可以看看 browser_use/dom/service.py 的实现。