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.
Files changed (84) hide show
  1. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/CHANGELOG.md +18 -0
  2. {scrapely_python-0.1.38/src/scrapely_python.egg-info → scrapely_python-0.1.40}/PKG-INFO +1 -1
  3. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/pyproject.toml +1 -1
  4. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/__init__.py +1 -1
  5. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/charging.py +2 -1
  6. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/events/event_manager.py +8 -6
  7. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/http/_client.py +97 -14
  8. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/http/_headers.py +72 -0
  9. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/http/_options.py +4 -4
  10. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/http/_policy.py +1 -0
  11. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/http/_profiles.py +5 -0
  12. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/http/profiles.json +397 -46
  13. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/proxy_configuration.py +31 -2
  14. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/storage/api_client.py +29 -5
  15. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/storage/client.py +6 -0
  16. {scrapely_python-0.1.38 → scrapely_python-0.1.40/src/scrapely_python.egg-info}/PKG-INFO +1 -1
  17. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/LICENSE +0 -0
  18. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/LICENSE-APACHE +0 -0
  19. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/MANIFEST.in +0 -0
  20. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/NOTICE +0 -0
  21. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/README.md +0 -0
  22. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/setup.cfg +0 -0
  23. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/_browser_locations.py +0 -0
  24. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/actor.py +0 -0
  25. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/browser_configuration.py +0 -0
  26. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/cdp_retry.py +0 -0
  27. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/charging_client.py +0 -0
  28. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/configuration.py +0 -0
  29. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/crypto/__init__.py +0 -0
  30. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/crypto/_secrets.py +0 -0
  31. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/errors.py +0 -0
  32. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/events/__init__.py +0 -0
  33. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/events/_types.py +0 -0
  34. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/http/__init__.py +0 -0
  35. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/http/_capture.py +0 -0
  36. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/http/_compat.py +0 -0
  37. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/http/_detect.py +0 -0
  38. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/http/_response.py +0 -0
  39. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/http/_stats.py +0 -0
  40. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/http/_transport.py +0 -0
  41. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/log.py +0 -0
  42. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/patch_browser_pool.py +0 -0
  43. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/pricing_types.py +0 -0
  44. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/py.typed +0 -0
  45. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/request_loaders/__init__.py +0 -0
  46. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
  47. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/scrapy/__init__.py +0 -0
  48. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/scrapy/_actor_runner.py +0 -0
  49. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/scrapy/_async_thread.py +0 -0
  50. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/scrapy/_logging_config.py +0 -0
  51. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/scrapy/_serialization.py +0 -0
  52. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/scrapy/_warnings.py +0 -0
  53. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/scrapy/extensions/__init__.py +0 -0
  54. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/scrapy/extensions/_graceful_stop.py +0 -0
  55. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/scrapy/extensions/_httpcache.py +0 -0
  56. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/scrapy/middlewares/__init__.py +0 -0
  57. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/scrapy/middlewares/apify_proxy.py +0 -0
  58. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/scrapy/pipelines/__init__.py +0 -0
  59. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/scrapy/pipelines/actor_dataset_push.py +0 -0
  60. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/scrapy/requests.py +0 -0
  61. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/scrapy/scheduler.py +0 -0
  62. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/scrapy/utils.py +0 -0
  63. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/solver.py +0 -0
  64. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/storage/__init__.py +0 -0
  65. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/storage/_file_system/__init__.py +0 -0
  66. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
  67. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
  68. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/storage/_metadata.py +0 -0
  69. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/storage/patch_storage.py +0 -0
  70. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/storage/scrapely_dataset_client.py +0 -0
  71. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
  72. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/storage/scrapely_rq_client.py +0 -0
  73. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/storage/scrapely_storage_client.py +0 -0
  74. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
  75. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/storage/types.py +0 -0
  76. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/storage/utils.py +0 -0
  77. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/storage_clients/__init__.py +0 -0
  78. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/storages/__init__.py +0 -0
  79. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/types.py +0 -0
  80. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely/webhook.py +0 -0
  81. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely_python.egg-info/SOURCES.txt +0 -0
  82. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
  83. {scrapely_python-0.1.38 → scrapely_python-0.1.40}/src/scrapely_python.egg-info/requires.txt +0 -0
  84. {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`:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapely-python
3
- Version: 0.1.38
3
+ Version: 0.1.40
4
4
  Summary: Scrapely Python SDK - A toolkit for building Actors on the Scrapely platform
5
5
  Author-email: Scrapely <hello@scrape.ly>
6
6
  License-Expression: MIT AND Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "scrapely-python"
7
- version = "0.1.38"
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"
@@ -227,7 +227,7 @@ if TYPE_CHECKING: # Static analysers and IDEs see the lazy names as plain impor
227
227
  set_api_storage,
228
228
  )
229
229
 
230
- __version__ = '0.1.38'
230
+ __version__ = '0.1.40'
231
231
  __all__ = [
232
232
  # Core
233
233
  'Actor',
@@ -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
- assert self._max_total_charge_usd is not None
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
- # Append authentication parameters to the WebSocket URL
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({'token': token, 'runId': run_id})}"
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 _new_identity(self, client: Any, profile: BrowserProfile, session_id: str, proxy_url: Optional[str]) -> Identity:
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
- url = await self.proxy_configuration.new_url(session_id=session_id)
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
- proxy_url = await self._proxy_url(sid)
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
- proxy_url = self._proxy_url(sid)
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 taken from
103
- the proxy country.
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"]``; the first is the primary language. Wins over
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(