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,601 @@
|
|
|
1
|
+
"""Storage data types for the Scrapely Python SDK.
|
|
2
|
+
|
|
3
|
+
This module contains all the data types used by the storage clients,
|
|
4
|
+
including metadata classes, request types, and response types.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Any, Dict, List, Mapping, MutableMapping, Optional, Sequence, Union
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RequestState(str, Enum):
|
|
16
|
+
"""State of a request in the request queue."""
|
|
17
|
+
|
|
18
|
+
UNPROCESSED = 'UNPROCESSED'
|
|
19
|
+
IN_PROGRESS = 'IN_PROGRESS'
|
|
20
|
+
HANDLED = 'HANDLED'
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class EnqueueStrategy(str, Enum):
|
|
24
|
+
"""Strategy for enqueuing requests."""
|
|
25
|
+
|
|
26
|
+
ALL = 'All'
|
|
27
|
+
SAME_DOMAIN = 'SameDomain'
|
|
28
|
+
SAME_HOSTNAME = 'SameHostname'
|
|
29
|
+
SAME_ORIGIN = 'SameOrigin'
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class CrawleeRequestData:
|
|
34
|
+
"""Crawlee-specific configuration stored in the user_data.
|
|
35
|
+
|
|
36
|
+
This dataclass holds Crawlee-specific request configuration that is
|
|
37
|
+
stored in the user_data field of Request objects.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
crawl_depth: int = 0
|
|
41
|
+
enqueue_strategy: Optional[EnqueueStrategy] = None
|
|
42
|
+
forefront: bool = False
|
|
43
|
+
label: Optional[str] = None
|
|
44
|
+
last_proxy_tier: Optional[int] = None
|
|
45
|
+
max_retries: Optional[int] = None
|
|
46
|
+
session_rotation_count: int = 0
|
|
47
|
+
state: RequestState = RequestState.UNPROCESSED
|
|
48
|
+
|
|
49
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
50
|
+
"""Convert to dictionary for serialization."""
|
|
51
|
+
data: Dict[str, Any] = {}
|
|
52
|
+
if self.crawl_depth != 0:
|
|
53
|
+
data['crawlDepth'] = self.crawl_depth
|
|
54
|
+
if self.enqueue_strategy is not None:
|
|
55
|
+
data['enqueueStrategy'] = self.enqueue_strategy.value
|
|
56
|
+
if self.forefront:
|
|
57
|
+
data['forefront'] = self.forefront
|
|
58
|
+
if self.label is not None:
|
|
59
|
+
data['label'] = self.label
|
|
60
|
+
if self.last_proxy_tier is not None:
|
|
61
|
+
data['lastProxyTier'] = self.last_proxy_tier
|
|
62
|
+
if self.max_retries is not None:
|
|
63
|
+
data['maxRetries'] = self.max_retries
|
|
64
|
+
if self.session_rotation_count != 0:
|
|
65
|
+
data['sessionRotationCount'] = self.session_rotation_count
|
|
66
|
+
if self.state != RequestState.UNPROCESSED:
|
|
67
|
+
data['state'] = self.state.value
|
|
68
|
+
return data
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_dict(cls, data: Mapping[str, Any]) -> 'CrawleeRequestData':
|
|
72
|
+
"""Create from dictionary."""
|
|
73
|
+
enqueue_strategy_raw = data.get('enqueueStrategy')
|
|
74
|
+
enqueue_strategy = None
|
|
75
|
+
if enqueue_strategy_raw:
|
|
76
|
+
try:
|
|
77
|
+
enqueue_strategy = EnqueueStrategy(enqueue_strategy_raw)
|
|
78
|
+
except ValueError:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
state_raw = data.get('state')
|
|
82
|
+
state = RequestState.UNPROCESSED
|
|
83
|
+
if state_raw:
|
|
84
|
+
try:
|
|
85
|
+
state = RequestState(state_raw)
|
|
86
|
+
except ValueError:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
return cls(
|
|
90
|
+
crawl_depth=int(data.get('crawlDepth') or 0),
|
|
91
|
+
enqueue_strategy=enqueue_strategy,
|
|
92
|
+
forefront=bool(data.get('forefront') or False),
|
|
93
|
+
label=data.get('label'),
|
|
94
|
+
last_proxy_tier=data.get('lastProxyTier'),
|
|
95
|
+
max_retries=data.get('maxRetries'),
|
|
96
|
+
session_rotation_count=int(data.get('sessionRotationCount') or 0),
|
|
97
|
+
state=state,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class StorageMetadata:
|
|
103
|
+
"""Common metadata shared by all storage types."""
|
|
104
|
+
|
|
105
|
+
id: str
|
|
106
|
+
name: Optional[str] = None
|
|
107
|
+
user_id: Optional[str] = None
|
|
108
|
+
created_at: Optional[datetime] = None
|
|
109
|
+
modified_at: Optional[datetime] = None
|
|
110
|
+
accessed_at: Optional[datetime] = None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass
|
|
114
|
+
class DatasetMetadata(StorageMetadata):
|
|
115
|
+
"""Metadata for a Dataset."""
|
|
116
|
+
|
|
117
|
+
item_count: int = 0
|
|
118
|
+
clean_item_count: int = 0
|
|
119
|
+
act_id: Optional[str] = None
|
|
120
|
+
act_run_id: Optional[str] = None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class KeyValueStoreMetadata(StorageMetadata):
|
|
125
|
+
"""Metadata for a KeyValueStore."""
|
|
126
|
+
|
|
127
|
+
act_id: Optional[str] = None
|
|
128
|
+
act_run_id: Optional[str] = None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class RequestQueueMetadata(StorageMetadata):
|
|
133
|
+
"""Metadata for a RequestQueue."""
|
|
134
|
+
|
|
135
|
+
total_request_count: int = 0
|
|
136
|
+
handled_request_count: int = 0
|
|
137
|
+
pending_request_count: int = 0
|
|
138
|
+
had_multiple_clients: bool = False
|
|
139
|
+
act_id: Optional[str] = None
|
|
140
|
+
act_run_id: Optional[str] = None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class DatasetItemsListPage:
|
|
145
|
+
"""Paginated list of dataset items."""
|
|
146
|
+
|
|
147
|
+
items: List[Mapping[str, Any]] = field(default_factory=list)
|
|
148
|
+
total: int = 0
|
|
149
|
+
offset: int = 0
|
|
150
|
+
count: int = 0
|
|
151
|
+
limit: int = 0
|
|
152
|
+
desc: bool = False
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass
|
|
156
|
+
class KeyValueStoreRecordMetadata:
|
|
157
|
+
"""Metadata for a single key-value store record."""
|
|
158
|
+
|
|
159
|
+
key: str
|
|
160
|
+
content_type: Optional[str] = None
|
|
161
|
+
size: int = 0
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass
|
|
165
|
+
class KeyValueStoreRecord(KeyValueStoreRecordMetadata):
|
|
166
|
+
"""A key-value store record with its value."""
|
|
167
|
+
|
|
168
|
+
value: Any = None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass
|
|
172
|
+
class ProcessedRequest:
|
|
173
|
+
"""Result of adding or updating a request in a request queue."""
|
|
174
|
+
|
|
175
|
+
id: Optional[str] = None
|
|
176
|
+
unique_key: str = ''
|
|
177
|
+
was_already_present: bool = False
|
|
178
|
+
was_already_handled: bool = False
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass
|
|
182
|
+
class UnprocessedRequest:
|
|
183
|
+
"""A request that could not be processed."""
|
|
184
|
+
|
|
185
|
+
unique_key: str = ''
|
|
186
|
+
url: Optional[str] = None
|
|
187
|
+
method: Optional[str] = None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass
|
|
191
|
+
class AddRequestsResponse:
|
|
192
|
+
"""Response to adding requests to a queue."""
|
|
193
|
+
|
|
194
|
+
processed_requests: List[ProcessedRequest] = field(default_factory=list)
|
|
195
|
+
unprocessed_requests: List[UnprocessedRequest] = field(default_factory=list)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@dataclass
|
|
199
|
+
class Request:
|
|
200
|
+
"""A request in the request queue."""
|
|
201
|
+
|
|
202
|
+
unique_key: str
|
|
203
|
+
url: str
|
|
204
|
+
id: Optional[str] = None
|
|
205
|
+
method: str = 'GET'
|
|
206
|
+
headers: Dict[str, str] = field(default_factory=dict)
|
|
207
|
+
user_data: Dict[str, Any] = field(default_factory=dict)
|
|
208
|
+
retry_count: int = 0
|
|
209
|
+
handled_at: Optional[str] = None
|
|
210
|
+
lock_expires_at: Optional[str] = None
|
|
211
|
+
loaded_url: Optional[str] = None
|
|
212
|
+
payload: Optional[Any] = None
|
|
213
|
+
no_retry: bool = False
|
|
214
|
+
error_messages: List[str] = field(default_factory=list)
|
|
215
|
+
|
|
216
|
+
# Crawlee-specific properties stored in user_data
|
|
217
|
+
@property
|
|
218
|
+
def crawlee_data(self) -> CrawleeRequestData:
|
|
219
|
+
"""Get Crawlee-specific configuration from user_data."""
|
|
220
|
+
crawlee_data = self.user_data.get('__crawlee', {})
|
|
221
|
+
if isinstance(crawlee_data, CrawleeRequestData):
|
|
222
|
+
return crawlee_data
|
|
223
|
+
return CrawleeRequestData.from_dict(crawlee_data)
|
|
224
|
+
|
|
225
|
+
@crawlee_data.setter
|
|
226
|
+
def crawlee_data(self, value: CrawleeRequestData) -> None:
|
|
227
|
+
"""Set Crawlee-specific configuration in user_data."""
|
|
228
|
+
self.user_data['__crawlee'] = value.to_dict()
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def crawl_depth(self) -> int:
|
|
232
|
+
"""The depth of the request in the crawl tree."""
|
|
233
|
+
return self.crawlee_data.crawl_depth
|
|
234
|
+
|
|
235
|
+
@crawl_depth.setter
|
|
236
|
+
def crawl_depth(self, value: int) -> None:
|
|
237
|
+
data = self.crawlee_data
|
|
238
|
+
data.crawl_depth = value
|
|
239
|
+
self.crawlee_data = data
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def enqueue_strategy(self) -> Optional[EnqueueStrategy]:
|
|
243
|
+
"""The strategy that was used for enqueuing the request."""
|
|
244
|
+
return self.crawlee_data.enqueue_strategy
|
|
245
|
+
|
|
246
|
+
@enqueue_strategy.setter
|
|
247
|
+
def enqueue_strategy(self, value: Optional[EnqueueStrategy]) -> None:
|
|
248
|
+
data = self.crawlee_data
|
|
249
|
+
data.enqueue_strategy = value
|
|
250
|
+
self.crawlee_data = data
|
|
251
|
+
|
|
252
|
+
@property
|
|
253
|
+
def forefront(self) -> bool:
|
|
254
|
+
"""Indicate whether the request should be enqueued at the front of the queue."""
|
|
255
|
+
return self.crawlee_data.forefront
|
|
256
|
+
|
|
257
|
+
@forefront.setter
|
|
258
|
+
def forefront(self, value: bool) -> None:
|
|
259
|
+
data = self.crawlee_data
|
|
260
|
+
data.forefront = value
|
|
261
|
+
self.crawlee_data = data
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def label(self) -> Optional[str]:
|
|
265
|
+
"""A string used to differentiate between arbitrary request types."""
|
|
266
|
+
return self.crawlee_data.label
|
|
267
|
+
|
|
268
|
+
@label.setter
|
|
269
|
+
def label(self, value: Optional[str]) -> None:
|
|
270
|
+
data = self.crawlee_data
|
|
271
|
+
data.label = value
|
|
272
|
+
self.crawlee_data = data
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def last_proxy_tier(self) -> Optional[int]:
|
|
276
|
+
"""The last proxy tier used to process the request."""
|
|
277
|
+
return self.crawlee_data.last_proxy_tier
|
|
278
|
+
|
|
279
|
+
@last_proxy_tier.setter
|
|
280
|
+
def last_proxy_tier(self, value: Optional[int]) -> None:
|
|
281
|
+
data = self.crawlee_data
|
|
282
|
+
data.last_proxy_tier = value
|
|
283
|
+
self.crawlee_data = data
|
|
284
|
+
|
|
285
|
+
@property
|
|
286
|
+
def max_retries(self) -> Optional[int]:
|
|
287
|
+
"""Crawlee-specific limit on the number of retries of the request."""
|
|
288
|
+
return self.crawlee_data.max_retries
|
|
289
|
+
|
|
290
|
+
@max_retries.setter
|
|
291
|
+
def max_retries(self, value: Optional[int]) -> None:
|
|
292
|
+
data = self.crawlee_data
|
|
293
|
+
data.max_retries = value
|
|
294
|
+
self.crawlee_data = data
|
|
295
|
+
|
|
296
|
+
@property
|
|
297
|
+
def session_rotation_count(self) -> int:
|
|
298
|
+
"""Crawlee-specific number of finished session rotations for the request."""
|
|
299
|
+
return self.crawlee_data.session_rotation_count
|
|
300
|
+
|
|
301
|
+
@session_rotation_count.setter
|
|
302
|
+
def session_rotation_count(self, value: int) -> None:
|
|
303
|
+
data = self.crawlee_data
|
|
304
|
+
data.session_rotation_count = value
|
|
305
|
+
self.crawlee_data = data
|
|
306
|
+
|
|
307
|
+
@property
|
|
308
|
+
def state(self) -> RequestState:
|
|
309
|
+
"""Crawlee-specific request handling state."""
|
|
310
|
+
return self.crawlee_data.state
|
|
311
|
+
|
|
312
|
+
@state.setter
|
|
313
|
+
def state(self, value: RequestState) -> None:
|
|
314
|
+
data = self.crawlee_data
|
|
315
|
+
data.state = value
|
|
316
|
+
self.crawlee_data = data
|
|
317
|
+
|
|
318
|
+
@property
|
|
319
|
+
def was_already_handled(self) -> bool:
|
|
320
|
+
"""Indicates whether the request was handled."""
|
|
321
|
+
return self.state == RequestState.HANDLED
|
|
322
|
+
|
|
323
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
324
|
+
"""Convert to dictionary for API."""
|
|
325
|
+
data: Dict[str, Any] = {
|
|
326
|
+
'uniqueKey': self.unique_key,
|
|
327
|
+
'url': self.url,
|
|
328
|
+
'method': self.method,
|
|
329
|
+
}
|
|
330
|
+
if self.id:
|
|
331
|
+
data['id'] = self.id
|
|
332
|
+
if self.headers:
|
|
333
|
+
data['headers'] = self.headers
|
|
334
|
+
if self.user_data:
|
|
335
|
+
data['userData'] = self.user_data
|
|
336
|
+
if self.retry_count:
|
|
337
|
+
data['retryCount'] = self.retry_count
|
|
338
|
+
if self.handled_at is not None:
|
|
339
|
+
data['handledAt'] = self.handled_at
|
|
340
|
+
if self.loaded_url is not None:
|
|
341
|
+
data['loadedUrl'] = self.loaded_url
|
|
342
|
+
if self.payload is not None:
|
|
343
|
+
data['payload'] = self.payload
|
|
344
|
+
if self.no_retry:
|
|
345
|
+
data['noRetry'] = self.no_retry
|
|
346
|
+
if self.error_messages:
|
|
347
|
+
data['errorMessages'] = self.error_messages
|
|
348
|
+
return data
|
|
349
|
+
|
|
350
|
+
@classmethod
|
|
351
|
+
def from_dict(cls, data: Mapping[str, Any]) -> 'Request':
|
|
352
|
+
"""Create from API response."""
|
|
353
|
+
return cls(
|
|
354
|
+
id=data.get('id'),
|
|
355
|
+
unique_key=data.get('uniqueKey', '') or '',
|
|
356
|
+
url=data.get('url', '') or '',
|
|
357
|
+
method=data.get('method', 'GET') or 'GET',
|
|
358
|
+
headers=dict(data.get('headers') or {}),
|
|
359
|
+
user_data=dict(data.get('userData') or {}),
|
|
360
|
+
retry_count=int(data.get('retryCount') or 0),
|
|
361
|
+
handled_at=data.get('handledAt'),
|
|
362
|
+
lock_expires_at=data.get('lockExpiresAt'),
|
|
363
|
+
loaded_url=data.get('loadedUrl'),
|
|
364
|
+
payload=data.get('payload'),
|
|
365
|
+
no_retry=bool(data.get('noRetry') or False),
|
|
366
|
+
error_messages=list(data.get('errorMessages') or []),
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
@classmethod
|
|
370
|
+
def from_url(
|
|
371
|
+
cls,
|
|
372
|
+
url: str,
|
|
373
|
+
*,
|
|
374
|
+
method: str = 'GET',
|
|
375
|
+
headers: Optional[Dict[str, str]] = None,
|
|
376
|
+
payload: Optional[Any] = None,
|
|
377
|
+
label: Optional[str] = None,
|
|
378
|
+
session_id: Optional[str] = None,
|
|
379
|
+
unique_key: Optional[str] = None,
|
|
380
|
+
keep_url_fragment: bool = False,
|
|
381
|
+
use_extended_unique_key: bool = False,
|
|
382
|
+
always_enqueue: bool = False,
|
|
383
|
+
enqueue_strategy: Optional[EnqueueStrategy] = None,
|
|
384
|
+
max_retries: Optional[int] = None,
|
|
385
|
+
**kwargs: Any,
|
|
386
|
+
) -> 'Request':
|
|
387
|
+
"""Create a new Request instance from a URL.
|
|
388
|
+
|
|
389
|
+
This is the recommended constructor for creating new Request instances.
|
|
390
|
+
It generates a Request object from a given URL with additional options
|
|
391
|
+
to customize HTTP method, payload, unique key, and other request properties.
|
|
392
|
+
If no unique_key or id is provided, they are computed automatically based
|
|
393
|
+
on the URL, method and payload.
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
url: The URL of the request.
|
|
397
|
+
method: The HTTP method of the request.
|
|
398
|
+
headers: The HTTP headers of the request.
|
|
399
|
+
payload: The data to be sent as the request body.
|
|
400
|
+
label: A custom label to differentiate between request types.
|
|
401
|
+
session_id: ID of a specific Session to which the request will be strictly bound.
|
|
402
|
+
unique_key: A unique key identifying the request.
|
|
403
|
+
keep_url_fragment: Determines whether the URL fragment should be included
|
|
404
|
+
in the unique_key computation.
|
|
405
|
+
use_extended_unique_key: Determines whether to include the HTTP method,
|
|
406
|
+
ID Session and payload in the unique_key computation.
|
|
407
|
+
always_enqueue: If True, the request will be enqueued even if already present.
|
|
408
|
+
enqueue_strategy: The strategy that will be used for enqueuing the request.
|
|
409
|
+
max_retries: Maximum number of retries for this request.
|
|
410
|
+
kwargs: Additional keyword arguments.
|
|
411
|
+
"""
|
|
412
|
+
# Build user_data with Crawlee-specific data
|
|
413
|
+
user_data: Dict[str, Any] = kwargs.pop('user_data', None) or {}
|
|
414
|
+
|
|
415
|
+
crawlee_data: Dict[str, Any] = {}
|
|
416
|
+
if label is not None:
|
|
417
|
+
crawlee_data['label'] = label
|
|
418
|
+
if enqueue_strategy is not None:
|
|
419
|
+
crawlee_data['enqueueStrategy'] = enqueue_strategy.value
|
|
420
|
+
if max_retries is not None:
|
|
421
|
+
crawlee_data['maxRetries'] = max_retries
|
|
422
|
+
|
|
423
|
+
if session_id is not None:
|
|
424
|
+
user_data['sessionId'] = session_id
|
|
425
|
+
|
|
426
|
+
if crawlee_data:
|
|
427
|
+
user_data['__crawlee'] = crawlee_data
|
|
428
|
+
|
|
429
|
+
# Compute unique_key if not provided
|
|
430
|
+
computed_unique_key = unique_key
|
|
431
|
+
if computed_unique_key is None:
|
|
432
|
+
# Normalize URL for unique_key
|
|
433
|
+
from urllib.parse import urlparse, urlunparse
|
|
434
|
+
|
|
435
|
+
parsed = urlparse(url)
|
|
436
|
+
scheme = parsed.scheme.lower()
|
|
437
|
+
netloc = parsed.netloc.lower()
|
|
438
|
+
|
|
439
|
+
# Handle URL fragment
|
|
440
|
+
fragment = parsed.fragment if keep_url_fragment else ''
|
|
441
|
+
|
|
442
|
+
if use_extended_unique_key:
|
|
443
|
+
# Include method and optionally payload in unique_key
|
|
444
|
+
key_parts = [method.upper(), scheme, netloc, parsed.path, parsed.params, parsed.query, fragment]
|
|
445
|
+
if payload is not None:
|
|
446
|
+
import json
|
|
447
|
+
|
|
448
|
+
try:
|
|
449
|
+
payload_str = json.dumps(payload, sort_keys=True)
|
|
450
|
+
except (TypeError, ValueError):
|
|
451
|
+
payload_str = str(payload)
|
|
452
|
+
key_parts.append(payload_str)
|
|
453
|
+
computed_unique_key = ':'.join(key_parts)
|
|
454
|
+
else:
|
|
455
|
+
computed_unique_key = urlunparse((scheme, netloc, parsed.path, parsed.params, parsed.query, fragment))
|
|
456
|
+
|
|
457
|
+
return cls(
|
|
458
|
+
unique_key=computed_unique_key or url,
|
|
459
|
+
url=url,
|
|
460
|
+
method=method.upper(),
|
|
461
|
+
headers=headers or {},
|
|
462
|
+
user_data=user_data,
|
|
463
|
+
payload=payload,
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
def get_query_param_from_url(self, param: str, *, default: Optional[str] = None) -> Optional[str]:
|
|
467
|
+
"""Get the value of a specific query parameter from the URL.
|
|
468
|
+
|
|
469
|
+
Args:
|
|
470
|
+
param: The query parameter name.
|
|
471
|
+
default: Default value if parameter not found.
|
|
472
|
+
|
|
473
|
+
Returns:
|
|
474
|
+
The query parameter value or default.
|
|
475
|
+
"""
|
|
476
|
+
from urllib.parse import parse_qs, urlparse
|
|
477
|
+
|
|
478
|
+
parsed = urlparse(self.url)
|
|
479
|
+
query_params = parse_qs(parsed.query)
|
|
480
|
+
values = query_params.get(param, [])
|
|
481
|
+
return values[0] if values else default
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
@dataclass
|
|
485
|
+
class RequestQueueHead:
|
|
486
|
+
"""Result of listing/locking the head of a request queue."""
|
|
487
|
+
|
|
488
|
+
items: List[Request] = field(default_factory=list)
|
|
489
|
+
limit: int = 0
|
|
490
|
+
queue_modified_at: Optional[datetime] = None
|
|
491
|
+
had_multiple_clients: bool = False
|
|
492
|
+
lock_secs: Optional[int] = None
|
|
493
|
+
queue_has_locked_requests: Optional[bool] = None
|
|
494
|
+
|
|
495
|
+
@classmethod
|
|
496
|
+
def from_client_locked_head(cls, client_locked_head: Mapping[str, Any]) -> 'RequestQueueHead':
|
|
497
|
+
"""Create a RequestQueueHead from an Apify API client's LockedRequestQueueHead model.
|
|
498
|
+
|
|
499
|
+
Args:
|
|
500
|
+
client_locked_head: LockedRequestQueueHead instance from Apify API client.
|
|
501
|
+
|
|
502
|
+
Returns:
|
|
503
|
+
RequestQueueHead instance with properly converted types.
|
|
504
|
+
"""
|
|
505
|
+
items = [Request.from_dict(item) for item in (client_locked_head.get('items') or [])]
|
|
506
|
+
modified = client_locked_head.get('queueModifiedAt')
|
|
507
|
+
|
|
508
|
+
return cls(
|
|
509
|
+
items=items,
|
|
510
|
+
limit=int(client_locked_head.get('limit') or 0),
|
|
511
|
+
queue_modified_at=(
|
|
512
|
+
datetime.fromisoformat(str(modified).replace('Z', '+00:00')) if modified else None
|
|
513
|
+
),
|
|
514
|
+
had_multiple_clients=bool(client_locked_head.get('hadMultipleClients')),
|
|
515
|
+
lock_secs=client_locked_head.get('lockSecs'),
|
|
516
|
+
queue_has_locked_requests=client_locked_head.get('queueHasLockedRequests'),
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
JsonSerializable = Any
|
|
521
|
+
StorageOpenId = Optional[str]
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def parse_api_datetime(value: Any) -> Optional[datetime]:
|
|
525
|
+
"""Parse an API datetime value into a datetime, or None."""
|
|
526
|
+
if value is None:
|
|
527
|
+
return None
|
|
528
|
+
if isinstance(value, datetime):
|
|
529
|
+
return value
|
|
530
|
+
if isinstance(value, str) and value:
|
|
531
|
+
try:
|
|
532
|
+
return datetime.fromisoformat(value.replace('Z', '+00:00'))
|
|
533
|
+
except ValueError:
|
|
534
|
+
return None
|
|
535
|
+
return None
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def dataset_metadata_from_dict(data: Mapping[str, Any], fallback_id: str = '') -> DatasetMetadata:
|
|
539
|
+
"""Build DatasetMetadata from an API response dict."""
|
|
540
|
+
return DatasetMetadata(
|
|
541
|
+
id=str(data.get('id') or fallback_id),
|
|
542
|
+
name=data.get('name'),
|
|
543
|
+
user_id=data.get('userId'),
|
|
544
|
+
created_at=parse_api_datetime(data.get('createdAt')),
|
|
545
|
+
modified_at=parse_api_datetime(data.get('modifiedAt')),
|
|
546
|
+
accessed_at=parse_api_datetime(data.get('accessedAt')),
|
|
547
|
+
item_count=int(data.get('itemCount') or 0),
|
|
548
|
+
clean_item_count=int(data.get('cleanItemCount') or 0),
|
|
549
|
+
act_id=data.get('actId') or data.get('actorId'),
|
|
550
|
+
act_run_id=data.get('actRunId') or data.get('runId'),
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def kvs_metadata_from_dict(data: Mapping[str, Any], fallback_id: str = '') -> KeyValueStoreMetadata:
|
|
555
|
+
"""Build KeyValueStoreMetadata from an API response dict."""
|
|
556
|
+
return KeyValueStoreMetadata(
|
|
557
|
+
id=str(data.get('id') or fallback_id),
|
|
558
|
+
name=data.get('name'),
|
|
559
|
+
user_id=data.get('userId'),
|
|
560
|
+
created_at=parse_api_datetime(data.get('createdAt')),
|
|
561
|
+
modified_at=parse_api_datetime(data.get('modifiedAt')),
|
|
562
|
+
accessed_at=parse_api_datetime(data.get('accessedAt')),
|
|
563
|
+
act_id=data.get('actId') or data.get('actorId'),
|
|
564
|
+
act_run_id=data.get('actRunId') or data.get('runId'),
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def rq_metadata_from_dict(data: Mapping[str, Any], fallback_id: str = '') -> RequestQueueMetadata:
|
|
569
|
+
"""Build RequestQueueMetadata from an API response dict."""
|
|
570
|
+
return RequestQueueMetadata(
|
|
571
|
+
id=str(data.get('id') or fallback_id),
|
|
572
|
+
name=data.get('name'),
|
|
573
|
+
user_id=data.get('userId'),
|
|
574
|
+
created_at=parse_api_datetime(data.get('createdAt')),
|
|
575
|
+
modified_at=parse_api_datetime(data.get('modifiedAt')),
|
|
576
|
+
accessed_at=parse_api_datetime(data.get('accessedAt')),
|
|
577
|
+
total_request_count=int(data.get('totalRequestCount') or 0),
|
|
578
|
+
handled_request_count=int(data.get('handledRequestCount') or 0),
|
|
579
|
+
pending_request_count=int(data.get('pendingRequestCount') or 0),
|
|
580
|
+
had_multiple_clients=bool(data.get('hadMultipleClients') or False),
|
|
581
|
+
act_id=data.get('actId') or data.get('actorId'),
|
|
582
|
+
act_run_id=data.get('actRunId') or data.get('runId'),
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def normalize_request(request: Union['Request', str, Mapping[str, Any]]) -> Request:
|
|
587
|
+
"""Normalize a URL string, mapping, or Request into a Request."""
|
|
588
|
+
if isinstance(request, Request):
|
|
589
|
+
return request
|
|
590
|
+
if isinstance(request, str):
|
|
591
|
+
return Request.from_url(request)
|
|
592
|
+
return Request.from_dict(request)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def as_sequence(
|
|
596
|
+
data: Union[Mapping[str, Any], Sequence[Mapping[str, Any]]],
|
|
597
|
+
) -> List[Mapping[str, Any]]:
|
|
598
|
+
"""Normalize a single mapping or sequence of mappings into a list."""
|
|
599
|
+
if isinstance(data, Mapping):
|
|
600
|
+
return [data]
|
|
601
|
+
return list(data)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Utility functions for Scrapely storage clients."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def unique_key_to_request_id(unique_key: str, request_id_length: int = 15) -> str:
|
|
9
|
+
"""Generate a deterministic request ID based on a unique key.
|
|
10
|
+
|
|
11
|
+
This function creates a URL-safe, truncated request ID from a unique key using
|
|
12
|
+
SHA-256 hashing and base64 encoding. The same unique key will always produce
|
|
13
|
+
the same request ID, allowing for deterministic lookups without storing a mapping.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
unique_key: The unique key to convert into a request ID.
|
|
17
|
+
request_id_length: The length of the resulting request ID (default 15).
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
A URL-safe, truncated request ID based on the unique key.
|
|
21
|
+
|
|
22
|
+
Example:
|
|
23
|
+
>>> unique_key_to_request_id("https://example.com")
|
|
24
|
+
'aBcDeFgHiJkLmNo'
|
|
25
|
+
>>> unique_key_to_request_id("https://example.com") # Same input
|
|
26
|
+
'aBcDeFgHiJkLmNo' # Same output
|
|
27
|
+
"""
|
|
28
|
+
# Encode the unique key and compute its SHA-256 hash
|
|
29
|
+
hashed_key = hashlib.sha256(unique_key.encode('utf-8')).digest()
|
|
30
|
+
|
|
31
|
+
# Encode the hash in base64 and decode it to get a string
|
|
32
|
+
base64_encoded = base64.b64encode(hashed_key).decode('utf-8')
|
|
33
|
+
|
|
34
|
+
# Remove characters that are not URL-safe ('+', '/', or '=')
|
|
35
|
+
url_safe_key = re.sub(r'(\+|/|=)', '', base64_encoded)
|
|
36
|
+
|
|
37
|
+
# Truncate the key to the desired length
|
|
38
|
+
return url_safe_key[:request_id_length]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Storage clients (Apify ``apify.storage_clients`` parity).
|
|
2
|
+
|
|
3
|
+
The source transform rewrites module paths only, so Apify's class names are
|
|
4
|
+
provided as aliases of the Scrapely clients.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from crawlee.storage_clients import MemoryStorageClient
|
|
8
|
+
|
|
9
|
+
from scrapely.storage import (
|
|
10
|
+
FileSystemStorageClient,
|
|
11
|
+
ScrapelyStorageClient,
|
|
12
|
+
SmartScrapelyStorageClient,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
ApifyStorageClient = ScrapelyStorageClient
|
|
16
|
+
SmartApifyStorageClient = SmartScrapelyStorageClient
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
'ApifyStorageClient',
|
|
20
|
+
'FileSystemStorageClient',
|
|
21
|
+
'MemoryStorageClient',
|
|
22
|
+
'ScrapelyStorageClient',
|
|
23
|
+
'SmartApifyStorageClient',
|
|
24
|
+
'SmartScrapelyStorageClient',
|
|
25
|
+
]
|