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 +1 -1
- scrapely/charging.py +2 -1
- scrapely/events/event_manager.py +8 -6
- scrapely/http/__init__.py +57 -0
- scrapely/http/_capture.py +97 -0
- scrapely/http/_client.py +1206 -0
- scrapely/http/_compat.py +92 -0
- scrapely/http/_detect.py +138 -0
- scrapely/http/_headers.py +394 -0
- scrapely/http/_options.py +169 -0
- scrapely/http/_policy.py +234 -0
- scrapely/http/_profiles.py +250 -0
- scrapely/http/_response.py +157 -0
- scrapely/http/_stats.py +163 -0
- scrapely/http/_transport.py +343 -0
- scrapely/http/profiles.json +951 -0
- scrapely/proxy_configuration.py +31 -2
- scrapely/storage/api_client.py +29 -5
- scrapely/storage/client.py +6 -0
- {scrapely_python-0.1.36.dist-info → scrapely_python-0.1.40.dist-info}/METADATA +20 -2
- {scrapely_python-0.1.36.dist-info → scrapely_python-0.1.40.dist-info}/RECORD +26 -13
- {scrapely_python-0.1.36.dist-info → scrapely_python-0.1.40.dist-info}/WHEEL +0 -0
- {scrapely_python-0.1.36.dist-info → scrapely_python-0.1.40.dist-info}/licenses/LICENSE +0 -0
- {scrapely_python-0.1.36.dist-info → scrapely_python-0.1.40.dist-info}/licenses/LICENSE-APACHE +0 -0
- {scrapely_python-0.1.36.dist-info → scrapely_python-0.1.40.dist-info}/licenses/NOTICE +0 -0
- {scrapely_python-0.1.36.dist-info → scrapely_python-0.1.40.dist-info}/top_level.txt +0 -0
scrapely/__init__.py
CHANGED
scrapely/charging.py
CHANGED
|
@@ -308,7 +308,8 @@ class ChargingManager:
|
|
|
308
308
|
|
|
309
309
|
def _remaining_budget_usd(self) -> Decimal:
|
|
310
310
|
"""maxTotalChargeUsd minus this SDK's charges and the platform's other run spend."""
|
|
311
|
-
|
|
311
|
+
if self._max_total_charge_usd is None:
|
|
312
|
+
raise RuntimeError('maxTotalChargeUsd is not set')
|
|
312
313
|
return self._max_total_charge_usd - self._total_charged_usd - self._platform_other_usd
|
|
313
314
|
|
|
314
315
|
def _sync_platform_budget(self) -> None:
|
scrapely/events/event_manager.py
CHANGED
|
@@ -122,13 +122,15 @@ class ScrapelyEventManager(CrawleeEventManager):
|
|
|
122
122
|
# Run tasks but don't await them
|
|
123
123
|
ws_url = self._configuration.get('actor_events_ws_url')
|
|
124
124
|
if ws_url:
|
|
125
|
-
#
|
|
126
|
-
# This matches the Node SDK behavior in events-client.ts
|
|
125
|
+
# Authenticate with the token in a header and the runId in the URL (URLs end up in access
|
|
126
|
+
# logs). This matches the Node SDK behavior in events-client.ts
|
|
127
127
|
token = self._configuration.get('token')
|
|
128
128
|
run_id = self._configuration.get('actor_run_id')
|
|
129
|
+
headers: dict[str, str] = {}
|
|
129
130
|
if token and run_id:
|
|
130
131
|
separator = '&' if '?' in ws_url else '?'
|
|
131
|
-
ws_url = f"{ws_url}{separator}{urlencode({'
|
|
132
|
+
ws_url = f"{ws_url}{separator}{urlencode({'runId': run_id})}"
|
|
133
|
+
headers['Authorization'] = f'Bearer {token}'
|
|
132
134
|
logger.debug(f'Connecting to platform events websocket with runId={run_id}')
|
|
133
135
|
else:
|
|
134
136
|
logger.warning(
|
|
@@ -139,7 +141,7 @@ class ScrapelyEventManager(CrawleeEventManager):
|
|
|
139
141
|
# Connect in the background without blocking init, matching the Node SDK: a slow or failed
|
|
140
142
|
# connection is retried and must not crash the Actor.
|
|
141
143
|
self._process_platform_messages_task = asyncio.create_task(
|
|
142
|
-
self._process_platform_messages(ws_url)
|
|
144
|
+
self._process_platform_messages(ws_url, headers)
|
|
143
145
|
)
|
|
144
146
|
else:
|
|
145
147
|
logger.debug(
|
|
@@ -170,7 +172,7 @@ class ScrapelyEventManager(CrawleeEventManager):
|
|
|
170
172
|
|
|
171
173
|
await super().__aexit__(exc_type, exc_value, exc_traceback)
|
|
172
174
|
|
|
173
|
-
async def _process_platform_messages(self, ws_url: str) -> None:
|
|
175
|
+
async def _process_platform_messages(self, ws_url: str, headers: Optional[dict[str, str]] = None) -> None:
|
|
174
176
|
"""Process messages from the platform WebSocket with reconnect logic."""
|
|
175
177
|
try:
|
|
176
178
|
import websockets.asyncio.client
|
|
@@ -189,7 +191,7 @@ class ScrapelyEventManager(CrawleeEventManager):
|
|
|
189
191
|
try:
|
|
190
192
|
# Used as an async iterator, `connect` reconnects with exponential backoff on failed connection attempts,
|
|
191
193
|
# including the first one (timeouts, network errors and 5xx responses are retried).
|
|
192
|
-
async for websocket in websockets.asyncio.client.connect(ws_url):
|
|
194
|
+
async for websocket in websockets.asyncio.client.connect(ws_url, additional_headers=headers or None):
|
|
193
195
|
self._platform_events_websocket = websocket
|
|
194
196
|
if self._connected_to_platform_websocket and not self._connected_to_platform_websocket.done():
|
|
195
197
|
self._connected_to_platform_websocket.set_result(True)
|
|
@@ -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()
|