scrapely-python 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.
- scrapely/__init__.py +246 -0
- scrapely/_browser_locations.py +5470 -0
- scrapely/actor.py +1836 -0
- scrapely/browser_configuration.py +146 -0
- scrapely/charging.py +1143 -0
- scrapely/charging_client.py +107 -0
- scrapely/configuration.py +375 -0
- scrapely/crypto/__init__.py +21 -0
- scrapely/crypto/_secrets.py +210 -0
- scrapely/errors.py +153 -0
- scrapely/events/__init__.py +57 -0
- scrapely/events/_types.py +194 -0
- scrapely/events/event_manager.py +292 -0
- scrapely/log.py +39 -0
- scrapely/patch_browser_pool.py +861 -0
- scrapely/pricing_types.py +172 -0
- scrapely/proxy_configuration.py +741 -0
- scrapely/py.typed +2 -0
- scrapely/request_loaders/__init__.py +5 -0
- scrapely/request_loaders/_scrapely_request_list.py +169 -0
- scrapely/storage/__init__.py +48 -0
- scrapely/storage/_file_system/__init__.py +9 -0
- scrapely/storage/_file_system/_key_value_store_client.py +155 -0
- scrapely/storage/_file_system/_storage_client.py +50 -0
- scrapely/storage/_metadata.py +138 -0
- scrapely/storage/api_client.py +350 -0
- scrapely/storage/client.py +618 -0
- scrapely/storage/patch_storage.py +257 -0
- scrapely/storage/scrapely_dataset_client.py +305 -0
- scrapely/storage/scrapely_kvs_client.py +220 -0
- scrapely/storage/scrapely_rq_client.py +496 -0
- scrapely/storage/scrapely_storage_client.py +192 -0
- scrapely/storage/smart_scrapely_storage_client.py +109 -0
- scrapely/storage/types.py +601 -0
- scrapely/storage/utils.py +38 -0
- scrapely/storage_clients/__init__.py +25 -0
- scrapely/storages/__init__.py +5 -0
- scrapely/types.py +269 -0
- scrapely/webhook.py +82 -0
- scrapely_python-0.1.0.dist-info/METADATA +83 -0
- scrapely_python-0.1.0.dist-info/RECORD +43 -0
- scrapely_python-0.1.0.dist-info/WHEEL +5 -0
- scrapely_python-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base API client for Scrapely storage operations.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import logging
|
|
9
|
+
import random
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, Dict, Optional, Union
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ApiClientError(Exception):
|
|
19
|
+
"""HTTP error from the Scrapely API."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str, status_code: int, body: Any = None):
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.status_code = status_code
|
|
24
|
+
self.body = body
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def unwrap_data(value: Any) -> Any:
|
|
28
|
+
"""Unwrap Apify-style v2 response envelopes ({ data: ... })."""
|
|
29
|
+
if isinstance(value, list):
|
|
30
|
+
return value
|
|
31
|
+
if isinstance(value, dict) and 'data' in value:
|
|
32
|
+
return value['data']
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AdaptiveRateLimiter:
|
|
37
|
+
"""Adaptive throttling based on X-RateLimit-* headers (TS AdaptiveRateLimiter)."""
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
self.limit = 1000
|
|
41
|
+
self.remaining = 1000
|
|
42
|
+
self.reset_at = 0.0
|
|
43
|
+
self.low_water_mark = 50
|
|
44
|
+
self.min_delay = 0.05
|
|
45
|
+
self.max_delay = 1.0
|
|
46
|
+
self.last_request_time = 0.0
|
|
47
|
+
self._rate_limit_errors: Dict[int, int] = {}
|
|
48
|
+
|
|
49
|
+
def update_from_headers(self, headers: httpx.Headers) -> None:
|
|
50
|
+
limit_header = headers.get('X-RateLimit-Limit')
|
|
51
|
+
remaining_header = headers.get('X-RateLimit-Remaining')
|
|
52
|
+
reset_header = headers.get('X-RateLimit-Reset')
|
|
53
|
+
if limit_header:
|
|
54
|
+
try:
|
|
55
|
+
self.limit = int(limit_header) or 600
|
|
56
|
+
except ValueError:
|
|
57
|
+
pass
|
|
58
|
+
if remaining_header:
|
|
59
|
+
try:
|
|
60
|
+
self.remaining = int(remaining_header)
|
|
61
|
+
except ValueError:
|
|
62
|
+
pass
|
|
63
|
+
if reset_header:
|
|
64
|
+
try:
|
|
65
|
+
self.reset_at = float(reset_header) * 1000
|
|
66
|
+
except ValueError:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
async def wait_for_slot(self) -> None:
|
|
70
|
+
now = time.time() * 1000
|
|
71
|
+
if now >= self.reset_at:
|
|
72
|
+
self.remaining = self.limit
|
|
73
|
+
|
|
74
|
+
delay = self.min_delay
|
|
75
|
+
if self.remaining < self.low_water_mark:
|
|
76
|
+
ratio = self.remaining / self.low_water_mark if self.low_water_mark else 0
|
|
77
|
+
delay = min(self.max_delay, self.min_delay + (1 - ratio) * 0.5)
|
|
78
|
+
elif self.remaining < self.limit * 0.2:
|
|
79
|
+
delay = 0.1
|
|
80
|
+
|
|
81
|
+
time_since_last = (now - self.last_request_time) / 1000
|
|
82
|
+
if time_since_last < delay:
|
|
83
|
+
await asyncio.sleep(delay - time_since_last)
|
|
84
|
+
|
|
85
|
+
self.last_request_time = time.time() * 1000
|
|
86
|
+
|
|
87
|
+
async def handle_rate_limited(self, response: httpx.Response) -> None:
|
|
88
|
+
self._rate_limit_errors[429] = self._rate_limit_errors.get(429, 0) + 1
|
|
89
|
+
retry_after = response.headers.get('Retry-After')
|
|
90
|
+
wait_ms = 1000.0
|
|
91
|
+
if retry_after:
|
|
92
|
+
try:
|
|
93
|
+
wait_ms = float(retry_after) * 1000
|
|
94
|
+
except ValueError:
|
|
95
|
+
try:
|
|
96
|
+
from email.utils import parsedate_to_datetime
|
|
97
|
+
|
|
98
|
+
retry_date = parsedate_to_datetime(retry_after).timestamp() * 1000
|
|
99
|
+
wait_ms = max(0.0, retry_date - time.time() * 1000)
|
|
100
|
+
except Exception:
|
|
101
|
+
wait_ms = 1000.0
|
|
102
|
+
|
|
103
|
+
wait_ms = wait_ms + random.random() * 100
|
|
104
|
+
logger.warning('[RateLimiter] Rate limited, waiting %.0fms before retry', wait_ms)
|
|
105
|
+
await asyncio.sleep(wait_ms / 1000)
|
|
106
|
+
|
|
107
|
+
def get_rate_limit_errors(self) -> Dict[int, int]:
|
|
108
|
+
return dict(self._rate_limit_errors)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ApiClient:
|
|
112
|
+
"""Base HTTP client for Scrapely API operations.
|
|
113
|
+
|
|
114
|
+
Handles authentication, rate limiting, and error handling for all API calls.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
_rate_limiter = AdaptiveRateLimiter()
|
|
118
|
+
_max_retries = 3
|
|
119
|
+
|
|
120
|
+
def __init__(
|
|
121
|
+
self,
|
|
122
|
+
api_url: str,
|
|
123
|
+
token: str,
|
|
124
|
+
run_id: str,
|
|
125
|
+
timeout: float = 30.0,
|
|
126
|
+
transport: Optional[httpx.AsyncBaseTransport] = None,
|
|
127
|
+
):
|
|
128
|
+
self.api_url = api_url.rstrip('/')
|
|
129
|
+
self.token = token
|
|
130
|
+
self.run_id = run_id
|
|
131
|
+
self.timeout = timeout
|
|
132
|
+
self._transport = transport
|
|
133
|
+
self._client: Optional[httpx.AsyncClient] = None
|
|
134
|
+
|
|
135
|
+
async def __aenter__(self) -> 'ApiClient':
|
|
136
|
+
await self._ensure_client()
|
|
137
|
+
return self
|
|
138
|
+
|
|
139
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
140
|
+
await self.close()
|
|
141
|
+
|
|
142
|
+
async def _ensure_client(self) -> httpx.AsyncClient:
|
|
143
|
+
if self._client is None or self._client.is_closed:
|
|
144
|
+
kwargs: Dict[str, Any] = {
|
|
145
|
+
'timeout': self.timeout,
|
|
146
|
+
'headers': {
|
|
147
|
+
'Authorization': f'Bearer {self.token}',
|
|
148
|
+
'X-Scrapely-Run-Id': self.run_id,
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
if self._transport is not None:
|
|
152
|
+
kwargs['transport'] = self._transport
|
|
153
|
+
self._client = httpx.AsyncClient(**kwargs)
|
|
154
|
+
return self._client
|
|
155
|
+
|
|
156
|
+
async def close(self) -> None:
|
|
157
|
+
if self._client and not self._client.is_closed:
|
|
158
|
+
await self._client.aclose()
|
|
159
|
+
self._client = None
|
|
160
|
+
|
|
161
|
+
def get_rate_limit_errors(self) -> Dict[int, int]:
|
|
162
|
+
return self._rate_limiter.get_rate_limit_errors()
|
|
163
|
+
|
|
164
|
+
def _url(self, path: str) -> str:
|
|
165
|
+
if path.startswith('http://') or path.startswith('https://'):
|
|
166
|
+
return path
|
|
167
|
+
if not path.startswith('/'):
|
|
168
|
+
path = f'/{path}'
|
|
169
|
+
return f'{self.api_url}{path}'
|
|
170
|
+
|
|
171
|
+
async def _raise_for_status(self, response: httpx.Response) -> None:
|
|
172
|
+
if response.is_success:
|
|
173
|
+
return
|
|
174
|
+
body: Any = None
|
|
175
|
+
message = response.reason_phrase
|
|
176
|
+
try:
|
|
177
|
+
body = response.json()
|
|
178
|
+
if isinstance(body, dict):
|
|
179
|
+
error = body.get('error') or {}
|
|
180
|
+
if isinstance(error, dict) and error.get('message'):
|
|
181
|
+
message = error['message']
|
|
182
|
+
elif body.get('message'):
|
|
183
|
+
message = body['message']
|
|
184
|
+
except Exception:
|
|
185
|
+
body = response.text
|
|
186
|
+
if body:
|
|
187
|
+
message = body
|
|
188
|
+
raise ApiClientError(message, response.status_code, body)
|
|
189
|
+
|
|
190
|
+
async def request(
|
|
191
|
+
self,
|
|
192
|
+
method: str,
|
|
193
|
+
path: str,
|
|
194
|
+
*,
|
|
195
|
+
params: Optional[Dict[str, Any]] = None,
|
|
196
|
+
json_data: Any = None,
|
|
197
|
+
content: Optional[Union[bytes, str]] = None,
|
|
198
|
+
headers: Optional[Dict[str, str]] = None,
|
|
199
|
+
raise_for_status: bool = True,
|
|
200
|
+
do_not_retry_timeouts: bool = False,
|
|
201
|
+
) -> httpx.Response:
|
|
202
|
+
"""Make an HTTP request with adaptive rate limiting and 429 retries."""
|
|
203
|
+
client = await self._ensure_client()
|
|
204
|
+
url = self._url(path)
|
|
205
|
+
logger.debug('%s %s params=%s', method, url, params)
|
|
206
|
+
|
|
207
|
+
request_headers = dict(headers or {})
|
|
208
|
+
if json_data is not None and 'Content-Type' not in request_headers:
|
|
209
|
+
request_headers['Content-Type'] = 'application/json'
|
|
210
|
+
|
|
211
|
+
last_error: Optional[Exception] = None
|
|
212
|
+
for attempt in range(self._max_retries + 1):
|
|
213
|
+
await self._rate_limiter.wait_for_slot()
|
|
214
|
+
try:
|
|
215
|
+
response = await client.request(
|
|
216
|
+
method,
|
|
217
|
+
url,
|
|
218
|
+
params=params,
|
|
219
|
+
json=json_data,
|
|
220
|
+
content=content,
|
|
221
|
+
headers=request_headers or None,
|
|
222
|
+
)
|
|
223
|
+
except httpx.TimeoutException as exc:
|
|
224
|
+
last_error = exc
|
|
225
|
+
if do_not_retry_timeouts or attempt >= self._max_retries:
|
|
226
|
+
raise
|
|
227
|
+
await asyncio.sleep(0.25 * (attempt + 1))
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
self._rate_limiter.update_from_headers(response.headers)
|
|
231
|
+
|
|
232
|
+
if response.status_code == 429 and attempt < self._max_retries:
|
|
233
|
+
await self._rate_limiter.handle_rate_limited(response)
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
if raise_for_status:
|
|
237
|
+
await self._raise_for_status(response)
|
|
238
|
+
return response
|
|
239
|
+
|
|
240
|
+
if last_error is not None:
|
|
241
|
+
raise last_error
|
|
242
|
+
raise ApiClientError('Request failed after retries', 429)
|
|
243
|
+
|
|
244
|
+
async def get(
|
|
245
|
+
self,
|
|
246
|
+
path: str,
|
|
247
|
+
params: Optional[Dict[str, Any]] = None,
|
|
248
|
+
*,
|
|
249
|
+
raise_for_status: bool = True,
|
|
250
|
+
) -> httpx.Response:
|
|
251
|
+
return await self.request('GET', path, params=params, raise_for_status=raise_for_status)
|
|
252
|
+
|
|
253
|
+
async def head(
|
|
254
|
+
self,
|
|
255
|
+
path: str,
|
|
256
|
+
params: Optional[Dict[str, Any]] = None,
|
|
257
|
+
*,
|
|
258
|
+
raise_for_status: bool = True,
|
|
259
|
+
) -> httpx.Response:
|
|
260
|
+
return await self.request('HEAD', path, params=params, raise_for_status=raise_for_status)
|
|
261
|
+
|
|
262
|
+
async def post(
|
|
263
|
+
self,
|
|
264
|
+
path: str,
|
|
265
|
+
json_data: Any = None,
|
|
266
|
+
content: Optional[Union[bytes, str]] = None,
|
|
267
|
+
headers: Optional[Dict[str, str]] = None,
|
|
268
|
+
params: Optional[Dict[str, Any]] = None,
|
|
269
|
+
*,
|
|
270
|
+
raise_for_status: bool = True,
|
|
271
|
+
) -> httpx.Response:
|
|
272
|
+
return await self.request(
|
|
273
|
+
'POST',
|
|
274
|
+
path,
|
|
275
|
+
params=params,
|
|
276
|
+
json_data=json_data,
|
|
277
|
+
content=content,
|
|
278
|
+
headers=headers,
|
|
279
|
+
raise_for_status=raise_for_status,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
async def put(
|
|
283
|
+
self,
|
|
284
|
+
path: str,
|
|
285
|
+
json_data: Any = None,
|
|
286
|
+
content: Optional[Union[bytes, str]] = None,
|
|
287
|
+
headers: Optional[Dict[str, str]] = None,
|
|
288
|
+
params: Optional[Dict[str, Any]] = None,
|
|
289
|
+
*,
|
|
290
|
+
raise_for_status: bool = True,
|
|
291
|
+
) -> httpx.Response:
|
|
292
|
+
return await self.request(
|
|
293
|
+
'PUT',
|
|
294
|
+
path,
|
|
295
|
+
params=params,
|
|
296
|
+
json_data=json_data,
|
|
297
|
+
content=content,
|
|
298
|
+
headers=headers,
|
|
299
|
+
raise_for_status=raise_for_status,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
async def delete(
|
|
303
|
+
self,
|
|
304
|
+
path: str,
|
|
305
|
+
params: Optional[Dict[str, Any]] = None,
|
|
306
|
+
json_data: Any = None,
|
|
307
|
+
*,
|
|
308
|
+
raise_for_status: bool = True,
|
|
309
|
+
) -> httpx.Response:
|
|
310
|
+
return await self.request(
|
|
311
|
+
'DELETE',
|
|
312
|
+
path,
|
|
313
|
+
params=params,
|
|
314
|
+
json_data=json_data,
|
|
315
|
+
raise_for_status=raise_for_status,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
async def get_json(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
319
|
+
response = await self.get(path, params=params)
|
|
320
|
+
return unwrap_data(response.json())
|
|
321
|
+
|
|
322
|
+
async def post_json(
|
|
323
|
+
self,
|
|
324
|
+
path: str,
|
|
325
|
+
data: Any = None,
|
|
326
|
+
params: Optional[Dict[str, Any]] = None,
|
|
327
|
+
headers: Optional[Dict[str, str]] = None,
|
|
328
|
+
) -> Any:
|
|
329
|
+
response = await self.post(path, json_data=data, params=params, headers=headers)
|
|
330
|
+
if not response.content:
|
|
331
|
+
return {}
|
|
332
|
+
try:
|
|
333
|
+
return unwrap_data(response.json())
|
|
334
|
+
except Exception:
|
|
335
|
+
return {}
|
|
336
|
+
|
|
337
|
+
async def put_json(
|
|
338
|
+
self,
|
|
339
|
+
path: str,
|
|
340
|
+
data: Any = None,
|
|
341
|
+
params: Optional[Dict[str, Any]] = None,
|
|
342
|
+
headers: Optional[Dict[str, str]] = None,
|
|
343
|
+
) -> Any:
|
|
344
|
+
response = await self.put(path, json_data=data, params=params, headers=headers)
|
|
345
|
+
if not response.content:
|
|
346
|
+
return {}
|
|
347
|
+
try:
|
|
348
|
+
return unwrap_data(response.json())
|
|
349
|
+
except Exception:
|
|
350
|
+
return {}
|