jarvis-cloud / backend /tools /youtube_tools.py
Jarvis2345's picture
Squash history — remove all prior commits (secret hygiene, S4)
a31f556
Raw
History Blame
7.23 kB
# 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)}