平台侧核验这件事,我们做了快两个月,踩得最深的坑不是「查不到」,而是把「查不到」直接翻译成「没发出去」。
三源(CLI 回执、主日志、pushData)全绿之后,第四源去平台页面找那条标题。找不到就报 NOT_FOUND,于是调度器下一轮判定「这条没上」,重新发一遍。结果是:内容没少发,是多发了。重复内容比漏发更容易触发平台风控。
这篇文章讲我们后来怎么把这套重试逻辑收紧的:结果判定从「命中/没命中」两态,改成四态加置信标记;重试只在能追溯到具体原因的时候发生;以及为什么 headless Chrome 的「4 分钟无输出」绝不能当成失败。
一条标题在页面上要经过多少道关
先说清核验为什么天生会假阴性。verify_juejin_live.py 干的事是:解密 MatrixMedia 掘金分区的 cookie → 起 headless Chrome → CDP 注入 cookie → 打开创作者内容页 → 读 .essays-container 的 innerText → 归一化后做子串匹配。
这条链路每一环都可能让「明明发了」看起来像「没发」:
- SPA 首屏未渲染完 。内容列表是前端拉数据再渲染的,容器存在但 innerText 还是空的。我们见过
containerLen=0的假 NOT_FOUND,几十秒后再跑就是containerLen=672。 - 归一化口径不一致 。标题里全角半角、空格、标点,页面渲染时会做处理。所以我们匹配的不是原始标题,而是取前 12 个归一化字符 (
.split()去掉全部空白再拼回去)做包含判断。短了容易误命中,长了遇到一个被吃掉的空格就全废。 - 列表分页。发得多的时候,新条目不一定在第一页。
- 「审核中」不等于「没有」。这是最要命的一条,下文单独说。
也就是说,NOT_FOUND 的语义从来不是「平台没有这篇」,而是「在这一次的页面快照里,我没看见它」。这两句话差了一个数量级的可信度。
判定不是两态 是四态加一个置信度
我们最初的判定只有两个分支,后来按「结论能不能拿去驱动动作」拆成了四态:
| 判定 | 含义 | 允许的动作 |
|---|---|---|
| FOUND | 列表命中且条目前方无状态标记 | 记 success,不重发 |
| AUDIT_REVIEW | 命中但条目前方有「审核中」 | 停手,次日复验,不重发 |
| AUDIT_FAIL | 命中但条目前方有「未通过」 | 内容问题,改稿后另发,不重发同文 |
| NOT_FOUND | 列表加载正常但没命中 | 存疑,重试核验,不重发 |
| BLOCKED | 页面没加载出来 / 被挡 | 无判据,重试核验,不重发 |

