scrapely-python 0.1.36__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.36'
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)
@@ -0,0 +1,57 @@
1
+ """scrapely.http: an HTTP client that looks like a real browser and handles blocks.
2
+
3
+ Every request goes out with a current browser fingerprint whose TLS, HTTP/2,
4
+ User-Agent, client hints and header order all come from one backend profile.
5
+ Requests use identities (profile + proxy session + cookie jar) that are
6
+ retired together when a site blocks them; the request is retried on a fresh
7
+ identity at once, without waiting: the block was tied to the old identity.
8
+ ``polite=True`` turns on Retry-After, per-host backoff and a slow mode for
9
+ actors that must be gentle with a site.
10
+
11
+ Quick start::
12
+
13
+ from scrapely import Actor
14
+ from scrapely.http import AsyncClient
15
+
16
+ proxy = await Actor.create_proxy_configuration(groups=["RESIDENTIAL"], country_code="DE")
17
+ async with AsyncClient(proxy_configuration=proxy) as http:
18
+ r = await http.get("https://example.com/")
19
+ data = (await http.get("https://example.com/api/items", kind="json")).json()
20
+
21
+ Migrating from curl_cffi is one line: ``from scrapely.http import AsyncSession``.
22
+ """
23
+
24
+ from ._client import AsyncClient, AsyncHttpSession, Client, HttpSession
25
+ from ._compat import AsyncSession, Session
26
+ from ._detect import detect_block
27
+ from ._options import FingerprintOptions
28
+ from ._policy import BackoffPolicy
29
+ from ._profiles import BrowserProfile, BrowserSpec, NoMatchingProfile, load_table
30
+ from ._response import Headers, HTTPError, Response
31
+ from ._stats import HttpStats
32
+ from ._transport import TransportError
33
+
34
+ # curl_cffi / requests-style name for transport errors.
35
+ RequestsError = TransportError
36
+
37
+ __all__ = [
38
+ 'AsyncClient',
39
+ 'AsyncHttpSession',
40
+ 'AsyncSession',
41
+ 'BackoffPolicy',
42
+ 'BrowserProfile',
43
+ 'BrowserSpec',
44
+ 'Client',
45
+ 'FingerprintOptions',
46
+ 'HTTPError',
47
+ 'Headers',
48
+ 'HttpSession',
49
+ 'HttpStats',
50
+ 'NoMatchingProfile',
51
+ 'RequestsError',
52
+ 'Response',
53
+ 'Session',
54
+ 'TransportError',
55
+ 'detect_block',
56
+ 'load_table',
57
+ ]
@@ -0,0 +1,97 @@
1
+ """Read the headers a backend profile really sends, over loopback HTTP/1.1.
2
+
3
+ Used by the profile-table tests and to regenerate the ``headers`` of each
4
+ profile in ``profiles.json`` after a backend upgrade::
5
+
6
+ python -m scrapely.http._capture > /tmp/native.json
7
+
8
+ No network access: a one-shot server on 127.0.0.1 records the request.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import socket
15
+ import sys
16
+ import threading
17
+ from typing import Dict, List, Tuple
18
+
19
+
20
+ class LoopbackServer:
21
+ """Accepts connections on 127.0.0.1 and records each request's header lines in order."""
22
+
23
+ def __init__(self, response: bytes = b'HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok') -> None:
24
+ self.response = response
25
+ self.requests: List[str] = []
26
+ self._sock = socket.socket()
27
+ self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
28
+ self._sock.bind(('127.0.0.1', 0))
29
+ self._sock.listen(64)
30
+ self.port = self._sock.getsockname()[1]
31
+ threading.Thread(target=self._loop, daemon=True).start()
32
+
33
+ @property
34
+ def url(self) -> str:
35
+ return f'http://127.0.0.1:{self.port}'
36
+
37
+ def _loop(self) -> None:
38
+ while True:
39
+ try:
40
+ conn, _ = self._sock.accept()
41
+ except OSError:
42
+ return
43
+ threading.Thread(target=self._one, args=(conn,), daemon=True).start()
44
+
45
+ def _one(self, conn: socket.socket) -> None:
46
+ data = b''
47
+ try:
48
+ while b'\r\n\r\n' not in data:
49
+ chunk = conn.recv(65536)
50
+ if not chunk:
51
+ break
52
+ data += chunk
53
+ self.requests.append(data.decode('latin-1'))
54
+ conn.sendall(self.response)
55
+ finally:
56
+ conn.close()
57
+
58
+ def close(self) -> None:
59
+ self._sock.close()
60
+
61
+
62
+ def parse_headers(raw: str) -> List[Tuple[str, str]]:
63
+ head = raw.split('\r\n\r\n', 1)[0]
64
+ out = []
65
+ for line in head.split('\r\n')[1:]:
66
+ if ':' in line:
67
+ k, v = line.split(':', 1)
68
+ out.append((k.strip().lower(), v.strip()))
69
+ return out
70
+
71
+
72
+ def native_headers(wreq_profile: str, wreq_platform: str) -> List[Tuple[str, str]]:
73
+ """Header (name, value) pairs wreq sends for a profile, in its native order (host excluded)."""
74
+ import wreq
75
+ from wreq.blocking import Client
76
+
77
+ srv = LoopbackServer()
78
+ try:
79
+ emu = wreq.Emulation(profile=getattr(wreq.Profile, wreq_profile), platform=getattr(wreq.Platform, wreq_platform))
80
+ Client(emulation=emu).get(srv.url + '/').text()
81
+ return [(k, v) for k, v in parse_headers(srv.requests[-1]) if k != 'host']
82
+ finally:
83
+ srv.close()
84
+
85
+
86
+ def main() -> None:
87
+ from ._profiles import load_table
88
+
89
+ out: Dict[str, Dict[str, str]] = {}
90
+ for p in load_table().profiles:
91
+ out[p.id] = dict(native_headers(p.wreq_profile, p.wreq_platform))
92
+ json.dump(out, sys.stdout, indent=2)
93
+ sys.stdout.write('\n')
94
+
95
+
96
+ if __name__ == '__main__':
97
+ main()