我让 AI 操作了 8 个内容平台,才发现 UI 里藏着多少「只有人看得懂」的约束

简介: 图形界面同时干着两件事——交互和约束。人用的时候感觉不到区别,因为约束是画给你看的:按钮灰了、选项变浅、提示条弹一下。当你让程序去操作同一个界面时,交互那部分它拿得到,约束那部分它几乎全看不见。

先给结论:图形界面同时干着两件事——交互和约束。人用的时候感觉不到区别,因为约束是画给你看的:按钮灰了、选项变浅、提示条弹一下。当你让程序去操作同一个界面时,交互那部分它拿得到,约束那部分它几乎全看不见。

于是就出现了这一类最难查的故障:操作返回成功,但什么都没发生。

这篇记录我把八个内容平台的发布流程自动化时踩到的坑。不讲怎么做自动化,讲的是界面到底把哪些信息只编码在了视觉层,以及这对任何「让程序驱动为人设计的系统」的场景都成立的一条原则。


一、最典型的一类:禁用态

某平台的标签选择器,上限是 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                     # 额度真的减少了,才算加上

这个数是平台自己维护的业务状态,不是我对操作结果的推断。

这是整篇最重要的一条:不要用「操作有没有执行」判断「状态有没有改变」。


二、第二类:视觉上存在,DOM 里不存在

另一个平台的发布按钮,截图上清清楚楚一个红色「发布」。但:

// 全页扫描,清洗掉零宽字符和空白后精确匹配
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()。

所以让程序驱动为人设计的系统时,有一条我认为绕不开的规矩:

永远不要用「操作的返回值」判断「状态是否改变」。要回读状态本身。

具体到实践:

不要 要
看 click() 有没有抛错 读平台自己维护的计数、额度、状态标签
看返回的 URL 长得对不对 比对 URL 里的业务 ID 是不是原来那个
看提交后页面有没有跳转 重新加载页面,回读内容
看某个元素在不在 看那个元素能不能真的起作用

最后一条尤其重要:当一个检查无论如何都返回相同结果时,它已经没有区分能力了,留着只会给你虚假的安全感。


七、这件事的适用范围比自动化大得多

我做的只是内容发布这么个小场景,但同样的结构在别处也成立。

当 Agent 开始替人操作各种系统时,它面对的正是同一个问题:过去很多业务约束是靠界面来实施的——某个字段你看不到是因为页面没渲染,某个操作你做不了是因为按钮灰着,某个流程必须走审批是因为页面强制你提交。

界面被绕开之后,这些约束不会自动跟着走。它们要么被显式地表达出来(在接口层、在权限层、在状态机里),要么就变成一堆「调用成功但什么都没发生」的静默失败。

我这一天遇到的每一个坑,本质上都是同一句话:约束没有被表达,只是被画了出来。


小结

症状 真正的原因 可靠的判据
操作返回成功,状态没变 禁用态只在 CSS 里 读平台自己的计数/额度
元素肉眼可见,代码找不到 closed shadow root 坐标反查定位
提交成功,改的却是别的对象 同一界面两套语义 比对返回的业务 ID
截图有内容,脚本说没有 两个 bug 叠加 每个环节独立回读
删除后还在 多步确认(回收站) 看平台的分类计数

如果只记一句:回读状态,别信返回值。


本文的实测来自把八个内容平台的发布流程自动化的过程,工具与踩坑记录由 Code2AI 团队维护。

相关文章
|
12天前
|
JSON API 数据格式
没有海外信用卡,怎么用上 Claude Code:四条路径和一个常见误区(2026 年 9 月)
「没有海外信用卡就用不了 Claude Code」其实把三件事捆在了一起:支付是商务问题、网络是工程问题、条款是法律问题——只有前两个能换路径解决。本文先拆问题,再给四条路径(国内云厂商、国产模型套餐、国内订阅制服务、已有海外主体),最后说明「以为搞到一张能付款的卡就解决了」为什么是误区。
|
2天前
|
缓存 C++
0.8MB 跑通 Qwen|第 3-1 篇:推理引擎的 mmap 直挂——为什么权重加载可以只要一秒多
本文实测RK3588板端冷启动仅1.0–1.2秒,核心在于mmap直挂VQF权重文件:不全量读取,仅映射+校验头部,权重页由内核按需缺页加载,配合预量化布局,较safetensors全量读快33倍。(239字)
 0.8MB 跑通 Qwen|第 3-1 篇:推理引擎的 mmap 直挂——为什么权重加载可以只要一秒多
