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 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"}))