@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
|
@@ -83,11 +83,18 @@ function extractMetadata(html, url) {
|
|
|
83
83
|
ogImage: null,
|
|
84
84
|
ogUrl: null,
|
|
85
85
|
ogType: null,
|
|
86
|
+
ogSiteName: null,
|
|
87
|
+
ogLocale: null,
|
|
86
88
|
twitterCard: null,
|
|
87
89
|
twitterTitle: null,
|
|
88
90
|
twitterDescription: null,
|
|
91
|
+
twitterImage: null,
|
|
92
|
+
twitterSite: null,
|
|
89
93
|
canonical: null,
|
|
90
94
|
robots: null,
|
|
95
|
+
hreflang: [],
|
|
96
|
+
themeColor: null,
|
|
97
|
+
favicon: null,
|
|
91
98
|
jsonLd: [],
|
|
92
99
|
schemaTypes: [],
|
|
93
100
|
lang: null
|
|
@@ -107,14 +114,27 @@ function extractMetadata(html, url) {
|
|
|
107
114
|
if (name === "twitter:card") meta.twitterCard = content;
|
|
108
115
|
if (name === "twitter:title") meta.twitterTitle = content;
|
|
109
116
|
if (name === "twitter:description") meta.twitterDescription = content;
|
|
117
|
+
if (name === "twitter:image") meta.twitterImage = content;
|
|
118
|
+
if (name === "twitter:site") meta.twitterSite = content;
|
|
119
|
+
if (name === "theme-color") meta.themeColor = content;
|
|
110
120
|
if (prop === "og:title") meta.ogTitle = content;
|
|
111
121
|
if (prop === "og:description") meta.ogDescription = content;
|
|
112
122
|
if (prop === "og:image") meta.ogImage = content;
|
|
113
123
|
if (prop === "og:url") meta.ogUrl = content;
|
|
114
124
|
if (prop === "og:type") meta.ogType = content;
|
|
125
|
+
if (prop === "og:site_name") meta.ogSiteName = content;
|
|
126
|
+
if (prop === "og:locale") meta.ogLocale = content;
|
|
115
127
|
}
|
|
116
128
|
const canonical = document.querySelector('link[rel="canonical"]');
|
|
117
129
|
if (canonical) meta.canonical = canonical.getAttribute("href") ?? null;
|
|
130
|
+
const faviconEl = document.querySelector('link[rel="icon"], link[rel="shortcut icon"]');
|
|
131
|
+
if (faviconEl) meta.favicon = faviconEl.getAttribute("href") ?? null;
|
|
132
|
+
const hreflangEls = [...document.querySelectorAll('link[rel="alternate"][hreflang]')];
|
|
133
|
+
for (const el of hreflangEls) {
|
|
134
|
+
const lang = el.getAttribute("hreflang") ?? "";
|
|
135
|
+
const href = el.getAttribute("href") ?? "";
|
|
136
|
+
if (lang && href) meta.hreflang.push({ lang, href });
|
|
137
|
+
}
|
|
118
138
|
const jsonLdScripts = [...document.querySelectorAll('script[type="application/ld+json"]')];
|
|
119
139
|
for (const s of jsonLdScripts) {
|
|
120
140
|
try {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/extractors/advanced.ts"],
|
|
4
|
-
"sourcesContent": ["import { parseHTML } from 'linkedom';\n\nexport interface ExtractionResult {\n matches: string[];\n count: number;\n method: string;\n}\n\nexport type TableRow = Record<string, string>;\n\nexport interface PageMetadata {\n title: string | null;\n description: string | null;\n keywords: string | null;\n ogTitle: string | null;\n ogDescription: string | null;\n ogImage: string | null;\n ogUrl: string | null;\n ogType: string | null;\n twitterCard: string | null;\n twitterTitle: string | null;\n twitterDescription: string | null;\n canonical: string | null;\n robots: string | null;\n jsonLd: object[];\n schemaTypes: string[];\n lang: string | null;\n}\n\nexport function extractByCssSelector(html: string, selector: string, attr?: string): ExtractionResult {\n try {\n const { document } = parseHTML(html);\n const nodes = Array.from(document.querySelectorAll(selector));\n const matches = nodes.map((n: any) => {\n if (attr) return (n as any).getAttribute(attr) ?? '';\n return (n as any).textContent?.trim() ?? '';\n }).filter(Boolean);\n return { matches, count: matches.length, method: 'css-selector' };\n } catch (e) {\n return { matches: [], count: 0, method: 'css-selector-error' };\n }\n}\n\nexport function extractByXPath(html: string, xpath: string): ExtractionResult {\n try {\n const { document, XPathResult } = parseHTML(html) as any;\n const result = document.evaluate(xpath, document, null, XPathResult?.ANY_TYPE ?? 0, null);\n const matches: string[] = [];\n let node = result.iterateNext?.();\n while (node) {\n const text = (node as any).textContent?.trim() ?? (node as any).nodeValue?.trim() ?? '';\n if (text) matches.push(text);\n node = result.iterateNext?.();\n }\n return { matches, count: matches.length, method: 'xpath' };\n } catch (e) {\n return { matches: [], count: 0, method: 'xpath-error' };\n }\n}\n\nexport function extractByRegex(text: string, pattern: string, flags?: string): ExtractionResult {\n try {\n const re = new RegExp(pattern, (flags ?? 'g').includes('g') ? flags : (flags ?? '') + 'g');\n const matches: string[] = [];\n let m: RegExpExecArray | null;\n while ((m = re.exec(text)) !== null) {\n matches.push(m[1] ?? m[0]);\n if (!re.global) break;\n }\n return { matches, count: matches.length, method: 'regex' };\n } catch {\n return { matches: [], count: 0, method: 'regex-error' };\n }\n}\n\nexport function extractTables(html: string): TableRow[][] {\n try {\n const { document } = parseHTML(html);\n const tables = Array.from(document.querySelectorAll('table'));\n return tables.map((table: any) => {\n const headers: string[] = [];\n const rows: TableRow[] = [];\n const ths = [...table.querySelectorAll('thead th, thead td, tr:first-child th')];\n if (ths.length > 0) {\n ths.forEach((th: any, i: number) => headers.push(th.textContent?.trim() || `col${i + 1}`));\n }\n const trs = [...table.querySelectorAll('tbody tr, tr')];\n for (const tr of trs) {\n const cells = [...(tr as any).querySelectorAll('td, th')];\n if (cells.length === 0) continue;\n if (headers.length === 0) {\n cells.forEach((_: any, i: number) => headers.push(`col${i + 1}`));\n }\n const row: TableRow = {};\n cells.forEach((cell: any, i: number) => {\n const key = headers[i] || `col${i + 1}`;\n row[key] = cell.textContent?.trim() ?? '';\n });\n if (Object.values(row).some(v => v)) rows.push(row);\n }\n return rows;\n }).filter(t => t.length > 0);\n } catch {\n return [];\n }\n}\n\nexport function extractMetadata(html: string, url: string): PageMetadata {\n const meta: PageMetadata = {\n title: null, description: null, keywords: null,\n ogTitle: null, ogDescription: null, ogImage: null, ogUrl: null, ogType: null,\n twitterCard: null, twitterTitle: null, twitterDescription: null,\n canonical: null, robots: null, jsonLd: [], schemaTypes: [], lang: null,\n };\n try {\n const { document } = parseHTML(html);\n meta.title = (document as any).title ?? (document as any).querySelector('title')?.textContent?.trim() ?? null;\n meta.lang = (document as any).documentElement?.getAttribute('lang') ?? null;\n\n const metas = [...(document as any).querySelectorAll('meta')];\n for (const m of metas) {\n const name = (m as any).getAttribute('name')?.toLowerCase() ?? '';\n const prop = (m as any).getAttribute('property')?.toLowerCase() ?? '';\n const content = (m as any).getAttribute('content') ?? '';\n if (name === 'description') meta.description = content;\n if (name === 'keywords') meta.keywords = content;\n if (name === 'robots') meta.robots = content;\n if (name === 'twitter:card') meta.twitterCard = content;\n if (name === 'twitter:title') meta.twitterTitle = content;\n if (name === 'twitter:description') meta.twitterDescription = content;\n if (prop === 'og:title') meta.ogTitle = content;\n if (prop === 'og:description') meta.ogDescription = content;\n if (prop === 'og:image') meta.ogImage = content;\n if (prop === 'og:url') meta.ogUrl = content;\n if (prop === 'og:type') meta.ogType = content;\n }\n\n const canonical = (document as any).querySelector('link[rel=\"canonical\"]');\n if (canonical) meta.canonical = canonical.getAttribute('href') ?? null;\n\n const jsonLdScripts = [...(document as any).querySelectorAll('script[type=\"application/ld+json\"]')];\n for (const s of jsonLdScripts) {\n try {\n const parsed = JSON.parse((s as any).textContent ?? '{}');\n meta.jsonLd.push(parsed);\n const types = Array.isArray(parsed) ? parsed.map((p: any) => p['@type']).flat() : [parsed['@type']];\n meta.schemaTypes.push(...types.filter(Boolean));\n } catch { /* skip invalid JSON-LD */ }\n }\n } catch { /* non-fatal */ }\n return meta;\n}\n\nexport interface ProductData {\n name: string | null;\n description: string | null;\n brand: string | null;\n sku: string | null;\n price: number | null;\n priceCurrency: string | null;\n availability: string | null;\n rating: number | null;\n reviewCount: number | null;\n image: string | null;\n url: string | null;\n}\n\nexport function extractStructuredProduct(jsonLd: object[]): ProductData | null {\n for (const item of jsonLd) {\n try {\n const type = (item as any)['@type'];\n const isProduct = Array.isArray(type)\n ? type.some((t: any) => typeof t === 'string' && t.toLowerCase() === 'product')\n : typeof type === 'string' && type.toLowerCase() === 'product';\n if (!isProduct) continue;\n\n const i = item as any;\n const offers = Array.isArray(i.offers) ? i.offers[0] : i.offers;\n\n const rawPrice = offers?.price;\n const price = rawPrice != null ? parseFloat(rawPrice) : NaN;\n\n const rawRating = i.aggregateRating?.ratingValue;\n const rating = rawRating != null ? parseFloat(rawRating) : NaN;\n\n const rawReviewCount = i.aggregateRating?.reviewCount;\n const reviewCount = rawReviewCount != null ? parseInt(rawReviewCount, 10) : NaN;\n\n const rawAvailability: string | undefined = offers?.availability;\n const availability = rawAvailability\n ? rawAvailability.replace(/^https?:\\/\\/schema\\.org\\//, '')\n : null;\n\n const rawImage = i.image;\n const image = Array.isArray(rawImage) ? (rawImage[0] ?? null) : (rawImage ?? null);\n\n const brand = i.brand?.name ?? i.brand ?? null;\n\n return {\n name: i.name ?? null,\n description: i.description ?? null,\n brand: typeof brand === 'string' ? brand : null,\n sku: i.sku ?? null,\n price: isNaN(price) ? null : price,\n priceCurrency: offers?.priceCurrency ?? null,\n availability,\n rating: isNaN(rating) ? null : rating,\n reviewCount: isNaN(reviewCount) ? null : reviewCount,\n image,\n url: i.url ?? null,\n };\n } catch { /* skip malformed item */ }\n }\n return null;\n}\n\nexport function extractImages(html: string, baseUrl: string): Array<{ src: string; alt: string; width?: number; height?: number }> {\n try {\n const { document } = parseHTML(html);\n const base = new URL(baseUrl);\n return Array.from((document as any).querySelectorAll('img'))\n .map((img: any) => {\n const src = img.getAttribute('src') ?? img.getAttribute('data-src') ?? '';\n if (!src) return null;\n try {\n return {\n src: new URL(src, base).href,\n alt: (img.getAttribute('alt') ?? '') as string,\n width: img.getAttribute('width') ? parseInt(img.getAttribute('width')) : undefined,\n height: img.getAttribute('height') ? parseInt(img.getAttribute('height')) : undefined,\n } as { src: string; alt: string; width?: number; height?: number };\n } catch { return null; }\n })\n .filter((x): x is { src: string; alt: string; width?: number; height?: number } => x !== null);\n } catch {\n return [];\n }\n}\n\nexport function extractMediaFiles(html: string, baseUrl: string): Array<{ url: string; type: 'image' | 'video' | 'audio' | 'document'; tag: string }> {\n const IMAGE_EXT = /\\.(png|jpe?g|gif|webp|svg|ico|avif)(\\?.*)?$/i;\n const VIDEO_EXT = /\\.(mp4|webm|ogv|mov|avi)(\\?.*)?$/i;\n const AUDIO_EXT = /\\.(mp3|ogg|wav|flac|aac)(\\?.*)?$/i;\n const DOC_EXT = /\\.(pdf|docx?|xlsx?|pptx?|csv|zip)(\\?.*)?$/i;\n\n function classify(url: string, tag: string): 'image' | 'video' | 'audio' | 'document' {\n if (tag === 'img' || IMAGE_EXT.test(url)) return 'image';\n if (tag === 'video' || VIDEO_EXT.test(url)) return 'video';\n if (tag === 'audio' || AUDIO_EXT.test(url)) return 'audio';\n if (DOC_EXT.test(url)) return 'document';\n return 'document';\n }\n\n try {\n const { document } = parseHTML(html);\n const base = new URL(baseUrl);\n const results: Array<{ url: string; type: 'image' | 'video' | 'audio' | 'document'; tag: string }> = [];\n const seen = new Set<string>();\n\n const selectors: [string, string] = ['img[src],video[src],audio[src],source[src]', 'src'];\n const attrPairs: Array<[string, string]> = [\n ['img', 'src'], ['video', 'src'], ['audio', 'src'], ['source', 'src'],\n ['a', 'href'],\n ];\n\n for (const [sel, attr] of attrPairs) {\n for (const el of [...(document as any).querySelectorAll(sel)]) {\n const raw = (el as any).getAttribute(attr) ?? '';\n if (!raw) continue;\n try {\n const resolved = new URL(raw, base).href;\n if (seen.has(resolved)) continue;\n if (sel === 'a' && !DOC_EXT.test(resolved) && !IMAGE_EXT.test(resolved) && !VIDEO_EXT.test(resolved) && !AUDIO_EXT.test(resolved)) continue;\n seen.add(resolved);\n results.push({ url: resolved, type: classify(resolved, sel), tag: sel });\n } catch { /* skip */ }\n }\n }\n return results;\n } catch {\n return [];\n }\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,iBAAiB;
|
|
4
|
+
"sourcesContent": ["import { parseHTML } from 'linkedom';\n\nexport interface ExtractionResult {\n matches: string[];\n count: number;\n method: string;\n}\n\nexport type TableRow = Record<string, string>;\n\nexport interface PageMetadata {\n title: string | null;\n description: string | null;\n keywords: string | null;\n ogTitle: string | null;\n ogDescription: string | null;\n ogImage: string | null;\n ogUrl: string | null;\n ogType: string | null;\n ogSiteName: string | null;\n ogLocale: string | null;\n twitterCard: string | null;\n twitterTitle: string | null;\n twitterDescription: string | null;\n twitterImage: string | null;\n twitterSite: string | null;\n canonical: string | null;\n robots: string | null;\n hreflang: Array<{ lang: string; href: string }>;\n themeColor: string | null;\n favicon: string | null;\n jsonLd: object[];\n schemaTypes: string[];\n lang: string | null;\n}\n\nexport function extractByCssSelector(html: string, selector: string, attr?: string): ExtractionResult {\n try {\n const { document } = parseHTML(html);\n const nodes = Array.from(document.querySelectorAll(selector));\n const matches = nodes.map((n: any) => {\n if (attr) return (n as any).getAttribute(attr) ?? '';\n return (n as any).textContent?.trim() ?? '';\n }).filter(Boolean);\n return { matches, count: matches.length, method: 'css-selector' };\n } catch (e) {\n return { matches: [], count: 0, method: 'css-selector-error' };\n }\n}\n\nexport function extractByXPath(html: string, xpath: string): ExtractionResult {\n try {\n const { document, XPathResult } = parseHTML(html) as any;\n const result = document.evaluate(xpath, document, null, XPathResult?.ANY_TYPE ?? 0, null);\n const matches: string[] = [];\n let node = result.iterateNext?.();\n while (node) {\n const text = (node as any).textContent?.trim() ?? (node as any).nodeValue?.trim() ?? '';\n if (text) matches.push(text);\n node = result.iterateNext?.();\n }\n return { matches, count: matches.length, method: 'xpath' };\n } catch (e) {\n return { matches: [], count: 0, method: 'xpath-error' };\n }\n}\n\nexport function extractByRegex(text: string, pattern: string, flags?: string): ExtractionResult {\n try {\n const re = new RegExp(pattern, (flags ?? 'g').includes('g') ? flags : (flags ?? '') + 'g');\n const matches: string[] = [];\n let m: RegExpExecArray | null;\n while ((m = re.exec(text)) !== null) {\n matches.push(m[1] ?? m[0]);\n if (!re.global) break;\n }\n return { matches, count: matches.length, method: 'regex' };\n } catch {\n return { matches: [], count: 0, method: 'regex-error' };\n }\n}\n\nexport function extractTables(html: string): TableRow[][] {\n try {\n const { document } = parseHTML(html);\n const tables = Array.from(document.querySelectorAll('table'));\n return tables.map((table: any) => {\n const headers: string[] = [];\n const rows: TableRow[] = [];\n const ths = [...table.querySelectorAll('thead th, thead td, tr:first-child th')];\n if (ths.length > 0) {\n ths.forEach((th: any, i: number) => headers.push(th.textContent?.trim() || `col${i + 1}`));\n }\n const trs = [...table.querySelectorAll('tbody tr, tr')];\n for (const tr of trs) {\n const cells = [...(tr as any).querySelectorAll('td, th')];\n if (cells.length === 0) continue;\n if (headers.length === 0) {\n cells.forEach((_: any, i: number) => headers.push(`col${i + 1}`));\n }\n const row: TableRow = {};\n cells.forEach((cell: any, i: number) => {\n const key = headers[i] || `col${i + 1}`;\n row[key] = cell.textContent?.trim() ?? '';\n });\n if (Object.values(row).some(v => v)) rows.push(row);\n }\n return rows;\n }).filter(t => t.length > 0);\n } catch {\n return [];\n }\n}\n\nexport function extractMetadata(html: string, url: string): PageMetadata {\n const meta: PageMetadata = {\n title: null, description: null, keywords: null,\n ogTitle: null, ogDescription: null, ogImage: null, ogUrl: null, ogType: null,\n ogSiteName: null, ogLocale: null,\n twitterCard: null, twitterTitle: null, twitterDescription: null,\n twitterImage: null, twitterSite: null,\n canonical: null, robots: null,\n hreflang: [], themeColor: null, favicon: null,\n jsonLd: [], schemaTypes: [], lang: null,\n };\n try {\n const { document } = parseHTML(html);\n meta.title = (document as any).title ?? (document as any).querySelector('title')?.textContent?.trim() ?? null;\n meta.lang = (document as any).documentElement?.getAttribute('lang') ?? null;\n\n const metas = [...(document as any).querySelectorAll('meta')];\n for (const m of metas) {\n const name = (m as any).getAttribute('name')?.toLowerCase() ?? '';\n const prop = (m as any).getAttribute('property')?.toLowerCase() ?? '';\n const content = (m as any).getAttribute('content') ?? '';\n if (name === 'description') meta.description = content;\n if (name === 'keywords') meta.keywords = content;\n if (name === 'robots') meta.robots = content;\n if (name === 'twitter:card') meta.twitterCard = content;\n if (name === 'twitter:title') meta.twitterTitle = content;\n if (name === 'twitter:description') meta.twitterDescription = content;\n if (name === 'twitter:image') meta.twitterImage = content;\n if (name === 'twitter:site') meta.twitterSite = content;\n if (name === 'theme-color') meta.themeColor = content;\n if (prop === 'og:title') meta.ogTitle = content;\n if (prop === 'og:description') meta.ogDescription = content;\n if (prop === 'og:image') meta.ogImage = content;\n if (prop === 'og:url') meta.ogUrl = content;\n if (prop === 'og:type') meta.ogType = content;\n if (prop === 'og:site_name') meta.ogSiteName = content;\n if (prop === 'og:locale') meta.ogLocale = content;\n }\n\n const canonical = (document as any).querySelector('link[rel=\"canonical\"]');\n if (canonical) meta.canonical = canonical.getAttribute('href') ?? null;\n\n const faviconEl = (document as any).querySelector('link[rel=\"icon\"], link[rel=\"shortcut icon\"]');\n if (faviconEl) meta.favicon = faviconEl.getAttribute('href') ?? null;\n\n const hreflangEls = [...(document as any).querySelectorAll('link[rel=\"alternate\"][hreflang]')];\n for (const el of hreflangEls) {\n const lang = (el as any).getAttribute('hreflang') ?? '';\n const href = (el as any).getAttribute('href') ?? '';\n if (lang && href) meta.hreflang.push({ lang, href });\n }\n\n const jsonLdScripts = [...(document as any).querySelectorAll('script[type=\"application/ld+json\"]')];\n for (const s of jsonLdScripts) {\n try {\n const parsed = JSON.parse((s as any).textContent ?? '{}');\n meta.jsonLd.push(parsed);\n const types = Array.isArray(parsed) ? parsed.map((p: any) => p['@type']).flat() : [parsed['@type']];\n meta.schemaTypes.push(...types.filter(Boolean));\n } catch { /* skip invalid JSON-LD */ }\n }\n } catch { /* non-fatal */ }\n return meta;\n}\n\nexport interface ProductData {\n name: string | null;\n description: string | null;\n brand: string | null;\n sku: string | null;\n price: number | null;\n priceCurrency: string | null;\n availability: string | null;\n rating: number | null;\n reviewCount: number | null;\n image: string | null;\n url: string | null;\n}\n\nexport function extractStructuredProduct(jsonLd: object[]): ProductData | null {\n for (const item of jsonLd) {\n try {\n const type = (item as any)['@type'];\n const isProduct = Array.isArray(type)\n ? type.some((t: any) => typeof t === 'string' && t.toLowerCase() === 'product')\n : typeof type === 'string' && type.toLowerCase() === 'product';\n if (!isProduct) continue;\n\n const i = item as any;\n const offers = Array.isArray(i.offers) ? i.offers[0] : i.offers;\n\n const rawPrice = offers?.price;\n const price = rawPrice != null ? parseFloat(rawPrice) : NaN;\n\n const rawRating = i.aggregateRating?.ratingValue;\n const rating = rawRating != null ? parseFloat(rawRating) : NaN;\n\n const rawReviewCount = i.aggregateRating?.reviewCount;\n const reviewCount = rawReviewCount != null ? parseInt(rawReviewCount, 10) : NaN;\n\n const rawAvailability: string | undefined = offers?.availability;\n const availability = rawAvailability\n ? rawAvailability.replace(/^https?:\\/\\/schema\\.org\\//, '')\n : null;\n\n const rawImage = i.image;\n const image = Array.isArray(rawImage) ? (rawImage[0] ?? null) : (rawImage ?? null);\n\n const brand = i.brand?.name ?? i.brand ?? null;\n\n return {\n name: i.name ?? null,\n description: i.description ?? null,\n brand: typeof brand === 'string' ? brand : null,\n sku: i.sku ?? null,\n price: isNaN(price) ? null : price,\n priceCurrency: offers?.priceCurrency ?? null,\n availability,\n rating: isNaN(rating) ? null : rating,\n reviewCount: isNaN(reviewCount) ? null : reviewCount,\n image,\n url: i.url ?? null,\n };\n } catch { /* skip malformed item */ }\n }\n return null;\n}\n\nexport function extractImages(html: string, baseUrl: string): Array<{ src: string; alt: string; width?: number; height?: number }> {\n try {\n const { document } = parseHTML(html);\n const base = new URL(baseUrl);\n return Array.from((document as any).querySelectorAll('img'))\n .map((img: any) => {\n const src = img.getAttribute('src') ?? img.getAttribute('data-src') ?? '';\n if (!src) return null;\n try {\n return {\n src: new URL(src, base).href,\n alt: (img.getAttribute('alt') ?? '') as string,\n width: img.getAttribute('width') ? parseInt(img.getAttribute('width')) : undefined,\n height: img.getAttribute('height') ? parseInt(img.getAttribute('height')) : undefined,\n } as { src: string; alt: string; width?: number; height?: number };\n } catch { return null; }\n })\n .filter((x): x is { src: string; alt: string; width?: number; height?: number } => x !== null);\n } catch {\n return [];\n }\n}\n\nexport function extractMediaFiles(html: string, baseUrl: string): Array<{ url: string; type: 'image' | 'video' | 'audio' | 'document'; tag: string }> {\n const IMAGE_EXT = /\\.(png|jpe?g|gif|webp|svg|ico|avif)(\\?.*)?$/i;\n const VIDEO_EXT = /\\.(mp4|webm|ogv|mov|avi)(\\?.*)?$/i;\n const AUDIO_EXT = /\\.(mp3|ogg|wav|flac|aac)(\\?.*)?$/i;\n const DOC_EXT = /\\.(pdf|docx?|xlsx?|pptx?|csv|zip)(\\?.*)?$/i;\n\n function classify(url: string, tag: string): 'image' | 'video' | 'audio' | 'document' {\n if (tag === 'img' || IMAGE_EXT.test(url)) return 'image';\n if (tag === 'video' || VIDEO_EXT.test(url)) return 'video';\n if (tag === 'audio' || AUDIO_EXT.test(url)) return 'audio';\n if (DOC_EXT.test(url)) return 'document';\n return 'document';\n }\n\n try {\n const { document } = parseHTML(html);\n const base = new URL(baseUrl);\n const results: Array<{ url: string; type: 'image' | 'video' | 'audio' | 'document'; tag: string }> = [];\n const seen = new Set<string>();\n\n const selectors: [string, string] = ['img[src],video[src],audio[src],source[src]', 'src'];\n const attrPairs: Array<[string, string]> = [\n ['img', 'src'], ['video', 'src'], ['audio', 'src'], ['source', 'src'],\n ['a', 'href'],\n ];\n\n for (const [sel, attr] of attrPairs) {\n for (const el of [...(document as any).querySelectorAll(sel)]) {\n const raw = (el as any).getAttribute(attr) ?? '';\n if (!raw) continue;\n try {\n const resolved = new URL(raw, base).href;\n if (seen.has(resolved)) continue;\n if (sel === 'a' && !DOC_EXT.test(resolved) && !IMAGE_EXT.test(resolved) && !VIDEO_EXT.test(resolved) && !AUDIO_EXT.test(resolved)) continue;\n seen.add(resolved);\n results.push({ url: resolved, type: classify(resolved, sel), tag: sel });\n } catch { /* skip */ }\n }\n }\n return results;\n } catch {\n return [];\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -106,7 +106,7 @@ async function findModalTriggers(page) {
|
|
|
106
106
|
p = p.parentElement;
|
|
107
107
|
}
|
|
108
108
|
return true;
|
|
109
|
-
}).slice(0,
|
|
109
|
+
}).slice(0, 20).map((el) => {
|
|
110
110
|
const label = el.innerText?.trim() || el.getAttribute("aria-label") || "button";
|
|
111
111
|
return { selector: `[aria-label="${label}"]`, label: label.slice(0, 60) };
|
|
112
112
|
});
|
|
@@ -161,7 +161,20 @@ async function probeScreen(page, screenId, url, elements) {
|
|
|
161
161
|
await submitBtn.click({ timeout: 2e3 });
|
|
162
162
|
await page.waitForTimeout(1500);
|
|
163
163
|
const errors = await collectErrors(page);
|
|
164
|
-
|
|
164
|
+
const ariaFieldErrors = await page.evaluate(
|
|
165
|
+
() => Array.from(document.querySelectorAll('[aria-invalid="true"]')).map((el) => {
|
|
166
|
+
const describedBy = el.getAttribute("aria-describedby");
|
|
167
|
+
const errorEl = describedBy ? document.getElementById(describedBy) : null;
|
|
168
|
+
const label = el.getAttribute("aria-label") || el.getAttribute("placeholder") || el.name || "";
|
|
169
|
+
return { label, errorText: errorEl?.textContent?.trim() || "" };
|
|
170
|
+
}).filter((f) => f.errorText)
|
|
171
|
+
);
|
|
172
|
+
const ariaMatched = /* @__PURE__ */ new Set();
|
|
173
|
+
for (const af of ariaFieldErrors) {
|
|
174
|
+
ariaMatched.add(af.errorText);
|
|
175
|
+
result.validationErrors.push({ field: af.label || "unknown", trigger: "empty_submit", errorText: af.errorText.slice(0, 150) });
|
|
176
|
+
}
|
|
177
|
+
for (const errorText of errors.filter((e) => !ariaMatched.has(e))) {
|
|
165
178
|
const matchedField = formInputs.find(
|
|
166
179
|
(el) => errorText.toLowerCase().includes(el.meaning.toLowerCase().split(" ")[0]) || el.meaning.toLowerCase().includes(errorText.toLowerCase().split(" ")[0])
|
|
167
180
|
);
|
|
@@ -240,6 +253,28 @@ async function probeScreen(page, screenId, url, elements) {
|
|
|
240
253
|
content: dialogText.content,
|
|
241
254
|
fields: dialogText.fields.slice(0, 10)
|
|
242
255
|
});
|
|
256
|
+
} else {
|
|
257
|
+
const accordionText = await page.evaluate(() => {
|
|
258
|
+
const panels = Array.from(document.querySelectorAll(
|
|
259
|
+
'[aria-expanded="true"] + *, details[open] > :not(summary), [data-state="open"]:not([role="dialog"]):not([class*="modal" i]), [data-headlessui-state="open"] > *'
|
|
260
|
+
)).filter((el) => {
|
|
261
|
+
const s = window.getComputedStyle(el);
|
|
262
|
+
return s.display !== "none" && s.visibility !== "hidden";
|
|
263
|
+
});
|
|
264
|
+
if (panels.length === 0) return null;
|
|
265
|
+
const panel = panels[0];
|
|
266
|
+
const heading = panel.querySelector("h2,h3,h4")?.textContent?.replace(/\s+/g, " ").trim() || "";
|
|
267
|
+
const content = panel.innerText?.replace(/\s+/g, " ").trim().slice(0, 400) || "";
|
|
268
|
+
return content.length > 15 ? { heading, content } : null;
|
|
269
|
+
});
|
|
270
|
+
if (accordionText) {
|
|
271
|
+
result.modals.push({
|
|
272
|
+
triggerElement: trigger.label,
|
|
273
|
+
title: accordionText.heading || `${trigger.label} (revealed)`,
|
|
274
|
+
content: accordionText.content,
|
|
275
|
+
fields: []
|
|
276
|
+
});
|
|
277
|
+
}
|
|
243
278
|
}
|
|
244
279
|
await page.keyboard.press("Escape");
|
|
245
280
|
await page.waitForTimeout(400);
|
|
@@ -257,6 +292,34 @@ async function probeScreen(page, screenId, url, elements) {
|
|
|
257
292
|
result.conditionals.push(...conditionals);
|
|
258
293
|
} catch {
|
|
259
294
|
}
|
|
295
|
+
try {
|
|
296
|
+
result.tabPanels = await probeTabPanels(page);
|
|
297
|
+
} catch {
|
|
298
|
+
}
|
|
299
|
+
try {
|
|
300
|
+
result.tooltips = await probeTooltips(page);
|
|
301
|
+
} catch {
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
result.carouselSlides = await probeCarousels(page);
|
|
305
|
+
} catch {
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
result.wizardSteps = await probeWizardSteps(page);
|
|
309
|
+
} catch {
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
result.focusTraps = await detectFocusTraps(page);
|
|
313
|
+
} catch {
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
result.skipNavIssues = await probeSkipNavigation(page);
|
|
317
|
+
} catch {
|
|
318
|
+
}
|
|
319
|
+
try {
|
|
320
|
+
result.contextMenus = await probeContextMenus(page);
|
|
321
|
+
} catch {
|
|
322
|
+
}
|
|
260
323
|
return result;
|
|
261
324
|
}
|
|
262
325
|
function probeResultToObservations(result) {
|
|
@@ -276,6 +339,27 @@ function probeResultToObservations(result) {
|
|
|
276
339
|
for (const lp of result.loadingPatterns) {
|
|
277
340
|
obs.push({ elementMeaning: "(submit button)", observationType: "LOADING_STATE", observedText: lp, triggeredBy: "form_submit" });
|
|
278
341
|
}
|
|
342
|
+
for (const tp of result.tabPanels ?? []) {
|
|
343
|
+
obs.push({ elementMeaning: tp.tabLabel, observationType: "TAB_PANEL", observedText: tp.panelContent, triggeredBy: `tab_click:${tp.tabLabel}` });
|
|
344
|
+
}
|
|
345
|
+
for (const tt of result.tooltips ?? []) {
|
|
346
|
+
obs.push({ elementMeaning: tt.triggerLabel, observationType: "TOOLTIP", observedText: tt.tooltipText, triggeredBy: "hover_or_title" });
|
|
347
|
+
}
|
|
348
|
+
for (const cs of result.carouselSlides ?? []) {
|
|
349
|
+
obs.push({ elementMeaning: `Slide ${cs.slideIndex + 1}`, observationType: "CAROUSEL_SLIDE", observedText: cs.content, triggeredBy: `carousel_next:${cs.slideIndex}` });
|
|
350
|
+
}
|
|
351
|
+
for (const ws of result.wizardSteps ?? []) {
|
|
352
|
+
obs.push({ elementMeaning: ws.stepLabel ?? `Step ${ws.stepIndex + 1}`, observationType: "WIZARD_STEP", observedText: ws.content, triggeredBy: `wizard_next:${ws.stepIndex}` });
|
|
353
|
+
}
|
|
354
|
+
for (const ft of result.focusTraps ?? []) {
|
|
355
|
+
obs.push({ elementMeaning: ft.modalTrigger, observationType: "FOCUS_TRAP", observedText: ft.focusTrapped ? "Focus correctly trapped inside dialog" : "FAIL: focus escaped dialog \u2014 WCAG 2.1.2 violation", triggeredBy: "tab_key_sequence" });
|
|
356
|
+
}
|
|
357
|
+
for (const issue of result.skipNavIssues ?? []) {
|
|
358
|
+
obs.push({ elementMeaning: "(page)", observationType: "SKIP_NAV_ISSUE", observedText: issue, triggeredBy: "skip_nav_audit" });
|
|
359
|
+
}
|
|
360
|
+
for (const cm of result.contextMenus ?? []) {
|
|
361
|
+
obs.push({ elementMeaning: cm.trigger, observationType: "CONTEXT_MENU", observedText: cm.items.join(" | "), triggeredBy: "right_click" });
|
|
362
|
+
}
|
|
279
363
|
return obs;
|
|
280
364
|
}
|
|
281
365
|
function probeResultToMarkdown(result) {
|
|
@@ -302,6 +386,39 @@ function probeResultToMarkdown(result) {
|
|
|
302
386
|
if (m.fields.length > 0) lines.push(` - Fields: ${m.fields.join(", ")}`);
|
|
303
387
|
}
|
|
304
388
|
}
|
|
389
|
+
if ((result.tabPanels ?? []).length > 0) {
|
|
390
|
+
lines.push("\n### Tab Panel Contents (observed)");
|
|
391
|
+
for (const tp of result.tabPanels) {
|
|
392
|
+
lines.push(`- **Tab: ${tp.tabLabel}**${tp.panelHeading ? ` \u2014 ${tp.panelHeading}` : ""}: ${tp.panelContent.slice(0, 200)}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if ((result.tooltips ?? []).length > 0) {
|
|
396
|
+
lines.push("\n### Tooltips (observed)");
|
|
397
|
+
for (const tt of result.tooltips) {
|
|
398
|
+
lines.push(`- **${tt.triggerLabel}**: ${tt.tooltipText}`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
if ((result.carouselSlides ?? []).length > 0) {
|
|
402
|
+
lines.push("\n### Carousel Slides (observed)");
|
|
403
|
+
for (const cs of result.carouselSlides) {
|
|
404
|
+
lines.push(`- **Slide ${cs.slideIndex + 1}**${cs.heading ? ` \u2014 ${cs.heading}` : ""}: ${cs.content.slice(0, 200)}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if ((result.wizardSteps ?? []).length > 0) {
|
|
408
|
+
lines.push("\n### Wizard / Multi-Step Form (observed)");
|
|
409
|
+
for (const ws of result.wizardSteps) {
|
|
410
|
+
const label = ws.stepLabel ? ` \u2014 ${ws.stepLabel}` : "";
|
|
411
|
+
lines.push(`- **Step ${ws.stepIndex + 1}${label}**: ${ws.content.slice(0, 200)}`);
|
|
412
|
+
if (ws.fields.length > 0) lines.push(` - Fields: ${ws.fields.join(", ")}`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if ((result.focusTraps ?? []).length > 0) {
|
|
416
|
+
lines.push("\n### Focus-Trap Audit (observed)");
|
|
417
|
+
for (const ft of result.focusTraps) {
|
|
418
|
+
const verdict = ft.focusTrapped ? "\u2705 PASS" : "\u274C FAIL (WCAG 2.1.2)";
|
|
419
|
+
lines.push(`- **${ft.modalTrigger}**: ${verdict} \u2014 focused: [${ft.sampledFocused.slice(0, 4).join(", ")}]`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
305
422
|
if (result.conditionals.length > 0) {
|
|
306
423
|
lines.push("\n### Conditional UI (observed)");
|
|
307
424
|
for (const c of result.conditionals) {
|
|
@@ -383,6 +500,221 @@ ${interactionChunks.join("\n\n")}`;
|
|
|
383
500
|
await onProgress?.(98, "Interactive probe complete");
|
|
384
501
|
console.log(`[probe] Probed ${probeable.length} screens. Errors: ${errorCatalogueChunks.length}, Interactions: ${interactionChunks.length}`);
|
|
385
502
|
}
|
|
503
|
+
async function probeSkipNavigation(page) {
|
|
504
|
+
const issues = [];
|
|
505
|
+
try {
|
|
506
|
+
const skipLinks = await page.evaluate(
|
|
507
|
+
() => Array.from(document.querySelectorAll('a[href^="#"]')).filter((el) => /skip|jump to|main content|content|nav/i.test(el.innerText?.toLowerCase().trim() || "")).map((el) => ({
|
|
508
|
+
text: el.innerText?.trim() || "",
|
|
509
|
+
href: el.getAttribute("href") || "",
|
|
510
|
+
hasTarget: !!document.querySelector(el.getAttribute("href") || "#__invalid__")
|
|
511
|
+
}))
|
|
512
|
+
);
|
|
513
|
+
if (skipLinks.length === 0) {
|
|
514
|
+
issues.push("No skip navigation links found (WCAG 2.4.1 \u2014 keyboard users cannot bypass nav blocks)");
|
|
515
|
+
}
|
|
516
|
+
for (const link of skipLinks) {
|
|
517
|
+
if (!link.hasTarget) issues.push(`Skip link "${link.text}" \u2192 "${link.href}" target not found in DOM`);
|
|
518
|
+
}
|
|
519
|
+
} catch {
|
|
520
|
+
}
|
|
521
|
+
return issues;
|
|
522
|
+
}
|
|
523
|
+
async function probeContextMenus(page) {
|
|
524
|
+
const results = [];
|
|
525
|
+
try {
|
|
526
|
+
const targets = await page.$$('canvas, [data-contextmenu], [oncontextmenu], [class*="chart" i]:not(script)');
|
|
527
|
+
for (const t of targets.slice(0, 4)) {
|
|
528
|
+
try {
|
|
529
|
+
await t.click({ button: "right", timeout: 1e3 });
|
|
530
|
+
await page.waitForTimeout(350);
|
|
531
|
+
const items = await page.evaluate(
|
|
532
|
+
() => Array.from(document.querySelectorAll('[role="menu"] [role="menuitem"], .context-menu-item, [class*="contextmenu" i] li')).map((el) => el.innerText?.trim()).filter(Boolean).slice(0, 12)
|
|
533
|
+
);
|
|
534
|
+
if (items.length > 0) {
|
|
535
|
+
const trigger = await t.evaluate((el) => el.getAttribute("aria-label") || el.className.slice(0, 40) || el.tagName).catch(() => "");
|
|
536
|
+
results.push({ trigger, items });
|
|
537
|
+
}
|
|
538
|
+
await page.keyboard.press("Escape").catch(() => {
|
|
539
|
+
});
|
|
540
|
+
} catch {
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
} catch {
|
|
544
|
+
}
|
|
545
|
+
return results;
|
|
546
|
+
}
|
|
547
|
+
async function probeCarousels(page) {
|
|
548
|
+
const results = [];
|
|
549
|
+
try {
|
|
550
|
+
const CAROUSEL_SEL = '[class*="carousel" i], [class*="slider" i], [class*="swiper" i], [role="region"][aria-roledescription*="carousel" i]';
|
|
551
|
+
const hasCarousel = await page.evaluate((sel) => !!document.querySelector(sel), CAROUSEL_SEL);
|
|
552
|
+
if (!hasCarousel) return results;
|
|
553
|
+
const NEXT_SEL = '[aria-label*="next" i], [aria-label*="Next"], [class*="next" i]:not([class*="footnext"]):not(a), button[class*="arrow-right" i], button[class*="chevron-right" i]';
|
|
554
|
+
const slide0 = await page.evaluate((carSel) => {
|
|
555
|
+
const car = document.querySelector(carSel);
|
|
556
|
+
if (!car) return null;
|
|
557
|
+
const active = car.querySelector('[aria-hidden="false"], .active, [class*="active" i], [class*="current" i]');
|
|
558
|
+
const target = active ?? car;
|
|
559
|
+
return { heading: target.querySelector("h2,h3,h4")?.textContent?.trim() ?? "", content: target.innerText?.replace(/\s+/g, " ").trim().slice(0, 300) ?? "" };
|
|
560
|
+
}, CAROUSEL_SEL);
|
|
561
|
+
if (slide0?.content && slide0.content.length > 15) results.push({ slideIndex: 0, heading: slide0.heading || void 0, content: slide0.content });
|
|
562
|
+
const seen = new Set(slide0?.content ? [slide0.content] : []);
|
|
563
|
+
for (let i = 1; i < 8; i++) {
|
|
564
|
+
try {
|
|
565
|
+
const nextBtn = page.locator(NEXT_SEL).first();
|
|
566
|
+
const visible = await nextBtn.isVisible().catch(() => false);
|
|
567
|
+
if (!visible) break;
|
|
568
|
+
await nextBtn.click({ timeout: 1e3 });
|
|
569
|
+
await page.waitForTimeout(600);
|
|
570
|
+
const slide = await page.evaluate((carSel) => {
|
|
571
|
+
const car = document.querySelector(carSel);
|
|
572
|
+
if (!car) return null;
|
|
573
|
+
const active = car.querySelector('[aria-hidden="false"], .active, [class*="active" i], [class*="current" i]');
|
|
574
|
+
const target = active ?? car;
|
|
575
|
+
return { heading: target.querySelector("h2,h3,h4")?.textContent?.trim() ?? "", content: target.innerText?.replace(/\s+/g, " ").trim().slice(0, 300) ?? "" };
|
|
576
|
+
}, CAROUSEL_SEL);
|
|
577
|
+
if (!slide?.content || slide.content.length < 15 || seen.has(slide.content)) break;
|
|
578
|
+
seen.add(slide.content);
|
|
579
|
+
results.push({ slideIndex: i, heading: slide.heading || void 0, content: slide.content });
|
|
580
|
+
} catch {
|
|
581
|
+
break;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
} catch {
|
|
585
|
+
}
|
|
586
|
+
return results;
|
|
587
|
+
}
|
|
588
|
+
async function probeWizardSteps(page) {
|
|
589
|
+
const results = [];
|
|
590
|
+
try {
|
|
591
|
+
const NEXT_LABELS = /^(next|continue|proceed|step \d|›|→|forward)/i;
|
|
592
|
+
const BACK_LABELS = /^(back|previous|prev|‹|←)/i;
|
|
593
|
+
const WIZARD_INDICATORS = '[role="progressbar"], [class*="stepper" i], [class*="wizard" i], [class*="progress-step" i], [aria-label*="step" i]';
|
|
594
|
+
const isWizard = await page.evaluate((sel) => !!document.querySelector(sel), WIZARD_INDICATORS);
|
|
595
|
+
if (!isWizard) return results;
|
|
596
|
+
const captureStep = async (stepIndex) => {
|
|
597
|
+
const data = await page.evaluate(() => {
|
|
598
|
+
const content = document.querySelector('main, [role="main"], form')?.innerText?.replace(/\s+/g, " ").trim().slice(0, 400) ?? "";
|
|
599
|
+
const stepLabel = document.querySelector('[aria-current="step"], [class*="step-label" i], [class*="stepper-label" i]')?.textContent?.trim() ?? "";
|
|
600
|
+
const fields = Array.from(document.querySelectorAll('input:not([type="hidden"]), select, textarea')).map((el) => el.getAttribute("aria-label") || el.placeholder || el.name || "").filter(Boolean).slice(0, 10);
|
|
601
|
+
return { content, stepLabel, fields };
|
|
602
|
+
});
|
|
603
|
+
return { stepIndex, stepLabel: data.stepLabel || void 0, content: data.content, fields: data.fields };
|
|
604
|
+
};
|
|
605
|
+
results.push(await captureStep(0));
|
|
606
|
+
for (let i = 1; i < 6; i++) {
|
|
607
|
+
try {
|
|
608
|
+
const nextLabel = await page.evaluate((nextRe) => {
|
|
609
|
+
const btns = Array.from(document.querySelectorAll('button, [role="button"]'));
|
|
610
|
+
const btn2 = btns.find((b) => new RegExp(nextRe).test(b.innerText?.trim() || ""));
|
|
611
|
+
return btn2?.innerText?.trim() || null;
|
|
612
|
+
}, NEXT_LABELS.source);
|
|
613
|
+
if (!nextLabel) break;
|
|
614
|
+
const btn = page.locator(`button:has-text("${nextLabel}")`).first();
|
|
615
|
+
await btn.click({ timeout: 1500 });
|
|
616
|
+
await page.waitForTimeout(800);
|
|
617
|
+
results.push(await captureStep(i));
|
|
618
|
+
} catch {
|
|
619
|
+
break;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
} catch {
|
|
623
|
+
}
|
|
624
|
+
return results;
|
|
625
|
+
}
|
|
626
|
+
async function detectFocusTraps(page) {
|
|
627
|
+
const results = [];
|
|
628
|
+
try {
|
|
629
|
+
const openDialogs = await page.evaluate(() => {
|
|
630
|
+
return Array.from(document.querySelectorAll('[role="dialog"], [role="alertdialog"]')).filter((el) => window.getComputedStyle(el).display !== "none").map((el) => el.getAttribute("aria-label") || el.querySelector('[role="heading"]')?.textContent?.trim() || "dialog");
|
|
631
|
+
});
|
|
632
|
+
for (const dialogLabel of openDialogs.slice(0, 3)) {
|
|
633
|
+
const sampled = [];
|
|
634
|
+
let allInside = true;
|
|
635
|
+
for (let i = 0; i < 8; i++) {
|
|
636
|
+
await page.keyboard.press("Tab");
|
|
637
|
+
await page.waitForTimeout(80);
|
|
638
|
+
const focused = await page.evaluate(() => {
|
|
639
|
+
const el = document.activeElement;
|
|
640
|
+
const inDialog = !!el?.closest('[role="dialog"], [role="alertdialog"]');
|
|
641
|
+
return { label: el?.getAttribute("aria-label") || el?.textContent?.trim().slice(0, 40) || el?.tagName || "", inDialog };
|
|
642
|
+
});
|
|
643
|
+
sampled.push(focused.label);
|
|
644
|
+
if (!focused.inDialog) {
|
|
645
|
+
allInside = false;
|
|
646
|
+
break;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
results.push({ modalTrigger: dialogLabel, focusTrapped: allInside, sampledFocused: sampled });
|
|
650
|
+
}
|
|
651
|
+
} catch {
|
|
652
|
+
}
|
|
653
|
+
return results;
|
|
654
|
+
}
|
|
655
|
+
async function probeTabPanels(page) {
|
|
656
|
+
const results = [];
|
|
657
|
+
try {
|
|
658
|
+
const tabs = await page.$$('[role="tab"]:not([aria-disabled="true"])');
|
|
659
|
+
for (const tab of tabs.slice(0, 10)) {
|
|
660
|
+
try {
|
|
661
|
+
const tabLabel = await tab.evaluate((el) => el.innerText?.trim() || el.getAttribute("aria-label") || "");
|
|
662
|
+
if (!tabLabel) continue;
|
|
663
|
+
await tab.click({ timeout: 1e3 });
|
|
664
|
+
await page.waitForTimeout(400);
|
|
665
|
+
const panelContent = await page.evaluate(() => {
|
|
666
|
+
const panel = document.querySelector('[role="tabpanel"]:not([hidden])');
|
|
667
|
+
if (!panel) return null;
|
|
668
|
+
const style = window.getComputedStyle(panel);
|
|
669
|
+
if (style.display === "none" || style.visibility === "hidden") return null;
|
|
670
|
+
return {
|
|
671
|
+
heading: panel.querySelector("h2,h3,h4")?.textContent?.trim() ?? "",
|
|
672
|
+
content: panel.innerText?.replace(/\s+/g, " ").trim().slice(0, 400) ?? ""
|
|
673
|
+
};
|
|
674
|
+
});
|
|
675
|
+
if (panelContent?.content && panelContent.content.length > 15) {
|
|
676
|
+
results.push({ tabLabel: tabLabel.slice(0, 60), panelContent: panelContent.content, panelHeading: panelContent.heading || void 0 });
|
|
677
|
+
}
|
|
678
|
+
} catch {
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
} catch {
|
|
682
|
+
}
|
|
683
|
+
return results;
|
|
684
|
+
}
|
|
685
|
+
async function probeTooltips(page) {
|
|
686
|
+
const results = [];
|
|
687
|
+
try {
|
|
688
|
+
const candidates = await page.evaluate(() => {
|
|
689
|
+
return Array.from(document.querySelectorAll('[title], [aria-describedby], [data-tooltip], [data-tip], [class*="tooltip" i]')).filter((el) => {
|
|
690
|
+
const style = window.getComputedStyle(el);
|
|
691
|
+
return style.display !== "none" && style.visibility !== "hidden";
|
|
692
|
+
}).slice(0, 8).map((el) => ({
|
|
693
|
+
label: el.innerText?.trim().slice(0, 60) || el.getAttribute("aria-label") || "",
|
|
694
|
+
title: el.getAttribute("title") || "",
|
|
695
|
+
describedBy: el.getAttribute("aria-describedby") || "",
|
|
696
|
+
selector: el.tagName.toLowerCase() + (el.id ? `#${el.id}` : "")
|
|
697
|
+
}));
|
|
698
|
+
});
|
|
699
|
+
for (const c of candidates) {
|
|
700
|
+
try {
|
|
701
|
+
if (c.title && c.title.length > 5) {
|
|
702
|
+
results.push({ triggerLabel: c.label || c.selector, tooltipText: c.title.slice(0, 200) });
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
if (c.describedBy) {
|
|
706
|
+
const tooltipText = await page.evaluate((id) => document.getElementById(id)?.textContent?.trim() ?? "", c.describedBy);
|
|
707
|
+
if (tooltipText && tooltipText.length > 5) {
|
|
708
|
+
results.push({ triggerLabel: c.label || c.selector, tooltipText: tooltipText.slice(0, 200) });
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
} catch {
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
} catch {
|
|
715
|
+
}
|
|
716
|
+
return results;
|
|
717
|
+
}
|
|
386
718
|
async function probeKeyboardNav(page) {
|
|
387
719
|
const sequence = [];
|
|
388
720
|
const issues = [];
|