1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const USER_AGENT = 'ButtonAccessibleNameAuditor/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 idTextMap(html) {
62 const map = new Map();
63 for (const match of html.matchAll(/<([a-z][a-z0-9:-]*)\b[^>]*\sid\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))[^>]*>([\s\S]*?)<\/\1>/gi)) {
64 const id = decodeBasicEntities((match[3] ?? match[4] ?? match[5] ?? '').trim());
65 const text = decodeBasicEntities(stripTags(match[6] || ''));
66 if (id && text) map.set(id, text);
67 }
68 return map;
69}
70
71function firstNonEmpty(...values) {
72 return values.map((value) => (value || '').trim()).find(Boolean) || '';
73}
74
75export function parseButtons(html, maxButtons = 50) {
76 const labelledBy = idTextMap(html);
77 const buttons = [];
78 const patterns = [
79 /<button\b[^>]*>[\s\S]*?<\/button>/gi,
80 /<input\b[^>]*\btype\s*=\s*("(?:button|submit|reset|image)"|'(?:button|submit|reset|image)'|(?:button|submit|reset|image))[^>]*>/gi,
81 /<[a-z][a-z0-9:-]*\b[^>]*\brole\s*=\s*("button"|'button'|button)[^>]*>[\s\S]*?<\/[a-z][a-z0-9:-]*>/gi,
82 ];
83
84 for (const pattern of patterns) {
85 for (const match of html.matchAll(pattern)) {
86 const element = match[0];
87 const startTag = element.match(/^<[^>]+>/)?.[0] || '';
88 const tag = (startTag.match(/^<([a-z][a-z0-9:-]*)/i)?.[1] || '').toLowerCase();
89 const type = (getAttr(startTag, 'type') || (tag === 'button' ? 'button' : null))?.toLowerCase() || null;
90 const ariaLabelledby = getAttr(startTag, 'aria-labelledby');
91 const labelledByText = ariaLabelledby ? ariaLabelledby.split(/\s+/).map((id) => labelledBy.get(id) || '').filter(Boolean).join(' ') : '';
92 const visibleText = tag === 'input' ? '' : decodeBasicEntities(stripTags(element));
93 const accessibleName = firstNonEmpty(getAttr(startTag, 'aria-label'), labelledByText, getAttr(startTag, 'value'), getAttr(startTag, 'alt'), visibleText, getAttr(startTag, 'title'));
94 const disabled = /\sdisabled(\s|=|>)/i.test(startTag) || /\saria-hidden\s*=\s*("true"|'true'|true)/i.test(startTag);
95 buttons.push({
96 tag,
97 type,
98 id: getAttr(startTag, 'id'),
99 className: getAttr(startTag, 'class'),
100 role: getAttr(startTag, 'role'),
101 hasAriaLabel: Boolean(getAttr(startTag, 'aria-label')),
102 hasAriaLabelledby: Boolean(ariaLabelledby),
103 hasVisibleText: Boolean(visibleText),
104 accessibleName: accessibleName || null,
105 ok: disabled || Boolean(accessibleName),
106 });
107 if (buttons.length >= maxButtons) break;
108 }
109 if (buttons.length >= maxButtons) break;
110 }
111
112 const unnamedButtons = buttons.filter((button) => !button.ok);
113 const namedCount = buttons.length - unnamedButtons.length;
114 return {
115 buttonCount: buttons.length,
116 namedCount,
117 unnamedButtonCount: unnamedButtons.length,
118 accessibleNameCoveragePercent: buttons.length ? Math.round((namedCount / buttons.length) * 100) : 100,
119 buttons,
120 unnamedButtons,
121 };
122}
123
124function scoreButtons({ parsed, status, error }) {
125 const issues = [];
126 const recommendations = [];
127 let score = 100;
128
129 if (error) return { score: 0, grade: 'F', issues: [error], recommendations: ['Verify the URL is public, reachable, and returns HTML.'] };
130 if (!status || status >= 400) {
131 score -= 40;
132 issues.push(`HTTP status is ${status || 'unknown'}`);
133 recommendations.push('Audit a live 2xx HTML page.');
134 }
135 if (parsed.buttonCount === 0) {
136 score -= 10;
137 issues.push('No buttons found on the page');
138 recommendations.push('Run this actor on pages with forms, navigation controls, dialogs, or calls to action.');
139 }
140 if (parsed.unnamedButtonCount) {
141 score -= Math.min(80, parsed.unnamedButtonCount * 20);
142 issues.push(`${parsed.unnamedButtonCount} button(s) lack an accessible name`);
143 recommendations.push('Add button text, aria-label, aria-labelledby, value, or alt text for icon/image buttons.');
144 }
145
146 const bounded = Math.max(0, score);
147 const grade = bounded >= 90 ? 'A' : bounded >= 75 ? 'B' : bounded >= 60 ? 'C' : bounded >= 45 ? 'D' : 'F';
148 return { score: bounded, grade, issues: [...new Set(issues)], recommendations: [...new Set(recommendations)] };
149}
150
151async function fetchHtml(url, timeoutSeconds) {
152 await normalizeAndValidateUrl(url.href);
153 const controller = new AbortController();
154 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
155 try {
156 const response = await fetch(url, { redirect: 'manual', signal: controller.signal, headers: { 'user-agent': USER_AGENT, accept: 'text/html,*/*;q=0.1' } });
157 const location = response.headers.get('location');
158 if (location && response.status >= 300 && response.status < 400) {
159 const next = new URL(location, url.href);
160 await normalizeAndValidateUrl(next.href);
161 return fetchHtml(next, timeoutSeconds);
162 }
163 return { status: response.status, finalUrl: url.href, contentType: response.headers.get('content-type') || '', body: (await response.text()).slice(0, MAX_BODY_BYTES) };
164 } finally {
165 clearTimeout(timeout);
166 }
167}
168
169export async function auditButtonNames(input) {
170 const startUrl = await normalizeAndValidateUrl(input.startUrl);
171 const timeoutSeconds = clampInteger(input.timeoutSeconds, 10, 3, 30);
172 const maxButtons = clampInteger(input.maxButtons, 50, 1, 200);
173 const checkedAt = new Date().toISOString();
174 let fetched = null;
175 let error = null;
176
177 try { fetched = await fetchHtml(startUrl, timeoutSeconds); } catch (caught) { error = caught.message; }
178
179 const parsed = parseButtons(fetched?.body || '', maxButtons);
180 const scored = scoreButtons({ parsed, status: fetched?.status, error });
181 return {
182 inputUrl: input.startUrl,
183 normalizedInputUrl: startUrl.href,
184 finalUrl: fetched?.finalUrl || startUrl.href,
185 status: fetched?.status || null,
186 ok: !error && fetched?.status >= 200 && fetched?.status < 400 && parsed.unnamedButtonCount === 0,
187 checkedAt,
188 buttonCount: parsed.buttonCount,
189 namedCount: parsed.namedCount,
190 unnamedButtonCount: parsed.unnamedButtonCount,
191 accessibleNameCoveragePercent: parsed.accessibleNameCoveragePercent,
192 buttons: parsed.buttons,
193 unnamedButtons: parsed.unnamedButtons,
194 score: scored.score,
195 grade: scored.grade,
196 issues: scored.issues,
197 recommendations: scored.recommendations,
198 error,
199 };
200}
201
202const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
203
204if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
205 await Actor.init();
206 try {
207 const input = await Actor.getInput();
208 const result = await auditButtonNames(input || {});
209 await Actor.pushData(result);
210 await Actor.setValue('OUTPUT', result);
211 Actor.log.info('Button accessible name audit complete', { finalUrl: result.finalUrl, score: result.score, grade: result.grade });
212 } finally {
213 await Actor.exit();
214 }
215}