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