先给结论:图形界面同时干着两件事——交互和约束。人用的时候感觉不到区别,因为约束是画给你看的:按钮灰了、选项变浅、提示条弹一下。当你让程序去操作同一个界面时,交互那部分它拿得到,约束那部分它几乎全看不见。
于是就出现了这一类最难查的故障:操作返回成功,但什么都没发生。
这篇记录我把八个内容平台的发布流程自动化时踩到的坑。不讲怎么做自动化,讲的是界面到底把哪些信息只编码在了视觉层,以及这对任何「让程序驱动为人设计的系统」的场景都成立的一条原则。
某平台的标签选择器,上限是 2 个。加满之后,剩下的候选项会变成浅灰色。
人看到浅灰色就知道点不动了。程序看到的是什么?
// 加满之后,对一个"禁用"的候选项调用
el.click(); // 不抛错,不报警,正常返回它不抛错、不返回 false、DOM 上也没有 disabled 属性——只是 CSS 把它画浅了,并且点击回调里加了个提前 return。
所以我的代码是这么写的:
if click_option(page, tag):
print(f"✅ 标签 {tag} 已添加")三个标签,三次 ✅。实际只加上了两个。
日志、返回值、异常,三条通道全部显示成功——只有截图能看出问题。
后来改成读平台自己算的那个数:
# 页面上有一行「你还能添加 N 个标签」
left = read_remaining_quota(page) # 加之前
click_option(page, tag)
now = read_remaining_quota(page) # 加之后
ok = now < left # 额度真的减少了,才算加上这个数是平台自己维护的业务状态,不是我对操作结果的推断。
这是整篇最重要的一条:不要用「操作有没有执行」判断「状态有没有改变」。
另一个平台的发布按钮,截图上清清楚楚一个红色「发布」。但:
// 全页扫描,清洗掉零宽字符和空白后精确匹配
Array.from(document.querySelectorAll('*'))
.filter(e => clean(e.innerText) === '发布')
// → 只找到侧边栏的「发布笔记」和一个「定时发布」开关,没有那个按钮排查绕了很久,因为它伪装成好几种常见问题:先怀疑按钮文本里夹了零宽字符(清洗了还是找不到),再怀疑在 iframe 里(只有主文档),再怀疑 DOM 查询被截断了。
最后是用坐标反查一步定位的:
document.elementFromPoint(743, 855)
// → <xhs-publish-btn submit-text="发布" save-text="暂存离开" submit-disabled="false">按钮文字放在 HTML 属性上,真正的按钮渲染在 closed shadow root 里。 element.shadowRoot 返回 null,querySelectorAll 穿不进去,innerText 是空串。
所有基于文本的定位——包括各种测试框架的 getByText——在这里结构上不可能成功,不是选择器没写对。
所以解法是按坐标点。这又带出一个更阴的问题:
我一开始按容器宽度的比例算坐标,落在了按钮右侧的空白上。而 closed shadow root 让 elementFromPoint 无论点容器里哪一处都返回同一个元素——看着像命中了,一切"正常",就是不发布。
后来改成按容器中心的固定偏移算(按钮尺寸是固定的,容器宽度随窗口变),才稳定。
教训:当一个查询无论如何都返回同一个结果时,它就不再有区分能力,也就不能拿来当校验。
某平台的文章编辑页,URL 里带着文章 ID,标题和正文都正确加载了原文。页面上有个「导入 md 文件」的入口——发布新文章时我一直用它,因为它能顺带解决标题栏的一个问题。
在编辑页用同样的方式导入、提交,返回的成功页长这样:
https://.../creation/success/164183558而我编辑的那篇文章 ID 是 164152491。
它新建了一篇重复文章,原文纹丝不动。
「导入」这个动作在产品语义里始终是「导入成一篇新文章」,跟你当前打开的是不是编辑页无关。而成功页的 URL 格式一模一样——不比对 ID 根本发现不了。
后来改成剪贴板粘贴(直接改当前文章的编辑器内容),并加了一道硬校验:
if returned_id != original_id:
print(f"🔴 新建了一篇重复文章({returned_id},原文是 {original_id})")
return 1编辑已发布内容时,按钮的名字会变:
平台 | 新建时 | 编辑时 |
|---|---|---|
A | 发布 | 更新 |
B | 发布 → 确定并发布 | 更新 → 确定并更新 |
C | 发布 | 保存修改 |
C 那个最坑:页面上同时还有一个「发布」文本元素(在别处)。我的宽匹配点到了它,返回 True,然后什么也没发生。
判据当时写的是「URL 里的文章 ID 没变」——它确实没变,因为压根没提交。校验条件和失败模式正交,等于没校验。
最后加的规矩是:保存后重新加载页面,回读内容比对。这是唯一不会被骗的判据。
这一类不怪界面,怪我。
某平台的标签框里已经有两个标签 chip,我想清空搜索输入框,写了:
input.fill("")结果两个 chip 一起没了。
fill 的实现是「聚焦 → 全选 → 替换」。在这类复合组件里,全选的选区包含了 chip,插入空文本等于把它们删掉。而 fill("非空") 就没这个问题——它替换的是选区内容。
更麻烦的是这个 bug 和第一节那个「判据不可靠」叠在一起:判据永远返回 False → 每次都走清空分支 → chip 被删光 → 判据继续 False。表现是「截图里明明有标签,脚本却说一个都没加上」。
两个各自不致命的问题叠在一起,症状会指向一个不存在的第三个原因。
删一篇文章,我以为点了「删除」→「确定」就完事了。
页签数字确实变了:已发布 10 → 9。但另一个数字我没看:回收站 0 → 1。
删除只是移到了回收站,回收站里还有一步「彻底删除」,弹窗文案是「删除后无法恢复」。
我当时的判据是「页面上还有没有这篇文章的链接」——回收站里的文章链接仍在列表页 DOM 里,所以判据显示"仍在",我以为删除失败了。判据错了两次,方向还相反。
真正可靠的信号是平台自己的计数:全部 12 → 11、回收站 1 → 0。
把上面五类放在一起看,共同点很清楚:
界面把「能不能做」这件事,大量地只编码在了视觉层。
按钮灰了、选项浅了、提示条弹了、菜单项禁用了——人一眼就懂,程序拿到的却是一个正常返回的 click()。
所以让程序驱动为人设计的系统时,有一条我认为绕不开的规矩:
永远不要用「操作的返回值」判断「状态是否改变」。要回读状态本身。
具体到实践:
不要 | 要 |
|---|---|
看 | 读平台自己维护的计数、额度、状态标签 |
看返回的 URL 长得对不对 | 比对 URL 里的业务 ID 是不是原来那个 |
看提交后页面有没有跳转 | 重新加载页面,回读内容 |
看某个元素在不在 | 看那个元素能不能真的起作用 |
最后一条尤其重要:当一个检查无论如何都返回相同结果时,它已经没有区分能力了,留着只会给你虚假的安全感。
我做的只是内容发布这么个小场景,但同样的结构在别处也成立。
当 Agent 开始替人操作各种系统时,它面对的正是同一个问题:过去很多业务约束是靠界面来实施的——某个字段你看不到是因为页面没渲染,某个操作你做不了是因为按钮灰着,某个流程必须走审批是因为页面强制你提交。
界面被绕开之后,这些约束不会自动跟着走。它们要么被显式地表达出来(在接口层、在权限层、在状态机里),要么就变成一堆「调用成功但什么都没发生」的静默失败。
我这一天遇到的每一个坑,本质上都是同一句话:约束没有被表达,只是被画了出来。
症状 | 真正的原因 | 可靠的判据 |
|---|---|---|
操作返回成功,状态没变 | 禁用态只在 CSS 里 | 读平台自己的计数/额度 |
元素肉眼可见,代码找不到 | closed shadow root | 坐标反查定位 |
提交成功,改的却是别的对象 | 同一界面两套语义 | 比对返回的业务 ID |
截图有内容,脚本说没有 | 两个 bug 叠加 | 每个环节独立回读 |
删除后还在 | 多步确认(回收站) | 看平台的分类计数 |
如果只记一句:回读状态,别信返回值。
本文的实测来自把八个内容平台的发布流程自动化的过程,工具与踩坑记录由 Code2AI 团队维护。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。