1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const USER_AGENT = 'AriaLandmarkAuditor/0.1 (+https://apify.com)';
7const MAX_BODY_BYTES = 2_000_000;
8const LANDMARK_ROLES = new Set(['banner', 'complementary', 'contentinfo', 'form', 'main', 'navigation', 'region', 'search']);
9const IMPLICIT_ROLES = { main: 'main', nav: 'navigation', aside: 'complementary', header: 'banner', footer: 'contentinfo' };
10const REPEATED_ROLES_NEED_LABELS = new Set(['navigation', 'complementary', 'form', 'region', 'search']);
11
12function isPrivateIPv4(ip) {
13 const parts = ip.split('.').map(Number);
14 if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return false;
15 const [a, b] = parts;
16 return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a === 127 || a === 0 || (a === 169 && b === 254);
17}
18
19function isPrivateIPv6(ip) {
20 const normalized = ip.toLowerCase();
21 return normalized === '::1' || normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe80:');
22}
23
24export async function normalizeAndValidateUrl(rawUrl) {
25 if (!rawUrl || typeof rawUrl !== 'string') throw new Error('startUrl is required');
26 if (/^[a-z][a-z0-9+.-]*:/i.test(rawUrl) && !/^https?:\/\//i.test(rawUrl)) throw new Error('Only HTTP and HTTPS URLs are supported');
27
28 const withScheme = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`;
29 const url = new URL(withScheme);
30 if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported');
31 if (!url.hostname || url.username || url.password) throw new Error('URL must be public and must not include credentials');
32
33 const literalType = net.isIP(url.hostname);
34 if (literalType === 4 && isPrivateIPv4(url.hostname)) throw new Error('Private IPv4 targets are blocked');
35 if (literalType === 6 && isPrivateIPv6(url.hostname)) throw new Error('Private IPv6 targets are blocked');
36
37 const records = literalType ? [{ address: url.hostname, family: literalType }] : await dns.lookup(url.hostname, { all: true });
38 for (const record of records) {
39 if (record.family === 4 && isPrivateIPv4(record.address)) throw new Error('DNS resolves to a private IPv4 address; blocked for SSRF safety');
40 if (record.family === 6 && isPrivateIPv6(record.address)) throw new Error('DNS resolves to a private IPv6 address; blocked for SSRF safety');
41 }
42 return url;
43}
44
45function clampInteger(value, fallback, min, max) {
46 const parsed = Number(value);
47 if (!Number.isFinite(parsed)) return fallback;
48 return Math.min(Math.max(Math.trunc(parsed), min), max);
49}
50
51function getAttr(tag, name) {
52 const match = tag.match(new RegExp(`\\s${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i'));
53 return match ? (match[2] ?? match[3] ?? match[4] ?? '').trim() : null;
54}
55
56function hasAttr(tag, name) {
57 return new RegExp(`\\s${name}(\\s|=|>|/)`, 'i').test(tag);
58}
59
60export function parseLandmarks(html, maxLandmarks = 100) {
61 const landmarks = [];
62 for (const match of html.matchAll(/<([a-z][a-z0-9-]*)\b[^>]*>/gi)) {
63 const tag = match[0];
64 const tagName = match[1].toLowerCase();
65 if (tag.startsWith('</') || hasAttr(tag, 'hidden') || getAttr(tag, 'aria-hidden') === 'true') continue;
66
67 const explicitRole = (getAttr(tag, 'role') || '').toLowerCase().split(/\s+/).find((role) => LANDMARK_ROLES.has(role));
68 const role = explicitRole || IMPLICIT_ROLES[tagName];
69 if (!role) continue;
70
71 const label = getAttr(tag, 'aria-label') || getAttr(tag, 'aria-labelledby') || null;
72 landmarks.push({
73 tag: tagName,
74 role,
75 explicitRole: explicitRole || null,
76 label,
77 id: getAttr(tag, 'id') || null,
78 className: getAttr(tag, 'class') || null,
79 });
80 }
81
82 const counts = Object.fromEntries([...LANDMARK_ROLES].map((role) => [role, landmarks.filter((item) => item.role === role).length]));
83 const unlabeledRepeated = landmarks.filter((item) => REPEATED_ROLES_NEED_LABELS.has(item.role) && counts[item.role] > 1 && !item.label);
84 return {
85 landmarkCount: landmarks.length,
86 mainCount: counts.main,
87 navigationCount: counts.navigation,
88 bannerCount: counts.banner,
89 contentinfoCount: counts.contentinfo,
90 complementaryCount: counts.complementary,
91 searchCount: counts.search,
92 formLandmarkCount: counts.form,
93 regionCount: counts.region,
94 unlabeledRepeatedCount: unlabeledRepeated.length,
95 landmarks: landmarks.slice(0, maxLandmarks),
96 unlabeledRepeatedLandmarks: unlabeledRepeated.slice(0, maxLandmarks),
97 };
98}
99
100function scoreLandmarks({ parsed, status, error }) {
101 const issues = [];
102 const recommendations = [];
103 let score = 100;
104
105 if (error) return { score: 0, grade: 'F', issues: [error], recommendations: ['Verify the URL is public, reachable, and returns HTML.'] };
106 if (!status || status >= 400) {
107 score -= 40;
108 issues.push(`HTTP status is ${status || 'unknown'}`);
109 recommendations.push('Audit a live 2xx HTML page.');
110 }
111 if (parsed.landmarkCount === 0) {
112 score -= 40;
113 issues.push('No semantic or ARIA landmarks found');
114 recommendations.push('Add semantic landmarks such as main, nav, header, footer, and aside.');
115 }
116 if (parsed.mainCount === 0) {
117 score -= 25;
118 issues.push('No main landmark found');
119 recommendations.push('Add one main element or role="main" landmark for primary content.');
120 } else if (parsed.mainCount > 1) {
121 score -= 25;
122 issues.push(`${parsed.mainCount} main landmarks found`);
123 recommendations.push('Keep exactly one main landmark on the page.');
124 }
125 if (parsed.unlabeledRepeatedCount > 0) {
126 score -= Math.min(30, parsed.unlabeledRepeatedCount * 10);
127 issues.push(`${parsed.unlabeledRepeatedCount} repeated landmark(s) lack accessible labels`);
128 recommendations.push('Add aria-label or aria-labelledby to repeated navigation, search, form, complementary, or region landmarks.');
129 }
130 if (parsed.bannerCount > 1) {
131 score -= 10;
132 issues.push(`${parsed.bannerCount} banner landmarks found`);
133 recommendations.push('Use one top-level header/banner landmark unless nested context requires otherwise.');
134 }
135 if (parsed.contentinfoCount > 1) {
136 score -= 10;
137 issues.push(`${parsed.contentinfoCount} contentinfo landmarks found`);
138 recommendations.push('Use one top-level footer/contentinfo landmark unless nested context requires otherwise.');
139 }
140
141 const bounded = Math.max(0, score);
142 const grade = bounded >= 90 ? 'A' : bounded >= 75 ? 'B' : bounded >= 60 ? 'C' : bounded >= 45 ? 'D' : 'F';
143 return { score: bounded, grade, issues: [...new Set(issues)], recommendations: [...new Set(recommendations)] };
144}
145
146async function fetchHtml(url, timeoutSeconds) {
147 await normalizeAndValidateUrl(url.href);
148 const controller = new AbortController();
149 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
150 try {
151 const response = await fetch(url, { redirect: 'manual', signal: controller.signal, headers: { 'user-agent': USER_AGENT, accept: 'text/html,*/*;q=0.1' } });
152 const location = response.headers.get('location');
153 if (location && response.status >= 300 && response.status < 400) {
154 const next = new URL(location, url.href);
155 await normalizeAndValidateUrl(next.href);
156 return fetchHtml(next, timeoutSeconds);
157 }
158 return { status: response.status, finalUrl: url.href, contentType: response.headers.get('content-type') || '', body: (await response.text()).slice(0, MAX_BODY_BYTES) };
159 } finally {
160 clearTimeout(timeout);
161 }
162}
163
164export async function auditAriaLandmarks(input) {
165 const startUrl = await normalizeAndValidateUrl(input.startUrl);
166 const timeoutSeconds = clampInteger(input.timeoutSeconds, 10, 3, 30);
167 const maxLandmarks = clampInteger(input.maxLandmarks, 100, 1, 300);
168 const checkedAt = new Date().toISOString();
169 let fetched = null;
170 let error = null;
171
172 try { fetched = await fetchHtml(startUrl, timeoutSeconds); } catch (caught) { error = caught.message; }
173
174 const parsed = parseLandmarks(fetched?.body || '', maxLandmarks);
175 const scored = scoreLandmarks({ parsed, status: fetched?.status, error });
176 return {
177 inputUrl: input.startUrl,
178 normalizedInputUrl: startUrl.href,
179 finalUrl: fetched?.finalUrl || startUrl.href,
180 status: fetched?.status || null,
181 ok: !error && fetched?.status >= 200 && fetched?.status < 400 && scored.score >= 90,
182 checkedAt,
183 ...parsed,
184 score: scored.score,
185 grade: scored.grade,
186 issues: scored.issues,
187 recommendations: scored.recommendations,
188 error,
189 };
190}
191
192const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
193
194if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
195 await Actor.init();
196 try {
197 const input = await Actor.getInput();
198 const result = await auditAriaLandmarks(input || {});
199 await Actor.pushData(result);
200 await Actor.setValue('OUTPUT', result);
201 Actor.log.info('ARIA landmark audit complete', { finalUrl: result.finalUrl, score: result.score, grade: result.grade });
202 } finally {
203 await Actor.exit();
204 }
205}