render-url 1.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.
parse_html.py ADDED
@@ -0,0 +1,432 @@
1
+ """
2
+ parse_html.py — deterministic HTML -> structured JSON extractor (Step 2).
3
+
4
+ Pipeline: Step 1 JSON (rendered_page_*.json) -> "html" field -> BeautifulSoup4
5
+ (html.parser backend) -> deterministic structural extraction rules
6
+ -> single JSON object on stdout.
7
+
8
+ No LLM, AI model, agent reasoning, or semantic HTML interpretation is used
9
+ anywhere in this script. No network access, no Playwright. This module is
10
+ fully independent of render_url.py (never imports it).
11
+
12
+ Extracted fields (see parse_html()):
13
+ title <title> text, whitespace-normalized. null if absent/empty.
14
+ headings every h1-h6 in document order: {"level": int, "text": str}.
15
+ links every <a href=...> in document order: {"text": str, "href": str}.
16
+ Anchors without an href attribute are excluded. Not deduped.
17
+ images every <img src=...> in document order: {"src": str, "alt": str}.
18
+ alt defaults to "" if the attribute is absent. Images without a
19
+ src attribute are excluded.
20
+ meta dict of {name_or_property: content} from <meta name=...> and
21
+ <meta property=...> tags, collected in document order with the
22
+ later tag winning on duplicate keys.
23
+ text visible text of <body> (or whole document if no body), with
24
+ <script>/<style> contents excluded, whitespace-normalized.
25
+
26
+ `selector` (optional CSS selector, config key) scopes headings/links/images/
27
+ text extraction to the first element matched by BeautifulSoup's .select();
28
+ title/meta remain document-level. No match -> those fields come back empty
29
+ and `selector_matched` is false (not an error).
30
+
31
+ `strip_whitespace` (default true) controls whitespace normalization
32
+ (collapse runs of whitespace to a single space, strip ends) for text/
33
+ headings/links text. When false, raw tag text is used as-is.
34
+ """
35
+
36
+ import argparse
37
+ import json
38
+ import re
39
+ import sys
40
+ from pathlib import Path
41
+
42
+ from bs4 import BeautifulSoup
43
+
44
+
45
+ DEFAULT_SELECTOR = None
46
+ DEFAULT_STRIP_WHITESPACE = True
47
+ DEFAULT_OUTPUT_PREFIX = "parsed_page"
48
+ DEFAULT_OUTPUT_DIR = "."
49
+ DEFAULT_CONFIG_PATH = "config.json"
50
+ DEFAULT_VERBOSE = False
51
+ DEFAULT_LOG_JSON = False
52
+
53
+ DEFAULTS = {
54
+ "selector": DEFAULT_SELECTOR,
55
+ "strip_whitespace": DEFAULT_STRIP_WHITESPACE,
56
+ "output_prefix": DEFAULT_OUTPUT_PREFIX,
57
+ "output_dir": DEFAULT_OUTPUT_DIR,
58
+ "verbose": DEFAULT_VERBOSE,
59
+ "log_json": DEFAULT_LOG_JSON,
60
+ }
61
+
62
+
63
+ def _log(verbose, message):
64
+ if verbose:
65
+ print(f"[parse-html] {message}", file=sys.stderr)
66
+
67
+ _WHITESPACE_RE = re.compile(r"\s+")
68
+
69
+
70
+ def load_config_file(path):
71
+ """Read the shared config file and return only its "parser" sub-object.
72
+
73
+ render_url.py owns the rest of the file (flat renderer keys); this
74
+ reads the same file but scopes itself to the "parser" key so both
75
+ tools can coexist in one config.json with no changes needed to
76
+ render_url.py's own key handling beyond tolerating "parser" as known-but-
77
+ ignored.
78
+ """
79
+ p = Path(path)
80
+ if not p.exists():
81
+ return {}
82
+ try:
83
+ with p.open(encoding="utf-8") as f:
84
+ data = json.load(f)
85
+ except (OSError, json.JSONDecodeError) as e:
86
+ raise SystemExit(f"error: failed to read config file {path}: {e}")
87
+ if not isinstance(data, dict):
88
+ raise SystemExit(f"error: config file {path} must contain a JSON object.")
89
+ parser_section = data.get("parser", {})
90
+ if not isinstance(parser_section, dict):
91
+ raise SystemExit(f"error: \"parser\" section in {path} must be a JSON object.")
92
+ unknown = set(parser_section) - set(DEFAULTS)
93
+ if unknown:
94
+ raise SystemExit(
95
+ f"error: unknown parser config key(s) in {path}: {', '.join(sorted(unknown))}"
96
+ )
97
+ return parser_section
98
+
99
+
100
+ def resolve_settings(args):
101
+ """Merge defaults <- config file's "parser" section <- CLI args (CLI wins)."""
102
+ settings = dict(DEFAULTS)
103
+ settings.update(load_config_file(args.config))
104
+ for key in DEFAULTS:
105
+ cli_value = getattr(args, key, None)
106
+ if cli_value is not None:
107
+ settings[key] = cli_value
108
+ return settings
109
+
110
+
111
+ def next_output_path(prefix, directory="."):
112
+ n = 1
113
+ while True:
114
+ candidate = Path(directory) / f"{prefix}_{n}.json"
115
+ if not candidate.exists():
116
+ return candidate
117
+ n += 1
118
+
119
+
120
+ def build_result(ok, source_url=None, title=None, data=None, selector_matched=None, error=None):
121
+ return {
122
+ "ok": ok,
123
+ "source_url": source_url,
124
+ "title": title,
125
+ "data": data,
126
+ "selector_matched": selector_matched,
127
+ "error": error,
128
+ }
129
+
130
+
131
+ def error_result(source_url, error_type, message):
132
+ return build_result(ok=False, source_url=source_url, error={"type": error_type, "message": message})
133
+
134
+
135
+ def _norm(text, strip_whitespace):
136
+ if strip_whitespace:
137
+ return _WHITESPACE_RE.sub(" ", text).strip()
138
+ return text
139
+
140
+
141
+ def _extract_title(soup, strip_whitespace):
142
+ tag = soup.find("title")
143
+ if tag is None:
144
+ return None
145
+ text = _norm(tag.get_text(), strip_whitespace)
146
+ return text if text else None
147
+
148
+
149
+ def _extract_headings(scope, strip_whitespace):
150
+ headings = []
151
+ for tag in scope.find_all(re.compile(r"^h[1-6]$")):
152
+ level = int(tag.name[1])
153
+ headings.append({"level": level, "text": _norm(tag.get_text(), strip_whitespace)})
154
+ return headings
155
+
156
+
157
+ def _extract_links(scope, strip_whitespace):
158
+ links = []
159
+ for tag in scope.find_all("a"):
160
+ if not tag.has_attr("href"):
161
+ continue
162
+ text = _norm(tag.get_text(), strip_whitespace) if tag.get_text() else ""
163
+ links.append({"text": text, "href": tag.get("href")})
164
+ return links
165
+
166
+
167
+ def _extract_images(scope):
168
+ images = []
169
+ for tag in scope.find_all("img"):
170
+ if not tag.has_attr("src"):
171
+ continue
172
+ images.append({"src": tag.get("src"), "alt": tag.get("alt", "")})
173
+ return images
174
+
175
+
176
+ def _extract_meta(soup):
177
+ meta = {}
178
+ for tag in soup.find_all("meta"):
179
+ key = tag.get("property") if tag.has_attr("property") else tag.get("name")
180
+ if not key or not tag.has_attr("content"):
181
+ continue
182
+ meta[key] = tag.get("content")
183
+ return meta
184
+
185
+
186
+ def _extract_text(scope, strip_whitespace):
187
+ for tag in scope.find_all(["script", "style"]):
188
+ tag.decompose()
189
+ text = scope.get_text(separator=" ")
190
+ return _norm(text, strip_whitespace)
191
+
192
+
193
+ def parse_html(html, config=None):
194
+ """Parse raw HTML into the deterministic structured-extraction schema.
195
+
196
+ Returns the same dict shape build_result() produces. No network access,
197
+ no Playwright, no LLM/AI reasoning; pure structural DOM traversal.
198
+ """
199
+ config = config or {}
200
+ source_url = config.get("source_url")
201
+ strip_whitespace = config.get("strip_whitespace", DEFAULT_STRIP_WHITESPACE)
202
+ selector = config.get("selector", DEFAULT_SELECTOR)
203
+ verbose = config.get("verbose", DEFAULT_VERBOSE)
204
+
205
+ if html is None or not isinstance(html, str):
206
+ _log(verbose, "invalid input: html is missing or not a string")
207
+ return error_result(source_url, "invalid_input", "html must be a non-empty string.")
208
+
209
+ try:
210
+ if not isinstance(strip_whitespace, bool):
211
+ raise ValueError("strip_whitespace must be a boolean.")
212
+ if selector is not None and not isinstance(selector, str):
213
+ raise ValueError("selector must be a string or null.")
214
+ except ValueError as e:
215
+ _log(verbose, f"invalid config: {e}")
216
+ return error_result(source_url, "invalid_config", str(e))
217
+
218
+ _log(verbose, f"parsing {len(html)} chars of HTML with BeautifulSoup4 (html.parser)")
219
+ try:
220
+ soup = BeautifulSoup(html, "html.parser")
221
+ except Exception as e:
222
+ _log(verbose, f"parse error: {e}")
223
+ return error_result(source_url, "parse_error", str(e))
224
+
225
+ try:
226
+ _log(verbose, "extracting title and meta tags")
227
+ title = _extract_title(soup, strip_whitespace)
228
+ meta = _extract_meta(soup)
229
+
230
+ selector_matched = None
231
+ scope = soup.body if soup.body is not None else soup
232
+
233
+ if selector:
234
+ _log(verbose, f"applying selector: {selector!r}")
235
+ try:
236
+ matches = soup.select(selector)
237
+ except Exception as e:
238
+ _log(verbose, f"invalid selector: {e}")
239
+ return error_result(source_url, "invalid_config", f"invalid selector: {e}")
240
+ if matches:
241
+ scope = matches[0]
242
+ selector_matched = True
243
+ _log(verbose, "selector matched an element; scoping extraction to it")
244
+ else:
245
+ selector_matched = False
246
+ _log(verbose, "selector matched nothing; returning empty scoped fields")
247
+ return build_result(
248
+ ok=True,
249
+ source_url=source_url,
250
+ title=title,
251
+ data={"headings": [], "links": [], "images": [], "meta": meta, "text": ""},
252
+ selector_matched=False,
253
+ error=None,
254
+ )
255
+
256
+ _log(verbose, "extracting headings, links, images, and text")
257
+ data = {
258
+ "headings": _extract_headings(scope, strip_whitespace),
259
+ "links": _extract_links(scope, strip_whitespace),
260
+ "images": _extract_images(scope),
261
+ "meta": meta,
262
+ "text": _extract_text(scope, strip_whitespace),
263
+ }
264
+ _log(
265
+ verbose,
266
+ f"done: {len(data['headings'])} headings, {len(data['links'])} links, "
267
+ f"{len(data['images'])} images extracted",
268
+ )
269
+
270
+ return build_result(
271
+ ok=True,
272
+ source_url=source_url,
273
+ title=title,
274
+ data=data,
275
+ selector_matched=selector_matched,
276
+ error=None,
277
+ )
278
+ except Exception as e:
279
+ _log(verbose, f"unexpected error: {e}")
280
+ return error_result(source_url, "unknown_error", str(e))
281
+
282
+
283
+ def parse_args(argv=None):
284
+ parser = argparse.ArgumentParser(
285
+ description="Parse a Step 1 rendered-page JSON file's HTML into deterministic structured JSON."
286
+ )
287
+ parser.add_argument(
288
+ "input",
289
+ nargs="?",
290
+ default=None,
291
+ help="Path to a Step 1 JSON output file (or use --input). Use \"-\" to read from stdin.",
292
+ )
293
+ parser.add_argument(
294
+ "--input",
295
+ dest="input_opt",
296
+ default=None,
297
+ help="Path to a Step 1 JSON output file (alternative to the positional argument).",
298
+ )
299
+ parser.add_argument(
300
+ "--config",
301
+ default=DEFAULT_CONFIG_PATH,
302
+ help=f"Path to a JSON config file (default: {DEFAULT_CONFIG_PATH}). Reads its \"parser\" section.",
303
+ )
304
+ parser.add_argument(
305
+ "--selector",
306
+ dest="selector",
307
+ default=None,
308
+ help="CSS selector to scope headings/links/images/text extraction to.",
309
+ )
310
+ parser.add_argument(
311
+ "--no-strip-whitespace",
312
+ dest="strip_whitespace",
313
+ action="store_const",
314
+ const=False,
315
+ default=None,
316
+ help="Preserve original whitespace in extracted text instead of collapsing it.",
317
+ )
318
+ parser.add_argument(
319
+ "--output-prefix",
320
+ dest="output_prefix",
321
+ default=None,
322
+ help=f"Prefix for the auto-incremented output file (default: {DEFAULT_OUTPUT_PREFIX}).",
323
+ )
324
+ parser.add_argument(
325
+ "--output-dir",
326
+ dest="output_dir",
327
+ default=None,
328
+ help=f"Directory to write the output file into (default: {DEFAULT_OUTPUT_DIR}).",
329
+ )
330
+ parser.add_argument(
331
+ "--verbose", "-v",
332
+ dest="verbose",
333
+ action="store_const",
334
+ const=True,
335
+ default=None,
336
+ help="Log parsing progress (input loading, extraction steps, field counts) to stderr.",
337
+ )
338
+ parser.add_argument(
339
+ "--log-json",
340
+ dest="log_json",
341
+ action="store_const",
342
+ const=True,
343
+ default=None,
344
+ help="Additionally log the final result JSON, pretty-printed, to stderr.",
345
+ )
346
+ return parser.parse_args(argv)
347
+
348
+
349
+ def _print_result(result, log_json):
350
+ print(json.dumps(result))
351
+ if log_json:
352
+ print(json.dumps(result, indent=2), file=sys.stderr)
353
+
354
+
355
+ def main():
356
+ args = parse_args()
357
+ settings = resolve_settings(args)
358
+ verbose = settings["verbose"]
359
+ log_json = settings["log_json"]
360
+
361
+ input_path = args.input_opt or args.input
362
+
363
+ if not input_path:
364
+ _log(verbose, "no input file provided")
365
+ result = error_result(None, "invalid_input", "No input file provided (positional argument, --input, or \"-\" for stdin).")
366
+ _print_result(result, log_json)
367
+ sys.exit(1)
368
+
369
+ _log(verbose, f"reading input from {'stdin' if input_path == '-' else input_path}")
370
+ try:
371
+ if input_path == "-":
372
+ raw_text = sys.stdin.read()
373
+ else:
374
+ raw_text = Path(input_path).read_text(encoding="utf-8")
375
+ except OSError as e:
376
+ _log(verbose, f"failed to read input: {e}")
377
+ result = error_result(None, "invalid_input", f"failed to read input file {input_path}: {e}")
378
+ _print_result(result, log_json)
379
+ sys.exit(1)
380
+
381
+ try:
382
+ step1 = json.loads(raw_text)
383
+ except json.JSONDecodeError as e:
384
+ _log(verbose, f"input is not valid JSON: {e}")
385
+ result = error_result(None, "invalid_input", f"input file is not valid JSON: {e}")
386
+ _print_result(result, log_json)
387
+ output_path = next_output_path(settings["output_prefix"], settings["output_dir"])
388
+ _write_output(output_path, result, verbose)
389
+ sys.exit(1)
390
+
391
+ if not isinstance(step1, dict):
392
+ _log(verbose, "input JSON is not an object")
393
+ result = error_result(None, "invalid_input", "Step 1 JSON must be an object.")
394
+ _print_result(result, log_json)
395
+ output_path = next_output_path(settings["output_prefix"], settings["output_dir"])
396
+ _write_output(output_path, result, verbose)
397
+ sys.exit(1)
398
+
399
+ source_url = step1.get("url")
400
+ html = step1.get("html")
401
+
402
+ if not step1.get("ok", False) or html is None:
403
+ _log(verbose, "Step 1 result had ok=false or no html field; skipping parse")
404
+ result = error_result(source_url, "missing_html", "Step 1 result had ok=false or a null html field.")
405
+ else:
406
+ config = {
407
+ "source_url": source_url,
408
+ "selector": settings["selector"],
409
+ "strip_whitespace": settings["strip_whitespace"],
410
+ "verbose": verbose,
411
+ }
412
+ try:
413
+ result = parse_html(html, config=config)
414
+ except Exception as e:
415
+ _log(verbose, f"unexpected error: {e}")
416
+ result = error_result(source_url, "unknown_error", str(e))
417
+
418
+ _print_result(result, log_json)
419
+ output_path = next_output_path(settings["output_prefix"], settings["output_dir"])
420
+ _write_output(output_path, result, verbose)
421
+
422
+
423
+ def _write_output(path, result, verbose=False):
424
+ _log(verbose, f"writing output to {path}")
425
+ try:
426
+ path.write_text(json.dumps(result), encoding="utf-8")
427
+ except OSError as e:
428
+ print(f"warning: failed to write output file {path}: {e}", file=sys.stderr)
429
+
430
+
431
+ if __name__ == "__main__":
432
+ main()
render_and_parse.py ADDED
@@ -0,0 +1,156 @@
1
+ """
2
+ render_and_parse.py — convenience wrapper chaining Step 1 and Step 2.
3
+
4
+ Pipeline: URL -> render_url.render() -> Step 1 JSON -> parse_html.parse_html()
5
+ -> Step 2 JSON on stdout.
6
+
7
+ This is a thin orchestration layer only. It imports both render_url and
8
+ parse_html and calls their public functions directly (no subprocesses); it
9
+ adds no rendering or parsing logic of its own. render_url.py and
10
+ parse_html.py remain independently usable and parse_html.py still does not
11
+ import render_url.
12
+ """
13
+
14
+ import argparse
15
+ import json
16
+ import sys
17
+ from types import SimpleNamespace
18
+
19
+ import render_url
20
+ import parse_html
21
+
22
+
23
+ def _log(verbose, message):
24
+ if verbose:
25
+ print(f"[render-and-parse] {message}", file=sys.stderr)
26
+
27
+
28
+ def parse_args(argv=None):
29
+ parser = argparse.ArgumentParser(
30
+ description="Render a URL with Chromium and parse the result into structured JSON, in one step."
31
+ )
32
+ parser.add_argument("url", nargs="?", default=None, help="URL to render and parse.")
33
+ parser.add_argument("--config", default=render_url.DEFAULT_CONFIG_PATH,
34
+ help=f"Path to a JSON config file (default: {render_url.DEFAULT_CONFIG_PATH}).")
35
+ parser.add_argument("--timeout", dest="timeout_ms", type=int, default=None,
36
+ help="Overall navigation timeout in milliseconds (render stage).")
37
+ parser.add_argument("--stabilization", dest="stabilization_ms", type=int, default=None,
38
+ help="Settle time in milliseconds after load (render stage).")
39
+ parser.add_argument("--wait-until", dest="wait_until", choices=render_url.VALID_WAIT_UNTIL, default=None,
40
+ help="Load state to wait for after navigation (render stage).")
41
+ parser.add_argument("--headless", dest="headless", action="store_const", const=True, default=None,
42
+ help="Run Chromium headless (render stage).")
43
+ parser.add_argument("--no-headless", dest="headless", action="store_const", const=False,
44
+ help="Run Chromium with a visible window (render stage).")
45
+ parser.add_argument("--selector", dest="selector", default=None,
46
+ help="CSS selector to scope extraction to (parse stage).")
47
+ parser.add_argument("--no-strip-whitespace", dest="strip_whitespace", action="store_const",
48
+ const=False, default=None, help="Preserve raw whitespace (parse stage).")
49
+ parser.add_argument("--output-dir", dest="output_dir", default=None,
50
+ help="Directory for both the rendered_page_*.json and parsed_page_*.json files.")
51
+ parser.add_argument("--verbose", "-v", dest="verbose", action="store_const", const=True, default=None,
52
+ help="Log progress from both the render and parse stages to stderr.")
53
+ parser.add_argument("--log-json", dest="log_json", action="store_const", const=True, default=None,
54
+ help="Additionally log the render stage and final parse stage JSON, pretty-printed, to stderr.")
55
+ return parser.parse_args(argv)
56
+
57
+
58
+ def _log_json(log_json, label, result):
59
+ if log_json:
60
+ print(f"[render-and-parse] {label}:", file=sys.stderr)
61
+ print(json.dumps(result, indent=2), file=sys.stderr)
62
+
63
+
64
+ def main():
65
+ args = parse_args()
66
+
67
+ render_args = SimpleNamespace(
68
+ config=args.config,
69
+ url=args.url,
70
+ timeout_ms=args.timeout_ms,
71
+ stabilization_ms=args.stabilization_ms,
72
+ wait_until=args.wait_until,
73
+ headless=args.headless,
74
+ output_prefix=None,
75
+ output_dir=args.output_dir,
76
+ verbose=args.verbose,
77
+ log_json=args.log_json,
78
+ )
79
+ render_settings = render_url.resolve_settings(render_args)
80
+ verbose = render_settings["verbose"]
81
+ log_json = render_settings["log_json"]
82
+
83
+ url = render_settings["url"]
84
+ if not url:
85
+ result = parse_html.error_result(None, "invalid_input", "No URL provided via argument or config file.")
86
+ print(json.dumps(result))
87
+ _log_json(log_json, "final result", result)
88
+ sys.exit(1)
89
+
90
+ _log(verbose, f"stage 1/2: rendering {url}")
91
+ if not render_url.is_valid_url(url):
92
+ render_result = render_url.error_result(url, "invalid_url", "URL must be an absolute http(s) URL.")
93
+ else:
94
+ try:
95
+ render_result = render_url.render(
96
+ url,
97
+ timeout_ms=render_settings["timeout_ms"],
98
+ stabilization_ms=render_settings["stabilization_ms"],
99
+ wait_until=render_settings["wait_until"],
100
+ headless=render_settings["headless"],
101
+ verbose=verbose,
102
+ )
103
+ except Exception as e:
104
+ render_result = render_url.error_result(url, "unknown_error", str(e))
105
+
106
+ render_output_path = render_url.next_output_path(render_settings["output_prefix"], render_settings["output_dir"])
107
+ _log(verbose, f"writing render stage output to {render_output_path}")
108
+ _log_json(log_json, "render stage result", render_result)
109
+ try:
110
+ render_output_path.write_text(json.dumps(render_result), encoding="utf-8")
111
+ except OSError as e:
112
+ print(f"warning: failed to write output file {render_output_path}: {e}", file=sys.stderr)
113
+
114
+ parse_args_ns = SimpleNamespace(
115
+ config=args.config,
116
+ input=None,
117
+ input_opt=None,
118
+ selector=args.selector,
119
+ strip_whitespace=args.strip_whitespace,
120
+ output_prefix=None,
121
+ output_dir=args.output_dir,
122
+ verbose=args.verbose,
123
+ log_json=args.log_json,
124
+ )
125
+ parse_settings = parse_html.resolve_settings(parse_args_ns)
126
+
127
+ _log(verbose, "stage 2/2: parsing rendered HTML")
128
+ html = render_result.get("html")
129
+ if not render_result.get("ok", False) or html is None:
130
+ _log(verbose, "render stage failed or produced no html; skipping parse")
131
+ parse_result = parse_html.error_result(url, "missing_html", "Step 1 result had ok=false or a null html field.")
132
+ else:
133
+ parse_config = {
134
+ "source_url": render_result.get("url"),
135
+ "selector": parse_settings["selector"],
136
+ "strip_whitespace": parse_settings["strip_whitespace"],
137
+ "verbose": verbose,
138
+ }
139
+ try:
140
+ parse_result = parse_html.parse_html(html, config=parse_config)
141
+ except Exception as e:
142
+ parse_result = parse_html.error_result(url, "unknown_error", str(e))
143
+
144
+ parse_output_path = parse_html.next_output_path(parse_settings["output_prefix"], parse_settings["output_dir"])
145
+ _log(verbose, f"writing parse stage output to {parse_output_path}")
146
+ try:
147
+ parse_output_path.write_text(json.dumps(parse_result), encoding="utf-8")
148
+ except OSError as e:
149
+ print(f"warning: failed to write output file {parse_output_path}: {e}", file=sys.stderr)
150
+
151
+ print(json.dumps(parse_result))
152
+ _log_json(log_json, "final result", parse_result)
153
+
154
+
155
+ if __name__ == "__main__":
156
+ main()
@@ -0,0 +1,189 @@
1
+ Metadata-Version: 2.4
2
+ Name: render-url
3
+ Version: 1.0.0
4
+ Summary: Render a single URL with Chromium (via Playwright) and emit the post-JS DOM as JSON.
5
+ Requires-Python: >=3.8
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: playwright>=1.40.0
8
+ Requires-Dist: beautifulsoup4>=4.12.0
9
+
10
+ # PlaywrightURLJsonExtractor
11
+
12
+ Two independent, deterministic CLI tools — no LLM/AI, no crawling:
13
+
14
+ - **`render-url`** — renders one URL in headless Chromium (via Playwright) and outputs the post-JS DOM as JSON.
15
+ - **`parse-html`** — parses the `html` field from `render-url`'s output into structured JSON (via BeautifulSoup4).
16
+
17
+ ```
18
+ URL -> render-url -> rendered JSON (html field) -> parse-html -> structured JSON
19
+ ```
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pip install -e .
25
+ playwright install chromium
26
+ ```
27
+
28
+ This puts `render-url` and `parse-html` on your `PATH`.
29
+
30
+ ## Commands
31
+
32
+ ### `render-url` — render a URL to JSON
33
+
34
+ ```bash
35
+ render-url "https://example.com"
36
+ ```
37
+
38
+ Prints one JSON object to stdout and writes it to an auto-incremented file (`rendered_page_1.json`, `rendered_page_2.json`, ...). Never overwrites existing files.
39
+
40
+ | Parameter | Flag | Default | Description |
41
+ |---|---|---|---|
42
+ | URL | positional, or `url` in config | *(none)* | The single URL to render. Required (from CLI or config). |
43
+ | Config file | `--config <path>` | `config.json` | Path to the JSON config file. |
44
+ | Timeout | `--timeout <ms>` | `30000` | Overall navigation timeout in milliseconds. |
45
+ | Stabilization | `--stabilization <ms>` | `1000` | Fixed settle time (ms) after load, before capturing the DOM. |
46
+ | Wait condition | `--wait-until <state>` | `load` | `load`, `domcontentloaded`, or `networkidle`. |
47
+ | Headless | `--headless` / `--no-headless` | `--headless` | Run Chromium headless or with a visible window. |
48
+ | Output prefix | `--output-prefix <name>` | `rendered_page` | Base name for the output JSON file. |
49
+ | Output dir | `--output-dir <dir>` | `.` | Directory the output file is written into. |
50
+ | Verbose | `--verbose` / `-v` | off | Log progress (navigation, waits, extraction, browser lifecycle) to stderr. Stdout still carries only the final JSON. |
51
+ | Log JSON | `--log-json` | off | Additionally pretty-print the final result JSON to stderr. |
52
+
53
+ ### `parse-html` — extract structured data from rendered HTML
54
+
55
+ ```bash
56
+ parse-html rendered_page_1.json
57
+ ```
58
+
59
+ Reads a `render-url` JSON file (or stdin via `-`), extracts fields from its `html`, prints one JSON object to stdout, and writes it to an auto-incremented file (`parsed_page_1.json`, `parsed_page_2.json`, ...).
60
+
61
+ | Parameter | Flag | Default | Description |
62
+ |---|---|---|---|
63
+ | Input file | positional, or `--input <path>` | *(none)* | `render-url` JSON file to read. Use `-` for stdin. |
64
+ | Config file | `--config <path>` | `config.json` | Path to the JSON config file (reads its `"parser"` section). |
65
+ | CSS selector | `--selector <css>` | `null` | Scopes `headings`/`links`/`images`/`text` to the first matching element. No match -> those fields come back empty, not an error. |
66
+ | Whitespace | `--no-strip-whitespace` | strip on | Disable whitespace collapsing in extracted text. |
67
+ | Output prefix | `--output-prefix <name>` | `parsed_page` | Base name for the output JSON file. |
68
+ | Output dir | `--output-dir <dir>` | `.` | Directory the output file is written into. |
69
+ | Verbose | `--verbose` / `-v` | off | Log progress (input loading, extraction steps, field counts) to stderr. Stdout still carries only the final JSON. |
70
+ | Log JSON | `--log-json` | off | Additionally pretty-print the final result JSON to stderr. |
71
+
72
+ ### Extracted fields
73
+
74
+ | Field | What it is | Fallback |
75
+ |---|---|---|
76
+ | `title` | `<title>` text | `null` if absent/empty |
77
+ | `headings` | `<h1>`–`<h6>`, in order | `[]` if none |
78
+ | `links` | `<a href>` text + href, in order (duplicates kept) | anchors without `href` excluded |
79
+ | `images` | `<img src>` + `alt`, in order | `alt` defaults to `""`; no `src` excluded |
80
+ | `meta` | `<meta name/property>` -> `content` | later tag wins on duplicate keys |
81
+ | `text` | visible body text (scripts/styles excluded) | `""` if none |
82
+
83
+ ### `render-and-parse` — do both in one command
84
+
85
+ ```bash
86
+ render-and-parse "https://example.com"
87
+ ```
88
+
89
+ Runs `render-url` then `parse-html` in a single process and prints the final structured JSON. Writes both `rendered_page_N.json` and `parsed_page_N.json`.
90
+
91
+ | Parameter | Flag | Default | Description |
92
+ |---|---|---|---|
93
+ | URL | positional | *(none)* | The URL to render and parse. |
94
+ | Config file | `--config <path>` | `config.json` | Shared config file for both stages. |
95
+ | Timeout | `--timeout <ms>` | `30000` | Render-stage navigation timeout. |
96
+ | Stabilization | `--stabilization <ms>` | `1000` | Render-stage settle time. |
97
+ | Wait condition | `--wait-until <state>` | `load` | Render-stage load state. |
98
+ | Headless | `--headless` / `--no-headless` | `--headless` | Render-stage Chromium visibility. |
99
+ | CSS selector | `--selector <css>` | `null` | Parse-stage extraction scope. |
100
+ | Whitespace | `--no-strip-whitespace` | strip on | Parse-stage whitespace handling. |
101
+ | Output dir | `--output-dir <dir>` | `.` | Directory for both output files. |
102
+ | Verbose | `--verbose` / `-v` | off | Log progress from both stages to stderr. |
103
+ | Log JSON | `--log-json` | off | Additionally pretty-print the render stage and final result JSON to stderr. |
104
+
105
+ ### Full pipeline manually (two commands)
106
+
107
+ ```bash
108
+ render-url "https://example.com"
109
+ parse-html rendered_page_1.json
110
+
111
+ # or piped:
112
+ render-url "https://example.com" | parse-html -
113
+ ```
114
+
115
+ ### Tests
116
+
117
+ ```bash
118
+ python -m pytest tests/ -v
119
+ ```
120
+
121
+ ### Publishing to PyPI
122
+
123
+ ```bash
124
+ pip install build twine
125
+
126
+ # bump "version" in pyproject.toml first, then:
127
+ rm -rf dist build render_url.egg-info # PowerShell: Remove-Item -Recurse -Force dist, build, render_url.egg-info -ErrorAction SilentlyContinue
128
+ python -m build # builds dist/*.whl and dist/*.tar.gz
129
+ python -m twine check dist/* # validates metadata before upload
130
+ python -m twine upload dist/* # uploads to PyPI (prompts for credentials/token)
131
+ ```
132
+
133
+ Use `python -m twine upload --repository testpypi dist/*` to publish to [TestPyPI](https://test.pypi.org/) first if you want to verify the package before a real release.
134
+
135
+ ## Configuration
136
+
137
+ Precedence: **built-in defaults < `config.json` < CLI flags**. Both tools share one `config.json` (`parse-html` only reads its `"parser"` key). Copy [config.example.json](config.example.json) to get started — `config.json` is gitignored (local/per-environment). Unknown keys are rejected as typos.
138
+
139
+ ```json
140
+ {
141
+ "url": "https://example.com",
142
+ "timeout_ms": 30000,
143
+ "stabilization_ms": 1000,
144
+ "wait_until": "load",
145
+ "headless": true,
146
+ "output_prefix": "rendered_page",
147
+ "output_dir": ".",
148
+ "verbose": false,
149
+ "log_json": false,
150
+ "parser": {
151
+ "selector": null,
152
+ "strip_whitespace": true,
153
+ "output_prefix": "parsed_page",
154
+ "output_dir": ".",
155
+ "verbose": false,
156
+ "log_json": false
157
+ }
158
+ }
159
+ ```
160
+
161
+ ## Output schemas
162
+
163
+ **`render-url` success:**
164
+ ```json
165
+ {"ok": true, "url": "...", "final_url": "...", "status_code": 200, "title": "...", "html": "...", "error": null}
166
+ ```
167
+ Errors: `invalid_url`, `navigation_error`, `navigation_timeout`, `browser_error`, `html_extraction_error`, `unknown_error`.
168
+
169
+ **`parse-html` success:**
170
+ ```json
171
+ {
172
+ "ok": true,
173
+ "source_url": "...",
174
+ "title": "...",
175
+ "data": {"headings": [...], "links": [...], "images": [...], "meta": {...}, "text": "..."},
176
+ "selector_matched": null,
177
+ "error": null
178
+ }
179
+ ```
180
+ Errors: `invalid_input`, `missing_html` (Step 1 failed or had no `html`), `invalid_config`, `parse_error`, `unknown_error`.
181
+
182
+ Both tools always print exactly one JSON object to stdout (success or failure) — diagnostics go to stderr only.
183
+
184
+ ## What this project intentionally does NOT do
185
+
186
+ - No crawling, link-following, or multi-URL batching.
187
+ - No DOM mutation, clicking, form submission, or authentication.
188
+ - No LLM, AI, embeddings, or semantic interpretation — structural extraction only.
189
+ - `parse-html` makes no network calls and never launches a browser.
@@ -0,0 +1,8 @@
1
+ parse_html.py,sha256=zvLD6Nrph0cutkuLSET4-NCPkev7-0x9uX-XiL6GOjY,15189
2
+ render_and_parse.py,sha256=khjFlkSlnmaexchr5Tah0bi8KYPg3umsGKs8Tp5ObYs,6950
3
+ render_url.py,sha256=raeb1uQwV4O6JX1rlPQqLvKVU3FyaFMRizWvyY_hvqA,10908
4
+ render_url-1.0.0.dist-info/METADATA,sha256=7vzl0iEjrzNnhkBpqiyluW8uzXb5gtlUOTpElzag5fw,8212
5
+ render_url-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
6
+ render_url-1.0.0.dist-info/entry_points.txt,sha256=1CIhkowLh4Uxh4k7oDCqfc97gRKiItv7QyjPStv9U58,117
7
+ render_url-1.0.0.dist-info/top_level.txt,sha256=TNsFRwMotgU-rCoAsa1ZJNwOc23VnFeX1vSXHdhiwaQ,39
8
+ render_url-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ parse-html = parse_html:main
3
+ render-and-parse = render_and_parse:main
4
+ render-url = render_url:main
@@ -0,0 +1,3 @@
1
+ parse_html
2
+ render_and_parse
3
+ render_url
render_url.py ADDED
@@ -0,0 +1,322 @@
1
+ """
2
+ render_url.py — deterministic URL -> rendered HTML JSON utility.
3
+
4
+ Pipeline: URL -> Playwright (Chromium) -> JS/WASM execution -> rendered DOM
5
+ -> HTML -> single JSON object on stdout.
6
+
7
+ No LLM, AI model, agent reasoning, or semantic HTML interpretation is used
8
+ anywhere in this script. It is a renderer, not a scraper.
9
+ """
10
+
11
+ import argparse
12
+ import json
13
+ import sys
14
+ from pathlib import Path
15
+ from urllib.parse import urlparse
16
+
17
+ from playwright.sync_api import sync_playwright, Error as PlaywrightError, TimeoutError as PlaywrightTimeoutError
18
+
19
+
20
+ DEFAULT_TIMEOUT_MS = 30000
21
+ DEFAULT_STABILIZATION_MS = 1000
22
+ DEFAULT_OUTPUT_PREFIX = "rendered_page"
23
+ DEFAULT_OUTPUT_DIR = "."
24
+ DEFAULT_WAIT_UNTIL = "load"
25
+ DEFAULT_HEADLESS = True
26
+ DEFAULT_CONFIG_PATH = "config.json"
27
+
28
+ # wait_until values Playwright's page.wait_for_load_state() accepts.
29
+ VALID_WAIT_UNTIL = ("load", "domcontentloaded", "networkidle")
30
+
31
+ DEFAULT_VERBOSE = False
32
+ DEFAULT_LOG_JSON = False
33
+
34
+ DEFAULTS = {
35
+ "url": None,
36
+ "timeout_ms": DEFAULT_TIMEOUT_MS,
37
+ "stabilization_ms": DEFAULT_STABILIZATION_MS,
38
+ "wait_until": DEFAULT_WAIT_UNTIL,
39
+ "headless": DEFAULT_HEADLESS,
40
+ "output_prefix": DEFAULT_OUTPUT_PREFIX,
41
+ "output_dir": DEFAULT_OUTPUT_DIR,
42
+ "verbose": DEFAULT_VERBOSE,
43
+ "log_json": DEFAULT_LOG_JSON,
44
+ }
45
+
46
+
47
+ def load_config_file(path):
48
+ """Read a JSON config file. Returns {} if the file doesn't exist."""
49
+ p = Path(path)
50
+ if not p.exists():
51
+ return {}
52
+ try:
53
+ with p.open(encoding="utf-8") as f:
54
+ data = json.load(f)
55
+ except (OSError, json.JSONDecodeError) as e:
56
+ raise SystemExit(f"error: failed to read config file {path}: {e}")
57
+ if not isinstance(data, dict):
58
+ raise SystemExit(f"error: config file {path} must contain a JSON object.")
59
+ # "parser" is reserved for Step 2 (parse_html.py), which reads the same
60
+ # config file but only looks at this sub-key. Ignored here so both tools
61
+ # can share one config.json without render_url.py rejecting it as a typo.
62
+ unknown = set(data) - set(DEFAULTS) - {"parser"}
63
+ if unknown:
64
+ raise SystemExit(f"error: unknown config key(s) in {path}: {', '.join(sorted(unknown))}")
65
+ return {k: v for k, v in data.items() if k != "parser"}
66
+
67
+
68
+ def resolve_settings(args):
69
+ """Merge defaults <- config file <- CLI args (CLI wins)."""
70
+ settings = dict(DEFAULTS)
71
+ settings.update(load_config_file(args.config))
72
+ for key in DEFAULTS:
73
+ cli_value = getattr(args, key, None)
74
+ if cli_value is not None:
75
+ settings[key] = cli_value
76
+ return settings
77
+
78
+
79
+ def next_output_path(prefix, directory="."):
80
+ n = 1
81
+ while True:
82
+ candidate = Path(directory) / f"{prefix}_{n}.json"
83
+ if not candidate.exists():
84
+ return candidate
85
+ n += 1
86
+
87
+
88
+ def build_result(ok, url, final_url=None, status_code=None, title=None, html=None, error=None):
89
+ return {
90
+ "ok": ok,
91
+ "url": url,
92
+ "final_url": final_url,
93
+ "status_code": status_code,
94
+ "title": title,
95
+ "html": html,
96
+ "error": error,
97
+ }
98
+
99
+
100
+ def error_result(url, error_type, message):
101
+ return build_result(
102
+ ok=False,
103
+ url=url,
104
+ error={"type": error_type, "message": message},
105
+ )
106
+
107
+
108
+ def is_valid_url(url):
109
+ try:
110
+ parsed = urlparse(url)
111
+ return parsed.scheme in ("http", "https") and bool(parsed.netloc)
112
+ except Exception:
113
+ return False
114
+
115
+
116
+ def _log(verbose, message):
117
+ if verbose:
118
+ print(f"[render-url] {message}", file=sys.stderr)
119
+
120
+
121
+ def render(url, timeout_ms, stabilization_ms=DEFAULT_STABILIZATION_MS,
122
+ wait_until=DEFAULT_WAIT_UNTIL, headless=DEFAULT_HEADLESS, verbose=False):
123
+ with sync_playwright() as p:
124
+ browser = None
125
+ try:
126
+ _log(verbose, f"launching Chromium (headless={headless})")
127
+ try:
128
+ browser = p.chromium.launch(headless=headless)
129
+ except PlaywrightError as e:
130
+ _log(verbose, f"browser launch failed: {e}")
131
+ return error_result(url, "browser_error", str(e))
132
+
133
+ page = browser.new_page()
134
+ page.set_default_timeout(timeout_ms)
135
+
136
+ status_code = None
137
+ _log(verbose, f"navigating to {url} (timeout={timeout_ms}ms)")
138
+ try:
139
+ response = page.goto(url, wait_until="domcontentloaded", timeout=timeout_ms)
140
+ if response is not None:
141
+ status_code = response.status
142
+ _log(verbose, f"domcontentloaded (status={status_code})")
143
+ except PlaywrightTimeoutError as e:
144
+ _log(verbose, f"navigation timed out: {e}")
145
+ return error_result(url, "navigation_timeout", str(e))
146
+ except PlaywrightError as e:
147
+ _log(verbose, f"navigation error: {e}")
148
+ return error_result(url, "navigation_error", str(e))
149
+
150
+ # Best-effort extra settle time beyond domcontentloaded, without
151
+ # relying on networkidle by default (SPAs may keep long-lived
152
+ # connections open and never reach it) — configurable via
153
+ # wait_until for callers who do want it.
154
+ _log(verbose, f"waiting for load state \"{wait_until}\"")
155
+ try:
156
+ page.wait_for_load_state(wait_until, timeout=timeout_ms)
157
+ except PlaywrightTimeoutError:
158
+ _log(verbose, f"load state \"{wait_until}\" timed out (continuing)")
159
+ except PlaywrightError:
160
+ pass
161
+
162
+ _log(verbose, f"stabilizing for {stabilization_ms}ms")
163
+ page.wait_for_timeout(stabilization_ms)
164
+
165
+ _log(verbose, "extracting outerHTML, title, and final URL")
166
+ try:
167
+ html = page.evaluate("document.documentElement.outerHTML")
168
+ title = page.title()
169
+ final_url = page.url
170
+ except PlaywrightError as e:
171
+ _log(verbose, f"HTML extraction failed: {e}")
172
+ return error_result(url, "html_extraction_error", str(e))
173
+
174
+ _log(verbose, f"done: {len(html)} chars of HTML captured")
175
+ return build_result(
176
+ ok=True,
177
+ url=url,
178
+ final_url=final_url,
179
+ status_code=status_code,
180
+ title=title,
181
+ html=html,
182
+ error=None,
183
+ )
184
+ finally:
185
+ if browser is not None:
186
+ _log(verbose, "closing Chromium")
187
+ try:
188
+ browser.close()
189
+ except PlaywrightError:
190
+ pass
191
+
192
+
193
+ def parse_args(argv=None):
194
+ parser = argparse.ArgumentParser(
195
+ description="Render a single URL with Chromium (via Playwright) and emit the post-JS DOM as JSON."
196
+ )
197
+ parser.add_argument(
198
+ "url",
199
+ nargs="?",
200
+ default=None,
201
+ help="Exactly one URL to render. Falls back to \"url\" in the config file if omitted.",
202
+ )
203
+ parser.add_argument(
204
+ "--config",
205
+ default=DEFAULT_CONFIG_PATH,
206
+ help=f"Path to a JSON config file (default: {DEFAULT_CONFIG_PATH}; ignored if it doesn't exist). "
207
+ "CLI flags override values from this file.",
208
+ )
209
+ parser.add_argument(
210
+ "--timeout",
211
+ dest="timeout_ms",
212
+ type=int,
213
+ default=None,
214
+ help=f"Overall navigation timeout in milliseconds (default: {DEFAULT_TIMEOUT_MS}).",
215
+ )
216
+ parser.add_argument(
217
+ "--stabilization",
218
+ dest="stabilization_ms",
219
+ type=int,
220
+ default=None,
221
+ help=f"Fixed settle time after load, in milliseconds, before capturing the DOM "
222
+ f"(default: {DEFAULT_STABILIZATION_MS}).",
223
+ )
224
+ parser.add_argument(
225
+ "--wait-until",
226
+ dest="wait_until",
227
+ choices=VALID_WAIT_UNTIL,
228
+ default=None,
229
+ help=f"Load state to wait for after navigation (default: {DEFAULT_WAIT_UNTIL}). "
230
+ "\"networkidle\" is not recommended for SPAs that keep long-lived connections open.",
231
+ )
232
+ parser.add_argument(
233
+ "--headless",
234
+ dest="headless",
235
+ action="store_const",
236
+ const=True,
237
+ default=None,
238
+ help=f"Run Chromium headless (default: {DEFAULT_HEADLESS}).",
239
+ )
240
+ parser.add_argument(
241
+ "--no-headless",
242
+ dest="headless",
243
+ action="store_const",
244
+ const=False,
245
+ help="Run Chromium with a visible browser window.",
246
+ )
247
+ parser.add_argument(
248
+ "--output-prefix",
249
+ dest="output_prefix",
250
+ default=None,
251
+ help=f"Prefix for the auto-incremented output file, e.g. <prefix>_1.json, <prefix>_2.json, ... "
252
+ f"(default: {DEFAULT_OUTPUT_PREFIX}).",
253
+ )
254
+ parser.add_argument(
255
+ "--output-dir",
256
+ dest="output_dir",
257
+ default=None,
258
+ help=f"Directory to write the output file into (default: {DEFAULT_OUTPUT_DIR}).",
259
+ )
260
+ parser.add_argument(
261
+ "--verbose", "-v",
262
+ dest="verbose",
263
+ action="store_const",
264
+ const=True,
265
+ default=None,
266
+ help="Log rendering progress (navigation, waits, extraction, browser lifecycle) to stderr.",
267
+ )
268
+ parser.add_argument(
269
+ "--log-json",
270
+ dest="log_json",
271
+ action="store_const",
272
+ const=True,
273
+ default=None,
274
+ help="Additionally log the final result JSON, pretty-printed, to stderr.",
275
+ )
276
+ return parser.parse_args(argv)
277
+
278
+
279
+ def main():
280
+ args = parse_args()
281
+ settings = resolve_settings(args)
282
+ verbose = settings["verbose"]
283
+ log_json = settings["log_json"]
284
+
285
+ url = settings["url"]
286
+ if not url:
287
+ print(json.dumps(error_result(None, "invalid_url", "No URL provided via argument or config file.")))
288
+ sys.exit(1)
289
+
290
+ if not is_valid_url(url):
291
+ _log(verbose, f"invalid URL: {url!r}")
292
+ result = error_result(url, "invalid_url", "URL must be an absolute http(s) URL.")
293
+ else:
294
+ try:
295
+ result = render(
296
+ url,
297
+ timeout_ms=settings["timeout_ms"],
298
+ stabilization_ms=settings["stabilization_ms"],
299
+ wait_until=settings["wait_until"],
300
+ headless=settings["headless"],
301
+ verbose=verbose,
302
+ )
303
+ except Exception as e:
304
+ _log(verbose, f"unexpected error: {e}")
305
+ result = error_result(url, "unknown_error", str(e))
306
+
307
+ output_text = json.dumps(result)
308
+ print(output_text)
309
+
310
+ if log_json:
311
+ print(json.dumps(result, indent=2), file=sys.stderr)
312
+
313
+ output_path = next_output_path(settings["output_prefix"], settings["output_dir"])
314
+ _log(verbose, f"writing output to {output_path}")
315
+ try:
316
+ output_path.write_text(output_text, encoding="utf-8")
317
+ except OSError as e:
318
+ print(f"warning: failed to write output file {output_path}: {e}", file=sys.stderr)
319
+
320
+
321
+ if __name__ == "__main__":
322
+ main()