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=." ) 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": [""]} 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