文章 · 浏览器自动化

选择器坏了先别急着改:三类失败,三种修法

同步回复的脚本第一次坏掉时,我花了一晚上改选择器,第二天它又坏了。后来才明白:超时、多匹配、过期引用是三种不同的问题,只有其中一种该靠改选择器解决。

作者 Nick2026-10-169 分钟阅读

这篇回应的是 ego lite 官网的 《How to Handle Selectors Breaking in Browser Automation》。观点是我自己的,例子来自我搭的 KOL 建联系统。

先说结论
  1. 先读报错的类型再碰代码。超时说明元素没找到,严格模式冲突说明找到了不止一个,过期引用说明你拿着的是一个已经被替换掉的节点。三种报错指向三个方向。
  2. 再把失败归到三层:标识符坏了、页面状态不对、工作流本身变了。只有第一层该改选择器,第二层改等待和作用域,第三层要人来改任务。
  3. 建联系统里我的做法是:用角色加名字定位,脚本坏在第三层时让 agent 看着当前页面重新规划,而不是硬改一个本来就不该存在的步骤。

一个坏掉的选择器在告诉你什么

大家嘴里的「选择器坏了」,报错其实只有三种,而且它们指向完全不同的方向。

等待元素超时,意思是查询什么都没找到。可能页面根本没渲染它,可能它在一个你没进去的 iframe 里,可能它还在加载态后面,也可能标识符真的变了。把这种情况当成命名问题是最常见的误诊,因为「元素不存在」和「元素改名」的症状一模一样。

严格模式冲突正好相反:查询现在匹配到了不止一个元素,工具拒绝替你猜。Playwright 的定位器默认就是严格模式,一旦多匹配立刻抛错,不会等。这对诊断很重要,因为多匹配和超时毫无共同点,哪怕它们都以「这一步失败了」的面目出现。

节点已分离或引用过期,意思是你拿着一个早些时候取到的元素,而页面后来把它换掉了。重新渲染时旧节点被丢弃,引用就没了,哪怕那个控件还在屏幕上、还是对的。

在动代码之前先把报错类型读清楚,这是第一步,也是大多数人跳过的一步。

失败的三层

知道了报错类型,下一个问题是:哪个假设失效了?把失败归到三层之一,正确的应对就从层级而不是症状里来。

失败类别真正坏掉的东西正确的应对
标识符不稳定你匹配的属性是实现细节:自动生成的 class、位置索引、一长串 CSS 路径。改定位器。换成角色加可访问名字、test id,或者一个稳定的容器作用域。这是唯一该改选择器的一类。
页面状态不对查询本身合理,但它跑在页面到达预期状态之前,或者跑在了一个标记不同的响应式版本上。改等待、改 iframe 作用域、改视口假设。改选择器只会把真正的原因留在原地。
工作流假设失效这一步本身错了。控件挪进了别的流程,任务多了一个确认步骤,通往结果的路径变了。人来改工作流。改任何定位器都不对,而「让这一步无论如何过去」的补丁是最危险的结果。

这张表的实际价值在于:三行里有两行应该阻止你去改选择器。公开的测试维护调查里,脆弱的选择器通常只占失败的四分之一到三分之一,时间和交互问题占的份额差不多甚至更大。如果你的失败几乎全被归到「选择器问题」,说明分类哪里错了。

改之前先留证据

在改代码之前先把失败现场留下来。截图回答「用户当时能看到什么」,DOM 或可访问性快照回答「自动化当时能定位到什么」,控制台和网络请求解释「预期状态为什么始终没出现」。如果你只留了一条超时信息,三种根因就会塌缩成同一张工单。

证据它回答的问题别据此下的结论
截图加当前 URL这次运行是否在预期的路由、账号状态、弹层和视口上?一个看得见的标签不等于元素唯一可操作
DOM 或可访问性快照元素存在吗?角色和名字是什么?有几个匹配?工作流的含义仍然相同
控制台和失败的请求定位器运行之前,渲染或数据加载失败了吗?把超时调长就能修好应用状态

三层证据拿到之后,在干净的页面加载上复现一次。只有稳定标识符的失败才应该产出一次定位器修改;页面状态的失败改等待或前置步骤;工作流的失败改任务。

为什么定位器活得下来,而句柄活不下来

定位器不是一个缓存起来的元素引用,而是一段查询描述,用到的那一刻才解析,并且在每次操作前都对着当前页面重新解析一遍。句柄正好相反:它指向某个时刻捕获的某一个节点。这一个差别解释了大多数被归咎于「选择器质量」的存活与否。

// 脆弱:存下了早先渲染出来的节点,后来一次重渲染就让它失效
const button = await page.$("div.toolbar > button:nth-child(2)");
await button.click();

