eaa-kit 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -4
- package/dist/astro/index.d.ts +0 -9
- package/dist/astro/index.js +1 -1
- package/dist/audit-CqrR9pIO.js +2 -0
- package/dist/{audit-6gbV0Zjd.js → audit-D4gju2BT.js} +507 -140
- package/dist/{baseline-Itspu3-Y.js → baseline-CV_3lbER.js} +1 -1
- package/dist/{baseline-DQTnNlc4.js → baseline-CgBmzFTr.js} +16 -16
- package/dist/cli/index.js +54 -34
- package/dist/crawl-CtJbMNNb.js +254 -0
- package/dist/index.d.ts +155 -65
- package/dist/index.js +2 -1
- package/dist/init-DRKIdpK1.js +110 -0
- package/dist/{json-1ESNIiHY.js → json-C9xS1PNC.js} +3 -1
- package/dist/load-sCkKsvGQ.js +192 -0
- package/dist/{playwright-BfWuTG_u.js → playwright-DWux49V3.js} +4 -4
- package/dist/project-CiyzKQud.js +258 -0
- package/dist/{render-K9KxDDSA.js → render-BO0nVrrZ.js} +21 -208
- package/dist/routes-BxbSZKXC.js +123 -0
- package/dist/schema-CMZ8ItGk.js +192 -0
- package/package.json +4 -5
- package/dist/audit-VtuUOuyX.js +0 -2
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as readBaseline, r as applyBaseline, t as BaselineError } from "./baseline-
|
|
1
|
+
import { a as readBaseline, r as applyBaseline, t as BaselineError } from "./baseline-CgBmzFTr.js";
|
|
2
2
|
export { BaselineError, applyBaseline, readBaseline };
|
|
@@ -1,30 +1,30 @@
|
|
|
1
|
+
import { _ as withDefault, c as object, f as safeParse, i as isoDate$1, l as optional, o as nullable, p as string, s as number, t as array } from "./schema-CMZ8ItGk.js";
|
|
1
2
|
import { t as elementFingerprint } from "./fingerprint-DRoneAjj.js";
|
|
2
|
-
import { z } from "zod";
|
|
3
3
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
/** Default filename, used by the CLI when no path is given. */
|
|
6
6
|
const DEFAULT_BASELINE_FILE = "eaa-baseline.json";
|
|
7
|
-
const entrySchema =
|
|
7
|
+
const entrySchema = object({
|
|
8
8
|
/** Page path relative to the audited directory, POSIX separators. */
|
|
9
|
-
page:
|
|
10
|
-
ruleId:
|
|
9
|
+
page: string(),
|
|
10
|
+
ruleId: string(),
|
|
11
11
|
/** Identity of the element, from elementFingerprint. */
|
|
12
|
-
fingerprint:
|
|
12
|
+
fingerprint: string(),
|
|
13
13
|
/** Carried for readability only; matching never looks at these. */
|
|
14
|
-
selector:
|
|
15
|
-
help:
|
|
16
|
-
impact:
|
|
14
|
+
selector: withDefault(string(), () => ""),
|
|
15
|
+
help: withDefault(string(), () => ""),
|
|
16
|
+
impact: withDefault(nullable(string()), () => null),
|
|
17
17
|
/** ISO date the entry was written. */
|
|
18
|
-
acceptedOn:
|
|
18
|
+
acceptedOn: withDefault(string(), () => ""),
|
|
19
19
|
/** ISO date after which this entry stops suppressing anything. */
|
|
20
|
-
expiresOn:
|
|
20
|
+
expiresOn: optional(isoDate$1()),
|
|
21
21
|
/** Why this is being lived with. Free text, for whoever reads the file. */
|
|
22
|
-
note:
|
|
22
|
+
note: optional(string())
|
|
23
23
|
});
|
|
24
|
-
const baselineSchema =
|
|
25
|
-
schemaVersion:
|
|
26
|
-
createdOn:
|
|
27
|
-
entries:
|
|
24
|
+
const baselineSchema = object({
|
|
25
|
+
schemaVersion: number(),
|
|
26
|
+
createdOn: withDefault(string(), () => ""),
|
|
27
|
+
entries: withDefault(array(entrySchema), () => [])
|
|
28
28
|
});
|
|
29
29
|
var BaselineError = class extends Error {
|
|
30
30
|
name = "BaselineError";
|
|
@@ -142,7 +142,7 @@ async function readBaseline(file, cwd = process.cwd()) {
|
|
|
142
142
|
} catch (cause) {
|
|
143
143
|
throw new BaselineError(`${path.basename(target)} is not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
144
144
|
}
|
|
145
|
-
const result =
|
|
145
|
+
const result = safeParse(baselineSchema, value);
|
|
146
146
|
if (!result.success) {
|
|
147
147
|
const issues = result.error.issues.map((issue) => `${issue.path.join(".") || "document"}: ${issue.message}`).slice(0, 5);
|
|
148
148
|
throw new BaselineError(`${path.basename(target)} is not an eaa-kit baseline (${issues.join("; ")})`);
|
package/dist/cli/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { l as STATEMENT_LOCALES, o as COUNTRIES, r as loadConfig, s as ConfigError } from "../load-sCkKsvGQ.js";
|
|
3
|
+
import { i as readAuditReport, o as StatementError, t as renderStatement } from "../render-BO0nVrrZ.js";
|
|
3
4
|
import { i as isImpactLevel, n as IMPACT_LEVELS, t as DEFAULT_FAIL_ON } from "../impact-DvgBjupx.js";
|
|
4
5
|
import { t as TOOL_VERSION } from "../version-B3v4rNoG.js";
|
|
5
|
-
import { i as buildBaseline, n as DEFAULT_BASELINE_FILE, s as writeBaseline, t as BaselineError } from "../baseline-
|
|
6
|
-
import {
|
|
6
|
+
import { i as buildBaseline, n as DEFAULT_BASELINE_FILE, s as writeBaseline, t as BaselineError } from "../baseline-CgBmzFTr.js";
|
|
7
|
+
import { i as resolvePages, n as isOutputFormat, r as runAuditCommand, t as OUTPUT_FORMATS } from "../audit-D4gju2BT.js";
|
|
7
8
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
8
9
|
import path from "node:path";
|
|
9
10
|
import { Command, InvalidArgumentError } from "commander";
|
|
@@ -23,39 +24,26 @@ import pc from "picocolors";
|
|
|
23
24
|
*/
|
|
24
25
|
async function runBaselineCommand(dir, options = {}) {
|
|
25
26
|
const cwd = options.cwd ?? process.cwd();
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
exitCode: 2
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
throw cause;
|
|
41
|
-
}
|
|
42
|
-
if (pages.length === 0) {
|
|
43
|
-
process.stderr.write(`${pc.yellow("warning")} No HTML files found in ${dir}\n`);
|
|
44
|
-
return {
|
|
45
|
-
entries: 0,
|
|
46
|
-
exitCode: 2
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
process.stderr.write(pc.dim(`Auditing ${pages.length} ${pages.length === 1 ? "page" : "pages"} in ${dir}…\n`));
|
|
27
|
+
const resolved = await resolvePages(path.resolve(cwd, dir), {
|
|
28
|
+
...options,
|
|
29
|
+
label: dir
|
|
30
|
+
});
|
|
31
|
+
if (!resolved) return {
|
|
32
|
+
entries: 0,
|
|
33
|
+
exitCode: 2
|
|
34
|
+
};
|
|
35
|
+
const { pages, origin, label } = resolved;
|
|
36
|
+
process.stderr.write(pc.dim(`Auditing ${pages.length} ${pages.length === 1 ? "page" : "pages"} in ${label}…\n`));
|
|
37
|
+
const effectiveBaseUrl = options.baseUrl ?? origin;
|
|
50
38
|
const runnerOptions = {
|
|
51
|
-
...
|
|
39
|
+
...effectiveBaseUrl === void 0 ? {} : { baseUrl: effectiveBaseUrl },
|
|
52
40
|
...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs }
|
|
53
41
|
};
|
|
54
42
|
let audits;
|
|
55
43
|
if (options.browser) {
|
|
56
|
-
const { BrowserUnavailableError, runBrowserAudit } = await import("../playwright-
|
|
44
|
+
const { BrowserUnavailableError, runBrowserAudit } = await import("../playwright-DWux49V3.js");
|
|
57
45
|
try {
|
|
58
|
-
audits = await runBrowserAudit(path.resolve(cwd, dir), pages, runnerOptions);
|
|
46
|
+
audits = await runBrowserAudit(options.url === void 0 ? path.resolve(cwd, dir) : void 0, pages, runnerOptions);
|
|
59
47
|
} catch (cause) {
|
|
60
48
|
if (cause instanceof BrowserUnavailableError) {
|
|
61
49
|
process.stderr.write(`${pc.red("error")} ${cause.message}\n`);
|
|
@@ -177,8 +165,10 @@ function formatFor(output) {
|
|
|
177
165
|
const program = new Command();
|
|
178
166
|
program.exitOverride();
|
|
179
167
|
program.name("eaa-kit").description("WCAG 2.2 AA auditor and EU accessibility statement generator for static sites.\nNot legal advice.").version(TOOL_VERSION, "-v, --version");
|
|
180
|
-
program.command("audit").description("Audit built HTML against WCAG 2.2 AA").argument("[dir]", "directory holding the built site
|
|
168
|
+
program.command("audit").description("Audit built HTML against WCAG 2.2 AA").argument("[dir]", "directory holding the built site (default: found automatically)").option("--include <globs...>", "glob patterns to audit, relative to dir").option("--exclude <globs...>", "glob patterns to skip").option("--base-url <url>", "audit pages under their real site URL").option("--url <url>", "audit a running site instead of a build directory").option("--no-build", "never run the project build or start its server").option("--per-page", "also list every page and its result").option("--allow-remote", "allow --url to crawl a host that is not localhost").option("--ignore-robots", "crawl paths robots.txt disallows").option("--max-pages <n>", "stop the crawl after this many pages", parsePositive).option("--max-depth <n>", "how far from the entry URL to follow links", parseDepth).option("--fail-on <impact>", `exit 1 on violations at or above this impact (${IMPACT_LEVELS.join("|")})`, parseImpact, DEFAULT_FAIL_ON).option("--format <format>", `output format (${OUTPUT_FORMATS.join("|")})`, parseFormat, "console").option("--output <path>", "write the report to a file instead of stdout").option("--browser", "audit in real Chromium, covering the rules jsdom cannot evaluate").option("--concurrency <n>", "worker threads to audit with, or 1 for none (default: from the page and core count)", parseConcurrency).option("--baseline <path>", "accept the violations recorded in this file; fail only on new ones").action(async (dir, options) => {
|
|
181
169
|
const { exitCode } = await runAuditCommand(dir, {
|
|
170
|
+
...options["build"] === false ? { noBuild: true } : {},
|
|
171
|
+
...options["perPage"] === true ? { perPage: true } : {},
|
|
182
172
|
...Array.isArray(options["include"]) ? { include: options["include"] } : {},
|
|
183
173
|
...Array.isArray(options["exclude"]) ? { exclude: options["exclude"] } : {},
|
|
184
174
|
...typeof options["baseUrl"] === "string" ? { baseUrl: options["baseUrl"] } : {},
|
|
@@ -187,11 +177,16 @@ program.command("audit").description("Audit built HTML against WCAG 2.2 AA").arg
|
|
|
187
177
|
...typeof options["output"] === "string" ? { output: options["output"] } : {},
|
|
188
178
|
...options["browser"] === true ? { browser: true } : {},
|
|
189
179
|
...typeof options["concurrency"] === "number" ? { concurrency: options["concurrency"] } : {},
|
|
190
|
-
...typeof options["baseline"] === "string" ? { baseline: options["baseline"] } : {}
|
|
180
|
+
...typeof options["baseline"] === "string" ? { baseline: options["baseline"] } : {},
|
|
181
|
+
...typeof options["url"] === "string" ? { url: options["url"] } : {},
|
|
182
|
+
...options["allowRemote"] === true ? { allowRemote: true } : {},
|
|
183
|
+
...options["ignoreRobots"] === true ? { ignoreRobots: true } : {},
|
|
184
|
+
...typeof options["maxPages"] === "number" ? { maxPages: options["maxPages"] } : {},
|
|
185
|
+
...typeof options["maxDepth"] === "number" ? { maxDepth: options["maxDepth"] } : {}
|
|
191
186
|
});
|
|
192
187
|
process.exitCode = exitCode;
|
|
193
188
|
});
|
|
194
|
-
program.command("baseline").description("Record the violations a build already has, so later runs fail only on new ones").argument("[dir]", "directory holding the built site", "./dist").option("--include <globs...>", "glob patterns to audit, relative to dir").option("--exclude <globs...>", "glob patterns to skip").option("--base-url <url>", "audit pages under their real site URL").option("--output <path>", `where to write it (default: ${DEFAULT_BASELINE_FILE})`).option("--note <text>", "recorded on every entry, for whoever reads the file").option("--expires-on <date>", "ISO date after which the entries stop suppressing", parseDate).option("--browser", "audit in real Chromium instead of jsdom").option("--concurrency <n>", "worker threads to audit with, or 1 for none", parseConcurrency).action(async (dir, options) => {
|
|
189
|
+
program.command("baseline").description("Record the violations a build already has, so later runs fail only on new ones").argument("[dir]", "directory holding the built site", "./dist").option("--include <globs...>", "glob patterns to audit, relative to dir").option("--exclude <globs...>", "glob patterns to skip").option("--base-url <url>", "audit pages under their real site URL").option("--url <url>", "record a baseline from a running site instead of a directory").option("--allow-remote", "allow --url to crawl a host that is not localhost").option("--ignore-robots", "crawl paths robots.txt disallows").option("--max-pages <n>", "stop the crawl after this many pages", parsePositive).option("--max-depth <n>", "how far from the entry URL to follow links", parseDepth).option("--output <path>", `where to write it (default: ${DEFAULT_BASELINE_FILE})`).option("--note <text>", "recorded on every entry, for whoever reads the file").option("--expires-on <date>", "ISO date after which the entries stop suppressing", parseDate).option("--browser", "audit in real Chromium instead of jsdom").option("--concurrency <n>", "worker threads to audit with, or 1 for none", parseConcurrency).action(async (dir, options) => {
|
|
195
190
|
const { exitCode } = await runBaselineCommand(dir, {
|
|
196
191
|
...Array.isArray(options["include"]) ? { include: options["include"] } : {},
|
|
197
192
|
...Array.isArray(options["exclude"]) ? { exclude: options["exclude"] } : {},
|
|
@@ -200,7 +195,21 @@ program.command("baseline").description("Record the violations a build already h
|
|
|
200
195
|
...typeof options["note"] === "string" ? { note: options["note"] } : {},
|
|
201
196
|
...typeof options["expiresOn"] === "string" ? { expiresOn: options["expiresOn"] } : {},
|
|
202
197
|
...options["browser"] === true ? { browser: true } : {},
|
|
203
|
-
...typeof options["concurrency"] === "number" ? { concurrency: options["concurrency"] } : {}
|
|
198
|
+
...typeof options["concurrency"] === "number" ? { concurrency: options["concurrency"] } : {},
|
|
199
|
+
...typeof options["url"] === "string" ? { url: options["url"] } : {},
|
|
200
|
+
...options["allowRemote"] === true ? { allowRemote: true } : {},
|
|
201
|
+
...options["ignoreRobots"] === true ? { ignoreRobots: true } : {},
|
|
202
|
+
...typeof options["maxPages"] === "number" ? { maxPages: options["maxPages"] } : {},
|
|
203
|
+
...typeof options["maxDepth"] === "number" ? { maxDepth: options["maxDepth"] } : {}
|
|
204
|
+
});
|
|
205
|
+
process.exitCode = exitCode;
|
|
206
|
+
});
|
|
207
|
+
program.command("init").description("Write an eaa.config.json to generate statements from").option("--output <path>", "write here instead of eaa.config.json").option("--force", "overwrite a config that is already there").option("-y, --yes", "take every default without asking").action(async (options) => {
|
|
208
|
+
const { runInitCommand } = await import("../init-DRKIdpK1.js");
|
|
209
|
+
const { exitCode } = await runInitCommand({
|
|
210
|
+
...typeof options["output"] === "string" ? { output: options["output"] } : {},
|
|
211
|
+
...options["force"] === true ? { force: true } : {},
|
|
212
|
+
...options["yes"] === true ? { yes: true } : {}
|
|
204
213
|
});
|
|
205
214
|
process.exitCode = exitCode;
|
|
206
215
|
});
|
|
@@ -232,6 +241,17 @@ function parseDate(value) {
|
|
|
232
241
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || Number.isNaN(Date.parse(`${value}T00:00:00Z`))) throw new InvalidArgumentError("expected an ISO date, e.g. 2026-12-31");
|
|
233
242
|
return value;
|
|
234
243
|
}
|
|
244
|
+
function parsePositive(value) {
|
|
245
|
+
const parsed = Number(value);
|
|
246
|
+
if (!Number.isInteger(parsed) || parsed < 1) throw new InvalidArgumentError("expected a whole number of 1 or more");
|
|
247
|
+
return parsed;
|
|
248
|
+
}
|
|
249
|
+
/** Depth 0 is meaningful here: audit only the entry page. */
|
|
250
|
+
function parseDepth(value) {
|
|
251
|
+
const parsed = Number(value);
|
|
252
|
+
if (!Number.isInteger(parsed) || parsed < 0) throw new InvalidArgumentError("expected a whole number of 0 or more");
|
|
253
|
+
return parsed;
|
|
254
|
+
}
|
|
235
255
|
function parseConcurrency(value) {
|
|
236
256
|
const parsed = Number(value);
|
|
237
257
|
if (!Number.isInteger(parsed) || parsed < 1) throw new InvalidArgumentError("expected a whole number of 1 or more");
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/** Requests in flight at once. Politeness, not throughput. */
|
|
2
|
+
const REQUEST_CONCURRENCY = 4;
|
|
3
|
+
var CrawlError = class extends Error {
|
|
4
|
+
name = "CrawlError";
|
|
5
|
+
};
|
|
6
|
+
/** Hosts that are this machine. Anything else needs allowRemote. */
|
|
7
|
+
function isLoopback(hostname) {
|
|
8
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
9
|
+
return host === "localhost" || host === "::1" || host.endsWith(".localhost") || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
|
|
10
|
+
}
|
|
11
|
+
function parseEntryUrl(raw, allowRemote = false) {
|
|
12
|
+
let url;
|
|
13
|
+
try {
|
|
14
|
+
url = new URL(raw);
|
|
15
|
+
} catch {
|
|
16
|
+
throw new CrawlError(`${raw} is not a URL. Include the scheme, e.g. http://localhost:3000`);
|
|
17
|
+
}
|
|
18
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw new CrawlError(`${raw} is not an http or https URL`);
|
|
19
|
+
if (!allowRemote && !isLoopback(url.hostname)) throw new CrawlError(`${url.host} is not a local address, and eaa-kit does not crawl remote hosts by default.\n Auditing a site you do not control sends it traffic and reads pages you may not
|
|
20
|
+
have meant to. Pass --allow-remote if this is your site and you meant to.`);
|
|
21
|
+
return url;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Page path used as the page's identity, relative to the origin.
|
|
25
|
+
*
|
|
26
|
+
* This is what appears in reports and what a baseline matches on, so it has to
|
|
27
|
+
* be stable across runs and independent of how the link was written. The query
|
|
28
|
+
* string is dropped — two URLs differing only by a tracking parameter are the
|
|
29
|
+
* same page — and the fragment with it.
|
|
30
|
+
*/
|
|
31
|
+
function pageIdentity(url) {
|
|
32
|
+
const path = url.pathname.replace(/^\/+/, "");
|
|
33
|
+
return path === "" ? "/" : path;
|
|
34
|
+
}
|
|
35
|
+
/** Same site: same protocol, host and port. */
|
|
36
|
+
function sameOrigin(a, b) {
|
|
37
|
+
return a.origin === b.origin;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Links worth following. Anything that is not a page — an asset, a download, a
|
|
41
|
+
* mailto: — is left alone, and so is anything off-origin.
|
|
42
|
+
*/
|
|
43
|
+
const NON_PAGE = /\.(?:css|js|mjs|json|xml|txt|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|eot|pdf|zip|gz|mp4|webm|mp3|wav)$/i;
|
|
44
|
+
function linksFrom(html, from) {
|
|
45
|
+
const found = [];
|
|
46
|
+
const seen = /* @__PURE__ */ new Set();
|
|
47
|
+
for (const match of html.matchAll(/<a\b[^>]*?\shref\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/gi)) {
|
|
48
|
+
const raw = (match[2] ?? match[3] ?? match[4] ?? "").trim();
|
|
49
|
+
if (raw === "" || raw.startsWith("#")) continue;
|
|
50
|
+
if (/^(?:mailto|tel|javascript|data):/i.test(raw)) continue;
|
|
51
|
+
let target;
|
|
52
|
+
try {
|
|
53
|
+
target = new URL(raw, from);
|
|
54
|
+
} catch {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
target.hash = "";
|
|
58
|
+
if (!sameOrigin(target, from)) continue;
|
|
59
|
+
if (NON_PAGE.test(target.pathname)) continue;
|
|
60
|
+
if (seen.has(target.href)) continue;
|
|
61
|
+
seen.add(target.href);
|
|
62
|
+
found.push(target);
|
|
63
|
+
}
|
|
64
|
+
return found;
|
|
65
|
+
}
|
|
66
|
+
/** Page URLs listed in a sitemap, including one level of sitemap index. */
|
|
67
|
+
function urlsFromSitemap(xml, origin) {
|
|
68
|
+
const urls = [];
|
|
69
|
+
for (const match of xml.matchAll(/<loc>\s*([^<]+?)\s*<\/loc>/gi)) {
|
|
70
|
+
const raw = match[1];
|
|
71
|
+
if (!raw) continue;
|
|
72
|
+
try {
|
|
73
|
+
const url = new URL(raw);
|
|
74
|
+
url.hash = "";
|
|
75
|
+
if (sameOrigin(url, origin)) urls.push(url);
|
|
76
|
+
} catch {}
|
|
77
|
+
}
|
|
78
|
+
return urls;
|
|
79
|
+
}
|
|
80
|
+
/** One request, with a timeout, returning HTML or a reason it is not a page. */
|
|
81
|
+
async function fetchPage(url, impl, timeoutMs, origin) {
|
|
82
|
+
const controller = new AbortController();
|
|
83
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
84
|
+
try {
|
|
85
|
+
const response = await impl(url.href, {
|
|
86
|
+
signal: controller.signal,
|
|
87
|
+
redirect: "follow",
|
|
88
|
+
headers: {
|
|
89
|
+
accept: "text/html,application/xhtml+xml",
|
|
90
|
+
"user-agent": "eaa-kit"
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
if (!response.ok) return {
|
|
94
|
+
ok: false,
|
|
95
|
+
reason: `HTTP ${response.status}`
|
|
96
|
+
};
|
|
97
|
+
const finalUrl = new URL(response.url || url.href);
|
|
98
|
+
finalUrl.hash = "";
|
|
99
|
+
if (finalUrl.origin !== origin) return {
|
|
100
|
+
ok: false,
|
|
101
|
+
reason: `redirected off ${origin} to ${finalUrl.origin}`
|
|
102
|
+
};
|
|
103
|
+
const type = response.headers.get("content-type") ?? "";
|
|
104
|
+
if (!/\b(?:text\/html|application\/xhtml\+xml)\b/i.test(type)) return {
|
|
105
|
+
ok: false,
|
|
106
|
+
reason: `not HTML (${type.split(";")[0] || "no content-type"})`
|
|
107
|
+
};
|
|
108
|
+
return {
|
|
109
|
+
ok: true,
|
|
110
|
+
value: {
|
|
111
|
+
url: finalUrl,
|
|
112
|
+
html: await response.text()
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
} catch (cause) {
|
|
116
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
reason: controller.signal.aborted ? `timed out after ${timeoutMs}ms` : message
|
|
120
|
+
};
|
|
121
|
+
} finally {
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/** Paths robots.txt disallows for us. Only the wildcard group is read. */
|
|
126
|
+
function disallowedPaths(robots) {
|
|
127
|
+
const lines = robots.split(/\r?\n/).map((line) => line.replace(/#.*$/, "").trim());
|
|
128
|
+
const paths = [];
|
|
129
|
+
let applies = false;
|
|
130
|
+
for (const line of lines) {
|
|
131
|
+
const agent = /^user-agent\s*:\s*(.+)$/i.exec(line);
|
|
132
|
+
if (agent) {
|
|
133
|
+
applies = (agent[1] ?? "").trim() === "*";
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const rule = /^disallow\s*:\s*(.*)$/i.exec(line);
|
|
137
|
+
if (rule && applies) {
|
|
138
|
+
const value = (rule[1] ?? "").trim();
|
|
139
|
+
if (value !== "") paths.push(value);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return paths;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Paths this crawler must not visit, from the site's own robots.txt.
|
|
146
|
+
*
|
|
147
|
+
* Read directly rather than through fetchPage, which correctly refuses anything
|
|
148
|
+
* that is not a page. A site with no robots.txt disallows nothing.
|
|
149
|
+
*/
|
|
150
|
+
async function blockedPaths(entry, impl) {
|
|
151
|
+
try {
|
|
152
|
+
const response = await impl(new URL("/robots.txt", entry).href, { redirect: "follow" });
|
|
153
|
+
return response.ok ? disallowedPaths(await response.text()) : [];
|
|
154
|
+
} catch {
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Page URLs the site lists for itself.
|
|
160
|
+
*
|
|
161
|
+
* Worth one request: a sitemap finds pages nothing links to, which link
|
|
162
|
+
* following alone never reaches.
|
|
163
|
+
*/
|
|
164
|
+
async function sitemapUrls(entry, impl) {
|
|
165
|
+
try {
|
|
166
|
+
const response = await impl(new URL("/sitemap.xml", entry).href, { redirect: "follow" });
|
|
167
|
+
return response.ok ? urlsFromSitemap(await response.text(), entry) : [];
|
|
168
|
+
} catch {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Fetch a site's pages, starting at `entry`.
|
|
174
|
+
*
|
|
175
|
+
* Pages come back sorted by identity, so two crawls of the same site produce
|
|
176
|
+
* the same report even though the requests finish in whatever order the server
|
|
177
|
+
* answers them — the same guarantee the directory collector gives.
|
|
178
|
+
*/
|
|
179
|
+
async function crawlSite(entry, options = {}) {
|
|
180
|
+
const impl = options.fetchImpl ?? fetch;
|
|
181
|
+
const maxPages = options.maxPages ?? 200;
|
|
182
|
+
const maxDepth = options.maxDepth ?? 3;
|
|
183
|
+
const timeoutMs = options.timeoutMs ?? 15e3;
|
|
184
|
+
const failures = [];
|
|
185
|
+
const blocked = options.ignoreRobots ? [] : await blockedPaths(entry, impl);
|
|
186
|
+
const allowed = (url) => !blocked.some((path) => url.pathname.startsWith(path));
|
|
187
|
+
let discovery = "links";
|
|
188
|
+
const queue = [];
|
|
189
|
+
const queued = /* @__PURE__ */ new Set();
|
|
190
|
+
const enqueue = (url, depth) => {
|
|
191
|
+
if (queued.has(url.href) || !allowed(url)) return;
|
|
192
|
+
queued.add(url.href);
|
|
193
|
+
queue.push({
|
|
194
|
+
url,
|
|
195
|
+
depth
|
|
196
|
+
});
|
|
197
|
+
};
|
|
198
|
+
const listed = await sitemapUrls(entry, impl);
|
|
199
|
+
if (listed.length > 0) {
|
|
200
|
+
discovery = "sitemap";
|
|
201
|
+
for (const url of listed) enqueue(url, 0);
|
|
202
|
+
}
|
|
203
|
+
enqueue(entry, 0);
|
|
204
|
+
const pages = [];
|
|
205
|
+
while (queue.length > 0 && pages.length < maxPages) {
|
|
206
|
+
const batch = queue.splice(0, Math.min(REQUEST_CONCURRENCY, maxPages - pages.length));
|
|
207
|
+
const results = await Promise.all(batch.map(async (item) => ({
|
|
208
|
+
item,
|
|
209
|
+
result: await fetchPage(item.url, impl, timeoutMs, entry.origin)
|
|
210
|
+
})));
|
|
211
|
+
for (const { item, result } of results) {
|
|
212
|
+
if (!result.ok) {
|
|
213
|
+
failures.push({
|
|
214
|
+
url: item.url.href,
|
|
215
|
+
reason: result.reason
|
|
216
|
+
});
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const { url, html } = result.value;
|
|
220
|
+
pages.push({
|
|
221
|
+
absolutePath: url.href,
|
|
222
|
+
relativePath: pageIdentity(url),
|
|
223
|
+
html: html.charCodeAt(0) === 65279 ? html.slice(1) : html
|
|
224
|
+
});
|
|
225
|
+
options.onProgress?.(pages.length, queue.length);
|
|
226
|
+
if (item.depth < maxDepth) for (const link of linksFrom(html, url)) enqueue(link, item.depth + 1);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
pages: byIdentity(pages),
|
|
231
|
+
origin: entry.origin,
|
|
232
|
+
failures,
|
|
233
|
+
truncated: queue.length > 0,
|
|
234
|
+
discovery
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* One page per identity, in a stable order.
|
|
239
|
+
*
|
|
240
|
+
* The queue already refuses a URL it has seen, so this catches the case the
|
|
241
|
+
* queue cannot: two different URLs that redirect to the same page. Sorting is
|
|
242
|
+
* what makes two crawls of one site produce the same report even though the
|
|
243
|
+
* requests finish in whatever order the server answers them.
|
|
244
|
+
*/
|
|
245
|
+
function byIdentity(pages) {
|
|
246
|
+
const seen = /* @__PURE__ */ new Set();
|
|
247
|
+
return pages.filter((page) => {
|
|
248
|
+
if (seen.has(page.relativePath)) return false;
|
|
249
|
+
seen.add(page.relativePath);
|
|
250
|
+
return true;
|
|
251
|
+
}).sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
252
|
+
}
|
|
253
|
+
//#endregion
|
|
254
|
+
export { CrawlError, crawlSite, parseEntryUrl };
|