proxy2model 2.0.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.
Files changed (66) hide show
  1. proxy2model/__init__.py +19 -0
  2. proxy2model/__main__.py +6 -0
  3. proxy2model/atomic.py +68 -0
  4. proxy2model/cli/__init__.py +3 -0
  5. proxy2model/cli/analyze.py +501 -0
  6. proxy2model/cli/app.py +408 -0
  7. proxy2model/cli/browse.py +279 -0
  8. proxy2model/cli/build.py +399 -0
  9. proxy2model/cli/common.py +190 -0
  10. proxy2model/cli/history.py +224 -0
  11. proxy2model/cli/mcp.py +184 -0
  12. proxy2model/cli/ui.py +62 -0
  13. proxy2model/core/__init__.py +1 -0
  14. proxy2model/core/changes.py +124 -0
  15. proxy2model/core/context.py +571 -0
  16. proxy2model/core/diff.py +330 -0
  17. proxy2model/core/domains.py +98 -0
  18. proxy2model/core/graph.py +562 -0
  19. proxy2model/core/methodology.py +343 -0
  20. proxy2model/core/model.py +998 -0
  21. proxy2model/core/stack.py +218 -0
  22. proxy2model/data/fonts/LICENSES.md +16 -0
  23. proxy2model/data/fonts/inter-latin-wght-normal.woff2 +0 -0
  24. proxy2model/data/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
  25. proxy2model/data/fonts/nippo-200.woff2 +0 -0
  26. proxy2model/data/fonts/nippo-300.woff2 +0 -0
  27. proxy2model/data/fonts/nippo-400.woff2 +0 -0
  28. proxy2model/data/fonts/nippo-500.woff2 +0 -0
  29. proxy2model/data/fonts/nippo-700.woff2 +0 -0
  30. proxy2model/data/public_suffix_list.dat +16497 -0
  31. proxy2model/db/__init__.py +1 -0
  32. proxy2model/db/bql.py +710 -0
  33. proxy2model/db/bql_js.py +278 -0
  34. proxy2model/db/store.py +547 -0
  35. proxy2model/export/__init__.py +1 -0
  36. proxy2model/export/openapi.py +248 -0
  37. proxy2model/ingest/__init__.py +1 -0
  38. proxy2model/ingest/browse.py +181 -0
  39. proxy2model/ingest/browser.py +1058 -0
  40. proxy2model/ingest/crawl.py +673 -0
  41. proxy2model/ingest/extract.py +208 -0
  42. proxy2model/ingest/infer.py +75 -0
  43. proxy2model/ingest/journey.py +148 -0
  44. proxy2model/ingest/live.py +96 -0
  45. proxy2model/ingest/parse.py +1275 -0
  46. proxy2model/ingest/redact.py +535 -0
  47. proxy2model/py.typed +0 -0
  48. proxy2model/recon/__init__.py +1 -0
  49. proxy2model/recon/osint.py +607 -0
  50. proxy2model/report/__init__.py +1 -0
  51. proxy2model/report/animate.py +163 -0
  52. proxy2model/report/dash.py +469 -0
  53. proxy2model/report/fonts.py +51 -0
  54. proxy2model/report/html.py +988 -0
  55. proxy2model/report/inventory.py +258 -0
  56. proxy2model/report/map.py +515 -0
  57. proxy2model/report/pages.py +537 -0
  58. proxy2model/report/query.py +306 -0
  59. proxy2model/report/shot.py +55 -0
  60. proxy2model/report/view.py +213 -0
  61. proxy2model-2.0.0.dist-info/METADATA +148 -0
  62. proxy2model-2.0.0.dist-info/RECORD +66 -0
  63. proxy2model-2.0.0.dist-info/WHEEL +5 -0
  64. proxy2model-2.0.0.dist-info/entry_points.txt +2 -0
  65. proxy2model-2.0.0.dist-info/licenses/LICENSE +201 -0
  66. proxy2model-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1058 @@
