1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const USER_AGENT = 'AdsTxtAuditor/0.1 (+https://apify.com)';
7const DEFAULT_TIMEOUT_SECONDS = 10;
8const MAX_BODY_BYTES = 2 * 1024 * 1024;
9
10
11
12
13
14
15
16
17function isPrivateIPv4(ip) {
18 const parts = ip.split('.').map(Number);
19 if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return false;
20 const [a, b] = parts;
21 return a === 10
22 || (a === 172 && b >= 16 && b <= 31)
23 || (a === 192 && b === 168)
24 || a === 127
25 || a === 0
26 || (a === 169 && b === 254);
27}
28
29function isPrivateIPv6(ip) {
30 const normalized = ip.toLowerCase();
31 return normalized === '::1'
32 || normalized.startsWith('fc')
33 || normalized.startsWith('fd')
34 || normalized.startsWith('fe80:');
35}
36
37export async function normalizeAndValidateUrl(rawUrl) {
38 if (!rawUrl || typeof rawUrl !== 'string') throw new Error('startUrl is required');
39 if (/^[a-z][a-z0-9+.-]*:/i.test(rawUrl) && !/^https?:\/\//i.test(rawUrl)) {
40 throw new Error('Only HTTP and HTTPS URLs are supported');
41 }
42 const withScheme = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`;
43 const url = new URL(withScheme);
44 if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported');
45 if (!url.hostname || url.username || url.password) throw new Error('URL must be public and must not include credentials');
46
47 const literalType = net.isIP(url.hostname);
48 if (literalType === 4 && isPrivateIPv4(url.hostname)) throw new Error('Private IPv4 targets are blocked');
49 if (literalType === 6 && isPrivateIPv6(url.hostname)) throw new Error('Private IPv6 targets are blocked');
50
51 const records = literalType ? [{ address: url.hostname, family: literalType }] : await dns.lookup(url.hostname, { all: true });
52 for (const record of records) {
53 if (record.family === 4 && isPrivateIPv4(record.address)) throw new Error('DNS resolves to a private IPv4 address; blocked for SSRF safety');
54 if (record.family === 6 && isPrivateIPv6(record.address)) throw new Error('DNS resolves to a private IPv6 address; blocked for SSRF safety');
55 }
56 return url;
57}
58
59
60
61
62
63
64async function fetchTextFile(initialUrl, timeoutSeconds, redirectsRemaining = 3) {
65 await normalizeAndValidateUrl(initialUrl.href);
66 const controller = new AbortController();
67 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
68 try {
69 const response = await fetch(initialUrl, {
70 redirect: 'manual',
71 signal: controller.signal,
72 headers: {
73 'user-agent': USER_AGENT,
74 accept: 'text/plain,*/*;q=0.1',
75 },
76 });
77
78 if ([301, 302, 303, 307, 308].includes(response.status)) {
79 if (redirectsRemaining <= 0) throw new Error('Too many redirects');
80 const location = response.headers.get('location');
81 if (!location) throw new Error('Redirect without Location header');
82 const nextUrl = new URL(location, initialUrl.href);
83 try { await response.arrayBuffer(); } catch { }
84 return fetchTextFile(nextUrl, timeoutSeconds, redirectsRemaining - 1);
85 }
86
87 const contentType = response.headers.get('content-type') || null;
88
89 if (!response.ok) {
90 try { await response.arrayBuffer(); } catch { }
91 return {
92 ok: false,
93 status: response.status,
94 finalUrl: response.url || initialUrl.href,
95 https: (response.url || initialUrl.href).startsWith('https://'),
96 contentType,
97 body: null,
98 error: null,
99 };
100 }
101
102
103 const buffer = await response.arrayBuffer();
104 const bytes = new Uint8Array(buffer.slice(0, MAX_BODY_BYTES));
105 const body = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
106
107 return {
108 ok: true,
109 status: response.status,
110 finalUrl: response.url || initialUrl.href,
111 https: (response.url || initialUrl.href).startsWith('https://'),
112 contentType,
113 body,
114 error: null,
115 };
116 } catch (error) {
117 return {
118 ok: false,
119 status: null,
120 finalUrl: initialUrl.href,
121 https: initialUrl.protocol === 'https:',
122 contentType: null,
123 body: null,
124 error: error.message,
125 };
126 } finally {
127 clearTimeout(timeout);
128 }
129}
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147export function parseAdsTxtLine(line) {
148 const trimmed = line.trim();
149
150 if (trimmed === '') return { type: 'blank', raw: line };
151 if (trimmed.startsWith('#')) return { type: 'comment', raw: line };
152
153
154 const varMatch = /^([A-Za-z][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(trimmed);
155 if (varMatch) {
156 const varName = varMatch[1].toUpperCase();
157 const varValue = varMatch[2].trim();
158 if (['OWNERDOMAIN', 'MANAGERDOMAIN'].includes(varName)) {
159 return { type: 'variable', name: varName, value: varValue, raw: line };
160 }
161
162 return { type: 'variable', name: varName, value: varValue, raw: line };
163 }
164
165
166 const fields = trimmed.split(',').map((f) => f.trim());
167
168
169 if (fields.length < 3) {
170 return {
171 type: 'invalid',
172 raw: line,
173 reason: 'Entry has fewer than 3 fields (expected: adSystemDomain, publisherAccountId, accountType[, authorityId]).',
174 };
175 }
176
177 const [adSystemDomain, publisherAccountId, accountTypeRaw, authorityId] = fields;
178 const accountType = accountTypeRaw ? accountTypeRaw.toUpperCase() : '';
179
180 if (!adSystemDomain) {
181 return { type: 'invalid', raw: line, reason: 'Ad system domain is empty.' };
182 }
183 if (!publisherAccountId) {
184 return { type: 'invalid', raw: line, reason: 'Publisher account ID is empty.' };
185 }
186 if (accountType !== 'DIRECT' && accountType !== 'RESELLER') {
187 return {
188 type: 'invalid',
189 raw: line,
190 reason: `Account type "${accountTypeRaw}" is not DIRECT or RESELLER.`,
191 };
192 }
193
194 return {
195 type: 'entry',
196 adSystemDomain: adSystemDomain.toLowerCase(),
197 publisherAccountId,
198 accountType,
199 authorityId: authorityId || null,
200 raw: line,
201 };
202}
203
204export function parseAdsTxt(body) {
205 if (!body || typeof body !== 'string') {
206 return {
207 entries: [],
208 comments: [],
209 variables: [],
210 invalidEntries: [],
211 blankLines: 0,
212 lineCount: 0,
213 };
214 }
215
216 const lines = body.split(/\r?\n/);
217 const entries = [];
218 const comments = [];
219 const variables = [];
220 const invalidEntries = [];
221 let blankLines = 0;
222
223 for (const line of lines) {
224 const parsed = parseAdsTxtLine(line);
225 switch (parsed.type) {
226 case 'blank': blankLines++; break;
227 case 'comment': comments.push(parsed); break;
228 case 'variable': variables.push(parsed); break;
229 case 'entry': entries.push(parsed); break;
230 case 'invalid': invalidEntries.push(parsed); break;
231 }
232 }
233
234 return {
235 entries,
236 comments,
237 variables,
238 invalidEntries,
239 blankLines,
240 lineCount: lines.length,
241 };
242}
243
244
245
246
247
248
249
250export function detectDuplicates(entries) {
251 const seen = new Map();
252 const duplicates = [];
253
254 for (const entry of entries) {
255 const key = `${entry.adSystemDomain},${entry.publisherAccountId},${entry.accountType}`;
256 if (seen.has(key)) {
257 duplicates.push({
258 ...entry,
259 duplicateOf: seen.get(key),
260 });
261 } else {
262 seen.set(key, entry.raw);
263 }
264 }
265
266 return duplicates;
267}
268
269
270
271
272
273export function validateEntry(entry) {
274 const issues = [];
275
276
277 if (!/\./.test(entry.adSystemDomain)) {
278 issues.push({
279 severity: 'warn',
280 message: `Ad system domain "${entry.adSystemDomain}" does not contain a dot; it may not be a valid domain name.`,
281 });
282 }
283
284
285 if (entry.accountType === 'RESELLER' && !entry.authorityId) {
286 issues.push({
287 severity: 'warn',
288 message: `RESELLER entry for "${entry.adSystemDomain}" has no authority ID; IAB recommends including the TAG-ID for resellers.`,
289 });
290 }
291
292
293 if (entry.accountType === 'DIRECT' && !entry.authorityId) {
294 issues.push({
295 severity: 'info',
296 message: `DIRECT entry for "${entry.adSystemDomain}" has no authority ID; including it improves supply chain transparency.`,
297 });
298 }
299
300 return { ...entry, issues };
301}
302
303
304
305
306
307export function scoreAdsTxt(analysis) {
308 if (!analysis.adsTxtFound) {
309 return {
310 score: 0,
311 grade: 'F',
312 issues: ['No ads.txt file found at /ads.txt. Buyers cannot verify authorized sellers for this domain.'],
313 recommendations: [
314 'Publish an ads.txt file at the domain root (https://<domain>/ads.txt) listing all authorized digital sellers.',
315 'Follow the IAB Authorized Digital Sellers spec: each line is "adSystemDomain, publisherAccountId, DIRECT|RESELLER, authorityId".',
316 'Include the TAG-ID as the authority ID for RESELLER entries to improve supply chain transparency.',
317 ],
318 };
319 }
320
321 let score = 100;
322 const issues = [];
323
324
325 if (analysis.invalidEntryCount > 0) {
326 const penalty = Math.min(40, analysis.invalidEntryCount * 10);
327 score -= penalty;
328 issues.push(`${analysis.invalidEntryCount} invalid entry/entries detected that do not conform to the IAB ads.txt format.`);
329 }
330
331
332 if (analysis.duplicateCount > 0) {
333 const penalty = Math.min(20, analysis.duplicateCount * 5);
334 score -= penalty;
335 issues.push(`${analysis.duplicateCount} duplicate entry/entries found; remove duplicates to avoid buyer confusion.`);
336 }
337
338
339 const resellerNoAuthority = analysis.entries.filter(
340 (e) => e.accountType === 'RESELLER' && !e.authorityId,
341 );
342 if (resellerNoAuthority.length > 0) {
343 const penalty = Math.min(15, resellerNoAuthority.length * 3);
344 score -= penalty;
345 issues.push(`${resellerNoAuthority.length} RESELLER entry/entries are missing an authority ID (TAG-ID); IAB recommends including it.`);
346 }
347
348
349 if (analysis.appAdsTxtChecked && !analysis.appAdsTxtFound) {
350 score -= 5;
351 issues.push('app-ads.txt was not found at /app-ads.txt; if the domain has a mobile app, publish app-ads.txt for in-app inventory verification.');
352 }
353
354
355 if (analysis.adsTxtFound && !analysis.https) {
356 score -= 10;
357 issues.push('ads.txt is served over HTTP, not HTTPS; buyers and crawlers may not reliably discover it.');
358 }
359
360
361 if (analysis.adsTxtFound && analysis.adsTxtContentType) {
362 const ct = analysis.adsTxtContentType.toLowerCase();
363 if (!ct.includes('text/plain') && !ct.includes('text/plain')) {
364 score -= 3;
365 issues.push(`ads.txt is served with Content-Type "${analysis.adsTxtContentType}" instead of "text/plain"; some crawlers may reject it.`);
366 }
367 }
368
369
370 const hasOwnerDomain = analysis.variables.some((v) => v.name === 'OWNERDOMAIN');
371 const hasManagerDomain = analysis.variables.some((v) => v.name === 'MANAGERDOMAIN');
372 if (hasOwnerDomain || hasManagerDomain) {
373 score = Math.min(100, score + 3);
374 }
375
376
377 if (analysis.entryCount === 0) {
378 score -= 30;
379 issues.push('ads.txt file was found but contains zero valid entries; it may be empty or contain only comments.');
380 }
381
382 const bounded = Math.max(0, Math.min(100, score));
383 const grade = bounded >= 95 ? 'A+'
384 : bounded >= 85 ? 'A'
385 : bounded >= 75 ? 'B'
386 : bounded >= 65 ? 'C'
387 : bounded >= 50 ? 'D'
388 : bounded >= 30 ? 'E'
389 : 'F';
390
391 const recommendations = buildRecommendations(analysis, issues, bounded);
392 return { score: bounded, grade, issues, recommendations };
393}
394
395function buildRecommendations(analysis, issues, score) {
396 const recs = [...issues];
397
398 if (!analysis.adsTxtFound) return recs;
399
400 if (analysis.invalidEntryCount > 0) {
401 recs.push('Fix or remove invalid entries. Each data line must be: "adSystemDomain, publisherAccountId, DIRECT|RESELLER, authorityId".');
402 }
403 if (analysis.duplicateCount > 0) {
404 recs.push('Remove duplicate entries (same adSystemDomain + publisherAccountId + accountType) to avoid buyer confusion.');
405 }
406 const resellerNoAuthority = analysis.entries.filter(
407 (e) => e.accountType === 'RESELLER' && !e.authorityId,
408 );
409 if (resellerNoAuthority.length > 0) {
410 recs.push('Add the TAG-ID authority ID to RESELLER entries to improve supply chain transparency.');
411 }
412 if (analysis.entryCount === 0) {
413 recs.push('Add at least one valid entry to the ads.txt file.');
414 }
415 if (analysis.appAdsTxtChecked && !analysis.appAdsTxtFound) {
416 recs.push('If the domain has a mobile app, publish an app-ads.txt file at the root for in-app inventory verification.');
417 }
418 if (!analysis.https) {
419 recs.push('Serve ads.txt over HTTPS to ensure reliable crawler discovery.');
420 }
421 if (score >= 95 && issues.length === 0) {
422 recs.push('ads.txt posture is strong and spec-compliant. Continue monitoring after deploys and CDN cutovers.');
423 }
424
425 return recs.length ? recs : ['No ads.txt issues detected.'];
426}
427
428
429
430
431
432export async function auditAdsTxt(input) {
433 const timeoutSeconds = Math.min(Math.max(Number(input.timeoutSeconds || DEFAULT_TIMEOUT_SECONDS), 3), 30);
434 const checkAppAdsTxt = input.checkAppAdsTxt !== false;
435
436 let startUrl;
437 try {
438 startUrl = await normalizeAndValidateUrl(input.startUrl);
439 } catch (validationError) {
440 return errorResult(input.startUrl, validationError.message);
441 }
442
443 const adsTxtUrl = new URL('/ads.txt', startUrl.origin);
444
445 let adsTxtFetched;
446 try {
447 adsTxtFetched = await fetchTextFile(adsTxtUrl, timeoutSeconds);
448 } catch (fetchError) {
449 return errorResult(input.startUrl, fetchError.message);
450 }
451
452 if (adsTxtFetched.error) {
453 return errorResult(input.startUrl, adsTxtFetched.error, adsTxtFetched.finalUrl, adsTxtFetched.https, adsTxtFetched.status);
454 }
455
456 const adsTxtFound = adsTxtFetched.ok && adsTxtFetched.body !== null;
457 const parsed = parseAdsTxt(adsTxtFetched.body);
458 const duplicates = detectDuplicates(parsed.entries);
459 const validatedEntries = parsed.entries.map(validateEntry);
460
461 const directCount = validatedEntries.filter((e) => e.accountType === 'DIRECT').length;
462 const resellerCount = validatedEntries.filter((e) => e.accountType === 'RESELLER').length;
463 const uniqueDomains = [...new Set(validatedEntries.map((e) => e.adSystemDomain))];
464
465
466 let appAdsTxtFound = false;
467 let appAdsTxtStatus = null;
468 let appAdsTxtUrl = null;
469 let appAdsTxtEntryCount = 0;
470
471 if (checkAppAdsTxt) {
472 const appAdsTxtUrlObj = new URL('/app-ads.txt', startUrl.origin);
473 appAdsTxtUrl = appAdsTxtUrlObj.href;
474 const appFetched = await fetchTextFile(appAdsTxtUrlObj, timeoutSeconds);
475 if (appFetched.ok && appFetched.body !== null) {
476 appAdsTxtFound = true;
477 appAdsTxtStatus = appFetched.status;
478 const appParsed = parseAdsTxt(appFetched.body);
479 appAdsTxtEntryCount = appParsed.entries.length;
480 } else {
481 appAdsTxtStatus = appFetched.status;
482 }
483 }
484
485 const analysis = {
486 adsTxtFound,
487 adsTxtContentType: adsTxtFetched.contentType,
488 https: adsTxtFetched.https,
489 entries: validatedEntries,
490 entryCount: validatedEntries.length,
491 invalidEntryCount: parsed.invalidEntries.length,
492 duplicateCount: duplicates.length,
493 variables: parsed.variables,
494 appAdsTxtChecked: checkAppAdsTxt,
495 appAdsTxtFound,
496 };
497
498 const scored = scoreAdsTxt(analysis);
499
500 return {
501 inputUrl: input.startUrl,
502 normalizedInputUrl: startUrl.href,
503 finalUrl: adsTxtFetched.finalUrl,
504 https: adsTxtFetched.https,
505 ok: true,
506 checkedAt: new Date().toISOString(),
507 adsTxtFound,
508 adsTxtStatus: adsTxtFetched.status,
509 adsTxtUrl: adsTxtUrl.href,
510 adsTxtContentType: adsTxtFetched.contentType,
511 adsTxtLineCount: parsed.lineCount,
512 rawAdsTxt: adsTxtFetched.body,
513 appAdsTxtFound,
514 appAdsTxtStatus,
515 appAdsTxtUrl,
516 appAdsTxtEntryCount,
517 entries: validatedEntries,
518 entryCount: validatedEntries.length,
519 directCount,
520 resellerCount,
521 commentCount: parsed.comments.length,
522 blankLineCount: parsed.blankLines,
523 duplicateCount: duplicates.length,
524 invalidEntryCount: parsed.invalidEntries.length,
525 invalidEntries: parsed.invalidEntries,
526 duplicateEntries: duplicates,
527 uniqueDomains,
528 uniqueDomainCount: uniqueDomains.length,
529 variables: parsed.variables,
530 score: scored.score,
531 grade: scored.grade,
532 issues: scored.issues,
533 recommendations: scored.recommendations,
534 error: null,
535 };
536}
537
538function errorResult(inputUrl, errorMsg, finalUrl, https, httpStatus) {
539 return {
540 inputUrl,
541 normalizedInputUrl: null,
542 finalUrl: finalUrl || (typeof inputUrl === 'string' ? inputUrl : null),
543 https: https ?? false,
544 ok: false,
545 checkedAt: new Date().toISOString(),
546 adsTxtFound: false,
547 adsTxtStatus: httpStatus ?? null,
548 adsTxtUrl: null,
549 adsTxtContentType: null,
550 adsTxtLineCount: 0,
551 rawAdsTxt: null,
552 appAdsTxtFound: false,
553 appAdsTxtStatus: null,
554 appAdsTxtUrl: null,
555 appAdsTxtEntryCount: 0,
556 entries: [],
557 entryCount: 0,
558 directCount: 0,
559 resellerCount: 0,
560 commentCount: 0,
561 blankLineCount: 0,
562 duplicateCount: 0,
563 invalidEntryCount: 0,
564 invalidEntries: [],
565 duplicateEntries: [],
566 uniqueDomains: [],
567 uniqueDomainCount: 0,
568 variables: [],
569 score: 0,
570 grade: 'F',
571 issues: ['The request failed before ads.txt could be inspected. Verify the URL is reachable and try again.'],
572 recommendations: ['Verify the URL is public, reachable, and returns a response.'],
573 error: errorMsg,
574 };
575}
576
577
578
579
580
581const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
582
583if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
584 await Actor.init();
585 try {
586 const input = await Actor.getInput();
587 const result = await auditAdsTxt(input || {});
588 await Actor.pushData(result);
589 await Actor.setValue('OUTPUT', result);
590 Actor.log.info('ads.txt audit complete', { adsTxtFound: result.adsTxtFound, score: result.score, grade: result.grade, entryCount: result.entryCount });
591 } finally {
592 await Actor.exit();
593 }
594}