YouTube動画26本をDeepgramで文字起こし→Tursoに投入したパイプライン構築ログ
蔵書ナレッジベース(book-knowledge-base)に音声講座を取り込むため、簿記3級向けプレイリスト全26動画を一気通貫で処理するパイプラインを構築した。yt-dlp でmp3を落とし、Deepgram Nova-3で文字起こしし、サブエージェント並列でMarkdown整形してTurso DBに投入するまで、合計326,796字・16時間28分の音声を扱った。途中でDeepgramの仕様に裏切られ、サブエージェントの並列度を上げ、Codexから6件の致命的指摘を喰らい、Web UIで動画01のイントロしか表示されない不具合を踏んだ。
パイプライン全体像
yt-dlp (mp3) → Deepgram Nova-3 (JSON) → extract_timestamps.py (txt生成)
→ サブエージェント整形 (Markdown) → youtube_import_v2.py → Turso DB
動画01でパイロット検証
最初に30分の動画01を1本だけ通して、各工程の出力を目視確認した。文字数は12,711字、最終的に23チャンクに分割してDBへ投入した。ここで2つの想定外が出た。
Deepgramの paragraph 構造が空っぽだった
当初は Deepgram の paragraphs フィールドをそのままMarkdownの段落単位に使う前提だった。ところが日本語Nova-3を叩くと、paragraph が1つ・sentence が1つしか返ってこない。30分の音声が「1段落・1文」で潰れた状態で返ってきた。
仕方なく words 配列ベースで擬似paragraphを再構成した。60秒バケットで切り、バケット内の word を結合して段落とする方式に書き換えた。
# 60秒バケットで擬似paragraphを生成
def bucket_words_to_paragraphs(words, bucket_sec=60):
buckets = {}
for w in words:
key = int(w["start"] // bucket_sec)
buckets.setdefault(key, []).append(w["punctuated_word"])
return [" ".join(ws) for _, ws in sorted(buckets.items())]
extract_timestamps.py には自然な日本語版の transcript.txt を併せて吐かせるように追加した。タイムスタンプ付きJSONとは別に、人間が読める素のテキストを残しておくと整形時のレビューが楽になる。
サブエージェントによるMarkdown整形
整形は general-purpose サブエージェントに任せた。## でセクション、### でサブセクションの階層を作る方針。ここで「1,000字以上 かつ 3段落以上」のセクションは ### で再分割するルールを導入した。動画01では「インプット2 vs アウトプット8」セクションが2,400字・8段落で肥大化したため、3つに再分割した。
このとき、メインClaudeのレビュー責務を計画書に明記した。サブエージェントは粒度判断にブレが出るため、メインが最後に必ず章立ての妥当性を読み直す運用にした。
Web UIを4カラムレイアウト化
DB投入後、Web UI側を「蔵書 / ページ / コンテンツ / 目次」の4カラムに作り変えた。目次のスクロール追従は IntersectionObserver で実装し、現在表示中のセクションをハイライトする。
ここで地雷を踏んだ。API側 [page].get.ts が rs.rows[0] で最初の1チャンクしか返していなかったため、動画01を開くとイントロだけが表示される状態になっていた。page_number=N の全チャンクを ## h2 ### h3 階層付きMarkdownで連結して返すよう書き直し、pages一覧側は GROUP BY page_number で1動画1ページに集約した。
動画02〜26を並列で流す
当初は動画02〜04を直列で粒度確認し、05以降を自動運転する計画だった。ユーザーから「サブエージェント並列で3本ずつ流せばいいのでは」と提案を受け、3並列バッチに切り替えた。サブエージェントの整形は1本あたり3〜10分かかるため、ここがボトルネック。3並列にした瞬間、総処理時間が1/3に縮んだ。
動画13〜26は6並列バッチで一気に流した。22/23/26 の再分割と 19/20/21 の整形を同時起動し、I/O待ちの隙間を埋めた。
最終的に26本全てを処理し終えた時点で、合計326,796字・16時間28分47秒を取り込んだ。
YouTubeタイトルが冗長で全件リネーム
YouTubeの元タイトルが「簿記3級②現金/小口現金/現金過不足/通貨代用証券!」のような詰め込み形式で、SEO的にも検索体験的にも厳しい。26本一括でリネームし、DBへ再投入した。リネーム後の差し替えは「staging book_id でスワップ → 本番 book_id を UPDATE して FTS を自動追従させる」方式を採った。既存チャンクを直接DELETE/INSERTする方式は、FTSとの整合が崩れる瞬間ができるため避けた。
Codex GPT-5.5レビューを4回回した
実装計画は /codex で4回レビューを回した。最初の計画で「並列実行」を含めて出したところ、ユーザーから「いきなりやって失敗したら際どい」と指摘が入り、まず直列で挙動確認 → 並列化、という順序に組み直した。
Codexからは合計6件の致命的指摘が返ってきた。主なものを挙げると、
- 既存チャンクの差し替えで FTS が古いまま残るリスク → staging book_id スワップ方式に変更
- セクション内2,000字超の再分割ロジックが抜けている → 「1セクション1チャンク + セクション内2,000字ごと再分割」の階層構造に修正
- dry-run モードがなく本番DBに直接書く設計 →
youtube_import_v2.pyを新規作成し、--dry-run/--prodを切り分け
「瑣末な点へのクソリプはしないで。致命的な点だけ指摘して」のプロンプトを毎回入れたおかげで、本質的な指摘だけが返ってきた。
朝のメイン作業:著者情報リトライ取得
並行して、未取得の著者情報をリトライ取得するスクリプトを朝のうちに回した。CAPTCHA解除後の状態で叩き、93件中89件を取得成功。残り4件はCAPTCHA出現分とノイズ著者で、後回しにした。
試行錯誤で学んだこと
- Deepgramの仕様は言語ごとに違う: 英語Nova-3で動く前提を日本語に持ち込むと壊れる。paragraphs が返らないのを目で確認するまで、JSONをずっと信じていた
- サブエージェント並列はI/O待ちを潰す: 整形のような3〜10分タスクは、3〜6並列にすると体感が変わる。ただし粒度判断のブレを吸収するメインのレビュー工程は省略しない
- マークダウン整形の粒度は事前に数値で決める: 200字・500字・1,000字・2,400字でどう分割するかを、毎回サブエージェントに口頭で指示するとブレる。「1,000字 かつ 3段落」のような明確な数値ルールを計画書に書いておく
- Codexレビューは「瑣末な指摘するな」を毎回入れる: 入れないと細かい命名規則の指摘で埋まる
- API側の
rs.rows[0]は要注意: 1ページ1チャンクの想定が崩れた瞬間、UIで「最初の数行しか表示されない」不具合になる。GROUP BYで集約する設計を最初から入れる
最終成果
- 動画26本 / 326,796字 / 16時間28分47秒の文字起こし完了
- Turso DBに章立てMarkdownチャンクとして投入完了
- Web UIの4カラムレイアウト + 目次スクロール追従ハイライトが稼働
youtube_import_v2.pyで dry-run / prod の切り替えができる差し替え基盤が整った
明日以降は別プレイリスト(簿記2級向け)への横展開と、検索クエリから該当チャンクへ直接ジャンプするUI改修に着手する。