写文章并不难,麻烦的是同一份内容到了不同平台,要重新处理图片、格式、凭证和发布动作。网站 Blog 需要生成 Hugo 内容包并提交 Git;微信公众号需要把 Markdown 变成兼容 HTML、上传图片,再保存到草稿箱。

这套流水线把这些动作收进一个项目级 Codex Skill:内容只编辑一次,随后冻结成同一份文章快照,再分别渲染给 Blog、微信公众号或其他自定义端点。真正会修改 Git 或调用远端 API 的动作,统一放到人工确认之后。

一、整体架构

流水线不是把 Markdown 直接发送两次,而是先生成一个与渠道无关的 ArticlePackage。它包含规范化的 metadata、正文、资源清单和 revision,是后续所有发布端点共同使用的内容真相。

从这个固定 revision 出发,Hugo Renderer 生成 Blog leaf bundle,WeChat Renderer 生成经过校验的 HTML 与 JPG/PNG 图片。两个候选再交给 Publisher Registry 编排,最后统一输出 diff、预览、动作列表和确认令牌。

Codex 多平台发布流水线五层整体架构

这套结构把“写什么”“各平台长什么样”“怎样发送出去”拆成三个阶段。增加新平台时,不需要重新实现文章导入、配图和版本冻结,只需要接入新的渲染器与发布端点。

二、五层内容与实现原理

1. 内容层:只读导入,工作副本改造

入口始终是一个明确的 Markdown 文件,而不是扫描整个笔记目录。程序解析 frontmatter 和本地图片引用,把可编辑副本放进被 Git 忽略的 .publish/ 工作区。标题、摘要、段落、配图都在副本上调整,原始文章和截图保持不变。

2. 文章包层:用 revision 固定内容

编辑完成后,程序将 metadata、正文和按顺序排列的资源哈希写入 ArticlePackage,并计算确定性的 revision。时间戳、本机绝对路径、预览文件和凭证不会参与计算,因此相同内容会得到相同 revision;任何正文或图片变化都会产生新 revision。

3. 渲染层:同一内容,不同渠道表现

Blog 渲染器把资源引用改写为 Hugo leaf bundle,并执行本地 Hugo 构建。构建成功还不够:程序会继续检查 PaperMod 的 post-content md-content 正文容器、代码块结构和对应 CSS 选择器,避免主题升级后旧模板让整篇文章失去排版。

微信公众号的主题外壳可以设计,但代码内容不能交给模型重写。每个 Markdown 代码围栏先变成一个有序槽位,再由 Shiki 和微信专用序列化器逐行注入;空格使用不会折叠的实体,颜色全部写成内联样式。冻结前,程序会把 HTML 反向还原并与源代码逐块、逐行比较,缩进、空行、语言或顺序有任何变化都会停止。最后再用 renderDigest 绑定审核过的精确字节,发布阶段不会重新排版。

4. 端点层:每个平台独立负责副作用

每个 Publisher Endpoint 都实现 preflight → prepare → publish → status 生命周期。Blog 端点只管理目标文章目录、提交和受保护的 Git push;微信端点只上传冻结素材并调用 draft/add 创建草稿。各端点拥有独立 receipt,一个平台失败不会被伪装成整条流水线的原子回滚。

5. 安全层:先展示,再确认,再执行

prepare 阶段只做本地校验和只读预检,输出 Blog diff、Hugo 构建结果、微信公众号预览、素材清单和确切动作。确认令牌同时绑定文章 revision、渲染结果、目标账号、Git 基线和动作顺序;其中任意一项变化,旧令牌都会失效。

确认后的 publish 才允许产生副作用。遇到超时或无法判断远端结果时,端点返回 outcome_unknown 并停止自动重试,避免重复提交文章或创建两个微信公众号草稿。

prepare、人工确认与 publish 的安全流程

三、完整操作流程

首先准备 Bun、Git、Hugo 和 Python 3,并安装 baoyu-post-to-wechat。公众号排版使用外部 gzh-design-skill,它会被锁定到指定 commit 和文件哈希。微信后台还需要配置 AppID、AppSecret、接口权限和执行机器的公网出口 IP 白名单。

