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,208 @@
1
+ """
2
+ Static endpoint extraction from JavaScript and HTML bodies.
3
+
4
+ A reference found in code is a *claim* that the app may call an endpoint, not
5
+ proof that it does, so everything extracted here becomes an INFERRED edge in
6
+ the model. Each reference is a (method, host, path, kind) tuple:
7
+
8
+ method "GET", "POST", ... when the call site names it, else None
9
+ host the host of an absolute URL, else None (relative to the page origin)
10
+ path templated the same way runtime paths are (`/users/{id}`), so a
11
+ reference and a request for the same endpoint reconcile
12
+ kind "call" (fetch/axios/XHR/verb call), "string" (bare API-looking
13
+ string literal) or "form" (an HTML <form action>)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ import urllib.parse
20
+
21
+ from .redact import template_path
22
+
23
+ # Bodies larger than this are scanned only up to the cap and flagged.
24
+ MAX_SCAN_BYTES = 10 * 1024 * 1024
25
+
26
+ # Host of a reference whose base URL the code keeps in a variable (`${apiHost}/v1/accounts`):
27
+ # the path is known, the host is not, so it is never pinned to the page's own host.
28
+ VAR_BASE = "{base}"
29
+
30
+ API_HINT = re.compile(r"/(api|v\d+|graphql|rest|gql)(/|$)", re.I)
31
+
32
+ # `${expr}` inside template literals becomes a {param} placeholder.
33
+ _TEMPLATE_EXPR_RE = re.compile(r"\$\{[^{}`]{0,120}\}")
34
+
35
+ # A URL-ish literal: optional scheme+host, then a path. `{param}` may appear
36
+ # (from template literals) anywhere, including as a leading base-URL prefix.
37
+ _URL_BODY = r"(?:\{param\})?(?:https?:)?/[^\s\"'`<>\\]{0,300}?"
38
+
39
+ _VERB_CALL_RE = re.compile(
40
+ r"\.(get|post|put|patch|delete|head)\s*\(\s*([\"'`])(" + _URL_BODY + r")\2", re.I)
41
+ _FETCH_RE = re.compile(
42
+ r"(?:\b(?:fetch|axios|ajax|request)|\$http)\s*\(\s*([\"'`])(" + _URL_BODY + r")\1")
43
+ _XHR_OPEN_RE = re.compile(
44
+ r"\.open\s*\(\s*[\"'](GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)[\"']\s*,\s*"
45
+ r"([\"'`])(" + _URL_BODY + r")\2", re.I)
46
+ _METHOD_NEAR_RE = re.compile(r"""method\s*:\s*["'`](GET|POST|PUT|PATCH|DELETE|HEAD)["'`]""", re.I)
47
+ _STRING_RE = re.compile(r"([\"'`])(" + _URL_BODY + r")\1")
48
+
49
+ _FORM_RE = re.compile(r"<form\b([^>]*)>", re.I)
50
+ _ATTR_RE = re.compile(r"""\b(action|method)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))""", re.I)
51
+
52
+ _STATIC_EXT = re.compile(
53
+ r"\.(?:png|jpe?g|gif|svg|ico|webp|avif|css|woff2?|ttf|otf|eot|map|mp4|webm|mp3|"
54
+ r"html?|js|mjs|json|txt|md)$", re.I)
55
+
56
+
57
+ def _normalize(raw: str) -> tuple[str | None, str] | None:
58
+ """Split a raw literal into (host, templated path); None if not a path."""
59
+ raw = raw.strip()
60
+ var_base = raw.startswith("{param}")
61
+ if var_base:
62
+ raw = raw[len("{param}"):] # the base URL is a variable: the host is unknown
63
+ if raw.startswith("//"):
64
+ raw = "https:" + raw
65
+ host = None
66
+ if raw.lower().startswith(("http://", "https://")):
67
+ try:
68
+ parts = urllib.parse.urlsplit(raw)
69
+ except ValueError: # `https://[{x}]/…` in minified code is not a URL
70
+ return None
71
+ host = (parts.hostname or "").lower() or None
72
+ path = parts.path or "/"
73
+ else:
74
+ if not raw.startswith("/"):
75
+ return None
76
+ path = raw.split("?", 1)[0].split("#", 1)[0]
77
+ if "//" in path or len(path) > 200:
78
+ return None
79
+ # `"/api/users/" + id` — a trailing slash is a concatenation point
80
+ if len(path) > 1 and path.endswith("/"):
81
+ path = path + "{param}"
82
+ return host or (VAR_BASE if var_base else None), template_path(path)
83
+
84
+
85
+ def _ref(method, raw, kind, out: set) -> None:
86
+ norm = _normalize(raw)
87
+ if norm is None:
88
+ return
89
+ host, path = norm
90
+ if path == "/" and host in (None, VAR_BASE):
91
+ return
92
+ # `${this.host}/${id}/reviews` — the base is a service prefix the bundle
93
+ # keeps in a variable, so all that is left is `/{param}/reviews`. That is
94
+ # not a path anything can be matched to; drop it rather than invent a
95
+ # root-level endpoint (an API-looking segment after it keeps it: `/{t}/api/x`).
96
+ if host in (None, VAR_BASE) and path.startswith("/{param}") and not API_HINT.search(path):
97
+ return
98
+ out.add((method.upper() if method else None, host, path, kind))
99
+
100
+
101
+ def extract_js_refs(body: str) -> set[tuple[str | None, str | None, str, str]]:
102
+ """Endpoint references inside JavaScript (or inline <script>) text."""
103
+ out: set = set()
104
+ if not body:
105
+ return out
106
+ body = _TEMPLATE_EXPR_RE.sub("{param}", body)
107
+ call_spans: list[tuple[int, int]] = []
108
+
109
+ for m in _XHR_OPEN_RE.finditer(body):
110
+ _ref(m.group(1), m.group(3), "call", out)
111
+ call_spans.append(m.span(3))
112
+ for m in _VERB_CALL_RE.finditer(body):
113
+ _ref(m.group(1), m.group(3), "call", out)
114
+ call_spans.append(m.span(3))
115
+ for m in _FETCH_RE.finditer(body):
116
+ # fetch(url, {method: "POST"}) — read the method only from THIS call's
117
+ # options object, i.e. before the statement ends or the next call begins,
118
+ # so a later call's method can't bleed onto this one.
119
+ window = body[m.end(): m.end() + 200]
120
+ stop = len(window)
121
+ for tok in (";", "fetch(", "axios", ".open(", ".then(", "\n\n"):
122
+ i = window.find(tok)
123
+ if i != -1:
124
+ stop = min(stop, i)
125
+ near = _METHOD_NEAR_RE.search(window[:stop])
126
+ _ref(near.group(1) if near else None, m.group(2), "call", out)
127
+ call_spans.append(m.span(2))
128
+
129
+ called = {s for s in call_spans}
130
+ for m in _STRING_RE.finditer(body):
131
+ if m.span(2) in called:
132
+ continue
133
+ raw = m.group(2)
134
+ norm = _normalize(raw)
135
+ if norm is None:
136
+ continue
137
+ _, path = norm
138
+ # a bare string is only interesting if it looks like an API route
139
+ if API_HINT.search(path) and not _STATIC_EXT.search(path):
140
+ _ref(None, raw, "string", out)
141
+ return out
142
+
143
+
144
+ def extract_html_refs(body: str) -> set[tuple[str | None, str | None, str, str]]:
145
+ """<form action> targets plus references inside inline scripts."""
146
+ out: set = set()
147
+ if not body:
148
+ return out
149
+ for fm in _FORM_RE.finditer(body):
150
+ attrs = {}
151
+ for am in _ATTR_RE.finditer(fm.group(1)):
152
+ attrs[am.group(1).lower()] = am.group(2) or am.group(3) or am.group(4) or ""
153
+ action = attrs.get("action", "").strip()
154
+ if not action or action.startswith(("#", "javascript:", "mailto:")):
155
+ continue
156
+ method = (attrs.get("method") or "GET").upper()
157
+ _ref(method if method in ("GET", "POST") else "GET", action, "form", out)
158
+ out |= extract_js_refs(body)
159
+ return out
160
+
161
+
162
+ # ---- source maps and workers -------------------------------------------------
163
+
164
+ _SOURCEMAP_RE = re.compile(r"//[#@]\s*sourceMappingURL=([^\s'\"]+)")
165
+ _WORKER_RES = (
166
+ ("worker", re.compile(r"new\s+(?:Shared)?Worker\s*\(\s*([\"'`])([^\"'`\s]{1,200})\1")),
167
+ ("service_worker", re.compile(r"serviceWorker\s*\.\s*register\s*\(\s*([\"'`])([^\"'`\s]{1,200})\1")),
168
+ ("import_scripts", re.compile(r"importScripts\s*\(\s*([\"'`])([^\"'`\s]{1,200})\1")),
169
+ )
170
+
171
+
172
+ def _resolve_asset(raw: str, script_host: str, script_path: str):
173
+ """Resolve a reference found in a script to (host, templated path); None if not a file path."""
174
+ raw = raw.strip()
175
+ if not raw or raw.startswith(("data:", "blob:", "{param}")) or "{param}" in raw:
176
+ return None
177
+ try:
178
+ parts = urllib.parse.urlsplit(raw if "://" in raw or raw.startswith("//") else
179
+ urllib.parse.urljoin("https://x" + script_path, raw))
180
+ except ValueError:
181
+ return None
182
+ if "://" in raw or raw.startswith("//"):
183
+ host = (parts.hostname or "").lower() or script_host
184
+ path = parts.path
185
+ else:
186
+ host, path = script_host, parts.path
187
+ if not path.startswith("/") or "//" in path or len(path) > 200:
188
+ return None
189
+ return host, template_path(path)
190
+
191
+
192
+ def extract_script_assets(body: str, host: str, path: str,
193
+ header_map: str | None = None) -> set[tuple[str, str, str]]:
194
+ """Source maps and workers a script names: a set of (kind, host, path).
195
+
196
+ kind is "sourcemap", "worker", "service_worker" or "import_scripts". Like every code
197
+ reference these are claims about the code; the model records them as INFERRED."""
198
+ out: set = set()
199
+ refs = [("sourcemap", m.group(1)) for m in _SOURCEMAP_RE.finditer(body or "")]
200
+ if header_map:
201
+ refs.append(("sourcemap", header_map))
202
+ for kind, rx in _WORKER_RES:
203
+ refs += [(kind, m.group(2)) for m in rx.finditer(body or "")]
204
+ for kind, raw in refs:
205
+ r = _resolve_asset(raw, host, path)
206
+ if r:
207
+ out.add((kind, r[0], r[1]))
208
+ return out
@@ -0,0 +1,75 @@
1
+ """
2
+ Infer what kind of value a parameter carries, from its raw value.
3
+
4
+ Runs in parse.py before values are masked, so only the label survives — the
5
+ value itself never reaches the model. The checks run as a waterfall, most
6
+ specific first, and the first match wins:
7
+
8
+ jwt -> uuid -> email -> integer -> number -> boolean -> base64 -> string
9
+
10
+ An empty value says nothing about its type and yields None.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+
17
+ TYPES = ("jwt", "uuid", "email", "integer", "number", "boolean", "base64", "string")
18
+
19
+ # ASCII classes only, so the Java engine (Infer.java) labels every value the same way
20
+ _JWT_RE = re.compile(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*")
21
+ _UUID_RE = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
22
+ _EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
23
+ _INT_RE = re.compile(r"-?[0-9]{1,19}")
24
+ _NUM_RE = re.compile(r"-?[0-9]+\.[0-9]+(?:[eE][+-]?[0-9]+)?")
25
+ _B64_RE = re.compile(r"[A-Za-z0-9+/]+")
26
+ _B64URL_RE = re.compile(r"[A-Za-z0-9_-]+")
27
+ _UPPER_RE = re.compile(r"[A-Z]")
28
+ _LOWER_RE = re.compile(r"[a-z]")
29
+ _B64_TELL_RE = re.compile(r"[0-9+/=_-]")
30
+
31
+
32
+ def _is_base64(v: str) -> bool:
33
+ # short alphanumerics (`admin`, `true1`) are valid base64 by accident: demand
34
+ # length, well-formed padding, and the mix of cases and digits words rarely have
35
+ core = v.rstrip("=")
36
+ pad = len(v) - len(core)
37
+ if pad > 2 or (pad and len(v) % 4) or len(core) < 12 or len(core) % 4 == 1:
38
+ return False
39
+ if not (_B64_RE.fullmatch(core) or _B64URL_RE.fullmatch(core)):
40
+ return False
41
+ return bool(_B64_TELL_RE.search(v) and _UPPER_RE.search(v) and _LOWER_RE.search(v))
42
+
43
+
44
+ def value_type(value: str | None) -> str | None:
45
+ """The type label for one raw value, or None when it is empty."""
46
+ if value is None:
47
+ return None
48
+ v = value.strip()
49
+ if not v:
50
+ return None
51
+ if _JWT_RE.fullmatch(v):
52
+ return "jwt"
53
+ if _UUID_RE.fullmatch(v):
54
+ return "uuid"
55
+ if _EMAIL_RE.fullmatch(v):
56
+ return "email"
57
+ if _INT_RE.fullmatch(v):
58
+ return "integer"
59
+ if _NUM_RE.fullmatch(v):
60
+ return "number"
61
+ if v.lower() in ("true", "false"):
62
+ return "boolean"
63
+ if _is_base64(v):
64
+ return "base64"
65
+ return "string"
66
+
67
+
68
+ def param_types(location: str, params: list[tuple[str, str]]) -> list[tuple[str, str, str]]:
69
+ """(location, name, type) for each parameter whose value has a type."""
70
+ out = []
71
+ for name, value in params:
72
+ t = value_type(value)
73
+ if t is not None:
74
+ out.append((location, name, t))
75
+ return out
@@ -0,0 +1,148 @@
1
+ """
2
+ Scripted journeys: the flows an automatic crawl cannot invent.
3
+
4
+ A crawler can click what is on the screen. It will not register an account, log
5
+ in, fill a checkout, upload a file or ask for something a role should not reach.
6
+ A journey is a short JSON file that says how, and the crawler performs it in the
7
+ same browser session as the crawl, so the traffic lands in the same model (and
8
+ an authenticated session carries on into the autonomous crawl that follows).
9
+
10
+ {
11
+ "name": "customer checkout",
12
+ "vars": {"email": "${env:SHOP_EMAIL}"},
13
+ "steps": [
14
+ {"request": {"method": "POST", "path": "/rest/user/login",
15
+ "json": {"email": "${email}", "password": "${env:SHOP_PASSWORD}"},
16
+ "extract": {"token": "authentication.token", "bid": "authentication.bid"}}},
17
+ {"set_header": {"Authorization": "Bearer ${token}"}},
18
+ {"set_storage": {"token": "${token}"}},
19
+ {"goto": "/#/basket"},
20
+ {"click": {"text": "Checkout"}},
21
+ {"fill": {"selector": "#address", "value": "1 Probe Street"}},
22
+ {"press": "Enter"},
23
+ {"wait_idle": true}
24
+ ]
25
+ }
26
+
27
+ Steps: goto click fill press wait wait_for wait_idle request set_header
28
+ set_storage set_cookie scroll assert (browser mode; a static crawl runs
29
+ goto, request, set_header and set_cookie only)
30
+
31
+ `assert` checks where the journey has got to: {"assert": {"url_contains": "/welcome",
32
+ "text": "Sign out", "not_text": "Invalid password", "selector": "#account"}}. A login
33
+ that did not log in is the usual reason, and without it the crawl would carry on
34
+ anonymously and say nothing.
35
+
36
+ Values may use ${name} (a var, or something an earlier `extract` saved) and
37
+ ${env:NAME} (an environment variable: keep passwords out of the file and out of
38
+ your shell history). Each step may carry "required": true to abort the journey if
39
+ it fails; otherwise a failure is reported and the journey carries on.
40
+
41
+ A journey is deliberate, so its requests are not held to the crawler's
42
+ "looks destructive" filter. They are still held to the scope, to --read-only, and
43
+ to --yes.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import json
49
+ import os
50
+ import re
51
+ from dataclasses import dataclass, field
52
+
53
+ STEPS = {"goto", "click", "fill", "press", "wait", "wait_for", "wait_idle", "request",
54
+ "set_header", "set_storage", "set_cookie", "scroll", "assert"}
55
+ BROWSER_ONLY = {"click", "fill", "press", "wait_for", "wait_idle", "set_storage", "scroll", "assert"}
56
+ _VAR = re.compile(r"\$\{(env:)?([A-Za-z_][A-Za-z0-9_]*)\}")
57
+
58
+
59
+ class JourneyError(ValueError):
60
+ """A journey file that cannot be run (bad JSON, unknown step, missing variable)."""
61
+
62
+
63
+ @dataclass
64
+ class Journey:
65
+ name: str
66
+ vars: dict = field(default_factory=dict)
67
+ steps: list = field(default_factory=list)
68
+ path: str = ""
69
+
70
+
71
+ def load(path: str) -> Journey:
72
+ try:
73
+ with open(path, encoding="utf-8") as f:
74
+ data = json.load(f)
75
+ except OSError as e:
76
+ raise JourneyError(f"cannot read journey {path}: {e}")
77
+ except ValueError as e:
78
+ raise JourneyError(f"{path} is not valid JSON: {e}")
79
+ if isinstance(data, list):
80
+ data = {"steps": data}
81
+ if not isinstance(data, dict) or not isinstance(data.get("steps"), list) or not data["steps"]:
82
+ raise JourneyError(f"{path}: a journey needs a non-empty \"steps\" list")
83
+ j = Journey(name=str(data.get("name") or os.path.basename(path)), vars=dict(data.get("vars") or {}),
84
+ steps=data["steps"], path=path)
85
+ for n, step in enumerate(j.steps, 1):
86
+ validate_step(step, f"{path} step {n}")
87
+ return j
88
+
89
+
90
+ def validate_step(step, where: str) -> None:
91
+ if not isinstance(step, dict):
92
+ raise JourneyError(f"{where}: a step is an object like {{\"goto\": \"/\"}}")
93
+ kinds = [k for k in step if k in STEPS]
94
+ if len(kinds) != 1:
95
+ extra = [k for k in step if k not in STEPS and k not in ("required", "label")]
96
+ raise JourneyError(f"{where}: exactly one of {sorted(STEPS)} per step"
97
+ + (f" (unknown key {extra[0]!r})" if extra else ""))
98
+ kind, arg = kinds[0], step[kinds[0]]
99
+ if kind == "goto" and not isinstance(arg, str):
100
+ raise JourneyError(f"{where}: goto takes a URL or path string")
101
+ if kind in ("click", "fill", "wait_for") and not isinstance(arg, (str, dict)):
102
+ raise JourneyError(f"{where}: {kind} takes a selector or {{\"text\": ...}}")
103
+ if kind == "fill" and not (isinstance(arg, dict) and "value" in arg and ("selector" in arg or "name" in arg)):
104
+ raise JourneyError(f"{where}: fill takes {{\"selector\": ..., \"value\": ...}}")
105
+ if kind == "request":
106
+ if not (isinstance(arg, dict) and arg.get("path") or isinstance(arg, dict) and arg.get("url")):
107
+ raise JourneyError(f"{where}: request takes {{\"method\": ..., \"path\": ...}}")
108
+ if kind == "assert" and not (isinstance(arg, dict) and set(arg) & {"url_contains", "text", "not_text", "selector"}):
109
+ raise JourneyError(f"{where}: assert takes url_contains, text, not_text or selector")
110
+ if kind in ("set_header", "set_storage", "set_cookie") and not isinstance(arg, dict):
111
+ raise JourneyError(f"{where}: {kind} takes an object of name: value")
112
+
113
+
114
+ def substitute(value, variables: dict):
115
+ """Replace ${name} and ${env:NAME} anywhere in a step; a missing one is an error."""
116
+ if isinstance(value, str):
117
+ # a value that is exactly one ${var} keeps the variable's type, so an extracted
118
+ # id stays a JSON number instead of becoming "7"
119
+ whole = _VAR.fullmatch(value)
120
+ if whole and not whole.group(1) and whole.group(2) in variables:
121
+ return variables[whole.group(2)]
122
+
123
+ def one(m: re.Match) -> str:
124
+ name = m.group(2)
125
+ if m.group(1):
126
+ if name not in os.environ:
127
+ raise JourneyError(f"environment variable {name} is not set (used as ${{env:{name}}})")
128
+ return os.environ[name]
129
+ if name not in variables:
130
+ raise JourneyError(f"variable ${{{name}}} is not defined (set it under \"vars\", "
131
+ "pass --var, or extract it in an earlier step)")
132
+ return str(variables[name])
133
+ return _VAR.sub(one, value)
134
+ if isinstance(value, dict):
135
+ return {k: substitute(v, variables) for k, v in value.items()}
136
+ if isinstance(value, list):
137
+ return [substitute(v, variables) for v in value]
138
+ return value
139
+
140
+
141
+ def dig(node, path: str):
142
+ """`authentication.token`, `data.0.id` -> the value, or None."""
143
+ for part in path.split("."):
144
+ try:
145
+ node = node[int(part)] if isinstance(node, list) else node[part]
146
+ except (KeyError, IndexError, ValueError, TypeError):
147
+ return None
148
+ return node
@@ -0,0 +1,96 @@
1
+ """
2
+ Live capture: exchanges recorded while you browse are masked as they arrive, appended to
3
+ `inputs/browse.jsonl`, and the model is rebuilt every few seconds while anything new comes in.
4
+
5
+ Only the masked record is ever written; a restart continues where the last session stopped.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import threading
13
+ import time
14
+ from xml.etree.ElementTree import Element
15
+
16
+ from .parse import Exchange, parse_elements
17
+
18
+
19
+ class Live:
20
+ """Masked exchanges waiting to be saved, and the file they are appended to."""
21
+
22
+ def __init__(self, path: str, role: str | None = None):
23
+ self.path = path
24
+ self.role = role
25
+ self.lock = threading.Lock()
26
+ self.pending: list[Exchange] = []
27
+ self.stats = {"items": 0, "parsed": 0, "skipped": 0}
28
+ self.received = 0
29
+ self.saved = 0
30
+ if os.path.exists(path):
31
+ with open(path, encoding="utf-8") as f:
32
+ self.saved = sum(1 for line in f if line.strip() and '"_stats"' not in line[:12])
33
+
34
+ def ingest(self, items: list[Element]) -> tuple[int, int]:
35
+ """Parse and mask each recorded <item>; returns (accepted, skipped)."""
36
+ stats: dict = {}
37
+ parsed = list(parse_elements(items, source="live", role=self.role, stats=stats))
38
+ skipped = len(items) - len(parsed)
39
+ with self.lock:
40
+ for ex in parsed:
41
+ ex.item = self.saved + len(self.pending)
42
+ self.pending.append(ex)
43
+ self.stats["items"] += len(items)
44
+ self.stats["parsed"] += len(parsed)
45
+ self.stats["skipped"] += skipped
46
+ self.received += len(parsed)
47
+ return len(parsed), skipped
48
+
49
+ def flush(self) -> int:
50
+ """Append what is pending to the input file in one write; returns how many were saved."""
51
+ with self.lock:
52
+ batch, stats = self.pending, self.stats
53
+ self.pending, self.stats = [], {"items": 0, "parsed": 0, "skipped": 0}
54
+ if not batch and not stats["items"]:
55
+ return 0
56
+ lines = [json.dumps(ex.to_record()) for ex in batch]
57
+ lines.append(json.dumps({"_stats": stats, "_version": 1}))
58
+ os.makedirs(os.path.dirname(self.path), exist_ok=True)
59
+ with open(self.path, "a", encoding="utf-8") as f:
60
+ f.write("\n".join(lines) + "\n")
61
+ f.flush()
62
+ os.fsync(f.fileno())
63
+ self.saved += len(batch)
64
+ return len(batch)
65
+
66
+
67
+ class Rebuilder(threading.Thread):
68
+ """Every `interval` seconds, saves what arrived and, if anything did, rebuilds.
69
+
70
+ `report` gets one dict per rebuild: {"added", "saved", "summary", "seconds"}, or {"error"}."""
71
+
72
+ def __init__(self, live: Live, rebuild, interval: float, report=print):
73
+ super().__init__(daemon=True)
74
+ self.live, self.rebuild, self.interval, self.report = live, rebuild, interval, report
75
+ self.stop = threading.Event()
76
+ self.last = 0.0 # how long the last rebuild took
77
+
78
+ def step(self) -> None:
79
+ n = self.live.flush()
80
+ if not n:
81
+ return
82
+ t0 = time.monotonic()
83
+ try:
84
+ summary = self.rebuild()
85
+ except Exception as e: # a bad rebuild must not stop the listener
86
+ self.report({"error": str(e)})
87
+ else:
88
+ self.report({"added": n, "saved": self.live.saved, "summary": summary,
89
+ "seconds": time.monotonic() - t0})
90
+ self.last = time.monotonic() - t0
91
+
92
+ def run(self) -> None:
93
+ # a big session rebuilds slowly: wait at least twice as long as the last rebuild took,
94
+ # so the listener never spends more than a third of its time rebuilding
95
+ while not self.stop.wait(max(self.interval, 2 * self.last)):
96
+ self.step()