1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const USER_AGENT = 'OpenGraphImageValidator/0.1 (+https://apify.com)';
7const DEFAULT_TIMEOUT_SECONDS = 10;
8const DEFAULT_MAX_HTML_BYTES = 1024 * 1024;
9const MAX_HTML_BYTES = 2 * 1024 * 1024;
10const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
11
12
13const MIN_IMAGE_BYTES = 200;
14const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
15const IMAGE_TYPE_RE = /^image\/(png|jpeg|jpg|gif|webp|avif|svg\+xml|bmp|ico)/i;
16
17
18
19
20
21function isPrivateIPv4(ip) {
22 const parts = ip.split('.').map(Number);
23 if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return false;
24 const [a, b] = parts;
25 return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a === 127 || a === 0 || (a === 169 && b === 254);
26}
27
28function isPrivateIPv6(ip) {
29 const normalized = ip.toLowerCase();
30 return normalized === '::1' || normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe80:');
31}
32
33export async function normalizeAndValidateUrl(rawUrl) {
34 if (!rawUrl || typeof rawUrl !== 'string') throw new Error('startUrl is required');
35 if (/^[a-z][a-z0-9+.-]*:/i.test(rawUrl) && !/^https?:\/\//i.test(rawUrl)) throw new Error('Only HTTP and HTTPS URLs are supported');
36 const withScheme = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`;
37 const url = new URL(withScheme);
38 if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported');
39 if (!url.hostname || url.username || url.password) throw new Error('URL must be public and must not include credentials');
40
41 const literalType = net.isIP(url.hostname);
42 if (literalType === 4 && isPrivateIPv4(url.hostname)) throw new Error('Private IPv4 targets are blocked');
43 if (literalType === 6 && isPrivateIPv6(url.hostname)) throw new Error('Private IPv6 targets are blocked');
44
45 const records = literalType ? [{ address: url.hostname, family: literalType }] : await dns.lookup(url.hostname, { all: true });
46 for (const record of records) {
47 if (record.family === 4 && isPrivateIPv4(record.address)) throw new Error('DNS resolves to a private IPv4 address; blocked for SSRF safety');
48 if (record.family === 6 && isPrivateIPv6(record.address)) throw new Error('DNS resolves to a private IPv6 address; blocked for SSRF safety');
49 }
50 return url;
51}
52
53function clampInteger(value, fallback, min, max) {
54 const parsed = Number(value);
55 if (!Number.isFinite(parsed)) return fallback;
56 return Math.min(Math.max(Math.trunc(parsed), min), max);
57}
58
59function decodeBasicEntities(value) {
60 return (value || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'");
61}
62
63function getAttr(tag, name) {
64 const match = tag.match(new RegExp(`\\s${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i'));
65 return match ? decodeBasicEntities((match[2] ?? match[3] ?? match[4] ?? '').trim()) : null;
66}
67
68function parseMetaTag(match) {
69 const tag = match[0];
70 const property = getAttr(tag, 'property') || getAttr(tag, 'name');
71 const content = getAttr(tag, 'content');
72 return property && content ? { property: property.toLowerCase(), content: content.trim() } : null;
73}
74
75const OG_IMAGE_PROPERTIES = new Set([
76 'og:image',
77 'og:image:url',
78 'og:image:secure_url',
79 'twitter:image',
80 'twitter:image:src',
81]);
82
83export function extractImageUrls(html, baseUrl) {
84 const base = new URL(baseUrl);
85 const seen = new Set();
86 const images = [];
87 for (const match of html.matchAll(/<meta\b[^>]*>/gi)) {
88 const meta = parseMetaTag(match);
89 if (!meta) continue;
90 if (!OG_IMAGE_PROPERTIES.has(meta.property)) continue;
91 let resolved;
92 try { resolved = new URL(meta.content, base).href; } catch { continue; }
93 if (seen.has(resolved)) continue;
94 if (!/^https?:\/\//i.test(resolved)) continue;
95 seen.add(resolved);
96 images.push({ source: meta.property, candidateUrl: meta.content, url: resolved });
97 }
98 return images;
99}
100
101export function extractImageMeta(html, baseUrl) {
102
103 const base = new URL(baseUrl);
104 const meta = {};
105 for (const match of html.matchAll(/<meta\b[^>]*>/gi)) {
106 const tag = match[0];
107 const property = (getAttr(tag, 'property') || getAttr(tag, 'name') || '').toLowerCase();
108 const content = (getAttr(tag, 'content') || '').trim();
109 if (!property || !content) continue;
110 if (property === 'og:image:width') meta.width = content;
111 if (property === 'og:image:height') meta.height = content;
112 if (property === 'og:image:alt') meta.alt = content;
113 if (property === 'twitter:image:alt') meta.twitterAlt = content;
114 }
115 return meta;
116}
117
118async function fetchHtml(initialUrl, timeoutSeconds, maxHtmlBytes, redirectsRemaining = 3) {
119 await normalizeAndValidateUrl(initialUrl.href);
120 const controller = new AbortController();
121 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
122 try {
123 const response = await fetch(initialUrl, { redirect: 'manual', signal: controller.signal, headers: { 'user-agent': USER_AGENT, accept: 'text/html,application/xhtml+xml,*/*;q=0.1' } });
124 if (REDIRECT_STATUSES.has(response.status)) {
125 if (redirectsRemaining <= 0) throw new Error('Too many redirects');
126 const location = response.headers.get('location');
127 if (!location) throw new Error('Redirect without Location header');
128 const next = new URL(location, initialUrl.href);
129 await normalizeAndValidateUrl(next.href);
130 return fetchHtml(next, timeoutSeconds, maxHtmlBytes, redirectsRemaining - 1);
131 }
132 const reader = response.body?.getReader();
133 if (!reader) throw new Error('Response body is not readable');
134 const chunks = [];
135 let received = 0;
136 while (true) {
137 const { done, value } = await reader.read();
138 if (done) break;
139 received += value.byteLength;
140 if (received > maxHtmlBytes) throw new Error(`HTML response exceeds ${maxHtmlBytes} byte limit`);
141 chunks.push(value);
142 }
143 return { ok: response.ok, status: response.status, finalUrl: response.url || initialUrl.href, html: Buffer.concat(chunks).toString('utf8'), contentType: response.headers.get('content-type') || '', error: null };
144 } catch (error) {
145 return { ok: false, status: null, finalUrl: initialUrl.href, html: '', contentType: '', error: error.message };
146 } finally {
147 clearTimeout(timeout);
148 }
149}
150
151const IMAGE_OK_STATUS = [200];
152
153async function checkImage(rawUrl, timeoutSeconds, redirectsRemaining = 3, redirected = false) {
154 let url;
155 try {
156 url = await normalizeAndValidateUrl(rawUrl);
157 } catch (error) {
158 return { url: rawUrl, ok: false, status: null, contentType: null, contentLength: null, redirected, error: error.message };
159 }
160 const controller = new AbortController();
161 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
162 try {
163 let response = await fetch(url, { method: 'GET', redirect: 'manual', signal: controller.signal, headers: { 'user-agent': USER_AGENT, accept: 'image/*,*/*;q=0.1' } });
164 if (REDIRECT_STATUSES.has(response.status)) {
165 if (redirectsRemaining <= 0) return { url: rawUrl, ok: false, status: response.status, contentType: null, contentLength: null, redirected: true, error: 'Too many redirects' };
166 const location = response.headers.get('location');
167 if (!location) return { url: rawUrl, ok: false, status: response.status, contentType: null, contentLength: null, redirected: true, error: 'Redirect without Location header' };
168 try { await response.arrayBuffer(); } catch { }
169 return checkImage(new URL(location, url.href).href, timeoutSeconds, redirectsRemaining - 1, true);
170 }
171 const contentType = response.headers.get('content-type') || '';
172 const contentLengthHeader = response.headers.get('content-length');
173 let contentLength = contentLengthHeader ? Number(contentLengthHeader) : null;
174
175 let isImageType = IMAGE_TYPE_RE.test(contentType);
176 let byteCount = contentLength;
177 if (!isImageType || (!byteCount || byteCount <= 0)) {
178 const reader = response.body?.getReader();
179 if (reader) {
180
181 const { value } = await reader.read();
182 if (value) {
183 byteCount = value.byteLength;
184 isImageType = isImageType || sniffImageType(value) !== null;
185 }
186 try { reader.cancel(); } catch { }
187 }
188 }
189 const okStatus = IMAGE_OK_STATUS.includes(response.status);
190 const sizeOk = byteCount !== null && byteCount >= MIN_IMAGE_BYTES && byteCount <= MAX_IMAGE_BYTES;
191 const ok = okStatus && isImageType && sizeOk;
192 const issues = [];
193 if (!okStatus) issues.push(`HTTP status ${response.status}`);
194 if (!isImageType) issues.push('response is not a recognized image type');
195 if (!sizeOk) issues.push(byteCount === null || byteCount <= 0 ? 'content length is unknown' : byteCount < MIN_IMAGE_BYTES ? `image smaller than ${MIN_IMAGE_BYTES} bytes` : `image larger than ${MAX_IMAGE_BYTES} bytes`);
196 return { url: rawUrl, ok, status: response.status, contentType: contentType || null, contentLength: byteCount, redirected, issues };
197 } catch (error) {
198 return { url: rawUrl, ok: false, status: null, contentType: null, contentLength: null, redirected, error: error.message };
199 } finally {
200 clearTimeout(timeout);
201 }
202}
203
204function sniffImageType(bytes) {
205
206 if (bytes.byteLength >= 3 && bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) return 'image/jpeg';
207 if (bytes.byteLength >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47 && bytes[4] === 0x0D && bytes[5] === 0x0A && bytes[6] === 0x1A && bytes[7] === 0x0A) return 'image/png';
208 if (bytes.byteLength >= 6 && bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38 && (bytes[4] === 0x37 || bytes[4] === 0x39) && bytes[5] === 0x61) return 'image/gif';
209 if (bytes.byteLength >= 12 && bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 && bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) return 'image/webp';
210 return null;
211}
212
213function scoreAudit({ images, meta, error }) {
214 const issues = [];
215 const recommendations = [];
216 let score = 100;
217
218 if (error) return { score: 0, grade: 'F', issues: [error], recommendations: ['Verify the page URL is public and reachable.'] };
219
220 if (images.length === 0) {
221 score -= 60;
222 issues.push('No Open Graph or Twitter Card image found');
223 recommendations.push('Add og:image meta tags for social preview images.');
224 recommendations.push('Add twitter:image meta tag for Twitter/X card previews.');
225 score = Math.max(0, score);
226 const grade = score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 60 ? 'C' : score >= 45 ? 'D' : 'F';
227 return { score, grade, issues, recommendations };
228 }
229
230 const broken = images.filter((img) => !img.ok);
231 if (broken.length) {
232 score -= Math.min(80, broken.length * 30);
233 issues.push(`${broken.length} social preview image(s) failed validation`);
234 recommendations.push('Fix or replace social preview images returning errors, non-image content, or out-of-range sizes.');
235 }
236
237 if (images.length === 1) {
238 issues.push('Only one social preview image is set; consider adding twitter:image if only og:image is present');
239 recommendations.push('Add twitter:image meta tag if Twitter/X card previews are not covered by og:image.');
240 }
241
242 if (meta.width && meta.height) {
243 const w = Number(meta.width);
244 const h = Number(meta.height);
245 if (w < 200 || h < 200) {
246 score -= 10;
247 issues.push(`Declared og:image dimensions (${meta.width}x${meta.height}) are below 200x200`);
248 recommendations.push('Use social preview images at least 200x200, ideally 1200x630 for og:image.');
249 }
250 } else if (images.length) {
251 score -= 5;
252 issues.push('og:image:width and og:image:height are not declared');
253 recommendations.push('Declare og:image:width and og:image:height so platforms can render previews without re-fetching.');
254 }
255
256 const bounded = Math.max(0, score);
257 const grade = bounded >= 90 ? 'A' : bounded >= 75 ? 'B' : bounded >= 60 ? 'C' : bounded >= 45 ? 'D' : 'F';
258 return { score: bounded, grade, issues: [...new Set(issues)], recommendations: [...new Set(recommendations)] };
259}
260
261export async function auditOpenGraphImages(input) {
262 const startUrl = await normalizeAndValidateUrl(input.startUrl);
263 const timeoutSeconds = clampInteger(input.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS, 3, 30);
264 const maxHtmlBytes = clampInteger(input.maxHtmlBytes, DEFAULT_MAX_HTML_BYTES, 100000, MAX_HTML_BYTES);
265 const maxImages = clampInteger(input.maxImages, 10, 1, 20);
266 const checkedAt = new Date().toISOString();
267
268 const page = await fetchHtml(startUrl, timeoutSeconds, maxHtmlBytes);
269 if (page.error) {
270 return {
271 inputUrl: input.startUrl,
272 normalizedInputUrl: startUrl.href,
273 finalUrl: page.finalUrl,
274 ok: false,
275 checkedAt,
276 score: 0,
277 grade: 'F',
278 imageCount: 0,
279 validImageCount: 0,
280 brokenImageCount: 0,
281 images: [],
282 issues: [page.error],
283 recommendations: ['Verify the page URL is public and reachable.'],
284 error: page.error,
285 };
286 }
287
288 const found = page.error ? [] : extractImageUrls(page.html, page.finalUrl);
289 const meta = page.error ? {} : extractImageMeta(page.html, page.finalUrl);
290 const toCheck = found.slice(0, maxImages);
291
292 const images = [];
293 for (const img of toCheck) {
294 const result = await checkImage(img.url, timeoutSeconds);
295 images.push({ source: img.source, url: img.url, ...result });
296 }
297
298 const scored = scoreAudit({ images, meta, error: null });
299 const validImageCount = images.filter((img) => img.ok).length;
300 const brokenImageCount = images.length - validImageCount;
301
302 return {
303 inputUrl: input.startUrl,
304 normalizedInputUrl: startUrl.href,
305 finalUrl: page.finalUrl,
306 ok: images.length > 0 && brokenImageCount === 0,
307 checkedAt,
308 score: scored.score,
309 grade: scored.grade,
310 imageCount: images.length,
311 validImageCount,
312 brokenImageCount,
313 images,
314 declaredWidth: meta.width || null,
315 declaredHeight: meta.height || null,
316 declaredAlt: meta.alt || meta.twitterAlt || null,
317 issues: scored.issues,
318 recommendations: scored.recommendations,
319 error: null,
320 };
321}
322
323const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
324
325if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
326 await Actor.init();
327 try {
328 const input = await Actor.getInput();
329 const result = await auditOpenGraphImages(input || {});
330 await Actor.pushData(result);
331 await Actor.setValue('OUTPUT', result);
332 Actor.log.info('Open Graph image validation complete', { finalUrl: result.finalUrl, score: result.score, grade: result.grade });
333 } finally {
334 await Actor.exit();
335 }
336}