119 lines
4.0 KiB
Python
119 lines
4.0 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
dump-rendered.py — Open a URL (typically the local dev server) and dump its DOM
|
|
using the same __CLONE_DUMP_COMPUTED__ walker the capture pipeline uses for the
|
|
source. This produces a directly-comparable structural snapshot the validate
|
|
agent can diff against the captured original.
|
|
|
|
Usage:
|
|
dump-rendered.py --url http://localhost:3000/ --output workspace/rendered/1280-step-00.json \
|
|
--viewport 1280 --scroll-y 0
|
|
|
|
Dependencies: playwright.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
try:
|
|
from playwright.sync_api import sync_playwright, TimeoutError as PWTimeout
|
|
except ImportError:
|
|
print(json.dumps({"error": "Missing dependencies. pip install playwright && playwright install chromium"}), file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def load_init_hooks(hooks_dir: Path) -> list[str]:
|
|
sources: list[str] = []
|
|
# We only need computed-styles + section-scan for rendered-DOM purposes.
|
|
for name in ("bootstrap.js", "computed-styles.js", "section-scan.js"):
|
|
p = hooks_dir / name
|
|
if p.exists():
|
|
sources.append(p.read_text())
|
|
return sources
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--url", required=True)
|
|
parser.add_argument("--output", required=True, help="Path to write the rendered DOM JSON")
|
|
parser.add_argument("--viewport", type=int, default=1280)
|
|
parser.add_argument("--viewport-height", type=int, default=None, help="Default: 16:9 of viewport width")
|
|
parser.add_argument("--scroll-y", type=int, default=0)
|
|
parser.add_argument("--reduce-motion", action="store_true")
|
|
parser.add_argument("--settle-ms", type=int, default=800)
|
|
args = parser.parse_args()
|
|
|
|
output = Path(args.output).resolve()
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
height = args.viewport_height or round(args.viewport * 9 / 16)
|
|
script_dir = Path(__file__).resolve().parent
|
|
init_sources = load_init_hooks(script_dir / "init-hooks")
|
|
|
|
with sync_playwright() as p:
|
|
browser = p.chromium.launch(headless=True, args=["--disable-blink-features=AutomationControlled"])
|
|
ctx = browser.new_context(viewport={"width": args.viewport, "height": height})
|
|
for src in init_sources:
|
|
ctx.add_init_script(src)
|
|
page = ctx.new_page()
|
|
|
|
try:
|
|
page.goto(args.url, wait_until="networkidle", timeout=30_000)
|
|
except PWTimeout:
|
|
page.goto(args.url, wait_until="domcontentloaded", timeout=30_000)
|
|
|
|
if args.reduce_motion:
|
|
try:
|
|
page.evaluate("document.documentElement.classList.add('reduce-motion')")
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
page.evaluate("() => document.fonts.ready")
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
page.wait_for_function("window.__CLONE_READY__ === true", timeout=5_000)
|
|
except Exception:
|
|
pass
|
|
|
|
if args.scroll_y:
|
|
page.evaluate(f"window.scrollTo({{ top: {args.scroll_y}, behavior: 'instant' }})")
|
|
|
|
time.sleep(args.settle_ms / 1000.0)
|
|
|
|
snapshot = page.evaluate(
|
|
"() => (typeof window.__CLONE_DUMP_COMPUTED__ === 'function') ? window.__CLONE_DUMP_COMPUTED__() : null"
|
|
)
|
|
sections = page.evaluate(
|
|
"() => (typeof window.__CLONE_LIST_SECTIONS__ === 'function') ? window.__CLONE_LIST_SECTIONS__() : []"
|
|
) or []
|
|
|
|
if snapshot is None:
|
|
raise SystemExit("dump-rendered: __CLONE_DUMP_COMPUTED__ unavailable — init hooks failed to load")
|
|
|
|
output.write_text(json.dumps(snapshot, separators=(",", ":")))
|
|
sections_path = output.with_name(output.stem + "-sections.json")
|
|
sections_path.write_text(json.dumps(sections, indent=2))
|
|
|
|
page.close()
|
|
ctx.close()
|
|
browser.close()
|
|
|
|
print(json.dumps({
|
|
"status": "ok",
|
|
"output": str(output),
|
|
"sections_path": str(sections_path),
|
|
"section_count": len(sections),
|
|
}))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|