From 0d1671e69e39202af45f8cb49b41a08cd85c84b1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 02:08:11 +0000 Subject: [PATCH 01/16] Update idna requirement from >=3.15 to >=3.18 Updates the requirements on [idna](https://github.com/kjd/idna) to permit the latest version. - [Release notes](https://github.com/kjd/idna/releases) - [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.md) - [Commits](https://github.com/kjd/idna/compare/v3.15...v3.18) --- updated-dependencies: - dependency-name: idna dependency-version: '3.18' dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 6551e7fe..36ac71c1 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -5,4 +5,4 @@ enum-tools[sphinx]==0.13.0 requests>=2.32.4 # not directly required, pinned by Snyk to avoid a vulnerability urllib3>=2.2.2 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.23.1 # not directly required, pinned by Snyk to avoid a vulnerability -idna>=3.15 # not directly required, pinned by Snyk to avoid a vulnerability +idna>=3.18 # not directly required, pinned by Snyk to avoid a vulnerability From 63e43f223ff2bc6da34bd9d24978c4e018e50472 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:52:40 +0000 Subject: [PATCH 02/16] [github-actions] Bump codecov/codecov-action from 6 to 7 Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 6 to 7. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v6...v7) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9dbc905e..d32cc982 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,7 +48,7 @@ jobs: run: make tests - name: Upload coverage to Codecov - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} files: ${{github.workspace}}/coverage/coverage.xml From 4dd59aa8df30f88d0e44d4c894ca394914289112 Mon Sep 17 00:00:00 2001 From: bshahin Date: Wed, 17 Jun 2026 10:22:53 +0300 Subject: [PATCH 03/16] feat: add CaptchaAI as a provider (classic in.php/res.php adapter) CaptchaAI is 2Captcha-API-compatible via the classic in.php/res.php endpoints (rather than the JSON createTask API). Adds ServiceEnm.CAPTCHAAI, URL routing in CaptchaOptionsSer.urls_set(), and a small classic-API adapter (core/captchaai.py) that maps the library's createTask payloads to classic params and parses the classic responses, returning the usual GetTaskResultResponseSer shape so the public Turnstile / ReCaptcha / ImageCaptcha classes work transparently. Supported task types: Turnstile, reCAPTCHA v2 (incl. Enterprise), reCAPTCHA v3, ImageToText; unsupported types raise a clear ValueError. Adds README usage note and tests/test_captchaai.py (no-network unit tests). Refs #366 --- README.md | 17 +- src/python_rucaptcha/core/base.py | 20 +++ src/python_rucaptcha/core/captchaai.py | 197 ++++++++++++++++++++++++ src/python_rucaptcha/core/enums.py | 1 + src/python_rucaptcha/core/serializer.py | 6 +- tests/test_captchaai.py | 73 +++++++++ 6 files changed, 312 insertions(+), 2 deletions(-) create mode 100644 src/python_rucaptcha/core/captchaai.py create mode 100644 tests/test_captchaai.py diff --git a/README.md b/README.md index 6edea8e2..c1b94833 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,22 @@ [![Downloads](https://static.pepy.tech/badge/python-rucaptcha/month)](https://pepy.tech/project/python-rucaptcha) [![Documentation](https://img.shields.io/badge/docs-Sphinx-green)](https://andreidrang.github.io/python-rucaptcha/) -**Python 3.9+ library to solve CAPTCHAs automatically using RuCaptcha, 2Captcha, or DeathByCaptcha services.** +**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. +> ```python +> from python_rucaptcha.turnstile import Turnstile +> from python_rucaptcha.core.enums import ServiceEnm +> result = Turnstile( +> rucaptcha_key="CAPTCHAAI_KEY", +> service_type=ServiceEnm.CAPTCHAAI, +> websiteURL="https://example.com", +> websiteKey="0x4AAAAAAA...", +> userAgent="Mozilla/5.0 ...", +> ).captcha_handler() +> ``` ## What is this? diff --git a/src/python_rucaptcha/core/base.py b/src/python_rucaptcha/core/base.py index af5ff582..a6e95d95 100644 --- a/src/python_rucaptcha/core/base.py +++ b/src/python_rucaptcha/core/base.py @@ -82,6 +82,16 @@ def _processing_response(self, **kwargs: dict[str, Any]) -> dict[str, Any]: Method processing captcha solving task creation result :param kwargs: additional params for Requests library """ + # CaptchaAI speaks the classic in.php/res.php API, not the JSON createTask API. + if self.params.service_type == ServiceEnm.CAPTCHAAI.value: + from . import captchaai + + return captchaai.solve( + create_task_payload=self.create_task_payload, + url_request=self.params.url_request, + url_response=self.params.url_response, + sleep_time=self.params.sleep_time, + ) try: response = GetTaskResultResponseSer( **self.session.post(self.params.url_request, json=self.create_task_payload, **kwargs).json() @@ -126,6 +136,16 @@ async def _aio_processing_response(self) -> dict[str, Any]: """ Method processing async captcha solving task creation result """ + # CaptchaAI speaks the classic in.php/res.php API, not the JSON createTask API. + if self.params.service_type == ServiceEnm.CAPTCHAAI.value: + from . import captchaai + + return await captchaai.aio_solve( + create_task_payload=self.create_task_payload, + url_request=self.params.url_request, + url_response=self.params.url_response, + sleep_time=self.params.sleep_time, + ) try: # make async or sync request response = await self.__aio_create_task() diff --git a/src/python_rucaptcha/core/captchaai.py b/src/python_rucaptcha/core/captchaai.py new file mode 100644 index 00000000..73a77694 --- /dev/null +++ b/src/python_rucaptcha/core/captchaai.py @@ -0,0 +1,197 @@ +""" +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. +""" + +from __future__ import annotations + +import time +import asyncio +import logging +from typing import Any + +import aiohttp +import requests + +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"} + + +def _err(request: str | None) -> dict[str, Any]: + return GetTaskResultResponseSer( + status="failed", + errorId=12, + errorCode=request or "ERROR_UNKNOWN", + errorDescription=f"CaptchaAI returned: {request}", + ).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: + raise ValueError( + f"CaptchaAI provider does not support task type {ctype!r}. " + f"Supported: Turnstile, ReCaptchaV2, ReCaptchaV3, ImageToText." + ) + + # pass-through proxy / user-agent if present + if task.get("userAgent"): + extra["userAgent"] = task["userAgent"] + return method, extra + + +def _solution(method: str, token: str) -> dict[str, str]: + if method == "base64": + return {"text": token} + return {"token": token, "gRecaptchaResponse": token} + + +def solve( + create_task_payload: dict[str, Any], + url_request: str, + url_response: str, + sleep_time: int, +) -> dict[str, Any]: + """Synchronous classic in.php/res.php solve.""" + key = create_task_payload["clientKey"] + task = create_task_payload["task"] + try: + method, extra = build_classic_params(task) + except (KeyError, ValueError) as error: + return _err(str(error)) + + data = {"key": key, "method": method, "json": 1, **extra} + try: + created = requests.post(url_request, data=data).json() + except Exception as error: # noqa: BLE001 + return _err(f"ERROR_SUBMIT {error}") + if created.get("status") != 1: + return _err(created.get("request")) + + captcha_id = created["request"] + time.sleep(sleep_time) + for _ in attempts_generator(): + try: + res = requests.get( + url_response, + params={"key": key, "action": "get", "id": captcha_id, "json": 1}, + ).json() + logging.info(f"CaptchaAI sync result - {res = }") + except Exception as error: # noqa: BLE001 + 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": + time.sleep(sleep_time) + continue + return _err(res.get("request")) + return _err("ERROR_TIMEOUT") + + +async def aio_solve( + create_task_payload: dict[str, Any], + url_request: str, + url_response: str, + sleep_time: int, +) -> dict[str, Any]: + """Asynchronous classic in.php/res.php solve.""" + key = create_task_payload["clientKey"] + task = create_task_payload["task"] + try: + method, extra = build_classic_params(task) + except (KeyError, ValueError) as error: + return _err(str(error)) + + data = {"key": key, "method": method, "json": 1, **extra} + async with aiohttp.ClientSession() as session: + try: + async with session.post(url_request, data=data) as resp: + created = await resp.json(content_type=None) + except Exception as error: # noqa: BLE001 + return _err(f"ERROR_SUBMIT {error}") + if created.get("status") != 1: + return _err(created.get("request")) + + captcha_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 = }") + except Exception as error: # noqa: BLE001 + 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": + await asyncio.sleep(sleep_time) + continue + return _err(res.get("request")) + return _err("ERROR_TIMEOUT") diff --git a/src/python_rucaptcha/core/enums.py b/src/python_rucaptcha/core/enums.py index 26315bff..12243d73 100644 --- a/src/python_rucaptcha/core/enums.py +++ b/src/python_rucaptcha/core/enums.py @@ -24,6 +24,7 @@ class ServiceEnm(str, MyEnum): TWOCAPTCHA = "2captcha" RUCAPTCHA = "rucaptcha" DEATHBYCAPTCHA = "deathbycaptcha" + CAPTCHAAI = "captchaai" class SaveFormatsEnm(str, MyEnum): diff --git a/src/python_rucaptcha/core/serializer.py b/src/python_rucaptcha/core/serializer.py index 1428c16e..e36aea67 100644 --- a/src/python_rucaptcha/core/serializer.py +++ b/src/python_rucaptcha/core/serializer.py @@ -68,7 +68,11 @@ def urls_set(self): if isinstance(self.service_type, enums.ServiceEnm): self.service_type = self.service_type.value - if self.service_type == enums.ServiceEnm.DEATHBYCAPTCHA: + if self.service_type == enums.ServiceEnm.CAPTCHAAI: + # CaptchaAI exposes the classic 2captcha in.php/res.php API. + self.url_request = "https://ocr.captchaai.com/in.php" + self.url_response = "https://ocr.captchaai.com/res.php" + elif self.service_type == enums.ServiceEnm.DEATHBYCAPTCHA: self.url_request = f"http://api.{self.service_type}.com/2captcha/in.php" self.url_response = f"http://api.{self.service_type}.com/2captcha/res.php" else: diff --git a/tests/test_captchaai.py b/tests/test_captchaai.py new file mode 100644 index 00000000..cb4f2464 --- /dev/null +++ b/tests/test_captchaai.py @@ -0,0 +1,73 @@ +import pytest + +from python_rucaptcha.core import captchaai +from python_rucaptcha.core.enums import ServiceEnm +from python_rucaptcha.core.serializer import CaptchaOptionsSer + + +class TestCaptchaAIService: + """Unit tests for the CaptchaAI classic-API provider adapter (no network).""" + + def test_service_enum(self): + assert ServiceEnm.CAPTCHAAI.value == "captchaai" + assert "captchaai" in ServiceEnm.list_values() + + 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" + + def test_map_turnstile(self): + method, extra = captchaai.build_classic_params( + {"type": "TurnstileTaskProxyless", "websiteKey": "0xAAA", "websiteURL": "https://x.com"} + ) + 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 method == "userrecaptcha" + assert extra["googlekey"] == "k" + assert extra["pageurl"] == "https://x" + + def test_map_recaptcha_v2_enterprise(self): + method, extra = captchaai.build_classic_params( + {"type": "RecaptchaV2EnterpriseTaskProxyless", "websiteKey": "k", "websiteURL": "https://x"} + ) + 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, + } + ) + 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"} + + def test_unsupported_type_raises(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" From 4bb9fa97c7f62999fee2ce7123181b7a7154b4a4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:52:39 +0000 Subject: [PATCH 04/16] [github-actions] Bump actions/checkout from 6 to 7 Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/build.yml | 2 +- .github/workflows/install.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/sphinx.yml | 2 +- .github/workflows/test.yml | 2 +- .github/workflows/zai-code-bot.yml | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6a42cd31..f246435a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,7 +23,7 @@ jobs: python-version: ["3.9", "3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: diff --git a/.github/workflows/install.yml b/.github/workflows/install.yml index 2886378a..f492c0fc 100644 --- a/.github/workflows/install.yml +++ b/.github/workflows/install.yml @@ -23,7 +23,7 @@ jobs: python-version: ["3.9", "3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a862597a..4ea9a46f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -27,7 +27,7 @@ jobs: python-version: ["3.12"] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: diff --git a/.github/workflows/sphinx.yml b/.github/workflows/sphinx.yml index 8896470d..59761710 100644 --- a/.github/workflows/sphinx.yml +++ b/.github/workflows/sphinx.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-python@v6 - name: Build docs requirements diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9dbc905e..e9d0f2fc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,7 +33,7 @@ jobs: python-version: ["3.11"] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: diff --git a/.github/workflows/zai-code-bot.yml b/.github/workflows/zai-code-bot.yml index 6d6abb10..4686f6f1 100644 --- a/.github/workflows/zai-code-bot.yml +++ b/.github/workflows/zai-code-bot.yml @@ -35,7 +35,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Run Z.ai Bot uses: AndreiDrang/zai-code-bot@main From abddcaa74b7061511d66a883b1c96d1c8e53c53a Mon Sep 17 00:00:00 2001 From: bshahin Date: Fri, 19 Jun 2026 21:47:45 +0300 Subject: [PATCH 05/16] style: narrow broad except clauses to specific exceptions (requests/aiohttp + ValueError) Addresses Codacy broad-exception-caught flags on the CaptchaAI adapter. --- src/python_rucaptcha/core/captchaai.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/python_rucaptcha/core/captchaai.py b/src/python_rucaptcha/core/captchaai.py index 73a77694..d6ee344a 100644 --- a/src/python_rucaptcha/core/captchaai.py +++ b/src/python_rucaptcha/core/captchaai.py @@ -119,7 +119,7 @@ def solve( data = {"key": key, "method": method, "json": 1, **extra} try: created = requests.post(url_request, data=data).json() - except Exception as error: # noqa: BLE001 + except (requests.RequestException, ValueError) as error: return _err(f"ERROR_SUBMIT {error}") if created.get("status") != 1: return _err(created.get("request")) @@ -133,7 +133,7 @@ def solve( params={"key": key, "action": "get", "id": captcha_id, "json": 1}, ).json() logging.info(f"CaptchaAI sync result - {res = }") - except Exception as error: # noqa: BLE001 + except (requests.RequestException, ValueError) as error: return _err(f"ERROR_POLL {error}") if res.get("status") == 1: return GetTaskResultResponseSer( @@ -167,7 +167,7 @@ async def aio_solve( try: async with session.post(url_request, data=data) as resp: created = await resp.json(content_type=None) - except Exception as error: # noqa: BLE001 + except (aiohttp.ClientError, ValueError) as error: return _err(f"ERROR_SUBMIT {error}") if created.get("status") != 1: return _err(created.get("request")) @@ -182,7 +182,7 @@ async def aio_solve( ) as resp: res = await resp.json(content_type=None) logging.info(f"CaptchaAI async result - {res = }") - except Exception as error: # noqa: BLE001 + except (aiohttp.ClientError, ValueError) as error: return _err(f"ERROR_POLL {error}") if res.get("status") == 1: return GetTaskResultResponseSer( From c5fc8f3bae2ad3fa7ca806f8b96ac71394181ccd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:52:38 +0000 Subject: [PATCH 06/16] Bump black from 25.11.0 to 26.5.1 Bumps [black](https://github.com/psf/black) from 25.11.0 to 26.5.1. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/compare/25.11.0...26.5.1) --- updated-dependencies: - dependency-name: black dependency-version: 26.5.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- requirements.style.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.style.txt b/requirements.style.txt index b4100bfa..3fa5b69d 100644 --- a/requirements.style.txt +++ b/requirements.style.txt @@ -1,4 +1,4 @@ # codestyle isort==6.* -black==25.11.0 +black==26.5.1 autoflake==2.* From 4c253a3c3b88f0665510f98df95c8dd7246e674b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:52:45 +0000 Subject: [PATCH 07/16] Update pytest requirement from ==8.* to ==9.* Updates the requirements on [pytest](https://github.com/pytest-dev/pytest) to permit the latest version. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/8.0.0.dev0...9.1.1) --- updated-dependencies: - dependency-name: pytest dependency-version: 9.1.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.test.txt b/requirements.test.txt index cc87b91b..e14e8600 100644 --- a/requirements.test.txt +++ b/requirements.test.txt @@ -1,3 +1,3 @@ -pytest==8.* +pytest==9.* coverage==7.* pytest-asyncio==1.* From b70cebd10ede417156b892dd982084c13d0330c7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:52:49 +0000 Subject: [PATCH 08/16] Update zipp requirement from >=3.23.1 to >=4.1.0 Updates the requirements on [zipp](https://github.com/jaraco/zipp) to permit the latest version. - [Release notes](https://github.com/jaraco/zipp/releases) - [Changelog](https://github.com/jaraco/zipp/blob/main/NEWS.rst) - [Commits](https://github.com/jaraco/zipp/compare/v3.23.1...v4.1.0) --- updated-dependencies: - dependency-name: zipp dependency-version: 4.1.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 6551e7fe..066b5c2d 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -4,5 +4,5 @@ myst-parser==5.1.0 enum-tools[sphinx]==0.13.0 requests>=2.32.4 # not directly required, pinned by Snyk to avoid a vulnerability urllib3>=2.2.2 # not directly required, pinned by Snyk to avoid a vulnerability -zipp>=3.23.1 # not directly required, pinned by Snyk to avoid a vulnerability +zipp>=4.1.0 # not directly required, pinned by Snyk to avoid a vulnerability idna>=3.15 # not directly required, pinned by Snyk to avoid a vulnerability From 51d713ca8d004e45c563fe52df62578a65411d24 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:52:55 +0000 Subject: [PATCH 09/16] Update urllib3 requirement from >=2.2.2 to >=2.7.0 Updates the requirements on [urllib3](https://github.com/urllib3/urllib3) to permit the latest version. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/2.2.2...2.7.0) --- updated-dependencies: - dependency-name: urllib3 dependency-version: 2.7.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 6551e7fe..1f87cff3 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -3,6 +3,6 @@ pallets_sphinx_themes==2.5.0 myst-parser==5.1.0 enum-tools[sphinx]==0.13.0 requests>=2.32.4 # not directly required, pinned by Snyk to avoid a vulnerability -urllib3>=2.2.2 # not directly required, pinned by Snyk to avoid a vulnerability +urllib3>=2.7.0 # not directly required, pinned by Snyk to avoid a vulnerability zipp>=3.23.1 # not directly required, pinned by Snyk to avoid a vulnerability idna>=3.15 # not directly required, pinned by Snyk to avoid a vulnerability From 38644475fdf761a8bd001117a6ee824b190ed687 Mon Sep 17 00:00:00 2001 From: AndreiDrang Date: Sat, 11 Jul 2026 21:41:23 +0300 Subject: [PATCH 10/16] upd agents --- AGENTS.md | 116 +++++++++++++--------------- docs/AGENTS.md | 35 +++++++++ src/python_rucaptcha/AGENTS.md | 69 +++++++---------- src/python_rucaptcha/core/AGENTS.md | 60 ++++++++------ tests/AGENTS.md | 61 +++++++-------- 5 files changed, 179 insertions(+), 162 deletions(-) create mode 100644 docs/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md index 9f1b394d..5c49d204 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,68 +1,60 @@ -# PROJECT KNOWLEDGE BASE +# AGENTS.md -**Generated:** 2026-02-20 -**Commit:** 86ee3e6 -**Branch:** master +## Repository overview -## OVERVIEW -Python 3.9+ library for RuCaptcha/2Captcha/DeathByCaptcha service APIs. Supports 30+ CAPTCHA types with dual sync/async interfaces. +- `python-rucaptcha` is a Python 3.9+ setuptools library for the 2Captcha, RuCaptcha, and DeathByCaptcha APIs. +- The package uses a `src/` layout and provides synchronous and asynchronous CAPTCHA solver handlers. +- The repository is a single package, not a monorepo. The root `AGENTS.md` applies everywhere unless a nearer local file applies. -## STRUCTURE -``` -./ -├── src/python_rucaptcha/ # Main package (30+ captcha modules) -│ └── core/ # Base classes, serializers, enums -├── tests/ # Pytest test suite (23+ files) -├── docs/ # Sphinx documentation -├── pyproject.toml # Build config (black 110, isort, pytest) -└── Makefile # Build automation -``` +## Instruction scope + +- Local instruction files are `src/python_rucaptcha/AGENTS.md`, `src/python_rucaptcha/core/AGENTS.md`, `tests/AGENTS.md`, and `docs/AGENTS.md`. +- A nearer `AGENTS.md` adds local guidance; it does not repeat or silently contradict this file. Sibling files do not affect one another. +- No `AGENTS.override.md` files are present. If one is introduced, it takes precedence only within its own directory. + +## Where to work -## WHERE TO LOOK -| Task | Location | Notes | -|------|----------|-------| -| Add new CAPTCHA | `src/python_rucaptcha/` | Create module inheriting `BaseCaptcha` | -| Modify API flow | `src/python_rucaptcha/core/base.py` | `_processing_response()` methods | -| Change serialization | `src/python_rucaptcha/core/serializer.py` | `MyBaseModel` class | -| Add CAPTCHA enum | `src/python_rucaptcha/core/enums.py` | 25+ enum classes | -| Run tests | `tests/` | Requires `RUCAPTCHA_KEY` env var | - -## CODE MAP -| Symbol | Type | Location | Role | -|--------|------|----------|------| -| BaseCaptcha | Class | core/base.py | Parent for all captcha solvers | -| MyBaseModel | Class | core/serializer.py | msgspec Struct wrapper | -| CaptchaOptionsSer | Class | core/config.py | Service URL abstraction | -| MyEnum | Class | core/enums.py | Custom enum with utils | - -## CONVENTIONS -- **Line length**: 110 chars (pyproject.toml) -- **Async mode**: `asyncio_mode = auto` in pytest -- **No tox**: Uses Makefile directly for test/lint -- **Import order**: isort with black profile - -## ANTI-PATTERNS (THIS PROJECT) -- No TODO/FIXME/DEPRECATED comments in code -- No explicit "DO NOT" directives -- Logging warnings output full result objects (potential sensitive data) - -## UNIQUE STYLES -- **25+ custom enums**: Each CAPTCHA type has dedicated enum (e.g., `HCaptchaEnm`) -- **Service abstraction**: Unified API for 2Captcha/RuCaptcha/DeathByCaptcha -- **msgspec**: Fast serialization (replaced pydantic v6.0) -- **Dual sync/async**: Every captcha class has both handlers - -## COMMANDS -```bash -make install # pip install -e . -make tests # Run pytest with coverage -make lint # autoflake + black + isort check -make build # Build package -make doc # Build Sphinx docs +```text +src/python_rucaptcha/ # CAPTCHA implementations and package API +└── core/ # Base request flow, service config, serializers, enums + tests/ # Pytest suite and shared fixtures + docs/ # Sphinx sources and per-CAPTCHA examples + pyproject.toml # setuptools, Black, isort, and pytest configuration + Makefile # install, lint, test, build, and documentation workflows ``` -## NOTES -- Tests require live API keys (`RUCAPTCHA_KEY`, `DEATHBYCAPTCHA_KEY`) -- CI runs on Python 3.11 (tests) / 3.12 (lint) — version mismatch -- No cross-platform testing (only ubuntu-latest) -- x.py in root is debug script (non-standard) +Do not hand-edit `dist/` or `src/python_rucaptcha.egg-info/`; they are build/package metadata outputs. + +## Architecture and boundaries + +- Concrete CAPTCHA modules inherit from `BaseCaptcha` in `src/python_rucaptcha/core/base.py`. Keep CAPTCHA-specific payload preparation in the concrete module, not in the shared base flow. +- `core/` owns request/polling behavior, service URL selection, msgspec serialization, result models, and shared enums. Changes there can affect every solver. +- Each solver's synchronous and asynchronous handlers must preserve the same task semantics and response shape. Service task field names are part of the external API contract. + +## Context routing + +- For public usage and supported solver/service behavior, read `README.md`. +- For contribution expectations, read `CONTRIBUTING.md` before preparing a project-wide change. +- For documentation changes, read `docs/index.rst` and `docs/conf.py`; the local rules are in `docs/AGENTS.md`. +- For build, formatting, test, or packaging changes, read `pyproject.toml`, `Makefile`, and the relevant `.github/workflows/*.yml` file. +- No `ARCHITECTURE.md` is present; use the core modules and package-local instructions as the current implementation source of truth. + +## Change rules + +- Adding a solver normally requires a new package module, a matching enum in `core/enums.py`, tests under `tests/`, and a documentation example/toctree entry when it is user-facing. +- Preserve the existing flat `*_captcha.py` module naming and `{CaptchaType}Captcha`/`{CaptchaType}Enm` naming patterns. +- Do not put solver-specific branches into `BaseCaptcha` merely to support one CAPTCHA type. +- Never commit API keys or add diagnostics that expose credentials or complete service responses unnecessarily. The integration tests make external service calls. + +## Validation + +- Formatting/static checks: `make lint` (autoflake, Black, and isort over `src/`). +- Integration tests and coverage: `make tests`; collection requires `RUCAPTCHA_KEY`, and DeathByCaptcha coverage may use `DEATHBYCAPTCHA_KEY`. +- Package build: `make build`. +- Sphinx documentation: `make doc`. +- CI runs tests on Python 3.11, lint on 3.12, and build checks on Python 3.9–3.12; account for the supported `requires-python >= 3.9` range. + +## Repository-specific gotchas + +- `tests/conftest.py` reads `RUCAPTCHA_KEY` while defining `BaseTest`, so missing credentials can prevent normal test collection rather than merely skip a test. +- The test fixtures deliberately sleep between cases and the suite is not an offline-only unit suite; use targeted tests while iterating, then run the relevant full command before handoff. diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 00000000..3654e7e1 --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,35 @@ +# AGENTS.md + +## Scope and inheritance + +Applies to: `docs/` and its descendants. + +Inherits repository-wide guidance from `../AGENTS.md`. + +This file defines only local differences for the Sphinx documentation subtree. + +## What lives here + +```text +docs/ +├── conf.py # Sphinx extensions, autodoc imports, theme, metadata +├── index.rst # Main toctrees and documentation navigation +├── modules/ # Per-CAPTCHA examples and supporting Markdown/RST +├── _static/ # Images and other published assets +├── requirements.txt # Documentation-only dependencies +└── Makefile # Local Sphinx build wrapper +``` + +## Local boundaries and invariants + +- Sphinx configuration imports the package's solver modules directly and uses autodoc; documentation builds therefore depend on importable, current Python modules. +- User-facing solver additions should have a matching example under `docs/modules/` and an entry in the appropriate `index.rst` toctree. Keep example parameter names aligned with the actual solver payload. +- Keep generated `_build/` output out of source edits. Static images belong in `docs/_static/` when they are part of the published docs. + +## Validation + +From the repository root, run `make doc`; it installs the package and invokes the Sphinx build in `docs/`. For a docs-only iteration, `cd docs && make html -e` uses the local documentation Makefile after its requirements are installed. + +## Nearby docs + +Read `docs/index.rst` when changing navigation and `docs/conf.py` when changing extensions, autodoc imports, theme settings, or published metadata. diff --git a/src/python_rucaptcha/AGENTS.md b/src/python_rucaptcha/AGENTS.md index 77569d04..f97915dd 100644 --- a/src/python_rucaptcha/AGENTS.md +++ b/src/python_rucaptcha/AGENTS.md @@ -1,46 +1,35 @@ -# python_rucaptcha Package +# AGENTS.md -**Parent:** ./AGENTS.md +## Scope and inheritance -## OVERVIEW -Main package containing 30+ CAPTCHA solver modules. Each module implements a specific CAPTCHA type. +Applies to: `src/python_rucaptcha/` and its descendants. -## STRUCTURE -``` +Inherits repository-wide guidance from `../../AGENTS.md`. + +This file defines only local differences for this package subtree. + +## What lives here + +```text src/python_rucaptcha/ -├── __init__.py # Exports __version__, package info -├── core/ # Base classes (base.py, config.py, enums.py, serializer.py) -├── _captcha.py # Internal helpers -├── hcaptcha.py # hCaptcha solver -├── re_captcha.py # reCaptcha v2/v3 solver -├── turnstile.py # Cloudflare Turnstile solver -├── image_captcha.py # Image captcha solver -├── audio_captcha.py # Audio captcha solver -├── gee_test.py # GeeTest solver -├── key_captcha.py # KeyCaptcha solver -├── ... # 20+ more captcha types -└── control.py # Balance/status checker +├── *_captcha.py # Concrete CAPTCHA task builders and handlers +├── control.py # Balance/status-style service operations +├── __init__.py # Package version exposure +└── core/ # Shared request flow and data models ``` -## WHERE TO LOOK -| Task | File | -|------|------| -| Add new CAPTCHA type | Create new `*_captcha.py` module | -| Modify BaseCaptcha | `core/base.py` | -| Add new enum | `core/enums.py` | - -## CONVENTIONS -- **Module naming**: `*_captcha.py` pattern -- **Class naming**: `{CaptchaType}Captcha` (e.g., `HCaptcha`, `ReCaptcha`) -- **Enum naming**: `{CaptchaType}Enm` (e.g., `HCaptchaEnm`) -- **Inheritance**: All captcha classes extend `BaseCaptcha` - -## ADDING NEW CAPTCHA -1. Create `src/python_rucaptcha/new_captcha.py` -2. Define enum `NewCaptchaEnm` in `core/enums.py` -3. Create class `NewCaptcha(BaseCaptcha)` implementing required methods -4. Add tests in `tests/test_new_captcha.py` - -## ANTI-PATTERNS -- DO NOT modify core files without understanding the inheritance chain -- DO NOT add captcha-specific logic directly in BaseCaptcha +## Local boundaries and invariants + +- Concrete modules are deliberately flat and inherit from `core.base.BaseCaptcha`. +- A solver owns its service task fields, method validation, and sync/async handler entry points; shared transport and polling remain in `core/`. +- Keep module names in the existing `*_captcha.py` style and enum names in the `{CaptchaType}Enm` style, including established exceptions such as `re_captcha.py` and `hcaptcha.py`. + +## Safe change rules + +When adding a CAPTCHA type, add the module, its enum in `core/enums.py`, a corresponding `tests/test_.py`, and the relevant `docs/modules/*` example/toctree entry. Reuse `BaseCaptcha` rather than introducing a second transport path. + +Before changing a core call or payload contract for one solver, inspect the sibling modules and the shared core instruction file; a local workaround should not become a global branch. + +## Validation + +For focused feedback, run the matching test module, for example `pytest tests/test_hcaptcha.py`, then use the root-level `make lint` and `make tests` checks as appropriate. API-key and live-service requirements are documented in `tests/AGENTS.md`. diff --git a/src/python_rucaptcha/core/AGENTS.md b/src/python_rucaptcha/core/AGENTS.md index 580836be..e92f2724 100644 --- a/src/python_rucaptcha/core/AGENTS.md +++ b/src/python_rucaptcha/core/AGENTS.md @@ -1,25 +1,35 @@ -# core Module - -**Parent:** ../AGENTS.md, ../../src/python_rucaptcha/AGENTS.md - -## OVERVIEW -Foundation module with base classes, configuration, serialization, and enums. All captcha solvers depend on these. - -## WHERE TO LOOK -| File | Role | -|------|------| -| `base.py` | BaseCaptcha class - parent for all solvers | -| `config.py` | CaptchaOptionsSer - service URL abstraction | -| `serializer.py` | MyBaseModel - msgspec wrapper | -| `enums.py` | MyEnum + 25+ CAPTCHA enums | - -## KEY SYMBOLS -- `BaseCaptcha`: Parent class for all captcha solvers (sync + async) -- `MyBaseModel`: msgspec Struct wrapper with `.to_dict()` -- `CaptchaOptionsSer`: Dynamic service URL selection -- `MyEnum`: Custom enum with `.list()`, `.list_values()`, `.list_names()` - -## CONVENTIONS -- **BaseCaptcha provides**: `captcha_handler()`, `aio_captcha_handler()`, session management, file handling -- **Serialization**: Uses msgspec (not pydantic) -- **Enums**: Each CAPTCHA type has dedicated enum in enums.py +# AGENTS.md + +## Scope and inheritance + +Applies to: `src/python_rucaptcha/core/` and its descendants. + +Inherits repository-wide guidance from `../../../AGENTS.md` and package-local guidance from `../AGENTS.md`. + +This file defines only local differences for this foundation subtree. + +## What lives here + +```text +core/ +├── base.py # BaseCaptcha sync/async transport, polling, file handling +├── config.py # Retry settings and application/service configuration +├── serializer.py # msgspec Struct request/response models +├── enums.py # Service, task-method, and save-format enums +└── result_handler.py # Sync/async result polling helpers +``` + +## Local boundaries and invariants + +- This is the shared foundation for every solver. `BaseCaptcha` owns task creation, result polling, retries, sessions, and file/base64 preparation for both sync and async paths. +- Request and response models use `msgspec`; preserve `MyBaseModel.to_dict()` behavior and the serialized field names expected by the remote APIs. +- `CaptchaOptionsSer.urls_set()` selects the 2Captcha/RuCaptcha create-task endpoints and the DeathByCaptcha-compatible endpoints. Keep service selection and response/error shapes compatible with concrete modules. +- CAPTCHA-specific behavior belongs in the leaf solver modules, not in `base.py` or another shared model. + +## Safe change rules + +Treat changes to `base.py`, `serializer.py`, `config.py`, `enums.py`, or `result_handler.py` as cross-package API changes. Check all affected solver modules and update shared/core tests plus representative solver tests; do not replace msgspec with another model system without an explicit repository-wide migration. + +## Validation + +Start with `pytest tests/test_core.py` for foundation changes and add targeted solver tests for changed payload or handler behavior. Run `make lint` before handoff; live integration requirements are defined by the root and `tests/AGENTS.md`. diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 83dd84dd..4f43ec1d 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -1,41 +1,32 @@ -# tests Package +# AGENTS.md -**Parent:** ./AGENTS.md +## Scope and inheritance -## OVERVIEW -Pytest test suite with 23+ test files covering all CAPTCHA types. +Applies to: `tests/` and its descendants. -## STRUCTURE -``` +Inherits repository-wide guidance from `../AGENTS.md`. + +This file defines only local differences for the test subtree. + +## What lives here + +```text tests/ -├── __init__.py -├── conftest.py # Fixtures + BaseTest class -├── test_core.py # BaseCaptcha tests -├── test_hcaptcha.py # hCaptcha tests -├── test_recaptcha.py # reCaptcha tests -├── test_turnstile.py # Turnstile tests -├── ... # 20+ more test files -└── test_image.py # Image captcha tests +├── conftest.py # Environment-dependent fixtures and BaseTest classes +├── test_core.py # Shared base/config/enum behavior +└── test_.py # Coverage organized by solver module ``` -## WHERE TO LOOK -| Task | File | -|------|------| -| Add new test | Create `test_{captcha}.py` | -| Test fixtures | `conftest.py` | - -## CONVENTIONS -- **File naming**: `test_*.py` pattern -- **Test class**: Extend `BaseTest` from `conftest.py` -- **Run**: `make tests` or `pytest tests/` - -## TEST REQUIREMENTS -- Requires `RUCAPTCHA_KEY` environment variable -- Optional: `DEATHBYCAPTCHA_KEY` for DeathByCaptcha tests -- Tests make real API calls to captcha services - -## FIXTURES (conftest.py) -- `delay_func`: 0.5s sleep (function scope) -- `delay_class`: 3s sleep (class scope) -- `BaseTest`: Base test class with required env var check -- `DeathByTest`: Subclass with optional DEATHBYCAPTCHA_KEY +## Local boundaries and invariants + +- Tests exercise the real service-facing library. `conftest.py` reads `RUCAPTCHA_KEY` during test class definition, and DeathByCaptcha-specific tests use `DEATHBYCAPTCHA_KEY` when available. +- `delay_func` and `delay_class` intentionally throttle cases; do not remove or shorten them just to make the suite look like an offline unit suite. +- New solver coverage belongs in a matching `test_.py` module. Reuse `BaseTest` for the shared random-string helper and credential setup; use `DeathByTest` for DeathByCaptcha-specific coverage. + +## Safe change rules + +When changing a payload, enum, or shared handler, update `test_core.py` or the affected solver test rather than relying only on a live smoke test. Avoid placing real API keys, service responses, or other credentials in fixtures and assertions. + +## Validation + +For iteration, run a focused module such as `pytest tests/test_hcaptcha.py`. The repository-level `make tests` command installs the package, runs pytest with coverage, and generates coverage artifacts; it requires the environment variables described above. From 350360e60b58788c270b7a76aaaa8bba4968f7ed Mon Sep 17 00:00:00 2001 From: AndreiDrang Date: Sat, 11 Jul 2026 23:47:52 +0300 Subject: [PATCH 11/16] 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 12/16] 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 13/16] 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" From 7291c0d4f91f864a9c531a119f61191e17a6866c Mon Sep 17 00:00:00 2001 From: AndreiDrang Date: Sun, 12 Jul 2026 00:28:02 +0300 Subject: [PATCH 14/16] feat(captcha): add TSPD, Basilisk, Alibaba, and Imperva/Incapsula captcha solvers Add four new CAPTCHA solving methods based on 2Captcha API specifications: - TSPD: cookie-based protection requiring static proxy session - Basilisk: token-based challenge with proxyless and proxy options - Alibaba: token-based with sceneId, prefix, and optional dynamic parameters - Imperva/Incapsula: cookie-based protection with script URL and cookies New modules: - src/python_rucaptcha/tspd_captcha.py - src/python_rucaptcha/basilisk_captcha.py - src/python_rucaptcha/alibaba_captcha.py - src/python_rucaptcha/incapsula_captcha.py (with ImpervaCaptcha alias) New enums in core/enums.py: - TSPDEnm, BasiliskEnm, AlibabaEnm, IncapsulaEnm Includes unit tests, documentation, and README updates. Co-authored-by: pi --- README.md | 4 + docs/conf.py | 4 + docs/index.rst | 4 + docs/modules/alibaba-captcha/example.rst | 21 +++++ docs/modules/basilisk-captcha/example.rst | 21 +++++ docs/modules/enum/info.rst | 12 +++ docs/modules/incapsula-captcha/example.rst | 21 +++++ docs/modules/tspd-captcha/example.rst | 23 ++++++ src/python_rucaptcha/alibaba_captcha.py | 94 ++++++++++++++++++++++ src/python_rucaptcha/basilisk_captcha.py | 72 +++++++++++++++++ src/python_rucaptcha/core/enums.py | 18 +++++ src/python_rucaptcha/incapsula_captcha.py | 75 +++++++++++++++++ src/python_rucaptcha/tspd_captcha.py | 73 +++++++++++++++++ tests/test_alibaba.py | 65 +++++++++++++++ tests/test_basilisk.py | 55 +++++++++++++ tests/test_incapsula.py | 62 ++++++++++++++ tests/test_tspd.py | 60 ++++++++++++++ 17 files changed, 684 insertions(+) create mode 100644 docs/modules/alibaba-captcha/example.rst create mode 100644 docs/modules/basilisk-captcha/example.rst create mode 100644 docs/modules/incapsula-captcha/example.rst create mode 100644 docs/modules/tspd-captcha/example.rst create mode 100644 src/python_rucaptcha/alibaba_captcha.py create mode 100644 src/python_rucaptcha/basilisk_captcha.py create mode 100644 src/python_rucaptcha/incapsula_captcha.py create mode 100644 src/python_rucaptcha/tspd_captcha.py create mode 100644 tests/test_alibaba.py create mode 100644 tests/test_basilisk.py create mode 100644 tests/test_incapsula.py create mode 100644 tests/test_tspd.py diff --git a/README.md b/README.md index 39c3600b..fc641184 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,10 @@ token = asyncio.run(solve()) | KeyCaptcha | `KeyCaptcha` | KeyCAPTCHA service | | Amazon WAF | `AmazonWaf` | AWS WAF challenge | | ALTCHA | `AltchaCaptcha` | ALTCHA challenge | +| TSPD | `TSPDCaptcha` | Cookie-based TSPD protection | +| Basilisk | `BasiliskCaptcha` | Token-based Basilisk challenge | +| Alibaba | `AlibabaCaptcha` | Token-based Alibaba challenge | +| Imperva/Incapsula | `IncapsulaCaptcha` | Cookie-based Imperva protection | | Binance | `BinanceCaptcha` | Token-based Binance challenge | | Grid | `GridCaptcha` | Select grid cells | | Coordinates | `CoordinatesCaptcha` | Click on coordinates | diff --git a/docs/conf.py b/docs/conf.py index 38108420..cb605b96 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,13 +23,17 @@ grid_captcha, temu_captcha, text_captcha, + tspd_captcha, image_captcha, lemin_captcha, yidun_captcha, rotate_captcha, + alibaba_captcha, binance_captcha, + basilisk_captcha, datadome_captcha, friendly_captcha, + incapsula_captcha, cyber_siara_captcha, draw_around_captcha, bounding_box_captcha, diff --git a/docs/index.rst b/docs/index.rst index 078d88f4..49caab4e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -59,6 +59,10 @@ Check our other projects here - `RedPandaDev group dict: + """Synchronously submit and poll the Alibaba task.""" + return self._processing_response(**kwargs) + + async def aio_captcha_handler(self) -> dict: + """Asynchronously submit and poll the Alibaba task.""" + return await self._aio_processing_response() diff --git a/src/python_rucaptcha/basilisk_captcha.py b/src/python_rucaptcha/basilisk_captcha.py new file mode 100644 index 00000000..9dc9b974 --- /dev/null +++ b/src/python_rucaptcha/basilisk_captcha.py @@ -0,0 +1,72 @@ +from typing import Union, Optional + +from .core.base import BaseCaptcha +from .core.enums import BasiliskEnm + + +class BasiliskCaptcha(BaseCaptcha): + """Solve Basilisk captcha with a proxyless or customer-provided proxy task.""" + + def __init__( + self, + websiteURL: str, + websiteKey: str, + method: Union[str, BasiliskEnm] = BasiliskEnm.BasiliskTaskProxyless, + userAgent: Optional[str] = None, + proxyType: Optional[str] = None, + proxyAddress: Optional[str] = None, + proxyPort: Optional[int] = None, + proxyLogin: Optional[str] = None, + proxyPassword: Optional[str] = None, + *args, + **kwargs, + ): + """ + Args: + rucaptcha_key: User API key. + websiteURL: Full URL of the page where Basilisk is loaded. + websiteKey: The page's Basilisk data-sitekey value. + method: ``BasiliskTaskProxyless`` or ``BasiliskTask``. + userAgent: Optional browser User-Agent. + proxyType: Proxy type for ``BasiliskTask``. + proxyAddress: Proxy IP address or hostname. + proxyPort: Proxy port. + proxyLogin: Optional proxy login. + proxyPassword: Optional proxy password. + kwargs: Additional task parameters. + """ + super().__init__(method=method, *args, **kwargs) + + if method not in BasiliskEnm.list_values(): + raise ValueError(f"Invalid method parameter set, available - {BasiliskEnm.list_values()}") + + task_data = {"websiteURL": websiteURL, "websiteKey": websiteKey} + if userAgent is not None: + task_data["userAgent"] = userAgent + + if method == BasiliskEnm.BasiliskTask.value: + if not all([proxyType, proxyAddress, proxyPort]): + raise ValueError( + "Proxy parameters (proxyType, proxyAddress, proxyPort) are required for BasiliskTask" + ) + task_data.update( + { + "proxyType": proxyType, + "proxyAddress": proxyAddress, + "proxyPort": proxyPort, + } + ) + if proxyLogin is not None: + task_data["proxyLogin"] = proxyLogin + if proxyPassword is not None: + task_data["proxyPassword"] = proxyPassword + + self.create_task_payload["task"].update(task_data) + + def captcha_handler(self, **kwargs) -> dict: + """Synchronously submit and poll the Basilisk task.""" + return self._processing_response(**kwargs) + + async def aio_captcha_handler(self) -> dict: + """Asynchronously submit and poll the Basilisk task.""" + return await self._aio_processing_response() diff --git a/src/python_rucaptcha/core/enums.py b/src/python_rucaptcha/core/enums.py index ca9c884f..caee2739 100644 --- a/src/python_rucaptcha/core/enums.py +++ b/src/python_rucaptcha/core/enums.py @@ -191,6 +191,24 @@ class BinanceCaptchaEnm(str, MyEnum): BinanceTask = "BinanceTask" +class TSPDEnm(str, MyEnum): + TspdTask = "tspdtask" + + +class BasiliskEnm(str, MyEnum): + BasiliskTaskProxyless = "BasiliskTaskProxyless" + BasiliskTask = "BasiliskTask" + + +class AlibabaEnm(str, MyEnum): + AlibabaTaskProxyless = "AlibabaTaskProxyless" + AlibabaTask = "AlibabaTask" + + +class IncapsulaEnm(str, MyEnum): + IncapsulaTask = "IncapsulaTask" + + class YidunEnm(str, MyEnum): YidunTaskProxyless = "YidunTaskProxyless" YidunTask = "YidunTask" diff --git a/src/python_rucaptcha/incapsula_captcha.py b/src/python_rucaptcha/incapsula_captcha.py new file mode 100644 index 00000000..f7a5637e --- /dev/null +++ b/src/python_rucaptcha/incapsula_captcha.py @@ -0,0 +1,75 @@ +from typing import Union, Optional + +from .core.base import BaseCaptcha +from .core.enums import IncapsulaEnm + + +class IncapsulaCaptcha(BaseCaptcha): + """Solve the cookie-based Imperva (Incapsula) captcha.""" + + def __init__( + self, + websiteURL: str, + incapsulaScriptUrl: str, + incapsulaCookies: str, + proxyType: str, + proxyAddress: str, + proxyPort: int, + method: Union[str, IncapsulaEnm] = IncapsulaEnm.IncapsulaTask, + userAgent: Optional[str] = None, + reese84UrlEndpoint: Optional[str] = None, + proxyLogin: Optional[str] = None, + proxyPassword: Optional[str] = None, + *args, + **kwargs, + ): + """ + Args: + rucaptcha_key: User API key. + websiteURL: Full URL of the page where Incapsula is loaded. + incapsulaScriptUrl: Incapsula JavaScript resource URL or name. + incapsulaCookies: Cookies received from the Incapsula challenge page. + proxyType: Proxy type: ``http``, ``socks4`` or ``socks5``. + proxyAddress: Proxy IP address or hostname. + proxyPort: Proxy port. + method: Captcha task type. Only ``IncapsulaTask`` is supported. + userAgent: Optional browser User-Agent. + reese84UrlEndpoint: Optional Reese84 fingerprint endpoint. + proxyLogin: Optional proxy login. + proxyPassword: Optional proxy password. + kwargs: Additional task parameters. + """ + super().__init__(method=method, *args, **kwargs) + + if method not in IncapsulaEnm.list_values(): + raise ValueError(f"Invalid method parameter set, available - {IncapsulaEnm.list_values()}") + + task_data = { + "websiteURL": websiteURL, + "incapsulaScriptUrl": incapsulaScriptUrl, + "incapsulaCookies": incapsulaCookies, + "proxyType": proxyType, + "proxyAddress": proxyAddress, + "proxyPort": proxyPort, + } + optional_data = { + "userAgent": userAgent, + "reese84UrlEndpoint": reese84UrlEndpoint, + "proxyLogin": proxyLogin, + "proxyPassword": proxyPassword, + } + task_data.update({key: value for key, value in optional_data.items() if value is not None}) + self.create_task_payload["task"].update(task_data) + + def captcha_handler(self, **kwargs) -> dict: + """Synchronously submit and poll the Incapsula task.""" + return self._processing_response(**kwargs) + + async def aio_captcha_handler(self) -> dict: + """Asynchronously submit and poll the Incapsula task.""" + return await self._aio_processing_response() + + +# Imperva is the product name; keep an intuitive alias alongside the API name. +ImpervaCaptcha = IncapsulaCaptcha +ImpervaEnm = IncapsulaEnm diff --git a/src/python_rucaptcha/tspd_captcha.py b/src/python_rucaptcha/tspd_captcha.py new file mode 100644 index 00000000..4036d4d2 --- /dev/null +++ b/src/python_rucaptcha/tspd_captcha.py @@ -0,0 +1,73 @@ +from typing import Union, Optional + +from .core.base import BaseCaptcha +from .core.enums import TSPDEnm + + +class TSPDCaptcha(BaseCaptcha): + """Solve the cookie-based TSPD captcha using a static proxy session.""" + + def __init__( + self, + websiteURL: str, + tspdcookie: str, + htmlPageBase64: str, + proxyType: str, + proxyAddress: str, + proxyPort: int, + method: Union[str, TSPDEnm] = TSPDEnm.TspdTask, + proxyLogin: Optional[str] = None, + proxyPassword: Optional[str] = None, + userAgent: Optional[str] = None, + *args, + **kwargs, + ): + """ + Args: + rucaptcha_key: User API key. + websiteURL: Full URL of the page where TSPD is loaded. + tspdcookie: Cookies received from the TSPD challenge page. + htmlPageBase64: Full challenge-page HTML encoded as Base64. + proxyType: Proxy type: ``http``, ``socks4`` or ``socks5``. + proxyAddress: Proxy IP address or hostname. + proxyPort: Proxy port. + method: Captcha task type. Only ``tspdtask`` is supported. + proxyLogin: Optional proxy login. + proxyPassword: Optional proxy password. + userAgent: Browser User-Agent used to load the page. + kwargs: Additional task parameters. + + Notes: + The proxy must use a static session and the same outbound IP must be + maintained while obtaining cookies, solving the task, and using the + returned cookies. + """ + super().__init__(method=method, *args, **kwargs) + + if method not in TSPDEnm.list_values(): + raise ValueError(f"Invalid method parameter set, available - {TSPDEnm.list_values()}") + + task_data = { + "websiteURL": websiteURL, + "tspdcookie": tspdcookie, + "htmlPageBase64": htmlPageBase64, + "proxyType": proxyType, + "proxyAddress": proxyAddress, + "proxyPort": proxyPort, + } + if proxyLogin is not None: + task_data["proxyLogin"] = proxyLogin + if proxyPassword is not None: + task_data["proxyPassword"] = proxyPassword + if userAgent is not None: + task_data["userAgent"] = userAgent + + self.create_task_payload["task"].update(task_data) + + def captcha_handler(self, **kwargs) -> dict: + """Synchronously submit and poll the TSPD task.""" + return self._processing_response(**kwargs) + + async def aio_captcha_handler(self) -> dict: + """Asynchronously submit and poll the TSPD task.""" + return await self._aio_processing_response() diff --git a/tests/test_alibaba.py b/tests/test_alibaba.py new file mode 100644 index 00000000..bbb6929d --- /dev/null +++ b/tests/test_alibaba.py @@ -0,0 +1,65 @@ +import pytest + +from python_rucaptcha.core.enums import AlibabaEnm +from python_rucaptcha.alibaba_captcha import AlibabaCaptcha + +KEY = "test-key" + + +class TestAlibabaCaptcha: + def test_methods_exist(self): + assert "captcha_handler" in AlibabaCaptcha.__dict__ + assert "aio_captcha_handler" in AlibabaCaptcha.__dict__ + + @pytest.mark.parametrize("method", AlibabaEnm.list_values()) + def test_payload(self, method): + proxy = {} + if method == AlibabaEnm.AlibabaTask.value: + proxy = {"proxyType": "socks5", "proxyAddress": "203.0.113.10", "proxyPort": 1080} + + instance = AlibabaCaptcha( + rucaptcha_key=KEY, + websiteURL="https://example.com", + sceneId="scene-123", + prefix="captcha-prefix", + method=method, + userId="user-123", + userUserId="secondary-user", + verifyType="verify", + region="cn", + userCertifyId="certify-123", + apiGetLib="https://example.com/captcha.js", + userAgent="Mozilla/5.0", + **proxy, + ) + task = instance.create_task_payload["task"] + + assert task["type"] == method + assert task["sceneId"] == "scene-123" + assert task["prefix"] == "captcha-prefix" + assert task["userCertifyId"] == "certify-123" + assert task["apiGetLib"] == "https://example.com/captcha.js" + if method == AlibabaEnm.AlibabaTask.value: + assert task["proxyPort"] == 1080 + else: + assert "proxyPort" not in task + + def test_proxy_required(self): + with pytest.raises(ValueError, match="proxyType|proxyAddress|proxyPort"): + AlibabaCaptcha( + rucaptcha_key=KEY, + websiteURL="https://example.com", + sceneId="scene-123", + prefix="captcha-prefix", + method=AlibabaEnm.AlibabaTask, + ) + + def test_invalid_method(self): + with pytest.raises(ValueError): + AlibabaCaptcha( + rucaptcha_key=KEY, + websiteURL="https://example.com", + sceneId="scene-123", + prefix="captcha-prefix", + method="invalid", + ) diff --git a/tests/test_basilisk.py b/tests/test_basilisk.py new file mode 100644 index 00000000..c23f49c3 --- /dev/null +++ b/tests/test_basilisk.py @@ -0,0 +1,55 @@ +import pytest + +from python_rucaptcha.core.enums import BasiliskEnm +from python_rucaptcha.basilisk_captcha import BasiliskCaptcha + +KEY = "test-key" + + +class TestBasiliskCaptcha: + def test_methods_exist(self): + assert "captcha_handler" in BasiliskCaptcha.__dict__ + assert "aio_captcha_handler" in BasiliskCaptcha.__dict__ + + @pytest.mark.parametrize("method", BasiliskEnm.list_values()) + def test_payload(self, method): + proxy = {} + if method == BasiliskEnm.BasiliskTask.value: + proxy = {"proxyType": "http", "proxyAddress": "203.0.113.10", "proxyPort": 8080} + + instance = BasiliskCaptcha( + rucaptcha_key=KEY, + websiteURL="https://example.com", + websiteKey="site-key", + method=method, + userAgent="Mozilla/5.0", + **proxy, + ) + task = instance.create_task_payload["task"] + + assert task["type"] == method + assert task["websiteURL"] == "https://example.com" + assert task["websiteKey"] == "site-key" + assert task["userAgent"] == "Mozilla/5.0" + if method == BasiliskEnm.BasiliskTask.value: + assert task["proxyAddress"] == "203.0.113.10" + else: + assert "proxyAddress" not in task + + def test_proxy_required(self): + with pytest.raises(ValueError, match="proxyType|proxyAddress|proxyPort"): + BasiliskCaptcha( + rucaptcha_key=KEY, + websiteURL="https://example.com", + websiteKey="site-key", + method=BasiliskEnm.BasiliskTask, + ) + + def test_invalid_method(self): + with pytest.raises(ValueError): + BasiliskCaptcha( + rucaptcha_key=KEY, + websiteURL="https://example.com", + websiteKey="site-key", + method="invalid", + ) diff --git a/tests/test_incapsula.py b/tests/test_incapsula.py new file mode 100644 index 00000000..eb343230 --- /dev/null +++ b/tests/test_incapsula.py @@ -0,0 +1,62 @@ +import pytest + +from python_rucaptcha.core.enums import IncapsulaEnm +from python_rucaptcha.incapsula_captcha import ImpervaCaptcha, IncapsulaCaptcha + +KEY = "test-key" + + +class TestIncapsulaCaptcha: + def test_methods_exist(self): + assert "captcha_handler" in IncapsulaCaptcha.__dict__ + assert "aio_captcha_handler" in IncapsulaCaptcha.__dict__ + + def test_payload(self): + instance = IncapsulaCaptcha( + rucaptcha_key=KEY, + websiteURL="https://example.com", + incapsulaScriptUrl="_Incapsula_Resource?SWJIYLWA=example", + incapsulaCookies="incap_sess_abc=value; visid_incap_abc=value", + proxyType="http", + proxyAddress="203.0.113.10", + proxyPort=8080, + userAgent="Mozilla/5.0", + reese84UrlEndpoint="https://example.com/?d=example.com", + proxyLogin="user", + proxyPassword="password", + ) + task = instance.create_task_payload["task"] + + assert task["type"] == IncapsulaEnm.IncapsulaTask.value + assert task["incapsulaScriptUrl"].startswith("_Incapsula_Resource") + assert task["incapsulaCookies"].startswith("incap_sess_") + assert task["reese84UrlEndpoint"] == "https://example.com/?d=example.com" + assert task["proxyLogin"] == "user" + assert task["proxyPassword"] == "password" + + def test_alias(self): + assert ImpervaCaptcha is IncapsulaCaptcha + + def test_invalid_method(self): + with pytest.raises(ValueError): + IncapsulaCaptcha( + rucaptcha_key=KEY, + websiteURL="https://example.com", + incapsulaScriptUrl="script", + incapsulaCookies="cookies", + proxyType="http", + proxyAddress="203.0.113.10", + proxyPort=8080, + method="invalid", + ) + + def test_required_arguments(self): + with pytest.raises(TypeError): + IncapsulaCaptcha( + rucaptcha_key=KEY, + websiteURL="https://example.com", + incapsulaScriptUrl="script", + incapsulaCookies="cookies", + proxyType="http", + proxyAddress="203.0.113.10", + ) diff --git a/tests/test_tspd.py b/tests/test_tspd.py new file mode 100644 index 00000000..07c62845 --- /dev/null +++ b/tests/test_tspd.py @@ -0,0 +1,60 @@ +import pytest + +from python_rucaptcha.core.enums import TSPDEnm +from python_rucaptcha.tspd_captcha import TSPDCaptcha + +KEY = "test-key" + + +class TestTSPDCaptcha: + def test_methods_exist(self): + assert "captcha_handler" in TSPDCaptcha.__dict__ + assert "aio_captcha_handler" in TSPDCaptcha.__dict__ + + def test_payload(self): + instance = TSPDCaptcha( + rucaptcha_key=KEY, + websiteURL="https://example.com", + tspdcookie="TS386a=challenge-cookie", + htmlPageBase64="PGh0bWw+Y2hhbGxlbmdlPC9odG1sPg==", + proxyType="http", + proxyAddress="203.0.113.10", + proxyPort=8080, + userAgent="Mozilla/5.0", + proxyLogin="user", + proxyPassword="password", + ) + task = instance.create_task_payload["task"] + + assert task["type"] == TSPDEnm.TspdTask.value + assert task["websiteURL"] == "https://example.com" + assert task["tspdcookie"] == "TS386a=challenge-cookie" + assert task["htmlPageBase64"] == "PGh0bWw+Y2hhbGxlbmdlPC9odG1sPg==" + assert task["proxyPort"] == 8080 + assert task["userAgent"] == "Mozilla/5.0" + assert task["proxyLogin"] == "user" + assert task["proxyPassword"] == "password" + + def test_invalid_method(self): + with pytest.raises(ValueError): + TSPDCaptcha( + rucaptcha_key=KEY, + websiteURL="https://example.com", + tspdcookie="cookie", + htmlPageBase64="html", + proxyType="http", + proxyAddress="203.0.113.10", + proxyPort=8080, + method="invalid", + ) + + def test_required_arguments(self): + with pytest.raises(TypeError): + TSPDCaptcha( + rucaptcha_key=KEY, + websiteURL="https://example.com", + tspdcookie="cookie", + htmlPageBase64="html", + proxyType="http", + proxyAddress="203.0.113.10", + ) From 7f9b7e0ab06eb4a561a3e263a4eca19fadaa628c Mon Sep 17 00:00:00 2001 From: AndreiDrang Date: Sun, 12 Jul 2026 00:28:54 +0300 Subject: [PATCH 15/16] chore: update AGENTS.md documentation and test file Update repository and package-level AGENTS.md files with additional clarifications about CaptchaAI integration patterns and data-driven profile validation. Includes minor test file update. Co-authored-by: pi --- AGENTS.md | 5 +++-- src/python_rucaptcha/AGENTS.md | 9 ++++++--- src/python_rucaptcha/core/AGENTS.md | 5 ++++- tests/test_yandex_smart_captcha.py | 6 ++++-- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5c49d204..6ebcb2c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Repository overview -- `python-rucaptcha` is a Python 3.9+ setuptools library for the 2Captcha, RuCaptcha, and DeathByCaptcha APIs. +- `python-rucaptcha` is a Python 3.9+ setuptools library for the 2Captcha, RuCaptcha, DeathByCaptcha, and CaptchaAI APIs. - The package uses a `src/` layout and provides synchronous and asynchronous CAPTCHA solver handlers. - The repository is a single package, not a monorepo. The root `AGENTS.md` applies everywhere unless a nearer local file applies. @@ -16,7 +16,7 @@ ```text src/python_rucaptcha/ # CAPTCHA implementations and package API -└── core/ # Base request flow, service config, serializers, enums +└── core/ # Base request flow, service config, serializers, enums, CaptchaAI profile data tests/ # Pytest suite and shared fixtures docs/ # Sphinx sources and per-CAPTCHA examples pyproject.toml # setuptools, Black, isort, and pytest configuration @@ -30,6 +30,7 @@ Do not hand-edit `dist/` or `src/python_rucaptcha.egg-info/`; they are build/pac - Concrete CAPTCHA modules inherit from `BaseCaptcha` in `src/python_rucaptcha/core/base.py`. Keep CAPTCHA-specific payload preparation in the concrete module, not in the shared base flow. - `core/` owns request/polling behavior, service URL selection, msgspec serialization, result models, and shared enums. Changes there can affect every solver. - Each solver's synchronous and asynchronous handlers must preserve the same task semantics and response shape. Service task field names are part of the external API contract. +- The data-driven `captchaai` client (top-level `captchaai.py` + `core/captchaai.py`) is deliberately separate from the `BaseCaptcha` flow: it validates packaged JSON profiles in `core/data/` instead of branching on task types. Do not refactor it into the `BaseCaptcha` pattern or add per-method branches to it. ## Context routing diff --git a/src/python_rucaptcha/AGENTS.md b/src/python_rucaptcha/AGENTS.md index f97915dd..81f0c021 100644 --- a/src/python_rucaptcha/AGENTS.md +++ b/src/python_rucaptcha/AGENTS.md @@ -12,10 +12,12 @@ This file defines only local differences for this package subtree. ```text src/python_rucaptcha/ -├── *_captcha.py # Concrete CAPTCHA task builders and handlers +├── *_captcha.py # Concrete CAPTCHA task builders and handlers (inherit BaseCaptcha) +├── captchaai.py # Native CaptchaAI client — data-driven, NOT a BaseCaptcha solver ├── control.py # Balance/status-style service operations -├── __init__.py # Package version exposure -└── core/ # Shared request flow and data models +├── __init__.py # Package re-exports +├── __version__.py # Single source of the package version +└── core/ # Shared request flow, serializers, enums, CaptchaAI profile data ``` ## Local boundaries and invariants @@ -23,6 +25,7 @@ src/python_rucaptcha/ - Concrete modules are deliberately flat and inherit from `core.base.BaseCaptcha`. - A solver owns its service task fields, method validation, and sync/async handler entry points; shared transport and polling remain in `core/`. - Keep module names in the existing `*_captcha.py` style and enum names in the `{CaptchaType}Enm` style, including established exceptions such as `re_captcha.py` and `hcaptcha.py`. +- `captchaai.py` is the deliberate exception to the `BaseCaptcha` pattern: it is a data-driven native client that validates against packaged profiles in `core/data/` and passes provider-native params through. Do not merge it into `BaseCaptcha`, and do not add per-method task branches to it. ## Safe change rules diff --git a/src/python_rucaptcha/core/AGENTS.md b/src/python_rucaptcha/core/AGENTS.md index e92f2724..aea05bb5 100644 --- a/src/python_rucaptcha/core/AGENTS.md +++ b/src/python_rucaptcha/core/AGENTS.md @@ -13,10 +13,12 @@ This file defines only local differences for this foundation subtree. ```text core/ ├── base.py # BaseCaptcha sync/async transport, polling, file handling +├── captchaai.py # CaptchaAI classic multipart transport (data-driven, profile-validated) ├── config.py # Retry settings and application/service configuration ├── serializer.py # msgspec Struct request/response models ├── enums.py # Service, task-method, and save-format enums -└── result_handler.py # Sync/async result polling helpers +├── result_handler.py # Sync/async result polling helpers +└── data/ # Packaged CaptchaAI profile JSON (runtime metadata, shipped in the wheel) ``` ## Local boundaries and invariants @@ -25,6 +27,7 @@ core/ - Request and response models use `msgspec`; preserve `MyBaseModel.to_dict()` behavior and the serialized field names expected by the remote APIs. - `CaptchaOptionsSer.urls_set()` selects the 2Captcha/RuCaptcha create-task endpoints and the DeathByCaptcha-compatible endpoints. Keep service selection and response/error shapes compatible with concrete modules. - CAPTCHA-specific behavior belongs in the leaf solver modules, not in `base.py` or another shared model. +- `captchaai.py` loads and validates provider contracts from `data/captchaai_profiles.json` and `data/captchaai_legacy_profiles.json` (cached via `lru_cache`). These JSON files are packaged runtime metadata declared in `pyproject.toml` as `package-data = ["core/data/*.json"]`; renaming, moving, or deleting them also requires updating that declaration so the wheel still ships both files. ## Safe change rules diff --git a/tests/test_yandex_smart_captcha.py b/tests/test_yandex_smart_captcha.py index 412014c3..e85855a0 100644 --- a/tests/test_yandex_smart_captcha.py +++ b/tests/test_yandex_smart_captcha.py @@ -1,7 +1,7 @@ import pytest from tests.conftest import BaseTest -from python_rucaptcha.core.enums import YandexSmartCaptchaEnm, CoordinatesCaptchaEnm, SaveFormatsEnm +from python_rucaptcha.core.enums import CoordinatesCaptchaEnm, YandexSmartCaptchaEnm from python_rucaptcha.core.serializer import GetTaskResultResponseSer from python_rucaptcha.yandex_smart_captcha import YandexSmartCaptcha @@ -26,7 +26,9 @@ def test_methods_exists(self): assert "captcha_handler" in YandexSmartCaptcha.__dict__.keys() assert "aio_captcha_handler" in YandexSmartCaptcha.__dict__.keys() - @pytest.mark.parametrize("method", YandexSmartCaptchaEnm.list_values() + [CoordinatesCaptchaEnm.CoordinatesTask.value]) + @pytest.mark.parametrize( + "method", YandexSmartCaptchaEnm.list_values() + [CoordinatesCaptchaEnm.CoordinatesTask.value] + ) def test_args(self, method: str): kwargs = {} if method == YandexSmartCaptchaEnm.YandexSmartCaptchaTask.value: From 594aefa83c25eb0a2b97692ea092dce74d29efa8 Mon Sep 17 00:00:00 2001 From: AndreiDrang Date: Sun, 12 Jul 2026 00:29:03 +0300 Subject: [PATCH 16/16] v6.8.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 95bb205a..e3e28dc8 100644 --- a/src/python_rucaptcha/__version__.py +++ b/src/python_rucaptcha/__version__.py @@ -1 +1 @@ -__version__ = "6.7.0" +__version__ = "6.8.0"