Initial commit

This commit is contained in:
Samraaj Bath
2026-06-29 15:11:48 -07:00
commit 66dfdcc58d
404 changed files with 45970 additions and 0 deletions
+913
View File
@@ -0,0 +1,913 @@
#!/usr/bin/env python3
"""
capture.py — Playwright-based site capture for the clone pipeline.
Produces a full capture bundle at --output. Layout is documented in
.opencode/skills/site-manifest/SKILL.md and consumed by the analyze + generate
agents.
Pipeline:
Stage 0: Per-viewport scroll-loop capture — DOM + computed styles, screenshots,
HAR, animation library dumps, fonts, css-vars, css-rules.
Stage 1: Alt-height capture at the canonical desktop width (1280) for
vh-relative detection.
Stage 2: Per-section cropped screenshot pass — scroll each candidate section
into view and crop to its bbox.
Stage 3: Post-process — derive vh-relative flags, merge HAR + CSS asset URLs,
write meta.json.
Replay mode: --replay skips stages 0-2 and only re-runs stage 3 against existing
capture data. Use this when iterating on prompts/agents/skills without paying the
~3-minute capture cost again.
Dependencies: playwright (pip install playwright && playwright install chromium)
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import sys
import time
from pathlib import Path
from urllib.parse import unquote, urlparse
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)
THIRD_PARTY_HOST_RE = re.compile(
r"(intercom\.io|driftt\.com|drift\.com|typeform\.com|calendly\.com|hsforms\.(net|com)|"
r"cookielaw\.org|cookiebot\.com|osano\.com|googletagmanager\.com|google-analytics\.com|"
r"segment\.(io|com)|hotjar\.com|fullstory\.com)"
)
# Viewport that the analyze agent treats as canonical desktop. We capture a second
# DOM dump at this width but a different height to detect vh-relative dimensions.
CANONICAL_WIDTH = 1280
CANONICAL_HEIGHT_PRIMARY = 720
CANONICAL_HEIGHT_ALT = 1080
def sha1_8(s: str) -> str:
return hashlib.sha1(s.encode("utf-8")).hexdigest()[:8]
def sanitize(name: str) -> str:
name = name.lower()
name = re.sub(r"\s+", "-", name)
name = re.sub(r"[^a-z0-9.\-]", "", name)
return name[:80] or "asset"
def load_init_hooks(hooks_dir: Path) -> list[str]:
sources: list[str] = []
for path in sorted(hooks_dir.glob("*.js")):
sources.append(path.read_text())
return sources
def asset_type_from_url(url: str, content_type: str | None) -> str | None:
u = url.lower().split("?", 1)[0]
ct = (content_type or "").lower()
# SVG must be checked before generic image/* — content-type "image/svg+xml" matches both.
if u.endswith(".svg") or ct == "image/svg+xml":
return "svg"
if any(u.endswith(ext) for ext in (".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif")) or ct.startswith("image/"):
return "image"
if any(u.endswith(ext) for ext in (".mp4", ".mov", ".webm")) or ct.startswith("video/"):
return "video"
if any(u.endswith(ext) for ext in (".woff2", ".woff", ".ttf", ".otf")) or ct in (
"font/woff2", "font/woff", "font/ttf", "application/font-woff2", "application/font-woff", "application/x-font-ttf"
):
return "font"
if u.endswith(".json") and ("lottie" in u or "animations" in u):
return "lottie"
return None
def asset_type_from_url_only(url: str) -> str | None:
return asset_type_from_url(url, None)
def viewport_height_for(width: int) -> int:
"""Default capture height — 16:9 ratio, but rounded sensibly."""
return round(width * 9 / 16)
# ---------------------------------------------------------------------------
# Browser helpers
# ---------------------------------------------------------------------------
def make_context(browser, viewport_w: int, viewport_h: int, har_path: Path | None, init_sources: list[str], skip_third_party: bool):
ctx_kwargs = {"viewport": {"width": viewport_w, "height": viewport_h}}
if har_path is not None:
ctx_kwargs["record_har_path"] = str(har_path)
ctx = browser.new_context(**ctx_kwargs)
if skip_third_party:
def route_handler(route):
if THIRD_PARTY_HOST_RE.search(route.request.url):
route.abort()
else:
route.continue_()
ctx.route("**/*", route_handler)
for src in init_sources:
ctx.add_init_script(src)
return ctx
def goto_and_settle(page, url: str, wait_strategy: str) -> None:
try:
page.goto(url, wait_until=wait_strategy, timeout=60_000)
except PWTimeout:
page.goto(url, wait_until="domcontentloaded", timeout=60_000)
try:
page.evaluate("() => document.fonts.ready")
except Exception:
pass
try:
page.wait_for_function("window.__CLONE_READY__ === true", timeout=10_000)
except Exception:
pass
def dump_dom(page) -> dict:
"""Use the rich __CLONE_DUMP_COMPUTED__ walker (which filters per-tag defaults
and captures pseudo-elements). Falls back to a minimal walk if the hook
didn't load."""
try:
out = page.evaluate("() => (typeof window.__CLONE_DUMP_COMPUTED__ === 'function') ? window.__CLONE_DUMP_COMPUTED__() : null")
if out is not None:
return out
except Exception:
pass
return page.evaluate(
"""() => {
const serialize = (el) => {
if (el.nodeType === Node.TEXT_NODE) {
const t = el.textContent;
return t && t.trim() ? { text: t } : null;
}
if (el.nodeType !== Node.ELEMENT_NODE) return null;
const cs = getComputedStyle(el);
const computed = {};
for (const p of ['color','backgroundColor','backgroundImage','fontFamily','fontSize','fontWeight','lineHeight','display','width','height','padding','margin','position']) {
const v = cs[p];
if (v && v !== 'normal' && v !== 'none' && v !== 'auto' && v !== '0px' && v !== 'rgba(0, 0, 0, 0)') computed[p] = v;
}
const attrs = {};
for (const a of el.attributes) attrs[a.name] = a.value;
const r = el.getBoundingClientRect();
const children = [];
for (const c of el.childNodes) { const s = serialize(c); if (s) children.push(s); }
return {
tag: el.tagName.toLowerCase(),
attrs,
computed,
bbox: { x: r.x, y: r.y + window.scrollY, width: r.width, height: r.height },
children,
};
};
return serialize(document.body);
}"""
)
def list_sections(page) -> list[dict]:
try:
return page.evaluate("() => (typeof window.__CLONE_LIST_SECTIONS__ === 'function') ? window.__CLONE_LIST_SECTIONS__() : []") or []
except Exception:
return []
def smart_scroll_targets(sections: list[dict], page_height: int, viewport_h: int) -> list[int]:
"""Compute scroll y positions to visit. We always include 0 (top), then
each section's y minus a small offset, deduped. Also fill any gap larger
than 1.5x viewport_h with intermediate stops so we don't skip content
between sections.
"""
raw = [0]
for sec in sections:
y = max(int(sec.get("y") or 0) - 80, 0)
raw.append(y)
raw.append(max(page_height - viewport_h, 0))
# Sort + dedupe + fill gaps
raw = sorted(set(raw))
filled: list[int] = []
for i, y in enumerate(raw):
if filled and y - filled[-1] > int(viewport_h * 1.5):
mid = filled[-1] + viewport_h
while mid < y - viewport_h:
filled.append(mid)
mid += viewport_h
filled.append(y)
# Cap to avoid pathological cases
return filled[:50]
def settle(page, max_ms: int = 1500) -> None:
"""Wait for the layout to settle: networkidle attempt + a short fixed delay
+ waitForFunction on document.fonts.ready. If a MutationObserver fires
rapidly, we yield more time up to max_ms."""
deadline = time.monotonic() + (max_ms / 1000.0)
try:
page.wait_for_load_state("networkidle", timeout=max(int(max_ms / 2), 500))
except Exception:
pass
remaining = deadline - time.monotonic()
if remaining > 0:
# Watch for mutation activity; if quiet for 250ms, return
try:
page.evaluate(
"""async (maxMs) => {
return await new Promise((resolve) => {
let lastMutation = performance.now();
const start = lastMutation;
const obs = new MutationObserver(() => { lastMutation = performance.now(); });
obs.observe(document.body, { subtree: true, childList: true, attributes: true });
const tick = () => {
const now = performance.now();
if (now - lastMutation > 250 || now - start > maxMs) { obs.disconnect(); resolve(true); }
else requestAnimationFrame(tick);
};
tick();
});
}""",
int(remaining * 1000),
)
except Exception:
time.sleep(min(remaining, 0.5))
# ---------------------------------------------------------------------------
# Stage 0 — per-viewport scroll-loop capture
# ---------------------------------------------------------------------------
def capture_viewport(
browser,
viewport_width: int,
url: str,
wait_strategy: str,
init_sources: list[str],
output_dir: Path,
skip_third_party: bool,
) -> dict:
viewport_height = viewport_height_for(viewport_width)
vp_dir = output_dir / "screenshots" / str(viewport_width)
dom_dir = output_dir / "dom" / str(viewport_width)
har_dir = output_dir / "har"
for d in (vp_dir, dom_dir, har_dir):
d.mkdir(parents=True, exist_ok=True)
har_path = har_dir / f"{viewport_width}.har"
page_context = make_context(browser, viewport_width, viewport_height, har_path, init_sources, skip_third_party)
page = page_context.new_page()
discovered_assets: dict[str, dict] = {}
def on_response(response):
try:
url_ = response.url
content_type = response.headers.get("content-type")
t = asset_type_from_url(url_, content_type)
if t is None:
return
if url_ in discovered_assets:
return
discovered_assets[url_] = {
"type": t,
"source_url": url_,
"content_type": content_type,
"status": response.status,
}
except Exception:
pass
page.on("response", on_response)
goto_and_settle(page, url, wait_strategy)
sections = list_sections(page)
page_height = page.evaluate("() => document.documentElement.scrollHeight") or 0
targets = smart_scroll_targets(sections, page_height, viewport_height)
for step, y in enumerate(targets):
page.evaluate(f"window.scrollTo({{ top: {y}, behavior: 'instant' }})")
settle(page, max_ms=1200)
page.screenshot(path=str(vp_dir / f"step-{step:02d}.png"), full_page=False)
snapshot = dump_dom(page)
(dom_dir / f"step-{step:02d}.json").write_text(json.dumps(snapshot, separators=(",", ":")))
# Pull animation/css-rules hook output
hook_output = page.evaluate("() => window.__CLONE_CAPTURE__ || {}") or {}
for kind in ("shaders", "gsap", "framer", "lottie", "threejs"):
d = output_dir / kind
d.mkdir(parents=True, exist_ok=True)
(d / f"{viewport_width}.json").write_text(json.dumps(hook_output.get(kind, []), indent=2))
for kind, dirname in (("cssVars", "css-vars"), ("fonts", "fonts"), ("cssRules", "css-rules")):
d = output_dir / dirname
d.mkdir(parents=True, exist_ok=True)
(d / f"{viewport_width}.json").write_text(json.dumps(hook_output.get(kind, {}), indent=2))
# Dump candidate sections per viewport (analyze agent uses these)
sec_dir = output_dir / "sections"
sec_dir.mkdir(parents=True, exist_ok=True)
(sec_dir / f"{viewport_width}.json").write_text(json.dumps(sections, indent=2))
# Hover capture
try:
hover_rects = page.evaluate(
"""() => {
const els = document.querySelectorAll('a, button, [role="button"], [data-cta]');
const out = [];
for (const el of els) {
const r = el.getBoundingClientRect();
if (r.width < 40 || r.height < 20) continue;
out.push({ x: r.x + r.width/2, y: r.y + r.height/2, tag: el.tagName });
if (out.length >= 10) break;
}
return out;
}"""
)
hover_dir = output_dir / "screenshots" / str(viewport_width) / "hover"
hover_dir.mkdir(parents=True, exist_ok=True)
for idx, pos in enumerate(hover_rects):
try:
page.mouse.move(pos["x"], pos["y"])
time.sleep(0.2)
page.screenshot(path=str(hover_dir / f"hover-{idx:02d}.png"), full_page=False)
except Exception:
continue
except Exception:
pass
page.close()
page_context.close()
return {
"viewport": viewport_width,
"height": viewport_height,
"steps": len(targets),
"section_candidates": len(sections),
"hook_output_keys": [k for k in hook_output.keys()],
"har": str(har_path.relative_to(output_dir)),
"assets": discovered_assets,
}
# ---------------------------------------------------------------------------
# Stage 1 — alt-height capture at canonical width (vh detection)
# ---------------------------------------------------------------------------
def capture_alt_height(browser, url: str, wait_strategy: str, init_sources: list[str], output_dir: Path, skip_third_party: bool) -> dict:
"""Capture only DOM at scroll 0 at (CANONICAL_WIDTH x CANONICAL_HEIGHT_ALT).
The post-process compares this to the primary capture to flag vh-relative
dimensions."""
page_context = make_context(browser, CANONICAL_WIDTH, CANONICAL_HEIGHT_ALT, None, init_sources, skip_third_party)
page = page_context.new_page()
goto_and_settle(page, url, wait_strategy)
settle(page, max_ms=1200)
snapshot = dump_dom(page)
sections = list_sections(page)
out_dir = output_dir / "dom-alt" / f"{CANONICAL_WIDTH}-{CANONICAL_HEIGHT_ALT}"
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / "step-00.json").write_text(json.dumps(snapshot, separators=(",", ":")))
(out_dir / "sections.json").write_text(json.dumps(sections, indent=2))
page.close()
page_context.close()
return {"viewport": CANONICAL_WIDTH, "alt_height": CANONICAL_HEIGHT_ALT, "section_candidates": len(sections)}
# ---------------------------------------------------------------------------
# Stage 2 — per-section cropped screenshots
# ---------------------------------------------------------------------------
def capture_section_screenshots(browser, url: str, wait_strategy: str, init_sources: list[str], output_dir: Path, skip_third_party: bool, viewport_widths: list[int]) -> int:
"""For each viewport width, scroll each candidate section into view and
save a screenshot cropped to its bbox. The analyze agent maps these to
section ids; the validate agent uses them as the diff reference."""
total = 0
for vw in viewport_widths:
sections_path = output_dir / "sections" / f"{vw}.json"
if not sections_path.exists():
continue
sections = json.loads(sections_path.read_text())
if not sections:
continue
vh = viewport_height_for(vw)
page_context = make_context(browser, vw, vh, None, init_sources, skip_third_party)
page = page_context.new_page()
goto_and_settle(page, url, wait_strategy)
out_dir = output_dir / "section-shots" / str(vw)
out_dir.mkdir(parents=True, exist_ok=True)
for idx, sec in enumerate(sections):
try:
# Scroll so the section's top is ~80px below the viewport top
target_y = max(int(sec.get("y") or 0) - 80, 0)
page.evaluate(f"window.scrollTo({{ top: {target_y}, behavior: 'instant' }})")
settle(page, max_ms=1000)
clip = {
"x": max(int(sec.get("x") or 0), 0),
"y": max(int(sec.get("y") or 0) - target_y, 0),
"width": min(int(sec.get("width") or vw), vw),
"height": min(int(sec.get("height") or vh), vh * 4),
}
# Playwright clip cannot exceed the page viewport vertically; if the section is
# taller than the viewport, capture as-is and analyze handles the partial.
clip["height"] = min(clip["height"], vh)
page.screenshot(path=str(out_dir / f"section-{idx:02d}.png"), clip=clip)
total += 1
except Exception:
continue
page.close()
page_context.close()
return total
# ---------------------------------------------------------------------------
# Stage 3 — post-process: build assets list, derive vh flags, write meta.json
# ---------------------------------------------------------------------------
def merge_css_assets(output_dir: Path, har_assets: dict[str, dict]) -> dict[str, dict]:
"""Walk css-rules/<vp>.json across viewports, append every url() reference
not already in har_assets. Type is inferred from the URL alone since CSS
references don't carry content-type."""
merged = dict(har_assets)
css_rules_dir = output_dir / "css-rules"
if css_rules_dir.exists():
for f in css_rules_dir.glob("*.json"):
try:
data = json.loads(f.read_text())
except Exception:
continue
for asset in data.get("assets", []) or []:
src = asset.get("source_url")
if not src or src in merged:
continue
t = asset_type_from_url_only(src)
if t is None:
continue
merged[src] = {
"type": t,
"source_url": src,
"content_type": None,
"status": None,
"from_css": True,
}
return merged
def build_assets_list(all_assets: dict[str, dict]) -> list[dict]:
out = []
for url, a in all_assets.items():
name = sanitize(unquote(os.path.basename(urlparse(url).path)) or "asset")
local_path = f"public/assets/cloned/{a['type']}s/{sha1_8(url)}-{name}"
entry = {
"type": a["type"],
"source_url": url,
"local_path": local_path,
}
if a.get("from_css"):
entry["from_css"] = True
out.append(entry)
return out
def detect_vh_flags(output_dir: Path) -> list[dict]:
"""Compare DOM at (1280x720) vs (1280x1080). For each element matchable by
structural path, if `h_alt / h_primary ≈ alt_height / primary_height`, flag
it as vh-relative and record the implied vh percentage.
Output: a flat list of { path, primary_h, alt_h, vh } entries.
"""
primary = output_dir / "dom" / str(CANONICAL_WIDTH) / "step-00.json"
alt = output_dir / "dom-alt" / f"{CANONICAL_WIDTH}-{CANONICAL_HEIGHT_ALT}" / "step-00.json"
if not primary.exists() or not alt.exists():
return []
try:
a = json.loads(primary.read_text())
b = json.loads(alt.read_text())
except Exception:
return []
expected_ratio = CANONICAL_HEIGHT_ALT / CANONICAL_HEIGHT_PRIMARY # 1.5
tolerance = 0.06 # 6% — accounts for content-driven elements that happen to be near vh
flags: list[dict] = []
def walk(na, nb, path):
if not isinstance(na, dict) or not isinstance(nb, dict):
return
if na.get("tag") != nb.get("tag"):
return
ha = (na.get("bbox") or {}).get("height") or 0
hb = (nb.get("bbox") or {}).get("height") or 0
if ha > 40 and hb > 40:
ratio = hb / ha
if abs(ratio - expected_ratio) / expected_ratio < tolerance:
vh_pct = round((ha / CANONICAL_HEIGHT_PRIMARY) * 100)
flags.append({
"path": path,
"tag": na.get("tag"),
"id": (na.get("attrs") or {}).get("id") or None,
"class": (na.get("attrs") or {}).get("class") or None,
"primary_h": round(ha, 2),
"alt_h": round(hb, 2),
"ratio": round(ratio, 3),
"vh": vh_pct,
})
ca = na.get("children") or []
cb = nb.get("children") or []
# Only walk matched element children; pair by tag-aware index
ai = bi = 0
idx = 0
while ai < len(ca) and bi < len(cb):
ea = ca[ai]
eb = cb[bi]
if not isinstance(ea, dict) or not isinstance(eb, dict) or ea.get("tag") != eb.get("tag"):
# Skip text-only / mismatched
if not isinstance(ea, dict) or "tag" not in ea:
ai += 1
continue
if not isinstance(eb, dict) or "tag" not in eb:
bi += 1
continue
# Different tags at same index — skip both
ai += 1
bi += 1
continue
walk(ea, eb, f"{path}>{ea.get('tag')}[{idx}]")
ai += 1
bi += 1
idx += 1
walk(a, b, a.get("tag", "body"))
return flags
SECTION_LIKE_TAGS = {"section", "header", "footer", "nav", "main", "article", "aside"}
SECTION_CLASS_RE = re.compile(r"(section|hero|banner|elementor-element|e-parent|e-con|footer|header|nav|main)", re.I)
SECTION_ID_RE = re.compile(r"(hero|banner|section|footer|nav|header|main)", re.I)
def _is_section_like(node: dict) -> bool:
tag = (node.get("tag") or "").lower()
if tag in SECTION_LIKE_TAGS:
return True
attrs = node.get("attrs") or {}
eid = attrs.get("id") or ""
if eid and SECTION_ID_RE.search(eid):
return True
cls = attrs.get("class") or ""
if cls and SECTION_CLASS_RE.search(cls):
return True
return False
def _is_visible(node: dict) -> bool:
cs = node.get("computed") or {}
if cs.get("display") == "none" or cs.get("visibility") == "hidden":
return False
op = cs.get("opacity")
if op is not None and op != "":
try:
if float(op) == 0:
return False
except (TypeError, ValueError):
pass
return True
def _build_selector(node: dict) -> str:
attrs = node.get("attrs") or {}
if attrs.get("id"):
return f"#{attrs['id']}"
cls = (attrs.get("class") or "").strip().split()
if cls:
# First class is usually the most distinctive (Elementor style)
return f"{node.get('tag')}.{cls[0]}"
return node.get("tag") or "?"
def _walk_section_candidates(node: dict, vw: int, path: str, idx: int, out: list[dict]) -> None:
if not isinstance(node, dict) or "tag" not in node:
return
cur_path = f"{path}>{node.get('tag')}[{idx}]" if path else (node.get("tag") or "?")
bbox = node.get("bbox") or {}
w = bbox.get("width") or 0
h = bbox.get("height") or 0
if _is_visible(node) and _is_section_like(node) and w >= vw * 0.5 and h >= 80:
attrs = node.get("attrs") or {}
out.append({
"_path": cur_path,
"_node": node,
"selector": _build_selector(node),
"tag": node.get("tag"),
"id": attrs.get("id") or None,
"className": (attrs.get("class") or "")[:200] or None,
"x": round(bbox.get("x") or 0, 1),
"y": round(bbox.get("y") or 0, 1),
"width": round(w, 1),
"height": round(h, 1),
})
for i, c in enumerate(node.get("children") or []):
_walk_section_candidates(c, vw, cur_path, i, out)
def _outermost_wins(candidates: list[dict]) -> list[dict]:
# Sort by path depth — shallowest first; only keep candidates that are not
# descendants of an already-kept candidate.
by_depth = sorted(candidates, key=lambda c: c["_path"].count(">"))
kept: list[dict] = []
for c in by_depth:
if any(c["_path"].startswith(k["_path"] + ">") for k in kept):
continue
kept.append(c)
return kept
def _expand_oversized(candidates: list[dict], vw: int, vh: int, doc_h: float) -> list[dict]:
"""For wrapper-shaped candidates (>2x viewport_h with >=3 inner sections),
replace the wrapper with its section-like children. Without this step,
Elementor / WP layouts where everything is wrapped in a single <main id="main">
or <div class="elementor-section-wrap"> collapse to a single huge candidate
and the smart-scroll loop never visits the actual sections."""
threshold = max(vh * 2, doc_h * 0.5)
expanded: list[dict] = []
for c in candidates:
if c["height"] < threshold:
expanded.append(c)
continue
inner: list[dict] = []
for i, child in enumerate(c["_node"].get("children") or []):
_walk_section_candidates(child, vw, c["_path"], i, inner)
inner = _outermost_wins(inner)
# Recurse: an inner section may itself be an oversized wrapper.
inner = _expand_oversized(inner, vw, vh, doc_h)
if len(inner) >= 3:
expanded.extend(inner)
else:
expanded.append(c)
return expanded
def enrich_sections(output_dir: Path) -> int:
"""Re-derive sections/<vp>.json from the captured dom/<vp>/step-00.json so
the JS section-scan's outermost-wins rule is corrected for wrapper-shaped
layouts. Idempotent — safe to run in both fresh-capture and replay paths.
Returns the total candidate count across viewports."""
dom_root = output_dir / "dom"
sec_root = output_dir / "sections"
if not dom_root.exists():
return 0
sec_root.mkdir(parents=True, exist_ok=True)
total = 0
for vp_dir in sorted(dom_root.iterdir()):
if not vp_dir.is_dir() or not vp_dir.name.isdigit():
continue
vp = int(vp_dir.name)
step0 = vp_dir / "step-00.json"
if not step0.exists():
continue
try:
root = json.loads(step0.read_text())
except Exception:
continue
doc_h = (root.get("bbox") or {}).get("height") or 0
vh = viewport_height_for(vp)
candidates: list[dict] = []
_walk_section_candidates(root, vp, "", 0, candidates)
kept = _outermost_wins(candidates)
expanded = _expand_oversized(kept, vp, vh, doc_h)
# Strip private fields, sort by y
public = [{k: v for k, v in c.items() if not k.startswith("_")} for c in expanded]
public = [s for s in public if s.get("height", 0) > 80]
public.sort(key=lambda s: s.get("y") or 0)
(sec_root / f"{vp}.json").write_text(json.dumps(public, indent=2))
total += len(public)
return total
def detect_libs(output_dir: Path) -> list[str]:
libs_detected = set()
for kind in ("gsap", "framer", "lottie", "three", "shaders"):
dirname = "threejs" if kind == "three" else kind
d = output_dir / dirname
if not d.exists():
continue
for f in d.glob("*.json"):
try:
data = json.loads(f.read_text())
if data:
libs_detected.add(kind)
break
except Exception:
continue
return sorted(libs_detected)
def collect_har_assets_from_disk(output_dir: Path) -> dict[str, dict]:
"""Fallback for replay mode — read each har/<vp>.har and extract response URLs."""
har_dir = output_dir / "har"
out: dict[str, dict] = {}
if not har_dir.exists():
return out
for f in har_dir.glob("*.har"):
try:
har = json.loads(f.read_text())
except Exception:
continue
for entry in (har.get("log") or {}).get("entries", []):
req = entry.get("request") or {}
res = entry.get("response") or {}
url = req.get("url")
if not url:
continue
ct = None
for h in (res.get("headers") or []):
if (h.get("name") or "").lower() == "content-type":
ct = h.get("value")
break
t = asset_type_from_url(url, ct)
if t and url not in out:
out[url] = {"type": t, "source_url": url, "content_type": ct, "status": res.get("status")}
return out
def write_meta(output_dir: Path, source_url: str, viewports: list[int], per_viewport_summary: list[dict], all_assets: dict[str, dict]) -> Path:
merged = merge_css_assets(output_dir, all_assets)
assets_list = build_assets_list(merged)
vh_flags = detect_vh_flags(output_dir)
# Emit vh-flags.json at the workspace level (parent of capture/) so it lives next to manifest.json.
if vh_flags:
workspace_root = output_dir.parent
(workspace_root / "vh-flags.json").write_text(json.dumps(vh_flags, indent=2))
# Re-derive sections/<vp>.json from the DOM dumps. Replaces the JS section-scan
# output with a Python pass that recurses into oversized wrappers (the JS hook
# also has the recursion now, but Python being authoritative makes the fix
# available in --replay mode against existing capture data).
enriched_section_total = enrich_sections(output_dir)
meta = {
"source_url": source_url,
"captured_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"viewports": viewports,
"canonical_viewport": {"width": CANONICAL_WIDTH, "height": CANONICAL_HEIGHT_PRIMARY, "alt_height": CANONICAL_HEIGHT_ALT},
"per_viewport": per_viewport_summary,
"assets": assets_list,
"asset_sources": {
"har": sum(1 for a in assets_list if not a.get("from_css")),
"css": sum(1 for a in assets_list if a.get("from_css")),
},
"libs_detected": detect_libs(output_dir),
"dom_snapshots": sum(s.get("steps", 0) for s in per_viewport_summary),
"screenshots": sum(s.get("steps", 0) for s in per_viewport_summary),
"vh_relative_count": len(vh_flags),
"section_shots": len(list((output_dir / "section-shots").rglob("*.png"))) if (output_dir / "section-shots").exists() else 0,
"section_candidates_total": enriched_section_total,
"canvas_regions": 0,
}
out_path = output_dir / "meta.json"
out_path.write_text(json.dumps(meta, indent=2))
return out_path
def replay(output_dir: Path, source_url: str | None) -> Path:
"""Replay mode: re-derive meta.json + vh-flags.json from existing capture data.
Useful for iterating on the analyze/generate/validate agents without re-running
the (slow) browser pipeline."""
if not output_dir.exists():
raise SystemExit(f"replay: output dir does not exist: {output_dir}")
# Try existing meta.json for source_url + viewports
existing_meta_path = output_dir / "meta.json"
existing_meta = {}
if existing_meta_path.exists():
try:
existing_meta = json.loads(existing_meta_path.read_text())
except Exception:
existing_meta = {}
if not source_url:
source_url = existing_meta.get("source_url") or "unknown://replay"
# Infer viewports from existing dom/<vp>/ folders
dom_root = output_dir / "dom"
viewports: list[int] = []
if dom_root.exists():
for child in dom_root.iterdir():
if child.is_dir() and child.name.isdigit():
viewports.append(int(child.name))
viewports.sort()
per_viewport_summary: list[dict] = []
for vp in viewports:
steps = len(list((dom_root / str(vp)).glob("step-*.json")))
sections_path = output_dir / "sections" / f"{vp}.json"
section_count = 0
if sections_path.exists():
try:
section_count = len(json.loads(sections_path.read_text()))
except Exception:
pass
per_viewport_summary.append({
"viewport": vp,
"height": viewport_height_for(vp),
"steps": steps,
"section_candidates": section_count,
"har": f"har/{vp}.har" if (output_dir / "har" / f"{vp}.har").exists() else None,
})
har_assets = collect_har_assets_from_disk(output_dir)
return write_meta(output_dir, source_url, viewports, per_viewport_summary, har_assets)
# ---------------------------------------------------------------------------
# main
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--url", required=False, help="Required unless --replay is set")
parser.add_argument("--viewports", required=False, default="375,768,1280,1920", help="csv of widths")
parser.add_argument("--output", required=True)
parser.add_argument("--wait-strategy", default="networkidle")
parser.add_argument("--scroll-step", type=int, default=900, help="(legacy; ignored — scroll is now section-driven)")
parser.add_argument("--skip-third-party", action="store_true")
parser.add_argument("--replay", action="store_true", help="Skip browser capture; only re-run post-process")
parser.add_argument("--skip-alt-height", action="store_true", help="Skip the vh-detection alt-height pass")
parser.add_argument("--skip-section-shots", action="store_true", help="Skip the per-section cropped screenshot pass")
args = parser.parse_args()
output_dir = Path(args.output).resolve()
output_dir.mkdir(parents=True, exist_ok=True)
if args.replay:
meta_path = replay(output_dir, args.url)
print(json.dumps({"status": "ok", "mode": "replay", "meta": str(meta_path)}))
return
if not args.url:
raise SystemExit("--url is required (unless --replay)")
script_dir = Path(__file__).resolve().parent
init_sources = load_init_hooks(script_dir / "init-hooks")
viewports = [int(v.strip()) for v in args.viewports.split(",") if v.strip()]
all_assets: dict[str, dict] = {}
per_viewport_summary: list[dict] = []
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=["--disable-blink-features=AutomationControlled"])
# Stage 0
for vp in viewports:
summary = capture_viewport(browser, vp, args.url, args.wait_strategy, init_sources, output_dir, args.skip_third_party)
for url, a in summary.pop("assets", {}).items():
if url not in all_assets:
all_assets[url] = a
per_viewport_summary.append(summary)
# Stage 1
if not args.skip_alt_height and CANONICAL_WIDTH in viewports:
try:
alt_summary = capture_alt_height(browser, args.url, args.wait_strategy, init_sources, output_dir, args.skip_third_party)
per_viewport_summary.append({"alt_pass": True, **alt_summary})
except Exception as e:
print(json.dumps({"warning": f"alt-height capture failed: {e}"}), file=sys.stderr)
# Stage 2
if not args.skip_section_shots:
try:
shots = capture_section_screenshots(browser, args.url, args.wait_strategy, init_sources, output_dir, args.skip_third_party, viewports)
per_viewport_summary.append({"section_shots_total": shots})
except Exception as e:
print(json.dumps({"warning": f"section-shots pass failed: {e}"}), file=sys.stderr)
browser.close()
# Stage 3
meta_path = write_meta(output_dir, args.url, viewports, per_viewport_summary, all_assets)
print(json.dumps({"status": "ok", "mode": "capture", "meta": str(meta_path)}))
if __name__ == "__main__":
main()
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""
diff.py — Pixel diff two PNGs, producing a highlighted diff image and stats.
Uses the `pixelmatch` Python port (pip install pixelmatch pillow). Returns JSON to stdout:
{ diff_pct, diff_image_path, worst_regions: [ { x, y, width, height } ] }
Worst regions are computed via connected-component analysis on the diff mask, sorted by area desc.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
try:
from PIL import Image
from pixelmatch.contrib.PIL import pixelmatch
except ImportError:
print(json.dumps({"error": "Missing dependencies. pip install pixelmatch pillow numpy"}), file=sys.stderr)
sys.exit(1)
try:
import numpy as np
from scipy import ndimage
HAVE_SCIPY = True
except ImportError:
HAVE_SCIPY = False
def resize_to_match(a: Image.Image, b: Image.Image) -> tuple[Image.Image, Image.Image]:
if a.size == b.size:
return a, b
target = (min(a.size[0], b.size[0]), min(a.size[1], b.size[1]))
return a.resize(target), b.resize(target)
def worst_regions_from_diff(diff_img: Image.Image, top_n: int = 5) -> list[dict]:
if not HAVE_SCIPY:
return []
arr = np.array(diff_img)
if arr.ndim == 3:
mask = (arr[..., :3].sum(axis=-1) > 0).astype(np.uint8)
else:
mask = (arr > 0).astype(np.uint8)
labels, n = ndimage.label(mask)
if n == 0:
return []
regions = []
for i in range(1, n + 1):
ys, xs = np.where(labels == i)
if len(xs) < 20:
continue
regions.append({
"x": int(xs.min()),
"y": int(ys.min()),
"width": int(xs.max() - xs.min() + 1),
"height": int(ys.max() - ys.min() + 1),
"area": int(len(xs)),
})
regions.sort(key=lambda r: r["area"], reverse=True)
out = []
for r in regions[:top_n]:
r.pop("area", None)
out.append(r)
return out
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--before", required=True)
parser.add_argument("--after", required=True)
parser.add_argument("--diff-out", required=True)
parser.add_argument("--threshold", type=float, default=0.1)
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
a = Image.open(args.before).convert("RGBA")
b = Image.open(args.after).convert("RGBA")
a, b = resize_to_match(a, b)
diff = Image.new("RGBA", a.size, (0, 0, 0, 0))
mismatched = pixelmatch(a, b, diff, threshold=args.threshold, includeAA=False)
total = a.size[0] * a.size[1]
diff_pct = (mismatched / total) * 100.0 if total else 0.0
Path(args.diff_out).parent.mkdir(parents=True, exist_ok=True)
diff.save(args.diff_out)
worst = worst_regions_from_diff(diff)
result = {
"diff_pct": round(diff_pct, 3),
"diff_image_path": args.diff_out,
"worst_regions": worst,
"size": {"width": a.size[0], "height": a.size[1]},
}
if args.json:
print(json.dumps(result))
else:
print(f"diff_pct={result['diff_pct']}% diff_image={result['diff_image_path']} regions={len(worst)}")
if __name__ == "__main__":
main()
+342
View File
@@ -0,0 +1,342 @@
#!/usr/bin/env python3
"""
dom-diff.py — Structural diff between two DOM JSON snapshots produced by the
__CLONE_DUMP_COMPUTED__ walker. Used by the validate agent to surface concrete,
actionable issues to the generate agent (font sizes, missing elements, wrong
dimensions) instead of relying solely on PNG pixel diffs.
Inputs: --captured <captured DOM JSON>, --rendered <rendered DOM JSON>.
Optionally --root-selector to scope the comparison to a specific subtree
(typically a section's id or class), and --max-depth to cap recursion.
Output (JSON to stdout): {
matched: <int>,
missing_in_rendered: [{ path, tag, id, class, expected_h, expected_w }],
extra_in_rendered: [{ path, tag, id, class }],
style_mismatches: [{ path, property, expected, actual, severity }],
size_mismatches: [{ path, expected, actual, delta_pct }],
issues: [string, string, ...] // top human-readable issues
}
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
# Properties we treat as load-bearing for visual fidelity. Reported in this
# order in the human-readable `issues` list.
COMPARE_STYLE_PROPS = [
"fontSize",
"fontFamily",
"fontWeight",
"lineHeight",
"color",
"backgroundColor",
"backgroundImage",
"padding",
"margin",
"borderRadius",
"boxShadow",
"display",
"flexDirection",
"justifyContent",
"alignItems",
"gridTemplateColumns",
"gap",
"textAlign",
"letterSpacing",
"textTransform",
]
# How far apart two values can be before we flag them.
NUMERIC_TOLERANCE_PX = 2
SIZE_TOLERANCE_PCT = 5.0
def find_subtree(node: dict, selector: str | None) -> dict | None:
if not selector:
return node
target = selector.lstrip("#.")
is_id = selector.startswith("#")
is_class = selector.startswith(".")
def walk(n: dict) -> dict | None:
if not isinstance(n, dict):
return None
attrs = n.get("attrs") or {}
if is_id and attrs.get("id") == target:
return n
if is_class:
cls = attrs.get("class") or ""
if target in cls.split():
return n
if not is_id and not is_class:
# Tag selector
if n.get("tag") == target:
return n
for c in n.get("children") or []:
r = walk(c)
if r is not None:
return r
return None
return walk(node)
def parse_px(v: str | None) -> float | None:
if not v:
return None
m = re.match(r"^(-?\d+(?:\.\d+)?)px$", str(v).strip())
if m:
return float(m.group(1))
return None
def normalize_style_value(v: str | None) -> str:
if v is None:
return ""
s = str(v).strip()
s = re.sub(r"\s+", " ", s)
return s
def style_close(prop: str, expected: str | None, actual: str | None) -> bool:
if expected == actual:
return True
e_px = parse_px(expected)
a_px = parse_px(actual)
if e_px is not None and a_px is not None:
return abs(e_px - a_px) <= NUMERIC_TOLERANCE_PX
if normalize_style_value(expected) == normalize_style_value(actual):
return True
# backgroundImage: if both reference url(), only flag if the url paths differ
if prop == "backgroundImage" and expected and actual:
e_url = re.search(r"url\([^)]+\)", expected)
a_url = re.search(r"url\([^)]+\)", actual)
if e_url and a_url:
return False # both have urls but different — flag
return False
return False
def child_key(n: dict, idx: int) -> str:
if not isinstance(n, dict):
return f"text[{idx}]"
tag = n.get("tag") or "?"
attrs = n.get("attrs") or {}
if attrs.get("id"):
return f"{tag}#{attrs['id']}"
return f"{tag}[{idx}]"
def compare(
a: dict | None,
b: dict | None,
path: str,
issues: list[dict],
counters: dict,
max_depth: int,
depth: int = 0,
) -> None:
if depth > max_depth:
return
if a is None and b is None:
return
if a is None or not isinstance(a, dict):
return # only flag when source had it
if b is None or not isinstance(b, dict):
# Element missing in rendered
attrs = a.get("attrs") or {}
bbox = a.get("bbox") or {}
issues.append({
"kind": "missing_in_rendered",
"path": path,
"tag": a.get("tag"),
"id": attrs.get("id"),
"class": attrs.get("class"),
"expected_w": round(bbox.get("width") or 0, 1),
"expected_h": round(bbox.get("height") or 0, 1),
})
counters["missing"] += 1
return
# Tag mismatch
if a.get("tag") != b.get("tag"):
attrs_a = a.get("attrs") or {}
attrs_b = b.get("attrs") or {}
issues.append({
"kind": "tag_mismatch",
"path": path,
"expected_tag": a.get("tag"),
"actual_tag": b.get("tag"),
"expected_id": attrs_a.get("id"),
"actual_id": attrs_b.get("id"),
})
counters["tag_mismatch"] += 1
return
counters["matched"] += 1
# Style comparison
ca = (a.get("computed") or {})
cb = (b.get("computed") or {})
for prop in COMPARE_STYLE_PROPS:
ev, av = ca.get(prop), cb.get(prop)
if ev is None and av is None:
continue
# If property only exists on rendered, that's a divergence too — but
# we only flag when source explicitly set it (prevents noise).
if ev is None:
continue
if not style_close(prop, ev, av):
issues.append({
"kind": "style_mismatch",
"path": path,
"property": prop,
"expected": ev,
"actual": av,
})
counters["style"] += 1
# Size comparison
ba = a.get("bbox") or {}
bb = b.get("bbox") or {}
ew, eh = ba.get("width") or 0, ba.get("height") or 0
aw, ah = bb.get("width") or 0, bb.get("height") or 0
if ew > 8 and eh > 8:
wdelta = abs(ew - aw) / max(ew, 1) * 100
hdelta = abs(eh - ah) / max(eh, 1) * 100
if wdelta > SIZE_TOLERANCE_PCT or hdelta > SIZE_TOLERANCE_PCT:
issues.append({
"kind": "size_mismatch",
"path": path,
"expected": [round(ew, 1), round(eh, 1)],
"actual": [round(aw, 1), round(ah, 1)],
"delta_pct": round(max(wdelta, hdelta), 1),
})
counters["size"] += 1
# Children — match by (tag, id) tuple when ids exist, else positional
ka = a.get("children") or []
kb = b.get("children") or []
# Filter to element children (skip pure text)
ka_el = [(i, c) for i, c in enumerate(ka) if isinstance(c, dict) and "tag" in c]
kb_el = [(i, c) for i, c in enumerate(kb) if isinstance(c, dict) and "tag" in c]
# Build a lookup of rendered children by id (cheap) and by (tag, idx) fallback
used_b: set[int] = set()
b_by_id: dict[str, int] = {}
for j, (_, ch) in enumerate(kb_el):
cid = (ch.get("attrs") or {}).get("id")
if cid:
b_by_id[cid] = j
for ai, (_, ch) in enumerate(ka_el):
a_id = (ch.get("attrs") or {}).get("id")
match_idx = None
if a_id and a_id in b_by_id and b_by_id[a_id] not in used_b:
match_idx = b_by_id[a_id]
elif ai < len(kb_el) and ai not in used_b:
cand = kb_el[ai][1]
if cand.get("tag") == ch.get("tag"):
match_idx = ai
if match_idx is None:
compare(ch, None, f"{path} > {child_key(ch, ai)}", issues, counters, max_depth, depth + 1)
else:
used_b.add(match_idx)
compare(ch, kb_el[match_idx][1], f"{path} > {child_key(ch, ai)}", issues, counters, max_depth, depth + 1)
# Extra children in rendered (no source counterpart) — only flag at top levels
# to avoid noise from minor wrappers
if depth < 3:
for j, (_, ch) in enumerate(kb_el):
if j in used_b:
continue
attrs = ch.get("attrs") or {}
issues.append({
"kind": "extra_in_rendered",
"path": f"{path} > {child_key(ch, j)}",
"tag": ch.get("tag"),
"id": attrs.get("id"),
"class": attrs.get("class"),
})
counters["extra"] += 1
def humanize(issues: list[dict], top_n: int = 12) -> list[str]:
"""Convert structured issues to human-readable strings, ranked by impact."""
# Rank: missing > tag_mismatch > size_mismatch > style_mismatch > extra
weights = {"missing_in_rendered": 100, "tag_mismatch": 80, "size_mismatch": 50, "style_mismatch": 25, "extra_in_rendered": 10}
style_weights = {"backgroundImage": 4, "fontSize": 3, "color": 2, "backgroundColor": 2, "fontFamily": 2}
def score(i: dict) -> float:
base = weights.get(i["kind"], 0)
if i["kind"] == "size_mismatch":
base += min(i.get("delta_pct", 0), 50)
if i["kind"] == "style_mismatch":
base += style_weights.get(i.get("property", ""), 1)
return base
issues_sorted = sorted(issues, key=score, reverse=True)
out: list[str] = []
for i in issues_sorted[:top_n]:
path = i.get("path", "?")
if i["kind"] == "missing_in_rendered":
out.append(f"{path}: missing in rendered (expected {i.get('tag')}{' #' + i['id'] if i.get('id') else ''}, ~{i.get('expected_w')}x{i.get('expected_h')})")
elif i["kind"] == "tag_mismatch":
out.append(f"{path}: expected <{i.get('expected_tag')}>, rendered <{i.get('actual_tag')}>")
elif i["kind"] == "size_mismatch":
ew, eh = i.get("expected", [0, 0])
aw, ah = i.get("actual", [0, 0])
out.append(f"{path}: size {aw}x{ah}, should be {ew}x{eh}{i.get('delta_pct')}%)")
elif i["kind"] == "style_mismatch":
out.append(f"{path}: {i.get('property')} is {i.get('actual')}, should be {i.get('expected')}")
elif i["kind"] == "extra_in_rendered":
out.append(f"{path}: extra element in rendered (<{i.get('tag')}>{' #' + i['id'] if i.get('id') else ''}) — not in source")
return out
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--captured", required=True)
parser.add_argument("--rendered", required=True)
parser.add_argument("--root-selector", default=None, help="e.g. #hero or .ecosystem to scope the diff to a section")
parser.add_argument("--max-depth", type=int, default=8)
parser.add_argument("--max-issues", type=int, default=200)
args = parser.parse_args()
captured = json.loads(Path(args.captured).read_text())
rendered = json.loads(Path(args.rendered).read_text())
a_root = find_subtree(captured, args.root_selector)
b_root = find_subtree(rendered, args.root_selector)
if a_root is None:
print(json.dumps({"error": f"root selector not found in captured: {args.root_selector}"}))
sys.exit(2)
if b_root is None:
print(json.dumps({"error": f"root selector not found in rendered: {args.root_selector}"}))
sys.exit(3)
issues: list[dict] = []
counters = {"matched": 0, "missing": 0, "extra": 0, "tag_mismatch": 0, "style": 0, "size": 0}
compare(a_root, b_root, args.root_selector or a_root.get("tag", "body"), issues, counters, args.max_depth)
issues = issues[: args.max_issues]
humanized = humanize(issues, top_n=12)
print(json.dumps({
"matched": counters["matched"],
"counts": counters,
"issues": humanized,
"structured_issues": issues,
}))
if __name__ == "__main__":
main()
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""
download-assets.py — Download all assets referenced in a capture meta.json (or manifest.json)
into <project>/public/assets/cloned/ using hash-based filenames.
Input: JSON file with `assets[]` of shape { type, source_url, local_path }.
Output (to --json stdout): { downloaded: [...], failed: [...], skipped: [...] }
Dependencies: httpx (pip install httpx).
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
try:
import httpx
except ImportError:
print(json.dumps({"error": "Missing dependencies. pip install httpx"}), file=sys.stderr)
sys.exit(1)
LICENSED_FONT_HOST_RE = re.compile(r"(use\.typekit\.net|fonts\.adobe\.com|fast\.fonts\.net|cloud\.typography\.com)")
ROTATING_AUTH_RE = re.compile(r"[?&](token|signature|expires)=")
USER_AGENT = "Mozilla/5.0 (compatible; IonCloneBot/1.0)"
def download_one(client: httpx.Client, url: str, out_path: Path) -> str:
out_path.parent.mkdir(parents=True, exist_ok=True)
with client.stream("GET", url, timeout=30, headers={"User-Agent": USER_AGENT}) as r:
r.raise_for_status()
with out_path.open("wb") as f:
for chunk in r.iter_bytes():
f.write(chunk)
return "downloaded"
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--manifest", required=True, help="Path to meta.json or manifest.json")
parser.add_argument("--public-dir", required=True, help="Path to <project>/public/assets/cloned")
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
manifest_path = Path(args.manifest)
public_dir = Path(args.public_dir)
public_dir.mkdir(parents=True, exist_ok=True)
manifest = json.loads(manifest_path.read_text())
assets = manifest.get("assets", [])
downloaded: list[str] = []
failed: list[dict] = []
skipped: list[dict] = []
with httpx.Client(follow_redirects=True) as client:
for a in assets:
url = a.get("source_url")
local_path = a.get("local_path")
atype = a.get("type")
if not url or not local_path:
failed.append({"url": url, "reason": "malformed_asset"})
continue
if atype == "font" and LICENSED_FONT_HOST_RE.search(url):
skipped.append({"url": url, "reason": "licensed_font_unclear"})
continue
if ROTATING_AUTH_RE.search(url):
skipped.append({"url": url, "reason": "rotating_auth_token"})
continue
# local_path in the asset is relative to the project root; public-dir is
# <project>/public/assets/cloned, so strip that prefix.
rel = local_path
for prefix in ("public/assets/cloned/", "assets/cloned/"):
if rel.startswith(prefix):
rel = rel[len(prefix):]
break
out_path = public_dir / rel
try:
download_one(client, url, out_path)
downloaded.append(str(out_path))
except httpx.HTTPError as e:
failed.append({"url": url, "reason": str(e)})
except Exception as e:
failed.append({"url": url, "reason": str(e)})
result = {"downloaded": downloaded, "failed": failed, "skipped": skipped}
if args.json:
print(json.dumps(result))
else:
print(f"downloaded={len(downloaded)} failed={len(failed)} skipped={len(skipped)}")
if __name__ == "__main__":
main()
+118
View File
@@ -0,0 +1,118 @@
#!/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()
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""
extract-fonts.py — Parse @font-face rules from captured CSS, resolve URLs, download,
and write license_hint into the manifest.
Run after capture.py and before generation. Input: capture directory with fonts/*.json.
Output: writes font files into <public_dir>/fonts/ and an updated JSON summary.
Dependencies: httpx, tinycss2 (pip install httpx tinycss2).
"""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import sys
from pathlib import Path
from urllib.parse import urljoin
try:
import httpx
import tinycss2
except ImportError:
print(json.dumps({"error": "Missing deps. pip install httpx tinycss2"}), file=sys.stderr)
sys.exit(1)
LICENSED_HOSTS = re.compile(r"(use\.typekit\.net|fonts\.adobe\.com|fast\.fonts\.net|cloud\.typography\.com)")
OPEN_HOSTS = re.compile(r"(fonts\.googleapis\.com|fonts\.gstatic\.com)")
def sha1_8(s: str) -> str:
return hashlib.sha1(s.encode("utf-8")).hexdigest()[:8]
def classify(url: str) -> str:
if LICENSED_HOSTS.search(url):
return "licensed"
if OPEN_HOSTS.search(url):
return "open"
return "unclear"
def parse_face_rules(css_text: str, base_url: str) -> list[dict]:
out = []
rules = tinycss2.parse_stylesheet(css_text, skip_comments=True, skip_whitespace=True)
for rule in rules:
if rule.type != "at-rule" or rule.lower_at_keyword != "font-face":
continue
block = tinycss2.parse_declaration_list(rule.content or [])
face: dict = {}
for decl in block:
if decl.type != "declaration":
continue
name = decl.lower_name
value = tinycss2.serialize(decl.value).strip().strip('"').strip("'")
if name == "font-family":
face["family"] = value
elif name == "font-weight":
face["weight"] = value
elif name == "font-style":
face["style"] = value
elif name == "src":
urls = re.findall(r"url\(\s*['\"]?([^'\")]+)['\"]?\s*\)", tinycss2.serialize(decl.value))
face["urls"] = [urljoin(base_url, u) for u in urls]
if face.get("family") and face.get("urls"):
out.append(face)
return out
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--capture-dir", required=True)
parser.add_argument("--public-dir", required=True)
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
capture_dir = Path(args.capture_dir)
public_dir = Path(args.public_dir)
fonts_out = public_dir / "fonts"
fonts_out.mkdir(parents=True, exist_ok=True)
face_dumps = list((capture_dir / "fonts").glob("*.json"))
all_faces: list[dict] = []
for dump in face_dumps:
data = json.loads(dump.read_text())
for entry in data if isinstance(data, list) else []:
css = entry.get("cssText") or ""
base = entry.get("baseUrl") or ""
all_faces.extend(parse_face_rules(css, base))
downloaded: list[dict] = []
skipped: list[dict] = []
with httpx.Client(follow_redirects=True) as client:
for face in all_faces:
family = face["family"]
license_hint = "unclear"
for url in face["urls"]:
license_hint = classify(url)
if license_hint == "licensed":
skipped.append({"family": family, "url": url, "reason": "licensed"})
break
try:
ext = url.rsplit(".", 1)[-1].split("?", 1)[0].lower()
if ext not in ("woff2", "woff", "ttf", "otf"):
ext = "woff2"
fname = f"{sha1_8(url)}-{re.sub(r'[^a-z0-9]+', '-', family.lower())}.{ext}"
out_path = fonts_out / fname
r = client.get(url, timeout=30, headers={"User-Agent": "Mozilla/5.0 (compatible; IonCloneBot/1.0)"})
r.raise_for_status()
out_path.write_bytes(r.content)
downloaded.append({
"family": family,
"weight": face.get("weight"),
"style": face.get("style"),
"source_url": url,
"local_path": str(out_path),
"license_hint": license_hint,
})
except Exception as e:
skipped.append({"family": family, "url": url, "reason": str(e)})
result = {"downloaded": downloaded, "skipped": skipped}
if args.json:
print(json.dumps(result))
else:
print(f"downloaded={len(downloaded)} skipped={len(skipped)}")
if __name__ == "__main__":
main()
+25
View File
@@ -0,0 +1,25 @@
// Initializes the shared capture bucket that every other hook writes into.
// Runs first because hooks load in lexicographic order.
(() => {
if (window.__CLONE_CAPTURE__) return;
window.__CLONE_CAPTURE__ = {
shaders: [],
gsap: [],
framer: [],
lottie: [],
threejs: [],
cssVars: {},
fonts: [],
};
// Sentinel the capture script waits on after DOMContentLoaded + a tick.
// Hooks that dump state on demand (gsap, framer) set __CLONE_READY__ when they've run.
window.__CLONE_READY__ = false;
window.addEventListener('load', () => {
setTimeout(() => {
try {
if (typeof window.__CLONE_FINALIZE__ === 'function') window.__CLONE_FINALIZE__();
} catch {}
window.__CLONE_READY__ = true;
}, 800);
});
})();
@@ -0,0 +1,193 @@
// Exposes window.__CLONE_DUMP_COMPUTED__() which walks the DOM and returns a minimal tree
// of resolved computed styles (only non-default values). The capture script calls this on demand
// after each scroll step — cheap to call repeatedly, much richer than the inline per-step snapshot
// the Playwright script does as a fallback.
(() => {
const PROPS = [
'color',
'backgroundColor',
'backgroundImage',
'backgroundSize',
'backgroundPosition',
'backgroundRepeat',
'backgroundAttachment',
'backgroundClip',
'fontFamily',
'fontSize',
'fontWeight',
'lineHeight',
'letterSpacing',
'textTransform',
'textAlign',
'textDecoration',
'textShadow',
'whiteSpace',
'display',
'flexDirection',
'flexWrap',
'justifyContent',
'alignItems',
'alignContent',
'alignSelf',
'gap',
'rowGap',
'columnGap',
'gridTemplateColumns',
'gridTemplateRows',
'gridTemplateAreas',
'gridColumn',
'gridRow',
'gridAutoFlow',
'padding',
'paddingTop',
'paddingRight',
'paddingBottom',
'paddingLeft',
'margin',
'marginTop',
'marginRight',
'marginBottom',
'marginLeft',
'width',
'height',
'maxWidth',
'maxHeight',
'minWidth',
'minHeight',
'aspectRatio',
'position',
'top',
'right',
'bottom',
'left',
'zIndex',
'inset',
'border',
'borderTop',
'borderRight',
'borderBottom',
'borderLeft',
'borderRadius',
'borderColor',
'borderWidth',
'borderStyle',
'boxShadow',
'opacity',
'transform',
'transformOrigin',
'filter',
'backdropFilter',
'clipPath',
'mask',
'maskImage',
'mixBlendMode',
'isolation',
'overflow',
'overflowX',
'overflowY',
'scrollBehavior',
'scrollSnapType',
'cursor',
'pointerEvents',
'userSelect',
'willChange',
'contain',
];
// Compute defaults once per tag by creating a hidden iframe so we don't treat
// inherited defaults as "used styles". The iframe is created lazily — at
// init-script time, document.documentElement may still be null (Playwright
// fires add_init_script before the HTML parser materialises <html>).
let iframe = null;
function ensureIframe() {
if (iframe) return iframe;
if (!document.documentElement) return null;
try {
iframe = document.createElement('iframe');
iframe.style.cssText = 'position:absolute;width:0;height:0;border:0;opacity:0;pointer-events:none';
iframe.srcdoc = '<!doctype html><html><body></body></html>';
document.documentElement.appendChild(iframe);
} catch {
iframe = null;
}
return iframe;
}
const defaultsByTag = new Map();
function defaultsFor(tag) {
if (defaultsByTag.has(tag)) return defaultsByTag.get(tag);
const ifr = ensureIframe();
const doc = ifr ? ifr.contentDocument : null;
if (!doc || !doc.body) return {};
const el = doc.createElement(tag);
doc.body.appendChild(el);
const cs = doc.defaultView.getComputedStyle(el);
const d = {};
for (const p of PROPS) d[p] = cs[p];
doc.body.removeChild(el);
defaultsByTag.set(tag, d);
return d;
}
function nonDefault(el) {
const cs = getComputedStyle(el);
const defaults = defaultsFor(el.tagName.toLowerCase());
const out = {};
for (const p of PROPS) {
const v = cs[p];
if (v == null || v === '') continue;
if (v === defaults[p]) continue;
out[p] = v;
}
return out;
}
function pseudoStyles(el) {
const out = {};
for (const pseudo of ['::before', '::after']) {
try {
const cs = getComputedStyle(el, pseudo);
const content = cs.content;
if (content && content !== 'normal' && content !== 'none') {
const block = {};
for (const p of PROPS) {
const v = cs[p];
if (v && v !== 'none' && v !== 'normal' && v !== 'auto') block[p] = v;
}
block.content = content;
out[pseudo] = block;
}
} catch {}
}
return Object.keys(out).length ? out : undefined;
}
function serialize(el) {
if (el.nodeType === Node.TEXT_NODE) {
const t = el.textContent;
return t && t.trim() ? { text: t } : null;
}
if (el.nodeType !== Node.ELEMENT_NODE) return null;
if (el.tagName === 'SCRIPT' || el.tagName === 'STYLE' || el.tagName === 'NOSCRIPT') return null;
const attrs = {};
for (const a of el.attributes) attrs[a.name] = a.value;
const computed = nonDefault(el);
const pseudo = pseudoStyles(el);
if (pseudo) computed.pseudo = pseudo;
const r = el.getBoundingClientRect();
const children = [];
for (const c of el.childNodes) {
const s = serialize(c);
if (s) children.push(s);
}
return {
tag: el.tagName.toLowerCase(),
attrs,
computed,
bbox: { x: r.x, y: r.y + window.scrollY, width: r.width, height: r.height },
children,
};
}
window.__CLONE_DUMP_COMPUTED__ = () => serialize(document.body);
})();
+121
View File
@@ -0,0 +1,121 @@
// Dumps every same-origin CSS rule as { selector, cssText } into __CLONE_CAPTURE__.cssRules,
// and extracts every url(...) asset reference (resolved to absolute URLs) into
// __CLONE_CAPTURE__.cssAssets. Catches background-images set in stylesheets that the
// response listener missed (e.g. @media-gated, ::before, mask-image, list-style-image, cursor).
(() => {
function isSameOrigin(href) {
if (!href) return true;
try {
const u = new URL(href, location.href);
return u.origin === location.origin;
} catch {
return false;
}
}
function resolveUrl(raw, baseHref) {
if (!raw) return null;
let s = raw.trim();
if (s.startsWith('"') || s.startsWith("'")) s = s.slice(1, -1);
if (s.startsWith('data:') || s.startsWith('#')) return null;
try {
return new URL(s, baseHref || location.href).href;
} catch {
return null;
}
}
function collectFromRules(rules, baseHref, out) {
if (!rules) return;
for (const rule of rules) {
const type = rule.constructor?.name || '';
if (type === 'CSSStyleRule') {
const text = rule.cssText || '';
out.rules.push({ selector: rule.selectorText, cssText: text, baseHref });
const urls = text.match(/url\(\s*([^)]+?)\s*\)/g) || [];
for (const m of urls) {
const inner = m
.slice(4, -1)
.trim()
.replace(/^['"]|['"]$/g, '');
const abs = resolveUrl(inner, baseHref);
if (abs && !out.assetSet.has(abs)) {
out.assetSet.add(abs);
out.assets.push({ source_url: abs, selector: rule.selectorText });
}
}
} else if (type === 'CSSMediaRule' || type === 'CSSSupportsRule' || type === 'CSSContainerRule') {
const condition = rule.conditionText || rule.media?.mediaText;
out.media.push({ condition, type });
collectFromRules(rule.cssRules, baseHref, out);
} else if (type === 'CSSImportRule') {
// Recurse into imported stylesheet if same-origin and accessible
try {
const importedHref = rule.styleSheet?.href || baseHref;
if (isSameOrigin(importedHref)) {
collectFromRules(rule.styleSheet?.cssRules, importedHref, out);
}
} catch {}
} else if (type === 'CSSFontFaceRule') {
const text = rule.cssText || '';
out.fontFaces.push({ cssText: text, baseHref });
const urls = text.match(/url\(\s*([^)]+?)\s*\)/g) || [];
for (const m of urls) {
const inner = m
.slice(4, -1)
.trim()
.replace(/^['"]|['"]$/g, '');
const abs = resolveUrl(inner, baseHref);
if (abs && !out.assetSet.has(abs)) {
out.assetSet.add(abs);
out.assets.push({ source_url: abs, selector: '@font-face' });
}
}
} else if (type === 'CSSKeyframesRule') {
const frames = [];
for (const k of rule.cssRules || []) frames.push({ key: k.keyText, cssText: k.style?.cssText || '' });
out.keyframes.push({ name: rule.name, frames, baseHref });
} else if (rule.cssRules) {
collectFromRules(rule.cssRules, baseHref, out);
}
}
}
function collect() {
const out = {
rules: [],
media: [],
fontFaces: [],
keyframes: [],
assets: [],
assetSet: new Set(),
crossOrigin: 0,
};
for (const sheet of document.styleSheets) {
const baseHref = sheet.href || location.href;
let rules;
try {
rules = sheet.cssRules;
} catch {
out.crossOrigin += 1;
continue;
}
collectFromRules(rules, baseHref, out);
}
delete out.assetSet;
window.__CLONE_CAPTURE__.cssRules = out;
}
const prevFinalize = window.__CLONE_FINALIZE__;
window.__CLONE_FINALIZE__ = function () {
if (prevFinalize)
try {
prevFinalize();
} catch {}
try {
collect();
} catch (e) {
window.__CLONE_CAPTURE__.cssRules = { error: String(e) };
}
};
})();
+51
View File
@@ -0,0 +1,51 @@
// Dumps every :root --custom-property and every @property registration into
// window.__CLONE_CAPTURE__.cssVars, keyed under { custom, registered }.
(() => {
function collect() {
const custom = {};
try {
const cs = getComputedStyle(document.documentElement);
for (let i = 0; i < cs.length; i++) {
const name = cs[i];
if (!name.startsWith('--')) continue;
try {
custom[name] = cs.getPropertyValue(name).trim();
} catch {}
}
} catch {}
const registered = [];
try {
for (const sheet of document.styleSheets) {
let rules;
try {
rules = sheet.cssRules;
} catch {
continue;
}
if (!rules) continue;
for (const rule of rules) {
if (rule.constructor?.name === 'CSSPropertyRule' || rule.type === 18 /* @property */) {
registered.push({
name: rule.name,
syntax: rule.syntax,
initialValue: rule.initialValue,
inherits: rule.inherits,
});
}
}
}
} catch {}
window.__CLONE_CAPTURE__.cssVars = { custom, registered };
}
const prevFinalize = window.__CLONE_FINALIZE__;
window.__CLONE_FINALIZE__ = function () {
if (prevFinalize)
try {
prevFinalize();
} catch {}
collect();
};
})();
+107
View File
@@ -0,0 +1,107 @@
// Walks the React fiber tree (via __REACT_DEVTOOLS_GLOBAL_HOOK__) to find framer-motion
// components and dump their animate/variants/transition/whileInView/whileHover props.
// Called from __CLONE_FINALIZE__.
(() => {
function cssPath(el) {
if (!el || el.nodeType !== 1) return null;
if (el.id) return `#${el.id}`;
const parts = [];
while (el && el.nodeType === 1 && parts.length < 10) {
let s = el.tagName.toLowerCase();
const p = el.parentNode;
if (p) {
const sibs = Array.from(p.children).filter((c) => c.tagName === el.tagName);
if (sibs.length > 1) s += `:nth-of-type(${sibs.indexOf(el) + 1})`;
}
parts.unshift(s);
el = el.parentNode;
}
return parts.join(' > ');
}
function isMotionComponent(fiber) {
const type = fiber?.type;
if (!type) return false;
if (type.$$typeof && type.render && type.render.displayName?.startsWith('motion.')) return true;
const dn = type.displayName || type.name;
if (typeof dn === 'string' && (dn.startsWith('motion.') || dn === 'MotionComponent')) return true;
return false;
}
function serializeProp(v) {
try {
if (v == null) return v;
if (typeof v === 'function') return null;
if (typeof v === 'object') {
const out = {};
for (const k of Object.keys(v)) {
const val = v[k];
if (typeof val === 'function') continue;
if (val && typeof val === 'object' && val.nodeType) continue;
out[k] = val;
}
return out;
}
return v;
} catch {
return null;
}
}
function findFibers(fiber, out) {
if (!fiber) return;
if (isMotionComponent(fiber)) {
const props = fiber.memoizedProps || {};
let node = fiber.stateNode;
if (!(node instanceof Element)) {
// Walk down to the first DOM host for selector extraction.
let cursor = fiber.child;
while (cursor) {
if (cursor.stateNode instanceof Element) {
node = cursor.stateNode;
break;
}
cursor = cursor.child;
}
}
out.push({
selector: node instanceof Element ? cssPath(node) : null,
initial: serializeProp(props.initial),
animate: serializeProp(props.animate),
exit: serializeProp(props.exit),
variants: serializeProp(props.variants),
transition: serializeProp(props.transition),
whileHover: serializeProp(props.whileHover),
whileTap: serializeProp(props.whileTap),
whileInView: serializeProp(props.whileInView),
viewport: serializeProp(props.viewport),
});
}
if (fiber.child) findFibers(fiber.child, out);
if (fiber.sibling) findFibers(fiber.sibling, out);
}
const prevFinalize = window.__CLONE_FINALIZE__;
window.__CLONE_FINALIZE__ = function () {
if (prevFinalize)
try {
prevFinalize();
} catch {}
try {
const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
if (!hook || !hook.renderers) return;
for (const [, renderer] of hook.renderers) {
if (!renderer) continue;
const roots = renderer.findFiberByHostInstance ? null : renderer.roots || null;
const scanFrom = [];
if (hook.getFiberRoots) {
const rootSet = hook.getFiberRoots(1) || new Set();
for (const r of rootSet) scanFrom.push(r.current);
}
for (const fiber of scanFrom) findFibers(fiber, window.__CLONE_CAPTURE__.framer);
}
} catch (e) {
window.__CLONE_CAPTURE__.framer.push({ error: String(e) });
}
};
})();
+103
View File
@@ -0,0 +1,103 @@
// Dumps GSAP timelines + tweens into window.__CLONE_CAPTURE__.gsap after load.
// Runs on __CLONE_FINALIZE__ so that page-load animations have had time to register.
(() => {
function cssPath(el) {
if (!el || el.nodeType !== 1) return null;
if (el.id) return `#${el.id}`;
const parts = [];
while (el && el.nodeType === 1 && parts.length < 10) {
let s = el.tagName.toLowerCase();
const p = el.parentNode;
if (p) {
const sibs = Array.from(p.children).filter((c) => c.tagName === el.tagName);
if (sibs.length > 1) s += `:nth-of-type(${sibs.indexOf(el) + 1})`;
}
parts.unshift(s);
el = el.parentNode;
}
return parts.join(' > ');
}
function targetsToSelectors(targets) {
if (!targets) return [];
if (targets instanceof Element) return [cssPath(targets)];
if (Array.isArray(targets) || targets.length != null) {
const out = [];
for (let i = 0; i < targets.length; i++) {
const t = targets[i];
if (t instanceof Element) out.push(cssPath(t));
else if (typeof t === 'string') out.push(t);
}
return out;
}
if (typeof targets === 'string') return [targets];
return [];
}
function serializeEase(ease) {
if (!ease) return null;
if (typeof ease === 'string') return ease;
if (typeof ease === 'function') return ease.name || ease.toString().slice(0, 40);
return String(ease);
}
function serializeTween(t) {
const vars = {};
if (t.vars) {
for (const k of Object.keys(t.vars)) {
if (['onComplete', 'onStart', 'onUpdate', 'onRepeat', 'scrollTrigger'].includes(k)) continue;
try {
const v = t.vars[k];
if (typeof v === 'function') continue;
if (v && typeof v === 'object' && v.nodeType) continue;
vars[k] = v;
} catch {}
}
}
return {
type: 'tween',
targets: targetsToSelectors(t._targets || t.targets?.()),
duration: t.duration?.() ?? t._duration,
ease: serializeEase(t.vars?.ease),
vars,
scrollTrigger: t.vars?.scrollTrigger
? {
trigger:
t.vars.scrollTrigger.trigger instanceof Element
? cssPath(t.vars.scrollTrigger.trigger)
: t.vars.scrollTrigger.trigger,
start: t.vars.scrollTrigger.start,
end: t.vars.scrollTrigger.end,
scrub: t.vars.scrollTrigger.scrub,
pin: t.vars.scrollTrigger.pin,
}
: null,
};
}
function walk(tl) {
const out = { type: 'timeline', duration: tl.duration?.(), children: [] };
const kids = tl.getChildren ? tl.getChildren(true, true, true) : [];
for (const c of kids) {
if (c.getChildren) out.children.push(walk(c));
else out.children.push(serializeTween(c));
}
return out;
}
const prevFinalize = window.__CLONE_FINALIZE__;
window.__CLONE_FINALIZE__ = function () {
if (prevFinalize)
try {
prevFinalize();
} catch {}
try {
if (!window.gsap) return;
const root = window.gsap.globalTimeline;
if (!root) return;
window.__CLONE_CAPTURE__.gsap.push(walk(root));
} catch (e) {
window.__CLONE_CAPTURE__.gsap.push({ error: String(e) });
}
};
})();
@@ -0,0 +1,47 @@
// Pulls registered Lottie animations + their animationData into window.__CLONE_CAPTURE__.lottie.
// Called from __CLONE_FINALIZE__. Supports lottie-web directly; lottie-react uses lottie-web internally.
(() => {
function cssPath(el) {
if (!el || el.nodeType !== 1) return null;
if (el.id) return `#${el.id}`;
const parts = [];
while (el && el.nodeType === 1 && parts.length < 10) {
let s = el.tagName.toLowerCase();
const p = el.parentNode;
if (p) {
const sibs = Array.from(p.children).filter((c) => c.tagName === el.tagName);
if (sibs.length > 1) s += `:nth-of-type(${sibs.indexOf(el) + 1})`;
}
parts.unshift(s);
el = el.parentNode;
}
return parts.join(' > ');
}
const prevFinalize = window.__CLONE_FINALIZE__;
window.__CLONE_FINALIZE__ = function () {
if (prevFinalize)
try {
prevFinalize();
} catch {}
try {
const lottie = window.lottie || window.bodymovin;
if (!lottie || typeof lottie.getRegisteredAnimations !== 'function') return;
const anims = lottie.getRegisteredAnimations();
for (const a of anims) {
try {
window.__CLONE_CAPTURE__.lottie.push({
selector: a.wrapper instanceof Element ? cssPath(a.wrapper) : null,
renderer: a.renderer?.name || 'unknown',
animationData: a.animationData ? JSON.parse(JSON.stringify(a.animationData)) : null,
loop: a.loop,
autoplay: a.autoplay,
name: a.name,
});
} catch {}
}
} catch (e) {
window.__CLONE_CAPTURE__.lottie.push({ error: String(e) });
}
};
})();
@@ -0,0 +1,147 @@
// Exposes window.__CLONE_LIST_SECTIONS__() which returns a list of candidate top-level
// section bounding boxes for the capture script's scroll planner. We emit one entry per
// large flex/block child of <body>, <main>, or any element classed as section/container/etc.
// The capture script uses these to scroll-to-section instead of fixed-pixel stepping.
(() => {
const SECTIONLIKE_TAGS = new Set(['section', 'header', 'footer', 'nav', 'main', 'article', 'aside']);
const SECTIONLIKE_CLASS_RE =
/(^|\s)(section|hero|container|banner|elementor-section|elementor-element-[^\s]+|e-con|e-parent|wp-block-[^\s]+)(\s|$)/i;
function isViewportWide(el, vw) {
const r = el.getBoundingClientRect();
return r.width >= vw * 0.5 && r.height >= 80;
}
function isVisible(el) {
const cs = getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden') return false;
if (parseFloat(cs.opacity) === 0) return false;
return true;
}
function looksLikeSection(el) {
const tag = el.tagName.toLowerCase();
if (SECTIONLIKE_TAGS.has(tag)) return true;
if (el.id && /(hero|banner|section|footer|nav|header)/i.test(el.id)) return true;
if (typeof el.className === 'string' && SECTIONLIKE_CLASS_RE.test(el.className)) return true;
return false;
}
function cssPath(el) {
if (!el || el.nodeType !== 1) return null;
if (el.id) return `#${el.id}`;
const parts = [];
while (el && el.nodeType === 1 && parts.length < 8) {
let s = el.tagName.toLowerCase();
const p = el.parentNode;
if (p && p.nodeType === 1) {
const sibs = Array.from(p.children).filter((c) => c.tagName === el.tagName);
if (sibs.length > 1) s += `:nth-of-type(${sibs.indexOf(el) + 1})`;
}
parts.unshift(s);
el = el.parentNode;
}
return parts.join(' > ');
}
const SECTION_QUERY =
'section, header, footer, nav, main, article, ' +
'[class*="section"], [class*="elementor-element"], [class*="e-parent"], [class*="e-con"], ' +
'[class*="hero"], [class*="banner"], [id*="section"], [id*="hero"], [id*="banner"]';
function snapshotEntry(el, vw) {
const r = el.getBoundingClientRect();
return {
element: el,
selector: cssPath(el),
tag: el.tagName.toLowerCase(),
id: el.id || null,
className: typeof el.className === 'string' ? el.className.slice(0, 200) : null,
x: r.x,
y: r.y + window.scrollY,
width: r.width,
height: r.height,
looksLikeSection: looksLikeSection(el),
};
}
function collectInScope(scope, vw, out, candidates) {
for (const el of candidates) {
if (!scope.contains(el) || el === scope) continue;
if (!isVisible(el) || !isViewportWide(el, vw)) continue;
// Outermost-wins inside this scope
let skip = false;
for (const o of out) {
if (o.element.contains(el)) {
skip = true;
break;
}
}
if (skip) continue;
for (let i = out.length - 1; i >= 0; i--) {
if (el.contains(out[i].element)) out.splice(i, 1);
}
out.push(snapshotEntry(el, vw));
}
}
window.__CLONE_LIST_SECTIONS__ = () => {
const vw = window.innerWidth;
const vh = window.innerHeight;
const docHeight = Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0);
// Pass 1: known section-like elements
const candidates = Array.from(document.querySelectorAll(SECTION_QUERY));
// Pass 2: large direct children of body/main as a fallback
const root = document.querySelector('main') || document.body;
if (root) {
for (const child of root.children) {
if (!candidates.includes(child)) candidates.push(child);
}
}
// First-pass dedup with outermost-wins
const out = [];
for (const el of candidates) {
if (!isVisible(el) || !isViewportWide(el, vw)) continue;
let skip = false;
for (const o of out) {
if (o.element.contains(el)) {
skip = true;
break;
}
}
if (skip) continue;
for (let i = out.length - 1; i >= 0; i--) {
if (el.contains(out[i].element)) out.splice(i, 1);
}
out.push(snapshotEntry(el, vw));
}
// Pass 3: oversized-wrapper expansion. WordPress / Elementor sites commonly
// wrap the whole page in <main id="main">. Without this step the outer
// wrapper wins and individual sections are dropped, so the smart-scroll
// loop never visits them and lazy backgrounds never fire.
const oversizedThreshold = Math.max(vh * 2, docHeight * 0.5);
const expanded = [];
for (const sec of out) {
if (sec.height < oversizedThreshold) {
expanded.push(sec);
continue;
}
const inner = [];
const innerCandidates = Array.from(sec.element.querySelectorAll(SECTION_QUERY));
collectInScope(sec.element, vw, inner, innerCandidates);
// Only replace the wrapper if recursion produced enough sections to be
// meaningful — otherwise this is a genuine large section, not a wrapper.
if (inner.length >= 3) expanded.push(...inner);
else expanded.push(sec);
}
return expanded
.filter((s) => s.height > 80)
.sort((a, b) => a.y - b.y)
.map(({ element, ...rest }) => rest);
};
})();
+97
View File
@@ -0,0 +1,97 @@
// Extract GLSL from WebGL1 + WebGL2 by intercepting shaderSource / compileShader / linkProgram / useProgram.
// Stashes into window.__CLONE_CAPTURE__.shaders as
// { programId, vertex, fragment, attributes, uniforms, canvasSelector }
(() => {
const CAPTURE = () => window.__CLONE_CAPTURE__;
const shaderMap = new WeakMap(); // WebGLShader -> { type, source }
const programMap = new WeakMap(); // WebGLProgram -> { vertex, fragment }
let nextProgramId = 1;
const contextCanvas = new WeakMap(); // WebGL(2)RenderingContext -> canvas
function patch(ctxProto, isGL2) {
const origShaderSource = ctxProto.shaderSource;
ctxProto.shaderSource = function (shader, source) {
shaderMap.set(shader, { source, type: this.getShaderParameter?.(shader, this.SHADER_TYPE) });
return origShaderSource.call(this, shader, source);
};
const origAttach = ctxProto.attachShader;
ctxProto.attachShader = function (program, shader) {
const info = shaderMap.get(shader);
const t = this.getShaderParameter(shader, this.SHADER_TYPE);
const entry = programMap.get(program) || {};
if (t === this.VERTEX_SHADER) entry.vertex = info?.source || '';
else if (t === this.FRAGMENT_SHADER) entry.fragment = info?.source || '';
programMap.set(program, entry);
return origAttach.call(this, program, shader);
};
const origLink = ctxProto.linkProgram;
ctxProto.linkProgram = function (program) {
const res = origLink.call(this, program);
const entry = programMap.get(program) || {};
if (!entry.id) entry.id = nextProgramId++;
const canvas = contextCanvas.get(this);
const sel = canvas ? cssPath(canvas) : null;
const attributes = [];
const uniforms = [];
try {
const aCount = this.getProgramParameter(program, this.ACTIVE_ATTRIBUTES);
for (let i = 0; i < aCount; i++) {
const info = this.getActiveAttrib(program, i);
if (info) attributes.push({ name: info.name, type: info.type, size: info.size });
}
const uCount = this.getProgramParameter(program, this.ACTIVE_UNIFORMS);
for (let i = 0; i < uCount; i++) {
const info = this.getActiveUniform(program, i);
if (info) uniforms.push({ name: info.name, type: info.type, size: info.size });
}
} catch {}
CAPTURE().shaders.push({
programId: entry.id,
vertex: entry.vertex || '',
fragment: entry.fragment || '',
attributes,
uniforms,
canvasSelector: sel,
isWebGL2: isGL2,
});
return res;
};
}
// Intercept getContext to remember which canvas owns which WebGL context.
const origGetContext = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function (type, ...rest) {
const ctx = origGetContext.call(this, type, ...rest);
if (ctx && (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl')) {
contextCanvas.set(ctx, this);
}
return ctx;
};
if (typeof WebGLRenderingContext !== 'undefined') patch(WebGLRenderingContext.prototype, false);
if (typeof WebGL2RenderingContext !== 'undefined') patch(WebGL2RenderingContext.prototype, true);
function cssPath(el) {
if (!el) return null;
const parts = [];
while (el && el.nodeType === 1 && parts.length < 10) {
let s = el.tagName.toLowerCase();
if (el.id) {
s += '#' + el.id;
parts.unshift(s);
break;
}
const parent = el.parentNode;
if (parent) {
const sibs = Array.from(parent.children).filter((c) => c.tagName === el.tagName);
if (sibs.length > 1) s += `:nth-of-type(${sibs.indexOf(el) + 1})`;
}
parts.unshift(s);
el = el.parentNode;
}
return parts.join(' > ');
}
})();
@@ -0,0 +1,130 @@
// Patches THREE.WebGLRenderer to track its canvas + scene root, then serializes the scene graph
// at __CLONE_FINALIZE__. Records geometry types, material types + uniforms, lights, cameras,
// and per-object position/rotation/scale.
(() => {
const renderers = new Set();
const rendererToScene = new WeakMap();
function cssPath(el) {
if (!el || el.nodeType !== 1) return null;
if (el.id) return `#${el.id}`;
const parts = [];
while (el && el.nodeType === 1 && parts.length < 10) {
let s = el.tagName.toLowerCase();
const p = el.parentNode;
if (p) {
const sibs = Array.from(p.children).filter((c) => c.tagName === el.tagName);
if (sibs.length > 1) s += `:nth-of-type(${sibs.indexOf(el) + 1})`;
}
parts.unshift(s);
el = el.parentNode;
}
return parts.join(' > ');
}
function patchWhenReady() {
if (!window.THREE || !window.THREE.WebGLRenderer) return false;
const WR = window.THREE.WebGLRenderer;
const origRender = WR.prototype.render;
WR.prototype.render = function (scene, camera) {
renderers.add(this);
if (scene) rendererToScene.set(this, { scene, camera });
return origRender.call(this, scene, camera);
};
return true;
}
if (!patchWhenReady()) {
const timer = setInterval(() => {
if (patchWhenReady()) clearInterval(timer);
}, 200);
setTimeout(() => clearInterval(timer), 10_000);
}
function serializeObject(obj) {
const out = {
type: obj.type || obj.constructor?.name || 'Object3D',
name: obj.name || undefined,
position: obj.position ? [obj.position.x, obj.position.y, obj.position.z] : undefined,
rotation: obj.rotation ? [obj.rotation.x, obj.rotation.y, obj.rotation.z] : undefined,
scale: obj.scale ? [obj.scale.x, obj.scale.y, obj.scale.z] : undefined,
visible: obj.visible,
children: [],
};
if (obj.geometry) {
out.geometry = {
type: obj.geometry.type,
parameters: obj.geometry.parameters ? { ...obj.geometry.parameters } : undefined,
};
}
if (obj.material) {
const m = Array.isArray(obj.material) ? obj.material[0] : obj.material;
out.material = {
type: m.type,
color: m.color ? m.color.getHex?.().toString(16) : undefined,
opacity: m.opacity,
transparent: m.transparent,
uniforms: m.uniforms ? summarizeUniforms(m.uniforms) : undefined,
vertexShader: m.vertexShader?.slice(0, 4000),
fragmentShader: m.fragmentShader?.slice(0, 4000),
};
}
if (obj.isLight) {
out.light = {
type: obj.type,
color: obj.color?.getHex?.()?.toString(16),
intensity: obj.intensity,
};
}
if (obj.isCamera) {
out.camera = {
type: obj.type,
fov: obj.fov,
aspect: obj.aspect,
near: obj.near,
far: obj.far,
};
}
if (obj.children?.length) {
out.children = obj.children.map(serializeObject);
}
return out;
}
function summarizeUniforms(u) {
const out = {};
for (const k of Object.keys(u)) {
try {
const v = u[k]?.value;
if (v == null) continue;
if (typeof v === 'number') out[k] = v;
else if (Array.isArray(v)) out[k] = v.slice(0, 16);
else if (v.x != null && v.y != null) out[k] = [v.x, v.y, v.z ?? 0, v.w ?? 0];
else out[k] = String(v).slice(0, 80);
} catch {}
}
return out;
}
const prevFinalize = window.__CLONE_FINALIZE__;
window.__CLONE_FINALIZE__ = function () {
if (prevFinalize)
try {
prevFinalize();
} catch {}
try {
for (const r of renderers) {
const { scene, camera } = rendererToScene.get(r) || {};
const dom = r.domElement;
window.__CLONE_CAPTURE__.threejs.push({
canvasSelector: dom instanceof Element ? cssPath(dom) : null,
size: dom ? [dom.width, dom.height] : null,
scene: scene ? serializeObject(scene) : null,
camera: camera ? serializeObject(camera) : null,
});
}
} catch (e) {
window.__CLONE_CAPTURE__.threejs.push({ error: String(e) });
}
};
})();
+7
View File
@@ -0,0 +1,7 @@
playwright>=1.40
httpx>=0.25
tinycss2>=1.2
pixelmatch>=0.3
pillow>=10
numpy>=1.26
scipy>=1.11