自动化发布 Loop 引擎(toutiao-auto-publish)
> 这是一个「只发送」的发布 Loop:读存稿目录,按时间表逐条发到目标平台,每发一条通知一条。
> 不管内容怎么来,只管把存稿按节奏发出去。
最初设计场景:今日头条微头条全自动发布。本文档抽象出其中可迁移的工程范式,剥离所有场景细节,让这套设计能套用到任何定时定量发布的自动化系统。
🎯 单一职责原则(设计哲学)
一句话定位:发布器只做「发送」这一件事。
为什么单一职责:
| ❌ 不做什么 | ✅ 做什么 |
|---|---|
| 不采集(不抓数据、不调 LLM) | 读存稿目录拿到待发内容 |
| 不生成(不写文案、不调模型) | 按时间表逐条发到目标平台 |
| 不关心内容来源 | 每发一条通知;失败计数+熔断 |
核心洞见:把"内容生成"和"内容发布"解耦后,整个系统的故障域被切开了——
- 内容质量问题(写不好)→ 不影响发已有的存稿
- 发布技术问题(登录态过期、被限流)→ 不影响继续写新存稿
- 两条线各自迭代、各自回滚、互不阻塞
> 这是 v1.5 重构的核心决策:从「采集→创建→发送」三段式编排器,简化为「只发送」单一职责。出问题好定位 + 改动不影响内容生成。
📝 使用前提:内容源需提前写好(在笔记库里)
这是整套系统的输入边界——必须先理解这一点,Loop 才有意义。
这套 Loop 不生成内容。它读的是你提前在笔记库(Obsidian / Notion / Logseq / 任何 Markdown 笔记库)里写好的存稿。
你的笔记库(Obsidian 等) Loop 引擎
───────────────────── ─────────────
人 / AI 提前写好存稿 → 读取 → 按时间表发布
(产,与 Loop 解耦) (发,只管发)
为什么这样设计(可迁移洞见):
| 如果 Loop 自己生成内容 | 如果 Loop 只读笔记库 |
|---|---|
| 生成质量出问题 → 发布跟着停 | 内容写不好 → 不影响发已有的稿 |
| 改文风/换模型 → 要改 Loop 代码 | 笔记库随便换 → Loop 一行不改 |
| 笔记和发布耦合 → 一处改两处动 | 笔记归笔记,发布归发布 → 各自迭代 |
| 你看不到将要发什么(黑盒) | 发之前能翻笔记库预览(白盒) |
实现要点:
- 笔记库 = 单一可信源:所有待发内容都先落到笔记库的某个目录,Loop 只读这个目录
- 提前量:建议至少提前一期写好(如今天的 Loop 发昨天写好的稿),给自己留缓冲
- 格式契约:笔记里用 Markdown 二级标题分段(## 内容N:标题),Loop 按分段逐条发
- 笔记库的选择:Obsidian / Notion / Logseq / VSCode + Markdown 都行——Loop 不挑工具,只挑"导出/同步成 Markdown 文件"的能力
核心洞见:把"写"和"发"切成两件事,让写的人专注写(在熟悉的笔记环境里)、发的程序专注发(按工程范式跑)。两边通过"笔记库目录"这一个契约耦合,互不干扰。
> 最初设计场景:作者在本地 Obsidian 笔记库里维护一个「待发布」目录,每天提前写好明天的微头条存稿,Loop 引擎第二天自动读取并按时间表发到今日头条。
📦 库存模型(Producer-Consumer 解耦)
核心设计:产-消解耦,通过文件目录协作。
Producer(产) Consumer(消)
───────────── ──────────────────
提前一期写好存稿 → 存稿目录 → 到期时只读当期存稿
(严格只读"今天"那份)
为什么这样设计:
| 问题 | 库存模型的解法 |
|---|---|
| 生成慢、发布快,节奏不匹配 | Producer 提前一期写,Consumer 到期就发,互不等待 |
| Producer 出问题(写不动、质量差) | Consumer 继续发已存的稿,不阻塞 |
| Consumer 出问题(限流、登录失效) | Producer 继续写下一期,不阻塞 |
| Producer 升级(换风格/换模型) | Consumer 完全无感,照发不误 |
目录约定(笔记库里的某个固定目录):
- 存稿目录:<笔记库>/待发布/(Obsidian / Notion 导出目录等都行,Loop 配置里指向它)
- Producer 产出:<笔记库>/待发布/YYYY-MM-DD.md(按日期命名)
- Consumer 严格只读:<笔记库>/待发布/今天.md(不回退找其他日期,这是关键约束)
- 存稿不存在 → Consumer 通知告警 + 跳过,不发布
文件格式约定:
存档文件内部用 Markdown 二级标题分段:
## 内容1:标题
正文...
## 内容2:标题
正文...
Consumer 按 index 逐条发布。
核心洞见:Consumer 不挑食——任何符合格式约定的存稿都能发。这让你可以随时换 Producer(人工写、AI 生成、模板填充都行),Consumer 一行代码不改。
自动化系统的最小职责单元
┌────────────────────────────────────────────────┐
│ 发布 Loop(单一职责) │
├────────────────────────────────────────────────┤
│ │
│ 读存稿 → 查护栏 → 发布 → 通知 │
│ │
│ ↑ ↑ ↑ ↑ │
│ <draft- guard publish notify │
│ dir> (5护栏) script script │
│ │
│ ⏰ 调度器:scheduler script │
│ 定时器每隔 N 分钟触发 │
│ │
└────────────────────────────────────────────────┘
四个角色:存稿目录(数据源)、护栏(防护层)、发布脚本(执行层)、通知脚本(可观测层)。调度器是节拍器。各司其职,每一层都可以独立替换。
关键参数(命令行覆盖)
设计上让所有运行时行为都可以通过命令行参数覆盖,避免改代码:
-WindowStart/-WindowEnd:发布窗口(默认如08:00/23:00)-ItemsFile:手动指定存稿文件(跨期补发场景;不指定则严格读当期)-DryRun:只看逻辑不真发(验证用,这个必须有——自动化系统没 DryRun 等于盲飞)-CatchUpInterval:补发节奏(声明用,实际节奏由定时器触发频率决定)
数据驱动的发布时段选择
核心洞见:发布时段不应该靠拍脑袋,应该从历史数据反推。
方法论:
1. 拉一段时间(如 30 天)的同类内容表现数据
2. 按发布时段聚合,找出"平均表现最好"的时段
3. 选出 TOP N 个时段作为发布槽位
4. 槽位对齐到定时器精度的整数倍(如每 15 分钟触发,槽位就只设在 xx:00/15/30/45)
示例(最初设计场景的 11 槽版):
08:00 1
09:30 2
10:30 3
11:30 4
13:00 5
14:30 6
15:30 7
16:30 8
17:30 9
18:30 10
20:00 11
- 全部对齐到 15 分钟边界(与定时器精度匹配)
- 间隔 60-90 分钟(避免被平台判定刷屏)
- 基于当月 ~12000 条样本数据分析得出
关键决策原则:「不锁条数,只锁时段」——时间表是优先级排序,不是条数上限。
- 实际发几条 = 存稿几条
- 存稿不足槽位数?发完即停,不补凑数
- 存稿超过槽位数?尾部兜底,继续发(见下方调度算法)
双文件同步:机器读的 .conf + 人读的 .md 双写,MD5 一致。修改时同时改两份。
发布段(系统的执行层)
职责
将存稿按时间表逐条发布到目标平台,每发一条通知。
工具链角色(不绑定具体实现)
| 角色 | 职责 | 实现提示 |
|---|---|---|
| 发布脚本 | 调平台 API/逆向接口,支持 --index N 单条发布 |
优先用平台官方 API;无 API 时用浏览器自动化(Playwright/Puppeteer)逆向 |
| 单条发布+通知 | 发一条 → 通知一条 | 关注点分离:发布和通知解耦 |
| 多条序列(间隔强制) | 多条按间隔发布,间隔由脚本内部强制 | 间隔不能依赖调用方传参,必须在脚本内硬性强制 |
| 护栏脚本 | 发布前强制检查所有护栏 | 见「五大护栏」章节 |
| 调度器 | 定时触发,判断模式,调发布脚本 | 见「调度器工作原理」 |
| 静默启动器 | 隐藏命令行黑窗口(定时任务场景) | 用 .vbs / nohup / systemd 之类 |
| 通知脚本 | 发消息到 IM(飞书/钉钉/Slack/邮件) | 失败要带 🚨 + 累计失败次数 |
发送间隔(硬性规则)
- 多条必须分散发布,相邻两条之间至少间隔 N 分钟(具体值看平台风控强度)
- 间隔必须由脚本内部强制,不依赖调用方传 sleep——调用方会忘、会传错
- 目的:避免被平台判定为刷屏/机器群发,影响推荐权重
Loop 编排(可执行规则)
触发与时机
| 项 | 规则 |
|---|---|
| 触发信号 | 定时任务每隔 N 分钟触发调度器(窗口期内) |
| 触发精度 | 槽位对齐到触发精度的整数倍(如每 15 分钟触发,槽位只设在 xx:00/15/30/45) |
| 存稿读取 | 严格只读当期存稿,不存在 → 通知告警 + 退出 |
| 正常节奏 | 按时间表槽位发,过槽位就发对应序号;高峰时段优先 |
| 补发逻辑 | 落后(应发 > 已发)→ CATCH-UP 模式逐次触发追回 |
| 尾部兜底 | 所有槽位发完后,剩余存稿继续追发,存稿几条发几条 |
| 发布窗口 | 固定时段(如 08:00-23:00),窗外直接退出 |
| 条数上限 | 无硬上限,发完存稿即停(ALL DONE) |
| 发完即停 | 当期存稿全部发完后,后续触发返回"ALL DONE",不再发 |
🎯 时间表的真实控制点(重要)
时间表 ≠ 条数上限。决策原则「不锁条数,只锁时段」意味着——
- 时间表是「优先级排序」,告诉调度器哪些时段更重要
- 实际发几条 = 存稿几条(槽位都过了但还有存稿?继续发,见「尾部兜底」)
- 存稿不足槽位数?发完即停,不补凑数
🔐 时间表双文件同步管理
- 机器读版(
.conf/.json):调度器实际读取 - 人读版(
.md):人类查看和修改 - 修改时必须同时改两份,保持一致
- 备份命名:
schedule.conf.bak-描述(如.bak-高峰期、.bak-低谷期)
调度器工作原理(核心算法)
每次触发(每隔 N 分钟,窗口期内):
1. 检查窗口,窗外直接退出
2. 读当期存稿,不存在 → 通知 + 退出
3. 数标题数 → 得出 TotalItems(动态,不写死)
4. 读时间表配置 → 加载槽位
5. 从发布日志统计已成功发几条
6. 计算 behind = (已过槽位数) - published
⚠️ 关键:behind 负数时重算为 (TotalItems - published)
——进入「尾部兜底」场景,upTo 已被 published 超过,
重算为"还剩几条没发"
7. 比较:
- behind ≤ 0 且未过最后槽位 → ON TRACK,等下次触发
- behind = 1 → NORMAL 模式,发 1 条
- behind ≥ 2 → CATCH-UP 模式,逐次触发追发
- 已过最后槽位且有剩余存稿 → 尾部兜底,继续追发
8. 发布失败:
- 写失败计数文件(每行一次失败)
- published 不 +1(不丢稿,下次重试该条)
- 通知 🚨 告警(含累计失败次数 + 「请恢复登录态」提示)
9. 全部发完 → 后续触发返回 ALL DONE
三个关键设计点:
1. TotalItems 动态计算(数标题得出,不写死)——存稿几条就发几条
2. behind 负数修正——避免漏发尾部存稿
3. 失败不递增 published——避免"失败的条目被当作已发跳过"
调度器调用
# 定时任务自动触发(指向静默启动器,再 → 主脚本):
scheduler.vbs / nohup / systemd → scheduler script
# 手动测试(不真发,看逻辑,验证用):
scheduler --dry-run
# 手动指定存稿文件(跨期补发场景):
scheduler --items-file /path/to/特定存稿.md
失败处理(三档分级 + 自动熔断)
> 这是本技能最强的可迁移智慧:自动化系统的失败处理必须有分级、有熔断,否则一个小故障会演变成连续刷错污染账号。
| 故障类型 | 处理 |
|---|---|
| 登录态过期 | 发布失败 → 写失败计数 → 通知 🚨 告警(含累计失败次数)→ 用户恢复登录态 → 下次触发自动重试(失败不 +1 已发数,不丢稿) |
| 连续失败熔断 | 单期累计失败 ≥ N 次(推荐 3)→ 护栏自动拦截(防持续刷错),需人工介入 |
| 存稿没生成 | 跳过本期发布 → 通知报错(不重试,等下期) |
| 单条发布失败 | 通知 🚨 告警该条 + 计入失败计数;下次触发重试该条(不跳过、不丢稿) |
三个关键反模式(踩过的坑):
- 失败时不写计数文件 → 护栏的失败计数永远不触发(等于失效)
- 正确:每次失败都Append到failures-$period.txt - 失败时仍
published + 1→ 失败的那条被当作"已发"跳过,真正丢稿
- 正确:失败时 published 不递增,下次触发重试该条 - 只看 URL 判断登录态 → 平台发布页 URL 即使未登录也不跳转,导致永远说"已登录"
- 正确:见下方「登录态双验证」
核心洞见:自动化系统失败时,最怕的不是失败本身,而是"失败被当成成功"。每次失败必须:① 写计数 ② 不递增进度 ③ 通知告警。三件事缺一不可。
登录态预检(双验证模式)
登录态失效的识别(双验证):
1. 身份 cookie 存在:检查 uid/sid/sso_uid 等身份类 cookie(排除 csrf token 类无状态 cookie)
2. 页面 DOM 验证:访问受保护页面,检查是否出现登录框(扫码登录/验证码登录等文字)
两个条件都满足才算登录态有效。
为什么必须双验证:
- 只看 cookie:cookie 可能存在但已过期
- 只看 URL:很多平台登录页 URL 即使未登录也不跳转
- 只看 DOM:DOM 结构可能因 A/B 测试变化
登录态过期的典型症状:发布 API 返回 userID==0 或 auth failed——这是平台告诉你"身份无效"的明确信号。
恢复方法:跑登录脚本(弹浏览器扫码 / 走 OAuth 流程),下次调度器触发(一个触发周期内)自动重试失败的条目。
为什么不预发:登录态过期时直接发会连续刷错(每隔 N 分钟一次失败),所以必须配合失败计数熔断——第 N 次失败护栏自动拦,防止持续刷错污染账号。
🛡️ 五大护栏(发布前强制检查)
> 设计哲学:自动化系统必须有"防护层"——发布前跑一遍护栏,全过才允许发布。护栏是一组独立的可执行检查,exit 0 = 通过,非 0 = 拦截并报告原因。
# 1. 先跑护栏(exit 0 = 全过,非 0 = 拦截,看输出原因)
guard --items-file /path/to/存稿.md --indexes "1,2,...,N"
# 2. 护栏通过后,再发布
if [ $? -eq 0 ]; then
publish_sequence --indexes "1,2,...,N" --interval 300 --items-file /path/to/存稿.md
fi
| 护栏 | 拦截什么 | 设计理由 |
|---|---|---|
| ① 发布窗口 | 当前时间不在允许窗口内 | 防凌晨/异常时段发布被判异常 |
| ② 失败计数器 | 单期累计失败 ≥ N 次(推荐 3) | 自动停 Loop,防登录态/网络问题下持续刷错 |
| ③ 重复检测 | 与上期标题完全相同 | 防连续两期发重复内容(中文重复检测建议委托 Python/Node,Shell 处理 UTF-8 不可靠) |
| ④ 间隔强制 | 相邻两条 < N 分钟 | 防刷屏判定(由发布脚本内部强制,非 guard) |
| ⑤ 条数上限(可选) | 单期超过 N 条 | 防失控批量发送(可关闭,改为"发完存稿即停") |
护栏失败计数器:<state-dir>/failures-$period.txt(每行一次失败,累计 ≥ N 触发拦截)。
核心洞见:护栏和发布必须分两个独立可执行文件——护栏失败时发布脚本根本不会被调用。这比"在发布脚本里加 if 判断"更稳,因为护栏 bug 不会影响发布逻辑,发布 bug 也不会绕过护栏。
内容质量监控(建议,非强制)
自动化最大风险是内容质量滑坡:
- 定期人工抽检:看一段时间的发布内容,判断是否开始套路化
- 隔几期插 1 条人工精选:打破纯自动节奏,让平台算法觉得账号有"人味"
- 长期效果追踪:每条发布 24h 后拉表现数据(阅读/互动),写回效果文件,累积后反哺选材规则
核心洞见:自动化系统的"自动"是相对的——完全无人值守 + 零人工介入 = 必然质量滑坡。必须在自动化链路里显式预留人工介入的接口(定期抽检、精选插入、效果回写)。
Producer / Consumer 分工模式
产-消关系:Producer 和 Consumer 通过存稿目录解耦协作——
| 维度 | Producer(产) | Consumer(消) |
|---|---|---|
| 时机 | 提前一期写好存稿 | 到期时只发当期存稿 |
| 目录 | 写入 <draft-dir>/ |
严格读 <draft-dir>/当期.md |
| 触发 | 手动或独立调度 | 定时 Loop |
| 失败处理 | 当场看到,自己改 | 通知告警 + 失败计数熔断 |
解耦的工程价值:
- Producer 出问题(写不动、质量不行)→ 不影响 Consumer 发已存的稿
- Consumer 出问题(登录态过期、被限流)→ 不影响 Producer 继续写下一期
- Producer 升级(换风格 / 换模型 / 换策略)→ Consumer 完全无感
实现要点:
- 存稿目录是唯一的耦合点,目录结构必须稳定
- 文件命名约定(如 YYYY-MM-DD.md)是契约,双方都遵守
- Consumer 严格只读"当期"——不回退、不预测、不跨期
设计原则总结(可迁移智慧)
这套系统的核心不是某个具体脚本,而是几个可迁移的工程范式:
| 范式 | 一句话 | 适用场景 |
|---|---|---|
| 单一职责 | 发布器只发布,不生成 | 任何"生成 vs 分发"可分离的系统 |
| 库存模型 | 产-消解耦,目录协作 | 节奏不匹配的两端(生成慢、发布快) |
| 数据驱动时段 | 从历史数据反推最佳时段 | 任何有"时段表现差异"的发布场景 |
| 失败三档分级 | 登录态/连续失败/单条失败 各有处理 | 任何会失败的自动化系统 |
| 失败熔断 | 累计失败 ≥ N 自动停 | 防止小故障演变成大事故 |
| 登录态双验证 | cookie + 页面 DOM | 任何依赖持久登录态的爬虫/自动化 |
| 五大护栏 | 窗口/计数/重复/间隔/上限 | 任何"必须在约束下运行"的自动操作 |
| behind 负数修正 | 总数 - 已发 = 待发 | 调度算法的兜底设计 |
| 失败不递增进度 | 失败时 published 不 +1 | 防止"失败被当成功"的丢稿 |
| DryRun 模式 | 不真发只看逻辑 | 自动化系统必备的验证手段 |
这套设计不绑定任何平台。最初在「今日头条微头条」场景落地,但同样的范式适用于:公众号定时推送、小红书批量发布、知乎专栏、邮件营销自动化、社媒矩阵分发、推送通知调度……任何「需要定时定量、防刷屏、防封号、可观测」的发布场景。