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