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,210 @@
|
|
|
1
|
+
"""Input secret decryption for the Scrapely Python SDK.
|
|
2
|
+
|
|
3
|
+
Port of the Node SDK's ``src/crypto/secrets.ts``. The platform encrypts secret
|
|
4
|
+
Actor input values with RSA-OAEP (SHA-256) and delivers the RSA private key to
|
|
5
|
+
the container as a passphrase-encrypted file (AES-256-GCM with a PBKDF2-derived
|
|
6
|
+
key). This module decrypts the private key, decrypts each secret field, and
|
|
7
|
+
merges the plaintext values back into the Actor input.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Dict
|
|
19
|
+
|
|
20
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
21
|
+
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
|
22
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
# ─── Constants (must match the Node SDK's crypto/secrets.ts) ──────────────────
|
|
27
|
+
|
|
28
|
+
_IV_LENGTH = 12
|
|
29
|
+
_AUTH_TAG_LENGTH = 16
|
|
30
|
+
_PBKDF2_ITERATIONS = 100_000
|
|
31
|
+
_PBKDF2_HASH = 'sha256'
|
|
32
|
+
_SALT_LENGTH = 32
|
|
33
|
+
_KEY_LENGTH = 32
|
|
34
|
+
|
|
35
|
+
_OAEP_PADDING = padding.OAEP(
|
|
36
|
+
mgf=padding.MGF1(algorithm=hashes.SHA256()),
|
|
37
|
+
algorithm=hashes.SHA256(),
|
|
38
|
+
label=None,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def decrypt_private_key_file(private_key_file_path: str, passphrase: str) -> str:
|
|
43
|
+
"""Read the private key file from disk and decrypt it with the run passphrase.
|
|
44
|
+
|
|
45
|
+
The file layout is ``salt(32) | iv(12) | authTag(16) | ciphertext``, where the
|
|
46
|
+
AES-256-GCM key is derived from the passphrase with PBKDF2-HMAC-SHA256.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
private_key_file_path: Path to the encrypted private key file
|
|
50
|
+
(e.g. ``/run/secrets/private_key.pem``).
|
|
51
|
+
passphrase: The run passphrase from ``APIFY_INPUT_SECRETS_PRIVATE_KEY_PASSPHRASE``.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
The decrypted private key as a PEM string.
|
|
55
|
+
"""
|
|
56
|
+
encrypted_data = Path(private_key_file_path).read_bytes()
|
|
57
|
+
|
|
58
|
+
salt = encrypted_data[:_SALT_LENGTH]
|
|
59
|
+
iv = encrypted_data[_SALT_LENGTH:_SALT_LENGTH + _IV_LENGTH]
|
|
60
|
+
auth_tag = encrypted_data[_SALT_LENGTH + _IV_LENGTH:_SALT_LENGTH + _IV_LENGTH + _AUTH_TAG_LENGTH]
|
|
61
|
+
encrypted = encrypted_data[_SALT_LENGTH + _IV_LENGTH + _AUTH_TAG_LENGTH:]
|
|
62
|
+
|
|
63
|
+
key = hashlib.pbkdf2_hmac(
|
|
64
|
+
_PBKDF2_HASH,
|
|
65
|
+
passphrase.encode('utf-8'),
|
|
66
|
+
salt,
|
|
67
|
+
_PBKDF2_ITERATIONS,
|
|
68
|
+
dklen=_KEY_LENGTH,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# The ``cryptography`` AESGCM helper expects ciphertext || tag (the file
|
|
72
|
+
# stores tag before the ciphertext, like Node's setAuthTag() flow).
|
|
73
|
+
decrypted = AESGCM(key).decrypt(iv, encrypted + auth_tag, None)
|
|
74
|
+
|
|
75
|
+
return decrypted.decode('utf-8')
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def decrypt_secret_value(encrypted_base64: str, private_key_pem: str) -> Any:
|
|
79
|
+
"""Decrypt a single secret value encrypted with RSA-OAEP (SHA-256).
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
encrypted_base64: Base64-encoded encrypted data.
|
|
83
|
+
private_key_pem: The decrypted private key PEM.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
The decrypted value; JSON-shaped plaintext is parsed into Python objects,
|
|
87
|
+
anything else is returned as a plain string.
|
|
88
|
+
"""
|
|
89
|
+
encrypted = base64.b64decode(encrypted_base64)
|
|
90
|
+
|
|
91
|
+
private_key = serialization.load_pem_private_key(private_key_pem.encode('utf-8'), password=None)
|
|
92
|
+
if not isinstance(private_key, rsa.RSAPrivateKey):
|
|
93
|
+
raise ValueError('The decrypted private key is not an RSA key')
|
|
94
|
+
decrypted_data = private_key.decrypt(encrypted, _OAEP_PADDING)
|
|
95
|
+
|
|
96
|
+
plaintext = decrypted_data.decode('utf-8')
|
|
97
|
+
|
|
98
|
+
# Try to parse as JSON (for objects, arrays, numbers, booleans)
|
|
99
|
+
try:
|
|
100
|
+
return json.loads(plaintext)
|
|
101
|
+
except json.JSONDecodeError:
|
|
102
|
+
return plaintext
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def decrypt_all_secrets(
|
|
106
|
+
encrypted_secrets: Dict[str, str],
|
|
107
|
+
private_key_pem: str,
|
|
108
|
+
) -> Dict[str, Any]:
|
|
109
|
+
"""Decrypt all secret values from the encrypted input payload.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
encrypted_secrets: Map of field name to base64 encrypted value.
|
|
113
|
+
private_key_pem: The decrypted private key PEM.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Map of field name to decrypted value; fields that fail to decrypt are
|
|
117
|
+
skipped with a warning (matching the Node SDK behavior).
|
|
118
|
+
"""
|
|
119
|
+
decrypted: Dict[str, Any] = {}
|
|
120
|
+
|
|
121
|
+
for field_name, encrypted_base64 in encrypted_secrets.items():
|
|
122
|
+
try:
|
|
123
|
+
decrypted[field_name] = decrypt_secret_value(encrypted_base64, private_key_pem)
|
|
124
|
+
except Exception as err: # noqa: BLE001 - a bad field must not kill the run
|
|
125
|
+
logger.warning('Failed to decrypt secret field "%s": %s', field_name, err)
|
|
126
|
+
|
|
127
|
+
return decrypted
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def merge_decrypted_input(
|
|
131
|
+
input_data: Dict[str, Any],
|
|
132
|
+
decrypted_secrets: Dict[str, Any],
|
|
133
|
+
) -> Dict[str, Any]:
|
|
134
|
+
"""Merge decrypted secret values into the original input object.
|
|
135
|
+
|
|
136
|
+
Supports nested paths like ``credentials.apiKey`` or ``database.password``.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
input_data: The original input object (with secrets stripped/masked).
|
|
140
|
+
decrypted_secrets: Map of field name to decrypted value.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
A new input object with secrets populated.
|
|
144
|
+
"""
|
|
145
|
+
merged = dict(input_data)
|
|
146
|
+
|
|
147
|
+
for field_name, value in decrypted_secrets.items():
|
|
148
|
+
if '.' in field_name:
|
|
149
|
+
# Handle nested paths
|
|
150
|
+
parts = field_name.split('.')
|
|
151
|
+
current = merged
|
|
152
|
+
for part in parts[:-1]:
|
|
153
|
+
if not isinstance(current.get(part), dict):
|
|
154
|
+
current[part] = {}
|
|
155
|
+
current = current[part]
|
|
156
|
+
current[parts[-1]] = value
|
|
157
|
+
else:
|
|
158
|
+
merged[field_name] = value
|
|
159
|
+
|
|
160
|
+
return merged
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def decrypt_secret_inputs(input_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
164
|
+
"""Full decryption flow: read the private key file, decrypt it with the passphrase,
|
|
165
|
+
then decrypt all secrets and merge them into the input.
|
|
166
|
+
|
|
167
|
+
This is called from ``Actor.get_input()`` when
|
|
168
|
+
``APIFY_INPUT_SECRETS_PRIVATE_KEY_FILE`` is set.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
input_data: Current Actor input object.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
The input object with decrypted secret values merged in; on failure the
|
|
175
|
+
input is returned as-is so the Actor can still run.
|
|
176
|
+
"""
|
|
177
|
+
private_key_file = os.environ.get('APIFY_INPUT_SECRETS_PRIVATE_KEY_FILE')
|
|
178
|
+
passphrase = os.environ.get('APIFY_INPUT_SECRETS_PRIVATE_KEY_PASSPHRASE')
|
|
179
|
+
|
|
180
|
+
if not private_key_file or not passphrase:
|
|
181
|
+
return input_data # No secrets to decrypt
|
|
182
|
+
|
|
183
|
+
logger.info('Decrypting secret inputs...')
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
# 1. Read and decrypt the private key file
|
|
187
|
+
private_key_pem = decrypt_private_key_file(private_key_file, passphrase)
|
|
188
|
+
logger.info('Private key decrypted successfully')
|
|
189
|
+
|
|
190
|
+
# 2. Get the encrypted secrets from the input
|
|
191
|
+
# The encrypted secrets are stored in a special __encrypted_secrets property
|
|
192
|
+
encrypted_secrets = input_data.get('__encrypted_secrets')
|
|
193
|
+
|
|
194
|
+
if not encrypted_secrets or not isinstance(encrypted_secrets, dict) or not encrypted_secrets:
|
|
195
|
+
logger.info('No encrypted secrets found in input')
|
|
196
|
+
return input_data
|
|
197
|
+
|
|
198
|
+
# 3. Decrypt all secret values
|
|
199
|
+
decrypted_secrets = decrypt_all_secrets(encrypted_secrets, private_key_pem)
|
|
200
|
+
logger.info('Decrypted %d secret field(s)', len(decrypted_secrets))
|
|
201
|
+
|
|
202
|
+
# 4. Merge decrypted values into input and remove the __encrypted_secrets key
|
|
203
|
+
merged = merge_decrypted_input(input_data, decrypted_secrets)
|
|
204
|
+
merged.pop('__encrypted_secrets', None)
|
|
205
|
+
merged.pop('__encrypted_secret_fields', None)
|
|
206
|
+
|
|
207
|
+
return merged
|
|
208
|
+
except Exception as err: # noqa: BLE001 - secrets won't be available but actor can still run
|
|
209
|
+
logger.error('Failed to decrypt secret inputs: %s', err)
|
|
210
|
+
return input_data
|
scrapely/errors.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Error classes for the Scrapely Python SDK.
|
|
3
|
+
|
|
4
|
+
This module provides a hierarchy of error classes for handling API errors,
|
|
5
|
+
following the same pattern as the Apify SDK.
|
|
6
|
+
|
|
7
|
+
Hierarchy:
|
|
8
|
+
ScrapelyClientError
|
|
9
|
+
├── ScrapelyApiError
|
|
10
|
+
│ ├── InvalidRequestError (400)
|
|
11
|
+
│ ├── UnauthorizedError (401)
|
|
12
|
+
│ ├── ForbiddenError (403)
|
|
13
|
+
│ ├── NotFoundError (404)
|
|
14
|
+
│ ├── ConflictError (409)
|
|
15
|
+
│ ├── RateLimitError (429)
|
|
16
|
+
│ └── ServerError (5xx)
|
|
17
|
+
└── InvalidResponseBodyError
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import TYPE_CHECKING, Any, Dict, Optional, Type
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ScrapelyClientError(Exception):
|
|
29
|
+
"""Base class for all Scrapely API client errors."""
|
|
30
|
+
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class InvalidResponseBodyError(ScrapelyClientError):
|
|
35
|
+
"""Error raised when a response body cannot be parsed."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, response: 'httpx.Response') -> None:
|
|
38
|
+
self.response = response
|
|
39
|
+
super().__init__(f'Invalid response body: {response.text[:200]}')
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ScrapelyApiError(ScrapelyClientError):
|
|
43
|
+
"""Error raised when the Scrapely API returns an error response."""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
response: 'httpx.Response',
|
|
48
|
+
attempt: int,
|
|
49
|
+
*,
|
|
50
|
+
method: str = 'GET',
|
|
51
|
+
) -> None:
|
|
52
|
+
self.response = response
|
|
53
|
+
self.attempt = attempt
|
|
54
|
+
self.method = method
|
|
55
|
+
self.status_code = response.status_code
|
|
56
|
+
|
|
57
|
+
self.type: Optional[str] = None
|
|
58
|
+
self.message: Optional[str] = None
|
|
59
|
+
self.data: Optional[Dict[str, Any]] = None
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
body = response.json()
|
|
63
|
+
self.type = body.get('type')
|
|
64
|
+
self.message = body.get('message')
|
|
65
|
+
self.data = body.get('data')
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
error_parts = [f'HTTP {response.status_code}']
|
|
70
|
+
if self.type:
|
|
71
|
+
error_parts.append(f'({self.type})')
|
|
72
|
+
if self.message:
|
|
73
|
+
error_parts.append(f'- {self.message}')
|
|
74
|
+
error_parts.append(f'[attempt {attempt}]')
|
|
75
|
+
|
|
76
|
+
super().__init__(' '.join(error_parts))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class InvalidRequestError(ScrapelyApiError):
|
|
80
|
+
"""Raised when the Scrapely API returns an HTTP 400 Bad Request response."""
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class UnauthorizedError(ScrapelyApiError):
|
|
85
|
+
"""Raised when the Scrapely API returns an HTTP 401 Unauthorized response."""
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ForbiddenError(ScrapelyApiError):
|
|
90
|
+
"""Raised when the Scrapely API returns an HTTP 403 Forbidden response."""
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class NotFoundError(ScrapelyApiError):
|
|
95
|
+
"""Raised when the Scrapely API returns an HTTP 404 Not Found response."""
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ConflictError(ScrapelyApiError):
|
|
100
|
+
"""Raised when the Scrapely API returns an HTTP 409 Conflict response."""
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class RateLimitError(ScrapelyApiError):
|
|
105
|
+
"""Raised when the Scrapely API returns an HTTP 429 Too Many Requests response."""
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class ServerError(ScrapelyApiError):
|
|
110
|
+
"""Raised when the Scrapely API returns an HTTP 5xx response."""
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
_STATUS_CODE_TO_ERROR: Dict[int, Type[ScrapelyApiError]] = {
|
|
115
|
+
400: InvalidRequestError,
|
|
116
|
+
401: UnauthorizedError,
|
|
117
|
+
403: ForbiddenError,
|
|
118
|
+
404: NotFoundError,
|
|
119
|
+
409: ConflictError,
|
|
120
|
+
429: RateLimitError,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def raise_for_status(response: 'httpx.Response', attempt: int = 1, *, method: str = 'GET') -> None:
|
|
125
|
+
"""Raise an appropriate error for the given HTTP response."""
|
|
126
|
+
if response.status_code < 400:
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
status_code = response.status_code
|
|
130
|
+
error_class = _STATUS_CODE_TO_ERROR.get(status_code)
|
|
131
|
+
|
|
132
|
+
if error_class is None and 500 <= status_code < 600:
|
|
133
|
+
error_class = ServerError
|
|
134
|
+
|
|
135
|
+
if error_class is None:
|
|
136
|
+
error_class = ScrapelyApiError
|
|
137
|
+
|
|
138
|
+
raise error_class(response, attempt, method=method)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
__all__ = [
|
|
142
|
+
'ScrapelyClientError',
|
|
143
|
+
'ScrapelyApiError',
|
|
144
|
+
'InvalidResponseBodyError',
|
|
145
|
+
'InvalidRequestError',
|
|
146
|
+
'UnauthorizedError',
|
|
147
|
+
'ForbiddenError',
|
|
148
|
+
'NotFoundError',
|
|
149
|
+
'ConflictError',
|
|
150
|
+
'RateLimitError',
|
|
151
|
+
'ServerError',
|
|
152
|
+
'raise_for_status',
|
|
153
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Event management for Scrapely Actors.
|
|
3
|
+
|
|
4
|
+
Exports all event types and data models matching the Apify SDK.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from crawlee.events import EventManager as CrawleeEventManager
|
|
8
|
+
from crawlee.events import LocalEventManager
|
|
9
|
+
from crawlee.events._types import (
|
|
10
|
+
EventAbortingData,
|
|
11
|
+
EventExitData,
|
|
12
|
+
EventMigratingData,
|
|
13
|
+
EventPersistStateData,
|
|
14
|
+
EventSystemInfoData,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from ._types import (
|
|
18
|
+
AbortingEvent,
|
|
19
|
+
ActorEventTypes,
|
|
20
|
+
DeprecatedEvent,
|
|
21
|
+
Event,
|
|
22
|
+
EventMessage,
|
|
23
|
+
EventWithoutData,
|
|
24
|
+
ExitEvent,
|
|
25
|
+
MigratingEvent,
|
|
26
|
+
PersistStateEvent,
|
|
27
|
+
SystemInfoEvent,
|
|
28
|
+
SystemInfoEventData,
|
|
29
|
+
UnknownEvent,
|
|
30
|
+
)
|
|
31
|
+
from .event_manager import EventManager, ScrapelyEventManager
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
'Event',
|
|
35
|
+
'EventManager',
|
|
36
|
+
'ScrapelyEventManager',
|
|
37
|
+
'CrawleeEventManager',
|
|
38
|
+
'LocalEventManager',
|
|
39
|
+
# Event data types
|
|
40
|
+
'SystemInfoEventData',
|
|
41
|
+
'EventAbortingData',
|
|
42
|
+
'EventExitData',
|
|
43
|
+
'EventMigratingData',
|
|
44
|
+
'EventPersistStateData',
|
|
45
|
+
'EventSystemInfoData',
|
|
46
|
+
# Event types
|
|
47
|
+
'PersistStateEvent',
|
|
48
|
+
'SystemInfoEvent',
|
|
49
|
+
'MigratingEvent',
|
|
50
|
+
'AbortingEvent',
|
|
51
|
+
'ExitEvent',
|
|
52
|
+
'EventWithoutData',
|
|
53
|
+
'DeprecatedEvent',
|
|
54
|
+
'UnknownEvent',
|
|
55
|
+
'EventMessage',
|
|
56
|
+
'ActorEventTypes',
|
|
57
|
+
]
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Scrapely event types - extends Crawlee's event types with Scrapely-specific data models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Annotated, Any, Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
9
|
+
from pydantic.alias_generators import to_camel
|
|
10
|
+
|
|
11
|
+
from crawlee.events._types import (
|
|
12
|
+
Event,
|
|
13
|
+
EventAbortingData,
|
|
14
|
+
EventExitData,
|
|
15
|
+
EventMigratingData,
|
|
16
|
+
EventPersistStateData,
|
|
17
|
+
EventSystemInfoData,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# Re-export Event for convenience
|
|
21
|
+
__all__ = [
|
|
22
|
+
'Event',
|
|
23
|
+
'SystemInfoEventData',
|
|
24
|
+
'PersistStateEvent',
|
|
25
|
+
'SystemInfoEvent',
|
|
26
|
+
'MigratingEvent',
|
|
27
|
+
'AbortingEvent',
|
|
28
|
+
'ExitEvent',
|
|
29
|
+
'EventWithoutData',
|
|
30
|
+
'DeprecatedEvent',
|
|
31
|
+
'UnknownEvent',
|
|
32
|
+
'EventMessage',
|
|
33
|
+
'ActorEventTypes',
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
ActorEventTypes = Literal['systemInfo', 'persistState', 'migrating', 'aborting']
|
|
37
|
+
"""Event types emitted by the Scrapely platform during an Actor run.
|
|
38
|
+
|
|
39
|
+
This is the Scrapely-specific subset of `Event`. For the full set (including framework-level events
|
|
40
|
+
like `SESSION_RETIRED` or `BROWSER_LAUNCHED`), use `Event` from `scrapely`.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SystemInfoEventData(BaseModel):
|
|
45
|
+
"""Resource usage metrics carried by a `systemInfo` event."""
|
|
46
|
+
|
|
47
|
+
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
|
|
48
|
+
|
|
49
|
+
mem_avg_bytes: float
|
|
50
|
+
"""Average memory usage over the measured interval, in bytes."""
|
|
51
|
+
|
|
52
|
+
mem_current_bytes: float
|
|
53
|
+
"""Current memory usage, in bytes."""
|
|
54
|
+
|
|
55
|
+
mem_max_bytes: float
|
|
56
|
+
"""Peak memory usage observed so far, in bytes."""
|
|
57
|
+
|
|
58
|
+
cpu_avg_usage: float
|
|
59
|
+
"""Average CPU usage over the measured interval, in percent."""
|
|
60
|
+
|
|
61
|
+
cpu_max_usage: float
|
|
62
|
+
"""Peak CPU usage observed so far, in percent."""
|
|
63
|
+
|
|
64
|
+
cpu_current_usage: float
|
|
65
|
+
"""Current CPU usage, in percent."""
|
|
66
|
+
|
|
67
|
+
is_cpu_overloaded: bool
|
|
68
|
+
"""Whether the CPU is currently overloaded."""
|
|
69
|
+
|
|
70
|
+
created_at: datetime
|
|
71
|
+
"""Timestamp when the metrics were collected."""
|
|
72
|
+
|
|
73
|
+
def to_crawlee_format(self, dedicated_cpus: float) -> EventSystemInfoData:
|
|
74
|
+
"""Convert to Crawlee's EventSystemInfoData format."""
|
|
75
|
+
return EventSystemInfoData.model_validate(
|
|
76
|
+
{
|
|
77
|
+
'cpu_info': {
|
|
78
|
+
'used_ratio': (self.cpu_current_usage / 100) / dedicated_cpus,
|
|
79
|
+
'created_at': self.created_at,
|
|
80
|
+
},
|
|
81
|
+
'memory_info': {
|
|
82
|
+
'total_size': self.mem_max_bytes,
|
|
83
|
+
'current_size': self.mem_current_bytes,
|
|
84
|
+
'created_at': self.created_at,
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class PersistStateEvent(BaseModel):
|
|
91
|
+
"""A `persistState` event instructing the Actor to persist its state."""
|
|
92
|
+
|
|
93
|
+
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
|
|
94
|
+
|
|
95
|
+
name: Literal[Event.PERSIST_STATE]
|
|
96
|
+
"""The event name."""
|
|
97
|
+
|
|
98
|
+
data: Annotated[EventPersistStateData, Field(default_factory=lambda: EventPersistStateData(is_migrating=False))]
|
|
99
|
+
"""The event payload."""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class SystemInfoEvent(BaseModel):
|
|
103
|
+
"""A `systemInfo` event carrying the Actor's resource usage metrics."""
|
|
104
|
+
|
|
105
|
+
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
|
|
106
|
+
|
|
107
|
+
name: Literal[Event.SYSTEM_INFO]
|
|
108
|
+
"""The event name."""
|
|
109
|
+
|
|
110
|
+
data: SystemInfoEventData
|
|
111
|
+
"""The event payload."""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class MigratingEvent(BaseModel):
|
|
115
|
+
"""A `migrating` event signalling the Actor is about to be migrated to another host."""
|
|
116
|
+
|
|
117
|
+
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
|
|
118
|
+
|
|
119
|
+
name: Literal[Event.MIGRATING]
|
|
120
|
+
"""The event name."""
|
|
121
|
+
|
|
122
|
+
data: Annotated[EventMigratingData, Field(default_factory=EventMigratingData)]
|
|
123
|
+
"""The event payload."""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class AbortingEvent(BaseModel):
|
|
127
|
+
"""An `aborting` event signalling the Actor run is being aborted."""
|
|
128
|
+
|
|
129
|
+
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
|
|
130
|
+
|
|
131
|
+
name: Literal[Event.ABORTING]
|
|
132
|
+
"""The event name."""
|
|
133
|
+
|
|
134
|
+
data: Annotated[EventAbortingData, Field(default_factory=EventAbortingData)]
|
|
135
|
+
"""The event payload."""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class ExitEvent(BaseModel):
|
|
139
|
+
"""An `exit` event signalling the Actor process is about to exit."""
|
|
140
|
+
|
|
141
|
+
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
|
|
142
|
+
|
|
143
|
+
name: Literal[Event.EXIT]
|
|
144
|
+
"""The event name."""
|
|
145
|
+
|
|
146
|
+
data: Annotated[EventExitData, Field(default_factory=EventExitData)]
|
|
147
|
+
"""The event payload."""
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class EventWithoutData(BaseModel):
|
|
151
|
+
"""A framework-level event that carries no payload (e.g. browser and page lifecycle events)."""
|
|
152
|
+
|
|
153
|
+
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
|
|
154
|
+
|
|
155
|
+
name: Literal[
|
|
156
|
+
Event.SESSION_RETIRED,
|
|
157
|
+
Event.BROWSER_LAUNCHED,
|
|
158
|
+
Event.BROWSER_RETIRED,
|
|
159
|
+
Event.BROWSER_CLOSED,
|
|
160
|
+
Event.PAGE_CREATED,
|
|
161
|
+
Event.PAGE_CLOSED,
|
|
162
|
+
]
|
|
163
|
+
"""The event name."""
|
|
164
|
+
|
|
165
|
+
data: Any = None
|
|
166
|
+
"""The event payload, always empty for this event."""
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class DeprecatedEvent(BaseModel):
|
|
170
|
+
"""A deprecated event kept for backward compatibility (e.g. `cpuInfo`)."""
|
|
171
|
+
|
|
172
|
+
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
|
|
173
|
+
|
|
174
|
+
name: Literal['cpuInfo']
|
|
175
|
+
"""The event name."""
|
|
176
|
+
|
|
177
|
+
data: Annotated[dict[str, Any], Field(default_factory=dict)]
|
|
178
|
+
"""The event payload."""
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class UnknownEvent(BaseModel):
|
|
182
|
+
"""A fallback for any event whose name is not recognized by the SDK."""
|
|
183
|
+
|
|
184
|
+
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
|
|
185
|
+
|
|
186
|
+
name: str
|
|
187
|
+
"""The event name."""
|
|
188
|
+
|
|
189
|
+
data: Annotated[dict[str, Any], Field(default_factory=dict)]
|
|
190
|
+
"""The event payload."""
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
EventMessage = PersistStateEvent | SystemInfoEvent | MigratingEvent | AbortingEvent | ExitEvent | EventWithoutData
|
|
194
|
+
"""Union of all recognized event message types."""
|