# 如何在 Notion 写文章，再自动同步到静态博客

> Canonical: https://vxi.me/posts/notion-to-astro-auto-publish-workflow/
> Locale: zh-cn
> Published: 2026-04-10
> Updated: 2026-04-10
> Markdown: https://vxi.me/posts/notion-to-astro-auto-publish-workflow.md

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

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

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

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

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

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

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

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

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

```text
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 写法，大概就是这种感觉：

````text
# 标题

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

## 某个重点

继续写正文。

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

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

https://example.com/some-reference
````

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

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

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

```bash
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 则把中间那段重复劳动吞掉了。

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