Setting the file. One moment. Snapshot · Stitch::extract Static HTML · google-labs-code/stitch-skills · Skills Docsscripts/snapshot.ts
scripts/snapshot.ts
TypeScript·1,470 lines·53 KB
15 * Flags:
16 * --url URL to capture (required)
17 * --output Output file path (required)
18 * --wait Extra wait time in ms after network idle (default: 1000)
19 * --viewport Viewport size as WIDTHxHEIGHT (default: 1280x800)
20 * --html-class Class(es) to add to <html> element (e.g., "dark")
21 * --remove-fixed Remove fixed/sticky positioned elements (e.g., cookie banners)
22 * --full-height Capture full scrollable content by resizing viewport to scrollHeight
23 * --title Override the page title
24 * --timeout Global timeout in ms (default: 60000)
25 * --concurrency Max concurrent resource fetches (default: 6)
26 * --json Output machine-readable JSON stats to stdout
27 */
28
29import puppeteer, { type Browser } from 'puppeteer';
30import path from 'node:path';
31import fs from 'node:fs';
32
33// ---------------------------------------------------------------------------
34// Types
35// ---------------------------------------------------------------------------
36interface Opts {
37 url: string | null;
38 output: string | null;
39 wait: number;
40 viewport: string;
41 htmlClass: string | null;
42 removeFixed: boolean;
43 fullHeight: boolean;
44 title: string | null;
45 authScript: string | null;
46 inlineCanvas: boolean;
47 timeout: number;
48 concurrency: number;
49 json: boolean;
50 inlineFonts: boolean;
51 removeSelectors: string | null;
52 click: string | null;
53}
54
55interface Stats {
56 url: string | null;
57 output: string | null;
58 sizeBytes: number;
59 stylesheets: number;
60 images: number;
61 cssUrls: number;
62 svgImages: number;
63 videoPoster: number;
64 favicons: number;
65 scriptsRemoved: number;
66 warnings: string[];
67 durationMs: number;
68 error?: string;
69}
70
71// ---------------------------------------------------------------------------
72// Argument parsing
73// ---------------------------------------------------------------------------
74function parseArgs(): Opts {
75 const args = process.argv.slice(2);
76 const opts: Opts = {
77 url: null,
78 output: null,
79 wait: 1000,
80 viewport: '1280x800',
81 htmlClass: null,
82 removeFixed: false,
83 fullHeight: false,
84 title: null,
85 authScript: null,
86 inlineCanvas: false,
87 timeout: 60000,
88 concurrency: 6,
89 json: false,
90 inlineFonts: false,
91 removeSelectors: null,
92 click: null,
93 };
94
95 for (let i = 0; i < args.length; i++) {
96 switch (args[i]) {
97 case '--url':
98 opts.url = args[++i];
99 break;
100 case '--output':
101 opts.output = args[++i];
102 break;
103 case '--wait':
104 opts.wait = parseInt(args[++i], 10);
105 break;
106 case '--viewport':
107 opts.viewport = args[++i];
108 break;
109 case '--html-class':
110 opts.htmlClass = args[++i];
111 break;
112 case '--remove-fixed':
113 opts.removeFixed = true;
114 break;
115 case '--full-height':
116 opts.fullHeight = true;
117 break;
118 case '--title':
119 opts.title = args[++i];
120 break;
121 case '--auth-script':
122 opts.authScript = args[++i];
123 break;
124 case '--inline-canvas':
125 opts.inlineCanvas = true;
126 break;
127 case '--timeout':
128 opts.timeout = parseInt(args[++i], 10);
129 break;
130 case '--concurrency':
131 opts.concurrency = parseInt(args[++i], 10);
132 break;
133 case '--json':
134 opts.json = true;
135 break;
136 case '--inline-fonts':
137 opts.inlineFonts = true;
138 break;
139 case '--remove-selectors':
140 opts.removeSelectors = args[++i];
141 break;
142 case '--click':
143 opts.click = args[++i];
144 break;
145 case '--help':
146 console.log(`
147Usage: npx tsx snapshot.ts --url <URL> --output <FILE> [options]
148
149Options:
150 --url URL to capture (required)
151 --output Output file path (required)
152 --wait Extra wait time in ms after network idle (default: 1000)
153 --viewport Viewport size as WIDTHxHEIGHT (default: 1280x800)
154 --html-class Class(es) to add to <html> element (e.g., "dark")
155 --remove-fixed Remove fixed/sticky positioned elements (cookie banners, etc.)
156 --full-height Resize viewport to capture full scrollable content
157 --title Override the page title
158 --auth-script Path to JS/TS script exporting an async function to authenticate before capture
159 --inline-canvas Convert <canvas> elements (charts, graphs) to base64 images
160 --timeout Global timeout in ms (default: 60000)
161 --concurrency Max concurrent resource fetches (default: 6)
162 --json Output machine-readable JSON stats
163`);
164 process.exit(0);
165 default:
166 console.error(`Unknown argument: ${args[i]}`);
167 process.exit(1);
168 }
169 }
170
171 return opts;
172}
173
174// ---------------------------------------------------------------------------
175// Input validation
176// ---------------------------------------------------------------------------
177function validateOpts(opts: Opts): void {
178 const errors: string[] = [];
179
180 if (!opts.url) errors.push('--url is required');
181 if (!opts.output) errors.push('--output is required');
182
183 if (opts.url) {
184 try {
185 new URL(opts.url);
186 } catch {
187 errors.push(
188 `Invalid URL: "${opts.url}". Must be a valid URL (e.g., http://localhost:5173)`,
189 );
190 }
191 }
192
193 if (opts.viewport) {
194 const vpMatch = opts.viewport.match(/^(\d+)x(\d+)$/);
195 if (!vpMatch) {
196 errors.push(
197 `Invalid viewport: "${opts.viewport}". Must be WIDTHxHEIGHT (e.g., 1280x800)`,
198 );
199 } else {
200 const w = Number(vpMatch[1]);
201 const h = Number(vpMatch[2]);
202 if (w < 1 || h < 1) {
203 errors.push('Viewport dimensions must be positive integers');
204 }
205 if (w > 7680 || h > 4320) {
206 errors.push('Viewport too large: max 7680x4320');
207 }
208 }
209 }
210
211 if (isNaN(opts.wait) || opts.wait < 0) {
212 errors.push('--wait must be a non-negative integer');
213 }
214
215 if (isNaN(opts.timeout) || opts.timeout < 1000) {
216 errors.push('--timeout must be at least 1000ms');
217 }
218
219 if (isNaN(opts.concurrency) || opts.concurrency < 1 || opts.concurrency > 20) {
220 errors.push('--concurrency must be between 1 and 20');
221 }
222
223 if (opts.output) {
224 const outputDir = path.dirname(path.resolve(opts.output));
225 try {
226 fs.mkdirSync(outputDir, { recursive: true });
227 fs.accessSync(outputDir, fs.constants.W_OK);
228 } catch (e: unknown) {
229 errors.push(`Cannot write to output directory: ${(e as Error).message}`);
230 }
231 }
232
233 if (errors.length > 0) {
234 console.error('❌ Validation errors:');
235 errors.forEach((e) => console.error(` • ${e}`));
236 process.exit(1);
237 }
238}
239
240// ---------------------------------------------------------------------------
241// Main snapshot logic
242// ---------------------------------------------------------------------------
243async function snapshot(opts: Opts): Promise<void> {
244 const [, widthStr, heightStr] = opts.viewport.match(/^(\d+)x(\d+)$/)!;
245 const width = Number(widthStr);
246 const height = Number(heightStr);
247
248 let browser: Browser | undefined;
249 let globalTimer: ReturnType<typeof setTimeout> | undefined;
250
251 // Stats tracking
252 const stats: Stats = {
253 url: opts.url,
254 output: null,
255 sizeBytes: 0,
256 stylesheets: 0,
257 images: 0,
258 cssUrls: 0,
259 svgImages: 0,
260 videoPoster: 0,
261 favicons: 0,
262 scriptsRemoved: 0,
263 warnings: [],
264 durationMs: 0,
265 };
266 const startTime = Date.now();
267
268 try {
269 // Global timeout safety net — prevents zombie browser processes
270 globalTimer = setTimeout(() => {
271 const msg = `Global timeout of ${opts.timeout}ms exceeded — aborting`;
272 console.error(`⏰ ${msg}`);
273 stats.warnings.push(msg);
274 if (browser) browser.close().catch(() => {});
275 if (opts.json) {
276 stats.durationMs = Date.now() - startTime;
277 stats.error = msg;
278 console.log(JSON.stringify(stats, null, 2));
279 }
280 process.exit(2);
281 }, opts.timeout);
282
283 // ----- Launch browser -----
284 console.log('🚀 Launching browser...');
285 browser = await puppeteer.launch({
286 headless: true,
287 args: [
288 '--no-sandbox',
289 '--disable-setuid-sandbox',
290 '--disable-dev-shm-usage',
291 `--window-size=${width},${height}`,
292 ],
293 });
294
295 const page = await browser.newPage();
296 await page.setViewport({ width, height });
297
298 // Forward browser console logs to Node.js
299 page.on('console', (msg) => {
300 const type = msg.type().toString();
301 if (type === 'warning' || type === 'error') {
302 console.log(` [Browser ${type.toUpperCase()}] ${msg.text()}`);
303 }
304 });
305
306 // ----- Navigate and wait for network idle -----
307 console.log(`📄 Navigating to ${opts.url}...`);
308 try {
309 await page.goto(opts.url!, {
310 waitUntil: 'networkidle0',
311 timeout: 10000,
312 });
313 } catch {
314 const msg = 'networkidle0 timed out, falling back to networkidle2';
315 console.warn(`⚠️ ${msg}...`);
316 stats.warnings.push(msg);
317 try {
318 await page.goto(opts.url!, {
319 waitUntil: 'networkidle2',
320 timeout: 10000,
321 });
322 } catch {
323 const msg2 = 'networkidle2 timed out, falling back to domcontentloaded';
324 console.warn(`⚠️ ${msg2}...`);
325 stats.warnings.push(msg2);
326 await page.goto(opts.url!, {
327 waitUntil: 'domcontentloaded',
328 timeout: 15000,
329 });
330 }
331 }
332
333 // Extra wait for JS-rendered content (animations, lazy loading, etc.)
334 if (opts.wait > 0) {
335 console.log(`⏳ Waiting ${opts.wait}ms for rendering to settle...`);
336 await new Promise((r) => setTimeout(r, opts.wait));
337 }
338
339 // Execute authentication script if provided
340 if (opts.authScript) {
341 console.log(`🔐 Running authentication script from ${opts.authScript}...`);
342 try {
343 const path = await import('path');
344 const authPath = path.resolve(process.cwd(), opts.authScript);
345 const authModule = await import(authPath);
346 const authFn = authModule.default || authModule;
347 if (typeof authFn === 'function') {
348 await authFn(page);
349 console.log(' ✅ Auth script executed, re-navigating to target URL...');
350 await page.goto(opts.url!, {
351 waitUntil: 'networkidle2',
352 timeout: Math.min(30000, opts.timeout),
353 });
354 if (opts.wait > 0) {
355 await new Promise((r) => setTimeout(r, opts.wait));
356 }
357 } else {
358 console.warn(`⚠️ --auth-script (${opts.authScript}) did not export a function`);
359 }
360 } catch (err: any) {
361 console.warn(`⚠️ Failed to execute --auth-script: ${err.message}`);
362 stats.warnings.push(`auth-script error: ${err.message}`);
363 }
364 }
365
366 // Perform click interaction if specified
367 if (opts.click) {
368 console.log(`🖱️ Clicking element "${opts.click}"...`);
369 try {
370 let element = await page.$(opts.click);
371 if (!element) {
372 // Search in child frames recursively!
373 for (const frame of page.frames()) {
374 const childElement = await frame.$(opts.click);
375 if (childElement) {
376 element = childElement;
377 console.log(` Found element inside child frame: ${frame.url()}`);
378 break;
379 }
380 }
381 }
382
383 if (element) {
384 await element.click();
385 // wait an extra 2 seconds for animation or modal loading to settle
386 console.log(` Click succeeded! Waiting 2000ms for click action to settle...`);
387 await new Promise((r) => setTimeout(r, 2000));
388 } else {
389 throw new Error(`Selector "${opts.click}" not found in main document or child frames.`);
390 }
391 } catch (clickErr: any) {
392 console.error(`⚠️ Click action failed:`, clickErr);
393 stats.warnings.push(`Click action failed: ${clickErr.message || clickErr}`);
394 }
395 }
396
397 // ----- Pre-processing options -----
398
399 // Add class to <html> (e.g., dark mode)
400 if (opts.htmlClass) {
401 console.log(`🎨 Adding class "${opts.htmlClass}" to <html>...`);
402 await page.evaluate((cls: string) => {
403 document.documentElement.classList.add(...cls.split(/\s+/));
404 if (cls.includes('dark')) {
405 document.documentElement.setAttribute('data-theme', 'dark');
406 } else if (cls.includes('light')) {
407 document.documentElement.setAttribute('data-theme', 'light');
408 }
409 }, opts.htmlClass);
410 await new Promise((r) => setTimeout(r, 500));
411 }
412
413 // Remove fixed/sticky elements
414 if (opts.removeFixed) {
415 console.log('🧹 Removing fixed/sticky positioned elements...');
416 await page.evaluate(() => {
417 const all = document.querySelectorAll('*');
418 for (const el of all) {
419 const style = getComputedStyle(el);
420 if (style.position === 'fixed' || style.position === 'sticky') {
421 const rect = el.getBoundingClientRect();
422 if (rect.top > 100 || rect.height < 50) {
423 el.remove();
424 }
425 }
426 }
427 });
428 }
429
430 // Remove custom selectors
431 if (opts.removeSelectors) {
432 console.log(`🧹 Removing custom selectors: "${opts.removeSelectors}"...`);
433 await page.evaluate((selectors: string) => {
434 const items = selectors.split(',').map((s) => s.trim()).filter(Boolean);
435 for (const selector of items) {
436 try {
437 document.querySelectorAll(selector).forEach((el) => el.remove());
438 } catch (e) {
439 console.warn(`Invalid selector "${selector}":`, e);
440 }
441 }
442 }, opts.removeSelectors);
443 }
444
445
446
447 // Override title
448 if (opts.title) {
449 await page.evaluate((t: string) => {
450 document.title = t;
451 }, opts.title);
452 }
453
454 // ----- Inject shared browser-side helpers (deduplication) -----
455 // Mock __name to prevent esbuild generated code from failing in browser
456 await page.evaluate(() => {
457 (window as any).__name = (fn: any, name: string) => fn;
458 });
459
460 await page.evaluate((concurrency: number) => {
461 (window as any).__snapshot = {
462 CONCURRENCY: concurrency,
463
464 toDataUri: async (url: string): Promise<string | null> => {
465 try {
466 const resp = await fetch(url, {
467 mode: 'cors',
468 credentials: 'same-origin',
469 });
470 if (!resp.ok) return null;
471 const blob = await resp.blob();
472 return new Promise((resolve) => {
473 const reader = new FileReader();
474 reader.onloadend = () => resolve(reader.result as string);
475 reader.onerror = () => resolve(null);
476 reader.readAsDataURL(blob);
477 });
478 } catch {
479 return null;
480 }
481 },
482
483 processInBatches: async <T, R>(
484 items: T[],
485 batchSize: number,
486 fn: (item: T) => Promise<R>,
487 ): Promise<(R | null)[]> => {
488 const results: (R | null)[] = [];
489 for (let i = 0; i < items.length; i += batchSize) {
490 const batch = items.slice(i, i + batchSize);
491 const batchResults = await Promise.allSettled(batch.map(fn));
492 results.push(
493 ...batchResults.map((r) =>
494 r.status === 'fulfilled' ? r.value : null,
495 ),
496 );
497 }
498 return results;
499 },
500
501 /**
502 * Robust CSS url() parser — character-by-character parsing instead of regex.
503 * Handles: quoted/unquoted values, escaped characters, whitespace,
504 * data URIs, and malformed url() tokens.
505 *
506 * Returns: Array of { url, fullMatch, start, end }
507 */
508 extractCssUrls: (cssText: string) => {
509 const results: Array<{ url: string; fullMatch: string; start: number; end: number }> = [];
510 let i = 0;
511 const len = cssText.length;
512
513 while (i < len) {
514 // Look for 'url(' — case insensitive
515 if (
516 i + 3 < len &&
517 cssText[i].toLowerCase() === 'u' &&
518 cssText[i + 1].toLowerCase() === 'r' &&
519 cssText[i + 2].toLowerCase() === 'l' &&
520 cssText[i + 3] === '('
521 ) {
522 const urlStart = i;
523 i += 4; // skip 'url('
524
525 // Skip whitespace
526 while (
527 i < len &&
528 (cssText[i] === ' ' ||
529 cssText[i] === '\t' ||
530 cssText[i] === '\n' ||
531 cssText[i] === '\r')
532 ) {
533 i++;
534 }
535
536 // Check for quote
537 let quote: string | null = null;
538 if (i < len && (cssText[i] === '"' || cssText[i] === "'")) {
539 quote = cssText[i];
540 i++;
541 }
542
543 // Read the URL value
544 let url = '';
545 if (quote) {
546 // Quoted: read until matching unescaped quote
547 while (i < len && cssText[i] !== quote) {
548 if (cssText[i] === '\\' && i + 1 < len) {
549 i++; // skip backslash
550 url += cssText[i]; // include next char literally
551 } else {
552 url += cssText[i];
553 }
554 i++;
555 }
556 if (i < len) i++; // skip closing quote
557 } else {
558 // Unquoted: stop at ) or whitespace (per CSS spec)
559 while (
560 i < len &&
561 cssText[i] !== ')' &&
562 cssText[i] !== ' ' &&
563 cssText[i] !== '\t' &&
564 cssText[i] !== '\n' &&
565 cssText[i] !== '\r'
566 ) {
567 url += cssText[i];
568 i++;
569 }
570 }
571
572 // Skip trailing whitespace before ')'
573 while (
574 i < len &&
575 (cssText[i] === ' ' ||
576 cssText[i] === '\t' ||
577 cssText[i] === '\n' ||
578 cssText[i] === '\r')
579 ) {
580 i++;
581 }
582
583 if (i < len && cssText[i] === ')') {
584 const fullMatch = cssText.substring(urlStart, i + 1);
585 results.push({
586 url: url.trim(),
587 fullMatch,
588 start: urlStart,
589 end: i + 1,
590 });
591 i++;
592 } else {
593 // Malformed url() — skip past 'url(' and try again
594 i = urlStart + 1;
595 }
596 } else {
597 i++;
598 }
599 }
600
601 return results;
602 },
603
604 /**
605 * Replace CSS url() references using pre-computed positions.
606 * Replaces from end-to-start to preserve earlier indices.
607 */
608 replaceCssUrls: (
609 cssText: string,
610 replacements: Array<{ start: number; end: number; dataUri: string }>,
611 ): string => {
612 const sorted = [...replacements].sort((a, b) => b.start - a.start);
613 for (const r of sorted) {
614 cssText =
615 cssText.substring(0, r.start) +
616 "url('" +
617 r.dataUri +
618 "')" +
619 cssText.substring(r.end);
620 }
621 return cssText;
622 },
623 };
624 }, opts.concurrency);
625
626 // -----------------------------------------------------------------------
627 // -2. Remove dev-overlay / screenshot-ignore elements (e.g. VeloUI)
628 // -----------------------------------------------------------------------
629 const removedCount = await page.evaluate(() => {
630 let count = 0;
631 // Remove any element explicitly marked as screenshot-ignore
632 document.querySelectorAll('[data-screenshot-ignore="true"]').forEach(el => {
633 el.parentNode?.removeChild(el); count++;
634 });
635 // Remove VeloUI overlay elements (pause overlay, probe, root container, etc.)
636 const veloSelectors = [
637 '[data-veloui-pause-overlay]',
638 '[data-veloui-probe]',
639 '[data-veloui-extractor]',
640 '[data-veloui-scan]',
641 '.veloui-root',
642 '.veloui-liquid-glass',
643 ];
644 for (const sel of veloSelectors) {
645 document.querySelectorAll(sel).forEach(el => {
646 el.parentNode?.removeChild(el); count++;
647 });
648 }
649 return count;
650 });
651 if (removedCount > 0) {
652 console.log(`🧹 Removed ${removedCount} dev-overlay / screenshot-ignore element(s) from the DOM.`);
653 }
654
655 // -----------------------------------------------------------------------
656 // -1. Inline local iframes (e.g., companion-app test iframe)
657 // -----------------------------------------------------------------------
658 const iframesCount = await page.evaluate(() => document.querySelectorAll('iframe').length);
659 if (iframesCount > 0) {
660 console.log(`🔍 Found ${iframesCount} iframe(s) in the main page. Extracting content natively...`);
661
662 // First, recursively inline all same-origin and srcDoc iframes browser-side
663 console.log('🔍 Inlining same-origin and srcDoc iframes recursively...');
664 await page.evaluate(() => {
665 const inlineSameOriginIframes = (root: Document | HTMLElement) => {
666 const iframes = Array.from(root.querySelectorAll('iframe'));
667 for (const iframe of iframes) {
668 if (
669 iframe.getAttribute('data-screenshot-ignore') === 'true' ||
670 iframe.getAttribute('data-veloui-scan') === 'true' ||
671 iframe.getAttribute('data-veloui-probe') === 'true' ||
672 iframe.hasAttribute('data-veloui-extractor')
673 ) {
674 // Remove the hidden crawler/scan iframes completely from DOM
675 try { iframe.parentNode && iframe.parentNode.removeChild(iframe); } catch { }
676 continue;
677 }
678 try {
679 const doc = iframe.contentDocument || iframe.contentWindow?.document;
680 if (doc && doc.body) {
681 // Recursively inline same-origin iframes inside this child frame first
682 inlineSameOriginIframes(doc);
683
684 const bodyHtml = doc.body.innerHTML;
685
686 const styles: string[] = [];
687 doc.querySelectorAll('style').forEach(s => styles.push(s.outerHTML));
688 doc.querySelectorAll('link[rel="stylesheet"]').forEach(l => styles.push((l as HTMLLinkElement).outerHTML));
689
690 styles.forEach(styleHtml => {
691 const temp = document.createElement('div');
692 temp.innerHTML = styleHtml;
693 document.head.appendChild(temp.firstChild!);
694 });
695
696 const wrapper = document.createElement('div');
697 wrapper.className = 'ac-iframe-inlined-wrapper';
698
699 // Apply child body's classes and attributes to same-origin wrapper
700 for (const attr of Array.from(doc.body.attributes)) {
701 if (attr.name === 'class') {
702 wrapper.classList.add(...attr.value.split(/\s+/).filter(Boolean));
703 } else if (attr.name !== 'style') {
704 wrapper.setAttribute(attr.name, attr.value);
705 }
706 }
707
708 wrapper.style.position = 'absolute';
709 wrapper.style.top = '0';
710 wrapper.style.left = '0';
711 wrapper.style.width = '100%';
712 wrapper.style.height = '100%';
713 wrapper.style.overflow = 'hidden';
714 wrapper.innerHTML = bodyHtml;
715
716 iframe.parentNode!.replaceChild(wrapper, iframe);
717 }
718 } catch (e) {
719 // Ignore cross-origin iframes; the Puppeteer frame loop will process them
720 }
721 }
722 };
723 inlineSameOriginIframes(document);
724 });
725
726 const childFrames = page.frames()
727 .filter(f => f !== page.mainFrame())
728 .map(f => {
729 let depth = 0;
730 let p = f.parentFrame();
731 while (p) {
732 depth++;
733 p = p.parentFrame();
734 }
735 return { frame: f, depth };
736 })
737 .sort((a, b) => b.depth - a.depth);
738
739 for (const { frame } of childFrames) {
740 try {
741 const frameUrl = frame.url();
742 const cleanUrl = frameUrl.split('?')[0].split('#')[0];
743 console.log(`📦 Extracting frame content from: ${cleanUrl} (depth: ${frame.parentFrame() ? 'nested' : 'root'})`);
744
745 // Inject __name mock to prevent esbuild helper ReferenceError in child frame
746 await frame.evaluate(() => {
747 (window as any).__name = (fn: any) => fn;
748 });
749
750 // Resolve all relative assets inside the frame to absolute URLs relative to the frame's URL
751 await frame.evaluate((base) => {
752 const resolveAttr = (el: Element, attr: string) => {
753 const val = el.getAttribute(attr);
754 if (val && !val.startsWith('data:') && !val.startsWith('http:') && !val.startsWith('https:') && !val.startsWith('//')) {
755 try {
756 const abs = new URL(val, base).href;
757 el.setAttribute(attr, abs);
758 } catch (e) { }
759 }
760 };
761 document.querySelectorAll('img[src]').forEach(img => resolveAttr(img, 'src'));
762 document.querySelectorAll('img[srcset]').forEach(img => resolveAttr(img, 'srcset'));
763 document.querySelectorAll('source[srcset]').forEach(src => resolveAttr(src, 'srcset'));
764 document.querySelectorAll('link[rel="stylesheet"]').forEach(link => resolveAttr(link, 'href'));
765
766 // Resolve relative url() references in inline <style> tags
767 document.querySelectorAll('style').forEach((styleEl) => {
768 if (styleEl.textContent) {
769 styleEl.textContent = styleEl.textContent.replace(/url\(['"]?([^'")\s]+)['"]?\)/gi, (match, url) => {
770 if (
771 url.startsWith('data:') ||
772 url.startsWith('http:') ||
773 url.startsWith('https:') ||
774 url.startsWith('//')
775 ) {
776 return match;
777 }
778 try {
779 return `url('${new URL(url, base).href}')`;
780 } catch {
781 return match;
782 }
783 });
784 }
785 });
786 }, frameUrl);
787
788 const frameStyles = await frame.evaluate(() => {
789 const stylesList: string[] = [];
790 document.querySelectorAll('style').forEach(s => stylesList.push(s.outerHTML));
791 document.querySelectorAll('link[rel="stylesheet"]').forEach(l => stylesList.push((l as HTMLLinkElement).outerHTML));
792 return stylesList;
793 });
794
795 const frameBodyHtml = await frame.evaluate(() => document.body.innerHTML);
796 const frameBodyAttrs = await frame.evaluate(() => {
797 const attrs: Record<string, string> = {};
798 for (const attr of Array.from(document.body.attributes)) {
799 attrs[attr.name] = attr.value;
800 }
801 return attrs;
802 });
803 const frameHtmlAttrs = await frame.evaluate(() => {
804 const attrs: Record<string, string> = {};
805 for (const attr of Array.from(document.documentElement.attributes)) {
806 attrs[attr.name] = attr.value;
807 }
808 return attrs;
809 });
810
811 const parent = frame.parentFrame();
812 if (parent) {
813 await parent.evaluate((url, bodyHtml, styles, bodyAttrs, htmlAttrs) => {
814 styles.forEach(styleHtml => {
815 const temp = document.createElement('div');
816 temp.innerHTML = styleHtml;
817 document.head.appendChild(temp.firstChild!);
818 });
819
820 // Apply child html attributes to parent documentElement (e.g., data-theme)
821 for (const [name, val] of Object.entries(htmlAttrs)) {
822 if (name !== 'style') {
823 document.documentElement.setAttribute(name, val);
824 }
825 }
826
827 const iframes = Array.from(document.querySelectorAll('iframe'));
828 for (const iframe of iframes) {
829 if (
830 iframe.getAttribute('data-screenshot-ignore') === 'true' ||
831 iframe.getAttribute('data-veloui-scan') === 'true' ||
832 iframe.getAttribute('data-veloui-probe') === 'true' ||
833 iframe.hasAttribute('data-veloui-extractor')
834 ) {
835 try { iframe.parentNode && iframe.parentNode.removeChild(iframe); } catch { }
836 continue;
837 }
838 const cleanIframeSrc = iframe.src.split('?')[0].split('#')[0];
839 if (cleanIframeSrc && (url.includes(cleanIframeSrc) || cleanIframeSrc.includes(url))) {
840 const wrapper = document.createElement('div');
841 wrapper.className = 'ac-iframe-inlined-wrapper';
842
843 // Apply child body's classes and attributes to the wrapper
844 for (const [name, val] of Object.entries(bodyAttrs)) {
845 if (name === 'class') {
846 wrapper.classList.add(...val.split(/\s+/).filter(Boolean));
847 } else if (name !== 'style') {
848 wrapper.setAttribute(name, val);
849 }
850 }
851
852 wrapper.style.position = 'absolute';
853 wrapper.style.top = '0';
854 wrapper.style.left = '0';
855 wrapper.style.width = '100%';
856 wrapper.style.height = '100%';
857 wrapper.style.overflow = 'hidden';
858 wrapper.innerHTML = bodyHtml;
859 iframe.parentNode!.replaceChild(wrapper, iframe);
860 break;
861 }
862 }
863 }, cleanUrl, frameBodyHtml, frameStyles, frameBodyAttrs, frameHtmlAttrs);
864 }
865
866 } catch (frameErr) {
867 console.warn('Failed to extract child frame content:', frameErr);
868 }
869 }
870 }
871
872 // Resize viewport to full scroll height (executed after iframe contents are natively merged)
873 if (opts.fullHeight) {
874 console.log('📐 Scanning DOM for maximum scrollable container height...');
875
876 const maxScrollHeight = await page.evaluate(() => {
877 let maxVal = document.documentElement.scrollHeight;
878 const all = document.querySelectorAll('*');
879 for (const el of all) {
880 const style = getComputedStyle(el);
881 if (style.overflow === 'auto' || style.overflowY === 'auto' || style.overflow === 'scroll' || style.overflowY === 'scroll') {
882 if (el.scrollHeight > maxVal) {
883 maxVal = el.scrollHeight;
884 }
885 }
886 }
887 return maxVal;
888 });
889
890 // Resize viewport to the true maximum scroll height (plus 120px buffer for safety)
891 const finalViewportHeight = maxScrollHeight + 120;
892 console.log(`📐 Resizing viewport to maximum content height: ${finalViewportHeight}px`);
893 await page.setViewport({ width, height: finalViewportHeight });
894
895 // Force layout wrappers and scrollable containers to unlock their heights
896 await page.evaluate(() => {
897 document.documentElement.style.setProperty('height', 'auto', 'important');
898 document.documentElement.style.setProperty('overflow', 'visible', 'important');
899 document.body.style.setProperty('height', 'auto', 'important');
900 document.body.style.setProperty('overflow', 'visible', 'important');
901
902 const elements = document.querySelectorAll('*');
903 for (const el of elements) {
904 const style = getComputedStyle(el);
905 const hasViewportHeight = style.height.includes('vh') ||
906 style.height.includes('svh') ||
907 style.height === '100%' ||
908 style.height === '100vh' ||
909 style.height === '100svh' ||
910 style.maxHeight.includes('vh') ||
911 style.maxHeight.includes('svh') ||
912 style.maxHeight === '100%' ||
913 el.classList.contains('h-svh') ||
914 el.classList.contains('h-screen') ||
915 el.classList.contains('ac-iframe-inlined-wrapper') ||
916 el.classList.contains('ac-iframe');
917
918 if (hasViewportHeight) {
919 (el as HTMLElement).style.setProperty('height', 'auto', 'important');
920 (el as HTMLElement).style.setProperty('min-height', '0', 'important');
921 (el as HTMLElement).style.setProperty('max-height', 'none', 'important');
922 }
923
924 if (style.overflow === 'auto' || style.overflowY === 'auto' || style.overflow === 'scroll' || style.overflowY === 'scroll') {
925 (el as HTMLElement).style.setProperty('height', 'auto', 'important');
926 (el as HTMLElement).style.setProperty('max-height', 'none', 'important');
927 (el as HTMLElement).style.setProperty('overflow', 'visible', 'important');
928 (el as HTMLElement).style.setProperty('position', 'relative', 'important');
929 }
930 }
931 });
932
933 await new Promise((r) => setTimeout(r, 1000));
934 }
935
936
937
938 if (opts.inlineCanvas) {
939 console.log('📊 Converting <canvas> elements to base64 images...');
940 const canvasCount = await page.evaluate(() => {
941 let count = 0;
942 document.querySelectorAll('canvas').forEach((canvas) => {
943 try {
944 const dataUrl = canvas.toDataURL('image/png');
945 const img = document.createElement('img');
946 img.src = dataUrl;
947 img.className = canvas.className;
948 img.style.cssText = canvas.style.cssText;
949 if (canvas.id) img.id = canvas.id;
950 canvas.replaceWith(img);
951 count++;
952 } catch (e) {
953 // Tainted canvas or security error
954 }
955 });
956 return count;
957 });
958 console.log(` ✅ Converted ${canvasCount} canvas element(s)`);
959 }
960
961 // -----------------------------------------------------------------------
962 // 0. Materialize all rules from document.styleSheets into DOM
963 // -----------------------------------------------------------------------
964 // In modern dev servers (e.g. Vite dev mode with Tailwind) and CSS-in-JS
965 // libraries, stylesheets are injected dynamically into CSSOM without being
966 // serialized as clean text in <style> textContent. Additionally, Vite HMR
967 // injects <style> tags containing JS client import syntax that break CSS
968 // parsers. This step extracts all rules across all document.styleSheets,
969 // cleans up dev HMR style tags, and injects a unified style bundle.
970 console.log('🎨 Capturing all CSSOM rules from document.styleSheets...');
971 const cssomCount = await page.evaluate(() => {
972 let totalRules = 0;
973 let extraCssText = '/* --- EXTRACTED CSSOM BUNDLE --- */\n';
974 const extractedNodes = new Set<Node>();
975 for (const sheet of Array.from(document.styleSheets)) {
976 try {
977 let sheetCss = '';
978 for (const rule of Array.from(sheet.cssRules)) {
979 sheetCss += rule.cssText + '\n';
980 totalRules++;
981 }
982 if (sheetCss.trim().length > 0) {
983 extraCssText += sheetCss + '\n';
984 if (sheet.ownerNode) {
985 extractedNodes.add(sheet.ownerNode);
986 }
987 }
988 } catch (e) {
989 // Ignore cross-origin stylesheet security errors
990 }
991 }
992 if (extraCssText.trim().length > 0) {
993 // Only remove <style data-vite-dev-id> and <link rel="stylesheet"> whose CSSOM rules we successfully captured
994 document.querySelectorAll('style[data-vite-dev-id], link[rel="stylesheet"]').forEach(el => {
995 if (extractedNodes.has(el) || el.hasAttribute('data-vite-dev-id')) {
996 el.remove();
997 }
998 });
999 // Remove any <style> tag containing Vite HMR client syntax (createHotContext / import.meta.hot)
1000 document.querySelectorAll('style').forEach(el => {
1001 if (el.textContent && (el.textContent.includes('createHotContext') || el.textContent.includes('import.meta.hot'))) {
1002 el.remove();
1003 }
1004 });
1005 // Remove relative font preload links that cause 404 errors in static viewers
1006 document.querySelectorAll('link[rel="preload"][as="font"]').forEach(el => el.remove());
1007 const combinedStyle = document.createElement('style');
1008 combinedStyle.id = 'extracted-cssom-bundle';
1009 combinedStyle.textContent = extraCssText;
1010 document.head.appendChild(combinedStyle);
1011 }
1012 return totalRules;
1013 });
1014 console.log(` ✅ Captured ${cssomCount} rules from document.styleSheets`);
1015
1016 // -----------------------------------------------------------------------
1017 // 1. Inline all external stylesheets as <style> blocks
1018 // -----------------------------------------------------------------------
1019 console.log('🎨 Inlining external stylesheets...');
1020 stats.stylesheets = await page.evaluate(async () => {
1021 const { toDataUri, extractCssUrls, replaceCssUrls } = (window as any).__snapshot;
1022 let count = 0;
1023 const links = Array.from(
1024 document.querySelectorAll('link[rel="stylesheet"]'),
1025 ) as HTMLLinkElement[];
1026
1027 for (const link of links) {
1028 try {
1029 const href = link.href;
1030 if (!href) continue;
1031
1032 const resp = await fetch(href);
1033 if (!resp.ok) continue;
1034
1035 let cssText = await resp.text();
1036
1037 // Resolve relative url() references to absolute URLs using the parser
1038 const baseUrl = new URL(href);
1039 const urlRefs = extractCssUrls(cssText);
1040 const replacements: Array<{ start: number; end: number; dataUri: string }> = [];
1041
1042 for (const ref of urlRefs) {
1043 // Skip absolute URLs, data URIs, and protocol-relative URLs
1044 if (
1045 ref.url.startsWith('data:') ||
1046 ref.url.startsWith('http:') ||
1047 ref.url.startsWith('https:') ||
1048 ref.url.startsWith('//')
1049 ) {
1050 continue;
1051 }
1052 try {
1053 const absUrl = new URL(ref.url, baseUrl).href;
1054 replacements.push({
1055 start: ref.start,
1056 end: ref.end,
1057 dataUri: absUrl, // Not a data URI yet — just resolved to absolute
1058 });
1059 } catch {
1060 // Malformed URL — skip
1061 }
1062 }
1063
1064 if (replacements.length > 0) {
1065 cssText = replaceCssUrls(cssText, replacements);
1066 }
1067
1068 const style = document.createElement('style');
1069 style.textContent = cssText;
1070 if (link.media && link.media !== 'all') {
1071 style.setAttribute('media', link.media);
1072 }
1073 link.parentNode!.replaceChild(style, link);
1074 count++;
1075 } catch (e) {
1076 console.warn(`Failed to inline stylesheet: ${link.href}`, e);
1077 }
1078 }
1079 return count;
1080 });
1081 console.log(` ✅ Inlined ${stats.stylesheets} stylesheets`);
1082
1083 // -----------------------------------------------------------------------
1084 // 2. Inline images with concurrency (img, srcset, source, background-image)
1085 // -----------------------------------------------------------------------
1086 console.log('🖼️ Inlining images as base64...');
1087 stats.images = await page.evaluate(async () => {
1088 const { toDataUri, processInBatches, CONCURRENCY } = (window as any).__snapshot;
1089 let count = 0;
1090
1091 // --- <img src="..."> ---
1092 const images = Array.from(document.querySelectorAll('img[src]')) as HTMLImageElement[];
1093 await processInBatches(images, CONCURRENCY, async (img: HTMLImageElement) => {
1094 const src = img.src;
1095 if (!src || src.startsWith('data:')) return;
1096 const dataUri = await toDataUri(src);
1097 if (dataUri) {
1098 img.setAttribute('src', dataUri);
1099 count++;
1100 }
1101 });
1102
1103 // --- <img srcset="..."> ---
1104 const imgsWithSrcset = Array.from(
1105 document.querySelectorAll('img[srcset]'),
1106 ) as HTMLImageElement[];
1107 for (const img of imgsWithSrcset) {
1108 const srcset = img.getAttribute('srcset');
1109 if (!srcset) continue;
1110 const parts = srcset.split(',').map((s: string) => s.trim());
1111 const newParts: string[] = [];
1112
1113 await processInBatches(parts, CONCURRENCY, async (part: string) => {
1114 const [url, ...descriptors] = part.split(/\s+/);
1115 if (url.startsWith('data:')) {
1116 newParts.push(part);
1117 return;
1118 }
1119 const dataUri = await toDataUri(url);
1120 if (dataUri) {
1121 newParts.push([dataUri, ...descriptors].join(' '));
1122 count++;
1123 }
1124 // If fetch fails, drop — inlined src is the fallback
1125 });
1126
1127 if (newParts.length > 0) {
1128 img.setAttribute('srcset', newParts.join(', '));
1129 } else {
1130 img.removeAttribute('srcset');
1131 }
1132 }
1133
1134 // --- <source srcset="..."> ---
1135 const sources = Array.from(
1136 document.querySelectorAll('source[srcset]'),
1137 ) as HTMLSourceElement[];
1138 for (const source of sources) {
1139 const srcset = source.getAttribute('srcset');
1140 if (!srcset || srcset.startsWith('data:')) continue;
1141 const parts = srcset.split(',').map((s: string) => s.trim());
1142 const newParts: string[] = [];
1143
1144 await processInBatches(parts, CONCURRENCY, async (part: string) => {
1145 const [url, ...descriptors] = part.split(/\s+/);
1146 if (url.startsWith('data:')) {
1147 newParts.push(part);
1148 return;
1149 }
1150 const dataUri = await toDataUri(url);
1151 if (dataUri) {
1152 newParts.push([dataUri, ...descriptors].join(' '));
1153 count++;
1154 }
1155 });
1156
1157 if (newParts.length > 0) {
1158 source.setAttribute('srcset', newParts.join(', '));
1159 } else {
1160 source.remove();
1161 }
1162 }
1163
1164 // --- Inline ALL background-image url() in inline styles (handles multiple) ---
1165 const styledElements = Array.from(
1166 document.querySelectorAll('[style]'),
1167 ) as HTMLElement[];
1168 for (const el of styledElements) {
1169 const style = el.getAttribute('style');
1170 if (!style || !style.includes('url(')) continue;
1171
1172 // Use matchAll to handle multiple url() references
1173 const urlPattern =
1174 /url\(['"]?(https?:\/\/[^'"\)\s]+)['"]?\)/g;
1175 const matches = [...style.matchAll(urlPattern)];
1176 if (matches.length === 0) continue;
1177
1178 let newStyle = style;
1179 // Process in reverse order to preserve string positions
1180 for (let i = matches.length - 1; i >= 0; i--) {
1181 const m = matches[i];
1182 const dataUri = await toDataUri(m[1]);
1183 if (dataUri) {
1184 newStyle =
1185 newStyle.substring(0, m.index!) +
1186 "url('" +
1187 dataUri +
1188 "')" +
1189 newStyle.substring(m.index! + m[0].length);
1190 count++;
1191 }
1192 }
1193 el.setAttribute('style', newStyle);
1194 }
1195
1196 return count;
1197 });
1198 console.log(` ✅ Inlined ${stats.images} images`);
1199
1200 // -----------------------------------------------------------------------
1201 // 3. Inline CSS url() references in <style> blocks (using parser)
1202 // -----------------------------------------------------------------------
1203 console.log('🔗 Inlining CSS url() references in <style> blocks...');
1204 stats.cssUrls = await page.evaluate(async (inlineFonts) => {
1205 const {
1206 toDataUri,
1207 processInBatches,
1208 extractCssUrls,
1209 replaceCssUrls,
1210 CONCURRENCY,
1211 } = (window as any).__snapshot;
1212 let count = 0;
1213
1214 /** Check if a URL points to a font file (skip external fonts — too large, not visual) */
1215 const isFontFile = (url: string): boolean => {
1216 const isSameOrigin = url.startsWith('/') || url.startsWith('./') || url.startsWith('../') || url.startsWith(window.location.origin);
1217 if (isSameOrigin) return false;
1218 return /\.(woff2?|ttf|eot|otf)(\?|$)/i.test(url);
1219 }
1220
1221 const styles = Array.from(document.querySelectorAll('style')) as HTMLStyleElement[];
1222 for (const styleEl of styles) {
1223 let css = styleEl.textContent!;
1224 const urlRefs = extractCssUrls(css);
1225
1226 // Filter to http(s), same-origin, or relative URLs that aren't external fonts
1227 const toInline = urlRefs.filter(
1228 (ref: any) =>
1229 (ref.url.startsWith('http://') ||
1230 ref.url.startsWith('https://') ||
1231 ref.url.startsWith('/') ||
1232 ref.url.startsWith('./') ||
1233 ref.url.startsWith('../')) &&
1234 (!isFontFile(ref.url) || inlineFonts),
1235 );
1236
1237 if (toInline.length === 0) continue;
1238
1239 // Fetch all URLs concurrently
1240 const fetched: Array<{ start: number; end: number; dataUri: string }> = [];
1241 await processInBatches(toInline, CONCURRENCY, async (ref: any) => {
1242 const dataUri = await toDataUri(ref.url);
1243 if (dataUri) {
1244 fetched.push({ start: ref.start, end: ref.end, dataUri });
1245 count++;
1246 }
1247 });
1248
1249 if (fetched.length > 0) {
1250 css = replaceCssUrls(css, fetched);
1251 styleEl.textContent = css;
1252 }
1253 }
1254 return count;
1255 }, opts.inlineFonts);
1256 console.log(` ✅ Inlined ${stats.cssUrls} CSS url() references`);
1257
1258 // -----------------------------------------------------------------------
1259 // 4. Inline additional resource types (SVG, video, favicons)
1260 // -----------------------------------------------------------------------
1261 console.log('🔗 Inlining additional resources (SVG, video, favicons)...');
1262 const additionalStats = await page.evaluate(async () => {
1263 const { toDataUri, processInBatches, CONCURRENCY } = (window as any).__snapshot;
1264 const stats = { svgImages: 0, videoPoster: 0, favicons: 0 };
1265
1266 // --- SVG <image href="..."> and <image xlink:href="..."> ---
1267 const svgImages = Array.from(document.querySelectorAll('image')) as SVGImageElement[];
1268 await processInBatches(svgImages, CONCURRENCY, async (img: SVGImageElement) => {
1269 // Check both href and xlink:href
1270 const href =
1271 img.getAttribute('href') ||
1272 img.getAttributeNS('http://www.w3.org/1999/xlink', 'href');
1273 if (!href || href.startsWith('data:')) return;
1274 const dataUri = await toDataUri(href);
1275 if (dataUri) {
1276 // Set both for compatibility
1277 if (img.hasAttribute('href')) img.setAttribute('href', dataUri);
1278 if (
1279 img.hasAttributeNS('http://www.w3.org/1999/xlink', 'href')
1280 ) {
1281 img.setAttributeNS(
1282 'http://www.w3.org/1999/xlink',
1283 'href',
1284 dataUri,
1285 );
1286 }
1287 stats.svgImages++;
1288 }
1289 });
1290
1291 // --- <video poster="..."> ---
1292 const videos = Array.from(
1293 document.querySelectorAll('video[poster]'),
1294 ) as HTMLVideoElement[];
1295 await processInBatches(videos, CONCURRENCY, async (video: HTMLVideoElement) => {
1296 const poster = video.getAttribute('poster');
1297 if (!poster || poster.startsWith('data:')) return;
1298 const dataUri = await toDataUri(poster);
1299 if (dataUri) {
1300 video.setAttribute('poster', dataUri);
1301 stats.videoPoster++;
1302 }
1303 });
1304
1305 // --- <link rel="icon"> and <link rel="apple-touch-icon"> favicons ---
1306 const favicons = Array.from(
1307 document.querySelectorAll(
1308 'link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]',
1309 ),
1310 ) as HTMLLinkElement[];
1311 await processInBatches(favicons, CONCURRENCY, async (link: HTMLLinkElement) => {
1312 const href = link.href;
1313 if (!href || href.startsWith('data:')) return;
1314 const dataUri = await toDataUri(href);
1315 if (dataUri) {
1316 link.setAttribute('href', dataUri);
1317 stats.favicons++;
1318 }
1319 });
1320
1321 // --- <object data="..."> ---
1322 const objects = Array.from(
1323 document.querySelectorAll('object[data]'),
1324 ) as HTMLObjectElement[];
1325 await processInBatches(objects, CONCURRENCY, async (obj: HTMLObjectElement) => {
1326 const data = obj.getAttribute('data');
1327 if (!data || data.startsWith('data:')) return;
1328 // Only inline small objects (SVGs, etc.) — skip large ones
1329 try {
1330 const resp = await fetch(data);
1331 if (!resp.ok) return;
1332 const contentLength = resp.headers.get('content-length');
1333 if (contentLength && Number(contentLength) > 500000) return; // Skip >500KB
1334 const blob = await resp.blob();
1335 const dataUri: string | null = await new Promise((resolve) => {
1336 const reader = new FileReader();
1337 reader.onloadend = () => resolve(reader.result as string);
1338 reader.onerror = () => resolve(null);
1339 reader.readAsDataURL(blob);
1340 });
1341 if (dataUri) {
1342 obj.setAttribute('data', dataUri);
1343 }
1344 } catch {
1345 // Skip failed objects
1346 }
1347 });
1348
1349 return stats;
1350 });
1351
1352 stats.svgImages = additionalStats.svgImages;
1353 stats.videoPoster = additionalStats.videoPoster;
1354 stats.favicons = additionalStats.favicons;
1355
1356 const additionalTotal =
1357 stats.svgImages + stats.videoPoster + stats.favicons;
1358 console.log(
1359 ` ✅ Inlined ${additionalTotal} additional resources ` +
1360 `(${stats.svgImages} SVG, ${stats.videoPoster} video posters, ${stats.favicons} favicons)`,
1361 );
1362
1363 // -----------------------------------------------------------------------
1364 // 5. Remove all <script> tags and dev-tool overlays
1365 // -----------------------------------------------------------------------
1366 console.log('🗑️ Removing <script> tags and dev overlays...');
1367 stats.scriptsRemoved = await page.evaluate(() => {
1368 // Remove all scripts
1369 const scripts = Array.from(document.querySelectorAll('script'));
1370 scripts.forEach((s) => s.remove());
1371
1372 // Remove framework-specific dev overlays
1373 const devSelectors = [
1374 // Vite
1375 'vite-error-overlay',
1376 // Next.js
1377 '[data-nextjs-dialog-overlay]',
1378 'nextjs-portal',
1379 // Webpack/CRA
1380 '#webpack-dev-server-client-overlay',
1381 '#webpack-dev-server-client-overlay-div',
1382 // Parcel
1383 '[data-parcel-error-overlay]',
1384 // Nuxt
1385 '[data-v-inspector]',
1386 ];
1387
1388 for (const selector of devSelectors) {
1389 document
1390 .querySelectorAll(selector)
1391 .forEach((el) => el.remove());
1392 }
1393
1394 // Remove noscript tags
1395 document.querySelectorAll('noscript').forEach((el) => el.remove());
1396
1397 return scripts.length;
1398 });
1399 console.log(` ✅ Removed ${stats.scriptsRemoved} scripts`);
1400
1401 // -----------------------------------------------------------------------
1402 // 6. Clean up injected helpers
1403 // -----------------------------------------------------------------------
1404 await page.evaluate(() => {
1405 delete (window as any).__snapshot;
1406 });
1407
1408 // -----------------------------------------------------------------------
1409 // 7. Extract the final HTML and write output
1410 // -----------------------------------------------------------------------
1411 console.log('📦 Extracting final HTML...');
1412 const html = await page.evaluate(
1413 () => '<!DOCTYPE html>\n' + document.documentElement.outerHTML,
1414 );
1415
1416 // Write output
1417 const outputPath = path.resolve(opts.output!);
1418 const outputDir = path.dirname(outputPath);
1419 fs.mkdirSync(outputDir, { recursive: true });
1420 fs.writeFileSync(outputPath, html, 'utf-8');
1421
1422 stats.output = outputPath;
1423 stats.sizeBytes = Buffer.byteLength(html);
1424 stats.durationMs = Date.now() - startTime;
1425
1426 const sizeKB = (stats.sizeBytes / 1024).toFixed(1);
1427 console.log(`\n✅ Snapshot saved to ${outputPath} (${sizeKB} KB)`);
1428 console.log(
1429 ` ${stats.stylesheets} stylesheets, ${stats.images} images, ${stats.cssUrls} CSS urls inlined`,
1430 );
1431 console.log(
1432 ` ${additionalTotal} additional resources (SVG/video/favicon)`,
1433 );
1434 console.log(` ${stats.scriptsRemoved} scripts removed`);
1435 console.log(` Completed in ${stats.durationMs}ms`);
1436
1437 if (stats.warnings.length > 0) {
1438 console.log(`\n⚠️ ${stats.warnings.length} warning(s):`);
1439 stats.warnings.forEach((w) => console.log(` • ${w}`));
1440 }
1441
1442 // JSON output for CI/CD integration
1443 if (opts.json) {
1444 console.log('\n--- JSON Stats ---');
1445 console.log(JSON.stringify(stats, null, 2));
1446 }
1447 } finally {
1448 // Guaranteed browser cleanup — prevents zombie Chrome processes
1449 if (globalTimer) clearTimeout(globalTimer);
1450 if (browser) {
1451 try {
1452 await browser.close();
1453 } catch {
1454 // Browser may already be closed by timeout handler
1455 }
1456 }
1457 }
1458}
1459
1460// ---------------------------------------------------------------------------
1461// Entry point
1462// ---------------------------------------------------------------------------
1463const opts = parseArgs();
1464validateOpts(opts);
1465
1466snapshot(opts).catch((err: Error) => {
1467 console.error('❌ Snapshot failed:', err.message);
1468 if (err.stack) console.error(err.stack);
1469 process.exit(1);
1470});