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,292 @@
1
+ """Event manager for Scrapely Actors.
2
+
3
+ Extends Crawlee's EventManager with WebSocket connectivity to the Scrapely platform
4
+ for receiving platform events (persistState, migrating, aborting, systemInfo, etc.).
5
+
6
+ Uses Crawlee's Event enum which includes all 12 event types:
7
+ ABORTING, BROWSER_CLOSED, BROWSER_LAUNCHED, BROWSER_RETIRED, CRAWLER_STATUS,
8
+ EXIT, MIGRATING, PAGE_CLOSED, PAGE_CREATED, PERSIST_STATE, SESSION_RETIRED, SYSTEM_INFO
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import contextlib
15
+ import logging
16
+ import time
17
+ from typing import TYPE_CHECKING, Any, Callable, Optional, Union, TypeAlias
18
+ from urllib.parse import urlencode
19
+
20
+ from pydantic import Discriminator, TypeAdapter
21
+ from typing_extensions import override
22
+
23
+ from crawlee.events import EventManager as CrawleeEventManager
24
+ from crawlee.events._types import Event, EventPersistStateData
25
+
26
+ from ._types import (
27
+ DeprecatedEvent,
28
+ EventMessage,
29
+ SystemInfoEventData,
30
+ UnknownEvent,
31
+ )
32
+
33
+ if TYPE_CHECKING:
34
+ from types import TracebackType
35
+
36
+ from typing_extensions import Unpack
37
+
38
+ from crawlee.events._event_manager import EventManagerOptions
39
+
40
+ from scrapely.configuration import Configuration
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ # Re-export Event for backward compatibility
45
+ __all__ = ['Event', 'EventManager', 'ScrapelyEventManager']
46
+
47
+ # Type adapter for parsing platform WebSocket messages
48
+ event_data_adapter = TypeAdapter[EventMessage | DeprecatedEvent | UnknownEvent](
49
+ EventMessage | DeprecatedEvent | UnknownEvent,
50
+ )
51
+
52
+
53
+ class ScrapelyEventManager(CrawleeEventManager):
54
+ """Event manager for the Scrapely platform.
55
+
56
+ This class extends Crawlee's ``EventManager`` to provide Scrapely-specific functionality, including
57
+ WebSocket connectivity to the Scrapely platform for receiving platform events.
58
+
59
+ The event manager handles:
60
+ - Registration and emission of events and their listeners.
61
+ - WebSocket connection to Scrapely platform events.
62
+ - Processing and validation of platform messages.
63
+ - Automatic event forwarding from the platform to local event listeners.
64
+
65
+ This class should not be used directly. Use the ``Actor.on`` and ``Actor.off`` methods to interact
66
+ with the event system.
67
+ """
68
+
69
+ _NON_RETRYABLE_CLOSE_CODES = frozenset(
70
+ {
71
+ 1002, # PROTOCOL_ERROR
72
+ 1003, # UNSUPPORTED_DATA
73
+ 1007, # INVALID_DATA
74
+ 1008, # POLICY_VIOLATION
75
+ 1010, # MANDATORY_EXTENSION
76
+ }
77
+ )
78
+ """WebSocket close codes for a permanent condition, on which the connection is not re-established.
79
+
80
+ The platform sends ``POLICY_VIOLATION`` (1008) for an unknown/missing run ID or an exceeded per-run
81
+ connection limit. The rest are protocol, data, or mandatory-extension failures that reconnecting
82
+ cannot resolve.
83
+ """
84
+
85
+ _HEALTHY_CONNECTION_MIN_DURATION = 1.0
86
+ """Seconds a connection must stay open to count as healthy, after which a drop reconnects without backoff."""
87
+
88
+ def __init__(
89
+ self,
90
+ configuration: Configuration,
91
+ **kwargs: Unpack[EventManagerOptions],
92
+ ) -> None:
93
+ """Initialize a new instance.
94
+
95
+ Args:
96
+ configuration: The Actor configuration for the event manager.
97
+ **kwargs: Additional event manager options passed to the parent class.
98
+ """
99
+ super().__init__(**kwargs)
100
+
101
+ self._configuration = configuration
102
+ """The Actor configuration for the event manager."""
103
+
104
+ self._platform_events_websocket: Any = None
105
+ """WebSocket connection to the platform events."""
106
+
107
+ self._process_platform_messages_task: Optional[asyncio.Task] = None
108
+ """Task for processing messages from the platform WebSocket."""
109
+
110
+ self._connected_to_platform_websocket: Optional[asyncio.Future[bool]] = None
111
+ """Future that resolves when the connection to the platform WebSocket is established."""
112
+
113
+ @override
114
+ async def __aenter__(self) -> ScrapelyEventManager:
115
+ await super().__aenter__()
116
+ self._connected_to_platform_websocket = asyncio.Future()
117
+
118
+ # Run tasks but don't await them
119
+ ws_url = self._configuration.get('actor_events_ws_url')
120
+ if ws_url:
121
+ # Append authentication parameters to the WebSocket URL
122
+ # This matches the Node SDK behavior in events-client.ts
123
+ token = self._configuration.get('token')
124
+ run_id = self._configuration.get('actor_run_id')
125
+ if token and run_id:
126
+ separator = '&' if '?' in ws_url else '?'
127
+ ws_url = f"{ws_url}{separator}{urlencode({'token': token, 'runId': run_id})}"
128
+ logger.debug(f'Connecting to platform events websocket with runId={run_id}')
129
+ else:
130
+ logger.warning(
131
+ 'Token or runId not available, connecting to platform events websocket without auth params. '
132
+ 'This may result in authentication failures.'
133
+ )
134
+
135
+ # Connect in the background without blocking init, matching the Node SDK: a slow or failed
136
+ # connection is retried and must not crash the Actor.
137
+ self._process_platform_messages_task = asyncio.create_task(
138
+ self._process_platform_messages(ws_url)
139
+ )
140
+ else:
141
+ logger.debug(
142
+ 'ACTOR_EVENTS_WEBSOCKET_URL env var not set, no events from Scrapely platform will be emitted.'
143
+ )
144
+
145
+ return self
146
+
147
+ @override
148
+ async def __aexit__(
149
+ self,
150
+ exc_type: type[BaseException] | None,
151
+ exc_value: BaseException | None,
152
+ exc_traceback: TracebackType | None,
153
+ ) -> None:
154
+ # Cancel the task before closing the websocket so that the closed connection is not treated as a drop
155
+ # and followed by a reconnect attempt.
156
+ if self._process_platform_messages_task and not self._process_platform_messages_task.done():
157
+ self._process_platform_messages_task.cancel()
158
+ with contextlib.suppress(asyncio.CancelledError):
159
+ await self._process_platform_messages_task
160
+
161
+ if self._platform_events_websocket:
162
+ try:
163
+ await self._platform_events_websocket.close()
164
+ except Exception:
165
+ pass
166
+
167
+ await super().__aexit__(exc_type, exc_value, exc_traceback)
168
+
169
+ async def _process_platform_messages(self, ws_url: str) -> None:
170
+ """Process messages from the platform WebSocket with reconnect logic."""
171
+ try:
172
+ import websockets.asyncio.client
173
+ import websockets.client
174
+ except ImportError:
175
+ logger.error('websockets package not installed, cannot connect to platform events')
176
+ if self._connected_to_platform_websocket is not None and not self._connected_to_platform_websocket.done():
177
+ self._connected_to_platform_websocket.set_result(False)
178
+ return
179
+
180
+ # The `websockets` reconnect iterator only backs off on failed connection *attempts*, not on a connection
181
+ # that opens and is then closed. Track our own backoff here so a server that keeps accepting and immediately
182
+ # closing is not hammered; it is reset after a healthy connection so a healthy drop reconnects immediately.
183
+ backoff_delays = None
184
+
185
+ try:
186
+ # Used as an async iterator, `connect` reconnects with exponential backoff on failed connection attempts,
187
+ # including the first one (timeouts, network errors and 5xx responses are retried).
188
+ async for websocket in websockets.asyncio.client.connect(ws_url):
189
+ self._platform_events_websocket = websocket
190
+ if self._connected_to_platform_websocket and not self._connected_to_platform_websocket.done():
191
+ self._connected_to_platform_websocket.set_result(True)
192
+ else:
193
+ logger.info('Reconnected to the platform events websocket.')
194
+
195
+ connection_opened_at = time.monotonic()
196
+ connection_lost = await self._consume_messages(websocket)
197
+
198
+ if not self._should_reconnect_after_close(websocket, connection_lost=connection_lost):
199
+ break
200
+
201
+ # Reconnect a healthy connection immediately; back off only on repeated rapid drops.
202
+ if time.monotonic() - connection_opened_at >= self._HEALTHY_CONNECTION_MIN_DURATION:
203
+ backoff_delays = None
204
+ elif backoff_delays is None:
205
+ backoff_delays = websockets.client.backoff()
206
+ else:
207
+ await asyncio.sleep(next(backoff_delays))
208
+ except Exception:
209
+ logger.exception('Error in websocket connection')
210
+ if self._connected_to_platform_websocket is not None and not self._connected_to_platform_websocket.done():
211
+ self._connected_to_platform_websocket.set_result(False)
212
+
213
+ async def _consume_messages(self, websocket: Any) -> bool:
214
+ """Handle platform messages until the connection closes; return whether it was lost vs. closed cleanly."""
215
+ try:
216
+ import websockets.exceptions
217
+ async for message in websocket:
218
+ await self._handle_platform_message(message)
219
+ except websockets.exceptions.ConnectionClosed:
220
+ return True
221
+ return False
222
+
223
+ async def _handle_platform_message(self, message: str | bytes) -> None:
224
+ """Parse a single platform message and emit the matching local event."""
225
+ try:
226
+ if isinstance(message, bytes):
227
+ message = message.decode('utf-8')
228
+
229
+ parsed_message = event_data_adapter.validate_json(message)
230
+
231
+ if isinstance(parsed_message, DeprecatedEvent):
232
+ return
233
+
234
+ if isinstance(parsed_message, UnknownEvent):
235
+ logger.debug(
236
+ f'Unknown message received: event_name={parsed_message.name}, event_data={parsed_message.data}'
237
+ )
238
+ return
239
+
240
+ # Convert SystemInfoEventData to Crawlee format, pass other data as-is
241
+ event_data = parsed_message.data
242
+ if isinstance(event_data, SystemInfoEventData):
243
+ dedicated_cpus = self._configuration.get('dedicated_cpus') or 1
244
+ event_data = event_data.to_crawlee_format(dedicated_cpus)
245
+
246
+ self.emit(event=parsed_message.name, event_data=event_data)
247
+
248
+ if parsed_message.name == Event.MIGRATING:
249
+ # Stop the persist state recurring task and emit a final persistState with isMigrating=True
250
+ await self._emit_persist_state_event_rec_task.stop()
251
+ self.emit(
252
+ event=Event.PERSIST_STATE,
253
+ event_data=EventPersistStateData(is_migrating=True),
254
+ )
255
+ except Exception:
256
+ logger.exception('Cannot parse Actor event', extra={'raw_message': message})
257
+
258
+ def _should_reconnect_after_close(
259
+ self,
260
+ websocket: Any,
261
+ *,
262
+ connection_lost: bool,
263
+ ) -> bool:
264
+ """Log the websocket close and report whether to reconnect (``False`` on a non-retryable close code)."""
265
+ close_code = getattr(websocket, 'close_code', None)
266
+ close_reason = getattr(websocket, 'close_reason', None)
267
+
268
+ if close_code in self._NON_RETRYABLE_CLOSE_CODES:
269
+ logger.error(
270
+ f'Connection to platform events websocket was closed with a non-retryable code '
271
+ f'(code={close_code}, reason={close_reason!r}); not reconnecting. '
272
+ f'No further platform events will be received for the rest of this run, so migration and '
273
+ f'abort handling (e.g. persisting state before a migration) is now disabled.'
274
+ )
275
+ return False
276
+
277
+ if connection_lost:
278
+ logger.warning(
279
+ f'Connection to platform events websocket was lost '
280
+ f'(code={close_code}, reason={close_reason!r}), reconnecting...'
281
+ )
282
+ else:
283
+ logger.info(
284
+ f'Connection to platform events websocket was closed '
285
+ f'(code={close_code}, reason={close_reason!r}), reconnecting...'
286
+ )
287
+ return True
288
+
289
+
290
+ # Backward compatibility: keep the old EventManager name as an alias
291
+ # for code that imported it directly. New code should use ScrapelyEventManager.
292
+ EventManager: TypeAlias = ScrapelyEventManager
scrapely/log.py ADDED
@@ -0,0 +1,39 @@
1
+ """Logging for Scrapely Actors (Apify ``apify.log`` parity).
2
+
3
+ Imported Apify actors use ``from apify.log import ActorLogFormatter, logger``,
4
+ which the source transform rewrites to ``scrapely.log``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+
11
+ from crawlee._log_config import CrawleeLogFormatter, configure_logger, get_configured_log_level
12
+
13
+ # Name of the logger used throughout the library (resolves to 'scrapely')
14
+ logger_name = __name__.split('.')[0]
15
+
16
+ # Logger used throughout the library
17
+ logger = logging.getLogger(logger_name)
18
+
19
+
20
+ class ActorLogFormatter(CrawleeLogFormatter): # noqa: D101 (Inherited from parent class)
21
+ pass
22
+
23
+
24
+ def _configure_logging() -> None:
25
+ """Set up the default Actor log output (called by ``Actor.init`` unless ``configure_logging=False``)."""
26
+ level = get_configured_log_level()
27
+
28
+ # Keep the API client logger quiet unless debug logging is requested
29
+ client_logger = logging.getLogger('scrapely_client')
30
+ configure_logger(client_logger, remove_old_handlers=True)
31
+ client_logger.setLevel(logging.INFO if level > logging.DEBUG else level)
32
+
33
+ # Silence HTTPX logger unless debug logging is requested
34
+ logging.getLogger('httpx').setLevel(logging.WARNING if level > logging.DEBUG else level)
35
+
36
+ # Use configured log level for the SDK logger
37
+ sdk_logger = logging.getLogger(logger_name)
38
+ configure_logger(sdk_logger, remove_old_handlers=True)
39
+ sdk_logger.setLevel(level)