@elisym/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,312 @@
1
+ #!/usr/bin/env python3
2
+ """YouTube transcript extractor with chunked output.
3
+
4
+ Usage:
5
+ python3 summarize.py <youtube_url> --lang auto # fetch & cache, return metadata
6
+ python3 summarize.py <youtube_url> --chunk 0 # return chunk 0
7
+ python3 summarize.py <youtube_url> --chunk 1 # return chunk 1
8
+
9
+ Extracts subtitles/transcript from a YouTube video.
10
+ Long transcripts are split into chunks for LLM processing.
11
+ """
12
+
13
+ import argparse
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import re
18
+ import subprocess
19
+ import sys
20
+ import tempfile
21
+
22
+ CHUNK_SIZE = 30_000 # ~7500 tokens, safe for rate limits
23
+ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
24
+ CACHE_DIR = os.path.join(SCRIPT_DIR, ".cache")
25
+ COOKIES_FILE = os.path.join(os.path.dirname(SCRIPT_DIR), "cookies.txt")
26
+
27
+
28
+ def _cookies_args() -> list[str]:
29
+ """Return yt-dlp --cookies args if cookies.txt exists."""
30
+ if os.path.exists(COOKIES_FILE):
31
+ return ["--cookies", COOKIES_FILE]
32
+ return []
33
+
34
+
35
+ def video_id_from_url(url: str) -> str:
36
+ """Extract video ID from YouTube URL."""
37
+ m = re.search(r"(?:v=|youtu\.be/|shorts/)([a-zA-Z0-9_-]{11})", url)
38
+ if m:
39
+ return m.group(1)
40
+ return hashlib.md5(url.encode()).hexdigest()[:12]
41
+
42
+
43
+ def cache_path(url: str) -> str:
44
+ """Path to cached transcript JSON."""
45
+ os.makedirs(CACHE_DIR, exist_ok=True)
46
+ return os.path.join(CACHE_DIR, f"{video_id_from_url(url)}.json")
47
+
48
+
49
+ def load_cache(url: str) -> dict | None:
50
+ path = cache_path(url)
51
+ if os.path.exists(path):
52
+ with open(path, "r", encoding="utf-8") as f:
53
+ return json.load(f)
54
+ return None
55
+
56
+
57
+ def save_cache(url: str, data: dict):
58
+ path = cache_path(url)
59
+ with open(path, "w", encoding="utf-8") as f:
60
+ json.dump(data, f, ensure_ascii=False)
61
+
62
+
63
+ def get_video_info(url: str) -> dict:
64
+ """Fetch video title, duration, description, and language info."""
65
+ result = subprocess.run(
66
+ [sys.executable, "-m", "yt_dlp", "--dump-json", "--no-download", "--no-check-formats", *_cookies_args(), url],
67
+ capture_output=True, text=True, timeout=30,
68
+ )
69
+ if result.returncode != 0:
70
+ print(f"yt-dlp --dump-json failed (code {result.returncode}): {result.stderr.strip()}", file=sys.stderr)
71
+ # Fallback: return minimal info if --dump-json fails (e.g. EJS solver issues)
72
+ vid = video_id_from_url(url)
73
+ return {
74
+ "title": f"Video {vid}",
75
+ "duration": 0,
76
+ "channel": "Unknown",
77
+ "description": "",
78
+ "language": None,
79
+ "subtitles": [],
80
+ "automatic_captions": [],
81
+ }
82
+ info = json.loads(result.stdout)
83
+ return {
84
+ "title": info.get("title", "Unknown"),
85
+ "duration": info.get("duration", 0),
86
+ "channel": info.get("channel", "Unknown"),
87
+ "description": (info.get("description") or "")[:500],
88
+ "language": info.get("language"),
89
+ "subtitles": list((info.get("subtitles") or {}).keys()),
90
+ "automatic_captions": list((info.get("automatic_captions") or {}).keys()),
91
+ }
92
+
93
+
94
+ def detect_language(video_info: dict) -> str:
95
+ lang = video_info.get("language")
96
+ if lang:
97
+ return lang.split("-")[0].lower()
98
+ subs = video_info.get("subtitles", [])
99
+ if subs:
100
+ return subs[0]
101
+ auto = video_info.get("automatic_captions", [])
102
+ if auto:
103
+ return auto[0]
104
+ return "en"
105
+
106
+
107
+ def fetch_subtitles(url: str, lang: str) -> str | None:
108
+ lang_attempts = [lang]
109
+ if lang != "en":
110
+ lang_attempts.append("en")
111
+ for try_lang in lang_attempts:
112
+ result = _try_fetch_subs(url, try_lang)
113
+ if result:
114
+ return result
115
+ return _try_fetch_subs_any(url)
116
+
117
+
118
+ def _try_fetch_subs(url: str, lang: str) -> str | None:
119
+ with tempfile.TemporaryDirectory() as tmpdir:
120
+ output_path = os.path.join(tmpdir, "subs")
121
+ subprocess.run(
122
+ [
123
+ sys.executable, "-m", "yt_dlp",
124
+ "--write-auto-sub", "--write-sub",
125
+ "--sub-lang", lang,
126
+ "--sub-format", "vtt",
127
+ "--skip-download",
128
+ *_cookies_args(),
129
+ "-o", output_path, url,
130
+ ],
131
+ capture_output=True, text=True, timeout=60,
132
+ )
133
+ for f in os.listdir(tmpdir):
134
+ if f.endswith(".vtt"):
135
+ text = parse_vtt(os.path.join(tmpdir, f))
136
+ if text and len(text.strip()) > 50:
137
+ return text
138
+ return None
139
+
140
+
141
+ def _try_fetch_subs_any(url: str) -> str | None:
142
+ with tempfile.TemporaryDirectory() as tmpdir:
143
+ output_path = os.path.join(tmpdir, "subs")
144
+ subprocess.run(
145
+ [
146
+ sys.executable, "-m", "yt_dlp",
147
+ "--write-auto-sub", "--write-sub",
148
+ "--sub-format", "vtt",
149
+ "--skip-download",
150
+ *_cookies_args(),
151
+ "-o", output_path, url,
152
+ ],
153
+ capture_output=True, text=True, timeout=60,
154
+ )
155
+ for f in os.listdir(tmpdir):
156
+ if f.endswith(".vtt"):
157
+ text = parse_vtt(os.path.join(tmpdir, f))
158
+ if text and len(text.strip()) > 50:
159
+ return text
160
+ return None
161
+
162
+
163
+ def parse_vtt(path: str) -> str:
164
+ lines = []
165
+ seen = set()
166
+ with open(path, "r", encoding="utf-8") as f:
167
+ for line in f:
168
+ line = line.strip()
169
+ if not line or line.startswith("WEBVTT") or line.startswith("Kind:") or line.startswith("Language:"):
170
+ continue
171
+ if re.match(r"^\d{2}:\d{2}", line) or "-->" in line:
172
+ continue
173
+ clean = re.sub(r"<[^>]+>", "", line)
174
+ if clean and clean not in seen:
175
+ seen.add(clean)
176
+ lines.append(clean)
177
+ return " ".join(lines)
178
+
179
+
180
+ def transcribe_audio(url: str) -> str:
181
+ try:
182
+ import whisper
183
+ except ImportError:
184
+ raise RuntimeError(
185
+ "No subtitles available and Whisper is not installed. "
186
+ "Install with: pip install openai-whisper"
187
+ )
188
+ with tempfile.TemporaryDirectory() as tmpdir:
189
+ audio_path = os.path.join(tmpdir, "audio.mp3")
190
+ print("Downloading audio...", file=sys.stderr)
191
+ result = subprocess.run(
192
+ [sys.executable, "-m", "yt_dlp", "-x", "--audio-format", "mp3", "--audio-quality", "5", *_cookies_args(), "-o", audio_path, url],
193
+ capture_output=True, text=True, timeout=300,
194
+ )
195
+ if result.returncode != 0:
196
+ raise RuntimeError(f"Audio download failed: {result.stderr.strip()}")
197
+ actual_path = audio_path
198
+ if not os.path.exists(actual_path):
199
+ for f in os.listdir(tmpdir):
200
+ if f.startswith("audio"):
201
+ actual_path = os.path.join(tmpdir, f)
202
+ break
203
+ print("Transcribing with Whisper...", file=sys.stderr)
204
+ model = whisper.load_model("base")
205
+ result = model.transcribe(actual_path)
206
+ return result["text"]
207
+
208
+
209
+ def split_chunks(text: str) -> list[str]:
210
+ """Split text into chunks, breaking at sentence boundaries."""
211
+ if len(text) <= CHUNK_SIZE:
212
+ return [text]
213
+ chunks = []
214
+ while text:
215
+ if len(text) <= CHUNK_SIZE:
216
+ chunks.append(text)
217
+ break
218
+ # Find last sentence boundary within chunk size
219
+ cut = CHUNK_SIZE
220
+ for sep in [". ", "! ", "? ", "\n"]:
221
+ pos = text.rfind(sep, 0, CHUNK_SIZE)
222
+ if pos > CHUNK_SIZE // 2:
223
+ cut = pos + len(sep)
224
+ break
225
+ chunks.append(text[:cut])
226
+ text = text[cut:]
227
+ return chunks
228
+
229
+
230
+ def main():
231
+ parser = argparse.ArgumentParser(description="Extract transcript from a YouTube video")
232
+ parser.add_argument("url", help="YouTube video URL")
233
+ parser.add_argument("--lang", default="auto", help="Subtitle language or 'auto'")
234
+ parser.add_argument("--chunk", type=int, default=None, help="Return specific chunk (0-indexed)")
235
+ args = parser.parse_args()
236
+
237
+ if not re.search(r"(youtube\.com|youtu\.be)", args.url):
238
+ print("Error: not a valid YouTube URL", file=sys.stderr)
239
+ sys.exit(1)
240
+
241
+ # If requesting a specific chunk, read from cache
242
+ if args.chunk is not None:
243
+ cached = load_cache(args.url)
244
+ if not cached:
245
+ print("Error: no cached transcript. Call without --chunk first.", file=sys.stderr)
246
+ sys.exit(1)
247
+ chunks = cached["chunks"]
248
+ if args.chunk < 0 or args.chunk >= len(chunks):
249
+ print(f"Error: chunk {args.chunk} out of range (0-{len(chunks)-1})", file=sys.stderr)
250
+ sys.exit(1)
251
+ output = json.dumps({
252
+ "title": cached["title"],
253
+ "chunk": args.chunk,
254
+ "total_chunks": len(chunks),
255
+ "transcript": chunks[args.chunk],
256
+ }, ensure_ascii=False, indent=2)
257
+ print(output)
258
+ return
259
+
260
+ # Fetch transcript
261
+ print("Fetching video info...", file=sys.stderr)
262
+ video_info = get_video_info(args.url)
263
+ print(f"Video: {video_info['title']} ({video_info['duration'] // 60} min)", file=sys.stderr)
264
+
265
+ if args.lang == "auto":
266
+ lang = detect_language(video_info)
267
+ print(f"Detected language: {lang}", file=sys.stderr)
268
+ else:
269
+ lang = args.lang
270
+
271
+ print(f"Fetching subtitles (lang={lang})...", file=sys.stderr)
272
+ transcript = fetch_subtitles(args.url, lang)
273
+
274
+ if not transcript:
275
+ print("No subtitles found, trying Whisper transcription...", file=sys.stderr)
276
+ try:
277
+ transcript = transcribe_audio(args.url)
278
+ except RuntimeError as e:
279
+ print(f"Error: {e}", file=sys.stderr)
280
+ sys.exit(1)
281
+
282
+ if not transcript or len(transcript.strip()) < 50:
283
+ print("Error: could not extract transcript from video", file=sys.stderr)
284
+ sys.exit(1)
285
+
286
+ chunks = split_chunks(transcript)
287
+ print(f"Transcript: {len(transcript)} chars, {len(chunks)} chunk(s)", file=sys.stderr)
288
+
289
+ # Cache for chunk retrieval
290
+ save_cache(args.url, {
291
+ "title": video_info["title"],
292
+ "channel": video_info["channel"],
293
+ "duration_min": video_info["duration"] // 60,
294
+ "language": lang,
295
+ "chunks": chunks,
296
+ })
297
+
298
+ # Return metadata + first chunk inline
299
+ output = json.dumps({
300
+ "title": video_info["title"],
301
+ "channel": video_info["channel"],
302
+ "duration_min": video_info["duration"] // 60,
303
+ "language": lang,
304
+ "total_chunks": len(chunks),
305
+ "chunk": 0,
306
+ "transcript": chunks[0],
307
+ }, ensure_ascii=False, indent=2)
308
+ print(output)
309
+
310
+
311
+ if __name__ == "__main__":
312
+ main()