返回 last30days-skill
youtube_yt.py
根目录 / skills / last30days / scripts / lib / youtube_yt.py
1 """YouTube search and transcript extraction via yt-dlp for the v3.0.0 pipeline.
2
3 Uses yt-dlp (https://github.com/yt-dlp/yt-dlp) for both YouTube search and
4 transcript extraction. No API keys needed — just have yt-dlp installed.
5
6 Inspired by Peter Steinberger's toolchain approach (yt-dlp + summarize CLI).
7 """
8
9 import copy
10 import json
11 import math
12 import os
13 import re
14 import shlex
15 import shutil
16 import sys
17 import tempfile
18 import threading
19 import time
20 import urllib.error
21 import urllib.request
22 from concurrent.futures import ThreadPoolExecutor, as_completed
23 from pathlib import Path
24 from typing import Any, Dict, List, Optional, Set, Tuple
25
26 # Depth configurations: how many videos to search / transcribe
27 DEPTH_CONFIG = {
28 "quick": 6,
29 "default": 8,
30 "deep": 40,
31 }
32
33 TRANSCRIPT_LIMITS = {
34 "quick": 0,
35 "default": 2,
36 "deep": 8,
37 }
38
39 # Cumulative yt-dlp transcript-fetch stats for the current process. The final
40 # report only sees post-pruning items, so it can't distinguish "fetches failed
41 # (stale binary)" from "fetches succeeded but the videos were pruned later".
42 # quality_nudge reads these via last30days.py to suppress the stale-yt-dlp
43 # nudge when every attempted fetch actually succeeded. yt-dlp path only: the
44 # nudge diagnoses the local binary, not the ScrapeCreators API.
45 _TRANSCRIPT_FETCH_STATS = {"attempts": 0, "failures": 0}
46
47
48 def get_transcript_fetch_stats() -> Dict[str, int]:
49 """Return cumulative transcript-fetch stats for this process."""
50 return dict(_TRANSCRIPT_FETCH_STATS)
51
52
53 def reset_transcript_fetch_stats() -> None:
54 """Reset cumulative transcript-fetch stats (used by tests)."""
55 _TRANSCRIPT_FETCH_STATS["attempts"] = 0
56 _TRANSCRIPT_FETCH_STATS["failures"] = 0
57
58 # Max words to keep from each transcript
59 TRANSCRIPT_MAX_WORDS = 5000
60
61 from . import dates, health, http, log, subproc
62 from .query import infer_query_intent
63
64 from .relevance import token_overlap_relevance as _compute_relevance
65
66 # yt-dlp transcript-fetch resilience. A non-zero yt-dlp exit means a real fetch
67 # error (rate-limit / bot-check / network), NOT "no captions" — yt-dlp exits 0
68 # with no file for a video that genuinely lacks the requested captions. So we
69 # capture the returncode, log a classified reason instead of failing silently,
70 # and retry transient errors a couple of times with a small per-video staggered
71 # backoff.
72 _TRANSCRIPT_MAX_RETRIES = 2
73 _TRANSCRIPT_BACKOFF_BASE = 2.0 # seconds; multiplied by (attempt + 1)
74 _TRANSCRIPT_TIMEOUT = 30 # seconds per yt-dlp attempt (keyless: no fallback to fail over to)
75 _TRANSCRIPT_FAST_TIMEOUT = 12 # seconds per attempt when a ScrapeCreators fallback exists
76 _SEARCH_TIMEOUT = 120 # seconds per ytsearch metadata extraction
77 # Comparison-mode fan-out (and nested transcript/comment pools) can stampede the
78 # same throttled YouTube IP. Cap concurrent yt-dlp processes process-wide.
79 _YTDLP_MAX_CONCURRENT = 2
80 _ytdlp_slots = threading.Semaphore(_YTDLP_MAX_CONCURRENT)
81 # In-run search cache: comparison mode re-issues identical ytsearch queries from
82 # every entity sub-run; cache hits avoid the redundant expensive --dump-json work.
83 # Inflight coalescing prevents N concurrent identical searches from all missing
84 # the cache and stampeding YouTube together.
85 _search_cache: Dict[Tuple[str, int, str], Dict[str, Any]] = {}
86 _search_inflight: Dict[Tuple[str, int, str], tuple[threading.Event, list]] = {}
87 _search_cache_lock = threading.Lock()
88 # Comments are enrichment, not core evidence: keep the budget tight so a slow
89 # comment API can never dominate a run's wall clock (bounded to 3 videos).
90 _COMMENT_TIMEOUT = 20
91 _SC_LOW_CREDIT_THRESHOLD = 50 # warn once ScrapeCreators credits drop below this
92 # Transient = worth retrying (and definitely not "no captions").
93 _TRANSIENT_RE = re.compile(
94 r"429|too many requests|sign in to confirm|not a bot|rate.?limit"
95 r"|temporarily|try again|timed out|timeout|connection|unable to (extract|download)"
96 r"|failed to (extract|download)|got error|read error",
97 re.IGNORECASE,
98 )
99 # A genuine no-captions signal — treat as no captions, never retry/surface.
100 _NO_CAPTION_RE = re.compile(
101 r"no subtitles|requested (format|language)|there'?s no .*subtitles",
102 re.IGNORECASE,
103 )
104
105
106 def extract_transcript_highlights(transcript: str, topic: str, limit: int = 5) -> list[str]:
107 """Extract quotable highlights from a YouTube transcript.
108
109 Filters filler (subscribe, welcome back, etc.), scores sentences by
110 specificity (numbers, proper nouns, topic relevance), and returns
111 the top highlights.
112 """
113 if not transcript:
114 return []
115
116 sentences = re.split(r'(?<=[.!?])\s+', transcript)
117
118 # Fallback for punctuation-free transcripts (common with auto-captions):
119 # chunk into ~20-word segments so they pass the 8-50 word filter.
120 if len(sentences) <= 1 and len(transcript.split()) > 50:
121 words = transcript.split()
122 sentences = [' '.join(words[i:i+20]) for i in range(0, len(words), 20)]
123
124 filler = [
125 r"^(hey |hi |what's up|welcome back|in today's video|don't forget to)",
126 r"(subscribe|like and comment|hit the bell|check out the link|down below)",
127 r"^(so |and |but |okay |alright |um |uh )",
128 r"(thanks for watching|see you (next|in the)|bye)",
129 ]
130
131 topic_words = [w.lower() for w in topic.lower().split() if len(w) > 2]
132
133 candidates = []
134 for sent in sentences:
135 sent = sent.strip()
136 words = sent.split()
137 if len(words) < 8 or len(words) > 50:
138 continue
139 if any(re.search(p, sent, re.IGNORECASE) for p in filler):
140 continue
141
142 score = 0
143 if re.search(r'\d', sent):
144 score += 2
145 if re.search(r'[A-Z][a-z]+', sent):
146 score += 1
147 if '?' in sent:
148 score += 1
149 sent_lower = sent.lower()
150 if any(w in sent_lower for w in topic_words):
151 score += 2
152
153 candidates.append((score, sent))
154
155 candidates.sort(key=lambda x: -x[0])
156 return [sent for _, sent in candidates[:limit]]
157
158
159 def _log(msg: str):
160 log.source_log("YouTube", msg, tty_only=False)
161
162
163 def reset_search_cache() -> None:
164 """Clear the in-run ytsearch cache.
165
166 Call at the start of each top-level research run so a long-lived process
167 (agent host, REPL, test suite) does not reuse results across runs. Within
168 one comparison fan-out the cache stays hot so identical queries coalesce.
169 """
170 with _search_cache_lock:
171 _search_cache.clear()
172 _search_inflight.clear()
173
174
175 def _env_positive_float(name: str, default: float) -> float:
176 """Read a positive finite float from the environment, else ``default``."""
177 raw = os.environ.get(name, "").strip()
178 try:
179 value = float(raw) if raw else float(default)
180 except ValueError:
181 return float(default)
182 if not math.isfinite(value) or value <= 0:
183 return float(default)
184 return value
185
186
187 def _search_timeout() -> float:
188 """Return the ytsearch timeout, preserving the 120s default."""
189 return _env_positive_float("LAST30DAYS_YT_SEARCH_TIMEOUT", float(_SEARCH_TIMEOUT))
190
191
192 def _run_ytdlp(cmd: List[str], *, timeout: float) -> subproc.SubprocResult:
193 """Run a yt-dlp (or SSH-wrapped) command under the process-wide concurrency gate."""
194 with _ytdlp_slots:
195 return subproc.run_with_timeout(cmd, timeout=timeout)
196
197
198 def _claim_search_slot(
199 cache_key: Tuple[str, int, str],
200 ) -> tuple[Optional[Dict[str, Any]], Optional[threading.Event], Optional[list], bool]:
201 """Return ``(cached, event, slot, is_leader)`` for search coalesce.
202
203 - Cache hit: ``(payload, None, None, False)`` — caller returns ``payload``.
204 - Waiter: ``(None, event, slot, False)`` — caller awaits ``slot`` via ``event``.
205 - Leader: ``(None, event, slot, True)`` — caller runs yt-dlp and finishes the slot.
206 """
207 with _search_cache_lock:
208 cached = _search_cache.get(cache_key)
209 if cached is not None:
210 return copy.deepcopy(cached), None, None, False
211 existing = _search_inflight.get(cache_key)
212 if existing is not None:
213 return None, existing[0], existing[1], False
214 event = threading.Event()
215 slot: list = [None]
216 _search_inflight[cache_key] = (event, slot)
217 return None, event, slot, True
218
219
220 def _finish_search_slot(
221 cache_key: Tuple[str, int, str],
222 payload: Dict[str, Any],
223 *,
224 event: threading.Event,
225 slot: list,
226 ) -> Dict[str, Any]:
227 """Publish a search result to waiters; cache only clean (non-error) payloads.
228
229 Ownership is by slot identity: after ``reset_search_cache()`` clears the
230 registry, a stale leader must still wake its own waiters but must not pop
231 or overwrite a newer run's registration for the same key.
232 """
233 shared = copy.deepcopy(payload)
234 with _search_cache_lock:
235 if slot[0] is not None:
236 # Idempotent re-finish of this slot (e.g. finally after return).
237 event.set()
238 return payload
239 slot[0] = shared
240 current = _search_inflight.get(cache_key)
241 if current is not None and current[1] is slot:
242 if not payload.get("error"):
243 _search_cache[cache_key] = shared
244 _search_inflight.pop(cache_key, None)
245 # else: stale leader after a reset — wake local waiters only.
246 event.set()
247 return payload
248
249
250 def _await_search_slot(
251 event: threading.Event,
252 slot: list,
253 ) -> Dict[str, Any]:
254 """Wait for a leader search to publish.
255
256 Waiters block until the leader finishes (success or failure). The leader
257 path always publishes via ``_finish_search_slot``, including on unexpected
258 exceptions, so a timed wait would only invent a false timeout while the
259 leader was still queued behind other yt-dlp work.
260 """
261 event.wait()
262 shared = slot[0]
263 if isinstance(shared, dict):
264 return copy.deepcopy(shared)
265 return {"items": [], "error": "YouTube search failed"}
266
267
268 def classify_run_failure(detail: str) -> str:
269 """Map yt-dlp's text-only throttling and bot-gate errors."""
270 text = detail.lower()
271 if any(marker in text for marker in ("yt-dlp not installed", "yt-dlp not found")):
272 return health.SKIPPED_UNCONFIGURED
273 if any(marker in text for marker in ("timed out", "timeout")):
274 return health.TIMEOUT
275 if any(
276 marker in text
277 for marker in ("http error 429", "confirm you're not a bot", "confirm you’re not a bot", "bot-gate")
278 ):
279 return health.RATE_LIMITED
280 if any(marker in text for marker in ("sign in", "login required", "cookies are no longer valid")):
281 return health.AUTH_FAILED
282 return http.classify_failure(message=detail)
283
284
285 def is_ytdlp_installed() -> bool:
286 """Check if yt-dlp is available locally, or if SSH routing is configured.
287
288 When LAST30DAYS_YOUTUBE_SSH_HOST is set, returns True without a local check —
289 yt-dlp lives on the remote host. Failures surface naturally on first use.
290 """
291 if _ytdlp_ssh_host():
292 return True
293 return shutil.which("yt-dlp") is not None
294
295
296 # Host aliases must be plain hostnames / SSH config aliases — no flags, no
297 # shell metacharacters. Rejects any value that could be reinterpreted by ssh
298 # (or the surrounding shell) as something other than a destination.
299 _SSH_HOST_ALIAS_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
300
301
302 def _ytdlp_ssh_host() -> Optional[str]:
303 """Return SSH host alias if yt-dlp should be routed via SSH, else None.
304
305 Set LAST30DAYS_YOUTUBE_SSH_HOST=<ssh-alias> (e.g. 'macmini') in the environment
306 to route yt-dlp through SSH for residential IP egress. This bypasses
307 YouTube's bot-wall on datacenter IPs (Hetzner, DigitalOcean, AWS, etc.)
308 where ytsearch returns 0 results regardless of cookies.
309
310 The remote host must have yt-dlp installed and reachable via the named
311 SSH alias (configured in ~/.ssh/config). On macOS hosts with Homebrew,
312 add brew shellenv to ~/.zshenv (not just ~/.zprofile) so non-login SSH
313 shells find yt-dlp on PATH.
314
315 Validation: host value must match ``[A-Za-z0-9._-]+``. Anything starting
316 with ``-`` or containing shell/SSH metacharacters is rejected with a
317 stderr warning and treated as unset, so a misconfigured or attacker-
318 controlled value can't slip through as an SSH option flag or proxy command.
319 The ``--`` option terminator in ``_wrap_ytdlp_cmd`` is a second line of
320 defense; this regex closes the door on the env var ever reaching ssh
321 in the first place.
322
323 To use a value from ~/.config/last30days/.env, export it into the
324 environment before invoking the engine, e.g. in a wrapper:
325 set -a; source ~/.config/last30days/.env; set +a
326 python3 last30days.py "..."
327 """
328 host = os.environ.get("LAST30DAYS_YOUTUBE_SSH_HOST", "").strip()
329 if not host:
330 return None
331 if not _SSH_HOST_ALIAS_RE.match(host):
332 sys.stderr.write(
333 f"[youtube_yt] WARNING: LAST30DAYS_YOUTUBE_SSH_HOST={host!r} "
334 "does not look like a plain hostname/alias; ignoring. "
335 "Expected pattern: letters, digits, dot, underscore, hyphen.\n"
336 )
337 return None
338 return host
339
340
341 def _wrap_ytdlp_cmd(cmd: List[str]) -> List[str]:
342 """Wrap a yt-dlp command list with `ssh <host>` when SSH routing is set.
343
344 Args are shell-quoted to survive the remote shell. Uses BatchMode=yes so
345 a misconfigured key fails fast instead of hanging on a password prompt.
346 The `--` option terminator prevents an SSH option-injection if
347 LAST30DAYS_YOUTUBE_SSH_HOST were ever set to a value starting with `-`.
348 """
349 host = _ytdlp_ssh_host()
350 if not host:
351 return cmd
352 remote_cmd = " ".join(shlex.quote(a) for a in cmd)
353 return ["ssh", "-o", "BatchMode=yes", "--", host, remote_cmd]
354
355
356 def _extract_core_subject(topic: str) -> str:
357 """Extract core subject from verbose query for YouTube search.
358
359 NOTE: 'tips', 'tricks', 'tutorial', 'guide', 'review', 'reviews'
360 are intentionally KEPT — they're YouTube content types that improve search.
361 """
362 from .query import VIRAL_NOISE, extract_core_subject
363 # YouTube extends VIRAL_NOISE with temporal/meta words the planner emits
364 # that don't appear in YouTube titles (months, recent year tokens, etc.).
365 _YT_EXTRA = frozenset({
366 'last', 'days', 'recent', 'recently', 'month', 'week',
367 'january', 'february', 'march', 'april', 'may', 'june',
368 'july', 'august', 'september', 'october', 'november', 'december',
369 '2025', '2026', '2027',
370 'music', 'public', 'appearances', 'developments', 'discussions', 'coverage',
371 })
372 return extract_core_subject(topic, noise=VIRAL_NOISE | _YT_EXTRA)
373
374
375 def expand_youtube_queries(topic: str, depth: str) -> List[str]:
376 """Generate multiple YouTube search queries from a topic.
377
378 Mirrors reddit.py's expand_reddit_queries() pattern:
379 1. Extract core subject (strip noise words)
380 2. Include original topic if different from core
381 3. Add intent-specific OR-joined content-type variants
382 4. Cap by depth: 1 for quick, 2 for default, 3 for deep
383
384 Returns 1-3 query strings depending on depth.
385 """
386 core = _extract_core_subject(topic)
387 queries = [core]
388
389 # Include cleaned original topic as variant if different from core
390 original_clean = topic.strip().rstrip('?!.')
391 if core.lower() != original_clean.lower() and len(original_clean.split()) <= 8:
392 queries.append(original_clean)
393
394 qtype = infer_query_intent(topic)
395
396 # Intent-specific YouTube content-type variants
397 if qtype == "opinion":
398 queries.append(f"{core} review OR reaction OR breakdown")
399 elif qtype == "product":
400 queries.append(f"{core} review OR comparison OR unboxing")
401 elif qtype == "comparison":
402 queries.append(f"{core} vs OR compared OR head to head")
403 elif qtype == "how_to":
404 queries.append(f"{core} tutorial OR guide OR explained")
405 else:
406 # breaking_news / general — YouTube content types
407 queries.append(f"{core} review OR reaction OR breakdown")
408
409 # Deep depth: add full-length content variant
410 if depth == "deep":
411 queries.append(f"{core} full OR complete OR official")
412
413 # Cap by depth budget
414 caps = {"quick": 1, "default": 2, "deep": 3}
415 cap = caps.get(depth, 2)
416 return queries[:cap]
417
418
419 def search_youtube(
420 topic: str,
421 from_date: str,
422 to_date: str,
423 depth: str = "default",
424 ) -> Dict[str, Any]:
425 """Search YouTube via yt-dlp. No API key needed.
426
427 Args:
428 topic: Search topic
429 from_date: Start date (YYYY-MM-DD)
430 to_date: End date (YYYY-MM-DD)
431 depth: 'quick', 'default', or 'deep'
432
433 Returns:
434 Dict with 'items' list of video metadata dicts.
435 """
436 if not is_ytdlp_installed():
437 return {"items": [], "error": "yt-dlp not installed"}
438
439 count = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
440 core_topic = _extract_core_subject(topic)
441 cache_key = (core_topic, count, from_date)
442 timeout = _search_timeout()
443
444 cached, event, slot, is_leader = _claim_search_slot(cache_key)
445 if cached is not None:
446 _log(f"YouTube search cache hit for '{core_topic}' (count={count})")
447 return cached
448 assert event is not None and slot is not None
449 if not is_leader:
450 _log(f"YouTube search awaiting in-flight query for '{core_topic}'")
451 return _await_search_slot(event, slot)
452
453 def _publish(payload: Dict[str, Any]) -> Dict[str, Any]:
454 return _finish_search_slot(cache_key, payload, event=event, slot=slot)
455
456 _log(f"Searching YouTube for '{core_topic}' (since {from_date}, count={count})")
457
458 # yt-dlp search with full metadata (no --flat-playlist so dates are real).
459 # NOTE: --dateafter intentionally omitted — YouTube search returns
460 # relevance-sorted results and strict date filtering returns 0 for
461 # evergreen topics. Python soft filter (below) handles date filtering.
462 cmd = [
463 "yt-dlp",
464 "--ignore-config",
465 "--no-cookies-from-browser",
466 f"ytsearch{count}:{core_topic}",
467 "--dump-json",
468 "--no-warnings",
469 "--no-download",
470 ]
471 cmd = _wrap_ytdlp_cmd(cmd)
472 ssh_host = _ytdlp_ssh_host()
473
474 published: Dict[str, Any] | None = None
475 try:
476 try:
477 result = _run_ytdlp(cmd, timeout=timeout)
478 except subproc.SubprocTimeout:
479 _log(f"YouTube search timed out ({timeout:g}s)")
480 published = _publish(
481 {"items": [], "error": f"Search timed out after {timeout:g}s"}
482 )
483 return published
484 except FileNotFoundError:
485 published = _publish({"items": [], "error": "yt-dlp not found"})
486 return published
487
488 stdout = result.stdout
489 if ssh_host and result.returncode != 0 and not stdout.strip():
490 stderr_first = (result.stderr or "").strip().splitlines()
491 first_line = stderr_first[0] if stderr_first else "(no stderr)"
492 _log(
493 f"YouTube search via SSH host {ssh_host!r} failed "
494 f"(rc={result.returncode}): {first_line}"
495 )
496 published = _publish(
497 {"items": [], "error": f"SSH routing to {ssh_host!r} failed: {first_line}"},
498 )
499 return published
500 if not stdout.strip():
501 _log("YouTube search returned 0 results")
502 published = _publish({"items": []})
503 return published
504
505 # Parse JSON-per-line output
506 items = []
507 for line in stdout.strip().split("\n"):
508 line = line.strip()
509 if not line:
510 continue
511 try:
512 video = json.loads(line)
513 except json.JSONDecodeError:
514 continue
515
516 video_id = video.get("id", "")
517 view_count = video.get("view_count") if video.get("view_count") is not None else 0
518 like_count = video.get("like_count") if video.get("like_count") is not None else 0
519 comment_count = video.get("comment_count") if video.get("comment_count") is not None else 0
520 upload_date = video.get("upload_date", "") # YYYYMMDD
521
522 # Convert YYYYMMDD to YYYY-MM-DD
523 date_str = None
524 if upload_date and len(upload_date) == 8:
525 date_str = f"{upload_date[:4]}-{upload_date[4:6]}-{upload_date[6:8]}"
526
527 description = str(video.get("description", ""))[:500]
528 items.append({
529 "video_id": video_id,
530 "title": video.get("title", ""),
531 "url": f"https://www.youtube.com/watch?v={video_id}",
532 "channel_name": video.get("channel", video.get("uploader", "")),
533 "date": date_str,
534 "engagement": {
535 "views": view_count,
536 "likes": like_count,
537 "comments": comment_count,
538 },
539 "duration": video.get("duration"),
540 "relevance": _compute_relevance(core_topic, f"{video.get('title', '')} {description}"),
541 "why_relevant": f"YouTube: {video.get('title', core_topic)[:60]}",
542 "description": description,
543 })
544
545 # Soft date filter: prefer recent items but fall back to all if too few
546 recent = [i for i in items if i["date"] and i["date"] >= from_date]
547 if len(recent) >= 3:
548 items = recent
549 _log(f"Found {len(items)} videos within date range")
550 else:
551 _log(f"Found {len(items)} videos ({len(recent)} within date range, keeping all)")
552
553 # Sort by views descending
554 items.sort(key=lambda x: x["engagement"]["views"], reverse=True)
555 published = _publish({"items": items})
556 return published
557 except Exception as exc:
558 # Post-subprocess failures (parse/relevance/sort) must still unblock
559 # coalesced waiters — otherwise the inflight key orphans forever.
560 published = _publish({"items": [], "error": str(exc)})
561 return published
562 finally:
563 if published is None:
564 _publish({"items": [], "error": "YouTube search failed"})
565
566
567 def _clean_vtt(vtt_text: str) -> str:
568 """Convert VTT subtitle format to clean plaintext."""
569 # Strip VTT header
570 text = re.sub(r'^WEBVTT.*?\n\n', '', vtt_text, flags=re.DOTALL)
571 # Strip timestamps
572 text = re.sub(r'\d{2}:\d{2}:\d{2}\.\d{3}\s*-->\s*\d{2}:\d{2}:\d{2}\.\d{3}.*\n', '', text)
573 # Strip position/alignment tags
574 text = re.sub(r'<[^>]+>', '', text)
575 # Strip cue numbers
576 text = re.sub(r'^\d+\s*$', '', text, flags=re.MULTILINE)
577 # Deduplicate overlapping lines
578 lines = text.strip().split('\n')
579 seen = set()
580 unique = []
581 for line in lines:
582 stripped = line.strip()
583 if stripped and stripped not in seen:
584 seen.add(stripped)
585 unique.append(stripped)
586 return re.sub(r'\s+', ' ', ' '.join(unique)).strip()
587
588
589 _YT_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
590
591
592 def _fetch_transcript_direct(
593 video_id: str,
594 timeout: int = 30,
595 status: Optional[Dict[str, Any]] = None,
596 ) -> Optional[str]:
597 """Fetch YouTube transcript via direct HTTP without yt-dlp.
598
599 Scrapes the watch page HTML for the captions track URL in
600 ytInitialPlayerResponse, then fetches the VTT subtitle file.
601
602 Args:
603 video_id: YouTube video ID
604 timeout: HTTP request timeout in seconds
605 status: Optional dict mutated to record per-video signals. Sets
606 ``status["no_caption_tracks"] = True`` when the player response
607 confirms the uploader has no caption tracks (vs. fetch failure).
608
609 Returns:
610 Raw VTT text, or None if captions are unavailable.
611 """
612 watch_url = f"https://www.youtube.com/watch?v={video_id}"
613 headers = {
614 "User-Agent": _YT_USER_AGENT,
615 "Accept-Language": "en-US,en;q=0.9",
616 }
617
618 # Step 1: Fetch the watch page HTML
619 req = urllib.request.Request(watch_url, headers=headers)
620 try:
621 with urllib.request.urlopen(req, timeout=timeout) as resp:
622 html = resp.read().decode("utf-8", errors="replace")
623 except (urllib.error.URLError, urllib.error.HTTPError, OSError, TimeoutError) as exc:
624 _log(f"Direct transcript: failed to fetch watch page for {video_id}: {exc}")
625 return None
626
627 # Step 2: Extract captions URL from ytInitialPlayerResponse
628 # YouTube embeds this as a JS variable in the page HTML
629 match = re.search(
630 r'ytInitialPlayerResponse\s*=\s*(\{.+?\})\s*;(?:\s*var\s|\s*<\/script>)',
631 html,
632 )
633 if not match:
634 # Fallback: try the JSON embedded in the script tag
635 match = re.search(
636 r'var\s+ytInitialPlayerResponse\s*=\s*(\{.+?\})\s*;',
637 html,
638 )
639 if not match:
640 _log(f"Direct transcript: no ytInitialPlayerResponse found for {video_id}")
641 return None
642
643 try:
644 player_response = json.loads(match.group(1))
645 except json.JSONDecodeError:
646 _log(f"Direct transcript: failed to parse ytInitialPlayerResponse for {video_id}")
647 return None
648
649 # Navigate to caption tracks
650 captions = player_response.get("captions", {})
651 renderer = captions.get("playerCaptionsTracklistRenderer", {})
652 caption_tracks = renderer.get("captionTracks", [])
653
654 if not caption_tracks:
655 _log(f"Direct transcript: no caption tracks for {video_id}")
656 if status is not None:
657 status["no_caption_tracks"] = True
658 return None
659
660 # Find English track (prefer exact 'en', then any en variant, then first track)
661 base_url = None
662 for track in caption_tracks:
663 lang = track.get("languageCode", "")
664 if lang == "en":
665 base_url = track.get("baseUrl")
666 break
667 if not base_url:
668 for track in caption_tracks:
669 lang = track.get("languageCode", "")
670 if lang.startswith("en"):
671 base_url = track.get("baseUrl")
672 break
673 if not base_url:
674 # Fall back to first available track
675 base_url = caption_tracks[0].get("baseUrl")
676 if not base_url:
677 _log(f"Direct transcript: no baseUrl in caption tracks for {video_id}")
678 return None
679
680 # Step 3: Fetch the VTT subtitle file
681 sep = "&" if "?" in base_url else "?"
682 vtt_url = f"{base_url}{sep}fmt=vtt"
683 vtt_req = urllib.request.Request(vtt_url, headers=headers)
684 try:
685 with urllib.request.urlopen(vtt_req, timeout=timeout) as resp:
686 vtt_text = resp.read().decode("utf-8", errors="replace")
687 except (urllib.error.URLError, urllib.error.HTTPError, OSError, TimeoutError) as exc:
688 _log(f"Direct transcript: failed to fetch VTT for {video_id}: {exc}")
689 return None
690
691 if not vtt_text or not vtt_text.strip():
692 return None
693
694 return vtt_text
695
696
697 def _fetch_transcript_ytdlp_via_ssh(video_id: str, ssh_host: str) -> Optional[str]:
698 """Fetch transcript via yt-dlp on a remote SSH host (mktemp + cat pipeline)."""
699 if not _SSH_HOST_ALIAS_RE.match(ssh_host):
700 return None
701 url = f"https://www.youtube.com/watch?v={video_id}"
702 quoted_url = shlex.quote(url)
703 sub_langs = shlex.quote(_ytdlp_sub_langs())
704 remote_script = (
705 "set -e; "
706 "TMPD=$(mktemp -d); "
707 "yt-dlp --ignore-config --no-cookies-from-browser "
708 f"--write-auto-subs --sub-lang {sub_langs} --sub-format vtt "
709 "--skip-download --no-warnings "
710 f'-o "$TMPD/%(id)s" {quoted_url} >/dev/null 2>&1 || true; '
711 'VTT=$(find "$TMPD" -maxdepth 1 -name "*.vtt" 2>/dev/null | head -1); '
712 '[ -n "$VTT" ] && cat "$VTT"; '
713 'rm -rf "$TMPD"'
714 )
715 cmd = ["ssh", "-o", "BatchMode=yes", "--", ssh_host, remote_script]
716 try:
717 result = _run_ytdlp(cmd, timeout=45)
718 except subproc.SubprocTimeout:
719 _log(f"SSH yt-dlp transcript timed out for {video_id} via {ssh_host!r}")
720 return None
721 except FileNotFoundError:
722 _log("ssh executable not found; cannot route transcript fetch")
723 return None
724 out = result.stdout or ""
725 if not out.strip().startswith("WEBVTT"):
726 if result.returncode != 0 and result.stderr:
727 first_line = result.stderr.strip().splitlines()[0]
728 _log(
729 f"SSH yt-dlp transcript via {ssh_host!r} failed for "
730 f"{video_id} (rc={result.returncode}): {first_line}"
731 )
732 return None
733 return out
734
735
736 def _ytdlp_sub_langs() -> str:
737 """Caption languages to try, from LAST30DAYS_YT_SUB_LANGS (default en,es,pt)."""
738 raw = os.environ.get("LAST30DAYS_YT_SUB_LANGS", "").strip()
739 if not raw:
740 return "en,es,pt"
741 return ",".join(code.strip().lower() for code in raw.split(",") if code.strip()) or "en,es,pt"
742
743
744 def _transcript_fast_timeout() -> float:
745 """Return the keyed-run yt-dlp timeout, preserving the 12s default."""
746 return _env_positive_float(
747 "LAST30DAYS_YT_TRANSCRIPT_FAST_TIMEOUT",
748 float(_TRANSCRIPT_FAST_TIMEOUT),
749 )
750
751 def _pick_ytdlp_vtt(video_id: str, temp_dir: str, priority: List[str]) -> Optional[Path]:
752 """Return the best on-disk VTT match for video_id, preferring priority order."""
753 matches = list(Path(temp_dir).glob(f"{video_id}*.vtt"))
754 if not matches:
755 return None
756 priority_index = {code: i for i, code in enumerate(priority)}
757
758 def rank(p: Path) -> int:
759 stem = p.stem
760 suffix = stem[len(video_id) + 1:] if stem.startswith(video_id + ".") else ""
761 code = suffix.split("-")[0].split(".")[0]
762 return priority_index.get(code, len(priority_index))
763
764 return sorted(matches, key=rank)[0]
765
766
767 def _transcript_backoff(video_id: str, attempt: int) -> float:
768 """Backoff seconds before a transcript retry.
769
770 Staggered per-video (a sub-second offset derived from the id) so parallel
771 workers don't retry in lockstep and re-trip YouTube's limiter.
772 """
773 offset = (sum(ord(c) for c in video_id) % 1000) / 1000.0 # 0.0–1.0s
774 return _TRANSCRIPT_BACKOFF_BASE * (attempt + 1) + offset
775
776
777 def _read_vtt(video_id: str, temp_dir: str) -> Optional[str]:
778 """Return the VTT text yt-dlp wrote for ``video_id``, or None if absent."""
779 vtt_path = _pick_ytdlp_vtt(video_id, temp_dir, _ytdlp_sub_langs().split(","))
780 if vtt_path is None:
781 return None
782
783 try:
784 return vtt_path.read_text(encoding="utf-8", errors="replace")
785 except OSError:
786 return None
787
788
789 def _fetch_transcript_ytdlp(
790 video_id: str,
791 temp_dir: str,
792 status: Optional[Dict[str, Any]] = None,
793 fast_fail: bool = False,
794 ) -> Optional[str]:
795 """Fetch transcript using yt-dlp (original implementation).
796
797 Args:
798 video_id: YouTube video ID
799 temp_dir: Temporary directory for subtitle files
800 status: Optional dict mutated to record a yt-dlp failure reason
801 (``status["ytdlp_error"]``) so the caller can tell a real fetch
802 error (rate-limit / bot-check / network / timeout) apart from a
803 video that genuinely has no captions, and skip the misleading
804 "no captions found" log + the YouTube-blocked HTTP fallback.
805 fast_fail: When True, a ScrapeCreators fallback is available, so a
806 transient failure (429 / bot-gate) should fail over fast rather
807 than retry into the same rate limit. Collapses to a single attempt
808 with a shorter per-attempt timeout. The slow thing in a run is
809 yt-dlp retrying, not the fast SC fetch, so this is what keeps
810 "yt-dlp first" from reintroducing the multi-minute hang.
811
812 Returns:
813 Raw VTT text, or None if no captions are available or the fetch failed.
814 On a hard (non-no-caption) failure, sets ``status["ytdlp_error"]``.
815 """
816 cmd = [
817 "yt-dlp",
818 "--ignore-config",
819 "--no-cookies-from-browser",
820 "--write-auto-subs",
821 "--sub-lang", _ytdlp_sub_langs(),
822 "--sub-format", "vtt",
823 "--skip-download",
824 "--no-warnings",
825 "-o", f"{temp_dir}/%(id)s",
826 f"https://www.youtube.com/watch?v={video_id}",
827 ]
828
829 timeout = _transcript_fast_timeout() if fast_fail else _TRANSCRIPT_TIMEOUT
830 attempts = 1 if fast_fail else _TRANSCRIPT_MAX_RETRIES + 1
831 last_reason: Optional[str] = None
832 for attempt in range(attempts):
833 try:
834 result = _run_ytdlp(cmd, timeout=timeout)
835 except subproc.SubprocTimeout:
836 last_reason = f"timed out after {timeout}s"
837 _log(f"yt-dlp transcript timed out after {timeout}s for {video_id} "
838 f"(attempt {attempt + 1}/{attempts})")
839 # yt-dlp downloads requested languages sequentially. A timeout can
840 # therefore leave a complete first-choice VTT on disk; keep it
841 # instead of spending a ScrapeCreators fallback credit.
842 partial_vtt = _read_vtt(video_id, temp_dir)
843 if partial_vtt is not None:
844 return partial_vtt
845 if attempt < attempts - 1:
846 time.sleep(_transcript_backoff(video_id, attempt))
847 continue
848 break
849 except FileNotFoundError:
850 # yt-dlp binary missing — not transient, not retryable.
851 if status is not None:
852 status["ytdlp_error"] = "yt-dlp not found"
853 return None
854
855 if result.returncode == 0:
856 vtt = _read_vtt(video_id, temp_dir)
857 if vtt is not None:
858 return vtt
859 # Exit 0 with no file == the uploader has no matching captions.
860 # Genuine no-captions: return quietly (caller may still try direct).
861 return None
862
863 # Non-zero exit, but yt-dlp may have written a usable VTT before the
864 # failing language errored. With the default `--sub-lang en,es,pt`, an
865 # English video fetches `en` fine, then `es`/`pt` hit a 429 and yt-dlp
866 # exits non-zero — yet the `en` track is already on disk. A partial
867 # success is still a real transcript, so salvage any VTT before
868 # classifying this as an error (and, worse, retrying straight back into
869 # the same rate limit). This is the root cause of the 0/N transcript
870 # runs reported when every video had captions.
871 partial_vtt = _read_vtt(video_id, temp_dir)
872 if partial_vtt is not None:
873 return partial_vtt
874
875 # Non-zero exit == a real error worth classifying & surfacing.
876 stderr = (result.stderr or "").strip()
877 snippet = (stderr.splitlines()[-1][:200] if stderr
878 else f"exit {result.returncode}")
879 if _NO_CAPTION_RE.search(stderr):
880 # yt-dlp can exit non-zero when the requested language is absent.
881 # Treat as genuine no-captions, not an error worth retrying.
882 return None
883 last_reason = snippet
884 if _TRANSIENT_RE.search(stderr) and attempt < attempts - 1:
885 _log(f"yt-dlp transcript transient failure for {video_id} "
886 f"(attempt {attempt + 1}/{attempts}): {snippet}")
887 time.sleep(_transcript_backoff(video_id, attempt))
888 continue
889 # Non-transient, or retries exhausted — surface the real reason.
890 _log(f"yt-dlp transcript failed for {video_id} "
891 f"(exit {result.returncode}): {snippet}")
892 break
893
894 if status is not None and last_reason is not None:
895 status["ytdlp_error"] = last_reason
896 return None
897
898
899 def _should_try_sc_transcript(status: Optional[Dict[str, Any]]) -> bool:
900 """Whether to spend a ScrapeCreators credit after the keyless cascade failed.
901
902 Skip when the keyless path *proved* the uploader has no caption track
903 (``no_caption_tracks``): SC would also return nothing, so a credit would be
904 wasted. A transient hard failure (``ytdlp_error``: 429 / bot-gate / timeout)
905 is a false negative, so SC is worth trying.
906 """
907 st = status or {}
908 return not st.get("no_caption_tracks")
909
910
911 def fetch_transcript(
912 video_id: str,
913 temp_dir: str,
914 status: Optional[Dict[str, Any]] = None,
915 token: Optional[str] = None,
916 ) -> Optional[str]:
917 """Fetch auto-generated transcript for a YouTube video.
918
919 Uses yt-dlp when available (preferred, more robust). Falls back to
920 direct HTTP transcript fetching when yt-dlp is not installed, and finally
921 to the ScrapeCreators transcript endpoint when a key is present and the
922 keyless cascade comes back empty.
923
924 Args:
925 video_id: YouTube video ID
926 temp_dir: Temporary directory for subtitle files
927 status: Optional dict mutated by the direct-HTTP path to record
928 per-video signals like ``no_caption_tracks``. Used to surface a
929 captions-disabled count so the quality nudge avoids false-positive
930 "stale yt-dlp" flags.
931 token: Optional ScrapeCreators API key. When present, yt-dlp fails over
932 fast (see ``_fetch_transcript_ytdlp`` ``fast_fail``) and a true hard
933 failure falls back to the SC transcript endpoint. A credit is only
934 spent on a genuine yt-dlp failure, never on success and never on a
935 video proven to have no captions. None preserves keyless behavior.
936
937 Returns:
938 Plaintext transcript string, or None if no captions available.
939 """
940 raw_vtt = None
941 ssh_host = _ytdlp_ssh_host()
942 if ssh_host and is_ytdlp_installed():
943 raw_vtt = _fetch_transcript_ytdlp_via_ssh(video_id, ssh_host)
944 if not raw_vtt:
945 _log(f"SSH yt-dlp transcript failed for {video_id}, trying direct HTTP fallback")
946 raw_vtt = _fetch_transcript_direct(video_id, status=status)
947 elif is_ytdlp_installed():
948 raw_vtt = _fetch_transcript_ytdlp(
949 video_id, temp_dir, status=status, fast_fail=bool(token),
950 )
951 if not raw_vtt:
952 ytdlp_error = (status or {}).get("ytdlp_error")
953 if ytdlp_error:
954 # Hard failure (429 / bot-gate / timeout). The direct-HTTP
955 # fallback is also YouTube-blocked, so skip it and let the
956 # ScrapeCreators fallback below handle it when a key is present.
957 _log(f"Transcript fetch failed for {video_id}: {ytdlp_error}")
958 else:
959 _log(f"yt-dlp found no captions for {video_id}, trying direct HTTP fallback")
960 raw_vtt = _fetch_transcript_direct(video_id, status=status)
961 else:
962 _log("yt-dlp not installed, using direct HTTP transcript fetch")
963 raw_vtt = _fetch_transcript_direct(video_id, status=status)
964
965 if raw_vtt:
966 transcript = _clean_vtt(raw_vtt)
967 # Truncate to max words
968 words = transcript.split()
969 if len(words) > TRANSCRIPT_MAX_WORDS:
970 transcript = ' '.join(words[:TRANSCRIPT_MAX_WORDS]) + '...'
971 return transcript if transcript else None
972
973 # Keyless cascade produced nothing. When a ScrapeCreators key is present and
974 # the video was not proven caption-less, fall back to the SC transcript
975 # endpoint (fetched server-side: no 429, cookies, or PO tokens). Returns
976 # already-cleaned, word-capped plaintext.
977 if token and _should_try_sc_transcript(status):
978 sc_transcript = _sc_fetch_transcript(video_id, token)
979 if sc_transcript:
980 # The keyless cascade (yt-dlp / direct HTTP) already logged its
981 # failure above. Without this line that failure is the last thing
982 # printed for this video, and the batch summary in
983 # fetch_transcripts_parallel() counts it as a plain success —
984 # making a rate-limited/bot-gated run look like nothing went
985 # wrong. Log the rescue and flag it in `status` so the summary
986 # can report it explicitly instead of masking it (#831).
987 _log(f"ScrapeCreators transcript fallback rescued {video_id} "
988 f"after the keyless fetch cascade failed")
989 if status is not None:
990 status["sc_rescued"] = True
991 return sc_transcript
992
993 _log(f"No transcript available for {video_id}")
994 return None
995
996
997 def fetch_transcripts_parallel(
998 video_ids: List[str],
999 max_workers: int = 5,
1000 out_captions_disabled: Optional[Set[str]] = None,
1001 token: Optional[str] = None,
1002 ) -> Dict[str, Optional[str]]:
1003 """Fetch transcripts for multiple videos in parallel.
1004
1005 Args:
1006 video_ids: List of YouTube video IDs
1007 max_workers: Max parallel fetches
1008 out_captions_disabled: Optional set mutated to record video_ids whose
1009 uploader confirmed no caption tracks (vs. transient fetch failures).
1010 Backward-compatible: callers that don't care can omit.
1011 token: Optional ScrapeCreators API key, threaded to each
1012 ``fetch_transcript`` so the per-video SC fallback activates on
1013 yt-dlp failure. None preserves keyless behavior.
1014
1015 Returns:
1016 Dict mapping video_id to transcript text (or None).
1017 """
1018 if not video_ids:
1019 return {}
1020
1021 _log(f"Fetching transcripts for {len(video_ids)} videos")
1022
1023 results = {}
1024 statuses: Dict[str, Dict[str, Any]] = {vid: {} for vid in video_ids}
1025 with tempfile.TemporaryDirectory() as temp_dir:
1026 with ThreadPoolExecutor(max_workers=max_workers) as executor:
1027 futures = {
1028 http.submit_with_context(
1029 executor, fetch_transcript, vid, temp_dir, statuses[vid], token,
1030 ): vid
1031 for vid in video_ids
1032 }
1033 for future in as_completed(futures):
1034 vid = futures[future]
1035 try:
1036 results[vid] = future.result()
1037 except OSError as exc:
1038 _log(f"Transcript fetch error for {vid}: {exc}")
1039 results[vid] = None
1040 except Exception as exc:
1041 _log(f"Unexpected transcript error for {vid}: {type(exc).__name__}: {exc}")
1042 results[vid] = None
1043
1044 if out_captions_disabled is not None:
1045 for vid, st in statuses.items():
1046 if st.get("no_caption_tracks"):
1047 out_captions_disabled.add(vid)
1048
1049 got = sum(1 for v in results.values() if v)
1050 errors = sum(1 for v in results.values() if v is None)
1051 # `got` includes videos that only succeeded because the ScrapeCreators
1052 # fallback rescued a failed keyless fetch — yt-dlp when available, or the
1053 # direct HTTP path alone (see fetch_transcript()). Folding
1054 # those into a bare "M failed" count previously made a fully rate-limited
1055 # yt-dlp run — every fetch failing, silently saved by the fallback — read
1056 # as "0 failed", with no trace of the fallback ever having fired (#831).
1057 # Surface the split so the summary can't misrepresent a masked failure
1058 # as a clean success.
1059 sc_rescued = sum(1 for st in statuses.values() if st.get("sc_rescued"))
1060 if sc_rescued:
1061 _log(f"Got transcripts for {got}/{len(video_ids)} videos "
1062 f"({errors} failed, {sc_rescued} rescued via ScrapeCreators fallback)")
1063 else:
1064 _log(f"Got transcripts for {got}/{len(video_ids)} videos ({errors} failed)")
1065 return results
1066
1067
1068 def backfill_transcripts(
1069 items: List[Any], topic: str = "", depth: str = "default",
1070 token: Optional[str] = None,
1071 ) -> None:
1072 """Second-pass transcript fetch for finalized items that lack one (#542).
1073
1074 ``token`` is the optional ScrapeCreators key, threaded to
1075 ``fetch_transcripts_parallel`` so the SC fallback covers backfill survivors
1076 that yt-dlp can't fetch. None preserves keyless behavior.
1077 """
1078 limit = TRANSCRIPT_LIMITS.get(depth, TRANSCRIPT_LIMITS["default"])
1079 if limit <= 0 or not items or not is_ytdlp_installed():
1080 return
1081 have = sum(
1082 1 for it in items
1083 if it.metadata.get("transcript_highlights") or it.metadata.get("transcript_snippet")
1084 )
1085 need = limit - have
1086 if need <= 0:
1087 return
1088 missing = [
1089 it for it in items
1090 if it.item_id
1091 and not it.metadata.get("transcript_highlights")
1092 and not it.metadata.get("transcript_snippet")
1093 and not it.metadata.get("captions_disabled")
1094 ]
1095 attempts = missing[: need * 3]
1096 if not attempts:
1097 return
1098 _log(f"Backfilling transcripts for {len(attempts)} finalized videos (target: {need})")
1099 captions_disabled: Set[str] = set()
1100 transcripts = fetch_transcripts_parallel(
1101 [it.item_id for it in attempts],
1102 out_captions_disabled=captions_disabled,
1103 token=token,
1104 )
1105 for it in attempts:
1106 if it.item_id in captions_disabled:
1107 it.metadata["captions_disabled"] = True
1108 continue
1109 transcript = transcripts.get(it.item_id)
1110 if not transcript:
1111 continue
1112 it.metadata["transcript_snippet"] = transcript
1113 highlights = extract_transcript_highlights(transcript, topic)
1114 if highlights:
1115 it.metadata["transcript_highlights"] = highlights
1116 if not it.snippet:
1117 it.snippet = " ".join(transcript.split()[:80])
1118
1119
1120 def _transcript_candidate_sort_key(item: dict) -> tuple:
1121 """Sort key for transcript candidate selection.
1122
1123 Combines views with recency so that recent videos (which survive
1124 strict_recent freshness pruning) are prioritised over old high-view
1125 videos whose transcripts would be discarded downstream.
1126 """
1127 views = item.get("engagement", {}).get("views", 0) or 0
1128 recency = dates.recency_score(item.get("date", ""))
1129 return (views, recency)
1130
1131
1132 def _prefer_search_error(current: Optional[str], new: str) -> str:
1133 """Keep the most actionable search failure across multi-query merges."""
1134 if current is None:
1135 return new
1136 priority = ("timed out", "timeout", "429", "bot")
1137
1138 def _rank(text: str) -> int:
1139 lower = text.lower()
1140 for index, marker in enumerate(priority):
1141 if marker in lower:
1142 return index
1143 return len(priority)
1144
1145 return new if _rank(new) < _rank(current) else current
1146
1147
1148 def search_and_transcribe(
1149 topic: str,
1150 from_date: str,
1151 to_date: str,
1152 depth: str = "default",
1153 token: Optional[str] = None,
1154 ) -> Dict[str, Any]:
1155 """Full YouTube search: find videos, then fetch transcripts for top results.
1156
1157 Uses expand_youtube_queries() to generate multiple search queries,
1158 runs yt-dlp for each, and merges/deduplicates results by video ID.
1159
1160 Args:
1161 topic: Search topic
1162 from_date: Start date (YYYY-MM-DD)
1163 to_date: End date (YYYY-MM-DD)
1164 depth: 'quick', 'default', or 'deep'
1165 token: Optional ScrapeCreators key for the per-video transcript
1166 fallback (threaded to fetch_transcripts_parallel).
1167
1168 Returns:
1169 Dict with 'items' list. Each item has a 'transcript_snippet' field.
1170 """
1171 # Step 1: Multi-query search — run yt-dlp for each expanded query
1172 queries = expand_youtube_queries(topic, depth)
1173 seen_ids: Set[str] = set()
1174 items: List[Dict[str, Any]] = []
1175 search_error: Optional[str] = None
1176 for q in queries:
1177 search_result = search_youtube(q, from_date, to_date, depth)
1178 err = search_result.get("error")
1179 if err:
1180 search_error = _prefer_search_error(search_error, str(err))
1181 for item in search_result.get("items", []):
1182 vid = item.get("video_id", "")
1183 if vid and vid not in seen_ids:
1184 seen_ids.add(vid)
1185 items.append(item)
1186
1187 # Sort merged results by views descending
1188 items.sort(key=lambda x: x.get("engagement", {}).get("views") or 0, reverse=True)
1189
1190 if not items:
1191 return {"items": [], **({"error": search_error} if search_error else {})}
1192
1193 # Step 2: Fetch transcripts for top videos.
1194 # Sort candidates by a combination of views and recency so that recent
1195 # videos (which survive strict_recent pruning) are not starved of
1196 # transcript budget by older high-view-count outliers.
1197 # Try more candidates than the limit because some videos (music videos,
1198 # short clips) lack captions. Attempt up to 3x the limit so we have a
1199 # good chance of reaching the target number of successful transcripts.
1200 transcript_limit = TRANSCRIPT_LIMITS.get(depth, TRANSCRIPT_LIMITS["default"])
1201 transcripts: Dict[str, Optional[str]] = {}
1202 captions_disabled_ids: Set[str] = set()
1203 if transcript_limit > 0:
1204 attempt_count = min(len(items), transcript_limit * 3)
1205 transcript_candidates = sorted(
1206 items, key=_transcript_candidate_sort_key, reverse=True,
1207 )
1208 candidate_ids = [item["video_id"] for item in transcript_candidates[:attempt_count]]
1209 _log(f"Fetching transcripts for up to {attempt_count} videos (target: {transcript_limit}): {candidate_ids}")
1210 transcripts = fetch_transcripts_parallel(
1211 candidate_ids, out_captions_disabled=captions_disabled_ids,
1212 token=token,
1213 )
1214 # Record fetch outcomes (captions-disabled videos can never succeed,
1215 # so they don't count as failures) for the stale-yt-dlp nudge.
1216 _TRANSCRIPT_FETCH_STATS["attempts"] += len(candidate_ids)
1217 _TRANSCRIPT_FETCH_STATS["failures"] += sum(
1218 1 for vid in candidate_ids
1219 if not transcripts.get(vid) and vid not in captions_disabled_ids
1220 )
1221 else:
1222 _log(f"Transcript limit is 0 for depth={depth}, skipping transcript fetch")
1223
1224 # Step 3: Attach transcripts and extract highlights. Mark captions_disabled
1225 # so quality_nudge can subtract those videos from the degraded-ratio
1226 # denominator (uploader-disabled captions can never produce a transcript;
1227 # counting them was producing false-positive stale-yt-dlp nudges).
1228 core_topic = _extract_core_subject(topic)
1229 for item in items:
1230 vid = item["video_id"]
1231 transcript = transcripts.get(vid)
1232 item["transcript_snippet"] = transcript or ""
1233 item["transcript_highlights"] = extract_transcript_highlights(
1234 transcript or "", core_topic,
1235 )
1236 item["captions_disabled"] = vid in captions_disabled_ids
1237
1238 result: Dict[str, Any] = {"items": items}
1239 if search_error:
1240 # Partial coverage: some queries succeeded; keep the failure visible so
1241 # source_status becomes partial/timeout rather than a quiet OK.
1242 result["error"] = search_error
1243 return result
1244
1245
1246 def parse_youtube_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
1247 """Parse YouTube search response to normalized format.
1248
1249 Returns:
1250 List of item dicts ready for normalization.
1251 """
1252 return response.get("items", [])
1253
1254
1255 # ---------------------------------------------------------------------------
1256 # ScrapeCreators YouTube API support
1257 # ---------------------------------------------------------------------------
1258
1259 SCRAPECREATORS_YT_BASE = "https://api.scrapecreators.com/v1/youtube"
1260
1261
1262 def _total_engagement(item: Dict[str, Any]) -> int:
1263 """Combined engagement score for ranking which videos to enrich."""
1264 eng = item.get("engagement", {})
1265 views = eng.get("views", 0) or 0
1266 likes = eng.get("likes", 0) or 0
1267 comments = eng.get("comments", 0) or 0
1268 return views + likes + comments
1269
1270
1271 def enrich_with_comments(
1272 items: List[Dict[str, Any]],
1273 token: str,
1274 max_videos: int = 3,
1275 max_comments: int = 5,
1276 ) -> List[Dict[str, Any]]:
1277 """Enrich top YouTube videos with comment data from ScrapeCreators.
1278
1279 For the top N videos by engagement, fetches comments via the SC API
1280 and attaches them as a ``top_comments`` field on each item.
1281
1282 Args:
1283 items: YouTube items from search_and_transcribe() or search_youtube_sc()
1284 token: ScrapeCreators API key
1285 max_videos: How many videos to enrich with comments
1286 max_comments: Max comments to keep per video
1287
1288 Returns:
1289 Items list (mutated in place) with top_comments added to enriched items.
1290 """
1291 if not items or max_videos <= 0:
1292 return items
1293 # yt-dlp needs no key, so an empty token is only fatal when it is absent too.
1294 if not token and not is_ytdlp_installed():
1295 return items
1296
1297 ranked = sorted(items, key=_total_engagement, reverse=True)
1298 top_items = ranked[:max_videos]
1299 _log(f"Enriching comments for {len(top_items)} YouTube videos")
1300
1301 from concurrent.futures import ThreadPoolExecutor, as_completed
1302
1303 def _enrich_one(item: dict) -> bool:
1304 video_id = item.get("video_id", "")
1305 if not video_id:
1306 return False
1307 try:
1308 comments = _fetch_video_comments(video_id, token, max_comments)
1309 if comments:
1310 item["top_comments"] = comments
1311 return True
1312 except Exception as exc:
1313 _log(f"Comment enrichment failed for {video_id}: {exc}")
1314 return False
1315
1316 enriched_count = 0
1317 with ThreadPoolExecutor(max_workers=min(4, len(top_items))) as executor:
1318 futures = {http.submit_with_context(executor, _enrich_one, item): item for item in top_items}
1319 for future in as_completed(futures):
1320 if future.result():
1321 enriched_count += 1
1322
1323 _log(f"Enriched {enriched_count}/{len(top_items)} videos with comments")
1324 return items
1325
1326
1327 def _ytdlp_comments_result(
1328 video_id: str,
1329 max_comments: int = 5,
1330 ) -> tuple[List[Dict[str, Any]], bool]:
1331 """Fetch top comments via yt-dlp, returning ``(comments, ran_cleanly)``.
1332
1333 The bool distinguishes "yt-dlp succeeded, this video simply has no
1334 comments" (True, []) from "yt-dlp was absent or errored" (False, []), so
1335 the caller only spends a ScrapeCreators credit on a genuine failure — not
1336 on a video that legitimately has zero comments. Mirrors the transcript
1337 path, which is likewise careful not to bill SC for a caption-less video.
1338
1339 Comments are sorted by top so a low ``max_comments`` still returns the
1340 highest-voted ones rather than an arbitrary slice.
1341 """
1342 if not is_ytdlp_installed():
1343 return [], False
1344
1345 cmd = _wrap_ytdlp_cmd([
1346 "yt-dlp",
1347 "--write-comments",
1348 "--skip-download",
1349 "--dump-single-json",
1350 "--no-warnings",
1351 "--ignore-config",
1352 "--extractor-args",
1353 f"youtube:comment_sort=top;max_comments={max_comments},all,{max_comments}",
1354 f"https://www.youtube.com/watch?v={video_id}",
1355 ])
1356
1357 try:
1358 result = _run_ytdlp(cmd, timeout=_COMMENT_TIMEOUT)
1359 except Exception as exc:
1360 _log(f"yt-dlp comment fetch failed for {video_id}: {exc}")
1361 return [], False
1362
1363 if result.returncode != 0 or not result.stdout:
1364 _log(f"yt-dlp comment fetch failed for {video_id} (exit {result.returncode})")
1365 return [], False
1366
1367 try:
1368 payload = json.loads(result.stdout)
1369 except (ValueError, TypeError) as exc:
1370 _log(f"yt-dlp comment JSON parse failed for {video_id}: {exc}")
1371 return [], False
1372
1373 comments = []
1374 for c in (payload.get("comments") or [])[:max_comments]:
1375 text = c.get("text") or ""
1376 if not text:
1377 continue
1378 comments.append({
1379 "author": c.get("author") or "",
1380 "text": text[:400],
1381 "likes": c.get("like_count") or 0,
1382 "date": c.get("_time_text") or "",
1383 })
1384 return comments, True
1385
1386
1387 def _fetch_video_comments_ytdlp(
1388 video_id: str,
1389 max_comments: int = 5,
1390 ) -> List[Dict[str, Any]]:
1391 """Comments for a video via yt-dlp (free, keyless), or [] on any failure.
1392
1393 Thin list-returning wrapper over ``_ytdlp_comments_result`` for callers
1394 that don't need to tell a clean empty result from a failure.
1395 """
1396 return _ytdlp_comments_result(video_id, max_comments)[0]
1397
1398
1399 def _fetch_video_comments(
1400 video_id: str,
1401 token: str,
1402 max_comments: int = 5,
1403 ) -> List[Dict[str, Any]]:
1404 """Fetch comments for one video, preferring the free yt-dlp path.
1405
1406 yt-dlp is tried first because it is keyless and costs nothing.
1407 ScrapeCreators stays as the backstop for when yt-dlp is absent or gets
1408 throttled, and is only called when a token is actually configured.
1409
1410 Args:
1411 video_id: YouTube video ID
1412 token: ScrapeCreators API key (may be empty — yt-dlp needs none)
1413 max_comments: Maximum comments to return
1414
1415 Returns:
1416 List of comment dicts with author, text, likes, date.
1417 """
1418 ytdlp_comments, ran_cleanly = _ytdlp_comments_result(video_id, max_comments)
1419 if ytdlp_comments:
1420 return ytdlp_comments
1421 # Clean run with no comments -> the video simply has none. Don't spend an
1422 # SC credit chasing comments that aren't there; only fall back on failure.
1423 if ran_cleanly:
1424 return []
1425
1426 if not token:
1427 return []
1428
1429 video_url = f"https://www.youtube.com/watch?v={video_id}"
1430 try:
1431 data = http.get(
1432 f"{SCRAPECREATORS_YT_BASE}/video/comments",
1433 params={"url": video_url},
1434 headers=http.scrapecreators_headers(token),
1435 timeout=30,
1436 retries=2,
1437 )
1438 except Exception as exc:
1439 _log(f"Comment fetch error for {video_id}: {exc}")
1440 return []
1441
1442 raw_comments = data.get("comments", data.get("data", []))
1443 comments = []
1444 for c in raw_comments[:max_comments]:
1445 text = c.get("text") or c.get("body") or c.get("content", "")
1446 if not text:
1447 continue
1448
1449 # SC returns author as {"name": "@handle", ...}; legacy mocks may pass a string.
1450 author = c.get("author") or c.get("author_name", "")
1451 if isinstance(author, dict):
1452 author = author.get("name") or author.get("handle") or ""
1453
1454 # SC nests likes under engagement.likes; legacy shapes used top-level keys.
1455 engagement = c.get("engagement") or {}
1456 likes = c.get("likes")
1457 if likes is None:
1458 likes = engagement.get("likes", 0) if isinstance(engagement, dict) else 0
1459 if not likes:
1460 likes = c.get("vote_count", 0)
1461
1462 date = (
1463 c.get("date")
1464 or c.get("published_at")
1465 or c.get("publishedTime")
1466 or c.get("publishedTimeText", "")
1467 )
1468
1469 comments.append({
1470 "author": author,
1471 "text": text[:400],
1472 "likes": likes,
1473 "date": date,
1474 })
1475
1476 return comments
1477
1478
1479 def search_youtube_sc(
1480 topic: str,
1481 from_date: str,
1482 to_date: str,
1483 depth: str = "default",
1484 token: str = None,
1485 ) -> Dict[str, Any]:
1486 """Search YouTube via ScrapeCreators API (fallback when yt-dlp is unavailable).
1487
1488 Uses SC keyword search to find videos and SC transcript endpoint to
1489 fetch transcripts. Called by pipeline.py when yt-dlp fails.
1490
1491 Args:
1492 topic: Search topic
1493 from_date: Start date (YYYY-MM-DD)
1494 to_date: End date (YYYY-MM-DD)
1495 depth: 'quick', 'default', or 'deep'
1496 token: ScrapeCreators API key
1497
1498 Returns:
1499 Dict with 'items' list of video metadata dicts.
1500 """
1501 if not token:
1502 return {"items": [], "error": "No SCRAPECREATORS_API_KEY configured"}
1503
1504 count = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
1505 core_topic = _extract_core_subject(topic)
1506 _log(f"Searching YouTube via ScrapeCreators for '{core_topic}' (depth={depth})")
1507
1508 # Step 1: Search
1509 raw_items = _sc_youtube_search(core_topic, token)
1510 if not raw_items:
1511 _log("SC YouTube search returned 0 results")
1512 return {"items": []}
1513
1514 # Parse into normalized items
1515 items = []
1516 for i, raw in enumerate(raw_items[:count]):
1517 video_id = (
1518 raw.get("id") or raw.get("video_id") or raw.get("videoId") or ""
1519 )
1520 title = raw.get("title", "")
1521 channel = raw.get("channel") or raw.get("channel_name") or raw.get("uploader", "")
1522 description = str(raw.get("description", ""))[:500]
1523 view_count = raw.get("view_count") or raw.get("views", 0)
1524 like_count = raw.get("like_count") or raw.get("likes", 0)
1525 comment_count = raw.get("comment_count") or raw.get("comments", 0)
1526
1527 # Date: try multiple field names
1528 date_str = raw.get("upload_date") or raw.get("date") or raw.get("published_at", "")
1529 if date_str and len(date_str) == 8 and date_str.isdigit():
1530 date_str = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]}"
1531 elif date_str and "T" in date_str:
1532 date_str = date_str[:10]
1533
1534 url = raw.get("url", "")
1535 if not url and video_id:
1536 url = f"https://www.youtube.com/watch?v={video_id}"
1537
1538 items.append({
1539 "video_id": video_id,
1540 "title": title,
1541 "url": url,
1542 "channel_name": channel,
1543 "date": date_str if date_str else None,
1544 "engagement": {
1545 "views": view_count or 0,
1546 "likes": like_count or 0,
1547 "comments": comment_count or 0,
1548 },
1549 "duration": raw.get("duration"),
1550 "relevance": _compute_relevance(core_topic, f"{title} {description}"),
1551 "why_relevant": f"YouTube: {title[:60]}" if title else f"YouTube: {core_topic}",
1552 "description": description,
1553 })
1554
1555 # Soft date filter
1556 recent = [i for i in items if i["date"] and i["date"] >= from_date]
1557 if len(recent) >= 3:
1558 items = recent
1559 _log(f"Found {len(items)} videos within date range")
1560 else:
1561 _log(f"Found {len(items)} videos ({len(recent)} within date range, keeping all)")
1562
1563 # Sort by views
1564 items.sort(key=lambda x: x["engagement"]["views"], reverse=True)
1565
1566 # Step 2: Fetch transcripts for top videos
1567 transcript_limit = TRANSCRIPT_LIMITS.get(depth, TRANSCRIPT_LIMITS["default"])
1568 if transcript_limit > 0 and items:
1569 attempt_count = min(len(items), transcript_limit * 3)
1570 # Same in-window-first ordering as search_and_transcribe(): don't let
1571 # an out-of-window back-catalog (kept by the soft date filter above)
1572 # consume the transcript budget of videos the freshness scorer keeps.
1573 in_window = [i for i in items if i.get("date") and i["date"] >= from_date]
1574 out_of_window = [i for i in items if not (i.get("date") and i["date"] >= from_date)]
1575 _log(f"Fetching SC transcripts for up to {attempt_count} videos (target: {transcript_limit})")
1576 for item in (in_window + out_of_window)[:attempt_count]:
1577 vid = item["video_id"]
1578 if not vid:
1579 continue
1580 transcript = _sc_fetch_transcript(vid, token)
1581 item["transcript_snippet"] = transcript or ""
1582 item["transcript_highlights"] = extract_transcript_highlights(
1583 transcript or "", core_topic,
1584 )
1585 else:
1586 for item in items:
1587 item["transcript_snippet"] = ""
1588 item["transcript_highlights"] = []
1589
1590 _log(f"SC YouTube: {len(items)} videos returned")
1591 return {"items": items}
1592
1593
1594 def _sc_youtube_search(keyword: str, token: str) -> List[Dict[str, Any]]:
1595 """Call ScrapeCreators YouTube search endpoint.
1596
1597 Args:
1598 keyword: Search keyword
1599 token: ScrapeCreators API key
1600
1601 Returns:
1602 List of raw video dicts from the API.
1603 """
1604 try:
1605 # SC's /v1/youtube/search rejects ?keyword= with HTTP 400; the canonical
1606 # parameter for that endpoint is `query`. Other SC endpoints use their
1607 # own per-endpoint param names so this was the lone outlier.
1608 data = http.get(
1609 f"{SCRAPECREATORS_YT_BASE}/search",
1610 params={"query": keyword},
1611 headers=http.scrapecreators_headers(token),
1612 timeout=30,
1613 retries=2,
1614 )
1615 return data.get("videos", data.get("data", data.get("items", [])))
1616 except Exception as exc:
1617 _log(f"SC YouTube search error: {exc}")
1618 return []
1619
1620
1621 def _sc_segment_text(seg: Any) -> str:
1622 """Extract caption text from a ScrapeCreators transcript segment.
1623
1624 The transcript endpoint returns a list of segment dicts
1625 (``{text, startMs, endMs}``); older/simpler shapes return plain strings.
1626 Pull the ``text`` field for dicts so segment metadata is not stringified
1627 into the output (``{'text': ...}`` garbage).
1628 """
1629 if isinstance(seg, dict):
1630 # `or ""` (not a get default): a present-but-null `text` returns None,
1631 # which would stringify to the literal "None" for silent/music segments.
1632 return str(seg.get("text") or "")
1633 return str(seg)
1634
1635
1636 def _warn_low_sc_credits(data: Dict[str, Any]) -> None:
1637 """Surface a low-credit warning from a ScrapeCreators response, if present."""
1638 credits = data.get("credits_remaining")
1639 if isinstance(credits, (int, float)) and not isinstance(credits, bool):
1640 if credits < _SC_LOW_CREDIT_THRESHOLD:
1641 _log(f"ScrapeCreators credits low: {int(credits)} remaining "
1642 f"(below {_SC_LOW_CREDIT_THRESHOLD})")
1643
1644
1645 def _sc_fetch_transcript(video_id: str, token: str) -> Optional[str]:
1646 """Fetch transcript for a YouTube video via ScrapeCreators.
1647
1648 Args:
1649 video_id: YouTube video ID
1650 token: ScrapeCreators API key
1651
1652 Returns:
1653 Plaintext transcript string, or None if unavailable.
1654 """
1655 video_url = f"https://www.youtube.com/watch?v={video_id}"
1656 try:
1657 # Isolate SC transcript fetch errors from the pipeline-level
1658 # capture_failures() context.
1659 with http.capture_failures() as _tf:
1660 data = http.get(
1661 f"{SCRAPECREATORS_YT_BASE}/video/transcript",
1662 params={"url": video_url},
1663 headers=http.scrapecreators_headers(token),
1664 timeout=30,
1665 retries=1,
1666 )
1667 except Exception as exc:
1668 _log(f"SC transcript error for {video_id}: {exc}")
1669 return None
1670
1671 _warn_low_sc_credits(data)
1672
1673 transcript = data.get("transcript")
1674 if not transcript:
1675 return None
1676
1677 if isinstance(transcript, list):
1678 transcript = " ".join(_sc_segment_text(seg) for seg in transcript).strip()
1679
1680 # Clean VTT formatting if present
1681 transcript = _clean_vtt(transcript)
1682
1683 # Truncate to max words
1684 words = transcript.split()
1685 if len(words) > TRANSCRIPT_MAX_WORDS:
1686 transcript = " ".join(words[:TRANSCRIPT_MAX_WORDS]) + "..."
1687
1688 return transcript if transcript else None
1689
1689 lines PYTHON