1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const USER_AGENT = 'HreflangTagsAuditor/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
14 || (a === 172 && b >= 16 && b <= 31)
15 || (a === 192 && b === 168)
16 || a === 127
17 || a === 0
18 || (a === 169 && b === 254);
19}
20
21function isPrivateIPv6(ip) {
22 const normalized = ip.toLowerCase();
23 return normalized === '::1'
24 || normalized.startsWith('fc')
25 || normalized.startsWith('fd')
26 || normalized.startsWith('fe80:');
27}
28
29export async function normalizeAndValidateUrl(rawUrl) {
30 if (!rawUrl || typeof rawUrl !== 'string') throw new Error('startUrl is required');
31 if (/^[a-z][a-z0-9+.-]*:/i.test(rawUrl) && !/^https?:\/\//i.test(rawUrl)) {
32 throw new Error('Only HTTP and HTTPS URLs are supported');
33 }
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 attrsFromTag(tag) {
59 const attrs = {};
60 for (const match of tag.matchAll(/([\w:-]+)\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/g)) {
61 attrs[match[1].toLowerCase()] = match[2].replace(/^['"]|['"]$/g, '').trim();
62 }
63 return attrs;
64}
65
66function extractCanonical(html, baseUrl) {
67 for (const match of html.matchAll(/<link\b[^>]*>/gi)) {
68 const attrs = attrsFromTag(match[0]);
69 if ((attrs.rel || '').toLowerCase().split(/\s+/).includes('canonical') && attrs.href) {
70 try { return new URL(attrs.href, baseUrl).href; } catch { return attrs.href; }
71 }
72 }
73 return null;
74}
75
76export function parseHreflangTags(html, baseUrl) {
77 const tags = [];
78 for (const match of html.matchAll(/<link\b[^>]*>/gi)) {
79 const attrs = attrsFromTag(match[0]);
80 const rels = (attrs.rel || '').toLowerCase().split(/\s+/);
81 if (!rels.includes('alternate') || !attrs.hreflang || !attrs.href) continue;
82 let href = attrs.href;
83 try { href = new URL(attrs.href, baseUrl).href; } catch {}
84 tags.push({ hreflang: attrs.hreflang.toLowerCase(), href });
85 }
86 return tags;
87}
88
89function scoreHreflang({ tags, canonicalUrl, pageUrl, status, error }) {
90 const issues = [];
91 const recommendations = [];
92 let score = 100;
93
94 if (error) {
95 return { score: 0, grade: 'F', issues: [error], recommendations: ['Verify the URL is public, reachable, and returns HTML.'] };
96 }
97 if (!status || status >= 400) {
98 score -= 40;
99 issues.push(`HTTP status is ${status || 'unknown'}`);
100 recommendations.push('Audit a live 2xx HTML page.');
101 }
102 if (tags.length === 0) {
103 score -= 45;
104 issues.push('No hreflang tags found');
105 recommendations.push('Add rel="alternate" hreflang tags for localized page variants when targeting multiple languages or regions.');
106 }
107
108 const byLang = new Map();
109 const invalid = [];
110 const duplicates = [];
111 const page = new URL(pageUrl);
112 for (const tag of tags) {
113 if (tag.hreflang !== 'x-default' && !/^[a-z]{2,3}(-[a-z0-9]{2,8})?$/i.test(tag.hreflang)) invalid.push(tag.hreflang);
114 if (byLang.has(tag.hreflang)) duplicates.push(tag.hreflang);
115 byLang.set(tag.hreflang, (byLang.get(tag.hreflang) || 0) + 1);
116 try {
117 const target = new URL(tag.href);
118 if (page.protocol === 'https:' && target.protocol === 'http:') {
119 issues.push(`HTTP hreflang target for ${tag.hreflang}`);
120 }
121 } catch {
122 invalid.push(`${tag.hreflang}: invalid href`);
123 }
124 }
125
126 if (invalid.length) {
127 score -= 20;
128 issues.push(`Invalid hreflang values: ${[...new Set(invalid)].join(', ')}`);
129 recommendations.push('Use valid ISO language codes, optional region codes, or x-default.');
130 }
131 if (duplicates.length) {
132 score -= 15;
133 issues.push(`Duplicate hreflang values: ${[...new Set(duplicates)].join(', ')}`);
134 recommendations.push('Keep one target URL per hreflang value on a page.');
135 }
136 if (tags.length > 1 && !byLang.has('x-default')) {
137 score -= 10;
138 issues.push('Missing x-default tag');
139 recommendations.push('Add hreflang="x-default" for the default language or country selector page.');
140 }
141 const selfReferences = tags.some((tag) => tag.href === pageUrl || (canonicalUrl && tag.href === canonicalUrl));
142 if (tags.length && !selfReferences) {
143 score -= 15;
144 issues.push('No self-referencing hreflang tag');
145 recommendations.push('Include a hreflang tag for the current page language that points to the canonical URL.');
146 }
147
148 const bounded = Math.max(0, score);
149 const grade = bounded >= 90 ? 'A' : bounded >= 75 ? 'B' : bounded >= 60 ? 'C' : bounded >= 45 ? 'D' : 'F';
150 return { score: bounded, grade, issues: [...new Set(issues)], recommendations: [...new Set(recommendations)] };
151}
152
153async function fetchHtml(url, timeoutSeconds) {
154 await normalizeAndValidateUrl(url.href);
155 const controller = new AbortController();
156 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
157 try {
158 const response = await fetch(url, {
159 redirect: 'manual',
160 signal: controller.signal,
161 headers: { 'user-agent': USER_AGENT, accept: 'text/html,*/*;q=0.1' },
162 });
163 const location = response.headers.get('location');
164 if (location && response.status >= 300 && response.status < 400) {
165 const next = new URL(location, url.href);
166 await normalizeAndValidateUrl(next.href);
167 return fetchHtml(next, timeoutSeconds);
168 }
169 const body = (await response.text()).slice(0, MAX_BODY_BYTES);
170 return { status: response.status, finalUrl: url.href, contentType: response.headers.get('content-type') || '', body };
171 } finally {
172 clearTimeout(timeout);
173 }
174}
175
176export async function auditHreflangTags(input) {
177 const startUrl = await normalizeAndValidateUrl(input.startUrl);
178 const timeoutSeconds = clampInteger(input.timeoutSeconds, 10, 3, 30);
179 const checkedAt = new Date().toISOString();
180 let fetched = null;
181 let error = null;
182
183 try {
184 fetched = await fetchHtml(startUrl, timeoutSeconds);
185 } catch (caught) {
186 error = caught.message;
187 }
188
189 const finalUrl = fetched?.finalUrl || startUrl.href;
190 const html = fetched?.body || '';
191 const tags = parseHreflangTags(html, finalUrl);
192 const canonicalUrl = extractCanonical(html, finalUrl);
193 const scored = scoreHreflang({ tags, canonicalUrl, pageUrl: finalUrl, status: fetched?.status, error });
194
195 return {
196 inputUrl: input.startUrl,
197 normalizedInputUrl: startUrl.href,
198 finalUrl,
199 status: fetched?.status || null,
200 ok: !error && fetched?.status >= 200 && fetched?.status < 400,
201 checkedAt,
202 canonicalUrl,
203 tagCount: tags.length,
204 uniqueHreflangCount: new Set(tags.map((tag) => tag.hreflang)).size,
205 hasXDefault: tags.some((tag) => tag.hreflang === 'x-default'),
206 hreflangTags: tags,
207 score: scored.score,
208 grade: scored.grade,
209 issues: scored.issues,
210 recommendations: scored.recommendations,
211 error,
212 };
213}
214
215const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
216
217if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
218 await Actor.init();
219 try {
220 const input = await Actor.getInput();
221 const result = await auditHreflangTags(input || {});
222 await Actor.pushData(result);
223 await Actor.setValue('OUTPUT', result);
224 Actor.log.info('Hreflang audit complete', { finalUrl: result.finalUrl, score: result.score, grade: result.grade });
225 } finally {
226 await Actor.exit();
227 }
228}