SurfSense/surfsense_backend/app/utils/captcha/solvers.py

316 lines
11 KiB
Python
Raw Normal View History

"""In-house captcha-solver clients (vendor API glue, Apache-2.0).
Replaces the unmaintained ``captchatools`` registry. Each provider is a small
function that submits a challenge to the vendor and polls for the token;
:func:`solve` dispatches on the app-wide ``CAPTCHA_SOLVER_PROVIDER``.
Two providers are wired: **2captcha** (legacy ``in.php``/``res.php``) and
**capsolver** (AI-native ``createTask``/``getTaskResult``, materially faster on
the reCAPTCHA-*Enterprise* ``/sorry`` wall). Both express the Enterprise pieces
Google needs the widget sitekey plus the page's dynamic ``data-s`` token
(something ``captchatools`` could not). More vendors (anticaptcha / capmonster)
are added progressively as new entries in :data:`_PROVIDERS`; until then an
2026-07-17 14:39:39 -07:00
unconfigured provider raises :class:`SolverUnsupportedError` so callers latch off
cleanly instead of leaking the API key to the wrong service.
"""
from __future__ import annotations
import logging
import time
from urllib.parse import urlsplit
import requests
from app.utils.captcha.config import CaptchaConfig
logger = logging.getLogger(__name__)
_LOG = "[captcha][solver]"
class SolverError(Exception):
"""Base class for solver failures the caller may want to latch on."""
class SolverAuthError(SolverError):
"""Bad / unknown API key — unrecoverable without a config change."""
class SolverBalanceError(SolverError):
"""Solver account is out of balance — unrecoverable this process."""
2026-07-17 14:39:39 -07:00
class SolverUnsupportedError(SolverError):
"""Configured provider has no in-house client yet — unrecoverable."""
# --- 2captcha (legacy in.php/res.php) --------------------------------------
_2CAP_IN = "http://2captcha.com/in.php"
_2CAP_RES = "http://2captcha.com/res.php"
# Historical captchatools soft_id, kept so solves stay attributed on 2captcha.
_2CAP_SOFT_ID = 4782723
def proxy_login_form(proxy_url: str | None) -> str | None:
"""``http://user:pass@host:port`` -> ``user:pass@host:port`` (no scheme).
The vendor APIs want the proxy without a scheme prefix (2captcha rejects a
scheme with ``ERROR_PROXY_FORMAT``). Returns ``None`` for a missing or
unparseable proxy so the solve goes proxyless rather than crashing the fetch
(the token may then be IP-mismatched, but that fails cleanly).
"""
if not proxy_url:
return None
try:
p = urlsplit(proxy_url)
if not p.hostname or not p.port:
return None
if p.username and p.password:
return f"{p.username}:{p.password}@{p.hostname}:{p.port}"
return f"{p.hostname}:{p.port}"
except Exception:
return None
def _raise_2cap(err: str) -> None:
"""Map a 2captcha error string to a typed exception (or just log a soft one)."""
up = err.upper()
if "ZERO_BALANCE" in up:
raise SolverBalanceError(err)
if "WRONG_USER_KEY" in up or "KEY_DOES_NOT_EXIST" in up:
raise SolverAuthError(err)
logger.warning("%s 2captcha error: %s", _LOG, err)
def _twocaptcha(
cfg: CaptchaConfig,
*,
challenge_type: str,
sitekey: str,
page_url: str,
proxy_url: str | None,
user_agent: str | None,
enterprise: bool,
data_s: str | None,
) -> str | None:
payload: dict = {"key": cfg.api_key, "json": 1, "soft_id": _2CAP_SOFT_ID}
if challenge_type in ("hcaptcha", "hcap"):
payload |= {"method": "hcaptcha", "sitekey": sitekey, "pageurl": page_url}
elif challenge_type == "v3":
payload |= {
"method": "userrecaptcha",
"version": "v3",
"googlekey": sitekey,
"pageurl": page_url,
"action": cfg.v3_action,
"min_score": cfg.v3_min_score,
}
else: # v2, optionally the Enterprise variant (adds enterprise=1 + data-s)
2026-07-17 14:39:39 -07:00
payload |= {
"method": "userrecaptcha",
"googlekey": sitekey,
"pageurl": page_url,
}
if enterprise:
payload["enterprise"] = 1
if data_s:
payload["data-s"] = data_s
proxy = proxy_login_form(proxy_url)
if proxy:
payload["proxy"] = proxy
payload["proxytype"] = "HTTP"
if user_agent:
payload["userAgent"] = user_agent
try:
submit = requests.post(_2CAP_IN, data=payload, timeout=30).json()
except requests.RequestException as e:
logger.warning("%s 2captcha submit request failed: %s", _LOG, e)
return None
if submit.get("status") != 1:
_raise_2cap(str(submit.get("request", "")))
return None
task_id = submit["request"]
deadline = time.monotonic() + cfg.timeout_s
while time.monotonic() < deadline:
time.sleep(5)
try:
got = requests.get(
f"{_2CAP_RES}?key={cfg.api_key}&action=get&id={task_id}&json=1",
timeout=30,
).json()
except requests.RequestException:
continue
if got.get("status") == 1:
return got["request"] or None
req = str(got.get("request", ""))
if req != "CAPCHA_NOT_READY":
_raise_2cap(req)
return None
logger.warning("%s 2captcha solve timed out after %ss", _LOG, cfg.timeout_s)
return None
# --- capsolver (createTask / getTaskResult) --------------------------------
_CAPSOLVER_CREATE = "https://api.capsolver.com/createTask"
_CAPSOLVER_RESULT = "https://api.capsolver.com/getTaskResult"
def capsolver_proxy(proxy_url: str | None) -> str | None:
"""``http://user:pass@host:port`` -> ``http:host:port:user:pass``.
CapSolver's ``proxy`` field is a single colon-delimited
``scheme:host:port[:user:pass]`` string (NOT a URL). Returns ``None`` for a
missing/unparseable proxy so the caller falls back to a proxyless task.
"""
if not proxy_url:
return None
try:
p = urlsplit(proxy_url)
if not p.hostname or not p.port:
return None
scheme = p.scheme or "http"
if p.username and p.password:
return f"{scheme}:{p.hostname}:{p.port}:{p.username}:{p.password}"
return f"{scheme}:{p.hostname}:{p.port}"
except Exception:
return None
def _raise_capsolver(code: str, desc: str) -> None:
"""Map a CapSolver errorCode to a typed exception (or log a soft one)."""
up = (code or "").upper()
if "ZERO_BALANCE" in up or "INSUFFICIENT" in up:
raise SolverBalanceError(f"{code}: {desc}")
if "KEY" in up: # ERROR_KEY_DENIED_ACCESS / ERROR_KEY_DOES_NOT_EXIST
raise SolverAuthError(f"{code}: {desc}")
logger.warning("%s capsolver error: %s %s", _LOG, code, desc)
def _capsolver(
cfg: CaptchaConfig,
*,
challenge_type: str,
sitekey: str,
page_url: str,
proxy_url: str | None,
user_agent: str | None,
enterprise: bool,
data_s: str | None,
) -> str | None:
# We always egress through our own sticky proxy, so the proxied task types
# (no "ProxyLess" casing ambiguity) are the hot path; the proxyless variants
# are only the fallback when no proxy was threaded through.
proxy = capsolver_proxy(proxy_url)
has_proxy = proxy is not None
task: dict = {"websiteURL": page_url, "websiteKey": sitekey}
if challenge_type in ("hcaptcha", "hcap"):
task["type"] = "HCaptchaTask" if has_proxy else "HCaptchaTaskProxyLess"
elif challenge_type == "v3":
base = "ReCaptchaV3EnterpriseTask" if enterprise else "ReCaptchaV3Task"
task["type"] = base if has_proxy else base + "ProxyLess"
task["pageAction"] = cfg.v3_action
task["minScore"] = cfg.v3_min_score
else: # v2, optionally the Enterprise variant (Google /sorry)
base = "ReCaptchaV2EnterpriseTask" if enterprise else "ReCaptchaV2Task"
task["type"] = base if has_proxy else base + "ProxyLess"
if data_s:
# Enterprise carries the /anchor `s` under enterprisePayload; the
# normal-v2 field is recaptchaDataSValue.
task["enterprisePayload" if enterprise else "recaptchaDataSValue"] = (
{"s": data_s} if enterprise else data_s
)
if has_proxy:
task["proxy"] = proxy
if user_agent:
task["userAgent"] = user_agent
try:
created = requests.post(
_CAPSOLVER_CREATE,
json={"clientKey": cfg.api_key, "task": task},
timeout=30,
).json()
except requests.RequestException as e:
logger.warning("%s capsolver createTask failed: %s", _LOG, e)
return None
if created.get("errorId"):
_raise_capsolver(
str(created.get("errorCode", "")), str(created.get("errorDescription", ""))
)
return None
task_id = created.get("taskId")
if not task_id:
return None
deadline = time.monotonic() + cfg.timeout_s
while time.monotonic() < deadline:
time.sleep(2) # AI solver is fast; poll tighter than 2captcha's 5 s
try:
got = requests.post(
_CAPSOLVER_RESULT,
json={"clientKey": cfg.api_key, "taskId": task_id},
timeout=30,
).json()
except requests.RequestException:
continue
if got.get("errorId"):
_raise_capsolver(
str(got.get("errorCode", "")), str(got.get("errorDescription", ""))
)
return None
if got.get("status") == "ready":
return (got.get("solution") or {}).get("gRecaptchaResponse") or None
logger.warning("%s capsolver solve timed out after %ss", _LOG, cfg.timeout_s)
return None
# provider name (CAPTCHA_SOLVER_PROVIDER) -> client. Add vendors here.
_PROVIDERS = {"2captcha": _twocaptcha, "capsolver": _capsolver}
def supported_providers() -> list[str]:
return sorted(_PROVIDERS)
def solve(
cfg: CaptchaConfig,
*,
challenge_type: str,
sitekey: str,
page_url: str,
proxy_url: str | None = None,
user_agent: str | None = None,
enterprise: bool = False,
data_s: str | None = None,
) -> str | None:
"""Harvest a token from the configured solver, or ``None`` on soft failure.
``challenge_type`` is ``v2`` / ``v3`` / ``hcaptcha``; set ``enterprise`` +
``data_s`` for reCAPTCHA-v2-Enterprise pages (e.g. Google ``/sorry``). The
solve egresses through ``proxy_url`` so the token is bound to the crawl's
own exit IP. Raises a :class:`SolverError` subclass on unrecoverable errors
(bad key / no balance / unsupported provider) so callers can latch.
"""
client = _PROVIDERS.get((cfg.solving_site or "").lower())
if client is None:
2026-07-17 14:39:39 -07:00
raise SolverUnsupportedError(
f"captcha provider {cfg.solving_site!r} has no in-house client "
f"(supported: {supported_providers()})"
)
return client(
cfg,
challenge_type=challenge_type,
sitekey=sitekey,
page_url=page_url,
proxy_url=proxy_url,
user_agent=user_agent,
enterprise=enterprise,
data_s=data_s,
)