@queryrev/ai-readiness 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CITATION.cff +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +195 -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-CTetT2bF.js +2950 -0
  9. package/dist/chunks/main-Bd0uzoHe.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 +78 -4
@@ -0,0 +1,7 @@
1
+ import type { Finding, FindingLevel } from '../facts/types.js';
2
+ import type { LlmsReport } from './index.js';
3
+ /** The finding ids this module can return. */
4
+ export declare const LLMS_FINDING_IDS: readonly string[];
5
+ export declare function llmsFinding(id: string, level: FindingLevel, message: string, snippet?: string): Finding;
6
+ /** The findings for one validated answer. */
7
+ export declare function llmsFindings(report: LlmsReport): Finding[];
@@ -0,0 +1,88 @@
1
+ /** llms.txt checks: telling the file from a page, and its structure (llmstxt.org). */
2
+ /** Whether a body is an HTML document (a site that answers every address with a page). */
3
+ export declare function looksLikeHtml(contentType: string | null, body: string): boolean;
4
+ /**
5
+ * A robots.txt served at another address (a site that answers every path with it): a
6
+ * `User-agent` line and no Markdown link. It is not an llms.txt, whatever its first line.
7
+ */
8
+ export declare function looksLikeRobotsTxt(body: string): boolean;
9
+ /**
10
+ * The structure of an llms.txt, as llmstxt.org describes the file: a Markdown H1 with the
11
+ * site's name first (required), then a blockquote summary, then sections under H2 headings
12
+ * that list links (`- [name](url): notes`).
13
+ *
14
+ * - `summary`: a blockquote line (`> …`) before the first H2.
15
+ * - `sections`: H2 headings with at least one linked list item under them.
16
+ * - `links`: Markdown links anywhere in the file (images not counted).
17
+ * - `structured`: an H1 and at least one such section.
18
+ */
19
+ export interface LlmsStructure {
20
+ readonly bytes: number;
21
+ readonly summary: boolean;
22
+ readonly sections: number;
23
+ readonly links: number;
24
+ readonly structured: boolean;
25
+ /** The link targets as written, in order. */
26
+ readonly targets: readonly string[];
27
+ }
28
+ /**
29
+ * Characters of a file read for its structure. An llms.txt is meant to be a short map; a
30
+ * longer file is checked on its start, so the work stays small whatever a site serves.
31
+ */
32
+ export declare const LLMS_STRUCTURE_MAX_CHARS: number;
33
+ export declare function llmsStructure(body: string): LlmsStructure;
34
+ /**
35
+ * Whether an answer is an llms.txt: a 200, a text type that is not HTML, not a robots.txt
36
+ * served at that address, and a body whose first non-empty line is a Markdown H1 (`# Name`),
37
+ * which is the one part llmstxt.org requires.
38
+ */
39
+ export declare function looksLikeLlmsTxt(status: number | null, contentType: string | null, body: string): boolean;
40
+ /** Why an answer is or is not an llms.txt. */
41
+ export type LlmsVerdict =
42
+ /** An llms.txt: a 200 with a text type, not a page or a robots.txt, starting with an H1. */
43
+ 'llms_txt'
44
+ /** No answer arrived (refused, unreachable, timed out). */
45
+ | 'no_answer'
46
+ /** 404 or 410: there is no file. */
47
+ | 'not_found'
48
+ /** Another status than 200. */
49
+ | 'status'
50
+ /** A 200 with a type that is not text. */
51
+ | 'not_text'
52
+ /** A 200 that is an HTML page (a site that answers every address with a page). */
53
+ | 'html'
54
+ /** A 200 that is a robots.txt served at this address. */
55
+ | 'robots'
56
+ /** A 200 that is text but does not start with a Markdown H1. */
57
+ | 'no_h1';
58
+ /** The validation of one answer at `/llms.txt`. */
59
+ export interface LlmsReport {
60
+ /** Whether the answer is an llms.txt (the soft-404 rule: a 200 alone is not enough). */
61
+ readonly present: boolean;
62
+ readonly verdict: LlmsVerdict;
63
+ readonly status: number | null;
64
+ readonly contentType: string | null;
65
+ /** Bytes of the body read (after decompression). */
66
+ readonly bytes: number;
67
+ /** The body went over the size cap and was cut there. */
68
+ readonly truncated: boolean;
69
+ /** The first H1's text, as written, cut to 200 characters; null without one. */
70
+ readonly h1: string | null;
71
+ /** H1 headings outside code blocks; llmstxt.org asks for exactly one. */
72
+ readonly h1Count: number;
73
+ /** The structure, when the answer is an llms.txt. */
74
+ readonly structure: LlmsStructure | null;
75
+ }
76
+ /** Characters of an H1 kept in a report. */
77
+ export declare const LLMS_H1_MAX = 200;
78
+ /**
79
+ * Validates an answer at `/llms.txt` against the llmstxt.org format, after the soft-404 rule:
80
+ * an answer that is not a 200, is HTML, is a robots.txt, or does not start with an H1 is not
81
+ * an llms.txt, whatever its status. Pure: pass what was fetched.
82
+ */
83
+ export declare function validateLlmsTxt(answer: {
84
+ readonly status: number | null;
85
+ readonly contentType: string | null;
86
+ readonly body: string | null;
87
+ readonly truncated: boolean;
88
+ }): LlmsReport;
@@ -0,0 +1,46 @@
1
+ import type { Finding } from '../facts/types.js';
2
+ import { type TextFetcher } from '../net/fetch-text.js';
3
+ /** Links checked in one run. */
4
+ export declare const LLMS_LINKS_MAX = 20;
5
+ /** Bytes of each linked page that are read. */
6
+ export declare const LLMS_LINK_BODY_BYTES: number;
7
+ export type LlmsLinkOutcome =
8
+ /** A 2xx answer (after at most 3 redirects on the same site). */
9
+ 'ok'
10
+ /** 404 or 410. */
11
+ | 'missing'
12
+ /** Another 4xx or 5xx answer. */
13
+ | 'error_status'
14
+ /** No answer, a refused address, a redirect off the site, or a loop. */
15
+ | 'unreachable'
16
+ /** The robots.txt of the link's own origin keeps this reader from it, so it was not requested. */
17
+ | 'not_allowed'
18
+ /** The robots.txt of the link's own origin could not be read, so the link was not requested. */
19
+ | 'robots_unreadable'
20
+ /** The time budget ran out before the link was asked. */
21
+ | 'not_checked';
22
+ export interface LlmsLinkResult {
23
+ /** The link as an absolute address. */
24
+ readonly url: string;
25
+ readonly outcome: LlmsLinkOutcome;
26
+ readonly status: number | null;
27
+ }
28
+ export interface LlmsLinksReport {
29
+ /** Links in the file that were not checked because they point at another site or are not web addresses. */
30
+ readonly skipped: number;
31
+ /** Links in the file beyond the first 20 that were left unchecked. */
32
+ readonly beyondLimit: number;
33
+ /** False when robots.txt could not be read, so no link was requested. */
34
+ readonly robotsRead: boolean;
35
+ readonly results: readonly LlmsLinkResult[];
36
+ }
37
+ export interface CheckLlmsLinksOptions {
38
+ readonly fetcher: TextFetcher;
39
+ /** The site's address; relative links are resolved against it, and only its site is checked. */
40
+ readonly base: string;
41
+ readonly signal?: AbortSignal;
42
+ readonly registrableDomain?: (host: string) => string;
43
+ }
44
+ export declare function checkLlmsLinks(targets: readonly string[], options: CheckLlmsLinksOptions): Promise<LlmsLinksReport>;
45
+ /** The findings of a link check. One finding for the set; the evidence lists what is wrong. */
46
+ export declare function llmsLinkFindings(report: LlmsLinksReport): Finding[];
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Which IP addresses a crawler may connect to (the network guard).
3
+ *
4
+ * The rule is an allow-list, not a block-list: an IPv4 address is allowed only when it is in
5
+ * none of the special-purpose ranges of the IANA registry, and an IPv6 address only when it is
6
+ * global unicast (`2000::/3`) outside the special-purpose blocks inside that range. Anything
7
+ * this module cannot parse as a canonical IPv4 dotted quad or an IPv6 address is refused, so a
8
+ * resolver answer in an odd form (`0177.0.0.1`, `2130706433`) fails closed.
9
+ *
10
+ * IPv6 forms that carry an IPv4 address (IPv4-mapped `::ffff:a.b.c.d`, IPv4-compatible
11
+ * `::a.b.c.d`, NAT64 `64:ff9b::/96`, 6to4 `2002::/16`, Teredo `2001::/32`) are refused outright:
12
+ * a site does not need them to be crawled, and each is a way to reach an internal IPv4 address.
13
+ */
14
+ export type AddressRefusal = 'invalid' | 'unspecified' | 'loopback' | 'private' | 'link_local' | 'shared' | 'reserved' | 'documentation' | 'multicast' | 'broadcast' | 'ipv4_embedded' | 'not_global' | 'cloud_metadata';
15
+ export type AddressClass = {
16
+ readonly allowed: true;
17
+ readonly family: 4 | 6;
18
+ } | {
19
+ readonly allowed: false;
20
+ readonly reason: AddressRefusal;
21
+ };
22
+ /**
23
+ * Classifies one IP address as the network guard sees it. Accepts the forms `dns.lookup`
24
+ * returns: a canonical dotted quad, or an IPv6 address with or without brackets.
25
+ */
26
+ export declare function classifyAddress(address: string): AddressClass;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Reading a page's bytes as text. One decoder for the library and for any program that uses it,
3
+ * so a page in windows-1251, Shift_JIS, GBK or EUC-KR is counted the same everywhere. The order
4
+ * is the one browsers use, shortened: a byte order mark; the `charset` of the Content-Type
5
+ * header; a `<meta charset>` or `<meta http-equiv="Content-Type">` in the first 2 KiB; else
6
+ * UTF-8. Labels are the WHATWG Encoding Standard's; a label it does not know is skipped.
7
+ */
8
+ export interface DecodedText {
9
+ readonly text: string;
10
+ /** The encoding the bytes were read as (a WHATWG name, such as `windows-1251`). */
11
+ readonly charset: string;
12
+ }
13
+ /** The character set of a page by the rule above. */
14
+ export declare function detectCharset(bytes: Uint8Array, contentTypeHeader: string | null): string;
15
+ /**
16
+ * The bytes of an HTML page as text. `contentTypeHeader` is the raw `Content-Type` header, with
17
+ * its parameters. A body cut at a size cap (`truncated`) loses no character to the cut: an
18
+ * incomplete one at the end is held back.
19
+ */
20
+ export declare function decodeHtml(bytes: Uint8Array, contentTypeHeader: string | null, truncated?: boolean): DecodedText;
@@ -0,0 +1,13 @@
1
+ import { type AddressClass } from './address.js';
2
+ import { type TextFetcher, type TextFetcherOptions } from './fetch-text.js';
3
+ import { type HostResolver, type NetworkGuard } from './guard.js';
4
+ /** The address rule of the private-network opt-in. */
5
+ export declare function classifyPrivateNetworkAddress(address: string): AddressClass;
6
+ /** The guard of the opt-in: ports 80 and 443, the address rule above. */
7
+ export declare function createPrivateNetworkGuard(resolver?: HostResolver): NetworkGuard;
8
+ export interface CliTextFetcherOptions extends TextFetcherOptions {
9
+ /** Set only from the user's explicit command-line flag. Off by default. */
10
+ readonly allowPrivateNetwork?: boolean;
11
+ }
12
+ /** The CLI's fetcher: strict unless the user asked for the private network. One per run. */
13
+ export declare function createCliTextFetcher(options?: CliTextFetcherOptions): TextFetcher;
@@ -0,0 +1,191 @@
1
+ import type { AddressRefusal } from './address.js';
2
+ import { type HostResolver, type NetworkGuard } from './guard.js';
3
+ /**
4
+ * `fetchText`: the one way this library reads a file from a site. The library is run by
5
+ * strangers against URLs they type, and a site's answer (a redirect, a sitemap entry, an
6
+ * llms.txt link) can point anywhere, so every request is held to these rules:
7
+ *
8
+ * - The URL is parsed by the WHATWG parser. Only `http` and `https`, only ports 80 and 443, no
9
+ * user name or password, a host name (an IP-literal host is refused, in every spelling the
10
+ * parser accepts), at most 253 characters.
11
+ * - The connection goes through the network guard: the name is resolved once, every address
12
+ * must be public (private, loopback, link-local, cloud metadata and the other special ranges
13
+ * are refused), and the socket is opened to the address that was checked, so a DNS answer
14
+ * that changes between check and connect cannot slip through.
15
+ * - At most 3 redirects, each checked again as a new request; the final URL and host are
16
+ * reported.
17
+ * - 5 seconds to connect, 10 seconds a request (network time of the whole redirect chain), and
18
+ * the caller's overall budget through `signal`.
19
+ * - A size cap per kind of file, counted after decompression; a longer body is cut there and
20
+ * reported as cut.
21
+ * - GET only, no cookies, no credentials, no proxy from the environment; the body is read as
22
+ * UTF-8 text.
23
+ * - Politeness per host: one request a second, `Crawl-delay` up to 10 seconds, and no further
24
+ * request to a host that answered 429 or 503.
25
+ *
26
+ * Nothing a site does makes it throw: every failure is an `outcome` on the result.
27
+ */
28
+ /** Who the library says it is. */
29
+ export declare const AI_READINESS_USER_AGENT = "Mozilla/5.0 (compatible; QueryRev-AIReadiness/1.0; +https://queryrev.com/bot)";
30
+ /** The kinds of file the library reads, each with its own size cap. */
31
+ export type FetchKind = 'robots' | 'llms' | 'page' | 'sitemap';
32
+ /** Size caps in bytes, counted after decompression. */
33
+ export declare const FETCH_LIMITS: Readonly<Record<FetchKind, number>>;
34
+ /** Redirects followed after the first request. */
35
+ export declare const MAX_REDIRECTS = 3;
36
+ /** Longest host name accepted, in characters (as the parser writes it, punycode for IDNs). */
37
+ export declare const MAX_HOST_LENGTH = 253;
38
+ /** Time allowed to open a connection (TCP, and TLS for https). */
39
+ export declare const CONNECT_TIMEOUT_MS = 5000;
40
+ /** Time allowed for one `fetchText` call, redirects included; waits for politeness excluded. */
41
+ export declare const REQUEST_TIMEOUT_MS = 10000;
42
+ /** Overall budgets a caller passes through `signal`: a site check, and llms.txt generation. */
43
+ export declare const CHECK_BUDGET_MS = 30000;
44
+ export declare const GENERATE_BUDGET_MS = 120000;
45
+ /** Politeness: the smallest gap between two request starts to one host. */
46
+ export declare const HOST_INTERVAL_MS = 1000;
47
+ /** `Crawl-delay` is honoured up to this many seconds. */
48
+ export declare const MAX_CRAWL_DELAY_SECONDS = 10;
49
+ /** Response headers passed on. Everything else, cookies first, is dropped. */
50
+ export declare const KEPT_HEADERS: readonly ["content-type", "content-language", "last-modified", "etag", "x-robots-tag", "retry-after"];
51
+ export type KeptHeader = (typeof KEPT_HEADERS)[number];
52
+ /** Why a URL was refused before any connection. */
53
+ export type UrlRefusal = 'invalid_url' | 'scheme' | 'credentials' | 'ip_literal' | 'host_too_long' | 'port';
54
+ export type TextFetchOutcome =
55
+ /** An HTTP answer that is not a followed redirect (any status). */
56
+ 'response'
57
+ /** The URL (or a redirect target) broke a URL rule; `refusal` says which. */
58
+ | 'refused_url'
59
+ /** The host resolved to an address that is not allowed; `refusal` says why. */
60
+ | 'refused_address' | 'dns_failed' | 'too_many_redirects' | 'redirect_loop'
61
+ /** A redirect without a usable `Location`. */
62
+ | 'invalid_redirect'
63
+ /** A redirect the caller's `redirectAllowed` refused; its target was not contacted. */
64
+ | 'redirect_refused'
65
+ /** The connection or the request ran out of its time. */
66
+ | 'timeout'
67
+ /** The caller's overall budget (`signal`) ran out. */
68
+ | 'budget_exceeded'
69
+ /** The host answered 429 or 503 earlier; no further request is sent to it. */
70
+ | 'host_stopped' | 'network_error';
71
+ export interface RedirectHop {
72
+ readonly url: string;
73
+ readonly status: number;
74
+ /** Where it pointed, resolved against `url`; null when `Location` was unusable. */
75
+ readonly location: string | null;
76
+ }
77
+ export interface TextFetchResult {
78
+ readonly outcome: TextFetchOutcome;
79
+ readonly refusal: UrlRefusal | AddressRefusal | null;
80
+ readonly requestedUrl: string;
81
+ /** The last URL requested or refused (the requested URL when there were no redirects). */
82
+ readonly finalUrl: string;
83
+ /** Host of `finalUrl`; empty when the URL did not parse. */
84
+ readonly finalHost: string;
85
+ readonly redirects: readonly RedirectHop[];
86
+ /** Status of the last answer; null when none arrived. */
87
+ readonly status: number | null;
88
+ /** Media type without parameters, lower case; null when absent. */
89
+ readonly contentType: string | null;
90
+ readonly headers: Readonly<Partial<Record<KeptHeader, readonly string[]>>>;
91
+ /** The body as UTF-8 text, up to the cap; null when no body was read or it did not decode. */
92
+ readonly text: string | null;
93
+ /**
94
+ * The same decoded bytes, for a file that is not text (a gzip sitemap served as a file, not as
95
+ * a content encoding); null when no body was read.
96
+ */
97
+ readonly rawBody: Uint8Array | null;
98
+ /** Decoded bytes kept. */
99
+ readonly bytes: number;
100
+ /** The cap that applied. */
101
+ readonly limitBytes: number;
102
+ /** The body went over the cap and was cut there. */
103
+ readonly truncated: boolean;
104
+ /** The content encoding was unknown or the compressed stream was corrupt. */
105
+ readonly decodeFailed: boolean;
106
+ /** Requests attempted, a connection the guard refused included. */
107
+ readonly requests: number;
108
+ /** Network milliseconds of the whole call, waits for politeness excluded. */
109
+ readonly elapsedMs: number;
110
+ }
111
+ export interface FetchTextOptions {
112
+ readonly kind: FetchKind;
113
+ /** A lower cap than the kind's (for example to read only the start of a page). */
114
+ readonly maxBytes?: number;
115
+ /** The caller's overall budget. When it aborts, the call ends as `budget_exceeded`. */
116
+ readonly signal?: AbortSignal;
117
+ /**
118
+ * Where a redirect may lead, asked before each further hop (after the URL rules and before
119
+ * the guard). A hop it refuses ends the call as `redirect_refused`, and its target is never
120
+ * contacted. The guard still checks every hop it allows.
121
+ */
122
+ readonly redirectAllowed?: (target: URL) => boolean;
123
+ }
124
+ export interface TextFetcher {
125
+ fetchText(url: string, options: FetchTextOptions): Promise<TextFetchResult>;
126
+ /** Applies robots.txt `Crawl-delay` (seconds) for a host, capped at 10. Only ever slows. */
127
+ setCrawlDelay(host: string, seconds: number | null): void;
128
+ /** Whether a host answered 429 or 503 and gets no further request. */
129
+ isStopped(host: string): boolean;
130
+ close(): Promise<void>;
131
+ }
132
+ export interface TextFetcherOptions {
133
+ /** The resolver; the operating system's by default. Every answer is still checked. */
134
+ readonly resolver?: HostResolver;
135
+ }
136
+ /** Settings only the testing entry point may change (fixture port, short timeouts). */
137
+ export interface TextFetcherConfig {
138
+ readonly guard: NetworkGuard;
139
+ readonly connectTimeoutMs?: number;
140
+ readonly requestTimeoutMs?: number;
141
+ readonly hostIntervalMs?: number;
142
+ /** Extra trusted CA certificate; never set in production. */
143
+ readonly testCa?: string;
144
+ }
145
+ /**
146
+ * The production fetcher, with the strict guard: public addresses only. There is no option that
147
+ * loosens it; the private-network opt-in for a command-line user lives in the `./cli` entry
148
+ * point only.
149
+ *
150
+ * Use one fetcher per check (or per generation run) and call `close()` after: the fetcher
151
+ * holds that run's connections and its politeness state.
152
+ */
153
+ export declare function createTextFetcher(options?: TextFetcherOptions): TextFetcher;
154
+ /** A host as politeness and reports key it: lower case, without one trailing dot. */
155
+ export declare function hostKey(hostname: string): string;
156
+ /** The URL rules applied before the guard. Exported for its tests only. */
157
+ export declare function checkTextUrl(input: string | URL): {
158
+ url: URL;
159
+ } | {
160
+ refusal: UrlRefusal;
161
+ };
162
+ /** Hosts whose politeness state is kept; past it the oldest entry is dropped. */
163
+ export declare const MAX_TRACKED_HOSTS = 1000;
164
+ /**
165
+ * One request a second per host, `Crawl-delay` up to 10 s, stop after 429 or 503. The state is
166
+ * bounded: start slots already in the past are dropped, and each table keeps at most
167
+ * MAX_TRACKED_HOSTS hosts (a check reads a handful; only a site that redirects to thousands of
168
+ * hosts reaches the bound). Exported for its tests only.
169
+ */
170
+ export declare class HostPacing {
171
+ private readonly baseIntervalMs;
172
+ private readonly now;
173
+ private readonly nextAt;
174
+ private readonly interval;
175
+ private readonly stopped;
176
+ constructor(baseIntervalMs: number, now?: () => number);
177
+ /** Entries held per table, for tests of the bound. */
178
+ get sizes(): {
179
+ readonly nextAt: number;
180
+ readonly interval: number;
181
+ readonly stopped: number;
182
+ };
183
+ intervalFor(host: string): number;
184
+ setCrawlDelay(host: string, seconds: number | null): void;
185
+ stop(host: string): void;
186
+ isStopped(host: string): boolean;
187
+ /** Reserves the next start slot for a host synchronously, then waits for it. */
188
+ acquire(host: string, signal: AbortSignal | undefined): Promise<void>;
189
+ }
190
+ /** The fetcher with the given guard and timings. Production reaches it only with the defaults. */
191
+ export declare function buildTextFetcher(config: TextFetcherConfig): TextFetcher;
@@ -0,0 +1,71 @@
1
+ import { buildConnector } from 'undici';
2
+ import { type AddressClass, type AddressRefusal } from './address.js';
3
+ /**
4
+ * The network guard of a crawler. A crawler fetches URLs that come from the sites it reads
5
+ * (links, sitemaps, redirects), so a URL can point anywhere, including at the machine it runs
6
+ * on or its network: the cloud metadata address, a database on a private network, a service on
7
+ * the loopback. The guard closes that door at three points:
8
+ *
9
+ * 1. Before a request, the URL itself: scheme `http` or `https`, port 80 or 443, no user
10
+ * name or password, and an IP-literal host must be an allowed address. The URL is parsed by
11
+ * the WHATWG parser, which turns decimal, octal, hexadecimal and shorthand IPv4 forms
12
+ * (`2130706433`, `0177.0.0.1`, `0x7f.1`, `127.1`) into the dotted quad before the check.
13
+ * 2. At connection time, in the socket connector: the host name is resolved once, every
14
+ * address returned must be allowed (one refused address refuses the host), and the socket
15
+ * is opened to the address that was checked. Nothing resolves the name a second time, so a
16
+ * DNS answer that changes between check and connect (DNS rebinding) cannot slip through.
17
+ * 3. Every redirect hop is a new request, so 1 and 2 apply to each hop (done by the fetcher that uses this guard).
18
+ *
19
+ * The guard is not configurable in production code: `createNetworkGuard` always uses the
20
+ * strict rules. Tests reach a local fixture server through `createFixtureNetworkGuard` in the
21
+ * package's testing entry point (`@queryrev/ai-readiness/testing`), which allows exactly one loopback address and one port.
22
+ */
23
+ /** Ports the crawler connects to. */
24
+ export declare const CRAWL_PORTS: readonly number[];
25
+ /** Time allowed for one host-name lookup; past it the host counts as `dns_failed`. */
26
+ export declare const DNS_TIMEOUT_MS = 5000;
27
+ /** Resolves a host name to its IP addresses (all of them, as `dns.lookup` with `all`). */
28
+ export type HostResolver = (hostname: string) => Promise<readonly string[]>;
29
+ /** The operating system's resolver, as a connection would use it. */
30
+ export declare const systemResolver: HostResolver;
31
+ export type GuardErrorCode = 'refused_scheme' | 'refused_port' | 'refused_address' | 'dns_failed';
32
+ /** A request the guard refused, or a host name that did not resolve. The message is a code. */
33
+ export declare class NetworkGuardError extends Error {
34
+ readonly code: GuardErrorCode;
35
+ /** For `refused_address`: why the address is not allowed. */
36
+ readonly reason: AddressRefusal | null;
37
+ constructor(code: GuardErrorCode, reason?: AddressRefusal | null);
38
+ }
39
+ export interface NetworkGuard {
40
+ /** Checks a URL before it is requested. Throws NetworkGuardError when it is refused. */
41
+ checkUrl(url: URL): void;
42
+ /**
43
+ * Resolves a host (a name or an IP literal, brackets allowed) and checks every address.
44
+ * Returns the address to connect to. Throws NetworkGuardError.
45
+ */
46
+ vet(hostname: string, port: number): Promise<string>;
47
+ }
48
+ /** The rules a guard applies. Not exported from the package: production uses the strict set. */
49
+ export interface GuardRules {
50
+ readonly ports: readonly number[];
51
+ readonly classify: (address: string) => AddressClass;
52
+ }
53
+ export declare const STRICT_RULES: GuardRules;
54
+ export declare function guardWithRules(rules: GuardRules, resolver: HostResolver, dnsTimeoutMs?: number): NetworkGuard;
55
+ /** The production guard: ports 80 and 443, public addresses only. */
56
+ export declare function createNetworkGuard(options?: {
57
+ readonly resolver?: HostResolver;
58
+ readonly dnsTimeoutMs?: number;
59
+ }): NetworkGuard;
60
+ /**
61
+ * An undici socket connector that vets every new connection through the guard and connects to
62
+ * the vetted address. The `Host` header and the TLS server name still come from the URL (undici
63
+ * derives both from the origin, not from the address it connects to), so certificates are
64
+ * verified against the site's name.
65
+ */
66
+ export declare function guardedConnector(guard: NetworkGuard, connectTimeoutMs: number,
67
+ /**
68
+ * Extra trusted CA certificates. Only the testing entry point passes one (the fixture's
69
+ * self-signed certificate); production trusts Node's default CA store and nothing else.
70
+ */
71
+ testCa?: string): buildConnector.connector;
@@ -0,0 +1,15 @@
1
+ import { type TextFetcher } from '../fetch-text.js';
2
+ import type { HostResolver } from '../guard.js';
3
+ /**
4
+ * A text fetcher for tests against a local fixture server: the fixture guard (127.0.0.1 on one
5
+ * port only) and, unless a test shortens them, the production timings and politeness.
6
+ */
7
+ export declare function createFixtureTextFetcher(options: {
8
+ readonly port: number;
9
+ readonly resolver: HostResolver;
10
+ readonly connectTimeoutMs?: number;
11
+ readonly requestTimeoutMs?: number;
12
+ readonly hostIntervalMs?: number;
13
+ /** PEM of a fixture's self-signed certificate, to trust in this fetcher only. */
14
+ readonly ca?: string;
15
+ }): TextFetcher;
@@ -0,0 +1,20 @@
1
+ import { type HostResolver, type NetworkGuard } from '../guard.js';
2
+ /**
3
+ * A network guard for tests against the local fixture site. It allows exactly one address,
4
+ * 127.0.0.1, on exactly one port, the fixture's. Every other address is refused: with the
5
+ * strict rule's reason when the strict rules refuse it, and as `not_global` when they would
6
+ * allow it, so a test can never reach the public internet. Ports other than the fixture's are
7
+ * refused, 80 and 443 included.
8
+ *
9
+ * This lives in the testing entry point only; production code has no way to build a guard
10
+ * with other rules.
11
+ */
12
+ export declare const FIXTURE_ADDRESS = "127.0.0.1";
13
+ export declare function createFixtureNetworkGuard(options: {
14
+ readonly port: number;
15
+ readonly resolver: HostResolver;
16
+ }): NetworkGuard;
17
+ /** A resolver from a fixed table; unknown names fail as `ENOTFOUND` would. Counts lookups. */
18
+ export declare function tableResolver(table: Readonly<Record<string, readonly string[]>>): HostResolver & {
19
+ readonly lookups: string[];
20
+ };
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Test helpers: a guard that allows one loopback address and one port, a resolver from a fixed
3
+ * table, and a text fetcher built on them. For tests of code that fetches from a local fixture
4
+ * server only.
5
+ */
6
+ export { createFixtureNetworkGuard, FIXTURE_ADDRESS, tableResolver } from './fixture-guard.js';
7
+ export { createFixtureTextFetcher } from './fixture-fetcher.js';
@@ -0,0 +1 @@
1
+ export { t as classifyPrivateNetworkAddress, n as createCliTextFetcher, r as createPrivateNetworkGuard } from "./chunks/cli-BK65ic84.js";
@@ -0,0 +1,67 @@
1
+ /**
2
+ * robots.txt (RFC 9309). Parsing and matching are pure functions of text.
3
+ *
4
+ * Matching: the group whose user-agent equals the product token (case-insensitive) applies,
5
+ * all such groups merged; otherwise the `*` groups merged; otherwise nothing is disallowed.
6
+ * The longest matching rule wins, `allow` wins a tie. `*` matches any run of characters and a
7
+ * final `$` anchors the end. `/robots.txt` itself is always allowed.
8
+ */
9
+ /** Bytes of a robots.txt that are parsed (RFC 9309 asks for at least 500 KiB). */
10
+ export declare const ROBOTS_MAX_BYTES: number;
11
+ /** A robots.txt answer is used for at most this long (RFC 9309, section 2.4). */
12
+ export declare const ROBOTS_MAX_AGE_MS: number;
13
+ /** Redirects followed for robots.txt (RFC 9309 asks for at least five). */
14
+ export declare const ROBOTS_MAX_REDIRECTS = 5;
15
+ export interface RobotsRule {
16
+ readonly allow: boolean;
17
+ /** Pattern as written, percent-encoding normalised. */
18
+ readonly pattern: string;
19
+ /** The rule's line in the file, counted from 1. */
20
+ readonly line: number;
21
+ }
22
+ export interface RobotsGroup {
23
+ /** Product tokens of the group, lower case; `*` for the catch-all. */
24
+ readonly agents: readonly string[];
25
+ readonly rules: readonly RobotsRule[];
26
+ /** `Crawl-delay` in seconds when given; not part of the standard. */
27
+ readonly crawlDelay: number | null;
28
+ /** The line of the group's first `User-agent` line, counted from 1. */
29
+ readonly line: number;
30
+ }
31
+ export interface RobotsTxt {
32
+ readonly groups: readonly RobotsGroup[];
33
+ /** `Sitemap:` lines, absolute URLs as written (resolved against the robots.txt URL). */
34
+ readonly sitemaps: readonly string[];
35
+ }
36
+ /**
37
+ * Parses a robots.txt body. Never throws; unknown lines are ignored. A group is a run of
38
+ * user-agent lines and the rules after it; only an Allow or Disallow line ends the run, so a
39
+ * Sitemap, Crawl-delay or unknown line between two user-agent lines does not split the group.
40
+ */
41
+ export declare function parseRobots(body: string, robotsUrl?: string): RobotsTxt;
42
+ /**
43
+ * Which rule decides a path for a product token, and through which group.
44
+ *
45
+ * - `via`: `named` when a group names the token, `catch_all` when the `*` group applies, `none`
46
+ * when no group applies (everything is allowed).
47
+ * - `group`: the first applying group's line and agents (several groups that name the token
48
+ * are merged; the one that holds the deciding rule is given when a rule decides).
49
+ * - `rule`: the deciding rule, or null when no rule matches (allowed).
50
+ */
51
+ export interface RuleMatch {
52
+ readonly allowed: boolean;
53
+ readonly via: 'named' | 'catch_all' | 'none';
54
+ readonly group: {
55
+ readonly line: number;
56
+ readonly agents: readonly string[];
57
+ } | null;
58
+ readonly rule: RobotsRule | null;
59
+ }
60
+ export declare function matchRobots(robots: RobotsTxt, productToken: string, pathAndQuery: string): RuleMatch;
61
+ /**
62
+ * Whether robots.txt allows a URL path (path plus `?query`, as sent in the request line) for
63
+ * a product token.
64
+ */
65
+ export declare function isAllowedByRobots(robots: RobotsTxt, productToken: string, pathAndQuery: string): boolean;
66
+ /** `Crawl-delay` for a product token, in seconds; null when none is set. */
67
+ export declare function crawlDelayFor(robots: RobotsTxt, productToken: string): number | null;
package/dist/run.js ADDED
@@ -0,0 +1 @@
1
+ export { t as CLI_VERSION, n as runCli } from "./chunks/main-Bd0uzoHe.js";
@@ -0,0 +1,58 @@
1
+ import { C as classifyAddress, b as guardWithRules, f as buildTextFetcher } from "./chunks/fetch-text-NUUnM91O.js";
2
+ // src/net/testing/fixture-guard.ts
3
+ /**
4
+ * A network guard for tests against the local fixture site. It allows exactly one address,
5
+ * 127.0.0.1, on exactly one port, the fixture's. Every other address is refused: with the
6
+ * strict rule's reason when the strict rules refuse it, and as `not_global` when they would
7
+ * allow it, so a test can never reach the public internet. Ports other than the fixture's are
8
+ * refused, 80 and 443 included.
9
+ *
10
+ * This lives in the testing entry point only; production code has no way to build a guard
11
+ * with other rules.
12
+ */
13
+ var FIXTURE_ADDRESS = "127.0.0.1";
14
+ function createFixtureNetworkGuard(options) {
15
+ return guardWithRules({
16
+ ports: [options.port],
17
+ classify: (address) => {
18
+ if (address === "127.0.0.1") return {
19
+ allowed: true,
20
+ family: 4
21
+ };
22
+ const strict = classifyAddress(address);
23
+ return strict.allowed ? {
24
+ allowed: false,
25
+ reason: "not_global"
26
+ } : strict;
27
+ }
28
+ }, options.resolver);
29
+ }
30
+ /** A resolver from a fixed table; unknown names fail as `ENOTFOUND` would. Counts lookups. */
31
+ function tableResolver(table) {
32
+ const lookups = [];
33
+ const resolve = (hostname) => {
34
+ lookups.push(hostname);
35
+ const answer = table[hostname.toLowerCase()];
36
+ if (answer === void 0) return Promise.reject(/* @__PURE__ */ new Error("ENOTFOUND"));
37
+ return Promise.resolve(answer);
38
+ };
39
+ return Object.assign(resolve, { lookups });
40
+ }
41
+ // src/net/testing/fixture-fetcher.ts
42
+ /**
43
+ * A text fetcher for tests against a local fixture server: the fixture guard (127.0.0.1 on one
44
+ * port only) and, unless a test shortens them, the production timings and politeness.
45
+ */
46
+ function createFixtureTextFetcher(options) {
47
+ return buildTextFetcher({
48
+ guard: createFixtureNetworkGuard({
49
+ port: options.port,
50
+ resolver: options.resolver
51
+ }),
52
+ ...options.connectTimeoutMs === void 0 ? {} : { connectTimeoutMs: options.connectTimeoutMs },
53
+ ...options.requestTimeoutMs === void 0 ? {} : { requestTimeoutMs: options.requestTimeoutMs },
54
+ ...options.hostIntervalMs === void 0 ? {} : { hostIntervalMs: options.hostIntervalMs },
55
+ ...options.ca === void 0 ? {} : { testCa: options.ca }
56
+ });
57
+ }
58
+ export { FIXTURE_ADDRESS, createFixtureNetworkGuard, createFixtureTextFetcher, tableResolver };