如果你平时已经习惯在 Notion 里写东西,但博客又是 Astro 这种文件化静态站,迟早会碰到一个挺烦的小断层。

写稿的时候你在 Notion 里很顺,整理结构、拖图片、改段落都方便。可一到发布,你又得把正文搬回仓库,补 frontmatter,生成英文版,跑构建,再 push,再等部署。前面像在写作,后面又突然切回了运维模式。文章少的时候还能忍,发得一多,这种重复动作就会越来越碍事。

我后来把这件事收成了一条固定链路:文章继续在 Notion 里写,GitHub Actions 定时或手动去拉页面,把正文落成仓库里的 Markdown,自动补 slug、生成英文稿,写回 Astro 内容目录,本地规则照样校验,最后再把生产部署接上。这样做不是为了追求“全自动”这三个字,而是因为我越来越不想重复做那段机械搬运。

如果你还没把博客切到文件化内容,可以先看这篇 Astro 博客 vs WordPress:为什么我现在更偏向 Markdown + GitHub Actions 这套内容架构。如果你现在本来就是在仓库里直接写 Markdown,那篇 本地写 Markdown,git push 后自动上线:我的双语博客发布流程 也可以一起看。前者解决“为什么要走这条路”,后者解决“纯仓库写作时怎么发”,而我这篇更像是在补中间那层:既想保留 Notion 的写作手感,又不想把 Astro 博客重新拉回重后台。

这条链路最早我也想过直接做成事件驱动。Notion 一发布,马上触发 Worker,再写仓库,再部署,听起来当然更完整。但真开始往下拆时,我反而把第一版收得很土。原因很简单,对个人博客来说,第一版最值钱的不是“快”,而是“坏了以后我能不能一眼看出坏在哪”。所以我没先上 webhook,也没做多篇并发,而是老老实实地把规则定成这样:正文直接写在 Notion 页面正文里,页面属性只放标题、摘要、分类、标签、Slug、状态这些元信息,GitHub Actions 每次只处理一篇,默认认 发布 = true 且 状态 = Ready,如果是改旧文,就保留原 slug,再显式勾上“允许更新已发布文章”。

这一套看起来不高级,但非常容易排。文章没进队列,十有八九是字段没勾对;生成英文失败,就去看翻译那一层;build 挂了,就回到仓库里的内容 schema;部署没起,就看后面的 Deploy Production。我后来越来越确认,个人博客和团队内容系统真的不是一回事。团队更在意实时触发、多人审核、复杂状态流;我现在更在意的是“今晚写完一篇,明天早上它能不能稳稳出现在站上,而且别把旧文章撞掉”。在这个目标下,轮询一点也不丢人,反而很稳。

而这条 Notion -> Astro 链路真正跑起来,其实也就下面这一条:

Notion 页面正文
  -> 标记为 Ready + 发布
  -> GitHub Actions 拉 markdown
  -> 自动生成英文标题、description、正文和 slug
  -> 写入 src/content/posts/zh-cn 与 en-us
  -> 执行 build 校验
  -> push 到 main
  -> 显式触发生产部署
  -> 回写 Notion 状态、链接和提交 SHA

我最在意的其实就两件事。第一,Notion 只是写作入口,不是最终发布产物,真正上线的文章还是仓库里的 Markdown 文件。第二,这条链路不能因为“自动化”三个字,就把覆盖保护做没了。所以仓库里最终还是一个很朴素的结构:

src/content/posts/
├── zh-cn/
│   └── notion-to-astro-auto-publish-workflow.md
└── en-us/
    └── notion-to-astro-auto-publish-workflow.md

同一篇文章的中英文共用一个 path,这样 Astro 这一层的内容模型、URL 和聚合页逻辑都不用临时打补丁。Notion 那边要补全的也不是一大堆东西,核心无非就是 名称、摘要、分类、标签、Slug、发布、状态、自动翻译英文 和 允许更新已发布文章 这些字段。正文则继续老老实实写在页面正文里,不塞到某个属性里。这一点看起来像废话,实际上挺关键。因为我现在脚本取的是页面 markdown,不是自己去拼 block 树。正文只要能被 Notion 正常导出,后面进博客内容目录就会稳定很多。

如果你问得再具体一点,其实很多人真正想知道的是:那我在 Notion 里到底要怎么写,后面自动同步到博客时才不会乱。我的经验是,尽量把 Notion 当成一个更舒服的 Markdown 编辑器,而不是一个花哨的排版工具。也就是说,标题就老老实实用标题层级,正文就是正常段落,命令和配置一定放代码块,图片就放在它对应的段落附近,外链和视频如果你后面希望博客能自动转成卡片或嵌入,最好单独占一行。你越依赖 Notion 那些“看起来很漂亮但导出形态不稳定”的东西,比如复杂列布局、过多折叠、很重的视觉卡片,后面同步时越容易额外返工。

我现在自己在 Notion 里写博客,脑子里其实一直有一个“可导出写法”。开头两三段先把问题和为什么这样做说清楚,中间需要命令、配置、目录结构时直接放 fenced code block,需要图片就插在相关段落旁边,参考资料和视频链接如果重要就单独放一行。你会发现,这样写出来的页面,虽然还在 Notion 里,但它本质上已经很接近一篇结构干净的 Markdown 文章了。这样一来,后面的“自动同步到静态博客”就更像是在搬运一篇已经写好的源稿,而不是把一个很花的 Notion 页面硬翻译成博客页面。

