【Claude×Whisper】YouTube動画を文字起こし・要約するSkills「youtube-digest」の導入・操作マニュアル

目次

YouTubeチャンネルを丸ごと「AIの知識」に変える手順|Claude Code × Whisper で作る動画要約パイプライン

「参考にしているYouTubeチャンネルの内容を、ChatGPTやClaudeにぜんぶ覚えさせたい」——そう思ったことはないでしょうか。

この記事では、YouTubeのチャンネルURL(または動画URL)を渡すだけで、全動画を「文字起こし → 要約 → 1つのナレッジファイルに統合」するところまでを自動化する仕組みを、コピペで作れる形で紹介します。

  • YouTubeの字幕がなくても/字幕APIがブロックされていても動く(音声をダウンロードしてローカルのWhisperで文字起こしするため)
  • できあがる digest.md は、そのまま Claude Projects の Project KnowledgeObsidian に放り込めます
  • 実際にこの仕組みで、あるチャンネルの203本を丸ごとナレッジ化しました

対象読者は「ターミナルで python3 コマンドが打てる」レベル。所要時間はセットアップ15分+動画本数ぶんの処理待ちです。


1. これは何をするもの?

入力出力
YouTubeチャンネルのURL全動画(または新着N本)の要約を束ねた digest.md
再生リストのURL再生リスト内の全動画の要約
動画URL(複数OK)指定した動画だけの要約
URLを並べたテキストファイルその一覧の要約

処理はこの4ステップです。

  YouTubeのURL
      │  STEP 1  collect_links.py
      ▼
  videos.json          ← 動画ID・タイトル・URLの一覧
      │  STEP 2  transcribe.py(yt-dlpで音声DL → Whisperで文字起こし)
      ▼
  transcripts/{動画ID}.txt
      │  STEP 3  要約(Claude または API)
      ▼
  summaries/{動画ID}.md
      │  STEP 4  build_digest.py
      ▼
  digest.md            ← 全要約を1ファイルに統合=ナレッジベース

STEP 1・2・4 はスクリプト、STEP 3 は Claude(または Claude API)が担当します。


2. 完成イメージ

フォルダ構成(自己完結型)

作業フォルダを1つ作り、その中に .claude/skills/youtube-digest/ を丸ごと置くのがこの仕組みの肝です。スクリプトの出力はすべてこの作業フォルダに作られるので、フォルダごとコピーすれば別マシンでもそのまま動きます。

~/youtube-digest/mychannel/        ← 作業フォルダ(cd するのはここ)
├── .claude/skills/
│   └── youtube-digest/
│       ├── scripts/
│       │   ├── common.py
│       │   ├── collect_links.py
│       │   ├── transcribe.py
│       │   ├── build_digest.py
│       │   ├── summarize_api.py   (任意)
│       │   └── pipeline.sh
│       └── reference/
│           └── summary_format.md
├── videos.json          ← STEP1 の出力
├── transcripts/         ← STEP2 の出力
├── summaries/           ← STEP3 の出力
└── digest.md            ← STEP4 の出力(これがゴール)

3. 用意するもの

  • OS:macOS(Apple Silicon 推奨)。Windows / Linux でも faster-whisper を使えば動きます。
  • Python 3.10 以上

インストール

# 動画のダウンロードと音声抽出
pip install -U yt-dlp
brew install ffmpeg            # mac 以外は各OSの方法で

# 文字起こしエンジン(どちらか一方でOK)
pip install -U mlx-whisper     # Apple Silicon(速い・推奨)
pip install -U faster-whisper  # それ以外のPC

# (任意)要約をAPIで自動化したい場合だけ
pip install -U anthropic

動作確認

yt-dlp --version
ffmpeg -version | head -1
python3 -c "import mlx_whisper; print('mlx-whisper OK')"

APIキーは不要です。 動画一覧の取得も文字起こしも、YouTube Data API を使いません。


4. セットアップ

4-1. 作業フォルダを作る

まとめたい対象ごとに専用フォルダを1つ作ります。名前も場所も自由です。

mkdir -p ~/youtube-digest/mychannel
cd ~/youtube-digest/mychannel

4-2. .claude/skills/youtube-digest/ を作る

作業フォルダの直下に、この記事の 「付録:スクリプト全文」 の各ファイルを配置します。

