@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.d.mts +79 -5
- package/dist/index.d.ts +79 -5
- package/dist/index.js +699 -237
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +698 -237
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -182,8 +182,40 @@ async function isSafeUrl(url) {
|
|
|
182
182
|
return false;
|
|
183
183
|
}
|
|
184
184
|
}
|
|
185
|
-
function
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
1176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
3536
|
-
|
|
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
|
|
3552
|
-
return this.
|
|
3553
|
-
"Site is served over HTTPS
|
|
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
|
-
|
|
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 (
|
|
3560
|
-
return this.
|
|
3561
|
-
|
|
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
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
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.
|
|
3573
|
-
"Site
|
|
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
|
-
|
|
3882
|
+
`${ctx.baseUrl} \u2014 a 2xx response that carried no document`,
|
|
3576
3883
|
{
|
|
3577
|
-
priority: "
|
|
3578
|
-
description: "
|
|
3579
|
-
code: "#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
23700
|
-
push("json-ld", host
|
|
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:
|
|
31182
|
-
|
|
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
|
-
|
|
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
|
|
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,
|