# Notion 公式 API を GitHub に自動バックアップする：GitHub Actions 構成

> Canonical: https://vxi.me/ja/posts/notion-api-github-actions-backup/
> Locale: ja-jp
> Published: 2026-03-27
> Updated: 2026-03-27
> Markdown: https://vxi.me/ja/posts/notion-api-github-actions-backup.md

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 にするか**です。

## 全体構成

私はまず処理を次のように分けています。

```text
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 を作ります。

1. [Notion Integrations](https://www.notion.so/my-integrations) を開く
2. 新しい integration を `Internal` として作る
3. 最低でも `Read content` を許可
4. `secret_` で始まる Token を保存する

Token を作っただけでは、すべてのページへアクセスできません。

バックアップ対象のトップページを開き、

1. 右上の `...`
2. `Add connections`
3. 作った integration を追加

します。

ここを忘れると、

- `Could not find block with ID`
- page ID は分かっているのに内容を取得できない

といったエラーになりやすいです。

トップページの `page_id` も保存しておきます。URL 内の 32 文字 ID はハイフンを除いて使うこともできます。

## GitHub 側は二つの Secrets から始める

Token や page ID を workflow に直書きせず、GitHub Secrets に置きます。

- `NOTION_INTEGRATION_TOKEN`
- `NOTION_PAGE_ID`

workflow は例えば次の形です。

```yaml
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 "action@github.com"
          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 の展開

基本骨格は次のようにできます。

```python
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 が壊れていないか

改善するならまず二つです。

1. failure 時に原因を Actions log へ分かりやすく出す
2. backup repository の directory / date 構造を早めに決める

## 向いている人

この構成は、

- Notion を knowledge base として長期保存したい
- temporary Cookie に依存したくない
- GitHub を使っていて backup history も Git に残したい
- child page / database / attachment まで自動化したい

人に向いています。

一度だけ export したいなら、Notion の標準 export でも十分です。

この自動化が価値を持つのは、コンテンツが増えて手動 export を続けたくなくなったときです。
