1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const USER_AGENT = 'LinkAttributeAuditor/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 decodeEntities(text) {
49 return String(text || '')
50 .replace(/ /gi, ' ')
51 .replace(/&/gi, '&')
52 .replace(/</gi, '<')
53 .replace(/>/gi, '>')
54 .replace(/"/gi, '"')
55 .replace(/'/g, "'")
56 .replace(/\s+/g, ' ')
57 .trim();
58}
59
60function stripTags(html) {
61 return html.replace(/<script\b[\s\S]*?<\/script>/gi, ' ')
62 .replace(/<style\b[\s\S]*?<\/style>/gi, ' ')
63 .replace(/<[^>]+>/g, ' ')
64 .replace(/\s+/g, ' ')
65 .trim();
66}
67
68function attrsFromTag(tag) {
69 const attrs = {};
70 for (const match of tag.matchAll(/([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g)) {
71 attrs[match[1].toLowerCase()] = decodeEntities(match[3] ?? match[4] ?? match[5] ?? '');
72 }
73 return attrs;
74}
75
76export function parseAnchors(html, baseUrl, maxLinks = 200) {
77 const anchors = [];
78 const base = new URL(baseUrl);
79 for (const match of html.matchAll(/<a\b[^>]*>[\s\S]*?<\/a>/gi)) {
80 if (anchors.length >= maxLinks) break;
81 const tag = match[0].match(/^<a\b[^>]*>/i)?.[0] || '';
82 const attrs = attrsFromTag(tag);
83 if (!attrs.href || attrs.href.startsWith('#') || /^javascript:/i.test(attrs.href) || /^mailto:/i.test(attrs.href) || /^tel:/i.test(attrs.href)) continue;
84
85 let absoluteUrl = null;
86 let host = null;
87 try {
88 const parsed = new URL(attrs.href, base.href);
89 if (!['http:', 'https:'].includes(parsed.protocol)) continue;
90 absoluteUrl = parsed.href;
91 host = parsed.hostname;
92 } catch {
93 continue;
94 }
95
96 const relTokens = (attrs.rel || '').toLowerCase().split(/\s+/).filter(Boolean);
97 const targetBlank = (attrs.target || '').toLowerCase() === '_blank';
98 const external = host !== base.hostname;
99 const text = decodeEntities(stripTags(match[0]));
100 anchors.push({
101 url: absoluteUrl,
102 text: text.slice(0, 120),
103 external,
104 targetBlank,
105 rel: relTokens,
106 nofollow: relTokens.includes('nofollow'),
107 sponsored: relTokens.includes('sponsored'),
108 ugc: relTokens.includes('ugc'),
109 noopener: relTokens.includes('noopener'),
110 noreferrer: relTokens.includes('noreferrer'),
111 issues: [],
112 });
113 }
114 return anchors;
115}
116
117function scoreLinks({ links, status, error }) {
118 const issues = [];
119 const recommendations = [];
120 let score = 100;
121 if (error) return { score: 0, grade: 'F', issues: [error], recommendations: ['Verify the URL is public, reachable, and returns HTML.'] };
122 if (!status || status >= 400) {
123 score -= 40;
124 issues.push(`HTTP status is ${status || 'unknown'}`);
125 recommendations.push('Audit a live 2xx HTML page.');
126 }
127
128 const externalLinks = links.filter((l) => l.external);
129 const blankLinks = links.filter((l) => l.targetBlank);
130 const unsafeBlankLinks = blankLinks.filter((l) => !l.noopener && !l.noreferrer);
131 const externalFollowLinks = externalLinks.filter((l) => !l.nofollow && !l.sponsored && !l.ugc);
132 const sponsoredWithoutRel = externalLinks.filter((l) => /\b(sponsor|affiliate|partner|paid|ref=|utm_medium=affiliate)\b/i.test(`${l.text} ${l.url}`) && !l.sponsored);
133 const emptyTextLinks = links.filter((l) => !l.text);
134
135 for (const link of unsafeBlankLinks) link.issues.push('target_blank_without_noopener');
136 for (const link of sponsoredWithoutRel) link.issues.push('likely_sponsored_without_rel_sponsored');
137 for (const link of emptyTextLinks) link.issues.push('empty_anchor_text');
138
139 if (unsafeBlankLinks.length) {
140 score -= Math.min(30, unsafeBlankLinks.length * 8);
141 issues.push(`target="_blank" links missing rel="noopener" or rel="noreferrer": ${unsafeBlankLinks.length}`);
142 recommendations.push('Add rel="noopener" to links that open in a new tab.');
143 }
144 if (sponsoredWithoutRel.length) {
145 score -= Math.min(25, sponsoredWithoutRel.length * 10);
146 issues.push(`Likely sponsored or affiliate links missing rel="sponsored": ${sponsoredWithoutRel.length}`);
147 recommendations.push('Mark paid, affiliate, or partner links with rel="sponsored".');
148 }
149 if (emptyTextLinks.length) {
150 score -= Math.min(20, emptyTextLinks.length * 5);
151 issues.push(`Links with empty anchor text: ${emptyTextLinks.length}`);
152 recommendations.push('Use descriptive link text or an accessible label for image/icon links.');
153 }
154 if (externalLinks.length && externalFollowLinks.length === externalLinks.length) {
155 score -= 10;
156 issues.push('All external links are followed links');
157 recommendations.push('Review external links and add nofollow, sponsored, or ugc where appropriate.');
158 }
159
160 const bounded = Math.max(0, score);
161 const grade = bounded >= 90 ? 'A' : bounded >= 75 ? 'B' : bounded >= 60 ? 'C' : bounded >= 45 ? 'D' : 'F';
162 return { score: bounded, grade, issues: [...new Set(issues)], recommendations: [...new Set(recommendations)] };
163}
164
165async function fetchHtml(url, timeoutSeconds) {
166 await normalizeAndValidateUrl(url.href);
167 const controller = new AbortController();
168 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
169 try {
170 const response = await fetch(url, { redirect: 'manual', signal: controller.signal, headers: { 'user-agent': USER_AGENT, accept: 'text/html,*/*;q=0.1' } });
171 const location = response.headers.get('location');
172 if (location && response.status >= 300 && response.status < 400) {
173 const next = new URL(location, url.href);
174 await normalizeAndValidateUrl(next.href);
175 return fetchHtml(next, timeoutSeconds);
176 }
177 return { status: response.status, finalUrl: url.href, contentType: response.headers.get('content-type') || '', body: (await response.text()).slice(0, MAX_BODY_BYTES) };
178 } finally {
179 clearTimeout(timeout);
180 }
181}
182
183export async function auditLinkAttributes(input) {
184 const startUrl = await normalizeAndValidateUrl(input.startUrl);
185 const timeoutSeconds = clampInteger(input.timeoutSeconds, 10, 3, 30);
186 const maxLinks = clampInteger(input.maxLinks, 200, 1, 1000);
187 const checkedAt = new Date().toISOString();
188 let fetched = null;
189 let error = null;
190
191 try { fetched = await fetchHtml(startUrl, timeoutSeconds); } catch (caught) { error = caught.message; }
192
193 const finalUrl = fetched?.finalUrl || startUrl.href;
194 const links = parseAnchors(fetched?.body || '', finalUrl, maxLinks);
195 const scored = scoreLinks({ links, status: fetched?.status, error });
196 const externalLinks = links.filter((l) => l.external);
197 const targetBlankLinks = links.filter((l) => l.targetBlank);
198 const unsafeTargetBlankLinks = targetBlankLinks.filter((l) => !l.noopener && !l.noreferrer);
199 const sponsoredLinks = links.filter((l) => l.sponsored);
200 const nofollowLinks = links.filter((l) => l.nofollow);
201 const ugcLinks = links.filter((l) => l.ugc);
202 const linkSamples = links.filter((l) => l.issues.length).slice(0, 50);
203
204 return {
205 inputUrl: input.startUrl,
206 normalizedInputUrl: startUrl.href,
207 finalUrl,
208 status: fetched?.status || null,
209 ok: !error && fetched?.status >= 200 && fetched?.status < 400,
210 checkedAt,
211 linkCount: links.length,
212 externalLinkCount: externalLinks.length,
213 targetBlankCount: targetBlankLinks.length,
214 unsafeTargetBlankCount: unsafeTargetBlankLinks.length,
215 nofollowCount: nofollowLinks.length,
216 sponsoredCount: sponsoredLinks.length,
217 ugcCount: ugcLinks.length,
218 linkSamples,
219 score: scored.score,
220 grade: scored.grade,
221 issues: scored.issues,
222 recommendations: scored.recommendations,
223 error,
224 };
225}
226
227const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
228
229if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
230 await Actor.init();
231 try {
232 const input = await Actor.getInput();
233 const result = await auditLinkAttributes(input || {});
234 await Actor.pushData(result);
235 await Actor.setValue('OUTPUT', result);
236 Actor.log.info('Link attribute audit complete', { finalUrl: result.finalUrl, score: result.score, grade: result.grade });
237 } finally {
238 await Actor.exit();
239 }
240}