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,618 @@
1
+ """
2
+ API-backed storage client for Scrapely /v2 storage endpoints.
3
+
4
+ Ports the patterns from the TypeScript ApiStorageClient.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import re
11
+ import uuid
12
+ from datetime import datetime, timezone
13
+ from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple, cast
14
+ from urllib.parse import quote
15
+
16
+ from .api_client import ApiClient, ApiClientError
17
+ from .types import (
18
+ DatasetItemsListPage,
19
+ DatasetMetadata,
20
+ KeyValueStoreMetadata,
21
+ ProcessedRequest,
22
+ Request,
23
+ RequestQueueHead,
24
+ RequestQueueMetadata,
25
+ dataset_metadata_from_dict,
26
+ kvs_metadata_from_dict,
27
+ rq_metadata_from_dict,
28
+ )
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ STORAGE_UUID_REGEX = re.compile(
33
+ r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
34
+ re.IGNORECASE,
35
+ )
36
+ RQ_BATCH_SIZE = 25
37
+ # Must comfortably exceed Crawlee's default 60s request-handler timeout: if the
38
+ # lock expires first, the same request is handed to a second worker and both
39
+ # later try to reclaim it. Matches Apify's SDK default of 3 minutes.
40
+ DEFAULT_LOCK_SECS = 180
41
+ # Only needs to cover the update and unlock calls that follow the renewal.
42
+ _RECLAIM_LOCK_SECS = 60
43
+
44
+
45
+ def is_storage_uuid(value: str) -> bool:
46
+ return bool(STORAGE_UUID_REGEX.match(value))
47
+
48
+
49
+ def kvs_record_path(store_id: str, key: str) -> str:
50
+ """Build a KVS record path with a URL-encoded key."""
51
+ return f'/v2/key-value-stores/{store_id}/records/{quote(key, safe="")}'
52
+
53
+
54
+ def build_rq_params(
55
+ client_key: str,
56
+ *,
57
+ forefront: bool = False,
58
+ lock_secs: Optional[int] = None,
59
+ limit: Optional[int] = None,
60
+ run_id: Optional[str] = None,
61
+ ) -> Dict[str, Any]:
62
+ params: Dict[str, Any] = {'clientKey': client_key}
63
+ if forefront:
64
+ params['forefront'] = 'true'
65
+ if lock_secs is not None:
66
+ params['lockSecs'] = lock_secs
67
+ if limit is not None:
68
+ params['limit'] = limit
69
+ if run_id:
70
+ params['runId'] = run_id
71
+ return params
72
+
73
+
74
+ class ApiStorageClient:
75
+ """Factory/client for Scrapely cloud storages over HTTP."""
76
+
77
+ def __init__(
78
+ self,
79
+ api_url: str,
80
+ token: str,
81
+ run_id: str,
82
+ *,
83
+ api_client: Optional[ApiClient] = None,
84
+ timeout: float = 30.0,
85
+ transport: Any = None,
86
+ ):
87
+ self.api_client = api_client or ApiClient(
88
+ api_url=api_url,
89
+ token=token,
90
+ run_id=run_id,
91
+ timeout=timeout,
92
+ transport=transport,
93
+ )
94
+
95
+ async def close(self) -> None:
96
+ await self.api_client.close()
97
+
98
+ # --- Collections / get-or-create ---
99
+
100
+ async def get_or_create_dataset(
101
+ self,
102
+ *,
103
+ id: Optional[str] = None,
104
+ name: Optional[str] = None,
105
+ ) -> DatasetMetadata:
106
+ if id:
107
+ if is_storage_uuid(id):
108
+ try:
109
+ data = await self.api_client.get_json(f'/v2/datasets/{id}')
110
+ return dataset_metadata_from_dict(data, id)
111
+ except ApiClientError as err:
112
+ if err.status_code == 404:
113
+ raise ValueError(f'Dataset with id "{id}" was not found') from err
114
+ raise
115
+ # Treat non-UUID id as a name for convenience
116
+ name = name or id
117
+
118
+ data = await self.api_client.post_json(
119
+ '/v2/datasets',
120
+ params={'name': name} if name else None,
121
+ )
122
+ return dataset_metadata_from_dict(data)
123
+
124
+ async def get_or_create_key_value_store(
125
+ self,
126
+ *,
127
+ id: Optional[str] = None,
128
+ name: Optional[str] = None,
129
+ ) -> KeyValueStoreMetadata:
130
+ if id:
131
+ if is_storage_uuid(id):
132
+ try:
133
+ data = await self.api_client.get_json(f'/v2/key-value-stores/{id}')
134
+ return kvs_metadata_from_dict(data, id)
135
+ except ApiClientError as err:
136
+ if err.status_code == 404:
137
+ raise ValueError(f'Key-value store with id "{id}" was not found') from err
138
+ raise
139
+ name = name or id
140
+
141
+ data = await self.api_client.post_json(
142
+ '/v2/key-value-stores',
143
+ params={'name': name} if name else None,
144
+ )
145
+ return kvs_metadata_from_dict(data)
146
+
147
+ async def get_or_create_request_queue(
148
+ self,
149
+ *,
150
+ id: Optional[str] = None,
151
+ name: Optional[str] = None,
152
+ ) -> RequestQueueMetadata:
153
+ if id:
154
+ if is_storage_uuid(id):
155
+ try:
156
+ data = await self.api_client.get_json(f'/v2/request-queues/{id}')
157
+ return rq_metadata_from_dict(data, id)
158
+ except ApiClientError as err:
159
+ if err.status_code == 404:
160
+ raise ValueError(f'Request queue with id "{id}" was not found') from err
161
+ raise
162
+ name = name or id
163
+
164
+ data = await self.api_client.post_json(
165
+ '/v2/request-queues',
166
+ params={'name': name} if name else None,
167
+ )
168
+ return rq_metadata_from_dict(data)
169
+
170
+ # --- Dataset resource ops ---
171
+
172
+ async def dataset_get(self, dataset_id: str) -> DatasetMetadata:
173
+ data = await self.api_client.get_json(f'/v2/datasets/{dataset_id}')
174
+ return dataset_metadata_from_dict(data, dataset_id)
175
+
176
+ async def dataset_delete(self, dataset_id: str) -> None:
177
+ await self.api_client.delete(f'/v2/datasets/{dataset_id}')
178
+
179
+ async def dataset_push_items(
180
+ self,
181
+ dataset_id: str,
182
+ items: Sequence[Mapping[str, Any]],
183
+ ) -> None:
184
+ await self.api_client.post_json(f'/v2/datasets/{dataset_id}/items', list(items))
185
+
186
+ async def dataset_list_items(
187
+ self,
188
+ dataset_id: str,
189
+ *,
190
+ offset: int = 0,
191
+ limit: Optional[int] = None,
192
+ clean: bool = False,
193
+ desc: bool = False,
194
+ fields: Optional[List[str]] = None,
195
+ omit: Optional[List[str]] = None,
196
+ unwind: Optional[List[str]] = None,
197
+ skip_empty: bool = False,
198
+ skip_hidden: bool = False,
199
+ flatten: Optional[List[str]] = None,
200
+ view: Optional[str] = None,
201
+ format: Optional[str] = None,
202
+ ) -> Tuple[Any, DatasetItemsListPage]:
203
+ params: Dict[str, Any] = {'offset': offset}
204
+ if limit is not None:
205
+ params['limit'] = limit
206
+ if clean:
207
+ params['clean'] = 'true'
208
+ if desc:
209
+ params['desc'] = 'true'
210
+ if fields:
211
+ params['fields'] = ','.join(fields)
212
+ if omit:
213
+ params['omit'] = ','.join(omit)
214
+ if unwind:
215
+ params['unwind'] = ','.join(unwind)
216
+ if skip_empty:
217
+ params['skipEmpty'] = 'true'
218
+ if skip_hidden:
219
+ params['skipHidden'] = 'true'
220
+ if flatten:
221
+ params['flatten'] = ','.join(flatten)
222
+ if view:
223
+ params['view'] = view
224
+ if format:
225
+ params['format'] = format
226
+
227
+ response = await self.api_client.get(f'/v2/datasets/{dataset_id}/items', params=params)
228
+ headers = response.headers
229
+
230
+ def _header_int(name: str, default: int = 0) -> int:
231
+ raw = headers.get(name)
232
+ if raw is None:
233
+ return default
234
+ try:
235
+ return int(raw)
236
+ except ValueError:
237
+ return default
238
+
239
+ content_type = headers.get('content-type', '')
240
+ if format and format != 'json':
241
+ body: Any = response.content if 'text/' not in content_type else response.text
242
+ items: List[Any] = []
243
+ else:
244
+ body = response.json()
245
+ if isinstance(body, list):
246
+ items = body
247
+ elif isinstance(body, dict) and 'items' in body:
248
+ items = list(body['items'])
249
+ else:
250
+ items = []
251
+
252
+ page = DatasetItemsListPage(
253
+ items=items if isinstance(items, list) else [],
254
+ total=_header_int('X-Apify-Pagination-Total', len(items) if isinstance(items, list) else 0),
255
+ offset=_header_int('X-Apify-Pagination-Offset', offset),
256
+ count=_header_int('X-Apify-Pagination-Count', len(items) if isinstance(items, list) else 0),
257
+ limit=_header_int('X-Apify-Pagination-Limit', limit or 0),
258
+ desc=desc,
259
+ )
260
+ return body, page
261
+
262
+ async def dataset_get_statistics(self, dataset_id: str) -> Dict[str, Any]:
263
+ return cast(Dict[str, Any], await self.api_client.get_json(f'/v2/datasets/{dataset_id}/statistics'))
264
+
265
+ # --- KVS resource ops ---
266
+
267
+ async def kvs_get(self, store_id: str) -> KeyValueStoreMetadata:
268
+ data = await self.api_client.get_json(f'/v2/key-value-stores/{store_id}')
269
+ return kvs_metadata_from_dict(data, store_id)
270
+
271
+ async def kvs_delete(self, store_id: str) -> None:
272
+ await self.api_client.delete(f'/v2/key-value-stores/{store_id}')
273
+
274
+ async def kvs_get_record(self, store_id: str, key: str) -> Tuple[Any, Optional[str]]:
275
+ response = await self.api_client.get(
276
+ kvs_record_path(store_id, key),
277
+ raise_for_status=False,
278
+ )
279
+ if response.status_code == 404:
280
+ return None, None
281
+ await self.api_client._raise_for_status(response)
282
+ content_type = response.headers.get('content-type', '')
283
+ if 'application/json' in content_type:
284
+ return response.json(), content_type
285
+ if content_type.startswith('text/'):
286
+ return response.text, content_type
287
+ return response.content, content_type
288
+
289
+ async def kvs_set_record(
290
+ self,
291
+ store_id: str,
292
+ key: str,
293
+ value: Any,
294
+ content_type: Optional[str] = None,
295
+ ) -> None:
296
+ if content_type is None:
297
+ if isinstance(value, (str, bytes)):
298
+ content_type = 'text/plain'
299
+ else:
300
+ content_type = 'application/json'
301
+
302
+ path = kvs_record_path(store_id, key)
303
+ headers = {'Content-Type': content_type}
304
+ if isinstance(value, bytes):
305
+ await self.api_client.put(
306
+ path,
307
+ content=value,
308
+ headers=headers,
309
+ )
310
+ elif isinstance(value, str) and content_type != 'application/json':
311
+ await self.api_client.put(
312
+ path,
313
+ content=value.encode('utf-8'),
314
+ headers=headers,
315
+ )
316
+ else:
317
+ await self.api_client.put(
318
+ path,
319
+ json_data=value,
320
+ headers=headers,
321
+ )
322
+
323
+ async def kvs_delete_record(self, store_id: str, key: str) -> None:
324
+ await self.api_client.delete(kvs_record_path(store_id, key))
325
+
326
+ async def kvs_record_exists(self, store_id: str, key: str) -> bool:
327
+ response = await self.api_client.head(
328
+ kvs_record_path(store_id, key),
329
+ raise_for_status=False,
330
+ )
331
+ if response.status_code == 404:
332
+ return False
333
+ if response.is_success:
334
+ return True
335
+ await self.api_client._raise_for_status(response)
336
+ return False
337
+
338
+ async def kvs_list_keys(
339
+ self,
340
+ store_id: str,
341
+ *,
342
+ exclusive_start_key: Optional[str] = None,
343
+ limit: Optional[int] = None,
344
+ prefix: Optional[str] = None,
345
+ ) -> Dict[str, Any]:
346
+ params: Dict[str, Any] = {}
347
+ if exclusive_start_key is not None:
348
+ params['exclusiveStartKey'] = exclusive_start_key
349
+ if limit is not None:
350
+ params['limit'] = limit
351
+ if prefix is not None:
352
+ params['prefix'] = prefix
353
+ return cast(Dict[str, Any], await self.api_client.get_json(f'/v2/key-value-stores/{store_id}/keys', params=params))
354
+
355
+ def kvs_public_url(self, store_id: str, key: str) -> str:
356
+ return f'{self.api_client.api_url}{kvs_record_path(store_id, key)}'
357
+
358
+ # --- Request queue resource ops ---
359
+
360
+ def new_client_key(self) -> str:
361
+ return uuid.uuid4().hex[:16]
362
+
363
+ async def rq_get(self, queue_id: str) -> RequestQueueMetadata:
364
+ data = await self.api_client.get_json(f'/v2/request-queues/{queue_id}')
365
+ return rq_metadata_from_dict(data, queue_id)
366
+
367
+ async def rq_delete(self, queue_id: str) -> None:
368
+ await self.api_client.delete(f'/v2/request-queues/{queue_id}')
369
+
370
+ async def rq_add_request(
371
+ self,
372
+ queue_id: str,
373
+ request: Request,
374
+ *,
375
+ client_key: str,
376
+ forefront: bool = False,
377
+ ) -> ProcessedRequest:
378
+ data = await self.api_client.post_json(
379
+ f'/v2/request-queues/{queue_id}/requests',
380
+ request.to_dict(),
381
+ params=build_rq_params(client_key, forefront=forefront),
382
+ )
383
+ return ProcessedRequest(
384
+ id=str(data.get('requestId') or ''),
385
+ unique_key=request.unique_key,
386
+ was_already_present=bool(data.get('wasAlreadyPresent')),
387
+ was_already_handled=bool(data.get('wasAlreadyHandled')),
388
+ )
389
+
390
+ async def rq_batch_add_requests(
391
+ self,
392
+ queue_id: str,
393
+ requests: Sequence[Request],
394
+ *,
395
+ client_key: str,
396
+ forefront: bool = False,
397
+ ) -> Dict[str, List[Any]]:
398
+ processed: List[Dict[str, Any]] = []
399
+ unprocessed: List[Dict[str, Any]] = []
400
+
401
+ for i in range(0, len(requests), RQ_BATCH_SIZE):
402
+ chunk = list(requests[i:i + RQ_BATCH_SIZE])
403
+ try:
404
+ data = await self.api_client.post_json(
405
+ f'/v2/request-queues/{queue_id}/requests/batch',
406
+ [req.to_dict() for req in chunk],
407
+ params=build_rq_params(client_key, forefront=forefront),
408
+ )
409
+ except Exception:
410
+ logger.exception(
411
+ 'Failed to batch-add %d request(s) to queue %s',
412
+ len(chunk),
413
+ queue_id,
414
+ )
415
+ raise
416
+ processed.extend(data.get('processedRequests') or [])
417
+ unprocessed.extend(data.get('unprocessedRequests') or [])
418
+
419
+ return {'processedRequests': processed, 'unprocessedRequests': unprocessed}
420
+
421
+ async def rq_list_head(
422
+ self,
423
+ queue_id: str,
424
+ *,
425
+ client_key: str,
426
+ limit: int = 1,
427
+ ) -> RequestQueueHead:
428
+ """Peek at the queue head without locking requests."""
429
+ data = await self.api_client.get_json(
430
+ f'/v2/request-queues/{queue_id}/head',
431
+ params=build_rq_params(client_key, limit=limit),
432
+ )
433
+ items = [Request.from_dict(item) for item in (data.get('items') or [])]
434
+ modified = data.get('queueModifiedAt')
435
+ return RequestQueueHead(
436
+ items=items,
437
+ limit=int(data.get('limit') or limit),
438
+ queue_modified_at=(
439
+ datetime.fromisoformat(str(modified).replace('Z', '+00:00'))
440
+ if modified else None
441
+ ),
442
+ had_multiple_clients=bool(data.get('hadMultipleClients')),
443
+ lock_secs=data.get('lockSecs'),
444
+ queue_has_locked_requests=data.get('queueHasLockedRequests'),
445
+ )
446
+
447
+ async def rq_batch_delete_requests(
448
+ self,
449
+ queue_id: str,
450
+ requests: Sequence[Mapping[str, Any]],
451
+ ) -> Dict[str, Any]:
452
+ from .api_client import unwrap_data
453
+
454
+ response = await self.api_client.delete(
455
+ f'/v2/request-queues/{queue_id}/requests/batch',
456
+ json_data=list(requests),
457
+ )
458
+ if not response.content:
459
+ return {}
460
+ return cast(Dict[str, Any], unwrap_data(response.json()))
461
+
462
+ async def rq_get_request(self, queue_id: str, request_id: str) -> Optional[Request]:
463
+ try:
464
+ data = await self.api_client.get_json(f'/v2/request-queues/{queue_id}/requests/{request_id}')
465
+ return Request.from_dict(data)
466
+ except ApiClientError as err:
467
+ if err.status_code == 404:
468
+ return None
469
+ raise
470
+
471
+ async def rq_list_and_lock_head(
472
+ self,
473
+ queue_id: str,
474
+ *,
475
+ client_key: str,
476
+ lock_secs: int = DEFAULT_LOCK_SECS,
477
+ limit: int = 1,
478
+ ) -> RequestQueueHead:
479
+ data = await self.api_client.post_json(
480
+ f'/v2/request-queues/{queue_id}/head/lock',
481
+ params=build_rq_params(client_key, lock_secs=lock_secs, limit=limit),
482
+ )
483
+ items = [Request.from_dict(item) for item in (data.get('items') or [])]
484
+ modified = data.get('queueModifiedAt')
485
+ return RequestQueueHead(
486
+ items=items,
487
+ limit=int(data.get('limit') or limit),
488
+ queue_modified_at=(
489
+ datetime.fromisoformat(str(modified).replace('Z', '+00:00'))
490
+ if modified else None
491
+ ),
492
+ had_multiple_clients=bool(data.get('hadMultipleClients')),
493
+ lock_secs=data.get('lockSecs'),
494
+ queue_has_locked_requests=data.get('queueHasLockedRequests'),
495
+ )
496
+
497
+ async def rq_mark_request_handled(
498
+ self,
499
+ queue_id: str,
500
+ request: Request,
501
+ *,
502
+ client_key: str,
503
+ ) -> ProcessedRequest:
504
+ if not request.id:
505
+ raise ValueError('Request must have an id to be marked as handled')
506
+ payload = request.to_dict()
507
+ payload['handledAt'] = datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')
508
+ data = await self.api_client.put_json(
509
+ f'/v2/request-queues/{queue_id}/requests/{request.id}',
510
+ payload,
511
+ params=build_rq_params(client_key),
512
+ )
513
+ return ProcessedRequest(
514
+ id=str(data.get('requestId') or request.id),
515
+ unique_key=request.unique_key,
516
+ was_already_present=bool(data.get('wasAlreadyPresent')),
517
+ was_already_handled=bool(data.get('wasAlreadyHandled')),
518
+ )
519
+
520
+ async def rq_reclaim_request(
521
+ self,
522
+ queue_id: str,
523
+ request: Request,
524
+ *,
525
+ client_key: str,
526
+ forefront: bool = False,
527
+ ) -> ProcessedRequest:
528
+ if not request.id:
529
+ raise ValueError('Request must have an id to be reclaimed')
530
+ # The update endpoint does not check lock ownership, so renew the lock
531
+ # first: if it expired and another client took the request, this raises
532
+ # 403 and its retry state is left alone. A 409 means the lock expired
533
+ # but nobody holds it, so the update below cannot clobber anyone.
534
+ try:
535
+ await self.rq_prolong_request_lock(
536
+ queue_id,
537
+ request.id,
538
+ client_key=client_key,
539
+ lock_secs=_RECLAIM_LOCK_SECS,
540
+ )
541
+ except ApiClientError as err:
542
+ if err.status_code != 409 or 'is not locked' not in str(err):
543
+ raise
544
+ # Persist retryCount/errorMessages before unlocking, so the next fetch
545
+ # sees them and max_request_retries is ever reached.
546
+ await self.api_client.put_json(
547
+ f'/v2/request-queues/{queue_id}/requests/{request.id}',
548
+ request.to_dict(),
549
+ params=build_rq_params(client_key, forefront=forefront),
550
+ )
551
+ await self.api_client.delete(
552
+ f'/v2/request-queues/{queue_id}/requests/{request.id}/lock',
553
+ params=build_rq_params(client_key, forefront=forefront),
554
+ )
555
+ return ProcessedRequest(
556
+ id=request.id,
557
+ unique_key=request.unique_key,
558
+ was_already_present=True,
559
+ was_already_handled=False,
560
+ )
561
+
562
+ async def rq_list_requests(
563
+ self,
564
+ queue_id: str,
565
+ *,
566
+ client_key: str,
567
+ limit: Optional[int] = None,
568
+ cursor: Optional[str] = None,
569
+ exclusive_start_id: Optional[str] = None,
570
+ ) -> Dict[str, Any]:
571
+ params: Dict[str, Any] = {'clientKey': client_key}
572
+ if limit is not None:
573
+ params['limit'] = limit
574
+ if cursor is not None:
575
+ params['cursor'] = cursor
576
+ if exclusive_start_id is not None:
577
+ params['exclusiveStartId'] = exclusive_start_id
578
+ return cast(
579
+ Dict[str, Any],
580
+ await self.api_client.get_json(
581
+ f'/v2/request-queues/{queue_id}/requests',
582
+ params=params,
583
+ ),
584
+ )
585
+
586
+ async def rq_delete_request(self, queue_id: str, request_id: str) -> None:
587
+ await self.api_client.delete(f'/v2/request-queues/{queue_id}/requests/{request_id}')
588
+
589
+ async def rq_prolong_request_lock(
590
+ self,
591
+ queue_id: str,
592
+ request_id: str,
593
+ *,
594
+ client_key: str,
595
+ lock_secs: int,
596
+ forefront: bool = False,
597
+ ) -> Dict[str, Any]:
598
+ """Prolong the lock on a request (Apify REST API: PUT /requests/{id}/lock)."""
599
+ data = await self.api_client.put_json(
600
+ f'/v2/request-queues/{queue_id}/requests/{request_id}/lock',
601
+ params=build_rq_params(client_key, lock_secs=lock_secs, forefront=forefront),
602
+ )
603
+ return {'lockExpiresAt': data.get('lockExpiresAt')}
604
+
605
+ async def rq_unlock_requests(
606
+ self,
607
+ queue_id: str,
608
+ *,
609
+ client_key: str,
610
+ forefront: bool = False,
611
+ run_id: Optional[str] = None,
612
+ ) -> Dict[str, Any]:
613
+ """Unlock all requests locked by a clientKey (Apify REST API: POST /requests/unlock)."""
614
+ data = await self.api_client.post_json(
615
+ f'/v2/request-queues/{queue_id}/requests/unlock',
616
+ params=build_rq_params(client_key, forefront=forefront, run_id=run_id),
617
+ )
618
+ return {'unlockedCount': int(data.get('unlockedCount') or 0)}