NotionアーカイブをZIP依存から内部API再帰クロールに切り替えた話 ─ 入れ子DB・画像・429対策まで
NotionアーカイブをZIP依存から内部API再帰クロールに切り替えた話 ─ 入れ子DB・画像・429対策まで
Notionワークスペースを丸ごとローカルとGitHubに退避させる自動アーカイブの続き。朝の時点では「公式ZIPエクスポートを正として、内部APIは補完」という計画だったが、進捗説明を聞いているうちに自分の過去の経験を思い出した。ZIPをダウンロードしても、データベースの中身は結局全部入っていないのだ。ここで計画の土台を差し替えて、「内部API単体で実行、ZIPは将来の任意クロスチェック」に切り替えた。
結果、この1日で全38ルート・861ページのテキスト本文と画像222枚の取得が終わり、さらに検索APIで見つかった大規模な取得漏れをChrome常駐の自己監督ループに追わせて、新規2,316ページ・103,659ブロックまで取り込んだところで明日に引き継いだ。
1日の到達点
| 項目 | 実績 |
|---|---|
| テキスト本文 | 全38ルート・861ページ、実質未解決エラー0件 |
| 入れ子DB再帰 | 実証ページで10個のDB・63ページ・2,656ブロックを0エラーで追跡 |
| 画像 | 224枚中222枚(99.1%)をSHA-256検証付きで回収、Markdown 123ファイル差し替え |
| 添付ファイル | 3件回収、blobs・manifest・Markdownへ反映 |
| 追加クロール | 新規2,316ページ・103,659ブロック取得、残キュー4,757で安全停止 |
| セキュリティレビュー | path-traversal / SSRF の2指摘を確認、追加スキャンも実施 |
| R2複製 | 認証情報待ち。ステータスのみ保存 |
いずれもnotion-archiveリポジトリへコミット・push済み。以下、時系列で試行錯誤を残す。
「ZIPでもDBの中身は取り切れない」で方針転換
朝一のスパイクは順調だった。ログイン済みChromeのNotionタブからChrome DevTools MCP経由で内部API getSpaces を読み取り専用で叩かせ、ワークスペース台帳が取れることを確認。続けて loadPageChunk で現在ページの本文102ブロックも読めた。「Chrome DevToolsで読めるならPAT(公式APIトークン)は要らないのでは」という自分の思いつきが当たり、この時点でPATルートを廃止して内部API一本化を決めた。ブロックJSON→Markdownの変換器も純粋関数250行で書かせ、実ページ149ブロック→Markdown 8,699文字を1.3ms・未対応型ゼロで変換できるところまで見せてもらっていた。
一方でCodexの4回目レビューは「内部API一本化では完全性ゲートが弱くなる」というP0指摘を出してきて、公式ZIPを独立した検証基準に格上げしたばかりだった。理屈としては筋が通っている。独立した2経路で突き合わせれば、片方の取り漏れを検出できる。
ただ、進捗を聞いて引っかかった。ZIPエクスポートは自分で何度か使っていて、入れ子になったデータベースの中身が欠けた経験がある。「解約前の最終判定は公式ZIPと照合」という設計は、照合先自体に穴があるなら成立しない。それなら内部APIで取れるところまで全部取ってGitHubにプッシュする方が実利がある、と判断して方針を変えさせた。ZIPは捨てるのではなく「将来の任意クロスチェック」に降格した。
このとき正直な申告も引き出せた。実装済みなのは「1ページの本文取得→Markdown変換」までで、入れ子DBの再帰と画像取得は未実装。ここから実装フェーズに入らせた。
入れ子DBの再帰実装と「50件の壁」
入れ子DBの行データを取る queryCollection は、最初400エラーを返してきた。パラメータの形が分からないので、Notionのフロントエンドが実際に発行したリクエストをネットワークログから覗かせたら、collectionView.id(ビューID)が必須で、それはブロックの format.view_ids から取れると判明した。
これで再帰アルゴリズムが一様な形に落ちた。
- どのページも
loadPageChunkで子ブロックを取る - 中に
collection_viewを検出したらqueryCollectionで行ページの一覧を取る - 行ページをまた
loadPageChunkする(行ページの子ブロックはqueryCollectionには入っていない)
まず今開いていた記事ページで実証させたら、ルート直下の6個に加えて行ページの中からさらに4個、計10個の入れ子DBを再帰追跡して、63ページ・2,656ブロックを0エラーで取得した。
地味な制約にも当たった。MCPのファイル書き込みはワークスペース外に書けないので、取得結果は一度scratchpadに書かせてからBashでコピーする迂回で保存している。
全体展開の前に規模も測らせた。ワークスペース台帳から38個のルートページが見つかり、その直下だけで437要素。税務や事務所運営といった業務カテゴリごとのマニュアルツリーがぶら下がっていて、ナレッジベース全体では数百〜数千ページ規模になると分かった。実証に使った記事の65ページは、そのうちの1サブツリーに過ぎなかった。
全38ルートへ展開したところで壁に当たった。約300行あるはずの大きなDBが、limit をいくら上げても50件で止まる。レスポンスを眺めさせていたら、sizeHint: 298 という実行数のヒントと、allBlockIds という別フィールドに全298件のIDが並んでいるのが見えた。行の実体は50件で打ち切られても、IDの台帳は全量返ってきている。ウォーカーをこのIDリスト基準に直して、完全性の懸念(Codexの#3指摘そのもの)を潰した。
もうひとつの壁がレート制限で、連続リクエストで429が44件出た。リトライ+指数バックオフを入れ、リクエスト間隔400ms+バッチ間25〜60秒のクールダウンという安定パターンに落ち着けて、全38ルート・861ページを実質未解決エラー0件で取り切った。途中の459ページ時点で中間チェックポイントとしてコミット・pushしておき、どこで止まっても取り直しが最小で済むようにした。
クールダウンの待ち時間も遊ばせなかった。取得済みデータから未対応ブロック型が19件検出されていたので、待機中に blocksToMarkdown へtweet・pdf・embed・file・video・miroなど8型の対応を追加させ、テスト33件パス・型チェッククリアまで確認した。判明したレート制限の実挙動(回復に約3分かかる)も、その場で計画書に記録させた。
画像はネットワークログから署名付きURLを拾う
画像は素直には取れなかった。試した経路を並べると:
- S3(
secure.notion-static.com)直叩き → Cookie無しでは403 - ページ内JSからの
fetch()→ CORSでブロック - Notion自身のプロキシ(
/image/...)経由のfetch()→ CSPのconnect-srcに塞がれてこれも失敗
<img> タグでは表示できているのに fetch() だけ落ちる。ならばブラウザが実際に読み込んだリソースをネットワークログから直接回収すればいい。ログを見ると、プロキシは img.notionusercontent.com への302を返していて、そのリダイレクト先が exp/sig 付きの署名付きURLだった。これはCookie不要で、Nodeから直接ダウンロードできた。
午後のセッションでこの方式を本番投入した。1ページずつナビゲートしてネットワークログから署名付きURLを回収するワークフローを、124ページ・11バッチの逐次処理(ブラウザは共有リソースなので並列化しない)で約113分回し、224枚中222枚(99.1%)をSHA-256検証付きで回収。回収不能の2枚も理由を確定させてから締めた。細かい躓きもあった。バックグラウンドワークフローへの args がJSON文字列として渡される仕様に気づかず1回目が空振りし、小さなスモークテストで受け渡し方を切り分けてから本番を再実行している。
Markdown側への反映は、当初考えた「861ページ全体を再生成」をやめた。変換ロジックを完全に再現しないと無関係な差分が混ざるリスクがあるので、画像URLの文字列だけを直接置換するパッチスクリプト(patch-image-urls.mjs)を書かせて、確定済みの1件で検証してから123ファイルに適用した。コミット前の検査でURL・署名の混入ゼロも確認している。
Chrome常駐JSループが429を自分で待つ
画像が終わって完了報告を受けたところで、あるページが取得済みか確認したら漏れていた。検索APIでスペース全体を列挙して取得済み861ページと差分を出させたら、想定よりはるかに大きい取得漏れが見つかった。サイドバーからしか辿れないteamspaceのツリーなどが、ルートページ起点の再帰では届いていなかった。teamspaceのホームは直接開けないので、通常ページのサイドバーを展開させて74リンクを回収するという回り道もした。
ここからの追加クロールは量が桁違いなので、体制を変えた。Chrome DevTools MCP経由でNotionタブに常駐JavaScriptの自己監督ループを仕込ませ、429が返ったら実測で分かっていた回復時間どおり自動で3分待機→再開する作りにした。タブを閉じない限りクロールは勝手に続き、自分は10分タイマーで進捗を確認するだけになる。朝は429のたびに人間側が45秒だ60秒だとクールダウンを差配していたのと比べると、監督コストが1桁下がった。
仕込みには細かい工夫が要った。NotionページのCSPはlocalhostへのfetchも塞ぐので、クロール対象のID一覧は外部から読み込めず、3分割して関数本体に文字列として埋め込み注入した。また429を繰り返している間、自分の普段のNotion利用が巻き添えになっていないかをUIの表示で確かめさせ、通常操作には影響なしと確認してから続行している。
停止時点の実績は、新規2,316ページ・103,659ブロック、残キュー4,757(サイクル5回目)。チェックポイントは節目ごとにディスクへ退避させた。
添付・コメント・復元試験まで消化、R2は認証情報待ち
クローラーを回しながら、計画書(PLAN.md)の残作業も進めた。添付ファイル3件は同じ署名URL方式で回収してblobs・manifest・Markdownへ反映。コメント13件の取得、report生成、隔離環境での復元試験も同じ流れで進めた。閲覧UIはコンソールエラーゼロを確認してPLAN.mdに必須化を明記した。R2への複製だけは認証情報が必要なので、着手せずに現状をステータスとして書き残した。
データの置き場所の切り分けも計画書どおりに守らせた。内部APIの生レスポンス(12.7MB)は .gitignore で除外したGit管理外の raw/ 領域に置き、コミットするのはMarkdownとmanifestだけ。アーカイブデータのコミットは学習ゲートを機械的コミットとして通す運用も、計画書に明記した手順のままにした。
セキュリティレビューで path-traversal と SSRF を拾う
コミット前に、今日書いたスクリプト群のセキュリティレビューを回した。指摘として挙がったのは、patch-image-urls.mjs のpath-traversal(manifestの値がファイルパスに流れ込む経路)と save-images.mjs のSSRF(ダウンロードURLの検証)の2件。
この2件を押さえた上で、それ以外の脆弱性が残っていないかも重ねてスキャンさせた。ブラウザ常駐のクローラースクリプトについても、「自分のブラウザで・自分の認証情報の下で・自分のワークスペースだけを読む」という脅威モデルで確認した。
明日への引き継ぎ
終業前にクローラーを安全停止させた。最終チェックポイントの保存を指示したら、ページのメインスレッドが巨大JSONの直列化で固まって応答が数十秒返ってこない一幕もあったが、待って再試行し、状態は恒久保存できた。成果はすべてnotion-archiveリポジトリへコミット・push済み。
計画書のSSOTも整理した。画像取得を別セッションでやる流れで計画書を複製しかけたが、2箇所に置くと更新のたびに食い違うので、本体をnotion-archive側へ移動し、mdx-playground側は「移管済み」の1行ポインタだけ残した。
この引き継ぎ形式は、実は今日すでに一度機能している。朝のセッションで書かせた手順書 IMAGE_FETCH.md を午後の別セッションに読ませたら、そのまま画像取得を完走させられた。明日も同じ形で、進捗メモとチェックポイントから再開する。
- 常駐クローラーを再開して残キュー約4,700ページを取り切る
- 新規取得分のMarkdown組み立てと画像・添付の回収を同じ方式で回す
- R2複製の認証情報を用意して複製を実行する(ステータス保存済み)
学び
- 公式エクスポートが常に「正」とは限らない。ZIPよりも内部APIの方が網羅的だった。照合基準は理屈ではなく実データで選ぶ
- ページネーションは
hasMoreを信じるだけでは足りない。sizeHintとallBlockIdsのような台帳フィールドと突き合わせて初めて完全性を主張できる fetch()が403・CORS・CSPと三重に塞がれていても、ブラウザが画像を表示できているならネットワークログに署名付きURLという答えが残っている- レート制限対応は「人間が待つ」から「ページ内ループが自分で待って再開する」に寄せると、長時間クロールでも監督は10分に1回の確認で済む
- ルートページ起点の再帰だけでは、サイドバーからしか辿れないツリーを取りこぼす。「全部取れた」の判定には検索APIなど別経路の全件列挙と突き合わせる
- バックグラウンドに何かを走らせたら、その状態は自分側で追跡する。10分タイマーの確認・チェックポイント保存・安全停止までがワンセット
- 計画書を複製すると必ず食い違う。移すなら移動して、元の場所にはポインタだけ残す