Notion 9,375ページの移行先を設計し直す — 原本243MBの抜けと、Markdown→Googleドキュメント変換の実測
Notion 9,375ページの移行先を設計し直す
Notion解約に向けた移行は、データの吸い上げまで終わっている。9,375ページ、アセット5,305件、4.14GB。残っているのは raw から Markdown を書き出す変換だけ、のはずだった。
Xで「Notionを何に移行するんですか」と聞かれたので、移行後の姿を1枚の構成図にしてもらった。画像や動画といった静的アセットは R2 に置き、記事コンテンツは Cloudflare にデプロイしてメールアドレスでアクセス制限をかける。図としては整っていた。整っていたので、そのまま出すつもりでいた。
構成図に原本が入っていなかった
図を眺めていて、手が止まった。①Markdown、②画像、③予備。3箇所に置くと書いてある。しかしこの3つは全部、raw の生JSONから作った派生物である。肝心の raw/merged-blocks.json(243MB)が、移行先のどこにも置かれていない。3箇所に複製したところで原本はゼロになる。
自分の見立てを先に固めてから Codex にレビューさせた。判定は「方向性は妥当、ただし致命的な穴が2つ」。1つ目は自分と同じ raw の抜け。2つ目は自分が甘かった点で、GitHub・R2・予備は3コピーではなくデータを分割して置いているだけという指摘だった。図の帯に書いた「3箇所で持つ」が、そもそも成立していない。
原本が .gitignore に入っていた理由も確認させた。243MB は GitHub の100MB制限を超える、と明記されていた。書いた本人(自分)が制限を回避するために除外し、そのまま図でも落としていたわけである。
リポジトリごと作り直す方に倒した
構成の話をしている途中で、もっと手前の問題が出てきた。Markdown 9,376件をすでにプライベートリポジトリへ push 済みで、その中に平文のAPIキーらしき文字列や個人情報らしき記述が混ざっている疑いが出た。値は一切表示させず、判定結果とページ数だけを出すスクリプトを書かせて確認した。
対処を「1件ずつ履歴から消す」でいくか「リポジトリを消して作り直す」でいくかで迷った。ただ、これは最初に提案されていた案でもある。GitHub公式のドキュメントで裏を取らせたところ、git filter-repo で履歴を書き換えてもキャッシュされたビューやフォーク経由で残り得る、と明記されていた。1件ずつ潰す道は、確実さで負ける。
リポジトリの削除は自分の手で実行した。除外候補のリストは顧客名を含むため、リポジトリには置かずGit管理外のローカルディレクトリで運用している。この方針は最後まで崩さなかった。
閲覧側の理解も1つ確認できた。R2 は既定で非公開、その上に Cloudflare Access のメール認証をかけたページを置けば、Notionで見えていた内容とほぼ同じものを閲覧できる。アプリとして運用したいわけではなく、データが確実に移っていることと、AIエージェントに読ませられることのほうが目的として大きい。テキストは Turso に入れて画像は R2、という組み合わせも並行して検討対象に入れた。
「Googleドキュメントにそのまま載らないのか」
Markdown と画像で落としてあるのだから、Googleドキュメントに反映できるのではないか、と思いついた。ここから半日、実測に費やすことになる。
Drive API には --upload-content-type text/markdown が用意されていて、Markdown を Googleドキュメントに変換する経路は正式に存在した。テスト用の md を作って通してみると、変換自体はあっさり通る。テキストは綺麗に入る。ところが画像が3パターンとも消えた。
消えたと報告を受けた直後に Docs API 側を直接見させたら、画像オブジェクトは存在していた。エクスポートで落ちていただけである。中身を精査すると、公開URLの画像だけは Google が取り込んでいた。ローカル相対パス参照の画像は入らない。うちの md は6,321件がその形式なので、このままでは全滅ということになる。
Drive に上げた画像のリンクを貼る案
画像を自分のGoogleワークスペースのドライブに入れて、そのリンクをドキュメントに貼ればいいのではないか、と提案した。Drive の画像URLは4形式あるので、まとめて検証してもらった。
4形式すべてに contentUri が付いた。付いたので成功したように見える。しかし前のテストで存在しないダミーURLにも contentUri が付いていたため、これは証拠にならない。実体を確かめさせると、画像は1枚も入っていなかった。非公開のままでは4形式とも通らない。
ファイルを「リンクを知っている全員」に変える案は、権限変更が自動承認でブロックされて止まった。公開したくもないので、ここは行き止まりである。
pandoc で docx にしてから上げたら通った
公開せずに済む経路として、pandoc で DOCX に変換する案が出てきた。DOCX なら画像がファイル内部に埋め込まれる。303KB の DOCX ができ、それを Googleドキュメントに変換した。
APIの応答で判定しないと決めていたので、PDFに書き出して目に見える形で確かめた。213KB。画像なしのときは31KB だった。公開URLもDrive権限も一切使わず、ローカルの画像がGoogleドキュメントに入っている。
一番画像が多い記事で試す
テスト用の小さい md で通っても意味がない。画像が最も多いページを特定させ、146枚・105枚・103枚の3本で試した。
鬼門になりそうだったのが画像パスである。参照先は3階層上の blobs/ で、ファイル名に拡張子がない(sha256のまま)。ここで落ちると踏んでいたら、警告ゼロで21.6MBの DOCX ができた。拡張子なしでも pandoc は画像を認識する。
Googleドキュメント側で画像の実体数を数えさせると、168 / 105 / 103。2本目と3本目は完全一致だった。1本目だけ「226枚あるはずが168個」で58枚足りない。原因を追わせたところ、内訳はローカル146枚+外部URL 80枚で、落ちていたのは外部URL側だった。ローカル画像は100%入っている。
ここで1つ躓きがあった。regenerated 側は blobs から離れた場所にあるため、--resource-path を渡さないと画像が全部落ちる。23KB のまま出てきた DOCX が、指定を足したら1.49MB になった。
ファイル名とプロパティの設計
変換が通ることが分かると、次は「どう並べるか」の話になる。
Googleドキュメント側のファイル名は、Notionの記事タイトルをそのまま使える。1行目が # タイトル なので取得は容易で、9,375ファイル全部にタイトルが入っていた(100%)。日本語もリネームでそのまま通る。ただしタイトルにスラッシュが入るとフォルダ区切りと衝突するので、アンダースコアに置き換える方針にした。
もう1つ、画面を見ていて気になったのがプロパティである。NotionのDB行にはTOCなどのプロパティが並んでいるのに、変換後の Markdown には <!-- notion-toc --> という位置マーカーだけが残って中身が空だった。原本に値が残っているか探させたところ、列ID Ceo^ として完全に残っていた。Wordでいうフロントマターに相当する場所へ入れてしまえばいい。
教材開発のまとめページ(子ページ140件+データベース入り)を題材に、変換を作り直させた。ここで3つの欠陥が見つかった。
- 親ページに子の中身が丸ごと展開されていて、同じ内容が2箇所に重複していた
- 親からのリンクが脱落し、孤立ページが3,318件(全体の35%)に達していた
- DB行がタイトル1行だけになり、プロパティが全部落ちていた
199ページを再生成して、3つとも直ったことを確認した。親は4,185行から613行へ減り(85%削減)、画像は37→62、行数は6,339→8,733に増えた。差分の457行を精査させると、真の欠落はゼロで、59件はリンクラベルの装飾記号だけだった。
移行後の目視で、さらに2つバグが出た。コードブロックの言語指定に半角スペースが入っているとフェンスが閉じず、以降が全部コードブロックに飲まれる(5ファイル)。見出しの前に空行がないと、直前のリスト項目に見出しが飲み込まれる(55ファイル)。どちらも画面を見なければ気づかない類のもので、APIの応答は正常を返していた。
Googleドキュメントを Markdown の見た目で使う
作業の合間に、Googleドキュメント側の見た目も直した。Markdown記法を有効にする設定(ツール → 設定 → 全般タブ → 「マークダウンを有効にする」)は、すでにONになっていた。
自分が消したかったのは紙面レイアウトのほうだった。こちらは別物で、ファイル → ページ設定 → 「ページ分けなし」。適用すると改ページの区切りが消え、地続きの1枚になる。幅も「フル」にできる。これでほぼMarkdownと同じ感覚になった。
ただしこの設定はドキュメント単位である。「デフォルトとして保存」を押させようとしたら、ボタンがグレーアウトしていて押せなかった。しかも1度目は「押せる状態でした」と報告が返ってきており、aria-disabled だけを見て disabled: true を見落としていた。画面の実物を確かめさせて初めて分かった。
学びメモ
- 派生物ばかり並べた図は、それらしく見える。原本がどこにあるかを図の中で1度も指していないなら、その図は移行計画として機能していない
- APIの応答は「成功した証拠」にならない。存在しないダミーURLにも
contentUriは付く。PDFに書き出して目で見るところまでやらないと判定できない - 拡張子なしのファイル名でも pandoc は画像を認識する。落ちるのは
--resource-pathの指定漏れのほう - 履歴からの機密削除は、1件ずつ潰すより作り直したほうが確実。GitHub公式が「filter-repo でも残り得る」と書いている
残っている作業
- 抽出済みの機密4件を1Passwordへ移してから、Notion側を自分の手で削除する
- 除外リストを反映した状態で、新しいプライベートリポジトリを作り直す
- raw の243MB を移行先(R2)に置く経路を構成図に足す
- 9,375ページ全体を、今回確定した pandoc → Googleドキュメント経路で流す