如果你以前折騰過 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 腳本的人
  • 對附件、資料庫、遞迴結構完全沒有要求的人

如果你只是偶爾匯出一次,那官方網頁匯出也夠了。真正值得上這套自動化的,通常是筆記數量已經開始變多,而且你已經不想再手工點匯出了。