用 Obsidian、Hugo 和 GitHub Pages 搭建从私有笔记到公开网站的发布流水线一文中,我介绍了如何划清边界,将私有仓库与公开网站分开。

那套方案解决了文章应该存放在哪里,却留下了另一个问题:同一篇文章需要以两种语言发布时,该怎么办?

起初,我以为双语发布无非是翻译:把 Markdown 交给模型,让它生成中文或英文,再将结果保存在原文旁边。真正动手后我才发现,翻译反而是整个问题中最简单的一环。

真正棘手的是一系列架构问题:

  • 哪个版本才是唯一可信的原文?
  • 改写正文时,怎样确保代码块、链接、图片和 Hugo 元数据完好无损?
  • 一项耗时很长的任务究竟仍在运行、已经失败,还是根本没有启动,我该如何判断?
  • 生成的草稿由谁决定能否进入公开网站?

我最终搭建的系统没有把本地化做成一个翻译按钮,而是将它纳入一条受控的发布流水线。

核心规则很简单:

文章在 Obsidian 中写作,发布工具的代码在 Hugo 仓库中维护。生成的草稿未经人工批准,一律不得进入网站。

人工审核的双语发布流水线


原文始终是唯一可信版本 链接到标题

一篇文章可以先用英文写,也可以先用简体中文写。发布工具会识别原文语言,再生成另一种语言的版本:

英文原文 -> 中文本地化版本
中文原文 -> 英文本地化版本

最初的 Obsidian 笔记始终是权威原文。本地化文章只是从中生成的派生版本,不是另一份需要独立维护的原稿。

这一点很重要。两份都能独立编辑的原稿很快就会出现偏差:日期可能只在英文文章中得到修正,中文文章却没有同步;重写后的结论可能只出现在一种语言中,另一种语言仍沿用早先的论证。一旦把两个文件都视为原稿,之后的每次修改都会变成一道同步难题。

原始笔记只需声明允许自己进入发布流水线:

publish: true

系列文章还需要记录阅读顺序:

series: Remote Agent Workflow
seriesOrder: 2

系统只根据 seriesOrder 选择系列中的下一篇文章,绝不随机挑选。这看似只是一条不起眼的操作规则,却能避免自动化系统将内容本身没有问题的文章按错误的叙事顺序发布。


为什么一次翻译远远不够 链接到标题

如果要求一个提示词一次完成所有工作,它承担的任务就太多了:既要理解论证,又要用自然的目标语言写作,还要保留所有事实,并确保 Markdown 毫发无损。这些要求混在一起,产出的文章即使流畅,也可能出错;即使忠实,也可能满是翻译腔。

因此,我把整个过程拆成四个阶段。

0. 理解文章 链接到标题

第一轮不做翻译,只提取文章的核心主张、目标读者、推理链、事实、术语、语气和结构元素。

这样一来,后续阶段就有了一张语义地图,不必一边猜作者究竟想表达什么,一边组织目标语言。

1. 用目标语言重新写作 链接到标题

第二轮面向母语读者重写整篇文章。观点保持不变,表达却不逐句追随原文。

这个区别非常重要。英文和中文在重点、主语、衔接和节奏上的安排并不相同。好的本地化文章,应该让读者觉得作者原本就是用这种语言思考并写下这些内容的。

2. 以母语编辑的标准润色 链接到标题

第三轮只处理可读性,包括行文节奏、词语搭配、句子如何层层推进,以及残留的源语言语法痕迹。

编辑可以让文章读起来更自然,却不能增加观点、删去限定条件,也不能重组文章结构。这个阶段的权限是有意收窄的。

3. 审查事实一致性 链接到标题

最后一轮由模型逐节对照本地化草稿与权威原文,检查有无遗漏、增添、推理偏差或结论偏差,同时核对名称、日期、数字和技术术语。

这个阶段无权擅自“把文章写得更好”。审查者只能修正受影响的最小范围;如果无法安全地局部修改,就必须判定本次运行失败。

