CDP 自动化网页编辑器:三个静默失败的坑与回读校验
把内容发到十几个平台,最麻烦的不是"找不到按钮"。按钮就在那里,选择器也对,脚本退出码 0,日志里一行 error 都没有——但东西没发出去。踩到的坑集中在三类静默失败上。
一、登录判据:别看头像
一次典型误判:脚本报"未登录",人工打开同一个浏览器,账号明明还挂着。原因是判据选错了元素——不少站点用 canvas 或懒加载渲染头像,脚本查 img.avatar 得到 false,不代表会话失效,只代表那张图还没画出来。
可靠判据是"能不能直达受保护的路由":
location.href.includes('/editor/')
&& !!document.querySelector('input.title-input')
未登录时站点会 302 跳转,这条判据跟着跳转结果走,不依赖视觉元素。
二、受控输入:直接改 value 不触发状态
React 和 Vue 的受控输入框,DOM 上的 value 和框架内存里的 state 是两份数据。直接 el.value = title 只改了前者,提交时框架读的是后者——空的。正确做法是用原型上的原生 setter 绕过劫持,再补发事件:
const el = document.querySelector('input.title-input');
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype, 'value').set;
setter.call(el, title);
el.dispatchEvent(new Event('input', {bubbles: true}));
el.dispatchEvent(new Event('change', {bubbles: true}));
顺序不能反:先 setter 再事件,反过来事件派发时框架读到的还是旧值。
三、正文:改 DOM 不等于改文档模型
富文本和 Markdown 编辑器基本都在 DOM 之上有自己一层文档模型。往 textarea 写值、往 contenteditable 塞 innerHTML,界面看着有字,提交上去是空文档。CodeMirror 5 直接拿实例:
const cm = document.querySelector('.CodeMirror').CodeMirror;
cm.setValue(markdown);
cm.refresh();
cm.getValue().length; // 回读
setValue 走编辑器自己的变更通道,文档模型会同步更新;refresh() 重算行高和可视区,跳过这步在长文里会出现末尾段落不渲染。Draft.js 一类没有这种入口,只能粘贴或逐段键入,而粘贴常常无视光标位置直接追加——同一段粘两次就是两份,这类要改成"一次性构造完整文档"并用 [data-block="true"] 的块数校验。
四、点击:三种点法的成功率差很多
同一个按钮、同一个浏览器,三种点法结果完全不同:
| 点法 | 结果 |
|---|---|
el.click() | 无请求、无报错、页面不变 |
| CDP 坐标点击 | 无请求、无报错、页面不变 |
| PointerEvent 序列 | 请求发出,操作生效 |
一部分前端组件把逻辑挂在 pointer 事件上(按下 / 抬起 / 点击的完整序列),只派发 click 在它看来不成立,于是静默返回。可用序列:
const o = {bubbles:true, cancelable:true, view:window, pointerId:1,
pointerType:'mouse', isPrimary:true, button:0, clientX:x, clientY:y};
el.dispatchEvent(new PointerEvent('pointerdown', o));
el.dispatchEvent(new PointerEvent('pointerup', o));
el.dispatchEvent(new MouseEvent('click', o));
关键判据是网络请求,不是界面变化。 点之前在页面上挂 window.fetch hook 记录出站请求,点完读:列表空着说明事件根本没被框架收到,问题在前端;有请求再去看状态码。这一条能省掉大量"以为平台拒绝、其实是没点中"的误判。
顺手定了一条规矩:同一个按钮试三种点法都不发请求,就停手换路,不要继续加变体。反复重点同一处最耗时间,而且结果不能说明任何问题——真正该查的是按钮 handler 的前置校验条件(表单哪一项没通过),或者干脆走接口重放。
五、回读校验:字数对不上就是没进去
注入成功与否别看编辑器有没有字,用编辑器自己的读数:CodeMirror 用 cm.getValue().length,富文本用块数或实例字数,表单用 el.value.length。
我们内部是硬比对:源文本长度必须等于回读长度。实测一篇 2931 字符的稿子,回读 2931,页面显示字符数也是 2931,三条一致才算注入完成。差一个字符就停下来查——往下走就是发一篇空稿或半篇稿。块数还有一个用处:Markdown 的空行丢了字数不变、段落却被并成一段,这时候读块数比读字数灵敏得多。
六、交付前:匿名可达才算真发出去
发布成功页、后台列表、"已发布"标签,这三个都在登录态下;用户点开链接是匿名态。所以交付前必须无 cookie 验一遍:
curl -sL -o /dev/null -w '%{http_code} final=%{url_effective}\n' \
-m 20 -A "Mozilla/5.0" "<文章URL>"
200 才算发出去。final= 指向登录页,说明该账号访问权限是"仅登录用户可见"——发布确实成功,但用户点开会要求登录,这种链接不能当成果交付。容易自欺的点是:带 cookie 的浏览器能正常读到正文,所以"我自己能看到"证明不了"别人能看到"。同理,"后台列表里有这篇"也不等于用户能打开,判据只认无 cookie 请求的最终跳转地址。
七、台账:手工发的也得记
我们有过一天:手工在三个平台各发一条,后台都有回执,但发布台账里一条没有——因为台账只由自动流水线写,手工操作绕过了它。后果是统计失真:查近 30 天记录了 462 条,全是定时任务发的老平台,手工发的一条不在里面。
规矩:任何一次发布成功(自动、半自动、纯手工)立刻补一条记录,字段至少四个——平台、标题、URL、时间。数据链路断了,后面的判断全是错的。
小结
三个静默失败的共同点:它们都不报错。唯一能证明操作真的发生的,是回读的数据和发出去的请求。所以流程固定成五步:直达受保护路由判登录 → 原生 setter 填受控输入 → 编辑器实例写入正文 → 回读长度硬比对 → 匿名请求验可达。少任何一步,都可能在一个"看起来成功"的状态里交付空结果。