@goflag/cli 0.2.13 → 0.2.15
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/README.md +7 -7
- package/dist/{chunk-57NVXEJS.js → chunk-KHIN5TNX.js} +818 -221
- package/dist/chunk-KHIN5TNX.js.map +1 -0
- package/dist/cli.js +63 -1
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +1 -1
- package/package.json +2 -2
- package/rules.json +116 -2
- package/dist/chunk-57NVXEJS.js.map +0 -1
|
@@ -25,6 +25,25 @@ function fingerprint(category, ...parts) {
|
|
|
25
25
|
return `${category}-${digest}`;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// src/lib/core/canonicalise.ts
|
|
29
|
+
function canonicaliseUrl(href, base, options = {}) {
|
|
30
|
+
let url;
|
|
31
|
+
try {
|
|
32
|
+
url = new URL(href, base);
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
37
|
+
url.hash = "";
|
|
38
|
+
if (!options.preserveTrailingSlash && url.pathname.length > 1 && url.pathname.endsWith("/")) {
|
|
39
|
+
url.pathname = url.pathname.slice(0, -1);
|
|
40
|
+
}
|
|
41
|
+
return url.toString();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// src/lib/core/inspect.ts
|
|
45
|
+
import * as cheerio3 from "cheerio";
|
|
46
|
+
|
|
28
47
|
// src/lib/core/extract/static.ts
|
|
29
48
|
import * as cheerio from "cheerio";
|
|
30
49
|
|
|
@@ -105,7 +124,9 @@ function decodeHtmlEntities(s) {
|
|
|
105
124
|
|
|
106
125
|
// src/lib/core/extract/static.ts
|
|
107
126
|
function extractStatic(html, options) {
|
|
108
|
-
|
|
127
|
+
return extractStaticFrom(cheerio.load(html), options);
|
|
128
|
+
}
|
|
129
|
+
function extractStaticFrom($, options) {
|
|
109
130
|
const base = resolveBase($, options.baseUrl);
|
|
110
131
|
const raw = extractRaw($);
|
|
111
132
|
const meta = parseGenericMeta(raw, base);
|
|
@@ -233,8 +254,8 @@ function parseGenericMeta(raw, base) {
|
|
|
233
254
|
for (const l of raw.links) {
|
|
234
255
|
const rel = l.rel?.toLowerCase();
|
|
235
256
|
if (rel === "canonical" && l.href) {
|
|
236
|
-
const
|
|
237
|
-
meta.canonical = sourced(
|
|
257
|
+
const resolved2 = safeResolve(l.href, base);
|
|
258
|
+
meta.canonical = sourced(resolved2, { kind: "link", rel: "canonical" }, l.href);
|
|
238
259
|
}
|
|
239
260
|
}
|
|
240
261
|
return meta;
|
|
@@ -952,12 +973,98 @@ function countEntries(body) {
|
|
|
952
973
|
return url + sm;
|
|
953
974
|
}
|
|
954
975
|
|
|
976
|
+
// src/lib/core/links/extract.ts
|
|
977
|
+
import * as cheerio2 from "cheerio";
|
|
978
|
+
var ANCHOR_SPEC = { selector: "a[href]", attr: "href", source: "a" };
|
|
979
|
+
var ASSET_SPECS = [
|
|
980
|
+
{ selector: "img[src]", attr: "src", source: "img" },
|
|
981
|
+
{ selector: "script[src]", attr: "src", source: "script" },
|
|
982
|
+
{ selector: "link[href]", attr: "href", source: "link" },
|
|
983
|
+
{ selector: "iframe[src]", attr: "src", source: "iframe" }
|
|
984
|
+
];
|
|
985
|
+
function extractLinks(html, options) {
|
|
986
|
+
return extractLinksFrom(cheerio2.load(html), options);
|
|
987
|
+
}
|
|
988
|
+
function extractLinksFrom($, options) {
|
|
989
|
+
const base = resolveBase2($, options.baseUrl);
|
|
990
|
+
const origin = safeOrigin(options.baseUrl);
|
|
991
|
+
const specs = options.includeAssets ? [ANCHOR_SPEC, ...ASSET_SPECS] : [ANCHOR_SPEC];
|
|
992
|
+
const out = [];
|
|
993
|
+
const seen = /* @__PURE__ */ new Set();
|
|
994
|
+
for (const spec of specs) {
|
|
995
|
+
$(spec.selector).each((_, el) => {
|
|
996
|
+
const raw = $(el).attr(spec.attr);
|
|
997
|
+
if (raw === void 0) return;
|
|
998
|
+
const rawHref = raw.trim();
|
|
999
|
+
if (!rawHref) return;
|
|
1000
|
+
const ref = buildRef($, el, rawHref, spec, base, origin);
|
|
1001
|
+
if (!ref) return;
|
|
1002
|
+
const dedupeKey = `${ref.source}|${ref.url}`;
|
|
1003
|
+
if (seen.has(dedupeKey)) return;
|
|
1004
|
+
seen.add(dedupeKey);
|
|
1005
|
+
out.push(ref);
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
return out;
|
|
1009
|
+
}
|
|
1010
|
+
function buildRef($, el, rawHref, spec, base, origin) {
|
|
1011
|
+
const fragment = extractFragment(rawHref);
|
|
1012
|
+
const canonical = canonicaliseUrl(rawHref, base, { preserveTrailingSlash: true });
|
|
1013
|
+
const url = canonical ?? rawHref;
|
|
1014
|
+
const kind = canonical ? classify(canonical, origin) : "external";
|
|
1015
|
+
const ref = {
|
|
1016
|
+
rawHref,
|
|
1017
|
+
url,
|
|
1018
|
+
kind,
|
|
1019
|
+
source: spec.source,
|
|
1020
|
+
rel: spec.source === "a" || spec.source === "link" ? parseRel($(el).attr("rel")) : []
|
|
1021
|
+
};
|
|
1022
|
+
if (spec.source === "a") {
|
|
1023
|
+
const text = $(el).text().trim();
|
|
1024
|
+
if (text) ref.anchorText = text;
|
|
1025
|
+
}
|
|
1026
|
+
if (fragment) ref.fragment = fragment;
|
|
1027
|
+
return ref;
|
|
1028
|
+
}
|
|
1029
|
+
function extractFragment(rawHref) {
|
|
1030
|
+
const hash = rawHref.indexOf("#");
|
|
1031
|
+
if (hash === -1) return void 0;
|
|
1032
|
+
const frag = rawHref.slice(hash);
|
|
1033
|
+
return frag.length > 1 ? frag : void 0;
|
|
1034
|
+
}
|
|
1035
|
+
function parseRel(rel) {
|
|
1036
|
+
if (!rel) return [];
|
|
1037
|
+
return rel.split(/\s+/).map((t) => t.trim().toLowerCase()).filter(Boolean);
|
|
1038
|
+
}
|
|
1039
|
+
function classify(url, origin) {
|
|
1040
|
+
if (!origin) return "external";
|
|
1041
|
+
return safeOrigin(url) === origin ? "internal" : "external";
|
|
1042
|
+
}
|
|
1043
|
+
function safeOrigin(url) {
|
|
1044
|
+
try {
|
|
1045
|
+
return new URL(url).origin;
|
|
1046
|
+
} catch {
|
|
1047
|
+
return null;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
function resolveBase2($, pageUrl) {
|
|
1051
|
+
const declared = $("base[href]").first().attr("href")?.trim();
|
|
1052
|
+
if (declared) {
|
|
1053
|
+
try {
|
|
1054
|
+
return new URL(declared, pageUrl).toString();
|
|
1055
|
+
} catch {
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
return pageUrl;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
955
1061
|
// src/lib/core/inspect.ts
|
|
956
1062
|
async function inspect(url, options = {}) {
|
|
957
1063
|
const {
|
|
958
1064
|
probes: enableProbes = true,
|
|
959
1065
|
mode = "auto",
|
|
960
1066
|
headless: headlessOpts,
|
|
1067
|
+
linkRefs: wantLinkRefs = false,
|
|
961
1068
|
...fetchOptions
|
|
962
1069
|
} = options;
|
|
963
1070
|
const fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -971,7 +1078,8 @@ async function inspect(url, options = {}) {
|
|
|
971
1078
|
});
|
|
972
1079
|
}
|
|
973
1080
|
const { meta: staticMeta, body: staticBody } = await fetchStatic(url, fetchOptions);
|
|
974
|
-
const staticParsed =
|
|
1081
|
+
const { staticParsed, linkRefs } = parseStatic(staticBody, staticMeta.finalUrl, wantLinkRefs);
|
|
1082
|
+
const withLinkRefs = linkRefs ? { linkRefs } : {};
|
|
975
1083
|
if (mode === "static") {
|
|
976
1084
|
const probes2 = await runProbes({
|
|
977
1085
|
enabled: enableProbes,
|
|
@@ -985,7 +1093,8 @@ async function inspect(url, options = {}) {
|
|
|
985
1093
|
fetch: staticMeta,
|
|
986
1094
|
extractor: { mode: "static", escalated: false },
|
|
987
1095
|
html: { static: staticBody },
|
|
988
|
-
probes: probes2
|
|
1096
|
+
probes: probes2,
|
|
1097
|
+
...withLinkRefs
|
|
989
1098
|
};
|
|
990
1099
|
}
|
|
991
1100
|
const heuristic = looksClientRendered(staticParsed);
|
|
@@ -1002,7 +1111,8 @@ async function inspect(url, options = {}) {
|
|
|
1002
1111
|
fetch: staticMeta,
|
|
1003
1112
|
extractor: { mode: "static", escalated: false },
|
|
1004
1113
|
html: { static: staticBody },
|
|
1005
|
-
probes: probes2
|
|
1114
|
+
probes: probes2,
|
|
1115
|
+
...withLinkRefs
|
|
1006
1116
|
};
|
|
1007
1117
|
}
|
|
1008
1118
|
let renderedBody;
|
|
@@ -1034,7 +1144,8 @@ async function inspect(url, options = {}) {
|
|
|
1034
1144
|
escalationBlocked: err.message
|
|
1035
1145
|
},
|
|
1036
1146
|
html: { static: staticBody },
|
|
1037
|
-
probes: probes2
|
|
1147
|
+
probes: probes2,
|
|
1148
|
+
...withLinkRefs
|
|
1038
1149
|
};
|
|
1039
1150
|
}
|
|
1040
1151
|
const renderedParsed = extractStatic(renderedBody, { baseUrl: renderedFetch.finalUrl });
|
|
@@ -1056,9 +1167,16 @@ async function inspect(url, options = {}) {
|
|
|
1056
1167
|
},
|
|
1057
1168
|
html: { static: staticBody, rendered: renderedBody },
|
|
1058
1169
|
hydration,
|
|
1059
|
-
probes
|
|
1170
|
+
probes,
|
|
1171
|
+
...withLinkRefs
|
|
1060
1172
|
};
|
|
1061
1173
|
}
|
|
1174
|
+
function parseStatic(html, baseUrl, wantLinkRefs) {
|
|
1175
|
+
const $ = cheerio3.load(html);
|
|
1176
|
+
const staticParsed = extractStaticFrom($, { baseUrl });
|
|
1177
|
+
if (!wantLinkRefs) return { staticParsed };
|
|
1178
|
+
return { staticParsed, linkRefs: extractLinksFrom($, { baseUrl }) };
|
|
1179
|
+
}
|
|
1062
1180
|
async function inspectHeadlessOnly(args) {
|
|
1063
1181
|
const { url, fetchedAt, enableProbes, fetchOptions, headlessOpts } = args;
|
|
1064
1182
|
const result = await extractHeadless(url, {
|
|
@@ -1209,60 +1327,74 @@ async function crawl(options) {
|
|
|
1209
1327
|
const recovered = [];
|
|
1210
1328
|
let truncated = false;
|
|
1211
1329
|
const seenFinal = /* @__PURE__ */ new Set();
|
|
1212
|
-
|
|
1330
|
+
const inspectItem = async (item) => {
|
|
1331
|
+
try {
|
|
1332
|
+
const page = await inspect(item.url, options.inspectOptions);
|
|
1333
|
+
return { kind: "ok", item, page };
|
|
1334
|
+
} catch (err) {
|
|
1335
|
+
return { kind: "err", item, error: err instanceof Error ? err.message : String(err) };
|
|
1336
|
+
}
|
|
1337
|
+
};
|
|
1338
|
+
const pending = [];
|
|
1339
|
+
const maxPending = concurrency * 4;
|
|
1340
|
+
let active = 0;
|
|
1341
|
+
let requestDone = () => void 0;
|
|
1342
|
+
const start = (item) => {
|
|
1343
|
+
active += 1;
|
|
1344
|
+
pending.push(
|
|
1345
|
+
inspectItem(item).finally(() => {
|
|
1346
|
+
active -= 1;
|
|
1347
|
+
requestDone();
|
|
1348
|
+
})
|
|
1349
|
+
);
|
|
1350
|
+
};
|
|
1351
|
+
while (queue.length > 0 || pending.length > 0) {
|
|
1213
1352
|
if (options.inspectOptions?.signal?.aborted) break;
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
return {
|
|
1221
|
-
kind: "err",
|
|
1222
|
-
item,
|
|
1223
|
-
error: err instanceof Error ? err.message : String(err)
|
|
1224
|
-
};
|
|
1225
|
-
}
|
|
1353
|
+
while (active < concurrency && pending.length < maxPending && queue.length > 0) {
|
|
1354
|
+
start(queue.shift());
|
|
1355
|
+
}
|
|
1356
|
+
const oldest = pending[0];
|
|
1357
|
+
const finished = new Promise((resolve3) => {
|
|
1358
|
+
requestDone = resolve3;
|
|
1226
1359
|
});
|
|
1227
|
-
const
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
}
|
|
1235
|
-
errors.push({ url: result.item.url, message: result.error });
|
|
1360
|
+
const oldestReady = await Promise.race([oldest.then(() => true), finished.then(() => false)]);
|
|
1361
|
+
if (!oldestReady) continue;
|
|
1362
|
+
const result = await pending.shift();
|
|
1363
|
+
if (result.kind === "err") {
|
|
1364
|
+
const attempt = (result.item.attempt ?? 0) + 1;
|
|
1365
|
+
if (attempt < MAX_ATTEMPTS) {
|
|
1366
|
+
queue.push({ ...result.item, attempt });
|
|
1236
1367
|
continue;
|
|
1237
1368
|
}
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1369
|
+
errors.push({ url: result.item.url, message: result.error });
|
|
1370
|
+
continue;
|
|
1371
|
+
}
|
|
1372
|
+
if (result.item.attempt) recovered.push(result.item.url);
|
|
1373
|
+
const finalUrl = canonicaliseUrl(result.page.fetch.finalUrl) ?? result.page.fetch.finalUrl;
|
|
1374
|
+
if (seenFinal.has(finalUrl)) continue;
|
|
1375
|
+
seenFinal.add(finalUrl);
|
|
1376
|
+
pages.push(result.page);
|
|
1377
|
+
if (options.onPage) {
|
|
1378
|
+
options.onPage(result.page, { visited: pages.length, queued: queue.length });
|
|
1379
|
+
}
|
|
1380
|
+
if (pages.length >= maxPages) {
|
|
1381
|
+
truncated = true;
|
|
1382
|
+
break;
|
|
1383
|
+
}
|
|
1384
|
+
if (result.item.depth >= depthLimit) continue;
|
|
1385
|
+
const candidates = extractCandidateLinks(result.page);
|
|
1386
|
+
for (const candidate of candidates) {
|
|
1387
|
+
const normalised = canonicaliseUrl(candidate.href, result.page.fetch.finalUrl);
|
|
1388
|
+
if (!normalised) continue;
|
|
1389
|
+
if (visitedSet.has(normalised)) continue;
|
|
1390
|
+
const parsed = new URL(normalised);
|
|
1391
|
+
if (parsed.origin !== origin) continue;
|
|
1392
|
+
const passesIncludeExclude = filterAllows(parsed.pathname, include, exclude);
|
|
1393
|
+
const isHreflang = candidate.source === "hreflang";
|
|
1394
|
+
if (!passesIncludeExclude && !(followHreflang && isHreflang)) continue;
|
|
1395
|
+
visitedSet.add(normalised);
|
|
1396
|
+
queue.push({ url: normalised, depth: result.item.depth + 1 });
|
|
1264
1397
|
}
|
|
1265
|
-
if (truncated) break;
|
|
1266
1398
|
}
|
|
1267
1399
|
return {
|
|
1268
1400
|
pages,
|
|
@@ -1277,20 +1409,6 @@ function filterAllows(pathname, include, exclude) {
|
|
|
1277
1409
|
if (include.length === 0) return true;
|
|
1278
1410
|
return matchesAny(pathname, include);
|
|
1279
1411
|
}
|
|
1280
|
-
function canonicaliseUrl(href, base, options = {}) {
|
|
1281
|
-
let url;
|
|
1282
|
-
try {
|
|
1283
|
-
url = new URL(href, base);
|
|
1284
|
-
} catch {
|
|
1285
|
-
return null;
|
|
1286
|
-
}
|
|
1287
|
-
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
1288
|
-
url.hash = "";
|
|
1289
|
-
if (!options.preserveTrailingSlash && url.pathname.length > 1 && url.pathname.endsWith("/")) {
|
|
1290
|
-
url.pathname = url.pathname.slice(0, -1);
|
|
1291
|
-
}
|
|
1292
|
-
return url.toString();
|
|
1293
|
-
}
|
|
1294
1412
|
|
|
1295
1413
|
// src/lib/rules/evaluate.ts
|
|
1296
1414
|
function bandFor(value, bands) {
|
|
@@ -1435,14 +1553,14 @@ function normalise(value) {
|
|
|
1435
1553
|
return value.trim().toLowerCase().split(/\s+/).sort().join(" ");
|
|
1436
1554
|
}
|
|
1437
1555
|
function sameAsset(href, src) {
|
|
1438
|
-
const
|
|
1556
|
+
const pathOf3 = (value) => {
|
|
1439
1557
|
try {
|
|
1440
1558
|
return new URL(value, "https://goflag.invalid").pathname;
|
|
1441
1559
|
} catch {
|
|
1442
1560
|
return value.split("?")[0] ?? value;
|
|
1443
1561
|
}
|
|
1444
1562
|
};
|
|
1445
|
-
return
|
|
1563
|
+
return pathOf3(href) === pathOf3(src);
|
|
1446
1564
|
}
|
|
1447
1565
|
function hasOpenGraph(ex) {
|
|
1448
1566
|
return Boolean(
|
|
@@ -2765,6 +2883,41 @@ function sortLocales(locales) {
|
|
|
2765
2883
|
return [...xDefault, ...rest];
|
|
2766
2884
|
}
|
|
2767
2885
|
|
|
2886
|
+
// src/lib/core/probes/not-found.ts
|
|
2887
|
+
import { randomBytes } from "crypto";
|
|
2888
|
+
async function probeNotFound(origin, options = {}) {
|
|
2889
|
+
const nonce = options.nonce ?? randomBytes(16).toString("hex");
|
|
2890
|
+
const base = `/goflag-probe-${nonce}`;
|
|
2891
|
+
return Promise.all([
|
|
2892
|
+
ask(new URL(base, origin).toString(), "bare", options),
|
|
2893
|
+
ask(new URL(`${base}.txt`, origin).toString(), "dotted", options)
|
|
2894
|
+
]);
|
|
2895
|
+
}
|
|
2896
|
+
async function ask(url, shape, options) {
|
|
2897
|
+
const { signal, cleanup } = combineSignals(options.signal, options.timeoutMs ?? 5e3);
|
|
2898
|
+
try {
|
|
2899
|
+
let res = await fetch(url, { method: "HEAD", signal, redirect: "follow" });
|
|
2900
|
+
if (!isNotFound(res.status)) {
|
|
2901
|
+
res = await fetch(url, { signal, redirect: "follow" });
|
|
2902
|
+
await res.body?.cancel().catch(() => void 0);
|
|
2903
|
+
}
|
|
2904
|
+
return {
|
|
2905
|
+
url,
|
|
2906
|
+
shape,
|
|
2907
|
+
status: res.status,
|
|
2908
|
+
finalUrl: res.url || url,
|
|
2909
|
+
contentType: res.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() || void 0
|
|
2910
|
+
};
|
|
2911
|
+
} catch {
|
|
2912
|
+
return { url, shape, status: 0, finalUrl: url };
|
|
2913
|
+
} finally {
|
|
2914
|
+
cleanup();
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
function isNotFound(status) {
|
|
2918
|
+
return status === 404 || status === 410;
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2768
2921
|
// src/lib/core/robots/match.ts
|
|
2769
2922
|
function robotsAllows(groups, path, userAgent = "*") {
|
|
2770
2923
|
const group = selectGroup(groups, userAgent);
|
|
@@ -2829,6 +2982,180 @@ function escapeRegExp(value) {
|
|
|
2829
2982
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2830
2983
|
}
|
|
2831
2984
|
|
|
2985
|
+
// src/lib/rules/site-shared.ts
|
|
2986
|
+
var SAMPLE = 5;
|
|
2987
|
+
function sample(locs) {
|
|
2988
|
+
const shown = locs.slice(0, SAMPLE);
|
|
2989
|
+
const rest = locs.length - shown.length;
|
|
2990
|
+
return `${shown.map((l) => `\`${l}\``).join(", ")}${rest > 0 ? `, and ${rest} more` : ""}`;
|
|
2991
|
+
}
|
|
2992
|
+
function isDead(status) {
|
|
2993
|
+
return status === 0 || status >= 400;
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
// src/lib/rules/llms-txt.ts
|
|
2997
|
+
var TEXT_TYPES = /* @__PURE__ */ new Set(["text/plain", "text/markdown", "text/x-markdown"]);
|
|
2998
|
+
function readable(site) {
|
|
2999
|
+
return site.llmsTxt?.found === true && !site.llmsTxt.html;
|
|
3000
|
+
}
|
|
3001
|
+
function explainedByCatchAll(site, status) {
|
|
3002
|
+
const dotted = site.notFound?.find((probe) => probe.shape === "dotted");
|
|
3003
|
+
if (!dotted) return false;
|
|
3004
|
+
const klass = (code) => Math.floor(code / 100);
|
|
3005
|
+
return (klass(status) === 2 || klass(status) === 5) && klass(dotted.status) === klass(status);
|
|
3006
|
+
}
|
|
3007
|
+
function sameOriginLinks(site) {
|
|
3008
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3009
|
+
const out = [];
|
|
3010
|
+
for (const link of site.llmsTxt?.links ?? []) {
|
|
3011
|
+
if (seen.has(link.url) || !sameOrigin2(link.url, site.origin)) continue;
|
|
3012
|
+
seen.add(link.url);
|
|
3013
|
+
out.push({ url: link.url, line: link.line });
|
|
3014
|
+
}
|
|
3015
|
+
return out;
|
|
3016
|
+
}
|
|
3017
|
+
function sameOrigin2(url, origin) {
|
|
3018
|
+
try {
|
|
3019
|
+
return new URL(url).origin === new URL(origin).origin;
|
|
3020
|
+
} catch {
|
|
3021
|
+
return false;
|
|
3022
|
+
}
|
|
3023
|
+
}
|
|
3024
|
+
function fileUrl(site) {
|
|
3025
|
+
return site.llmsTxt?.url ?? `${site.origin}/llms.txt`;
|
|
3026
|
+
}
|
|
3027
|
+
var llmstxtUnreachable = {
|
|
3028
|
+
id: "llmstxt.unreachable",
|
|
3029
|
+
severity: "warning",
|
|
3030
|
+
summary: "`/llms.txt` must answer with the file or with 404, not with a server error",
|
|
3031
|
+
rigor: "guideline",
|
|
3032
|
+
sources: ["lighthouse-llms-txt", "llmstxt"],
|
|
3033
|
+
// A 5xx only. No answer at all is not evidence about the route, and every
|
|
3034
|
+
// 4xx — a 404 above all — is the site saying it has no file.
|
|
3035
|
+
appliesTo: (site) => site.llmsTxt !== void 0 && site.llmsTxt.status >= 500 && !explainedByCatchAll(site, site.llmsTxt.status),
|
|
3036
|
+
check: ({ site, issue }) => issue({
|
|
3037
|
+
pageUrl: fileUrl(site),
|
|
3038
|
+
message: `\`/llms.txt\` answered ${site.llmsTxt.status}. Having no llms.txt is fine \u2014 a 404 says so \u2014 but a server error says something is here and failed, and the agent that asked for it gets nothing it can use. Lighthouse fails its llms.txt audit on exactly this.`,
|
|
3039
|
+
origin: { kind: "computed" }
|
|
3040
|
+
})
|
|
3041
|
+
};
|
|
3042
|
+
var llmstxtContentType = {
|
|
3043
|
+
id: "llmstxt.content-type",
|
|
3044
|
+
severity: "warning",
|
|
3045
|
+
summary: "`/llms.txt` must be served as text \u2014 `text/plain` or `text/markdown` \u2014 not as a page",
|
|
3046
|
+
rigor: "guideline",
|
|
3047
|
+
sources: ["llmstxt", "ietf-rfc9110"],
|
|
3048
|
+
appliesTo: (site) => {
|
|
3049
|
+
const probe = site.llmsTxt;
|
|
3050
|
+
if (probe?.found !== true) return false;
|
|
3051
|
+
if (probe.html) return !explainedByCatchAll(site, probe.status);
|
|
3052
|
+
return !TEXT_TYPES.has(probe.contentType ?? "");
|
|
3053
|
+
},
|
|
3054
|
+
check: ({ site, issue }) => {
|
|
3055
|
+
const probe = site.llmsTxt;
|
|
3056
|
+
const served = probe.contentType ? `\`${probe.contentType}\`` : "no content type";
|
|
3057
|
+
const message = probe.html ? `\`/llms.txt\` answered ${probe.status} with an HTML page (${served}), not a markdown file. An agent that asks for the site's llms.txt is handed a page instead. If the site has no llms.txt the path should answer 404; if it has one, this is not the route serving it.` : `\`/llms.txt\` is served with ${served}. The file is markdown, read by agents and by people: \`text/plain\` or \`text/markdown\` say so, while anything else tells a client to treat it as something it is not \u2014 \`text/html\` renders it as one run-on paragraph, and a download type does not render it at all.`;
|
|
3058
|
+
return issue({ pageUrl: fileUrl(site), message, origin: { kind: "computed" } });
|
|
3059
|
+
}
|
|
3060
|
+
};
|
|
3061
|
+
var llmstxtH1Missing = {
|
|
3062
|
+
id: "llmstxt.h1.missing",
|
|
3063
|
+
severity: "warning",
|
|
3064
|
+
summary: "An llms.txt must have an H1 naming the site",
|
|
3065
|
+
rigor: "guideline",
|
|
3066
|
+
sources: ["llmstxt"],
|
|
3067
|
+
appliesTo: (site) => readable(site) && !site.llmsTxt.h1,
|
|
3068
|
+
check: ({ site, issue }) => issue({
|
|
3069
|
+
pageUrl: fileUrl(site),
|
|
3070
|
+
message: "`/llms.txt` has no H1 (`# Name`, with a space after the `#`). The proposal calls it the only required section \u2014 without it the file is not an llms.txt by its own definition, and a reader following the format has no name for what the rest describes.",
|
|
3071
|
+
origin: { kind: "computed" }
|
|
3072
|
+
})
|
|
3073
|
+
};
|
|
3074
|
+
function listedProbes(site) {
|
|
3075
|
+
const byUrl = site.llmsTxtLinks?.byUrl;
|
|
3076
|
+
if (!byUrl) return [];
|
|
3077
|
+
return sameOriginLinks(site).flatMap(({ url }) => byUrl.get(url) ?? []);
|
|
3078
|
+
}
|
|
3079
|
+
var llmstxtLinkUnreachable = {
|
|
3080
|
+
id: "llmstxt.link.unreachable",
|
|
3081
|
+
severity: "warning",
|
|
3082
|
+
summary: "Every same-origin URL an llms.txt lists must answer",
|
|
3083
|
+
rigor: "guideline",
|
|
3084
|
+
sources: ["llmstxt"],
|
|
3085
|
+
appliesTo: (site) => readable(site) && listedProbes(site).some((probe) => isDead(probe.status)),
|
|
3086
|
+
check: ({ site, issue }) => {
|
|
3087
|
+
const dead = listedProbes(site).filter((probe) => isDead(probe.status));
|
|
3088
|
+
const unprobed = site.llmsTxtLinks?.unprobed ?? 0;
|
|
3089
|
+
const floor = unprobed > 0 ? ` ${unprobed} further URL${unprobed === 1 ? " was" : "s were"} not checked \u2014 this count is a floor, not a total.` : "";
|
|
3090
|
+
return issue({
|
|
3091
|
+
pageUrl: fileUrl(site),
|
|
3092
|
+
message: `${dead.length} URL${dead.length === 1 ? "" : "s"} \`/llms.txt\` lists ${dead.length === 1 ? "does" : "do"} not answer: ${sample(dead.map((probe) => `${probe.url} (${probe.status === 0 ? "no response" : `HTTP ${probe.status}`})`))}. The file exists to send agents to these pages, so each dead one is a lookup that ends in an error instead of an answer.${floor}`,
|
|
3093
|
+
origin: { kind: "computed" }
|
|
3094
|
+
});
|
|
3095
|
+
}
|
|
3096
|
+
};
|
|
3097
|
+
var llmstxtLinkBlockedByRobots = {
|
|
3098
|
+
id: "llmstxt.link.blocked-by-robots",
|
|
3099
|
+
severity: "warning",
|
|
3100
|
+
summary: "An llms.txt must not list URLs that robots.txt forbids fetching",
|
|
3101
|
+
rigor: "guideline",
|
|
3102
|
+
sources: ["llmstxt", "ietf-rfc9309", "anthropic-crawlers"],
|
|
3103
|
+
appliesTo: (site) => readable(site) && site.robots?.found === true && robotsAllows(site.robots.groups, "/").allowed,
|
|
3104
|
+
check: ({ site, issue }) => {
|
|
3105
|
+
const robots = site.robots;
|
|
3106
|
+
const file = fileUrl(site);
|
|
3107
|
+
const blocked = [];
|
|
3108
|
+
for (const url of [file, ...sameOriginLinks(site).map((link) => link.url)]) {
|
|
3109
|
+
let path;
|
|
3110
|
+
try {
|
|
3111
|
+
const parsed = new URL(url);
|
|
3112
|
+
path = `${parsed.pathname}${parsed.search}`;
|
|
3113
|
+
} catch {
|
|
3114
|
+
continue;
|
|
3115
|
+
}
|
|
3116
|
+
const decision = robotsAllows(robots.groups, path);
|
|
3117
|
+
if (decision.allowed || !decision.rule) continue;
|
|
3118
|
+
const self = url === file ? ", the file itself" : "";
|
|
3119
|
+
blocked.push(
|
|
3120
|
+
`${url} (line ${decision.rule.line}: Disallow: ${decision.rule.pattern}${self})`
|
|
3121
|
+
);
|
|
3122
|
+
}
|
|
3123
|
+
if (blocked.length === 0) return [];
|
|
3124
|
+
return issue({
|
|
3125
|
+
pageUrl: file,
|
|
3126
|
+
message: `${blocked.length} URL${blocked.length === 1 ? "" : "s"} \`/llms.txt\` points agents to ${blocked.length === 1 ? "is" : "are"} disallowed by \`robots.txt\` for \`User-agent: *\`: ${sample(blocked)}. The file offers them to agents, and an agent that honours robots.txt \u2014 Anthropic documents that Claude-User does \u2014 never fetches them.`,
|
|
3127
|
+
origin: { kind: "computed" }
|
|
3128
|
+
});
|
|
3129
|
+
}
|
|
3130
|
+
};
|
|
3131
|
+
var LLMS_TXT_RULES = [
|
|
3132
|
+
llmstxtContentType,
|
|
3133
|
+
llmstxtH1Missing,
|
|
3134
|
+
llmstxtLinkBlockedByRobots,
|
|
3135
|
+
llmstxtLinkUnreachable,
|
|
3136
|
+
llmstxtUnreachable
|
|
3137
|
+
];
|
|
3138
|
+
|
|
3139
|
+
// src/lib/rules/sitemap-inventory.ts
|
|
3140
|
+
function sitemapReadInFull(site) {
|
|
3141
|
+
const discovery = site.discovery;
|
|
3142
|
+
if (discovery?.diagnostics.found !== true) return false;
|
|
3143
|
+
if (discovery.truncated || discovery.diagnostics.childSitemapErrors > 0) return false;
|
|
3144
|
+
const read = new Set(discovery.documents.map((doc) => resolved(doc.url)));
|
|
3145
|
+
const base = site.robots?.url ?? `${site.origin}/robots.txt`;
|
|
3146
|
+
return (site.robots?.sitemaps ?? []).every((declared) => {
|
|
3147
|
+
const url = resolved(declared.value, base);
|
|
3148
|
+
return url === void 0 || read.has(url);
|
|
3149
|
+
});
|
|
3150
|
+
}
|
|
3151
|
+
function resolved(value, base) {
|
|
3152
|
+
try {
|
|
3153
|
+
return new URL(value, base).href;
|
|
3154
|
+
} catch {
|
|
3155
|
+
return void 0;
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
3158
|
+
|
|
2832
3159
|
// src/lib/rules/site-rules.ts
|
|
2833
3160
|
function isMultilingual(site) {
|
|
2834
3161
|
return site.localeAxis.multilingual;
|
|
@@ -3037,6 +3364,69 @@ var iconsIcoMissing = {
|
|
|
3037
3364
|
});
|
|
3038
3365
|
}
|
|
3039
3366
|
};
|
|
3367
|
+
var httpNotFound = {
|
|
3368
|
+
id: "http.not-found",
|
|
3369
|
+
severity: "error",
|
|
3370
|
+
summary: "A URL that does not exist must answer 404 or 410",
|
|
3371
|
+
rigor: "vendor-spec",
|
|
3372
|
+
sources: ["ietf-rfc9110", "google-soft-404", "google-http-status"],
|
|
3373
|
+
appliesTo: (site) => (site.notFound ?? []).some((probe) => notFoundFailure(probe.status)),
|
|
3374
|
+
check: ({ site, issue }) => {
|
|
3375
|
+
const probes = site.notFound ?? [];
|
|
3376
|
+
const failed = probes.filter((probe) => notFoundFailure(probe.status));
|
|
3377
|
+
const held = probes.filter((probe) => isNotFound(probe.status));
|
|
3378
|
+
const answers = failed.map((probe) => {
|
|
3379
|
+
const path = pathOf(probe.url);
|
|
3380
|
+
const via = probe.finalUrl !== probe.url ? ` after redirecting to \`${probe.finalUrl}\`` : "";
|
|
3381
|
+
const type = probe.contentType ? ` \`${probe.contentType}\`` : "";
|
|
3382
|
+
return `\`${path}\` answered ${probe.status}${type}${via}`;
|
|
3383
|
+
});
|
|
3384
|
+
const contrast = held.length ? ` (${held.map((probe) => `\`${pathOf(probe.url)}\` answered ${probe.status}`).join(", ")}, so only some unknown paths are affected)` : "";
|
|
3385
|
+
const soft = failed.some((probe) => notFoundFailure(probe.status) === "soft");
|
|
3386
|
+
const crash = failed.some((probe) => notFoundFailure(probe.status) === "error");
|
|
3387
|
+
const consequences = [
|
|
3388
|
+
soft ? "A 2xx for a page that does not exist is a soft 404: every invented URL becomes a page to crawl, and a client asking for a file the site does not have \u2014 `/llms.txt`, `/feed.xml` \u2014 gets a page it may take for that file." : "",
|
|
3389
|
+
crash ? "A 5xx says the server failed, not that the page is missing: every mistyped link becomes a server error, and Google slows its crawl of the whole site in proportion to the URLs that return one." : ""
|
|
3390
|
+
].filter(Boolean);
|
|
3391
|
+
return issue({
|
|
3392
|
+
pageUrl: site.origin,
|
|
3393
|
+
message: `Asked for a path that cannot exist, the origin did not answer 404 or 410: ${answers.join("; ")}${contrast}. ${consequences.join(" ")}`,
|
|
3394
|
+
origin: { kind: "computed" },
|
|
3395
|
+
fix: {
|
|
3396
|
+
title: "Make the route that catches unknown paths call notFound()",
|
|
3397
|
+
snippet: [
|
|
3398
|
+
"// A dynamic first segment matches everything, `/anything.txt` included,",
|
|
3399
|
+
"// whenever a middleware matcher skips dotted paths. Validate it wherever",
|
|
3400
|
+
"// it is read \u2014 the layout, every page, every generateMetadata:",
|
|
3401
|
+
"const { locale } = await params;",
|
|
3402
|
+
"if (!routing.locales.includes(locale)) notFound();",
|
|
3403
|
+
"//",
|
|
3404
|
+
"// The layout alone is not enough in a production build: the page and its",
|
|
3405
|
+
"// metadata render alongside it, and whichever throws first sets the",
|
|
3406
|
+
"// status \u2014 a 500 from a page that formatted a date in locale `llms.txt`.",
|
|
3407
|
+
"//",
|
|
3408
|
+
"// A catch-all `[...slug]` must do the same for slugs it does not know,",
|
|
3409
|
+
"// and a static host's SPA fallback (`/* /index.html 200`) should be a",
|
|
3410
|
+
"// 404 page instead."
|
|
3411
|
+
].join("\n"),
|
|
3412
|
+
language: "ts"
|
|
3413
|
+
}
|
|
3414
|
+
});
|
|
3415
|
+
}
|
|
3416
|
+
};
|
|
3417
|
+
function notFoundFailure(status) {
|
|
3418
|
+
if (status >= 200 && status < 300) return "soft";
|
|
3419
|
+
if (status >= 500) return "error";
|
|
3420
|
+
return void 0;
|
|
3421
|
+
}
|
|
3422
|
+
function pathOf(url) {
|
|
3423
|
+
try {
|
|
3424
|
+
const parsed = new URL(url);
|
|
3425
|
+
return `${parsed.pathname}${parsed.search}`;
|
|
3426
|
+
} catch {
|
|
3427
|
+
return url;
|
|
3428
|
+
}
|
|
3429
|
+
}
|
|
3040
3430
|
var ROBOTS_BYTE_LIMIT = 500 * 1024;
|
|
3041
3431
|
var robotstxtUnreachable = {
|
|
3042
3432
|
id: "robotstxt.unreachable",
|
|
@@ -3172,12 +3562,6 @@ var robotsBlocksPage = {
|
|
|
3172
3562
|
}
|
|
3173
3563
|
};
|
|
3174
3564
|
var CHANGEFREQ = /* @__PURE__ */ new Set(["always", "hourly", "daily", "weekly", "monthly", "yearly", "never"]);
|
|
3175
|
-
var SAMPLE = 5;
|
|
3176
|
-
function sample(locs) {
|
|
3177
|
-
const shown = locs.slice(0, SAMPLE);
|
|
3178
|
-
const rest = locs.length - shown.length;
|
|
3179
|
-
return `${shown.map((l) => `\`${l}\``).join(", ")}${rest > 0 ? `, and ${rest} more` : ""}`;
|
|
3180
|
-
}
|
|
3181
3565
|
var sitemapMissing = {
|
|
3182
3566
|
id: "sitemap.missing",
|
|
3183
3567
|
severity: "warning",
|
|
@@ -3494,16 +3878,43 @@ function sameUrlKey(url) {
|
|
|
3494
3878
|
}
|
|
3495
3879
|
function pagesByUrl(site) {
|
|
3496
3880
|
const byUrl = /* @__PURE__ */ new Map();
|
|
3497
|
-
for (const page of site.pages) {
|
|
3881
|
+
for (const page of [...site.pages, ...site.variants ?? []]) {
|
|
3498
3882
|
const key = sameUrlKey(page.fetch.finalUrl);
|
|
3499
3883
|
if (key) byUrl.set(key, page);
|
|
3500
3884
|
}
|
|
3501
3885
|
return byUrl;
|
|
3502
3886
|
}
|
|
3887
|
+
var NOINDEX = /* @__PURE__ */ new Set(["noindex", "none"]);
|
|
3888
|
+
var VALUED_DIRECTIVES = /* @__PURE__ */ new Set([
|
|
3889
|
+
"max-snippet",
|
|
3890
|
+
"max-image-preview",
|
|
3891
|
+
"max-video-preview",
|
|
3892
|
+
"unavailable_after"
|
|
3893
|
+
]);
|
|
3894
|
+
function headerDirectiveNames(value) {
|
|
3895
|
+
if (!value) return [];
|
|
3896
|
+
return value.split(",").map((token) => {
|
|
3897
|
+
let directive = token.trim().toLowerCase();
|
|
3898
|
+
const colon = directive.indexOf(":");
|
|
3899
|
+
if (colon !== -1 && !VALUED_DIRECTIVES.has(directive.slice(0, colon).trim())) {
|
|
3900
|
+
directive = directive.slice(colon + 1);
|
|
3901
|
+
}
|
|
3902
|
+
return directive.split(":")[0].trim();
|
|
3903
|
+
});
|
|
3904
|
+
}
|
|
3503
3905
|
function saysNoindex(page) {
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
return
|
|
3906
|
+
const metas = [page.meta.robots?.value, page.meta.googlebot?.value];
|
|
3907
|
+
if (metas.some((content) => (content ?? "").split(",").some(isNoindex))) return true;
|
|
3908
|
+
return headerDirectiveNames(page.fetch.headers["x-robots-tag"]).some(isNoindex);
|
|
3909
|
+
}
|
|
3910
|
+
function isNoindex(directive) {
|
|
3911
|
+
return NOINDEX.has(directive.trim().toLowerCase());
|
|
3912
|
+
}
|
|
3913
|
+
function canonicalElsewhere(page, self = sameUrlKey(page.fetch.finalUrl)) {
|
|
3914
|
+
const canonical = page.links.canonical;
|
|
3915
|
+
if (!canonical) return false;
|
|
3916
|
+
const target = sameUrlKey(canonical);
|
|
3917
|
+
return target !== void 0 && target !== self;
|
|
3507
3918
|
}
|
|
3508
3919
|
var sitemapEntryBlockedByRobots = {
|
|
3509
3920
|
id: "sitemap.entry.blocked-by-robots",
|
|
@@ -3574,17 +3985,14 @@ var sitemapEntryNonCanonical = {
|
|
|
3574
3985
|
for (const entry of site.discovery.urls) {
|
|
3575
3986
|
const key = sameUrlKey(entry.loc);
|
|
3576
3987
|
const page = key ? byUrl.get(key) : void 0;
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
const canonicalKey = sameUrlKey(canonical);
|
|
3580
|
-
if (canonicalKey && canonicalKey !== key) {
|
|
3581
|
-
wrong.push(`${entry.loc} \u2192 ${canonical}`);
|
|
3988
|
+
if (page && canonicalElsewhere(page)) {
|
|
3989
|
+
wrong.push(`${entry.loc} \u2192 ${page.links.canonical}`);
|
|
3582
3990
|
}
|
|
3583
3991
|
}
|
|
3584
3992
|
if (wrong.length === 0) return [];
|
|
3585
3993
|
return issue({
|
|
3586
3994
|
pageUrl: site.discovery.diagnostics.sitemapUrl ?? `${site.origin}/sitemap.xml`,
|
|
3587
|
-
message: `${wrong.length} sitemap entr${wrong.length === 1 ? "y names a page whose canonical" : "ies name pages whose canonicals"}
|
|
3995
|
+
message: `${wrong.length} sitemap entr${wrong.length === 1 ? "y names a page whose canonical points" : "ies name pages whose canonicals point"} elsewhere: ${sample(wrong)}. The sitemap is a list of what to index, so it should name the URL the site itself prefers.`,
|
|
3588
3996
|
origin: { kind: "computed" }
|
|
3589
3997
|
});
|
|
3590
3998
|
}
|
|
@@ -3592,26 +4000,96 @@ var sitemapEntryNonCanonical = {
|
|
|
3592
4000
|
var sitemapOrphans = {
|
|
3593
4001
|
id: "sitemap.orphans",
|
|
3594
4002
|
severity: "warning",
|
|
3595
|
-
summary: "Indexable pages the crawl found should be listed in the sitemap",
|
|
4003
|
+
summary: "Indexable pages the crawl found, and the canonical URLs they name, should be listed in the sitemap",
|
|
3596
4004
|
rigor: "guideline",
|
|
3597
4005
|
sources: ["sitemaps-protocol", "google-sitemaps"],
|
|
3598
4006
|
// One finding with a count and a sample, not one per page — the same shape
|
|
3599
4007
|
// translation holes take, and for the same reason: forty repeats of one
|
|
3600
4008
|
// omission is noise, and the omission is a property of the sitemap.
|
|
3601
|
-
|
|
4009
|
+
//
|
|
4010
|
+
// Judged only against an inventory goflag read in full — every sitemap the
|
|
4011
|
+
// site declares, none of them cut short (`./sitemap-inventory.ts`). A page
|
|
4012
|
+
// listed where goflag could not read looks exactly like one the site forgot.
|
|
4013
|
+
// What stopped the read is its own finding where one exists
|
|
4014
|
+
// (`sitemap.index.child-error`); this one waits until the list is whole.
|
|
4015
|
+
appliesTo: (site) => sitemapReadInFull(site) && site.discovery.urls.length > 0,
|
|
3602
4016
|
check: ({ site, issue }) => {
|
|
3603
|
-
const listed =
|
|
3604
|
-
|
|
3605
|
-
);
|
|
3606
|
-
const
|
|
3607
|
-
|
|
4017
|
+
const { listed, fetched, unfollowed } = sitemapReach(site);
|
|
4018
|
+
const orphans = [];
|
|
4019
|
+
const unaudited = /* @__PURE__ */ new Map();
|
|
4020
|
+
for (const page of site.pages) {
|
|
4021
|
+
if (saysNoindex(page)) continue;
|
|
4022
|
+
const key = sameUrlKey(page.fetch.finalUrl);
|
|
4023
|
+
if (key === void 0) continue;
|
|
4024
|
+
if (!canonicalElsewhere(page, key)) {
|
|
4025
|
+
if (!listed.has(key)) orphans.push(page.fetch.finalUrl);
|
|
4026
|
+
continue;
|
|
4027
|
+
}
|
|
4028
|
+
const target = page.links.canonical;
|
|
4029
|
+
const targetKey2 = sameUrlKey(target);
|
|
4030
|
+
if (sameOrigin3(target, site.origin) && !listed.has(targetKey2) && !fetched.has(targetKey2) && !unaudited.has(targetKey2)) {
|
|
4031
|
+
unaudited.set(targetKey2, { url: target, namedBy: page.fetch.finalUrl });
|
|
4032
|
+
}
|
|
4033
|
+
}
|
|
4034
|
+
const named = [...unaudited.values()];
|
|
4035
|
+
const total = orphans.length + named.length;
|
|
4036
|
+
if (total === 0) return [];
|
|
4037
|
+
const said = [];
|
|
4038
|
+
if (orphans.length > 0) {
|
|
4039
|
+
said.push(
|
|
4040
|
+
`${orphans.length} crawled page${orphans.length === 1 ? "" : "s"} ask${orphans.length === 1 ? "s" : ""} to be indexed and ${orphans.length === 1 ? "is" : "are"} absent from the sitemap: ${sample(orphans)}.`
|
|
4041
|
+
);
|
|
4042
|
+
}
|
|
4043
|
+
if (named.length > 0) {
|
|
4044
|
+
said.push(
|
|
4045
|
+
`${named.length} URL${named.length === 1 ? "" : "s"} that crawled pages name as their canonical ${named.length === 1 ? "is" : "are"} absent from ${orphans.length > 0 ? "it too" : "the sitemap"}, and ${named.length === 1 ? "was" : "were"} not audited: ${sample(named.map((t) => `${t.url} (canonical of ${t.namedBy})`))}.`
|
|
4046
|
+
);
|
|
4047
|
+
}
|
|
3608
4048
|
return issue({
|
|
3609
4049
|
pageUrl: site.discovery.diagnostics.sitemapUrl ?? `${site.origin}/sitemap.xml`,
|
|
3610
|
-
message: `${
|
|
4050
|
+
message: `${said.join(" ")} A consumer that reads the sitemap rather than following links will never see ${total === 1 ? "it" : "them"}.${ceilingNote(unfollowed)}`,
|
|
3611
4051
|
origin: { kind: "computed" }
|
|
3612
4052
|
});
|
|
3613
4053
|
}
|
|
3614
4054
|
};
|
|
4055
|
+
function sameOrigin3(a, b) {
|
|
4056
|
+
try {
|
|
4057
|
+
return new URL(a).origin === new URL(b).origin;
|
|
4058
|
+
} catch {
|
|
4059
|
+
return false;
|
|
4060
|
+
}
|
|
4061
|
+
}
|
|
4062
|
+
function sitemapReach(site) {
|
|
4063
|
+
const fetched = /* @__PURE__ */ new Set();
|
|
4064
|
+
const landingOf = /* @__PURE__ */ new Map();
|
|
4065
|
+
for (const page of [...site.pages, ...site.variants ?? []]) {
|
|
4066
|
+
const final = sameUrlKey(page.fetch.finalUrl);
|
|
4067
|
+
const requested = sameUrlKey(page.fetch.requestedUrl);
|
|
4068
|
+
if (!final) continue;
|
|
4069
|
+
fetched.add(final);
|
|
4070
|
+
if (requested && requested !== final) landingOf.set(requested, final);
|
|
4071
|
+
}
|
|
4072
|
+
for (const probe of entryProbes(site)) {
|
|
4073
|
+
const from = sameUrlKey(probe.url);
|
|
4074
|
+
const to = probe.redirected ? sameUrlKey(probe.finalUrl) : void 0;
|
|
4075
|
+
if (from && to) landingOf.set(from, to);
|
|
4076
|
+
}
|
|
4077
|
+
const listed = /* @__PURE__ */ new Set();
|
|
4078
|
+
let unfollowed = 0;
|
|
4079
|
+
for (const entry of site.discovery.urls) {
|
|
4080
|
+
const key = sameUrlKey(entry.loc);
|
|
4081
|
+
if (!key) continue;
|
|
4082
|
+
listed.add(key);
|
|
4083
|
+
const landing = landingOf.get(key);
|
|
4084
|
+
if (landing) listed.add(landing);
|
|
4085
|
+
else if (!fetched.has(key) && !site.sitemapEntries?.byUrl.has(entry.loc)) unfollowed += 1;
|
|
4086
|
+
}
|
|
4087
|
+
return { listed, fetched, unfollowed };
|
|
4088
|
+
}
|
|
4089
|
+
function ceilingNote(unfollowed) {
|
|
4090
|
+
if (unfollowed === 0) return "";
|
|
4091
|
+
return ` ${unfollowed} sitemap entr${unfollowed === 1 ? "y was" : "ies were"} not fetched, and a page one of them redirects to would be counted here \u2014 this count is a ceiling, not a total.`;
|
|
4092
|
+
}
|
|
3615
4093
|
function entryProbes(site) {
|
|
3616
4094
|
return [...site.sitemapEntries?.byUrl.values() ?? []];
|
|
3617
4095
|
}
|
|
@@ -3652,9 +4130,6 @@ var sitemapEntryRedirects = {
|
|
|
3652
4130
|
});
|
|
3653
4131
|
}
|
|
3654
4132
|
};
|
|
3655
|
-
function isDead(status) {
|
|
3656
|
-
return status === 0 || status >= 400;
|
|
3657
|
-
}
|
|
3658
4133
|
function isRobotsFailure(status) {
|
|
3659
4134
|
return status === 0 || status >= 500;
|
|
3660
4135
|
}
|
|
@@ -3669,7 +4144,9 @@ function isAbsolute(value) {
|
|
|
3669
4144
|
var SITE_RULES = [
|
|
3670
4145
|
hreflangMissing,
|
|
3671
4146
|
hreflangClusterIncomplete,
|
|
4147
|
+
httpNotFound,
|
|
3672
4148
|
iconsIcoMissing,
|
|
4149
|
+
...LLMS_TXT_RULES,
|
|
3673
4150
|
robotsBlocksPage,
|
|
3674
4151
|
robotsBlocksSite,
|
|
3675
4152
|
robotstxtCrossOrigin,
|
|
@@ -3824,7 +4301,7 @@ function suggestedLocales(axis) {
|
|
|
3824
4301
|
import { gunzipSync } from "zlib";
|
|
3825
4302
|
|
|
3826
4303
|
// src/lib/core/sitemap/parse.ts
|
|
3827
|
-
import * as
|
|
4304
|
+
import * as cheerio4 from "cheerio";
|
|
3828
4305
|
function alternatesOf(node) {
|
|
3829
4306
|
const out = [];
|
|
3830
4307
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -3852,7 +4329,7 @@ function parseSitemap(body) {
|
|
|
3852
4329
|
}
|
|
3853
4330
|
let $;
|
|
3854
4331
|
try {
|
|
3855
|
-
$ =
|
|
4332
|
+
$ = cheerio4.load(trimmed, { xmlMode: true });
|
|
3856
4333
|
} catch {
|
|
3857
4334
|
return { kind: "unknown", wellFormed: false, urls: [], sitemaps: [] };
|
|
3858
4335
|
}
|
|
@@ -4249,6 +4726,10 @@ function normaliseContentType(raw) {
|
|
|
4249
4726
|
const semi = raw.indexOf(";");
|
|
4250
4727
|
return (semi === -1 ? raw : raw.slice(0, semi)).trim().toLowerCase() || void 0;
|
|
4251
4728
|
}
|
|
4729
|
+
function capText(text, maxBytes) {
|
|
4730
|
+
if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
|
|
4731
|
+
return Buffer.from(text.slice(0, maxBytes), "utf8").subarray(0, maxBytes).toString("utf8");
|
|
4732
|
+
}
|
|
4252
4733
|
async function readCappedText(res, maxBytes) {
|
|
4253
4734
|
const reader = res.body?.getReader();
|
|
4254
4735
|
if (!reader) {
|
|
@@ -4381,6 +4862,34 @@ async function checkLink(url, options = {}) {
|
|
|
4381
4862
|
durationMs: Date.now() - started
|
|
4382
4863
|
};
|
|
4383
4864
|
}
|
|
4865
|
+
function checkFromAnswer(url, answer, options = {}) {
|
|
4866
|
+
if (answer.status < 200 || answer.status >= 300) return void 0;
|
|
4867
|
+
const outcome = {
|
|
4868
|
+
status: answer.status,
|
|
4869
|
+
finalUrl: url,
|
|
4870
|
+
redirectChain: [],
|
|
4871
|
+
loop: false,
|
|
4872
|
+
body: capText(answer.body, options.softBodyBytes ?? DEFAULTS2.softBodyBytes),
|
|
4873
|
+
method: "GET"
|
|
4874
|
+
};
|
|
4875
|
+
const softNotFound = isSoftNotFound(outcome);
|
|
4876
|
+
return {
|
|
4877
|
+
url,
|
|
4878
|
+
finalUrl: url,
|
|
4879
|
+
status: outcome.status,
|
|
4880
|
+
verdict: classifyLink({
|
|
4881
|
+
status: outcome.status,
|
|
4882
|
+
redirected: false,
|
|
4883
|
+
softNotFound,
|
|
4884
|
+
loop: false
|
|
4885
|
+
}),
|
|
4886
|
+
method: "GET",
|
|
4887
|
+
redirectChain: [],
|
|
4888
|
+
reason: describeReason(outcome, softNotFound),
|
|
4889
|
+
checkedAt: answer.checkedAt,
|
|
4890
|
+
durationMs: answer.durationMs
|
|
4891
|
+
};
|
|
4892
|
+
}
|
|
4384
4893
|
async function probeWithRetry(url, options) {
|
|
4385
4894
|
const retries = options.retries ?? DEFAULTS2.retries;
|
|
4386
4895
|
const sleep = options.sleep ?? defaultSleep;
|
|
@@ -4498,7 +5007,7 @@ function schemeOf(url) {
|
|
|
4498
5007
|
}
|
|
4499
5008
|
}
|
|
4500
5009
|
function defaultSleep(ms) {
|
|
4501
|
-
return new Promise((
|
|
5010
|
+
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
4502
5011
|
}
|
|
4503
5012
|
|
|
4504
5013
|
// src/lib/core/sitemap/entries.ts
|
|
@@ -4919,6 +5428,86 @@ function isIconType(contentType) {
|
|
|
4919
5428
|
return contentType.startsWith("image/") || contentType === "application/ico" || contentType === "application/x-ico";
|
|
4920
5429
|
}
|
|
4921
5430
|
|
|
5431
|
+
// src/lib/core/llmstxt/parse.ts
|
|
5432
|
+
var H1 = /^ {0,3}#(?:[ \t]+(.*?))?[ \t]*#*[ \t]*$/;
|
|
5433
|
+
var FENCE = /^ {0,3}(`{3,}|~{3,})/;
|
|
5434
|
+
var LINK = /(!?)\[([^\]]*)\]\(\s*<?([^)\s>]+)>?(?:\s+(?:"[^"]*"|'[^']*'))?\s*\)/g;
|
|
5435
|
+
function parseLlmsTxt(body, fileUrl2) {
|
|
5436
|
+
const lines = body.replace(/^\uFEFF/, "").split(/\r\n|\r|\n/);
|
|
5437
|
+
const links = [];
|
|
5438
|
+
let h1;
|
|
5439
|
+
let fence;
|
|
5440
|
+
for (const [index, raw] of lines.entries()) {
|
|
5441
|
+
const line = index + 1;
|
|
5442
|
+
const fenceMatch = FENCE.exec(raw);
|
|
5443
|
+
if (fenceMatch) {
|
|
5444
|
+
const marker = fenceMatch[1];
|
|
5445
|
+
if (fence === void 0) fence = marker;
|
|
5446
|
+
else if (marker[0] === fence[0] && marker.length >= fence.length) fence = void 0;
|
|
5447
|
+
continue;
|
|
5448
|
+
}
|
|
5449
|
+
if (fence !== void 0) continue;
|
|
5450
|
+
if (!h1) {
|
|
5451
|
+
const heading = H1.exec(raw);
|
|
5452
|
+
const text = heading?.[1]?.trim();
|
|
5453
|
+
if (text) h1 = { text, line };
|
|
5454
|
+
}
|
|
5455
|
+
const prose = raw.replace(/`[^`]*`/g, "");
|
|
5456
|
+
for (const match of prose.matchAll(LINK)) {
|
|
5457
|
+
if (match[1] === "!") continue;
|
|
5458
|
+
const url = resolve(match[3], fileUrl2);
|
|
5459
|
+
if (url) links.push({ name: match[2].trim(), url, line });
|
|
5460
|
+
}
|
|
5461
|
+
}
|
|
5462
|
+
return { ...h1 ? { h1 } : {}, links };
|
|
5463
|
+
}
|
|
5464
|
+
function looksLikeHtml(body) {
|
|
5465
|
+
return /^(?:<!doctype\s+html|<html[\s>])/i.test(body.replace(/^\uFEFF/, "").trimStart());
|
|
5466
|
+
}
|
|
5467
|
+
function resolve(href, base) {
|
|
5468
|
+
try {
|
|
5469
|
+
const url = new URL(href, base);
|
|
5470
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return void 0;
|
|
5471
|
+
url.hash = "";
|
|
5472
|
+
return url.toString();
|
|
5473
|
+
} catch {
|
|
5474
|
+
return void 0;
|
|
5475
|
+
}
|
|
5476
|
+
}
|
|
5477
|
+
|
|
5478
|
+
// src/lib/core/probes/llms-txt.ts
|
|
5479
|
+
async function probeLlmsTxt(origin, options = {}) {
|
|
5480
|
+
const url = new URL("/llms.txt", origin).toString();
|
|
5481
|
+
const { signal, cleanup } = combineSignals(options.signal, options.timeoutMs ?? 5e3);
|
|
5482
|
+
const nothing = { found: false, html: false, links: [] };
|
|
5483
|
+
try {
|
|
5484
|
+
const res = await fetch(url, { signal, redirect: "follow" });
|
|
5485
|
+
const finalUrl = res.url || url;
|
|
5486
|
+
const contentType = res.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() || void 0;
|
|
5487
|
+
if (!res.ok) {
|
|
5488
|
+
await res.body?.cancel().catch(() => void 0);
|
|
5489
|
+
return { url, status: res.status, finalUrl, contentType, ...nothing };
|
|
5490
|
+
}
|
|
5491
|
+
const body = await res.text();
|
|
5492
|
+
if (looksLikeHtml(body)) {
|
|
5493
|
+
return { url, status: res.status, finalUrl, contentType, found: true, html: true, links: [] };
|
|
5494
|
+
}
|
|
5495
|
+
return {
|
|
5496
|
+
url,
|
|
5497
|
+
status: res.status,
|
|
5498
|
+
finalUrl,
|
|
5499
|
+
contentType,
|
|
5500
|
+
found: true,
|
|
5501
|
+
html: false,
|
|
5502
|
+
...parseLlmsTxt(body, finalUrl)
|
|
5503
|
+
};
|
|
5504
|
+
} catch {
|
|
5505
|
+
return { url, status: 0, finalUrl: url, ...nothing };
|
|
5506
|
+
} finally {
|
|
5507
|
+
cleanup();
|
|
5508
|
+
}
|
|
5509
|
+
}
|
|
5510
|
+
|
|
4922
5511
|
// src/lib/rules/advisory.ts
|
|
4923
5512
|
function resolveEvidencePath(extraction, path) {
|
|
4924
5513
|
let current = extraction;
|
|
@@ -5087,7 +5676,12 @@ var hreflangSitemapMismatch = {
|
|
|
5087
5676
|
summary: "`<head>` alternates advertise a locale the sitemap does not list",
|
|
5088
5677
|
prose: "Your `<head>` advertises a translation your sitemap does not list. Is the sitemap missing an entry, or is the alternate pointing at a page you did not mean to publish?",
|
|
5089
5678
|
why: "The two are derived from one intent by different code paths, so a disagreement means one of them is wrong. No specification says which: the declaration methods are equivalent, and a page can legitimately be cross-linked and kept out of the sitemap.",
|
|
5090
|
-
|
|
5679
|
+
// A question about what the sitemap *omits*, so it waits for a sitemap read in
|
|
5680
|
+
// full (`./sitemap-inventory.ts`): a translation listed in a child goflag
|
|
5681
|
+
// could not read, or in a second declared sitemap it never opened, would be
|
|
5682
|
+
// asked about as missing — a question with a false premise wastes an agent's
|
|
5683
|
+
// turn as surely as a false finding wastes a human's.
|
|
5684
|
+
appliesTo: (site) => site.localeAxis.multilingual && (site.discovery?.urls.length ?? 0) > 0 && sitemapReadInFull(site),
|
|
5091
5685
|
ask: (site) => {
|
|
5092
5686
|
const axis = new Set(site.localeAxis.locales.map((l) => l.toLowerCase()));
|
|
5093
5687
|
const bySitemap = /* @__PURE__ */ new Map();
|
|
@@ -5164,89 +5758,6 @@ function collectSiteAdvisories(site, rules = SITE_PROSE_RULES) {
|
|
|
5164
5758
|
return out;
|
|
5165
5759
|
}
|
|
5166
5760
|
|
|
5167
|
-
// src/lib/core/links/extract.ts
|
|
5168
|
-
import * as cheerio3 from "cheerio";
|
|
5169
|
-
var ANCHOR_SPEC = { selector: "a[href]", attr: "href", source: "a" };
|
|
5170
|
-
var ASSET_SPECS = [
|
|
5171
|
-
{ selector: "img[src]", attr: "src", source: "img" },
|
|
5172
|
-
{ selector: "script[src]", attr: "src", source: "script" },
|
|
5173
|
-
{ selector: "link[href]", attr: "href", source: "link" },
|
|
5174
|
-
{ selector: "iframe[src]", attr: "src", source: "iframe" }
|
|
5175
|
-
];
|
|
5176
|
-
function extractLinks(html, options) {
|
|
5177
|
-
const $ = cheerio3.load(html);
|
|
5178
|
-
const base = resolveBase2($, options.baseUrl);
|
|
5179
|
-
const origin = safeOrigin(options.baseUrl);
|
|
5180
|
-
const specs = options.includeAssets ? [ANCHOR_SPEC, ...ASSET_SPECS] : [ANCHOR_SPEC];
|
|
5181
|
-
const out = [];
|
|
5182
|
-
const seen = /* @__PURE__ */ new Set();
|
|
5183
|
-
for (const spec of specs) {
|
|
5184
|
-
$(spec.selector).each((_, el) => {
|
|
5185
|
-
const raw = $(el).attr(spec.attr);
|
|
5186
|
-
if (raw === void 0) return;
|
|
5187
|
-
const rawHref = raw.trim();
|
|
5188
|
-
if (!rawHref) return;
|
|
5189
|
-
const ref = buildRef($, el, rawHref, spec, base, origin);
|
|
5190
|
-
if (!ref) return;
|
|
5191
|
-
const dedupeKey = `${ref.source}|${ref.url}`;
|
|
5192
|
-
if (seen.has(dedupeKey)) return;
|
|
5193
|
-
seen.add(dedupeKey);
|
|
5194
|
-
out.push(ref);
|
|
5195
|
-
});
|
|
5196
|
-
}
|
|
5197
|
-
return out;
|
|
5198
|
-
}
|
|
5199
|
-
function buildRef($, el, rawHref, spec, base, origin) {
|
|
5200
|
-
const fragment = extractFragment(rawHref);
|
|
5201
|
-
const canonical = canonicaliseUrl(rawHref, base, { preserveTrailingSlash: true });
|
|
5202
|
-
const url = canonical ?? rawHref;
|
|
5203
|
-
const kind = canonical ? classify(canonical, origin) : "external";
|
|
5204
|
-
const ref = {
|
|
5205
|
-
rawHref,
|
|
5206
|
-
url,
|
|
5207
|
-
kind,
|
|
5208
|
-
source: spec.source,
|
|
5209
|
-
rel: spec.source === "a" || spec.source === "link" ? parseRel($(el).attr("rel")) : []
|
|
5210
|
-
};
|
|
5211
|
-
if (spec.source === "a") {
|
|
5212
|
-
const text = $(el).text().trim();
|
|
5213
|
-
if (text) ref.anchorText = text;
|
|
5214
|
-
}
|
|
5215
|
-
if (fragment) ref.fragment = fragment;
|
|
5216
|
-
return ref;
|
|
5217
|
-
}
|
|
5218
|
-
function extractFragment(rawHref) {
|
|
5219
|
-
const hash = rawHref.indexOf("#");
|
|
5220
|
-
if (hash === -1) return void 0;
|
|
5221
|
-
const frag = rawHref.slice(hash);
|
|
5222
|
-
return frag.length > 1 ? frag : void 0;
|
|
5223
|
-
}
|
|
5224
|
-
function parseRel(rel) {
|
|
5225
|
-
if (!rel) return [];
|
|
5226
|
-
return rel.split(/\s+/).map((t) => t.trim().toLowerCase()).filter(Boolean);
|
|
5227
|
-
}
|
|
5228
|
-
function classify(url, origin) {
|
|
5229
|
-
if (!origin) return "external";
|
|
5230
|
-
return safeOrigin(url) === origin ? "internal" : "external";
|
|
5231
|
-
}
|
|
5232
|
-
function safeOrigin(url) {
|
|
5233
|
-
try {
|
|
5234
|
-
return new URL(url).origin;
|
|
5235
|
-
} catch {
|
|
5236
|
-
return null;
|
|
5237
|
-
}
|
|
5238
|
-
}
|
|
5239
|
-
function resolveBase2($, pageUrl) {
|
|
5240
|
-
const declared = $("base[href]").first().attr("href")?.trim();
|
|
5241
|
-
if (declared) {
|
|
5242
|
-
try {
|
|
5243
|
-
return new URL(declared, pageUrl).toString();
|
|
5244
|
-
} catch {
|
|
5245
|
-
}
|
|
5246
|
-
}
|
|
5247
|
-
return pageUrl;
|
|
5248
|
-
}
|
|
5249
|
-
|
|
5250
5761
|
// src/lib/core/links/types.ts
|
|
5251
5762
|
function emptyVerdictSummary() {
|
|
5252
5763
|
return { ok: 0, redirect: 0, broken: 0, blocked: 0, warning: 0, skipped: 0 };
|
|
@@ -5278,8 +5789,16 @@ async function runLinkAudit(discovery, options = {}) {
|
|
|
5278
5789
|
let pagesScanned = 0;
|
|
5279
5790
|
let pagesFailed = 0;
|
|
5280
5791
|
let scanDone = 0;
|
|
5792
|
+
const maxBytes = options.maxScanBytes ?? DEFAULTS3.maxScanBytes;
|
|
5793
|
+
const handedPages = [];
|
|
5794
|
+
const toFetch = [];
|
|
5795
|
+
for (const pageUrl of pageUrls) {
|
|
5796
|
+
const handed = options.pages?.get(pageUrl);
|
|
5797
|
+
if (handed) handedPages.push([pageUrl, handed]);
|
|
5798
|
+
else toFetch.push(pageUrl);
|
|
5799
|
+
}
|
|
5281
5800
|
await mapWithHostCaps(
|
|
5282
|
-
|
|
5801
|
+
toFetch,
|
|
5283
5802
|
{
|
|
5284
5803
|
concurrency: options.scanConcurrency ?? DEFAULTS3.scanConcurrency,
|
|
5285
5804
|
perHost: Infinity,
|
|
@@ -5291,7 +5810,7 @@ async function runLinkAudit(discovery, options = {}) {
|
|
|
5291
5810
|
timeoutMs: options.timeoutMs ?? DEFAULTS3.timeoutMs,
|
|
5292
5811
|
allowInsecureTls: options.allowInsecureTls,
|
|
5293
5812
|
userAgent: options.userAgent,
|
|
5294
|
-
maxBytes
|
|
5813
|
+
maxBytes,
|
|
5295
5814
|
signal: options.signal
|
|
5296
5815
|
});
|
|
5297
5816
|
if (res.status === 0 || res.status >= 400 || res.body === void 0) {
|
|
@@ -5309,6 +5828,17 @@ async function runLinkAudit(discovery, options = {}) {
|
|
|
5309
5828
|
},
|
|
5310
5829
|
() => "scan"
|
|
5311
5830
|
);
|
|
5831
|
+
for (const [pageUrl, handed] of handedPages) {
|
|
5832
|
+
if (options.signal?.aborted) break;
|
|
5833
|
+
const refs = handed.links && !options.includeAssets && Buffer.byteLength(handed.html) <= maxBytes ? handed.links : extractLinks(capText(handed.html, maxBytes), {
|
|
5834
|
+
baseUrl: pageUrl,
|
|
5835
|
+
includeAssets: options.includeAssets
|
|
5836
|
+
});
|
|
5837
|
+
for (const ref of refs) occurrences.push({ pageUrl, ref });
|
|
5838
|
+
pagesScanned += 1;
|
|
5839
|
+
scanDone += 1;
|
|
5840
|
+
options.onProgress?.({ phase: "scan", done: scanDone, total: pageUrls.length });
|
|
5841
|
+
}
|
|
5312
5842
|
const uniqueUrls = [];
|
|
5313
5843
|
const seen = /* @__PURE__ */ new Set();
|
|
5314
5844
|
for (const occ of occurrences) {
|
|
@@ -5325,8 +5855,20 @@ async function runLinkAudit(discovery, options = {}) {
|
|
|
5325
5855
|
}
|
|
5326
5856
|
const checks = {};
|
|
5327
5857
|
let checkDone = 0;
|
|
5858
|
+
const toProbe = [];
|
|
5859
|
+
for (const url of uniqueUrls) {
|
|
5860
|
+
const answer = options.answers?.get(url);
|
|
5861
|
+
const check = answer ? checkFromAnswer(url, answer) : void 0;
|
|
5862
|
+
if (!check) {
|
|
5863
|
+
toProbe.push(url);
|
|
5864
|
+
continue;
|
|
5865
|
+
}
|
|
5866
|
+
checks[url] = check;
|
|
5867
|
+
checkDone += 1;
|
|
5868
|
+
options.onProgress?.({ phase: "check", done: checkDone, total: uniqueUrls.length });
|
|
5869
|
+
}
|
|
5328
5870
|
await mapWithHostCaps(
|
|
5329
|
-
|
|
5871
|
+
toProbe,
|
|
5330
5872
|
{
|
|
5331
5873
|
concurrency: options.checkConcurrency ?? DEFAULTS3.checkConcurrency,
|
|
5332
5874
|
perHost: options.maxPerHost ?? DEFAULTS3.maxPerHost,
|
|
@@ -5400,9 +5942,9 @@ function hostOf(url) {
|
|
|
5400
5942
|
function mapWithHostCaps(items, opts, worker, hostOf3) {
|
|
5401
5943
|
const concurrency = Math.max(1, opts.concurrency);
|
|
5402
5944
|
const perHost = opts.perHost;
|
|
5403
|
-
return new Promise((
|
|
5945
|
+
return new Promise((resolve3) => {
|
|
5404
5946
|
if (items.length === 0) {
|
|
5405
|
-
|
|
5947
|
+
resolve3();
|
|
5406
5948
|
return;
|
|
5407
5949
|
}
|
|
5408
5950
|
const pending = items.map((item) => ({ item, host: hostOf3(item) }));
|
|
@@ -5412,7 +5954,7 @@ function mapWithHostCaps(items, opts, worker, hostOf3) {
|
|
|
5412
5954
|
const finish = () => {
|
|
5413
5955
|
if (!settled) {
|
|
5414
5956
|
settled = true;
|
|
5415
|
-
|
|
5957
|
+
resolve3();
|
|
5416
5958
|
}
|
|
5417
5959
|
};
|
|
5418
5960
|
const pump = () => {
|
|
@@ -5578,14 +6120,14 @@ async function attachManifests(pages, options) {
|
|
|
5578
6120
|
}
|
|
5579
6121
|
}
|
|
5580
6122
|
async function probeAssets(pages, options) {
|
|
5581
|
-
const
|
|
6123
|
+
const sameOrigin4 = (url) => {
|
|
5582
6124
|
try {
|
|
5583
6125
|
return new URL(url).origin === new URL(options.origin).origin;
|
|
5584
6126
|
} catch {
|
|
5585
6127
|
return false;
|
|
5586
6128
|
}
|
|
5587
6129
|
};
|
|
5588
|
-
const wantedUrl = (url) => /^https?:\/\//i.test(url) && (options.checkExternal ||
|
|
6130
|
+
const wantedUrl = (url) => /^https?:\/\//i.test(url) && (options.checkExternal || sameOrigin4(url));
|
|
5589
6131
|
const wanted = /* @__PURE__ */ new Set();
|
|
5590
6132
|
for (const page of pages) {
|
|
5591
6133
|
for (const image of page.openGraph.images) {
|
|
@@ -5639,7 +6181,8 @@ function dropCanonicalDuplicates(pages) {
|
|
|
5639
6181
|
if (target === self) return true;
|
|
5640
6182
|
return !crawled.has(target);
|
|
5641
6183
|
});
|
|
5642
|
-
|
|
6184
|
+
const keptSet = new Set(kept);
|
|
6185
|
+
return { kept, variants: pages.filter((page) => !keptSet.has(page)) };
|
|
5643
6186
|
}
|
|
5644
6187
|
async function runAudit(inputUrl, options = {}) {
|
|
5645
6188
|
const normalized = normalizeInputUrl(inputUrl);
|
|
@@ -5673,6 +6216,14 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
5673
6216
|
signal: options.signal,
|
|
5674
6217
|
timeoutMs: options.timeoutMs
|
|
5675
6218
|
}).catch(() => void 0);
|
|
6219
|
+
const [notFound, llmsTxt] = await Promise.all([
|
|
6220
|
+
probeNotFound(origin, { signal: options.signal, timeoutMs: options.timeoutMs }).catch(
|
|
6221
|
+
() => void 0
|
|
6222
|
+
),
|
|
6223
|
+
probeLlmsTxt(origin, { signal: options.signal, timeoutMs: options.timeoutMs }).catch(
|
|
6224
|
+
() => void 0
|
|
6225
|
+
)
|
|
6226
|
+
]);
|
|
5676
6227
|
const crawlResult = await crawl({
|
|
5677
6228
|
entryUrl: entry,
|
|
5678
6229
|
seedUrls: sitemapUrls,
|
|
@@ -5683,6 +6234,7 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
5683
6234
|
inspectOptions: {
|
|
5684
6235
|
mode: options.static ? "static" : "auto",
|
|
5685
6236
|
probes: false,
|
|
6237
|
+
linkRefs: true,
|
|
5686
6238
|
allowInsecureTls: options.allowInsecureTls,
|
|
5687
6239
|
timeoutMs: options.timeoutMs,
|
|
5688
6240
|
signal: options.signal
|
|
@@ -5706,7 +6258,8 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
5706
6258
|
if (escalationWarning) warnings.push(escalationWarning);
|
|
5707
6259
|
const okPages = pages.filter((p) => isOkStatus(p.fetch.status));
|
|
5708
6260
|
const documents = okPages.filter(isHtmlPage);
|
|
5709
|
-
const { kept: htmlPages,
|
|
6261
|
+
const { kept: htmlPages, variants } = dropCanonicalDuplicates(documents);
|
|
6262
|
+
const duplicatePages = variants.length;
|
|
5710
6263
|
const unreachablePages = [
|
|
5711
6264
|
...pages.filter((p) => !isOkStatus(p.fetch.status)).map((p) => ({
|
|
5712
6265
|
id: fingerprint("page", routeKey(p.fetch.finalUrl)),
|
|
@@ -5822,6 +6375,16 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
5822
6375
|
checkExternal,
|
|
5823
6376
|
timeoutMs: options.timeoutMs,
|
|
5824
6377
|
maxPages: effectiveMaxPages,
|
|
6378
|
+
pages: new Map(
|
|
6379
|
+
okPages.filter(isHtmlPage).map((page) => [
|
|
6380
|
+
page.fetch.finalUrl,
|
|
6381
|
+
{
|
|
6382
|
+
html: page.html.static,
|
|
6383
|
+
links: page.extractor.escalated ? void 0 : page.linkRefs
|
|
6384
|
+
}
|
|
6385
|
+
])
|
|
6386
|
+
),
|
|
6387
|
+
answers: crawlAnswers(okPages),
|
|
5825
6388
|
signal: options.signal,
|
|
5826
6389
|
onProgress: (p) => options.onProgress?.({
|
|
5827
6390
|
phase: p.phase === "scan" ? "scan" : "links",
|
|
@@ -5830,18 +6393,23 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
5830
6393
|
})
|
|
5831
6394
|
});
|
|
5832
6395
|
warnings.push(...linkReport.diagnostics.warnings);
|
|
6396
|
+
const answered = {
|
|
6397
|
+
crawled: new Map(crawlResult.pages.map((page) => [page.fetch.finalUrl, page.fetch.status])),
|
|
6398
|
+
checked: new Map(Object.values(linkReport.checks).map((check) => [check.url, check])),
|
|
6399
|
+
maxProbes: effectiveMaxPages,
|
|
6400
|
+
signal: options.signal,
|
|
6401
|
+
timeoutMs: options.timeoutMs,
|
|
6402
|
+
allowInsecureTls: options.allowInsecureTls
|
|
6403
|
+
};
|
|
5833
6404
|
const sitemapEntries = discovery ? await probeSitemapEntries(
|
|
5834
6405
|
discovery.urls.map((entry2) => entry2.loc),
|
|
5835
|
-
|
|
5836
|
-
|
|
5837
|
-
|
|
5838
|
-
|
|
5839
|
-
|
|
5840
|
-
|
|
5841
|
-
|
|
5842
|
-
timeoutMs: options.timeoutMs,
|
|
5843
|
-
allowInsecureTls: options.allowInsecureTls
|
|
5844
|
-
}
|
|
6406
|
+
answered
|
|
6407
|
+
) : void 0;
|
|
6408
|
+
const llmsTxtLinks = llmsTxt?.found && !llmsTxt.html ? await probeSitemapEntries(
|
|
6409
|
+
[...new Set(llmsTxt.links.map((link) => link.url))].filter(
|
|
6410
|
+
(url) => sameOriginAs(url, origin)
|
|
6411
|
+
),
|
|
6412
|
+
answered
|
|
5845
6413
|
) : void 0;
|
|
5846
6414
|
const siteContext = {
|
|
5847
6415
|
origin,
|
|
@@ -5851,7 +6419,14 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
5851
6419
|
discovery,
|
|
5852
6420
|
robots,
|
|
5853
6421
|
favicon: favicon2,
|
|
6422
|
+
notFound,
|
|
6423
|
+
llmsTxt,
|
|
6424
|
+
llmsTxtLinks,
|
|
5854
6425
|
sitemapEntries,
|
|
6426
|
+
// Set aside above, and still what their URLs serve. The sitemap rules that
|
|
6427
|
+
// ask what a listed URL serves need them: a listed variant is exactly what
|
|
6428
|
+
// `sitemap.entry.non-canonical` exists to catch.
|
|
6429
|
+
variants,
|
|
5855
6430
|
// Same index the matrix was built from, so a rule that groups by route
|
|
5856
6431
|
// and the grid that reports holes cannot disagree about which URLs are
|
|
5857
6432
|
// one page.
|
|
@@ -6014,6 +6589,28 @@ function countUncrawled(sitemapUrls, pages) {
|
|
|
6014
6589
|
for (const url of sitemapUrls) if (!crawled.has(url)) n += 1;
|
|
6015
6590
|
return n;
|
|
6016
6591
|
}
|
|
6592
|
+
function sameOriginAs(url, origin) {
|
|
6593
|
+
try {
|
|
6594
|
+
return new URL(url).origin === origin;
|
|
6595
|
+
} catch {
|
|
6596
|
+
return false;
|
|
6597
|
+
}
|
|
6598
|
+
}
|
|
6599
|
+
function crawlAnswers(pages) {
|
|
6600
|
+
const answers = /* @__PURE__ */ new Map();
|
|
6601
|
+
for (const page of pages) {
|
|
6602
|
+
if (!isOkStatus(page.fetch.status) || !isHtmlPage(page)) continue;
|
|
6603
|
+
if (page.extractor.escalated) continue;
|
|
6604
|
+
if (page.fetch.redirectCount > 0 || page.fetch.finalUrl !== page.fetch.requestedUrl) continue;
|
|
6605
|
+
answers.set(page.fetch.requestedUrl, {
|
|
6606
|
+
status: page.fetch.status,
|
|
6607
|
+
body: page.html.static,
|
|
6608
|
+
checkedAt: page.fetchedAt,
|
|
6609
|
+
durationMs: page.fetch.durationMs
|
|
6610
|
+
});
|
|
6611
|
+
}
|
|
6612
|
+
return answers;
|
|
6613
|
+
}
|
|
6017
6614
|
function syntheticDiscovery(origin, baseUrl, pages, truncated) {
|
|
6018
6615
|
return {
|
|
6019
6616
|
origin,
|
|
@@ -6436,7 +7033,7 @@ function hostOf2(value) {
|
|
|
6436
7033
|
return value;
|
|
6437
7034
|
}
|
|
6438
7035
|
}
|
|
6439
|
-
function
|
|
7036
|
+
function pathOf2(value) {
|
|
6440
7037
|
try {
|
|
6441
7038
|
const { pathname, search } = new URL(value);
|
|
6442
7039
|
return `${pathname}${search}`;
|
|
@@ -6465,7 +7062,7 @@ function originLabel(origin) {
|
|
|
6465
7062
|
return "computed";
|
|
6466
7063
|
}
|
|
6467
7064
|
}
|
|
6468
|
-
function
|
|
7065
|
+
function resolve2(...facts) {
|
|
6469
7066
|
for (const fact of facts) {
|
|
6470
7067
|
if (fact && fact.value.trim() !== "") {
|
|
6471
7068
|
return { value: fact.value, from: originLabel(fact.origin) };
|
|
@@ -6509,13 +7106,13 @@ function cardOf(extraction) {
|
|
|
6509
7106
|
return {
|
|
6510
7107
|
url,
|
|
6511
7108
|
host: hostOf2(url),
|
|
6512
|
-
path:
|
|
6513
|
-
ogTitle:
|
|
6514
|
-
ogDescription:
|
|
6515
|
-
documentTitle:
|
|
6516
|
-
metaDescription:
|
|
6517
|
-
siteName:
|
|
6518
|
-
xTitle:
|
|
7109
|
+
path: pathOf2(url),
|
|
7110
|
+
ogTitle: resolve2(og.title, doc.title),
|
|
7111
|
+
ogDescription: resolve2(og.description, meta.description),
|
|
7112
|
+
documentTitle: resolve2(doc.title),
|
|
7113
|
+
metaDescription: resolve2(meta.description),
|
|
7114
|
+
siteName: resolve2(og.siteName),
|
|
7115
|
+
xTitle: resolve2(twitter.title, og.title, doc.title),
|
|
6519
7116
|
image: imageOf(extraction),
|
|
6520
7117
|
icon: safeUrl(extraction.links.icons[0]?.href),
|
|
6521
7118
|
themeColor: meta.themeColor?.value,
|
|
@@ -7018,7 +7615,7 @@ function renderPreview(report, options = {}) {
|
|
|
7018
7615
|
}
|
|
7019
7616
|
const empty = report.extractions === void 0 ? `This report carries no extractions, so there is nothing to draw. Pass <code>extractions: true</code> to <code>runAudit</code>, or use <code>goflag preview <url></code>, which does.` : `The crawl reached no HTML page it could read, so there is nothing to draw. Every page was unreachable, not HTML, or a declared duplicate of another.`;
|
|
7020
7617
|
const rows = extractions.map((extraction, index) => {
|
|
7021
|
-
const path =
|
|
7618
|
+
const path = pathOf2(extraction.http.finalUrl);
|
|
7022
7619
|
return {
|
|
7023
7620
|
index,
|
|
7024
7621
|
locale: splitRoute(path).locale,
|
|
@@ -7030,7 +7627,7 @@ function renderPreview(report, options = {}) {
|
|
|
7030
7627
|
});
|
|
7031
7628
|
const byRoute = /* @__PURE__ */ new Map();
|
|
7032
7629
|
extractions.forEach((extraction, index) => {
|
|
7033
|
-
const route = splitRoute(
|
|
7630
|
+
const route = splitRoute(pathOf2(extraction.http.finalUrl)).route;
|
|
7034
7631
|
byRoute.set(route, [...byRoute.get(route) ?? [], rows[index]]);
|
|
7035
7632
|
});
|
|
7036
7633
|
const body = extractions.length === 0 ? `<section class="page"><p class="none">${empty}</p></section>` : extractions.map(
|
|
@@ -7038,13 +7635,13 @@ function renderPreview(report, options = {}) {
|
|
|
7038
7635
|
extraction,
|
|
7039
7636
|
issuesByPage.get(extraction.http.finalUrl) ?? [],
|
|
7040
7637
|
index,
|
|
7041
|
-
byRoute.get(splitRoute(
|
|
7638
|
+
byRoute.get(splitRoute(pathOf2(extraction.http.finalUrl)).route) ?? []
|
|
7042
7639
|
)
|
|
7043
7640
|
).join("\n");
|
|
7044
7641
|
const nav = extractions.length > 1 ? `<nav class="nav">
|
|
7045
7642
|
<p class="navnote">${extractions.length} pages, by path. A folder folds; the \u2197 beside one opens the page at that path itself.</p>
|
|
7046
7643
|
${routeList(
|
|
7047
|
-
buildRouteTree(extractions.map((extraction) =>
|
|
7644
|
+
buildRouteTree(extractions.map((extraction) => pathOf2(extraction.http.finalUrl))),
|
|
7048
7645
|
extractions.length <= TREE_OPEN_MAX,
|
|
7049
7646
|
"tree"
|
|
7050
7647
|
)}
|
|
@@ -7362,4 +7959,4 @@ export {
|
|
|
7362
7959
|
summarize,
|
|
7363
7960
|
Logger
|
|
7364
7961
|
};
|
|
7365
|
-
//# sourceMappingURL=chunk-
|
|
7962
|
+
//# sourceMappingURL=chunk-KHIN5TNX.js.map
|