Skill: hushenglang/extract-youtube-transcript (version 2.1.0)

--- name: extract-youtube-transcript version: 2.1.0 description: Extract plain-text transcripts from YouTube videos using a local Python script. Use when the user wants to fetch, extract, or get a transcript from a YouTube video URL, analyze YouTube video content as text, or needs subtitles/captions from a video. --- # Extract YouTube Transcript Fetches plain-text transcripts from YouTube videos using `extract_youtube_transcript.py` in this skill folder. ## Dependency ```bash pip show youtube-transcript-api &>/dev/null || pip install youtube-transcript-api ``` ## Quick Start ```bash python extract_youtube_transcript.py "https://www.youtube.com/watch?v=VIDEO_ID" ``` Supported URL formats: `youtube.com/watch?v=`, `youtu.be/`, `/embed/`, `/live/`, `/shorts/`, or a raw 11-char video ID. ## Common Patterns ### Fetch with preferred language(s) ```bash python extract_youtube_transcript.py "URL" --lang zh-Hant en ``` Pass languages in priority order. Falls back to any available transcript if none match. ### Save transcript to file ```bash python extract_youtube_transcript.py "URL" --output transcript.txt ``` Text is printed to stdout and also written to the file. ### List available languages first ```bash python extract_youtube_transcript.py "URL" --list-langs ``` Use this to discover what language codes are available before fetching. ## Language Codes | Code | Language | |------|----------| | `en` | English | | `zh-Hant` | Traditional Chinese | | `zh-Hans` | Simplified Chinese | | `ja` | Japanese | | `ko` | Korean | | `es` | Spanish | ## Error Handling | Error | Cause | Recovery | |-------|-------|----------| | `TranscriptsDisabled` | Owner disabled captions | No transcript available | | `NoTranscriptFound` | Requested lang not found | Run `--list-langs`, pick an available code | | `VideoUnavailable` | Video is private/deleted | Verify the URL | | `AgeRestricted` | Age-gated video | Auth not supported; no workaround | | `InvalidVideoId` | Malformed URL or ID | Check the URL format | ## Workflow 1. Try a direct fetch first 2. If `NoTranscriptFound`, run `--list-langs` to see available codes, then re-fetch with `--lang <code>` 3. Save long transcripts to a file with `--output` for easier downstream processing

path: scripts/extract_youtube_transcript.py
content: #!/usr/bin/env python3
"""
YouTube Transcript Extractor

Fetches the transcript of a YouTube video from its URL and prints
the plain text. Optionally writes the result to a file.

Usage:
    python extract_youtube_transcript.py "https://www.youtube.com/watch?v=VIDEO_ID"
    python extract_youtube_transcript.py "https://youtu.be/VIDEO_ID" --lang zh-Hant en
    python extract_youtube_transcript.py "https://www.youtube.com/watch?v=VIDEO_ID" --output transcript.txt
    python extract_youtube_transcript.py "https://www.youtube.com/watch?v=VIDEO_ID" --list-langs
    python extract_youtube_transcript.py "https://www.youtube.com/watch?v=VIDEO_ID" --cookies youtube_cookies.txt
"""

import argparse
import sys
from http.cookiejar import MozillaCookieJar
from urllib.parse import urlparse, parse_qs

import requests
from youtube_transcript_api import YouTubeTranscriptApi
from youtube_transcript_api._errors import (
    TranscriptsDisabled,
    NoTranscriptFound,
    VideoUnavailable,
    AgeRestricted,
    InvalidVideoId,
)


