browser-helpers.js
1 /** 2 * Extracted pure functions from page.evaluate() browser-context code. 3 * These functions contain the business logic that was previously only executable 4 * inside Playwright's browser context, now testable with Node.js + JSDOM. 5 * 6 * Source modules: capture.js, dom-crop-analyzer.js, stealth-browser.js 7 */ 8 9 // ============================================================ 10 // SELECTOR CONSTANTS 11 // ============================================================ 12 13 /** Selectors for detecting navigation/header elements */ 14 export const NAV_SELECTORS = [ 15 'nav', 16 'header', 17 '[role="navigation"]', 18 '[role="banner"]', 19 '.navbar', 20 '.header', 21 '.nav', 22 '.navigation', 23 '#header', 24 '#navbar', 25 '#navigation', 26 ]; 27 28 /** Selectors for CTA elements (buttons, contact links, forms) */ 29 export const CTA_SELECTORS = [ 30 'button', 31 'a[href^="tel:"]', 32 'a[href^="mailto:"]', 33 'a[href*="contact"]', 34 'a[href*="quote"]', 35 'a[href*="booking"]', 36 'a[href*="appointment"]', 37 '[class*="cta"]', 38 '[class*="btn"]', 39 '[class*="button"]', 40 '[class*="call"]', 41 '[id*="cta"]', 42 '[id*="call"]', 43 'input[type="submit"]', 44 ]; 45 46 /** Selectors for trust signal elements (badges, certifications) */ 47 export const TRUST_SIGNAL_SELECTORS = [ 48 'img[alt*="certified"]', 49 'img[alt*="certification"]', 50 'img[alt*="badge"]', 51 'img[alt*="award"]', 52 'img[alt*="accredited"]', 53 'img[alt*="licensed"]', 54 'img[alt*="insured"]', 55 'img[alt*="rated"]', 56 'img[alt*="BBB"]', 57 '[class*="trust"]', 58 '[class*="badge"]', 59 '[class*="certification"]', 60 '[class*="award"]', 61 '[class*="accredit"]', 62 ]; 63 64 /** Selectors for main content container elements */ 65 export const MAIN_CONTENT_SELECTORS = [ 66 'main', 67 '[role="main"]', 68 '.container', 69 '.content', 70 '.main-content', 71 '#main', 72 '#content', 73 '.wrapper', 74 '.page-wrapper', 75 ]; 76 77 /** Selectors for chat widget overlays */ 78 export const CHAT_WIDGET_SELECTORS = [ 79 "[class*='intercom']", 80 "[class*='drift']", 81 "[class*='zendesk']", 82 "[class*='livechat']", 83 "[class*='tawk']", 84 "[class*='crisp']", 85 "[class*='hubspot-messages']", 86 "[class*='tidio']", 87 "[class*='olark']", 88 "[id*='chat-widget']", 89 "[id*='chat-bubble']", 90 "[class*='chat-launcher']", 91 "[class*='chat-button']", 92 "[class*='messenger-frame']", 93 ]; 94 95 /** Selectors for cookie banner overlays */ 96 export const COOKIE_BANNER_SELECTORS = [ 97 "[class*='cookie-banner']", 98 "[class*='cookie-notice']", 99 "[class*='cookie-consent']", 100 "[id*='cookie-banner']", 101 "[id*='cookie-notice']", 102 "[id*='cookie-consent']", 103 '#onetrust-banner-sdk', 104 '#CybotCookiebotDialog', 105 "[class*='gdpr-banner']", 106 "[class*='consent-banner']", 107 '.cc-banner', 108 '#cookieChoiceInfo', 109 '#wow-modal-overlay-1', 110 '[id^="wow-modal-overlay"]', 111 '.wow-modal-overlay', 112 ]; 113 114 /** Cloudflare/Turnstile block detection indicators */ 115 export const CLOUDFLARE_INDICATORS = [ 116 'checking your browser', 117 'verifying you are human', 118 'just a moment', 119 'enable javascript and cookies', 120 'attention required', 121 'cloudflare', 122 ]; 123 124 // ============================================================ 125 // PURE FUNCTIONS (no DOM access needed) 126 // ============================================================ 127 128 /** 129 * Quadratic ease-in-out function for smooth scrolling 130 * @param {number} t - Progress value 0-1 131 * @returns {number} Eased value 0-1 132 */ 133 export function easeInOutQuad(t) { 134 return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; 135 } 136 137 /** 138 * Check if an element's position and z-index indicate it's an overlay 139 * @param {string} position - CSS position value 140 * @param {number} zIndex - Parsed z-index value 141 * @returns {boolean} 142 */ 143 export function isOverlayPositioned(position, zIndex) { 144 return (position === 'fixed' || position === 'absolute') && zIndex > 1000; 145 } 146 147 /** 148 * Calculate viewport coverage percentages 149 * @param {{ width: number, height: number }} elementSize - Element dimensions 150 * @param {{ width: number, height: number }} viewport - Viewport dimensions 151 * @returns {{ widthCoverage: number, heightCoverage: number }} 152 */ 153 export function calculateViewportCoverage(elementSize, viewport) { 154 return { 155 widthCoverage: elementSize.width / viewport.width, 156 heightCoverage: elementSize.height / viewport.height, 157 }; 158 } 159 160 /** 161 * Determine if an element covers enough viewport to be a full-screen overlay 162 * Must cover at least 80% in both dimensions 163 * @param {number} widthCoverage - Width coverage ratio (0-1) 164 * @param {number} heightCoverage - Height coverage ratio (0-1) 165 * @returns {boolean} 166 */ 167 export function isFullScreenOverlay(widthCoverage, heightCoverage) { 168 return widthCoverage >= 0.8 && heightCoverage >= 0.8; 169 } 170 171 /** 172 * Check if a background color indicates transparency 173 * @param {string} bgColor - CSS backgroundColor value 174 * @param {number} opacity - CSS opacity value 175 * @returns {boolean} 176 */ 177 export function isTransparentBackground(bgColor, opacity) { 178 return ( 179 bgColor === 'transparent' || bgColor === 'rgba(0, 0, 0, 0)' || opacity === 0 || bgColor === '' 180 ); 181 } 182 183 /** 184 * Check if an element is likely a navigation bar based on its dimensions 185 * @param {number} height - Element height in pixels 186 * @param {number} heightCoverage - Height as a fraction of viewport 187 * @returns {boolean} 188 */ 189 export function isLikelyNavElement(height, heightCoverage) { 190 return height < 200 && heightCoverage < 0.3; 191 } 192 193 /** 194 * Determine if a navigation element should be cropped 195 * Crop only if: (1) sticky/fixed, (2) no important content, (3) reasonable height 196 * @param {Object} params 197 * @param {boolean} params.isSticky - Whether nav is position:fixed or position:sticky 198 * @param {boolean} params.hasImportantContent - Whether nav contains CTAs/trust signals 199 * @param {number} params.navHeight - Nav height in pixels 200 * @param {number} params.viewportHeight - Viewport height in pixels 201 * @returns {{ shouldCrop: boolean, reasoning: string, topCrop: number, navBottom: number }} 202 */ 203 export function evaluateNavCrop({ 204 isSticky, 205 hasImportantContent, 206 navHeight, 207 viewportHeight, 208 navBottom, 209 }) { 210 const reasonableHeight = navHeight < viewportHeight * 0.3; 211 const shouldCrop = isSticky && !hasImportantContent && reasonableHeight; 212 213 let reasoning; 214 if (shouldCrop) { 215 const topCrop = Math.ceil(navBottom); 216 reasoning = `Cropped ${topCrop}px sticky nav (no CTAs/trust signals)`; 217 return { shouldCrop, reasoning, topCrop, navBottom }; 218 } else if (hasImportantContent) { 219 reasoning = 'Preserved nav (contains CTAs or trust signals)'; 220 } else if (!isSticky) { 221 reasoning = 'Preserved nav (not sticky/fixed)'; 222 } else if (!reasonableHeight) { 223 reasoning = `Preserved nav (too large: ${Math.round(navHeight)}px)`; 224 } 225 226 return { shouldCrop: false, reasoning, topCrop: 0, navBottom }; 227 } 228 229 /** 230 * Calculate margin crop values from main content boundaries 231 * Only crops if significant whitespace (>5% on each side) 232 * @param {Object} mainRect - Main content bounding rect 233 * @param {number} mainRect.left - Left edge of main content 234 * @param {number} mainRect.right - Right edge of main content 235 * @param {number} viewportWidth - Viewport width 236 * @returns {{ leftCrop: number, rightCrop: number, reasoning: string }} 237 */ 238 export function calculateMarginCrop(mainRect, viewportWidth) { 239 const leftMargin = mainRect.left; 240 const rightMargin = viewportWidth - mainRect.right; 241 242 let leftCrop = 0; 243 let rightCrop = 0; 244 245 if (leftMargin > viewportWidth * 0.05) { 246 leftCrop = Math.floor(leftMargin); 247 } 248 if (rightMargin > viewportWidth * 0.05) { 249 rightCrop = Math.floor(rightMargin); 250 } 251 252 const reasoning = 253 leftCrop > 0 || rightCrop > 0 254 ? `Cropped margins (L:${leftCrop}px, R:${rightCrop}px)` 255 : 'No margin cropping'; 256 257 return { leftCrop, rightCrop, reasoning }; 258 } 259 260 /** 261 * Apply screenshot-type-specific adjustments to crop values 262 * @param {string} screenshotType - 'desktop_above'|'desktop_below'|'mobile_above' 263 * @param {Object} cropResult - Current crop values 264 * @returns {Object} Adjusted crop values with updated reasoning 265 */ 266 export function applyTypeAdjustments(screenshotType, cropResult) { 267 const adjusted = { ...cropResult }; 268 269 if (screenshotType === 'desktop_below') { 270 adjusted.topCrop = 0; 271 adjusted.navReasoning = 'Below-fold (no nav cropping needed)'; 272 } else if (screenshotType === 'mobile_above') { 273 adjusted.leftCrop = 0; 274 adjusted.rightCrop = 0; 275 adjusted.marginReasoning = 'Mobile (full-width)'; 276 } 277 278 return adjusted; 279 } 280 281 /** 282 * Check if page content indicates a Cloudflare/Turnstile challenge 283 * @param {string} bodyText - Page body text (lowercased) 284 * @param {string} title - Page title (lowercased) 285 * @returns {boolean} 286 */ 287 export function isCloudflareBlocked(bodyText, title) { 288 return CLOUDFLARE_INDICATORS.some( 289 indicator => bodyText.includes(indicator) || title.includes(indicator) 290 ); 291 } 292 293 /** 294 * Check if a CSS element is visible based on computed styles and dimensions 295 * @param {Object} style - Computed style properties 296 * @param {string} style.display - CSS display value 297 * @param {string} style.visibility - CSS visibility value 298 * @param {string} [style.opacity] - CSS opacity value 299 * @param {Object} rect - Bounding client rect 300 * @param {number} rect.width - Element width 301 * @param {number} rect.height - Element height 302 * @param {Object} [options] - Additional options 303 * @param {number} [options.minWidth=0] - Minimum width to consider visible 304 * @param {number} [options.minHeight=0] - Minimum height to consider visible 305 * @returns {boolean} 306 */ 307 export function isElementVisible(style, rect, options = {}) { 308 const { minWidth = 0, minHeight = 0 } = options; 309 310 if (style.display === 'none' || style.visibility === 'hidden') { 311 return false; 312 } 313 314 if (style.opacity === '0') { 315 return false; 316 } 317 318 if (rect.width <= minWidth || rect.height <= minHeight) { 319 return false; 320 } 321 322 return true; 323 } 324 325 /** 326 * Determine if a positioned element should be treated as a modal overlay 327 * Combines all overlay detection heuristics 328 * @param {Object} params 329 * @param {string} params.position - CSS position value 330 * @param {number} params.zIndex - Parsed z-index value 331 * @param {Object} params.rect - Element bounding rect { width, height } 332 * @param {Object} params.viewport - Viewport dimensions { width, height } 333 * @param {string} params.bgColor - CSS backgroundColor 334 * @param {number} params.opacity - CSS opacity 335 * @param {string} params.display - CSS display value 336 * @param {string} params.visibility - CSS visibility value 337 * @returns {{ isOverlay: boolean, reason: string }} 338 */ 339 export function classifyOverlayElement(params) { 340 const { position, zIndex, rect, viewport, bgColor, opacity, display, visibility } = params; 341 342 // Must be positioned 343 if (position !== 'fixed' && position !== 'absolute') { 344 return { isOverlay: false, reason: 'not positioned' }; 345 } 346 347 // Must not be already hidden 348 if (display === 'none' || visibility === 'hidden') { 349 return { isOverlay: false, reason: 'already hidden' }; 350 } 351 352 // Must have significant z-index 353 if (zIndex < 100) { 354 return { isOverlay: false, reason: 'low z-index' }; 355 } 356 357 // Must cover most of viewport 358 const coverage = calculateViewportCoverage(rect, viewport); 359 if (!isFullScreenOverlay(coverage.widthCoverage, coverage.heightCoverage)) { 360 return { isOverlay: false, reason: 'not full screen' }; 361 } 362 363 // Must not be transparent 364 if (isTransparentBackground(bgColor, opacity)) { 365 return { isOverlay: false, reason: 'transparent' }; 366 } 367 368 // Must not look like a nav bar 369 if (isLikelyNavElement(rect.height, coverage.heightCoverage)) { 370 return { isOverlay: false, reason: 'likely nav element' }; 371 } 372 373 return { isOverlay: true, reason: 'full-screen modal overlay' }; 374 } 375 376 /** 377 * Generate Bezier curve waypoints for human-like mouse movement 378 * @param {number} x1 - Start X 379 * @param {number} y1 - Start Y 380 * @param {number} x2 - End X 381 * @param {number} y2 - End Y 382 * @param {number} numPoints - Number of waypoints (default 4) 383 * @param {Function} [randomFn] - Random function for testing (default Math.random) 384 * @returns {Array<{x: number, y: number}>} Waypoint coordinates 385 */ 386 // eslint-disable-next-line max-params -- mirrors Playwright mouse movement API 387 export function generateBezierWaypoints(x1, y1, x2, y2, numPoints = 4, randomFn = Math.random) { 388 const points = []; 389 const cp1x = x1 + (x2 - x1) * (0.2 + randomFn() * 0.3); 390 const cp1y = y1 + (y2 - y1) * (0.2 + randomFn() * 0.3); 391 const cp2x = x1 + (x2 - x1) * (0.5 + randomFn() * 0.3); 392 const cp2y = y1 + (y2 - y1) * (0.5 + randomFn() * 0.3); 393 394 for (let i = 0; i <= numPoints; i++) { 395 const t = i / numPoints; 396 const mt = 1 - t; 397 const x = mt ** 3 * x1 + 3 * mt ** 2 * t * cp1x + 3 * mt * t ** 2 * cp2x + t ** 3 * x2; 398 const y = mt ** 3 * y1 + 3 * mt ** 2 * t * cp1y + 3 * mt * t ** 2 * cp2y + t ** 3 * y2; 399 points.push({ x: Math.round(x), y: Math.round(y) }); 400 } 401 return points; 402 }