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 +432 -0
- render_and_parse.py +156 -0
- render_url-1.0.0.dist-info/METADATA +189 -0
- render_url-1.0.0.dist-info/RECORD +8 -0
- render_url-1.0.0.dist-info/WHEEL +5 -0
- render_url-1.0.0.dist-info/entry_points.txt +4 -0
- render_url-1.0.0.dist-info/top_level.txt +3 -0
- render_url.py +322 -0
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,,
|
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()
|