1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const USER_AGENT = 'AccessibilityStatementAuditor/0.1 (+https://apify.com)';
7const MAX_BODY_BYTES = 2_000_000;
8const MAX_REDIRECTS = 5;
9const MAX_LINKS_PER_PAGE = 200;
10
11
12const COMMON_STATEMENT_PATHS = [
13 '/accessibility',
14 '/accessibility-statement',
15 '/en/accessibility',
16 '/accessibility.html',
17];
18
19function isPrivateIPv4(ip) {
20 const parts = ip.split('.').map(Number);
21 if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return false;
22 const [a, b] = parts;
23 return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a === 127 || a === 0 || (a === 169 && b === 254);
24}
25
26function isPrivateIPv6(ip) {
27 const normalized = ip.toLowerCase();
28 return normalized === '::1' || normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe80:');
29}
30
31export async function normalizeAndValidateUrl(rawUrl) {
32 if (!rawUrl || typeof rawUrl !== 'string') throw new Error('startUrl is required');
33 if (/^[a-z][a-z0-9+.-]*:/i.test(rawUrl) && !/^https?:\/\//i.test(rawUrl)) throw new Error('Only HTTP and HTTPS URLs are supported');
34
35 const withScheme = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`;
36 const url = new URL(withScheme);
37 if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported');
38 if (!url.hostname || url.username || url.password) throw new Error('URL must be public and must not include credentials');
39
40 const literalType = net.isIP(url.hostname);
41 if (literalType === 4 && isPrivateIPv4(url.hostname)) throw new Error('Private IPv4 targets are blocked');
42 if (literalType === 6 && isPrivateIPv6(url.hostname)) throw new Error('Private IPv6 targets are blocked');
43
44 const records = literalType ? [{ address: url.hostname, family: literalType }] : await dns.lookup(url.hostname, { all: true });
45 for (const record of records) {
46 if (record.family === 4 && isPrivateIPv4(record.address)) throw new Error('DNS resolves to a private IPv4 address; blocked for SSRF safety');
47 if (record.family === 6 && isPrivateIPv6(record.address)) throw new Error('DNS resolves to a private IPv6 address; blocked for SSRF safety');
48 }
49 return url;
50}
51
52function clampInteger(value, fallback, min, max) {
53 const parsed = Number(value);
54 if (!Number.isFinite(parsed)) return fallback;
55 return Math.min(Math.max(Math.trunc(parsed), min), max);
56}
57
58function stripTags(value) {
59 return value.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
60}
61
62function decodeBasicEntities(value) {
63 return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, ' ');
64}
65
66function getAttr(tag, name) {
67 const match = tag.match(new RegExp(`\\s${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i'));
68 return match ? decodeBasicEntities((match[2] ?? match[3] ?? match[4] ?? '').trim()) : null;
69}
70
71function toText(html) {
72 let text = html.replace(/<script\b[\s\S]*?<\/script>/gi, ' ');
73 text = text.replace(/<style\b[\s\S]*?<\/style>/gi, ' ');
74 text = stripTags(text);
75 return decodeBasicEntities(text);
76}
77
78function isHtmlResponse(contentType, body) {
79 if (/text\/html/i.test(contentType || '')) return true;
80 if (/json|xml|pdf|image\//i.test(contentType || '')) return false;
81 return /^\s*<(?:!doctype|html)\b/i.test(body || '');
82}
83
84
85
86function looksLikeStatementText(lower) {
87 if (!lower.includes('accessib')) return false;
88 if (!(lower.includes('statement') || lower.includes('declaration') || lower.includes('conformance'))) return false;
89
90
91
92 return ['conform', 'wcag', 'committed', 'feedback', 'limitation', 'barrier', 'en 301', '2016/2102', 'w3c', 'assistive']
93 .some((word) => lower.includes(word));
94}
95
96function looksLikeStatementUrl(urlPath) {
97 const p = urlPath.toLowerCase();
98 return p.includes('accessib') && (p.includes('statement') || p.includes('declaration') || p.endsWith('/accessibility') || p.endsWith('/accessibility.html') || p.endsWith('accessibilita'));
99}
100
101export function isStatementCandidate({ url, status, isHtml, body }) {
102 if (status < 200 || status >= 400 || !isHtml) return false;
103 const lower = toText(body).slice(0, 20_000).toLowerCase();
104 if (!lower.includes('accessib')) return false;
105 if (looksLikeStatementUrl(url.pathname)) return true;
106 return looksLikeStatementText(lower);
107}
108
109
110
111export function analyzeStatement(html) {
112 const text = toText(html);
113 const lower = text.toLowerCase();
114
115 const hasCommitment = /(committed|committed to|dedicated to|continually improving).{0,80}(accessib)|(accessib.{0,120}(priority|important to us|part of our mission))/.test(lower)
116 || /(we are committed|committed to ensuring).{0,60}accessib/.test(lower);
117
118 const wcagVersionMatch = lower.match(/wcag\s*(?:guidelines?\b[^.]{0,80}?version\s*)?(\d\.\d)/) || text.match(/WCAG\s*(\d\.\d)/i);
119 const wcagVersion = wcagVersionMatch ? wcagVersionMatch[1] : null;
120 const levelMatch = lower.match(/level\s+(a{1,3})\b/) || lower.match(/\b(aa|aaa)\b(?=[^.]{0,40}conform)/);
121 const wcagLevel = levelMatch ? levelMatch[1].toUpperCase() : null;
122
123 const conformancePhrases = ['fully conformant', 'partially conformant', 'non-conformant', 'nonconformant', 'not conformant'];
124 let conformanceStatus = null;
125 for (const phrase of conformancePhrases) {
126 if (lower.includes(phrase)) { conformanceStatus = phrase; break; }
127 }
128 if (!conformanceStatus && /(conforms?|conformant|conformance)\s+(with|to)\s+.{0,60}wcag/.test(lower)) conformanceStatus = 'conforms with WCAG';
129 const hasConformanceStatus = Boolean(conformanceStatus) || /(conformance status|level of conformance)/.test(lower);
130
131 const emailMatch = text.match(/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/i);
132 const contactEmail = emailMatch ? emailMatch[0].toLowerCase() : null;
133 const hasFeedbackContact = Boolean(contactEmail)
134 || /(feedback|contact us|let us know|please contact|reach us|e-mail|email|phone|telephone|\+\d{1,3}[\s-]?\d)/.test(lower);
135
136 const hasStatementDate = /\b(\d{1,2}\s+(?:january|february|march|april|may|june|july|august|september|october|november|december)\s+\d{4})\b/i.test(text)
137 || /\b((?:january|february|march|april|may|june|july|august|september|october|november|december)\s+\d{1,2},?\s+\d{4})\b/i.test(text)
138 || /\b(\d{4}-\d{2}-\d{2})\b/.test(text)
139 || /\b(\d{1,2}\/\d{1,2}\/\d{4})\b/.test(text);
140 const dateMatch = text.match(/\b(\d{1,2}\s+(?:january|february|march|april|may|june|july|august|september|october|november|december)\s+\d{4})\b/i)
141 || text.match(/\b((?:january|february|march|april|may|june|july|august|september|october|november|december)\s+\d{1,2},?\s+\d{4})\b/i)
142 || text.match(/\b(\d{4}-\d{2}-\d{2})\b/)
143 || text.match(/\b(\d{1,2}\/\d{1,2}\/\d{4})\b/);
144 const statementDate = dateMatch ? dateMatch[1] : null;
145
146 const hasLimitations = /(known limitations?|limitations and alternatives|there may be some limitations|not fully conform|content that is not accessible|non-accessible content)/.test(lower);
147 const hasMeasures = /(measures to support accessibility|measures to ensure accessibility|organizational measures|we take the following measures|accessibility is part of)/.test(lower);
148 const hasAssessmentApproach = /(assessment approach|assessed the accessibility|self-evaluation|self-assessment|external evaluation|evaluation approach)/.test(lower);
149 const hasEvaluationReport = /(evaluation report|audit report|assessment report)/.test(lower);
150 const hasFormalApproval = /(formally approved|formal approval|approved by|this statement (?:was )?(?:is )?approved)/.test(lower);
151 const hasComplaintsInfo = /(formal complaints?|lodge a complaint|file a complaint|escalate a complaint|enforcement|supervisory authority|national authority|feedback procedure|complaints? procedure)/.test(lower);
152
153 const legalReferences = [];
154 if (/en\s*301\s*549/i.test(text)) legalReferences.push('EN 301 549');
155 if (/2016\/?2102|web accessibility directive/i.test(text)) legalReferences.push('EU Web Accessibility Directive 2016/2102');
156 if (/(european accessibility act|2102\/2019|2019\/2102|\beaa\b)/i.test(text)) legalReferences.push('European Accessibility Act');
157 if (/section\s*508|\b508\b/i.test(text)) legalReferences.push('Section 508');
158 if (/\bbfsg\b|barrierefreiheitsstärkungsgesetz/i.test(text)) legalReferences.push('BFSG');
159 if (/wcag\s*\d/i.test(text)) legalReferences.push(`WCAG${wcagVersion ? ` ${wcagVersion}` : ''}`);
160 const hasLegalReference = legalReferences.length > 0;
161
162 return {
163 hasCommitment,
164 wcagVersion,
165 wcagLevel,
166 hasConformanceStatus,
167 conformanceStatus,
168 hasFeedbackContact,
169 contactEmail,
170 hasStatementDate,
171 statementDate,
172 hasLimitations,
173 hasMeasures,
174 hasAssessmentApproach,
175 hasEvaluationReport,
176 hasFormalApproval,
177 hasComplaintsInfo,
178 hasLegalReference,
179 legalReferences,
180 };
181}
182
183export function scoreStatement(analysis) {
184 const issues = [];
185 const recommendations = [];
186 let score = 100;
187
188
189 if (!analysis.hasCommitment) { score -= 15; issues.push('No accessibility commitment statement found'); recommendations.push('State a clear commitment to accessibility for people with disabilities.'); }
190 if (!analysis.hasConformanceStatus) { score -= 20; issues.push('No conformance status declared'); recommendations.push('Declare conformance status against a specific standard, for example WCAG 2.2 level AA.'); }
191 if (!analysis.hasFeedbackContact) { score -= 20; issues.push('No accessibility feedback contact found'); recommendations.push('Provide a contact channel (email, phone, or postal address) for accessibility feedback.'); }
192 if (!analysis.hasStatementDate) { score -= 15; issues.push('No statement date found'); recommendations.push('Include the date the statement was created or last reviewed.'); }
193 if (!analysis.wcagVersion) { score -= 10; issues.push('No specific accessibility standard version referenced'); recommendations.push('Reference a specific standard version, for example WCAG 2.2.'); }
194
195
196 if (!analysis.hasLimitations) { score -= 5; issues.push('No known limitations or non-accessible content section'); recommendations.push('List known limitations and alternatives for users.'); }
197 if (!analysis.hasMeasures) { score -= 5; issues.push('No organizational measures described'); recommendations.push('Describe measures taken to ensure accessibility.'); }
198 if (!analysis.hasAssessmentApproach) { score -= 5; issues.push('No assessment approach described'); recommendations.push('Describe how accessibility was assessed (self-evaluation or external audit).'); }
199 if (!analysis.hasComplaintsInfo) { score -= 5; issues.push('No complaint or escalation information'); recommendations.push('Explain how users can escalate unresolved accessibility complaints.'); }
200
201 const bounded = Math.max(0, score);
202 const grade = bounded >= 90 ? 'A' : bounded >= 75 ? 'B' : bounded >= 60 ? 'C' : bounded >= 45 ? 'D' : 'F';
203 return { score: bounded, grade, issues, recommendations };
204}
205
206
207
208async function fetchOnce(url, timeoutSeconds) {
209 const controller = new AbortController();
210 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
211 try {
212 const response = await fetch(url, { redirect: 'manual', signal: controller.signal, headers: { 'user-agent': USER_AGENT, accept: 'text/html,application/xhtml+xml,*/*;q=0.1' } });
213 const contentType = response.headers.get('content-type') || '';
214 const body = (await response.text()).slice(0, MAX_BODY_BYTES);
215 return { status: response.status, contentType, body, location: response.headers.get('location') };
216 } finally {
217 clearTimeout(timeout);
218 }
219}
220
221async function fetchPage(url, timeoutSeconds, redirectsLeft = MAX_REDIRECTS) {
222 await normalizeAndValidateUrl(url.href);
223 const response = await fetchOnce(url, timeoutSeconds);
224 if (response.location && response.status >= 300 && response.status < 400 && redirectsLeft > 0) {
225 const next = new URL(response.location, url.href);
226 return fetchPage(next, timeoutSeconds, redirectsLeft - 1);
227 }
228 const isHtml = isHtmlResponse(response.contentType, response.body);
229 return { status: response.status, finalUrl: url.href, contentType: response.contentType, isHtml, body: response.body };
230}
231
232function extractSameSiteLinks(base, html) {
233 const links = [];
234 for (const match of html.matchAll(/<a\b[^>]*>/gi)) {
235 const href = getAttr(match[0], 'href');
236 if (!href || href.startsWith('#') || /^(mailto:|tel:|javascript:)/i.test(href)) continue;
237 try {
238 const resolved = new URL(href, base.href);
239 if (resolved.hostname !== base.hostname) continue;
240 if (!['http:', 'https:'].includes(resolved.protocol)) continue;
241 links.push(resolved);
242 } catch { }
243 if (links.length >= MAX_LINKS_PER_PAGE) break;
244 }
245 return links;
246}
247
248
249
250export async function auditAccessibilityStatement(input) {
251 const startUrl = await normalizeAndValidateUrl(input.startUrl);
252 const timeoutSeconds = clampInteger(input.timeoutSeconds, 15, 3, 30);
253 const maxCandidates = clampInteger(input.maxCandidates, 6, 1, 10);
254 const checkedAt = new Date().toISOString();
255 let error = null;
256
257 const candidates = [];
258 try {
259 const page = await fetchPage(startUrl, timeoutSeconds);
260
261 if (page.isHtml) {
262 const links = extractSameSiteLinks(startUrl, page.body);
263 for (const link of links) {
264 if (looksLikeStatementUrl(link.pathname)) candidates.push(link);
265 }
266
267 candidates.unshift(startUrl);
268 } else {
269 candidates.push(startUrl);
270 }
271
272 const origin = new URL(startUrl.href);
273 for (const path of COMMON_STATEMENT_PATHS) {
274 candidates.push(new URL(path, `${origin.protocol}//${origin.hostname}`));
275 }
276
277 const seen = new Set();
278 const unique = candidates.filter((c) => {
279 const key = c.href.replace(/\/$/, '');
280 if (seen.has(key)) return false;
281 seen.add(key);
282 return true;
283 });
284
285 const fetchedCandidates = [];
286 let best = null;
287 for (const candidate of unique) {
288 if (fetchedCandidates.length >= maxCandidates) break;
289 let info = { url: candidate.href, status: null, isHtml: false, statementCandidate: false, score: -1 };
290 try {
291 const fetched = await fetchPage(candidate, timeoutSeconds);
292 const isCandidate = isStatementCandidate({ url: new URL(fetched.finalUrl), status: fetched.status, isHtml: fetched.isHtml, body: fetched.body });
293 let analysis = null;
294 let scored = null;
295 if (isCandidate) {
296 analysis = analyzeStatement(fetched.body);
297 scored = scoreStatement(analysis);
298 }
299 info = { url: candidate.href, status: fetched.status, isHtml: fetched.isHtml, statementCandidate: isCandidate, score: scored ? scored.score : -1 };
300 if (isCandidate && (!best || scored.score > best.score)) {
301 best = { url: candidate.href, status: fetched.status, analysis, scored };
302 }
303 } catch (caught) {
304 info = { ...info, status: null, statementCandidate: false, score: -1, error: caught.message };
305 }
306 fetchedCandidates.push(info);
307 }
308
309 const result = {
310 inputUrl: input.startUrl,
311 normalizedInputUrl: startUrl.href,
312 checkedAt,
313 statementFound: Boolean(best),
314 statementUrl: best ? best.url : null,
315 statementStatus: best ? best.status : null,
316 candidateCount: fetchedCandidates.length,
317 candidates: fetchedCandidates,
318 ...({ ...emptyAnalysis(), ...(best ? best.analysis : {}) }),
319 score: best ? best.scored.score : 0,
320 grade: best ? best.scored.grade : 'F',
321 issues: best ? best.scored.issues : ['No accessibility statement found on the site or common statement paths'],
322 recommendations: best ? best.scored.recommendations : ['Publish an accessibility statement and link it from the site footer or contact page.'],
323 error,
324 };
325 return result;
326 } catch (caught) {
327 error = caught.message;
328 return {
329 inputUrl: input.startUrl,
330 normalizedInputUrl: startUrl.href,
331 checkedAt,
332 statementFound: false,
333 statementUrl: null,
334 statementStatus: null,
335 candidateCount: 0,
336 candidates: [],
337 ...emptyAnalysis(),
338 score: 0,
339 grade: 'F',
340 issues: [error],
341 recommendations: ['Verify the URL is public, reachable, and returns HTML.'],
342 error,
343 };
344 }
345}
346
347function emptyAnalysis() {
348 return {
349 hasCommitment: false,
350 wcagVersion: null,
351 wcagLevel: null,
352 hasConformanceStatus: false,
353 conformanceStatus: null,
354 hasFeedbackContact: false,
355 contactEmail: null,
356 hasStatementDate: false,
357 statementDate: null,
358 hasLimitations: false,
359 hasMeasures: false,
360 hasAssessmentApproach: false,
361 hasEvaluationReport: false,
362 hasFormalApproval: false,
363 hasComplaintsInfo: false,
364 hasLegalReference: false,
365 legalReferences: [],
366 };
367}
368
369const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
370
371if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
372 await Actor.init();
373 try {
374 const input = await Actor.getInput();
375 const result = await auditAccessibilityStatement(input || {});
376 await Actor.pushData(result);
377 await Actor.setValue('OUTPUT', result);
378 Actor.log.info('Accessibility statement audit complete', { statementUrl: result.statementUrl, score: result.score, grade: result.grade });
379 } finally {
380 await Actor.exit();
381 }
382}