1329 lines
48 KiB
TypeScript
1329 lines
48 KiB
TypeScript
#!/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);
|
||
}); |