@detiq/crawler 1.0.6 → 1.0.7

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