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",不能当交付证据。判据按可信度排三层:
- 浏览器打开读
h1——最硬。刚发布时可能是"找不到页面",body 只有几十个字符,一眼能看出来; - 平台公开的列表接口——用作者 uid 拉列表,能搜到自己标题 = 已公开;
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 秒,是发完以后不知道到底发没发出去。