Ctrl K
ComunidadGuías…

scripts/monitor_youtube_playlist.py

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""Monitor one or more YouTube playlists and prepare transcript packages for agent/XKB card generation.

This script intentionally does NOT call any LLM. It only:
1. loads playlist configs
2. lists playlist videos via yt-dlp
3. compares against existing youtube cards/raw packages
4. downloads subtitles for new videos
5. writes raw transcript JSON packages under memory/x-knowledge-base/youtube-raw/

Card generation is handled by OpenClaw cron agent, not by a direct model provider.
"""

from __future__ import annotations

import argparse
import json
import os
import re
import subprocess
import sys
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import parse_qs, urlparse

sys.path.insert(0, str(Path(__file__).resolve().parent))
import xkb_paths

WORKSPACE = xkb_paths.WORKSPACE
BOOKMARKS_DIR = xkb_paths.BOOKMARKS_DIR
YOUTUBE_CARD_DIR = BOOKMARKS_DIR / "youtube"
RAW_DIR = WORKSPACE / "memory" / "x-knowledge-base" / "youtube-raw"
SKILL_DIR = Path(__file__).resolve().parent.parent
DEFAULT_PLAYLISTS_FILE = SKILL_DIR / "config" / "youtube-playlists.json"
COOKIES_FILE = Path.home() / ".config" / "yt-dlp" / "cookies.txt"


def yt_cmd(extra: list[str]) -> list[str]:
    base = [
        sys.executable,
        "-m",
        "yt_dlp",
        "--js-runtimes",
        "node",
        "--remote-components",
        "ejs:github",
    ]
    if COOKIES_FILE.exists():
        base += ["--cookies", str(COOKIES_FILE)]
    return base + extra


def playlist_slug(url: str, fallback: str = "playlist") -> str:
    try:
        parsed = urlparse(url)
        list_id = parse_qs(parsed.query).get("list", [""])[0]
        if list_id:
            return re.sub(r"[^A-Za-z0-9_-]+", "-", list_id).strip("-")[:80]
    except Exception:
        pass
    return re.sub(r"[^A-Za-z0-9_-]+", "-", fallback).strip("-")[:80] or "playlist"


def normalize_playlist(raw: dict | str, idx: int = 0) -> dict:
    if isinstance(raw, str):
        raw = {"url": raw}
    url = str(raw.get("url") or raw.get("playlist_url") or "").strip()
    if not url:
        raise ValueError(f"Playlist config #{idx + 1} missing url")
    pid = str(raw.get("id") or raw.get("slug") or playlist_slug(url, f"playlist-{idx + 1}"))
    return {
        "id": pid,
        "url": url,
        "title": str(raw.get("title") or pid),
        "category": str(raw.get("category") or "ai-tools"),
        "tags": [str(t).strip() for t in raw.get("tags", []) if str(t).strip()],
        "notes": str(raw.get("notes") or ""),
    }


def load_playlists(args: argparse.Namespace) -> list[dict]:
    if args.playlist:
        return [
            normalize_playlist(
                {
                    "id": args.playlist_id or playlist_slug(args.playlist),
                    "url": args.playlist,
                    "title": args.playlist_title or args.playlist_id or playlist_slug(args.playlist),
                    "category": args.category,
                    "tags": args.tags or [],
                }
            )
        ]

    if os.getenv("YOUTUBE_PLAYLISTS_JSON"):
        data = json.loads(os.environ["YOUTUBE_PLAYLISTS_JSON"])
        if isinstance(data, dict) and "playlists" in data:
            data = data["playlists"]
        if not isinstance(data, list):
            raise ValueError("YOUTUBE_PLAYLISTS_JSON must be a list or {playlists: [...]}")
        return [normalize_playlist(item, idx) for idx, item in enumerate(data)]

    playlists_file = Path(os.getenv("YOUTUBE_PLAYLISTS_FILE", str(DEFAULT_PLAYLISTS_FILE)))
    if playlists_file.exists():
        data = json.loads(playlists_file.read_text(encoding="utf-8"))
        if isinstance(data, dict):
            data = data.get("playlists", [])
        if not isinstance(data, list):
            raise ValueError(f"{playlists_file} must contain a list or {{playlists: [...]}}")
        return [normalize_playlist(item, idx) for idx, item in enumerate(data)]

    if os.getenv("YOUTUBE_PLAYLIST_URL"):
        return [normalize_playlist(os.environ["YOUTUBE_PLAYLIST_URL"])]

    return []


def playlist_videos(playlist_url: str) -> list[dict]:
    cmd = yt_cmd([
        "--flat-playlist",
        "--print",
        "%(id)s\t%(title)s\t%(duration)s\t%(channel)s",
        "--quiet",
        playlist_url,
    ])
    result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
    if result.returncode != 0:
        raise RuntimeError(result.stderr.strip() or "yt-dlp playlist listing failed")

    videos = []
    for line in result.stdout.strip().splitlines():
        parts = line.split("\t")
        if len(parts) < 2:
            continue
        vid = parts[0].strip()
        title = parts[1].strip()
        duration = int(parts[2]) if len(parts) > 2 and parts[2].strip().isdigit() else 0
        channel = parts[3].strip() if len(parts) > 3 else ""
        if vid:
            videos.append({"id": vid, "title": title, "duration": duration, "channel": channel})
    return videos


def parse_vtt(vtt_path: Path) -> str:
    content = vtt_path.read_text(encoding="utf-8")
    seen: set[str] = set()
    texts: list[str] = []
    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)


def download_subtitle(video_id: str, tmp_dir: Path) -> tuple[str, str]:
    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, timeout=180)
    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 existing_card_ids() -> set[str]:
    if not YOUTUBE_CARD_DIR.exists():
        return set()
    return {p.stem for p in YOUTUBE_CARD_DIR.glob("*.md")}


def existing_raw_ids() -> set[str]:
    if not RAW_DIR.exists():
        return set()
    return {p.stem for p in RAW_DIR.glob("*.json")}


def package_video(video: dict, playlist: dict, tmp_dir: Path) -> tuple[dict | None, dict | None]:
    transcript, lang = download_subtitle(video["id"], tmp_dir)
    base = {
        "id": video["id"],
        "title": video["title"],
        "channel": video.get("channel") or "YouTube",
        "duration": video.get("duration", 0),
        "source_url": f"https://www.youtube.com/watch?v={video['id']}",
        "playlist_id": playlist["id"],
        "playlist_title": playlist["title"],
        "playlist_url": playlist["url"],
        "category": playlist["category"],
        "playlist_tags": playlist["tags"],
        "playlist_notes": playlist.get("notes", ""),
        "created_at": datetime.now(timezone.utc).isoformat(),
        "generator": "apan2-cron-agent",
    }
    out = RAW_DIR / f"{video['id']}.json"
    if not transcript:
        marker = {**base, "status": "no_subtitles"}
        out.write_text(json.dumps(marker, ensure_ascii=False, indent=2), encoding="utf-8")
        return None, {**video, "playlist_id": playlist["id"], "reason": "no_subtitles", "raw_path": str(out)}

    package = {
        **base,
        "lang": lang,
        "transcript_chars": len(transcript),
        "transcript": transcript,
        "status": "pending_card_generation",
    }
    out.write_text(json.dumps(package, ensure_ascii=False, indent=2), encoding="utf-8")
    return {**video, "playlist_id": playlist["id"], "lang": lang, "transcript_chars": len(transcript), "raw_path": str(out)}, None


def main() -> int:
    parser = argparse.ArgumentParser(description="Monitor YouTube playlists and prepare transcript packages")
    parser.add_argument("--playlist", default="", help="Single YouTube playlist URL override")
    parser.add_argument("--playlist-id", default="", help="id/slug for single --playlist")
    parser.add_argument("--playlist-title", default="", help="title for single --playlist")
    parser.add_argument("--category", default="ai-tools", help="default category for single --playlist")
    parser.add_argument("--tag", dest="tags", action="append", default=[], help="default tag for single --playlist; repeatable")
    parser.add_argument("--limit", type=int, default=0, help="max new videos to package across all playlists; 0 = all")
    parser.add_argument("--json", action="store_true", help="print machine-readable JSON summary")
    args = parser.parse_args()

    playlists = load_playlists(args)
    if not playlists:
        print("No YouTube playlists configured. Set config/youtube-playlists.json, YOUTUBE_PLAYLISTS_JSON, YOUTUBE_PLAYLISTS_FILE, YOUTUBE_PLAYLIST_URL, or --playlist.", file=sys.stderr)
        return 2

    RAW_DIR.mkdir(parents=True, exist_ok=True)
    YOUTUBE_CARD_DIR.mkdir(parents=True, exist_ok=True)

    done = existing_card_ids()
    already_raw = existing_raw_ids()
    packaged: list[dict] = []
    skipped: list[dict] = []
    playlist_summaries: list[dict] = []
    remaining = args.limit if args.limit else None

    with tempfile.TemporaryDirectory() as tmp:
        tmp_dir = Path(tmp)
        for playlist in playlists:
            videos = playlist_videos(playlist["url"])
            candidates = [v for v in videos if v["id"] not in done and v["id"] not in already_raw]
            if remaining is not None:
                candidates = candidates[:remaining]

            p_packaged = 0
            p_skipped = 0
            for video in candidates:
                item, skip = package_video(video, playlist, tmp_dir)
                already_raw.add(video["id"])
                if item:
                    packaged.append(item)
                    p_packaged += 1
                if skip:
                    skipped.append(skip)
                    p_skipped += 1
                if remaining is not None:
                    remaining -= 1
                    if remaining <= 0:
                        break

            playlist_summaries.append({
                "id": playlist["id"],
                "title": playlist["title"],
                "url": playlist["url"],
                "playlist_total": len(videos),
                "new_candidates": len(candidates),
                "packaged": p_packaged,
                "skipped": p_skipped,
                "category": playlist["category"],
                "tags": playlist["tags"],
            })
            if remaining is not None and remaining <= 0:
                break

    summary = {
        "playlist_count": len(playlists),
        "playlists": playlist_summaries,
        "existing_cards": len(done),
        "existing_raw": len(already_raw),
        "packaged": packaged,
        "skipped": skipped,
        "raw_dir": str(RAW_DIR),
    }

    if args.json:
        print(json.dumps(summary, ensure_ascii=False, indent=2))
    else:
        print(f"Playlists: {len(playlists)}")
        print(f"Existing YouTube cards: {len(done)}")
        print(f"Packaged new transcripts: {len(packaged)}")
        for ps in playlist_summaries:
            print(f"[{ps['id']}] total={ps['playlist_total']} candidates={ps['new_candidates']} packaged={ps['packaged']} skipped={ps['skipped']}")
        for item in packaged:
            print(f"+ [{item['playlist_id']}] {item['id']} {item['title']} ({item['lang']}, {item['transcript_chars']} chars)")
        for item in skipped:
            print(f"- [{item['playlist_id']}] {item['id']} {item['title']} skipped: {item['reason']}")
    return 0


import xkb_usage  # noqa: E402  — 量測誰在跑,見 scripts/xkb_usage.py

if __name__ == "__main__":
    xkb_usage.record(__file__)
    raise SystemExit(main())