如果你以前折腾过 Notion 备份,大概率踩过两种坑:一种是靠 token_v2 这类非官方方式,隔一阵就过期;另一种是导出看起来成功了,结果只拿到一个主页面,子页面、子数据库、附件和文件名全都不太像你想要的样子。

我最后定下来的做法,是直接走 Notion 官方 API,再把导出流程交给 GitHub Actions。这样至少先把三件麻烦事压住了:授权不再靠 Cookie,定时任务不靠本地机器开着,导出的 Markdown 还能继续补一层递归和重命名逻辑。

如果你正在找这些问题的方案,比如 Notion 自动备份到 GitHub、Notion GitHub Actions 备份、Notion 官方 API 导出 Markdown,这篇就是围绕这几个搜索意图写的。

我为什么不再继续用非官方 Token 方案

非官方方案也不是完全不能用,问题在于它总带着一点“不知道哪天会失效”的不确定性。你今天能跑,明天可能就开始 401、403,或者因为异地 IP、Cookie 失效、登录状态变化突然断掉。

而官方 API 至少把最核心的一层稳定性给你了:

  • Token 是 Internal Integration,不是临时抓来的 Cookie
  • 权限模型清楚,页面能不能读,取决于你有没有把集成加进页面
  • GitHub Actions 定时跑起来以后,不需要你自己守着本地机器

真正要补的,其实不是“怎么调用 API”,而是怎么把官方 API、导出工具和递归逻辑拼成一个能长期跑的备份链路。

这套方案最终长什么样

我更喜欢先把结构看明白,再写命令。整条链路其实不复杂:

Notion 顶层页面
      ↓
Internal Integration 授权
      ↓
GitHub Actions 定时触发
      ↓
Python 调 notion-client + notion2md
      ↓
递归遍历子页面 / 子数据库
      ↓
导出 Markdown + 下载附件
      ↓
自动解压 zip
      ↓
把文件名从 block id 改成真实标题
      ↓
提交到 GitHub 仓库

外层看是 GitHub Actions 在跑,真正的灵魂其实是中间那段 Python 脚本。因为 GitHub Actions 只是调度器,Notion 官方 SDK 只是 API 客户端,notion2md 也主要解决“单页导出 Markdown”这一步。你真正想要的“递归抓全站”和“导出后像人能看懂的目录结构”,还得自己补。

先把官方授权打通,不然后面脚本再长都白搭

第一步是去 Notion 开发者后台建一个 Internal Integration:

  1. 打开 Notion Integrations
  2. 新建集成时,类型选 Internal
  3. 至少给它 Read content
  4. 创建后拿到以 secret_ 开头的 Token

这里只拿到 Token 还不够。Notion 的权限控制很明确,集成默认并不能读你所有页面,你还得把它手动加到目标页面里。

这个动作在网页端做:

  1. 打开你准备作为备份入口的顶层页面
  2. 点右上角 ...
  3. 找到 Add connections
  4. 把刚刚创建的集成加进去

这一步没做对,后面最常见的报错就是:

  • Could not find block with ID
  • 查得到页面 ID,但 API 死活读不到内容

还有一个细节也别忘了:你需要保存这个顶层页面的 page_id。通常就是 URL 里那串 32 位 ID,去掉横线也能用。

GitHub 这边其实只要准备两个 Secrets

我不建议把 Token 和页面 ID 写死进工作流文件,直接放 GitHub Secrets 就行。

要准备的变量就两个:

  • NOTION_INTEGRATION_TOKEN
  • NOTION_PAGE_ID

然后在仓库里建:

.github/workflows/backup.yml

工作流外壳并不花哨,核心就是:

  • 检出仓库
  • 准备 Python
  • 安装 notion-client 和 notion2md
  • 跑你自己的导出脚本
  • 如果内容有变化就提交回仓库

像这样就够了:

name: "Notion Official API Backup"

on:
  schedule:
    - cron: "0 0 * * *"
  workflow_dispatch:

jobs:
  backup:
    runs-on: ubuntu-latest
    permissions:
      contents: write

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.10"

      - name: Install dependencies
        run: pip install notion2md notion-client

      - name: Run export
        env:
          NOTION_TOKEN: ${{ secrets.NOTION_INTEGRATION_TOKEN }}
          NOTION_PAGE_ID: ${{ secrets.NOTION_PAGE_ID }}
        run: python export_script.py

      - name: Commit changes
        run: |
          git config --local user.email "[email protected]"
          git config --local user.name "GitHub Action Backup"
          git add .
          if git diff --staged --quiet; then
            echo "No content changes"
          else
            git commit -m "Auto backup: $(date +'%Y-%m-%d %H:%M:%S')"
            git push
          fi

如果你只是想先把流程跑通,到这里其实已经够用了。后面真的决定这套方案好不好用的,是导出脚本本身。

真正关键的是递归导出,不是工作流外壳

直接调用官方 API 的问题是,它不会自动替你做“站点级递归导出”。很多人第一次跑完都会以为出问题了,其实不是没导出,而是只导出了你指定的那个页面。

所以我会额外包一层 Python 逻辑,至少处理这四件事:

  • 把主页面导出成 Markdown
  • 遍历所有子页面
  • 如果碰到子数据库,继续把数据库里的页面抓出来
  • 导出后自动把 block id 文件名改成真实标题

