full-page-capture.js
1 /** 2 * Full-Page Screenshot Capture 3 * 4 * Captures full-page screenshots for premium audit reports. 5 * Reuses stealth browser infrastructure from src/utils/stealth-browser.js 6 * and popover closing from src/capture.js. 7 * 8 * Also supports selector-based element screenshots: pass `selectors` array to 9 * captureFullPage and each matching element is screenshot while the page is still open. 10 * Returns `selectorScreenshots: Map<selector, Buffer>` alongside the full-page buffers. 11 */ 12 13 import sharp from 'sharp'; 14 import { launchStealthBrowser, createStealthContext } from '../utils/stealth-browser.js'; 15 import Logger from '../utils/logger.js'; 16 17 const logger = new Logger('FullPageCapture'); 18 19 const MAX_PAGE_HEIGHT = 10000; 20 const ABOVE_FOLD_WIDTH = 1440; 21 const ABOVE_FOLD_HEIGHT = 900; 22 23 const PADDING = 24; // px padding around element crops 24 25 /** 26 * Capture a full-page screenshot of a URL 27 * @param {string} url - URL to capture 28 * @param {Object} [options] - Capture options 29 * @param {number} [options.timeout=30000] - Page load timeout 30 * @param {number} [options.maxHeight=10000] - Max screenshot height in px 31 * @param {string[]} [options.selectors=[]] - CSS selectors to capture as tight element screenshots 32 * while the page is still open. Results in selectorScreenshots Map<selector, Buffer>. 33 * @returns {Object} { fullPageBuffer, aboveFoldBuffer, httpHeaders, sslStatus, htmlContent, pageHeight, selectorScreenshots } 34 */ 35 export async function captureFullPage(url, options = {}) { 36 const { timeout = 30000, maxHeight = MAX_PAGE_HEIGHT, selectors = [] } = options; 37 38 logger.info(`Capturing full page: ${url}`); 39 40 let browser; 41 try { 42 browser = await launchStealthBrowser({ stealthLevel: 'minimal' }); 43 const context = await createStealthContext(browser, { 44 viewport: { width: ABOVE_FOLD_WIDTH, height: ABOVE_FOLD_HEIGHT }, 45 }); 46 const page = await context.newPage(); 47 48 // Navigate and capture response headers 49 const response = await page.goto(url, { 50 waitUntil: 'networkidle', 51 timeout, 52 }); 53 54 const httpHeaders = response 55 ? Object.fromEntries( 56 Object.entries(response.headers()).filter(([k]) => 57 [ 58 'content-type', 59 'server', 60 'x-powered-by', 61 'strict-transport-security', 62 'content-security-policy', 63 'x-frame-options', 64 'x-content-type-options', 65 'cache-control', 66 'content-encoding', 67 'x-xss-protection', 68 'referrer-policy', 69 'permissions-policy', 70 ].includes(k.toLowerCase()) 71 ) 72 ) 73 : {}; 74 75 const sslStatus = url.startsWith('https') ? 'https' : 'http'; 76 77 // Wait for lazy-loaded content 78 await page.waitForTimeout(2000); 79 80 // Close popovers/modals that might obstruct 81 try { 82 /* eslint-disable no-undef */ 83 await page.evaluate(() => { 84 // Close common popover/modal elements 85 const selectors = [ 86 '[class*="cookie"] button', 87 '[class*="Cookie"] button', 88 '[id*="cookie"] button', 89 '[class*="consent"] button', 90 '[class*="popup"] [class*="close"]', 91 '[class*="modal"] [class*="close"]', 92 '[class*="overlay"] [class*="close"]', 93 '[aria-label="Close"]', 94 'button[class*="dismiss"]', 95 '[class*="banner"] [class*="close"]', 96 ]; 97 for (const sel of selectors) { 98 const el = document.querySelector(sel); 99 if (el) el.click(); 100 } 101 }); 102 /* eslint-enable no-undef */ 103 await page.waitForTimeout(500); 104 } catch { 105 // Popover closing is best-effort 106 } 107 108 // Get page dimensions 109 const pageHeight = await page.evaluate(() => document.documentElement.scrollHeight); // eslint-disable-line no-undef 110 const cappedHeight = Math.min(pageHeight, maxHeight); 111 112 // Capture above-fold screenshot 113 const aboveFoldBuffer = await page.screenshot({ 114 clip: { x: 0, y: 0, width: ABOVE_FOLD_WIDTH, height: ABOVE_FOLD_HEIGHT }, 115 type: 'png', 116 }); 117 118 // Capture full-page screenshot with height cap 119 await page.setViewportSize({ width: ABOVE_FOLD_WIDTH, height: cappedHeight }); 120 await page.waitForTimeout(500); 121 122 let fullPageBuffer = await page.screenshot({ 123 fullPage: true, 124 type: 'png', 125 }); 126 127 // Cap height with sharp if screenshot is taller than maxHeight 128 const metadata = await sharp(fullPageBuffer).metadata(); 129 if (metadata.height > maxHeight) { 130 fullPageBuffer = await sharp(fullPageBuffer) 131 .extract({ left: 0, top: 0, width: metadata.width, height: maxHeight }) 132 .png() 133 .toBuffer(); 134 } 135 136 // Get HTML content 137 const htmlContent = await page.content(); 138 139 // Capture selector-based element screenshots while page is still open 140 const selectorScreenshots = new Map(); 141 if (selectors.length > 0) { 142 // Scroll back to top before element captures 143 await page.evaluate(() => window.scrollTo(0, 0)); // eslint-disable-line no-undef 144 await page.waitForTimeout(300); 145 146 for (const selector of selectors) { 147 if (!selector || selector === 'body') continue; 148 try { 149 const locator = page.locator(selector).first(); 150 const box = await locator.boundingBox({ timeout: 3000 }); 151 if (!box) { 152 logger.debug(`Selector not found or not visible: ${selector}`); 153 continue; 154 } 155 156 // Scroll element into view 157 await locator.scrollIntoViewIfNeeded(); 158 await page.waitForTimeout(200); 159 160 // Get updated bounding box after scroll 161 const box2 = await locator.boundingBox({ timeout: 3000 }); 162 if (!box2) continue; 163 164 // Add padding, clamp to viewport width 165 const padded = { 166 x: Math.max(0, box2.x - PADDING), 167 y: Math.max(0, box2.y - PADDING), 168 width: Math.min(ABOVE_FOLD_WIDTH, box2.width + PADDING * 2), 169 height: Math.min(ABOVE_FOLD_HEIGHT, box2.height + PADDING * 2), 170 }; 171 172 const elementBuffer = await page.screenshot({ 173 clip: padded, 174 type: 'png', 175 }); 176 177 selectorScreenshots.set(selector, elementBuffer); 178 logger.debug( 179 `Captured selector "${selector}": ${Math.round(padded.width)}x${Math.round(padded.height)}px` 180 ); 181 } catch (err) { 182 logger.debug(`Failed to capture selector "${selector}": ${err.message}`); 183 } 184 } 185 logger.info(`Selector screenshots: ${selectorScreenshots.size}/${selectors.length} captured`); 186 } 187 188 await context.close(); 189 190 logger.success(`Captured ${url}: ${metadata.width}x${Math.min(metadata.height, maxHeight)}px`); 191 192 return { 193 fullPageBuffer, 194 aboveFoldBuffer, 195 httpHeaders, 196 sslStatus, 197 htmlContent, 198 pageHeight: cappedHeight, 199 selectorScreenshots, 200 }; 201 } finally { 202 if (browser) { 203 await browser.close(); 204 } 205 } 206 }