@forkpoint/agent-lighthouse-core 2.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 +224 -10
- package/dist/index.d.ts +224 -10
- package/dist/index.js +1129 -152
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1123 -152
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -48,6 +48,7 @@ __export(index_exports, {
|
|
|
48
48
|
DEFAULT_SCAN_LIMIT: () => DEFAULT_SCAN_LIMIT,
|
|
49
49
|
DeprecationNoticeSchema: () => DeprecationNoticeSchema,
|
|
50
50
|
EvidenceGradeSchema: () => EvidenceGradeSchema,
|
|
51
|
+
EvidenceKeySchema: () => EvidenceKeySchema,
|
|
51
52
|
FixEffortSchema: () => FixEffortSchema,
|
|
52
53
|
MAX_CONCURRENT_REQUESTS: () => MAX_CONCURRENT_REQUESTS,
|
|
53
54
|
MAX_PAGES_PER_SCAN: () => MAX_PAGES_PER_SCAN,
|
|
@@ -63,9 +64,13 @@ __export(index_exports, {
|
|
|
63
64
|
SCORE_TIER_LABELS: () => SCORE_TIER_LABELS,
|
|
64
65
|
ScoreDisplayModeSchema: () => ScoreDisplayModeSchema,
|
|
65
66
|
TAG_SCAN_ERROR: () => TAG_SCAN_ERROR,
|
|
67
|
+
TAG_SKIPPED_NO_EVIDENCE: () => TAG_SKIPPED_NO_EVIDENCE,
|
|
66
68
|
TAG_SKIPPED_PAGE_TYPE: () => TAG_SKIPPED_PAGE_TYPE,
|
|
69
|
+
allEvidenceMet: () => allEvidenceMet,
|
|
67
70
|
allJsonLdNodes: () => allJsonLdNodes,
|
|
71
|
+
boundedDispatcher: () => boundedDispatcher,
|
|
68
72
|
buildCategoryResult: () => buildCategoryResult,
|
|
73
|
+
buildScanEvidence: () => buildScanEvidence,
|
|
69
74
|
calculateCategoryScore: () => calculateCategoryScore,
|
|
70
75
|
calculateOverallScore: () => calculateOverallScore,
|
|
71
76
|
classifyFetch: () => classifyFetch,
|
|
@@ -96,6 +101,7 @@ __export(index_exports, {
|
|
|
96
101
|
formatTrace: () => formatTrace,
|
|
97
102
|
getMainContentText: () => getMainContentText,
|
|
98
103
|
getPreset: () => getPreset,
|
|
104
|
+
getRenderedText: () => getRenderedText,
|
|
99
105
|
getScoreTier: () => getScoreTier,
|
|
100
106
|
getTierColor: () => getTierColor,
|
|
101
107
|
getTierLabel: () => getTierLabel,
|
|
@@ -145,6 +151,7 @@ var MAX_CONCURRENT_REQUESTS = 10;
|
|
|
145
151
|
var SCANNER_USER_AGENT = "AgentLighthouse/1.0 (+https://github.com/ForkPoint/agent-lighthouse)";
|
|
146
152
|
var TAG_SKIPPED_PAGE_TYPE = "skipped:page-type";
|
|
147
153
|
var TAG_SCAN_ERROR = "scan-error";
|
|
154
|
+
var TAG_SKIPPED_NO_EVIDENCE = "skipped:no-evidence";
|
|
148
155
|
var CATEGORY_NAMES = {
|
|
149
156
|
"access-crawl-control": "Access & Crawl Control",
|
|
150
157
|
"content-extraction": "Content Extraction",
|
|
@@ -318,8 +325,40 @@ async function isSafeUrl(url) {
|
|
|
318
325
|
return false;
|
|
319
326
|
}
|
|
320
327
|
}
|
|
321
|
-
function
|
|
328
|
+
function createGate(limit) {
|
|
329
|
+
let inFlight = 0;
|
|
330
|
+
const waiting = [];
|
|
331
|
+
const release2 = () => {
|
|
332
|
+
inFlight -= 1;
|
|
333
|
+
const next = waiting.shift();
|
|
334
|
+
if (next) next();
|
|
335
|
+
};
|
|
336
|
+
return {
|
|
337
|
+
acquire: async () => {
|
|
338
|
+
if (inFlight >= limit) {
|
|
339
|
+
await new Promise((resolve4) => waiting.push(resolve4));
|
|
340
|
+
}
|
|
341
|
+
inFlight += 1;
|
|
342
|
+
return release2;
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
function boundedDispatcher(connections) {
|
|
347
|
+
return new import_undici.Agent({ connections });
|
|
348
|
+
}
|
|
349
|
+
function createFetcher(fetcherOptions = {}) {
|
|
350
|
+
const dispatcher = fetcherOptions.dispatcher ?? noRedirectAgent;
|
|
351
|
+
const gate = fetcherOptions.maxConcurrent && fetcherOptions.maxConcurrent > 0 ? createGate(Math.floor(fetcherOptions.maxConcurrent)) : void 0;
|
|
322
352
|
async function fetch(options) {
|
|
353
|
+
if (!gate) return issue(options);
|
|
354
|
+
const release2 = await gate.acquire();
|
|
355
|
+
try {
|
|
356
|
+
return await issue(options);
|
|
357
|
+
} finally {
|
|
358
|
+
release2();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
async function issue(options) {
|
|
323
362
|
const {
|
|
324
363
|
url,
|
|
325
364
|
timeout = REQUEST_TIMEOUT_MS,
|
|
@@ -359,10 +398,11 @@ function createFetcher() {
|
|
|
359
398
|
headers: reqHeaders,
|
|
360
399
|
body: currentBody,
|
|
361
400
|
signal,
|
|
362
|
-
dispatcher
|
|
401
|
+
dispatcher
|
|
363
402
|
});
|
|
364
403
|
let gateArmed;
|
|
365
404
|
let hops = 0;
|
|
405
|
+
const redirectChain = [];
|
|
366
406
|
while (followRedirects && REDIRECT_STATUS.has(response.statusCode) && response.headers["location"] !== void 0 && hops < MAX_REDIRECTS) {
|
|
367
407
|
const rawLocation = response.headers["location"];
|
|
368
408
|
const location = Array.isArray(rawLocation) ? rawLocation[0] : rawLocation;
|
|
@@ -398,6 +438,7 @@ function createFetcher() {
|
|
|
398
438
|
currentMethod = "GET";
|
|
399
439
|
currentBody = void 0;
|
|
400
440
|
}
|
|
441
|
+
redirectChain.push({ status: response.statusCode, from: currentUrl, to: next });
|
|
401
442
|
currentUrl = next;
|
|
402
443
|
hops += 1;
|
|
403
444
|
response = await (0, import_undici.request)(currentUrl, {
|
|
@@ -405,7 +446,7 @@ function createFetcher() {
|
|
|
405
446
|
headers: reqHeaders,
|
|
406
447
|
body: currentBody,
|
|
407
448
|
signal,
|
|
408
|
-
dispatcher
|
|
449
|
+
dispatcher
|
|
409
450
|
});
|
|
410
451
|
}
|
|
411
452
|
ttfbMs = performance.now() - start;
|
|
@@ -444,7 +485,8 @@ function createFetcher() {
|
|
|
444
485
|
totalMs: Math.round(totalMs),
|
|
445
486
|
contentType: headers["content-type"] ?? "",
|
|
446
487
|
contentLength: bytes ? bytes.byteLength : truncatedBody.length,
|
|
447
|
-
...bytes ? { bytes } : {}
|
|
488
|
+
...bytes ? { bytes } : {},
|
|
489
|
+
...redirectChain.length > 0 ? { redirectChain } : {}
|
|
448
490
|
};
|
|
449
491
|
} catch (err) {
|
|
450
492
|
const totalMs = performance.now() - start;
|
|
@@ -693,12 +735,23 @@ function extractHeadings($) {
|
|
|
693
735
|
});
|
|
694
736
|
return headings;
|
|
695
737
|
}
|
|
696
|
-
function
|
|
697
|
-
const root = $("main").first().length ? $("main").first() : $("body");
|
|
738
|
+
function readableText(root) {
|
|
698
739
|
const clone = root.clone();
|
|
699
740
|
clone.find("script, style, noscript, template").remove();
|
|
700
741
|
return clone.text().replace(/\s+/g, " ").trim();
|
|
701
742
|
}
|
|
743
|
+
function getMainContentText($) {
|
|
744
|
+
let best = "";
|
|
745
|
+
$("body").find("main").each((_, el) => {
|
|
746
|
+
const text3 = readableText($(el));
|
|
747
|
+
if (text3.length > best.length) best = text3;
|
|
748
|
+
});
|
|
749
|
+
if (best) return best;
|
|
750
|
+
return readableText($("body"));
|
|
751
|
+
}
|
|
752
|
+
function getRenderedText($) {
|
|
753
|
+
return readableText($("body"));
|
|
754
|
+
}
|
|
702
755
|
function getWordCount($) {
|
|
703
756
|
const text3 = getMainContentText($);
|
|
704
757
|
return text3.split(/\s+/).filter(Boolean).length;
|
|
@@ -932,6 +985,12 @@ var DeprecationNoticeSchema = import_zod.z.object({
|
|
|
932
985
|
var EvidenceGradeSchema = import_zod.z.enum(["A", "B", "C", "D"]);
|
|
933
986
|
var AuditTierSchema = import_zod.z.enum(["scored", "informative", "experimental"]);
|
|
934
987
|
var AUDIT_ID_PATTERN = /^[a-z-]+\/[a-z0-9-]+$/;
|
|
988
|
+
var EvidenceKeySchema = import_zod.z.enum([
|
|
989
|
+
"origin-reachable",
|
|
990
|
+
"unblocked-fetches",
|
|
991
|
+
"rendered-body",
|
|
992
|
+
"sample-adequate"
|
|
993
|
+
]);
|
|
935
994
|
var AuditMetaSchema = import_zod.z.object({
|
|
936
995
|
id: import_zod.z.string().regex(AUDIT_ID_PATTERN, "audit id must be a `category/slug` path"),
|
|
937
996
|
category: import_zod.z.string(),
|
|
@@ -950,7 +1009,10 @@ var AuditMetaSchema = import_zod.z.object({
|
|
|
950
1009
|
// its weight comes from (grade + tier) and which dossier proves it.
|
|
951
1010
|
evidenceGrade: EvidenceGradeSchema,
|
|
952
1011
|
tier: AuditTierSchema,
|
|
953
|
-
dossier: import_zod.z.string().min(1).max(500)
|
|
1012
|
+
dossier: import_zod.z.string().min(1).max(500),
|
|
1013
|
+
// What the audit needs the scan to have obtained. Checked against the
|
|
1014
|
+
// source by `scripts/check-requires.mjs`, not enforced here beyond shape.
|
|
1015
|
+
requires: import_zod.z.array(EvidenceKeySchema).optional()
|
|
954
1016
|
});
|
|
955
1017
|
var CheckResultSchema = import_zod.z.object({
|
|
956
1018
|
// v2 ids are `category/slug` paths, which outgrew the old 20-char cap.
|
|
@@ -1175,6 +1237,210 @@ function calculateOverallScore(categories) {
|
|
|
1175
1237
|
if (totalMass === 0) return 0;
|
|
1176
1238
|
return Math.round(weighted / totalMass);
|
|
1177
1239
|
}
|
|
1240
|
+
var GATED_MASS_UNSCORED_THRESHOLD = 0.35;
|
|
1241
|
+
function gatedMassShare(checks2) {
|
|
1242
|
+
let gated = 0;
|
|
1243
|
+
let total = 0;
|
|
1244
|
+
for (const check of checks2) {
|
|
1245
|
+
if (isInformative(check)) continue;
|
|
1246
|
+
const mass = check.weight ?? 0;
|
|
1247
|
+
if (mass <= 0) continue;
|
|
1248
|
+
total += mass;
|
|
1249
|
+
if (check.tags?.includes(TAG_SKIPPED_NO_EVIDENCE)) gated += mass;
|
|
1250
|
+
}
|
|
1251
|
+
return total === 0 ? 0 : gated / total;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// src/gatherers/domains.ts
|
|
1255
|
+
var MULTI_SUFFIX = /* @__PURE__ */ new Set([
|
|
1256
|
+
"co.uk",
|
|
1257
|
+
"org.uk",
|
|
1258
|
+
"ac.uk",
|
|
1259
|
+
"gov.uk",
|
|
1260
|
+
"me.uk",
|
|
1261
|
+
"net.uk",
|
|
1262
|
+
"com.au",
|
|
1263
|
+
"net.au",
|
|
1264
|
+
"org.au",
|
|
1265
|
+
"edu.au",
|
|
1266
|
+
"gov.au",
|
|
1267
|
+
"co.nz",
|
|
1268
|
+
"co.jp",
|
|
1269
|
+
"or.jp",
|
|
1270
|
+
"ne.jp",
|
|
1271
|
+
"co.za",
|
|
1272
|
+
"co.kr",
|
|
1273
|
+
"co.il",
|
|
1274
|
+
"co.id",
|
|
1275
|
+
"co.th",
|
|
1276
|
+
"com.br",
|
|
1277
|
+
"com.mx",
|
|
1278
|
+
"com.ar",
|
|
1279
|
+
"com.co",
|
|
1280
|
+
"com.pe",
|
|
1281
|
+
"co.in",
|
|
1282
|
+
"com.sg",
|
|
1283
|
+
"com.tr",
|
|
1284
|
+
"com.cn",
|
|
1285
|
+
"com.hk",
|
|
1286
|
+
"com.tw",
|
|
1287
|
+
"com.my",
|
|
1288
|
+
"com.ph",
|
|
1289
|
+
"com.ua",
|
|
1290
|
+
"com.pl",
|
|
1291
|
+
"com.es",
|
|
1292
|
+
"com.pt",
|
|
1293
|
+
"com.gr"
|
|
1294
|
+
]);
|
|
1295
|
+
function registrableDomain(host) {
|
|
1296
|
+
const parts = host.toLowerCase().replace(/\.$/, "").split(".").filter(Boolean);
|
|
1297
|
+
if (parts.length <= 2) return parts.join(".");
|
|
1298
|
+
const lastTwo = parts.slice(-2).join(".");
|
|
1299
|
+
return MULTI_SUFFIX.has(lastTwo) ? parts.slice(-3).join(".") : lastTwo;
|
|
1300
|
+
}
|
|
1301
|
+
function registrableOf(url) {
|
|
1302
|
+
try {
|
|
1303
|
+
return registrableDomain(new URL(url).hostname);
|
|
1304
|
+
} catch {
|
|
1305
|
+
return "";
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// src/scan-evidence.ts
|
|
1310
|
+
var ALL_PAGE_TYPES = ["homepage", "category", "product", "content"];
|
|
1311
|
+
var HTML_TYPES = ["text/html", "application/xhtml+xml"];
|
|
1312
|
+
var PERMANENT_REDIRECT = /* @__PURE__ */ new Set([301, 308]);
|
|
1313
|
+
function bareHost(url) {
|
|
1314
|
+
try {
|
|
1315
|
+
return new URL(url).hostname.toLowerCase().replace(/^www\./, "");
|
|
1316
|
+
} catch {
|
|
1317
|
+
return "";
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
function registrableName(url) {
|
|
1321
|
+
const domain = registrableOf(url);
|
|
1322
|
+
if (!domain) return "";
|
|
1323
|
+
const parts = domain.split(".");
|
|
1324
|
+
return parts.length > 1 ? parts.slice(0, -1).join(".") : domain;
|
|
1325
|
+
}
|
|
1326
|
+
function reachedTheRequestedSite(requestedUrl, result) {
|
|
1327
|
+
const requested = bareHost(requestedUrl);
|
|
1328
|
+
const final = bareHost(result.finalUrl || result.url);
|
|
1329
|
+
if (!final) return { ok: false, reason: `The homepage response carried no usable URL.` };
|
|
1330
|
+
if (requested === final) return { ok: true };
|
|
1331
|
+
const requestedDomain = registrableOf(requestedUrl);
|
|
1332
|
+
const finalDomain = registrableOf(result.finalUrl || result.url);
|
|
1333
|
+
if (requestedDomain && requestedDomain === finalDomain) return { ok: true };
|
|
1334
|
+
const requestedName = registrableName(requestedUrl);
|
|
1335
|
+
if (requestedName && requestedName === registrableName(result.finalUrl || result.url)) {
|
|
1336
|
+
return { ok: true };
|
|
1337
|
+
}
|
|
1338
|
+
const chain = result.redirectChain ?? [];
|
|
1339
|
+
const leaving = chain.filter((hop) => registrableOf(hop.from) !== registrableOf(hop.to));
|
|
1340
|
+
if (leaving.length > 0 && leaving.every((hop) => PERMANENT_REDIRECT.has(hop.status))) {
|
|
1341
|
+
return { ok: true };
|
|
1342
|
+
}
|
|
1343
|
+
return {
|
|
1344
|
+
ok: false,
|
|
1345
|
+
reason: `The requested host redirected to ${final}, a different site, without a permanent redirect.`
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
function originReachable(requestedUrl, result) {
|
|
1349
|
+
if (result.error) {
|
|
1350
|
+
return { met: false, reason: `The homepage could not be fetched: ${result.error}.` };
|
|
1351
|
+
}
|
|
1352
|
+
if (result.status < 200 || result.status > 299) {
|
|
1353
|
+
return { met: false, reason: `The homepage answered HTTP ${result.status}.` };
|
|
1354
|
+
}
|
|
1355
|
+
const type = (result.contentType || "").toLowerCase();
|
|
1356
|
+
if (!HTML_TYPES.some((html) => type.includes(html))) {
|
|
1357
|
+
return {
|
|
1358
|
+
met: false,
|
|
1359
|
+
reason: `The homepage served ${result.contentType || "no content type"}, not HTML.`
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
const reached = reachedTheRequestedSite(requestedUrl, result);
|
|
1363
|
+
return reached.ok ? { met: true } : { met: false, reason: reached.reason };
|
|
1364
|
+
}
|
|
1365
|
+
function unblockedFetches(homepageResult, waf) {
|
|
1366
|
+
if (waf?.isBlocked) {
|
|
1367
|
+
return waf.isRateLimit ? {
|
|
1368
|
+
met: false,
|
|
1369
|
+
reason: `The scan was throttled (${waf.name}): ${waf.reason}.`
|
|
1370
|
+
} : { met: false, reason: `${waf.name} refused the scan: ${waf.reason}.` };
|
|
1371
|
+
}
|
|
1372
|
+
if (homepageResult.status === 429) {
|
|
1373
|
+
return { met: false, reason: "The homepage answered HTTP 429: the scan was throttled." };
|
|
1374
|
+
}
|
|
1375
|
+
return { met: true };
|
|
1376
|
+
}
|
|
1377
|
+
function pageRendersText(page) {
|
|
1378
|
+
const text3 = getRenderedText(page.$);
|
|
1379
|
+
const wordCount2 = text3.split(/\s+/).filter(Boolean).length;
|
|
1380
|
+
return wordCount2 > 50 || text3.length > 200;
|
|
1381
|
+
}
|
|
1382
|
+
function buildScanEvidence(input) {
|
|
1383
|
+
const origin = originReachable(input.requestedUrl, input.homepageResult);
|
|
1384
|
+
const unblocked = unblockedFetches(input.homepageResult, input.wafProtection);
|
|
1385
|
+
const renderedByPage = {};
|
|
1386
|
+
const usablePageTypes = /* @__PURE__ */ new Set();
|
|
1387
|
+
for (const page of input.pages) {
|
|
1388
|
+
const rendered = pageRendersText(page);
|
|
1389
|
+
renderedByPage[page.url] = rendered;
|
|
1390
|
+
if (rendered) usablePageTypes.add(page.pageType);
|
|
1391
|
+
}
|
|
1392
|
+
const renderedCount = Object.values(renderedByPage).filter(Boolean).length;
|
|
1393
|
+
const met = {
|
|
1394
|
+
"origin-reachable": origin.met,
|
|
1395
|
+
"unblocked-fetches": unblocked.met,
|
|
1396
|
+
"rendered-body": renderedCount > 0,
|
|
1397
|
+
"sample-adequate": usablePageTypes.size > 0
|
|
1398
|
+
};
|
|
1399
|
+
const reasons = {};
|
|
1400
|
+
if (origin.reason) reasons["origin-reachable"] = origin.reason;
|
|
1401
|
+
if (unblocked.reason) reasons["unblocked-fetches"] = unblocked.reason;
|
|
1402
|
+
if (!met["rendered-body"]) {
|
|
1403
|
+
reasons["rendered-body"] = input.pages.length === 0 ? "The scan fetched no pages." : `None of the ${input.pages.length} fetched page(s) served readable text.`;
|
|
1404
|
+
}
|
|
1405
|
+
if (!met["sample-adequate"]) {
|
|
1406
|
+
reasons["sample-adequate"] = input.pages.length === 0 ? "The scan fetched no pages." : "No fetched page of any type served readable text.";
|
|
1407
|
+
}
|
|
1408
|
+
return {
|
|
1409
|
+
met,
|
|
1410
|
+
reasons,
|
|
1411
|
+
renderedByPage,
|
|
1412
|
+
usablePageTypes,
|
|
1413
|
+
// A shell site was seen. What it serves is a finding about it, so
|
|
1414
|
+
// `rendered-body` and `sample-adequate` do not clear `judgeable`.
|
|
1415
|
+
judgeable: met["origin-reachable"] && met["unblocked-fetches"]
|
|
1416
|
+
};
|
|
1417
|
+
}
|
|
1418
|
+
function scanReadTheSite(evidence) {
|
|
1419
|
+
return evidence.judgeable;
|
|
1420
|
+
}
|
|
1421
|
+
function unreadSiteReason(evidence) {
|
|
1422
|
+
return evidence.reasons["origin-reachable"] ?? evidence.reasons["unblocked-fetches"] ?? "The scan obtained no response it could attribute to this site.";
|
|
1423
|
+
}
|
|
1424
|
+
function scanReadPageText(evidence) {
|
|
1425
|
+
return evidence.met["rendered-body"];
|
|
1426
|
+
}
|
|
1427
|
+
function unreadPageTextReason(evidence) {
|
|
1428
|
+
return evidence.reasons["rendered-body"] ?? "No fetched page served text a non-JS consumer can read.";
|
|
1429
|
+
}
|
|
1430
|
+
function allEvidenceMet() {
|
|
1431
|
+
return {
|
|
1432
|
+
met: {
|
|
1433
|
+
"origin-reachable": true,
|
|
1434
|
+
"unblocked-fetches": true,
|
|
1435
|
+
"rendered-body": true,
|
|
1436
|
+
"sample-adequate": true
|
|
1437
|
+
},
|
|
1438
|
+
reasons: {},
|
|
1439
|
+
renderedByPage: {},
|
|
1440
|
+
usablePageTypes: new Set(ALL_PAGE_TYPES),
|
|
1441
|
+
judgeable: true
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1178
1444
|
|
|
1179
1445
|
// src/audits/access-crawl-control/no-nofollow.ts
|
|
1180
1446
|
var NoNofollowAudit = class _NoNofollowAudit extends Audit {
|
|
@@ -1189,6 +1455,9 @@ var NoNofollowAudit = class _NoNofollowAudit extends Audit {
|
|
|
1189
1455
|
evidenceGrade: "A",
|
|
1190
1456
|
tier: "scored",
|
|
1191
1457
|
dossier: "docs/evidence/audits/access-crawl-control/no-nofollow.md",
|
|
1458
|
+
// Gate exemption: being refused is what this category reports, and the meta tag and
|
|
1459
|
+
// header this audit reads are served by a page whose body renders nothing.
|
|
1460
|
+
requires: ["origin-reachable"],
|
|
1192
1461
|
defaultPriority: "high",
|
|
1193
1462
|
guidance: {
|
|
1194
1463
|
impact: "A nofollow directive prevents AI crawlers from following links on your pages, effectively hiding all linked content from AI indexing. Your deeper pages become invisible to AI search engines, drastically reducing discoverability.",
|
|
@@ -1200,6 +1469,13 @@ var NoNofollowAudit = class _NoNofollowAudit extends Audit {
|
|
|
1200
1469
|
}
|
|
1201
1470
|
};
|
|
1202
1471
|
audit(ctx) {
|
|
1472
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
1473
|
+
return this.notApplicable(
|
|
1474
|
+
"No page here can be attributed to this site, so its nofollow directives were not judged.",
|
|
1475
|
+
"No site-wide nofollow directives",
|
|
1476
|
+
unreadSiteReason(ctx.evidence)
|
|
1477
|
+
);
|
|
1478
|
+
}
|
|
1203
1479
|
if (ctx.pages.length === 0) {
|
|
1204
1480
|
return this.fail(
|
|
1205
1481
|
"No pages scanned.",
|
|
@@ -1270,6 +1546,10 @@ var NoRedirectChainsAudit = class _NoRedirectChainsAudit extends Audit {
|
|
|
1270
1546
|
evidenceGrade: "A",
|
|
1271
1547
|
tier: "scored",
|
|
1272
1548
|
dossier: "docs/evidence/audits/access-crawl-control/no-redirect-chains.md",
|
|
1549
|
+
// Gate exemption: a hop that left the site is this audit's subject, and leaving the
|
|
1550
|
+
// site is exactly what denies `origin-reachable`. It reads request URL against final
|
|
1551
|
+
// URL, which every response carries, and reports "no pages scanned" itself.
|
|
1552
|
+
requires: [],
|
|
1273
1553
|
defaultPriority: "medium",
|
|
1274
1554
|
guidance: {
|
|
1275
1555
|
impact: "Redirect chains slow down AI crawlers and waste their limited crawl budget. Each extra redirect adds latency and increases the chance a crawler gives up before reaching the final page, leaving content unindexed.",
|
|
@@ -1280,17 +1560,6 @@ var NoRedirectChainsAudit = class _NoRedirectChainsAudit extends Audit {
|
|
|
1280
1560
|
}
|
|
1281
1561
|
};
|
|
1282
1562
|
audit(ctx) {
|
|
1283
|
-
if (ctx.pages.length === 0) {
|
|
1284
|
-
return this.fail(
|
|
1285
|
-
"No pages scanned.",
|
|
1286
|
-
"No redirect chains (URL equals finalUrl or single redirect)",
|
|
1287
|
-
"No pages scanned",
|
|
1288
|
-
{
|
|
1289
|
-
priority: "medium",
|
|
1290
|
-
description: _NoRedirectChainsAudit.meta.description
|
|
1291
|
-
}
|
|
1292
|
-
);
|
|
1293
|
-
}
|
|
1294
1563
|
const redirected = [];
|
|
1295
1564
|
for (const page of ctx.pages) {
|
|
1296
1565
|
const requestUrl = page.fetchResult.url;
|
|
@@ -1300,6 +1569,24 @@ var NoRedirectChainsAudit = class _NoRedirectChainsAudit extends Audit {
|
|
|
1300
1569
|
}
|
|
1301
1570
|
}
|
|
1302
1571
|
if (redirected.length === 0) {
|
|
1572
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
1573
|
+
return this.notApplicable(
|
|
1574
|
+
"No page here can be attributed to this site, so its redirect behaviour was not judged.",
|
|
1575
|
+
"No redirect chains",
|
|
1576
|
+
unreadSiteReason(ctx.evidence)
|
|
1577
|
+
);
|
|
1578
|
+
}
|
|
1579
|
+
if (ctx.pages.length === 0) {
|
|
1580
|
+
return this.fail(
|
|
1581
|
+
"No pages scanned.",
|
|
1582
|
+
"No redirect chains (URL equals finalUrl or single redirect)",
|
|
1583
|
+
"No pages scanned",
|
|
1584
|
+
{
|
|
1585
|
+
priority: "medium",
|
|
1586
|
+
description: _NoRedirectChainsAudit.meta.description
|
|
1587
|
+
}
|
|
1588
|
+
);
|
|
1589
|
+
}
|
|
1303
1590
|
return this.pass(
|
|
1304
1591
|
`All ${ctx.pages.length} page(s) resolve without redirects.`,
|
|
1305
1592
|
"No redirect chains",
|
|
@@ -1415,6 +1702,8 @@ var CanonicalLinksAudit = class extends Audit {
|
|
|
1415
1702
|
evidenceGrade: "A",
|
|
1416
1703
|
tier: "scored",
|
|
1417
1704
|
dossier: "docs/evidence/audits/access-crawl-control/canonical.md",
|
|
1705
|
+
// Gate exemption: being refused is what this category reports.
|
|
1706
|
+
requires: ["origin-reachable", "rendered-body", "sample-adequate"],
|
|
1418
1707
|
defaultPriority: "medium",
|
|
1419
1708
|
guidance: {
|
|
1420
1709
|
impact: "A canonical pointing at the wrong URL is worse than no canonical at all: when every page canonicalizes onto the homepage \u2014 a common CMS and SPA template bug \u2014 the pages consolidate onto one URL and drop out of the index that AI Overviews and AI Mode draw on. A canonical pointing at another domain hands the attribution there.",
|
|
@@ -1427,6 +1716,13 @@ var CanonicalLinksAudit = class extends Audit {
|
|
|
1427
1716
|
};
|
|
1428
1717
|
audit(ctx) {
|
|
1429
1718
|
const expected = "Each page declares a canonical URL that points at itself";
|
|
1719
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
1720
|
+
return this.notApplicable(
|
|
1721
|
+
"No page here can be attributed to this site, so its canonical links were not judged.",
|
|
1722
|
+
expected,
|
|
1723
|
+
unreadSiteReason(ctx.evidence)
|
|
1724
|
+
);
|
|
1725
|
+
}
|
|
1430
1726
|
if (ctx.pages.length === 0) {
|
|
1431
1727
|
return this.notApplicable(
|
|
1432
1728
|
"No pages were scanned, so no canonical links could be read.",
|
|
@@ -1863,6 +2159,8 @@ var GptbotAudit = class extends CrawlerBotAudit {
|
|
|
1863
2159
|
evidenceGrade: "A",
|
|
1864
2160
|
tier: "scored",
|
|
1865
2161
|
dossier: "docs/evidence/audits/access-crawl-control/gptbot.md",
|
|
2162
|
+
// Gate exemption: being refused is what this category reports.
|
|
2163
|
+
requires: ["origin-reachable"],
|
|
1866
2164
|
defaultPriority: "medium",
|
|
1867
2165
|
guidance: {
|
|
1868
2166
|
impact: "Blocking GPTBot prevents your content from being used by OpenAI's models and appearing in ChatGPT responses. Explicitly allowing it signals that your site welcomes AI indexing for the largest AI platform by user base.",
|
|
@@ -1893,6 +2191,8 @@ var GoogleExtendedAudit = class extends CrawlerBotAudit {
|
|
|
1893
2191
|
evidenceGrade: "A",
|
|
1894
2192
|
tier: "scored",
|
|
1895
2193
|
dossier: "docs/evidence/audits/access-crawl-control/google-extended.md",
|
|
2194
|
+
// Gate exemption: being refused is what this category reports.
|
|
2195
|
+
requires: ["origin-reachable"],
|
|
1896
2196
|
defaultPriority: "medium",
|
|
1897
2197
|
guidance: {
|
|
1898
2198
|
impact: "Blocking Google-Extended prevents your content from being used in Google's AI features like Gemini and AI Overviews. Allowing it ensures your site appears in Google's AI-powered search experiences alongside traditional results.",
|
|
@@ -1927,6 +2227,8 @@ var AnthropicAudit = class extends CrawlerBotAudit {
|
|
|
1927
2227
|
evidenceGrade: "A",
|
|
1928
2228
|
tier: "scored",
|
|
1929
2229
|
dossier: "docs/evidence/audits/access-crawl-control/anthropic-ai.md",
|
|
2230
|
+
// Gate exemption: being refused is what this category reports.
|
|
2231
|
+
requires: ["origin-reachable"],
|
|
1930
2232
|
defaultPriority: "medium",
|
|
1931
2233
|
guidance: {
|
|
1932
2234
|
impact: "Disallowing ClaudeBot keeps the site out of the web content Anthropic collects for potential model training. It is an effective, documented control, so it is only a problem where the block was not intended. It buys back very little traffic either way: Cloudflare Radar measures Anthropic's crawl-to-refer ratio at roughly 50,000:1, so the allow-side case is about corpus inclusion rather than referral visibility.",
|
|
@@ -2036,6 +2338,8 @@ var PerplexitybotAudit = class extends CrawlerBotAudit {
|
|
|
2036
2338
|
evidenceGrade: "A",
|
|
2037
2339
|
tier: "scored",
|
|
2038
2340
|
dossier: "docs/evidence/audits/access-crawl-control/perplexitybot.md",
|
|
2341
|
+
// Gate exemption: being refused is what this category reports.
|
|
2342
|
+
requires: ["origin-reachable"],
|
|
2039
2343
|
defaultPriority: "medium",
|
|
2040
2344
|
guidance: {
|
|
2041
2345
|
impact: "Blocking PerplexityBot prevents your content from appearing in Perplexity AI search results, one of the fastest-growing AI answer engines. Allowing it gives your content visibility in AI-native search.",
|
|
@@ -2066,6 +2370,8 @@ var ApplebotExtendedAudit = class extends CrawlerBotAudit {
|
|
|
2066
2370
|
evidenceGrade: "A",
|
|
2067
2371
|
tier: "scored",
|
|
2068
2372
|
dossier: "docs/evidence/audits/access-crawl-control/applebot-extended.md",
|
|
2373
|
+
// Gate exemption: being refused is what this category reports.
|
|
2374
|
+
requires: ["origin-reachable"],
|
|
2069
2375
|
defaultPriority: "medium",
|
|
2070
2376
|
guidance: {
|
|
2071
2377
|
impact: "Blocking Applebot-Extended prevents your content from being used in Apple Intelligence features, Siri AI answers, and Safari Highlights. Allowing it ensures visibility across Apple's AI ecosystem.",
|
|
@@ -2096,6 +2402,8 @@ var CcbotAudit = class extends CrawlerBotAudit {
|
|
|
2096
2402
|
evidenceGrade: "A",
|
|
2097
2403
|
tier: "scored",
|
|
2098
2404
|
dossier: "docs/evidence/audits/access-crawl-control/ccbot.md",
|
|
2405
|
+
// Gate exemption: being refused is what this category reports.
|
|
2406
|
+
requires: ["origin-reachable"],
|
|
2099
2407
|
defaultPriority: "medium",
|
|
2100
2408
|
guidance: {
|
|
2101
2409
|
impact: "Blocking CCBot prevents your content from being included in the Common Crawl dataset, which is a foundational training data source for many AI models. Allowing it broadens your content's reach across multiple AI systems.",
|
|
@@ -2127,6 +2435,8 @@ var MetaExternalAgentAudit = class extends CrawlerBotAudit {
|
|
|
2127
2435
|
evidenceGrade: "A",
|
|
2128
2436
|
tier: "scored",
|
|
2129
2437
|
dossier: "docs/evidence/audits/access-crawl-control/meta-external-agent.md",
|
|
2438
|
+
// Gate exemption: being refused is what this category reports.
|
|
2439
|
+
requires: ["origin-reachable"],
|
|
2130
2440
|
defaultPriority: "medium",
|
|
2131
2441
|
guidance: {
|
|
2132
2442
|
impact: "Disallowing Meta-ExternalAgent keeps the site out of Meta's foundation-model training corpus and out of the direct content indexing that improves Meta products. It is an effective, documented control, so it is only a problem where the block was not intended. It does not by itself govern Meta AI search citations \u2014 Meta documents Meta-WebIndexer as the token behind those.",
|
|
@@ -2219,6 +2529,8 @@ var AmazonbotAudit = class extends CrawlerBotAudit {
|
|
|
2219
2529
|
evidenceGrade: "A",
|
|
2220
2530
|
tier: "scored",
|
|
2221
2531
|
dossier: "docs/evidence/audits/access-crawl-control/amazonbot.md",
|
|
2532
|
+
// Gate exemption: being refused is what this category reports.
|
|
2533
|
+
requires: ["origin-reachable"],
|
|
2222
2534
|
defaultPriority: "medium",
|
|
2223
2535
|
guidance: {
|
|
2224
2536
|
impact: "Blocking Amazonbot prevents your content from appearing in Alexa AI answers and Amazon's AI-powered search features. Allowing it gives your content visibility in Amazon's voice and commerce AI ecosystem.",
|
|
@@ -2293,6 +2605,8 @@ var AiBotDirectivesAudit = class extends Audit {
|
|
|
2293
2605
|
evidenceGrade: "B",
|
|
2294
2606
|
tier: "scored",
|
|
2295
2607
|
dossier: "docs/evidence/audits/access-crawl-control/ai-bot-directives.md",
|
|
2608
|
+
// Gate exemption: being refused is what this category reports.
|
|
2609
|
+
requires: ["origin-reachable"],
|
|
2296
2610
|
defaultPriority: "medium",
|
|
2297
2611
|
guidance: {
|
|
2298
2612
|
impact: "Blocking YouBot removes the site from You.com's live search index; blocking AI2Bot removes it from the Allen Institute's open training corpora while leaving closed commercial crawlers untouched. Leaving either to the wildcard rule means the policy silently flips the day a blanket block is added. The other three tokens carry no comparable consumer, so this audit never penalises blocking them.",
|
|
@@ -2359,6 +2673,8 @@ var ChatgptUserAudit = class extends CrawlerBotAudit {
|
|
|
2359
2673
|
evidenceGrade: "C",
|
|
2360
2674
|
tier: "informative",
|
|
2361
2675
|
dossier: "docs/evidence/audits/access-crawl-control/chatgpt-user.md",
|
|
2676
|
+
// Gate exemption: being refused is what this category reports.
|
|
2677
|
+
requires: ["origin-reachable"],
|
|
2362
2678
|
defaultPriority: "medium",
|
|
2363
2679
|
guidance: {
|
|
2364
2680
|
impact: "Blocking ChatGPT-User prevents ChatGPT from browsing your site in real-time when users ask it to visit your pages. This blocks your content from being cited in ChatGPT Browse conversations, losing a significant source of AI-driven traffic.",
|
|
@@ -2389,6 +2705,8 @@ var ClaudeUserAudit = class extends CrawlerBotAudit {
|
|
|
2389
2705
|
evidenceGrade: "A",
|
|
2390
2706
|
tier: "scored",
|
|
2391
2707
|
dossier: "docs/evidence/audits/access-crawl-control/claude-user.md",
|
|
2708
|
+
// Gate exemption: being refused is what this category reports.
|
|
2709
|
+
requires: ["origin-reachable"],
|
|
2392
2710
|
defaultPriority: "medium",
|
|
2393
2711
|
guidance: {
|
|
2394
2712
|
impact: "Blocking Claude-User prevents Claude from browsing your site in real-time when users ask it to visit your pages. This blocks your content from being cited in Claude conversations with web access enabled.",
|
|
@@ -2418,6 +2736,8 @@ var OaiSearchbotAudit = class extends CrawlerBotAudit {
|
|
|
2418
2736
|
evidenceGrade: "A",
|
|
2419
2737
|
tier: "scored",
|
|
2420
2738
|
dossier: "docs/evidence/audits/access-crawl-control/oai-searchbot.md",
|
|
2739
|
+
// Gate exemption: being refused is what this category reports.
|
|
2740
|
+
requires: ["origin-reachable"],
|
|
2421
2741
|
defaultPriority: "medium",
|
|
2422
2742
|
guidance: {
|
|
2423
2743
|
impact: "Blocking OAI-SearchBot prevents your content from appearing in OpenAI's SearchGPT and ChatGPT web search results. Allowing it ensures your site is discoverable through OpenAI's real-time search features.",
|
|
@@ -2448,6 +2768,8 @@ var MetaExternalFetcherAudit = class extends CrawlerBotAudit {
|
|
|
2448
2768
|
evidenceGrade: "A",
|
|
2449
2769
|
tier: "scored",
|
|
2450
2770
|
dossier: "docs/evidence/audits/access-crawl-control/meta-external-fetcher.md",
|
|
2771
|
+
// Gate exemption: being refused is what this category reports.
|
|
2772
|
+
requires: ["origin-reachable"],
|
|
2451
2773
|
defaultPriority: "medium",
|
|
2452
2774
|
guidance: {
|
|
2453
2775
|
impact: "Blocking Meta-ExternalFetcher prevents Meta's AI from fetching your content in real-time for AI-powered features across Facebook, Instagram, and WhatsApp. Allowing it ensures your content can be surfaced in Meta's real-time AI experiences.",
|
|
@@ -2477,6 +2799,8 @@ var BravebotAudit = class extends CrawlerBotAudit {
|
|
|
2477
2799
|
evidenceGrade: "C",
|
|
2478
2800
|
tier: "informative",
|
|
2479
2801
|
dossier: "docs/evidence/audits/access-crawl-control/bravebot.md",
|
|
2802
|
+
// Gate exemption: being refused is what this category reports.
|
|
2803
|
+
requires: ["origin-reachable"],
|
|
2480
2804
|
defaultPriority: "medium",
|
|
2481
2805
|
guidance: {
|
|
2482
2806
|
impact: "Blocking Bravebot prevents your content from appearing in Brave Search AI answers and Brave Leo AI assistant responses. Allowing it gives your content visibility in the privacy-focused Brave browser ecosystem.",
|
|
@@ -2506,6 +2830,8 @@ var DuckassistbotAudit = class extends CrawlerBotAudit {
|
|
|
2506
2830
|
evidenceGrade: "A",
|
|
2507
2831
|
tier: "scored",
|
|
2508
2832
|
dossier: "docs/evidence/audits/access-crawl-control/duckassistbot.md",
|
|
2833
|
+
// Gate exemption: being refused is what this category reports.
|
|
2834
|
+
requires: ["origin-reachable"],
|
|
2509
2835
|
defaultPriority: "medium",
|
|
2510
2836
|
guidance: {
|
|
2511
2837
|
impact: "Blocking DuckAssistBot prevents your content from appearing in DuckDuckGo's AI-powered DuckAssist feature, which generates instant answers from crawled web pages. Allowing it ensures visibility in this privacy-first AI search experience.",
|
|
@@ -2535,6 +2861,8 @@ var MistralaiUserAudit = class extends CrawlerBotAudit {
|
|
|
2535
2861
|
evidenceGrade: "A",
|
|
2536
2862
|
tier: "scored",
|
|
2537
2863
|
dossier: "docs/evidence/audits/access-crawl-control/mistralai-user.md",
|
|
2864
|
+
// Gate exemption: being refused is what this category reports.
|
|
2865
|
+
requires: ["origin-reachable"],
|
|
2538
2866
|
defaultPriority: "medium",
|
|
2539
2867
|
guidance: {
|
|
2540
2868
|
impact: "Blocking MistralAI-User prevents Mistral AI's Le Chat from browsing your site in real-time when users ask it to visit your pages. Allowing it ensures your content can be cited in Mistral-powered AI conversations.",
|
|
@@ -2564,6 +2892,8 @@ var ClaudeSearchbotAudit = class extends CrawlerBotAudit {
|
|
|
2564
2892
|
evidenceGrade: "A",
|
|
2565
2893
|
tier: "scored",
|
|
2566
2894
|
dossier: "docs/evidence/audits/access-crawl-control/claude-searchbot.md",
|
|
2895
|
+
// Gate exemption: being refused is what this category reports.
|
|
2896
|
+
requires: ["origin-reachable"],
|
|
2567
2897
|
defaultPriority: "medium",
|
|
2568
2898
|
guidance: {
|
|
2569
2899
|
impact: "Blocking Claude-SearchBot prevents your content from appearing in Claude's web search results. Allowing it ensures your site is included when Claude searches the web to answer user questions.",
|
|
@@ -2593,6 +2923,8 @@ var NoBlanketBlockAudit = class extends Audit {
|
|
|
2593
2923
|
evidenceGrade: "B",
|
|
2594
2924
|
tier: "scored",
|
|
2595
2925
|
dossier: "docs/evidence/audits/access-crawl-control/no-blanket-block.md",
|
|
2926
|
+
// Gate exemption: being refused is what this category reports.
|
|
2927
|
+
requires: ["origin-reachable"],
|
|
2596
2928
|
defaultPriority: "critical",
|
|
2597
2929
|
guidance: {
|
|
2598
2930
|
impact: "A blanket Disallow: / under User-agent: * blocks every crawler, including all AI agents. Your site becomes completely invisible to AI search engines, ChatGPT Browse, Perplexity, Claude, and all other AI-powered discovery tools.",
|
|
@@ -2604,6 +2936,13 @@ var NoBlanketBlockAudit = class extends Audit {
|
|
|
2604
2936
|
}
|
|
2605
2937
|
};
|
|
2606
2938
|
audit(ctx) {
|
|
2939
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
2940
|
+
return this.notApplicable(
|
|
2941
|
+
"No response here can be attributed to this site, so its robots.txt was not judged.",
|
|
2942
|
+
"User-agent: * does not Disallow: / entirely",
|
|
2943
|
+
unreadSiteReason(ctx.evidence)
|
|
2944
|
+
);
|
|
2945
|
+
}
|
|
2607
2946
|
const robotsFile = ctx.rootFiles["/robots.txt"];
|
|
2608
2947
|
if (!robotsFile || robotsFile.status !== 200 || !robotsFile.body) {
|
|
2609
2948
|
return this.warn(
|
|
@@ -2746,6 +3085,8 @@ var SensitivePathsAudit = class extends Audit {
|
|
|
2746
3085
|
evidenceGrade: "A",
|
|
2747
3086
|
tier: "scored",
|
|
2748
3087
|
dossier: "docs/evidence/audits/access-crawl-control/sensitive-paths.md",
|
|
3088
|
+
// Gate exemption: being refused is what this category reports.
|
|
3089
|
+
requires: ["origin-reachable", "rendered-body", "sample-adequate"],
|
|
2749
3090
|
defaultPriority: "low",
|
|
2750
3091
|
guidance: {
|
|
2751
3092
|
impact: 'Cart, checkout, site-search, login and account URLs carry nothing an answer engine can cite, but they are crawled and can surface in AI answers as dead, session-bearing links. Apple documents Applebot and Applebot-Extended honouring "Disallow: /private/", and Meta documents the same for meta-externalagent, so a path-level rule keeps that noise out of AI crawls. Two limits matter: RFC 9309 states the protocol "is not a substitute for valid content security measures" and that listed paths become publicly discoverable, so never use robots.txt to protect anything; and user-initiated fetchers are documented not to obey it \u2014 OpenAI says of ChatGPT-User "Because these actions are initiated by a user, robots.txt rules may not apply", and Perplexity says Perplexity-User "generally ignores robots.txt rules".',
|
|
@@ -2841,6 +3182,8 @@ var CrawlDelayAudit = class extends Audit {
|
|
|
2841
3182
|
evidenceGrade: "C",
|
|
2842
3183
|
tier: "informative",
|
|
2843
3184
|
dossier: "docs/evidence/audits/access-crawl-control/crawl-delay.md",
|
|
3185
|
+
// Gate exemption: being refused is what this category reports.
|
|
3186
|
+
requires: ["origin-reachable"],
|
|
2844
3187
|
defaultPriority: "high",
|
|
2845
3188
|
guidance: {
|
|
2846
3189
|
impact: "Excessive Crawl-delay values (over 10 seconds) dramatically slow AI indexing, meaning your latest content may take days or weeks to appear in AI search results while competitors with lower delays get indexed faster.",
|
|
@@ -2851,6 +3194,13 @@ var CrawlDelayAudit = class extends Audit {
|
|
|
2851
3194
|
}
|
|
2852
3195
|
};
|
|
2853
3196
|
audit(ctx) {
|
|
3197
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
3198
|
+
return this.notApplicable(
|
|
3199
|
+
"No response here can be attributed to this site, so its robots.txt was not judged.",
|
|
3200
|
+
"If Crawl-delay is present, it is <= 10 seconds",
|
|
3201
|
+
unreadSiteReason(ctx.evidence)
|
|
3202
|
+
);
|
|
3203
|
+
}
|
|
2854
3204
|
const robotsFile = ctx.rootFiles["/robots.txt"];
|
|
2855
3205
|
if (!robotsFile || robotsFile.status !== 200 || !robotsFile.body) {
|
|
2856
3206
|
return this.warn(
|
|
@@ -2981,6 +3331,9 @@ var MetaRobotsNotBlockingAudit = class extends Audit {
|
|
|
2981
3331
|
evidenceGrade: "A",
|
|
2982
3332
|
tier: "scored",
|
|
2983
3333
|
dossier: "docs/evidence/audits/access-crawl-control/robots-directives.md",
|
|
3334
|
+
// Gate exemption: being refused is what this category reports, and robots directives
|
|
3335
|
+
// live in the head and the headers, which arrive whether or not the body renders.
|
|
3336
|
+
requires: ["origin-reachable"],
|
|
2984
3337
|
defaultPriority: "high",
|
|
2985
3338
|
guidance: {
|
|
2986
3339
|
impact: 'A content page carrying "noindex" (in a robots meta tag, a per-bot meta tag, or the X-Robots-Tag response header) is dropped from the search index, and Google documents that a page must be indexed to appear in AI Overviews or AI Mode. "nosnippet", "noarchive" and "max-snippet:0" keep the page indexed but stop its text being used as a direct input for AI answers.',
|
|
@@ -2992,6 +3345,13 @@ var MetaRobotsNotBlockingAudit = class extends Audit {
|
|
|
2992
3345
|
}
|
|
2993
3346
|
};
|
|
2994
3347
|
audit(ctx) {
|
|
3348
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
3349
|
+
return this.notApplicable(
|
|
3350
|
+
"No page here can be attributed to this site, so its robots directives were not judged.",
|
|
3351
|
+
"No blocking robots directive on content pages",
|
|
3352
|
+
unreadSiteReason(ctx.evidence)
|
|
3353
|
+
);
|
|
3354
|
+
}
|
|
2995
3355
|
if (!ctx.pages || ctx.pages.length === 0) {
|
|
2996
3356
|
return this.notApplicable(
|
|
2997
3357
|
"No pages were scanned, so no robots directives could be read.",
|
|
@@ -3069,6 +3429,10 @@ var NoBotDetectionAudit = class extends Audit {
|
|
|
3069
3429
|
evidenceGrade: "A",
|
|
3070
3430
|
tier: "scored",
|
|
3071
3431
|
dossier: "docs/evidence/audits/access-crawl-control/no-bot-detection.md",
|
|
3432
|
+
// Gate exemption: being refused is what this category reports, and this audit names
|
|
3433
|
+
// the firewall from `wafProtection` alone. Evidence a wall destroys is not evidence
|
|
3434
|
+
// the wall finding needs.
|
|
3435
|
+
requires: [],
|
|
3072
3436
|
defaultPriority: "high",
|
|
3073
3437
|
guidance: {
|
|
3074
3438
|
impact: "Bot-detection services like Cloudflare Turnstile, DataDome, and reCAPTCHA can block legitimate AI agents from accessing your content. When agents are challenged, they cannot complete page fetches, making your content inaccessible to AI-powered search and assistants.",
|
|
@@ -3098,6 +3462,13 @@ var NoBotDetectionAudit = class extends Audit {
|
|
|
3098
3462
|
}
|
|
3099
3463
|
);
|
|
3100
3464
|
}
|
|
3465
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
3466
|
+
return this.notApplicable(
|
|
3467
|
+
"No page here can be attributed to this site, so its scripts were not judged.",
|
|
3468
|
+
"No JavaScript-based bot challenges that would block legitimate AI agents",
|
|
3469
|
+
unreadSiteReason(ctx.evidence)
|
|
3470
|
+
);
|
|
3471
|
+
}
|
|
3101
3472
|
if (!ctx.pages || ctx.pages.length === 0) {
|
|
3102
3473
|
return this.warn(
|
|
3103
3474
|
"No pages were scanned to check for bot-detection scripts.",
|
|
@@ -3123,6 +3494,13 @@ var NoBotDetectionAudit = class extends Audit {
|
|
|
3123
3494
|
}
|
|
3124
3495
|
}
|
|
3125
3496
|
if (detectedServices.size === 0) {
|
|
3497
|
+
if (!scanReadPageText(ctx.evidence)) {
|
|
3498
|
+
return this.notApplicable(
|
|
3499
|
+
"The scanned page served no readable text, so its scripts were not judged.",
|
|
3500
|
+
"No JavaScript-based bot challenges that would block legitimate AI agents",
|
|
3501
|
+
unreadPageTextReason(ctx.evidence)
|
|
3502
|
+
);
|
|
3503
|
+
}
|
|
3126
3504
|
return this.pass(
|
|
3127
3505
|
"No aggressive bot-detection scripts found on scanned pages.",
|
|
3128
3506
|
"No JavaScript-based bot challenges that would block legitimate AI agents",
|
|
@@ -3230,6 +3608,8 @@ var TdmRepAudit = class extends Audit {
|
|
|
3230
3608
|
evidenceGrade: "C",
|
|
3231
3609
|
tier: "experimental",
|
|
3232
3610
|
dossier: "docs/evidence/audits/access-crawl-control/tdm-rep.md",
|
|
3611
|
+
// Gate exemption: being refused is what this category reports.
|
|
3612
|
+
requires: ["origin-reachable", "rendered-body", "sample-adequate"],
|
|
3233
3613
|
// Nothing consumes the signal, so nothing here should outrank an item that
|
|
3234
3614
|
// changes what an agent can do.
|
|
3235
3615
|
defaultPriority: "low",
|
|
@@ -3243,6 +3623,13 @@ var TdmRepAudit = class extends Audit {
|
|
|
3243
3623
|
}
|
|
3244
3624
|
};
|
|
3245
3625
|
audit(ctx) {
|
|
3626
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
3627
|
+
return this.notApplicable(
|
|
3628
|
+
"No response here can be attributed to this site, so no TDM-Rep declaration was read.",
|
|
3629
|
+
EXPECTED3,
|
|
3630
|
+
unreadSiteReason(ctx.evidence)
|
|
3631
|
+
);
|
|
3632
|
+
}
|
|
3246
3633
|
const pageUrl = ctx.pages[0]?.url;
|
|
3247
3634
|
const header = readHeader(ctx);
|
|
3248
3635
|
if (header) {
|
|
@@ -3378,6 +3765,8 @@ var AgentGovernanceAudit = class extends Audit {
|
|
|
3378
3765
|
evidenceGrade: "A",
|
|
3379
3766
|
tier: "scored",
|
|
3380
3767
|
dossier: "docs/evidence/audits/access-crawl-control/agent-governance.md",
|
|
3768
|
+
// Gate exemption: being refused is what this category reports.
|
|
3769
|
+
requires: ["origin-reachable"],
|
|
3381
3770
|
defaultPriority: "medium",
|
|
3382
3771
|
guidance: {
|
|
3383
3772
|
impact: "Without separate rules for training crawlers and live conversational agents, you cannot block dataset scraping while still appearing in ChatGPT, Claude, and Perplexity answers. A blanket policy either locks you out of AI-powered discovery entirely or leaves your content open to bulk training crawls you never agreed to.",
|
|
@@ -3513,6 +3902,8 @@ var AiContentDeclarationAudit = class extends Audit {
|
|
|
3513
3902
|
evidenceGrade: "D",
|
|
3514
3903
|
tier: "experimental",
|
|
3515
3904
|
dossier: "docs/evidence/audits/access-crawl-control/ai-content-declaration.md",
|
|
3905
|
+
// Gate exemption: being refused is what this category reports.
|
|
3906
|
+
requires: ["origin-reachable", "rendered-body", "sample-adequate"],
|
|
3516
3907
|
// Was `medium` on an invented directive; the whole class of signals is
|
|
3517
3908
|
// pre-consumer, so nothing here should outrank an actionable item.
|
|
3518
3909
|
defaultPriority: "low",
|
|
@@ -3526,6 +3917,13 @@ var AiContentDeclarationAudit = class extends Audit {
|
|
|
3526
3917
|
}
|
|
3527
3918
|
};
|
|
3528
3919
|
audit(ctx) {
|
|
3920
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
3921
|
+
return this.notApplicable(
|
|
3922
|
+
"No response here can be attributed to this site, so no AI-usage declaration was read.",
|
|
3923
|
+
EXPECTED4,
|
|
3924
|
+
unreadSiteReason(ctx.evidence)
|
|
3925
|
+
);
|
|
3926
|
+
}
|
|
3529
3927
|
const found = survey(ctx);
|
|
3530
3928
|
if (found.aipref) {
|
|
3531
3929
|
return this.pass(
|
|
@@ -3576,6 +3974,9 @@ var HttpsEnabledAudit = class extends Audit {
|
|
|
3576
3974
|
evidenceGrade: "A",
|
|
3577
3975
|
tier: "scored",
|
|
3578
3976
|
dossier: "docs/evidence/audits/access-crawl-control/https-enabled.md",
|
|
3977
|
+
// Gate exemption: a base URL on plain HTTP is proven by the request, with no response
|
|
3978
|
+
// at all, and that fail is worth reporting on a site whose homepage never answered.
|
|
3979
|
+
requires: [],
|
|
3579
3980
|
defaultPriority: "critical",
|
|
3580
3981
|
guidance: {
|
|
3581
3982
|
impact: "HTTP-only sites are completely excluded from all major AI systems. GPTBot, ClaudeBot, Perplexity, and enterprise RAG pipelines refuse to connect to non-HTTPS origins due to security policies. Your entire site is invisible to AI-generated answers, product recommendations, and agentic workflows.",
|
|
@@ -3590,35 +3991,42 @@ var HttpsEnabledAudit = class extends Audit {
|
|
|
3590
3991
|
const isHttps = ctx.baseUrl.startsWith("https://");
|
|
3591
3992
|
const page = ctx.pages?.[0];
|
|
3592
3993
|
const status200 = page?.fetchResult.status === 200;
|
|
3593
|
-
if (isHttps
|
|
3594
|
-
return this.
|
|
3595
|
-
"Site is served over HTTPS
|
|
3994
|
+
if (!isHttps) {
|
|
3995
|
+
return this.fail(
|
|
3996
|
+
"Site is not served over HTTPS. AI agents require secure connections.",
|
|
3596
3997
|
"Base URL uses https:// and homepage returns 200",
|
|
3597
|
-
|
|
3998
|
+
`Base URL: ${ctx.baseUrl}`,
|
|
3999
|
+
{
|
|
4000
|
+
priority: "critical",
|
|
4001
|
+
description: "Enterprise AI frameworks refuse to interact with non-HTTPS sites due to security policies. GPTBot, ClaudeBot, and enterprise RAG systems all skip HTTP-only sites entirely, making your content invisible to AI-generated answers. Enable HTTPS with a valid TLS certificate.",
|
|
4002
|
+
code: "# For nginx:\nserver {\n listen 443 ssl;\n ssl_certificate /path/to/cert.pem;\n ssl_certificate_key /path/to/key.pem;\n}"
|
|
4003
|
+
},
|
|
3598
4004
|
page?.url
|
|
3599
4005
|
);
|
|
3600
4006
|
}
|
|
3601
|
-
if (
|
|
3602
|
-
return this.
|
|
3603
|
-
|
|
4007
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
4008
|
+
return this.notApplicable(
|
|
4009
|
+
"No homepage here can be attributed to this site, so its transport was not judged.",
|
|
3604
4010
|
"Base URL uses https:// and homepage returns 200",
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
4011
|
+
unreadSiteReason(ctx.evidence)
|
|
4012
|
+
);
|
|
4013
|
+
}
|
|
4014
|
+
if (status200) {
|
|
4015
|
+
return this.pass(
|
|
4016
|
+
"Site is served over HTTPS with a valid TLS connection.",
|
|
4017
|
+
"Base URL uses https:// and homepage returns 200",
|
|
4018
|
+
`${ctx.baseUrl} \u2014 status ${page?.fetchResult.status}`,
|
|
3611
4019
|
page?.url
|
|
3612
4020
|
);
|
|
3613
4021
|
}
|
|
3614
|
-
return this.
|
|
3615
|
-
"Site
|
|
4022
|
+
return this.warn(
|
|
4023
|
+
"Site uses HTTPS and the homepage answered, but the response carried no document, so an agent has nothing to read over that connection.",
|
|
3616
4024
|
"Base URL uses https:// and homepage returns 200",
|
|
3617
|
-
|
|
4025
|
+
`${ctx.baseUrl} \u2014 a 2xx response that carried no document`,
|
|
3618
4026
|
{
|
|
3619
|
-
priority: "
|
|
3620
|
-
description: "
|
|
3621
|
-
code: "#
|
|
4027
|
+
priority: "high",
|
|
4028
|
+
description: "The homepage answered over HTTPS and returned no document \u2014 an empty 200 body, or a 2xx status that carries none. An AI agent that follows a link to this origin receives nothing, so nothing about the site can be indexed or quoted. Check the origin, the CDN cache entry and any edge rule that can strip a response body.",
|
|
4029
|
+
code: "# Reproduce with:\ncurl -sSi https://yoursite.com | head -20"
|
|
3622
4030
|
},
|
|
3623
4031
|
page?.url
|
|
3624
4032
|
);
|
|
@@ -3724,6 +4132,10 @@ var RobotsAiGroupShadowingAudit = class _RobotsAiGroupShadowingAudit extends Aud
|
|
|
3724
4132
|
evidenceGrade: "A",
|
|
3725
4133
|
tier: "scored",
|
|
3726
4134
|
dossier: "docs/evidence/audits/access-crawl-control/robots-ai-group-shadowing.md",
|
|
4135
|
+
// Gate exemption: being refused is what this category reports.
|
|
4136
|
+
// Gate exemption: the verdict comes from robots.txt. The scanned pages only widen
|
|
4137
|
+
// the probe path set, so a shell narrows the probe and changes nothing judged.
|
|
4138
|
+
requires: ["origin-reachable"],
|
|
3727
4139
|
defaultPriority: "high",
|
|
3728
4140
|
guidance: {
|
|
3729
4141
|
impact: "RFC 9309 \xA72.2.1 states the wildcard group is consulted only 'if no matching group exists'. Therefore, for any site with a named AI-bot group, the wildcard group's Disallow rules provably do not apply to that bot, and the operator's stated intent (expressed once in `*`) diverges from the enforced policy by exactly the symmetric difference of the two rule sets. Falsifiable by construction: given robots.txt R and token T, the set of paths where R_T and R_star disagree is computable and either empty or not.",
|
|
@@ -3742,6 +4154,13 @@ var RobotsAiGroupShadowingAudit = class _RobotsAiGroupShadowingAudit extends Aud
|
|
|
3742
4154
|
};
|
|
3743
4155
|
}
|
|
3744
4156
|
audit(ctx) {
|
|
4157
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
4158
|
+
return this.notApplicable(
|
|
4159
|
+
"No response here can be attributed to this site, so its robots groups were not judged.",
|
|
4160
|
+
EXPECTED5,
|
|
4161
|
+
unreadSiteReason(ctx.evidence)
|
|
4162
|
+
);
|
|
4163
|
+
}
|
|
3745
4164
|
const robots = ctx.rootFiles["/robots.txt"];
|
|
3746
4165
|
if (!robots || robots.status !== 200 || !robots.body.trim()) {
|
|
3747
4166
|
return this.notApplicable(
|
|
@@ -4182,6 +4601,8 @@ var AiCrawlerEdgeParityAudit = class extends Audit {
|
|
|
4182
4601
|
evidenceGrade: "A",
|
|
4183
4602
|
tier: "scored",
|
|
4184
4603
|
dossier: "docs/evidence/audits/access-crawl-control/ai-crawler-edge-parity.md",
|
|
4604
|
+
// Gate exemption: being refused is what this category reports.
|
|
4605
|
+
requires: ["origin-reachable", "rendered-body", "sample-adequate"],
|
|
4185
4606
|
defaultPriority: "critical",
|
|
4186
4607
|
guidance: {
|
|
4187
4608
|
impact: 'robots.txt (RFC 9309) is advisory metadata parsed by the crawler; the edge access decision is enforced independently by the WAF. A site can therefore publish "User-agent: PerplexityBot / Allow: /" and return a non-200 to every request carrying that user agent, and the operator \u2014 who reads their own robots.txt \u2014 believes they are open while the crawler never sees a byte. Falsifiable: fetch URL U with a browser UA and with crawler UA C; if robots.txt permits C for U and the C request is not 2xx while the browser request is 200, the two policy layers contradict each other. Cloudflare makes one branch deterministic \u2014 a challenge always carries cf-mitigated: challenge \u2014 and a 200 whose main-content text is under 40% of the baseline is a block wearing a 200.',
|
|
@@ -4397,6 +4818,8 @@ var BotContentDeltaDeclaredAudit = class extends Audit {
|
|
|
4397
4818
|
evidenceGrade: "A",
|
|
4398
4819
|
tier: "scored",
|
|
4399
4820
|
dossier: "docs/evidence/audits/access-crawl-control/bot-content-delta-declared.md",
|
|
4821
|
+
// Gate exemption: being refused is what this category reports.
|
|
4822
|
+
requires: ["origin-reachable", "rendered-body", "sample-adequate"],
|
|
4400
4823
|
defaultPriority: "high",
|
|
4401
4824
|
guidance: {
|
|
4402
4825
|
impact: "Google states that isAccessibleForFree: false with hasPart/cssSelector markup 'helps Google differentiate paywalled content from the practice of cloaking, which violates spam policies' \u2014 serving a crawler less than a user is sanctioned only when it is declared. The measurement is falsifiable both ways: extract the main text of URL U under a browser UA and under crawler UA C, and if the length ratio falls below 0.6 or the 5-gram shingle similarity below 0.7, the site conditions content on the User-Agent. The declaration is equally checkable, and the declared cssSelector must match a real element in the served HTML \u2014 which is where most implementations silently fail, leaving markup that validates and points at nothing. The second-order cost is not the spam risk: an answer engine that only ever sees the stub cites the stub.",
|
|
@@ -4697,6 +5120,8 @@ var AiUsageSignalCoherenceAcrossChannelsAudit = class extends Audit {
|
|
|
4697
5120
|
weight: weightForGrade("B", "scored"),
|
|
4698
5121
|
defaultPriority: "high",
|
|
4699
5122
|
dossier: "docs/evidence/audits/access-crawl-control/ai-usage-signal-coherence-across-channels.md",
|
|
5123
|
+
// Gate exemption: being refused is what this category reports.
|
|
5124
|
+
requires: ["origin-reachable", "rendered-body", "sample-adequate"],
|
|
4700
5125
|
guidance: {
|
|
4701
5126
|
impact: "No standard defines precedence between these channels; each specifies only its own parsing. A crawler that reads TDM-Rep and a crawler that reads AIPREF therefore read disjoint inputs, and when those inputs disagree the two reach opposite conclusions about the same page. Whichever one you did not mean to publish is the one some operator will act on. The documented worst case is not even yours to make: Cloudflare\u2019s managed robots.txt prepends its own Content-Signal block above your file, so your stated policy can be contradicted at the edge without you knowing.",
|
|
4702
5127
|
fix: "Decide the policy once, then say the same thing in every channel you publish. If you do not intend to maintain a channel, remove it rather than leaving a stale value \u2014 a contradicted signal is worse than a missing one. Where your CDN prepends its own robots.txt block, either turn that feature off or make your own declarations match it.",
|
|
@@ -4706,6 +5131,13 @@ var AiUsageSignalCoherenceAcrossChannelsAudit = class extends Audit {
|
|
|
4706
5131
|
}
|
|
4707
5132
|
};
|
|
4708
5133
|
audit(ctx) {
|
|
5134
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
5135
|
+
return this.notApplicable(
|
|
5136
|
+
"No response here can be attributed to this site, so its AI-usage channels were not compared.",
|
|
5137
|
+
"Every channel that carries an AI-usage signal says the same thing",
|
|
5138
|
+
unreadSiteReason(ctx.evidence)
|
|
5139
|
+
);
|
|
5140
|
+
}
|
|
4709
5141
|
if (ctx.pages.length === 0 && (ctx.rootFiles["/robots.txt"]?.status ?? 0) !== 200) {
|
|
4710
5142
|
return this.notApplicable(
|
|
4711
5143
|
"The scan read no page and no robots.txt, so no channel could carry a signal.",
|
|
@@ -4909,6 +5341,8 @@ var AiprefContentUsageDeclarationValidityAudit = class extends Audit {
|
|
|
4909
5341
|
weight: weightForGrade("B", "scored"),
|
|
4910
5342
|
defaultPriority: "medium",
|
|
4911
5343
|
dossier: "docs/evidence/audits/access-crawl-control/aipref-content-usage-declaration-validity.md",
|
|
5344
|
+
// Gate exemption: being refused is what this category reports.
|
|
5345
|
+
requires: ["origin-reachable", "rendered-body", "sample-adequate"],
|
|
4912
5346
|
guidance: {
|
|
4913
5347
|
impact: "AIPREF is the one AI-usage vocabulary on the IETF standards track, so a declaration written in it is the one a future crawler is most likely to read. A crawler that cannot parse the line ignores it, and the site is then treated as having no preference at all \u2014 the same outcome as publishing nothing, after the work of publishing something. The costliest version is invisible: a preference attached to a path robots.txt disallows is discarded by the spec itself, so the line looks right and does nothing.",
|
|
4914
5348
|
fix: "Write `Content-Usage: train-ai=n` \u2014 an RFC 8941 dictionary of `y`/`n` values against the `train-ai` and `search` categories. Use `yes`/`no` only in a Cloudflare `Content-Signal:` line, which is a different directive. Attach preferences to paths a crawler is allowed to fetch, and keep the robots.txt line and the response header saying the same thing for the same path.",
|
|
@@ -4918,6 +5352,13 @@ var AiprefContentUsageDeclarationValidityAudit = class extends Audit {
|
|
|
4918
5352
|
}
|
|
4919
5353
|
};
|
|
4920
5354
|
audit(ctx) {
|
|
5355
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
5356
|
+
return this.notApplicable(
|
|
5357
|
+
"No response here can be attributed to this site, so no Content-Usage declaration was read.",
|
|
5358
|
+
"Every Content-Usage declaration parses as an RFC 8941 dictionary of AIPREF categories, attaches to a crawlable path, and agrees with the other channel",
|
|
5359
|
+
unreadSiteReason(ctx.evidence)
|
|
5360
|
+
);
|
|
5361
|
+
}
|
|
4921
5362
|
const robots = ctx.rootFiles["/robots.txt"];
|
|
4922
5363
|
const robotsBody = robots?.status === 200 ? robots.body : "";
|
|
4923
5364
|
const groups = robotsBody === "" ? [] : parseRobots(robotsBody);
|
|
@@ -5100,6 +5541,8 @@ var RslLicensingTermsConformanceAudit = class extends Audit {
|
|
|
5100
5541
|
weight: weightForGrade("B", "scored"),
|
|
5101
5542
|
defaultPriority: "medium",
|
|
5102
5543
|
dossier: "docs/evidence/audits/access-crawl-control/rsl-licensing-terms-conformance.md",
|
|
5544
|
+
// Gate exemption: being refused is what this category reports.
|
|
5545
|
+
requires: ["origin-reachable", "rendered-body", "sample-adequate"],
|
|
5103
5546
|
guidance: {
|
|
5104
5547
|
impact: 'RSL is the machine-readable form of "here are my terms". A crawler that cannot find the document applies its own defaults instead, and a document it finds but cannot parse is worth no more than one it never found. The specification mandates no default location, so a licence reachable only at a guessed path is one no crawler is obliged to look for. The quiet failure is a `<content url>` prefix that does not cover the pages the licence was written for: the terms load, parse, and apply to nothing.',
|
|
5105
5548
|
fix: 'Point at the licence from robots.txt with an absolute `License:` URI, and add the `Link: <...>; rel="license"; type="application/rsl+xml"` response header so a crawler that never reads robots.txt still finds it. Serve the document as `application/rsl+xml`, keep the `https://rslstandard.org/rsl` namespace on the root element, and make every `<content url>` prefix cover the paths it licenses.',
|
|
@@ -5407,6 +5850,8 @@ var MachineActionable402PaidAccessAudit = class extends Audit {
|
|
|
5407
5850
|
weight: weightForGrade("B", "scored"),
|
|
5408
5851
|
defaultPriority: "medium",
|
|
5409
5852
|
dossier: "docs/evidence/audits/access-crawl-control/machine-actionable-402-paid-access.md",
|
|
5853
|
+
// Gate exemption: being refused is what this category reports.
|
|
5854
|
+
requires: ["origin-reachable", "rendered-body", "sample-adequate"],
|
|
5410
5855
|
guidance: {
|
|
5411
5856
|
impact: "Charging for crawler access is a legitimate choice, and 402 is the status code for it. But a crawler is a program: it can pay only what it can parse. A 402 whose body is an HTML page explaining your licensing terms reads, to the client, as an unexplained refusal \u2014 the same outcome as a 403, after you built a paywall meant to earn revenue. A 402 that a shared cache is allowed to store is worse: the next crawler gets a stored refusal even after paying.",
|
|
5412
5857
|
fix: 'Send one of the machine-readable forms with the 402: Cloudflare\u2019s `crawler-price: USD 0.01`, an x402 `PAYMENT-REQUIRED` challenge listing what you accept, or a `Link: rel=license` pointing at an RSL document whose `<payment type="crawl">` covers the path. Mark the response `Cache-Control: no-store` so a proxy cannot hand your 402 to a crawler that already paid.',
|
|
@@ -5611,6 +6056,8 @@ var WebBotAuthRequestToleranceAudit = class _WebBotAuthRequestToleranceAudit ext
|
|
|
5611
6056
|
weight: weightForGrade("B", "scored"),
|
|
5612
6057
|
defaultPriority: "medium",
|
|
5613
6058
|
dossier: "docs/evidence/audits/access-crawl-control/web-bot-auth-request-tolerance.md",
|
|
6059
|
+
// Gate exemption: being refused is what this category reports.
|
|
6060
|
+
requires: ["origin-reachable"],
|
|
5614
6061
|
guidance: {
|
|
5615
6062
|
impact: "Web Bot Auth is how an agent says who it is in a way an origin can check, and the operators building it are the ones whose traffic you would most want to identify. An edge that answers a signed request with 400 or 403 turns that identification into a reason for refusal: the agents willing to declare themselves are the ones you turn away, and the ones that lie carry no signature headers at all and sail through. A 431 is the same outcome from a different cause \u2014 a header-size limit \u2014 and it is fixed differently.",
|
|
5616
6063
|
fix: "Let unknown request headers through: `Signature`, `Signature-Input` and `Signature-Agent` are additive and safe to ignore. If your edge enforces a header-size budget, raise it enough for an Ed25519 signature. If you do vary behaviour on those headers, list them in `Vary` so a shared cache cannot serve the rejected variant to everyone.",
|
|
@@ -5799,6 +6246,9 @@ var ServerResponsivenessAudit = class extends Audit {
|
|
|
5799
6246
|
evidenceGrade: "B",
|
|
5800
6247
|
tier: "scored",
|
|
5801
6248
|
dossier: "docs/evidence/audits/content-extraction/server-responsiveness.md",
|
|
6249
|
+
// Gate exemption: TTFB is measured from the response, and a shell answers as fast
|
|
6250
|
+
// or as slow as anything else the origin serves.
|
|
6251
|
+
requires: ["origin-reachable", "unblocked-fetches"],
|
|
5802
6252
|
defaultPriority: "medium",
|
|
5803
6253
|
guidance: {
|
|
5804
6254
|
impact: 'Google documents that crawl capacity falls when a host slows down ("if the site slows down\u2026 the limit goes down and Google crawls less"), and slow origins are where logged HTTP 499 client-closed-request clusters from AI fetchers appear. A slow origin therefore gets less of its content into the indexes AI answers are drawn from.',
|
|
@@ -5817,6 +6267,13 @@ var ServerResponsivenessAudit = class extends Audit {
|
|
|
5817
6267
|
`Blocked by ${ctx.wafProtection.name}`
|
|
5818
6268
|
);
|
|
5819
6269
|
}
|
|
6270
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
6271
|
+
return this.notApplicable(
|
|
6272
|
+
"No page here can be attributed to this site, so its response time was not judged.",
|
|
6273
|
+
EXPECTED8,
|
|
6274
|
+
unreadSiteReason(ctx.evidence)
|
|
6275
|
+
);
|
|
6276
|
+
}
|
|
5820
6277
|
const measured = ctx.pages.filter((p) => !p.fetchResult.error && p.fetchResult.status !== 0);
|
|
5821
6278
|
const unmeasured = ctx.pages.length - measured.length;
|
|
5822
6279
|
if (measured.length === 0) {
|
|
@@ -5877,6 +6334,8 @@ var LanguageAttributeAudit = class extends Audit {
|
|
|
5877
6334
|
evidenceGrade: "A",
|
|
5878
6335
|
tier: "scored",
|
|
5879
6336
|
dossier: "docs/evidence/audits/content-extraction/language-attribute.md",
|
|
6337
|
+
// Gate exemption: `<html lang>` is served before any body renders.
|
|
6338
|
+
requires: ["origin-reachable", "unblocked-fetches"],
|
|
5880
6339
|
defaultPriority: "high",
|
|
5881
6340
|
guidance: {
|
|
5882
6341
|
impact: "AI agents use the lang attribute to select the correct language model and tokenizer when processing your content. Without it, agents may misinterpret content language, leading to poor translations or incorrect answers in multilingual AI systems.",
|
|
@@ -5888,6 +6347,13 @@ var LanguageAttributeAudit = class extends Audit {
|
|
|
5888
6347
|
}
|
|
5889
6348
|
};
|
|
5890
6349
|
audit(ctx) {
|
|
6350
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
6351
|
+
return this.notApplicable(
|
|
6352
|
+
"No page here can be attributed to this site, so its language attribute was not judged.",
|
|
6353
|
+
'<html lang="..."> with a non-empty language code',
|
|
6354
|
+
unreadSiteReason(ctx.evidence)
|
|
6355
|
+
);
|
|
6356
|
+
}
|
|
5891
6357
|
const page = ctx.pages[0];
|
|
5892
6358
|
const $ = page?.$;
|
|
5893
6359
|
const lang = $?.("html").attr("lang") ?? "";
|
|
@@ -6026,6 +6492,7 @@ var MarkdownAlternateAudit = class extends Audit {
|
|
|
6026
6492
|
weight: weightForGrade("A", "scored"),
|
|
6027
6493
|
defaultPriority: "medium",
|
|
6028
6494
|
dossier: "docs/evidence/audits/content-extraction/markdown-alternate.md",
|
|
6495
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
6029
6496
|
guidance: {
|
|
6030
6497
|
impact: "A markdown alternate is a promise that an agent can read the page cheaply and get the same answer. A stale or partial alternate breaks that promise silently: the agent gets a document that looks authoritative, costs less, and says less than the page it claims to mirror. Serving it as `text/plain` or `text/html` is the same failure one level down \u2014 the client that negotiated for markdown cannot tell it got any. The consumers this is graded on are interactive coding agents \u2014 Claude Code, Cursor, Copilot Chat and CLI, Codex CLI \u2014 and GPTBot, measured taking markdown on 34.8% of fetches where a `.md` URL exists.",
|
|
6031
6498
|
fix: 'Serve the alternate from the same source as the HTML, so headings and prose cannot drift, with `Content-Type: text/markdown` (a `charset` parameter is fine). Publish it on the page URL plus `.md`, or answer `Accept: text/markdown` on the page URL itself \u2014 those are the two routes with documented consumers. Declaring it with `<link rel="alternate" type="text/markdown" href="...">` saves an agent a guess, but the link relation itself has one single-sourced consumer, so this audit reports it rather than scoring it.',
|
|
@@ -6256,6 +6723,7 @@ var JsonLdDuplicationMassAudit = class extends Audit {
|
|
|
6256
6723
|
weight: weightForGrade("C", "informative"),
|
|
6257
6724
|
defaultPriority: "low",
|
|
6258
6725
|
dossier: "docs/evidence/audits/content-extraction/json-ld-duplication-mass.md",
|
|
6726
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
6259
6727
|
guidance: {
|
|
6260
6728
|
impact: "A non-rendering agent tokenizes the whole document, JSON-LD included. Where a block repeats the article body the DOM already carries, the page ships that text twice and the agent pays for both copies out of one context window. The same holds for a node declared identically in two blocks: the second copy adds tokens and no facts.",
|
|
6261
6729
|
fix: "Keep JSON-LD to the facts a parser needs \u2014 identifiers, prices, dates, relationships \u2014 and let the prose live in the DOM. Where a schema property genuinely needs body text, a summary is usually enough. Merge blocks that declare the same `@id` into one.",
|
|
@@ -6365,6 +6833,7 @@ var SingleH1Audit = class extends Audit {
|
|
|
6365
6833
|
evidenceGrade: "B",
|
|
6366
6834
|
tier: "scored",
|
|
6367
6835
|
dossier: "docs/evidence/audits/content-extraction/single-h1.md",
|
|
6836
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
6368
6837
|
defaultPriority: "high",
|
|
6369
6838
|
guidance: {
|
|
6370
6839
|
impact: "AI agents use the single <h1> as the authoritative page title for content indexing and answer generation. Multiple <h1> elements create ambiguity about the page's primary topic, causing agents to misidentify or conflate subjects when generating answers.",
|
|
@@ -6376,6 +6845,13 @@ var SingleH1Audit = class extends Audit {
|
|
|
6376
6845
|
}
|
|
6377
6846
|
};
|
|
6378
6847
|
audit(ctx) {
|
|
6848
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
6849
|
+
return this.notApplicable(
|
|
6850
|
+
"No page here can be attributed to this site, so its headings were not judged.",
|
|
6851
|
+
"Exactly one <h1> on the homepage",
|
|
6852
|
+
unreadSiteReason(ctx.evidence)
|
|
6853
|
+
);
|
|
6854
|
+
}
|
|
6379
6855
|
const homepage = ctx.pages[0];
|
|
6380
6856
|
if (!homepage) {
|
|
6381
6857
|
return this.fail(
|
|
@@ -6427,6 +6903,7 @@ var SequentialHeadingsAudit = class extends Audit {
|
|
|
6427
6903
|
evidenceGrade: "B",
|
|
6428
6904
|
tier: "scored",
|
|
6429
6905
|
dossier: "docs/evidence/audits/content-extraction/sequential-headings.md",
|
|
6906
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
6430
6907
|
defaultPriority: "high",
|
|
6431
6908
|
guidance: {
|
|
6432
6909
|
impact: "AI systems build content outlines from heading levels to understand document hierarchy. Skipped levels (e.g., h1 directly to h3) break this hierarchy, causing agents to misinterpret section nesting and produce inaccurate content summaries with wrong parent-child relationships.",
|
|
@@ -6516,6 +6993,7 @@ var MainElementAudit = class extends Audit {
|
|
|
6516
6993
|
evidenceGrade: "A",
|
|
6517
6994
|
tier: "scored",
|
|
6518
6995
|
dossier: "docs/evidence/audits/content-extraction/main-element.md",
|
|
6996
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
6519
6997
|
defaultPriority: "high",
|
|
6520
6998
|
guidance: {
|
|
6521
6999
|
impact: "Without a <main> element, AI scrapers cannot distinguish primary content from navigation, sidebars, and footer boilerplate. This causes agents to ingest menus, disclaimers, and repeated chrome into their context window, increasing hallucination risk and reducing answer relevance.",
|
|
@@ -6527,6 +7005,13 @@ var MainElementAudit = class extends Audit {
|
|
|
6527
7005
|
}
|
|
6528
7006
|
};
|
|
6529
7007
|
audit(ctx) {
|
|
7008
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
7009
|
+
return this.notApplicable(
|
|
7010
|
+
"No page here can be attributed to this site, so its landmarks were not judged.",
|
|
7011
|
+
"<main> element present on all pages",
|
|
7012
|
+
unreadSiteReason(ctx.evidence)
|
|
7013
|
+
);
|
|
7014
|
+
}
|
|
6530
7015
|
let pagesWithMain = 0;
|
|
6531
7016
|
for (const page of ctx.pages) {
|
|
6532
7017
|
if (page.$("main").length > 0) pagesWithMain++;
|
|
@@ -6573,6 +7058,7 @@ var ArticleElementAudit = class extends Audit {
|
|
|
6573
7058
|
evidenceGrade: "A",
|
|
6574
7059
|
tier: "scored",
|
|
6575
7060
|
dossier: "docs/evidence/audits/content-extraction/article-element.md",
|
|
7061
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
6576
7062
|
applicablePageTypes: ["content"],
|
|
6577
7063
|
defaultPriority: "medium",
|
|
6578
7064
|
guidance: {
|
|
@@ -6585,6 +7071,13 @@ var ArticleElementAudit = class extends Audit {
|
|
|
6585
7071
|
}
|
|
6586
7072
|
};
|
|
6587
7073
|
audit(ctx) {
|
|
7074
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
7075
|
+
return this.notApplicable(
|
|
7076
|
+
"No page here can be attributed to this site, so its landmarks were not judged.",
|
|
7077
|
+
"<article> elements on content pages",
|
|
7078
|
+
unreadSiteReason(ctx.evidence)
|
|
7079
|
+
);
|
|
7080
|
+
}
|
|
6588
7081
|
let pagesWithArticle = 0;
|
|
6589
7082
|
for (const page of ctx.pages) {
|
|
6590
7083
|
if (page.$("article").length > 0) pagesWithArticle++;
|
|
@@ -6631,6 +7124,7 @@ var HeaderFooterAudit = class extends Audit {
|
|
|
6631
7124
|
evidenceGrade: "A",
|
|
6632
7125
|
tier: "scored",
|
|
6633
7126
|
dossier: "docs/evidence/audits/content-extraction/header-footer.md",
|
|
7127
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
6634
7128
|
defaultPriority: "medium",
|
|
6635
7129
|
guidance: {
|
|
6636
7130
|
impact: "AI agents use <header> and <footer> landmarks to identify and exclude boilerplate content (navigation menus, copyright notices, legal links) from primary content extraction. Without these landmarks, agents may include footer disclaimers or nav menus in their content summaries, reducing answer accuracy.",
|
|
@@ -6642,6 +7136,13 @@ var HeaderFooterAudit = class extends Audit {
|
|
|
6642
7136
|
}
|
|
6643
7137
|
};
|
|
6644
7138
|
audit(ctx) {
|
|
7139
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
7140
|
+
return this.notApplicable(
|
|
7141
|
+
"No page here can be attributed to this site, so its landmarks were not judged.",
|
|
7142
|
+
"Both <header> and <footer> present on all pages",
|
|
7143
|
+
unreadSiteReason(ctx.evidence)
|
|
7144
|
+
);
|
|
7145
|
+
}
|
|
6645
7146
|
let pagesWithBoth = 0;
|
|
6646
7147
|
let pagesWithHeader = 0;
|
|
6647
7148
|
let pagesWithFooter = 0;
|
|
@@ -6722,6 +7223,7 @@ var AsideElementAudit = class extends Audit {
|
|
|
6722
7223
|
evidenceGrade: "B",
|
|
6723
7224
|
tier: "scored",
|
|
6724
7225
|
dossier: "docs/evidence/audits/content-extraction/aside-element.md",
|
|
7226
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
6725
7227
|
applicablePageTypes: ["content"],
|
|
6726
7228
|
defaultPriority: "low",
|
|
6727
7229
|
guidance: {
|
|
@@ -6804,6 +7306,7 @@ var SectionHeadingsAudit = class extends Audit {
|
|
|
6804
7306
|
evidenceGrade: "B",
|
|
6805
7307
|
tier: "scored",
|
|
6806
7308
|
dossier: "docs/evidence/audits/content-extraction/section-headings.md",
|
|
7309
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
6807
7310
|
defaultPriority: "medium",
|
|
6808
7311
|
guidance: {
|
|
6809
7312
|
impact: "AI agents use section headings to build a topic map of your page for retrieval-augmented generation. Unlabeled <section> elements are opaque to AI chunking systems, preventing them from indexing and retrieving your content by topic, which reduces your visibility in AI-generated answers.",
|
|
@@ -6982,6 +7485,7 @@ var SemanticListsAudit = class extends Audit {
|
|
|
6982
7485
|
evidenceGrade: "B",
|
|
6983
7486
|
tier: "scored",
|
|
6984
7487
|
dossier: "docs/evidence/audits/content-extraction/semantic-lists.md",
|
|
7488
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
6985
7489
|
defaultPriority: "medium",
|
|
6986
7490
|
guidance: {
|
|
6987
7491
|
impact: 'AI agents recognize <ul>, <ol>, and <dl> as structured lists and extract them as bullet points, numbered steps or term/definition pairs. Content formatted as styled <div> elements \u2014 or as paragraphs that start with "1.", "2." \u2014 collapses into undelimited prose when the page is converted to markdown or an accessibility tree, so the agent has to re-infer where each item begins.',
|
|
@@ -7053,6 +7557,7 @@ var DataTablesAudit = class extends Audit {
|
|
|
7053
7557
|
evidenceGrade: "B",
|
|
7054
7558
|
tier: "scored",
|
|
7055
7559
|
dossier: "docs/evidence/audits/content-extraction/data-tables.md",
|
|
7560
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
7056
7561
|
defaultPriority: "medium",
|
|
7057
7562
|
guidance: {
|
|
7058
7563
|
impact: "AI agents rely on <thead> and <th> elements to understand column headers and map cell values to their meanings. Without proper table structure, agents cannot interpret tabular data correctly, leading to garbled comparisons and inaccurate data extraction in AI-generated summaries.",
|
|
@@ -7064,6 +7569,13 @@ var DataTablesAudit = class extends Audit {
|
|
|
7064
7569
|
}
|
|
7065
7570
|
};
|
|
7066
7571
|
audit(ctx) {
|
|
7572
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
7573
|
+
return this.notApplicable(
|
|
7574
|
+
"No page here can be attributed to this site, so its tables were not judged.",
|
|
7575
|
+
"Tables have <thead> and <th> elements",
|
|
7576
|
+
unreadSiteReason(ctx.evidence)
|
|
7577
|
+
);
|
|
7578
|
+
}
|
|
7067
7579
|
let totalTables = 0;
|
|
7068
7580
|
let properTables = 0;
|
|
7069
7581
|
for (const page of ctx.pages) {
|
|
@@ -7076,6 +7588,13 @@ var DataTablesAudit = class extends Audit {
|
|
|
7076
7588
|
});
|
|
7077
7589
|
}
|
|
7078
7590
|
if (totalTables === 0) {
|
|
7591
|
+
if (!scanReadPageText(ctx.evidence)) {
|
|
7592
|
+
return this.notApplicable(
|
|
7593
|
+
"The scanned page served no readable text, so it held no tables to judge.",
|
|
7594
|
+
"Tables have <thead> and <th> elements",
|
|
7595
|
+
unreadPageTextReason(ctx.evidence)
|
|
7596
|
+
);
|
|
7597
|
+
}
|
|
7079
7598
|
return this.pass(
|
|
7080
7599
|
"No data tables found \u2014 check not applicable.",
|
|
7081
7600
|
"Tables have <thead> and <th> elements",
|
|
@@ -7129,6 +7648,7 @@ var CodeLanguageAudit = class extends Audit {
|
|
|
7129
7648
|
evidenceGrade: "C",
|
|
7130
7649
|
tier: "informative",
|
|
7131
7650
|
dossier: "docs/evidence/audits/content-extraction/code-language.md",
|
|
7651
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
7132
7652
|
applicablePageTypes: ["content"],
|
|
7133
7653
|
defaultPriority: "low",
|
|
7134
7654
|
guidance: {
|
|
@@ -7210,6 +7730,7 @@ var TimeElementAudit = class extends Audit {
|
|
|
7210
7730
|
evidenceGrade: "C",
|
|
7211
7731
|
tier: "informative",
|
|
7212
7732
|
dossier: "docs/evidence/audits/content-extraction/time-element.md",
|
|
7733
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
7213
7734
|
applicablePageTypes: ["content"],
|
|
7214
7735
|
defaultPriority: "medium",
|
|
7215
7736
|
guidance: {
|
|
@@ -7260,6 +7781,7 @@ var ContentDepthAudit = class extends Audit {
|
|
|
7260
7781
|
evidenceGrade: "B",
|
|
7261
7782
|
tier: "scored",
|
|
7262
7783
|
dossier: "docs/evidence/audits/content-extraction/content-depth.md",
|
|
7784
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
7263
7785
|
defaultPriority: "medium",
|
|
7264
7786
|
guidance: {
|
|
7265
7787
|
impact: "Pages with fewer than 300 words provide too little context for AI RAG systems to generate accurate, detailed answers. Thin content produces weak vector embeddings that rank poorly in retrieval, causing your pages to be excluded from AI-generated responses entirely.",
|
|
@@ -7269,6 +7791,13 @@ var ContentDepthAudit = class extends Audit {
|
|
|
7269
7791
|
}
|
|
7270
7792
|
};
|
|
7271
7793
|
audit(ctx) {
|
|
7794
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
7795
|
+
return this.notApplicable(
|
|
7796
|
+
"No page here can be attributed to this site, so its content depth was not judged.",
|
|
7797
|
+
"More than 300 words of content per page",
|
|
7798
|
+
unreadSiteReason(ctx.evidence)
|
|
7799
|
+
);
|
|
7800
|
+
}
|
|
7272
7801
|
let pagesAboveThreshold = 0;
|
|
7273
7802
|
const wordCounts = [];
|
|
7274
7803
|
for (const page of ctx.pages) {
|
|
@@ -7342,6 +7871,7 @@ var ImageAltTextAudit = class extends Audit {
|
|
|
7342
7871
|
evidenceGrade: "A",
|
|
7343
7872
|
tier: "scored",
|
|
7344
7873
|
dossier: "docs/evidence/audits/content-extraction/image-alt-text.md",
|
|
7874
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
7345
7875
|
defaultPriority: "high",
|
|
7346
7876
|
guidance: {
|
|
7347
7877
|
impact: "An image with no text alternative has no accessible name, so it is an unnamed node in the accessibility-tree snapshots agent toolkits send to a model \u2014 Playwright MCP, Claude-in-Chrome read_page, Chrome DevTools take_snapshot \u2014 and it carries no subject matter for Google Images, which states it uses alt text to understand what an image shows. A multimodal agent that fetches the image bytes can caption it without one; a text-only crawler or a snapshot-driven agent cannot.",
|
|
@@ -7428,6 +7958,7 @@ var FigureFigcaptionAudit = class extends Audit {
|
|
|
7428
7958
|
evidenceGrade: "C",
|
|
7429
7959
|
tier: "informative",
|
|
7430
7960
|
dossier: "docs/evidence/audits/content-extraction/figure-figcaption.md",
|
|
7961
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
7431
7962
|
defaultPriority: "medium",
|
|
7432
7963
|
guidance: {
|
|
7433
7964
|
impact: "AI agents use <figcaption> to understand the purpose and context of visual content beyond what alt text provides. Without captions, figures are treated as opaque image containers, and your charts, diagrams, and illustrations cannot be meaningfully cited in AI-generated answers.",
|
|
@@ -7439,6 +7970,13 @@ var FigureFigcaptionAudit = class extends Audit {
|
|
|
7439
7970
|
}
|
|
7440
7971
|
};
|
|
7441
7972
|
audit(ctx) {
|
|
7973
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
7974
|
+
return this.notApplicable(
|
|
7975
|
+
"No page here can be attributed to this site, so its figures were not judged.",
|
|
7976
|
+
"Images with context wrapped in <figure> with <figcaption>",
|
|
7977
|
+
unreadSiteReason(ctx.evidence)
|
|
7978
|
+
);
|
|
7979
|
+
}
|
|
7442
7980
|
let totalFigures = 0;
|
|
7443
7981
|
let figuresWithCaption = 0;
|
|
7444
7982
|
for (const page of ctx.pages) {
|
|
@@ -7467,6 +8005,13 @@ var FigureFigcaptionAudit = class extends Audit {
|
|
|
7467
8005
|
}
|
|
7468
8006
|
);
|
|
7469
8007
|
}
|
|
8008
|
+
if (!scanReadPageText(ctx.evidence)) {
|
|
8009
|
+
return this.notApplicable(
|
|
8010
|
+
"The scanned page served no readable text, so it held no images or figures to judge.",
|
|
8011
|
+
"Images with context wrapped in <figure> with <figcaption>",
|
|
8012
|
+
unreadPageTextReason(ctx.evidence)
|
|
8013
|
+
);
|
|
8014
|
+
}
|
|
7470
8015
|
return this.pass(
|
|
7471
8016
|
"No images or <figure> elements found \u2014 check not applicable.",
|
|
7472
8017
|
"Images with context wrapped in <figure> with <figcaption>",
|
|
@@ -7550,6 +8095,7 @@ var SvgBloatAudit = class extends Audit {
|
|
|
7550
8095
|
evidenceGrade: "B",
|
|
7551
8096
|
tier: "scored",
|
|
7552
8097
|
dossier: "docs/evidence/audits/content-extraction/svg-bloat.md",
|
|
8098
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
7553
8099
|
defaultPriority: "medium",
|
|
7554
8100
|
guidance: {
|
|
7555
8101
|
impact: "Large inline SVGs are inlined verbatim as path-data tokens when an LLM converts your page to Markdown. A single 10KB icon or chart can consume thousands of tokens of agent context per page load, inflating agent cost and pushing real content out of the context window \u2014 reducing the quality of what agents extract and say about your site.",
|
|
@@ -12960,6 +13506,7 @@ var TokenRatioAudit = class extends Audit {
|
|
|
12960
13506
|
evidenceGrade: "B",
|
|
12961
13507
|
tier: "scored",
|
|
12962
13508
|
dossier: "docs/evidence/audits/content-extraction/token-ratio.md",
|
|
13509
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
12963
13510
|
defaultPriority: "high",
|
|
12964
13511
|
guidance: {
|
|
12965
13512
|
impact: "When less than 15% of your HTML is actual content, AI agents burn most of their context window and token budget on markup noise: inline scripts, CSS, SVG sprites, tracking tags, and deeply nested divs. The useful text that remains gets weaker attention from the model, and pages with extreme bloat may be truncated before the real content is even read.",
|
|
@@ -12970,6 +13517,13 @@ var TokenRatioAudit = class extends Audit {
|
|
|
12970
13517
|
}
|
|
12971
13518
|
};
|
|
12972
13519
|
audit(ctx) {
|
|
13520
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
13521
|
+
return this.notApplicable(
|
|
13522
|
+
"No page here can be attributed to this site, so its token mix was not measured.",
|
|
13523
|
+
"A homepage from this site whose token mix can be measured",
|
|
13524
|
+
unreadSiteReason(ctx.evidence)
|
|
13525
|
+
);
|
|
13526
|
+
}
|
|
12973
13527
|
const page = ctx.pages[0];
|
|
12974
13528
|
const rawHtml = page?.fetchResult.body ?? "";
|
|
12975
13529
|
if (!page || rawHtml.trim().length === 0) {
|
|
@@ -13091,6 +13645,7 @@ var FakeHeadingsAudit = class extends Audit {
|
|
|
13091
13645
|
evidenceGrade: "B",
|
|
13092
13646
|
tier: "scored",
|
|
13093
13647
|
dossier: "docs/evidence/audits/content-extraction/fake-headings.md",
|
|
13648
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
13094
13649
|
defaultPriority: "medium",
|
|
13095
13650
|
guidance: {
|
|
13096
13651
|
impact: "AI agents build content outlines exclusively from <h1>\u2013<h6> elements. Text that only looks like a heading is treated as ordinary body copy, so agents miss your section structure entirely \u2014 summaries flatten into a wall of text, section-level citations become impossible, and chunking for retrieval splits content at arbitrary points instead of at your intended section boundaries.",
|
|
@@ -13102,6 +13657,13 @@ var FakeHeadingsAudit = class extends Audit {
|
|
|
13102
13657
|
}
|
|
13103
13658
|
};
|
|
13104
13659
|
audit(ctx) {
|
|
13660
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
13661
|
+
return this.notApplicable(
|
|
13662
|
+
"No page here can be attributed to this site, so its headings were not judged.",
|
|
13663
|
+
"All heading-like text uses semantic <h1>-<h6> elements",
|
|
13664
|
+
unreadSiteReason(ctx.evidence)
|
|
13665
|
+
);
|
|
13666
|
+
}
|
|
13105
13667
|
const found = [];
|
|
13106
13668
|
for (const page of ctx.pages) {
|
|
13107
13669
|
const $ = page.$;
|
|
@@ -13125,6 +13687,13 @@ var FakeHeadingsAudit = class extends Audit {
|
|
|
13125
13687
|
const foundSummary = found.slice(0, 5).map((f) => `${f.url}: ${describe5(f.heading)}`).join("; ");
|
|
13126
13688
|
const expected = "All heading-like text uses semantic <h1>-<h6> elements";
|
|
13127
13689
|
if (found.length === 0) {
|
|
13690
|
+
if (!scanReadPageText(ctx.evidence)) {
|
|
13691
|
+
return this.notApplicable(
|
|
13692
|
+
"The scanned page served no readable text, so it held no headings to judge.",
|
|
13693
|
+
expected,
|
|
13694
|
+
unreadPageTextReason(ctx.evidence)
|
|
13695
|
+
);
|
|
13696
|
+
}
|
|
13128
13697
|
return this.pass(
|
|
13129
13698
|
"No fake headings detected \u2014 heading-like text uses semantic heading elements.",
|
|
13130
13699
|
expected,
|
|
@@ -13154,6 +13723,9 @@ var FakeHeadingsAudit = class extends Audit {
|
|
|
13154
13723
|
};
|
|
13155
13724
|
|
|
13156
13725
|
// src/audits/content-extraction/server-rendered.ts
|
|
13726
|
+
function withDetails(result, details) {
|
|
13727
|
+
return { ...result, details: { ...result.details ?? {}, ...details } };
|
|
13728
|
+
}
|
|
13157
13729
|
var ServerRenderedAudit = class extends Audit {
|
|
13158
13730
|
static meta = {
|
|
13159
13731
|
id: "content-extraction/server-rendered",
|
|
@@ -13166,6 +13738,8 @@ var ServerRenderedAudit = class extends Audit {
|
|
|
13166
13738
|
evidenceGrade: "B",
|
|
13167
13739
|
tier: "scored",
|
|
13168
13740
|
dossier: "docs/evidence/audits/content-extraction/server-rendered.md",
|
|
13741
|
+
// Gate exemption: A shell is what this audit reports. Gating it would delete the finding.
|
|
13742
|
+
requires: ["origin-reachable", "unblocked-fetches"],
|
|
13169
13743
|
defaultPriority: "critical",
|
|
13170
13744
|
guidance: {
|
|
13171
13745
|
impact: "AI crawlers (GPTBot, ClaudeBot, PerplexityBot) do not execute JavaScript. If your content is only rendered client-side, these crawlers see an empty or near-empty page. Your products, articles, and brand information are completely absent from AI knowledge bases, meaning AI-generated answers never reference your site.",
|
|
@@ -13177,37 +13751,64 @@ var ServerRenderedAudit = class extends Audit {
|
|
|
13177
13751
|
}
|
|
13178
13752
|
};
|
|
13179
13753
|
audit(ctx) {
|
|
13180
|
-
|
|
13181
|
-
|
|
13182
|
-
|
|
13183
|
-
"
|
|
13184
|
-
|
|
13185
|
-
"No homepage fetched",
|
|
13186
|
-
void 0,
|
|
13187
|
-
void 0
|
|
13754
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
13755
|
+
return this.notApplicable(
|
|
13756
|
+
"No page here can be attributed to this site, so its served HTML was not judged.",
|
|
13757
|
+
"Every fetched page serves > 50 words or > 200 characters of readable text",
|
|
13758
|
+
unreadSiteReason(ctx.evidence)
|
|
13188
13759
|
);
|
|
13189
13760
|
}
|
|
13190
|
-
const
|
|
13191
|
-
|
|
13192
|
-
|
|
13193
|
-
|
|
13194
|
-
|
|
13195
|
-
|
|
13196
|
-
|
|
13197
|
-
|
|
13198
|
-
|
|
13761
|
+
const pages = ctx.pages ?? [];
|
|
13762
|
+
if (pages.length === 0) {
|
|
13763
|
+
return this.notApplicable(
|
|
13764
|
+
"The scan fetched no page, so there is no served HTML to judge.",
|
|
13765
|
+
"Every fetched page serves > 50 words or > 200 characters of readable text",
|
|
13766
|
+
"No page fetched"
|
|
13767
|
+
);
|
|
13768
|
+
}
|
|
13769
|
+
const rendered = ctx.evidence.renderedByPage;
|
|
13770
|
+
const emptyPages = pages.filter((page) => !(rendered[page.url] ?? pageRendersText(page))).map((page) => page.url);
|
|
13771
|
+
const total = pages.length;
|
|
13772
|
+
const renderedCount = total - emptyPages.length;
|
|
13773
|
+
const expected = "Every fetched page serves > 50 words or > 200 characters of readable text";
|
|
13774
|
+
const found = `${renderedCount} of ${total} page(s) served readable text`;
|
|
13775
|
+
if (emptyPages.length === 0) {
|
|
13776
|
+
return withDetails(
|
|
13777
|
+
this.pass(
|
|
13778
|
+
`All ${total} fetched page(s) serve their content in the HTML response.`,
|
|
13779
|
+
expected,
|
|
13780
|
+
found,
|
|
13781
|
+
pages[0].url
|
|
13782
|
+
),
|
|
13783
|
+
{ pagesChecked: total, renderedPages: renderedCount }
|
|
13199
13784
|
);
|
|
13200
13785
|
}
|
|
13201
|
-
|
|
13202
|
-
|
|
13203
|
-
"
|
|
13204
|
-
|
|
13205
|
-
|
|
13206
|
-
|
|
13207
|
-
|
|
13208
|
-
|
|
13209
|
-
|
|
13210
|
-
|
|
13786
|
+
const failGuidance = {
|
|
13787
|
+
priority: "critical",
|
|
13788
|
+
description: "AI crawlers like GPTBot and ClaudeBot do not execute JavaScript. Content only visible after JS execution is completely invisible to them, meaning your site effectively has no content in AI knowledge bases. Use SSR (server-side rendering) or SSG (static site generation) to serve content in the initial HTML response.",
|
|
13789
|
+
code: "// Next.js SSR example:\nexport async function getServerSideProps() {\n const data = await fetchData();\n return { props: { data } };\n}"
|
|
13790
|
+
};
|
|
13791
|
+
if (renderedCount === 0) {
|
|
13792
|
+
return withDetails(
|
|
13793
|
+
this.fail(
|
|
13794
|
+
`None of the ${total} fetched page(s) serve readable content in the HTML response. AI agents cannot read client-side-only rendered content.`,
|
|
13795
|
+
expected,
|
|
13796
|
+
found,
|
|
13797
|
+
failGuidance,
|
|
13798
|
+
pages[0].url
|
|
13799
|
+
),
|
|
13800
|
+
{ pagesChecked: total, renderedPages: 0, emptyPages }
|
|
13801
|
+
);
|
|
13802
|
+
}
|
|
13803
|
+
return withDetails(
|
|
13804
|
+
this.warn(
|
|
13805
|
+
`${emptyPages.length} of ${total} fetched page(s) serve no readable content in the HTML response. AI agents read nothing on those pages.`,
|
|
13806
|
+
expected,
|
|
13807
|
+
found,
|
|
13808
|
+
failGuidance,
|
|
13809
|
+
emptyPages[0]
|
|
13810
|
+
),
|
|
13811
|
+
{ pagesChecked: total, renderedPages: renderedCount, emptyPages }
|
|
13211
13812
|
);
|
|
13212
13813
|
}
|
|
13213
13814
|
};
|
|
@@ -13452,6 +14053,7 @@ var CssHiddenGhostContentAudit = class _CssHiddenGhostContentAudit extends Audit
|
|
|
13452
14053
|
evidenceGrade: "A",
|
|
13453
14054
|
tier: "scored",
|
|
13454
14055
|
dossier: "docs/evidence/audits/content-extraction/css-hidden-ghost-content.md",
|
|
14056
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
13455
14057
|
defaultPriority: "medium",
|
|
13456
14058
|
guidance: {
|
|
13457
14059
|
impact: "This is provable from source, not inferred. Readability's visibility test consults only node.style.display, node.style.visibility, the hidden attribute and aria-hidden \u2014 it explicitly does not evaluate class-based CSS rules from stylesheets. AI crawlers do not render, so no cascade is ever computed. Therefore any subtree hidden by `.mobile-only{display:none}`, `.tab-panel:not(.active){display:none}` or `[data-state=closed]{display:none}` reaches the model as ordinary body text with full weight. Consequence is not just cost: the agent sees three parallel copies of a nav, both the collapsed and expanded FAQ answers, and often stale price text from a hidden variant block, and irrelevant/contradictory context measurably degrades answers.",
|
|
@@ -13470,6 +14072,13 @@ var CssHiddenGhostContentAudit = class _CssHiddenGhostContentAudit extends Audit
|
|
|
13470
14072
|
};
|
|
13471
14073
|
}
|
|
13472
14074
|
async audit(ctx) {
|
|
14075
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
14076
|
+
return this.notApplicable(
|
|
14077
|
+
"No page here can be attributed to this site, so its hidden text was not measured.",
|
|
14078
|
+
EXPECTED11,
|
|
14079
|
+
unreadSiteReason(ctx.evidence)
|
|
14080
|
+
);
|
|
14081
|
+
}
|
|
13473
14082
|
const s = await survey2(ctx);
|
|
13474
14083
|
const partial = s.crossOrigin > 0 ? `; ${s.crossOrigin} cross-origin stylesheet not fetched` : "";
|
|
13475
14084
|
if (s.totalChars === 0) {
|
|
@@ -13630,6 +14239,7 @@ var HydrationPayloadShareAudit = class _HydrationPayloadShareAudit extends Audit
|
|
|
13630
14239
|
evidenceGrade: "A",
|
|
13631
14240
|
tier: "scored",
|
|
13632
14241
|
dossier: "docs/evidence/audits/content-extraction/hydration-payload-share.md",
|
|
14242
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
13633
14243
|
defaultPriority: "medium",
|
|
13634
14244
|
guidance: {
|
|
13635
14245
|
impact: "These blobs are inlined into every HTML response by design, and the framework vendor itself flags > 128 kB as a defect. A browser parses them and throws them away after hydration; a non-rendering AI crawler cannot \u2014 it tokenizes the JSON verbatim, including escaped HTML, CDN image variants, GraphQL type metadata and the full body text a second time. The causal claim is falsifiable per page: strip these script nodes, re-tokenize, and the delta is the exact context cost that carries zero incremental information, since duplicate #3 is byte-identical content the agent already has.",
|
|
@@ -13784,6 +14394,7 @@ var PreambleTaxTokensBeforeTheFirstContentTokenAudit = class extends Audit {
|
|
|
13784
14394
|
weight: weightForGrade("B", "scored"),
|
|
13785
14395
|
defaultPriority: "medium",
|
|
13786
14396
|
dossier: "docs/evidence/audits/content-extraction/preamble-tax.md",
|
|
14397
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
13787
14398
|
guidance: {
|
|
13788
14399
|
impact: "A non-rendering agent ingests the document as a linear stream, so DOM order is context order. A page that inlines a critical-CSS block and a serialized state blob ahead of its content does two things at once: it pushes the answer into the middle of the context window, where retrieval is measurably weakest, and it guarantees the answer is what gets cut when the fetching harness truncates to a byte or token cap.",
|
|
13789
14400
|
fix: "Move inline `<style>` and `<script>` blocks below the main content or into external files, and put `<main>` as early in the body as the layout allows. Where critical CSS must be inline, keep it to the rules that paint the first screen rather than the whole stylesheet.",
|
|
@@ -13804,6 +14415,13 @@ var PreambleTaxTokensBeforeTheFirstContentTokenAudit = class extends Audit {
|
|
|
13804
14415
|
}
|
|
13805
14416
|
};
|
|
13806
14417
|
audit(ctx) {
|
|
14418
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
14419
|
+
return this.notApplicable(
|
|
14420
|
+
"No page here can be attributed to this site, so its preamble was not measured.",
|
|
14421
|
+
"A page from this site whose preamble can be measured",
|
|
14422
|
+
unreadSiteReason(ctx.evidence)
|
|
14423
|
+
);
|
|
14424
|
+
}
|
|
13807
14425
|
const page = ctx.pages[0];
|
|
13808
14426
|
if (!page) {
|
|
13809
14427
|
return this.notApplicable(
|
|
@@ -13928,6 +14546,7 @@ var BoilerplateTaxAudit = class extends Audit {
|
|
|
13928
14546
|
weight: weightForGrade("B", "scored"),
|
|
13929
14547
|
defaultPriority: "medium",
|
|
13930
14548
|
dossier: "docs/evidence/audits/content-extraction/boilerplate-tax.md",
|
|
14549
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
13931
14550
|
guidance: {
|
|
13932
14551
|
impact: "An agent answering a question about a site fetches several of its pages. If each fetch delivers the same navigation, the same promotional header and the same footer around a thin body, the agent pays for those tokens once per fetch and learns nothing new from them. The cost compounds with every page, and the distinct content it came for competes for what is left of the context window.",
|
|
13933
14552
|
fix: "Cut repeated chrome down to what a reader needs on every page: collapse mega-menus to a short nav, move legal and marketing boilerplate to the pages that are about it, and let each page carry more of its own content. Where the chrome must stay for humans, keeping it out of `<main>` at least lets an extractor drop it.",
|
|
@@ -14053,6 +14672,7 @@ var ExtractionDeterminismAudit = class extends Audit {
|
|
|
14053
14672
|
weight: weightForGrade("B", "scored"),
|
|
14054
14673
|
defaultPriority: "high",
|
|
14055
14674
|
dossier: "docs/evidence/audits/content-extraction/extraction-determinism.md",
|
|
14675
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
14056
14676
|
guidance: {
|
|
14057
14677
|
impact: "Every agent pipeline strips a page down before a model reads it, and they do not all strip the same way. When the extractors disagree, the same URL yields different answers depending on which tool fetched it \u2014 and the page cannot be tested, because there is no single thing it says. When readability declines a page outright, the most widely deployed extractor of the three hands an agent nothing at all.",
|
|
14058
14678
|
fix: "Put the article in one container \u2014 `<main>` or `<article>` \u2014 with the chrome outside it, and keep the largest block of prose on the page the one you want quoted. Readability keys on paragraph density and link density, so a body split across many small wrappers, or padded with link-heavy blocks, is what makes the three disagree.",
|
|
@@ -14062,6 +14682,13 @@ var ExtractionDeterminismAudit = class extends Audit {
|
|
|
14062
14682
|
}
|
|
14063
14683
|
};
|
|
14064
14684
|
audit(ctx) {
|
|
14685
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
14686
|
+
return this.notApplicable(
|
|
14687
|
+
"No page here can be attributed to this site, so no extraction could be compared.",
|
|
14688
|
+
"A page from this site whose extraction can be compared",
|
|
14689
|
+
unreadSiteReason(ctx.evidence)
|
|
14690
|
+
);
|
|
14691
|
+
}
|
|
14065
14692
|
const page = ctx.pages[0];
|
|
14066
14693
|
if (!page) {
|
|
14067
14694
|
return this.notApplicable(
|
|
@@ -14230,6 +14857,7 @@ var LlmsTxtExistsAudit = class extends Audit {
|
|
|
14230
14857
|
evidenceGrade: "C",
|
|
14231
14858
|
tier: "informative",
|
|
14232
14859
|
dossier: "docs/evidence/audits/machine-discovery/llms-txt-exists.md",
|
|
14860
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
14233
14861
|
defaultPriority: "low",
|
|
14234
14862
|
guidance: {
|
|
14235
14863
|
impact: "Thousands of sites publish an llms.txt, including every major AI lab, but as publishers rather than readers. No vendor documentation names an agent that fetches it, and Google Search Central states Search ignores it. Publishing one is cheap and harmless; it is not a documented path to any AI answer.",
|
|
@@ -14310,6 +14938,7 @@ var LlmsTxtStructureAudit = class extends Audit {
|
|
|
14310
14938
|
evidenceGrade: "C",
|
|
14311
14939
|
tier: "informative",
|
|
14312
14940
|
dossier: "docs/evidence/audits/machine-discovery/llms-txt-structure.md",
|
|
14941
|
+
requires: ["origin-reachable"],
|
|
14313
14942
|
defaultPriority: "low",
|
|
14314
14943
|
guidance: {
|
|
14315
14944
|
impact: "The reference llms.txt parser extracts the blockquote as a `summary` field and the H2 headings as a `sections` map, so a file that carries both is machine-navigable: an agent can read the summary and pick a section instead of consuming the whole file. No vendor documents an agent behaving differently when either element is absent, so this is reported, not scored.",
|
|
@@ -14373,6 +15002,7 @@ var LlmsTxtLinkDescriptionsAudit = class extends Audit {
|
|
|
14373
15002
|
evidenceGrade: "C",
|
|
14374
15003
|
tier: "informative",
|
|
14375
15004
|
dossier: "docs/evidence/audits/machine-discovery/llms-txt-link-descriptions.md",
|
|
15005
|
+
requires: ["origin-reachable"],
|
|
14376
15006
|
defaultPriority: "medium",
|
|
14377
15007
|
guidance: {
|
|
14378
15008
|
impact: "Links without descriptions force AI agents to visit every page to understand its content, wasting crawl budget and slowing down response generation. Described links let agents filter relevant pages instantly.",
|
|
@@ -14468,6 +15098,7 @@ var LlmsTxtLinksValidAudit = class extends Audit {
|
|
|
14468
15098
|
evidenceGrade: "C",
|
|
14469
15099
|
tier: "informative",
|
|
14470
15100
|
dossier: "docs/evidence/audits/machine-discovery/llms-txt-links-valid.md",
|
|
15101
|
+
requires: ["origin-reachable"],
|
|
14471
15102
|
defaultPriority: "low",
|
|
14472
15103
|
guidance: {
|
|
14473
15104
|
impact: "A broken link inside llms.txt points at nothing, the same as a broken link anywhere else. No documented agent consumer reads the file, so the cost is to any human or tool that follows it, not to a measured AI outcome.",
|
|
@@ -14549,6 +15180,7 @@ var LlmsFullTxtAudit = class extends Audit {
|
|
|
14549
15180
|
evidenceGrade: "C",
|
|
14550
15181
|
tier: "informative",
|
|
14551
15182
|
dossier: "docs/evidence/audits/machine-discovery/llms-full-txt.md",
|
|
15183
|
+
requires: ["origin-reachable"],
|
|
14552
15184
|
defaultPriority: "high",
|
|
14553
15185
|
guidance: {
|
|
14554
15186
|
impact: "Without llms-full.txt, AI agents must crawl your site page by page, which is slow and often incomplete. This means AI assistants give shallow or outdated answers about your products and services.",
|
|
@@ -14560,6 +15192,13 @@ var LlmsFullTxtAudit = class extends Audit {
|
|
|
14560
15192
|
}
|
|
14561
15193
|
};
|
|
14562
15194
|
audit(ctx) {
|
|
15195
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
15196
|
+
return this.notApplicable(
|
|
15197
|
+
"No response here can be attributed to this site, so no llms-full.txt was judged.",
|
|
15198
|
+
"GET /llms-full.txt returns 200",
|
|
15199
|
+
unreadSiteReason(ctx.evidence)
|
|
15200
|
+
);
|
|
15201
|
+
}
|
|
14563
15202
|
const result = ctx.rootFiles["/llms-full.txt"];
|
|
14564
15203
|
if (!result || !isOk5(result)) {
|
|
14565
15204
|
return this.fail(
|
|
@@ -14617,6 +15256,7 @@ var SitemapExistsAudit = class extends Audit {
|
|
|
14617
15256
|
evidenceGrade: "A",
|
|
14618
15257
|
tier: "scored",
|
|
14619
15258
|
dossier: "docs/evidence/audits/machine-discovery/sitemap-exists.md",
|
|
15259
|
+
requires: ["origin-reachable"],
|
|
14620
15260
|
defaultPriority: "critical",
|
|
14621
15261
|
guidance: {
|
|
14622
15262
|
impact: "Without a sitemap, AI crawlers must discover your pages solely through link-following, which is slow and incomplete. Pages deep in your site hierarchy may never be found, meaning AI search engines like Perplexity and ChatGPT Browse cannot surface your full content.",
|
|
@@ -14756,6 +15396,7 @@ var DiscoveryIndexCoverageAudit = class extends Audit {
|
|
|
14756
15396
|
evidenceGrade: "B",
|
|
14757
15397
|
tier: "scored",
|
|
14758
15398
|
dossier: "docs/evidence/audits/machine-discovery/discovery-index-coverage.md",
|
|
15399
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
14759
15400
|
defaultPriority: "medium",
|
|
14760
15401
|
guidance: {
|
|
14761
15402
|
impact: "A page listed in no discovery index is reachable only through the link graph, and the major AI crawlers do not execute JavaScript \u2014 so a page missing from both the sitemap and llms.txt can stay invisible to AI search even though it exists on your site.",
|
|
@@ -14888,6 +15529,7 @@ var SitemapAbsoluteUrlsAudit = class extends Audit {
|
|
|
14888
15529
|
evidenceGrade: "B",
|
|
14889
15530
|
tier: "scored",
|
|
14890
15531
|
dossier: "docs/evidence/audits/machine-discovery/sitemap-absolute-urls.md",
|
|
15532
|
+
requires: ["origin-reachable"],
|
|
14891
15533
|
defaultPriority: "high",
|
|
14892
15534
|
guidance: {
|
|
14893
15535
|
impact: "Relative URLs in your sitemap cannot be resolved by AI crawlers, causing them to silently skip those pages. Any page listed with a relative URL is effectively invisible to AI search engines.",
|
|
@@ -14992,6 +15634,7 @@ var SitemapLastmodAudit = class extends Audit {
|
|
|
14992
15634
|
evidenceGrade: "A",
|
|
14993
15635
|
tier: "scored",
|
|
14994
15636
|
dossier: "docs/evidence/audits/machine-discovery/sitemap-lastmod.md",
|
|
15637
|
+
requires: ["origin-reachable"],
|
|
14995
15638
|
defaultPriority: "medium",
|
|
14996
15639
|
guidance: {
|
|
14997
15640
|
impact: "Without <lastmod> dates, AI crawlers must re-fetch every page on every visit because they cannot tell which pages have changed. This wastes crawl budget and delays indexing of your freshest content.",
|
|
@@ -15128,6 +15771,7 @@ var RssFeedAudit = class extends Audit {
|
|
|
15128
15771
|
evidenceGrade: "B",
|
|
15129
15772
|
tier: "scored",
|
|
15130
15773
|
dossier: "docs/evidence/audits/machine-discovery/rss-feed.md",
|
|
15774
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
15131
15775
|
defaultPriority: "medium",
|
|
15132
15776
|
guidance: {
|
|
15133
15777
|
impact: "Without an RSS/Atom feed, AI agents have no efficient way to track new and updated content on your site. They must re-crawl your entire site to find changes, which means your latest posts and pages may take much longer to appear in AI search results.",
|
|
@@ -15138,6 +15782,13 @@ var RssFeedAudit = class extends Audit {
|
|
|
15138
15782
|
}
|
|
15139
15783
|
};
|
|
15140
15784
|
async audit(ctx) {
|
|
15785
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
15786
|
+
return this.notApplicable(
|
|
15787
|
+
"No response here can be attributed to this site, so no feed was judged.",
|
|
15788
|
+
"Feed returns HTTP 200",
|
|
15789
|
+
unreadSiteReason(ctx.evidence)
|
|
15790
|
+
);
|
|
15791
|
+
}
|
|
15141
15792
|
const links = autodiscoveryLinks(ctx);
|
|
15142
15793
|
const feed = await findFeedResult(ctx, links);
|
|
15143
15794
|
const linkNote = links.length > 0 ? `autodiscovery <link> present (${links[0].url})` : "no autodiscovery <link> in <head>";
|
|
@@ -15216,6 +15867,7 @@ var RssFeedContentAudit = class extends Audit {
|
|
|
15216
15867
|
evidenceGrade: "C",
|
|
15217
15868
|
tier: "informative",
|
|
15218
15869
|
dossier: "docs/evidence/audits/machine-discovery/rss-feed-content.md",
|
|
15870
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
15219
15871
|
defaultPriority: "medium",
|
|
15220
15872
|
guidance: {
|
|
15221
15873
|
impact: "Truncated RSS feed items force AI agents to visit each page individually, increasing crawl time and often resulting in incomplete indexing. Full-content feeds let agents ingest all your articles in a single request, producing richer AI-generated answers.",
|
|
@@ -15371,6 +16023,7 @@ var InContentLinksAudit = class extends Audit {
|
|
|
15371
16023
|
evidenceGrade: "A",
|
|
15372
16024
|
tier: "scored",
|
|
15373
16025
|
dossier: "docs/evidence/audits/machine-discovery/in-content-links.md",
|
|
16026
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
15374
16027
|
defaultPriority: "medium",
|
|
15375
16028
|
guidance: {
|
|
15376
16029
|
impact: "Google can only crawl a link that is an <a> element with an href, and the measured behaviour of GPTBot and ClaudeBot is that they do not execute JavaScript \u2014 so a page whose only links are in a client-rendered nav is a dead end for them. Links inside the body copy also tell an agent which pages belong together, which template chrome (identical on every page) cannot.",
|
|
@@ -15447,6 +16100,7 @@ var NoBrokenLinksAudit = class _NoBrokenLinksAudit extends Audit {
|
|
|
15447
16100
|
evidenceGrade: "A",
|
|
15448
16101
|
tier: "scored",
|
|
15449
16102
|
dossier: "docs/evidence/audits/machine-discovery/no-broken-links.md",
|
|
16103
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
15450
16104
|
defaultPriority: "high",
|
|
15451
16105
|
guidance: {
|
|
15452
16106
|
impact: "Broken internal links waste AI crawlers' limited crawl budget by sending them to dead ends. This means fewer of your pages get indexed, and users asking AI about your site may encounter errors or missing information.",
|
|
@@ -15547,6 +16201,7 @@ var CorsAiFilesAudit = class extends Audit {
|
|
|
15547
16201
|
evidenceGrade: "C",
|
|
15548
16202
|
tier: "informative",
|
|
15549
16203
|
dossier: "docs/evidence/audits/machine-discovery/cors-ai-files.md",
|
|
16204
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
15550
16205
|
defaultPriority: "medium",
|
|
15551
16206
|
guidance: {
|
|
15552
16207
|
impact: "Browser-based AI tools, ChatGPT plugins, and MCP clients all run in browser contexts governed by the same-origin policy. Without CORS headers on your llms.txt and AI catalog, these agents receive a network error instead of your content \u2014 making your AI-facing files completely invisible to the fastest-growing category of AI consumers.",
|
|
@@ -15668,6 +16323,7 @@ var AiFileDeliveryAudit = class extends Audit {
|
|
|
15668
16323
|
evidenceGrade: "B",
|
|
15669
16324
|
tier: "informative",
|
|
15670
16325
|
dossier: "docs/evidence/audits/machine-discovery/ai-file-delivery.md",
|
|
16326
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
15671
16327
|
defaultPriority: "medium",
|
|
15672
16328
|
guidance: {
|
|
15673
16329
|
impact: "Incorrect Content-Type headers cause AI agents to misparse your files: JSON served as text/html breaks structured-data extraction, an XML sitemap served as text/plain hides it from crawl discovery, and llms.txt served as application/octet-stream triggers a download instead of a read. Missing caching headers make every agent re-download the full file on each visit rather than revalidating it.",
|
|
@@ -15761,6 +16417,7 @@ var NoBrokenAiEndpointsAudit = class extends Audit {
|
|
|
15761
16417
|
evidenceGrade: "A",
|
|
15762
16418
|
tier: "scored",
|
|
15763
16419
|
dossier: "docs/evidence/audits/machine-discovery/no-broken-ai-endpoints.md",
|
|
16420
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
15764
16421
|
defaultPriority: "high",
|
|
15765
16422
|
guidance: {
|
|
15766
16423
|
impact: "Broken URLs in your AI manifest files (ai-catalog.json, llms.txt, navigation.json) cause agents to lose trust in your entire manifest. After encountering broken links, AI systems may stop following any of your listed endpoints, effectively making all your AI-facing resources undiscoverable.",
|
|
@@ -15839,6 +16496,15 @@ var NoBrokenAiEndpointsAudit = class extends Audit {
|
|
|
15839
16496
|
for (const url of allUrls) {
|
|
15840
16497
|
if (await isSafeUrl(url)) urls.push(url);
|
|
15841
16498
|
}
|
|
16499
|
+
if (urls.length === 0) {
|
|
16500
|
+
return this.warn(
|
|
16501
|
+
`${allUrls.length} AI endpoint URL(s) are listed, and none of them could be requested: each names localhost, a private address, or a host that does not resolve.`,
|
|
16502
|
+
"All URLs from AI-related files return 200",
|
|
16503
|
+
`${allUrls.length} URL(s) listed, 0 reachable to check`,
|
|
16504
|
+
void 0,
|
|
16505
|
+
page?.url
|
|
16506
|
+
);
|
|
16507
|
+
}
|
|
15842
16508
|
const results = await Promise.all(
|
|
15843
16509
|
urls.map(async (url) => {
|
|
15844
16510
|
try {
|
|
@@ -16030,6 +16696,7 @@ var AiCrawlerSurfaceReachabilityAudit = class extends Audit {
|
|
|
16030
16696
|
evidenceGrade: "A",
|
|
16031
16697
|
tier: "scored",
|
|
16032
16698
|
dossier: "docs/evidence/audits/machine-discovery/ai-crawler-surface-reachability.md",
|
|
16699
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
16033
16700
|
defaultPriority: "high",
|
|
16034
16701
|
guidance: {
|
|
16035
16702
|
impact: "The Sitemap: directive is host-global and user-agent independent (RFC 9309 \xA72.2.3), but the sitemap file, the feed files and every URL they list obey per-crawler rules \u2014 and under \xA72.2.1 a crawler with a named group ignores the '*' group entirely. OpenAI documents the consequence at the extreme: 'Sites that are opted out of OAI-SearchBot will not be shown in ChatGPT search answers.' So for any crawler whose named group disallows the advertised sitemap or feed path, or a majority of the URLs the sitemap lists, the site's whole pull-indexing surface is unreachable to that agent no matter how good the sitemap is. The common trigger is a bot-blocking plugin adding a broad pattern (Disallow: /*.xml$, Disallow: /feed/, Disallow: /) to an AI-bot group while the site keeps advertising those exact paths.",
|
|
@@ -16215,6 +16882,7 @@ var SitemapLastmodVerifiabilityAudit = class extends Audit {
|
|
|
16215
16882
|
evidenceGrade: "A",
|
|
16216
16883
|
tier: "scored",
|
|
16217
16884
|
dossier: "docs/evidence/audits/machine-discovery/sitemap-lastmod-verifiability.md",
|
|
16885
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
16218
16886
|
defaultPriority: "medium",
|
|
16219
16887
|
guidance: {
|
|
16220
16888
|
impact: `Google states it uses <lastmod> "if it's consistently and verifiably (for example by comparing to the last modification of the page) accurate". lastmod is therefore a conditional signal an engine silently discards on divergence \u2014 and it is the only freshness hint a pull-based AI crawler gets from a sitemap. If sampled values disagree with every available page-level signal for a material share of URLs, the freshness channel is inert and re-crawl scheduling degrades to organic rediscovery. Two specific pathologies are detectable without guessing: over 90% of URLs sharing one lastmod equal to the last deploy date \u2014 a build stamp, exactly the pattern Google's "copyright date is not significant" rule disqualifies \u2014 and a lastmod in the future relative to the scan, which is never valid.`,
|
|
@@ -16596,6 +17264,7 @@ var CheckoutOfferFieldMappingAudit = class _CheckoutOfferFieldMappingAudit exten
|
|
|
16596
17264
|
evidenceGrade: "A",
|
|
16597
17265
|
tier: "scored",
|
|
16598
17266
|
dossier: "docs/evidence/audits/agentic-commerce/checkout-offer-field-mapping.md",
|
|
17267
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
16599
17268
|
applicablePageTypes: ["product"],
|
|
16600
17269
|
defaultPriority: "high",
|
|
16601
17270
|
guidance: {
|
|
@@ -16792,6 +17461,7 @@ var AgentCommerceFeedParityAudit = class extends Audit {
|
|
|
16792
17461
|
evidenceGrade: "A",
|
|
16793
17462
|
tier: "scored",
|
|
16794
17463
|
dossier: "docs/evidence/audits/machine-discovery/agent-commerce-feed-parity.md",
|
|
17464
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
16795
17465
|
defaultPriority: "high",
|
|
16796
17466
|
guidance: {
|
|
16797
17467
|
impact: `Google's automatic item updates repair feed/page discrepancies "using the structured data markup the crawlers find on your website", and state that where extractors cannot determine price, availability and condition, "your products will be subject to item-level disapprovals". Merchant Center separately requires that feed availability match the landing page and that price match the landing page and checkout. OpenAI's Product Feed Spec requires a strictly larger per-item set than Google's rich-result minimum: a stable item_id (<=100 chars), brand (<=70), seller_name, target_countries as ISO 3166-1 alpha-2, a plain-text description under 5000 characters, availability from a fixed enum, and price with an ISO 4217 currency. Falsifiable claim: a PDP missing brand, seller, itemCondition-as-URL, a stable SKU or a country signal passes every Google rich-result test yet cannot be reconciled by automatic item updates, so feed rejections are silent and unattributable. Second claim, sharper: where the JSON-LD price disagrees with the price the page renders, automatic item updates overwrite the feed with one value while an agent reading the page quotes the other.`,
|
|
@@ -17332,6 +18002,7 @@ var ConditionalRequestSupportAudit = class extends Audit {
|
|
|
17332
18002
|
weight: weightForGrade("B", "scored"),
|
|
17333
18003
|
defaultPriority: "medium",
|
|
17334
18004
|
dossier: "docs/evidence/audits/machine-discovery/conditional-request-support.md",
|
|
18005
|
+
requires: ["origin-reachable"],
|
|
17335
18006
|
guidance: {
|
|
17336
18007
|
impact: 'A crawler that wants to know what changed re-reads your sitemap and your feed on a schedule. If those responses carry no `ETag` and no `Last-Modified`, it cannot ask "has this changed?" \u2014 it can only download the file again, every time, forever. The cost is yours as much as theirs: bandwidth you serve for no new information, and a crawl budget spent re-reading a list instead of fetching the pages on it. A validator that changes on every build is the same cost wearing a correct-looking header.',
|
|
17337
18008
|
fix: "Emit a strong `ETag` derived from the file\u2019s content, not from the build, and a `Last-Modified` that moves only when the content does. Answer `If-None-Match` and `If-Modified-Since` with 304 and an empty body. Keep `no-store` and `private` off public discovery surfaces \u2014 they tell a crawler not to keep the copy it just paid for.",
|
|
@@ -17485,6 +18156,7 @@ var FeedEntryIdentityAndCanonicalIntegrityAudit = class extends Audit {
|
|
|
17485
18156
|
weight: weightForGrade("B", "scored"),
|
|
17486
18157
|
defaultPriority: "medium",
|
|
17487
18158
|
dossier: "docs/evidence/audits/machine-discovery/feed-entry-identity-and-canonical-integrity.md",
|
|
18159
|
+
requires: ["origin-reachable"],
|
|
17488
18160
|
guidance: {
|
|
17489
18161
|
impact: 'A feed is how a consumer tracks what changed without re-crawling the site, and identity is what makes that possible: the id says "this is the same item you saw last time". An entry with no id, or with an id that repeats, forces the consumer to guess \u2014 usually by URL, which is exactly the thing that changes. A link that carries `utm_` parameters or redirects somewhere else creates a second address for one page, so the item the consumer stores is not the page the site considers canonical.',
|
|
17490
18162
|
fix: "Give every entry a stable id \u2014 an `atom:id` that never changes, or an RSS `<guid>` that is an absolute URL when `isPermaLink` is true \u2014 and never reuse one. Point item links at the canonical URL itself, with no tracking parameters and no redirect in between. Serve the feed as its registered media type, with no byte-order mark before the first element.",
|
|
@@ -17664,6 +18336,7 @@ var RootTextFileResolutionIntegrityAudit = class extends Audit {
|
|
|
17664
18336
|
weight: weightForGrade("B", "scored"),
|
|
17665
18337
|
defaultPriority: "medium",
|
|
17666
18338
|
dossier: "docs/evidence/audits/machine-discovery/root-text-file-resolution-integrity.md",
|
|
18339
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
17667
18340
|
guidance: {
|
|
17668
18341
|
impact: "IndexNow proves ownership by fetching `https://host/{key}.txt` and byte-comparing the body to the key, and six engines discard the submission when that comparison fails. The same property decides whether any other root `.txt` file means anything: if an origin answers 200 for a path that does not exist, then a 200 for `/llms.txt` is not evidence the file is there. A catch-all rewrite ahead of static file serving turns every one of those signals into noise, with no visible symptom on the site itself.",
|
|
17669
18342
|
fix: "Serve root-level `.txt` paths from static files and let a missing one answer 404. Order the static-file handler ahead of any SPA or catch-all rewrite, and make sure the rewrite does not cover `*.txt`. Serve `/robots.txt` as `text/plain`, not as `text/html` or `application/octet-stream`.",
|
|
@@ -17839,6 +18512,7 @@ var ThreeWayFreshnessLagAudit = class extends Audit {
|
|
|
17839
18512
|
weight: weightForGrade("B", "scored"),
|
|
17840
18513
|
defaultPriority: "medium",
|
|
17841
18514
|
dossier: "docs/evidence/audits/machine-discovery/three-way-freshness-lag.md",
|
|
18515
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
17842
18516
|
guidance: {
|
|
17843
18517
|
impact: "A pull-based crawler fetches the sitemap and the feed on a schedule and reads nothing else. When those two surfaces trail the site, everything published in between is discoverable only by link-following, which is the slow path the site published a sitemap to avoid. A feed whose `lastBuildDate` is older than its own newest item is worse than stale: consumers that poll conditionally on that timestamp skip the feed entirely, so the new items are never read at all.",
|
|
17844
18518
|
fix: "Regenerate the sitemap and the feed when content changes, not on a nightly cron that can fail silently. Stamp `<lastBuildDate>` (or the Atom feed-level `<updated>`) from the newest item at generation time. Order feed items newest-first, since many consumers read only the head. Remove sitemap entries whose URLs 404 or are noindex.",
|
|
@@ -17993,6 +18667,7 @@ var WebsubHubAdvertisementAudit = class extends Audit {
|
|
|
17993
18667
|
weight: 0,
|
|
17994
18668
|
defaultPriority: "low",
|
|
17995
18669
|
dossier: "docs/evidence/audits/machine-discovery/websub-hub-advertisement.md",
|
|
18670
|
+
requires: ["origin-reachable"],
|
|
17996
18671
|
guidance: {
|
|
17997
18672
|
impact: "A hub subscription is verified against the feed\u2019s own `rel=self`. When that link is missing, relative, or points at a different URL than the one the feed is served from, verification cannot complete, and the push path degrades to whatever polling cadence subscribers happen to use. The publisher sees a hub that looks configured and no error anywhere. The benefit side is unproven: WebSub is a W3C Recommendation, but no AI answer engine is documented as a subscriber, which is why this audit reports and does not score.",
|
|
17998
18673
|
fix: "Advertise the hub and the canonical topic URL in the feed\u2019s `Link:` response headers, which is where a subscriber looks first. Emit exactly one `rel=self` with an absolute URL identical to the address the feed is served from, and at least one `rel=hub` over HTTPS. If you run no hub, a hosted one (Google\u2019s pubsubhubbub, Superfeedr, websub.rocks) needs only the two link relations.",
|
|
@@ -18136,6 +18811,7 @@ var JsonLdPresentAudit = class extends Audit {
|
|
|
18136
18811
|
evidenceGrade: "A",
|
|
18137
18812
|
tier: "scored",
|
|
18138
18813
|
dossier: "docs/evidence/audits/structured-data/json-ld-present.md",
|
|
18814
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
18139
18815
|
defaultPriority: "critical",
|
|
18140
18816
|
guidance: {
|
|
18141
18817
|
impact: "Without any JSON-LD structured data, AI agents like ChatGPT and Perplexity treat your site as unstructured text with no machine-readable identity. Your brand, products, and services become invisible to AI-powered discovery, search, and recommendation systems.",
|
|
@@ -18199,6 +18875,7 @@ var SchemaValidationAudit = class extends Audit {
|
|
|
18199
18875
|
evidenceGrade: "A",
|
|
18200
18876
|
tier: "scored",
|
|
18201
18877
|
dossier: "docs/evidence/audits/structured-data/schema-validation.md",
|
|
18878
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
18202
18879
|
defaultPriority: "critical",
|
|
18203
18880
|
guidance: {
|
|
18204
18881
|
impact: "JSON-LD blocks missing @context or @type are silently ignored by every schema consumer, including Google, ChatGPT plugins, and RAG pipelines. Even if you have structured data on the page, invalid blocks provide zero value to AI agents.",
|
|
@@ -18325,6 +19002,7 @@ var OrganizationSchemaAudit = class extends Audit {
|
|
|
18325
19002
|
evidenceGrade: "A",
|
|
18326
19003
|
tier: "scored",
|
|
18327
19004
|
dossier: "docs/evidence/audits/structured-data/organization-schema.md",
|
|
19005
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
18328
19006
|
applicablePageTypes: ["homepage"],
|
|
18329
19007
|
defaultPriority: "high",
|
|
18330
19008
|
guidance: {
|
|
@@ -18428,6 +19106,7 @@ var BreadcrumbSchemaAudit = class extends Audit {
|
|
|
18428
19106
|
evidenceGrade: "A",
|
|
18429
19107
|
tier: "scored",
|
|
18430
19108
|
dossier: "docs/evidence/audits/structured-data/breadcrumb-schema.md",
|
|
19109
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
18431
19110
|
applicablePageTypes: ["category", "product", "content"],
|
|
18432
19111
|
defaultPriority: "medium",
|
|
18433
19112
|
guidance: {
|
|
@@ -18550,6 +19229,7 @@ var ArticleSchemaAudit = class extends Audit {
|
|
|
18550
19229
|
evidenceGrade: "A",
|
|
18551
19230
|
tier: "scored",
|
|
18552
19231
|
dossier: "docs/evidence/audits/structured-data/article-schema.md",
|
|
19232
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
18553
19233
|
applicablePageTypes: ["content"],
|
|
18554
19234
|
defaultPriority: "high",
|
|
18555
19235
|
guidance: {
|
|
@@ -18676,6 +19356,7 @@ var FaqPageSchemaAudit = class extends Audit {
|
|
|
18676
19356
|
evidenceGrade: "C",
|
|
18677
19357
|
tier: "informative",
|
|
18678
19358
|
dossier: "docs/evidence/audits/structured-data/faqpage-schema.md",
|
|
19359
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
18679
19360
|
defaultPriority: "medium",
|
|
18680
19361
|
guidance: {
|
|
18681
19362
|
impact: "AI answer engines like Perplexity and Google SGE give priority to FAQ-structured content for direct answers. Without FAQPage schema, your Q&A content is treated as unstructured text and is less likely to be surfaced as a featured answer in AI-generated responses.",
|
|
@@ -18829,6 +19510,7 @@ var ServiceSchemaAudit = class _ServiceSchemaAudit extends Audit {
|
|
|
18829
19510
|
evidenceGrade: "A",
|
|
18830
19511
|
tier: "scored",
|
|
18831
19512
|
dossier: "docs/evidence/audits/structured-data/service-schema.md",
|
|
19513
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
18832
19514
|
// Where a service business publishes its offerings. NOT ['product'] —
|
|
18833
19515
|
// that was inherited from the pre-split audit and inverted this check:
|
|
18834
19516
|
// it skipped every service site (no product page in the scan) and ran only
|
|
@@ -18972,6 +19654,7 @@ var SpeakableSchemaAudit = class _SpeakableSchemaAudit extends Audit {
|
|
|
18972
19654
|
evidenceGrade: "B",
|
|
18973
19655
|
tier: "scored",
|
|
18974
19656
|
dossier: "docs/evidence/audits/structured-data/speakable-schema.md",
|
|
19657
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
18975
19658
|
// News and article publishing is the whole documented scope of the
|
|
18976
19659
|
// feature, so a scan with no content page never runs this audit at all.
|
|
18977
19660
|
// The runtime guard below repeats the precondition for the pages that
|
|
@@ -19070,6 +19753,7 @@ var HowToSchemaAudit = class extends Audit {
|
|
|
19070
19753
|
evidenceGrade: "C",
|
|
19071
19754
|
tier: "informative",
|
|
19072
19755
|
dossier: "docs/evidence/audits/structured-data/howto-schema.md",
|
|
19756
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
19073
19757
|
applicablePageTypes: ["content"],
|
|
19074
19758
|
defaultPriority: "low",
|
|
19075
19759
|
guidance: {
|
|
@@ -19218,6 +19902,7 @@ var LocalBusinessSchemaAudit = class extends Audit {
|
|
|
19218
19902
|
evidenceGrade: "A",
|
|
19219
19903
|
tier: "scored",
|
|
19220
19904
|
dossier: "docs/evidence/audits/structured-data/local-business-schema.md",
|
|
19905
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
19221
19906
|
applicablePageTypes: ["homepage"],
|
|
19222
19907
|
defaultPriority: "medium",
|
|
19223
19908
|
guidance: {
|
|
@@ -19383,6 +20068,7 @@ var ReviewSchemaAudit = class extends Audit {
|
|
|
19383
20068
|
evidenceGrade: "A",
|
|
19384
20069
|
tier: "scored",
|
|
19385
20070
|
dossier: "docs/evidence/audits/structured-data/review-schema.md",
|
|
20071
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
19386
20072
|
applicablePageTypes: ["homepage", "product"],
|
|
19387
20073
|
defaultPriority: "medium",
|
|
19388
20074
|
guidance: {
|
|
@@ -19500,6 +20186,7 @@ var AuthorSchemaAudit = class extends Audit {
|
|
|
19500
20186
|
evidenceGrade: "C",
|
|
19501
20187
|
tier: "informative",
|
|
19502
20188
|
dossier: "docs/evidence/audits/structured-data/author-schema.md",
|
|
20189
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
19503
20190
|
applicablePageTypes: ["content"],
|
|
19504
20191
|
defaultPriority: "medium",
|
|
19505
20192
|
guidance: {
|
|
@@ -19622,6 +20309,7 @@ var ProductDetailsAudit = class extends Audit {
|
|
|
19622
20309
|
evidenceGrade: "A",
|
|
19623
20310
|
tier: "scored",
|
|
19624
20311
|
dossier: "docs/evidence/audits/structured-data/advanced-product-details.md",
|
|
20312
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
19625
20313
|
applicablePageTypes: ["product"],
|
|
19626
20314
|
defaultPriority: "medium",
|
|
19627
20315
|
guidance: {
|
|
@@ -19775,6 +20463,7 @@ var ClaimreviewAdvisoryAudit = class extends Audit {
|
|
|
19775
20463
|
evidenceGrade: "A",
|
|
19776
20464
|
tier: "informative",
|
|
19777
20465
|
dossier: "docs/evidence/audits/structured-data/claimreview-advisory.md",
|
|
20466
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
19778
20467
|
defaultPriority: "low",
|
|
19779
20468
|
guidance: {
|
|
19780
20469
|
impact: "Google's fact check documentation states plainly: 'We're phasing out support for ClaimReview markup in Google Search', with no deprecation date, and notes only one ClaimReview element per page qualifies for rich results. A check that scored ClaimReview coverage as an AI-readiness win would therefore push publishers to invest in a channel its largest documented consumer is actively withdrawing from. FALSIFIABLE and grade A on the evidence, but it measures the state of an external product, not the quality of the site \u2014 which is exactly why it must not contribute to a score.",
|
|
@@ -19930,6 +20619,7 @@ var MetaDescriptionAudit = class extends Audit {
|
|
|
19930
20619
|
evidenceGrade: "B",
|
|
19931
20620
|
tier: "scored",
|
|
19932
20621
|
dossier: "docs/evidence/audits/answer-readiness/meta-description.md",
|
|
20622
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
19933
20623
|
defaultPriority: "high",
|
|
19934
20624
|
guidance: {
|
|
19935
20625
|
impact: "Google sometimes uses the meta description as the search snippet when it describes the page more accurately than the body text, and AI Overviews and AI Mode inherit that snippet pipeline. A missing, keyword-stuffed or off-topic description means the summary shown alongside your page is written by someone else.",
|
|
@@ -20024,6 +20714,7 @@ var MetaAuthorAudit = class extends Audit {
|
|
|
20024
20714
|
evidenceGrade: "C",
|
|
20025
20715
|
tier: "informative",
|
|
20026
20716
|
dossier: "docs/evidence/audits/answer-readiness/meta-author.md",
|
|
20717
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
20027
20718
|
applicablePageTypes: ["content"],
|
|
20028
20719
|
defaultPriority: "medium",
|
|
20029
20720
|
guidance: {
|
|
@@ -20072,6 +20763,7 @@ var UniqueMetaAudit = class extends Audit {
|
|
|
20072
20763
|
evidenceGrade: "C",
|
|
20073
20764
|
tier: "informative",
|
|
20074
20765
|
dossier: "docs/evidence/audits/answer-readiness/unique-meta.md",
|
|
20766
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
20075
20767
|
defaultPriority: "high",
|
|
20076
20768
|
guidance: {
|
|
20077
20769
|
impact: "AI crawlers use title and description pairs to distinguish between pages. Duplicate meta across pages causes agents to merge or skip content, meaning some pages become invisible in AI-generated answers.",
|
|
@@ -20082,6 +20774,13 @@ var UniqueMetaAudit = class extends Audit {
|
|
|
20082
20774
|
}
|
|
20083
20775
|
};
|
|
20084
20776
|
audit(ctx) {
|
|
20777
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
20778
|
+
return this.notApplicable(
|
|
20779
|
+
"No page here can be attributed to this site, so its metadata was not judged.",
|
|
20780
|
+
"Each page has a unique title + description combination",
|
|
20781
|
+
unreadSiteReason(ctx.evidence)
|
|
20782
|
+
);
|
|
20783
|
+
}
|
|
20085
20784
|
const canonicalGroups = /* @__PURE__ */ new Map();
|
|
20086
20785
|
for (const page of ctx.pages) {
|
|
20087
20786
|
let canon = (page.meta?.["canonical"] || page.url).trim();
|
|
@@ -20098,7 +20797,7 @@ var UniqueMetaAudit = class extends Audit {
|
|
|
20098
20797
|
}
|
|
20099
20798
|
const uniquePages = Array.from(canonicalGroups.values());
|
|
20100
20799
|
if (uniquePages.length < 2) {
|
|
20101
|
-
return this.
|
|
20800
|
+
return this.notApplicable(
|
|
20102
20801
|
"Only one distinct canonical page scanned; uniqueness check not applicable.",
|
|
20103
20802
|
"Each page has a unique title + description combination",
|
|
20104
20803
|
"1 distinct page scanned"
|
|
@@ -20185,6 +20884,7 @@ var CoreOpenGraphAudit = class extends Audit {
|
|
|
20185
20884
|
evidenceGrade: "A",
|
|
20186
20885
|
tier: "scored",
|
|
20187
20886
|
dossier: "docs/evidence/audits/answer-readiness/core-open-graph.md",
|
|
20887
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
20188
20888
|
defaultPriority: "high",
|
|
20189
20889
|
guidance: {
|
|
20190
20890
|
impact: "Link-preview crawlers use Open Graph tags to build the card shown wherever your page is shared, and Google uses og:title and og:site_name as inputs to the title link and site name on a result \u2014 the same labels that carry into AI Overviews source cards. Without them the crawler falls back to guessing.",
|
|
@@ -20268,6 +20968,7 @@ var OgTypeAudit = class extends Audit {
|
|
|
20268
20968
|
evidenceGrade: "B",
|
|
20269
20969
|
tier: "scored",
|
|
20270
20970
|
dossier: "docs/evidence/audits/answer-readiness/og-type.md",
|
|
20971
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
20271
20972
|
defaultPriority: "medium",
|
|
20272
20973
|
guidance: {
|
|
20273
20974
|
impact: "AI agents use og:type to classify page content for type-specific handling. Without it, agents treat every page as generic content, missing opportunities for article freshness scoring or product-specific handling.",
|
|
@@ -20331,6 +21032,7 @@ var OgImageAltAudit = class extends Audit {
|
|
|
20331
21032
|
evidenceGrade: "C",
|
|
20332
21033
|
tier: "informative",
|
|
20333
21034
|
dossier: "docs/evidence/audits/answer-readiness/og-image-alt.md",
|
|
21035
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
20334
21036
|
defaultPriority: "medium",
|
|
20335
21037
|
guidance: {
|
|
20336
21038
|
impact: "AI agents cannot process images directly and rely on og:image:alt text to understand your page's visual content. Without alt text, the OG image is invisible to text-based AI systems generating answers about your page.",
|
|
@@ -20402,6 +21104,7 @@ var FaqSectionsAudit = class _FaqSectionsAudit extends Audit {
|
|
|
20402
21104
|
evidenceGrade: "C",
|
|
20403
21105
|
tier: "informative",
|
|
20404
21106
|
dossier: "docs/evidence/audits/answer-readiness/faq-sections.md",
|
|
21107
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
20405
21108
|
defaultPriority: "medium",
|
|
20406
21109
|
guidance: {
|
|
20407
21110
|
impact: 'FAQ sections with clear question headings are the highest-priority extraction target for AI-generated answers and "People Also Ask" results. Without them, your content misses the most direct path to appearing in AI answer snippets.',
|
|
@@ -20496,6 +21199,7 @@ var QuestionHeadingsAudit = class _QuestionHeadingsAudit extends Audit {
|
|
|
20496
21199
|
evidenceGrade: "C",
|
|
20497
21200
|
tier: "informative",
|
|
20498
21201
|
dossier: "docs/evidence/audits/answer-readiness/question-headings.md",
|
|
21202
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
20499
21203
|
defaultPriority: "medium",
|
|
20500
21204
|
guidance: {
|
|
20501
21205
|
impact: "AI answer engines directly match user questions to heading text. Question-formatted headings are the primary signal for identifying which section answers a specific query. Without them, agents must guess which section is relevant, reducing your content's match rate.",
|
|
@@ -20664,6 +21368,7 @@ var DatesOnContentAudit = class extends Audit {
|
|
|
20664
21368
|
evidenceGrade: "A",
|
|
20665
21369
|
tier: "scored",
|
|
20666
21370
|
dossier: "docs/evidence/audits/answer-readiness/dates-on-content.md",
|
|
21371
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
20667
21372
|
applicablePageTypes: ["content"],
|
|
20668
21373
|
defaultPriority: "medium",
|
|
20669
21374
|
guidance: {
|
|
@@ -20755,6 +21460,7 @@ var FirstParagraphAnswersAudit = class extends Audit {
|
|
|
20755
21460
|
evidenceGrade: "C",
|
|
20756
21461
|
tier: "informative",
|
|
20757
21462
|
dossier: "docs/evidence/audits/answer-readiness/first-paragraph-answers.md",
|
|
21463
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
20758
21464
|
applicablePageTypes: ["content"],
|
|
20759
21465
|
defaultPriority: "high",
|
|
20760
21466
|
guidance: {
|
|
@@ -20920,6 +21626,7 @@ var DirectDefinitionsAudit = class extends Audit {
|
|
|
20920
21626
|
evidenceGrade: "C",
|
|
20921
21627
|
tier: "informative",
|
|
20922
21628
|
dossier: "docs/evidence/audits/answer-readiness/direct-definitions.md",
|
|
21629
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
20923
21630
|
applicablePageTypes: ["content"],
|
|
20924
21631
|
// Never a defect, so never above the actionable items.
|
|
20925
21632
|
defaultPriority: "low",
|
|
@@ -20978,6 +21685,7 @@ var ComparisonTablesAudit = class _ComparisonTablesAudit extends Audit {
|
|
|
20978
21685
|
evidenceGrade: "C",
|
|
20979
21686
|
tier: "informative",
|
|
20980
21687
|
dossier: "docs/evidence/audits/answer-readiness/comparison-tables.md",
|
|
21688
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
20981
21689
|
applicablePageTypes: ["category", "product", "content"],
|
|
20982
21690
|
defaultPriority: "low",
|
|
20983
21691
|
guidance: {
|
|
@@ -21053,6 +21761,7 @@ var SpecificNumbersAudit = class _SpecificNumbersAudit extends Audit {
|
|
|
21053
21761
|
evidenceGrade: "B",
|
|
21054
21762
|
tier: "scored",
|
|
21055
21763
|
dossier: "docs/evidence/audits/answer-readiness/specific-numbers.md",
|
|
21764
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
21056
21765
|
defaultPriority: "medium",
|
|
21057
21766
|
guidance: {
|
|
21058
21767
|
impact: "AI answer engines strongly prefer content with concrete data points over vague claims. Pages with specific numbers, percentages, and metrics are ranked higher for data-driven queries because agents can cite exact figures in generated answers.",
|
|
@@ -21111,17 +21820,6 @@ var SpecificNumbersAudit = class _SpecificNumbersAudit extends Audit {
|
|
|
21111
21820
|
};
|
|
21112
21821
|
|
|
21113
21822
|
// src/audits/answer-readiness/content-without-clickthrough.ts
|
|
21114
|
-
function contentWordCount($) {
|
|
21115
|
-
const main = $("main").first();
|
|
21116
|
-
const extract = (sel) => {
|
|
21117
|
-
const clone = sel.clone();
|
|
21118
|
-
clone.find("script, style, noscript, template").remove();
|
|
21119
|
-
return clone.text().replace(/\s+/g, " ").trim();
|
|
21120
|
-
};
|
|
21121
|
-
let text3 = main.length ? extract(main) : "";
|
|
21122
|
-
if (!text3) text3 = extract($("body"));
|
|
21123
|
-
return text3.split(/\s+/).filter(Boolean).length;
|
|
21124
|
-
}
|
|
21125
21823
|
var TEASER_PATTERNS = [
|
|
21126
21824
|
/click\s+(here\s+)?to\s+read\s+more/i,
|
|
21127
21825
|
/contact\s+us\s+to\s+learn/i,
|
|
@@ -21144,6 +21842,7 @@ var ContentWithoutClickthroughAudit = class _ContentWithoutClickthroughAudit ext
|
|
|
21144
21842
|
evidenceGrade: "B",
|
|
21145
21843
|
tier: "scored",
|
|
21146
21844
|
dossier: "docs/evidence/audits/answer-readiness/content-without-clickthrough.md",
|
|
21845
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
21147
21846
|
defaultPriority: "high",
|
|
21148
21847
|
guidance: {
|
|
21149
21848
|
impact: 'AI answer engines skip pages dominated by teaser content ("click to read more", "sign up to access"). These pages provide no extractable answers, so agents will never surface your content in AI-generated responses, costing you visibility in AI search.',
|
|
@@ -21154,6 +21853,13 @@ var ContentWithoutClickthroughAudit = class _ContentWithoutClickthroughAudit ext
|
|
|
21154
21853
|
}
|
|
21155
21854
|
};
|
|
21156
21855
|
audit(ctx) {
|
|
21856
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
21857
|
+
return this.notApplicable(
|
|
21858
|
+
"No page here can be attributed to this site, so its teasers were not judged.",
|
|
21859
|
+
'No "click to read more" or "contact us to learn" teasers dominating the page',
|
|
21860
|
+
unreadSiteReason(ctx.evidence)
|
|
21861
|
+
);
|
|
21862
|
+
}
|
|
21157
21863
|
const page = ctx.pages[0];
|
|
21158
21864
|
if (!page) {
|
|
21159
21865
|
return this.fail(
|
|
@@ -21195,7 +21901,7 @@ var ContentWithoutClickthroughAudit = class _ContentWithoutClickthroughAudit ext
|
|
|
21195
21901
|
return !head2.startsWith("<?xml");
|
|
21196
21902
|
});
|
|
21197
21903
|
if (checkPage) {
|
|
21198
|
-
const wordCount2 =
|
|
21904
|
+
const wordCount2 = getWordCount(checkPage.$);
|
|
21199
21905
|
if (wordCount2 < 50) {
|
|
21200
21906
|
return this.warn(
|
|
21201
21907
|
"Insufficient content to evaluate.",
|
|
@@ -21210,6 +21916,13 @@ var ContentWithoutClickthroughAudit = class _ContentWithoutClickthroughAudit ext
|
|
|
21210
21916
|
);
|
|
21211
21917
|
}
|
|
21212
21918
|
}
|
|
21919
|
+
if (!scanReadPageText(ctx.evidence)) {
|
|
21920
|
+
return this.notApplicable(
|
|
21921
|
+
"The scanned page served no readable text, so there was no content to judge for teasers.",
|
|
21922
|
+
'No "click to read more" or "contact us to learn" teasers dominating the page',
|
|
21923
|
+
unreadPageTextReason(ctx.evidence)
|
|
21924
|
+
);
|
|
21925
|
+
}
|
|
21213
21926
|
return this.pass(
|
|
21214
21927
|
"No excessive click-through teasers found.",
|
|
21215
21928
|
'No "click to read more" or "contact us to learn" teasers dominating the page',
|
|
@@ -21286,6 +21999,7 @@ var NamedAuthorAudit = class extends Audit {
|
|
|
21286
21999
|
evidenceGrade: "C",
|
|
21287
22000
|
tier: "informative",
|
|
21288
22001
|
dossier: "docs/evidence/audits/answer-readiness/named-author.md",
|
|
22002
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
21289
22003
|
applicablePageTypes: ["content"],
|
|
21290
22004
|
defaultPriority: "high",
|
|
21291
22005
|
guidance: {
|
|
@@ -21412,6 +22126,7 @@ var AuthorSameAsAudit = class extends Audit {
|
|
|
21412
22126
|
evidenceGrade: "C",
|
|
21413
22127
|
tier: "informative",
|
|
21414
22128
|
dossier: "docs/evidence/audits/answer-readiness/author-same-as.md",
|
|
22129
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
21415
22130
|
applicablePageTypes: ["content"],
|
|
21416
22131
|
defaultPriority: "medium",
|
|
21417
22132
|
guidance: {
|
|
@@ -21531,6 +22246,7 @@ var AuthorPageAudit = class extends Audit {
|
|
|
21531
22246
|
evidenceGrade: "C",
|
|
21532
22247
|
tier: "informative",
|
|
21533
22248
|
dossier: "docs/evidence/audits/answer-readiness/author-page.md",
|
|
22249
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
21534
22250
|
applicablePageTypes: ["content"],
|
|
21535
22251
|
defaultPriority: "medium",
|
|
21536
22252
|
guidance: {
|
|
@@ -21665,6 +22381,7 @@ var AboutCredentialsAudit = class extends Audit {
|
|
|
21665
22381
|
evidenceGrade: "C",
|
|
21666
22382
|
tier: "informative",
|
|
21667
22383
|
dossier: "docs/evidence/audits/answer-readiness/about-credentials.md",
|
|
22384
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
21668
22385
|
defaultPriority: "medium",
|
|
21669
22386
|
guidance: {
|
|
21670
22387
|
impact: "AI engines crawl your about page to build an organizational authority profile. Without credential-rich content (team bios, expertise areas, certifications), agents cannot assess your organization's authority, reducing your content's trust score in AI-generated recommendations.",
|
|
@@ -21774,6 +22491,7 @@ var ExternalCitationsAudit = class extends Audit {
|
|
|
21774
22491
|
evidenceGrade: "B",
|
|
21775
22492
|
tier: "scored",
|
|
21776
22493
|
dossier: "docs/evidence/audits/answer-readiness/external-citations.md",
|
|
22494
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
21777
22495
|
applicablePageTypes: ["content"],
|
|
21778
22496
|
defaultPriority: "medium",
|
|
21779
22497
|
guidance: {
|
|
@@ -21888,6 +22606,7 @@ var BrandNameAudit = class extends Audit {
|
|
|
21888
22606
|
evidenceGrade: "C",
|
|
21889
22607
|
tier: "informative",
|
|
21890
22608
|
dossier: "docs/evidence/audits/answer-readiness/brand-name.md",
|
|
22609
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
21891
22610
|
defaultPriority: "medium",
|
|
21892
22611
|
guidance: {
|
|
21893
22612
|
impact: "AI engines build entity graphs by matching Organization schema names to in-content mentions. If your brand name only appears in schema but not body text, agents cannot associate your content with your entity, weakening brand recognition in AI responses.",
|
|
@@ -21982,7 +22701,7 @@ function statesZeroReviews(record3) {
|
|
|
21982
22701
|
}
|
|
21983
22702
|
return false;
|
|
21984
22703
|
}
|
|
21985
|
-
function
|
|
22704
|
+
function readableText2(page) {
|
|
21986
22705
|
const body = page.$("body").clone();
|
|
21987
22706
|
body.find("script, style, noscript, template").remove();
|
|
21988
22707
|
return body.text().replace(/\s+/g, " ").trim();
|
|
@@ -22083,6 +22802,7 @@ var ReviewSignalsAudit = class extends Audit {
|
|
|
22083
22802
|
evidenceGrade: "B",
|
|
22084
22803
|
tier: "scored",
|
|
22085
22804
|
dossier: "docs/evidence/audits/answer-readiness/review-signals.md",
|
|
22805
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
22086
22806
|
applicablePageTypes: ["homepage", "product"],
|
|
22087
22807
|
defaultPriority: "medium",
|
|
22088
22808
|
guidance: {
|
|
@@ -22130,7 +22850,7 @@ var ReviewSignalsAudit = class extends Audit {
|
|
|
22130
22850
|
).toArray().filter((el) => p.$(el).text().trim() !== "" || p.$(el).children().length > 0);
|
|
22131
22851
|
if (widget.length > 0) {
|
|
22132
22852
|
noteWeak("review widget markup", p.url);
|
|
22133
|
-
} else if (/\b\d[\d,]*\s+reviews?\b/i.test(
|
|
22853
|
+
} else if (/\b\d[\d,]*\s+reviews?\b/i.test(readableText2(p))) {
|
|
22134
22854
|
noteWeak('"N reviews" text', p.url);
|
|
22135
22855
|
}
|
|
22136
22856
|
}
|
|
@@ -22190,7 +22910,7 @@ function isNonEnglish(page) {
|
|
|
22190
22910
|
const lang = (page.$("html").attr("lang") ?? "").trim().toLowerCase();
|
|
22191
22911
|
return lang !== "" && !lang.startsWith("en");
|
|
22192
22912
|
}
|
|
22193
|
-
function
|
|
22913
|
+
function readableText3(page) {
|
|
22194
22914
|
const body = page.$("body").clone();
|
|
22195
22915
|
body.find("script, style, noscript, template").remove();
|
|
22196
22916
|
return body.text().replace(/\s+/g, " ").trim();
|
|
@@ -22242,6 +22962,7 @@ var TrustSignalsAudit = class _TrustSignalsAudit extends Audit {
|
|
|
22242
22962
|
evidenceGrade: "B",
|
|
22243
22963
|
tier: "scored",
|
|
22244
22964
|
dossier: "docs/evidence/audits/answer-readiness/trust-signals.md",
|
|
22965
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
22245
22966
|
applicablePageTypes: ["homepage"],
|
|
22246
22967
|
defaultPriority: "low",
|
|
22247
22968
|
guidance: {
|
|
@@ -22269,7 +22990,7 @@ var TrustSignalsAudit = class _TrustSignalsAudit extends Audit {
|
|
|
22269
22990
|
"Non-English homepage \u2014 detector not applicable"
|
|
22270
22991
|
);
|
|
22271
22992
|
}
|
|
22272
|
-
const text3 =
|
|
22993
|
+
const text3 = readableText3(page);
|
|
22273
22994
|
const satisfied = [];
|
|
22274
22995
|
const missing = [];
|
|
22275
22996
|
let counted = 0;
|
|
@@ -22379,6 +23100,7 @@ var PublicationDateAudit = class extends Audit {
|
|
|
22379
23100
|
evidenceGrade: "B",
|
|
22380
23101
|
tier: "scored",
|
|
22381
23102
|
dossier: "docs/evidence/audits/answer-readiness/publication-date.md",
|
|
23103
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
22382
23104
|
applicablePageTypes: ["content"],
|
|
22383
23105
|
defaultPriority: "medium",
|
|
22384
23106
|
guidance: {
|
|
@@ -22475,6 +23197,7 @@ var LastModifiedSchemaAudit = class extends Audit {
|
|
|
22475
23197
|
evidenceGrade: "B",
|
|
22476
23198
|
tier: "scored",
|
|
22477
23199
|
dossier: "docs/evidence/audits/answer-readiness/last-modified-schema.md",
|
|
23200
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
22478
23201
|
applicablePageTypes: ["content"],
|
|
22479
23202
|
defaultPriority: "medium",
|
|
22480
23203
|
guidance: {
|
|
@@ -22561,6 +23284,7 @@ var UniqueDataAudit = class extends Audit {
|
|
|
22561
23284
|
evidenceGrade: "B",
|
|
22562
23285
|
tier: "scored",
|
|
22563
23286
|
dossier: "docs/evidence/audits/answer-readiness/unique-data.md",
|
|
23287
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
22564
23288
|
defaultPriority: "medium",
|
|
22565
23289
|
guidance: {
|
|
22566
23290
|
impact: "AI generative engines prioritize content with unique, citable data points because agents can quote exact figures in generated answers. Content without specific numbers reads as opinion rather than evidence, reducing its chances of being cited.",
|
|
@@ -22658,6 +23382,8 @@ var DescriptiveUrlsAudit = class extends Audit {
|
|
|
22658
23382
|
evidenceGrade: "C",
|
|
22659
23383
|
tier: "informative",
|
|
22660
23384
|
dossier: "docs/evidence/audits/answer-readiness/descriptive-urls.md",
|
|
23385
|
+
// Gate exemption: a URL is readable whether or not the page behind it rendered text.
|
|
23386
|
+
requires: ["origin-reachable", "unblocked-fetches"],
|
|
22661
23387
|
defaultPriority: "high",
|
|
22662
23388
|
guidance: {
|
|
22663
23389
|
impact: "AI engines use URL text as a pre-fetch topic signal and display URLs in generated citations. Non-descriptive slugs with UUIDs or numeric IDs provide no topical context, reducing your content's relevance score before the page is even crawled.",
|
|
@@ -22668,6 +23394,13 @@ var DescriptiveUrlsAudit = class extends Audit {
|
|
|
22668
23394
|
}
|
|
22669
23395
|
};
|
|
22670
23396
|
audit(ctx) {
|
|
23397
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
23398
|
+
return this.notApplicable(
|
|
23399
|
+
"No page here can be attributed to this site, so its URLs were not judged.",
|
|
23400
|
+
"Page URLs use readable slugs (no UUIDs, no /post-123/, no encoded params)",
|
|
23401
|
+
unreadSiteReason(ctx.evidence)
|
|
23402
|
+
);
|
|
23403
|
+
}
|
|
22671
23404
|
const page = ctx.pages[0];
|
|
22672
23405
|
if (!page) {
|
|
22673
23406
|
return this.fail(
|
|
@@ -22913,6 +23646,7 @@ var SnippetGateCoverageAudit = class _SnippetGateCoverageAudit extends Audit {
|
|
|
22913
23646
|
evidenceGrade: "A",
|
|
22914
23647
|
tier: "scored",
|
|
22915
23648
|
dossier: "docs/evidence/audits/answer-readiness/snippet-gate-coverage.md",
|
|
23649
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
22916
23650
|
defaultPriority: "high",
|
|
22917
23651
|
guidance: {
|
|
22918
23652
|
impact: "Google states the eligibility gate directly: to appear as a supporting link a page 'must be indexed and eligible to be shown in Google Search with a snippet', and names nosnippet, data-nosnippet, max-snippet and noindex as the controls that limit what AI Overviews and AI Mode can show. This makes the causal chain fully documented rather than inferred: a max-snippet value shorter than the answer sentence truncates the answer below usefulness, and data-nosnippet wrapping the answer removes it from AI surfaces entirely while leaving it visible to humans \u2014 an invisible failure that page-level SEO reports do not surface because the directive itself is technically 'valid'.",
|
|
@@ -22931,6 +23665,13 @@ var SnippetGateCoverageAudit = class _SnippetGateCoverageAudit extends Audit {
|
|
|
22931
23665
|
};
|
|
22932
23666
|
}
|
|
22933
23667
|
audit(ctx) {
|
|
23668
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
23669
|
+
return this.notApplicable(
|
|
23670
|
+
"No page here can be attributed to this site, so snippet permissions were not resolved.",
|
|
23671
|
+
EXPECTED22,
|
|
23672
|
+
unreadSiteReason(ctx.evidence)
|
|
23673
|
+
);
|
|
23674
|
+
}
|
|
22934
23675
|
const page = ctx.pages[0];
|
|
22935
23676
|
if (!page) {
|
|
22936
23677
|
return this.notApplicable(
|
|
@@ -23125,6 +23866,7 @@ var TextFragmentAddressabilityAudit = class _TextFragmentAddressabilityAudit ext
|
|
|
23125
23866
|
evidenceGrade: "A",
|
|
23126
23867
|
tier: "scored",
|
|
23127
23868
|
dossier: "docs/evidence/audits/answer-readiness/text-fragment-addressability.md",
|
|
23869
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
23128
23870
|
defaultPriority: "medium",
|
|
23129
23871
|
guidance: {
|
|
23130
23872
|
impact: "Google Search auto-generates text-fragment URLs to land users on the exact featured-snippet text, and the spec requires each of prefix/start/end/suffix to match within a single block-level element. When an answer sentence is fragmented across block boundaries, or the header opt-out is set, the fragment silently fails and the link degrades to page-top. Falsifiable and directly testable: take the citing surface\u2019s own generated URL, load it, and observe whether the browser scrolls and highlights. Two failure classes are binary and deterministic \u2014 the opt-out header, and a start string that straddles two blocks.",
|
|
@@ -23272,6 +24014,7 @@ var ChunkBoundaryReferentIntegrityAudit = class extends Audit {
|
|
|
23272
24014
|
weight: weightForGrade("B", "scored"),
|
|
23273
24015
|
defaultPriority: "high",
|
|
23274
24016
|
dossier: "docs/evidence/audits/answer-readiness/chunk-boundary-referent-integrity.md",
|
|
24017
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
23275
24018
|
guidance: {
|
|
23276
24019
|
impact: 'An answer engine retrieves a passage, not a page. A section that opens "This means you should..." and never names its subject is unusable on arrival: the model either drops it or attributes it to whatever else is in the window. The fix is per-sentence and cheap, and it is invisible to a reader of the whole page \u2014 which is why it survives editing.',
|
|
23277
24020
|
fix: 'Open each section with its subject rather than a pronoun, name the product or topic once in every section over about forty words, and replace "as described above" with the name of the thing described.',
|
|
@@ -23403,6 +24146,13 @@ function chainOf($, el) {
|
|
|
23403
24146
|
const parents = $(el).parents().toArray().filter((parent) => !["html", "body"].includes(parent.tagName)).reverse().map(describe3);
|
|
23404
24147
|
return [...parents, describe3(el)].join(" > ");
|
|
23405
24148
|
}
|
|
24149
|
+
function lastElementContaining($, text3) {
|
|
24150
|
+
const all = $("*").toArray();
|
|
24151
|
+
for (let i = all.length - 1; i >= 0; i--) {
|
|
24152
|
+
if ($(all[i]).text().includes(text3)) return all[i];
|
|
24153
|
+
}
|
|
24154
|
+
return void 0;
|
|
24155
|
+
}
|
|
23406
24156
|
function needleOf(text3) {
|
|
23407
24157
|
return normalizeText(text3).split(" ").slice(0, SPAN_WORDS).join(" ");
|
|
23408
24158
|
}
|
|
@@ -23435,8 +24185,8 @@ function keySpans(page) {
|
|
|
23435
24185
|
if (typeof value === "string") {
|
|
23436
24186
|
const needle = needleOf(value);
|
|
23437
24187
|
if (needle.split(" ").length >= 3 && bodyText.includes(needle)) {
|
|
23438
|
-
const host =
|
|
23439
|
-
push("json-ld", host
|
|
24188
|
+
const host = lastElementContaining($, value.slice(0, 40));
|
|
24189
|
+
push("json-ld", host ?? $("body")[0], value);
|
|
23440
24190
|
}
|
|
23441
24191
|
return;
|
|
23442
24192
|
}
|
|
@@ -23470,6 +24220,7 @@ var ExtractorSurvivalRecallAudit = class extends Audit {
|
|
|
23470
24220
|
weight: weightForGrade("B", "scored"),
|
|
23471
24221
|
defaultPriority: "high",
|
|
23472
24222
|
dossier: "docs/evidence/audits/answer-readiness/extractor-survival-recall.md",
|
|
24223
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
23473
24224
|
guidance: {
|
|
23474
24225
|
impact: 'An answer engine never sees the page; it sees whatever its extractor kept. A specification table inside `<aside class="related-specs">` is invisible to every pipeline that strips asides, and the answer about that product gets written without it. The loss is silent: the page looks complete to its author and to every human reviewer.',
|
|
23475
24226
|
fix: 'Put facts inside the main content container, not in an aside, a footer, or a block whose class says "related" or "promo". Where a table must sit outside the article, repeat its facts in the prose so at least one copy survives.',
|
|
@@ -23479,6 +24230,13 @@ var ExtractorSurvivalRecallAudit = class extends Audit {
|
|
|
23479
24230
|
}
|
|
23480
24231
|
};
|
|
23481
24232
|
audit(ctx) {
|
|
24233
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
24234
|
+
return this.notApplicable(
|
|
24235
|
+
"No page here can be attributed to this site, so no key span was measured.",
|
|
24236
|
+
"A page from this site whose key spans can be measured",
|
|
24237
|
+
unreadSiteReason(ctx.evidence)
|
|
24238
|
+
);
|
|
24239
|
+
}
|
|
23482
24240
|
const page = ctx.pages[0];
|
|
23483
24241
|
if (!page) {
|
|
23484
24242
|
return this.notApplicable(
|
|
@@ -23602,6 +24360,7 @@ var SectionSplitRiskProfileAudit = class extends Audit {
|
|
|
23602
24360
|
weight: weightForGrade("B", "scored"),
|
|
23603
24361
|
defaultPriority: "medium",
|
|
23604
24362
|
dossier: "docs/evidence/audits/answer-readiness/section-split-risk-profile.md",
|
|
24363
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
23605
24364
|
guidance: {
|
|
23606
24365
|
impact: "Retrieval pipelines cut pages into fixed windows. A section longer than the window becomes one chunk carrying the heading and one or more tail chunks carrying none \u2014 and a tail chunk is text with no subject, which retrieves badly and cites worse. A page with no headings at all is cut at arbitrary offsets throughout.",
|
|
23607
24366
|
fix: "Add an `h2` or `h3` roughly every 400 tokens of prose, and split a specification table that runs past the window into per-topic tables so the header row stays with its rows.",
|
|
@@ -23791,6 +24550,7 @@ var SiteWidePassageUniquenessRatioAudit = class extends Audit {
|
|
|
23791
24550
|
weight: weightForGrade("B", "scored"),
|
|
23792
24551
|
defaultPriority: "medium",
|
|
23793
24552
|
dossier: "docs/evidence/audits/answer-readiness/site-wide-passage-uniqueness-ratio.md",
|
|
24553
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
23794
24554
|
guidance: {
|
|
23795
24555
|
impact: "A search engine clusters duplicate and near-duplicate URLs and elects one canonical; the losers have their signals folded into the winner. A cluster of near-duplicate pages that each name themselves canonical therefore competes against itself, and at most one member stays citable however good the others are. Separately, a page whose sentences are mostly site-wide template produces chunks whose embeddings encode the template rather than the page, so every page built from that template lands in the same place in vector space and none is a distinctive match for any question.",
|
|
23796
24556
|
fix: 'Merge near-duplicate pages into one, or point the weaker members at the strongest with rel="canonical" so the election has an answer. For pages that stay, raise the share of text that is theirs alone: cut the repeated intro, the repeated legal paragraph and the repeated call to action, and let each page carry the sentences only it can carry.',
|
|
@@ -24024,6 +24784,7 @@ var TableMarkdownRoundTripLossAudit = class extends Audit {
|
|
|
24024
24784
|
weight: weightForGrade("B", "scored"),
|
|
24025
24785
|
defaultPriority: "medium",
|
|
24026
24786
|
dossier: "docs/evidence/audits/answer-readiness/table-markdown-round-trip-loss.md",
|
|
24787
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
24027
24788
|
guidance: {
|
|
24028
24789
|
impact: "A model does not read your table markup. Something converts it to markdown first, and GFM markdown has no merged cells, no second header row and no lists inside a cell. A header spanning two columns arrives heading one of them; the other column of numbers arrives with no header at all. The model still answers the question \u2014 with a number read from the wrong column, stated as confidently as a right one.",
|
|
24029
24790
|
fix: "Flatten spanned headers into one header row of plain `th` cells, repeating the text where a span used to cover two columns. Put the unit or currency in the header cell rather than in the caption. Take paragraphs and lists out of cells. Where a table is genuinely two tables, publish it as two.",
|
|
@@ -24291,6 +25052,7 @@ var OpenApiExistsAudit = class _OpenApiExistsAudit extends Audit {
|
|
|
24291
25052
|
evidenceGrade: "B",
|
|
24292
25053
|
tier: "informative",
|
|
24293
25054
|
dossier: "docs/evidence/audits/agent-interfaces/openapi-exists.md",
|
|
25055
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
24294
25056
|
defaultPriority: "medium",
|
|
24295
25057
|
guidance: {
|
|
24296
25058
|
impact: "An agent that cannot find your API description cannot call it. Note that every documented consumer today (GPT Actions, Microsoft 365 Copilot API plugins) receives the document from a developer rather than fetching it from your site, so this check is informative and unscored.",
|
|
@@ -24431,6 +25193,7 @@ var OpenApiEndpointsAudit = class _OpenApiEndpointsAudit extends Audit {
|
|
|
24431
25193
|
evidenceGrade: "B",
|
|
24432
25194
|
tier: "scored",
|
|
24433
25195
|
dossier: "docs/evidence/audits/agent-interfaces/openapi-endpoints.md",
|
|
25196
|
+
requires: ["origin-reachable"],
|
|
24434
25197
|
defaultPriority: "high",
|
|
24435
25198
|
guidance: {
|
|
24436
25199
|
impact: "An OpenAPI spec without endpoints is unusable -- AI agents see a spec file but have zero actions they can perform. Your site remains a passive document that agents cannot interact with programmatically.",
|
|
@@ -24558,6 +25321,7 @@ var OpenApiOperationIdsAudit = class _OpenApiOperationIdsAudit extends Audit {
|
|
|
24558
25321
|
evidenceGrade: "B",
|
|
24559
25322
|
tier: "scored",
|
|
24560
25323
|
dossier: "docs/evidence/audits/agent-interfaces/openapi-operation-ids.md",
|
|
25324
|
+
requires: ["origin-reachable"],
|
|
24561
25325
|
defaultPriority: "medium",
|
|
24562
25326
|
guidance: {
|
|
24563
25327
|
impact: "AI agents use operationIds as stable function names when calling your API. Without unique operationIds, agents must infer endpoint names from URL paths, leading to ambiguous calls, naming collisions, and broken integrations.",
|
|
@@ -24737,6 +25501,7 @@ var OpenApiServersAudit = class _OpenApiServersAudit extends Audit {
|
|
|
24737
25501
|
evidenceGrade: "B",
|
|
24738
25502
|
tier: "scored",
|
|
24739
25503
|
dossier: "docs/evidence/audits/agent-interfaces/openapi-servers.md",
|
|
25504
|
+
requires: ["origin-reachable"],
|
|
24740
25505
|
defaultPriority: "high",
|
|
24741
25506
|
guidance: {
|
|
24742
25507
|
impact: "Without a servers array, AI agents cannot determine the base URL for your API. Even if your endpoints are perfectly documented, agents cannot construct valid request URLs, rendering the entire OpenAPI spec unusable.",
|
|
@@ -24901,6 +25666,7 @@ var OpenApiSchemasAudit = class _OpenApiSchemasAudit extends Audit {
|
|
|
24901
25666
|
evidenceGrade: "B",
|
|
24902
25667
|
tier: "scored",
|
|
24903
25668
|
dossier: "docs/evidence/audits/agent-interfaces/openapi-schemas.md",
|
|
25669
|
+
requires: ["origin-reachable"],
|
|
24904
25670
|
defaultPriority: "medium",
|
|
24905
25671
|
guidance: {
|
|
24906
25672
|
impact: "Without request/response schemas, AI agents must guess what data to send and what to expect back. This leads to malformed requests, failed API calls, and agents that cannot reliably use your endpoints.",
|
|
@@ -25305,6 +26071,7 @@ var AiCatalogExistsAudit = class _AiCatalogExistsAudit extends Audit {
|
|
|
25305
26071
|
evidenceGrade: "C",
|
|
25306
26072
|
tier: "informative",
|
|
25307
26073
|
dossier: "docs/evidence/audits/agent-interfaces/ai-catalog-exists.md",
|
|
26074
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
25308
26075
|
defaultPriority: "medium",
|
|
25309
26076
|
guidance: {
|
|
25310
26077
|
impact: "Without an AI catalog, agents must probe multiple endpoints to discover your services. This wastes time, increases error rates, and often results in agents skipping your site entirely in favor of competitors with a machine-readable capability manifest.",
|
|
@@ -25425,6 +26192,7 @@ var AiCatalogMetadataAudit = class _AiCatalogMetadataAudit extends Audit {
|
|
|
25425
26192
|
evidenceGrade: "B",
|
|
25426
26193
|
tier: "scored",
|
|
25427
26194
|
dossier: "docs/evidence/audits/agent-interfaces/ai-catalog-metadata.md",
|
|
26195
|
+
requires: ["origin-reachable"],
|
|
25428
26196
|
defaultPriority: "medium",
|
|
25429
26197
|
guidance: {
|
|
25430
26198
|
impact: "A thin catalog entry is a catalog entry nobody finds. Consumers match a user query against the entry text, so entries with no description, tags, capabilities or representative queries lose to better-described alternatives even when your service is the better answer.",
|
|
@@ -25560,6 +26328,7 @@ var AiCatalogUrlsAudit = class _AiCatalogUrlsAudit extends Audit {
|
|
|
25560
26328
|
evidenceGrade: "B",
|
|
25561
26329
|
tier: "scored",
|
|
25562
26330
|
dossier: "docs/evidence/audits/agent-interfaces/ai-catalog-urls.md",
|
|
26331
|
+
requires: ["origin-reachable"],
|
|
25563
26332
|
defaultPriority: "medium",
|
|
25564
26333
|
guidance: {
|
|
25565
26334
|
impact: "A broken entry url makes an agent fail mid-task: it read your manifest, followed the link you published, and got nothing. Entries whose url points at a nested catalog or registry cut off everything behind them as well.",
|
|
@@ -25687,6 +26456,7 @@ var AgentsJsonAudit = class extends Audit {
|
|
|
25687
26456
|
evidenceGrade: "C",
|
|
25688
26457
|
tier: "informative",
|
|
25689
26458
|
dossier: "docs/evidence/audits/agent-interfaces/agents-json.md",
|
|
26459
|
+
requires: ["origin-reachable"],
|
|
25690
26460
|
defaultPriority: "low",
|
|
25691
26461
|
guidance: {
|
|
25692
26462
|
impact: "Publishing agents.json is not known to make a site reachable to any agent: no vendor documents reading the file, and the specification has been dormant since 2025-08-21. What does matter is that a document already published at a well-known path can be read \u2014 a 200 carrying the site's HTML shell tells a conforming client the resource exists and then gives it nothing to parse, which is worse than a clean 404.",
|
|
@@ -25810,6 +26580,7 @@ var McpDiscoveryAudit = class _McpDiscoveryAudit extends Audit {
|
|
|
25810
26580
|
evidenceGrade: "C",
|
|
25811
26581
|
tier: "informative",
|
|
25812
26582
|
dossier: "docs/evidence/audits/agent-interfaces/mcp-discovery.md",
|
|
26583
|
+
requires: ["origin-reachable"],
|
|
25813
26584
|
defaultPriority: "medium",
|
|
25814
26585
|
guidance: {
|
|
25815
26586
|
impact: "No shipping MCP client is documented as fetching `/.well-known/mcp/servers.json` or `/.well-known/ucp`, so publishing one is not known to make a site reachable to any agent. What does matter is that a document published at a well-known path can be read: a 200 carrying HTML or unparseable JSON tells a conforming client the resource exists and then gives it nothing to parse.",
|
|
@@ -26129,6 +26900,7 @@ var McpEndpointAudit = class _McpEndpointAudit extends Audit {
|
|
|
26129
26900
|
evidenceGrade: "C",
|
|
26130
26901
|
tier: "informative",
|
|
26131
26902
|
dossier: "docs/evidence/audits/agent-interfaces/mcp-endpoint.md",
|
|
26903
|
+
requires: ["origin-reachable"],
|
|
26132
26904
|
defaultPriority: "high",
|
|
26133
26905
|
guidance: {
|
|
26134
26906
|
impact: "If your MCP endpoint does not answer an initialize handshake, AI assistants cannot connect at all. Capabilities and tool annotations come off the same connection: without them an agent cannot tell whether your server offers tools, or which of them are destructive enough to need user confirmation.",
|
|
@@ -26426,6 +27198,7 @@ var SearchEndpointAudit = class _SearchEndpointAudit extends Audit {
|
|
|
26426
27198
|
evidenceGrade: "C",
|
|
26427
27199
|
tier: "informative",
|
|
26428
27200
|
dossier: "docs/evidence/audits/agent-interfaces/search-endpoint.md",
|
|
27201
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
26429
27202
|
defaultPriority: "low",
|
|
26430
27203
|
guidance: {
|
|
26431
27204
|
impact: 'Without a declared search endpoint, an agent asked to "find pricing info on Example.com" has to crawl the site to answer. Note that no vendor documents an agent that reads SearchAction today \u2014 Google retired its only documented consumer in 2024 \u2014 so this check is informative and unscored.',
|
|
@@ -26596,6 +27369,7 @@ var WebmcpRegisteredToolsAudit = class extends Audit {
|
|
|
26596
27369
|
// detector that cannot distinguish "no tools" from "cannot see the tools".
|
|
26597
27370
|
tier: "experimental",
|
|
26598
27371
|
dossier: "docs/evidence/audits/agent-interfaces/webmcp-registered-tools.md",
|
|
27372
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
26599
27373
|
// Was `high` on an admittedly non-standard convention, so it outranked
|
|
26600
27374
|
// genuinely actionable items in the recommendation list.
|
|
26601
27375
|
defaultPriority: "low",
|
|
@@ -26711,6 +27485,7 @@ var WebmcpDeclarativeFormsAudit = class extends Audit {
|
|
|
26711
27485
|
evidenceGrade: "B",
|
|
26712
27486
|
tier: "scored",
|
|
26713
27487
|
dossier: "docs/evidence/audits/agent-interfaces/webmcp-declarative-forms.md",
|
|
27488
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
26714
27489
|
// Softened from 'high': the feature is Baseline "limited" (Chrome 149 /
|
|
26715
27490
|
// Edge 150 origin trials, Brave Leo experimental) and Apple's WebKit
|
|
26716
27491
|
// standards position is "oppose", so this is worth doing, not urgent.
|
|
@@ -26838,6 +27613,7 @@ var OpenApiDescriptionQualityAudit = class _OpenApiDescriptionQualityAudit exten
|
|
|
26838
27613
|
evidenceGrade: "A",
|
|
26839
27614
|
tier: "scored",
|
|
26840
27615
|
dossier: "docs/evidence/audits/agent-interfaces/openapi-description-quality.md",
|
|
27616
|
+
requires: ["origin-reachable"],
|
|
26841
27617
|
defaultPriority: "high",
|
|
26842
27618
|
guidance: {
|
|
26843
27619
|
impact: "LLM tool-calling treats your OpenAPI descriptions as the function-calling prompt. Missing or terse descriptions force the model to guess what each endpoint does and what each parameter accepts, producing wrong tool selection, malformed arguments, and failed API calls that erode user trust in agent-driven workflows on your site.",
|
|
@@ -27019,6 +27795,7 @@ var CorsApiRoutesAudit = class _CorsApiRoutesAudit extends Audit {
|
|
|
27019
27795
|
evidenceGrade: "C",
|
|
27020
27796
|
tier: "informative",
|
|
27021
27797
|
dossier: "docs/evidence/audits/agent-interfaces/cors-api-routes.md",
|
|
27798
|
+
requires: ["origin-reachable"],
|
|
27022
27799
|
// The affected consumer class is small; nothing here should outrank an
|
|
27023
27800
|
// item that changes what a crawler or an MCP client can do.
|
|
27024
27801
|
defaultPriority: "low",
|
|
@@ -27178,6 +27955,7 @@ var McpModernEraReachabilityAudit = class extends Audit {
|
|
|
27178
27955
|
evidenceGrade: "A",
|
|
27179
27956
|
tier: "scored",
|
|
27180
27957
|
dossier: "docs/evidence/audits/agent-interfaces/mcp-modern-era-reachability.md",
|
|
27958
|
+
requires: ["origin-reachable"],
|
|
27181
27959
|
defaultPriority: "high",
|
|
27182
27960
|
guidance: {
|
|
27183
27961
|
impact: "Revision 2026-07-28 abolished the `initialize` handshake and protocol-level sessions: version, client identity and capabilities now travel as per-request `_meta`, and `server/discover` is a MUST-implement RPC. The spec's own compatibility matrix states verbatim that a Modern client against a Legacy server FAILS, with no fall-forward path. Therefore: if a single POST of `server/discover` carrying `_meta` + `MCP-Protocol-Version: 2026-07-28` does not yield either a DiscoverResult or a recognized modern JSON-RPC error, then every client that has moved to the current revision cannot invoke a single tool on this server \u2014 the failure is total, not degraded. Conversely a 404/-32601 on `server/discover` from a server that otherwise answers modern requests is a direct MUST violation that breaks pre-consent capability presentation.",
|
|
@@ -27428,6 +28206,7 @@ var McpOauthDiscoveryChainAudit = class extends Audit {
|
|
|
27428
28206
|
evidenceGrade: "A",
|
|
27429
28207
|
tier: "scored",
|
|
27430
28208
|
dossier: "docs/evidence/audits/agent-interfaces/mcp-oauth-discovery-chain.md",
|
|
28209
|
+
requires: ["origin-reachable"],
|
|
27431
28210
|
defaultPriority: "high",
|
|
27432
28211
|
guidance: {
|
|
27433
28212
|
impact: "The spec makes RFC 9728 mandatory for MCP servers and makes clients apply two hard identity checks: RFC 9728 \xA73.3 requires the PRM's `resource` value to be string-identical to the resource identifier used to construct the request URL, and the MCP AS-discovery rules require the fetched AS metadata's `issuer` to be string-identical to the issuer used to construct the well-known URL \u2014 on either mismatch the client MUST NOT use the metadata. MCP additionally strengthens RFC 9728 by requiring `authorization_servers` to carry at least one entry (it is merely OPTIONAL in the RFC). Each of these is a silent, total blocker: the discovery chain either resolves end to end or the agent never reaches an authorization prompt, so a single character of drift between the deployed endpoint URL and the `resource` claim makes the server unusable to every conforming client while the server's own logs show nothing but 401s.",
|
|
@@ -27684,6 +28463,7 @@ var McpToolContractValidityAudit = class extends Audit {
|
|
|
27684
28463
|
evidenceGrade: "A",
|
|
27685
28464
|
tier: "scored",
|
|
27686
28465
|
dossier: "docs/evidence/audits/agent-interfaces/mcp-tool-contract-validity.md",
|
|
28466
|
+
requires: ["origin-reachable"],
|
|
27687
28467
|
defaultPriority: "critical",
|
|
27688
28468
|
guidance: {
|
|
27689
28469
|
impact: "The spec gives clients an explicit deletion instruction: 'Clients using the Streamable HTTP transport MUST reject tool definitions where any x-mcp-header value violates these constraints. Rejection means the client MUST exclude the invalid tool from the result of tools/list.' This makes malformed tool metadata a silent-invisibility bug rather than an error: the server returns the tool, logs a successful tools/list, and the model never sees it. The constraint set is fully machine-checkable with no network calls beyond the one list fetch \u2014 token syntax, no CR/LF, case-insensitive uniqueness, primitive types only with `number` explicitly excluded, and static reachability through a chain consisting solely of `properties` keys. Alongside it, `inputSchema` MUST be a valid JSON Schema object and not null; a null or scalar inputSchema breaks argument construction in every SDK.",
|
|
@@ -27940,6 +28720,7 @@ var McpToolsListDeterminismAudit = class extends Audit {
|
|
|
27940
28720
|
evidenceGrade: "A",
|
|
27941
28721
|
tier: "scored",
|
|
27942
28722
|
dossier: "docs/evidence/audits/agent-interfaces/mcp-tools-list-determinism.md",
|
|
28723
|
+
requires: ["origin-reachable"],
|
|
27943
28724
|
defaultPriority: "medium",
|
|
27944
28725
|
guidance: {
|
|
27945
28726
|
impact: "The spec states its own causal rationale verbatim: deterministic ordering 'enables clients to reliably cache the tool list and improves LLM prompt cache hit rates when tools are included in model context.' Tool definitions sit near the front of the model's prompt; if their serialized bytes change between turns, the provider-side prefix cache misses and the full tool block is re-billed at uncached rates on every single turn. Separately, servers MUST include caching hints on complete results, and when ttlMs is absent clients SHOULD assume 0 \u2014 immediately stale \u2014 so an omitted hint converts one cheap cached read into a network round-trip on every access. Both defects are invisible in functional testing and both are measurable with three identical requests.",
|
|
@@ -28124,6 +28905,7 @@ var McpVersionDowngradeAudit = class extends Audit {
|
|
|
28124
28905
|
evidenceGrade: "A",
|
|
28125
28906
|
tier: "scored",
|
|
28126
28907
|
dossier: "docs/evidence/audits/agent-interfaces/mcp-version-downgrade.md",
|
|
28908
|
+
requires: ["origin-reachable"],
|
|
28127
28909
|
defaultPriority: "medium",
|
|
28128
28910
|
guidance: {
|
|
28129
28911
|
impact: "With the handshake removed, the ONLY mechanism by which a client discovers a mutually supported version mid-flight is the `UnsupportedProtocolVersionError`: the spec requires code -32022 with `data.supported[]` listing the server's versions, and instructs clients to select from that list and retry. A server that instead returns a 500, a generic -32600/-32602, or a 400 with no `supported` array gives the client nothing to downgrade to \u2014 so a client whose preferred version is one revision ahead of the server's fails permanently even though a mutually supported version exists on both sides. Separately, the spec requires the header and the `_meta` value to agree, with a 400 + -32020 HeaderMismatch on divergence; a server that silently ignores the mismatch is trusting whichever source of truth its proxy layer did not, which is the exact split-brain the header-validation rules exist to prevent.",
|
|
@@ -28287,6 +29069,7 @@ var McpOriginValidationCorsAudit = class extends Audit {
|
|
|
28287
29069
|
weight: weightForGrade("B", "scored"),
|
|
28288
29070
|
defaultPriority: "high",
|
|
28289
29071
|
dossier: "docs/evidence/audits/agent-interfaces/mcp-origin-validation-cors.md",
|
|
29072
|
+
requires: ["origin-reachable"],
|
|
28290
29073
|
guidance: {
|
|
28291
29074
|
impact: "The transport spec is unambiguous: servers MUST validate the Origin header on all incoming connections, and answer 403 when it is present and invalid, because a server that does not is reachable from any web page the user has open. The provable defect is the CORS pairing: an endpoint that reflects the requesting Origin into `Access-Control-Allow-Origin` and returns `Access-Control-Allow-Credentials: true` has authorized any page to enumerate its tool surface and invoke tools with the user\u2019s session.",
|
|
28292
29075
|
fix: "Validate `Origin` on every request and answer 403 when it is present and not one you allow. Never reflect an arbitrary Origin while allowing credentials: return a fixed allow-list, or drop `Access-Control-Allow-Credentials`. `Access-Control-Allow-Origin: *` is only safe on an endpoint that accepts no credentials at all.",
|
|
@@ -28408,61 +29191,6 @@ var McpOriginValidationCorsAudit = class extends Audit {
|
|
|
28408
29191
|
}
|
|
28409
29192
|
};
|
|
28410
29193
|
|
|
28411
|
-
// src/gatherers/domains.ts
|
|
28412
|
-
var MULTI_SUFFIX = /* @__PURE__ */ new Set([
|
|
28413
|
-
"co.uk",
|
|
28414
|
-
"org.uk",
|
|
28415
|
-
"ac.uk",
|
|
28416
|
-
"gov.uk",
|
|
28417
|
-
"me.uk",
|
|
28418
|
-
"net.uk",
|
|
28419
|
-
"com.au",
|
|
28420
|
-
"net.au",
|
|
28421
|
-
"org.au",
|
|
28422
|
-
"edu.au",
|
|
28423
|
-
"gov.au",
|
|
28424
|
-
"co.nz",
|
|
28425
|
-
"co.jp",
|
|
28426
|
-
"or.jp",
|
|
28427
|
-
"ne.jp",
|
|
28428
|
-
"co.za",
|
|
28429
|
-
"co.kr",
|
|
28430
|
-
"co.il",
|
|
28431
|
-
"co.id",
|
|
28432
|
-
"co.th",
|
|
28433
|
-
"com.br",
|
|
28434
|
-
"com.mx",
|
|
28435
|
-
"com.ar",
|
|
28436
|
-
"com.co",
|
|
28437
|
-
"com.pe",
|
|
28438
|
-
"co.in",
|
|
28439
|
-
"com.sg",
|
|
28440
|
-
"com.tr",
|
|
28441
|
-
"com.cn",
|
|
28442
|
-
"com.hk",
|
|
28443
|
-
"com.tw",
|
|
28444
|
-
"com.my",
|
|
28445
|
-
"com.ph",
|
|
28446
|
-
"com.ua",
|
|
28447
|
-
"com.pl",
|
|
28448
|
-
"com.es",
|
|
28449
|
-
"com.pt",
|
|
28450
|
-
"com.gr"
|
|
28451
|
-
]);
|
|
28452
|
-
function registrableDomain(host) {
|
|
28453
|
-
const parts = host.toLowerCase().replace(/\.$/, "").split(".").filter(Boolean);
|
|
28454
|
-
if (parts.length <= 2) return parts.join(".");
|
|
28455
|
-
const lastTwo = parts.slice(-2).join(".");
|
|
28456
|
-
return MULTI_SUFFIX.has(lastTwo) ? parts.slice(-3).join(".") : lastTwo;
|
|
28457
|
-
}
|
|
28458
|
-
function registrableOf(url) {
|
|
28459
|
-
try {
|
|
28460
|
-
return registrableDomain(new URL(url).hostname);
|
|
28461
|
-
} catch {
|
|
28462
|
-
return "";
|
|
28463
|
-
}
|
|
28464
|
-
}
|
|
28465
|
-
|
|
28466
29194
|
// src/audits/agent-interfaces/mcp-registry-listing-ownership.ts
|
|
28467
29195
|
var REGISTRY = "https://registry.modelcontextprotocol.io/v0.1/servers";
|
|
28468
29196
|
var PROOF_PATH = "/.well-known/mcp-registry-auth";
|
|
@@ -28518,6 +29246,7 @@ var McpRegistryListingOwnershipAudit = class extends Audit {
|
|
|
28518
29246
|
weight: weightForGrade("B", "scored"),
|
|
28519
29247
|
defaultPriority: "medium",
|
|
28520
29248
|
dossier: "docs/evidence/audits/agent-interfaces/mcp-registry-listing-ownership.md",
|
|
29249
|
+
requires: ["origin-reachable"],
|
|
28521
29250
|
guidance: {
|
|
28522
29251
|
impact: 'The registry is the index a client resolves "the MCP server for this domain" against. A domain with no first-party entry is absent from it, so the only path to the server is a URL somebody pastes by hand. A listing under an aggregator\u2019s namespace is worse than absent in one way: the brand cannot update or revoke it, and agents routed through it reach a proxy rather than the origin. The reverse-DNS namespace that fixes this is granted on proof of domain control, and that proof has to keep being served.',
|
|
28523
29252
|
fix: "Publish the server under your own reverse-DNS namespace (`com.example/...`), serve the proof at `/.well-known/mcp-registry-auth` in the exact `v=MCPv1; k=ed25519; p=<base64>` form and keep serving it after DNS migrations, keep the listing\u2019s version in step with what the server reports, and offer a `streamable-http` remote rather than only the deprecated `sse`.",
|
|
@@ -28734,6 +29463,7 @@ var McpToolDescriptionCoverageAudit = class extends Audit {
|
|
|
28734
29463
|
weight: weightForGrade("B", "scored"),
|
|
28735
29464
|
defaultPriority: "medium",
|
|
28736
29465
|
dossier: "docs/evidence/audits/agent-interfaces/mcp-tool-description-coverage.md",
|
|
29466
|
+
requires: ["origin-reachable"],
|
|
28737
29467
|
guidance: {
|
|
28738
29468
|
impact: "A tool description and its parameter descriptions are the only prose a model ever sees about a tool \u2014 they are the whole basis on which it decides whether to call it and what to pass. A required parameter with no description, no enum and no pattern gives the model nothing to derive a legal value from, so it guesses. Guessed values come back as validation errors, and the agent spends retry turns per call until it gives up on the tool.",
|
|
28739
29469
|
fix: "Describe every tool and every parameter, in prose long enough to say what a legal value looks like. Constrain string parameters with `enum`, `format` or `pattern` where the legal set is finite. Declare an `outputSchema` so a client can parse the result rather than re-reading it, give each tool a `title` for the consent prompt, and return top-level `instructions` telling a model how the tools fit together.",
|
|
@@ -28971,6 +29701,7 @@ var OfferSchemaAudit = class extends Audit {
|
|
|
28971
29701
|
evidenceGrade: "A",
|
|
28972
29702
|
tier: "scored",
|
|
28973
29703
|
dossier: "docs/evidence/audits/agentic-commerce/offer-schema.md",
|
|
29704
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
28974
29705
|
applicablePageTypes: ["product"],
|
|
28975
29706
|
defaultPriority: "medium",
|
|
28976
29707
|
guidance: {
|
|
@@ -29089,6 +29820,7 @@ var ProductIdentifiersAudit = class extends Audit {
|
|
|
29089
29820
|
evidenceGrade: "A",
|
|
29090
29821
|
tier: "scored",
|
|
29091
29822
|
dossier: "docs/evidence/audits/agentic-commerce/product-identifiers.md",
|
|
29823
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
29092
29824
|
applicablePageTypes: ["product"],
|
|
29093
29825
|
defaultPriority: "high",
|
|
29094
29826
|
guidance: {
|
|
@@ -29206,6 +29938,7 @@ var ProductTransactionCertaintyAudit = class extends Audit {
|
|
|
29206
29938
|
evidenceGrade: "A",
|
|
29207
29939
|
tier: "scored",
|
|
29208
29940
|
dossier: "docs/evidence/audits/agentic-commerce/product-transaction-certainty.md",
|
|
29941
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
29209
29942
|
applicablePageTypes: ["product"],
|
|
29210
29943
|
defaultPriority: "high",
|
|
29211
29944
|
guidance: {
|
|
@@ -29538,6 +30271,7 @@ var BuyableVariantResolutionAudit = class extends Audit {
|
|
|
29538
30271
|
weight: weightForGrade("B", "scored"),
|
|
29539
30272
|
defaultPriority: "high",
|
|
29540
30273
|
dossier: "docs/evidence/audits/agentic-commerce/buyable-variant-resolution.md",
|
|
30274
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
29541
30275
|
applicablePageTypes: ["product"],
|
|
29542
30276
|
guidance: {
|
|
29543
30277
|
impact: "The agentic-commerce feed models a catalogue variant-first: every sellable thing is a variant with its own id, price and availability. A page that shows five sizes and three colours but publishes one Offer \u2014 or an AggregateOffer with only lowPrice and highPrice \u2014 gives an agent no purchasable unit to name and no single price to quote. The row is dropped at feed validation, or the checkout session comes back with `invalid` on the line item.",
|
|
@@ -29751,6 +30485,7 @@ var CartHandoffReachabilityAudit = class extends Audit {
|
|
|
29751
30485
|
weight: weightForGrade("B", "scored"),
|
|
29752
30486
|
defaultPriority: "high",
|
|
29753
30487
|
dossier: "docs/evidence/audits/agentic-commerce/cart-handoff-reachability.md",
|
|
30488
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
29754
30489
|
guidance: {
|
|
29755
30490
|
impact: "Every upstream signal can be perfect and the purchase still dies at the last click. If the cart 302s to a login form because guest checkout is off, or Turnstile is mounted on the checkout document alone, the agent walks the buyer to a wall it cannot pass. ACP reserves a `requires_sign_in` message code for exactly this case, which is a description of the failure, not a fix for it.",
|
|
29756
30491
|
fix: "Allow guest checkout, or at least let an unauthenticated buyer reach the cart and see the totals. Keep bot challenges off the cart and checkout documents \u2014 challenge the payment submission instead, where a human is present. Allow ChatGPT-User in robots.txt and at the edge on cart paths: blocking GPTBot does not block it, and the two are separately tokened.",
|
|
@@ -29956,6 +30691,7 @@ var OfferTruthConsistencyAudit = class extends Audit {
|
|
|
29956
30691
|
weight: weightForGrade("B", "scored"),
|
|
29957
30692
|
defaultPriority: "high",
|
|
29958
30693
|
dossier: "docs/evidence/audits/agentic-commerce/offer-truth-consistency.md",
|
|
30694
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
29959
30695
|
applicablePageTypes: ["product"],
|
|
29960
30696
|
guidance: {
|
|
29961
30697
|
impact: "An agent quotes from the structured data; the seller recomputes the real amount at checkout. When the two disagree the buyer has already committed, and the session comes back with `invalid` or `out_of_stock` \u2014 the most expensive moment at which a purchase can fail. Google says the same thing from the other side: structured data must be a true representation of the page content. Markup that is present and lying passes every syntax validator on the market.",
|
|
@@ -30305,6 +31041,7 @@ var AcpPolicyLinkSurfaceAudit = class _AcpPolicyLinkSurfaceAudit extends Audit {
|
|
|
30305
31041
|
evidenceGrade: "A",
|
|
30306
31042
|
tier: "scored",
|
|
30307
31043
|
dossier: "docs/evidence/audits/agentic-commerce/acp-policy-link-surface.md",
|
|
31044
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
30308
31045
|
defaultPriority: "high",
|
|
30309
31046
|
guidance: {
|
|
30310
31047
|
impact: "Falsifiable claim: ACP spec 2026-04-17 makes `links` one of the 9 REQUIRED fields on every CheckoutSession response, with type enum {terms_of_use, privacy_policy, return_policy, shipping_policy, contact_us, about_us, faq, support}. Independently, the OpenAI product feed spec makes `seller_privacy_policy` and `seller_tos` HARD-REQUIRED whenever `is_eligible_checkout=true`. Therefore a merchant that cannot produce a resolvable HTTPS URL for terms_of_use and privacy_policy CANNOT set is_eligible_checkout=true and its catalogue is excluded from Instant Checkout no matter how good the feed is. Disproof condition: if a merchant with no reachable ToS URL is observed transacting via ACP Instant Checkout, the check is wrong.",
|
|
@@ -30555,6 +31292,7 @@ var LandedCostAndReturnsAudit = class _LandedCostAndReturnsAudit extends Audit {
|
|
|
30555
31292
|
evidenceGrade: "A",
|
|
30556
31293
|
tier: "scored",
|
|
30557
31294
|
dossier: "docs/evidence/audits/agentic-commerce/landed-cost-and-returns.md",
|
|
31295
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
30558
31296
|
applicablePageTypes: ["product"],
|
|
30559
31297
|
defaultPriority: "high",
|
|
30560
31298
|
guidance: {
|
|
@@ -30683,6 +31421,7 @@ var AgentUaCommerceParityAudit = class extends Audit {
|
|
|
30683
31421
|
evidenceGrade: "A",
|
|
30684
31422
|
tier: "scored",
|
|
30685
31423
|
dossier: "docs/evidence/audits/agentic-commerce/agent-ua-commerce-parity.md",
|
|
31424
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
30686
31425
|
defaultPriority: "critical",
|
|
30687
31426
|
guidance: {
|
|
30688
31427
|
impact: "OpenAI operates four separately-tokened agents with separately published IP ranges: OAI-SearchBot (search indexing), ChatGPT-User (user-initiated fetches \u2014 the shopper's agent), GPTBot (training) and OAI-AdsBot (ad landing-page validation). Falsifiable claim: if a product page returns 403, 429, 503 or a challenge interstitial to ChatGPT-User or OAI-SearchBot while returning 200 to a browser, ChatGPT cannot read live price and availability nor follow the buy link, so the product cannot be surfaced or transacted no matter how good the feed is. That block lives at the WAF or CDN edge, which is why an audit that only parses robots.txt is structurally blind to it. Disproof condition: a site 403ing ChatGPT-User on its product pages that still shows live, accurate prices in ChatGPT.",
|
|
@@ -30826,6 +31565,7 @@ var ContactFormAudit = class _ContactFormAudit extends Audit {
|
|
|
30826
31565
|
evidenceGrade: "C",
|
|
30827
31566
|
tier: "informative",
|
|
30828
31567
|
dossier: "docs/evidence/audits/operability-safety/contact-form.md",
|
|
31568
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
30829
31569
|
defaultPriority: "high",
|
|
30830
31570
|
guidance: {
|
|
30831
31571
|
impact: 'When users ask AI agents to "contact this company for a quote" or "send a message to their support team," the agent needs a machine-submittable form or API endpoint. Without one, the agent cannot complete the request and users turn to competitors.',
|
|
@@ -30934,6 +31674,10 @@ var NoBlockingCaptchaAudit = class _NoBlockingCaptchaAudit extends Audit {
|
|
|
30934
31674
|
evidenceGrade: "A",
|
|
30935
31675
|
tier: "scored",
|
|
30936
31676
|
dossier: "docs/evidence/audits/operability-safety/no-blocking-captcha.md",
|
|
31677
|
+
// Gate exemption: a captcha wall is what this audit reports, and a wall denies
|
|
31678
|
+
// `origin-reachable` — gating on it made the finding unreachable for the 403 that
|
|
31679
|
+
// produced it. The wall branch reads `wafProtection`, not any response body.
|
|
31680
|
+
requires: [],
|
|
30937
31681
|
defaultPriority: "high",
|
|
30938
31682
|
guidance: {
|
|
30939
31683
|
impact: 'Blocking CAPTCHAs completely prevent AI agents from submitting forms on behalf of users. When a user asks an agent to "fill out the contact form," the CAPTCHA blocks the action entirely, forcing the user to do it manually or go to a competitor.',
|
|
@@ -30956,6 +31700,30 @@ var NoBlockingCaptchaAudit = class _NoBlockingCaptchaAudit extends Audit {
|
|
|
30956
31700
|
}
|
|
30957
31701
|
};
|
|
30958
31702
|
audit(ctx) {
|
|
31703
|
+
const waf = ctx.wafProtection;
|
|
31704
|
+
if (waf?.isBlocked && !waf.isRateLimit) {
|
|
31705
|
+
return this.fail(
|
|
31706
|
+
`The site answered the scanner with a bot wall (${waf.name}). An AI agent acting for a user meets the same wall.`,
|
|
31707
|
+
"No bot wall or blocking CAPTCHA between an agent and the page",
|
|
31708
|
+
`${waf.name}: ${waf.reason}`,
|
|
31709
|
+
{ priority: "high", description: _NoBlockingCaptchaAudit.meta.description },
|
|
31710
|
+
ctx.baseUrl
|
|
31711
|
+
);
|
|
31712
|
+
}
|
|
31713
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
31714
|
+
return this.notApplicable(
|
|
31715
|
+
"No page here can be attributed to this site, so no form was inspected for a CAPTCHA.",
|
|
31716
|
+
"No recaptcha, hcaptcha, or turnstile script includes detected",
|
|
31717
|
+
unreadSiteReason(ctx.evidence)
|
|
31718
|
+
);
|
|
31719
|
+
}
|
|
31720
|
+
if (ctx.pages.length === 0) {
|
|
31721
|
+
return this.notApplicable(
|
|
31722
|
+
"No page was fetched, so no form could be inspected for a blocking CAPTCHA.",
|
|
31723
|
+
"No recaptcha, hcaptcha, or turnstile script includes detected",
|
|
31724
|
+
"No page fetched"
|
|
31725
|
+
);
|
|
31726
|
+
}
|
|
30959
31727
|
const detectedCaptchas = [];
|
|
30960
31728
|
for (const page of ctx.pages) {
|
|
30961
31729
|
const html = page.fetchResult.body.toLowerCase();
|
|
@@ -30966,6 +31734,13 @@ var NoBlockingCaptchaAudit = class _NoBlockingCaptchaAudit extends Audit {
|
|
|
30966
31734
|
}
|
|
30967
31735
|
}
|
|
30968
31736
|
if (detectedCaptchas.length === 0) {
|
|
31737
|
+
if (!scanReadPageText(ctx.evidence)) {
|
|
31738
|
+
return this.notApplicable(
|
|
31739
|
+
"The scanned page served no readable text, so no form was inspected for a CAPTCHA.",
|
|
31740
|
+
"No recaptcha, hcaptcha, or turnstile script includes detected",
|
|
31741
|
+
unreadPageTextReason(ctx.evidence)
|
|
31742
|
+
);
|
|
31743
|
+
}
|
|
30969
31744
|
return this.pass(
|
|
30970
31745
|
"No blocking CAPTCHA scripts detected on scanned pages.",
|
|
30971
31746
|
"No recaptcha, hcaptcha, or turnstile script includes detected",
|
|
@@ -31012,6 +31787,7 @@ var FormsNoJsAudit = class _FormsNoJsAudit extends Audit {
|
|
|
31012
31787
|
evidenceGrade: "C",
|
|
31013
31788
|
tier: "informative",
|
|
31014
31789
|
dossier: "docs/evidence/audits/operability-safety/forms-no-js.md",
|
|
31790
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
31015
31791
|
defaultPriority: "medium",
|
|
31016
31792
|
guidance: {
|
|
31017
31793
|
impact: "Most AI agents do not execute JavaScript. If your forms rely on JS for submission (e.g., React/Vue event handlers with no HTML action), agents cannot submit them at all. This blocks lead capture, contact requests, and any form-based interaction.",
|
|
@@ -31191,6 +31967,7 @@ var FormActionabilityAudit = class extends Audit {
|
|
|
31191
31967
|
evidenceGrade: "A",
|
|
31192
31968
|
tier: "scored",
|
|
31193
31969
|
dossier: "docs/evidence/audits/operability-safety/form-actionability.md",
|
|
31970
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
31194
31971
|
defaultPriority: "high",
|
|
31195
31972
|
guidance: {
|
|
31196
31973
|
impact: "AI agents do not render your page visually. Unlabeled fields, div-based fake inputs, and missing autocomplete attributes mean agents cannot tell which field is the email address or the name, so submissions fail silently or land in the wrong fields \u2014 lost leads, broken signups, and abandoned checkouts.",
|
|
@@ -31344,6 +32121,7 @@ var AriaLandmarksAudit = class extends Audit {
|
|
|
31344
32121
|
evidenceGrade: "A",
|
|
31345
32122
|
tier: "scored",
|
|
31346
32123
|
dossier: "docs/evidence/audits/operability-safety/aria-landmarks.md",
|
|
32124
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
31347
32125
|
defaultPriority: "high",
|
|
31348
32126
|
guidance: {
|
|
31349
32127
|
impact: "Claude computer use and browser agents rely on ARIA landmarks to identify page regions (navigation, main content, footer). Missing landmarks force agents to guess page structure from raw HTML, leading to misclicked elements and incorrect content extraction.",
|
|
@@ -31483,7 +32261,19 @@ function defineA11yAudit(spec) {
|
|
|
31483
32261
|
};
|
|
31484
32262
|
}
|
|
31485
32263
|
var base = {
|
|
31486
|
-
category: "operability-safety"
|
|
32264
|
+
category: "operability-safety",
|
|
32265
|
+
/**
|
|
32266
|
+
* Every audit built on this base reads the sampled pages through
|
|
32267
|
+
* `A11yBackedAudit`, so they all carry the same requirement set. Declared
|
|
32268
|
+
* once here; `scripts/check-requires.mjs` resolves it for each audit that
|
|
32269
|
+
* spreads `base`.
|
|
32270
|
+
*/
|
|
32271
|
+
requires: [
|
|
32272
|
+
"origin-reachable",
|
|
32273
|
+
"unblocked-fetches",
|
|
32274
|
+
"rendered-body",
|
|
32275
|
+
"sample-adequate"
|
|
32276
|
+
]
|
|
31487
32277
|
};
|
|
31488
32278
|
function graded(grade, slug) {
|
|
31489
32279
|
const tier = grade === "A" || grade === "B" ? "scored" : "informative";
|
|
@@ -31593,6 +32383,7 @@ var FormErrorMessagesAudit = class _FormErrorMessagesAudit extends Audit {
|
|
|
31593
32383
|
evidenceGrade: "A",
|
|
31594
32384
|
tier: "scored",
|
|
31595
32385
|
dossier: "docs/evidence/audits/operability-safety/form-error-messages.md",
|
|
32386
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
31596
32387
|
defaultPriority: "medium",
|
|
31597
32388
|
guidance: {
|
|
31598
32389
|
impact: "A field with no aria-errormessage or aria-describedby reference has no message attached to it in the accessibility tree, so an agent that submits a form and gets it back rejected cannot tell which field was wrong or why. It retries the same values or abandons the form.",
|
|
@@ -31998,6 +32789,7 @@ var SecurityHeaderHygieneAudit = class extends Audit {
|
|
|
31998
32789
|
evidenceGrade: "C",
|
|
31999
32790
|
tier: "informative",
|
|
32000
32791
|
dossier: "docs/evidence/audits/operability-safety/security-header-hygiene.md",
|
|
32792
|
+
requires: ["origin-reachable"],
|
|
32001
32793
|
defaultPriority: "low",
|
|
32002
32794
|
guidance: {
|
|
32003
32795
|
impact: "Vulnerability-disclosure hygiene, reported for completeness. A conformant security.txt tells a security researcher who to contact; it is read by researchers and disclosure scanners, not by AI agents. Publishing one changes nothing about how an agent retrieves, parses or cites the site, which is why nothing here moves your score. If you do publish one, an expired or contactless file is worse than none: it advertises a disclosure route that no longer works.",
|
|
@@ -32256,6 +33048,7 @@ var FormAutofillTokenCoverageAudit = class _FormAutofillTokenCoverageAudit exten
|
|
|
32256
33048
|
evidenceGrade: "A",
|
|
32257
33049
|
tier: "scored",
|
|
32258
33050
|
dossier: "docs/evidence/audits/operability-safety/form-autofill-token-coverage.md",
|
|
33051
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
32259
33052
|
defaultPriority: "high",
|
|
32260
33053
|
guidance: {
|
|
32261
33054
|
impact: 'Falsifiable claim: an agent filling a checkout must map each field to a value from user profile data. When the field declares autocomplete="postal-code", that mapping is a table lookup against a ratified vocabulary; when it declares name="field_7" with a visual-only label, the mapping is an inference that fails on ambiguous cases (address-line2 vs address-level2, cc-exp vs bday, tel-national vs tel). WebSuite measures the consequence directly: complex form filling succeeds 12.5% and 0% for the two agents tested, against 85%/76% for simple operational clicks. Test: add correct autocomplete tokens to a failing form and re-run the same fill task.',
|
|
@@ -32427,6 +33220,7 @@ var NativeControlSubstitutionAudit = class _NativeControlSubstitutionAudit exten
|
|
|
32427
33220
|
evidenceGrade: "A",
|
|
32428
33221
|
tier: "scored",
|
|
32429
33222
|
dossier: "docs/evidence/audits/operability-safety/native-control-substitution.md",
|
|
33223
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
32430
33224
|
defaultPriority: "high",
|
|
32431
33225
|
guidance: {
|
|
32432
33226
|
impact: `Falsifiable claim: native <select>, <input type="date">, and <input type="file"> are single-call primitives in every mainstream agent toolkit (selectOption, fill, setInputFiles) and are keyboard-operable, so they succeed in one action with no actionability risk. A custom equivalent requires open \u2192 wait for popup \u2192 scroll the option list into view \u2192 locate the option \u2192 click, where each step is independently subject to Playwright's visible/stable/receives-events gates, and Anthropic documents dropdowns specifically as 'tricky for Claude to manipulate using mouse movements'. Test: instrument the same form with native vs custom controls and count tool calls and retries to reach an identical value.`,
|
|
@@ -32765,6 +33559,7 @@ var InvisibleInstructionScanAudit = class _InvisibleInstructionScanAudit extends
|
|
|
32765
33559
|
evidenceGrade: "A",
|
|
32766
33560
|
tier: "scored",
|
|
32767
33561
|
dossier: "docs/evidence/audits/operability-safety/invisible-instruction-scan.md",
|
|
33562
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
32768
33563
|
defaultPriority: "critical",
|
|
32769
33564
|
guidance: {
|
|
32770
33565
|
impact: "If a page carries text nodes that a sighted human cannot perceive but that survive DOM-to-text serialization, an LLM browsing agent ingests them with the same weight as body copy and can act on them. Brave demonstrated exactly this against Comet (white-on-white text, HTML comments, invisible elements hidden in a Reddit spoiler tag) and confirmed Opera Neon was exploitable through 'hidden HTML elements and other non-rendered markup'. Falsifier: an agent that ingests only visually perceivable, rendered text would be immune \u2014 the disclosed incidents show current agents are not. Google's spam policy independently enumerates the same hiding techniques and their legitimate exceptions, giving the detector a canonical technique list and a false-positive allowlist.",
|
|
@@ -32783,6 +33578,13 @@ var InvisibleInstructionScanAudit = class _InvisibleInstructionScanAudit extends
|
|
|
32783
33578
|
};
|
|
32784
33579
|
}
|
|
32785
33580
|
async audit(ctx) {
|
|
33581
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
33582
|
+
return this.notApplicable(
|
|
33583
|
+
"No page here can be attributed to this site, so its hidden text was not judged.",
|
|
33584
|
+
EXPECTED50,
|
|
33585
|
+
unreadSiteReason(ctx.evidence)
|
|
33586
|
+
);
|
|
33587
|
+
}
|
|
32786
33588
|
const s = await survey9(ctx);
|
|
32787
33589
|
const partial = s.crossOrigin > 0 ? `; ${s.crossOrigin} cross-origin stylesheet not fetched` : "";
|
|
32788
33590
|
if (s.textNodesSeen === 0) {
|
|
@@ -33041,6 +33843,7 @@ var AriaLayerInjectionScanAudit = class _AriaLayerInjectionScanAudit extends Aud
|
|
|
33041
33843
|
evidenceGrade: "A",
|
|
33042
33844
|
tier: "scored",
|
|
33043
33845
|
dossier: "docs/evidence/audits/operability-safety/aria-layer-injection-scan.md",
|
|
33846
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
33044
33847
|
defaultPriority: "critical",
|
|
33045
33848
|
guidance: {
|
|
33046
33849
|
impact: "Computer-use and browser agents drive pages through the DOM and accessibility tree, not pixels, so a11y attributes enter the model context with the same weight as visible text while remaining invisible to a sighted human. Anthropic names the vector explicitly: 'hidden malicious form fields in a webpage's DOM invisible to humans, and other hard-to-catch injections such as through the URL text and tab title that only an agent might see.' The divergence sub-check is a defect in its own right independent of injection: an agent that clicks by accessible name will actuate an aria-label that contradicts the rendered label. Falsifier: if every a11y attribute is short, descriptive, and token-consistent with its element's visible text, this channel carries no payload.",
|
|
@@ -33059,6 +33862,13 @@ var AriaLayerInjectionScanAudit = class _AriaLayerInjectionScanAudit extends Aud
|
|
|
33059
33862
|
};
|
|
33060
33863
|
}
|
|
33061
33864
|
audit(ctx) {
|
|
33865
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
33866
|
+
return this.notApplicable(
|
|
33867
|
+
"No page here can be attributed to this site, so its non-visual values were not judged.",
|
|
33868
|
+
EXPECTED51,
|
|
33869
|
+
unreadSiteReason(ctx.evidence)
|
|
33870
|
+
);
|
|
33871
|
+
}
|
|
33062
33872
|
const s = survey10(ctx);
|
|
33063
33873
|
if (s.valuesSeen === 0) {
|
|
33064
33874
|
return this.notApplicable(
|
|
@@ -33104,6 +33914,13 @@ var AriaLayerInjectionScanAudit = class _AriaLayerInjectionScanAudit extends Aud
|
|
|
33104
33914
|
warnings[0].pageUrl
|
|
33105
33915
|
);
|
|
33106
33916
|
}
|
|
33917
|
+
if (!scanReadPageText(ctx.evidence)) {
|
|
33918
|
+
return this.notApplicable(
|
|
33919
|
+
"The scanned page served no readable text, so its accessibility layer was not judged.",
|
|
33920
|
+
EXPECTED51,
|
|
33921
|
+
unreadPageTextReason(ctx.evidence)
|
|
33922
|
+
);
|
|
33923
|
+
}
|
|
33107
33924
|
return this.pass(
|
|
33108
33925
|
`All ${s.valuesSeen} non-visual value(s) are descriptions that agree with their element and carry no instruction addressed to an AI.`,
|
|
33109
33926
|
EXPECTED51,
|
|
@@ -33204,6 +34021,7 @@ var GhostClickableElementRatioAudit = class _GhostClickableElementRatioAudit ext
|
|
|
33204
34021
|
evidenceGrade: "B",
|
|
33205
34022
|
tier: "scored",
|
|
33206
34023
|
dossier: "docs/evidence/audits/operability-safety/ghost-clickable-element-ratio.md",
|
|
34024
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
33207
34025
|
defaultPriority: "high",
|
|
33208
34026
|
guidance: {
|
|
33209
34027
|
impact: "An element whose click behaviour comes only from a JS listener on a non-interactive tag, or from cursor:pointer styling, and which carries no role and no accessible name, is omitted from the serialized accessibility snapshot that agent toolkits send to the model. Playwright MCP's default mode is the accessibility tree, not pixel input: every action tool takes an exact element reference from the snapshot, and coordinate clicking exists only behind the optional vision capability. An element absent from the snapshot is therefore unaddressable by the default toolchain \u2014 the agent cannot emit a valid click and must fail or guess a URL. The accessibility linters cannot warn about it either: axe's button-name and link-name rules only fire on elements that already declare button or link semantics, so a bare unroled div is invisible to them by construction.",
|
|
@@ -33222,6 +34040,13 @@ var GhostClickableElementRatioAudit = class _GhostClickableElementRatioAudit ext
|
|
|
33222
34040
|
};
|
|
33223
34041
|
}
|
|
33224
34042
|
async audit(ctx) {
|
|
34043
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
34044
|
+
return this.notApplicable(
|
|
34045
|
+
"No page here can be attributed to this site, so its click targets were not counted.",
|
|
34046
|
+
EXPECTED52,
|
|
34047
|
+
unreadSiteReason(ctx.evidence)
|
|
34048
|
+
);
|
|
34049
|
+
}
|
|
33225
34050
|
const s = await survey11(ctx);
|
|
33226
34051
|
const total = s.semantic + s.ghosts.length;
|
|
33227
34052
|
const partial = s.crossOrigin > 0 ? `; ${s.crossOrigin} cross-origin stylesheet not fetched` : "";
|
|
@@ -33426,6 +34251,7 @@ var StatefulControlIntrospectabilityAudit = class _StatefulControlIntrospectabil
|
|
|
33426
34251
|
evidenceGrade: "B",
|
|
33427
34252
|
tier: "scored",
|
|
33428
34253
|
dossier: "docs/evidence/audits/operability-safety/stateful-control-introspectability.md",
|
|
34254
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
33429
34255
|
defaultPriority: "high",
|
|
33430
34256
|
guidance: {
|
|
33431
34257
|
impact: 'An agent works as observe, act, verify. If a toggle\'s only "on" signal is `class="is-active"` and a colour change, the accessibility snapshot is byte-identical before and after the click, so the agent cannot verify the post-condition: it either clicks again and flips the state back, or reports success with no evidence. The accessibility linters cannot catch this, because `aria-required-attr` fires only once the element already declares `role="switch"` or `role="checkbox"` \u2014 the common class-only toggle declares no role and passes silently. Benchmarks put the cost high: WebSuite measures switch, accordion and dropdown primitives among the worst-performing interactions for web agents, and Operator\'s confirmation design assumes the agent can observe a state transition before acting on it.',
|
|
@@ -33635,6 +34461,7 @@ var HoverOnlyContentAndNavigationAudit = class _HoverOnlyContentAndNavigationAud
|
|
|
33635
34461
|
evidenceGrade: "B",
|
|
33636
34462
|
tier: "scored",
|
|
33637
34463
|
dossier: "docs/evidence/audits/operability-safety/hover-only-content-and-navigation.md",
|
|
34464
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
33638
34465
|
defaultPriority: "high",
|
|
33639
34466
|
guidance: {
|
|
33640
34467
|
impact: "A submenu revealed only by an ancestor `:hover` rule is `display:none` or `visibility:hidden` in the resting DOM, and Playwright's actionability contract defines such an element as not visible \u2014 so every Playwright-derived agent refuses to click it, and the snapshot serializer omits it entirely. The agent never learns those destinations exist: it does not fail loudly, it simply reports that the site has no page for what the user asked. WebSuite measures the information half of the same defect at 0% success for tooltip-based retrieval across both agents it tested. The fix is cheap and it is the same fix keyboard users need, which is why it is worth doing once.",
|
|
@@ -33862,6 +34689,7 @@ var DragAndSliderDependencyAudit = class _DragAndSliderDependencyAudit extends A
|
|
|
33862
34689
|
evidenceGrade: "B",
|
|
33863
34690
|
tier: "scored",
|
|
33864
34691
|
dossier: "docs/evidence/audits/operability-safety/drag-and-slider-dependency.md",
|
|
34692
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
33865
34693
|
defaultPriority: "high",
|
|
33866
34694
|
guidance: {
|
|
33867
34695
|
impact: 'A continuous pointer gesture asks an agent to synthesise a pointerdown, a run of intermediate pointermove events and a pointerup at a computed pixel offset, with no feedback between steps and no way to check the interim value. Every other agent action is discrete and verifiable. WebSuite measures slider interaction at 0% success for both agents it tested \u2014 the worst primitive in its taxonomy \u2014 and Anthropic separately documents scrollbars and dropdowns as unreliable under mouse control, recommending keyboard paths instead. Pair the slider with a numeric input bound to the same value and "set max price to 300" stops being a gesture and becomes a fill.',
|
|
@@ -34110,6 +34938,7 @@ var UrlAddressableStateAndPaginationFallbackAudit = class _UrlAddressableStateAn
|
|
|
34110
34938
|
evidenceGrade: "B",
|
|
34111
34939
|
tier: "scored",
|
|
34112
34940
|
dossier: "docs/evidence/audits/operability-safety/url-addressable-state-and-pagination-fallback.md",
|
|
34941
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
34113
34942
|
defaultPriority: "high",
|
|
34114
34943
|
applicablePageTypes: ["category"],
|
|
34115
34944
|
guidance: {
|
|
@@ -34311,6 +35140,7 @@ var FirstContactConsentGateOperabilityAudit = class _FirstContactConsentGateOper
|
|
|
34311
35140
|
evidenceGrade: "C",
|
|
34312
35141
|
tier: "informative",
|
|
34313
35142
|
dossier: "docs/evidence/audits/operability-safety/first-contact-consent-gate-operability.md",
|
|
35143
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
34314
35144
|
defaultPriority: "low",
|
|
34315
35145
|
guidance: {
|
|
34316
35146
|
impact: "An agent arriving with no cookies spends its first actions on the consent layer, before any step of the actual task. Three properties decide whether it can. A layer rendered inside a cross-origin iframe is invisible to a DOM-text extractor that reads only the top document, so the agent's text and its screenshot disagree and it acts on content it cannot actually see. Accept and reject controls built as unroled, unnamed divs are unaddressable in a snapshot for the same reason a ghost-clickable div is. And main content set `inert` or `aria-hidden=\"true\"` while the layer is open empties every snapshot until the layer is gone \u2014 axe's own guidance is that `aria-hidden` removes the element and all its children from the accessibility API. The evidence here is convention rather than documented consumer behaviour, which is why this audit reports rather than scores.",
|
|
@@ -34562,6 +35392,7 @@ var UnicodeCovertChannelScanAudit = class _UnicodeCovertChannelScanAudit extends
|
|
|
34562
35392
|
evidenceGrade: "B",
|
|
34563
35393
|
tier: "scored",
|
|
34564
35394
|
dossier: "docs/evidence/audits/operability-safety/unicode-covert-channel-scan.md",
|
|
35395
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
34565
35396
|
defaultPriority: "critical",
|
|
34566
35397
|
guidance: {
|
|
34567
35398
|
impact: "Tag-block codepoints mirror ASCII and, per Unicode, render as nothing in tag-unaware implementations \u2014 while modern LLM tokenizers process them normally. A complete instruction can therefore ride inside a product description that no human and no visual QA pass can see. Bidi controls make the rendered order differ from the logical order a text-extracting agent reads, which is the Trojan Source class (CVE-2021-42574). Zero-width characters defeat naive substring matching on both sides at once: the site\u2019s own filters and the agent\u2019s. None of this is visible in a screenshot, a browser, or a review \u2014 only in the bytes.",
|
|
@@ -34580,6 +35411,13 @@ var UnicodeCovertChannelScanAudit = class _UnicodeCovertChannelScanAudit extends
|
|
|
34580
35411
|
};
|
|
34581
35412
|
}
|
|
34582
35413
|
audit(ctx) {
|
|
35414
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
35415
|
+
return this.notApplicable(
|
|
35416
|
+
"No response here can be attributed to this site, so its codepoints were not judged.",
|
|
35417
|
+
EXPECTED58,
|
|
35418
|
+
unreadSiteReason(ctx.evidence)
|
|
35419
|
+
);
|
|
35420
|
+
}
|
|
34583
35421
|
const hits2 = [];
|
|
34584
35422
|
for (const page of ctx.pages) hits2.push(...scanPage(page));
|
|
34585
35423
|
for (const path of ROOT_FILES) {
|
|
@@ -34606,6 +35444,16 @@ var UnicodeCovertChannelScanAudit = class _UnicodeCovertChannelScanAudit extends
|
|
|
34606
35444
|
fillerCount: filler
|
|
34607
35445
|
};
|
|
34608
35446
|
if (hits2.length === 0) {
|
|
35447
|
+
if (!scanReadPageText(ctx.evidence)) {
|
|
35448
|
+
return {
|
|
35449
|
+
...this.notApplicable(
|
|
35450
|
+
"The scanned page served no readable text, so its codepoints were not judged.",
|
|
35451
|
+
EXPECTED58,
|
|
35452
|
+
unreadPageTextReason(ctx.evidence)
|
|
35453
|
+
),
|
|
35454
|
+
details
|
|
35455
|
+
};
|
|
35456
|
+
}
|
|
34609
35457
|
return {
|
|
34610
35458
|
...this.pass(
|
|
34611
35459
|
"No invisible codepoint carries text on the scanned pages or in the root files.",
|
|
@@ -34801,6 +35649,10 @@ var ThirdPartyDomWriteBlastRadiusAudit = class _ThirdPartyDomWriteBlastRadiusAud
|
|
|
34801
35649
|
evidenceGrade: "B",
|
|
34802
35650
|
tier: "scored",
|
|
34803
35651
|
dossier: "docs/evidence/audits/operability-safety/third-party-dom-write-blast-radius.md",
|
|
35652
|
+
// Gate exemption: every origin the served HTML names is counted whether or not the
|
|
35653
|
+
// body renders, so a page that ships a vendor script statically is still reported.
|
|
35654
|
+
// The empty census is the case a shell cannot support, and `audit()` declines it.
|
|
35655
|
+
requires: ["origin-reachable", "unblocked-fetches"],
|
|
34804
35656
|
defaultPriority: "high",
|
|
34805
35657
|
guidance: {
|
|
34806
35658
|
impact: 'An agent reads the DOM as one document with one level of trust. It has no way to tell text the site wrote from text a vendor script injected after load, so every third-party origin that can write to the page can write instructions the agent will read as the site\'s own. The count is the risk: eleven uncontrolled origins is eleven independent companies \u2014 and their own supply chains \u2014 with the same authority over what an agent believes about the site. A Content-Security-Policy with a nonce, a hash or `strict-dynamic` is what turns that list from "whoever" into "these, and only these". A policy whose sources include `https:` or `*` is present in the response and constrains nothing.',
|
|
@@ -34819,6 +35671,13 @@ var ThirdPartyDomWriteBlastRadiusAudit = class _ThirdPartyDomWriteBlastRadiusAud
|
|
|
34819
35671
|
};
|
|
34820
35672
|
}
|
|
34821
35673
|
audit(ctx) {
|
|
35674
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
35675
|
+
return this.notApplicable(
|
|
35676
|
+
"No page here can be attributed to this site, so its third-party surface was not measured.",
|
|
35677
|
+
EXPECTED59,
|
|
35678
|
+
unreadSiteReason(ctx.evidence)
|
|
35679
|
+
);
|
|
35680
|
+
}
|
|
34822
35681
|
if (ctx.pages.length === 0) {
|
|
34823
35682
|
return this.notApplicable(
|
|
34824
35683
|
"No page was fetched, so there is no third-party surface to measure.",
|
|
@@ -34858,6 +35717,17 @@ var ThirdPartyDomWriteBlastRadiusAudit = class _ThirdPartyDomWriteBlastRadiusAud
|
|
|
34858
35717
|
details
|
|
34859
35718
|
};
|
|
34860
35719
|
}
|
|
35720
|
+
if (!scanReadPageText(ctx.evidence)) {
|
|
35721
|
+
return {
|
|
35722
|
+
...this.notApplicable(
|
|
35723
|
+
"The scanned page served no readable text, so the origins writing into it were not counted.",
|
|
35724
|
+
EXPECTED59,
|
|
35725
|
+
unreadPageTextReason(ctx.evidence)
|
|
35726
|
+
),
|
|
35727
|
+
displayValue: found,
|
|
35728
|
+
details
|
|
35729
|
+
};
|
|
35730
|
+
}
|
|
34861
35731
|
return {
|
|
34862
35732
|
...this.pass(
|
|
34863
35733
|
"No third-party origin ships executable code into the page, so nothing but the site itself writes what an agent reads.",
|
|
@@ -34996,6 +35866,7 @@ var UnsafeAgentTriggerableAffordancesAudit = class _UnsafeAgentTriggerableAfford
|
|
|
34996
35866
|
evidenceGrade: "B",
|
|
34997
35867
|
tier: "scored",
|
|
34998
35868
|
dossier: "docs/evidence/audits/operability-safety/unsafe-agent-triggerable-affordances.md",
|
|
35869
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
34999
35870
|
defaultPriority: "critical",
|
|
35000
35871
|
guidance: {
|
|
35001
35872
|
impact: "An agent exploring a site follows links, and a link that changes state changes it on the first fetch \u2014 no click, no intent, no confirmation. The same property makes the site a target for indirect prompt injection: text on a page can name the URL, and an agent that reads it as an instruction performs the action with the user's own session. Disallowing the path in robots.txt is only a partial mitigation, because a user-initiated fetch is documented as not necessarily bound by robots.txt. The underlying rule is older than agents: a GET is a safe method, meaning it must not have side effects, and everything here is a violation of that rule that agents simply make expensive.",
|
|
@@ -35014,6 +35885,13 @@ var UnsafeAgentTriggerableAffordancesAudit = class _UnsafeAgentTriggerableAfford
|
|
|
35014
35885
|
};
|
|
35015
35886
|
}
|
|
35016
35887
|
audit(ctx) {
|
|
35888
|
+
if (!scanReadTheSite(ctx.evidence)) {
|
|
35889
|
+
return this.notApplicable(
|
|
35890
|
+
"No page here can be attributed to this site, so its links were not inspected.",
|
|
35891
|
+
EXPECTED60,
|
|
35892
|
+
unreadSiteReason(ctx.evidence)
|
|
35893
|
+
);
|
|
35894
|
+
}
|
|
35017
35895
|
if (ctx.pages.length === 0) {
|
|
35018
35896
|
return this.notApplicable(
|
|
35019
35897
|
"No page was fetched, so there is no link to inspect.",
|
|
@@ -35032,6 +35910,16 @@ var UnsafeAgentTriggerableAffordancesAudit = class _UnsafeAgentTriggerableAfford
|
|
|
35032
35910
|
urls: findings.slice(0, 10).map((f) => f.href)
|
|
35033
35911
|
};
|
|
35034
35912
|
if (findings.length === 0) {
|
|
35913
|
+
if (!scanReadPageText(ctx.evidence)) {
|
|
35914
|
+
return {
|
|
35915
|
+
...this.notApplicable(
|
|
35916
|
+
"The scanned page served no readable text, so it exposed no links or forms to inspect.",
|
|
35917
|
+
EXPECTED60,
|
|
35918
|
+
unreadPageTextReason(ctx.evidence)
|
|
35919
|
+
),
|
|
35920
|
+
details
|
|
35921
|
+
};
|
|
35922
|
+
}
|
|
35035
35923
|
return {
|
|
35036
35924
|
...this.pass(
|
|
35037
35925
|
"No link or GET form on the scanned pages changes state when it is fetched.",
|
|
@@ -35133,6 +36021,7 @@ var ReflectedParameterInjectionCanaryAudit = class extends Audit {
|
|
|
35133
36021
|
weight: weightForGrade("B", "scored"),
|
|
35134
36022
|
defaultPriority: "critical",
|
|
35135
36023
|
dossier: "docs/evidence/audits/operability-safety/reflected-parameter-injection-canary.md",
|
|
36024
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
35136
36025
|
guidance: {
|
|
35137
36026
|
impact: "Agents and answer engines weight a source by domain authority, and a reflected-input URL passes human inspection because the hostname is genuine. If attacker-controlled query or path input lands in the page's own title, meta description, canonical link, or JSON-LD strings, the domain becomes a self-serve injection host: the attacker does not need to compromise anything, only to share a link. Reflection into rendered text is the same defect one step down, and it is only contained while the page stays out of an index.",
|
|
35138
36027
|
fix: 'Escape URL-derived input before it reaches any template, and keep it out of `<title>`, `<meta name="description">`, `og:description`, `rel="canonical"` and JSON-LD entirely \u2014 those fields should describe the page, not the request. Where a search page must echo the query back to the visitor, render it as escaped text inside the body and mark the page `noindex`.',
|
|
@@ -35401,6 +36290,7 @@ var UgcTrustBoundaryMarkersAudit = class extends Audit {
|
|
|
35401
36290
|
weight: weightForGrade("B", "scored"),
|
|
35402
36291
|
defaultPriority: "high",
|
|
35403
36292
|
dossier: "docs/evidence/audits/operability-safety/ugc-trust-boundary-markers.md",
|
|
36293
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
35404
36294
|
guidance: {
|
|
35405
36295
|
impact: "Attacker-controllable text sits in the same DOM as first-party copy with no boundary, so anything a visitor types reads, to a fetching agent, as a statement the domain made. Google excludes text inside a `data-nosnippet` span, div or section from snippets across web search, Discover and AI Overviews, and includes everything outside it. The sanitizer arm matters most: if a comment body can carry an inline style or an iframe, hiding an instruction inside visitor text becomes self-serve on this site.",
|
|
35406
36296
|
fix: 'Wrap each visitor-written region in a `<div data-nosnippet>` \u2014 the attribute is honoured on span, div and section only \u2014 and add `rel="ugc"` to links inside it. Strip inline `style`, `iframe`, `script` and remote `img` from submitted markup at render time rather than at submit time, so already-stored content is covered too.',
|
|
@@ -35522,6 +36412,7 @@ var AgentUaContentDivergenceDiffAudit = class extends Audit {
|
|
|
35522
36412
|
weight: weightForGrade("B", "scored"),
|
|
35523
36413
|
defaultPriority: "high",
|
|
35524
36414
|
dossier: "docs/evidence/audits/operability-safety/agent-ua-content-divergence-diff.md",
|
|
36415
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
35525
36416
|
guidance: {
|
|
35526
36417
|
impact: "An agent that reads a different page from the one a human sees cannot be checked by the human it answers to. Where the crawler copy is thinner, the answer engine quotes a page the visitor will never find; where it carries text the browser copy does not, the site is speaking to the model privately \u2014 which is the delivery mechanism for every instruction-injection attack that does not need a compromise. A JSON-LD block that differs between variants is the same problem in the field a machine trusts most.",
|
|
35527
36418
|
fix: "Serve one document to every User-Agent. Where a bot-management rule reduces the page for unknown clients, allow the published AI-crawler UAs through it rather than branching on them, and keep the JSON-LD identical across variants. If a crawler should not read the site at all, block it in robots.txt and at the edge rather than serving it a different story.",
|
|
@@ -35892,6 +36783,7 @@ var C2paManifestSurvivesDeliveryAudit = class extends Audit {
|
|
|
35892
36783
|
weight: weightForGrade("B", "scored"),
|
|
35893
36784
|
defaultPriority: "medium",
|
|
35894
36785
|
dossier: "docs/evidence/audits/operability-safety/c2pa-manifest-survives-delivery.md",
|
|
36786
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
35895
36787
|
guidance: {
|
|
35896
36788
|
impact: "Signing an image at creation proves nothing if the bytes a crawler downloads are unsigned. Image transformation layers discard Content Credentials by default \u2014 Cloudflare states outright that with preservation disabled, existing Content Credentials are always discarded \u2014 so the publisher sees signed assets in their library while every consumer sees stripped ones. The provenance work is done and none of it reaches the reader.",
|
|
35897
36789
|
fix: "Turn on Content Credentials preservation in the image pipeline (Cloudflare Images has an explicit setting; Next.js image optimization and most CDN resizers need the manifest copied through or the asset served unoptimized). Verify by fetching the URL the page actually renders, not the asset in the library.",
|
|
@@ -36041,6 +36933,7 @@ var C2paSignerTrustStatusAudit = class extends Audit {
|
|
|
36041
36933
|
weight: weightForGrade("B", "scored"),
|
|
36042
36934
|
defaultPriority: "medium",
|
|
36043
36935
|
dossier: "docs/evidence/audits/operability-safety/c2pa-signer-trust-status.md",
|
|
36936
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
36044
36937
|
guidance: {
|
|
36045
36938
|
impact: "A manifest that exists is not a manifest that verifies. A conforming C2PA validator resolves the signing certificate against the published Trust List and shows the credential as untrusted when it cannot \u2014 which is what a self-signed certificate always produces, and what an expired one produces the day it lapses. The publisher sees Content Credentials on every asset; the consumer sees a warning, or nothing at all.",
|
|
36046
36939
|
fix: "Sign with a certificate from a CA on the C2PA Trust List rather than a self-signed one, renew before it expires, and include an RFC 3161 timestamp so credentials stay valid past the certificate\u2019s own expiry.",
|
|
@@ -36219,6 +37112,7 @@ var OrganizationIdentifierRegistryResolutionAudit = class extends Audit {
|
|
|
36219
37112
|
weight: weightForGrade("B", "scored"),
|
|
36220
37113
|
defaultPriority: "medium",
|
|
36221
37114
|
dossier: "docs/evidence/audits/operability-safety/organization-identifier-registry-resolution.md",
|
|
37115
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
36222
37116
|
guidance: {
|
|
36223
37117
|
impact: "A shopping or payment agent transacting with an unfamiliar merchant needs one thing no amount of markup can self-assert: a legal identity it can check against an authority. The LEI is the only schema.org organization identifier backed by a free, queryable, authoritative registry, which makes it the only one whose truth an outside party can establish. An identifier that resolves to nothing, or to a lapsed registration, or to a different legal name, is worse than none: it looks like verification and is not.",
|
|
36224
37118
|
fix: 'Publish the LEI as `iso6523Code: "0199:<LEI>"` \u2014 Google documents a preference for the prefixed form over bare `leiCode` \u2014 keep the GLEIF registration renewed so its status stays ISSUED, and make sure the `legalName` in your markup is the name GLEIF has on record, not the trading name.',
|
|
@@ -36432,6 +37326,7 @@ var SyntheticMediaDisclosureValidityAudit = class extends Audit {
|
|
|
36432
37326
|
weight: weightForGrade("B", "scored"),
|
|
36433
37327
|
defaultPriority: "medium",
|
|
36434
37328
|
dossier: "docs/evidence/audits/operability-safety/synthetic-media-disclosure-validity.md",
|
|
37329
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
36435
37330
|
guidance: {
|
|
36436
37331
|
impact: "Disclosure only counts if a machine can read it. IPTC types `DigitalSourceType` as a URI from a controlled vocabulary, so a consumer matching against that vocabulary silently ignores `AI-generated`, a bare `trainedAlgorithmicMedia`, or an `https://` spelling of the `http://` vocabulary URI. The publisher believes the image is disclosed; every machine reader sees an undisclosed image. Worse is an asset whose XMP and C2PA manifest disagree about whether a human took the photo \u2014 two provenance channels, one of them wrong.",
|
|
36437
37332
|
fix: "Write the full vocabulary URI, exactly: `http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia`. Keep the `http` scheme the vocabulary itself uses, no trailing slash, no free text, and make sure the value agrees with the digital source type asserted in the asset\u2019s C2PA manifest.",
|
|
@@ -36601,6 +37496,7 @@ var TrustTxtReciprocityCoherenceAudit = class extends Audit {
|
|
|
36601
37496
|
weight: 0,
|
|
36602
37497
|
defaultPriority: "low",
|
|
36603
37498
|
dossier: "docs/evidence/audits/operability-safety/trust-txt-reciprocity-coherence.md",
|
|
37499
|
+
requires: ["origin-reachable"],
|
|
36604
37500
|
guidance: {
|
|
36605
37501
|
impact: "trust.txt association attributes are defined as reciprocal: `belongto=<association>` means something only if that association\u2019s own trust.txt carries `member=<this domain>`. That makes the claim checkable rather than self-asserted, which is the whole point of publishing it. Separately, `datatrainingallowed=no` beside a robots.txt that leaves GPTBot and ClaudeBot free to crawl states two opposite policies, and the channel that actually gates crawlers is the one that says yes. Adoption caveat: no AI engine, answer engine or crawler is documented as reading trust.txt.",
|
|
36606
37502
|
fix: "Ask each association you claim to belong to for a reciprocal `member=` line, drop the ones that will not reciprocate, and make `datatrainingallowed=` say the same thing your robots.txt AI-bot groups say.",
|
|
@@ -36793,6 +37689,7 @@ var WikidataRoundTripVerificationAudit = class extends Audit {
|
|
|
36793
37689
|
weight: weightForGrade("B", "scored"),
|
|
36794
37690
|
defaultPriority: "medium",
|
|
36795
37691
|
dossier: "docs/evidence/audits/operability-safety/wikidata-round-trip-verification.md",
|
|
37692
|
+
requires: ["origin-reachable", "unblocked-fetches", "rendered-body", "sample-adequate"],
|
|
36796
37693
|
guidance: {
|
|
36797
37694
|
impact: "A knowledge-graph consumer that grounds a brand to an entity needs corroboration from the authority side, because `sameAs` carries no reciprocity requirement \u2014 Google documents it as a link to a page with more information, nothing more. Wikidata publishes that corroboration for free as P856. A claim whose entity points at an unrelated domain is either the wrong entity or an unbacked identity claim, and an answer engine that resolves it grounds the brand to somebody else.",
|
|
36798
37695
|
fix: "Claim the entity that really is your organization, and make sure the Wikidata item carries your domain as its official website (P856). If the item has no P856 at all, add one: until it does, the claim cannot be corroborated by anyone.",
|
|
@@ -36997,6 +37894,7 @@ function outcomeOf(check) {
|
|
|
36997
37894
|
const tags = check.tags ?? [];
|
|
36998
37895
|
if (tags.includes(TAG_SCAN_ERROR)) return "error";
|
|
36999
37896
|
if (tags.includes(TAG_SKIPPED_PAGE_TYPE)) return "skipped";
|
|
37897
|
+
if (tags.includes(TAG_SKIPPED_NO_EVIDENCE)) return "gated";
|
|
37000
37898
|
return "ran";
|
|
37001
37899
|
}
|
|
37002
37900
|
function traceFromCheck(check, durationMs) {
|
|
@@ -37058,7 +37956,31 @@ function stubCheck(meta2, tag2, explanation) {
|
|
|
37058
37956
|
tier: meta2.tier
|
|
37059
37957
|
};
|
|
37060
37958
|
}
|
|
37061
|
-
function
|
|
37959
|
+
function unmetRequirements(ctx, meta2) {
|
|
37960
|
+
const required = meta2.requires ?? [];
|
|
37961
|
+
if (required.length === 0) return [];
|
|
37962
|
+
const evidence = ctx.evidence;
|
|
37963
|
+
const unmet = [];
|
|
37964
|
+
for (const key2 of required) {
|
|
37965
|
+
if (key2 === "sample-adequate") {
|
|
37966
|
+
const wanted = meta2.applicablePageTypes?.length ? meta2.applicablePageTypes : ["homepage"];
|
|
37967
|
+
if (!wanted.some((type) => evidence.usablePageTypes.has(type))) unmet.push(key2);
|
|
37968
|
+
continue;
|
|
37969
|
+
}
|
|
37970
|
+
if (!evidence.met[key2]) unmet.push(key2);
|
|
37971
|
+
}
|
|
37972
|
+
return unmet;
|
|
37973
|
+
}
|
|
37974
|
+
function gateExplanation(ctx, meta2, unmet) {
|
|
37975
|
+
const reasons = unmet.map((key2) => ctx.evidence.reasons[key2]).filter(Boolean);
|
|
37976
|
+
if (unmet.includes("sample-adequate") && reasons.length === 0) {
|
|
37977
|
+
const wanted = meta2.applicablePageTypes?.length ? meta2.applicablePageTypes.join("/") : "homepage";
|
|
37978
|
+
return `Not assessed: no scanned ${wanted} page served readable text.`;
|
|
37979
|
+
}
|
|
37980
|
+
const why = reasons.length > 0 ? ` ${reasons.join(" ")}` : "";
|
|
37981
|
+
return `Not assessed: this scan has no ${unmet.join(", ")} evidence.${why}`;
|
|
37982
|
+
}
|
|
37983
|
+
function planAudits(ctx, config, options = {}) {
|
|
37062
37984
|
const scannedPageTypes = new Set(ctx.pages.map((p) => p.pageType));
|
|
37063
37985
|
const runnable = [];
|
|
37064
37986
|
const skipped = [];
|
|
@@ -37078,6 +38000,15 @@ function planAudits(ctx, config) {
|
|
|
37078
38000
|
continue;
|
|
37079
38001
|
}
|
|
37080
38002
|
}
|
|
38003
|
+
if (options.enforceEvidence) {
|
|
38004
|
+
const unmet = unmetRequirements(ctx, reg2.meta);
|
|
38005
|
+
if (unmet.length > 0) {
|
|
38006
|
+
skipped.push(
|
|
38007
|
+
stubCheck(reg2.meta, TAG_SKIPPED_NO_EVIDENCE, gateExplanation(ctx, reg2.meta, unmet))
|
|
38008
|
+
);
|
|
38009
|
+
continue;
|
|
38010
|
+
}
|
|
38011
|
+
}
|
|
37081
38012
|
runnable.push({ reg: reg2, categoryId: cat.id });
|
|
37082
38013
|
}
|
|
37083
38014
|
}
|
|
@@ -37400,6 +38331,18 @@ function detectWafProtection(targetUrl, homepageResult, rootFiles, scannedPagesC
|
|
|
37400
38331
|
|
|
37401
38332
|
// src/orchestrator.ts
|
|
37402
38333
|
var A11Y_MAX_PAGES = Math.max(0, Number(process.env.SCANNER_A11Y_MAX_PAGES ?? 3));
|
|
38334
|
+
var RATE_LIMIT_BACKOFF_MS = 5e3;
|
|
38335
|
+
var MAX_RETRY_AFTER_MS = 3e4;
|
|
38336
|
+
async function fetchHomepage(fetcher, url, signal) {
|
|
38337
|
+
const first5 = await fetcher.fetch({ url, signal });
|
|
38338
|
+
if (first5.status !== 429) return first5;
|
|
38339
|
+
const header = Number(first5.headers["retry-after"]);
|
|
38340
|
+
const waitMs = Number.isFinite(header) && header > 0 ? Math.min(header * 1e3, MAX_RETRY_AFTER_MS) : RATE_LIMIT_BACKOFF_MS;
|
|
38341
|
+
logger.debug({ url, waitMs }, `[orchestrator] Homepage answered 429; retrying once in ${waitMs}ms`);
|
|
38342
|
+
await new Promise((resolve4) => setTimeout(resolve4, waitMs));
|
|
38343
|
+
signal?.throwIfAborted();
|
|
38344
|
+
return fetcher.fetch({ url, signal });
|
|
38345
|
+
}
|
|
37403
38346
|
function discoverPages(homepageUrl, domain, rootFiles, homepage$, exclude, maxAdditional) {
|
|
37404
38347
|
const discovered = /* @__PURE__ */ new Set();
|
|
37405
38348
|
const sitemapBody = rootFiles["/sitemap.xml"]?.status === 200 ? rootFiles["/sitemap.xml"].body : rootFiles["/sitemap-index.xml"]?.status === 200 ? rootFiles["/sitemap-index.xml"].body : "";
|
|
@@ -37492,7 +38435,10 @@ async function runScan(url, options) {
|
|
|
37492
38435
|
const signal = options?.signal;
|
|
37493
38436
|
const tracker = new ProgressTracker((event) => onEvent?.(event));
|
|
37494
38437
|
const start = performance.now();
|
|
37495
|
-
const fetcher = createFetcher(
|
|
38438
|
+
const fetcher = createFetcher({
|
|
38439
|
+
dispatcher: options?.dispatcher,
|
|
38440
|
+
maxConcurrent: options?.maxConcurrent
|
|
38441
|
+
});
|
|
37496
38442
|
const baseUrl = new URL(url).origin;
|
|
37497
38443
|
const domain = new URL(url).hostname;
|
|
37498
38444
|
const displayUrl = splitCredentials(url).url;
|
|
@@ -37550,13 +38496,18 @@ async function runScan(url, options) {
|
|
|
37550
38496
|
];
|
|
37551
38497
|
logger.debug({ count: rootFilePaths.length }, "[orchestrator] Phase 1: Fetching root files");
|
|
37552
38498
|
tracker.phaseStart("fetch-root", rootFilePaths.length);
|
|
38499
|
+
const prefetchedRobots = options?.robotsTxt;
|
|
37553
38500
|
const rootResults = await Promise.all(
|
|
37554
|
-
rootFilePaths.map(
|
|
37555
|
-
(path
|
|
38501
|
+
rootFilePaths.map((path) => {
|
|
38502
|
+
if (path === "/robots.txt" && prefetchedRobots) {
|
|
38503
|
+
tracker.unitDone(path);
|
|
38504
|
+
return Promise.resolve(prefetchedRobots);
|
|
38505
|
+
}
|
|
38506
|
+
return fetcher.fetch({ url: `${baseUrl}${path}`, signal }).then((result) => {
|
|
37556
38507
|
tracker.unitDone(path);
|
|
37557
38508
|
return result;
|
|
37558
|
-
})
|
|
37559
|
-
)
|
|
38509
|
+
});
|
|
38510
|
+
})
|
|
37560
38511
|
);
|
|
37561
38512
|
const rootFiles = {};
|
|
37562
38513
|
rootFilePaths.forEach((path, i) => {
|
|
@@ -37567,7 +38518,7 @@ async function runScan(url, options) {
|
|
|
37567
38518
|
signal?.throwIfAborted();
|
|
37568
38519
|
logger.debug("[orchestrator] Phase 2: Fetching pages");
|
|
37569
38520
|
tracker.phaseStart("fetch-pages", 1);
|
|
37570
|
-
const homepageResult = await fetcher
|
|
38521
|
+
const homepageResult = await fetchHomepage(fetcher, url, signal);
|
|
37571
38522
|
tracker.unitDone(displayUrl);
|
|
37572
38523
|
const homepage$ = homepageResult.status === 200 && homepageResult.body ? parseHtml(homepageResult.body) : null;
|
|
37573
38524
|
const discoverLimit = Math.max(0, MAX_PAGES_PER_SCAN - 1 - overrideUrls.length);
|
|
@@ -37626,19 +38577,29 @@ async function runScan(url, options) {
|
|
|
37626
38577
|
signal?.throwIfAborted();
|
|
37627
38578
|
logger.debug("[orchestrator] Phase 3: Running audits");
|
|
37628
38579
|
const wafProtection = detectWafProtection(url, homepageResult, rootFiles, pages.length);
|
|
38580
|
+
const evidence = buildScanEvidence({
|
|
38581
|
+
requestedUrl: url,
|
|
38582
|
+
homepageResult,
|
|
38583
|
+
pages,
|
|
38584
|
+
rootFiles,
|
|
38585
|
+
wafProtection: wafProtection ?? null
|
|
38586
|
+
});
|
|
37629
38587
|
const ctx = {
|
|
37630
38588
|
rootFiles,
|
|
37631
38589
|
pages,
|
|
37632
38590
|
domain,
|
|
37633
38591
|
baseUrl,
|
|
37634
38592
|
fetch: (options2) => fetcher.fetch({ ...options2, signal }),
|
|
37635
|
-
wafProtection: wafProtection ?? void 0
|
|
38593
|
+
wafProtection: wafProtection ?? void 0,
|
|
38594
|
+
evidence
|
|
37636
38595
|
};
|
|
37637
38596
|
const config = filterConfig(defaultConfig, {
|
|
37638
38597
|
categories: options?.categories,
|
|
37639
38598
|
includeExperimental: options?.includeExperimental ?? false
|
|
37640
38599
|
});
|
|
37641
|
-
const auditPlan = planAudits(ctx, config
|
|
38600
|
+
const auditPlan = planAudits(ctx, config, {
|
|
38601
|
+
enforceEvidence: options?.enforceEvidenceGate ?? true
|
|
38602
|
+
});
|
|
37642
38603
|
tracker.phaseStart("audits", auditPlan.runnable.length);
|
|
37643
38604
|
const {
|
|
37644
38605
|
checks: allChecks,
|
|
@@ -37659,7 +38620,7 @@ async function runScan(url, options) {
|
|
|
37659
38620
|
tracker.phaseStart("report", 1);
|
|
37660
38621
|
logger.debug("[orchestrator] Phase 4: Building final report");
|
|
37661
38622
|
const durationMs = Math.round(performance.now() - start);
|
|
37662
|
-
const recommendations = allChecks.filter((c) => c.status
|
|
38623
|
+
const recommendations = allChecks.filter((c) => (c.status === "fail" || c.status === "warn") && !isInformative(c)).slice().sort((a, b) => {
|
|
37663
38624
|
const order = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
37664
38625
|
return (order[a.priority] ?? 3) - (order[b.priority] ?? 3);
|
|
37665
38626
|
});
|
|
@@ -37671,13 +38632,23 @@ async function runScan(url, options) {
|
|
|
37671
38632
|
const readinessScore = Math.round(
|
|
37672
38633
|
readinessVitals.commerce * READINESS_WEIGHTS.commerce + readinessVitals.content * READINESS_WEIGHTS.content + readinessVitals.botAccessibility * READINESS_WEIGHTS.botAccessibility + readinessVitals.technical * READINESS_WEIGHTS.technical
|
|
37673
38634
|
);
|
|
38635
|
+
const gatedShare = gatedMassShare(allChecks);
|
|
38636
|
+
const escalated = gatedShare > GATED_MASS_UNSCORED_THRESHOLD;
|
|
38637
|
+
const unscoredReason = !evidence.judgeable ? Object.values(evidence.reasons).filter(Boolean).join(" ") || "The scan obtained too little evidence to judge this site." : escalated ? `The scan could not feed ${Math.round(gatedShare * 100)}% of the registry's evidence mass, so what remains is not a reading of this site.` : void 0;
|
|
38638
|
+
const scored = unscoredReason === void 0;
|
|
37674
38639
|
const report = {
|
|
37675
38640
|
scanId: "",
|
|
37676
38641
|
// Set by the caller
|
|
37677
38642
|
url: displayUrl,
|
|
37678
38643
|
domain,
|
|
37679
|
-
overallScore,
|
|
37680
|
-
scoreTier: getScoreTier(overallScore),
|
|
38644
|
+
overallScore: scored ? overallScore : null,
|
|
38645
|
+
scoreTier: scored ? getScoreTier(overallScore) : null,
|
|
38646
|
+
scanValidity: {
|
|
38647
|
+
judgeable: evidence.judgeable,
|
|
38648
|
+
evidence: evidence.met,
|
|
38649
|
+
reasons: evidence.reasons,
|
|
38650
|
+
...unscoredReason ? { unscoredReason } : {}
|
|
38651
|
+
},
|
|
37681
38652
|
summary: "",
|
|
37682
38653
|
// Set below
|
|
37683
38654
|
categories,
|
|
@@ -37698,7 +38669,7 @@ async function runScan(url, options) {
|
|
|
37698
38669
|
report.summary = generateScanSummary(report);
|
|
37699
38670
|
tracker.unitDone();
|
|
37700
38671
|
tracker.phaseDone();
|
|
37701
|
-
tracker.scanDone(overallScore);
|
|
38672
|
+
tracker.scanDone(report.overallScore);
|
|
37702
38673
|
logger.debug({ durationMs, score: overallScore }, "[orchestrator] runScan complete");
|
|
37703
38674
|
return report;
|
|
37704
38675
|
}
|
|
@@ -37868,6 +38839,7 @@ function loadConfigFile(customPath) {
|
|
|
37868
38839
|
DEFAULT_SCAN_LIMIT,
|
|
37869
38840
|
DeprecationNoticeSchema,
|
|
37870
38841
|
EvidenceGradeSchema,
|
|
38842
|
+
EvidenceKeySchema,
|
|
37871
38843
|
FixEffortSchema,
|
|
37872
38844
|
MAX_CONCURRENT_REQUESTS,
|
|
37873
38845
|
MAX_PAGES_PER_SCAN,
|
|
@@ -37883,9 +38855,13 @@ function loadConfigFile(customPath) {
|
|
|
37883
38855
|
SCORE_TIER_LABELS,
|
|
37884
38856
|
ScoreDisplayModeSchema,
|
|
37885
38857
|
TAG_SCAN_ERROR,
|
|
38858
|
+
TAG_SKIPPED_NO_EVIDENCE,
|
|
37886
38859
|
TAG_SKIPPED_PAGE_TYPE,
|
|
38860
|
+
allEvidenceMet,
|
|
37887
38861
|
allJsonLdNodes,
|
|
38862
|
+
boundedDispatcher,
|
|
37888
38863
|
buildCategoryResult,
|
|
38864
|
+
buildScanEvidence,
|
|
37889
38865
|
calculateCategoryScore,
|
|
37890
38866
|
calculateOverallScore,
|
|
37891
38867
|
classifyFetch,
|
|
@@ -37916,6 +38892,7 @@ function loadConfigFile(customPath) {
|
|
|
37916
38892
|
formatTrace,
|
|
37917
38893
|
getMainContentText,
|
|
37918
38894
|
getPreset,
|
|
38895
|
+
getRenderedText,
|
|
37919
38896
|
getScoreTier,
|
|
37920
38897
|
getTierColor,
|
|
37921
38898
|
getTierLabel,
|