開発book-knowledge-base

裁断してOCRした実務書を Notion に貯めてきたが、検索のたびに Notion を開くのが面倒で、結局あまり開かなくなっていた。今日は Notion からエクスポートした HTML 群を Turso DB に取り込み、既存の書籍ビューア(yomitoku 経由で取り込んだ書籍と同じ画面)から横断検索できるところまで一気通貫させた。

きっかけ

所得税の仕組みを章ごとにメモしてきた Notion ページがある。裁断した実務書を読みながら、自分の言葉で章立てを書き直したノートだ。これが Notion の中に閉じていて、Claude Code 越しに引けない。

Notion からエクスポートしたら HTML がページ数だけ吐き出された。フォルダを開くと ExportBlock という ZIP 構造で、ルートにCSV、配下に各章のHTMLと画像フォルダ(figures/Untitled-*.png)が並んでいる。

既存の書籍ナレッジベースは yomitoku で PDF → Markdown → Turso の経路だけを想定していた。Notion 経由を新しい入口として足せばいいのか、別建てにすべきか、まず構造を読むところから始めた。

方針決定: R2 は使わず既存パターンに合わせる

最初は画像を Cloudflare R2 に置く案を考えたが、調べたら既存書籍は /api/figures/<book_id>/figures/<filename> のエンドポイントで配信していた。Turso の book_figures テーブルに BLOB として入れて、API で配信している。

R2 を追加すると、認証・URL署名・キャッシュ設定が増える。R2 は捨てて、Notion からの画像も同じ book_figures に流し込む方針にした。yomitoku 由来の書籍と Notion 由来の書籍が同じテーブルに混ざるが、source_type カラムで識別できれば困らない。

Codex レビューを3周回した

計画書を memo/2026-05-12/notion-import-plan.md に書いて、Codex CLI(gpt-5.5)に投げた。

1周目: 「ファイル名から章番号を推定」する案にツッコミが入った。「ルートCSVにステータス・章立・概要が全件揃っているなら、ファイル名から推定する理由は何か」と返ってきた。確かに CSV を grep したら章立てが全部入っていた。ファイル名推定の案を捨て、CSV のプロパティを正とする方針に書き直した

2周目: importer のテストカバレッジが薄いと指摘された。replace_book_with_chunks の挙動(既存書籍を消してから入れ直す)が境界ケースで怪しい、と。テストを 26 件から 30 件に増やした。

3周目: 致命的な指摘ゼロ。GO サインが出た。

毎回 resume --last を付けないと文脈が飛ぶのを忘れて、2周目で一回 Codex に「これは何のレビュー?」と聞き返された。学習。

実装の核: 純粋関数と副作用シェルの分離

importer は HTML を読んで → 整形して → DB に書き込む。整形ロジックは純粋関数に切り出して、DB 書き込みは薄いシェルに集約した。

# 純粋関数: HTML → 整形済みチャンク
def transform_notion_html(html: str, book_id: str) -> Chunk:
    ...

# 副作用シェル: トランザクションで一括差し替え
def replace_book_with_chunks(book_id: str, chunks: list[Chunk]) -> None:
    with db.transaction():
        db.execute("DELETE FROM book_chunks WHERE book_id = ?", [book_id])
        db.execute("DELETE FROM book_figures WHERE book_id = ?", [book_id])
        for chunk in chunks:
            db.execute(...)

純粋関数側にテストを集中させたら、画像URL書き換え・タイトル抽出・TOC除去のロジックを画面なしで詰められた。

画面で違和感を拾う係

書き込みまで通ったので、Nuxt の別ポート(3100)で起動して agent-browser で開いた。

最初の画面: 画像が全部リンクになっていて、クリックすると Notion の元URL に飛ぶ。HTML に <a href="..."> の画像ラッパーが残っていた。ラッパーを除去して、画像クリックでモーダル拡大表示に乗るようにした(既存書籍と同じ挙動)。

次の違和感: ページタイトルが本文の冒頭に重複して出ていた。Notion の HTML は <title> タグと、本文先頭の <h1> で同じ文字列が2回出る構造だった。当初は本文先頭の <h1> を削る雑なパッチを当てかけたが、それだと別のページで <h1> が落ちる。<title> を text 化して別カラム(chunk_title)に格納し、本文HTMLからは <title> も TOC も丸ごと除去する形に根本対応した。

右側の目次に h1(ページタイトル)が含まれていなかったのも、chunk_title を目次の先頭に差し込む形で直した。p.{pageNum} の横にセクション名を出すと、目次から飛んだときに「今どこにいるか」が分かるようになった。

agent-browser でスクリーンショットを撮って、画像クリック→モーダル拡大まで動くのを確認できた。

最後にスラッシュコマンド化

このパイプライン、また別の Notion エクスポートで使う。.claude/commands/notion-import.md を作って、CLAUDE.md に追記した。次回は「/notion-import <export-dir>」だけで走る。

学びメモ

  • 計画段階のファイル名推定は罠: メタデータが既にどこかに揃っていないか先に grep する。今回はCSVに全部入っていた
  • Codex レビューは3周回しても1日で終わる: gpt-5.5 で -m 指定、resume --last で文脈継続、瑣末な指摘は無視のルールを徹底すると速い
  • タイトル重複は雑なパッチに走らない: <h1> を消す案に飛びかけたが、<title> 側を text 化する根本対応にして良かった。別ページで <h1> が落ちる事故を未然に防げた
  • 画面で違和感を拾う係は人間: 画像のリンク化・タイトル重複は、テストでは検知できなかった。3100 ポートで開いて目で見る工程を省くと事故る

明日以降

  • 別の Notion ノート(会計の章別メモ)を /notion-import で取り込む
  • yomitoku 由来の書籍と Notion 由来の書籍を、書籍一覧画面でバッジ分けして区別する
  • 全文検索で source_type フィルタを足す