1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const USER_AGENT = 'SitemapFreshnessAuditor/0.1 (+https://apify.com)';
7const DEFAULT_TIMEOUT_SECONDS = 10;
8const DEFAULT_MAX_BYTES = 2 * 1024 * 1024;
9const MAX_BYTES = 5 * 1024 * 1024;
10const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
11
12function isPrivateIPv4(ip) {
13 const parts = ip.split('.').map(Number);
14 if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return false;
15 const [a, b] = parts;
16 return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a === 127 || a === 0 || (a === 169 && b === 254);
17}
18
19function isPrivateIPv6(ip) {
20 const normalized = ip.toLowerCase();
21 return normalized === '::1' || normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe80:');
22}
23
24export async function normalizeAndValidateUrl(rawUrl) {
25 if (!rawUrl || typeof rawUrl !== 'string') throw new Error('startUrl is required');
26 if (/^[a-z][a-z0-9+.-]*:/i.test(rawUrl) && !/^https?:\/\//i.test(rawUrl)) throw new Error('Only HTTP and HTTPS URLs are supported');
27 const withScheme = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`;
28 const url = new URL(withScheme);
29 if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported');
30 if (!url.hostname || url.username || url.password) throw new Error('URL must be public and must not include credentials');
31
32 const literalType = net.isIP(url.hostname);
33 if (literalType === 4 && isPrivateIPv4(url.hostname)) throw new Error('Private IPv4 targets are blocked');
34 if (literalType === 6 && isPrivateIPv6(url.hostname)) throw new Error('Private IPv6 targets are blocked');
35
36 const records = literalType ? [{ address: url.hostname, family: literalType }] : await dns.lookup(url.hostname, { all: true });
37 for (const record of records) {
38 if (record.family === 4 && isPrivateIPv4(record.address)) throw new Error('DNS resolves to a private IPv4 address; blocked for SSRF safety');
39 if (record.family === 6 && isPrivateIPv6(record.address)) throw new Error('DNS resolves to a private IPv6 address; blocked for SSRF safety');
40 }
41 return url;
42}
43
44function clampInteger(value, fallback, min, max) {
45 const parsed = Number(value);
46 if (!Number.isFinite(parsed)) return fallback;
47 return Math.min(Math.max(Math.trunc(parsed), min), max);
48}
49
50function decodeXml(value) {
51 return (value || '').replace(/&/gi, '&').replace(/"/gi, '"').replace(/'/gi, "'").replace(/</gi, '<').replace(/>/gi, '>').trim();
52}
53
54function firstTag(block, tag) {
55 const match = block.match(new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tag}>`, 'i'));
56 return match ? decodeXml(match[1]) : null;
57}
58
59export function resolveSitemapUrl(targetUrl) {
60 const url = new URL(targetUrl);
61 if (/\.xml(\.gz)?$/i.test(url.pathname) || url.pathname.toLowerCase().includes('sitemap')) return url;
62 return new URL('/sitemap.xml', url.origin);
63}
64
65export function parseSitemapXml(xml, now = new Date(), staleAfterDays = 365) {
66 const urls = [];
67 const sitemapIndexes = [];
68 const staleCutoff = now.getTime() - staleAfterDays * 24 * 60 * 60 * 1000;
69
70 for (const match of xml.matchAll(/<url\b[^>]*>([\s\S]*?)<\/url>/gi)) {
71 const loc = firstTag(match[1], 'loc');
72 if (!loc) continue;
73 const lastmod = firstTag(match[1], 'lastmod');
74 const lastmodTime = lastmod ? Date.parse(lastmod) : NaN;
75 urls.push({ loc, lastmod, hasLastmod: Boolean(lastmod), stale: Number.isFinite(lastmodTime) ? lastmodTime < staleCutoff : null });
76 }
77
78 for (const match of xml.matchAll(/<sitemap\b[^>]*>([\s\S]*?)<\/sitemap>/gi)) {
79 const loc = firstTag(match[1], 'loc');
80 if (!loc) continue;
81 sitemapIndexes.push({ loc, lastmod: firstTag(match[1], 'lastmod') });
82 }
83
84 const withLastmod = urls.filter((item) => item.hasLastmod).length;
85 const staleUrls = urls.filter((item) => item.stale).length;
86 return {
87 type: sitemapIndexes.length && !urls.length ? 'sitemapindex' : 'urlset',
88 urlCount: urls.length,
89 sitemapIndexCount: sitemapIndexes.length,
90 lastmodCount: withLastmod,
91 lastmodCoveragePercent: urls.length ? Math.round((withLastmod / urls.length) * 100) : 0,
92 staleUrlCount: staleUrls,
93 sampleUrls: urls.slice(0, 20),
94 sitemapIndexes: sitemapIndexes.slice(0, 50),
95 };
96}
97
98async function fetchText(initialUrl, timeoutSeconds, maxBytes, redirectsRemaining = 3) {
99 await normalizeAndValidateUrl(initialUrl.href);
100 const controller = new AbortController();
101 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
102 try {
103 const response = await fetch(initialUrl, { redirect: 'manual', signal: controller.signal, headers: { 'user-agent': USER_AGENT, accept: 'application/xml,text/xml,*/*;q=0.1' } });
104 if (REDIRECT_STATUSES.has(response.status)) {
105 if (redirectsRemaining <= 0) throw new Error('Too many redirects');
106 const location = response.headers.get('location');
107 if (!location) throw new Error('Redirect without Location header');
108 return fetchText(new URL(location, initialUrl.href), timeoutSeconds, maxBytes, redirectsRemaining - 1);
109 }
110 const reader = response.body?.getReader();
111 if (!reader) throw new Error('Response body is not readable');
112 const chunks = [];
113 let received = 0;
114 while (true) {
115 const { done, value } = await reader.read();
116 if (done) break;
117 received += value.byteLength;
118 if (received > maxBytes) throw new Error(`Sitemap response exceeds ${maxBytes} byte limit`);
119 chunks.push(value);
120 }
121 return { ok: response.ok, status: response.status, finalUrl: response.url || initialUrl.href, contentType: response.headers.get('content-type') || '', bytes: received, text: Buffer.concat(chunks).toString('utf8'), error: null };
122 } catch (error) {
123 return { ok: false, status: null, finalUrl: initialUrl.href, contentType: '', bytes: 0, text: '', error: error.message };
124 } finally {
125 clearTimeout(timeout);
126 }
127}
128
129function score(parsed, fetchResult) {
130 if (fetchResult.error || !fetchResult.ok) return { score: 0, grade: 'F', issues: [fetchResult.error || `Sitemap returned HTTP ${fetchResult.status}`], recommendations: ['Verify the sitemap URL is public, reachable, and returns XML.'] };
131 let value = 100;
132 const issues = [];
133 const recommendations = [];
134 if (parsed.type === 'sitemapindex') {
135 issues.push('Input is a sitemap index; child sitemap freshness is not expanded in this bounded audit');
136 recommendations.push('Audit important child sitemap URLs directly when freshness details are needed.');
137 value -= 10;
138 }
139 if (parsed.urlCount === 0 && parsed.sitemapIndexCount === 0) {
140 issues.push('No sitemap URLs or child sitemaps were parsed');
141 recommendations.push('Check that the URL returns standard XML sitemap markup.');
142 value -= 80;
143 }
144 if (parsed.urlCount > 0 && parsed.lastmodCoveragePercent < 80) {
145 issues.push(`Only ${parsed.lastmodCoveragePercent}% of URLs include lastmod`);
146 recommendations.push('Add lastmod values for important URLs so crawlers can prioritize recrawls.');
147 value -= Math.min(35, 80 - parsed.lastmodCoveragePercent);
148 }
149 if (parsed.staleUrlCount > 0) {
150 issues.push(`${parsed.staleUrlCount} URLs have stale lastmod dates`);
151 recommendations.push('Refresh stale content timestamps only when content materially changes.');
152 value -= Math.min(30, parsed.staleUrlCount * 3);
153 }
154 const bounded = Math.max(0, value);
155 return { score: bounded, grade: bounded >= 90 ? 'A' : bounded >= 75 ? 'B' : bounded >= 60 ? 'C' : bounded >= 45 ? 'D' : 'F', issues, recommendations };
156}
157
158export async function auditSitemapFreshness(input) {
159 const target = await normalizeAndValidateUrl(input.startUrl);
160 const sitemapUrl = resolveSitemapUrl(target.href);
161 const timeoutSeconds = clampInteger(input.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS, 3, 30);
162 const maxBytes = clampInteger(input.maxBytes, DEFAULT_MAX_BYTES, 100000, MAX_BYTES);
163 const staleAfterDays = clampInteger(input.staleAfterDays, 365, 1, 3650);
164 const fetched = await fetchText(sitemapUrl, timeoutSeconds, maxBytes);
165 const parsed = fetched.error ? parseSitemapXml('', new Date(), staleAfterDays) : parseSitemapXml(fetched.text, new Date(), staleAfterDays);
166 const scored = score(parsed, fetched);
167 return { inputUrl: input.startUrl, sitemapUrl: sitemapUrl.href, finalUrl: fetched.finalUrl, ok: !fetched.error && fetched.ok && scored.score >= 75, checkedAt: new Date().toISOString(), status: fetched.status, contentType: fetched.contentType, bytes: fetched.bytes, staleAfterDays, score: scored.score, grade: scored.grade, ...parsed, issues: scored.issues, recommendations: scored.recommendations, error: fetched.error };
168}
169
170const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
171
172if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
173 await Actor.init();
174 try {
175 const result = await auditSitemapFreshness(await Actor.getInput() || {});
176 await Actor.pushData(result);
177 await Actor.setValue('OUTPUT', result);
178 Actor.log.info('Sitemap freshness audit complete', { sitemapUrl: result.sitemapUrl, urls: result.urlCount, score: result.score, grade: result.grade });
179 } finally {
180 await Actor.exit();
181 }
182}