spiderlint 0.0.0 → 0.143.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +233 -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 +168 -0
  71. package/dist/plugins/css.js +328 -0
  72. package/dist/plugins/dns.js +864 -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 +302 -0
  79. package/dist/plugins/images.js +269 -0
  80. package/dist/plugins/index.js +286 -0
  81. package/dist/plugins/keyboard.js +194 -0
  82. package/dist/plugins/lighthouse.js +110 -0
  83. package/dist/plugins/link-text.js +73 -0
  84. package/dist/plugins/list.js +22 -0
  85. package/dist/plugins/live.js +254 -0
  86. package/dist/plugins/mail.js +985 -0
  87. package/dist/plugins/manifest.js +97 -0
  88. package/dist/plugins/markup.js +98 -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 +358 -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 +56 -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 +16 -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 +353 -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,498 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { load } from "cheerio";
5
+ import { spawn } from "node:child_process";
6
+ import { createHash } from "node:crypto";
7
+ import { existsSync } from "node:fs";
8
+ import { createRequire } from "node:module";
9
+ import { createServer } from "node:net";
10
+ import path from "node:path";
11
+ import { MIMEType } from "node:util";
12
+ import { Configuration, PlaywrightCrawler } from "crawlee";
13
+ import { chromium, firefox, webkit } from "playwright";
14
+ import { browserContext } from "../agent.js";
15
+ import { ConfigError } from "../config/index.js";
16
+ import { COOKIE_WRITES, headerFacts, observedResources, scriptCookies, redirectFacts, remoteFacts, timingFacts, tlsFacts, weightFacts, wireSize, withProbe } from "../facts/browser.js";
17
+ import { declaredHtml, extractHtml, HTML_TYPES } from "../facts/html.js";
18
+ import { parityFacts } from "../facts/parity.js";
19
+ import { extractResources } from "../facts/resources.js";
20
+ import { cookieFacts, dateSkew, redactHeaders } from "../facts/transport.js";
21
+ import { log } from "../logger.js";
22
+ import { isParsed } from "./body.js";
23
+ import { reason } from "./fetch.js";
24
+ import { width } from "./resources.js";
25
+ import { chromiumArguments } from "./resolve.js";
26
+ import { TlsProber } from "./tls-probe.js";
27
+ const SETTLE_MS = 5000;
28
+ // Pages a browser renders before a fresh one replaces it.
29
+ const RETIRE_AFTER_PAGES = 1000;
30
+ const LAUNCHERS = { chromium, firefox, webkit };
31
+ const PORT_ARGUMENT = "--remote-debugging-port=";
32
+ // Chromium past Crawlee’s loopback proxy, so it reports the server it reached.
33
+ const DIRECT = "--proxy-server=direct://";
34
+ // Each rendered page’s browser DevTools port, when an extractor asked for one.
35
+ const ports = new WeakMap();
36
+ const loaded = new WeakMap();
37
+ // The DevTools port of the browser rendering `page`; undefined unless an active extractor is `debugging`.
38
+ export function debuggingPort(page) {
39
+ return ports.get(page);
40
+ }
41
+ // The digest of every sub-resource body `page` loaded; undefined when one failed, was not read, or a frame navigated.
42
+ export function loadedDigest(page) {
43
+ return loaded.get(page);
44
+ }
45
+ // A loopback port free right now, for the next browser to listen on.
46
+ async function freePort() {
47
+ const server = createServer();
48
+ await new Promise((resolve, reject) => server.once("error", reject).listen(0, "127.0.0.1", resolve));
49
+ const { port } = server.address();
50
+ await new Promise((resolve) => server.close(resolve));
51
+ return port;
52
+ }
53
+ // Headers, address and certificate of a document response; an unreadable one keeps no headers rather than failing the page.
54
+ async function readTransport(response) {
55
+ try {
56
+ const [headers, address, security] = await Promise.all([response.headersArray(), response.serverAddr(), response.securityDetails()]);
57
+ const tls = tlsFacts(security);
58
+ return { headers: headerFacts(headers), remote: remoteFacts(address), ...(tls && { tls }), ...(security?.protocol && { security: security.protocol }) };
59
+ }
60
+ catch (error) {
61
+ log.warn({ url: response.url(), error: String(error) }, "document transport unreadable");
62
+ return { headers: {} };
63
+ }
64
+ }
65
+ // Listens to a page before it navigates, so a download that aborts the navigation still leaves its response behind.
66
+ function observe(page) {
67
+ const seen = { protocols: new Map(), console: { errors: [], warnings: [] }, requests: [] };
68
+ const errors = new Set();
69
+ const warnings = new Set();
70
+ page.on("response", (response) => {
71
+ if (!response.request().isNavigationRequest() || response.frame() !== page.mainFrame())
72
+ return;
73
+ seen.document = response;
74
+ seen.transport = readTransport(response);
75
+ });
76
+ page.on("console", (message) => {
77
+ const bucket = message.type() === "error" ? errors : message.type() === "warning" ? warnings : undefined;
78
+ bucket?.add(message.text());
79
+ seen.console = { errors: [...errors], warnings: [...warnings] };
80
+ });
81
+ page.on("pageerror", (error) => {
82
+ errors.add(error.message);
83
+ seen.console = { errors: [...errors], warnings: [...warnings] };
84
+ });
85
+ const record = (request) => {
86
+ seen.requests.push(request);
87
+ };
88
+ page.on("requestfinished", record);
89
+ page.on("requestfailed", record);
90
+ return seen;
91
+ }
92
+ // Records per document URL the protocol Chromium’s network log names, `h3`, `h2` or `http/1.1`; other browsers record nothing.
93
+ async function watchProtocols(page, seen) {
94
+ try {
95
+ const cdp = await page.context().newCDPSession(page);
96
+ cdp.on("Network.responseReceived", ({ type, response }) => type === "Document" && response.protocol && seen.protocols.set(response.url, response.protocol));
97
+ await cdp.send("Network.enable");
98
+ }
99
+ catch (error) {
100
+ log.debug({ url: page.url(), error: String(error) }, "document protocol unobservable");
101
+ }
102
+ }
103
+ // `type/subtype` and charset of a Content-Type header; an unparsable one is its raw essence.
104
+ function contentTypeOf(header) {
105
+ const raw = [header ?? ""].flat()[0] ?? "";
106
+ try {
107
+ const mime = new MIMEType(raw);
108
+ const charset = mime.params.get("charset");
109
+ return { type: mime.essence, ...(charset && { charset }) };
110
+ }
111
+ catch {
112
+ return { type: raw.split(";", 1)[0]?.trim().toLowerCase() ?? "" };
113
+ }
114
+ }
115
+ // Http and tls facts of a document response, the prober filling what Chromium does not say; `size` is what the caller could measure of its body.
116
+ async function transportFacts(observation, size, timing, isDirect, prober, hop) {
117
+ const response = observation.document;
118
+ const { headers, remote: seenRemote, tls: seen, security } = await observation.transport;
119
+ const remote = isDirect ? seenRemote : undefined;
120
+ const { type, charset } = contentTypeOf(headers["content-type"]);
121
+ const url = new URL(response.url());
122
+ const { tls, version } = withProbe(url.href, seen, security, await prober?.facts(url, remote?.address), hop);
123
+ return {
124
+ http: {
125
+ status: response.status(),
126
+ ...(version && { version }),
127
+ redirects: await redirectFacts(response.request()),
128
+ headers: redactHeaders(headers),
129
+ ...(remote && { remote }),
130
+ timing,
131
+ cookies: cookieFacts(headers["set-cookie"], headers.date),
132
+ size,
133
+ "content-type": type,
134
+ ...(charset && { charset }),
135
+ },
136
+ ...(tls && { tls }),
137
+ };
138
+ }
139
+ // The byte count a Content-Length header declares, when it is a whole number.
140
+ function declaredSize(headers) {
141
+ const declared = Number(headers["content-length"]);
142
+ return Number.isSafeInteger(declared) ? { declared } : {};
143
+ }
144
+ // A milestone the page reached, in whole milliseconds; zero means it never fired.
145
+ function reached(value) {
146
+ return value && value > 0 ? Math.round(value) : undefined;
147
+ }
148
+ // Navigation Timing milestones, measured from the start of the navigation.
149
+ async function milestones(page) {
150
+ const entry = await page.evaluate(() => performance.getEntriesByType("navigation")[0]?.toJSON());
151
+ const domContentLoaded = reached(entry?.domContentLoadedEventEnd);
152
+ const load = reached(entry?.loadEventEnd);
153
+ return { ...(domContentLoaded !== undefined && { "dom-content-loaded": domContentLoaded }), ...(load !== undefined && { load }) };
154
+ }
155
+ // Waits for the network to go quiet so client-rendered tags land; a page that never settles is read as it stands.
156
+ async function didSettle(page) {
157
+ try {
158
+ await page.waitForLoadState("networkidle", { timeout: SETTLE_MS });
159
+ return true;
160
+ }
161
+ catch {
162
+ return false;
163
+ }
164
+ }
165
+ // A navigation Chromium turned into a download.
166
+ function isDownload(error) {
167
+ return error instanceof Error && /Download is starting|net::ERR_ABORTED/.test(error.message);
168
+ }
169
+ // Chromium aborts a navigation that becomes a download; the response it already had stands in as the page.
170
+ class Crawler extends PlaywrightCrawler {
171
+ #observations;
172
+ constructor(options, config, observations) {
173
+ super(options, config);
174
+ this.#observations = observations;
175
+ }
176
+ async _navigationHandler(context, gotoOptions) {
177
+ try {
178
+ return await super._navigationHandler(context, gotoOptions);
179
+ }
180
+ catch (error) {
181
+ const observation = this.#observations.get(context.request);
182
+ if (!isDownload(error) || !observation?.document)
183
+ throw error;
184
+ log.debug({ url: context.request.url, status: observation.document.status() }, "navigation became a download");
185
+ observation.isDownload = true;
186
+ return observation.document;
187
+ }
188
+ }
189
+ }
190
+ // The response’s own bytes; for a document an XSLT stylesheet turned into another type Chromium answers the output, so those bytes are fetched once more.
191
+ async function rawOf(page, response, type, timeout) {
192
+ const raw = await response.body();
193
+ if (HTML_TYPES.has(type) || !isParsed(type))
194
+ return raw;
195
+ try {
196
+ const rendered = (await page.evaluate("document.contentType"));
197
+ if (rendered === type)
198
+ return raw;
199
+ const again = await page.request.fetch(response.request(), { timeout, failOnStatusCode: false });
200
+ const body = await again.body();
201
+ log.debug({ url: response.url(), type, rendered, transformed: raw.length, bytes: body.length, status: again.status() }, "transformed document read again");
202
+ return body;
203
+ }
204
+ catch (error) {
205
+ log.debug({ url: response.url(), type, error: reason(error) }, "transformed document not read again, its output kept");
206
+ return raw;
207
+ }
208
+ }
209
+ // The body as the facts see it: rendered DOM for HTML, raw text for other parsed types, nothing for a download or a binary.
210
+ async function bodyOf(page, response, observation, type, timeout) {
211
+ if (observation.isDownload)
212
+ return { raw: Buffer.alloc(0), text: "" };
213
+ const raw = await rawOf(page, response, type, timeout);
214
+ return { raw, text: HTML_TYPES.has(type) ? await page.content() : isParsed(type) ? raw.toString("utf8") : "" };
215
+ }
216
+ // The document as served, before any script ran, and its html facts.
217
+ function staticHtml(raw, url, scope) {
218
+ const source = raw.toString("utf8");
219
+ const $ = load(source);
220
+ return { $, html: extractHtml($, source, url, scope) };
221
+ }
222
+ // The first URL of a redirect chain, without its fragment.
223
+ function requestedUrl(request) {
224
+ let first = request;
225
+ for (let previous = first.redirectedFrom(); previous; previous = previous.redirectedFrom())
226
+ first = previous;
227
+ const url = new URL(first.url());
228
+ url.hash = "";
229
+ return url.href;
230
+ }
231
+ // Logs each finished sub-request’s final response once per URL, keeping a body under `max` only where `isKeptType` takes its type.
232
+ async function logResponses(requests, responses, max, isKeptType) {
233
+ const finished = requests.filter((request) => !request.isNavigationRequest() && request.failure() === null && request.redirectedTo() === null && /^https?:/.test(request.url()));
234
+ await Promise.all(finished.map(async (request) => {
235
+ const url = requestedUrl(request);
236
+ if (responses.has(url))
237
+ return;
238
+ try {
239
+ const response = await request.response();
240
+ if (!response)
241
+ return;
242
+ const [headers, body] = await Promise.all([response.allHeaders(), response.body()]);
243
+ const isKept = body.length < max && isKeptType(contentTypeOf(headers["content-type"]).type);
244
+ const { responseEnd } = request.timing();
245
+ const digest = createHash("sha256").update(body).digest("hex");
246
+ responses.set(url, { status: response.status(), headers, bytes: body.length, ...(isKept && { body }), digest, ...(responseEnd >= 0 && { ms: Math.round(responseEnd) }) });
247
+ }
248
+ catch (error) {
249
+ log.debug({ url, error: String(error) }, "browser response unreadable, fetched again later");
250
+ }
251
+ }));
252
+ log.debug({ finished: finished.length, logged: responses.size }, "browser responses logged");
253
+ }
254
+ // sha256 over each finished sub-request’s origin, path and body digest, sorted and distinct, so a cache-busting query or a repeated beacon keys nothing; undefined when one is unknown.
255
+ function resourcesDigest(requests, responses) {
256
+ const pairs = new Set();
257
+ for (const request of requests) {
258
+ const isMainDocument = request.isNavigationRequest() && request.frame().parentFrame() === null;
259
+ if (isMainDocument || request.redirectedTo() !== null || !/^https?:/.test(request.url()))
260
+ continue;
261
+ const url = requestedUrl(request);
262
+ const digest = request.isNavigationRequest() || request.failure() !== null ? undefined : responses.get(url)?.digest;
263
+ if (digest === undefined) {
264
+ log.debug({ url, failure: request.failure()?.errorText, isFrame: request.isNavigationRequest() }, "page input unknown, extractors run uncached");
265
+ return undefined;
266
+ }
267
+ const { origin, pathname } = new URL(url);
268
+ pairs.add(`${origin}${pathname} ${digest}`);
269
+ }
270
+ return createHash("sha256")
271
+ .update([...pairs].toSorted((a, b) => a.localeCompare(b)).join("\n"))
272
+ .digest("hex");
273
+ }
274
+ // Minutes a browser download may take before the run gives up on it.
275
+ const INSTALL_TIMEOUT_MS = 600_000;
276
+ // Install output lines kept for the error when the download fails.
277
+ const INSTALL_TAIL_LINES = 20;
278
+ // Whether `error` is Playwright reporting the browser binary is not downloaded.
279
+ export function isMissingExecutable(error) {
280
+ return reason(error).includes("Executable doesn't exist");
281
+ }
282
+ // The `playwright install` arguments downloading `name`; headless runs never launch the full Chromium.
283
+ export function installArguments(name) {
284
+ return ["install", name, ...(name === "chromium" ? ["--only-shell"] : [])];
285
+ }
286
+ // The install command a ConfigError names when spiderlint may not download the browser itself.
287
+ export function installCommand(name) {
288
+ return `npx playwright ${installArguments(name).join(" ")}`;
289
+ }
290
+ // Downloads `name` with the pinned Playwright CLI beside this file, which no `npx` lookup can miss.
291
+ export async function installBrowser(name) {
292
+ const command = installCommand(name);
293
+ let cli;
294
+ try {
295
+ cli = path.join(path.dirname(createRequire(import.meta.url).resolve("playwright/package.json")), "cli.js");
296
+ if (!existsSync(cli))
297
+ throw new Error("no cli.js");
298
+ }
299
+ catch {
300
+ throw new ConfigError(`browser ${name} is not installed; install it with: ${command}`);
301
+ }
302
+ const started = Date.now();
303
+ log.info({ browser: name, command }, "browser is not installed, downloading it");
304
+ const child = spawn(process.execPath, [cli, ...installArguments(name)], { stdio: ["ignore", "pipe", "pipe"] });
305
+ let output = "";
306
+ child.stdout.on("data", (chunk) => {
307
+ output += chunk;
308
+ });
309
+ child.stderr.on("data", (chunk) => {
310
+ output += chunk;
311
+ });
312
+ let isTimedOut = false;
313
+ const code = await new Promise((resolve) => {
314
+ const timer = setTimeout(() => {
315
+ isTimedOut = true;
316
+ child.kill("SIGKILL");
317
+ resolve(-1);
318
+ }, INSTALL_TIMEOUT_MS);
319
+ child.once("error", () => {
320
+ clearTimeout(timer);
321
+ resolve(-1);
322
+ });
323
+ child.once("close", (status) => {
324
+ clearTimeout(timer);
325
+ resolve(status ?? -1);
326
+ });
327
+ });
328
+ const seconds = Math.round((Date.now() - started) / 1000);
329
+ if (code !== 0)
330
+ throw new ConfigError(`browser ${name} could not be downloaded (${isTimedOut ? `timed out after ${seconds} s` : `exited ${code}`}): ${output.trim().split("\n").slice(-INSTALL_TAIL_LINES).join("\n") || command}`);
331
+ log.info({ browser: name, seconds }, "browser downloaded");
332
+ }
333
+ // Launches `name` once to prove it is installed, downloading it first when it is missing and allowed to.
334
+ export async function ensureBrowser(name, launcher, check, install) {
335
+ try {
336
+ const launched = await launcher.launch();
337
+ await launched.close();
338
+ return;
339
+ }
340
+ catch (error) {
341
+ if (!isMissingExecutable(error))
342
+ throw error;
343
+ }
344
+ if (!check.browserInstall)
345
+ throw new ConfigError(`browser ${name} is not installed; install it with: ${installCommand(name)}`);
346
+ if (check.cacheMode === "offline")
347
+ throw new ConfigError(`browser ${name} is not installed, and --offline cannot download it; install it with: ${installCommand(name)}`);
348
+ await install();
349
+ try {
350
+ const launched = await launcher.launch();
351
+ await launched.close();
352
+ }
353
+ catch (error) {
354
+ throw new ConfigError(`browser ${name} still does not launch after downloading it: ${reason(error)}`);
355
+ }
356
+ }
357
+ // Makes sure `name` launches before any scan, downloading it into the cache when missing.
358
+ export async function prepareBrowser(name) {
359
+ await ensureBrowser(name, launcherOf(name), { browserInstall: true, cacheMode: "use" }, () => installBrowser(name));
360
+ }
361
+ // The UA the browser sends unmodified, read off a throwaway launch so rendered pages carry it with the token appended.
362
+ async function realUserAgent(launcher) {
363
+ const launched = await launcher.launch();
364
+ try {
365
+ const context = await launched.newContext();
366
+ const page = await context.newPage();
367
+ const ua = (await page.evaluate("navigator.userAgent"));
368
+ await context.close();
369
+ log.debug({ ua }, "browser default user agent read");
370
+ return ua;
371
+ }
372
+ finally {
373
+ await launched.close();
374
+ }
375
+ }
376
+ // The Playwright launcher for `name`; anything else is a config error.
377
+ function launcherOf(name) {
378
+ const launcher = Object.hasOwn(LAUNCHERS, name) ? LAUNCHERS[name] : undefined;
379
+ if (!launcher)
380
+ throw new ConfigError(`browser ${name}: expected chromium, firefox or webkit`);
381
+ log.debug({ browser: name }, "browser chosen");
382
+ return launcher;
383
+ }
384
+ // The browser crawler of a frontier: facts come from the rendered DOM and the browser’s own network log; an adaptive group’s rendered page is compared with its static HTML.
385
+ export async function browserCrawler(config, onPage, frontier, router, storage, proxy, isKeptType = () => false, isDebugged = false, isExpensive = false) {
386
+ const launcher = launcherOf(config.browser);
387
+ await ensureBrowser(config.browser, launcher, config, () => installBrowser(config.browser));
388
+ const agent = browserContext(await realUserAgent(launcher));
389
+ const isPortOpen = isDebugged && config.browser === "chromium";
390
+ if (isDebugged && !isPortOpen)
391
+ log.warn({ browser: config.browser }, "only Chromium opens a DevTools port; its extractors add nothing");
392
+ let pages = 0;
393
+ const observations = new WeakMap();
394
+ const responses = new Map();
395
+ const prober = proxy ? undefined : new TlsProber(config.allowPrivate);
396
+ if (proxy)
397
+ log.warn({ fetch: "browser" }, "TLS probes would bypass the proxy; browser pages carry no cipher, ALPN, SAN or HTTP version");
398
+ const isDirect = !proxy && config.browser === "chromium";
399
+ log.debug({ browser: config.browser, isDirect }, "browser remote address recorded only when direct");
400
+ let launches = 0;
401
+ // Pages rendered at once, all in one browser: `concurrency`, else one beside an expensive extractor, else half of NUMPROCS.
402
+ const openPages = config.concurrency || (isExpensive ? 1 : Math.ceil(width() / 2));
403
+ log.debug({ openPages, concurrency: config.concurrency, isExpensive }, "browser concurrency");
404
+ const crawler = new Crawler({
405
+ ...frontier.options("browser", storage, proxy),
406
+ headless: true,
407
+ // Snapshot with `page.content()`; crawlee’s shadow-root expansion writes `innerHTML` into the live page.
408
+ ignoreShadowRoots: true,
409
+ maxConcurrency: openPages,
410
+ launchContext: { launcher, userAgent: agent.userAgent, launchOptions: { args: config.browser === "chromium" ? [...chromiumArguments(), ...(proxy ? [] : [DIRECT])] : [] } },
411
+ browserPoolOptions: {
412
+ useFingerprints: false,
413
+ maxOpenPagesPerBrowser: openPages,
414
+ retireBrowserAfterPageCount: RETIRE_AFTER_PAGES,
415
+ preLaunchHooks: isPortOpen
416
+ ? [
417
+ async (pageId, launchContext) => {
418
+ const port = await freePort();
419
+ launchContext.launchOptions = { ...launchContext.launchOptions, args: [...(launchContext.launchOptions?.args ?? []), `${PORT_ARGUMENT}${port}`] };
420
+ log.debug({ pageId, port }, "browser DevTools port opened on loopback");
421
+ },
422
+ ]
423
+ : [],
424
+ postLaunchHooks: [
425
+ (pageId) => {
426
+ launches += 1;
427
+ log.debug({ pageId, launches }, "browser launched");
428
+ },
429
+ ],
430
+ },
431
+ preNavigationHooks: [
432
+ async ({ page, request }) => {
433
+ const observation = observe(page);
434
+ observations.set(request, observation);
435
+ await watchProtocols(page, observation);
436
+ await page.addInitScript({ content: COOKIE_WRITES });
437
+ if (agent.extraHTTPHeaders)
438
+ await page.context().setExtraHTTPHeaders(agent.extraHTTPHeaders);
439
+ },
440
+ ],
441
+ async requestHandler({ request, page, parseWithCheerio, enqueueLinks, browserController }) {
442
+ const observation = observations.get(request);
443
+ const response = observation?.document;
444
+ if (!observation || !response)
445
+ return log.warn({ url: request.url }, "page rendered without a document response");
446
+ const url = new URL(response.url());
447
+ if (!frontier.admit(request, url))
448
+ return;
449
+ const { headers } = await observation.transport;
450
+ const { type } = contentTypeOf(headers["content-type"]);
451
+ const isHtml = HTML_TYPES.has(type) && !observation.isDownload;
452
+ const settled = isHtml && (await didSettle(page));
453
+ const { raw, text } = await bodyOf(page, response, observation, type, config.timeout * 1000);
454
+ const body = text.slice(0, config.maxBodySize);
455
+ const wire = observation.isDownload ? 0 : await wireSize(response.request(), raw.length);
456
+ const size = { body: wire, decoded: raw.length, ...declaredSize(headers), ...((body.length < text.length || observation.isDownload) && { truncated: true }) };
457
+ const timing = observation.isDownload ? {} : timingFacts(response.request().timing());
458
+ const facts = { ...frontier.identity(request, url, "browser"), ...(await transportFacts(observation, size, timing, isDirect, prober, observation.protocols.get(response.url()))) };
459
+ const { startTime, requestStart, responseStart } = response.request().timing();
460
+ const skew = observation.isDownload || responseStart < 0 ? undefined : dateSkew(url.href, headers, startTime + requestStart, startTime + responseStart);
461
+ if (skew !== undefined)
462
+ facts.http["date-skew"] = skew;
463
+ const served = isHtml ? staticHtml(raw, url, config.scope) : undefined;
464
+ if (isHtml && served) {
465
+ const $ = await parseWithCheerio();
466
+ const rendered = extractHtml($, text, url, config.scope);
467
+ facts.html = declaredHtml(served.html, rendered);
468
+ log.debug({ url: url.href, rewritten: facts.html.rewritten?.length ?? 0 }, "served declarations kept over the rendered DOM");
469
+ facts.parity = parityFacts(url.href, served.$, served.html, $, rendered);
470
+ facts.resources = observedResources(extractResources($, url, config.maxResourcesPerPage), observation.requests, url, config.maxResourcesPerPage);
471
+ facts.browser = { timing: await milestones(page), console: observation.console, weight: await weightFacts(observation.requests), cookies: await scriptCookies(page) };
472
+ await logResponses(observation.requests, responses, config.maxBodySize, isKeptType);
473
+ const digest = resourcesDigest(observation.requests, responses);
474
+ if (digest)
475
+ loaded.set(page, digest);
476
+ }
477
+ log.debug({ url: url.href, status: facts.http.status, type, bytes: size.body, depth: facts.crawl.depth, settled, isDownload: observation.isDownload === true, requests: observation.requests.length }, "page rendered");
478
+ if (router.isDetecting(url.href))
479
+ router.detected(url.href, served?.html, facts.html);
480
+ pages += 1;
481
+ const port = browserController.launchContext.launchOptions?.args?.findLast((argument) => argument.startsWith(PORT_ARGUMENT))?.slice(PORT_ARGUMENT.length);
482
+ if (port)
483
+ ports.set(page, Number(port));
484
+ await onPage(facts, body, isHtml ? page : undefined);
485
+ if (!isHtml && !facts.feed)
486
+ return;
487
+ log.debug({ url: url.href, enqueued: await frontier.enqueue(enqueueLinks, facts, "browser") }, "links enqueued");
488
+ },
489
+ async failedRequestHandler({ request }, error) {
490
+ const facts = frontier.failed(request, reason(error));
491
+ if (facts)
492
+ await onPage(facts, "");
493
+ },
494
+ }, storage?.config ?? new Configuration({ persistStorage: false, purgeOnStart: false }), observations);
495
+ if (storage?.earlier)
496
+ log.debug({ fetch: "browser" }, "browser pages are re-rendered, never revalidated");
497
+ return { crawler, stats: () => ({ pages, launches, responses, tlsProbes: prober?.probes ?? 0 }) };
498
+ }
@@ -0,0 +1,58 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { ConfigError } from "../config/index.js";
5
+ import { interrupted } from "../interrupt.js";
6
+ import { log } from "../logger.js";
7
+ import { browserCrawler } from "./browser.js";
8
+ import { Frontier } from "./frontier.js";
9
+ import { httpCrawler } from "./http.js";
10
+ import { bridgeCrawleeLog } from "./log.js";
11
+ import { singleOrigin } from "./scope.js";
12
+ import { trackProgress } from "../progress.js";
13
+ // Crawls with every crawler some group needs, side by side over one frontier that routes each URL to its group’s.
14
+ export async function crawlSite(config, onPage, cache, router, storage, proxy, isKeptType, isDebugged, isExpensive) {
15
+ bridgeCrawleeLog();
16
+ const modes = router.crawlers;
17
+ if (modes.includes("browser") && !config.allowPrivate)
18
+ throw new ConfigError("fetch browser: the address guard cannot check what the browser connects to");
19
+ const frontier = await Frontier.open(config, cache, router);
20
+ const http = modes.includes("http") ? httpCrawler(config, onPage, frontier, storage, proxy) : undefined;
21
+ const browser = modes.includes("browser") ? await browserCrawler(config, onPage, frontier, router, storage, proxy, isKeptType, isDebugged, isExpensive) : undefined;
22
+ log.debug({ crawlers: modes, groups: router.modes }, "crawlers chosen");
23
+ const crawlers = { ...(http && { http: http.crawler }), ...(browser && { browser: browser.crawler }) };
24
+ const stop = trackProgress(() => frontier.known(), singleOrigin(config.seeds));
25
+ const halt = (reason, why) => {
26
+ frontier.cut(reason);
27
+ for (const crawler of [http?.crawler, browser?.crawler])
28
+ crawler?.stop(why);
29
+ };
30
+ const interrupt = () => halt("interrupted", `interrupted by ${String(interrupted.reason)}`);
31
+ interrupted.addEventListener("abort", interrupt, { once: true });
32
+ const deadline = config.crawlDeadline > 0
33
+ ? setTimeout(() => {
34
+ log.warn({ crawlDeadline: config.crawlDeadline }, "crawl deadline reached, stopping after the pages in flight");
35
+ halt("timeout", `crawl deadline of ${config.crawlDeadline} s reached`);
36
+ }, config.crawlDeadline * 1000)
37
+ : undefined;
38
+ try {
39
+ await frontier.run(crawlers, cache.robots, storage?.resumed);
40
+ }
41
+ finally {
42
+ clearTimeout(deadline);
43
+ interrupted.removeEventListener("abort", interrupt);
44
+ stop();
45
+ }
46
+ const [fetched, rendered] = [http?.stats(), browser?.stats()];
47
+ if (fetched?.revalidated)
48
+ log.debug({ revalidated: fetched.revalidated }, "pages revalidated");
49
+ return {
50
+ site: frontier.site(),
51
+ pages: { ...(fetched && { http: fetched.pages }), ...(rendered && { browser: rendered.pages }) },
52
+ revalidated: fetched?.revalidated ?? 0,
53
+ launches: rendered?.launches ?? 0,
54
+ responses: rendered?.responses ?? new Map(),
55
+ tlsProbes: rendered?.tlsProbes ?? 0,
56
+ modes: router.modes,
57
+ };
58
+ }