// 耐久:每次操作前重新解析,重渲染后照样能用
const button = page
  .getByRole("navigation", { name: "Issue actions" })
  .getByRole("button", { name: "Close issue" });
await button.click();

两个细节值得在重写之前知道。第一,角色定位器查的是可访问性树,所以它扛得住包装元素和改名的生成 class:它匹配的是这个控件「是什么、叫什么」,而不是「在哪里」。第二,严格模式只对定位器生效,对原始查询方法不生效,所以把定位器换成原始查询的「修复」可能看起来管用,实际上悄悄拆掉了一道护栏。

耐久的定位器仍然做不到一件事:判断这一步还是不是该走的一步。class 改名是定位器问题,控件挪进另一个流程是任务问题,大多数选择器调试走错的地方恰恰是这两者的分界。

五个可以自己复现的案例

每个案例都能在你自己控制的页面上复现。做一遍的意义在于,分清楚定位器修复真正能修好什么,哪些只有人能修。

  1. 生成的 class 名变了。 给按钮换个 class,所有按旧 class 匹配的查询都停止解析;带可访问名字的角色定位器完全不受影响,因为 class 从来不是控件身份的一部分。这是定位器修复能完全修好的一类,也是人们由此推断「稳定定位器解决 UI 变化」的那一类。
  2. 加了一层包装元素。 在控件外面包一个 div,所有后代和子代组合路径都断,所有位置选择器都偏移;角色定位器和 test id 照样解析。它和第一类是同一类,修法相同,这正是为什么前两个案例是对整个问题的误导性抽样。
  3. 组件重渲染替换了节点。 强制组件卸载再挂载,持有旧引用的代码路径以过期引用失败;在使用时刻重新解析定位器的代码路径继续工作。可操作性检查会等元素附加、可见、稳定、可用,而不是立刻失败,所以有些时序问题换成定位器后就消失了,尽管身份什么都没变。
  4. 可访问名字变了。 把按钮从「保存」改成「保存草稿」,或者加一个改变计算名字的图标。匹配旧名字的角色定位器失败了,但这个失败比 class 改名更有信息量,因为它通常反映了一次真实的产品变化。如果标签由内容或翻译控制、会一直变,诚实的选择是 test id,而不是再写一个文本匹配。
  5. 控件挪进了新的流程。 多一步确认,或者动作藏进了以前没有的菜单。所有定位技术都会以同一个原因失败:旧的那一步不再成立。你可以把一个正确的定位器指向一个正确的元素,但如果那一步本身不该存在,任务照样完不成。人得改工作流;而一个有人监督的 agent 往往能看着当前页面另找一条路把任务做完。

还有一种情况即便界面没变也表现得像第五类:页面是别人的第三方网站。维护者可以毫无预警地重构它,定位器写得再好也护不住你,因为你依赖的那份契约从来不属于你。

我在建联系统里的三条规则

同步回复的脚本要进邮箱读信、再进 KOL 后台改状态,两个页面我都不拥有,都会改版。被坑过几次之后,我给自己定了三条规则。

  • 定位只用角色加名字,或者 test id。 脚本里不允许出现 nth-child 和生成的 class。这一条消灭了第一层的绝大多数失败。
  • 每次失败先分层,再决定改什么。 脚本捕获到错误时自动存一张截图、一份快照和当前 URL,然后退出。我看完证据再决定是改等待、改定位,还是改任务。不允许「先把超时调成 60 秒再说」。
  • 第三层交给 agent 重新规划,而不是硬改脚本。 当后台把「同步」按钮挪进了下拉菜单,脚本当然坏了。但这时我不去猜新路径,而是让 Claude Code 通过 ego-browser 看一眼当前页面,描述它看到的路径,确认之后再把新步骤写回脚本。确定性的脚本负责日常,有判断力的 agent 负责变化。

最后一条的边界很清楚:工作流稳定、步骤明确、要反复跑,就用脚本;页面在变、路径要重新找,就让 agent 看着页面做决定。元素变了,修选择器;流程变了,重新规划。

常见问题

只有在页面状态那一层才算,而且前提是你已经确认元素确实会出现、只是慢。对标识符失败和工作流失败,调长超时只是让你多等几十秒看同一个错误。

控件的名字稳定就用角色加名字,它同时验证了可访问性。名字由内容或翻译控制、会一直变,就用 test id。两者都比 class 和位置可靠得多。

日常的稳定任务用确定性脚本更便宜、更可预测。agent 的价值在第三层:页面变了、路径要重新找的时候。两者搭配比任何一个单独用都好。

想看这些想法落地的样子?

KOL 建联系统就是用这套方法搭出来的。