From ab2d8b1f34af0e82ad6e9b00ca29c96acfb772e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Mar 2026 15:09:44 +0000 Subject: [PATCH 1/8] Add async support with AsyncAnticaptchaClient and AsyncJob Add a new async_client module providing AsyncAnticaptchaClient and AsyncJob classes that mirror the sync API but use httpx.AsyncClient and asyncio.sleep for non-blocking operation in async frameworks (FastAPI, aiohttp, etc.). This is a non-breaking change: the sync API is untouched, httpx remains an optional dependency (pip install python-anticaptcha[async]), and the new classes are lazily imported in __init__.py to avoid requiring httpx at package import time. https://claude.ai/code/session_01Pimg4VAco2v4srPeZj44Zm --- pyproject.toml | 2 +- python_anticaptcha/__init__.py | 13 ++ python_anticaptcha/async_client.py | 228 +++++++++++++++++++++++ tests/test_async_client.py | 290 +++++++++++++++++++++++++++++ 4 files changed, 532 insertions(+), 1 deletion(-) create mode 100644 python_anticaptcha/async_client.py create mode 100644 tests/test_async_client.py diff --git a/pyproject.toml b/pyproject.toml index 94ed9b9..dec7cbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ Homepage = "https://github.com/ad-m/python-anticaptcha" [project.optional-dependencies] async = ["httpx>=0.24"] -tests = ["pytest", "retry", "selenium"] +tests = ["pytest", "pytest-asyncio", "retry", "selenium"] docs = ["sphinx", "sphinx-rtd-theme"] [tool.setuptools.package-data] diff --git a/python_anticaptcha/__init__.py b/python_anticaptcha/__init__.py index 1cb621f..d965416 100644 --- a/python_anticaptcha/__init__.py +++ b/python_anticaptcha/__init__.py @@ -30,6 +30,17 @@ # package is not installed pass + +def __getattr__(name: str): + if name in ("AsyncAnticaptchaClient", "AsyncJob"): + from .async_client import AsyncAnticaptchaClient, AsyncJob + + globals()["AsyncAnticaptchaClient"] = AsyncAnticaptchaClient + globals()["AsyncJob"] = AsyncJob + return globals()[name] + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + __all__ = [ "AnticaptchaClient", "Job", @@ -52,4 +63,6 @@ "AntiGateTask", "AnticaptchaException", "AnticatpchaException", + "AsyncAnticaptchaClient", + "AsyncJob", ] diff --git a/python_anticaptcha/async_client.py b/python_anticaptcha/async_client.py new file mode 100644 index 0000000..f4e879f --- /dev/null +++ b/python_anticaptcha/async_client.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +import asyncio +import os +from types import TracebackType +from typing import Any +from urllib.parse import urljoin + +try: + import httpx +except ImportError: + raise ImportError( + "httpx is required for async support. " + "Install it with: pip install python-anticaptcha[async]" + ) + +from .exceptions import AnticaptchaException +from .tasks import BaseTask +from .base import SLEEP_EVERY_CHECK_FINISHED, MAXIMUM_JOIN_TIME + + +class AsyncJob: + client = None + task_id = None + _last_result = None + + def __init__(self, client: AsyncAnticaptchaClient, task_id: int) -> None: + self.client = client + self.task_id = task_id + + async def _update(self) -> None: + self._last_result = await self.client.getTaskResult(self.task_id) + + async def check_is_ready(self) -> bool: + await self._update() + return self._last_result["status"] == "ready" + + def get_solution_response(self) -> str: # Recaptcha + return self._last_result["solution"]["gRecaptchaResponse"] + + def get_solution(self) -> dict[str, Any]: + return self._last_result["solution"] + + def get_token_response(self) -> str: # Funcaptcha + return self._last_result["solution"]["token"] + + def get_answers(self) -> dict[str, str]: + return self._last_result["solution"]["answers"] + + def get_captcha_text(self) -> str: # Image + return self._last_result["solution"]["text"] + + def get_cells_numbers(self) -> list[int]: + return self._last_result["solution"]["cellNumbers"] + + async def report_incorrect_image(self) -> bool: + return await self.client.reportIncorrectImage(self.task_id) + + async def report_incorrect_recaptcha(self) -> bool: + return await self.client.reportIncorrectRecaptcha(self.task_id) + + def __repr__(self) -> str: + status = self._last_result.get("status") if self._last_result else None + if status: + return f"" + return f"" + + async def join(self, maximum_time: int | None = None, on_check=None) -> None: + """Poll for task completion, sleeping asynchronously until ready or timeout. + + :param maximum_time: Maximum seconds to wait (default: ``MAXIMUM_JOIN_TIME``). + :param on_check: Optional callback invoked after each poll with + ``(elapsed_time, status)`` where *elapsed_time* is the total seconds + waited so far and *status* is the last task status string + (e.g. ``"processing"``). + :raises AnticaptchaException: If *maximum_time* is exceeded. + """ + elapsed_time = 0 + maximum_time = maximum_time or MAXIMUM_JOIN_TIME + while not await self.check_is_ready(): + await asyncio.sleep(SLEEP_EVERY_CHECK_FINISHED) + elapsed_time += SLEEP_EVERY_CHECK_FINISHED + if on_check is not None: + on_check(elapsed_time, self._last_result.get("status")) + if elapsed_time > maximum_time: + raise AnticaptchaException( + None, + 250, + "The execution time exceeded a maximum time of {} seconds. It takes {} seconds.".format( + maximum_time, elapsed_time + ), + ) + + +class AsyncAnticaptchaClient: + client_key = None + CREATE_TASK_URL = "/createTask" + TASK_RESULT_URL = "/getTaskResult" + BALANCE_URL = "/getBalance" + REPORT_IMAGE_URL = "/reportIncorrectImageCaptcha" + REPORT_RECAPTCHA_URL = "/reportIncorrectRecaptcha" + APP_STAT_URL = "/getAppStats" + SOFT_ID = 847 + language_pool = "en" + response_timeout = 5 + + def __init__( + self, client_key: str | None = None, language_pool: str = "en", host: str = "api.anti-captcha.com", use_ssl: bool = True, + ) -> None: + self.client_key = client_key or os.environ.get("ANTICAPTCHA_API_KEY") + if not self.client_key: + raise AnticaptchaException( + None, + "CONFIG_ERROR", + "API key required. Pass client_key or set ANTICAPTCHA_API_KEY env var.", + ) + self.language_pool = language_pool + self.base_url = "{proto}://{host}/".format( + proto="https" if use_ssl else "http", host=host + ) + self.session = httpx.AsyncClient() + + async def __aenter__(self) -> AsyncAnticaptchaClient: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool: + await self.session.aclose() + return False + + async def close(self) -> None: + await self.session.aclose() + + def __repr__(self) -> str: + from urllib.parse import urlparse + host = urlparse(self.base_url).hostname or self.base_url + return f"" + + async def _get_client_ip(self) -> str: + if not hasattr(self, "_client_ip"): + response = await self.session.get( + "https://api.myip.com", timeout=self.response_timeout + ) + self._client_ip = response.json()["ip"] + return self._client_ip + + async def _check_response(self, response: dict[str, Any]) -> None: + if response.get("errorId", False) == 11: + ip = await self._get_client_ip() + response[ + "errorDescription" + ] = "{} Your missing IP address is probably {}.".format( + response["errorDescription"], ip + ) + if response.get("errorId", False): + raise AnticaptchaException( + response["errorId"], response["errorCode"], response["errorDescription"] + ) + + async def createTask(self, task: BaseTask) -> AsyncJob: + request = { + "clientKey": self.client_key, + "task": task.serialize(), + "softId": self.SOFT_ID, + "languagePool": self.language_pool, + } + response = (await self.session.post( + urljoin(self.base_url, self.CREATE_TASK_URL), + json=request, + timeout=self.response_timeout, + )).json() + await self._check_response(response) + return AsyncJob(self, response["taskId"]) + + async def getTaskResult(self, task_id: int) -> dict[str, Any]: + request = {"clientKey": self.client_key, "taskId": task_id} + response = (await self.session.post( + urljoin(self.base_url, self.TASK_RESULT_URL), json=request + )).json() + await self._check_response(response) + return response + + async def getBalance(self) -> float: + request = { + "clientKey": self.client_key, + "softId": self.SOFT_ID, + } + response = (await self.session.post( + urljoin(self.base_url, self.BALANCE_URL), json=request + )).json() + await self._check_response(response) + return response["balance"] + + async def getAppStats(self, soft_id: int, mode: str) -> dict[str, Any]: + request = {"clientKey": self.client_key, "softId": soft_id, "mode": mode} + response = (await self.session.post( + urljoin(self.base_url, self.APP_STAT_URL), json=request + )).json() + await self._check_response(response) + return response + + async def reportIncorrectImage(self, task_id: int) -> bool: + request = {"clientKey": self.client_key, "taskId": task_id} + response = (await self.session.post( + urljoin(self.base_url, self.REPORT_IMAGE_URL), json=request + )).json() + await self._check_response(response) + return response.get("status", False) != False + + async def reportIncorrectRecaptcha(self, task_id: int) -> bool: + request = {"clientKey": self.client_key, "taskId": task_id} + response = (await self.session.post( + urljoin(self.base_url, self.REPORT_RECAPTCHA_URL), json=request + )).json() + await self._check_response(response) + return response["status"] == "success" + + # Snake_case aliases + create_task = createTask + get_task_result = getTaskResult + get_balance = getBalance + get_app_stats = getAppStats + report_incorrect_image = reportIncorrectImage + report_incorrect_recaptcha = reportIncorrectRecaptcha diff --git a/tests/test_async_client.py b/tests/test_async_client.py new file mode 100644 index 0000000..33b0eef --- /dev/null +++ b/tests/test_async_client.py @@ -0,0 +1,290 @@ +from unittest.mock import patch, MagicMock, AsyncMock +import pytest + +from python_anticaptcha.async_client import ( + AsyncAnticaptchaClient, + AsyncJob, + SLEEP_EVERY_CHECK_FINISHED, +) +from python_anticaptcha.exceptions import AnticaptchaException + + +class TestAsyncAnticaptchaClientInit: + def test_https_url(self): + client = AsyncAnticaptchaClient("key123") + assert client.base_url == "https://api.anti-captcha.com/" + assert client.client_key == "key123" + + def test_http_url(self): + client = AsyncAnticaptchaClient("key123", use_ssl=False) + assert client.base_url == "http://api.anti-captcha.com/" + + def test_custom_host(self): + client = AsyncAnticaptchaClient("key123", host="custom.host.com") + assert client.base_url == "https://custom.host.com/" + + def test_language_pool(self): + client = AsyncAnticaptchaClient("key123", language_pool="rn") + assert client.language_pool == "rn" + + def test_env_var_fallback(self, monkeypatch): + monkeypatch.setenv("ANTICAPTCHA_API_KEY", "env-key-456") + client = AsyncAnticaptchaClient() + assert client.client_key == "env-key-456" + + def test_explicit_key_over_env(self, monkeypatch): + monkeypatch.setenv("ANTICAPTCHA_API_KEY", "env-key-456") + client = AsyncAnticaptchaClient("explicit-key-789") + assert client.client_key == "explicit-key-789" + + def test_no_key_raises(self, monkeypatch): + monkeypatch.delenv("ANTICAPTCHA_API_KEY", raising=False) + with pytest.raises(AnticaptchaException) as exc_info: + AsyncAnticaptchaClient() + assert exc_info.value.error_code == "CONFIG_ERROR" + + +class TestAsyncCheckResponse: + def setup_method(self): + self.client = AsyncAnticaptchaClient("key123") + + @pytest.mark.asyncio + async def test_success_passthrough(self): + response = {"errorId": 0, "taskId": 42} + await self.client._check_response(response) + + @pytest.mark.asyncio + async def test_error_raises(self): + response = { + "errorId": 1, + "errorCode": "ERROR_KEY_DOES_NOT_EXIST", + "errorDescription": "Account authorization key not found", + } + with pytest.raises(AnticaptchaException) as exc_info: + await self.client._check_response(response) + assert exc_info.value.error_id == 1 + assert exc_info.value.error_code == "ERROR_KEY_DOES_NOT_EXIST" + + @pytest.mark.asyncio + async def test_error_id_11_appends_ip(self): + response = { + "errorId": 11, + "errorCode": "ERROR_IP_NOT_ALLOWED", + "errorDescription": "IP not allowed", + } + self.client._get_client_ip = AsyncMock(return_value="5.6.7.8") + with pytest.raises(AnticaptchaException) as exc_info: + await self.client._check_response(response) + assert "5.6.7.8" in exc_info.value.error_description + + +class TestAsyncCreateTask: + @pytest.mark.asyncio + async def test_payload_structure(self): + client = AsyncAnticaptchaClient("key123") + mock_task = MagicMock() + mock_task.serialize.return_value = {"type": "NoCaptchaTaskProxyless", "websiteURL": "https://example.com"} + + mock_response = MagicMock() + mock_response.json.return_value = {"errorId": 0, "taskId": 99} + + client.session.post = AsyncMock(return_value=mock_response) + job = await client.createTask(mock_task) + + call_kwargs = client.session.post.call_args + payload = call_kwargs[1]["json"] if "json" in call_kwargs[1] else call_kwargs.kwargs["json"] + assert payload["clientKey"] == "key123" + assert payload["task"] == {"type": "NoCaptchaTaskProxyless", "websiteURL": "https://example.com"} + assert payload["softId"] == 847 + assert payload["languagePool"] == "en" + assert isinstance(job, AsyncJob) + assert job.task_id == 99 + + +class TestAsyncGetBalance: + @pytest.mark.asyncio + async def test_returns_balance(self): + client = AsyncAnticaptchaClient("key123") + mock_response = MagicMock() + mock_response.json.return_value = {"errorId": 0, "balance": 3.21} + + client.session.post = AsyncMock(return_value=mock_response) + balance = await client.getBalance() + assert balance == 3.21 + + +class TestAsyncJobCheckIsReady: + @pytest.mark.asyncio + async def test_ready(self): + client = MagicMock() + client.getTaskResult = AsyncMock(return_value={"status": "ready", "solution": {}}) + job = AsyncJob(client, task_id=1) + assert await job.check_is_ready() is True + + @pytest.mark.asyncio + async def test_processing(self): + client = MagicMock() + client.getTaskResult = AsyncMock(return_value={"status": "processing"}) + job = AsyncJob(client, task_id=1) + assert await job.check_is_ready() is False + + +class TestAsyncJobSolutionGetters: + def setup_method(self): + self.client = MagicMock() + self.job = AsyncJob(self.client, task_id=1) + self.job._last_result = { + "status": "ready", + "solution": { + "gRecaptchaResponse": "recaptcha-token", + "token": "funcaptcha-token", + "text": "captcha text", + "cellNumbers": [1, 3, 5], + "answers": {"q1": "a1"}, + }, + } + + def test_get_solution_response(self): + assert self.job.get_solution_response() == "recaptcha-token" + + def test_get_token_response(self): + assert self.job.get_token_response() == "funcaptcha-token" + + def test_get_captcha_text(self): + assert self.job.get_captcha_text() == "captcha text" + + def test_get_cells_numbers(self): + assert self.job.get_cells_numbers() == [1, 3, 5] + + def test_get_answers(self): + assert self.job.get_answers() == {"q1": "a1"} + + def test_get_solution(self): + assert self.job.get_solution() == self.job._last_result["solution"] + + +class TestAsyncJobJoinTimeout: + @pytest.mark.asyncio + @patch("python_anticaptcha.async_client.asyncio.sleep", new_callable=AsyncMock) + async def test_timeout_raises(self, mock_sleep): + client = MagicMock() + client.getTaskResult = AsyncMock(return_value={"status": "processing"}) + job = AsyncJob(client, task_id=1) + with pytest.raises(AnticaptchaException) as exc_info: + await job.join(maximum_time=SLEEP_EVERY_CHECK_FINISHED) + assert "exceeded" in str(exc_info.value).lower() + + +class TestAsyncJobJoinOnCheck: + @pytest.mark.asyncio + @patch("python_anticaptcha.async_client.asyncio.sleep", new_callable=AsyncMock) + async def test_on_check_called_each_iteration(self, mock_sleep): + client = MagicMock() + client.getTaskResult = AsyncMock(side_effect=[ + {"status": "processing"}, + {"status": "processing"}, + {"status": "ready", "solution": {}}, + ]) + job = AsyncJob(client, task_id=1) + callback = MagicMock() + await job.join(on_check=callback) + assert callback.call_count == 2 + callback.assert_any_call(SLEEP_EVERY_CHECK_FINISHED, "processing") + callback.assert_any_call(SLEEP_EVERY_CHECK_FINISHED * 2, "processing") + + @pytest.mark.asyncio + @patch("python_anticaptcha.async_client.asyncio.sleep", new_callable=AsyncMock) + async def test_on_check_none_by_default(self, mock_sleep): + client = MagicMock() + client.getTaskResult = AsyncMock(return_value={"status": "ready", "solution": {}}) + job = AsyncJob(client, task_id=1) + await job.join() + + @pytest.mark.asyncio + @patch("python_anticaptcha.async_client.asyncio.sleep", new_callable=AsyncMock) + async def test_on_check_not_called_when_immediately_ready(self, mock_sleep): + client = MagicMock() + client.getTaskResult = AsyncMock(return_value={"status": "ready", "solution": {}}) + job = AsyncJob(client, task_id=1) + callback = MagicMock() + await job.join(on_check=callback) + callback.assert_not_called() + + +class TestAsyncContextManager: + @pytest.mark.asyncio + async def test_aenter_returns_self(self): + client = AsyncAnticaptchaClient("key123") + assert await client.__aenter__() is client + + @pytest.mark.asyncio + async def test_aexit_closes_session(self): + client = AsyncAnticaptchaClient("key123") + client.session.aclose = AsyncMock() + await client.__aexit__(None, None, None) + client.session.aclose.assert_called_once() + + @pytest.mark.asyncio + async def test_close_closes_session(self): + client = AsyncAnticaptchaClient("key123") + client.session.aclose = AsyncMock() + await client.close() + client.session.aclose.assert_called_once() + + @pytest.mark.asyncio + async def test_async_with_statement(self): + async with AsyncAnticaptchaClient("key123") as client: + assert isinstance(client, AsyncAnticaptchaClient) + + @pytest.mark.asyncio + async def test_aexit_returns_false(self): + client = AsyncAnticaptchaClient("key123") + client.session.aclose = AsyncMock() + result = await client.__aexit__(None, None, None) + assert result is False + + +class TestAsyncSnakeCaseAliases: + def test_create_task_alias(self): + assert AsyncAnticaptchaClient.create_task is AsyncAnticaptchaClient.createTask + + def test_get_task_result_alias(self): + assert AsyncAnticaptchaClient.get_task_result is AsyncAnticaptchaClient.getTaskResult + + def test_get_balance_alias(self): + assert AsyncAnticaptchaClient.get_balance is AsyncAnticaptchaClient.getBalance + + def test_get_app_stats_alias(self): + assert AsyncAnticaptchaClient.get_app_stats is AsyncAnticaptchaClient.getAppStats + + def test_report_incorrect_image_alias(self): + assert AsyncAnticaptchaClient.report_incorrect_image is AsyncAnticaptchaClient.reportIncorrectImage + + def test_report_incorrect_recaptcha_alias(self): + assert AsyncAnticaptchaClient.report_incorrect_recaptcha is AsyncAnticaptchaClient.reportIncorrectRecaptcha + + @pytest.mark.asyncio + async def test_alias_works_on_instance(self): + client = AsyncAnticaptchaClient("key123") + mock_response = MagicMock() + mock_response.json.return_value = {"errorId": 0, "balance": 5.0} + client.session.post = AsyncMock(return_value=mock_response) + balance = await client.get_balance() + assert balance == 5.0 + + +class TestAsyncRepr: + def test_client_repr(self): + client = AsyncAnticaptchaClient("key123") + assert repr(client) == "" + + def test_job_repr_no_result(self): + client = MagicMock() + job = AsyncJob(client, task_id=42) + assert repr(job) == "" + + def test_job_repr_with_status(self): + client = MagicMock() + job = AsyncJob(client, task_id=42) + job._last_result = {"status": "ready"} + assert repr(job) == "" From df32de08d9fbda36097ec70ff0f6897bc7466929 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Mar 2026 15:23:28 +0000 Subject: [PATCH 2/8] Extract shared mixins, add documentation and examples for async support - Extract _BaseClientMixin and _BaseJobMixin into _common.py to share init logic, payload builders, solution getters, and repr between sync and async clients - Refactor AnticaptchaClient and AsyncAnticaptchaClient to inherit from shared mixins, reducing maintenance burden - Add "Async Usage" section to README.md and docs/usage.rst - Add async_client automodule to docs/api.rst - Add CHANGELOG.rst entries for async support - Add async example scripts (async_recaptcha_request.py, async_balance.py) - Add "Framework :: AsyncIO" and "Topic :: Internet :: WWW/HTTP" classifiers - Add "asyncio", "async", "httpx" keywords for PyPI discoverability https://claude.ai/code/session_01Pimg4VAco2v4srPeZj44Zm --- CHANGELOG.rst | 2 + README.md | 27 ++++++++ docs/api.rst | 7 +++ docs/usage.rst | 26 ++++++++ examples/async_balance.py | 17 +++++ examples/async_recaptcha_request.py | 40 ++++++++++++ pyproject.toml | 4 +- python_anticaptcha/_common.py | 98 +++++++++++++++++++++++++++++ python_anticaptcha/async_client.py | 84 +++++-------------------- python_anticaptcha/base.py | 87 ++++++------------------- 10 files changed, 255 insertions(+), 137 deletions(-) create mode 100644 examples/async_balance.py create mode 100644 examples/async_recaptcha_request.py create mode 100644 python_anticaptcha/_common.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 1d4365d..e59c398 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,6 +7,8 @@ Unreleased Added ##### +- Add ``AsyncAnticaptchaClient`` and ``AsyncJob`` for async/await usage with ``httpx`` (``pip install python-anticaptcha[async]``) +- Add shared ``_BaseClientMixin`` and ``_BaseJobMixin`` to reduce duplication between sync and async clients - Add context manager support to ``AnticaptchaClient`` (``__enter__``, ``__exit__``, ``close``) - Add ``ANTICAPTCHA_API_KEY`` environment variable fallback for ``AnticaptchaClient`` - Add ``Proxy`` frozen dataclass with ``parse_url()`` and ``to_kwargs()`` methods diff --git a/README.md b/README.md index b91cb75..b74ad22 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,12 @@ Install as standard Python package using: pip install python-anticaptcha ``` +For async support (FastAPI, aiohttp, Starlette, etc.): + +``` +pip install python-anticaptcha[async] +``` + ## Usage To use this library you need [Anticaptcha.com](http://getcaptchasolution.com/p9bwplkicx) API key. @@ -42,6 +48,27 @@ with AnticaptchaClient(api_key) as client: job.join() ``` +### Async Usage + +For async frameworks, use `AsyncAnticaptchaClient` — the API mirrors the sync +client but all methods are awaitable: + +```python +from python_anticaptcha import AsyncAnticaptchaClient, NoCaptchaTaskProxylessTask + +api_key = '174faff8fbc769e94a5862391ecfd010' +site_key = '6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-' +url = 'https://www.google.com/recaptcha/api2/demo' + +async with AsyncAnticaptchaClient(api_key) as client: + task = NoCaptchaTaskProxylessTask(url, site_key) + job = await client.create_task(task) + await job.join() + print(job.get_solution_response()) +``` + +The full integration example is available in file `examples/async_recaptcha_request.py`. + ### Solve recaptcha Example snippet for Recaptcha: diff --git a/docs/api.rst b/docs/api.rst index bee2fd0..a00238e 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -8,6 +8,13 @@ Base :members: :undoc-members: +Async Client +------------ + + .. automodule:: python_anticaptcha.async_client + :members: + :undoc-members: + Exceptions ---------- diff --git a/docs/usage.rst b/docs/usage.rst index 633b1f1..50fa43e 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -21,6 +21,32 @@ The client can be used as a context manager to ensure the underlying session is job = client.create_task(task) job.join() +Async client +############ + +For async frameworks (FastAPI, aiohttp, Starlette), install with async support:: + + pip install python-anticaptcha[async] + +Then use ``AsyncAnticaptchaClient`` — the API mirrors the sync client but all +methods are awaitable: + +.. code:: python + + from python_anticaptcha import AsyncAnticaptchaClient, NoCaptchaTaskProxylessTask + + api_key = '174faff8fbc769e94a5862391ecfd010' + site_key = '6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-' + url = 'https://www.google.com/recaptcha/api2/demo' + + async with AsyncAnticaptchaClient(api_key) as client: + task = NoCaptchaTaskProxylessTask(url, site_key) + job = await client.create_task(task) + await job.join() + print(job.get_solution_response()) + +The full integration example is available in file ``examples/async_recaptcha_request.py``. + Solve recaptcha ############### diff --git a/examples/async_balance.py b/examples/async_balance.py new file mode 100644 index 0000000..49d1d85 --- /dev/null +++ b/examples/async_balance.py @@ -0,0 +1,17 @@ +import asyncio +from os import environ +from pprint import pprint + +from python_anticaptcha import AsyncAnticaptchaClient + +api_key = environ["KEY"] + + +async def process(): + async with AsyncAnticaptchaClient(api_key) as client: + balance = await client.get_balance() + pprint(balance) + + +if __name__ == "__main__": + asyncio.run(process()) diff --git a/examples/async_recaptcha_request.py b/examples/async_recaptcha_request.py new file mode 100644 index 0000000..b3dcaa1 --- /dev/null +++ b/examples/async_recaptcha_request.py @@ -0,0 +1,40 @@ +import asyncio +import re +from os import environ + +import httpx + +from python_anticaptcha import AsyncAnticaptchaClient, NoCaptchaTaskProxylessTask + +api_key = environ["KEY"] +site_key_pattern = 'data-sitekey="(.+?)"' +url = "https://www.google.com/recaptcha/api2/demo?invisible=false" +EXPECTED_RESULT = "Verification Success... Hooray!" + + +async def get_form_html(session: httpx.AsyncClient) -> str: + return (await session.get(url)).text + + +async def get_token(client: AsyncAnticaptchaClient, form_html: str) -> str: + site_key = re.search(site_key_pattern, form_html).group(1) + task = NoCaptchaTaskProxylessTask(website_url=url, website_key=site_key) + job = await client.create_task(task) + await job.join() + return job.get_solution_response() + + +async def form_submit(session: httpx.AsyncClient, token: str) -> str: + return (await session.post(url, data={"g-recaptcha-response": token})).text + + +async def process(): + async with AsyncAnticaptchaClient(api_key) as client, httpx.AsyncClient() as session: + html = await get_form_html(session) + token = await get_token(client, html) + return await form_submit(session, token) + + +if __name__ == "__main__": + result = asyncio.run(process()) + assert "Verification Success... Hooray!" in result diff --git a/pyproject.toml b/pyproject.toml index dec7cbd..89dc767 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ license = "MIT" requires-python = ">=3.9" dependencies = ["requests"] dynamic = ["version"] -keywords = ["recaptcha", "captcha", "development"] +keywords = ["recaptcha", "captcha", "development", "asyncio", "async", "httpx"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", @@ -21,6 +21,8 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Framework :: AsyncIO", + "Topic :: Internet :: WWW/HTTP", ] [project.urls] diff --git a/python_anticaptcha/_common.py b/python_anticaptcha/_common.py new file mode 100644 index 0000000..632f948 --- /dev/null +++ b/python_anticaptcha/_common.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import os +from typing import Any +from urllib.parse import urlparse + +from .exceptions import AnticaptchaException +from .tasks import BaseTask + +SOFT_ID = 847 +SLEEP_EVERY_CHECK_FINISHED = 3 +MAXIMUM_JOIN_TIME = 60 * 5 + + +class _BaseClientMixin: + CREATE_TASK_URL = "/createTask" + TASK_RESULT_URL = "/getTaskResult" + BALANCE_URL = "/getBalance" + REPORT_IMAGE_URL = "/reportIncorrectImageCaptcha" + REPORT_RECAPTCHA_URL = "/reportIncorrectRecaptcha" + APP_STAT_URL = "/getAppStats" + SOFT_ID = SOFT_ID + language_pool = "en" + response_timeout = 5 + + def _init_client( + self, client_key: str | None, language_pool: str, host: str, use_ssl: bool, + ) -> None: + self.client_key = client_key or os.environ.get("ANTICAPTCHA_API_KEY") + if not self.client_key: + raise AnticaptchaException( + None, + "CONFIG_ERROR", + "API key required. Pass client_key or set ANTICAPTCHA_API_KEY env var.", + ) + self.language_pool = language_pool + self.base_url = "{proto}://{host}/".format( + proto="https" if use_ssl else "http", host=host + ) + + def _build_create_task_request(self, task: BaseTask) -> dict[str, Any]: + return { + "clientKey": self.client_key, + "task": task.serialize(), + "softId": self.SOFT_ID, + "languagePool": self.language_pool, + } + + def _build_key_request(self, **extra: Any) -> dict[str, Any]: + return {"clientKey": self.client_key, **extra} + + def _process_check_response( + self, response: dict[str, Any], client_ip: str | None = None, + ) -> None: + if response.get("errorId", False) == 11 and client_ip: + response[ + "errorDescription" + ] = "{} Your missing IP address is probably {}.".format( + response["errorDescription"], client_ip + ) + if response.get("errorId", False): + raise AnticaptchaException( + response["errorId"], response["errorCode"], response["errorDescription"] + ) + + def _repr_client(self, class_name: str) -> str: + host = urlparse(self.base_url).hostname or self.base_url + return f"<{class_name} host={host!r}>" + + +class _BaseJobMixin: + client = None + task_id = None + _last_result = None + + def get_solution_response(self) -> str: # Recaptcha + return self._last_result["solution"]["gRecaptchaResponse"] + + def get_solution(self) -> dict[str, Any]: + return self._last_result["solution"] + + def get_token_response(self) -> str: # Funcaptcha + return self._last_result["solution"]["token"] + + def get_answers(self) -> dict[str, str]: + return self._last_result["solution"]["answers"] + + def get_captcha_text(self) -> str: # Image + return self._last_result["solution"]["text"] + + def get_cells_numbers(self) -> list[int]: + return self._last_result["solution"]["cellNumbers"] + + def _repr_job(self, class_name: str) -> str: + status = self._last_result.get("status") if self._last_result else None + if status: + return f"<{class_name} task_id={self.task_id} status={status!r}>" + return f"<{class_name} task_id={self.task_id}>" diff --git a/python_anticaptcha/async_client.py b/python_anticaptcha/async_client.py index f4e879f..7006387 100644 --- a/python_anticaptcha/async_client.py +++ b/python_anticaptcha/async_client.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import os from types import TracebackType from typing import Any from urllib.parse import urljoin @@ -16,14 +15,15 @@ from .exceptions import AnticaptchaException from .tasks import BaseTask -from .base import SLEEP_EVERY_CHECK_FINISHED, MAXIMUM_JOIN_TIME +from ._common import ( + _BaseClientMixin, + _BaseJobMixin, + SLEEP_EVERY_CHECK_FINISHED, + MAXIMUM_JOIN_TIME, +) -class AsyncJob: - client = None - task_id = None - _last_result = None - +class AsyncJob(_BaseJobMixin): def __init__(self, client: AsyncAnticaptchaClient, task_id: int) -> None: self.client = client self.task_id = task_id @@ -35,24 +35,6 @@ async def check_is_ready(self) -> bool: await self._update() return self._last_result["status"] == "ready" - def get_solution_response(self) -> str: # Recaptcha - return self._last_result["solution"]["gRecaptchaResponse"] - - def get_solution(self) -> dict[str, Any]: - return self._last_result["solution"] - - def get_token_response(self) -> str: # Funcaptcha - return self._last_result["solution"]["token"] - - def get_answers(self) -> dict[str, str]: - return self._last_result["solution"]["answers"] - - def get_captcha_text(self) -> str: # Image - return self._last_result["solution"]["text"] - - def get_cells_numbers(self) -> list[int]: - return self._last_result["solution"]["cellNumbers"] - async def report_incorrect_image(self) -> bool: return await self.client.reportIncorrectImage(self.task_id) @@ -60,10 +42,7 @@ async def report_incorrect_recaptcha(self) -> bool: return await self.client.reportIncorrectRecaptcha(self.task_id) def __repr__(self) -> str: - status = self._last_result.get("status") if self._last_result else None - if status: - return f"" - return f"" + return self._repr_job("AsyncJob") async def join(self, maximum_time: int | None = None, on_check=None) -> None: """Poll for task completion, sleeping asynchronously until ready or timeout. @@ -92,32 +71,13 @@ async def join(self, maximum_time: int | None = None, on_check=None) -> None: ) -class AsyncAnticaptchaClient: +class AsyncAnticaptchaClient(_BaseClientMixin): client_key = None - CREATE_TASK_URL = "/createTask" - TASK_RESULT_URL = "/getTaskResult" - BALANCE_URL = "/getBalance" - REPORT_IMAGE_URL = "/reportIncorrectImageCaptcha" - REPORT_RECAPTCHA_URL = "/reportIncorrectRecaptcha" - APP_STAT_URL = "/getAppStats" - SOFT_ID = 847 - language_pool = "en" - response_timeout = 5 def __init__( self, client_key: str | None = None, language_pool: str = "en", host: str = "api.anti-captcha.com", use_ssl: bool = True, ) -> None: - self.client_key = client_key or os.environ.get("ANTICAPTCHA_API_KEY") - if not self.client_key: - raise AnticaptchaException( - None, - "CONFIG_ERROR", - "API key required. Pass client_key or set ANTICAPTCHA_API_KEY env var.", - ) - self.language_pool = language_pool - self.base_url = "{proto}://{host}/".format( - proto="https" if use_ssl else "http", host=host - ) + self._init_client(client_key, language_pool, host, use_ssl) self.session = httpx.AsyncClient() async def __aenter__(self) -> AsyncAnticaptchaClient: @@ -136,9 +96,7 @@ async def close(self) -> None: await self.session.aclose() def __repr__(self) -> str: - from urllib.parse import urlparse - host = urlparse(self.base_url).hostname or self.base_url - return f"" + return self._repr_client("AsyncAnticaptchaClient") async def _get_client_ip(self) -> str: if not hasattr(self, "_client_ip"): @@ -162,12 +120,7 @@ async def _check_response(self, response: dict[str, Any]) -> None: ) async def createTask(self, task: BaseTask) -> AsyncJob: - request = { - "clientKey": self.client_key, - "task": task.serialize(), - "softId": self.SOFT_ID, - "languagePool": self.language_pool, - } + request = self._build_create_task_request(task) response = (await self.session.post( urljoin(self.base_url, self.CREATE_TASK_URL), json=request, @@ -177,7 +130,7 @@ async def createTask(self, task: BaseTask) -> AsyncJob: return AsyncJob(self, response["taskId"]) async def getTaskResult(self, task_id: int) -> dict[str, Any]: - request = {"clientKey": self.client_key, "taskId": task_id} + request = self._build_key_request(taskId=task_id) response = (await self.session.post( urljoin(self.base_url, self.TASK_RESULT_URL), json=request )).json() @@ -185,10 +138,7 @@ async def getTaskResult(self, task_id: int) -> dict[str, Any]: return response async def getBalance(self) -> float: - request = { - "clientKey": self.client_key, - "softId": self.SOFT_ID, - } + request = self._build_key_request(softId=self.SOFT_ID) response = (await self.session.post( urljoin(self.base_url, self.BALANCE_URL), json=request )).json() @@ -196,7 +146,7 @@ async def getBalance(self) -> float: return response["balance"] async def getAppStats(self, soft_id: int, mode: str) -> dict[str, Any]: - request = {"clientKey": self.client_key, "softId": soft_id, "mode": mode} + request = self._build_key_request(softId=soft_id, mode=mode) response = (await self.session.post( urljoin(self.base_url, self.APP_STAT_URL), json=request )).json() @@ -204,7 +154,7 @@ async def getAppStats(self, soft_id: int, mode: str) -> dict[str, Any]: return response async def reportIncorrectImage(self, task_id: int) -> bool: - request = {"clientKey": self.client_key, "taskId": task_id} + request = self._build_key_request(taskId=task_id) response = (await self.session.post( urljoin(self.base_url, self.REPORT_IMAGE_URL), json=request )).json() @@ -212,7 +162,7 @@ async def reportIncorrectImage(self, task_id: int) -> bool: return response.get("status", False) != False async def reportIncorrectRecaptcha(self, task_id: int) -> bool: - request = {"clientKey": self.client_key, "taskId": task_id} + request = self._build_key_request(taskId=task_id) response = (await self.session.post( urljoin(self.base_url, self.REPORT_RECAPTCHA_URL), json=request )).json() diff --git a/python_anticaptcha/base.py b/python_anticaptcha/base.py index f620957..85dfc4f 100644 --- a/python_anticaptcha/base.py +++ b/python_anticaptcha/base.py @@ -1,6 +1,5 @@ from __future__ import annotations -import os import requests import time import json @@ -11,16 +10,16 @@ from urllib.parse import urljoin from .exceptions import AnticaptchaException from .tasks import BaseTask +from ._common import ( + _BaseClientMixin, + _BaseJobMixin, + SOFT_ID, + SLEEP_EVERY_CHECK_FINISHED, + MAXIMUM_JOIN_TIME, +) -SLEEP_EVERY_CHECK_FINISHED = 3 -MAXIMUM_JOIN_TIME = 60 * 5 - - -class Job: - client = None - task_id = None - _last_result = None +class Job(_BaseJobMixin): def __init__(self, client: AnticaptchaClient, task_id: int) -> None: self.client = client self.task_id = task_id @@ -32,24 +31,6 @@ def check_is_ready(self) -> bool: self._update() return self._last_result["status"] == "ready" - def get_solution_response(self) -> str: # Recaptcha - return self._last_result["solution"]["gRecaptchaResponse"] - - def get_solution(self) -> dict[str, Any]: - return self._last_result["solution"] - - def get_token_response(self) -> str: # Funcaptcha - return self._last_result["solution"]["token"] - - def get_answers(self) -> dict[str, str]: - return self._last_result["solution"]["answers"] - - def get_captcha_text(self) -> str: # Image - return self._last_result["solution"]["text"] - - def get_cells_numbers(self) -> list[int]: - return self._last_result["solution"]["cellNumbers"] - def report_incorrect(self) -> bool: warnings.warn( "report_incorrect is deprecated, use report_incorrect_image instead", @@ -64,10 +45,7 @@ def report_incorrect_recaptcha(self) -> bool: return self.client.reportIncorrectRecaptcha(self.task_id) def __repr__(self) -> str: - status = self._last_result.get("status") if self._last_result else None - if status: - return f"" - return f"" + return self._repr_job("Job") def join(self, maximum_time: int | None = None, on_check=None) -> None: """Poll for task completion, blocking until ready or timeout. @@ -96,32 +74,13 @@ def join(self, maximum_time: int | None = None, on_check=None) -> None: ) -class AnticaptchaClient: +class AnticaptchaClient(_BaseClientMixin): client_key = None - CREATE_TASK_URL = "/createTask" - TASK_RESULT_URL = "/getTaskResult" - BALANCE_URL = "/getBalance" - REPORT_IMAGE_URL = "/reportIncorrectImageCaptcha" - REPORT_RECAPTCHA_URL = "/reportIncorrectRecaptcha" - APP_STAT_URL = "/getAppStats" - SOFT_ID = 847 - language_pool = "en" - response_timeout = 5 def __init__( self, client_key: str | None = None, language_pool: str = "en", host: str = "api.anti-captcha.com", use_ssl: bool = True, ) -> None: - self.client_key = client_key or os.environ.get("ANTICAPTCHA_API_KEY") - if not self.client_key: - raise AnticaptchaException( - None, - "CONFIG_ERROR", - "API key required. Pass client_key or set ANTICAPTCHA_API_KEY env var.", - ) - self.language_pool = language_pool - self.base_url = "{proto}://{host}/".format( - proto="https" if use_ssl else "http", host=host - ) + self._init_client(client_key, language_pool, host, use_ssl) self.session = requests.Session() def __enter__(self) -> AnticaptchaClient: @@ -140,9 +99,7 @@ def close(self) -> None: self.session.close() def __repr__(self) -> str: - from urllib.parse import urlparse - host = urlparse(self.base_url).hostname or self.base_url - return f"" + return self._repr_client("AnticaptchaClient") @property def client_ip(self) -> str: @@ -165,12 +122,7 @@ def _check_response(self, response: dict[str, Any]) -> None: ) def createTask(self, task: BaseTask) -> Job: - request = { - "clientKey": self.client_key, - "task": task.serialize(), - "softId": self.SOFT_ID, - "languagePool": self.language_pool, - } + request = self._build_create_task_request(task) response = self.session.post( urljoin(self.base_url, self.CREATE_TASK_URL), json=request, @@ -222,7 +174,7 @@ def createTaskSmee(self, task: BaseTask, timeout: int = MAXIMUM_JOIN_TIME) -> Jo return job def getTaskResult(self, task_id: int) -> dict[str, Any]: - request = {"clientKey": self.client_key, "taskId": task_id} + request = self._build_key_request(taskId=task_id) response = self.session.post( urljoin(self.base_url, self.TASK_RESULT_URL), json=request ).json() @@ -230,10 +182,7 @@ def getTaskResult(self, task_id: int) -> dict[str, Any]: return response def getBalance(self) -> float: - request = { - "clientKey": self.client_key, - "softId": self.SOFT_ID, - } + request = self._build_key_request(softId=self.SOFT_ID) response = self.session.post( urljoin(self.base_url, self.BALANCE_URL), json=request ).json() @@ -241,7 +190,7 @@ def getBalance(self) -> float: return response["balance"] def getAppStats(self, soft_id: int, mode: str) -> dict[str, Any]: - request = {"clientKey": self.client_key, "softId": soft_id, "mode": mode} + request = self._build_key_request(softId=soft_id, mode=mode) response = self.session.post( urljoin(self.base_url, self.APP_STAT_URL), json=request ).json() @@ -249,7 +198,7 @@ def getAppStats(self, soft_id: int, mode: str) -> dict[str, Any]: return response def reportIncorrectImage(self, task_id: int) -> bool: - request = {"clientKey": self.client_key, "taskId": task_id} + request = self._build_key_request(taskId=task_id) response = self.session.post( urljoin(self.base_url, self.REPORT_IMAGE_URL), json=request ).json() @@ -257,7 +206,7 @@ def reportIncorrectImage(self, task_id: int) -> bool: return response.get("status", False) != False def reportIncorrectRecaptcha(self, task_id: int) -> bool: - request = {"clientKey": self.client_key, "taskId": task_id} + request = self._build_key_request(taskId=task_id) response = self.session.post( urljoin(self.base_url, self.REPORT_RECAPTCHA_URL), json=request ).json() From 3051ca4e958c388a4f453cfc999354df07a5fc17 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Mar 2026 18:43:57 +0000 Subject: [PATCH 3/8] Balance sync and async as equal first-class citizens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename base.py → sync_client.py for symmetry with async_client.py; add backward-compat base.py re-export shim - Rename all sync example files with sync_ prefix to match async_ examples - Rename test_base.py → test_sync_client.py - Promote "Async Usage" to same heading level as sync in README and docs - Add "Sync client" heading in docs/usage.rst for symmetry - Rename "Base" → "Sync Client" in docs/api.rst - Update __init__.py to import from sync_client instead of base - All existing import paths preserved for backward compatibility https://claude.ai/code/session_01Pimg4VAco2v4srPeZj44Zm --- CHANGELOG.rst | 2 + README.md | 6 +- docs/api.rst | 6 +- docs/usage.rst | 13 +- examples/{antigate.py => sync_antigate.py} | 0 examples/{app_stat.py => sync_app_stat.py} | 0 examples/{balance.py => sync_balance.py} | 0 ..._request.py => sync_funcaptcha_request.py} | 0 ...elenium.py => sync_funcaptcha_selenium.py} | 0 ...y => sync_funcaptcha_selenium_callback.py} | 0 ...ha_request.py => sync_hcaptcha_request.py} | 0 ...roxy.py => sync_hcaptcha_request_proxy.py} | 0 ..._request.py => sync_recaptcha3_request.py} | 0 ...a_request.py => sync_recaptcha_request.py} | 0 ...selenium.py => sync_recaptcha_selenium.py} | 0 ...py => sync_recaptcha_selenium_callback.py} | 0 .../{remote_image.py => sync_remote_image.py} | 0 examples/{text.py => sync_text.py} | 0 .../{text_stream.py => sync_text_stream.py} | 0 python_anticaptcha/__init__.py | 2 +- python_anticaptcha/base.py | 226 +----------------- python_anticaptcha/sync_client.py | 223 +++++++++++++++++ tests/{test_base.py => test_sync_client.py} | 2 +- 23 files changed, 245 insertions(+), 235 deletions(-) rename examples/{antigate.py => sync_antigate.py} (100%) rename examples/{app_stat.py => sync_app_stat.py} (100%) rename examples/{balance.py => sync_balance.py} (100%) rename examples/{funcaptcha_request.py => sync_funcaptcha_request.py} (100%) rename examples/{funcaptcha_selenium.py => sync_funcaptcha_selenium.py} (100%) rename examples/{funcaptcha_selenium_callback.py => sync_funcaptcha_selenium_callback.py} (100%) rename examples/{hcaptcha_request.py => sync_hcaptcha_request.py} (100%) rename examples/{hcaptcha_request_proxy.py => sync_hcaptcha_request_proxy.py} (100%) rename examples/{recaptcha3_request.py => sync_recaptcha3_request.py} (100%) rename examples/{recaptcha_request.py => sync_recaptcha_request.py} (100%) rename examples/{recaptcha_selenium.py => sync_recaptcha_selenium.py} (100%) rename examples/{recaptcha_selenium_callback.py => sync_recaptcha_selenium_callback.py} (100%) rename examples/{remote_image.py => sync_remote_image.py} (100%) rename examples/{text.py => sync_text.py} (100%) rename examples/{text_stream.py => sync_text_stream.py} (100%) create mode 100644 python_anticaptcha/sync_client.py rename tests/{test_base.py => test_sync_client.py} (99%) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e59c398..f64a8a7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,8 @@ Added - Add ``AsyncAnticaptchaClient`` and ``AsyncJob`` for async/await usage with ``httpx`` (``pip install python-anticaptcha[async]``) - Add shared ``_BaseClientMixin`` and ``_BaseJobMixin`` to reduce duplication between sync and async clients +- Rename ``base.py`` → ``sync_client.py`` for symmetry with ``async_client.py``; backward-compatible ``base.py`` shim preserved +- Rename sync example files with ``sync_`` prefix to match ``async_`` examples - Add context manager support to ``AnticaptchaClient`` (``__enter__``, ``__exit__``, ``close``) - Add ``ANTICAPTCHA_API_KEY`` environment variable fallback for ``AnticaptchaClient`` - Add ``Proxy`` frozen dataclass with ``parse_url()`` and ``to_kwargs()`` methods diff --git a/README.md b/README.md index b74ad22..e1efe2d 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ with AnticaptchaClient(api_key) as client: job.join() ``` -### Async Usage +## Async Usage For async frameworks, use `AsyncAnticaptchaClient` — the API mirrors the sync client but all methods are awaitable: @@ -69,6 +69,8 @@ async with AsyncAnticaptchaClient(api_key) as client: The full integration example is available in file `examples/async_recaptcha_request.py`. +## Sync Usage + ### Solve recaptcha Example snippet for Recaptcha: @@ -87,7 +89,7 @@ job.join() print(job.get_solution_response()) ``` -The full integration example is available in file `examples/recaptcha_request.py`. +The full integration example is available in file `examples/sync_recaptcha_request.py`. If you process the same page many times, to increase reliability you can specify whether the captcha is visible or not. This parameter is not required, as the diff --git a/docs/api.rst b/docs/api.rst index a00238e..02f80e9 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1,10 +1,10 @@ API === -Base ----- +Sync Client +----------- - .. automodule:: python_anticaptcha.base + .. automodule:: python_anticaptcha.sync_client :members: :undoc-members: diff --git a/docs/usage.rst b/docs/usage.rst index 50fa43e..ecc3edb 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -24,12 +24,12 @@ The client can be used as a context manager to ensure the underlying session is Async client ############ -For async frameworks (FastAPI, aiohttp, Starlette), install with async support:: +.. note:: - pip install python-anticaptcha[async] + Requires the ``async`` extra: ``pip install python-anticaptcha[async]`` -Then use ``AsyncAnticaptchaClient`` — the API mirrors the sync client but all -methods are awaitable: +For async frameworks (FastAPI, aiohttp, Starlette) use ``AsyncAnticaptchaClient`` — +the API mirrors the sync client but all methods are awaitable: .. code:: python @@ -47,6 +47,9 @@ methods are awaitable: The full integration example is available in file ``examples/async_recaptcha_request.py``. +Sync client +########### + Solve recaptcha ############### @@ -66,7 +69,7 @@ Example snippet for Recaptcha: job.join() print(job.get_solution_response()) -The full integration example is available in file ``examples/recaptcha_request.py``. +The full integration example is available in file ``examples/sync_recaptcha_request.py``. If you process the same page many times, to increase reliability you can specify whether the captcha is visible or not. This parameter is not required, as the diff --git a/examples/antigate.py b/examples/sync_antigate.py similarity index 100% rename from examples/antigate.py rename to examples/sync_antigate.py diff --git a/examples/app_stat.py b/examples/sync_app_stat.py similarity index 100% rename from examples/app_stat.py rename to examples/sync_app_stat.py diff --git a/examples/balance.py b/examples/sync_balance.py similarity index 100% rename from examples/balance.py rename to examples/sync_balance.py diff --git a/examples/funcaptcha_request.py b/examples/sync_funcaptcha_request.py similarity index 100% rename from examples/funcaptcha_request.py rename to examples/sync_funcaptcha_request.py diff --git a/examples/funcaptcha_selenium.py b/examples/sync_funcaptcha_selenium.py similarity index 100% rename from examples/funcaptcha_selenium.py rename to examples/sync_funcaptcha_selenium.py diff --git a/examples/funcaptcha_selenium_callback.py b/examples/sync_funcaptcha_selenium_callback.py similarity index 100% rename from examples/funcaptcha_selenium_callback.py rename to examples/sync_funcaptcha_selenium_callback.py diff --git a/examples/hcaptcha_request.py b/examples/sync_hcaptcha_request.py similarity index 100% rename from examples/hcaptcha_request.py rename to examples/sync_hcaptcha_request.py diff --git a/examples/hcaptcha_request_proxy.py b/examples/sync_hcaptcha_request_proxy.py similarity index 100% rename from examples/hcaptcha_request_proxy.py rename to examples/sync_hcaptcha_request_proxy.py diff --git a/examples/recaptcha3_request.py b/examples/sync_recaptcha3_request.py similarity index 100% rename from examples/recaptcha3_request.py rename to examples/sync_recaptcha3_request.py diff --git a/examples/recaptcha_request.py b/examples/sync_recaptcha_request.py similarity index 100% rename from examples/recaptcha_request.py rename to examples/sync_recaptcha_request.py diff --git a/examples/recaptcha_selenium.py b/examples/sync_recaptcha_selenium.py similarity index 100% rename from examples/recaptcha_selenium.py rename to examples/sync_recaptcha_selenium.py diff --git a/examples/recaptcha_selenium_callback.py b/examples/sync_recaptcha_selenium_callback.py similarity index 100% rename from examples/recaptcha_selenium_callback.py rename to examples/sync_recaptcha_selenium_callback.py diff --git a/examples/remote_image.py b/examples/sync_remote_image.py similarity index 100% rename from examples/remote_image.py rename to examples/sync_remote_image.py diff --git a/examples/text.py b/examples/sync_text.py similarity index 100% rename from examples/text.py rename to examples/sync_text.py diff --git a/examples/text_stream.py b/examples/sync_text_stream.py similarity index 100% rename from examples/text_stream.py rename to examples/sync_text_stream.py diff --git a/python_anticaptcha/__init__.py b/python_anticaptcha/__init__.py index d965416..3a1567a 100644 --- a/python_anticaptcha/__init__.py +++ b/python_anticaptcha/__init__.py @@ -1,6 +1,6 @@ from importlib.metadata import version, PackageNotFoundError -from .base import AnticaptchaClient, Job +from .sync_client import AnticaptchaClient, Job from .proxy import Proxy from .tasks import ( NoCaptchaTaskProxylessTask, diff --git a/python_anticaptcha/base.py b/python_anticaptcha/base.py index 85dfc4f..4eb45c7 100644 --- a/python_anticaptcha/base.py +++ b/python_anticaptcha/base.py @@ -1,223 +1,3 @@ -from __future__ import annotations - -import requests -import time -import json -import warnings -from types import TracebackType -from typing import Any - -from urllib.parse import urljoin -from .exceptions import AnticaptchaException -from .tasks import BaseTask -from ._common import ( - _BaseClientMixin, - _BaseJobMixin, - SOFT_ID, - SLEEP_EVERY_CHECK_FINISHED, - MAXIMUM_JOIN_TIME, -) - - -class Job(_BaseJobMixin): - def __init__(self, client: AnticaptchaClient, task_id: int) -> None: - self.client = client - self.task_id = task_id - - def _update(self) -> None: - self._last_result = self.client.getTaskResult(self.task_id) - - def check_is_ready(self) -> bool: - self._update() - return self._last_result["status"] == "ready" - - def report_incorrect(self) -> bool: - warnings.warn( - "report_incorrect is deprecated, use report_incorrect_image instead", - DeprecationWarning, - ) - return self.client.reportIncorrectImage() - - def report_incorrect_image(self) -> bool: - return self.client.reportIncorrectImage(self.task_id) - - def report_incorrect_recaptcha(self) -> bool: - return self.client.reportIncorrectRecaptcha(self.task_id) - - def __repr__(self) -> str: - return self._repr_job("Job") - - def join(self, maximum_time: int | None = None, on_check=None) -> None: - """Poll for task completion, blocking until ready or timeout. - - :param maximum_time: Maximum seconds to wait (default: ``MAXIMUM_JOIN_TIME``). - :param on_check: Optional callback invoked after each poll with - ``(elapsed_time, status)`` where *elapsed_time* is the total seconds - waited so far and *status* is the last task status string - (e.g. ``"processing"``). - :raises AnticaptchaException: If *maximum_time* is exceeded. - """ - elapsed_time = 0 - maximum_time = maximum_time or MAXIMUM_JOIN_TIME - while not self.check_is_ready(): - time.sleep(SLEEP_EVERY_CHECK_FINISHED) - elapsed_time += SLEEP_EVERY_CHECK_FINISHED - if on_check is not None: - on_check(elapsed_time, self._last_result.get("status")) - if elapsed_time > maximum_time: - raise AnticaptchaException( - None, - 250, - "The execution time exceeded a maximum time of {} seconds. It takes {} seconds.".format( - maximum_time, elapsed_time - ), - ) - - -class AnticaptchaClient(_BaseClientMixin): - client_key = None - - def __init__( - self, client_key: str | None = None, language_pool: str = "en", host: str = "api.anti-captcha.com", use_ssl: bool = True, - ) -> None: - self._init_client(client_key, language_pool, host, use_ssl) - self.session = requests.Session() - - def __enter__(self) -> AnticaptchaClient: - return self - - def __exit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: TracebackType | None, - ) -> bool: - self.session.close() - return False - - def close(self) -> None: - self.session.close() - - def __repr__(self) -> str: - return self._repr_client("AnticaptchaClient") - - @property - def client_ip(self) -> str: - if not hasattr(self, "_client_ip"): - self._client_ip = self.session.get( - "https://api.myip.com", timeout=self.response_timeout - ).json()["ip"] - return self._client_ip - - def _check_response(self, response: dict[str, Any]) -> None: - if response.get("errorId", False) == 11: - response[ - "errorDescription" - ] = "{} Your missing IP address is probably {}.".format( - response["errorDescription"], self.client_ip - ) - if response.get("errorId", False): - raise AnticaptchaException( - response["errorId"], response["errorCode"], response["errorDescription"] - ) - - def createTask(self, task: BaseTask) -> Job: - request = self._build_create_task_request(task) - response = self.session.post( - urljoin(self.base_url, self.CREATE_TASK_URL), - json=request, - timeout=self.response_timeout, - ).json() - self._check_response(response) - return Job(self, response["taskId"]) - - def createTaskSmee(self, task: BaseTask, timeout: int = MAXIMUM_JOIN_TIME) -> Job: - """ - Beta method to stream response from smee.io - """ - response = self.session.head( - "https://smee.io/new", timeout=self.response_timeout - ) - smee_url = response.headers["Location"] - task = task.serialize() - request = { - "clientKey": self.client_key, - "task": task, - "softId": self.SOFT_ID, - "languagePool": self.language_pool, - "callbackUrl": smee_url, - } - r = self.session.get( - url=smee_url, - headers={"Accept": "text/event-stream"}, - stream=True, - timeout=(self.response_timeout, timeout), - ) - response = self.session.post( - url=urljoin(self.base_url, self.CREATE_TASK_URL), - json=request, - timeout=self.response_timeout, - ).json() - self._check_response(response) - for line in r.iter_lines(): - content = line.decode("utf-8") - if '"host":"smee.io"' not in content: - continue - payload = json.loads(content.split(":", maxsplit=1)[1].strip()) - if "taskId" not in payload["body"] or str(payload["body"]["taskId"]) != str( - response["taskId"] - ): - continue - r.close() - job = Job(client=self, task_id=response["taskId"]) - job._last_result = payload["body"] - return job - - def getTaskResult(self, task_id: int) -> dict[str, Any]: - request = self._build_key_request(taskId=task_id) - response = self.session.post( - urljoin(self.base_url, self.TASK_RESULT_URL), json=request - ).json() - self._check_response(response) - return response - - def getBalance(self) -> float: - request = self._build_key_request(softId=self.SOFT_ID) - response = self.session.post( - urljoin(self.base_url, self.BALANCE_URL), json=request - ).json() - self._check_response(response) - return response["balance"] - - def getAppStats(self, soft_id: int, mode: str) -> dict[str, Any]: - request = self._build_key_request(softId=soft_id, mode=mode) - response = self.session.post( - urljoin(self.base_url, self.APP_STAT_URL), json=request - ).json() - self._check_response(response) - return response - - def reportIncorrectImage(self, task_id: int) -> bool: - request = self._build_key_request(taskId=task_id) - response = self.session.post( - urljoin(self.base_url, self.REPORT_IMAGE_URL), json=request - ).json() - self._check_response(response) - return response.get("status", False) != False - - def reportIncorrectRecaptcha(self, task_id: int) -> bool: - request = self._build_key_request(taskId=task_id) - response = self.session.post( - urljoin(self.base_url, self.REPORT_RECAPTCHA_URL), json=request - ).json() - self._check_response(response) - return response["status"] == "success" - - # Snake_case aliases - create_task = createTask - create_task_smee = createTaskSmee - get_task_result = getTaskResult - get_balance = getBalance - get_app_stats = getAppStats - report_incorrect_image = reportIncorrectImage - report_incorrect_recaptcha = reportIncorrectRecaptcha +# Backward compatibility — canonical location is sync_client.py +from .sync_client import * # noqa: F401,F403 +from .sync_client import Job, AnticaptchaClient diff --git a/python_anticaptcha/sync_client.py b/python_anticaptcha/sync_client.py new file mode 100644 index 0000000..85dfc4f --- /dev/null +++ b/python_anticaptcha/sync_client.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +import requests +import time +import json +import warnings +from types import TracebackType +from typing import Any + +from urllib.parse import urljoin +from .exceptions import AnticaptchaException +from .tasks import BaseTask +from ._common import ( + _BaseClientMixin, + _BaseJobMixin, + SOFT_ID, + SLEEP_EVERY_CHECK_FINISHED, + MAXIMUM_JOIN_TIME, +) + + +class Job(_BaseJobMixin): + def __init__(self, client: AnticaptchaClient, task_id: int) -> None: + self.client = client + self.task_id = task_id + + def _update(self) -> None: + self._last_result = self.client.getTaskResult(self.task_id) + + def check_is_ready(self) -> bool: + self._update() + return self._last_result["status"] == "ready" + + def report_incorrect(self) -> bool: + warnings.warn( + "report_incorrect is deprecated, use report_incorrect_image instead", + DeprecationWarning, + ) + return self.client.reportIncorrectImage() + + def report_incorrect_image(self) -> bool: + return self.client.reportIncorrectImage(self.task_id) + + def report_incorrect_recaptcha(self) -> bool: + return self.client.reportIncorrectRecaptcha(self.task_id) + + def __repr__(self) -> str: + return self._repr_job("Job") + + def join(self, maximum_time: int | None = None, on_check=None) -> None: + """Poll for task completion, blocking until ready or timeout. + + :param maximum_time: Maximum seconds to wait (default: ``MAXIMUM_JOIN_TIME``). + :param on_check: Optional callback invoked after each poll with + ``(elapsed_time, status)`` where *elapsed_time* is the total seconds + waited so far and *status* is the last task status string + (e.g. ``"processing"``). + :raises AnticaptchaException: If *maximum_time* is exceeded. + """ + elapsed_time = 0 + maximum_time = maximum_time or MAXIMUM_JOIN_TIME + while not self.check_is_ready(): + time.sleep(SLEEP_EVERY_CHECK_FINISHED) + elapsed_time += SLEEP_EVERY_CHECK_FINISHED + if on_check is not None: + on_check(elapsed_time, self._last_result.get("status")) + if elapsed_time > maximum_time: + raise AnticaptchaException( + None, + 250, + "The execution time exceeded a maximum time of {} seconds. It takes {} seconds.".format( + maximum_time, elapsed_time + ), + ) + + +class AnticaptchaClient(_BaseClientMixin): + client_key = None + + def __init__( + self, client_key: str | None = None, language_pool: str = "en", host: str = "api.anti-captcha.com", use_ssl: bool = True, + ) -> None: + self._init_client(client_key, language_pool, host, use_ssl) + self.session = requests.Session() + + def __enter__(self) -> AnticaptchaClient: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool: + self.session.close() + return False + + def close(self) -> None: + self.session.close() + + def __repr__(self) -> str: + return self._repr_client("AnticaptchaClient") + + @property + def client_ip(self) -> str: + if not hasattr(self, "_client_ip"): + self._client_ip = self.session.get( + "https://api.myip.com", timeout=self.response_timeout + ).json()["ip"] + return self._client_ip + + def _check_response(self, response: dict[str, Any]) -> None: + if response.get("errorId", False) == 11: + response[ + "errorDescription" + ] = "{} Your missing IP address is probably {}.".format( + response["errorDescription"], self.client_ip + ) + if response.get("errorId", False): + raise AnticaptchaException( + response["errorId"], response["errorCode"], response["errorDescription"] + ) + + def createTask(self, task: BaseTask) -> Job: + request = self._build_create_task_request(task) + response = self.session.post( + urljoin(self.base_url, self.CREATE_TASK_URL), + json=request, + timeout=self.response_timeout, + ).json() + self._check_response(response) + return Job(self, response["taskId"]) + + def createTaskSmee(self, task: BaseTask, timeout: int = MAXIMUM_JOIN_TIME) -> Job: + """ + Beta method to stream response from smee.io + """ + response = self.session.head( + "https://smee.io/new", timeout=self.response_timeout + ) + smee_url = response.headers["Location"] + task = task.serialize() + request = { + "clientKey": self.client_key, + "task": task, + "softId": self.SOFT_ID, + "languagePool": self.language_pool, + "callbackUrl": smee_url, + } + r = self.session.get( + url=smee_url, + headers={"Accept": "text/event-stream"}, + stream=True, + timeout=(self.response_timeout, timeout), + ) + response = self.session.post( + url=urljoin(self.base_url, self.CREATE_TASK_URL), + json=request, + timeout=self.response_timeout, + ).json() + self._check_response(response) + for line in r.iter_lines(): + content = line.decode("utf-8") + if '"host":"smee.io"' not in content: + continue + payload = json.loads(content.split(":", maxsplit=1)[1].strip()) + if "taskId" not in payload["body"] or str(payload["body"]["taskId"]) != str( + response["taskId"] + ): + continue + r.close() + job = Job(client=self, task_id=response["taskId"]) + job._last_result = payload["body"] + return job + + def getTaskResult(self, task_id: int) -> dict[str, Any]: + request = self._build_key_request(taskId=task_id) + response = self.session.post( + urljoin(self.base_url, self.TASK_RESULT_URL), json=request + ).json() + self._check_response(response) + return response + + def getBalance(self) -> float: + request = self._build_key_request(softId=self.SOFT_ID) + response = self.session.post( + urljoin(self.base_url, self.BALANCE_URL), json=request + ).json() + self._check_response(response) + return response["balance"] + + def getAppStats(self, soft_id: int, mode: str) -> dict[str, Any]: + request = self._build_key_request(softId=soft_id, mode=mode) + response = self.session.post( + urljoin(self.base_url, self.APP_STAT_URL), json=request + ).json() + self._check_response(response) + return response + + def reportIncorrectImage(self, task_id: int) -> bool: + request = self._build_key_request(taskId=task_id) + response = self.session.post( + urljoin(self.base_url, self.REPORT_IMAGE_URL), json=request + ).json() + self._check_response(response) + return response.get("status", False) != False + + def reportIncorrectRecaptcha(self, task_id: int) -> bool: + request = self._build_key_request(taskId=task_id) + response = self.session.post( + urljoin(self.base_url, self.REPORT_RECAPTCHA_URL), json=request + ).json() + self._check_response(response) + return response["status"] == "success" + + # Snake_case aliases + create_task = createTask + create_task_smee = createTaskSmee + get_task_result = getTaskResult + get_balance = getBalance + get_app_stats = getAppStats + report_incorrect_image = reportIncorrectImage + report_incorrect_recaptcha = reportIncorrectRecaptcha diff --git a/tests/test_base.py b/tests/test_sync_client.py similarity index 99% rename from tests/test_base.py rename to tests/test_sync_client.py index 5c97223..bfa89e6 100644 --- a/tests/test_base.py +++ b/tests/test_sync_client.py @@ -2,7 +2,7 @@ import os import pytest -from python_anticaptcha.base import AnticaptchaClient, Job, SLEEP_EVERY_CHECK_FINISHED +from python_anticaptcha.sync_client import AnticaptchaClient, Job, SLEEP_EVERY_CHECK_FINISHED from python_anticaptcha.exceptions import AnticaptchaException From ab6bd09b8a2ad344c19c9b374f520d324cc944e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Mar 2026 18:52:13 +0000 Subject: [PATCH 4/8] Defer httpx ImportError to instantiation time for Sphinx compatibility The module-level ImportError prevented Sphinx autodoc from importing async_client when httpx is not installed. Now the module imports successfully (setting httpx=None) and raises ImportError only when AsyncAnticaptchaClient is instantiated without httpx. https://claude.ai/code/session_01Pimg4VAco2v4srPeZj44Zm --- python_anticaptcha/async_client.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/python_anticaptcha/async_client.py b/python_anticaptcha/async_client.py index ec8c458..df8fbc4 100644 --- a/python_anticaptcha/async_client.py +++ b/python_anticaptcha/async_client.py @@ -9,10 +9,7 @@ try: import httpx except ImportError: - raise ImportError( - "httpx is required for async support. " - "Install it with: pip install python-anticaptcha[async]" - ) + httpx = None # type: ignore[assignment] from .exceptions import AnticaptchaException from .tasks import BaseTask @@ -130,6 +127,11 @@ def __init__( host: str = "api.anti-captcha.com", use_ssl: bool = True, ) -> None: + if httpx is None: + raise ImportError( + "httpx is required for async support. " + "Install it with: pip install python-anticaptcha[async]" + ) self.client_key = client_key or os.environ.get("ANTICAPTCHA_API_KEY") if not self.client_key: raise AnticaptchaException( From 2bdcac879192db5f369e51654a812f1f156cdeb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Mar 2026 18:57:28 +0000 Subject: [PATCH 5/8] Fix CI: linter errors, mypy types, and add httpx to test deps - Fix ruff import sorting in __init__.py, test_sync_client.py, test_async_client.py - Fix ruff-format: async_client.py await parenthesization, string concatenation - Fix mypy: add return type to __getattr__, add type:ignore for optional httpx import - Add httpx to test dependencies so async tests work in CI - Remove redundant explicit imports from base.py shim (covered by *) https://claude.ai/code/session_01Pimg4VAco2v4srPeZj44Zm --- pyproject.toml | 2 +- python_anticaptcha/__init__.py | 4 ++-- python_anticaptcha/async_client.py | 37 +++++++++++------------------- python_anticaptcha/base.py | 1 - tests/test_async_client.py | 17 ++++++++------ tests/test_sync_client.py | 2 +- 6 files changed, 28 insertions(+), 35 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index eaf598e..77a1775 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ Homepage = "https://github.com/ad-m/python-anticaptcha" [project.optional-dependencies] async = ["httpx>=0.24"] -tests = ["pytest", "pytest-asyncio", "retry", "selenium"] +tests = ["pytest", "pytest-asyncio", "httpx>=0.24", "retry", "selenium"] docs = ["sphinx", "sphinx-rtd-theme"] [tool.setuptools.package-data] diff --git a/python_anticaptcha/__init__.py b/python_anticaptcha/__init__.py index fab404a..dfa2bfd 100644 --- a/python_anticaptcha/__init__.py +++ b/python_anticaptcha/__init__.py @@ -1,9 +1,9 @@ import contextlib from importlib.metadata import PackageNotFoundError, version -from .sync_client import AnticaptchaClient, Job from .exceptions import AnticaptchaException from .proxy import Proxy +from .sync_client import AnticaptchaClient, Job from .tasks import ( AntiGateTask, AntiGateTaskProxyless, @@ -29,7 +29,7 @@ __version__ = version(__name__) -def __getattr__(name: str): +def __getattr__(name: str) -> type: if name in ("AsyncAnticaptchaClient", "AsyncJob"): from .async_client import AsyncAnticaptchaClient, AsyncJob diff --git a/python_anticaptcha/async_client.py b/python_anticaptcha/async_client.py index df8fbc4..c496573 100644 --- a/python_anticaptcha/async_client.py +++ b/python_anticaptcha/async_client.py @@ -7,7 +7,7 @@ from urllib.parse import urljoin try: - import httpx + import httpx # type: ignore[import-not-found] except ImportError: httpx = None # type: ignore[assignment] @@ -129,8 +129,7 @@ def __init__( ) -> None: if httpx is None: raise ImportError( - "httpx is required for async support. " - "Install it with: pip install python-anticaptcha[async]" + "httpx is required for async support. Install it with: pip install python-anticaptcha[async]" ) self.client_key = client_key or os.environ.get("ANTICAPTCHA_API_KEY") if not self.client_key: @@ -186,19 +185,19 @@ async def createTask(self, task: BaseTask) -> AsyncJob: "softId": self.SOFT_ID, "languagePool": self.language_pool, } - response = (await self.session.post( - urljoin(self.base_url, self.CREATE_TASK_URL), - json=request, - timeout=self.response_timeout, - )).json() + response = ( + await self.session.post( + urljoin(self.base_url, self.CREATE_TASK_URL), + json=request, + timeout=self.response_timeout, + ) + ).json() await self._check_response(response) return AsyncJob(self, response["taskId"]) async def getTaskResult(self, task_id: int) -> dict[str, Any]: request = {"clientKey": self.client_key, "taskId": task_id} - response = (await self.session.post( - urljoin(self.base_url, self.TASK_RESULT_URL), json=request - )).json() + response = (await self.session.post(urljoin(self.base_url, self.TASK_RESULT_URL), json=request)).json() await self._check_response(response) return response @@ -207,33 +206,25 @@ async def getBalance(self) -> float: "clientKey": self.client_key, "softId": self.SOFT_ID, } - response = (await self.session.post( - urljoin(self.base_url, self.BALANCE_URL), json=request - )).json() + response = (await self.session.post(urljoin(self.base_url, self.BALANCE_URL), json=request)).json() await self._check_response(response) return response["balance"] async def getAppStats(self, soft_id: int, mode: str) -> dict[str, Any]: request = {"clientKey": self.client_key, "softId": soft_id, "mode": mode} - response = (await self.session.post( - urljoin(self.base_url, self.APP_STAT_URL), json=request - )).json() + response = (await self.session.post(urljoin(self.base_url, self.APP_STAT_URL), json=request)).json() await self._check_response(response) return response async def reportIncorrectImage(self, task_id: int) -> bool: request = {"clientKey": self.client_key, "taskId": task_id} - response = (await self.session.post( - urljoin(self.base_url, self.REPORT_IMAGE_URL), json=request - )).json() + response = (await self.session.post(urljoin(self.base_url, self.REPORT_IMAGE_URL), json=request)).json() await self._check_response(response) return bool(response.get("status", False)) async def reportIncorrectRecaptcha(self, task_id: int) -> bool: request = {"clientKey": self.client_key, "taskId": task_id} - response = (await self.session.post( - urljoin(self.base_url, self.REPORT_RECAPTCHA_URL), json=request - )).json() + response = (await self.session.post(urljoin(self.base_url, self.REPORT_RECAPTCHA_URL), json=request)).json() await self._check_response(response) return response["status"] == "success" diff --git a/python_anticaptcha/base.py b/python_anticaptcha/base.py index 4eb45c7..e7d7979 100644 --- a/python_anticaptcha/base.py +++ b/python_anticaptcha/base.py @@ -1,3 +1,2 @@ # Backward compatibility — canonical location is sync_client.py from .sync_client import * # noqa: F401,F403 -from .sync_client import Job, AnticaptchaClient diff --git a/tests/test_async_client.py b/tests/test_async_client.py index 33b0eef..97b523d 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -1,10 +1,11 @@ -from unittest.mock import patch, MagicMock, AsyncMock +from unittest.mock import AsyncMock, MagicMock, patch + import pytest from python_anticaptcha.async_client import ( + SLEEP_EVERY_CHECK_FINISHED, AsyncAnticaptchaClient, AsyncJob, - SLEEP_EVERY_CHECK_FINISHED, ) from python_anticaptcha.exceptions import AnticaptchaException @@ -180,11 +181,13 @@ class TestAsyncJobJoinOnCheck: @patch("python_anticaptcha.async_client.asyncio.sleep", new_callable=AsyncMock) async def test_on_check_called_each_iteration(self, mock_sleep): client = MagicMock() - client.getTaskResult = AsyncMock(side_effect=[ - {"status": "processing"}, - {"status": "processing"}, - {"status": "ready", "solution": {}}, - ]) + client.getTaskResult = AsyncMock( + side_effect=[ + {"status": "processing"}, + {"status": "processing"}, + {"status": "ready", "solution": {}}, + ] + ) job = AsyncJob(client, task_id=1) callback = MagicMock() await job.join(on_check=callback) diff --git a/tests/test_sync_client.py b/tests/test_sync_client.py index 2ab7f27..812f7a6 100644 --- a/tests/test_sync_client.py +++ b/tests/test_sync_client.py @@ -2,8 +2,8 @@ import pytest -from python_anticaptcha.sync_client import SLEEP_EVERY_CHECK_FINISHED, AnticaptchaClient, Job from python_anticaptcha.exceptions import AnticaptchaException +from python_anticaptcha.sync_client import SLEEP_EVERY_CHECK_FINISHED, AnticaptchaClient, Job class TestAnticaptchaClientInit: From b37d815a50ae7794fc06d982df903aa3c438fee6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Mar 2026 18:59:43 +0000 Subject: [PATCH 6/8] Fix E2E tests: update example imports to use sync_ prefix The example files were renamed with sync_ prefix but test_examples.py still imported them by their old names. https://claude.ai/code/session_01Pimg4VAco2v4srPeZj44Zm --- tests/test_examples.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index d7f6c0e..238991b 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -22,7 +22,7 @@ def missing_proxy(*args, **kwargs): class AntiGateTestCase(TestCase): @retry(tries=3) def test_process_antigate(self): - from examples import antigate + from examples import sync_antigate as antigate solution = antigate.process() for key in ["url", "domain", "localStorage", "cookies", "fingerprint"]: @@ -37,7 +37,7 @@ class FuncaptchaTestCase(TestCase): # Occasionally fails, so I repeat my attempt to have others selected. @retry(tries=3) def test_funcaptcha(self): - from examples import funcaptcha_request + from examples import sync_funcaptcha_request as funcaptcha_request self.assertIn("Solved!", funcaptcha_request.process()) @@ -48,7 +48,7 @@ class RecaptchaRequestTestCase(TestCase): # Anticaptcha responds is not fully reliable. @retry(tries=6) def test_process(self): - from examples import recaptcha_request + from examples import sync_recaptcha_request as recaptcha_request self.assertIn(recaptcha_request.EXPECTED_RESULT, recaptcha_request.process()) @@ -60,7 +60,7 @@ class RecaptchaV3ProxylessTestCase(TestCase): # Anticaptcha responds is not fully reliable. @retry(tries=3) def test_process(self): - from examples import recaptcha3_request + from examples import sync_recaptcha3_request as recaptcha3_request self.assertTrue(recaptcha3_request.process()["success"]) @@ -84,7 +84,7 @@ class RecaptchaSeleniumtTestCase(TestCase): def test_process(self): from selenium.webdriver.chrome.options import Options - from examples import recaptcha_selenium + from examples import sync_recaptcha_selenium as recaptcha_selenium options = Options() options.headless = True @@ -100,7 +100,7 @@ def test_process(self): @missing_key class TextTestCase(TestCase): def test_process(self): - from examples import text + from examples import sync_text as text self.assertEqual(text.process(text.IMAGE).lower(), text.EXPECTED_RESULT.lower()) @@ -111,7 +111,7 @@ def test_process(self): class HCaptchaTaskProxylessTestCase(TestCase): @retry(tries=3) def test_process(self): - from examples import hcaptcha_request + from examples import sync_hcaptcha_request as hcaptcha_request self.assertIn(hcaptcha_request.EXPECTED_RESULT, hcaptcha_request.process()) @@ -122,7 +122,7 @@ def test_process(self): class HCaptchaTaskTestCase(TestCase): @retry(tries=3) def test_process(self): - from examples import hcaptcha_request_proxy + from examples import sync_hcaptcha_request_proxy as hcaptcha_request_proxy self.assertIn( "Your request have submitted successfully.", From 0853ce2575a15c33f379b47aed8dd1f2be9bad0d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Mar 2026 19:06:25 +0000 Subject: [PATCH 7/8] Remove import aliases in test_examples.py, use sync_ prefixed names directly https://claude.ai/code/session_01Pimg4VAco2v4srPeZj44Zm --- tests/test_examples.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index 238991b..5bd8054 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -22,9 +22,9 @@ def missing_proxy(*args, **kwargs): class AntiGateTestCase(TestCase): @retry(tries=3) def test_process_antigate(self): - from examples import sync_antigate as antigate + from examples import sync_antigate - solution = antigate.process() + solution = sync_antigate.process() for key in ["url", "domain", "localStorage", "cookies", "fingerprint"]: self.assertIn(key, solution) @@ -37,9 +37,9 @@ class FuncaptchaTestCase(TestCase): # Occasionally fails, so I repeat my attempt to have others selected. @retry(tries=3) def test_funcaptcha(self): - from examples import sync_funcaptcha_request as funcaptcha_request + from examples import sync_funcaptcha_request - self.assertIn("Solved!", funcaptcha_request.process()) + self.assertIn("Solved!", sync_funcaptcha_request.process()) @pytest.mark.e2e @@ -48,9 +48,9 @@ class RecaptchaRequestTestCase(TestCase): # Anticaptcha responds is not fully reliable. @retry(tries=6) def test_process(self): - from examples import sync_recaptcha_request as recaptcha_request + from examples import sync_recaptcha_request - self.assertIn(recaptcha_request.EXPECTED_RESULT, recaptcha_request.process()) + self.assertIn(sync_recaptcha_request.EXPECTED_RESULT, sync_recaptcha_request.process()) @pytest.mark.e2e @@ -60,9 +60,9 @@ class RecaptchaV3ProxylessTestCase(TestCase): # Anticaptcha responds is not fully reliable. @retry(tries=3) def test_process(self): - from examples import sync_recaptcha3_request as recaptcha3_request + from examples import sync_recaptcha3_request - self.assertTrue(recaptcha3_request.process()["success"]) + self.assertTrue(sync_recaptcha3_request.process()["success"]) @contextmanager @@ -84,7 +84,7 @@ class RecaptchaSeleniumtTestCase(TestCase): def test_process(self): from selenium.webdriver.chrome.options import Options - from examples import sync_recaptcha_selenium as recaptcha_selenium + from examples import sync_recaptcha_selenium options = Options() options.headless = True @@ -93,16 +93,16 @@ def test_process(self): with open_driver( options=options, ) as driver: - self.assertIn(recaptcha_selenium.EXPECTED_RESULT, recaptcha_selenium.process(driver)) + self.assertIn(sync_recaptcha_selenium.EXPECTED_RESULT, sync_recaptcha_selenium.process(driver)) @pytest.mark.e2e @missing_key class TextTestCase(TestCase): def test_process(self): - from examples import sync_text as text + from examples import sync_text - self.assertEqual(text.process(text.IMAGE).lower(), text.EXPECTED_RESULT.lower()) + self.assertEqual(sync_text.process(sync_text.IMAGE).lower(), sync_text.EXPECTED_RESULT.lower()) @pytest.mark.e2e @@ -111,9 +111,9 @@ def test_process(self): class HCaptchaTaskProxylessTestCase(TestCase): @retry(tries=3) def test_process(self): - from examples import sync_hcaptcha_request as hcaptcha_request + from examples import sync_hcaptcha_request - self.assertIn(hcaptcha_request.EXPECTED_RESULT, hcaptcha_request.process()) + self.assertIn(sync_hcaptcha_request.EXPECTED_RESULT, sync_hcaptcha_request.process()) @pytest.mark.e2e @@ -122,9 +122,9 @@ def test_process(self): class HCaptchaTaskTestCase(TestCase): @retry(tries=3) def test_process(self): - from examples import sync_hcaptcha_request_proxy as hcaptcha_request_proxy + from examples import sync_hcaptcha_request_proxy self.assertIn( "Your request have submitted successfully.", - hcaptcha_request_proxy.process(), + sync_hcaptcha_request_proxy.process(), ) From 20064467223fbe1fe384ac7a4bf3179de4e9eda5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Mar 2026 19:14:25 +0000 Subject: [PATCH 8/8] Add six to test dependencies Required by funcaptcha and hcaptcha example tests. https://claude.ai/code/session_01Pimg4VAco2v4srPeZj44Zm --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 77a1775..6981df6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ Homepage = "https://github.com/ad-m/python-anticaptcha" [project.optional-dependencies] async = ["httpx>=0.24"] -tests = ["pytest", "pytest-asyncio", "httpx>=0.24", "retry", "selenium"] +tests = ["pytest", "pytest-asyncio", "httpx>=0.24", "retry", "selenium", "six"] docs = ["sphinx", "sphinx-rtd-theme"] [tool.setuptools.package-data]