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 CHANGED
@@ -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',
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
- 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)
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 _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
 
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 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.
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:
@@ -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(
@@ -2,7 +2,7 @@
2
2
  "schema": 1,
3
3
  "backend": "wreq",
4
4
  "backend_version": "0.13.0",
5
- "generated": "2026-10-27",
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": "chrome154-win",
224
+ "id": "chrome156-win",
225
225
  "family": "chrome",
226
- "version": 154,
227
- "version_label": "154",
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.39,
234
- "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",
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": "\"Chromium\";v=\"154\", \"Google Chrome\";v=\"154\", \"Not A(Brand\";v=\"99\"",
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/154.0.0.0 Safari/537.36",
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": "chrome153-win",
257
+ "id": "chrome156-mac",
253
258
  "family": "chrome",
254
- "version": 153,
255
- "version_label": "153",
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": "Chrome153",
329
+ "wreq_profile": "Chrome154",
259
330
  "wreq_platform": "Windows",
260
331
  "verified": true,
261
- "weight": 0.26,
262
- "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",
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=\"153\", \"Not_A Brand\";v=\"8\", \"Chromium\";v=\"153\"",
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/153.0.0.0 Safari/537.36",
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": "chrome154-mac",
356
+ "id": "chrome155-mac",
281
357
  "family": "chrome",
282
- "version": 154,
283
- "version_label": "154",
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.162,
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": "\"macOS\"",
436
+ "sec-ch-ua-platform": "\"Windows\"",
295
437
  "upgrade-insecure-requests": "1",
296
- "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",
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": "chrome153-mac",
450
+ "id": "chrome154-mac",
309
451
  "family": "chrome",
310
- "version": 153,
311
- "version_label": "153",
452
+ "version": 154,
453
+ "version_label": "154",
312
454
  "os": "macos",
313
455
  "device": "desktop",
314
- "wreq_profile": "Chrome153",
456
+ "wreq_profile": "Chrome154",
315
457
  "wreq_platform": "MacOS",
316
458
  "verified": true,
317
- "weight": 0.108,
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": "\"Google Chrome\";v=\"153\", \"Not_A Brand\";v=\"8\", \"Chromium\";v=\"153\"",
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/153.0.0.0 Safari/537.36",
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.048,
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.032,
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.5,
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": false,
485
- "weight": 0.04,
486
- "note": "Akamai H2 and header order equal real Firefox 159; key-share groups and ciphers differ",
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": false,
511
- "weight": 0.01,
512
- "note": "Akamai H2 and header order equal real Firefox 159; key-share groups and ciphers differ",
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": false,
537
- "weight": 0.01,
538
- "note": "Akamai H2 and header order equal real Firefox 159; key-share groups and ciphers differ",
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
+ }
@@ -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
@@ -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
- return f'{u.scheme}://{u.netloc.decode()}'
112
+ origin = f'{u.scheme}://{u.netloc.decode()}'
101
113
  except Exception:
102
- return url
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
- return _keyed_writes_deduped.get(_origin_of(url)) is True
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(
@@ -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,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
@@ -1,16 +1,16 @@
1
- scrapely/__init__.py,sha256=FSwal-iDUoXIor_yZBy_p_Kq_QXGti9j9dnDZhOdNng,9668
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=osi2Ys99IyIJwFj0eRtV_RshokPs4mA2CE7oftNbN58,95559
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=LCKXCV5huSba2rwT_QnOv36tQJYgqRoHsfvxDjjnMEw,37447
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=9x3VqNy1gBi5tNQRv_1KptCRJQ9QnPwCDLT7JdlvjIM,12774
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=o65Mb4OywW7gTtBnvE7g9aTojaXwhwhHkS3CmmUewuU,44585
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=CeYTaXYVDCoC6n56vqvVDfmAIS8fhHQWWRcmpXWr1cA,13137
29
- scrapely/http/_options.py,sha256=FAxjamCMJildHCQTxze7-3-QXPBE_BpBUyv_EPyzsmo,6418
30
- scrapely/http/_policy.py,sha256=jWC287sFtdpL8ZgyOMNUxbhf9lURRHcJuyCsbVsIVOE,8732
31
- scrapely/http/_profiles.py,sha256=b52ZXYvwSIVnGww6kqT6GCmq4HBFkGZJcHw7YM42EJ0,9542
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=8PP2SbbYI-bjPfS5rHX-TZQNo1dc6DQ8RCyQ8xl9UKQ,20954
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=61G2MIYlTnWzEuA4RRmBapBF0eGLNmdhrMzefciQuuo,22997
57
- scrapely/storage/client.py,sha256=VKieti-7RbEPGS9JlQS_vCUM8yD7GcFdjs8oSbKG7K0,23394
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.38.dist-info/licenses/LICENSE,sha256=pQGY0XwjIYh-a2HfawxHq1en53bXkFkDj2Q70jKWHr4,1088
72
- scrapely_python-0.1.38.dist-info/licenses/LICENSE-APACHE,sha256=N8m0FzMq8i4UbTgXZvKEZJSD-27OF8pWZYS_atz4kTU,11355
73
- scrapely_python-0.1.38.dist-info/licenses/NOTICE,sha256=1ltrzKk2ez7x2BPNpXeyvOlTty5AfABYDBlwNilpORk,788
74
- scrapely_python-0.1.38.dist-info/METADATA,sha256=s2UUbg1otzuMt4_-Bfw8bKA70ofUgwaQts8Ry_4b6t8,4914
75
- scrapely_python-0.1.38.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
76
- scrapely_python-0.1.38.dist-info/top_level.txt,sha256=T4B9UMrs4q99QSAFyD1zdXhl40KnLhBKqrDkPhOTsmY,9
77
- scrapely_python-0.1.38.dist-info/RECORD,,
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,,