- Published on
解构 browser-use:如何用 1400 行 JS 代码让 AI 精准识别网页交互元素
- Authors
- Name
- 青雲
前言
最近在研究 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 行,但解决的是一个极其复杂的工程问题。它的成功在于:
- 分层检测:四重过滤机制,每层都有明确职责
- 性能优先:三级缓存策略,处理大页面不卡顿
- 实战导向:cursor 检测等都是踩坑后的经验总结
- 边界完善:处理了 iframe、Shadow DOM、富文本等复杂场景
- 代码健壮:大量 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
的实现。