Electron 发布核验 别把没查到当成没发出去

平台侧核验这件事,我们做了快两个月,踩得最深的坑不是「查不到」,而是把「查不到」直接翻译成「没发出去」。

三源(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

配套三条约束,都是踩出来的:

  1. 重试间隔要长于渲染时间,不是指数退避 。这里等的是别人的前端渲染,不是网络抖动。我们用「首次 15 秒(脚本内的 time.sleep(15))+ 外部重试间隔 ≥ 30 秒」,实测能覆盖绝大多数 containerLen=0 的首屏空窗。
  2. 重试只重跑核验,绝不重跑发布。这两件事必须分开成两个函数,否则一次误判就等于一条重复内容。
  3. 重试耗尽仍然不判失败 。最后一次还是 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 后缀的键。这样「当时判了什么」和「后来是不是翻了」两条信息都在,事后复盘不会被改写的历史误导。

整套东西下来,最省力的其实不是哪段代码,而是把一句话钉在判定入口上:

发布成功是「我提交了」,可见是「平台让我被看见」。前者是动作的回执,后者才是结果。

把这两件事混在一个布尔值里,就会不断重复发同样的内容。拆开之后,四态判定、有限重试、次日复验这三件事自然就长出来了。

相关推荐
开开心心就好1 小时前
文件被占用删不掉?解锁工具强制删除
java·运维·服务器·开发语言·前端·pdf·ocr
flash俊杰1 小时前
桌宠点击反应:把一坨 if 拆成「归一化 → 门控查表 → 执行 → 广播」
electron
inksci2 小时前
发布Ai生成的html markdown 推荐
前端·javascript·html
在西安放羊的牛油果2 小时前
WebP 图片优化方案详解
运维·前端·架构
coding漫漫长路2 小时前
签名PDF一压就失效,接压缩需求前先查这5件事
前端
Lank_M2 小时前
PDF签完名还能改什么?会签能过,压缩不行
前端
补码缩写2 小时前
压缩前查PDF签名,字节搜索会漏掉对象流
前端
星光不问赶路人2 小时前
Vue 项目类型检查:深入理解 vue-tsc --build
前端·vue.js