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,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
+ ]
@@ -0,0 +1,5 @@
1
+ """Storages (Apify ``apify.storages`` parity)."""
2
+
3
+ from crawlee.storages import Dataset, KeyValueStore, RequestQueue
4
+
5
+ __all__ = ['Dataset', 'KeyValueStore', 'RequestQueue']