Skip to content

Commit df32de0

Browse files
committed
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
1 parent ab2d8b1 commit df32de0

10 files changed

Lines changed: 255 additions & 137 deletions

File tree

CHANGELOG.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ Unreleased
77
Added
88
#####
99

10+
- Add ``AsyncAnticaptchaClient`` and ``AsyncJob`` for async/await usage with ``httpx`` (``pip install python-anticaptcha[async]``)
11+
- Add shared ``_BaseClientMixin`` and ``_BaseJobMixin`` to reduce duplication between sync and async clients
1012
- Add context manager support to ``AnticaptchaClient`` (``__enter__``, ``__exit__``, ``close``)
1113
- Add ``ANTICAPTCHA_API_KEY`` environment variable fallback for ``AnticaptchaClient``
1214
- Add ``Proxy`` frozen dataclass with ``parse_url()`` and ``to_kwargs()`` methods

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ Install as standard Python package using:
2020
pip install python-anticaptcha
2121
```
2222

23+
For async support (FastAPI, aiohttp, Starlette, etc.):
24+
25+
```
26+
pip install python-anticaptcha[async]
27+
```
28+
2329
## Usage
2430

2531
To use this library you need [Anticaptcha.com](http://getcaptchasolution.com/p9bwplkicx) API key.
@@ -42,6 +48,27 @@ with AnticaptchaClient(api_key) as client:
4248
job.join()
4349
```
4450

51+
### Async Usage
52+
53+
For async frameworks, use `AsyncAnticaptchaClient` — the API mirrors the sync
54+
client but all methods are awaitable:
55+
56+
```python
57+
from python_anticaptcha import AsyncAnticaptchaClient, NoCaptchaTaskProxylessTask
58+
59+
api_key = '174faff8fbc769e94a5862391ecfd010'
60+
site_key = '6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-'
61+
url = 'https://www.google.com/recaptcha/api2/demo'
62+
63+
async with AsyncAnticaptchaClient(api_key) as client:
64+
task = NoCaptchaTaskProxylessTask(url, site_key)
65+
job = await client.create_task(task)
66+
await job.join()
67+
print(job.get_solution_response())
68+
```
69+
70+
The full integration example is available in file `examples/async_recaptcha_request.py`.
71+
4572
### Solve recaptcha
4673

4774
Example snippet for Recaptcha:

docs/api.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ Base
88
:members:
99
:undoc-members:
1010

11+
Async Client
12+
------------
13+
14+
.. automodule:: python_anticaptcha.async_client
15+
:members:
16+
:undoc-members:
17+
1118
Exceptions
1219
----------
1320

docs/usage.rst

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,32 @@ The client can be used as a context manager to ensure the underlying session is
2121
job = client.create_task(task)
2222
job.join()
2323
24+
Async client
25+
############
26+
27+
For async frameworks (FastAPI, aiohttp, Starlette), install with async support::
28+
29+
pip install python-anticaptcha[async]
30+
31+
Then use ``AsyncAnticaptchaClient`` — the API mirrors the sync client but all
32+
methods are awaitable:
33+
34+
.. code:: python
35+
36+
from python_anticaptcha import AsyncAnticaptchaClient, NoCaptchaTaskProxylessTask
37+
38+
api_key = '174faff8fbc769e94a5862391ecfd010'
39+
site_key = '6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-'
40+
url = 'https://www.google.com/recaptcha/api2/demo'
41+
42+
async with AsyncAnticaptchaClient(api_key) as client:
43+
task = NoCaptchaTaskProxylessTask(url, site_key)
44+
job = await client.create_task(task)
45+
await job.join()
46+
print(job.get_solution_response())
47+
48+
The full integration example is available in file ``examples/async_recaptcha_request.py``.
49+
2450
Solve recaptcha
2551
###############
2652

