pryscraper/captcha_solver.py
cryptorugmunch a7c30b12cd
Some checks failed
CI / lint (push) Failing after 2s
CI / typecheck (push) Failing after 2s
CI / test (push) Failing after 2s
CI / Secret scan (gitleaks) (push) Failing after 1s
CI / Security audit (bandit) (push) Failing after 2s
chore(lint): auto-fix 253 of 283 ruff issues (F401, I001, E402, RUF100, UP037, SIM105)
Mass ruff auto-fix:
  - ruff check --fix: 109 issues fixed (F401 unused imports,
    I001 unsorted imports, UP037 quoted annotations, SIM105
    suppressible exception, RUF100 unused-noqa)
  - ruff check --fix --unsafe-fixes: 22 additional issues
  - ruff format: 70 files reformatted
  - Manual pass: fix 16 misplaced import httpx lines
  - Manual pass: fix remaining E402 (import-after-docstring)

Result: 283 errors -> 30 errors.

The remaining 30 are real issues that need manual review:
  5 F401 unused-import (likely auto-generated stubs)
  5 F821 undefined-name (real bugs in code that references
    redis/pydantic/LLMRegistry without imports)
  3 BLE001 (the compliance LLM fallback is intentional; the
    other two are real)
  3 RUF012 mutable-class-default
  3 SIM105, 3 SIM117, 2 E722, 2 E741
  1 B007, 1 B025, 1 E402, 1 RUF200 (pyproject.toml issue)

Tests: 436/437 pass (1 pre-existing SSE sandbox failure).
format check + import sort: now clean.
make ci: still gated on the 30 remaining real issues.
Follow-up: triage the 30 issues file-by-file.
2026-07-02 21:51:25 +02:00

429 lines
17 KiB
Python

