我的浏览器自动化脚本挂在一个很蠢的地方:
lua
tag input not found while adding 'Python'
"没找到输入框。"
但我刚截过图。输入框明明在页面上,我甚至能量出它的位置。
这中间我花了两小时,反复改选择器。最后发现:它一直找得到。是我自己在找到之后,又把它从候选列表里删掉了。
下面是我撞上的三道假信号。每一道都长得像"我在仔细检查",实际上都是在丢掉正确的答案。
假信号一:我把几何位置当成了身份
我需要在发布面板里定位标签输入框。我的做法是:找出所有可能的输入框,取最靠上的那个。
python
ins = [...document.querySelectorAll('input.byte-select__input')]
ins.sort((a, b) => a.r.y - b.r.y)
const el = ins[0].el; // "最靠上的那个"
第一次它成功了。 第一个字段(添加标签)就在最上面。
然后它悄悄坏了。
因为加完一个标签之后,DOM 里的顺序变了。而"最靠上"这个假设不再成立 ------ 我的后续输入落到另一个字段里去了。
具体后果是:我要往「标签」里填的三个词,第二、三个跑进了「创作话题」。而创作话题是可选字段 ------ 界面上完全正常,没有任何报错,提交按钮就在下面等着我按。
再往前一步,我就把一串没人看的字符串带进了正式发布。
"最靠上"不是身份,是一次侥幸。
规则
锚定结构,不要锚定坐标。
位置和顺序是会变的状态。能不能定位到一个控件,应该取决于它在结构里的位置,而不是它在屏幕上的位置:
python
# 不要这样
ins.sort(by_y); el = ins[0]
# 要这样:找到那个 label 是"添加标签:"的容器,再取它里面的输入框
for (const fi of document.querySelectorAll('.form-item')) {
const lab = fi.querySelector('.label');
if (lab.innerText.trim() !== '添加标签:') continue;
return fi.querySelector('input'); // 结构关系,不受顺序影响
}
结构是稳定的,位置是流动的。
假信号二:我的过滤器把目标删掉了
上面那个方案里,我加了一条看起来很负责的过滤:
python
.filter(o => o.r.width > 50 && o.r.height > 10)
这条过滤删掉了我要找的东西。
那个输入框空的时候只有 42 像素宽(它随输入内容增长)。
42 < 50,所以它被排除了。然后脚本非常诚实、非常自信地报告:
css
tag input not found
我盯着这句话看了很久,因为这句话是真的------在它自己那个已经过滤过的世界里,确实找不到。
而"过滤器存在"这件事,是我写的,我却忘了。
我以为我在观察,其实我在筛选。
规则
过滤器不是观察,是决定。查不到东西时,先怀疑过滤器,再怀疑事实。
那条规则最后是怎么被用上的
我列了一张表,把所有候选的原始属性打出来,不做任何过滤:
ini
.byte-select__input w=42 h=24 y=271 closest('.tag-input')=true
.byte-select__input w=0 h=0 y=0 closest('.tag-input')=false
.byte-select__input w=42 h=24 y=530 closest('.tag-input')=false
一眼就看到了那个 42。 而在此之前,我"知道"宽度过滤是合理的,所以我从来没去看它到底删掉了什么。
看到原始数据之后,答案通常不需要推理。
假信号三:类名存在 ≠ 元素可见
我需要判断"发布面板是不是开着"。我写的检查是:
javascript
!!document.querySelector('.publish-popup')
它在两种完全不同的状态下都返回 true。
因为 .publish-popup 这个类名在"发布"按钮上也有:
| 元素 | 类名 | 尺寸 |
|---|---|---|
| 发布按钮 | publish-popup publish-popup with-padding |
62 × 32 |
| 发布面板 | publish-popup publish-popup with-padding **active** |
560 × 799 |
同一个类名,一个是按钮,一个是面板。我的检查永远通过。
于是我的脚本以为面板开着,去操作里面那些 0×0 的元素,然后"元素不可见"。
规则
问"它是不是可见、有没有尺寸",不要问"这个类名存不存在"。
python
# 不要这样
ok = document.querySelector('.publish-popup') !== null
# 要这样
for (const el of document.querySelectorAll('.tag-input input')) {
const r = el.getBoundingClientRect();
if (r.width > 0 && r.height > 0) return true; // 真的渲染出来了
}
类名匹配会同时命中"容器"和"里面随便什么东西"。渲染尺寸不会撒谎。
让我脱困的那一行打印
三道假信号我都改完之后,我以为好了。还是不行。
rust
+ tag 'Python' (matched option 'Python')
tag chips: 1 -> 1 ← 加了,但没加进去
日志说"匹配到了选项",芯片数量说"没变化"。两个信号打架。
这时候我做了一件本该更早做的事:不打印我自己的推断,直接打印那个字段的真实值。
python
typed = page.evaluate("() => document.querySelector('.tag-input input').value")
print(f"input value after typing: {typed!r}")
输出:
css
input value after typing: ''
空的。 我敲进去的所有字符,一个都没进去。
这句话让整个问题变得确定:焦点从来没有落到那个输入框上。
原因还是那个 42 像素 ------ 我为了"保险"给点击点加了个最小边距:
python
w = max(real_width, 60) # 我以为这更安全
click(x + w / 2, y + h / 2) # 结果点到了输入框右边外面
我以为的安全边距,把我自己推出了目标。
改成用 Playwright 的 locator 点击(它自己处理滚动和居中)之后,一次就对了。
规则
当"动作报告成功"和"状态说没成功"冲突时,相信状态。
我的日志说 matched option 'Python' ------ 那是我的判断 。 value = '' 是事实。
日志记录的是我以为发生了什么;状态记录的是发生了什么。 两者冲突时,只有一个能作为下一步的依据。
而且这条还有个推论:要打印原始状态,不要打印你对状态的理解。
python
print(f"clicked the tag option") # 我的推断 ------ 可能错
print(f"input value: {value!r}") # 事实 ------ 不会骗人
我前面两小时都在打印第一种。
把三道假信号放在一起看
| 假信号 | 我以为我在做的 | 实际发生的 |
|---|---|---|
| 几何位置当身份 | 精确定位 | 一次侥幸,状态一变就失效 |
| 宽度过滤 | 排除干扰项 | 把目标删掉了 |
| 类名存在性 | 检查状态 | 命中了同名按钮,永远为真 |
三个都是"看起来更严谨"的操作。
它们不是粗心,恰恰相反 ------ 我在"仔细地"排序、"谨慎地"过滤、"规范地"用类名判断。每一个都让代码看起来更专业,同时悄悄地把正确答案排除在外。
严谨的动作如果不检查自己的前提,就只是精致的错误。
如果你也在写浏览器自动化
这三条是我现在会先做的:
- 定位用结构关系,不用坐标或顺序。 找到那个有明确文本的标签/容器,再取它里面的元素。
- 任何过滤器都要能被单独关掉验证一次。 尤其是有阈值的(
width > N、y > M)。先打印未过滤的原始列表,再决定过什么。 - 判断元素状态用
getBoundingClientRect()的尺寸,不用类名。 类名是给人看的,尺寸是给渲染引擎算的。
还有一条和工作流有关的:
- 动作之后立刻读回状态。 不要"填了就当作填上了"。填完之后把那个字段的值打出来 ------ 一个空字符串能省你两小时。
本文所有代码片段、报错文本与输出均为实际运行记录,问题已修复,规则已应用到后续脚本中。