@detiq/crawler 1.0.6 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/crawler.ts CHANGED
@@ -20,7 +20,7 @@ import StealthPlugin from 'puppeteer-extra-plugin-stealth';
20
20
  // Option 3: Camoufox — Firefox-based browser with built-in fingerprint randomisation.
21
21
  // Loaded dynamically (not at startup) to avoid ESM/CJS conflict when API imports crawler.
22
22
  import { spawn, execSync, type ChildProcess } from 'node:child_process';
23
- import { readFileSync, existsSync as existsSyncFs } from 'node:fs';
23
+ import { existsSync as existsSyncFs, readFileSync } from 'node:fs';
24
24
  import { createRequire } from 'node:module';
25
25
 
26
26
  stealthChromium.use(StealthPlugin());
@@ -35,7 +35,7 @@ import type { Credentials } from './auth.js';
35
35
  import { skyvernFallback, skyvernNavigate, type SkyvernConfig } from './skyvern.js';
36
36
  import { loginWithStagehand } from './ai-login.js';
37
37
  import { readScreen } from './screen-reader.js';
38
- import { probeAllScreens } from './interactive-probe.js';
38
+ import { probeAllScreens, probeKeyboardNav } from './interactive-probe.js';
39
39
  import { computeElementHash } from './element-hash.js';
40
40
  import { logger } from './logger.js';
41
41
  import { solveCaptcha } from './captcha-solver.js';
@@ -67,6 +67,7 @@ import { auditColorContrast } from './color-contrast.js';
67
67
  import { detectFeatureFlags } from './feature-flag-detector.js';
68
68
  import { detectLanguage } from './language-detector.js';
69
69
  import { detectForms } from './form-detector.js';
70
+ import { auditAccessibility } from './accessibility-auditor.js';
70
71
  import { RedirectRegistry } from './redirect-tracker.js';
71
72
  import { DeadLinkTracker } from './dead-link-detector.js';
72
73
  import { screensToSitemapUrls, writeSitemap } from './sitemap-writer.js';
@@ -268,6 +269,7 @@ async function launchChromeCDP(
268
269
  '--remote-allow-origins=*',
269
270
  '--no-first-run',
270
271
  '--no-default-browser-check',
272
+ '--window-size=1280,900',
271
273
  ],
272
274
  });
273
275
 