这样的分工形成了一套有效的制衡机制:

为母语表达而重写
        +
为母语可读性而编辑
        +
对照权威原文审查事实

语言是否自然,事实是否忠实,是两个不同的质量维度。流水线为它们分别设置了独立阶段。

目前默认使用的模型是 gpt-5.6-sol。如果某篇文章需要不同的取舍,可以在原始笔记中为任意阶段指定其他模型:

understandModel:
rewriteModel:
editorModel:
reviewModel:

将这些选择写入 frontmatter,例外配置就能与文章放在一起,清晰可见,而不会藏在某台机器独有的插件设置中。


Markdown 是必须遵守的契约,不只是文本 链接到标题

一篇文章不只有正文,还包含许多不能任由模型改动的结构。

每完成一个生成阶段,发布工具都会检查:

  • 标题层级
  • 围栏代码块
  • 图片目标地址
  • 链接目标地址
  • Obsidian wikilink 的目标与嵌入
  • 脚注标识符
  • 表格行列数
  • Hugo shortcode

处理代码块时,工具会明确区分内容类型:

  • text 围栏中是自然语言,应当进行本地化。
  • jsshbashjson 或其他代码围栏中的内容必须逐字节保持不变。
  • # On the Mac 这样的 Shell 注释属于代码,不是 Markdown 标题。

最后一条规则源于一次真实故障。有一篇长文在本地化时看似卡住,实际却是校验器将 Shell 代码块中的注释误判为文章标题。后续检查又认定 SSHTailscaletmux 等标题仍是未经翻译的英文,于是拒绝继续处理。

此外,所有模型阶段还会收到同一份术语约定。例如:

{
  "source": "control plane",
  "target": "控制面",
  "avoid": ["控制平面"]
}

重写、编辑和事实审查阶段都必须遵守同一套规则。最后还有一道确定性检查:如果首选术语没有出现,或者禁用写法仍有残留,流水线就会停止。

提示词负责引导行为,校验器负责强制维护不变量。两者缺一不可。


发布工具应该和网站代码放在一起 链接到标题

最初,发布工具只放在我的 Obsidian 仓库里。用起来方便,却很难可靠地管理。

它没有可信的版本历史,不受 CI 检查,也很难证明 Obsidian 中安装的 QuickAdd 脚本与我以为自己正在测试的代码完全一致。即使重新加载插件,内存中仍可能残留旧版的用户脚本模块。

现在,发布工具的权威版本放在 Hugo 仓库中:

tools/obsidian-publisher/
├── publish-note.js
├── terminology.json
├── prompts/
├── tests/
└── bin/
    ├── publish-note
    └── install

运行时、提示词、术语表、安装程序、测试和操作手册全部纳入代码仓库管理。Obsidian 的 Scripts/ 目录只接收安装副本。

每次运行前,启动器都会核对已安装的运行时、所有提示词和术语文件,确认其哈希与仓库中的权威版本一致。它还会检查必需的工具是否齐全,并确认 GitHub Publisher 的定时同步已经关闭。

这样一来,各处的职责就很清楚:

位置职责
Obsidian 原始笔记保存权威正文与文章元数据
Obsidian Publish/保存等待审核的双语草稿
Hugo 仓库 tools/保存纳入版本管理的发布工具实现与测试
Hugo content/posts/Publish/保存从 Obsidian 同步而来的已批准内容
GitHub Pages托管构建后的公开网站

写作者的工作空间仍然服务于写作,软件仓库则按工程规范管理工具。


生成草稿不等于发布 链接到标题

这是整个系统最重要的边界:

Publish Note 只负责生成草稿,并不授予发布权限。

该命令会在 Obsidian 仓库内生成一组双语文件:

Publish/<slug>/
├── index.md
├── index.zh.md
└── assets/

两个文档共用同一个 translationKey,Hugo 据此关联英文页面和中文页面。

到这里,流程便会停下。我必须亲自检查目标语言是否自然、术语是否准确、标题是否恰当,还要确认有无遗漏或误解,以及系列顺序是否正确。

