@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/dist/crawler.js CHANGED
@@ -3,7 +3,7 @@ import { chromium } from "rebrowser-playwright-core";
3
3
  import { chromium as stealthChromium } from "playwright-extra";
4
4
  import StealthPlugin from "puppeteer-extra-plugin-stealth";
5
5
  import { execSync } from "node:child_process";
6
- import { readFileSync, existsSync as existsSyncFs } from "node:fs";
6
+ import { existsSync as existsSyncFs, readFileSync } from "node:fs";
7
7
  import { createRequire } from "node:module";
8
8
  stealthChromium.use(StealthPlugin());
9
9
  import { z } from "zod";
@@ -16,7 +16,7 @@ import { pHashVerdict, mergeAiVisualVerdict } from "./visual-diff-verdict.js";
16
16
  import { skyvernFallback, skyvernNavigate } from "./skyvern.js";
17
17
  import { loginWithStagehand } from "./ai-login.js";
18
18
  import { readScreen } from "./screen-reader.js";
19
- import { probeAllScreens } from "./interactive-probe.js";
19
+ import { probeAllScreens, probeKeyboardNav } from "./interactive-probe.js";
20
20
  import { computeElementHash } from "./element-hash.js";
21
21
  import { logger } from "./logger.js";
22
22
  import { getSsoToken } from "./sso-token.js";
@@ -43,6 +43,7 @@ import { auditColorContrast } from "./color-contrast.js";
43
43
  import { detectFeatureFlags } from "./feature-flag-detector.js";
44
44
  import { detectLanguage } from "./language-detector.js";
45
45
  import { detectForms } from "./form-detector.js";
46
+ import { auditAccessibility } from "./accessibility-auditor.js";
46
47
  import { RedirectRegistry } from "./redirect-tracker.js";
47
48
  import { DeadLinkTracker } from "./dead-link-detector.js";
48
49
  import { screensToSitemapUrls, writeSitemap } from "./sitemap-writer.js";
@@ -1262,7 +1263,7 @@ async function crawlProject(opts, onProgress) {
1262
1263
  const discovered = [];
1263
1264
  const performanceMetrics = [];
1264
1265
  const seenDomHashes = new Set(opts.knownDomHashes ?? []);
1265
- 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;
1266
+ const SKIP_PATH = /\/(terms|privacy|dpa|legal|cookie-policy|changelog|blog|imprint|gdpr|compliance|acceptable-use|security-policy|press|sitemap)([-/]|$)/i;
1266
1267
  const segIdx = opts.segmentIndex ?? 1;
1267
1268
  const robotsCache = opts.respectRobotsTxt ? new RobotsCache() : null;
1268
1269
  let avgNavMs = 0;
@@ -1678,8 +1679,8 @@ async function crawlProject(opts, onProgress) {
1678
1679
  try {
1679
1680
  const _require = createRequire(import.meta.url);
1680
1681
  const axePath = _require.resolve("axe-core");
1681
- const axeScript = readFileSync(axePath, "utf8");
1682
- await page.addScriptTag({ content: axeScript });
1682
+ const axeContent = readFileSync(axePath, "utf8");
1683
+ await page.evaluate(axeContent);
1683
1684
  const result = await page.evaluate(async () => {
1684
1685
  return window.axe.run(document, {
1685
1686
  runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "best-practice"] },
@@ -1696,7 +1697,8 @@ async function crawlProject(opts, onProgress) {
1696
1697
  }));
1697
1698
  const criticalCount = violations.filter((v) => v.impact === "critical").length;
1698
1699
  return { violations, violationCount: violations.length, criticalCount };
1699
- } catch {
1700
+ } catch (err) {
1701
+ logger.warn({ err: String(err?.message ?? err) }, "[crawler] axe-core a11y audit failed (non-fatal)");
1700
1702
  return null;
1701
1703
  }
1702
1704
  })();
@@ -1808,12 +1810,13 @@ async function crawlProject(opts, onProgress) {
1808
1810
  })();
1809
1811
  void (async () => {
1810
1812
  const screenId = screen.id;
1811
- const [seoR, contrastR, flagsR, langR, formsR] = await Promise.allSettled([
1813
+ const [seoR, contrastR, flagsR, langR, formsR, a11yManualR] = await Promise.allSettled([
1812
1814
  checkSeo(page, url),
1813
1815
  auditColorContrast(page, url),
1814
1816
  detectFeatureFlags(page, url),
1815
1817
  detectLanguage(page, url),
1816
- detectForms(page)
1818
+ detectForms(page),
1819
+ auditAccessibility(page, url)
1817
1820
  ]);
1818
1821
  const base = { screenId, jobId: opts.jobId };
1819
1822
  if (seoR.status === "fulfilled")
@@ -1831,11 +1834,23 @@ async function crawlProject(opts, onProgress) {
1831
1834
  if (formsR.status === "fulfilled" && Array.isArray(formsR.value) && formsR.value.length)
1832
1835
  AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, { ...base, type: "FORMS_DETECTED", payload: { forms: formsR.value } }).catch(() => {
1833
1836
  });
1837
+ if (a11yManualR.status === "fulfilled" && a11yManualR.value.violations?.length)
1838
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, { ...base, type: "A11Y_MANUAL_AUDIT", payload: a11yManualR.value }).catch(() => {
1839
+ });
1834
1840
  })();
1835
1841
  try {
1836
- await page.addStyleTag({ content: "*, *::before, *::after { animation-duration: 0s !important; animation-delay: 0s !important; transition-duration: 0s !important; transition-delay: 0s !important; }" });
1842
+ 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; }" });
1837
1843
  } catch {
1838
1844
  }
1845
+ await page.evaluate(() => {
1846
+ try {
1847
+ const noop = () => 0;
1848
+ Object.defineProperty(window, "requestAnimationFrame", { value: noop, configurable: true });
1849
+ Object.defineProperty(window, "cancelAnimationFrame", { value: noop, configurable: true });
1850
+ } catch {
1851
+ }
1852
+ }).catch(() => {
1853
+ });
1839
1854
  const canonUrl2 = canonicalize(url);
