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.
Files changed (186) hide show
  1. package/LICENSE +235 -0
  2. package/README.md +244 -0
  3. package/bin/spiderlint.js +11 -0
  4. package/dist/agent.js +46 -0
  5. package/dist/cache/extractors.js +37 -0
  6. package/dist/cache/http.js +99 -0
  7. package/dist/cache/index.js +101 -0
  8. package/dist/cache/purge.js +63 -0
  9. package/dist/cache/status.js +52 -0
  10. package/dist/cli.js +602 -0
  11. package/dist/color.js +10 -0
  12. package/dist/config/environment.js +88 -0
  13. package/dist/config/index.js +104 -0
  14. package/dist/config/policy.js +170 -0
  15. package/dist/config/schema.js +127 -0
  16. package/dist/crawl/body.js +92 -0
  17. package/dist/crawl/browser.js +498 -0
  18. package/dist/crawl/crawl.js +58 -0
  19. package/dist/crawl/diversity.js +153 -0
  20. package/dist/crawl/dns.js +194 -0
  21. package/dist/crawl/fetch.js +48 -0
  22. package/dist/crawl/frontier.js +370 -0
  23. package/dist/crawl/guard.js +69 -0
  24. package/dist/crawl/http.js +171 -0
  25. package/dist/crawl/links.js +89 -0
  26. package/dist/crawl/log.js +27 -0
  27. package/dist/crawl/network.js +88 -0
  28. package/dist/crawl/probe.js +107 -0
  29. package/dist/crawl/profile.js +33 -0
  30. package/dist/crawl/rdap.js +86 -0
  31. package/dist/crawl/resolve.js +132 -0
  32. package/dist/crawl/resources.js +234 -0
  33. package/dist/crawl/robots.js +100 -0
  34. package/dist/crawl/route.js +92 -0
  35. package/dist/crawl/scope.js +32 -0
  36. package/dist/crawl/scratch.js +15 -0
  37. package/dist/crawl/sitemap.js +187 -0
  38. package/dist/crawl/svcb.js +59 -0
  39. package/dist/crawl/tls-hello.js +297 -0
  40. package/dist/crawl/tls-probe.js +71 -0
  41. package/dist/facts/browser.js +138 -0
  42. package/dist/facts/byline.js +17 -0
  43. package/dist/facts/co2.js +27 -0
  44. package/dist/facts/csp.js +71 -0
  45. package/dist/facts/export.js +79 -0
  46. package/dist/facts/flatten.js +33 -0
  47. package/dist/facts/graph.js +73 -0
  48. package/dist/facts/headers.js +330 -0
  49. package/dist/facts/html.js +230 -0
  50. package/dist/facts/labels.js +89 -0
  51. package/dist/facts/language.js +32 -0
  52. package/dist/facts/parity.js +38 -0
  53. package/dist/facts/read-note.js +11 -0
  54. package/dist/facts/resources.js +46 -0
  55. package/dist/facts/robots.js +13 -0
  56. package/dist/facts/sites.js +140 -0
  57. package/dist/facts/transport.js +200 -0
  58. package/dist/facts/types.js +4 -0
  59. package/dist/facts/vendors.js +76 -0
  60. package/dist/fold/index.js +94 -0
  61. package/dist/groups/assign.js +45 -0
  62. package/dist/groups/sample.js +60 -0
  63. package/dist/i18n.js +92 -0
  64. package/dist/index.js +607 -0
  65. package/dist/interrupt.js +37 -0
  66. package/dist/logger.js +95 -0
  67. package/dist/plugins/axe.js +104 -0
  68. package/dist/plugins/carbon-txt.js +96 -0
  69. package/dist/plugins/cookies-registry.js +25 -0
  70. package/dist/plugins/cookies.js +169 -0
  71. package/dist/plugins/css.js +328 -0
  72. package/dist/plugins/dns.js +865 -0
  73. package/dist/plugins/feed-content.js +132 -0
  74. package/dist/plugins/feed-model.js +282 -0
  75. package/dist/plugins/feeds.js +836 -0
  76. package/dist/plugins/html-validate.js +191 -0
  77. package/dist/plugins/htmlhint.js +95 -0
  78. package/dist/plugins/icons.js +303 -0
  79. package/dist/plugins/images.js +268 -0
  80. package/dist/plugins/index.js +286 -0
  81. package/dist/plugins/keyboard.js +193 -0
  82. package/dist/plugins/lighthouse.js +110 -0
  83. package/dist/plugins/link-text.js +72 -0
  84. package/dist/plugins/list.js +21 -0
  85. package/dist/plugins/live.js +253 -0
  86. package/dist/plugins/mail.js +986 -0
  87. package/dist/plugins/manifest.js +96 -0
  88. package/dist/plugins/markup.js +97 -0
  89. package/dist/plugins/messages.js +25 -0
  90. package/dist/plugins/network.js +192 -0
  91. package/dist/plugins/origin.js +488 -0
  92. package/dist/plugins/profile-links.js +102 -0
  93. package/dist/plugins/schema-registry.js +101 -0
  94. package/dist/plugins/sshfp.js +159 -0
  95. package/dist/plugins/stack.js +161 -0
  96. package/dist/plugins/structured-data.js +357 -0
  97. package/dist/plugins/tls-probe.js +298 -0
  98. package/dist/plugins/tls-registry.js +418 -0
  99. package/dist/plugins/tls-scan.js +280 -0
  100. package/dist/plugins/trackers-registry.js +27 -0
  101. package/dist/plugins/trackers.js +55 -0
  102. package/dist/plugins/types.js +7 -0
  103. package/dist/plugins/visit.js +50 -0
  104. package/dist/plugins/well-known-registry.js +111 -0
  105. package/dist/plugins/well-known.js +732 -0
  106. package/dist/progress.js +162 -0
  107. package/dist/report/agent.js +122 -0
  108. package/dist/report/checkstyle.js +22 -0
  109. package/dist/report/csv.js +18 -0
  110. package/dist/report/html.js +266 -0
  111. package/dist/report/human.js +220 -0
  112. package/dist/report/index.js +15 -0
  113. package/dist/report/json.js +8 -0
  114. package/dist/report/printable.js +29 -0
  115. package/dist/report/rating.js +26 -0
  116. package/dist/report/sarif.js +100 -0
  117. package/dist/report/stats.js +46 -0
  118. package/dist/rules/builtin.js +465 -0
  119. package/dist/rules/caching.js +103 -0
  120. package/dist/rules/catalog.js +139 -0
  121. package/dist/rules/clock.js +48 -0
  122. package/dist/rules/declarative.js +234 -0
  123. package/dist/rules/deprecated.js +45 -0
  124. package/dist/rules/disclosure.js +99 -0
  125. package/dist/rules/fix.js +21 -0
  126. package/dist/rules/i18n.js +131 -0
  127. package/dist/rules/insights.js +100 -0
  128. package/dist/rules/length.js +52 -0
  129. package/dist/rules/message.js +48 -0
  130. package/dist/rules/relations.js +88 -0
  131. package/dist/rules/robots.js +75 -0
  132. package/dist/rules/rulesets.js +134 -0
  133. package/dist/rules/run.js +75 -0
  134. package/dist/rules/score.js +61 -0
  135. package/dist/rules/types.js +7 -0
  136. package/dist/rules/url.js +90 -0
  137. package/dist/server/analytics.js +49 -0
  138. package/dist/server/api.js +134 -0
  139. package/dist/server/clients.js +54 -0
  140. package/dist/server/clock.js +33 -0
  141. package/dist/server/compress.js +58 -0
  142. package/dist/server/health.js +14 -0
  143. package/dist/server/jobs.js +82 -0
  144. package/dist/server/main.js +83 -0
  145. package/dist/server/page.js +161 -0
  146. package/dist/server/policy.js +145 -0
  147. package/dist/server/providers.js +60 -0
  148. package/dist/server/queue.js +50 -0
  149. package/dist/server/scan.js +39 -0
  150. package/dist/server/settings.js +197 -0
  151. package/dist/server/web.js +354 -0
  152. package/dist/server/worker.js +129 -0
  153. package/dist/store/disk.js +215 -0
  154. package/dist/store/memory.js +18 -0
  155. package/dist/telemetry-environment.js +9 -0
  156. package/dist/telemetry.js +104 -0
  157. package/locales/es/LC_MESSAGES/messages.po +1864 -0
  158. package/locales/uk/LC_MESSAGES/messages.po +1864 -0
  159. package/package.json +134 -2
  160. package/presets/browser.yaml +26 -0
  161. package/presets/graph.yaml +35 -0
  162. package/presets/i18n.yaml +18 -0
  163. package/presets/insights.yaml +12 -0
  164. package/presets/links.yaml +22 -0
  165. package/presets/parity.yaml +59 -0
  166. package/presets/performance.yaml +271 -0
  167. package/presets/recommended.yaml +50 -0
  168. package/presets/redirects.yaml +23 -0
  169. package/presets/resources.yaml +29 -0
  170. package/presets/robots.yaml +13 -0
  171. package/presets/security-headers.yaml +257 -0
  172. package/presets/seo.yaml +380 -0
  173. package/presets/sitemap.yaml +34 -0
  174. package/presets/suggestions.yaml +97 -0
  175. package/presets/sustainability.yaml +16 -0
  176. package/presets/tls.yaml +72 -0
  177. package/presets/vendor.yaml +14 -0
  178. package/presets/web-comprehensive.yaml +17 -0
  179. package/presets/web-quick.yaml +15 -0
  180. package/src/server/apple-touch-icon.png +0 -0
  181. package/src/server/logo-36.png +0 -0
  182. package/src/server/logo-72.png +0 -0
  183. package/src/server/logo.png +0 -0
  184. package/vendors/dns.yaml +21 -0
  185. package/vendors/paths.yaml +55 -0
  186. package/vendors/stack.yaml +57 -0
