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 +1 -1
- scrapely/http/__init__.py +57 -0
- scrapely/http/_capture.py +97 -0
- scrapely/http/_client.py +1123 -0
- scrapely/http/_compat.py +92 -0
- scrapely/http/_detect.py +138 -0
- scrapely/http/_headers.py +322 -0
- scrapely/http/_options.py +169 -0
- scrapely/http/_policy.py +233 -0
- scrapely/http/_profiles.py +245 -0
- scrapely/http/_response.py +157 -0
- scrapely/http/_stats.py +163 -0
- scrapely/http/_transport.py +343 -0
- scrapely/http/profiles.json +600 -0
- scrapely/storage/api_client.py +8 -6
- {scrapely_python-0.1.35.dist-info → scrapely_python-0.1.38.dist-info}/METADATA +20 -2
- {scrapely_python-0.1.35.dist-info → scrapely_python-0.1.38.dist-info}/RECORD +22 -9
- {scrapely_python-0.1.35.dist-info → scrapely_python-0.1.38.dist-info}/WHEEL +0 -0
- {scrapely_python-0.1.35.dist-info → scrapely_python-0.1.38.dist-info}/licenses/LICENSE +0 -0
- {scrapely_python-0.1.35.dist-info → scrapely_python-0.1.38.dist-info}/licenses/LICENSE-APACHE +0 -0
- {scrapely_python-0.1.35.dist-info → scrapely_python-0.1.38.dist-info}/licenses/NOTICE +0 -0
- {scrapely_python-0.1.35.dist-info → scrapely_python-0.1.38.dist-info}/top_level.txt +0 -0
scrapely/__init__.py
CHANGED
|
@@ -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()
|