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,370 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { ProxyConfiguration } from "crawlee";
5
+ import picomatch from "picomatch";
6
+ import { vendorPath } from "../facts/vendors.js";
7
+ import { log } from "../logger.js";
8
+ import { Spread } from "./diversity.js";
9
+ import { width } from "./resources.js";
10
+ import { crawlDelayOf, robotsFactsOf } from "./robots.js";
11
+ import { isInScope, STRATEGY } from "./scope.js";
12
+ import { loadSitemap } from "./sitemap.js";
13
+ // Feed media types a head `rel=alternate` names; such a feed is crawled as a page.
14
+ const FEED_TYPES = new Set(["application/rss+xml", "application/atom+xml", "application/feed+json"]);
15
+ // Item links, the stylesheet and the archive pages a feed names, which its rules judge against the crawl.
16
+ function feedTargets(facts) {
17
+ const feed = facts.feed;
18
+ return [...(feed?.entries ?? []).flatMap((entry) => (entry.link ? [entry.link] : [])), ...(feed?.stylesheet ? [feed.stylesheet] : []), ...Object.values(feed?.archives ?? {})];
19
+ }
20
+ // Reasons a crawl ended early, the one that names it first.
21
+ const CRAWL_ENDS = ["interrupted", "timeout", "max-pages", "max-depth"];
22
+ function globMatchers(config) {
23
+ return { include: config.includeUrls.map((glob) => picomatch(glob)), exclude: config.excludeUrls.map((glob) => picomatch(glob)) };
24
+ }
25
+ // What every fetch mode shares: seeds, sitemap, globs, robots, the page budget, the visited set, and which crawler each URL goes to.
26
+ export class Frontier {
27
+ static async open(config, cache, router) {
28
+ return new Frontier(config, config.sitemap ? await loadSitemap(config.seeds, cache.robots, cache.sitemaps, config.canonicalOrigin) : { index: new Map(), files: [] }, router);
29
+ }
30
+ #config;
31
+ #seeds;
32
+ #sitemap;
33
+ #skipped = new Map();
34
+ // Vendor entries that kept a page out of the crawl, with the pages and the links they took.
35
+ #vendors = new Map();
36
+ #visited = new Set();
37
+ #globs;
38
+ #robots = [];
39
+ // Crawlee resets maxRequestsPerCrawl on every run(), so --max-pages needs its own cross-phase tally.
40
+ #handled = 0;
41
+ // Candidates pooled while `diversify` holds, and the requests already given to a crawler.
42
+ #spread;
43
+ #released = 0;
44
+ #router;
45
+ #crawlers = new Map();
46
+ #hasStraggled = false;
47
+ // Why pages were left unfetched, most telling first.
48
+ #cuts = new Set();
49
+ files;
50
+ // A vendor-owned path is no page of the site; include and exclude globs then run on `pathname + search`, as group matchers do.
51
+ transformRequestFunction = (request) => {
52
+ const vendor = this.#vendorOf(request.url);
53
+ if (vendor) {
54
+ const tally = this.#vendors.get(vendor) ?? { pages: new Set(), links: 0 };
55
+ tally.pages.add(request.url);
56
+ tally.links += 1;
57
+ this.#vendors.set(vendor, tally);
58
+ log.debug({ url: request.url, vendor: vendor.vendor, match: vendor.match }, "link skipped, vendor path");
59
+ return false;
60
+ }
61
+ const { include, exclude } = this.#globs;
62
+ const url = new URL(request.url);
63
+ const path = url.pathname + url.search;
64
+ const reason = include.length > 0 && include.every((match) => !match(path)) ? "include" : exclude.some((match) => match(path)) ? "exclude" : undefined;
65
+ if (!reason)
66
+ return request;
67
+ log.debug({ url: request.url, reason }, "link skipped");
68
+ this.#skipped.set(request.url, reason);
69
+ return false;
70
+ };
71
+ constructor(config, sitemaps, router) {
72
+ this.#config = config;
73
+ this.#router = router;
74
+ this.#seeds = new Set(config.seeds);
75
+ this.#sitemap = sitemaps.index;
76
+ this.#globs = globMatchers(config);
77
+ this.#spread = config.diversify ? new Spread(config.maxPages || Infinity) : undefined;
78
+ this.files = sitemaps.files;
79
+ }
80
+ // The shipped vendor entry owning `href` as a page, unless `vendor-paths` is off.
81
+ #vendorOf(href) {
82
+ return this.#config.vendorPaths ? vendorPath(href, "page") : undefined;
83
+ }
84
+ // Whether a URL a sitemap or a stored page names may join the crawl: unvisited, no vendor page, in scope and inside the globs.
85
+ #isCrawlable(href, reference) {
86
+ if (this.#visited.has(href) || this.#vendorOf(href))
87
+ return false;
88
+ const url = new URL(href);
89
+ if (!isInScope(url, reference, this.#config.scope))
90
+ return false;
91
+ const { include, exclude } = this.#globs;
92
+ const path = url.pathname + url.search;
93
+ return (include.length === 0 || include.some((match) => match(path))) && exclude.every((match) => !match(path));
94
+ }
95
+ // A sitemap URL still unvisited once the link crawl settles joins the frontier as its own root.
96
+ #stragglers() {
97
+ if (this.#sitemap.size === 0 || this.#config.seeds.length === 0 || !this.#config.follow)
98
+ return [];
99
+ const reference = new URL(this.#config.seeds[0]);
100
+ const extra = this.#sitemap
101
+ .keys()
102
+ .filter((href) => this.#isCrawlable(href, reference))
103
+ .toArray();
104
+ return extra;
105
+ }
106
+ // Gives the requests to their crawlers, or pools them while a budget spreads; returns how many the pool took.
107
+ async #offer(urls, crawlDepth) {
108
+ if (!this.#spread) {
109
+ await this.#add(urls.map((url) => ({ url, crawlDepth })));
110
+ return 0;
111
+ }
112
+ const spread = this.#spread;
113
+ const isDeep = this.#config.maxDepth > 0 && crawlDepth > this.#config.maxDepth;
114
+ let pooled = 0;
115
+ for (const url of urls) {
116
+ if (this.#visited.has(url))
117
+ continue;
118
+ const isNew = spread.add({ url, crawlDepth });
119
+ const isRelinked = !isNew && !isDeep && spread.relink(url, crawlDepth);
120
+ if (isNew)
121
+ pooled += 1;
122
+ if (isRelinked)
123
+ log.debug({ url, crawlDepth }, "listed candidate reached by a link");
124
+ }
125
+ log.debug({ offered: urls.length, pooled, size: this.#spread.size, crawlDepth }, "candidates pooled");
126
+ await this.#refill();
127
+ return pooled;
128
+ }
129
+ // Counts the seeds and the pages a resumed crawl stored as scheduled, then pools their links and the sitemap, which a spread crawl draws from at once.
130
+ #prime(resumed) {
131
+ const spread = this.#spread;
132
+ for (const page of resumed) {
133
+ this.#visited.add(page.url.href);
134
+ if (page.crawl.requested)
135
+ this.#visited.add(page.crawl.requested);
136
+ if (spread.note(page.url.href))
137
+ this.#released += 1;
138
+ }
139
+ this.#handled += resumed.length;
140
+ for (const seed of this.#seeds)
141
+ if (spread.note(seed))
142
+ this.#released += 1;
143
+ const reference = this.#config.seeds[0] ? new URL(this.#config.seeds[0]) : undefined;
144
+ const links = resumed.flatMap((page) => (page.html?.links.internal ?? []).filter((href) => reference && this.#config.follow && URL.canParse(href) && this.#isCrawlable(href, reference)).map((href) => ({ url: href, crawlDepth: page.crawl.depth + 1 })));
145
+ const listed = this.#stragglers().map((url) => ({ url, crawlDepth: 0 }));
146
+ const pooled = [...links, ...listed].filter((candidate) => spread.add(candidate)).length;
147
+ this.#hasStraggled = true;
148
+ log.debug({ resumed: resumed.length, seeds: this.#seeds.size, links: links.length, listed: listed.length, pooled, released: this.#released }, "budget primed");
149
+ }
150
+ // Gives the crawlers the pool’s most diverse candidates while their queues run short and the budget lasts; a drained queue has nothing pending.
151
+ async #refill(isDrained = false) {
152
+ if (!this.#spread)
153
+ return 0;
154
+ const pending = isDrained ? 0 : Math.max(0, this.#released - this.#handled);
155
+ const left = this.#config.maxPages > 0 ? this.#config.maxPages - this.#released : Infinity;
156
+ const room = Math.min(2 * width(this.#config.concurrency) - pending, left);
157
+ log.debug({ pending, room, released: this.#released, pooled: this.#spread.size, maxPages: this.#config.maxPages, isDrained }, "refill decided");
158
+ if (room <= 0)
159
+ return 0;
160
+ const batch = this.#spread.take(room);
161
+ this.#released += batch.length;
162
+ await this.#add(batch);
163
+ return batch.length;
164
+ }
165
+ // Queues each request on the crawler its group needs.
166
+ async #add(requests) {
167
+ const byCrawler = Object.groupBy(requests, (request) => this.#router.queue(request.url));
168
+ for (const [mode, batch = []] of Object.entries(byCrawler)) {
169
+ log.debug({ crawler: mode, requests: batch.length }, "requests routed");
170
+ await this.#crawlers.get(mode)?.addRequests(batch);
171
+ }
172
+ }
173
+ // Finished once every crawler’s queue drains and no sitemap straggler is left to add.
174
+ async #isFinished() {
175
+ for (const crawler of this.#crawlers.values()) {
176
+ const queue = await crawler.getRequestQueue();
177
+ if (!(await queue.isFinished()))
178
+ return false;
179
+ }
180
+ if ((await this.#refill(true)) > 0)
181
+ return false;
182
+ if (this.#hasStraggled)
183
+ return true;
184
+ this.#hasStraggled = true;
185
+ const stragglers = this.#stragglers();
186
+ const isOverBudget = this.#config.maxPages > 0 && this.#handled >= this.#config.maxPages;
187
+ log.debug({ listed: this.#sitemap.size, stragglers: stragglers.length, handled: this.#handled, isOverBudget }, "queue drained");
188
+ if (isOverBudget && stragglers.length > 0)
189
+ this.cut("max-pages");
190
+ if (isOverBudget || stragglers.length === 0)
191
+ return true;
192
+ await this.#add(stragglers.map((url) => ({ url, crawlDepth: 0 })));
193
+ return false;
194
+ }
195
+ // Each seed’s origin, once.
196
+ #origins() {
197
+ return [...new Set(this.#config.seeds.map((seed) => new URL(seed).origin))];
198
+ }
199
+ // The longest `Crawl-delay` over the seed origins, as Crawlee applies one delay to every domain.
200
+ async #crawlDelay(robots) {
201
+ const origins = this.#origins();
202
+ const delays = await Promise.all(origins.map(async (origin) => crawlDelayOf(await robots(origin))));
203
+ const delay = Math.max(0, ...delays);
204
+ if (delay > 0)
205
+ log.info({ delay, origins: origins.length }, `robots.txt asks for ${delay} s between requests, honoured`);
206
+ return delay;
207
+ }
208
+ // Why a seed was never fetched, naming the network error behind an unreachable robots.txt.
209
+ #explainSkippedSeed(seed) {
210
+ const reason = this.#skipped.get(seed);
211
+ if (reason === undefined)
212
+ return;
213
+ const error = this.#robots.find((facts) => facts.url === `${new URL(seed).origin}/robots.txt`)?.error;
214
+ if (error)
215
+ log.error({ url: seed, error }, "seed not crawled: its robots.txt is unreachable, which disallows the whole origin (RFC 9309)");
216
+ else
217
+ log.warn({ url: seed, reason }, `seed not crawled, skipped by its ${reason} check:`);
218
+ }
219
+ // How the crawl ended: complete when nothing was left unfetched, else the first reason of `CRAWL_ENDS` that applies.
220
+ #crawl() {
221
+ if (this.#spread && this.#spread.size > 0)
222
+ this.#cuts.add("max-pages");
223
+ const reason = CRAWL_ENDS.find((end) => this.#cuts.has(end));
224
+ log.debug({ cuts: [...this.#cuts], pooled: this.#spread?.size, reason }, "crawl end decided");
225
+ return reason ? { complete: false, reason } : { complete: true };
226
+ }
227
+ // Crawler options every adapter passes through unchanged; crawlers running side by side split the rate.
228
+ options(mode, storage, proxy) {
229
+ return {
230
+ ...(storage && { requestQueue: storage.queues[mode] }),
231
+ autoscaledPoolOptions: { isFinishedFunction: () => this.#isFinished() },
232
+ // A 401, 403 or 429 is a page to lint, never a session to retire and retry.
233
+ sessionPoolOptions: { blockedStatusCodes: [] },
234
+ requestHandlerTimeoutSecs: this.#config.timeout,
235
+ navigationTimeoutSecs: Math.ceil(this.#config.timeout / 2),
236
+ maxRequestsPerCrawl: this.#config.maxPages || undefined,
237
+ maxRequestsPerMinute: this.#config.rate ? Math.max(1, Math.floor(this.#config.rate / this.#router.crawlers.length)) : undefined,
238
+ ...(proxy && { proxyConfiguration: new ProxyConfiguration({ proxyUrls: [proxy] }) }),
239
+ maxCrawlDepth: this.#config.maxDepth || undefined,
240
+ respectRobotsTxtFile: this.#config.robots && { userAgent: "spiderlint" },
241
+ onSkippedRequest: ({ url, reason }) => {
242
+ log.debug({ url, reason }, "link skipped");
243
+ this.#skipped.set(url, reason);
244
+ if (reason === "limit")
245
+ this.cut("max-pages");
246
+ else if (reason === "depth")
247
+ this.cut("max-depth");
248
+ if (this.#spread)
249
+ this.#released = Math.max(0, this.#released - 1);
250
+ },
251
+ };
252
+ }
253
+ // False once --max-pages is spent; otherwise the requested and the loaded URL count as visited.
254
+ admit(request, loaded) {
255
+ this.#handled += 1;
256
+ if (this.#config.maxPages && this.#handled > this.#config.maxPages) {
257
+ log.debug({ url: request.url, handled: this.#handled, maxPages: this.#config.maxPages }, "page dropped past max-pages");
258
+ this.cut("max-pages");
259
+ return false;
260
+ }
261
+ this.#visited.add(request.url);
262
+ this.#visited.add(loaded.href);
263
+ return true;
264
+ }
265
+ // Records that the crawl left pages unfetched for `reason`.
266
+ cut(reason) {
267
+ log.debug({ reason, cuts: [...this.#cuts] }, "crawl cut");
268
+ this.#cuts.add(reason);
269
+ }
270
+ // The facts a page owes to its URL and how the crawl reached it, whatever fetched it; the sitemap entry is the loaded URL’s, else the requested one’s.
271
+ identity(request, url, mode) {
272
+ const listing = this.#sitemap.get(url.href) ?? this.#sitemap.get(request.url);
273
+ return {
274
+ url: { href: url.href, origin: url.origin, protocol: url.protocol, host: url.host, pathname: url.pathname, search: url.search },
275
+ group: "default",
276
+ crawl: { depth: request.crawlDepth, "discovered-via": request.crawlDepth > 0 ? "link" : this.#seeds.has(request.url) ? "seed" : "sitemap", referrers: [], ...(request.url !== url.href && { requested: request.url }), at: new Date().toISOString(), ...(mode && { mode }) },
277
+ ...(this.#sitemap.size > 0 && { sitemap: listing ?? { listed: false } }),
278
+ };
279
+ }
280
+ // A page no attempt fetched, as status 0 and its error so its referrers still see it; undefined past --max-pages.
281
+ failed(request, error) {
282
+ const url = new URL(request.url);
283
+ if (!this.admit(request, url))
284
+ return undefined;
285
+ log.warn({ url: url.href, error, retries: request.retryCount }, "page not fetched");
286
+ return { ...this.identity(request, url), http: { status: 0, redirects: [], headers: {}, timing: {}, cookies: [], size: { body: 0, decoded: 0 }, "content-type": "", error } };
287
+ }
288
+ // Hands a request to the browser before any fetch when its group renders there; true once handed.
289
+ async handOff(request) {
290
+ if (!this.#crawlers.has("browser") || !(await this.#router.handOff(request.url)))
291
+ return false;
292
+ await this.#crawlers.get("browser")?.addRequests([{ url: request.url, crawlDepth: request.crawlDepth }]);
293
+ return true;
294
+ }
295
+ // The page’s anchors and head feeds, or a feed’s targets, queued under the scope and globs on the crawler their group needs, unless the seeds are the whole frontier; returns how many anchors and head feeds were new.
296
+ async enqueue(enqueueLinks, facts, mode) {
297
+ if (!this.#config.follow)
298
+ return 0;
299
+ const routed = [];
300
+ const transformRequestFunction = (request) => {
301
+ const kept = this.transformRequestFunction(request);
302
+ const isRouted = kept && isInScope(new URL(request.url), new URL(facts.url.href), this.#config.scope) && (this.#spread !== undefined || this.#router.queue(request.url) !== mode);
303
+ if (!isRouted)
304
+ return kept;
305
+ routed.push(request.url);
306
+ return false;
307
+ };
308
+ const options = { strategy: STRATEGY[this.#config.scope], transformRequestFunction };
309
+ const heads = facts.html?.head.links ?? [];
310
+ const feeds = heads.filter((link) => /\balternate\b/i.test(link.rel ?? "") && FEED_TYPES.has(link.type?.toLowerCase() ?? "")).flatMap((link) => (link.href ? [link.href] : []));
311
+ log.debug({ url: facts.url.href, feeds }, "head feeds found");
312
+ const batches = facts.html ? [await enqueueLinks(options), ...(feeds.length > 0 ? [await enqueueLinks({ ...options, urls: feeds })] : [])] : [];
313
+ const targets = feedTargets(facts).filter((url) => URL.canParse(url) && isInScope(new URL(url), new URL(facts.url.href), this.#config.scope) && this.transformRequestFunction({ url }) !== false);
314
+ log.debug({ url: facts.url.href, targets: targets.length }, "feed targets queued");
315
+ const pooled = await this.#offer([...routed, ...targets], facts.crawl.depth + 1);
316
+ return batches.flatMap((batch) => batch.processedRequests).filter((entry) => !entry.wasAlreadyPresent).length + (this.#spread ? pooled : routed.length);
317
+ }
318
+ // Pages known: every queue’s requests, plus sitemap URLs no link has reached yet, within the page budget.
319
+ async known() {
320
+ let total = (this.#spread?.size ?? 0) + (this.#hasStraggled ? 0 : this.#stragglers().length);
321
+ for (const crawler of this.#crawlers.values()) {
322
+ const queue = await crawler.getRequestQueue();
323
+ total += queue.getTotalCount();
324
+ }
325
+ return this.#config.maxPages > 0 ? Math.min(total, this.#config.maxPages) : total;
326
+ }
327
+ // What the crawl learnt about the site: how it ended, sitemap files, and each seed origin’s robots.txt when one was read.
328
+ site() {
329
+ return { sitemaps: this.files, crawl: this.#crawl(), ...(this.#robots.length > 0 && { robots: this.#robots }) };
330
+ }
331
+ // Seeds first, each on its group’s crawler, sitemap stragglers while the budget lasts; robots.txt answered through the robots bucket, a crawl-delay stretched over the crawlers sharing it.
332
+ async run(crawlers, robots, resumed = []) {
333
+ if (this.#spread)
334
+ this.#prime(resumed);
335
+ const answer = (url) => (this.#config.robots ? robots(url) : Promise.resolve(undefined));
336
+ const delay = this.#config.robots ? await this.#crawlDelay(robots) : 0;
337
+ const running = Object.entries(crawlers);
338
+ for (const [mode, crawler] of running) {
339
+ Object.assign(crawler, { getRobotsTxtFileForUrl: answer });
340
+ if (delay > 0)
341
+ Object.assign(crawler, { sameDomainDelayMillis: delay * 1000 * running.length });
342
+ this.#crawlers.set(mode, crawler);
343
+ }
344
+ const seeds = Object.groupBy(this.#config.seeds, (seed) => this.#router.queue(seed));
345
+ log.debug({ crawlers: running.map(([mode]) => mode), http: seeds.http?.length ?? 0, browser: seeds.browser?.length ?? 0 }, "seeds routed");
346
+ await Promise.all(running.map(([mode, crawler]) => crawler.run(seeds[mode] ?? [])));
347
+ for (const [mode, crawler] of running) {
348
+ const queue = await crawler.getRequestQueue();
349
+ if (this.#config.maxPages === 0 || (await queue.isFinished()))
350
+ continue;
351
+ log.debug({ crawler: mode, handled: this.#handled }, "queue left requests behind");
352
+ this.cut("max-pages");
353
+ }
354
+ const isRead = this.#config.robots || this.#config.sitemap;
355
+ const files = isRead ? await Promise.all(this.#origins().map(async (origin) => robotsFactsOf(await robots(origin)))) : [];
356
+ this.#robots = files.filter((facts) => facts !== undefined);
357
+ log.debug({ isRead, files: this.#robots.length }, "robots.txt facts collected");
358
+ const reasons = Object.groupBy(this.#skipped.values(), (reason) => reason);
359
+ if (this.#skipped.size > 0)
360
+ log.debug({ skipped: this.#skipped.size, ...Object.fromEntries(Object.entries(reasons).map(([reason, all]) => [reason, all?.length])) }, "links skipped");
361
+ for (const seed of this.#seeds)
362
+ this.#explainSkippedSeed(seed);
363
+ const vendors = this.#vendors
364
+ .entries()
365
+ .map(([entry, tally]) => ({ vendor: entry.vendor, match: entry.match, pages: tally.pages.size, links: tally.links }))
366
+ .toArray();
367
+ if (vendors.length > 0)
368
+ log.debug({ vendors }, "vendor paths kept out of the crawl");
369
+ }
370
+ }
@@ -0,0 +1,69 @@
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 { BlockList, isIP } from "node:net";
6
+ import { log } from "../logger.js";
7
+ // Loopback, private, link-local, CGNAT, unspecified, unique-local and cloud metadata ranges.
8
+ const PRIVATE = new BlockList();
9
+ for (const [network, prefix] of [
10
+ ["0.0.0.0", 8],
11
+ ["10.0.0.0", 8],
12
+ ["100.64.0.0", 10],
13
+ ["127.0.0.0", 8],
14
+ ["169.254.0.0", 16],
15
+ ["172.16.0.0", 12],
16
+ ["192.168.0.0", 16],
17
+ ])
18
+ PRIVATE.addSubnet(network, prefix, "ipv4");
19
+ for (const [network, prefix] of [
20
+ ["::", 128],
21
+ ["::1", 128],
22
+ ["fc00::", 7],
23
+ ["fe80::", 10],
24
+ ])
25
+ PRIVATE.addSubnet(network, prefix, "ipv6");
26
+ // An address no public probe may reach; an IPv4-mapped IPv6 address is judged as its IPv4 form.
27
+ export function isPrivate(address) {
28
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address)?.[1];
29
+ return mapped ? PRIVATE.check(mapped, "ipv4") : PRIVATE.check(address, isIP(address) === 6 ? "ipv6" : "ipv4");
30
+ }
31
+ // A refused address, named so the log and the fact say why.
32
+ export class PrivateAddress extends Error {
33
+ code = "EPRIVATE";
34
+ }
35
+ // The address a raw socket to `hostname` may connect to, through the run’s lookup; a private one is refused unless allowed.
36
+ export async function connectable(hostname, isPrivateAllowed) {
37
+ const address = isIP(hostname) === 0 ? await new Promise((resolve, reject) => dns.lookup(hostname, (error, found) => (error ? reject(error) : resolve(found)))) : hostname;
38
+ log.debug({ hostname, address, isPrivateAllowed }, "socket address resolved");
39
+ if (!isPrivateAllowed && isPrivate(address))
40
+ throw new PrivateAddress(`${hostname} resolves to private address ${address}`);
41
+ return address;
42
+ }
43
+ // `lookup` failing when any answer is private, so the socket connects only to an address it checked.
44
+ export function guarding(lookup) {
45
+ return (hostname, options, callback) => {
46
+ lookup(hostname, { ...options, all: true }, (error, found) => {
47
+ if (error)
48
+ return callback(error, "", 0);
49
+ const addresses = found;
50
+ const refused = addresses.find((answer) => isPrivate(answer.address));
51
+ log.debug({ hostname, addresses: addresses.map((answer) => answer.address), refused: refused?.address }, "address checked");
52
+ if (refused)
53
+ return callback(new PrivateAddress(`${hostname} resolves to private address ${refused.address}`), "", 0);
54
+ const [first] = addresses;
55
+ // eslint-disable-next-line unicorn/no-null -- the lookup callback contract types a success as a null error
56
+ callback(null, options.all ? addresses : (first?.address ?? ""), first?.family ?? 0);
57
+ });
58
+ };
59
+ }
60
+ // The system lookup, guarded.
61
+ export const guardedLookup = guarding((hostname, options, callback) => dns.lookup(hostname, options, callback));
62
+ // Throws for a URL whose host is a private address literal, which no lookup ever sees.
63
+ export function refuseLiteral(url, isPrivateAllowed) {
64
+ const host = new URL(url).hostname.replaceAll(/^\[|\]$/g, "");
65
+ if (isPrivateAllowed || isIP(host) === 0 || !isPrivate(host))
66
+ return;
67
+ log.warn({ host, url: String(url) }, "private address literal refused");
68
+ throw new PrivateAddress(`${host} is a private address`);
69
+ }
@@ -0,0 +1,171 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { CheerioCrawler, Configuration } from "crawlee";
5
+ import { agentHeaders } from "../agent.js";
6
+ import { ACCEPT_ENCODING, capped, isParsed, replayed } from "./body.js";
7
+ import { extractHtml, HTML_TYPES } from "../facts/html.js";
8
+ import { extractResources } from "../facts/resources.js";
9
+ import { cookieFacts, dateSkew, earlyHintsHook, redactHeaders, redirectHook, timingFacts, tlsFacts } from "../facts/transport.js";
10
+ import { log } from "../logger.js";
11
+ import { MISMATCH, reason } from "./fetch.js";
12
+ import { guardUrl } from "./network.js";
13
+ import { width } from "./resources.js";
14
+ // The first value of a header that may repeat.
15
+ function first(value) {
16
+ return Array.isArray(value) ? value[0] : value;
17
+ }
18
+ // `If-None-Match` and `If-Modified-Since` from a stored page’s `ETag` and `Last-Modified`.
19
+ function validators(headers) {
20
+ const etag = first(headers.etag);
21
+ const modified = first(headers["last-modified"]);
22
+ return { ...(etag && { "if-none-match": etag }), ...(modified && { "if-modified-since": modified }) };
23
+ }
24
+ // A 304 keeps the stored status, size and cookies and merges its headers over the stored ones.
25
+ function revalidated(earlier, fresh) {
26
+ const cookies = fresh.http.cookies.length > 0 ? fresh.http.cookies : earlier.http.cookies;
27
+ const headers = { ...earlier.http.headers, ...fresh.http.headers };
28
+ return { ...fresh.http, status: earlier.http.status, size: earlier.http.size, headers, cookies, revalidated: true };
29
+ }
30
+ // The stored `Content-Type` header, else one rebuilt from the stored type and charset.
31
+ function storedContentType(facts) {
32
+ return first(facts.http.headers["content-type"]) ?? `${facts.http["content-type"]}${facts.http.charset ? `; charset=${facts.http.charset}` : ""}`;
33
+ }
34
+ // Wire bytes received so far, from got's progress on the original response stream.
35
+ function transferred(source) {
36
+ return source?.downloadProgress?.transferred;
37
+ }
38
+ // The response's own connection, read while it is still attached.
39
+ function socketOf(source) {
40
+ const stream = source;
41
+ return stream.socket ?? stream.request?.socket;
42
+ }
43
+ // Why a page fetch failed, naming both byte counts when the body ended short of `Content-Length`.
44
+ function failure(error, source) {
45
+ const declared = Number(first(source?.headers?.["content-length"]));
46
+ const received = transferred(source);
47
+ log.debug({ error: error.message, declared, received }, "page failure named");
48
+ if (received !== undefined && received < declared)
49
+ return `${MISMATCH}: body ended at ${received} of the ${declared} bytes Content-Length declares`;
50
+ return /content-length/i.test(error.message) ? MISMATCH : reason(error);
51
+ }
52
+ // Hands a request its group renders to the browser before fetching it.
53
+ class Crawler extends CheerioCrawler {
54
+ #frontier;
55
+ constructor(options, config, frontier) {
56
+ super(options, config);
57
+ this.#frontier = frontier;
58
+ }
59
+ async _runRequestHandler(context) {
60
+ const isHanded = await this.#frontier.handOff(context.request);
61
+ log.debug({ url: context.request.url, isHanded }, "request crawler chosen");
62
+ return isHanded ? undefined : super._runRequestHandler(context);
63
+ }
64
+ }
65
+ // The http crawler of a frontier: fetches and parses without rendering; storage stays in memory.
66
+ export function httpCrawler(config, onPage, frontier, storage, proxy) {
67
+ let pages = 0;
68
+ const revalidating = new WeakMap();
69
+ const hinted = new WeakMap();
70
+ const hopped = new WeakMap();
71
+ let revalidatedPages = 0;
72
+ const bodies = new WeakMap();
73
+ const crawler = new Crawler({
74
+ additionalMimeTypes: ["*/*"],
75
+ ...frontier.options("http", storage, proxy),
76
+ maxConcurrency: width(config.concurrency),
77
+ preNavigationHooks: [
78
+ async ({ request }, gotOptions) => {
79
+ bodies.delete(request);
80
+ Object.assign(gotOptions, { decompress: false, headers: { ...gotOptions.headers, ...agentHeaders(), "accept-encoding": ACCEPT_ENCODING } });
81
+ const hints = [];
82
+ hinted.set(request, hints);
83
+ const hops = [];
84
+ hopped.set(request, hops);
85
+ Object.assign(gotOptions, {
86
+ hooks: { ...gotOptions.hooks, beforeRequest: [...(gotOptions.hooks?.beforeRequest ?? []), (options) => guardUrl(options.url ?? request.url), earlyHintsHook(request.url, hints)], beforeRedirect: [...(gotOptions.hooks?.beforeRedirect ?? []), redirectHook(hops)] },
87
+ });
88
+ const earlier = config.cacheMode === "use" ? await storage?.earlier?.(request.url) : undefined;
89
+ const conditional = earlier ? validators(earlier.facts.http.headers) : {};
90
+ log.debug({ url: request.url, isStored: earlier !== undefined, conditional: Object.keys(conditional) }, "page revalidation decided");
91
+ if (earlier && Object.keys(conditional).length > 0) {
92
+ revalidating.set(request, earlier);
93
+ Object.assign(gotOptions, { headers: { ...gotOptions.headers, ...conditional } });
94
+ }
95
+ if (config.keepalive)
96
+ return;
97
+ Object.assign(gotOptions, { http2: false, headers: { ...gotOptions.headers, connection: "close" } });
98
+ },
99
+ ],
100
+ postNavigationHooks: [
101
+ (context) => {
102
+ const source = context.response;
103
+ const contentType = source.headers["content-type"];
104
+ const max = isParsed(contentType) ? config.maxBodySize : 0;
105
+ const cap = capped(source, max);
106
+ log.debug({ url: context.request.url, contentType, max }, "body capped");
107
+ const socket = socketOf(source);
108
+ const address = socket?.remoteAddress ?? source.ip;
109
+ const remote = address ? { address, ...(socket?.remoteFamily && { family: socket.remoteFamily }) } : undefined;
110
+ bodies.set(context.request, { ...cap, source: source, tls: tlsFacts(socket), remote });
111
+ const earlier = source.statusCode === 304 ? revalidating.get(context.request) : undefined;
112
+ log.debug({ url: context.request.url, isReplayed: earlier !== undefined }, "page body chosen");
113
+ Object.assign(context, { response: earlier ? replayed(cap.stream, earlier.body, storedContentType(earlier.facts)) : cap.stream });
114
+ },
115
+ ],
116
+ async requestHandler({ request, response, body, $, contentType, enqueueLinks }) {
117
+ const url = new URL(request.loadedUrl ?? request.url);
118
+ if (!frontier.admit(request, url))
119
+ return;
120
+ const pending = revalidating.get(request);
121
+ const earlier = response.statusCode === 304 ? pending : undefined;
122
+ // A validator answered 200 with the stored body still byte-identical.
123
+ const isUnmodified = response.statusCode === 200 && pending !== undefined && body.toString() === pending.body;
124
+ const isHtml = HTML_TYPES.has(contentType.type);
125
+ const cap = bodies.get(request);
126
+ const decoded = Buffer.byteLength(body);
127
+ const declared = Number(response.headers["content-length"]);
128
+ const skew = dateSkew(url.href, response.headers, cap?.source.timings?.upload, cap?.source.timings?.response);
129
+ const facts = {
130
+ ...frontier.identity(request, url, "http"),
131
+ http: {
132
+ status: response.statusCode ?? 0,
133
+ ...(cap?.source.httpVersion && { version: cap.source.httpVersion }),
134
+ redirects: (cap?.source.redirectUrls ?? []).map((redirect, index, all) => hopped.get(request)?.at(index - all.length) ?? { url: String(redirect) }),
135
+ headers: redactHeaders(response.headers),
136
+ ...(cap?.remote && { remote: cap.remote }),
137
+ timing: cap ? timingFacts(cap.source) : {},
138
+ cookies: cookieFacts(response.headers["set-cookie"], response.headers.date),
139
+ ...(hinted.get(request)?.length && { "early-hints": hinted.get(request) }),
140
+ size: {
141
+ body: transferred(cap?.source) ?? decoded,
142
+ decoded,
143
+ ...(Number.isSafeInteger(declared) && { declared }),
144
+ ...(cap?.isTruncated() && { truncated: true }),
145
+ },
146
+ "content-type": contentType.type,
147
+ ...(contentType.encoding && { charset: contentType.encoding }),
148
+ ...(skew !== undefined && { "date-skew": skew }),
149
+ ...(isUnmodified && { unmodified: true }),
150
+ },
151
+ ...(cap?.tls && { tls: cap.tls }),
152
+ ...(isHtml && { html: extractHtml($, body.toString(), url, config.scope), resources: extractResources($, url, config.maxResourcesPerPage) }),
153
+ };
154
+ if (earlier)
155
+ facts.http = revalidated(earlier.facts, facts);
156
+ revalidatedPages += earlier ? 1 : 0;
157
+ pages += 1;
158
+ log.debug({ url: url.href, status: facts.http.status, type: facts.http["content-type"], bytes: facts.http.size.body, depth: facts.crawl.depth, revalidated: facts.http.revalidated, unmodified: facts.http.unmodified }, "page fetched");
159
+ await onPage(facts, body.toString());
160
+ if (!isHtml && !facts.feed)
161
+ return;
162
+ log.debug({ url: url.href, enqueued: await frontier.enqueue(enqueueLinks, facts, "http") }, "links enqueued");
163
+ },
164
+ async failedRequestHandler({ request }, error) {
165
+ const facts = frontier.failed(request, failure(error, bodies.get(request)?.source));
166
+ if (facts)
167
+ await onPage(facts, "");
168
+ },
169
+ }, storage?.config ?? new Configuration({ persistStorage: false, purgeOnStart: false }), frontier);
170
+ return { crawler, stats: () => ({ pages, revalidated: revalidatedPages }) };
171
+ }