👻

YouTube字幕805本をClaude APIでMap-Reduce要約して検索可能なHTMLデータベースを作った

に公開

はじめに

  • YouTube字幕TXT(10MB・805ファイル)をClaude APIで1本ずつ構造化要約
  • 要約結果をJSON集約 → 静的HTMLとして出力
  • 全処理コスト約$1〜2、処理時間約2〜3時間
  • WindowsのPowerShell環境で詰まったポイントも記録

背景・モチベーション

好きなYouTuberの動画が800本以上あり、全部見るのは現実的でない。字幕データが手元にあったので、Claude APIで一括要約して検索可能なデータベースにすることにした。

なぜAPIか

Claude.aiのProプランでも試みたが2つの制約がある。

  1. コンテキストウィンドウ上限:200K tokens ≒ 日本語で約1MB。10MBは入らない
  2. メッセージレート制限:800回分の対話は現実的でない

Claude ProプランとAnthropicのAPI利用は独立した課金体系である点に注意。Proに課金済みでもAPI利用には別途クレジット購入が必要。


アーキテクチャ

output/
  *.txt (805ファイル)         # 字幕データ(1動画1ファイル)

step1_summarize.py            # Claude APIで1本ずつ要約 → JSON

summaries.json (~600KB)       # 構造化要約の集積

step2_generate_html.py        # 静的HTML生成

sora_database.html            # 完成品(単一ファイル)

Map-Reduce的な考え方:各ファイルをAPIに投げて部分要約(Map)→ 全要約を集約してHTML生成(Reduce)。


字幕ファイルのフォーマット

各TXTはこの形式:

タイトル: 動画タイトル
URL: https://www.youtube.com/watch?v=XXXXX
投稿日:
字幕種別: ja-auto
==================================================

Kind: captions Language: ja 字幕テキスト本文...

句読点なし・スペース区切りの自動生成字幕。[音楽] などのノイズタグも含まれる。


Step 1:字幕パースとAPI要約

フォルダ一括読み込み

from pathlib import Path
import re

def parse_file(filepath: str) -> dict:
    with open(filepath, "r", encoding="utf-8") as f:
        content = f.read()

    title_match = re.search(r'タイトル: (.+)', content)
    url_match   = re.search(r'URL: (https?://\S+)', content)

    sep_idx = content.find('=' * 10)
    transcript = content[sep_idx:].lstrip('=').strip() if sep_idx != -1 else content

    # ノイズ除去
    transcript = re.sub(r'^Kind: captions Language: \w+ ', '', transcript)
    transcript = re.sub(r'\[.+?\]', '', transcript)
    transcript = re.sub(r'\s+', ' ', transcript).strip()

    return {
        "title":      title_match.group(1).strip() if title_match else Path(filepath).stem,
        "url":        url_match.group(1).strip() if url_match else "",
        "transcript": transcript,
    }

def parse_folder(folder_path: str) -> list[dict]:
    return [parse_file(str(f)) for f in sorted(Path(folder_path).glob("*.txt"))]

システムプロンプト設計

JSON以外を出力させないよう明示的に指示する。カテゴリは事前定義リストから選ばせることで後段の集計処理を安定させる。

SYSTEM_PROMPT = """あなたはYouTube動画字幕の分析者です。
与えられた字幕テキストを読んで、以下のJSON形式で構造化してください。
JSON以外は一切出力しないでください。マークダウンのコードブロックも不要です。

{
  "title": "動画タイトル",
  "url": "YouTube URL",
  "main_theme": "この動画の主テーマを15字以内で",
  "categories": ["該当するカテゴリ(複数可)"],
  "key_points": ["主要な主張・ポイントを箇条書きで(3〜7個)"],
  "core_message": "この動画で話者が最も伝えたいことを2〜3文で",
  "keywords": ["重要キーワード(5〜10個)"],
  "target_audience": "この動画が特に刺さる対象者",
  "memorable_phrase": "動画中の印象的なフレーズや比喩(あれば)"
}

カテゴリは以下から選んでください(複数可):
営業・セールス, ビジネス思考, お金・稼ぎ方, 人間観察・人物分析,
マインドセット, 起業・独立, 組織・マネジメント, 人間関係・コミュニケーション,
自己成長, 習慣・行動, 恋愛・結婚, 投資・資産, キャリア, 雑談・エピソード"""

API呼び出しとJSONパース

ハマりポイントJSON以外出力するなと指示してもモデルが ```json ``` で囲んで返すケースがある。パース前にコードブロックを除去する処理が必要。