只有在我明确批准“发布”后,独立的 Sync Published Site 工作流才可以运行。

GitHub Publisher 也遵循这一原则:

{
  "selectedPaths": ["Publish"],
  "publishTags": [],
  "syncInterval": 0
}

这些设置划清了三条边界:

  • 只有 Publish/ 中生成的派生版本可以离开 Obsidian 仓库。
  • 标签不能误选仓库其他位置的原始笔记。
  • 定时任务不能绕过人工审核。

最后一项控制是在尚无任何草稿获批却仍出现延迟同步提交后添加的。原因是插件中仍保留着非零的同步间隔。关闭定时同步不只是为了省事,而是要把人工授权正式写入系统架构。


一篇文章的完整发布流程 链接到标题

整个流程从 Hugo 仓库中的预检开始:

tools/obsidian-publisher/bin/publish-note doctor

然后,我只启动一次本地化任务:

tools/obsidian-publisher/bin/publish-note publish

启动器会解析实际的 QuickAdd 命令 ID,检查当前笔记和 publish: true,执行命令,然后等待结构化状态发生变化。它不会使用 obsidian://quickadd URL,因为把 Obsidian 切到前台,并不能证明命令确实执行了。

可以用下面的命令查看进度:

tools/obsidian-publisher/bin/publish-note status

双语文件生成后,我会在 Obsidian 中逐一检查。此时,工作流暂停,等待我明确批准。

批准后,Sync Published Site 会把这组派生文件复制到:

content/posts/Publish/<slug>/

我还会检查远程提交的文件列表,确认其中只有刚刚批准的文章。随后,通过仓库统一的验证入口,依次运行发布工具测试、站点测试、Python 语法检查和生产环境 Hugo 构建:

./init.sh

推送到 main 后,GitHub Actions 随即触发,并部署 GitHub Pages。

这套流程刻意没有做成一键发布。它尽量减少阻力,但保留了一次真正有意义的停顿。


长文章需要可观察、可恢复 链接到标题

促使我设计这套恢复机制的那次故障,大约花了 47 分钟才查清。大部分时间并没有用于有效的模型处理。我只是在等待,却不知道系统究竟是否还在运行。

现在,系统会明确区分三种状态:

status=running + 锁仍然有效
    -> 继续等待;不要启动另一次运行

status=failed
    -> 查看错误,修复原因,再从已完成的缓存继续

锁已超过 30 分钟
    -> 先确认没有请求仍在运行,再执行 publish-note recover

每个 AI 请求都有五分钟的逻辑超时。长文章会按二级标题拆分。已经完成的编辑和审查分块会写入缓存,因此重试时无须再次承担已经成功完成的处理成本。

操作体验由此彻底改变。我不再根据等待时间猜测发生了什么,而是直接查询系统记录的当前状态。我也不必反复重载插件、再次点击,而是可以据此判断任务仍在运行、已经失败、锁已过期,还是可以恢复。

自动化只有把不确定性清楚地暴露出来,才值得信任。


这条流水线真正保护的是什么 链接到标题

最初的发布流水线保护的是我的私有知识系统与公开网络之间的边界。

双语流水线又增加了三条边界:

  1. 原文与派生版本——一篇权威文章,派生出两种公开语言版本。
  2. 正文与结构——模型可以改写语言,但受保护的 Markdown 必须保持稳定。
  3. 生成与授权——自动化可以准备待发布的内容,但是否允许这些内容离开 Obsidian 知识库,必须由人决定。

我最看重的是最后一条。

AI 可以理解、重写、编辑和比对。测试可以发现结构损坏。缓存和锁可以让耗时任务从中断处恢复。但这些机制都不能替我决定:我是否已经读完这篇文章,是否愿意署上自己的名字,让它公开发布。

因此,最终的系统没有实现全自动化。这是有意为之。

它自动完成成本高昂的重复工作,同时保留那项理应由人作出的决定:这篇文章是否已经可以公开发布。