spiderlint 0.0.0 → 0.143.1
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 +234 -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 +169 -0
- package/dist/plugins/css.js +328 -0
- package/dist/plugins/dns.js +865 -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 +303 -0
- package/dist/plugins/images.js +268 -0
- package/dist/plugins/index.js +286 -0
- package/dist/plugins/keyboard.js +193 -0
- package/dist/plugins/lighthouse.js +110 -0
- package/dist/plugins/link-text.js +72 -0
- package/dist/plugins/list.js +21 -0
- package/dist/plugins/live.js +253 -0
- package/dist/plugins/mail.js +986 -0
- package/dist/plugins/manifest.js +96 -0
- package/dist/plugins/markup.js +97 -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 +357 -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 +55 -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 +15 -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 +354 -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,99 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import CachePolicy from "http-cache-semantics";
|
|
5
|
+
import { agentHeaders } from "../agent.js";
|
|
6
|
+
import { fetchRetrying } from "../crawl/fetch.js";
|
|
7
|
+
import { redactHeaders } from "../facts/transport.js";
|
|
8
|
+
import { log } from "../logger.js";
|
|
9
|
+
// A private cache, and no heuristic freshness: an origin that says nothing gets the bucket TTL instead.
|
|
10
|
+
const OPTIONS = { shared: false, cacheHeuristic: 0 };
|
|
11
|
+
const REQUEST = (url, headers = {}) => ({ url, method: "GET", headers: { ...headers, ...agentHeaders() } });
|
|
12
|
+
// No `Cache-Control` and no `Expires`: the origin states no freshness.
|
|
13
|
+
function isSilent(headers) {
|
|
14
|
+
return headers["cache-control"] === undefined && headers.expires === undefined;
|
|
15
|
+
}
|
|
16
|
+
// Policy, headers and value as written to the bucket, with secrets redacted.
|
|
17
|
+
function toStored(policy, status, headers, value, ms) {
|
|
18
|
+
const object = policy.toObject();
|
|
19
|
+
return { policy: { ...object, resh: redactHeaders(object.resh) }, status, headers: redactHeaders(headers), value, ms };
|
|
20
|
+
}
|
|
21
|
+
// Fresh by the origin’s headers, or by the bucket TTL when they are silent or `isCapped`; everything is fresh offline.
|
|
22
|
+
function isFresh(bucket, entry, policy, isCapped, request) {
|
|
23
|
+
if (bucket.mode === "offline")
|
|
24
|
+
return true;
|
|
25
|
+
return isSilent(entry.value.headers) ? bucket.isFresh(entry) : policy.satisfiesWithoutRevalidation(request) && (!isCapped || bucket.isFresh(entry));
|
|
26
|
+
}
|
|
27
|
+
// A GET with `sent` headers answered from `bucket` while fresh, revalidated with its validators once stale, stored when RFC 9111 allows; an entry `isUsable` rejects is fetched again in full, except offline.
|
|
28
|
+
export async function fetchCached(bucket, url, consume, isCapped = false, isUsable, sent = {}) {
|
|
29
|
+
const found = await bucket.get(url);
|
|
30
|
+
const entry = found && (!isUsable || bucket.mode === "offline" || isUsable(found.value)) ? found : undefined;
|
|
31
|
+
if (found && !entry)
|
|
32
|
+
log.debug({ bucket: bucket.name, url, stored: found.stored }, "cache entry lacks what the caller needs");
|
|
33
|
+
if (!entry)
|
|
34
|
+
bucket.missed(url);
|
|
35
|
+
const request = REQUEST(url, sent);
|
|
36
|
+
const policy = entry && CachePolicy.fromObject(entry.value.policy);
|
|
37
|
+
if (entry && policy && isFresh(bucket, entry, policy, isCapped, request)) {
|
|
38
|
+
log.debug({ bucket: bucket.name, url, stored: entry.stored }, "served from cache");
|
|
39
|
+
return { ...entry.value, cached: true, at: entry.stored };
|
|
40
|
+
}
|
|
41
|
+
const conditional = policy ? policy.revalidationHeaders(request) : {};
|
|
42
|
+
const { response, value, ms } = await fetchRetrying(url, consume, { ...sent, ...conditional });
|
|
43
|
+
const headers = Object.fromEntries(response.headers);
|
|
44
|
+
if (entry && policy && response.status === 304) {
|
|
45
|
+
const { policy: updated, modified } = policy.revalidatedPolicy(request, { status: 304, headers });
|
|
46
|
+
log.debug({ bucket: bucket.name, url, modified }, "revalidated");
|
|
47
|
+
if (!modified) {
|
|
48
|
+
const next = toStored(updated, entry.value.status, { ...entry.value.headers, ...headers }, entry.value.value, ms);
|
|
49
|
+
await bucket.set(url, next);
|
|
50
|
+
return { ...next, revalidated: true, at: new Date().toISOString() };
|
|
51
|
+
}
|
|
52
|
+
const again = await fetchRetrying(url, consume, sent);
|
|
53
|
+
return store(bucket, url, request, again.response, again.value, again.ms);
|
|
54
|
+
}
|
|
55
|
+
return store(bucket, url, request, response, value, ms);
|
|
56
|
+
}
|
|
57
|
+
// A HEAD with `sent` headers answered from `bucket` while fresh, revalidated once stale, stored when RFC 9111 allows; the value keeps headers only, never a body.
|
|
58
|
+
export async function fetchHeadCached(bucket, url, sent = {}) {
|
|
59
|
+
const found = await bucket.get(url);
|
|
60
|
+
const entry = found && found.value.value.head === true ? found : undefined;
|
|
61
|
+
if (found && !entry)
|
|
62
|
+
log.debug({ bucket: bucket.name, url, stored: found.stored }, "cache entry holds a body, not a HEAD answer");
|
|
63
|
+
if (!entry)
|
|
64
|
+
bucket.missed(url);
|
|
65
|
+
const request = REQUEST(url, sent);
|
|
66
|
+
const policy = entry && CachePolicy.fromObject(entry.value.policy);
|
|
67
|
+
if (entry && policy && isFresh(bucket, entry, policy, false, request)) {
|
|
68
|
+
log.debug({ bucket: bucket.name, url, stored: entry.stored }, "served from cache");
|
|
69
|
+
return { ...entry.value, cached: true, at: entry.stored };
|
|
70
|
+
}
|
|
71
|
+
const conditional = policy ? policy.revalidationHeaders(request) : {};
|
|
72
|
+
const { response, ms } = await fetchRetrying(url, async (answer) => {
|
|
73
|
+
await answer.body?.cancel();
|
|
74
|
+
return {};
|
|
75
|
+
}, { ...sent, ...conditional }, "HEAD");
|
|
76
|
+
const headers = Object.fromEntries(response.headers);
|
|
77
|
+
if (entry && policy && response.status === 304) {
|
|
78
|
+
const { policy: updated, modified } = policy.revalidatedPolicy(request, { status: 304, headers });
|
|
79
|
+
log.debug({ bucket: bucket.name, url, modified }, "revalidated");
|
|
80
|
+
if (!modified) {
|
|
81
|
+
const next = toStored(updated, entry.value.status, { ...entry.value.headers, ...headers }, entry.value.value, ms);
|
|
82
|
+
await bucket.set(url, next);
|
|
83
|
+
return { ...next, revalidated: true, at: new Date().toISOString() };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const value = { bytes: 0, head: true };
|
|
87
|
+
return store(bucket, url, request, response, value, ms);
|
|
88
|
+
}
|
|
89
|
+
// A full response, kept when its policy is storable.
|
|
90
|
+
async function store(bucket, url, request, response, value, ms) {
|
|
91
|
+
const headers = Object.fromEntries(response.headers);
|
|
92
|
+
const policy = new CachePolicy(request, { status: response.status, headers }, OPTIONS);
|
|
93
|
+
const stored = toStored(policy, response.status, headers, value, ms);
|
|
94
|
+
const isStorable = policy.storable();
|
|
95
|
+
log.debug({ bucket: bucket.name, url, status: response.status, isStorable }, "response judged for cache");
|
|
96
|
+
if (isStorable)
|
|
97
|
+
await bucket.set(url, stored);
|
|
98
|
+
return { ...stored, at: new Date().toISOString() };
|
|
99
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { log } from "../logger.js";
|
|
9
|
+
// Seconds each bucket stays fresh when the origin says nothing; `pages` 0 lets origin headers alone decide, `extractors` 0 keeps an entry until its body changes.
|
|
10
|
+
export const TTL_DEFAULTS = { pages: 0, probes: 7 * 86_400, profiles: 3600, lists: 7 * 86_400, resources: 86_400, robots: 86_400, sitemaps: 86_400, origins: 86_400, dns: 60, extractors: 0 };
|
|
11
|
+
// Buckets beside the project; the rest hold third-party observations shared by every site on the machine.
|
|
12
|
+
const PROJECT = new Set(["pages", "resources", "sitemaps", "origins", "dns", "extractors"]);
|
|
13
|
+
// `--offline` found nothing stored for a key; the run exits 3.
|
|
14
|
+
export class OfflineMiss extends Error {
|
|
15
|
+
}
|
|
16
|
+
// Write to a unique sibling temp file, then rename over the target.
|
|
17
|
+
export async function writeAtomic(file, content) {
|
|
18
|
+
const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
19
|
+
await writeFile(temporary, content);
|
|
20
|
+
await rename(temporary, file);
|
|
21
|
+
}
|
|
22
|
+
// `$XDG_CACHE_HOME/spiderlint`, else `~/.cache/spiderlint`.
|
|
23
|
+
export function userCacheDirectory() {
|
|
24
|
+
return path.join(process.env.XDG_CACHE_HOME || path.join(homedir(), ".cache"), "spiderlint");
|
|
25
|
+
}
|
|
26
|
+
// `$XDG_STATE_HOME/spiderlint`, else `~/.local/state/spiderlint`.
|
|
27
|
+
export function userStateDirectory() {
|
|
28
|
+
return path.join(process.env.XDG_STATE_HOME || path.join(homedir(), ".local", "state"), "spiderlint");
|
|
29
|
+
}
|
|
30
|
+
// `<user cache>/<host>` for the seeds’ hosts, `+`-joined when they span several; undefined without a seed.
|
|
31
|
+
export function siteDirectory(seeds) {
|
|
32
|
+
const hosts = [...new Set(seeds.filter((seed) => URL.canParse(seed)).map((seed) => new URL(seed).host))].toSorted((a, b) => a.localeCompare(b));
|
|
33
|
+
return hosts.length === 0 ? undefined : path.join(userCacheDirectory(), hosts.join("+"));
|
|
34
|
+
}
|
|
35
|
+
// Where `name` lives: the store's `cache/` for project buckets, the user cache otherwise; none without a store.
|
|
36
|
+
export function bucketDirectory(name, store) {
|
|
37
|
+
if (!PROJECT.has(name))
|
|
38
|
+
return path.join(userCacheDirectory(), name);
|
|
39
|
+
return store === undefined ? undefined : path.join(store, "cache", name);
|
|
40
|
+
}
|
|
41
|
+
// One JSON file per key, named by the key's hash; `off` neither reads nor writes, `refresh` only writes.
|
|
42
|
+
export class Bucket {
|
|
43
|
+
name;
|
|
44
|
+
directory;
|
|
45
|
+
ttlSeconds;
|
|
46
|
+
mode;
|
|
47
|
+
constructor(name, directory, ttlSeconds, mode) {
|
|
48
|
+
this.name = name;
|
|
49
|
+
this.directory = directory;
|
|
50
|
+
this.ttlSeconds = ttlSeconds;
|
|
51
|
+
this.mode = mode;
|
|
52
|
+
}
|
|
53
|
+
#file(key) {
|
|
54
|
+
return path.join(this.directory, `${createHash("sha256").update(key).digest("hex")}.json`);
|
|
55
|
+
}
|
|
56
|
+
// The stored entry for `key`, however old.
|
|
57
|
+
async get(key) {
|
|
58
|
+
if (this.directory === undefined || this.mode === "off" || this.mode === "refresh")
|
|
59
|
+
return undefined;
|
|
60
|
+
let entry;
|
|
61
|
+
try {
|
|
62
|
+
entry = JSON.parse(await readFile(this.#file(key), "utf8"));
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (error.code !== "ENOENT")
|
|
66
|
+
log.warn({ bucket: this.name, key, error: error instanceof Error ? error.message : String(error) }, `${this.name} cache entry unreadable and ignored:`);
|
|
67
|
+
log.debug({ bucket: this.name, key }, "cache miss");
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
if (entry.key !== key)
|
|
71
|
+
return undefined;
|
|
72
|
+
log.debug({ bucket: this.name, key, stored: entry.stored }, "cache hit");
|
|
73
|
+
return entry;
|
|
74
|
+
}
|
|
75
|
+
// Younger than the bucket TTL.
|
|
76
|
+
isFresh(entry) {
|
|
77
|
+
return Date.now() - Date.parse(entry.stored) < this.ttlSeconds * 1000;
|
|
78
|
+
}
|
|
79
|
+
async set(key, value) {
|
|
80
|
+
if (this.directory === undefined || this.mode === "off" || this.mode === "offline")
|
|
81
|
+
return;
|
|
82
|
+
await mkdir(this.directory, { recursive: true });
|
|
83
|
+
const entry = { key, stored: new Date().toISOString(), value };
|
|
84
|
+
await writeAtomic(this.#file(key), JSON.stringify(entry));
|
|
85
|
+
log.debug({ bucket: this.name, key }, "cache stored");
|
|
86
|
+
}
|
|
87
|
+
// A miss under `--offline` ends the run.
|
|
88
|
+
missed(key) {
|
|
89
|
+
if (this.mode === "offline")
|
|
90
|
+
throw new OfflineMiss(`--offline: ${this.name} holds nothing for ${key}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// The bucket `name` for this run, located and timed from the config.
|
|
94
|
+
export function openBucket(name, config, store) {
|
|
95
|
+
return new Bucket(name, bucketDirectory(name, store), config.cacheTtl[name] ?? TTL_DEFAULTS[name], config.cacheMode);
|
|
96
|
+
}
|
|
97
|
+
// `90`, `45s`, `30m`, `24h`, `7d` to seconds.
|
|
98
|
+
export function parseDuration(raw) {
|
|
99
|
+
const match = /^(\d+)([smhd]?)$/.exec(String(raw).trim());
|
|
100
|
+
return match ? Number(match[1]) * { "": 1, s: 1, m: 60, h: 3600, d: 86_400 }[match[2]] : undefined;
|
|
101
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { readFile, rm } from "node:fs/promises";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { lockStore } from "../store/disk.js";
|
|
8
|
+
import { log } from "../logger.js";
|
|
9
|
+
import { bucketDirectory } from "./index.js";
|
|
10
|
+
import { FILE_BUCKETS, files } from "./status.js";
|
|
11
|
+
// Store directories that together make up the `pages` bucket.
|
|
12
|
+
const PAGES = ["datasets", "key_value_stores", "request_queues", "cache/pages", "manifest.json"];
|
|
13
|
+
export const PURGEABLE = new Set(["pages", ...FILE_BUCKETS]);
|
|
14
|
+
// Deletes entries older than `olderThanSeconds` (all when 0) and returns how many went.
|
|
15
|
+
async function purgeFiles(directory, olderThanSeconds) {
|
|
16
|
+
const cutoff = Date.now() - olderThanSeconds * 1000;
|
|
17
|
+
const present = await files(directory);
|
|
18
|
+
const stale = present.filter((file) => file.name.endsWith(".json") && file.mtime.getTime() <= cutoff);
|
|
19
|
+
await Promise.all(stale.map((file) => rm(path.join(directory, file.name), { force: true })));
|
|
20
|
+
return stale.length;
|
|
21
|
+
}
|
|
22
|
+
// The store manifest’s start, absent when there is no manifest.
|
|
23
|
+
async function readManifest(root) {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(await readFile(path.join(root, "manifest.json"), "utf8"));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// A crawl’s pages are as old as the crawl, so `pages` goes whole once its manifest’s start passes the cutoff.
|
|
32
|
+
async function purgePages(root, olderThanSeconds) {
|
|
33
|
+
const manifest = await readManifest(root);
|
|
34
|
+
const isOld = !manifest.started || Date.now() - Date.parse(manifest.started) >= olderThanSeconds * 1000;
|
|
35
|
+
log.debug({ root, started: manifest.started, olderThanSeconds, isOld }, "pages bucket aged");
|
|
36
|
+
if (!isOld)
|
|
37
|
+
return 0;
|
|
38
|
+
const facts = await files(path.join(root, "datasets/facts"));
|
|
39
|
+
const pages = facts.filter((file) => file.name.endsWith(".json")).length;
|
|
40
|
+
await Promise.all(PAGES.map((entry) => rm(path.join(root, entry), { recursive: true, force: true })));
|
|
41
|
+
return pages;
|
|
42
|
+
}
|
|
43
|
+
// Purges `bucket`, or every bucket, holding the store lock while project buckets are touched.
|
|
44
|
+
export async function purgeCache(root, bucket, olderThanSeconds) {
|
|
45
|
+
const names = bucket === undefined ? [...PURGEABLE] : [bucket];
|
|
46
|
+
const hasStore = root !== undefined && existsSync(root);
|
|
47
|
+
const release = hasStore ? await lockStore(root) : undefined;
|
|
48
|
+
const purged = {};
|
|
49
|
+
try {
|
|
50
|
+
for (const name of names) {
|
|
51
|
+
const directory = bucketDirectory(name, hasStore ? root : undefined);
|
|
52
|
+
if (name === "pages")
|
|
53
|
+
purged[name] = hasStore ? await purgePages(root, olderThanSeconds) : 0;
|
|
54
|
+
else
|
|
55
|
+
purged[name] = directory === undefined ? 0 : await purgeFiles(directory, olderThanSeconds);
|
|
56
|
+
log.info({ bucket: name, purged: purged[name], olderThanSeconds }, `${purged[name]} entries purged from the ${name} cache`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
await release?.();
|
|
61
|
+
}
|
|
62
|
+
return purged;
|
|
63
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { readdir, stat } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { log } from "../logger.js";
|
|
7
|
+
import { TTL_DEFAULTS, bucketDirectory } from "./index.js";
|
|
8
|
+
// Buckets kept as one JSON file per key: every bucket but the store’s `pages`.
|
|
9
|
+
export const FILE_BUCKETS = Object.keys(TTL_DEFAULTS).filter((name) => name !== "pages");
|
|
10
|
+
// Bucket name, the directories holding it (entries counted in the first), and the extension of one entry; user buckets only without a store.
|
|
11
|
+
function layout(root) {
|
|
12
|
+
const files = FILE_BUCKETS.filter((name) => bucketDirectory(name, root)).map((name) => [name, [bucketDirectory(name, root)], ".json"]);
|
|
13
|
+
return root === undefined
|
|
14
|
+
? files
|
|
15
|
+
: [
|
|
16
|
+
["pages", [path.join(root, "datasets/facts"), path.join(root, "key_value_stores/bodies"), bucketDirectory("pages", root)], ".json"],
|
|
17
|
+
["records", [path.join(root, "key_value_stores/records")], ".json"],
|
|
18
|
+
["frontier", [path.join(root, "request_queues/frontier"), path.join(root, "request_queues/frontier-browser")], ".json"],
|
|
19
|
+
...files,
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
// Files directly under `directory`, empty when it does not exist.
|
|
23
|
+
export async function files(directory) {
|
|
24
|
+
let names;
|
|
25
|
+
try {
|
|
26
|
+
names = await readdir(directory);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
return Promise.all(names.map(async (name) => {
|
|
32
|
+
const { size, mtime } = await stat(path.join(directory, name));
|
|
33
|
+
return { name, size, mtime };
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
// Entries, bytes and age range of every bucket present for the store at `root` and in the user cache.
|
|
37
|
+
export async function cacheStatus(root) {
|
|
38
|
+
const out = [];
|
|
39
|
+
for (const [bucket, directories, entryExtension] of layout(root)) {
|
|
40
|
+
const [first = "", ...rest] = directories;
|
|
41
|
+
const primary = await files(first);
|
|
42
|
+
const counted = primary.filter((file) => file.name.endsWith(entryExtension) && !file.name.startsWith("__"));
|
|
43
|
+
const others = await Promise.all(rest.map((directory) => files(directory)));
|
|
44
|
+
const all = [...counted, ...others.flat()];
|
|
45
|
+
const times = all.map((file) => file.mtime.getTime()).toSorted((a, b) => a - b);
|
|
46
|
+
log.debug({ root, bucket, entries: counted.length, files: all.length }, "bucket measured");
|
|
47
|
+
if (all.length === 0)
|
|
48
|
+
continue;
|
|
49
|
+
out.push({ bucket, entries: counted.length, bytes: all.reduce((sum, file) => sum + file.size, 0), oldest: new Date(times[0]).toISOString(), newest: new Date(times.at(-1)).toISOString() });
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|