1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const USER_AGENT = 'FormLabelAuditor/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 getAttr(tag, name) {
49 const match = tag.match(new RegExp(`\\s${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i'));
50 return match ? (match[2] ?? match[3] ?? match[4] ?? '').trim() : null;
51}
52
53function textFromHtml(raw) {
54 return raw.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
55}
56
57export function parseFormLabels(html, maxControls = 50) {
58 const labelForIds = new Map();
59 const nestedLabels = [];
60 for (const match of html.matchAll(/<label\b[^>]*>[\s\S]*?<\/label>/gi)) {
61 const tag = match[0].match(/<label\b[^>]*>/i)?.[0] || '';
62 const text = textFromHtml(match[0]);
63 const forId = getAttr(tag, 'for');
64 if (forId) labelForIds.set(forId, text);
65 for (const control of match[0].matchAll(/<(input|select|textarea)\b[^>]*>/gi)) nestedLabels.push({ tag: control[0], text });
66 }
67
68 const controls = [];
69 for (const match of html.matchAll(/<(input|select|textarea)\b[^>]*>/gi)) {
70 const tag = match[0];
71 const type = (getAttr(tag, 'type') || match[1]).toLowerCase();
72 if (match[1].toLowerCase() === 'input' && ['hidden', 'submit', 'button', 'reset', 'image'].includes(type)) continue;
73 const id = getAttr(tag, 'id');
74 const ariaLabel = getAttr(tag, 'aria-label');
75 const ariaLabelledBy = getAttr(tag, 'aria-labelledby');
76 const title = getAttr(tag, 'title');
77 const placeholder = getAttr(tag, 'placeholder');
78 const labelText = (id && labelForIds.get(id)) || nestedLabels.find((item) => item.tag === tag)?.text || null;
79 const accessibleName = labelText || ariaLabel || ariaLabelledBy || title || null;
80 controls.push({
81 tag: match[1].toLowerCase(),
82 type,
83 id: id || null,
84 name: getAttr(tag, 'name') || null,
85 hasLabel: Boolean(labelText),
86 hasAriaLabel: Boolean(ariaLabel || ariaLabelledBy),
87 hasTitle: Boolean(title),
88 placeholder: placeholder || null,
89 accessibleName,
90 ok: Boolean(accessibleName),
91 });
92 }
93
94 const missing = controls.filter((control) => !control.ok);
95 return {
96 controlCount: controls.length,
97 labeledCount: controls.length - missing.length,
98 missingLabelCount: missing.length,
99 labelCoveragePercent: controls.length ? Math.round(((controls.length - missing.length) / controls.length) * 100) : 100,
100 controls: controls.slice(0, maxControls),
101 missingControls: missing.slice(0, maxControls),
102 };
103}
104
105function scoreLabels({ parsed, status, error }) {
106 const issues = [];
107 const recommendations = [];
108 let score = 100;
109
110 if (error) return { score: 0, grade: 'F', issues: [error], recommendations: ['Verify the URL is public, reachable, and returns HTML.'] };
111 if (!status || status >= 400) {
112 score -= 40;
113 issues.push(`HTTP status is ${status || 'unknown'}`);
114 recommendations.push('Audit a live 2xx HTML page.');
115 }
116 if (parsed.controlCount === 0) {
117 issues.push('No form controls found');
118 recommendations.push('No action needed if the page has no forms.');
119 }
120 if (parsed.missingLabelCount > 0) {
121 score -= Math.min(80, parsed.missingLabelCount * 20);
122 issues.push(`${parsed.missingLabelCount} form control(s) lack an accessible name`);
123 recommendations.push('Add visible label elements linked with for/id, or use aria-label for compact controls.');
124 }
125
126 const bounded = Math.max(0, score);
127 const grade = bounded >= 90 ? 'A' : bounded >= 75 ? 'B' : bounded >= 60 ? 'C' : bounded >= 45 ? 'D' : 'F';
128 return { score: bounded, grade, issues: [...new Set(issues)], recommendations: [...new Set(recommendations)] };
129}
130
131async function fetchHtml(url, timeoutSeconds) {
132 await normalizeAndValidateUrl(url.href);
133 const controller = new AbortController();
134 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
135 try {
136 const response = await fetch(url, { redirect: 'manual', signal: controller.signal, headers: { 'user-agent': USER_AGENT, accept: 'text/html,*/*;q=0.1' } });
137 const location = response.headers.get('location');
138 if (location && response.status >= 300 && response.status < 400) {
139 const next = new URL(location, url.href);
140 await normalizeAndValidateUrl(next.href);
141 return fetchHtml(next, timeoutSeconds);
142 }
143 return { status: response.status, finalUrl: url.href, contentType: response.headers.get('content-type') || '', body: (await response.text()).slice(0, MAX_BODY_BYTES) };
144 } finally {
145 clearTimeout(timeout);
146 }
147}
148
149export async function auditFormLabels(input) {
150 const startUrl = await normalizeAndValidateUrl(input.startUrl);
151 const timeoutSeconds = clampInteger(input.timeoutSeconds, 10, 3, 30);
152 const maxControls = clampInteger(input.maxControls, 50, 1, 200);
153 const checkedAt = new Date().toISOString();
154 let fetched = null;
155 let error = null;
156
157 try { fetched = await fetchHtml(startUrl, timeoutSeconds); } catch (caught) { error = caught.message; }
158
159 const parsed = parseFormLabels(fetched?.body || '', maxControls);
160 const scored = scoreLabels({ parsed, status: fetched?.status, error });
161 return {
162 inputUrl: input.startUrl,
163 normalizedInputUrl: startUrl.href,
164 finalUrl: fetched?.finalUrl || startUrl.href,
165 status: fetched?.status || null,
166 ok: !error && fetched?.status >= 200 && fetched?.status < 400 && parsed.missingLabelCount === 0,
167 checkedAt,
168 ...parsed,
169 score: scored.score,
170 grade: scored.grade,
171 issues: scored.issues,
172 recommendations: scored.recommendations,
173 error,
174 };
175}
176
177const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
178
179if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
180 await Actor.init();
181 try {
182 const input = await Actor.getInput();
183 const result = await auditFormLabels(input || {});
184 await Actor.pushData(result);
185 await Actor.setValue('OUTPUT', result);
186 Actor.log.info('Form label audit complete', { finalUrl: result.finalUrl, score: result.score, grade: result.grade });
187 } finally {
188 await Actor.exit();
189 }
190}