/ src / utils / dom-crop-analyzer.js
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  }