import anthropic, json, re, time

MODEL = "claude-haiku-4-5-20251001"

def summarize_video(client: anthropic.Anthropic, video: dict) -> dict:
    prompt = f"タイトル: {video['title']}\nURL: {video['url']}\n\n字幕:\n{video['transcript'][:8000]}"

    for attempt in range(3):
        try:
            msg = client.messages.create(
                model=MODEL,
                max_tokens=1500,
                system=SYSTEM_PROMPT,
                messages=[{"role": "user", "content": prompt}],
            )
            raw = msg.content[0].text.strip()

            # コードブロック除去
            raw = re.sub(r'^```json\s*', '', raw)
            raw = re.sub(r'\s*```$', '', raw)

            result = json.loads(raw)
            result["url"] = video["url"]
            return result

        except json.JSONDecodeError:
            if attempt < 2:
                time.sleep(2)
        except anthropic.RateLimitError:
            time.sleep(10)

    return {"title": video["title"], "url": video["url"], "_error": True, ...}

リジューム対応

805本を一気に処理するとレートリミットや予期せぬ中断が起きうる。10本ごとの中間保存と--startオプションでリジュームできるようにした。

# 中間保存
if (i + 1) % 10 == 0:
    with open(args.output, "w", encoding="utf-8") as f:
        json.dump(results, f, ensure_ascii=False, indent=2)

# リジューム実行例
# python step1_summarize.py --folder ./output --output summaries.json --start 300

Step 2:HTML生成

summaries.jsonを読み込み、カテゴリ別ナビ・カード一覧・検索・モーダルを備えた単一HTMLファイルを生成する。

カテゴリ正規化

モデルがたまにカテゴリを微妙に揺らがせる(例:「コミュニケーション」と「人間関係・コミュニケーション」)。集計前にマッピングで統一する。

CAT_MAP = {
    'コミュニケーション':      '人間関係・コミュニケーション',
    '人物分析':               '人間観察・人物分析',
    '資産形成':               '投資・資産',
    '転職・独立':             '起業・独立',
    'メンタル':               'マインドセット',
    # ...
}

for v in data:
    v['categories'] = list(dict.fromkeys(
        CAT_MAP.get(c, c) for c in v.get('categories', [])
        if CAT_MAP.get(c, c) in MAIN_CATS
    )) or ['雑談・エピソード']

URLなし動画の補完

688/805本にURLがあり、残り117本はYouTube検索リンクで補完。

import urllib.parse

if not v.get('url'):
    q = urllib.parse.quote(v.get('title', ''))
    v['url'] = f'https://www.youtube.com/results?search_query={q}'
    v['url_is_search'] = True

リアルタイム検索

全データをHTMLに埋め込み、JavaScriptでフィルタリングする構成。サーバー不要で単一ファイルで完結する。

function doSearch(q) {
  const lower = q.trim().toLowerCase();
  document.querySelectorAll('#all-grid .card').forEach(card => {
    card.classList.toggle('hidden', !card.dataset.search.includes(lower));
  });
}

data-search属性にタイトル・テーマ・キーワードをスペース区切りで埋め込んでおくことで全文検索的に動く。


結果

指標
処理本数 805本
エラー件数 0件
APIコスト 約$1〜2(Haiku使用)
処理時間 約2〜3時間
生成HTML 約6MB(全データ埋め込み)
URLあり 688本(85%)

カテゴリ分布:

カテゴリ 本数
マインドセット 686
ビジネス思考 489
自己成長 436
人間観察・人物分析 402
営業・セールス 313
人間関係・コミュニケーション 309

Windows / PowerShell 環境での注意点

本作業はWindows + PowerShellで実施した。Linuxと異なる点をまとめる。

# pip が認識されない場合
python -m pip install anthropic

# 環境変数設定(export は使えない)
$env:ANTHROPIC_API_KEY = "sk-ant-api03-..."
# ※ PowerShellを閉じるとリセットされるため毎回設定が必要

# パスにスペース・日本語が含まれる場合はクォートを忘れずに
python step1_summarize.py --folder "C:\Users\...\output" --output summaries.json

まとめ

  • LLMで大量テキストを処理する場合はMap-Reduceパターンが基本
  • プロンプトでJSON onlyと指示してもコードブロックが入ることがある。パース前の除去処理は必須
  • カテゴリのような列挙型の出力はプロンプトで候補リストを明示すると揺れを抑えられる
  • Claude Haikuは要約タスクに対してコスパが高い。805本・2〜3時間・$5〜10

関連リンク

Discussion