@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.mjs CHANGED
@@ -182,8 +182,40 @@ async function isSafeUrl(url) {
182
182
  return false;
183
183
  }
184
184
  }
185
- function createFetcher() {
185
+ function createGate(limit) {
186
+ let inFlight = 0;
187
+ const waiting = [];
188
+ const release2 = () => {
189
+ inFlight -= 1;
190
+ const next = waiting.shift();
191
+ if (next) next();
192
+ };
193
+ return {
194
+ acquire: async () => {
195
+ if (inFlight >= limit) {
196
+ await new Promise((resolve4) => waiting.push(resolve4));
197
+ }
198
+ inFlight += 1;
199
+ return release2;
200
+ }
201
+ };
202
+ }
203
+ function boundedDispatcher(connections) {
204
+ return new Agent({ connections });
205
+ }
206
+ function createFetcher(fetcherOptions = {}) {
207
+ const dispatcher = fetcherOptions.dispatcher ?? noRedirectAgent;
208
+ const gate = fetcherOptions.maxConcurrent && fetcherOptions.maxConcurrent > 0 ? createGate(Math.floor(fetcherOptions.maxConcurrent)) : void 0;
186
209
  async function fetch(options) {
210
+ if (!gate) return issue(options);
211
+ const release2 = await gate.acquire();
212
+ try {
213
+ return await issue(options);
214
+ } finally {
215
+ release2();
216
+ }
217
+ }
218
+ async function issue(options) {
187
219
  const {
188
220
  url,
189
221
  timeout = REQUEST_TIMEOUT_MS,
@@ -223,7 +255,7 @@ function createFetcher() {
223
255
  headers: reqHeaders,
224
256
  body: currentBody,
225
257
  signal,
226
- dispatcher: noRedirectAgent
258
+ dispatcher
227
259
  });
228
260
  let gateArmed;
229
261
  let hops = 0;
@@ -271,7 +303,7 @@ function createFetcher() {
271
303
  headers: reqHeaders,
272
304
  body: currentBody,
273
305
  signal,
274
- dispatcher: noRedirectAgent
306
+ dispatcher
275
307
  });
276
308
  }
277
309
  ttfbMs = performance.now() - start;
@@ -1076,6 +1108,197 @@ function gatedMassShare(checks2) {
1076
1108
  return total === 0 ? 0 : gated / total;
1077
1109
  }
1078
1110
 
1111
+ // src/gatherers/domains.ts
1112
+ var MULTI_SUFFIX = /* @__PURE__ */ new Set([
1113
+ "co.uk",
1114
+ "org.uk",
1115
+ "ac.uk",
1116
+ "gov.uk",
1117
+ "me.uk",
1118
+ "net.uk",
1119
+ "com.au",
1120
+ "net.au",
1121
+ "org.au",
1122
+ "edu.au",
1123
+ "gov.au",
1124
+ "co.nz",
1125
+ "co.jp",
1126
+ "or.jp",
1127
+ "ne.jp",
1128
+ "co.za",
1129
+ "co.kr",
1130
+ "co.il",
1131
+ "co.id",
1132
+ "co.th",
1133
+ "com.br",
1134
+ "com.mx",
1135
+ "com.ar",
1136
+ "com.co",
1137
+ "com.pe",
1138
+ "co.in",
1139
+ "com.sg",
1140
+ "com.tr",
1141
+ "com.cn",
1142
+ "com.hk",
1143
+ "com.tw",
1144
+ "com.my",
1145
+ "com.ph",
1146
+ "com.ua",
1147
+ "com.pl",
1148
+ "com.es",
1149
+ "com.pt",
1150
+ "com.gr"
1151
+ ]);
1152
+ function registrableDomain(host) {
1153
+ const parts = host.toLowerCase().replace(/\.$/, "").split(".").filter(Boolean);
1154
+ if (parts.length <= 2) return parts.join(".");
1155
+ const lastTwo = parts.slice(-2).join(".");
1156
+ return MULTI_SUFFIX.has(lastTwo) ? parts.slice(-3).join(".") : lastTwo;
1157
+ }
1158
+ function registrableOf(url) {
1159
+ try {
1160
+ return registrableDomain(new URL(url).hostname);
1161
+ } catch {
1162
+ return "";
1163
+ }
1164
+ }
1165
+
1166
+ // src/scan-evidence.ts
1167
+ var ALL_PAGE_TYPES = ["homepage", "category", "product", "content"];
1168
+ var HTML_TYPES = ["text/html", "application/xhtml+xml"];
1169
+ var PERMANENT_REDIRECT = /* @__PURE__ */ new Set([301, 308]);
1170
+ function bareHost(url) {
1171
+ try {
1172
+ return new URL(url).hostname.toLowerCase().replace(/^www\./, "");
1173
+ } catch {
1174
+ return "";
1175
+ }
1176
+ }
1177
+ function registrableName(url) {
1178
+ const domain = registrableOf(url);
1179
+ if (!domain) return "";
1180
+ const parts = domain.split(".");
1181
+ return parts.length > 1 ? parts.slice(0, -1).join(".") : domain;
1182
+ }
1183
+ function reachedTheRequestedSite(requestedUrl, result) {
1184
+ const requested = bareHost(requestedUrl);
1185
+ const final = bareHost(result.finalUrl || result.url);
1186
+ if (!final) return { ok: false, reason: `The homepage response carried no usable URL.` };
1187
+ if (requested === final) return { ok: true };
1188
+ const requestedDomain = registrableOf(requestedUrl);
1189
+ const finalDomain = registrableOf(result.finalUrl || result.url);
1190
+ if (requestedDomain && requestedDomain === finalDomain) return { ok: true };
1191
+ const requestedName = registrableName(requestedUrl);
1192
+ if (requestedName && requestedName === registrableName(result.finalUrl || result.url)) {
1193
+ return { ok: true };
1194
+ }
1195
+ const chain = result.redirectChain ?? [];
1196
+ const leaving = chain.filter((hop) => registrableOf(hop.from) !== registrableOf(hop.to));
1197
+ if (leaving.length > 0 && leaving.every((hop) => PERMANENT_REDIRECT.has(hop.status))) {
1198
+ return { ok: true };
1199
+ }
1200
+ return {
1201
+ ok: false,
1202
+ reason: `The requested host redirected to ${final}, a different site, without a permanent redirect.`
1203
+ };
1204
+ }
1205
+ function originReachable(requestedUrl, result) {
1206
+ if (result.error) {
1207
+ return { met: false, reason: `The homepage could not be fetched: ${result.error}.` };
1208
+ }
1209
+ if (result.status < 200 || result.status > 299) {
1210
+ return { met: false, reason: `The homepage answered HTTP ${result.status}.` };
1211
+ }
1212
+ const type = (result.contentType || "").toLowerCase();
1213
+ if (!HTML_TYPES.some((html) => type.includes(html))) {
1214
+ return {
1215
+ met: false,
1216
+ reason: `The homepage served ${result.contentType || "no content type"}, not HTML.`
1217
+ };
1218
+ }
1219
+ const reached = reachedTheRequestedSite(requestedUrl, result);
1220
+ return reached.ok ? { met: true } : { met: false, reason: reached.reason };
1221
+ }
1222
+ function unblockedFetches(homepageResult, waf) {
1223
+ if (waf?.isBlocked) {
1224
+ return waf.isRateLimit ? {
1225
+ met: false,
1226
+ reason: `The scan was throttled (${waf.name}): ${waf.reason}.`
1227
+ } : { met: false, reason: `${waf.name} refused the scan: ${waf.reason}.` };
1228
+ }
1229
+ if (homepageResult.status === 429) {
1230
+ return { met: false, reason: "The homepage answered HTTP 429: the scan was throttled." };
1231
+ }
1232
+ return { met: true };
1233
+ }
1234
+ function pageRendersText(page) {
1235
+ const text3 = getRenderedText(page.$);
1236
+ const wordCount2 = text3.split(/\s+/).filter(Boolean).length;
1237
+ return wordCount2 > 50 || text3.length > 200;
1238
+ }
1239
+ function buildScanEvidence(input) {
1240
+ const origin = originReachable(input.requestedUrl, input.homepageResult);
1241
+ const unblocked = unblockedFetches(input.homepageResult, input.wafProtection);
1242
+ const renderedByPage = {};
1243
+ const usablePageTypes = /* @__PURE__ */ new Set();
1244
+ for (const page of input.pages) {
1245
+ const rendered = pageRendersText(page);
1246
+ renderedByPage[page.url] = rendered;
1247
+ if (rendered) usablePageTypes.add(page.pageType);
1248
+ }
1249
+ const renderedCount = Object.values(renderedByPage).filter(Boolean).length;
1250
+ const met = {
1251
+ "origin-reachable": origin.met,
1252
+ "unblocked-fetches": unblocked.met,
1253
+ "rendered-body": renderedCount > 0,
1254
+ "sample-adequate": usablePageTypes.size > 0
1255
+ };
1256
+ const reasons = {};
1257
+ if (origin.reason) reasons["origin-reachable"] = origin.reason;
1258
+ if (unblocked.reason) reasons["unblocked-fetches"] = unblocked.reason;
1259
+ if (!met["rendered-body"]) {
1260
+ reasons["rendered-body"] = input.pages.length === 0 ? "The scan fetched no pages." : `None of the ${input.pages.length} fetched page(s) served readable text.`;
1261
+ }
1262
+ if (!met["sample-adequate"]) {
1263
+ reasons["sample-adequate"] = input.pages.length === 0 ? "The scan fetched no pages." : "No fetched page of any type served readable text.";
1264
+ }
1265
+ return {
1266
+ met,
1267
+ reasons,
1268
+ renderedByPage,
1269
+ usablePageTypes,
1270
+ // A shell site was seen. What it serves is a finding about it, so
1271
+ // `rendered-body` and `sample-adequate` do not clear `judgeable`.
1272
+ judgeable: met["origin-reachable"] && met["unblocked-fetches"]
1273
+ };
1274
+ }
1275
+ function scanReadTheSite(evidence) {
1276
+ return evidence.judgeable;
1277
+ }
1278
+ function unreadSiteReason(evidence) {
1279
+ return evidence.reasons["origin-reachable"] ?? evidence.reasons["unblocked-fetches"] ?? "The scan obtained no response it could attribute to this site.";
1280
+ }
1281
+ function scanReadPageText(evidence) {
1282
+ return evidence.met["rendered-body"];
1283
+ }
1284
+ function unreadPageTextReason(evidence) {
1285
+ return evidence.reasons["rendered-body"] ?? "No fetched page served text a non-JS consumer can read.";
1286
+ }
1287
+ function allEvidenceMet() {
1288
+ return {
1289
+ met: {
1290
+ "origin-reachable": true,
1291
+ "unblocked-fetches": true,
1292
+ "rendered-body": true,
1293
+ "sample-adequate": true
1294
+ },
1295
+ reasons: {},
1296
+ renderedByPage: {},
1297
+ usablePageTypes: new Set(ALL_PAGE_TYPES),
1298
+ judgeable: true
1299
+ };
1300
+ }
1301
+
1079
1302
  // src/audits/access-crawl-control/no-nofollow.ts
1080
1303
  var NoNofollowAudit = class _NoNofollowAudit extends Audit {
1081
1304
  static meta = {
@@ -1089,8 +1312,9 @@ var NoNofollowAudit = class _NoNofollowAudit extends Audit {
1089
1312
  evidenceGrade: "A",
1090
1313
  tier: "scored",
1091
1314
  dossier: "docs/evidence/audits/access-crawl-control/no-nofollow.md",
1092
- // Gate exemption: being refused is what this category reports.
1093
- requires: ["origin-reachable", "rendered-body", "sample-adequate"],
1315
+ // Gate exemption: being refused is what this category reports, and the meta tag and
1316
+ // header this audit reads are served by a page whose body renders nothing.
1317
+ requires: ["origin-reachable"],
1094
1318
  defaultPriority: "high",
1095
1319
  guidance: {
1096
1320
  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.",
@@ -1102,6 +1326,13 @@ var NoNofollowAudit = class _NoNofollowAudit extends Audit {
1102
1326
  }
1103
1327
  };
1104
1328
  audit(ctx) {
1329
+ if (!scanReadTheSite(ctx.evidence)) {
1330
+ return this.notApplicable(
1331
+ "No page here can be attributed to this site, so its nofollow directives were not judged.",
1332
+ "No site-wide nofollow directives",
1333
+ unreadSiteReason(ctx.evidence)
1334
+ );
1335
+ }
1105
1336
  if (ctx.pages.length === 0) {
1106
1337
  return this.fail(
1107
1338
  "No pages scanned.",
@@ -1172,8 +1403,10 @@ var NoRedirectChainsAudit = class _NoRedirectChainsAudit extends Audit {
1172
1403
  evidenceGrade: "A",
1173
1404
  tier: "scored",
1174
1405
  dossier: "docs/evidence/audits/access-crawl-control/no-redirect-chains.md",
1175
- // Gate exemption: being refused is what this category reports.
1176
- requires: ["origin-reachable", "rendered-body", "sample-adequate"],
1406
+ // Gate exemption: a hop that left the site is this audit's subject, and leaving the
1407
+ // site is exactly what denies `origin-reachable`. It reads request URL against final
1408
+ // URL, which every response carries, and reports "no pages scanned" itself.
1409
+ requires: [],
1177
1410
  defaultPriority: "medium",
1178
1411
  guidance: {
1179
1412
  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.",
@@ -1184,17 +1417,6 @@ var NoRedirectChainsAudit = class _NoRedirectChainsAudit extends Audit {
1184
1417
  }
1185
1418
  };
1186
1419
  audit(ctx) {
1187
- if (ctx.pages.length === 0) {
1188
- return this.fail(
1189
- "No pages scanned.",
1190
- "No redirect chains (URL equals finalUrl or single redirect)",
1191
- "No pages scanned",
1192
- {
1193
- priority: "medium",
1194
- description: _NoRedirectChainsAudit.meta.description
1195
- }
1196
- );
1197
- }
1198
1420
  const redirected = [];
1199
1421
  for (const page of ctx.pages) {
1200
1422
  const requestUrl = page.fetchResult.url;
@@ -1204,6 +1426,24 @@ var NoRedirectChainsAudit = class _NoRedirectChainsAudit extends Audit {
1204
1426
  }
1205
1427
  }
1206
1428
  if (redirected.length === 0) {
1429
+ if (!scanReadTheSite(ctx.evidence)) {
1430
+ return this.notApplicable(
1431
+ "No page here can be attributed to this site, so its redirect behaviour was not judged.",
1432
+ "No redirect chains",
1433
+ unreadSiteReason(ctx.evidence)
1434
+ );
1435
+ }
1436
+ if (ctx.pages.length === 0) {
1437
+ return this.fail(
1438
+ "No pages scanned.",
1439
+ "No redirect chains (URL equals finalUrl or single redirect)",
1440
+ "No pages scanned",
1441
+ {
1442
+ priority: "medium",
1443
+ description: _NoRedirectChainsAudit.meta.description
1444
+ }
1445
+ );
1446
+ }
1207
1447
  return this.pass(
1208
1448
  `All ${ctx.pages.length} page(s) resolve without redirects.`,
1209
1449
  "No redirect chains",
@@ -1333,6 +1573,13 @@ var CanonicalLinksAudit = class extends Audit {
1333
1573
  };
1334
1574
  audit(ctx) {
1335
1575
  const expected = "Each page declares a canonical URL that points at itself";
1576
+ if (!scanReadTheSite(ctx.evidence)) {
1577
+ return this.notApplicable(
1578
+ "No page here can be attributed to this site, so its canonical links were not judged.",
1579
+ expected,
1580
+ unreadSiteReason(ctx.evidence)
1581
+ );
1582
+ }
1336
1583
  if (ctx.pages.length === 0) {
1337
1584
  return this.notApplicable(
1338
1585
  "No pages were scanned, so no canonical links could be read.",
@@ -2546,6 +2793,13 @@ var NoBlanketBlockAudit = class extends Audit {
2546
2793
  }
2547
2794
  };
2548
2795
  audit(ctx) {
2796
+ if (!scanReadTheSite(ctx.evidence)) {
2797
+ return this.notApplicable(
2798
+ "No response here can be attributed to this site, so its robots.txt was not judged.",
2799
+ "User-agent: * does not Disallow: / entirely",
2800
+ unreadSiteReason(ctx.evidence)
2801
+ );
2802
+ }
2549
2803
  const robotsFile = ctx.rootFiles["/robots.txt"];
2550
2804
  if (!robotsFile || robotsFile.status !== 200 || !robotsFile.body) {
2551
2805
  return this.warn(
@@ -2797,6 +3051,13 @@ var CrawlDelayAudit = class extends Audit {
2797
3051
  }
2798
3052
  };
2799
3053
  audit(ctx) {
3054
+ if (!scanReadTheSite(ctx.evidence)) {
3055
+ return this.notApplicable(
3056
+ "No response here can be attributed to this site, so its robots.txt was not judged.",
3057
+ "If Crawl-delay is present, it is <= 10 seconds",
3058
+ unreadSiteReason(ctx.evidence)
3059
+ );
3060
+ }
2800
3061
  const robotsFile = ctx.rootFiles["/robots.txt"];
2801
3062
  if (!robotsFile || robotsFile.status !== 200 || !robotsFile.body) {
2802
3063
  return this.warn(
@@ -2927,8 +3188,9 @@ var MetaRobotsNotBlockingAudit = class extends Audit {
2927
3188
  evidenceGrade: "A",
2928
3189
  tier: "scored",
2929
3190
  dossier: "docs/evidence/audits/access-crawl-control/robots-directives.md",
2930
- // Gate exemption: being refused is what this category reports.
2931
- requires: ["origin-reachable", "rendered-body", "sample-adequate"],
3191
+ // Gate exemption: being refused is what this category reports, and robots directives
3192
+ // live in the head and the headers, which arrive whether or not the body renders.
3193
+ requires: ["origin-reachable"],
2932
3194
  defaultPriority: "high",
2933
3195
  guidance: {
2934
3196
  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.',
@@ -2940,6 +3202,13 @@ var MetaRobotsNotBlockingAudit = class extends Audit {
2940
3202
  }
2941
3203
  };
2942
3204
  audit(ctx) {
3205
+ if (!scanReadTheSite(ctx.evidence)) {
3206
+ return this.notApplicable(
3207
+ "No page here can be attributed to this site, so its robots directives were not judged.",
3208
+ "No blocking robots directive on content pages",
3209
+ unreadSiteReason(ctx.evidence)
3210
+ );
3211
+ }
2943
3212
  if (!ctx.pages || ctx.pages.length === 0) {
2944
3213
  return this.notApplicable(
2945
3214
  "No pages were scanned, so no robots directives could be read.",
@@ -3017,8 +3286,10 @@ var NoBotDetectionAudit = class extends Audit {
3017
3286
  evidenceGrade: "A",
3018
3287
  tier: "scored",
3019
3288
  dossier: "docs/evidence/audits/access-crawl-control/no-bot-detection.md",
3020
- // Gate exemption: being refused is what this category reports.
3021
- requires: ["origin-reachable", "rendered-body", "sample-adequate"],
3289
+ // Gate exemption: being refused is what this category reports, and this audit names
3290
+ // the firewall from `wafProtection` alone. Evidence a wall destroys is not evidence
3291
+ // the wall finding needs.
3292
+ requires: [],
3022
3293
  defaultPriority: "high",
3023
3294
  guidance: {
3024
3295
  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.",
@@ -3048,6 +3319,13 @@ var NoBotDetectionAudit = class extends Audit {
3048
3319
  }
3049
3320
  );
3050
3321
  }
3322
+ if (!scanReadTheSite(ctx.evidence)) {
3323
+ return this.notApplicable(
3324
+ "No page here can be attributed to this site, so its scripts were not judged.",
3325
+ "No JavaScript-based bot challenges that would block legitimate AI agents",
3326
+ unreadSiteReason(ctx.evidence)
3327
+ );
3328
+ }
3051
3329
  if (!ctx.pages || ctx.pages.length === 0) {
3052
3330
  return this.warn(
3053
3331
  "No pages were scanned to check for bot-detection scripts.",
@@ -3073,6 +3351,13 @@ var NoBotDetectionAudit = class extends Audit {
3073
3351
  }
3074
3352
  }
3075
3353
  if (detectedServices.size === 0) {
3354
+ if (!scanReadPageText(ctx.evidence)) {
3355
+ return this.notApplicable(
3356
+ "The scanned page served no readable text, so its scripts were not judged.",
3357
+ "No JavaScript-based bot challenges that would block legitimate AI agents",
3358
+ unreadPageTextReason(ctx.evidence)
3359
+ );
3360
+ }
3076
3361
  return this.pass(
3077
3362
  "No aggressive bot-detection scripts found on scanned pages.",
3078
3363
  "No JavaScript-based bot challenges that would block legitimate AI agents",
@@ -3195,6 +3480,13 @@ var TdmRepAudit = class extends Audit {
3195
3480
  }
3196
3481
  };
3197
3482
  audit(ctx) {
3483
+ if (!scanReadTheSite(ctx.evidence)) {
3484
+ return this.notApplicable(
3485
+ "No response here can be attributed to this site, so no TDM-Rep declaration was read.",
3486
+ EXPECTED3,
3487
+ unreadSiteReason(ctx.evidence)
3488
+ );
3489
+ }
3198
3490
  const pageUrl = ctx.pages[0]?.url;
3199
3491
  const header = readHeader(ctx);
3200
3492
  if (header) {
@@ -3482,6 +3774,13 @@ var AiContentDeclarationAudit = class extends Audit {
3482
3774
  }
3483
3775
  };
3484
3776
  audit(ctx) {
3777
+ if (!scanReadTheSite(ctx.evidence)) {
3778
+ return this.notApplicable(
3779
+ "No response here can be attributed to this site, so no AI-usage declaration was read.",
3780
+ EXPECTED4,
3781
+ unreadSiteReason(ctx.evidence)
3782
+ );
3783
+ }
3485
3784
  const found = survey(ctx);
3486
3785
  if (found.aipref) {
3487
3786
  return this.pass(
@@ -3532,8 +3831,9 @@ var HttpsEnabledAudit = class extends Audit {
3532
3831
  evidenceGrade: "A",
3533
3832
  tier: "scored",
3534
3833
  dossier: "docs/evidence/audits/access-crawl-control/https-enabled.md",
3535
- // Gate exemption: being refused is what this category reports.
3536
- requires: ["origin-reachable", "rendered-body", "sample-adequate"],
3834
+ // Gate exemption: a base URL on plain HTTP is proven by the request, with no response
3835
+ // at all, and that fail is worth reporting on a site whose homepage never answered.
3836
+ requires: [],
3537
3837
  defaultPriority: "critical",
3538
3838
  guidance: {
3539
3839
  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.",
@@ -3548,35 +3848,42 @@ var HttpsEnabledAudit = class extends Audit {
3548
3848
  const isHttps = ctx.baseUrl.startsWith("https://");
3549
3849
  const page = ctx.pages?.[0];
3550
3850
  const status200 = page?.fetchResult.status === 200;
3551
- if (isHttps && status200) {
3552
- return this.pass(
3553
- "Site is served over HTTPS with a valid TLS connection.",
3851
+ if (!isHttps) {
3852
+ return this.fail(
3853
+ "Site is not served over HTTPS. AI agents require secure connections.",
3554
3854
  "Base URL uses https:// and homepage returns 200",
3555
- `${ctx.baseUrl} \u2014 status ${page?.fetchResult.status}`,
3855
+ `Base URL: ${ctx.baseUrl}`,
3856
+ {
3857
+ priority: "critical",
3858
+ 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.",
3859
+ code: "# For nginx:\nserver {\n listen 443 ssl;\n ssl_certificate /path/to/cert.pem;\n ssl_certificate_key /path/to/key.pem;\n}"
3860
+ },
3556
3861
  page?.url
3557
3862
  );
3558
3863
  }
3559
- if (isHttps && !status200) {
3560
- return this.warn(
3561
- `Site uses HTTPS but homepage returned status ${page?.fetchResult.status ?? "unknown"}. Possible TLS or server error.`,
3864
+ if (!scanReadTheSite(ctx.evidence)) {
3865
+ return this.notApplicable(
3866
+ "No homepage here can be attributed to this site, so its transport was not judged.",
3562
3867
  "Base URL uses https:// and homepage returns 200",
3563
- `${ctx.baseUrl} \u2014 status ${page?.fetchResult.status ?? "N/A"}`,
3564
- {
3565
- priority: "high",
3566
- 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.",
3567
- code: "# Verify TLS with: curl -vI https://yoursite.com\n# Check for certificate expiry, chain issues, or redirect loops"
3568
- },
3868
+ unreadSiteReason(ctx.evidence)
3869
+ );
3870
+ }
3871
+ if (status200) {
3872
+ return this.pass(
3873
+ "Site is served over HTTPS with a valid TLS connection.",
3874
+ "Base URL uses https:// and homepage returns 200",
3875
+ `${ctx.baseUrl} \u2014 status ${page?.fetchResult.status}`,
3569
3876
  page?.url
3570
3877
  );
3571
3878
  }
3572
- return this.fail(
3573
- "Site is not served over HTTPS. AI agents require secure connections.",
3879
+ return this.warn(
3880
+ "Site uses HTTPS and the homepage answered, but the response carried no document, so an agent has nothing to read over that connection.",
3574
3881
  "Base URL uses https:// and homepage returns 200",
3575
- `Base URL: ${ctx.baseUrl}`,
3882
+ `${ctx.baseUrl} \u2014 a 2xx response that carried no document`,
3576
3883
  {
3577
- priority: "critical",
3578
- 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.",
3579
- code: "# For nginx:\nserver {\n listen 443 ssl;\n ssl_certificate /path/to/cert.pem;\n ssl_certificate_key /path/to/key.pem;\n}"
3884
+ priority: "high",
3885
+ 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.",
3886
+ code: "# Reproduce with:\ncurl -sSi https://yoursite.com | head -20"
3580
3887
  },
3581
3888
  page?.url
3582
3889
  );
@@ -3683,7 +3990,9 @@ var RobotsAiGroupShadowingAudit = class _RobotsAiGroupShadowingAudit extends Aud
3683
3990
  tier: "scored",
3684
3991
  dossier: "docs/evidence/audits/access-crawl-control/robots-ai-group-shadowing.md",
3685
3992
  // Gate exemption: being refused is what this category reports.
3686
- requires: ["origin-reachable", "rendered-body", "sample-adequate"],
3993
+ // Gate exemption: the verdict comes from robots.txt. The scanned pages only widen
3994
+ // the probe path set, so a shell narrows the probe and changes nothing judged.
3995
+ requires: ["origin-reachable"],
3687
3996
  defaultPriority: "high",
3688
3997
  guidance: {
3689
3998
  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.",
@@ -3702,6 +4011,13 @@ var RobotsAiGroupShadowingAudit = class _RobotsAiGroupShadowingAudit extends Aud
3702
4011
  };
3703
4012
  }
3704
4013
  audit(ctx) {
4014
+ if (!scanReadTheSite(ctx.evidence)) {
4015
+ return this.notApplicable(
4016
+ "No response here can be attributed to this site, so its robots groups were not judged.",
4017
+ EXPECTED5,
4018
+ unreadSiteReason(ctx.evidence)
4019
+ );
4020
+ }
3705
4021
  const robots = ctx.rootFiles["/robots.txt"];
3706
4022
  if (!robots || robots.status !== 200 || !robots.body.trim()) {
3707
4023
  return this.notApplicable(
@@ -4672,6 +4988,13 @@ var AiUsageSignalCoherenceAcrossChannelsAudit = class extends Audit {
4672
4988
  }
4673
4989
  };
4674
4990
  audit(ctx) {
4991
+ if (!scanReadTheSite(ctx.evidence)) {
4992
+ return this.notApplicable(
4993
+ "No response here can be attributed to this site, so its AI-usage channels were not compared.",
4994
+ "Every channel that carries an AI-usage signal says the same thing",
4995
+ unreadSiteReason(ctx.evidence)
4996
+ );
4997
+ }
4675
4998
  if (ctx.pages.length === 0 && (ctx.rootFiles["/robots.txt"]?.status ?? 0) !== 200) {
4676
4999
  return this.notApplicable(
4677
5000
  "The scan read no page and no robots.txt, so no channel could carry a signal.",
@@ -4886,6 +5209,13 @@ var AiprefContentUsageDeclarationValidityAudit = class extends Audit {
4886
5209
  }
4887
5210
  };
4888
5211
  audit(ctx) {
5212
+ if (!scanReadTheSite(ctx.evidence)) {
5213
+ return this.notApplicable(
5214
+ "No response here can be attributed to this site, so no Content-Usage declaration was read.",
5215
+ "Every Content-Usage declaration parses as an RFC 8941 dictionary of AIPREF categories, attaches to a crawlable path, and agrees with the other channel",
5216
+ unreadSiteReason(ctx.evidence)
5217
+ );
5218
+ }
4889
5219
  const robots = ctx.rootFiles["/robots.txt"];
4890
5220
  const robotsBody = robots?.status === 200 ? robots.body : "";
4891
5221
  const groups = robotsBody === "" ? [] : parseRobots(robotsBody);
@@ -5773,7 +6103,9 @@ var ServerResponsivenessAudit = class extends Audit {
5773
6103
  evidenceGrade: "B",
5774
6104
  tier: "scored",
5775
6105
  dossier: "docs/evidence/audits/content-extraction/server-responsiveness.md",
5776
- requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
6106
+ // Gate exemption: TTFB is measured from the response, and a shell answers as fast
6107
+ // or as slow as anything else the origin serves.
6108
+ requires: ["origin-reachable", "unblocked-fetches"],
5777
6109
  defaultPriority: "medium",
5778
6110
  guidance: {
5779
6111
  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.',
@@ -5792,6 +6124,13 @@ var ServerResponsivenessAudit = class extends Audit {
5792
6124
  `Blocked by ${ctx.wafProtection.name}`
5793
6125
  );
5794
6126
  }
6127
+ if (!scanReadTheSite(ctx.evidence)) {
6128
+ return this.notApplicable(
6129
+ "No page here can be attributed to this site, so its response time was not judged.",
6130
+ EXPECTED8,
6131
+ unreadSiteReason(ctx.evidence)
6132
+ );
6133
+ }
5795
6134
  const measured = ctx.pages.filter((p) => !p.fetchResult.error && p.fetchResult.status !== 0);
5796
6135
  const unmeasured = ctx.pages.length - measured.length;
5797
6136
  if (measured.length === 0) {
@@ -5852,7 +6191,8 @@ var LanguageAttributeAudit = class extends Audit {
5852
6191
  evidenceGrade: "A",
5853
6192
  tier: "scored",
5854
6193
  dossier: "docs/evidence/audits/content-extraction/language-attribute.md",
5855
- requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
6194
+ // Gate exemption: `<html lang>` is served before any body renders.
6195
+ requires: ["origin-reachable", "unblocked-fetches"],
5856
6196
  defaultPriority: "high",
5857
6197
  guidance: {
5858
6198
  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.",
@@ -5864,6 +6204,13 @@ var LanguageAttributeAudit = class extends Audit {
5864
6204
  }
5865
6205
  };
5866
6206
  audit(ctx) {
6207
+ if (!scanReadTheSite(ctx.evidence)) {
6208
+ return this.notApplicable(
6209
+ "No page here can be attributed to this site, so its language attribute was not judged.",
6210
+ '<html lang="..."> with a non-empty language code',
6211
+ unreadSiteReason(ctx.evidence)
6212
+ );
6213
+ }
5867
6214
  const page = ctx.pages[0];
5868
6215
  const $ = page?.$;
5869
6216
  const lang = $?.("html").attr("lang") ?? "";
@@ -6355,6 +6702,13 @@ var SingleH1Audit = class extends Audit {
6355
6702
  }
6356
6703
  };
6357
6704
  audit(ctx) {
6705
+ if (!scanReadTheSite(ctx.evidence)) {
6706
+ return this.notApplicable(
6707
+ "No page here can be attributed to this site, so its headings were not judged.",
6708
+ "Exactly one <h1> on the homepage",
6709
+ unreadSiteReason(ctx.evidence)
6710
+ );
6711
+ }
6358
6712
  const homepage = ctx.pages[0];
6359
6713
  if (!homepage) {
6360
6714
  return this.fail(
@@ -6508,6 +6862,13 @@ var MainElementAudit = class extends Audit {
6508
6862
  }
6509
6863
  };
6510
6864
  audit(ctx) {
6865
+ if (!scanReadTheSite(ctx.evidence)) {
6866
+ return this.notApplicable(
6867
+ "No page here can be attributed to this site, so its landmarks were not judged.",
6868
+ "<main> element present on all pages",
6869
+ unreadSiteReason(ctx.evidence)
6870
+ );
6871
+ }
6511
6872
  let pagesWithMain = 0;
6512
6873
  for (const page of ctx.pages) {
6513
6874
  if (page.$("main").length > 0) pagesWithMain++;
@@ -6567,6 +6928,13 @@ var ArticleElementAudit = class extends Audit {
6567
6928
  }
6568
6929
  };
6569
6930
  audit(ctx) {
6931
+ if (!scanReadTheSite(ctx.evidence)) {
6932
+ return this.notApplicable(
6933
+ "No page here can be attributed to this site, so its landmarks were not judged.",
6934
+ "<article> elements on content pages",
6935
+ unreadSiteReason(ctx.evidence)
6936
+ );
6937
+ }
6570
6938
  let pagesWithArticle = 0;
6571
6939
  for (const page of ctx.pages) {
6572
6940
  if (page.$("article").length > 0) pagesWithArticle++;
@@ -6625,6 +6993,13 @@ var HeaderFooterAudit = class extends Audit {
6625
6993
  }
6626
6994
  };
6627
6995
  audit(ctx) {
6996
+ if (!scanReadTheSite(ctx.evidence)) {
6997
+ return this.notApplicable(
6998
+ "No page here can be attributed to this site, so its landmarks were not judged.",
6999
+ "Both <header> and <footer> present on all pages",
7000
+ unreadSiteReason(ctx.evidence)
7001
+ );
7002
+ }
6628
7003
  let pagesWithBoth = 0;
6629
7004
  let pagesWithHeader = 0;
6630
7005
  let pagesWithFooter = 0;
@@ -7051,6 +7426,13 @@ var DataTablesAudit = class extends Audit {
7051
7426
  }
7052
7427
  };
7053
7428
  audit(ctx) {
7429
+ if (!scanReadTheSite(ctx.evidence)) {
7430
+ return this.notApplicable(
7431
+ "No page here can be attributed to this site, so its tables were not judged.",
7432
+ "Tables have <thead> and <th> elements",
7433
+ unreadSiteReason(ctx.evidence)
7434
+ );
7435
+ }
7054
7436
  let totalTables = 0;
7055
7437
  let properTables = 0;
7056
7438
  for (const page of ctx.pages) {
@@ -7063,6 +7445,13 @@ var DataTablesAudit = class extends Audit {
7063
7445
  });
7064
7446
  }
7065
7447
  if (totalTables === 0) {
7448
+ if (!scanReadPageText(ctx.evidence)) {
7449
+ return this.notApplicable(
7450
+ "The scanned page served no readable text, so it held no tables to judge.",
7451
+ "Tables have <thead> and <th> elements",
7452
+ unreadPageTextReason(ctx.evidence)
7453
+ );
7454
+ }
7066
7455
  return this.pass(
7067
7456
  "No data tables found \u2014 check not applicable.",
7068
7457
  "Tables have <thead> and <th> elements",
@@ -7259,6 +7648,13 @@ var ContentDepthAudit = class extends Audit {
7259
7648
  }
7260
7649
  };
7261
7650
  audit(ctx) {
7651
+ if (!scanReadTheSite(ctx.evidence)) {
7652
+ return this.notApplicable(
7653
+ "No page here can be attributed to this site, so its content depth was not judged.",
7654
+ "More than 300 words of content per page",
7655
+ unreadSiteReason(ctx.evidence)
7656
+ );
7657
+ }
7262
7658
  let pagesAboveThreshold = 0;
7263
7659
  const wordCounts = [];
7264
7660
  for (const page of ctx.pages) {
@@ -7431,6 +7827,13 @@ var FigureFigcaptionAudit = class extends Audit {
7431
7827
  }
7432
7828
  };
7433
7829
  audit(ctx) {
7830
+ if (!scanReadTheSite(ctx.evidence)) {
7831
+ return this.notApplicable(
7832
+ "No page here can be attributed to this site, so its figures were not judged.",
7833
+ "Images with context wrapped in <figure> with <figcaption>",
7834
+ unreadSiteReason(ctx.evidence)
7835
+ );
7836
+ }
7434
7837
  let totalFigures = 0;
7435
7838
  let figuresWithCaption = 0;
7436
7839
  for (const page of ctx.pages) {
@@ -7459,6 +7862,13 @@ var FigureFigcaptionAudit = class extends Audit {
7459
7862
  }
7460
7863
  );
7461
7864
  }
7865
+ if (!scanReadPageText(ctx.evidence)) {
7866
+ return this.notApplicable(
7867
+ "The scanned page served no readable text, so it held no images or figures to judge.",
7868
+ "Images with context wrapped in <figure> with <figcaption>",
7869
+ unreadPageTextReason(ctx.evidence)
7870
+ );
7871
+ }
7462
7872
  return this.pass(
7463
7873
  "No images or <figure> elements found \u2014 check not applicable.",
7464
7874
  "Images with context wrapped in <figure> with <figcaption>",
@@ -12964,6 +13374,13 @@ var TokenRatioAudit = class extends Audit {
12964
13374
  }
12965
13375
  };
12966
13376
  audit(ctx) {
13377
+ if (!scanReadTheSite(ctx.evidence)) {
13378
+ return this.notApplicable(
13379
+ "No page here can be attributed to this site, so its token mix was not measured.",
13380
+ "A homepage from this site whose token mix can be measured",
13381
+ unreadSiteReason(ctx.evidence)
13382
+ );
13383
+ }
12967
13384
  const page = ctx.pages[0];
12968
13385
  const rawHtml = page?.fetchResult.body ?? "";
12969
13386
  if (!page || rawHtml.trim().length === 0) {
@@ -13097,6 +13514,13 @@ var FakeHeadingsAudit = class extends Audit {
13097
13514
  }
13098
13515
  };
13099
13516
  audit(ctx) {
13517
+ if (!scanReadTheSite(ctx.evidence)) {
13518
+ return this.notApplicable(
13519
+ "No page here can be attributed to this site, so its headings were not judged.",
13520
+ "All heading-like text uses semantic <h1>-<h6> elements",
13521
+ unreadSiteReason(ctx.evidence)
13522
+ );
13523
+ }
13100
13524
  const found = [];
13101
13525
  for (const page of ctx.pages) {
13102
13526
  const $ = page.$;
@@ -13120,6 +13544,13 @@ var FakeHeadingsAudit = class extends Audit {
13120
13544
  const foundSummary = found.slice(0, 5).map((f) => `${f.url}: ${describe5(f.heading)}`).join("; ");
13121
13545
  const expected = "All heading-like text uses semantic <h1>-<h6> elements";
13122
13546
  if (found.length === 0) {
13547
+ if (!scanReadPageText(ctx.evidence)) {
13548
+ return this.notApplicable(
13549
+ "The scanned page served no readable text, so it held no headings to judge.",
13550
+ expected,
13551
+ unreadPageTextReason(ctx.evidence)
13552
+ );
13553
+ }
13123
13554
  return this.pass(
13124
13555
  "No fake headings detected \u2014 heading-like text uses semantic heading elements.",
13125
13556
  expected,
@@ -13148,185 +13579,6 @@ var FakeHeadingsAudit = class extends Audit {
13148
13579
  }
13149
13580
  };
13150
13581
 
13151
- // src/gatherers/domains.ts
13152
- var MULTI_SUFFIX = /* @__PURE__ */ new Set([
13153
- "co.uk",
13154
- "org.uk",
13155
- "ac.uk",
13156
- "gov.uk",
13157
- "me.uk",
13158
- "net.uk",
13159
- "com.au",
13160
- "net.au",
13161
- "org.au",
13162
- "edu.au",
13163
- "gov.au",
13164
- "co.nz",
13165
- "co.jp",
13166
- "or.jp",
13167
- "ne.jp",
13168
- "co.za",
13169
- "co.kr",
13170
- "co.il",
13171
- "co.id",
13172
- "co.th",
13173
- "com.br",
13174
- "com.mx",
13175
- "com.ar",
13176
- "com.co",
13177
- "com.pe",
13178
- "co.in",
13179
- "com.sg",
13180
- "com.tr",
13181
- "com.cn",
13182
- "com.hk",
13183
- "com.tw",
13184
- "com.my",
13185
- "com.ph",
13186
- "com.ua",
13187
- "com.pl",
13188
- "com.es",
13189
- "com.pt",
13190
- "com.gr"
13191
- ]);
13192
- function registrableDomain(host) {
13193
- const parts = host.toLowerCase().replace(/\.$/, "").split(".").filter(Boolean);
13194
- if (parts.length <= 2) return parts.join(".");
13195
- const lastTwo = parts.slice(-2).join(".");
13196
- return MULTI_SUFFIX.has(lastTwo) ? parts.slice(-3).join(".") : lastTwo;
13197
- }
13198
- function registrableOf(url) {
13199
- try {
13200
- return registrableDomain(new URL(url).hostname);
13201
- } catch {
13202
- return "";
13203
- }
13204
- }
13205
-
13206
- // src/scan-evidence.ts
13207
- var ALL_PAGE_TYPES = ["homepage", "category", "product", "content"];
13208
- var HTML_TYPES = ["text/html", "application/xhtml+xml"];
13209
- var PERMANENT_REDIRECT = /* @__PURE__ */ new Set([301, 308]);
13210
- function bareHost(url) {
13211
- try {
13212
- return new URL(url).hostname.toLowerCase().replace(/^www\./, "");
13213
- } catch {
13214
- return "";
13215
- }
13216
- }
13217
- function registrableName(url) {
13218
- const domain = registrableOf(url);
13219
- if (!domain) return "";
13220
- const parts = domain.split(".");
13221
- return parts.length > 1 ? parts.slice(0, -1).join(".") : domain;
13222
- }
13223
- function reachedTheRequestedSite(requestedUrl, result) {
13224
- const requested = bareHost(requestedUrl);
13225
- const final = bareHost(result.finalUrl || result.url);
13226
- if (!final) return { ok: false, reason: `The homepage response carried no usable URL.` };
13227
- if (requested === final) return { ok: true };
13228
- const requestedDomain = registrableOf(requestedUrl);
13229
- const finalDomain = registrableOf(result.finalUrl || result.url);
13230
- if (requestedDomain && requestedDomain === finalDomain) return { ok: true };
13231
- const requestedName = registrableName(requestedUrl);
13232
- if (requestedName && requestedName === registrableName(result.finalUrl || result.url)) {
13233
- return { ok: true };
13234
- }
13235
- const chain = result.redirectChain ?? [];
13236
- const leaving = chain.filter((hop) => registrableOf(hop.from) !== registrableOf(hop.to));
13237
- if (leaving.length > 0 && leaving.every((hop) => PERMANENT_REDIRECT.has(hop.status))) {
13238
- return { ok: true };
13239
- }
13240
- return {
13241
- ok: false,
13242
- reason: `The requested host redirected to ${final}, a different site, without a permanent redirect.`
13243
- };
13244
- }
13245
- function originReachable(requestedUrl, result) {
13246
- if (result.error) {
13247
- return { met: false, reason: `The homepage could not be fetched: ${result.error}.` };
13248
- }
13249
- if (result.status < 200 || result.status > 299) {
13250
- return { met: false, reason: `The homepage answered HTTP ${result.status}.` };
13251
- }
13252
- const type = (result.contentType || "").toLowerCase();
13253
- if (!HTML_TYPES.some((html) => type.includes(html))) {
13254
- return {
13255
- met: false,
13256
- reason: `The homepage served ${result.contentType || "no content type"}, not HTML.`
13257
- };
13258
- }
13259
- const reached = reachedTheRequestedSite(requestedUrl, result);
13260
- return reached.ok ? { met: true } : { met: false, reason: reached.reason };
13261
- }
13262
- function unblockedFetches(homepageResult, waf) {
13263
- if (waf?.isBlocked) {
13264
- return waf.isRateLimit ? {
13265
- met: false,
13266
- reason: `The scan was throttled (${waf.name}): ${waf.reason}.`
13267
- } : { met: false, reason: `${waf.name} refused the scan: ${waf.reason}.` };
13268
- }
13269
- if (homepageResult.status === 429) {
13270
- return { met: false, reason: "The homepage answered HTTP 429: the scan was throttled." };
13271
- }
13272
- return { met: true };
13273
- }
13274
- function pageRendersText(page) {
13275
- const text3 = getRenderedText(page.$);
13276
- const wordCount2 = text3.split(/\s+/).filter(Boolean).length;
13277
- return wordCount2 > 50 || text3.length > 200;
13278
- }
13279
- function buildScanEvidence(input) {
13280
- const origin = originReachable(input.requestedUrl, input.homepageResult);
13281
- const unblocked = unblockedFetches(input.homepageResult, input.wafProtection);
13282
- const renderedByPage = {};
13283
- const usablePageTypes = /* @__PURE__ */ new Set();
13284
- for (const page of input.pages) {
13285
- const rendered = pageRendersText(page);
13286
- renderedByPage[page.url] = rendered;
13287
- if (rendered) usablePageTypes.add(page.pageType);
13288
- }
13289
- const renderedCount = Object.values(renderedByPage).filter(Boolean).length;
13290
- const met = {
13291
- "origin-reachable": origin.met,
13292
- "unblocked-fetches": unblocked.met,
13293
- "rendered-body": renderedCount > 0,
13294
- "sample-adequate": usablePageTypes.size > 0
13295
- };
13296
- const reasons = {};
13297
- if (origin.reason) reasons["origin-reachable"] = origin.reason;
13298
- if (unblocked.reason) reasons["unblocked-fetches"] = unblocked.reason;
13299
- if (!met["rendered-body"]) {
13300
- reasons["rendered-body"] = input.pages.length === 0 ? "The scan fetched no pages." : `None of the ${input.pages.length} fetched page(s) served readable text.`;
13301
- }
13302
- if (!met["sample-adequate"]) {
13303
- reasons["sample-adequate"] = input.pages.length === 0 ? "The scan fetched no pages." : "No fetched page of any type served readable text.";
13304
- }
13305
- return {
13306
- met,
13307
- reasons,
13308
- renderedByPage,
13309
- usablePageTypes,
13310
- // A shell site was seen. What it serves is a finding about it, so
13311
- // `rendered-body` and `sample-adequate` do not clear `judgeable`.
13312
- judgeable: met["origin-reachable"] && met["unblocked-fetches"]
13313
- };
13314
- }
13315
- function allEvidenceMet() {
13316
- return {
13317
- met: {
13318
- "origin-reachable": true,
13319
- "unblocked-fetches": true,
13320
- "rendered-body": true,
13321
- "sample-adequate": true
13322
- },
13323
- reasons: {},
13324
- renderedByPage: {},
13325
- usablePageTypes: new Set(ALL_PAGE_TYPES),
13326
- judgeable: true
13327
- };
13328
- }
13329
-
13330
13582
  // src/audits/content-extraction/server-rendered.ts
13331
13583
  function withDetails(result, details) {
13332
13584
  return { ...result, details: { ...result.details ?? {}, ...details } };
@@ -13356,6 +13608,13 @@ var ServerRenderedAudit = class extends Audit {
13356
13608
  }
13357
13609
  };
13358
13610
  audit(ctx) {
13611
+ if (!scanReadTheSite(ctx.evidence)) {
13612
+ return this.notApplicable(
13613
+ "No page here can be attributed to this site, so its served HTML was not judged.",
13614
+ "Every fetched page serves > 50 words or > 200 characters of readable text",
13615
+ unreadSiteReason(ctx.evidence)
13616
+ );
13617
+ }
13359
13618
  const pages = ctx.pages ?? [];
13360
13619
  if (pages.length === 0) {
13361
13620
  return this.notApplicable(
@@ -13670,6 +13929,13 @@ var CssHiddenGhostContentAudit = class _CssHiddenGhostContentAudit extends Audit
13670
13929
  };
13671
13930
  }
13672
13931
  async audit(ctx) {
13932
+ if (!scanReadTheSite(ctx.evidence)) {
13933
+ return this.notApplicable(
13934
+ "No page here can be attributed to this site, so its hidden text was not measured.",
13935
+ EXPECTED11,
13936
+ unreadSiteReason(ctx.evidence)
13937
+ );
13938
+ }
13673
13939
  const s = await survey2(ctx);
13674
13940
  const partial = s.crossOrigin > 0 ? `; ${s.crossOrigin} cross-origin stylesheet not fetched` : "";
13675
13941
  if (s.totalChars === 0) {
@@ -14006,6 +14272,13 @@ var PreambleTaxTokensBeforeTheFirstContentTokenAudit = class extends Audit {
14006
14272
  }
14007
14273
  };
14008
14274
  audit(ctx) {
14275
+ if (!scanReadTheSite(ctx.evidence)) {
14276
+ return this.notApplicable(
14277
+ "No page here can be attributed to this site, so its preamble was not measured.",
14278
+ "A page from this site whose preamble can be measured",
14279
+ unreadSiteReason(ctx.evidence)
14280
+ );
14281
+ }
14009
14282
  const page = ctx.pages[0];
14010
14283
  if (!page) {
14011
14284
  return this.notApplicable(
@@ -14266,6 +14539,13 @@ var ExtractionDeterminismAudit = class extends Audit {
14266
14539
  }
14267
14540
  };
14268
14541
  audit(ctx) {
14542
+ if (!scanReadTheSite(ctx.evidence)) {
14543
+ return this.notApplicable(
14544
+ "No page here can be attributed to this site, so no extraction could be compared.",
14545
+ "A page from this site whose extraction can be compared",
14546
+ unreadSiteReason(ctx.evidence)
14547
+ );
14548
+ }
14269
14549
  const page = ctx.pages[0];
14270
14550
  if (!page) {
14271
14551
  return this.notApplicable(
@@ -14769,6 +15049,13 @@ var LlmsFullTxtAudit = class extends Audit {
14769
15049
  }
14770
15050
  };
14771
15051
  audit(ctx) {
15052
+ if (!scanReadTheSite(ctx.evidence)) {
15053
+ return this.notApplicable(
15054
+ "No response here can be attributed to this site, so no llms-full.txt was judged.",
15055
+ "GET /llms-full.txt returns 200",
15056
+ unreadSiteReason(ctx.evidence)
15057
+ );
15058
+ }
14772
15059
  const result = ctx.rootFiles["/llms-full.txt"];
14773
15060
  if (!result || !isOk5(result)) {
14774
15061
  return this.fail(
@@ -15352,6 +15639,13 @@ var RssFeedAudit = class extends Audit {
15352
15639
  }
15353
15640
  };
15354
15641
  async audit(ctx) {
15642
+ if (!scanReadTheSite(ctx.evidence)) {
15643
+ return this.notApplicable(
15644
+ "No response here can be attributed to this site, so no feed was judged.",
15645
+ "Feed returns HTTP 200",
15646
+ unreadSiteReason(ctx.evidence)
15647
+ );
15648
+ }
15355
15649
  const links = autodiscoveryLinks(ctx);
15356
15650
  const feed = await findFeedResult(ctx, links);
15357
15651
  const linkNote = links.length > 0 ? `autodiscovery <link> present (${links[0].url})` : "no autodiscovery <link> in <head>";
@@ -16059,6 +16353,15 @@ var NoBrokenAiEndpointsAudit = class extends Audit {
16059
16353
  for (const url of allUrls) {
16060
16354
  if (await isSafeUrl(url)) urls.push(url);
16061
16355
  }
16356
+ if (urls.length === 0) {
16357
+ return this.warn(
16358
+ `${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.`,
16359
+ "All URLs from AI-related files return 200",
16360
+ `${allUrls.length} URL(s) listed, 0 reachable to check`,
16361
+ void 0,
16362
+ page?.url
16363
+ );
16364
+ }
16062
16365
  const results = await Promise.all(
16063
16366
  urls.map(async (url) => {
16064
16367
  try {
@@ -20328,6 +20631,13 @@ var UniqueMetaAudit = class extends Audit {
20328
20631
  }
20329
20632
  };
20330
20633
  audit(ctx) {
20634
+ if (!scanReadTheSite(ctx.evidence)) {
20635
+ return this.notApplicable(
20636
+ "No page here can be attributed to this site, so its metadata was not judged.",
20637
+ "Each page has a unique title + description combination",
20638
+ unreadSiteReason(ctx.evidence)
20639
+ );
20640
+ }
20331
20641
  const canonicalGroups = /* @__PURE__ */ new Map();
20332
20642
  for (const page of ctx.pages) {
20333
20643
  let canon = (page.meta?.["canonical"] || page.url).trim();
@@ -20344,7 +20654,7 @@ var UniqueMetaAudit = class extends Audit {
20344
20654
  }
20345
20655
  const uniquePages = Array.from(canonicalGroups.values());
20346
20656
  if (uniquePages.length < 2) {
20347
- return this.pass(
20657
+ return this.notApplicable(
20348
20658
  "Only one distinct canonical page scanned; uniqueness check not applicable.",
20349
20659
  "Each page has a unique title + description combination",
20350
20660
  "1 distinct page scanned"
@@ -21400,6 +21710,13 @@ var ContentWithoutClickthroughAudit = class _ContentWithoutClickthroughAudit ext
21400
21710
  }
21401
21711
  };
21402
21712
  audit(ctx) {
21713
+ if (!scanReadTheSite(ctx.evidence)) {
21714
+ return this.notApplicable(
21715
+ "No page here can be attributed to this site, so its teasers were not judged.",
21716
+ 'No "click to read more" or "contact us to learn" teasers dominating the page',
21717
+ unreadSiteReason(ctx.evidence)
21718
+ );
21719
+ }
21403
21720
  const page = ctx.pages[0];
21404
21721
  if (!page) {
21405
21722
  return this.fail(
@@ -21456,6 +21773,13 @@ var ContentWithoutClickthroughAudit = class _ContentWithoutClickthroughAudit ext
21456
21773
  );
21457
21774
  }
21458
21775
  }
21776
+ if (!scanReadPageText(ctx.evidence)) {
21777
+ return this.notApplicable(
21778
+ "The scanned page served no readable text, so there was no content to judge for teasers.",
21779
+ 'No "click to read more" or "contact us to learn" teasers dominating the page',
21780
+ unreadPageTextReason(ctx.evidence)
21781
+ );
21782
+ }
21459
21783
  return this.pass(
21460
21784
  "No excessive click-through teasers found.",
21461
21785
  'No "click to read more" or "contact us to learn" teasers dominating the page',
@@ -22915,7 +23239,8 @@ var DescriptiveUrlsAudit = class extends Audit {
22915
23239
  evidenceGrade: "C",
22916
23240
  tier: "informative",
22917
23241
  dossier: "docs/evidence/audits/answer-readiness/descriptive-urls.md",
22918
- requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
23242
+ // Gate exemption: a URL is readable whether or not the page behind it rendered text.
23243
+ requires: ["origin-reachable", "unblocked-fetches"],
22919
23244
  defaultPriority: "high",
22920
23245
  guidance: {
22921
23246
  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.",
@@ -22926,6 +23251,13 @@ var DescriptiveUrlsAudit = class extends Audit {
22926
23251
  }
22927
23252
  };
22928
23253
  audit(ctx) {
23254
+ if (!scanReadTheSite(ctx.evidence)) {
23255
+ return this.notApplicable(
23256
+ "No page here can be attributed to this site, so its URLs were not judged.",
23257
+ "Page URLs use readable slugs (no UUIDs, no /post-123/, no encoded params)",
23258
+ unreadSiteReason(ctx.evidence)
23259
+ );
23260
+ }
22929
23261
  const page = ctx.pages[0];
22930
23262
  if (!page) {
22931
23263
  return this.fail(
@@ -23190,6 +23522,13 @@ var SnippetGateCoverageAudit = class _SnippetGateCoverageAudit extends Audit {
23190
23522
  };
23191
23523
  }
23192
23524
  audit(ctx) {
23525
+ if (!scanReadTheSite(ctx.evidence)) {
23526
+ return this.notApplicable(
23527
+ "No page here can be attributed to this site, so snippet permissions were not resolved.",
23528
+ EXPECTED22,
23529
+ unreadSiteReason(ctx.evidence)
23530
+ );
23531
+ }
23193
23532
  const page = ctx.pages[0];
23194
23533
  if (!page) {
23195
23534
  return this.notApplicable(
@@ -23664,6 +24003,13 @@ function chainOf($, el) {
23664
24003
  const parents = $(el).parents().toArray().filter((parent) => !["html", "body"].includes(parent.tagName)).reverse().map(describe3);
23665
24004
  return [...parents, describe3(el)].join(" > ");
23666
24005
  }
24006
+ function lastElementContaining($, text3) {
24007
+ const all = $("*").toArray();
24008
+ for (let i = all.length - 1; i >= 0; i--) {
24009
+ if ($(all[i]).text().includes(text3)) return all[i];
24010
+ }
24011
+ return void 0;
24012
+ }
23667
24013
  function needleOf(text3) {
23668
24014
  return normalizeText(text3).split(" ").slice(0, SPAN_WORDS).join(" ");
23669
24015
  }
@@ -23696,8 +24042,8 @@ function keySpans(page) {
23696
24042
  if (typeof value === "string") {
23697
24043
  const needle = needleOf(value);
23698
24044
  if (needle.split(" ").length >= 3 && bodyText.includes(needle)) {
23699
- const host = $(`:contains("${value.slice(0, 40).replace(/"/g, "")}")`).last();
23700
- push("json-ld", host[0] ?? $("body")[0], value);
24045
+ const host = lastElementContaining($, value.slice(0, 40));
24046
+ push("json-ld", host ?? $("body")[0], value);
23701
24047
  }
23702
24048
  return;
23703
24049
  }
@@ -23741,6 +24087,13 @@ var ExtractorSurvivalRecallAudit = class extends Audit {
23741
24087
  }
23742
24088
  };
23743
24089
  audit(ctx) {
24090
+ if (!scanReadTheSite(ctx.evidence)) {
24091
+ return this.notApplicable(
24092
+ "No page here can be attributed to this site, so no key span was measured.",
24093
+ "A page from this site whose key spans can be measured",
24094
+ unreadSiteReason(ctx.evidence)
24095
+ );
24096
+ }
23744
24097
  const page = ctx.pages[0];
23745
24098
  if (!page) {
23746
24099
  return this.notApplicable(
@@ -31178,8 +31531,10 @@ var NoBlockingCaptchaAudit = class _NoBlockingCaptchaAudit extends Audit {
31178
31531
  evidenceGrade: "A",
31179
31532
  tier: "scored",
31180
31533
  dossier: "docs/evidence/audits/operability-safety/no-blocking-captcha.md",
31181
- // Gate exemption: A captcha wall is what this audit reports.
31182
- requires: ["origin-reachable"],
31534
+ // Gate exemption: a captcha wall is what this audit reports, and a wall denies
31535
+ // `origin-reachable` — gating on it made the finding unreachable for the 403 that
31536
+ // produced it. The wall branch reads `wafProtection`, not any response body.
31537
+ requires: [],
31183
31538
  defaultPriority: "high",
31184
31539
  guidance: {
31185
31540
  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.',
@@ -31212,6 +31567,13 @@ var NoBlockingCaptchaAudit = class _NoBlockingCaptchaAudit extends Audit {
31212
31567
  ctx.baseUrl
31213
31568
  );
31214
31569
  }
31570
+ if (!scanReadTheSite(ctx.evidence)) {
31571
+ return this.notApplicable(
31572
+ "No page here can be attributed to this site, so no form was inspected for a CAPTCHA.",
31573
+ "No recaptcha, hcaptcha, or turnstile script includes detected",
31574
+ unreadSiteReason(ctx.evidence)
31575
+ );
31576
+ }
31215
31577
  if (ctx.pages.length === 0) {
31216
31578
  return this.notApplicable(
31217
31579
  "No page was fetched, so no form could be inspected for a blocking CAPTCHA.",
@@ -31229,6 +31591,13 @@ var NoBlockingCaptchaAudit = class _NoBlockingCaptchaAudit extends Audit {
31229
31591
  }
31230
31592
  }
31231
31593
  if (detectedCaptchas.length === 0) {
31594
+ if (!scanReadPageText(ctx.evidence)) {
31595
+ return this.notApplicable(
31596
+ "The scanned page served no readable text, so no form was inspected for a CAPTCHA.",
31597
+ "No recaptcha, hcaptcha, or turnstile script includes detected",
31598
+ unreadPageTextReason(ctx.evidence)
31599
+ );
31600
+ }
31232
31601
  return this.pass(
31233
31602
  "No blocking CAPTCHA scripts detected on scanned pages.",
31234
31603
  "No recaptcha, hcaptcha, or turnstile script includes detected",
@@ -33066,6 +33435,13 @@ var InvisibleInstructionScanAudit = class _InvisibleInstructionScanAudit extends
33066
33435
  };
33067
33436
  }
33068
33437
  async audit(ctx) {
33438
+ if (!scanReadTheSite(ctx.evidence)) {
33439
+ return this.notApplicable(
33440
+ "No page here can be attributed to this site, so its hidden text was not judged.",
33441
+ EXPECTED50,
33442
+ unreadSiteReason(ctx.evidence)
33443
+ );
33444
+ }
33069
33445
  const s = await survey9(ctx);
33070
33446
  const partial = s.crossOrigin > 0 ? `; ${s.crossOrigin} cross-origin stylesheet not fetched` : "";
33071
33447
  if (s.textNodesSeen === 0) {
@@ -33343,6 +33719,13 @@ var AriaLayerInjectionScanAudit = class _AriaLayerInjectionScanAudit extends Aud
33343
33719
  };
33344
33720
  }
33345
33721
  audit(ctx) {
33722
+ if (!scanReadTheSite(ctx.evidence)) {
33723
+ return this.notApplicable(
33724
+ "No page here can be attributed to this site, so its non-visual values were not judged.",
33725
+ EXPECTED51,
33726
+ unreadSiteReason(ctx.evidence)
33727
+ );
33728
+ }
33346
33729
  const s = survey10(ctx);
33347
33730
  if (s.valuesSeen === 0) {
33348
33731
  return this.notApplicable(
@@ -33388,6 +33771,13 @@ var AriaLayerInjectionScanAudit = class _AriaLayerInjectionScanAudit extends Aud
33388
33771
  warnings[0].pageUrl
33389
33772
  );
33390
33773
  }
33774
+ if (!scanReadPageText(ctx.evidence)) {
33775
+ return this.notApplicable(
33776
+ "The scanned page served no readable text, so its accessibility layer was not judged.",
33777
+ EXPECTED51,
33778
+ unreadPageTextReason(ctx.evidence)
33779
+ );
33780
+ }
33391
33781
  return this.pass(
33392
33782
  `All ${s.valuesSeen} non-visual value(s) are descriptions that agree with their element and carry no instruction addressed to an AI.`,
33393
33783
  EXPECTED51,
@@ -33507,6 +33897,13 @@ var GhostClickableElementRatioAudit = class _GhostClickableElementRatioAudit ext
33507
33897
  };
33508
33898
  }
33509
33899
  async audit(ctx) {
33900
+ if (!scanReadTheSite(ctx.evidence)) {
33901
+ return this.notApplicable(
33902
+ "No page here can be attributed to this site, so its click targets were not counted.",
33903
+ EXPECTED52,
33904
+ unreadSiteReason(ctx.evidence)
33905
+ );
33906
+ }
33510
33907
  const s = await survey11(ctx);
33511
33908
  const total = s.semantic + s.ghosts.length;
33512
33909
  const partial = s.crossOrigin > 0 ? `; ${s.crossOrigin} cross-origin stylesheet not fetched` : "";
@@ -34871,6 +35268,13 @@ var UnicodeCovertChannelScanAudit = class _UnicodeCovertChannelScanAudit extends
34871
35268
  };
34872
35269
  }
34873
35270
  audit(ctx) {
35271
+ if (!scanReadTheSite(ctx.evidence)) {
35272
+ return this.notApplicable(
35273
+ "No response here can be attributed to this site, so its codepoints were not judged.",
35274
+ EXPECTED58,
35275
+ unreadSiteReason(ctx.evidence)
35276
+ );
35277
+ }
34874
35278
  const hits2 = [];
34875
35279
  for (const page of ctx.pages) hits2.push(...scanPage(page));
34876
35280
  for (const path of ROOT_FILES) {
@@ -34897,6 +35301,16 @@ var UnicodeCovertChannelScanAudit = class _UnicodeCovertChannelScanAudit extends
34897
35301
  fillerCount: filler
34898
35302
  };
34899
35303
  if (hits2.length === 0) {
35304
+ if (!scanReadPageText(ctx.evidence)) {
35305
+ return {
35306
+ ...this.notApplicable(
35307
+ "The scanned page served no readable text, so its codepoints were not judged.",
35308
+ EXPECTED58,
35309
+ unreadPageTextReason(ctx.evidence)
35310
+ ),
35311
+ details
35312
+ };
35313
+ }
34900
35314
  return {
34901
35315
  ...this.pass(
34902
35316
  "No invisible codepoint carries text on the scanned pages or in the root files.",
@@ -35092,7 +35506,10 @@ var ThirdPartyDomWriteBlastRadiusAudit = class _ThirdPartyDomWriteBlastRadiusAud
35092
35506
  evidenceGrade: "B",
35093
35507
  tier: "scored",
35094
35508
  dossier: "docs/evidence/audits/operability-safety/third-party-dom-write-blast-radius.md",
35095
- requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
35509
+ // Gate exemption: every origin the served HTML names is counted whether or not the
35510
+ // body renders, so a page that ships a vendor script statically is still reported.
35511
+ // The empty census is the case a shell cannot support, and `audit()` declines it.
35512
+ requires: ["origin-reachable", "unblocked-fetches"],
35096
35513
  defaultPriority: "high",
35097
35514
  guidance: {
35098
35515
  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.',
@@ -35111,6 +35528,13 @@ var ThirdPartyDomWriteBlastRadiusAudit = class _ThirdPartyDomWriteBlastRadiusAud
35111
35528
  };
35112
35529
  }
35113
35530
  audit(ctx) {
35531
+ if (!scanReadTheSite(ctx.evidence)) {
35532
+ return this.notApplicable(
35533
+ "No page here can be attributed to this site, so its third-party surface was not measured.",
35534
+ EXPECTED59,
35535
+ unreadSiteReason(ctx.evidence)
35536
+ );
35537
+ }
35114
35538
  if (ctx.pages.length === 0) {
35115
35539
  return this.notApplicable(
35116
35540
  "No page was fetched, so there is no third-party surface to measure.",
@@ -35150,6 +35574,17 @@ var ThirdPartyDomWriteBlastRadiusAudit = class _ThirdPartyDomWriteBlastRadiusAud
35150
35574
  details
35151
35575
  };
35152
35576
  }
35577
+ if (!scanReadPageText(ctx.evidence)) {
35578
+ return {
35579
+ ...this.notApplicable(
35580
+ "The scanned page served no readable text, so the origins writing into it were not counted.",
35581
+ EXPECTED59,
35582
+ unreadPageTextReason(ctx.evidence)
35583
+ ),
35584
+ displayValue: found,
35585
+ details
35586
+ };
35587
+ }
35153
35588
  return {
35154
35589
  ...this.pass(
35155
35590
  "No third-party origin ships executable code into the page, so nothing but the site itself writes what an agent reads.",
@@ -35307,6 +35742,13 @@ var UnsafeAgentTriggerableAffordancesAudit = class _UnsafeAgentTriggerableAfford
35307
35742
  };
35308
35743
  }
35309
35744
  audit(ctx) {
35745
+ if (!scanReadTheSite(ctx.evidence)) {
35746
+ return this.notApplicable(
35747
+ "No page here can be attributed to this site, so its links were not inspected.",
35748
+ EXPECTED60,
35749
+ unreadSiteReason(ctx.evidence)
35750
+ );
35751
+ }
35310
35752
  if (ctx.pages.length === 0) {
35311
35753
  return this.notApplicable(
35312
35754
  "No page was fetched, so there is no link to inspect.",
@@ -35325,6 +35767,16 @@ var UnsafeAgentTriggerableAffordancesAudit = class _UnsafeAgentTriggerableAfford
35325
35767
  urls: findings.slice(0, 10).map((f) => f.href)
35326
35768
  };
35327
35769
  if (findings.length === 0) {
35770
+ if (!scanReadPageText(ctx.evidence)) {
35771
+ return {
35772
+ ...this.notApplicable(
35773
+ "The scanned page served no readable text, so it exposed no links or forms to inspect.",
35774
+ EXPECTED60,
35775
+ unreadPageTextReason(ctx.evidence)
35776
+ ),
35777
+ details
35778
+ };
35779
+ }
35328
35780
  return {
35329
35781
  ...this.pass(
35330
35782
  "No link or GET form on the scanned pages changes state when it is fetched.",
@@ -37840,7 +38292,10 @@ async function runScan(url, options) {
37840
38292
  const signal = options?.signal;
37841
38293
  const tracker = new ProgressTracker((event) => onEvent?.(event));
37842
38294
  const start = performance.now();
37843
- const fetcher = createFetcher();
38295
+ const fetcher = createFetcher({
38296
+ dispatcher: options?.dispatcher,
38297
+ maxConcurrent: options?.maxConcurrent
38298
+ });
37844
38299
  const baseUrl = new URL(url).origin;
37845
38300
  const domain = new URL(url).hostname;
37846
38301
  const displayUrl = splitCredentials(url).url;
@@ -37898,13 +38353,18 @@ async function runScan(url, options) {
37898
38353
  ];
37899
38354
  logger.debug({ count: rootFilePaths.length }, "[orchestrator] Phase 1: Fetching root files");
37900
38355
  tracker.phaseStart("fetch-root", rootFilePaths.length);
38356
+ const prefetchedRobots = options?.robotsTxt;
37901
38357
  const rootResults = await Promise.all(
37902
- rootFilePaths.map(
37903
- (path) => fetcher.fetch({ url: `${baseUrl}${path}`, signal }).then((result) => {
38358
+ rootFilePaths.map((path) => {
38359
+ if (path === "/robots.txt" && prefetchedRobots) {
38360
+ tracker.unitDone(path);
38361
+ return Promise.resolve(prefetchedRobots);
38362
+ }
38363
+ return fetcher.fetch({ url: `${baseUrl}${path}`, signal }).then((result) => {
37904
38364
  tracker.unitDone(path);
37905
38365
  return result;
37906
- })
37907
- )
38366
+ });
38367
+ })
37908
38368
  );
37909
38369
  const rootFiles = {};
37910
38370
  rootFilePaths.forEach((path, i) => {
@@ -38255,6 +38715,7 @@ export {
38255
38715
  TAG_SKIPPED_PAGE_TYPE,
38256
38716
  allEvidenceMet,
38257
38717
  allJsonLdNodes,
38718
+ boundedDispatcher,
38258
38719
  buildCategoryResult,
38259
38720
  buildScanEvidence,
38260
38721
  calculateCategoryScore,