Notion 官方 API 自動備份到 GitHub:GitHub Actions 完整方案
如果你以前折騰過 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:
- 開啟 Notion Integrations
- 新建整合時,型別選
Internal - 至少給它
Read content - 建立後拿到以
secret_開頭的 Token
這裡只拿到 Token 還不夠。Notion 的權限控制很明確,整合預設並不能讀你所有頁面,你還得把它手動加到目標頁面裡。
這個動作在網頁端做:
- 開啟你準備作為備份入口的頂層頁面
- 點右上角
... - 找到
Add connections - 把剛剛建立的整合加進去
這一步沒做對,後面最常見的報錯就是:
Could not find block with ID- 查得到頁面 ID,但 API 死活讀不到內容
還有一個細節也別忘了:你需要儲存這個頂層頁面的 page_id。通常就是 URL 裡那串 32 位 ID,去掉橫線也能用。
GitHub 這邊其實只要準備兩個 Secrets
我不建議把 Token 和頁面 ID 寫死進工作流程檔案,直接放 GitHub Secrets 就行。
要準備的變數就兩個:
NOTION_INTEGRATION_TOKENNOTION_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,先別急著判死刑,先看日誌是不是還在繼續往下滾。
如果我自己要長期維護,會盯這幾件事
這類備份腳本一旦跑起來,後面最重要的就不是“它能不能匯出一次”,而是“它有沒有在變化後繼續穩定產出”。
我自己會重點看:
- 儲存庫裡最近幾天有沒有正常提交
- 新建子頁面後,遞迴匯出有沒有把它帶出來
- 資料庫裡的新條目有沒有跟著更新
- 重新命名邏輯在中英文標題、特殊符號場景下會不會出錯
如果你後面繼續把這套鏈路往下打磨,我建議先做兩件小事:
- 失敗時把關鍵報錯寫進 Actions 日誌裡,別隻返回退出碼
- 備份儲存庫按日期或目錄結構管理好,不然後面會越來越亂
這篇方案適合誰,不適合誰
適合:
- 想把 Notion 當知識庫長期存檔的人
- 不想再靠臨時 Cookie 抓匯出的人
- 已經在用 GitHub,希望順手把備份也納入儲存庫歷史的人
不太適合:
- 只想偶爾手工匯出一兩次的人
- 完全不想碰 GitHub Actions 和 Python 腳本的人
- 對附件、資料庫、遞迴結構完全沒有要求的人
如果你只是偶爾匯出一次,那官方網頁匯出也夠了。真正值得上這套自動化的,通常是筆記數量已經開始變多,而且你已經不想再手工點匯出了。