下面这段是我觉得真正值得保留的核心骨架:

import os
import zipfile
from notion_client import Client
from notion2md.exporter.block import MarkdownExporter

token = os.environ["NOTION_TOKEN"]
root_id = os.environ["NOTION_PAGE_ID"].replace("-", "")
notion = Client(auth=token)

def sanitize_title(title: str) -> str:
    safe = "".join(
        c for c in title
        if c.isalnum() or c in (" ", "-", "_", "(", ")", "【", "】", "、", ".")
    ).rstrip()
    return safe or "untitled"

def export_recursive(block_id: str, output_dir: str, title: str):
    block_id = block_id.replace("-", "")
    safe_title = sanitize_title(title)
    os.makedirs(output_dir, exist_ok=True)

    MarkdownExporter(block_id=block_id, output_path=output_dir, download=True).export()

    zip_path = os.path.join(output_dir, f"{block_id}.zip")
    if os.path.exists(zip_path):
        with zipfile.ZipFile(zip_path, "r") as zip_ref:
            zip_ref.extractall(output_dir)
        os.remove(zip_path)

    md_path = os.path.join(output_dir, f"{block_id}.md")
    new_md_path = os.path.join(output_dir, f"{safe_title}.md")
    if os.path.exists(md_path):
      if os.path.exists(new_md_path):
          os.remove(new_md_path)
      os.rename(md_path, new_md_path)

    results = []
    next_cursor = None
    while True:
        res = notion.blocks.children.list(block_id=block_id, start_cursor=next_cursor)
        results.extend(res.get("results", []))
        next_cursor = res.get("next_cursor")
        if not next_cursor:
            break

    child_dir = os.path.join(output_dir, safe_title)

    for block in results:
        if block["type"] == "child_page":
            export_recursive(block["id"], child_dir, block["child_page"]["title"])
        elif block["type"] == "child_database":
            db_dir = os.path.join(child_dir, sanitize_title(block["child_database"]["title"]))
            pages = notion.databases.query(database_id=block["id"]).get("results", [])
            for page in pages:
                page_title = "untitled"
                for _, prop in page["properties"].items():
                    if prop["type"] == "title" and prop["title"]:
                        page_title = prop["title"][0]["plain_text"]
                        break
                export_recursive(page["id"], db_dir, page_title)

这个版本的重点不是“代码多优雅”,而是它终于把几个很烦的实际问题一次性压住了:

  • 官方 API 不递归的问题
  • 导出带 zip 附件的问题
  • 文件名全是 block id 的问题
  • 子数据库页面默认不会一起落盘的问题

这套方案为什么比 token_v2 更值得长期留着

我觉得最关键的不是“官方方案听起来更正规”,而是它真的更适合长期无人值守。

你把这件事拆开看就很明显:

  • token_v2 方案更像是借用浏览器登录状态
  • Internal Integration 更像是明确授权一个服务账号去读内容

前者能快速验证,但长期维护时总有一点悬着。后者前期多配一步权限,后面反而更省心。

再加上 GitHub Actions 本来就很适合这类“每天跑一次、失败了留日志、成功了自动提交”的后台任务,所以这套组合放在一起就很自然。

最容易翻车的地方,不在 API 调用本身

真要说排错,我会先看下面这几项:

  • Secrets 里的 Token 多了空格,或者压根不是最新的 secret_...
  • 集成虽然建好了,但没有被加进目标页面
  • 页面 ID 取错,拿成了别的块或者别的数据库
  • 导出脚本能跑,但根目录、重命名逻辑或者 zip 解压逻辑没处理好

还有一个经常被忽略的点:Notion API 有速率限制,页面多、附件多、数据库层级深的时候,跑得慢不一定是挂了。很多时候就是在慢慢遍历和下载。

所以如果你看到 GitHub Actions 挂了好几分钟,还在 Run,先别急着判死刑,先看日志是不是还在继续往下滚。

如果我自己要长期维护,会盯这几件事

这类备份脚本一旦跑起来,后面最重要的就不是“它能不能导出一次”,而是“它有没有在变化后继续稳定产出”。

我自己会重点看:

  • 仓库里最近几天有没有正常提交
  • 新建子页面后,递归导出有没有把它带出来
  • 数据库里的新条目有没有跟着更新
  • 重命名逻辑在中英文标题、特殊符号场景下会不会出错

如果你后面继续把这套链路往下打磨,我建议先做两件小事:

  1. 失败时把关键报错写进 Actions 日志里,别只返回退出码
  2. 备份仓库按日期或目录结构管理好,不然后面会越来越乱

这篇方案适合谁,不适合谁

适合:

  • 想把 Notion 当知识库长期存档的人
  • 不想再靠临时 Cookie 抓导出的人
  • 已经在用 GitHub,希望顺手把备份也纳入仓库历史的人

不太适合:

  • 只想偶尔手工导出一两次的人
  • 完全不想碰 GitHub Actions 和 Python 脚本的人
  • 对附件、数据库、递归结构完全没有要求的人

如果你只是偶尔导出一次,那官方网页导出也够了。真正值得上这套自动化的,通常是笔记数量已经开始变多,而且你已经不想再手工点导出了。