/ src / reports / full-page-capture.js
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  }