scrapely-python 0.1.35__py3-none-any.whl → 0.1.38__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.35'
230
+ __version__ = '0.1.38'
231
231
  __all__ = [
232
232
  # Core
233
233
  'Actor',
@@ -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()