From 350360e60b58788c270b7a76aaaa8bba4968f7ed Mon Sep 17 00:00:00 2001 From: AndreiDrang Date: Sat, 11 Jul 2026 23:47:52 +0300 Subject: [PATCH 1/3] Update index.rst --- docs/index.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/index.rst b/docs/index.rst index a26664d5..078d88f4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -56,6 +56,7 @@ Check our other projects here - `RedPandaDev group Date: Sat, 11 Jul 2026 23:51:19 +0300 Subject: [PATCH 2/3] feat: add schema-driven CaptchaAI client --- README.md | 47 +- docs/modules/captchaai/example.rst | 66 ++ pyproject.toml | 3 + src/python_rucaptcha/captchaai.py | 160 ++++ src/python_rucaptcha/core/base.py | 1 + src/python_rucaptcha/core/captchaai.py | 746 +++++++++++++++--- src/python_rucaptcha/core/data/__init__.py | 1 + .../core/data/captchaai_legacy_profiles.json | 19 + .../core/data/captchaai_profiles.json | 26 + src/python_rucaptcha/core/enums.py | 1 + src/python_rucaptcha/core/serializer.py | 4 +- tests/test_captchaai.py | 416 ++++++++-- 12 files changed, 1301 insertions(+), 189 deletions(-) create mode 100644 docs/modules/captchaai/example.rst create mode 100644 src/python_rucaptcha/captchaai.py create mode 100644 src/python_rucaptcha/core/data/__init__.py create mode 100644 src/python_rucaptcha/core/data/captchaai_legacy_profiles.json create mode 100644 src/python_rucaptcha/core/data/captchaai_profiles.json diff --git a/README.md b/README.md index c1b94833..39c3600b 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,8 @@ **Python 3.9+ library to solve CAPTCHAs automatically using RuCaptcha, 2Captcha, DeathByCaptcha, or CaptchaAI services.** -> **Using CaptchaAI:** pass `service_type=ServiceEnm.CAPTCHAAI` to any captcha class. CaptchaAI is -> 2Captcha-API-compatible via the classic `in.php`/`res.php` endpoints; supported task types: Turnstile, -> reCAPTCHA v2 (incl. Enterprise), reCAPTCHA v3, and ImageToText. +> **Using CaptchaAI:** compatible high-level classes can pass `service_type=ServiceEnm.CAPTCHAAI`. +> CaptchaAI uses the classic multipart `in.php`/`res.php` endpoints. > ```python > from python_rucaptcha.turnstile import Turnstile > from python_rucaptcha.core.enums import ServiceEnm @@ -21,6 +20,48 @@ > userAgent="Mozilla/5.0 ...", > ).captcha_handler() > ``` +> +> Proxy task types require `proxyType`, `proxyAddress`, and `proxyPort`; credentials are optional: +> ```python +> from python_rucaptcha.re_captcha import ReCaptcha +> from python_rucaptcha.core.enums import ReCaptchaEnm, ServiceEnm +> +> result = ReCaptcha( +> rucaptcha_key="CAPTCHAAI_KEY", +> service_type=ServiceEnm.CAPTCHAAI, +> websiteURL="https://example.com/login", +> websiteKey="SITE_KEY", +> method=ReCaptchaEnm.RecaptchaV2Task, +> proxyType="HTTPS", +> proxyAddress="203.0.113.7", +> proxyPort=3128, +> proxyLogin="user", +> proxyPassword="pass", +> ).captcha_handler() +> ``` +> +> For every documented CaptchaAI method—or a provider method released after this package—use the native, +> type-free client. `profile` validates a packaged documented contract; omit it to pass provider-native fields +> through unchanged. +> ```python +> from python_rucaptcha.captchaai import CaptchaAI, CaptchaAIFile +> +> result = CaptchaAI( +> rucaptcha_key="CAPTCHAAI_KEY", +> profile="cloudflare-challenge", +> params={ +> "pageurl": "https://example.com/protected", +> "proxy": "user:pass@203.0.113.7:3128", +> "proxytype": "HTTPS", +> }, +> ).captcha_handler() +> +> image = CaptchaAI( +> rucaptcha_key="CAPTCHAAI_KEY", +> profile="normal-solve-file", +> files={"file": CaptchaAIFile(b"... PNG bytes ...", "captcha.png", "image/png")}, +> ).captcha_handler() +> ``` ## What is this? diff --git a/docs/modules/captchaai/example.rst b/docs/modules/captchaai/example.rst new file mode 100644 index 00000000..13283058 --- /dev/null +++ b/docs/modules/captchaai/example.rst @@ -0,0 +1,66 @@ +CaptchaAI +========= + +CaptchaAI uses a classic multipart ``in.php`` / ``res.php`` API. Existing +high-level solvers can use ``service_type=ServiceEnm.CAPTCHAAI`` where a +compatibility profile is available. For every documented CaptchaAI method, use +the native client with provider field names. + +Documented profile +------------------ + +A profile validates the documented required fields and supplies documented +defaults. The profile list is available through ``CaptchaAI.profiles()``. + +.. code-block:: python + + from python_rucaptcha.captchaai import CaptchaAI + + result = CaptchaAI( + rucaptcha_key="CAPTCHAAI_KEY", + profile="cloudflare-challenge", + params={ + "pageurl": "https://example.com/protected", + "proxy": "user:pass@203.0.113.7:3128", + "proxytype": "HTTPS", + }, + ).captcha_handler() + +Future provider methods +----------------------- + +The client does not restrict the native ``method`` string. A newly released +CaptchaAI classic API method can be used before this package adds a profile. + +.. code-block:: python + + from python_rucaptcha.captchaai import CaptchaAI + + result = CaptchaAI( + rucaptcha_key="CAPTCHAAI_KEY", + method="provider_future_method", + params={"provider_field": "value"}, + ).captcha_handler() + +File uploads and controls +------------------------- + +Use ``CaptchaAIFile`` for API methods that require a real multipart file part, +such as Normal or Grid image CAPTCHA requests. + +.. code-block:: python + + from python_rucaptcha.captchaai import CaptchaAI, CaptchaAIFile + + result = CaptchaAI( + rucaptcha_key="CAPTCHAAI_KEY", + profile="normal-solve-file", + files={"file": CaptchaAIFile(b"... image bytes ...", "captcha.png", "image/png")}, + ).captcha_handler() + + client = CaptchaAI(rucaptcha_key="CAPTCHAAI_KEY", profile="turnstile", params={ + "sitekey": "SITE_KEY", + "pageurl": "https://example.com", + }) + balance = client.get_balance() + threads = client.get_threads_info() diff --git a/pyproject.toml b/pyproject.toml index 24ff62cc..2801ee0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,6 +99,9 @@ dependencies = [ where = ["src"] include = ["python_rucaptcha*"] +[tool.setuptools.package-data] +python_rucaptcha = ["core/data/*.json"] + [tool.setuptools.dynamic] version = {attr = "python_rucaptcha.__version__"} diff --git a/src/python_rucaptcha/captchaai.py b/src/python_rucaptcha/captchaai.py new file mode 100644 index 00000000..36bd6b6b --- /dev/null +++ b/src/python_rucaptcha/captchaai.py @@ -0,0 +1,160 @@ +"""Native, future-compatible CaptchaAI classic API client.""" + +from __future__ import annotations + +from typing import Any, Mapping + +import requests +from requests.adapters import HTTPAdapter + +from .core import captchaai as transport +from .core.enums import ServiceEnm +from .core.config import RETRIES +from .core.serializer import CaptchaOptionsSer + +CaptchaAIFile = transport.CaptchaAIFile + + +class CaptchaAI: + """Client for CaptchaAI's classic multipart API. + + A packaged profile can validate a documented method contract. Omitting the + profile allows provider-native parameters for methods not yet represented + in the package metadata. + + Attributes: + key: CaptchaAI API key used for requests. + method: Provider method submitted by the client. + params: Provider-native scalar parameters. + files: Multipart file parts keyed by provider field name. + profile: Optional packaged profile name. + poll: Whether to poll for a result after submission. + """ + + def __init__( + self, + rucaptcha_key: str, + method: str | None = None, + params: Mapping[str, Any] | None = None, + *, + files: Mapping[str, CaptchaAIFile] | None = None, + profile: str | None = None, + poll: bool | None = None, + sleep_time: int = 10, + ): + """Create a client for one CaptchaAI method. + + Args: + rucaptcha_key: API key used for CaptchaAI requests. + method: Provider-native method name. Required unless ``profile`` + supplies the method. + params: Provider-native scalar parameters. + files: Multipart file parts keyed by provider field name. + profile: Optional packaged profile used for validation and defaults. + poll: Whether to poll after submission. If omitted, the profile + determines the default and native methods poll by default. + sleep_time: Seconds between result polls. + + Raises: + ValueError: If neither ``method`` nor ``profile`` is supplied. + """ + if method is None: + if profile is None: + raise ValueError("CaptchaAI requires a method or a documented profile") + method = transport.profile_method(profile) + + self.key = rucaptcha_key + self.method = method + self.params = dict(params or {}) + self.files = dict(files or {}) + self.profile = profile + self.poll = poll + self.options = CaptchaOptionsSer(sleep_time=sleep_time, service_type=ServiceEnm.CAPTCHAAI) + self.options.urls_set() + self.session = requests.Session() + self.session.mount("http://", HTTPAdapter(max_retries=RETRIES)) + self.session.mount("https://", HTTPAdapter(max_retries=RETRIES)) + + @classmethod + def profiles(cls) -> tuple[str, ...]: + """List the packaged CaptchaAI profile names. + + Returns: + Profile names available for declarative validation. + """ + return transport.profiles() + + def captcha_handler(self) -> dict[str, Any]: + """Submit the configured method and optionally poll for its result. + + Returns: + A normalized result mapping containing either the solution or an + error description. + """ + return transport.solve_native( + key=self.key, + method=self.method, + params=self.params, + files=self.files, + profile=self.profile, + poll=self.poll, + url_request=self.options.url_request, + url_response=self.options.url_response, + sleep_time=self.options.sleep_time, + session=self.session, + ) + + async def aio_captcha_handler(self) -> dict[str, Any]: + """Asynchronously submit the method and optionally poll for its result. + + Returns: + A normalized result mapping containing either the solution or an + error description. + """ + return await transport.aio_solve_native( + key=self.key, + method=self.method, + params=self.params, + files=self.files, + profile=self.profile, + poll=self.poll, + url_request=self.options.url_request, + url_response=self.options.url_response, + sleep_time=self.options.sleep_time, + ) + + def get_balance(self) -> dict[str, Any]: + """Fetch the account balance through the CaptchaAI control API. + + Returns: + The provider response with the balance value when successful, or + the normalized error mapping returned by the transport. + """ + return transport.control(self.key, "balance", self.options.url_response, session=self.session) + + async def aio_get_balance(self) -> dict[str, Any]: + """Asynchronously fetch the account balance. + + Returns: + The provider response with the balance value when successful, or + the normalized error mapping returned by the transport. + """ + return await transport.aio_control(self.key, "balance", self.options.url_response) + + def get_threads_info(self) -> dict[str, Any]: + """Fetch total and active thread information. + + Returns: + The provider response with thread information, or the normalized + error mapping returned by the transport. + """ + return transport.control(self.key, "threads_info", self.options.url_response, session=self.session) + + async def aio_get_threads_info(self) -> dict[str, Any]: + """Asynchronously fetch total and active thread information. + + Returns: + The provider response with thread information, or the normalized + error mapping returned by the transport. + """ + return await transport.aio_control(self.key, "threads_info", self.options.url_response) diff --git a/src/python_rucaptcha/core/base.py b/src/python_rucaptcha/core/base.py index a6e95d95..c87ffc44 100644 --- a/src/python_rucaptcha/core/base.py +++ b/src/python_rucaptcha/core/base.py @@ -91,6 +91,7 @@ def _processing_response(self, **kwargs: dict[str, Any]) -> dict[str, Any]: url_request=self.params.url_request, url_response=self.params.url_response, sleep_time=self.params.sleep_time, + session=self.session, ) try: response = GetTaskResultResponseSer( diff --git a/src/python_rucaptcha/core/captchaai.py b/src/python_rucaptcha/core/captchaai.py index d6ee344a..17b1940b 100644 --- a/src/python_rucaptcha/core/captchaai.py +++ b/src/python_rucaptcha/core/captchaai.py @@ -1,30 +1,19 @@ -""" -CaptchaAI provider adapter. - -CaptchaAI (https://captchaai.com) is 2Captcha-API-compatible but exposes the -**classic** ``in.php`` / ``res.php`` form-parameter API rather than the newer -JSON ``createTask`` / ``getTaskResult`` API the rest of this library speaks. - -This module translates the library's internal ``create_task_payload`` (the v2 -JSON task dict) into the classic form parameters CaptchaAI expects, submits the -task, polls for the result, and returns a response shaped like -``GetTaskResultResponseSer.to_dict()`` so the public ``Turnstile`` / ``ReCaptcha`` -/ ``ImageCaptcha`` classes work transparently when ``service_type`` is -``ServiceEnm.CAPTCHAAI``. - -Supported task types (the ones CaptchaAI solves through the classic API): -``TurnstileTaskProxyless``/``TurnstileTask``, ``RecaptchaV2TaskProxyless``/ -``RecaptchaV2Task``, ``RecaptchaV3TaskProxyless``, ``ImageToTextTask``. -Other task types raise a clear ``ValueError`` so callers fail fast rather than -silently sending an unsupported method. +"""Transport CaptchaAI classic multipart requests using packaged metadata. + +Provider contracts are data-driven, so native callers can submit documented +methods and future methods without adding task-type branches here. """ from __future__ import annotations +import json import time import asyncio import logging -from typing import Any +from typing import Any, Mapping +from pathlib import Path +from functools import lru_cache +from dataclasses import dataclass import aiohttp import requests @@ -32,166 +21,665 @@ from .config import attempts_generator from .serializer import GetTaskResultResponseSer -# v2 task ``type`` -> classic ``method`` -_TURNSTILE = {"TurnstileTaskProxyless", "TurnstileTask"} -_RECAPTCHA_V2 = { - "RecaptchaV2TaskProxyless", - "RecaptchaV2Task", - "RecaptchaV2EnterpriseTaskProxyless", - "RecaptchaV2EnterpriseTask", -} -_RECAPTCHA_V3 = {"RecaptchaV3TaskProxyless"} -_IMAGE = {"ImageToTextTask"} +REQUEST_TIMEOUT = 30 +_PENDING = "CAPCHA_NOT_READY" +_RESERVED_FIELDS = {"key", "method", "json"} + + +@dataclass(frozen=True) +class CaptchaAIFile: + """Describe one binary part in a native CaptchaAI multipart request. + + Attributes: + content: Bytes sent as the multipart field body. + filename: Filename presented to the provider. + content_type: MIME type presented for the file part. + """ + + content: bytes + filename: str = "captcha.bin" + content_type: str = "application/octet-stream" + + +@lru_cache(maxsize=1) +def _metadata() -> dict[str, Any]: + """Load the packaged native CaptchaAI profile metadata once. + + Returns: + Parsed profile and control-operation metadata. + """ + data_path = Path(__file__).with_name("data") / "captchaai_profiles.json" + return json.loads(data_path.read_text(encoding="utf-8")) + + +@lru_cache(maxsize=1) +def _legacy_metadata() -> dict[str, Any]: + """Load the packaged legacy task translation metadata once. + + Returns: + Parsed mappings from library task names to CaptchaAI profiles. + """ + data_path = Path(__file__).with_name("data") / "captchaai_legacy_profiles.json" + return json.loads(data_path.read_text(encoding="utf-8")) + + +def profiles() -> tuple[str, ...]: + """Return the packaged CaptchaAI profile names. + + Returns: + Profile names available for declarative validation. + """ + return tuple(_metadata()["profiles"]) + + +def profile_method(profile_name: str) -> str: + """Return the native method configured by a packaged profile. + Args: + profile_name: Name of the packaged profile to inspect. -def _err(request: str | None) -> dict[str, Any]: + Returns: + Provider-native method name for the profile. + + Raises: + ValueError: If ``profile_name`` is not packaged. + """ + return str(_profile(profile_name)["method"]) + + +def _err(request: Any) -> dict[str, Any]: + """Normalize a transport or provider failure into a result mapping. + + Args: + request: Error value or provider error text to expose in the result. + + Returns: + A failed ``GetTaskResultResponseSer`` mapping. + """ + message = str(request) if request is not None else "ERROR_UNKNOWN" return GetTaskResultResponseSer( status="failed", errorId=12, - errorCode=request or "ERROR_UNKNOWN", - errorDescription=f"CaptchaAI returned: {request}", + errorCode=message, + errorDescription=f"CaptchaAI returned: {message}", ).to_dict() -def build_classic_params(task: dict[str, Any]) -> tuple[str, dict[str, Any]]: - """Map a v2 task dict to (classic ``method``, classic params).""" - ctype = task.get("type") - extra: dict[str, Any] = {} - - if ctype in _TURNSTILE: - method = "turnstile" - extra["sitekey"] = task["websiteKey"] - extra["pageurl"] = task["websiteURL"] - if task.get("action"): - extra["action"] = task["action"] - if task.get("data"): - extra["data"] = task["data"] - elif ctype in _RECAPTCHA_V2: - method = "userrecaptcha" - extra["googlekey"] = task["websiteKey"] - extra["pageurl"] = task["websiteURL"] - if "Enterprise" in (ctype or ""): - extra["enterprise"] = 1 - elif ctype in _RECAPTCHA_V3: - method = "userrecaptcha" - extra["googlekey"] = task["websiteKey"] - extra["pageurl"] = task["websiteURL"] - extra["version"] = "v3" - if task.get("pageAction"): - extra["action"] = task["pageAction"] - if task.get("minScore"): - extra["min_score"] = task["minScore"] - elif ctype in _IMAGE: - method = "base64" - extra["body"] = task["body"] - else: +def _profile(profile_name: str | None) -> dict[str, Any]: + """Resolve an optional packaged profile. + + Args: + profile_name: Profile name, or ``None`` for unprofiled native calls. + + Returns: + Profile metadata, or an empty mapping when no profile is selected. + + Raises: + ValueError: If the requested profile is not packaged. + """ + if profile_name is None: + return {} + try: + return _metadata()["profiles"][profile_name] + except KeyError as error: + raise ValueError(f"Unknown CaptchaAI profile {profile_name!r}") from error + + +def _prepare_params( + method: str, + params: Mapping[str, Any] | None, + files: Mapping[str, CaptchaAIFile] | None, + profile_name: str | None, +) -> tuple[str, dict[str, Any], dict[str, CaptchaAIFile], dict[str, Any]]: + """Apply profile defaults and validate a native method request. + + Args: + method: Provider-native method name. + params: Scalar provider parameters. + files: Binary multipart fields. + profile_name: Optional packaged profile used for validation. + + Returns: + The method, prepared scalar parameters, file parts, and profile data. + + Raises: + ValueError: If the request violates reserved-field, profile, or file + validation rules. + """ + if not method: + raise ValueError("CaptchaAI method is required") + profile = _profile(profile_name) + supplied = dict(params or {}) + if invalid := _RESERVED_FIELDS.intersection(supplied): + raise ValueError(f"CaptchaAI params cannot override reserved fields: {sorted(invalid)}") + + prepared_files = dict(files or {}) + for field, value in prepared_files.items(): + if not isinstance(value, CaptchaAIFile): + raise ValueError(f"CaptchaAI file field {field!r} must be a CaptchaAIFile") + + prepared = {**profile.get("defaults", {}), **supplied} + expected_method = profile.get("method") + if expected_method is not None and method != expected_method: + raise ValueError(f"Profile {profile_name!r} requires method {expected_method!r}") + + file_fields = set(profile.get("file_fields", [])) + missing = [ + field + for field in profile.get("required", []) + if (field not in prepared_files if field in file_fields else field not in prepared) + ] + if missing: raise ValueError( - f"CaptchaAI provider does not support task type {ctype!r}. " - f"Supported: Turnstile, ReCaptchaV2, ReCaptchaV3, ImageToText." + f"CaptchaAI profile {profile_name!r} is missing required fields: {', '.join(missing)}" ) - # pass-through proxy / user-agent if present - if task.get("userAgent"): - extra["userAgent"] = task["userAgent"] - return method, extra + unexpected_files = set(prepared_files).difference(file_fields) if file_fields else set() + if unexpected_files and profile_name is not None: + raise ValueError( + f"CaptchaAI profile {profile_name!r} does not accept files: {sorted(unexpected_files)}" + ) + return method, prepared, prepared_files, profile -def _solution(method: str, token: str) -> dict[str, str]: - if method == "base64": - return {"text": token} - return {"token": token, "gRecaptchaResponse": token} +def _proxy_params(task: Mapping[str, Any]) -> dict[str, str]: + """Translate canonical proxy fields to CaptchaAI classic API fields. + Args: + task: Legacy library task containing canonical proxy fields. -def solve( - create_task_payload: dict[str, Any], + Returns: + Provider-native ``proxy`` and ``proxytype`` fields, or an empty mapping + when no proxy fields are present. + + Raises: + ValueError: If required proxy fields are incomplete or invalid. + """ + fields = ("proxyType", "proxyAddress", "proxyPort", "proxyLogin", "proxyPassword") + if not any(task.get(field) is not None for field in fields): + return {} + + proxy_type = task.get("proxyType") + proxy_address = task.get("proxyAddress") + proxy_port = task.get("proxyPort") + if not all((proxy_type, proxy_address, proxy_port)): + raise ValueError("proxyType, proxyAddress, and proxyPort must be supplied together") + + normalized_type = str(proxy_type).upper() + if normalized_type not in {"HTTP", "HTTPS", "SOCKS4", "SOCKS5"}: + raise ValueError("proxyType must be HTTP, HTTPS, SOCKS4, or SOCKS5") + + proxy_login = task.get("proxyLogin") + proxy_password = task.get("proxyPassword") + if (proxy_login is None) != (proxy_password is None): + raise ValueError("proxyLogin and proxyPassword must be supplied together") + + proxy = f"{proxy_address}:{proxy_port}" + if proxy_login is not None: + proxy = f"{proxy_login}:{proxy_password}@{proxy}" + return {"proxy": proxy, "proxytype": normalized_type} + + +def _legacy_request(task: Mapping[str, Any]) -> tuple[str, dict[str, Any], str | None]: + """Translate a legacy task through packaged compatibility metadata. + + Args: + task: Existing library task payload. + + Returns: + Provider method, provider-native parameters, and optional profile name. + + Raises: + ValueError: If no compatibility profile exists or explicit parameters + are not a mapping. + """ + explicit_method = task.get("captchaai_method") + explicit_profile = task.get("captchaai_profile") + if explicit_method is not None: + explicit_params = task.get("captchaai_params", {}) + if not isinstance(explicit_params, Mapping): + raise ValueError("captchaai_params must be a mapping") + return str(explicit_method), dict(explicit_params), explicit_profile + + task_type = getattr(task.get("type"), "value", task.get("type")) + task_config = _legacy_metadata().get(str(task_type)) + if task_config is None: + raise ValueError( + "No CaptchaAI compatibility profile exists for this library task. " + "Use the native CaptchaAI client with method and params." + ) + + params: dict[str, Any] = {} + bool_int = set(task_config.get("bool_int", [])) + for source, target in task_config.get("fields", {}).items(): + if task.get(source) is not None: + value = task[source] + params[target] = int(bool(value)) if source in bool_int else value + if task_config.get("proxy"): + params.update(_proxy_params(task)) + + profile_name = task_config["profile"] + method = _profile(profile_name)["method"] + return method, params, profile_name + + +def _requests_form( + fields: Mapping[str, Any], files: Mapping[str, CaptchaAIFile] +) -> dict[str, tuple[Any, ...]]: + """Build requests-compatible multipart parts. + + Args: + fields: Scalar fields to encode as text parts. + files: Binary fields to encode with filename and MIME metadata. + + Returns: + Multipart parts accepted by ``requests.Session.post``. + """ + form: dict[str, tuple[Any, ...]] = {name: (None, str(value)) for name, value in fields.items()} + for name, value in files.items(): + form[name] = (value.filename, value.content, value.content_type) + return form + + +def _aio_form(fields: Mapping[str, Any], files: Mapping[str, CaptchaAIFile]) -> aiohttp.FormData: + """Build an aiohttp multipart form from scalar and binary fields. + + Args: + fields: Scalar fields to encode as text parts. + files: Binary fields to encode with filename and MIME metadata. + + Returns: + Multipart form suitable for an aiohttp POST request. + """ + form = aiohttp.FormData() + for name, value in fields.items(): + form.add_field(name, str(value), content_type="text/plain") + for name, value in files.items(): + form.add_field(name, value.content, filename=value.filename, content_type=value.content_type) + return form + + +def _response_payload(response: Any) -> dict[str, Any]: + """Validate the JSON response shape expected by the transport. + + Args: + response: Decoded provider response. + + Returns: + The response as a dictionary. + + Raises: + ValueError: If the response is not a JSON object. + """ + if not isinstance(response, dict): + raise ValueError("response is not a JSON object") + return response + + +def _error_value(response: Mapping[str, Any]) -> Any: + """Select the most useful provider error value from a response. + + Args: + response: Provider response mapping. + + Returns: + The first available provider error value, or ``ERROR_UNKNOWN``. + """ + return ( + response.get("request") + or response.get("error") + or response.get("errorDescription") + or "ERROR_UNKNOWN" + ) + + +def _ready(task_id: str | None, response: Mapping[str, Any], profile: Mapping[str, Any]) -> dict[str, Any]: + """Normalize a successful provider response and apply profile aliases. + + Args: + task_id: Provider task identifier, if the request was polled. + response: Successful provider response mapping. + profile: Profile metadata containing optional result aliases. + + Returns: + A ready ``GetTaskResultResponseSer`` mapping. + """ + solution = {key: value for key, value in response.items() if key != "status"} + for alias, source in profile.get("aliases", {}).items(): + if source in solution: + value = solution[source] + if alias == "click" and isinstance(value, str): + try: + value = json.loads(value) + except ValueError: + pass + solution[alias] = value + return GetTaskResultResponseSer(status="ready", solution=solution, taskId=task_id).to_dict() + + +def _is_success(response: Mapping[str, Any], profile: Mapping[str, Any]) -> bool: + """Determine whether a response satisfies its profile success contract. + + Args: + response: Provider response mapping. + profile: Profile metadata containing optional success fields. + + Returns: + ``True`` when the standard status or all profile success fields match. + """ + if response.get("status") == 1: + return True + success_fields = profile.get("success_fields", []) + return bool(success_fields) and all(field in response for field in success_fields) + + +def _submit_url(url_request: str, profile: Mapping[str, Any]) -> str: + """Resolve a profile-specific submission URL from the configured base URL. + + Args: + url_request: Configured default submission URL. + profile: Profile metadata containing an optional submission path. + + Returns: + Submission URL selected for the profile. + """ + path = profile.get("submit_path", "/in.php") + if path == "/in.php": + return url_request + return f"{url_request.rsplit('/', 1)[0]}{path}" + + +def solve_native( + key: str, + method: str, + params: Mapping[str, Any] | None, url_request: str, url_response: str, sleep_time: int, + *, + files: Mapping[str, CaptchaAIFile] | None = None, + profile: str | None = None, + poll: bool | None = None, + session: requests.Session | None = None, ) -> dict[str, Any]: - """Synchronous classic in.php/res.php solve.""" - key = create_task_payload["clientKey"] - task = create_task_payload["task"] + """Submit and optionally poll any CaptchaAI classic API method. + + Args: + key: CaptchaAI API key. + method: Provider-native method name. + params: Scalar provider parameters. + url_request: Submission URL. + url_response: Polling URL. + sleep_time: Seconds between polling attempts. + files: Optional binary multipart fields. + profile: Optional packaged profile name. + poll: Whether to poll after submission. + session: Optional requests session to reuse. + + Returns: + A normalized ready or failed result mapping. + """ try: - method, extra = build_classic_params(task) - except (KeyError, ValueError) as error: - return _err(str(error)) + method, params, files, profile_data = _prepare_params(method, params, files, profile) + except ValueError as error: + return _err(error) - data = {"key": key, "method": method, "json": 1, **extra} + should_poll = profile_data.get("poll", True) if poll is None else poll + data = {"key": key, "method": method, "json": 1, **params} + http = session or requests.Session() try: - created = requests.post(url_request, data=data).json() + response = http.post( + _submit_url(url_request, profile_data), + files=_requests_form(data, files), + timeout=REQUEST_TIMEOUT, + ) + response.raise_for_status() + created = _response_payload(response.json()) except (requests.RequestException, ValueError) as error: return _err(f"ERROR_SUBMIT {error}") - if created.get("status") != 1: - return _err(created.get("request")) - captcha_id = created["request"] + if not should_poll: + if _is_success(created, profile_data): + return _ready(None, created, profile_data) + return _err(_error_value(created)) + if created.get("status") != 1 or not isinstance(created.get("request"), str): + return _err(_error_value(created)) + + task_id = created["request"] time.sleep(sleep_time) for _ in attempts_generator(): try: - res = requests.get( + response = http.post( url_response, - params={"key": key, "action": "get", "id": captcha_id, "json": 1}, - ).json() - logging.info(f"CaptchaAI sync result - {res = }") + files=_requests_form({"key": key, "action": "get", "id": task_id, "json": 1}, {}), + timeout=REQUEST_TIMEOUT, + ) + response.raise_for_status() + result = _response_payload(response.json()) except (requests.RequestException, ValueError) as error: return _err(f"ERROR_POLL {error}") - if res.get("status") == 1: - return GetTaskResultResponseSer( - status="ready", - solution=_solution(method, res["request"]), - taskId=captcha_id if str(captcha_id).isdigit() else None, - ).to_dict() - if res.get("request") == "CAPCHA_NOT_READY": + logging.info("CaptchaAI sync result received for task %s", task_id) + if result.get("request") == _PENDING: time.sleep(sleep_time) continue - return _err(res.get("request")) + if _is_success(result, profile_data): + return _ready(task_id, result, profile_data) + return _err(_error_value(result)) return _err("ERROR_TIMEOUT") -async def aio_solve( - create_task_payload: dict[str, Any], +async def aio_solve_native( + key: str, + method: str, + params: Mapping[str, Any] | None, url_request: str, url_response: str, sleep_time: int, + *, + files: Mapping[str, CaptchaAIFile] | None = None, + profile: str | None = None, + poll: bool | None = None, ) -> dict[str, Any]: - """Asynchronous classic in.php/res.php solve.""" - key = create_task_payload["clientKey"] - task = create_task_payload["task"] + """Asynchronously submit and optionally poll a CaptchaAI method. + + Args: + key: CaptchaAI API key. + method: Provider-native method name. + params: Scalar provider parameters. + url_request: Submission URL. + url_response: Polling URL. + sleep_time: Seconds between polling attempts. + files: Optional binary multipart fields. + profile: Optional packaged profile name. + poll: Whether to poll after submission. + + Returns: + A normalized ready or failed result mapping. + """ try: - method, extra = build_classic_params(task) - except (KeyError, ValueError) as error: - return _err(str(error)) + method, params, files, profile_data = _prepare_params(method, params, files, profile) + except ValueError as error: + return _err(error) - data = {"key": key, "method": method, "json": 1, **extra} - async with aiohttp.ClientSession() as session: + should_poll = profile_data.get("poll", True) if poll is None else poll + data = {"key": key, "method": method, "json": 1, **params} + timeout = aiohttp.ClientTimeout(total=REQUEST_TIMEOUT) + async with aiohttp.ClientSession(timeout=timeout) as http: try: - async with session.post(url_request, data=data) as resp: - created = await resp.json(content_type=None) + async with http.post( + _submit_url(url_request, profile_data), data=_aio_form(data, files) + ) as response: + response.raise_for_status() + created = _response_payload(await response.json(content_type=None)) except (aiohttp.ClientError, ValueError) as error: return _err(f"ERROR_SUBMIT {error}") - if created.get("status") != 1: - return _err(created.get("request")) - captcha_id = created["request"] + if not should_poll: + if _is_success(created, profile_data): + return _ready(None, created, profile_data) + return _err(_error_value(created)) + if created.get("status") != 1 or not isinstance(created.get("request"), str): + return _err(_error_value(created)) + + task_id = created["request"] await asyncio.sleep(sleep_time) for _ in attempts_generator(): try: - async with session.get( - url_response, - params={"key": key, "action": "get", "id": captcha_id, "json": 1}, - ) as resp: - res = await resp.json(content_type=None) - logging.info(f"CaptchaAI async result - {res = }") + poll_data = {"key": key, "action": "get", "id": task_id, "json": 1} + async with http.post(url_response, data=_aio_form(poll_data, {})) as response: + response.raise_for_status() + result = _response_payload(await response.json(content_type=None)) except (aiohttp.ClientError, ValueError) as error: return _err(f"ERROR_POLL {error}") - if res.get("status") == 1: - return GetTaskResultResponseSer( - status="ready", - solution=_solution(method, res["request"]), - taskId=captcha_id if str(captcha_id).isdigit() else None, - ).to_dict() - if res.get("request") == "CAPCHA_NOT_READY": + logging.info("CaptchaAI async result received for task %s", task_id) + if result.get("request") == _PENDING: await asyncio.sleep(sleep_time) continue - return _err(res.get("request")) + if _is_success(result, profile_data): + return _ready(task_id, result, profile_data) + return _err(_error_value(result)) return _err("ERROR_TIMEOUT") + + +def solve( + create_task_payload: Mapping[str, Any], + url_request: str, + url_response: str, + sleep_time: int, + session: requests.Session | None = None, +) -> dict[str, Any]: + """Solve a legacy library task through CaptchaAI compatibility metadata. + + Args: + create_task_payload: Existing library create-task payload. + url_request: CaptchaAI submission URL. + url_response: CaptchaAI polling URL. + sleep_time: Seconds between polling attempts. + session: Optional requests session to reuse. + + Returns: + A normalized ready or failed result mapping. + """ + try: + method, params, profile = _legacy_request(create_task_payload["task"]) + except (KeyError, ValueError) as error: + return _err(error) + return solve_native( + key=str(create_task_payload["clientKey"]), + method=method, + params=params, + profile=profile, + url_request=url_request, + url_response=url_response, + sleep_time=sleep_time, + session=session, + ) + + +async def aio_solve( + create_task_payload: Mapping[str, Any], + url_request: str, + url_response: str, + sleep_time: int, +) -> dict[str, Any]: + """Asynchronously solve a legacy task through compatibility metadata. + + Args: + create_task_payload: Existing library create-task payload. + url_request: CaptchaAI submission URL. + url_response: CaptchaAI polling URL. + sleep_time: Seconds between polling attempts. + + Returns: + A normalized ready or failed result mapping. + """ + try: + method, params, profile = _legacy_request(create_task_payload["task"]) + except (KeyError, ValueError) as error: + return _err(error) + return await aio_solve_native( + key=str(create_task_payload["clientKey"]), + method=method, + params=params, + profile=profile, + url_request=url_request, + url_response=url_response, + sleep_time=sleep_time, + ) + + +def control( + key: str, + name: str, + url_response: str, + session: requests.Session | None = None, +) -> dict[str, Any]: + """Execute a declarative CaptchaAI control operation. + + Args: + key: CaptchaAI API key. + name: Packaged control-operation name. + url_response: CaptchaAI control endpoint. + session: Optional requests session to reuse. + + Returns: + The provider control response or a normalized failed result. + """ + try: + control_data = _metadata()["controls"][name] + except KeyError: + return _err(f"Unknown CaptchaAI control operation {name!r}") + + try: + response = (session or requests.Session()).post( + url_response, + files=_requests_form({"key": key, "json": 1, "action": control_data["action"]}, {}), + timeout=REQUEST_TIMEOUT, + ) + response.raise_for_status() + payload = _response_payload(response.json()) + except (requests.RequestException, ValueError) as error: + return _err(f"ERROR_CONTROL {error}") + + if payload.get("status") == 0: + return _err(_error_value(payload)) + result = {field: value for field, value in payload.items() if field != "status"} + for alias, source in control_data.get("aliases", {}).items(): + if source in result: + result[alias] = result[source] + return result + + +async def aio_control(key: str, name: str, url_response: str) -> dict[str, Any]: + """Asynchronously execute a declarative CaptchaAI control operation. + + Args: + key: CaptchaAI API key. + name: Packaged control-operation name. + url_response: CaptchaAI control endpoint. + + Returns: + The provider control response or a normalized failed result. + """ + try: + control_data = _metadata()["controls"][name] + except KeyError: + return _err(f"Unknown CaptchaAI control operation {name!r}") + + timeout = aiohttp.ClientTimeout(total=REQUEST_TIMEOUT) + try: + async with aiohttp.ClientSession(timeout=timeout) as http: + data = {"key": key, "json": 1, "action": control_data["action"]} + async with http.post(url_response, data=_aio_form(data, {})) as response: + response.raise_for_status() + payload = _response_payload(await response.json(content_type=None)) + except (aiohttp.ClientError, ValueError) as error: + return _err(f"ERROR_CONTROL {error}") + + if payload.get("status") == 0: + return _err(_error_value(payload)) + result = {field: value for field, value in payload.items() if field != "status"} + for alias, source in control_data.get("aliases", {}).items(): + if source in result: + result[alias] = result[source] + return result diff --git a/src/python_rucaptcha/core/data/__init__.py b/src/python_rucaptcha/core/data/__init__.py new file mode 100644 index 00000000..4861e237 --- /dev/null +++ b/src/python_rucaptcha/core/data/__init__.py @@ -0,0 +1 @@ +"""Packaged CaptchaAI provider metadata.""" diff --git a/src/python_rucaptcha/core/data/captchaai_legacy_profiles.json b/src/python_rucaptcha/core/data/captchaai_legacy_profiles.json new file mode 100644 index 00000000..9ccc9a6c --- /dev/null +++ b/src/python_rucaptcha/core/data/captchaai_legacy_profiles.json @@ -0,0 +1,19 @@ +{ + "RecaptchaV2TaskProxyless": {"profile": "recaptcha-v2", "fields": {"websiteKey": "googlekey", "websiteURL": "pageurl", "apiDomain": "domain", "recaptchaDataSValue": "data-s", "cookies": "cookies", "userAgent": "userAgent", "isInvisible": "invisible"}, "bool_int": ["isInvisible"]}, + "RecaptchaV2Task": {"profile": "recaptcha-v2", "fields": {"websiteKey": "googlekey", "websiteURL": "pageurl", "apiDomain": "domain", "recaptchaDataSValue": "data-s", "cookies": "cookies", "userAgent": "userAgent", "isInvisible": "invisible"}, "bool_int": ["isInvisible"], "proxy": true}, + "RecaptchaV2EnterpriseTaskProxyless": {"profile": "recaptcha-v2-enterprise", "fields": {"websiteKey": "googlekey", "websiteURL": "pageurl", "cookies": "cookies", "userAgent": "userAgent"}}, + "RecaptchaV2EnterpriseTask": {"profile": "recaptcha-v2-enterprise", "fields": {"websiteKey": "googlekey", "websiteURL": "pageurl", "cookies": "cookies", "userAgent": "userAgent"}, "proxy": true}, + "RecaptchaV3TaskProxyless": {"profile": "recaptcha-v3", "fields": {"websiteKey": "googlekey", "websiteURL": "pageurl", "pageAction": "action", "apiDomain": "domain", "recaptchaDataSValue": "data-s", "cookies": "cookies", "userAgent": "userAgent"}}, + "RecaptchaV3EnterpriseTaskProxyless": {"profile": "recaptcha-v3-enterprise", "fields": {"websiteKey": "googlekey", "websiteURL": "pageurl", "pageAction": "action", "cookies": "cookies", "userAgent": "userAgent"}}, + "TurnstileTaskProxyless": {"profile": "turnstile", "fields": {"websiteKey": "sitekey", "websiteURL": "pageurl", "action": "action"}}, + "TurnstileTask": {"profile": "turnstile", "fields": {"websiteKey": "sitekey", "websiteURL": "pageurl", "action": "action"}, "proxy": true}, + "ImageToTextTask": {"profile": "normal-base64", "fields": {"body": "body"}}, + "GeeTestTask": {"profile": "geetest-v3", "fields": {"websiteURL": "pageurl", "gt": "gt", "challenge": "challenge", "userAgent": "userAgent"}}, + "GeeTestTaskProxyless": {"profile": "geetest-v3", "fields": {"websiteURL": "pageurl", "gt": "gt", "challenge": "challenge", "userAgent": "userAgent"}}, + "FriendlyCaptchaTask": {"profile": "friendly-captcha", "fields": {"websiteURL": "pageurl", "websiteKey": "sitekey"}, "proxy": true}, + "FriendlyCaptchaTaskProxyless": {"profile": "friendly-captcha", "fields": {"websiteURL": "pageurl", "websiteKey": "sitekey"}}, + "LeminTask": {"profile": "lemin", "fields": {"websiteURL": "pageurl", "captchaId": "captcha_id", "div_id": "div_id"}}, + "LeminTaskProxyless": {"profile": "lemin", "fields": {"websiteURL": "pageurl", "captchaId": "captcha_id", "div_id": "div_id"}}, + "CaptchaFoxTask": {"profile": "captchafox", "fields": {"websiteURL": "pageurl", "websiteKey": "sitekey", "userAgent": "userAgent"}, "proxy": true}, + "GridTask": {"profile": "grid-base64", "fields": {"body": "body", "comment": "instructions", "grid_size": "grid_size"}} +} diff --git a/src/python_rucaptcha/core/data/captchaai_profiles.json b/src/python_rucaptcha/core/data/captchaai_profiles.json new file mode 100644 index 00000000..bce6a3d9 --- /dev/null +++ b/src/python_rucaptcha/core/data/captchaai_profiles.json @@ -0,0 +1,26 @@ +{ + "profiles": { + "normal-base64": {"method": "base64", "required": ["body"], "aliases": {"text": "request"}}, + "normal-file": {"method": "post", "required": ["file"], "file_fields": ["file"], "aliases": {"text": "request"}}, + "normal-solve-base64": {"method": "base64", "required": ["body"], "submit_path": "/solve.php", "poll": false, "aliases": {"text": "request"}}, + "normal-solve-file": {"method": "post", "required": ["file"], "file_fields": ["file"], "submit_path": "/solve.php", "poll": false, "aliases": {"text": "request"}}, + "grid-base64": {"method": "base64", "required": ["body", "instructions", "grid_size"], "defaults": {"img_type": "recaptcha"}, "aliases": {"click": "request"}}, + "grid-file": {"method": "post", "required": ["file", "instructions", "grid_size"], "file_fields": ["file"], "defaults": {"img_type": "recaptcha"}, "aliases": {"click": "request"}}, + "recaptcha-v2": {"method": "userrecaptcha", "required": ["googlekey", "pageurl"], "aliases": {"token": "request", "gRecaptchaResponse": "request"}}, + "recaptcha-v2-invisible": {"method": "userrecaptcha", "required": ["googlekey", "pageurl", "invisible"], "defaults": {"invisible": 1}, "aliases": {"token": "request", "gRecaptchaResponse": "request"}}, + "recaptcha-v2-enterprise": {"method": "userrecaptcha", "required": ["googlekey", "pageurl", "enterprise"], "defaults": {"enterprise": 1}, "aliases": {"token": "result", "gRecaptchaResponse": "result"}}, + "recaptcha-v3": {"method": "userrecaptcha", "required": ["googlekey", "pageurl", "version"], "defaults": {"version": "v3"}, "aliases": {"token": "request", "gRecaptchaResponse": "request"}}, + "recaptcha-v3-enterprise": {"method": "userrecaptcha", "required": ["googlekey", "pageurl", "version", "enterprise"], "defaults": {"version": "v3", "enterprise": 1}, "aliases": {"token": "result", "gRecaptchaResponse": "result"}}, + "turnstile": {"method": "turnstile", "required": ["sitekey", "pageurl"], "aliases": {"token": "request", "gRecaptchaResponse": "request"}}, + "geetest-v3": {"method": "geetest", "required": ["gt", "challenge", "pageurl"], "success_fields": ["challenge", "validate", "seccode"]}, + "friendly-captcha": {"method": "friendly_captcha", "required": ["pageurl", "sitekey"], "aliases": {"token": "request"}}, + "lemin": {"method": "lemin", "required": ["pageurl", "captcha_id"], "aliases": {"token": "request"}}, + "captchafox": {"method": "captchafox", "required": ["pageurl", "sitekey", "proxy", "proxytype"], "aliases": {"token": "request"}}, + "cloudflare-challenge": {"method": "cloudflare_challenge", "required": ["pageurl", "proxy", "proxytype"], "aliases": {"cf_clearance": "result"}}, + "bls": {"method": "bls", "required": ["instructions", "image_base64_1", "image_base64_2", "image_base64_3", "image_base64_4", "image_base64_5", "image_base64_6", "image_base64_7", "image_base64_8", "image_base64_9"], "aliases": {"click": "request"}} + }, + "controls": { + "balance": {"action": "getbalance", "aliases": {"balance": "request"}}, + "threads_info": {"action": "threadsinfo"} + } +} diff --git a/src/python_rucaptcha/core/enums.py b/src/python_rucaptcha/core/enums.py index 12243d73..ca9c884f 100644 --- a/src/python_rucaptcha/core/enums.py +++ b/src/python_rucaptcha/core/enums.py @@ -59,6 +59,7 @@ class ReCaptchaEnm(str, MyEnum): RecaptchaV2EnterpriseTask = "RecaptchaV2EnterpriseTask" RecaptchaV3TaskProxyless = "RecaptchaV3TaskProxyless" + RecaptchaV3EnterpriseTaskProxyless = "RecaptchaV3EnterpriseTaskProxyless" class LeminCaptchaEnm(str, MyEnum): diff --git a/src/python_rucaptcha/core/serializer.py b/src/python_rucaptcha/core/serializer.py index e36aea67..717392db 100644 --- a/src/python_rucaptcha/core/serializer.py +++ b/src/python_rucaptcha/core/serializer.py @@ -87,13 +87,13 @@ def urls_set(self): class GetTaskResultResponseSer(MyBaseModel): status: str = "ready" - solution: dict[str, str] | None = None + solution: dict[str, Any] | None = None cost: float = 0.0 ip: str | None = None createTime: int | None = None endTime: int | None = None solveCount: int | None = None - taskId: int | None = None + taskId: int | str | None = None # control method params balance: float | None = None # error info diff --git a/tests/test_captchaai.py b/tests/test_captchaai.py index cb4f2464..0ef7f3f6 100644 --- a/tests/test_captchaai.py +++ b/tests/test_captchaai.py @@ -1,73 +1,379 @@ +from unittest.mock import AsyncMock + import pytest from python_rucaptcha.core import captchaai -from python_rucaptcha.core.enums import ServiceEnm -from python_rucaptcha.core.serializer import CaptchaOptionsSer +from python_rucaptcha.captchaai import CaptchaAI, CaptchaAIFile +from python_rucaptcha.core.enums import ServiceEnm, ReCaptchaEnm +from python_rucaptcha.re_captcha import ReCaptcha +from python_rucaptcha.core.serializer import CaptchaOptionsSer, GetTaskResultResponseSer + + +class SyncResponse: + def __init__(self, payload): + self.payload = payload + + def raise_for_status(self): + return None + + def json(self): + return self.payload + + +class SyncSession: + def __init__(self, responses): + self.responses = iter(responses) + self.calls = [] + + def post(self, url, **kwargs): + self.calls.append((url, kwargs)) + return next(self.responses) + + +class AsyncResponse: + def __init__(self, payload): + self.payload = payload + + def raise_for_status(self): + return None + + async def json(self, **kwargs): + return self.payload + + +class AsyncRequest: + def __init__(self, response): + self.response = response + + async def __aenter__(self): + return self.response + + async def __aexit__(self, *args): + return None + + +class AsyncSession: + def __init__(self, responses): + self.responses = iter(responses) + self.calls = [] + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return None + + def post(self, url, **kwargs): + self.calls.append((url, kwargs)) + return AsyncRequest(next(self.responses)) + + +def fields(call): + return {name: value[1] for name, value in call[1]["files"].items() if value[0] is None} class TestCaptchaAIService: - """Unit tests for the CaptchaAI classic-API provider adapter (no network).""" + """No-network coverage for native and legacy CaptchaAI API paths.""" + + def test_service_urls(self): + options = CaptchaOptionsSer(service_type=ServiceEnm.CAPTCHAAI) + options.urls_set() + assert options.url_request == "https://ocr.captchaai.com/in.php" + assert options.url_response == "https://ocr.captchaai.com/res.php" + + def test_solution_supports_structured_provider_results(self): + result = GetTaskResultResponseSer(solution={"click": [1, 3, 6]}).to_dict() + assert result["solution"] == {"click": [1, 3, 6]} + + def test_profiles_cover_all_supplied_solver_contracts(self): + expected = { + "bls", + "captchafox", + "cloudflare-challenge", + "friendly-captcha", + "geetest-v3", + "grid-base64", + "grid-file", + "lemin", + "normal-base64", + "normal-file", + "normal-solve-base64", + "normal-solve-file", + "recaptcha-v2", + "recaptcha-v2-enterprise", + "recaptcha-v2-invisible", + "recaptcha-v3", + "recaptcha-v3-enterprise", + "turnstile", + } + assert expected == set(CaptchaAI.profiles()) + + @pytest.mark.parametrize( + ("profile", "method"), + [ + ("bls", "bls"), + ("captchafox", "captchafox"), + ("cloudflare-challenge", "cloudflare_challenge"), + ("friendly-captcha", "friendly_captcha"), + ("geetest-v3", "geetest"), + ("lemin", "lemin"), + ("recaptcha-v2", "userrecaptcha"), + ("recaptcha-v3-enterprise", "userrecaptcha"), + ("turnstile", "turnstile"), + ], + ) + def test_profile_methods_are_declarative(self, profile, method): + assert captchaai.profile_method(profile) == method + + def test_unknown_future_method_is_passed_through(self, monkeypatch): + session = SyncSession( + [ + SyncResponse({"status": 1, "request": "FUTURE-1"}), + SyncResponse({"status": 1, "request": "future-result"}), + ] + ) + monkeypatch.setattr(captchaai.time, "sleep", lambda _: None) + + result = captchaai.solve_native( + key="api-key", + method="provider_future_method", + params={"custom_field": "custom-value"}, + url_request="submit-url", + url_response="result-url", + sleep_time=0, + session=session, + ) + + assert result["taskId"] == "FUTURE-1" + assert result["solution"] == {"request": "future-result"} + assert fields(session.calls[0]) == { + "key": "api-key", + "method": "provider_future_method", + "json": "1", + "custom_field": "custom-value", + } + assert fields(session.calls[1]) == {"key": "api-key", "action": "get", "id": "FUTURE-1", "json": "1"} + assert all(call[1]["timeout"] == captchaai.REQUEST_TIMEOUT for call in session.calls) - def test_service_enum(self): - assert ServiceEnm.CAPTCHAAI.value == "captchaai" - assert "captchaai" in ServiceEnm.list_values() + def test_profile_defaults_and_enterprise_result_shape(self, monkeypatch): + session = SyncSession( + [ + SyncResponse({"status": 1, "request": "TASK-1"}), + SyncResponse({"status": 1, "result": "enterprise-token", "user_agent": "UA"}), + ] + ) + monkeypatch.setattr(captchaai.time, "sleep", lambda _: None) + + result = captchaai.solve_native( + key="api-key", + method="userrecaptcha", + params={"googlekey": "site-key", "pageurl": "https://example.com"}, + profile="recaptcha-v2-enterprise", + url_request="submit-url", + url_response="result-url", + sleep_time=0, + session=session, + ) + + assert fields(session.calls[0])["enterprise"] == "1" + assert result["solution"]["token"] == "enterprise-token" + assert result["solution"]["gRecaptchaResponse"] == "enterprise-token" + assert result["solution"]["user_agent"] == "UA" + + def test_geetest_structured_response_without_status(self, monkeypatch): + session = SyncSession( + [ + SyncResponse({"status": 1, "request": "TASK-1"}), + SyncResponse({"challenge": "challenge", "validate": "validate", "seccode": "seccode"}), + ] + ) + monkeypatch.setattr(captchaai.time, "sleep", lambda _: None) + + result = captchaai.solve_native( + key="api-key", + method="geetest", + params={"gt": "gt", "challenge": "challenge", "pageurl": "https://example.com"}, + profile="geetest-v3", + url_request="submit-url", + url_response="result-url", + sleep_time=0, + session=session, + ) + + assert result["solution"] == {"challenge": "challenge", "validate": "validate", "seccode": "seccode"} + + def test_normal_file_direct_mode_uses_solve_endpoint(self): + session = SyncSession([SyncResponse({"status": 1, "request": "captcha-text"})]) + captcha_file = CaptchaAIFile(b"png", filename="captcha.png", content_type="image/png") + + result = captchaai.solve_native( + key="api-key", + method="post", + params={}, + files={"file": captcha_file}, + profile="normal-solve-file", + url_request="https://ocr.captchaai.com/in.php", + url_response="https://ocr.captchaai.com/res.php", + sleep_time=0, + session=session, + ) + + assert session.calls[0][0] == "https://ocr.captchaai.com/solve.php" + assert session.calls[0][1]["files"]["file"] == ("captcha.png", b"png", "image/png") + assert result["taskId"] is None + assert result["solution"]["text"] == "captcha-text" + + def test_profile_rejects_missing_required_fields(self): + result = captchaai.solve_native( + key="api-key", + method="cloudflare_challenge", + params={"pageurl": "https://example.com"}, + profile="cloudflare-challenge", + url_request="submit-url", + url_response="result-url", + sleep_time=0, + ) + assert result["errorId"] == 12 + assert "proxy" in result["errorCode"] + + def test_file_profile_requires_a_file_part(self): + result = captchaai.solve_native( + key="api-key", + method="post", + params={"file": "not-a-binary-part"}, + profile="normal-file", + url_request="submit-url", + url_response="result-url", + sleep_time=0, + ) + assert result["errorId"] == 12 + assert "file" in result["errorCode"] + + def test_legacy_class_translation_uses_metadata_and_preserves_string_id(self, monkeypatch): + session = SyncSession( + [ + SyncResponse({"status": 1, "request": "ABC123"}), + SyncResponse({"status": 1, "request": "captcha-token"}), + ] + ) + monkeypatch.setattr(captchaai.time, "sleep", lambda _: None) + payload = { + "clientKey": "api-key", + "task": { + "type": "RecaptchaV2Task", + "websiteURL": "https://example.com", + "websiteKey": "site-key", + "proxyType": "HTTPS", + "proxyAddress": "203.0.113.7", + "proxyPort": 3128, + }, + } + + result = captchaai.solve(payload, "submit-url", "result-url", sleep_time=0, session=session) - def test_urls_set(self): - opts = CaptchaOptionsSer(service_type=ServiceEnm.CAPTCHAAI) - opts.urls_set() - assert opts.url_request == "https://ocr.captchaai.com/in.php" - assert opts.url_response == "https://ocr.captchaai.com/res.php" + assert result["taskId"] == "ABC123" + assert result["solution"]["token"] == "captcha-token" + submit = fields(session.calls[0]) + assert submit["method"] == "userrecaptcha" + assert submit["googlekey"] == "site-key" + assert submit["pageurl"] == "https://example.com" + assert submit["proxy"] == "203.0.113.7:3128" + assert submit["proxytype"] == "HTTPS" - def test_map_turnstile(self): - method, extra = captchaai.build_classic_params( - {"type": "TurnstileTaskProxyless", "websiteKey": "0xAAA", "websiteURL": "https://x.com"} + def test_high_level_v3_enterprise_uses_declarative_compatibility_profile(self, monkeypatch): + captured = {} + + def solve(**kwargs): + captured.update(kwargs) + return {"errorId": 0} + + monkeypatch.setattr(captchaai, "solve", solve) + instance = ReCaptcha( + rucaptcha_key="api-key", + service_type=ServiceEnm.CAPTCHAAI, + websiteURL="https://example.com", + websiteKey="site-key", + method=ReCaptchaEnm.RecaptchaV3EnterpriseTaskProxyless, ) - assert method == "turnstile" - assert extra == {"sitekey": "0xAAA", "pageurl": "https://x.com"} - def test_map_recaptcha_v2(self): - method, extra = captchaai.build_classic_params( - {"type": "RecaptchaV2TaskProxyless", "websiteKey": "k", "websiteURL": "https://x"} + assert instance.captcha_handler() == {"errorId": 0} + assert captured["session"] is instance.session + + def test_control_operations_are_declarative(self): + session = SyncSession([SyncResponse({"status": 1, "request": "600"})]) + result = captchaai.control("api-key", "balance", "result-url", session=session) + + assert result == {"request": "600", "balance": "600"} + assert fields(session.calls[0]) == {"key": "api-key", "json": "1", "action": "getbalance"} + + def test_invalid_response_is_reported(self): + session = SyncSession([SyncResponse([])]) + result = captchaai.solve_native( + key="api-key", + method="future", + params={}, + url_request="submit-url", + url_response="result-url", + sleep_time=0, + session=session, ) - assert method == "userrecaptcha" - assert extra["googlekey"] == "k" - assert extra["pageurl"] == "https://x" + assert result["errorId"] == 12 + assert result["errorCode"].startswith("ERROR_SUBMIT") - def test_map_recaptcha_v2_enterprise(self): - method, extra = captchaai.build_classic_params( - {"type": "RecaptchaV2EnterpriseTaskProxyless", "websiteKey": "k", "websiteURL": "https://x"} + async def test_async_native_future_method(self, monkeypatch): + session = AsyncSession( + [ + AsyncResponse({"status": 1, "request": "ABC123"}), + AsyncResponse({"status": 0, "request": "CAPCHA_NOT_READY"}), + AsyncResponse({"status": 1, "request": "captcha-token"}), + ] ) - assert method == "userrecaptcha" - assert extra["enterprise"] == 1 - - def test_map_recaptcha_v3(self): - method, extra = captchaai.build_classic_params( - { - "type": "RecaptchaV3TaskProxyless", - "websiteKey": "k", - "websiteURL": "https://x", - "pageAction": "login", - "minScore": 0.7, - } + monkeypatch.setattr(captchaai.aiohttp, "ClientSession", lambda **kwargs: session) + monkeypatch.setattr(captchaai, "_aio_form", lambda values, files: dict(values)) + monkeypatch.setattr(captchaai.asyncio, "sleep", AsyncMock()) + + result = await captchaai.aio_solve_native( + key="api-key", + method="future_method", + params={"provider_option": "enabled"}, + url_request="submit-url", + url_response="result-url", + sleep_time=0, ) - assert method == "userrecaptcha" - assert extra["version"] == "v3" - assert extra["action"] == "login" - assert extra["min_score"] == 0.7 - def test_map_image(self): - method, extra = captchaai.build_classic_params({"type": "ImageToTextTask", "body": "B64"}) - assert method == "base64" - assert extra == {"body": "B64"} + assert result["taskId"] == "ABC123" + assert result["solution"] == {"request": "captcha-token"} + assert [call[0] for call in session.calls] == ["submit-url", "result-url", "result-url"] + assert session.calls[0][1]["data"] == { + "key": "api-key", + "method": "future_method", + "json": 1, + "provider_option": "enabled", + } - def test_unsupported_type_raises(self): + def test_aio_form_is_multipart_for_scalar_and_file_fields(self): + form = captchaai._aio_form({"key": "api-key"}, {"file": CaptchaAIFile(b"image")}) + assert form.is_multipart + + def test_public_client_requires_method_or_profile(self): with pytest.raises(ValueError): - captchaai.build_classic_params( - {"type": "HCaptchaTaskProxyless", "websiteKey": "k", "websiteURL": "u"} - ) - - def test_solution_shapes(self): - assert captchaai._solution("base64", "abc") == {"text": "abc"} - token = captchaai._solution("turnstile", "tok") - assert token["token"] == "tok" - assert token["gRecaptchaResponse"] == "tok" + CaptchaAI(rucaptcha_key="api-key") + + def test_public_client_dispatches_native_method(self, monkeypatch): + captured = {} + + def solve_native(**kwargs): + captured.update(kwargs) + return {"errorId": 0} + + monkeypatch.setattr(captchaai, "solve_native", solve_native) + client = CaptchaAI( + rucaptcha_key="api-key", + method="provider_future_method", + params={"provider_field": "value"}, + ) + + assert client.captcha_handler() == {"errorId": 0} + assert captured["method"] == "provider_future_method" + assert captured["params"] == {"provider_field": "value"} + assert captured["session"] is client.session From e92b767cc0a94630ed9d5a647e27bb2bb912e8a1 Mon Sep 17 00:00:00 2001 From: AndreiDrang Date: Sun, 12 Jul 2026 00:00:32 +0300 Subject: [PATCH 3/3] v6.7.0 --- src/python_rucaptcha/__version__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/python_rucaptcha/__version__.py b/src/python_rucaptcha/__version__.py index e205487d..95bb205a 100644 --- a/src/python_rucaptcha/__version__.py +++ b/src/python_rucaptcha/__version__.py @@ -1 +1 @@ -__version__ = "6.6.0" +__version__ = "6.7.0"