YouTubeチャンネルを丸ごと「AIの知識」に変える手順|Claude Code × Whisper で作る動画要約パイプライン
「参考にしているYouTubeチャンネルの内容を、ChatGPTやClaudeにぜんぶ覚えさせたい」——そう思ったことはないでしょうか。
この記事では、YouTubeのチャンネルURL(または動画URL)を渡すだけで、全動画を「文字起こし → 要約 → 1つのナレッジファイルに統合」するところまでを自動化する仕組みを、コピペで作れる形で紹介します。
- YouTubeの字幕がなくても/字幕APIがブロックされていても動く(音声をダウンロードしてローカルのWhisperで文字起こしするため)
- できあがる
digest.mdは、そのまま Claude Projects の Project Knowledge やObsidian に放り込めます - 実際にこの仕組みで、あるチャンネルの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.pycollect_links.pytranscribe.pybuild_digest.pypipeline.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.json や transcripts/ を作ります。だから、作業フォルダの直下でコマンドを打つだけで、出力先を指定する必要がありません。
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}.txtとmanifest.json
主なオプション:
| オプション | 意味 |
|---|---|
--limit N | N本だけ処理 |
--language auto | 多言語チャンネル向け(既定は ja) |
--model large-v3 | モデル変更(mlxの既定は large-v3-turbo) |
--backend faster | faster-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 found | brew 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()
