capsolver-core 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.
@@ -0,0 +1,163 @@
1
+ """CapsolverClient — the pure-Python token-solving core.
2
+
3
+ Mirrors the Node SDK's core/client.ts. Responsible only for
4
+ ``/createTask``, ``/getTaskResult`` polling and ``/getBalance``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import time
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Callable, NoReturn
13
+
14
+ from capsolver_core.core.errors import CapsolverError, CapsolverTimeoutError, RateLimitError
15
+ from capsolver_core.core.http import FetchHttp
16
+ from capsolver_core.core.types import BalanceResp
17
+
18
+
19
+ @dataclass
20
+ class WaitOptions:
21
+ """Per-call overrides for polling behaviour."""
22
+
23
+ timeout: float | None = None
24
+ polling_interval: float | None = None
25
+ cancel_event: asyncio.Event | None = None
26
+
27
+
28
+ @dataclass
29
+ class CapsolverClientOptions:
30
+ api_key: str = ""
31
+ service: str = "https://api.capsolver.com"
32
+ default_timeout: float = 120.0
33
+ polling_interval: float = 5.0
34
+ request_timeout_ms: int = 30_000
35
+ app_id: str | None = None
36
+ source: str | None = None
37
+ version: str | None = None
38
+ on_error: Callable[[CapsolverError], None] | None = field(default=None, repr=False)
39
+
40
+
41
+ class CapsolverClient:
42
+ """Token-solving client — create tasks, poll for results, check balance."""
43
+
44
+ def __init__(self, options: CapsolverClientOptions | None = None, **kwargs: Any) -> None:
45
+ opts = options or CapsolverClientOptions(**kwargs)
46
+ if not opts.api_key:
47
+ raise CapsolverError("CapsolverClient: apiKey is required")
48
+ self._options = opts
49
+ self._http = FetchHttp(opts.service)
50
+
51
+ # ── public API ────────────────────────────────────────────────
52
+
53
+ async def aclose(self) -> None:
54
+ """Close the underlying HTTP client and release connections."""
55
+ await self._http.aclose()
56
+
57
+ async def get_balance(self) -> BalanceResp:
58
+ res = await self._http.post(
59
+ "/getBalance",
60
+ {"clientKey": self._options.api_key},
61
+ timeout_ms=self._options.request_timeout_ms,
62
+ )
63
+ data = self._unwrap(res.status, res.data, "getBalance failed")
64
+ return BalanceResp.from_dict(data)
65
+
66
+ async def create_task(self, task: dict[str, Any], **extra: Any) -> dict[str, Any]:
67
+ body: dict[str, Any] = {
68
+ "clientKey": self._options.api_key,
69
+ "task": task,
70
+ }
71
+ if extra.get("app_id") or self._options.app_id:
72
+ body["appId"] = extra.get("app_id") or self._options.app_id
73
+ if extra.get("source") or self._options.source:
74
+ body["source"] = extra.get("source") or self._options.source
75
+ if extra.get("version") or self._options.version:
76
+ body["version"] = extra.get("version") or self._options.version
77
+
78
+ res = await self._http.post(
79
+ "/createTask",
80
+ body,
81
+ timeout_ms=self._options.request_timeout_ms,
82
+ )
83
+ data = self._unwrap(res.status, res.data, "createTask failed")
84
+ if not data.get("taskId"):
85
+ self._fail(CapsolverError("createTask returned an empty taskId"))
86
+ return data
87
+
88
+ async def get_task_solution(self, task_id: str) -> dict[str, Any]:
89
+ res = await self._http.post(
90
+ "/getTaskResult",
91
+ {"clientKey": self._options.api_key, "taskId": task_id},
92
+ timeout_ms=self._options.request_timeout_ms,
93
+ )
94
+ return self._unwrap(res.status, res.data, "getTaskResult failed")
95
+
96
+ async def create_task_result(
97
+ self,
98
+ task: dict[str, Any],
99
+ wait_options: WaitOptions | None = None,
100
+ ) -> dict[str, Any]:
101
+ """Create a task and poll until ``ready``, throwing on ``failed`` or timeout."""
102
+ timeout = (
103
+ wait_options.timeout if wait_options and wait_options.timeout is not None else self._options.default_timeout
104
+ )
105
+ interval = (
106
+ wait_options.polling_interval
107
+ if wait_options and wait_options.polling_interval is not None
108
+ else self._options.polling_interval
109
+ )
110
+ cancel = wait_options.cancel_event if wait_options else None
111
+
112
+ result = await self.create_task(task)
113
+ task_id = result.get("taskId", "")
114
+ started_at = time.monotonic()
115
+
116
+ # Poll immediately before the first sleep — the task may already be ready
117
+ # (e.g. simple captcha types or cached solutions).
118
+ while True:
119
+ if cancel and cancel.is_set():
120
+ self._fail(CapsolverError("Polling aborted", error_code="ABORTED"))
121
+
122
+ if time.monotonic() - started_at > timeout:
123
+ self._fail(CapsolverTimeoutError(timeout, task_id))
124
+
125
+ state = await self.get_task_solution(task_id)
126
+ status = state.get("status")
127
+
128
+ if status == "ready":
129
+ return state
130
+ if status == "failed":
131
+ self._fail(
132
+ CapsolverError(
133
+ state.get("errorDescription") or "Task failed",
134
+ error_id=state.get("errorId"),
135
+ error_code=state.get("errorCode"),
136
+ error_description=state.get("errorDescription"),
137
+ )
138
+ )
139
+
140
+ await asyncio.sleep(interval)
141
+
142
+ # ── internals ─────────────────────────────────────────────────
143
+
144
+ def _unwrap(self, http_status: int, data: dict[str, Any], fallback_message: str) -> dict[str, Any]:
145
+ if http_status != 200 or (data and (data.get("errorId") or data.get("errorCode"))):
146
+ error_msg = data.get("errorDescription") if data else fallback_message
147
+ message = str(error_msg or fallback_message)
148
+ error_kwargs: dict[str, Any] = {
149
+ "error_id": data.get("errorId") if data else None,
150
+ "error_code": data.get("errorCode") if data else None,
151
+ "error_description": data.get("errorDescription") if data else None,
152
+ "http_status": http_status,
153
+ }
154
+ if http_status == 429:
155
+ self._fail(RateLimitError(message or "Rate limit exceeded", **error_kwargs))
156
+ else:
157
+ self._fail(CapsolverError(message, **error_kwargs))
158
+ return data
159
+
160
+ def _fail(self, error: CapsolverError) -> NoReturn:
161
+ if self._options.on_error:
162
+ self._options.on_error(error)
163
+ raise error
@@ -0,0 +1,55 @@
1
+ """Error types for the CapSolver SDK.
2
+
3
+ Mirrors the Node SDK's core/errors.ts.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+
11
+ class CapsolverError(Exception):
12
+ """Raised when the CapSolver API returns an error envelope or an unexpected HTTP status."""
13
+
14
+ def __init__(
15
+ self,
16
+ message: str,
17
+ *,
18
+ error_id: int | None = None,
19
+ error_code: str | None = None,
20
+ error_description: str | None = None,
21
+ http_status: int | None = None,
22
+ ) -> None:
23
+ super().__init__(message)
24
+ self.error_id = error_id
25
+ self.error_code = error_code
26
+ self.error_description = error_description
27
+ self.http_status = http_status
28
+
29
+
30
+ class CapsolverTimeoutError(CapsolverError):
31
+ """Raised when polling exceeds the configured timeout."""
32
+
33
+ def __init__(self, timeout_seconds: float, task_id: str | None = None) -> None:
34
+ super().__init__(f"Timeout of {timeout_seconds}s reached while waiting for task result")
35
+ self.task_id = task_id
36
+
37
+
38
+ class NetworkError(CapsolverError):
39
+ """Raised when the CapSolver API is unreachable after all retries.
40
+
41
+ Wraps connection-level failures (DNS, TCP, TLS, read timeout) so that
42
+ callers can distinguish "server said no" from "couldn't reach server".
43
+ """
44
+
45
+ def __init__(self, message: str, *, cause: Exception | None = None) -> None:
46
+ super().__init__(message)
47
+ self.cause = cause
48
+
49
+
50
+ class RateLimitError(CapsolverError):
51
+ """Raised when the API returns HTTP 429 (Too Many Requests)."""
52
+
53
+ def __init__(self, message: str = "Rate limit exceeded", **kwargs: Any) -> None:
54
+ kwargs.setdefault("http_status", 429)
55
+ super().__init__(message, **kwargs)
@@ -0,0 +1,137 @@
1
+ """Thin ``httpx`` wrapper around the CapSolver JSON API.
2
+
3
+ Mirrors the Node SDK's core/http.ts. Uses ``httpx`` so both sync and
4
+ async callers are supported without pulling in extra dependencies.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import logging
11
+ from dataclasses import dataclass, field
12
+ from typing import Any
13
+
14
+ import httpx
15
+
16
+ from capsolver_core.core.errors import NetworkError
17
+
18
+ logger = logging.getLogger("capsolver_core")
19
+
20
+
21
+ @dataclass
22
+ class HttpResp:
23
+ """Normalized HTTP response."""
24
+
25
+ status: int
26
+ status_text: str
27
+ data: Any
28
+ headers: httpx.Headers = field(repr=False)
29
+
30
+
31
+ # Transient status codes that should trigger an automatic retry.
32
+ _RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
33
+
34
+
35
+ class FetchHttp:
36
+ """Minimal async HTTP client for the CapSolver JSON API.
37
+
38
+ Features:
39
+ - Reuses a single ``httpx.AsyncClient`` for connection pooling.
40
+ - Retries on transient HTTP errors (429, 5xx) with exponential backoff.
41
+ - Gracefully handles non-JSON responses instead of crashing.
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ base_url: str = "https://api.capsolver.com",
47
+ *,
48
+ max_retries: int = 3,
49
+ retry_backoff: float = 1.0,
50
+ ) -> None:
51
+ self.base_url = base_url.rstrip("/")
52
+ self._max_retries = max_retries
53
+ self._retry_backoff = retry_backoff
54
+ self._client: httpx.AsyncClient | None = None
55
+
56
+ def get_url(self, path: str) -> str:
57
+ if not path.startswith("/"):
58
+ path = "/" + path
59
+ return self.base_url + path
60
+
61
+ async def _get_client(self, timeout_s: float) -> httpx.AsyncClient:
62
+ """Return the shared client, (re)creating it if the timeout changed."""
63
+ if self._client is None or self._client.is_closed:
64
+ self._client = httpx.AsyncClient(timeout=timeout_s)
65
+ return self._client
66
+
67
+ async def aclose(self) -> None:
68
+ """Close the underlying HTTP client. Safe to call multiple times."""
69
+ if self._client is not None and not self._client.is_closed:
70
+ await self._client.aclose()
71
+ self._client = None
72
+
73
+ async def post(
74
+ self,
75
+ path: str,
76
+ body: Any,
77
+ *,
78
+ timeout_ms: int = 30_000,
79
+ headers: dict[str, str] | None = None,
80
+ ) -> HttpResp:
81
+ merged_headers = {"Content-Type": "application/json"}
82
+ if headers:
83
+ merged_headers.update(headers)
84
+
85
+ url = self.get_url(path)
86
+ timeout_s = timeout_ms / 1000.0
87
+ last_exc: Exception | None = None
88
+
89
+ for attempt in range(1, self._max_retries + 1):
90
+ try:
91
+ client = await self._get_client(timeout_s)
92
+ resp = await client.post(url, json=body, headers=merged_headers)
93
+
94
+ # Retry on transient server errors
95
+ if resp.status_code in _RETRY_STATUSES and attempt < self._max_retries:
96
+ delay = self._retry_backoff * (2 ** (attempt - 1))
97
+ logger.warning(
98
+ "Transient HTTP %d on %s (attempt %d/%d), retrying in %.1fs",
99
+ resp.status_code, path, attempt, self._max_retries, delay,
100
+ )
101
+ await asyncio.sleep(delay)
102
+ continue
103
+
104
+ # Parse JSON safely — non-JSON bodies (e.g. HTML 502 page)
105
+ # should produce an empty dict, not crash.
106
+ try:
107
+ data = resp.json()
108
+ except (ValueError, TypeError):
109
+ logger.warning(
110
+ "Non-JSON response (HTTP %d) from %s, treating as empty",
111
+ resp.status_code, path,
112
+ )
113
+ data = {}
114
+
115
+ return HttpResp(
116
+ status=resp.status_code,
117
+ status_text=resp.reason_phrase or "",
118
+ data=data,
119
+ headers=resp.headers,
120
+ )
121
+
122
+ except (httpx.ConnectError, httpx.ReadError, httpx.TimeoutException) as exc:
123
+ last_exc = exc
124
+ if attempt < self._max_retries:
125
+ delay = self._retry_backoff * (2 ** (attempt - 1))
126
+ logger.warning(
127
+ "Network error on %s: %s (attempt %d/%d), retrying in %.1fs",
128
+ path, exc, attempt, self._max_retries, delay,
129
+ )
130
+ await asyncio.sleep(delay)
131
+ continue
132
+
133
+ # All retries exhausted on network errors
134
+ raise NetworkError(
135
+ f"Failed to reach CapSolver API after {self._max_retries} attempts: {last_exc}",
136
+ cause=last_exc,
137
+ )
@@ -0,0 +1,134 @@
1
+ """Pure task-payload builders.
2
+
3
+ Mirrors the Node SDK's core/tasks.ts. Each builder is a pure function
4
+ that takes keyword arguments and returns a plain ``dict`` ready for
5
+ JSON serialization.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from capsolver_core.core.types import (
13
+ ReCaptchaV2Task,
14
+ ReCaptchaV3Task,
15
+ CloudflareTask,
16
+ )
17
+
18
+
19
+ # ── reCAPTCHA v2 ──────────────────────────────────────────────────
20
+
21
+
22
+ def build_recaptcha_v2_task(
23
+ *,
24
+ website_url: str,
25
+ website_key: str,
26
+ page_action: str | None = None,
27
+ invisible: bool | None = None,
28
+ enterprise: bool | None = None,
29
+ enterprise_payload: dict[str, Any] | None = None,
30
+ api_domain: str | None = None,
31
+ proxy: str | None = None,
32
+ user_agent: str | None = None,
33
+ ) -> ReCaptchaV2Task:
34
+ proxyless = not proxy
35
+ is_enterprise = enterprise
36
+
37
+ if is_enterprise:
38
+ task_type = "ReCaptchaV2EnterpriseTaskProxyLess" if proxyless else "ReCaptchaV2EnterpriseTask"
39
+ else:
40
+ task_type = "ReCaptchaV2TaskProxyLess" if proxyless else "ReCaptchaV2Task"
41
+
42
+ task: ReCaptchaV2Task = {
43
+ "type": task_type,
44
+ "websiteURL": website_url,
45
+ "websiteKey": website_key,
46
+ }
47
+
48
+ if invisible is not None:
49
+ task["invisible"] = invisible
50
+ if page_action:
51
+ task["pageAction"] = page_action
52
+ if api_domain:
53
+ task["apiDomain"] = api_domain
54
+ if enterprise_payload:
55
+ task["enterprisePayload"] = enterprise_payload
56
+ if proxy:
57
+ task["proxy"] = proxy
58
+ if user_agent:
59
+ task["userAgent"] = user_agent
60
+
61
+ return task
62
+
63
+
64
+ # ── reCAPTCHA v3 ──────────────────────────────────────────────────
65
+
66
+
67
+ def build_recaptcha_v3_task(
68
+ *,
69
+ website_url: str,
70
+ website_key: str,
71
+ page_action: str | None = None,
72
+ min_score: float | None = None,
73
+ enterprise: bool | None = None,
74
+ enterprise_payload: dict[str, Any] | None = None,
75
+ api_domain: str | None = None,
76
+ proxy: str | None = None,
77
+ ) -> ReCaptchaV3Task:
78
+ proxyless = not proxy
79
+ is_enterprise = enterprise
80
+
81
+ if is_enterprise:
82
+ task_type = "ReCaptchaV3EnterpriseTaskProxyLess" if proxyless else "ReCaptchaV3EnterpriseTask"
83
+ else:
84
+ task_type = "ReCaptchaV3TaskProxyLess" if proxyless else "ReCaptchaV3Task"
85
+
86
+ task: ReCaptchaV3Task = {
87
+ "type": task_type,
88
+ "websiteURL": website_url,
89
+ "websiteKey": website_key,
90
+ }
91
+
92
+ if page_action:
93
+ task["pageAction"] = page_action
94
+ if min_score is not None:
95
+ task["minScore"] = min_score
96
+ if api_domain:
97
+ task["apiDomain"] = api_domain
98
+ if enterprise_payload:
99
+ task["enterprisePayload"] = enterprise_payload
100
+ if proxy:
101
+ task["proxy"] = proxy
102
+
103
+ return task
104
+
105
+
106
+ # ── Cloudflare Turnstile ──────────────────────────────────────────
107
+
108
+
109
+ def build_cloudflare_task(
110
+ *,
111
+ website_url: str,
112
+ website_key: str,
113
+ action: str | None = None,
114
+ cdata: str | None = None,
115
+ proxy: str | None = None,
116
+ ) -> CloudflareTask:
117
+ task: CloudflareTask = {
118
+ "type": "AntiTurnstileTaskProxyLess" if not proxy else "AntiTurnstileTask",
119
+ "websiteURL": website_url,
120
+ "websiteKey": website_key,
121
+ }
122
+
123
+ if action or cdata:
124
+ metadata: dict[str, str] = {"type": "turnstile"}
125
+ if action:
126
+ metadata["action"] = action
127
+ if cdata:
128
+ metadata["cdata"] = cdata
129
+ task["metadata"] = metadata
130
+
131
+ if proxy:
132
+ task["proxy"] = proxy
133
+
134
+ return task
@@ -0,0 +1,157 @@
1
+ """CapSolver API types — token-mode only.
2
+
3
+ Mirrors the Node SDK's core/types.ts. Uses ``str, Enum`` so values can
4
+ be compared directly against raw strings from the API.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from enum import Enum
10
+ from typing import Any
11
+
12
+
13
+ class CaptchaType(str, Enum):
14
+ """Captcha families the SDK can reason about."""
15
+
16
+ RECAPTCHA_V2 = "reCaptchaV2"
17
+ RECAPTCHA_V3 = "reCaptchaV3"
18
+ CLOUDFLARE = "cloudflare"
19
+
20
+
21
+ # ── Task lifecycle ────────────────────────────────────────────────
22
+
23
+
24
+ class TaskStatus(str, Enum):
25
+ IDLE = "idle"
26
+ READY = "ready"
27
+ PROCESSING = "processing"
28
+ FAILED = "failed"
29
+
30
+
31
+ # ── Cookie ────────────────────────────────────────────────────────
32
+
33
+
34
+ class Cookie:
35
+ __slots__ = ("name", "value")
36
+
37
+ def __init__(self, name: str, value: str) -> None:
38
+ self.name = name
39
+ self.value = value
40
+
41
+ def to_dict(self) -> dict[str, str]:
42
+ return {"name": self.name, "value": self.value}
43
+
44
+
45
+ # ── API envelope types ────────────────────────────────────────────
46
+
47
+
48
+ class BaseResp:
49
+ """Base shape for every CapSolver API response."""
50
+
51
+ __slots__ = ("error_id", "error_code", "error_description", "status", "solution")
52
+
53
+ def __init__(
54
+ self,
55
+ error_id: int = 0,
56
+ error_code: str = "",
57
+ error_description: str = "",
58
+ status: str | None = None,
59
+ solution: Any = None,
60
+ ) -> None:
61
+ self.error_id = error_id
62
+ self.error_code = error_code
63
+ self.error_description = error_description
64
+ self.status = status
65
+ self.solution = solution
66
+
67
+ @classmethod
68
+ def from_dict(cls, data: dict[str, Any]) -> BaseResp:
69
+ return cls(
70
+ error_id=data.get("errorId", 0),
71
+ error_code=data.get("errorCode", ""),
72
+ error_description=data.get("errorDescription", ""),
73
+ status=data.get("status"),
74
+ solution=data.get("solution"),
75
+ )
76
+
77
+
78
+ class CreateTaskResp(BaseResp):
79
+ __slots__ = ("task_id",)
80
+
81
+ def __init__(self, task_id: str, **kwargs: Any) -> None:
82
+ super().__init__(**kwargs)
83
+ self.task_id = task_id
84
+
85
+ @classmethod
86
+ def from_dict(cls, data: dict[str, Any]) -> CreateTaskResp:
87
+ return cls(
88
+ task_id=data.get("taskId", ""),
89
+ error_id=data.get("errorId", 0),
90
+ error_code=data.get("errorCode", ""),
91
+ error_description=data.get("errorDescription", ""),
92
+ status=data.get("status"),
93
+ solution=data.get("solution"),
94
+ )
95
+
96
+
97
+ class BalanceResp(BaseResp):
98
+ __slots__ = ("balance", "packages")
99
+
100
+ def __init__(self, balance: float = 0.0, packages: list[Any] | None = None, **kwargs: Any) -> None:
101
+ super().__init__(**kwargs)
102
+ self.balance = balance
103
+ self.packages = packages or []
104
+
105
+ @classmethod
106
+ def from_dict(cls, data: dict[str, Any]) -> BalanceResp:
107
+ return cls(
108
+ balance=data.get("balance", 0.0),
109
+ packages=data.get("packages", []),
110
+ error_id=data.get("errorId", 0),
111
+ error_code=data.get("errorCode", ""),
112
+ error_description=data.get("errorDescription", ""),
113
+ status=data.get("status"),
114
+ solution=data.get("solution"),
115
+ )
116
+
117
+
118
+ # ── Token-mode task payload types ─────────────────────────────────
119
+ # These are typed dicts so task builders can return plain dicts that
120
+ # serialize directly to JSON without extra conversion.
121
+
122
+ ReCaptchaV2Task = dict[str, Any]
123
+ ReCaptchaV3Task = dict[str, Any]
124
+ CloudflareTask = dict[str, Any]
125
+ AnyTask = dict[str, Any]
126
+
127
+
128
+ # ── Solution payloads ─────────────────────────────────────────────
129
+
130
+
131
+ class TokenSolution:
132
+ """Shared shape for reCAPTCHA / Cloudflare solutions."""
133
+
134
+ __slots__ = ("g_recaptcha_response", "token", "user_agent", "expire_time")
135
+
136
+ def __init__(
137
+ self,
138
+ g_recaptcha_response: str | None = None,
139
+ token: str | None = None,
140
+ user_agent: str | None = None,
141
+ expire_time: int | None = None,
142
+ ) -> None:
143
+ self.g_recaptcha_response = g_recaptcha_response
144
+ self.token = token
145
+ self.user_agent = user_agent
146
+ self.expire_time = expire_time
147
+
148
+ @classmethod
149
+ def from_dict(cls, data: dict[str, Any] | None) -> TokenSolution | None:
150
+ if data is None:
151
+ return None
152
+ return cls(
153
+ g_recaptcha_response=data.get("gRecaptchaResponse"),
154
+ token=data.get("token"),
155
+ user_agent=data.get("userAgent"),
156
+ expire_time=data.get("expireTime"),
157
+ )
File without changes