把 Skill 复制到现有 Hugo 仓库:

DIST=/absolute/path/to/codex-multi-publisher
BLOG=/absolute/path/to/hugo-blog

mkdir -p "$BLOG/.agents/skills"
cp -R "$DIST/.agents/skills/publish-article" \
  "$BLOG/.agents/skills/publish-article"

cd "$BLOG/.agents/skills/publish-article/scripts"
bun install --frozen-lockfile

在端点配置中声明 Blog 和微信公众号草稿:

{
  "schemaVersion": 1,
  "defaultTargets": ["blog", "wechat"],
  "endpoints": {
    "blog": {
      "driver": "blog-git",
      "branch": "main",
      "remote": "origin",
      "contentRoot": "content/posts"
    },
    "wechat": {
      "driver": "wechat-draft-baoyu",
      "account": "example-account",
      "mode": "draft"
    }
  }
}

凭证只保存在环境变量或被忽略的 .baoyu-skills/.env,不要写进文章、配置或 Git:

WECHAT_EXAMPLE_ACCOUNT_APP_ID=
WECHAT_EXAMPLE_ACCOUNT_APP_SECRET=

随后从 Blog 根目录执行完整链路:

CLI=.agents/skills/publish-article/scripts/src/cli.ts
SOURCE=/absolute/path/to/article.md

bun "$CLI" import --source "$SOURCE"
# 将输出中的 runRoot 记录为 RUN_ROOT

bun "$CLI" package --run "$RUN_ROOT"
bun "$CLI" render-blog --run "$RUN_ROOT"

# Codex 只生成主题外壳,代码位置写成 PUBLISH_ARTICLE_CODE_SLOT 槽位
bun "$CLI" render-wechat-code \
  --run "$RUN_ROOT" \
  --html "$RUN_ROOT/working/wechat-layout.html" \
  --output "$RUN_ROOT/working/wechat-candidate.html"

bun "$CLI" validate-wechat \
  --run "$RUN_ROOT" \
  --html "$RUN_ROOT/working/wechat-candidate.html"

bun "$CLI" freeze-wechat \
  --run "$RUN_ROOT" \
  --html "$RUN_ROOT/working/wechat-candidate.html" \
  --style moyu-green

bun "$CLI" prepare --run "$RUN_ROOT" --targets blog,wechat

此时必须停下来检查 diff、Hugo 构建、公众号预览和动作列表。确认无误后,使用 prepare 返回的原始令牌执行一次:

bun "$CLI" publish \
  --run "$RUN_ROOT" \
  --confirm "$CONFIRMATION_TOKEN"

Blog 端点的成功边界是目标 commit 已推送到确认过的远端分支;Vercel 是否完成部署需要另行观察。微信公众号端点只把内容保存为草稿,正式群发仍然在公众平台人工预览和操作。

四、源码与自定义扩展

完整源码已经公开在 lukelmouse-github/codex-multi-publisher。仓库同时是源码、测试项目和可以复制到其他 Blog 的 Skill 分发包:

codex-multi-publisher/
├── .agents/skills/publish-article/
│   ├── config/
│   ├── references/
│   ├── scripts/src/
│   │   ├── endpoints/
│   │   └── wechat-code.ts
│   └── scripts/test/
├── docs/
│   ├── architecture.md
│   └── adding-an-endpoint.md
└── examples/minimal-article/

端点共享同一份接口:

interface PublisherEndpoint {
  id: string;
  preflight(article, context): Promise<object>;
  prepare(article, context): Promise<PreparedPublication>;
  publish(prepared, confirmation, context): Promise<PublishReceipt>;
  status(receipt, context): Promise<object>;
}

要增加新的平台,需要实现这个接口,注册 endpoint factory,再补充端点配置、上下文构造、prepare 报告和失败恢复测试。当前版本采用显式注册,不是运行时插件市场;这样多写几处接线代码,但端点身份、动作和错误语义都能在编译期与测试中被看见。

最重要的原则只有一个:不要让“多平台发布”退化成一组不可审计的脚本。先把内容冻结,再展示每个平台真正要执行的动作,最后用一次明确确认授权副作用,才能让自动化既省事又可控。