Files
last30days-skill/scripts/lib/youtube_yt.py
T
Matt Van Horn 775596ce21 feat: v2.9.6 — free-first NUX, cookie extraction, quality scoring
Setup wizard with consent-first cookie extraction (Chrome/Firefox/Safari),
yt-dlp auto-install, ScrapeCreators push, quality scoring (5 core sources),
status banner redesign, honest Reddit labeling, inline YouTube transcripts,
Exa free web search, Reddit public fallback, and post-research quality nudge.

Co-authored-by: Matt Van Horn <mvanhorn@MacBook-Pro.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 14:33:17 -07:00

539 lines
17 KiB
Python

"""YouTube search and transcript extraction via yt-dlp for /last30days v2.1.
Uses yt-dlp (https://github.com/yt-dlp/yt-dlp) for both YouTube search and
transcript extraction. No API keys needed — just have yt-dlp installed.
Inspired by Peter Steinberger's toolchain approach (yt-dlp + summarize CLI).
"""
import json
import math
import os
import re
import signal
import shutil
import subprocess
import sys
import tempfile
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
# Depth configurations: how many videos to search / transcribe
DEPTH_CONFIG = {
"quick": 10,
"default": 20,
"deep": 40,
}
TRANSCRIPT_LIMITS = {
"quick": 3,
"default": 5,
"deep": 8,
}
# Max words to keep from each transcript
TRANSCRIPT_MAX_WORDS = 5000
from .relevance import token_overlap_relevance as _compute_relevance
def extract_transcript_highlights(transcript: str, topic: str, limit: int = 5) -> List[str]:
"""Extract quotable highlights from a YouTube transcript.
Similar to reddit_enrich.extract_comment_insights() but for
continuous speech-to-text rather than threaded comments.
"""
if not transcript:
return []
sentences = re.split(r'(?<=[.!?])\s+', transcript)
filler = [
r"^(hey |hi |what's up|welcome back|in today's video|don't forget to)",
r"(subscribe|like and comment|hit the bell|check out the link|down below)",
r"^(so |and |but |okay |alright |um |uh )",
r"(thanks for watching|see you (next|in the)|bye)",
]
topic_words = [w.lower() for w in topic.lower().split() if len(w) > 2]
candidates = []
for sent in sentences:
sent = sent.strip()
words = sent.split()
if len(words) < 8 or len(words) > 50:
continue
if any(re.search(p, sent, re.IGNORECASE) for p in filler):
continue
score = 0
if re.search(r'\d', sent):
score += 2
if re.search(r'[A-Z][a-z]+', sent):
score += 1
if '?' in sent:
score += 1
sent_lower = sent.lower()
if any(w in sent_lower for w in topic_words):
score += 2
candidates.append((score, sent))
candidates.sort(key=lambda x: -x[0])
return [sent for _, sent in candidates[:limit]]
def _log(msg: str):
"""Log to stderr."""
sys.stderr.write(f"[YouTube] {msg}\n")
sys.stderr.flush()
def is_ytdlp_installed() -> bool:
"""Check if yt-dlp is available in PATH."""
return shutil.which("yt-dlp") is not None
def _extract_core_subject(topic: str) -> str:
"""Extract core subject from verbose query for YouTube search.
NOTE: 'tips', 'tricks', 'tutorial', 'guide', 'review', 'reviews'
are intentionally KEPT — they're YouTube content types that improve search.
"""
from .query import extract_core_subject
# YouTube-specific noise set: smaller than default, keeps content-type words
_YT_NOISE = frozenset({
'best', 'top', 'good', 'great', 'awesome', 'killer',
'latest', 'new', 'news', 'update', 'updates',
'trending', 'hottest', 'popular', 'viral',
'practices', 'features',
'recommendations', 'advice',
'prompt', 'prompts', 'prompting',
'methods', 'strategies', 'approaches',
})
return extract_core_subject(topic, noise=_YT_NOISE)
def search_youtube(
topic: str,
from_date: str,
to_date: str,
depth: str = "default",
) -> Dict[str, Any]:
"""Search YouTube via yt-dlp. No API key needed.
Args:
topic: Search topic
from_date: Start date (YYYY-MM-DD)
to_date: End date (YYYY-MM-DD)
depth: 'quick', 'default', or 'deep'
Returns:
Dict with 'items' list of video metadata dicts.
"""
if not is_ytdlp_installed():
return {"items": [], "error": "yt-dlp not installed"}
count = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
core_topic = _extract_core_subject(topic)
_log(f"Searching YouTube for '{core_topic}' (since {from_date}, count={count})")
# yt-dlp search with full metadata (no --flat-playlist so dates are real).
# NOTE: --dateafter intentionally omitted — YouTube search returns
# relevance-sorted results and strict date filtering returns 0 for
# evergreen topics. Python soft filter (below) handles date filtering.
cmd = [
"yt-dlp",
"--ignore-config",
"--no-cookies-from-browser",
f"ytsearch{count}:{core_topic}",
"--dump-json",
"--no-warnings",
"--no-download",
]
preexec = os.setsid if hasattr(os, 'setsid') else None
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
preexec_fn=preexec,
)
try:
stdout, stderr = proc.communicate(timeout=120)
except subprocess.TimeoutExpired:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except (ProcessLookupError, PermissionError, OSError):
proc.kill()
proc.wait(timeout=5)
_log("YouTube search timed out (120s)")
return {"items": [], "error": "Search timed out"}
except FileNotFoundError:
return {"items": [], "error": "yt-dlp not found"}
if not (stdout or "").strip():
_log("YouTube search returned 0 results")
return {"items": []}
# Parse JSON-per-line output
items = []
for line in stdout.strip().split("\n"):
line = line.strip()
if not line:
continue
try:
video = json.loads(line)
except json.JSONDecodeError:
continue
video_id = video.get("id", "")
view_count = video.get("view_count") or 0
like_count = video.get("like_count") or 0
comment_count = video.get("comment_count") or 0
upload_date = video.get("upload_date", "") # YYYYMMDD
# Convert YYYYMMDD to YYYY-MM-DD
date_str = None
if upload_date and len(upload_date) == 8:
date_str = f"{upload_date[:4]}-{upload_date[4:6]}-{upload_date[6:8]}"
items.append({
"video_id": video_id,
"title": video.get("title", ""),
"url": f"https://www.youtube.com/watch?v={video_id}",
"channel_name": video.get("channel", video.get("uploader", "")),
"date": date_str,
"engagement": {
"views": view_count,
"likes": like_count,
"comments": comment_count,
},
"duration": video.get("duration"),
"relevance": _compute_relevance(core_topic, video.get("title", "")),
"why_relevant": f"YouTube: {video.get('title', core_topic)[:60]}",
})
# Soft date filter: prefer recent items but fall back to all if too few
recent = [i for i in items if i["date"] and i["date"] >= from_date]
if len(recent) >= 3:
items = recent
_log(f"Found {len(items)} videos within date range")
else:
_log(f"Found {len(items)} videos ({len(recent)} within date range, keeping all)")
# Sort by views descending
items.sort(key=lambda x: x["engagement"]["views"], reverse=True)
return {"items": items}
def _clean_vtt(vtt_text: str) -> str:
"""Convert VTT subtitle format to clean plaintext."""
# Strip VTT header
text = re.sub(r'^WEBVTT.*?\n\n', '', vtt_text, flags=re.DOTALL)
# Strip timestamps
text = re.sub(r'\d{2}:\d{2}:\d{2}\.\d{3}\s*-->\s*\d{2}:\d{2}:\d{2}\.\d{3}.*\n', '', text)
# Strip position/alignment tags
text = re.sub(r'<[^>]+>', '', text)
# Strip cue numbers
text = re.sub(r'^\d+\s*$', '', text, flags=re.MULTILINE)
# Deduplicate overlapping lines
lines = text.strip().split('\n')
seen = set()
unique = []
for line in lines:
stripped = line.strip()
if stripped and stripped not in seen:
seen.add(stripped)
unique.append(stripped)
return re.sub(r'\s+', ' ', ' '.join(unique)).strip()
_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"
def _fetch_transcript_direct(video_id: str, timeout: int = 30) -> Optional[str]:
"""Fetch YouTube transcript via direct HTTP without yt-dlp.
Scrapes the watch page HTML for the captions track URL in
ytInitialPlayerResponse, then fetches the VTT subtitle file.
Args:
video_id: YouTube video ID
timeout: HTTP request timeout in seconds
Returns:
Raw VTT text, or None if captions are unavailable.
"""
watch_url = f"https://www.youtube.com/watch?v={video_id}"
headers = {
"User-Agent": _YT_USER_AGENT,
"Accept-Language": "en-US,en;q=0.9",
}
# Step 1: Fetch the watch page HTML
req = urllib.request.Request(watch_url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
html = resp.read().decode("utf-8", errors="replace")
except (urllib.error.URLError, urllib.error.HTTPError, OSError, TimeoutError) as exc:
_log(f"Direct transcript: failed to fetch watch page for {video_id}: {exc}")
return None
# Step 2: Extract captions URL from ytInitialPlayerResponse
# YouTube embeds this as a JS variable in the page HTML
match = re.search(
r'ytInitialPlayerResponse\s*=\s*(\{.+?\})\s*;(?:\s*var\s|\s*<\/script>)',
html,
)
if not match:
# Fallback: try the JSON embedded in the script tag
match = re.search(
r'var\s+ytInitialPlayerResponse\s*=\s*(\{.+?\})\s*;',
html,
)
if not match:
_log(f"Direct transcript: no ytInitialPlayerResponse found for {video_id}")
return None
try:
player_response = json.loads(match.group(1))
except json.JSONDecodeError:
_log(f"Direct transcript: failed to parse ytInitialPlayerResponse for {video_id}")
return None
# Navigate to caption tracks
captions = player_response.get("captions", {})
renderer = captions.get("playerCaptionsTracklistRenderer", {})
caption_tracks = renderer.get("captionTracks", [])
if not caption_tracks:
_log(f"Direct transcript: no caption tracks for {video_id}")
return None
# Find English track (prefer exact 'en', then any en variant, then first track)
base_url = None
for track in caption_tracks:
lang = track.get("languageCode", "")
if lang == "en":
base_url = track.get("baseUrl")
break
if not base_url:
for track in caption_tracks:
lang = track.get("languageCode", "")
if lang.startswith("en"):
base_url = track.get("baseUrl")
break
if not base_url:
# Fall back to first available track
base_url = caption_tracks[0].get("baseUrl")
if not base_url:
_log(f"Direct transcript: no baseUrl in caption tracks for {video_id}")
return None
# Step 3: Fetch the VTT subtitle file
sep = "&" if "?" in base_url else "?"
vtt_url = f"{base_url}{sep}fmt=vtt"
vtt_req = urllib.request.Request(vtt_url, headers=headers)
try:
with urllib.request.urlopen(vtt_req, timeout=timeout) as resp:
vtt_text = resp.read().decode("utf-8", errors="replace")
except (urllib.error.URLError, urllib.error.HTTPError, OSError, TimeoutError) as exc:
_log(f"Direct transcript: failed to fetch VTT for {video_id}: {exc}")
return None
if not vtt_text or not vtt_text.strip():
return None
return vtt_text
def _fetch_transcript_ytdlp(video_id: str, temp_dir: str) -> Optional[str]:
"""Fetch transcript using yt-dlp (original implementation).
Args:
video_id: YouTube video ID
temp_dir: Temporary directory for subtitle files
Returns:
Raw VTT text, or None if no captions available.
"""
cmd = [
"yt-dlp",
"--ignore-config",
"--no-cookies-from-browser",
"--write-auto-subs",
"--sub-lang", "en",
"--sub-format", "vtt",
"--skip-download",
"--no-warnings",
"-o", f"{temp_dir}/%(id)s",
f"https://www.youtube.com/watch?v={video_id}",
]
preexec = os.setsid if hasattr(os, 'setsid') else None
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
preexec_fn=preexec,
)
try:
proc.communicate(timeout=30)
except subprocess.TimeoutExpired:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except (ProcessLookupError, PermissionError, OSError):
proc.kill()
proc.wait(timeout=5)
return None
except FileNotFoundError:
return None
# yt-dlp may save as .en.vtt or .en-orig.vtt
vtt_path = Path(temp_dir) / f"{video_id}.en.vtt"
if not vtt_path.exists():
# Try alternate naming
for p in Path(temp_dir).glob(f"{video_id}*.vtt"):
vtt_path = p
break
else:
return None
try:
return vtt_path.read_text(encoding="utf-8", errors="replace")
except OSError:
return None
def fetch_transcript(video_id: str, temp_dir: str) -> Optional[str]:
"""Fetch auto-generated transcript for a YouTube video.
Uses yt-dlp when available (preferred, more robust). Falls back to
direct HTTP transcript fetching when yt-dlp is not installed.
Args:
video_id: YouTube video ID
temp_dir: Temporary directory for subtitle files
Returns:
Plaintext transcript string, or None if no captions available.
"""
raw_vtt = None
if is_ytdlp_installed():
raw_vtt = _fetch_transcript_ytdlp(video_id, temp_dir)
else:
_log("yt-dlp not installed, using direct HTTP transcript fetch")
raw_vtt = _fetch_transcript_direct(video_id)
if not raw_vtt:
return None
transcript = _clean_vtt(raw_vtt)
# Truncate to max words
words = transcript.split()
if len(words) > TRANSCRIPT_MAX_WORDS:
transcript = ' '.join(words[:TRANSCRIPT_MAX_WORDS]) + '...'
return transcript if transcript else None
def fetch_transcripts_parallel(
video_ids: List[str],
max_workers: int = 5,
) -> Dict[str, Optional[str]]:
"""Fetch transcripts for multiple videos in parallel.
Args:
video_ids: List of YouTube video IDs
max_workers: Max parallel fetches
Returns:
Dict mapping video_id to transcript text (or None).
"""
if not video_ids:
return {}
_log(f"Fetching transcripts for {len(video_ids)} videos")
results = {}
with tempfile.TemporaryDirectory() as temp_dir:
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(fetch_transcript, vid, temp_dir): vid
for vid in video_ids
}
for future in as_completed(futures):
vid = futures[future]
try:
results[vid] = future.result()
except Exception:
results[vid] = None
got = sum(1 for v in results.values() if v)
_log(f"Got transcripts for {got}/{len(video_ids)} videos")
return results
def search_and_transcribe(
topic: str,
from_date: str,
to_date: str,
depth: str = "default",
) -> Dict[str, Any]:
"""Full YouTube search: find videos, then fetch transcripts for top results.
Args:
topic: Search topic
from_date: Start date (YYYY-MM-DD)
to_date: End date (YYYY-MM-DD)
depth: 'quick', 'default', or 'deep'
Returns:
Dict with 'items' list. Each item has a 'transcript_snippet' field.
"""
# Step 1: Search
search_result = search_youtube(topic, from_date, to_date, depth)
items = search_result.get("items", [])
if not items:
return search_result
# Step 2: Fetch transcripts for top N by views
transcript_limit = TRANSCRIPT_LIMITS.get(depth, TRANSCRIPT_LIMITS["default"])
top_ids = [item["video_id"] for item in items[:transcript_limit]]
transcripts = fetch_transcripts_parallel(top_ids)
# Step 3: Attach transcripts and extract highlights
core_topic = _extract_core_subject(topic)
for item in items:
vid = item["video_id"]
transcript = transcripts.get(vid)
item["transcript_snippet"] = transcript or ""
item["transcript_highlights"] = extract_transcript_highlights(
transcript or "", core_topic,
)
return {"items": items}
def parse_youtube_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Parse YouTube search response to normalized format.
Returns:
List of item dicts ready for normalization.
"""
return response.get("items", [])