@@ -0,0 +1,89 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { progressCount, progressPhase } from "../progress.js";
5
+ import { vendorPath } from "../facts/vendors.js";
6
+ import { log } from "../logger.js";
7
+ import { reason } from "./fetch.js";
8
+ import { PrivateAddress } from "./guard.js";
9
+ import { probe } from "./probe.js";
10
+ import { width } from "./resources.js";
11
+ // A Cloudflare challenge, a Cloudflare edge block that never asked the origin, or LinkedIn’s 999: the answer says nothing about the page.
12
+ function isWalled(answer) {
13
+ const timing = [answer.headers["server-timing"] ?? []].flat().join(",");
14
+ return answer.headers["cf-mitigated"] === "challenge" || (answer.status === 403 && /(^|,)\s*cfOrigin;dur=0(,|$)/.test(timing)) || answer.status === 999;
15
+ }
16
+ // A HEAD, then a GET when the server refuses HEAD; a network failure is status 0 with its error.
17
+ async function probeOne(href, config, signal) {
18
+ const options = { host: new URL(href).hostname, allowPrivate: config.allowPrivate, signal };
19
+ try {
20
+ const head = await probe(href, { method: "HEAD", redirect: "follow" }, options);
21
+ const answer = head.status === 405 ? await probe(href, { method: "GET", redirect: "follow" }, options) : head;
22
+ const isWall = isWalled(answer);
23
+ log.debug({ url: href, head: head.status, status: answer.status, isWall }, "external link probed");
24
+ return { status: answer.status, method: head.status === 405 ? "GET" : "HEAD", ...(isWall && { walled: true }) };
25
+ }
26
+ catch (error) {
27
+ log.debug({ url: href, error: reason(error) }, "external link unreachable");
28
+ return { status: 0, error: reason(error), ...(error instanceof PrivateAddress && { refused: true }) };
29
+ }
30
+ }
31
+ // Whether an answer says anything about its link: not excluded, a vendor’s, refused, walled or rate limited.
32
+ export function isJudged(answer) {
33
+ return !answer.excluded && !answer.vendor && !answer.refused && !answer.walled && answer.status !== 429;
34
+ }
35
+ // A healthy or walled answer: the only kind worth keeping.
36
+ function isKept(answer) {
37
+ return answer.status > 0 && (answer.status < 400 || answer.walled === true);
38
+ }
39
+ // One link’s answer, from `bucket` while fresh and still worth keeping; an excluded host or a vendor path is never asked, and only a healthy or walled answer is stored.
40
+ export async function answerOf(href, config, bucket, signal) {
41
+ const vendor = config.vendorPaths ? vendorPath(href, "page")?.vendor : undefined;
42
+ log.debug({ url: href, vendor }, "external link vendor");
43
+ if (vendor)
44
+ return { status: 0, vendor };
45
+ const host = new URL(href).hostname;
46
+ const isExcluded = config.linkExclude.some((entry) => host === entry || host.endsWith(`.${entry}`));
47
+ log.debug({ url: href, host, isExcluded }, "external link scoped");
48
+ if (isExcluded)
49
+ return { status: 0, excluded: true };
50
+ const entry = await bucket.get(href);
51
+ const isReused = entry !== undefined && bucket.isFresh(entry) && isKept(entry.value);
52
+ log.debug({ url: href, isCached: entry !== undefined, isReused }, "external link cache");
53
+ if (isReused)
54
+ return { ...entry.value, cached: true };
55
+ const answer = { ...(await probeOne(href, config, signal)), checked: new Date().toISOString() };
56
+ const isStored = isKept(answer);
57
+ log.debug({ url: href, status: answer.status, isStored }, "external link answer");
58
+ if (isStored)
59
+ await bucket.set(href, answer);
60
+ return answer;
61
+ }
62
+ // Probes every distinct http(s) link once, one request at a time per host, hosts in parallel.
63
+ export async function probeLinks(links, config, bucket) {
64
+ const hrefs = [...new Set(links)].filter((href) => URL.canParse(href) && /^https?:$/.test(new URL(href).protocol));
65
+ const hosts = Map.groupBy(hrefs, (href) => new URL(href).hostname).values();
66
+ log.debug({ links: hrefs.length }, "external links found");
67
+ const answers = {};
68
+ const signal = new AbortController().signal;
69
+ let cached = 0;
70
+ let probed = 0;
71
+ progressPhase("probes", hrefs.length);
72
+ const worker = async () => {
73
+ for (const group of hosts) {
74
+ for (const href of group) {
75
+ const { cached: isCached, ...answer } = await answerOf(href, config, bucket, signal);
76
+ cached += isCached ? 1 : 0;
77
+ answers[href] = answer;
78
+ progressCount((probed += 1));
79
+ }
80
+ }
81
+ };
82
+ const workers = Array.from({ length: Math.min(width(config.concurrency), hrefs.length) }, worker);
83
+ await Promise.all(workers);
84
+ const all = Object.values(answers);
85
+ const skipped = all.filter((answer) => !isJudged(answer)).length;
86
+ const failed = all.filter((answer) => isJudged(answer) && (answer.status === 0 || answer.status >= 400)).length;
87
+ log.debug({ links: hrefs.length, cached, failed, skipped }, "external links probed");
88
+ return answers;
89
+ }
@@ -0,0 +1,27 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { log as crawleeLog, Logger, LogLevel } from "crawlee";
5
+ import { log } from "../logger.js";
6
+ const METHOD = {
7
+ [LogLevel.ERROR]: "error",
8
+ [LogLevel.SOFT_FAIL]: "warn",
9
+ [LogLevel.WARNING]: "warn",
10
+ [LogLevel.INFO]: "info",
11
+ [LogLevel.DEBUG]: "debug",
12
+ [LogLevel.PERF]: "trace",
13
+ };
14
+ // Crawlee lines land in the pino stream with their data fields and a `crawlee` marker; its npm-worded Ctrl-C hint is dropped for spiderlint’s own.
15
+ class PinoLogger extends Logger {
16
+ _log(level, message, data, exception, options) {
17
+ if (message.startsWith("Pausing..."))
18
+ return;
19
+ const error = exception instanceof Error ? exception.message : exception;
20
+ log[METHOD[level] ?? "info"]({ ...data, crawlee: options?.prefix ?? true, ...(error !== undefined && { error }) }, message.split("\n at ", 1)[0]);
21
+ }
22
+ }
23
+ // Crawlee stays at WARNING unless spiderlint itself is at debug or below.
24
+ export function bridgeCrawleeLog() {
25
+ const isVerbose = log.levelVal <= log.levels.values.debug;
26
+ crawleeLog.setOptions({ logger: new PinoLogger({}), level: isVerbose ? LogLevel.DEBUG : LogLevel.WARNING });
27
+ }
@@ -0,0 +1,88 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { setGlobalProxyFromEnv } from "node:http";
5
+ import { setTimeout as sleep } from "node:timers/promises";
6
+ import { Server } from "proxy-chain";
7
+ import { ConfigError, defaults } from "../config/index.js";
8
+ import { log } from "../logger.js";
9
+ import { refuseLiteral } from "./guard.js";
10
+ import { openResolution } from "./resolve.js";
11
+ const REDIRECTS = new Set([301, 302, 303, 307, 308]);
12
+ const MAX_REDIRECTS = 20;
13
+ const SOCKS = new Set(["socks:", "socks4:", "socks4a:", "socks5:", "socks5h:"]);
14
+ // Milliseconds between two requests, when the next may start, and the run’s `timeout` over its default.
15
+ const pacing = { spacing: 0, next: 0, scale: 1 };
16
+ // Whether the open run may reach loopback, private and link-local addresses.
17
+ const guard = { isPrivateAllowed: true };
18
+ // Throws for a private address literal while the open run refuses private addresses.
19
+ export function guardUrl(url) {
20
+ refuseLiteral(url, guard.isPrivateAllowed);
21
+ }
22
+ // `fetch`, following redirects itself while the guard is on, so every hop’s address literal is checked as the first one was.
23
+ export async function guardedFetch(url, init) {
24
+ guardUrl(url);
25
+ if (guard.isPrivateAllowed)
26
+ return fetch(url, init);
27
+ let current = url;
28
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
29
+ const response = await fetch(current, { ...init, redirect: "manual" });
30
+ const location = response.headers.get("location");
31
+ if (location === null || !REDIRECTS.has(response.status))
32
+ return response;
33
+ await response.body?.cancel();
34
+ const next = new URL(location, current).href;
35
+ log.debug({ from: current, to: next, status: response.status, hop }, "redirect followed");
36
+ guardUrl(next);
37
+ current = next;
38
+ }
39
+ throw new TypeError(`${url}: more than ${MAX_REDIRECTS} redirects`);
40
+ }
41
+ // `ms` stretched by the run’s `timeout`, so a slow network gets as long as a page does.
42
+ export function patient(ms) {
43
+ return ms * pacing.scale;
44
+ }
45
+ // Waits for the next slot under `rate`; unlimited returns at once.
46
+ export async function pace() {
47
+ if (pacing.spacing === 0)
48
+ return;
49
+ const now = Date.now();
50
+ const slot = Math.max(now, pacing.next);
51
+ pacing.next = slot + pacing.spacing;
52
+ log.trace({ waitMs: slot - now, spacing: pacing.spacing }, "request paced");
53
+ if (slot > now)
54
+ await sleep(slot - now);
55
+ }
56
+ // Paces, and routes every HTTP client of one run through `config.proxy`, until `close`.
57
+ export async function openNetwork(config) {
58
+ pacing.spacing = config.rate > 0 ? 60_000 / config.rate : 0;
59
+ pacing.next = 0;
60
+ pacing.scale = config.timeout / defaults().timeout;
61
+ log.debug({ rate: config.rate, spacing: pacing.spacing, scale: pacing.scale, isProxied: config.proxy !== "" }, "network opened");
62
+ guard.isPrivateAllowed = config.allowPrivate;
63
+ const unresolve = await openResolution(config.resolve, config.resolver, config.seeds, config.proxy !== "", config.allowPrivate);
64
+ const reset = () => {
65
+ pacing.spacing = 0;
66
+ pacing.scale = 1;
67
+ guard.isPrivateAllowed = true;
68
+ unresolve();
69
+ };
70
+ if (!config.proxy)
71
+ return { close: async () => reset() };
72
+ if (!config.allowPrivate)
73
+ throw new ConfigError("proxy: the address guard cannot check what a proxy connects to");
74
+ const upstream = new URL(config.proxy);
75
+ const bridge = SOCKS.has(upstream.protocol) ? new Server({ host: "127.0.0.1", port: 0, prepareRequestFunction: () => ({ upstreamProxyUrl: config.proxy }) }) : undefined;
76
+ await bridge?.listen();
77
+ const proxy = bridge ? `http://127.0.0.1:${bridge.port}` : config.proxy;
78
+ const restore = setGlobalProxyFromEnv({ HTTP_PROXY: proxy, HTTPS_PROXY: proxy });
79
+ log.debug({ proxy: `${upstream.protocol}//${upstream.host}`, isBridged: bridge !== undefined }, "requests go through the proxy");
80
+ return {
81
+ proxy,
82
+ async close() {
83
+ restore();
84
+ reset();
85
+ await bridge?.close(true);
86
+ },
87
+ };
88
+ }
@@ -0,0 +1,107 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { request as httpRequest } from "node:http";
5
+ import { request as httpsRequest } from "node:https";
6
+ import { isIP } from "node:net";
7
+ import { setTimeout as sleep } from "node:timers/promises";
8
+ import { agentHeaders } from "../agent.js";
9
+ import { redactHeaders } from "../facts/transport.js";
10
+ import { log } from "../logger.js";
11
+ import { delay, reason } from "./fetch.js";
12
+ import { guardedLookup, isPrivate, PrivateAddress } from "./guard.js";
13
+ import { pace, patient } from "./network.js";
14
+ import { robotsFactsOf } from "./robots.js";
15
+ const ATTEMPTS = 2;
16
+ const TIMEOUT_MS = 10_000;
17
+ const MAX_HOPS = 10;
18
+ const MAX_BODY = 1_048_576;
19
+ const RETRY_STATUS = new Set([429, 503]);
20
+ // A probe robots.txt disallows for `spiderlint`, never sent; `unreachable` when that is only because the file did not answer.
21
+ export class RobotsDisallowed extends Error {
22
+ unreachable;
23
+ constructor(message, unreachable) {
24
+ super(message);
25
+ if (unreachable !== undefined)
26
+ this.unreachable = unreachable;
27
+ }
28
+ }
29
+ // Reads at most MAX_BODY bytes of a response, as text and as they came.
30
+ async function text(response) {
31
+ const chunks = [];
32
+ let bytes = 0;
33
+ for await (const chunk of response) {
34
+ chunks.push(chunk);
35
+ bytes += chunk.byteLength;
36
+ if (bytes < MAX_BODY)
37
+ continue;
38
+ response.destroy();
39
+ const raw = Buffer.concat(chunks).subarray(0, MAX_BODY);
40
+ return { body: raw.toString("utf8"), raw, truncated: true };
41
+ }
42
+ const raw = Buffer.concat(chunks);
43
+ return { body: raw.toString("utf8"), raw };
44
+ }
45
+ // One request on a guarded socket, never following a redirect.
46
+ async function once(url, init, options) {
47
+ const literal = url.hostname.replaceAll(/^\[|\]$/g, "");
48
+ if (!options.allowPrivate && isIP(literal) && isPrivate(literal))
49
+ throw new PrivateAddress(`${url.hostname} is a private address`);
50
+ const request = url.protocol === "https:" ? httpsRequest : httpRequest;
51
+ await pace();
52
+ const expiry = AbortSignal.timeout(patient(TIMEOUT_MS));
53
+ const response = await new Promise((resolve, reject) => {
54
+ const outgoing = request(url, { method: init.method ?? "GET", headers: { ...init.headers, ...agentHeaders() }, signal: AbortSignal.any([options.signal, expiry]), ...(!options.allowPrivate && { lookup: guardedLookup }) }, resolve);
55
+ outgoing.on("error", reject);
56
+ outgoing.end();
57
+ });
58
+ const { body, raw, truncated } = await text(response);
59
+ return { url: url.href, status: response.statusCode ?? 0, headers: redactHeaders(response.headers), body, ...(truncated && { truncated }), ...(init.binary && { bytes: raw }) };
60
+ }
61
+ // One request with a retry on a network error, 429 or 503.
62
+ async function retrying(url, init, options) {
63
+ for (let attempt = 0;; attempt += 1) {
64
+ const isLast = attempt === ATTEMPTS - 1;
65
+ try {
66
+ const answer = await once(url, init, options);
67
+ log.debug({ url: url.href, method: init.method ?? "GET", status: answer.status, attempt }, "probed");
68
+ if (isLast || !RETRY_STATUS.has(answer.status))
69
+ return answer;
70
+ const retryAfter = answer.headers["retry-after"];
71
+ await sleep(delay(attempt, typeof retryAfter === "string" ? retryAfter : undefined), undefined, { signal: options.signal });
72
+ }
73
+ catch (error) {
74
+ log.debug({ url: url.href, attempt, error: reason(error) }, "probe failed");
75
+ if (isLast || error instanceof PrivateAddress || options.signal.aborted)
76
+ throw error;
77
+ await sleep(delay(attempt), undefined, { signal: options.signal });
78
+ }
79
+ }
80
+ }
81
+ // A GET or HEAD on `options.host` only, through the address guard unless private addresses are allowed.
82
+ export async function probe(href, init, options) {
83
+ const started = performance.now();
84
+ const redirects = [];
85
+ let url = new URL(href);
86
+ for (let hop = 0;; hop += 1) {
87
+ if (url.hostname !== options.host)
88
+ throw new Error(`probe ${url.href} leaves host ${options.host}`);
89
+ const robots = await options.robots?.(url.href);
90
+ if (robots && !robots.isAllowed(url.href, "spiderlint")) {
91
+ const facts = robotsFactsOf(robots);
92
+ const unreachable = facts?.error ?? (facts && facts.status >= 500 ? `robots.txt answers ${facts.status}` : undefined);
93
+ log.info({ url: url.href, unreachable }, "robots.txt disallows the probe; skipped");
94
+ throw new RobotsDisallowed(`robots.txt disallows ${url.href}`, unreachable);
95
+ }
96
+ const answer = await retrying(url, init, options);
97
+ const location = answer.headers.location;
98
+ const isRedirect = answer.status >= 300 && answer.status < 400 && typeof location === "string";
99
+ const next = isRedirect && URL.canParse(location, url) ? new URL(location, url) : undefined;
100
+ const isFollowed = init.redirect === "follow" && next?.hostname === options.host && hop < MAX_HOPS;
101
+ log.debug({ url: url.href, status: answer.status, location, hop, isFollowed }, "probe answered");
102
+ if (!isFollowed)
103
+ return { ...answer, redirects, ms: Math.round(performance.now() - started) };
104
+ redirects.push({ url: url.href, status: answer.status });
105
+ url = next;
106
+ }
107
+ }
@@ -0,0 +1,33 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { fetchCached } from "../cache/http.js";
5
+ import { log } from "../logger.js";
6
+ import { RobotsDisallowed } from "./probe.js";
7
+ // Bytes of a page read, as a probe reads at most as many.
8
+ const MAX_BODY = 1_048_576;
9
+ async function read(response) {
10
+ const chunks = [];
11
+ let bytes = 0;
12
+ const stream = response.body;
13
+ if (stream) {
14
+ for await (const chunk of stream) {
15
+ chunks.push(chunk);
16
+ bytes += chunk.byteLength;
17
+ if (bytes >= MAX_BODY)
18
+ break;
19
+ }
20
+ }
21
+ return { url: response.url, body: Buffer.concat(chunks).subarray(0, MAX_BODY).toString("utf8") };
22
+ }
23
+ // A GET answered from `bucket` while fresh, revalidated with its validators once stale; robots.txt applies as to any probe.
24
+ export async function cachedGet(url, bucket, robots) {
25
+ const file = await robots?.(url);
26
+ if (file && !file.isAllowed(url, "spiderlint")) {
27
+ log.info({ url }, "robots.txt disallows the page; skipped");
28
+ throw new RobotsDisallowed(`robots.txt disallows ${url}`);
29
+ }
30
+ const { status, value, at, cached, revalidated } = await fetchCached(bucket, url, read, true);
31
+ log.debug({ url, status, at, cached, revalidated }, "page read through the profiles bucket");
32
+ return { url: value.url, status, body: value.body, at: at ?? new Date().toISOString(), ...(cached && { cached }), ...(revalidated && { revalidated }) };
33
+ }
@@ -0,0 +1,86 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { mkdir, readFile } from "node:fs/promises";
5
+ import path from "node:path";
6
+ import lockfile from "proper-lockfile";
7
+ import { userCacheDirectory, writeAtomic } from "../cache/index.js";
8
+ import { log } from "../logger.js";
9
+ import { reason } from "./fetch.js";
10
+ // RFC 9224 §4: the IANA bootstrap mapping TLDs to RDAP base URLs.
11
+ export const BOOTSTRAP = "https://data.iana.org/rdap/dns.json";
12
+ const FRESH_MS = 86_400_000;
13
+ const STALE_LOCK_MS = 30_000;
14
+ const bootstrapFile = () => path.join(userCacheDirectory(), "rdap", "dns.json");
15
+ // The stored bootstrap, however old; none when absent or unreadable.
16
+ async function stored() {
17
+ try {
18
+ return JSON.parse(await readFile(bootstrapFile(), "utf8"));
19
+ }
20
+ catch (error) {
21
+ log.debug({ file: bootstrapFile(), error: reason(error) }, "rdap bootstrap not stored");
22
+ return undefined;
23
+ }
24
+ }
25
+ const isFresh = (entry) => Date.now() - Date.parse(entry.fetched) < FRESH_MS;
26
+ // Fetches and stores the bootstrap; the caller holds the lock.
27
+ async function refresh(get) {
28
+ const answer = await get(BOOTSTRAP, { headers: { accept: "application/json" } });
29
+ if (answer.status !== 200)
30
+ throw new Error(`the IANA RDAP bootstrap answered ${answer.status}`);
31
+ const services = JSON.parse(answer.body).services ?? [];
32
+ await writeAtomic(bootstrapFile(), JSON.stringify({ fetched: new Date().toISOString(), services }));
33
+ log.debug({ url: BOOTSTRAP, services: services.length }, "rdap bootstrap refreshed");
34
+ return services;
35
+ }
36
+ // The bootstrap services from the user cache while under a day old, else refetched by one process under a file lock; a stale copy stands in when the refresh fails.
37
+ export async function bootstrap(get) {
38
+ const cached = await stored();
39
+ if (cached && isFresh(cached))
40
+ return cached.services;
41
+ await mkdir(path.dirname(bootstrapFile()), { recursive: true });
42
+ const release = await lockfile.lock(path.dirname(bootstrapFile()), { lockfilePath: `${bootstrapFile()}.lock`, realpath: false, stale: STALE_LOCK_MS, retries: { retries: 10, minTimeout: 200, maxTimeout: 2000, randomize: true } });
43
+ try {
44
+ const again = await stored();
45
+ return again && isFresh(again) ? again.services : await refresh(get);
46
+ }
47
+ catch (error) {
48
+ if (!cached)
49
+ throw error;
50
+ log.warn({ url: BOOTSTRAP, fetched: cached.fetched, error: reason(error) }, "rdap bootstrap refresh failed; using the stored copy");
51
+ return cached.services;
52
+ }
53
+ finally {
54
+ await release();
55
+ }
56
+ }
57
+ // The RDAP base URL of the longest bootstrap suffix `domain` ends with, https preferred.
58
+ export function serverFor(domain, services) {
59
+ const labels = domain.toLowerCase().split(".");
60
+ for (let index = 1; index < labels.length; index += 1) {
61
+ const suffix = labels.slice(index).join(".");
62
+ const urls = services.find(([suffixes]) => suffixes.includes(suffix))?.[1];
63
+ if (urls)
64
+ return urls.find((url) => url.startsWith("https:")) ?? urls[0];
65
+ }
66
+ return undefined;
67
+ }
68
+ // A name server name lower-cased without its root dot.
69
+ export const nsName = (name) => name.toLowerCase().replace(/\.$/, "");
70
+ // The status, expiration and name servers of `domain` from its registry’s RDAP server (RFC 9083); none when no server serves its TLD.
71
+ export async function registration(domain, get) {
72
+ const base = serverFor(domain, await bootstrap(get));
73
+ log.debug({ domain, server: base }, "rdap server found");
74
+ if (!base)
75
+ return undefined;
76
+ const url = new URL(`domain/${domain}`, base.endsWith("/") ? base : `${base}/`).href;
77
+ const answer = await get(url, { headers: { accept: "application/rdap+json" }, redirect: "follow" });
78
+ if (answer.status !== 200)
79
+ throw new Error(`RDAP ${url} answered ${answer.status}`);
80
+ const body = JSON.parse(answer.body);
81
+ const expires = body.events?.find((event) => event.eventAction === "expiration")?.eventDate;
82
+ const isDated = expires !== undefined && !Number.isNaN(Date.parse(expires));
83
+ const nameservers = [...new Set((body.nameservers ?? []).flatMap((server) => (server.ldhName ? nsName(server.ldhName) : [])))].toSorted((a, b) => a.localeCompare(b));
84
+ log.debug({ domain, url, status: body.status, expires, nameservers: nameservers.length }, "rdap answered");
85
+ return { server: base, ...(Array.isArray(body.status) && { status: body.status.map((entry) => String(entry).toLowerCase()) }), ...(isDated && { expires: new Date(expires).toISOString() }), nameservers };
86
+ }
@@ -0,0 +1,132 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import dns, {} from "node:dns";
5
+ import { syncBuiltinESMExports } from "node:module";
6
+ import { isIP } from "node:net";
7
+ import { Bucket } from "../cache/index.js";
8
+ import { ConfigError } from "../config/index.js";
9
+ import { log } from "../logger.js";
10
+ import { dnsClient } from "./dns.js";
11
+ import { reason } from "./fetch.js";
12
+ import { guarding } from "./guard.js";
13
+ // Chromium’s `--host-resolver-rules` for the open run, empty when nothing is pinned.
14
+ const browser = { hostRules: "" };
15
+ // `host:address`, or curl’s `host:port:address` whose port is accepted and ignored, since a lookup never sees it.
16
+ export function parsePin(raw) {
17
+ const match = /^([^:[\]\s]+)(?::\d+)?:(\[[^\]]+\]|[^:[\]\s]+)$/.exec(raw.trim());
18
+ const address = match?.[2]?.replaceAll(/^\[|\]$/g, "") ?? "";
19
+ if (!match || isIP(address) === 0)
20
+ throw new ConfigError(`resolve: invalid pin ${raw} (expected host[:port]:address)`);
21
+ return { host: match[1].toLowerCase(), address };
22
+ }
23
+ function answer(addresses, options, callback) {
24
+ const [first] = addresses;
25
+ // eslint-disable-next-line unicorn/no-null -- the lookup callback contract types a success as a null error
26
+ callback(null, options.all ? addresses : (first?.address ?? ""), first?.family ?? 0);
27
+ }
28
+ function notFound(hostname, why) {
29
+ return Object.assign(new Error(`getaddrinfo ENOTFOUND ${hostname} (${why})`), { code: "ENOTFOUND", hostname });
30
+ }
31
+ // A and AAAA of `hostname` from `client`, IPv4 first, restricted to `family` when it is 4 or 6.
32
+ async function addressesOf(client, hostname, family) {
33
+ const types = family === 4 ? ["A"] : family === 6 ? ["AAAA"] : ["A", "AAAA"];
34
+ const replies = await Promise.all(types.map((type) => client.query(hostname, type)));
35
+ return replies.flatMap((reply) => reply.answers.flatMap((record) => (record.type === "A" || record.type === "AAAA" ? { address: record.data, family: record.type === "A" ? 4 : 6 } : [])));
36
+ }
37
+ // `dns.lookup` overloads (`(host, cb)`, `(host, family, cb)`) normalised to the three-argument form `lookup` takes.
38
+ function overloaded(lookup) {
39
+ return ((hostname, options, callback) => {
40
+ log.debug({ hostname, form: typeof options }, "dns.lookup called");
41
+ if (typeof options === "function")
42
+ return lookup(hostname, {}, options);
43
+ const normal = typeof options === "number" ? { family: options } : (options ?? {});
44
+ return lookup(hostname, normal, callback);
45
+ });
46
+ }
47
+ // Pins first, then the configured servers, `localhost` always from the system; every answer is kept for the run.
48
+ function crawlLookup(pins, client, system) {
49
+ const answers = new Map();
50
+ return (hostname, options, callback) => {
51
+ const name = hostname.toLowerCase().replace(/\.$/, "");
52
+ const pin = pins.find((entry) => entry.host === name);
53
+ if (pin) {
54
+ log.debug({ hostname, address: pin.address }, "name pinned by --resolve");
55
+ answer([{ address: pin.address, family: isIP(pin.address) }], options, callback);
56
+ return;
57
+ }
58
+ if (!client || name === "localhost" || name.endsWith(".localhost")) {
59
+ system(hostname, options, callback);
60
+ return;
61
+ }
62
+ const family = Number(options.family ?? 0);
63
+ const key = `${name}\t${family}`;
64
+ let pending = answers.get(key);
65
+ if (!pending) {
66
+ pending = addressesOf(client, name, family);
67
+ answers.set(key, pending);
68
+ }
69
+ const settle = async (asked) => {
70
+ let addresses;
71
+ try {
72
+ addresses = await asked;
73
+ }
74
+ catch (error) {
75
+ log.debug({ hostname, error: reason(error) }, "resolver lookup failed");
76
+ callback(notFound(hostname, reason(error)), "", 0);
77
+ return;
78
+ }
79
+ log.debug({ hostname, family, addresses: addresses.map((entry) => entry.address) }, "name resolved through the resolver");
80
+ if (addresses.length === 0)
81
+ callback(notFound(hostname, "no address from the resolver"), "", 0);
82
+ else
83
+ answer(addresses, options, callback);
84
+ };
85
+ void settle(pending);
86
+ };
87
+ }
88
+ // `MAP host address` per pin, then per seed host the resolver answers; Chromium resolves any other name itself.
89
+ async function chromiumRules(pins, seeds, client) {
90
+ const mapped = new Map(pins.map((pin) => [pin.host, pin.address]));
91
+ const hosts = [...new Set(seeds.flatMap((seed) => (URL.canParse(seed) ? [new URL(seed).hostname] : [])))].filter((host) => isIP(host.replaceAll(/^\[|\]$/g, "")) === 0 && !mapped.has(host));
92
+ if (client) {
93
+ for (const host of hosts) {
94
+ try {
95
+ const [first] = await addressesOf(client, host, 0);
96
+ log.debug({ host, address: first?.address }, "seed host resolved for the browser");
97
+ if (first)
98
+ mapped.set(host, first.address);
99
+ }
100
+ catch (error) {
101
+ log.warn({ host, error: reason(error) }, `${host} unresolved for the browser, which asks the system instead:`);
102
+ }
103
+ }
104
+ }
105
+ return [...mapped].map(([host, address]) => `MAP ${host} ${isIP(address) === 6 ? `[${address}]` : address}`).join(", ");
106
+ }
107
+ // Chromium launch arguments for the open run’s pins and resolver.
108
+ export function chromiumArguments() {
109
+ return browser.hostRules ? [`--host-resolver-rules=${browser.hostRules}`] : [];
110
+ }
111
+ // Swaps `dns.lookup` so got, fetch, probes and TLS handshakes resolve through pins and `resolver`, guarded unless private is allowed; returns the restore.
112
+ export async function openResolution(pins, resolver, seeds, isProxied, isPrivateAllowed = true) {
113
+ const isSystem = resolver === "system";
114
+ if (isPrivateAllowed && isSystem && pins.length === 0)
115
+ return () => { };
116
+ if (isProxied) {
117
+ log.warn({ pins: pins.length, resolver }, "names resolve inside the proxy; --resolve and --resolver do not reach the crawl");
118
+ return () => { };
119
+ }
120
+ const client = isSystem ? undefined : dnsClient(resolver, new Bucket("dns", undefined, 60, "off"), false);
121
+ const system = dns.lookup;
122
+ const lookup = crawlLookup(pins, client, system);
123
+ dns.lookup = overloaded(isPrivateAllowed ? lookup : guarding(lookup));
124
+ syncBuiltinESMExports();
125
+ browser.hostRules = await chromiumRules(pins, seeds, client);
126
+ log.debug({ pins: pins.map((pin) => pin.host), resolver, isPrivateAllowed, browserRules: browser.hostRules }, "crawl resolves names through the configured resolution");
127
+ return () => {
128
+ dns.lookup = system;
129
+ syncBuiltinESMExports();
130
+ browser.hostRules = "";
131
+ };
132
+ }