nonecap 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nonecap/__init__.py +66 -0
- nonecap/_client.py +696 -0
- nonecap/_errors.py +112 -0
- nonecap/_types.py +127 -0
- nonecap/_version.py +1 -0
- nonecap/py.typed +0 -0
- nonecap-0.1.0.dist-info/METADATA +190 -0
- nonecap-0.1.0.dist-info/RECORD +10 -0
- nonecap-0.1.0.dist-info/WHEEL +4 -0
- nonecap-0.1.0.dist-info/licenses/LICENSE +21 -0
nonecap/__init__.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Official Python client for the NoneCap hCaptcha solving API.
|
|
2
|
+
|
|
3
|
+
>>> from nonecap import NoneCap
|
|
4
|
+
>>> nc = NoneCap(api_key="nc_live_...")
|
|
5
|
+
>>> solve = nc.solve(type="hcaptcha", sitekey="...", url="https://example.com")
|
|
6
|
+
>>> solve.token
|
|
7
|
+
'P1_...'
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from ._client import AsyncNoneCap, NoneCap
|
|
11
|
+
from ._errors import (
|
|
12
|
+
APIConnectionError,
|
|
13
|
+
APIError,
|
|
14
|
+
APITimeoutError,
|
|
15
|
+
AuthenticationError,
|
|
16
|
+
ConflictError,
|
|
17
|
+
InsufficientCreditsError,
|
|
18
|
+
NoneCapError,
|
|
19
|
+
NotFoundError,
|
|
20
|
+
PermissionDeniedError,
|
|
21
|
+
RateLimitError,
|
|
22
|
+
SolveFailedError,
|
|
23
|
+
SolveTimeoutError,
|
|
24
|
+
ValidationError,
|
|
25
|
+
)
|
|
26
|
+
from ._types import (
|
|
27
|
+
TERMINAL_STATUSES,
|
|
28
|
+
Account,
|
|
29
|
+
Proxy,
|
|
30
|
+
Solve,
|
|
31
|
+
SolveError,
|
|
32
|
+
SolvePage,
|
|
33
|
+
SolveStatus,
|
|
34
|
+
SolveType,
|
|
35
|
+
)
|
|
36
|
+
from ._version import __version__
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"__version__",
|
|
40
|
+
# clients
|
|
41
|
+
"NoneCap",
|
|
42
|
+
"AsyncNoneCap",
|
|
43
|
+
# types
|
|
44
|
+
"Solve",
|
|
45
|
+
"SolveError",
|
|
46
|
+
"SolvePage",
|
|
47
|
+
"Account",
|
|
48
|
+
"Proxy",
|
|
49
|
+
"SolveType",
|
|
50
|
+
"SolveStatus",
|
|
51
|
+
"TERMINAL_STATUSES",
|
|
52
|
+
# errors
|
|
53
|
+
"NoneCapError",
|
|
54
|
+
"AuthenticationError",
|
|
55
|
+
"PermissionDeniedError",
|
|
56
|
+
"InsufficientCreditsError",
|
|
57
|
+
"ValidationError",
|
|
58
|
+
"NotFoundError",
|
|
59
|
+
"ConflictError",
|
|
60
|
+
"RateLimitError",
|
|
61
|
+
"APIError",
|
|
62
|
+
"APIConnectionError",
|
|
63
|
+
"APITimeoutError",
|
|
64
|
+
"SolveFailedError",
|
|
65
|
+
"SolveTimeoutError",
|
|
66
|
+
]
|
nonecap/_client.py
ADDED
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
"""Sync and async clients for the NoneCap API.
|
|
2
|
+
|
|
3
|
+
Both clients expose the same surface:
|
|
4
|
+
|
|
5
|
+
- ``client.solve(...)`` — submit a captcha and wait for the token (the
|
|
6
|
+
convenient path; long-polls under the hood).
|
|
7
|
+
- ``client.solves.create / retrieve / cancel / list / list_all`` — the raw
|
|
8
|
+
resource methods, mapping one to one to the REST API.
|
|
9
|
+
- ``client.me()`` — account info and credit balance.
|
|
10
|
+
|
|
11
|
+
``rqdata`` is required for ``type="hcaptcha_enterprise"`` and optional for
|
|
12
|
+
``type="hcaptcha"``; the ``@overload`` signatures enforce that in mypy and
|
|
13
|
+
pyright, and a runtime check backs it up for untyped callers.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import AsyncIterator, Iterator
|
|
20
|
+
from typing import (
|
|
21
|
+
Any,
|
|
22
|
+
Literal,
|
|
23
|
+
Optional,
|
|
24
|
+
Union,
|
|
25
|
+
overload,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
import httpx
|
|
29
|
+
|
|
30
|
+
from ._errors import (
|
|
31
|
+
APIConnectionError,
|
|
32
|
+
APIError,
|
|
33
|
+
APITimeoutError,
|
|
34
|
+
SolveFailedError,
|
|
35
|
+
SolveTimeoutError,
|
|
36
|
+
ValidationError,
|
|
37
|
+
error_from_response,
|
|
38
|
+
)
|
|
39
|
+
from ._types import Account, Proxy, Solve, SolvePage, SolveStatus, SolveType
|
|
40
|
+
from ._version import __version__
|
|
41
|
+
|
|
42
|
+
DEFAULT_BASE_URL = "https://api.nonecap.com"
|
|
43
|
+
DEFAULT_REQUEST_TIMEOUT = 100.0
|
|
44
|
+
"""Per-request timeout (seconds): just above the API's 90s long-poll window."""
|
|
45
|
+
DEFAULT_SOLVE_TIMEOUT = 180.0
|
|
46
|
+
"""Default overall budget (seconds) for the ``solve()`` helper."""
|
|
47
|
+
_MAX_WAIT_SECONDS = 90
|
|
48
|
+
"""The API caps server-side long-poll at 90 seconds."""
|
|
49
|
+
|
|
50
|
+
_USER_AGENT = f"nonecap-python/{__version__}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _wait_seconds(deadline: float) -> int:
|
|
54
|
+
"""The ``wait`` value for the next long-poll: whole seconds until
|
|
55
|
+
``deadline``, clamped to the server's 1-90 window. The floor of 1 keeps
|
|
56
|
+
the param valid, so callers must decide whether the deadline has passed
|
|
57
|
+
with the clock, not with this return value."""
|
|
58
|
+
remaining = int(deadline - time.monotonic()) + 1
|
|
59
|
+
return max(1, min(_MAX_WAIT_SECONDS, remaining))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _request_timeout_for_wait(wait: Optional[int], default: float) -> float:
|
|
63
|
+
"""Give the socket a margin beyond the server's long-poll window."""
|
|
64
|
+
if wait is None:
|
|
65
|
+
return default
|
|
66
|
+
return float(wait) + 15.0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _build_solve_body(
|
|
70
|
+
*,
|
|
71
|
+
type: SolveType,
|
|
72
|
+
sitekey: str,
|
|
73
|
+
url: str,
|
|
74
|
+
rqdata: Optional[str],
|
|
75
|
+
user_agent: Optional[str],
|
|
76
|
+
proxy: Union[Proxy, str, None],
|
|
77
|
+
webhook_url: Optional[str],
|
|
78
|
+
) -> dict[str, Any]:
|
|
79
|
+
if type == "hcaptcha_enterprise" and not rqdata:
|
|
80
|
+
raise ValidationError(
|
|
81
|
+
"rqdata is required for hcaptcha_enterprise solves.",
|
|
82
|
+
code="validation_error",
|
|
83
|
+
param="rqdata",
|
|
84
|
+
)
|
|
85
|
+
body: dict[str, Any] = {"type": type, "sitekey": sitekey, "url": url}
|
|
86
|
+
if rqdata is not None:
|
|
87
|
+
body["rqdata"] = rqdata
|
|
88
|
+
if user_agent is not None:
|
|
89
|
+
body["user_agent"] = user_agent
|
|
90
|
+
if proxy is not None:
|
|
91
|
+
body["proxy"] = proxy
|
|
92
|
+
if webhook_url is not None:
|
|
93
|
+
body["webhook_url"] = webhook_url
|
|
94
|
+
return body
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _list_params(
|
|
98
|
+
*,
|
|
99
|
+
limit: Optional[int],
|
|
100
|
+
starting_after: Optional[str],
|
|
101
|
+
status: Optional[SolveStatus],
|
|
102
|
+
type: Optional[SolveType],
|
|
103
|
+
) -> dict[str, Any]:
|
|
104
|
+
params: dict[str, Any] = {}
|
|
105
|
+
if limit is not None:
|
|
106
|
+
params["limit"] = limit
|
|
107
|
+
if starting_after is not None:
|
|
108
|
+
params["starting_after"] = starting_after
|
|
109
|
+
if status is not None:
|
|
110
|
+
params["status"] = status
|
|
111
|
+
if type is not None:
|
|
112
|
+
params["type"] = type
|
|
113
|
+
return params
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _process_response(response: httpx.Response) -> Any:
|
|
117
|
+
"""Parse a response, mapping non-2xx envelopes to typed errors.
|
|
118
|
+
|
|
119
|
+
202 is a success here: the API returns it for a solve that is still
|
|
120
|
+
pending/solving, with the solve resource as the body.
|
|
121
|
+
"""
|
|
122
|
+
try:
|
|
123
|
+
payload = response.json() if response.content else None
|
|
124
|
+
except ValueError:
|
|
125
|
+
raise APIError(
|
|
126
|
+
f"Unexpected non-JSON response (HTTP {response.status_code}): "
|
|
127
|
+
f"{response.text[:200]}",
|
|
128
|
+
status=response.status_code,
|
|
129
|
+
) from None
|
|
130
|
+
|
|
131
|
+
if response.status_code < 400:
|
|
132
|
+
return payload
|
|
133
|
+
|
|
134
|
+
error = (payload or {}).get("error") if isinstance(payload, dict) else None
|
|
135
|
+
error = error if isinstance(error, dict) else {}
|
|
136
|
+
raise error_from_response(
|
|
137
|
+
response.status_code,
|
|
138
|
+
error.get("code"),
|
|
139
|
+
error.get("message") or f"HTTP {response.status_code}",
|
|
140
|
+
error.get("param"),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class _BaseClient:
|
|
145
|
+
def __init__(self, *, api_key: str, base_url: Optional[str], timeout: float) -> None:
|
|
146
|
+
if not api_key:
|
|
147
|
+
raise ValueError(
|
|
148
|
+
"A NoneCap API key is required. Pass it as NoneCap(api_key=...)."
|
|
149
|
+
)
|
|
150
|
+
self._api_key = api_key
|
|
151
|
+
self._base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
152
|
+
self._timeout = timeout
|
|
153
|
+
|
|
154
|
+
def _url(self, path: str) -> str:
|
|
155
|
+
return self._base_url + path
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def _headers(self) -> dict[str, str]:
|
|
159
|
+
return {
|
|
160
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
161
|
+
"Accept": "application/json",
|
|
162
|
+
"User-Agent": _USER_AGENT,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
# Sync
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class Solves:
|
|
172
|
+
"""Operations on solves (sync). Reached as ``client.solves``."""
|
|
173
|
+
|
|
174
|
+
def __init__(self, client: NoneCap) -> None:
|
|
175
|
+
self._client = client
|
|
176
|
+
|
|
177
|
+
@overload
|
|
178
|
+
def create(
|
|
179
|
+
self,
|
|
180
|
+
*,
|
|
181
|
+
type: Literal["hcaptcha"],
|
|
182
|
+
sitekey: str,
|
|
183
|
+
url: str,
|
|
184
|
+
rqdata: Optional[str] = None,
|
|
185
|
+
user_agent: Optional[str] = None,
|
|
186
|
+
proxy: Union[Proxy, str, None] = None,
|
|
187
|
+
webhook_url: Optional[str] = None,
|
|
188
|
+
wait: Optional[int] = None,
|
|
189
|
+
) -> Solve: ...
|
|
190
|
+
|
|
191
|
+
@overload
|
|
192
|
+
def create(
|
|
193
|
+
self,
|
|
194
|
+
*,
|
|
195
|
+
type: Literal["hcaptcha_enterprise"],
|
|
196
|
+
sitekey: str,
|
|
197
|
+
url: str,
|
|
198
|
+
rqdata: str,
|
|
199
|
+
user_agent: Optional[str] = None,
|
|
200
|
+
proxy: Union[Proxy, str, None] = None,
|
|
201
|
+
webhook_url: Optional[str] = None,
|
|
202
|
+
wait: Optional[int] = None,
|
|
203
|
+
) -> Solve: ...
|
|
204
|
+
|
|
205
|
+
def create(
|
|
206
|
+
self,
|
|
207
|
+
*,
|
|
208
|
+
type: SolveType,
|
|
209
|
+
sitekey: str,
|
|
210
|
+
url: str,
|
|
211
|
+
rqdata: Optional[str] = None,
|
|
212
|
+
user_agent: Optional[str] = None,
|
|
213
|
+
proxy: Union[Proxy, str, None] = None,
|
|
214
|
+
webhook_url: Optional[str] = None,
|
|
215
|
+
wait: Optional[int] = None,
|
|
216
|
+
) -> Solve:
|
|
217
|
+
"""Submit a solve. Pass ``wait`` (1-90 seconds) to hold the connection
|
|
218
|
+
open until it finishes instead of returning a pending solve."""
|
|
219
|
+
body = _build_solve_body(
|
|
220
|
+
type=type,
|
|
221
|
+
sitekey=sitekey,
|
|
222
|
+
url=url,
|
|
223
|
+
rqdata=rqdata,
|
|
224
|
+
user_agent=user_agent,
|
|
225
|
+
proxy=proxy,
|
|
226
|
+
webhook_url=webhook_url,
|
|
227
|
+
)
|
|
228
|
+
payload = self._client._request(
|
|
229
|
+
"POST",
|
|
230
|
+
"/v1/solves",
|
|
231
|
+
params={"wait": wait} if wait is not None else None,
|
|
232
|
+
json=body,
|
|
233
|
+
wait=wait,
|
|
234
|
+
)
|
|
235
|
+
return Solve._from_dict(payload)
|
|
236
|
+
|
|
237
|
+
def retrieve(self, solve_id: str, *, wait: Optional[int] = None) -> Solve:
|
|
238
|
+
"""Fetch a solve by id. Pass ``wait`` to long-poll until it finishes."""
|
|
239
|
+
payload = self._client._request(
|
|
240
|
+
"GET",
|
|
241
|
+
f"/v1/solves/{solve_id}",
|
|
242
|
+
params={"wait": wait} if wait is not None else None,
|
|
243
|
+
wait=wait,
|
|
244
|
+
)
|
|
245
|
+
return Solve._from_dict(payload)
|
|
246
|
+
|
|
247
|
+
def cancel(self, solve_id: str) -> Solve:
|
|
248
|
+
"""Cancel a pending or in-flight solve. Cancelled solves are never charged."""
|
|
249
|
+
payload = self._client._request("DELETE", f"/v1/solves/{solve_id}")
|
|
250
|
+
return Solve._from_dict(payload)
|
|
251
|
+
|
|
252
|
+
def list(
|
|
253
|
+
self,
|
|
254
|
+
*,
|
|
255
|
+
limit: Optional[int] = None,
|
|
256
|
+
starting_after: Optional[str] = None,
|
|
257
|
+
status: Optional[SolveStatus] = None,
|
|
258
|
+
type: Optional[SolveType] = None,
|
|
259
|
+
) -> SolvePage:
|
|
260
|
+
"""Fetch one page of solves, newest first."""
|
|
261
|
+
payload = self._client._request(
|
|
262
|
+
"GET",
|
|
263
|
+
"/v1/solves",
|
|
264
|
+
params=_list_params(
|
|
265
|
+
limit=limit, starting_after=starting_after, status=status, type=type
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
return SolvePage._from_dict(payload)
|
|
269
|
+
|
|
270
|
+
def list_all(
|
|
271
|
+
self,
|
|
272
|
+
*,
|
|
273
|
+
limit: Optional[int] = None,
|
|
274
|
+
status: Optional[SolveStatus] = None,
|
|
275
|
+
type: Optional[SolveType] = None,
|
|
276
|
+
) -> Iterator[Solve]:
|
|
277
|
+
"""Iterate every solve across pages, newest first.
|
|
278
|
+
|
|
279
|
+
>>> for solve in client.solves.list_all():
|
|
280
|
+
... print(solve.id, solve.status)
|
|
281
|
+
"""
|
|
282
|
+
cursor: Optional[str] = None
|
|
283
|
+
while True:
|
|
284
|
+
page = self.list(limit=limit, starting_after=cursor, status=status, type=type)
|
|
285
|
+
yield from page.data
|
|
286
|
+
if not page.has_more or not page.data:
|
|
287
|
+
return
|
|
288
|
+
cursor = page.data[-1].id
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class NoneCap(_BaseClient):
|
|
292
|
+
"""The NoneCap API client (sync).
|
|
293
|
+
|
|
294
|
+
>>> from nonecap import NoneCap
|
|
295
|
+
>>> nc = NoneCap(api_key="nc_live_...")
|
|
296
|
+
>>> solve = nc.solve(type="hcaptcha", sitekey="...", url="https://example.com")
|
|
297
|
+
>>> solve.token
|
|
298
|
+
'P1_...'
|
|
299
|
+
"""
|
|
300
|
+
|
|
301
|
+
def __init__(
|
|
302
|
+
self,
|
|
303
|
+
*,
|
|
304
|
+
api_key: str,
|
|
305
|
+
base_url: Optional[str] = None,
|
|
306
|
+
timeout: float = DEFAULT_REQUEST_TIMEOUT,
|
|
307
|
+
http_client: Optional[httpx.Client] = None,
|
|
308
|
+
) -> None:
|
|
309
|
+
super().__init__(api_key=api_key, base_url=base_url, timeout=timeout)
|
|
310
|
+
self._http = http_client or httpx.Client()
|
|
311
|
+
self._owns_http = http_client is None
|
|
312
|
+
self.solves = Solves(self)
|
|
313
|
+
|
|
314
|
+
def _request(
|
|
315
|
+
self,
|
|
316
|
+
method: str,
|
|
317
|
+
path: str,
|
|
318
|
+
*,
|
|
319
|
+
params: Optional[dict[str, Any]] = None,
|
|
320
|
+
json: Optional[dict[str, Any]] = None,
|
|
321
|
+
wait: Optional[int] = None,
|
|
322
|
+
) -> Any:
|
|
323
|
+
try:
|
|
324
|
+
response = self._http.request(
|
|
325
|
+
method,
|
|
326
|
+
self._url(path),
|
|
327
|
+
params=params,
|
|
328
|
+
json=json,
|
|
329
|
+
headers=self._headers,
|
|
330
|
+
timeout=_request_timeout_for_wait(wait, self._timeout),
|
|
331
|
+
)
|
|
332
|
+
except httpx.TimeoutException as exc:
|
|
333
|
+
raise APITimeoutError(f"Request to {path} timed out: {exc}") from exc
|
|
334
|
+
except httpx.HTTPError as exc:
|
|
335
|
+
raise APIConnectionError(
|
|
336
|
+
f"Could not reach the NoneCap API at {self._base_url}: {exc}"
|
|
337
|
+
) from exc
|
|
338
|
+
return _process_response(response)
|
|
339
|
+
|
|
340
|
+
@overload
|
|
341
|
+
def solve(
|
|
342
|
+
self,
|
|
343
|
+
*,
|
|
344
|
+
type: Literal["hcaptcha"],
|
|
345
|
+
sitekey: str,
|
|
346
|
+
url: str,
|
|
347
|
+
rqdata: Optional[str] = None,
|
|
348
|
+
user_agent: Optional[str] = None,
|
|
349
|
+
proxy: Union[Proxy, str, None] = None,
|
|
350
|
+
webhook_url: Optional[str] = None,
|
|
351
|
+
timeout: float = DEFAULT_SOLVE_TIMEOUT,
|
|
352
|
+
) -> Solve: ...
|
|
353
|
+
|
|
354
|
+
@overload
|
|
355
|
+
def solve(
|
|
356
|
+
self,
|
|
357
|
+
*,
|
|
358
|
+
type: Literal["hcaptcha_enterprise"],
|
|
359
|
+
sitekey: str,
|
|
360
|
+
url: str,
|
|
361
|
+
rqdata: str,
|
|
362
|
+
user_agent: Optional[str] = None,
|
|
363
|
+
proxy: Union[Proxy, str, None] = None,
|
|
364
|
+
webhook_url: Optional[str] = None,
|
|
365
|
+
timeout: float = DEFAULT_SOLVE_TIMEOUT,
|
|
366
|
+
) -> Solve: ...
|
|
367
|
+
|
|
368
|
+
def solve(
|
|
369
|
+
self,
|
|
370
|
+
*,
|
|
371
|
+
type: SolveType,
|
|
372
|
+
sitekey: str,
|
|
373
|
+
url: str,
|
|
374
|
+
rqdata: Optional[str] = None,
|
|
375
|
+
user_agent: Optional[str] = None,
|
|
376
|
+
proxy: Union[Proxy, str, None] = None,
|
|
377
|
+
webhook_url: Optional[str] = None,
|
|
378
|
+
timeout: float = DEFAULT_SOLVE_TIMEOUT,
|
|
379
|
+
) -> Solve:
|
|
380
|
+
"""Submit a solve and wait for it to finish, returning the solved solve.
|
|
381
|
+
|
|
382
|
+
Uses the server's long-poll under the hood and keeps polling until the
|
|
383
|
+
solve is terminal or ``timeout`` seconds elapse. Raises
|
|
384
|
+
:class:`SolveFailedError` if the solve fails/expires/is cancelled, or
|
|
385
|
+
:class:`SolveTimeoutError` on timeout.
|
|
386
|
+
"""
|
|
387
|
+
deadline = time.monotonic() + timeout
|
|
388
|
+
# Build the body directly rather than dispatching through the
|
|
389
|
+
# overloaded create(): the union-typed passthrough args defeat
|
|
390
|
+
# overload resolution, and the runtime rqdata check lives in
|
|
391
|
+
# _build_solve_body either way.
|
|
392
|
+
body = _build_solve_body(
|
|
393
|
+
type=type,
|
|
394
|
+
sitekey=sitekey,
|
|
395
|
+
url=url,
|
|
396
|
+
rqdata=rqdata,
|
|
397
|
+
user_agent=user_agent,
|
|
398
|
+
proxy=proxy,
|
|
399
|
+
webhook_url=webhook_url,
|
|
400
|
+
)
|
|
401
|
+
wait = _wait_seconds(deadline)
|
|
402
|
+
payload = self._request(
|
|
403
|
+
"POST", "/v1/solves", params={"wait": wait}, json=body, wait=wait
|
|
404
|
+
)
|
|
405
|
+
solve = Solve._from_dict(payload)
|
|
406
|
+
while not solve.is_terminal:
|
|
407
|
+
if time.monotonic() >= deadline:
|
|
408
|
+
raise SolveTimeoutError(
|
|
409
|
+
f"Solve {solve.id} did not finish within {timeout:g}s "
|
|
410
|
+
f"(last status: {solve.status})."
|
|
411
|
+
)
|
|
412
|
+
solve = self.solves.retrieve(solve.id, wait=_wait_seconds(deadline))
|
|
413
|
+
if solve.status != "solved":
|
|
414
|
+
raise SolveFailedError(solve)
|
|
415
|
+
return solve
|
|
416
|
+
|
|
417
|
+
def me(self) -> Account:
|
|
418
|
+
"""Fetch your account, including the current credit balance."""
|
|
419
|
+
return Account._from_dict(self._request("GET", "/v1/me"))
|
|
420
|
+
|
|
421
|
+
def close(self) -> None:
|
|
422
|
+
"""Close the underlying HTTP client (only if this client created it)."""
|
|
423
|
+
if self._owns_http:
|
|
424
|
+
self._http.close()
|
|
425
|
+
|
|
426
|
+
def __enter__(self) -> NoneCap:
|
|
427
|
+
return self
|
|
428
|
+
|
|
429
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
430
|
+
self.close()
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
# ---------------------------------------------------------------------------
|
|
434
|
+
# Async
|
|
435
|
+
# ---------------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
class AsyncSolves:
|
|
439
|
+
"""Operations on solves (async). Reached as ``client.solves``."""
|
|
440
|
+
|
|
441
|
+
def __init__(self, client: AsyncNoneCap) -> None:
|
|
442
|
+
self._client = client
|
|
443
|
+
|
|
444
|
+
@overload
|
|
445
|
+
async def create(
|
|
446
|
+
self,
|
|
447
|
+
*,
|
|
448
|
+
type: Literal["hcaptcha"],
|
|
449
|
+
sitekey: str,
|
|
450
|
+
url: str,
|
|
451
|
+
rqdata: Optional[str] = None,
|
|
452
|
+
user_agent: Optional[str] = None,
|
|
453
|
+
proxy: Union[Proxy, str, None] = None,
|
|
454
|
+
webhook_url: Optional[str] = None,
|
|
455
|
+
wait: Optional[int] = None,
|
|
456
|
+
) -> Solve: ...
|
|
457
|
+
|
|
458
|
+
@overload
|
|
459
|
+
async def create(
|
|
460
|
+
self,
|
|
461
|
+
*,
|
|
462
|
+
type: Literal["hcaptcha_enterprise"],
|
|
463
|
+
sitekey: str,
|
|
464
|
+
url: str,
|
|
465
|
+
rqdata: str,
|
|
466
|
+
user_agent: Optional[str] = None,
|
|
467
|
+
proxy: Union[Proxy, str, None] = None,
|
|
468
|
+
webhook_url: Optional[str] = None,
|
|
469
|
+
wait: Optional[int] = None,
|
|
470
|
+
) -> Solve: ...
|
|
471
|
+
|
|
472
|
+
async def create(
|
|
473
|
+
self,
|
|
474
|
+
*,
|
|
475
|
+
type: SolveType,
|
|
476
|
+
sitekey: str,
|
|
477
|
+
url: str,
|
|
478
|
+
rqdata: Optional[str] = None,
|
|
479
|
+
user_agent: Optional[str] = None,
|
|
480
|
+
proxy: Union[Proxy, str, None] = None,
|
|
481
|
+
webhook_url: Optional[str] = None,
|
|
482
|
+
wait: Optional[int] = None,
|
|
483
|
+
) -> Solve:
|
|
484
|
+
"""Submit a solve. Pass ``wait`` (1-90 seconds) to hold the connection
|
|
485
|
+
open until it finishes instead of returning a pending solve."""
|
|
486
|
+
body = _build_solve_body(
|
|
487
|
+
type=type,
|
|
488
|
+
sitekey=sitekey,
|
|
489
|
+
url=url,
|
|
490
|
+
rqdata=rqdata,
|
|
491
|
+
user_agent=user_agent,
|
|
492
|
+
proxy=proxy,
|
|
493
|
+
webhook_url=webhook_url,
|
|
494
|
+
)
|
|
495
|
+
payload = await self._client._request(
|
|
496
|
+
"POST",
|
|
497
|
+
"/v1/solves",
|
|
498
|
+
params={"wait": wait} if wait is not None else None,
|
|
499
|
+
json=body,
|
|
500
|
+
wait=wait,
|
|
501
|
+
)
|
|
502
|
+
return Solve._from_dict(payload)
|
|
503
|
+
|
|
504
|
+
async def retrieve(self, solve_id: str, *, wait: Optional[int] = None) -> Solve:
|
|
505
|
+
"""Fetch a solve by id. Pass ``wait`` to long-poll until it finishes."""
|
|
506
|
+
payload = await self._client._request(
|
|
507
|
+
"GET",
|
|
508
|
+
f"/v1/solves/{solve_id}",
|
|
509
|
+
params={"wait": wait} if wait is not None else None,
|
|
510
|
+
wait=wait,
|
|
511
|
+
)
|
|
512
|
+
return Solve._from_dict(payload)
|
|
513
|
+
|
|
514
|
+
async def cancel(self, solve_id: str) -> Solve:
|
|
515
|
+
"""Cancel a pending or in-flight solve. Cancelled solves are never charged."""
|
|
516
|
+
payload = await self._client._request("DELETE", f"/v1/solves/{solve_id}")
|
|
517
|
+
return Solve._from_dict(payload)
|
|
518
|
+
|
|
519
|
+
async def list(
|
|
520
|
+
self,
|
|
521
|
+
*,
|
|
522
|
+
limit: Optional[int] = None,
|
|
523
|
+
starting_after: Optional[str] = None,
|
|
524
|
+
status: Optional[SolveStatus] = None,
|
|
525
|
+
type: Optional[SolveType] = None,
|
|
526
|
+
) -> SolvePage:
|
|
527
|
+
"""Fetch one page of solves, newest first."""
|
|
528
|
+
payload = await self._client._request(
|
|
529
|
+
"GET",
|
|
530
|
+
"/v1/solves",
|
|
531
|
+
params=_list_params(
|
|
532
|
+
limit=limit, starting_after=starting_after, status=status, type=type
|
|
533
|
+
),
|
|
534
|
+
)
|
|
535
|
+
return SolvePage._from_dict(payload)
|
|
536
|
+
|
|
537
|
+
async def list_all(
|
|
538
|
+
self,
|
|
539
|
+
*,
|
|
540
|
+
limit: Optional[int] = None,
|
|
541
|
+
status: Optional[SolveStatus] = None,
|
|
542
|
+
type: Optional[SolveType] = None,
|
|
543
|
+
) -> AsyncIterator[Solve]:
|
|
544
|
+
"""Iterate every solve across pages, newest first.
|
|
545
|
+
|
|
546
|
+
>>> async for solve in client.solves.list_all():
|
|
547
|
+
... print(solve.id, solve.status)
|
|
548
|
+
"""
|
|
549
|
+
cursor: Optional[str] = None
|
|
550
|
+
while True:
|
|
551
|
+
page = await self.list(
|
|
552
|
+
limit=limit, starting_after=cursor, status=status, type=type
|
|
553
|
+
)
|
|
554
|
+
for solve in page.data:
|
|
555
|
+
yield solve
|
|
556
|
+
if not page.has_more or not page.data:
|
|
557
|
+
return
|
|
558
|
+
cursor = page.data[-1].id
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
class AsyncNoneCap(_BaseClient):
|
|
562
|
+
"""The NoneCap API client (async).
|
|
563
|
+
|
|
564
|
+
>>> from nonecap import AsyncNoneCap
|
|
565
|
+
>>> async with AsyncNoneCap(api_key="nc_live_...") as nc:
|
|
566
|
+
... solve = await nc.solve(type="hcaptcha", sitekey="...", url="https://example.com")
|
|
567
|
+
"""
|
|
568
|
+
|
|
569
|
+
def __init__(
|
|
570
|
+
self,
|
|
571
|
+
*,
|
|
572
|
+
api_key: str,
|
|
573
|
+
base_url: Optional[str] = None,
|
|
574
|
+
timeout: float = DEFAULT_REQUEST_TIMEOUT,
|
|
575
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
576
|
+
) -> None:
|
|
577
|
+
super().__init__(api_key=api_key, base_url=base_url, timeout=timeout)
|
|
578
|
+
self._http = http_client or httpx.AsyncClient()
|
|
579
|
+
self._owns_http = http_client is None
|
|
580
|
+
self.solves = AsyncSolves(self)
|
|
581
|
+
|
|
582
|
+
async def _request(
|
|
583
|
+
self,
|
|
584
|
+
method: str,
|
|
585
|
+
path: str,
|
|
586
|
+
*,
|
|
587
|
+
params: Optional[dict[str, Any]] = None,
|
|
588
|
+
json: Optional[dict[str, Any]] = None,
|
|
589
|
+
wait: Optional[int] = None,
|
|
590
|
+
) -> Any:
|
|
591
|
+
try:
|
|
592
|
+
response = await self._http.request(
|
|
593
|
+
method,
|
|
594
|
+
self._url(path),
|
|
595
|
+
params=params,
|
|
596
|
+
json=json,
|
|
597
|
+
headers=self._headers,
|
|
598
|
+
timeout=_request_timeout_for_wait(wait, self._timeout),
|
|
599
|
+
)
|
|
600
|
+
except httpx.TimeoutException as exc:
|
|
601
|
+
raise APITimeoutError(f"Request to {path} timed out: {exc}") from exc
|
|
602
|
+
except httpx.HTTPError as exc:
|
|
603
|
+
raise APIConnectionError(
|
|
604
|
+
f"Could not reach the NoneCap API at {self._base_url}: {exc}"
|
|
605
|
+
) from exc
|
|
606
|
+
return _process_response(response)
|
|
607
|
+
|
|
608
|
+
@overload
|
|
609
|
+
async def solve(
|
|
610
|
+
self,
|
|
611
|
+
*,
|
|
612
|
+
type: Literal["hcaptcha"],
|
|
613
|
+
sitekey: str,
|
|
614
|
+
url: str,
|
|
615
|
+
rqdata: Optional[str] = None,
|
|
616
|
+
user_agent: Optional[str] = None,
|
|
617
|
+
proxy: Union[Proxy, str, None] = None,
|
|
618
|
+
webhook_url: Optional[str] = None,
|
|
619
|
+
timeout: float = DEFAULT_SOLVE_TIMEOUT,
|
|
620
|
+
) -> Solve: ...
|
|
621
|
+
|
|
622
|
+
@overload
|
|
623
|
+
async def solve(
|
|
624
|
+
self,
|
|
625
|
+
*,
|
|
626
|
+
type: Literal["hcaptcha_enterprise"],
|
|
627
|
+
sitekey: str,
|
|
628
|
+
url: str,
|
|
629
|
+
rqdata: str,
|
|
630
|
+
user_agent: Optional[str] = None,
|
|
631
|
+
proxy: Union[Proxy, str, None] = None,
|
|
632
|
+
webhook_url: Optional[str] = None,
|
|
633
|
+
timeout: float = DEFAULT_SOLVE_TIMEOUT,
|
|
634
|
+
) -> Solve: ...
|
|
635
|
+
|
|
636
|
+
async def solve(
|
|
637
|
+
self,
|
|
638
|
+
*,
|
|
639
|
+
type: SolveType,
|
|
640
|
+
sitekey: str,
|
|
641
|
+
url: str,
|
|
642
|
+
rqdata: Optional[str] = None,
|
|
643
|
+
user_agent: Optional[str] = None,
|
|
644
|
+
proxy: Union[Proxy, str, None] = None,
|
|
645
|
+
webhook_url: Optional[str] = None,
|
|
646
|
+
timeout: float = DEFAULT_SOLVE_TIMEOUT,
|
|
647
|
+
) -> Solve:
|
|
648
|
+
"""Submit a solve and wait for it to finish, returning the solved solve.
|
|
649
|
+
|
|
650
|
+
Uses the server's long-poll under the hood and keeps polling until the
|
|
651
|
+
solve is terminal or ``timeout`` seconds elapse. Raises
|
|
652
|
+
:class:`SolveFailedError` if the solve fails/expires/is cancelled, or
|
|
653
|
+
:class:`SolveTimeoutError` on timeout.
|
|
654
|
+
"""
|
|
655
|
+
deadline = time.monotonic() + timeout
|
|
656
|
+
# Same shape as the sync client: build the body directly instead of
|
|
657
|
+
# dispatching through the overloaded create() with union-typed args.
|
|
658
|
+
body = _build_solve_body(
|
|
659
|
+
type=type,
|
|
660
|
+
sitekey=sitekey,
|
|
661
|
+
url=url,
|
|
662
|
+
rqdata=rqdata,
|
|
663
|
+
user_agent=user_agent,
|
|
664
|
+
proxy=proxy,
|
|
665
|
+
webhook_url=webhook_url,
|
|
666
|
+
)
|
|
667
|
+
wait = _wait_seconds(deadline)
|
|
668
|
+
payload = await self._request(
|
|
669
|
+
"POST", "/v1/solves", params={"wait": wait}, json=body, wait=wait
|
|
670
|
+
)
|
|
671
|
+
solve = Solve._from_dict(payload)
|
|
672
|
+
while not solve.is_terminal:
|
|
673
|
+
if time.monotonic() >= deadline:
|
|
674
|
+
raise SolveTimeoutError(
|
|
675
|
+
f"Solve {solve.id} did not finish within {timeout:g}s "
|
|
676
|
+
f"(last status: {solve.status})."
|
|
677
|
+
)
|
|
678
|
+
solve = await self.solves.retrieve(solve.id, wait=_wait_seconds(deadline))
|
|
679
|
+
if solve.status != "solved":
|
|
680
|
+
raise SolveFailedError(solve)
|
|
681
|
+
return solve
|
|
682
|
+
|
|
683
|
+
async def me(self) -> Account:
|
|
684
|
+
"""Fetch your account, including the current credit balance."""
|
|
685
|
+
return Account._from_dict(await self._request("GET", "/v1/me"))
|
|
686
|
+
|
|
687
|
+
async def close(self) -> None:
|
|
688
|
+
"""Close the underlying HTTP client (only if this client created it)."""
|
|
689
|
+
if self._owns_http:
|
|
690
|
+
await self._http.aclose()
|
|
691
|
+
|
|
692
|
+
async def __aenter__(self) -> AsyncNoneCap:
|
|
693
|
+
return self
|
|
694
|
+
|
|
695
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
696
|
+
await self.close()
|
nonecap/_errors.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""The error tree. Catch :class:`NoneCapError` for everything this library
|
|
2
|
+
throws, or a subclass for one specific failure."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import TYPE_CHECKING, Optional
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from ._types import Solve
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NoneCapError(Exception):
|
|
13
|
+
"""Base class for every error raised by this library."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
message: str,
|
|
18
|
+
*,
|
|
19
|
+
code: Optional[str] = None,
|
|
20
|
+
status: Optional[int] = None,
|
|
21
|
+
param: Optional[str] = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.message = message
|
|
25
|
+
self.code = code
|
|
26
|
+
"""Machine-readable error code from the API envelope, when there is one."""
|
|
27
|
+
self.status = status
|
|
28
|
+
"""HTTP status, when the error came from a response."""
|
|
29
|
+
self.param = param
|
|
30
|
+
"""The request field that was rejected, for validation errors."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AuthenticationError(NoneCapError):
|
|
34
|
+
"""401 — the API key is missing, malformed, or revoked."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PermissionDeniedError(NoneCapError):
|
|
38
|
+
"""403 — the key is valid but not allowed to do this (scope or locked account)."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class InsufficientCreditsError(NoneCapError):
|
|
42
|
+
"""402 — the account is out of credits."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ValidationError(NoneCapError):
|
|
46
|
+
"""422 / 400 — the request was rejected. ``param`` names the offending field."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class NotFoundError(NoneCapError):
|
|
50
|
+
"""404 — no such resource."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ConflictError(NoneCapError):
|
|
54
|
+
"""409 — the solve is already in a terminal state."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class RateLimitError(NoneCapError):
|
|
58
|
+
"""429 — too many concurrent solves, or rate limited. Back off and retry."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class APIError(NoneCapError):
|
|
62
|
+
"""5xx, or a response that was not the expected shape."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class APIConnectionError(NoneCapError):
|
|
66
|
+
"""The request never reached the API (DNS, TCP, TLS, offline)."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class APITimeoutError(APIConnectionError):
|
|
70
|
+
"""A single HTTP request exceeded its timeout."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class SolveTimeoutError(NoneCapError):
|
|
74
|
+
"""Raised by ``solve()`` when the overall timeout elapses first."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SolveFailedError(NoneCapError):
|
|
78
|
+
"""Raised by ``solve()`` when a solve reaches a terminal state without a
|
|
79
|
+
token: ``failed``, ``expired``, or ``cancelled``. The full solve is attached
|
|
80
|
+
as ``.solve`` so you can inspect ``solve.error`` and the timings."""
|
|
81
|
+
|
|
82
|
+
def __init__(self, solve: Solve) -> None:
|
|
83
|
+
detail = f"{solve.error.code}: {solve.error.message}" if solve.error else solve.status
|
|
84
|
+
super().__init__(f"Solve {solve.id} {solve.status} ({detail})")
|
|
85
|
+
self.solve = solve
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def error_from_response(
|
|
89
|
+
status: int,
|
|
90
|
+
code: Optional[str],
|
|
91
|
+
message: str,
|
|
92
|
+
param: Optional[str],
|
|
93
|
+
) -> NoneCapError:
|
|
94
|
+
"""Map an API error envelope (plus HTTP status) to the right subclass."""
|
|
95
|
+
cls: type[NoneCapError]
|
|
96
|
+
if code == "unauthorized":
|
|
97
|
+
cls = AuthenticationError
|
|
98
|
+
elif code in ("forbidden", "account_locked"):
|
|
99
|
+
cls = PermissionDeniedError
|
|
100
|
+
elif code == "insufficient_credits":
|
|
101
|
+
cls = InsufficientCreditsError
|
|
102
|
+
elif code in ("invalid_request", "validation_error"):
|
|
103
|
+
cls = ValidationError
|
|
104
|
+
elif code == "not_found":
|
|
105
|
+
cls = NotFoundError
|
|
106
|
+
elif code == "conflict":
|
|
107
|
+
cls = ConflictError
|
|
108
|
+
elif code in ("rate_limited", "concurrency_limit_exceeded", "ext_daily_limit"):
|
|
109
|
+
cls = RateLimitError
|
|
110
|
+
else:
|
|
111
|
+
cls = APIError
|
|
112
|
+
return cls(message, code=code, status=status, param=param)
|
nonecap/_types.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Wire types for the NoneCap API.
|
|
2
|
+
|
|
3
|
+
Field names are snake_case and mirror the JSON on the wire exactly, so what
|
|
4
|
+
you read in the docs is what you access in code. Parsers pick known keys and
|
|
5
|
+
ignore unknown ones, so new server-side fields never break old clients.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any, Literal, Optional, TypedDict
|
|
12
|
+
|
|
13
|
+
SolveType = Literal["hcaptcha", "hcaptcha_enterprise"]
|
|
14
|
+
"""Captcha type a solve targets."""
|
|
15
|
+
|
|
16
|
+
SolveStatus = Literal["pending", "solving", "solved", "failed", "cancelled", "expired"]
|
|
17
|
+
"""Lifecycle of a solve. ``solved``/``failed``/``cancelled``/``expired`` are terminal."""
|
|
18
|
+
|
|
19
|
+
TERMINAL_STATUSES: frozenset[str] = frozenset({"solved", "failed", "cancelled", "expired"})
|
|
20
|
+
"""The statuses a solve can never leave."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Proxy(TypedDict, total=False):
|
|
24
|
+
"""A proxy the solve should egress through."""
|
|
25
|
+
|
|
26
|
+
scheme: str
|
|
27
|
+
host: str
|
|
28
|
+
port: str | int
|
|
29
|
+
username: str
|
|
30
|
+
password: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class SolveError:
|
|
35
|
+
"""The error attached to a solve that did not succeed."""
|
|
36
|
+
|
|
37
|
+
code: str
|
|
38
|
+
message: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Solve:
|
|
43
|
+
"""A solve resource, exactly as the API returns it."""
|
|
44
|
+
|
|
45
|
+
id: str
|
|
46
|
+
object: str
|
|
47
|
+
type: SolveType
|
|
48
|
+
status: SolveStatus
|
|
49
|
+
sitekey: str
|
|
50
|
+
url: str
|
|
51
|
+
token: Optional[str]
|
|
52
|
+
"""The captcha token once ``status == "solved"``, otherwise None."""
|
|
53
|
+
error: Optional[SolveError]
|
|
54
|
+
"""Set when the solve did not succeed, otherwise None."""
|
|
55
|
+
credits_charged: Optional[int]
|
|
56
|
+
"""Credits charged for this solve. Only successful solves are charged."""
|
|
57
|
+
proxy_bytes: Optional[int]
|
|
58
|
+
"""Bytes that egressed through the metered proxy, or None if none was used."""
|
|
59
|
+
created_at: str
|
|
60
|
+
started_at: Optional[str]
|
|
61
|
+
finished_at: Optional[str]
|
|
62
|
+
queue_ms: Optional[int]
|
|
63
|
+
"""Milliseconds the solve waited in the queue before a worker picked it up."""
|
|
64
|
+
resolve_ms: Optional[int]
|
|
65
|
+
"""Milliseconds of actual solving."""
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def is_terminal(self) -> bool:
|
|
69
|
+
"""Whether the solve has reached a final state."""
|
|
70
|
+
return self.status in TERMINAL_STATUSES
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def _from_dict(cls, data: dict[str, Any]) -> Solve:
|
|
74
|
+
raw_error = data.get("error")
|
|
75
|
+
return cls(
|
|
76
|
+
id=data["id"],
|
|
77
|
+
object=data.get("object", "solve"),
|
|
78
|
+
type=data["type"],
|
|
79
|
+
status=data["status"],
|
|
80
|
+
sitekey=data.get("sitekey", ""),
|
|
81
|
+
url=data.get("url", ""),
|
|
82
|
+
token=data.get("token"),
|
|
83
|
+
error=SolveError(code=raw_error["code"], message=raw_error["message"])
|
|
84
|
+
if raw_error
|
|
85
|
+
else None,
|
|
86
|
+
credits_charged=data.get("credits_charged"),
|
|
87
|
+
proxy_bytes=data.get("proxy_bytes"),
|
|
88
|
+
created_at=data.get("created_at", ""),
|
|
89
|
+
started_at=data.get("started_at"),
|
|
90
|
+
finished_at=data.get("finished_at"),
|
|
91
|
+
queue_ms=data.get("queue_ms"),
|
|
92
|
+
resolve_ms=data.get("resolve_ms"),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class SolvePage:
|
|
98
|
+
"""One page of solves, newest first."""
|
|
99
|
+
|
|
100
|
+
data: list[Solve]
|
|
101
|
+
has_more: bool
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def _from_dict(cls, payload: dict[str, Any]) -> SolvePage:
|
|
105
|
+
return cls(
|
|
106
|
+
data=[Solve._from_dict(item) for item in payload.get("data", [])],
|
|
107
|
+
has_more=bool(payload.get("has_more", False)),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class Account:
|
|
113
|
+
"""Your account, including the current credit balance."""
|
|
114
|
+
|
|
115
|
+
id: str
|
|
116
|
+
email: str
|
|
117
|
+
credits_balance: int
|
|
118
|
+
created_at: str
|
|
119
|
+
|
|
120
|
+
@classmethod
|
|
121
|
+
def _from_dict(cls, data: dict[str, Any]) -> Account:
|
|
122
|
+
return cls(
|
|
123
|
+
id=data["id"],
|
|
124
|
+
email=data.get("email", ""),
|
|
125
|
+
credits_balance=int(data.get("credits_balance", 0)),
|
|
126
|
+
created_at=data.get("created_at", ""),
|
|
127
|
+
)
|
nonecap/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
nonecap/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nonecap
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python client for the NoneCap hCaptcha solving API.
|
|
5
|
+
Project-URL: Homepage, https://nonecap.com
|
|
6
|
+
Project-URL: Documentation, https://nonecap.com/api-reference
|
|
7
|
+
Project-URL: Repository, https://github.com/nonecap/nonecap-py
|
|
8
|
+
Project-URL: Issues, https://github.com/nonecap/nonecap-py/issues
|
|
9
|
+
Author: Lunium
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: api-client,captcha,captcha-solver,hcaptcha,hcaptcha-solver,nonecap,sdk
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Requires-Dist: httpx>=0.24
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: mypy>=1.13; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
<h1 align="center">nonecap</h1>
|
|
33
|
+
|
|
34
|
+
<p align="center">
|
|
35
|
+
<a href="https://github.com/nonecap/nonecap-py/actions/workflows/ci.yml"><img src="https://github.com/nonecap/nonecap-py/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
36
|
+
<a href="https://pypi.org/project/nonecap/"><img src="https://img.shields.io/pypi/v/nonecap.svg" alt="PyPI"></a>
|
|
37
|
+
<a href="https://pypi.org/project/nonecap/"><img src="https://img.shields.io/pypi/pyversions/nonecap.svg" alt="Python versions"></a>
|
|
38
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT"></a>
|
|
39
|
+
</p>
|
|
40
|
+
|
|
41
|
+
<p align="center">Official Python client for the <a href="https://nonecap.com">NoneCap</a> hCaptcha solving API.</p>
|
|
42
|
+
|
|
43
|
+
Submit a captcha, get back a token. The client handles the polling, the timeouts, and the error cases so you don't write the request loop yourself. Sync and async, fully typed.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
pip install nonecap
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Python 3.9+. The only dependency is [httpx](https://www.python-httpx.org/).
|
|
52
|
+
|
|
53
|
+
## Quick start
|
|
54
|
+
|
|
55
|
+
Grab an API key from [dashboard.nonecap.com](https://dashboard.nonecap.com), then:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from nonecap import NoneCap
|
|
59
|
+
|
|
60
|
+
nc = NoneCap(api_key="nc_live_...")
|
|
61
|
+
|
|
62
|
+
solve = nc.solve(
|
|
63
|
+
type="hcaptcha",
|
|
64
|
+
sitekey="10000000-ffff-ffff-ffff-000000000001",
|
|
65
|
+
url="https://example.com/login",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
print(solve.token) # the hCaptcha token, ready to submit
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`solve()` submits the captcha and waits until it's done, using the API's long-poll so you aren't hammering it with requests. It returns the solved solve, or raises if the solve fails or your timeout runs out.
|
|
72
|
+
|
|
73
|
+
## Async
|
|
74
|
+
|
|
75
|
+
Same surface, `await`ed. Use it as an async context manager so the connection pool gets cleaned up:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
import asyncio
|
|
79
|
+
from nonecap import AsyncNoneCap
|
|
80
|
+
|
|
81
|
+
async def main() -> None:
|
|
82
|
+
async with AsyncNoneCap(api_key="nc_live_...") as nc:
|
|
83
|
+
solve = await nc.solve(type="hcaptcha", sitekey="...", url="https://example.com")
|
|
84
|
+
print(solve.token)
|
|
85
|
+
|
|
86
|
+
asyncio.run(main())
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Handling failures
|
|
90
|
+
|
|
91
|
+
Every error this library raises extends `NoneCapError`, so you can catch the whole family or pick out the one you care about.
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from nonecap import (
|
|
95
|
+
NoneCap,
|
|
96
|
+
SolveFailedError,
|
|
97
|
+
InsufficientCreditsError,
|
|
98
|
+
RateLimitError,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
solve = nc.solve(type="hcaptcha", sitekey=sitekey, url=url)
|
|
103
|
+
except SolveFailedError as err:
|
|
104
|
+
print("Could not solve it:", err.solve.error.code if err.solve.error else "?")
|
|
105
|
+
except InsufficientCreditsError:
|
|
106
|
+
print("Out of credits. Top up at dashboard.nonecap.com")
|
|
107
|
+
except RateLimitError:
|
|
108
|
+
print("Too many solves in flight, back off and retry")
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The subclasses are `AuthenticationError` (401), `PermissionDeniedError` (403), `InsufficientCreditsError` (402), `ValidationError` (422/400, with a `param` naming the bad field), `NotFoundError` (404), `ConflictError` (409), `RateLimitError` (429), `APIError` (5xx), `APIConnectionError` and `APITimeoutError` (the request never landed), and `SolveTimeoutError` (your `solve()` budget ran out). `SolveFailedError` carries the full `solve` so you can read the underlying error code and the timings.
|
|
112
|
+
|
|
113
|
+
## Enterprise captchas
|
|
114
|
+
|
|
115
|
+
For `hcaptcha_enterprise`, `rqdata` is required. The `@overload` signatures enforce that in mypy and pyright, so leaving it out fails your type check, and a runtime check backs it up before any network call:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
solve = nc.solve(
|
|
119
|
+
type="hcaptcha_enterprise",
|
|
120
|
+
sitekey=sitekey,
|
|
121
|
+
url=url,
|
|
122
|
+
rqdata="...", # required for enterprise
|
|
123
|
+
)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Proxies
|
|
127
|
+
|
|
128
|
+
Pass a proxy as a dict or a URL string. The solve runs through it, and the bytes are metered back on the solve.
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
nc.solve(
|
|
132
|
+
type="hcaptcha",
|
|
133
|
+
sitekey=sitekey,
|
|
134
|
+
url=url,
|
|
135
|
+
proxy={"scheme": "http", "host": "1.2.3.4", "port": 8080, "username": "u", "password": "p"},
|
|
136
|
+
# or: proxy="http://u:p@1.2.3.4:8080"
|
|
137
|
+
)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Lower-level API
|
|
141
|
+
|
|
142
|
+
`solve()` is the convenient path. When you want control over submission and polling, the resource methods map one to one to the REST API:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
# Submit without waiting: returns immediately with a pending solve
|
|
146
|
+
pending = nc.solves.create(type="hcaptcha", sitekey=sitekey, url=url)
|
|
147
|
+
|
|
148
|
+
# Submit and hold the connection up to 30s for it to finish
|
|
149
|
+
maybe_done = nc.solves.create(type="hcaptcha", sitekey=sitekey, url=url, wait=30)
|
|
150
|
+
|
|
151
|
+
# Poll one solve, long-polling up to 30s
|
|
152
|
+
solve = nc.solves.retrieve(pending.id, wait=30)
|
|
153
|
+
|
|
154
|
+
# Cancel a pending or in-flight solve
|
|
155
|
+
nc.solves.cancel(pending.id)
|
|
156
|
+
|
|
157
|
+
# List a page of solves
|
|
158
|
+
page = nc.solves.list(limit=50, status="solved")
|
|
159
|
+
|
|
160
|
+
# Or iterate every solve, newest first
|
|
161
|
+
for s in nc.solves.list_all():
|
|
162
|
+
print(s.id, s.status)
|
|
163
|
+
|
|
164
|
+
# Your account and credit balance
|
|
165
|
+
me = nc.me()
|
|
166
|
+
print(me.credits_balance)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
On `AsyncNoneCap` the same methods are coroutines, and `list_all()` is an async iterator (`async for s in nc.solves.list_all()`).
|
|
170
|
+
|
|
171
|
+
## Configuration
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
NoneCap(
|
|
175
|
+
api_key="nc_live_...", # required
|
|
176
|
+
base_url="https://api.nonecap.com", # override if you need to
|
|
177
|
+
timeout=100.0, # per HTTP request, seconds
|
|
178
|
+
http_client=my_httpx_client, # inject your own httpx.Client
|
|
179
|
+
)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
`solve()` takes its own `timeout` (seconds, default 180) for the overall wait.
|
|
183
|
+
|
|
184
|
+
## Typing
|
|
185
|
+
|
|
186
|
+
The package ships a `py.typed` marker and full inline annotations. Solves come back as frozen dataclasses with the exact field names the API uses (`solve.token`, `solve.credits_charged`, `solve.queue_ms`), so what you read in the [API reference](https://nonecap.com/api-reference) is what you get in code.
|
|
187
|
+
|
|
188
|
+
## License
|
|
189
|
+
|
|
190
|
+
MIT, see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
nonecap/__init__.py,sha256=y5acaR3Vuo7M2XTlwIUr0RiXoDVUb6vokkpDtkLEy-w,1331
|
|
2
|
+
nonecap/_client.py,sha256=7wsFElFydzgPF6EB9GBCz63nMPr1ONHush9whXRYwKw,22313
|
|
3
|
+
nonecap/_errors.py,sha256=iebsKERVgKmt03KvWzsXRPQZGVM4EvH3qJTdIYK4fiY,3476
|
|
4
|
+
nonecap/_types.py,sha256=YdFXAY-R4KIWybO5PjSdPdApgeLgNgUDOgrQZQPQgxg,3873
|
|
5
|
+
nonecap/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
6
|
+
nonecap/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
nonecap-0.1.0.dist-info/METADATA,sha256=3Hw0Tub5tJxixETEdSsTejvSzq2BYL_pkHW_SHa37T0,6812
|
|
8
|
+
nonecap-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
9
|
+
nonecap-0.1.0.dist-info/licenses/LICENSE,sha256=u5uegtPBAfHVIaKG03gcXYSOhKo8MI2_LwnxSTnZI38,1063
|
|
10
|
+
nonecap-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lunium
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|