@goflag/cli 0.2.14 → 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 +3 -3
- package/dist/{chunk-YQPL67HX.js → chunk-KHIN5TNX.js} +282 -158
- package/dist/chunk-KHIN5TNX.js.map +1 -0
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +1 -1
- package/package.json +2 -2
- package/dist/chunk-YQPL67HX.js.map +0 -1
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ here and carries no code.
|
|
|
37
37
|
|
|
38
38
|
## What it does
|
|
39
39
|
|
|
40
|
-
- **Broken links** —
|
|
40
|
+
- **Broken links** — reads the links of every crawled page from the copy the crawl kept, dedupes targets globally (a footer link on 500 pages is probed once, and a link to a page the crawl fetched without a redirect is judged from that answer rather than asked again), and checks each with `HEAD`→`GET` fallback, redirect-chain/loop detection, soft-404 and anti-bot (403/429) triage. Broken links are mapped back to the pages that reference them.
|
|
41
41
|
- **Missing translation pages** — builds a `route × locale` matrix and flags every route that exists in one locale but is missing in another, plus hreflang reciprocity gaps (`A → B` with no `B → A`), missing `x-default`, and locale tags naming a language, script or region that does not exist. Rows are keyed by pathname, unless the site declares its translation clusters — either with `xhtml:link` in the sitemap, or with reciprocal `hreflang` between two crawled pages' `<head>`. Then those declarations decide which URLs are one page, whatever their slugs, and the run reports `diagnostics.declaredClusters`. The sitemap source survives `--coverage structural`, where the two locales of a slug-translating family are rarely both sampled; the `<head>` source needs both pages in hand and is what fixes the common site that declares properly there and nothing in its sitemap.
|
|
42
42
|
- **robots.txt policy** — the file is parsed to RFC 9309, not scanned: every group, every rule, every line it could not read, each with its number. goflag flags a site that forbids crawling while its pages ask to be indexed — site-wide, and now per path, which is the quiet version nothing was watching. It also reports the file that errors (a 5xx there is read as a site-wide ban), one over the 500 KiB a parser must honour, a typo that silently deletes the rule you meant to write, a redirect handing your crawl policy to another origin, and a `Sitemap:` that is not a full URL.
|
|
43
43
|
- **SEO metadata** — lints each page's `<head>` for the handful of mistakes that actually hurt in search and social and are invisible in a browser: missing/oversized `<title>` and description, missing/relative canonical, missing `og:title`/`og:description`/`og:image`, missing viewport, and contradictory `robots`/`googlebot`/`X-Robots-Tag` directives.
|
|
@@ -558,7 +558,7 @@ Three rules, whichever you use:
|
|
|
558
558
|
stages: [build, deploy, audit]
|
|
559
559
|
|
|
560
560
|
variables:
|
|
561
|
-
GOFLAG_VERSION: "0.2.
|
|
561
|
+
GOFLAG_VERSION: "0.2.15"
|
|
562
562
|
|
|
563
563
|
# Before the merge: build the branch, let goflag boot it, audit that.
|
|
564
564
|
seo:mr:
|
|
@@ -610,7 +610,7 @@ jobs:
|
|
|
610
610
|
- run: corepack enable && pnpm install --frozen-lockfile
|
|
611
611
|
- run: pnpm build
|
|
612
612
|
- run: |
|
|
613
|
-
npx --yes @goflag/cli@0.2.
|
|
613
|
+
npx --yes @goflag/cli@0.2.15 http://localhost:3000 \
|
|
614
614
|
--start "pnpm start" --static --no-external \
|
|
615
615
|
--baseline .goflag/baseline.json --regressions-only --max-debt 41 \
|
|
616
616
|
--report goflag-report.json
|
|
@@ -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);
|
|
@@ -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) {
|
|
@@ -4183,7 +4301,7 @@ function suggestedLocales(axis) {
|
|
|
4183
4301
|
import { gunzipSync } from "zlib";
|
|
4184
4302
|
|
|
4185
4303
|
// src/lib/core/sitemap/parse.ts
|
|
4186
|
-
import * as
|
|
4304
|
+
import * as cheerio4 from "cheerio";
|
|
4187
4305
|
function alternatesOf(node) {
|
|
4188
4306
|
const out = [];
|
|
4189
4307
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -4211,7 +4329,7 @@ function parseSitemap(body) {
|
|
|
4211
4329
|
}
|
|
4212
4330
|
let $;
|
|
4213
4331
|
try {
|
|
4214
|
-
$ =
|
|
4332
|
+
$ = cheerio4.load(trimmed, { xmlMode: true });
|
|
4215
4333
|
} catch {
|
|
4216
4334
|
return { kind: "unknown", wellFormed: false, urls: [], sitemaps: [] };
|
|
4217
4335
|
}
|
|
@@ -4608,6 +4726,10 @@ function normaliseContentType(raw) {
|
|
|
4608
4726
|
const semi = raw.indexOf(";");
|
|
4609
4727
|
return (semi === -1 ? raw : raw.slice(0, semi)).trim().toLowerCase() || void 0;
|
|
4610
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
|
+
}
|
|
4611
4733
|
async function readCappedText(res, maxBytes) {
|
|
4612
4734
|
const reader = res.body?.getReader();
|
|
4613
4735
|
if (!reader) {
|
|
@@ -4740,6 +4862,34 @@ async function checkLink(url, options = {}) {
|
|
|
4740
4862
|
durationMs: Date.now() - started
|
|
4741
4863
|
};
|
|
4742
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
|
+
}
|
|
4743
4893
|
async function probeWithRetry(url, options) {
|
|
4744
4894
|
const retries = options.retries ?? DEFAULTS2.retries;
|
|
4745
4895
|
const sleep = options.sleep ?? defaultSleep;
|
|
@@ -5608,89 +5758,6 @@ function collectSiteAdvisories(site, rules = SITE_PROSE_RULES) {
|
|
|
5608
5758
|
return out;
|
|
5609
5759
|
}
|
|
5610
5760
|
|
|
5611
|
-
// src/lib/core/links/extract.ts
|
|
5612
|
-
import * as cheerio3 from "cheerio";
|
|
5613
|
-
var ANCHOR_SPEC = { selector: "a[href]", attr: "href", source: "a" };
|
|
5614
|
-
var ASSET_SPECS = [
|
|
5615
|
-
{ selector: "img[src]", attr: "src", source: "img" },
|
|
5616
|
-
{ selector: "script[src]", attr: "src", source: "script" },
|
|
5617
|
-
{ selector: "link[href]", attr: "href", source: "link" },
|
|
5618
|
-
{ selector: "iframe[src]", attr: "src", source: "iframe" }
|
|
5619
|
-
];
|
|
5620
|
-
function extractLinks(html, options) {
|
|
5621
|
-
const $ = cheerio3.load(html);
|
|
5622
|
-
const base = resolveBase2($, options.baseUrl);
|
|
5623
|
-
const origin = safeOrigin(options.baseUrl);
|
|
5624
|
-
const specs = options.includeAssets ? [ANCHOR_SPEC, ...ASSET_SPECS] : [ANCHOR_SPEC];
|
|
5625
|
-
const out = [];
|
|
5626
|
-
const seen = /* @__PURE__ */ new Set();
|
|
5627
|
-
for (const spec of specs) {
|
|
5628
|
-
$(spec.selector).each((_, el) => {
|
|
5629
|
-
const raw = $(el).attr(spec.attr);
|
|
5630
|
-
if (raw === void 0) return;
|
|
5631
|
-
const rawHref = raw.trim();
|
|
5632
|
-
if (!rawHref) return;
|
|
5633
|
-
const ref = buildRef($, el, rawHref, spec, base, origin);
|
|
5634
|
-
if (!ref) return;
|
|
5635
|
-
const dedupeKey = `${ref.source}|${ref.url}`;
|
|
5636
|
-
if (seen.has(dedupeKey)) return;
|
|
5637
|
-
seen.add(dedupeKey);
|
|
5638
|
-
out.push(ref);
|
|
5639
|
-
});
|
|
5640
|
-
}
|
|
5641
|
-
return out;
|
|
5642
|
-
}
|
|
5643
|
-
function buildRef($, el, rawHref, spec, base, origin) {
|
|
5644
|
-
const fragment = extractFragment(rawHref);
|
|
5645
|
-
const canonical = canonicaliseUrl(rawHref, base, { preserveTrailingSlash: true });
|
|
5646
|
-
const url = canonical ?? rawHref;
|
|
5647
|
-
const kind = canonical ? classify(canonical, origin) : "external";
|
|
5648
|
-
const ref = {
|
|
5649
|
-
rawHref,
|
|
5650
|
-
url,
|
|
5651
|
-
kind,
|
|
5652
|
-
source: spec.source,
|
|
5653
|
-
rel: spec.source === "a" || spec.source === "link" ? parseRel($(el).attr("rel")) : []
|
|
5654
|
-
};
|
|
5655
|
-
if (spec.source === "a") {
|
|
5656
|
-
const text = $(el).text().trim();
|
|
5657
|
-
if (text) ref.anchorText = text;
|
|
5658
|
-
}
|
|
5659
|
-
if (fragment) ref.fragment = fragment;
|
|
5660
|
-
return ref;
|
|
5661
|
-
}
|
|
5662
|
-
function extractFragment(rawHref) {
|
|
5663
|
-
const hash = rawHref.indexOf("#");
|
|
5664
|
-
if (hash === -1) return void 0;
|
|
5665
|
-
const frag = rawHref.slice(hash);
|
|
5666
|
-
return frag.length > 1 ? frag : void 0;
|
|
5667
|
-
}
|
|
5668
|
-
function parseRel(rel) {
|
|
5669
|
-
if (!rel) return [];
|
|
5670
|
-
return rel.split(/\s+/).map((t) => t.trim().toLowerCase()).filter(Boolean);
|
|
5671
|
-
}
|
|
5672
|
-
function classify(url, origin) {
|
|
5673
|
-
if (!origin) return "external";
|
|
5674
|
-
return safeOrigin(url) === origin ? "internal" : "external";
|
|
5675
|
-
}
|
|
5676
|
-
function safeOrigin(url) {
|
|
5677
|
-
try {
|
|
5678
|
-
return new URL(url).origin;
|
|
5679
|
-
} catch {
|
|
5680
|
-
return null;
|
|
5681
|
-
}
|
|
5682
|
-
}
|
|
5683
|
-
function resolveBase2($, pageUrl) {
|
|
5684
|
-
const declared = $("base[href]").first().attr("href")?.trim();
|
|
5685
|
-
if (declared) {
|
|
5686
|
-
try {
|
|
5687
|
-
return new URL(declared, pageUrl).toString();
|
|
5688
|
-
} catch {
|
|
5689
|
-
}
|
|
5690
|
-
}
|
|
5691
|
-
return pageUrl;
|
|
5692
|
-
}
|
|
5693
|
-
|
|
5694
5761
|
// src/lib/core/links/types.ts
|
|
5695
5762
|
function emptyVerdictSummary() {
|
|
5696
5763
|
return { ok: 0, redirect: 0, broken: 0, blocked: 0, warning: 0, skipped: 0 };
|
|
@@ -5722,8 +5789,16 @@ async function runLinkAudit(discovery, options = {}) {
|
|
|
5722
5789
|
let pagesScanned = 0;
|
|
5723
5790
|
let pagesFailed = 0;
|
|
5724
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
|
+
}
|
|
5725
5800
|
await mapWithHostCaps(
|
|
5726
|
-
|
|
5801
|
+
toFetch,
|
|
5727
5802
|
{
|
|
5728
5803
|
concurrency: options.scanConcurrency ?? DEFAULTS3.scanConcurrency,
|
|
5729
5804
|
perHost: Infinity,
|
|
@@ -5735,7 +5810,7 @@ async function runLinkAudit(discovery, options = {}) {
|
|
|
5735
5810
|
timeoutMs: options.timeoutMs ?? DEFAULTS3.timeoutMs,
|
|
5736
5811
|
allowInsecureTls: options.allowInsecureTls,
|
|
5737
5812
|
userAgent: options.userAgent,
|
|
5738
|
-
maxBytes
|
|
5813
|
+
maxBytes,
|
|
5739
5814
|
signal: options.signal
|
|
5740
5815
|
});
|
|
5741
5816
|
if (res.status === 0 || res.status >= 400 || res.body === void 0) {
|
|
@@ -5753,6 +5828,17 @@ async function runLinkAudit(discovery, options = {}) {
|
|
|
5753
5828
|
},
|
|
5754
5829
|
() => "scan"
|
|
5755
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
|
+
}
|
|
5756
5842
|
const uniqueUrls = [];
|
|
5757
5843
|
const seen = /* @__PURE__ */ new Set();
|
|
5758
5844
|
for (const occ of occurrences) {
|
|
@@ -5769,8 +5855,20 @@ async function runLinkAudit(discovery, options = {}) {
|
|
|
5769
5855
|
}
|
|
5770
5856
|
const checks = {};
|
|
5771
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
|
+
}
|
|
5772
5870
|
await mapWithHostCaps(
|
|
5773
|
-
|
|
5871
|
+
toProbe,
|
|
5774
5872
|
{
|
|
5775
5873
|
concurrency: options.checkConcurrency ?? DEFAULTS3.checkConcurrency,
|
|
5776
5874
|
perHost: options.maxPerHost ?? DEFAULTS3.maxPerHost,
|
|
@@ -6136,6 +6234,7 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
6136
6234
|
inspectOptions: {
|
|
6137
6235
|
mode: options.static ? "static" : "auto",
|
|
6138
6236
|
probes: false,
|
|
6237
|
+
linkRefs: true,
|
|
6139
6238
|
allowInsecureTls: options.allowInsecureTls,
|
|
6140
6239
|
timeoutMs: options.timeoutMs,
|
|
6141
6240
|
signal: options.signal
|
|
@@ -6276,6 +6375,16 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
6276
6375
|
checkExternal,
|
|
6277
6376
|
timeoutMs: options.timeoutMs,
|
|
6278
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),
|
|
6279
6388
|
signal: options.signal,
|
|
6280
6389
|
onProgress: (p) => options.onProgress?.({
|
|
6281
6390
|
phase: p.phase === "scan" ? "scan" : "links",
|
|
@@ -6487,6 +6596,21 @@ function sameOriginAs(url, origin) {
|
|
|
6487
6596
|
return false;
|
|
6488
6597
|
}
|
|
6489
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
|
+
}
|
|
6490
6614
|
function syntheticDiscovery(origin, baseUrl, pages, truncated) {
|
|
6491
6615
|
return {
|
|
6492
6616
|
origin,
|
|
@@ -7835,4 +7959,4 @@ export {
|
|
|
7835
7959
|
summarize,
|
|
7836
7960
|
Logger
|
|
7837
7961
|
};
|
|
7838
|
-
//# sourceMappingURL=chunk-
|
|
7962
|
+
//# sourceMappingURL=chunk-KHIN5TNX.js.map
|