用 CDP 做发布自动化:四道机器闸门与产物验收

8 阅读7分钟

JavaScript前端发布这件事,人做一遍没问题,做成自动化之后真正难的,不是"点不到按钮",而是每一步都看起来成功了,实际什么都没发生。这篇文章把一条已经跑通的浏览器发布链路拆成四道机器闸门,每道闸门给出可照抄的判据和代码。文中数字全部来自实测。

闸门一:登录判据,不要读 cookie

最初的写法是读 cookie:sessionid 在就算已登录。这个判据在三个不同平台上分别撒过谎:

  • 有的站点被踢下线后 cookie 一条不删,页面已经跳去登录页;
  • 有的站点只剩 passport_csrf_token 这类访客 cookie,名字看着像会话;
  • 有的站点 cookie 齐全,但接口返回 401。

后来统一改成**"打开目标页 + 读落地 URL + 找关键元素"**三步,cookie 只用来筛"肯定没登过"这一档:

// 打开 https://juejin.cn/editor/drafts/new 之后
(function(){
  return {
    url: location.href,                                  // 必须停在 /editor/
    title_input: !!document.querySelector('input.title-input'),
    cm: !!document.querySelector('.CodeMirror')
  };
})()

三项同时成立才算已登录。未登录会被重定向到登录页,title-input 自然不存在,判据失效得很干脆,不会出现"半登录"这种模糊态。

顺带一个坑:别用头像元素判断登录。已登录状态下 img.avatar 和 .user-avatar 实测都是 false,拿它当判据会稳定误报"未登录"。

闸门二:注入后回读,字符数必须逐字相等

标题和正文是两种完全不同的输入控件,两边的失败方式都是"看起来填上了"。

标题是框架受控输入,直接赋 el.value = title 不会触发状态更新,提交时是空的。必须走原生 setter 再补事件:

const el = document.querySelector('input.title-input');
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')
  .set.call(el, title);
el.dispatchEvent(new Event('input',  {bubbles: true}));
el.dispatchEvent(new Event('change', {bubbles: true}));

正文是代码编辑器,改 DOM 或往隐藏的 textarea 写值都不会同步到文章模型。正解是拿到编辑器实例,写入后立刻回读:

const cm = document.querySelector('.CodeMirror').CodeMirror;
cm.setValue(markdown);
cm.refresh();
cm.getValue().length;   // 回读

回读值必须等于源文长度,不是"差不多"。实测一篇 2931 字符的稿子,getValue().length 与源文 len() 逐字相等,页面上的字符数也显示 2931。三个数字对上了才允许进下一道闸门。

只对字符数还不够。带富文本块的编辑器(Draft.js、Slate 这一类)能把两段粘成一段,字数不变但结构塌了。所以加第二个指标:块数。

document.querySelectorAll('[data-block="true"]').length

字数和块数双指标都通过,才算真正"注进去了"。

面板校验:读状态,不看界面

编辑器填对了,还有一道面板关。发布面板通常要求"分类 + 至少一个标签",这里最容易误判的是标签。

输入框里输入标签词、按回车之后,面板上会出现一个看着像标签的文字块,body.innerText 也能搜到这个词——但组件内部的状态数组仍然是空的,那只是输入框里的待提交文本。此时点"确定并发布":事件确实到达了按钮,disabled 是 false,可它不发请求、不报错、面板也不关。看上去就是"按钮点不动",其实是被前端校验拦了。

诊断只要一行——面板还开着时把状态数组读出来,空数组就是标签没落库,不用再去换点击方式。可靠的做法是等下拉渲染出来(约 2 秒),用真实鼠标事件点下拉里的同名项,再回读状态非空才允许提交。

另外两个细节:标签数量有上限,面板会提示"你还能添加 N 个",加满即停,不要因为"只加上一个"就判失败;第二个标签的残留文本必须在提交前清空,否则界面上看起来有两个词,实际只落库一个。

闸门三:产物验收,200 不等于发出去了

这条最反直觉。同一个 SPA 文章页,对存在和不存在的文章 id,匿名 curl 都返回 200,body 一模一样是空壳——实测 2397 字节,标题和正文一个字都没有。

所以 curl -o /dev/null -w '%{http_code}' 只能证明"路由没 404",不能当交付证据。判据按可信度排三层:

  1. 浏览器打开读 h1——最硬。刚发布时可能是"找不到页面",body 只有几十个字符,一眼能看出来;
  2. 平台公开的列表接口——用作者 uid 拉列表,能搜到自己标题 = 已公开;
  3. curl 状态码——只作辅助,不单独使用。

还要接受一个事实:上架有延迟。本次实测,发布成功页出现后,文章页在浏览器里连续约 8 分钟都是"找不到页面",公开接口的列表里也没有;大约 10 分钟后 h1 正常、列表里出现。这段时间的正确动作是等,不是重发——重发会变成同一标题两篇文章,比失败更难收场。

闸门四:台账,去重和执行本身一样重要

前三道闸门保证"这一篇发出去了",台账保证"下一次不会重复发、事后能说清发过什么"。

每条记录只留六个字段,按日期落一个 JSON 文件:

{"platform": "掘金", "title": "...", "url": "...",
 "time": "2026-11-13T10:20:00+08:00",
 "form": "article", "status": "published"}

两个配套约束:

  • 去重键 =(平台,归一化后的标题),发布前扫最近 30 天。标题归一化只做三件事:去空白、全角转半角、大小写统一。别做同义改写匹配,会误杀。
  • 拿不到链接时写状态,不写假链接。发布成功但列表还没索引到时,url 写 null、status 写 pending_index,隔 5 分钟用标题反查补回。绝不能因为"没拿到链接"就再发一遍。

闸门怎么落到代码里

四道闸门在同一进程里串起来,每个原子(check-login / fill / publish / verify)只做一件事,标准输出最后一行固定格式:

===RESULT=== {"cmd": "fill", "ok": true, "body_chars": 2931, "category": true, "tags": ["Python"]}

退出码 0/1 表示成败,JSON 里放本次的关键读数。调用方不用解析人类可读日志,只看这两样。好处是某天页面改版,失败会立刻落在具体原子、具体字段上——body_chars: 0 是没注进去,tags: [] 是标签没落库——而不是笼统的一句"发布失败"。

一次完整发布(填写 + 选分类 + 加标签 + 确认 + 取链接)实测约 52 秒,其中标签下拉的渲染等待占了大头。那 2 秒不能省,省了就是随机失败。

一条经验:同一处试两次就换路

调试期间最大的时间浪费,是在同一个动作上反复换点击方式:el.click()、坐标点击、合成 PointerEvent 序列,三种都"点了但没反应"。最后定位到的根因跟点击方式无关——是必填项没落库,前端校验把提交拦住了:事件确实到达了按钮,disabled 也是 false,就是不请求、不报错、面板不关。

于是定了一条规矩:同一个动作失败两次,先读状态再改手法。先把变量读出来——必填项是不是空、字符数是不是 0、URL 是不是还停在编辑页——读到具体读数再决定改什么。读数都拿不到,才去怀疑点击方式。

这套闸门的价值不在"自动化",在于失败时能定位。发布链路上最贵的从来不是那 52 秒,是发完以后不知道到底发没发出去。