mkdir -p .claude/skills/youtube-digest/scripts
mkdir -p .claude/skills/youtube-digest/reference
  • .claude/skills/youtube-digest/scripts/ に … common.py collect_links.py transcribe.pybuild_digest.py pipeline.sh (任意で summarize_api.py
  • .claude/skills/youtube-digest/reference/ に … summary_format.md
chmod +x .claude/skills/youtube-digest/scripts/pipeline.sh

4-3. しくみ(なぜ --workdir を書かなくていいのか)

スクリプトは自分の置かれた場所(.claude/skills/youtube-digest/scripts/)から4つ上のフォルダを「作業フォルダ」と自動判定し、そこに videos.jsontranscripts/ を作ります。だから、作業フォルダの直下でコマンドを打つだけで、出力先を指定する必要がありません。

Claude Code を使っていて「このチャンネルまとめて」で自動起動させたい人は、 作業フォルダに次のリンクを張ります。

mkdir -p .claude/skills && ln -s ../../.claude/skills/youtube-digest .claude/skills/youtube-digest

5. 使い方

以降のコマンドは、すべて 作業フォルダの直下(cd ~/youtube-digest/mychannel した状態) で実行します。

STEP 1. 動画リンクの収集

# チャンネル全部
python3 .claude/skills/youtube-digest/scripts/collect_links.py "https://www.youtube.com/@CHANNEL_NAME"

# 新着20本だけ
python3 .claude/skills/youtube-digest/scripts/collect_links.py --limit 20 "https://www.youtube.com/@CHANNEL_NAME"

# 再生リスト
python3 .claude/skills/youtube-digest/scripts/collect_links.py "https://www.youtube.com/playlist?list=PLxxxx"

# 動画を個別に(複数OK)
python3 .claude/skills/youtube-digest/scripts/collect_links.py "https://youtu.be/aaaaaaaaaaa" "https://youtu.be/bbbbbbbbbbb"
  • チャンネルURLは @ハンドル の形でOK(自動で /videos を見に行きます)
  • 何度実行しても重複は増えません(差分だけ追加)
  • 出力:videos.json
  • 0件 と出たら → URLの形式、動画の公開状態、pip install -U yt-dlp を確認

STEP 2. Whisperで文字起こし

# mac(スリープ防止つき)
caffeinate -i -s python3 .claude/skills/youtube-digest/scripts/transcribe.py

# mac以外
python3 .claude/skills/youtube-digest/scripts/transcribe.py

# まず5本だけ試す
python3 .claude/skills/youtube-digest/scripts/transcribe.py --limit 5
  • 未処理の動画だけを処理します。中断して再実行すれば続きから
  • 音声DLが5回連続で失敗すると自動で止まります(時間を置いて再実行)
  • 1本目だけ遅いのは正常(モデルの初回ダウンロード+コンパイル)。2本目以降は数十秒/本
  • 出力:transcripts/{動画ID}.txtmanifest.json

主なオプション:

オプション意味
--limit NN本だけ処理
--language auto多言語チャンネル向け(既定は ja
--model large-v3モデル変更(mlxの既定は large-v3-turbo
--backend fasterfaster-whisper を強制
--cookies-from-browser safariダウンロードがブロックされたとき

STEP 1+2 をまとめて実行するなら:

.claude/skills/youtube-digest/scripts/pipeline.sh "https://www.youtube.com/@CHANNEL_NAME"

STEP 3. 要約してナレッジに整理

transcripts/*.txt を読んで、動画ごとの要約 summaries/{動画ID}.md を作ります。

方法A:Claude に書かせる(APIキー不要・おすすめ)

Claude Code や Claude デスクトップに、こう頼みます。

transcripts/ の文字起こしを、.claude/skills/youtube-digest/reference/summary_format.md の形式で1本ずつ要約して、summaries/{動画ID}.md に保存して。

方法B:APIで自動化(大量・無人向け)

export ANTHROPIC_API_KEY=sk-ant-...
python3 .claude/skills/youtube-digest/scripts/summarize_api.py

出力:summaries/{動画ID}.md

STEP 4. 1ファイルに統合

python3 .claude/skills/youtube-digest/scripts/build_digest.py --title "〇〇チャンネル ナレッジベース"

# 古い動画から順に並べたいとき
python3 .claude/skills/youtube-digest/scripts/build_digest.py --title "..." --order oldest
  • summaries/*.md を集約し、進捗(文字起こし◯/◯、要約◯/◯)を表示
  • 出力:digest.md = これがナレッジベース本体

進捗確認はこのコマンドをいつ打ってもOKです。

同期: 3 件
進捗: 文字起こし 203/203  要約 145/203
統合: 145 本 -> .../digest.md

6. できた digest.md の使い道

  • Claude Projects:Project Knowledge にアップロード。大きくても、質問に関係する部分だけ自動で検索して使われます(見出しを残しておくと精度が上がります)
  • Google ドライブ経由で使う場合:.md のままだと読み込まれないことがあるので、中身を Google ドキュメントに変換してから追加します
  • Obsidian:Vault に置くだけ
  • ChatGPTのカスタムGPT / Gem:ナレッジファイルとしてアップロード

7. カスタマイズ

要約の型を変える.claude/skills/youtube-digest/reference/summary_format.md を書き換えるだけ。「話し手の口調・口癖」「価値観」といったセクションを足せば、”その人になりきるAI” 用の学習データにもできます。

新着動画だけ定期的に取り込むvideos.json は追記式、transcribe.py は未処理分だけ処理します。pipeline.sh を cron で回し、増えた分を要約 → build_digest.py、で差分更新できます。

複数チャンネルを扱うチャンネルごとに作業フォルダを分け、それぞれに .claude/skills/ をコピーします。完全に独立して動きます。


8. つまずきポイント

症状対処
リンク収集が 0件pip install -U yt-dlp / URL形式を確認 / 限定公開は取得不可
音声DLが失敗し続ける時間を置いて再実行 / --cookies-from-browser safari--proxy
Whisperが見つかりませんpip install -U mlx-whisper(mac)or faster-whisper
ffmpeg not foundbrew install ffmpeg
処理中にスリープして止まるcaffeinate -i -s python3 .claude/skills/youtube-digest/scripts/transcribe.py
要約が 0/N のままsummaries/{動画ID}.md が必要(STEP 3)
固有名詞が誤変換される文字起こしは直さず、要約の段階で修正。頻出用語のメモを要約時に渡す

著作権について:要約は自分の学習・整理用にとどめるのが無難です。 対象チャンネルの規約・権利者の許諾を必ず確認してください。


9. まとめ

  • 作業フォルダ+.claude/skills/ の自己完結セットにしておくと、対象ごとにフォルダを増やすだけで運用できる
  • 字幕に頼らず音声+Whisperなので、どんな動画でも文字起こしできる
  • 要約は Claude に任せればAPIキー不要、大量なら API で無人化
  • ゴールの digest.md は、Claude Projects・Obsidian・カスタムGPT にそのまま使える

「あの人の考え方をAIに丸ごと入れておきたい」——その第一歩に。


付録:スクリプト全文

以下を、~/youtube-digest/<作業フォルダ>/.claude/skills/youtube-digest/ 以下に配置してください。

scripts/common.py

"""youtube-digest 共通ヘルパー(プロジェクト非依存)"""
from __future__ import annotations

import json
import os
import re
import subprocess
from datetime import datetime, timezone


def now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


def load_json(path: str, default):
    try:
        with open(path, "r", encoding="utf-8") as f:
            return json.load(f)
    except (FileNotFoundError, json.JSONDecodeError):
        return default


def save_json(path: str, data) -> None:
    os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
    tmp = f"{path}.tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)
    os.replace(tmp, path)


def slugify(text: str, maxlen: int = 60) -> str:
    text = re.sub(r"\s+", "-", (text or "").strip())
    text = re.sub(r"[^\w\-]+", "", text, flags=re.UNICODE).strip("-")
    return (text[:maxlen] or "digest")


_VIDEO_ID_RE = re.compile(r"(?:v=|/shorts/|/live/|youtu\.be/|/embed/)([A-Za-z0-9_-]{11})")


def extract_video_id(url: str):
    if re.fullmatch(r"[A-Za-z0-9_-]{11}", url or ""):
        return url
    m = _VIDEO_ID_RE.search(url or "")
    return m.group(1) if m else None


def default_workdir() -> str:
    """このスクリプト群を格納している `.claude/skills/` の親フォルダ=作業フォルダを返す。

    配置想定: <作業フォルダ>/.claude/skills/youtube-digest/scripts/common.py
      dirname x4 で <作業フォルダ> に届く。
    """
    here = os.path.abspath(__file__)
    return os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(here))))


def resolve_workdir(arg: str | None) -> str:
    return os.path.abspath(arg) if arg else default_workdir()


def paths(workdir: str) -> dict:
    return {
        "workdir": workdir,
        "videos": os.path.join(workdir, "videos.json"),
        "manifest": os.path.join(workdir, "manifest.json"),
        "audio": os.path.join(workdir, "audio"),
        "transcripts": os.path.join(workdir, "transcripts"),
        "summaries": os.path.join(workdir, "summaries"),
        "digest": os.path.join(workdir, "digest.md"),
    }


def ensure_dirs(workdir: str) -> dict:
    p = paths(workdir)
    for d in (p["workdir"], p["audio"], p["transcripts"], p["summaries"]):
        os.makedirs(d, exist_ok=True)
    return p


def run(cmd, timeout: int | None = None):
    return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)

scripts/collect_links.py

"""
STEP 1: 動画リンクの収集

YouTubeのチャンネル / 再生リスト / 動画URL(複数可)や、URLを1行ずつ書いたテキストファイルから、
対象動画の一覧を <workdir>/videos.json に書き出す。

使い方(作業フォルダの中で実行。出力先は自動でその作業フォルダになる):
    python3 .claude/skills/youtube-digest/scripts/collect_links.py "https://www.youtube.com/@handle"
    python3 .claude/skills/youtube-digest/scripts/collect_links.py --limit 20 "https://www.youtube.com/playlist?list=..."
    python3 .claude/skills/youtube-digest/scripts/collect_links.py --from-file links.txt

出力先を明示したいときだけ --workdir を付ける。APIキーは不要(yt-dlp のみで動作)。
"""
import argparse
import os
import sys

from common import (ensure_dirs, extract_video_id, load_json, now_iso, resolve_workdir,
                    run, save_json)


def _from_ytdlp_playlist(url: str, limit: int):
    cmd = ["yt-dlp", "--flat-playlist", "--ignore-errors", "--no-warnings",
           "--print", "%(id)s\t%(title)s"]
    if limit:
        cmd += ["--playlist-end", str(limit)]
    cmd.append(url)
    r = run(cmd)
    if r.returncode != 0 and not r.stdout.strip():
        sys.stderr.write((r.stderr or "").strip()[:1000] + "\n")
        return []
    out = []
    for line in r.stdout.splitlines():
        if "\t" not in line:
            continue
        vid, _, title = line.partition("\t")
        vid = vid.strip()
        if len(vid) == 11:
            out.append({"id": vid, "title": title.strip() or vid,
                        "url": f"https://www.youtube.com/watch?v={vid}"})
    return out


def _single_video(url: str):
    vid = extract_video_id(url)
    if not vid:
        return None
    r = run(["yt-dlp", "--skip-download", "--no-warnings", "--print", "%(title)s",
             f"https://www.youtube.com/watch?v={vid}"])
    title = vid
    if r.returncode == 0 and r.stdout.strip():
        title = r.stdout.strip().splitlines()[0]
    return {"id": vid, "title": title,
            "url": f"https://www.youtube.com/watch?v={vid}"}


def _normalize_channel(url: str) -> str:
    u = url.strip()
    is_channel = any(k in u for k in ("/@", "/channel/", "/c/", "/user/"))
    already_tab = any(t in u for t in ("/videos", "/streams", "/shorts",
                                       "/playlists", "watch?v=", "list="))
    if is_channel and not already_tab:
        u = u.rstrip("/") + "/videos"
    return u


def _looks_like_single_video(src: str) -> bool:
    return (("watch?v=" in src) or ("youtu.be/" in src) or ("/shorts/" in src)
            or ("/live/" in src) or ("/embed/" in src)
            or (len(src) == 11 and extract_video_id(src) == src))


def collect(sources, limit):
    seen, videos = set(), []
    for src in sources:
        src = src.strip()
        if not src:
            continue
        if _looks_like_single_video(src):
            got = [_single_video(src)]
        else:
            got = _from_ytdlp_playlist(_normalize_channel(src), limit)
        for v in got:
            if v and v["id"] not in seen:
                seen.add(v["id"])
                videos.append(v)
    return videos


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("sources", nargs="*", help="チャンネル / 再生リスト / 動画 の URL")
    ap.add_argument("--workdir", default=None,
                    help="出力先。省略時は .claude/skills/ の親(=作業フォルダ)")
    ap.add_argument("--from-file", help="URLを1行ずつ書いたテキストファイル(# で始まる行は無視)")
    ap.add_argument("--limit", type=int, default=0, help="新しい方から N 件だけ")
    args = ap.parse_args()

    sources = list(args.sources)
    if args.from_file:
        with open(args.from_file, encoding="utf-8") as f:
            sources += [ln.strip() for ln in f if ln.strip() and not ln.lstrip().startswith("#")]
    if not sources:
        ap.error("URL か --from-file を指定してください")

    workdir = resolve_workdir(args.workdir)
    print(f"作業フォルダ: {workdir}")
    p = ensure_dirs(workdir)
    by_id = {v["id"]: v for v in load_json(p["videos"], [])}

    found = collect(sources, args.limit)
    added = 0
    for v in found:
        if v["id"] not in by_id:
            by_id[v["id"]] = v
            added += 1
    merged = list(by_id.values())
    save_json(p["videos"], merged)

    meta = load_json(p["manifest"], {"sources": [], "created_at": now_iso(), "videos": {}})
    for s in sources:
        if s not in meta["sources"]:
            meta["sources"].append(s)
    save_json(p["manifest"], meta)

    print(f"収集: 新規 {added} 件 / 合計 {len(merged)} 件 -> {p['videos']}")
    if not found:
        print("  0件でした。URLの形式・動画の公開状態・yt-dlpのバージョン(pip install -U yt-dlp)を確認してください。")


if __name__ == "__main__":
    main()

scripts/transcribe.py

"""
STEP 2: Whisper でローカル文字起こし

<workdir>/videos.json の各動画について、
  1) yt-dlp で音声だけダウンロード(HLS優先。字幕がIPブロックされていても通りやすい)
  2) Whisper で文字起こし
  3) <workdir>/transcripts/{id}.txt に保存、<workdir>/manifest.json を逐次更新

バックエンドは自動判定: mlx-whisper(Apple Silicon)→ faster-whisper → どちらも無ければ中断。
未処理の動画だけを処理するので、中断・再実行で続きから再開できる。

使い方(作業フォルダの中で実行):
    python3 .claude/skills/youtube-digest/scripts/transcribe.py
    python3 .claude/skills/youtube-digest/scripts/transcribe.py --limit 5 --keep-audio
    python3 .claude/skills/youtube-digest/scripts/transcribe.py --language auto --model large-v3 --backend faster
"""
import argparse
import os
import sys
import time

from common import ensure_dirs, load_json, now_iso, paths, resolve_workdir, run, save_json

DEFAULT_MODEL = {"mlx": "mlx-community/whisper-large-v3-turbo", "faster": "large-v3"}
MAX_CONSEC_DL_FAIL = 5
_FASTER_MODELS = {}


def pick_backend(requested: str):
    order = ["mlx", "faster"] if requested == "auto" else [requested]
    for b in order:
        try:
            if b == "mlx":
                import mlx_whisper  # noqa: F401
            elif b == "faster":
                from faster_whisper import WhisperModel  # noqa: F401
            return b
        except Exception:
            continue
    return None


def _join_segments(segments, fallback: str) -> str:
    lines, tail = [], None
    for s in segments:
        t = (s.get("text") or "").strip()
        if t and t != tail:
            lines.append(t)
            tail = t
    return "\n".join(lines) if lines else (fallback or "").strip()


def transcribe_mlx(audio: str, model: str, language: str) -> str:
    import mlx_whisper
    r = mlx_whisper.transcribe(
        audio, path_or_hf_repo=model,
        language=None if language == "auto" else language,
        condition_on_previous_text=False,
    )
    return _join_segments(r.get("segments", []), r.get("text", ""))


def transcribe_faster(audio: str, model: str, language: str) -> str:
    from faster_whisper import WhisperModel
    if model not in _FASTER_MODELS:
        _FASTER_MODELS[model] = WhisperModel(model, device="auto", compute_type="int8")
    segments, _info = _FASTER_MODELS[model].transcribe(
        audio, language=None if language == "auto" else language,
        condition_on_previous_text=False,
    )
    return _join_segments([{"text": s.text} for s in segments], "")


def download_audio(workdir: str, video: dict, proxy=None, cookies=None):
    p = paths(workdir)
    exts = ("m4a", "mp4", "webm", "opus", "mp3")
    for ext in exts:
        f = os.path.join(p["audio"], f"{video['id']}.{ext}")
        if os.path.exists(f) and os.path.getsize(f) > 1024:
            return f
    out_tmpl = os.path.join(p["audio"], f"{video['id']}.%(ext)s")
    cmd = ["yt-dlp", "-f", "bestaudio[protocol^=m3u8]/bestaudio/worstaudio",
           "--no-warnings", "--socket-timeout", "20", "--retries", "3",
           "--fragment-retries", "5", "-o", out_tmpl]
    if proxy:
        cmd += ["--proxy", proxy]
    if cookies:
        cmd += ["--cookies-from-browser", cookies]
    cmd.append(video["url"])
    run(cmd)
    for ext in exts:
        f = os.path.join(p["audio"], f"{video['id']}.{ext}")
        if os.path.exists(f) and os.path.getsize(f) > 1024:
            return f
    return None


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--workdir", default=None,
                    help="作業フォルダ。省略時は .claude/skills/ の親")
    ap.add_argument("--backend", default="auto", choices=["auto", "mlx", "faster"])
    ap.add_argument("--model", default="", help="空なら backend ごとの既定値")
    ap.add_argument("--language", default="ja", help="'ja' など。自動判定は 'auto'")
    ap.add_argument("--limit", type=int, default=0)
    ap.add_argument("--keep-audio", action="store_true")
    ap.add_argument("--proxy", default=os.environ.get("YTDLP_PROXY") or None)
    ap.add_argument("--cookies-from-browser", default=None,
                    help="chrome / safari / firefox など")
    args = ap.parse_args()

    backend = pick_backend(args.backend)
    if not backend:
        sys.exit("Whisperが見つかりません。次のどちらかを入れてください:\n"
                 "  pip install -U mlx-whisper      # Apple Silicon 推奨\n"
                 "  pip install -U faster-whisper   # それ以外")
    model = args.model or DEFAULT_MODEL[backend]
    transcribe = transcribe_mlx if backend == "mlx" else transcribe_faster
    workdir = resolve_workdir(args.workdir)
    print(f"作業フォルダ: {workdir}")
    print(f"backend={backend}  model={model}  language={args.language}")

    p = ensure_dirs(workdir)
    videos = load_json(p["videos"], [])
    if not videos:
        sys.exit(f"{p['videos']} が空です。先に collect_links.py を実行してください。")
    manifest = load_json(p["manifest"], {"sources": [], "created_at": now_iso(), "videos": {}})

    todo = [v for v in videos
            if not os.path.exists(os.path.join(p["transcripts"], f"{v['id']}.txt"))]
    if args.limit:
        todo = todo[:args.limit]
    if not todo:
        print("未処理の動画はありません。")
        return

    print(f"対象 {len(todo)} 本")
    consec_fail, done, dl_fail = 0, 0, 0
    for i, v in enumerate(todo, 1):
        print(f"  ({i}/{len(todo)}) {v['title'][:60]}  [{v['id']}]")
        audio = download_audio(workdir, v, args.proxy, args.cookies_from_browser)
        if not audio:
            dl_fail += 1
            consec_fail += 1
            print(f"      音声DL失敗  [{consec_fail}/{MAX_CONSEC_DL_FAIL}]")
            if consec_fail >= MAX_CONSEC_DL_FAIL:
                print("\n連続失敗のため中断。時間を置くか、--proxy / --cookies-from-browser を指定して再実行してください。")
                break
            time.sleep(5)
            continue
        consec_fail = 0

        t0 = time.time()
        try:
            text = transcribe(audio, model, args.language)
        except Exception as e:
            print(f"      文字起こし失敗: {e}")
            time.sleep(2)
            continue
        if len(text) < 20:
            print(f"      結果が短すぎ({len(text)}字)。スキップ")
            continue

        tpath = os.path.join(p["transcripts"], f"{v['id']}.txt")
        with open(tpath, "w", encoding="utf-8") as f:
            f.write(
                f"# {v['title']}\n# URL: {v['url']}\n# video_id: {v['id']}\n"
                f"# source: whisper/{backend} ({model})\n"
                f"# transcribed_at: {now_iso()}\n\n{text}\n"
            )
        rec = manifest["videos"].get(v["id"], {})
        rec.update({
            "title": v["title"], "url": v["url"], "transcript_path": tpath,
            "transcribed_at": now_iso(), "source": f"whisper/{backend}", "model": model,
        })
        rec.setdefault("summarized", False)
        rec.setdefault("summary_path", None)
        manifest["videos"][v["id"]] = rec
        save_json(p["manifest"], manifest)

        if not args.keep_audio:
            try:
                os.remove(audio)
            except OSError:
                pass
        done += 1
        print(f"      OK({len(text)}字, {time.time() - t0:.0f}秒)")

    total = len([n for n in os.listdir(p["transcripts"]) if n.endswith(".txt")])
    print(f"\n今回 {done} 本 / 音声DL失敗 {dl_fail} 本 / 文字起こし累計 {total} 本")


if __name__ == "__main__":
    main()

scripts/build_digest.py

"""
STEP 3-4: 要約をナレッジベースに整理・統合

- <workdir>/summaries/{id}.md を manifest.json に同期(summarized フラグ / summary_path)
- すべての要約を1ファイルに結合 → <workdir>/digest.md

要約ファイル(summaries/{id}.md)は、Claude が transcripts/{id}.txt を読んで
reference/summary_format.md のテンプレに沿って書き出す。追加のAPIキーは不要。

使い方(作業フォルダの中で実行):
    python3 .claude/skills/youtube-digest/scripts/build_digest.py --title "〇〇チャンネル ナレッジ"
    python3 .claude/skills/youtube-digest/scripts/build_digest.py --order oldest
"""
import argparse
import glob
import os

from common import ensure_dirs, load_json, now_iso, resolve_workdir, save_json


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--workdir", default=None, help="作業フォルダ。省略時は .claude/skills/ の親")
    ap.add_argument("--title", default="動画ナレッジベース(要約集)")
    ap.add_argument("--order", default="source", choices=["source", "oldest"],
                    help="source=リンク収集順(チャンネルは新しい順) / oldest=古い順")
    args = ap.parse_args()

    workdir = resolve_workdir(args.workdir)
    p = ensure_dirs(workdir)
    videos = load_json(p["videos"], [])
    manifest = load_json(p["manifest"], {"sources": [], "created_at": now_iso(), "videos": {}})

    # summaries/*.md を manifest に同期
    synced = 0
    for md in glob.glob(os.path.join(p["summaries"], "*.md")):
        vid = os.path.splitext(os.path.basename(md))[0]
        rec = manifest["videos"].setdefault(vid, {})
        if not rec.get("summarized") or rec.get("summary_path") != md:
            rec["summarized"] = True
            rec["summary_path"] = md
            synced += 1
    save_json(p["manifest"], manifest)

    # 統合対象(要約が存在するものだけ)
    pos = {v["id"]: i for i, v in enumerate(videos)}
    have = [(vid, rec) for vid, rec in manifest["videos"].items()
            if rec.get("summarized") and rec.get("summary_path")
            and os.path.exists(rec["summary_path"])]
    if args.order == "oldest":
        have.sort(key=lambda x: -pos.get(x[0], -1))
    else:
        have.sort(key=lambda x: pos.get(x[0], 10 ** 9))

    out = [
        f"# {args.title}",
        f"\n収録本数: {len(have)}本  /  生成: {now_iso()}\n",
        "各動画の文字起こしを要約し、1ファイルに統合したものです。\n",
        "---\n",
    ]
    for _vid, rec in have:
        with open(rec["summary_path"], encoding="utf-8") as f:
            out.append(f.read().strip())
        out.append("\n---\n")

    with open(p["digest"], "w", encoding="utf-8") as f:
        f.write("\n".join(out))

    total = len(videos)
    tdone = len(glob.glob(os.path.join(p["transcripts"], "*.txt")))
    sdone = len(have)
    print(f"同期: {synced} 件")
    print(f"進捗: 文字起こし {tdone}/{total}  要約 {sdone}/{total}")
    print(f"統合: {sdone} 本 -> {p['digest']}")


if __name__ == "__main__":
    main()

scripts/pipeline.sh

#!/usr/bin/env bash
# youtube-digest: STEP1(リンク収集)+ STEP2(文字起こし)をまとめて実行する。
# 出力はこのスクリプトを置いた .claude/skills/ の親フォルダ(=作業フォルダ)に作られる。
# STEP3(要約)は Claude か summarize_api.py で別途行う。
#
# 使い方(作業フォルダはどこにあってもOK):
#   .claude/skills/youtube-digest/scripts/pipeline.sh "<チャンネル/再生リスト/動画のURL>" [URL...]
#
# 環境変数:
#   LIMIT=20                     新しい方から N 件だけ収集
#   YTDLP_PROXY                  音声DLがブロックされた場合のプロキシ
#   COOKIES_FROM_BROWSER=safari  同上(ブラウザのCookieを使う)

set -euo pipefail

[ "$#" -ge 1 ] || { echo '使い方: pipeline.sh "<URL>" [URL...]' >&2; exit 1; }

HERE="$(cd "$(dirname "$0")" && pwd)"
WORKDIR="$(cd "$HERE/../../.." && pwd)"   # scripts -> youtube-digest -> .claude/skills -> 作業フォルダ
echo "作業フォルダ: $WORKDIR"

COLLECT_ARGS=()
[ -n "${LIMIT:-}" ] && COLLECT_ARGS+=(--limit "$LIMIT")
python3 "$HERE/collect_links.py" "${COLLECT_ARGS[@]}" "$@"

TR_ARGS=()
[ -n "${YTDLP_PROXY:-}" ] && TR_ARGS+=(--proxy "$YTDLP_PROXY")
[ -n "${COOKIES_FROM_BROWSER:-}" ] && TR_ARGS+=(--cookies-from-browser "$COOKIES_FROM_BROWSER")

if command -v caffeinate >/dev/null 2>&1; then
  caffeinate -i -s python3 "$HERE/transcribe.py" "${TR_ARGS[@]}"
else
  python3 "$HERE/transcribe.py" "${TR_ARGS[@]}"
fi

echo
echo "次のステップ:"
echo "  1) $WORKDIR/transcripts/*.txt を要約して $WORKDIR/summaries/{動画ID}.md に保存"
echo "     (Claude に依頼する / または  python3 $HERE/summarize_api.py)"
echo "  2) python3 $HERE/build_digest.py --title \"ナレッジのタイトル\""

reference/summary_format.md

# {動画タイトル}
URL: {動画URL}

## ひとことで
(この動画が何の話か、1〜2文で)

## 要点
- (箇条書きで5〜10個。結論・主張・具体例を1行ずつ)

## 詳しい内容
(話の流れに沿って、段落または箇条書きで整理する。
 出てきた数字・固有名詞・具体例・エピソードは省略せず残す。
 対談形式なら「誰がどう答えたか」がわかるように書く)

## 引用したい発言
- 「(短い引用。話し手の言い回し・キーフレーズがわかるもの)」

## メモ / 未確認
- (曖昧な点、Whisperの誤変換が疑われる固有名詞など。なければ「特になし」)

scripts/summarize_api.py(任意)

"""
(任意)STEP 3 を Anthropic API で自動化する版。

Claude Code に対話で要約させる場合は不要。大量の動画を無人で回したいときだけ使う。

    pip install anthropic
    export ANTHROPIC_API_KEY=sk-ant-...
    python3 .claude/skills/youtube-digest/scripts/summarize_api.py [--model claude-sonnet-5] [--limit 10]

要約テンプレートは reference/summary_format.md(--format で差し替え可)。
"""
import argparse
import glob
import os
import sys

from common import ensure_dirs, load_json, resolve_workdir, save_json, now_iso

DEFAULT_FORMAT = os.path.join(os.path.dirname(__file__), "..", "reference", "summary_format.md")


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--workdir", default=None, help="作業フォルダ。省略時は .claude/skills/ の親")
    ap.add_argument("--model", default="claude-sonnet-5")
    ap.add_argument("--format", default=DEFAULT_FORMAT, help="要約テンプレートのMarkdownファイル")
    ap.add_argument("--limit", type=int, default=0)
    args = ap.parse_args()

    try:
        import anthropic
    except ImportError:
        sys.exit("pip install anthropic が必要です")

    with open(args.format, encoding="utf-8") as f:
        template = f.read()

    client = anthropic.Anthropic()
    p = ensure_dirs(resolve_workdir(args.workdir))
    manifest = load_json(p["manifest"], {"videos": {}})

    transcripts = sorted(glob.glob(os.path.join(p["transcripts"], "*.txt")))
    todo = [t for t in transcripts
            if not os.path.exists(os.path.join(
                p["summaries"], os.path.splitext(os.path.basename(t))[0] + ".md"))]
    if args.limit:
        todo = todo[:args.limit]
    if not todo:
        print("未要約の文字起こしはありません。")
        return

    for i, tpath in enumerate(todo, 1):
        vid = os.path.splitext(os.path.basename(tpath))[0]
        body = open(tpath, encoding="utf-8").read()
        print(f"({i}/{len(todo)}) {vid}")
        msg = client.messages.create(
            model=args.model,
            max_tokens=2500,
            messages=[{
                "role": "user",
                "content": (
                    "次のYouTube動画の文字起こしを、以下のテンプレートに厳密に従って"
                    "日本語で要約してください。テンプレート以外の前置き・後書きは書かないこと。\n\n"
                    f"=== テンプレート ===\n{template}\n\n"
                    f"=== 文字起こし ===\n{body}"
                ),
            }],
        )
        text = "".join(block.text for block in msg.content if block.type == "text").strip()
        out = os.path.join(p["summaries"], f"{vid}.md")
        with open(out, "w", encoding="utf-8") as f:
            f.write(text + "\n")
        rec = manifest["videos"].setdefault(vid, {})
        rec["summarized"] = True
        rec["summary_path"] = out
        save_json(p["manifest"], manifest)

    print("完了。build_digest.py で統合してください。")


if __name__ == "__main__":
    main()

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次