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,741 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Proxy configuration for Scrapely Actors.
|
|
3
|
+
|
|
4
|
+
Configures connection to proxy servers to prevent target websites
|
|
5
|
+
from blocking your crawlers based on IP address rate limits or blacklists.
|
|
6
|
+
|
|
7
|
+
Three modes are supported (JS SDK parity):
|
|
8
|
+
|
|
9
|
+
- Platform proxy: when the Scrapely server injects SCRAPELY_PROXY_*/APIFY_PROXY_*
|
|
10
|
+
credentials (Evomi-backed), groups/country_code/password build Apify-style
|
|
11
|
+
gateway usernames automatically.
|
|
12
|
+
- BYOP: custom proxy_urls / new_url_function / tiered proxy lists win over the
|
|
13
|
+
platform proxy.
|
|
14
|
+
- Mixed: the 'auto' (or 'http://auto') sentinel inside proxy_urls / tiered
|
|
15
|
+
lists reserves a slot for the platform proxy alongside BYOP entries.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, cast
|
|
24
|
+
|
|
25
|
+
from .types import ProxyInfo
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from .configuration import Configuration
|
|
29
|
+
from .storage.api_client import ApiClient
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
# Apify parity: session IDs are at most 50 chars of [0-9a-zA-Z._~].
|
|
34
|
+
MAX_SESSION_ID_LENGTH = 50
|
|
35
|
+
SESSION_ID_REGEX = re.compile(r'^[a-zA-Z0-9._~]+$')
|
|
36
|
+
|
|
37
|
+
# Apify parity: two-letter ISO country codes; ISO 3166-2 subdivision codes
|
|
38
|
+
# are 1-3 uppercase alphanumeric characters (e.g. 'CA', 'NSW', '9').
|
|
39
|
+
COUNTRY_CODE_REGEX = re.compile(r'^[A-Z]{2}$')
|
|
40
|
+
SUBDIVISION_CODE_REGEX = re.compile(r'^[A-Z0-9]{1,3}$')
|
|
41
|
+
|
|
42
|
+
# Sentinel values reserving a platform-proxy slot inside proxy URL lists.
|
|
43
|
+
PLATFORM_SENTINELS = frozenset({'auto', 'http://auto'})
|
|
44
|
+
|
|
45
|
+
# Status-check constants (Apify parity: 4s timeout, 2 attempts).
|
|
46
|
+
CHECK_ACCESS_REQUEST_TIMEOUT_MILLIS = 4000
|
|
47
|
+
CHECK_ACCESS_MAX_ATTEMPTS = 2
|
|
48
|
+
|
|
49
|
+
# Gateway upstream error codes (590-599, proxy-chain semantics) — the same
|
|
50
|
+
# table Apify Proxy uses. See docs/evomi-integration.md for the contract.
|
|
51
|
+
PROXY_STATUS_CODE_DESCRIPTIONS = {
|
|
52
|
+
590: 'Non Successful: upstream responded with a non-200 status code',
|
|
53
|
+
591: 'reserved for further use',
|
|
54
|
+
592: 'Status Code Out Of Range: upstream responded with status code different than 100-999',
|
|
55
|
+
593: 'Not Found: DNS lookup failed (EAI_NODATA or EAI_NONAME)',
|
|
56
|
+
594: 'Connection Refused: upstream refused connection',
|
|
57
|
+
595: 'Connection Reset: connection reset due to loss of connection or timeout',
|
|
58
|
+
596: 'Broken Pipe: trying to write on a closed socket',
|
|
59
|
+
597: 'Auth Failed: incorrect upstream credentials',
|
|
60
|
+
598: 'reserved for further use',
|
|
61
|
+
599: 'Upstream Error: generic upstream error',
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def is_platform_sentinel(url: Optional[str]) -> bool:
|
|
66
|
+
"""True for platform-proxy sentinel values ('auto', 'http://auto')."""
|
|
67
|
+
return url is not None and url in PLATFORM_SENTINELS
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _validate_session_id(session_id: str) -> None:
|
|
71
|
+
"""Validate a proxy session ID like the Apify SDKs do."""
|
|
72
|
+
if len(session_id) > MAX_SESSION_ID_LENGTH:
|
|
73
|
+
raise ValueError(f'Session ID must not be longer than {MAX_SESSION_ID_LENGTH} characters.')
|
|
74
|
+
if not SESSION_ID_REGEX.match(session_id):
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f'Session ID "{session_id}" contains unsupported characters. '
|
|
77
|
+
'Only "a-z", "A-Z", "0-9", ".", "_" and "~" are allowed.'
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _validate_country_options(country_code: Optional[str], subdivision_code: Optional[str]) -> None:
|
|
82
|
+
"""Validate country/subdivision options like the Apify SDKs do.
|
|
83
|
+
|
|
84
|
+
Country must be a two-letter ISO code; subdivision an ISO 3166-2 code
|
|
85
|
+
(1-3 uppercase alphanumeric characters) and requires a country.
|
|
86
|
+
"""
|
|
87
|
+
if country_code is not None and not COUNTRY_CODE_REGEX.match(country_code):
|
|
88
|
+
raise ValueError(
|
|
89
|
+
f'ProxyConfiguration: "country_code" must be a two-letter ISO country code (got "{country_code}").'
|
|
90
|
+
)
|
|
91
|
+
if subdivision_code is not None:
|
|
92
|
+
if not SUBDIVISION_CODE_REGEX.match(subdivision_code):
|
|
93
|
+
raise ValueError(
|
|
94
|
+
f'ProxyConfiguration: "subdivision_code" must be a 1-3 character ISO 3166-2 '
|
|
95
|
+
f'subdivision code (got "{subdivision_code}").'
|
|
96
|
+
)
|
|
97
|
+
if not country_code:
|
|
98
|
+
raise ValueError('ProxyConfiguration: "subdivision_code" requires "country_code" to be set.')
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _sanitize_fragment(value: str) -> str:
|
|
102
|
+
"""Strip URL-breaking characters from a username fragment.
|
|
103
|
+
|
|
104
|
+
The gateway grammar is comma-based, so fragments themselves must never
|
|
105
|
+
contain ',@:/?#' or whitespace (Apify behavior: the username is NOT
|
|
106
|
+
percent-encoded).
|
|
107
|
+
"""
|
|
108
|
+
return re.sub(r'[:@/?#\s]', '', value)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _platform_env() -> Optional[Dict[str, Any]]:
|
|
112
|
+
"""Read platform proxy credentials from the environment (or None)."""
|
|
113
|
+
hostname = os.environ.get('SCRAPELY_PROXY_HOSTNAME') or os.environ.get('APIFY_PROXY_HOSTNAME')
|
|
114
|
+
password = os.environ.get('SCRAPELY_PROXY_PASSWORD') or os.environ.get('APIFY_PROXY_PASSWORD')
|
|
115
|
+
if not hostname or not password:
|
|
116
|
+
return None
|
|
117
|
+
port_str = os.environ.get('SCRAPELY_PROXY_PORT') or os.environ.get('APIFY_PROXY_PORT')
|
|
118
|
+
try:
|
|
119
|
+
port = int(port_str) if port_str else 8000
|
|
120
|
+
if not 0 < port < 65536:
|
|
121
|
+
raise ValueError
|
|
122
|
+
except ValueError:
|
|
123
|
+
port = 8000
|
|
124
|
+
return {
|
|
125
|
+
'hostname': hostname,
|
|
126
|
+
'port': port,
|
|
127
|
+
'password': password,
|
|
128
|
+
'username': os.environ.get('SCRAPELY_PROXY_USERNAME') or os.environ.get('APIFY_PROXY_USERNAME'),
|
|
129
|
+
'status_url': os.environ.get('SCRAPELY_PROXY_STATUS_URL') or os.environ.get('APIFY_PROXY_STATUS_URL'),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _platform_missing_error() -> str:
|
|
134
|
+
return (
|
|
135
|
+
'The platform proxy was requested (via the "auto" sentinel, tiered proxies without '
|
|
136
|
+
'proxy URLs, or by relying on the default platform configuration), but no platform '
|
|
137
|
+
'proxy credentials (SCRAPELY_PROXY_*/APIFY_PROXY_*) were injected into this '
|
|
138
|
+
'environment. Run the actor on Scrapely with proxy access enabled, or remove the '
|
|
139
|
+
'platform proxy entries. To use the platform proxy locally or from outside '
|
|
140
|
+
'Scrapely, copy your proxy credentials from the Scrapely console (Proxy page) '
|
|
141
|
+
'into the SCRAPELY_PROXY_HOSTNAME, SCRAPELY_PROXY_PORT, SCRAPELY_PROXY_USERNAME '
|
|
142
|
+
'and SCRAPELY_PROXY_PASSWORD environment variables (or pass password=... '
|
|
143
|
+
'explicitly). External platform proxy access requires an active plan or a '
|
|
144
|
+
'prepaid (PAYG) balance of at least $1; free accounts get run-scoped '
|
|
145
|
+
'credentials injected into their on-platform runs only.'
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class ProxyConfiguration:
|
|
150
|
+
"""Configuration for connecting to proxy servers.
|
|
151
|
+
|
|
152
|
+
Proxy servers are used to prevent target websites from blocking your
|
|
153
|
+
crawlers based on IP address rate limits or blacklists.
|
|
154
|
+
|
|
155
|
+
Usage:
|
|
156
|
+
proxy_config = await Actor.create_proxy_configuration()
|
|
157
|
+
proxy_url = await proxy_config.new_url()
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
def __init__(
|
|
161
|
+
self,
|
|
162
|
+
groups: Optional[List[str]] = None,
|
|
163
|
+
password: Optional[str] = None,
|
|
164
|
+
country_code: Optional[str] = None,
|
|
165
|
+
subdivision_code: Optional[str] = None,
|
|
166
|
+
proxy_urls: Optional[List[str]] = None,
|
|
167
|
+
new_url_function: Optional[Callable[..., Any]] = None,
|
|
168
|
+
tiered_proxy_urls: Optional[List[List[Optional[str]]]] = None,
|
|
169
|
+
tiered_proxy_config: Optional[List[Dict[str, Any]]] = None,
|
|
170
|
+
actor_proxy_input: Optional[Dict] = None,
|
|
171
|
+
_actor_config: Optional['Configuration'] = None,
|
|
172
|
+
_api_client: Optional['ApiClient'] = None,
|
|
173
|
+
):
|
|
174
|
+
"""Initialize the proxy configuration.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
groups: Proxy groups to use
|
|
178
|
+
password: Proxy password (defaults to environment)
|
|
179
|
+
country_code: Country code for proxy location (two-letter ISO code)
|
|
180
|
+
subdivision_code: ISO 3166-2 subdivision code (e.g., 'CA' for
|
|
181
|
+
California with country_code='US'); requires country_code
|
|
182
|
+
proxy_urls: Custom proxy URLs to rotate through; may contain the
|
|
183
|
+
'auto' / 'http://auto' sentinel to mix in the platform proxy
|
|
184
|
+
new_url_function: Function that returns a new proxy URL; when it
|
|
185
|
+
accepts a positional argument it receives the session ID
|
|
186
|
+
(Apify parity), zero-argument callables are also supported
|
|
187
|
+
tiered_proxy_urls: Proxy URL lists stratified into tiers; lists may
|
|
188
|
+
contain the 'auto' sentinel (and None for no-proxy tiers)
|
|
189
|
+
tiered_proxy_config: Declarative tiers (proxy_urls / groups /
|
|
190
|
+
country_code / password per tier); tiers without proxy_urls
|
|
191
|
+
become lazy platform slots resolved per new_url() call with
|
|
192
|
+
the tier's options and the session ID (Apify parity)
|
|
193
|
+
actor_proxy_input: Proxy configuration from Actor input
|
|
194
|
+
_actor_config: Internal - Actor configuration
|
|
195
|
+
_api_client: Internal - API client
|
|
196
|
+
"""
|
|
197
|
+
self._groups = groups or []
|
|
198
|
+
self._password = password
|
|
199
|
+
self._country_code = country_code
|
|
200
|
+
self._subdivision_code = subdivision_code
|
|
201
|
+
self._proxy_urls = proxy_urls or []
|
|
202
|
+
self._new_url_function = new_url_function
|
|
203
|
+
self._actor_config = _actor_config
|
|
204
|
+
self._api_client = _api_client
|
|
205
|
+
self._initialized = False
|
|
206
|
+
self._is_man_in_the_middle = False
|
|
207
|
+
|
|
208
|
+
# Rotation / stickiness state (sessions are keyed by (session_id, tier)
|
|
209
|
+
# so a pinned session never leaks across proxy tiers — Crawlee parity).
|
|
210
|
+
self._url_index = 0
|
|
211
|
+
self._session_urls: Dict[Tuple[str, int], str] = {}
|
|
212
|
+
self._current_tier = 0
|
|
213
|
+
|
|
214
|
+
# If actor_proxy_input is provided, extract settings from it
|
|
215
|
+
if actor_proxy_input:
|
|
216
|
+
if actor_proxy_input.get('useApifyProxy', False):
|
|
217
|
+
self._groups = actor_proxy_input.get('apifyProxyGroups', []) or []
|
|
218
|
+
self._country_code = actor_proxy_input.get('apifyProxyCountry')
|
|
219
|
+
self._subdivision_code = actor_proxy_input.get('apifyProxySubdivision')
|
|
220
|
+
else:
|
|
221
|
+
self._proxy_urls = actor_proxy_input.get('proxyUrls', []) or []
|
|
222
|
+
# Tiered lists may also arrive through actor input (future-proof
|
|
223
|
+
# for a tiered console UI / hand-written input JSON); explicit
|
|
224
|
+
# constructor arguments win over the input dict.
|
|
225
|
+
tiered_proxy_urls = tiered_proxy_urls or actor_proxy_input.get('tieredProxyUrls')
|
|
226
|
+
raw_tiered_config = actor_proxy_input.get('tieredProxyConfig')
|
|
227
|
+
if raw_tiered_config and not tiered_proxy_config:
|
|
228
|
+
# Actor input is Apify-shaped (camelCase); normalize tier
|
|
229
|
+
# keys to the snake_case the expansion logic reads.
|
|
230
|
+
tiered_proxy_config = [
|
|
231
|
+
{
|
|
232
|
+
'proxy_urls': t.get('proxyUrls', t.get('proxy_urls')),
|
|
233
|
+
'groups': t.get('groups'),
|
|
234
|
+
'country_code': t.get('countryCode', t.get('country_code')),
|
|
235
|
+
'subdivision_code': t.get('subdivisionCode', t.get('subdivision_code')),
|
|
236
|
+
'password': t.get('password'),
|
|
237
|
+
'new_url_function': t.get('newUrlFunction', t.get('new_url_function')),
|
|
238
|
+
}
|
|
239
|
+
for t in raw_tiered_config
|
|
240
|
+
]
|
|
241
|
+
|
|
242
|
+
# Validation (Apify parity): bad country/subdivision values throw at
|
|
243
|
+
# construction time, not on the first new_url() call.
|
|
244
|
+
_validate_country_options(self._country_code, self._subdivision_code)
|
|
245
|
+
|
|
246
|
+
# Declarative tiers expand to tiered URL lists. Sentinel tiers keep a
|
|
247
|
+
# lazy platform slot resolved with the tier's options; tiers without
|
|
248
|
+
# proxy_urls ALSO keep a lazy 'auto' slot so new_url(session_id) builds
|
|
249
|
+
# the platform URL with the tier's own options AND the session ID —
|
|
250
|
+
# building it eagerly here would freeze it without a session fragment
|
|
251
|
+
# and lose session stickiness (parity with the JS SDK's lazy fix).
|
|
252
|
+
self._tier_options: Dict[int, Dict[str, Any]] = {}
|
|
253
|
+
if tiered_proxy_config:
|
|
254
|
+
expanded: List[List[Optional[str]]] = []
|
|
255
|
+
env = _platform_env()
|
|
256
|
+
for tier in tiered_proxy_config:
|
|
257
|
+
if tier.get('new_url_function'):
|
|
258
|
+
raise ValueError(
|
|
259
|
+
'tiered_proxy_config tiers with new_url_function are not supported. '
|
|
260
|
+
'Use tiered_proxy_urls instead.'
|
|
261
|
+
)
|
|
262
|
+
tier_urls = tier.get('proxy_urls')
|
|
263
|
+
if tier_urls:
|
|
264
|
+
if any(is_platform_sentinel(u) for u in tier_urls):
|
|
265
|
+
if not env:
|
|
266
|
+
raise ValueError(_platform_missing_error())
|
|
267
|
+
self._tier_options[len(expanded)] = dict(tier)
|
|
268
|
+
expanded.append(list(tier_urls))
|
|
269
|
+
else:
|
|
270
|
+
if not env:
|
|
271
|
+
raise ValueError(_platform_missing_error())
|
|
272
|
+
self._tier_options[len(expanded)] = dict(tier)
|
|
273
|
+
expanded.append(['auto'])
|
|
274
|
+
self._tiered_proxy_urls = expanded
|
|
275
|
+
else:
|
|
276
|
+
self._tiered_proxy_urls = tiered_proxy_urls or []
|
|
277
|
+
|
|
278
|
+
self._tier_url_indexes = [0] * len(self._tiered_proxy_urls)
|
|
279
|
+
|
|
280
|
+
# Validation (Apify parity): real (non-sentinel) custom proxies may not
|
|
281
|
+
# combine with platform-only options. Sentinel-only lists are platform
|
|
282
|
+
# slots and combine legally with groups/country_code.
|
|
283
|
+
has_real_custom = (
|
|
284
|
+
any(u is not None and not is_platform_sentinel(u) for u in self._proxy_urls)
|
|
285
|
+
or any(
|
|
286
|
+
u is not None and not is_platform_sentinel(u)
|
|
287
|
+
for tier in self._tiered_proxy_urls
|
|
288
|
+
for u in tier
|
|
289
|
+
)
|
|
290
|
+
or new_url_function is not None
|
|
291
|
+
)
|
|
292
|
+
if has_real_custom and (self._groups or self._country_code or self._subdivision_code or password):
|
|
293
|
+
raise ValueError(
|
|
294
|
+
'Cannot combine custom proxies with Apify Proxy! It is not allowed to set '
|
|
295
|
+
'"proxy_urls" or "new_url_function" combined with "groups", "country_code" '
|
|
296
|
+
'or "subdivision_code".'
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
needs_platform = (
|
|
300
|
+
not self._proxy_urls
|
|
301
|
+
and not self._tiered_proxy_urls
|
|
302
|
+
and new_url_function is None
|
|
303
|
+
) or (
|
|
304
|
+
any(is_platform_sentinel(u) for u in self._proxy_urls)
|
|
305
|
+
or any(
|
|
306
|
+
is_platform_sentinel(u)
|
|
307
|
+
for tier in self._tiered_proxy_urls
|
|
308
|
+
for u in tier
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
self._needs_platform = needs_platform
|
|
312
|
+
if needs_platform and not _platform_env():
|
|
313
|
+
raise ValueError(_platform_missing_error())
|
|
314
|
+
|
|
315
|
+
@classmethod
|
|
316
|
+
async def create(
|
|
317
|
+
cls,
|
|
318
|
+
groups: Optional[List[str]] = None,
|
|
319
|
+
password: Optional[str] = None,
|
|
320
|
+
country_code: Optional[str] = None,
|
|
321
|
+
subdivision_code: Optional[str] = None,
|
|
322
|
+
proxy_urls: Optional[List[str]] = None,
|
|
323
|
+
new_url_function: Optional[Callable[..., Any]] = None,
|
|
324
|
+
tiered_proxy_urls: Optional[List[List[Optional[str]]]] = None,
|
|
325
|
+
tiered_proxy_config: Optional[List[Dict[str, Any]]] = None,
|
|
326
|
+
actor_proxy_input: Optional[Dict] = None,
|
|
327
|
+
) -> Optional['ProxyConfiguration']:
|
|
328
|
+
"""Create a ProxyConfiguration instance.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
groups: Proxy groups to use
|
|
332
|
+
password: Proxy password
|
|
333
|
+
country_code: Country code for proxy location
|
|
334
|
+
subdivision_code: Subdivision code
|
|
335
|
+
proxy_urls: Custom proxy URLs (may contain the 'auto' sentinel)
|
|
336
|
+
new_url_function: Function returning new proxy URL; when it
|
|
337
|
+
accepts a positional argument it receives the session ID
|
|
338
|
+
(Apify parity)
|
|
339
|
+
tiered_proxy_urls: Proxy URL lists stratified into tiers
|
|
340
|
+
tiered_proxy_config: Declarative tiers (proxy_urls / groups / ...)
|
|
341
|
+
actor_proxy_input: Proxy configuration from Actor input
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
ProxyConfiguration instance or None if no proxy should be used
|
|
345
|
+
"""
|
|
346
|
+
# Platform proxy password: SCRAPELY_* env (injected by the Scrapely
|
|
347
|
+
# server, Evomi-backed) takes precedence over the APIFY_* alias.
|
|
348
|
+
if password is None:
|
|
349
|
+
password = (
|
|
350
|
+
os.environ.get('SCRAPELY_PROXY_PASSWORD')
|
|
351
|
+
or os.environ.get('APIFY_PROXY_PASSWORD')
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
# Check if proxy should be used
|
|
355
|
+
if (
|
|
356
|
+
not groups and not proxy_urls and not new_url_function and not actor_proxy_input
|
|
357
|
+
and not tiered_proxy_urls and not tiered_proxy_config
|
|
358
|
+
):
|
|
359
|
+
# Nothing explicitly configured — fall back to the platform proxy
|
|
360
|
+
# only when the server injected credentials into the environment.
|
|
361
|
+
if not password:
|
|
362
|
+
# JS SDK parity: warn (like createProxyConfiguration's
|
|
363
|
+
# BYOP_WARNING) instead of returning None silently, so local
|
|
364
|
+
# runs make it obvious why no proxy is being used.
|
|
365
|
+
logger.warning(
|
|
366
|
+
'The Scrapely platform proxy is only available in on-platform runs when your '
|
|
367
|
+
'account has proxy access. Locally, either add proxy_urls to your actor input, '
|
|
368
|
+
'or copy your proxy credentials from the Scrapely console (Proxy page) into the '
|
|
369
|
+
'SCRAPELY_PROXY_HOSTNAME, SCRAPELY_PROXY_PORT, SCRAPELY_PROXY_USERNAME and '
|
|
370
|
+
'SCRAPELY_PROXY_PASSWORD environment variables. Using the platform proxy from '
|
|
371
|
+
'outside Scrapely requires an active plan or a prepaid (PAYG) balance of at '
|
|
372
|
+
'least $1; free accounts get run-scoped credentials injected into their '
|
|
373
|
+
'on-platform runs only. No proxy configuration was created.'
|
|
374
|
+
)
|
|
375
|
+
return None
|
|
376
|
+
|
|
377
|
+
return cls(
|
|
378
|
+
groups=groups,
|
|
379
|
+
password=password,
|
|
380
|
+
country_code=country_code,
|
|
381
|
+
subdivision_code=subdivision_code,
|
|
382
|
+
proxy_urls=proxy_urls,
|
|
383
|
+
new_url_function=new_url_function,
|
|
384
|
+
tiered_proxy_urls=tiered_proxy_urls,
|
|
385
|
+
tiered_proxy_config=tiered_proxy_config,
|
|
386
|
+
actor_proxy_input=actor_proxy_input,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
def _build_platform_url(
|
|
390
|
+
self,
|
|
391
|
+
session_id: Optional[str] = None,
|
|
392
|
+
groups: Optional[List[str]] = None,
|
|
393
|
+
country_code: Optional[str] = None,
|
|
394
|
+
subdivision_code: Optional[str] = None,
|
|
395
|
+
password: Optional[str] = None,
|
|
396
|
+
) -> str:
|
|
397
|
+
"""Build a platform proxy URL with the Apify username grammar.
|
|
398
|
+
|
|
399
|
+
http://[SUB,]groups-G1+G2,session-xyz,country-US_CA:PASSWORD@HOST:PORT
|
|
400
|
+
|
|
401
|
+
The subdivision merges into the country fragment (Apify grammar,
|
|
402
|
+
ISO 3166-2: ``country-US_CA``). The optional leading fragment is the
|
|
403
|
+
subaccount username injected by the server
|
|
404
|
+
(SCRAPELY_PROXY_USERNAME/APIFY_PROXY_USERNAME) so the gateway
|
|
405
|
+
attributes traffic to the right Scrapely tenant; without it,
|
|
406
|
+
the username falls back to Apify's 'auto' when no fragments apply.
|
|
407
|
+
"""
|
|
408
|
+
from urllib.parse import quote
|
|
409
|
+
|
|
410
|
+
env = _platform_env()
|
|
411
|
+
if not env:
|
|
412
|
+
raise ValueError(_platform_missing_error())
|
|
413
|
+
|
|
414
|
+
groups = self._groups if groups is None else groups
|
|
415
|
+
country_code = self._country_code if country_code is None else country_code
|
|
416
|
+
subdivision_code = self._subdivision_code if subdivision_code is None else subdivision_code
|
|
417
|
+
password = self._password if password is None else password
|
|
418
|
+
password = password or env['password']
|
|
419
|
+
|
|
420
|
+
_validate_country_options(country_code, subdivision_code)
|
|
421
|
+
|
|
422
|
+
# Apify grammar order: groups, then session, then country (with the
|
|
423
|
+
# subdivision merged in as `country-XX_YY`).
|
|
424
|
+
username_parts = []
|
|
425
|
+
if groups:
|
|
426
|
+
username_parts.append(f"groups-{'+'.join(_sanitize_fragment(g) for g in groups)}")
|
|
427
|
+
if session_id is not None:
|
|
428
|
+
username_parts.append(f'session-{_sanitize_fragment(str(session_id))}')
|
|
429
|
+
if country_code and subdivision_code:
|
|
430
|
+
username_parts.append(
|
|
431
|
+
f'country-{_sanitize_fragment(country_code)}_{_sanitize_fragment(subdivision_code)}'
|
|
432
|
+
)
|
|
433
|
+
elif country_code:
|
|
434
|
+
username_parts.append(f'country-{_sanitize_fragment(country_code)}')
|
|
435
|
+
fragments = ','.join(username_parts)
|
|
436
|
+
|
|
437
|
+
if env['username']:
|
|
438
|
+
username = _sanitize_fragment(env['username']) + (f',{fragments}' if fragments else '')
|
|
439
|
+
else:
|
|
440
|
+
username = fragments or 'auto'
|
|
441
|
+
|
|
442
|
+
return f"http://{username}:{quote(password, safe='')}@{env['hostname']}:{env['port']}"
|
|
443
|
+
|
|
444
|
+
def _next_url(self, session_id: Optional[str] = None, proxy_tier: Optional[int] = None) -> str:
|
|
445
|
+
"""Pick the next URL (round-robin), honoring tiers and sentinels."""
|
|
446
|
+
if self._tiered_proxy_urls:
|
|
447
|
+
tier = proxy_tier if proxy_tier is not None else self._current_tier
|
|
448
|
+
if not 0 <= tier < len(self._tiered_proxy_urls):
|
|
449
|
+
raise ValueError(f'Proxy tier {tier} is out of range (0..{len(self._tiered_proxy_urls) - 1}).')
|
|
450
|
+
self._current_tier = tier
|
|
451
|
+
urls = [u for u in self._tiered_proxy_urls[tier] if u is not None]
|
|
452
|
+
if urls:
|
|
453
|
+
url = urls[self._tier_url_indexes[tier] % len(urls)]
|
|
454
|
+
self._tier_url_indexes[tier] += 1
|
|
455
|
+
if is_platform_sentinel(url):
|
|
456
|
+
# Sentinel tiers resolve with the tier's own options.
|
|
457
|
+
tier_options = self._tier_options.get(tier, {})
|
|
458
|
+
return self._build_platform_url(
|
|
459
|
+
session_id=session_id,
|
|
460
|
+
groups=tier_options.get('groups'),
|
|
461
|
+
country_code=tier_options.get('country_code'),
|
|
462
|
+
subdivision_code=tier_options.get('subdivision_code'),
|
|
463
|
+
password=tier_options.get('password'),
|
|
464
|
+
)
|
|
465
|
+
return url
|
|
466
|
+
# Tier explicitly has no URLs (all None) — fall through to platform.
|
|
467
|
+
|
|
468
|
+
if self._proxy_urls:
|
|
469
|
+
url = self._proxy_urls[self._url_index % len(self._proxy_urls)]
|
|
470
|
+
self._url_index += 1
|
|
471
|
+
if is_platform_sentinel(url):
|
|
472
|
+
return self._build_platform_url(session_id=session_id)
|
|
473
|
+
return url
|
|
474
|
+
|
|
475
|
+
return self._build_platform_url(session_id=session_id)
|
|
476
|
+
|
|
477
|
+
def _fetch_proxy_status(self) -> Optional[Dict[str, Any]]:
|
|
478
|
+
"""Fetch the gateway status payload (Apify format) through the gateway.
|
|
479
|
+
|
|
480
|
+
Sends ``GET {status_url}/?format=json`` *through the platform gateway``
|
|
481
|
+
so the proxy handshake itself authenticates the run's credentials.
|
|
482
|
+
Mirrors the JS SDK: 4s timeout, 2 attempts, returns ``None`` when the
|
|
483
|
+
endpoint stays unreachable. Never raises.
|
|
484
|
+
"""
|
|
485
|
+
import urllib.error
|
|
486
|
+
import urllib.request
|
|
487
|
+
|
|
488
|
+
# The gateway URL can only be built with complete platform
|
|
489
|
+
# credentials; without them the check is skipped, never fatal.
|
|
490
|
+
env = _platform_env()
|
|
491
|
+
if not env or not env['status_url']:
|
|
492
|
+
return None
|
|
493
|
+
status_url = env['status_url']
|
|
494
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
495
|
+
|
|
496
|
+
parts = urlsplit(status_url)
|
|
497
|
+
query = dict(parse_qsl(parts.query))
|
|
498
|
+
query['format'] = 'json'
|
|
499
|
+
url = urlunsplit(parts._replace(path=parts.path or '/', query=urlencode(query)))
|
|
500
|
+
try:
|
|
501
|
+
proxy_url = self._build_platform_url()
|
|
502
|
+
except ValueError:
|
|
503
|
+
return None
|
|
504
|
+
|
|
505
|
+
for _attempt in range(CHECK_ACCESS_MAX_ATTEMPTS):
|
|
506
|
+
try:
|
|
507
|
+
opener = urllib.request.build_opener(
|
|
508
|
+
urllib.request.ProxyHandler({'http': proxy_url, 'https': proxy_url})
|
|
509
|
+
)
|
|
510
|
+
req = urllib.request.Request(url, headers={'Accept': 'application/json'})
|
|
511
|
+
with opener.open(req, timeout=CHECK_ACCESS_REQUEST_TIMEOUT_MILLIS / 1000) as resp:
|
|
512
|
+
payload = json.loads(resp.read().decode('utf-8', 'replace'))
|
|
513
|
+
return {
|
|
514
|
+
'connected': payload.get('connected') is True,
|
|
515
|
+
'connectionError': payload.get('connectionError') or '',
|
|
516
|
+
'isManInTheMiddle': payload.get('isManInTheMiddle') is True,
|
|
517
|
+
}
|
|
518
|
+
except urllib.error.HTTPError as e:
|
|
519
|
+
if e.code == 407:
|
|
520
|
+
return {
|
|
521
|
+
'connected': False,
|
|
522
|
+
'connectionError': (
|
|
523
|
+
'Platform proxy authentication failed (HTTP 407). Check the injected '
|
|
524
|
+
'SCRAPELY_PROXY_*/APIFY_PROXY_* credentials.'
|
|
525
|
+
),
|
|
526
|
+
'isManInTheMiddle': False,
|
|
527
|
+
}
|
|
528
|
+
if 590 <= e.code <= 599:
|
|
529
|
+
description = PROXY_STATUS_CODE_DESCRIPTIONS.get(e.code, 'upstream error')
|
|
530
|
+
return {
|
|
531
|
+
'connected': False,
|
|
532
|
+
'connectionError': f'Platform proxy upstream error (HTTP {e.code}: {description}).',
|
|
533
|
+
'isManInTheMiddle': False,
|
|
534
|
+
}
|
|
535
|
+
# Other HTTP errors behave like connection errors: retry.
|
|
536
|
+
except Exception: # noqa: BLE001 - retry connection errors (Apify behavior)
|
|
537
|
+
pass
|
|
538
|
+
return None
|
|
539
|
+
|
|
540
|
+
async def _call_new_url_function(self, session_id: Optional[str] = None) -> str:
|
|
541
|
+
"""Invoke the user's ``new_url_function`` (Apify parity).
|
|
542
|
+
|
|
543
|
+
Functions accepting a positional argument receive the session ID
|
|
544
|
+
(upstream Apify passes ``session_id``); zero-argument callables keep
|
|
545
|
+
working. Sync and async functions are both supported: an awaitable
|
|
546
|
+
result is awaited (upstream Apify behavior).
|
|
547
|
+
"""
|
|
548
|
+
import inspect as _inspect
|
|
549
|
+
|
|
550
|
+
fn = self._new_url_function
|
|
551
|
+
if fn is None:
|
|
552
|
+
raise ValueError('No new_url_function configured')
|
|
553
|
+
try:
|
|
554
|
+
signature = _inspect.signature(fn)
|
|
555
|
+
accepts_arg = bool(signature.parameters) and any(
|
|
556
|
+
p.kind
|
|
557
|
+
in (_inspect.Parameter.POSITIONAL_ONLY, _inspect.Parameter.POSITIONAL_OR_KEYWORD, _inspect.Parameter.VAR_POSITIONAL)
|
|
558
|
+
for p in signature.parameters.values()
|
|
559
|
+
)
|
|
560
|
+
except (TypeError, ValueError):
|
|
561
|
+
accepts_arg = False
|
|
562
|
+
|
|
563
|
+
result = fn(session_id) if accepts_arg else fn()
|
|
564
|
+
if _inspect.isawaitable(result):
|
|
565
|
+
result = await result
|
|
566
|
+
return cast(str, result)
|
|
567
|
+
|
|
568
|
+
async def new_url(
|
|
569
|
+
self,
|
|
570
|
+
session_id: Optional[str] = None,
|
|
571
|
+
request: Optional[Any] = None, # Request object from crawlee (unused, Apify API compatibility)
|
|
572
|
+
proxy_tier: Optional[int] = None,
|
|
573
|
+
) -> str:
|
|
574
|
+
"""Get a new proxy URL.
|
|
575
|
+
|
|
576
|
+
Async like upstream Apify's ``ProxyConfiguration.new_url()``, so actors
|
|
577
|
+
written for Apify (``await proxy_config.new_url(...)``) work unchanged.
|
|
578
|
+
|
|
579
|
+
Args:
|
|
580
|
+
session_id: Optional session ID for sticky sessions (validated like
|
|
581
|
+
the Apify SDKs: max 50 chars, only 0-9 a-z A-Z . _ ~). With
|
|
582
|
+
rotating proxy_urls, the same session ID always maps to the
|
|
583
|
+
same URL. When a ``new_url_function`` accepting an argument is
|
|
584
|
+
configured, the session ID is passed to it (Apify parity).
|
|
585
|
+
request: Request object from crawlee (unused, for API compatibility).
|
|
586
|
+
proxy_tier: Optional tier to select when tiered proxies are used.
|
|
587
|
+
|
|
588
|
+
Returns:
|
|
589
|
+
Proxy URL string
|
|
590
|
+
"""
|
|
591
|
+
if self._new_url_function:
|
|
592
|
+
return await self._call_new_url_function(session_id)
|
|
593
|
+
return self._new_url_sync(session_id, proxy_tier)
|
|
594
|
+
|
|
595
|
+
def _new_url_sync(self, session_id: Optional[str] = None, proxy_tier: Optional[int] = None) -> str:
|
|
596
|
+
"""Resolve a proxy URL from the configured proxy_urls / platform gateway."""
|
|
597
|
+
if session_id is not None:
|
|
598
|
+
_validate_session_id(str(session_id))
|
|
599
|
+
|
|
600
|
+
# Session stickiness is scoped per tier (parity with Crawlee's tiered
|
|
601
|
+
# handling): the same session ID on a different tier resolves to that
|
|
602
|
+
# tier's own URL instead of leaking the pinned URL across tiers. The
|
|
603
|
+
# effective tier mirrors _next_url()'s selection semantics.
|
|
604
|
+
effective_tier = proxy_tier if proxy_tier is not None else self._current_tier
|
|
605
|
+
cache_key: Tuple[str, int] = (str(session_id), effective_tier)
|
|
606
|
+
|
|
607
|
+
if session_id is not None and cache_key in self._session_urls:
|
|
608
|
+
return self._session_urls[cache_key]
|
|
609
|
+
|
|
610
|
+
url = self._next_url(session_id=session_id, proxy_tier=proxy_tier)
|
|
611
|
+
if session_id is not None:
|
|
612
|
+
self._session_urls[cache_key] = url
|
|
613
|
+
return url
|
|
614
|
+
|
|
615
|
+
async def initialize(self, check_access: bool = True) -> None:
|
|
616
|
+
"""Initialize the proxy configuration.
|
|
617
|
+
|
|
618
|
+
This method is called automatically by Actor.create_proxy_configuration().
|
|
619
|
+
It validates the configuration and fetches any necessary data from the API.
|
|
620
|
+
|
|
621
|
+
Args:
|
|
622
|
+
check_access: Whether to run the best-effort platform status check.
|
|
623
|
+
|
|
624
|
+
Raises:
|
|
625
|
+
ValueError: If proxy configuration is invalid
|
|
626
|
+
"""
|
|
627
|
+
if self._initialized:
|
|
628
|
+
return
|
|
629
|
+
|
|
630
|
+
# Get password from environment if not provided
|
|
631
|
+
# (SCRAPELY_* injected by the Scrapely server takes precedence)
|
|
632
|
+
if self._password is None:
|
|
633
|
+
self._password = (
|
|
634
|
+
os.environ.get('SCRAPELY_PROXY_PASSWORD')
|
|
635
|
+
or os.environ.get('APIFY_PROXY_PASSWORD')
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
# Validate configuration
|
|
639
|
+
if self._proxy_urls and self._new_url_function:
|
|
640
|
+
raise ValueError('Cannot specify both proxy_urls and new_url_function')
|
|
641
|
+
|
|
642
|
+
# Apify-parity access check (best-effort, never fatal): GET
|
|
643
|
+
# {status_url}/?format=json *through the platform gateway* so the proxy
|
|
644
|
+
# handshake itself authenticates the run's credentials. Parity with the
|
|
645
|
+
# Apify SDKs (usesApifyProxy / uses_apify_proxy) and the JS Scrapely
|
|
646
|
+
# port (platformEnv): the check only applies when this configuration
|
|
647
|
+
# actually uses the platform proxy — BYOP-only configurations skip it
|
|
648
|
+
# even when platform credentials happen to be injected.
|
|
649
|
+
if check_access and self._needs_platform and (
|
|
650
|
+
os.environ.get('SCRAPELY_PROXY_STATUS_URL')
|
|
651
|
+
or os.environ.get('APIFY_PROXY_STATUS_URL')
|
|
652
|
+
):
|
|
653
|
+
# Run the (blocking urllib) status check off the event loop — worst
|
|
654
|
+
# case it blocks a worker thread for ~8s (2 attempts x 4s timeout),
|
|
655
|
+
# never the actor's loop (JS SDK parity: its check is fully async).
|
|
656
|
+
status = await asyncio.to_thread(self._fetch_proxy_status)
|
|
657
|
+
if status is None:
|
|
658
|
+
logger.warning(
|
|
659
|
+
'Platform proxy access check timed out. Watch out for errors with status '
|
|
660
|
+
'code 407. If you see some, it most likely means you do not have access to '
|
|
661
|
+
'either all or some of the proxies you are trying to use.'
|
|
662
|
+
)
|
|
663
|
+
else:
|
|
664
|
+
self._is_man_in_the_middle = status['isManInTheMiddle']
|
|
665
|
+
if not status['connected']:
|
|
666
|
+
logger.warning('Platform proxy access check failed: %s', status['connectionError'])
|
|
667
|
+
else:
|
|
668
|
+
logger.info('Platform proxy access check succeeded.')
|
|
669
|
+
|
|
670
|
+
self._initialized = True
|
|
671
|
+
logger.debug('ProxyConfiguration initialized')
|
|
672
|
+
|
|
673
|
+
async def new_proxy_info(
|
|
674
|
+
self,
|
|
675
|
+
session_id: Optional[str] = None,
|
|
676
|
+
request: Optional[Any] = None, # Request object from crawlee (unused but required for API compatibility)
|
|
677
|
+
proxy_tier: Optional[int] = None, # Proxy tier, honored when tiered proxies are configured
|
|
678
|
+
) -> Optional[ProxyInfo]:
|
|
679
|
+
"""Get proxy information including URL and credentials.
|
|
680
|
+
|
|
681
|
+
This method matches crawlee's ProxyConfiguration.new_proxy_info() signature
|
|
682
|
+
to support crawlee's _basic_crawler.py which calls:
|
|
683
|
+
proxy_configuration.new_proxy_info(request=..., session=...)
|
|
684
|
+
|
|
685
|
+
Args:
|
|
686
|
+
session_id: Optional session ID for sticky sessions
|
|
687
|
+
request: Request object from crawlee (unused, for API compatibility)
|
|
688
|
+
proxy_tier: Proxy tier (honored when tiered proxies are configured)
|
|
689
|
+
|
|
690
|
+
Returns:
|
|
691
|
+
ProxyInfo object with connection details, or None if no proxy configured
|
|
692
|
+
"""
|
|
693
|
+
url = await self.new_url(session_id, proxy_tier=proxy_tier)
|
|
694
|
+
|
|
695
|
+
# Derive connection details from the generated URL (covers both custom
|
|
696
|
+
# proxy URLs and the platform gateway), falling back to the injected
|
|
697
|
+
# SCRAPELY_PROXY_*/APIFY_PROXY_* environment values.
|
|
698
|
+
from urllib.parse import urlsplit
|
|
699
|
+
|
|
700
|
+
parsed = urlsplit(url)
|
|
701
|
+
env = _platform_env() or {}
|
|
702
|
+
|
|
703
|
+
hostname = parsed.hostname or env.get('hostname', '')
|
|
704
|
+
port = parsed.port or env.get('port', 8000)
|
|
705
|
+
password = parsed.password or self._password or env.get('password', '')
|
|
706
|
+
if parsed.username:
|
|
707
|
+
username = parsed.username
|
|
708
|
+
elif self._groups:
|
|
709
|
+
username = f"groups-{'+'.join(self._groups)}"
|
|
710
|
+
else:
|
|
711
|
+
username = env.get('username') or 'auto'
|
|
712
|
+
|
|
713
|
+
# Extract scheme from URL
|
|
714
|
+
scheme = 'http'
|
|
715
|
+
if url.startswith('https://'):
|
|
716
|
+
scheme = 'https'
|
|
717
|
+
elif url.startswith('socks5://'):
|
|
718
|
+
scheme = 'socks5'
|
|
719
|
+
elif url.startswith('socks4://'):
|
|
720
|
+
scheme = 'socks4'
|
|
721
|
+
|
|
722
|
+
# Attach groups/country_code/subdivision_code only when the URL is the
|
|
723
|
+
# platform gateway.
|
|
724
|
+
is_platform = bool(hostname and env and hostname == env.get('hostname'))
|
|
725
|
+
groups = self._groups if is_platform else []
|
|
726
|
+
country_code = self._country_code if is_platform else None
|
|
727
|
+
subdivision_code = self._subdivision_code if is_platform else None
|
|
728
|
+
|
|
729
|
+
return ProxyInfo(
|
|
730
|
+
url=url,
|
|
731
|
+
scheme=scheme,
|
|
732
|
+
hostname=hostname,
|
|
733
|
+
port=port,
|
|
734
|
+
username=username,
|
|
735
|
+
password=password,
|
|
736
|
+
session_id=session_id,
|
|
737
|
+
proxy_tier=proxy_tier,
|
|
738
|
+
groups=groups,
|
|
739
|
+
country_code=country_code,
|
|
740
|
+
subdivision_code=subdivision_code,
|
|
741
|
+
)
|