lean-browser-use 0.0.0-stage → 0.1.0

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 (76) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/CREDITS.md +18 -0
  3. package/LICENSE +21 -0
  4. package/README.md +159 -2
  5. package/agents/aun-browser-runner.md +48 -0
  6. package/bin/lean-browser-use.mjs +3 -0
  7. package/briefs/browser-read.md +63 -0
  8. package/briefs/helper-read.md +63 -0
  9. package/docs/PROVENANCE.md +32 -0
  10. package/docs/benchmark.md +91 -0
  11. package/docs/guards.md +54 -0
  12. package/docs/how-it-works.md +27 -0
  13. package/docs/install.md +78 -0
  14. package/docs/measure.md +53 -0
  15. package/docs/reader.md +39 -0
  16. package/docs/search.md +16 -0
  17. package/hooks/batch-nudge.mjs +14 -0
  18. package/hooks/batch-nudge.sh +8 -0
  19. package/hooks/common.mjs +71 -0
  20. package/hooks/hooks.settings.json +58 -0
  21. package/hooks/pick-guard.mjs +27 -0
  22. package/hooks/pick-guard.sh +8 -0
  23. package/hooks/retry-guard.mjs +69 -0
  24. package/hooks/retry-guard.sh +8 -0
  25. package/llms.txt +40 -0
  26. package/package.json +61 -4
  27. package/proof/benchmark.json +166 -0
  28. package/proof/results.json +241 -0
  29. package/reader/README.md +48 -0
  30. package/reader/crawl_helper.py +256 -0
  31. package/reader/reader +14 -0
  32. package/reader/reader_base.py +106 -0
  33. package/reader/reader_compile.py +106 -0
  34. package/reader/reader_crawl.py +67 -0
  35. package/reader/reader_extract.py +261 -0
  36. package/reader/reader_fetch.py +148 -0
  37. package/reader/reader_runtime.py +199 -0
  38. package/reader/reader_test_harness.py +173 -0
  39. package/reader/reader_test_support.py +214 -0
  40. package/reader/specs/claude-pricing.json +223 -0
  41. package/reader/specs/npm-package.json +67 -0
  42. package/reader/test_reader.py +345 -0
  43. package/reader/test_reader_1.py +309 -0
  44. package/reader/test_reader_2.py +348 -0
  45. package/reader/test_reader_3.py +254 -0
  46. package/reader/test_reader_contract.py +57 -0
  47. package/reader/testdata/claude-pricing.html +4 -0
  48. package/reader/testdata/compile_stub.py +24 -0
  49. package/reader/testdata/npm-react.html +4 -0
  50. package/reader/testdata/npm-schema.json +14 -0
  51. package/reader/testdata/npm-zod.html +4 -0
  52. package/reader/testdata/pricing-schema.json +24 -0
  53. package/searxng/README.md +29 -0
  54. package/searxng/compose.yml +21 -0
  55. package/searxng/core-config/settings.yml +26 -0
  56. package/searxng/deploy.sh +36 -0
  57. package/searxng/searx +73 -0
  58. package/searxng/test_searx.py +107 -0
  59. package/searxng/test_searx_offline.py +67 -0
  60. package/skills/lean-browser-use/SKILL.md +35 -0
  61. package/skills/lean-browser-use/references/cleanup.md +17 -0
  62. package/skills/lean-browser-use/references/cost-model.md +11 -0
  63. package/skills/lean-browser-use/references/draft-offline.md +9 -0
  64. package/skills/lean-browser-use/references/failure-modes.md +16 -0
  65. package/skills/lean-browser-use/references/logged-in-run.md +23 -0
  66. package/skills/lean-browser-use/references/packet-principle.md +15 -0
  67. package/skills/lean-browser-use/references/route-by-job.md +18 -0
  68. package/skills/lean-browser-use/references/runner-rules.md +27 -0
  69. package/skills/lean-browser-use/references/verify.md +9 -0
  70. package/src/cli.mjs +42 -0
  71. package/src/doctor.mjs +60 -0
  72. package/src/install.mjs +116 -0
  73. package/src/measure.mjs +64 -0
  74. package/src/paths.mjs +51 -0
  75. package/src/uninstall.mjs +68 -0
  76. package/src/util.mjs +35 -0