1840
1855
  const linkRecipe = await AppBrain.getActionRecipe(
1841
1856
  opts.tenantId,
@@ -1901,9 +1916,10 @@ async function crawlProject(opts, onProgress) {
1901
1916
  const fallback = err?.domLinks ?? [];
1902
1917
  if (err?.isRateLimit) {
1903
1918
  aiErrorStreak++;
1904
- if (aiErrorStreak >= 3) {
1905
- aiSkipCount = 10;
1906
- logger.warn("[crawler] 3 consecutive AI rate limits \u2014 skipping AI link discovery for next 10 screens");
1919
+ if (aiErrorStreak >= 5) {
1920
+ aiSkipCount = 3;
1921
+ aiErrorStreak = 0;
1922
+ logger.warn("[crawler] 5 consecutive AI rate limits \u2014 pausing AI link discovery for next 3 screens then retrying");
1907
1923
  }
1908
1924
  if (!aiRateLimitWarned) {
1909
1925
  aiRateLimitWarned = true;
@@ -1917,6 +1933,18 @@ async function crawlProject(opts, onProgress) {
1917
1933
  ]);
1918
1934
  logger.info({ url, screenId: screen.id, version: screen.version, elements: elements.length, links: nextLinks.length, atSample: atElements.slice(0, 3).map((a) => a.label) }, "[crawler] screen captured");
1919
1935
  console.log("[crawler] AT:", JSON.stringify(atElements.slice(0, 5)));
1936
+ probeKeyboardNav(page).then((kbResult) => {
1937
+ if (kbResult.focusableCount > 0) {
1938
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
1939
+ screenId: screen.id,
1940
+ jobId: opts.jobId,
1941
+ type: "KEYBOARD_NAV",
1942
+ payload: kbResult
1943
+ }).catch(() => {
1944
+ });
1945
+ }
1946
+ }).catch(() => {
1947
+ });
1920
1948
  try {
1921
1949
  const domContent = await capturePageHtml(page);
1922
1950
  if (!domContent) {
@@ -1957,15 +1985,16 @@ async function crawlProject(opts, onProgress) {
1957
1985
  try {
1958
1986
  const llmCfg = await getCachedCrawlLLM(opts.tenantId);
1959
1987
  const snippet = extracted.markdown.slice(0, 1200);
1988
+ if (snippet.trim().length < 80) return void 0;
1960
1989
  const { text, usage } = await askTracked(
1961
- "You are a QA analyst. Given page content, write ONE crisp sentence describing what this screen does or shows. Be specific. No preamble.",
1990
+ "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.",
1962
1991
  `Screen: "${name}"
1963
1992
  URL: ${url}
1964
1993
 
1965
1994
  Content:
1966
1995
  ${snippet}`,
1967
1996
  llmCfg,
1968
- 80
1997
+ 200
1969
1998
  );
1970
1999
  if (usage) {
1971
2000
  recordLLMCall({
@@ -1978,7 +2007,9 @@ ${snippet}`,
1978
2007
  }).catch(() => {
1979
2008
  });
1980
2009
  }
1981
- return text.trim().replace(/^["']|["']$/g, "");
2010
+ const clean = text.trim().replace(/^["']|["']$/g, "");
2011
+ const firstSentence = clean.match(/^[^.!?]+[.!?]/)?.[0] ?? clean;
2012
+ return firstSentence.trim();
1982
2013
  } catch {
1983
2014
  return void 0;
1984
2015
  }
@@ -2835,7 +2866,7 @@ ${snippet}`,
2835
2866
  }
2836
2867
  }
2837
2868
  }
2838
- const shouldRunInteractiveProbe = opts.interactive || discovered.filter((d) => d.screenId).length < 30;
2869
+ const shouldRunInteractiveProbe = true;
2839
2870
  if (shouldRunInteractiveProbe) {
2840
2871
  await onProgress?.(88, "Running interactive probe (capturing real errors & modals)");
2841
2872
  const probeInputs = discovered.filter((d) => d.screenId).map((d) => ({ url: d.url, screenId: d.screenId, elements: d.elementFacts ?? [] }));
@@ -2974,7 +3005,10 @@ ${snippet}`,
2974
3005
  }
2975
3006
  const ELEMENT_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;
2976
3007
  async function extractElements(stagehand, page, llmCfg) {
2977
- const domElements = await domFallbackElements(page);
3008
+ const domElements = await domFallbackElements(page).catch((err) => {
3009
+ console.warn("[crawler] domFallbackElements failed (page context issue or DOM error):", err?.message ?? err);
3010
+ return [];
3011
+ });
2978
3012
  const iframeElements = [];
2979
3013
  try {
2980
3014
  const iframes = page.frames ? page.frames() : [];
@@ -3009,9 +3043,32 @@ async function extractElements(stagehand, page, llmCfg) {
3009
3043
  dynamicElements = [];
3010
3044
  }
3011
3045
  }
3012
- if (domElements.length >= 2) return [...domElements, ...iframeElements, ...dynamicElements];
3013
3046
  const contentElements = await contentFallbackElements(page);
3014
- if (contentElements.length > 0) return [...domElements, ...contentElements];
3047
+ const crossSourceDedup = (arr) => {
3048
+ const stripPhrases = (s) => s.replace(/^button to expand ['"]?|['"]?\s*details$/gi, "").replace(/\s+(faq\s*)?button$/i, "").replace(/^button:\s*/i, "").toLowerCase().trim().slice(0, 80);
3049
+ const ROLE_PRIORITY = { "faq-question": 10, "faq-answer": 9, "h1": 8, "h2": 7, "h3": 6, "h4": 5, "heading": 8, button: 3, a: 2 };
3050
+ const normMap = /* @__PURE__ */ new Map();
3051
+ const result = [];
3052
+ for (const el of arr) {
3053
+ const norm = stripPhrases(el.meaning);
3054
+ if (!norm) {
3055
+ result.push(el);
3056
+ continue;
3057
+ }
3058
+ const existingIdx = normMap.get(norm);
3059
+ if (existingIdx === void 0) {
3060
+ normMap.set(norm, result.length);
3061
+ result.push(el);
3062
+ } else {
3063
+ const exPri = ROLE_PRIORITY[result[existingIdx].role] ?? 1;
3064
+ const thisPri = ROLE_PRIORITY[el.role] ?? 1;
3065
+ if (thisPri > exPri) result[existingIdx] = el;
3066
+ }
3067
+ }
3068
+ return result;
3069
+ };
3070
+ if (domElements.length >= 2) return crossSourceDedup([...domElements, ...iframeElements, ...dynamicElements, ...contentElements]);
3071
+ if (contentElements.length > 0) return crossSourceDedup([...domElements, ...contentElements]);
3015
3072
  try {
3016
3073
  const extracted = await Promise.race([
3017
3074
  stagehand.extract(
@@ -3082,21 +3139,26 @@ async function domFallbackElements(page) {
3082
3139
  const seen = /* @__PURE__ */ new Set();
3083
3140
  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;
3084
3141
  const querySelectorAllDeep = (root, selector2) => {
3085
- const results = Array.from(root.querySelectorAll(selector2));
3086
- root.querySelectorAll("*").forEach((el) => {
3087
- if (el.shadowRoot) {
3088
- results.push(...querySelectorAllDeep(el.shadowRoot, selector2));
3089
- }
3090
- });
3142
+ const results = [];
3143
+ const queue = [root];
3144
+ while (queue.length > 0) {
3145
+ const current = queue.shift();
3146
+ Array.from(current.querySelectorAll(selector2)).forEach((el) => results.push(el));
3147
+ current.querySelectorAll("*").forEach((el) => {
3148
+ if (el.shadowRoot) queue.push(el.shadowRoot);
3149
+ });
3150
+ }
3091
3151
  return results;
3092
3152
  };
3093
- 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"]';
3153
+ 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"]';
3094
3154
  querySelectorAllDeep(document, selector).forEach((el) => {
3095
3155
  const style = window.getComputedStyle(el);
3096
3156
  if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0" || style.pointerEvents === "none") return;
3097
3157
  if (el.hasAttribute("inert") || el.getAttribute("aria-disabled") === "true") return;
3098
3158
  const rect = el.getBoundingClientRect();
3099
- if (rect.width === 0 && rect.height === 0) return;
3159
+ const hasAccessibleLabel = !!(el.getAttribute("aria-label") || el.getAttribute("aria-labelledby"));
3160
+ if (rect.width === 0 && rect.height === 0 && !hasAccessibleLabel) return;
3161
+ 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;
3100
3162
  const tag = el.tagName.toLowerCase();
3101
3163
  const ariaRole = el.getAttribute("role");
3102
3164
  const isActionEl = tag === "button" || tag === "a" || ariaRole === "button";
@@ -3249,7 +3311,7 @@ async function domFallbackElements(page) {
3249
3311
  const priority = (e) => ["email", "password", "text", "tel", "date", "number", "select", "textarea", "url", "file"].includes(e.role) ? 0 : 1;
3250
3312
  out.sort((a, b) => priority(a) - priority(b));
3251
3313
  return out;
3252
- }).catch(() => []);
3314
+ }).then((elems) => elems.map((e) => ({ ...e, isInteractive: true }))).catch(() => []);
3253
3315
  }
3254
3316
  async function contentFallbackElements(page) {
3255
3317
  return page.evaluate(() => {
@@ -3257,11 +3319,15 @@ async function contentFallbackElements(page) {
3257
3319
  const seen = /* @__PURE__ */ new Set();
3258
3320
  const addUniq = (item) => {
3259
3321
  const key = `${item.role}:${item.meaning.toLowerCase().slice(0, 60)}`;
3260
- if (!seen.has(key) && item.meaning.trim().length > 0) {
3322
+ if (!seen.has(key) && item.meaning.trim().length > 1) {
3261
3323
  seen.add(key);
3262
3324
  out.push(item);
3263
3325
  }
3264
3326
  };
3327
+ const isVisible = (el) => {
3328
+ const s = window.getComputedStyle(el);
3329
+ return s.display !== "none" && s.visibility !== "hidden" && s.opacity !== "0" && el.offsetParent !== null;
3330
+ };
3265
3331
  const title = document.title?.trim();
3266
3332
  if (title) addUniq({ meaning: title, role: "page-title", notes: JSON.stringify({ tag: "title" }) });
3267
3333
  const desc = document.querySelector('meta[name="description"]')?.getAttribute("content")?.trim();
@@ -3270,17 +3336,197 @@ async function contentFallbackElements(page) {
3270
3336
  if (ogTitle) addUniq({ meaning: ogTitle, role: "og-title", notes: JSON.stringify({ tag: "meta[property=og:title]" }) });
3271
3337
  const canonical = document.querySelector('link[rel="canonical"]')?.getAttribute("href")?.trim();
3272
3338
  if (canonical) addUniq({ meaning: canonical, role: "canonical-url", notes: JSON.stringify({ tag: "link[rel=canonical]" }) });
3273
- document.querySelectorAll("h1, h2, h3").forEach((h) => {
3274
- const text = h.textContent?.replace(/\s+/g, " ").trim().slice(0, 80);
3339
+ document.querySelectorAll("h1, h2, h3, h4, h5, h6").forEach((h) => {
3340
+ if (!isVisible(h)) return;
3341
+ const text = h.textContent?.replace(/\s+/g, " ").trim().slice(0, 100);
3275
3342
  if (text) addUniq({ meaning: text, role: h.tagName.toLowerCase(), notes: JSON.stringify({ level: h.tagName }) });
3276
3343
  });
3277
- document.querySelectorAll("img[alt]").forEach((img) => {
3278
- const alt = img.getAttribute("alt")?.trim().slice(0, 80);
3279
- const src = img.getAttribute("src")?.slice(0, 120);
3280
- if (alt) addUniq({ meaning: alt, role: "image", notes: JSON.stringify({ src }) });
3344
+ document.querySelectorAll('details, [data-state="closed"], [data-state="open"]').forEach((el) => {
3345
+ if (!isVisible(el)) return;
3346
+ const summary = el.tagName === "DETAILS" ? el.querySelector("summary") : el.querySelector('[class*="trigger" i], [class*="header" i], [class*="question" i], [class*="title" i]') ?? el;
3347
+ const qText = summary?.textContent?.replace(/\s+/g, " ").trim().slice(0, 120);
3348
+ if (qText && qText.length > 3) {
3349
+ addUniq({ meaning: qText, role: "faq-question", notes: JSON.stringify({ expanded: el.hasAttribute("open") || el.getAttribute("data-state") === "open" }) });
3350
+ }
3351
+ const contentEl = el.tagName === "DETAILS" ? Array.from(el.children).find((c) => c.tagName !== "SUMMARY") : el.querySelector('[class*="content" i], [class*="answer" i], [class*="panel" i], [class*="body" i]:not(body)') ?? el.nextElementSibling;
3352
+ if (contentEl) {
3353
+ const aText = contentEl.textContent?.replace(/\s+/g, " ").trim().slice(0, 200);
3354
+ if (aText && aText.length > 5 && aText !== qText) addUniq({ meaning: aText, role: "faq-answer", notes: JSON.stringify({ question: qText }) });
3355
+ }
3356
+ });
3357
+ document.querySelectorAll('[class*="faq" i]:not(script):not(style):not(nav), [id*="faq" i]:not(script):not(style)').forEach((container) => {
3358
+ if (!isVisible(container)) return;
3359
+ const qEls = container.querySelectorAll('[class*="question" i], [class*="trigger" i], summary, dt, [class*="heading" i]');
3360
+ const aEls = container.querySelectorAll('[class*="answer" i], [class*="content" i]:not(main):not(article), [class*="panel" i], dd');
3361
+ qEls.forEach((q) => {
3362
+ const qText = q.textContent?.replace(/\s+/g, " ").trim().slice(0, 120);
3363
+ if (qText && qText.length > 3) addUniq({ meaning: qText, role: "faq-question", notes: JSON.stringify({ source: "class-faq" }) });
3364
+ });
3365
+ aEls.forEach((a) => {
3366
+ const aText = a.textContent?.replace(/\s+/g, " ").trim().slice(0, 200);
3367
+ if (aText && aText.length > 5) addUniq({ meaning: aText, role: "faq-answer", notes: JSON.stringify({ source: "class-faq" }) });
3368
+ });
3369
+ });
3370
+ 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) => {
3371
+ if (!isVisible(el)) return;
3372
+ const text = el.textContent?.replace(/\s+/g, " ").trim().slice(0, 150);
3373
+ if (text && text.length > 2 && text.length < 200) addUniq({ meaning: text, role: "content-card" });
3374
+ });
3375
+ const mainContent = document.querySelector('main, [role="main"], article') ?? document.body;
3376
+ let listItemCount = 0;
3377
+ mainContent.querySelectorAll("li").forEach((li) => {
3378
+ if (listItemCount >= 30) return;
3379
+ if (!isVisible(li)) return;
3380
+ const text = li.textContent?.replace(/\s+/g, " ").trim().slice(0, 150);
3381
+ if (text && text.length > 3 && text.length < 200) {
3382
+ addUniq({ meaning: text, role: "list-item" });
3383
+ listItemCount++;
3384
+ }
3385
+ });
3386
+ mainContent.querySelectorAll('section > p:first-of-type, article > p:first-of-type, [class*="section" i] > p:first-of-type').forEach((p) => {
3387
+ if (!isVisible(p)) return;
3388
+ const text = p.textContent?.replace(/\s+/g, " ").trim().slice(0, 250);
3389
+ if (text && text.length > 10) addUniq({ meaning: text, role: "section-paragraph" });
3390
+ });
3391
+ document.querySelectorAll('[class*="cta" i] p, [class*="call-to-action" i] p, section:not(form) > p').forEach((p) => {
3392
+ if (!isVisible(p)) return;
3393
+ const text = p.textContent?.replace(/\s+/g, " ").trim().slice(0, 300);
3394
+ if (text && text.length > 10 && text.length < 400) addUniq({ meaning: text, role: "cta-section" });
3395
+ });
3396
+ 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) => {
3397
+ if (!isVisible(a)) return;
3398
+ const label = (a.getAttribute("aria-label") || a.textContent?.replace(/\s+/g, " ").trim() || "").slice(0, 60);
3399
+ const href = a.href;
3400
+ if (!label || !href || /^(javascript:|#$)/i.test(href)) return;
3401
+ let landmark = "Site Navigation";
3402
+ let el = a.parentElement;
3403
+ while (el) {
3404
+ const tag = el.tagName?.toLowerCase();
3405
+ const role = el.getAttribute?.("role")?.toLowerCase();
3406
+ if (tag === "header" || role === "banner") {
3407
+ landmark = "Header";
3408
+ break;
3409
+ }
3410
+ if (tag === "footer" || role === "contentinfo") {
3411
+ landmark = "Footer";
3412
+ break;
3413
+ }
3414
+ if (tag === "nav" || role === "navigation") {
3415
+ const directLabel = el.getAttribute("aria-label")?.trim() ?? "";
3416
+ const labelId = el.getAttribute("aria-labelledby");
3417
+ const referencedLabel = labelId ? document.getElementById(labelId)?.textContent?.trim() ?? "" : "";
3418
+ landmark = directLabel || referencedLabel || "Site Navigation";
3419
+ break;
3420
+ }
3421
+ el = el.parentElement;
3422
+ }
3423
+ addUniq({ meaning: label, role: "nav-link", notes: JSON.stringify({ href: href.slice(0, 150), landmark }) });
3424
+ });
3425
+ document.querySelectorAll('[aria-label*="breadcrumb" i] a, [class*="breadcrumb" i] a, nav[aria-label*="breadcrumb" i] a').forEach((a) => {
3426
+ const label = a.textContent?.replace(/\s+/g, " ").trim().slice(0, 60);
3427
+ const href = a.href;
3428
+ if (label) addUniq({ meaning: label, role: "breadcrumb-item", notes: JSON.stringify({ href: href?.slice(0, 150) }) });
3429
+ });
3430
+ let footerLinkCount = 0;
3431
+ const footerEls = /* @__PURE__ */ new Set();
3432
+ document.querySelectorAll('footer, [role="contentinfo"], [class*="footer" i]:not(script):not(style):not(nav), [id*="footer" i]:not(script):not(style)').forEach((f) => footerEls.add(f));
3433
+ const footerLinks = [];
3434
+ footerEls.forEach((f) => f.querySelectorAll("a").forEach((a) => footerLinks.push(a)));
3435
+ const seenFooterHrefs = /* @__PURE__ */ new Set();
3436
+ footerLinks.filter((a) => {
3437
+ const href = a.href ?? "";
3438
+ if (seenFooterHrefs.has(href)) return false;
3439
+ seenFooterHrefs.add(href);
3440
+ return true;
3441
+ }).forEach((a) => {
3442
+ if (footerLinkCount >= 25) return;
3443
+ if (!isVisible(a)) return;
3444
+ const label = (a.getAttribute("aria-label") || a.textContent?.replace(/\s+/g, " ").trim() || "").slice(0, 60);
3445
+ const href = a.href ?? "";
3446
+ if (!label) return;
3447
+ if (/^mailto:/i.test(href)) {
3448
+ addUniq({ meaning: label || href, role: "footer-contact", notes: JSON.stringify({ href }) });
3449
+ footerLinkCount++;
3450
+ } else if (/^tel:/i.test(href)) {
3451
+ addUniq({ meaning: label || href, role: "footer-contact", notes: JSON.stringify({ href }) });
3452
+ footerLinkCount++;
3453
+ } else if (label.length > 0) {
3454
+ addUniq({ meaning: label, role: "footer-link", notes: JSON.stringify({ href: href.slice(0, 150) }) });
3455
+ footerLinkCount++;
3456
+ }
3457
+ });
3458
+ let imgCount = 0;
3459
+ const isImgVisible = (el) => {
3460
+ const s = window.getComputedStyle(el);
3461
+ return s.display !== "none" && s.visibility !== "hidden";
3462
+ };
3463
+ document.querySelectorAll("img").forEach((img) => {
3464
+ if (imgCount >= 20) return;
3465
+ if (!isImgVisible(img)) return;
3466
+ const alt = img.getAttribute("alt")?.trim().slice(0, 80) ?? "";
3467
+ const src = (img.getAttribute("src") || img.getAttribute("data-src") || img.getAttribute("data-lazy-src") || img.getAttribute("data-original") || "").slice(0, 120);
3468
+ const srcsetFirst = img.getAttribute("srcset")?.split(",")[0]?.trim().split(" ")[0]?.slice(0, 120) ?? "";
3469
+ const resolvedSrc = src || srcsetFirst;
3470
+ if (!resolvedSrc && !alt) return;
3471
+ const label = alt || resolvedSrc.split("/").pop()?.split("?")[0]?.slice(0, 60) || "Image";
3472
+ addUniq({ meaning: label, role: alt ? "image" : "image-no-alt", notes: JSON.stringify({ src: resolvedSrc, alt, hasAlt: !!alt }) });
3473
+ imgCount++;
3474
+ });
3475
+ document.querySelectorAll("picture source[srcset]").forEach((src) => {
3476
+ if (imgCount >= 25) return;
3477
+ const srcset = src.getAttribute("srcset")?.split(",")[0]?.trim().split(" ")[0]?.slice(0, 120) ?? "";
3478
+ const media = src.getAttribute("media") ?? "";
3479
+ if (srcset) {
3480
+ addUniq({ meaning: `Responsive image${media ? " (" + media + ")" : ""}: ${srcset.split("/").pop()?.split("?")[0] ?? ""}`, role: "picture-source", notes: JSON.stringify({ srcset: srcset.slice(0, 150), media }) });
3481
+ imgCount++;
3482
+ }
3483
+ });
3484
+ let bgCount = 0;
3485
+ document.querySelectorAll('[style*="background-image"], [style*="background:"]').forEach((el) => {
3486
+ if (bgCount >= 15) return;
3487
+ if (!isImgVisible(el)) return;
3488
+ const style = el.getAttribute("style") ?? "";
3489
+ const match = style.match(/url\(['"]?([^'")\s]+)['"]?\)/i);
3490
+ if (match) {
3491
+ const url = match[1].slice(0, 120);
3492
+ const role = el.tagName.toLowerCase();
3493
+ const context = (el.getAttribute("aria-label") || el.getAttribute("title") || el.className.split(" ").find((c) => c.length > 3 && !["true", "false"].includes(c)) || "").slice(0, 60);
3494
+ addUniq({ meaning: context || `BG image on <${role}>: ${url.split("/").pop()?.split("?")[0] ?? url}`, role: "bg-image", notes: JSON.stringify({ url, element: role, context }) });
3495
+ bgCount++;
3496
+ }
3497
+ });
3498
+ 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) => {
3499
+ if (bgCount >= 15) return;
3500
+ if (!isImgVisible(el)) return;
3501
+ try {
3502
+ const computed = window.getComputedStyle(el).backgroundImage;
3503
+ if (computed && computed !== "none" && computed.includes("url(")) {
3504
+ const match = computed.match(/url\(['"]?([^'")\s]+)['"]?\)/i);
3505
+ if (match) {
3506
+ const url = match[1].slice(0, 120);
3507
+ const tag = el.tagName.toLowerCase();
3508
+ const cls = Array.from(el.classList).find((c) => typeof c === "string" && c.length > 3 && !/^(true|false|active|visible)$/.test(c)) ?? "";
3509
+ 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 }) });
3510
+ bgCount++;
3511
+ }
3512
+ }
3513
+ } catch {
3514
+ }
3515
+ });
3516
+ document.querySelectorAll("[data-bg], [data-background], [data-background-image]").forEach((el) => {
3517
+ if (bgCount >= 15) return;
3518
+ if (!isVisible(el)) return;
3519
+ const url = (el.getAttribute("data-bg") || el.getAttribute("data-background") || el.getAttribute("data-background-image") || "").slice(0, 120);
3520
+ if (url) {
3521
+ addUniq({ meaning: `Lazy BG image: ${url.split("/").pop()?.split("?")[0] ?? url}`, role: "bg-image", notes: JSON.stringify({ url, pattern: "data-bg" }) });
3522
+ bgCount++;
3523
+ }
3281
3524
  });
3282
3525
  const navTags = /* @__PURE__ */ new Set(["nav", "header", "footer"]);
3526
+ let contentLinkCount = 0;
3283
3527
  document.querySelectorAll("a[href]").forEach((a) => {
3528
+ if (contentLinkCount >= 20) return;
3529
+ if (!isVisible(a)) return;
3284
3530
  let p = a.parentElement;
3285
3531
  while (p) {
3286
3532
  if (navTags.has(p.tagName?.toLowerCase())) return;
@@ -3288,17 +3534,413 @@ async function contentFallbackElements(page) {
3288
3534
  }
3289
3535
  const label = (a.getAttribute("aria-label") || a.textContent?.replace(/\s+/g, " ").trim() || "").slice(0, 80);
3290
3536
  const href = a.href;
3291
- if (label && href && !/^(#|javascript:|mailto:)/i.test(href)) {
3537
+ if (label && href && !/^(#$|javascript:|mailto:|tel:)/i.test(href)) {
3292
3538
  const isExternal = !href.includes(location.hostname);
3293
3539
  addUniq({ meaning: label, role: isExternal ? "external-link" : "internal-link", notes: JSON.stringify({ href: href.slice(0, 120) }) });
3540
+ contentLinkCount++;
3294
3541
  }
3295
3542
  });
3296
3543
  const hasAnimation = !!document.querySelector('[class*="animate"], [class*="motion"], [class*="transition"], [class*="scroll-"], [data-aos], [data-scroll]');
3297
3544
  if (hasAnimation) addUniq({ meaning: "CSS animations / scroll effects detected on page", role: "animation-layer", notes: JSON.stringify({ detected: true }) });
3298
3545
  if (document.querySelector("video")) addUniq({ meaning: "Video element present", role: "video", notes: JSON.stringify({ count: document.querySelectorAll("video").length }) });
3299
3546
  if (document.querySelector("canvas")) addUniq({ meaning: "Canvas element present", role: "canvas" });
3300
- return out.slice(0, 30);
3301
- }).catch(() => []);
3547
+ document.querySelectorAll('main section, article section, [role="main"] section').forEach((el) => {
3548
+ if (!isVisible(el)) return;
3549
+ const heading = el.querySelector("h1,h2,h3,h4")?.textContent?.trim() ?? "";
3550
+ const itemCount = el.querySelectorAll('li, [class*="card" i], [class*="chip" i]').length;
3551
+ const paragraphs = el.querySelectorAll("p").length;
3552
+ const meaning = heading || `Section (${itemCount} items)`;
3553
+ if (meaning) addUniq({ meaning, role: "page-section", notes: JSON.stringify({ heading, itemCount, paragraphs, id: el.id || el.getAttribute("data-section") }) });
3554
+ });
3555
+ const ogDesc = document.querySelector('meta[property="og:description"]')?.getAttribute("content")?.trim();
3556
+ if (ogDesc) addUniq({ meaning: ogDesc, role: "og-description", notes: JSON.stringify({ tag: "meta[property=og:description]" }) });
3557
+ const ogImage = document.querySelector('meta[property="og:image"]')?.getAttribute("content")?.trim();
3558
+ if (ogImage) addUniq({ meaning: ogImage, role: "og-image", notes: JSON.stringify({ tag: "meta[property=og:image]", src: ogImage }) });
3559
+ const twitterTitle = document.querySelector('meta[name="twitter:title"]')?.getAttribute("content")?.trim();
3560
+ if (twitterTitle) addUniq({ meaning: twitterTitle, role: "twitter-title", notes: JSON.stringify({ tag: "meta[name=twitter:title]" }) });
3561
+ const twitterDesc = document.querySelector('meta[name="twitter:description"]')?.getAttribute("content")?.trim();
3562
+ if (twitterDesc) addUniq({ meaning: twitterDesc, role: "twitter-description", notes: JSON.stringify({ tag: "meta[name=twitter:description]" }) });
3563
+ const jsonFonts = [];
3564
+ document.querySelectorAll('link[rel="preload"][as="font"], link[href*="fonts.googleapis"]').forEach((l) => {
3565
+ const url = l.href?.slice(0, 120);
3566
+ if (url) jsonFonts.push(url);
3567
+ });
3568
+ if (jsonFonts.length) addUniq({ meaning: `Font imports: ${jsonFonts.length} font file(s)`, role: "font-stack", notes: JSON.stringify({ fonts: jsonFonts.slice(0, 5) }) });
3569
+ document.querySelectorAll('script[type="application/ld+json"], script[class*="tsr"]').forEach((s) => {
3570
+ try {
3571
+ const data = JSON.parse(s.textContent ?? "{}");
3572
+ const items = Array.isArray(data) ? data : [data];
3573
+ for (const item of items) {
3574
+ const faqArr = item.mainEntity ?? item.faqs ?? item.questions ?? item.faq ?? [];
3575
+ (Array.isArray(faqArr) ? faqArr : []).forEach((f) => {
3576
+ const q = f.name ?? f.question ?? "";
3577
+ const a = f.acceptedAnswer?.text ?? f.answer ?? "";
3578
+ if (q) addUniq({ meaning: String(q).slice(0, 150), role: "faq-question", notes: JSON.stringify({ source: "json-ld" }) });
3579
+ if (a) addUniq({ meaning: String(a).slice(0, 200), role: "faq-answer", notes: JSON.stringify({ question: String(q).slice(0, 80), source: "json-ld" }) });
3580
+ });
3581
+ const deliverables = item.deliverables ?? item.services ?? item.offerings ?? [];
3582
+ (Array.isArray(deliverables) ? deliverables : []).forEach((d) => {
3583
+ const title2 = d.title ?? d.name ?? "";
3584
+ const desc2 = d.description ?? d.desc ?? "";
3585
+ if (title2) addUniq({ meaning: `${title2}${desc2 ? ": " + desc2.slice(0, 120) : ""}`, role: "deliverable", notes: JSON.stringify({ title: title2, description: String(desc2).slice(0, 200) }) });
3586
+ });
3587
+ const stack = item.stack ?? item.technologies ?? item.tools ?? [];
3588
+ (Array.isArray(stack) ? stack : []).forEach((tool) => {
3589
+ const name = typeof tool === "string" ? tool : tool.name ?? "";
3590
+ if (name) addUniq({ meaning: name, role: "tech-stack-item", notes: JSON.stringify({ source: "json-ld" }) });
3591
+ });
3592
+ const outcomes = item.outcomes ?? item.metrics ?? item.stats ?? [];
3593
+ (Array.isArray(outcomes) ? outcomes : []).forEach((m) => {
3594
+ const metric = m.metric ?? m.value ?? "";
3595
+ const label = m.label ?? m.name ?? "";
3596
+ if (metric && label) addUniq({ meaning: `${metric} \u2014 ${label}`, role: "metric-card", notes: JSON.stringify({ metric, label }) });
3597
+ });
3598
+ const features = item.features ?? item.capabilities ?? [];
3599
+ (Array.isArray(features) ? features : []).forEach((f) => {
3600
+ const name = typeof f === "string" ? f : f.name ?? "";
3601
+ if (name) addUniq({ meaning: name, role: "feature-badge", notes: JSON.stringify({ source: "json-ld" }) });
3602
+ });
3603
+ if (item["@type"] === "HowTo" || item["@type"] === "Recipe") {
3604
+ const steps = item.step ?? item.steps ?? [];
3605
+ (Array.isArray(steps) ? steps : []).forEach((step, idx) => {
3606
+ const text = step.text ?? step.description ?? (typeof step === "string" ? step : "");
3607
+ const name = step.name ?? `Step ${idx + 1}`;
3608
+ if (text) addUniq({ meaning: `${name}: ${String(text).slice(0, 200)}`, role: "howto-step", notes: JSON.stringify({ stepIndex: idx + 1, name }) });
3609
+ });
3610
+ }
3611
+ if (item["@type"] === "BreadcrumbList") {
3612
+ const crumbs = item.itemListElement ?? [];
3613
+ (Array.isArray(crumbs) ? crumbs : []).forEach((crumb) => {
3614
+ const name = crumb.name ?? crumb.item?.name ?? "";
3615
+ const url = crumb.item?.["@id"] ?? crumb.item ?? "";
3616
+ if (name) addUniq({ meaning: name, role: "breadcrumb-item", notes: JSON.stringify({ position: crumb.position, url }) });
3617
+ });
3618
+ }
3619
+ if (item["@type"] === "Event") {
3620
+ const name = item.name ?? "";
3621
+ const startDate = item.startDate ?? "";
3622
+ const location2 = item.location?.name ?? item.location?.address?.streetAddress ?? "";
3623
+ if (name) addUniq({ meaning: name, role: "event-name", notes: JSON.stringify({ startDate, location: location2, source: "json-ld" }) });
3624
+ }
3625
+ if (item["@type"] === "JobPosting") {
3626
+ const title2 = item.title ?? "";
3627
+ const desc2 = item.description ?? "";
3628
+ const employer = item.hiringOrganization?.name ?? "";
3629
+ if (title2) addUniq({ meaning: `${title2}${employer ? " at " + employer : ""}`, role: "job-posting", notes: JSON.stringify({ description: String(desc2).slice(0, 200) }) });
3630
+ }
3631
+ if (item["@type"] === "Review") {
3632
+ const body = item.reviewBody ?? "";
3633
+ const rating = item.reviewRating?.ratingValue ?? "";
3634
+ const author = item.author?.name ?? "";
3635
+ if (body) addUniq({ meaning: String(body).slice(0, 200), role: "review-text", notes: JSON.stringify({ rating, author }) });
3636
+ }
3637
+ }
3638
+ } catch {
3639
+ }
3640
+ });
3641
+ document.querySelectorAll('[role="tabpanel"], [data-state="active"][id*="panel"], [id*="panel"][aria-labelledby]').forEach((el) => {
3642
+ if (!isVisible(el)) return;
3643
+ const heading = el.querySelector("h2, h3, h4")?.textContent?.trim() ?? "";
3644
+ const desc2 = el.querySelector("p")?.textContent?.replace(/\s+/g, " ").trim().slice(0, 200) ?? "";
3645
+ const badges = Array.from(el.querySelectorAll('[class*="badge" i], [class*="tag" i], [class*="pill" i], [class*="chip" i], [class*="rounded-full" i]')).map((b) => b.textContent?.trim()).filter(Boolean).slice(0, 8);
3646
+ const meaning = heading || desc2;
3647
+ if (meaning) addUniq({ meaning: meaning.slice(0, 150), role: "tab-panel", notes: JSON.stringify({ heading, description: desc2, badges, panelId: el.id }) });
3648
+ });
3649
+ document.querySelectorAll("[aria-controls]").forEach((btn) => {
3650
+ if (!isVisible(btn)) return;
3651
+ const panelId = btn.getAttribute("aria-controls");
3652
+ if (!panelId) return;
3653
+ const panel = document.getElementById(panelId);
3654
+ if (!panel) return;
3655
+ const qText = btn.textContent?.replace(/\s+/g, " ").trim().slice(0, 120);
3656
+ const aText = panel.textContent?.replace(/\s+/g, " ").trim().slice(0, 200);
3657
+ if (qText && qText.length > 3) addUniq({ meaning: qText, role: "faq-question", notes: JSON.stringify({ panelId, expanded: btn.getAttribute("aria-expanded") }) });
3658
+ if (aText && aText.length > 5 && aText !== qText) addUniq({ meaning: aText, role: "faq-answer", notes: JSON.stringify({ question: qText?.slice(0, 80), source: "aria-controls" }) });
3659
+ });
3660
+ const mainEl = document.querySelector('main, [role="main"], article') ?? document.body;
3661
+ 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) => {
3662
+ if (!isVisible(el)) return;
3663
+ const text = el.textContent?.replace(/\s+/g, " ").trim().slice(0, 60);
3664
+ if (text && text.length > 1 && text.length < 60 && !text.includes(" ")) {
3665
+ addUniq({ meaning: text, role: "tech-stack-item", notes: JSON.stringify({ source: "dom-pill" }) });
3666
+ } else if (text && text.length > 1 && text.length < 40) {
3667
+ addUniq({ meaning: text, role: "feature-badge", notes: JSON.stringify({ source: "dom-pill" }) });
3668
+ }
3669
+ });
3670
+ mainEl.querySelectorAll('a[href][class*="card" i], [class*="card" i] a[href], [class*="service" i][href], a[class*="service" i]').forEach((el) => {
3671
+ if (!isVisible(el)) return;
3672
+ const anchor = el.tagName === "A" ? el : el.querySelector("a");
3673
+ if (!anchor) return;
3674
+ const href = anchor.href;
3675
+ if (!href || /^(#|javascript:|mailto:|tel:)/i.test(href)) return;
3676
+ const heading = el.querySelector("h2, h3, h4")?.textContent?.trim() ?? "";
3677
+ const desc2 = el.querySelector("p")?.textContent?.replace(/\s+/g, " ").trim().slice(0, 120) ?? "";
3678
+ const label = anchor.textContent?.replace(/\s+/g, " ").trim().slice(0, 80) ?? "";
3679
+ const meaning = heading || label || "Linked card";
3680
+ if (meaning && meaning !== "Linked card" || heading && desc2) {
3681
+ addUniq({ meaning: `${heading || meaning}${desc2 ? ": " + desc2 : ""}`.slice(0, 180), role: "service-card", notes: JSON.stringify({ href: href.slice(0, 150), heading, description: desc2 }) });
3682
+ }
3683
+ });
3684
+ mainEl.querySelectorAll('[class*="metric" i], [class*="outcome" i], [class*="stat" i], [class*="kpi" i]').forEach((el) => {
3685
+ if (!isVisible(el)) return;
3686
+ const text = el.textContent?.replace(/\s+/g, " ").trim().slice(0, 100);
3687
+ if (text && text.length > 2 && text.length < 120) addUniq({ meaning: text, role: "metric-card", notes: JSON.stringify({ source: "dom-class" }) });
3688
+ });
3689
+ document.querySelectorAll('.sr-only, [class*="sr-only" i], [class*="visually-hidden" i]').forEach((el) => {
3690
+ const tag = el.tagName?.toLowerCase() ?? "";
3691
+ if (!/^h[1-6]$/.test(tag)) return;
3692
+ const text = el.textContent?.replace(/\s+/g, " ").trim().slice(0, 100);
3693
+ if (text) addUniq({ meaning: text, role: `${tag}-sr-only`, notes: JSON.stringify({ srOnly: true, tag }) });
3694
+ });
3695
+ try {
3696
+ const cssTokens = {};
3697
+ const rootStyle = window.getComputedStyle(document.documentElement);
3698
+ try {
3699
+ for (let si = 0; si < document.styleSheets.length; si++) {
3700
+ try {
3701
+ const sheet = document.styleSheets[si];
3702
+ const rules = sheet.cssRules;
3703
+ for (let ri = 0; ri < rules.length; ri++) {
3704
+ const rule = rules[ri];
3705
+ if (rule.selectorText === ":root" || rule.selectorText === "html" || rule.selectorText === ":root, html") {
3706
+ const style = rule.style;
3707
+ for (let pi = 0; pi < style.length; pi++) {
3708
+ const prop = style[pi];
3709
+ if (prop.startsWith("--") && Object.keys(cssTokens).length < 30) {
3710
+ const val = style.getPropertyValue(prop).trim();
3711
+ if (val) cssTokens[prop] = val.slice(0, 60);
3712
+ }
3713
+ }
3714
+ }
3715
+ }
3716
+ } catch {
3717
+ }
3718
+ }
3719
+ } catch {
3720
+ }
3721
+ const probePrefixes = ["", "--color-", "--bs-", "--brand-", "--clr-", "--theme-", "--md-sys-color-"];
3722
+ const probeNames = ["primary", "secondary", "accent", "background", "foreground", "surface", "text", "muted", "border", "ring", "card", "destructive", "warning", "success"];
3723
+ const probeFonts = ["--font-sans", "--font-mono", "--font-display", "--font-heading", "--font-body", "--font-family-base"];
3724
+ for (const pfx of probePrefixes) {
3725
+ for (const name of probeNames) {
3726
+ const v = `${pfx}${name}`;
3727
+ if (!cssTokens[v]) {
3728
+ const val = rootStyle.getPropertyValue(v).trim();
3729
+ if (val) cssTokens[v] = val.slice(0, 60);
3730
+ }
3731
+ }
3732
+ }
3733
+ for (const v of probeFonts) {
3734
+ if (!cssTokens[v]) {
3735
+ const val = rootStyle.getPropertyValue(v).trim();
3736
+ if (val) cssTokens[v] = val.slice(0, 60);
3737
+ }
3738
+ }
3739
+ if (Object.keys(cssTokens).length > 0) {
3740
+ const limited = Object.fromEntries(Object.entries(cssTokens).slice(0, 25));
3741
+ addUniq({ meaning: `Design tokens: ${Object.keys(limited).join(", ")}`, role: "design-tokens", notes: JSON.stringify(limited) });
3742
+ }
3743
+ } catch {
3744
+ }
3745
+ document.querySelectorAll("table").forEach((table) => {
3746
+ if (!isVisible(table)) return;
3747
+ const headers = Array.from(table.querySelectorAll("thead th, thead td, tr:first-child th")).map((th) => th.textContent?.trim()).filter(Boolean).slice(0, 8);
3748
+ const rowCount = table.querySelectorAll("tbody tr").length;
3749
+ if (headers.length > 0) addUniq({ meaning: `Table: ${headers.join(" \xB7 ")}`, role: "data-table", notes: JSON.stringify({ headers, rowCount }) });
3750
+ });
3751
+ document.querySelectorAll("dl").forEach((dl) => {
3752
+ if (!isVisible(dl)) return;
3753
+ const terms = Array.from(dl.querySelectorAll("dt")).map((dt) => dt.textContent?.trim()).filter(Boolean).slice(0, 10);
3754
+ if (terms.length > 0) addUniq({ meaning: `Definition list: ${terms.slice(0, 4).join(", ")}`, role: "definition-list", notes: JSON.stringify({ terms: terms.slice(0, 10) }) });
3755
+ });
3756
+ let quoteCount = 0;
3757
+ document.querySelectorAll('blockquote, [class*="testimonial" i], [class*="review" i]:not(script), [class*="quote" i]:not([class*="icon"]), [class*="pullquote" i]').forEach((el) => {
3758
+ if (quoteCount >= 10) return;
3759
+ if (!isVisible(el)) return;
3760
+ const text = el.textContent?.replace(/\s+/g, " ").trim().slice(0, 200);
3761
+ const author = el.querySelector('[class*="author" i], [class*="name" i], cite')?.textContent?.trim().slice(0, 60) ?? "";
3762
+ if (text && text.length > 20) {
3763
+ addUniq({ meaning: `${text.slice(0, 160)}${author ? " \u2014 " + author : ""}`, role: "testimonial", notes: JSON.stringify({ author }) });
3764
+ quoteCount++;
3765
+ }
3766
+ });
3767
+ 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) => {
3768
+ if (!isVisible(el)) return;
3769
+ const text = el.textContent?.replace(/\s+/g, " ").trim().slice(0, 150);
3770
+ if (text && text.length > 5) addUniq({ meaning: text, role: "alert-banner", notes: JSON.stringify({ ariaRole: el.getAttribute("role") }) });
3771
+ });
3772
+ let pricingCount = 0;
3773
+ document.querySelectorAll('[class*="pricing" i], [class*="plan" i]:not(main):not(article), [class*="tier" i]:not(nav), [class*="package" i]:not(script)').forEach((el) => {
3774
+ if (pricingCount >= 8) return;
3775
+ if (!isVisible(el)) return;
3776
+ const name = el.querySelector('h2,h3,h4,[class*="plan-name" i],[class*="tier-name" i]')?.textContent?.trim().slice(0, 60) ?? "";
3777
+ const price = el.querySelector('[class*="price" i],[class*="amount" i]')?.textContent?.trim().slice(0, 40) ?? "";
3778
+ if (name || price) {
3779
+ addUniq({ meaning: `${name || "Plan"}${price ? ": " + price : ""}`, role: "pricing-plan", notes: JSON.stringify({ planName: name, price }) });
3780
+ pricingCount++;
3781
+ }
3782
+ });
3783
+ const paginationEl = document.querySelector('[role="navigation"][aria-label*="paginat" i], [class*="pagination" i], [aria-label*="paginat" i]');
3784
+ if (paginationEl && isVisible(paginationEl)) {
3785
+ const pages = Array.from(paginationEl.querySelectorAll("a, button")).map((p) => p.textContent?.trim()).filter(Boolean).slice(0, 10);
3786
+ if (pages.length > 0) addUniq({ meaning: `Pagination: ${pages.join(", ")}`, role: "pagination", notes: JSON.stringify({ pages }) });
3787
+ }
3788
+ let codeCount = 0;
3789
+ document.querySelectorAll('pre code, [class*="code-block" i], [class*="highlight" i] pre').forEach((el) => {
3790
+ if (codeCount >= 5) return;
3791
+ if (!isVisible(el)) return;
3792
+ const lang = el.getAttribute("class")?.match(/language-(\w+)/)?.[1] ?? "";
3793
+ const text = el.textContent?.replace(/\s+/g, " ").trim().slice(0, 100);
3794
+ if (text && text.length > 10) {
3795
+ addUniq({ meaning: `Code example${lang ? " (" + lang + ")" : ""}: ${text.slice(0, 80)}`, role: "code-block", notes: JSON.stringify({ language: lang }) });
3796
+ codeCount++;
3797
+ }
3798
+ });
3799
+ let dropdownCount = 0;
3800
+ document.querySelectorAll('[role="listbox"] [role="option"], [role="option"]:not([role="listbox"] *)').forEach((el) => {
3801
+ if (dropdownCount >= 20) return;
3802
+ if (!isVisible(el)) return;
3803
+ const text = el.textContent?.trim().slice(0, 80);
3804
+ if (text) {
3805
+ addUniq({ meaning: text, role: "dropdown-option", notes: JSON.stringify({ selected: el.getAttribute("aria-selected") }) });
3806
+ dropdownCount++;
3807
+ }
3808
+ });
3809
+ let timelineCount = 0;
3810
+ document.querySelectorAll('[class*="timeline" i] li, [class*="timeline" i] [class*="step" i], [class*="process" i] li, [class*="steps" i] li').forEach((el) => {
3811
+ if (timelineCount >= 10) return;
3812
+ if (!isVisible(el)) return;
3813
+ const text = el.textContent?.replace(/\s+/g, " ").trim().slice(0, 150);
3814
+ if (text && text.length > 5) {
3815
+ addUniq({ meaning: text, role: "timeline-step", notes: JSON.stringify({ source: "dom-class" }) });
3816
+ timelineCount++;
3817
+ }
3818
+ });
3819
+ let teamCount = 0;
3820
+ document.querySelectorAll('[class*="team-member" i], [class*="team-card" i], [class*="person-card" i], [class*="bio" i][class*="card" i]').forEach((el) => {
3821
+ if (teamCount >= 10) return;
3822
+ if (!isVisible(el)) return;
3823
+ const name = el.querySelector('[class*="name" i], h3, h4')?.textContent?.trim().slice(0, 60) ?? "";
3824
+ const role = el.querySelector('[class*="role" i], [class*="title" i], [class*="position" i]')?.textContent?.trim().slice(0, 60) ?? "";
3825
+ if (name) {
3826
+ addUniq({ meaning: `${name}${role ? " \u2014 " + role : ""}`, role: "team-member", notes: JSON.stringify({ name, role }) });
3827
+ teamCount++;
3828
+ }
3829
+ });
3830
+ document.querySelectorAll("iframe").forEach((iframe) => {
3831
+ const src = iframe.getAttribute("src") || iframe.getAttribute("data-src") || "";
3832
+ const title2 = iframe.getAttribute("title") || iframe.getAttribute("aria-label") || "";
3833
+ if (!src) return;
3834
+ if (/youtube\.com\/embed|youtu\.be\/|youtube-nocookie\.com/i.test(src)) {
3835
+ const videoId = src.match(/\/embed\/([^/?&]+)/)?.[1] ?? "";
3836
+ addUniq({ meaning: title2 || `YouTube video${videoId ? " (ID: " + videoId + ")" : ""}`, role: "video-embed", notes: JSON.stringify({ platform: "youtube", src: src.slice(0, 200), title: title2, videoId }) });
3837
+ } else if (/vimeo\.com\/video\//i.test(src)) {
3838
+ const videoId = src.match(/video\/(\d+)/)?.[1] ?? "";
3839
+ addUniq({ meaning: title2 || `Vimeo video${videoId ? " (ID: " + videoId + ")" : ""}`, role: "video-embed", notes: JSON.stringify({ platform: "vimeo", src: src.slice(0, 200), title: title2, videoId }) });
3840
+ } else if (/wistia\.com|loom\.com|dailymotion\.com|twitch\.tv|brightcove\.com/i.test(src)) {
3841
+ const platform = /wistia/.test(src) ? "wistia" : /loom/.test(src) ? "loom" : /dailymotion/.test(src) ? "dailymotion" : /twitch/.test(src) ? "twitch" : "brightcove";
3842
+ addUniq({ meaning: title2 || `${platform} video`, role: "video-embed", notes: JSON.stringify({ platform, src: src.slice(0, 200), title: title2 }) });
3843
+ } else if (/maps\.google\.com|google\.com\/maps\/embed/i.test(src)) {
3844
+ const q = new URLSearchParams(src.split("?")[1] ?? "").get("q") ?? new URLSearchParams(src.split("?")[1] ?? "").get("query") ?? "";
3845
+ addUniq({ meaning: title2 || `Google Maps${q ? ": " + decodeURIComponent(q).slice(0, 80) : ""}`, role: "map-embed", notes: JSON.stringify({ provider: "google", src: src.slice(0, 200), query: q }) });
3846
+ } else if (/openstreetmap\.org|mapbox\.com|here\.com|maps\.apple\.com/i.test(src)) {
3847
+ const provider = /openstreetmap/.test(src) ? "OpenStreetMap" : /mapbox/.test(src) ? "Mapbox" : /here\.com/.test(src) ? "HERE Maps" : "Apple Maps";
3848
+ addUniq({ meaning: title2 || `${provider} embed`, role: "map-embed", notes: JSON.stringify({ provider, src: src.slice(0, 200), title: title2 }) });
3849
+ }
3850
+ });
3851
+ document.querySelectorAll("svg").forEach((svg) => {
3852
+ if (!isVisible(svg)) return;
3853
+ const titleEl = svg.querySelector("title");
3854
+ const label = svg.getAttribute("aria-label")?.trim() || titleEl?.textContent?.trim() || "";
3855
+ if (!label) return;
3856
+ const rect = svg.getBoundingClientRect();
3857
+ if (rect.width < 48 && rect.height < 48) return;
3858
+ addUniq({ meaning: label.slice(0, 100), role: "svg-image", notes: JSON.stringify({ width: Math.round(rect.width), height: Math.round(rect.height) }) });
3859
+ });
3860
+ let counterCount = 0;
3861
+ document.querySelectorAll('[data-count],[data-target],[data-to],[data-countup],[class*="counter" i]:not(button),[class*="count-up" i],[class*="odometer" i]').forEach((el) => {
3862
+ if (counterCount >= 15) return;
3863
+ if (!isVisible(el)) return;
3864
+ const targetVal = el.getAttribute("data-count") || el.getAttribute("data-target") || el.getAttribute("data-to") || el.getAttribute("data-countup") || "";
3865
+ const currentText = el.textContent?.replace(/\s+/g, " ").trim().slice(0, 40) ?? "";
3866
+ 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) ?? "";
3867
+ const value = targetVal || currentText;
3868
+ if (value && value !== "0") {
3869
+ addUniq({ meaning: contextLabel.slice(0, 100) || `Counter: ${value}`, role: "animated-counter", notes: JSON.stringify({ targetValue: targetVal, displayedValue: currentText }) });
3870
+ counterCount++;
3871
+ }
3872
+ });
3873
+ let logoCount = 0;
3874
+ 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) => {
3875
+ if (logoCount >= 20) return;
3876
+ if (!isVisible(img)) return;
3877
+ const alt = img.getAttribute("alt")?.trim().slice(0, 60) ?? "";
3878
+ const src = (img.getAttribute("src") || img.getAttribute("data-src") || "").slice(0, 120);
3879
+ if (!alt && !src) return;
3880
+ addUniq({ meaning: alt || src.split("/").pop()?.split("?")[0]?.slice(0, 50) || "Client logo", role: "client-logo", notes: JSON.stringify({ alt, src, hasAlt: !!alt }) });
3881
+ logoCount++;
3882
+ });
3883
+ let awardCount = 0;
3884
+ 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) => {
3885
+ if (awardCount >= 10) return;
3886
+ if (!isVisible(el)) return;
3887
+ const text = el.textContent?.replace(/\s+/g, " ").trim().slice(0, 80);
3888
+ const imgEl = el.querySelector("img");
3889
+ const label = text || imgEl?.getAttribute("alt")?.trim() || "";
3890
+ if (label && label.length > 2) {
3891
+ addUniq({ meaning: label, role: "award-badge", notes: JSON.stringify({ hasImage: !!imgEl }) });
3892
+ awardCount++;
3893
+ }
3894
+ });
3895
+ try {
3896
+ const abTools = [];
3897
+ if (window.optimizely || window.Optimizely) abTools.push("Optimizely");
3898
+ if (window.VWO || window._vwo_code) abTools.push("VWO");
3899
+ if (window.__ABTasty || window.ABTasty) abTools.push("AB Tasty");
3900
+ if (document.querySelector('script[src*="optimize.js"]')) abTools.push("Google Optimize");
3901
+ if (window.splitbee) abTools.push("Splitbee");
3902
+ if (window.__GROWTHBOOK__ || document.querySelector('script[src*="growthbook"]')) abTools.push("GrowthBook");
3903
+ if (window.Kameleoon) abTools.push("Kameleoon");
3904
+ if (abTools.length > 0) {
3905
+ const optExp = window.optimizely?.get?.("state")?.getActiveExperimentIds?.() ?? [];
3906
+ 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) }) });
3907
+ }
3908
+ } catch {
3909
+ }
3910
+ try {
3911
+ const signals = [];
3912
+ if (window.__geo || window.__country || window.__region) signals.push("window geo globals");
3913
+ if (document.querySelector("[data-geo],[data-country],[data-region],[data-locale]")) signals.push("data-geo DOM attributes");
3914
+ const ipScripts = Array.from(document.querySelectorAll("script[src]")).filter((s) => /ipinfo|maxmind|ipstack|ipgeolocation/i.test(s.getAttribute("src") ?? "")).length;
3915
+ if (ipScripts > 0) signals.push("IP geolocation script");
3916
+ if (window.Intl?.DateTimeFormat?.()?.resolvedOptions?.()?.timeZone) signals.push(`timezone: ${window.Intl.DateTimeFormat().resolvedOptions().timeZone}`);
3917
+ if (signals.length > 1) {
3918
+ addUniq({ meaning: `Personalization/geo signals: ${signals.join("; ")}`, role: "personalization-layer", notes: JSON.stringify({ signals }) });
3919
+ }
3920
+ } catch {
3921
+ }
3922
+ let hoverCount = 0;
3923
+ document.querySelectorAll('[class*="group"]:not(nav):not(ul):not(ol),[class*="hover-trigger" i],[data-hover],[class*="has-dropdown" i]').forEach((trigger) => {
3924
+ if (hoverCount >= 8) return;
3925
+ if (!isVisible(trigger)) return;
3926
+ const hiddenChildren = Array.from(trigger.querySelectorAll('[class*="group-hover"],[class*="hover-content" i],[class*="dropdown-menu" i],[class*="submenu" i]')).filter((c) => {
3927
+ try {
3928
+ const s = window.getComputedStyle(c);
3929
+ return s.display === "none" || s.visibility === "hidden" || parseFloat(s.opacity) < 0.1;
3930
+ } catch {
3931
+ return false;
3932
+ }
3933
+ });
3934
+ for (const child of hiddenChildren.slice(0, 3)) {
3935
+ const text = child.textContent?.replace(/\s+/g, " ").trim().slice(0, 150);
3936
+ if (text && text.length > 3) {
3937
+ addUniq({ meaning: text, role: "hover-content", notes: JSON.stringify({ triggerClass: (trigger.className?.toString() ?? "").slice(0, 60) }) });
3938
+ hoverCount++;
3939
+ }
3940
+ }
3941
+ });
3942
+ return out.slice(0, 400);
3943
+ }).then((elems) => elems.map((e) => ({ ...e, isInteractive: false }))).catch(() => []);
3302
3944
  }
3303
3945
  async function interactAndCollectLinks(page, origin) {
3304
3946
  const collected = /* @__PURE__ */ new Set();