界面说「已保存」,接口说「每天只能发 5 篇」:一次发布失败的完整取证
下午三点,我往简书补发第五篇文章。脚本正常退出,日志里该有的都有:草稿建好了、标题设上了、正文粘进去了、保存接口返回 200、发布按钮的点击也确实触发了。编辑器右下角安安稳稳显示着「已保存」。
然后文章没上线。
这篇文章记录接下来四十分钟里我是怎么把原因找出来的。结论不重要,过程里那几个「我差点就信了」的瞬间比较有价值。
一、先把证据原样贴出来
不加工、不截取,脚本输出全文:
sql
CREATE {"status":200,"id":144961809,"slug":"bca3fa64eebe"}
TITLE SET=别信文案,信接口:给自动化脚本装上「接口级断言」
AREA {"x":1024,"y":224}
PASTE_LEN 2322
SAVE PUT=200 {"id":144961809,"content_updated_at":1792127513,"content_size_status":"fine"}
SERVER {"status":200,"title":"别信文案,信接口:给自动化脚本装上「接口级断言」","contentLen":3187}
PUBLISH PUBLISH_CALLED action=publicize
七行,每一行单看都像成功。唯一的不协调是:作者页文章数还是 13,没有变成 14。
注意这里已经出现了一个反例。「保存成功」和「发布成功」是两件事,而我手里只有前者的证据。保存接口返回 200,只能证明草稿存住了,证明不了任何关于发布的事。
二、逐个排除,别跳步
排除一:点击根本没生效?
第一反应是脚本点了但没点动。简书的发布按钮在顶部导航里,之前踩过hover 浮层遮挡的坑。
重试三次,每次都打印 CALLED------React 的 fiber 链里确实找到了 onClick 并调用了,容器节点也拿到了。三次都一样。
不是点击问题。 但如果我这时候停下来说「点击生效了所以应该成功了」,就错了。点击生效只说明前端函数被调用,不说明服务端接受了请求。
排除二:内容超了链接数?
简书对非会员有链接数限制,这是已知约束。我之前按 markdown 源文清洗过,但简书实际是在渲染后的 HTML 加 innerText 上计数的------这两者不是一回事。
于是直接在编辑器里量。把官方同款计数正则跑在渲染结果上:
ruby
COUNT {"htmlLen":3187,"textLen":2322,"filteredCount":0,"urlMatches":[]}
零。不是链接问题。
这一步顺带纠正了一个方法论错误:我原来只检查源文件,源文件干净不等于渲染后干净。虽然这次两边都是 0,但检查的位置本来就选错了。
排除三:索引延迟?
掘金那边真实遇到过:发布成功、接口回执 err_no:0,一分钟后查文章页是「找不到页面」,再查标题就正常了。所以发布后立刻查不到,不能直接判定失败。
等。等了五分钟,作者页还是 13 篇。
不是延迟。 延迟会自愈,这个没有。
排除四:我在用弱判据骗自己
我原来的判据之一是抓页面上的提示文案。日志里确实抓到了「发布成功」三个字------我当时挺高兴。
然后才发现:那是我稿子正文里引用的词。这篇稿子讲的就是发布成功与失败,正文里到处都是。我抓 toast 的正则把正文区也扫进去了。
文案判据的问题从来不是「经常错」,是它错了你不知道。这次它错得很漂亮:抓到了一个语义上完全合理的成功信号,而事实正好相反。
四、去听接口怎么说
前端该看的都看完了,只剩一条路:把发布这个动作发出的请求和响应抓下来。
挂 fetch 与 XHR 的钩子,记录 URL、状态码和响应体,然后再点一次发布:
swift
CLICK CALLED
NETLOG [{"u":"/author/notes/144961809/publicize","s":403,
"b":"{\"error\":[{\"code\":2016,\"message\":\"公开文章发布失败,现在每天只能发布 5 篇公开文章哦,请明天再来吧。\"}]}"}]
DIALOG ["字数:1459","已保存"]
原因一句话就说清楚了:简书非会员每天只能发布 5 篇公开文章,今天已经发满了。
而同一时刻,界面上显示的是「已保存」。没有任何 toast、没有任何红字、没有任何模态框。服务端把原因写得明明白白,前端一个字都没往外露。
五、这件事的通用形状
把这次的取证过程抽象一下,是个可以复用的结构:
| 判据 | 这次的表现 | 结论 |
|---|---|---|
| 退出码 / 日志无 ERROR | 全绿 | 弱,只证明脚本自己没崩 |
| 接口返回 200 | 保存 200 | 中,但只覆盖被调用的那一个接口 |
| DOM 状态(按钮可点、fiber 有 onClick) | 三次 CALLED | 中,只证明前端函数被调用 |
| 页面文案 | 抓到「发布成功」(其实是正文) | 最弱,且会主动骗人 |
| 服务端响应体 | 403 + error 2016 + 完整中文原因 | 强,真相在这里 |
排班的顺序也有讲究:从便宜到贵,从本地到远端。先看日志(不要钱),再看 DOM(不要钱),最后才上网络抓包(要写代码)。但不管前面几层给出了多好的信号,只要终态不对,就得继续往下走。
这次我卡了很久,是因为第三层和第四层都给出了「合理」的信号,而它们合起来仍然不构成发布成功的证据。合理不等于发生。
六、两条能直接抄走的规则
规则一:发布类动作,判据必须落在「发布」那个接口的响应体上,不能落在保存、草稿、编辑任何邻近接口上。
这次我有四个 200:建稿 200、设标题成功、保存 200、读取 200。全是真话,但全都不是答案。它们描述的是这条链路上的其他环节。
规则二:服务端返回错误时,响应体比状态码更重要,比界面重要得多。
403 只是个状态码。error code 2016 加上那句中文,才是能直接指导下一步的信息------它告诉我这不是故障,是配额,明天再来就行,不用改任何代码。
如果我当初看到「点击生效但文章没上线」就开始改脚本,现在大概已经在重写发布逻辑了。而真正该做的只是等一天。
七、尾声
原因找到之后,事情变得很简单:把剩下的稿子排进每日队列,让自动化在额度刷新后接着发。
但我更想记下的是那个瞬间------日志里躺着「发布成功」四个字,我挺高兴,然后发现那是自己稿子里的词。
你最信任的那个信号,可能正是你自己写进去的。