1
+ """
2
+ A JavaScript-enabled crawl: drive a real Chrome/Chromium over the DevTools
3
+ Protocol and record what it does, the way Burp's browser-powered crawler does.
4
+
5
+ There is no dependency to install. The WebSocket client and the DevTools client
6
+ below are a few hundred lines of standard library; the only requirement is a
7
+ Chrome or Chromium on the machine (found automatically, or `--chrome PATH`).
8
+
9
+ Why a browser: a single-page app builds its links and calls its API in
10
+ JavaScript, so a fetch-and-parse crawler sees an empty shell. A browser runs the
11
+ app, so the crawl records the real XHR/fetch traffic, with real bodies, headers
12
+ and cookies, and can click through routes, menus, tabs and forms to reach more.
13
+
14
+ What is recorded: every in-scope request the page makes, with its response, as
15
+ a Burp-style item. It goes through the same parser and redaction as a Burp
16
+ export, in memory; nothing raw is written by the crawl.
17
+
18
+ Guard rails (the browser is told, not trusted):
19
+ * Fetch interception blocks every request outside the scope before it is sent,
20
+ and every URL that looks destructive (logout, delete, ...); with --read-only
21
+ it blocks every non-GET as well;
22
+ * dialogs are dismissed (a confirm() is answered "cancel"), downloads are denied;
23
+ * elements whose label looks destructive are never clicked, and a form with a
24
+ password field is never submitted;
25
+ * a request budget, a click budget per page, and an overall time limit.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import base64
31
+ import json
32
+ import os
33
+ import re
34
+ import select
35
+ import shutil
36
+ import socket
37
+ import struct
38
+ import subprocess
39
+ import sys
40
+ import tempfile
41
+ import time
42
+ import urllib.parse
43
+ from collections import deque
44
+ from xml.etree.ElementTree import Element, SubElement
45
+
46
+ from .crawl import _UNSAFE, CrawlConfig, CrawlResult, Crawler, Task, _burp_mime
47
+ from .redact import route_template, template_path
48
+
49
+ _CANDIDATES = [
50
+ "google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome",
51
+ "microsoft-edge", "microsoft-edge-stable", "brave-browser",
52
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
53
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
54
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
55
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
56
+ r"C:\Program Files\Google\Chrome\Application\chrome.exe",
57
+ r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
58
+ ]
59
+
60
+
61
+ def find_chrome(explicit: str | None = None) -> str | None:
62
+ for c in ([explicit] if explicit else []) + [os.environ.get("PROXY2MODEL_CHROME")] + _CANDIDATES:
63
+ if not c:
64
+ continue
65
+ found = shutil.which(c) or (c if os.path.isfile(c) and os.access(c, os.X_OK) else None)
66
+ if found:
67
+ return found
68
+ return None
69
+
70
+
71
+ # ------------------------------------------------------------- WebSocket -----
72
+
73
+ def launch_chrome(chrome: str, tmp: str, extra: list[str] | None = None, headless: bool = True,
74
+ attempts: int = 3):
75
+ """Start Chrome with DevTools on a free port; return (process, websocket url).
76
+
77
+ A cold Chrome on a busy CI runner sometimes exits or never opens its port, so a failed start is
78
+ retried before giving up."""
79
+ args = [chrome, "--no-first-run", "--no-default-browser-check", "--disable-gpu",
80
+ "--disable-extensions", "--disable-background-networking", "--disable-sync", "--mute-audio",
81
+ "--disable-dev-shm-usage", "--hide-scrollbars", "--remote-debugging-port=0",
82
+ f"--user-data-dir={tmp}", "--disable-features=Translate,MediaRouter", "--disable-component-update"]
83
+ if headless:
84
+ args.insert(1, "--headless=new")
85
+ if (hasattr(os, "geteuid") and os.geteuid() == 0) or os.environ.get("PROXY2MODEL_CHROME_NO_SANDBOX") == "1":
86
+ args.append("--no-sandbox") # containers and CI runners that cannot create a sandbox
87
+ args += extra or []
88
+ args.append("about:blank")
89
+ port_file = os.path.join(tmp, "DevToolsActivePort")
90
+ last = "the browser did not open its DevTools port"
91
+ for _ in range(attempts):
92
+ if os.path.exists(port_file):
93
+ os.remove(port_file)
94
+ proc = subprocess.Popen(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
95
+ for _ in range(200):
96
+ if os.path.exists(port_file):
97
+ lines = open(port_file).read().split("\n")
98
+ if len(lines) >= 2 and lines[1]:
99
+ return proc, f"ws://127.0.0.1:{lines[0].strip()}{lines[1].strip()}"
100
+ if proc.poll() is not None:
101
+ last = "the browser exited during start-up"
102
+ break
103
+ time.sleep(0.1)
104
+ if proc.poll() is None:
105
+ proc.kill()
106
+ try:
107
+ proc.wait(5)
108
+ except subprocess.TimeoutExpired:
109
+ pass
110
+ raise RuntimeError(last)
111
+
112
+
113
+ class WebSocket:
114
+ """A minimal RFC 6455 client: text frames, fragments, ping/pong, close."""
115
+
116
+ def __init__(self, url: str, timeout: float = 15.0):
117
+ u = urllib.parse.urlsplit(url)
118
+ self.sock = socket.create_connection((u.hostname, u.port), timeout=timeout)
119
+ key = base64.b64encode(os.urandom(16)).decode()
120
+ path = (u.path or "/") + ("?" + u.query if u.query else "")
121
+ self.sock.sendall((f"GET {path} HTTP/1.1\r\nHost: {u.hostname}:{u.port}\r\n"
122
+ f"Upgrade: websocket\r\nConnection: Upgrade\r\n"
123
+ f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n").encode())
124
+ head = b""
125
+ while b"\r\n\r\n" not in head:
126
+ chunk = self.sock.recv(4096)
127
+ if not chunk:
128
+ raise ConnectionError("DevTools closed the connection during the handshake")
129
+ head += chunk
130
+ head, _, rest = head.partition(b"\r\n\r\n")
131
+ if b" 101 " not in head.split(b"\r\n", 1)[0]:
132
+ raise ConnectionError("DevTools refused the WebSocket upgrade: " + head[:80].decode("latin-1"))
133
+ self.buf = rest
134
+ self.closed = False
135
+
136
+ def send(self, text: str) -> None:
137
+ data = text.encode()
138
+ n = len(data)
139
+ head = bytes([0x81])
140
+ if n < 126:
141
+ head += bytes([0x80 | n])
142
+ elif n < 65536:
143
+ head += bytes([0x80 | 126]) + struct.pack(">H", n)
144
+ else:
145
+ head += bytes([0x80 | 127]) + struct.pack(">Q", n)
146
+ mask = os.urandom(4)
147
+ self.sock.sendall(head + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(data)))
148
+
149
+ def _need(self, n: int, deadline: float | None) -> bytes | None:
150
+ while len(self.buf) < n:
151
+ if deadline is not None:
152
+ left = deadline - time.monotonic()
153
+ if left <= 0 or not select.select([self.sock], [], [], left)[0]:
154
+ return None
155
+ chunk = self.sock.recv(1 << 20)
156
+ if not chunk:
157
+ self.closed = True
158
+ raise ConnectionError("DevTools connection closed")
159
+ self.buf += chunk
160
+ out, self.buf = self.buf[:n], self.buf[n:]
161
+ return out
162
+
163
+ def recv(self, timeout: float | None) -> str | None:
164
+ """The next text message, or None if none arrives within `timeout`."""
165
+ deadline = None if timeout is None else time.monotonic() + timeout
166
+ parts: list[bytes] = []
167
+ while True:
168
+ h = self._need(2, deadline)
169
+ if h is None:
170
+ return None
171
+ fin, op, n = h[0] & 0x80, h[0] & 0x0F, h[1] & 0x7F
172
+ if n == 126:
173
+ n = struct.unpack(">H", self._need(2, None))[0]
174
+ elif n == 127:
175
+ n = struct.unpack(">Q", self._need(8, None))[0]
176
+ payload = self._need(n, None) if n else b""
177
+ if op == 8:
178
+ self.closed = True
179
+ raise ConnectionError("DevTools closed the WebSocket")
180
+ if op == 9:
181
+ self.sock.sendall(bytes([0x8A, 0x80]) + os.urandom(4)) # empty masked pong
182
+ continue
183
+ if op == 10:
184
+ continue
185
+ parts.append(payload)
186
+ if fin:
187
+ return b"".join(parts).decode("utf-8", "replace")
188
+
189
+ def close(self) -> None:
190
+ try:
191
+ self.sock.close()
192
+ except OSError:
193
+ pass
194
+
195
+
196
+ class CDP:
197
+ """Commands and events over one DevTools WebSocket."""
198
+
199
+ def __init__(self, ws: WebSocket):
200
+ self.ws = ws
201
+ self.n = 0
202
+ self.events: deque = deque()
203
+ # events that must be answered at once, even while another command is waiting
204
+ # (a paused request blocks the navigation that is waiting for it)
205
+ self.urgent = None
206
+ self.answers: dict = {}
207
+
208
+ def call(self, method: str, params: dict | None = None, session: str | None = None,
209
+ timeout: float = 20.0):
210
+ self.n += 1
211
+ my_id = self.n # urgent events answered while waiting issue commands of their own
212
+ msg = {"id": my_id, "method": method, "params": params or {}}
213
+ if session:
214
+ msg["sessionId"] = session
215
+ self.ws.send(json.dumps(msg))
216
+ end = time.monotonic() + timeout
217
+ while True:
218
+ m = self.answers.pop(my_id, None)
219
+ if m is None:
220
+ raw = self.ws.recv(max(0.05, end - time.monotonic()))
221
+ if raw is None:
222
+ raise TimeoutError(f"DevTools did not answer {method}")
223
+ m = json.loads(raw)
224
+ if "id" in m and m["id"] != my_id:
225
+ # the answer to a command waiting further up the stack (an urgent event
226
+ # handler issued its own command meanwhile): keep it for its owner
227
+ self.answers[m["id"]] = m
228
+ continue
229
+ if m.get("id") == my_id:
230
+ if "error" in m:
231
+ raise RuntimeError(f"{method}: {m['error'].get('message')}")
232
+ return m.get("result", {})
233
+ if "method" in m:
234
+ if self.urgent and m["method"] in ("Fetch.requestPaused", "Page.javascriptDialogOpening"):
235
+ self.urgent(m)
236
+ else:
237
+ self.events.append(m)
238
+
239
+ def next_event(self, timeout: float):
240
+ if self.events:
241
+ return self.events.popleft()
242
+ raw = self.ws.recv(timeout)
243
+ if raw is None:
244
+ return None
245
+ m = json.loads(raw)
246
+ return m if "method" in m else self.next_event(0)
247
+
248
+
249
+ # ---------------------------------------------------- in-page helper scripts --
250
+
251
+ _SAFE_JS = r"""
252
+ (() => {
253
+ const bad = /(log-?\s?out|sign-?\s?out|log-?\s?off|delete|remove|destroy|unsubscribe|deactivate|erase|erasure|reset[ /-]*(database|db)|create[ /]+reset|shutdown|purge|wipe|truncate|factory reset|set-?up|install|clear (all|cart|history))/i;
254
+ const vis = e => { const r = e.getBoundingClientRect(); const s = getComputedStyle(e);
255
+ return r.width > 1 && r.height > 1 && s.visibility !== 'hidden' && s.display !== 'none'; };
256
+ const label = e => ((e.innerText || e.value || e.getAttribute('aria-label') || e.title || '') + '').trim().replace(/\s+/g,' ').slice(0, 60);
257
+ window.__b2m = { bad, vis, label };
258
+ return true;
259
+ })()
260
+ """
261
+
262
+ _HARVEST_JS = r"""
263
+ (() => {
264
+ const abs = x => { try { return new URL(x, document.baseURI).href; } catch (e) { return null; } };
265
+ const links = [];
266
+ document.querySelectorAll('a[href],area[href],iframe[src],frame[src]').forEach(e => {
267
+ const v = abs(e.getAttribute('href') || e.getAttribute('src')); if (v) links.push(v); });
268
+ document.querySelectorAll('[routerlink],[ng-reflect-router-link],[data-href],[to]').forEach(e => {
269
+ const v = e.getAttribute('routerlink') || e.getAttribute('ng-reflect-router-link') || e.getAttribute('data-href');
270
+ if (v && v.startsWith('/')) links.push({route: v}); });
271
+ const forms = Array.from(document.forms).map((f, i) => ({
272
+ index: i, action: abs(f.getAttribute('action') || '') || location.href, method: (f.method || 'get').toUpperCase(),
273
+ password: !!f.querySelector('input[type=password]'),
274
+ fields: Array.from(f.elements).filter(x => x.name).map(x => x.name) }));
275
+ return { href: location.href, title: document.title, links, forms,
276
+ hashRouting: location.hash.startsWith('#/') || links.some(l => typeof l === 'string' && l.includes('#/')) };
277
+ })()
278
+ """
279
+
280
+ _CLICKABLES_JS = r"""
281
+ (() => {
282
+ if (!window.__b2m) return [];
283
+ const { bad, vis, label } = window.__b2m;
284
+ const sel = 'button,[role=button],[role=menuitem],[role=tab],[role=link],[role=option],[routerlink],[onclick],summary,' +
285
+ 'a:not([href]),a[href="#"],a[href^="javascript:"],[class*=menu-item],[class*=nav-item],[class*=tab],mat-select,[aria-haspopup],[data-toggle],[data-bs-toggle]';
286
+ const out = [];
287
+ window.__b2m_c = Array.from(document.querySelectorAll(sel)).filter(vis);
288
+ window.__b2m_c.forEach((e, i) => {
289
+ const t = label(e);
290
+ if (bad.test(t) || bad.test(e.className || '') || bad.test(e.id || '')) return;
291
+ if (e.closest('form') && e.closest('form').querySelector('input[type=password]')) return;
292
+ if (e.disabled || e.getAttribute('aria-disabled') === 'true') return;
293
+ const r = e.getBoundingClientRect();
294
+ out.push({ i, sig: e.tagName + '|' + t + '|' + (e.getAttribute('routerlink') || e.getAttribute('href') || '') +
295
+ '|' + Math.round(r.x / 40) + ',' + Math.round(r.y / 40) });
296
+ });
297
+ return out;
298
+ })()
299
+ """
300
+
301
+
302
+ def _click_js(i: int) -> str:
303
+ return ("(() => { const e = (window.__b2m_c || [])[%d]; if (!e) return false; "
304
+ "try { e.scrollIntoView({block:'center'}); e.click(); return true; } catch (x) { return false; } })()" % i)
305
+
306
+
307
+ _FILL_JS = r"""
308
+ (() => {
309
+ const filled = [];
310
+ const val = t => ({email:'test@example.invalid', number:'1', tel:'5550100', url:'https://example.invalid/',
311
+ date:'2030-01-01', search:'a', text:'test'})[t] || 'test';
312
+ const set = (e, v) => { const d = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(e), 'value');
313
+ d && d.set ? d.set.call(e, v) : (e.value = v);
314
+ e.dispatchEvent(new Event('input', {bubbles:true})); e.dispatchEvent(new Event('change', {bubbles:true})); };
315
+ document.querySelectorAll('input:not([type=hidden]):not([type=password]):not([type=file]):not([type=checkbox]):not([type=radio]):not([type=submit]):not([type=button]),textarea').forEach(e => {
316
+ if (e.value || e.disabled || e.readOnly) return;
317
+ const r = e.getBoundingClientRect(); if (r.width < 2 || r.height < 2) return;
318
+ if (e.closest('form') && e.closest('form').querySelector('input[type=password]')) return;
319
+ set(e, val((e.type || 'text').toLowerCase())); filled.push(e.name || e.id || e.type); });
320
+ return filled;
321
+ })()
322
+ """
323
+
324
+ _SUBMIT_JS = r"""
325
+ (() => {
326
+ const forms = Array.from(document.forms).filter(f => !f.querySelector('input[type=password]'));
327
+ const bad = window.__b2m ? window.__b2m.bad : /$^/;
328
+ let n = 0;
329
+ forms.forEach(f => {
330
+ const txt = (f.innerText || '') + ' ' + (f.action || '');
331
+ if (bad.test(txt)) return;
332
+ try { f.requestSubmit ? f.requestSubmit() : f.submit(); n++; } catch (e) {}
333
+ });
334
+ return n;
335
+ })()
336
+ """
337
+
338
+ _ENTER_JS = r"""
339
+ (() => { const e = document.querySelector('input[type=search],input[type=text]:not([form])');
340
+ if (!e || !e.value) return false;
341
+ ['keydown','keypress','keyup'].forEach(t => e.dispatchEvent(new KeyboardEvent(t, {key:'Enter', code:'Enter', keyCode:13, which:13, bubbles:true})));
342
+ return true; })()
343
+ """
344
+
345
+ _SCROLL_JS = "(() => { window.scrollTo(0, document.body.scrollHeight); return document.body.scrollHeight; })()"
346
+
347
+
348
+ # chatty background channels (long-polling, event streams, dev servers) that never "finish"
349
+ _BACKGROUND = re.compile(r"socket\.io|sockjs|transport=(?:polling|websocket)|/signalr|/hub(?:/|\?|$)|"
350
+ r"__webpack_hmr|livereload|/sse\b|/events?(?:\?|$)", re.I)
351
+
352
+
353
+ class _Rec:
354
+ """One request as the browser made it, assembled from several DevTools events."""
355
+ __slots__ = ("id", "req", "extra_req", "resp", "extra_resp", "body", "b64", "rtype", "done", "failed",
356
+ "post", "redirect")
357
+
358
+ def __init__(self, rid):
359
+ self.id, self.req, self.extra_req, self.resp, self.extra_resp = rid, None, None, None, None
360
+ self.body, self.b64, self.rtype, self.done, self.failed, self.post, self.redirect = b"", False, "", False, False, None, False
361
+
362
+
363
+ def rec_to_item(rec: _Rec, in_scope) -> Element | None:
364
+ """One recorded request and its response as a Burp-style <item>, or None when out of scope."""
365
+ req, resp = rec.req, rec.resp
366
+ if not req or not resp:
367
+ return None
368
+ u = urllib.parse.urlsplit(req["url"])
369
+ if u.scheme not in ("http", "https") or not in_scope(req["url"]):
370
+ return None
371
+ port = u.port or (443 if u.scheme == "https" else 80)
372
+ path = (u.path or "/") + ("?" + u.query if u.query else "")
373
+ rq = dict(req.get("headers") or {})
374
+ rq.update(rec.extra_req or {})
375
+ rq = {k: v for k, v in rq.items() if not k.startswith(":")}
376
+ if not any(k.lower() == "host" for k in rq):
377
+ rq = {"Host": u.netloc, **rq}
378
+ post = (rec.post or "").encode("utf-8", "replace") if rec.post else b""
379
+ if post and not any(k.lower() == "content-length" for k in rq):
380
+ rq["Content-Length"] = str(len(post))
381
+ raw_req = (f"{req['method']} {path} HTTP/1.1\r\n" + "".join(f"{k}: {v}\r\n" for k, v in rq.items())
382
+ + "\r\n").encode("latin-1", "replace") + post
383
+ status = int(resp.get("status") or 0)
384
+ rh: list[tuple[str, str]] = []
385
+ for k, v in {**(resp.get("headers") or {}), **(rec.extra_resp or {})}.items():
386
+ if k.startswith(":") or k.lower() in ("content-encoding", "transfer-encoding", "content-length"):
387
+ continue
388
+ for line in str(v).split("\n"):
389
+ rh.append((k, line))
390
+ # DevTools hands back the body already decoded, so the encoding header is dropped
391
+ rh.append(("Content-Length", str(len(rec.body))))
392
+ head = f"HTTP/1.1 {status} {resp.get('statusText') or ''}\r\n" + "".join(f"{k}: {v}\r\n" for k, v in rh) + "\r\n"
393
+ raw_resp = head.encode("latin-1", "replace") + rec.body
394
+ item = Element("item")
395
+ low = {k.lower(): v for k, v in (resp.get("headers") or {}).items()}
396
+ for tag, val in (("method", req["method"]), ("host", u.hostname.lower()), ("port", str(port)),
397
+ ("protocol", u.scheme), ("path", path), ("status", str(status)),
398
+ ("mimetype", _burp_mime(low.get("content-type", resp.get("mimeType", ""))))):
399
+ SubElement(item, tag).text = val
400
+ SubElement(item, "request", {"base64": "true"}).text = base64.b64encode(raw_req).decode()
401
+ SubElement(item, "response", {"base64": "true"}).text = base64.b64encode(raw_resp).decode()
402
+ return item
403
+
404
+
405
+ class BrowserCrawler(Crawler):
406
+ """Crawl with a real browser; fall back to nothing silently (see make_crawler)."""
407
+
408
+ def __init__(self, cfg: CrawlConfig, chrome: str, attach: str | None = None):
409
+ super().__init__(cfg)
410
+ self.chrome = chrome
411
+ # `attach`: the DevTools URL of a browser someone is using (`browse --active`). The crawl
412
+ # then runs in a background tab of that browser, sharing its login, and leaves it open.
413
+ self.attach = attach
414
+ self.target: str | None = None
415
+ self.proc = None
416
+ self.tmp = None
417
+ self.cdp: CDP | None = None
418
+ self.session: str | None = None
419
+ self.recs: dict[str, _Rec] = {}
420
+ self.inflight: set[str] = set()
421
+ self.started: dict[str, float] = {}
422
+ self.last_net = time.monotonic()
423
+ self.finalised: list[_Rec] = []
424
+ self._heavy_assets = 0
425
+ self.stats.update({"browser": True, "clicks": 0, "forms_submitted": 0, "states": 0,
426
+ "blocked_scope": 0, "blocked_unsafe": 0, "blocked_method": 0, "dialogs_dismissed": 0,
427
+ "blocked_asset_cap": 0})
428
+ self._deadline = time.monotonic() + (cfg.max_seconds if cfg.max_seconds else 10 ** 9)
429
+ self._journey_active = False # a scripted step is deliberate: no "looks destructive" filter
430
+ self.screenshot: bytes | None = None
431
+ self._scripts: list[tuple[str, str]] = [] # (url, text) of JS/HTML bodies, for the static pass
432
+
433
+ # ------------------------------------------------------------ lifecycle --
434
+ def _launch(self) -> None:
435
+ if self.attach:
436
+ self.cdp = CDP(WebSocket(self.attach))
437
+ self.cdp.urgent = self._handle
438
+ return
439
+ self.tmp = tempfile.mkdtemp(prefix="b2m-chrome-")
440
+ extra = ["--window-size=1366,900", "--disable-popup-blocking"]
441
+ if self.cfg.insecure:
442
+ extra.append("--ignore-certificate-errors")
443
+ self.proc, url = launch_chrome(self.chrome, self.tmp, extra, headless=not self.cfg.headful)
444
+ self.cdp = CDP(WebSocket(url))
445
+ self.cdp.urgent = self._handle
446
+
447
+ def _capture(self) -> bytes | None:
448
+ """The page as it looks now: a JPEG of the viewport, small enough to embed."""
449
+ for quality in (72, 50):
450
+ try:
451
+ r = self._page("Page.captureScreenshot", {"format": "jpeg", "quality": quality}, 15)
452
+ data = base64.b64decode(r.get("data") or "")
453
+ except (RuntimeError, TimeoutError, ValueError):
454
+ return None
455
+ if data and len(data) <= 600_000:
456
+ return data
457
+ return None
458
+
459
+ def _close(self) -> None:
460
+ try:
461
+ if self.cdp and self.attach and self.target:
462
+ self.cdp.call("Target.closeTarget", {"targetId": self.target}, timeout=5)
463
+ except Exception:
464
+ pass
465
+ try:
466
+ if self.cdp:
467
+ self.cdp.ws.close()
468
+ except Exception:
469
+ pass
470
+ if self.proc and self.proc.poll() is None:
471
+ self.proc.terminate()
472
+ try:
473
+ self.proc.wait(5)
474
+ except subprocess.TimeoutExpired:
475
+ self.proc.kill()
476
+ if self.tmp:
477
+ shutil.rmtree(self.tmp, ignore_errors=True)
478
+
479
+ def _page(self, method: str, params: dict | None = None, timeout: float = 20.0):
480
+ return self.cdp.call(method, params, self.session, timeout)
481
+
482
+ def _eval(self, expr: str, timeout: float = 15.0):
483
+ try:
484
+ r = self._page("Runtime.evaluate", {"expression": expr, "returnByValue": True,
485
+ "awaitPromise": True}, timeout)
486
+ except (RuntimeError, TimeoutError):
487
+ return None
488
+ return (r.get("result") or {}).get("value")
489
+
490
+ def _setup(self) -> None:
491
+ c = self.cdp
492
+ if not self.attach: # never change the downloads of someone's own browser
493
+ try:
494
+ c.call("Browser.setDownloadBehavior", {"behavior": "deny"})
495
+ except RuntimeError:
496
+ pass
497
+ tid = self.target = c.call("Target.createTarget", {"url": "about:blank", "background": True})["targetId"]
498
+ self.session = c.call("Target.attachToTarget", {"targetId": tid, "flatten": True})["sessionId"]
499
+ for dom in ("Page", "Network", "Runtime"):
500
+ self._page(f"{dom}.enable")
501
+ self._page("Network.setCacheDisabled", {"cacheDisabled": True})
502
+ self._page("Fetch.enable", {"patterns": [{"urlPattern": "*"}]})
503
+ if self.headers:
504
+ self._page("Network.setExtraHTTPHeaders", {"headers": self.headers})
505
+ origin = f"{self.start.scheme}://{self.start.netloc}"
506
+ for k, v in (self.cfg.cookies or {}).items():
507
+ self._page("Network.setCookie", {"name": k, "value": v, "url": origin})
508
+ inject = {}
509
+ inject.update(self.cfg.local_storage or {})
510
+ if self.cfg.auth_storage and self.headers.get(self.cfg.auth_header):
511
+ tok = self.headers[self.cfg.auth_header].split(" ", 1)[-1]
512
+ inject[self.cfg.auth_storage] = tok
513
+ self._page("Network.setCookie", {"name": self.cfg.auth_storage, "value": tok, "url": origin})
514
+ if inject:
515
+ js = "try{" + ";".join(f"localStorage.setItem({json.dumps(k)},{json.dumps(v)})"
516
+ for k, v in inject.items()) + "}catch(e){}"
517
+ self._page("Page.addScriptToEvaluateOnNewDocument", {"source": js})
518
+
519
+ # --------------------------------------------------------------- events --
520
+ # Image/Media/Font never carry an endpoint or app logic, so once the budget for them
521
+ # is spent the browser is left to render without them rather than starving the page
522
+ # and API requests that the model is actually built from.
523
+ _HEAVY_ASSET_TYPES = frozenset({"Image", "Media", "Font"})
524
+
525
+ def _allow(self, url: str, method: str, resource_type: str = "") -> bool:
526
+ if not self.in_scope(url):
527
+ self.stats["blocked_scope"] += 1
528
+ return False
529
+ u = urllib.parse.urlsplit(url)
530
+ if not self._journey_active and (_UNSAFE.search(u.path) or _UNSAFE.search(u.query)):
531
+ self.stats["blocked_unsafe"] += 1
532
+ return False
533
+ if self.cfg.read_only and method.upper() not in ("GET", "HEAD", "OPTIONS"):
534
+ self.stats["blocked_method"] += 1
535
+ return False
536
+ if resource_type in self._HEAVY_ASSET_TYPES and self.cfg.max_assets:
537
+ if self._heavy_assets >= self.cfg.max_assets:
538
+ self.stats["blocked_asset_cap"] += 1
539
+ return False
540
+ self._heavy_assets += 1
541
+ return True
542
+
543
+ def _handle(self, ev: dict) -> None:
544
+ m, p = ev["method"], ev.get("params", {})
545
+ sid = ev.get("sessionId")
546
+ if sid and sid != self.session:
547
+ return
548
+ if m == "Fetch.requestPaused":
549
+ req = p["request"]
550
+ try:
551
+ if self._allow(req["url"], req["method"], p.get("resourceType", "")):
552
+ self._page("Fetch.continueRequest", {"requestId": p["requestId"]}, 5)
553
+ else:
554
+ self._page("Fetch.failRequest", {"requestId": p["requestId"],
555
+ "errorReason": "BlockedByClient"}, 5)
556
+ except (RuntimeError, TimeoutError):
557
+ pass
558
+ elif m == "Page.javascriptDialogOpening":
559
+ self.stats["dialogs_dismissed"] += 1
560
+ try:
561
+ self._page("Page.handleJavaScriptDialog",
562
+ {"accept": p.get("type") == "alert"}, 5)
563
+ except (RuntimeError, TimeoutError):
564
+ pass
565
+ elif m == "Network.requestWillBeSent":
566
+ rid = p["requestId"]
567
+ if p.get("redirectResponse") and rid in self.recs:
568
+ old = self.recs[rid]
569
+ old.resp, old.done, old.redirect = p["redirectResponse"], True, True
570
+ self.finalised.append(old)
571
+ self.inflight.discard(rid)
572
+ rec = _Rec(rid)
573
+ rec.req, rec.rtype = p["request"], p.get("type", "")
574
+ rec.post = rec.req.get("postData")
575
+ self.recs[rid] = rec
576
+ if not _BACKGROUND.search(rec.req.get("url", "")):
577
+ self.inflight.add(rid)
578
+ self.started[rid] = time.monotonic()
579
+ self.last_net = time.monotonic()
580
+ elif m == "Network.requestWillBeSentExtraInfo":
581
+ if p["requestId"] in self.recs:
582
+ self.recs[p["requestId"]].extra_req = p.get("headers")
583
+ elif m == "Network.responseReceived":
584
+ if p["requestId"] in self.recs:
585
+ self.recs[p["requestId"]].resp = p["response"]
586
+ elif m == "Network.responseReceivedExtraInfo":
587
+ if p["requestId"] in self.recs:
588
+ self.recs[p["requestId"]].extra_resp = p.get("headers")
589
+ elif m == "Network.loadingFinished":
590
+ rid = p["requestId"]
591
+ rec = self.recs.get(rid)
592
+ self.inflight.discard(rid)
593
+ if rec and not _BACKGROUND.search((rec.req or {}).get("url", "")):
594
+ self.last_net = time.monotonic()
595
+ if rec and rec.resp:
596
+ self._fetch_body(rec)
597
+ rec.done = True
598
+ self.finalised.append(rec)
599
+ elif m == "Network.loadingFailed":
600
+ self.inflight.discard(p["requestId"])
601
+ self.last_net = time.monotonic()
602
+ rec = self.recs.get(p["requestId"])
603
+ if rec:
604
+ rec.failed = True
605
+
606
+ def _fetch_body(self, rec: _Rec) -> None:
607
+ if rec.rtype in ("Image", "Font", "Media") or not rec.resp:
608
+ return
609
+ try:
610
+ r = self._page("Network.getResponseBody", {"requestId": rec.id}, 15)
611
+ body = r.get("body", "")
612
+ rec.body = base64.b64decode(body) if r.get("base64Encoded") else body.encode("utf-8", "replace")
613
+ rec.body = rec.body[: self.cfg.max_body]
614
+ except (RuntimeError, TimeoutError, ValueError):
615
+ pass
616
+ if rec.req and rec.req.get("hasPostData") and rec.post is None:
617
+ try:
618
+ rec.post = self._page("Network.getRequestPostData", {"requestId": rec.id}, 5).get("postData")
619
+ except (RuntimeError, TimeoutError):
620
+ pass
621
+
622
+ def _pump(self, seconds: float) -> None:
623
+ end = time.monotonic() + seconds
624
+ while time.monotonic() < end:
625
+ ev = self.cdp.next_event(max(0.01, end - time.monotonic()))
626
+ if ev is None:
627
+ return
628
+ self._handle(ev)
629
+
630
+ def _settle(self, quiet: float = 0.6, limit: float = 12.0) -> None:
631
+ """Wait for the network to go quiet (nothing in flight for `quiet` seconds)."""
632
+ end = time.monotonic() + limit
633
+ self.last_net = time.monotonic()
634
+ while time.monotonic() < min(end, self._deadline):
635
+ self._pump(0.1)
636
+ now = time.monotonic()
637
+ # a request still open after 2.5 s is a long-poll or an event stream
638
+ # (socket.io, SSE); it must not keep the page "busy" forever
639
+ young = [r for r in self.inflight if now - self.started.get(r, now) < 2.5]
640
+ if not young and now - self.last_net >= quiet:
641
+ return
642
+
643
+ # ------------------------------------------------------------- recording --
644
+ def _to_item(self, rec: _Rec) -> Element | None:
645
+ item = rec_to_item(rec, self.in_scope)
646
+ if item is None:
647
+ return None
648
+ low = {k.lower(): v for k, v in (rec.resp.get("headers") or {}).items()}
649
+ ct = low.get("content-type", rec.resp.get("mimeType", "")).lower()
650
+ if rec.body and ("javascript" in ct or "html" in ct) and len(self._scripts) < 400:
651
+ self._scripts.append((rec.req["url"], rec.body.decode("utf-8", "replace")))
652
+ return item
653
+
654
+ def _drain(self) -> None:
655
+ while self.finalised:
656
+ rec = self.finalised.pop(0)
657
+ item = self._to_item(rec)
658
+ if item is None:
659
+ continue
660
+ self.items.append(item)
661
+ self.stats["requests"] += 1
662
+ if rec.rtype in ("Document",):
663
+ self.stats["pages"] += 1
664
+ elif rec.rtype in ("XHR", "Fetch"):
665
+ self.stats["js_probes"] += 1
666
+ else:
667
+ self.stats["assets"] += 1
668
+
669
+ def _budget_left(self) -> bool:
670
+ if time.monotonic() > self._deadline:
671
+ return False
672
+ return not self.cfg.max_requests or self.stats["requests"] < self.cfg.max_requests
673
+
674
+ # -------------------------------------------------------------- crawling --
675
+ def _state_url(self) -> str:
676
+ return self._eval("location.href") or ""
677
+
678
+ def _navigate(self, url: str) -> bool:
679
+ try:
680
+ self._page("Page.navigate", {"url": url}, 25)
681
+ except (RuntimeError, TimeoutError):
682
+ return False
683
+ self._pump(0.2)
684
+ self._settle()
685
+ self._drain()
686
+ self._eval(_SAFE_JS)
687
+ return True
688
+
689
+ @staticmethod
690
+ def _key(url: str) -> str:
691
+ """Canonical state key: the fragment only counts for hash routes (#/ and #!)."""
692
+ base, _, frag = url.partition("#")
693
+ return base + ("#" + frag if frag.startswith(("/", "!")) else "")
694
+
695
+ def _enqueue(self, url: str, depth: int, referer: str, queue: deque, kind="page") -> None:
696
+ url = url.split("#")[0] + ("#" + url.partition("#")[2] if url.partition("#")[2].startswith(("/", "!")) else "")
697
+ if not url.startswith(("http://", "https://")) or not self.in_scope(url):
698
+ return
699
+ if _UNSAFE.search(urllib.parse.urlsplit(url).path):
700
+ return
701
+ key = self._key(url)
702
+ if ("GET", key) in self.seen:
703
+ return
704
+ self.seen.add(("GET", key))
705
+ frag = url.partition("#")[2]
706
+ us = urllib.parse.urlsplit(url)
707
+ t = (us.netloc, route_template(us.path + ("?" + us.query if us.query else ""), template_path(us.path)),
708
+ template_path("/" + frag.lstrip("/!")) if frag.startswith(("/", "!")) else "")
709
+ if self.template_count.get(t, 0) >= self.cfg.per_template_cap:
710
+ self.stats["skipped_cap"] += 1
711
+ return
712
+ self.template_count[t] = self.template_count.get(t, 0) + 1
713
+ queue.append((url, depth))
714
+
715
+ def _harvest(self, depth: int, queue: deque, page_url: str) -> dict:
716
+ h = self._eval(_HARVEST_JS) or {}
717
+ base = h.get("href", page_url)
718
+ for link in h.get("links", []):
719
+ if isinstance(link, dict): # [routerLink] -> a route on this app
720
+ route = link["route"]
721
+ origin = f"{self.start.scheme}://{self.start.netloc}"
722
+ dest = f"{origin}/#{route}" if h.get("hashRouting") else origin + route
723
+ self._enqueue(dest, depth + 1, base, queue)
724
+ elif isinstance(link, str):
725
+ self._enqueue(link, depth + 1, base, queue)
726
+ for f in h.get("forms", []):
727
+ self.stats["forms_found"] += 1
728
+ self.forms.append({"page": base, "action": urllib.parse.urlsplit(f["action"]).path or "/",
729
+ "method": f["method"], "fields": f["fields"],
730
+ **({"note": "password field — never submitted"} if f["password"] else {})})
731
+ return h
732
+
733
+ def _interact(self, url: str, depth: int, queue: deque) -> None:
734
+ """Click what a user would, fill and submit forms, scroll; follow what changes."""
735
+ cfg = self.cfg
736
+ if cfg.no_interact:
737
+ return
738
+ clicked: set = set()
739
+ clicks = 0
740
+ # fill empty fields first, so a click on a form's own submit button sends real values
741
+ filled = self._eval(_FILL_JS) or []
742
+ self._eval(_SCROLL_JS)
743
+ self._settle(0.4, 5)
744
+ while clicks < cfg.max_clicks and self._budget_left():
745
+ cands = self._eval(_CLICKABLES_JS) or []
746
+ nxt = next((c for c in cands if c["sig"] not in clicked), None)
747
+ if nxt is None:
748
+ break
749
+ clicked.add(nxt["sig"])
750
+ clicks += 1
751
+ self.stats["clicks"] += 1
752
+ self._eval(_click_js(nxt["i"]))
753
+ self._pump(0.15)
754
+ self._settle(0.5, 8)
755
+ self._drain()
756
+ now = self._state_url()
757
+ if now and self._key(now) != self._key(url):
758
+ self._enqueue(now, depth + 1, url, queue)
759
+ if not self._navigate(url):
760
+ return
761
+ if self._key(self._state_url()) != self._key(url):
762
+ return
763
+ else:
764
+ self._harvest(depth, queue, url) # a menu may have revealed new links
765
+ try: # close a menu or dialog it opened
766
+ self._page("Input.dispatchKeyEvent", {"type": "keyDown", "key": "Escape", "code": "Escape",
767
+ "windowsVirtualKeyCode": 27}, 3)
768
+ self._page("Input.dispatchKeyEvent", {"type": "keyUp", "key": "Escape", "code": "Escape",
769
+ "windowsVirtualKeyCode": 27}, 3)
770
+ except (RuntimeError, TimeoutError):
771
+ pass
772
+ # press Enter in a search box, submit the forms that are safe to submit
773
+ filled = (self._eval(_FILL_JS) or []) or filled
774
+ if filled:
775
+ self._settle(0.3, 4)
776
+ if self._eval(_ENTER_JS):
777
+ self._settle(0.5, 6)
778
+ self._drain()
779
+ if not cfg.no_forms:
780
+ submitted = self._eval(_SUBMIT_JS) or 0
781
+ self.stats["forms_submitted"] += int(submitted)
782
+ if submitted:
783
+ self._settle(0.6, 8)
784
+ self._drain()
785
+ now = self._state_url()
786
+ if now and self._key(now) != self._key(url):
787
+ self._enqueue(now, depth + 1, url, queue)
788
+ self._navigate(url)
789
+
790
+ def _route_seeds(self, queue: deque, depth: int) -> None:
791
+ """Router paths written into a bundle (`path:"basket"`), for hash- and history-routed SPAs."""
792
+ origin = f"{self.start.scheme}://{self.start.netloc}"
793
+ hash_mode = "#/" in (self._state_url() or "") or self.cfg.start.find("#/") != -1
794
+ paths: set[str] = set()
795
+ for _, text in self._scripts:
796
+ if len(text) < 2000:
797
+ continue
798
+ Q = """["'`]"""
799
+ for m in re.finditer(r"path\s*:\s*" + Q + r"([A-Za-z0-9_\-/]{1,40})" + Q, text):
800
+ p = m.group(1).strip("/")
801
+ if p and not _UNSAFE.search(p):
802
+ paths.add(p)
803
+ # one level of nesting: {path:`account`, ..., children:[{path:`orders`}, ...]}
804
+ for m in re.finditer(r"path\s*:\s*" + Q + r"([A-Za-z0-9_\-/]{1,40})" + Q
805
+ + r"[^\[\]{}]{0,80}children\s*:\s*\[", text):
806
+ for c in re.finditer(r"path\s*:\s*" + Q + r"([A-Za-z0-9_\-/]{1,40})" + Q,
807
+ text[m.end(): m.end() + 900].split("]}")[0]):
808
+ child = c.group(1).strip("/")
809
+ if child and not _UNSAFE.search(child):
810
+ paths.add(f"{m.group(1).strip('/')}/{child}")
811
+ for p in sorted(paths)[:200]:
812
+ self._enqueue(f"{origin}/#/{p}" if hash_mode else f"{origin}/{p}", depth, origin + "/", queue, "route")
813
+
814
+ def run(self) -> CrawlResult:
815
+ cfg = self.cfg
816
+ try:
817
+ if cfg.auth_login:
818
+ self._login() # plain HTTP, once; the token is then given to the browser
819
+ self._launch()
820
+ self._setup()
821
+ if cfg.journeys:
822
+ self._run_journeys()
823
+ if cfg.journey_only:
824
+ self._settle(0.3, 3)
825
+ self._drain()
826
+ self._sync_cookies()
827
+ return CrawlResult(self.items, self.stats, self.forms, self.scope, self.screenshot)
828
+ queue: deque = deque()
829
+ start = cfg.start
830
+ self.seen.add(("GET", self._key(start)))
831
+ queue.append((start, 0))
832
+ routes_done = False
833
+ todo: deque = deque() # states visited, not yet clicked through
834
+ # Breadth first: reach every page state before spending clicks inside any of them,
835
+ # so a time or request budget runs out on depth, not on breadth.
836
+ while self._budget_left() and (queue or todo):
837
+ if queue:
838
+ url, depth = queue.popleft()
839
+ if depth > cfg.max_depth:
840
+ self.stats["skipped_cap"] += 1
841
+ continue
842
+ if not self._navigate(url):
843
+ self.stats["errors"] += 1
844
+ continue
845
+ self.stats["states"] += 1
846
+ if cfg.progress:
847
+ print(f"crawl: [{self.stats['requests']} req] {url}", file=sys.stderr)
848
+ self._harvest(depth, queue, url)
849
+ if cfg.screenshot and self.screenshot is None:
850
+ self.screenshot = self._capture()
851
+ todo.append((url, depth))
852
+ if not routes_done:
853
+ self._route_seeds(queue, depth + 1)
854
+ routes_done = True
855
+ else:
856
+ url, depth = todo.popleft()
857
+ if self._key(self._state_url()) != self._key(url) and not self._navigate(url):
858
+ continue
859
+ self._interact(url, depth, queue)
860
+ self._settle(0.3, 3)
861
+ self._drain()
862
+ self._sync_cookies()
863
+ finally:
864
+ self._close()
865
+ if not self.attach: # attached: the browser's own cookies are not ours to copy
866
+ self._static_pass()
867
+ return CrawlResult(self.items, self.stats, self.forms, self.scope, self.screenshot)
868
+
869
+ # ------------------------------------------------------------ journeys ---
870
+ _KEYS = {"Enter": (13, "\r"), "Tab": (9, ""), "Escape": (27, ""), "ArrowDown": (40, ""),
871
+ "ArrowUp": (38, ""), "Space": (32, " "), "Backspace": (8, "")}
872
+
873
+ def _run_journeys(self) -> None:
874
+ from . import journey as jy
875
+ self._journey_active = True
876
+ try:
877
+ for j in self.cfg.journeys:
878
+ variables: dict = {}
879
+ try:
880
+ for k, v in j.vars.items():
881
+ variables[k] = jy.substitute(v, {**variables, **self.cfg.journey_vars})
882
+ variables.update(self.cfg.journey_vars)
883
+ except jy.JourneyError as e:
884
+ self._journey_note(j, 0, "vars", str(e))
885
+ continue
886
+ if self.cfg.progress:
887
+ print(f"crawl: journey {j.name!r} ({len(j.steps)} steps)", file=sys.stderr)
888
+ for n, step in enumerate(j.steps, 1):
889
+ kind = next(k for k in step if k in jy.STEPS)
890
+ self.stats["journey_steps"] += 1
891
+ try:
892
+ self._step(kind, jy.substitute(step[kind], variables), variables)
893
+ self._pump(0.1)
894
+ self._drain()
895
+ except (jy.JourneyError, RuntimeError, TimeoutError) as e:
896
+ self._journey_note(j, n, kind, str(e))
897
+ if step.get("required"):
898
+ print(f"crawl: journey {j.name!r} aborted at step {n} (required)", file=sys.stderr)
899
+ break
900
+ finally:
901
+ self._journey_active = False
902
+
903
+ def _ensure_origin(self) -> None:
904
+ origin = f"{self.start.scheme}://{self.start.netloc}"
905
+ if (self._eval("location.origin") or "") != origin:
906
+ self._navigate(origin + "/")
907
+
908
+ def _step(self, kind: str, arg, variables: dict) -> None:
909
+ from . import journey as jy
910
+ origin = f"{self.start.scheme}://{self.start.netloc}"
911
+ if kind == "goto":
912
+ url = urllib.parse.urljoin(origin + "/", arg)
913
+ if not self.in_scope(url):
914
+ raise jy.JourneyError(f"{url} is outside the crawl scope")
915
+ if not self._navigate(url):
916
+ raise jy.JourneyError(f"could not load {url}")
917
+ elif kind == "click":
918
+ sel, text = (arg, "") if isinstance(arg, str) else (arg.get("selector", ""), arg.get("text", ""))
919
+ self._eval(_SAFE_JS)
920
+ ok = self._eval(
921
+ "((sel, text) => { const vis = window.__b2m ? window.__b2m.vis : () => true;"
922
+ " const label = window.__b2m ? window.__b2m.label : e => (e.innerText || '');"
923
+ " let els = Array.from(document.querySelectorAll(sel || 'button,a,[role=button],[role=tab],[role=menuitem],"
924
+ "input[type=submit],summary,[routerlink],[onclick]')).filter(vis);"
925
+ " if (text) els = els.filter(e => (label(e) || '').toLowerCase().includes(text.toLowerCase()));"
926
+ " const e = els[0]; if (!e) return false; e.scrollIntoView({block:'center'}); e.click(); return true; })(%s, %s)"
927
+ % (json.dumps(sel), json.dumps(text)))
928
+ if not ok:
929
+ raise jy.JourneyError(f"nothing to click for {arg!r}")
930
+ self._pump(0.15)
931
+ self._settle(0.5, 8)
932
+ elif kind == "fill":
933
+ sel = arg.get("selector") or f"[name={json.dumps(arg['name'])}]"
934
+ ok = self._eval(
935
+ "((sel, v) => { const e = document.querySelector(sel); if (!e) return false; e.focus();"
936
+ " const d = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(e), 'value');"
937
+ " d && d.set ? d.set.call(e, v) : (e.value = v);"
938
+ " e.dispatchEvent(new Event('input', {bubbles:true})); e.dispatchEvent(new Event('change', {bubbles:true}));"
939
+ " return true; })(%s, %s)" % (json.dumps(sel), json.dumps(str(arg["value"]))))
940
+ if not ok:
941
+ raise jy.JourneyError(f"no field matches {sel!r}")
942
+ elif kind == "press":
943
+ code, ch = self._KEYS.get(str(arg), (0, str(arg) if len(str(arg)) == 1 else ""))
944
+ for t in ("keyDown", "keyUp"):
945
+ self._page("Input.dispatchKeyEvent", {"type": t, "key": str(arg), "code": str(arg),
946
+ "windowsVirtualKeyCode": code,
947
+ **({"text": ch} if ch and t == "keyDown" else {})}, 5)
948
+ self._pump(0.15)
949
+ self._settle(0.5, 8)
950
+ elif kind == "wait":
951
+ self._pump(float(arg))
952
+ elif kind == "wait_for":
953
+ sel, text, timeout = (arg, "", 10.0) if isinstance(arg, str) else (
954
+ arg.get("selector", ""), arg.get("text", ""), float(arg.get("timeout", 10)))
955
+ end = time.monotonic() + timeout
956
+ while time.monotonic() < end:
957
+ if self._eval("((s, t) => { const q = s ? document.querySelector(s) : document.body;"
958
+ " return !!q && (!t || (q.innerText || '').includes(t)); })(%s, %s)"
959
+ % (json.dumps(sel), json.dumps(text))):
960
+ return
961
+ self._pump(0.2)
962
+ raise jy.JourneyError(f"timed out waiting for {arg!r}")
963
+ elif kind == "wait_idle":
964
+ self._settle(0.6, 12)
965
+ elif kind == "assert":
966
+ state = self._eval("({url: location.href, text: (document.body ? document.body.innerText : ''), "
967
+ "has: %s})" % (("!!document.querySelector(%s)" % json.dumps(arg["selector"]))
968
+ if arg.get("selector") else "true")) or {}
969
+ if arg.get("url_contains") and arg["url_contains"] not in state.get("url", ""):
970
+ raise jy.JourneyError(f"expected the URL to contain {arg['url_contains']!r}, got {state.get('url')!r}")
971
+ if arg.get("text") and arg["text"] not in state.get("text", ""):
972
+ raise jy.JourneyError(f"expected the page to contain {arg['text']!r}")
973
+ if arg.get("not_text") and arg["not_text"] in state.get("text", ""):
974
+ raise jy.JourneyError(f"the page contains {arg['not_text']!r}")
975
+ if not state.get("has", True):
976
+ raise jy.JourneyError(f"no element matches {arg['selector']!r}")
977
+ elif kind == "scroll":
978
+ self._eval(_SCROLL_JS)
979
+ self._settle(0.4, 5)
980
+ elif kind == "set_header":
981
+ self.headers.update({k: str(v) for k, v in arg.items()})
982
+ self._page("Network.setExtraHTTPHeaders", {"headers": self.headers})
983
+ elif kind == "set_cookie":
984
+ for k, v in arg.items():
985
+ self._page("Network.setCookie", {"name": k, "value": str(v), "url": origin})
986
+ elif kind == "set_storage":
987
+ self._ensure_origin()
988
+ js = ";".join(f"localStorage.setItem({json.dumps(k)},{json.dumps(str(v))})" for k, v in arg.items())
989
+ self._eval(js)
990
+ self._page("Page.addScriptToEvaluateOnNewDocument", {"source": "try{" + js + "}catch(e){}"})
991
+ elif kind == "request":
992
+ self._request_step(arg, origin, variables)
993
+
994
+ def _request_step(self, spec: dict, origin: str, variables: dict) -> None:
995
+ """An HTTP request made from inside the page, so it carries the session and is recorded."""
996
+ from . import journey as jy
997
+ url = spec.get("url") or urllib.parse.urljoin(origin + "/", spec["path"])
998
+ if not self.in_scope(url):
999
+ raise jy.JourneyError(f"{url} is outside the crawl scope")
1000
+ method = spec.get("method", "GET").upper()
1001
+ headers = dict(spec.get("headers") or {})
1002
+ body = None
1003
+ if "json" in spec:
1004
+ body = json.dumps(spec["json"])
1005
+ headers.setdefault("Content-Type", "application/json")
1006
+ elif "form" in spec:
1007
+ body = urllib.parse.urlencode(spec["form"])
1008
+ headers.setdefault("Content-Type", "application/x-www-form-urlencoded")
1009
+ elif "body" in spec:
1010
+ body = str(spec["body"])
1011
+ self._ensure_origin()
1012
+ res = self._eval(
1013
+ "(async (u, m, h, b) => { try { const r = await fetch(u, {method: m, headers: h, body: b, credentials: 'include'});"
1014
+ " const t = await r.text(); return {status: r.status, text: t.slice(0, 2000000)}; }"
1015
+ " catch (e) { return {status: 0, error: String(e)}; } })(%s, %s, %s, %s)"
1016
+ % (json.dumps(url), json.dumps(method), json.dumps(headers), json.dumps(body)), 40)
1017
+ if not isinstance(res, dict) or not res.get("status"):
1018
+ why = (res or {}).get("error", "no response") if isinstance(res, dict) else "no response"
1019
+ raise jy.JourneyError(f"{method} {url} was not sent or failed ({why}; blocked by scope or --read-only?)")
1020
+ expect = spec.get("expect")
1021
+ if expect is not None and res["status"] not in ([expect] if isinstance(expect, int) else expect):
1022
+ raise jy.JourneyError(f"{method} {url} answered {res['status']}, expected {expect}")
1023
+ if spec.get("extract"):
1024
+ try:
1025
+ data = json.loads(res.get("text", ""))
1026
+ except ValueError:
1027
+ data = None
1028
+ for name, path in spec["extract"].items():
1029
+ val = jy.dig(data, path) if isinstance(path, str) else None
1030
+ if val is None:
1031
+ raise jy.JourneyError(f"could not extract {name!r} from {path!r} in the response")
1032
+ variables[name] = val
1033
+
1034
+ def _sync_cookies(self) -> None:
1035
+ try:
1036
+ for c in self._page("Network.getAllCookies", {}, 8).get("cookies", []):
1037
+ host = c.get("domain", "").lstrip(".").lower()
1038
+ self.jar.setdefault(host or self.start.hostname.lower(), {})[c["name"]] = c["value"]
1039
+ except (RuntimeError, TimeoutError, ConnectionError, AttributeError):
1040
+ pass
1041
+
1042
+ def _static_pass(self) -> None:
1043
+ """GET the concrete endpoints the bundles name that the UI never triggered."""
1044
+ if not self.cfg.probe_js:
1045
+ return
1046
+ pending: list[Task] = []
1047
+ for url, text in self._scripts:
1048
+ self._probe_refs(Task("GET", url, 0, self.cfg.start, "js"), text, pending)
1049
+ for task in pending:
1050
+ if not self._budget_left():
1051
+ break
1052
+ got = self.fetch(task)
1053
+ if got is None:
1054
+ continue
1055
+ item, *_ = got
1056
+ self.items.append(item)
1057
+ self.stats["requests"] += 1
1058
+ self.stats["js_probes"] += 1