nslsolver 1.0.0__tar.gz
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.
- nslsolver-1.0.0/PKG-INFO +148 -0
- nslsolver-1.0.0/README.md +111 -0
- nslsolver-1.0.0/nslsolver/__init__.py +40 -0
- nslsolver-1.0.0/nslsolver/async_client.py +235 -0
- nslsolver-1.0.0/nslsolver/client.py +227 -0
- nslsolver-1.0.0/nslsolver/exceptions.py +49 -0
- nslsolver-1.0.0/nslsolver/types.py +45 -0
- nslsolver-1.0.0/nslsolver.egg-info/PKG-INFO +148 -0
- nslsolver-1.0.0/nslsolver.egg-info/SOURCES.txt +12 -0
- nslsolver-1.0.0/nslsolver.egg-info/dependency_links.txt +1 -0
- nslsolver-1.0.0/nslsolver.egg-info/requires.txt +11 -0
- nslsolver-1.0.0/nslsolver.egg-info/top_level.txt +1 -0
- nslsolver-1.0.0/pyproject.toml +64 -0
- nslsolver-1.0.0/setup.cfg +4 -0
nslsolver-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nslsolver
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python SDK for the NSLSolver captcha solving API
|
|
5
|
+
Author-email: NSLSolver <support@nslsolver.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://nslsolver.com
|
|
8
|
+
Project-URL: Documentation, https://docs.nslsolver.com
|
|
9
|
+
Project-URL: Repository, https://github.com/NSLSolver/NSLSolver-SDK-Python
|
|
10
|
+
Project-URL: Issues, https://github.com/NSLSolver/NSLSolver-SDK-Python/issues
|
|
11
|
+
Keywords: captcha,turnstile,cloudflare,solver,nslsolver
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.8
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
Requires-Dist: requests>=2.25.0
|
|
29
|
+
Provides-Extra: async
|
|
30
|
+
Requires-Dist: aiohttp>=3.8.0; extra == "async"
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: aiohttp>=3.8.0; extra == "dev"
|
|
33
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
34
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
35
|
+
Requires-Dist: mypy>=1.0; extra == "dev"
|
|
36
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
37
|
+
|
|
38
|
+
# NSLSolver Python SDK
|
|
39
|
+
|
|
40
|
+
Python SDK for the [NSLSolver](https://nslsolver.com) captcha solving API.
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install nslsolver
|
|
46
|
+
|
|
47
|
+
# async support
|
|
48
|
+
pip install nslsolver[async]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Quick Start
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from nslsolver import NSLSolver
|
|
55
|
+
|
|
56
|
+
solver = NSLSolver("your-api-key")
|
|
57
|
+
|
|
58
|
+
# Turnstile
|
|
59
|
+
result = solver.solve_turnstile(
|
|
60
|
+
site_key="0x4AAAAAAAB...",
|
|
61
|
+
url="https://example.com",
|
|
62
|
+
)
|
|
63
|
+
print(result.token)
|
|
64
|
+
|
|
65
|
+
# Cloudflare challenge (proxy required)
|
|
66
|
+
result = solver.solve_challenge(
|
|
67
|
+
url="https://example.com/protected",
|
|
68
|
+
proxy="http://user:pass@host:port",
|
|
69
|
+
)
|
|
70
|
+
print(result.cookies, result.user_agent)
|
|
71
|
+
|
|
72
|
+
# Balance
|
|
73
|
+
balance = solver.get_balance()
|
|
74
|
+
print(balance.balance, balance.max_threads, balance.allowed_types)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Async
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
import asyncio
|
|
81
|
+
from nslsolver import AsyncNSLSolver
|
|
82
|
+
|
|
83
|
+
async def main():
|
|
84
|
+
async with AsyncNSLSolver("your-api-key") as solver:
|
|
85
|
+
result = await solver.solve_turnstile(
|
|
86
|
+
site_key="0x4AAAAAAAB...",
|
|
87
|
+
url="https://example.com",
|
|
88
|
+
)
|
|
89
|
+
print(result.token)
|
|
90
|
+
|
|
91
|
+
asyncio.run(main())
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Error Handling
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from nslsolver import (
|
|
98
|
+
NSLSolver,
|
|
99
|
+
AuthenticationError,
|
|
100
|
+
InsufficientBalanceError,
|
|
101
|
+
RateLimitError,
|
|
102
|
+
SolveError,
|
|
103
|
+
NSLSolverError,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
solver = NSLSolver("your-api-key")
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
result = solver.solve_turnstile(
|
|
110
|
+
site_key="0x4AAAAAAAB...",
|
|
111
|
+
url="https://example.com",
|
|
112
|
+
)
|
|
113
|
+
except AuthenticationError:
|
|
114
|
+
print("Bad API key.")
|
|
115
|
+
except InsufficientBalanceError:
|
|
116
|
+
print("Top up your balance.")
|
|
117
|
+
except RateLimitError:
|
|
118
|
+
print("Rate limited after all retries.")
|
|
119
|
+
except SolveError as e:
|
|
120
|
+
print(f"Solve failed: {e.message}")
|
|
121
|
+
except NSLSolverError as e:
|
|
122
|
+
print(f"API error (HTTP {e.status_code}): {e.message}")
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Rate-limit (429) and backend (503) errors are retried automatically with exponential backoff before raising.
|
|
126
|
+
|
|
127
|
+
## Configuration
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
solver = NSLSolver(
|
|
131
|
+
api_key="your-api-key",
|
|
132
|
+
base_url="https://api.nslsolver.com", # default
|
|
133
|
+
timeout=120, # seconds (default: 120)
|
|
134
|
+
max_retries=3, # retries for 429/503 (default: 3)
|
|
135
|
+
)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Both clients support context managers (`with` / `async with`) for session cleanup.
|
|
139
|
+
|
|
140
|
+
## Requirements
|
|
141
|
+
|
|
142
|
+
- Python 3.8+
|
|
143
|
+
- `requests` (sync client)
|
|
144
|
+
- `aiohttp` (async client, optional)
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# NSLSolver Python SDK
|
|
2
|
+
|
|
3
|
+
Python SDK for the [NSLSolver](https://nslsolver.com) captcha solving API.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install nslsolver
|
|
9
|
+
|
|
10
|
+
# async support
|
|
11
|
+
pip install nslsolver[async]
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Quick Start
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from nslsolver import NSLSolver
|
|
18
|
+
|
|
19
|
+
solver = NSLSolver("your-api-key")
|
|
20
|
+
|
|
21
|
+
# Turnstile
|
|
22
|
+
result = solver.solve_turnstile(
|
|
23
|
+
site_key="0x4AAAAAAAB...",
|
|
24
|
+
url="https://example.com",
|
|
25
|
+
)
|
|
26
|
+
print(result.token)
|
|
27
|
+
|
|
28
|
+
# Cloudflare challenge (proxy required)
|
|
29
|
+
result = solver.solve_challenge(
|
|
30
|
+
url="https://example.com/protected",
|
|
31
|
+
proxy="http://user:pass@host:port",
|
|
32
|
+
)
|
|
33
|
+
print(result.cookies, result.user_agent)
|
|
34
|
+
|
|
35
|
+
# Balance
|
|
36
|
+
balance = solver.get_balance()
|
|
37
|
+
print(balance.balance, balance.max_threads, balance.allowed_types)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Async
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import asyncio
|
|
44
|
+
from nslsolver import AsyncNSLSolver
|
|
45
|
+
|
|
46
|
+
async def main():
|
|
47
|
+
async with AsyncNSLSolver("your-api-key") as solver:
|
|
48
|
+
result = await solver.solve_turnstile(
|
|
49
|
+
site_key="0x4AAAAAAAB...",
|
|
50
|
+
url="https://example.com",
|
|
51
|
+
)
|
|
52
|
+
print(result.token)
|
|
53
|
+
|
|
54
|
+
asyncio.run(main())
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Error Handling
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from nslsolver import (
|
|
61
|
+
NSLSolver,
|
|
62
|
+
AuthenticationError,
|
|
63
|
+
InsufficientBalanceError,
|
|
64
|
+
RateLimitError,
|
|
65
|
+
SolveError,
|
|
66
|
+
NSLSolverError,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
solver = NSLSolver("your-api-key")
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
result = solver.solve_turnstile(
|
|
73
|
+
site_key="0x4AAAAAAAB...",
|
|
74
|
+
url="https://example.com",
|
|
75
|
+
)
|
|
76
|
+
except AuthenticationError:
|
|
77
|
+
print("Bad API key.")
|
|
78
|
+
except InsufficientBalanceError:
|
|
79
|
+
print("Top up your balance.")
|
|
80
|
+
except RateLimitError:
|
|
81
|
+
print("Rate limited after all retries.")
|
|
82
|
+
except SolveError as e:
|
|
83
|
+
print(f"Solve failed: {e.message}")
|
|
84
|
+
except NSLSolverError as e:
|
|
85
|
+
print(f"API error (HTTP {e.status_code}): {e.message}")
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Rate-limit (429) and backend (503) errors are retried automatically with exponential backoff before raising.
|
|
89
|
+
|
|
90
|
+
## Configuration
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
solver = NSLSolver(
|
|
94
|
+
api_key="your-api-key",
|
|
95
|
+
base_url="https://api.nslsolver.com", # default
|
|
96
|
+
timeout=120, # seconds (default: 120)
|
|
97
|
+
max_retries=3, # retries for 429/503 (default: 3)
|
|
98
|
+
)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Both clients support context managers (`with` / `async with`) for session cleanup.
|
|
102
|
+
|
|
103
|
+
## Requirements
|
|
104
|
+
|
|
105
|
+
- Python 3.8+
|
|
106
|
+
- `requests` (sync client)
|
|
107
|
+
- `aiohttp` (async client, optional)
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""NSLSolver Python SDK."""
|
|
2
|
+
|
|
3
|
+
__version__ = "1.0.0"
|
|
4
|
+
|
|
5
|
+
from .client import NSLSolver
|
|
6
|
+
from .exceptions import (
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
BackendError,
|
|
9
|
+
InsufficientBalanceError,
|
|
10
|
+
NSLSolverError,
|
|
11
|
+
RateLimitError,
|
|
12
|
+
SolveError,
|
|
13
|
+
TypeNotAllowedError,
|
|
14
|
+
)
|
|
15
|
+
from .types import BalanceResult, ChallengeResult, TurnstileResult
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def __getattr__(name: str) -> object:
|
|
19
|
+
if name == "AsyncNSLSolver":
|
|
20
|
+
from .async_client import AsyncNSLSolver
|
|
21
|
+
|
|
22
|
+
return AsyncNSLSolver
|
|
23
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"__version__",
|
|
28
|
+
"NSLSolver",
|
|
29
|
+
"AsyncNSLSolver",
|
|
30
|
+
"TurnstileResult",
|
|
31
|
+
"ChallengeResult",
|
|
32
|
+
"BalanceResult",
|
|
33
|
+
"NSLSolverError",
|
|
34
|
+
"AuthenticationError",
|
|
35
|
+
"InsufficientBalanceError",
|
|
36
|
+
"TypeNotAllowedError",
|
|
37
|
+
"RateLimitError",
|
|
38
|
+
"BackendError",
|
|
39
|
+
"SolveError",
|
|
40
|
+
]
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Async NSLSolver client (requires aiohttp)."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import aiohttp
|
|
9
|
+
except ImportError:
|
|
10
|
+
raise ImportError(
|
|
11
|
+
"The 'aiohttp' package is required for the async client. "
|
|
12
|
+
"Install it with: pip install nslsolver[async]"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from .exceptions import (
|
|
16
|
+
AuthenticationError,
|
|
17
|
+
BackendError,
|
|
18
|
+
InsufficientBalanceError,
|
|
19
|
+
NSLSolverError,
|
|
20
|
+
RateLimitError,
|
|
21
|
+
SolveError,
|
|
22
|
+
TypeNotAllowedError,
|
|
23
|
+
)
|
|
24
|
+
from .types import BalanceResult, ChallengeResult, TurnstileResult
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("nslsolver")
|
|
27
|
+
|
|
28
|
+
_DEFAULT_BASE_URL = "https://api.nslsolver.com"
|
|
29
|
+
_DEFAULT_TIMEOUT = 120
|
|
30
|
+
_DEFAULT_MAX_RETRIES = 3
|
|
31
|
+
_INITIAL_BACKOFF = 1.0
|
|
32
|
+
_BACKOFF_MULTIPLIER = 2.0
|
|
33
|
+
_MAX_BACKOFF = 30.0
|
|
34
|
+
|
|
35
|
+
_RETRYABLE_EXCEPTIONS = (RateLimitError, BackendError)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AsyncNSLSolver:
|
|
39
|
+
"""Async client for the NSLSolver API."""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
api_key: str,
|
|
44
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
45
|
+
timeout: int = _DEFAULT_TIMEOUT,
|
|
46
|
+
max_retries: int = _DEFAULT_MAX_RETRIES,
|
|
47
|
+
) -> None:
|
|
48
|
+
if not api_key:
|
|
49
|
+
raise ValueError("api_key must be a non-empty string.")
|
|
50
|
+
|
|
51
|
+
self._api_key = api_key
|
|
52
|
+
self._base_url = base_url.rstrip("/")
|
|
53
|
+
self._timeout = aiohttp.ClientTimeout(total=timeout)
|
|
54
|
+
self._max_retries = max_retries
|
|
55
|
+
self._session: Optional[aiohttp.ClientSession] = None
|
|
56
|
+
self._headers = {
|
|
57
|
+
"X-API-Key": self._api_key,
|
|
58
|
+
"Content-Type": "application/json",
|
|
59
|
+
"User-Agent": "nslsolver-python/1.0.0 (async)",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async def _get_session(self) -> aiohttp.ClientSession:
|
|
63
|
+
if self._session is None or self._session.closed:
|
|
64
|
+
self._session = aiohttp.ClientSession(
|
|
65
|
+
headers=self._headers,
|
|
66
|
+
timeout=self._timeout,
|
|
67
|
+
)
|
|
68
|
+
return self._session
|
|
69
|
+
|
|
70
|
+
# -- Public API ----------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
async def solve_turnstile(
|
|
73
|
+
self,
|
|
74
|
+
site_key: str,
|
|
75
|
+
url: str,
|
|
76
|
+
action: Optional[str] = None,
|
|
77
|
+
cdata: Optional[str] = None,
|
|
78
|
+
proxy: Optional[str] = None,
|
|
79
|
+
user_agent: Optional[str] = None,
|
|
80
|
+
) -> TurnstileResult:
|
|
81
|
+
"""Solve a Cloudflare Turnstile captcha."""
|
|
82
|
+
payload: Dict[str, Any] = {
|
|
83
|
+
"type": "turnstile",
|
|
84
|
+
"site_key": site_key,
|
|
85
|
+
"url": url,
|
|
86
|
+
}
|
|
87
|
+
if action is not None:
|
|
88
|
+
payload["action"] = action
|
|
89
|
+
if cdata is not None:
|
|
90
|
+
payload["cdata"] = cdata
|
|
91
|
+
if proxy is not None:
|
|
92
|
+
payload["proxy"] = proxy
|
|
93
|
+
if user_agent is not None:
|
|
94
|
+
payload["user_agent"] = user_agent
|
|
95
|
+
|
|
96
|
+
data = await self._request("POST", "/solve", json_body=payload)
|
|
97
|
+
|
|
98
|
+
return TurnstileResult(
|
|
99
|
+
token=data["token"],
|
|
100
|
+
type=data.get("type", "turnstile"),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
async def solve_challenge(
|
|
104
|
+
self,
|
|
105
|
+
url: str,
|
|
106
|
+
proxy: str,
|
|
107
|
+
user_agent: Optional[str] = None,
|
|
108
|
+
) -> ChallengeResult:
|
|
109
|
+
"""Solve a Cloudflare challenge page."""
|
|
110
|
+
payload: Dict[str, Any] = {
|
|
111
|
+
"type": "challenge",
|
|
112
|
+
"url": url,
|
|
113
|
+
"proxy": proxy,
|
|
114
|
+
}
|
|
115
|
+
if user_agent is not None:
|
|
116
|
+
payload["user_agent"] = user_agent
|
|
117
|
+
|
|
118
|
+
data = await self._request("POST", "/solve", json_body=payload)
|
|
119
|
+
|
|
120
|
+
return ChallengeResult(
|
|
121
|
+
cookies=data.get("cookies", {}),
|
|
122
|
+
user_agent=data.get("user_agent", ""),
|
|
123
|
+
type=data.get("type", "challenge"),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
async def get_balance(self) -> BalanceResult:
|
|
127
|
+
"""Retrieve current account balance."""
|
|
128
|
+
data = await self._request("GET", "/balance")
|
|
129
|
+
|
|
130
|
+
known_keys = {"balance", "max_threads", "allowed_types"}
|
|
131
|
+
extra = {k: v for k, v in data.items() if k not in known_keys}
|
|
132
|
+
|
|
133
|
+
return BalanceResult(
|
|
134
|
+
balance=float(data["balance"]),
|
|
135
|
+
max_threads=int(data["max_threads"]),
|
|
136
|
+
allowed_types=list(data.get("allowed_types", [])),
|
|
137
|
+
extra=extra,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# -- Internal ------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
async def _request(
|
|
143
|
+
self,
|
|
144
|
+
method: str,
|
|
145
|
+
path: str,
|
|
146
|
+
json_body: Optional[Dict[str, Any]] = None,
|
|
147
|
+
) -> Dict[str, Any]:
|
|
148
|
+
"""Execute an HTTP request with retries on transient errors."""
|
|
149
|
+
url = f"{self._base_url}{path}"
|
|
150
|
+
last_exc: Optional[Exception] = None
|
|
151
|
+
backoff = _INITIAL_BACKOFF
|
|
152
|
+
session = await self._get_session()
|
|
153
|
+
|
|
154
|
+
for attempt in range(self._max_retries + 1):
|
|
155
|
+
try:
|
|
156
|
+
async with session.request(
|
|
157
|
+
method=method,
|
|
158
|
+
url=url,
|
|
159
|
+
json=json_body,
|
|
160
|
+
) as response:
|
|
161
|
+
if response.status == 200:
|
|
162
|
+
return await response.json() # type: ignore[no-any-return]
|
|
163
|
+
|
|
164
|
+
response_text = await response.text()
|
|
165
|
+
self._handle_error_response(response.status, response_text)
|
|
166
|
+
|
|
167
|
+
except _RETRYABLE_EXCEPTIONS as exc:
|
|
168
|
+
last_exc = exc
|
|
169
|
+
except asyncio.TimeoutError:
|
|
170
|
+
last_exc = NSLSolverError("Request timed out.")
|
|
171
|
+
except aiohttp.ClientConnectionError:
|
|
172
|
+
last_exc = NSLSolverError("Connection error.")
|
|
173
|
+
else:
|
|
174
|
+
break # pragma: no cover
|
|
175
|
+
|
|
176
|
+
if attempt < self._max_retries:
|
|
177
|
+
sleep_time = min(backoff, _MAX_BACKOFF)
|
|
178
|
+
logger.warning(
|
|
179
|
+
"%s on attempt %d/%d, retrying in %.1fs",
|
|
180
|
+
last_exc,
|
|
181
|
+
attempt + 1,
|
|
182
|
+
self._max_retries + 1,
|
|
183
|
+
sleep_time,
|
|
184
|
+
)
|
|
185
|
+
await asyncio.sleep(sleep_time)
|
|
186
|
+
backoff *= _BACKOFF_MULTIPLIER
|
|
187
|
+
else:
|
|
188
|
+
raise last_exc # type: ignore[misc]
|
|
189
|
+
|
|
190
|
+
if last_exc is not None:
|
|
191
|
+
raise last_exc
|
|
192
|
+
raise NSLSolverError("Unexpected error: no response received.")
|
|
193
|
+
|
|
194
|
+
@staticmethod
|
|
195
|
+
def _handle_error_response(status: int, response_text: str) -> None:
|
|
196
|
+
"""Map an HTTP error response to the appropriate exception."""
|
|
197
|
+
import json
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
body = json.loads(response_text)
|
|
201
|
+
except (ValueError, KeyError):
|
|
202
|
+
body = {}
|
|
203
|
+
|
|
204
|
+
message = body.get("error", body.get("message", response_text))
|
|
205
|
+
|
|
206
|
+
exc_map = {
|
|
207
|
+
400: SolveError,
|
|
208
|
+
401: AuthenticationError,
|
|
209
|
+
402: InsufficientBalanceError,
|
|
210
|
+
403: TypeNotAllowedError,
|
|
211
|
+
429: RateLimitError,
|
|
212
|
+
503: BackendError,
|
|
213
|
+
}
|
|
214
|
+
cls = exc_map.get(status)
|
|
215
|
+
if cls:
|
|
216
|
+
raise cls(message=str(message), status_code=status, response_body=body)
|
|
217
|
+
raise NSLSolverError(
|
|
218
|
+
message=f"Unexpected API error (HTTP {status}): {message}",
|
|
219
|
+
status_code=status,
|
|
220
|
+
response_body=body,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
async def close(self) -> None:
|
|
224
|
+
"""Close the underlying aiohttp session."""
|
|
225
|
+
if self._session and not self._session.closed:
|
|
226
|
+
await self._session.close()
|
|
227
|
+
|
|
228
|
+
async def __aenter__(self) -> "AsyncNSLSolver":
|
|
229
|
+
return self
|
|
230
|
+
|
|
231
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
232
|
+
await self.close()
|
|
233
|
+
|
|
234
|
+
def __repr__(self) -> str:
|
|
235
|
+
return f"AsyncNSLSolver(base_url={self._base_url!r})"
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Synchronous NSLSolver client."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from .exceptions import (
|
|
10
|
+
AuthenticationError,
|
|
11
|
+
BackendError,
|
|
12
|
+
InsufficientBalanceError,
|
|
13
|
+
NSLSolverError,
|
|
14
|
+
RateLimitError,
|
|
15
|
+
SolveError,
|
|
16
|
+
TypeNotAllowedError,
|
|
17
|
+
)
|
|
18
|
+
from .types import BalanceResult, ChallengeResult, TurnstileResult
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("nslsolver")
|
|
21
|
+
|
|
22
|
+
_DEFAULT_BASE_URL = "https://api.nslsolver.com"
|
|
23
|
+
_DEFAULT_TIMEOUT = 120
|
|
24
|
+
_DEFAULT_MAX_RETRIES = 3
|
|
25
|
+
_INITIAL_BACKOFF = 1.0
|
|
26
|
+
_BACKOFF_MULTIPLIER = 2.0
|
|
27
|
+
_MAX_BACKOFF = 30.0
|
|
28
|
+
|
|
29
|
+
# Errors worth retrying
|
|
30
|
+
_RETRYABLE_EXCEPTIONS = (RateLimitError, BackendError)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class NSLSolver:
|
|
34
|
+
"""Synchronous client for the NSLSolver API."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
api_key: str,
|
|
39
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
40
|
+
timeout: int = _DEFAULT_TIMEOUT,
|
|
41
|
+
max_retries: int = _DEFAULT_MAX_RETRIES,
|
|
42
|
+
) -> None:
|
|
43
|
+
if not api_key:
|
|
44
|
+
raise ValueError("api_key must be a non-empty string.")
|
|
45
|
+
|
|
46
|
+
self._api_key = api_key
|
|
47
|
+
self._base_url = base_url.rstrip("/")
|
|
48
|
+
self._timeout = timeout
|
|
49
|
+
self._max_retries = max_retries
|
|
50
|
+
self._session = requests.Session()
|
|
51
|
+
self._session.headers.update(
|
|
52
|
+
{
|
|
53
|
+
"X-API-Key": self._api_key,
|
|
54
|
+
"Content-Type": "application/json",
|
|
55
|
+
"User-Agent": "nslsolver-python/1.0.0",
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# -- Public API ----------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
def solve_turnstile(
|
|
62
|
+
self,
|
|
63
|
+
site_key: str,
|
|
64
|
+
url: str,
|
|
65
|
+
action: Optional[str] = None,
|
|
66
|
+
cdata: Optional[str] = None,
|
|
67
|
+
proxy: Optional[str] = None,
|
|
68
|
+
user_agent: Optional[str] = None,
|
|
69
|
+
) -> TurnstileResult:
|
|
70
|
+
"""Solve a Cloudflare Turnstile captcha."""
|
|
71
|
+
payload: Dict[str, Any] = {
|
|
72
|
+
"type": "turnstile",
|
|
73
|
+
"site_key": site_key,
|
|
74
|
+
"url": url,
|
|
75
|
+
}
|
|
76
|
+
if action is not None:
|
|
77
|
+
payload["action"] = action
|
|
78
|
+
if cdata is not None:
|
|
79
|
+
payload["cdata"] = cdata
|
|
80
|
+
if proxy is not None:
|
|
81
|
+
payload["proxy"] = proxy
|
|
82
|
+
if user_agent is not None:
|
|
83
|
+
payload["user_agent"] = user_agent
|
|
84
|
+
|
|
85
|
+
data = self._request("POST", "/solve", json_body=payload)
|
|
86
|
+
|
|
87
|
+
return TurnstileResult(
|
|
88
|
+
token=data["token"],
|
|
89
|
+
type=data.get("type", "turnstile"),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def solve_challenge(
|
|
93
|
+
self,
|
|
94
|
+
url: str,
|
|
95
|
+
proxy: str,
|
|
96
|
+
user_agent: Optional[str] = None,
|
|
97
|
+
) -> ChallengeResult:
|
|
98
|
+
"""Solve a Cloudflare challenge page."""
|
|
99
|
+
payload: Dict[str, Any] = {
|
|
100
|
+
"type": "challenge",
|
|
101
|
+
"url": url,
|
|
102
|
+
"proxy": proxy,
|
|
103
|
+
}
|
|
104
|
+
if user_agent is not None:
|
|
105
|
+
payload["user_agent"] = user_agent
|
|
106
|
+
|
|
107
|
+
data = self._request("POST", "/solve", json_body=payload)
|
|
108
|
+
|
|
109
|
+
return ChallengeResult(
|
|
110
|
+
cookies=data.get("cookies", {}),
|
|
111
|
+
user_agent=data.get("user_agent", ""),
|
|
112
|
+
type=data.get("type", "challenge"),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def get_balance(self) -> BalanceResult:
|
|
116
|
+
"""Retrieve current account balance."""
|
|
117
|
+
data = self._request("GET", "/balance")
|
|
118
|
+
|
|
119
|
+
known_keys = {"balance", "max_threads", "allowed_types"}
|
|
120
|
+
extra = {k: v for k, v in data.items() if k not in known_keys}
|
|
121
|
+
|
|
122
|
+
return BalanceResult(
|
|
123
|
+
balance=float(data["balance"]),
|
|
124
|
+
max_threads=int(data["max_threads"]),
|
|
125
|
+
allowed_types=list(data.get("allowed_types", [])),
|
|
126
|
+
extra=extra,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# -- Internal ------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def _request(
|
|
132
|
+
self,
|
|
133
|
+
method: str,
|
|
134
|
+
path: str,
|
|
135
|
+
json_body: Optional[Dict[str, Any]] = None,
|
|
136
|
+
) -> Dict[str, Any]:
|
|
137
|
+
"""Execute an HTTP request with retries on transient errors."""
|
|
138
|
+
url = f"{self._base_url}{path}"
|
|
139
|
+
last_exc: Optional[Exception] = None
|
|
140
|
+
backoff = _INITIAL_BACKOFF
|
|
141
|
+
|
|
142
|
+
for attempt in range(self._max_retries + 1):
|
|
143
|
+
try:
|
|
144
|
+
response = self._session.request(
|
|
145
|
+
method=method,
|
|
146
|
+
url=url,
|
|
147
|
+
json=json_body,
|
|
148
|
+
timeout=self._timeout,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
if response.status_code == 200:
|
|
152
|
+
return response.json() # type: ignore[no-any-return]
|
|
153
|
+
|
|
154
|
+
self._handle_error_response(response)
|
|
155
|
+
|
|
156
|
+
except _RETRYABLE_EXCEPTIONS as exc:
|
|
157
|
+
last_exc = exc
|
|
158
|
+
except requests.exceptions.Timeout:
|
|
159
|
+
last_exc = NSLSolverError(f"Request timed out after {self._timeout}s.")
|
|
160
|
+
except requests.exceptions.ConnectionError:
|
|
161
|
+
last_exc = NSLSolverError("Connection error.")
|
|
162
|
+
else:
|
|
163
|
+
# _handle_error_response always raises, so we only land here
|
|
164
|
+
# on a 200, which already returned above.
|
|
165
|
+
break # pragma: no cover
|
|
166
|
+
|
|
167
|
+
# Retry or give up
|
|
168
|
+
if attempt < self._max_retries:
|
|
169
|
+
sleep_time = min(backoff, _MAX_BACKOFF)
|
|
170
|
+
logger.warning(
|
|
171
|
+
"%s on attempt %d/%d, retrying in %.1fs",
|
|
172
|
+
last_exc,
|
|
173
|
+
attempt + 1,
|
|
174
|
+
self._max_retries + 1,
|
|
175
|
+
sleep_time,
|
|
176
|
+
)
|
|
177
|
+
time.sleep(sleep_time)
|
|
178
|
+
backoff *= _BACKOFF_MULTIPLIER
|
|
179
|
+
else:
|
|
180
|
+
raise last_exc # type: ignore[misc]
|
|
181
|
+
|
|
182
|
+
# Should not be reached, but keeps the type checker happy.
|
|
183
|
+
if last_exc is not None:
|
|
184
|
+
raise last_exc
|
|
185
|
+
raise NSLSolverError("Unexpected error: no response received.")
|
|
186
|
+
|
|
187
|
+
@staticmethod
|
|
188
|
+
def _handle_error_response(response: requests.Response) -> None:
|
|
189
|
+
"""Map an HTTP error response to the appropriate exception."""
|
|
190
|
+
status = response.status_code
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
body = response.json()
|
|
194
|
+
except (ValueError, KeyError):
|
|
195
|
+
body = {}
|
|
196
|
+
|
|
197
|
+
message = body.get("error", body.get("message", response.text))
|
|
198
|
+
|
|
199
|
+
exc_map = {
|
|
200
|
+
400: SolveError,
|
|
201
|
+
401: AuthenticationError,
|
|
202
|
+
402: InsufficientBalanceError,
|
|
203
|
+
403: TypeNotAllowedError,
|
|
204
|
+
429: RateLimitError,
|
|
205
|
+
503: BackendError,
|
|
206
|
+
}
|
|
207
|
+
cls = exc_map.get(status)
|
|
208
|
+
if cls:
|
|
209
|
+
raise cls(message=str(message), status_code=status, response_body=body)
|
|
210
|
+
raise NSLSolverError(
|
|
211
|
+
message=f"Unexpected API error (HTTP {status}): {message}",
|
|
212
|
+
status_code=status,
|
|
213
|
+
response_body=body,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
def close(self) -> None:
|
|
217
|
+
"""Close the underlying HTTP session."""
|
|
218
|
+
self._session.close()
|
|
219
|
+
|
|
220
|
+
def __enter__(self) -> "NSLSolver":
|
|
221
|
+
return self
|
|
222
|
+
|
|
223
|
+
def __exit__(self, *args: Any) -> None:
|
|
224
|
+
self.close()
|
|
225
|
+
|
|
226
|
+
def __repr__(self) -> str:
|
|
227
|
+
return f"NSLSolver(base_url={self._base_url!r})"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""NSLSolver SDK exceptions."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class NSLSolverError(Exception):
|
|
7
|
+
"""Base exception for all NSLSolver API errors."""
|
|
8
|
+
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
message: str,
|
|
12
|
+
status_code: Optional[int] = None,
|
|
13
|
+
response_body: Optional[dict] = None,
|
|
14
|
+
) -> None:
|
|
15
|
+
self.message = message
|
|
16
|
+
self.status_code = status_code
|
|
17
|
+
self.response_body = response_body
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
|
|
20
|
+
def __repr__(self) -> str:
|
|
21
|
+
return (
|
|
22
|
+
f"{self.__class__.__name__}("
|
|
23
|
+
f"message={self.message!r}, "
|
|
24
|
+
f"status_code={self.status_code!r})"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AuthenticationError(NSLSolverError):
|
|
29
|
+
"""Invalid or missing API key (401)."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class InsufficientBalanceError(NSLSolverError):
|
|
33
|
+
"""Account balance too low (402)."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TypeNotAllowedError(NSLSolverError):
|
|
37
|
+
"""Solve type not allowed for this key (403)."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RateLimitError(NSLSolverError):
|
|
41
|
+
"""Rate limit exceeded (429). Retried automatically."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class BackendError(NSLSolverError):
|
|
45
|
+
"""Backend unavailable (503). Retried automatically."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SolveError(NSLSolverError):
|
|
49
|
+
"""Solve request failed (400)."""
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Response types for the NSLSolver API."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class TurnstileResult:
|
|
9
|
+
"""Result of a Turnstile captcha solve."""
|
|
10
|
+
|
|
11
|
+
token: str
|
|
12
|
+
type: str = "turnstile"
|
|
13
|
+
|
|
14
|
+
def __str__(self) -> str:
|
|
15
|
+
return self.token
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class ChallengeResult:
|
|
20
|
+
"""Result of a Cloudflare challenge solve."""
|
|
21
|
+
|
|
22
|
+
cookies: Dict[str, str]
|
|
23
|
+
user_agent: str
|
|
24
|
+
type: str = "challenge"
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def cf_clearance(self) -> Optional[str]:
|
|
28
|
+
return self.cookies.get("cf_clearance")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class BalanceResult:
|
|
33
|
+
"""Account balance and capability info."""
|
|
34
|
+
|
|
35
|
+
balance: float
|
|
36
|
+
max_threads: int
|
|
37
|
+
allowed_types: List[str]
|
|
38
|
+
extra: Dict[str, object] = field(default_factory=dict)
|
|
39
|
+
|
|
40
|
+
def __str__(self) -> str:
|
|
41
|
+
return (
|
|
42
|
+
f"Balance: {self.balance}, "
|
|
43
|
+
f"Max Threads: {self.max_threads}, "
|
|
44
|
+
f"Allowed Types: {self.allowed_types}"
|
|
45
|
+
)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nslsolver
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python SDK for the NSLSolver captcha solving API
|
|
5
|
+
Author-email: NSLSolver <support@nslsolver.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://nslsolver.com
|
|
8
|
+
Project-URL: Documentation, https://docs.nslsolver.com
|
|
9
|
+
Project-URL: Repository, https://github.com/NSLSolver/NSLSolver-SDK-Python
|
|
10
|
+
Project-URL: Issues, https://github.com/NSLSolver/NSLSolver-SDK-Python/issues
|
|
11
|
+
Keywords: captcha,turnstile,cloudflare,solver,nslsolver
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.8
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
Requires-Dist: requests>=2.25.0
|
|
29
|
+
Provides-Extra: async
|
|
30
|
+
Requires-Dist: aiohttp>=3.8.0; extra == "async"
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: aiohttp>=3.8.0; extra == "dev"
|
|
33
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
34
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
35
|
+
Requires-Dist: mypy>=1.0; extra == "dev"
|
|
36
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
37
|
+
|
|
38
|
+
# NSLSolver Python SDK
|
|
39
|
+
|
|
40
|
+
Python SDK for the [NSLSolver](https://nslsolver.com) captcha solving API.
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install nslsolver
|
|
46
|
+
|
|
47
|
+
# async support
|
|
48
|
+
pip install nslsolver[async]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Quick Start
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from nslsolver import NSLSolver
|
|
55
|
+
|
|
56
|
+
solver = NSLSolver("your-api-key")
|
|
57
|
+
|
|
58
|
+
# Turnstile
|
|
59
|
+
result = solver.solve_turnstile(
|
|
60
|
+
site_key="0x4AAAAAAAB...",
|
|
61
|
+
url="https://example.com",
|
|
62
|
+
)
|
|
63
|
+
print(result.token)
|
|
64
|
+
|
|
65
|
+
# Cloudflare challenge (proxy required)
|
|
66
|
+
result = solver.solve_challenge(
|
|
67
|
+
url="https://example.com/protected",
|
|
68
|
+
proxy="http://user:pass@host:port",
|
|
69
|
+
)
|
|
70
|
+
print(result.cookies, result.user_agent)
|
|
71
|
+
|
|
72
|
+
# Balance
|
|
73
|
+
balance = solver.get_balance()
|
|
74
|
+
print(balance.balance, balance.max_threads, balance.allowed_types)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Async
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
import asyncio
|
|
81
|
+
from nslsolver import AsyncNSLSolver
|
|
82
|
+
|
|
83
|
+
async def main():
|
|
84
|
+
async with AsyncNSLSolver("your-api-key") as solver:
|
|
85
|
+
result = await solver.solve_turnstile(
|
|
86
|
+
site_key="0x4AAAAAAAB...",
|
|
87
|
+
url="https://example.com",
|
|
88
|
+
)
|
|
89
|
+
print(result.token)
|
|
90
|
+
|
|
91
|
+
asyncio.run(main())
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Error Handling
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from nslsolver import (
|
|
98
|
+
NSLSolver,
|
|
99
|
+
AuthenticationError,
|
|
100
|
+
InsufficientBalanceError,
|
|
101
|
+
RateLimitError,
|
|
102
|
+
SolveError,
|
|
103
|
+
NSLSolverError,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
solver = NSLSolver("your-api-key")
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
result = solver.solve_turnstile(
|
|
110
|
+
site_key="0x4AAAAAAAB...",
|
|
111
|
+
url="https://example.com",
|
|
112
|
+
)
|
|
113
|
+
except AuthenticationError:
|
|
114
|
+
print("Bad API key.")
|
|
115
|
+
except InsufficientBalanceError:
|
|
116
|
+
print("Top up your balance.")
|
|
117
|
+
except RateLimitError:
|
|
118
|
+
print("Rate limited after all retries.")
|
|
119
|
+
except SolveError as e:
|
|
120
|
+
print(f"Solve failed: {e.message}")
|
|
121
|
+
except NSLSolverError as e:
|
|
122
|
+
print(f"API error (HTTP {e.status_code}): {e.message}")
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Rate-limit (429) and backend (503) errors are retried automatically with exponential backoff before raising.
|
|
126
|
+
|
|
127
|
+
## Configuration
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
solver = NSLSolver(
|
|
131
|
+
api_key="your-api-key",
|
|
132
|
+
base_url="https://api.nslsolver.com", # default
|
|
133
|
+
timeout=120, # seconds (default: 120)
|
|
134
|
+
max_retries=3, # retries for 429/503 (default: 3)
|
|
135
|
+
)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Both clients support context managers (`with` / `async with`) for session cleanup.
|
|
139
|
+
|
|
140
|
+
## Requirements
|
|
141
|
+
|
|
142
|
+
- Python 3.8+
|
|
143
|
+
- `requests` (sync client)
|
|
144
|
+
- `aiohttp` (async client, optional)
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
nslsolver/__init__.py
|
|
4
|
+
nslsolver/async_client.py
|
|
5
|
+
nslsolver/client.py
|
|
6
|
+
nslsolver/exceptions.py
|
|
7
|
+
nslsolver/types.py
|
|
8
|
+
nslsolver.egg-info/PKG-INFO
|
|
9
|
+
nslsolver.egg-info/SOURCES.txt
|
|
10
|
+
nslsolver.egg-info/dependency_links.txt
|
|
11
|
+
nslsolver.egg-info/requires.txt
|
|
12
|
+
nslsolver.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nslsolver
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nslsolver"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Official Python SDK for the NSLSolver captcha solving API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.8"
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "NSLSolver", email = "support@nslsolver.com"},
|
|
14
|
+
]
|
|
15
|
+
keywords = ["captcha", "turnstile", "cloudflare", "solver", "nslsolver"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 5 - Production/Stable",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.8",
|
|
23
|
+
"Programming Language :: Python :: 3.9",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Programming Language :: Python :: 3.13",
|
|
28
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
29
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
30
|
+
"Typing :: Typed",
|
|
31
|
+
]
|
|
32
|
+
dependencies = [
|
|
33
|
+
"requests>=2.25.0",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.optional-dependencies]
|
|
37
|
+
async = ["aiohttp>=3.8.0"]
|
|
38
|
+
dev = [
|
|
39
|
+
"aiohttp>=3.8.0",
|
|
40
|
+
"pytest>=7.0",
|
|
41
|
+
"pytest-asyncio>=0.21",
|
|
42
|
+
"mypy>=1.0",
|
|
43
|
+
"ruff>=0.1.0",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[project.urls]
|
|
47
|
+
Homepage = "https://nslsolver.com"
|
|
48
|
+
Documentation = "https://docs.nslsolver.com"
|
|
49
|
+
Repository = "https://github.com/NSLSolver/NSLSolver-SDK-Python"
|
|
50
|
+
Issues = "https://github.com/NSLSolver/NSLSolver-SDK-Python/issues"
|
|
51
|
+
|
|
52
|
+
[tool.setuptools.packages.find]
|
|
53
|
+
include = ["nslsolver*"]
|
|
54
|
+
|
|
55
|
+
[tool.mypy]
|
|
56
|
+
strict = true
|
|
57
|
+
python_version = "3.8"
|
|
58
|
+
|
|
59
|
+
[tool.ruff]
|
|
60
|
+
target-version = "py38"
|
|
61
|
+
line-length = 100
|
|
62
|
+
|
|
63
|
+
[tool.ruff.lint]
|
|
64
|
+
select = ["E", "F", "I", "N", "W", "UP"]
|