#!/usr/bin/env python3 """ fetch_youtube_playlist.py 從 YouTube 播放清單抓影片字幕,用 LLM 生成知識卡片,加入 search_index.json。 Usage: python3 scripts/fetch_youtube_playlist.py python3 scripts/fetch_youtube_playlist.py --playlist "URL" python3 scripts/fetch_youtube_playlist.py --limit 5 python3 scripts/fetch_youtube_playlist.py --dry-run """ import argparse import json import os import re import subprocess import sys import tempfile from datetime import datetime from pathlib import Path # ── Shared card prompt module ───────────────────────────────────────────────── sys.path.insert(0, str(Path(__file__).parent)) from gbrain_publish import PublicationBatch from xkb_frontmatter import parse as extract_frontmatter from _card_prompt import ( build_prompt, extract_summary, find_related_context, llm_call as _llm_call, SOURCE_LABELS, gbrain_put as _gbrain_put, condense_long_content, ) from category_classifier import classify_content from category_classifier import apply_category import xkb_paths SOURCE_LABELS["youtube"] = "YouTube 影片" # ensure registered # ── Paths ───────────────────────────────────────────────────────────────────── SKILL_DIR = Path(__file__).resolve().parent.parent WORKSPACE_DIR = Path( os.getenv( "OPENCLAW_WORKSPACE", os.getenv("WORKSPACE_DIR", str(Path.home() / ".openclaw" / "workspace")) ) ) BOOKMARKS_DIR = xkb_paths.BOOKMARKS_DIR INDEX_FILE = xkb_paths.INDEX_FILE YOUTUBE_DIR = BOOKMARKS_DIR / "youtube" COOKIES_FILE = Path.home() / ".config" / "yt-dlp" / "cookies.txt" # ── LLM ───────────────────────────────────────────────────────────────────── # Model routing is handled by _card_prompt.py → _llm.py → config/llm.json. CARD_CATEGORIES = [ "ai-tools", "seo-marketing", "workflows", "video", "startup", "design", "tech", "learning", "other" ] BOOKMARKS_DIR_ITEMS: list[dict] = [] # populated after index load, used for related context # ── yt-dlp helpers ──────────────────────────────────────────────────────────── def _yt_cmd(extra_args: list) -> list: base = [sys.executable, "-m", "yt_dlp", "--js-runtimes", "node", "--remote-components", "ejs:github"] if COOKIES_FILE.exists(): base += ["--cookies", str(COOKIES_FILE)] else: print("⚠️ ~/.config/yt-dlp/cookies.txt 不存在,可能被 YouTube 封鎖", file=sys.stderr) return base + extra_args def get_playlist_videos(playlist_url: str) -> list[dict]: """回傳播放清單影片列表 [{"id", "title", "duration"}]""" cmd = _yt_cmd([ "--flat-playlist", "--print", "%(id)s\t%(title)s\t%(duration)s", "--quiet", playlist_url ]) result = subprocess.run(cmd, capture_output=True, text=True) videos = [] for line in result.stdout.strip().splitlines(): parts = line.split("\t") if len(parts) >= 2: vid_id = parts[0].strip() title = parts[1].strip() dur = int(parts[2]) if len(parts) > 2 and parts[2].strip().isdigit() else 0 if vid_id: videos.append({"id": vid_id, "title": title, "duration": dur}) return videos def download_subtitles(video_id: str, tmp_dir: Path) -> str: """下載字幕並回傳純文字(優先 zh-Hans,備選 en)""" cmd = _yt_cmd([ "--write-auto-subs", "--sub-langs", "zh-Hans,zh-TW,en", "--skip-download", "--sub-format", "vtt", "--no-progress", "--quiet", "-o", str(tmp_dir / "%(id)s"), f"https://www.youtube.com/watch?v={video_id}" ]) subprocess.run(cmd, capture_output=True, text=True) for lang in ["zh-Hans", "zh-TW", "en"]: vtt = tmp_dir / f"{video_id}.{lang}.vtt" if vtt.exists(): return _parse_vtt(vtt), lang return "", "" def _parse_vtt(vtt_path: Path) -> str: """VTT → 去重純文字""" content = vtt_path.read_text(encoding="utf-8") seen, texts = set(), [] for line in content.splitlines(): line = line.strip() if (not line or "-->" in line or line.startswith(("WEBVTT", "Kind:", "Language:")) or re.match(r"^\d+$", line)): continue line = re.sub(r"<[^>]+>", "", line) line = re.sub(r"\s+", " ", line).strip() if line and line not in seen: seen.add(line) texts.append(line) return " ".join(texts) # ── LLM card generation (uses shared _card_prompt) ─────────────────────────── def generate_card(title: str, video_id: str, transcript: str, lang: str, api_key: str, existing_items: list[dict]) -> str: """Generate 9-section knowledge card for a YouTube video.""" source_url = f"https://www.youtube.com/watch?v={video_id}" card_id = f"youtube-{video_id}" # 長文 map-reduce(TODOS 2026-07-13):長字幕分段濃縮,取代硬截 4000 字元 content = f"標題: {title}\n字幕語言: {lang or 'unknown'}\n\n{condense_long_content(transcript)}" related = find_related_context(content, existing_items) classified = classify_content(content, source_type="youtube", current_category="03-video-prompts") prompt = build_prompt( content=content, card_id=card_id, source_type="youtube", source_url=source_url, category=classified["category"], related_context=related, ) return _llm_call(prompt, api_key) # ── Search index helpers ────────────────────────────────────────────────────── def load_index() -> dict: if INDEX_FILE.exists(): return json.loads(INDEX_FILE.read_text(encoding="utf-8")) return {"items": []} # extract_summary imported from _card_prompt # ── Main ────────────────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser(description="Fetch YouTube playlist → knowledge cards") parser.add_argument("--playlist", default=os.environ.get("YOUTUBE_PLAYLIST_URL", ""), help="YouTube 播放清單 URL") parser.add_argument("--limit", type=int, default=0, help="最多處理幾支新影片(0=全部)") parser.add_argument("--dry-run", action="store_true", help="只列出待處理影片,不實際執行") args = parser.parse_args() if not args.playlist: print("❌ 請設定 YOUTUBE_PLAYLIST_URL 環境變數或傳入 --playlist 參數", file=sys.stderr) return 1 # LLM auth/model routing is handled by the OpenClaw model helper. api_key = os.environ.get("LLM_API_KEY", "") YOUTUBE_DIR.mkdir(parents=True, exist_ok=True) # Load existing index to check processed videos index_data = load_index() existing_items_list = index_data.get("items", []) existing_ids = { item.get("relative_path", "").split("/")[-1].replace(".md", "") for item in existing_items_list } print(f"📋 抓取播放清單:{args.playlist}") videos = get_playlist_videos(args.playlist) print(f" 共 {len(videos)} 支影片") batch = PublicationBatch(_gbrain_put) if not args.dry_run and not batch.ready(): return 1 # Publication retries are independent of generation/index deduplication. if not args.dry_run: for video in videos: saved = YOUTUBE_DIR / f"{video['id']}.md" if saved.exists(): batch.existing(saved, f"youtube-{video['id']}") existing_ids.add(video['id']) # Filter new videos new_videos = [v for v in videos if v["id"] not in existing_ids] print(f" 新影片:{len(new_videos)} 支(已跳過 {len(videos) - len(new_videos)} 支)") if args.limit: new_videos = new_videos[:args.limit] if args.dry_run: for v in new_videos: dur = f"{v['duration']//60}:{v['duration']%60:02d}" if v["duration"] else "?" print(f" [{dur}] {v['id']} — {v['title']}") return 0 if not new_videos: print("✅ 沒有新影片需要處理") return 0 if batch.finish() else 1 processed = 0 with tempfile.TemporaryDirectory() as tmp_dir: tmp_path = Path(tmp_dir) for v in new_videos: if batch.blocked: break vid_id = v["id"] title = v["title"] dur = v["duration"] dur_str = f"{dur//60}:{dur%60:02d}" if dur else "?" print(f"\n🎬 [{dur_str}] {title}") # Skip very short videos (< 60s, probably shorts) if dur and dur < 60: print(f" ⏭️ 跳過(影片太短:{dur_str})") continue # Download subtitles print(f" 📥 下載字幕...") transcript, lang = download_subtitles(vid_id, tmp_path) if not transcript: print(f" ⚠️ 無法取得字幕,跳過") continue print(f" ✓ 字幕 ({lang}):{len(transcript)} 字") # Generate card print(f" 🤖 生成知識卡片(9-section)...") try: card_content = generate_card(title, vid_id, transcript, lang, api_key, existing_items_list) except Exception as e: print(f" ❌ LLM 失敗:{e}") continue # Save .md # 分類在 build_card 裡已經做過了,而且用的是壓縮過的內容。 # 這裡再分類一次不只多付一次 LLM,送的還是未壓縮的完整字幕, # 結果又會覆蓋前一次——同一支影片可能得到兩個不同的分類。 md_path = YOUTUBE_DIR / f"{vid_id}.md" md_path.write_text(card_content, encoding="utf-8") batch.publish(md_path, f"youtube-{vid_id}", generated=True) print(f" 💾 儲存:youtube/{vid_id}.md") # Parse and add to index fm = extract_frontmatter(card_content) summary = extract_summary(card_content) source_url = f"https://www.youtube.com/watch?v={vid_id}" relative_path = f"youtube/{vid_id}.md" tags_raw = fm.get("tags", "").strip("[]") tags = [t.strip() for t in tags_raw.split(",") if t.strip()] new_item = { "path": str(md_path), "relative_path": relative_path, "title": fm.get("title", title), "category": fm.get("category", "video"), "tags": tags, "summary": summary, "source_url": source_url, "source_type": "youtube", "searchable": f"{title} {summary} {' '.join(tags)}", "mtime": datetime.now().isoformat(), "size": md_path.stat().st_size, "enriched": True, } existing_items_list.append(new_item) # keep related context fresh processed += 1 if not batch.finish(): return 1 print(f"\n✅ 完成:新增 {processed} 張 YouTube 知識卡片") if processed > 0: print("💡 執行以下指令更新語意索引:") print(" python3 scripts/build_vector_index.py --incremental") return 0 import xkb_usage # noqa: E402 — 量測誰在跑,見 scripts/xkb_usage.py if __name__ == "__main__": xkb_usage.record(__file__) raise SystemExit(main())