[{"data":1,"prerenderedAt":541},["ShallowReactive",2],{"content-/notion-export-to-turso-db":3,"all-pages-for-dir":539,"og-image-/notion-export-to-turso-db":540},{"id":4,"title":5,"body":6,"category":522,"description":523,"extension":524,"meta":525,"navigation":181,"ogImage":526,"path":527,"project_name":528,"published":529,"publishedAt":530,"seo":531,"stem":532,"tags":533,"todo":526,"unpublished":529,"updatedAt":526,"__hash__":538},"pages/2026-05/2026-05-12/notion-export-to-turso-db.md","Notionの裁断書籍データをTurso DBに取り込むパイプライン構築",{"type":7,"value":8,"toc":512},"minimark",[9,13,17,20,32,35,39,50,60,64,71,79,86,89,96,100,103,344,347,350,357,368,399,409,412,415,426,429,473,476,508],[10,11,12],"p",{},"裁断してOCRした実務書を Notion に貯めてきたが、検索のたびに Notion を開くのが面倒で、結局あまり開かなくなっていた。今日は Notion からエクスポートした HTML 群を Turso DB に取り込み、既存の書籍ビューア（yomitoku 経由で取り込んだ書籍と同じ画面）から横断検索できるところまで一気通貫させた。",[14,15,16],"h2",{"id":16},"きっかけ",[10,18,19],{},"所得税の仕組みを章ごとにメモしてきた Notion ページがある。裁断した実務書を読みながら、自分の言葉で章立てを書き直したノートだ。これが Notion の中に閉じていて、Claude Code 越しに引けない。",[10,21,22,23,27,28,31],{},"Notion からエクスポートしたら HTML がページ数だけ吐き出された。フォルダを開くと ",[24,25,26],"code",{},"ExportBlock"," という ZIP 構造で、ルートにCSV、配下に各章のHTMLと画像フォルダ（",[24,29,30],{},"figures/Untitled-*.png","）が並んでいる。",[10,33,34],{},"既存の書籍ナレッジベースは yomitoku で PDF → Markdown → Turso の経路だけを想定していた。Notion 経由を新しい入口として足せばいいのか、別建てにすべきか、まず構造を読むところから始めた。",[14,36,38],{"id":37},"方針決定-r2-は使わず既存パターンに合わせる","方針決定: R2 は使わず既存パターンに合わせる",[10,40,41,42,45,46,49],{},"最初は画像を Cloudflare R2 に置く案を考えたが、調べたら既存書籍は ",[24,43,44],{},"/api/figures/\u003Cbook_id>/figures/\u003Cfilename>"," のエンドポイントで配信していた。Turso の ",[24,47,48],{},"book_figures"," テーブルに BLOB として入れて、API で配信している。",[10,51,52,53,55,56,59],{},"R2 を追加すると、認証・URL署名・キャッシュ設定が増える。R2 は捨てて、Notion からの画像も同じ ",[24,54,48],{}," に流し込む方針にした。yomitoku 由来の書籍と Notion 由来の書籍が同じテーブルに混ざるが、",[24,57,58],{},"source_type"," カラムで識別できれば困らない。",[14,61,63],{"id":62},"codex-レビューを3周回した","Codex レビューを3周回した",[10,65,66,67,70],{},"計画書を ",[24,68,69],{},"memo/2026-05-12/notion-import-plan.md"," に書いて、Codex CLI（gpt-5.5）に投げた。",[10,72,73,74,78],{},"1周目: 「ファイル名から章番号を推定」する案にツッコミが入った。「ルートCSVにステータス・章立・概要が全件揃っているなら、ファイル名から推定する理由は何か」と返ってきた。確かに CSV を grep したら章立てが全部入っていた。",[75,76,77],"strong",{},"ファイル名推定の案を捨て、CSV のプロパティを正とする方針に書き直した","。",[10,80,81,82,85],{},"2周目: importer のテストカバレッジが薄いと指摘された。",[24,83,84],{},"replace_book_with_chunks"," の挙動（既存書籍を消してから入れ直す）が境界ケースで怪しい、と。テストを 26 件から 30 件に増やした。",[10,87,88],{},"3周目: 致命的な指摘ゼロ。GO サインが出た。",[10,90,91,92,95],{},"毎回 ",[24,93,94],{},"resume --last"," を付けないと文脈が飛ぶのを忘れて、2周目で一回 Codex に「これは何のレビュー？」と聞き返された。学習。",[14,97,99],{"id":98},"実装の核-純粋関数と副作用シェルの分離","実装の核: 純粋関数と副作用シェルの分離",[10,101,102],{},"importer は HTML を読んで → 整形して → DB に書き込む。整形ロジックは純粋関数に切り出して、DB 書き込みは薄いシェルに集約した。",[104,105,110],"pre",{"className":106,"code":107,"language":108,"meta":109,"style":109},"language-python shiki shiki-themes vitesse-light vitesse-light","# 純粋関数: HTML → 整形済みチャンク\ndef transform_notion_html(html: str, book_id: str) -> Chunk:\n    ...\n\n# 副作用シェル: トランザクションで一括差し替え\ndef replace_book_with_chunks(book_id: str, chunks: list[Chunk]) -> None:\n    with db.transaction():\n        db.execute(\"DELETE FROM book_chunks WHERE book_id = ?\", [book_id])\n        db.execute(\"DELETE FROM book_figures WHERE book_id = ?\", [book_id])\n        for chunk in chunks:\n            db.execute(...)\n","python","",[24,111,112,121,169,176,183,189,233,251,284,310,326],{"__ignoreMap":109},[113,114,117],"span",{"class":115,"line":116},"line",1,[113,118,120],{"class":119},"sxvE3","# 純粋関数: HTML → 整形済みチャンク\n",[113,122,124,128,132,136,140,143,147,150,153,155,157,160,163,166],{"class":115,"line":123},2,[113,125,127],{"class":126},"stQ0i","def",[113,129,131],{"class":130},"senZ8"," transform_notion_html",[113,133,135],{"class":134},"shFtX","(",[113,137,139],{"class":138},"sG7-3","html",[113,141,142],{"class":134},":",[113,144,146],{"class":145},"sz8Xr"," str",[113,148,149],{"class":134},",",[113,151,152],{"class":138}," book_id",[113,154,142],{"class":134},[113,156,146],{"class":145},[113,158,159],{"class":134},")",[113,161,162],{"class":134}," ->",[113,164,165],{"class":138}," Chunk",[113,167,168],{"class":134},":\n",[113,170,172],{"class":115,"line":171},3,[113,173,175],{"class":174},"snbK4","    ...\n",[113,177,179],{"class":115,"line":178},4,[113,180,182],{"emptyLinePlaceholder":181},true,"\n",[113,184,186],{"class":115,"line":185},5,[113,187,188],{"class":119},"# 副作用シェル: トランザクションで一括差し替え\n",[113,190,192,194,197,199,202,204,206,208,211,213,216,219,222,225,227,231],{"class":115,"line":191},6,[113,193,127],{"class":126},[113,195,196],{"class":130}," replace_book_with_chunks",[113,198,135],{"class":134},[113,200,201],{"class":138},"book_id",[113,203,142],{"class":134},[113,205,146],{"class":145},[113,207,149],{"class":134},[113,209,210],{"class":138}," chunks",[113,212,142],{"class":134},[113,214,215],{"class":138}," list",[113,217,218],{"class":134},"[",[113,220,221],{"class":138},"Chunk",[113,223,224],{"class":134},"])",[113,226,162],{"class":134},[113,228,230],{"class":229},"sHkkW"," None",[113,232,168],{"class":134},[113,234,236,239,242,245,248],{"class":115,"line":235},7,[113,237,238],{"class":229},"    with",[113,240,241],{"class":138}," db",[113,243,244],{"class":134},".",[113,246,247],{"class":138},"transaction",[113,249,250],{"class":134},"():\n",[113,252,254,257,259,262,264,268,272,274,276,279,281],{"class":115,"line":253},8,[113,255,256],{"class":138},"        db",[113,258,244],{"class":134},[113,260,261],{"class":138},"execute",[113,263,135],{"class":134},[113,265,267],{"class":266},"sMJiu","\"",[113,269,271],{"class":270},"sdGka","DELETE FROM book_chunks WHERE book_id = ?",[113,273,267],{"class":266},[113,275,149],{"class":134},[113,277,278],{"class":134}," [",[113,280,201],{"class":138},[113,282,283],{"class":134},"])\n",[113,285,287,289,291,293,295,297,300,302,304,306,308],{"class":115,"line":286},9,[113,288,256],{"class":138},[113,290,244],{"class":134},[113,292,261],{"class":138},[113,294,135],{"class":134},[113,296,267],{"class":266},[113,298,299],{"class":270},"DELETE FROM book_figures WHERE book_id = ?",[113,301,267],{"class":266},[113,303,149],{"class":134},[113,305,278],{"class":134},[113,307,201],{"class":138},[113,309,283],{"class":134},[113,311,313,316,319,322,324],{"class":115,"line":312},10,[113,314,315],{"class":229},"        for",[113,317,318],{"class":138}," chunk ",[113,320,321],{"class":229},"in",[113,323,210],{"class":138},[113,325,168],{"class":134},[113,327,329,332,334,336,338,341],{"class":115,"line":328},11,[113,330,331],{"class":138},"            db",[113,333,244],{"class":134},[113,335,261],{"class":138},[113,337,135],{"class":134},[113,339,340],{"class":174},"...",[113,342,343],{"class":134},")\n",[10,345,346],{},"純粋関数側にテストを集中させたら、画像URL書き換え・タイトル抽出・TOC除去のロジックを画面なしで詰められた。",[14,348,349],{"id":349},"画面で違和感を拾う係",[10,351,352,353,356],{},"書き込みまで通ったので、Nuxt の別ポート（3100）で起動して ",[24,354,355],{},"agent-browser"," で開いた。",[10,358,359,360,363,364,367],{},"最初の画面: 画像が全部リンクになっていて、クリックすると Notion の元URL に飛ぶ。HTML に ",[24,361,362],{},"\u003Ca href=\"...\">"," の画像ラッパーが残っていた。",[75,365,366],{},"ラッパーを除去","して、画像クリックでモーダル拡大表示に乗るようにした（既存書籍と同じ挙動）。",[10,369,370,371,374,375,378,379,381,382,384,385,387,388,391,392,394,395,398],{},"次の違和感: ページタイトルが本文の冒頭に重複して出ていた。Notion の HTML は ",[24,372,373],{},"\u003Ctitle>"," タグと、本文先頭の ",[24,376,377],{},"\u003Ch1>"," で同じ文字列が2回出る構造だった。当初は本文先頭の ",[24,380,377],{}," を削る雑なパッチを当てかけたが、それだと別のページで ",[24,383,377],{}," が落ちる。",[24,386,373],{}," を text 化して別カラム（",[24,389,390],{},"chunk_title","）に格納し、本文HTMLからは ",[24,393,373],{}," も TOC も丸ごと除去する形に",[75,396,397],{},"根本対応","した。",[10,400,401,402,404,405,408],{},"右側の目次に h1（ページタイトル）が含まれていなかったのも、",[24,403,390],{}," を目次の先頭に差し込む形で直した。",[24,406,407],{},"p.{pageNum}"," の横にセクション名を出すと、目次から飛んだときに「今どこにいるか」が分かるようになった。",[10,410,411],{},"agent-browser でスクリーンショットを撮って、画像クリック→モーダル拡大まで動くのを確認できた。",[14,413,414],{"id":414},"最後にスラッシュコマンド化",[10,416,417,418,421,422,425],{},"このパイプライン、また別の Notion エクスポートで使う。",[24,419,420],{},".claude/commands/notion-import.md"," を作って、CLAUDE.md に追記した。次回は「",[24,423,424],{},"/notion-import \u003Cexport-dir>","」だけで走る。",[14,427,428],{"id":428},"学びメモ",[430,431,432,439,452,467],"ul",{},[433,434,435,438],"li",{},[75,436,437],{},"計画段階のファイル名推定は罠",": メタデータが既にどこかに揃っていないか先に grep する。今回はCSVに全部入っていた",[433,440,441,444,445,448,449,451],{},[75,442,443],{},"Codex レビューは3周回しても1日で終わる",": gpt-5.5 で ",[24,446,447],{},"-m"," 指定、",[24,450,94],{}," で文脈継続、瑣末な指摘は無視のルールを徹底すると速い",[433,453,454,457,458,460,461,463,464,466],{},[75,455,456],{},"タイトル重複は雑なパッチに走らない",": ",[24,459,377],{}," を消す案に飛びかけたが、",[24,462,373],{}," 側を text 化する根本対応にして良かった。別ページで ",[24,465,377],{}," が落ちる事故を未然に防げた",[433,468,469,472],{},[75,470,471],{},"画面で違和感を拾う係は人間",": 画像のリンク化・タイトル重複は、テストでは検知できなかった。3100 ポートで開いて目で見る工程を省くと事故る",[14,474,475],{"id":475},"明日以降",[430,477,480,493,499],{"className":478},[479],"contains-task-list",[433,481,484,488,489,492],{"className":482},[483],"task-list-item",[485,486],"input",{"disabled":181,"type":487},"checkbox"," 別の Notion ノート（会計の章別メモ）を ",[24,490,491],{},"/notion-import"," で取り込む",[433,494,496,498],{"className":495},[483],[485,497],{"disabled":181,"type":487}," yomitoku 由来の書籍と Notion 由来の書籍を、書籍一覧画面でバッジ分けして区別する",[433,500,502,504,505,507],{"className":501},[483],[485,503],{"disabled":181,"type":487}," 全文検索で ",[24,506,58],{}," フィルタを足す",[509,510,511],"style",{},"html pre.shiki code .sxvE3, html code.shiki .sxvE3{--shiki-default:#A0ADA0;--shiki-dark:#A0ADA0}html pre.shiki code .stQ0i, html code.shiki .stQ0i{--shiki-default:#AB5959;--shiki-dark:#AB5959}html pre.shiki code .senZ8, html code.shiki .senZ8{--shiki-default:#59873A;--shiki-dark:#59873A}html pre.shiki code .shFtX, html code.shiki .shFtX{--shiki-default:#999999;--shiki-dark:#999999}html pre.shiki code .sG7-3, html code.shiki .sG7-3{--shiki-default:#393A34;--shiki-dark:#393A34}html pre.shiki code .sz8Xr, html code.shiki .sz8Xr{--shiki-default:#998418;--shiki-dark:#998418}html pre.shiki code .snbK4, html code.shiki .snbK4{--shiki-default:#A65E2B;--shiki-dark:#A65E2B}html pre.shiki code .sHkkW, html code.shiki .sHkkW{--shiki-default:#1E754F;--shiki-dark:#1E754F}html pre.shiki code .sMJiu, html code.shiki .sMJiu{--shiki-default:#B5695977;--shiki-dark:#B5695977}html pre.shiki code .sdGka, html code.shiki .sdGka{--shiki-default:#B56959;--shiki-dark:#B56959}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":109,"searchDepth":123,"depth":123,"links":513},[514,515,516,517,518,519,520,521],{"id":16,"depth":123,"text":16},{"id":37,"depth":123,"text":38},{"id":62,"depth":123,"text":63},{"id":98,"depth":123,"text":99},{"id":349,"depth":123,"text":349},{"id":414,"depth":123,"text":414},{"id":428,"depth":123,"text":428},{"id":475,"depth":123,"text":475},"dev","Notionからエクスポートした所得税解説書のHTML群をTurso DBに取り込み、既存のyomitokuパイプラインに合流させた一日。Codexレビューを3周回し、画像URL書き換えとタイトル重複の根本解決まで進めた記録。","md",{},null,"/notion-export-to-turso-db","book-knowledge-base",false,"2026-05-12T00:00:00.000Z",{"title":5,"description":523},"2026-05/2026-05-12/notion-export-to-turso-db",[534,535,528,536,537],"notion","turso","codex-review","nuxt","VLo4qws5fAGYkxODmCYBOOtBibGArm76fiTYuv_QiDE",[],"https://log.eurekapu.com/og/blog/notion-export-to-turso-db.png?v=2026-05-12T00%3A00%3A00.000Z&title=Notion%E3%81%AE%E8%A3%81%E6%96%AD%E6%9B%B8%E7%B1%8D%E3%83%87%E3%83%BC%E3%82%BF%E3%82%92Turso%20DB%E3%81%AB%E5%8F%96%E3%82%8A%E8%BE%BC%E3%82%80%E3%83%91%E3%82%A4%E3%83%97%E3%83%A9%E3%82%A4%E3%83%B3%E6%A7%8B%E7%AF%89&author=Kei%20Komatsu&sig=1f064e83de80753b",1782528836054]