mockup-generator.js
1 /** 2 * Mockup Generator 3 * 4 * Creates before/after screenshot pairs for each problem area by: 5 * 1. Opening the live page in Playwright 6 * 2. Screenshotting the element targeted by `selector` (the "before") 7 * 3. Applying `mockup_changes` via page.evaluate() DOM manipulation 8 * 4. Re-screenshotting the same region (the "after") 9 * 10 * The "after" shows the business owner exactly what their site could look like 11 * with the recommended fix applied — not a vague description of empty space. 12 * 13 * Falls back to y% crop from fullPageBuffer if Playwright can't find the selector. 14 */ 15 16 import sharp from 'sharp'; 17 import { launchStealthBrowser, createStealthContext } from '../utils/stealth-browser.js'; 18 import Logger from '../utils/logger.js'; 19 20 const logger = new Logger('MockupGenerator'); 21 22 const VIEWPORT_WIDTH = 1440; 23 const VIEWPORT_HEIGHT = 900; 24 const PADDING_X = 40; // px horizontal padding around element crops 25 const PADDING_Y = 80; // px vertical padding — enough to show surrounding context 26 const MAX_CROP_HEIGHT = 700; // don't let a single crop exceed this 27 const MAX_CROP_WIDTH = 900; // natural width cap — no stretching 28 29 /** 30 * Apply mockup changes to the page via DOM manipulation. 31 * Passes serialisable changes array to page.evaluate(). 32 */ 33 async function applyMockupChanges(page, changes) { 34 /* eslint-disable no-undef */ 35 await page.evaluate(changeList => { 36 for (const change of changeList) { 37 try { 38 const el = document.querySelector(change.selector); 39 if (!el) continue; 40 41 switch (change.action) { 42 case 'replace_text': 43 el.textContent = change.value; 44 break; 45 case 'set_style': 46 Object.assign(el.style, change.value); 47 break; 48 case 'insert_before': 49 el.insertAdjacentHTML('beforebegin', change.html); 50 break; 51 case 'insert_after': 52 el.insertAdjacentHTML('afterend', change.html); 53 break; 54 case 'set_attribute': 55 el.setAttribute(change.attr, change.value); 56 break; 57 case 'remove': 58 el.remove(); 59 break; 60 } 61 } catch { 62 // Best-effort — some changes may fail if selector doesn't match 63 } 64 } 65 }, changes); 66 /* eslint-enable no-undef */ 67 } 68 69 /** 70 * Take a padded screenshot of an element by selector. 71 * Returns { buffer, box } or null if not found. 72 */ 73 /** 74 * @param {Object} [opts] 75 * @param {number} [opts.extraPadY] - Extra vertical padding (for after-shots that include injected siblings) 76 */ 77 async function screenshotSelector(page, selector, opts = {}) { 78 const selectors = selector.split(',').map(s => s.trim()); 79 const extraY = opts.extraPadY || 0; 80 81 for (const sel of selectors) { 82 try { 83 const locator = page.locator(sel).first(); 84 const box = await locator.boundingBox({ timeout: 3000 }); 85 if (!box) continue; 86 87 await locator.scrollIntoViewIfNeeded(); 88 await page.waitForTimeout(200); 89 90 const box2 = await locator.boundingBox({ timeout: 3000 }); 91 if (!box2) continue; 92 93 // Get page dimensions to clamp against 94 /* eslint-disable no-undef */ 95 const pageHeight = await page.evaluate(() => document.documentElement.scrollHeight); 96 /* eslint-enable no-undef */ 97 98 // Generous padding to show surrounding context (not just the element in isolation) 99 const padY = PADDING_Y + extraY; 100 const clip = { 101 x: Math.max(0, box2.x - PADDING_X), 102 y: Math.max(0, box2.y - padY), 103 width: Math.min(VIEWPORT_WIDTH, Math.max(400, box2.width + PADDING_X * 2)), 104 height: Math.min(MAX_CROP_HEIGHT, Math.max(200, box2.height + padY * 2)), 105 }; 106 107 // Clamp to page bounds 108 if (clip.y + clip.height > pageHeight) { 109 clip.height = pageHeight - clip.y; 110 } 111 // Ensure minimum dimensions for Playwright 112 if (clip.width < 1 || clip.height < 1) continue; 113 114 const buffer = await page.screenshot({ clip, type: 'png' }); 115 return { buffer, box: clip }; 116 } catch { 117 continue; 118 } 119 } 120 return null; 121 } 122 123 /** 124 * Take a region screenshot by y% from the full page. 125 * Fallback when selector doesn't match. 126 */ 127 async function screenshotYPercent(page, yPercent) { 128 /* eslint-disable no-undef */ 129 const pageHeight = await page.evaluate(() => document.documentElement.scrollHeight); 130 const yCenter = Math.round((yPercent / 100) * pageHeight); 131 const cropHeight = Math.min(400, pageHeight); 132 const cropTop = Math.max(0, yCenter - Math.round(cropHeight / 2)); 133 134 // Scroll to position 135 await page.evaluate(y => window.scrollTo(0, y), cropTop); 136 /* eslint-enable no-undef */ 137 await page.waitForTimeout(300); 138 139 const clip = { 140 x: 0, 141 y: cropTop, 142 width: VIEWPORT_WIDTH, 143 height: Math.min(cropHeight, pageHeight - cropTop), 144 }; 145 146 const buffer = await page.screenshot({ clip, type: 'png' }); 147 return { buffer, box: clip }; 148 } 149 150 /** 151 * Normalise a screenshot buffer: cap width, convert to JPEG. 152 */ 153 async function normaliseBuffer(buffer) { 154 const meta = await sharp(buffer).metadata(); 155 156 let pipeline = sharp(buffer); 157 158 // Cap width if wider than MAX_CROP_WIDTH (don't upscale, only downscale) 159 if (meta.width > MAX_CROP_WIDTH) { 160 pipeline = pipeline.resize(MAX_CROP_WIDTH, null, { 161 fit: 'inside', 162 withoutEnlargement: true, 163 }); 164 } 165 166 return pipeline.jpeg({ quality: 92 }).toBuffer(); 167 } 168 169 /** 170 * Generate before/after mockup pairs for all problem areas. 171 * 172 * @param {string} url - Page URL to open 173 * @param {Array} problemAreas - From score_json.problem_areas 174 * @param {Buffer} _fullPageBuffer - Reserved for future fallback use 175 * @returns {Array<Object>} Array of { factor, beforeBuffer, afterBuffer, description, recommendation, severity, current_text, suggested_text, usedSelector } 176 */ 177 export async function generateMockups(url, problemAreas, _fullPageBuffer) { 178 if (!problemAreas || problemAreas.length === 0) { 179 logger.info('No problem areas for mockup generation'); 180 return []; 181 } 182 183 logger.info(`Generating before/after mockups for ${problemAreas.length} problem areas`); 184 185 let browser; 186 try { 187 browser = await launchStealthBrowser({ stealthLevel: 'minimal' }); 188 const context = await createStealthContext(browser, { 189 viewport: { width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT }, 190 }); 191 const page = await context.newPage(); 192 193 await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 }); 194 await page.waitForTimeout(2000); 195 196 // Close cookie/popup overlays 197 try { 198 /* eslint-disable no-undef */ 199 await page.evaluate(() => { 200 const sels = [ 201 '[class*="cookie"] button', 202 '[class*="Cookie"] button', 203 '[id*="cookie"] button', 204 '[class*="consent"] button', 205 '[class*="popup"] [class*="close"]', 206 '[class*="modal"] [class*="close"]', 207 '[aria-label="Close"]', 208 'button[class*="dismiss"]', 209 ]; 210 for (const sel of sels) { 211 const el = document.querySelector(sel); 212 if (el) el.click(); 213 } 214 }); 215 /* eslint-enable no-undef */ 216 await page.waitForTimeout(500); 217 } catch { 218 /* best effort */ 219 } 220 221 const mockups = []; 222 223 for (const area of problemAreas) { 224 try { 225 const { selector } = area; 226 const changes = area.mockup_changes; 227 let usedSelector = false; 228 229 // --- BEFORE screenshot --- 230 let beforeResult = null; 231 // For top-of-page issues, use above-fold screenshot — selector often 232 // matches a zero-height element like <script> or <style>. 233 // Detect via: y% <= 5, or selector targeting body's first child, or 234 // mockup_changes with insert_before on body > *:first-child 235 const hasTopInsert = (changes || []).some( 236 c => 237 c.action === 'insert_before' && 238 /body\s*>|:first-child|^header$|^nav$/i.test(c.selector || '') 239 ); 240 const isTopOfPage = (area.approximate_y_position_percent || 50) <= 5 || hasTopInsert; 241 if (isTopOfPage) { 242 // Above-fold: top 900px of page at full viewport width 243 /* eslint-disable no-undef */ 244 await page.evaluate(() => window.scrollTo(0, 0)); 245 /* eslint-enable no-undef */ 246 await page.waitForTimeout(200); 247 const clip = { x: 0, y: 0, width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT }; 248 beforeResult = { 249 buffer: await page.screenshot({ clip, type: 'png' }), 250 box: clip, 251 }; 252 usedSelector = true; // treat as "found" so the after path runs 253 } else if (selector) { 254 beforeResult = await screenshotSelector(page, selector); 255 if (beforeResult) usedSelector = true; 256 } 257 if (!beforeResult) { 258 beforeResult = await screenshotYPercent(page, area.approximate_y_position_percent || 50); 259 } 260 261 const beforeBuffer = await normaliseBuffer(beforeResult.buffer); 262 263 // --- AFTER screenshot --- 264 let afterBuffer = null; 265 if (changes && changes.length > 0) { 266 // Check if any change inserts new elements (insert_before/insert_after) 267 // — these add siblings that won't be in the original selector's bounding box 268 const hasInjection = changes.some( 269 c => c.action === 'insert_before' || c.action === 'insert_after' 270 ); 271 const hasInsertBefore = changes.some(c => c.action === 'insert_before'); 272 273 // Apply DOM changes 274 await applyMockupChanges(page, changes); 275 await page.waitForTimeout(300); 276 277 // Re-screenshot — strategy depends on what changed 278 let afterResult = null; 279 280 if (isTopOfPage) { 281 // Top-of-page: just re-screenshot the above-fold area (banner is now visible at top) 282 /* eslint-disable no-undef */ 283 await page.evaluate(() => window.scrollTo(0, 0)); 284 /* eslint-enable no-undef */ 285 await page.waitForTimeout(200); 286 const clip = { x: 0, y: 0, width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT }; 287 afterResult = { 288 buffer: await page.screenshot({ clip, type: 'png' }), 289 box: clip, 290 }; 291 } else if (hasInjection && usedSelector && selector) { 292 // Injected elements are siblings — the original selector's box won't include them. 293 // Use extra vertical padding to capture the injected content above/below. 294 const extraPad = hasInsertBefore ? 120 : 80; 295 afterResult = await screenshotSelector(page, selector, { extraPadY: extraPad }); 296 } else if (usedSelector && selector) { 297 // No injection — same element, just restyled/rewritten text 298 afterResult = await screenshotSelector(page, selector); 299 } 300 301 if (!afterResult) { 302 // Fallback: use the before clip region, expanded to catch injected content 303 const expandedClip = { ...beforeResult.box }; 304 if (hasInsertBefore) { 305 // insert_before pushes content down — expand upward from y=0 306 const extraH = expandedClip.y; 307 expandedClip.y = 0; 308 expandedClip.height = Math.min(MAX_CROP_HEIGHT, expandedClip.height + extraH + 120); 309 } else if (hasInjection) { 310 expandedClip.height = Math.min(MAX_CROP_HEIGHT, expandedClip.height + 120); 311 } 312 /* eslint-disable no-undef */ 313 await page.evaluate(y => window.scrollTo(0, y), expandedClip.y); 314 const pageH = await page.evaluate(() => document.documentElement.scrollHeight); 315 /* eslint-enable no-undef */ 316 await page.waitForTimeout(200); 317 // Clamp to page bounds 318 if (expandedClip.y + expandedClip.height > pageH) { 319 expandedClip.height = pageH - expandedClip.y; 320 } 321 afterResult = { 322 buffer: await page.screenshot({ clip: expandedClip, type: 'png' }), 323 box: expandedClip, 324 }; 325 } 326 327 afterBuffer = await normaliseBuffer(afterResult.buffer); 328 329 // Revert DOM changes by reloading page (cleanest approach) 330 await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 }); 331 await page.waitForTimeout(1500); 332 333 // Re-close popups 334 try { 335 /* eslint-disable no-undef */ 336 await page.evaluate(() => { 337 const sels = [ 338 '[class*="cookie"] button', 339 '[class*="Cookie"] button', 340 '[id*="cookie"] button', 341 '[class*="consent"] button', 342 '[class*="popup"] [class*="close"]', 343 '[class*="modal"] [class*="close"]', 344 '[aria-label="Close"]', 345 'button[class*="dismiss"]', 346 ]; 347 for (const sel of sels) { 348 const el = document.querySelector(sel); 349 if (el) el.click(); 350 } 351 }); 352 /* eslint-enable no-undef */ 353 await page.waitForTimeout(500); 354 } catch { 355 /* best effort */ 356 } 357 } 358 359 mockups.push({ 360 factor: area.factor, 361 beforeBuffer, 362 afterBuffer, 363 description: area.description, 364 recommendation: area.recommendation, 365 severity: area.severity || 'medium', 366 current_text: area.current_text, 367 suggested_text: area.suggested_text, 368 usedSelector, 369 }); 370 371 logger.debug( 372 `[${area.factor}] ${usedSelector ? 'selector' : 'y% fallback'}, ` + 373 `${afterBuffer ? 'with' : 'no'} after mockup` 374 ); 375 } catch (err) { 376 logger.warn(`Failed mockup for ${area.factor}: ${err.message}`); 377 } 378 } 379 380 await context.close(); 381 382 const selectorCount = mockups.filter(m => m.usedSelector).length; 383 const afterCount = mockups.filter(m => m.afterBuffer).length; 384 logger.success( 385 `Generated ${mockups.length} mockups (${selectorCount} via selector, ` + 386 `${afterCount} with before/after pairs)` 387 ); 388 389 return mockups; 390 } finally { 391 if (browser) await browser.close(); 392 } 393 }