@@ -1613,16 +1615,18 @@ export async function crawlProject(opts: {
1613
1615
  const performanceMetrics: PerformanceMetric[] = [];
1614
1616
  const seenDomHashes = new Set<string>(opts.knownDomHashes ?? []);
1615
1617
 
1616
- // Content/legal pages — still discovered as links but skipped from crawl queue
1617
- // (no test case value; pollute element counts with nav noise)
1618
- const SKIP_PATH = /\/(terms|privacy|dpa|legal|cookie|changelog|blog|help|docs|about|contact|careers|press|imprint|gdpr|compliance|acceptable-use|security-policy|accessibility)([-/]|$)/i;
1618
+ // Legal/policy-only pages — no interactive elements worth testing; skip to prevent noise
1619
+ // NOTE: /about, /contact, /careers, /help are intentionally NOT skipped — they have
1620
+ // real test targets (contact forms, job apply forms, help search, team content)
1621
+ const SKIP_PATH = /\/(terms|privacy|dpa|legal|cookie-policy|changelog|blog|imprint|gdpr|compliance|acceptable-use|security-policy|press|sitemap)([-/]|$)/i;
1619
1622
  const segIdx = opts.segmentIndex ?? 1;
1620
1623
 
1621
1624
  const robotsCache = opts.respectRobotsTxt ? new RobotsCache() : null;
1622
1625
  // AutoThrottle state — exponential moving average of navigation latency
1623
1626
  let avgNavMs = 0;
1624
1627
  let navCount = 0;
1625
- // Rate-limit circuit breaker: after 3 consecutive AI errors (429), skip AI for 10 screens
1628
+ // Rate-limit circuit breaker: after 5 consecutive AI errors (429), skip AI for 3 screens then retry
1629
+ // (was: 3 errors → skip 10 screens — too aggressive for Nvidia NIM which rate-limits quickly)
1626
1630
  let aiErrorStreak = 0;
1627
1631
  let aiSkipCount = 0;
1628
1632
  let aiRateLimitWarned = false;
@@ -2095,8 +2099,10 @@ export async function crawlProject(opts: {
2095
2099
  try {
2096
2100
  const _require = createRequire(import.meta.url);
2097
2101
  const axePath = _require.resolve('axe-core');
2098
- const axeScript = readFileSync(axePath, 'utf8');
2099
- await page.addScriptTag({ content: axeScript });
2102
+ // page.evaluate() with script content bypasses CSP (DevTools Runtime.evaluate).
2103
+ // page.addScriptTag({ path }) is blocked by strict-CSP on many production sites.
2104
+ const axeContent = readFileSync(axePath, 'utf8');
2105
+ await page.evaluate(axeContent);
2100
2106
  const result = await page.evaluate(async () => {
2101
2107
  return (window as any).axe.run(document, {
2102
2108
  runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'best-practice'] },
@@ -2114,7 +2120,10 @@ export async function crawlProject(opts: {
2114
2120
  }));
2115
2121
  const criticalCount = violations.filter((v: any) => v.impact === 'critical').length;
2116
2122
  return { violations, violationCount: violations.length, criticalCount };
2117
- } catch { return null; }
2123
+ } catch (err) {
2124
+ logger.warn({ err: String((err as any)?.message ?? err) }, '[crawler] axe-core a11y audit failed (non-fatal)');
2125
+ return null;
2126
+ }
2118
2127
  })();
2119
2128
  const a11yScore = a11yResult
2120
2129
  ? Math.max(0, 100 - (a11yResult.criticalCount * 20) - ((a11yResult.violationCount - a11yResult.criticalCount) * 5))
@@ -2256,12 +2265,13 @@ export async function crawlProject(opts: {
2256
2265
  // screen is now declared; page still has current page state and is fully hydrated.
2257
2266
  void (async () => {
2258
2267
  const screenId = screen.id;
2259
- const [seoR, contrastR, flagsR, langR, formsR] = await Promise.allSettled([
2268
+ const [seoR, contrastR, flagsR, langR, formsR, a11yManualR] = await Promise.allSettled([
2260
2269
  checkSeo(page, url),
2261
2270
  auditColorContrast(page, url),
2262
2271
  detectFeatureFlags(page, url),
2263
2272
  detectLanguage(page, url),
2264
2273
  detectForms(page),
2274
+ auditAccessibility(page, url),
2265
2275
  ]);
2266
2276
  const base = { screenId, jobId: opts.jobId };
2267
2277
  if (seoR.status === 'fulfilled')
@@ -2274,10 +2284,20 @@ export async function crawlProject(opts: {
2274
2284
  AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, { ...base, type: 'LANGUAGE_DETECTION', payload: langR.value }).catch(() => { });
2275
2285
  if (formsR.status === 'fulfilled' && Array.isArray(formsR.value) && formsR.value.length)
2276
2286
  AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, { ...base, type: 'FORMS_DETECTED', payload: { forms: formsR.value } }).catch(() => { });
2287
+ if (a11yManualR.status === 'fulfilled' && (a11yManualR.value as any).violations?.length)
2288
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, { ...base, type: 'A11Y_MANUAL_AUDIT', payload: a11yManualR.value }).catch(() => { });
2277
2289
  })();
2278
2290
 
2279
2291
  // Freeze all CSS animations/transitions so screenshots are stable across runs
2280
- try { await page.addStyleTag({ content: '*, *::before, *::after { animation-duration: 0s !important; animation-delay: 0s !important; transition-duration: 0s !important; transition-delay: 0s !important; }' }); } catch { /* Stagehand proxy may not expose addStyleTag */ }
2292
+ try { await page.addStyleTag({ content: '*, *::before, *::after { animation-duration: 0s !important; animation-delay: 0s !important; transition-duration: 0s !important; transition-delay: 0s !important; animation-play-state: paused !important; animation-fill-mode: both !important; }' }); } catch { /* Stagehand proxy may not expose addStyleTag */ }
2293
+ // Freeze JS-driven animations (requestAnimationFrame loops, setInterval animations)
2294
+ await page.evaluate(() => {
2295
+ try {
2296
+ const noop = () => 0;
2297
+ Object.defineProperty(window, 'requestAnimationFrame', { value: noop, configurable: true });
2298
+ Object.defineProperty(window, 'cancelAnimationFrame', { value: noop, configurable: true });
2299
+ } catch { /* property may not be configurable */ }
2300
+ }).catch(() => { });
2281
2301
 
2282
2302
  // Replay-first: check for a cached link-discovery recipe before calling AI Stagehand
2283
2303
  const canonUrl = canonicalize(url);
@@ -2353,7 +2373,7 @@ export async function crawlProject(opts: {
2353
2373
  const fallback: string[] = err?.domLinks ?? [];
2354
2374
  if (err?.isRateLimit) {
2355
2375
  aiErrorStreak++;
2356
- if (aiErrorStreak >= 3) { aiSkipCount = 10; logger.warn('[crawler] 3 consecutive AI rate limits — skipping AI link discovery for next 10 screens'); }
2376
+ if (aiErrorStreak >= 5) { aiSkipCount = 3; aiErrorStreak = 0; logger.warn('[crawler] 5 consecutive AI rate limits — pausing AI link discovery for next 3 screens then retrying'); }
2357
2377
  if (!aiRateLimitWarned) {
2358
2378
  aiRateLimitWarned = true;
2359
2379
  onProgress?.(20, 'AI quota exhausted — falling back to DOM-only link discovery').catch(() => { });
@@ -2365,6 +2385,14 @@ export async function crawlProject(opts: {
2365
2385
  ]);
2366
2386
  logger.info({ url, screenId: screen.id, version: screen.version, elements: elements.length, links: nextLinks.length, atSample: atElements.slice(0, 3).map((a: any) => a.label) }, '[crawler] screen captured');
2367
2387
  console.log('[crawler] AT:', JSON.stringify(atElements.slice(0, 5)));
2388
+ // Fire-and-forget keyboard-nav probe — records Tab sequence and focus-indicator issues per screen
2389
+ probeKeyboardNav(page).then((kbResult) => {
2390
+ if (kbResult.focusableCount > 0) {
2391
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
2392
+ screenId: screen.id, jobId: opts.jobId, type: 'KEYBOARD_NAV', payload: kbResult,
2393
+ }).catch(() => { });
2394
+ }
2395
+ }).catch(() => { });
2368
2396
 
2369
2397
  // Capture DOM snapshot for structural analysis, and extract Markdown/text
2370
2398
  // content from the same DOM (crawl4AI-parity text output) — no extra fetch.
@@ -2412,11 +2440,13 @@ export async function crawlProject(opts: {
2412
2440
  try {
2413
2441
  const llmCfg = await getCachedCrawlLLM(opts.tenantId);
2414
2442
  const snippet = extracted.markdown.slice(0, 1200);
2443
+ // Skip LLM when content is too thin — output would be useless
2444
+ if (snippet.trim().length < 80) return undefined;
2415
2445
  const { text, usage } = await askTracked(
2416
- 'You are a QA analyst. Given page content, write ONE crisp sentence describing what this screen does or shows. Be specific. No preamble.',
2446
+ 'You are a QA analyst. Output ONLY a single complete sentence (no preamble, no quotes) describing what this screen does or shows. Be specific. Stop after the first sentence.',
2417
2447
  `Screen: "${name}"\nURL: ${url}\n\nContent:\n${snippet}`,
2418
2448
  llmCfg,
2419
- 80,
2449
+ 200,
2420
2450
  );
2421
2451
  if (usage) {
2422
2452
  recordLLMCall({
@@ -2428,7 +2458,10 @@ export async function crawlProject(opts: {
2428
2458
  usage,
2429
2459
  }).catch(() => { });
2430
2460
  }
2431
- return text.trim().replace(/^["']|["']$/g, '');
2461
+ // Keep only the first sentence if the LLM returned multiple
2462
+ const clean = text.trim().replace(/^["']|["']$/g, '');
2463
+ const firstSentence = clean.match(/^[^.!?]+[.!?]/)?.[0] ?? clean;
2464
+ return firstSentence.trim();
2432
2465
  } catch { return undefined; }
2433
2466
  })();
2434
2467
 
@@ -2633,6 +2666,8 @@ export async function crawlProject(opts: {
2633
2666
  }
2634
2667
 
2635
2668
  const linkMappings = await extractLinkMappings(page, origin);
2669
+ // Clear existing elements before re-saving — prevents duplicates on re-crawl
2670
+ await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {});
2636
2671
  // Batch element saves: 4 concurrent DB writes instead of serial round-trips
2637
2672
  for (let _ei = 0; _ei < elements.length; _ei += 4) {
2638
2673
  const _batch = elements.slice(_ei, _ei + 4);
@@ -3375,9 +3410,9 @@ export async function crawlProject(opts: {
3375
3410
  }
3376
3411
  }
3377
3412
 
3378
- // Step 6: Interactive probe — observe real dynamic behaviour (optional, ~30s extra)
3379
- // Auto-enable for sparse crawls (< 30 screens): interaction-gated states are proportionally more common.
3380
- const shouldRunInteractiveProbe = opts.interactive || discovered.filter(d => d.screenId).length < 30;
3413
+ // Step 6: Interactive probe — always-on (C-06) to capture real validation errors, toasts, and modals.
3414
+ // Previously gated on opts.interactive or sparse crawls; now unconditional for accurate expected-result generation.
3415
+ const shouldRunInteractiveProbe = true;
3381
3416
  if (shouldRunInteractiveProbe) {
3382
3417
  await onProgress?.(88, 'Running interactive probe (capturing real errors & modals)');
3383
3418
  const probeInputs = discovered
@@ -3512,7 +3547,10 @@ async function extractElements(stagehand: Stagehand, page: any, llmCfg?: { provi
3512
3547
  // llmCfg reserved for per-task model selection (future: create task-scoped Stagehand)
3513
3548
  // DOM-first: fast, no LLM cost, covers server-rendered and most SPAs.
3514
3549
  // Threshold >= 2 so a lone dark-mode button doesn't suppress AI fallback for rich pages.
3515
- const domElements = await domFallbackElements(page);
3550
+ const domElements = await domFallbackElements(page).catch((err: any) => {
3551
+ console.warn('[crawler] domFallbackElements failed (page context issue or DOM error):', err?.message ?? err);
3552
+ return [] as Array<{ meaning: string; role: string; expectedData?: string; notes?: string; boundingRect?: Record<string, number>; ariaState?: Record<string, string>; parentLandmark?: string }>;
3553
+ });
3516
3554
 
3517
3555
  // FM-8: extract interactive elements from visible same-origin iframes
3518
3556
  const iframeElements: typeof domElements = [];
@@ -3553,12 +3591,40 @@ async function extractElements(stagehand: Stagehand, page: any, llmCfg?: { provi
3553
3591
  } catch { dynamicElements = []; }
3554
3592
  }
3555
3593
 
3556
- if (domElements.length >= 2) return [...domElements, ...iframeElements, ...dynamicElements];
3557
-
3558
- // Content fallback: static/marketing pages have no interactive widgets but have headings,
3559
- // images, links, and meta tags that feed SEO/a11y/visual test generation.
3594
+ // Always capture content elements (headings, meta, cards, nav, FAQ) regardless of interactive count.
3595
+ // Previously this was fallback-only; now both paths run together so static sections are
3596
+ // always visible to the test generator even on pages with forms.
3560
3597
  const contentElements = await contentFallbackElements(page);
3561
- if (contentElements.length > 0) return [...domElements, ...contentElements];
3598
+
3599
+ // Cross-source text deduplication: strips wrapper phrases like "Button to expand '...' details"
3600
+ // and "... FAQ button" so duplicate FAQ buttons from DOM + content sources are collapsed.
3601
+ const crossSourceDedup = <T extends { meaning: string; role: string }>(arr: T[]): T[] => {
3602
+ const stripPhrases = (s: string) => s
3603
+ .replace(/^button to expand ['"]?|['"]?\s*details$/gi, '')
3604
+ .replace(/\s+(faq\s*)?button$/i, '')
3605
+ .replace(/^button:\s*/i, '')
3606
+ .toLowerCase().trim().slice(0, 80);
3607
+ const ROLE_PRIORITY: Record<string, number> = { 'faq-question': 10, 'faq-answer': 9, 'h1': 8, 'h2': 7, 'h3': 6, 'h4': 5, 'heading': 8, button: 3, a: 2 };
3608
+ const normMap = new Map<string, number>();
3609
+ const result: T[] = [];
3610
+ for (const el of arr) {
3611
+ const norm = stripPhrases(el.meaning);
3612
+ if (!norm) { result.push(el); continue; }
3613
+ const existingIdx = normMap.get(norm);
3614
+ if (existingIdx === undefined) { normMap.set(norm, result.length); result.push(el); }
3615
+ else {
3616
+ const exPri = ROLE_PRIORITY[result[existingIdx].role] ?? 1;
3617
+ const thisPri = ROLE_PRIORITY[el.role] ?? 1;
3618
+ if (thisPri > exPri) result[existingIdx] = el;
3619
+ }
3620
+ }
3621
+ return result;
3622
+ };
3623
+
3624
+ if (domElements.length >= 2) return crossSourceDedup([...domElements, ...iframeElements, ...dynamicElements, ...contentElements]);
3625
+
3626
+ // Static/marketing page — no interactive widgets; use content elements.
3627
+ if (contentElements.length > 0) return crossSourceDedup([...domElements, ...contentElements]);
3562
3628
 
3563
3629
  // AI fallback: DOM found nothing or only noise — try AI (pure SPA, shadow DOM, canvas-based).
3564
3630
  try {
@@ -3654,17 +3720,20 @@ async function domFallbackElements(page: any): Promise<Array<{ meaning: string;
3654
3720
  const noise = /^(cookie|consent|privacy|accept|decline|dismiss|close|copy|©|v\d|switch to (dark|light)|dark mode|light mode|toggle (dark|light|theme)|theme (toggle|switch)|share|tweet|follow|subscribe to newsletter|skip to|back to top|\s*$)/i;
3655
3721
 
3656
3722
  const querySelectorAllDeep = (root: any, selector: string): Element[] => {
3657
- const results: Element[] = Array.from(root.querySelectorAll(selector));
3658
- root.querySelectorAll('*').forEach((el: any) => {
3659
- if (el.shadowRoot) {
3660
- results.push(...querySelectorAllDeep(el.shadowRoot, selector));
3661
- }
3662
- });
3723
+ const results: Element[] = [];
3724
+ const queue: any[] = [root];
3725
+ while (queue.length > 0) {
3726
+ const current = queue.shift();
3727
+ Array.from(current.querySelectorAll(selector)).forEach((el: any) => results.push(el));
3728
+ current.querySelectorAll('*').forEach((el: any) => {
3729
+ if (el.shadowRoot) queue.push(el.shadowRoot);
3730
+ });
3731
+ }
3663
3732
  return results;
3664
3733
  };
3665
3734
 
3666
- // Expanded selector with more ARIA roles + rich text editors
3667
- const selector = 'input:not([type=hidden]),select,textarea,button,[role="button"],[role="combobox"],[role="slider"],[role="spinbutton"],[role="switch"],[role="tab"],[role="dialog"],[role="alertdialog"],[role="menu"],[role="menuitem"],[role="listbox"],[role="option"],[role="treeitem"],[role="searchbox"],a[role="button"],[contenteditable="true"]';
3735
+ // Expanded selector — all interactive ARIA roles + native form elements + rich editors
3736
+ const selector = 'input:not([type=hidden]),select,textarea,button,[role="button"],[role="checkbox"],[role="radio"],[role="combobox"],[role="slider"],[role="spinbutton"],[role="switch"],[role="tab"],[role="dialog"],[role="alertdialog"],[role="menu"],[role="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"],[role="listbox"],[role="option"],[role="treeitem"],[role="searchbox"],[role="gridcell"],[role="link"],a[role="button"],[contenteditable="true"]';
3668
3737
 
3669
3738
  querySelectorAllDeep(document, selector).forEach((el: any) => {
3670
3739
  const style = window.getComputedStyle(el);
@@ -3673,7 +3742,11 @@ async function domFallbackElements(page: any): Promise<Array<{ meaning: string;
3673
3742
  if (el.hasAttribute('inert') || el.getAttribute('aria-disabled') === 'true') return;
3674
3743
 
3675
3744
  const rect = el.getBoundingClientRect();
3676
- if (rect.width === 0 && rect.height === 0) return;
3745
+ const hasAccessibleLabel = !!(el.getAttribute('aria-label') || el.getAttribute('aria-labelledby'));
3746
+ if (rect.width === 0 && rect.height === 0 && !hasAccessibleLabel) return;
3747
+
3748
+ // Skip elements inside 3rd-party chat widgets, cookie banners, floating overlays — these appear on EVERY page
3749
+ if (el.closest('[id*="intercom"],[class*="intercom-"],[id*="crisp"],[class*="crisp-"],[id*="freshchat"],[id*="freshdesk"],[id*="drift"],[class*="drift-"],[id*="hubspot-"],[class*="hs-chat"],[class*="hs-widget"],[id*="tawk"],[class*="tawk-"],[id*="chat-widget"],[class*="chat-widget"],[class*="chatbot"],[id*="zopim"],[id*="ze-snippet"],[class*="cookie-banner"],[class*="cookie-consent"],[class*="gdpr-banner"],[id*="cookie-law"],[class*="cookie-law"],[id*="popup-form"],[class*="popup-form"],[class*="floating-form"],[class*="sticky-cta"],[id*="wm-ipp"],[class*="wm-ipp"]')) return;
3677
3750
 
3678
3751
  const tag = el.tagName.toLowerCase();
3679
3752
  const ariaRole = el.getAttribute('role');
@@ -3867,22 +3940,27 @@ async function domFallbackElements(page: any): Promise<Array<{ meaning: string;
3867
3940
  ['email', 'password', 'text', 'tel', 'date', 'number', 'select', 'textarea', 'url', 'file'].includes(e.role) ? 0 : 1;
3868
3941
  out.sort((a, b) => priority(a) - priority(b));
3869
3942
  return out; // Cap removed
3870
- }).catch(() => []);
3943
+ }).then((elems: any[]) => elems.map((e: any) => ({ ...e, isInteractive: true }))).catch(() => []);
3871
3944
  }
3872
3945
 
3873
- /** For pages with no interactive elements (marketing/static pages), capture content anatomy:
3874
- * headings, images-with-alt, CTA links outside nav/header/footer, and meta tags.
3875
- * These give the test generator something to work with for SEO, a11y, and visual tests. */
3946
+ /** Capture content anatomy for EVERY page — not just static ones.
3947
+ * Headings, meta tags, cards, chips, FAQ items, nav/footer structure, section text.
3948
+ * Previously fallback-only; now always runs alongside domFallbackElements so
3949
+ * the test generator sees ALL page content, not just interactive widgets. */
3876
3950
  async function contentFallbackElements(page: any): Promise<Array<{ meaning: string; role: string; expectedData?: string; notes?: string }>> {
3877
3951
  return page.evaluate(() => {
3878
3952
  const out: { meaning: string; role: string; notes?: string }[] = [];
3879
3953
  const seen = new Set<string>();
3880
3954
  const addUniq = (item: { meaning: string; role: string; notes?: string }) => {
3881
3955
  const key = `${item.role}:${item.meaning.toLowerCase().slice(0, 60)}`;
3882
- if (!seen.has(key) && item.meaning.trim().length > 0) { seen.add(key); out.push(item); }
3956
+ if (!seen.has(key) && item.meaning.trim().length > 1) { seen.add(key); out.push(item); }
3957
+ };
3958
+ const isVisible = (el: Element) => {
3959
+ const s = window.getComputedStyle(el);
3960
+ return s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0' && (el as HTMLElement).offsetParent !== null;
3883
3961
  };
3884
3962
 
3885
- // 1. Page-level meta (title, description, og:)
3963
+ // 1. Page-level meta (title, description, og:, canonical)
3886
3964
  const title = document.title?.trim();
3887
3965
  if (title) addUniq({ meaning: title, role: 'page-title', notes: JSON.stringify({ tag: 'title' }) });
3888
3966
  const desc = document.querySelector('meta[name="description"]')?.getAttribute('content')?.trim();
@@ -3892,40 +3970,678 @@ async function contentFallbackElements(page: any): Promise<Array<{ meaning: stri
3892
3970
  const canonical = document.querySelector('link[rel="canonical"]')?.getAttribute('href')?.trim();
3893
3971
  if (canonical) addUniq({ meaning: canonical, role: 'canonical-url', notes: JSON.stringify({ tag: 'link[rel=canonical]' }) });
3894
3972
 
3895
- // 2. Headings — page structure
3896
- document.querySelectorAll('h1, h2, h3').forEach((h: any) => {
3897
- const text = h.textContent?.replace(/\s+/g, ' ').trim().slice(0, 80);
3973
+ // 2. All headings h1–h6 — full page structure
3974
+ document.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach((h: any) => {
3975
+ if (!isVisible(h)) return;
3976
+ const text = h.textContent?.replace(/\s+/g, ' ').trim().slice(0, 100);
3898
3977
  if (text) addUniq({ meaning: text, role: h.tagName.toLowerCase(), notes: JSON.stringify({ level: h.tagName }) });
3899
3978
  });
3900
3979
 
3901
- // 3. Images with alt text — accessibility targets
3902
- document.querySelectorAll('img[alt]').forEach((img: any) => {
3903
- const alt = img.getAttribute('alt')?.trim().slice(0, 80);
3904
- const src = img.getAttribute('src')?.slice(0, 120);
3905
- if (alt) addUniq({ meaning: alt, role: 'image', notes: JSON.stringify({ src }) });
3980
+ // 3. FAQ / accordion — <details>/<summary>, ARIA pattern, and class-name scan
3981
+ document.querySelectorAll('details, [data-state="closed"], [data-state="open"]').forEach((el: any) => {
3982
+ if (!isVisible(el)) return;
3983
+ const summary = el.tagName === 'DETAILS'
3984
+ ? el.querySelector('summary')
3985
+ : el.querySelector('[class*="trigger" i], [class*="header" i], [class*="question" i], [class*="title" i]') ?? el;
3986
+ const qText = summary?.textContent?.replace(/\s+/g, ' ').trim().slice(0, 120);
3987
+ if (qText && qText.length > 3) {
3988
+ addUniq({ meaning: qText, role: 'faq-question', notes: JSON.stringify({ expanded: el.hasAttribute('open') || el.getAttribute('data-state') === 'open' }) });
3989
+ }
3990
+ // Answer: for <details> it's the non-summary child; for React accordions try both child content and next sibling
3991
+ const contentEl = el.tagName === 'DETAILS'
3992
+ ? Array.from(el.children).find((c: any) => c.tagName !== 'SUMMARY')
3993
+ : (el.querySelector('[class*="content" i], [class*="answer" i], [class*="panel" i], [class*="body" i]:not(body)') ?? el.nextElementSibling);
3994
+ if (contentEl) {
3995
+ const aText = (contentEl as any).textContent?.replace(/\s+/g, ' ').trim().slice(0, 200);
3996
+ if (aText && aText.length > 5 && aText !== qText) addUniq({ meaning: aText, role: 'faq-answer', notes: JSON.stringify({ question: qText }) });
3997
+ }
3998
+ });
3999
+ // 3b. FAQ by class-name container scan — catches sites without ARIA or data-state attributes
4000
+ document.querySelectorAll('[class*="faq" i]:not(script):not(style):not(nav), [id*="faq" i]:not(script):not(style)').forEach((container: any) => {
4001
+ if (!isVisible(container)) return;
4002
+ const qEls = container.querySelectorAll('[class*="question" i], [class*="trigger" i], summary, dt, [class*="heading" i]');
4003
+ const aEls = container.querySelectorAll('[class*="answer" i], [class*="content" i]:not(main):not(article), [class*="panel" i], dd');
4004
+ qEls.forEach((q: any) => {
4005
+ const qText = q.textContent?.replace(/\s+/g, ' ').trim().slice(0, 120);
4006
+ if (qText && qText.length > 3) addUniq({ meaning: qText, role: 'faq-question', notes: JSON.stringify({ source: 'class-faq' }) });
4007
+ });
4008
+ aEls.forEach((a: any) => {
4009
+ const aText = a.textContent?.replace(/\s+/g, ' ').trim().slice(0, 200);
4010
+ if (aText && aText.length > 5) addUniq({ meaning: aText, role: 'faq-answer', notes: JSON.stringify({ source: 'class-faq' }) });
4011
+ });
3906
4012
  });
3907
4013
 
3908
- // 4. CTA / content links — outside nav, header, footer
4014
+ // 4. Card and chip content (component-library agnostic)
4015
+ document.querySelectorAll('[class*="card" i]:not(nav):not(header):not(footer), [class*="chip" i], [class*="badge" i], [class*="tag" i], [class*="pill" i]').forEach((el: any) => {
4016
+ if (!isVisible(el)) return;
4017
+ const text = el.textContent?.replace(/\s+/g, ' ').trim().slice(0, 150);
4018
+ if (text && text.length > 2 && text.length < 200) addUniq({ meaning: text, role: 'content-card' });
4019
+ });
4020
+
4021
+ // 5. Feature / service list items inside main content (capped per category)
4022
+ const mainContent = document.querySelector('main, [role="main"], article') ?? document.body;
4023
+ let listItemCount = 0;
4024
+ mainContent.querySelectorAll('li').forEach((li: any) => {
4025
+ if (listItemCount >= 30) return;
4026
+ if (!isVisible(li)) return;
4027
+ const text = li.textContent?.replace(/\s+/g, ' ').trim().slice(0, 150);
4028
+ if (text && text.length > 3 && text.length < 200) { addUniq({ meaning: text, role: 'list-item' }); listItemCount++; }
4029
+ });
4030
+
4031
+ // 6. First paragraph in each section (key descriptive copy)
4032
+ mainContent.querySelectorAll('section > p:first-of-type, article > p:first-of-type, [class*="section" i] > p:first-of-type').forEach((p: any) => {
4033
+ if (!isVisible(p)) return;
4034
+ const text = p.textContent?.replace(/\s+/g, ' ').trim().slice(0, 250);
4035
+ if (text && text.length > 10) addUniq({ meaning: text, role: 'section-paragraph' });
4036
+ });
4037
+
4038
+ // 7. CTA section text (standalone paragraphs near buttons that aren't form fields)
4039
+ document.querySelectorAll('[class*="cta" i] p, [class*="call-to-action" i] p, section:not(form) > p').forEach((p: any) => {
4040
+ if (!isVisible(p)) return;
4041
+ const text = p.textContent?.replace(/\s+/g, ' ').trim().slice(0, 300);
4042
+ if (text && text.length > 10 && text.length < 400) addUniq({ meaning: text, role: 'cta-section' });
4043
+ });
4044
+
4045
+ // 8. Navigation links — explicitly captured (excluded from interactive element selector)
4046
+ // Use a lighter visibility check: skip offsetParent because nav is often inside a responsive
4047
+ // container (e.g. hidden lg:grid) that may be display:none at the crawler's viewport,
4048
+ // causing offsetParent to be null even though the link exists in the page's nav structure.
4049
+ const isNavVisible = (el: Element) => {
4050
+ const s = window.getComputedStyle(el);
4051
+ return s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0';
4052
+ };
4053
+ document.querySelectorAll('nav a, [role="navigation"] a, header a, header nav a, footer nav a, [aria-label*="navigation" i] a, [aria-label*="menu" i] a').forEach((a: any) => {
4054
+ if (!isNavVisible(a)) return;
4055
+ const label = (a.getAttribute('aria-label') || a.textContent?.replace(/\s+/g, ' ').trim() || '').slice(0, 60);
4056
+ const href = a.href;
4057
+ if (!label || !href || /^(javascript:|#$)/i.test(href)) return;
4058
+ // Walk up to find nearest nav/header/footer ancestor to get landmark label
4059
+ let landmark = 'Site Navigation';
4060
+ let el: any = a.parentElement;
4061
+ while (el) {
4062
+ const tag = el.tagName?.toLowerCase();
4063
+ const role = el.getAttribute?.('role')?.toLowerCase();
4064
+ if (tag === 'header' || role === 'banner') { landmark = 'Header'; break; }
4065
+ if (tag === 'footer' || role === 'contentinfo') { landmark = 'Footer'; break; }
4066
+ if (tag === 'nav' || role === 'navigation') {
4067
+ const directLabel = el.getAttribute('aria-label')?.trim() ?? '';
4068
+ const labelId = el.getAttribute('aria-labelledby');
4069
+ const referencedLabel = labelId ? (document.getElementById(labelId)?.textContent?.trim() ?? '') : '';
4070
+ landmark = directLabel || referencedLabel || 'Site Navigation';
4071
+ break;
4072
+ }
4073
+ el = el.parentElement;
4074
+ }
4075
+ addUniq({ meaning: label, role: 'nav-link', notes: JSON.stringify({ href: href.slice(0, 150), landmark }) });
4076
+ });
4077
+
4078
+ // 9. Breadcrumb items
4079
+ document.querySelectorAll('[aria-label*="breadcrumb" i] a, [class*="breadcrumb" i] a, nav[aria-label*="breadcrumb" i] a').forEach((a: any) => {
4080
+ const label = a.textContent?.replace(/\s+/g, ' ').trim().slice(0, 60);
4081
+ const href = a.href;
4082
+ if (label) addUniq({ meaning: label, role: 'breadcrumb-item', notes: JSON.stringify({ href: href?.slice(0, 150) }) });
4083
+ });
4084
+
4085
+ // 10. Footer links and contact info (capped at 25)
4086
+ // Matches semantic <footer>, ARIA contentinfo, and common div/section footer patterns
4087
+ let footerLinkCount = 0;
4088
+ const footerEls = new Set<Element>();
4089
+ document.querySelectorAll('footer, [role="contentinfo"], [class*="footer" i]:not(script):not(style):not(nav), [id*="footer" i]:not(script):not(style)').forEach((f: any) => footerEls.add(f));
4090
+ const footerLinks: Element[] = [];
4091
+ footerEls.forEach((f) => f.querySelectorAll('a').forEach((a: Element) => footerLinks.push(a)));
4092
+ // Deduplicate links that appear in multiple matched footer containers
4093
+ const seenFooterHrefs = new Set<string>();
4094
+ footerLinks.filter((a: any) => {
4095
+ const href = a.href ?? '';
4096
+ if (seenFooterHrefs.has(href)) return false;
4097
+ seenFooterHrefs.add(href);
4098
+ return true;
4099
+ }).forEach((a: any) => {
4100
+ if (footerLinkCount >= 25) return;
4101
+ if (!isVisible(a)) return;
4102
+ const label = (a.getAttribute('aria-label') || a.textContent?.replace(/\s+/g, ' ').trim() || '').slice(0, 60);
4103
+ const href = a.href ?? '';
4104
+ if (!label) return;
4105
+ if (/^mailto:/i.test(href)) {
4106
+ addUniq({ meaning: label || href, role: 'footer-contact', notes: JSON.stringify({ href }) }); footerLinkCount++;
4107
+ } else if (/^tel:/i.test(href)) {
4108
+ addUniq({ meaning: label || href, role: 'footer-contact', notes: JSON.stringify({ href }) }); footerLinkCount++;
4109
+ } else if (label.length > 0) {
4110
+ addUniq({ meaning: label, role: 'footer-link', notes: JSON.stringify({ href: href.slice(0, 150) }) }); footerLinkCount++;
4111
+ }
4112
+ });
4113
+
4114
+ // 11. All image types — img tags (with or without alt), picture/source, CSS background-image, lazy-load patterns
4115
+ let imgCount = 0;
4116
+ // Lighter visibility check for images: skip only display:none or visibility:hidden.
4117
+ // Do NOT check offsetParent (fails for position:fixed heroes) or opacity (kills fade-in animations).
4118
+ const isImgVisible = (el: any) => {
4119
+ const s = window.getComputedStyle(el);
4120
+ return s.display !== 'none' && s.visibility !== 'hidden';
4121
+ };
4122
+ // 11a. <img> — both with and without alt (capped at 20)
4123
+ document.querySelectorAll('img').forEach((img: any) => {
4124
+ if (imgCount >= 20) return;
4125
+ if (!isImgVisible(img)) return;
4126
+ const alt = img.getAttribute('alt')?.trim().slice(0, 80) ?? '';
4127
+ const src = (img.getAttribute('src') || img.getAttribute('data-src') || img.getAttribute('data-lazy-src') || img.getAttribute('data-original') || '').slice(0, 120);
4128
+ // Also grab first URL from srcset (Next.js Image component uses srcset directly on <img>)
4129
+ const srcsetFirst = img.getAttribute('srcset')?.split(',')[0]?.trim().split(' ')[0]?.slice(0, 120) ?? '';
4130
+ const resolvedSrc = src || srcsetFirst;
4131
+ if (!resolvedSrc && !alt) return;
4132
+ const label = alt || resolvedSrc.split('/').pop()?.split('?')[0]?.slice(0, 60) || 'Image';
4133
+ addUniq({ meaning: label, role: alt ? 'image' : 'image-no-alt', notes: JSON.stringify({ src: resolvedSrc, alt, hasAlt: !!alt }) });
4134
+ imgCount++;
4135
+ });
4136
+ // 11b. <picture> / <source srcset> — responsive images
4137
+ document.querySelectorAll('picture source[srcset]').forEach((src: any) => {
4138
+ if (imgCount >= 25) return;
4139
+ const srcset = src.getAttribute('srcset')?.split(',')[0]?.trim().split(' ')[0]?.slice(0, 120) ?? '';
4140
+ const media = src.getAttribute('media') ?? '';
4141
+ if (srcset) { addUniq({ meaning: `Responsive image${media ? ' (' + media + ')' : ''}: ${srcset.split('/').pop()?.split('?')[0] ?? ''}`, role: 'picture-source', notes: JSON.stringify({ srcset: srcset.slice(0, 150), media }) }); imgCount++; }
4142
+ });
4143
+ // 11c. CSS background-image via inline style and computed style (capped at 15)
4144
+ let bgCount = 0;
4145
+ document.querySelectorAll('[style*="background-image"], [style*="background:"]').forEach((el: any) => {
4146
+ if (bgCount >= 15) return;
4147
+ if (!isImgVisible(el)) return;
4148
+ const style = el.getAttribute('style') ?? '';
4149
+ const match = style.match(/url\(['"]?([^'")\s]+)['"]?\)/i);
4150
+ if (match) {
4151
+ const url = match[1].slice(0, 120);
4152
+ const role = el.tagName.toLowerCase();
4153
+ const context = (el.getAttribute('aria-label') || el.getAttribute('title') || el.className.split(' ').find((c: string) => c.length > 3 && !['true','false'].includes(c)) || '').slice(0, 60);
4154
+ addUniq({ meaning: context || `BG image on <${role}>: ${url.split('/').pop()?.split('?')[0] ?? url}`, role: 'bg-image', notes: JSON.stringify({ url, element: role, context }) });
4155
+ bgCount++;
4156
+ }
4157
+ });
4158
+ // 11d. Computed background-image on key visual elements (expanded class list)
4159
+ document.querySelectorAll('[class*="hero" i], [class*="banner" i], [class*="cover" i], [class*="header-bg" i], [class*="card" i]:not(nav), [class*="feature" i], [class*="service" i], [class*="about" i], [class*="team" i], section').forEach((el: any) => {
4160
+ if (bgCount >= 15) return;
4161
+ if (!isImgVisible(el)) return;
4162
+ try {
4163
+ const computed = window.getComputedStyle(el).backgroundImage;
4164
+ if (computed && computed !== 'none' && computed.includes('url(')) {
4165
+ const match = computed.match(/url\(['"]?([^'")\s]+)['"]?\)/i);
4166
+ if (match) {
4167
+ const url = match[1].slice(0, 120);
4168
+ const tag = el.tagName.toLowerCase();
4169
+ const cls = Array.from(el.classList as DOMTokenList).find((c: any) => typeof c === 'string' && c.length > 3 && !/^(true|false|active|visible)$/.test(c)) ?? '';
4170
+ addUniq({ meaning: `CSS bg image on .${cls || tag}: ${url.split('/').pop()?.split('?')[0] ?? url}`, role: 'bg-image', notes: JSON.stringify({ url, cssClass: cls, element: tag }) });
4171
+ bgCount++;
4172
+ }
4173
+ }
4174
+ } catch { /* getComputedStyle not always available */ }
4175
+ });
4176
+ // 11e. Lazy-load data attribute patterns (data-bg, data-background, data-lazy)
4177
+ document.querySelectorAll('[data-bg], [data-background], [data-background-image]').forEach((el: any) => {
4178
+ if (bgCount >= 15) return;
4179
+ if (!isVisible(el)) return;
4180
+ const url = (el.getAttribute('data-bg') || el.getAttribute('data-background') || el.getAttribute('data-background-image') || '').slice(0, 120);
4181
+ if (url) { addUniq({ meaning: `Lazy BG image: ${url.split('/').pop()?.split('?')[0] ?? url}`, role: 'bg-image', notes: JSON.stringify({ url, pattern: 'data-bg' }) }); bgCount++; }
4182
+ });
4183
+
4184
+ // 12. Content links outside nav/header/footer (capped at 20)
3909
4185
  const navTags = new Set(['nav', 'header', 'footer']);
4186
+ let contentLinkCount = 0;
3910
4187
  document.querySelectorAll('a[href]').forEach((a: any) => {
4188
+ if (contentLinkCount >= 20) return;
4189
+ if (!isVisible(a)) return;
3911
4190
  let p = a.parentElement;
3912
4191
  while (p) { if (navTags.has(p.tagName?.toLowerCase())) return; p = p.parentElement; }
3913
4192
  const label = (a.getAttribute('aria-label') || a.textContent?.replace(/\s+/g, ' ').trim() || '').slice(0, 80);
3914
4193
  const href = a.href;
3915
- if (label && href && !/^(#|javascript:|mailto:)/i.test(href)) {
4194
+ if (label && href && !/^(#$|javascript:|mailto:|tel:)/i.test(href)) {
3916
4195
  const isExternal = !href.includes(location.hostname);
3917
4196
  addUniq({ meaning: label, role: isExternal ? 'external-link' : 'internal-link', notes: JSON.stringify({ href: href.slice(0, 120) }) });
4197
+ contentLinkCount++;
3918
4198
  }
3919
4199
  });
3920
4200
 
3921
- // 5. Animated / interactive content markers (CSS animation, video, canvas)
4201
+ // 13. Animated / interactive content markers
3922
4202
  const hasAnimation = !!document.querySelector('[class*="animate"], [class*="motion"], [class*="transition"], [class*="scroll-"], [data-aos], [data-scroll]');
3923
4203
  if (hasAnimation) addUniq({ meaning: 'CSS animations / scroll effects detected on page', role: 'animation-layer', notes: JSON.stringify({ detected: true }) });
3924
4204
  if (document.querySelector('video')) addUniq({ meaning: 'Video element present', role: 'video', notes: JSON.stringify({ count: document.querySelectorAll('video').length }) });
3925
4205
  if (document.querySelector('canvas')) addUniq({ meaning: 'Canvas element present', role: 'canvas' });
3926
4206
 
3927
- return out.slice(0, 30);
3928
- }).catch(() => []);
4207
+ // C-07: Section structure as named semantic units
4208
+ document.querySelectorAll('main section, article section, [role="main"] section').forEach((el: any) => {
4209
+ if (!isVisible(el)) return;
4210
+ const heading = el.querySelector('h1,h2,h3,h4')?.textContent?.trim() ?? '';
4211
+ const itemCount = el.querySelectorAll('li, [class*="card" i], [class*="chip" i]').length;
4212
+ const paragraphs = el.querySelectorAll('p').length;
4213
+ const meaning = heading || `Section (${itemCount} items)`;
4214
+ if (meaning) addUniq({ meaning, role: 'page-section', notes: JSON.stringify({ heading, itemCount, paragraphs, id: el.id || el.getAttribute('data-section') }) });
4215
+ });
4216
+
4217
+ // 14. Extended meta tags (og:description, og:image, twitter card)
4218
+ const ogDesc = document.querySelector('meta[property="og:description"]')?.getAttribute('content')?.trim();
4219
+ if (ogDesc) addUniq({ meaning: ogDesc, role: 'og-description', notes: JSON.stringify({ tag: 'meta[property=og:description]' }) });
4220
+ const ogImage = document.querySelector('meta[property="og:image"]')?.getAttribute('content')?.trim();
4221
+ if (ogImage) addUniq({ meaning: ogImage, role: 'og-image', notes: JSON.stringify({ tag: 'meta[property=og:image]', src: ogImage }) });
4222
+ const twitterTitle = document.querySelector('meta[name="twitter:title"]')?.getAttribute('content')?.trim();
4223
+ if (twitterTitle) addUniq({ meaning: twitterTitle, role: 'twitter-title', notes: JSON.stringify({ tag: 'meta[name=twitter:title]' }) });
4224
+ const twitterDesc = document.querySelector('meta[name="twitter:description"]')?.getAttribute('content')?.trim();
4225
+ if (twitterDesc) addUniq({ meaning: twitterDesc, role: 'twitter-description', notes: JSON.stringify({ tag: 'meta[name=twitter:description]' }) });
4226
+ const jsonFonts: string[] = [];
4227
+ document.querySelectorAll('link[rel="preload"][as="font"], link[href*="fonts.googleapis"]').forEach((l: any) => {
4228
+ const url = l.href?.slice(0, 120);
4229
+ if (url) jsonFonts.push(url);
4230
+ });
4231
+ if (jsonFonts.length) addUniq({ meaning: `Font imports: ${jsonFonts.length} font file(s)`, role: 'font-stack', notes: JSON.stringify({ fonts: jsonFonts.slice(0, 5) }) });
4232
+
4233
+ // 15. JSON-LD structured data — extract FAQ answers, deliverables, tech stack, metrics
4234
+ document.querySelectorAll('script[type="application/ld+json"], script[class*="tsr"]').forEach((s: any) => {
4235
+ try {
4236
+ const data = JSON.parse(s.textContent ?? '{}');
4237
+ const items = Array.isArray(data) ? data : [data];
4238
+ for (const item of items) {
4239
+ // FAQ answers from JSON-LD (often NOT rendered in collapsed accordions)
4240
+ const faqArr = item.mainEntity ?? item.faqs ?? item.questions ?? item.faq ?? [];
4241
+ (Array.isArray(faqArr) ? faqArr : []).forEach((f: any) => {
4242
+ const q = f.name ?? f.question ?? '';
4243
+ const a = f.acceptedAnswer?.text ?? f.answer ?? '';
4244
+ if (q) addUniq({ meaning: String(q).slice(0, 150), role: 'faq-question', notes: JSON.stringify({ source: 'json-ld' }) });
4245
+ if (a) addUniq({ meaning: String(a).slice(0, 200), role: 'faq-answer', notes: JSON.stringify({ question: String(q).slice(0, 80), source: 'json-ld' }) });
4246
+ });
4247
+ // Deliverables with descriptions
4248
+ const deliverables = item.deliverables ?? item.services ?? item.offerings ?? [];
4249
+ (Array.isArray(deliverables) ? deliverables : []).forEach((d: any) => {
4250
+ const title = d.title ?? d.name ?? '';
4251
+ const desc = d.description ?? d.desc ?? '';
4252
+ if (title) addUniq({ meaning: `${title}${desc ? ': ' + desc.slice(0, 120) : ''}`, role: 'deliverable', notes: JSON.stringify({ title, description: String(desc).slice(0, 200) }) });
4253
+ });
4254
+ // Tech stack items
4255
+ const stack = item.stack ?? item.technologies ?? item.tools ?? [];
4256
+ (Array.isArray(stack) ? stack : []).forEach((tool: any) => {
4257
+ const name = typeof tool === 'string' ? tool : (tool.name ?? '');
4258
+ if (name) addUniq({ meaning: name, role: 'tech-stack-item', notes: JSON.stringify({ source: 'json-ld' }) });
4259
+ });
4260
+ // Outcome/metric cards
4261
+ const outcomes = item.outcomes ?? item.metrics ?? item.stats ?? [];
4262
+ (Array.isArray(outcomes) ? outcomes : []).forEach((m: any) => {
4263
+ const metric = m.metric ?? m.value ?? '';
4264
+ const label = m.label ?? m.name ?? '';
4265
+ if (metric && label) addUniq({ meaning: `${metric} — ${label}`, role: 'metric-card', notes: JSON.stringify({ metric, label }) });
4266
+ });
4267
+ // Features list
4268
+ const features = item.features ?? item.capabilities ?? [];
4269
+ (Array.isArray(features) ? features : []).forEach((f: any) => {
4270
+ const name = typeof f === 'string' ? f : (f.name ?? '');
4271
+ if (name) addUniq({ meaning: name, role: 'feature-badge', notes: JSON.stringify({ source: 'json-ld' }) });
4272
+ });
4273
+ // HowTo steps (tutorials, guides, recipes)
4274
+ if (item['@type'] === 'HowTo' || item['@type'] === 'Recipe') {
4275
+ const steps = item.step ?? item.steps ?? [];
4276
+ (Array.isArray(steps) ? steps : []).forEach((step: any, idx: number) => {
4277
+ const text = step.text ?? step.description ?? (typeof step === 'string' ? step : '');
4278
+ const name = step.name ?? `Step ${idx + 1}`;
4279
+ if (text) addUniq({ meaning: `${name}: ${String(text).slice(0, 200)}`, role: 'howto-step', notes: JSON.stringify({ stepIndex: idx + 1, name }) });
4280
+ });
4281
+ }
4282
+ // BreadcrumbList — page hierarchy for navigation test generation
4283
+ if (item['@type'] === 'BreadcrumbList') {
4284
+ const crumbs = item.itemListElement ?? [];
4285
+ (Array.isArray(crumbs) ? crumbs : []).forEach((crumb: any) => {
4286
+ const name = crumb.name ?? crumb.item?.name ?? '';
4287
+ const url = crumb.item?.['@id'] ?? crumb.item ?? '';
4288
+ if (name) addUniq({ meaning: name, role: 'breadcrumb-item', notes: JSON.stringify({ position: crumb.position, url }) });
4289
+ });
4290
+ }
4291
+ // Event details (conference, webinar, workshop)
4292
+ if (item['@type'] === 'Event') {
4293
+ const name = item.name ?? '';
4294
+ const startDate = item.startDate ?? '';
4295
+ const location = item.location?.name ?? item.location?.address?.streetAddress ?? '';
4296
+ if (name) addUniq({ meaning: name, role: 'event-name', notes: JSON.stringify({ startDate, location, source: 'json-ld' }) });
4297
+ }
4298
+ // JobPosting — for job boards and career pages
4299
+ if (item['@type'] === 'JobPosting') {
4300
+ const title = item.title ?? '';
4301
+ const desc = item.description ?? '';
4302
+ const employer = item.hiringOrganization?.name ?? '';
4303
+ if (title) addUniq({ meaning: `${title}${employer ? ' at ' + employer : ''}`, role: 'job-posting', notes: JSON.stringify({ description: String(desc).slice(0, 200) }) });
4304
+ }
4305
+ // Review / AggregateRating — for product and service pages
4306
+ if (item['@type'] === 'Review') {
4307
+ const body = item.reviewBody ?? '';
4308
+ const rating = item.reviewRating?.ratingValue ?? '';
4309
+ const author = item.author?.name ?? '';
4310
+ if (body) addUniq({ meaning: String(body).slice(0, 200), role: 'review-text', notes: JSON.stringify({ rating, author }) });
4311
+ }
4312
+ }
4313
+ } catch { /* non-fatal — malformed JSON-LD */ }
4314
+ });
4315
+
4316
+ // 16. Tab panels — capture visible and aria-labeled panels (process steps, tabbed content)
4317
+ document.querySelectorAll('[role="tabpanel"], [data-state="active"][id*="panel"], [id*="panel"][aria-labelledby]').forEach((el: any) => {
4318
+ if (!isVisible(el)) return;
4319
+ const heading = el.querySelector('h2, h3, h4')?.textContent?.trim() ?? '';
4320
+ const desc = el.querySelector('p')?.textContent?.replace(/\s+/g, ' ').trim().slice(0, 200) ?? '';
4321
+ const badges = Array.from(el.querySelectorAll('[class*="badge" i], [class*="tag" i], [class*="pill" i], [class*="chip" i], [class*="rounded-full" i]')).map((b: any) => b.textContent?.trim()).filter(Boolean).slice(0, 8);
4322
+ const meaning = heading || desc;
4323
+ if (meaning) addUniq({ meaning: meaning.slice(0, 150), role: 'tab-panel', notes: JSON.stringify({ heading, description: desc, badges, panelId: el.id }) });
4324
+ });
4325
+
4326
+ // 17. FAQ accordion: aria-controls pattern — find answer panel via aria-controls attribute
4327
+ // Include both aria-expanded buttons and plain aria-controls (some FAQ components omit aria-expanded)
4328
+ document.querySelectorAll('[aria-controls]').forEach((btn: any) => {
4329
+ if (!isVisible(btn)) return;
4330
+ const panelId = btn.getAttribute('aria-controls');
4331
+ if (!panelId) return;
4332
+ const panel = document.getElementById(panelId);
4333
+ if (!panel) return;
4334
+ const qText = btn.textContent?.replace(/\s+/g, ' ').trim().slice(0, 120);
4335
+ // Read panel text even if hidden (accordion may be collapsed)
4336
+ const aText = panel.textContent?.replace(/\s+/g, ' ').trim().slice(0, 200);
4337
+ if (qText && qText.length > 3) addUniq({ meaning: qText, role: 'faq-question', notes: JSON.stringify({ panelId, expanded: btn.getAttribute('aria-expanded') }) });
4338
+ if (aText && aText.length > 5 && aText !== qText) addUniq({ meaning: aText, role: 'faq-answer', notes: JSON.stringify({ question: qText?.slice(0, 80), source: 'aria-controls' }) });
4339
+ });
4340
+
4341
+ // 18. Tech stack / tool pills — monospace pill elements outside nav/footer
4342
+ const mainEl = document.querySelector('main, [role="main"], article') ?? document.body;
4343
+ mainEl.querySelectorAll('[class*="mono" i]:not(code):not(pre), [class*="pill" i]:not(nav), [class*="chip" i]:not(nav), [class*="tag" i]:not(nav), [class*="badge" i]:not(nav)').forEach((el: any) => {
4344
+ if (!isVisible(el)) return;
4345
+ const text = el.textContent?.replace(/\s+/g, ' ').trim().slice(0, 60);
4346
+ if (text && text.length > 1 && text.length < 60 && !text.includes(' ')) {
4347
+ // Single-word or hyphenated tool name
4348
+ addUniq({ meaning: text, role: 'tech-stack-item', notes: JSON.stringify({ source: 'dom-pill' }) });
4349
+ } else if (text && text.length > 1 && text.length < 40) {
4350
+ addUniq({ meaning: text, role: 'feature-badge', notes: JSON.stringify({ source: 'dom-pill' }) });
4351
+ }
4352
+ });
4353
+
4354
+ // 19. Related/linked service cards in main content (cards with links to other pages)
4355
+ mainEl.querySelectorAll('a[href][class*="card" i], [class*="card" i] a[href], [class*="service" i][href], a[class*="service" i]').forEach((el: any) => {
4356
+ if (!isVisible(el)) return;
4357
+ const anchor = el.tagName === 'A' ? el : el.querySelector('a');
4358
+ if (!anchor) return;
4359
+ const href = anchor.href;
4360
+ if (!href || /^(#|javascript:|mailto:|tel:)/i.test(href)) return;
4361
+ const heading = el.querySelector('h2, h3, h4')?.textContent?.trim() ?? '';
4362
+ const desc = el.querySelector('p')?.textContent?.replace(/\s+/g, ' ').trim().slice(0, 120) ?? '';
4363
+ const label = anchor.textContent?.replace(/\s+/g, ' ').trim().slice(0, 80) ?? '';
4364
+ const meaning = heading || label || 'Linked card';
4365
+ if (meaning && meaning !== 'Linked card' || (heading && desc)) {
4366
+ addUniq({ meaning: `${heading || meaning}${desc ? ': ' + desc : ''}`.slice(0, 180), role: 'service-card', notes: JSON.stringify({ href: href.slice(0, 150), heading, description: desc }) });
4367
+ }
4368
+ });
4369
+
4370
+ // 20. Metric/outcome cards — structured numeric data cards
4371
+ mainEl.querySelectorAll('[class*="metric" i], [class*="outcome" i], [class*="stat" i], [class*="kpi" i]').forEach((el: any) => {
4372
+ if (!isVisible(el)) return;
4373
+ const text = el.textContent?.replace(/\s+/g, ' ').trim().slice(0, 100);
4374
+ if (text && text.length > 2 && text.length < 120) addUniq({ meaning: text, role: 'metric-card', notes: JSON.stringify({ source: 'dom-class' }) });
4375
+ });
4376
+
4377
+ // 21. Screen-reader-only headings (sr-only) — accessibility landmarks hidden from visual view
4378
+ document.querySelectorAll('.sr-only, [class*="sr-only" i], [class*="visually-hidden" i]').forEach((el: any) => {
4379
+ const tag = el.tagName?.toLowerCase() ?? '';
4380
+ if (!/^h[1-6]$/.test(tag)) return;
4381
+ const text = el.textContent?.replace(/\s+/g, ' ').trim().slice(0, 100);
4382
+ if (text) addUniq({ meaning: text, role: `${tag}-sr-only`, notes: JSON.stringify({ srOnly: true, tag }) });
4383
+ });
4384
+
4385
+ // 22. CSS custom property (design token) detection
4386
+ // Step 1: enumerate :root / html rules from all same-origin stylesheets
4387
+ try {
4388
+ const cssTokens: Record<string, string> = {};
4389
+ const rootStyle = window.getComputedStyle(document.documentElement);
4390
+ // Enumerate from stylesheets (catches ALL custom properties, not just a hardcoded list)
4391
+ try {
4392
+ for (let si = 0; si < document.styleSheets.length; si++) {
4393
+ try {
4394
+ const sheet = document.styleSheets[si];
4395
+ const rules = sheet.cssRules;
4396
+ for (let ri = 0; ri < rules.length; ri++) {
4397
+ const rule = rules[ri] as any;
4398
+ if (rule.selectorText === ':root' || rule.selectorText === 'html' || rule.selectorText === ':root, html') {
4399
+ const style = rule.style;
4400
+ for (let pi = 0; pi < style.length; pi++) {
4401
+ const prop = style[pi];
4402
+ if (prop.startsWith('--') && Object.keys(cssTokens).length < 30) {
4403
+ const val = style.getPropertyValue(prop).trim();
4404
+ if (val) cssTokens[prop] = val.slice(0, 60);
4405
+ }
4406
+ }
4407
+ }
4408
+ }
4409
+ } catch { /* cross-origin sheet, skip */ }
4410
+ }
4411
+ } catch { /* no styleSheets access */ }
4412
+ // Step 2: fall back to computed value probe with common framework naming schemes
4413
+ // Covers: plain CSS, Tailwind v4, Bootstrap, Material UI, Shadcn/ui, custom brands
4414
+ const probePrefixes = ['', '--color-', '--bs-', '--brand-', '--clr-', '--theme-', '--md-sys-color-'];
4415
+ const probeNames = ['primary', 'secondary', 'accent', 'background', 'foreground', 'surface', 'text', 'muted', 'border', 'ring', 'card', 'destructive', 'warning', 'success'];
4416
+ const probeFonts = ['--font-sans', '--font-mono', '--font-display', '--font-heading', '--font-body', '--font-family-base'];
4417
+ for (const pfx of probePrefixes) {
4418
+ for (const name of probeNames) {
4419
+ const v = `${pfx}${name}`;
4420
+ if (!cssTokens[v]) {
4421
+ const val = rootStyle.getPropertyValue(v).trim();
4422
+ if (val) cssTokens[v] = val.slice(0, 60);
4423
+ }
4424
+ }
4425
+ }
4426
+ for (const v of probeFonts) {
4427
+ if (!cssTokens[v]) {
4428
+ const val = rootStyle.getPropertyValue(v).trim();
4429
+ if (val) cssTokens[v] = val.slice(0, 60);
4430
+ }
4431
+ }
4432
+ if (Object.keys(cssTokens).length > 0) {
4433
+ const limited = Object.fromEntries(Object.entries(cssTokens).slice(0, 25));
4434
+ addUniq({ meaning: `Design tokens: ${Object.keys(limited).join(', ')}`, role: 'design-tokens', notes: JSON.stringify(limited) });
4435
+ }
4436
+ } catch { /* computed styles not available */ }
4437
+
4438
+ // 23. Data tables — headers + row count (critical for business/admin pages)
4439
+ document.querySelectorAll('table').forEach((table: any) => {
4440
+ if (!isVisible(table)) return;
4441
+ const headers = Array.from(table.querySelectorAll('thead th, thead td, tr:first-child th')).map((th: any) => th.textContent?.trim()).filter(Boolean).slice(0, 8);
4442
+ const rowCount = table.querySelectorAll('tbody tr').length;
4443
+ if (headers.length > 0) addUniq({ meaning: `Table: ${headers.join(' · ')}`, role: 'data-table', notes: JSON.stringify({ headers, rowCount }) });
4444
+ });
4445
+
4446
+ // 24. Definition lists — term/description pairs (used for specs, glossaries, FAQs, metadata)
4447
+ document.querySelectorAll('dl').forEach((dl: any) => {
4448
+ if (!isVisible(dl)) return;
4449
+ const terms = Array.from(dl.querySelectorAll('dt')).map((dt: any) => dt.textContent?.trim()).filter(Boolean).slice(0, 10);
4450
+ if (terms.length > 0) addUniq({ meaning: `Definition list: ${terms.slice(0, 4).join(', ')}`, role: 'definition-list', notes: JSON.stringify({ terms: terms.slice(0, 10) }) });
4451
+ });
4452
+
4453
+ // 25. Blockquotes / testimonials / reviews / pull quotes
4454
+ let quoteCount = 0;
4455
+ document.querySelectorAll('blockquote, [class*="testimonial" i], [class*="review" i]:not(script), [class*="quote" i]:not([class*="icon"]), [class*="pullquote" i]').forEach((el: any) => {
4456
+ if (quoteCount >= 10) return;
4457
+ if (!isVisible(el)) return;
4458
+ const text = el.textContent?.replace(/\s+/g, ' ').trim().slice(0, 200);
4459
+ const author = el.querySelector('[class*="author" i], [class*="name" i], cite')?.textContent?.trim().slice(0, 60) ?? '';
4460
+ if (text && text.length > 20) { addUniq({ meaning: `${text.slice(0, 160)}${author ? ' — ' + author : ''}`, role: 'testimonial', notes: JSON.stringify({ author }) }); quoteCount++; }
4461
+ });
4462
+
4463
+ // 26. Alert / notice / notification banners (non-interactive, but key content for test assertions)
4464
+ document.querySelectorAll('[role="alert"], [role="status"], [role="banner"]:not(header), .alert, .notice, .notification, .warning-banner, .error-banner, .success-banner, [class*="alert-" i]:not(button), [class*="toast" i]:not(button), [class*="snackbar" i]').forEach((el: any) => {
4465
+ if (!isVisible(el)) return;
4466
+ const text = el.textContent?.replace(/\s+/g, ' ').trim().slice(0, 150);
4467
+ if (text && text.length > 5) addUniq({ meaning: text, role: 'alert-banner', notes: JSON.stringify({ ariaRole: el.getAttribute('role') }) });
4468
+ });
4469
+
4470
+ // 27. Pricing / plan cards — structured pricing content
4471
+ let pricingCount = 0;
4472
+ document.querySelectorAll('[class*="pricing" i], [class*="plan" i]:not(main):not(article), [class*="tier" i]:not(nav), [class*="package" i]:not(script)').forEach((el: any) => {
4473
+ if (pricingCount >= 8) return;
4474
+ if (!isVisible(el)) return;
4475
+ const name = el.querySelector('h2,h3,h4,[class*="plan-name" i],[class*="tier-name" i]')?.textContent?.trim().slice(0, 60) ?? '';
4476
+ const price = el.querySelector('[class*="price" i],[class*="amount" i]')?.textContent?.trim().slice(0, 40) ?? '';
4477
+ if (name || price) { addUniq({ meaning: `${name || 'Plan'}${price ? ': ' + price : ''}`, role: 'pricing-plan', notes: JSON.stringify({ planName: name, price }) }); pricingCount++; }
4478
+ });
4479
+
4480
+ // 28. Pagination — page navigation for multi-page content
4481
+ const paginationEl = document.querySelector('[role="navigation"][aria-label*="paginat" i], [class*="pagination" i], [aria-label*="paginat" i]');
4482
+ if (paginationEl && isVisible(paginationEl)) {
4483
+ const pages = Array.from(paginationEl.querySelectorAll('a, button')).map((p: any) => p.textContent?.trim()).filter(Boolean).slice(0, 10);
4484
+ if (pages.length > 0) addUniq({ meaning: `Pagination: ${pages.join(', ')}`, role: 'pagination', notes: JSON.stringify({ pages }) });
4485
+ }
4486
+
4487
+ // 29. Code blocks / pre blocks — critical for developer documentation pages
4488
+ let codeCount = 0;
4489
+ document.querySelectorAll('pre code, [class*="code-block" i], [class*="highlight" i] pre').forEach((el: any) => {
4490
+ if (codeCount >= 5) return;
4491
+ if (!isVisible(el)) return;
4492
+ const lang = el.getAttribute('class')?.match(/language-(\w+)/)?.[1] ?? '';
4493
+ const text = el.textContent?.replace(/\s+/g, ' ').trim().slice(0, 100);
4494
+ if (text && text.length > 10) { addUniq({ meaning: `Code example${lang ? ' (' + lang + ')' : ''}: ${text.slice(0, 80)}`, role: 'code-block', notes: JSON.stringify({ language: lang }) }); codeCount++; }
4495
+ });
4496
+
4497
+ // 30. Dropdown / select menu items exposed via ARIA listbox/option
4498
+ let dropdownCount = 0;
4499
+ document.querySelectorAll('[role="listbox"] [role="option"], [role="option"]:not([role="listbox"] *)').forEach((el: any) => {
4500
+ if (dropdownCount >= 20) return;
4501
+ if (!isVisible(el)) return;
4502
+ const text = el.textContent?.trim().slice(0, 80);
4503
+ if (text) { addUniq({ meaning: text, role: 'dropdown-option', notes: JSON.stringify({ selected: el.getAttribute('aria-selected') }) }); dropdownCount++; }
4504
+ });
4505
+
4506
+ // 31. Timeline / process steps (common on landing pages)
4507
+ let timelineCount = 0;
4508
+ document.querySelectorAll('[class*="timeline" i] li, [class*="timeline" i] [class*="step" i], [class*="process" i] li, [class*="steps" i] li').forEach((el: any) => {
4509
+ if (timelineCount >= 10) return;
4510
+ if (!isVisible(el)) return;
4511
+ const text = el.textContent?.replace(/\s+/g, ' ').trim().slice(0, 150);
4512
+ if (text && text.length > 5) { addUniq({ meaning: text, role: 'timeline-step', notes: JSON.stringify({ source: 'dom-class' }) }); timelineCount++; }
4513
+ });
4514
+
4515
+ // 32. Team members / person cards — social proof and about pages
4516
+ let teamCount = 0;
4517
+ document.querySelectorAll('[class*="team-member" i], [class*="team-card" i], [class*="person-card" i], [class*="bio" i][class*="card" i]').forEach((el: any) => {
4518
+ if (teamCount >= 10) return;
4519
+ if (!isVisible(el)) return;
4520
+ const name = el.querySelector('[class*="name" i], h3, h4')?.textContent?.trim().slice(0, 60) ?? '';
4521
+ const role = el.querySelector('[class*="role" i], [class*="title" i], [class*="position" i]')?.textContent?.trim().slice(0, 60) ?? '';
4522
+ if (name) { addUniq({ meaning: `${name}${role ? ' — ' + role : ''}`, role: 'team-member', notes: JSON.stringify({ name, role }) }); teamCount++; }
4523
+ });
4524
+
4525
+ // 33. Video embeds — YouTube, Vimeo, Wistia, Loom iframes (src is readable even cross-origin)
4526
+ document.querySelectorAll('iframe').forEach((iframe: any) => {
4527
+ const src = iframe.getAttribute('src') || iframe.getAttribute('data-src') || '';
4528
+ const title = iframe.getAttribute('title') || iframe.getAttribute('aria-label') || '';
4529
+ if (!src) return;
4530
+ if (/youtube\.com\/embed|youtu\.be\/|youtube-nocookie\.com/i.test(src)) {
4531
+ const videoId = src.match(/\/embed\/([^/?&]+)/)?.[1] ?? '';
4532
+ addUniq({ meaning: title || `YouTube video${videoId ? ' (ID: ' + videoId + ')' : ''}`, role: 'video-embed', notes: JSON.stringify({ platform: 'youtube', src: src.slice(0, 200), title, videoId }) });
4533
+ } else if (/vimeo\.com\/video\//i.test(src)) {
4534
+ const videoId = src.match(/video\/(\d+)/)?.[1] ?? '';
4535
+ addUniq({ meaning: title || `Vimeo video${videoId ? ' (ID: ' + videoId + ')' : ''}`, role: 'video-embed', notes: JSON.stringify({ platform: 'vimeo', src: src.slice(0, 200), title, videoId }) });
4536
+ } else if (/wistia\.com|loom\.com|dailymotion\.com|twitch\.tv|brightcove\.com/i.test(src)) {
4537
+ const platform = /wistia/.test(src) ? 'wistia' : /loom/.test(src) ? 'loom' : /dailymotion/.test(src) ? 'dailymotion' : /twitch/.test(src) ? 'twitch' : 'brightcove';
4538
+ addUniq({ meaning: title || `${platform} video`, role: 'video-embed', notes: JSON.stringify({ platform, src: src.slice(0, 200), title }) });
4539
+ } else if (/maps\.google\.com|google\.com\/maps\/embed/i.test(src)) {
4540
+ const q = new URLSearchParams(src.split('?')[1] ?? '').get('q') ?? new URLSearchParams(src.split('?')[1] ?? '').get('query') ?? '';
4541
+ addUniq({ meaning: title || `Google Maps${q ? ': ' + decodeURIComponent(q).slice(0, 80) : ''}`, role: 'map-embed', notes: JSON.stringify({ provider: 'google', src: src.slice(0, 200), query: q }) });
4542
+ } else if (/openstreetmap\.org|mapbox\.com|here\.com|maps\.apple\.com/i.test(src)) {
4543
+ const provider = /openstreetmap/.test(src) ? 'OpenStreetMap' : /mapbox/.test(src) ? 'Mapbox' : /here\.com/.test(src) ? 'HERE Maps' : 'Apple Maps';
4544
+ addUniq({ meaning: title || `${provider} embed`, role: 'map-embed', notes: JSON.stringify({ provider, src: src.slice(0, 200), title }) });
4545
+ }
4546
+ });
4547
+
4548
+ // 34. SVG content — only meaningful SVGs (have title/aria-label AND are large enough to be illustrations)
4549
+ document.querySelectorAll('svg').forEach((svg: any) => {
4550
+ if (!isVisible(svg)) return;
4551
+ const titleEl = svg.querySelector('title');
4552
+ const label = svg.getAttribute('aria-label')?.trim() || titleEl?.textContent?.trim() || '';
4553
+ if (!label) return;
4554
+ const rect = svg.getBoundingClientRect();
4555
+ if (rect.width < 48 && rect.height < 48) return; // Skip icon-size SVGs
4556
+ addUniq({ meaning: label.slice(0, 100), role: 'svg-image', notes: JSON.stringify({ width: Math.round(rect.width), height: Math.round(rect.height) }) });
4557
+ });
4558
+
4559
+ // 35. Animated counters — read data-target/data-count BEFORE animation completes
4560
+ let counterCount = 0;
4561
+ document.querySelectorAll('[data-count],[data-target],[data-to],[data-countup],[class*="counter" i]:not(button),[class*="count-up" i],[class*="odometer" i]').forEach((el: any) => {
4562
+ if (counterCount >= 15) return;
4563
+ if (!isVisible(el)) return;
4564
+ const targetVal = el.getAttribute('data-count') || el.getAttribute('data-target') || el.getAttribute('data-to') || el.getAttribute('data-countup') || '';
4565
+ const currentText = el.textContent?.replace(/\s+/g, ' ').trim().slice(0, 40) ?? '';
4566
+ const contextLabel = el.closest('[class*="stat" i],[class*="metric" i],[class*="kpi" i],[class*="counter-item" i]')?.textContent?.replace(/\s+/g, ' ').trim().slice(0, 80) ?? '';
4567
+ const value = targetVal || currentText;
4568
+ if (value && value !== '0') {
4569
+ addUniq({ meaning: contextLabel.slice(0, 100) || `Counter: ${value}`, role: 'animated-counter', notes: JSON.stringify({ targetValue: targetVal, displayedValue: currentText }) });
4570
+ counterCount++;
4571
+ }
4572
+ });
4573
+
4574
+ // 36. Client / partner logos — distinguish from generic images
4575
+ let logoCount = 0;
4576
+ document.querySelectorAll('[class*="client" i] img,[class*="partner" i] img,[class*="logo-grid" i] img,[class*="clients-" i] img,[class*="brand-logo" i] img,[class*="trust-logo" i] img,[class*="sponsor" i] img').forEach((img: any) => {
4577
+ if (logoCount >= 20) return;
4578
+ if (!isVisible(img)) return;
4579
+ const alt = img.getAttribute('alt')?.trim().slice(0, 60) ?? '';
4580
+ const src = (img.getAttribute('src') || img.getAttribute('data-src') || '').slice(0, 120);
4581
+ if (!alt && !src) return;
4582
+ addUniq({ meaning: alt || src.split('/').pop()?.split('?')[0]?.slice(0, 50) || 'Client logo', role: 'client-logo', notes: JSON.stringify({ alt, src, hasAlt: !!alt }) });
4583
+ logoCount++;
4584
+ });
4585
+
4586
+ // 37. Awards / certifications / trust badges
4587
+ let awardCount = 0;
4588
+ document.querySelectorAll('[class*="award" i],[class*="certif" i]:not(script):not(link),[class*="accredit" i],[class*="trust-badge" i],[class*="recognition" i],[class*="badge" i][class*="award" i]').forEach((el: any) => {
4589
+ if (awardCount >= 10) return;
4590
+ if (!isVisible(el)) return;
4591
+ const text = el.textContent?.replace(/\s+/g, ' ').trim().slice(0, 80);
4592
+ const imgEl = el.querySelector('img');
4593
+ const label = text || imgEl?.getAttribute('alt')?.trim() || '';
4594
+ if (label && label.length > 2) { addUniq({ meaning: label, role: 'award-badge', notes: JSON.stringify({ hasImage: !!imgEl }) }); awardCount++; }
4595
+ });
4596
+
4597
+ // 38. A/B testing detection — captures active experiments (not variant content)
4598
+ try {
4599
+ const abTools: string[] = [];
4600
+ if ((window as any).optimizely || (window as any).Optimizely) abTools.push('Optimizely');
4601
+ if ((window as any).VWO || (window as any)._vwo_code) abTools.push('VWO');
4602
+ if ((window as any).__ABTasty || (window as any).ABTasty) abTools.push('AB Tasty');
4603
+ if (document.querySelector('script[src*="optimize.js"]')) abTools.push('Google Optimize');
4604
+ if ((window as any).splitbee) abTools.push('Splitbee');
4605
+ if ((window as any).__GROWTHBOOK__ || document.querySelector('script[src*="growthbook"]')) abTools.push('GrowthBook');
4606
+ if ((window as any).Kameleoon) abTools.push('Kameleoon');
4607
+ if (abTools.length > 0) {
4608
+ const optExp = (window as any).optimizely?.get?.('state')?.getActiveExperimentIds?.() ?? [];
4609
+ addUniq({ meaning: `A/B testing: ${abTools.join(', ')}${optExp.length ? ' (' + optExp.length + ' active experiments)' : ''}`, role: 'ab-test-layer', notes: JSON.stringify({ tools: abTools, activeExperiments: optExp.slice(0, 10) }) });
4610
+ }
4611
+ } catch { /* best-effort */ }
4612
+
4613
+ // 39. Geo / personalization detection
4614
+ try {
4615
+ const signals: string[] = [];
4616
+ if ((window as any).__geo || (window as any).__country || (window as any).__region) signals.push('window geo globals');
4617
+ if (document.querySelector('[data-geo],[data-country],[data-region],[data-locale]')) signals.push('data-geo DOM attributes');
4618
+ const ipScripts = Array.from(document.querySelectorAll('script[src]')).filter((s: any) => /ipinfo|maxmind|ipstack|ipgeolocation/i.test(s.getAttribute('src') ?? '')).length;
4619
+ if (ipScripts > 0) signals.push('IP geolocation script');
4620
+ if ((window as any).Intl?.DateTimeFormat?.()?.resolvedOptions?.()?.timeZone) signals.push(`timezone: ${(window as any).Intl.DateTimeFormat().resolvedOptions().timeZone}`);
4621
+ if (signals.length > 1) {
4622
+ addUniq({ meaning: `Personalization/geo signals: ${signals.join('; ')}`, role: 'personalization-layer', notes: JSON.stringify({ signals }) });
4623
+ }
4624
+ } catch { /* best-effort */ }
4625
+
4626
+ // 40. Hover-revealed content — read DOM text of CSS-hidden children on hover triggers
4627
+ let hoverCount = 0;
4628
+ document.querySelectorAll('[class*="group"]:not(nav):not(ul):not(ol),[class*="hover-trigger" i],[data-hover],[class*="has-dropdown" i]').forEach((trigger: any) => {
4629
+ if (hoverCount >= 8) return;
4630
+ if (!isVisible(trigger)) return;
4631
+ const hiddenChildren = Array.from(trigger.querySelectorAll('[class*="group-hover"],[class*="hover-content" i],[class*="dropdown-menu" i],[class*="submenu" i]')).filter((c: any) => {
4632
+ try { const s = window.getComputedStyle(c); return s.display === 'none' || s.visibility === 'hidden' || parseFloat(s.opacity) < 0.1; } catch { return false; }
4633
+ });
4634
+ for (const child of hiddenChildren.slice(0, 3)) {
4635
+ const text = (child as any).textContent?.replace(/\s+/g, ' ').trim().slice(0, 150);
4636
+ if (text && text.length > 3) {
4637
+ addUniq({ meaning: text, role: 'hover-content', notes: JSON.stringify({ triggerClass: (trigger.className?.toString() ?? '').slice(0, 60) }) });
4638
+ hoverCount++;
4639
+ }
4640
+ }
4641
+ });
4642
+
4643
+ return out.slice(0, 400); // Per-category caps above; raised global cap to 400 for new categories
4644
+ }).then((elems: any[]) => elems.map((e: any) => ({ ...e, isInteractive: false }))).catch(() => []);
3929
4645
  }
3930
4646
 
3931
4647
  /**