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,257 @@
1
+ """Register Scrapely storage with Crawlee's service locator (TS setApiStorage equivalent)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any, Dict, MutableMapping, Optional, TypedDict, cast
7
+
8
+ from crawlee import service_locator
9
+ from crawlee.storage_clients._base import StorageClient
10
+
11
+ from ._file_system import ScrapelyFileSystemStorageClient
12
+ from .scrapely_storage_client import ScrapelyStorageClient
13
+ from .smart_scrapely_storage_client import SmartScrapelyStorageClient
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ _api_factory: Optional[ScrapelyStorageClient] = None
18
+ _smart_client: Optional[SmartScrapelyStorageClient] = None
19
+
20
+ # Cache for auto-saved values: store_id -> key -> value
21
+ # This allows get_auto_saved_value to return the same object reference,
22
+ # and persist_state listeners can iterate and save all cached values.
23
+ _auto_save_cache: Dict[str, Dict[str, Any]] = {}
24
+
25
+ # Track which stores have registered persistState listeners
26
+ _persist_state_registered: set = set()
27
+
28
+
29
+ class ApiStorageConfig(TypedDict, total=False):
30
+ api_url: str
31
+ token: str
32
+ run_id: str
33
+ transport: Any
34
+
35
+
36
+ def set_api_storage(
37
+ api_config: Optional[ApiStorageConfig] = None,
38
+ *,
39
+ cloud_storage_client: Optional[ScrapelyStorageClient] = None,
40
+ local_storage_client: Optional[StorageClient] = None,
41
+ ) -> SmartScrapelyStorageClient:
42
+ """Configure Crawlee to use Scrapely storage (Smart wrapper + ``/v2`` cloud).
43
+
44
+ Equivalent to TypeScript ``setApiStorage()`` plus Apify's ``SmartApifyStorageClient``
45
+ registration on the service locator.
46
+ """
47
+ global _api_factory, _smart_client
48
+
49
+ if cloud_storage_client is not None:
50
+ cloud = cloud_storage_client
51
+ elif api_config is not None:
52
+ cloud = ScrapelyStorageClient(
53
+ api_url=api_config.get('api_url'),
54
+ token=api_config.get('token'),
55
+ run_id=api_config.get('run_id'),
56
+ transport=api_config.get('transport'),
57
+ )
58
+ else:
59
+ cloud = ScrapelyStorageClient()
60
+
61
+ _api_factory = cloud
62
+ local = local_storage_client or ScrapelyFileSystemStorageClient()
63
+ smart = SmartScrapelyStorageClient(
64
+ cloud_storage_client=cloud,
65
+ local_storage_client=local,
66
+ )
67
+ _smart_client = smart
68
+ service_locator.set_storage_client(smart)
69
+ return smart
70
+
71
+
72
+ def set_storage_client(client: StorageClient) -> SmartScrapelyStorageClient:
73
+ """Register a storage client; wrap non-Smart clients like Apify Actor expects."""
74
+ global _api_factory, _smart_client
75
+
76
+ if isinstance(client, SmartScrapelyStorageClient):
77
+ smart = client
78
+ elif isinstance(client, ScrapelyStorageClient):
79
+ _api_factory = client
80
+ smart = SmartScrapelyStorageClient(
81
+ cloud_storage_client=client,
82
+ local_storage_client=ScrapelyFileSystemStorageClient(),
83
+ )
84
+ else:
85
+ smart = SmartScrapelyStorageClient(
86
+ cloud_storage_client=_api_factory or ScrapelyStorageClient(),
87
+ local_storage_client=client,
88
+ )
89
+
90
+ _smart_client = smart
91
+ service_locator.set_storage_client(smart)
92
+ return smart
93
+
94
+
95
+ def get_api_storage_factory() -> Optional[ScrapelyStorageClient]:
96
+ return _api_factory
97
+
98
+
99
+ def get_smart_storage_client() -> Optional[SmartScrapelyStorageClient]:
100
+ return _smart_client
101
+
102
+
103
+ def is_api_storage_configured() -> bool:
104
+ return _api_factory is not None
105
+
106
+
107
+ def disconnect_storage() -> None:
108
+ global _api_factory, _smart_client, _auto_save_cache, _persist_state_registered
109
+ _api_factory = None
110
+ _smart_client = None
111
+ _auto_save_cache.clear()
112
+ _persist_state_registered.clear()
113
+
114
+
115
+ def _ensure_persist_state_listener(store_id: str, config: Any) -> None:
116
+ """Register a persistState listener for a store if not already registered.
117
+
118
+ This listener saves all cached values for that store when persistState is emitted.
119
+ """
120
+ if store_id in _persist_state_registered:
121
+ return
122
+
123
+ event_manager = config.get_event_manager() if hasattr(config, 'get_event_manager') else None
124
+ if event_manager is None:
125
+ logger.warning('No event manager available for persistState listener')
126
+ return
127
+
128
+ async def on_persist_state(_: Any) -> None:
129
+ store_cache = _auto_save_cache.get(store_id)
130
+ if not store_cache:
131
+ return
132
+
133
+ factory = _api_factory
134
+ if not factory:
135
+ return
136
+
137
+ try:
138
+ api = factory._ensure_api()
139
+ for key, value in store_cache.items():
140
+ try:
141
+ await api.kvs_set_record(store_id, key, value)
142
+ except Exception as e:
143
+ logger.warning('Failed to persist state value to %s: %s', key, e)
144
+ except Exception as e:
145
+ logger.warning('Failed to persist auto-saved values: %s', e)
146
+
147
+ event_manager.on('persistState', on_persist_state)
148
+ _persist_state_registered.add(store_id)
149
+
150
+
151
+ async def get_auto_saved_value(
152
+ key: str,
153
+ default_value: MutableMapping[str, Any],
154
+ *,
155
+ store_id: Optional[str] = None,
156
+ ) -> MutableMapping[str, Any]:
157
+ """Get a value from KVS that will be automatically saved on changes.
158
+
159
+ This implementation matches Crawlee/Apify's approach:
160
+ 1. Check cache first (return same object reference for subsequent calls)
161
+ 2. Fetch value from storage if not cached
162
+ 3. Cache the value (so mutations are tracked)
163
+ 4. Register persistState listener to save cached values
164
+
165
+ Args:
166
+ key: The key of the record to get
167
+ default_value: The default value to return and store if the key doesn't exist
168
+ store_id: Optional store ID (uses default KVS if not provided)
169
+
170
+ Returns:
171
+ The stored value or the default_value
172
+ """
173
+ from scrapely.configuration import Configuration as ScrapelyConfiguration
174
+
175
+ config = ScrapelyConfiguration.get_global_config()
176
+
177
+ # Resolve store ID from environment if not provided
178
+ if store_id is None:
179
+ store_id = (
180
+ config.get('default_key_value_store_id')
181
+ or config.actor_run_id
182
+ )
183
+
184
+ if store_id is None:
185
+ raise ValueError('No key-value store ID available for get_auto_saved_value')
186
+
187
+ # Check if we have a cached value for this store/key
188
+ store_cache = _auto_save_cache.get(store_id)
189
+ if store_cache is not None and key in store_cache:
190
+ return cast(MutableMapping[str, Any], store_cache[key])
191
+
192
+ # Fetch value from storage
193
+ factory = _api_factory
194
+ if factory is None:
195
+ raise ValueError('API storage client not initialized. Call set_api_storage() first.')
196
+
197
+ api = factory._ensure_api()
198
+
199
+ # Try to get existing value
200
+ value, content_type = await api.kvs_get_record(store_id, key)
201
+
202
+ if value is None:
203
+ # Store the default value and return it
204
+ await api.kvs_set_record(store_id, key, default_value)
205
+ value = default_value
206
+ elif not isinstance(value, MutableMapping):
207
+ # If the stored value is not a mutable mapping, we can't track mutations
208
+ # Return it as-is but don't cache for auto-save
209
+ logger.warning(
210
+ 'get_auto_saved_value: stored value for key "%s" is not a mutable mapping, '
211
+ 'auto-save will not work',
212
+ key,
213
+ )
214
+ return cast(MutableMapping[str, Any], value)
215
+
216
+ # Initialize store cache if needed
217
+ if store_cache is None:
218
+ store_cache = {}
219
+ _auto_save_cache[store_id] = store_cache
220
+
221
+ # Check cache again (race condition: another call might have cached it)
222
+ if key in store_cache:
223
+ return cast(MutableMapping[str, Any], store_cache[key])
224
+
225
+ # Cache the value - mutations to this object will be persisted
226
+ store_cache[key] = value
227
+
228
+ # Register persistState listener for this store
229
+ _ensure_persist_state_listener(store_id, config)
230
+
231
+ return cast(MutableMapping[str, Any], value)
232
+
233
+
234
+ async def persist_autosaved_values(*, store_id: Optional[str] = None) -> None:
235
+ """Force autosaved values to be saved without waiting for an event.
236
+
237
+ Args:
238
+ store_id: Optional store ID to persist. If None, persists all stores.
239
+ """
240
+ factory = _api_factory
241
+ if factory is None:
242
+ return
243
+
244
+ api = factory._ensure_api()
245
+
246
+ stores_to_persist = [store_id] if store_id else list(_auto_save_cache.keys())
247
+
248
+ for sid in stores_to_persist:
249
+ store_cache = _auto_save_cache.get(sid)
250
+ if not store_cache:
251
+ continue
252
+
253
+ for key, value in store_cache.items():
254
+ try:
255
+ await api.kvs_set_record(sid, key, value)
256
+ except Exception as e:
257
+ logger.warning('Failed to persist state value to %s: %s', key, e)
@@ -0,0 +1,305 @@
1
+ """Scrapely cloud DatasetClient for Crawlee."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any, List, Mapping, Optional, Sequence, Union
6
+
7
+ from typing_extensions import Self, override
8
+
9
+ from crawlee.storage_clients._base import DatasetClient
10
+ from crawlee.storage_clients.models import DatasetItemsListPage, DatasetMetadata
11
+
12
+ from .client import ApiStorageClient
13
+ from ._metadata import resolve_storage_id, to_dataset_metadata
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import AsyncIterator
17
+
18
+ from crawlee._types import JsonSerializable
19
+ from crawlee.configuration import Configuration
20
+ from crawlee.storage_clients import StorageClient
21
+
22
+
23
+ def _old_dataset_to_crawlee(meta: Any) -> DatasetMetadata:
24
+ return to_dataset_metadata(
25
+ {
26
+ 'id': meta.id,
27
+ 'name': meta.name,
28
+ 'createdAt': meta.created_at.isoformat() if getattr(meta, 'created_at', None) else None,
29
+ 'accessedAt': meta.accessed_at.isoformat() if getattr(meta, 'accessed_at', None) else None,
30
+ 'modifiedAt': meta.modified_at.isoformat() if getattr(meta, 'modified_at', None) else None,
31
+ 'itemCount': getattr(meta, 'item_count', 0),
32
+ },
33
+ meta.id,
34
+ )
35
+
36
+
37
+ class ScrapelyDatasetClient(DatasetClient):
38
+ """Dataset client backed by Scrapely `/v2/datasets`."""
39
+
40
+ def __init__(self, *, api: ApiStorageClient, metadata: DatasetMetadata) -> None:
41
+ self._api = api
42
+ self._metadata = metadata
43
+
44
+ @property
45
+ def id(self) -> str:
46
+ """Get the storage ID."""
47
+ return self._metadata.id
48
+
49
+ @property
50
+ def name(self) -> Optional[str]:
51
+ """Get the storage name."""
52
+ return self._metadata.name
53
+
54
+ @classmethod
55
+ async def open(
56
+ cls,
57
+ *,
58
+ api: ApiStorageClient,
59
+ id: Optional[str] = None,
60
+ name: Optional[str] = None,
61
+ alias: Optional[str] = None,
62
+ default_id: Optional[str] = None,
63
+ ) -> Self:
64
+ open_id, open_name = resolve_storage_id(
65
+ id=id, name=name, alias=alias, default_id=default_id,
66
+ )
67
+ meta = await api.get_or_create_dataset(id=open_id, name=open_name)
68
+ return cls(api=api, metadata=_old_dataset_to_crawlee(meta))
69
+
70
+ @override
71
+ async def get_metadata(self) -> DatasetMetadata:
72
+ meta = await self._api.dataset_get(self._metadata.id)
73
+ self._metadata = _old_dataset_to_crawlee(meta)
74
+ return self._metadata
75
+
76
+ @override
77
+ async def drop(self) -> None:
78
+ await self._api.dataset_delete(self._metadata.id)
79
+
80
+ @override
81
+ async def purge(self) -> None:
82
+ if not self._metadata.name:
83
+ raise NotImplementedError(
84
+ 'Dataset.purge() is not supported for unnamed/default datasets on Scrapely '
85
+ '(no clear endpoint). Use a named dataset or drop() and reopen.'
86
+ )
87
+ name = self._metadata.name
88
+ await self.drop()
89
+ meta = await self._api.get_or_create_dataset(name=name)
90
+ self._metadata = _old_dataset_to_crawlee(meta)
91
+
92
+ @override
93
+ async def push_data(
94
+ self,
95
+ data: Union[Sequence[Mapping[str, 'JsonSerializable']], Mapping[str, 'JsonSerializable']],
96
+ ) -> None:
97
+ items: List[Mapping[str, Any]]
98
+ if isinstance(data, Mapping):
99
+ items = [data]
100
+ else:
101
+ items = list(data)
102
+ await self._api.dataset_push_items(self._metadata.id, items)
103
+
104
+ @override
105
+ async def get_data(
106
+ self,
107
+ *,
108
+ offset: int = 0,
109
+ limit: Optional[int] = 999_999_999_999,
110
+ clean: bool = False,
111
+ desc: bool = False,
112
+ fields: Optional[List[str]] = None,
113
+ omit: Optional[List[str]] = None,
114
+ unwind: Optional[List[str]] = None,
115
+ skip_empty: bool = False,
116
+ skip_hidden: bool = False,
117
+ flatten: Optional[List[str]] = None,
118
+ view: Optional[str] = None,
119
+ ) -> DatasetItemsListPage:
120
+ _, page = await self._api.dataset_list_items(
121
+ self._metadata.id,
122
+ offset=offset,
123
+ limit=None if limit == 999_999_999_999 else limit,
124
+ clean=clean,
125
+ desc=desc,
126
+ fields=fields,
127
+ omit=omit,
128
+ unwind=unwind,
129
+ skip_empty=skip_empty,
130
+ skip_hidden=skip_hidden,
131
+ flatten=flatten,
132
+ view=view,
133
+ )
134
+ return DatasetItemsListPage(
135
+ count=page.count,
136
+ offset=page.offset,
137
+ limit=page.limit,
138
+ total=page.total,
139
+ desc=page.desc,
140
+ items=list(page.items),
141
+ )
142
+
143
+ @override
144
+ async def iterate_items(
145
+ self,
146
+ *,
147
+ offset: int = 0,
148
+ limit: Optional[int] = None,
149
+ clean: bool = False,
150
+ desc: bool = False,
151
+ fields: Optional[List[str]] = None,
152
+ omit: Optional[List[str]] = None,
153
+ unwind: Optional[List[str]] = None,
154
+ skip_empty: bool = False,
155
+ skip_hidden: bool = False,
156
+ ) -> AsyncIterator[Mapping[str, 'JsonSerializable']]:
157
+ current = offset
158
+ remaining = limit
159
+ chunk = 1000
160
+ while True:
161
+ fetch = chunk if remaining is None else min(chunk, remaining)
162
+ if fetch <= 0:
163
+ break
164
+ page = await self.get_data(
165
+ offset=current,
166
+ limit=fetch,
167
+ clean=clean,
168
+ desc=desc,
169
+ fields=fields,
170
+ omit=omit,
171
+ unwind=unwind,
172
+ skip_empty=skip_empty,
173
+ skip_hidden=skip_hidden,
174
+ )
175
+ if not page.items:
176
+ break
177
+ for item in page.items:
178
+ yield item
179
+ if remaining is not None:
180
+ remaining -= 1
181
+ if remaining <= 0:
182
+ return
183
+ current += len(page.items)
184
+ if len(page.items) < fetch:
185
+ break
186
+
187
+ async def list_items(
188
+ self,
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
+ ) -> list[Mapping[str, 'JsonSerializable']]:
200
+ """List all items in the dataset.
201
+
202
+ Args:
203
+ offset: Number of items to skip at the start.
204
+ limit: Maximum number of items to return.
205
+ clean: Return only non-empty items, exclude hidden fields.
206
+ desc: Sort in descending order.
207
+ fields: Fields to include in each item.
208
+ omit: Fields to exclude from each item.
209
+ unwind: Unwind items by a specified array field.
210
+ skip_empty: Exclude empty items.
211
+ skip_hidden: Exclude fields starting with '#'.
212
+ flatten: Fields to flatten in returned items.
213
+
214
+ Returns:
215
+ List of items as dictionaries.
216
+ """
217
+ result: list[Mapping[str, 'JsonSerializable']] = []
218
+ async for item in self.iterate_items(
219
+ offset=offset,
220
+ limit=limit,
221
+ clean=clean,
222
+ desc=desc,
223
+ fields=fields,
224
+ omit=omit,
225
+ unwind=unwind,
226
+ skip_empty=skip_empty,
227
+ skip_hidden=skip_hidden,
228
+ ):
229
+ result.append(item)
230
+ return result
231
+
232
+ async def export_to(
233
+ self,
234
+ key: str,
235
+ *,
236
+ content_type: str = 'json',
237
+ to_kvs_id: Optional[str] = None,
238
+ to_kvs_name: Optional[str] = None,
239
+ to_kvs_storage_client: Optional['StorageClient'] = None,
240
+ to_kvs_configuration: Optional['Configuration'] = None,
241
+ **kwargs: Any,
242
+ ) -> None:
243
+ """Export the entire dataset to a key-value store.
244
+
245
+ Args:
246
+ key: Key under which to save the data in the KVS.
247
+ content_type: Format for export ('json' or 'csv').
248
+ to_kvs_id: ID of the target key-value store.
249
+ to_kvs_name: Name of the target key-value store.
250
+ to_kvs_storage_client: Storage client for the target KVS.
251
+ to_kvs_configuration: Configuration for the target KVS.
252
+ **kwargs: Additional parameters for the export (e.g., JSON/CSV options).
253
+ """
254
+ import json
255
+ import csv
256
+ import io
257
+
258
+ # Get all items from the dataset
259
+ items = await self.list_items(
260
+ clean=kwargs.get('clean', False),
261
+ skip_empty=kwargs.get('skip_empty', False),
262
+ skip_hidden=kwargs.get('skip_hidden', False),
263
+ )
264
+
265
+ # Determine target KVS - use this API client's KVS by default
266
+ if to_kvs_id or to_kvs_name:
267
+ target_kvs_meta = await self._api.get_or_create_key_value_store(
268
+ id=to_kvs_id,
269
+ name=to_kvs_name,
270
+ )
271
+ target_kvs_id = target_kvs_meta.id
272
+ else:
273
+ # Use default KVS
274
+ target_kvs_meta = await self._api.get_or_create_key_value_store()
275
+ target_kvs_id = target_kvs_meta.id
276
+
277
+ # Format the data
278
+ if content_type == 'csv':
279
+ output = io.StringIO()
280
+ if items:
281
+ writer = csv.DictWriter(
282
+ output,
283
+ fieldnames=items[0].keys() if items else [],
284
+ **{k: v for k, v in kwargs.items() if k in [
285
+ 'dialect', 'delimiter', 'doublequote', 'escapechar',
286
+ 'lineterminator', 'quotechar', 'quoting', 'skipinitialspace', 'strict'
287
+ ]},
288
+ )
289
+ writer.writeheader()
290
+ writer.writerows(items)
291
+ value = output.getvalue()
292
+ ct = 'text/csv'
293
+ else:
294
+ # JSON format
295
+ value = json.dumps(
296
+ items,
297
+ **{k: v for k, v in kwargs.items() if k in [
298
+ 'skipkeys', 'ensure_ascii', 'check_circular', 'allow_nan',
299
+ 'cls', 'indent', 'separators', 'default', 'sort_keys'
300
+ ]},
301
+ )
302
+ ct = 'application/json'
303
+
304
+ # Store in KVS
305
+ await self._api.kvs_set_record(target_kvs_id, key, value, ct)