Skip to content

Commit ab2d8b1

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

4 files changed

Lines changed: 532 additions & 1 deletion

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ Homepage = "https://github.com/ad-m/python-anticaptcha"
2828

2929
[project.optional-dependencies]
3030
async = ["httpx>=0.24"]
31-
tests = ["pytest", "retry", "selenium"]
31+
tests = ["pytest", "pytest-asyncio", "retry", "selenium"]
3232
docs = ["sphinx", "sphinx-rtd-theme"]
3333

3434
[tool.setuptools.package-data]

python_anticaptcha/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,17 @@
3030
# package is not installed
3131
pass
3232

33+
34+
def __getattr__(name: str):
35+
if name in ("AsyncAnticaptchaClient", "AsyncJob"):
36+
from .async_client import AsyncAnticaptchaClient, AsyncJob
37+
38+
globals()["AsyncAnticaptchaClient"] = AsyncAnticaptchaClient
39+
globals()["AsyncJob"] = AsyncJob
40+
return globals()[name]
41+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
42+
43+
3344
__all__ = [
3445
"AnticaptchaClient",
3546
"Job",
@@ -52,4 +63,6 @@
5263
"AntiGateTask",
5364
"AnticaptchaException",
5465
"AnticatpchaException",
66+
"AsyncAnticaptchaClient",
67+
"AsyncJob",
5568
]

