spiderlint 0.0.0 → 0.143.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/LICENSE +235 -0
- package/README.md +244 -0
- package/bin/spiderlint.js +11 -0
- package/dist/agent.js +46 -0
- package/dist/cache/extractors.js +37 -0
- package/dist/cache/http.js +99 -0
- package/dist/cache/index.js +101 -0
- package/dist/cache/purge.js +63 -0
- package/dist/cache/status.js +52 -0
- package/dist/cli.js +602 -0
- package/dist/color.js +10 -0
- package/dist/config/environment.js +88 -0
- package/dist/config/index.js +104 -0
- package/dist/config/policy.js +170 -0
- package/dist/config/schema.js +127 -0
- package/dist/crawl/body.js +92 -0
- package/dist/crawl/browser.js +498 -0
- package/dist/crawl/crawl.js +58 -0
- package/dist/crawl/diversity.js +153 -0
- package/dist/crawl/dns.js +194 -0
- package/dist/crawl/fetch.js +48 -0
- package/dist/crawl/frontier.js +370 -0
- package/dist/crawl/guard.js +69 -0
- package/dist/crawl/http.js +171 -0
- package/dist/crawl/links.js +89 -0
- package/dist/crawl/log.js +27 -0
- package/dist/crawl/network.js +88 -0
- package/dist/crawl/probe.js +107 -0
- package/dist/crawl/profile.js +33 -0
- package/dist/crawl/rdap.js +86 -0
- package/dist/crawl/resolve.js +132 -0
- package/dist/crawl/resources.js +233 -0
- package/dist/crawl/robots.js +100 -0
- package/dist/crawl/route.js +92 -0
- package/dist/crawl/scope.js +32 -0
- package/dist/crawl/scratch.js +15 -0
- package/dist/crawl/sitemap.js +187 -0
- package/dist/crawl/svcb.js +59 -0
- package/dist/crawl/tls-hello.js +297 -0
- package/dist/crawl/tls-probe.js +71 -0
- package/dist/facts/browser.js +138 -0
- package/dist/facts/byline.js +17 -0
- package/dist/facts/co2.js +27 -0
- package/dist/facts/csp.js +71 -0
- package/dist/facts/export.js +79 -0
- package/dist/facts/flatten.js +33 -0
- package/dist/facts/graph.js +73 -0
- package/dist/facts/headers.js +330 -0
- package/dist/facts/html.js +230 -0
- package/dist/facts/labels.js +89 -0
- package/dist/facts/language.js +32 -0
- package/dist/facts/parity.js +38 -0
- package/dist/facts/read-note.js +11 -0
- package/dist/facts/resources.js +46 -0
- package/dist/facts/robots.js +13 -0
- package/dist/facts/sites.js +140 -0
- package/dist/facts/transport.js +200 -0
- package/dist/facts/types.js +4 -0
- package/dist/facts/vendors.js +76 -0
- package/dist/fold/index.js +94 -0
- package/dist/groups/assign.js +45 -0
- package/dist/groups/sample.js +60 -0
- package/dist/i18n.js +92 -0
- package/dist/index.js +607 -0
- package/dist/interrupt.js +37 -0
- package/dist/logger.js +95 -0
- package/dist/plugins/axe.js +104 -0
- package/dist/plugins/carbon-txt.js +96 -0
- package/dist/plugins/cookies-registry.js +25 -0
- package/dist/plugins/cookies.js +168 -0
- package/dist/plugins/css.js +328 -0
- package/dist/plugins/dns.js +864 -0
- package/dist/plugins/feed-content.js +132 -0
- package/dist/plugins/feed-model.js +282 -0
- package/dist/plugins/feeds.js +836 -0
- package/dist/plugins/html-validate.js +191 -0
- package/dist/plugins/htmlhint.js +95 -0
- package/dist/plugins/icons.js +302 -0
- package/dist/plugins/images.js +269 -0
- package/dist/plugins/index.js +286 -0
- package/dist/plugins/keyboard.js +194 -0
- package/dist/plugins/lighthouse.js +110 -0
- package/dist/plugins/link-text.js +73 -0
- package/dist/plugins/list.js +22 -0
- package/dist/plugins/live.js +254 -0
- package/dist/plugins/mail.js +985 -0
- package/dist/plugins/manifest.js +97 -0
- package/dist/plugins/markup.js +98 -0
- package/dist/plugins/messages.js +25 -0
- package/dist/plugins/network.js +192 -0
- package/dist/plugins/origin.js +488 -0
- package/dist/plugins/profile-links.js +102 -0
- package/dist/plugins/schema-registry.js +101 -0
- package/dist/plugins/sshfp.js +159 -0
- package/dist/plugins/stack.js +161 -0
- package/dist/plugins/structured-data.js +358 -0
- package/dist/plugins/tls-probe.js +298 -0
- package/dist/plugins/tls-registry.js +418 -0
- package/dist/plugins/tls-scan.js +280 -0
- package/dist/plugins/trackers-registry.js +27 -0
- package/dist/plugins/trackers.js +56 -0
- package/dist/plugins/types.js +7 -0
- package/dist/plugins/visit.js +50 -0
- package/dist/plugins/well-known-registry.js +111 -0
- package/dist/plugins/well-known.js +732 -0
- package/dist/progress.js +162 -0
- package/dist/report/agent.js +122 -0
- package/dist/report/checkstyle.js +22 -0
- package/dist/report/csv.js +18 -0
- package/dist/report/html.js +266 -0
- package/dist/report/human.js +220 -0
- package/dist/report/index.js +16 -0
- package/dist/report/json.js +8 -0
- package/dist/report/printable.js +29 -0
- package/dist/report/rating.js +26 -0
- package/dist/report/sarif.js +100 -0
- package/dist/report/stats.js +46 -0
- package/dist/rules/builtin.js +465 -0
- package/dist/rules/caching.js +103 -0
- package/dist/rules/catalog.js +139 -0
- package/dist/rules/clock.js +48 -0
- package/dist/rules/declarative.js +234 -0
- package/dist/rules/deprecated.js +45 -0
- package/dist/rules/disclosure.js +99 -0
- package/dist/rules/fix.js +21 -0
- package/dist/rules/i18n.js +131 -0
- package/dist/rules/insights.js +100 -0
- package/dist/rules/length.js +52 -0
- package/dist/rules/message.js +48 -0
- package/dist/rules/relations.js +88 -0
- package/dist/rules/robots.js +75 -0
- package/dist/rules/rulesets.js +134 -0
- package/dist/rules/run.js +75 -0
- package/dist/rules/score.js +61 -0
- package/dist/rules/types.js +7 -0
- package/dist/rules/url.js +90 -0
- package/dist/server/analytics.js +49 -0
- package/dist/server/api.js +134 -0
- package/dist/server/clients.js +54 -0
- package/dist/server/clock.js +33 -0
- package/dist/server/compress.js +58 -0
- package/dist/server/health.js +14 -0
- package/dist/server/jobs.js +82 -0
- package/dist/server/main.js +83 -0
- package/dist/server/page.js +161 -0
- package/dist/server/policy.js +145 -0
- package/dist/server/providers.js +60 -0
- package/dist/server/queue.js +50 -0
- package/dist/server/scan.js +39 -0
- package/dist/server/settings.js +197 -0
- package/dist/server/web.js +353 -0
- package/dist/server/worker.js +129 -0
- package/dist/store/disk.js +215 -0
- package/dist/store/memory.js +18 -0
- package/dist/telemetry-environment.js +9 -0
- package/dist/telemetry.js +104 -0
- package/locales/es/LC_MESSAGES/messages.po +1864 -0
- package/locales/uk/LC_MESSAGES/messages.po +1864 -0
- package/package.json +134 -2
- package/presets/browser.yaml +26 -0
- package/presets/graph.yaml +35 -0
- package/presets/i18n.yaml +18 -0
- package/presets/insights.yaml +12 -0
- package/presets/links.yaml +22 -0
- package/presets/parity.yaml +59 -0
- package/presets/performance.yaml +271 -0
- package/presets/recommended.yaml +50 -0
- package/presets/redirects.yaml +23 -0
- package/presets/resources.yaml +29 -0
- package/presets/robots.yaml +13 -0
- package/presets/security-headers.yaml +257 -0
- package/presets/seo.yaml +380 -0
- package/presets/sitemap.yaml +34 -0
- package/presets/suggestions.yaml +97 -0
- package/presets/sustainability.yaml +16 -0
- package/presets/tls.yaml +72 -0
- package/presets/vendor.yaml +14 -0
- package/presets/web-comprehensive.yaml +17 -0
- package/presets/web-quick.yaml +15 -0
- package/src/server/apple-touch-icon.png +0 -0
- package/src/server/logo-36.png +0 -0
- package/src/server/logo-72.png +0 -0
- package/src/server/logo.png +0 -0
- package/vendors/dns.yaml +21 -0
- package/vendors/paths.yaml +55 -0
- package/vendors/stack.yaml +57 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { createInterface } from "node:readline";
|
|
9
|
+
import { Worker } from "bullmq";
|
|
10
|
+
import { log } from "../logger.js";
|
|
11
|
+
import { inSpan, recordScan, traceCarrier, withTraceCarrier } from "../telemetry.js";
|
|
12
|
+
import { latestKey, PREFIX, PROGRESS_FD, QUEUE } from "./queue.js";
|
|
13
|
+
const RUNNER = new URL(`scan${path.extname(import.meta.url)}`, import.meta.url).pathname;
|
|
14
|
+
const PROGRESS_MS = 1000;
|
|
15
|
+
const REPORT_MAX = 64 * 1024 * 1024;
|
|
16
|
+
const TAIL = 20;
|
|
17
|
+
// The share of a scan’s time the crawl may use, leaving the rest to lint and the report.
|
|
18
|
+
const CRAWL_SHARE = 0.8;
|
|
19
|
+
// Why a runner exit code failed the scan, as the job’s error says it.
|
|
20
|
+
const EXITS = { 2: "invalid settings", 3: "no page could be fetched", 4: "the scan failed" };
|
|
21
|
+
// Runners in flight, killed together on shutdown.
|
|
22
|
+
const running = new Set();
|
|
23
|
+
// Pipes each line of `stream` to `onLine`.
|
|
24
|
+
function lines(stream, onLine) {
|
|
25
|
+
createInterface({ input: stream, crlfDelay: Infinity }).on("line", onLine);
|
|
26
|
+
}
|
|
27
|
+
// One JSON log line of a runner, or nothing when it is not JSON.
|
|
28
|
+
function parsed(line) {
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(line);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// The last error a runner logged, else the meaning of its exit code.
|
|
37
|
+
function reasonOf(code, tail, isTimedOut, scanTimeout) {
|
|
38
|
+
if (isTimedOut)
|
|
39
|
+
return `the scan exceeded ${scanTimeout} s`;
|
|
40
|
+
const logged = tail.map((line) => parsed(line)).findLast((entry) => (entry.level ?? 0) >= 50);
|
|
41
|
+
return [EXITS[code ?? 4] ?? `the scan exited with ${code}`, logged?.error].filter(Boolean).join(": ");
|
|
42
|
+
}
|
|
43
|
+
// One scan as a span continuing the submitting request’s trace, timed by outcome.
|
|
44
|
+
async function tracedScan(job) {
|
|
45
|
+
const started = performance.now();
|
|
46
|
+
try {
|
|
47
|
+
const result = await withTraceCarrier(job.data.trace, async () => inSpan("scan job", { "spiderlint.job": String(job.id), "url.full": job.data.url, "spiderlint.policy": job.data.policy }, async () => runScan(job)));
|
|
48
|
+
recordScan((performance.now() - started) / 1000, "done", result.summary.pages);
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
recordScan((performance.now() - started) / 1000, "failed");
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// One scan in a runner child under a temporary working directory, its progress relayed at most once per second.
|
|
57
|
+
async function runScan(job) {
|
|
58
|
+
const { url, settings, scanTimeout, deny, via } = job.data;
|
|
59
|
+
const cwd = await mkdtemp(path.join(tmpdir(), "spiderlint-scan-"));
|
|
60
|
+
const child = spawn(process.execPath, ["--experimental-strip-types", RUNNER], { cwd, stdio: ["pipe", "pipe", "pipe", "pipe"], env: { ...process.env, SPIDERLINT_LOG_FORMAT: "json" } });
|
|
61
|
+
running.add(child);
|
|
62
|
+
log.info({ job: job.id, url, pid: child.pid, scanTimeout }, "scan started");
|
|
63
|
+
let isTimedOut = false;
|
|
64
|
+
const timer = setTimeout(() => {
|
|
65
|
+
isTimedOut = true;
|
|
66
|
+
child.kill("SIGKILL");
|
|
67
|
+
}, scanTimeout * 1000);
|
|
68
|
+
const chunks = [];
|
|
69
|
+
let size = 0;
|
|
70
|
+
child.stdout?.on("data", (chunk) => {
|
|
71
|
+
size += chunk.length;
|
|
72
|
+
if (size <= REPORT_MAX)
|
|
73
|
+
chunks.push(chunk);
|
|
74
|
+
});
|
|
75
|
+
const tail = [];
|
|
76
|
+
lines(child.stderr, (line) => {
|
|
77
|
+
tail.push(line);
|
|
78
|
+
if (tail.length > TAIL)
|
|
79
|
+
tail.shift();
|
|
80
|
+
log.debug({ job: job.id, line }, "runner log");
|
|
81
|
+
});
|
|
82
|
+
const progress = { sent: 0, held: "" };
|
|
83
|
+
const store = (line) => void job.updateProgress(JSON.parse(line)).catch((error) => log.warn({ job: job.id, error: String(error) }, "progress not stored"));
|
|
84
|
+
lines(child.stdio[PROGRESS_FD], (line) => {
|
|
85
|
+
const now = Date.now();
|
|
86
|
+
// A line inside the interval is held, so the last one is stored once the runner exits.
|
|
87
|
+
progress.held = now - progress.sent < PROGRESS_MS ? line : "";
|
|
88
|
+
if (progress.held)
|
|
89
|
+
return;
|
|
90
|
+
progress.sent = now;
|
|
91
|
+
store(line);
|
|
92
|
+
});
|
|
93
|
+
child.stdin?.end(JSON.stringify({ url, settings, deny, via, crawlDeadline: Math.floor(scanTimeout * CRAWL_SHARE), trace: traceCarrier() }));
|
|
94
|
+
const [code, signal] = await new Promise((resolve) => child.once("close", (exitCode, exitSignal) => resolve([exitCode, exitSignal])));
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
if (progress.held)
|
|
97
|
+
store(progress.held);
|
|
98
|
+
running.delete(child);
|
|
99
|
+
await rm(cwd, { recursive: true, force: true });
|
|
100
|
+
log.info({ job: job.id, url, code, signal, bytes: size }, "scan exited");
|
|
101
|
+
if (code !== 0)
|
|
102
|
+
throw new Error(reasonOf(code, tail, isTimedOut, scanTimeout));
|
|
103
|
+
if (size > REPORT_MAX)
|
|
104
|
+
throw new Error(`the report exceeds ${REPORT_MAX} bytes`);
|
|
105
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
106
|
+
}
|
|
107
|
+
// Takes `concurrency` scans at a time off the queue until `close`, which kills the runners in flight; a finished scan becomes its host’s latest for `retention` seconds.
|
|
108
|
+
export function startWorker(redis, concurrency, retention) {
|
|
109
|
+
const worker = new Worker(QUEUE, tracedScan, { connection: redis, prefix: PREFIX, concurrency });
|
|
110
|
+
worker.on("failed", (job, error) => log.warn({ job: job?.id, host: job?.data.host, error: error.message }, "scan failed"));
|
|
111
|
+
worker.on("completed", async (job) => {
|
|
112
|
+
log.info({ job: job.id, host: job.data.host, findings: job.returnvalue.findings.length, grade: job.returnvalue.summary.rating?.grade }, "scan completed");
|
|
113
|
+
try {
|
|
114
|
+
await redis.set(latestKey(job.data.host), job.id, "EX", retention);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
log.warn({ job: job.id, error: String(error) }, "latest scan not recorded");
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
log.info({ queue: QUEUE, concurrency }, "worker started");
|
|
121
|
+
return {
|
|
122
|
+
async close() {
|
|
123
|
+
log.info({ running: running.size }, "worker stopping");
|
|
124
|
+
for (const child of running)
|
|
125
|
+
child.kill("SIGKILL");
|
|
126
|
+
await worker.close(true);
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
7
|
+
import { hostname } from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { Configuration, Dataset, KeyValueStore, RequestQueue } from "crawlee";
|
|
10
|
+
import lockfile from "proper-lockfile";
|
|
11
|
+
import { VERSION } from "../agent.js";
|
|
12
|
+
import { writeAtomic } from "../cache/index.js";
|
|
13
|
+
import { ConfigError } from "../config/index.js";
|
|
14
|
+
import { log, logRelativeTo } from "../logger.js";
|
|
15
|
+
const RESOURCES = "resources";
|
|
16
|
+
const REPORT = "report";
|
|
17
|
+
const SITE = "site";
|
|
18
|
+
// Crawlee hands a text/plain record back verbatim; a page’s own type would parse JSON into an object.
|
|
19
|
+
const BODY_TYPE = "text/plain; charset=utf-8";
|
|
20
|
+
function key(url) {
|
|
21
|
+
return createHash("sha256").update(url).digest("hex");
|
|
22
|
+
}
|
|
23
|
+
async function openStorages(config) {
|
|
24
|
+
return [await Dataset.open("facts", { config }), await KeyValueStore.open("bodies", { config }), await KeyValueStore.open("records", { config }), await RequestQueue.open("frontier", { config }), await RequestQueue.open("frontier-browser", { config })];
|
|
25
|
+
}
|
|
26
|
+
// `lint` or `show-report` found no stored crawl; the run exits 3.
|
|
27
|
+
export class NothingStored extends Error {
|
|
28
|
+
}
|
|
29
|
+
const STALE_MS = 30_000;
|
|
30
|
+
// The lock holder the pid file names, when it is readable.
|
|
31
|
+
async function readHolder(holderPath) {
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(await readFile(holderPath, "utf8"));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// Whether the process that wrote `holder` is gone; one on another host is presumed alive.
|
|
40
|
+
function isGone(holder) {
|
|
41
|
+
if (!holder || holder.host !== hostname())
|
|
42
|
+
return false;
|
|
43
|
+
try {
|
|
44
|
+
process.kill(holder.pid, 0);
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
return error.code === "ESRCH";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Holds the store at `directory` for this process; a live second holder is a ConfigError, a dead one’s lock is waited out until stale.
|
|
52
|
+
export async function lockStore(directory) {
|
|
53
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
54
|
+
const lockfilePath = path.join(directory, "manifest.json.lock");
|
|
55
|
+
const holderPath = `${lockfilePath}.pid`;
|
|
56
|
+
const options = { lockfilePath, realpath: false, stale: STALE_MS, update: STALE_MS / 3 };
|
|
57
|
+
let release;
|
|
58
|
+
try {
|
|
59
|
+
release = await lockfile.lock(directory, { ...options, retries: 0 });
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (error.code !== "ELOCKED")
|
|
63
|
+
throw error;
|
|
64
|
+
const holder = await readHolder(holderPath);
|
|
65
|
+
const isDead = isGone(holder);
|
|
66
|
+
log.debug({ directory, holder, isDead }, "store lock held");
|
|
67
|
+
if (!isDead)
|
|
68
|
+
throw new ConfigError(`store ${directory} is in use by ${holder ? `process ${holder.pid} on ${holder.host}` : "another process"}`);
|
|
69
|
+
log.warn({ directory, holder: holder?.pid, staleSeconds: STALE_MS / 1000 }, `store ${directory} locked by process ${holder?.pid}, which is gone; waiting ${STALE_MS / 1000} s for the lock to go stale`);
|
|
70
|
+
try {
|
|
71
|
+
release = await lockfile.lock(directory, { ...options, retries: { retries: STALE_MS / 1000 + 5, factor: 1, minTimeout: 1000, maxTimeout: 1000 } });
|
|
72
|
+
}
|
|
73
|
+
catch (error_) {
|
|
74
|
+
if (error_.code === "ELOCKED")
|
|
75
|
+
throw new ConfigError(`store ${directory} was taken by another process`);
|
|
76
|
+
throw error_;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
await writeAtomic(holderPath, JSON.stringify({ pid: process.pid, host: hostname() }));
|
|
80
|
+
return release;
|
|
81
|
+
}
|
|
82
|
+
// The `pages` bucket: facts in a Dataset, bodies and results in KeyValueStores, the frontier in a RequestQueue.
|
|
83
|
+
export class DiskStore {
|
|
84
|
+
// Locks `directory`; a fresh crawl empties it, a resumed crawl or a re-lint keeps it, and `existing` refuses one never written.
|
|
85
|
+
static async open(directory, mode) {
|
|
86
|
+
const isStored = existsSync(path.join(directory, "manifest.json"));
|
|
87
|
+
log.debug({ directory, existing: mode.existing, isStored }, "store looked up");
|
|
88
|
+
if (!isStored && mode.existing)
|
|
89
|
+
throw new NothingStored(`nothing stored in ${directory}; run spiderlint audit or crawl first`);
|
|
90
|
+
const release = await lockStore(directory);
|
|
91
|
+
const config = new Configuration({ storageClientOptions: { localDataDirectory: directory }, persistStorage: true, purgeOnStart: false });
|
|
92
|
+
const previous = await DiskStore.#readManifest(directory);
|
|
93
|
+
let storages = await openStorages(config);
|
|
94
|
+
const lastReport = await storages[2].getValue(REPORT);
|
|
95
|
+
const last = lastReport?.summary;
|
|
96
|
+
const earlier = new Map();
|
|
97
|
+
if (mode.fresh) {
|
|
98
|
+
const stored = await storages[0].map((item) => item);
|
|
99
|
+
for (const facts of stored)
|
|
100
|
+
for (const href of [facts.url.href, facts.crawl.requested])
|
|
101
|
+
if (href)
|
|
102
|
+
earlier.set(href, facts);
|
|
103
|
+
const crawlerState = await KeyValueStore.open(undefined, { config });
|
|
104
|
+
const [facts, , records, frontier, rendering] = storages;
|
|
105
|
+
await Promise.all([facts, records, frontier, rendering, crawlerState].map((storage) => storage.drop()));
|
|
106
|
+
storages = await openStorages(config);
|
|
107
|
+
}
|
|
108
|
+
const { configHash, fresh, seeds = [] } = mode;
|
|
109
|
+
if (previous && configHash && previous.configHash !== configHash)
|
|
110
|
+
log.warn({ directory, stored: previous.configHash, current: configHash }, "store was crawled with another configuration");
|
|
111
|
+
const manifest = fresh || !previous ? { version: VERSION, seeds, configHash: configHash ?? "", started: new Date().toISOString() } : previous;
|
|
112
|
+
const store = new DiskStore(directory, config, storages, release, manifest, earlier, last ?? undefined);
|
|
113
|
+
await store.#writeManifest();
|
|
114
|
+
log.info({ directory, fresh: mode.fresh, started: manifest.started, earlier: earlier.size }, earlier.size > 0 ? `resuming ${earlier.size} pages stored in ${directory}` : `pages stored in ${directory}`);
|
|
115
|
+
logRelativeTo(manifest.seeds);
|
|
116
|
+
return store;
|
|
117
|
+
}
|
|
118
|
+
static async #readManifest(directory) {
|
|
119
|
+
try {
|
|
120
|
+
return JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8"));
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (error.code === "ENOENT")
|
|
124
|
+
return undefined;
|
|
125
|
+
throw new ConfigError(`store ${directory}: unreadable manifest.json (${error instanceof Error ? error.message : String(error)})`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
#release;
|
|
129
|
+
directory;
|
|
130
|
+
config;
|
|
131
|
+
facts;
|
|
132
|
+
bodies;
|
|
133
|
+
records;
|
|
134
|
+
// One frontier per crawler, so a resumed run hands each its own.
|
|
135
|
+
frontiers;
|
|
136
|
+
manifest;
|
|
137
|
+
earlier;
|
|
138
|
+
last;
|
|
139
|
+
constructor(directory, config, storages, release, manifest, earlier, last) {
|
|
140
|
+
this.earlier = earlier;
|
|
141
|
+
if (last)
|
|
142
|
+
this.last = last;
|
|
143
|
+
this.directory = directory;
|
|
144
|
+
this.config = config;
|
|
145
|
+
this.facts = storages[0];
|
|
146
|
+
this.bodies = storages[1];
|
|
147
|
+
this.records = storages[2];
|
|
148
|
+
this.frontiers = { http: storages[3], browser: storages[4] };
|
|
149
|
+
this.#release = release;
|
|
150
|
+
this.manifest = manifest;
|
|
151
|
+
}
|
|
152
|
+
async #writeManifest() {
|
|
153
|
+
await writeAtomic(path.join(this.directory, "manifest.json"), `${JSON.stringify(this.manifest, undefined, 2)}\n`);
|
|
154
|
+
}
|
|
155
|
+
async add(facts, body) {
|
|
156
|
+
await this.facts.pushData(facts);
|
|
157
|
+
await this.bodies.setValue(key(facts.url.href), body, { contentType: BODY_TYPE });
|
|
158
|
+
}
|
|
159
|
+
// A page’s stored body, which a fresh crawl keeps for revalidation; one that reads back as neither text nor bytes is a miss.
|
|
160
|
+
async body(href) {
|
|
161
|
+
const value = await this.bodies.getValue(key(href));
|
|
162
|
+
if (value === null || value === undefined || typeof value === "string")
|
|
163
|
+
return value ?? undefined;
|
|
164
|
+
if (value instanceof Uint8Array)
|
|
165
|
+
return Buffer.from(value).toString("utf8");
|
|
166
|
+
log.warn({ url: href, type: typeof value }, "stored body unreadable, page fetched in full");
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
// Deletes the bodies of pages this crawl no longer has.
|
|
170
|
+
async pruneBodies(pages) {
|
|
171
|
+
const keep = new Set(pages.map((page) => key(page.url.href)));
|
|
172
|
+
const gone = [];
|
|
173
|
+
await this.bodies.forEachKey((name) => {
|
|
174
|
+
if (!keep.has(name))
|
|
175
|
+
gone.push(name);
|
|
176
|
+
});
|
|
177
|
+
// eslint-disable-next-line unicorn/no-null -- Crawlee deletes a record when its value is null
|
|
178
|
+
for (const name of gone)
|
|
179
|
+
await this.bodies.setValue(name, null);
|
|
180
|
+
log.debug({ kept: keep.size, pruned: gone.length }, "bodies pruned");
|
|
181
|
+
return gone.length;
|
|
182
|
+
}
|
|
183
|
+
async pages() {
|
|
184
|
+
return this.facts.map((item) => item);
|
|
185
|
+
}
|
|
186
|
+
async saveResources(results) {
|
|
187
|
+
await this.records.setValue(RESOURCES, results);
|
|
188
|
+
}
|
|
189
|
+
async resources() {
|
|
190
|
+
return (await this.records.getValue(RESOURCES)) ?? {};
|
|
191
|
+
}
|
|
192
|
+
async saveSite(site) {
|
|
193
|
+
await this.records.setValue(SITE, site);
|
|
194
|
+
}
|
|
195
|
+
async site() {
|
|
196
|
+
return (await this.records.getValue(SITE)) ?? { sitemaps: [] };
|
|
197
|
+
}
|
|
198
|
+
async saveReport(report) {
|
|
199
|
+
await this.records.setValue(REPORT, report);
|
|
200
|
+
}
|
|
201
|
+
async report() {
|
|
202
|
+
const report = await this.records.getValue(REPORT);
|
|
203
|
+
if (!report)
|
|
204
|
+
throw new ConfigError(`store ${this.directory} holds no report; run spiderlint lint --store ${this.directory} first`);
|
|
205
|
+
return report;
|
|
206
|
+
}
|
|
207
|
+
// Stamps `finished` and releases the lock.
|
|
208
|
+
async close(isFinished) {
|
|
209
|
+
if (isFinished)
|
|
210
|
+
this.manifest.finished = new Date().toISOString();
|
|
211
|
+
await this.#writeManifest();
|
|
212
|
+
await this.#release();
|
|
213
|
+
log.debug({ directory: this.directory, finished: this.manifest.finished }, "store closed");
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { log } from "../logger.js";
|
|
5
|
+
// Stream-mode store: facts only, nothing persisted; one record per final URL.
|
|
6
|
+
export class MemoryStore {
|
|
7
|
+
#seen = new Set();
|
|
8
|
+
pages = [];
|
|
9
|
+
add(facts) {
|
|
10
|
+
if (this.#seen.has(facts.url.href)) {
|
|
11
|
+
log.debug({ url: facts.url.href }, "page already stored");
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
this.#seen.add(facts.url.href);
|
|
15
|
+
this.pages.push(facts);
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
// The OTLP endpoint variables; any one set switches telemetry on.
|
|
5
|
+
export const ENDPOINTS = ["OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT"];
|
|
6
|
+
// Whether the environment names an OTLP endpoint and does not disable the SDK.
|
|
7
|
+
export function isTelemetryConfigured(environment = process.env) {
|
|
8
|
+
return environment.OTEL_SDK_DISABLED !== "true" && ENDPOINTS.some((name) => (environment[name] ?? "") !== "");
|
|
9
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { context, metrics, propagation, SpanStatusCode, trace } from "@opentelemetry/api";
|
|
5
|
+
import { VERSION } from "./agent.js";
|
|
6
|
+
import { log } from "./logger.js";
|
|
7
|
+
import { ENDPOINTS, isTelemetryConfigured } from "./telemetry-environment.js";
|
|
8
|
+
// Milliseconds between metric exports; OTEL_METRIC_EXPORT_INTERVAL overrides it.
|
|
9
|
+
const EXPORT_MS = 15_000;
|
|
10
|
+
const state = {};
|
|
11
|
+
const tracer = trace.getTracer("spiderlint", VERSION);
|
|
12
|
+
// Starts OTLP trace and metric export for `service` when an endpoint is set; unset, nothing is imported and undefined comes back.
|
|
13
|
+
export async function startTelemetry(service) {
|
|
14
|
+
if (!isTelemetryConfigured()) {
|
|
15
|
+
log.debug({ service, variables: ENDPOINTS }, "telemetry off, no OTLP endpoint set");
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
const [{ NodeTracerProvider, BatchSpanProcessor }, { MeterProvider, PeriodicExportingMetricReader }, { OTLPTraceExporter }, { OTLPMetricExporter }, { resourceFromAttributes }] = await Promise.all([
|
|
19
|
+
import("@opentelemetry/sdk-trace-node"),
|
|
20
|
+
import("@opentelemetry/sdk-metrics"),
|
|
21
|
+
import("@opentelemetry/exporter-trace-otlp-http"),
|
|
22
|
+
import("@opentelemetry/exporter-metrics-otlp-http"),
|
|
23
|
+
import("@opentelemetry/resources"),
|
|
24
|
+
]);
|
|
25
|
+
const name = process.env.OTEL_SERVICE_NAME || service;
|
|
26
|
+
const resource = resourceFromAttributes({ "service.name": name, "service.version": VERSION });
|
|
27
|
+
const tracing = new NodeTracerProvider({ resource, spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter())] });
|
|
28
|
+
tracing.register();
|
|
29
|
+
const interval = Number(process.env.OTEL_METRIC_EXPORT_INTERVAL) || EXPORT_MS;
|
|
30
|
+
const metering = new MeterProvider({ resource, readers: [new PeriodicExportingMetricReader({ exporter: new OTLPMetricExporter(), exportIntervalMillis: interval })] });
|
|
31
|
+
metrics.setGlobalMeterProvider(metering);
|
|
32
|
+
const meter = metrics.getMeter("spiderlint", VERSION);
|
|
33
|
+
state.instruments = {
|
|
34
|
+
requests: meter.createCounter("spiderlint.http.requests", { description: "API requests by route, method and status" }),
|
|
35
|
+
refusals: meter.createCounter("spiderlint.refusals", { description: "Refused API requests by code" }),
|
|
36
|
+
scanDuration: meter.createHistogram("spiderlint.scan.duration", { description: "Scan wall time by outcome", unit: "s" }),
|
|
37
|
+
scanPages: meter.createHistogram("spiderlint.scan.pages", { description: "Pages per finished scan" }),
|
|
38
|
+
extractorDuration: meter.createHistogram("spiderlint.extractor.duration", { description: "Time per extractor run", unit: "ms" }),
|
|
39
|
+
queueDepth: meter.createObservableGauge("spiderlint.queue.depth", { description: "Scans by queue state" }),
|
|
40
|
+
};
|
|
41
|
+
log.info({ service: name, interval }, "telemetry on, exporting traces and metrics over OTLP");
|
|
42
|
+
return {
|
|
43
|
+
async shutdown() {
|
|
44
|
+
await Promise.allSettled([tracing.shutdown(), metering.shutdown()]);
|
|
45
|
+
log.debug({ service: name }, "telemetry flushed");
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
// Runs `work` inside a new active span, recording a throw as its error; a no-op span when telemetry is off.
|
|
50
|
+
export async function inSpan(name, attributes, work) {
|
|
51
|
+
return tracer.startActiveSpan(name, { attributes }, async (span) => {
|
|
52
|
+
try {
|
|
53
|
+
return await work(span);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
span.recordException(error instanceof Error ? error : String(error));
|
|
57
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: error instanceof Error ? error.message : String(error) });
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
span.end();
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
// Renames the active span once its subject is known, as the CLI’s command.
|
|
66
|
+
export function nameSpan(name) {
|
|
67
|
+
trace.getActiveSpan()?.updateName(name);
|
|
68
|
+
}
|
|
69
|
+
// The active trace context as W3C headers, to carry through the queue and into a scan runner; empty when telemetry is off.
|
|
70
|
+
export function traceCarrier() {
|
|
71
|
+
const carrier = {};
|
|
72
|
+
propagation.inject(context.active(), carrier);
|
|
73
|
+
return carrier;
|
|
74
|
+
}
|
|
75
|
+
// Runs `work` as a continuation of the trace `carrier` names.
|
|
76
|
+
export function withTraceCarrier(carrier, work) {
|
|
77
|
+
return context.with(propagation.extract(context.active(), carrier ?? {}), work);
|
|
78
|
+
}
|
|
79
|
+
// Counts one API request.
|
|
80
|
+
export function countRequest(route, method, status) {
|
|
81
|
+
state.instruments?.requests.add(1, { "http.route": route, "http.request.method": method, "http.response.status_code": status });
|
|
82
|
+
}
|
|
83
|
+
// Counts one refusal by its code: invalid bodies, policy refusals, rate windows and client buckets alike.
|
|
84
|
+
export function countRefusal(code) {
|
|
85
|
+
state.instruments?.refusals.add(1, { code });
|
|
86
|
+
}
|
|
87
|
+
// Records one finished or failed scan.
|
|
88
|
+
export function recordScan(seconds, outcome, pages) {
|
|
89
|
+
state.instruments?.scanDuration.record(seconds, { outcome });
|
|
90
|
+
if (pages !== undefined)
|
|
91
|
+
state.instruments?.scanPages.record(pages);
|
|
92
|
+
}
|
|
93
|
+
// Records one extractor run.
|
|
94
|
+
export function recordExtractor(id, ms) {
|
|
95
|
+
state.instruments?.extractorDuration.record(ms, { extractor: id });
|
|
96
|
+
}
|
|
97
|
+
// Reports queue depth by state from `read` on every metric export.
|
|
98
|
+
export function observeQueue(read) {
|
|
99
|
+
state.instruments?.queueDepth.addCallback(async (result) => {
|
|
100
|
+
const counts = await read();
|
|
101
|
+
for (const [name, count] of Object.entries(counts))
|
|
102
|
+
result.observe(count, { state: name });
|
|
103
|
+
});
|
|
104
|
+
}
|