"""Open-source reCAPTCHA v2 audio-challenge solver (X13, Tier 1). Pure open-source, zero-API-cost: switch the reCAPTCHA widget to its **audio** challenge, download the mp3, transcribe it with a **local Whisper** model (``faster-whisper``), and submit the transcript. This is the well-known "Buster"-style technique. It is intentionally a *best-effort* solver — reCAPTCHA actively fights audio solving, so a non-trivial failure rate is expected and handled by the Tier-2 human fallback (INV-CF3), never hidden. Model is loaded lazily and cached; ``WHISPER_MODEL`` (default ``small``) and ``WHISPER_DEVICE`` (default ``cpu``) tune it. The dependency is optional — if ``faster-whisper`` isn't installed, ``transcribe_audio`` raises a clear error so the caller falls back to a human solve rather than crashing the service. """ from __future__ import annotations import logging import os import tempfile import httpx logger = logging.getLogger(__name__) _WHISPER_MODEL_NAME = os.environ.get("WHISPER_MODEL", "small") _WHISPER_DEVICE = os.environ.get("WHISPER_DEVICE", "cpu") _model = None class AudioSolveUnavailable(RuntimeError): """faster-whisper isn't installed — cannot solve audio locally.""" def _get_model(): global _model if _model is not None: return _model try: from faster_whisper import WhisperModel # type: ignore except ImportError as e: raise AudioSolveUnavailable( "faster-whisper אינו מותקן — לא ניתן לפתור reCAPTCHA אודיו מקומית. " "התקן `pip install faster-whisper` או הסתמך על fallback אנושי (VNC)." ) from e logger.info("loading whisper model %s on %s", _WHISPER_MODEL_NAME, _WHISPER_DEVICE) _model = WhisperModel( _WHISPER_MODEL_NAME, device=_WHISPER_DEVICE, compute_type="int8" ) return _model async def download_audio(audio_url: str) -> bytes: async with httpx.AsyncClient(timeout=30, follow_redirects=True) as c: r = await c.get(audio_url) r.raise_for_status() return r.content def transcribe_audio(mp3_bytes: bytes) -> str: """Transcribe a reCAPTCHA audio clip to its (English) digit/word phrase. Raises ``AudioSolveUnavailable`` if the local model isn't installed. """ model = _get_model() with tempfile.NamedTemporaryFile(suffix=".mp3", delete=True) as f: f.write(mp3_bytes) f.flush() # reCAPTCHA audio is English regardless of page locale. segments, _info = model.transcribe(f.name, language="en") text = " ".join(seg.text for seg in segments).strip() # Normalise: reCAPTCHA expects the bare phrase, lower-case, no punctuation. cleaned = "".join(ch for ch in text.lower() if ch.isalnum() or ch.isspace()) return " ".join(cleaned.split()) async def solve_from_audio_url(audio_url: str) -> str: """Convenience: download + transcribe an audio-challenge URL.""" mp3 = await download_audio(audio_url) return transcribe_audio(mp3)