scrapely-python 0.1.38__py3-none-any.whl → 0.1.40__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 +1 -1
- scrapely/charging.py +2 -1
- scrapely/events/event_manager.py +8 -6
- scrapely/http/_client.py +97 -14
- scrapely/http/_headers.py +72 -0
- scrapely/http/_options.py +4 -4
- scrapely/http/_policy.py +1 -0
- scrapely/http/_profiles.py +5 -0
- scrapely/http/profiles.json +397 -46
- scrapely/proxy_configuration.py +31 -2
- scrapely/storage/api_client.py +29 -5
- scrapely/storage/client.py +6 -0
- {scrapely_python-0.1.38.dist-info → scrapely_python-0.1.40.dist-info}/METADATA +1 -1
- {scrapely_python-0.1.38.dist-info → scrapely_python-0.1.40.dist-info}/RECORD +19 -19
- {scrapely_python-0.1.38.dist-info → scrapely_python-0.1.40.dist-info}/WHEEL +0 -0
- {scrapely_python-0.1.38.dist-info → scrapely_python-0.1.40.dist-info}/licenses/LICENSE +0 -0
- {scrapely_python-0.1.38.dist-info → scrapely_python-0.1.40.dist-info}/licenses/LICENSE-APACHE +0 -0
- {scrapely_python-0.1.38.dist-info → scrapely_python-0.1.40.dist-info}/licenses/NOTICE +0 -0
- {scrapely_python-0.1.38.dist-info → scrapely_python-0.1.40.dist-info}/top_level.txt +0 -0
scrapely/__init__.py
CHANGED
scrapely/charging.py
CHANGED
|
@@ -308,7 +308,8 @@ class ChargingManager:
|
|
|
308
308
|
|
|
309
309
|
def _remaining_budget_usd(self) -> Decimal:
|
|
310
310
|
"""maxTotalChargeUsd minus this SDK's charges and the platform's other run spend."""
|
|
311
|
-
|
|
311
|
+
if self._max_total_charge_usd is None:
|
|
312
|
+
raise RuntimeError('maxTotalChargeUsd is not set')
|
|
312
313
|
return self._max_total_charge_usd - self._total_charged_usd - self._platform_other_usd
|
|
313
314
|
|
|
314
315
|
def _sync_platform_budget(self) -> None:
|
scrapely/events/event_manager.py
CHANGED
|
@@ -122,13 +122,15 @@ class ScrapelyEventManager(CrawleeEventManager):
|
|
|
122
122
|
# Run tasks but don't await them
|
|
123
123
|
ws_url = self._configuration.get('actor_events_ws_url')
|
|
124
124
|
if ws_url:
|
|
125
|
-
#
|
|
126
|
-
# This matches the Node SDK behavior in events-client.ts
|
|
125
|
+
# Authenticate with the token in a header and the runId in the URL (URLs end up in access
|
|
126
|
+
# logs). This matches the Node SDK behavior in events-client.ts
|
|
127
127
|
token = self._configuration.get('token')
|
|
128
128
|
run_id = self._configuration.get('actor_run_id')
|
|
129
|
+
headers: dict[str, str] = {}
|
|
129
130
|
if token and run_id:
|
|
130
131
|
separator = '&' if '?' in ws_url else '?'
|
|
131
|
-
ws_url = f"{ws_url}{separator}{urlencode({'
|
|
132
|
+
ws_url = f"{ws_url}{separator}{urlencode({'runId': run_id})}"
|
|
133
|
+
headers['Authorization'] = f'Bearer {token}'
|
|
132
134
|
logger.debug(f'Connecting to platform events websocket with runId={run_id}')
|
|
133
135
|
else:
|
|
134
136
|
logger.warning(
|
|
@@ -139,7 +141,7 @@ class ScrapelyEventManager(CrawleeEventManager):
|
|
|
139
141
|
# Connect in the background without blocking init, matching the Node SDK: a slow or failed
|
|
140
142
|
# connection is retried and must not crash the Actor.
|
|
141
143
|
self._process_platform_messages_task = asyncio.create_task(
|
|
142
|
-
self._process_platform_messages(ws_url)
|
|
144
|
+
self._process_platform_messages(ws_url, headers)
|
|
143
145
|
)
|
|
144
146
|
else:
|
|
145
147
|
logger.debug(
|
|
@@ -170,7 +172,7 @@ class ScrapelyEventManager(CrawleeEventManager):
|
|
|
170
172
|
|
|
171
173
|
await super().__aexit__(exc_type, exc_value, exc_traceback)
|
|
172
174
|
|
|
173
|
-
async def _process_platform_messages(self, ws_url: str) -> None:
|
|
175
|
+
async def _process_platform_messages(self, ws_url: str, headers: Optional[dict[str, str]] = None) -> None:
|
|
174
176
|
"""Process messages from the platform WebSocket with reconnect logic."""
|
|
175
177
|
try:
|
|
176
178
|
import websockets.asyncio.client
|
|
@@ -189,7 +191,7 @@ class ScrapelyEventManager(CrawleeEventManager):
|
|
|
189
191
|
try:
|
|
190
192
|
# Used as an async iterator, `connect` reconnects with exponential backoff on failed connection attempts,
|
|
191
193
|
# including the first one (timeouts, network errors and 5xx responses are retried).
|
|
192
|
-
async for websocket in websockets.asyncio.client.connect(ws_url):
|
|
194
|
+
async for websocket in websockets.asyncio.client.connect(ws_url, additional_headers=headers or None):
|
|
193
195
|
self._platform_events_websocket = websocket
|
|
194
196
|
if self._connected_to_platform_websocket and not self._connected_to_platform_websocket.done():
|
|
195
197
|
self._connected_to_platform_websocket.set_result(True)
|
scrapely/http/_client.py
CHANGED
|
@@ -11,7 +11,7 @@ from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Tuple
|
|
|
11
11
|
from urllib.parse import urlencode, urlsplit, urlunsplit
|
|
12
12
|
|
|
13
13
|
from ._detect import detect_block, parse_retry_after
|
|
14
|
-
from ._headers import accept_language_for, encode_body, prepare
|
|
14
|
+
from ._headers import accept_language_for, country_for_locale, encode_body, pick_accept_language, pick_country, prepare
|
|
15
15
|
from ._options import FingerprintOptions
|
|
16
16
|
from ._policy import BackoffPolicy, HostState, Identity, RetryPolicy, country_from_proxy, decide, new_session_id
|
|
17
17
|
from ._profiles import BrowserProfile, impersonate_to_options, load_table, pick, select_candidates
|
|
@@ -58,6 +58,29 @@ def _with_params(url: str, params: Any) -> str:
|
|
|
58
58
|
return urlunsplit((parts.scheme, parts.netloc, parts.path, joined, parts.fragment))
|
|
59
59
|
|
|
60
60
|
|
|
61
|
+
def _normalize_url(url: str) -> str:
|
|
62
|
+
"""``https://host?q`` → ``https://host/?q``: an empty path is sent as ``/``, as browsers do.
|
|
63
|
+
|
|
64
|
+
wreq 0.13 sends ``:path: ?q`` over HTTP/2 for such URLs, and servers
|
|
65
|
+
(Cloudflare, nginx) answer 400 Bad Request.
|
|
66
|
+
"""
|
|
67
|
+
parts = urlsplit(url)
|
|
68
|
+
if parts.scheme.lower() in ('http', 'https') and parts.netloc and not parts.path:
|
|
69
|
+
return urlunsplit((parts.scheme, parts.netloc, '/', parts.query, parts.fragment))
|
|
70
|
+
return url
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _can_pick_country(proxy_configuration: Any) -> bool:
|
|
74
|
+
"""True for a platform proxy that lets each identity choose its exit country."""
|
|
75
|
+
check = getattr(proxy_configuration, '_can_pick_country', None)
|
|
76
|
+
if not callable(check):
|
|
77
|
+
return False
|
|
78
|
+
try:
|
|
79
|
+
return bool(check())
|
|
80
|
+
except Exception: # noqa: BLE001 - a foreign proxy configuration: keep its own behaviour
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
61
84
|
def _rotations(max_rotations: Optional[int], max_retries: Optional[int], default: Optional[int]) -> Optional[int]:
|
|
62
85
|
"""``max_rotations`` wins; ``max_retries`` is its older name."""
|
|
63
86
|
if max_rotations is not None:
|
|
@@ -198,7 +221,12 @@ class _Base:
|
|
|
198
221
|
# Only a proxy configuration gives each identity its own exit IP.
|
|
199
222
|
self.rotates_ip = proxy_configuration is not None
|
|
200
223
|
country = country_from_proxy(proxy_configuration if proxy_configuration is not None else self.fixed_proxy)
|
|
224
|
+
self.proxy_country = country
|
|
225
|
+
# Default Accept-Language of this client (identities draw their own, see _identity_geo).
|
|
201
226
|
self.accept_language = accept_language_for(country, self.options.locales)
|
|
227
|
+
# A platform proxy without a country: every identity picks its exit
|
|
228
|
+
# country and sends the matching Accept-Language.
|
|
229
|
+
self.picks_country = country is None and proxy_configuration is not None and _can_pick_country(proxy_configuration)
|
|
202
230
|
if backoff is None and polite:
|
|
203
231
|
backoff = BackoffPolicy()
|
|
204
232
|
self.policy = RetryPolicy(max_rotations=_rotations(max_rotations, max_retries, 6), backoff=backoff)
|
|
@@ -244,14 +272,44 @@ class _Base:
|
|
|
244
272
|
def _pick_profile(self, avoid: Optional[BrowserProfile]) -> BrowserProfile:
|
|
245
273
|
return pick(self.candidates, self.rng, avoid)
|
|
246
274
|
|
|
247
|
-
def
|
|
275
|
+
def _identity_geo(self) -> Tuple[Optional[str], str]:
|
|
276
|
+
"""(exit country to request from the proxy or None, Accept-Language) for a new identity.
|
|
277
|
+
|
|
278
|
+
- ``locales``: one of them per identity, as a browser has one language
|
|
279
|
+
setup; with a country-pickable proxy the exit is that locale's country.
|
|
280
|
+
- proxy with a country: a language setup common in that country.
|
|
281
|
+
- platform proxy without a country: a country weighted like real
|
|
282
|
+
traffic, used for both the proxy session and the language.
|
|
283
|
+
- otherwise (no proxy, own proxy URLs, datacenter): the client default.
|
|
284
|
+
"""
|
|
285
|
+
locales = self.options.locales
|
|
286
|
+
if locales:
|
|
287
|
+
loc = locales[0] if len(locales) == 1 else self.rng.choice(locales)
|
|
288
|
+
return (country_for_locale(loc) if self.picks_country else None), accept_language_for(None, [loc])
|
|
289
|
+
if self.proxy_country:
|
|
290
|
+
return None, pick_accept_language(self.proxy_country, self.rng)
|
|
291
|
+
if self.picks_country:
|
|
292
|
+
cc = pick_country(self.rng)
|
|
293
|
+
return cc, pick_accept_language(cc, self.rng)
|
|
294
|
+
return None, self.accept_language
|
|
295
|
+
|
|
296
|
+
def _new_identity(
|
|
297
|
+
self,
|
|
298
|
+
client: Any,
|
|
299
|
+
profile: BrowserProfile,
|
|
300
|
+
session_id: str,
|
|
301
|
+
proxy_url: Optional[str],
|
|
302
|
+
accept_language: Optional[str] = None,
|
|
303
|
+
country: Optional[str] = None,
|
|
304
|
+
) -> Identity:
|
|
248
305
|
ident = Identity(
|
|
249
306
|
session_id=session_id,
|
|
250
307
|
profile=profile,
|
|
251
308
|
proxy_url=proxy_url,
|
|
252
309
|
client=client,
|
|
253
|
-
accept_language=self.accept_language,
|
|
310
|
+
accept_language=accept_language or self.accept_language,
|
|
254
311
|
created_at=self._clock(),
|
|
312
|
+
country=country or self.proxy_country,
|
|
255
313
|
)
|
|
256
314
|
for name, value, domain, path in self._caller_cookies._cookies:
|
|
257
315
|
self._apply_cookie(ident, name, value, domain, path)
|
|
@@ -428,18 +486,22 @@ class AsyncClient(_Base):
|
|
|
428
486
|
self._cond = asyncio.Condition()
|
|
429
487
|
return self._cond
|
|
430
488
|
|
|
431
|
-
async def _proxy_url(self, session_id: str) -> Optional[str]:
|
|
489
|
+
async def _proxy_url(self, session_id: str, country: Optional[str] = None) -> Optional[str]:
|
|
432
490
|
if self.proxy_configuration is not None:
|
|
433
|
-
|
|
491
|
+
if country:
|
|
492
|
+
url = await self.proxy_configuration.new_url(session_id=session_id, country_code=country)
|
|
493
|
+
else:
|
|
494
|
+
url = await self.proxy_configuration.new_url(session_id=session_id)
|
|
434
495
|
return str(url) if url else None
|
|
435
496
|
return self.fixed_proxy
|
|
436
497
|
|
|
437
498
|
async def _open(self, avoid: Optional[BrowserProfile]) -> Identity:
|
|
438
499
|
profile = self._pick_profile(avoid)
|
|
439
500
|
sid = new_session_id(self.rng)
|
|
440
|
-
|
|
501
|
+
country, al = self._identity_geo()
|
|
502
|
+
proxy_url = await self._proxy_url(sid, country)
|
|
441
503
|
client = self.transport.new_client(profile, proxy_url, self.timeout, self.verify, self.http1_only)
|
|
442
|
-
return self._new_identity(client, profile, sid, proxy_url)
|
|
504
|
+
return self._new_identity(client, profile, sid, proxy_url, al, country)
|
|
443
505
|
|
|
444
506
|
async def _retire(self, ident: Identity, force: bool = False) -> None:
|
|
445
507
|
"""Retire an identity; its client closes once no request uses it any more."""
|
|
@@ -536,7 +598,7 @@ class AsyncClient(_Base):
|
|
|
536
598
|
) -> Response:
|
|
537
599
|
"""Send a request; a block or error rotates to a fresh identity at once. Returns the last response."""
|
|
538
600
|
method = method.upper()
|
|
539
|
-
url = _with_params(url, params)
|
|
601
|
+
url = _normalize_url(_with_params(url, params))
|
|
540
602
|
body, ctype = encode_body(data, json, content)
|
|
541
603
|
host, hs = self._host(url)
|
|
542
604
|
started = self._clock()
|
|
@@ -684,6 +746,16 @@ class AsyncHttpSession:
|
|
|
684
746
|
def session_id(self) -> Optional[str]:
|
|
685
747
|
return self._pin.identity.session_id if self._pin.identity else None
|
|
686
748
|
|
|
749
|
+
@property
|
|
750
|
+
def accept_language(self) -> Optional[str]:
|
|
751
|
+
"""Accept-Language of the current identity."""
|
|
752
|
+
return self._pin.identity.accept_language if self._pin.identity else None
|
|
753
|
+
|
|
754
|
+
@property
|
|
755
|
+
def country(self) -> Optional[str]:
|
|
756
|
+
"""Exit country the current identity asked the proxy for (None when the proxy chooses)."""
|
|
757
|
+
return self._pin.identity.country if self._pin.identity else None
|
|
758
|
+
|
|
687
759
|
async def request(self, method: str, url: str, **kwargs: Any) -> Response:
|
|
688
760
|
return await self._client.request(method, url, _pin=self._pin, **kwargs)
|
|
689
761
|
|
|
@@ -755,18 +827,18 @@ class Client(_Base):
|
|
|
755
827
|
def _make_transport(self, backend: str) -> Any:
|
|
756
828
|
return WreqSyncTransport() if backend == 'wreq' else CurlSyncTransport(self.table.curl_cffi_targets)
|
|
757
829
|
|
|
758
|
-
def _proxy_url(self, session_id: str) -> Optional[str]:
|
|
830
|
+
def _proxy_url(self, session_id: str, country: Optional[str] = None) -> Optional[str]:
|
|
759
831
|
pc = self.proxy_configuration
|
|
760
832
|
if pc is None:
|
|
761
833
|
return self.fixed_proxy
|
|
762
834
|
sync = getattr(pc, '_new_url_sync', None)
|
|
763
835
|
if sync is not None and not getattr(pc, '_new_url_function', None):
|
|
764
|
-
url = sync(session_id)
|
|
836
|
+
url = sync(session_id, country_code=country) if country else sync(session_id)
|
|
765
837
|
else:
|
|
766
838
|
try:
|
|
767
839
|
asyncio.get_running_loop()
|
|
768
840
|
except RuntimeError:
|
|
769
|
-
url = asyncio.run(pc.new_url(session_id=session_id))
|
|
841
|
+
url = asyncio.run(pc.new_url(session_id=session_id, country_code=country) if country else pc.new_url(session_id=session_id))
|
|
770
842
|
else:
|
|
771
843
|
raise RuntimeError('scrapely.http.Client: use AsyncClient inside async code (this proxy configuration needs await)')
|
|
772
844
|
return str(url) if url else None
|
|
@@ -775,10 +847,11 @@ class Client(_Base):
|
|
|
775
847
|
profile = self._pick_profile(avoid)
|
|
776
848
|
with self._lock:
|
|
777
849
|
sid = new_session_id(self.rng)
|
|
778
|
-
|
|
850
|
+
country, al = self._identity_geo()
|
|
851
|
+
proxy_url = self._proxy_url(sid, country)
|
|
779
852
|
client = self.transport.new_client(profile, proxy_url, self.timeout, self.verify, self.http1_only)
|
|
780
853
|
with self._lock:
|
|
781
|
-
return self._new_identity(client, profile, sid, proxy_url)
|
|
854
|
+
return self._new_identity(client, profile, sid, proxy_url, al, country)
|
|
782
855
|
|
|
783
856
|
def _retire(self, ident: Identity, force: bool = False) -> None:
|
|
784
857
|
"""Retire an identity; its client closes once no request uses it any more."""
|
|
@@ -885,7 +958,7 @@ class Client(_Base):
|
|
|
885
958
|
) -> Response:
|
|
886
959
|
"""Send a request; a block or error rotates to a fresh identity at once. Returns the last response."""
|
|
887
960
|
method = method.upper()
|
|
888
|
-
url = _with_params(url, params)
|
|
961
|
+
url = _normalize_url(_with_params(url, params))
|
|
889
962
|
body, ctype = encode_body(data, json, content)
|
|
890
963
|
with self._lock:
|
|
891
964
|
host, hs = self._host(url)
|
|
@@ -1030,6 +1103,16 @@ class HttpSession:
|
|
|
1030
1103
|
def session_id(self) -> Optional[str]:
|
|
1031
1104
|
return self._pin.identity.session_id if self._pin.identity else None
|
|
1032
1105
|
|
|
1106
|
+
@property
|
|
1107
|
+
def accept_language(self) -> Optional[str]:
|
|
1108
|
+
"""Accept-Language of the current identity."""
|
|
1109
|
+
return self._pin.identity.accept_language if self._pin.identity else None
|
|
1110
|
+
|
|
1111
|
+
@property
|
|
1112
|
+
def country(self) -> Optional[str]:
|
|
1113
|
+
"""Exit country the current identity asked the proxy for (None when the proxy chooses)."""
|
|
1114
|
+
return self._pin.identity.country if self._pin.identity else None
|
|
1115
|
+
|
|
1033
1116
|
def request(self, method: str, url: str, **kwargs: Any) -> Response:
|
|
1034
1117
|
return self._client.request(method, url, _pin=self._pin, **kwargs)
|
|
1035
1118
|
|
scrapely/http/_headers.py
CHANGED
|
@@ -107,6 +107,74 @@ def accept_language_for(country_code: Optional[str] = None, locales: Sequence[st
|
|
|
107
107
|
return f'{lang}-{cc},{lang};q=0.9,en;q=0.8'
|
|
108
108
|
|
|
109
109
|
|
|
110
|
+
# ─── per-identity geo: exit country + matching Accept-Language ──────────────
|
|
111
|
+
|
|
112
|
+
# Exit countries for identities whose proxy has no country set, weighted like
|
|
113
|
+
# desktop web traffic (rough shares by country; only countries the residential
|
|
114
|
+
# pool serves well). The proxy session and Accept-Language use the same draw,
|
|
115
|
+
# so the language always matches the exit IP.
|
|
116
|
+
TRAFFIC_COUNTRIES: Tuple[Tuple[str, float], ...] = (
|
|
117
|
+
('US', 32.0), ('GB', 7.0), ('DE', 7.0), ('FR', 5.0), ('CA', 4.0), ('BR', 4.0), ('IN', 3.0),
|
|
118
|
+
('IT', 3.5), ('ES', 3.5), ('NL', 2.5), ('PL', 2.5), ('MX', 2.5), ('AU', 2.5), ('JP', 2.0),
|
|
119
|
+
('TR', 1.5), ('SE', 1.0), ('BE', 1.0), ('AT', 1.0), ('CH', 1.0), ('PT', 1.0), ('AR', 1.0),
|
|
120
|
+
('CO', 1.0), ('RO', 1.0), ('CZ', 1.0), ('ID', 1.0),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Main country for a bare language (``locales=["de"]``): where its speakers mostly are.
|
|
124
|
+
_LANG_COUNTRY: Dict[str, str] = {
|
|
125
|
+
'en': 'US', 'de': 'DE', 'fr': 'FR', 'es': 'ES', 'it': 'IT', 'pt': 'BR', 'nl': 'NL', 'pl': 'PL',
|
|
126
|
+
'cs': 'CZ', 'sk': 'SK', 'hu': 'HU', 'ro': 'RO', 'bg': 'BG', 'el': 'GR', 'tr': 'TR', 'ru': 'RU',
|
|
127
|
+
'uk': 'UA', 'sv': 'SE', 'nb': 'NO', 'no': 'NO', 'da': 'DK', 'fi': 'FI', 'ja': 'JP', 'ko': 'KR',
|
|
128
|
+
'zh': 'TW', 'vi': 'VN', 'th': 'TH', 'id': 'ID', 'ms': 'MY', 'he': 'IL', 'ar': 'SA', 'hi': 'IN',
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def pick_country(rng: Any) -> str:
|
|
133
|
+
"""A country drawn from ``TRAFFIC_COUNTRIES``."""
|
|
134
|
+
names = [c for c, _ in TRAFFIC_COUNTRIES]
|
|
135
|
+
return rng.choices(names, weights=[w for _, w in TRAFFIC_COUNTRIES], k=1)[0]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def country_for_locale(locale: str) -> Optional[str]:
|
|
139
|
+
"""The country a locale points at: ``de-AT`` → AT, ``de`` → DE, ``en`` → US."""
|
|
140
|
+
parts = str(locale).strip().replace('_', '-').split('-')
|
|
141
|
+
for p in parts[1:]:
|
|
142
|
+
if len(p) == 2 and p.isalpha():
|
|
143
|
+
return p.upper()
|
|
144
|
+
return _LANG_COUNTRY.get(parts[0].lower())
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def accept_language_variants(country_code: Optional[str]) -> List[Tuple[str, float]]:
|
|
148
|
+
"""Accept-Language values real Chrome users in a country send, with rough weights.
|
|
149
|
+
|
|
150
|
+
All variants put the country's language first (``de-DE,de;q=0.9`` with or
|
|
151
|
+
without English after it), so any of them matches an exit IP in that
|
|
152
|
+
country. Chrome builds the header from the user's language list: the
|
|
153
|
+
regional tag, its base language, then further languages 0.1 lower each.
|
|
154
|
+
"""
|
|
155
|
+
cc = (country_code or '').strip().upper()
|
|
156
|
+
if cc == 'UK':
|
|
157
|
+
cc = 'GB'
|
|
158
|
+
if not cc or cc == 'US':
|
|
159
|
+
return [(DEFAULT_ACCEPT_LANGUAGE, 0.92), ('en-US,en;q=0.9,es;q=0.8', 0.08)]
|
|
160
|
+
if cc in _ENGLISH_COUNTRIES:
|
|
161
|
+
return [(f'en-{cc},en;q=0.9', 0.5), (f'en-{cc},en-US;q=0.9,en;q=0.8', 0.2), (DEFAULT_ACCEPT_LANGUAGE, 0.3)]
|
|
162
|
+
lang = _COUNTRY_LANG.get(cc)
|
|
163
|
+
if not lang:
|
|
164
|
+
return [(DEFAULT_ACCEPT_LANGUAGE, 1.0)]
|
|
165
|
+
return [
|
|
166
|
+
(f'{lang}-{cc},{lang};q=0.9,en-US;q=0.8,en;q=0.7', 0.45),
|
|
167
|
+
(f'{lang}-{cc},{lang};q=0.9', 0.30),
|
|
168
|
+
(f'{lang}-{cc},{lang};q=0.9,en;q=0.8', 0.25),
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def pick_accept_language(country_code: Optional[str], rng: Any) -> str:
|
|
173
|
+
"""One Accept-Language for an identity whose exit is in ``country_code``."""
|
|
174
|
+
variants = accept_language_variants(country_code)
|
|
175
|
+
return rng.choices([v for v, _ in variants], weights=[w for _, w in variants], k=1)[0]
|
|
176
|
+
|
|
177
|
+
|
|
110
178
|
# ─── sec-fetch-site ─────────────────────────────────────────────────────────
|
|
111
179
|
|
|
112
180
|
_TWO_LEVEL_SUFFIXES = frozenset(
|
|
@@ -257,6 +325,10 @@ def prepare(
|
|
|
257
325
|
if kind == 'navigate':
|
|
258
326
|
# Backend default headers; we only adjust values that depend on the request.
|
|
259
327
|
prep.default_headers = True
|
|
328
|
+
for h in profile.header_overrides:
|
|
329
|
+
# Newer version on the backend's TLS profile: its own UA, client hints and Accept.
|
|
330
|
+
if h in native:
|
|
331
|
+
out[h] = native[h]
|
|
260
332
|
if al != native.get('accept-language'):
|
|
261
333
|
out['accept-language'] = al
|
|
262
334
|
site = fetch_site(referer, url)
|
scrapely/http/_options.py
CHANGED
|
@@ -99,16 +99,16 @@ class FingerprintOptions:
|
|
|
99
99
|
"""Which browsers, operating systems, devices and locales a client may present.
|
|
100
100
|
|
|
101
101
|
All fields are optional. The defaults are desktop Chrome on Windows, macOS
|
|
102
|
-
and Linux (weighted toward real traffic), with Accept-Language
|
|
103
|
-
|
|
102
|
+
and Linux (weighted toward real traffic), with Accept-Language matching
|
|
103
|
+
each identity's exit country.
|
|
104
104
|
|
|
105
105
|
Attributes:
|
|
106
106
|
browsers: ``"chrome"``, ``"edge"``, ``"firefox"``, ``"safari"``, or
|
|
107
107
|
``{"name": "chrome", "min_version": 150}``.
|
|
108
108
|
operating_systems: ``"windows"``, ``"macos"``, ``"linux"``, ``"android"``, ``"ios"``.
|
|
109
109
|
devices: ``"desktop"`` (default) and/or ``"mobile"``.
|
|
110
|
-
locales: e.g. ``["de-DE"]
|
|
111
|
-
the proxy country.
|
|
110
|
+
locales: e.g. ``["de-DE"]``. Each identity uses one of them, as a
|
|
111
|
+
browser has one language setup. Wins over the proxy country.
|
|
112
112
|
strict: only profiles whose fingerprint was verified against the real
|
|
113
113
|
browser, and fail instead of relaxing options that match nothing.
|
|
114
114
|
http_version: ``"2"`` (default) or ``"1"`` to force HTTP/1.1.
|
scrapely/http/_policy.py
CHANGED
|
@@ -198,6 +198,7 @@ class Identity:
|
|
|
198
198
|
retired: bool = False
|
|
199
199
|
in_flight: int = 0 # requests using it right now (a session's identity is shared)
|
|
200
200
|
closed: bool = False
|
|
201
|
+
country: Optional[str] = None # exit country requested from the proxy, when known
|
|
201
202
|
|
|
202
203
|
def expired(self, now: float, max_usage: int, max_age: float) -> bool:
|
|
203
204
|
if self.retired:
|
scrapely/http/_profiles.py
CHANGED
|
@@ -39,6 +39,10 @@ class BrowserProfile:
|
|
|
39
39
|
weight: float
|
|
40
40
|
headers: Dict[str, str] = field(default_factory=dict, compare=False, hash=False)
|
|
41
41
|
note: str = field(default='', compare=False, hash=False)
|
|
42
|
+
# Headers whose value differs from the backend profile's own default
|
|
43
|
+
# (a newer browser version on the same TLS/HTTP2 profile: User-Agent,
|
|
44
|
+
# client hints, Accept). They are sent explicitly on every request.
|
|
45
|
+
header_overrides: Tuple[str, ...] = field(default=(), compare=False, hash=False)
|
|
42
46
|
|
|
43
47
|
@property
|
|
44
48
|
def label(self) -> str:
|
|
@@ -83,6 +87,7 @@ def _parse_table(raw: Dict[str, Any]) -> ProfileTable:
|
|
|
83
87
|
weight=float(row.get('weight') or 0.0),
|
|
84
88
|
headers={k.lower(): v for k, v in (row.get('headers') or {}).items()},
|
|
85
89
|
note=row.get('note') or '',
|
|
90
|
+
header_overrides=tuple(h.lower() for h in (row.get('header_overrides') or ())),
|
|
86
91
|
)
|
|
87
92
|
)
|
|
88
93
|
return ProfileTable(
|
scrapely/http/profiles.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schema": 1,
|
|
3
3
|
"backend": "wreq",
|
|
4
4
|
"backend_version": "0.13.0",
|
|
5
|
-
"generated": "2026-10-
|
|
5
|
+
"generated": "2026-10-28",
|
|
6
6
|
"reference_versions": {
|
|
7
7
|
"chrome": 156,
|
|
8
8
|
"edge": 156,
|
|
@@ -221,24 +221,24 @@
|
|
|
221
221
|
},
|
|
222
222
|
"profiles": [
|
|
223
223
|
{
|
|
224
|
-
"id": "
|
|
224
|
+
"id": "chrome156-win",
|
|
225
225
|
"family": "chrome",
|
|
226
|
-
"version":
|
|
227
|
-
"version_label": "
|
|
226
|
+
"version": 156,
|
|
227
|
+
"version_label": "156",
|
|
228
228
|
"os": "windows",
|
|
229
229
|
"device": "desktop",
|
|
230
230
|
"wreq_profile": "Chrome154",
|
|
231
231
|
"wreq_platform": "Windows",
|
|
232
232
|
"verified": true,
|
|
233
|
-
"weight": 0.
|
|
234
|
-
"note": "TLS, HTTP/2
|
|
233
|
+
"weight": 0.3575,
|
|
234
|
+
"note": "Chrome 156 on the Chrome154 TLS/HTTP2 profile. Chrome for Testing 154, 155 and 156 send the same cipher suites, extension set, groups, signature algorithms and HTTP/2 settings (local captures 2026-10-28; those builds add extension 0x12e0, which real Chrome 156 stable did not send on tls.peet.ws, where it equals this profile, 2026-10-27). UA, client hints and Accept (image/jxl since 155) are 156's own.",
|
|
235
235
|
"headers": {
|
|
236
|
-
"sec-ch-ua": "\"
|
|
236
|
+
"sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"",
|
|
237
237
|
"sec-ch-ua-mobile": "?0",
|
|
238
238
|
"sec-ch-ua-platform": "\"Windows\"",
|
|
239
239
|
"upgrade-insecure-requests": "1",
|
|
240
|
-
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
|
241
|
-
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
|
240
|
+
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36",
|
|
241
|
+
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
|
242
242
|
"sec-fetch-site": "none",
|
|
243
243
|
"sec-fetch-mode": "navigate",
|
|
244
244
|
"sec-fetch-user": "?1",
|
|
@@ -246,27 +246,98 @@
|
|
|
246
246
|
"accept-encoding": "gzip, deflate, br, zstd",
|
|
247
247
|
"accept-language": "en-US,en;q=0.9",
|
|
248
248
|
"priority": "u=0, i"
|
|
249
|
-
}
|
|
249
|
+
},
|
|
250
|
+
"header_overrides": [
|
|
251
|
+
"user-agent",
|
|
252
|
+
"sec-ch-ua",
|
|
253
|
+
"accept"
|
|
254
|
+
]
|
|
250
255
|
},
|
|
251
256
|
{
|
|
252
|
-
"id": "
|
|
257
|
+
"id": "chrome156-mac",
|
|
253
258
|
"family": "chrome",
|
|
254
|
-
"version":
|
|
255
|
-
"version_label": "
|
|
259
|
+
"version": 156,
|
|
260
|
+
"version_label": "156",
|
|
261
|
+
"os": "macos",
|
|
262
|
+
"device": "desktop",
|
|
263
|
+
"wreq_profile": "Chrome154",
|
|
264
|
+
"wreq_platform": "MacOS",
|
|
265
|
+
"verified": true,
|
|
266
|
+
"weight": 0.1485,
|
|
267
|
+
"note": "Chrome 156 on the Chrome154 TLS/HTTP2 profile. Chrome for Testing 154, 155 and 156 send the same cipher suites, extension set, groups, signature algorithms and HTTP/2 settings (local captures 2026-10-28; those builds add extension 0x12e0, which real Chrome 156 stable did not send on tls.peet.ws, where it equals this profile, 2026-10-27). UA, client hints and Accept (image/jxl since 155) are 156's own.",
|
|
268
|
+
"headers": {
|
|
269
|
+
"sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"",
|
|
270
|
+
"sec-ch-ua-mobile": "?0",
|
|
271
|
+
"sec-ch-ua-platform": "\"macOS\"",
|
|
272
|
+
"upgrade-insecure-requests": "1",
|
|
273
|
+
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36",
|
|
274
|
+
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
|
275
|
+
"sec-fetch-site": "none",
|
|
276
|
+
"sec-fetch-mode": "navigate",
|
|
277
|
+
"sec-fetch-user": "?1",
|
|
278
|
+
"sec-fetch-dest": "document",
|
|
279
|
+
"accept-encoding": "gzip, deflate, br, zstd",
|
|
280
|
+
"accept-language": "en-US,en;q=0.9",
|
|
281
|
+
"priority": "u=0, i"
|
|
282
|
+
},
|
|
283
|
+
"header_overrides": [
|
|
284
|
+
"user-agent",
|
|
285
|
+
"sec-ch-ua",
|
|
286
|
+
"accept"
|
|
287
|
+
]
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
"id": "chrome156-linux",
|
|
291
|
+
"family": "chrome",
|
|
292
|
+
"version": 156,
|
|
293
|
+
"version_label": "156",
|
|
294
|
+
"os": "linux",
|
|
295
|
+
"device": "desktop",
|
|
296
|
+
"wreq_profile": "Chrome154",
|
|
297
|
+
"wreq_platform": "Linux",
|
|
298
|
+
"verified": true,
|
|
299
|
+
"weight": 0.044,
|
|
300
|
+
"note": "Chrome 156 on the Chrome154 TLS/HTTP2 profile. Chrome for Testing 154, 155 and 156 send the same cipher suites, extension set, groups, signature algorithms and HTTP/2 settings (local captures 2026-10-28; those builds add extension 0x12e0, which real Chrome 156 stable did not send on tls.peet.ws, where it equals this profile, 2026-10-27). UA, client hints and Accept (image/jxl since 155) are 156's own.",
|
|
301
|
+
"headers": {
|
|
302
|
+
"sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"",
|
|
303
|
+
"sec-ch-ua-mobile": "?0",
|
|
304
|
+
"sec-ch-ua-platform": "\"Linux\"",
|
|
305
|
+
"upgrade-insecure-requests": "1",
|
|
306
|
+
"user-agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36",
|
|
307
|
+
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
|
308
|
+
"sec-fetch-site": "none",
|
|
309
|
+
"sec-fetch-mode": "navigate",
|
|
310
|
+
"sec-fetch-user": "?1",
|
|
311
|
+
"sec-fetch-dest": "document",
|
|
312
|
+
"accept-encoding": "gzip, deflate, br, zstd",
|
|
313
|
+
"accept-language": "en-US,en;q=0.9",
|
|
314
|
+
"priority": "u=0, i"
|
|
315
|
+
},
|
|
316
|
+
"header_overrides": [
|
|
317
|
+
"user-agent",
|
|
318
|
+
"sec-ch-ua",
|
|
319
|
+
"accept"
|
|
320
|
+
]
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
"id": "chrome155-win",
|
|
324
|
+
"family": "chrome",
|
|
325
|
+
"version": 155,
|
|
326
|
+
"version_label": "155",
|
|
256
327
|
"os": "windows",
|
|
257
328
|
"device": "desktop",
|
|
258
|
-
"wreq_profile": "
|
|
329
|
+
"wreq_profile": "Chrome154",
|
|
259
330
|
"wreq_platform": "Windows",
|
|
260
331
|
"verified": true,
|
|
261
|
-
"weight": 0.
|
|
262
|
-
"note": "TLS, HTTP/2
|
|
332
|
+
"weight": 0.195,
|
|
333
|
+
"note": "Chrome 155 on the Chrome154 TLS/HTTP2 profile. Chrome for Testing 154, 155 and 156 send the same cipher suites, extension set, groups, signature algorithms and HTTP/2 settings (local captures 2026-10-28; those builds add extension 0x12e0, which real Chrome 156 stable did not send on tls.peet.ws, where it equals this profile, 2026-10-27). UA, client hints and Accept (image/jxl since 155) are 155's own.",
|
|
263
334
|
"headers": {
|
|
264
|
-
"sec-ch-ua": "\"Google Chrome\";v=\"
|
|
335
|
+
"sec-ch-ua": "\"Google Chrome\";v=\"155\", \"Chromium\";v=\"155\", \"Not(A:Brand\";v=\"24\"",
|
|
265
336
|
"sec-ch-ua-mobile": "?0",
|
|
266
337
|
"sec-ch-ua-platform": "\"Windows\"",
|
|
267
338
|
"upgrade-insecure-requests": "1",
|
|
268
|
-
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
|
269
|
-
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
|
339
|
+
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36",
|
|
340
|
+
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
|
270
341
|
"sec-fetch-site": "none",
|
|
271
342
|
"sec-fetch-mode": "navigate",
|
|
272
343
|
"sec-fetch-user": "?1",
|
|
@@ -274,26 +345,97 @@
|
|
|
274
345
|
"accept-encoding": "gzip, deflate, br, zstd",
|
|
275
346
|
"accept-language": "en-US,en;q=0.9",
|
|
276
347
|
"priority": "u=0, i"
|
|
277
|
-
}
|
|
348
|
+
},
|
|
349
|
+
"header_overrides": [
|
|
350
|
+
"user-agent",
|
|
351
|
+
"sec-ch-ua",
|
|
352
|
+
"accept"
|
|
353
|
+
]
|
|
278
354
|
},
|
|
279
355
|
{
|
|
280
|
-
"id": "
|
|
356
|
+
"id": "chrome155-mac",
|
|
281
357
|
"family": "chrome",
|
|
282
|
-
"version":
|
|
283
|
-
"version_label": "
|
|
358
|
+
"version": 155,
|
|
359
|
+
"version_label": "155",
|
|
284
360
|
"os": "macos",
|
|
285
361
|
"device": "desktop",
|
|
286
362
|
"wreq_profile": "Chrome154",
|
|
287
363
|
"wreq_platform": "MacOS",
|
|
288
364
|
"verified": true,
|
|
289
|
-
"weight": 0.
|
|
365
|
+
"weight": 0.081,
|
|
366
|
+
"note": "Chrome 155 on the Chrome154 TLS/HTTP2 profile. Chrome for Testing 154, 155 and 156 send the same cipher suites, extension set, groups, signature algorithms and HTTP/2 settings (local captures 2026-10-28; those builds add extension 0x12e0, which real Chrome 156 stable did not send on tls.peet.ws, where it equals this profile, 2026-10-27). UA, client hints and Accept (image/jxl since 155) are 155's own.",
|
|
367
|
+
"headers": {
|
|
368
|
+
"sec-ch-ua": "\"Google Chrome\";v=\"155\", \"Chromium\";v=\"155\", \"Not(A:Brand\";v=\"24\"",
|
|
369
|
+
"sec-ch-ua-mobile": "?0",
|
|
370
|
+
"sec-ch-ua-platform": "\"macOS\"",
|
|
371
|
+
"upgrade-insecure-requests": "1",
|
|
372
|
+
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36",
|
|
373
|
+
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
|
374
|
+
"sec-fetch-site": "none",
|
|
375
|
+
"sec-fetch-mode": "navigate",
|
|
376
|
+
"sec-fetch-user": "?1",
|
|
377
|
+
"sec-fetch-dest": "document",
|
|
378
|
+
"accept-encoding": "gzip, deflate, br, zstd",
|
|
379
|
+
"accept-language": "en-US,en;q=0.9",
|
|
380
|
+
"priority": "u=0, i"
|
|
381
|
+
},
|
|
382
|
+
"header_overrides": [
|
|
383
|
+
"user-agent",
|
|
384
|
+
"sec-ch-ua",
|
|
385
|
+
"accept"
|
|
386
|
+
]
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
"id": "chrome155-linux",
|
|
390
|
+
"family": "chrome",
|
|
391
|
+
"version": 155,
|
|
392
|
+
"version_label": "155",
|
|
393
|
+
"os": "linux",
|
|
394
|
+
"device": "desktop",
|
|
395
|
+
"wreq_profile": "Chrome154",
|
|
396
|
+
"wreq_platform": "Linux",
|
|
397
|
+
"verified": true,
|
|
398
|
+
"weight": 0.024,
|
|
399
|
+
"note": "Chrome 155 on the Chrome154 TLS/HTTP2 profile. Chrome for Testing 154, 155 and 156 send the same cipher suites, extension set, groups, signature algorithms and HTTP/2 settings (local captures 2026-10-28; those builds add extension 0x12e0, which real Chrome 156 stable did not send on tls.peet.ws, where it equals this profile, 2026-10-27). UA, client hints and Accept (image/jxl since 155) are 155's own.",
|
|
400
|
+
"headers": {
|
|
401
|
+
"sec-ch-ua": "\"Google Chrome\";v=\"155\", \"Chromium\";v=\"155\", \"Not(A:Brand\";v=\"24\"",
|
|
402
|
+
"sec-ch-ua-mobile": "?0",
|
|
403
|
+
"sec-ch-ua-platform": "\"Linux\"",
|
|
404
|
+
"upgrade-insecure-requests": "1",
|
|
405
|
+
"user-agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36",
|
|
406
|
+
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
|
407
|
+
"sec-fetch-site": "none",
|
|
408
|
+
"sec-fetch-mode": "navigate",
|
|
409
|
+
"sec-fetch-user": "?1",
|
|
410
|
+
"sec-fetch-dest": "document",
|
|
411
|
+
"accept-encoding": "gzip, deflate, br, zstd",
|
|
412
|
+
"accept-language": "en-US,en;q=0.9",
|
|
413
|
+
"priority": "u=0, i"
|
|
414
|
+
},
|
|
415
|
+
"header_overrides": [
|
|
416
|
+
"user-agent",
|
|
417
|
+
"sec-ch-ua",
|
|
418
|
+
"accept"
|
|
419
|
+
]
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
"id": "chrome154-win",
|
|
423
|
+
"family": "chrome",
|
|
424
|
+
"version": 154,
|
|
425
|
+
"version_label": "154",
|
|
426
|
+
"os": "windows",
|
|
427
|
+
"device": "desktop",
|
|
428
|
+
"wreq_profile": "Chrome154",
|
|
429
|
+
"wreq_platform": "Windows",
|
|
430
|
+
"verified": true,
|
|
431
|
+
"weight": 0.065,
|
|
290
432
|
"note": "TLS, HTTP/2 and header order equal to real Chrome 156 on tls.peet.ws (JA4 t13d1517h2_8daaf6152771_cb7bf5808d99, Akamai 1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p), 2026-10-27",
|
|
291
433
|
"headers": {
|
|
292
434
|
"sec-ch-ua": "\"Chromium\";v=\"154\", \"Google Chrome\";v=\"154\", \"Not A(Brand\";v=\"99\"",
|
|
293
435
|
"sec-ch-ua-mobile": "?0",
|
|
294
|
-
"sec-ch-ua-platform": "\"
|
|
436
|
+
"sec-ch-ua-platform": "\"Windows\"",
|
|
295
437
|
"upgrade-insecure-requests": "1",
|
|
296
|
-
"user-agent": "Mozilla/5.0 (
|
|
438
|
+
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36",
|
|
297
439
|
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
|
298
440
|
"sec-fetch-site": "none",
|
|
299
441
|
"sec-fetch-mode": "navigate",
|
|
@@ -305,23 +447,23 @@
|
|
|
305
447
|
}
|
|
306
448
|
},
|
|
307
449
|
{
|
|
308
|
-
"id": "
|
|
450
|
+
"id": "chrome154-mac",
|
|
309
451
|
"family": "chrome",
|
|
310
|
-
"version":
|
|
311
|
-
"version_label": "
|
|
452
|
+
"version": 154,
|
|
453
|
+
"version_label": "154",
|
|
312
454
|
"os": "macos",
|
|
313
455
|
"device": "desktop",
|
|
314
|
-
"wreq_profile": "
|
|
456
|
+
"wreq_profile": "Chrome154",
|
|
315
457
|
"wreq_platform": "MacOS",
|
|
316
458
|
"verified": true,
|
|
317
|
-
"weight": 0.
|
|
459
|
+
"weight": 0.027,
|
|
318
460
|
"note": "TLS, HTTP/2 and header order equal to real Chrome 156 on tls.peet.ws (JA4 t13d1517h2_8daaf6152771_cb7bf5808d99, Akamai 1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p), 2026-10-27",
|
|
319
461
|
"headers": {
|
|
320
|
-
"sec-ch-ua": "\"
|
|
462
|
+
"sec-ch-ua": "\"Chromium\";v=\"154\", \"Google Chrome\";v=\"154\", \"Not A(Brand\";v=\"99\"",
|
|
321
463
|
"sec-ch-ua-mobile": "?0",
|
|
322
464
|
"sec-ch-ua-platform": "\"macOS\"",
|
|
323
465
|
"upgrade-insecure-requests": "1",
|
|
324
|
-
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
|
466
|
+
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36",
|
|
325
467
|
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
|
326
468
|
"sec-fetch-site": "none",
|
|
327
469
|
"sec-fetch-mode": "navigate",
|
|
@@ -342,7 +484,7 @@
|
|
|
342
484
|
"wreq_profile": "Chrome154",
|
|
343
485
|
"wreq_platform": "Linux",
|
|
344
486
|
"verified": true,
|
|
345
|
-
"weight": 0.
|
|
487
|
+
"weight": 0.008,
|
|
346
488
|
"note": "TLS, HTTP/2 and header order equal to real Chrome 156 on tls.peet.ws (JA4 t13d1517h2_8daaf6152771_cb7bf5808d99, Akamai 1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p), 2026-10-27",
|
|
347
489
|
"headers": {
|
|
348
490
|
"sec-ch-ua": "\"Chromium\";v=\"154\", \"Google Chrome\";v=\"154\", \"Not A(Brand\";v=\"99\"",
|
|
@@ -360,6 +502,62 @@
|
|
|
360
502
|
"priority": "u=0, i"
|
|
361
503
|
}
|
|
362
504
|
},
|
|
505
|
+
{
|
|
506
|
+
"id": "chrome153-win",
|
|
507
|
+
"family": "chrome",
|
|
508
|
+
"version": 153,
|
|
509
|
+
"version_label": "153",
|
|
510
|
+
"os": "windows",
|
|
511
|
+
"device": "desktop",
|
|
512
|
+
"wreq_profile": "Chrome153",
|
|
513
|
+
"wreq_platform": "Windows",
|
|
514
|
+
"verified": true,
|
|
515
|
+
"weight": 0.0325,
|
|
516
|
+
"note": "TLS, HTTP/2 and header order equal to real Chrome 156 on tls.peet.ws (JA4 t13d1517h2_8daaf6152771_cb7bf5808d99, Akamai 1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p), 2026-10-27",
|
|
517
|
+
"headers": {
|
|
518
|
+
"sec-ch-ua": "\"Google Chrome\";v=\"153\", \"Not_A Brand\";v=\"8\", \"Chromium\";v=\"153\"",
|
|
519
|
+
"sec-ch-ua-mobile": "?0",
|
|
520
|
+
"sec-ch-ua-platform": "\"Windows\"",
|
|
521
|
+
"upgrade-insecure-requests": "1",
|
|
522
|
+
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36",
|
|
523
|
+
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
|
524
|
+
"sec-fetch-site": "none",
|
|
525
|
+
"sec-fetch-mode": "navigate",
|
|
526
|
+
"sec-fetch-user": "?1",
|
|
527
|
+
"sec-fetch-dest": "document",
|
|
528
|
+
"accept-encoding": "gzip, deflate, br, zstd",
|
|
529
|
+
"accept-language": "en-US,en;q=0.9",
|
|
530
|
+
"priority": "u=0, i"
|
|
531
|
+
}
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
"id": "chrome153-mac",
|
|
535
|
+
"family": "chrome",
|
|
536
|
+
"version": 153,
|
|
537
|
+
"version_label": "153",
|
|
538
|
+
"os": "macos",
|
|
539
|
+
"device": "desktop",
|
|
540
|
+
"wreq_profile": "Chrome153",
|
|
541
|
+
"wreq_platform": "MacOS",
|
|
542
|
+
"verified": true,
|
|
543
|
+
"weight": 0.0135,
|
|
544
|
+
"note": "TLS, HTTP/2 and header order equal to real Chrome 156 on tls.peet.ws (JA4 t13d1517h2_8daaf6152771_cb7bf5808d99, Akamai 1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p), 2026-10-27",
|
|
545
|
+
"headers": {
|
|
546
|
+
"sec-ch-ua": "\"Google Chrome\";v=\"153\", \"Not_A Brand\";v=\"8\", \"Chromium\";v=\"153\"",
|
|
547
|
+
"sec-ch-ua-mobile": "?0",
|
|
548
|
+
"sec-ch-ua-platform": "\"macOS\"",
|
|
549
|
+
"upgrade-insecure-requests": "1",
|
|
550
|
+
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36",
|
|
551
|
+
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
|
552
|
+
"sec-fetch-site": "none",
|
|
553
|
+
"sec-fetch-mode": "navigate",
|
|
554
|
+
"sec-fetch-user": "?1",
|
|
555
|
+
"sec-fetch-dest": "document",
|
|
556
|
+
"accept-encoding": "gzip, deflate, br, zstd",
|
|
557
|
+
"accept-language": "en-US,en;q=0.9",
|
|
558
|
+
"priority": "u=0, i"
|
|
559
|
+
}
|
|
560
|
+
},
|
|
363
561
|
{
|
|
364
562
|
"id": "chrome153-linux",
|
|
365
563
|
"family": "chrome",
|
|
@@ -370,7 +568,7 @@
|
|
|
370
568
|
"wreq_profile": "Chrome153",
|
|
371
569
|
"wreq_platform": "Linux",
|
|
372
570
|
"verified": true,
|
|
373
|
-
"weight": 0.
|
|
571
|
+
"weight": 0.004,
|
|
374
572
|
"note": "TLS, HTTP/2 and header order equal to real Chrome 156 on tls.peet.ws (JA4 t13d1517h2_8daaf6152771_cb7bf5808d99, Akamai 1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p), 2026-10-27",
|
|
375
573
|
"headers": {
|
|
376
574
|
"sec-ch-ua": "\"Google Chrome\";v=\"153\", \"Not_A Brand\";v=\"8\", \"Chromium\";v=\"153\"",
|
|
@@ -388,6 +586,72 @@
|
|
|
388
586
|
"priority": "u=0, i"
|
|
389
587
|
}
|
|
390
588
|
},
|
|
589
|
+
{
|
|
590
|
+
"id": "chrome156-android",
|
|
591
|
+
"family": "chrome",
|
|
592
|
+
"version": 156,
|
|
593
|
+
"version_label": "156",
|
|
594
|
+
"os": "android",
|
|
595
|
+
"device": "mobile",
|
|
596
|
+
"wreq_profile": "Chrome154",
|
|
597
|
+
"wreq_platform": "Android",
|
|
598
|
+
"verified": false,
|
|
599
|
+
"weight": 0.275,
|
|
600
|
+
"note": "Chrome 156 Android on the Chrome154 TLS profile (equal to desktop); no real Android Chrome capture to compare against",
|
|
601
|
+
"headers": {
|
|
602
|
+
"sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"",
|
|
603
|
+
"sec-ch-ua-mobile": "?1",
|
|
604
|
+
"sec-ch-ua-platform": "\"Android\"",
|
|
605
|
+
"upgrade-insecure-requests": "1",
|
|
606
|
+
"user-agent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Mobile Safari/537.36",
|
|
607
|
+
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
|
608
|
+
"sec-fetch-site": "none",
|
|
609
|
+
"sec-fetch-mode": "navigate",
|
|
610
|
+
"sec-fetch-user": "?1",
|
|
611
|
+
"sec-fetch-dest": "document",
|
|
612
|
+
"accept-encoding": "gzip, deflate, br, zstd",
|
|
613
|
+
"accept-language": "en-US,en;q=0.9",
|
|
614
|
+
"priority": "u=0, i"
|
|
615
|
+
},
|
|
616
|
+
"header_overrides": [
|
|
617
|
+
"user-agent",
|
|
618
|
+
"sec-ch-ua",
|
|
619
|
+
"accept"
|
|
620
|
+
]
|
|
621
|
+
},
|
|
622
|
+
{
|
|
623
|
+
"id": "chrome155-android",
|
|
624
|
+
"family": "chrome",
|
|
625
|
+
"version": 155,
|
|
626
|
+
"version_label": "155",
|
|
627
|
+
"os": "android",
|
|
628
|
+
"device": "mobile",
|
|
629
|
+
"wreq_profile": "Chrome154",
|
|
630
|
+
"wreq_platform": "Android",
|
|
631
|
+
"verified": false,
|
|
632
|
+
"weight": 0.15,
|
|
633
|
+
"note": "Chrome 155 Android on the Chrome154 TLS profile (equal to desktop); no real Android Chrome capture to compare against",
|
|
634
|
+
"headers": {
|
|
635
|
+
"sec-ch-ua": "\"Google Chrome\";v=\"155\", \"Chromium\";v=\"155\", \"Not(A:Brand\";v=\"24\"",
|
|
636
|
+
"sec-ch-ua-mobile": "?1",
|
|
637
|
+
"sec-ch-ua-platform": "\"Android\"",
|
|
638
|
+
"upgrade-insecure-requests": "1",
|
|
639
|
+
"user-agent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Mobile Safari/537.36",
|
|
640
|
+
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
|
641
|
+
"sec-fetch-site": "none",
|
|
642
|
+
"sec-fetch-mode": "navigate",
|
|
643
|
+
"sec-fetch-user": "?1",
|
|
644
|
+
"sec-fetch-dest": "document",
|
|
645
|
+
"accept-encoding": "gzip, deflate, br, zstd",
|
|
646
|
+
"accept-language": "en-US,en;q=0.9",
|
|
647
|
+
"priority": "u=0, i"
|
|
648
|
+
},
|
|
649
|
+
"header_overrides": [
|
|
650
|
+
"user-agent",
|
|
651
|
+
"sec-ch-ua",
|
|
652
|
+
"accept"
|
|
653
|
+
]
|
|
654
|
+
},
|
|
391
655
|
{
|
|
392
656
|
"id": "chrome154-android",
|
|
393
657
|
"family": "chrome",
|
|
@@ -398,7 +662,7 @@
|
|
|
398
662
|
"wreq_profile": "Chrome154",
|
|
399
663
|
"wreq_platform": "Android",
|
|
400
664
|
"verified": false,
|
|
401
|
-
"weight": 0.
|
|
665
|
+
"weight": 0.075,
|
|
402
666
|
"note": "TLS equals desktop Chrome 154; no real Android Chrome capture to compare against",
|
|
403
667
|
"headers": {
|
|
404
668
|
"sec-ch-ua": "\"Chromium\";v=\"154\", \"Google Chrome\";v=\"154\", \"Not A(Brand\";v=\"99\"",
|
|
@@ -472,6 +736,93 @@
|
|
|
472
736
|
"priority": "u=0, i"
|
|
473
737
|
}
|
|
474
738
|
},
|
|
739
|
+
{
|
|
740
|
+
"id": "firefox153-win",
|
|
741
|
+
"family": "firefox",
|
|
742
|
+
"version": 153,
|
|
743
|
+
"version_label": "153",
|
|
744
|
+
"os": "windows",
|
|
745
|
+
"device": "desktop",
|
|
746
|
+
"wreq_profile": "Firefox152",
|
|
747
|
+
"wreq_platform": "Windows",
|
|
748
|
+
"verified": true,
|
|
749
|
+
"weight": 0.032,
|
|
750
|
+
"note": "Firefox 153 ESR on the Firefox152 profile. TLS ClientHello (ciphers incl. c00a, extensions, groups incl. ffdhe2048/3072, signature algorithms, key shares) and HTTP/2 (1:65536;2:0;4:131072;5:16384|12517377|0|m,p,a,s, header order) equal to real Firefox 153.6.0esr (and 152.0), Linux builds from archive.mozilla.org captured 2026-10-28. Firefox 157+ differ (no c00a, no ffdhe groups).",
|
|
751
|
+
"headers": {
|
|
752
|
+
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:153.0) Gecko/20100101 Firefox/153.0",
|
|
753
|
+
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
754
|
+
"accept-language": "en-US,en;q=0.9",
|
|
755
|
+
"accept-encoding": "gzip, deflate, br, zstd",
|
|
756
|
+
"upgrade-insecure-requests": "1",
|
|
757
|
+
"sec-fetch-dest": "document",
|
|
758
|
+
"sec-fetch-mode": "navigate",
|
|
759
|
+
"sec-fetch-site": "none",
|
|
760
|
+
"sec-fetch-user": "?1",
|
|
761
|
+
"priority": "u=0, i",
|
|
762
|
+
"te": "trailers"
|
|
763
|
+
},
|
|
764
|
+
"header_overrides": [
|
|
765
|
+
"user-agent"
|
|
766
|
+
]
|
|
767
|
+
},
|
|
768
|
+
{
|
|
769
|
+
"id": "firefox153-mac",
|
|
770
|
+
"family": "firefox",
|
|
771
|
+
"version": 153,
|
|
772
|
+
"version_label": "153",
|
|
773
|
+
"os": "macos",
|
|
774
|
+
"device": "desktop",
|
|
775
|
+
"wreq_profile": "Firefox152",
|
|
776
|
+
"wreq_platform": "MacOS",
|
|
777
|
+
"verified": true,
|
|
778
|
+
"weight": 0.008,
|
|
779
|
+
"note": "Firefox 153 ESR on the Firefox152 profile. TLS ClientHello (ciphers incl. c00a, extensions, groups incl. ffdhe2048/3072, signature algorithms, key shares) and HTTP/2 (1:65536;2:0;4:131072;5:16384|12517377|0|m,p,a,s, header order) equal to real Firefox 153.6.0esr (and 152.0), Linux builds from archive.mozilla.org captured 2026-10-28. Firefox 157+ differ (no c00a, no ffdhe groups).",
|
|
780
|
+
"headers": {
|
|
781
|
+
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:153.0) Gecko/20100101 Firefox/153.0",
|
|
782
|
+
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
783
|
+
"accept-language": "en-US,en;q=0.9",
|
|
784
|
+
"accept-encoding": "gzip, deflate, br, zstd",
|
|
785
|
+
"upgrade-insecure-requests": "1",
|
|
786
|
+
"sec-fetch-dest": "document",
|
|
787
|
+
"sec-fetch-mode": "navigate",
|
|
788
|
+
"sec-fetch-site": "none",
|
|
789
|
+
"sec-fetch-user": "?1",
|
|
790
|
+
"priority": "u=0, i",
|
|
791
|
+
"te": "trailers"
|
|
792
|
+
},
|
|
793
|
+
"header_overrides": [
|
|
794
|
+
"user-agent"
|
|
795
|
+
]
|
|
796
|
+
},
|
|
797
|
+
{
|
|
798
|
+
"id": "firefox153-linux",
|
|
799
|
+
"family": "firefox",
|
|
800
|
+
"version": 153,
|
|
801
|
+
"version_label": "153",
|
|
802
|
+
"os": "linux",
|
|
803
|
+
"device": "desktop",
|
|
804
|
+
"wreq_profile": "Firefox152",
|
|
805
|
+
"wreq_platform": "Linux",
|
|
806
|
+
"verified": true,
|
|
807
|
+
"weight": 0.008,
|
|
808
|
+
"note": "Firefox 153 ESR on the Firefox152 profile. TLS ClientHello (ciphers incl. c00a, extensions, groups incl. ffdhe2048/3072, signature algorithms, key shares) and HTTP/2 (1:65536;2:0;4:131072;5:16384|12517377|0|m,p,a,s, header order) equal to real Firefox 153.6.0esr (and 152.0), Linux builds from archive.mozilla.org captured 2026-10-28. Firefox 157+ differ (no c00a, no ffdhe groups).",
|
|
809
|
+
"headers": {
|
|
810
|
+
"user-agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:153.0) Gecko/20100101 Firefox/153.0",
|
|
811
|
+
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
812
|
+
"accept-language": "en-US,en;q=0.9",
|
|
813
|
+
"accept-encoding": "gzip, deflate, br, zstd",
|
|
814
|
+
"upgrade-insecure-requests": "1",
|
|
815
|
+
"sec-fetch-dest": "document",
|
|
816
|
+
"sec-fetch-mode": "navigate",
|
|
817
|
+
"sec-fetch-site": "none",
|
|
818
|
+
"sec-fetch-user": "?1",
|
|
819
|
+
"priority": "u=0, i",
|
|
820
|
+
"te": "trailers"
|
|
821
|
+
},
|
|
822
|
+
"header_overrides": [
|
|
823
|
+
"user-agent"
|
|
824
|
+
]
|
|
825
|
+
},
|
|
475
826
|
{
|
|
476
827
|
"id": "firefox152-win",
|
|
477
828
|
"family": "firefox",
|
|
@@ -481,9 +832,9 @@
|
|
|
481
832
|
"device": "desktop",
|
|
482
833
|
"wreq_profile": "Firefox152",
|
|
483
834
|
"wreq_platform": "Windows",
|
|
484
|
-
"verified":
|
|
485
|
-
"weight": 0.
|
|
486
|
-
"note": "
|
|
835
|
+
"verified": true,
|
|
836
|
+
"weight": 0.008,
|
|
837
|
+
"note": "TLS ClientHello (ciphers incl. c00a, extensions, groups incl. ffdhe2048/3072, signature algorithms, key shares) and HTTP/2 (1:65536;2:0;4:131072;5:16384|12517377|0|m,p,a,s, header order) equal to real Firefox 152.0, Linux builds from archive.mozilla.org captured 2026-10-28. Firefox 157+ differ (no c00a, no ffdhe groups).",
|
|
487
838
|
"headers": {
|
|
488
839
|
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0",
|
|
489
840
|
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
@@ -507,9 +858,9 @@
|
|
|
507
858
|
"device": "desktop",
|
|
508
859
|
"wreq_profile": "Firefox152",
|
|
509
860
|
"wreq_platform": "MacOS",
|
|
510
|
-
"verified":
|
|
511
|
-
"weight": 0.
|
|
512
|
-
"note": "
|
|
861
|
+
"verified": true,
|
|
862
|
+
"weight": 0.002,
|
|
863
|
+
"note": "TLS ClientHello (ciphers incl. c00a, extensions, groups incl. ffdhe2048/3072, signature algorithms, key shares) and HTTP/2 (1:65536;2:0;4:131072;5:16384|12517377|0|m,p,a,s, header order) equal to real Firefox 152.0, Linux builds from archive.mozilla.org captured 2026-10-28. Firefox 157+ differ (no c00a, no ffdhe groups).",
|
|
513
864
|
"headers": {
|
|
514
865
|
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0",
|
|
515
866
|
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
@@ -533,9 +884,9 @@
|
|
|
533
884
|
"device": "desktop",
|
|
534
885
|
"wreq_profile": "Firefox152",
|
|
535
886
|
"wreq_platform": "Linux",
|
|
536
|
-
"verified":
|
|
537
|
-
"weight": 0.
|
|
538
|
-
"note": "
|
|
887
|
+
"verified": true,
|
|
888
|
+
"weight": 0.002,
|
|
889
|
+
"note": "TLS ClientHello (ciphers incl. c00a, extensions, groups incl. ffdhe2048/3072, signature algorithms, key shares) and HTTP/2 (1:65536;2:0;4:131072;5:16384|12517377|0|m,p,a,s, header order) equal to real Firefox 152.0, Linux builds from archive.mozilla.org captured 2026-10-28. Firefox 157+ differ (no c00a, no ffdhe groups).",
|
|
539
890
|
"headers": {
|
|
540
891
|
"user-agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0",
|
|
541
892
|
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
@@ -597,4 +948,4 @@
|
|
|
597
948
|
}
|
|
598
949
|
}
|
|
599
950
|
]
|
|
600
|
-
}
|
|
951
|
+
}
|
scrapely/proxy_configuration.py
CHANGED
|
@@ -631,11 +631,26 @@ class ProxyConfiguration:
|
|
|
631
631
|
result = await result
|
|
632
632
|
return cast(str, result)
|
|
633
633
|
|
|
634
|
+
def _can_pick_country(self) -> bool:
|
|
635
|
+
"""True when each URL may get its own exit country (``new_url(country_code=...)``).
|
|
636
|
+
|
|
637
|
+
Only for the plain platform proxy with no country set, no custom or
|
|
638
|
+
tiered URLs and no datacenter group: the datacenter pool exits in the
|
|
639
|
+
US only, and a datacenter request pinned to another country is routed
|
|
640
|
+
and billed as residential.
|
|
641
|
+
"""
|
|
642
|
+
if self._country_code or self._subdivision_code or self._proxy_urls or self._tiered_proxy_urls or self._new_url_function:
|
|
643
|
+
return False
|
|
644
|
+
if not self._needs_platform:
|
|
645
|
+
return False
|
|
646
|
+
return all(str(g).upper() == 'RESIDENTIAL' for g in self._groups)
|
|
647
|
+
|
|
634
648
|
async def new_url(
|
|
635
649
|
self,
|
|
636
650
|
session_id: Optional[str] = None,
|
|
637
651
|
request: Optional[Any] = None, # Request object from crawlee (unused, kept for API compatibility)
|
|
638
652
|
proxy_tier: Optional[int] = None,
|
|
653
|
+
country_code: Optional[str] = None,
|
|
639
654
|
) -> str:
|
|
640
655
|
"""Get a new proxy URL.
|
|
641
656
|
|
|
@@ -649,19 +664,33 @@ class ProxyConfiguration:
|
|
|
649
664
|
configured, the session ID is passed to it.
|
|
650
665
|
request: Request object from crawlee (unused, for API compatibility).
|
|
651
666
|
proxy_tier: Optional tier to select when tiered proxies are used.
|
|
667
|
+
country_code: Exit country for this URL only (two-letter ISO
|
|
668
|
+
code). Only for the plain platform proxy without a country of
|
|
669
|
+
its own (residential); ignored otherwise.
|
|
652
670
|
|
|
653
671
|
Returns:
|
|
654
672
|
Proxy URL string
|
|
655
673
|
"""
|
|
656
674
|
if self._new_url_function:
|
|
657
675
|
return await self._call_new_url_function(session_id)
|
|
658
|
-
return self._new_url_sync(session_id, proxy_tier)
|
|
676
|
+
return self._new_url_sync(session_id, proxy_tier, country_code=country_code)
|
|
659
677
|
|
|
660
|
-
def _new_url_sync(self, session_id: Optional[str] = None, proxy_tier: Optional[int] = None) -> str:
|
|
678
|
+
def _new_url_sync(self, session_id: Optional[str] = None, proxy_tier: Optional[int] = None, country_code: Optional[str] = None) -> str:
|
|
661
679
|
"""Resolve a proxy URL from the configured proxy_urls / platform gateway."""
|
|
662
680
|
if session_id is not None:
|
|
663
681
|
_validate_session_id(str(session_id))
|
|
664
682
|
|
|
683
|
+
if country_code and self._can_pick_country():
|
|
684
|
+
country_code = country_code.upper()
|
|
685
|
+
_validate_country_options(country_code, None)
|
|
686
|
+
key: Tuple[str, int] = (f'{session_id}@{country_code}', -1)
|
|
687
|
+
if session_id is not None and key in self._session_urls:
|
|
688
|
+
return self._session_urls[key]
|
|
689
|
+
url = self._build_platform_url(session_id=session_id, country_code=country_code)
|
|
690
|
+
if session_id is not None:
|
|
691
|
+
self._session_urls[key] = url
|
|
692
|
+
return url
|
|
693
|
+
|
|
665
694
|
# Session stickiness is scoped per tier (parity with Crawlee's tiered
|
|
666
695
|
# handling): the same session ID on a different tier resolves to that
|
|
667
696
|
# tier's own URL instead of leaking the pinned URL across tiers. The
|
scrapely/storage/api_client.py
CHANGED
|
@@ -27,7 +27,9 @@ class ApiClientError(Exception):
|
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
# PUT is left out: a KV record PUT is counted (and billed) as a write each time
|
|
30
|
-
# it is applied, so a lost response must not be re-sent blindly.
|
|
30
|
+
# it is applied, so a lost response must not be re-sent blindly. Record PUTs
|
|
31
|
+
# carry an Idempotency-Key instead (see is_kv_record_url), which the API
|
|
32
|
+
# de-duplicates, so they are re-sent after a lost answer (e.g. an edge 502).
|
|
31
33
|
IDEMPOTENT_METHODS = frozenset({'GET', 'HEAD', 'DELETE', 'OPTIONS'})
|
|
32
34
|
# HAProxy's own 503 page: no backend was available, so nothing was forwarded.
|
|
33
35
|
NO_BACKEND_MARKER = 'No server is available to handle this request'
|
|
@@ -94,20 +96,40 @@ _keyed_writes_deduped: Dict[str, bool] = {}
|
|
|
94
96
|
_keyed_writes_override: Optional[bool] = None
|
|
95
97
|
|
|
96
98
|
|
|
99
|
+
def is_kv_record_url(url: str) -> bool:
|
|
100
|
+
"""A key-value store record URL (``.../key-value-stores/<id>/records/<key>``)."""
|
|
101
|
+
try:
|
|
102
|
+
path = httpx.URL(url).path
|
|
103
|
+
except Exception:
|
|
104
|
+
path = url
|
|
105
|
+
return '/key-value-stores/' in path and '/records/' in path
|
|
106
|
+
|
|
107
|
+
|
|
97
108
|
def _origin_of(url: str) -> str:
|
|
109
|
+
"""What is learned per API: its origin, separately for record PUTs and other keyed writes."""
|
|
98
110
|
try:
|
|
99
111
|
u = httpx.URL(url)
|
|
100
|
-
|
|
112
|
+
origin = f'{u.scheme}://{u.netloc.decode()}'
|
|
101
113
|
except Exception:
|
|
102
|
-
|
|
114
|
+
origin = url
|
|
115
|
+
return origin + '#kv-record' if is_kv_record_url(url) else origin
|
|
103
116
|
|
|
104
117
|
|
|
105
118
|
def api_dedupes_keyed_writes(url: Optional[str] = None) -> bool:
|
|
106
|
-
"""Whether the API at ``url`` (default: the last one seen) de-duplicates keyed writes.
|
|
119
|
+
"""Whether the API at ``url`` (default: the last one seen) de-duplicates keyed writes.
|
|
120
|
+
|
|
121
|
+
Record PUTs count as de-duplicated until a keyed record PUT was stored
|
|
122
|
+
without the support header: re-applying the same body to the same record
|
|
123
|
+
leaves the same record, so the worst case on an API that ignores the key
|
|
124
|
+
is one extra counted write, while not re-sending fails the run.
|
|
125
|
+
"""
|
|
107
126
|
if _keyed_writes_override is not None:
|
|
108
127
|
return _keyed_writes_override
|
|
109
128
|
if url is not None:
|
|
110
|
-
|
|
129
|
+
learned = _keyed_writes_deduped.get(_origin_of(url))
|
|
130
|
+
if learned is None and is_kv_record_url(url):
|
|
131
|
+
return True
|
|
132
|
+
return learned is True
|
|
111
133
|
return bool(_keyed_writes_deduped) and list(_keyed_writes_deduped.values())[-1] is True
|
|
112
134
|
|
|
113
135
|
|
|
@@ -537,6 +559,7 @@ class ApiClient:
|
|
|
537
559
|
*,
|
|
538
560
|
raise_for_status: bool = True,
|
|
539
561
|
idempotent: Optional[bool] = None,
|
|
562
|
+
idempotency_key: Optional[str] = None,
|
|
540
563
|
) -> httpx.Response:
|
|
541
564
|
return await self.request(
|
|
542
565
|
'PUT',
|
|
@@ -547,6 +570,7 @@ class ApiClient:
|
|
|
547
570
|
headers=headers,
|
|
548
571
|
raise_for_status=raise_for_status,
|
|
549
572
|
idempotent=idempotent,
|
|
573
|
+
idempotency_key=idempotency_key,
|
|
550
574
|
)
|
|
551
575
|
|
|
552
576
|
async def delete(
|
scrapely/storage/client.py
CHANGED
|
@@ -334,23 +334,29 @@ class ApiStorageClient:
|
|
|
334
334
|
|
|
335
335
|
path = kvs_record_path(store_id, key)
|
|
336
336
|
headers = {'Content-Type': content_type}
|
|
337
|
+
# One key for every attempt of this write: a re-send after a lost answer
|
|
338
|
+
# (edge 502/504, dropped connection) is applied and counted once.
|
|
339
|
+
idempotency_key = uuid.uuid4().hex
|
|
337
340
|
if isinstance(value, bytes):
|
|
338
341
|
await self.api_client.put(
|
|
339
342
|
path,
|
|
340
343
|
content=value,
|
|
341
344
|
headers=headers,
|
|
345
|
+
idempotency_key=idempotency_key,
|
|
342
346
|
)
|
|
343
347
|
elif isinstance(value, str) and content_type != 'application/json':
|
|
344
348
|
await self.api_client.put(
|
|
345
349
|
path,
|
|
346
350
|
content=value.encode('utf-8'),
|
|
347
351
|
headers=headers,
|
|
352
|
+
idempotency_key=idempotency_key,
|
|
348
353
|
)
|
|
349
354
|
else:
|
|
350
355
|
await self.api_client.put(
|
|
351
356
|
path,
|
|
352
357
|
json_data=value,
|
|
353
358
|
headers=headers,
|
|
359
|
+
idempotency_key=idempotency_key,
|
|
354
360
|
)
|
|
355
361
|
|
|
356
362
|
async def kvs_delete_record(self, store_id: str, key: str) -> None:
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
scrapely/__init__.py,sha256=
|
|
1
|
+
scrapely/__init__.py,sha256=cTs0rZD-AnrB6WLOzrhgGkh9XqmYj8Ihvqk80JFPjsM,9668
|
|
2
2
|
scrapely/_browser_locations.py,sha256=8QVVnkms2bDn3_LDYgYDR59MvFx8757l_JYnZ7xb3oI,145269
|
|
3
3
|
scrapely/actor.py,sha256=z-kL24Y6m98v6lKLOxGYCnFmhVhaHJ6c0uOdYZW_4ak,93514
|
|
4
4
|
scrapely/browser_configuration.py,sha256=jAZapNSHLFW05B96df4wNBtcyiiU9DA3ml0yTL1FwfA,5770
|
|
5
5
|
scrapely/cdp_retry.py,sha256=hdTJEAIKhApM7IOk1DrQEl8NoflSJSmqd4RbNJ2IsgI,8540
|
|
6
|
-
scrapely/charging.py,sha256=
|
|
6
|
+
scrapely/charging.py,sha256=xWYBFSr20UN5ySW0Vp1g0Dl6SppXN1y9qvMPQ1B0F1M,95615
|
|
7
7
|
scrapely/charging_client.py,sha256=-4q-Ww7gu9EZXpM9QIn1Vd4vwAXmgL_WEKqaQIx3ViA,3961
|
|
8
8
|
scrapely/configuration.py,sha256=ONnzw4mhtrY2jVvoVUQ48dMgmMWnx3Gv7fXrT4pY9z8,16919
|
|
9
9
|
scrapely/errors.py,sha256=V8JBCGSyqrbwMD9GDv6ahWhZ92n2cb8AWxkxth6mcI8,4062
|
|
10
10
|
scrapely/log.py,sha256=H3SoBvmNNRp8ChD9qv_J-hG0Nc5qDy3RldLQcTTgWa8,1635
|
|
11
11
|
scrapely/patch_browser_pool.py,sha256=eZck-JSOvCuXdoGQaX6xUsKVGzolDlB4uKiBSBrFyr8,42190
|
|
12
12
|
scrapely/pricing_types.py,sha256=yy2nXmCxL0BvUXRtWnznwhoInYeVdWObMRT5Fo0fc4I,5083
|
|
13
|
-
scrapely/proxy_configuration.py,sha256=
|
|
13
|
+
scrapely/proxy_configuration.py,sha256=YAg2xm8eK4B14vCRWsSJxgtMIxdDJXbM66pZDx-HShk,39018
|
|
14
14
|
scrapely/py.typed,sha256=XtWlywqUjc6x_M2gAXlDtYXbAsYkCjKZEjFyJvaHvm4,105
|
|
15
15
|
scrapely/solver.py,sha256=RVoLNJTxniTI5SzDRpUdxe0ajzy4qqjuEmToTL1GRzA,35863
|
|
16
16
|
scrapely/types.py,sha256=2yGedPfb5786UNqnlnRG5Cw4Pbyc4EPBiYeoU2kix3I,8812
|
|
@@ -19,20 +19,20 @@ scrapely/crypto/__init__.py,sha256=MbMPs7Eq_fGSVjOZxNts_JckDCUtxCVqdkHU0VCfN4M,4
|
|
|
19
19
|
scrapely/crypto/_secrets.py,sha256=-ETpq1yCMaW-mheTGedFaQGGhu_pGT9_VMdJ3--V1rk,7614
|
|
20
20
|
scrapely/events/__init__.py,sha256=XYk_qVzcw3jme9jErm9eMAEaMlj6MGpQ5UJfv7syzgc,1216
|
|
21
21
|
scrapely/events/_types.py,sha256=XslMIEAQOe7othqevnxO86c7i7AysbvDLHH0lep64QU,5993
|
|
22
|
-
scrapely/events/event_manager.py,sha256=
|
|
22
|
+
scrapely/events/event_manager.py,sha256=svqrMb3KtSLvWBBoDrL6ozXaBE-IlHrdrh-rFPDnveM,12988
|
|
23
23
|
scrapely/http/__init__.py,sha256=KGbINWi2WY6tN4CTM6c4-RIOQGL6iV3giNCYoGYJfWU,1949
|
|
24
24
|
scrapely/http/_capture.py,sha256=wtS3bl_hNjICgxg3Iu_aL4uniQ_6xzXk_XsxGzpoBLE,3024
|
|
25
|
-
scrapely/http/_client.py,sha256=
|
|
25
|
+
scrapely/http/_client.py,sha256=_JoW2u3NCpmBdfZbGyJAqrCTIkCBcxvnLPO1Cw0yeKc,48651
|
|
26
26
|
scrapely/http/_compat.py,sha256=si8V3SAb-FDBcdUKuQHMCthRjNR6_oFlg8rpIKyEv2g,4082
|
|
27
27
|
scrapely/http/_detect.py,sha256=NXMonGYv0jFwG7ONY6MPdhJadTRL2LbyPss-HVbE5J4,5433
|
|
28
|
-
scrapely/http/_headers.py,sha256=
|
|
29
|
-
scrapely/http/_options.py,sha256=
|
|
30
|
-
scrapely/http/_policy.py,sha256=
|
|
31
|
-
scrapely/http/_profiles.py,sha256=
|
|
28
|
+
scrapely/http/_headers.py,sha256=jJMYYb6SpcMmIQ5ETTLVZi2xSA40wW_pt8UxzP2EMMQ,16633
|
|
29
|
+
scrapely/http/_options.py,sha256=rEn_xYD93On-Fe0taWzDpf9c6_nTyKKUc-qMC-uGHGI,6461
|
|
30
|
+
scrapely/http/_policy.py,sha256=Je_XwW6aeTOr9B6ghaAPdg9eq5Nic7YK6CHUDUibBX0,8819
|
|
31
|
+
scrapely/http/_profiles.py,sha256=FUtXHHNQqbgRbMdodg_-IT6gMFsHcat49-L0JK5SvNM,9942
|
|
32
32
|
scrapely/http/_response.py,sha256=kKSLJClZcLq5wWhXrtcdswGJxap1DObsidVfzUzi0Yk,5076
|
|
33
33
|
scrapely/http/_stats.py,sha256=wi64avHSUnGnN-Irfw5bD3Fv2UvpCPi0fw4hJJR7fgQ,6190
|
|
34
34
|
scrapely/http/_transport.py,sha256=EzdL7NK0epIHKeX71Z0GLVHwkepd16DDoBkxCnfFrC4,13238
|
|
35
|
-
scrapely/http/profiles.json,sha256=
|
|
35
|
+
scrapely/http/profiles.json,sha256=D4gM25kQ-dUnAfSL_fG7AEyVGF4BCT4khF-J6_0R180,38009
|
|
36
36
|
scrapely/request_loaders/__init__.py,sha256=aNgr4pRt3MziUbU6UQ_bjTT3moMNvvyUsPeCJyMQcwU,196
|
|
37
37
|
scrapely/request_loaders/_scrapely_request_list.py,sha256=HGOWmQptWqBr1DGY2ZSZdQrSXtw9dmgSfxERF-3GPBE,7616
|
|
38
38
|
scrapely/scrapy/__init__.py,sha256=3W2DwT6KlqiEKhlUCIn73dqb0V21EasVqfGmlN0PK34,1289
|
|
@@ -53,8 +53,8 @@ scrapely/scrapy/pipelines/__init__.py,sha256=gGUSWQtVC-7SvhvXbkQs0JlxpfezRMsBRVr
|
|
|
53
53
|
scrapely/scrapy/pipelines/actor_dataset_push.py,sha256=6dL5Ulesqkjq3wOL2wnnGY0sC9gCoSUR9ouvBi5s5UA,924
|
|
54
54
|
scrapely/storage/__init__.py,sha256=yN2gqhmPf3EVIekJpvJvKmEAYaE5zUA9oyUr8a2HQRo,3840
|
|
55
55
|
scrapely/storage/_metadata.py,sha256=WWawBnVN0b1iK2K-SlxZXUc6czb5wXWf8iC_0tzkgAQ,4764
|
|
56
|
-
scrapely/storage/api_client.py,sha256=
|
|
57
|
-
scrapely/storage/client.py,sha256=
|
|
56
|
+
scrapely/storage/api_client.py,sha256=w74FYCdquGgxAZB6abFziaHEDBXUSaDdnO1t4Z9JIiU,24107
|
|
57
|
+
scrapely/storage/client.py,sha256=bMQLDmdXp8kRJ2c9JjVd1kOqoqxXxKqCvfCVPyr17Xk,23739
|
|
58
58
|
scrapely/storage/patch_storage.py,sha256=6vOW5yjnZ8eiqb4XeF9dwa7rBQoa6XayyftVCrZcJ1w,8743
|
|
59
59
|
scrapely/storage/scrapely_dataset_client.py,sha256=u5dnXxe1BAsq7rh-haR5W1ChE7UZ_k7B9ShR8YtwFkg,10695
|
|
60
60
|
scrapely/storage/scrapely_kvs_client.py,sha256=ItQhe2rcSl20NkSmegfqHiRyKncH4Qg9BjQVpF6jHfk,7403
|
|
@@ -68,10 +68,10 @@ scrapely/storage/_file_system/_key_value_store_client.py,sha256=1C22qY-RWlARzMyW
|
|
|
68
68
|
scrapely/storage/_file_system/_storage_client.py,sha256=QHa3IULEb6AmgRFwpBqHD2OUTmtA_gl3lId-j6QdSFo,2027
|
|
69
69
|
scrapely/storage_clients/__init__.py,sha256=q2pC-Rwe-BfHl018OyN_P0EDSx4-hNdxbk3pOCLLd4U,618
|
|
70
70
|
scrapely/storages/__init__.py,sha256=kDNtd9VodjdHd4u7GzruJo0aB2HeiBN2_-HrkeqjmFg,139
|
|
71
|
-
scrapely_python-0.1.
|
|
72
|
-
scrapely_python-0.1.
|
|
73
|
-
scrapely_python-0.1.
|
|
74
|
-
scrapely_python-0.1.
|
|
75
|
-
scrapely_python-0.1.
|
|
76
|
-
scrapely_python-0.1.
|
|
77
|
-
scrapely_python-0.1.
|
|
71
|
+
scrapely_python-0.1.40.dist-info/licenses/LICENSE,sha256=pQGY0XwjIYh-a2HfawxHq1en53bXkFkDj2Q70jKWHr4,1088
|
|
72
|
+
scrapely_python-0.1.40.dist-info/licenses/LICENSE-APACHE,sha256=N8m0FzMq8i4UbTgXZvKEZJSD-27OF8pWZYS_atz4kTU,11355
|
|
73
|
+
scrapely_python-0.1.40.dist-info/licenses/NOTICE,sha256=1ltrzKk2ez7x2BPNpXeyvOlTty5AfABYDBlwNilpORk,788
|
|
74
|
+
scrapely_python-0.1.40.dist-info/METADATA,sha256=Dqdbn37BPsue4GTs3scUAmHGJUjmHGRDOX9ByjWuah8,4914
|
|
75
|
+
scrapely_python-0.1.40.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
76
|
+
scrapely_python-0.1.40.dist-info/top_level.txt,sha256=T4B9UMrs4q99QSAFyD1zdXhl40KnLhBKqrDkPhOTsmY,9
|
|
77
|
+
scrapely_python-0.1.40.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
{scrapely_python-0.1.38.dist-info → scrapely_python-0.1.40.dist-info}/licenses/LICENSE-APACHE
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|