"""Pry — Multi-provider CAPTCHA solver with auto-fallback.
Supports: Capsolver -> 2Captcha -> Anti-Captcha -> CapMonster -> DeathByCaptcha -> NextCaptcha
Also handles Turnstile, reCAPTCHA v2/v3/invisible, and hCaptcha."""
# SPDX-License-Identifier: BSL-1.1
# Copyright (c) 2026 Rug Munch Media LLC
#
# Part of Pry — Stealth / Anti-Detection Module
# Licensed under Business Source License 1.1 — see LICENSE-BSL-STEALTH.
# Change Date: 2029-01-01 (converts to MIT).
import asyncio
import json
import logging
import time
from typing import Any, ClassVar
logger = logging.getLogger(__name__)
class CaptchaSolver:
"""Unified CAPTCHA solver with 6+ providers and automatic fallback chain."""
PROVIDER_PRIORITY: ClassVar[list[str]] = [
"capsolver",
"2captcha",
"anti_captcha",
"capmonster",
"deathbycaptcha",
"nextcaptcha",
]
def __init__(self, api_keys: dict[str, str] | None = None):
self.api_keys = api_keys or {}
self.provider_stats: dict[str, dict[str, Any]] = {
p: {"success": 0, "failed": 0, "avg_time": 0, "last_error": ""}
for p in self.PROVIDER_PRIORITY
}
async def solve_recaptcha_v2(
self, site_key: str, page_url: str, provider: str = ""
) -> dict[str, Any]:
"""Solve reCAPTCHA v2."""
providers = [provider] if provider else self.PROVIDER_PRIORITY
for prov in providers:
key = self.api_keys.get(prov) or self.api_keys.get(prov + "_api_key", "")
if not key:
continue
try:
start = time.time()
result = await self._solve_with(prov, "ReCaptchaV2Task", site_key, page_url, key)
elapsed = time.time() - start
self._record(prov, True, elapsed)
return {
"success": True,
"provider": prov,
"token": result,
"elapsed": round(elapsed, 2),
}
except Exception as e: # noqa: BLE001
self._record(prov, False, 0, str(e))
logger.warning(
"captcha_provider_failed", extra={"provider": prov, "error": str(e)[:80]}
)
return {"success": False, "error": "All CAPTCHA providers failed"}
async def solve_recaptcha_v3(
self, site_key: str, page_url: str, action: str = "verify", min_score: float = 0.3
) -> dict[str, Any]:
"""Solve reCAPTCHA v3 (invisible, returns score)."""
for prov in self.PROVIDER_PRIORITY:
key = self.api_keys.get(prov) or self.api_keys.get(prov + "_api_key", "")
if not key:
continue
try:
result = await self._solve_with(
prov,
"ReCaptchaV3Task",
site_key,
page_url,
key,
extra={"action": action, "minScore": min_score},
)
return {"success": True, "provider": prov, "token": result}
except Exception as e: # noqa: BLE001
logger.warning(
"recaptcha_v3_failed", extra={"provider": prov, "error": str(e)[:80]}
)
return {"success": False, "error": "All reCAPTCHA v3 providers failed"}
async def solve_turnstile(self, site_key: str, page_url: str) -> dict[str, Any]:
"""Solve Cloudflare Turnstile challenge."""
for prov in self.PROVIDER_PRIORITY:
key = self.api_keys.get(prov) or self.api_keys.get(prov + "_api_key", "")
if not key:
continue
try:
result = await self._solve_with(prov, "TurnstileTask", site_key, page_url, key)
return {"success": True, "provider": prov, "token": result}
except Exception as e: # noqa: BLE001
logger.warning("turnstile_failed", extra={"provider": prov, "error": str(e)[:80]})
return {"success": False, "error": "All Turnstile providers failed"}
async def solve_hcaptcha(self, site_key: str, page_url: str) -> dict[str, Any]:
"""Solve hCaptcha."""
for prov in self.PROVIDER_PRIORITY:
key = self.api_keys.get(prov) or self.api_keys.get(prov + "_api_key", "")
if not key:
continue
try:
result = await self._solve_with(prov, "HCaptchaTask", site_key, page_url, key)
return {"success": True, "provider": prov, "token": result}
except Exception as e: # noqa: BLE001
logger.warning("hcaptcha_failed", extra={"provider": prov, "error": str(e)[:80]})
return {"success": False, "error": "All hCaptcha providers failed"}
async def solve_image(self, image_base64: str, case_sensitive: bool = False) -> dict[str, Any]:
"""Solve image-based CAPTCHA (text from image)."""
for prov in self.PROVIDER_PRIORITY:
key = self.api_keys.get(prov) or self.api_keys.get(prov + "_api_key", "")
if not key:
continue
try:
result = await self._solve_image_with(prov, image_base64, key, case_sensitive)
return {"success": True, "provider": prov, "text": result}
except Exception as e: # noqa: BLE001
logger.warning(
"image_captcha_failed", extra={"provider": prov, "error": str(e)[:80]}
)
return {"success": False, "error": "All image CAPTCHA providers failed"}
async def _solve_with(
self,
provider: str,
task_type: str,
site_key: str,
page_url: str,
api_key: str,
extra: dict | None = None,
) -> str:
if provider == "capsolver":
return await self._capsolver(task_type, site_key, page_url, api_key, extra)
elif provider == "2captcha":
return await self._two_captcha(task_type, site_key, page_url, api_key, extra)
elif provider == "anti_captcha":
return await self._anti_captcha(task_type, site_key, page_url, api_key, extra)
elif provider == "capmonster":
return await self._capmonster(task_type, site_key, page_url, api_key, extra)
elif provider == "deathbycaptcha":
return await self._deathbycaptcha(task_type, site_key, page_url, api_key, extra)
elif provider == "nextcaptcha":
return await self._nextcaptcha(task_type, site_key, page_url, api_key, extra)
raise ValueError(f"Unknown provider: {provider}")
async def _capsolver(
self, task_type: str, site_key: str, page_url: str, api_key: str, extra: dict | None = None
) -> str:
from client import get_client
client = await get_client()
task: dict[str, Any] = {"type": task_type, "websiteKey": site_key, "websiteURL": page_url}
if extra:
task.update(extra)
resp = await client.post(
"https://api.capsolver.com/createTask",
json={"clientKey": api_key, "task": task},
timeout=30,
)
data = resp.json()
task_id = data.get("taskId")
if not task_id:
raise Exception(data.get("errorDescription", "Capsolver error"))
for _ in range(60):
await asyncio.sleep(2)
r = await client.post(
"https://api.capsolver.com/getTaskResult",
json={"clientKey": api_key, "taskId": task_id},
)
rd = r.json()
if rd.get("status") == "ready":
return rd["solution"].get("gRecaptchaResponse") or rd["solution"].get("token", "")
if rd.get("status") == "failed":
raise Exception("Capsolver failed")
raise Exception("Capsolver timed out")
async def _two_captcha(
self, task_type: str, site_key: str, page_url: str, api_key: str, extra: dict | None = None
) -> str:
from client import get_client
client = await get_client()
method = {
"ReCaptchaV2Task": "userrecaptcha",
"ReCaptchaV3Task": "recaptcha_v3",
"HCaptchaTask": "hcaptcha",
"TurnstileTask": "turnstile",
}.get(task_type, "userrecaptcha")
params: dict[str, Any] = {
"key": api_key,
"method": method,
"googlekey": site_key,
"pageurl": page_url,
"json": 1,
}
if extra:
params.update(extra)
resp = await client.post("https://2captcha.com/in.php", data=params, timeout=30)
data = resp.json()
if data.get("status") != 1:
raise Exception(data.get("request", "2captcha error"))
request_id = data["request"]
for _ in range(60):
await asyncio.sleep(3)
r = await client.get(
f"https://2captcha.com/res.php?key={api_key}&action=get&id={request_id}&json=1"
)
rd = r.json()
if rd.get("status") == 1:
return rd["request"]
if rd.get("request") and "CAPCHA_NOT_READY" not in str(rd["request"]):
raise Exception(f"2captcha: {rd['request']}")
raise Exception("2captcha timed out")
async def _anti_captcha(
self, task_type: str, site_key: str, page_url: str, api_key: str, extra: dict | None = None
) -> str:
from client import get_client
client = await get_client()
type_map = {
"ReCaptchaV2Task": "NoCaptchaTaskProxyless",
"ReCaptchaV3Task": "RecaptchaV3TaskProxyless",
"HCaptchaTask": "HCaptchaTaskProxyless",
"TurnstileTask": "TurnstileTaskProxyless",
}
task = {
"type": type_map.get(task_type, "NoCaptchaTaskProxyless"),
"websiteURL": page_url,
"websiteKey": site_key,
}
if extra:
task.update(extra)
resp = await client.post(
"https://api.anti-captcha.com/createTask",
json={"clientKey": api_key, "task": task},
timeout=30,
)
data = resp.json()
if data.get("errorId") != 0:
raise Exception(data.get("errorDescription", "Anti-Captcha error"))
task_id = data["taskId"]
for _ in range(60):
await asyncio.sleep(2)
r = await client.post(
"https://api.anti-captcha.com/getTaskResult",
json={"clientKey": api_key, "taskId": task_id},
)
rd = r.json()
if rd.get("status") == "ready":
return rd["solution"].get("gRecaptchaResponse", "")
if rd.get("errorId") != 0:
raise Exception(rd.get("errorDescription", ""))
raise Exception("Anti-Captcha timed out")
async def _capmonster(
self, task_type: str, site_key: str, page_url: str, api_key: str, extra: dict | None = None
) -> str:
"""CapMonster Cloud — self-hosted option. Can run locally with 0 per-solve fees."""
return await self._anti_captcha(
task_type, site_key, page_url, api_key, extra
) # Same API as Anti-Captcha
async def _deathbycaptcha(
self, task_type: str, site_key: str, page_url: str, api_key: str, extra: dict | None = None
) -> str:
"""Solve via DeathByCaptcha API."""
from client import get_client
client = await get_client()
user, pw = api_key.split(":", 1) if ":" in api_key else (api_key, "")
method = {"ReCaptchaV2Task": "userrecaptcha", "HCaptchaTask": "hcaptcha"}.get(
task_type, "userrecaptcha"
)
payload = {
"username": user,
"password": pw,
"type": method,
"token_params": json.dumps({"googlekey": site_key, "pageurl": page_url}),
}
resp = await client.post("https://api.dbcapi.me/api/captcha", data=payload, timeout=30)
data = resp.json()
if data.get("status") != 1:
raise Exception(f"DBC error: {data}")
captcha_id = data["captcha"]
for _ in range(60):
await asyncio.sleep(3)
r = await client.get(f"https://api.dbcapi.me/api/captcha/{captcha_id}")
rd = r.json()
if rd.get("status") == 1:
return rd["text"]
raise Exception("DBC timed out")
async def _nextcaptcha(
self, task_type: str, site_key: str, page_url: str, api_key: str, extra: dict | None = None
) -> str:
"""Solve via NextCaptcha API."""
from client import get_client
client = await get_client()
task_type_map = {
"ReCaptchaV2Task": "RecaptchaV2TaskProxyless",
"ReCaptchaV3Task": "RecaptchaV3TaskProxyless",
"HCaptchaTask": "HCaptchaTaskProxyless",
"TurnstileTask": "TurnstileTaskProxyless",
}
body = {
"clientKey": api_key,
"task": {
"type": task_type_map.get(task_type, "RecaptchaV2TaskProxyless"),
"websiteURL": page_url,
"websiteKey": site_key,
},
}
if extra:
body["task"].update(extra)
resp = await client.post("https://api.nextcaptcha.com/createTask", json=body, timeout=30)
data = resp.json()
if data.get("errorId") != 0:
raise Exception(data.get("errorDescription", "NextCaptcha error"))
task_id = data["taskId"]
for _ in range(60):
await asyncio.sleep(2)
r = await client.post(
"https://api.nextcaptcha.com/getTaskResult",
json={"clientKey": api_key, "taskId": task_id},
)
rd = r.json()
if rd.get("status") == "ready":
return rd["solution"].get("gRecaptchaResponse", "")
if rd.get("errorId") != 0:
raise Exception(rd.get("errorDescription", ""))
raise Exception("NextCaptcha timed out")
async def _solve_image_with(
self, provider: str, image_base64: str, api_key: str, case_sensitive: bool
) -> str:
if provider == "capsolver":
return await self._capsolver_image(image_base64, api_key, case_sensitive)
elif provider == "2captcha":
return await self._two_captcha_image(image_base64, api_key, case_sensitive)
raise ValueError(f"Image solving not supported for {provider}")
async def _capsolver_image(self, image_base64: str, api_key: str, case_sensitive: bool) -> str:
from client import get_client
client = await get_client()
body = (
image_base64 if not image_base64.startswith("data:") else image_base64.split(",", 1)[1]
)
resp = await client.post(
"https://api.capsolver.com/createTask",
json={"clientKey": api_key, "task": {"type": "ImageToTextTask", "body": body[:100000]}},
timeout=30,
)
data = resp.json()
task_id = data.get("taskId")
if not task_id:
raise Exception(data.get("errorDescription", ""))
for _ in range(30):
await asyncio.sleep(2)
r = await client.post(
"https://api.capsolver.com/getTaskResult",
json={"clientKey": api_key, "taskId": task_id},
)
rd = r.json()
if rd.get("status") == "ready":
return rd["solution"].get("text", "")
raise Exception("Image solve timed out")
async def _two_captcha_image(
self, image_base64: str, api_key: str, case_sensitive: bool
) -> str:
from client import get_client
client = await get_client()
body = (
image_base64 if not image_base64.startswith("data:") else image_base64.split(",", 1)[1]
)
resp = await client.post(
"https://2captcha.com/in.php",
data={
"key": api_key,
"method": "base64",
"body": body,
"json": 1,
"regsense": 1 if case_sensitive else 0,
},
timeout=30,
)
data = resp.json()
if data.get("status") != 1:
raise Exception(data.get("request", ""))
request_id = data["request"]
for _ in range(30):
await asyncio.sleep(3)
r = await client.get(
f"https://2captcha.com/res.php?key={api_key}&action=get&id={request_id}&json=1"
)
rd = r.json()
if rd.get("status") == 1:
return rd["request"]
raise Exception("Image solve timed out")
def _record(self, provider: str, success: bool, elapsed: float, error: str = "") -> None:
stats = self.provider_stats[provider]
if success:
stats["success"] += 1
stats["avg_time"] = (
stats["avg_time"] * (stats["success"] + stats["failed"] - 1) + elapsed
) / (stats["success"] + stats["failed"])
else:
stats["failed"] += 1
stats["last_error"] = error[:100]
def get_stats(self) -> dict[str, Any]:
return {
"providers": self.provider_stats,
"healthy_providers": sum(1 for p in self.provider_stats.values() if p["failed"] < 3),
}