# backend/tools/youtube_tools.py # Returns structured status keys so the React layer can use i18n.t() for display text. import os import asyncio import logging from backend.tools.browser_tools import get_ephemeral_context, stealth_async from backend.tools.captcha_solver import solve_captcha_if_present async def scrape_youtube_transcript(url: str) -> dict: """ Navigates to a YT video, clicks show transcript, and scrapes it. If DOM scraping fails, falls back to downloading audio via yt-dlp and transcribing via Whisper. Returns status_key for i18n rendering on the frontend (youtube.scrape_success or youtube.no_transcript). """ try: async with get_ephemeral_context() as context: page = await context.new_page() if stealth_async: await stealth_async(page) await page.goto(url) await page.wait_for_timeout(2000) await solve_captcha_if_present(page) try: await page.wait_for_selector('#expand', timeout=5000) await page.click('#expand') await page.wait_for_timeout(1000) await page.click('button[aria-label="Show transcript"]') await page.wait_for_selector('ytd-transcript-segment-renderer', timeout=5000) segments = await page.locator('ytd-transcript-segment-renderer').all_inner_texts() transcript = "\n".join(segments) await page.close() return { "status": "ok", "status_key": "youtube.scrape_success", "transcript": transcript[:2000], "truncated": len(transcript) > 2000 } except Exception as e: logging.warning(f"YouTube DOM transcript failed: {e}. Falling back to yt-dlp + Whisper...") await page.close() # Fallback: yt-dlp + Whisper return await _fallback_whisper_transcript(url) except Exception as e: return {"status": "error", "status_key": "youtube.no_transcript", "detail": str(e)} async def _fallback_whisper_transcript(url: str) -> dict: import tempfile import subprocess with tempfile.TemporaryDirectory() as tmpdirname: audio_path = os.path.join(tmpdirname, "audio.wav") # Use yt-dlp to download the best audio format and convert to wav cmd = [ "yt-dlp", "-x", "--audio-format", "wav", "-o", audio_path, url ] try: process = await asyncio.create_subprocess_exec( *cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE ) stdout, stderr = await process.communicate() if process.returncode != 0: logging.error(f"yt-dlp failed: {stderr.decode()}") return {"status": "error", "status_key": "youtube.no_transcript", "detail": "yt-dlp download failed"} # If successful, transcription # yt-dlp might append .wav to the path depending on how it handles -o, let's ensure we find the file actual_audio_path = audio_path if not os.path.exists(audio_path): # Search for the downloaded file files = os.listdir(tmpdirname) if files: actual_audio_path = os.path.join(tmpdirname, files[0]) else: return {"status": "error", "status_key": "youtube.no_transcript", "detail": "Audio file not found after yt-dlp"} from backend.voice.stt import STTPipeline stt_engine = STTPipeline() transcript = await stt_engine.transcribe(actual_audio_path) if transcript: return { "status": "ok", "status_key": "youtube.scrape_success", "transcript": transcript[:2000], "truncated": len(transcript) > 2000 } else: return {"status": "error", "status_key": "youtube.no_transcript", "detail": "Whisper transcription returned empty"} except Exception as e: logging.error(f"Whisper fallback failed: {e}") return {"status": "error", "status_key": "youtube.no_transcript", "detail": str(e)} async def search_youtube(query: str, sort_by: str = "relevance", limit: int = 5) -> dict: """ Searches YouTube using yt-dlp's ytsearch capability. sort_by can be: 'relevance' (default), 'views', 'latest' Returns a dict with 'status', 'status_key', and 'results' (list of dicts). """ import asyncio import json import subprocess import logging try: # Fetch up to 15 results to have enough to sort if needed fetch_count = limit if sort_by == "relevance" else limit * 3 search_query = f"ytsearch{fetch_count}:{query}" if sort_by == "latest": search_query = f"ytsearchdate{fetch_count}:{query}" cmd = [ "yt-dlp", search_query, "--dump-json", "--flat-playlist", "--no-warnings" ] process = await asyncio.create_subprocess_exec( *cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE ) stdout, stderr = await process.communicate() if process.returncode != 0 and not stdout: logging.error(f"yt-dlp search failed: {stderr.decode()}") return {"status": "error", "status_key": "youtube.search_failed", "detail": "Search execution failed"} results = [] for line in stdout.decode('utf-8').splitlines(): if not line.strip(): continue try: data = json.loads(line) results.append({ "title": data.get("title", ""), "url": data.get("url", f"https://www.youtube.com/watch?v={data.get('id')}"), "views": data.get("view_count", 0), "uploader": data.get("uploader", ""), "duration": data.get("duration", 0), "upload_date": data.get("upload_date", "") }) except Exception as e: import logging; logging.getLogger(__name__).error(f"Swallowed exception: {e}") if sort_by == "views": results.sort(key=lambda x: x.get("views") or 0, reverse=True) results = results[:limit] return { "status": "ok", "status_key": "youtube.search_success", "results": results } except Exception as e: logging.error(f"YouTube search error: {e}") return {"status": "error", "status_key": "youtube.search_failed", "detail": str(e)}