@queryrev/ai-readiness 0.0.0-stage → 1.0.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 (66) hide show
  1. package/CITATION.cff +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +190 -2
  4. package/SECURITY.md +34 -0
  5. package/data/LICENSE +409 -0
  6. package/dist/chunks/cli-BK65ic84.js +56 -0
  7. package/dist/chunks/fetch-text-NUUnM91O.js +866 -0
  8. package/dist/chunks/findings-_twhMw3_.js +2950 -0
  9. package/dist/chunks/main-CJu6SE-W.js +1491 -0
  10. package/dist/cli/args.d.ts +27 -0
  11. package/dist/cli/bin.d.ts +2 -0
  12. package/dist/cli/commands/check.d.ts +4 -0
  13. package/dist/cli/commands/crawlers.d.ts +3 -0
  14. package/dist/cli/commands/llms-generate.d.ts +4 -0
  15. package/dist/cli/commands/llms-validate.d.ts +4 -0
  16. package/dist/cli/commands/robots-test.d.ts +4 -0
  17. package/dist/cli/common.d.ts +27 -0
  18. package/dist/cli/finding.d.ts +3 -0
  19. package/dist/cli/main.d.ts +7 -0
  20. package/dist/cli/node-context.d.ts +3 -0
  21. package/dist/cli/render.d.ts +26 -0
  22. package/dist/cli/run.d.ts +11 -0
  23. package/dist/cli/sanitize.d.ts +8 -0
  24. package/dist/cli/types.d.ts +59 -0
  25. package/dist/cli/usage.d.ts +1 -0
  26. package/dist/cli.js +36 -0
  27. package/dist/crawlers/index.d.ts +79 -0
  28. package/dist/facts/app-shell.d.ts +24 -0
  29. package/dist/facts/check-site.d.ts +13 -0
  30. package/dist/facts/domain.d.ts +15 -0
  31. package/dist/facts/findings.d.ts +5 -0
  32. package/dist/facts/home-page.d.ts +18 -0
  33. package/dist/facts/home-response.d.ts +5 -0
  34. package/dist/facts/html.d.ts +37 -0
  35. package/dist/facts/index.d.ts +16 -0
  36. package/dist/facts/json-ld.d.ts +35 -0
  37. package/dist/facts/marks.d.ts +51 -0
  38. package/dist/facts/organization.d.ts +23 -0
  39. package/dist/facts/origin-robots.d.ts +20 -0
  40. package/dist/facts/profiles.d.ts +13 -0
  41. package/dist/facts/reader.d.ts +2 -0
  42. package/dist/facts/schema-org-organization-types.d.ts +6 -0
  43. package/dist/facts/schema-org-types.d.ts +2 -0
  44. package/dist/facts/sitemap.d.ts +24 -0
  45. package/dist/facts/types.d.ts +214 -0
  46. package/dist/facts/words.d.ts +15 -0
  47. package/dist/generate/generate-llms.d.ts +51 -0
  48. package/dist/generate/index.d.ts +70 -0
  49. package/dist/index.d.ts +10 -0
  50. package/dist/index.js +1023 -0
  51. package/dist/llms/findings.d.ts +7 -0
  52. package/dist/llms/index.d.ts +88 -0
  53. package/dist/llms/links.d.ts +46 -0
  54. package/dist/net/address.d.ts +26 -0
  55. package/dist/net/charset.d.ts +20 -0
  56. package/dist/net/cli.d.ts +13 -0
  57. package/dist/net/fetch-text.d.ts +191 -0
  58. package/dist/net/guard.d.ts +71 -0
  59. package/dist/net/testing/fixture-fetcher.d.ts +15 -0
  60. package/dist/net/testing/fixture-guard.d.ts +20 -0
  61. package/dist/net/testing/index.d.ts +7 -0
  62. package/dist/net-cli.js +1 -0
  63. package/dist/robots/index.d.ts +67 -0
  64. package/dist/run.js +1 -0
  65. package/dist/testing.js +58 -0
  66. package/package.json +80 -6
