主题
点了 3 次发布,3 次都成功了:我把发布流程改成了状态机
先讲事故。
一条视频,我在发布页点了「发布」。页面没变化,没有 toast,没有跳转,按钮也没变灰。等了几秒,我又点了一次。还是没反应。第三次。
结果:三条一模一样的视频,全都发布成功了。 我手动删掉两条,花了十几分钟,还得担心账号被判重复内容。
失败的不是脚本,是模型。我当时脑子里的模型是「点一下 = 一个布尔值:成功 / 失败」。真实的发布是另一个东西:一个状态机,中间有一段时间你根本不知道发生了什么。
一、"没反馈"不等于"没发生"
这类事故的共同点,不是接口报错,而是正反馈缺失:
- 单页应用(SPA)里,点击后请求发出去了,但 UI 没更新;
- 请求成功了,但响应慢,前端 toast 还没弹出来;
- 平台做了异步审核,提交后状态是「审核中」,页面上看不出"已提交"。
任何一种情况,人(或者脚本)都会做同一个动作:重试。而重试的代价,是造第二条重复内容。
把发布拆开看,其实是四个状态:
待发 → 执行中 → 已提交(结果未知)→ 已确认 / 已失败大部分翻车都发生在第三个状态:已提交但没确认。这个状态被当成"失败",于是进入重试分支,重复就来了。
所以我做的第一件事,不是加重试,而是把这个中间状态显式命名,并给它一个独立的结果码。
二、三道闸门:查重、回读、重试门
现在每次发布都走三步,任何一步不通过就停手。
第一道:发布前查重
先把平台上「已经发过的内容」拉成一份列表,发布前比对标题。
标题对比不能直接字符串相等。平台会动你的标题:全角改半角、去掉多余空格、加 emoji、截断。归一化我做了三件事:
python
import re, unicodedata
def norm_title(t: str) -> str:
s = unicodedata.normalize("NFKC", t or "") # 全角/半角统一
s = re.sub(r"\s+", "", s) # 去掉所有空白
return s.casefold() # 忽略大小写命中就拒绝发布,退出码固定为 5。用固定退出码,是为了让上层编排(cron、脚本、Agent)不用解析文案,只看数字。
这里有个比代码更重要的坑:空列表不能证明你没发过。
有一次列表接口返回了限频错误,脚本拿到的是一份空列表。如果查重逻辑把"空"当成"没有重复",闸门就等于不存在,而且它会给你一个虚假的安全感。所以现在的规则是:列表抓取失败 = 状态未知 = 允许发布但必须由后面的回读兜底,绝不把"抓取失败"当成"零条"。
第二道:发布后回读
发布接口返回什么都别全信。发完去线上地址抓一次:
- HTTP 200,且正文里能找到归一化后的标题 → 已确认;
- 打不开、404、或者正文里没有标题 → 未确认(退出码
6),不是失败。
「未确认」和「失败」必须分开,因为它们的下一步动作完全相反:
- 失败 → 可以排查后重试;
- 未确认 → 去人工看一眼,禁止重试。
只这一个区分,就挡住了绝大部分重复发布。
顺便说一句,别拿 HTTP 200 当成功。SPA 的路由、登录墙、错误页,都可能返回 200。判断发布是否真的生效,只能看"目标页面上有没有你要的东西"。
第三道:重试门
如果真要重试,先查状态,再决定:
平台列表里已经有这个标题 → 已发过,停手(rc=5)
线上 URL 200 且正文含标题 → 实际已成功,停手(rc=5)
两者都没有 → 允许重试一次(rc=0)关键在顺序:先查状态,再重试。不是 while True: try_publish()。
这条规则听起来像常识,但在一个"页面没反应"的场景里,人是很难忍住不点的。所以必须把它写成机器规则,而不是写在文档里靠自觉。
三、跑起来之后,真正花时间的部分
三个函数本身不到一百行。上线之后,大部分时间花在了"让列表可信"上。
- 每个平台都得单独适配。 没有统一 API,有的要登录态,有的列表在接口里,有的只能抓页面。目前接了四个平台:掘金、小红书、雪球、B 站。
- 列表只读,且定时刷新。 每天固定时间抓一次,写成本地 JSON;发布动作本身不自动跑。只读抓取失败了不影响业务,写操作失败才是事故。
- 限频要退避,不要硬刷。 某个平台的列表接口会返回
-799(会话级限频)。我的处理是 45 秒退避、最多三次;三次之后仍然是空,就标记为"未知",而不是"没有"。 - 每次操作落审计日志。 一行 JSON:时间、动作、平台、标题、URL、退出码、备注。出事之后复盘,靠的是这份日志,不是记忆。
回归测试只做一件事:拿真实标题跑一遍,必须被拦下(rc=5);拿一个假标题跑,必须放行(rc=0)。加上各种归一化边界,一共十几个用例。这组测试的意义不是覆盖率,而是保证闸门不会在重构时悄悄失效——一个永远返回 0 的闸门,比没有闸门更危险。
四、三条可以搬走的结论
这套东西不只适用于发内容。任何"对外部世界产生写操作"的场景都成立:下单、发消息、提交表单、调第三方 API、给客户开通账号。
- 写操作必须由一次独立的读来确认。 不要相信返回值,更不要相信 toast。写完去读,读到才算数。
- 把"不确定"命名成一个状态。 只要它被塞进布尔值,早晚会被当成失败,然后触发重试。
- 重试要有前置条件。 重试之前先查状态;查不到状态,就不该重试。
那个点三次发布的中午,我损失的是两条重复内容。但如果这是给客户开通付费账号、或者重复扣款,代价就不是删两条视频了。
慢一点,确认一次,比快一点、发三遍便宜得多。
这篇是我在维护几个内容站点时分发流程的一段复盘。更多这类工程记录在 cosmocat.dev。