|
1天前
|
编译器 C语言
0.8MB 跑通 Qwen|第 6-1 篇:推理引擎的 ARMv8.2 dotprod——`vdotq_s32` 一条指令做 4 个点积
本系列《0.8MB跑通Qwen》聚焦ARM零依赖纯C推理引擎,实测RK3588平台,适配Qwen3-VL多模型。本文详解ARMv8.2 dotprod指令(vdotq_s32)如何在真实大内核中释放性能,破除微基准误导,揭示寄存器压力下加速本质。(239字)
|
4天前
|
JSON 网络协议 Shell
装了三个客户端、配过两个网关之后,我的 Claude Code 连不上了
「连不上」几乎从来不是一个原因。它至少横跨四层——本机环境变量、配置文件、网络协议栈、端点本身——而这四层互相独立,任何一层出问题,表现出来都是同一句「连不上」。所以排查的第一步不是改配置,是先确定卡在哪一层。顺序错了,你会在最后一层上耗掉一下午,而问题其实在第一层。
|
8天前
|
人工智能 JavaScript 前端开发
AI 会不会推荐你的产品?我拿自己的网站测了一遍,结果是「没搜到这个产品」
现在值得投入的不是「让 AI 帮你把货卖了」,而是「让 AI 读得到你、并且转述对」。前者 OpenAI 已经试过一轮又退回来了;后者才是眼下唯一能靠自己改变的一层。
|
1天前
|
存储 缓存 编解码
0.8MB 跑通 Qwen|第 19-2 篇:推理引擎的 vision_tokens 客户端预编码协议
本系列《0.8MB跑通Qwen》专注手搓零依赖纯C推理引擎,适配Qwen3-VL多模态大模型,在RK3588(aarch64)实测通过。本文详解`encode_media_item`三路协议:`image_url`(板端ViT+128位缓存)、`video_frames`(帧序列)与`vision_tokens`(客户端预编码跳过ViT),支持字节数自校验与缓存命中优化,真机实测ViT耗时295ms→缓存后仅301ms。
|
1天前
|
编解码 算法 C++
0.8MB 跑通 Qwen|第 17-3 篇:位级一致性实验——推理引擎两条转换路产物逐字节对拍
本系列《0.8MB跑通Qwen》聚焦ARM端零依赖纯C推理引擎实现,实测RK3588上位级一致性验证:同源重跑确定性、无损源跨格式殊途同归、有损源再量化边界清晰可测。覆盖Qwen3-VL多模型,强调sha256/逐张量哈希/块级比对三位一体验证。(239字)
|
1天前
|
搜索推荐 算法 数据可视化
单词背了记不住怎么办?就用抗遗忘单词表 APP
抗遗忘单词表APP基于艾宾浩斯遗忘曲线,智能规划复习时间,在遗忘临界点提醒复习,实现高效记忆。支持个性化调节、多端同步、词库导入与可视化追踪,助你告别死记硬背,用科学方法把单词真正记住。(239字)
|
1天前
|
缓存 自然语言处理 安全
CLM 与后缀缓存复用:让模型自己管上下文
CLM(上下文语言模型)将上下文管理从外部规则升级为模型原生能力,支持像编辑文件一样重写、删除、重排上下文;配套SCR缓存复用技术,在性能持平下降低35%算力。论文尚未同行评审,代码开源但含非商用许可。
34 0
|
1天前
|
人工智能 自然语言处理 数据可视化
阿里云万小智怎么样?真正的AI建站,实测一句话企业官网上线(成本15元)
阿里云万小智3.0是AI驱动的一站式建站工具,用户仅需用自然语言描述需求(如“创建科技公司官网”),AI即可自动生成完整网站,支持可视化编辑与AI对话优化,首站上线成本低至15元,操作便捷高效。

热门文章

最新文章