python_anticaptcha/async_client.py

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import os
5+
from types import TracebackType
6+
from typing import Any
7+
from urllib.parse import urljoin
8+
9+
try:
10+
import httpx
11+
except ImportError:
12+
raise ImportError(
13+
"httpx is required for async support. "
14+
"Install it with: pip install python-anticaptcha[async]"
15+
)
16+
17+
from .exceptions import AnticaptchaException
18+
from .tasks import BaseTask
19+
from .base import SLEEP_EVERY_CHECK_FINISHED, MAXIMUM_JOIN_TIME
20+
21+
22+
class AsyncJob:
23+
client = None
24+
task_id = None
25+
_last_result = None
26+
27+
def __init__(self, client: AsyncAnticaptchaClient, task_id: int) -> None:
28+
self.client = client
29+
self.task_id = task_id
30+
31+
async def _update(self) -> None:
32+
self._last_result = await self.client.getTaskResult(self.task_id)
33+
34+
async def check_is_ready(self) -> bool:
35+
await self._update()
36+
return self._last_result["status"] == "ready"
37+
38+
def get_solution_response(self) -> str: # Recaptcha
39+
return self._last_result["solution"]["gRecaptchaResponse"]
40+
41+
def get_solution(self) -> dict[str, Any]:
42+
return self._last_result["solution"]
43+
44+
def get_token_response(self) -> str: # Funcaptcha
45+
return self._last_result["solution"]["token"]
46+
47+
def get_answers(self) -> dict[str, str]:
48+
return self._last_result["solution"]["answers"]
49+
50+
def get_captcha_text(self) -> str: # Image
51+
return self._last_result["solution"]["text"]
52+
53+
def get_cells_numbers(self) -> list[int]:
54+
return self._last_result["solution"]["cellNumbers"]
55+
56+
async def report_incorrect_image(self) -> bool:
57+
return await self.client.reportIncorrectImage(self.task_id)
58+
59+
async def report_incorrect_recaptcha(self) -> bool:
60+
return await self.client.reportIncorrectRecaptcha(self.task_id)
61+
62+
def __repr__(self) -> str:
63+
status = self._last_result.get("status") if self._last_result else None
64+
if status:
65+
return f"<AsyncJob task_id={self.task_id} status={status!r}>"
66+
return f"<AsyncJob task_id={self.task_id}>"
67+
68+
async def join(self, maximum_time: int | None = None, on_check=None) -> None:
69+
"""Poll for task completion, sleeping asynchronously until ready or timeout.
70+
71+
:param maximum_time: Maximum seconds to wait (default: ``MAXIMUM_JOIN_TIME``).
72+
:param on_check: Optional callback invoked after each poll with
73+
``(elapsed_time, status)`` where *elapsed_time* is the total seconds
74+
waited so far and *status* is the last task status string
75+
(e.g. ``"processing"``).
76+
:raises AnticaptchaException: If *maximum_time* is exceeded.
77+
"""
78+
elapsed_time = 0
79+
maximum_time = maximum_time or MAXIMUM_JOIN_TIME
80+
while not await self.check_is_ready():
81+
await asyncio.sleep(SLEEP_EVERY_CHECK_FINISHED)
82+
elapsed_time += SLEEP_EVERY_CHECK_FINISHED
83+
if on_check is not None:
84+
on_check(elapsed_time, self._last_result.get("status"))
85+
if elapsed_time > maximum_time:
86+
raise AnticaptchaException(
87+
None,
88+
250,
89+
"The execution time exceeded a maximum time of {} seconds. It takes {} seconds.".format(
90+
maximum_time, elapsed_time
91+
),
92+
)
93+
94+
95+
class AsyncAnticaptchaClient:
96+
client_key = None
97+
CREATE_TASK_URL = "/createTask"
98+
TASK_RESULT_URL = "/getTaskResult"
99+
BALANCE_URL = "/getBalance"
100+
REPORT_IMAGE_URL = "/reportIncorrectImageCaptcha"
101+
REPORT_RECAPTCHA_URL = "/reportIncorrectRecaptcha"
102+
APP_STAT_URL = "/getAppStats"
103+
SOFT_ID = 847
104+
language_pool = "en"
105+
response_timeout = 5
106+
107+
def __init__(
108+
self, client_key: str | None = None, language_pool: str = "en", host: str = "api.anti-captcha.com", use_ssl: bool = True,
109+
) -> None:
110+
self.client_key = client_key or os.environ.get("ANTICAPTCHA_API_KEY")
111+
if not self.client_key:
112+
raise AnticaptchaException(
113+
None,
114+
"CONFIG_ERROR",
115+
"API key required. Pass client_key or set ANTICAPTCHA_API_KEY env var.",
116+
)
117+
self.language_pool = language_pool
118+
self.base_url = "{proto}://{host}/".format(
119+
proto="https" if use_ssl else "http", host=host
120+
)
121+
self.session = httpx.AsyncClient()
122+
123+
async def __aenter__(self) -> AsyncAnticaptchaClient:
124+
return self
125+
126+
async def __aexit__(
127+
self,
128+
exc_type: type[BaseException] | None,
129+
exc_val: BaseException | None,
130+
exc_tb: TracebackType | None,
131+
) -> bool:
132+
await self.session.aclose()
133+
return False
134+
135+
async def close(self) -> None:
136+
await self.session.aclose()
137+
138+
def __repr__(self) -> str:
139+
from urllib.parse import urlparse
140+
host = urlparse(self.base_url).hostname or self.base_url
141+
return f"<AsyncAnticaptchaClient host={host!r}>"
142+
143+
async def _get_client_ip(self) -> str:
144+
if not hasattr(self, "_client_ip"):
145+
response = await self.session.get(
146+
"https://api.myip.com", timeout=self.response_timeout
147+
)
148+
self._client_ip = response.json()["ip"]
149+
return self._client_ip
150+
151+
async def _check_response(self, response: dict[str, Any]) -> None:
152+
if response.get("errorId", False) == 11:
153+
ip = await self._get_client_ip()
154+
response[
155+
"errorDescription"
156+
] = "{} Your missing IP address is probably {}.".format(
157+
response["errorDescription"], ip
158+
)
159+
if response.get("errorId", False):
160+
raise AnticaptchaException(
161+
response["errorId"], response["errorCode"], response["errorDescription"]
162+
)
163+
164+
async def createTask(self, task: BaseTask) -> AsyncJob:
165+
request = {
166+
"clientKey": self.client_key,
167+
"task": task.serialize(),
168+
"softId": self.SOFT_ID,
169+
"languagePool": self.language_pool,
170+
}
171+
response = (await self.session.post(
172+
urljoin(self.base_url, self.CREATE_TASK_URL),
173+
json=request,
174+
timeout=self.response_timeout,
175+
)).json()
176+
await self._check_response(response)
177+
return AsyncJob(self, response["taskId"])
178+
179+
async def getTaskResult(self, task_id: int) -> dict[str, Any]:
180+
request = {"clientKey": self.client_key, "taskId": task_id}
181+
response = (await self.session.post(
182+
urljoin(self.base_url, self.TASK_RESULT_URL), json=request
183+
)).json()
184+
await self._check_response(response)
185+
return response
186+
187+
async def getBalance(self) -> float:
188+
request = {
189+
"clientKey": self.client_key,
190+
"softId": self.SOFT_ID,
191+
}
192+
response = (await self.session.post(
193+
urljoin(self.base_url, self.BALANCE_URL), json=request
194+
)).json()
195+
await self._check_response(response)
196+
return response["balance"]
197+
198+
async def getAppStats(self, soft_id: int, mode: str) -> dict[str, Any]:
199+
request = {"clientKey": self.client_key, "softId": soft_id, "mode": mode}
200+
response = (await self.session.post(
201+
urljoin(self.base_url, self.APP_STAT_URL), json=request
202+
)).json()
203+
await self._check_response(response)
204+
return response
205+
206+
async def reportIncorrectImage(self, task_id: int) -> bool:
207+
request = {"clientKey": self.client_key, "taskId": task_id}
208+
response = (await self.session.post(
209+
urljoin(self.base_url, self.REPORT_IMAGE_URL), json=request
210+
)).json()
211+
await self._check_response(response)
212+
return response.get("status", False) != False
213+
214+
async def reportIncorrectRecaptcha(self, task_id: int) -> bool:
215+
request = {"clientKey": self.client_key, "taskId": task_id}
216+
response = (await self.session.post(
217+
urljoin(self.base_url, self.REPORT_RECAPTCHA_URL), json=request
218+
)).json()
219+
await self._check_response(response)
220+
return response["status"] == "success"
221+
222+
# Snake_case aliases
223+
create_task = createTask
224+
get_task_result = getTaskResult
225+
get_balance = getBalance
226+
get_app_stats = getAppStats
227+
report_incorrect_image = reportIncorrectImage
228+
report_incorrect_recaptcha = reportIncorrectRecaptcha

0 commit comments

Comments
 (0)