summaryrefslogtreecommitdiff
path: root/youtube-transcript/yt_transcript.py
blob: 3215003657ead0db9519afe7975046c5f3119bf1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
#!/usr/bin/env python3
"""Extract clean plain-text transcripts from YouTube videos using yt-dlp."""

import argparse
from html import unescape
import re
import subprocess
import sys
import tempfile
from pathlib import Path


def download_subs(url: str, lang: str = "en") -> str:
    """Download auto-generated subtitles and return the VTT content."""
    with tempfile.TemporaryDirectory() as tmp:
        out = Path(tmp) / "sub"
        subprocess.run(
            [
                "yt-dlp",
                "--write-auto-sub",
                "--write-sub",
                "--skip-download",
                "--sub-lang", lang,
                "-o", str(out),
                url,
            ],
            check=True,
            capture_output=True,
            text=True,
        )

        # prefer manual subs over auto-generated
        for suffix in [f".{lang}.vtt", f".{lang}.vtt"]:
            vtt = Path(f"{out}{suffix}")
            if vtt.exists():
                return vtt.read_text(encoding="utf-8")

        # fallback: grab whatever vtt file was written
        vtts = list(Path(tmp).glob("*.vtt"))
        if vtts:
            return vtts[0].read_text(encoding="utf-8")

        raise FileNotFoundError("No subtitles found. Try --list-subs on the video.")


def parse_vtt(vtt_text: str) -> str:
    """Parse VTT content into clean deduplicated plain text."""
    lines: list[str] = []
    prev = ""

    for line in vtt_text.splitlines():
        # skip VTT header, metadata, timestamps, blank lines
        if re.match(r"^(WEBVTT|Kind:|Language:|\s*$)", line):
            continue
        if "-->" in line:
            continue

        # strip HTML-style tags (<c>, <b>, etc.) and formatting
        clean = re.sub(r"<[^>]+>", "", line)
        # decode HTML entities (&gt; &amp; &lt; etc.)
        clean = unescape(clean)
        # normalize whitespace
        clean = re.sub(r"\s+", " ", clean).strip()

        if not clean or clean == prev:
            continue

        # handle overlapping cues: skip if current line is a suffix of prev
        # or prev is a suffix of current (common in auto-subs)
        if prev and (prev.endswith(clean) or clean.startswith(prev)):
            # replace prev with the longer version
            if clean.startswith(prev) and clean != prev:
                lines[-1] = clean
                prev = clean
            continue

        lines.append(clean)
        prev = clean

    return "\n".join(lines)


def main():
    parser = argparse.ArgumentParser(description="Extract YouTube transcript as plain text")
    parser.add_argument("url", help="YouTube video URL")
    parser.add_argument("-l", "--lang", default="en", help="Subtitle language code (default: en)")
    parser.add_argument("-o", "--output", help="Output file (default: stdout)")
    args = parser.parse_args()

    try:
        vtt = download_subs(args.url, args.lang)
    except subprocess.CalledProcessError as e:
        print(f"yt-dlp error: {e.stderr}", file=sys.stderr)
        sys.exit(1)
    except FileNotFoundError as e:
        print(str(e), file=sys.stderr)
        sys.exit(1)

    transcript = parse_vtt(vtt)

    if args.output:
        Path(args.output).write_text(transcript, encoding="utf-8")
        print(f"Saved to {args.output}", file=sys.stderr)
    else:
        print(transcript)


if __name__ == "__main__":
    main()