@@ -0,0 +1,866 @@
1
+ import { isIP, isIPv4, isIPv6 } from "node:net";
2
+ import { Transform, pipeline } from "node:stream";
3
+ import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib";
4
+ import { Agent, buildConnector, request } from "undici";
5
+ import { lookup } from "node:dns/promises";
6
+ // src/net/address.ts
7
+ var IPV4_REFUSED = [
8
+ {
9
+ base: ip4("168.63.129.16"),
10
+ bits: 32,
11
+ reason: "cloud_metadata"
12
+ },
13
+ {
14
+ base: ip4("0.0.0.0"),
15
+ bits: 8,
16
+ reason: "unspecified"
17
+ },
18
+ {
19
+ base: ip4("10.0.0.0"),
20
+ bits: 8,
21
+ reason: "private"
22
+ },
23
+ {
24
+ base: ip4("100.64.0.0"),
25
+ bits: 10,
26
+ reason: "shared"
27
+ },
28
+ {
29
+ base: ip4("127.0.0.0"),
30
+ bits: 8,
31
+ reason: "loopback"
32
+ },
33
+ {
34
+ base: ip4("169.254.0.0"),
35
+ bits: 16,
36
+ reason: "link_local"
37
+ },
38
+ {
39
+ base: ip4("172.16.0.0"),
40
+ bits: 12,
41
+ reason: "private"
42
+ },
43
+ {
44
+ base: ip4("192.0.0.0"),
45
+ bits: 24,
46
+ reason: "reserved"
47
+ },
48
+ {
49
+ base: ip4("192.0.2.0"),
50
+ bits: 24,
51
+ reason: "documentation"
52
+ },
53
+ {
54
+ base: ip4("192.31.196.0"),
55
+ bits: 24,
56
+ reason: "reserved"
57
+ },
58
+ {
59
+ base: ip4("192.52.193.0"),
60
+ bits: 24,
61
+ reason: "reserved"
62
+ },
63
+ {
64
+ base: ip4("192.88.99.0"),
65
+ bits: 24,
66
+ reason: "reserved"
67
+ },
68
+ {
69
+ base: ip4("192.168.0.0"),
70
+ bits: 16,
71
+ reason: "private"
72
+ },
73
+ {
74
+ base: ip4("192.175.48.0"),
75
+ bits: 24,
76
+ reason: "reserved"
77
+ },
78
+ {
79
+ base: ip4("198.18.0.0"),
80
+ bits: 15,
81
+ reason: "reserved"
82
+ },
83
+ {
84
+ base: ip4("198.51.100.0"),
85
+ bits: 24,
86
+ reason: "documentation"
87
+ },
88
+ {
89
+ base: ip4("203.0.113.0"),
90
+ bits: 24,
91
+ reason: "documentation"
92
+ },
93
+ {
94
+ base: ip4("224.0.0.0"),
95
+ bits: 4,
96
+ reason: "multicast"
97
+ },
98
+ {
99
+ base: ip4("255.255.255.255"),
100
+ bits: 32,
101
+ reason: "broadcast"
102
+ },
103
+ {
104
+ base: ip4("240.0.0.0"),
105
+ bits: 4,
106
+ reason: "reserved"
107
+ }
108
+ ];
109
+ function ip4(dotted) {
110
+ const parts = dotted.split(".").map(Number);
111
+ return (parts[0] << 24 >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3] >>> 0;
112
+ }
113
+ function inRange4(value, range) {
114
+ if (range.bits === 0) return true;
115
+ const mask = range.bits === 32 ? 4294967295 : -1 << 32 - range.bits >>> 0;
116
+ return (value & mask) >>> 0 === (range.base & mask) >>> 0;
117
+ }
118
+ function classify4(address) {
119
+ const value = ip4(address);
120
+ for (const range of IPV4_REFUSED) if (inRange4(value, range)) return {
121
+ allowed: false,
122
+ reason: range.reason
123
+ };
124
+ return {
125
+ allowed: true,
126
+ family: 4
127
+ };
128
+ }
129
+ /** The eight 16-bit groups of an IPv6 address (with any dotted IPv4 tail folded in). */
130
+ function groups6(address) {
131
+ let text = address.toLowerCase();
132
+ const zone = text.indexOf("%");
133
+ if (zone !== -1) text = text.slice(0, zone);
134
+ const lastColon = text.lastIndexOf(":");
135
+ const tail = text.slice(lastColon + 1);
136
+ if (tail.includes(".")) {
137
+ if (!isIPv4(tail)) return null;
138
+ const v = ip4(tail);
139
+ text = `${text.slice(0, lastColon + 1)}${(v >>> 16).toString(16)}:${(v & 65535).toString(16)}`;
140
+ }
141
+ const halves = text.split("::");
142
+ if (halves.length > 2) return null;
143
+ const parse = (part) => part === "" ? [] : part.split(":").map((group) => parseInt(group, 16));
144
+ const head = parse(halves[0]);
145
+ const rest = halves.length === 2 ? parse(halves[1]) : [];
146
+ const missing = 8 - head.length - rest.length;
147
+ if (halves.length === 1 ? missing !== 0 : missing < 1) return null;
148
+ const all = [
149
+ ...head,
150
+ ...new Array(halves.length === 2 ? missing : 0).fill(0),
151
+ ...rest
152
+ ];
153
+ if (all.length !== 8 || all.some((g) => !Number.isInteger(g) || g < 0 || g > 65535)) return null;
154
+ return all;
155
+ }
156
+ function classify6(address) {
157
+ if (address.includes("%")) return {
158
+ allowed: false,
159
+ reason: "link_local"
160
+ };
161
+ const g = groups6(address);
162
+ if (g === null) return {
163
+ allowed: false,
164
+ reason: "invalid"
165
+ };
166
+ const [g0, g1, g2, g3, g4, g5] = g;
167
+ const firstSixZero = g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0;
168
+ if (firstSixZero && g5 === 0) {
169
+ if (g[6] === 0 && g[7] === 0) return {
170
+ allowed: false,
171
+ reason: "unspecified"
172
+ };
173
+ if (g[6] === 0 && g[7] === 1) return {
174
+ allowed: false,
175
+ reason: "loopback"
176
+ };
177
+ return {
178
+ allowed: false,
179
+ reason: "ipv4_embedded"
180
+ };
181
+ }
182
+ if (firstSixZero && g5 === 65535) return {
183
+ allowed: false,
184
+ reason: "ipv4_embedded"
185
+ };
186
+ if (g0 === 100 && g1 === 65435) return {
187
+ allowed: false,
188
+ reason: "ipv4_embedded"
189
+ };
190
+ if ((g0 & 65280) === 65280) return {
191
+ allowed: false,
192
+ reason: "multicast"
193
+ };
194
+ if ((g0 & 65472) === 65152) return {
195
+ allowed: false,
196
+ reason: "link_local"
197
+ };
198
+ if ((g0 & 65024) === 64512) return {
199
+ allowed: false,
200
+ reason: "private"
201
+ };
202
+ if ((g0 & 57344) !== 8192) return {
203
+ allowed: false,
204
+ reason: "not_global"
205
+ };
206
+ if (g0 === 8194) return {
207
+ allowed: false,
208
+ reason: "ipv4_embedded"
209
+ };
210
+ if (g0 === 8193 && g1 === 0) return {
211
+ allowed: false,
212
+ reason: "ipv4_embedded"
213
+ };
214
+ if (g0 === 8193 && g1 === 3512) return {
215
+ allowed: false,
216
+ reason: "documentation"
217
+ };
218
+ if (g0 === 16383 && (g1 & 61440) === 0) return {
219
+ allowed: false,
220
+ reason: "documentation"
221
+ };
222
+ if (g0 === 8193 && g1 < 512) return {
223
+ allowed: false,
224
+ reason: "reserved"
225
+ };
226
+ return {
227
+ allowed: true,
228
+ family: 6
229
+ };
230
+ }
231
+ /**
232
+ * Classifies one IP address as the network guard sees it. Accepts the forms `dns.lookup`
233
+ * returns: a canonical dotted quad, or an IPv6 address with or without brackets.
234
+ */
235
+ function classifyAddress(address) {
236
+ const bare = address.startsWith("[") && address.endsWith("]") ? address.slice(1, -1) : address;
237
+ if (isIPv4(bare)) return classify4(bare);
238
+ if (isIPv6(bare.split("%")[0] ?? "")) return classify6(bare);
239
+ return {
240
+ allowed: false,
241
+ reason: "invalid"
242
+ };
243
+ }
244
+ // src/net/guard.ts
245
+ /**
246
+ * The network guard of a crawler. A crawler fetches URLs that come from the sites it reads
247
+ * (links, sitemaps, redirects), so a URL can point anywhere, including at the machine it runs
248
+ * on or its network: the cloud metadata address, a database on a private network, a service on
249
+ * the loopback. The guard closes that door at three points:
250
+ *
251
+ * 1. Before a request, the URL itself: scheme `http` or `https`, port 80 or 443, no user
252
+ * name or password, and an IP-literal host must be an allowed address. The URL is parsed by
253
+ * the WHATWG parser, which turns decimal, octal, hexadecimal and shorthand IPv4 forms
254
+ * (`2130706433`, `0177.0.0.1`, `0x7f.1`, `127.1`) into the dotted quad before the check.
255
+ * 2. At connection time, in the socket connector: the host name is resolved once, every
256
+ * address returned must be allowed (one refused address refuses the host), and the socket
257
+ * is opened to the address that was checked. Nothing resolves the name a second time, so a
258
+ * DNS answer that changes between check and connect (DNS rebinding) cannot slip through.
259
+ * 3. Every redirect hop is a new request, so 1 and 2 apply to each hop (done by the fetcher that uses this guard).
260
+ *
261
+ * The guard is not configurable in production code: `createNetworkGuard` always uses the
262
+ * strict rules. Tests reach a local fixture server through `createFixtureNetworkGuard` in the
263
+ * package's testing entry point (`@queryrev/ai-readiness/testing`), which allows exactly one loopback address and one port.
264
+ */
265
+ /** Ports the crawler connects to. */
266
+ var CRAWL_PORTS = Object.freeze([80, 443]);
267
+ /** Time allowed for one host-name lookup; past it the host counts as `dns_failed`. */
268
+ var DNS_TIMEOUT_MS = 5e3;
269
+ /** The operating system's resolver, as a connection would use it. */
270
+ var systemResolver = async (hostname) => {
271
+ return (await lookup(hostname, {
272
+ all: true,
273
+ order: "verbatim"
274
+ })).map((answer) => answer.address);
275
+ };
276
+ /** A request the guard refused, or a host name that did not resolve. The message is a code. */
277
+ var NetworkGuardError = class extends Error {
278
+ code;
279
+ /** For `refused_address`: why the address is not allowed. */
280
+ reason;
281
+ constructor(code, reason = null) {
282
+ super(reason === null ? `NetworkGuard: ${code}` : `NetworkGuard: ${code} (${reason})`);
283
+ this.name = "NetworkGuardError";
284
+ this.code = code;
285
+ this.reason = reason;
286
+ }
287
+ };
288
+ var STRICT_RULES = Object.freeze({
289
+ ports: CRAWL_PORTS,
290
+ classify: classifyAddress
291
+ });
292
+ function defaultPort(protocol) {
293
+ return protocol === "https:" ? 443 : 80;
294
+ }
295
+ function stripBrackets$1(host) {
296
+ return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
297
+ }
298
+ function guardWithRules(rules, resolver, dnsTimeoutMs = DNS_TIMEOUT_MS) {
299
+ const checkAddress = (address) => {
300
+ const verdict = rules.classify(address);
301
+ if (!verdict.allowed) throw new NetworkGuardError("refused_address", verdict.reason);
302
+ };
303
+ const checkPort = (port) => {
304
+ if (!rules.ports.includes(port)) throw new NetworkGuardError("refused_port");
305
+ };
306
+ return Object.freeze({
307
+ checkUrl(url) {
308
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new NetworkGuardError("refused_scheme");
309
+ if (url.username !== "" || url.password !== "") throw new NetworkGuardError("refused_scheme");
310
+ const host = stripBrackets$1(url.hostname);
311
+ if (isIP(host) !== 0) checkAddress(host);
312
+ checkPort(url.port === "" ? defaultPort(url.protocol) : Number(url.port));
313
+ },
314
+ async vet(hostname, port) {
315
+ checkPort(port);
316
+ const host = stripBrackets$1(hostname);
317
+ if (host === "") throw new NetworkGuardError("dns_failed");
318
+ if (isIP(host) !== 0) {
319
+ checkAddress(host);
320
+ return host;
321
+ }
322
+ let addresses;
323
+ let timer;
324
+ try {
325
+ addresses = await Promise.race([resolver(host), new Promise((_, reject) => {
326
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error("dns_timeout")), dnsTimeoutMs);
327
+ })]);
328
+ } catch {
329
+ throw new NetworkGuardError("dns_failed");
330
+ } finally {
331
+ clearTimeout(timer);
332
+ }
333
+ if (addresses.length === 0) throw new NetworkGuardError("dns_failed");
334
+ for (const address of addresses) checkAddress(address);
335
+ return stripBrackets$1(addresses[0]);
336
+ }
337
+ });
338
+ }
339
+ /** The production guard: ports 80 and 443, public addresses only. */
340
+ function createNetworkGuard(options = {}) {
341
+ return guardWithRules(STRICT_RULES, options.resolver ?? systemResolver, options.dnsTimeoutMs ?? 5e3);
342
+ }
343
+ /**
344
+ * An undici socket connector that vets every new connection through the guard and connects to
345
+ * the vetted address. The `Host` header and the TLS server name still come from the URL (undici
346
+ * derives both from the origin, not from the address it connects to), so certificates are
347
+ * verified against the site's name.
348
+ */
349
+ function guardedConnector(guard, connectTimeoutMs, testCa) {
350
+ const connect = buildConnector({
351
+ timeout: connectTimeoutMs,
352
+ allowH2: false,
353
+ ...testCa === void 0 ? {} : { ca: testCa }
354
+ });
355
+ return (options, callback) => {
356
+ const port = options.port === "" ? defaultPort(options.protocol) : Number(options.port);
357
+ guard.vet(options.hostname, port).then((address) => connect({
358
+ ...options,
359
+ hostname: address
360
+ }, callback), (error) => callback(error instanceof Error ? error : new NetworkGuardError("refused_address"), null));
361
+ };
362
+ }
363
+ // src/net/fetch-text.ts
364
+ /**
365
+ * `fetchText`: the one way this library reads a file from a site. The library is run by
366
+ * strangers against URLs they type, and a site's answer (a redirect, a sitemap entry, an
367
+ * llms.txt link) can point anywhere, so every request is held to these rules:
368
+ *
369
+ * - The URL is parsed by the WHATWG parser. Only `http` and `https`, only ports 80 and 443, no
370
+ * user name or password, a host name (an IP-literal host is refused, in every spelling the
371
+ * parser accepts), at most 253 characters.
372
+ * - The connection goes through the network guard: the name is resolved once, every address
373
+ * must be public (private, loopback, link-local, cloud metadata and the other special ranges
374
+ * are refused), and the socket is opened to the address that was checked, so a DNS answer
375
+ * that changes between check and connect cannot slip through.
376
+ * - At most 3 redirects, each checked again as a new request; the final URL and host are
377
+ * reported.
378
+ * - 5 seconds to connect, 10 seconds a request (network time of the whole redirect chain), and
379
+ * the caller's overall budget through `signal`.
380
+ * - A size cap per kind of file, counted after decompression; a longer body is cut there and
381
+ * reported as cut.
382
+ * - GET only, no cookies, no credentials, no proxy from the environment; the body is read as
383
+ * UTF-8 text.
384
+ * - Politeness per host: one request a second, `Crawl-delay` up to 10 seconds, and no further
385
+ * request to a host that answered 429 or 503.
386
+ *
387
+ * Nothing a site does makes it throw: every failure is an `outcome` on the result.
388
+ */
389
+ /** Who the library says it is. */
390
+ var AI_READINESS_USER_AGENT = "Mozilla/5.0 (compatible; QueryRev-AIReadiness/1.0; +https://queryrev.com/bot)";
391
+ /** Size caps in bytes, counted after decompression. */
392
+ var FETCH_LIMITS = Object.freeze({
393
+ robots: 512e3,
394
+ llms: 1048576,
395
+ page: 2097152,
396
+ sitemap: 5242880
397
+ });
398
+ /** Redirects followed after the first request. */
399
+ var MAX_REDIRECTS = 3;
400
+ /** Longest host name accepted, in characters (as the parser writes it, punycode for IDNs). */
401
+ var MAX_HOST_LENGTH = 253;
402
+ /** Time allowed to open a connection (TCP, and TLS for https). */
403
+ var CONNECT_TIMEOUT_MS = 5e3;
404
+ /** Time allowed for one `fetchText` call, redirects included; waits for politeness excluded. */
405
+ var REQUEST_TIMEOUT_MS = 1e4;
406
+ /** Overall budgets a caller passes through `signal`: a site check, and llms.txt generation. */
407
+ var CHECK_BUDGET_MS = 3e4;
408
+ var GENERATE_BUDGET_MS = 12e4;
409
+ /** Politeness: the smallest gap between two request starts to one host. */
410
+ var HOST_INTERVAL_MS = 1e3;
411
+ /** `Crawl-delay` is honoured up to this many seconds. */
412
+ var MAX_CRAWL_DELAY_SECONDS = 10;
413
+ /**
414
+ * Compressed bytes read before decoding stops, above the decoded cap. Compression never makes
415
+ * real text much larger, so this only bounds a stream that would never decode to the cap.
416
+ */
417
+ var WIRE_SLACK_BYTES = 65536;
418
+ var REDIRECT_STATUSES = /* @__PURE__ */ new Set([
419
+ 301,
420
+ 302,
421
+ 303,
422
+ 307,
423
+ 308
424
+ ]);
425
+ var ACCEPT = Object.freeze({
426
+ robots: "text/plain, */*;q=0.5",
427
+ llms: "text/markdown, text/plain;q=0.9, */*;q=0.5",
428
+ page: "text/html, application/xhtml+xml;q=0.9, */*;q=0.5",
429
+ sitemap: "application/xml, text/xml;q=0.9, */*;q=0.5"
430
+ });
431
+ /** Response headers passed on. Everything else, cookies first, is dropped. */
432
+ var KEPT_HEADERS = Object.freeze([
433
+ "content-type",
434
+ "content-language",
435
+ "last-modified",
436
+ "etag",
437
+ "x-robots-tag",
438
+ "retry-after"
439
+ ]);
440
+ /**
441
+ * The production fetcher, with the strict guard: public addresses only. There is no option that
442
+ * loosens it; the private-network opt-in for a command-line user lives in the `./cli` entry
443
+ * point only.
444
+ *
445
+ * Use one fetcher per check (or per generation run) and call `close()` after: the fetcher
446
+ * holds that run's connections and its politeness state.
447
+ */
448
+ function createTextFetcher(options = {}) {
449
+ const resolver = options.resolver;
450
+ return buildTextFetcher({ guard: createNetworkGuard(resolver === void 0 ? {} : { resolver }) });
451
+ }
452
+ /** A host as politeness and reports key it: lower case, without one trailing dot. */
453
+ function hostKey(hostname) {
454
+ const lower = hostname.toLowerCase();
455
+ return lower.endsWith(".") ? lower.slice(0, -1) : lower;
456
+ }
457
+ function stripBrackets(host) {
458
+ return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
459
+ }
460
+ /** The URL rules applied before the guard. Exported for its tests only. */
461
+ function checkTextUrl(input) {
462
+ let url;
463
+ try {
464
+ url = typeof input === "string" ? new URL(input) : input;
465
+ } catch {
466
+ return { refusal: "invalid_url" };
467
+ }
468
+ if (url.protocol !== "http:" && url.protocol !== "https:") return { refusal: "scheme" };
469
+ if (url.username !== "" || url.password !== "") return { refusal: "credentials" };
470
+ const host = stripBrackets(url.hostname);
471
+ if (host === "") return { refusal: "invalid_url" };
472
+ if (isIP(host) !== 0) return { refusal: "ip_literal" };
473
+ if (url.hostname.length > 253) return { refusal: "host_too_long" };
474
+ return { url };
475
+ }
476
+ function headerValues(headers, name) {
477
+ const value = headers[name];
478
+ if (value === void 0) return [];
479
+ return Array.isArray(value) ? value : [value];
480
+ }
481
+ function keptHeaders(headers) {
482
+ const kept = {};
483
+ for (const name of KEPT_HEADERS) {
484
+ const values = headerValues(headers, name);
485
+ if (values.length > 0) kept[name] = Object.freeze(values);
486
+ }
487
+ return kept;
488
+ }
489
+ function mediaType(headers) {
490
+ const value = headerValues(headers, "content-type")[0];
491
+ if (value === void 0) return null;
492
+ const type = value.split(";")[0].trim().toLowerCase();
493
+ return type === "" ? null : type;
494
+ }
495
+ function decoderFor(encoding) {
496
+ switch (encoding) {
497
+ case "":
498
+ case "identity": return null;
499
+ case "gzip":
500
+ case "x-gzip": return createGunzip();
501
+ case "deflate": return createInflate();
502
+ case "br": return createBrotliDecompress();
503
+ default: return "unknown";
504
+ }
505
+ }
506
+ function discard(body) {
507
+ body.on("error", () => {});
508
+ body.destroy();
509
+ }
510
+ function isZlibError(error) {
511
+ return error instanceof Error && typeof error.code === "string" && error.code.startsWith("Z_");
512
+ }
513
+ /**
514
+ * Reads a body up to `limit` decoded bytes. Decoding stops at the cap, so a small compressed
515
+ * bomb cannot grow past it, and the compressed stream itself is bounded too.
516
+ */
517
+ async function readLimited(body, encodingHeader, limit) {
518
+ const encodings = encodingHeader.split(",").map((part) => part.trim().toLowerCase()).filter(Boolean);
519
+ const decoder = encodings.length > 1 ? "unknown" : decoderFor(encodings[0] ?? "");
520
+ if (decoder === "unknown") {
521
+ discard(body);
522
+ return {
523
+ body: null,
524
+ truncated: false,
525
+ decodeFailed: true
526
+ };
527
+ }
528
+ let wireTruncated = false;
529
+ let source;
530
+ if (decoder === null) source = body;
531
+ else {
532
+ let wireBytes = 0;
533
+ const counter = new Transform({ transform(chunk, _encoding, callback) {
534
+ wireBytes += chunk.length;
535
+ if (wireBytes > limit + WIRE_SLACK_BYTES) {
536
+ wireTruncated = true;
537
+ callback(/* @__PURE__ */ new Error("wire_limit"));
538
+ return;
539
+ }
540
+ callback(null, chunk);
541
+ } });
542
+ pipeline(body, counter, decoder, () => {});
543
+ source = decoder;
544
+ }
545
+ const chunks = [];
546
+ let kept = 0;
547
+ let truncated = false;
548
+ let decodeFailed = false;
549
+ try {
550
+ for await (const chunk of source) {
551
+ const room = limit - kept;
552
+ if (chunk.length > room) {
553
+ chunks.push(chunk.subarray(0, room));
554
+ kept += room;
555
+ truncated = true;
556
+ break;
557
+ }
558
+ chunks.push(chunk);
559
+ kept += chunk.length;
560
+ }
561
+ } catch (error) {
562
+ if (wireTruncated) truncated = true;
563
+ else if (decoder !== null && isZlibError(error)) decodeFailed = true;
564
+ else throw error;
565
+ } finally {
566
+ discard(body);
567
+ if (decoder !== null) decoder.destroy();
568
+ }
569
+ if (wireTruncated) truncated = true;
570
+ if (decodeFailed) return {
571
+ body: null,
572
+ truncated: false,
573
+ decodeFailed: true
574
+ };
575
+ return {
576
+ body: Buffer.concat(chunks, kept),
577
+ truncated,
578
+ decodeFailed: false
579
+ };
580
+ }
581
+ function errorChain(error) {
582
+ const chain = [];
583
+ let current = error;
584
+ while (current !== void 0 && current !== null && chain.length < 5) {
585
+ chain.push(current);
586
+ current = current instanceof Error ? current.cause : void 0;
587
+ }
588
+ return chain;
589
+ }
590
+ var TIMEOUT_CODES = /* @__PURE__ */ new Set([
591
+ "UND_ERR_HEADERS_TIMEOUT",
592
+ "UND_ERR_BODY_TIMEOUT",
593
+ "UND_ERR_CONNECT_TIMEOUT",
594
+ "ETIMEDOUT"
595
+ ]);
596
+ function classifyError(error, budget) {
597
+ for (const item of errorChain(error)) {
598
+ if (!(item instanceof NetworkGuardError)) continue;
599
+ if (item.code === "refused_address") return {
600
+ outcome: "refused_address",
601
+ refusal: item.reason
602
+ };
603
+ if (item.code === "refused_port") return {
604
+ outcome: "refused_url",
605
+ refusal: "port"
606
+ };
607
+ if (item.code === "refused_scheme") return {
608
+ outcome: "refused_url",
609
+ refusal: "scheme"
610
+ };
611
+ return {
612
+ outcome: "dns_failed",
613
+ refusal: null
614
+ };
615
+ }
616
+ if (budget?.aborted === true) return {
617
+ outcome: "budget_exceeded",
618
+ refusal: null
619
+ };
620
+ for (const item of errorChain(error)) {
621
+ if (!(item instanceof Error)) continue;
622
+ const code = item.code;
623
+ if (item.name === "TimeoutError" || item.name === "AbortError") return {
624
+ outcome: "timeout",
625
+ refusal: null
626
+ };
627
+ if (code !== void 0 && TIMEOUT_CODES.has(code)) return {
628
+ outcome: "timeout",
629
+ refusal: null
630
+ };
631
+ }
632
+ return {
633
+ outcome: "network_error",
634
+ refusal: null
635
+ };
636
+ }
637
+ /** Waits `ms`, or rejects when the signal aborts first. */
638
+ function wait(ms, signal) {
639
+ if (ms <= 0) return signal?.aborted === true ? Promise.reject(/* @__PURE__ */ new Error("aborted")) : Promise.resolve();
640
+ return new Promise((resolve, reject) => {
641
+ if (signal?.aborted === true) {
642
+ reject(/* @__PURE__ */ new Error("aborted"));
643
+ return;
644
+ }
645
+ const timer = setTimeout(() => {
646
+ signal?.removeEventListener("abort", onAbort);
647
+ resolve();
648
+ }, ms);
649
+ const onAbort = () => {
650
+ clearTimeout(timer);
651
+ reject(/* @__PURE__ */ new Error("aborted"));
652
+ };
653
+ signal?.addEventListener("abort", onAbort, { once: true });
654
+ });
655
+ }
656
+ function remember(map, key, value) {
657
+ map.delete(key);
658
+ map.set(key, value);
659
+ if (map.size > 1e3) map.delete(map.keys().next().value);
660
+ }
661
+ /**
662
+ * One request a second per host, `Crawl-delay` up to 10 s, stop after 429 or 503. The state is
663
+ * bounded: start slots already in the past are dropped, and each table keeps at most
664
+ * MAX_TRACKED_HOSTS hosts (a check reads a handful; only a site that redirects to thousands of
665
+ * hosts reaches the bound). Exported for its tests only.
666
+ */
667
+ var HostPacing = class {
668
+ baseIntervalMs;
669
+ now;
670
+ nextAt = /* @__PURE__ */ new Map();
671
+ interval = /* @__PURE__ */ new Map();
672
+ stopped = /* @__PURE__ */ new Map();
673
+ constructor(baseIntervalMs, now = Date.now) {
674
+ this.baseIntervalMs = baseIntervalMs;
675
+ this.now = now;
676
+ }
677
+ /** Entries held per table, for tests of the bound. */
678
+ get sizes() {
679
+ return {
680
+ nextAt: this.nextAt.size,
681
+ interval: this.interval.size,
682
+ stopped: this.stopped.size
683
+ };
684
+ }
685
+ intervalFor(host) {
686
+ return this.interval.get(hostKey(host)) ?? this.baseIntervalMs;
687
+ }
688
+ setCrawlDelay(host, seconds) {
689
+ if (seconds === null || !Number.isFinite(seconds) || !(seconds > 0)) return;
690
+ const key = hostKey(host);
691
+ const delayMs = Math.min(seconds, 10) * 1e3;
692
+ const current = this.intervalFor(key);
693
+ if (delayMs <= current) return;
694
+ remember(this.interval, key, delayMs);
695
+ const next = this.nextAt.get(key);
696
+ if (next !== void 0) this.nextAt.set(key, next + delayMs - current);
697
+ }
698
+ stop(host) {
699
+ remember(this.stopped, hostKey(host), true);
700
+ }
701
+ isStopped(host) {
702
+ return this.stopped.has(hostKey(host));
703
+ }
704
+ /** Reserves the next start slot for a host synchronously, then waits for it. */
705
+ async acquire(host, signal) {
706
+ const key = hostKey(host);
707
+ const now = this.now();
708
+ for (const [other, at] of this.nextAt) if (at <= now) this.nextAt.delete(other);
709
+ const at = Math.max(now, this.nextAt.get(key) ?? 0);
710
+ remember(this.nextAt, key, at + this.intervalFor(key));
711
+ await wait(at - now, signal);
712
+ }
713
+ };
714
+ /** The fetcher with the given guard and timings. Production reaches it only with the defaults. */
715
+ function buildTextFetcher(config) {
716
+ const connectTimeoutMs = config.connectTimeoutMs ?? 5e3;
717
+ const requestTimeoutMs = config.requestTimeoutMs ?? 1e4;
718
+ const guard = config.guard;
719
+ const pacing = new HostPacing(config.hostIntervalMs ?? 1e3);
720
+ const agent = new Agent({
721
+ connect: guardedConnector(guard, connectTimeoutMs, config.testCa),
722
+ connections: 1,
723
+ pipelining: 1,
724
+ headersTimeout: requestTimeoutMs,
725
+ bodyTimeout: requestTimeoutMs
726
+ });
727
+ async function fetchText(start, options) {
728
+ if (!Object.hasOwn(FETCH_LIMITS, options.kind)) throw new TypeError("fetchText: unknown kind");
729
+ const cap = FETCH_LIMITS[options.kind];
730
+ const asked = options.maxBytes;
731
+ const limitBytes = typeof asked === "number" && Number.isFinite(asked) && asked >= 0 ? Math.min(Math.floor(asked), cap) : cap;
732
+ const budget = options.signal;
733
+ const redirects = [];
734
+ const seen = /* @__PURE__ */ new Set();
735
+ let requests = 0;
736
+ let spentMs = 0;
737
+ let currentHref = start;
738
+ const finish = (outcome, refusal, extra = {}) => {
739
+ let finalHost = "";
740
+ try {
741
+ finalHost = hostKey(new URL(currentHref).hostname);
742
+ } catch {
743
+ finalHost = "";
744
+ }
745
+ return Object.freeze({
746
+ outcome,
747
+ refusal,
748
+ requestedUrl: start,
749
+ finalUrl: currentHref,
750
+ finalHost,
751
+ redirects: Object.freeze([...redirects]),
752
+ status: null,
753
+ contentType: null,
754
+ headers: Object.freeze({}),
755
+ text: null,
756
+ rawBody: null,
757
+ bytes: 0,
758
+ limitBytes,
759
+ truncated: false,
760
+ decodeFailed: false,
761
+ requests,
762
+ elapsedMs: spentMs,
763
+ ...extra
764
+ });
765
+ };
766
+ let current;
767
+ for (;;) {
768
+ const checked = checkTextUrl(currentHref);
769
+ if ("refusal" in checked) return finish("refused_url", checked.refusal);
770
+ current = checked.url;
771
+ currentHref = current.href;
772
+ try {
773
+ guard.checkUrl(current);
774
+ } catch (error) {
775
+ const { outcome, refusal } = classifyError(error, budget);
776
+ return finish(outcome, refusal);
777
+ }
778
+ seen.add(current.href);
779
+ if (pacing.isStopped(current.hostname)) return finish("host_stopped", null);
780
+ try {
781
+ await pacing.acquire(current.hostname, budget);
782
+ } catch {
783
+ return finish("budget_exceeded", null);
784
+ }
785
+ const remainingMs = requestTimeoutMs - spentMs;
786
+ if (remainingMs <= 0) return finish("timeout", null);
787
+ const timeout = AbortSignal.timeout(Math.ceil(remainingMs));
788
+ const signal = budget === void 0 ? timeout : AbortSignal.any([budget, timeout]);
789
+ const started = performance.now();
790
+ requests += 1;
791
+ let response;
792
+ try {
793
+ response = await request(current, {
794
+ dispatcher: agent,
795
+ method: "GET",
796
+ headers: {
797
+ "user-agent": AI_READINESS_USER_AGENT,
798
+ accept: ACCEPT[options.kind],
799
+ "accept-encoding": "gzip, deflate, br"
800
+ },
801
+ signal
802
+ });
803
+ } catch (error) {
804
+ spentMs += performance.now() - started;
805
+ const { outcome, refusal } = classifyError(error, budget);
806
+ return finish(outcome, refusal);
807
+ }
808
+ const status = response.statusCode;
809
+ if (status === 429 || status === 503) pacing.stop(current.hostname);
810
+ const location = headerValues(response.headers, "location")[0];
811
+ if (REDIRECT_STATUSES.has(status) && location !== void 0) {
812
+ await response.body.dump({ limit: 65536 }).catch(() => {});
813
+ spentMs += performance.now() - started;
814
+ let target;
815
+ try {
816
+ target = new URL(location, current);
817
+ } catch {
818
+ target = null;
819
+ }
820
+ redirects.push(Object.freeze({
821
+ url: current.href,
822
+ status,
823
+ location: target?.href ?? null
824
+ }));
825
+ if (target === null) return finish("invalid_redirect", null, { status });
826
+ if (seen.has(target.href)) return finish("redirect_loop", null, { status });
827
+ if (redirects.length > 3) return finish("too_many_redirects", null, { status });
828
+ if (options.redirectAllowed !== void 0 && !options.redirectAllowed(target)) return finish("redirect_refused", null, { status });
829
+ currentHref = target.href;
830
+ continue;
831
+ }
832
+ const contentType = mediaType(response.headers);
833
+ let read;
834
+ try {
835
+ const encoding = headerValues(response.headers, "content-encoding").join(",");
836
+ read = await readLimited(response.body, encoding, limitBytes);
837
+ } catch (error) {
838
+ discard(response.body);
839
+ spentMs += performance.now() - started;
840
+ const { outcome, refusal } = classifyError(error, budget);
841
+ return finish(outcome, refusal, {
842
+ status,
843
+ contentType
844
+ });
845
+ }
846
+ spentMs += performance.now() - started;
847
+ return finish("response", null, {
848
+ status,
849
+ contentType,
850
+ headers: Object.freeze(keptHeaders(response.headers)),
851
+ text: read.body === null ? null : new TextDecoder("utf-8").decode(read.body, { stream: read.truncated }),
852
+ rawBody: read.body === null ? null : new Uint8Array(read.body),
853
+ bytes: read.body?.length ?? 0,
854
+ truncated: read.truncated,
855
+ decodeFailed: read.decodeFailed
856
+ });
857
+ }
858
+ }
859
+ return Object.freeze({
860
+ fetchText,
861
+ setCrawlDelay: (host, seconds) => pacing.setCrawlDelay(host, seconds),
862
+ isStopped: (host) => pacing.isStopped(host),
863
+ close: () => agent.close()
864
+ });
865
+ }
866
+ export { classifyAddress as C, systemResolver as S, NetworkGuardError as _, GENERATE_BUDGET_MS as a, guardWithRules as b, MAX_CRAWL_DELAY_SECONDS as c, REQUEST_TIMEOUT_MS as d, buildTextFetcher as f, DNS_TIMEOUT_MS as g, CRAWL_PORTS as h, FETCH_LIMITS as i, MAX_HOST_LENGTH as l, createTextFetcher as m, CHECK_BUDGET_MS as n, HOST_INTERVAL_MS as o, checkTextUrl as p, CONNECT_TIMEOUT_MS as r, KEPT_HEADERS as s, AI_READINESS_USER_AGENT as t, MAX_REDIRECTS as u, STRICT_RULES as v, guardedConnector as x, createNetworkGuard as y };