@forkpoint/agent-lighthouse-core 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -68,6 +68,7 @@ __export(index_exports, {
68
68
  TAG_SKIPPED_PAGE_TYPE: () => TAG_SKIPPED_PAGE_TYPE,
69
69
  allEvidenceMet: () => allEvidenceMet,
70
70
  allJsonLdNodes: () => allJsonLdNodes,
71
+ boundedDispatcher: () => boundedDispatcher,
71
72
  buildCategoryResult: () => buildCategoryResult,
72
73
  buildScanEvidence: () => buildScanEvidence,
73
74
  calculateCategoryScore: () => calculateCategoryScore,
@@ -324,8 +325,40 @@ async function isSafeUrl(url) {
324
325
  return false;
325
326
  }
326
327
  }
327
- function createFetcher() {
328
+ function createGate(limit) {
329
+ let inFlight = 0;
330
+ const waiting = [];
331
+ const release2 = () => {
332
+ inFlight -= 1;
333
+ const next = waiting.shift();
334
+ if (next) next();
335
+ };
336
+ return {
337
+ acquire: async () => {
338
+ if (inFlight >= limit) {
339
+ await new Promise((resolve4) => waiting.push(resolve4));
340
+ }
341
+ inFlight += 1;
342
+ return release2;
343
+ }
344
+ };
345
+ }
346
+ function boundedDispatcher(connections) {
347
+ return new import_undici.Agent({ connections });
348
+ }
349
+ function createFetcher(fetcherOptions = {}) {
350
+ const dispatcher = fetcherOptions.dispatcher ?? noRedirectAgent;
351
+ const gate = fetcherOptions.maxConcurrent && fetcherOptions.maxConcurrent > 0 ? createGate(Math.floor(fetcherOptions.maxConcurrent)) : void 0;
328
352
  async function fetch(options) {
353
+ if (!gate) return issue(options);
354
+ const release2 = await gate.acquire();
355
+ try {
356
+ return await issue(options);
357
+ } finally {
358
+ release2();
359
+ }
360
+ }
361
+ async function issue(options) {
329
362
  const {
330
363
  url,
331
364
  timeout = REQUEST_TIMEOUT_MS,
@@ -365,7 +398,7 @@ function createFetcher() {
365
398
  headers: reqHeaders,
366
399
  body: currentBody,
367
400
  signal,
368
- dispatcher: noRedirectAgent
401
+ dispatcher
369
402
  });
370
403
  let gateArmed;
371
404
  let hops = 0;
@@ -413,7 +446,7 @@ function createFetcher() {
413
446
  headers: reqHeaders,
414
447
  body: currentBody,
415
448
  signal,
416
- dispatcher: noRedirectAgent
449
+ dispatcher
417
450
  });
418
451
  }
419
452
  ttfbMs = performance.now() - start;
@@ -1218,6 +1251,197 @@ function gatedMassShare(checks2) {
1218
1251
  return total === 0 ? 0 : gated / total;
1219
1252
  }
1220
1253
 
1254
+ // src/gatherers/domains.ts
1255
+ var MULTI_SUFFIX = /* @__PURE__ */ new Set([
1256
+ "co.uk",
1257
+ "org.uk",
1258
+ "ac.uk",
1259
+ "gov.uk",
1260
+ "me.uk",
1261
+ "net.uk",
1262
+ "com.au",
1263
+ "net.au",
1264
+ "org.au",
1265
+ "edu.au",
1266
+ "gov.au",
1267
+ "co.nz",
1268
+ "co.jp",
1269
+ "or.jp",
1270
+ "ne.jp",
1271
+ "co.za",
1272
+ "co.kr",
1273
+ "co.il",
1274
+ "co.id",
1275
+ "co.th",
1276
+ "com.br",
1277
+ "com.mx",
1278
+ "com.ar",
1279
+ "com.co",
1280
+ "com.pe",
1281
+ "co.in",
1282
+ "com.sg",
1283
+ "com.tr",
1284
+ "com.cn",
1285
+ "com.hk",
1286
+ "com.tw",
1287
+ "com.my",
1288
+ "com.ph",
1289
+ "com.ua",
1290
+ "com.pl",
1291
+ "com.es",
1292
+ "com.pt",
1293
+ "com.gr"
1294
+ ]);
1295
+ function registrableDomain(host) {
1296
+ const parts = host.toLowerCase().replace(/\.$/, "").split(".").filter(Boolean);
1297
+ if (parts.length <= 2) return parts.join(".");
1298
+ const lastTwo = parts.slice(-2).join(".");
1299
+ return MULTI_SUFFIX.has(lastTwo) ? parts.slice(-3).join(".") : lastTwo;
1300
+ }
1301
+ function registrableOf(url) {
1302
+ try {
1303
+ return registrableDomain(new URL(url).hostname);
1304
+ } catch {
1305
+ return "";
1306
+ }
1307
+ }
1308
+
1309
+ // src/scan-evidence.ts
1310
+ var ALL_PAGE_TYPES = ["homepage", "category", "product", "content"];
1311
+ var HTML_TYPES = ["text/html", "application/xhtml+xml"];
1312
+ var PERMANENT_REDIRECT = /* @__PURE__ */ new Set([301, 308]);
1313
+ function bareHost(url) {
1314
+ try {
1315
+ return new URL(url).hostname.toLowerCase().replace(/^www\./, "");
1316
+ } catch {
1317
+ return "";
1318
+ }
1319
+ }
1320
+ function registrableName(url) {
1321
+ const domain = registrableOf(url);
1322
+ if (!domain) return "";
1323
+ const parts = domain.split(".");
1324
+ return parts.length > 1 ? parts.slice(0, -1).join(".") : domain;
1325
+ }
1326
+ function reachedTheRequestedSite(requestedUrl, result) {
1327
+ const requested = bareHost(requestedUrl);
1328
+ const final = bareHost(result.finalUrl || result.url);
1329
+ if (!final) return { ok: false, reason: `The homepage response carried no usable URL.` };
1330
+ if (requested === final) return { ok: true };
1331
+ const requestedDomain = registrableOf(requestedUrl);
1332
+ const finalDomain = registrableOf(result.finalUrl || result.url);
1333
+ if (requestedDomain && requestedDomain === finalDomain) return { ok: true };
1334
+ const requestedName = registrableName(requestedUrl);
1335
+ if (requestedName && requestedName === registrableName(result.finalUrl || result.url)) {
1336
+ return { ok: true };
1337
+ }
1338
+ const chain = result.redirectChain ?? [];
1339
+ const leaving = chain.filter((hop) => registrableOf(hop.from) !== registrableOf(hop.to));
1340
+ if (leaving.length > 0 && leaving.every((hop) => PERMANENT_REDIRECT.has(hop.status))) {
1341
+ return { ok: true };
1342
+ }
1343
+ return {
1344
+ ok: false,
1345
+ reason: `The requested host redirected to ${final}, a different site, without a permanent redirect.`
1346
+ };
1347
+ }
1348
+ function originReachable(requestedUrl, result) {
1349
+ if (result.error) {
1350
+ return { met: false, reason: `The homepage could not be fetched: ${result.error}.` };
1351
+ }
1352
+ if (result.status < 200 || result.status > 299) {
1353
+ return { met: false, reason: `The homepage answered HTTP ${result.status}.` };
1354
+ }
1355
+ const type = (result.contentType || "").toLowerCase();
1356
+ if (!HTML_TYPES.some((html) => type.includes(html))) {
1357
+ return {
1358
+ met: false,
1359
+ reason: `The homepage served ${result.contentType || "no content type"}, not HTML.`
1360
+ };
1361
+ }
1362
+ const reached = reachedTheRequestedSite(requestedUrl, result);
1363
+ return reached.ok ? { met: true } : { met: false, reason: reached.reason };
1364
+ }
1365
+ function unblockedFetches(homepageResult, waf) {
1366
+ if (waf?.isBlocked) {
1367
+ return waf.isRateLimit ? {
1368
+ met: false,
1369
+ reason: `The scan was throttled (${waf.name}): ${waf.reason}.`
1370
+ } : { met: false, reason: `${waf.name} refused the scan: ${waf.reason}.` };
1371
+ }
1372
+ if (homepageResult.status === 429) {
1373
+ return { met: false, reason: "The homepage answered HTTP 429: the scan was throttled." };
1374
+ }
1375
+ return { met: true };
1376
+ }
1377
+ function pageRendersText(page) {
1378
+ const text3 = getRenderedText(page.$);
1379
+ const wordCount2 = text3.split(/\s+/).filter(Boolean).length;
1380
+ return wordCount2 > 50 || text3.length > 200;
1381
+ }
1382
+ function buildScanEvidence(input) {
1383
+ const origin = originReachable(input.requestedUrl, input.homepageResult);
1384
+ const unblocked = unblockedFetches(input.homepageResult, input.wafProtection);
1385
+ const renderedByPage = {};
1386
+ const usablePageTypes = /* @__PURE__ */ new Set();
1387
+ for (const page of input.pages) {
1388
+ const rendered = pageRendersText(page);
1389
+ renderedByPage[page.url] = rendered;
1390
+ if (rendered) usablePageTypes.add(page.pageType);
1391
+ }
1392
+ const renderedCount = Object.values(renderedByPage).filter(Boolean).length;
1393
+ const met = {
1394
+ "origin-reachable": origin.met,
1395
+ "unblocked-fetches": unblocked.met,
1396
+ "rendered-body": renderedCount > 0,
1397
+ "sample-adequate": usablePageTypes.size > 0
1398
+ };
1399
+ const reasons = {};
1400
+ if (origin.reason) reasons["origin-reachable"] = origin.reason;
1401
+ if (unblocked.reason) reasons["unblocked-fetches"] = unblocked.reason;
1402
+ if (!met["rendered-body"]) {
1403
+ reasons["rendered-body"] = input.pages.length === 0 ? "The scan fetched no pages." : `None of the ${input.pages.length} fetched page(s) served readable text.`;
1404
+ }
1405
+ if (!met["sample-adequate"]) {
1406
+ reasons["sample-adequate"] = input.pages.length === 0 ? "The scan fetched no pages." : "No fetched page of any type served readable text.";
1407
+ }
1408
+ return {
1409
+ met,
1410
+ reasons,
1411
+ renderedByPage,
1412
+ usablePageTypes,
1413
+ // A shell site was seen. What it serves is a finding about it, so
1414
+ // `rendered-body` and `sample-adequate` do not clear `judgeable`.
1415
+ judgeable: met["origin-reachable"] && met["unblocked-fetches"]
1416
+ };
1417
+ }
1418
+ function scanReadTheSite(evidence) {
1419
+ return evidence.judgeable;
1420
+ }
1421
+ function unreadSiteReason(evidence) {
1422
+ return evidence.reasons["origin-reachable"] ?? evidence.reasons["unblocked-fetches"] ?? "The scan obtained no response it could attribute to this site.";
1423
+ }
1424
+ function scanReadPageText(evidence) {
1425
+ return evidence.met["rendered-body"];
1426
+ }
1427
+ function unreadPageTextReason(evidence) {
1428
+ return evidence.reasons["rendered-body"] ?? "No fetched page served text a non-JS consumer can read.";
1429
+ }
1430
+ function allEvidenceMet() {
1431
+ return {
1432
+ met: {
1433
+ "origin-reachable": true,
1434
+ "unblocked-fetches": true,
1435
+ "rendered-body": true,
1436
+ "sample-adequate": true
1437
+ },
1438
+ reasons: {},
1439
+ renderedByPage: {},
1440
+ usablePageTypes: new Set(ALL_PAGE_TYPES),
1441
+ judgeable: true
1442
+ };
1443
+ }
1444
+
1221
1445
  // src/audits/access-crawl-control/no-nofollow.ts
1222
1446
  var NoNofollowAudit = class _NoNofollowAudit extends Audit {
1223
1447
  static meta = {
@@ -1231,8 +1455,9 @@ var NoNofollowAudit = class _NoNofollowAudit extends Audit {
1231
1455
  evidenceGrade: "A",
1232
1456
  tier: "scored",
1233
1457
  dossier: "docs/evidence/audits/access-crawl-control/no-nofollow.md",
1234
- // Gate exemption: being refused is what this category reports.
1235
- requires: ["origin-reachable", "rendered-body", "sample-adequate"],
1458
+ // Gate exemption: being refused is what this category reports, and the meta tag and
1459
+ // header this audit reads are served by a page whose body renders nothing.
1460
+ requires: ["origin-reachable"],
1236
1461
  defaultPriority: "high",
1237
1462
  guidance: {
1238
1463
  impact: "A nofollow directive prevents AI crawlers from following links on your pages, effectively hiding all linked content from AI indexing. Your deeper pages become invisible to AI search engines, drastically reducing discoverability.",
@@ -1244,6 +1469,13 @@ var NoNofollowAudit = class _NoNofollowAudit extends Audit {
1244
1469
  }
1245
1470
  };
1246
1471
  audit(ctx) {
1472
+ if (!scanReadTheSite(ctx.evidence)) {
1473
+ return this.notApplicable(
1474
+ "No page here can be attributed to this site, so its nofollow directives were not judged.",
1475
+ "No site-wide nofollow directives",
1476
+ unreadSiteReason(ctx.evidence)
1477
+ );
1478
+ }
1247
1479
  if (ctx.pages.length === 0) {
1248
1480
  return this.fail(
1249
1481
  "No pages scanned.",
@@ -1314,8 +1546,10 @@ var NoRedirectChainsAudit = class _NoRedirectChainsAudit extends Audit {
1314
1546
  evidenceGrade: "A",
1315
1547
  tier: "scored",
1316
1548
  dossier: "docs/evidence/audits/access-crawl-control/no-redirect-chains.md",
1317
- // Gate exemption: being refused is what this category reports.
1318
- requires: ["origin-reachable", "rendered-body", "sample-adequate"],
1549
+ // Gate exemption: a hop that left the site is this audit's subject, and leaving the
1550
+ // site is exactly what denies `origin-reachable`. It reads request URL against final
1551
+ // URL, which every response carries, and reports "no pages scanned" itself.
1552
+ requires: [],
1319
1553
  defaultPriority: "medium",
1320
1554
  guidance: {
1321
1555
  impact: "Redirect chains slow down AI crawlers and waste their limited crawl budget. Each extra redirect adds latency and increases the chance a crawler gives up before reaching the final page, leaving content unindexed.",
@@ -1326,17 +1560,6 @@ var NoRedirectChainsAudit = class _NoRedirectChainsAudit extends Audit {
1326
1560
  }
1327
1561
  };
1328
1562
  audit(ctx) {
1329
- if (ctx.pages.length === 0) {
1330
- return this.fail(
1331
- "No pages scanned.",
1332
- "No redirect chains (URL equals finalUrl or single redirect)",
1333
- "No pages scanned",
1334
- {
1335
- priority: "medium",
1336
- description: _NoRedirectChainsAudit.meta.description
1337
- }
1338
- );
1339
- }
1340
1563
  const redirected = [];
1341
1564
  for (const page of ctx.pages) {
1342
1565
  const requestUrl = page.fetchResult.url;
@@ -1346,6 +1569,24 @@ var NoRedirectChainsAudit = class _NoRedirectChainsAudit extends Audit {
1346
1569
  }
1347
1570
  }
1348
1571
  if (redirected.length === 0) {
1572
+ if (!scanReadTheSite(ctx.evidence)) {
1573
+ return this.notApplicable(
1574
+ "No page here can be attributed to this site, so its redirect behaviour was not judged.",
1575
+ "No redirect chains",
1576
+ unreadSiteReason(ctx.evidence)
1577
+ );
1578
+ }
1579
+ if (ctx.pages.length === 0) {
1580
+ return this.fail(
1581
+ "No pages scanned.",
1582
+ "No redirect chains (URL equals finalUrl or single redirect)",
1583
+ "No pages scanned",
1584
+ {
1585
+ priority: "medium",
1586
+ description: _NoRedirectChainsAudit.meta.description
1587
+ }
1588
+ );
1589
+ }
1349
1590
  return this.pass(
1350
1591
  `All ${ctx.pages.length} page(s) resolve without redirects.`,
1351
1592
  "No redirect chains",
@@ -1475,6 +1716,13 @@ var CanonicalLinksAudit = class extends Audit {
1475
1716
  };
1476
1717
  audit(ctx) {
1477
1718
  const expected = "Each page declares a canonical URL that points at itself";
1719
+ if (!scanReadTheSite(ctx.evidence)) {
1720
+ return this.notApplicable(
1721
+ "No page here can be attributed to this site, so its canonical links were not judged.",
1722
+ expected,
1723
+ unreadSiteReason(ctx.evidence)
1724
+ );
1725
+ }
1478
1726
  if (ctx.pages.length === 0) {
1479
1727
  return this.notApplicable(
1480
1728
  "No pages were scanned, so no canonical links could be read.",
@@ -2688,6 +2936,13 @@ var NoBlanketBlockAudit = class extends Audit {
2688
2936
  }
2689
2937
  };
2690
2938
  audit(ctx) {
2939
+ if (!scanReadTheSite(ctx.evidence)) {
2940
+ return this.notApplicable(
2941
+ "No response here can be attributed to this site, so its robots.txt was not judged.",
2942
+ "User-agent: * does not Disallow: / entirely",
2943
+ unreadSiteReason(ctx.evidence)
2944
+ );
2945
+ }
2691
2946
  const robotsFile = ctx.rootFiles["/robots.txt"];
2692
2947
  if (!robotsFile || robotsFile.status !== 200 || !robotsFile.body) {
2693
2948
  return this.warn(
@@ -2939,6 +3194,13 @@ var CrawlDelayAudit = class extends Audit {
2939
3194
  }
2940
3195
  };
2941
3196
  audit(ctx) {
3197
+ if (!scanReadTheSite(ctx.evidence)) {
3198
+ return this.notApplicable(
3199
+ "No response here can be attributed to this site, so its robots.txt was not judged.",
3200
+ "If Crawl-delay is present, it is <= 10 seconds",
3201
+ unreadSiteReason(ctx.evidence)
3202
+ );
3203
+ }
2942
3204
  const robotsFile = ctx.rootFiles["/robots.txt"];
2943
3205
  if (!robotsFile || robotsFile.status !== 200 || !robotsFile.body) {
2944
3206
  return this.warn(
@@ -3069,8 +3331,9 @@ var MetaRobotsNotBlockingAudit = class extends Audit {
3069
3331
  evidenceGrade: "A",
3070
3332
  tier: "scored",
3071
3333
  dossier: "docs/evidence/audits/access-crawl-control/robots-directives.md",
3072
- // Gate exemption: being refused is what this category reports.
3073
- requires: ["origin-reachable", "rendered-body", "sample-adequate"],
3334
+ // Gate exemption: being refused is what this category reports, and robots directives
3335
+ // live in the head and the headers, which arrive whether or not the body renders.
3336
+ requires: ["origin-reachable"],
3074
3337
  defaultPriority: "high",
3075
3338
  guidance: {
3076
3339
  impact: 'A content page carrying "noindex" (in a robots meta tag, a per-bot meta tag, or the X-Robots-Tag response header) is dropped from the search index, and Google documents that a page must be indexed to appear in AI Overviews or AI Mode. "nosnippet", "noarchive" and "max-snippet:0" keep the page indexed but stop its text being used as a direct input for AI answers.',
@@ -3082,6 +3345,13 @@ var MetaRobotsNotBlockingAudit = class extends Audit {
3082
3345
  }
3083
3346
  };
3084
3347
  audit(ctx) {
3348
+ if (!scanReadTheSite(ctx.evidence)) {
3349
+ return this.notApplicable(
3350
+ "No page here can be attributed to this site, so its robots directives were not judged.",
3351
+ "No blocking robots directive on content pages",
3352
+ unreadSiteReason(ctx.evidence)
3353
+ );
3354
+ }
3085
3355
  if (!ctx.pages || ctx.pages.length === 0) {
3086
3356
  return this.notApplicable(
3087
3357
  "No pages were scanned, so no robots directives could be read.",
@@ -3159,8 +3429,10 @@ var NoBotDetectionAudit = class extends Audit {
3159
3429
  evidenceGrade: "A",
3160
3430
  tier: "scored",
3161
3431
  dossier: "docs/evidence/audits/access-crawl-control/no-bot-detection.md",
3162
- // Gate exemption: being refused is what this category reports.
3163
- requires: ["origin-reachable", "rendered-body", "sample-adequate"],
3432
+ // Gate exemption: being refused is what this category reports, and this audit names
3433
+ // the firewall from `wafProtection` alone. Evidence a wall destroys is not evidence
3434
+ // the wall finding needs.
3435
+ requires: [],
3164
3436
  defaultPriority: "high",
3165
3437
  guidance: {
3166
3438
  impact: "Bot-detection services like Cloudflare Turnstile, DataDome, and reCAPTCHA can block legitimate AI agents from accessing your content. When agents are challenged, they cannot complete page fetches, making your content inaccessible to AI-powered search and assistants.",
@@ -3190,6 +3462,13 @@ var NoBotDetectionAudit = class extends Audit {
3190
3462
  }
3191
3463
  );
3192
3464
  }
3465
+ if (!scanReadTheSite(ctx.evidence)) {
3466
+ return this.notApplicable(
3467
+ "No page here can be attributed to this site, so its scripts were not judged.",
3468
+ "No JavaScript-based bot challenges that would block legitimate AI agents",
3469
+ unreadSiteReason(ctx.evidence)
3470
+ );
3471
+ }
3193
3472
  if (!ctx.pages || ctx.pages.length === 0) {
3194
3473
  return this.warn(
3195
3474
  "No pages were scanned to check for bot-detection scripts.",
@@ -3215,6 +3494,13 @@ var NoBotDetectionAudit = class extends Audit {
3215
3494
  }
3216
3495
  }
3217
3496
  if (detectedServices.size === 0) {
3497
+ if (!scanReadPageText(ctx.evidence)) {
3498
+ return this.notApplicable(
3499
+ "The scanned page served no readable text, so its scripts were not judged.",
3500
+ "No JavaScript-based bot challenges that would block legitimate AI agents",
3501
+ unreadPageTextReason(ctx.evidence)
3502
+ );
3503
+ }
3218
3504
  return this.pass(
3219
3505
  "No aggressive bot-detection scripts found on scanned pages.",
3220
3506
  "No JavaScript-based bot challenges that would block legitimate AI agents",
@@ -3337,6 +3623,13 @@ var TdmRepAudit = class extends Audit {
3337
3623
  }
3338
3624
  };
3339
3625
  audit(ctx) {
3626
+ if (!scanReadTheSite(ctx.evidence)) {
3627
+ return this.notApplicable(
3628
+ "No response here can be attributed to this site, so no TDM-Rep declaration was read.",
3629
+ EXPECTED3,
3630
+ unreadSiteReason(ctx.evidence)
3631
+ );
3632
+ }
3340
3633
  const pageUrl = ctx.pages[0]?.url;
3341
3634
  const header = readHeader(ctx);
3342
3635
  if (header) {
@@ -3624,6 +3917,13 @@ var AiContentDeclarationAudit = class extends Audit {
3624
3917
  }
3625
3918
  };
3626
3919
  audit(ctx) {
3920
+ if (!scanReadTheSite(ctx.evidence)) {
3921
+ return this.notApplicable(
3922
+ "No response here can be attributed to this site, so no AI-usage declaration was read.",
3923
+ EXPECTED4,
3924
+ unreadSiteReason(ctx.evidence)
3925
+ );
3926
+ }
3627
3927
  const found = survey(ctx);
3628
3928
  if (found.aipref) {
3629
3929
  return this.pass(
@@ -3674,8 +3974,9 @@ var HttpsEnabledAudit = class extends Audit {
3674
3974
  evidenceGrade: "A",
3675
3975
  tier: "scored",
3676
3976
  dossier: "docs/evidence/audits/access-crawl-control/https-enabled.md",
3677
- // Gate exemption: being refused is what this category reports.
3678
- requires: ["origin-reachable", "rendered-body", "sample-adequate"],
3977
+ // Gate exemption: a base URL on plain HTTP is proven by the request, with no response
3978
+ // at all, and that fail is worth reporting on a site whose homepage never answered.
3979
+ requires: [],
3679
3980
  defaultPriority: "critical",
3680
3981
  guidance: {
3681
3982
  impact: "HTTP-only sites are completely excluded from all major AI systems. GPTBot, ClaudeBot, Perplexity, and enterprise RAG pipelines refuse to connect to non-HTTPS origins due to security policies. Your entire site is invisible to AI-generated answers, product recommendations, and agentic workflows.",
@@ -3690,35 +3991,42 @@ var HttpsEnabledAudit = class extends Audit {
3690
3991
  const isHttps = ctx.baseUrl.startsWith("https://");
3691
3992
  const page = ctx.pages?.[0];
3692
3993
  const status200 = page?.fetchResult.status === 200;
3693
- if (isHttps && status200) {
3694
- return this.pass(
3695
- "Site is served over HTTPS with a valid TLS connection.",
3994
+ if (!isHttps) {
3995
+ return this.fail(
3996
+ "Site is not served over HTTPS. AI agents require secure connections.",
3696
3997
  "Base URL uses https:// and homepage returns 200",
3697
- `${ctx.baseUrl} \u2014 status ${page?.fetchResult.status}`,
3998
+ `Base URL: ${ctx.baseUrl}`,
3999
+ {
4000
+ priority: "critical",
4001
+ description: "Enterprise AI frameworks refuse to interact with non-HTTPS sites due to security policies. GPTBot, ClaudeBot, and enterprise RAG systems all skip HTTP-only sites entirely, making your content invisible to AI-generated answers. Enable HTTPS with a valid TLS certificate.",
4002
+ code: "# For nginx:\nserver {\n listen 443 ssl;\n ssl_certificate /path/to/cert.pem;\n ssl_certificate_key /path/to/key.pem;\n}"
4003
+ },
3698
4004
  page?.url
3699
4005
  );
3700
4006
  }
3701
- if (isHttps && !status200) {
3702
- return this.warn(
3703
- `Site uses HTTPS but homepage returned status ${page?.fetchResult.status ?? "unknown"}. Possible TLS or server error.`,
4007
+ if (!scanReadTheSite(ctx.evidence)) {
4008
+ return this.notApplicable(
4009
+ "No homepage here can be attributed to this site, so its transport was not judged.",
3704
4010
  "Base URL uses https:// and homepage returns 200",
3705
- `${ctx.baseUrl} \u2014 status ${page?.fetchResult.status ?? "N/A"}`,
3706
- {
3707
- priority: "high",
3708
- description: "Enterprise AI frameworks refuse to interact with sites that have TLS errors. A non-200 HTTPS response prevents AI agents from ingesting your content, effectively making your site invisible to all AI systems. Fix server or TLS configuration to return a clean 200.",
3709
- code: "# Verify TLS with: curl -vI https://yoursite.com\n# Check for certificate expiry, chain issues, or redirect loops"
3710
- },
4011
+ unreadSiteReason(ctx.evidence)
4012
+ );
4013
+ }
4014
+ if (status200) {
4015
+ return this.pass(
4016
+ "Site is served over HTTPS with a valid TLS connection.",
4017
+ "Base URL uses https:// and homepage returns 200",
4018
+ `${ctx.baseUrl} \u2014 status ${page?.fetchResult.status}`,
3711
4019
  page?.url
3712
4020
  );
3713
4021
  }
3714
- return this.fail(
3715
- "Site is not served over HTTPS. AI agents require secure connections.",
4022
+ return this.warn(
4023
+ "Site uses HTTPS and the homepage answered, but the response carried no document, so an agent has nothing to read over that connection.",
3716
4024
  "Base URL uses https:// and homepage returns 200",
3717
- `Base URL: ${ctx.baseUrl}`,
4025
+ `${ctx.baseUrl} \u2014 a 2xx response that carried no document`,
3718
4026
  {
3719
- priority: "critical",
3720
- description: "Enterprise AI frameworks refuse to interact with non-HTTPS sites due to security policies. GPTBot, ClaudeBot, and enterprise RAG systems all skip HTTP-only sites entirely, making your content invisible to AI-generated answers. Enable HTTPS with a valid TLS certificate.",
3721
- code: "# For nginx:\nserver {\n listen 443 ssl;\n ssl_certificate /path/to/cert.pem;\n ssl_certificate_key /path/to/key.pem;\n}"
4027
+ priority: "high",
4028
+ description: "The homepage answered over HTTPS and returned no document \u2014 an empty 200 body, or a 2xx status that carries none. An AI agent that follows a link to this origin receives nothing, so nothing about the site can be indexed or quoted. Check the origin, the CDN cache entry and any edge rule that can strip a response body.",
4029
+ code: "# Reproduce with:\ncurl -sSi https://yoursite.com | head -20"
3722
4030
  },
3723
4031
  page?.url
3724
4032
  );
@@ -3825,7 +4133,9 @@ var RobotsAiGroupShadowingAudit = class _RobotsAiGroupShadowingAudit extends Aud
3825
4133
  tier: "scored",
3826
4134
  dossier: "docs/evidence/audits/access-crawl-control/robots-ai-group-shadowing.md",
3827
4135
  // Gate exemption: being refused is what this category reports.
3828
- requires: ["origin-reachable", "rendered-body", "sample-adequate"],
4136
+ // Gate exemption: the verdict comes from robots.txt. The scanned pages only widen
4137
+ // the probe path set, so a shell narrows the probe and changes nothing judged.
4138
+ requires: ["origin-reachable"],
3829
4139
  defaultPriority: "high",
3830
4140
  guidance: {
3831
4141
  impact: "RFC 9309 \xA72.2.1 states the wildcard group is consulted only 'if no matching group exists'. Therefore, for any site with a named AI-bot group, the wildcard group's Disallow rules provably do not apply to that bot, and the operator's stated intent (expressed once in `*`) diverges from the enforced policy by exactly the symmetric difference of the two rule sets. Falsifiable by construction: given robots.txt R and token T, the set of paths where R_T and R_star disagree is computable and either empty or not.",
@@ -3844,6 +4154,13 @@ var RobotsAiGroupShadowingAudit = class _RobotsAiGroupShadowingAudit extends Aud
3844
4154
  };
3845
4155
  }
3846
4156
  audit(ctx) {
4157
+ if (!scanReadTheSite(ctx.evidence)) {
4158
+ return this.notApplicable(
4159
+ "No response here can be attributed to this site, so its robots groups were not judged.",
4160
+ EXPECTED5,
4161
+ unreadSiteReason(ctx.evidence)
4162
+ );
4163
+ }
3847
4164
  const robots = ctx.rootFiles["/robots.txt"];
3848
4165
  if (!robots || robots.status !== 200 || !robots.body.trim()) {
3849
4166
  return this.notApplicable(
@@ -4814,6 +5131,13 @@ var AiUsageSignalCoherenceAcrossChannelsAudit = class extends Audit {
4814
5131
  }
4815
5132
  };
4816
5133
  audit(ctx) {
5134
+ if (!scanReadTheSite(ctx.evidence)) {
5135
+ return this.notApplicable(
5136
+ "No response here can be attributed to this site, so its AI-usage channels were not compared.",
5137
+ "Every channel that carries an AI-usage signal says the same thing",
5138
+ unreadSiteReason(ctx.evidence)
5139
+ );
5140
+ }
4817
5141
  if (ctx.pages.length === 0 && (ctx.rootFiles["/robots.txt"]?.status ?? 0) !== 200) {
4818
5142
  return this.notApplicable(
4819
5143
  "The scan read no page and no robots.txt, so no channel could carry a signal.",
@@ -5028,6 +5352,13 @@ var AiprefContentUsageDeclarationValidityAudit = class extends Audit {
5028
5352
  }
5029
5353
  };
5030
5354
  audit(ctx) {
5355
+ if (!scanReadTheSite(ctx.evidence)) {
5356
+ return this.notApplicable(
5357
+ "No response here can be attributed to this site, so no Content-Usage declaration was read.",
5358
+ "Every Content-Usage declaration parses as an RFC 8941 dictionary of AIPREF categories, attaches to a crawlable path, and agrees with the other channel",
5359
+ unreadSiteReason(ctx.evidence)
5360
+ );
5361
+ }
5031
5362
  const robots = ctx.rootFiles["/robots.txt"];
5032
5363
  const robotsBody = robots?.status === 200 ? robots.body : "";
5033
5364
  const groups = robotsBody === "" ? [] : parseRobots(robotsBody);
@@ -5915,7 +6246,9 @@ var ServerResponsivenessAudit = class extends Audit {
5915
6246
  evidenceGrade: "B",
5916
6247
  tier: "scored",
5917
6248
  dossier: "docs/evidence/audits/content-extraction/server-responsiveness.md",
5918
- requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
6249
+ // Gate exemption: TTFB is measured from the response, and a shell answers as fast
6250
+ // or as slow as anything else the origin serves.
6251
+ requires: ["origin-reachable", "unblocked-fetches"],
5919
6252
  defaultPriority: "medium",
5920
6253
  guidance: {
5921
6254
  impact: 'Google documents that crawl capacity falls when a host slows down ("if the site slows down\u2026 the limit goes down and Google crawls less"), and slow origins are where logged HTTP 499 client-closed-request clusters from AI fetchers appear. A slow origin therefore gets less of its content into the indexes AI answers are drawn from.',
@@ -5934,6 +6267,13 @@ var ServerResponsivenessAudit = class extends Audit {
5934
6267
  `Blocked by ${ctx.wafProtection.name}`
5935
6268
  );
5936
6269
  }
6270
+ if (!scanReadTheSite(ctx.evidence)) {
6271
+ return this.notApplicable(
6272
+ "No page here can be attributed to this site, so its response time was not judged.",
6273
+ EXPECTED8,
6274
+ unreadSiteReason(ctx.evidence)
6275
+ );
6276
+ }
5937
6277
  const measured = ctx.pages.filter((p) => !p.fetchResult.error && p.fetchResult.status !== 0);
5938
6278
  const unmeasured = ctx.pages.length - measured.length;
5939
6279
  if (measured.length === 0) {
@@ -5994,7 +6334,8 @@ var LanguageAttributeAudit = class extends Audit {
5994
6334
  evidenceGrade: "A",
5995
6335
  tier: "scored",
5996
6336
  dossier: "docs/evidence/audits/content-extraction/language-attribute.md",
5997
- requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
6337
+ // Gate exemption: `<html lang>` is served before any body renders.
6338
+ requires: ["origin-reachable", "unblocked-fetches"],
5998
6339
  defaultPriority: "high",
5999
6340
  guidance: {
6000
6341
  impact: "AI agents use the lang attribute to select the correct language model and tokenizer when processing your content. Without it, agents may misinterpret content language, leading to poor translations or incorrect answers in multilingual AI systems.",
@@ -6006,6 +6347,13 @@ var LanguageAttributeAudit = class extends Audit {
6006
6347
  }
6007
6348
  };
6008
6349
  audit(ctx) {
6350
+ if (!scanReadTheSite(ctx.evidence)) {
6351
+ return this.notApplicable(
6352
+ "No page here can be attributed to this site, so its language attribute was not judged.",
6353
+ '<html lang="..."> with a non-empty language code',
6354
+ unreadSiteReason(ctx.evidence)
6355
+ );
6356
+ }
6009
6357
  const page = ctx.pages[0];
6010
6358
  const $ = page?.$;
6011
6359
  const lang = $?.("html").attr("lang") ?? "";
@@ -6497,6 +6845,13 @@ var SingleH1Audit = class extends Audit {
6497
6845
  }
6498
6846
  };
6499
6847
  audit(ctx) {
6848
+ if (!scanReadTheSite(ctx.evidence)) {
6849
+ return this.notApplicable(
6850
+ "No page here can be attributed to this site, so its headings were not judged.",
6851
+ "Exactly one <h1> on the homepage",
6852
+ unreadSiteReason(ctx.evidence)
6853
+ );
6854
+ }
6500
6855
  const homepage = ctx.pages[0];
6501
6856
  if (!homepage) {
6502
6857
  return this.fail(
@@ -6650,6 +7005,13 @@ var MainElementAudit = class extends Audit {
6650
7005
  }
6651
7006
  };
6652
7007
  audit(ctx) {
7008
+ if (!scanReadTheSite(ctx.evidence)) {
7009
+ return this.notApplicable(
7010
+ "No page here can be attributed to this site, so its landmarks were not judged.",
7011
+ "<main> element present on all pages",
7012
+ unreadSiteReason(ctx.evidence)
7013
+ );
7014
+ }
6653
7015
  let pagesWithMain = 0;
6654
7016
  for (const page of ctx.pages) {
6655
7017
  if (page.$("main").length > 0) pagesWithMain++;
@@ -6709,6 +7071,13 @@ var ArticleElementAudit = class extends Audit {
6709
7071
  }
6710
7072
  };
6711
7073
  audit(ctx) {
7074
+ if (!scanReadTheSite(ctx.evidence)) {
7075
+ return this.notApplicable(
7076
+ "No page here can be attributed to this site, so its landmarks were not judged.",
7077
+ "<article> elements on content pages",
7078
+ unreadSiteReason(ctx.evidence)
7079
+ );
7080
+ }
6712
7081
  let pagesWithArticle = 0;
6713
7082
  for (const page of ctx.pages) {
6714
7083
  if (page.$("article").length > 0) pagesWithArticle++;
@@ -6767,6 +7136,13 @@ var HeaderFooterAudit = class extends Audit {
6767
7136
  }
6768
7137
  };
6769
7138
  audit(ctx) {
7139
+ if (!scanReadTheSite(ctx.evidence)) {
7140
+ return this.notApplicable(
7141
+ "No page here can be attributed to this site, so its landmarks were not judged.",
7142
+ "Both <header> and <footer> present on all pages",
7143
+ unreadSiteReason(ctx.evidence)
7144
+ );
7145
+ }
6770
7146
  let pagesWithBoth = 0;
6771
7147
  let pagesWithHeader = 0;
6772
7148
  let pagesWithFooter = 0;
@@ -7193,6 +7569,13 @@ var DataTablesAudit = class extends Audit {
7193
7569
  }
7194
7570
  };
7195
7571
  audit(ctx) {
7572
+ if (!scanReadTheSite(ctx.evidence)) {
7573
+ return this.notApplicable(
7574
+ "No page here can be attributed to this site, so its tables were not judged.",
7575
+ "Tables have <thead> and <th> elements",
7576
+ unreadSiteReason(ctx.evidence)
7577
+ );
7578
+ }
7196
7579
  let totalTables = 0;
7197
7580
  let properTables = 0;
7198
7581
  for (const page of ctx.pages) {
@@ -7205,6 +7588,13 @@ var DataTablesAudit = class extends Audit {
7205
7588
  });
7206
7589
  }
7207
7590
  if (totalTables === 0) {
7591
+ if (!scanReadPageText(ctx.evidence)) {
7592
+ return this.notApplicable(
7593
+ "The scanned page served no readable text, so it held no tables to judge.",
7594
+ "Tables have <thead> and <th> elements",
7595
+ unreadPageTextReason(ctx.evidence)
7596
+ );
7597
+ }
7208
7598
  return this.pass(
7209
7599
  "No data tables found \u2014 check not applicable.",
7210
7600
  "Tables have <thead> and <th> elements",
@@ -7401,6 +7791,13 @@ var ContentDepthAudit = class extends Audit {
7401
7791
  }
7402
7792
  };
7403
7793
  audit(ctx) {
7794
+ if (!scanReadTheSite(ctx.evidence)) {
7795
+ return this.notApplicable(
7796
+ "No page here can be attributed to this site, so its content depth was not judged.",
7797
+ "More than 300 words of content per page",
7798
+ unreadSiteReason(ctx.evidence)
7799
+ );
7800
+ }
7404
7801
  let pagesAboveThreshold = 0;
7405
7802
  const wordCounts = [];
7406
7803
  for (const page of ctx.pages) {
@@ -7573,6 +7970,13 @@ var FigureFigcaptionAudit = class extends Audit {
7573
7970
  }
7574
7971
  };
7575
7972
  audit(ctx) {
7973
+ if (!scanReadTheSite(ctx.evidence)) {
7974
+ return this.notApplicable(
7975
+ "No page here can be attributed to this site, so its figures were not judged.",
7976
+ "Images with context wrapped in <figure> with <figcaption>",
7977
+ unreadSiteReason(ctx.evidence)
7978
+ );
7979
+ }
7576
7980
  let totalFigures = 0;
7577
7981
  let figuresWithCaption = 0;
7578
7982
  for (const page of ctx.pages) {
@@ -7601,6 +8005,13 @@ var FigureFigcaptionAudit = class extends Audit {
7601
8005
  }
7602
8006
  );
7603
8007
  }
8008
+ if (!scanReadPageText(ctx.evidence)) {
8009
+ return this.notApplicable(
8010
+ "The scanned page served no readable text, so it held no images or figures to judge.",
8011
+ "Images with context wrapped in <figure> with <figcaption>",
8012
+ unreadPageTextReason(ctx.evidence)
8013
+ );
8014
+ }
7604
8015
  return this.pass(
7605
8016
  "No images or <figure> elements found \u2014 check not applicable.",
7606
8017
  "Images with context wrapped in <figure> with <figcaption>",
@@ -13106,6 +13517,13 @@ var TokenRatioAudit = class extends Audit {
13106
13517
  }
13107
13518
  };
13108
13519
  audit(ctx) {
13520
+ if (!scanReadTheSite(ctx.evidence)) {
13521
+ return this.notApplicable(
13522
+ "No page here can be attributed to this site, so its token mix was not measured.",
13523
+ "A homepage from this site whose token mix can be measured",
13524
+ unreadSiteReason(ctx.evidence)
13525
+ );
13526
+ }
13109
13527
  const page = ctx.pages[0];
13110
13528
  const rawHtml = page?.fetchResult.body ?? "";
13111
13529
  if (!page || rawHtml.trim().length === 0) {
@@ -13239,6 +13657,13 @@ var FakeHeadingsAudit = class extends Audit {
13239
13657
  }
13240
13658
  };
13241
13659
  audit(ctx) {
13660
+ if (!scanReadTheSite(ctx.evidence)) {
13661
+ return this.notApplicable(
13662
+ "No page here can be attributed to this site, so its headings were not judged.",
13663
+ "All heading-like text uses semantic <h1>-<h6> elements",
13664
+ unreadSiteReason(ctx.evidence)
13665
+ );
13666
+ }
13242
13667
  const found = [];
13243
13668
  for (const page of ctx.pages) {
13244
13669
  const $ = page.$;
@@ -13262,6 +13687,13 @@ var FakeHeadingsAudit = class extends Audit {
13262
13687
  const foundSummary = found.slice(0, 5).map((f) => `${f.url}: ${describe5(f.heading)}`).join("; ");
13263
13688
  const expected = "All heading-like text uses semantic <h1>-<h6> elements";
13264
13689
  if (found.length === 0) {
13690
+ if (!scanReadPageText(ctx.evidence)) {
13691
+ return this.notApplicable(
13692
+ "The scanned page served no readable text, so it held no headings to judge.",
13693
+ expected,
13694
+ unreadPageTextReason(ctx.evidence)
13695
+ );
13696
+ }
13265
13697
  return this.pass(
13266
13698
  "No fake headings detected \u2014 heading-like text uses semantic heading elements.",
13267
13699
  expected,
@@ -13290,185 +13722,6 @@ var FakeHeadingsAudit = class extends Audit {
13290
13722
  }
13291
13723
  };
13292
13724
 
13293
- // src/gatherers/domains.ts
13294
- var MULTI_SUFFIX = /* @__PURE__ */ new Set([
13295
- "co.uk",
13296
- "org.uk",
13297
- "ac.uk",
13298
- "gov.uk",
13299
- "me.uk",
13300
- "net.uk",
13301
- "com.au",
13302
- "net.au",
13303
- "org.au",
13304
- "edu.au",
13305
- "gov.au",
13306
- "co.nz",
13307
- "co.jp",
13308
- "or.jp",
13309
- "ne.jp",
13310
- "co.za",
13311
- "co.kr",
13312
- "co.il",
13313
- "co.id",
13314
- "co.th",
13315
- "com.br",
13316
- "com.mx",
13317
- "com.ar",
13318
- "com.co",
13319
- "com.pe",
13320
- "co.in",
13321
- "com.sg",
13322
- "com.tr",
13323
- "com.cn",
13324
- "com.hk",
13325
- "com.tw",
13326
- "com.my",
13327
- "com.ph",
13328
- "com.ua",
13329
- "com.pl",
13330
- "com.es",
13331
- "com.pt",
13332
- "com.gr"
13333
- ]);
13334
- function registrableDomain(host) {
13335
- const parts = host.toLowerCase().replace(/\.$/, "").split(".").filter(Boolean);
13336
- if (parts.length <= 2) return parts.join(".");
13337
- const lastTwo = parts.slice(-2).join(".");
13338
- return MULTI_SUFFIX.has(lastTwo) ? parts.slice(-3).join(".") : lastTwo;
13339
- }
13340
- function registrableOf(url) {
13341
- try {
13342
- return registrableDomain(new URL(url).hostname);
13343
- } catch {
13344
- return "";
13345
- }
13346
- }
13347
-
13348
- // src/scan-evidence.ts
13349
- var ALL_PAGE_TYPES = ["homepage", "category", "product", "content"];
13350
- var HTML_TYPES = ["text/html", "application/xhtml+xml"];
13351
- var PERMANENT_REDIRECT = /* @__PURE__ */ new Set([301, 308]);
13352
- function bareHost(url) {
13353
- try {
13354
- return new URL(url).hostname.toLowerCase().replace(/^www\./, "");
13355
- } catch {
13356
- return "";
13357
- }
13358
- }
13359
- function registrableName(url) {
13360
- const domain = registrableOf(url);
13361
- if (!domain) return "";
13362
- const parts = domain.split(".");
13363
- return parts.length > 1 ? parts.slice(0, -1).join(".") : domain;
13364
- }
13365
- function reachedTheRequestedSite(requestedUrl, result) {
13366
- const requested = bareHost(requestedUrl);
13367
- const final = bareHost(result.finalUrl || result.url);
13368
- if (!final) return { ok: false, reason: `The homepage response carried no usable URL.` };
13369
- if (requested === final) return { ok: true };
13370
- const requestedDomain = registrableOf(requestedUrl);
13371
- const finalDomain = registrableOf(result.finalUrl || result.url);
13372
- if (requestedDomain && requestedDomain === finalDomain) return { ok: true };
13373
- const requestedName = registrableName(requestedUrl);
13374
- if (requestedName && requestedName === registrableName(result.finalUrl || result.url)) {
13375
- return { ok: true };
13376
- }
13377
- const chain = result.redirectChain ?? [];
13378
- const leaving = chain.filter((hop) => registrableOf(hop.from) !== registrableOf(hop.to));
13379
- if (leaving.length > 0 && leaving.every((hop) => PERMANENT_REDIRECT.has(hop.status))) {
13380
- return { ok: true };
13381
- }
13382
- return {
13383
- ok: false,
13384
- reason: `The requested host redirected to ${final}, a different site, without a permanent redirect.`
13385
- };
13386
- }
13387
- function originReachable(requestedUrl, result) {
13388
- if (result.error) {
13389
- return { met: false, reason: `The homepage could not be fetched: ${result.error}.` };
13390
- }
13391
- if (result.status < 200 || result.status > 299) {
13392
- return { met: false, reason: `The homepage answered HTTP ${result.status}.` };
13393
- }
13394
- const type = (result.contentType || "").toLowerCase();
13395
- if (!HTML_TYPES.some((html) => type.includes(html))) {
13396
- return {
13397
- met: false,
13398
- reason: `The homepage served ${result.contentType || "no content type"}, not HTML.`
13399
- };
13400
- }
13401
- const reached = reachedTheRequestedSite(requestedUrl, result);
13402
- return reached.ok ? { met: true } : { met: false, reason: reached.reason };
13403
- }
13404
- function unblockedFetches(homepageResult, waf) {
13405
- if (waf?.isBlocked) {
13406
- return waf.isRateLimit ? {
13407
- met: false,
13408
- reason: `The scan was throttled (${waf.name}): ${waf.reason}.`
13409
- } : { met: false, reason: `${waf.name} refused the scan: ${waf.reason}.` };
13410
- }
13411
- if (homepageResult.status === 429) {
13412
- return { met: false, reason: "The homepage answered HTTP 429: the scan was throttled." };
13413
- }
13414
- return { met: true };
13415
- }
13416
- function pageRendersText(page) {
13417
- const text3 = getRenderedText(page.$);
13418
- const wordCount2 = text3.split(/\s+/).filter(Boolean).length;
13419
- return wordCount2 > 50 || text3.length > 200;
13420
- }
13421
- function buildScanEvidence(input) {
13422
- const origin = originReachable(input.requestedUrl, input.homepageResult);
13423
- const unblocked = unblockedFetches(input.homepageResult, input.wafProtection);
13424
- const renderedByPage = {};
13425
- const usablePageTypes = /* @__PURE__ */ new Set();
13426
- for (const page of input.pages) {
13427
- const rendered = pageRendersText(page);
13428
- renderedByPage[page.url] = rendered;
13429
- if (rendered) usablePageTypes.add(page.pageType);
13430
- }
13431
- const renderedCount = Object.values(renderedByPage).filter(Boolean).length;
13432
- const met = {
13433
- "origin-reachable": origin.met,
13434
- "unblocked-fetches": unblocked.met,
13435
- "rendered-body": renderedCount > 0,
13436
- "sample-adequate": usablePageTypes.size > 0
13437
- };
13438
- const reasons = {};
13439
- if (origin.reason) reasons["origin-reachable"] = origin.reason;
13440
- if (unblocked.reason) reasons["unblocked-fetches"] = unblocked.reason;
13441
- if (!met["rendered-body"]) {
13442
- reasons["rendered-body"] = input.pages.length === 0 ? "The scan fetched no pages." : `None of the ${input.pages.length} fetched page(s) served readable text.`;
13443
- }
13444
- if (!met["sample-adequate"]) {
13445
- reasons["sample-adequate"] = input.pages.length === 0 ? "The scan fetched no pages." : "No fetched page of any type served readable text.";
13446
- }
13447
- return {
13448
- met,
13449
- reasons,
13450
- renderedByPage,
13451
- usablePageTypes,
13452
- // A shell site was seen. What it serves is a finding about it, so
13453
- // `rendered-body` and `sample-adequate` do not clear `judgeable`.
13454
- judgeable: met["origin-reachable"] && met["unblocked-fetches"]
13455
- };
13456
- }
13457
- function allEvidenceMet() {
13458
- return {
13459
- met: {
13460
- "origin-reachable": true,
13461
- "unblocked-fetches": true,
13462
- "rendered-body": true,
13463
- "sample-adequate": true
13464
- },
13465
- reasons: {},
13466
- renderedByPage: {},
13467
- usablePageTypes: new Set(ALL_PAGE_TYPES),
13468
- judgeable: true
13469
- };
13470
- }
13471
-
13472
13725
  // src/audits/content-extraction/server-rendered.ts
13473
13726
  function withDetails(result, details) {
13474
13727
  return { ...result, details: { ...result.details ?? {}, ...details } };
@@ -13498,6 +13751,13 @@ var ServerRenderedAudit = class extends Audit {
13498
13751
  }
13499
13752
  };
13500
13753
  audit(ctx) {
13754
+ if (!scanReadTheSite(ctx.evidence)) {
13755
+ return this.notApplicable(
13756
+ "No page here can be attributed to this site, so its served HTML was not judged.",
13757
+ "Every fetched page serves > 50 words or > 200 characters of readable text",
13758
+ unreadSiteReason(ctx.evidence)
13759
+ );
13760
+ }
13501
13761
  const pages = ctx.pages ?? [];
13502
13762
  if (pages.length === 0) {
13503
13763
  return this.notApplicable(
@@ -13812,6 +14072,13 @@ var CssHiddenGhostContentAudit = class _CssHiddenGhostContentAudit extends Audit
13812
14072
  };
13813
14073
  }
13814
14074
  async audit(ctx) {
14075
+ if (!scanReadTheSite(ctx.evidence)) {
14076
+ return this.notApplicable(
14077
+ "No page here can be attributed to this site, so its hidden text was not measured.",
14078
+ EXPECTED11,
14079
+ unreadSiteReason(ctx.evidence)
14080
+ );
14081
+ }
13815
14082
  const s = await survey2(ctx);
13816
14083
  const partial = s.crossOrigin > 0 ? `; ${s.crossOrigin} cross-origin stylesheet not fetched` : "";
13817
14084
  if (s.totalChars === 0) {
@@ -14148,6 +14415,13 @@ var PreambleTaxTokensBeforeTheFirstContentTokenAudit = class extends Audit {
14148
14415
  }
14149
14416
  };
14150
14417
  audit(ctx) {
14418
+ if (!scanReadTheSite(ctx.evidence)) {
14419
+ return this.notApplicable(
14420
+ "No page here can be attributed to this site, so its preamble was not measured.",
14421
+ "A page from this site whose preamble can be measured",
14422
+ unreadSiteReason(ctx.evidence)
14423
+ );
14424
+ }
14151
14425
  const page = ctx.pages[0];
14152
14426
  if (!page) {
14153
14427
  return this.notApplicable(
@@ -14408,6 +14682,13 @@ var ExtractionDeterminismAudit = class extends Audit {
14408
14682
  }
14409
14683
  };
14410
14684
  audit(ctx) {
14685
+ if (!scanReadTheSite(ctx.evidence)) {
14686
+ return this.notApplicable(
14687
+ "No page here can be attributed to this site, so no extraction could be compared.",
14688
+ "A page from this site whose extraction can be compared",
14689
+ unreadSiteReason(ctx.evidence)
14690
+ );
14691
+ }
14411
14692
  const page = ctx.pages[0];
14412
14693
  if (!page) {
14413
14694
  return this.notApplicable(
@@ -14911,6 +15192,13 @@ var LlmsFullTxtAudit = class extends Audit {
14911
15192
  }
14912
15193
  };
14913
15194
  audit(ctx) {
15195
+ if (!scanReadTheSite(ctx.evidence)) {
15196
+ return this.notApplicable(
15197
+ "No response here can be attributed to this site, so no llms-full.txt was judged.",
15198
+ "GET /llms-full.txt returns 200",
15199
+ unreadSiteReason(ctx.evidence)
15200
+ );
15201
+ }
14914
15202
  const result = ctx.rootFiles["/llms-full.txt"];
14915
15203
  if (!result || !isOk5(result)) {
14916
15204
  return this.fail(
@@ -15494,6 +15782,13 @@ var RssFeedAudit = class extends Audit {
15494
15782
  }
15495
15783
  };
15496
15784
  async audit(ctx) {
15785
+ if (!scanReadTheSite(ctx.evidence)) {
15786
+ return this.notApplicable(
15787
+ "No response here can be attributed to this site, so no feed was judged.",
15788
+ "Feed returns HTTP 200",
15789
+ unreadSiteReason(ctx.evidence)
15790
+ );
15791
+ }
15497
15792
  const links = autodiscoveryLinks(ctx);
15498
15793
  const feed = await findFeedResult(ctx, links);
15499
15794
  const linkNote = links.length > 0 ? `autodiscovery <link> present (${links[0].url})` : "no autodiscovery <link> in <head>";
@@ -16201,6 +16496,15 @@ var NoBrokenAiEndpointsAudit = class extends Audit {
16201
16496
  for (const url of allUrls) {
16202
16497
  if (await isSafeUrl(url)) urls.push(url);
16203
16498
  }
16499
+ if (urls.length === 0) {
16500
+ return this.warn(
16501
+ `${allUrls.length} AI endpoint URL(s) are listed, and none of them could be requested: each names localhost, a private address, or a host that does not resolve.`,
16502
+ "All URLs from AI-related files return 200",
16503
+ `${allUrls.length} URL(s) listed, 0 reachable to check`,
16504
+ void 0,
16505
+ page?.url
16506
+ );
16507
+ }
16204
16508
  const results = await Promise.all(
16205
16509
  urls.map(async (url) => {
16206
16510
  try {
@@ -20470,6 +20774,13 @@ var UniqueMetaAudit = class extends Audit {
20470
20774
  }
20471
20775
  };
20472
20776
  audit(ctx) {
20777
+ if (!scanReadTheSite(ctx.evidence)) {
20778
+ return this.notApplicable(
20779
+ "No page here can be attributed to this site, so its metadata was not judged.",
20780
+ "Each page has a unique title + description combination",
20781
+ unreadSiteReason(ctx.evidence)
20782
+ );
20783
+ }
20473
20784
  const canonicalGroups = /* @__PURE__ */ new Map();
20474
20785
  for (const page of ctx.pages) {
20475
20786
  let canon = (page.meta?.["canonical"] || page.url).trim();
@@ -20486,7 +20797,7 @@ var UniqueMetaAudit = class extends Audit {
20486
20797
  }
20487
20798
  const uniquePages = Array.from(canonicalGroups.values());
20488
20799
  if (uniquePages.length < 2) {
20489
- return this.pass(
20800
+ return this.notApplicable(
20490
20801
  "Only one distinct canonical page scanned; uniqueness check not applicable.",
20491
20802
  "Each page has a unique title + description combination",
20492
20803
  "1 distinct page scanned"
@@ -21542,6 +21853,13 @@ var ContentWithoutClickthroughAudit = class _ContentWithoutClickthroughAudit ext
21542
21853
  }
21543
21854
  };
21544
21855
  audit(ctx) {
21856
+ if (!scanReadTheSite(ctx.evidence)) {
21857
+ return this.notApplicable(
21858
+ "No page here can be attributed to this site, so its teasers were not judged.",
21859
+ 'No "click to read more" or "contact us to learn" teasers dominating the page',
21860
+ unreadSiteReason(ctx.evidence)
21861
+ );
21862
+ }
21545
21863
  const page = ctx.pages[0];
21546
21864
  if (!page) {
21547
21865
  return this.fail(
@@ -21598,6 +21916,13 @@ var ContentWithoutClickthroughAudit = class _ContentWithoutClickthroughAudit ext
21598
21916
  );
21599
21917
  }
21600
21918
  }
21919
+ if (!scanReadPageText(ctx.evidence)) {
21920
+ return this.notApplicable(
21921
+ "The scanned page served no readable text, so there was no content to judge for teasers.",
21922
+ 'No "click to read more" or "contact us to learn" teasers dominating the page',
21923
+ unreadPageTextReason(ctx.evidence)
21924
+ );
21925
+ }
21601
21926
  return this.pass(
21602
21927
  "No excessive click-through teasers found.",
21603
21928
  'No "click to read more" or "contact us to learn" teasers dominating the page',
@@ -23057,7 +23382,8 @@ var DescriptiveUrlsAudit = class extends Audit {
23057
23382
  evidenceGrade: "C",
23058
23383
  tier: "informative",
23059
23384
  dossier: "docs/evidence/audits/answer-readiness/descriptive-urls.md",
23060
- requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
23385
+ // Gate exemption: a URL is readable whether or not the page behind it rendered text.
23386
+ requires: ["origin-reachable", "unblocked-fetches"],
23061
23387
  defaultPriority: "high",
23062
23388
  guidance: {
23063
23389
  impact: "AI engines use URL text as a pre-fetch topic signal and display URLs in generated citations. Non-descriptive slugs with UUIDs or numeric IDs provide no topical context, reducing your content's relevance score before the page is even crawled.",
@@ -23068,6 +23394,13 @@ var DescriptiveUrlsAudit = class extends Audit {
23068
23394
  }
23069
23395
  };
23070
23396
  audit(ctx) {
23397
+ if (!scanReadTheSite(ctx.evidence)) {
23398
+ return this.notApplicable(
23399
+ "No page here can be attributed to this site, so its URLs were not judged.",
23400
+ "Page URLs use readable slugs (no UUIDs, no /post-123/, no encoded params)",
23401
+ unreadSiteReason(ctx.evidence)
23402
+ );
23403
+ }
23071
23404
  const page = ctx.pages[0];
23072
23405
  if (!page) {
23073
23406
  return this.fail(
@@ -23332,6 +23665,13 @@ var SnippetGateCoverageAudit = class _SnippetGateCoverageAudit extends Audit {
23332
23665
  };
23333
23666
  }
23334
23667
  audit(ctx) {
23668
+ if (!scanReadTheSite(ctx.evidence)) {
23669
+ return this.notApplicable(
23670
+ "No page here can be attributed to this site, so snippet permissions were not resolved.",
23671
+ EXPECTED22,
23672
+ unreadSiteReason(ctx.evidence)
23673
+ );
23674
+ }
23335
23675
  const page = ctx.pages[0];
23336
23676
  if (!page) {
23337
23677
  return this.notApplicable(
@@ -23806,6 +24146,13 @@ function chainOf($, el) {
23806
24146
  const parents = $(el).parents().toArray().filter((parent) => !["html", "body"].includes(parent.tagName)).reverse().map(describe3);
23807
24147
  return [...parents, describe3(el)].join(" > ");
23808
24148
  }
24149
+ function lastElementContaining($, text3) {
24150
+ const all = $("*").toArray();
24151
+ for (let i = all.length - 1; i >= 0; i--) {
24152
+ if ($(all[i]).text().includes(text3)) return all[i];
24153
+ }
24154
+ return void 0;
24155
+ }
23809
24156
  function needleOf(text3) {
23810
24157
  return normalizeText(text3).split(" ").slice(0, SPAN_WORDS).join(" ");
23811
24158
  }
@@ -23838,8 +24185,8 @@ function keySpans(page) {
23838
24185
  if (typeof value === "string") {
23839
24186
  const needle = needleOf(value);
23840
24187
  if (needle.split(" ").length >= 3 && bodyText.includes(needle)) {
23841
- const host = $(`:contains("${value.slice(0, 40).replace(/"/g, "")}")`).last();
23842
- push("json-ld", host[0] ?? $("body")[0], value);
24188
+ const host = lastElementContaining($, value.slice(0, 40));
24189
+ push("json-ld", host ?? $("body")[0], value);
23843
24190
  }
23844
24191
  return;
23845
24192
  }
@@ -23883,6 +24230,13 @@ var ExtractorSurvivalRecallAudit = class extends Audit {
23883
24230
  }
23884
24231
  };
23885
24232
  audit(ctx) {
24233
+ if (!scanReadTheSite(ctx.evidence)) {
24234
+ return this.notApplicable(
24235
+ "No page here can be attributed to this site, so no key span was measured.",
24236
+ "A page from this site whose key spans can be measured",
24237
+ unreadSiteReason(ctx.evidence)
24238
+ );
24239
+ }
23886
24240
  const page = ctx.pages[0];
23887
24241
  if (!page) {
23888
24242
  return this.notApplicable(
@@ -31320,8 +31674,10 @@ var NoBlockingCaptchaAudit = class _NoBlockingCaptchaAudit extends Audit {
31320
31674
  evidenceGrade: "A",
31321
31675
  tier: "scored",
31322
31676
  dossier: "docs/evidence/audits/operability-safety/no-blocking-captcha.md",
31323
- // Gate exemption: A captcha wall is what this audit reports.
31324
- requires: ["origin-reachable"],
31677
+ // Gate exemption: a captcha wall is what this audit reports, and a wall denies
31678
+ // `origin-reachable` — gating on it made the finding unreachable for the 403 that
31679
+ // produced it. The wall branch reads `wafProtection`, not any response body.
31680
+ requires: [],
31325
31681
  defaultPriority: "high",
31326
31682
  guidance: {
31327
31683
  impact: 'Blocking CAPTCHAs completely prevent AI agents from submitting forms on behalf of users. When a user asks an agent to "fill out the contact form," the CAPTCHA blocks the action entirely, forcing the user to do it manually or go to a competitor.',
@@ -31354,6 +31710,13 @@ var NoBlockingCaptchaAudit = class _NoBlockingCaptchaAudit extends Audit {
31354
31710
  ctx.baseUrl
31355
31711
  );
31356
31712
  }
31713
+ if (!scanReadTheSite(ctx.evidence)) {
31714
+ return this.notApplicable(
31715
+ "No page here can be attributed to this site, so no form was inspected for a CAPTCHA.",
31716
+ "No recaptcha, hcaptcha, or turnstile script includes detected",
31717
+ unreadSiteReason(ctx.evidence)
31718
+ );
31719
+ }
31357
31720
  if (ctx.pages.length === 0) {
31358
31721
  return this.notApplicable(
31359
31722
  "No page was fetched, so no form could be inspected for a blocking CAPTCHA.",
@@ -31371,6 +31734,13 @@ var NoBlockingCaptchaAudit = class _NoBlockingCaptchaAudit extends Audit {
31371
31734
  }
31372
31735
  }
31373
31736
  if (detectedCaptchas.length === 0) {
31737
+ if (!scanReadPageText(ctx.evidence)) {
31738
+ return this.notApplicable(
31739
+ "The scanned page served no readable text, so no form was inspected for a CAPTCHA.",
31740
+ "No recaptcha, hcaptcha, or turnstile script includes detected",
31741
+ unreadPageTextReason(ctx.evidence)
31742
+ );
31743
+ }
31374
31744
  return this.pass(
31375
31745
  "No blocking CAPTCHA scripts detected on scanned pages.",
31376
31746
  "No recaptcha, hcaptcha, or turnstile script includes detected",
@@ -33208,6 +33578,13 @@ var InvisibleInstructionScanAudit = class _InvisibleInstructionScanAudit extends
33208
33578
  };
33209
33579
  }
33210
33580
  async audit(ctx) {
33581
+ if (!scanReadTheSite(ctx.evidence)) {
33582
+ return this.notApplicable(
33583
+ "No page here can be attributed to this site, so its hidden text was not judged.",
33584
+ EXPECTED50,
33585
+ unreadSiteReason(ctx.evidence)
33586
+ );
33587
+ }
33211
33588
  const s = await survey9(ctx);
33212
33589
  const partial = s.crossOrigin > 0 ? `; ${s.crossOrigin} cross-origin stylesheet not fetched` : "";
33213
33590
  if (s.textNodesSeen === 0) {
@@ -33485,6 +33862,13 @@ var AriaLayerInjectionScanAudit = class _AriaLayerInjectionScanAudit extends Aud
33485
33862
  };
33486
33863
  }
33487
33864
  audit(ctx) {
33865
+ if (!scanReadTheSite(ctx.evidence)) {
33866
+ return this.notApplicable(
33867
+ "No page here can be attributed to this site, so its non-visual values were not judged.",
33868
+ EXPECTED51,
33869
+ unreadSiteReason(ctx.evidence)
33870
+ );
33871
+ }
33488
33872
  const s = survey10(ctx);
33489
33873
  if (s.valuesSeen === 0) {
33490
33874
  return this.notApplicable(
@@ -33530,6 +33914,13 @@ var AriaLayerInjectionScanAudit = class _AriaLayerInjectionScanAudit extends Aud
33530
33914
  warnings[0].pageUrl
33531
33915
  );
33532
33916
  }
33917
+ if (!scanReadPageText(ctx.evidence)) {
33918
+ return this.notApplicable(
33919
+ "The scanned page served no readable text, so its accessibility layer was not judged.",
33920
+ EXPECTED51,
33921
+ unreadPageTextReason(ctx.evidence)
33922
+ );
33923
+ }
33533
33924
  return this.pass(
33534
33925
  `All ${s.valuesSeen} non-visual value(s) are descriptions that agree with their element and carry no instruction addressed to an AI.`,
33535
33926
  EXPECTED51,
@@ -33649,6 +34040,13 @@ var GhostClickableElementRatioAudit = class _GhostClickableElementRatioAudit ext
33649
34040
  };
33650
34041
  }
33651
34042
  async audit(ctx) {
34043
+ if (!scanReadTheSite(ctx.evidence)) {
34044
+ return this.notApplicable(
34045
+ "No page here can be attributed to this site, so its click targets were not counted.",
34046
+ EXPECTED52,
34047
+ unreadSiteReason(ctx.evidence)
34048
+ );
34049
+ }
33652
34050
  const s = await survey11(ctx);
33653
34051
  const total = s.semantic + s.ghosts.length;
33654
34052
  const partial = s.crossOrigin > 0 ? `; ${s.crossOrigin} cross-origin stylesheet not fetched` : "";
@@ -35013,6 +35411,13 @@ var UnicodeCovertChannelScanAudit = class _UnicodeCovertChannelScanAudit extends
35013
35411
  };
35014
35412
  }
35015
35413
  audit(ctx) {
35414
+ if (!scanReadTheSite(ctx.evidence)) {
35415
+ return this.notApplicable(
35416
+ "No response here can be attributed to this site, so its codepoints were not judged.",
35417
+ EXPECTED58,
35418
+ unreadSiteReason(ctx.evidence)
35419
+ );
35420
+ }
35016
35421
  const hits2 = [];
35017
35422
  for (const page of ctx.pages) hits2.push(...scanPage(page));
35018
35423
  for (const path of ROOT_FILES) {
@@ -35039,6 +35444,16 @@ var UnicodeCovertChannelScanAudit = class _UnicodeCovertChannelScanAudit extends
35039
35444
  fillerCount: filler
35040
35445
  };
35041
35446
  if (hits2.length === 0) {
35447
+ if (!scanReadPageText(ctx.evidence)) {
35448
+ return {
35449
+ ...this.notApplicable(
35450
+ "The scanned page served no readable text, so its codepoints were not judged.",
35451
+ EXPECTED58,
35452
+ unreadPageTextReason(ctx.evidence)
35453
+ ),
35454
+ details
35455
+ };
35456
+ }
35042
35457
  return {
35043
35458
  ...this.pass(
35044
35459
  "No invisible codepoint carries text on the scanned pages or in the root files.",
@@ -35234,7 +35649,10 @@ var ThirdPartyDomWriteBlastRadiusAudit = class _ThirdPartyDomWriteBlastRadiusAud
35234
35649
  evidenceGrade: "B",
35235
35650
  tier: "scored",
35236
35651
  dossier: "docs/evidence/audits/operability-safety/third-party-dom-write-blast-radius.md",
35237
- requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
35652
+ // Gate exemption: every origin the served HTML names is counted whether or not the
35653
+ // body renders, so a page that ships a vendor script statically is still reported.
35654
+ // The empty census is the case a shell cannot support, and `audit()` declines it.
35655
+ requires: ["origin-reachable", "unblocked-fetches"],
35238
35656
  defaultPriority: "high",
35239
35657
  guidance: {
35240
35658
  impact: 'An agent reads the DOM as one document with one level of trust. It has no way to tell text the site wrote from text a vendor script injected after load, so every third-party origin that can write to the page can write instructions the agent will read as the site\'s own. The count is the risk: eleven uncontrolled origins is eleven independent companies \u2014 and their own supply chains \u2014 with the same authority over what an agent believes about the site. A Content-Security-Policy with a nonce, a hash or `strict-dynamic` is what turns that list from "whoever" into "these, and only these". A policy whose sources include `https:` or `*` is present in the response and constrains nothing.',
@@ -35253,6 +35671,13 @@ var ThirdPartyDomWriteBlastRadiusAudit = class _ThirdPartyDomWriteBlastRadiusAud
35253
35671
  };
35254
35672
  }
35255
35673
  audit(ctx) {
35674
+ if (!scanReadTheSite(ctx.evidence)) {
35675
+ return this.notApplicable(
35676
+ "No page here can be attributed to this site, so its third-party surface was not measured.",
35677
+ EXPECTED59,
35678
+ unreadSiteReason(ctx.evidence)
35679
+ );
35680
+ }
35256
35681
  if (ctx.pages.length === 0) {
35257
35682
  return this.notApplicable(
35258
35683
  "No page was fetched, so there is no third-party surface to measure.",
@@ -35292,6 +35717,17 @@ var ThirdPartyDomWriteBlastRadiusAudit = class _ThirdPartyDomWriteBlastRadiusAud
35292
35717
  details
35293
35718
  };
35294
35719
  }
35720
+ if (!scanReadPageText(ctx.evidence)) {
35721
+ return {
35722
+ ...this.notApplicable(
35723
+ "The scanned page served no readable text, so the origins writing into it were not counted.",
35724
+ EXPECTED59,
35725
+ unreadPageTextReason(ctx.evidence)
35726
+ ),
35727
+ displayValue: found,
35728
+ details
35729
+ };
35730
+ }
35295
35731
  return {
35296
35732
  ...this.pass(
35297
35733
  "No third-party origin ships executable code into the page, so nothing but the site itself writes what an agent reads.",
@@ -35449,6 +35885,13 @@ var UnsafeAgentTriggerableAffordancesAudit = class _UnsafeAgentTriggerableAfford
35449
35885
  };
35450
35886
  }
35451
35887
  audit(ctx) {
35888
+ if (!scanReadTheSite(ctx.evidence)) {
35889
+ return this.notApplicable(
35890
+ "No page here can be attributed to this site, so its links were not inspected.",
35891
+ EXPECTED60,
35892
+ unreadSiteReason(ctx.evidence)
35893
+ );
35894
+ }
35452
35895
  if (ctx.pages.length === 0) {
35453
35896
  return this.notApplicable(
35454
35897
  "No page was fetched, so there is no link to inspect.",
@@ -35467,6 +35910,16 @@ var UnsafeAgentTriggerableAffordancesAudit = class _UnsafeAgentTriggerableAfford
35467
35910
  urls: findings.slice(0, 10).map((f) => f.href)
35468
35911
  };
35469
35912
  if (findings.length === 0) {
35913
+ if (!scanReadPageText(ctx.evidence)) {
35914
+ return {
35915
+ ...this.notApplicable(
35916
+ "The scanned page served no readable text, so it exposed no links or forms to inspect.",
35917
+ EXPECTED60,
35918
+ unreadPageTextReason(ctx.evidence)
35919
+ ),
35920
+ details
35921
+ };
35922
+ }
35470
35923
  return {
35471
35924
  ...this.pass(
35472
35925
  "No link or GET form on the scanned pages changes state when it is fetched.",
@@ -37982,7 +38435,10 @@ async function runScan(url, options) {
37982
38435
  const signal = options?.signal;
37983
38436
  const tracker = new ProgressTracker((event) => onEvent?.(event));
37984
38437
  const start = performance.now();
37985
- const fetcher = createFetcher();
38438
+ const fetcher = createFetcher({
38439
+ dispatcher: options?.dispatcher,
38440
+ maxConcurrent: options?.maxConcurrent
38441
+ });
37986
38442
  const baseUrl = new URL(url).origin;
37987
38443
  const domain = new URL(url).hostname;
37988
38444
  const displayUrl = splitCredentials(url).url;
@@ -38040,13 +38496,18 @@ async function runScan(url, options) {
38040
38496
  ];
38041
38497
  logger.debug({ count: rootFilePaths.length }, "[orchestrator] Phase 1: Fetching root files");
38042
38498
  tracker.phaseStart("fetch-root", rootFilePaths.length);
38499
+ const prefetchedRobots = options?.robotsTxt;
38043
38500
  const rootResults = await Promise.all(
38044
- rootFilePaths.map(
38045
- (path) => fetcher.fetch({ url: `${baseUrl}${path}`, signal }).then((result) => {
38501
+ rootFilePaths.map((path) => {
38502
+ if (path === "/robots.txt" && prefetchedRobots) {
38503
+ tracker.unitDone(path);
38504
+ return Promise.resolve(prefetchedRobots);
38505
+ }
38506
+ return fetcher.fetch({ url: `${baseUrl}${path}`, signal }).then((result) => {
38046
38507
  tracker.unitDone(path);
38047
38508
  return result;
38048
- })
38049
- )
38509
+ });
38510
+ })
38050
38511
  );
38051
38512
  const rootFiles = {};
38052
38513
  rootFilePaths.forEach((path, i) => {
@@ -38398,6 +38859,7 @@ function loadConfigFile(customPath) {
38398
38859
  TAG_SKIPPED_PAGE_TYPE,
38399
38860
  allEvidenceMet,
38400
38861
  allJsonLdNodes,
38862
+ boundedDispatcher,
38401
38863
  buildCategoryResult,
38402
38864
  buildScanEvidence,
38403
38865
  calculateCategoryScore,