turva-llms-txt-validator 0.1.1 → 0.1.2
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/README.md +2 -2
- package/package.json +1 -1
- package/src/index.mjs +61 -37
package/README.md
CHANGED
|
@@ -18,13 +18,13 @@ Or run it without installing:
|
|
|
18
18
|
llms-txt-validate example.com --json
|
|
19
19
|
llms-txt-validate example.com --strict
|
|
20
20
|
|
|
21
|
-
Exit codes: 0 valid (warnings allowed), 1 not valid (with --strict, warnings also exit 1), 2 fetch or input error. Only the target site's /llms.txt
|
|
21
|
+
Exit codes: 0 valid (warnings allowed), 1 not valid (with --strict, warnings also exit 1), 2 fetch or input error. Only the target site's /llms.txt is fetched over https, following a redirect only to the same host or its www/apex twin (an off-site or unsafe redirect fails the first check), the fetch times out after 8 seconds and the read is capped at 256 KB. Nothing is stored.
|
|
22
22
|
|
|
23
23
|
## The seven checks
|
|
24
24
|
|
|
25
25
|
| # | Check | fail | warn |
|
|
26
26
|
|---|-------|------|------|
|
|
27
|
-
| 1 | File exists at /llms.txt, HTTP 200 | non-200 or
|
|
27
|
+
| 1 | File exists at /llms.txt, HTTP 200 (a same-site www/apex redirect is followed) | non-200, or an off-site or unsafe redirect | |
|
|
28
28
|
| 2 | Response is plain text | body looks like HTML | content-type is not text/plain or text/markdown |
|
|
29
29
|
| 3 | Starts with a single H1 title | first non-empty line is not a markdown H1 | |
|
|
30
30
|
| 4 | Blockquote summary after the title | | missing one-line summary |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "turva-llms-txt-validator",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Validate a site's llms.txt structure: seven checks, pass/warn/fail, JSON output. Same checks as the hosted validator at turva.dev/llms-txt-validator.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.mjs",
|
package/src/index.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// canonical: if the two ever disagree, the hosted one wins and this package
|
|
6
6
|
// gets the fix.
|
|
7
7
|
|
|
8
|
-
export const VERSION = "0.1.
|
|
8
|
+
export const VERSION = "0.1.2";
|
|
9
9
|
const UA = "turva-llms-txt-validator/" + VERSION + " (+https://turva.dev/llms-txt-validator)";
|
|
10
10
|
|
|
11
11
|
export function normalizeHostInput(raw) {
|
|
@@ -32,57 +32,81 @@ export function isValidPublicHost(host) {
|
|
|
32
32
|
export async function fetchLlmsTxt(host, opts = {}) {
|
|
33
33
|
const timeoutMs = opts.timeoutMs ?? 8000;
|
|
34
34
|
const cap = opts.cap ?? 262144;
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
35
|
+
const reqApex = host.startsWith("www.") ? host.slice(4) : host;
|
|
36
|
+
let url = "https://" + host + "/llms.txt";
|
|
37
|
+
let redirectedFrom = null;
|
|
38
|
+
for (let hop = 0; ; hop++) {
|
|
39
|
+
const res = await fetch(url, {
|
|
40
|
+
redirect: "manual",
|
|
41
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
42
|
+
headers: { "user-agent": opts.userAgent ?? UA, "accept": "text/plain, text/markdown;q=0.9, */*;q=0.1" }
|
|
43
|
+
});
|
|
44
|
+
if (res.status >= 300 && res.status < 400) {
|
|
45
|
+
const loc = res.headers.get("location") || "";
|
|
46
|
+
if (!loc) return { redirect: true, reason: "no-location", status: res.status, location: "" };
|
|
47
|
+
if (hop >= 4) return { redirect: true, reason: "too-many", status: res.status, location: loc.slice(0, 120) };
|
|
48
|
+
let next;
|
|
49
|
+
try { next = new URL(loc, url); } catch { return { redirect: true, reason: "bad-location", status: res.status, location: loc.slice(0, 120) }; }
|
|
50
|
+
const safeTarget = next.protocol === "https:" && !next.port && !next.username && !next.password && isValidPublicHost(next.hostname);
|
|
51
|
+
const twin = (next.hostname.startsWith("www.") ? next.hostname.slice(4) : next.hostname) === reqApex;
|
|
52
|
+
if (!safeTarget) return { redirect: true, reason: "unsafe-target", status: res.status, location: next.href.slice(0, 120) };
|
|
53
|
+
if (!twin) return { redirect: true, reason: "off-host", status: res.status, location: next.href.slice(0, 120) };
|
|
54
|
+
if (!redirectedFrom) redirectedFrom = url;
|
|
55
|
+
url = next.href;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
let bytes = 0, truncated = false;
|
|
59
|
+
const chunks = [];
|
|
60
|
+
if (res.body) {
|
|
61
|
+
const reader = res.body.getReader();
|
|
62
|
+
for (;;) {
|
|
63
|
+
const { done, value } = await reader.read();
|
|
64
|
+
if (done) break;
|
|
65
|
+
bytes += value.length;
|
|
66
|
+
if (bytes > cap) {
|
|
67
|
+
truncated = true;
|
|
68
|
+
chunks.push(value.slice(0, value.length - (bytes - cap)));
|
|
69
|
+
bytes = cap;
|
|
70
|
+
await reader.cancel();
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
chunks.push(value);
|
|
58
74
|
}
|
|
59
|
-
chunks.push(value);
|
|
60
75
|
}
|
|
76
|
+
const buf = new Uint8Array(bytes);
|
|
77
|
+
let o = 0;
|
|
78
|
+
for (const c of chunks) { buf.set(c, o); o += c.length; }
|
|
79
|
+
return {
|
|
80
|
+
status: res.status,
|
|
81
|
+
contentType: res.headers.get("content-type") || "",
|
|
82
|
+
text: new TextDecoder("utf-8").decode(buf),
|
|
83
|
+
bytes,
|
|
84
|
+
truncated,
|
|
85
|
+
redirectedFrom,
|
|
86
|
+
finalUrl: url
|
|
87
|
+
};
|
|
61
88
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
bytes,
|
|
70
|
-
truncated
|
|
71
|
-
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function redirectFailDetail(f) {
|
|
92
|
+
if (f.reason === "off-host") return "redirects to " + f.location + ", a different host; llms.txt is host-scoped, so validate that host directly";
|
|
93
|
+
if (f.reason === "unsafe-target") return "redirects to an unsupported target (" + f.location + "); only https redirects to the same site are followed";
|
|
94
|
+
if (f.reason === "too-many") return "too many redirects; the llms.txt is not served at a stable URL";
|
|
95
|
+
return "got a " + f.status + " redirect without a usable Location header";
|
|
72
96
|
}
|
|
73
97
|
|
|
74
98
|
export function validateLlmsTxt(f) {
|
|
75
99
|
const checks = [];
|
|
76
100
|
const add = (id, status, label, detail) => checks.push({ id, status, label, detail });
|
|
77
101
|
if (f.redirect) {
|
|
78
|
-
add("http-status", "fail", "File exists at /llms.txt",
|
|
102
|
+
add("http-status", "fail", "File exists at /llms.txt", redirectFailDetail(f));
|
|
79
103
|
return checks;
|
|
80
104
|
}
|
|
81
105
|
if (f.status !== 200) {
|
|
82
106
|
add("http-status", "fail", "File exists at /llms.txt", "expected HTTP 200, got " + f.status);
|
|
83
107
|
return checks;
|
|
84
108
|
}
|
|
85
|
-
add("http-status", "pass", "File exists at /llms.txt", "HTTP 200");
|
|
109
|
+
add("http-status", "pass", "File exists at /llms.txt", f.redirectedFrom ? "HTTP 200, followed a redirect from " + f.redirectedFrom + " to " + f.finalUrl : "HTTP 200");
|
|
86
110
|
const ct = (f.contentType || "").toLowerCase();
|
|
87
111
|
const looksHtml = /^\s*(<!doctype|<html|<head|<body)/i.test(f.text);
|
|
88
112
|
if (looksHtml) {
|