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,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)}
|