Files
awesome-kubernetes/src/gemini_utils.py
Nubenetes Bot 4e87c248fd feat: implement AI-powered news digest engine, MkDocs UX overhaul, pipeline hardening, and stub page merges
- Add 26-category news digest engine (src/news_digest.py) with Gemini AI ranking
  for 3/6/12 month temporal panels across tech, cloud, and geo categories
- Add discovered_at, company, geo_region fields to inventory schema with backfill
  script populating 18K+ existing entries
- Fix critical v2-mkdocs.yml bug: plugins were nested under theme (silently disabled)
- Add MkDocs Material features: instant nav, breadcrumbs, footer, announce bar
- Add trending cards CSS grid and replace Agentic Pulse with dynamic Trending Now
- Generate tech-digest.md and industry-digest.md with tabbed 3/6/12 month views
- Merge 12 stub pages (<40 lines each) into parent categories with redirects
- Replace 50 bare except:pass patterns with contextual logging across all pipeline files
- Expand autonomous discovery from 6 to 14 GitHub search queries
- Add stale health re-check for online entries older than 30 days
- Track addition_method by source type (rss, twitter, github_trending)
- Add digest generation step to CI publish workflow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 00:38:56 +02:00

599 lines
28 KiB
Python

import httpx
import asyncio
import random
import json
import re
import os
import time
from datetime import datetime
from typing import Dict, Any, List, Optional
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
from src.config import GEMINI_API_KEYS, GEMINI_API_VERSION, GEMINI_API_KEYS_DATA
from src.logger import log_event
# Global state for rate limiting and discovery
CURRENT_KEY_INDEX = 0
DISCOVERED_MODELS = []
GLOBAL_COOLDOWN_UNTIL = 0
THROTTLED_MODELS = {} # {model_name: timestamp}
GLOBAL_AI_SEMAPHORE = asyncio.Semaphore(15) # Increased to 15 for high-tier processing
class GeminiSessionTracker:
def __init__(self):
self.model_usage = {} # {model_name: count}
self.key_stats = {i: {"calls": 0, "429s": 0, "404s": 0, "type": GEMINI_API_KEYS_DATA[i]["type"], "label": GEMINI_API_KEYS_DATA[i]["label"]} for i in range(len(GEMINI_API_KEYS))}
self.discovery_log = []
self.start_time = datetime.now()
self.total_throttles = 0
self.total_tokens_prompt = 0
self.total_tokens_completion = 0
self.cache_hits = 0
self.est_tokens_saved = 0
def track_cache_hit(self, est_tokens: int = 1500):
self.cache_hits += 1
self.est_tokens_saved += est_tokens
def track_call(self, key_idx: int, model: str, status: int, usage: Dict = None, role: str = "General"):
if status == 200:
self.model_usage[model] = self.model_usage.get(model, 0) + 1
# Track by Role for Agentic Observability
self.key_stats[key_idx].setdefault("roles", {})
self.key_stats[key_idx]["roles"][role] = self.key_stats[key_idx]["roles"].get(role, 0) + 1
if usage:
self.total_tokens_prompt += usage.get("promptTokenCount", 0)
self.total_tokens_completion += usage.get("candidatesTokenCount", 0)
self.key_stats[key_idx]["calls"] += 1
if status == 429:
self.key_stats[key_idx]["429s"] += 1
self.total_throttles += 1
if status == 404: self.key_stats[key_idx]["404s"] += 1
def get_intelligence_report(self) -> str:
report = "\n### 🧠 AI Intelligence & Observability Report\n\n"
report += "#### 🤖 Agentic Roles & Model Selection (Dynamic)\n"
report += f"Execution utilized a multi-agent Analyst-Auditor workflow for maximum robustness.\n\n"
report += "| Agent Role | Model Used | Successes |\n| :--- | :--- | :---: |\n"
role_stats = {}
for idx, stats in self.key_stats.items():
for role, count in stats.get("roles", {}).items():
role_stats[role] = role_stats.get(role, 0) + count
for role, count in role_stats.items():
report += f"| **{role}** | Dynamic Selection | **{count}** |\n"
report += "\n#### 🤖 Model Performance Matrix\n"
report += "| Model Used | Successful Calls | Hierarchy Logic |\n| :--- | :---: | :--- |\n"
usage_items = sorted(self.model_usage.items(), key=lambda x: x[1], reverse=True)
for model, count in usage_items:
logic = "Elite Auditor (High Reasoning)" if "pro" in model else "Fast Analyst (High Speed)"
report += f"| `{model}` | **{count}** | {logic} |\n"
if not self.model_usage: report += "| No AI calls | 0 | N/A |\n"
report += "\n#### 🔑 API Infrastructure & Quota Management\n"
report += "| Key Index | Type | Provider Label | Usage | Errors (429/404) |\n| :--- | :--- | :--- | :---: | :---: |\n"
for idx, stats in self.key_stats.items():
usage_bar = "" * min(stats["calls"] // 5, 10) or ""
report += f"| Key {idx+1} | `{stats['type']}` | {stats['label']} | {usage_bar} ({stats['calls']}) | {stats['429s']} / {stats['404s']} |\n"
report += f"\n#### 📊 Consumption and Efficiency Metrics (2026 Units)\n"
report += f"- **Total Prompt Tokens**: {self.total_tokens_prompt:,}\n"
report += f"- **Total Completion Tokens**: {self.total_tokens_completion:,}\n"
# Financial Cost (EUR) based on Gemini 1.5 Flash approx rates
est_cost_eur = (self.total_tokens_prompt * 0.075 / 1_000_000) + (self.total_tokens_completion * 0.30 / 1_000_000)
report += f"- **💰 Estimated Cost**: **{est_cost_eur:.4f} €**\n"
# Cache-First Metrics
hit_ratio = (self.cache_hits / (self.cache_hits + sum(self.model_usage.values())) * 100) if (self.cache_hits + sum(self.model_usage.values())) > 0 else 0
report += f"- **Database-First Cache Hits**: **{self.cache_hits}** ({hit_ratio:.1f}% hit ratio)\n"
report += f"- **Estimated Tokens Saved**: ~{self.est_tokens_saved:,} (Zero-API cost)\n"
report += f"- **Execution Efficiency**: {((self.total_tokens_completion / self.total_tokens_prompt * 100) if self.total_tokens_prompt > 0 else 0):.1f}% (Completion/Prompt)\n"
status_msg = f"{len(DISCOVERED_MODELS)} models verified."
if self.total_throttles > 0:
status_msg += f" **Adaptive Tiering active ({self.total_throttles} throttles managed).**"
report += f"\n*Status: {status_msg} System auto-adopted newest versions found.*"
return report
SESSION_TRACKER = GeminiSessionTracker()
async def discover_optimal_models():
global DISCOVERED_MODELS
if DISCOVERED_MODELS: return DISCOVERED_MODELS
log_event("[*] Starting AI Model Auto-Discovery...", section_break=True)
all_supported = []
for key in GEMINI_API_KEYS:
try:
async with httpx.AsyncClient() as client:
url = f"https://generativelanguage.googleapis.com/v1beta/models?key={key}"
resp = await client.get(url, timeout=10)
if resp.status_code == 200:
models_data = resp.json().get("models", [])
for m in models_data:
name = m.get("name", "").replace("models/", "")
if "generateContent" in m.get("supportedGenerationMethods", []):
if name not in all_supported: all_supported.append(name)
elif resp.status_code == 429:
log_event(f" [!] Discovery Key is rate-limited (429). Skipping.")
except Exception as e:
log_event(f"[WARN] model discovery for key: {str(e)[:100]}")
if not all_supported:
log_event(" [!] Discovery failed. Falling back to safe defaults.")
DISCOVERED_MODELS = ["gemini-1.5-flash-latest", "gemini-1.5-flash", "gemini-1.5-pro"]
return DISCOVERED_MODELS
def score_model(name: str) -> float:
score = 0.0
version_match = re.search(r'(\d+\.\d+)', name)
if version_match:
try:
version = float(version_match.group(1))
score += version * 50
except Exception as e:
log_event(f"[WARN] parse model version for {name}: {str(e)[:100]}")
if "-ultra" in name: score += 100
elif "-pro" in name: score += 50
elif "-flash" in name: score += 25
elif "-lite" in name: score += 10
if "-latest" in name: score += 5
if "experimental" in name or "exp" in name: score -= 15
return score
DISCOVERED_MODELS = sorted(all_supported, key=score_model, reverse=True)
log_event(f" [+] Discovered {len(DISCOVERED_MODELS)} suitable models.")
log_event(f" [+] Top Tier AI: {', '.join(DISCOVERED_MODELS[:3])}")
return DISCOVERED_MODELS
class GeminiDiagnostics:
def __init__(self):
self.attempts = []
def add_attempt(self, model: str, status: int, error: str = None, response_text: str = None):
self.attempts.append({"model": model, "status": status, "error": error, "response_preview": response_text[:200] if response_text else None})
def get_report(self) -> str:
report = "DIAGNÓSTICO GEMINI:\n"
for i, a in enumerate(self.attempts):
report += f" {i+1}. [{a['model']}] Status: {a['status']}"
if a['error']: report += f" | Error: {a['error']}"
if a['response_preview']: report += f" | Resp: {a['response_preview']}"
report += "\n"
return report
async def resolve_url(url: str) -> str:
shorteners = ['t.co', 'bit.ly', 'buff.ly', 'goo.gl', 'tinyurl.com', 't.ly', 'rb.gy', 'is.gd', 'drp.li', 't.me', 'lnkd.in']
try: domain = url.split("//")[-1].split("/")[0].lower()
except Exception as e:
log_event(f"[WARN] parse domain from URL {url[:50]}: {str(e)[:100]}")
return url
final_url, max_hops, current_hop = url, 5, 0
async with httpx.AsyncClient(follow_redirects=True, timeout=8) as client:
while current_hop < max_hops:
try:
current_domain = final_url.split("//")[-1].split("/")[0].lower()
if current_hop > 0 and current_domain not in shorteners: break
resp = await client.head(final_url, timeout=5)
new_url = str(resp.url)
if new_url == final_url: break
final_url, current_hop = new_url, current_hop + 1
except Exception as e:
log_event(f"[WARN] resolve URL hop for {final_url[:50]}: {str(e)[:100]}")
break
# Mandate 34: Prevent multiple trailing slashes using centralized utility
return sanitize_trailing_slashes(final_url)
def clean_toc_text(text: str) -> str:
"""
Ensures technical titles and TOC entries are robust.
Strips emojis, replaces ampersands, and removes special chars.
"""
if not text: return ""
# 1. Replace ampersands
text = text.replace("&", "and")
# 2. Strip Emojis (Regex for Unicode emoji ranges)
text = re.sub(r'[\U00010000-\U0010ffff]', '', text)
# 3. Strip other common problematic non-alphanumeric chars (except spaces and hyphens)
text = re.sub(r'[^\w\s\-.]', '', text)
return text.strip()
async def get_github_activity(url: str) -> Dict:
"""
Mandate 15: Fetch real-time GitHub metadata (stars, license, last push).
"""
default_meta = {
"gh_stars": 0,
"gh_pushed": "N/A",
"gh_license": "N/A"
}
match = re.search(r'github\.com/([^/]+/[^/]+)', url)
if not match: return default_meta
repo = match.group(1).split('#')[0].split('?')[0].rstrip('/')
api_url = f"https://api.github.com/repos/{repo}"
# Import GH_TOKEN from config locally to avoid circular dependencies
try:
from src.config import GH_TOKEN
headers = {"Authorization": f"token {GH_TOKEN}"} if GH_TOKEN else {}
except Exception as e:
log_event(f"[WARN] import GH_TOKEN for GitHub activity: {str(e)[:100]}")
headers = {}
try:
async with httpx.AsyncClient() as client:
resp = await client.get(api_url, headers=headers, timeout=10.0)
if resp.status_code == 200:
data = resp.json()
lic = data.get("license")
lic_id = lic.get("spdx_id", "N/A") if isinstance(lic, dict) else "N/A"
return {
"gh_stars": data.get("stargazers_count", 0),
"gh_pushed": data.get("pushed_at", "N/A"),
"gh_license": lic_id
}
except Exception as e:
log_event(f"[WARN] fetch GitHub activity for {url}: {str(e)[:100]}")
return default_meta
def sanitize_trailing_slashes(url: str) -> str:
"""
Mandate 34: Enforces a ZERO trailing slash policy.
Removes ALL trailing slashes and question marks from the end of the URL.
Does NOT collapse slashes in the middle of the URL (to avoid breaking protocol or deep links).
"""
if not url or '://' not in url: return url
# Remove all trailing slashes and question marks from the end of the entire string
return url.rstrip('/').rstrip('?')
def normalize_url(url: str) -> str:
"""
Normalización de URLs de alta precisión para Nubenetes.
Preserva anclajes de línea (#L) y evita forzar minúsculas en rutas profundas.
"""
if not url: return ""
# 0. Mandate 34: Cleanup redundant slashes first
url = sanitize_trailing_slashes(url)
# 1. Separar fragmento (pero preservar si es técnico como #L123)
fragment = ""
if "#" in url:
url, fragment = url.split("#", 1)
if not re.match(r'^L\d+', fragment): fragment = "" # Solo preservamos anclajes de línea
# 2. Limpiar parámetros de tracking social (UTM, etc.)
# Mandate 24: Systematically remove trackers (X.com, LinkedIn, RedHat intcmp)
url = re.sub(r'(\?|&)(utm_[^&]+|s=[^&]+|t=[^&]+|ref=[^&]+|fbclid=[^&]+|intcmp=[^&]+|mc_cid=[^&]+|mc_eid=[^&]+)', '', url)
# Mandate 34: Remove all trailing slashes and question marks for internal canonical comparison
url = url.rstrip("/").rstrip("?")
# 3. Normalizar protocolo y dominio (Case Insensitive)
match = re.match(r'^(https?://)([^/]+)(.*)', url, re.IGNORECASE)
if match:
proto, domain, path = match.groups()
# El dominio es Case-Insensitive, el path puede ser Case-Sensitive
url = f"https://{domain.lower()}{path}"
return f"{url}#{fragment}" if fragment else url
def is_fuzzy_duplicate(url_a: str, url_b: str) -> bool:
return normalize_url(url_a) == normalize_url(url_b)
class GeminiQuotaExhausted(Exception):
pass
def handle_retry_error(retry_state):
import sys
log_event(" [🚨] CIRCUIT BREAKER TRIPPED: Tenacity exhausted retries. Emitting exit code 42.")
sys.exit(42)
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=2, min=4, max=60),
retry=retry_if_exception_type(GeminiQuotaExhausted),
retry_error_callback=handle_retry_error
)
async def call_gemini_with_retry(prompt: str, response_format: str = "json", max_retries: int = 3, prefer_flash: bool = False, use_grounding: bool = False, role: str = "General"):
global CURRENT_KEY_INDEX, GLOBAL_COOLDOWN_UNTIL
if os.environ.get("MOCK_DEBATE") == "true":
log_event(f" [MOCK AI] Intercepted call for role '{role}' in mock mode.")
if "Analyst-Fast" in role or "Analyst-Grounded" in role:
results = []
for line in prompt.splitlines():
line = line.strip()
match = re.match(r'^(\d+)\.\s+(.*?)\s+\((https?://.*?)\)', line)
if match:
idx = int(match.group(1))
title = match.group(2)
url = match.group(3)
stars = 3
if "awesome" in title.lower() or "best" in title.lower():
stars = 5
elif "operator" in title.lower() or "kubernetes" in title.lower():
stars = 4
results.append({
"idx": idx,
"year": "2026",
"stars": stars,
"hierarchy": ["Platform", "Reference"],
"tags": ["Resource", "Documentation"],
"summary": f"A curated reference on {title} for modern cloud native architectures.",
"language": "English",
"type": "Reference",
"complexity": "Intermediate",
"is_microservice": False
})
return {"results": results}
elif "Curator" in role:
results = []
for line in prompt.splitlines():
line = line.strip()
match = re.match(r'^-\s+(https?://[^\s:]+):', line)
if match:
url = match.group(1)
results.append({
"url": url,
"impact_score": 60,
"reputation_penalty": False,
"reputation_summary": "Clean reputation on community channels.",
"pub_date": "2026-01-01",
"primary_category": "Kubernetes",
"suggested_new_category": "Kubernetes",
"title": "Reference Resource",
"desc": "A validated Cloud Native resource.",
"en_summary": "A high-density reference guide containing validated implementation practices.",
"language": "English",
"type": "Reference",
"level": "Intermediate",
"technical_hierarchy": ["Kubernetes"],
"tags": ["RESOURCE", "DOCKER"],
"is_microservice": False
})
return results
elif "Link-Rescue" in role:
results = []
for line in prompt.splitlines():
line = line.strip()
match = re.match(r'^-\s+(.*?)\s*\|\s*(https?://[^\s]+)', line)
if match:
title = match.group(1)
url = match.group(2)
results.append({
"old_url": url,
"new_url": url
})
return results
elif "PR-Guardian" in role:
return "APPROVED\nNo violations found. Compliance with Nubenetes standards is 100%."
if response_format == "json":
return {}
else:
return "APPROVED"
if not GEMINI_API_KEYS: raise ValueError("No GEMINI_API_KEYS configured.")
models_pool = await discover_optimal_models()
diagnostics = GeminiDiagnostics()
consecutive_429s = 0
base_wait_time = 2.0
# 1. Smart Filtering and Re-ordering
if prefer_flash:
# Strict filter: Only allow flash/lite models
models = [m for m in models_pool if "flash" in m or "lite" in m]
if not models:
models = ["gemini-1.5-flash", "gemini-1.5-flash-latest"]
elif use_grounding:
# For grounding, we MANDATE Pro models as they have superior search/reasoning capabilities
models = [m for m in models_pool if "pro" in m]
if not models:
models = ["gemini-1.5-pro", "gemini-1.5-pro-latest"]
else:
models = models_pool
total_keys = len(GEMINI_API_KEYS)
async with GLOBAL_AI_SEMAPHORE:
for attempt_round in range(max_retries + 1):
now = time.time()
if now < GLOBAL_COOLDOWN_UNTIL:
await asyncio.sleep(GLOBAL_COOLDOWN_UNTIL - now)
for key_offset in range(total_keys):
current_idx = (CURRENT_KEY_INDEX + key_offset) % total_keys
api_key = GEMINI_API_KEYS[current_idx]
key_label = GEMINI_API_KEYS_DATA[current_idx]["label"]
async with httpx.AsyncClient() as client:
# Limit the number of models to try per key to avoid excessive timeouts
for model in models[:5]:
if THROTTLED_MODELS.get(f"{current_idx}_{model}", 0) > time.time():
continue
# Mandate 13: Detailed tracing for long-running workflows
# log_event(f" [AI] Attempt: Key {current_idx+1} ({key_label}) | Model: {model}")
full_model_name = f"models/{model}"
api_url = f"https://generativelanguage.googleapis.com/{GEMINI_API_VERSION}/{full_model_name}:generateContent?key={api_key}"
try:
# --- TOOL ENABLING (MCP-LIKE GROUNDING) ---
payload = {
"contents": [{"parts": [{"text": prompt}]}],
"tools": [{"google_search": {}}] if use_grounding else []
}
# TELEMETRY: Log Payload Size
payload_size = len(json.dumps(payload))
log_event(f" [TELEMETRY] Attempting Key {current_idx+1} | Model: {model} | Payload: ~{payload_size//4} tokens")
response = await client.post(api_url, json=payload, timeout=180.0)
resp_json = {}
try: resp_json = response.json()
except Exception as e:
log_event(f"[WARN] parse Gemini response JSON: {str(e)[:100]}")
usage = resp_json.get("usageMetadata", {})
SESSION_TRACKER.track_call(current_idx, model, response.status_code, usage, role=role)
if response.status_code == 200:
log_event(f" [AI] Success: Key {current_idx+1} | Model: {model} | Role: {role} | Tokens: P={usage.get('promptTokenCount',0)} C={usage.get('candidatesTokenCount',0)}")
CURRENT_KEY_INDEX = current_idx
if 'candidates' in resp_json and resp_json['candidates']:
text_resp = resp_json['candidates'][0]['content']['parts'][0]['text']
if response_format == "json":
match = re.search(r'\{.*\}|\[.*\]', text_resp, re.DOTALL)
if match:
try:
data = json.loads(match.group(0))
return data
except Exception as e:
log_event(f"[WARN] parse Gemini content JSON for model {model}: {str(e)[:100]}")
# QUALITY UPGRADE: If flash failed parsing, don't give up on the key, try a Pro model
if ("flash" in model or "lite" in model) and any("pro" in m for m in models):
diagnostics.add_attempt(model, 200, "Flash JSON error - Upgrading to Pro...")
continue
diagnostics.add_attempt(model, 200, "JSON not found")
break
return text_resp
diagnostics.add_attempt(model, 200, "No candidates")
break
elif response.status_code == 429:
consecutive_429s += 1
# TELEMETRY: Log Exact Rejection Reason
log_event(f" [TELEMETRY] 429 Details: {response.text[:200]}")
# 2. ADAPTIVE TIERING: Mark this specific model as throttled
throttle_duration = 30 if "pro" in model else 15
THROTTLED_MODELS[f"{current_idx}_{model}"] = time.time() + throttle_duration
# 3. GLOBAL THROTTLING: Slow down entire engine
GLOBAL_COOLDOWN_UNTIL = time.time() + 3.0
wait = base_wait_time * (1.8 ** (consecutive_429s - 1)) + random.uniform(1.0, 2.0)
log_event(f" [!] API 429 on `{model}` (Key {current_idx+1}). Tiering down & backing off {wait:.1f}s...")
await asyncio.sleep(wait)
# Continue to next model in current key (likely Flash)
continue
elif response.status_code == 404:
diagnostics.add_attempt(model, 404, "Not Found")
break
elif response.status_code in [500, 503, 504]:
log_event(f" [TELEMETRY] Server Error {response.status_code}: {response.text[:200]}")
diagnostics.add_attempt(model, response.status_code, "Server Error")
continue
else:
log_event(f" [TELEMETRY] Unknown Error {response.status_code}: {response.text[:200]}")
diagnostics.add_attempt(model, response.status_code, "API Error", response.text)
break
except Exception as e:
log_event(f" [TELEMETRY] Exception during request: {type(e).__name__} - {e}")
SESSION_TRACKER.track_call(current_idx, model, 0, {}, role=role)
diagnostics.add_attempt(model, 0, str(e))
break
if attempt_round < max_retries:
wait_round = base_wait_time * (2 ** attempt_round)
log_event(f" [!] Exhausted tier options in round {attempt_round+1}. Cooling down {wait_round}s...")
await asyncio.sleep(wait_round)
log_event(" [!] QUOTA EXHAUSTED: All keys and models rate-limited. Triggering Tenacity backoff...")
raise GeminiQuotaExhausted(f"Critical Gemini failure after adaptive tiering.\n{diagnostics.get_report()}")
async def fetch_youtube_metadata(url: str) -> Optional[Dict]:
"""
Fetches high-fidelity basic metadata (title, description) from a YouTube page.
Prioritizes Official YouTube Data API v3 if YOUTUBE_API_KEY is available.
Fallbacks to yt-dlp and eventually standard fetch.
"""
from src.config import YOUTUBE_API_KEY
# Extract Video ID
vid = None
if "/embed/" in url: vid = url.split("/embed/")[-1].split("?")[0]
elif "youtu.be/" in url: vid = url.split("youtu.be/")[-1].split("?")[0]
elif "v=" in url: vid = url.split("v=")[-1].split("&")[0]
if not vid: return None
# STRATEGY 1: Official YouTube Data API v3 (Guaranteed success)
if YOUTUBE_API_KEY:
try:
api_url = f"https://www.googleapis.com/youtube/v3/videos?part=snippet&id={vid}&key={YOUTUBE_API_KEY}"
async with httpx.AsyncClient() as client:
resp = await client.get(api_url, timeout=10.0)
if resp.status_code == 200:
data = resp.json()
if data.get("items"):
snippet = data["items"][0]["snippet"]
log_event(f" [YT-API] Success for {vid}: {snippet.get('title')}")
return {
"raw_title": snippet.get("title", "").strip(),
"raw_description": snippet.get("description", "").strip()[:3000]
}
except Exception as e:
log_event(f" [YT-API] Failed for {vid}: {e}")
# STRATEGY 2: Robust Extraction (yt-dlp)
try:
import yt_dlp
from youtube_transcript_api import YouTubeTranscriptApi
ydl_opts = {
'quiet': True,
'skip_download': True,
'force_generic_extractor': False,
'no_warnings': True,
'extractor_args': {
'youtube': {
'player_client': ['android', 'web_embedded'],
'skip': ['dash', 'hls']
}
}
}
# Extract basic info
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
title = info.get('title', 'YouTube Video')
description = info.get('description', '')
# Attempt to get transcript
transcript_text = ""
try:
transcript = YouTubeTranscriptApi.get_transcript(vid, languages=['en', 'es'])
transcript_text = " ".join([t['text'] for t in transcript[:100]])
except Exception as e:
log_event(f"[WARN] fetch YouTube transcript for {vid}: {str(e)[:100]}")
full_description = f"{description}\n\n[Transcript Snippet]: {transcript_text}" if transcript_text else description
return {
"raw_title": title.strip(),
"raw_description": full_description.strip()[:3000]
}
except Exception as e:
log_event(f" [!] Robust fetch failed for {url}: {e}")
return None