開発eurekapu-nuxt4

SVGスライドとKindle原稿の対照表を作った話

日商簿記3級の解説書籍をKindle化した原稿(artboardごとに分割した縦長テキスト)と、同じ内容をスライド化したSVG計1286枚の 対照表 を作りたい。slide_chapter01_2_0_01_13.svg がどの artboard に対応するのか、機械的に判定したい、というタスク。

朝、積み残しの確認から入った。前日に途中まで書いた自動マッピングのスクリプトを動かしてみると、目視確認のために最初の数件を開いた段階で、 slide_chapter01_2_0_01_13.svg と artboard のマッピングが1個ずれていた。

「ここから先、何百枚も同じズレが続くのか」と画面の前で息を吐いた。

自動マッピングを章ごとに分岐させる

まず自動マッピングのロジックを精緻化する方向で粘った。

  • 順序対応: artboard と slide を出現順に対応づける素朴な方式
  • 5n形式 vs 3n形式 LEGACY: 章によって slide のページ番号採番ルールが違う(5刻みで採番する章と、3刻みで採番する章がある)
  • h3扉一般則: h3見出しの「扉ページ」が章頭に挿入される章とされない章がある

これらを chapter ごとに戦略分岐させて流したら、 chapter02 はピタッと当たるが chapter01 がまだズレる。原因を掘っていくと、 chapter01 だけ _overrides.json に書いた章扉用の override(特定の slide を強制的に _00 artboard に紐づける指示)が、本体のマッピング処理の に適用されていた。

順序対応のロジックが先に _01 から枠を埋めてしまい、最後に override が _00 を上書きするので、本来 _00 に当たるべきページが _01 を取ってしまい、 _01〜_05 が全部1個ずつズレる。

修正は単純で、override の適用順を step 3 より前に移動するだけだった。_00 を予約済みとして先に確保すれば、その後の順序対応は _01〜_05 を素直に当てていく。

# Before: step 3 で順序対応した後で override
mapping = sequential_match(artboards, slides)
mapping = apply_overrides(mapping, overrides)  # 遅すぎる

# After: override を先に確定させてから残りを順序対応
reserved = apply_overrides({}, overrides)
mapping = sequential_match(artboards, slides, reserved=reserved)

これで chapter01 の最初の5枚は揃った。

監査ページを先に作る

ただ、自動マッピングを信じきれない。1286枚を目で全部見るのは無理なので、マッピング結果を一覧して怪しいところだけ拾える監査ページ を先に作った。

apps/web/app/pages/_audit.vue を作って、

  • artboard起点 / slide起点のタブ切替
  • confidence(マッチ確度)でフィルタ+ソート
  • サマリーカードに confidence の件数カウント

を入れた。confidence 0.5 以下だけ抽出して画面をスクロールすると、ロジックがどこで嘘をついているかが見える。これで chapter ごとの戦略分岐の効き具合をリアルタイムに確認できるようになった。

監査ページを眺めながら、「ロジックを足し続けても confidence の低い領域が消えない」のが見えてきた。chapter05 以降は slide の構造が h3 単位で分岐しまくっていて、ルールベースでは追い切れない。

方針転換:画像対照を Claude にやらせる

ここで方針を切り替えた。「自動マッピングはここまで。残りは画像対照を Claude でやる」

順序対応もチャプター分岐も、結局は「ファイル名・章番号・見出しテキスト」だけ見て判定している。判定材料に slide の画像そのもの が入っていないのが弱い。だったら Claude の画像認識に流し込めばいい。

手順はこう。

  1. Playwright で全SVG(1286枚)をPNGに変換する
  2. PNG を Read ツールで読ませて、Claude が artboard 側のテキストと突き合わせる
  3. 対照エントリを JSON に書き出す

Playwright のスクリプトを走らせて全PNGを吐かせた。これは1回流せば終わる作業だが、 Claude に画像対照させる工程は1286枚 × 各章の artboard 候補との突合せなので、まともに直列で流すと終わらない。

13セッション分割→4セッション並列に統合

最初は chapter 単位で13セッションに分割するつもりだった。が、各 chapter のページ数が均等ではなく、 chapter05 が250枚を超える一方で chapter11 が30枚しかなかったりする。13並列で起動しても、 chapter05 の終了を全員が待つ構図になる。

4セッションに統合して、各セッションがおおむね300枚ずつ受け持つ ように再分割した。Session 1 を「マージ役」として最後まで待機させ、各セッションは session-{N}-chapter{XX}.json に対照エントリを出力する形式に揃えた。

4セッションを並列起動して走らせ、完了したものから Session 1 がマージスクリプトを叩く。最終的に 1286/1286 を全件カバーし、重複なし、artboard 側で対応 slide が見つからなかった25件は no-match として別ファイルにまとめた。

13並列→4並列に減らした判断は、 「並列度を上げるほど速い」は嘘で、ボトルネックの一番遅い枝にロックされる という当たり前のことを再確認しただけだった。1セッションあたりの所要時間を平準化することの方が、並列度を増やすより効く。

スライドビューアの「戻る」バグ

対照表が完成した後、スライドビューアで動作確認していたら、章を跨いで「戻る」ボタンを押したときに、前章の 最後のページではなく1ページ目 に飛ぶバグを見つけた。

最初、コードを読んで「ルートパラメータの page が初期化されているからだろう」と当たりをつけて、初期化処理に分岐を足した。 Claude Code が修正案を出してきて、コードを読む限り筋は通っている。「直りました」と返してきた。

念のため Chrome DevTools MCP で開いて押してみたら、まだ1ページ目に飛んだ。

修正対象がそもそも違っていた。原因はルート遷移時に コンポーネントが再生成される ことだった。 page パラメータが正しく渡っていても、コンポーネントが新規マウントされて初期値で上書きしてしまう。Vue Router の router.push の query に jumpTo=last を渡し、コンポーネントの onMounted でこれを読んで最終ページに飛ばす方式に切り替えてようやく直った。

// Before: route.params.page を信頼して初期化
const currentPage = ref(Number(route.params.page) || 1)

// After: jumpTo=last なら最終ページへ
const initialPage = route.query.jumpTo === 'last'
  ? totalPages.value
  : (Number(route.params.page) || 1)
const currentPage = ref(initialPage)

自分に課したルール:DevTools を開くまで「直った」と言わない

このバグで身に染みたのは、Claude Code に「修正完了です」と言わせたまま自分も納得してしまう ことだった。コードを読んで筋が通っていれば、直ったと思い込む。

Chrome DevTools MCP でブラウザ上で手を動かして確認するまで、「修正完了」と報告しないルールを Claude Code 側に追加した。コードの読み筋と実機の挙動が一致しないケースがこれで2回目だったので、ここは仕組みで縛る。

振り返り

今日の収穫は3つ。

  1. ルールベース自動マッピングは「ロジックを足すほど精度が上がる」と思いがちだが、ある時点で頭打ちになる。突破するなら判定材料そのものを変える(テキスト→画像)方が早い
  2. 並列度は枝の数ではなく、最遅枝で決まる。13並列より4並列の方が、均等にすれば早い
  3. コードで筋が通っていても実機で確認しないと完了ではない。DevTools を開く一手間を仕組みで強制する

明日以降の積み残し。

  • no-match 25件を目視確認して、override で当てるか諦めるか決める
  • スライドビューアの章跨ぎ前進ボタンも同じパターンで動くか DevTools で確認する
  • _audit.vue を本番ビルドから除外する設定を入れる(開発用ページのまま公開されないように)