Files
OneOS-V2/.agents/skills/stitch-extract-static-html/scripts/snapshot.ts
2026-07-29 16:04:39 +08:00

1329 lines
48 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env npx tsx
/**
* snapshot.ts — Production-grade Puppeteer-based full-page HTML snapshot
*
* Captures the fully rendered DOM from a running web application and produces
* a self-contained HTML file with all CSS inlined and images converted to
* base64 data URIs. Works with any framework (React, Vue, Svelte, Angular,
* plain HTML, etc.) — no MockPage.jsx needed.
*
* Usage:
* npx tsx snapshot.ts --url http://localhost:5173 --output .stitch/home.html
* npx tsx snapshot.ts --url http://localhost:3000/pricing --output .stitch/pricing.html --html-class dark
* npx tsx snapshot.ts --url http://localhost:5173 --output .stitch/page.html --wait 5000 --viewport 1440x900
*
* Flags:
* --url URL to capture (required)
* --output Output file path (required)
* --wait Extra wait time in ms after network idle (default: 1000)
* --viewport Viewport size as WIDTHxHEIGHT (default: 1280x800)
* --html-class Class(es) to add to <html> element (e.g., "dark")
* --remove-fixed Remove fixed/sticky positioned elements (e.g., cookie banners)
* --full-height Capture full scrollable content by resizing viewport to scrollHeight
* --title Override the page title
* --timeout Global timeout in ms (default: 60000)
* --concurrency Max concurrent resource fetches (default: 6)
* --json Output machine-readable JSON stats to stdout
*/
import puppeteer, { type Browser } from 'puppeteer';
import path from 'node:path';
import fs from 'node:fs';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface Opts {
url: string | null;
output: string | null;
wait: number;
viewport: string;
htmlClass: string | null;
removeFixed: boolean;
fullHeight: boolean;
title: string | null;
timeout: number;
concurrency: number;
json: boolean;
inlineFonts: boolean;
removeSelectors: string | null;
click: string | null;
}
interface Stats {
url: string | null;
output: string | null;
sizeBytes: number;
stylesheets: number;
images: number;
cssUrls: number;
svgImages: number;
videoPoster: number;
favicons: number;
scriptsRemoved: number;
warnings: string[];
durationMs: number;
error?: string;
}
// ---------------------------------------------------------------------------
// Argument parsing
// ---------------------------------------------------------------------------
function parseArgs(): Opts {
const args = process.argv.slice(2);
const opts: Opts = {
url: null,
output: null,
wait: 1000,
viewport: '1280x800',
htmlClass: null,
removeFixed: false,
fullHeight: false,
title: null,
timeout: 60000,
concurrency: 6,
json: false,
inlineFonts: false,
removeSelectors: null,
click: null,
};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--url':
opts.url = args[++i];
break;
case '--output':
opts.output = args[++i];
break;
case '--wait':
opts.wait = parseInt(args[++i], 10);
break;
case '--viewport':
opts.viewport = args[++i];
break;
case '--html-class':
opts.htmlClass = args[++i];
break;
case '--remove-fixed':
opts.removeFixed = true;
break;
case '--full-height':
opts.fullHeight = true;
break;
case '--title':
opts.title = args[++i];
break;
case '--timeout':
opts.timeout = parseInt(args[++i], 10);
break;
case '--concurrency':
opts.concurrency = parseInt(args[++i], 10);
break;
case '--json':
opts.json = true;
break;
case '--inline-fonts':
opts.inlineFonts = true;
break;
case '--remove-selectors':
opts.removeSelectors = args[++i];
break;
case '--click':
opts.click = args[++i];
break;
case '--help':
console.log(`
Usage: npx tsx snapshot.ts --url <URL> --output <FILE> [options]
Options:
--url URL to capture (required)
--output Output file path (required)
--wait Extra wait time in ms after network idle (default: 1000)
--viewport Viewport size as WIDTHxHEIGHT (default: 1280x800)
--html-class Class(es) to add to <html> element (e.g., "dark")
--remove-fixed Remove fixed/sticky positioned elements (cookie banners, etc.)
--full-height Resize viewport to capture full scrollable content
--title Override the page title
--timeout Global timeout in ms (default: 60000)
--concurrency Max concurrent resource fetches (default: 6)
--json Output machine-readable JSON stats
`);
process.exit(0);
default:
console.error(`Unknown argument: ${args[i]}`);
process.exit(1);
}
}
return opts;
}
// ---------------------------------------------------------------------------
// Input validation
// ---------------------------------------------------------------------------
function validateOpts(opts: Opts): void {
const errors: string[] = [];
if (!opts.url) errors.push('--url is required');
if (!opts.output) errors.push('--output is required');
if (opts.url) {
try {
new URL(opts.url);
} catch {
errors.push(
`Invalid URL: "${opts.url}". Must be a valid URL (e.g., http://localhost:5173)`,
);
}
}
if (opts.viewport) {
const vpMatch = opts.viewport.match(/^(\d+)x(\d+)$/);
if (!vpMatch) {
errors.push(
`Invalid viewport: "${opts.viewport}". Must be WIDTHxHEIGHT (e.g., 1280x800)`,
);
} else {
const w = Number(vpMatch[1]);
const h = Number(vpMatch[2]);
if (w < 1 || h < 1) {
errors.push('Viewport dimensions must be positive integers');
}
if (w > 7680 || h > 4320) {
errors.push('Viewport too large: max 7680x4320');
}
}
}
if (isNaN(opts.wait) || opts.wait < 0) {
errors.push('--wait must be a non-negative integer');
}
if (isNaN(opts.timeout) || opts.timeout < 1000) {
errors.push('--timeout must be at least 1000ms');
}
if (isNaN(opts.concurrency) || opts.concurrency < 1 || opts.concurrency > 20) {
errors.push('--concurrency must be between 1 and 20');
}
if (opts.output) {
const outputDir = path.dirname(path.resolve(opts.output));
try {
fs.mkdirSync(outputDir, { recursive: true });
fs.accessSync(outputDir, fs.constants.W_OK);
} catch (e: unknown) {
errors.push(`Cannot write to output directory: ${(e as Error).message}`);
}
}
if (errors.length > 0) {
console.error('❌ Validation errors:');
errors.forEach((e) => console.error(`${e}`));
process.exit(1);
}
}
// ---------------------------------------------------------------------------
// Main snapshot logic
// ---------------------------------------------------------------------------
async function snapshot(opts: Opts): Promise<void> {
const [, widthStr, heightStr] = opts.viewport.match(/^(\d+)x(\d+)$/)!;
const width = Number(widthStr);
const height = Number(heightStr);
let browser: Browser | undefined;
let globalTimer: ReturnType<typeof setTimeout> | undefined;
// Stats tracking
const stats: Stats = {
url: opts.url,
output: null,
sizeBytes: 0,
stylesheets: 0,
images: 0,
cssUrls: 0,
svgImages: 0,
videoPoster: 0,
favicons: 0,
scriptsRemoved: 0,
warnings: [],
durationMs: 0,
};
const startTime = Date.now();
try {
// Global timeout safety net — prevents zombie browser processes
globalTimer = setTimeout(() => {
const msg = `Global timeout of ${opts.timeout}ms exceeded — aborting`;
console.error(`${msg}`);
stats.warnings.push(msg);
if (browser) browser.close().catch(() => {});
if (opts.json) {
stats.durationMs = Date.now() - startTime;
stats.error = msg;
console.log(JSON.stringify(stats, null, 2));
}
process.exit(2);
}, opts.timeout);
// ----- Launch browser -----
console.log('🚀 Launching browser...');
browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
`--window-size=${width},${height}`,
],
});
const page = await browser.newPage();
await page.setViewport({ width, height });
// Forward browser console logs to Node.js
page.on('console', (msg) => {
const type = msg.type().toString();
if (type === 'warning' || type === 'error') {
console.log(` [Browser ${type.toUpperCase()}] ${msg.text()}`);
}
});
// ----- Navigate and wait for network idle -----
console.log(`📄 Navigating to ${opts.url}...`);
try {
await page.goto(opts.url!, {
waitUntil: 'networkidle0',
timeout: Math.min(30000, opts.timeout - 5000),
});
} catch {
const msg = 'networkidle0 timed out, falling back to networkidle2';
console.warn(`⚠️ ${msg}...`);
stats.warnings.push(msg);
await page.goto(opts.url!, {
waitUntil: 'networkidle2',
timeout: Math.min(30000, opts.timeout - 5000),
});
}
// Extra wait for JS-rendered content (animations, lazy loading, etc.)
if (opts.wait > 0) {
console.log(`⏳ Waiting ${opts.wait}ms for rendering to settle...`);
await new Promise((r) => setTimeout(r, opts.wait));
}
// Perform click interaction if specified
if (opts.click) {
console.log(`🖱️ Clicking element "${opts.click}"...`);
try {
let element = await page.$(opts.click);
if (!element) {
// Search in child frames recursively!
for (const frame of page.frames()) {
const childElement = await frame.$(opts.click);
if (childElement) {
element = childElement;
console.log(` Found element inside child frame: ${frame.url()}`);
break;
}
}
}
if (element) {
await element.click();
// wait an extra 2 seconds for animation or modal loading to settle
console.log(` Click succeeded! Waiting 2000ms for click action to settle...`);
await new Promise((r) => setTimeout(r, 2000));
} else {
throw new Error(`Selector "${opts.click}" not found in main document or child frames.`);
}
} catch (clickErr: any) {
console.error(`⚠️ Click action failed:`, clickErr);
stats.warnings.push(`Click action failed: ${clickErr.message || clickErr}`);
}
}
// ----- Pre-processing options -----
// Add class to <html> (e.g., dark mode)
if (opts.htmlClass) {
console.log(`🎨 Adding class "${opts.htmlClass}" to <html>...`);
await page.evaluate((cls: string) => {
document.documentElement.classList.add(...cls.split(/\s+/));
if (cls.includes('dark')) {
document.documentElement.setAttribute('data-theme', 'dark');
} else if (cls.includes('light')) {
document.documentElement.setAttribute('data-theme', 'light');
}
}, opts.htmlClass);
await new Promise((r) => setTimeout(r, 500));
}
// Remove fixed/sticky elements
if (opts.removeFixed) {
console.log('🧹 Removing fixed/sticky positioned elements...');
await page.evaluate(() => {
const all = document.querySelectorAll('*');
for (const el of all) {
const style = getComputedStyle(el);
if (style.position === 'fixed' || style.position === 'sticky') {
const rect = el.getBoundingClientRect();
if (rect.top > 100 || rect.height < 50) {
el.remove();
}
}
}
});
}
// Remove custom selectors
if (opts.removeSelectors) {
console.log(`🧹 Removing custom selectors: "${opts.removeSelectors}"...`);
await page.evaluate((selectors: string) => {
const items = selectors.split(',').map((s) => s.trim()).filter(Boolean);
for (const selector of items) {
try {
document.querySelectorAll(selector).forEach((el) => el.remove());
} catch (e) {
console.warn(`Invalid selector "${selector}":`, e);
}
}
}, opts.removeSelectors);
}
// Override title
if (opts.title) {
await page.evaluate((t: string) => {
document.title = t;
}, opts.title);
}
// ----- Inject shared browser-side helpers (deduplication) -----
// Mock __name to prevent esbuild generated code from failing in browser
await page.evaluate(() => {
(window as any).__name = (fn: any, name: string) => fn;
});
await page.evaluate((concurrency: number) => {
(window as any).__snapshot = {
CONCURRENCY: concurrency,
toDataUri: async (url: string): Promise<string | null> => {
try {
const resp = await fetch(url, {
mode: 'cors',
credentials: 'same-origin',
});
if (!resp.ok) return null;
const blob = await resp.blob();
return new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = () => resolve(null);
reader.readAsDataURL(blob);
});
} catch {
return null;
}
},
processInBatches: async <T, R>(
items: T[],
batchSize: number,
fn: (item: T) => Promise<R>,
): Promise<(R | null)[]> => {
const results: (R | null)[] = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const batchResults = await Promise.allSettled(batch.map(fn));
results.push(
...batchResults.map((r) =>
r.status === 'fulfilled' ? r.value : null,
),
);
}
return results;
},
/**
* Robust CSS url() parser — character-by-character parsing instead of regex.
* Handles: quoted/unquoted values, escaped characters, whitespace,
* data URIs, and malformed url() tokens.
*
* Returns: Array of { url, fullMatch, start, end }
*/
extractCssUrls: (cssText: string) => {
const results: Array<{ url: string; fullMatch: string; start: number; end: number }> = [];
let i = 0;
const len = cssText.length;
while (i < len) {
// Look for 'url(' — case insensitive
if (
i + 3 < len &&
cssText[i].toLowerCase() === 'u' &&
cssText[i + 1].toLowerCase() === 'r' &&
cssText[i + 2].toLowerCase() === 'l' &&
cssText[i + 3] === '('
) {
const urlStart = i;
i += 4; // skip 'url('
// Skip whitespace
while (
i < len &&
(cssText[i] === ' ' ||
cssText[i] === '\t' ||
cssText[i] === '\n' ||
cssText[i] === '\r')
) {
i++;
}
// Check for quote
let quote: string | null = null;
if (i < len && (cssText[i] === '"' || cssText[i] === "'")) {
quote = cssText[i];
i++;
}
// Read the URL value
let url = '';
if (quote) {
// Quoted: read until matching unescaped quote
while (i < len && cssText[i] !== quote) {
if (cssText[i] === '\\' && i + 1 < len) {
i++; // skip backslash
url += cssText[i]; // include next char literally
} else {
url += cssText[i];
}
i++;
}
if (i < len) i++; // skip closing quote
} else {
// Unquoted: stop at ) or whitespace (per CSS spec)
while (
i < len &&
cssText[i] !== ')' &&
cssText[i] !== ' ' &&
cssText[i] !== '\t' &&
cssText[i] !== '\n' &&
cssText[i] !== '\r'
) {
url += cssText[i];
i++;
}
}
// Skip trailing whitespace before ')'
while (
i < len &&
(cssText[i] === ' ' ||
cssText[i] === '\t' ||
cssText[i] === '\n' ||
cssText[i] === '\r')
) {
i++;
}
if (i < len && cssText[i] === ')') {
const fullMatch = cssText.substring(urlStart, i + 1);
results.push({
url: url.trim(),
fullMatch,
start: urlStart,
end: i + 1,
});
i++;
} else {
// Malformed url() — skip past 'url(' and try again
i = urlStart + 1;
}
} else {
i++;
}
}
return results;
},
/**
* Replace CSS url() references using pre-computed positions.
* Replaces from end-to-start to preserve earlier indices.
*/
replaceCssUrls: (
cssText: string,
replacements: Array<{ start: number; end: number; dataUri: string }>,
): string => {
const sorted = [...replacements].sort((a, b) => b.start - a.start);
for (const r of sorted) {
cssText =
cssText.substring(0, r.start) +
"url('" +
r.dataUri +
"')" +
cssText.substring(r.end);
}
return cssText;
},
};
}, opts.concurrency);
// -----------------------------------------------------------------------
// -1. Inline local iframes (e.g., companion-app test iframe)
// -----------------------------------------------------------------------
const iframesCount = await page.evaluate(() => document.querySelectorAll('iframe').length);
if (iframesCount > 0) {
console.log(`🔍 Found ${iframesCount} iframe(s) in the main page. Extracting content natively...`);
// First, recursively inline all same-origin and srcDoc iframes browser-side
console.log('🔍 Inlining same-origin and srcDoc iframes recursively...');
await page.evaluate(() => {
const inlineSameOriginIframes = (root: Document | HTMLElement) => {
const iframes = Array.from(root.querySelectorAll('iframe'));
for (const iframe of iframes) {
try {
const doc = iframe.contentDocument || iframe.contentWindow?.document;
if (doc && doc.body) {
// Recursively inline same-origin iframes inside this child frame first
inlineSameOriginIframes(doc);
const bodyHtml = doc.body.innerHTML;
const styles: string[] = [];
doc.querySelectorAll('style').forEach(s => styles.push(s.outerHTML));
doc.querySelectorAll('link[rel="stylesheet"]').forEach(l => styles.push((l as HTMLLinkElement).outerHTML));
styles.forEach(styleHtml => {
const temp = document.createElement('div');
temp.innerHTML = styleHtml;
document.head.appendChild(temp.firstChild!);
});
const wrapper = document.createElement('div');
wrapper.className = 'ac-iframe-inlined-wrapper';
// Apply child body's classes and attributes to same-origin wrapper
for (const attr of Array.from(doc.body.attributes)) {
if (attr.name === 'class') {
wrapper.classList.add(...attr.value.split(/\s+/).filter(Boolean));
} else if (attr.name !== 'style') {
wrapper.setAttribute(attr.name, attr.value);
}
}
wrapper.style.position = 'absolute';
wrapper.style.top = '0';
wrapper.style.left = '0';
wrapper.style.width = '100%';
wrapper.style.height = '100%';
wrapper.style.overflow = 'hidden';
wrapper.innerHTML = bodyHtml;
iframe.parentNode!.replaceChild(wrapper, iframe);
}
} catch (e) {
// Ignore cross-origin iframes; the Puppeteer frame loop will process them
}
}
};
inlineSameOriginIframes(document);
});
const childFrames = page.frames()
.filter(f => f !== page.mainFrame())
.map(f => {
let depth = 0;
let p = f.parentFrame();
while (p) {
depth++;
p = p.parentFrame();
}
return { frame: f, depth };
})
.sort((a, b) => b.depth - a.depth);
for (const { frame } of childFrames) {
try {
const frameUrl = frame.url();
const cleanUrl = frameUrl.split('?')[0].split('#')[0];
console.log(`📦 Extracting frame content from: ${cleanUrl} (depth: ${frame.parentFrame() ? 'nested' : 'root'})`);
// Inject __name mock to prevent esbuild helper ReferenceError in child frame
await frame.evaluate(() => {
(window as any).__name = (fn: any) => fn;
});
// Resolve all relative assets inside the frame to absolute URLs relative to the frame's URL
await frame.evaluate((base) => {
const resolveAttr = (el: Element, attr: string) => {
const val = el.getAttribute(attr);
if (val && !val.startsWith('data:') && !val.startsWith('http:') && !val.startsWith('https:') && !val.startsWith('//')) {
try {
const abs = new URL(val, base).href;
el.setAttribute(attr, abs);
} catch (e) { }
}
};
document.querySelectorAll('img[src]').forEach(img => resolveAttr(img, 'src'));
document.querySelectorAll('img[srcset]').forEach(img => resolveAttr(img, 'srcset'));
document.querySelectorAll('source[srcset]').forEach(src => resolveAttr(src, 'srcset'));
document.querySelectorAll('link[rel="stylesheet"]').forEach(link => resolveAttr(link, 'href'));
// Resolve relative url() references in inline <style> tags
document.querySelectorAll('style').forEach((styleEl) => {
if (styleEl.textContent) {
styleEl.textContent = styleEl.textContent.replace(/url\(['"]?([^'")\s]+)['"]?\)/gi, (match, url) => {
if (
url.startsWith('data:') ||
url.startsWith('http:') ||
url.startsWith('https:') ||
url.startsWith('//')
) {
return match;
}
try {
return `url('${new URL(url, base).href}')`;
} catch {
return match;
}
});
}
});
}, frameUrl);
const frameStyles = await frame.evaluate(() => {
const stylesList: string[] = [];
document.querySelectorAll('style').forEach(s => stylesList.push(s.outerHTML));
document.querySelectorAll('link[rel="stylesheet"]').forEach(l => stylesList.push((l as HTMLLinkElement).outerHTML));
return stylesList;
});
const frameBodyHtml = await frame.evaluate(() => document.body.innerHTML);
const frameBodyAttrs = await frame.evaluate(() => {
const attrs: Record<string, string> = {};
for (const attr of Array.from(document.body.attributes)) {
attrs[attr.name] = attr.value;
}
return attrs;
});
const frameHtmlAttrs = await frame.evaluate(() => {
const attrs: Record<string, string> = {};
for (const attr of Array.from(document.documentElement.attributes)) {
attrs[attr.name] = attr.value;
}
return attrs;
});
const parent = frame.parentFrame();
if (parent) {
await parent.evaluate((url, bodyHtml, styles, bodyAttrs, htmlAttrs) => {
styles.forEach(styleHtml => {
const temp = document.createElement('div');
temp.innerHTML = styleHtml;
document.head.appendChild(temp.firstChild!);
});
// Apply child html attributes to parent documentElement (e.g., data-theme)
for (const [name, val] of Object.entries(htmlAttrs)) {
if (name !== 'style') {
document.documentElement.setAttribute(name, val);
}
}
const iframes = Array.from(document.querySelectorAll('iframe'));
for (const iframe of iframes) {
const cleanIframeSrc = iframe.src.split('?')[0].split('#')[0];
if (cleanIframeSrc && (url.includes(cleanIframeSrc) || cleanIframeSrc.includes(url))) {
const wrapper = document.createElement('div');
wrapper.className = 'ac-iframe-inlined-wrapper';
// Apply child body's classes and attributes to the wrapper
for (const [name, val] of Object.entries(bodyAttrs)) {
if (name === 'class') {
wrapper.classList.add(...val.split(/\s+/).filter(Boolean));
} else if (name !== 'style') {
wrapper.setAttribute(name, val);
}
}
wrapper.style.position = 'absolute';
wrapper.style.top = '0';
wrapper.style.left = '0';
wrapper.style.width = '100%';
wrapper.style.height = '100%';
wrapper.style.overflow = 'hidden';
wrapper.innerHTML = bodyHtml;
iframe.parentNode!.replaceChild(wrapper, iframe);
break;
}
}
}, cleanUrl, frameBodyHtml, frameStyles, frameBodyAttrs, frameHtmlAttrs);
}
} catch (frameErr) {
console.warn('Failed to extract child frame content:', frameErr);
}
}
}
// Resize viewport to full scroll height (executed after iframe contents are natively merged)
if (opts.fullHeight) {
console.log('📐 Scanning DOM for maximum scrollable container height...');
const maxScrollHeight = await page.evaluate(() => {
let maxVal = document.documentElement.scrollHeight;
const all = document.querySelectorAll('*');
for (const el of all) {
const style = getComputedStyle(el);
if (style.overflow === 'auto' || style.overflowY === 'auto' || style.overflow === 'scroll' || style.overflowY === 'scroll') {
if (el.scrollHeight > maxVal) {
maxVal = el.scrollHeight;
}
}
}
return maxVal;
});
// Resize viewport to the true maximum scroll height (plus 120px buffer for safety)
const finalViewportHeight = maxScrollHeight + 120;
console.log(`📐 Resizing viewport to maximum content height: ${finalViewportHeight}px`);
await page.setViewport({ width, height: finalViewportHeight });
// Force layout wrappers and scrollable containers to unlock their heights
await page.evaluate(() => {
document.documentElement.style.setProperty('height', 'auto', 'important');
document.documentElement.style.setProperty('overflow', 'visible', 'important');
document.body.style.setProperty('height', 'auto', 'important');
document.body.style.setProperty('overflow', 'visible', 'important');
const elements = document.querySelectorAll('*');
for (const el of elements) {
const style = getComputedStyle(el);
const hasViewportHeight = style.height.includes('vh') ||
style.height.includes('svh') ||
style.height === '100%' ||
style.height === '100vh' ||
style.height === '100svh' ||
style.maxHeight.includes('vh') ||
style.maxHeight.includes('svh') ||
style.maxHeight === '100%' ||
el.classList.contains('h-svh') ||
el.classList.contains('h-screen') ||
el.classList.contains('ac-iframe-inlined-wrapper') ||
el.classList.contains('ac-iframe');
if (hasViewportHeight) {
(el as HTMLElement).style.setProperty('height', 'auto', 'important');
(el as HTMLElement).style.setProperty('min-height', '0', 'important');
(el as HTMLElement).style.setProperty('max-height', 'none', 'important');
}
if (style.overflow === 'auto' || style.overflowY === 'auto' || style.overflow === 'scroll' || style.overflowY === 'scroll') {
(el as HTMLElement).style.setProperty('height', 'auto', 'important');
(el as HTMLElement).style.setProperty('max-height', 'none', 'important');
(el as HTMLElement).style.setProperty('overflow', 'visible', 'important');
(el as HTMLElement).style.setProperty('position', 'relative', 'important');
}
}
});
await new Promise((r) => setTimeout(r, 1000));
}
// -----------------------------------------------------------------------
// 0. Materialize CSS-in-JS styles into DOM
// -----------------------------------------------------------------------
// CSS-in-JS libraries (Emotion, styled-components, MUI) inject styles
// directly into the CSSOM via insertRule(), leaving <style> tags with
// empty textContent. When we serialize the DOM these rules are lost.
// This step writes the CSSOM rules back into the <style> element's
// textContent so they survive outerHTML serialization.
//
// We only touch <style> tags whose textContent is empty — normal
// stylesheets and text-based <style> blocks are left untouched to
// avoid duplication and preserve cascade order.
console.log('🎨 Materializing CSS-in-JS styles into DOM...');
const cssInJsCount = await page.evaluate(() => {
let count = 0;
const styleElements = document.querySelectorAll('style');
for (const styleEl of styleElements) {
// Skip styles that already have text content (not CSS-in-JS)
if (styleEl.textContent && styleEl.textContent.trim().length > 0) continue;
try {
const sheet = styleEl.sheet;
if (!sheet?.cssRules) continue;
let cssText = '';
for (let j = 0; j < sheet.cssRules.length; j++) {
cssText += sheet.cssRules[j].cssText + '\n';
}
if (cssText.length > 0) {
// Write the rules as text content so they survive DOM serialization
styleEl.textContent = cssText;
count++;
}
} catch (e) {
// Cross-origin or security error, ignore
}
}
return count;
});
console.log(` ✅ Materialized ${cssInJsCount} CSS-in-JS style blocks`);
// -----------------------------------------------------------------------
// 1. Inline all external stylesheets as <style> blocks
// -----------------------------------------------------------------------
console.log('🎨 Inlining external stylesheets...');
stats.stylesheets = await page.evaluate(async () => {
const { toDataUri, extractCssUrls, replaceCssUrls } = (window as any).__snapshot;
let count = 0;
const links = Array.from(
document.querySelectorAll('link[rel="stylesheet"]'),
) as HTMLLinkElement[];
for (const link of links) {
try {
const href = link.href;
if (!href) continue;
const resp = await fetch(href);
if (!resp.ok) continue;
let cssText = await resp.text();
// Resolve relative url() references to absolute URLs using the parser
const baseUrl = new URL(href);
const urlRefs = extractCssUrls(cssText);
const replacements: Array<{ start: number; end: number; dataUri: string }> = [];
for (const ref of urlRefs) {
// Skip absolute URLs, data URIs, and protocol-relative URLs
if (
ref.url.startsWith('data:') ||
ref.url.startsWith('http:') ||
ref.url.startsWith('https:') ||
ref.url.startsWith('//')
) {
continue;
}
try {
const absUrl = new URL(ref.url, baseUrl).href;
replacements.push({
start: ref.start,
end: ref.end,
dataUri: absUrl, // Not a data URI yet — just resolved to absolute
});
} catch {
// Malformed URL — skip
}
}
if (replacements.length > 0) {
cssText = replaceCssUrls(cssText, replacements);
}
const style = document.createElement('style');
style.textContent = cssText;
if (link.media && link.media !== 'all') {
style.setAttribute('media', link.media);
}
link.parentNode!.replaceChild(style, link);
count++;
} catch (e) {
console.warn(`Failed to inline stylesheet: ${link.href}`, e);
}
}
return count;
});
console.log(` ✅ Inlined ${stats.stylesheets} stylesheets`);
// -----------------------------------------------------------------------
// 2. Inline images with concurrency (img, srcset, source, background-image)
// -----------------------------------------------------------------------
console.log('🖼️ Inlining images as base64...');
stats.images = await page.evaluate(async () => {
const { toDataUri, processInBatches, CONCURRENCY } = (window as any).__snapshot;
let count = 0;
// --- <img src="..."> ---
const images = Array.from(document.querySelectorAll('img[src]')) as HTMLImageElement[];
await processInBatches(images, CONCURRENCY, async (img: HTMLImageElement) => {
const src = img.src;
if (!src || src.startsWith('data:')) return;
const dataUri = await toDataUri(src);
if (dataUri) {
img.setAttribute('src', dataUri);
count++;
}
});
// --- <img srcset="..."> ---
const imgsWithSrcset = Array.from(
document.querySelectorAll('img[srcset]'),
) as HTMLImageElement[];
for (const img of imgsWithSrcset) {
const srcset = img.getAttribute('srcset');
if (!srcset) continue;
const parts = srcset.split(',').map((s: string) => s.trim());
const newParts: string[] = [];
await processInBatches(parts, CONCURRENCY, async (part: string) => {
const [url, ...descriptors] = part.split(/\s+/);
if (url.startsWith('data:')) {
newParts.push(part);
return;
}
const dataUri = await toDataUri(url);
if (dataUri) {
newParts.push([dataUri, ...descriptors].join(' '));
count++;
}
// If fetch fails, drop — inlined src is the fallback
});
if (newParts.length > 0) {
img.setAttribute('srcset', newParts.join(', '));
} else {
img.removeAttribute('srcset');
}
}
// --- <source srcset="..."> ---
const sources = Array.from(
document.querySelectorAll('source[srcset]'),
) as HTMLSourceElement[];
for (const source of sources) {
const srcset = source.getAttribute('srcset');
if (!srcset || srcset.startsWith('data:')) continue;
const parts = srcset.split(',').map((s: string) => s.trim());
const newParts: string[] = [];
await processInBatches(parts, CONCURRENCY, async (part: string) => {
const [url, ...descriptors] = part.split(/\s+/);
if (url.startsWith('data:')) {
newParts.push(part);
return;
}
const dataUri = await toDataUri(url);
if (dataUri) {
newParts.push([dataUri, ...descriptors].join(' '));
count++;
}
});
if (newParts.length > 0) {
source.setAttribute('srcset', newParts.join(', '));
} else {
source.remove();
}
}
// --- Inline ALL background-image url() in inline styles (handles multiple) ---
const styledElements = Array.from(
document.querySelectorAll('[style]'),
) as HTMLElement[];
for (const el of styledElements) {
const style = el.getAttribute('style');
if (!style || !style.includes('url(')) continue;
// Use matchAll to handle multiple url() references
const urlPattern =
/url\(['"]?(https?:\/\/[^'"\)\s]+)['"]?\)/g;
const matches = [...style.matchAll(urlPattern)];
if (matches.length === 0) continue;
let newStyle = style;
// Process in reverse order to preserve string positions
for (let i = matches.length - 1; i >= 0; i--) {
const m = matches[i];
const dataUri = await toDataUri(m[1]);
if (dataUri) {
newStyle =
newStyle.substring(0, m.index!) +
"url('" +
dataUri +
"')" +
newStyle.substring(m.index! + m[0].length);
count++;
}
}
el.setAttribute('style', newStyle);
}
return count;
});
console.log(` ✅ Inlined ${stats.images} images`);
// -----------------------------------------------------------------------
// 3. Inline CSS url() references in <style> blocks (using parser)
// -----------------------------------------------------------------------
console.log('🔗 Inlining CSS url() references in <style> blocks...');
stats.cssUrls = await page.evaluate(async (inlineFonts) => {
const {
toDataUri,
processInBatches,
extractCssUrls,
replaceCssUrls,
CONCURRENCY,
} = (window as any).__snapshot;
let count = 0;
/** Check if a URL points to a font file (skip — too large, not visual) */
const isFontFile = (url: string): boolean => {
return /\.(woff2?|ttf|eot|otf)(\?|$)/i.test(url);
}
const styles = Array.from(document.querySelectorAll('style')) as HTMLStyleElement[];
for (const styleEl of styles) {
let css = styleEl.textContent!;
const urlRefs = extractCssUrls(css);
// Filter to only http(s) URLs that aren't fonts
const toInline = urlRefs.filter(
(ref: any) =>
(ref.url.startsWith('http://') ||
ref.url.startsWith('https://')) &&
(!isFontFile(ref.url) || inlineFonts),
);
if (toInline.length === 0) continue;
// Fetch all URLs concurrently
const fetched: Array<{ start: number; end: number; dataUri: string }> = [];
await processInBatches(toInline, CONCURRENCY, async (ref: any) => {
const dataUri = await toDataUri(ref.url);
if (dataUri) {
fetched.push({ start: ref.start, end: ref.end, dataUri });
count++;
}
});
if (fetched.length > 0) {
css = replaceCssUrls(css, fetched);
styleEl.textContent = css;
}
}
return count;
}, opts.inlineFonts);
console.log(` ✅ Inlined ${stats.cssUrls} CSS url() references`);
// -----------------------------------------------------------------------
// 4. Inline additional resource types (SVG, video, favicons)
// -----------------------------------------------------------------------
console.log('🔗 Inlining additional resources (SVG, video, favicons)...');
const additionalStats = await page.evaluate(async () => {
const { toDataUri, processInBatches, CONCURRENCY } = (window as any).__snapshot;
const stats = { svgImages: 0, videoPoster: 0, favicons: 0 };
// --- SVG <image href="..."> and <image xlink:href="..."> ---
const svgImages = Array.from(document.querySelectorAll('image')) as SVGImageElement[];
await processInBatches(svgImages, CONCURRENCY, async (img: SVGImageElement) => {
// Check both href and xlink:href
const href =
img.getAttribute('href') ||
img.getAttributeNS('http://www.w3.org/1999/xlink', 'href');
if (!href || href.startsWith('data:')) return;
const dataUri = await toDataUri(href);
if (dataUri) {
// Set both for compatibility
if (img.hasAttribute('href')) img.setAttribute('href', dataUri);
if (
img.hasAttributeNS('http://www.w3.org/1999/xlink', 'href')
) {
img.setAttributeNS(
'http://www.w3.org/1999/xlink',
'href',
dataUri,
);
}
stats.svgImages++;
}
});
// --- <video poster="..."> ---
const videos = Array.from(
document.querySelectorAll('video[poster]'),
) as HTMLVideoElement[];
await processInBatches(videos, CONCURRENCY, async (video: HTMLVideoElement) => {
const poster = video.getAttribute('poster');
if (!poster || poster.startsWith('data:')) return;
const dataUri = await toDataUri(poster);
if (dataUri) {
video.setAttribute('poster', dataUri);
stats.videoPoster++;
}
});
// --- <link rel="icon"> and <link rel="apple-touch-icon"> favicons ---
const favicons = Array.from(
document.querySelectorAll(
'link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]',
),
) as HTMLLinkElement[];
await processInBatches(favicons, CONCURRENCY, async (link: HTMLLinkElement) => {
const href = link.href;
if (!href || href.startsWith('data:')) return;
const dataUri = await toDataUri(href);
if (dataUri) {
link.setAttribute('href', dataUri);
stats.favicons++;
}
});
// --- <object data="..."> ---
const objects = Array.from(
document.querySelectorAll('object[data]'),
) as HTMLObjectElement[];
await processInBatches(objects, CONCURRENCY, async (obj: HTMLObjectElement) => {
const data = obj.getAttribute('data');
if (!data || data.startsWith('data:')) return;
// Only inline small objects (SVGs, etc.) — skip large ones
try {
const resp = await fetch(data);
if (!resp.ok) return;
const contentLength = resp.headers.get('content-length');
if (contentLength && Number(contentLength) > 500000) return; // Skip >500KB
const blob = await resp.blob();
const dataUri: string | null = await new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = () => resolve(null);
reader.readAsDataURL(blob);
});
if (dataUri) {
obj.setAttribute('data', dataUri);
}
} catch {
// Skip failed objects
}
});
return stats;
});
stats.svgImages = additionalStats.svgImages;
stats.videoPoster = additionalStats.videoPoster;
stats.favicons = additionalStats.favicons;
const additionalTotal =
stats.svgImages + stats.videoPoster + stats.favicons;
console.log(
` ✅ Inlined ${additionalTotal} additional resources ` +
`(${stats.svgImages} SVG, ${stats.videoPoster} video posters, ${stats.favicons} favicons)`,
);
// -----------------------------------------------------------------------
// 5. Remove all <script> tags and dev-tool overlays
// -----------------------------------------------------------------------
console.log('🗑️ Removing <script> tags and dev overlays...');
stats.scriptsRemoved = await page.evaluate(() => {
// Remove all scripts
const scripts = Array.from(document.querySelectorAll('script'));
scripts.forEach((s) => s.remove());
// Remove framework-specific dev overlays
const devSelectors = [
// Vite
'vite-error-overlay',
// Next.js
'[data-nextjs-dialog-overlay]',
'nextjs-portal',
// Webpack/CRA
'#webpack-dev-server-client-overlay',
'#webpack-dev-server-client-overlay-div',
// Parcel
'[data-parcel-error-overlay]',
// Nuxt
'[data-v-inspector]',
];
for (const selector of devSelectors) {
document
.querySelectorAll(selector)
.forEach((el) => el.remove());
}
// Remove noscript tags
document.querySelectorAll('noscript').forEach((el) => el.remove());
return scripts.length;
});
console.log(` ✅ Removed ${stats.scriptsRemoved} scripts`);
// -----------------------------------------------------------------------
// 6. Clean up injected helpers
// -----------------------------------------------------------------------
await page.evaluate(() => {
delete (window as any).__snapshot;
});
// -----------------------------------------------------------------------
// 7. Extract the final HTML and write output
// -----------------------------------------------------------------------
console.log('📦 Extracting final HTML...');
const html = await page.evaluate(
() => '<!DOCTYPE html>\n' + document.documentElement.outerHTML,
);
// Write output
const outputPath = path.resolve(opts.output!);
const outputDir = path.dirname(outputPath);
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(outputPath, html, 'utf-8');
stats.output = outputPath;
stats.sizeBytes = Buffer.byteLength(html);
stats.durationMs = Date.now() - startTime;
const sizeKB = (stats.sizeBytes / 1024).toFixed(1);
console.log(`\n✅ Snapshot saved to ${outputPath} (${sizeKB} KB)`);
console.log(
` ${stats.stylesheets} stylesheets, ${stats.images} images, ${stats.cssUrls} CSS urls inlined`,
);
console.log(
` ${additionalTotal} additional resources (SVG/video/favicon)`,
);
console.log(` ${stats.scriptsRemoved} scripts removed`);
console.log(` Completed in ${stats.durationMs}ms`);
if (stats.warnings.length > 0) {
console.log(`\n⚠ ${stats.warnings.length} warning(s):`);
stats.warnings.forEach((w) => console.log(`${w}`));
}
// JSON output for CI/CD integration
if (opts.json) {
console.log('\n--- JSON Stats ---');
console.log(JSON.stringify(stats, null, 2));
}
} finally {
// Guaranteed browser cleanup — prevents zombie Chrome processes
if (globalTimer) clearTimeout(globalTimer);
if (browser) {
try {
await browser.close();
} catch {
// Browser may already be closed by timeout handler
}
}
}
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
const opts = parseArgs();
validateOpts(opts);
snapshot(opts).catch((err: Error) => {
console.error('❌ Snapshot failed:', err.message);
if (err.stack) console.error(err.stack);
process.exit(1);
});