def extract_video_id(url: str) -> str:
    """
    Parse a YouTube video ID from common URL formats:
      - https://www.youtube.com/watch?v=VIDEO_ID
      - https://youtu.be/VIDEO_ID
      - https://www.youtube.com/embed/VIDEO_ID
      - https://www.youtube.com/live/VIDEO_ID
      - Raw video ID (11 chars, no slash)
    """
    url = url.strip()

    parsed = urlparse(url)

    # Raw video ID passed directly (no scheme)
    if not parsed.scheme and "/" not in url:
        return url

    host = parsed.netloc.lower().removeprefix("www.")

    if host in ("youtube.com", "m.youtube.com"):
        for prefix in ("/embed/", "/live/", "/shorts/"):
            if parsed.path.startswith(prefix):
                video_id = parsed.path.split(prefix, 1)[1].split("/")[0]
                if video_id:
                    return video_id
        # /watch?v=VIDEO_ID
        qs = parse_qs(parsed.query)
        ids = qs.get("v", [])
        if ids:
            return ids[0]

    elif host == "youtu.be":
        # youtu.be/VIDEO_ID
        video_id = parsed.path.lstrip("/").split("/")[0]
        if video_id:
            return video_id

    raise ValueError(
        f"Could not extract a video ID from the provided URL: {url}\n"
        "Supported formats:\n"
        "  https://www.youtube.com/watch?v=VIDEO_ID\n"
        "  https://youtu.be/VIDEO_ID\n"
        "  https://www.youtube.com/embed/VIDEO_ID\n"
        "  https://www.youtube.com/live/VIDEO_ID\n"
        "  https://www.youtube.com/shorts/VIDEO_ID"
    )


def format_transcript(transcript) -> str:
    """Join all snippet texts into a single readable string."""
    return " ".join(snippet.text for snippet in transcript)


def make_api(cookies: str | None) -> YouTubeTranscriptApi:
    """Instantiate the API, optionally with a Netscape cookies file."""
    if cookies:
        jar = MozillaCookieJar(cookies)
        jar.load(ignore_discard=True, ignore_expires=True)
        session = requests.Session()
        session.cookies = jar
        return YouTubeTranscriptApi(http_client=session)
    return YouTubeTranscriptApi()


def list_available_languages(video_id: str, cookies: str | None) -> None:
    """Print all available transcript languages for a video."""
    api = make_api(cookies)
    transcript_list = api.list(video_id)
    print(f"Available transcripts for video '{video_id}':\n")
    for t in transcript_list:
        kind = "auto-generated" if t.is_generated else "manual"
        print(f"  [{t.language_code}] {t.language} ({kind})")


def fetch_transcript(video_id: str, languages: list[str], cookies: str | None) -> str:
    """Fetch transcript text, trying preferred languages in order."""
    api = make_api(cookies)

    try:
        transcript = api.fetch(video_id, languages=languages)
    except NoTranscriptFound:
        # Fall back to any available transcript when preferred langs are absent
        transcript_list = api.list(video_id)
        transcript = transcript_list.find_transcript(
            [t.language_code for t in transcript_list]
        ).fetch()

    return format_transcript(transcript)


