scrapelyserp 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.
- scrapelyserp/__init__.py +50 -0
- scrapelyserp/_base.py +272 -0
- scrapelyserp/_errors.py +92 -0
- scrapelyserp/_resources.py +8412 -0
- scrapelyserp/_routes.py +303 -0
- scrapelyserp/_version.py +1 -0
- scrapelyserp/py.typed +0 -0
- scrapelyserp/types.py +3991 -0
- scrapelyserp-0.1.0.dist-info/METADATA +151 -0
- scrapelyserp-0.1.0.dist-info/RECORD +12 -0
- scrapelyserp-0.1.0.dist-info/WHEEL +4 -0
- scrapelyserp-0.1.0.dist-info/licenses/LICENSE +21 -0
scrapelyserp/__init__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Official Python SDK for the Scrapely API (https://scrapelyserp.com).
|
|
2
|
+
|
|
3
|
+
from scrapelyserp import Scrapely
|
|
4
|
+
|
|
5
|
+
client = Scrapely() # reads SCRAPELY_API_KEY
|
|
6
|
+
print(client.account.get()["balance"])
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from . import types
|
|
10
|
+
from ._base import DEFAULT_BASE_URL, RequestOptions, normalize_base_url
|
|
11
|
+
from ._errors import (
|
|
12
|
+
APIConnectionError,
|
|
13
|
+
APIError,
|
|
14
|
+
APITimeoutError,
|
|
15
|
+
AuthenticationError,
|
|
16
|
+
BadRequestError,
|
|
17
|
+
ComingSoonError,
|
|
18
|
+
ConflictError,
|
|
19
|
+
InsufficientCreditsError,
|
|
20
|
+
InternalServerError,
|
|
21
|
+
NotFoundError,
|
|
22
|
+
PermissionDeniedError,
|
|
23
|
+
RateLimitError,
|
|
24
|
+
ScrapelyError,
|
|
25
|
+
)
|
|
26
|
+
from ._resources import AsyncScrapely, Scrapely
|
|
27
|
+
from ._version import __version__
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Scrapely",
|
|
31
|
+
"AsyncScrapely",
|
|
32
|
+
"RequestOptions",
|
|
33
|
+
"DEFAULT_BASE_URL",
|
|
34
|
+
"normalize_base_url",
|
|
35
|
+
"types",
|
|
36
|
+
"ScrapelyError",
|
|
37
|
+
"APIError",
|
|
38
|
+
"BadRequestError",
|
|
39
|
+
"AuthenticationError",
|
|
40
|
+
"InsufficientCreditsError",
|
|
41
|
+
"PermissionDeniedError",
|
|
42
|
+
"NotFoundError",
|
|
43
|
+
"ConflictError",
|
|
44
|
+
"RateLimitError",
|
|
45
|
+
"InternalServerError",
|
|
46
|
+
"ComingSoonError",
|
|
47
|
+
"APIConnectionError",
|
|
48
|
+
"APITimeoutError",
|
|
49
|
+
"__version__",
|
|
50
|
+
]
|
scrapelyserp/_base.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""The transport every resource method goes through: auth, retries, errors, pagination."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import email.utils
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import random
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from typing import Any, AsyncIterator, Dict, Iterator, Mapping, Optional, Tuple, TypedDict
|
|
12
|
+
from urllib.parse import quote
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from ._errors import APIConnectionError, APITimeoutError, AuthenticationError, error_for
|
|
17
|
+
from ._routes import ROUTES
|
|
18
|
+
from ._version import __version__
|
|
19
|
+
|
|
20
|
+
DEFAULT_BASE_URL = "https://scrapelyserp.com"
|
|
21
|
+
DEFAULT_TIMEOUT = 360.0 # seconds per attempt: a large product call may take up to 300 s
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RequestOptions(TypedDict, total=False):
|
|
25
|
+
"""Per-call overrides."""
|
|
26
|
+
|
|
27
|
+
headers: Mapping[str, str]
|
|
28
|
+
timeout: float
|
|
29
|
+
max_retries: int
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def normalize_base_url(url: str) -> str:
|
|
33
|
+
"""``https://api.scrapelyserp.com/v1/`` -> ``https://api.scrapelyserp.com``: the routes carry ``/api/v1``."""
|
|
34
|
+
return re.sub(r"/(api/)?v1$", "", url.rstrip("/"))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _merge(body: Optional[Mapping[str, Any]], fields: Mapping[str, Any]) -> Optional[Dict[str, Any]]:
|
|
38
|
+
if body is None and not fields:
|
|
39
|
+
return None
|
|
40
|
+
return {**(body or {}), **fields}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _retry_after(value: Optional[str]) -> Optional[float]:
|
|
44
|
+
if not value:
|
|
45
|
+
return None
|
|
46
|
+
try:
|
|
47
|
+
return max(0.0, float(value))
|
|
48
|
+
except ValueError:
|
|
49
|
+
parsed = email.utils.parsedate_to_datetime(value) if value else None
|
|
50
|
+
return max(0.0, parsed.timestamp() - time.time()) if parsed else None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class BaseClient:
|
|
54
|
+
"""Shared by the sync and async clients: configuration, request building, retry policy."""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
api_key: Optional[str] = None,
|
|
59
|
+
*,
|
|
60
|
+
base_url: Optional[str] = None,
|
|
61
|
+
max_retries: int = 2,
|
|
62
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
63
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
self.api_key = api_key if api_key is not None else os.environ.get("SCRAPELY_API_KEY") or None
|
|
66
|
+
self.base_url = normalize_base_url(base_url or os.environ.get("SCRAPELY_BASE_URL") or DEFAULT_BASE_URL)
|
|
67
|
+
self.max_retries = max_retries
|
|
68
|
+
self.timeout = timeout
|
|
69
|
+
self._headers = dict(headers or {})
|
|
70
|
+
|
|
71
|
+
def _build(
|
|
72
|
+
self,
|
|
73
|
+
operation_id: str,
|
|
74
|
+
path: Optional[Mapping[str, Any]],
|
|
75
|
+
query: Optional[Mapping[str, Any]],
|
|
76
|
+
body: Any,
|
|
77
|
+
idempotency_key: Optional[str],
|
|
78
|
+
options: Optional[RequestOptions],
|
|
79
|
+
) -> Tuple[str, str, Dict[str, str], Dict[str, str], Optional[bytes]]:
|
|
80
|
+
if operation_id not in ROUTES:
|
|
81
|
+
raise ValueError(f"Unknown operation: {operation_id}")
|
|
82
|
+
method, template, flags = ROUTES[operation_id]
|
|
83
|
+
if not self.api_key and not flags & 1:
|
|
84
|
+
raise AuthenticationError(401, {"error": {"code": "UNAUTHORIZED", "message": "No API key: pass api_key or set SCRAPELY_API_KEY."}})
|
|
85
|
+
|
|
86
|
+
def sub(m: "re.Match[str]") -> str:
|
|
87
|
+
value = (path or {}).get(m.group(1))
|
|
88
|
+
if value is None or value == "":
|
|
89
|
+
raise ValueError(f"{operation_id}: missing path parameter `{m.group(1)}`")
|
|
90
|
+
return quote(str(value), safe="")
|
|
91
|
+
|
|
92
|
+
url = self.base_url + re.sub(r"\{(\w+)\}", sub, template)
|
|
93
|
+
params: Dict[str, str] = {}
|
|
94
|
+
for k, v in (query or {}).items():
|
|
95
|
+
if v is None:
|
|
96
|
+
continue
|
|
97
|
+
if isinstance(v, bool):
|
|
98
|
+
v = "true" if v else "false"
|
|
99
|
+
elif isinstance(v, (list, tuple)):
|
|
100
|
+
v = ",".join(str(x) for x in v)
|
|
101
|
+
params[k] = str(v)
|
|
102
|
+
headers = {"accept": "application/json", "user-agent": f"scrapelyserp-python/{__version__}", **self._headers}
|
|
103
|
+
if self.api_key:
|
|
104
|
+
headers["authorization"] = f"Bearer {self.api_key}"
|
|
105
|
+
content = None
|
|
106
|
+
if body is not None:
|
|
107
|
+
headers["content-type"] = "application/json"
|
|
108
|
+
content = json.dumps(body).encode()
|
|
109
|
+
if flags & 2:
|
|
110
|
+
# Generated once per call, so a retry can never create a second resource.
|
|
111
|
+
headers["idempotency-key"] = idempotency_key or str(uuid.uuid4())
|
|
112
|
+
headers.update((options or {}).get("headers") or {})
|
|
113
|
+
return method, url, params, headers, content
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _parse(response: httpx.Response) -> Any:
|
|
117
|
+
text = response.text
|
|
118
|
+
if text and "json" in response.headers.get("content-type", ""):
|
|
119
|
+
try:
|
|
120
|
+
return json.loads(text)
|
|
121
|
+
except ValueError:
|
|
122
|
+
return text
|
|
123
|
+
return text or None
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
def _retryable(status: int) -> bool:
|
|
127
|
+
return status == 429 or (status >= 500 and status != 501)
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def _backoff(attempt: int, retry_after: Optional[str]) -> float:
|
|
131
|
+
"""``Retry-After`` when sent (capped at 60 s), else 0.5 s, 1 s, 2 s... (max 8 s) with jitter."""
|
|
132
|
+
ra = _retry_after(retry_after)
|
|
133
|
+
if ra is not None:
|
|
134
|
+
return min(ra, 60.0)
|
|
135
|
+
return min(0.5 * 2**attempt, 8.0) * (0.75 + random.random() * 0.25)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class SyncBaseClient(BaseClient):
|
|
139
|
+
def __init__(self, api_key: Optional[str] = None, *, http_client: Optional[httpx.Client] = None, **kwargs: Any) -> None:
|
|
140
|
+
super().__init__(api_key, **kwargs)
|
|
141
|
+
self._http = http_client or httpx.Client()
|
|
142
|
+
|
|
143
|
+
def close(self) -> None:
|
|
144
|
+
self._http.close()
|
|
145
|
+
|
|
146
|
+
def __enter__(self) -> "SyncBaseClient":
|
|
147
|
+
return self
|
|
148
|
+
|
|
149
|
+
def __exit__(self, *exc: Any) -> None:
|
|
150
|
+
self.close()
|
|
151
|
+
|
|
152
|
+
def request(
|
|
153
|
+
self,
|
|
154
|
+
operation_id: str,
|
|
155
|
+
*,
|
|
156
|
+
path: Optional[Mapping[str, Any]] = None,
|
|
157
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
158
|
+
body: Any = None,
|
|
159
|
+
idempotency_key: Optional[str] = None,
|
|
160
|
+
options: Optional[RequestOptions] = None,
|
|
161
|
+
) -> Any:
|
|
162
|
+
"""Calls any operation by its operationId. The resource methods are typed wrappers of this."""
|
|
163
|
+
return self._request(operation_id, path=path, query=query, body=body, idempotency_key=idempotency_key, options=options)
|
|
164
|
+
|
|
165
|
+
def _request(self, operation_id: str, *, path: Any = None, query: Any = None, body: Any = None, idempotency_key: Optional[str] = None, options: Optional[RequestOptions] = None) -> Any:
|
|
166
|
+
method, url, params, headers, content = self._build(operation_id, path, query, body, idempotency_key, options)
|
|
167
|
+
opts = options or {}
|
|
168
|
+
max_retries = opts.get("max_retries", self.max_retries)
|
|
169
|
+
attempt = 0
|
|
170
|
+
while True:
|
|
171
|
+
try:
|
|
172
|
+
res = self._http.request(method, url, params=params, headers=headers, content=content, timeout=opts.get("timeout", self.timeout))
|
|
173
|
+
except httpx.TimeoutException as e:
|
|
174
|
+
if attempt < max_retries:
|
|
175
|
+
time.sleep(self._backoff(attempt, None))
|
|
176
|
+
attempt += 1
|
|
177
|
+
continue
|
|
178
|
+
raise APITimeoutError(f"Timed out: {e}") from e
|
|
179
|
+
except httpx.TransportError as e:
|
|
180
|
+
if attempt < max_retries:
|
|
181
|
+
time.sleep(self._backoff(attempt, None))
|
|
182
|
+
attempt += 1
|
|
183
|
+
continue
|
|
184
|
+
raise APIConnectionError(f"Connection error: {e}") from e
|
|
185
|
+
data = self._parse(res)
|
|
186
|
+
if res.is_success:
|
|
187
|
+
return data
|
|
188
|
+
if self._retryable(res.status_code) and attempt < max_retries:
|
|
189
|
+
time.sleep(self._backoff(attempt, res.headers.get("retry-after")))
|
|
190
|
+
attempt += 1
|
|
191
|
+
continue
|
|
192
|
+
raise error_for(res.status_code, data, res.headers)
|
|
193
|
+
|
|
194
|
+
def _paginate(self, operation_id: str, *, path: Any = None, query: Any = None, options: Optional[RequestOptions] = None, **_: Any) -> Iterator[Any]:
|
|
195
|
+
q = dict(query or {})
|
|
196
|
+
while True:
|
|
197
|
+
page = self._request(operation_id, path=path, query=q, options=options) or {}
|
|
198
|
+
data = page.get("data") or []
|
|
199
|
+
yield from data
|
|
200
|
+
cursor = page.get("next_starting_after") or (data[-1].get("id") if data and isinstance(data[-1], dict) else None)
|
|
201
|
+
if not page.get("has_more") or not cursor:
|
|
202
|
+
return
|
|
203
|
+
q["starting_after"] = cursor
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class AsyncBaseClient(BaseClient):
|
|
207
|
+
def __init__(self, api_key: Optional[str] = None, *, http_client: Optional[httpx.AsyncClient] = None, **kwargs: Any) -> None:
|
|
208
|
+
super().__init__(api_key, **kwargs)
|
|
209
|
+
self._http = http_client or httpx.AsyncClient()
|
|
210
|
+
|
|
211
|
+
async def close(self) -> None:
|
|
212
|
+
await self._http.aclose()
|
|
213
|
+
|
|
214
|
+
async def __aenter__(self) -> "AsyncBaseClient":
|
|
215
|
+
return self
|
|
216
|
+
|
|
217
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
218
|
+
await self.close()
|
|
219
|
+
|
|
220
|
+
async def request(
|
|
221
|
+
self,
|
|
222
|
+
operation_id: str,
|
|
223
|
+
*,
|
|
224
|
+
path: Optional[Mapping[str, Any]] = None,
|
|
225
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
226
|
+
body: Any = None,
|
|
227
|
+
idempotency_key: Optional[str] = None,
|
|
228
|
+
options: Optional[RequestOptions] = None,
|
|
229
|
+
) -> Any:
|
|
230
|
+
"""Calls any operation by its operationId. The resource methods are typed wrappers of this."""
|
|
231
|
+
return await self._request(operation_id, path=path, query=query, body=body, idempotency_key=idempotency_key, options=options)
|
|
232
|
+
|
|
233
|
+
async def _request(self, operation_id: str, *, path: Any = None, query: Any = None, body: Any = None, idempotency_key: Optional[str] = None, options: Optional[RequestOptions] = None) -> Any:
|
|
234
|
+
method, url, params, headers, content = self._build(operation_id, path, query, body, idempotency_key, options)
|
|
235
|
+
opts = options or {}
|
|
236
|
+
max_retries = opts.get("max_retries", self.max_retries)
|
|
237
|
+
attempt = 0
|
|
238
|
+
while True:
|
|
239
|
+
try:
|
|
240
|
+
res = await self._http.request(method, url, params=params, headers=headers, content=content, timeout=opts.get("timeout", self.timeout))
|
|
241
|
+
except httpx.TimeoutException as e:
|
|
242
|
+
if attempt < max_retries:
|
|
243
|
+
await asyncio.sleep(self._backoff(attempt, None))
|
|
244
|
+
attempt += 1
|
|
245
|
+
continue
|
|
246
|
+
raise APITimeoutError(f"Timed out: {e}") from e
|
|
247
|
+
except httpx.TransportError as e:
|
|
248
|
+
if attempt < max_retries:
|
|
249
|
+
await asyncio.sleep(self._backoff(attempt, None))
|
|
250
|
+
attempt += 1
|
|
251
|
+
continue
|
|
252
|
+
raise APIConnectionError(f"Connection error: {e}") from e
|
|
253
|
+
data = self._parse(res)
|
|
254
|
+
if res.is_success:
|
|
255
|
+
return data
|
|
256
|
+
if self._retryable(res.status_code) and attempt < max_retries:
|
|
257
|
+
await asyncio.sleep(self._backoff(attempt, res.headers.get("retry-after")))
|
|
258
|
+
attempt += 1
|
|
259
|
+
continue
|
|
260
|
+
raise error_for(res.status_code, data, res.headers)
|
|
261
|
+
|
|
262
|
+
async def _paginate(self, operation_id: str, *, path: Any = None, query: Any = None, options: Optional[RequestOptions] = None, **_: Any) -> AsyncIterator[Any]:
|
|
263
|
+
q = dict(query or {})
|
|
264
|
+
while True:
|
|
265
|
+
page = await self._request(operation_id, path=path, query=q, options=options) or {}
|
|
266
|
+
data = page.get("data") or []
|
|
267
|
+
for item in data:
|
|
268
|
+
yield item
|
|
269
|
+
cursor = page.get("next_starting_after") or (data[-1].get("id") if data and isinstance(data[-1], dict) else None)
|
|
270
|
+
if not page.get("has_more") or not cursor:
|
|
271
|
+
return
|
|
272
|
+
q["starting_after"] = cursor
|
scrapelyserp/_errors.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Every error the SDK raises is a ScrapelyError; API answers are an APIError subclass by status."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional, Type
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ScrapelyError(Exception):
|
|
9
|
+
"""Base class of every error this package raises."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class APIError(ScrapelyError):
|
|
13
|
+
"""The API answered with an error: ``{"error": {"code", "message", "param?", "docs"}}``."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, status: int, body: Any, headers: Optional[httpx.Headers] = None) -> None:
|
|
16
|
+
err: Dict[str, Any] = body.get("error", {}) if isinstance(body, dict) and isinstance(body.get("error"), dict) else {}
|
|
17
|
+
#: HTTP status.
|
|
18
|
+
self.status: int = status
|
|
19
|
+
#: Stable, machine-readable (``INVALID_PARAM``, ``INSUFFICIENT_CREDITS``...). Branch on this.
|
|
20
|
+
self.code: str = err.get("code") or f"HTTP_{status}"
|
|
21
|
+
#: The parameter at fault, when there is one.
|
|
22
|
+
self.param: Optional[str] = err.get("param")
|
|
23
|
+
#: Link to this code's documentation.
|
|
24
|
+
self.docs: Optional[str] = err.get("docs")
|
|
25
|
+
#: ``X-Request-Id``: quote it to support.
|
|
26
|
+
self.request_id: Optional[str] = headers.get("x-request-id") if headers is not None else None
|
|
27
|
+
self.body: Any = body
|
|
28
|
+
self.headers = headers
|
|
29
|
+
message = err.get("message") or (body[:200] if isinstance(body, str) and body else f"HTTP {status}")
|
|
30
|
+
self.message: str = message
|
|
31
|
+
super().__init__(f"{self.code}: {message}" + (f" (param: {self.param})" if self.param else ""))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class BadRequestError(APIError):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AuthenticationError(APIError):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class InsufficientCreditsError(APIError):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PermissionDeniedError(APIError):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class NotFoundError(APIError):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ConflictError(APIError):
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class RateLimitError(APIError):
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class InternalServerError(APIError):
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ComingSoonError(APIError):
|
|
67
|
+
"""501 ``NOT_IMPLEMENTED``: announced but not live yet. Nothing was charged."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class APIConnectionError(ScrapelyError):
|
|
71
|
+
"""The request never got an answer (DNS, connection reset, offline)."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class APITimeoutError(APIConnectionError):
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
_BY_STATUS: Dict[int, Type[APIError]] = {
|
|
79
|
+
400: BadRequestError,
|
|
80
|
+
401: AuthenticationError,
|
|
81
|
+
402: InsufficientCreditsError,
|
|
82
|
+
403: PermissionDeniedError,
|
|
83
|
+
404: NotFoundError,
|
|
84
|
+
409: ConflictError,
|
|
85
|
+
429: RateLimitError,
|
|
86
|
+
501: ComingSoonError,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def error_for(status: int, body: Any, headers: Optional[httpx.Headers] = None) -> APIError:
|
|
91
|
+
cls = _BY_STATUS.get(status) or (InternalServerError if status >= 500 else APIError)
|
|
92
|
+
return cls(status, body, headers)
|