Ctrl K
ComunidadGuías…

scripts/fetch_youtube_playlist.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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())