nettle-html 0.5.0__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.
- nettle/__init__.py +123 -0
- nettle/cdp.py +521 -0
- nettle/clean.py +90 -0
- nettle/compat.py +32 -0
- nettle/css.py +539 -0
- nettle/discover.py +225 -0
- nettle/exceptions.py +36 -0
- nettle/extract.py +168 -0
- nettle/format.py +137 -0
- nettle/http.py +468 -0
- nettle/network.py +533 -0
- nettle/nodes.py +462 -0
- nettle/parse.py +502 -0
- nettle/py.typed +0 -0
- nettle/query.py +149 -0
- nettle/registry.py +226 -0
- nettle/serialize.py +81 -0
- nettle/soup.py +127 -0
- nettle/table.py +155 -0
- nettle/text.py +240 -0
- nettle/urls.py +285 -0
- nettle_html-0.5.0.dist-info/METADATA +278 -0
- nettle_html-0.5.0.dist-info/RECORD +26 -0
- nettle_html-0.5.0.dist-info/WHEEL +5 -0
- nettle_html-0.5.0.dist-info/licenses/LICENSE +21 -0
- nettle_html-0.5.0.dist-info/top_level.txt +1 -0
nettle/__init__.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Nettle — pure-Python HTML toolkit for clean scrape pipelines.
|
|
2
|
+
|
|
3
|
+
Stdlib only. Parse, CSS-select, clean text, declarative extract, tables,
|
|
4
|
+
URL discovery, embedded-JSON sniffing, and direct HTTP to any endpoint.
|
|
5
|
+
|
|
6
|
+
from nettle import parse, fetch, request, call_endpoint
|
|
7
|
+
|
|
8
|
+
# scrape HTML
|
|
9
|
+
doc = parse(html)
|
|
10
|
+
data = doc.extract({...})
|
|
11
|
+
|
|
12
|
+
# you already have an endpoint — just call it (any method, any path)
|
|
13
|
+
request("POST", "https://shop.example/catalog/load", json={"q": "shoes"})
|
|
14
|
+
call_endpoint("https://shop.example/items/42", "DELETE")
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from .nodes import Comment, Document, Element, Node, Text
|
|
18
|
+
from .parse import parse, detect_charset
|
|
19
|
+
from .soup import Nettle
|
|
20
|
+
from .text import (
|
|
21
|
+
clean_text,
|
|
22
|
+
collapse_ws,
|
|
23
|
+
decode_entities,
|
|
24
|
+
normalize_unicode,
|
|
25
|
+
remove_invisible,
|
|
26
|
+
strip_noise,
|
|
27
|
+
)
|
|
28
|
+
from .extract import fields, records, table, lists, links, meta, values
|
|
29
|
+
from .clean import CleanPipeline, clean_tree
|
|
30
|
+
from .format import to_json, to_csv, to_tsv, to_dicts, pretty, write_json, write_csv
|
|
31
|
+
from .query import extract, ExtractQuery
|
|
32
|
+
from .http import fetch, fetch_response, fetch_html, Session, Response, request, call
|
|
33
|
+
from .urls import find_urls, classify_url, filter_urls, absolutize
|
|
34
|
+
from .network import sniff_embedded_json, sniff_api_candidates, probe_apis, har_from_cdp, call_endpoint
|
|
35
|
+
from .cdp import sniff_network, CDPSession, list_targets, ensure_debugging_chrome, find_debugging_port
|
|
36
|
+
from .discover import discover_endpoints
|
|
37
|
+
from .registry import registry
|
|
38
|
+
from .serialize import html as serialize_html, prettify
|
|
39
|
+
from .exceptions import (
|
|
40
|
+
NettleError,
|
|
41
|
+
ParseError,
|
|
42
|
+
SelectorError,
|
|
43
|
+
ExtractError,
|
|
44
|
+
FetchError,
|
|
45
|
+
FormatError,
|
|
46
|
+
JsonBodyError,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
# core
|
|
51
|
+
"parse",
|
|
52
|
+
"detect_charset",
|
|
53
|
+
"Nettle",
|
|
54
|
+
"Node",
|
|
55
|
+
"Element",
|
|
56
|
+
"Text",
|
|
57
|
+
"Comment",
|
|
58
|
+
"Document",
|
|
59
|
+
# text
|
|
60
|
+
"clean_text",
|
|
61
|
+
"collapse_ws",
|
|
62
|
+
"decode_entities",
|
|
63
|
+
"normalize_unicode",
|
|
64
|
+
"remove_invisible",
|
|
65
|
+
"strip_noise",
|
|
66
|
+
# extract
|
|
67
|
+
"fields",
|
|
68
|
+
"records",
|
|
69
|
+
"table",
|
|
70
|
+
"lists",
|
|
71
|
+
"links",
|
|
72
|
+
"meta",
|
|
73
|
+
"values",
|
|
74
|
+
"extract",
|
|
75
|
+
"ExtractQuery",
|
|
76
|
+
# clean / format
|
|
77
|
+
"CleanPipeline",
|
|
78
|
+
"clean_tree",
|
|
79
|
+
"to_json",
|
|
80
|
+
"to_csv",
|
|
81
|
+
"to_tsv",
|
|
82
|
+
"to_dicts",
|
|
83
|
+
"pretty",
|
|
84
|
+
"write_json",
|
|
85
|
+
"write_csv",
|
|
86
|
+
# http / urls / network
|
|
87
|
+
"fetch",
|
|
88
|
+
"fetch_response",
|
|
89
|
+
"fetch_html",
|
|
90
|
+
"request",
|
|
91
|
+
"call",
|
|
92
|
+
"Session",
|
|
93
|
+
"Response",
|
|
94
|
+
"find_urls",
|
|
95
|
+
"classify_url",
|
|
96
|
+
"filter_urls",
|
|
97
|
+
"absolutize",
|
|
98
|
+
"sniff_embedded_json",
|
|
99
|
+
"sniff_api_candidates",
|
|
100
|
+
"probe_apis",
|
|
101
|
+
"call_endpoint",
|
|
102
|
+
"har_from_cdp",
|
|
103
|
+
"discover_endpoints",
|
|
104
|
+
"registry",
|
|
105
|
+
"sniff_network",
|
|
106
|
+
"ensure_debugging_chrome",
|
|
107
|
+
"find_debugging_port",
|
|
108
|
+
"CDPSession",
|
|
109
|
+
"list_targets",
|
|
110
|
+
# serialize
|
|
111
|
+
"serialize_html",
|
|
112
|
+
"prettify",
|
|
113
|
+
# errors
|
|
114
|
+
"NettleError",
|
|
115
|
+
"ParseError",
|
|
116
|
+
"SelectorError",
|
|
117
|
+
"ExtractError",
|
|
118
|
+
"FetchError",
|
|
119
|
+
"FormatError",
|
|
120
|
+
"JsonBodyError",
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
__version__ = "0.5.0"
|
nettle/cdp.py
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
"""Minimal Chrome DevTools Protocol client (stdlib only).
|
|
2
|
+
|
|
3
|
+
Talks WebSocket to Chrome --remote-debugging-port for Network sniffing
|
|
4
|
+
the same way DevTools Network panel does. Cross-platform: finds
|
|
5
|
+
Chrome/Chromium/Edge/Brave on Windows, macOS, Linux and Android (Termux);
|
|
6
|
+
override with the NETTLE_CHROME_BIN environment variable.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import socket
|
|
15
|
+
import struct
|
|
16
|
+
import time
|
|
17
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
18
|
+
from urllib.parse import urlparse
|
|
19
|
+
from .registry import registry as _registry
|
|
20
|
+
from urllib.request import urlopen
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CDPError(RuntimeError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _ws_connect(ws_url: str, timeout: float = 10.0) -> socket.socket:
|
|
28
|
+
u = urlparse(ws_url)
|
|
29
|
+
host = u.hostname or "127.0.0.1"
|
|
30
|
+
port = u.port or 80
|
|
31
|
+
path = u.path + (f"?{u.query}" if u.query else "")
|
|
32
|
+
key = base64.b64encode(os.urandom(16)).decode("ascii")
|
|
33
|
+
sock = socket.create_connection((host, port), timeout=timeout)
|
|
34
|
+
req = (
|
|
35
|
+
f"GET {path} HTTP/1.1\r\n"
|
|
36
|
+
f"Host: {host}:{port}\r\n"
|
|
37
|
+
"Upgrade: websocket\r\n"
|
|
38
|
+
"Connection: Upgrade\r\n"
|
|
39
|
+
f"Sec-WebSocket-Key: {key}\r\n"
|
|
40
|
+
"Sec-WebSocket-Version: 13\r\n"
|
|
41
|
+
"\r\n"
|
|
42
|
+
).encode()
|
|
43
|
+
sock.sendall(req)
|
|
44
|
+
# read HTTP response headers
|
|
45
|
+
buf = b""
|
|
46
|
+
while b"\r\n\r\n" not in buf:
|
|
47
|
+
chunk = sock.recv(4096)
|
|
48
|
+
if not chunk:
|
|
49
|
+
raise CDPError("CDP websocket handshake closed early")
|
|
50
|
+
buf += chunk
|
|
51
|
+
if len(buf) > 65536:
|
|
52
|
+
raise CDPError("CDP handshake too large")
|
|
53
|
+
header, _ = buf.split(b"\r\n\r\n", 1)
|
|
54
|
+
status = header.split(b"\r\n", 1)[0]
|
|
55
|
+
if b"101" not in status:
|
|
56
|
+
raise CDPError(f"CDP handshake failed: {status.decode('latin1', 'replace')}")
|
|
57
|
+
expect = base64.b64encode(
|
|
58
|
+
hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()
|
|
59
|
+
).decode()
|
|
60
|
+
if expect.encode() not in header:
|
|
61
|
+
# some chromes still work; keep soft
|
|
62
|
+
pass
|
|
63
|
+
sock.settimeout(0.3)
|
|
64
|
+
return sock
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _ws_send(sock: socket.socket, payload: bytes, opcode: int = 0x1) -> None:
|
|
68
|
+
mask_key = os.urandom(4)
|
|
69
|
+
n = len(payload)
|
|
70
|
+
header = bytearray([0x80 | opcode])
|
|
71
|
+
if n < 126:
|
|
72
|
+
header.append(0x80 | n)
|
|
73
|
+
elif n < 65536:
|
|
74
|
+
header.append(0x80 | 126)
|
|
75
|
+
header.extend(struct.pack("!H", n))
|
|
76
|
+
else:
|
|
77
|
+
header.append(0x80 | 127)
|
|
78
|
+
header.extend(struct.pack("!Q", n))
|
|
79
|
+
header.extend(mask_key)
|
|
80
|
+
masked = bytes(b ^ mask_key[i % 4] for i, b in enumerate(payload))
|
|
81
|
+
sock.sendall(header + masked)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _ws_recv_frame(sock: socket.socket) -> Tuple[int, bytes]:
|
|
85
|
+
def read_exact(n: int) -> bytes:
|
|
86
|
+
out = bytearray()
|
|
87
|
+
while len(out) < n:
|
|
88
|
+
chunk = sock.recv(n - len(out))
|
|
89
|
+
if not chunk:
|
|
90
|
+
raise CDPError("CDP socket closed")
|
|
91
|
+
out.extend(chunk)
|
|
92
|
+
return bytes(out)
|
|
93
|
+
|
|
94
|
+
sock.settimeout(30.0)
|
|
95
|
+
h = read_exact(2)
|
|
96
|
+
opcode = h[0] & 0x0F
|
|
97
|
+
masked = (h[1] & 0x80) != 0
|
|
98
|
+
n = h[1] & 0x7F
|
|
99
|
+
if n == 126:
|
|
100
|
+
n = struct.unpack("!H", read_exact(2))[0]
|
|
101
|
+
elif n == 127:
|
|
102
|
+
n = struct.unpack("!Q", read_exact(8))[0]
|
|
103
|
+
mask = read_exact(4) if masked else b""
|
|
104
|
+
payload = read_exact(n)
|
|
105
|
+
if masked:
|
|
106
|
+
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
|
107
|
+
if opcode == 0x8: # close
|
|
108
|
+
raise CDPError("CDP closed")
|
|
109
|
+
if opcode == 0x9: # ping -> pong
|
|
110
|
+
_ws_send(sock, payload, opcode=0xA)
|
|
111
|
+
return _ws_recv_frame(sock)
|
|
112
|
+
return opcode, payload
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class CDPSession:
|
|
116
|
+
def __init__(self, ws_url: str) -> None:
|
|
117
|
+
self.ws_url = ws_url
|
|
118
|
+
self.sock = _ws_connect(ws_url)
|
|
119
|
+
self._id = 0
|
|
120
|
+
self._pending: Dict[int, Any] = {}
|
|
121
|
+
self.events: List[Dict[str, Any]] = []
|
|
122
|
+
self._listeners: Dict[str, List[Callable[[dict], None]]] = {}
|
|
123
|
+
|
|
124
|
+
def close(self) -> None:
|
|
125
|
+
try:
|
|
126
|
+
self.sock.close()
|
|
127
|
+
except OSError:
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
def on(self, method: str, cb: Callable[[dict], None]) -> None:
|
|
131
|
+
self._listeners.setdefault(method, []).append(cb)
|
|
132
|
+
|
|
133
|
+
def _dispatch(self, msg: dict) -> None:
|
|
134
|
+
if "id" in msg:
|
|
135
|
+
self._pending[msg["id"]] = msg
|
|
136
|
+
return
|
|
137
|
+
method = msg.get("method")
|
|
138
|
+
params = msg.get("params") or {}
|
|
139
|
+
self.events.append(msg)
|
|
140
|
+
for cb in self._listeners.get(method or "", []):
|
|
141
|
+
cb(params)
|
|
142
|
+
|
|
143
|
+
def _pump(self, wait_id: Optional[int] = None, deadline: float = 0.0) -> Optional[dict]:
|
|
144
|
+
while True:
|
|
145
|
+
if wait_id is not None and wait_id in self._pending:
|
|
146
|
+
return self._pending.pop(wait_id)
|
|
147
|
+
remaining = deadline - time.time()
|
|
148
|
+
if remaining <= 0 and wait_id is None:
|
|
149
|
+
return None
|
|
150
|
+
if remaining <= 0 and wait_id is not None:
|
|
151
|
+
# still try one nonblocking
|
|
152
|
+
pass
|
|
153
|
+
try:
|
|
154
|
+
self.sock.settimeout(max(0.05, min(1.0, remaining if remaining > 0 else 0.05)))
|
|
155
|
+
opcode, payload = _ws_recv_frame(self.sock)
|
|
156
|
+
except (socket.timeout, TimeoutError):
|
|
157
|
+
if wait_id is None:
|
|
158
|
+
return None
|
|
159
|
+
if time.time() >= deadline:
|
|
160
|
+
raise CDPError(f"CDP timeout waiting for id={wait_id}")
|
|
161
|
+
continue
|
|
162
|
+
if opcode != 0x1:
|
|
163
|
+
continue
|
|
164
|
+
msg = json.loads(payload.decode("utf-8"))
|
|
165
|
+
self._dispatch(msg)
|
|
166
|
+
if wait_id is not None and wait_id in self._pending:
|
|
167
|
+
return self._pending.pop(wait_id)
|
|
168
|
+
|
|
169
|
+
def call(self, method: str, params: Optional[dict] = None, timeout: float = 20.0) -> Any:
|
|
170
|
+
self._id += 1
|
|
171
|
+
mid = self._id
|
|
172
|
+
payload = {"id": mid, "method": method, "params": params or {}}
|
|
173
|
+
_ws_send(self.sock, json.dumps(payload).encode("utf-8"))
|
|
174
|
+
msg = self._pump(wait_id=mid, deadline=time.time() + timeout)
|
|
175
|
+
if not msg:
|
|
176
|
+
raise CDPError(f"No response for {method}")
|
|
177
|
+
if "error" in msg:
|
|
178
|
+
raise CDPError(f"{method}: {msg['error']}")
|
|
179
|
+
return msg.get("result")
|
|
180
|
+
|
|
181
|
+
def pump_for(self, seconds: float) -> None:
|
|
182
|
+
end = time.time() + seconds
|
|
183
|
+
while time.time() < end:
|
|
184
|
+
self._pump(wait_id=None, deadline=min(end, time.time() + 0.25))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def debugging_alive(port: int) -> bool:
|
|
188
|
+
try:
|
|
189
|
+
with urlopen(f"http://127.0.0.1:{port}/json/version", timeout=1.5) as r:
|
|
190
|
+
r.read(64)
|
|
191
|
+
return True
|
|
192
|
+
except Exception:
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def find_debugging_port(candidates: Optional[List[int]] = None) -> Optional[int]:
|
|
197
|
+
ports = candidates or list(range(9222, 9235))
|
|
198
|
+
for p in ports:
|
|
199
|
+
if debugging_alive(p):
|
|
200
|
+
return p
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _chrome_binaries() -> List[str]:
|
|
205
|
+
"""Locate a Chromium-based browser on Windows / macOS / Linux / Termux.
|
|
206
|
+
|
|
207
|
+
Priority: NETTLE_CHROME_BIN env var, then PATH lookups, then well-known
|
|
208
|
+
install locations per platform. Any Chromium (Chrome, Chromium, Edge,
|
|
209
|
+
Brave) works — CDP is the same protocol.
|
|
210
|
+
"""
|
|
211
|
+
from shutil import which
|
|
212
|
+
from sys import platform as _plat
|
|
213
|
+
|
|
214
|
+
found: List[str] = []
|
|
215
|
+
|
|
216
|
+
env_bin = os.environ.get("NETTLE_CHROME_BIN")
|
|
217
|
+
if env_bin and os.path.isfile(env_bin):
|
|
218
|
+
found.append(env_bin)
|
|
219
|
+
|
|
220
|
+
# Android/Termux installs live under $PREFIX (usually not on PATH)
|
|
221
|
+
prefix = os.environ.get("PREFIX", "")
|
|
222
|
+
if prefix:
|
|
223
|
+
for n in ("chromium", "chrome", "google-chrome", "chrome-browser"):
|
|
224
|
+
p = os.path.join(prefix, "bin", n)
|
|
225
|
+
if os.path.isfile(p):
|
|
226
|
+
found.append(p)
|
|
227
|
+
|
|
228
|
+
names = [
|
|
229
|
+
"google-chrome-stable", "google-chrome", "chromium", "chromium-browser",
|
|
230
|
+
"brave-browser", "msedge", "microsoft-edge",
|
|
231
|
+
]
|
|
232
|
+
if os.name == "nt":
|
|
233
|
+
names += ["chrome", "msedge"]
|
|
234
|
+
for n in names:
|
|
235
|
+
path = which(n)
|
|
236
|
+
if path and path not in found:
|
|
237
|
+
found.append(path)
|
|
238
|
+
|
|
239
|
+
candidates: List[str] = []
|
|
240
|
+
home = os.path.expanduser("~")
|
|
241
|
+
if os.name == "nt":
|
|
242
|
+
for env in ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"):
|
|
243
|
+
base = os.environ.get(env)
|
|
244
|
+
if not base:
|
|
245
|
+
continue
|
|
246
|
+
candidates += [
|
|
247
|
+
os.path.join(base, "Google", "Chrome", "Application", "chrome.exe"),
|
|
248
|
+
os.path.join(base, "Microsoft", "Edge", "Application", "msedge.exe"),
|
|
249
|
+
os.path.join(base, "Chromium", "Application", "chrome.exe"),
|
|
250
|
+
os.path.join(base, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
|
|
251
|
+
]
|
|
252
|
+
elif _plat == "darwin":
|
|
253
|
+
candidates += [
|
|
254
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
255
|
+
os.path.join(home, "Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
|
|
256
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
257
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
258
|
+
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
259
|
+
]
|
|
260
|
+
else:
|
|
261
|
+
candidates += [
|
|
262
|
+
"/usr/bin/google-chrome-stable",
|
|
263
|
+
"/usr/bin/google-chrome",
|
|
264
|
+
"/usr/bin/chromium-browser",
|
|
265
|
+
"/usr/bin/chromium",
|
|
266
|
+
"/usr/bin/brave-browser",
|
|
267
|
+
"/usr/bin/microsoft-edge",
|
|
268
|
+
"/snap/bin/chromium",
|
|
269
|
+
"/data/data/com.termux/files/usr/bin/chromium",
|
|
270
|
+
]
|
|
271
|
+
for p in candidates:
|
|
272
|
+
if os.path.isfile(p) and p not in found:
|
|
273
|
+
found.append(p)
|
|
274
|
+
return found
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def ensure_debugging_chrome(
|
|
278
|
+
port: int = 9222,
|
|
279
|
+
*,
|
|
280
|
+
user_data_dir: Optional[str] = None,
|
|
281
|
+
headless: bool = True,
|
|
282
|
+
wait: float = 12.0,
|
|
283
|
+
) -> int:
|
|
284
|
+
"""Return a live --remote-debugging-port, launching Chrome if needed.
|
|
285
|
+
|
|
286
|
+
Uses a dedicated user-data-dir so it does not fight your daily browser
|
|
287
|
+
profile. Works on Windows, macOS, Linux and Termux.
|
|
288
|
+
"""
|
|
289
|
+
import tempfile
|
|
290
|
+
|
|
291
|
+
existing = find_debugging_port([port] + list(range(9222, 9235)))
|
|
292
|
+
if existing is not None:
|
|
293
|
+
return existing
|
|
294
|
+
|
|
295
|
+
bins = _chrome_binaries()
|
|
296
|
+
if not bins:
|
|
297
|
+
raise CDPError(
|
|
298
|
+
"No Chrome/Chromium/Edge/Brave binary found. Install one, or point "
|
|
299
|
+
"NETTLE_CHROME_BIN at the executable, or start a browser with "
|
|
300
|
+
f"--remote-debugging-port={port}"
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
udd = user_data_dir or os.path.join(
|
|
304
|
+
tempfile.gettempdir(), f"nettle-chrome-cdp-{port}"
|
|
305
|
+
)
|
|
306
|
+
os.makedirs(udd, exist_ok=True)
|
|
307
|
+
cmd = [
|
|
308
|
+
bins[0],
|
|
309
|
+
f"--remote-debugging-port={port}",
|
|
310
|
+
f"--user-data-dir={udd}",
|
|
311
|
+
"--no-first-run",
|
|
312
|
+
"--no-default-browser-check",
|
|
313
|
+
"--disable-background-networking",
|
|
314
|
+
"--disable-features=Translate,MediaRouter",
|
|
315
|
+
"--mute-audio",
|
|
316
|
+
"about:blank",
|
|
317
|
+
]
|
|
318
|
+
if headless:
|
|
319
|
+
cmd.insert(1, "--headless=new")
|
|
320
|
+
cmd.insert(2, "--disable-gpu")
|
|
321
|
+
|
|
322
|
+
# Detach so the caller owns a CDP endpoint without blocking.
|
|
323
|
+
import subprocess
|
|
324
|
+
|
|
325
|
+
popen_kwargs: dict = {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL}
|
|
326
|
+
if os.name == "nt":
|
|
327
|
+
popen_kwargs["creationflags"] = (
|
|
328
|
+
subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
|
|
329
|
+
)
|
|
330
|
+
else:
|
|
331
|
+
popen_kwargs["start_new_session"] = True
|
|
332
|
+
subprocess.Popen(cmd, **popen_kwargs)
|
|
333
|
+
|
|
334
|
+
deadline = time.time() + wait
|
|
335
|
+
while time.time() < deadline:
|
|
336
|
+
if debugging_alive(port):
|
|
337
|
+
return port
|
|
338
|
+
time.sleep(0.25)
|
|
339
|
+
raise CDPError(
|
|
340
|
+
f"Started {bins[0]} with --remote-debugging-port={port} but "
|
|
341
|
+
f"http://127.0.0.1:{port}/json/version never answered. "
|
|
342
|
+
"Close other browser locks on that user-data-dir and retry."
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def list_targets(port: int = 9222) -> List[dict]:
|
|
347
|
+
with urlopen(f"http://127.0.0.1:{port}/json/list", timeout=5) as r:
|
|
348
|
+
return json.loads(r.read().decode())
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def new_tab(port: int = 9222, url: str = "about:blank") -> dict:
|
|
352
|
+
# Chrome accepts PUT /json/new?<url> (and some builds accept GET).
|
|
353
|
+
from urllib.request import Request
|
|
354
|
+
from urllib.error import URLError, HTTPError
|
|
355
|
+
|
|
356
|
+
endpoint = f"http://127.0.0.1:{port}/json/new?{url}"
|
|
357
|
+
last_err: Optional[Exception] = None
|
|
358
|
+
for method in ("PUT", "GET"):
|
|
359
|
+
try:
|
|
360
|
+
req = Request(endpoint, method=method)
|
|
361
|
+
with urlopen(req, timeout=5) as r:
|
|
362
|
+
return json.loads(r.read().decode())
|
|
363
|
+
except (URLError, HTTPError, TimeoutError, OSError) as e:
|
|
364
|
+
last_err = e
|
|
365
|
+
continue
|
|
366
|
+
raise CDPError(f"Cannot open new tab on port {port}: {last_err}")
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def sniff_network(
|
|
370
|
+
url: str,
|
|
371
|
+
*,
|
|
372
|
+
port: Optional[int] = None,
|
|
373
|
+
settle: float = 4.0,
|
|
374
|
+
on_event: Optional[Callable[[str, dict], None]] = None,
|
|
375
|
+
ensure_chrome: bool = True,
|
|
376
|
+
) -> Dict[str, Any]:
|
|
377
|
+
"""Open url in Chrome via CDP and capture Network requests/responses.
|
|
378
|
+
|
|
379
|
+
Returns dict with requests list (method, url, status, mime, type, body preview for JSON).
|
|
380
|
+
If ensure_chrome is True (default), auto-starts a dedicated headless Chrome when
|
|
381
|
+
no debugging port is listening.
|
|
382
|
+
"""
|
|
383
|
+
if port is None:
|
|
384
|
+
port = find_debugging_port() or 9222
|
|
385
|
+
if ensure_chrome:
|
|
386
|
+
port = ensure_debugging_chrome(port)
|
|
387
|
+
elif not debugging_alive(port):
|
|
388
|
+
raise CDPError(
|
|
389
|
+
f"Nothing listening on 127.0.0.1:{port}. "
|
|
390
|
+
f"Start Chrome with --remote-debugging-port={port} "
|
|
391
|
+
"or call ensure_debugging_chrome()."
|
|
392
|
+
)
|
|
393
|
+
tab = new_tab(port=port, url="about:blank")
|
|
394
|
+
ws = tab.get("webSocketDebuggerUrl")
|
|
395
|
+
if not ws:
|
|
396
|
+
raise CDPError("No webSocketDebuggerUrl for new tab")
|
|
397
|
+
cdp = CDPSession(ws)
|
|
398
|
+
captured: Dict[str, Dict[str, Any]] = {}
|
|
399
|
+
|
|
400
|
+
def req_sent(p: dict) -> None:
|
|
401
|
+
req = p.get("request") or {}
|
|
402
|
+
rid = p.get("requestId")
|
|
403
|
+
entry = {
|
|
404
|
+
"requestId": rid,
|
|
405
|
+
"url": req.get("url"),
|
|
406
|
+
"method": req.get("method"),
|
|
407
|
+
"type": p.get("type") or req.get("mixedContentType"),
|
|
408
|
+
"headers": req.get("headers") or {},
|
|
409
|
+
"status": None,
|
|
410
|
+
"mime": None,
|
|
411
|
+
"body": None,
|
|
412
|
+
"body_b64": False,
|
|
413
|
+
}
|
|
414
|
+
captured[rid] = entry
|
|
415
|
+
if on_event:
|
|
416
|
+
on_event("request", entry)
|
|
417
|
+
|
|
418
|
+
def resp_recv(p: dict) -> None:
|
|
419
|
+
rid = p.get("requestId")
|
|
420
|
+
resp = p.get("response") or {}
|
|
421
|
+
entry = captured.setdefault(rid, {"requestId": rid, "url": resp.get("url")})
|
|
422
|
+
entry["status"] = resp.get("status")
|
|
423
|
+
entry["mime"] = resp.get("mimeType")
|
|
424
|
+
entry["url"] = entry.get("url") or resp.get("url")
|
|
425
|
+
entry["type"] = entry.get("type") or p.get("type")
|
|
426
|
+
if on_event:
|
|
427
|
+
on_event("response", entry)
|
|
428
|
+
|
|
429
|
+
def loading_finished(p: dict) -> None:
|
|
430
|
+
rid = p.get("requestId")
|
|
431
|
+
entry = captured.get(rid)
|
|
432
|
+
if not entry:
|
|
433
|
+
return
|
|
434
|
+
mime = (entry.get("mime") or "").lower()
|
|
435
|
+
url = (entry.get("url") or "").lower()
|
|
436
|
+
rtype = (entry.get("type") or "").lower()
|
|
437
|
+
# Prefer MIME + ResourceType (works on any site). Path hints are fallback only.
|
|
438
|
+
want_body = (
|
|
439
|
+
"json" in mime
|
|
440
|
+
or rtype in ("xhr", "fetch")
|
|
441
|
+
or url.endswith(".json")
|
|
442
|
+
or "/api/" in url
|
|
443
|
+
or "graphql" in url
|
|
444
|
+
or "/wp-json/" in url
|
|
445
|
+
or "/_next/data/" in url
|
|
446
|
+
or mime.startswith("text/")
|
|
447
|
+
)
|
|
448
|
+
# also grab small media metadata only; bodies for mp4 skipped
|
|
449
|
+
if any(url.split("?", 1)[0].endswith(ext) for ext in _registry.media_exts):
|
|
450
|
+
entry["media"] = True
|
|
451
|
+
if on_event:
|
|
452
|
+
on_event("media", entry)
|
|
453
|
+
return
|
|
454
|
+
if not want_body:
|
|
455
|
+
return
|
|
456
|
+
try:
|
|
457
|
+
result = cdp.call("Network.getResponseBody", {"requestId": rid}, timeout=5)
|
|
458
|
+
body = result.get("body", "")
|
|
459
|
+
if result.get("base64Encoded"):
|
|
460
|
+
entry["body_b64"] = True
|
|
461
|
+
try:
|
|
462
|
+
raw = base64.b64decode(body)
|
|
463
|
+
entry["body"] = raw.decode("utf-8", errors="replace")[:4000]
|
|
464
|
+
except Exception:
|
|
465
|
+
entry["body"] = f"<base64 {len(body)} chars>"
|
|
466
|
+
else:
|
|
467
|
+
entry["body"] = (body or "")[:4000]
|
|
468
|
+
if on_event:
|
|
469
|
+
on_event("body", entry)
|
|
470
|
+
except Exception as e:
|
|
471
|
+
entry["body_error"] = str(e)
|
|
472
|
+
|
|
473
|
+
cdp.on("Network.requestWillBeSent", req_sent)
|
|
474
|
+
cdp.on("Network.responseReceived", resp_recv)
|
|
475
|
+
cdp.on("Network.loadingFinished", loading_finished)
|
|
476
|
+
|
|
477
|
+
try:
|
|
478
|
+
cdp.call("Network.enable", {"maxPostDataSize": 65536})
|
|
479
|
+
cdp.call("Page.enable")
|
|
480
|
+
cdp.call("Page.navigate", {"url": url})
|
|
481
|
+
cdp.pump_for(settle)
|
|
482
|
+
finally:
|
|
483
|
+
cdp.close()
|
|
484
|
+
_close_tab_quietly(port, tab.get("id"))
|
|
485
|
+
|
|
486
|
+
# classify helpers
|
|
487
|
+
entries = list(captured.values())
|
|
488
|
+
media = [e for e in entries if e.get("media") or _is_media_url(e.get("url") or "")]
|
|
489
|
+
jsonish = [
|
|
490
|
+
e for e in entries
|
|
491
|
+
if (e.get("mime") or "").lower().find("json") >= 0
|
|
492
|
+
or (e.get("body") or "").lstrip()[:1] in "{["
|
|
493
|
+
]
|
|
494
|
+
xhr = [e for e in entries if (e.get("type") or "").lower() in ("xhr", "fetch")]
|
|
495
|
+
|
|
496
|
+
return {
|
|
497
|
+
"ok": True,
|
|
498
|
+
"page": url,
|
|
499
|
+
"tabId": tab.get("id"),
|
|
500
|
+
"total": len(entries),
|
|
501
|
+
"entries": entries,
|
|
502
|
+
"media": media,
|
|
503
|
+
"json": jsonish,
|
|
504
|
+
"xhr_fetch": xhr,
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _close_tab_quietly(port: int, tab_id: Optional[str]) -> None:
|
|
509
|
+
"""Best-effort close of a CDP tab so browsers don't accumulate tabs."""
|
|
510
|
+
if not tab_id:
|
|
511
|
+
return
|
|
512
|
+
try:
|
|
513
|
+
urlopen(f"http://127.0.0.1:{port}/json/close/{tab_id}", timeout=2).read()
|
|
514
|
+
except Exception:
|
|
515
|
+
pass
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _is_media_url(url: str) -> bool:
|
|
519
|
+
from .registry import registry as _registry
|
|
520
|
+
path = url.lower().split("?", 1)[0]
|
|
521
|
+
return path.endswith(tuple(_registry.media_exts | {".ts"}))
|