def main() -> None:
    parser = argparse.ArgumentParser(
        description="Extract the transcript of a YouTube video.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=__doc__,
    )
    parser.add_argument("url", help="YouTube video URL or video ID")
    parser.add_argument(
        "--lang",
        nargs="+",
        default=["en"],
        metavar="LANG",
        help="Language code(s) in order of preference (default: en). "
             "Example: --lang zh-Hant en",
    )
    parser.add_argument(
        "--output",
        metavar="FILE",
        help="Write transcript to FILE instead of (or in addition to) stdout",
    )
    parser.add_argument(
        "--list-langs",
        action="store_true",
        help="List all available transcript languages for the video and exit",
    )
    parser.add_argument(
        "--cookies",
        metavar="FILE",
        help="Path to a Netscape-format cookies file for YouTube authentication. "
             "Useful when requests are blocked on cloud/server IPs.",
    )

    args = parser.parse_args()

    # --- Step 1: extract video ID ---
    try:
        video_id = extract_video_id(args.url)
    except ValueError as exc:
        print(f"Error: {exc}", file=sys.stderr)
        sys.exit(1)

    # --- Step 2: list languages if requested ---
    if args.list_langs:
        try:
            list_available_languages(video_id, args.cookies)
        except VideoUnavailable:
            print(f"Error: Video '{video_id}' is unavailable.", file=sys.stderr)
            sys.exit(1)
        except Exception as exc:
            print(f"Error listing transcripts: {exc}", file=sys.stderr)
            sys.exit(1)
        return

    # --- Step 3: fetch transcript ---
    try:
        text = fetch_transcript(video_id, args.lang, args.cookies)
    except TranscriptsDisabled:
        print(
            f"Error: Transcripts/subtitles are disabled for video '{video_id}'.",
            file=sys.stderr,
        )
        sys.exit(1)
    except NoTranscriptFound:
        print(
            f"Error: No transcript found for video '{video_id}' "
            f"in requested language(s): {args.lang}.\n"
            f"Run with --list-langs to see what is available.",
            file=sys.stderr,
        )
        sys.exit(1)
    except VideoUnavailable:
        print(f"Error: Video '{video_id}' is unavailable.", file=sys.stderr)
        sys.exit(1)
    except AgeRestricted:
        print(
            f"Error: Video '{video_id}' is age-restricted. "
            "Authentication is required to access its transcript.",
            file=sys.stderr,
        )
        sys.exit(1)
    except InvalidVideoId:
        print(
            f"Error: '{video_id}' is not a valid YouTube video ID.",
            file=sys.stderr,
        )
        sys.exit(1)
    except Exception as exc:
        print(f"Unexpected error: {exc}", file=sys.stderr)
        sys.exit(1)

    # --- Step 4: output ---
    print(text)

    if args.output:
        try:
            with open(args.output, "w", encoding="utf-8") as fh:
                fh.write(text)
                fh.write("\n")
            print(f"\nTranscript saved to: {args.output}", file=sys.stderr)
        except OSError as exc:
            print(f"Error writing to file '{args.output}': {exc}", file=sys.stderr)
            sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/youtube_cookies.txt
content: # Netscape HTTP Cookie File
# https://curl.haxx.se/rfc/cookie_spec.html
# This is a generated file! Do not edit.

