@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.
- package/README.md +111 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2134 -0
- package/dist/index.js.map +1 -0
- package/package.json +67 -0
- package/skills-examples/general-assistant/SKILL.md +23 -0
- package/skills-examples/github-repo/SKILL.md +25 -0
- package/skills-examples/github-repo/scripts/github_repo.py +55 -0
- package/skills-examples/requirements.txt +8 -0
- package/skills-examples/site-status/SKILL.md +25 -0
- package/skills-examples/site-status/scripts/site_status.py +75 -0
- package/skills-examples/stock-price/SKILL.md +25 -0
- package/skills-examples/stock-price/scripts/stock_price.py +49 -0
- package/skills-examples/trending/SKILL.md +28 -0
- package/skills-examples/trending/scripts/trending.py +105 -0
- package/skills-examples/whois-lookup/SKILL.md +26 -0
- package/skills-examples/whois-lookup/scripts/whois_lookup.py +65 -0
- package/skills-examples/youtube-summary/SKILL.md +44 -0
- package/skills-examples/youtube-summary/scripts/summarize.py +312 -0
|
@@ -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()
|