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