关键在最后两行。NOT_FOUND 和 BLOCKED 都是「我没有判据」,不是「平台给了我否定的判据」。没有证据 ≠ 反向证据,这条区分决定了后面所有的重试策略。
判断「列表是否真的加载了」也有个具体门槛:页面 title 里必须含「掘金」,且 bodyLen 得是正常量级。title 不对就直接判 BLOCKED,连匹配都不做------省得拿一个还没渲染的空页面去算 NOT_FOUND。
那个「条目前方 40 字符」的检查是 2026-09-05 补的。原因是掘金列表页带 ?status= 时 SPA 内切路由不会重新查询,抓到的一直是「全部」列表,未通过的条目也在里面。所以命中标题根本不够,得回退 40 个字符看看前面有没有状态标签:
javascript
// 命中之后必须回查状态标记,否则 FOUND 是假阳性
const raw = container || body;
const i = raw.find(keyword.slice(0, 8));
let auditFlag = "";
if (i >= 0) {
const before = raw.slice(Math.max(0, i - 40), i);
for (const flag of ["未通过", "审核中"]) {
if (before.includes(flag)) { auditFlag = flag; break; }
}
}
我们自己就被这个绕过一次:2026-09-03 那批八平台核验,三源全绿,最后平台侧只有 4 个真上架。从那以后,「命中」和「可见」在我们的口径里就是两个不同的词。
重试要有边界 更要有原因
核验失败要不要重跑,我们不按「失败就重试」写,按失败类型写:
python
def should_retry(verdict, attempt, max_attempts=3):
# 有判据的结论(FOUND/AUDIT_FAIL)一律不重试:再查一次也不会变
if verdict in ("FOUND", "AUDIT_FAIL"):
return False
# AUDIT_REVIEW 是平台暂扣,重查无意义,交给次日的复验任务
if verdict == "AUDIT_REVIEW":
return False
# NOT_FOUND / BLOCKED 属于「无判据」,可以重试,但必须有上限
if attempt >= max_attempts:
return False
return True
配套三条约束,都是踩出来的:
- 重试间隔要长于渲染时间,不是指数退避 。这里等的是别人的前端渲染,不是网络抖动。我们用「首次 15 秒(脚本内的
time.sleep(15))+ 外部重试间隔 ≥ 30 秒」,实测能覆盖绝大多数containerLen=0的首屏空窗。 - 重试只重跑核验,绝不重跑发布。这两件事必须分开成两个函数,否则一次误判就等于一条重复内容。
- 重试耗尽仍然不判失败 。最后一次还是 NOT_FOUND,就记成
unverified落台账,不进「待重发」队列。宁可留一条待人工看的记录,也不自动重发。
第三条最反直觉,但收益最明确。过去我们把「核验失败」和「发布失败」接在同一个判断上,一次页面空窗就能让调度器重发一条已经很正常的内容。改成「发布成功是发布成功,可见是可见」之后,重复率直接归零。
4 分钟无输出 是沙箱不是脚本
这是本机最贵的一个假 BLOCKED。verify_juejin_live.py 平时 23 到 30 秒出结果,但偶尔会卡 4 分钟以上,一行输出都没有。第一次遇到的时候我们以为是脚本挂了,去改脚本、加日志、重试,全是白费。
真因是沙箱没有放行 headless Chrome 的启动:进程根本没起来,脚本自然什么都不打印。判据很简单:看有没有 title: 那一行。出了 title 说明浏览器起来了,剩下的是判定逻辑的事;一行都没有,就是没起来,先解决放行,不要动代码。
还有一个更省事的信号:脚本失败时会打 SAFE_DELETE_BULK_CONFIRM_REQUIRED 这行 safe-delete 拦截,然后以非零码退出。退出码非零不代表核验失败------VERDICT 那一行早就打印出来了,JSON 也落盘了。我们的调度器读的是打印出来的 verdict,不是退出码。这条和「真相不在退出码里」是同一条原则,只是在核验脚本上又复现了一次。
顺手记一条:绝不用 taskkill /f /im chrome.exe 收尾。那会把用户正常开着的浏览器一起杀掉,而且杀完下一次核验还是同样的启动问题。要清就按 CDP 的调试端口和临时 user-data-dir 精确清。
把重试状态留在磁盘上 而不是内存里
调度任务是长时续跑的,中间还可能被中断、被并发唤起。重试计数如果只在内存里,进程一重启就归零,max_attempts=3 变成「无限重试」。
所以我们的做法是:每次核验完,把 {verdict, containerLen, probe, checkedAt, attempts} 写进 _publish_logs/verify_<date>_<slug>.json。下一轮进来先读这份文件,attempts 从盘上取。
python
import json, os, time
def load_state(path):
if os.path.exists(path):
with open(path, encoding="utf-8") as f:
return json.load(f)
return {"attempts": 0}
def save_state(path, verdict, extra):
state = load_state(path)
state["attempts"] = state.get("attempts", 0) + 1
state.update(extra)
state["checkedAt"] = time.strftime("%Y-%m-%d %H:%M:%S %z")
# 先写临时文件再替换,避免写一半被中断留下半个 JSON
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(state, f, ensure_ascii=False, indent=2)
os.replace(tmp, path)
return state
os.replace 这里要留意一件事:NTFS 上重命名会把目标文件的创建时间改成临时文件的创建时间。我们另一篇文章专门写过这个副作用怎么把「按 ctime 排序选片」搞乱;在这个场景里它无害,但如果你下游拿 ctime 做排序键,记得别用这套写法。
从「两次判定」到「次日复验」
最后说一个流程上的改动,比代码更管用。
原先是发布后立刻核验一次,FOUND 收工,其余全部记疑。现在多了一个次日复验任务:把前一天判为 AUDIT_REVIEW / NOT_FOUND 的记录,第二天早上再跑一遍,只重跑核验。
这个动作解决了两类问题:
- 审核中会自己放行 。我们的实测记录里,2026-10-11 和 2026-10-14 两篇当天都是「审核中」,次日至第三日复验全部转为 FOUND,内容一个字没改。10-17 那篇也是隔天复验才转正。这类暂扣是平台的量闸,不是内容问题,改稿重发只会让情况更糟。
- 首屏空窗会自己消失。当天那一次 NOT_FOUND 很多时候纯粹是渲染没赶上,隔天再看就是整齐的一条。
复验记录以非破坏性方式追加到台账:原条目保留,新增一条带 #audit-review-cleared 后缀的键。这样「当时判了什么」和「后来是不是翻了」两条信息都在,事后复盘不会被改写的历史误导。
整套东西下来,最省力的其实不是哪段代码,而是把一句话钉在判定入口上:
发布成功是「我提交了」,可见是「平台让我被看见」。前者是动作的回执,后者才是结果。
把这两件事混在一个布尔值里,就会不断重复发同样的内容。拆开之后,四态判定、有限重试、次日复验这三件事自然就长出来了。