browser-use 怎么判断按钮能不能点:绘制顺序与增强快照两块代码
本文基于 browser-use 仓库 commit f0aa3a8(2026-07-27)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/browser-use/browser-use 最新代码与文档为准。
被遮挡的元素在 browser-use 里不是”点不动”,而是根本不会出现在模型看到的那份元素清单上。 它被打上 ignored_by_paint_order = True,然后在分配选择器编号那一步被跳过——模型拿到的页面表示里没有这个编号,于是它不会去点它。这个判定错向哪一边,Agent 的表现完全不同:判过了头,一个真能点的按钮凭空消失,模型开始在页面上乱找替代路径、反复滚动、最后报告”页面上没有提交按钮”;判得不够,一个盖在弹层底下的输入框仍然带着编号交上去,模型下发点击,坐标落在遮罩上,动作”成功”了但页面毫无变化,接着它会重试同一个编号。
这两种失败长得都不像 bug,像模型笨。所以值得把这块判定的代码读一遍。
一、这块代码在解决什么问题
浏览器里”看得见”和”点得到”是两件事。一个元素在 DOM 里存在、有尺寸、CSS 也没把它隐藏,但它可能被一张全屏遮罩、一个 sticky 头部、一个 cookie 横幅或者一层装饰性的 div 压在下面。真实用户点下去命中的是上面那层。
browser-use 不靠给每个候选元素做一次浏览器端命中测试来解决这件事——它只发一次 DOMSnapshot.captureSnapshot,然后在 Python 侧用几何算法离线判断。browser_use/dom/service.py 里那次抓取的参数写得很直白:
return cdp_session.cdp_client.send.DOMSnapshot.captureSnapshot(
params={
'computedStyles': REQUIRED_COMPUTED_STYLES,
'includePaintOrder': True,
'includeDOMRects': True,
'includeBlendedBackgroundColors': False,
'includeTextColorOpacities': False,
},
session_id=cdp_session.session_id,
)
includePaintOrder 就是遮挡判定的原料:Chrome 告诉你每个布局节点的绘制次序,序号大的画在上面。includeBlendedBackgroundColors 明确关掉了,这一点后面会付出代价。
判定发生在序列化流水线的第二步。browser_use/dom/serializer/serializer.py 的 serialize_accessible_elements() 依次做:建简化树 → 跑 PaintOrderRemover → 优化树(去掉多余父节点)→ 边界框过滤 → 分配交互编号。顺序有讲究:遮挡判定跑在简化树之上、编号分配之前,所以被判为遮挡的节点连编号都拿不到。
二、增强快照:把一次 CDP 抓取压成一张查找表
browser_use/dom/enhanced_snapshot.py 只做一件事:build_snapshot_lookup(snapshot, device_pixel_ratio) 返回 dict[int, EnhancedSnapshotNode],键是 backend node id。后面所有判定都查这张表,不再回浏览器。
它取的计算样式是一份白名单,注释里写了原因是”prevents Chrome crashes on heavy sites”:
REQUIRED_COMPUTED_STYLES = [
'display',
'visibility',
'opacity',
'overflow',
'overflow-x',
'overflow-y',
'cursor',
'pointer-events',
'position',
'background-color',
]
十项,一项不多。这个白名单是硬约束:不在表里的属性,整条判定链上任何地方都拿不到。比如 z-index 不在里面,transform 不在里面——遮挡判定完全不看它们,只看 Chrome 给的绘制次序。
这个文件里另一处细节值得记住,因为它解释了为什么”元素多的页面”曾经很慢。CDP 的稀有布尔数据(如 isClickable)是一个索引列表,逐节点做 index in list 是 O(n) 的,注释记录了改成 set 之后的差别:
# At 20k elements: 5,925ms (list) → 2ms (set) = 3,000x speedup.
has_clickable_data = 'isClickable' in nodes
is_clickable_set: set[int] = set(nodes['isClickable']['index']) if has_clickable_data else set()
坐标这块有个容易踩的不对称。bounds 会除以设备像素比换算成 CSS 像素:
bounding_box = DOMRect(
x=raw_x / device_pixel_ratio,
y=raw_y / device_pixel_ratio,
width=raw_width / device_pixel_ratio,
height=raw_height / device_pixel_ratio,
)
而同一个函数里紧接着解析的 clientRects 和 scrollRects 是直接取原始数值、没做这个换算的。browser_use/dom/views.py 里 EnhancedSnapshotNode 的文档字符串把三者的语义分得很清楚:bounds 是文档坐标(原点在页面左上,不受当前滚动影响),clientRects 是视口坐标(等价于 getBoundingClientRect()),scrollRects 是可滚动区域。遮挡判定用的是 bounds,也就是文档坐标。这意味着它判的是”在整张页面的坐标系里谁压着谁”,与你当前滚到哪里无关。
另外,stacking_contexts 被解析出来存进了 EnhancedSnapshotNode,但在遮挡判定里没有被读取。字段在那儿,逻辑还没用上——读代码时别把它当成层叠上下文已经参与判断的证据。
三、绘制顺序:从上往下铺矩形,铺满了就算看不见
browser_use/dom/serializer/paint_order.py 的思路可以一句话讲完:按绘制次序从高到低遍历,把上层元素的矩形累积成一个”已被覆盖区域”的并集,某个元素的矩形如果整块落在这个并集里,就判它被遮挡。
三个部件。Rect 是个 frozen dataclass,四个浮点边界,带 area()、intersects()、contains()。RectUnionPure 维护一组互不重叠的矩形,核心是 contains(r)——判断 r 是否被并集完全覆盖:把 r 当作一个待处理碎片,逐个拿并集里的矩形去减它,_split_diff(a, b) 返回 a \ b 的最多四块(下、上、左、右切片)。如果某一轮之后碎片列表空了,说明整块被吃掉,返回 True;一直到最后还有碎片存活,返回 False。
PaintOrderRemover.calculate_paint_order() 是调度。它先递归收集所有同时具备 paint_order 和 bounds 的节点,按 paint_order 分组,然后倒序遍历:
for paint_order, nodes in sorted(grouped_by_paint_order.items(), key=lambda x: -x[0]):
组内每个节点用 bounds 拼出矩形,先查是否已被现有并集覆盖:
if rect_unions[context].contains(rect):
node.ignored_by_paint_order = True
注意 rects_to_add 是先攒起来、等整组遍历完才批量 add 进并集的。这不是写法随意——同一 paint_order 层里的兄弟节点不应该互相遮挡,攒一批再入并集正好保证了这一点。
这里有两个设计取舍,是整块逻辑里最该记住的部分。
第一个:不是所有上层元素都算”能遮挡”。 透明的东西不该把下面的按钮判死,所以有一道过滤:
# don't add to the nodes if opacity is less then 0.95 or background-color is transparent
if (
node.original_node.snapshot_node.computed_styles
and node.original_node.snapshot_node.computed_styles.get('background-color', 'rgba(0, 0, 0, 0)')
== 'rgba(0, 0, 0, 0)'
) or (
node.original_node.snapshot_node.computed_styles
and float(node.original_node.snapshot_node.computed_styles.get('opacity', '1'))
< 0.8 # this is highly vibes based number
):
continue
背景色是完全透明的、或者不透明度低于 0.8 的元素,自己可以被判为被遮挡,但不会被加进并集去遮挡别人。两点实情:注释写的是 0.95,代码里的比较值是 0.8,两者不一致;作者自己在旁边标了 this is highly vibes based number,即这个阈值是拍出来的。判定不透明的唯一依据是那个 background-color 字符串是否恰好等于 'rgba(0, 0, 0, 0)'——半透明的 rgba(0, 0, 0, 0.5) 遮罩,字符串不等于全透明值、opacity 又是 1,于是它会被当成实心遮挡物。而前面说过 includeBlendedBackgroundColors 是关掉的,所以这里拿不到”混合后的实际背景色”,只有元素自己声明的那个值。
第二个:遮挡判定按文档隔离。 _document_context(node) 沿 parent_node 往上找,遇到 iframe 或 frame 就返回 (session_id, 该 frame 的 frame_id),找不到就返回 (session_id, None)。rect_unions 是按这个二元组分桶的 defaultdict。为什么必要,仓库里 tests/ci/browser/test_dom_serializer_session_identity.py 写了两个对照测试:两个并排的 iframe(典型场景是支付页里卡号和 CVV 各占一个 iframe)里各有一个输入框,坐标完全重合、绘制次序一高一低,隔离生效时两个都不被忽略;把同样两个节点放进同一文档,低层那个就会被判成被遮挡。没有这层隔离,iframe 里的输入框会被另一个 iframe 的内容”遮”掉——支付表单里少一个可填字段,Agent 就永远填不完。
四、这几个部件各管什么
| 部件 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
build_snapshot_lookup 与 REQUIRED_COMPUTED_STYLES | 把一次 CDP 快照压成 backend node id → 快照节点的查找表,含 bounds、绘制次序、十项计算样式 | browser_use/dom/enhanced_snapshot.py | 想知道判定能看到哪些 CSS 属性、坐标是不是被设备像素比缩放过 |
Rect / RectUnionPure | 矩形几何与”已覆盖区域”并集,contains() 判完全覆盖,_split_diff() 做矩形相减 | browser_use/dom/serializer/paint_order.py | 怀疑遮挡判定太激进或太保守,想搞清覆盖是怎么算的 |
PaintOrderRemover.calculate_paint_order() | 按绘制次序倒序调度、过滤透明层、按文档分桶、给节点打 ignored_by_paint_order | browser_use/dom/serializer/paint_order.py | 某个确实存在的按钮没进元素清单 |
| 编号分配阶段 | 跳过 excluded_by_parent 或 ignored_by_paint_order 的节点,不给选择器编号 | browser_use/dom/serializer/serializer.py | 想确认”被判遮挡”到底导致了什么后果 |
paint_order_filtering 开关 | 浏览器配置项,默认 True,描述里自称 “Slightly experimental” | browser_use/browser/profile.py | 需要临时关掉整块过滤做 A/B 对比 |
五、边界与代价:这套设计放弃了什么
它放弃了精确性,换的是一次 CDP 往返。 真正准确的做法是对每个候选元素做命中测试,问浏览器”这个点上最顶层是谁”。那样要么多次往返、要么注入脚本遍历,在几千个元素的页面上代价很高。矩形并集是纯 Python 的几何近似,代价是几何之外的一切都判不了。
圆角、非矩形裁剪、clip-path、旋转,都判不了。 参与运算的只有 bounds 给出的轴对齐矩形。一个圆形头像按钮被一个矩形层压住四角时,几何上”完全覆盖”成立,实际上中间那块还能点。
层叠上下文不参与判断。 判定只信 Chrome 给的 paint_order 整数序号,z-index 和 stacking_contexts 都没被读。多数情况下绘制次序已经把层叠算完了,但你不能拿 z-index 去推断这块代码的行为。
它有一个明确的性能刹车,代价是判定会退化。 RectUnionPure._MAX_RECTS = 5000,注释解释得很清楚:每次 add() 在复杂重叠层上可能把已有矩形碎成最多四块,重页面上会指数膨胀。一旦到顶,add() 直接返回 False 不再收新矩形。注释里写的取舍是”conservatively returns False (i.e. nothing is hidden)“——宁可漏判遮挡,也不误杀元素。所以在层极多的页面上,遮挡过滤会悄悄变弱,不报错、不打日志。 你只会看到元素清单里多了一些其实点不到的东西。
它不管 pointer-events 与视觉之外的可点性。 pointer-events 在 REQUIRED_COMPUTED_STYLES 里,注释说是给可点性逻辑用的,但 calculate_paint_order() 里没读它。一个 pointer-events: none 的透明层从几何上看仍然是个矩形——它会不会遮挡,取决于它的背景色字符串和 opacity 是否过了那道过滤,而不是取决于它实际拦不拦鼠标。
opacity 解析没有兜底。 paint_order.py 里直接 float(...) 转换那个字符串;相比之下 service.py 的可见性判断对同一个属性包了 try / except (ValueError, TypeError)。两处的健壮性不一样,读的时候别以为一致。
它完全不管权限与合规。 这一层只回答”这个矩形在不在另一个矩形下面”。这类工具会驱动真实浏览器、可能带着你的登录态操作真实账号、并访问第三方站点:目标站点的使用条款是否允许自动化访问、验证码与反自动化机制会不会把会话判成异常、账号是否可能被风控、页面上的敏感数据会不会随快照进入模型上下文,都是你自己的责任,不在这两个文件的职责范围内。本文也不讨论如何规避这些机制——遇到验证码或明确禁止自动化的站点,正确处理是交回给人,不是想办法绕过。相关的权限收口思路可以看给 Agent 设计最小权限。
顺带说一个读代码时的坑:browser_use/dom/utils.py 里有 generate_css_selector_for_element(),从 tag、id、class 和一份 SAFE_ATTRIBUTES 白名单(含 data-testid、data-qa、data-cy、data-id 等动态属性)拼 CSS 选择器,函数注释说明它沿用的是早期版本的选择器思路,并且因为 EnhancedDOMTreeNode 里没有 xpath,所以只能从标签、id、class 和属性反推、做了简化。在当前仓库里检索不到其它文件调用它。同一文件里的 cap_text_length() 则被序列化器和 views.py 大量使用,属性值在页面表示里会被截断(序列化器里按 100 字符)。别把这个未被调用的选择器生成器当成运行时定位元素的路径。
六、遇到遮挡误判时的排查顺序
先关掉过滤做对照,而不是先改阈值。 browser_use/browser/profile.py 里 paint_order_filtering 默认 True,描述自称 “Slightly experimental”。同一个任务跑两遍——开、关各一遍——看那个消失的元素是否回来。为什么容易踩:遮挡误判和”元素本来就不可交互”、“元素在视口阈值外”表现一样,不做这个对照你会去改错的地方。
再区分”没进清单”与”点了没反应”。 两种症状对应两个方向:元素不在清单里,往遮挡误杀查;元素在清单里但点击无效,往误漏查(可能是半透明遮罩没被算成遮挡物、也可能是矩形数到顶了)。为什么容易踩:模型的自述往往把两者都描述成”按钮不工作”,你得看清单本身。
看那一层的背景色字符串,别只看视觉。 判定不透明的条件是 background-color 恰好等于 'rgba(0, 0, 0, 0)'。半透明遮罩、用 backdrop-filter 做的模糊层、靠伪元素上色的层,在这条判断里的归类可能和你眼睛看到的相反。为什么容易踩:includeBlendedBackgroundColors 关闭意味着这里拿不到混合后的实际颜色,只有元素自己声明的值。
页面元素上万时,默认怀疑过滤已经退化。 _MAX_RECTS 到顶后 add() 静默返回 False,不抛异常。为什么容易踩:它的表现是”过滤突然变松”,而不是报错,你会以为是模型退步。这种场景下先想办法缩小页面范围(先滚动、先关掉横幅、先进入更窄的子页面),而不是调大常量。
iframe 里的表单要单独验一遍。 判定按 (session_id, iframe frame_id) 分桶隔离,仓库里那两个对照测试就是为这件事写的。为什么容易踩:坐标重合的并排 iframe 是支付页的常态,一旦隔离在你的场景里没生效,症状是”表单永远缺一个字段”,而不是报错。
把绘制顺序耗时纳入观测。 browser_use/browser/watchdogs/dom_watchdog.py 会把各阶段耗时打成一棵时间树,其中包含 build_snapshot_lookup 和 calculate_paint_order 两项。为什么容易踩:这块是纯 CPU 计算,在重页面上会明显吃时间,但如果你只记录端到端时延,看不出时间花在几何运算还是模型推理上。日志该怎么组织可以看让 Agent 运行过程可观察。
收束
这篇只讲了一件事:browser-use 判断”能不能点”用的是文档坐标下的矩形并集加 Chrome 的绘制次序,判定结果通过 ignored_by_paint_order 影响元素能不能拿到选择器编号,而阈值、性能刹车、透明度口径这几处都是有意为之的近似。站内另外三篇讲的是相邻但不同的层:工具返回值该怎么设计讲动作执行完之后你把什么交回给模型,Agent 动作的对手验证讲怎么用独立信号确认动作真的生效了,Agent 失败分类讲怎么把”点了没反应”这类现象归到正确的失败类别里去;本篇管的是更靠下的那一层——元素清单本身是怎么被裁出来的。
接下来按这个顺序读源码收益最大:browser_use/dom/serializer/serializer.py 的 serialize_accessible_elements() 看清五步流水线,回头看 paint_order.py 的 RectUnionPure.contains() 理解覆盖判定,再看 tests/ci/browser/test_dom_serializer_session_identity.py 那两个对照测试——它们把”什么该被遮挡、什么不该”写成了可执行的定义,比任何文字描述都准。
本篇属于一个把开源浏览器操作 Agent 项目 browser-use逐层拆开讲的系列,整体地图见 browser-use 是什么;沿着这条线往下,还可以看 browser-use 的 DOM 序列化 和 browser-use 把页面转 markdown 喂模型。