problem-area-cropper.js
1 /** 2 * Problem Area Cropper 3 * 4 * Produces tight zoomed-in screenshots for each problem area in the report. 5 * 6 * Strategy (in priority order): 7 * 1. Selector screenshot — if area.selector was captured by full-page-capture.js while 8 * the page was open, use that buffer directly (perfectly tight around the element) 9 * 2. y% crop fallback — crop from the full-page screenshot using approximate_y_position_percent 10 * (legacy behaviour, used when selector didn't match or wasn't provided) 11 */ 12 13 import sharp from 'sharp'; 14 import Logger from '../utils/logger.js'; 15 16 const logger = new Logger('ProblemAreaCropper'); 17 18 const MISSING_KEYWORDS = [ 19 'no ', 20 'missing', 21 'lacks', 22 'absent', 23 'no clear', 24 'not present', 25 'does not have', 26 'without', 27 "doesn't have", 28 'there is no', 29 ]; 30 31 /** 32 * Crop problem areas from a full-page screenshot (or selector screenshots when available). 33 * @param {Buffer} fullPageBuffer - Full-page screenshot PNG buffer 34 * @param {Array} problemAreas - Array of problem area objects from LLM 35 * @param {Map<string,Buffer>} [selectorScreenshots] - Optional map of selector → tight element buffer 36 * @returns {Array<Object>} Array of { factor, imageBuffer, description, recommendation, severity, missing, current_text, suggested_text, usedSelector } 37 */ 38 export async function cropProblemAreas( 39 fullPageBuffer, 40 problemAreas, 41 selectorScreenshots = new Map() 42 ) { 43 if (!problemAreas || problemAreas.length === 0) { 44 logger.info('No problem areas to crop'); 45 return []; 46 } 47 48 const metadata = await sharp(fullPageBuffer).metadata(); 49 const { width: imgWidth, height: imgHeight } = metadata; 50 51 logger.info( 52 `Cropping ${problemAreas.length} problem areas from ${imgWidth}x${imgHeight} screenshot` 53 ); 54 55 const crops = []; 56 57 for (const area of problemAreas) { 58 try { 59 const isMissing = MISSING_KEYWORDS.some(kw => 60 (area.description || '').toLowerCase().includes(kw) 61 ); 62 63 let imageBuffer; 64 let usedSelector = false; 65 66 // Strategy 1: use pre-captured selector screenshot (tight, perfectly framed) 67 if (area.selector && selectorScreenshots.has(area.selector)) { 68 const rawBuffer = selectorScreenshots.get(area.selector); 69 // Normalise to JPEG at consistent quality 70 imageBuffer = await sharp(rawBuffer).jpeg({ quality: 90 }).toBuffer(); 71 usedSelector = true; 72 logger.debug(`Using selector screenshot for ${area.factor}: "${area.selector}"`); 73 } else { 74 // Strategy 2: y% crop fallback from full-page buffer 75 const cropWidth = area.zoom_hint === 'tight' ? 480 : 768; 76 const cropHeight = area.zoom_hint === 'tight' ? 280 : 400; 77 78 const yPercent = area.approximate_y_position_percent || 50; 79 const yCenter = Math.round((yPercent / 100) * imgHeight); 80 81 const cropTop = Math.max(0, yCenter - Math.round(cropHeight / 2)); 82 const actualCropHeight = Math.min(cropHeight, imgHeight - cropTop); 83 const actualCropWidth = Math.min(imgWidth, cropWidth * 2); 84 85 if (actualCropHeight < 50) { 86 logger.warn(`Skipping crop for ${area.factor}: insufficient height`); 87 continue; 88 } 89 90 imageBuffer = await sharp(fullPageBuffer) 91 .extract({ left: 0, top: cropTop, width: actualCropWidth, height: actualCropHeight }) 92 .resize(cropWidth, null, { fit: 'inside', withoutEnlargement: true }) 93 .jpeg({ quality: 90 }) 94 .toBuffer(); 95 96 if (area.selector) { 97 logger.debug( 98 `Selector "${area.selector}" not captured for ${area.factor} — using y% fallback` 99 ); 100 } 101 } 102 103 crops.push({ 104 factor: area.factor, 105 imageBuffer, 106 description: area.description, 107 recommendation: area.recommendation, 108 severity: area.severity || 'medium', 109 missing: isMissing, 110 current_text: area.current_text, 111 suggested_text: area.suggested_text, 112 usedSelector, 113 }); 114 } catch (error) { 115 logger.warn(`Failed to crop area ${area.factor}: ${error.message}`); 116 } 117 } 118 119 const selectorCount = crops.filter(c => c.usedSelector).length; 120 logger.success( 121 `Cropped ${crops.length}/${problemAreas.length} problem areas (${selectorCount} via selector, ${crops.length - selectorCount} via y% fallback)` 122 ); 123 return crops; 124 }