@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.
- package/CITATION.cff +31 -0
- package/LICENSE +21 -0
- package/README.md +190 -2
- package/SECURITY.md +34 -0
- package/data/LICENSE +409 -0
- package/dist/chunks/cli-BK65ic84.js +56 -0
- package/dist/chunks/fetch-text-NUUnM91O.js +866 -0
- package/dist/chunks/findings-_twhMw3_.js +2950 -0
- package/dist/chunks/main-CJu6SE-W.js +1491 -0
- package/dist/cli/args.d.ts +27 -0
- package/dist/cli/bin.d.ts +2 -0
- package/dist/cli/commands/check.d.ts +4 -0
- package/dist/cli/commands/crawlers.d.ts +3 -0
- package/dist/cli/commands/llms-generate.d.ts +4 -0
- package/dist/cli/commands/llms-validate.d.ts +4 -0
- package/dist/cli/commands/robots-test.d.ts +4 -0
- package/dist/cli/common.d.ts +27 -0
- package/dist/cli/finding.d.ts +3 -0
- package/dist/cli/main.d.ts +7 -0
- package/dist/cli/node-context.d.ts +3 -0
- package/dist/cli/render.d.ts +26 -0
- package/dist/cli/run.d.ts +11 -0
- package/dist/cli/sanitize.d.ts +8 -0
- package/dist/cli/types.d.ts +59 -0
- package/dist/cli/usage.d.ts +1 -0
- package/dist/cli.js +36 -0
- package/dist/crawlers/index.d.ts +79 -0
- package/dist/facts/app-shell.d.ts +24 -0
- package/dist/facts/check-site.d.ts +13 -0
- package/dist/facts/domain.d.ts +15 -0
- package/dist/facts/findings.d.ts +5 -0
- package/dist/facts/home-page.d.ts +18 -0
- package/dist/facts/home-response.d.ts +5 -0
- package/dist/facts/html.d.ts +37 -0
- package/dist/facts/index.d.ts +16 -0
- package/dist/facts/json-ld.d.ts +35 -0
- package/dist/facts/marks.d.ts +51 -0
- package/dist/facts/organization.d.ts +23 -0
- package/dist/facts/origin-robots.d.ts +20 -0
- package/dist/facts/profiles.d.ts +13 -0
- package/dist/facts/reader.d.ts +2 -0
- package/dist/facts/schema-org-organization-types.d.ts +6 -0
- package/dist/facts/schema-org-types.d.ts +2 -0
- package/dist/facts/sitemap.d.ts +24 -0
- package/dist/facts/types.d.ts +214 -0
- package/dist/facts/words.d.ts +15 -0
- package/dist/generate/generate-llms.d.ts +51 -0
- package/dist/generate/index.d.ts +70 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +1023 -0
- package/dist/llms/findings.d.ts +7 -0
- package/dist/llms/index.d.ts +88 -0
- package/dist/llms/links.d.ts +46 -0
- package/dist/net/address.d.ts +26 -0
- package/dist/net/charset.d.ts +20 -0
- package/dist/net/cli.d.ts +13 -0
- package/dist/net/fetch-text.d.ts +191 -0
- package/dist/net/guard.d.ts +71 -0
- package/dist/net/testing/fixture-fetcher.d.ts +15 -0
- package/dist/net/testing/fixture-guard.d.ts +20 -0
- package/dist/net/testing/index.d.ts +7 -0
- package/dist/net-cli.js +1 -0
- package/dist/robots/index.d.ts +67 -0
- package/dist/run.js +1 -0
- package/dist/testing.js +58 -0
- package/package.json +80 -6
|
@@ -0,0 +1,1491 @@
|
|
|
1
|
+
import { $ as looksLikeHtml, F as sitemapLocations, I as stripUnsafeCharacters, M as attributes, N as pageMeta, P as renderLlmsTxt, X as registrableDomain, _t as parseRobots, a as robotsRead, ct as crawlerAccess, f as READER_TOKEN, g as xRobotsNoindex, gt as matchRobots, ht as isAllowedByRobots, i as checkSite, it as CRAWLERS, j as PAGE_SCAN_MAX_CHARS, lt as crawlersCoveredLine, mt as crawlDelayFor, n as llmsFinding, nt as validateLlmsTxt, r as llmsFindings } from "./findings-_twhMw3_.js";
|
|
2
|
+
import { a as GENERATE_BUDGET_MS, i as FETCH_LIMITS, m as createTextFetcher, n as CHECK_BUDGET_MS, p as checkTextUrl } from "./fetch-text-NUUnM91O.js";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { gunzipSync } from "node:zlib";
|
|
5
|
+
// src/cli/types.ts
|
|
6
|
+
/** A wrong command, option or input: exit code 2. */
|
|
7
|
+
var UsageError = class extends Error {
|
|
8
|
+
name = "UsageError";
|
|
9
|
+
};
|
|
10
|
+
// src/cli/args.ts
|
|
11
|
+
var COMMON = {
|
|
12
|
+
json: { type: "boolean" },
|
|
13
|
+
"no-color": { type: "boolean" },
|
|
14
|
+
help: {
|
|
15
|
+
type: "boolean",
|
|
16
|
+
short: "h"
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
var FAIL_ON = { "fail-on": { type: "string" } };
|
|
20
|
+
var PRIVATE = { "allow-private-network": { type: "boolean" } };
|
|
21
|
+
var COMMANDS = {
|
|
22
|
+
check: {
|
|
23
|
+
name: "check",
|
|
24
|
+
options: {
|
|
25
|
+
...COMMON,
|
|
26
|
+
...FAIL_ON,
|
|
27
|
+
...PRIVATE
|
|
28
|
+
},
|
|
29
|
+
positionals: {
|
|
30
|
+
min: 1,
|
|
31
|
+
max: 1,
|
|
32
|
+
label: "<url>"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"robots test": {
|
|
36
|
+
name: "robots test",
|
|
37
|
+
options: {
|
|
38
|
+
...COMMON,
|
|
39
|
+
...FAIL_ON,
|
|
40
|
+
...PRIVATE,
|
|
41
|
+
agent: { type: "string" },
|
|
42
|
+
path: { type: "string" }
|
|
43
|
+
},
|
|
44
|
+
positionals: {
|
|
45
|
+
min: 1,
|
|
46
|
+
max: 1,
|
|
47
|
+
label: "<file|url>"
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"llms validate": {
|
|
51
|
+
name: "llms validate",
|
|
52
|
+
options: {
|
|
53
|
+
...COMMON,
|
|
54
|
+
...FAIL_ON,
|
|
55
|
+
...PRIVATE,
|
|
56
|
+
links: { type: "boolean" },
|
|
57
|
+
base: { type: "string" }
|
|
58
|
+
},
|
|
59
|
+
positionals: {
|
|
60
|
+
min: 1,
|
|
61
|
+
max: 1,
|
|
62
|
+
label: "<file|url>"
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"llms generate": {
|
|
66
|
+
name: "llms generate",
|
|
67
|
+
options: {
|
|
68
|
+
...COMMON,
|
|
69
|
+
...PRIVATE,
|
|
70
|
+
"max-pages": { type: "string" },
|
|
71
|
+
out: { type: "string" },
|
|
72
|
+
force: { type: "boolean" }
|
|
73
|
+
},
|
|
74
|
+
positionals: {
|
|
75
|
+
min: 1,
|
|
76
|
+
max: 1,
|
|
77
|
+
label: "<url>"
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
crawlers: {
|
|
81
|
+
name: "crawlers",
|
|
82
|
+
options: { ...COMMON },
|
|
83
|
+
positionals: {
|
|
84
|
+
min: 0,
|
|
85
|
+
max: 0,
|
|
86
|
+
label: ""
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
/** The command words at the start of the arguments, and how many of them there are. */
|
|
91
|
+
function commandOf(argv) {
|
|
92
|
+
const first = argv[0];
|
|
93
|
+
if (first === void 0 || first.startsWith("-")) throw new UsageError("Give a command: check, robots test, llms validate, llms generate or crawlers.");
|
|
94
|
+
if (first === "robots" || first === "llms") {
|
|
95
|
+
const second = argv[1];
|
|
96
|
+
const name = `${first} ${second ?? ""}`;
|
|
97
|
+
if (second === void 0 || !(name in COMMANDS)) throw new UsageError(first === "robots" ? "\"robots\" needs a subcommand: robots test <file|url>." : "\"llms\" needs a subcommand: llms validate <file|url> or llms generate <url>.");
|
|
98
|
+
return {
|
|
99
|
+
name,
|
|
100
|
+
words: 2
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (first in COMMANDS) return {
|
|
104
|
+
name: first,
|
|
105
|
+
words: 1
|
|
106
|
+
};
|
|
107
|
+
throw new UsageError(`Unknown command "${first.slice(0, 40)}".`);
|
|
108
|
+
}
|
|
109
|
+
function parseCommand(argv, offersPrivateNetwork = true) {
|
|
110
|
+
const { name, words } = commandOf(argv);
|
|
111
|
+
const spec = COMMANDS[name];
|
|
112
|
+
const specOptions = offersPrivateNetwork ? spec.options : Object.fromEntries(Object.entries(spec.options).filter(([key]) => !(key in PRIVATE)));
|
|
113
|
+
let parsed;
|
|
114
|
+
try {
|
|
115
|
+
parsed = parseArgs({
|
|
116
|
+
args: [...argv.slice(words)],
|
|
117
|
+
options: Object.fromEntries(Object.entries(specOptions).map(([key, option]) => [key, option.short === void 0 ? { type: option.type } : {
|
|
118
|
+
type: option.type,
|
|
119
|
+
short: option.short
|
|
120
|
+
}])),
|
|
121
|
+
allowPositionals: true,
|
|
122
|
+
strict: true
|
|
123
|
+
});
|
|
124
|
+
} catch (error) {
|
|
125
|
+
throw new UsageError(error instanceof Error ? error.message.split("\n")[0].slice(0, 200) : "Invalid arguments.");
|
|
126
|
+
}
|
|
127
|
+
const count = parsed.positionals.length;
|
|
128
|
+
if (parsed.values["help"] === true) return {
|
|
129
|
+
command: name,
|
|
130
|
+
positionals: parsed.positionals,
|
|
131
|
+
values: parsed.values
|
|
132
|
+
};
|
|
133
|
+
if (count < spec.positionals.min) throw new UsageError(`"${name}" needs ${spec.positionals.label}.`);
|
|
134
|
+
if (count > spec.positionals.max) throw new UsageError(`"${name}" takes ${spec.positionals.max === 0 ? "no argument" : `only ${spec.positionals.label}`}.`);
|
|
135
|
+
return {
|
|
136
|
+
command: name,
|
|
137
|
+
positionals: parsed.positionals,
|
|
138
|
+
values: parsed.values
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function failOnOf(parsed) {
|
|
142
|
+
const value = parsed.values["fail-on"];
|
|
143
|
+
if (value === void 0) return "fail";
|
|
144
|
+
if (value === "fail" || value === "warn") return value;
|
|
145
|
+
throw new UsageError("--fail-on takes \"fail\" or \"warn\".");
|
|
146
|
+
}
|
|
147
|
+
var flag = (parsed, name) => parsed.values[name] === true;
|
|
148
|
+
var text = (parsed, name) => {
|
|
149
|
+
const value = parsed.values[name];
|
|
150
|
+
return typeof value === "string" ? value : void 0;
|
|
151
|
+
};
|
|
152
|
+
// src/cli/common.ts
|
|
153
|
+
/** 1 when a finding is at or above the threshold, else 0. */
|
|
154
|
+
function exitForFindings(findings, failOn) {
|
|
155
|
+
return findings.some((finding) => finding.level === "fail" || failOn === "warn" && finding.level === "warn") ? 1 : 0;
|
|
156
|
+
}
|
|
157
|
+
var OUTCOME_TEXT = {
|
|
158
|
+
refused_url: "the address is not one this tool will request",
|
|
159
|
+
refused_address: "the address resolves to a private or reserved network",
|
|
160
|
+
dns_failed: "the host name does not resolve",
|
|
161
|
+
too_many_redirects: "too many redirects",
|
|
162
|
+
redirect_loop: "a redirect loop",
|
|
163
|
+
invalid_redirect: "a redirect without a usable address",
|
|
164
|
+
redirect_refused: "a redirect to another site, which was not followed",
|
|
165
|
+
timeout: "the request timed out",
|
|
166
|
+
budget_exceeded: "the time limit of the run was reached",
|
|
167
|
+
host_stopped: "the host asked this tool to stop (429 or 503)",
|
|
168
|
+
network_error: "the connection failed"
|
|
169
|
+
};
|
|
170
|
+
/** Why a fetch gave no usable answer, in a few plain words. Empty for an answer. */
|
|
171
|
+
function describeFetchFailure(result, offersPrivateNetwork = true) {
|
|
172
|
+
if (result.outcome === "response") return result.status === null ? "no answer" : `status ${result.status}`;
|
|
173
|
+
const text = OUTCOME_TEXT[result.outcome] ?? "no answer";
|
|
174
|
+
return result.outcome === "refused_address" && offersPrivateNetwork ? `${text} (use --allow-private-network for a staging site)` : text;
|
|
175
|
+
}
|
|
176
|
+
/** A name that ends like a file is a file, even where the ending is also a country's domain. */
|
|
177
|
+
var FILE_NAME = /\.(?:txt|md|markdown)$/i;
|
|
178
|
+
var HOST_LIKE = /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(?:[/?#].*)?$/i;
|
|
179
|
+
/**
|
|
180
|
+
* Tells a file from a site: an http(s) address is a site, a name that is an existing file is a
|
|
181
|
+
* file, and a bare host name (`example.com`) is a site.
|
|
182
|
+
*/
|
|
183
|
+
async function classifySource(context, input, maxBytes) {
|
|
184
|
+
if (/^https?:\/\//i.test(input)) return {
|
|
185
|
+
source: {
|
|
186
|
+
kind: "url",
|
|
187
|
+
location: input
|
|
188
|
+
},
|
|
189
|
+
file: null
|
|
190
|
+
};
|
|
191
|
+
try {
|
|
192
|
+
const file = await context.readFile(input, maxBytes);
|
|
193
|
+
return {
|
|
194
|
+
source: {
|
|
195
|
+
kind: "file",
|
|
196
|
+
location: input
|
|
197
|
+
},
|
|
198
|
+
file
|
|
199
|
+
};
|
|
200
|
+
} catch (error) {
|
|
201
|
+
const code = error.code;
|
|
202
|
+
if (code === "ENOENT" && HOST_LIKE.test(input) && !FILE_NAME.test(input)) return {
|
|
203
|
+
source: {
|
|
204
|
+
kind: "url",
|
|
205
|
+
location: `https://${input}`
|
|
206
|
+
},
|
|
207
|
+
file: null
|
|
208
|
+
};
|
|
209
|
+
if (code === "ENOENT") throw new UsageError(`No such file: ${input.slice(0, 200)}`);
|
|
210
|
+
if (code === "EISDIR") throw new UsageError(`${input.slice(0, 200)} is a directory.`);
|
|
211
|
+
if (code === "EACCES" || code === "EPERM") throw new UsageError(`Cannot read ${input.slice(0, 200)}: permission denied.`);
|
|
212
|
+
throw new UsageError(`Cannot read ${input.slice(0, 200)}.`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** The address of a well-known file on a site: the given address, or `/name` at the site's root. */
|
|
216
|
+
function fileAddress(input, name) {
|
|
217
|
+
let url;
|
|
218
|
+
try {
|
|
219
|
+
url = new URL(input);
|
|
220
|
+
} catch {
|
|
221
|
+
throw new UsageError(`Not a web address: ${input.slice(0, 200)}`);
|
|
222
|
+
}
|
|
223
|
+
if (url.pathname === "" || url.pathname === "/") {
|
|
224
|
+
url.pathname = `/${name}`;
|
|
225
|
+
url.search = "";
|
|
226
|
+
}
|
|
227
|
+
url.hash = "";
|
|
228
|
+
return url.href;
|
|
229
|
+
}
|
|
230
|
+
var ROBOTS_READ_LIMIT = FETCH_LIMITS.robots;
|
|
231
|
+
var LLMS_READ_LIMIT = FETCH_LIMITS.llms;
|
|
232
|
+
/** The text of a file read from disk; a byte order mark is left for the parsers to handle. */
|
|
233
|
+
function decodeFile(bytes) {
|
|
234
|
+
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
|
235
|
+
}
|
|
236
|
+
// src/cli/sanitize.ts
|
|
237
|
+
/**
|
|
238
|
+
* Text that came from a site (a title, a heading, a line of its robots.txt) can carry terminal
|
|
239
|
+
* escape sequences and characters that reorder how text is shown. Every string of a result goes
|
|
240
|
+
* through here before it is printed or written as JSON.
|
|
241
|
+
*/
|
|
242
|
+
function sanitizeDeep(value) {
|
|
243
|
+
if (typeof value === "string") return stripUnsafeCharacters(value);
|
|
244
|
+
if (Array.isArray(value)) return value.map((item) => sanitizeDeep(item));
|
|
245
|
+
if (value !== null && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitizeDeep(item)]));
|
|
246
|
+
return value;
|
|
247
|
+
}
|
|
248
|
+
/** One line: whitespace runs, line breaks included, become one space. */
|
|
249
|
+
function oneLine(text) {
|
|
250
|
+
return stripUnsafeCharacters(text).replace(/\s+/g, " ").trim();
|
|
251
|
+
}
|
|
252
|
+
// src/cli/render.ts
|
|
253
|
+
/** The "full report" link every human report ends with. It is printed, never opened. */
|
|
254
|
+
function fullReportUrl(command) {
|
|
255
|
+
return `https://queryrev.com/tools?utm_source=ai-readiness-cli&utm_medium=cli&utm_campaign=full-report&utm_content=${command}`;
|
|
256
|
+
}
|
|
257
|
+
/** What this tool cannot see, said once in the same words wherever it matters. */
|
|
258
|
+
var CANNOT_SEE = "Not seen: a CDN or firewall rule can answer AI crawlers differently from how it answers this tool.";
|
|
259
|
+
var CODES = {
|
|
260
|
+
pass: "32",
|
|
261
|
+
warn: "33",
|
|
262
|
+
fail: "31",
|
|
263
|
+
info: "36"
|
|
264
|
+
};
|
|
265
|
+
/** Colour only on a terminal, and never when NO_COLOR is set or `--no-color` is given. */
|
|
266
|
+
function makeStyle(options) {
|
|
267
|
+
const noColor = options.env["NO_COLOR"] !== void 0 && options.env["NO_COLOR"] !== "";
|
|
268
|
+
const on = options.isTTY && !noColor && !options.noColorFlag && options.env["TERM"] !== "dumb";
|
|
269
|
+
const wrap = (code, text) => on ? `\u001b[${code}m${text}\u001b[0m` : text;
|
|
270
|
+
return {
|
|
271
|
+
bold: (text) => wrap("1", text),
|
|
272
|
+
dim: (text) => wrap("2", text),
|
|
273
|
+
level: (level, text) => wrap(CODES[level], text)
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
var LABEL = {
|
|
277
|
+
pass: "PASS",
|
|
278
|
+
warn: "WARN",
|
|
279
|
+
fail: "FAIL",
|
|
280
|
+
info: "INFO"
|
|
281
|
+
};
|
|
282
|
+
function summarize(findings) {
|
|
283
|
+
const counts = {
|
|
284
|
+
pass: 0,
|
|
285
|
+
warn: 0,
|
|
286
|
+
fail: 0,
|
|
287
|
+
info: 0
|
|
288
|
+
};
|
|
289
|
+
for (const finding of findings) counts[finding.level] += 1;
|
|
290
|
+
return counts;
|
|
291
|
+
}
|
|
292
|
+
/** The findings as lines: a label, the id, the sentence, and the evidence under it. */
|
|
293
|
+
function renderFindings(findings, style) {
|
|
294
|
+
const idWidth = Math.min(30, Math.max(0, ...findings.map((finding) => finding.id.length)));
|
|
295
|
+
const lines = [];
|
|
296
|
+
for (const finding of findings) {
|
|
297
|
+
lines.push(` ${style.level(finding.level, LABEL[finding.level])} ${finding.id.padEnd(idWidth)} ${oneLine(finding.message)}`);
|
|
298
|
+
if (finding.snippet !== void 0) for (const line of finding.snippet.split(/\r\n|\r|\n/)) {
|
|
299
|
+
if (line.trim() === "") continue;
|
|
300
|
+
lines.push(` ${style.dim("|")} ${style.dim(line.trim())}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return lines.join("\n");
|
|
304
|
+
}
|
|
305
|
+
function renderSummary(findings) {
|
|
306
|
+
const counts = summarize(findings);
|
|
307
|
+
return `${counts.pass} pass, ${counts.warn} warn, ${counts.fail} fail, ${counts.info} info`;
|
|
308
|
+
}
|
|
309
|
+
/** The covered crawlers as a table: token, vendor, purpose. */
|
|
310
|
+
function renderCrawlerList(style) {
|
|
311
|
+
const width = Math.max(...CRAWLERS.map((crawler) => crawler.token.length));
|
|
312
|
+
const vendorWidth = Math.max(...CRAWLERS.map((crawler) => crawler.vendor.length));
|
|
313
|
+
return [` ${style.bold("Crawler".padEnd(width))} ${style.bold("Vendor".padEnd(vendorWidth))} ${style.bold("Purpose")}`, ...CRAWLERS.map((crawler) => ` ${crawler.token.padEnd(width)} ${crawler.vendor.padEnd(vendorWidth)} ${crawler.purpose}`)].join("\n");
|
|
314
|
+
}
|
|
315
|
+
/** A table with a header row; cells are padded to the widest entry of their column. */
|
|
316
|
+
function renderTable(header, rows, style) {
|
|
317
|
+
const widths = header.map((cell, column) => Math.max(cell.length, ...rows.map((row) => (row[column] ?? "").length)));
|
|
318
|
+
const line = (cells, bold) => ` ${cells.map((cell, column) => {
|
|
319
|
+
const padded = column === cells.length - 1 ? cell : cell.padEnd(widths[column] ?? 0);
|
|
320
|
+
return bold ? style.bold(padded) : padded;
|
|
321
|
+
}).join(" ")}`;
|
|
322
|
+
return [line(header, true), ...rows.map((row) => line(row, false))].join("\n");
|
|
323
|
+
}
|
|
324
|
+
// src/cli/commands/check.ts
|
|
325
|
+
var PRIVATE_NETWORK_HINT = "A staging site on a private network is reached with --allow-private-network.";
|
|
326
|
+
/** A site that gave no robots.txt answer to read, or an address that cannot be checked. */
|
|
327
|
+
function unreadable(site) {
|
|
328
|
+
if (site.findings.some((finding) => finding.id === "site-refused")) return site.findings[0]?.snippet ?? "the address cannot be checked";
|
|
329
|
+
if (site.robots.outcome === "not_read") return site.robots.status === null ? "no answer to the request for robots.txt" : `robots.txt answered ${site.robots.status}`;
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
async function runCheck(context, parsed, style) {
|
|
333
|
+
const failOn = failOnOf(parsed);
|
|
334
|
+
const fetcher = context.createFetcher({ allowPrivateNetwork: flag(parsed, "allow-private-network") });
|
|
335
|
+
let raw;
|
|
336
|
+
try {
|
|
337
|
+
raw = await checkSite(parsed.positionals[0], { fetcher });
|
|
338
|
+
} finally {
|
|
339
|
+
await fetcher.close();
|
|
340
|
+
}
|
|
341
|
+
const site = sanitizeDeep(raw);
|
|
342
|
+
const why = unreadable(site);
|
|
343
|
+
const exit = why !== null ? 3 : exitForFindings(site.findings, failOn);
|
|
344
|
+
const lines = [style.bold(`AI readiness check: ${site.origin === "" ? parsed.positionals[0] : site.origin}`), ""];
|
|
345
|
+
if (site.crawlerAccess.length > 0) {
|
|
346
|
+
lines.push(style.bold("Crawler access (robots.txt, whole site)"));
|
|
347
|
+
lines.push(renderTable([
|
|
348
|
+
"Crawler",
|
|
349
|
+
"Purpose",
|
|
350
|
+
"Access"
|
|
351
|
+
], site.crawlerAccess.map((entry) => [
|
|
352
|
+
entry.token,
|
|
353
|
+
entry.purpose,
|
|
354
|
+
entry.access === "partial" ? "partial (some paths closed)" : entry.access
|
|
355
|
+
]), style));
|
|
356
|
+
lines.push("");
|
|
357
|
+
}
|
|
358
|
+
lines.push(style.bold("Findings"));
|
|
359
|
+
lines.push(renderFindings(site.findings, style));
|
|
360
|
+
lines.push("");
|
|
361
|
+
lines.push(`${renderSummary(site.findings)}`);
|
|
362
|
+
if (why !== null) {
|
|
363
|
+
lines.push("", `The site could not be read: ${why}. Exit code 3.`);
|
|
364
|
+
if (site.robots.status === null && context.offersPrivateNetwork !== false && !flag(parsed, "allow-private-network")) lines.push(PRIVATE_NETWORK_HINT);
|
|
365
|
+
}
|
|
366
|
+
lines.push("", site.crawlersCovered, CANNOT_SEE, "", `Full report: ${fullReportUrl("check")}`);
|
|
367
|
+
return {
|
|
368
|
+
exit,
|
|
369
|
+
json: {
|
|
370
|
+
schemaVersion: 1,
|
|
371
|
+
command: "check",
|
|
372
|
+
exitCode: exit,
|
|
373
|
+
failOn,
|
|
374
|
+
summary: summarize(site.findings),
|
|
375
|
+
...why === null ? {} : { error: {
|
|
376
|
+
code: "unreadable",
|
|
377
|
+
message: why
|
|
378
|
+
} },
|
|
379
|
+
site
|
|
380
|
+
},
|
|
381
|
+
text: `${lines.join("\n")}\n`
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
// src/cli/commands/crawlers.ts
|
|
385
|
+
function runCrawlers(style) {
|
|
386
|
+
return {
|
|
387
|
+
exit: 0,
|
|
388
|
+
json: {
|
|
389
|
+
schemaVersion: 1,
|
|
390
|
+
command: "crawlers",
|
|
391
|
+
exitCode: 0,
|
|
392
|
+
crawlersCovered: crawlersCoveredLine(),
|
|
393
|
+
crawlers: CRAWLERS.map((crawler) => ({
|
|
394
|
+
token: crawler.token,
|
|
395
|
+
vendor: crawler.vendor,
|
|
396
|
+
purpose: crawler.purpose
|
|
397
|
+
}))
|
|
398
|
+
},
|
|
399
|
+
text: `${renderCrawlerList(style)}\n\n${crawlersCoveredLine()}\n`
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
// src/facts/origin-robots.ts
|
|
403
|
+
/**
|
|
404
|
+
* robots.txt per origin. An address on a subdomain, or on the other scheme, is a different
|
|
405
|
+
* origin with its own robots.txt: before this tool requests anything there, it reads that
|
|
406
|
+
* origin's file once and asks whether its own token may fetch the path. Answers are kept for
|
|
407
|
+
* the run.
|
|
408
|
+
*/
|
|
409
|
+
function createOriginRobots(options) {
|
|
410
|
+
const known = /* @__PURE__ */ new Map();
|
|
411
|
+
const robotsOf = (origin) => {
|
|
412
|
+
const cached = known.get(origin);
|
|
413
|
+
if (cached !== void 0) return cached;
|
|
414
|
+
const url = `${origin}/robots.txt`;
|
|
415
|
+
const read = options.fetcher.fetchText(url, {
|
|
416
|
+
kind: "robots",
|
|
417
|
+
...options.signal === void 0 ? {} : { signal: options.signal },
|
|
418
|
+
...options.redirectAllowed === void 0 ? {} : { redirectAllowed: options.redirectAllowed }
|
|
419
|
+
}).then((result) => {
|
|
420
|
+
const { robots } = robotsRead(result, url);
|
|
421
|
+
if (robots !== null) {
|
|
422
|
+
const delay = crawlDelayFor(robots, READER_TOKEN);
|
|
423
|
+
options.fetcher.setCrawlDelay(new URL(origin).hostname, delay === null ? null : Math.min(delay, 10));
|
|
424
|
+
}
|
|
425
|
+
return robots;
|
|
426
|
+
});
|
|
427
|
+
known.set(origin, read);
|
|
428
|
+
return read;
|
|
429
|
+
};
|
|
430
|
+
return {
|
|
431
|
+
robotsOf,
|
|
432
|
+
async check(url) {
|
|
433
|
+
let parsed;
|
|
434
|
+
try {
|
|
435
|
+
parsed = new URL(url);
|
|
436
|
+
} catch {
|
|
437
|
+
return "closed";
|
|
438
|
+
}
|
|
439
|
+
const robots = await robotsOf(parsed.origin);
|
|
440
|
+
if (robots === null) return "unreadable";
|
|
441
|
+
return isAllowedByRobots(robots, "QueryRev-AIReadiness", `${parsed.pathname}${parsed.search}`) ? "allowed" : "closed";
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
/** Bytes of each page that are read: the part `pageMeta` looks at. */
|
|
446
|
+
var GENERATE_PAGE_BYTES = PAGE_SCAN_MAX_CHARS;
|
|
447
|
+
/** Addresses taken from the home page when the site lists no sitemap. */
|
|
448
|
+
var HOME_LINKS_MAX = 500;
|
|
449
|
+
/** Pages requested per page listed, at most: skipped pages cost requests too. */
|
|
450
|
+
var ATTEMPT_FACTOR = 2;
|
|
451
|
+
var ASSET_EXTENSION = /\.(?:jpe?g|png|gif|svg|webp|avif|ico|pdf|zip|gz|xml|json|css|m?js|txt|mp[34]|webm|woff2?|ttf|otf|docx?|xlsx?|pptx?|csv|rss|atom)$/i;
|
|
452
|
+
/** The addresses of the `<a href>` links of a page, resolved against `base`; at most `max`. */
|
|
453
|
+
function homeLinks(html, base, max = HOME_LINKS_MAX) {
|
|
454
|
+
const text = html.length > 262144 ? html.slice(0, PAGE_SCAN_MAX_CHARS) : html;
|
|
455
|
+
const out = [];
|
|
456
|
+
const seen = /* @__PURE__ */ new Set();
|
|
457
|
+
let at = 0;
|
|
458
|
+
while (out.length < max) {
|
|
459
|
+
const open = text.indexOf("<a", at);
|
|
460
|
+
if (open === -1) break;
|
|
461
|
+
const next = text[open + 2];
|
|
462
|
+
if (next === void 0 || !(next === " " || next === "\n" || next === " " || next === "\r")) {
|
|
463
|
+
at = open + 2;
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
const end = text.indexOf(">", open + 2);
|
|
467
|
+
if (end === -1) break;
|
|
468
|
+
at = end + 1;
|
|
469
|
+
const href = attributes(text.slice(open + 2, end)).get("href");
|
|
470
|
+
if (href === void 0 || href.trim() === "") continue;
|
|
471
|
+
let url;
|
|
472
|
+
try {
|
|
473
|
+
url = new URL(stripUnsafeCharacters(href.trim()), base);
|
|
474
|
+
} catch {
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") continue;
|
|
478
|
+
url.hash = "";
|
|
479
|
+
if (!seen.has(url.href)) {
|
|
480
|
+
seen.add(url.href);
|
|
481
|
+
out.push(url.href);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return out;
|
|
485
|
+
}
|
|
486
|
+
function sitemapText(rawBody, text) {
|
|
487
|
+
if (rawBody !== null && rawBody.length > 2 && rawBody[0] === 31 && rawBody[1] === 139) try {
|
|
488
|
+
return gunzipSync(rawBody, { maxOutputLength: FETCH_LIMITS.sitemap }).toString("utf8");
|
|
489
|
+
} catch {
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
return text;
|
|
493
|
+
}
|
|
494
|
+
function clampPages(value) {
|
|
495
|
+
if (value === void 0 || !Number.isFinite(value)) return 30;
|
|
496
|
+
return Math.min(100, Math.max(1, Math.floor(value)));
|
|
497
|
+
}
|
|
498
|
+
function empty(outcome, origin, host, notes) {
|
|
499
|
+
return Object.freeze({
|
|
500
|
+
schemaVersion: 1,
|
|
501
|
+
outcome,
|
|
502
|
+
origin,
|
|
503
|
+
host,
|
|
504
|
+
text: null,
|
|
505
|
+
site: null,
|
|
506
|
+
pages: Object.freeze([]),
|
|
507
|
+
source: "none",
|
|
508
|
+
skipped: Object.freeze([]),
|
|
509
|
+
budgetReached: false,
|
|
510
|
+
notes: Object.freeze([...notes])
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
async function generateLlmsTxt(input, options = {}) {
|
|
514
|
+
const registrable = options.registrableDomain ?? registrableDomain;
|
|
515
|
+
const typed = input.trim();
|
|
516
|
+
const checked = checkTextUrl(/^[a-z][a-z0-9+.-]*:\/\//i.test(typed) ? typed : `https://${typed}`);
|
|
517
|
+
if ("refusal" in checked) return empty("refused", "", "", ["The address cannot be checked: it must be a web site name or an http(s) address."]);
|
|
518
|
+
const origin = checked.url.origin;
|
|
519
|
+
const host = checked.url.hostname;
|
|
520
|
+
const siteDomain = registrable(host);
|
|
521
|
+
const maxPages = clampPages(options.maxPages);
|
|
522
|
+
const owned = options.fetcher === void 0;
|
|
523
|
+
const fetcher = options.fetcher ?? createTextFetcher();
|
|
524
|
+
const budget = AbortSignal.timeout(GENERATE_BUDGET_MS);
|
|
525
|
+
const signal = options.signal === void 0 ? budget : AbortSignal.any([options.signal, budget]);
|
|
526
|
+
const sameSite = (target) => registrable(target.hostname) === siteDomain;
|
|
527
|
+
const notes = [];
|
|
528
|
+
const skipped = [];
|
|
529
|
+
try {
|
|
530
|
+
const origins = createOriginRobots({
|
|
531
|
+
fetcher,
|
|
532
|
+
signal,
|
|
533
|
+
redirectAllowed: sameSite
|
|
534
|
+
});
|
|
535
|
+
const robots = await origins.robotsOf(origin);
|
|
536
|
+
if (robots === null) return empty("robots_unreadable", origin, host, ["robots.txt could not be read, so nothing else was requested."]);
|
|
537
|
+
const allowed = async (url) => await origins.check(url) === "allowed";
|
|
538
|
+
let site = {
|
|
539
|
+
name: host,
|
|
540
|
+
summary: null
|
|
541
|
+
};
|
|
542
|
+
const pages = [];
|
|
543
|
+
const listed = /* @__PURE__ */ new Set();
|
|
544
|
+
let homeHtml = null;
|
|
545
|
+
if (await allowed(`${origin}/`)) {
|
|
546
|
+
const page = readPage(await fetcher.fetchText(`${origin}/`, {
|
|
547
|
+
kind: "page",
|
|
548
|
+
maxBytes: GENERATE_PAGE_BYTES,
|
|
549
|
+
signal,
|
|
550
|
+
redirectAllowed: sameSite
|
|
551
|
+
}), sameSite);
|
|
552
|
+
if (page.kind === "page") {
|
|
553
|
+
homeHtml = page.html;
|
|
554
|
+
const meta = pageMeta(page.html);
|
|
555
|
+
site = {
|
|
556
|
+
name: meta.title ?? meta.h1 ?? host,
|
|
557
|
+
summary: meta.description
|
|
558
|
+
};
|
|
559
|
+
if (!meta.noindex && !page.noindex) {
|
|
560
|
+
pages.push({
|
|
561
|
+
url: page.finalUrl,
|
|
562
|
+
title: meta.title ?? meta.h1 ?? host,
|
|
563
|
+
description: meta.description
|
|
564
|
+
});
|
|
565
|
+
listed.add(page.finalUrl);
|
|
566
|
+
}
|
|
567
|
+
} else notes.push("The home page could not be read, so the site is named by its host.");
|
|
568
|
+
} else notes.push("robots.txt keeps this reader from the home page, so it was not requested.");
|
|
569
|
+
let source = "none";
|
|
570
|
+
let candidates = [];
|
|
571
|
+
const fromSitemaps = await sitemapAddresses(fetcher, robots, allowed, sameSite, signal, notes);
|
|
572
|
+
if (fromSitemaps.length > 0) {
|
|
573
|
+
source = "sitemap";
|
|
574
|
+
candidates = fromSitemaps;
|
|
575
|
+
} else if (homeHtml !== null) {
|
|
576
|
+
source = "home_links";
|
|
577
|
+
candidates = homeLinks(homeHtml, `${origin}/`);
|
|
578
|
+
notes.push("No usable sitemap was found, so the links on the home page were used.");
|
|
579
|
+
}
|
|
580
|
+
candidates = candidates.filter((url) => {
|
|
581
|
+
try {
|
|
582
|
+
const parsed = new URL(url);
|
|
583
|
+
return (parsed.protocol === "http:" || parsed.protocol === "https:") && sameSite(parsed) && !ASSET_EXTENSION.test(parsed.pathname);
|
|
584
|
+
} catch {
|
|
585
|
+
return false;
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
let attempts = 0;
|
|
589
|
+
const attemptLimit = Math.min(200, maxPages * ATTEMPT_FACTOR);
|
|
590
|
+
let budgetReached = false;
|
|
591
|
+
for (const candidate of candidates) {
|
|
592
|
+
if (pages.length >= maxPages || attempts >= attemptLimit) break;
|
|
593
|
+
if (signal.aborted) {
|
|
594
|
+
budgetReached = true;
|
|
595
|
+
break;
|
|
596
|
+
}
|
|
597
|
+
if (listed.has(candidate)) continue;
|
|
598
|
+
if (!await allowed(candidate)) {
|
|
599
|
+
skipped.push({
|
|
600
|
+
url: candidate,
|
|
601
|
+
reason: "robots"
|
|
602
|
+
});
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
attempts += 1;
|
|
606
|
+
const result = await fetcher.fetchText(candidate, {
|
|
607
|
+
kind: "page",
|
|
608
|
+
maxBytes: GENERATE_PAGE_BYTES,
|
|
609
|
+
signal,
|
|
610
|
+
redirectAllowed: sameSite
|
|
611
|
+
});
|
|
612
|
+
if (result.outcome === "budget_exceeded") {
|
|
613
|
+
budgetReached = true;
|
|
614
|
+
break;
|
|
615
|
+
}
|
|
616
|
+
const page = readPage(result, sameSite);
|
|
617
|
+
if (page.kind !== "page") {
|
|
618
|
+
skipped.push({
|
|
619
|
+
url: candidate,
|
|
620
|
+
reason: page.kind
|
|
621
|
+
});
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
if (listed.has(page.finalUrl)) {
|
|
625
|
+
skipped.push({
|
|
626
|
+
url: candidate,
|
|
627
|
+
reason: "duplicate"
|
|
628
|
+
});
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
const meta = pageMeta(page.html);
|
|
632
|
+
if (meta.noindex || page.noindex) {
|
|
633
|
+
skipped.push({
|
|
634
|
+
url: candidate,
|
|
635
|
+
reason: "noindex"
|
|
636
|
+
});
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
listed.add(page.finalUrl);
|
|
640
|
+
pages.push({
|
|
641
|
+
url: page.finalUrl,
|
|
642
|
+
title: meta.title ?? meta.h1 ?? "",
|
|
643
|
+
description: meta.description
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
if (budgetReached) notes.push("The time limit of 120 seconds was reached; the file lists the pages read so far.");
|
|
647
|
+
if (pages.length === 0) return Object.freeze({
|
|
648
|
+
...empty("nothing_found", origin, host, [...notes, "No page could be read from this site."]),
|
|
649
|
+
skipped: Object.freeze(skipped),
|
|
650
|
+
budgetReached
|
|
651
|
+
});
|
|
652
|
+
return Object.freeze({
|
|
653
|
+
schemaVersion: 1,
|
|
654
|
+
outcome: "generated",
|
|
655
|
+
origin,
|
|
656
|
+
host,
|
|
657
|
+
text: renderLlmsTxt(site, pages),
|
|
658
|
+
site,
|
|
659
|
+
pages: Object.freeze(pages),
|
|
660
|
+
source,
|
|
661
|
+
skipped: Object.freeze(skipped),
|
|
662
|
+
budgetReached,
|
|
663
|
+
notes: Object.freeze(notes)
|
|
664
|
+
});
|
|
665
|
+
} finally {
|
|
666
|
+
if (owned) await fetcher.close();
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
function readPage(result, sameSite) {
|
|
670
|
+
if (result.outcome === "redirect_refused") return { kind: "off_site" };
|
|
671
|
+
if (result.outcome !== "response" || result.status === null) return { kind: "unreachable" };
|
|
672
|
+
if (result.status !== 200) return { kind: "status" };
|
|
673
|
+
if (result.text === null || result.contentType === null || !(result.contentType === "text/html" || result.contentType === "application/xhtml+xml")) return { kind: "not_html" };
|
|
674
|
+
let final;
|
|
675
|
+
try {
|
|
676
|
+
final = new URL(result.finalUrl);
|
|
677
|
+
} catch {
|
|
678
|
+
return { kind: "unreachable" };
|
|
679
|
+
}
|
|
680
|
+
if (!sameSite(final)) return { kind: "off_site" };
|
|
681
|
+
final.hash = "";
|
|
682
|
+
return {
|
|
683
|
+
kind: "page",
|
|
684
|
+
html: result.text,
|
|
685
|
+
finalUrl: final.href,
|
|
686
|
+
noindex: xRobotsNoindex(result.headers["x-robots-tag"])
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
/** The page addresses of the site's sitemaps; empty when there are none to read. */
|
|
690
|
+
async function sitemapAddresses(fetcher, robots, allowed, sameSite, signal, notes) {
|
|
691
|
+
const queue = [];
|
|
692
|
+
for (const line of robots.sitemaps) try {
|
|
693
|
+
const url = new URL(line);
|
|
694
|
+
if ((url.protocol === "http:" || url.protocol === "https:") && sameSite(url)) queue.push(url.href);
|
|
695
|
+
} catch {}
|
|
696
|
+
const pages = [];
|
|
697
|
+
const read = /* @__PURE__ */ new Set();
|
|
698
|
+
let files = 0;
|
|
699
|
+
while (queue.length > 0 && files < 5 && !signal.aborted) {
|
|
700
|
+
const url = queue.shift();
|
|
701
|
+
if (read.has(url)) continue;
|
|
702
|
+
read.add(url);
|
|
703
|
+
if (!await allowed(url)) continue;
|
|
704
|
+
files += 1;
|
|
705
|
+
const result = await fetcher.fetchText(url, {
|
|
706
|
+
kind: "sitemap",
|
|
707
|
+
signal,
|
|
708
|
+
redirectAllowed: sameSite
|
|
709
|
+
});
|
|
710
|
+
if (result.outcome !== "response" || result.status === null || result.status < 200 || result.status >= 300) continue;
|
|
711
|
+
const body = sitemapText(result.rawBody, result.text);
|
|
712
|
+
if (body === null) continue;
|
|
713
|
+
const found = sitemapLocations(body, 5e3);
|
|
714
|
+
if (found.kind === "index") for (const location of found.locations) try {
|
|
715
|
+
const child = new URL(location);
|
|
716
|
+
if (sameSite(child)) queue.push(child.href);
|
|
717
|
+
} catch {}
|
|
718
|
+
else if (found.kind === "urlset") pages.push(...found.locations);
|
|
719
|
+
}
|
|
720
|
+
if (queue.length > 0 && files >= 5) notes.push(`Only the first 5 sitemap files were read.`);
|
|
721
|
+
return pages;
|
|
722
|
+
}
|
|
723
|
+
// src/cli/commands/llms-generate.ts
|
|
724
|
+
function pagesOption(value) {
|
|
725
|
+
if (value === void 0) return 30;
|
|
726
|
+
if (!/^\d{1,4}$/.test(value)) throw new UsageError(`--max-pages takes a whole number from 1 to 100.`);
|
|
727
|
+
const count = Number(value);
|
|
728
|
+
if (count < 1 || count > 100) throw new UsageError(`--max-pages takes a whole number from 1 to 100.`);
|
|
729
|
+
return count;
|
|
730
|
+
}
|
|
731
|
+
var SOURCE_TEXT = {
|
|
732
|
+
sitemap: "the sitemap",
|
|
733
|
+
home_links: "the links on the home page",
|
|
734
|
+
none: "no source"
|
|
735
|
+
};
|
|
736
|
+
var SKIPPED_TEXT = {
|
|
737
|
+
robots: "closed to this reader by robots.txt",
|
|
738
|
+
noindex: "marked noindex",
|
|
739
|
+
not_html: "not an HTML page",
|
|
740
|
+
status: "not a 200 answer",
|
|
741
|
+
off_site: "redirected to another site",
|
|
742
|
+
unreachable: "no answer",
|
|
743
|
+
duplicate: "the same page as one listed"
|
|
744
|
+
};
|
|
745
|
+
function skippedSummary(result) {
|
|
746
|
+
const counts = /* @__PURE__ */ new Map();
|
|
747
|
+
for (const entry of result.skipped) counts.set(entry.reason, (counts.get(entry.reason) ?? 0) + 1);
|
|
748
|
+
return [...counts].map(([reason, count]) => `${count} ${SKIPPED_TEXT[reason]}`);
|
|
749
|
+
}
|
|
750
|
+
async function runLlmsGenerate(context, parsed, style) {
|
|
751
|
+
const maxPages = pagesOption(text(parsed, "max-pages"));
|
|
752
|
+
const out = text(parsed, "out");
|
|
753
|
+
const force = flag(parsed, "force");
|
|
754
|
+
if (out !== void 0) {
|
|
755
|
+
if (out === "") throw new UsageError("--out needs a file name.");
|
|
756
|
+
if (!force) {
|
|
757
|
+
let exists = true;
|
|
758
|
+
try {
|
|
759
|
+
await context.readFile(out, 1);
|
|
760
|
+
} catch (error) {
|
|
761
|
+
exists = error.code !== "ENOENT";
|
|
762
|
+
}
|
|
763
|
+
if (exists) throw new UsageError(`${out.slice(0, 200)} already exists (or cannot be checked). Use --force to replace it.`);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
const fetcher = context.createFetcher({ allowPrivateNetwork: flag(parsed, "allow-private-network") });
|
|
767
|
+
let raw;
|
|
768
|
+
try {
|
|
769
|
+
raw = await generateLlmsTxt(parsed.positionals[0], {
|
|
770
|
+
maxPages,
|
|
771
|
+
fetcher
|
|
772
|
+
});
|
|
773
|
+
} finally {
|
|
774
|
+
await fetcher.close();
|
|
775
|
+
}
|
|
776
|
+
const result = sanitizeDeep(raw);
|
|
777
|
+
const exit = result.outcome === "generated" ? 0 : 3;
|
|
778
|
+
if (result.text !== null && out !== void 0) await context.writeFile(out, result.text, force);
|
|
779
|
+
const summary = [];
|
|
780
|
+
if (result.outcome === "generated") {
|
|
781
|
+
const skipped = skippedSummary(result);
|
|
782
|
+
summary.push(style.bold(`Generated an llms.txt for ${result.origin}: ${result.pages.length} page${result.pages.length === 1 ? "" : "s"} from ${SOURCE_TEXT[result.source]}.`));
|
|
783
|
+
if (skipped.length > 0) summary.push(`Left out: ${skipped.join(", ")}.`);
|
|
784
|
+
if (out !== void 0) summary.push(`Wrote ${out}.`);
|
|
785
|
+
} else summary.push(style.bold(`No llms.txt was generated for ${result.origin === "" ? parsed.positionals[0] : result.origin}.`));
|
|
786
|
+
for (const note of result.notes) summary.push(oneLine(note));
|
|
787
|
+
if (result.outcome === "generated") summary.push("Titles and descriptions are the pages' own words. Read the file and edit it before you publish it.");
|
|
788
|
+
else {
|
|
789
|
+
summary.push("Exit code 3.");
|
|
790
|
+
if (result.outcome === "robots_unreadable" && context.offersPrivateNetwork !== false && !flag(parsed, "allow-private-network")) summary.push("A staging site on a private network is reached with --allow-private-network.");
|
|
791
|
+
}
|
|
792
|
+
summary.push("", crawlersCoveredLine(), "", `Full report: ${fullReportUrl("llms-generate")}`);
|
|
793
|
+
const json = {
|
|
794
|
+
schemaVersion: 1,
|
|
795
|
+
command: "llms-generate",
|
|
796
|
+
exitCode: exit,
|
|
797
|
+
crawlersCovered: crawlersCoveredLine(),
|
|
798
|
+
...exit === 3 ? { error: {
|
|
799
|
+
code: "unreadable",
|
|
800
|
+
message: result.notes.join(" ")
|
|
801
|
+
} } : {},
|
|
802
|
+
...out === void 0 ? {} : { out },
|
|
803
|
+
result
|
|
804
|
+
};
|
|
805
|
+
if (out !== void 0 || result.text === null) return {
|
|
806
|
+
exit,
|
|
807
|
+
json,
|
|
808
|
+
text: `${summary.join("\n")}\n`
|
|
809
|
+
};
|
|
810
|
+
return {
|
|
811
|
+
exit,
|
|
812
|
+
json,
|
|
813
|
+
text: result.text,
|
|
814
|
+
textErr: `${summary.join("\n")}\n`
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
/** Bytes of each linked page that are read. */
|
|
818
|
+
var LLMS_LINK_BODY_BYTES = 65536;
|
|
819
|
+
/** The distinct http(s) addresses among the targets, resolved against `base`, fragments removed. */
|
|
820
|
+
function resolveTargets(targets, base, sameSite) {
|
|
821
|
+
const seen = /* @__PURE__ */ new Set();
|
|
822
|
+
const onSite = [];
|
|
823
|
+
let skipped = 0;
|
|
824
|
+
for (const target of targets) {
|
|
825
|
+
let url;
|
|
826
|
+
try {
|
|
827
|
+
url = new URL(target, base);
|
|
828
|
+
} catch {
|
|
829
|
+
skipped += 1;
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
url.hash = "";
|
|
833
|
+
if (url.protocol !== "http:" && url.protocol !== "https:" || !sameSite(url)) {
|
|
834
|
+
skipped += 1;
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
if (seen.has(url.href)) continue;
|
|
838
|
+
seen.add(url.href);
|
|
839
|
+
onSite.push(url.href);
|
|
840
|
+
}
|
|
841
|
+
return {
|
|
842
|
+
onSite,
|
|
843
|
+
skipped
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
async function checkLlmsLinks(targets, options) {
|
|
847
|
+
const registrable = options.registrableDomain ?? registrableDomain;
|
|
848
|
+
const base = new URL(options.base);
|
|
849
|
+
const siteDomain = registrable(base.hostname);
|
|
850
|
+
const { onSite, skipped } = resolveTargets(targets, base, (url) => registrable(url.hostname) === siteDomain);
|
|
851
|
+
const chosen = onSite.slice(0, 20);
|
|
852
|
+
const beyondLimit = onSite.length - chosen.length;
|
|
853
|
+
if (chosen.length === 0) return {
|
|
854
|
+
skipped,
|
|
855
|
+
beyondLimit,
|
|
856
|
+
robotsRead: true,
|
|
857
|
+
results: []
|
|
858
|
+
};
|
|
859
|
+
const budget = AbortSignal.timeout(CHECK_BUDGET_MS);
|
|
860
|
+
const signal = options.signal === void 0 ? budget : AbortSignal.any([options.signal, budget]);
|
|
861
|
+
const sameSite = (target) => registrable(target.hostname) === siteDomain;
|
|
862
|
+
const origins = createOriginRobots({
|
|
863
|
+
fetcher: options.fetcher,
|
|
864
|
+
signal,
|
|
865
|
+
redirectAllowed: sameSite
|
|
866
|
+
});
|
|
867
|
+
if (await origins.robotsOf(base.origin) === null) return {
|
|
868
|
+
skipped,
|
|
869
|
+
beyondLimit,
|
|
870
|
+
robotsRead: false,
|
|
871
|
+
results: []
|
|
872
|
+
};
|
|
873
|
+
const results = [];
|
|
874
|
+
let outOfTime = false;
|
|
875
|
+
for (const url of chosen) {
|
|
876
|
+
if (outOfTime || signal.aborted) {
|
|
877
|
+
outOfTime = true;
|
|
878
|
+
results.push({
|
|
879
|
+
url,
|
|
880
|
+
outcome: "not_checked",
|
|
881
|
+
status: null
|
|
882
|
+
});
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
const access = await origins.check(url);
|
|
886
|
+
if (signal.aborted) {
|
|
887
|
+
outOfTime = true;
|
|
888
|
+
results.push({
|
|
889
|
+
url,
|
|
890
|
+
outcome: "not_checked",
|
|
891
|
+
status: null
|
|
892
|
+
});
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
if (access !== "allowed") {
|
|
896
|
+
results.push({
|
|
897
|
+
url,
|
|
898
|
+
outcome: access === "closed" ? "not_allowed" : "robots_unreadable",
|
|
899
|
+
status: null
|
|
900
|
+
});
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
const result = await options.fetcher.fetchText(url, {
|
|
904
|
+
kind: "page",
|
|
905
|
+
maxBytes: LLMS_LINK_BODY_BYTES,
|
|
906
|
+
signal,
|
|
907
|
+
redirectAllowed: sameSite
|
|
908
|
+
});
|
|
909
|
+
if (result.outcome === "budget_exceeded") {
|
|
910
|
+
outOfTime = true;
|
|
911
|
+
results.push({
|
|
912
|
+
url,
|
|
913
|
+
outcome: "not_checked",
|
|
914
|
+
status: null
|
|
915
|
+
});
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
const status = result.outcome === "response" ? result.status : null;
|
|
919
|
+
const outcome = status === null ? "unreachable" : status >= 200 && status < 300 ? "ok" : status === 404 || status === 410 ? "missing" : "error_status";
|
|
920
|
+
results.push({
|
|
921
|
+
url,
|
|
922
|
+
outcome,
|
|
923
|
+
status
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
return {
|
|
927
|
+
skipped,
|
|
928
|
+
beyondLimit,
|
|
929
|
+
robotsRead: true,
|
|
930
|
+
results
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
/** The findings of a link check. One finding for the set; the evidence lists what is wrong. */
|
|
934
|
+
function llmsLinkFindings(report) {
|
|
935
|
+
if (!report.robotsRead) return [llmsFinding("llms-txt-links", "warn", "robots.txt could not be read, so no link was requested.")];
|
|
936
|
+
if (report.results.length === 0) return [llmsFinding("llms-txt-links", "info", "The file has no link on this site to check.")];
|
|
937
|
+
const notRequested = /* @__PURE__ */ new Set([
|
|
938
|
+
"not_allowed",
|
|
939
|
+
"robots_unreadable",
|
|
940
|
+
"not_checked"
|
|
941
|
+
]);
|
|
942
|
+
const checked = report.results.filter((entry) => !notRequested.has(entry.outcome));
|
|
943
|
+
const missing = checked.filter((entry) => entry.outcome === "missing");
|
|
944
|
+
const other = checked.filter((entry) => entry.outcome === "error_status" || entry.outcome === "unreachable");
|
|
945
|
+
const notAllowed = report.results.filter((entry) => entry.outcome === "not_allowed" || entry.outcome === "robots_unreadable").length;
|
|
946
|
+
const notChecked = report.results.filter((entry) => entry.outcome === "not_checked").length;
|
|
947
|
+
const list = (entries) => entries.slice(0, 5).map((entry) => `${entry.status ?? "no answer"} ${entry.url}`).join("\n");
|
|
948
|
+
const tail = [
|
|
949
|
+
notAllowed > 0 ? `${notAllowed} not requested (robots.txt)` : "",
|
|
950
|
+
notChecked > 0 ? `${notChecked} not checked (time budget)` : "",
|
|
951
|
+
report.beyondLimit > 0 ? `${report.beyondLimit} beyond the first 20` : ""
|
|
952
|
+
].filter((part) => part !== "").join("; ");
|
|
953
|
+
const suffix = tail === "" ? "" : ` (${tail})`;
|
|
954
|
+
if (checked.length === 0) return [llmsFinding("llms-txt-links", notChecked > 0 ? "warn" : "info", `No link was requested${suffix}.`)];
|
|
955
|
+
if (missing.length > 0) return [llmsFinding("llms-txt-links", "fail", `${missing.length} of ${checked.length} checked links answer 404 or 410${suffix}.`, list(missing))];
|
|
956
|
+
if (other.length > 0) return [llmsFinding("llms-txt-links", "warn", `${other.length} of ${checked.length} checked links did not answer with a 2xx${suffix}.`, list(other))];
|
|
957
|
+
return [llmsFinding("llms-txt-links", "pass", `All ${checked.length} checked link${checked.length === 1 ? "" : "s"} on this site answer 2xx${suffix}.`)];
|
|
958
|
+
}
|
|
959
|
+
// src/cli/commands/llms-validate.ts
|
|
960
|
+
function reportJson(report) {
|
|
961
|
+
return {
|
|
962
|
+
present: report.present,
|
|
963
|
+
verdict: report.verdict,
|
|
964
|
+
status: report.status,
|
|
965
|
+
contentType: report.contentType,
|
|
966
|
+
bytes: report.bytes,
|
|
967
|
+
truncated: report.truncated,
|
|
968
|
+
h1: report.h1,
|
|
969
|
+
h1Count: report.h1Count,
|
|
970
|
+
structure: report.structure === null ? null : {
|
|
971
|
+
summary: report.structure.summary,
|
|
972
|
+
sections: report.structure.sections,
|
|
973
|
+
links: report.structure.links,
|
|
974
|
+
structured: report.structure.structured
|
|
975
|
+
}
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
async function runLlmsValidate(context, parsed, style) {
|
|
979
|
+
const failOn = failOnOf(parsed);
|
|
980
|
+
const wantLinks = flag(parsed, "links");
|
|
981
|
+
const baseOption = text(parsed, "base");
|
|
982
|
+
let base = null;
|
|
983
|
+
if (baseOption !== void 0) {
|
|
984
|
+
const checked = checkTextUrl(baseOption);
|
|
985
|
+
if ("refusal" in checked) throw new UsageError("--base takes the site address, such as https://example.com.");
|
|
986
|
+
base = checked.url.origin;
|
|
987
|
+
}
|
|
988
|
+
const { source, file } = await classifySource(context, parsed.positionals[0], LLMS_READ_LIMIT);
|
|
989
|
+
if (source.kind === "file" && wantLinks && base === null) throw new UsageError("--links on a file needs --base <site address> to resolve and limit the links.");
|
|
990
|
+
let report;
|
|
991
|
+
let location = source.location;
|
|
992
|
+
const fetcher = context.createFetcher({ allowPrivateNetwork: flag(parsed, "allow-private-network") });
|
|
993
|
+
try {
|
|
994
|
+
if (source.kind === "file" && file !== null) report = validateLlmsTxt({
|
|
995
|
+
status: 200,
|
|
996
|
+
contentType: "text/plain",
|
|
997
|
+
body: decodeFile(file.bytes),
|
|
998
|
+
truncated: file.truncated
|
|
999
|
+
});
|
|
1000
|
+
else {
|
|
1001
|
+
const address = fileAddress(source.location, "llms.txt");
|
|
1002
|
+
location = address;
|
|
1003
|
+
const signal = AbortSignal.timeout(CHECK_BUDGET_MS);
|
|
1004
|
+
const result = await fetcher.fetchText(address, {
|
|
1005
|
+
kind: "llms",
|
|
1006
|
+
signal
|
|
1007
|
+
});
|
|
1008
|
+
if (result.outcome !== "response") {
|
|
1009
|
+
const message = `${address} could not be read: ${describeFetchFailure(result, context.offersPrivateNetwork !== false)}`;
|
|
1010
|
+
return {
|
|
1011
|
+
exit: 3,
|
|
1012
|
+
json: sanitizeDeep({
|
|
1013
|
+
schemaVersion: 1,
|
|
1014
|
+
command: "llms-validate",
|
|
1015
|
+
exitCode: 3,
|
|
1016
|
+
source: {
|
|
1017
|
+
kind: "url",
|
|
1018
|
+
location: address
|
|
1019
|
+
},
|
|
1020
|
+
error: {
|
|
1021
|
+
code: "unreadable",
|
|
1022
|
+
message
|
|
1023
|
+
}
|
|
1024
|
+
}),
|
|
1025
|
+
text: `${oneLine(message)}\nExit code 3.\n`
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
report = validateLlmsTxt({
|
|
1029
|
+
status: result.status,
|
|
1030
|
+
contentType: result.contentType,
|
|
1031
|
+
body: result.text,
|
|
1032
|
+
truncated: result.truncated
|
|
1033
|
+
});
|
|
1034
|
+
if (base === null) base = new URL(result.finalUrl).origin;
|
|
1035
|
+
}
|
|
1036
|
+
const findings = llmsFindings(report);
|
|
1037
|
+
let links = null;
|
|
1038
|
+
if (wantLinks && report.structure !== null && base !== null) {
|
|
1039
|
+
links = await checkLlmsLinks(report.structure.targets, {
|
|
1040
|
+
fetcher,
|
|
1041
|
+
base,
|
|
1042
|
+
signal: AbortSignal.timeout(CHECK_BUDGET_MS)
|
|
1043
|
+
});
|
|
1044
|
+
findings.push(...llmsLinkFindings(links));
|
|
1045
|
+
}
|
|
1046
|
+
const exit = exitForFindings(findings, failOn);
|
|
1047
|
+
const safe = sanitizeDeep({
|
|
1048
|
+
findings,
|
|
1049
|
+
report: reportJson(report),
|
|
1050
|
+
links,
|
|
1051
|
+
source: {
|
|
1052
|
+
kind: source.kind,
|
|
1053
|
+
location
|
|
1054
|
+
}
|
|
1055
|
+
});
|
|
1056
|
+
const lines = [
|
|
1057
|
+
style.bold(`llms.txt validation: ${safe.source.location}`),
|
|
1058
|
+
"",
|
|
1059
|
+
renderFindings(safe.findings, style)
|
|
1060
|
+
];
|
|
1061
|
+
if (safe.links !== null && safe.links.results.length > 0) lines.push("", style.bold("Links checked"), renderTable([
|
|
1062
|
+
"Result",
|
|
1063
|
+
"Status",
|
|
1064
|
+
"Address"
|
|
1065
|
+
], safe.links.results.map((entry) => [
|
|
1066
|
+
entry.outcome === "ok" ? "OK" : entry.outcome.replace("_", " "),
|
|
1067
|
+
entry.status === null ? "-" : String(entry.status),
|
|
1068
|
+
entry.url
|
|
1069
|
+
]), style));
|
|
1070
|
+
lines.push("", renderSummary(findings), "", crawlersCoveredLine(), "", `Full report: ${fullReportUrl("llms-validate")}`);
|
|
1071
|
+
return {
|
|
1072
|
+
exit,
|
|
1073
|
+
json: {
|
|
1074
|
+
schemaVersion: 1,
|
|
1075
|
+
command: "llms-validate",
|
|
1076
|
+
exitCode: exit,
|
|
1077
|
+
failOn,
|
|
1078
|
+
crawlersCovered: crawlersCoveredLine(),
|
|
1079
|
+
source: safe.source,
|
|
1080
|
+
report: safe.report,
|
|
1081
|
+
...safe.links === null ? {} : { links: safe.links },
|
|
1082
|
+
findings: safe.findings
|
|
1083
|
+
},
|
|
1084
|
+
text: `${lines.join("\n")}\n`
|
|
1085
|
+
};
|
|
1086
|
+
} finally {
|
|
1087
|
+
await fetcher.close();
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
// src/cli/finding.ts
|
|
1091
|
+
var DOC_BASE = "https://queryrev.com/research/ai-search-readiness#";
|
|
1092
|
+
/** A finding of the command line's own checks (a robots.txt test); the evidence is cut short. */
|
|
1093
|
+
function cliFinding(id, level, message, snippet, anchor = "readable-by-ai-search") {
|
|
1094
|
+
const evidence = stripUnsafeCharacters(snippet ?? "").slice(0, 300);
|
|
1095
|
+
const docUrl = `${DOC_BASE}${anchor}`;
|
|
1096
|
+
return Object.freeze(evidence === "" ? {
|
|
1097
|
+
id,
|
|
1098
|
+
level,
|
|
1099
|
+
message: stripUnsafeCharacters(message),
|
|
1100
|
+
docUrl
|
|
1101
|
+
} : {
|
|
1102
|
+
id,
|
|
1103
|
+
level,
|
|
1104
|
+
message: stripUnsafeCharacters(message),
|
|
1105
|
+
docUrl,
|
|
1106
|
+
snippet: evidence
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
// src/cli/commands/robots-test.ts
|
|
1110
|
+
var AGENT_TOKEN = /^[A-Za-z0-9._*-]{1,64}$/;
|
|
1111
|
+
var PATH_MAX = 2048;
|
|
1112
|
+
/** Edit distance, for "did you mean" on a mistyped crawler token. Both strings are short. */
|
|
1113
|
+
function distance(a, b) {
|
|
1114
|
+
const row = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
1115
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
1116
|
+
let previous = row[0];
|
|
1117
|
+
row[0] = i;
|
|
1118
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
1119
|
+
const above = row[j];
|
|
1120
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
1121
|
+
previous = above;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
return row[b.length];
|
|
1125
|
+
}
|
|
1126
|
+
/** The covered crawler a typed token is probably meant to be, if it is not one. */
|
|
1127
|
+
function suggestion(token) {
|
|
1128
|
+
const lower = token.toLowerCase();
|
|
1129
|
+
if (CRAWLERS.some((crawler) => crawler.token.toLowerCase() === lower)) return null;
|
|
1130
|
+
let best = null;
|
|
1131
|
+
for (const crawler of CRAWLERS) {
|
|
1132
|
+
const score = distance(lower, crawler.token.toLowerCase());
|
|
1133
|
+
if (score <= 3 && (best === null || score < best.score)) best = {
|
|
1134
|
+
token: crawler.token,
|
|
1135
|
+
score
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
return best?.token ?? null;
|
|
1139
|
+
}
|
|
1140
|
+
function describeMatch(match) {
|
|
1141
|
+
const group = match.via === "none" ? "no group applies" : match.via === "named" ? `the group for this agent (line ${match.group?.line ?? 0})` : `the "*" group (line ${match.group?.line ?? 0})`;
|
|
1142
|
+
if (match.rule === null) return {
|
|
1143
|
+
sentence: match.via === "none" ? "no group applies, so nothing is closed" : `${group}, and no rule matches`,
|
|
1144
|
+
evidence: match.group === null ? "no group applies" : `${match.via === "named" ? "group for this agent" : "\"*\" group"} at line ${match.group.line}, no rule matches`
|
|
1145
|
+
};
|
|
1146
|
+
const rule = `${match.rule.allow ? "Allow" : "Disallow"}: ${match.rule.pattern}`;
|
|
1147
|
+
return {
|
|
1148
|
+
sentence: `${group}, decided by line ${match.rule.line} (${rule})`,
|
|
1149
|
+
evidence: `line ${match.rule.line}: ${rule}`
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
function flatMatch(match) {
|
|
1153
|
+
return {
|
|
1154
|
+
allowed: match.allowed,
|
|
1155
|
+
via: match.via,
|
|
1156
|
+
group: match.group === null ? null : {
|
|
1157
|
+
line: match.group.line,
|
|
1158
|
+
agents: [...match.group.agents]
|
|
1159
|
+
},
|
|
1160
|
+
rule: match.rule === null ? null : {
|
|
1161
|
+
type: match.rule.allow ? "allow" : "disallow",
|
|
1162
|
+
pattern: match.rule.pattern,
|
|
1163
|
+
line: match.rule.line
|
|
1164
|
+
}
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
async function load(context, parsed) {
|
|
1168
|
+
const { source, file } = await classifySource(context, parsed.positionals[0], ROBOTS_READ_LIMIT);
|
|
1169
|
+
const notes = [];
|
|
1170
|
+
if (source.kind === "file" && file !== null) {
|
|
1171
|
+
const body = decodeFile(file.bytes);
|
|
1172
|
+
if (looksLikeHtml(null, body)) throw new UsageError(`${source.location.slice(0, 200)} is an HTML page, not a robots.txt.`);
|
|
1173
|
+
if (file.truncated) notes.push(cliFinding("robots-file-cut", "info", "The file is larger than 500 KiB; it was read as far as that limit."));
|
|
1174
|
+
return {
|
|
1175
|
+
robots: parseRobots(body),
|
|
1176
|
+
source,
|
|
1177
|
+
notes
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
const address = fileAddress(source.location, "robots.txt");
|
|
1181
|
+
const fetcher = context.createFetcher({ allowPrivateNetwork: flag(parsed, "allow-private-network") });
|
|
1182
|
+
try {
|
|
1183
|
+
const result = await fetcher.fetchText(address, {
|
|
1184
|
+
kind: "robots",
|
|
1185
|
+
signal: AbortSignal.timeout(CHECK_BUDGET_MS)
|
|
1186
|
+
});
|
|
1187
|
+
const { read, robots } = robotsRead(result, address);
|
|
1188
|
+
if (robots === null) return {
|
|
1189
|
+
unreadable: `${address}: ${describeFetchFailure(result, context.offersPrivateNetwork !== false)}`,
|
|
1190
|
+
source
|
|
1191
|
+
};
|
|
1192
|
+
if (read.outcome === "missing") notes.push(cliFinding("robots-missing", "info", `There is no robots.txt at ${address} (status ${read.status ?? 0}), so nothing is closed.`));
|
|
1193
|
+
if (read.truncated) notes.push(cliFinding("robots-file-cut", "info", "The file is larger than 500 KiB; it was read as far as that limit."));
|
|
1194
|
+
return {
|
|
1195
|
+
robots,
|
|
1196
|
+
source: {
|
|
1197
|
+
kind: "url",
|
|
1198
|
+
location: address
|
|
1199
|
+
},
|
|
1200
|
+
notes
|
|
1201
|
+
};
|
|
1202
|
+
} finally {
|
|
1203
|
+
await fetcher.close();
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
async function runRobotsTest(context, parsed, style) {
|
|
1207
|
+
const failOn = failOnOf(parsed);
|
|
1208
|
+
const agent = text(parsed, "agent");
|
|
1209
|
+
const path = text(parsed, "path") ?? "/";
|
|
1210
|
+
if (agent !== void 0 && !AGENT_TOKEN.test(agent)) throw new UsageError("--agent takes a crawler token such as OAI-SearchBot (letters, digits, . _ - *).");
|
|
1211
|
+
if (!path.startsWith("/") || path.length > PATH_MAX || [...path].some((char) => char <= " " || char === "")) throw new UsageError("--path must start with \"/\" and hold no spaces or control characters.");
|
|
1212
|
+
const loaded = await load(context, parsed);
|
|
1213
|
+
if ("unreadable" in loaded) {
|
|
1214
|
+
const message = `robots.txt could not be read: ${loaded.unreadable}`;
|
|
1215
|
+
const exit = 3;
|
|
1216
|
+
return {
|
|
1217
|
+
exit,
|
|
1218
|
+
json: sanitizeDeep({
|
|
1219
|
+
schemaVersion: 1,
|
|
1220
|
+
command: "robots-test",
|
|
1221
|
+
exitCode: exit,
|
|
1222
|
+
source: loaded.source,
|
|
1223
|
+
error: {
|
|
1224
|
+
code: "unreadable",
|
|
1225
|
+
message
|
|
1226
|
+
}
|
|
1227
|
+
}),
|
|
1228
|
+
text: `${oneLine(message)}\nExit code 3.\n`
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
const { robots, source, notes } = loaded;
|
|
1232
|
+
const findings = [...notes];
|
|
1233
|
+
if (robots.groups.length === 0) findings.push(cliFinding("robots-groups", "info", "The file has no User-agent group, so nothing is closed."));
|
|
1234
|
+
const lines = [
|
|
1235
|
+
style.bold(`robots.txt test: ${source.location}`),
|
|
1236
|
+
`Path: ${path}`,
|
|
1237
|
+
""
|
|
1238
|
+
];
|
|
1239
|
+
let detail;
|
|
1240
|
+
let covered = null;
|
|
1241
|
+
if (agent !== void 0) {
|
|
1242
|
+
const match = matchRobots(robots, agent, path);
|
|
1243
|
+
const { sentence, evidence } = describeMatch(match);
|
|
1244
|
+
const level = match.allowed ? "pass" : "fail";
|
|
1245
|
+
findings.push(cliFinding("robots-access", level, `${agent} ${match.allowed ? "may" : "may not"} fetch ${path}: ${sentence}.`, evidence));
|
|
1246
|
+
const near = suggestion(agent);
|
|
1247
|
+
if (near !== null) findings.push(cliFinding("robots-agent-unlisted", "info", `${agent} is not one of the crawlers this tool covers. Did you mean ${near}? The rules are tested for any token you type.`));
|
|
1248
|
+
else if (!CRAWLERS.some((crawler) => crawler.token.toLowerCase() === agent.toLowerCase())) findings.push(cliFinding("robots-agent-unlisted", "info", `${agent} is not one of the crawlers this tool covers; the rules are tested for any token you type.`));
|
|
1249
|
+
lines.push(`Agent: ${agent}`, "", renderFindings(findings, style));
|
|
1250
|
+
detail = {
|
|
1251
|
+
agent,
|
|
1252
|
+
match: flatMatch(match)
|
|
1253
|
+
};
|
|
1254
|
+
} else {
|
|
1255
|
+
const rows = [];
|
|
1256
|
+
const entries = crawlerAccess(robots, { path }).map((entry) => {
|
|
1257
|
+
const match = entry.path ?? entry.root;
|
|
1258
|
+
const training = entry.crawler.purpose === "training";
|
|
1259
|
+
const level = !match.allowed ? training ? "info" : "fail" : !training && path === "/" && entry.access === "partial" ? "warn" : "pass";
|
|
1260
|
+
const { sentence, evidence } = describeMatch(match);
|
|
1261
|
+
findings.push(cliFinding(`robots-access-${entry.crawler.token.toLowerCase()}`, level, `${entry.crawler.token} ${match.allowed ? "may" : "may not"} fetch ${path}: ${sentence}.${level === "warn" ? " Parts of the site are closed to it." : ""}`, evidence));
|
|
1262
|
+
rows.push([
|
|
1263
|
+
level.toUpperCase(),
|
|
1264
|
+
entry.crawler.token,
|
|
1265
|
+
entry.crawler.purpose,
|
|
1266
|
+
match.allowed ? "allowed" : "blocked",
|
|
1267
|
+
evidence
|
|
1268
|
+
]);
|
|
1269
|
+
return {
|
|
1270
|
+
token: entry.crawler.token,
|
|
1271
|
+
purpose: entry.crawler.purpose,
|
|
1272
|
+
vendor: entry.crawler.vendor,
|
|
1273
|
+
siteAccess: entry.access,
|
|
1274
|
+
match: flatMatch(match)
|
|
1275
|
+
};
|
|
1276
|
+
});
|
|
1277
|
+
lines.push(renderTable([
|
|
1278
|
+
"Level",
|
|
1279
|
+
"Crawler",
|
|
1280
|
+
"Purpose",
|
|
1281
|
+
"Access",
|
|
1282
|
+
"Decided by"
|
|
1283
|
+
], rows, style));
|
|
1284
|
+
const other = findings.filter((finding) => !finding.id.startsWith("robots-access-"));
|
|
1285
|
+
if (other.length > 0) lines.push("", renderFindings(other, style));
|
|
1286
|
+
detail = {
|
|
1287
|
+
crawlersCovered: crawlersCoveredLine(),
|
|
1288
|
+
crawlers: entries
|
|
1289
|
+
};
|
|
1290
|
+
covered = crawlersCoveredLine();
|
|
1291
|
+
}
|
|
1292
|
+
const exit = exitForFindings(findings, failOn);
|
|
1293
|
+
const safe = sanitizeDeep({
|
|
1294
|
+
findings,
|
|
1295
|
+
detail,
|
|
1296
|
+
source
|
|
1297
|
+
});
|
|
1298
|
+
lines.push("", renderSummary(findings));
|
|
1299
|
+
if (covered !== null) lines.push("", covered);
|
|
1300
|
+
lines.push("", `Full report: ${fullReportUrl("robots-test")}`);
|
|
1301
|
+
return {
|
|
1302
|
+
exit,
|
|
1303
|
+
json: {
|
|
1304
|
+
schemaVersion: 1,
|
|
1305
|
+
command: "robots-test",
|
|
1306
|
+
exitCode: exit,
|
|
1307
|
+
failOn,
|
|
1308
|
+
path,
|
|
1309
|
+
source: safe.source,
|
|
1310
|
+
...safe.detail,
|
|
1311
|
+
findings: safe.findings
|
|
1312
|
+
},
|
|
1313
|
+
text: `${lines.join("\n")}\n`
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
// src/cli/usage.ts
|
|
1317
|
+
var USAGE = `ai-readiness: checks how a site's public files treat AI crawlers.
|
|
1318
|
+
|
|
1319
|
+
Usage: ai-readiness <command> [arguments] [options]
|
|
1320
|
+
|
|
1321
|
+
Commands
|
|
1322
|
+
check <url> robots.txt access of the covered AI crawlers, llms.txt, the
|
|
1323
|
+
home page as served, structured data and the sitemap
|
|
1324
|
+
robots test <file|url> which robots.txt line decides for a crawler and a path
|
|
1325
|
+
--agent <token> one crawler token (any token); all covered crawlers without it
|
|
1326
|
+
--path <path> the path to test, default /
|
|
1327
|
+
llms validate <file|url> checks an llms.txt against llmstxt.org
|
|
1328
|
+
--links also request up to 20 links on the same site
|
|
1329
|
+
--base <url> the site of a local file's links (needed with --links)
|
|
1330
|
+
llms generate <url> builds the skeleton of an llms.txt from the site's own pages
|
|
1331
|
+
--max-pages <n> pages to list, 1 to 100, default 30
|
|
1332
|
+
--out <file> write the file here (--force replaces an existing file)
|
|
1333
|
+
crawlers the AI crawlers covered, with vendor and purpose
|
|
1334
|
+
|
|
1335
|
+
Options
|
|
1336
|
+
--json one JSON document with schemaVersion 1, no colour, no other output
|
|
1337
|
+
--fail-on <level> exit 1 for findings at "fail" (default) or at "warn" (check, robots test, llms validate)
|
|
1338
|
+
--allow-private-network
|
|
1339
|
+
reach a staging site on a private network (never cloud metadata addresses)
|
|
1340
|
+
--no-color no colour (also off without a terminal, or when NO_COLOR is set)
|
|
1341
|
+
-h, --help this text
|
|
1342
|
+
-v, --version the version
|
|
1343
|
+
|
|
1344
|
+
Exit codes
|
|
1345
|
+
0 nothing at or above --fail-on
|
|
1346
|
+
1 checks failed
|
|
1347
|
+
2 usage error
|
|
1348
|
+
3 the site could not be read (refused, unreachable, timed out)
|
|
1349
|
+
4 internal error
|
|
1350
|
+
|
|
1351
|
+
Reads public files only. Sends nothing to queryrev.com.
|
|
1352
|
+
`;
|
|
1353
|
+
// src/cli/main.ts
|
|
1354
|
+
var CLI_VERSION = {
|
|
1355
|
+
name: "@queryrev/ai-readiness",
|
|
1356
|
+
version: "1.0.0",
|
|
1357
|
+
description: "robots.txt parsing, AI crawler access verdicts, llms.txt checks and a guarded network layer. No model calls.",
|
|
1358
|
+
license: "MIT",
|
|
1359
|
+
author: "QueryRev",
|
|
1360
|
+
homepage: "https://queryrev.com/research/ai-search-readiness",
|
|
1361
|
+
repository: {
|
|
1362
|
+
"type": "git",
|
|
1363
|
+
"url": "git+https://github.com/QueryRevAI/ai-readiness.git"
|
|
1364
|
+
},
|
|
1365
|
+
bugs: { "url": "https://github.com/QueryRevAI/ai-readiness/issues" },
|
|
1366
|
+
keywords: [
|
|
1367
|
+
"robots.txt",
|
|
1368
|
+
"llms.txt",
|
|
1369
|
+
"ai-crawlers",
|
|
1370
|
+
"geo",
|
|
1371
|
+
"aeo",
|
|
1372
|
+
"seo",
|
|
1373
|
+
"cli"
|
|
1374
|
+
],
|
|
1375
|
+
type: "module",
|
|
1376
|
+
engines: { "node": ">=22.19" },
|
|
1377
|
+
sideEffects: false,
|
|
1378
|
+
main: "./dist/index.js",
|
|
1379
|
+
types: "./dist/index.d.ts",
|
|
1380
|
+
bin: { "ai-readiness": "./dist/cli.js" },
|
|
1381
|
+
exports: {
|
|
1382
|
+
".": {
|
|
1383
|
+
"types": "./dist/index.d.ts",
|
|
1384
|
+
"default": "./dist/index.js"
|
|
1385
|
+
},
|
|
1386
|
+
"./cli": {
|
|
1387
|
+
"types": "./dist/net/cli.d.ts",
|
|
1388
|
+
"default": "./dist/net-cli.js"
|
|
1389
|
+
},
|
|
1390
|
+
"./run": {
|
|
1391
|
+
"types": "./dist/cli/run.d.ts",
|
|
1392
|
+
"default": "./dist/run.js"
|
|
1393
|
+
},
|
|
1394
|
+
"./testing": {
|
|
1395
|
+
"types": "./dist/net/testing/index.d.ts",
|
|
1396
|
+
"default": "./dist/testing.js"
|
|
1397
|
+
},
|
|
1398
|
+
"./package.json": "./package.json"
|
|
1399
|
+
},
|
|
1400
|
+
files: [
|
|
1401
|
+
"dist",
|
|
1402
|
+
"data",
|
|
1403
|
+
"README.md",
|
|
1404
|
+
"LICENSE",
|
|
1405
|
+
"CITATION.cff",
|
|
1406
|
+
"SECURITY.md"
|
|
1407
|
+
],
|
|
1408
|
+
publishConfig: { "access": "public" },
|
|
1409
|
+
scripts: {
|
|
1410
|
+
"build": "vite build --config vite.build.config.ts && node scripts/build-types.mjs",
|
|
1411
|
+
"prepublishOnly": "node scripts/check-publishable.mjs && npm run build",
|
|
1412
|
+
"typecheck": "tsc --noEmit",
|
|
1413
|
+
"test": "vitest run"
|
|
1414
|
+
},
|
|
1415
|
+
dependencies: { "undici": "^8.12.3" },
|
|
1416
|
+
devDependencies: {
|
|
1417
|
+
"@types/node": "^24.0.0",
|
|
1418
|
+
"eslint": "^9.39.5",
|
|
1419
|
+
"typescript": "~6.0.3",
|
|
1420
|
+
"vite": "^8.3.4",
|
|
1421
|
+
"vitest": "^5.0.3"
|
|
1422
|
+
}
|
|
1423
|
+
}.version;
|
|
1424
|
+
async function dispatch(context, parsed, style) {
|
|
1425
|
+
switch (parsed.command) {
|
|
1426
|
+
case "check": return runCheck(context, parsed, style);
|
|
1427
|
+
case "robots test": return runRobotsTest(context, parsed, style);
|
|
1428
|
+
case "llms validate": return runLlmsValidate(context, parsed, style);
|
|
1429
|
+
case "llms generate": return runLlmsGenerate(context, parsed, style);
|
|
1430
|
+
case "crawlers": return runCrawlers(style);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
function errorDocument(code, message, exit) {
|
|
1434
|
+
return `${JSON.stringify({
|
|
1435
|
+
schemaVersion: 1,
|
|
1436
|
+
exitCode: exit,
|
|
1437
|
+
error: {
|
|
1438
|
+
code,
|
|
1439
|
+
message
|
|
1440
|
+
}
|
|
1441
|
+
}, null, 2)}\n`;
|
|
1442
|
+
}
|
|
1443
|
+
/**
|
|
1444
|
+
* Runs the command line. Returns the exit code and sets no global state: output goes through the
|
|
1445
|
+
* context, so a test can run it with a fixture fetcher and read what it printed.
|
|
1446
|
+
*/
|
|
1447
|
+
async function runCli(argv, context) {
|
|
1448
|
+
const json = argv.includes("--json");
|
|
1449
|
+
try {
|
|
1450
|
+
if (argv.length === 0) {
|
|
1451
|
+
context.stderr.write(USAGE);
|
|
1452
|
+
return 2;
|
|
1453
|
+
}
|
|
1454
|
+
if (argv[0] === "--help" || argv[0] === "-h" || argv[0] === "help") {
|
|
1455
|
+
context.stdout.write(USAGE);
|
|
1456
|
+
return 0;
|
|
1457
|
+
}
|
|
1458
|
+
if (argv[0] === "--version" || argv[0] === "-v") {
|
|
1459
|
+
context.stdout.write(`${CLI_VERSION}\n`);
|
|
1460
|
+
return 0;
|
|
1461
|
+
}
|
|
1462
|
+
const parsed = parseCommand(argv, context.offersPrivateNetwork !== false);
|
|
1463
|
+
if (flag(parsed, "help")) {
|
|
1464
|
+
context.stdout.write(USAGE);
|
|
1465
|
+
return 0;
|
|
1466
|
+
}
|
|
1467
|
+
const result = await dispatch(context, parsed, makeStyle({
|
|
1468
|
+
isTTY: context.stdout.isTTY === true && !flag(parsed, "json"),
|
|
1469
|
+
env: context.env,
|
|
1470
|
+
noColorFlag: flag(parsed, "no-color")
|
|
1471
|
+
}));
|
|
1472
|
+
if (flag(parsed, "json")) context.stdout.write(`${JSON.stringify(result.json, null, 2)}\n`);
|
|
1473
|
+
else {
|
|
1474
|
+
context.stdout.write(result.text);
|
|
1475
|
+
if (result.textErr !== void 0) context.stderr.write(result.textErr);
|
|
1476
|
+
}
|
|
1477
|
+
return result.exit;
|
|
1478
|
+
} catch (error) {
|
|
1479
|
+
if (error instanceof UsageError) {
|
|
1480
|
+
const message = oneLine(error.message);
|
|
1481
|
+
if (json) context.stdout.write(errorDocument("usage", message, 2));
|
|
1482
|
+
else context.stderr.write(`ai-readiness: ${message}\nRun "ai-readiness --help" for usage.\n`);
|
|
1483
|
+
return 2;
|
|
1484
|
+
}
|
|
1485
|
+
const message = `Internal error: ${error instanceof Error ? oneLine(error.name) : "unknown"}.`;
|
|
1486
|
+
if (json) context.stdout.write(errorDocument("internal", message, 4));
|
|
1487
|
+
else context.stderr.write(`ai-readiness: ${message}\n`);
|
|
1488
|
+
return 4;
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
export { runCli as n, CLI_VERSION as t };
|