dom-crop-analyzer.js
1 /** 2 * DOM-aware screenshot crop boundary analyzer 3 * Detects navigation elements, CTAs, and trust signals to calculate safe crop boundaries 4 * Preserves scoring-critical elements while removing non-content chrome 5 */ 6 7 import Logger from './logger.js'; 8 9 const logger = new Logger('DOMCropAnalyzer'); 10 11 /** 12 * Analyze page DOM to determine safe crop boundaries 13 * Preserves CTAs, trust signals, and hero content 14 * @param {import('playwright').Page} page - Playwright page instance 15 * @param {string} type - Screenshot type: 'desktop_above'|'desktop_below'|'mobile_above' 16 * @returns {Promise<Object>} Crop boundaries and metadata 17 */ 18 // eslint-disable-next-line max-lines-per-function -- Browser evaluation function needs full DOM analysis logic 19 export async function analyzeCropBoundaries(page, type) { 20 try { 21 // eslint-disable-next-line max-lines-per-function, complexity -- Browser context code 22 const result = await page.evaluate(screenshotType => { 23 /* global document, window */ 24 // 1. Detect navigation/header elements 25 const navSelectors = [ 26 'nav', 27 'header', 28 '[role="navigation"]', 29 '[role="banner"]', 30 '.navbar', 31 '.header', 32 '.nav', 33 '.navigation', 34 '#header', 35 '#navbar', 36 '#navigation', 37 ]; 38 39 const navElements = []; 40 for (const selector of navSelectors) { 41 const elements = document.querySelectorAll(selector); 42 navElements.push(...Array.from(elements)); 43 } 44 45 // 2. Check if element contains scoring-critical content 46 function hasImportantContent(el) { 47 if (!el) return false; 48 49 // CTAs: buttons, prominent links, phone numbers, contact links 50 const ctaSelectors = [ 51 'button', 52 'a[href^="tel:"]', 53 'a[href^="mailto:"]', 54 'a[href*="contact"]', 55 'a[href*="quote"]', 56 'a[href*="booking"]', 57 'a[href*="appointment"]', 58 '[class*="cta"]', 59 '[class*="btn"]', 60 '[class*="button"]', 61 '[class*="call"]', 62 '[id*="cta"]', 63 '[id*="call"]', 64 'input[type="submit"]', 65 ]; 66 67 for (const selector of ctaSelectors) { 68 const ctaEl = el.querySelector(selector); 69 if (ctaEl) { 70 // Check if CTA is actually visible 71 const rect = ctaEl.getBoundingClientRect(); 72 const style = window.getComputedStyle(ctaEl); 73 const isVisible = 74 style.display !== 'none' && 75 style.visibility !== 'hidden' && 76 style.opacity !== '0' && 77 rect.width > 0 && 78 rect.height > 0; 79 80 if (isVisible) { 81 return true; 82 } 83 } 84 } 85 86 // Trust signals: logos, badges, certifications, awards 87 const trustSelectors = [ 88 'img[alt*="certified"]', 89 'img[alt*="certification"]', 90 'img[alt*="badge"]', 91 'img[alt*="award"]', 92 'img[alt*="accredited"]', 93 'img[alt*="licensed"]', 94 'img[alt*="insured"]', 95 'img[alt*="rated"]', 96 'img[alt*="BBB"]', 97 '[class*="trust"]', 98 '[class*="badge"]', 99 '[class*="certification"]', 100 '[class*="award"]', 101 '[class*="accredit"]', 102 ]; 103 104 for (const selector of trustSelectors) { 105 const trustEl = el.querySelector(selector); 106 if (trustEl) { 107 // Ignore tiny badges (<50px) - likely not prominent trust signals 108 const rect = trustEl.getBoundingClientRect(); 109 const style = window.getComputedStyle(trustEl); 110 const isVisible = 111 style.display !== 'none' && 112 style.visibility !== 'hidden' && 113 rect.width >= 50 && 114 rect.height >= 50; 115 116 if (isVisible) { 117 return true; 118 } 119 } 120 } 121 122 return false; 123 } 124 125 // 3. Calculate top boundary 126 let topCrop = 0; 127 let shouldCropNav = false; 128 let navReasoning = 'No navigation detected'; 129 130 if (navElements.length > 0) { 131 // Find primary nav (largest by area, in upper portion of viewport) 132 let primaryNav = null; 133 let maxArea = 0; 134 135 for (const el of navElements) { 136 const rect = el.getBoundingClientRect(); 137 const area = rect.width * rect.height; 138 139 // Must be visible and in upper half of viewport 140 if (rect.top < window.innerHeight / 2 && area > maxArea && rect.height > 0) { 141 maxArea = area; 142 primaryNav = { el, rect }; 143 } 144 } 145 146 if (primaryNav) { 147 const style = window.getComputedStyle(primaryNav.el); 148 const isSticky = style.position === 'fixed' || style.position === 'sticky'; 149 const hasImportant = hasImportantContent(primaryNav.el); 150 151 // Crop only if: (1) sticky/fixed, (2) no important content, (3) reasonable height 152 const reasonableHeight = primaryNav.rect.height < window.innerHeight * 0.3; // <30% of viewport 153 shouldCropNav = isSticky && !hasImportant && reasonableHeight; 154 155 if (shouldCropNav) { 156 topCrop = Math.ceil(primaryNav.rect.bottom); 157 navReasoning = `Cropped ${topCrop}px sticky nav (no CTAs/trust signals)`; 158 } else if (hasImportant) { 159 navReasoning = 'Preserved nav (contains CTAs or trust signals)'; 160 } else if (!isSticky) { 161 navReasoning = 'Preserved nav (not sticky/fixed)'; 162 } else if (!reasonableHeight) { 163 navReasoning = `Preserved nav (too large: ${Math.round(primaryNav.rect.height)}px)`; 164 } 165 } 166 } 167 168 // 4. Calculate side margins 169 let leftCrop = 0; 170 let rightCrop = 0; 171 let marginReasoning = 'No margin cropping'; 172 173 const mainSelectors = [ 174 'main', 175 '[role="main"]', 176 '.container', 177 '.content', 178 '.main-content', 179 '#main', 180 '#content', 181 '.wrapper', 182 '.page-wrapper', 183 ]; 184 185 let mainContent = null; 186 for (const selector of mainSelectors) { 187 mainContent = document.querySelector(selector); 188 if (mainContent) { 189 const rect = mainContent.getBoundingClientRect(); 190 // Ensure it's actually visible and substantial 191 if (rect.width > 0 && rect.height > 0) { 192 break; 193 } 194 mainContent = null; 195 } 196 } 197 198 if (mainContent) { 199 const mainRect = mainContent.getBoundingClientRect(); 200 const viewportWidth = window.innerWidth; 201 202 // Only crop if significant whitespace (>5% on each side) 203 const leftMargin = mainRect.left; 204 const rightMargin = viewportWidth - mainRect.right; 205 206 if (leftMargin > viewportWidth * 0.05) { 207 leftCrop = Math.floor(leftMargin); 208 } 209 if (rightMargin > viewportWidth * 0.05) { 210 rightCrop = Math.floor(rightMargin); 211 } 212 213 if (leftCrop > 0 || rightCrop > 0) { 214 marginReasoning = `Cropped margins (L:${leftCrop}px, R:${rightCrop}px)`; 215 } 216 } 217 218 // 5. Type-specific adjustments 219 if (screenshotType === 'desktop_below') { 220 // Below-fold: already scrolled past nav 221 topCrop = 0; 222 navReasoning = 'Below-fold (no nav cropping needed)'; 223 } else if (screenshotType === 'mobile_above') { 224 // Mobile: typically full-width content, no side margins 225 leftCrop = 0; 226 rightCrop = 0; 227 marginReasoning = 'Mobile (full-width)'; 228 } 229 230 return { 231 topCrop, 232 leftCrop, 233 rightCrop, 234 metadata: { 235 hadNav: navElements.length > 0, 236 navCropped: shouldCropNav, 237 navReasoning, 238 marginReasoning, 239 viewportWidth: window.innerWidth, 240 viewportHeight: window.innerHeight, 241 }, 242 }; 243 }, type); 244 245 logger.debug( 246 `DOM crop analysis (${type}): top=${result.topCrop}px, left=${result.leftCrop}px, right=${result.rightCrop}px | ${result.metadata.navReasoning}` 247 ); 248 249 return result; 250 } catch (error) { 251 // Fallback to conservative crop on error 252 logger.warn( 253 `DOM crop analysis failed for ${type}: ${error.message}, using conservative fallback` 254 ); 255 return { 256 topCrop: 0, 257 leftCrop: 0, 258 rightCrop: 0, 259 metadata: { 260 error: error.message, 261 fallback: true, 262 navReasoning: 'DOM analysis failed, using conservative crop (zero-crop)', 263 marginReasoning: 'DOM analysis failed', 264 }, 265 }; 266 } 267 }