examples/async_balance.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import asyncio
2+
from os import environ
3+
from pprint import pprint
4+
5+
from python_anticaptcha import AsyncAnticaptchaClient
6+
7+
api_key = environ["KEY"]
8+
9+
10+
async def process():
11+
async with AsyncAnticaptchaClient(api_key) as client:
12+
balance = await client.get_balance()
13+
pprint(balance)
14+
15+
16+
if __name__ == "__main__":
17+
asyncio.run(process())
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import asyncio
2+
import re
3+
from os import environ
4+
5+
import httpx
6+
7+
from python_anticaptcha import AsyncAnticaptchaClient, NoCaptchaTaskProxylessTask
8+
9+
api_key = environ["KEY"]
10+
site_key_pattern = 'data-sitekey="(.+?)"'
11+
url = "https://www.google.com/recaptcha/api2/demo?invisible=false"
12+
EXPECTED_RESULT = "Verification Success... Hooray!"
13+
14+
15+
async def get_form_html(session: httpx.AsyncClient) -> str:
16+
return (await session.get(url)).text
17+
18+
19+
async def get_token(client: AsyncAnticaptchaClient, form_html: str) -> str:
20+
site_key = re.search(site_key_pattern, form_html).group(1)
21+
task = NoCaptchaTaskProxylessTask(website_url=url, website_key=site_key)
22+
job = await client.create_task(task)
23+
await job.join()
24+
return job.get_solution_response()
25+
26+
27+
async def form_submit(session: httpx.AsyncClient, token: str) -> str:
28+
return (await session.post(url, data={"g-recaptcha-response": token})).text
29+
30+
31+
async def process():
32+
async with AsyncAnticaptchaClient(api_key) as client, httpx.AsyncClient() as session:
33+
html = await get_form_html(session)
34+
token = await get_token(client, html)
35+
return await form_submit(session, token)
36+
37+
38+
if __name__ == "__main__":
39+
result = asyncio.run(process())
40+
assert "Verification Success... Hooray!" in result

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ license = "MIT"
1010
requires-python = ">=3.9"
1111
dependencies = ["requests"]
1212
dynamic = ["version"]
13-
keywords = ["recaptcha", "captcha", "development"]
13+
keywords = ["recaptcha", "captcha", "development", "asyncio", "async", "httpx"]
1414
classifiers = [
1515
"Development Status :: 4 - Beta",
1616
"Intended Audience :: Developers",
@@ -21,6 +21,8 @@ classifiers = [
2121
"Programming Language :: Python :: 3.12",
2222
"Programming Language :: Python :: 3.13",
2323
"Programming Language :: Python :: 3.14",
24+
"Framework :: AsyncIO",
25+
"Topic :: Internet :: WWW/HTTP",
2426
]
2527

2628
[project.urls]

python_anticaptcha/_common.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
from __future__ import annotations
2+
3+
import os
4+
from typing import Any
5+
from urllib.parse import urlparse
6+
7+
from .exceptions import AnticaptchaException
8+
from .tasks import BaseTask
9+
10+
SOFT_ID = 847
11+
SLEEP_EVERY_CHECK_FINISHED = 3
12+
MAXIMUM_JOIN_TIME = 60 * 5
13+
14+
15+
class _BaseClientMixin:
16+
CREATE_TASK_URL = "/createTask"
17+
TASK_RESULT_URL = "/getTaskResult"
18+
BALANCE_URL = "/getBalance"
19+
REPORT_IMAGE_URL = "/reportIncorrectImageCaptcha"
20+
REPORT_RECAPTCHA_URL = "/reportIncorrectRecaptcha"
21+
APP_STAT_URL = "/getAppStats"
22+
SOFT_ID = SOFT_ID
23+
language_pool = "en"
24+
response_timeout = 5
25+
26+
def _init_client(
27+
self, client_key: str | None, language_pool: str, host: str, use_ssl: bool,
28+
) -> None:
29+
self.client_key = client_key or os.environ.get("ANTICAPTCHA_API_KEY")
30+
if not self.client_key:
31+
raise AnticaptchaException(
32+
None,
33+
"CONFIG_ERROR",
34+
"API key required. Pass client_key or set ANTICAPTCHA_API_KEY env var.",
35+
)
36+
self.language_pool = language_pool
37+
self.base_url = "{proto}://{host}/".format(
38+
proto="https" if use_ssl else "http", host=host
39+
)
40+
41+
def _build_create_task_request(self, task: BaseTask) -> dict[str, Any]:
42+
return {
43+
"clientKey": self.client_key,
44+
"task": task.serialize(),
45+
"softId": self.SOFT_ID,
46+
"languagePool": self.language_pool,
47+
}
48+
49+
def _build_key_request(self, **extra: Any) -> dict[str, Any]:
50+
return {"clientKey": self.client_key, **extra}
51+
52+
def _process_check_response(
53+
self, response: dict[str, Any], client_ip: str | None = None,
54+
) -> None:
55+
if response.get("errorId", False) == 11 and client_ip:
56+
response[
57+
"errorDescription"
58+
] = "{} Your missing IP address is probably {}.".format(
59+
response["errorDescription"], client_ip
60+
)
61+
if response.get("errorId", False):
62+
raise AnticaptchaException(
63+
response["errorId"], response["errorCode"], response["errorDescription"]
64+
)
65+
66+
def _repr_client(self, class_name: str) -> str:
67+
host = urlparse(self.base_url).hostname or self.base_url
68+
return f"<{class_name} host={host!r}>"
69+
70+
71+
class _BaseJobMixin:
72+
client = None
73+
task_id = None
74+
_last_result = None
75+
76+
def get_solution_response(self) -> str: # Recaptcha
77+
return self._last_result["solution"]["gRecaptchaResponse"]
78+
79+
def get_solution(self) -> dict[str, Any]:
80+
return self._last_result["solution"]
81+
82+
def get_token_response(self) -> str: # Funcaptcha
83+
return self._last_result["solution"]["token"]
84+
85+
def get_answers(self) -> dict[str, str]:
86+
return self._last_result["solution"]["answers"]
87+
88+
def get_captcha_text(self) -> str: # Image
89+
return self._last_result["solution"]["text"]
90+
91+
def get_cells_numbers(self) -> list[int]:
92+
return self._last_result["solution"]["cellNumbers"]
93+
94+
def _repr_job(self, class_name: str) -> str:
95+
status = self._last_result.get("status") if self._last_result else None
96+
if status:
97+
return f"<{class_name} task_id={self.task_id} status={status!r}>"
98+
return f"<{class_name} task_id={self.task_id}>"

0 commit comments

Comments
 (0)