crowdata / app /utils /captcha.py
YOSOYYONOSOYOTRO's picture
Upload folder using huggingface_hub (part 2)
4223796 verified
Raw
History Blame Contribute Delete
38.8 kB
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