spiderlint 0.0.0 → 0.143.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +235 -0
- package/README.md +244 -0
- package/bin/spiderlint.js +11 -0
- package/dist/agent.js +46 -0
- package/dist/cache/extractors.js +37 -0
- package/dist/cache/http.js +99 -0
- package/dist/cache/index.js +101 -0
- package/dist/cache/purge.js +63 -0
- package/dist/cache/status.js +52 -0
- package/dist/cli.js +602 -0
- package/dist/color.js +10 -0
- package/dist/config/environment.js +88 -0
- package/dist/config/index.js +104 -0
- package/dist/config/policy.js +170 -0
- package/dist/config/schema.js +127 -0
- package/dist/crawl/body.js +92 -0
- package/dist/crawl/browser.js +498 -0
- package/dist/crawl/crawl.js +58 -0
- package/dist/crawl/diversity.js +153 -0
- package/dist/crawl/dns.js +194 -0
- package/dist/crawl/fetch.js +48 -0
- package/dist/crawl/frontier.js +370 -0
- package/dist/crawl/guard.js +69 -0
- package/dist/crawl/http.js +171 -0
- package/dist/crawl/links.js +89 -0
- package/dist/crawl/log.js +27 -0
- package/dist/crawl/network.js +88 -0
- package/dist/crawl/probe.js +107 -0
- package/dist/crawl/profile.js +33 -0
- package/dist/crawl/rdap.js +86 -0
- package/dist/crawl/resolve.js +132 -0
- package/dist/crawl/resources.js +233 -0
- package/dist/crawl/robots.js +100 -0
- package/dist/crawl/route.js +92 -0
- package/dist/crawl/scope.js +32 -0
- package/dist/crawl/scratch.js +15 -0
- package/dist/crawl/sitemap.js +187 -0
- package/dist/crawl/svcb.js +59 -0
- package/dist/crawl/tls-hello.js +297 -0
- package/dist/crawl/tls-probe.js +71 -0
- package/dist/facts/browser.js +138 -0
- package/dist/facts/byline.js +17 -0
- package/dist/facts/co2.js +27 -0
- package/dist/facts/csp.js +71 -0
- package/dist/facts/export.js +79 -0
- package/dist/facts/flatten.js +33 -0
- package/dist/facts/graph.js +73 -0
- package/dist/facts/headers.js +330 -0
- package/dist/facts/html.js +230 -0
- package/dist/facts/labels.js +89 -0
- package/dist/facts/language.js +32 -0
- package/dist/facts/parity.js +38 -0
- package/dist/facts/read-note.js +11 -0
- package/dist/facts/resources.js +46 -0
- package/dist/facts/robots.js +13 -0
- package/dist/facts/sites.js +140 -0
- package/dist/facts/transport.js +200 -0
- package/dist/facts/types.js +4 -0
- package/dist/facts/vendors.js +76 -0
- package/dist/fold/index.js +94 -0
- package/dist/groups/assign.js +45 -0
- package/dist/groups/sample.js +60 -0
- package/dist/i18n.js +92 -0
- package/dist/index.js +607 -0
- package/dist/interrupt.js +37 -0
- package/dist/logger.js +95 -0
- package/dist/plugins/axe.js +104 -0
- package/dist/plugins/carbon-txt.js +96 -0
- package/dist/plugins/cookies-registry.js +25 -0
- package/dist/plugins/cookies.js +168 -0
- package/dist/plugins/css.js +328 -0
- package/dist/plugins/dns.js +864 -0
- package/dist/plugins/feed-content.js +132 -0
- package/dist/plugins/feed-model.js +282 -0
- package/dist/plugins/feeds.js +836 -0
- package/dist/plugins/html-validate.js +191 -0
- package/dist/plugins/htmlhint.js +95 -0
- package/dist/plugins/icons.js +302 -0
- package/dist/plugins/images.js +269 -0
- package/dist/plugins/index.js +286 -0
- package/dist/plugins/keyboard.js +194 -0
- package/dist/plugins/lighthouse.js +110 -0
- package/dist/plugins/link-text.js +73 -0
- package/dist/plugins/list.js +22 -0
- package/dist/plugins/live.js +254 -0
- package/dist/plugins/mail.js +985 -0
- package/dist/plugins/manifest.js +97 -0
- package/dist/plugins/markup.js +98 -0
- package/dist/plugins/messages.js +25 -0
- package/dist/plugins/network.js +192 -0
- package/dist/plugins/origin.js +488 -0
- package/dist/plugins/profile-links.js +102 -0
- package/dist/plugins/schema-registry.js +101 -0
- package/dist/plugins/sshfp.js +159 -0
- package/dist/plugins/stack.js +161 -0
- package/dist/plugins/structured-data.js +358 -0
- package/dist/plugins/tls-probe.js +298 -0
- package/dist/plugins/tls-registry.js +418 -0
- package/dist/plugins/tls-scan.js +280 -0
- package/dist/plugins/trackers-registry.js +27 -0
- package/dist/plugins/trackers.js +56 -0
- package/dist/plugins/types.js +7 -0
- package/dist/plugins/visit.js +50 -0
- package/dist/plugins/well-known-registry.js +111 -0
- package/dist/plugins/well-known.js +732 -0
- package/dist/progress.js +162 -0
- package/dist/report/agent.js +122 -0
- package/dist/report/checkstyle.js +22 -0
- package/dist/report/csv.js +18 -0
- package/dist/report/html.js +266 -0
- package/dist/report/human.js +220 -0
- package/dist/report/index.js +16 -0
- package/dist/report/json.js +8 -0
- package/dist/report/printable.js +29 -0
- package/dist/report/rating.js +26 -0
- package/dist/report/sarif.js +100 -0
- package/dist/report/stats.js +46 -0
- package/dist/rules/builtin.js +465 -0
- package/dist/rules/caching.js +103 -0
- package/dist/rules/catalog.js +139 -0
- package/dist/rules/clock.js +48 -0
- package/dist/rules/declarative.js +234 -0
- package/dist/rules/deprecated.js +45 -0
- package/dist/rules/disclosure.js +99 -0
- package/dist/rules/fix.js +21 -0
- package/dist/rules/i18n.js +131 -0
- package/dist/rules/insights.js +100 -0
- package/dist/rules/length.js +52 -0
- package/dist/rules/message.js +48 -0
- package/dist/rules/relations.js +88 -0
- package/dist/rules/robots.js +75 -0
- package/dist/rules/rulesets.js +134 -0
- package/dist/rules/run.js +75 -0
- package/dist/rules/score.js +61 -0
- package/dist/rules/types.js +7 -0
- package/dist/rules/url.js +90 -0
- package/dist/server/analytics.js +49 -0
- package/dist/server/api.js +134 -0
- package/dist/server/clients.js +54 -0
- package/dist/server/clock.js +33 -0
- package/dist/server/compress.js +58 -0
- package/dist/server/health.js +14 -0
- package/dist/server/jobs.js +82 -0
- package/dist/server/main.js +83 -0
- package/dist/server/page.js +161 -0
- package/dist/server/policy.js +145 -0
- package/dist/server/providers.js +60 -0
- package/dist/server/queue.js +50 -0
- package/dist/server/scan.js +39 -0
- package/dist/server/settings.js +197 -0
- package/dist/server/web.js +353 -0
- package/dist/server/worker.js +129 -0
- package/dist/store/disk.js +215 -0
- package/dist/store/memory.js +18 -0
- package/dist/telemetry-environment.js +9 -0
- package/dist/telemetry.js +104 -0
- package/locales/es/LC_MESSAGES/messages.po +1864 -0
- package/locales/uk/LC_MESSAGES/messages.po +1864 -0
- package/package.json +134 -2
- package/presets/browser.yaml +26 -0
- package/presets/graph.yaml +35 -0
- package/presets/i18n.yaml +18 -0
- package/presets/insights.yaml +12 -0
- package/presets/links.yaml +22 -0
- package/presets/parity.yaml +59 -0
- package/presets/performance.yaml +271 -0
- package/presets/recommended.yaml +50 -0
- package/presets/redirects.yaml +23 -0
- package/presets/resources.yaml +29 -0
- package/presets/robots.yaml +13 -0
- package/presets/security-headers.yaml +257 -0
- package/presets/seo.yaml +380 -0
- package/presets/sitemap.yaml +34 -0
- package/presets/suggestions.yaml +97 -0
- package/presets/sustainability.yaml +16 -0
- package/presets/tls.yaml +72 -0
- package/presets/vendor.yaml +14 -0
- package/presets/web-comprehensive.yaml +17 -0
- package/presets/web-quick.yaml +15 -0
- package/src/server/apple-touch-icon.png +0 -0
- package/src/server/logo-36.png +0 -0
- package/src/server/logo-72.png +0 -0
- package/src/server/logo.png +0 -0
- package/vendors/dns.yaml +21 -0
- package/vendors/paths.yaml +55 -0
- package/vendors/stack.yaml +57 -0
|
@@ -0,0 +1,233 @@
|
|
|
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 { availableParallelism } from "node:os";
|
|
6
|
+
import { ExtractorCache } from "../cache/extractors.js";
|
|
7
|
+
import { Bucket, OfflineMiss } from "../cache/index.js";
|
|
8
|
+
import { fetchCached, fetchHeadCached } from "../cache/http.js";
|
|
9
|
+
import { attemptsFor, MISMATCH, reason } from "./fetch.js";
|
|
10
|
+
import { cookieFacts, redactHeaders } from "../facts/transport.js";
|
|
11
|
+
import { log } from "../logger.js";
|
|
12
|
+
// The Accept header Chromium sends for an image, so an origin negotiating AVIF or WebP answers as it would a browser.
|
|
13
|
+
const IMAGE_ACCEPT = { accept: "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8" };
|
|
14
|
+
// The codings Chromium accepts, so an origin compressing in br or zstd alone answers as it would a browser.
|
|
15
|
+
const ACCEPT_ENCODING = { "accept-encoding": "gzip, deflate, br, zstd" };
|
|
16
|
+
// Pool width: `concurrency` when set, else NUMPROCS when the environment sets it, else the host's parallelism.
|
|
17
|
+
export function width(concurrency = 0) {
|
|
18
|
+
if (concurrency > 0)
|
|
19
|
+
return concurrency;
|
|
20
|
+
const numprocs = Number(process.env.NUMPROCS);
|
|
21
|
+
return Number.isSafeInteger(numprocs) && numprocs > 0 ? numprocs : availableParallelism();
|
|
22
|
+
}
|
|
23
|
+
// The next body chunk; a read failing short of `Content-Length` names both byte counts.
|
|
24
|
+
async function readChunk(reader, response, bytes) {
|
|
25
|
+
try {
|
|
26
|
+
return await reader.read();
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
const declared = Number(response.headers.get("content-length"));
|
|
30
|
+
log.debug({ url: response.url, bytes, declared, error: String(error) }, "resource body cut off");
|
|
31
|
+
throw bytes < declared ? new Error(`${MISMATCH}: body ended at ${bytes} of the ${declared} bytes Content-Length declares`) : error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// Reads at most `max` body bytes, then cancels the rest; `isKept` also returns what it read.
|
|
35
|
+
async function drain(response, max, isKept = false) {
|
|
36
|
+
let bytes = 0;
|
|
37
|
+
const chunks = [];
|
|
38
|
+
const reader = response.body?.getReader();
|
|
39
|
+
while (reader) {
|
|
40
|
+
const { done, value } = await readChunk(reader, response, bytes);
|
|
41
|
+
if (done)
|
|
42
|
+
break;
|
|
43
|
+
bytes += value.byteLength;
|
|
44
|
+
if (isKept)
|
|
45
|
+
chunks.push(value);
|
|
46
|
+
if (bytes < max)
|
|
47
|
+
continue;
|
|
48
|
+
await reader.cancel();
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
return { bytes, ...(isKept && { body: Buffer.concat(chunks) }) };
|
|
52
|
+
}
|
|
53
|
+
// The media type of a content-type header, lower-cased.
|
|
54
|
+
function mediaType(raw) {
|
|
55
|
+
return (String(raw ?? "")
|
|
56
|
+
.split(";", 1)[0]
|
|
57
|
+
?.trim()
|
|
58
|
+
.toLowerCase() ?? "");
|
|
59
|
+
}
|
|
60
|
+
// Extractors that read a 2xx body of `contentType`.
|
|
61
|
+
function readers(extractors, status, contentType) {
|
|
62
|
+
return status >= 200 && status < 300 ? extractors.filter((extractor) => extractor.types.some((type) => contentType.startsWith(type))) : [];
|
|
63
|
+
}
|
|
64
|
+
// Counts the body, and hands it whole to each extractor that reads its type.
|
|
65
|
+
async function consume(url, response, max, extractors, cache) {
|
|
66
|
+
const contentType = mediaType(response.headers.get("content-type"));
|
|
67
|
+
const { bytes, body } = await drain(response, max, readers(extractors, response.status, contentType).length > 0);
|
|
68
|
+
return extractBody(url, response.status, contentType, bytes, body, max, extractors, cache);
|
|
69
|
+
}
|
|
70
|
+
// Hands a whole body to each extractor that reads its type, through `cache`; a body cut at `max` is read by none.
|
|
71
|
+
async function extractBody(url, status, contentType, bytes, body, max, extractors, cache) {
|
|
72
|
+
const wanted = readers(extractors, status, contentType);
|
|
73
|
+
const read = wanted.map((extractor) => extractor.id);
|
|
74
|
+
if (!body || bytes >= max) {
|
|
75
|
+
if (wanted.length > 0)
|
|
76
|
+
log.debug({ url, bytes, max, extractors: read }, "resource body too large to extract");
|
|
77
|
+
return { bytes, ...(wanted.length > 0 && { read }) };
|
|
78
|
+
}
|
|
79
|
+
const started = performance.now();
|
|
80
|
+
const facts = {};
|
|
81
|
+
for (const extractor of wanted) {
|
|
82
|
+
try {
|
|
83
|
+
const value = await cache.run(extractor, url, contentType, body, () => extractor.extract(url, contentType, body));
|
|
84
|
+
if (value !== undefined)
|
|
85
|
+
facts[extractor.id] = value;
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
log.warn({ url, extractor: extractor.id, error: error instanceof Error ? error.message : String(error) }, `${extractor.id} checks skipped on`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const ms = Math.round(performance.now() - started);
|
|
92
|
+
log.debug({ url, contentType, bytes, extractors: Object.keys(facts), ms }, "resource extracted");
|
|
93
|
+
return { bytes, read, facts, ms };
|
|
94
|
+
}
|
|
95
|
+
// The failure entry key, beside the URL’s own answer.
|
|
96
|
+
const failureKey = (url) => `failed\t${url}`;
|
|
97
|
+
// A 5xx or no answer at all.
|
|
98
|
+
export function isFailure(status) {
|
|
99
|
+
return status === 0 || status >= 500;
|
|
100
|
+
}
|
|
101
|
+
// Seconds a `Retry-After` in seconds or as an HTTP date asks for, else `fallback`.
|
|
102
|
+
function retryAfter(raw, fallback) {
|
|
103
|
+
const value = [raw].flat()[0]?.trim();
|
|
104
|
+
if (!value)
|
|
105
|
+
return fallback;
|
|
106
|
+
const seconds = /^\d+$/.test(value) ? Number(value) : (Date.parse(value) - Date.now()) / 1000;
|
|
107
|
+
return Number.isFinite(seconds) ? Math.max(0, Math.round(seconds)) : fallback;
|
|
108
|
+
}
|
|
109
|
+
// The stored failure for `url` while it is younger than its `until`, or any under `--offline`.
|
|
110
|
+
async function storedFailure(url, failures) {
|
|
111
|
+
const entry = await failures.get(failureKey(url));
|
|
112
|
+
const isFresh = entry !== undefined && (failures.mode === "offline" || Date.now() < Date.parse(entry.value.until));
|
|
113
|
+
log.debug({ url, status: entry?.value.status, until: entry?.value.until, isFresh }, "resource failure looked up");
|
|
114
|
+
if (!entry || !isFresh)
|
|
115
|
+
return undefined;
|
|
116
|
+
const { status, error } = entry.value;
|
|
117
|
+
return { status, headers: {}, size: { body: 0 }, timing: {}, ...(error && { error }), cached: true };
|
|
118
|
+
}
|
|
119
|
+
// Keeps a failed `result` for its `Retry-After`, else `ttl` seconds.
|
|
120
|
+
async function storeFailure(url, result, failures, ttl) {
|
|
121
|
+
const seconds = retryAfter(result.headers["retry-after"], ttl);
|
|
122
|
+
const failure = { status: result.status, ...(result.error && { error: result.error }), attempts: attemptsFor(result.status), until: new Date(Date.now() + seconds * 1000).toISOString() };
|
|
123
|
+
log.debug({ url, ...failure, seconds }, "resource failure stored");
|
|
124
|
+
await failures.set(failureKey(url), failure);
|
|
125
|
+
}
|
|
126
|
+
// A stored answer every extractor now reading its type has already read.
|
|
127
|
+
function isServed(stored, extractors) {
|
|
128
|
+
const read = new Set(stored.value.read);
|
|
129
|
+
return readers(extractors, stored.status, mediaType(stored.headers["content-type"])).every((extractor) => read.has(extractor.id));
|
|
130
|
+
}
|
|
131
|
+
// A GET through the stored failure first; a fresh 5xx or unreachable answer is served, a new one stored.
|
|
132
|
+
async function fetchRemembered(url, config, bucket, failures, extractors, cache, sent) {
|
|
133
|
+
const stored = await storedFailure(url, failures);
|
|
134
|
+
if (stored)
|
|
135
|
+
return stored;
|
|
136
|
+
const result = await fetchOne(url, config.maxBodySize, bucket, extractors, cache, sent);
|
|
137
|
+
if (isFailure(result.status))
|
|
138
|
+
await storeFailure(url, result, failures, config.cacheFailureTtl);
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
// One HEAD through the stored failure first; a fresh 5xx or unreachable answer is served, a new one stored.
|
|
142
|
+
async function headRemembered(url, bucket, failures, ttl, sent) {
|
|
143
|
+
const stored = await storedFailure(url, failures);
|
|
144
|
+
if (stored)
|
|
145
|
+
return stored;
|
|
146
|
+
try {
|
|
147
|
+
const { status, headers, ms, cached, revalidated } = await fetchHeadCached(bucket, url, sent);
|
|
148
|
+
log.debug({ url, status, cached, revalidated }, "enclosure checked");
|
|
149
|
+
const contentType = mediaType(headers["content-type"]);
|
|
150
|
+
const cookies = cookieFacts(headers["set-cookie"], headers.date);
|
|
151
|
+
const result = { status, headers: redactHeaders(headers), ...(contentType && { "content-type": contentType }), size: { body: 0 }, timing: { total: ms }, ...(cookies.length > 0 && { cookies }), ...(cached && { cached }), ...(revalidated && { revalidated }) };
|
|
152
|
+
if (isFailure(result.status))
|
|
153
|
+
await storeFailure(url, result, failures, ttl);
|
|
154
|
+
return result;
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
if (error instanceof OfflineMiss)
|
|
158
|
+
throw error;
|
|
159
|
+
return { status: 0, headers: {}, size: { body: 0 }, timing: {}, error: reason(error) };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// One cached or retried GET with `sent` headers; a final failure is status 0 with its error.
|
|
163
|
+
async function fetchOne(url, max, bucket, extractors, cache, sent) {
|
|
164
|
+
try {
|
|
165
|
+
const { status, headers, value, ms, cached, revalidated } = await fetchCached(bucket, url, (response) => consume(url, response, max, extractors, cache), false, (stored) => isServed(stored, extractors), sent);
|
|
166
|
+
const { bytes, facts } = value;
|
|
167
|
+
log.debug({ url, status, bytes, cached, revalidated }, "resource fetched");
|
|
168
|
+
const contentType = mediaType(headers["content-type"]);
|
|
169
|
+
const hasFacts = facts !== undefined && Object.keys(facts).length > 0;
|
|
170
|
+
const cookies = cookieFacts(headers["set-cookie"], headers.date);
|
|
171
|
+
return { status, headers: redactHeaders(headers), ...(contentType && { "content-type": contentType }), size: { body: bytes }, timing: { total: ms - (value.ms ?? 0) }, ...(cookies.length > 0 && { cookies }), ...(cached && { cached }), ...(revalidated && { revalidated }), ...(hasFacts && { facts }) };
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
if (error instanceof OfflineMiss)
|
|
175
|
+
throw error;
|
|
176
|
+
return { status: 0, headers: {}, size: { body: 0 }, timing: {}, error: reason(error) };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// A logged answer that stands for a fetch: not a failure, nor a status a fetch would retry.
|
|
180
|
+
function isUsable(logged) {
|
|
181
|
+
return logged !== undefined && logged.status >= 200 && logged.status < 500 && logged.status !== 429;
|
|
182
|
+
}
|
|
183
|
+
// A result from the browser’s network log, read by the same extractors a fetch would feed.
|
|
184
|
+
async function fromLog(url, logged, max, extractors, cache) {
|
|
185
|
+
const contentType = mediaType(logged.headers["content-type"]);
|
|
186
|
+
const { facts } = await extractBody(url, logged.status, contentType, logged.bytes, logged.body, max, extractors, cache);
|
|
187
|
+
const hasFacts = facts !== undefined && Object.keys(facts).length > 0;
|
|
188
|
+
const cookies = cookieFacts(logged.headers["set-cookie"], logged.headers.date);
|
|
189
|
+
log.debug({ url, status: logged.status, bytes: logged.bytes, hasBody: logged.body !== undefined, cookies: cookies.length }, "resource answered from the browser log");
|
|
190
|
+
return { status: logged.status, headers: redactHeaders(logged.headers), ...(contentType && { "content-type": contentType }), size: { body: logged.bytes }, timing: { ...(logged.ms !== undefined && { total: logged.ms }) }, ...(cookies.length > 0 && { cookies }), logged: true, ...(hasFacts && { facts }) };
|
|
191
|
+
}
|
|
192
|
+
// Hangs each fetched result off every page entry that names its URL.
|
|
193
|
+
export function attachResources(pages, results) {
|
|
194
|
+
const entries = pages.flatMap((page) => page.resources ?? []);
|
|
195
|
+
for (const entry of entries) {
|
|
196
|
+
const { facts, ...http } = results[entry.url] ?? {};
|
|
197
|
+
entry.http = results[entry.url] && http;
|
|
198
|
+
Object.assign(entry, facts);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// GETs every distinct resource URL the pages name that `logged` lacks, once each, with `extractors` reading the bodies of their types through `cache`.
|
|
202
|
+
export async function fetchResources(pages, config, bucket, extractors = [], logged = new Map(), cache = new ExtractorCache(undefined)) {
|
|
203
|
+
const entries = pages.flatMap((page) => page.resources ?? []);
|
|
204
|
+
const urls = [...new Set(entries.map((entry) => entry.url))];
|
|
205
|
+
const gotten = new Set(entries.filter((entry) => entry.kind !== "enclosure").map((entry) => entry.url));
|
|
206
|
+
const headed = new Set(entries.filter((entry) => entry.kind === "enclosure" && !gotten.has(entry.url)).map((entry) => entry.url));
|
|
207
|
+
const images = new Set(entries.filter((entry) => entry.kind === "image").map((entry) => entry.url));
|
|
208
|
+
log.debug({ resources: urls.length, references: entries.length, images: images.size, headed: headed.size, fetch: config.fetchResources }, "resources found");
|
|
209
|
+
if (!config.fetchResources || urls.length === 0)
|
|
210
|
+
return {};
|
|
211
|
+
progressPhase("resources", urls.length);
|
|
212
|
+
const results = new Map();
|
|
213
|
+
const failures = new Bucket(bucket.name, bucket.directory, bucket.ttlSeconds, bucket.mode);
|
|
214
|
+
const queue = urls.values();
|
|
215
|
+
const worker = async () => {
|
|
216
|
+
for (const url of queue) {
|
|
217
|
+
const answer = logged.get(url);
|
|
218
|
+
results.set(url, headed.has(url) && !isUsable(answer)
|
|
219
|
+
? await headRemembered(url, bucket, failures, config.cacheFailureTtl, ACCEPT_ENCODING)
|
|
220
|
+
: isUsable(answer)
|
|
221
|
+
? await fromLog(url, answer, config.maxBodySize, extractors, cache)
|
|
222
|
+
: await fetchRemembered(url, config, bucket, failures, extractors, cache, { ...ACCEPT_ENCODING, ...(images.has(url) && IMAGE_ACCEPT) }));
|
|
223
|
+
progressCount(results.size);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
const workers = Array.from({ length: Math.min(width(config.concurrency), urls.length) }, worker);
|
|
227
|
+
await Promise.all(workers);
|
|
228
|
+
const all = results.values().toArray();
|
|
229
|
+
const [failed, cached, revalidated, fromBrowser] = [all.filter((result) => result.status === 0).length, all.filter((result) => result.cached).length, all.filter((result) => result.revalidated).length, all.filter((result) => result.logged).length];
|
|
230
|
+
const failedCached = all.filter((result) => result.cached && isFailure(result.status)).length;
|
|
231
|
+
log.debug({ resources: urls.length, failed, cached, failedCached, revalidated, logged: fromBrowser }, "resources fetched");
|
|
232
|
+
return Object.fromEntries(results);
|
|
233
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { RobotsTxtFile } from "crawlee";
|
|
5
|
+
import { fetchCached } from "../cache/http.js";
|
|
6
|
+
import { OfflineMiss } from "../cache/index.js";
|
|
7
|
+
import { log } from "../logger.js";
|
|
8
|
+
import { reason } from "./fetch.js";
|
|
9
|
+
const DISALLOW_ALL = "User-agent: *\nDisallow: /\n";
|
|
10
|
+
// Parsed facts per file; Crawlee’s own parser is private, so the body is parsed a second time.
|
|
11
|
+
const parsed = new WeakMap();
|
|
12
|
+
// `key=value` pairs of a `Content-Signal` line, as written.
|
|
13
|
+
function signalsOf(value) {
|
|
14
|
+
const pairs = value.split(",").map((pair) => pair.split("=", 2).map((part) => part.trim()));
|
|
15
|
+
return Object.fromEntries(pairs.filter(([key]) => key).map(([key = "", signal = ""]) => [key.toLowerCase(), signal.toLowerCase()]));
|
|
16
|
+
}
|
|
17
|
+
// RFC 9309 groups, `Sitemap:` lines and Content Signals; consecutive `User-agent` lines share one group, a rule before any is dropped.
|
|
18
|
+
export function parseRobots(url, status, body) {
|
|
19
|
+
const groups = [];
|
|
20
|
+
const sitemaps = [];
|
|
21
|
+
const contentSignals = [];
|
|
22
|
+
let group;
|
|
23
|
+
let isOpen = false;
|
|
24
|
+
for (const raw of body.split(/\r\n|\r|\n/)) {
|
|
25
|
+
const line = raw.replace(/#.*/, "").trim();
|
|
26
|
+
const colon = line.indexOf(":");
|
|
27
|
+
if (colon < 1)
|
|
28
|
+
continue;
|
|
29
|
+
const key = line.slice(0, colon).trim().toLowerCase();
|
|
30
|
+
const value = line.slice(colon + 1).trim();
|
|
31
|
+
if (key === "user-agent") {
|
|
32
|
+
if (!isOpen || !group)
|
|
33
|
+
groups.push((group = { agents: [], allow: [], disallow: [] }));
|
|
34
|
+
group.agents.push(value.toLowerCase());
|
|
35
|
+
isOpen = true;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
isOpen = false;
|
|
39
|
+
if (key === "sitemap")
|
|
40
|
+
sitemaps.push(value);
|
|
41
|
+
else if (key === "content-signal")
|
|
42
|
+
contentSignals.push({ agents: group?.agents ?? [], value, signals: signalsOf(value) });
|
|
43
|
+
else if (!group)
|
|
44
|
+
log.debug({ url, key }, "robots.txt rule outside a group dropped");
|
|
45
|
+
else if (value && (key === "allow" || key === "disallow"))
|
|
46
|
+
group[key].push(value);
|
|
47
|
+
else if (key === "crawl-delay" && Number.isFinite(Number(value)))
|
|
48
|
+
group["crawl-delay"] = Number(value);
|
|
49
|
+
}
|
|
50
|
+
log.debug({ url, status, groups: groups.length, sitemaps: sitemaps.length, contentSignals: contentSignals.length }, "robots.txt parsed");
|
|
51
|
+
return { url, status, groups, sitemaps, "content-signals": contentSignals };
|
|
52
|
+
}
|
|
53
|
+
// The groups naming `agent`, else those naming `*`, as RFC 9309 §2.2.1 picks them.
|
|
54
|
+
export function groupsFor(facts, agent) {
|
|
55
|
+
const named = facts.groups.filter((group) => group.agents.includes(agent));
|
|
56
|
+
return named.length > 0 ? named : facts.groups.filter((group) => group.agents.includes("*"));
|
|
57
|
+
}
|
|
58
|
+
// The `Crawl-delay` the spiderlint group, else the `*` group, asks for; 0 when none.
|
|
59
|
+
export function crawlDelayOf(file) {
|
|
60
|
+
const facts = parsed.get(file);
|
|
61
|
+
return (facts && groupsFor(facts, "spiderlint").find((group) => group["crawl-delay"] !== undefined)?.["crawl-delay"]) ?? 0;
|
|
62
|
+
}
|
|
63
|
+
// The file’s facts; a file this module did not load has none.
|
|
64
|
+
export function robotsFactsOf(file) {
|
|
65
|
+
return parsed.get(file);
|
|
66
|
+
}
|
|
67
|
+
// RFC 9309 §2.3.1: a 2xx file applies, a 4xx allows everything, a 5xx or no answer disallows everything.
|
|
68
|
+
async function load(origin, bucket) {
|
|
69
|
+
const url = `${origin}/robots.txt`;
|
|
70
|
+
let content;
|
|
71
|
+
let facts;
|
|
72
|
+
try {
|
|
73
|
+
const { status, value, cached, revalidated } = await fetchCached(bucket, url, (response) => response.text(), true);
|
|
74
|
+
const isApplied = status >= 200 && status < 300;
|
|
75
|
+
content = isApplied ? value : status >= 400 && status < 500 ? "" : DISALLOW_ALL;
|
|
76
|
+
facts = parseRobots(url, status, isApplied ? value : "");
|
|
77
|
+
log.debug({ url, status, cached, revalidated, isDisallowAll: content === DISALLOW_ALL }, "robots.txt read");
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (error instanceof OfflineMiss)
|
|
81
|
+
throw error;
|
|
82
|
+
content = DISALLOW_ALL;
|
|
83
|
+
facts = { ...parseRobots(url, 0, ""), error: reason(error) };
|
|
84
|
+
log.warn({ url, error: reason(error) }, "robots.txt unreachable, disallowing the origin");
|
|
85
|
+
}
|
|
86
|
+
const file = RobotsTxtFile.from(url, content);
|
|
87
|
+
parsed.set(file, facts);
|
|
88
|
+
log.debug({ url, delay: crawlDelayOf(file) }, "robots.txt crawl-delay read");
|
|
89
|
+
return file;
|
|
90
|
+
}
|
|
91
|
+
// One robots.txt per origin for the run, read through `bucket`.
|
|
92
|
+
export function robotsLoader(bucket) {
|
|
93
|
+
const byOrigin = new Map();
|
|
94
|
+
return (url) => {
|
|
95
|
+
const { origin } = new URL(url);
|
|
96
|
+
if (!byOrigin.has(origin))
|
|
97
|
+
byOrigin.set(origin, load(origin, bucket));
|
|
98
|
+
return byOrigin.get(origin);
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
5
|
+
import { assignGroup, compileGroups } from "../groups/assign.js";
|
|
6
|
+
import { log } from "../logger.js";
|
|
7
|
+
// Pages of an adaptive group rendered and compared with their static HTML before it settles on http.
|
|
8
|
+
const DETECTIONS = 3;
|
|
9
|
+
// Milliseconds a page of an undecided group waits for the verdict before the group settles on the browser.
|
|
10
|
+
const VERDICT_MS = 60_000;
|
|
11
|
+
// The html facts a client-side render can change and a rule or the crawl reads.
|
|
12
|
+
function rendered(html) {
|
|
13
|
+
const { title, lang, canonical, h1 = [], meta = {}, property = {}, links } = html ?? {};
|
|
14
|
+
return JSON.stringify({ title, lang, canonical, h1, meta, property, internal: links?.internal.toSorted((a, b) => a.localeCompare(b)) });
|
|
15
|
+
}
|
|
16
|
+
// Routes each URL to the crawler its group needs; an adaptive group renders until its pages read the same without a browser.
|
|
17
|
+
export class Router {
|
|
18
|
+
#groups;
|
|
19
|
+
#modes;
|
|
20
|
+
#agreed = new Map();
|
|
21
|
+
#taken = new Map();
|
|
22
|
+
#verdicts = new Map();
|
|
23
|
+
constructor(groups, modes) {
|
|
24
|
+
this.#groups = compileGroups(groups);
|
|
25
|
+
this.#modes = new Map(Object.entries(modes));
|
|
26
|
+
}
|
|
27
|
+
// The group a URL falls in before its response is known, so `content-type:` entries never match.
|
|
28
|
+
#groupOf(href) {
|
|
29
|
+
const url = new URL(href);
|
|
30
|
+
return assignGroup({ url: { href, pathname: url.pathname, search: url.search }, http: { headers: {} } }, this.#groups);
|
|
31
|
+
}
|
|
32
|
+
// A group’s mode, http for one the config never named.
|
|
33
|
+
#modeOf(group) {
|
|
34
|
+
return this.#modes.get(group) ?? "http";
|
|
35
|
+
}
|
|
36
|
+
// Resolves once `group` settles.
|
|
37
|
+
#verdict(group) {
|
|
38
|
+
const known = this.#verdicts.get(group) ?? Promise.withResolvers();
|
|
39
|
+
this.#verdicts.set(group, known);
|
|
40
|
+
return known;
|
|
41
|
+
}
|
|
42
|
+
// Fixes `group` on `mode` and wakes every page waiting for its verdict.
|
|
43
|
+
#settle(group, mode, agreed) {
|
|
44
|
+
this.#modes.set(group, mode);
|
|
45
|
+
this.#verdict(group).resolve();
|
|
46
|
+
log.debug({ group, fetch: mode, agreed }, "adaptive group settled");
|
|
47
|
+
}
|
|
48
|
+
// Crawlers the run needs: an adaptive group needs both.
|
|
49
|
+
get crawlers() {
|
|
50
|
+
const modes = new Set(this.#modes.values());
|
|
51
|
+
return ["http", "browser"].filter((mode) => modes.has(mode) || modes.has("adaptive"));
|
|
52
|
+
}
|
|
53
|
+
// Each group’s mode, adaptive ones as settled so far.
|
|
54
|
+
get modes() {
|
|
55
|
+
return Object.fromEntries(this.#modes);
|
|
56
|
+
}
|
|
57
|
+
// The queue `href` joins: the browser’s for a group that renders, else http’s, where an adaptive group is decided per request.
|
|
58
|
+
queue(href) {
|
|
59
|
+
return this.#modeOf(this.#groupOf(href)) === "browser" ? "browser" : "http";
|
|
60
|
+
}
|
|
61
|
+
// Whether the http crawler hands `href` to the browser: its group renders, or is undecided and has a detection slot; past the slots it waits for the verdict.
|
|
62
|
+
async handOff(href) {
|
|
63
|
+
const group = this.#groupOf(href);
|
|
64
|
+
const taken = this.#taken.get(group) ?? 0;
|
|
65
|
+
const isSlot = this.#modeOf(group) === "adaptive" && taken < DETECTIONS;
|
|
66
|
+
if (isSlot)
|
|
67
|
+
this.#taken.set(group, taken + 1);
|
|
68
|
+
if (!isSlot && this.#modeOf(group) === "adaptive")
|
|
69
|
+
await Promise.race([this.#verdict(group).promise, sleep(VERDICT_MS, undefined, { ref: false })]);
|
|
70
|
+
if (!isSlot && this.#modeOf(group) === "adaptive")
|
|
71
|
+
this.#settle(group, "browser", this.#agreed.get(group) ?? 0);
|
|
72
|
+
log.debug({ url: href, group, mode: this.#modeOf(group), taken, isSlot }, "hand-off decided");
|
|
73
|
+
return isSlot || this.#modeOf(group) !== "http";
|
|
74
|
+
}
|
|
75
|
+
// Whether a rendered page is to be compared with its static HTML, its group being still undecided.
|
|
76
|
+
isDetecting(href) {
|
|
77
|
+
return this.#modeOf(this.#groupOf(href)) === "adaptive";
|
|
78
|
+
}
|
|
79
|
+
// One difference settles the group on the browser, DETECTIONS agreements on http; a page without html facts agrees.
|
|
80
|
+
detected(href, stat, live) {
|
|
81
|
+
const group = this.#groupOf(href);
|
|
82
|
+
if (this.#modeOf(group) !== "adaptive")
|
|
83
|
+
return;
|
|
84
|
+
const isSame = rendered(stat) === rendered(live);
|
|
85
|
+
const agreed = (this.#agreed.get(group) ?? 0) + (isSame ? 1 : 0);
|
|
86
|
+
this.#agreed.set(group, agreed);
|
|
87
|
+
const settled = isSame ? (agreed >= DETECTIONS ? "http" : undefined) : "browser";
|
|
88
|
+
log.debug({ url: href, group, isSame, agreed, settled }, "rendering compared");
|
|
89
|
+
if (settled)
|
|
90
|
+
this.#settle(group, settled, agreed);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { getDomain } from "tldts";
|
|
5
|
+
// Crawlee enqueue strategy carrying the same meaning as each scope.
|
|
6
|
+
export const STRATEGY = {
|
|
7
|
+
origin: "same-origin",
|
|
8
|
+
host: "same-hostname",
|
|
9
|
+
domain: "same-domain",
|
|
10
|
+
};
|
|
11
|
+
// Same-domain compares registrable domains, as Crawlee's strategy does.
|
|
12
|
+
export function isInScope(link, page, scope) {
|
|
13
|
+
if (scope === "origin")
|
|
14
|
+
return link.origin === page.origin;
|
|
15
|
+
return scope === "host" ? link.hostname === page.hostname : (getDomain(link.hostname, { mixedInputs: false }) ?? link.hostname) === (getDomain(page.hostname, { mixedInputs: false }) ?? page.hostname);
|
|
16
|
+
}
|
|
17
|
+
// The one origin every URL shares, or empty when they span several.
|
|
18
|
+
export function singleOrigin(urls) {
|
|
19
|
+
const origins = new Set([...urls].flatMap((href) => (URL.canParse(href) ? [new URL(href).origin] : [])));
|
|
20
|
+
return origins.size === 1 ? [...origins][0] : "";
|
|
21
|
+
}
|
|
22
|
+
// `href` under `from` moved onto `to`, path and query kept; any other URL passes through.
|
|
23
|
+
export function onOrigin(href, from, to) {
|
|
24
|
+
if (!from || from === to || !URL.canParse(href))
|
|
25
|
+
return href;
|
|
26
|
+
const url = new URL(href);
|
|
27
|
+
return url.origin === from ? new URL(`${url.pathname}${url.search}${url.hash}`, to).href : href;
|
|
28
|
+
}
|
|
29
|
+
// A URL under `origin` becomes its path; any other passes through.
|
|
30
|
+
export function relative(href, origin) {
|
|
31
|
+
return origin && href.startsWith(`${origin}/`) ? href.slice(origin.length) : href;
|
|
32
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { Configuration } from "crawlee";
|
|
6
|
+
import { userStateDirectory } from "../cache/index.js";
|
|
7
|
+
import { log } from "../logger.js";
|
|
8
|
+
// Crawlee’s global storage is scratch: held in memory, rooted in the XDG state directory should it spill.
|
|
9
|
+
export function useScratchStorage() {
|
|
10
|
+
const directory = path.join(userStateDirectory(), "crawlee");
|
|
11
|
+
const global = Configuration.getGlobalConfig();
|
|
12
|
+
global.set("persistStorage", false);
|
|
13
|
+
global.set("storageClientOptions", { localDataDirectory: directory });
|
|
14
|
+
log.debug({ directory }, "crawlee scratch storage set");
|
|
15
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { gunzipSync } from "node:zlib";
|
|
5
|
+
import { load } from "cheerio";
|
|
6
|
+
import { parseSitemap } from "crawlee";
|
|
7
|
+
import { fetchCached } from "../cache/http.js";
|
|
8
|
+
import { OfflineMiss } from "../cache/index.js";
|
|
9
|
+
import { log } from "../logger.js";
|
|
10
|
+
import { reason } from "./fetch.js";
|
|
11
|
+
import { onOrigin } from "./scope.js";
|
|
12
|
+
// sitemaps.org caps one file at 50 MiB uncompressed.
|
|
13
|
+
const MAX_BYTES = 50 * 1024 * 1024;
|
|
14
|
+
const GZIP = Buffer.from([0x1f, 0x8b]);
|
|
15
|
+
// Probed when no seed names a sitemap itself, as Crawlee’s discovery does.
|
|
16
|
+
const CANDIDATES = ["/sitemap.xml", "/sitemap.txt", "/sitemap_index.xml"];
|
|
17
|
+
const SITEMAP_NAME = /sitemap(?:_index)?\.(?:xml|txt)(?:\.gz)?$/i;
|
|
18
|
+
// The body as base64, so gzip bytes survive the JSON bucket.
|
|
19
|
+
async function base64(response) {
|
|
20
|
+
return Buffer.from(await response.arrayBuffer()).toString("base64");
|
|
21
|
+
}
|
|
22
|
+
// `bucket` behind a per-run memo, so a probed candidate is not asked again when read.
|
|
23
|
+
function fetchOnce(bucket) {
|
|
24
|
+
const answers = new Map();
|
|
25
|
+
return (url) => {
|
|
26
|
+
const known = answers.get(url);
|
|
27
|
+
log.debug({ url, isKnown: known !== undefined }, "sitemap fetch looked up");
|
|
28
|
+
if (known)
|
|
29
|
+
return known;
|
|
30
|
+
const answer = fetchCached(bucket, url, base64);
|
|
31
|
+
answers.set(url, answer);
|
|
32
|
+
return answer;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
// Body text, gunzipped when the bytes carry the gzip magic.
|
|
36
|
+
function decode(body) {
|
|
37
|
+
return (body.subarray(0, 2).equals(GZIP) ? gunzipSync(body, { maxOutputLength: MAX_BYTES }) : body).toString("utf8");
|
|
38
|
+
}
|
|
39
|
+
// A text/plain or `.txt` file lists one URL per line; anything else parses as XML, nested sitemaps as `originSitemapUrl: null`.
|
|
40
|
+
async function* entries(url, contentType, content) {
|
|
41
|
+
const isText = contentType === "text/plain" || new URL(url).pathname.replace(/\.gz$/, "").endsWith(".txt");
|
|
42
|
+
if (!isText)
|
|
43
|
+
return yield* parseSitemap([{ type: "raw", content }], undefined, { maxDepth: 0, emitNestedSitemaps: true });
|
|
44
|
+
for (const line of content.split(/\r?\n/)) {
|
|
45
|
+
const loc = line.trim();
|
|
46
|
+
if (URL.canParse(loc))
|
|
47
|
+
yield { loc, originSitemapUrl: url };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// The image and video files a page’s sitemap entry lists.
|
|
51
|
+
export function mediaOf(page) {
|
|
52
|
+
return [...(page.sitemap?.images ?? []), ...(page.sitemap?.videos ?? [])];
|
|
53
|
+
}
|
|
54
|
+
// Alternates, images and videos per `<loc>`, parsed only when the file declares one of those namespaces.
|
|
55
|
+
function extensions(content) {
|
|
56
|
+
const found = new Map();
|
|
57
|
+
if (!/<(?:xhtml|image|video):/.test(content))
|
|
58
|
+
return found;
|
|
59
|
+
const $ = load(content, { xml: true });
|
|
60
|
+
const texts = (selection) => selection
|
|
61
|
+
.map((_, element) => $(element).text().trim())
|
|
62
|
+
.get()
|
|
63
|
+
.filter((text) => text.length > 0);
|
|
64
|
+
for (const element of $("url")) {
|
|
65
|
+
const url = $(element);
|
|
66
|
+
const alternates = url
|
|
67
|
+
.children(String.raw `xhtml\:link[rel='alternate'][hreflang][href]`)
|
|
68
|
+
.map((_, link) => ({ lang: String($(link).attr("hreflang")), href: String($(link).attr("href")) }))
|
|
69
|
+
.get();
|
|
70
|
+
const images = texts(url.find(String.raw `image\:image > image\:loc`));
|
|
71
|
+
const videos = texts(url.find(String.raw `video\:video > video\:content_loc, video\:video > video\:player_loc`));
|
|
72
|
+
found.set(url.children("loc").text().trim(), { ...(alternates.length > 0 && { alternates }), ...(images.length > 0 && { images }), ...(videos.length > 0 && { videos }) });
|
|
73
|
+
}
|
|
74
|
+
log.debug({ entries: found.size }, "sitemap extensions parsed");
|
|
75
|
+
return found;
|
|
76
|
+
}
|
|
77
|
+
// Parses one fetched body into `file` counts: page entries land in `index`, same-host nested sitemaps in `queue`; `canonical` URLs move onto the file’s origin.
|
|
78
|
+
async function collect(file, contentType, body, index, queue, canonical) {
|
|
79
|
+
const content = decode(body);
|
|
80
|
+
const extended = extensions(content);
|
|
81
|
+
const origin = new URL(file.url).origin;
|
|
82
|
+
const found = entries(file.url, contentType, content);
|
|
83
|
+
for await (const listed of found) {
|
|
84
|
+
const entry = { ...listed, loc: onOrigin(listed.loc, canonical, origin) };
|
|
85
|
+
if (entry.originSitemapUrl === null) {
|
|
86
|
+
file.sitemaps += 1;
|
|
87
|
+
const isSameHost = new URL(entry.loc).hostname === new URL(file.url).hostname;
|
|
88
|
+
log[isSameHost ? "debug" : "info"]({ sitemap: file.url, nested: entry.loc, isSameHost }, "nested sitemap found");
|
|
89
|
+
if (isSameHost)
|
|
90
|
+
queue.push(entry.loc);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
file.urls += 1;
|
|
94
|
+
const { alternates, images, videos } = extended.get(listed.loc) ?? {};
|
|
95
|
+
index.set(entry.loc, {
|
|
96
|
+
listed: true,
|
|
97
|
+
...(entry.lastmod && { lastmod: entry.lastmod.toISOString() }),
|
|
98
|
+
...(entry.changefreq && { changefreq: entry.changefreq }),
|
|
99
|
+
...(entry.priority !== undefined && { priority: entry.priority }),
|
|
100
|
+
...(alternates && { alternates }),
|
|
101
|
+
...(images && { images: images.map((href) => onOrigin(href, canonical, origin)) }),
|
|
102
|
+
...(videos && { videos: videos.map((href) => onOrigin(href, canonical, origin)) }),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Fetches and parses one file into its facts; `error` says which step failed.
|
|
107
|
+
async function readSitemap(url, index, queue, fetch, canonical) {
|
|
108
|
+
const file = { url, status: 0, urls: 0, sitemaps: 0 };
|
|
109
|
+
try {
|
|
110
|
+
const { status, headers, value } = await fetch(url);
|
|
111
|
+
file.status = status;
|
|
112
|
+
const contentType = String(headers["content-type"] ?? "")
|
|
113
|
+
.split(";", 1)[0]
|
|
114
|
+
?.trim() || undefined;
|
|
115
|
+
if (status < 200 || status >= 300)
|
|
116
|
+
file.error = `answers ${status}`;
|
|
117
|
+
else if (contentType === "text/html")
|
|
118
|
+
file.error = `is ${contentType}, not a sitemap`;
|
|
119
|
+
else
|
|
120
|
+
await collect(file, contentType, Buffer.from(value, "base64"), index, queue, canonical);
|
|
121
|
+
if (!file.error && file.urls + file.sitemaps === 0)
|
|
122
|
+
file.error = `names no URL (${contentType ?? "no content-type"})`;
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
if (error instanceof OfflineMiss)
|
|
126
|
+
throw error;
|
|
127
|
+
file.error = `${file.status > 0 ? "does not parse" : "does not fetch"}: ${reason(error).split("\n", 1)[0]}`;
|
|
128
|
+
}
|
|
129
|
+
log[file.error ? "warn" : "debug"](file, "sitemap read");
|
|
130
|
+
return file;
|
|
131
|
+
}
|
|
132
|
+
// A candidate name counts when it answers 2xx; its body stays in `bucket` for the read that follows, and a miss younger than the bucket TTL is not asked again.
|
|
133
|
+
async function isAnswering(url, bucket, fetch) {
|
|
134
|
+
const stored = await bucket.get(url);
|
|
135
|
+
const isFreshMiss = stored !== undefined && (stored.value.status < 200 || stored.value.status >= 300) && bucket.isFresh(stored);
|
|
136
|
+
log.debug({ url, status: stored?.value.status, isFreshMiss }, "sitemap candidate looked up");
|
|
137
|
+
if (isFreshMiss)
|
|
138
|
+
return false;
|
|
139
|
+
try {
|
|
140
|
+
const { status } = await fetch(url);
|
|
141
|
+
log.debug({ url, status }, "sitemap candidate probed");
|
|
142
|
+
return status >= 200 && status < 300;
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (error instanceof OfflineMiss)
|
|
146
|
+
throw error;
|
|
147
|
+
log.debug({ url, error: reason(error) }, "sitemap candidate unreachable");
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Per origin: its robots.txt `Sitemap:` lines, `canonical` ones read from this origin, then the seeds naming a sitemap, else the common names that answer.
|
|
152
|
+
async function discover(seeds, robotsFor, bucket, fetch, canonical) {
|
|
153
|
+
const found = new Set();
|
|
154
|
+
const origins = new Set(seeds.map((seed) => new URL(seed).origin));
|
|
155
|
+
for (const origin of origins) {
|
|
156
|
+
const robots = await robotsFor(origin);
|
|
157
|
+
const listed = robots.getSitemaps({ enqueueStrategy: "all" }).map((url) => onOrigin(url, canonical, origin));
|
|
158
|
+
for (const url of listed)
|
|
159
|
+
found.add(url);
|
|
160
|
+
const named = seeds.filter((seed) => new URL(seed).origin === origin && SITEMAP_NAME.test(seed));
|
|
161
|
+
const candidates = named.length > 0 ? [] : CANDIDATES.map((pathname) => new URL(pathname, origin).href);
|
|
162
|
+
for (const url of named)
|
|
163
|
+
found.add(url);
|
|
164
|
+
for (const url of candidates)
|
|
165
|
+
if (await isAnswering(url, bucket, fetch))
|
|
166
|
+
found.add(url);
|
|
167
|
+
log.debug({ origin, canonical, listed, found: found.size, named: named.length, probed: candidates.length }, "sitemaps discovered for origin");
|
|
168
|
+
}
|
|
169
|
+
return [...found];
|
|
170
|
+
}
|
|
171
|
+
// robots.txt `Sitemap:` lines plus the common `/sitemap.xml` names, each file and its nested files read once.
|
|
172
|
+
export async function loadSitemap(seeds, robotsFor, bucket, canonical) {
|
|
173
|
+
const index = new Map();
|
|
174
|
+
const fetch = fetchOnce(bucket);
|
|
175
|
+
const queue = await discover(seeds, robotsFor, bucket, fetch, canonical);
|
|
176
|
+
log.debug({ seeds, files: queue }, queue.length > 0 ? "sitemap discovered" : "no sitemap discovered");
|
|
177
|
+
const seen = new Set();
|
|
178
|
+
const files = [];
|
|
179
|
+
for (const url of queue) {
|
|
180
|
+
if (seen.has(url))
|
|
181
|
+
continue;
|
|
182
|
+
seen.add(url);
|
|
183
|
+
files.push(await readSitemap(url, index, queue, fetch, canonical));
|
|
184
|
+
}
|
|
185
|
+
log.debug({ files: files.length, failed: files.filter((file) => file.error).length, urls: index.size }, "sitemap parsed");
|
|
186
|
+
return { index, files };
|
|
187
|
+
}
|