@@ -0,0 +1,261 @@
1
+ """Public page reader: extract operations."""
2
+ from reader_base import *
3
+ from reader_crawl import crawl_helper
4
+
5
+ class PageParser(HTMLParser):
6
+ def __init__(self):
7
+ super().__init__(convert_charrefs=False)
8
+ self.scripts = []
9
+ self.script = None
10
+ self.trimmed = []
11
+ self.skip = 0
12
+
13
+ def handle_starttag(self, tag, attrs):
14
+ if tag == "script":
15
+ self.script = [dict(attrs), ""]
16
+ if tag in ("script", "style", "svg"):
17
+ self.skip += 1
18
+ if not self.skip:
19
+ self.trimmed.append(self.get_starttag_text())
20
+
21
+ def handle_endtag(self, tag):
22
+ if tag == "script" and self.script is not None:
23
+ self.scripts.append(self.script)
24
+ self.script = None
25
+ if tag in ("script", "style", "svg") and self.skip:
26
+ self.skip -= 1
27
+ elif not self.skip:
28
+ self.trimmed.append(f"</{tag}>")
29
+
30
+ def handle_data(self, data):
31
+ if self.script is not None:
32
+ self.script[1] += data
33
+ if not self.skip:
34
+ self.trimmed.append(data)
35
+
36
+ def handle_entityref(self, name):
37
+ self.handle_data(f"&{name};")
38
+
39
+ def handle_charref(self, name):
40
+ self.handle_data(f"&#{name};")
41
+
42
+
43
+ def page_data(html):
44
+ parser = PageParser()
45
+ parser.feed(html)
46
+ embedded = {}
47
+ decoder = json.JSONDecoder()
48
+ for attrs, content in parser.scripts:
49
+ if attrs.get("type", "").lower() in ("application/json", "application/ld+json") or attrs.get("id") == "__NEXT_DATA__":
50
+ try:
51
+ value = json.loads(content)
52
+ key = attrs.get("id") or f"ld+json:{len(embedded)}"
53
+ embedded[key] = value
54
+ except ValueError:
55
+ pass
56
+ for match in re.finditer(r"(?:window\s*\.\s*)?(__[\w$]+)\s*=\s*", content):
57
+ tail = content[match.end():].lstrip()
58
+ try:
59
+ if tail.startswith("JSON.parse("):
60
+ encoded, _ = decoder.raw_decode(tail[len("JSON.parse("):].lstrip())
61
+ value = json.loads(encoded)
62
+ else:
63
+ value, _ = decoder.raw_decode(tail)
64
+ if isinstance(value, (dict, list)):
65
+ embedded[match.group(1)] = value
66
+ except (ValueError, TypeError):
67
+ pass
68
+ return embedded, "".join(parser.trimmed)
69
+
70
+
71
+ def path_get(value, path):
72
+ if path in ("", ".", "$"):
73
+ return value
74
+ # A backslash escapes dots in object keys, for example dist-tags.latest.
75
+ parts = re.split(r"(?<!\\)\.", path.removeprefix("$."))
76
+ try:
77
+ for part in parts:
78
+ part = part.replace("\\.", ".").replace("\\\\", "\\")
79
+ value = value[int(part)] if isinstance(value, list) else value[part]
80
+ return value
81
+ except (KeyError, IndexError, TypeError, ValueError):
82
+ return None
83
+
84
+
85
+ def normalize(rows, transforms):
86
+ for row in rows:
87
+ if not isinstance(row, dict):
88
+ continue
89
+ for key, transform in transforms.items():
90
+ value = row.get(key)
91
+ if value is None:
92
+ continue
93
+ if isinstance(transform, str):
94
+ transform = {"number": transform == "number"}
95
+ if transform.get("regex"):
96
+ match = re.search(transform["regex"], str(value))
97
+ value = match.group(transform.get("group", 1)) if match else None
98
+ if value is not None and transform.get("number"):
99
+ try:
100
+ value = float(str(value).replace(",", "").strip())
101
+ if not math.isfinite(value):
102
+ value = None
103
+ elif value.is_integer():
104
+ value = int(value)
105
+ except ValueError:
106
+ value = None
107
+ row[key] = value
108
+ return rows
109
+
110
+
111
+ def extract(spec, html):
112
+ if spec["type"] == "embedded_json":
113
+ embedded, _ = page_data(html)
114
+ source = embedded.get(spec["source"])
115
+ data = path_get(source, spec.get("rows", "."))
116
+ if data is None:
117
+ return []
118
+ data = data if isinstance(data, list) else [data]
119
+ rows = [{key: path_get(row, path) for key, path in spec["paths"].items()} for row in data]
120
+ else:
121
+ rows = crawl_helper("css", {"html": html, "schema": spec["schema"]}, 12).get("rows")
122
+ if not isinstance(rows, list):
123
+ raise ReaderError(12, "extraction failed: expected list rows")
124
+ return normalize(rows, spec.get("transforms", {}))
125
+
126
+
127
+ def empty(value):
128
+ return value is None or value == "" or value == [] or value == {} or (isinstance(value, str) and not value.strip())
129
+
130
+
131
+ def failures(spec, rows):
132
+ if not rows:
133
+ return ["list rows >= 1", *spec["required"]]
134
+ example = spec.get("example")
135
+ if isinstance(example, list):
136
+ example = example[0] if example else {}
137
+ example = example or {}
138
+ keys = set(spec["required"]) | set(example or {})
139
+ missing = []
140
+ for index, row in enumerate(rows):
141
+ if not isinstance(row, dict):
142
+ missing.append(f"row[{index}] must be an object")
143
+ continue
144
+ for key in sorted(keys):
145
+ if key not in row or (key in spec["required"] and empty(row[key])):
146
+ missing.append(f"row[{index}].{key}")
147
+ elif row[key] is not None and isinstance(example.get(key), (int, float)) and not isinstance(example[key], bool):
148
+ if isinstance(row[key], bool) or not isinstance(row[key], (int, float)) or not math.isfinite(row[key]):
149
+ missing.append(f"row[{index}].{key} must be numeric")
150
+ example_keys = sorted(example)
151
+ for offset, key in enumerate(example_keys):
152
+ for other in example_keys[offset + 1:]:
153
+ if (isinstance(row.get(key), str) and len(row[key]) >= 3 and not empty(row[key])
154
+ and isinstance(row.get(other), str) and len(row[other]) >= 3 and not empty(row[other])
155
+ and example[key] != example[other]
156
+ and row[key] == row[other]):
157
+ missing.append(f"row[{index}].{key} equals {other}; example values differ")
158
+ return missing
159
+
160
+
161
+ def anchor_failures(example, rows):
162
+ if example is None:
163
+ return []
164
+ if isinstance(example, list):
165
+ example = example[0]
166
+
167
+ def matches(value, expected):
168
+ if isinstance(value, str) and isinstance(expected, str):
169
+ return " ".join(value.split()) == " ".join(expected.split())
170
+ if (isinstance(value, (int, float)) and not isinstance(value, bool)
171
+ and isinstance(expected, (int, float)) and not isinstance(expected, bool)):
172
+ return value == expected
173
+ return type(value) is type(expected) and value == expected
174
+
175
+ nearest, best = {}, -1
176
+ for row in rows:
177
+ if not isinstance(row, dict):
178
+ continue
179
+ score = sum(key in row and matches(row[key], value) for key, value in example.items())
180
+ if score == len(example):
181
+ return []
182
+ if score > best:
183
+ nearest, best = row, score
184
+ return ["example row not found; nearest row: " + json.dumps(nearest, ensure_ascii=False)]
185
+
186
+
187
+ def validate_candidate(candidate, example):
188
+ if example is not None and not (isinstance(example, dict) or
189
+ isinstance(example, list) and example and all(isinstance(row, dict) for row in example)):
190
+ raise ValueError("example must be an object or non-empty list of objects")
191
+ if not isinstance(candidate, dict) or candidate.get("type") not in ("embedded_json", "css"):
192
+ raise ValueError("schema needs type embedded_json or css")
193
+ if candidate["type"] == "embedded_json":
194
+ if not isinstance(candidate.get("source"), str) or not isinstance(candidate.get("paths"), dict) or not candidate["paths"]:
195
+ raise ValueError("embedded_json needs source and non-empty paths")
196
+ if not all(isinstance(p, str) for p in candidate["paths"].values()) or not isinstance(candidate.get("rows", "."), str):
197
+ raise ValueError("paths and rows must be dotted strings")
198
+ keys = list(candidate["paths"])
199
+ else:
200
+ schema = candidate.get("schema")
201
+ if not isinstance(schema, dict) or not schema.get("baseSelector") or not isinstance(schema.get("fields"), list) or not schema["fields"]:
202
+ raise ValueError("css needs schema with baseSelector and fields")
203
+ for field in schema["fields"]:
204
+ if not isinstance(field, dict) or not isinstance(field.get("name"), str) or field.get("type") not in ("text", "attribute", "regex", "html"):
205
+ raise ValueError("CSS fields must use text, attribute, regex, or html")
206
+ if any(key in field for key in ("expression", "function", "default")):
207
+ raise ValueError("CSS fields cannot use computed values or defaults")
208
+ keys = [field["name"] for field in schema["fields"]]
209
+ required = candidate.setdefault("required", keys)
210
+ if not isinstance(required, list) or not required or not all(isinstance(key, str) and key in keys for key in required):
211
+ raise ValueError("required must be a non-empty list of output field names")
212
+ if not isinstance(candidate.get("transforms", {}), dict):
213
+ raise ValueError("transforms must be an object")
214
+ for key, transform in candidate.get("transforms", {}).items():
215
+ if key not in keys:
216
+ raise ValueError(f"transform field {key} is absent from output")
217
+ if isinstance(transform, str):
218
+ if transform != "number":
219
+ raise ValueError("string transform must be number")
220
+ continue
221
+ if not isinstance(transform, dict) or set(transform) - {"regex", "group", "number"}:
222
+ raise ValueError("transform must use regex, group and/or number")
223
+ if "number" in transform and not isinstance(transform["number"], bool):
224
+ raise ValueError("transform number must be boolean")
225
+ if "regex" in transform:
226
+ if not isinstance(transform["regex"], str):
227
+ raise ValueError("transform regex must be a string")
228
+ try:
229
+ pattern = re.compile(transform["regex"])
230
+ except re.error as exc:
231
+ raise ValueError(f"invalid transform regex: {exc}") from exc
232
+ group = transform.get("group", 1)
233
+ if not isinstance(group, int) or isinstance(group, bool) or not 0 <= group <= pattern.groups:
234
+ raise ValueError("transform group must name an existing numeric capture group")
235
+ elif "group" in transform:
236
+ raise ValueError("transform group needs a regex")
237
+ # The compile command supplies extraction rules only, never stored metadata.
238
+ allowed = ("type", "source", "rows", "paths", "schema", "required", "transforms")
239
+ return {key: value for key, value in candidate.items() if key in allowed}
240
+
241
+
242
+ def failing_row_samples(spec, rows, errors, html):
243
+ indices = list(dict.fromkeys(int(match) for error in errors
244
+ for match in re.findall(r"row\[(\d+)\]", error)))[:3]
245
+ indices = [index for index in indices if index < len(rows)]
246
+ markup = {}
247
+ if indices and spec["type"] == "css":
248
+ try:
249
+ markup = crawl_helper("css", {"html": html, "schema": spec["schema"],
250
+ "sample_indices": indices}, 12).get("samples", {})
251
+ except ReaderError:
252
+ pass
253
+ elif indices:
254
+ for match in re.finditer(r"<script\b[^>]*>.*?</script\s*>", html, re.DOTALL | re.IGNORECASE):
255
+ if spec["source"] in page_data(match.group())[0]:
256
+ markup = {str(index): match.group()[:600] for index in indices}
257
+ break
258
+ return [{"index": index, "values": json.dumps(rows[index], ensure_ascii=False)[:600],
259
+ "outer_html": markup.get(str(index), "")[:600]} for index in indices]
260
+
261
+
@@ -0,0 +1,148 @@
1
+ """Public page reader: fetch operations."""
2
+ from reader_base import *
3
+ from reader_crawl import crawl_helper
4
+
5
+ def checked_address(url):
6
+ if not isinstance(url, str) or any(c.isspace() or ord(c) < 32 for c in url):
7
+ raise ReaderError(2, "refused target: whitespace or control character")
8
+ try:
9
+ target = urlsplit(url)
10
+ if target.scheme not in ("http", "https") or not target.hostname:
11
+ raise ValueError("only http(s) URLs with a host are supported")
12
+ if target.username is not None or target.password is not None:
13
+ raise ValueError("URL credentials are not supported")
14
+ port = target.port or (443 if target.scheme == "https" else 80)
15
+ except ValueError as exc:
16
+ raise ReaderError(2, f"refused target: {exc}") from exc
17
+ try:
18
+ addresses = socket.getaddrinfo(target.hostname, port, type=socket.SOCK_STREAM,
19
+ proto=socket.IPPROTO_TCP)
20
+ except OSError as exc:
21
+ raise ReaderError(10, f"fetch failed: DNS lookup: {exc}") from exc
22
+ if not addresses:
23
+ raise ReaderError(10, "fetch failed: DNS returned no addresses")
24
+ for address in addresses:
25
+ ip = ipaddress.ip_address(address[4][0].split("%", 1)[0])
26
+ # Tests may opt in to one exact local HTTP origin, never a host range.
27
+ if (target.scheme == "http" and target.hostname == "127.0.0.1" and target.port
28
+ and os.environ.get("READER_TEST_ALLOW_ORIGIN") == f"http://127.0.0.1:{port}"
29
+ and str(ip) == "127.0.0.1"):
30
+ continue
31
+ if not ip.is_global or ip.is_multicast or ip.is_reserved:
32
+ raise ReaderError(2, f"refused target: {target.hostname} resolves to a non-public address")
33
+ return addresses[0]
34
+
35
+
36
+ def connection(cls, host, address, **kwargs):
37
+ conn = cls(host, **kwargs)
38
+
39
+ def connect(unused, timeout, source_address=None):
40
+ sock = socket.socket(address[0], address[1], address[2])
41
+ try:
42
+ sock.settimeout(timeout)
43
+ sock.connect(address[4])
44
+ return sock
45
+ except BaseException:
46
+ sock.close()
47
+ raise
48
+
49
+ conn._create_connection = connect
50
+ return conn
51
+
52
+
53
+ class PinnedHTTP(HTTPHandler):
54
+ def http_open(self, req):
55
+ return self.do_open(lambda host, **kw: connection(
56
+ http.client.HTTPConnection, host, req.checked_address, **kw), req)
57
+
58
+
59
+ class PinnedHTTPS(HTTPSHandler):
60
+ def https_open(self, req):
61
+ return self.do_open(lambda host, **kw: connection(
62
+ http.client.HTTPSConnection, host, req.checked_address, **kw), req,
63
+ context=self._context)
64
+
65
+
66
+ class PublicRedirect(HTTPRedirectHandler):
67
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
68
+ address = checked_address(newurl)
69
+ redirected = super().redirect_request(req, fp, code, msg, headers, newurl)
70
+ if redirected:
71
+ redirected.checked_address = address
72
+ return redirected
73
+
74
+ def http_error_302(self, req, fp, code, msg, headers):
75
+ # urllib drains redirect bodies before following them. Bound that drain.
76
+ if len(fp.read(FETCH_CAP + 1)) > FETCH_CAP:
77
+ fp.close()
78
+ raise ReaderError(10, "fetch failed: redirect body exceeds size limit")
79
+ return super().http_error_302(req, fp, code, msg, headers)
80
+
81
+ http_error_301 = http_error_303 = http_error_307 = http_error_308 = http_error_302
82
+
83
+
84
+ def fetch_urllib(url):
85
+ req = Request(url, headers={"User-Agent": USER_AGENT, "Accept-Encoding": "identity"})
86
+ req.checked_address = checked_address(url)
87
+ # Disable ambient proxies so the checked sockaddr is the actual destination.
88
+ opener = build_opener(ProxyHandler({}), PinnedHTTP(), PinnedHTTPS(), PublicRedirect())
89
+ try:
90
+ with opener.open(req, timeout=30) as response:
91
+ body = response.read(FETCH_CAP + 1)
92
+ if len(body) > FETCH_CAP:
93
+ raise ReaderError(10, "fetch failed: page exceeds size limit")
94
+ try:
95
+ return body.decode(response.headers.get_content_charset() or "utf-8", errors="replace")
96
+ except LookupError:
97
+ return body.decode("utf-8", errors="replace")
98
+ except HTTPError:
99
+ raise
100
+ except (OSError, URLError, ValueError) as exc:
101
+ raise ReaderError(10, f"fetch failed: {exc}") from exc
102
+
103
+
104
+ def bot_wall(html):
105
+ title = re.search(r"<title\b[^>]*>(.*?)</title>", html, re.DOTALL | re.IGNORECASE)
106
+ if title and re.search(r"just a moment|attention required|access denied|verify (?:you are|you're) human|captcha",
107
+ title.group(1), re.IGNORECASE):
108
+ return True
109
+ return bool(re.search(r"/cdn-cgi/challenge-platform/[^\s\"'<>]*orchestrate/|\bcf-chl-|_cf_chl_opt\b|please enable js and disable any ad blocker",
110
+ html, re.IGNORECASE))
111
+
112
+
113
+ def fetch_crwl(url):
114
+ checked_address(url)
115
+ page = crawl_helper("fetch", {"url": url}, 10)
116
+ try:
117
+ if page.get("success") is False or (page.get("status_code") or 200) >= 400:
118
+ raise ValueError("returned an unsuccessful page")
119
+ for key in ("url", "redirected_url"):
120
+ if page.get(key):
121
+ checked_address(page[key])
122
+ html = page.get("html")
123
+ if not isinstance(html, str) or not html.strip():
124
+ raise ValueError("returned no html field")
125
+ if len(html.encode("utf-8")) > FETCH_CAP:
126
+ raise ValueError("page exceeds size limit")
127
+ if bot_wall(html):
128
+ raise ValueError("page is still a bot wall")
129
+ return html
130
+ except (ValueError, TypeError) as exc:
131
+ raise ReaderError(10, f"fetch failed: crwl helper: {exc}") from exc
132
+
133
+
134
+ def fetch(url, preferred="urllib"):
135
+ if preferred == "crwl":
136
+ return fetch_crwl(url), "crwl"
137
+ try:
138
+ html = fetch_urllib(url)
139
+ except HTTPError as exc:
140
+ exc.close()
141
+ if exc.code not in (401, 403, 429):
142
+ raise ReaderError(10, f"fetch failed: {exc}") from exc
143
+ return fetch_crwl(url), "crwl"
144
+ if bot_wall(html):
145
+ return fetch_crwl(url), "crwl"
146
+ return html, "urllib"
147
+
148
+
@@ -0,0 +1,199 @@
1
+ """Public page reader: runtime operations."""
2
+ from reader_base import *
3
+ from reader_fetch import fetch
4
+ from reader_extract import extract, failures, validate_candidate
5
+ from reader_compile import compile_spec, required_compile_command
6
+
7
+ def spec_path(name):
8
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,99}", name):
9
+ raise ReaderError(2, "name must use letters, digits, underscores or hyphens")
10
+ directory = Path(os.environ.get("READER_SPECS_DIR", ROOT / "specs"))
11
+ return directory / f"{name}.json"
12
+
13
+
14
+ def state_dir():
15
+ return Path(os.environ.get("READER_STATE_DIR", Path.home() / ".cache/lean-browser-use/reader"))
16
+
17
+
18
+ def state_path(name):
19
+ return state_dir() / spec_path(name).name
20
+
21
+
22
+ def override_path(name):
23
+ return state_dir() / "specs" / spec_path(name).name
24
+
25
+
26
+ def selected_path(name):
27
+ override = override_path(name)
28
+ return override if override.exists() else spec_path(name)
29
+
30
+
31
+ def save_json(path, value):
32
+ path.parent.mkdir(parents=True, exist_ok=True)
33
+ with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, prefix=f".{path.stem}.", suffix=".tmp", delete=False) as stream:
34
+ temp = Path(stream.name)
35
+ try:
36
+ json.dump(value, stream, indent=2, ensure_ascii=False, allow_nan=False)
37
+ stream.write("\n")
38
+ stream.close()
39
+ os.replace(temp, path)
40
+ finally:
41
+ temp.unlink(missing_ok=True)
42
+
43
+
44
+ def save(spec, runtime=False):
45
+ name = spec["name"]
46
+ save_json(override_path(name) if runtime else spec_path(name), spec)
47
+ if not runtime:
48
+ # An explicit compile replaces any older healed rules and run metadata.
49
+ override_path(name).unlink(missing_ok=True)
50
+ state_path(name).unlink(missing_ok=True)
51
+
52
+
53
+ def save_state(spec, html, fetch_method):
54
+ save_json(state_path(spec["name"]), {
55
+ "last_ok": now(), "healed_at": spec.get("healed_at"),
56
+ "fetch_method": fetch_method, "page_hash": page_hash(html),
57
+ })
58
+
59
+
60
+ def load(name):
61
+ try:
62
+ spec = json.loads(selected_path(name).read_text())
63
+ if not isinstance(spec, dict):
64
+ raise ValueError("spec must be a JSON object")
65
+ if state_path(name).exists():
66
+ state = json.loads(state_path(name).read_text())
67
+ if not isinstance(state, dict):
68
+ raise ValueError("run state must be a JSON object")
69
+ for key in ("last_ok", "healed_at", "fetch_method", "page_hash"):
70
+ if key in state:
71
+ spec[key] = state[key]
72
+ validate_candidate(spec, spec.get("example"))
73
+ if spec.get("name") != name or not isinstance(spec.get("url_template"), str):
74
+ raise ValueError("invalid stored name or url_template")
75
+ if not isinstance(spec.get("params"), dict) or not all(
76
+ isinstance(key, str) and isinstance(value, str) for key, value in spec["params"].items()):
77
+ raise ValueError("stored params must be an object of strings")
78
+ if not isinstance(spec.get("want"), str):
79
+ raise ValueError("stored want must be a string")
80
+ if spec.get("fetch_method", "urllib") not in ("urllib", "crwl"):
81
+ raise ValueError("stored fetch_method must be urllib or crwl")
82
+ return spec
83
+ except (OSError, ValueError, TypeError, KeyError) as exc:
84
+ raise ReaderError(2, f"cannot load spec {name}: {exc}") from exc
85
+
86
+
87
+ def parse_params(values):
88
+ params = {}
89
+ for value in values:
90
+ key, sep, text = value.partition("=")
91
+ if not sep or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key):
92
+ raise ReaderError(2, "--param must be key=value")
93
+ params[key] = text
94
+ return params
95
+
96
+
97
+ def render_url(template, params):
98
+ try:
99
+ # Only named placeholders are supported, with URL-encoded values.
100
+ def replace(match):
101
+ return quote(params[match.group(1)], safe="")
102
+ url = re.sub(r"\{([A-Za-z_][A-Za-z0-9_]*)\}", replace, template)
103
+ if "{" in url or "}" in url:
104
+ raise ValueError("invalid URL template")
105
+ return url
106
+ except (KeyError, ValueError) as exc:
107
+ raise ReaderError(2, f"URL template parameter: {exc}") from exc
108
+
109
+
110
+ def run_spec(spec, params, no_heal):
111
+ url = render_url(spec["url_template"], {**spec["params"], **params})
112
+ html, fetch_method = fetch(url, preferred=spec.get("fetch_method", "urllib"))
113
+ try:
114
+ rows = extract(spec, html)
115
+ errors = failures(spec, rows)
116
+ except (ReaderError, ValueError, TypeError, KeyError, re.error) as exc:
117
+ errors = [str(exc), *spec["required"]]
118
+ if errors:
119
+ if no_heal:
120
+ raise ReaderError(12, "extraction failed; missing fields: " + "; ".join(errors))
121
+ try:
122
+ healed, rows = compile_spec(spec["name"], spec["url_template"],
123
+ {**spec["params"], **params}, spec["want"],
124
+ spec.get("example"), html, healed_at=now(),
125
+ required_contract=spec["required"], fetch_method=fetch_method,
126
+ anchor_example=False)
127
+ # Rerun the replacement spec before replacing the saved file.
128
+ rows = extract(healed, html)
129
+ errors = failures(healed, rows)
130
+ if not errors:
131
+ save(healed, runtime=True)
132
+ save_state(healed, html, fetch_method)
133
+ return {"rows": rows, "meta": {"fetch_method": fetch_method}}
134
+ except ReaderError as exc:
135
+ if exc.code == 2:
136
+ raise
137
+ errors += [str(exc)]
138
+ raise ReaderError(12, "extraction failed after one heal; missing fields: " + "; ".join(errors))
139
+ save_state(spec, html, fetch_method)
140
+ return {"rows": rows, "meta": {"fetch_method": fetch_method}}
141
+
142
+
143
+ def main(argv=None):
144
+ parser = argparse.ArgumentParser(description=__doc__)
145
+ commands = parser.add_subparsers(dest="command", required=True)
146
+ compile_parser = commands.add_parser("compile", help="compile a public page once")
147
+ compile_parser.add_argument("name")
148
+ compile_parser.add_argument("--url", required=True)
149
+ compile_parser.add_argument("--want", required=True)
150
+ compile_parser.add_argument("--example")
151
+ compile_parser.add_argument("--param", action="append", default=[])
152
+ run_parser = commands.add_parser("run", help="extract without AI, healing once on failure")
153
+ run_parser.add_argument("name")
154
+ run_parser.add_argument("--param", action="append", default=[])
155
+ run_parser.add_argument("--no-heal", action="store_true")
156
+ commands.add_parser("ls", help="list saved specs")
157
+ args = parser.parse_args(argv)
158
+ try:
159
+ if args.command == "ls":
160
+ directories = (spec_path("unused").parent, override_path("unused").parent)
161
+ names = {path.stem for directory in directories for path in directory.glob("*.json")}
162
+ print("name\turl\ttype\tcompiled_at\tlast_ok\thealed_at\tsource")
163
+ for name in sorted(names):
164
+ spec = load(name)
165
+ print("\t".join(str(spec.get(key) or "") for key in
166
+ ("name", "url_template", "type", "compiled_at", "last_ok", "healed_at"))
167
+ + "\t" + str(selected_path(name)))
168
+ return 0
169
+ spec_path(args.name)
170
+ params = parse_params(args.param)
171
+ if args.command == "compile":
172
+ required_compile_command()
173
+ try:
174
+ example = json.loads(args.example) if args.example is not None else None
175
+ if example is not None and not (isinstance(example, dict) or
176
+ isinstance(example, list) and example and all(isinstance(row, dict) for row in example)):
177
+ raise ValueError("example must be an object or non-empty list of objects")
178
+ except ValueError as exc:
179
+ raise ReaderError(2, f"invalid --example JSON: {exc}") from exc
180
+ html, fetch_method = fetch(render_url(args.url, params))
181
+ if example is None:
182
+ print("reader: warning: wrong-but-plausible values cannot be detected without an example row; "
183
+ "use --example to anchor the compile", file=sys.stderr)
184
+ spec, rows = compile_spec(args.name, args.url, params, args.want, example, html,
185
+ fetch_method=fetch_method)
186
+ save(spec)
187
+ output = rows
188
+ else:
189
+ output = run_spec(load(args.name), params, args.no_heal)
190
+ print(json.dumps(output, indent=2, ensure_ascii=False, allow_nan=False))
191
+ return 0
192
+ except ReaderError as exc:
193
+ print(f"reader: {exc}", file=sys.stderr)
194
+ return exc.code
195
+ except (OSError, ValueError, TypeError, KeyError) as exc:
196
+ print(f"reader: invalid spec or local I/O failure: {exc}", file=sys.stderr)
197
+ return 2
198
+
199
+