scrapely-python 0.1.38__tar.gz → 0.1.40__tar.gz
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_python-0.1.38 → scrapely_python-0.1.40}/CHANGELOG.md +18 -0
- {scrapely_python-0.1.38/src/scrapely_python.egg-info → scrapely_python-0.1.40}/PKG-INFO +1 -1
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/pyproject.toml +1 -1
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/__init__.py +1 -1
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/charging.py +2 -1
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/events/event_manager.py +8 -6
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/http/_client.py +97 -14
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/http/_headers.py +72 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/http/_options.py +4 -4
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/http/_policy.py +1 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/http/_profiles.py +5 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/http/profiles.json +397 -46
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/proxy_configuration.py +31 -2
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/storage/api_client.py +29 -5
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/storage/client.py +6 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40/src/scrapely_python.egg-info}/PKG-INFO +1 -1
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/LICENSE +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/LICENSE-APACHE +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/MANIFEST.in +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/NOTICE +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/README.md +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/setup.cfg +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/_browser_locations.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/actor.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/browser_configuration.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/cdp_retry.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/charging_client.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/configuration.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/crypto/__init__.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/crypto/_secrets.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/errors.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/events/__init__.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/events/_types.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/http/__init__.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/http/_capture.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/http/_compat.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/http/_detect.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/http/_response.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/http/_stats.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/http/_transport.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/log.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/patch_browser_pool.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/pricing_types.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/py.typed +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/request_loaders/__init__.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/scrapy/__init__.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/scrapy/_actor_runner.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/scrapy/_async_thread.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/scrapy/_logging_config.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/scrapy/_serialization.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/scrapy/_warnings.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/scrapy/extensions/__init__.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/scrapy/extensions/_graceful_stop.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/scrapy/extensions/_httpcache.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/scrapy/middlewares/__init__.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/scrapy/middlewares/apify_proxy.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/scrapy/pipelines/__init__.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/scrapy/pipelines/actor_dataset_push.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/scrapy/requests.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/scrapy/scheduler.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/scrapy/utils.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/solver.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/storage/__init__.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/storage/_file_system/__init__.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/storage/_metadata.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/storage/patch_storage.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/storage/scrapely_dataset_client.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/storage/scrapely_rq_client.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/storage/scrapely_storage_client.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/storage/types.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/storage/utils.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/storage_clients/__init__.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/storages/__init__.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/types.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/webhook.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely_python.egg-info/SOURCES.txt +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely_python.egg-info/requires.txt +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely_python.egg-info/top_level.txt +0 -0
|
@@ -4,6 +4,24 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.1.40] - 2026-10-28
|
|
8
|
+
|
|
9
|
+
0.1.39 was never published to PyPI. This release includes the 0.1.39 change below, plus these fixes to `scrapely.http` from the fingerprint test on staging (2026-10-27):
|
|
10
|
+
|
|
11
|
+
- **Accept-Language matches the exit IP.** Before, a client without a proxy country sent `en-US` on every identity: 8 of 20 residential sessions did so from Brazil, France, Spain, Mexico or Taiwan.
|
|
12
|
+
- **Residential platform proxy without a country:** each identity now picks an exit country, weighted like real web traffic, asks the proxy for it and sends that country's language.
|
|
13
|
+
- **Proxy with a country:** each identity gets one of the language setups common there (for example `de-DE,de;q=0.9` or `de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7`).
|
|
14
|
+
- **Several `locales`:** each identity uses one of them, not all of them in one header. With a residential proxy without a country, the exit follows the locale's country.
|
|
15
|
+
- **Unchanged:** no proxy, your own proxy URLs and the `DATACENTER` group (US exits only) keep `en-US,en;q=0.9`.
|
|
16
|
+
- `ProxyConfiguration.new_url()` takes an optional `country_code` for one URL (residential platform proxy without a country only). Sessions expose `accept_language` and `country`.
|
|
17
|
+
- **No more `400` on `https://host?q`.** A URL with a query and no path is sent with the path `/`. wreq 0.13 sent `:path: ?q` over HTTP/2, which Cloudflare and nginx reject.
|
|
18
|
+
- **Chrome 155 and 156.** The default profiles are Chrome 156, 155, 154 and 153, weighted 55 / 30 / 10 / 5. 155 and 156 use the Chrome 154 TLS and HTTP/2 profile, which is the same as real Chrome 156's, with their own User-Agent, `sec-ch-ua` and Accept (with `image/jxl`, which real 155 and 156 send).
|
|
19
|
+
- **Firefox 153 ESR.** Added Firefox 153 (ESR) next to 152. Both match real Firefox 153.6 ESR and 152 builds byte for byte in the TLS ClientHello and HTTP/2 settings, and are now marked verified.
|
|
20
|
+
|
|
21
|
+
## [0.1.39] - 2026-10-27 (not published)
|
|
22
|
+
|
|
23
|
+
- A key-value store record write (`Actor.set_value`, `KeyValueStore.set_value`) is no longer lost to one failed answer. Each write now sends an `Idempotency-Key`, and when its answer is lost (a `502`/`504` page from the edge or load balancer, a dropped connection, a timeout) it is sent again with the same key; the API applies and counts it once. Before, the write was never re-sent, so one such page on the final `OUTPUT` write failed the whole run. An API that answers a keyed write without `X-Scrapely-Idempotency` (an older server) gets no re-sends, as before.
|
|
24
|
+
|
|
7
25
|
## [0.1.38] - 2026-10-27
|
|
8
26
|
|
|
9
27
|
0.1.37 was never published to PyPI. This release includes everything listed under 0.1.37 below, plus these changes to `scrapely.http`:
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "scrapely-python"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.40"
|
|
8
8
|
description = "Scrapely Python SDK - A toolkit for building Actors on the Scrapely platform"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT AND Apache-2.0"
|
|
@@ -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:
|
|
@@ -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)
|
|
@@ -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
|
|
|
@@ -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)
|
|
@@ -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.
|
|
@@ -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:
|
|
@@ -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(
|