Spaces:
Paused
Paused
| import logging | |
| import httpx | |
| import base64 | |
| import asyncio | |
| import time | |
| from app.config import get_settings | |
| logger = logging.getLogger(__name__) | |
| settings = get_settings() | |
| class CaptchaSolver: | |
| def __init__(self, api_key: str = None): | |
| self.api_key = api_key or settings.captcha_api_key | |
| self.base_url = "https://2captcha.com" | |
| async def solve_image_captcha(self, image_b64: str) -> str | None: | |
| """ | |
| Solves a standard image captcha using 2Captcha API via HTTPX. | |
| """ | |
| if not self.api_key: | |
| logger.warning("CaptchaSolver: No API Key provided.") | |
| return None | |
| try: | |
| async with httpx.AsyncClient(timeout=60) as client: | |
| # 1. Submit the captcha | |
| response = await client.post( | |
| f"{self.base_url}/in.php", | |
| data={ | |
| "key": self.api_key, | |
| "method": "base64", | |
| "body": image_b64, | |
| "json": 1 | |
| } | |
| ) | |
| res_data = response.json() | |
| if res_data.get("status") != 1: | |
| logger.error(f"Error submitting captcha: {res_data.get('request')}") | |
| return None | |
| captcha_id = res_data.get("request") | |
| # 2. Poll for the result | |
| for _ in range(20): # Max 100 seconds | |
| await asyncio.sleep(5) | |
| res_response = await client.get( | |
| f"{self.base_url}/res.php", | |
| params={ | |
| "key": self.api_key, | |
| "action": "get", | |
| "id": captcha_id, | |
| "json": 1 | |
| } | |
| ) | |
| res_data = res_response.json() | |
| if res_data.get("status") == 1: | |
| return res_data.get("request") | |
| if res_data.get("request") != "CAPCHA_NOT_READY": | |
| logger.error(f"Error getting captcha result: {res_data.get('request')}") | |
| return None | |
| logger.error("Captcha resolution timeout.") | |
| return None | |
| except Exception as e: | |
| logger.error(f"Error in CaptchaSolver: {e}") | |
| return None | |
| async def solve_image_captcha_local(self, image_b64: str) -> str | None: | |
| """ | |
| Solves an image captcha locally using ddddocr (Free, no API key). | |
| """ | |
| try: | |
| # We import ddddocr here to avoid heavy initialization on startup if not needed | |
| import ddddocr | |
| import base64 | |
| # Disable printing of ddddocr branding | |
| import sys | |
| import os | |
| # Decode the base64 image | |
| image_bytes = base64.b64decode(image_b64) | |
| # Instantiate ddddocr (it is fast enough to do on-the-fly, or could be cached) | |
| ocr = ddddocr.DdddOcr(show_ad=False, beta=True) | |
| res = ocr.classification(image_bytes) | |
| return res | |
| except Exception as e: | |
| logger.error(f"Error in local OCR CaptchaSolver: {e}") | |
| return None | |
| async def solve_image_captcha_preprocessed(self, image_bytes: bytes) -> str | None: | |
| """ | |
| Pre-procesamiento avanzado especΓfico para captchas Securimage (SSSalud, organismos AR). | |
| Pipeline de limpieza: | |
| 1. Escala de grises | |
| 2. Blur Gaussiano leve para reducir ruido de alta frecuencia | |
| 3. Threshold adaptativo tipo OTSU (mejor que el fijo 128) | |
| 4. Filtro mediano agresivo para romper lΓneas de interferencia | |
| 5. InversiΓ³n de colores si el fondo es oscuro | |
| 6. Upscale 2x para mejorar OCR | |
| 7. Prueba con ddddocr primero (rΓ‘pido, gratis) | |
| 8. Si falla β envΓa imagen limpia a Groq Vision (con preprocessing) | |
| """ | |
| import io | |
| import re | |
| try: | |
| from PIL import Image, ImageFilter, ImageEnhance, ImageOps | |
| import numpy as np | |
| img = Image.open(io.BytesIO(image_bytes)).convert("L") | |
| # 1. Blur Gaussiano leve β reduce ruido de alta frecuencia | |
| img = img.filter(ImageFilter.GaussianBlur(radius=1)) | |
| # 2. Threshold adaptativo OTSU via numpy | |
| arr = np.array(img) | |
| # Calcula el threshold Γ³ptimo (mΓ©todo de Otsu simplificado) | |
| hist, _ = np.histogram(arr, bins=256, range=(0, 256)) | |
| total = arr.size | |
| best_thresh, best_var = 0, 0.0 | |
| sumB, wB, total_sum = 0, 0, np.dot(np.arange(256), hist) | |
| for i, h in enumerate(hist): | |
| wB += h | |
| if wB == 0: | |
| continue | |
| wF = total - wB | |
| if wF == 0: | |
| break | |
| sumB += i * h | |
| mB = sumB / wB | |
| mF = (total_sum - sumB) / wF | |
| var = wB * wF * (mB - mF) ** 2 | |
| if var > best_var: | |
| best_var = var | |
| best_thresh = i | |
| binary = arr > best_thresh # True = blanco (texto), False = negro (fondo) | |
| # 3. Inferir si texto es claro sobre fondo oscuro (invertir si hace falta) | |
| white_ratio = binary.mean() | |
| if white_ratio > 0.5: | |
| binary = ~binary # texto oscuro sobre fondo claro: el estΓ‘ndar | |
| img_bin = Image.fromarray((binary * 255).astype(np.uint8)) | |
| # 4. Filtro mediano agresivo (size=5) para eliminar lΓneas de ruido | |
| img_bin = img_bin.filter(ImageFilter.MedianFilter(size=5)) | |
| # 5. Upscale 2x β mejora mucho el OCR en imΓ‘genes pequeΓ±as | |
| w, h = img_bin.size | |
| img_bin = img_bin.resize((w * 2, h * 2), Image.LANCZOS) | |
| # 6. Guardar PNG limpio | |
| buf = io.BytesIO() | |
| img_bin.save(buf, format="PNG") | |
| clean_bytes = buf.getvalue() | |
| # 7. Probar ddddocr con imagen limpia | |
| import ddddocr | |
| ocr = ddddocr.DdddOcr(show_ad=False, beta=True) | |
| result = ocr.classification(clean_bytes) | |
| cleaned = re.sub(r"[^A-Za-z0-9]", "", result) | |
| logger.info(f"[CaptchaPreProcess] ddddocr resultado: '{cleaned}'") | |
| if cleaned and len(cleaned) >= 4: | |
| return cleaned | |
| # 8. Fallback: enviar imagen LIMPIA a Groq Vision | |
| # (mayor chance de Γ©xito que la imagen original con ruido) | |
| if settings.groq_api_key: | |
| logger.info("[CaptchaPreProcess] ddddocr insuficiente, enviando imagen preprocesada a Groq Vision...") | |
| groq_result = await self.solve_image_captcha_groq(clean_bytes) | |
| if groq_result and len(groq_result) >= 3: | |
| logger.info(f"[CaptchaPreProcess] Groq Vision resultado: '{groq_result}'") | |
| return groq_result | |
| return cleaned if cleaned else None | |
| except ImportError: | |
| # numpy no disponible β usar versiΓ³n bΓ‘sica | |
| logger.warning("[CaptchaPreProcess] numpy no disponible, usando pipeline bΓ‘sico") | |
| try: | |
| from PIL import Image, ImageFilter | |
| img = Image.open(io.BytesIO(image_bytes)).convert("L") | |
| img = img.point(lambda x: 255 if x > 128 else 0) | |
| img = img.filter(ImageFilter.MedianFilter(size=3)) | |
| import ddddocr | |
| ocr = ddddocr.DdddOcr(show_ad=False, beta=True) | |
| buf = io.BytesIO() | |
| img.save(buf, format="PNG") | |
| result = ocr.classification(buf.getvalue()) | |
| cleaned = re.sub(r"[^A-Za-z0-9]", "", result) | |
| logger.info(f"[CaptchaPreProcess] ddddocr bΓ‘sico: '{cleaned}'") | |
| return cleaned if cleaned else None | |
| except Exception as e2: | |
| logger.error(f"[CaptchaPreProcess] Error pipeline bΓ‘sico: {e2}") | |
| return None | |
| except Exception as e: | |
| logger.error(f"[CaptchaPreProcess] Error pipeline avanzado: {e}") | |
| return None | |
| async def solve_image_captcha_groq(self, image_bytes: bytes, model: str = None) -> str | None: | |
| """ | |
| Resuelve un captcha de imagen usando Groq Vision (llama-4-scout-17b-16e-instruct). | |
| """ | |
| if not settings.groq_api_key: | |
| logger.warning("[CaptchaGroq] GROQ_API_KEY no configurada β fallback a ddddocr") | |
| return await self.solve_image_captcha_local( | |
| __import__("base64").b64encode(image_bytes).decode() | |
| ) | |
| groq_model = "meta-llama/llama-4-scout-17b-16e-instruct" | |
| try: | |
| import base64 | |
| image_b64 = base64.b64encode(image_bytes).decode("utf-8") | |
| magic = image_bytes[:4] | |
| if magic[:2] == b'\xff\xd8': | |
| mime = "image/jpeg" | |
| elif magic[:4] == b'\x89PNG': | |
| mime = "image/png" | |
| else: | |
| mime = "image/png" | |
| groq_url = "https://api.groq.com/openai/v1/chat/completions" | |
| payload = { | |
| "model": groq_model, | |
| "messages": [ | |
| { | |
| "role": "system", | |
| "content": ( | |
| "You are a CAPTCHA reader. You see distorted text images. " | |
| "Your ONLY job is to transcribe the characters exactly as they appear. " | |
| "Output ONLY the alphanumeric characters. No explanations, no quotes, no spaces." | |
| ) | |
| }, | |
| { | |
| "role": "user", | |
| "content": [ | |
| { | |
| "type": "image_url", | |
| "image_url": { | |
| "url": f"data:{mime};base64,{image_b64}" | |
| } | |
| }, | |
| { | |
| "type": "text", | |
| "text": "Transcribe the CAPTCHA text from this image. Output ONLY the characters." | |
| } | |
| ] | |
| } | |
| ], | |
| "max_tokens": 20, | |
| "temperature": 0.0 | |
| } | |
| async with httpx.AsyncClient(timeout=15) as client: | |
| resp = await client.post( | |
| groq_url, | |
| headers={"Authorization": f"Bearer {settings.groq_api_key}"}, | |
| json=payload | |
| ) | |
| if resp.status_code != 200: | |
| logger.warning(f"[CaptchaGroq] Groq Vision error {resp.status_code}: {resp.text[:200]}") | |
| # Fallback a ddddocr | |
| return await self.solve_image_captcha_local( | |
| __import__("base64").b64encode(image_bytes).decode() | |
| ) | |
| content = resp.json()["choices"][0]["message"]["content"].strip() | |
| # Limpiar la respuesta β quedarse solo con alfanumΓ©ricos | |
| import re | |
| clean = re.sub(r"[^A-Za-z0-9]", "", content) | |
| logger.info(f"[CaptchaGroq] Groq Vision resolviΓ³ captcha: '{clean}'") | |
| return clean if clean else None | |
| except Exception as e: | |
| logger.error(f"[CaptchaGroq] Error en Groq Vision: {e}") | |
| # Fallback a ddddocr | |
| try: | |
| return await self.solve_image_captcha_local( | |
| __import__("base64").b64encode(image_bytes).decode() | |
| ) | |
| except Exception: | |
| return None | |
| async def solve_recaptcha_v2(self, site_key: str, url: str) -> str | None: | |
| """ | |
| Solves reCAPTCHA v2. | |
| """ | |
| if not self.api_key: return None | |
| try: | |
| async with httpx.AsyncClient(timeout=120) as client: | |
| response = await client.post( | |
| f"{self.base_url}/in.php", | |
| data={ | |
| "key": self.api_key, | |
| "method": "userrecaptcha", | |
| "googlekey": site_key, | |
| "pageurl": url, | |
| "json": 1 | |
| } | |
| ) | |
| res_data = response.json() | |
| if res_data.get("status") != 1: return None | |
| captcha_id = res_data.get("request") | |
| for _ in range(40): # Max 200 seconds | |
| await asyncio.sleep(5) | |
| res_response = await client.get( | |
| f"{self.base_url}/res.php", | |
| params={"key": self.api_key, "action": "get", "id": captcha_id, "json": 1} | |
| ) | |
| res_data = res_response.json() | |
| if res_data.get("status") == 1: return res_data.get("request") | |
| if res_data.get("request") != "CAPCHA_NOT_READY": return None | |
| return None | |
| except Exception as e: | |
| logger.error(f"Error in reCAPTCHA solver: {e}") | |
| return None | |
| async def solve_recaptcha_v2_audio(self, page, iframe_selector: str = None, max_retries: int = 3) -> bool: | |
| """ | |
| Solves reCAPTCHA v2 VISIBLE on a page for free using browser automation, | |
| downloading the audio challenge, and transcribing it via Groq Whisper API. | |
| For visible reCAPTCHA with checkbox (#recaptcha-anchor). | |
| """ | |
| for attempt in range(max_retries): | |
| try: | |
| result = await self._solve_recaptcha_v2_audio_once(page, iframe_selector) | |
| if result: | |
| return True | |
| logger.warning(f"[reCAPTCHA] Intento {attempt+1} fallΓ³, reintentando en {(attempt+1)*3}s...") | |
| await asyncio.sleep((attempt + 1) * 3) | |
| except Exception as e: | |
| logger.warning(f"[reCAPTCHA] Intento {attempt+1} fallΓ³ con excepciΓ³n: {e}") | |
| await asyncio.sleep((attempt + 1) * 3) | |
| return False | |
| async def solve_recaptcha_v2_invisible(self, page, max_retries: int = 3) -> bool: | |
| """ | |
| Solves reCAPTCHA v2 INVISIBLE on a page for free using browser automation. | |
| Unlike visible reCAPTCHA, invisible reCAPTCHA has NO checkbox. | |
| It is triggered by grecaptcha.execute() and shows a challenge directly. | |
| This method: | |
| 1. Finds the anchor iframe (api2/anchor) | |
| 2. Clicks the anchor iframe element directly (no checkbox lookup) | |
| 3. Waits for the challenge iframe (api2/bframe) to appear | |
| 4. Solves the audio challenge using Groq Whisper API | |
| """ | |
| for attempt in range(max_retries): | |
| try: | |
| result = await self._solve_recaptcha_v2_invisible_once(page) | |
| if result: | |
| return True | |
| logger.warning(f"[reCAPTCHA-invisible] Intento {attempt+1} fallΓ³, reintentando en {(attempt+1)*3}s...") | |
| await asyncio.sleep((attempt + 1) * 3) | |
| except Exception as e: | |
| logger.warning(f"[reCAPTCHA-invisible] Intento {attempt+1} fallΓ³ con excepciΓ³n: {e}") | |
| await asyncio.sleep((attempt + 1) * 3) | |
| return False | |
| async def _solve_recaptcha_v2_invisible_once(self, page) -> bool: | |
| """ | |
| Single attempt to solve reCAPTCHA v2 INVISIBLE audio challenge. | |
| Key difference from visible: no checkbox, click the anchor iframe element directly. | |
| """ | |
| try: | |
| # Locate anchor iframe | |
| anchor_iframe = None | |
| anchor_element = None | |
| for frame in page.frames: | |
| if "api2/anchor" in frame.url: | |
| try: | |
| el = await frame.frame_element() | |
| if el and await el.is_visible(): | |
| anchor_iframe = frame | |
| anchor_element = el | |
| break | |
| except Exception: | |
| pass | |
| if not anchor_iframe: | |
| for frame in page.frames: | |
| if "api2/anchor" in frame.url: | |
| anchor_iframe = frame | |
| try: | |
| anchor_element = await frame.frame_element() | |
| except Exception: | |
| pass | |
| break | |
| if not anchor_iframe or not anchor_element: | |
| logger.error("[reCAPTCHA-invisible] Anchor iframe not found.") | |
| for i, frame in enumerate(page.frames): | |
| logger.debug(f"Frame {i}: url={frame.url}") | |
| return False | |
| # Click the anchor iframe element directly (no checkbox for invisible) | |
| try: | |
| await anchor_element.click(timeout=5000, force=True) | |
| logger.info("[reCAPTCHA-invisible] Clicked anchor iframe element") | |
| except Exception as e: | |
| logger.error(f"[reCAPTCHA-invisible] Failed to click anchor element: {e}") | |
| return False | |
| await asyncio.sleep(2) | |
| # Check if solved instantly | |
| try: | |
| checkbox = await anchor_iframe.query_selector("#recaptcha-anchor") | |
| if checkbox: | |
| is_checked = await checkbox.get_attribute("aria-checked") | |
| if is_checked == "true": | |
| logger.info("[reCAPTCHA-invisible] Solved instantly by reputation.") | |
| return True | |
| except Exception: | |
| pass | |
| # Wait for challenge iframe (bframe) to appear | |
| try: | |
| await page.wait_for_selector("iframe[src*='api2/bframe']", state="visible", timeout=8000) | |
| except Exception: | |
| pass | |
| # Locate challenge iframe | |
| challenge_iframe = None | |
| for frame in page.frames: | |
| if "api2/bframe" in frame.url: | |
| try: | |
| frame_element = await frame.frame_element() | |
| if frame_element and await frame_element.is_visible(): | |
| challenge_iframe = frame | |
| break | |
| except Exception: | |
| pass | |
| if not challenge_iframe: | |
| for frame in page.frames: | |
| if "api2/bframe" in frame.url: | |
| challenge_iframe = frame | |
| break | |
| if not challenge_iframe: | |
| logger.error("[reCAPTCHA-invisible] Challenge iframe (bframe) not found.") | |
| return False | |
| # Click audio challenge button | |
| audio_button = await challenge_iframe.query_selector("#recaptcha-audio-button") | |
| if not audio_button or not await audio_button.is_visible(): | |
| logger.warning("[reCAPTCHA-invisible] Audio button not found or invisible.") | |
| return False | |
| try: | |
| await audio_button.click(timeout=5000, force=True) | |
| logger.info("[reCAPTCHA-invisible] Clicked audio button") | |
| except Exception as e: | |
| logger.error(f"[reCAPTCHA-invisible] Failed to click audio button: {e}") | |
| return False | |
| await asyncio.sleep(2.5) | |
| # Check for block message | |
| block_msg = await challenge_iframe.query_selector(".rc-dsu-cant-solve-active") | |
| if block_msg and await block_msg.is_visible(): | |
| logger.error("[reCAPTCHA-invisible] Blocked: Automated queries detected.") | |
| return False | |
| # Get audio download link | |
| download_link = await challenge_iframe.query_selector(".rc-audiochallenge-tdownload-link") | |
| if not download_link: | |
| logger.error("[reCAPTCHA-invisible] Audio download link not found.") | |
| return False | |
| audio_url = await download_link.get_attribute("href") | |
| # Download audio file | |
| audio_resp = await page.request.get(audio_url) | |
| if not audio_resp.ok: | |
| logger.error(f"[reCAPTCHA-invisible] Failed to download audio (status {audio_resp.status}).") | |
| return False | |
| audio_content = await audio_resp.body() | |
| if len(audio_content) < 1024: | |
| logger.error(f"[reCAPTCHA-invisible] Audio file too small ({len(audio_content)} bytes) β blocked.") | |
| return False | |
| if not settings.groq_api_key: | |
| logger.error("[reCAPTCHA-invisible] GROQ_API_KEY not configured.") | |
| return False | |
| # Transcribe with Groq Whisper | |
| groq_url = "https://api.groq.com/openai/v1/audio/transcriptions" | |
| headers = {"Authorization": f"Bearer {settings.groq_api_key}"} | |
| files = {"file": ("challenge.mp3", audio_content, "audio/mpeg")} | |
| data = {"model": "whisper-large-v3-turbo", "response_format": "json"} | |
| async with httpx.AsyncClient(timeout=60) as client: | |
| transcription_resp = await client.post(groq_url, headers=headers, files=files, data=data) | |
| if transcription_resp.status_code != 200: | |
| logger.error(f"[reCAPTCHA-invisible] Groq API Error: {transcription_resp.text}") | |
| return False | |
| text = transcription_resp.json().get("text", "").strip() | |
| if not text: | |
| logger.error("[reCAPTCHA-invisible] Groq returned empty text.") | |
| return False | |
| logger.info(f"[reCAPTCHA-invisible] Transcribed: '{text}'") | |
| # Fill response input | |
| input_field = await challenge_iframe.query_selector("#audio-response") | |
| if not input_field: | |
| logger.error("[reCAPTCHA-invisible] Audio response input not found.") | |
| return False | |
| await input_field.fill(text) | |
| await asyncio.sleep(0.5) | |
| # Click verify | |
| verify_button = await challenge_iframe.query_selector("#recaptcha-verify-button") | |
| if not verify_button or not await verify_button.is_visible(): | |
| logger.error("[reCAPTCHA-invisible] Verify button not found.") | |
| return False | |
| try: | |
| await verify_button.click(timeout=5000, force=True) | |
| except Exception as e: | |
| logger.error(f"[reCAPTCHA-invisible] Failed to click verify: {e}") | |
| return False | |
| await asyncio.sleep(2) | |
| # Check if solved | |
| try: | |
| checkbox = await anchor_iframe.query_selector("#recaptcha-anchor") | |
| if checkbox: | |
| is_checked = await checkbox.get_attribute("aria-checked") | |
| if is_checked == "true": | |
| logger.info("[reCAPTCHA-invisible] Solved successfully via audio.") | |
| return True | |
| except Exception: | |
| pass | |
| # For invisible reCAPTCHA, also check if the token was generated | |
| try: | |
| token = await page.evaluate(""" | |
| () => { | |
| try { return grecaptcha.getResponse() || null; } | |
| catch(e) { return null; } | |
| } | |
| """) | |
| if token and len(token) > 10: | |
| logger.info("[reCAPTCHA-invisible] Token generated successfully.") | |
| return True | |
| except Exception: | |
| pass | |
| logger.warning("[reCAPTCHA-invisible] Verify clicked but not confirmed.") | |
| return False | |
| except Exception as e: | |
| logger.error(f"[reCAPTCHA-invisible] Error: {e}") | |
| return False | |
| async def _solve_recaptcha_v2_audio_once(self, page, iframe_selector: str = None) -> bool: | |
| """ | |
| Single attempt to solve reCAPTCHA v2 audio challenge. | |
| """ | |
| try: | |
| # We don't need ffmpeg or pydub anymore, we will use Groq API | |
| import os | |
| import uuid | |
| import shutil | |
| # Locate anchor iframe - try multiple strategies | |
| anchor_iframe = None | |
| if iframe_selector: | |
| iframe_el = await page.query_selector(iframe_selector) | |
| if iframe_el: | |
| anchor_iframe = await iframe_el.content_frame() | |
| # Strategy 1: Find by URL pattern | |
| if not anchor_iframe: | |
| for frame in page.frames: | |
| if "api2/anchor" in frame.url: | |
| try: | |
| el = await frame.frame_element() | |
| if el and await el.is_visible(): | |
| anchor_iframe = frame | |
| break | |
| except Exception: | |
| pass | |
| # Strategy 2: Find by title attribute | |
| if not anchor_iframe: | |
| for frame in page.frames: | |
| try: | |
| el = await frame.frame_element() | |
| if el: | |
| title = await el.get_attribute("title") | |
| if title and "recaptcha" in title.lower(): | |
| anchor_iframe = frame | |
| break | |
| except Exception: | |
| pass | |
| # Strategy 3: Find by frame name | |
| if not anchor_iframe: | |
| for frame in page.frames: | |
| if frame.name and "recaptcha" in frame.name.lower(): | |
| anchor_iframe = frame | |
| break | |
| # Strategy 4: Fallback - any frame with api2/anchor | |
| if not anchor_iframe: | |
| for frame in page.frames: | |
| if "api2/anchor" in frame.url: | |
| anchor_iframe = frame | |
| break | |
| if not anchor_iframe: | |
| logger.error("reCAPTCHA anchor iframe not found after all strategies.") | |
| # Log all frames for debugging | |
| for i, frame in enumerate(page.frames): | |
| logger.debug(f"Frame {i}: url={frame.url}, name={frame.name}") | |
| return False | |
| # Click checkbox | |
| checkbox = await anchor_iframe.query_selector("#recaptcha-anchor") | |
| if not checkbox: | |
| logger.error("reCAPTCHA anchor checkbox not found.") | |
| return False | |
| try: | |
| await checkbox.click(timeout=5000, force=True) | |
| except Exception as e: | |
| logger.error(f"Failed to click anchor checkbox: {e}") | |
| return False | |
| await asyncio.sleep(2) | |
| # Check if solved directly (some high-reputation browsers get passed instantly) | |
| is_checked = await checkbox.get_attribute("aria-checked") | |
| if is_checked == "true": | |
| logger.info("reCAPTCHA solved instantly by reputation.") | |
| return True | |
| # Wait for any challenge iframe to appear and be visible | |
| try: | |
| await page.wait_for_selector("iframe[src*='api2/bframe']", state="visible", timeout=5000) | |
| except Exception: | |
| pass | |
| # Locate challenge iframe | |
| challenge_iframe = None | |
| for frame in page.frames: | |
| if "api2/bframe" in frame.url: | |
| try: | |
| frame_element = await frame.frame_element() | |
| if frame_element and await frame_element.is_visible(): | |
| challenge_iframe = frame | |
| break | |
| except Exception: | |
| pass | |
| if not challenge_iframe: | |
| for frame in page.frames: | |
| if "api2/bframe" in frame.url: | |
| challenge_iframe = frame | |
| break | |
| if not challenge_iframe: | |
| logger.error("reCAPTCHA challenge iframe not found.") | |
| return False | |
| # Click audio challenge button | |
| audio_button = await challenge_iframe.query_selector("#recaptcha-audio-button") | |
| if not audio_button or not await audio_button.is_visible(): | |
| logger.warning("Audio challenge button not found or invisible. reCAPTCHA might be blocked.") | |
| return False | |
| try: | |
| await audio_button.click(timeout=5000, force=True) | |
| except Exception as e: | |
| logger.error(f"Failed to click audio button: {e}") | |
| return False | |
| await asyncio.sleep(2.5) | |
| # Look for block message | |
| block_msg = await challenge_iframe.query_selector(".rc-dsu-cant-solve-active") | |
| if block_msg and await block_msg.is_visible(): | |
| logger.error("reCAPTCHA blocked: Automated queries warning detected.") | |
| return False | |
| # Get download link | |
| download_link = await challenge_iframe.query_selector(".rc-audiochallenge-tdownload-link") | |
| if not download_link: | |
| logger.error("Audio download link not found.") | |
| return False | |
| audio_url = await download_link.get_attribute("href") | |
| # Download MP3 file β usar page.request para heredar cookies/TLS/headers del browser | |
| audio_resp = await page.request.get(audio_url) | |
| if not audio_resp.ok: | |
| logger.error(f"Failed to download audio challenge (status {audio_resp.status}).") | |
| return False | |
| # Verify the audio file has actual content (Google may block and serve empty file) | |
| audio_content = await audio_resp.body() | |
| if len(audio_content) < 1024: # Less than 1KB means the file is empty/blocked | |
| logger.error(f"Audio challenge file is too small ({len(audio_content)} bytes) β Google is blocking the audio challenge for this browser session.") | |
| return False | |
| if not settings.groq_api_key: | |
| logger.error("GROQ_API_KEY is not configured in settings. Cannot transcribe audio.") | |
| return False | |
| # Send the MP3 to Groq Whisper API | |
| groq_url = "https://api.groq.com/openai/v1/audio/transcriptions" | |
| headers = {"Authorization": f"Bearer {settings.groq_api_key}"} | |
| # We must use files= parameter for multipart/form-data | |
| files = { | |
| "file": ("challenge.mp3", audio_content, "audio/mpeg") | |
| } | |
| data = { | |
| "model": "whisper-large-v3-turbo", | |
| "response_format": "json" | |
| } | |
| async with httpx.AsyncClient(timeout=60) as client: | |
| transcription_resp = await client.post(groq_url, headers=headers, files=files, data=data) | |
| if transcription_resp.status_code != 200: | |
| logger.error(f"Groq API Error: {transcription_resp.text}") | |
| return False | |
| transcription_json = transcription_resp.json() | |
| text = transcription_json.get("text", "").strip() | |
| if not text: | |
| logger.error("Groq API returned empty text.") | |
| return False | |
| logger.info(f"reCAPTCHA audio transcribed via Groq successfully: '{text}'") | |
| # Fill the response input | |
| input_field = await challenge_iframe.query_selector("#audio-response") | |
| if not input_field: | |
| logger.error("Audio response input field not found.") | |
| return False | |
| await input_field.fill(text) | |
| await asyncio.sleep(0.5) | |
| # Click verify | |
| verify_button = await challenge_iframe.query_selector("#recaptcha-verify-button") | |
| if not verify_button or not await verify_button.is_visible(): | |
| logger.error("Verify button not found or invisible.") | |
| return False | |
| try: | |
| await verify_button.click(timeout=5000, force=True) | |
| except Exception as e: | |
| logger.error(f"Failed to click verify button: {e}") | |
| return False | |
| await asyncio.sleep(2) | |
| # Final check | |
| is_checked = await checkbox.get_attribute("aria-checked") | |
| if is_checked == "true": | |
| logger.info("reCAPTCHA solved successfully via audio transcription.") | |
| return True | |
| logger.warning("reCAPTCHA verify button clicked but checkmark not set.") | |
| return False | |
| except Exception as e: | |
| logger.error(f"Error solving audio reCAPTCHA: {e}") | |
| return False | |
| # --------------------------------------------------------------------------- | |
| # NopeCHA β reCAPTCHA v3 Solver | |
| # --------------------------------------------------------------------------- | |
| # NopeCHA offers a generous free tier with a daily quota. | |
| # Each reCAPTCHA v3 token costs ~20 credits. | |
| # Docs: https://nopecha.com/api | |
| # | |
| # Flow: | |
| # 1. POST /token/ β { id: "job_id" } (submit the solving job) | |
| # 2. GET /token/?key=...&id=... β poll until { data: ["token"] } or error | |
| # | |
| # The returned token is a standard g-recaptcha-response value that can be | |
| # injected into the target form directly β no browser interaction needed. | |
| # --------------------------------------------------------------------------- | |
| async def solve_recaptcha_v3_nopecha( | |
| self, | |
| site_key: str, | |
| page_url: str, | |
| action: str = "submit", | |
| max_wait_seconds: int = 90, | |
| ) -> str | None: | |
| """ | |
| Solves reCAPTCHA v3 using the NopeCHA token API. | |
| Args: | |
| site_key: The reCAPTCHA v3 data-sitekey on the target page. | |
| page_url: Full URL of the page being solved (RENAPER portal, etc). | |
| action: The grecaptcha action name (e.g. 'submit_tramite'). | |
| max_wait_seconds: How long to poll before giving up (default 90s). | |
| Returns: | |
| A valid g-recaptcha-response token string, or None on failure. | |
| Limits (NopeCHA free tier as of 2025): | |
| - ~100 reCAPTCHA v3 solves per day | |
| - ~20 credits per token | |
| - Typical solve time: 10β30 seconds | |
| - Score range: 0.3 β 0.9 (cannot be forced to a specific score) | |
| """ | |
| api_key = settings.nopecha_api_key | |
| if not api_key: | |
| logger.error( | |
| "[NopeCHA] NOPECHA_API_KEY is not configured in .env β " | |
| "cannot solve reCAPTCHA v3. Set NOPECHA_API_KEY=<your_key>." | |
| ) | |
| return None | |
| base_url = "https://api.nopecha.com" | |
| payload = { | |
| "key": api_key, | |
| "type": "recaptcha3", | |
| "sitekey": site_key, | |
| "url": page_url, | |
| "data": {"action": action}, | |
| } | |
| try: | |
| async with httpx.AsyncClient(timeout=30) as client: | |
| # Step 1 β Submit the job | |
| submit_resp = await client.post(f"{base_url}/token/", json=payload) | |
| submit_data = submit_resp.json() | |
| if submit_resp.status_code != 200 or "error" in submit_data: | |
| logger.error( | |
| f"[NopeCHA] Job submission failed " | |
| f"(HTTP {submit_resp.status_code}): {submit_data}" | |
| ) | |
| return None | |
| job_id = submit_data.get("id") | |
| if not job_id: | |
| logger.error(f"[NopeCHA] No job ID in response: {submit_data}") | |
| return None | |
| logger.info(f"[NopeCHA] Job submitted β id={job_id}, polling...") | |
| # Step 2 β Poll until resolved | |
| poll_interval = 5 # seconds between polls | |
| elapsed = 0 | |
| while elapsed < max_wait_seconds: | |
| await asyncio.sleep(poll_interval) | |
| elapsed += poll_interval | |
| poll_resp = await client.get( | |
| f"{base_url}/token/", | |
| params={"key": api_key, "id": job_id}, | |
| ) | |
| poll_data = poll_resp.json() | |
| # NopeCHA returns {"data": ["<token>"]} on success | |
| if poll_data.get("data"): | |
| token = poll_data["data"][0] | |
| if token and len(token) > 20: | |
| logger.info( | |
| f"[NopeCHA] reCAPTCHA v3 solved in ~{elapsed}s β " | |
| f"token length: {len(token)}" | |
| ) | |
| return token | |
| # Check for terminal errors | |
| error_code = poll_data.get("error") | |
| if error_code and error_code not in (0, None): | |
| logger.error( | |
| f"[NopeCHA] Solver returned error after {elapsed}s: " | |
| f"{poll_data}" | |
| ) | |
| return None | |
| logger.debug(f"[NopeCHA] Still solving... ({elapsed}s elapsed)") | |
| logger.error( | |
| f"[NopeCHA] Timed out after {max_wait_seconds}s without a token." | |
| ) | |
| return None | |
| except Exception as e: | |
| logger.error(f"[NopeCHA] Unexpected error: {e}") | |
| return None | |