Notion 公式 API を GitHub に自動バックアップする:GitHub Actions 構成
Notion のバックアップを自動化しようとすると、よく二つの問題に当たります。
一つは token_v2 のような非公式な認証方式が、時間が経つと失効したり、ログイン状態や IP の変化で突然使えなくなったりすること。
もう一つは「export は成功したのに、トップページしか取れていない」「子ページや database、添付、ファイル名が期待した形になっていない」という問題です。
私は最終的に、Notion の公式 API と Internal Integration を使い、実行は GitHub Actions に任せる構成にしました。
これで少なくとも、
- Cookie に依存しない認証
- ローカル PC を常時起動しなくてよい定期実行
- Markdown export 後の再帰処理と rename
の三つを安定させられます。
なぜ非公式 Token 方式をやめたのか
非公式方式も短期的には便利です。
ただ、長期バックアップでは「いつ動かなくなるか分からない」という不確実性が残ります。
今日動いていても、明日には 401 / 403 が出るかもしれません。Cookie、login state、異なる IP など、人間向け session に依存しているからです。
公式 API では、少なくとも認証の意味が明確になります。
- Token は Internal Integration
- 読めるページは integration に明示的に共有された範囲
- GitHub Actions から定期実行できる
- browser login session を維持する必要がない
難しいのは API call 自体ではなく、official API + exporter + recursive traversal をどう一つの backup pipeline にするかです。
全体構成
私はまず処理を次のように分けています。
Notion のトップページ
↓
Internal Integration を接続
↓
GitHub Actions が定期実行
↓
Python + notion-client + notion2md
↓
子ページ / 子 database を再帰取得
↓
Markdown + 添付を export
↓
zip を展開
↓
block id のファイル名を実タイトルへ rename
↓
GitHub repository に commit
GitHub Actions はスケジューラです。
Notion SDK は API client。
notion2md は主にページから Markdown を作る部分を担当します。
「配下をすべてたどる」「人間が読めるディレクトリにする」という部分は、自分の Python で補います。
最初に Internal Integration の権限を通す
まず Notion の integration を作ります。
- Notion Integrations を開く
- 新しい integration を
Internalとして作る - 最低でも
Read contentを許可 secret_で始まる Token を保存する
Token を作っただけでは、すべてのページへアクセスできません。
バックアップ対象のトップページを開き、
- 右上の
... Add connections- 作った integration を追加
します。
ここを忘れると、
Could not find block with ID- page ID は分かっているのに内容を取得できない
といったエラーになりやすいです。
トップページの page_id も保存しておきます。URL 内の 32 文字 ID はハイフンを除いて使うこともできます。
GitHub 側は二つの Secrets から始める
Token や page ID を workflow に直書きせず、GitHub Secrets に置きます。
NOTION_INTEGRATION_TOKENNOTION_PAGE_ID
workflow は例えば次の形です。
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
ここまででも単一ページの backup はできます。
本当に重要なのは、その中で呼ぶ export script です。
本質は recursive export
公式 API は「このページ以下をサイトとして全部 export して」と一度に処理してくれるわけではありません。
そのため、最低でも次を自分で扱います。
- root page の Markdown export
- child page の再帰
- child database 内 page の再帰
- export 後の block id filename を実タイトルへ rename
- 添付を含む zip の展開
基本骨格は次のようにできます。
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)
きれいな抽象化より、実際に困る点を確実に処理することを優先した形です。
このスクリプトで、
- official API が自動再帰しない
- export 添付が zip になる
- filename が block id になる
- database 内ページが別途必要
という実務上の問題をまとめて処理できます。
Internal Integration の方が長期運用しやすい理由
違いを単純化すると、
token_v2 は browser login state を利用する方法、
Internal Integration は service に明示的な read permission を与える方法です。
前者はすぐ試せます。
後者は最初に permission を設定する必要がありますが、定期 backup には向いています。
GitHub Actions も、
- 1 日 1 回動く
- failure log が残る
- 成功時に repository へ commit できる
という性質がこの用途と合っています。
失敗時に最初に見るポイント
API 自体より、次の設定で止まることが多いです。
- Secret の Token に余計な space がある
- 古い
secret_...を使っている - integration を target page に追加していない
- page ID が違う
- export は成功しているが rename / unzip / output path が間違っている
Notion API には rate limit もあります。
ページ数、attachment、database の階層が増えるほど処理には時間がかかります。
Actions が数分走っていても、ログが進んでいるなら単に recursive traversal / download 中の可能性があります。
長期運用で見るべきこと
一度成功したかより、変更後も継続して backup されているかを見ます。
私は次を確認します。
- 最近の定期 commit があるか
- 新しい child page が追加されているか
- database の新規 entry が入っているか
- 日本語 / 中国語 / 記号入り title の rename が壊れていないか
改善するならまず二つです。
- failure 時に原因を Actions log へ分かりやすく出す
- backup repository の directory / date 構造を早めに決める
向いている人
この構成は、
- Notion を knowledge base として長期保存したい
- temporary Cookie に依存したくない
- GitHub を使っていて backup history も Git に残したい
- child page / database / attachment まで自動化したい
人に向いています。
一度だけ export したいなら、Notion の標準 export でも十分です。
この自動化が価値を持つのは、コンテンツが増えて手動 export を続けたくなくなったときです。