1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const USER_AGENT = 'SkipLinkAuditor/0.1 (+https://apify.com)';
7const MAX_BODY_BYTES = 2_000_000;
8
9function isPrivateIPv4(ip) {
10 const parts = ip.split('.').map(Number);
11 if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return false;
12 const [a, b] = parts;
13 return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a === 127 || a === 0 || (a === 169 && b === 254);
14}
15
16function isPrivateIPv6(ip) {
17 const normalized = ip.toLowerCase();
18 return normalized === '::1' || normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe80:');
19}
20
21export async function normalizeAndValidateUrl(rawUrl) {
22 if (!rawUrl || typeof rawUrl !== 'string') throw new Error('startUrl is required');
23 if (/^[a-z][a-z0-9+.-]*:/i.test(rawUrl) && !/^https?:\/\//i.test(rawUrl)) throw new Error('Only HTTP and HTTPS URLs are supported');
24
25 const withScheme = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`;
26 const url = new URL(withScheme);
27 if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported');
28 if (!url.hostname || url.username || url.password) throw new Error('URL must be public and must not include credentials');
29
30 const literalType = net.isIP(url.hostname);
31 if (literalType === 4 && isPrivateIPv4(url.hostname)) throw new Error('Private IPv4 targets are blocked');
32 if (literalType === 6 && isPrivateIPv6(url.hostname)) throw new Error('Private IPv6 targets are blocked');
33
34 const records = literalType ? [{ address: url.hostname, family: literalType }] : await dns.lookup(url.hostname, { all: true });
35 for (const record of records) {
36 if (record.family === 4 && isPrivateIPv4(record.address)) throw new Error('DNS resolves to a private IPv4 address; blocked for SSRF safety');
37 if (record.family === 6 && isPrivateIPv6(record.address)) throw new Error('DNS resolves to a private IPv6 address; blocked for SSRF safety');
38 }
39 return url;
40}
41
42function clampInteger(value, fallback, min, max) {
43 const parsed = Number(value);
44 if (!Number.isFinite(parsed)) return fallback;
45 return Math.min(Math.max(Math.trunc(parsed), min), max);
46}
47
48function stripTags(value) {
49 return value.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
50}
51
52function decodeBasicEntities(value) {
53 return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'");
54}
55
56function getAttr(tag, name) {
57 const match = tag.match(new RegExp(`\\s${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i'));
58 return match ? decodeBasicEntities((match[2] ?? match[3] ?? match[4] ?? '').trim()) : null;
59}
60
61function collectTargetIds(html) {
62 const ids = new Set();
63 for (const match of html.matchAll(/<([a-z][a-z0-9:-]*)\b[^>]*>/gi)) {
64 const id = getAttr(match[0], 'id');
65 if (id) ids.add(id);
66 }
67 return ids;
68}
69
70export function parseSkipLinks(html, maxLinks = 20) {
71 const targetIds = collectTargetIds(html);
72 const links = [];
73 for (const match of html.matchAll(/<a\b[^>]*>[\s\S]*?<\/a>/gi)) {
74 const tag = match[0].match(/<a\b[^>]*>/i)?.[0] || '';
75 const href = getAttr(tag, 'href') || '';
76 const text = decodeBasicEntities(stripTags(match[0]));
77 const normalized = text.toLowerCase();
78 const isFragment = href.startsWith('#') && href.length > 1;
79 const looksLikeSkip = isFragment && /skip|main|content/.test(normalized);
80 if (!looksLikeSkip) continue;
81 const targetId = href.slice(1);
82 links.push({
83 text,
84 href,
85 targetId,
86 targetExists: targetIds.has(targetId),
87 appearsEarly: match.index < 2000,
88 });
89 if (links.length >= maxLinks) break;
90 }
91 return { skipLinkCount: links.length, links };
92}
93
94function scoreSkipLinks({ parsed, status, error }) {
95 const issues = [];
96 const recommendations = [];
97 let score = 100;
98
99 if (error) return { score: 0, grade: 'F', issues: [error], recommendations: ['Verify the URL is public, reachable, and returns HTML.'] };
100 if (!status || status >= 400) {
101 score -= 40;
102 issues.push(`HTTP status is ${status || 'unknown'}`);
103 recommendations.push('Audit a live 2xx HTML page.');
104 }
105 if (parsed.skipLinkCount === 0) {
106 score -= 60;
107 issues.push('No skip link to main content found');
108 recommendations.push('Add an early keyboard-accessible link such as <a href="#main">Skip to main content</a>.');
109 }
110 const broken = parsed.links.filter((link) => !link.targetExists);
111 if (broken.length) {
112 score -= 30;
113 issues.push(`${broken.length} skip link target missing`);
114 recommendations.push('Ensure each skip link href points to an existing element id.');
115 }
116 if (parsed.links.length && !parsed.links.some((link) => link.appearsEarly)) {
117 score -= 20;
118 issues.push('Skip link appears late in the document');
119 recommendations.push('Place the skip link near the start of the body before repeated navigation.');
120 }
121
122 const bounded = Math.max(0, score);
123 const grade = bounded >= 90 ? 'A' : bounded >= 75 ? 'B' : bounded >= 60 ? 'C' : bounded >= 45 ? 'D' : 'F';
124 return { score: bounded, grade, issues: [...new Set(issues)], recommendations: [...new Set(recommendations)] };
125}
126
127async function fetchHtml(url, timeoutSeconds) {
128 await normalizeAndValidateUrl(url.href);
129 const controller = new AbortController();
130 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
131 try {
132 const response = await fetch(url, { redirect: 'manual', signal: controller.signal, headers: { 'user-agent': USER_AGENT, accept: 'text/html,*/*;q=0.1' } });
133 const location = response.headers.get('location');
134 if (location && response.status >= 300 && response.status < 400) {
135 const next = new URL(location, url.href);
136 await normalizeAndValidateUrl(next.href);
137 return fetchHtml(next, timeoutSeconds);
138 }
139 return { status: response.status, finalUrl: url.href, contentType: response.headers.get('content-type') || '', body: (await response.text()).slice(0, MAX_BODY_BYTES) };
140 } finally {
141 clearTimeout(timeout);
142 }
143}
144
145export async function auditSkipLinks(input) {
146 const startUrl = await normalizeAndValidateUrl(input.startUrl);
147 const timeoutSeconds = clampInteger(input.timeoutSeconds, 10, 3, 30);
148 const maxLinks = clampInteger(input.maxLinks, 20, 1, 100);
149 const checkedAt = new Date().toISOString();
150 let fetched = null;
151 let error = null;
152
153 try { fetched = await fetchHtml(startUrl, timeoutSeconds); } catch (caught) { error = caught.message; }
154
155 const parsed = parseSkipLinks(fetched?.body || '', maxLinks);
156 const scored = scoreSkipLinks({ parsed, status: fetched?.status, error });
157 return {
158 inputUrl: input.startUrl,
159 normalizedInputUrl: startUrl.href,
160 finalUrl: fetched?.finalUrl || startUrl.href,
161 status: fetched?.status || null,
162 ok: !error && fetched?.status >= 200 && fetched?.status < 400,
163 checkedAt,
164 skipLinkCount: parsed.skipLinkCount,
165 links: parsed.links,
166 hasWorkingSkipLink: parsed.links.some((link) => link.targetExists),
167 hasEarlySkipLink: parsed.links.some((link) => link.appearsEarly),
168 score: scored.score,
169 grade: scored.grade,
170 issues: scored.issues,
171 recommendations: scored.recommendations,
172 error,
173 };
174}
175
176const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
177
178if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
179 await Actor.init();
180 try {
181 const input = await Actor.getInput();
182 const result = await auditSkipLinks(input || {});
183 await Actor.pushData(result);
184 await Actor.setValue('OUTPUT', result);
185 Actor.log.info('Skip link audit complete', { finalUrl: result.finalUrl, score: result.score, grade: result.grade });
186 } finally {
187 await Actor.exit();
188 }
189}