| 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 |