.youtube.com	TRUE	/	TRUE	1803255432	LOGIN_INFO	AFmmF2swRgIhAJVEjkq5rfq3WsC1rZOpck_o2BBDhe8OySnsT0UeqGD8AiEAmiU8axLSmkkAqrrzcGH_PffZkZeocoa4bWjUtBhG2Fo:QUQ3MjNmeS1uekNLc0tPYmVLako3aXg3anB5V2cxTFNmVkU1X3ZZYlhsUG5adXo2VlBUSGdMa2ZoVVY0MndadTVncE5qbU1rekVZYWJkX1JYUERBM3JFcXRYUUVvZ3VkUHphanJrU2R0UGJzQ3J3YzAtZ20xenQ4RE9lV2JoaDA3MDZUSDVOcGV1N21IQndDc0hrOVpaX083UlZmbEVVLUJR
.youtube.com	TRUE	/	FALSE	1806615731	HSID	AY0qc-yXlLlCopmsP
.youtube.com	TRUE	/	TRUE	1806615731	SSID	AclQy2tplG_G4a9hx
.youtube.com	TRUE	/	FALSE	1806615731	APISID	436PdSIBD5o7jxAd/A6gJrqIUmnT9D0gYJ
.youtube.com	TRUE	/	TRUE	1806615731	SAPISID	drB_creNy1_7gF_B/AsZs3vazm7n7GQ1TZ
.youtube.com	TRUE	/	TRUE	1806615731	__Secure-1PAPISID	drB_creNy1_7gF_B/AsZs3vazm7n7GQ1TZ
.youtube.com	TRUE	/	TRUE	1806615731	__Secure-3PAPISID	drB_creNy1_7gF_B/AsZs3vazm7n7GQ1TZ
.youtube.com	TRUE	/	FALSE	0	wide	0
.youtube.com	TRUE	/	FALSE	1806615731	SID	g.a0007QgMwwznDKYSlVZs65pMenyWJ0ToyfpYzsUFwuihE4juaxDol3VX_LHVAGRFKfzkGjNlQAACgYKAQcSARESFQHGX2MiLx54NNUp1VexBDkntlNqLhoVAUF8yKrZ_6ytweMnhTMqXNRyQwbC0076
.youtube.com	TRUE	/	TRUE	1806615731	__Secure-1PSID	g.a0007QgMwwznDKYSlVZs65pMenyWJ0ToyfpYzsUFwuihE4juaxDord7VH9OYplSx2cUh-cT8bAACgYKAdQSARESFQHGX2MiR-kofaDPbwAdhp9-NbmFRBoVAUF8yKofiNZgF-c3yVeGLGBUPal-0076
.youtube.com	TRUE	/	TRUE	1806615731	__Secure-3PSID	g.a0007QgMwwznDKYSlVZs65pMenyWJ0ToyfpYzsUFwuihE4juaxDoHy1dCLeV2kZyOAMS82FnoAACgYKAeYSARESFQHGX2MiToD8n1y1Jd17Dk1EuoeHCRoVAUF8yKr-m44FBoUyeF4TR07dDP570076
.youtube.com	TRUE	/	TRUE	1807442525	PREF	f6=40000080&f7=4100&tz=Asia.Hong_Kong&repeat=NONE&library_tab_browse_id=FEmusic_liked_playlists&f4=4000000&autoplay=true&f5=30000
.youtube.com	TRUE	/	TRUE	1772882709	CONSISTENCY	AG2Tqf8XeM_b6DAG-NCD6SuB1rx8fHZ_mu-6C-kf7Tz8i6b2Ol4vM1RHheggyroFe5_AnwmW2ea6ps2tlytFynDnzPszPlZbBwpwnT2kLA6deGh2MT1VQXIZKrkygonpUH6LErP7a2uXCJceoj2Rpwdf
.youtube.com	TRUE	/	TRUE	1804418228	__Secure-1PSIDTS	sidts-CjUBBj1CYhXMdQpJEGFhYS6hXwXVotkQtRGAcpntgBeKgRlLL0R26bCDRWcnCprFsl6xLCOfCBAA
.youtube.com	TRUE	/	TRUE	1804418228	__Secure-3PSIDTS	sidts-CjUBBj1CYhXMdQpJEGFhYS6hXwXVotkQtRGAcpntgBeKgRlLL0R26bCDRWcnCprFsl6xLCOfCBAA
.youtube.com	TRUE	/	FALSE	1804418538	SIDCC	AKEyXzXCBOjmw0ObcrcEgFxDrTV6_0buHAyv5IlAMUmKkKWGUJoVQLK50MEeiddlZpJtkBxR9z0
.youtube.com	TRUE	/	TRUE	1804418538	__Secure-1PSIDCC	AKEyXzVFjF7buR9yUeAGkEX5X3Ik5qmWQKF105esRtDup-ulYhvqXmQQZphJZNux3YgO5dz0N3LA
.youtube.com	TRUE	/	TRUE	1804418538	__Secure-3PSIDCC	AKEyXzVPDxnxdl1MkA0-LUiwYIDwzqkoQG-MUbD-9C5zO9tWSzEbQRQchRrlp3aNhkO8DeOr3BAC
.youtube.com	TRUE	/	TRUE	1788434523	VISITOR_PRIVACY_METADATA	CgJISxIEGgAgRA%3D%3D
.youtube.com	TRUE	/	TRUE	1788434523	VISITOR_INFO1_LIVE	ZdB2VZxueBk
.youtube.com	TRUE	/	TRUE	0	YSC	VROcB7-s2BQ
.youtube.com	TRUE	/	TRUE	1788434098	__Secure-ROLLOUT_TOKEN	CIjRhMOJn9KdcRCxmtSiouqKAxjhsLC91I2TAw%3D%3D


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)