一个最保守、也最不容易翻车的 Notion 写法,大概就是这种感觉:

# 标题

开头两三段,把问题和选择说清楚。

## 某个重点

继续写正文。

```bash
pnpm build
node scripts/notion-auto-publish.mjs process
```

再往下补排错、限制、恢复路径。

https://example.com/some-reference

这种写法的关键不是“规范”,而是它顺着导出链路来。你不是在赌同步脚本能不能把 Notion 里的所有视觉细节都复原,而是在一开始就按最容易导出、最容易落到博客内容目录的方式去写。这个思路一旦转过来,Notion 写文章自动同步到静态博客这件事,实际会顺很多。

脚本怎么拆:三个命令各管什么

脚本入口我也没放得很散,主要还是这三个命令:

node scripts/notion-auto-publish.mjs process
node scripts/notion-auto-publish.mjs finalize-success
node scripts/notion-auto-publish.mjs finalize-failure

真正干活的是 process。它先挑出一篇符合条件的 Notion 页面,拉正文,再去生成英文版,然后把 frontmatter 和正文写进仓库内容目录。等 build 过了,再走 commit、push 和部署触发。成功和失败的回写则拆到后面两步去做,这样每一段职责都比较干净,不会变成一个几百行全塞在一起的大脚本。也正因为这样,我后来越来越觉得,这条链路里最值钱的反而不是“自动翻译”,而是它终于不那么容易把旧文章撞掉了。发文一旦变成自动化,最大的风险通常不是“多点了几下按钮”,而是“你以为自己在发新文,系统以为你在改旧文”。尤其标题差不多、slug 又是自动生成的时候,这个坑非常现实。

所以我后来把规则定得很死:新文章最好手工给一个唯一 slug;如果仓库里已经有同 slug 文件,默认直接失败;只有明确勾了“允许更新已发布文章”,才允许覆盖。换句话说,这条链路默认就是偏保守的。它宁可报错,也不帮你“猜”到底是不是该更新旧文。我现在反而很喜欢这个保守,因为写博客不像跑临时脚本,线上文章被误覆盖一次,后面收拾会比重新发一遍麻烦得多。

另外还有一个挺现实的问题,是第三方 OpenAI 兼容网关并不总是老实。我之前遇到过返回 200 但正文是空的,非流式接口没内容但流式反而正常,或者 schema 看起来接上了,字段名却偏掉的情况。也因为这样,脚本里后来补了好几层回退。不是为了写得花,而是因为一旦把“生成英文”和“生成 slug”塞进自动发文链路,这一层就不能太脆。说白了,我现在不是把模型当成一个绝对可信的发文器,而是把它当成这条链路里一个可替换的加工节点。它能帮我补英文版、补标题和 description,但仓库 schema、构建校验和覆盖保护这些更硬的边界,还是得留在自己手里。

真正要守住的边界

真正开始跑以后,我觉得最容易出问题的不是正文,而是边界没想清楚。比如你以为自己更新旧文,结果顺手换了 slug;你以为正文写在属性里也能发,结果脚本根本不读;你以为 Published 意味着服务器一定部署完成了,但现在这版实际上表示“内容链路已经成功推进到触发生产部署”;又或者你以为自动发文以后就不需要手动测试了,结果 build 一挂,问题还是要回到仓库里看。

还有一种很常见的误区,是把“在 Notion 写文章”理解成“在 Notion 里怎么排版都行,后面反正会自动变成博客”。这件事至少在我现在这条链路里还不是这样。更准确的说法应该是:你是在 Notion 里写一份更舒服的 Markdown 源稿,只不过编辑器从本地文件换成了 Notion。这个理解一旦对了,很多动作都会自然很多。你不会再执着于把每个块都做得像海报,而会更在意标题层级有没有乱、代码块有没有包对、图片是不是放在合适位置、重要链接是不是应该独占一行。反而是这种“先按可导出的方式写”,最后会让自动同步真正变得省心。

所以我现在更推荐的调试方式一直很保守。要测某一篇页面,先手动跑 workflow,把 page_id 指过去,再开 dry_run。这样它会把 Notion 拉下来,生成英文版,写进临时工作区再 build,但不会真的 push,也不会改线上。先把这一步跑通,再让它进正式发布,排错会轻松很多。

如果只想记最少的几条规则,其实也就这几条:正文写在 Notion 页面正文,不写在属性;新文章最好手工填 slug;更新旧文时保留原 slug,并显式允许更新;先把中文原稿写到可发状态,再让脚本生成英文;真出错时别只看最后一个 exit 1,往上看是哪一环先失败。

这也是为什么我最后会把这套东西定成现在这个样子。它不是一个“最先进”的博客系统,更像是一条足够清晰、足够稳、也足够贴近我自己写作习惯的内容链路。Notion 继续负责我最顺手的写稿动作,Astro 继续负责我最想保留的文件化发布,而 GitHub Actions 则把中间那段重复劳动吞掉了。

对我来说,这比再多一个后台按钮有用得多。