← Files TubeCueARCHIVED FILE

skills/tubecue/scripts/fetch_transcript.py

5.38 KB · Sep 30, 2026 · 23:18 UTC

↓ Download file

#!/usr/bin/env python3

"""Fetch a complete timestamped YouTube transcript with stable status codes."""

from __future__ import annotations

import argparse
import re
import sys
from pathlib import Path
from urllib.parse import parse_qs, urlparse


VENDORED_LIBRARY_VERSION = "1.2.4"
VIDEO_ID_RE = re.compile(r"^[A-Za-z0-9_-]{11}$")
VENDOR_DIR = Path(__file__).resolve().with_name("vendor")
sys.path.insert(0, str(VENDOR_DIR))


def extract_video_id(value: str) -> str:
    value = value.strip()
    if VIDEO_ID_RE.fullmatch(value):
        return value

    parsed = urlparse(value)
    host = parsed.netloc.lower().split(":", 1)[0]
    if host.startswith("www."):
        host = host[4:]

    video_id = ""
    if host == "youtu.be":
        video_id = parsed.path.strip("/").split("/", 1)[0]
    elif host in {"youtube.com", "m.youtube.com", "music.youtube.com"}:
        if parsed.path.rstrip("/") == "/watch":
            video_id = parse_qs(parsed.query).get("v", [""])[0]
        else:
            match = re.match(
                r"^/(?:shorts|live|embed)/([A-Za-z0-9_-]{11})(?:/|$)",
                parsed.path,
            )
            video_id = match.group(1) if match else ""

    if not VIDEO_ID_RE.fullmatch(video_id):
        raise ValueError("Unsupported or invalid YouTube URL")
    return video_id


def format_timestamp(seconds: float) -> str:
    milliseconds = max(0, round(float(seconds) * 1000))
    hours, remainder = divmod(milliseconds, 3_600_000)
    minutes, remainder = divmod(remainder, 60_000)
    secs, millis = divmod(remainder, 1000)
    return f"{hours:02d}:{minutes:02d}:{secs:02d}.{millis:03d}"


def language_matches(code: str, preferred: str) -> bool:
    code = code.lower()
    preferred = preferred.lower()
    return code == preferred or code.split("-", 1)[0] == preferred.split("-", 1)[0]


def select_transcript(available: list, preferred_languages: list[str]):
    manual = [track for track in available if not track.is_generated]
    generated = [track for track in available if track.is_generated]

    for preferred in preferred_languages:
        for group in (manual, generated):
            match = next(
                (track for track in group if language_matches(track.language_code, preferred)),
                None,
            )
            if match is not None:
                return match

    if manual:
        return manual[0]
    if generated:
        return generated[0]
    return None


def print_error(label: str, message: str, exit_code: int) -> int:
    print(f"{label}: {message}", file=sys.stderr)
    return exit_code


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(add_help=True)
    parser.add_argument("video", help="YouTube URL or 11-character video ID")
    parser.add_argument(
        "--languages",
        nargs="+",
        default=["en"],
        metavar="CODE",
        help="preferred language codes in priority order (default: en)",
    )
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    try:
        video_id = extract_video_id(args.video)
    except ValueError as exc:
        return print_error("INVALID_URL", str(exc), 2)

    try:
        from youtube_transcript_api import (
            AgeRestricted,
            IpBlocked,
            RequestBlocked,
            TranscriptsDisabled,
            VideoUnavailable,
            VideoUnplayable,
            YouTubeTranscriptApi,
        )
    except (ImportError, ModuleNotFoundError) as exc:
        return print_error("DEPENDENCY_MISSING", exc.name or str(exc), 7)

    try:
        available = list(YouTubeTranscriptApi().list(video_id))
        selected = select_transcript(available, args.languages)
        if selected is None:
            return print_error("NO_CAPTIONS", "No caption track is available.", 3)
        fetched = selected.fetch()
    except TranscriptsDisabled:
        return print_error("NO_CAPTIONS", "Captions are disabled for this video.", 3)
    except (RequestBlocked, IpBlocked):
        return print_error(
            "BLOCKED",
            "YouTube blocked direct transcript retrieval; use the Tactiq fallback.",
            4,
        )
    except (AgeRestricted, VideoUnavailable, VideoUnplayable):
        return print_error("VIDEO_UNAVAILABLE", "The video is unavailable or restricted.", 5)
    except Exception as exc:
        return print_error("DIRECT_RETRIEVAL_FAILED", str(exc).splitlines()[0], 6)

    snippets = [snippet for snippet in fetched if " ".join(snippet.text.split())]
    if not snippets:
        return print_error("NO_CAPTIONS", "The selected caption track is empty.", 3)

    last_end = max(snippet.start + snippet.duration for snippet in snippets)
    print(f"VIDEO_ID: {video_id}")
    print(f"URL: https://www.youtube.com/watch?v={video_id}")
    print(f"BACKEND: youtube-transcript-api {VENDORED_LIBRARY_VERSION}")
    display_language = re.sub(
        r"\s+\(auto-generated\)\s*$", "", selected.language, flags=re.IGNORECASE
    )
    print(f"LANGUAGE: {display_language} ({selected.language_code})")
    print("CAPTION_TYPE: " + ("auto-generated" if selected.is_generated else "manual"))
    print(f"CUE_COUNT: {len(snippets)}")
    print(f"LAST_TIMESTAMP: {format_timestamp(last_end)}")
    print("--- TRANSCRIPT ---")
    for snippet in snippets:
        text = " ".join(snippet.text.split())
        print(f"[{format_timestamp(snippet.start)}] {text}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

SHA-256: 74bc743b40d93587243b59a20b6b58f10ca58056627e96ba00423379f8084d09