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.
Files changed (43) hide show
  1. scrapely/__init__.py +246 -0
  2. scrapely/_browser_locations.py +5470 -0
  3. scrapely/actor.py +1836 -0
  4. scrapely/browser_configuration.py +146 -0
  5. scrapely/charging.py +1143 -0
  6. scrapely/charging_client.py +107 -0
  7. scrapely/configuration.py +375 -0
  8. scrapely/crypto/__init__.py +21 -0
  9. scrapely/crypto/_secrets.py +210 -0
  10. scrapely/errors.py +153 -0
  11. scrapely/events/__init__.py +57 -0
  12. scrapely/events/_types.py +194 -0
  13. scrapely/events/event_manager.py +292 -0
  14. scrapely/log.py +39 -0
  15. scrapely/patch_browser_pool.py +861 -0
  16. scrapely/pricing_types.py +172 -0
  17. scrapely/proxy_configuration.py +741 -0
  18. scrapely/py.typed +2 -0
  19. scrapely/request_loaders/__init__.py +5 -0
  20. scrapely/request_loaders/_scrapely_request_list.py +169 -0
  21. scrapely/storage/__init__.py +48 -0
  22. scrapely/storage/_file_system/__init__.py +9 -0
  23. scrapely/storage/_file_system/_key_value_store_client.py +155 -0
  24. scrapely/storage/_file_system/_storage_client.py +50 -0
  25. scrapely/storage/_metadata.py +138 -0
  26. scrapely/storage/api_client.py +350 -0
  27. scrapely/storage/client.py +618 -0
  28. scrapely/storage/patch_storage.py +257 -0
  29. scrapely/storage/scrapely_dataset_client.py +305 -0
  30. scrapely/storage/scrapely_kvs_client.py +220 -0
  31. scrapely/storage/scrapely_rq_client.py +496 -0
  32. scrapely/storage/scrapely_storage_client.py +192 -0
  33. scrapely/storage/smart_scrapely_storage_client.py +109 -0
  34. scrapely/storage/types.py +601 -0
  35. scrapely/storage/utils.py +38 -0
  36. scrapely/storage_clients/__init__.py +25 -0
  37. scrapely/storages/__init__.py +5 -0
  38. scrapely/types.py +269 -0
  39. scrapely/webhook.py +82 -0
  40. scrapely_python-0.1.0.dist-info/METADATA +83 -0
  41. scrapely_python-0.1.0.dist-info/RECORD +43 -0
  42. scrapely_python-0.1.0.dist-info/WHEEL +5 -0
  43. 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 {}