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