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,496 @@
|
|
|
1
|
+
"""Scrapely cloud RequestQueueClient for Crawlee."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
from typing import Any, Dict, Optional, Sequence, Union
|
|
8
|
+
|
|
9
|
+
from typing_extensions import Self, override
|
|
10
|
+
|
|
11
|
+
from crawlee import Request
|
|
12
|
+
from crawlee.storage_clients.models import ProcessedRequest as CrawleeProcessedRequest
|
|
13
|
+
from crawlee.storage_clients._base import RequestQueueClient
|
|
14
|
+
from crawlee.storage_clients.models import AddRequestsResponse, ProcessedRequest, RequestQueueMetadata, UnprocessedRequest
|
|
15
|
+
|
|
16
|
+
from .api_client import ApiClientError
|
|
17
|
+
from .client import DEFAULT_LOCK_SECS, RQ_BATCH_SIZE, ApiStorageClient
|
|
18
|
+
from ._metadata import resolve_storage_id, to_rq_metadata
|
|
19
|
+
from .utils import unique_key_to_request_id
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _is_lost_lock_error(exc: ApiClientError) -> bool:
|
|
25
|
+
"""True when the API rejected a lock operation only because this client no
|
|
26
|
+
longer holds the lock: 403 "Request is not locked by this client" or 409
|
|
27
|
+
"Request with ID ... is not locked"."""
|
|
28
|
+
return exc.status_code in (403, 409) and 'not locked' in str(exc).lower()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _old_rq_to_crawlee(meta: Any) -> RequestQueueMetadata:
|
|
32
|
+
return to_rq_metadata(
|
|
33
|
+
{
|
|
34
|
+
'id': meta.id,
|
|
35
|
+
'name': meta.name,
|
|
36
|
+
'createdAt': meta.created_at.isoformat() if getattr(meta, 'created_at', None) else None,
|
|
37
|
+
'accessedAt': meta.accessed_at.isoformat() if getattr(meta, 'accessed_at', None) else None,
|
|
38
|
+
'modifiedAt': meta.modified_at.isoformat() if getattr(meta, 'modified_at', None) else None,
|
|
39
|
+
'hadMultipleClients': getattr(meta, 'had_multiple_clients', False),
|
|
40
|
+
'handledRequestCount': getattr(meta, 'handled_request_count', 0),
|
|
41
|
+
'pendingRequestCount': getattr(meta, 'pending_request_count', 0),
|
|
42
|
+
'totalRequestCount': getattr(meta, 'total_request_count', 0),
|
|
43
|
+
},
|
|
44
|
+
meta.id,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _request_to_api_dict(request: Request) -> Dict[str, Any]:
|
|
49
|
+
data = request.model_dump(by_alias=True, exclude_none=True)
|
|
50
|
+
# Scrapely expects plain dicts for headers/userData
|
|
51
|
+
if 'headers' in data and hasattr(data['headers'], 'items'):
|
|
52
|
+
data['headers'] = dict(data['headers'])
|
|
53
|
+
return data
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _request_from_api(data: Dict[str, Any]) -> Request:
|
|
57
|
+
# Crawlee Request accepts uniqueKey/url via model validation with aliases
|
|
58
|
+
payload = {
|
|
59
|
+
'uniqueKey': data.get('uniqueKey') or data.get('url') or '',
|
|
60
|
+
'url': data.get('url') or '',
|
|
61
|
+
'method': data.get('method') or 'GET',
|
|
62
|
+
'headers': data.get('headers') or {},
|
|
63
|
+
'userData': data.get('userData') or {},
|
|
64
|
+
'retryCount': data.get('retryCount') or 0,
|
|
65
|
+
'noRetry': data.get('noRetry') or False,
|
|
66
|
+
'loadedUrl': data.get('loadedUrl'),
|
|
67
|
+
'handledAt': data.get('handledAt'),
|
|
68
|
+
'payload': data.get('payload'),
|
|
69
|
+
}
|
|
70
|
+
return Request.model_validate(payload)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ScrapelyRequestQueueClient(RequestQueueClient):
|
|
74
|
+
"""Request queue client backed by Scrapely `/v2/request-queues`."""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
*,
|
|
79
|
+
api: ApiStorageClient,
|
|
80
|
+
metadata: RequestQueueMetadata,
|
|
81
|
+
client_key: Optional[str] = None,
|
|
82
|
+
) -> None:
|
|
83
|
+
self._api = api
|
|
84
|
+
self._metadata = metadata
|
|
85
|
+
self._client_key = client_key or api.new_client_key()
|
|
86
|
+
# Track requests currently being processed (fetched via fetch_next_request)
|
|
87
|
+
# Maps unique_key -> request_id for in-progress requests
|
|
88
|
+
self._in_progress: Dict[str, str] = {}
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def id(self) -> str:
|
|
92
|
+
"""Get the storage ID."""
|
|
93
|
+
return self._metadata.id
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def name(self) -> Optional[str]:
|
|
97
|
+
"""Get the storage name."""
|
|
98
|
+
return self._metadata.name
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
async def open(
|
|
102
|
+
cls,
|
|
103
|
+
*,
|
|
104
|
+
api: ApiStorageClient,
|
|
105
|
+
id: Optional[str] = None,
|
|
106
|
+
name: Optional[str] = None,
|
|
107
|
+
alias: Optional[str] = None,
|
|
108
|
+
default_id: Optional[str] = None,
|
|
109
|
+
) -> Self:
|
|
110
|
+
open_id, open_name = resolve_storage_id(
|
|
111
|
+
id=id, name=name, alias=alias, default_id=default_id,
|
|
112
|
+
)
|
|
113
|
+
meta = await api.get_or_create_request_queue(id=open_id, name=open_name)
|
|
114
|
+
return cls(api=api, metadata=_old_rq_to_crawlee(meta))
|
|
115
|
+
|
|
116
|
+
@override
|
|
117
|
+
async def get_metadata(self) -> RequestQueueMetadata:
|
|
118
|
+
meta = await self._api.rq_get(self._metadata.id)
|
|
119
|
+
self._metadata = _old_rq_to_crawlee(meta)
|
|
120
|
+
return self._metadata
|
|
121
|
+
|
|
122
|
+
@override
|
|
123
|
+
async def drop(self) -> None:
|
|
124
|
+
await self._api.rq_delete(self._metadata.id)
|
|
125
|
+
self._in_progress.clear()
|
|
126
|
+
|
|
127
|
+
@override
|
|
128
|
+
async def purge(self) -> None:
|
|
129
|
+
cursor: Optional[str] = None
|
|
130
|
+
while True:
|
|
131
|
+
data = await self._api.rq_list_requests(
|
|
132
|
+
self._metadata.id,
|
|
133
|
+
client_key=self._client_key,
|
|
134
|
+
limit=RQ_BATCH_SIZE,
|
|
135
|
+
cursor=cursor,
|
|
136
|
+
)
|
|
137
|
+
items = data.get('items') or []
|
|
138
|
+
if not items:
|
|
139
|
+
break
|
|
140
|
+
payload = []
|
|
141
|
+
for item in items:
|
|
142
|
+
if item.get('id'):
|
|
143
|
+
payload.append({'id': item['id']})
|
|
144
|
+
elif item.get('uniqueKey'):
|
|
145
|
+
payload.append({'uniqueKey': item['uniqueKey']})
|
|
146
|
+
if payload:
|
|
147
|
+
await self._api.rq_batch_delete_requests(self._metadata.id, payload)
|
|
148
|
+
cursor = data.get('nextCursor')
|
|
149
|
+
if not cursor or len(items) < RQ_BATCH_SIZE:
|
|
150
|
+
break
|
|
151
|
+
self._in_progress.clear()
|
|
152
|
+
|
|
153
|
+
@override
|
|
154
|
+
async def add_batch_of_requests(
|
|
155
|
+
self,
|
|
156
|
+
requests: Sequence[Request],
|
|
157
|
+
*,
|
|
158
|
+
forefront: bool = False,
|
|
159
|
+
) -> AddRequestsResponse:
|
|
160
|
+
if not requests:
|
|
161
|
+
return AddRequestsResponse(processed_requests=[], unprocessed_requests=[])
|
|
162
|
+
|
|
163
|
+
# Chunk via ApiStorageClient (max 25)
|
|
164
|
+
from .types import Request as ApiRequest
|
|
165
|
+
|
|
166
|
+
api_requests = []
|
|
167
|
+
for req in requests:
|
|
168
|
+
d = _request_to_api_dict(req)
|
|
169
|
+
api_requests.append(ApiRequest.from_dict(d))
|
|
170
|
+
|
|
171
|
+
result = await self._api.rq_batch_add_requests(
|
|
172
|
+
self._metadata.id,
|
|
173
|
+
api_requests,
|
|
174
|
+
client_key=self._client_key,
|
|
175
|
+
forefront=forefront,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
processed = []
|
|
179
|
+
for item in result.get('processedRequests') or []:
|
|
180
|
+
unique_key = str(item.get('uniqueKey') or '')
|
|
181
|
+
# Use deterministic ID computation - same unique_key always produces the same ID
|
|
182
|
+
# The server should return the same ID if it uses the same algorithm
|
|
183
|
+
request_id = str(item.get('requestId') or '') or unique_key_to_request_id(unique_key)
|
|
184
|
+
processed.append(
|
|
185
|
+
ProcessedRequest(
|
|
186
|
+
id=request_id or None,
|
|
187
|
+
unique_key=unique_key,
|
|
188
|
+
was_already_present=bool(item.get('wasAlreadyPresent')),
|
|
189
|
+
was_already_handled=bool(item.get('wasAlreadyHandled')),
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
unprocessed = []
|
|
194
|
+
for item in result.get('unprocessedRequests') or []:
|
|
195
|
+
unprocessed.append(
|
|
196
|
+
UnprocessedRequest(
|
|
197
|
+
unique_key=str(item.get('uniqueKey') or item.get('url') or ''),
|
|
198
|
+
url=str(item.get('url') or 'https://invalid.invalid'),
|
|
199
|
+
method=item.get('method'),
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
return AddRequestsResponse(
|
|
204
|
+
processed_requests=processed,
|
|
205
|
+
unprocessed_requests=unprocessed,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
@override
|
|
209
|
+
async def get_request(self, unique_key: str) -> Optional[Request]:
|
|
210
|
+
# Use deterministic ID computation - same unique_key always produces the same ID
|
|
211
|
+
request_id = unique_key_to_request_id(unique_key)
|
|
212
|
+
api_req = await self._api.rq_get_request(self._metadata.id, request_id)
|
|
213
|
+
if api_req is None:
|
|
214
|
+
return None
|
|
215
|
+
return _request_from_api({
|
|
216
|
+
'id': api_req.id,
|
|
217
|
+
'uniqueKey': api_req.unique_key,
|
|
218
|
+
'url': api_req.url,
|
|
219
|
+
'method': api_req.method,
|
|
220
|
+
'headers': api_req.headers,
|
|
221
|
+
'userData': api_req.user_data,
|
|
222
|
+
'retryCount': api_req.retry_count,
|
|
223
|
+
'handledAt': api_req.handled_at,
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
@override
|
|
227
|
+
async def fetch_next_request(self) -> Optional[Request]:
|
|
228
|
+
head = await self._api.rq_list_and_lock_head(
|
|
229
|
+
self._metadata.id,
|
|
230
|
+
client_key=self._client_key,
|
|
231
|
+
lock_secs=DEFAULT_LOCK_SECS,
|
|
232
|
+
limit=1,
|
|
233
|
+
)
|
|
234
|
+
if not head.items:
|
|
235
|
+
return None
|
|
236
|
+
api_req = head.items[0]
|
|
237
|
+
request = _request_from_api({
|
|
238
|
+
'id': api_req.id,
|
|
239
|
+
'uniqueKey': api_req.unique_key,
|
|
240
|
+
'url': api_req.url,
|
|
241
|
+
'method': api_req.method,
|
|
242
|
+
'headers': api_req.headers,
|
|
243
|
+
'userData': api_req.user_data,
|
|
244
|
+
'retryCount': api_req.retry_count,
|
|
245
|
+
'handledAt': api_req.handled_at,
|
|
246
|
+
'lockExpiresAt': api_req.lock_expires_at,
|
|
247
|
+
})
|
|
248
|
+
if api_req.id:
|
|
249
|
+
self._in_progress[request.unique_key] = api_req.id
|
|
250
|
+
return request
|
|
251
|
+
|
|
252
|
+
@override
|
|
253
|
+
async def mark_request_as_handled(self, request: Request) -> Optional[ProcessedRequest]:
|
|
254
|
+
# Use deterministic ID computation - same unique_key always produces the same ID
|
|
255
|
+
# Also check in_progress cache for requests fetched via fetch_next_request
|
|
256
|
+
request_id = self._in_progress.get(request.unique_key) or unique_key_to_request_id(request.unique_key)
|
|
257
|
+
|
|
258
|
+
from .types import Request as ApiRequest
|
|
259
|
+
|
|
260
|
+
api_req = ApiRequest.from_dict(_request_to_api_dict(request))
|
|
261
|
+
api_req.id = request_id
|
|
262
|
+
result = await self._api.rq_mark_request_handled(
|
|
263
|
+
self._metadata.id,
|
|
264
|
+
api_req,
|
|
265
|
+
client_key=self._client_key,
|
|
266
|
+
)
|
|
267
|
+
self._in_progress.pop(request.unique_key, None)
|
|
268
|
+
return ProcessedRequest(
|
|
269
|
+
id=result.id or request_id,
|
|
270
|
+
unique_key=request.unique_key,
|
|
271
|
+
was_already_present=result.was_already_present,
|
|
272
|
+
was_already_handled=result.was_already_handled,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
@override
|
|
276
|
+
async def reclaim_request(
|
|
277
|
+
self,
|
|
278
|
+
request: Request,
|
|
279
|
+
*,
|
|
280
|
+
forefront: bool = False,
|
|
281
|
+
) -> Optional[ProcessedRequest]:
|
|
282
|
+
# Use deterministic ID computation - same unique_key always produces the same ID
|
|
283
|
+
# Also check in_progress cache for requests fetched via fetch_next_request
|
|
284
|
+
request_id = self._in_progress.get(request.unique_key) or unique_key_to_request_id(request.unique_key)
|
|
285
|
+
|
|
286
|
+
from .types import Request as ApiRequest
|
|
287
|
+
|
|
288
|
+
api_req = ApiRequest.from_dict(_request_to_api_dict(request))
|
|
289
|
+
api_req.id = request_id
|
|
290
|
+
try:
|
|
291
|
+
result = await self._api.rq_reclaim_request(
|
|
292
|
+
self._metadata.id,
|
|
293
|
+
api_req,
|
|
294
|
+
client_key=self._client_key,
|
|
295
|
+
forefront=forefront,
|
|
296
|
+
)
|
|
297
|
+
result_id = result.id
|
|
298
|
+
except ApiClientError as exc:
|
|
299
|
+
# Only a lost lock is safe to ignore: this client no longer holds it
|
|
300
|
+
# (it expired and the request was handed out again, or it was
|
|
301
|
+
# already released). The request is still in the queue, so there is
|
|
302
|
+
# nothing to undo, and raising would make Crawlee abort the crawl.
|
|
303
|
+
# Any other failure, including other 403s (bad token, missing
|
|
304
|
+
# permissions), is still raised.
|
|
305
|
+
if not _is_lost_lock_error(exc):
|
|
306
|
+
raise
|
|
307
|
+
logger.warning(
|
|
308
|
+
'Could not release lock on request %s (%s); it was likely already '
|
|
309
|
+
'released or re-locked after its lock expired. Continuing.',
|
|
310
|
+
request_id,
|
|
311
|
+
exc,
|
|
312
|
+
)
|
|
313
|
+
result_id = None
|
|
314
|
+
self._in_progress.pop(request.unique_key, None)
|
|
315
|
+
return ProcessedRequest(
|
|
316
|
+
id=result_id or request_id,
|
|
317
|
+
unique_key=request.unique_key,
|
|
318
|
+
was_already_present=True,
|
|
319
|
+
was_already_handled=False,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
async def prolong_request_lock(
|
|
323
|
+
self,
|
|
324
|
+
request: Request,
|
|
325
|
+
*,
|
|
326
|
+
lock_secs: int = DEFAULT_LOCK_SECS,
|
|
327
|
+
forefront: bool = False,
|
|
328
|
+
) -> Optional[Dict[str, Any]]:
|
|
329
|
+
"""Prolong the lock on a request being processed.
|
|
330
|
+
|
|
331
|
+
Use this when processing takes longer than the initial lock duration
|
|
332
|
+
to prevent the request from being returned to other consumers.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
request: The request whose lock should be prolonged.
|
|
336
|
+
lock_secs: Number of seconds to prolong the lock for.
|
|
337
|
+
forefront: If True, move the request to the front of the queue
|
|
338
|
+
if the lock expires.
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
Dict with 'lockExpiresAt' timestamp, or None if the request
|
|
342
|
+
was not in progress.
|
|
343
|
+
"""
|
|
344
|
+
request_id = self._in_progress.get(request.unique_key) or unique_key_to_request_id(request.unique_key)
|
|
345
|
+
if request_id not in self._in_progress.values():
|
|
346
|
+
# Request not currently locked by this client
|
|
347
|
+
return None
|
|
348
|
+
result = await self._api.rq_prolong_request_lock(
|
|
349
|
+
self._metadata.id,
|
|
350
|
+
request_id,
|
|
351
|
+
client_key=self._client_key,
|
|
352
|
+
lock_secs=lock_secs,
|
|
353
|
+
forefront=forefront,
|
|
354
|
+
)
|
|
355
|
+
return result
|
|
356
|
+
|
|
357
|
+
async def unlock_requests(
|
|
358
|
+
self,
|
|
359
|
+
*,
|
|
360
|
+
forefront: bool = False,
|
|
361
|
+
) -> Dict[str, Any]:
|
|
362
|
+
"""Unlock all requests locked by this client.
|
|
363
|
+
|
|
364
|
+
Useful for cleanup when a client is done processing or when
|
|
365
|
+
recovering from errors. This releases all locks held by this
|
|
366
|
+
client's clientKey.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
forefront: If True, move unlocked requests to the front of the queue.
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
Dict with 'unlockedCount' indicating how many requests were unlocked.
|
|
373
|
+
"""
|
|
374
|
+
result = await self._api.rq_unlock_requests(
|
|
375
|
+
self._metadata.id,
|
|
376
|
+
client_key=self._client_key,
|
|
377
|
+
forefront=forefront,
|
|
378
|
+
)
|
|
379
|
+
self._in_progress.clear()
|
|
380
|
+
return result
|
|
381
|
+
|
|
382
|
+
async def add_request(
|
|
383
|
+
self,
|
|
384
|
+
request: Union[str, Request],
|
|
385
|
+
*,
|
|
386
|
+
forefront: bool = False,
|
|
387
|
+
) -> Optional[ProcessedRequest]:
|
|
388
|
+
"""Add a single request to the queue.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
request: The request object or URL string to add.
|
|
392
|
+
forefront: If True, add to the front of the queue.
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
ProcessedRequest with info about the operation, or None if not added.
|
|
396
|
+
"""
|
|
397
|
+
if isinstance(request, str):
|
|
398
|
+
# crawlee's Request requires uniqueKey — from_url computes it.
|
|
399
|
+
request = Request.from_url(request)
|
|
400
|
+
|
|
401
|
+
from .types import Request as ApiRequest
|
|
402
|
+
|
|
403
|
+
api_req = ApiRequest.from_dict(_request_to_api_dict(request))
|
|
404
|
+
result = await self._api.rq_add_request(
|
|
405
|
+
self._metadata.id,
|
|
406
|
+
api_req,
|
|
407
|
+
client_key=self._client_key,
|
|
408
|
+
forefront=forefront,
|
|
409
|
+
)
|
|
410
|
+
return CrawleeProcessedRequest(
|
|
411
|
+
id=result.id,
|
|
412
|
+
unique_key=result.unique_key,
|
|
413
|
+
was_already_present=result.was_already_present,
|
|
414
|
+
was_already_handled=result.was_already_handled,
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
async def add_requests(
|
|
418
|
+
self,
|
|
419
|
+
requests: Sequence[Union[str, Request]],
|
|
420
|
+
*,
|
|
421
|
+
forefront: bool = False,
|
|
422
|
+
batch_size: int = 1000,
|
|
423
|
+
wait_time_between_batches: timedelta = timedelta(seconds=1),
|
|
424
|
+
wait_for_all_requests_to_be_added: bool = False,
|
|
425
|
+
wait_for_all_requests_to_be_added_timeout: Optional[timedelta] = None,
|
|
426
|
+
) -> None:
|
|
427
|
+
"""Add multiple requests to the queue in batches.
|
|
428
|
+
|
|
429
|
+
Args:
|
|
430
|
+
requests: Sequence of request objects or URL strings.
|
|
431
|
+
forefront: If True, add to the front of the queue.
|
|
432
|
+
batch_size: Number of requests per batch (max 25 for API).
|
|
433
|
+
wait_time_between_batches: Time to wait between batches.
|
|
434
|
+
wait_for_all_requests_to_be_added: If True, wait for all to be added.
|
|
435
|
+
wait_for_all_requests_to_be_added_timeout: Timeout for waiting.
|
|
436
|
+
"""
|
|
437
|
+
import asyncio
|
|
438
|
+
|
|
439
|
+
# Convert strings to Request objects
|
|
440
|
+
converted: list[Request] = []
|
|
441
|
+
for req in requests:
|
|
442
|
+
if isinstance(req, str):
|
|
443
|
+
converted.append(Request.from_url(req))
|
|
444
|
+
else:
|
|
445
|
+
converted.append(req)
|
|
446
|
+
|
|
447
|
+
# API batch size is 25, so we use that regardless of batch_size param
|
|
448
|
+
api_batch_size = min(batch_size, RQ_BATCH_SIZE)
|
|
449
|
+
|
|
450
|
+
for i in range(0, len(converted), api_batch_size):
|
|
451
|
+
chunk = converted[i:i + api_batch_size]
|
|
452
|
+
await self.add_batch_of_requests(chunk, forefront=forefront)
|
|
453
|
+
|
|
454
|
+
# Wait between batches if there are more to process
|
|
455
|
+
if i + api_batch_size < len(converted):
|
|
456
|
+
await asyncio.sleep(wait_time_between_batches.total_seconds())
|
|
457
|
+
|
|
458
|
+
async def get_handled_count(self) -> int:
|
|
459
|
+
"""Get the number of handled requests in the queue."""
|
|
460
|
+
meta = await self.get_metadata()
|
|
461
|
+
return meta.handled_request_count
|
|
462
|
+
|
|
463
|
+
async def get_total_count(self) -> int:
|
|
464
|
+
"""Get the total number of requests in the queue (pending + handled)."""
|
|
465
|
+
meta = await self.get_metadata()
|
|
466
|
+
return meta.total_request_count
|
|
467
|
+
|
|
468
|
+
async def to_tandem(
|
|
469
|
+
self,
|
|
470
|
+
request_manager: Optional['RequestQueueClient'] = None,
|
|
471
|
+
) -> Any:
|
|
472
|
+
"""Combine this loader with a request manager.
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
request_manager: Request manager to combine with. If None, uses default.
|
|
476
|
+
|
|
477
|
+
Returns:
|
|
478
|
+
RequestManagerTandem combining this loader with the manager.
|
|
479
|
+
"""
|
|
480
|
+
from crawlee.request_loaders import RequestManagerTandem
|
|
481
|
+
manager: Any = request_manager
|
|
482
|
+
if manager is None:
|
|
483
|
+
# Use the default request queue as the manager
|
|
484
|
+
from crawlee.storages import RequestQueue
|
|
485
|
+
manager = await RequestQueue.open()
|
|
486
|
+
return RequestManagerTandem(self, manager) # type: ignore[arg-type] # duck-typed loader
|
|
487
|
+
|
|
488
|
+
@override
|
|
489
|
+
async def is_empty(self) -> bool:
|
|
490
|
+
meta = await self.get_metadata()
|
|
491
|
+
return meta.pending_request_count == 0 and not self._in_progress
|
|
492
|
+
|
|
493
|
+
@override
|
|
494
|
+
async def is_finished(self) -> bool:
|
|
495
|
+
meta = await self.get_metadata()
|
|
496
|
+
return meta.pending_request_count == 0 and not self._in_progress
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Scrapely cloud StorageClient for Crawlee (like ApifyStorageClient / TS ApiStorageClientFactory)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any, Literal, Optional
|
|
7
|
+
|
|
8
|
+
from typing_extensions import override
|
|
9
|
+
|
|
10
|
+
from crawlee.configuration import Configuration
|
|
11
|
+
from crawlee.storage_clients._base import StorageClient
|
|
12
|
+
|
|
13
|
+
from scrapely.configuration import Configuration as ScrapelyConfiguration
|
|
14
|
+
|
|
15
|
+
from .api_client import ApiClient
|
|
16
|
+
from .client import ApiStorageClient
|
|
17
|
+
from .scrapely_dataset_client import ScrapelyDatasetClient
|
|
18
|
+
from .scrapely_kvs_client import ScrapelyKeyValueStoreClient
|
|
19
|
+
from .scrapely_rq_client import ScrapelyRequestQueueClient
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ScrapelyStorageClient(StorageClient):
|
|
23
|
+
"""Cloud storage client that persists datasets, KVS, and RQs via Scrapely ``/v2``.
|
|
24
|
+
|
|
25
|
+
Mirrors Apify's ``ApifyStorageClient`` and the TypeScript ``ApiStorageClientFactory``.
|
|
26
|
+
The Scrapely server backs these endpoints with Dragonfly; actors never open Dragonfly directly.
|
|
27
|
+
|
|
28
|
+
``request_queue_access`` is accepted for Apify API compatibility. Scrapely ``/v2`` uses a single
|
|
29
|
+
head-lock RQ client (same as TypeScript); ``shared`` currently behaves like ``single``.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
request_queue_access: Literal['single', 'shared'] = 'single',
|
|
36
|
+
api_url: Optional[str] = None,
|
|
37
|
+
token: Optional[str] = None,
|
|
38
|
+
run_id: Optional[str] = None,
|
|
39
|
+
api: Optional[ApiStorageClient] = None,
|
|
40
|
+
transport: Any = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
self._request_queue_access = request_queue_access
|
|
43
|
+
self._api_url = api_url
|
|
44
|
+
self._token = token
|
|
45
|
+
self._run_id = run_id
|
|
46
|
+
self._transport = transport
|
|
47
|
+
self._api = api
|
|
48
|
+
|
|
49
|
+
def has_credentials(self) -> bool:
|
|
50
|
+
if self._api is not None:
|
|
51
|
+
return True
|
|
52
|
+
url, token, run_id = self._resolve_credentials()
|
|
53
|
+
return bool(url and token and run_id)
|
|
54
|
+
|
|
55
|
+
def _resolve_credentials(self) -> tuple[Optional[str], Optional[str], Optional[str]]:
|
|
56
|
+
scrapely = ScrapelyConfiguration.get_global_config()
|
|
57
|
+
url = (
|
|
58
|
+
self._api_url
|
|
59
|
+
or scrapely.get('api_base_url')
|
|
60
|
+
or os.environ.get('SCRAPELY_API_URL')
|
|
61
|
+
or os.environ.get('APIFY_API_BASE_URL')
|
|
62
|
+
)
|
|
63
|
+
token = (
|
|
64
|
+
self._token
|
|
65
|
+
or scrapely.token
|
|
66
|
+
or os.environ.get('SCRAPELY_TOKEN')
|
|
67
|
+
or os.environ.get('APIFY_TOKEN')
|
|
68
|
+
)
|
|
69
|
+
run_id = (
|
|
70
|
+
self._run_id
|
|
71
|
+
or scrapely.actor_run_id
|
|
72
|
+
or os.environ.get('ACTOR_RUN_ID')
|
|
73
|
+
or os.environ.get('APIFY_ACTOR_RUN_ID')
|
|
74
|
+
)
|
|
75
|
+
return url, token, run_id
|
|
76
|
+
|
|
77
|
+
def _ensure_api(self) -> ApiStorageClient:
|
|
78
|
+
if self._api is not None:
|
|
79
|
+
return self._api
|
|
80
|
+
url, token, run_id = self._resolve_credentials()
|
|
81
|
+
if not url or not token or not run_id:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
'ScrapelyStorageClient requires api_url, token, and run_id '
|
|
84
|
+
'(or SCRAPELY_API_URL / SCRAPELY_TOKEN / ACTOR_RUN_ID env vars).'
|
|
85
|
+
)
|
|
86
|
+
self._api = ApiStorageClient(
|
|
87
|
+
api_url=url,
|
|
88
|
+
token=token,
|
|
89
|
+
run_id=run_id,
|
|
90
|
+
transport=self._transport,
|
|
91
|
+
)
|
|
92
|
+
return self._api
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def api(self) -> ApiStorageClient:
|
|
96
|
+
return self._ensure_api()
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def api_client(self) -> ApiClient:
|
|
100
|
+
return self._ensure_api().api_client
|
|
101
|
+
|
|
102
|
+
async def close(self) -> None:
|
|
103
|
+
if self._api is not None:
|
|
104
|
+
await self._api.close()
|
|
105
|
+
|
|
106
|
+
def get_storage_client_cache_key(self, configuration: Configuration) -> Any:
|
|
107
|
+
run_id = self._run_id or self._resolve_credentials()[2] or 'unknown'
|
|
108
|
+
return (
|
|
109
|
+
f'{self.__class__.__module__}.{self.__class__.__name__}',
|
|
110
|
+
run_id,
|
|
111
|
+
self._request_queue_access,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def _default_dataset_id(self) -> Optional[str]:
|
|
115
|
+
return (
|
|
116
|
+
os.environ.get('ACTOR_DEFAULT_DATASET_ID')
|
|
117
|
+
or os.environ.get('APIFY_DEFAULT_DATASET_ID')
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def _default_kvs_id(self) -> Optional[str]:
|
|
121
|
+
return (
|
|
122
|
+
os.environ.get('ACTOR_DEFAULT_KEY_VALUE_STORE_ID')
|
|
123
|
+
or os.environ.get('APIFY_DEFAULT_KEY_VALUE_STORE_ID')
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def _default_rq_id(self) -> Optional[str]:
|
|
127
|
+
return (
|
|
128
|
+
os.environ.get('ACTOR_DEFAULT_REQUEST_QUEUE_ID')
|
|
129
|
+
or os.environ.get('APIFY_DEFAULT_REQUEST_QUEUE_ID')
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
@override
|
|
133
|
+
async def create_dataset_client(
|
|
134
|
+
self,
|
|
135
|
+
*,
|
|
136
|
+
id: Optional[str] = None,
|
|
137
|
+
name: Optional[str] = None,
|
|
138
|
+
alias: Optional[str] = None,
|
|
139
|
+
configuration: Optional[Configuration] = None,
|
|
140
|
+
) -> ScrapelyDatasetClient:
|
|
141
|
+
configuration = configuration or Configuration.get_global_configuration()
|
|
142
|
+
client = await ScrapelyDatasetClient.open(
|
|
143
|
+
api=self._ensure_api(),
|
|
144
|
+
id=id,
|
|
145
|
+
name=name,
|
|
146
|
+
alias=alias,
|
|
147
|
+
default_id=self._default_dataset_id(),
|
|
148
|
+
)
|
|
149
|
+
await self._purge_if_needed(client, configuration)
|
|
150
|
+
return client
|
|
151
|
+
|
|
152
|
+
@override
|
|
153
|
+
async def create_kvs_client(
|
|
154
|
+
self,
|
|
155
|
+
*,
|
|
156
|
+
id: Optional[str] = None,
|
|
157
|
+
name: Optional[str] = None,
|
|
158
|
+
alias: Optional[str] = None,
|
|
159
|
+
configuration: Optional[Configuration] = None,
|
|
160
|
+
) -> ScrapelyKeyValueStoreClient:
|
|
161
|
+
configuration = configuration or Configuration.get_global_configuration()
|
|
162
|
+
client = await ScrapelyKeyValueStoreClient.open(
|
|
163
|
+
api=self._ensure_api(),
|
|
164
|
+
id=id,
|
|
165
|
+
name=name,
|
|
166
|
+
alias=alias,
|
|
167
|
+
default_id=self._default_kvs_id(),
|
|
168
|
+
)
|
|
169
|
+
await self._purge_if_needed(client, configuration)
|
|
170
|
+
return client
|
|
171
|
+
|
|
172
|
+
@override
|
|
173
|
+
async def create_rq_client(
|
|
174
|
+
self,
|
|
175
|
+
*,
|
|
176
|
+
id: Optional[str] = None,
|
|
177
|
+
name: Optional[str] = None,
|
|
178
|
+
alias: Optional[str] = None,
|
|
179
|
+
configuration: Optional[Configuration] = None,
|
|
180
|
+
) -> ScrapelyRequestQueueClient:
|
|
181
|
+
configuration = configuration or Configuration.get_global_configuration()
|
|
182
|
+
# Scrapely /v2 uses one head-lock client (TS parity); access mode is API-compat only.
|
|
183
|
+
_ = self._request_queue_access
|
|
184
|
+
client = await ScrapelyRequestQueueClient.open(
|
|
185
|
+
api=self._ensure_api(),
|
|
186
|
+
id=id,
|
|
187
|
+
name=name,
|
|
188
|
+
alias=alias,
|
|
189
|
+
default_id=self._default_rq_id(),
|
|
190
|
+
)
|
|
191
|
+
await self._purge_if_needed(client, configuration)
|
|
192
|
+
return client
|