@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,2950 @@
|
|
|
1
|
+
import { m as createTextFetcher, n as CHECK_BUDGET_MS, p as checkTextUrl } from "./fetch-text-NUUnM91O.js";
|
|
2
|
+
import { Readable } from "node:stream";
|
|
3
|
+
import { createGunzip } from "node:zlib";
|
|
4
|
+
// src/facts/app-shell.ts
|
|
5
|
+
/**
|
|
6
|
+
* The empty application shell: an HTML document over a few KiB that carries almost no text and
|
|
7
|
+
* an application root element, or asks the reader to enable JavaScript. The constants are the
|
|
8
|
+
* ones the site crawler's render decision uses (it imports them from here), so a page is called
|
|
9
|
+
* a shell by one rule everywhere.
|
|
10
|
+
*/
|
|
11
|
+
/** An empty shell is HTML over this many bytes (5 KB, read as 5 KiB)... */
|
|
12
|
+
var EMPTY_SHELL_MIN_BYTES = 5120;
|
|
13
|
+
/** ...with fewer than this many words of main text... */
|
|
14
|
+
var EMPTY_SHELL_MAX_WORDS = 50;
|
|
15
|
+
/**
|
|
16
|
+
* ...and an application root element. Ids that the common frameworks mount into: React
|
|
17
|
+
* (create-react-app, Vite), Vue, Next.js, Nuxt, Gatsby, SvelteKit, Quasar, Remix, Docusaurus.
|
|
18
|
+
*/
|
|
19
|
+
var APP_ROOT_IDS = Object.freeze([
|
|
20
|
+
"root",
|
|
21
|
+
"app",
|
|
22
|
+
"__next",
|
|
23
|
+
"__nuxt",
|
|
24
|
+
"___gatsby",
|
|
25
|
+
"svelte",
|
|
26
|
+
"q-app",
|
|
27
|
+
"remix-root",
|
|
28
|
+
"__docusaurus",
|
|
29
|
+
"main-app",
|
|
30
|
+
"app-root"
|
|
31
|
+
]);
|
|
32
|
+
/** Elements and attributes that mark an application root besides the ids (Angular, AngularJS, React, Vue SSR). */
|
|
33
|
+
var APP_ROOT_TAG = "app-root";
|
|
34
|
+
var APP_ROOT_ATTRIBUTES = Object.freeze([
|
|
35
|
+
"ng-version",
|
|
36
|
+
"ng-app",
|
|
37
|
+
"data-reactroot",
|
|
38
|
+
"data-server-rendered"
|
|
39
|
+
]);
|
|
40
|
+
/** The same marks as one CSS selector, for a reader that has a DOM. */
|
|
41
|
+
var APP_ROOT_SELECTOR = [
|
|
42
|
+
...APP_ROOT_IDS.map((id) => `[id="${id}"]`),
|
|
43
|
+
APP_ROOT_TAG,
|
|
44
|
+
...APP_ROOT_ATTRIBUTES.map((name) => `[${name}]`)
|
|
45
|
+
].join(", ");
|
|
46
|
+
/** "Enable JavaScript" notices, in the words sites use. */
|
|
47
|
+
var JS_NOTICE = /\b(?:enable|turn on|activate|allow)\s+javascript\b|\bjavascript\s+(?:is\s+)?(?:required|disabled|needed|must be enabled|is not enabled|to run this app)\b|\brequires?\s+javascript\b|\bwithout\s+javascript\b/i;
|
|
48
|
+
/** Whether one start tag (its name and its attributes by name) is an application root. */
|
|
49
|
+
function isAppRoot(name, attributes) {
|
|
50
|
+
if (name === "app-root") return true;
|
|
51
|
+
const id = attributes.get("id");
|
|
52
|
+
if (id !== void 0 && APP_ROOT_IDS.includes(id)) return true;
|
|
53
|
+
return APP_ROOT_ATTRIBUTES.some((attribute) => attributes.has(attribute));
|
|
54
|
+
}
|
|
55
|
+
// src/robots/index.ts
|
|
56
|
+
/**
|
|
57
|
+
* robots.txt (RFC 9309). Parsing and matching are pure functions of text.
|
|
58
|
+
*
|
|
59
|
+
* Matching: the group whose user-agent equals the product token (case-insensitive) applies,
|
|
60
|
+
* all such groups merged; otherwise the `*` groups merged; otherwise nothing is disallowed.
|
|
61
|
+
* The longest matching rule wins, `allow` wins a tie. `*` matches any run of characters and a
|
|
62
|
+
* final `$` anchors the end. `/robots.txt` itself is always allowed.
|
|
63
|
+
*/
|
|
64
|
+
/** Bytes of a robots.txt that are parsed (RFC 9309 asks for at least 500 KiB). */
|
|
65
|
+
var ROBOTS_MAX_BYTES = 512e3;
|
|
66
|
+
/** A robots.txt answer is used for at most this long (RFC 9309, section 2.4). */
|
|
67
|
+
var ROBOTS_MAX_AGE_MS = 864e5;
|
|
68
|
+
/** Redirects followed for robots.txt (RFC 9309 asks for at least five). */
|
|
69
|
+
var ROBOTS_MAX_REDIRECTS = 5;
|
|
70
|
+
var HEX = /%([0-9a-fA-F]{2})/g;
|
|
71
|
+
var UNRESERVED = /[A-Za-z0-9\-._~]/;
|
|
72
|
+
/**
|
|
73
|
+
* One form for patterns and paths: non-ASCII and characters outside the URL path set
|
|
74
|
+
* percent-encoded as UTF-8, escape hex upper case, escapes of unreserved characters decoded.
|
|
75
|
+
* `*` and `$` in a pattern keep their meaning.
|
|
76
|
+
*/
|
|
77
|
+
function canonicalPath(text) {
|
|
78
|
+
let out = "";
|
|
79
|
+
for (const char of text) {
|
|
80
|
+
const code = char.codePointAt(0);
|
|
81
|
+
if (code > 126 || code <= 32 || char === "\"" || char === "<" || char === ">" || char === "`") for (const byte of Buffer.from(char, "utf8")) out += `%${byte.toString(16).toUpperCase().padStart(2, "0")}`;
|
|
82
|
+
else out += char;
|
|
83
|
+
}
|
|
84
|
+
return out.replace(HEX, (escape, hex) => {
|
|
85
|
+
const decoded = String.fromCharCode(parseInt(hex, 16));
|
|
86
|
+
return UNRESERVED.test(decoded) ? decoded : `%${hex.toUpperCase()}`;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/** The product token at the start of a `User-agent` value (`Googlebot/2.1` → `googlebot`). */
|
|
90
|
+
function agentToken(value) {
|
|
91
|
+
const trimmed = value.trim();
|
|
92
|
+
if (trimmed.startsWith("*")) return "*";
|
|
93
|
+
const match = /^[A-Za-z_-]+/.exec(trimmed);
|
|
94
|
+
return match ? match[0].toLowerCase() : "";
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Parses a robots.txt body. Never throws; unknown lines are ignored. A group is a run of
|
|
98
|
+
* user-agent lines and the rules after it; only an Allow or Disallow line ends the run, so a
|
|
99
|
+
* Sitemap, Crawl-delay or unknown line between two user-agent lines does not split the group.
|
|
100
|
+
*/
|
|
101
|
+
function parseRobots(body, robotsUrl) {
|
|
102
|
+
let text = body.length > 512e3 ? body.slice(0, ROBOTS_MAX_BYTES) : body;
|
|
103
|
+
if (text.charCodeAt(0) === 65279) text = text.slice(1);
|
|
104
|
+
const groups = [];
|
|
105
|
+
const sitemaps = [];
|
|
106
|
+
let current = null;
|
|
107
|
+
let lastWasAgent = false;
|
|
108
|
+
let lineNumber = 0;
|
|
109
|
+
for (const rawLine of text.split(/\r\n|\r|\n/)) {
|
|
110
|
+
lineNumber += 1;
|
|
111
|
+
const line = rawLine.replace(/#.*$/, "").trim();
|
|
112
|
+
const colon = line.indexOf(":");
|
|
113
|
+
if (colon === -1) continue;
|
|
114
|
+
const key = line.slice(0, colon).trim().toLowerCase();
|
|
115
|
+
const value = line.slice(colon + 1).trim();
|
|
116
|
+
switch (key) {
|
|
117
|
+
case "user-agent": {
|
|
118
|
+
const token = agentToken(value);
|
|
119
|
+
if (!lastWasAgent || current === null) {
|
|
120
|
+
current = {
|
|
121
|
+
agents: [],
|
|
122
|
+
rules: [],
|
|
123
|
+
crawlDelay: null,
|
|
124
|
+
line: lineNumber
|
|
125
|
+
};
|
|
126
|
+
groups.push(current);
|
|
127
|
+
}
|
|
128
|
+
if (token !== "") current.agents.push(token);
|
|
129
|
+
lastWasAgent = true;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
case "allow":
|
|
133
|
+
case "disallow":
|
|
134
|
+
lastWasAgent = false;
|
|
135
|
+
if (current === null || value === "") break;
|
|
136
|
+
if (!value.startsWith("/") && !value.startsWith("*")) break;
|
|
137
|
+
current.rules.push({
|
|
138
|
+
allow: key === "allow",
|
|
139
|
+
pattern: canonicalPath(value),
|
|
140
|
+
line: lineNumber
|
|
141
|
+
});
|
|
142
|
+
break;
|
|
143
|
+
case "crawl-delay": {
|
|
144
|
+
const seconds = Number(value);
|
|
145
|
+
if (current !== null && Number.isFinite(seconds) && seconds >= 0) current.crawlDelay = Math.max(current.crawlDelay ?? 0, seconds);
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
case "sitemap": try {
|
|
149
|
+
sitemaps.push(new URL(value, robotsUrl).href);
|
|
150
|
+
} catch {}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return Object.freeze({
|
|
154
|
+
groups: Object.freeze(groups.map((group) => Object.freeze({
|
|
155
|
+
agents: Object.freeze(group.agents),
|
|
156
|
+
rules: Object.freeze(group.rules),
|
|
157
|
+
crawlDelay: group.crawlDelay,
|
|
158
|
+
line: group.line
|
|
159
|
+
}))),
|
|
160
|
+
sitemaps: Object.freeze([...new Set(sitemaps)])
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
/** Whether a pattern matches the start of a path (RFC 9309 2.2.3). */
|
|
164
|
+
function patternMatches(pattern, path) {
|
|
165
|
+
const anchored = pattern.endsWith("$");
|
|
166
|
+
const parts = (anchored ? pattern.slice(0, -1) : pattern).split("*");
|
|
167
|
+
if (!path.startsWith(parts[0])) return false;
|
|
168
|
+
let position = parts[0].length;
|
|
169
|
+
for (let index = 1; index < parts.length; index += 1) {
|
|
170
|
+
const part = parts[index];
|
|
171
|
+
if (index === parts.length - 1 && anchored) return path.length - part.length >= position && path.endsWith(part);
|
|
172
|
+
const found = path.indexOf(part, position);
|
|
173
|
+
if (found === -1) return false;
|
|
174
|
+
position = found + part.length;
|
|
175
|
+
}
|
|
176
|
+
return anchored ? position === path.length : true;
|
|
177
|
+
}
|
|
178
|
+
/** The groups that apply to a product token, merged (RFC 9309 2.2.1). */
|
|
179
|
+
function groupsFor(robots, productToken) {
|
|
180
|
+
const token = productToken.toLowerCase();
|
|
181
|
+
const exact = robots.groups.filter((group) => group.agents.includes(token));
|
|
182
|
+
if (exact.length > 0) return exact;
|
|
183
|
+
return robots.groups.filter((group) => group.agents.includes("*"));
|
|
184
|
+
}
|
|
185
|
+
var NO_RULE = Object.freeze({
|
|
186
|
+
best: null,
|
|
187
|
+
bestGroup: null
|
|
188
|
+
});
|
|
189
|
+
/**
|
|
190
|
+
* The deciding rule for a set of groups and a path, computed once per robots.txt: agents that
|
|
191
|
+
* share their groups (several named in one group, or all on the catch-all) and repeated
|
|
192
|
+
* questions reuse it, so a file of many rules is matched about once per path, not once per
|
|
193
|
+
* agent. Kept with the parsed file and dropped with it.
|
|
194
|
+
*/
|
|
195
|
+
var DECIDED = /* @__PURE__ */ new WeakMap();
|
|
196
|
+
function decidingRule(robots, groups, path) {
|
|
197
|
+
let cache = DECIDED.get(robots);
|
|
198
|
+
if (cache === void 0) {
|
|
199
|
+
cache = {
|
|
200
|
+
index: new Map(robots.groups.map((group, index) => [group, index])),
|
|
201
|
+
rules: /* @__PURE__ */ new Map()
|
|
202
|
+
};
|
|
203
|
+
DECIDED.set(robots, cache);
|
|
204
|
+
}
|
|
205
|
+
const key = `${groups.map((group) => cache.index.get(group)).join(",")}|${path}`;
|
|
206
|
+
const known = cache.rules.get(key);
|
|
207
|
+
if (known !== void 0) return known;
|
|
208
|
+
let best = null;
|
|
209
|
+
let bestGroup = null;
|
|
210
|
+
for (const group of groups) for (const rule of group.rules) {
|
|
211
|
+
if (!patternMatches(rule.pattern, path)) continue;
|
|
212
|
+
if (best === null || rule.pattern.length > best.pattern.length || rule.pattern.length === best.pattern.length && rule.allow && !best.allow) {
|
|
213
|
+
best = rule;
|
|
214
|
+
bestGroup = group;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const decided = Object.freeze({
|
|
218
|
+
best,
|
|
219
|
+
bestGroup
|
|
220
|
+
});
|
|
221
|
+
cache.rules.set(key, decided);
|
|
222
|
+
return decided;
|
|
223
|
+
}
|
|
224
|
+
function matchRobots(robots, productToken, pathAndQuery) {
|
|
225
|
+
const token = productToken.toLowerCase();
|
|
226
|
+
const groups = groupsFor(robots, token);
|
|
227
|
+
const via = groups.length === 0 ? "none" : token !== "*" && groups[0].agents.includes(token) ? "named" : "catch_all";
|
|
228
|
+
const path = canonicalPath(pathAndQuery === "" ? "/" : pathAndQuery);
|
|
229
|
+
const { best, bestGroup } = path === "/robots.txt" ? NO_RULE : decidingRule(robots, groups, path);
|
|
230
|
+
const shown = bestGroup ?? groups[0] ?? null;
|
|
231
|
+
return Object.freeze({
|
|
232
|
+
allowed: best === null || best.allow,
|
|
233
|
+
via,
|
|
234
|
+
group: shown === null ? null : Object.freeze({
|
|
235
|
+
line: shown.line,
|
|
236
|
+
agents: shown.agents
|
|
237
|
+
}),
|
|
238
|
+
rule: best
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Whether robots.txt allows a URL path (path plus `?query`, as sent in the request line) for
|
|
243
|
+
* a product token.
|
|
244
|
+
*/
|
|
245
|
+
function isAllowedByRobots(robots, productToken, pathAndQuery) {
|
|
246
|
+
return matchRobots(robots, productToken, pathAndQuery).allowed;
|
|
247
|
+
}
|
|
248
|
+
/** `Crawl-delay` for a product token, in seconds; null when none is set. */
|
|
249
|
+
function crawlDelayFor(robots, productToken) {
|
|
250
|
+
const delays = groupsFor(robots, productToken).map((group) => group.crawlDelay).filter((delay) => delay !== null);
|
|
251
|
+
return delays.length === 0 ? null : Math.max(...delays);
|
|
252
|
+
}
|
|
253
|
+
// src/crawlers/index.ts
|
|
254
|
+
/**
|
|
255
|
+
* Which AI crawlers are measured, and what a robots.txt says to each of them. Nothing outside
|
|
256
|
+
* `STUDY_AGENTS` is measured or named.
|
|
257
|
+
*/
|
|
258
|
+
/**
|
|
259
|
+
* The product tokens measured, and the catch-all. The study names the vendors it measures on its
|
|
260
|
+
* page; any crawler not listed here is neither measured nor named.
|
|
261
|
+
*/
|
|
262
|
+
var STUDY_AGENTS = [
|
|
263
|
+
"GPTBot",
|
|
264
|
+
"OAI-SearchBot",
|
|
265
|
+
"ChatGPT-User",
|
|
266
|
+
"PerplexityBot",
|
|
267
|
+
"Perplexity-User",
|
|
268
|
+
"Google-Extended",
|
|
269
|
+
"Applebot-Extended",
|
|
270
|
+
"CCBot",
|
|
271
|
+
"Meta-ExternalAgent",
|
|
272
|
+
"Amazonbot",
|
|
273
|
+
"Bytespider",
|
|
274
|
+
"*"
|
|
275
|
+
];
|
|
276
|
+
/** The AI agents (every measured token but the catch-all). */
|
|
277
|
+
var AI_AGENTS = STUDY_AGENTS.filter((agent) => agent !== "*");
|
|
278
|
+
/** A pattern that matches every path: `/`, `/*` or `*`. */
|
|
279
|
+
function wholeSite(pattern) {
|
|
280
|
+
return pattern === "/" || pattern === "/*" || pattern === "*";
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Verdicts depend only on the groups that apply, so they are kept per parsed file and per set
|
|
284
|
+
* of groups: agents named together, or all left to the catch-all, share one computation, and a
|
|
285
|
+
* large file is read about once whatever the number of agents asked about.
|
|
286
|
+
*/
|
|
287
|
+
var VERDICTS = /* @__PURE__ */ new WeakMap();
|
|
288
|
+
function memoFor(robots) {
|
|
289
|
+
let memo = VERDICTS.get(robots);
|
|
290
|
+
if (memo === void 0) {
|
|
291
|
+
memo = {
|
|
292
|
+
index: new Map(robots.groups.map((group, index) => [group, index])),
|
|
293
|
+
housekeeping: null,
|
|
294
|
+
byGroups: /* @__PURE__ */ new Map()
|
|
295
|
+
};
|
|
296
|
+
VERDICTS.set(robots, memo);
|
|
297
|
+
}
|
|
298
|
+
return memo;
|
|
299
|
+
}
|
|
300
|
+
function agentVerdict(robots, agent) {
|
|
301
|
+
const token = agent.toLowerCase();
|
|
302
|
+
const own = token === "*" ? [] : robots.groups.filter((group) => group.agents.includes(token));
|
|
303
|
+
const named = own.length > 0;
|
|
304
|
+
const memo = memoFor(robots);
|
|
305
|
+
const key = named ? own.map((group) => memo.index.get(group)).join(",") : "*";
|
|
306
|
+
const known = memo.byGroups.get(key);
|
|
307
|
+
if (known !== void 0) return known;
|
|
308
|
+
const verdict = computeVerdict(robots, agent, own, named, memo);
|
|
309
|
+
memo.byGroups.set(key, verdict);
|
|
310
|
+
return verdict;
|
|
311
|
+
}
|
|
312
|
+
function computeVerdict(robots, agent, own, named, memo) {
|
|
313
|
+
const rules = (named ? own : robots.groups.filter((group) => group.agents.includes("*"))).flatMap((group) => group.rules);
|
|
314
|
+
const as = named ? agent : "*";
|
|
315
|
+
const rootAllowed = isAllowedByRobots(robots, as, "/");
|
|
316
|
+
const allows = rules.filter((rule) => rule.allow);
|
|
317
|
+
const disallows = rules.filter((rule) => !rule.allow);
|
|
318
|
+
let access;
|
|
319
|
+
if (!rootAllowed) access = !disallows.some((rule) => wholeSite(rule.pattern)) || allows.some((rule) => !wholeSite(rule.pattern)) ? "partial" : "blocked";
|
|
320
|
+
else if (disallows.some((rule) => wholeSite(rule.pattern))) access = allows.some((rule) => wholeSite(rule.pattern)) ? "allowed" : "partial";
|
|
321
|
+
else {
|
|
322
|
+
memo.housekeeping ??= new Set(robots.groups.filter((group) => group.agents.includes("*")).flatMap((group) => group.rules).filter((rule) => !rule.allow).map((rule) => rule.pattern));
|
|
323
|
+
const housekeeping = memo.housekeeping;
|
|
324
|
+
access = named && disallows.some((rule) => !housekeeping.has(rule.pattern)) ? "partial" : "allowed";
|
|
325
|
+
}
|
|
326
|
+
return Object.freeze({
|
|
327
|
+
access,
|
|
328
|
+
named,
|
|
329
|
+
llmsTxt: isAllowedByRobots(robots, as, "/llms.txt")
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
/** The verdicts of every measured agent for one robots.txt. */
|
|
333
|
+
function verdictsFor(robots) {
|
|
334
|
+
return Object.fromEntries(STUDY_AGENTS.map((agent) => [agent, agentVerdict(robots, agent)]));
|
|
335
|
+
}
|
|
336
|
+
/** The purposes, in the order they are listed. */
|
|
337
|
+
var CRAWLER_PURPOSES = [
|
|
338
|
+
"training",
|
|
339
|
+
"search",
|
|
340
|
+
"user"
|
|
341
|
+
];
|
|
342
|
+
/** The crawlers covered, in the order of `AI_AGENTS`. No other crawler is measured or named. */
|
|
343
|
+
var CRAWLERS = Object.freeze([
|
|
344
|
+
{
|
|
345
|
+
token: "GPTBot",
|
|
346
|
+
vendor: "OpenAI",
|
|
347
|
+
purpose: "training"
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
token: "OAI-SearchBot",
|
|
351
|
+
vendor: "OpenAI",
|
|
352
|
+
purpose: "search"
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
token: "ChatGPT-User",
|
|
356
|
+
vendor: "OpenAI",
|
|
357
|
+
purpose: "user"
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
token: "PerplexityBot",
|
|
361
|
+
vendor: "Perplexity",
|
|
362
|
+
purpose: "search"
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
token: "Perplexity-User",
|
|
366
|
+
vendor: "Perplexity",
|
|
367
|
+
purpose: "user"
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
token: "Google-Extended",
|
|
371
|
+
vendor: "Google",
|
|
372
|
+
purpose: "training"
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
token: "Applebot-Extended",
|
|
376
|
+
vendor: "Apple",
|
|
377
|
+
purpose: "training"
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
token: "CCBot",
|
|
381
|
+
vendor: "Common Crawl",
|
|
382
|
+
purpose: "training"
|
|
383
|
+
},
|
|
384
|
+
{
|
|
385
|
+
token: "Meta-ExternalAgent",
|
|
386
|
+
vendor: "Meta",
|
|
387
|
+
purpose: "training"
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
token: "Amazonbot",
|
|
391
|
+
vendor: "Amazon",
|
|
392
|
+
purpose: "search"
|
|
393
|
+
},
|
|
394
|
+
{
|
|
395
|
+
token: "Bytespider",
|
|
396
|
+
vendor: "ByteDance",
|
|
397
|
+
purpose: "training"
|
|
398
|
+
}
|
|
399
|
+
]);
|
|
400
|
+
/** The vendors of the crawlers covered, each once, in the order of `CRAWLERS`. */
|
|
401
|
+
function coveredVendors() {
|
|
402
|
+
return [...new Set(CRAWLERS.map((crawler) => crawler.vendor))];
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* The scope line every report carries: which crawlers were checked. A report never claims
|
|
406
|
+
* to cover every AI crawler.
|
|
407
|
+
*/
|
|
408
|
+
function crawlersCoveredLine() {
|
|
409
|
+
const vendors = coveredVendors();
|
|
410
|
+
const list = `${vendors.slice(0, -1).join(", ")} and ${vendors[vendors.length - 1]}`;
|
|
411
|
+
return `Crawlers covered: the ${CRAWLERS.length} AI crawlers on this list, from ${list}. Other crawlers are not checked.`;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* The access of every covered crawler: the verdict for the site, and the deciding rule for `/`
|
|
415
|
+
* and for `path` (a path and query, such as `/blog/post?x=1`). A missing robots.txt is an
|
|
416
|
+
* empty one: pass `parseRobots('')`.
|
|
417
|
+
*/
|
|
418
|
+
function crawlerAccess(robots, options = {}) {
|
|
419
|
+
const path = options.path ?? "/";
|
|
420
|
+
return CRAWLERS.map((crawler) => {
|
|
421
|
+
const verdict = agentVerdict(robots, crawler.token);
|
|
422
|
+
return Object.freeze({
|
|
423
|
+
crawler,
|
|
424
|
+
access: verdict.access,
|
|
425
|
+
named: verdict.named,
|
|
426
|
+
root: matchRobots(robots, crawler.token, "/"),
|
|
427
|
+
path: path === "/" ? null : matchRobots(robots, crawler.token, path),
|
|
428
|
+
llmsTxt: verdict.llmsTxt
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
// src/llms/index.ts
|
|
433
|
+
/** llms.txt checks: telling the file from a page, and its structure (llmstxt.org). */
|
|
434
|
+
/** A byte order mark at the start of a text. */
|
|
435
|
+
var BOM = new RegExp(`^${String.fromCharCode(65279)}`);
|
|
436
|
+
/** Whether a body is an HTML document (a site that answers every address with a page). */
|
|
437
|
+
function looksLikeHtml(contentType, body) {
|
|
438
|
+
if (contentType === "text/html" || contentType === "application/xhtml+xml") return true;
|
|
439
|
+
const start = body.replace(BOM, "").trimStart().slice(0, 200).toLowerCase();
|
|
440
|
+
return start.startsWith("<!doctype html") || start.startsWith("<html") || start.startsWith("<head");
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* A robots.txt served at another address (a site that answers every path with it): a
|
|
444
|
+
* `User-agent` line and no Markdown link. It is not an llms.txt, whatever its first line.
|
|
445
|
+
*/
|
|
446
|
+
function looksLikeRobotsTxt(body) {
|
|
447
|
+
return /^[ \t]*user-agent[ \t]*:/im.test(body) && llmsStructure(body).links === 0;
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Characters of a file read for its structure. An llms.txt is meant to be a short map; a
|
|
451
|
+
* longer file is checked on its start, so the work stays small whatever a site serves.
|
|
452
|
+
*/
|
|
453
|
+
var LLMS_STRUCTURE_MAX_CHARS = 262144;
|
|
454
|
+
/** Whitespace as `\s` matches it, tested one character at a time. */
|
|
455
|
+
var SPACE = /\s/;
|
|
456
|
+
/**
|
|
457
|
+
* The Markdown link targets of a text, `[text](target "title")` but not images, in one pass:
|
|
458
|
+
* every search starts after the last, so no text, however built, makes it slower than
|
|
459
|
+
* linear. The link text may not hold `]` or a line break; the target holds no space, `)` or
|
|
460
|
+
* `>`, and may sit in `<…>`; an optional quoted title follows.
|
|
461
|
+
*/
|
|
462
|
+
function linkTargets(text) {
|
|
463
|
+
const out = [];
|
|
464
|
+
const length = text.length;
|
|
465
|
+
let close = -1;
|
|
466
|
+
let lineEnd = -1;
|
|
467
|
+
let stop = -1;
|
|
468
|
+
let at = 0;
|
|
469
|
+
for (;;) {
|
|
470
|
+
const open = text.indexOf("[", at);
|
|
471
|
+
if (open === -1) break;
|
|
472
|
+
if (close <= open) close = text.indexOf("]", open + 1);
|
|
473
|
+
if (close === -1) break;
|
|
474
|
+
if (lineEnd <= open) {
|
|
475
|
+
const found = text.indexOf("\n", open + 1);
|
|
476
|
+
lineEnd = found === -1 ? length : found;
|
|
477
|
+
}
|
|
478
|
+
if (lineEnd < close) {
|
|
479
|
+
at = lineEnd + 1;
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
if (open > 0 && text[open - 1] === "!") {
|
|
483
|
+
at = open + 1;
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
at = close + 1;
|
|
487
|
+
if (text[close + 1] !== "(") continue;
|
|
488
|
+
let p = close + 2;
|
|
489
|
+
while (p < length && SPACE.test(text[p])) p += 1;
|
|
490
|
+
if (text[p] === "<") p += 1;
|
|
491
|
+
const from = p;
|
|
492
|
+
if (stop < from) {
|
|
493
|
+
stop = from;
|
|
494
|
+
while (stop < length && text[stop] !== ")" && text[stop] !== ">" && !SPACE.test(text[stop])) stop += 1;
|
|
495
|
+
}
|
|
496
|
+
p = stop;
|
|
497
|
+
if (p === from) continue;
|
|
498
|
+
const target = text.slice(from, p);
|
|
499
|
+
if (text[p] === ">") p += 1;
|
|
500
|
+
let q = p;
|
|
501
|
+
while (q < length && SPACE.test(text[q])) q += 1;
|
|
502
|
+
if (q > p && text[q] === "\"") {
|
|
503
|
+
const quote = text.indexOf("\"", q + 1);
|
|
504
|
+
if (quote !== -1 && quote - q <= 1e3) {
|
|
505
|
+
let r = quote + 1;
|
|
506
|
+
while (r < length && SPACE.test(text[r])) r += 1;
|
|
507
|
+
if (text[r] === ")") {
|
|
508
|
+
out.push(target);
|
|
509
|
+
at = r + 1;
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
while (p < length && SPACE.test(text[p])) p += 1;
|
|
515
|
+
if (text[p] === ")") {
|
|
516
|
+
out.push(target);
|
|
517
|
+
at = p + 1;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return out;
|
|
521
|
+
}
|
|
522
|
+
/** Whether a line holds a link start: a `[` (not an image's) whose text closes with `](`. */
|
|
523
|
+
function hasLinkStart(line) {
|
|
524
|
+
let open = false;
|
|
525
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
526
|
+
const char = line[index];
|
|
527
|
+
if (char === "[") {
|
|
528
|
+
if (index === 0 || line[index - 1] !== "!") open = true;
|
|
529
|
+
} else if (char === "]") {
|
|
530
|
+
if (open && line[index + 1] === "(") return true;
|
|
531
|
+
open = false;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
function llmsStructure(body) {
|
|
537
|
+
const full = body.replace(BOM, "");
|
|
538
|
+
const text = full.length > 262144 ? full.slice(0, LLMS_STRUCTURE_MAX_CHARS) : full;
|
|
539
|
+
const lines = text.split(/\r\n|\r|\n/);
|
|
540
|
+
let summary = false;
|
|
541
|
+
let sections = 0;
|
|
542
|
+
let inSection = false;
|
|
543
|
+
let sectionHasLink = false;
|
|
544
|
+
let seenH2 = false;
|
|
545
|
+
for (const line of lines) {
|
|
546
|
+
const trimmed = line.trimStart();
|
|
547
|
+
if (/^##\s+\S/.test(trimmed) && !trimmed.startsWith("###")) {
|
|
548
|
+
if (inSection && sectionHasLink) sections += 1;
|
|
549
|
+
inSection = true;
|
|
550
|
+
seenH2 = true;
|
|
551
|
+
sectionHasLink = false;
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
if (!seenH2 && /^>\s*\S/.test(trimmed)) summary = true;
|
|
555
|
+
if (inSection && /^[-*+]\s/.test(trimmed) && trimmed.indexOf("](", 2) !== -1 && hasLinkStart(trimmed)) sectionHasLink = true;
|
|
556
|
+
}
|
|
557
|
+
if (inSection && sectionHasLink) sections += 1;
|
|
558
|
+
const targets = linkTargets(text);
|
|
559
|
+
return {
|
|
560
|
+
bytes: Buffer.byteLength(full, "utf8"),
|
|
561
|
+
summary,
|
|
562
|
+
sections,
|
|
563
|
+
links: targets.length,
|
|
564
|
+
structured: sections > 0,
|
|
565
|
+
targets
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Whether an answer is an llms.txt: a 200, a text type that is not HTML, not a robots.txt
|
|
570
|
+
* served at that address, and a body whose first non-empty line is a Markdown H1 (`# Name`),
|
|
571
|
+
* which is the one part llmstxt.org requires.
|
|
572
|
+
*/
|
|
573
|
+
function looksLikeLlmsTxt(status, contentType, body) {
|
|
574
|
+
if (status !== 200) return false;
|
|
575
|
+
if (contentType !== null && !contentType.startsWith("text/")) return false;
|
|
576
|
+
if (looksLikeHtml(contentType, body)) return false;
|
|
577
|
+
if (looksLikeRobotsTxt(body)) return false;
|
|
578
|
+
const first = body.replace(BOM, "").split(/\r\n|\r|\n/).find((line) => line.trim() !== "");
|
|
579
|
+
return first !== void 0 && /^# \S/.test(first.trimStart());
|
|
580
|
+
}
|
|
581
|
+
/** Characters of an H1 kept in a report. */
|
|
582
|
+
var LLMS_H1_MAX = 200;
|
|
583
|
+
function headings(body) {
|
|
584
|
+
const found = [];
|
|
585
|
+
let fenced = false;
|
|
586
|
+
for (const line of body.replace(BOM, "").split(/\r\n|\r|\n/)) {
|
|
587
|
+
const trimmed = line.trimStart();
|
|
588
|
+
if (/^(```|~~~)/.test(trimmed)) {
|
|
589
|
+
fenced = !fenced;
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
if (!fenced && /^# \S/.test(trimmed)) found.push(trimmed.slice(2).trim());
|
|
593
|
+
}
|
|
594
|
+
return found;
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Validates an answer at `/llms.txt` against the llmstxt.org format, after the soft-404 rule:
|
|
598
|
+
* an answer that is not a 200, is HTML, is a robots.txt, or does not start with an H1 is not
|
|
599
|
+
* an llms.txt, whatever its status. Pure: pass what was fetched.
|
|
600
|
+
*/
|
|
601
|
+
function validateLlmsTxt(answer) {
|
|
602
|
+
const body = answer.body ?? "";
|
|
603
|
+
const base = {
|
|
604
|
+
status: answer.status,
|
|
605
|
+
contentType: answer.contentType,
|
|
606
|
+
bytes: Buffer.byteLength(body, "utf8"),
|
|
607
|
+
truncated: answer.truncated
|
|
608
|
+
};
|
|
609
|
+
const verdict = (() => {
|
|
610
|
+
if (answer.status === null) return "no_answer";
|
|
611
|
+
if (answer.status === 404 || answer.status === 410) return "not_found";
|
|
612
|
+
if (answer.status !== 200) return "status";
|
|
613
|
+
if (answer.contentType !== null && !answer.contentType.startsWith("text/")) return "not_text";
|
|
614
|
+
if (looksLikeHtml(answer.contentType, body)) return "html";
|
|
615
|
+
if (looksLikeRobotsTxt(body)) return "robots";
|
|
616
|
+
return looksLikeLlmsTxt(answer.status, answer.contentType, body) ? "llms_txt" : "no_h1";
|
|
617
|
+
})();
|
|
618
|
+
if (verdict !== "llms_txt") return Object.freeze({
|
|
619
|
+
...base,
|
|
620
|
+
present: false,
|
|
621
|
+
verdict,
|
|
622
|
+
h1: null,
|
|
623
|
+
h1Count: 0,
|
|
624
|
+
structure: null
|
|
625
|
+
});
|
|
626
|
+
const found = headings(body);
|
|
627
|
+
return Object.freeze({
|
|
628
|
+
...base,
|
|
629
|
+
present: true,
|
|
630
|
+
verdict,
|
|
631
|
+
h1: found[0] === void 0 ? null : found[0].slice(0, 200),
|
|
632
|
+
h1Count: found.length,
|
|
633
|
+
structure: llmsStructure(body)
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
// src/facts/domain.ts
|
|
637
|
+
/**
|
|
638
|
+
* A registrable domain without a public-suffix list. The library keeps one runtime dependency,
|
|
639
|
+
* so the built-in rule is small: the last two labels, or the last three when the last two are a
|
|
640
|
+
* common second-level suffix (`co.uk`, `com.au`). A caller that needs the full list passes its
|
|
641
|
+
* own function (`CheckOptions.registrableDomain`, `ReadHomePageOptions.registrableDomain`).
|
|
642
|
+
*/
|
|
643
|
+
var SECOND_LEVEL = new Set([
|
|
644
|
+
"co.uk org.uk ac.uk gov.uk me.uk ltd.uk plc.uk net.uk sch.uk",
|
|
645
|
+
"com.au net.au org.au edu.au gov.au id.au asn.au",
|
|
646
|
+
"co.nz net.nz org.nz govt.nz ac.nz",
|
|
647
|
+
"co.jp ne.jp or.jp ac.jp go.jp ad.jp ed.jp gr.jp",
|
|
648
|
+
"co.kr or.kr ne.kr go.kr ac.kr",
|
|
649
|
+
"com.cn net.cn org.cn gov.cn edu.cn",
|
|
650
|
+
"com.hk org.hk net.hk edu.hk gov.hk",
|
|
651
|
+
"com.tw org.tw net.tw edu.tw gov.tw",
|
|
652
|
+
"com.sg org.sg net.sg edu.sg gov.sg",
|
|
653
|
+
"com.my org.my net.my edu.my gov.my",
|
|
654
|
+
"com.vn net.vn org.vn edu.vn gov.vn",
|
|
655
|
+
"co.th or.th ac.th go.th in.th",
|
|
656
|
+
"co.id or.id ac.id go.id web.id",
|
|
657
|
+
"com.ph org.ph net.ph edu.ph gov.ph",
|
|
658
|
+
"com.pk org.pk net.pk edu.pk gov.pk",
|
|
659
|
+
"com.bd org.bd net.bd edu.bd gov.bd",
|
|
660
|
+
"co.in net.in org.in ac.in gov.in edu.in firm.in gen.in ind.in",
|
|
661
|
+
"co.il org.il ac.il gov.il net.il",
|
|
662
|
+
"co.za org.za ac.za gov.za net.za web.za",
|
|
663
|
+
"com.br net.br org.br gov.br edu.br",
|
|
664
|
+
"com.ar net.ar org.ar gov.ar edu.ar",
|
|
665
|
+
"com.mx org.mx net.mx gob.mx edu.mx",
|
|
666
|
+
"com.co net.co org.co edu.co gov.co",
|
|
667
|
+
"com.pe com.ve com.uy com.ec com.bo com.py com.pa com.gt com.do",
|
|
668
|
+
"com.tr net.tr org.tr gov.tr edu.tr",
|
|
669
|
+
"com.ua org.ua net.ua gov.ua edu.ua",
|
|
670
|
+
"com.sa org.sa net.sa gov.sa edu.sa",
|
|
671
|
+
"com.eg org.eg net.eg gov.eg edu.eg",
|
|
672
|
+
"com.ng org.ng net.ng gov.ng edu.ng",
|
|
673
|
+
"co.ke or.ke ac.ke go.ke",
|
|
674
|
+
"com.gh com.tn com.dz com.ma co.ma",
|
|
675
|
+
"com.ru net.ru org.ru",
|
|
676
|
+
"com.pl net.pl org.pl",
|
|
677
|
+
"com.es org.es nom.es",
|
|
678
|
+
"com.pt org.pt",
|
|
679
|
+
"com.gr org.gr",
|
|
680
|
+
"com.cy org.cy",
|
|
681
|
+
"com.lk com.np com.kh com.mm com.kz"
|
|
682
|
+
].join(" ").split(" "));
|
|
683
|
+
/** A host name as comparisons use it: lower case, without one trailing dot. */
|
|
684
|
+
function normalizeHost(host) {
|
|
685
|
+
const lower = host.toLowerCase();
|
|
686
|
+
return lower.endsWith(".") ? lower.slice(0, -1) : lower;
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* The registrable domain of a host name (`www.shop.example.co.uk` → `example.co.uk`). A name
|
|
690
|
+
* with one label, or an IP literal, is returned as it is.
|
|
691
|
+
*/
|
|
692
|
+
function registrableDomain(host) {
|
|
693
|
+
const name = normalizeHost(host);
|
|
694
|
+
if (name.includes(":") || /^\d{1,3}(?:\.\d{1,3}){3}$/.test(name)) return name;
|
|
695
|
+
const labels = name.split(".");
|
|
696
|
+
if (labels.length <= 2) return name;
|
|
697
|
+
const lastTwo = labels.slice(-2).join(".");
|
|
698
|
+
if (SECOND_LEVEL.has(lastTwo) && labels.length >= 3) return labels.slice(-3).join(".");
|
|
699
|
+
return lastTwo;
|
|
700
|
+
}
|
|
701
|
+
/** Whether `host` is the registrable domain `domain` or a name under it. */
|
|
702
|
+
function isOnDomain(host, domain) {
|
|
703
|
+
const name = normalizeHost(host);
|
|
704
|
+
return name === domain || name.endsWith(`.${domain}`);
|
|
705
|
+
}
|
|
706
|
+
// src/net/charset.ts
|
|
707
|
+
/**
|
|
708
|
+
* Reading a page's bytes as text. One decoder for the library and for the studies built on it,
|
|
709
|
+
* so a page in windows-1251, Shift_JIS, GBK or EUC-KR is counted the same everywhere. The order
|
|
710
|
+
* is the one browsers use, shortened: a byte order mark; the `charset` of the Content-Type
|
|
711
|
+
* header; a `<meta charset>` or `<meta http-equiv="Content-Type">` in the first 2 KiB; else
|
|
712
|
+
* UTF-8. Labels are the WHATWG Encoding Standard's; a label it does not know is skipped.
|
|
713
|
+
*/
|
|
714
|
+
var LABEL = /^[a-z0-9_:.-]{2,40}$/i;
|
|
715
|
+
var HEAD_BYTES = 2048;
|
|
716
|
+
function known(label) {
|
|
717
|
+
if (label === void 0 || !LABEL.test(label)) return null;
|
|
718
|
+
try {
|
|
719
|
+
return new TextDecoder(label).encoding;
|
|
720
|
+
} catch {
|
|
721
|
+
return null;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
/** The character set of a page by the rule above. */
|
|
725
|
+
function detectCharset(bytes, contentTypeHeader) {
|
|
726
|
+
if (bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191) return "utf-8";
|
|
727
|
+
if (bytes[0] === 255 && bytes[1] === 254) return "utf-16le";
|
|
728
|
+
if (bytes[0] === 254 && bytes[1] === 255) return "utf-16be";
|
|
729
|
+
const fromHeader = known(/charset\s*=\s*["']?([^\s;"']+)/i.exec(contentTypeHeader ?? "")?.[1]);
|
|
730
|
+
if (fromHeader !== null) return fromHeader;
|
|
731
|
+
const head = Buffer.from(bytes.subarray(0, HEAD_BYTES)).toString("latin1");
|
|
732
|
+
return known(/<meta[^>]+charset\s*=\s*["']?\s*([a-z0-9_:.-]+)/i.exec(head)?.[1]) ?? "utf-8";
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* The bytes of an HTML page as text. `contentTypeHeader` is the raw `Content-Type` header, with
|
|
736
|
+
* its parameters. A body cut at a size cap (`truncated`) loses no character to the cut: an
|
|
737
|
+
* incomplete one at the end is held back.
|
|
738
|
+
*/
|
|
739
|
+
function decodeHtml(bytes, contentTypeHeader, truncated = false) {
|
|
740
|
+
const charset = detectCharset(bytes, contentTypeHeader);
|
|
741
|
+
return {
|
|
742
|
+
text: new TextDecoder(charset).decode(bytes, { stream: truncated }),
|
|
743
|
+
charset
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
// src/facts/marks.ts
|
|
747
|
+
/**
|
|
748
|
+
* Text marks for answers that are not the page asked for: a bot check, and a placeholder (a
|
|
749
|
+
* parking page or a web server's default page). Fixed lists, so the same body gives the same
|
|
750
|
+
* answer in every tool.
|
|
751
|
+
*/
|
|
752
|
+
/** HTML markup anywhere in a body: no robots.txt holds it. */
|
|
753
|
+
var HTML_MARKUP = /<(?:!doctype\s+html|html|head|body|script|meta|title)[\s>]/i;
|
|
754
|
+
/**
|
|
755
|
+
* The marks of a bot check served in place of a file: Cloudflare ("Just a moment…", its
|
|
756
|
+
* challenge platform), reCAPTCHA, hCaptcha, Akamai Bot Manager, HUMAN (PerimeterX), DataDome,
|
|
757
|
+
* Imperva (Incapsula), AWS WAF, Sucuri, a hosting platform's security checkpoint, Kasada, and the
|
|
758
|
+
* generic wording.
|
|
759
|
+
*/
|
|
760
|
+
var CHALLENGE_MARKS = new RegExp([
|
|
761
|
+
"just a moment\\.\\.\\.",
|
|
762
|
+
"cf-browser-verification",
|
|
763
|
+
"cf_chl_",
|
|
764
|
+
"challenge-platform",
|
|
765
|
+
"challenges\\.cloudflare\\.com",
|
|
766
|
+
"attention required! \\| cloudflare",
|
|
767
|
+
"recaptcha",
|
|
768
|
+
"hcaptcha",
|
|
769
|
+
"errors\\.edgesuite\\.net",
|
|
770
|
+
"_sec/cp_challenge",
|
|
771
|
+
"bm-verify",
|
|
772
|
+
"px-captcha",
|
|
773
|
+
"_pxappid",
|
|
774
|
+
"perimeterx",
|
|
775
|
+
"captcha-delivery\\.com",
|
|
776
|
+
"datadome",
|
|
777
|
+
"_incapsula_resource",
|
|
778
|
+
"incapsula incident",
|
|
779
|
+
"awswaf",
|
|
780
|
+
"aws-waf-token",
|
|
781
|
+
"gokuprops",
|
|
782
|
+
"sucuri website firewall",
|
|
783
|
+
"sucuri_cloudproxy",
|
|
784
|
+
`vercel security checkpoint`,
|
|
785
|
+
"kpsdk",
|
|
786
|
+
"verify you are human",
|
|
787
|
+
"checking your browser",
|
|
788
|
+
"enable javascript and cookies to continue"
|
|
789
|
+
].join("|"), "i");
|
|
790
|
+
/**
|
|
791
|
+
* Whether a 2xx answer at /robots.txt is not the file: a 202 (a check still running, most
|
|
792
|
+
* often), or a body with no robots.txt group that is an HTML page or carries the marks of a bot
|
|
793
|
+
* check. Such a site is "not read", never "read, no rules". A body with at least one group is a
|
|
794
|
+
* file whatever its content type says.
|
|
795
|
+
*/
|
|
796
|
+
function isChallenge(status, body) {
|
|
797
|
+
if (status === 202) return true;
|
|
798
|
+
if (parseRobots(body).groups.length > 0) return false;
|
|
799
|
+
return HTML_MARKUP.test(body) || CHALLENGE_MARKS.test(body);
|
|
800
|
+
}
|
|
801
|
+
/** A page with fewer words than this, carrying an interstitial mark, is a bot check. */
|
|
802
|
+
var CHALLENGE_MAX_WORDS = 100;
|
|
803
|
+
/** A page with fewer words than this, carrying a mark, is a placeholder. */
|
|
804
|
+
var PLACEHOLDER_MAX_WORDS = 150;
|
|
805
|
+
/** Characters of a page searched for marks: the head and the start of the body. */
|
|
806
|
+
var MARKS_SCAN_CHARS = 65536;
|
|
807
|
+
/**
|
|
808
|
+
* What a bot-check page says to a visitor, in its title or visible text: Cloudflare's "Just a
|
|
809
|
+
* moment..." and "One more step", "Checking your browser", Imperva's "Pardon Our Interruption",
|
|
810
|
+
* "Verify you are human", "Access denied" beside a captcha, a security checkpoint, and the
|
|
811
|
+
* generic wording. These are for the home page, matched against text a visitor sees, never
|
|
812
|
+
* against script sources: the scripts of a bot-check vendor (`challenge-platform`, a captcha
|
|
813
|
+
* library, `datadome`) are loaded on ordinary pages too, and an empty application shell that
|
|
814
|
+
* loads one is a page, not a check.
|
|
815
|
+
*/
|
|
816
|
+
var INTERSTITIAL_MARKS = new RegExp([
|
|
817
|
+
"just a moment\\.\\.\\.",
|
|
818
|
+
"one more step",
|
|
819
|
+
"checking your browser",
|
|
820
|
+
"verify you are human",
|
|
821
|
+
"are you a robot",
|
|
822
|
+
"attention required! \\| cloudflare",
|
|
823
|
+
"pardon our interruption",
|
|
824
|
+
"please complete the security check",
|
|
825
|
+
"enable javascript and cookies to continue",
|
|
826
|
+
"incapsula incident",
|
|
827
|
+
"sucuri website firewall",
|
|
828
|
+
"you have been blocked",
|
|
829
|
+
"access denied[\\s\\S]{0,200}captcha",
|
|
830
|
+
"captcha[\\s\\S]{0,200}access denied",
|
|
831
|
+
`vercel security checkpoint`,
|
|
832
|
+
"press & hold to confirm you are a human"
|
|
833
|
+
].join("|"), "i");
|
|
834
|
+
/**
|
|
835
|
+
* Whether the home page is a bot check: a 202, or a small page (under `CHALLENGE_MAX_WORDS`
|
|
836
|
+
* words of text) whose title or visible text (`text`) carries one of `INTERSTITIAL_MARKS`.
|
|
837
|
+
*/
|
|
838
|
+
function isHomeChallenge(status, text, mainWords) {
|
|
839
|
+
if (status === 202) return true;
|
|
840
|
+
if (mainWords >= 100) return false;
|
|
841
|
+
return INTERSTITIAL_MARKS.test(text);
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* The marks of a placeholder home page, each a fixed phrase: the default pages of common web
|
|
845
|
+
* servers and control panels, and the wording of domain parking pages. Matched without case
|
|
846
|
+
* against the start of the page's HTML.
|
|
847
|
+
*/
|
|
848
|
+
var PLACEHOLDER_MARKS = Object.freeze([
|
|
849
|
+
{
|
|
850
|
+
id: "nginx_default",
|
|
851
|
+
pattern: /welcome to nginx/i
|
|
852
|
+
},
|
|
853
|
+
{
|
|
854
|
+
id: "apache_default",
|
|
855
|
+
pattern: /apache2? (?:\w+ )?default page|apache http server test page|test page for the apache/i
|
|
856
|
+
},
|
|
857
|
+
{
|
|
858
|
+
id: "apache_it_works",
|
|
859
|
+
pattern: /<h1>\s*it works!?\s*<\/h1>/i
|
|
860
|
+
},
|
|
861
|
+
{
|
|
862
|
+
id: "iis_default",
|
|
863
|
+
pattern: /iis windows server|internet information services|<title>\s*iis7?\b/i
|
|
864
|
+
},
|
|
865
|
+
{
|
|
866
|
+
id: "caddy_default",
|
|
867
|
+
pattern: /caddy works!/i
|
|
868
|
+
},
|
|
869
|
+
{
|
|
870
|
+
id: "panel_default",
|
|
871
|
+
pattern: /web server's default page|default web site page|domain default page|plesk default page|cpanel[^<]{0,40}default/i
|
|
872
|
+
},
|
|
873
|
+
{
|
|
874
|
+
id: "hosting_default",
|
|
875
|
+
pattern: /future home of something quite cool|this site can.t be reached yet|website coming\s+soon.{0,40}(?:namecheap|godaddy)/i
|
|
876
|
+
},
|
|
877
|
+
{
|
|
878
|
+
id: "parked_domain",
|
|
879
|
+
pattern: /this domain (?:is|may be) (?:for sale|parked|available)|domain (?:name )?(?:is )?for sale|buy this domain|is parked (?:free|courtesy)|parkingcrew|sedoparking|sedo\.com\/search|hugedomains|dan\.com\/buy-domain|afternic\.com\/forsale|bodis\.com|domainsponsor|above\.com\/parked/i
|
|
880
|
+
}
|
|
881
|
+
]);
|
|
882
|
+
/**
|
|
883
|
+
* Whether the home page is a placeholder: a small page (under `PLACEHOLDER_MAX_WORDS` words of
|
|
884
|
+
* text) that carries one of `PLACEHOLDER_MARKS`. Returns the mark's id, or null.
|
|
885
|
+
*/
|
|
886
|
+
function placeholderMark(html, mainWords) {
|
|
887
|
+
if (mainWords >= 150) return null;
|
|
888
|
+
const start = html.slice(0, MARKS_SCAN_CHARS);
|
|
889
|
+
for (const mark of PLACEHOLDER_MARKS) if (mark.pattern.test(start)) return mark.id;
|
|
890
|
+
return null;
|
|
891
|
+
}
|
|
892
|
+
// src/facts/words.ts
|
|
893
|
+
/**
|
|
894
|
+
* Word counting by the Unicode word-break rules (`Intl.Segmenter`, word-like segments), so
|
|
895
|
+
* a page in Chinese or Japanese is counted too, where splitting on spaces would find one word.
|
|
896
|
+
*/
|
|
897
|
+
/** Counting stops here: no rule of the library looks past it, and a page cannot make it slow. */
|
|
898
|
+
var MAX_WORDS_COUNTED = 1e5;
|
|
899
|
+
/** Characters given to the word-break rules at most (text that is not plain ASCII). */
|
|
900
|
+
var MAX_SEGMENTED_CHARS = 524288;
|
|
901
|
+
var SEGMENTER = new Intl.Segmenter("en", { granularity: "word" });
|
|
902
|
+
/**
|
|
903
|
+
* A word in plain ASCII text, as the word-break rules see it: a run of letters and digits, with
|
|
904
|
+
* a `.`, `'` or `:` joining two letters, or a `.`, `'`, `,` or `;` joining two digits. A test
|
|
905
|
+
* checks this against `Intl.Segmenter` on random text, so it only makes the common case fast.
|
|
906
|
+
*/
|
|
907
|
+
var ASCII_WORD = /[A-Za-z0-9]+(?:(?:(?<=[A-Za-z])[.':](?=[A-Za-z])|(?<=[0-9])[.',;](?=[0-9]))[A-Za-z0-9]+)*/g;
|
|
908
|
+
var PLAIN_ASCII = /^[\u0000-\u007f]*$/;
|
|
909
|
+
/** Words in `text`, counted up to `limit`. Linear in the length of the text. */
|
|
910
|
+
function countWords(text, limit = MAX_WORDS_COUNTED) {
|
|
911
|
+
let words = 0;
|
|
912
|
+
if (PLAIN_ASCII.test(text) && !text.includes("_")) {
|
|
913
|
+
ASCII_WORD.lastIndex = 0;
|
|
914
|
+
while (ASCII_WORD.exec(text) !== null) {
|
|
915
|
+
words += 1;
|
|
916
|
+
if (words >= limit) return {
|
|
917
|
+
words,
|
|
918
|
+
capped: true
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
return {
|
|
922
|
+
words,
|
|
923
|
+
capped: false
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
const cut = text.length > MAX_SEGMENTED_CHARS;
|
|
927
|
+
for (const segment of SEGMENTER.segment(cut ? text.slice(0, MAX_SEGMENTED_CHARS) : text)) {
|
|
928
|
+
if (!segment.isWordLike) continue;
|
|
929
|
+
words += 1;
|
|
930
|
+
if (words >= limit) return {
|
|
931
|
+
words,
|
|
932
|
+
capped: true
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
return {
|
|
936
|
+
words,
|
|
937
|
+
capped: cut
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
// src/generate/index.ts
|
|
941
|
+
/**
|
|
942
|
+
* llms.txt generation, as pure functions of text: what a page says about itself (its title,
|
|
943
|
+
* first H1 and meta description, as written), the addresses a sitemap lists, and the file
|
|
944
|
+
* built from them. Nothing here fetches; a caller reads the files with `fetchText` and passes
|
|
945
|
+
* the text in.
|
|
946
|
+
*/
|
|
947
|
+
/** Characters kept of each field read from a page. */
|
|
948
|
+
var TITLE_MAX = 200;
|
|
949
|
+
var DESCRIPTION_MAX = 500;
|
|
950
|
+
var NAMED_ENTITIES = Object.freeze({
|
|
951
|
+
amp: "&",
|
|
952
|
+
lt: "<",
|
|
953
|
+
gt: ">",
|
|
954
|
+
quot: "\"",
|
|
955
|
+
apos: "'",
|
|
956
|
+
nbsp: " ",
|
|
957
|
+
ndash: "–",
|
|
958
|
+
mdash: "—",
|
|
959
|
+
hellip: "…",
|
|
960
|
+
rsquo: "’",
|
|
961
|
+
lsquo: "‘",
|
|
962
|
+
rdquo: "”",
|
|
963
|
+
ldquo: "“",
|
|
964
|
+
copy: "©",
|
|
965
|
+
reg: "®",
|
|
966
|
+
trade: "™"
|
|
967
|
+
});
|
|
968
|
+
function decodeEntities(text) {
|
|
969
|
+
return text.replace(/&(#x[0-9a-fA-F]{1,6}|#[0-9]{1,7}|[a-zA-Z]{2,8});/g, (whole, body) => {
|
|
970
|
+
if (body.startsWith("#")) {
|
|
971
|
+
const code = body[1] === "x" || body[1] === "X" ? parseInt(body.slice(2), 16) : Number(body.slice(1));
|
|
972
|
+
if (!Number.isInteger(code) || code <= 0 || code > 1114111 || code >= 55296 && code <= 57343) return whole;
|
|
973
|
+
return String.fromCodePoint(code);
|
|
974
|
+
}
|
|
975
|
+
return NAMED_ENTITIES[body.toLowerCase()] ?? whole;
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* Control characters (C0 but tab and line breaks, DEL, C1), the bidirectional controls that
|
|
980
|
+
* can reorder how text is displayed (including the Arabic letter mark) and the deprecated
|
|
981
|
+
* format controls. Removed from every field read from a site.
|
|
982
|
+
*/
|
|
983
|
+
var UNSAFE_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u206f\u061c]/g;
|
|
984
|
+
/** Text from a site made safe to show: control and reordering characters removed. */
|
|
985
|
+
function stripUnsafeCharacters(text) {
|
|
986
|
+
return text.replace(UNSAFE_CHARACTERS, "");
|
|
987
|
+
}
|
|
988
|
+
/** Characters of a page read for its title, H1 and meta tags: its head and the start of its body. */
|
|
989
|
+
var PAGE_SCAN_MAX_CHARS = 262144;
|
|
990
|
+
/** Characters of a sitemap scanned: its whole size cap (5 MiB), in one linear pass. */
|
|
991
|
+
var SITEMAP_SCAN_MAX_CHARS = 5242880;
|
|
992
|
+
/**
|
|
993
|
+
* `<loc>` tags looked at in one sitemap, empty or unusable ones included, before the scan
|
|
994
|
+
* stops: enough for any list the tools take addresses from, and a bound on the work a file of
|
|
995
|
+
* millions of empty tags can cause.
|
|
996
|
+
*/
|
|
997
|
+
var MAX_LOC_TAGS_SCANNED = 1e4;
|
|
998
|
+
/**
|
|
999
|
+
* Every parser below reads remote text in one forward pass: each search starts where the last
|
|
1000
|
+
* one ended (`indexOf`, or a regular expression of fixed text from `lastIndex`), and a search
|
|
1001
|
+
* that finds nothing ends the scan. So no page or sitemap, however it is built, makes the work
|
|
1002
|
+
* more than linear in its length; a test feeds each one adversarial text at its cap.
|
|
1003
|
+
*/
|
|
1004
|
+
/** Tags removed, in one pass: a `<` with no `>` after it keeps the rest as text. */
|
|
1005
|
+
function stripTags$1(raw) {
|
|
1006
|
+
let out = "";
|
|
1007
|
+
let at = 0;
|
|
1008
|
+
for (;;) {
|
|
1009
|
+
const open = raw.indexOf("<", at);
|
|
1010
|
+
if (open === -1) return out + raw.slice(at);
|
|
1011
|
+
const close = raw.indexOf(">", open + 1);
|
|
1012
|
+
if (close === -1) return out + raw.slice(at);
|
|
1013
|
+
out += `${raw.slice(at, open)} `;
|
|
1014
|
+
at = close + 1;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
/** One line of plain text: entities decoded, tags removed, whitespace collapsed, cut to `max`. */
|
|
1018
|
+
function plain(raw, max) {
|
|
1019
|
+
const text = stripUnsafeCharacters(decodeEntities(stripTags$1(raw))).replace(/\s+/g, " ").trim();
|
|
1020
|
+
if (text === "") return null;
|
|
1021
|
+
return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
|
|
1022
|
+
}
|
|
1023
|
+
function isSpace(char) {
|
|
1024
|
+
return char === " " || char === " " || char === "\n" || char === "\r" || char === "\f";
|
|
1025
|
+
}
|
|
1026
|
+
/** The attributes of one tag's text (between its name and its `>`), in one pass. */
|
|
1027
|
+
function attributes(tag) {
|
|
1028
|
+
const found = /* @__PURE__ */ new Map();
|
|
1029
|
+
const length = tag.length;
|
|
1030
|
+
let at = 0;
|
|
1031
|
+
while (at < length) {
|
|
1032
|
+
while (at < length && (isSpace(tag[at]) || tag[at] === "/")) at += 1;
|
|
1033
|
+
const from = at;
|
|
1034
|
+
while (at < length && !isSpace(tag[at]) && tag[at] !== "=" && tag[at] !== "/") at += 1;
|
|
1035
|
+
const name = tag.slice(from, at).toLowerCase();
|
|
1036
|
+
if (name === "") {
|
|
1037
|
+
at += 1;
|
|
1038
|
+
continue;
|
|
1039
|
+
}
|
|
1040
|
+
while (at < length && isSpace(tag[at])) at += 1;
|
|
1041
|
+
if (tag[at] !== "=") {
|
|
1042
|
+
if (!found.has(name)) found.set(name, "");
|
|
1043
|
+
continue;
|
|
1044
|
+
}
|
|
1045
|
+
at += 1;
|
|
1046
|
+
while (at < length && isSpace(tag[at])) at += 1;
|
|
1047
|
+
let value;
|
|
1048
|
+
const quote = tag[at];
|
|
1049
|
+
if (quote === "\"" || quote === "'") {
|
|
1050
|
+
const end = tag.indexOf(quote, at + 1);
|
|
1051
|
+
value = tag.slice(at + 1, end === -1 ? length : end);
|
|
1052
|
+
at = end === -1 ? length : end + 1;
|
|
1053
|
+
} else {
|
|
1054
|
+
const start = at;
|
|
1055
|
+
while (at < length && !isSpace(tag[at])) at += 1;
|
|
1056
|
+
value = tag.slice(start, at);
|
|
1057
|
+
}
|
|
1058
|
+
if (!found.has(name)) found.set(name, value);
|
|
1059
|
+
}
|
|
1060
|
+
return found;
|
|
1061
|
+
}
|
|
1062
|
+
/** The next `</name` (any case) at or after `from`; -1 when there is none. */
|
|
1063
|
+
function closingTag(html, name, from) {
|
|
1064
|
+
const pattern = new RegExp(`</${name}`, "ig");
|
|
1065
|
+
pattern.lastIndex = from;
|
|
1066
|
+
return pattern.exec(html)?.index ?? -1;
|
|
1067
|
+
}
|
|
1068
|
+
/** Elements whose content is not the page's own text. */
|
|
1069
|
+
var RAW_TEXT$1 = /* @__PURE__ */ new Set([
|
|
1070
|
+
"script",
|
|
1071
|
+
"style",
|
|
1072
|
+
"template",
|
|
1073
|
+
"noscript",
|
|
1074
|
+
"textarea"
|
|
1075
|
+
]);
|
|
1076
|
+
var TAG_NAME = /[a-zA-Z][a-zA-Z0-9-]*/y;
|
|
1077
|
+
/** Reads a page's title, first H1, meta description and robots meta from its HTML. */
|
|
1078
|
+
function pageMeta(input) {
|
|
1079
|
+
const html = input.length > 262144 ? input.slice(0, PAGE_SCAN_MAX_CHARS) : input;
|
|
1080
|
+
let title = null;
|
|
1081
|
+
let h1 = null;
|
|
1082
|
+
let description = null;
|
|
1083
|
+
let noindex = false;
|
|
1084
|
+
let at = 0;
|
|
1085
|
+
for (;;) {
|
|
1086
|
+
const open = html.indexOf("<", at);
|
|
1087
|
+
if (open === -1) break;
|
|
1088
|
+
if (html.startsWith("<!--", open)) {
|
|
1089
|
+
const end = html.indexOf("-->", open + 4);
|
|
1090
|
+
if (end === -1) break;
|
|
1091
|
+
at = end + 3;
|
|
1092
|
+
continue;
|
|
1093
|
+
}
|
|
1094
|
+
TAG_NAME.lastIndex = open + 1;
|
|
1095
|
+
const match = TAG_NAME.exec(html);
|
|
1096
|
+
if (match === null) {
|
|
1097
|
+
at = open + 1;
|
|
1098
|
+
continue;
|
|
1099
|
+
}
|
|
1100
|
+
const name = match[0].toLowerCase();
|
|
1101
|
+
const tagEnd = html.indexOf(">", TAG_NAME.lastIndex);
|
|
1102
|
+
if (tagEnd === -1) break;
|
|
1103
|
+
at = tagEnd + 1;
|
|
1104
|
+
if (RAW_TEXT$1.has(name)) {
|
|
1105
|
+
const end = closingTag(html, name, at);
|
|
1106
|
+
if (end === -1) break;
|
|
1107
|
+
at = end;
|
|
1108
|
+
continue;
|
|
1109
|
+
}
|
|
1110
|
+
if (name === "title" || name === "h1") {
|
|
1111
|
+
const end = closingTag(html, name, at);
|
|
1112
|
+
if (end === -1) break;
|
|
1113
|
+
if (name === "title" && title === null) title = plain(html.slice(at, end), TITLE_MAX);
|
|
1114
|
+
if (name === "h1" && h1 === null) h1 = plain(html.slice(at, end), TITLE_MAX);
|
|
1115
|
+
at = end;
|
|
1116
|
+
continue;
|
|
1117
|
+
}
|
|
1118
|
+
if (name === "meta") {
|
|
1119
|
+
const attrs = attributes(html.slice(TAG_NAME.lastIndex, tagEnd));
|
|
1120
|
+
const kind = (attrs.get("name") ?? "").toLowerCase();
|
|
1121
|
+
const content = attrs.get("content") ?? "";
|
|
1122
|
+
if (kind === "description" && description === null) description = plain(content, DESCRIPTION_MAX);
|
|
1123
|
+
if (kind === "robots" && /\bnoindex\b|\bnone\b/i.test(content)) noindex = true;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
return Object.freeze({
|
|
1127
|
+
title,
|
|
1128
|
+
h1,
|
|
1129
|
+
description,
|
|
1130
|
+
noindex
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
/** Reads the `<loc>` addresses of a sitemap (sitemaps.org), up to `max` of them. */
|
|
1134
|
+
function sitemapLocations(input, max) {
|
|
1135
|
+
const xml = input.length > 5242880 ? input.slice(0, SITEMAP_SCAN_MAX_CHARS) : input;
|
|
1136
|
+
const kind = /<sitemapindex\b/i.test(xml) ? "index" : /<urlset\b/i.test(xml) ? "urlset" : "unknown";
|
|
1137
|
+
const locations = [];
|
|
1138
|
+
const loc = /<loc\b/gi;
|
|
1139
|
+
let scanned = 0;
|
|
1140
|
+
while (kind !== "unknown" && locations.length < max && scanned < 1e4) {
|
|
1141
|
+
if (loc.exec(xml) === null) break;
|
|
1142
|
+
scanned += 1;
|
|
1143
|
+
const tagEnd = xml.indexOf(">", loc.lastIndex);
|
|
1144
|
+
if (tagEnd === -1) break;
|
|
1145
|
+
let from = tagEnd + 1;
|
|
1146
|
+
while (isSpace(xml[from])) from += 1;
|
|
1147
|
+
let raw;
|
|
1148
|
+
if (xml.startsWith("<![CDATA[", from)) {
|
|
1149
|
+
const end = xml.indexOf("]]>", from + 9);
|
|
1150
|
+
if (end === -1) break;
|
|
1151
|
+
raw = xml.slice(from + 9, end);
|
|
1152
|
+
loc.lastIndex = end + 3;
|
|
1153
|
+
} else {
|
|
1154
|
+
const end = xml.indexOf("<", from);
|
|
1155
|
+
if (end === -1) break;
|
|
1156
|
+
raw = xml.slice(from, end);
|
|
1157
|
+
loc.lastIndex = end;
|
|
1158
|
+
}
|
|
1159
|
+
if (raw.length > 2112) continue;
|
|
1160
|
+
const value = stripUnsafeCharacters(decodeEntities(raw.trim()));
|
|
1161
|
+
if (value !== "" && value.length <= 2048) locations.push(value);
|
|
1162
|
+
}
|
|
1163
|
+
return Object.freeze({
|
|
1164
|
+
kind,
|
|
1165
|
+
locations: Object.freeze(locations)
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
/** The section of pages that sit at the top of the site (no first path segment). */
|
|
1169
|
+
var MAIN_SECTION = "Main pages";
|
|
1170
|
+
function oneLine(text) {
|
|
1171
|
+
return stripUnsafeCharacters(text).replace(/\s+/g, " ").trim();
|
|
1172
|
+
}
|
|
1173
|
+
/** Link text with the characters that would end a Markdown link escaped. */
|
|
1174
|
+
function linkText(text) {
|
|
1175
|
+
return oneLine(text).replace(/[\\[\]]/g, (char) => `\\${char}`);
|
|
1176
|
+
}
|
|
1177
|
+
/** A link target with the characters that would end a Markdown link encoded. */
|
|
1178
|
+
function linkTarget(url) {
|
|
1179
|
+
return url.replace(/[()\s<>]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`);
|
|
1180
|
+
}
|
|
1181
|
+
/** A first path segment as a section name: `getting-started` → `Getting started`. */
|
|
1182
|
+
function sectionName(segment) {
|
|
1183
|
+
let decoded = segment;
|
|
1184
|
+
try {
|
|
1185
|
+
decoded = decodeURIComponent(segment);
|
|
1186
|
+
} catch {}
|
|
1187
|
+
const words = oneLine(decoded.replace(/[-_+.]+/g, " ")).slice(0, 60);
|
|
1188
|
+
if (words === "") return MAIN_SECTION;
|
|
1189
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
1190
|
+
}
|
|
1191
|
+
function segments(url) {
|
|
1192
|
+
try {
|
|
1193
|
+
return new URL(url).pathname.split("/").filter((part) => part !== "");
|
|
1194
|
+
} catch {
|
|
1195
|
+
return [];
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
/**
|
|
1199
|
+
* The llms.txt for a site and its pages (llmstxt.org): the site's name as the H1, its summary
|
|
1200
|
+
* as a blockquote, then one H2 section per folder (first path segment with pages under it), in
|
|
1201
|
+
* the order the pages come, the other pages first under "Main pages". Each page is a list item
|
|
1202
|
+
* `- [title](url): description`, the description left out when there is none. Titles and
|
|
1203
|
+
* descriptions are used as written, on one line.
|
|
1204
|
+
*/
|
|
1205
|
+
function renderLlmsTxt(site, pages) {
|
|
1206
|
+
const folders = new Set(pages.map((page) => segments(page.url)).filter((parts) => parts.length > 1).map((parts) => parts[0]));
|
|
1207
|
+
const sections = /* @__PURE__ */ new Map();
|
|
1208
|
+
sections.set(MAIN_SECTION, []);
|
|
1209
|
+
for (const page of pages) {
|
|
1210
|
+
const first = segments(page.url)[0];
|
|
1211
|
+
const name = first !== void 0 && folders.has(first) ? sectionName(first) : MAIN_SECTION;
|
|
1212
|
+
const list = sections.get(name) ?? [];
|
|
1213
|
+
list.push(page);
|
|
1214
|
+
sections.set(name, list);
|
|
1215
|
+
}
|
|
1216
|
+
const lines = [`# ${oneLine(site.name) || "Site"}`, ""];
|
|
1217
|
+
const summary = site.summary === null ? "" : oneLine(site.summary);
|
|
1218
|
+
if (summary !== "") lines.push(`> ${summary}`, "");
|
|
1219
|
+
for (const [name, list] of sections) {
|
|
1220
|
+
if (list.length === 0) continue;
|
|
1221
|
+
lines.push(`## ${name}`, "");
|
|
1222
|
+
for (const page of list) {
|
|
1223
|
+
const description = page.description === null ? "" : oneLine(page.description);
|
|
1224
|
+
lines.push(`- [${linkText(page.title) || linkTarget(page.url)}](${linkTarget(page.url)})${description === "" ? "" : `: ${description}`}`);
|
|
1225
|
+
}
|
|
1226
|
+
lines.push("");
|
|
1227
|
+
}
|
|
1228
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
1229
|
+
}
|
|
1230
|
+
// src/facts/html.ts
|
|
1231
|
+
/**
|
|
1232
|
+
* One forward pass over a page's HTML, read from a string the way a crawler that runs no
|
|
1233
|
+
* scripts receives it. It collects what the readers need: the text of the main content, the
|
|
1234
|
+
* text a visitor sees, the robots meta tags, the `itemtype` values of microdata, the
|
|
1235
|
+
* JSON-LD scripts, and the notice text inside `<noscript>`.
|
|
1236
|
+
*
|
|
1237
|
+
* It is not an HTML parser and makes no tree. Elements that matter are tracked by a depth
|
|
1238
|
+
* counter per name, so nothing here costs more than one step per tag: a search starts where
|
|
1239
|
+
* the last one ended, and a search that finds nothing ends the scan. A test feeds it hostile
|
|
1240
|
+
* pages at the 2 MiB cap and checks the time.
|
|
1241
|
+
*/
|
|
1242
|
+
/** Characters of a page scanned; the fetch cap for a page is 2 MiB, so a fetched page fits. */
|
|
1243
|
+
var HOME_SCAN_MAX_CHARS = 2097152;
|
|
1244
|
+
/** JSON-LD limits: blocks read, one block's size, all blocks' size (in characters). */
|
|
1245
|
+
var MAX_LD_BLOCKS = 50;
|
|
1246
|
+
var MAX_LD_BLOCK_CHARS = 524288;
|
|
1247
|
+
var MAX_LD_TOTAL_CHARS = 1048576;
|
|
1248
|
+
/** Characters kept of the visible text and of `<noscript>` and `<title>` text, for notices. */
|
|
1249
|
+
var NOTICE_TEXT_CHARS = 65536;
|
|
1250
|
+
var MAX_ITEMTYPES = 200;
|
|
1251
|
+
var MAX_ITEMTYPE_CHARS = 200;
|
|
1252
|
+
var MAX_META_CHARS = 1e3;
|
|
1253
|
+
/** Elements that belong in the head; any other start tag ends it, as a browser would. */
|
|
1254
|
+
var HEAD_ELEMENTS = /* @__PURE__ */ new Set([
|
|
1255
|
+
"title",
|
|
1256
|
+
"meta",
|
|
1257
|
+
"link",
|
|
1258
|
+
"script",
|
|
1259
|
+
"style",
|
|
1260
|
+
"base",
|
|
1261
|
+
"noscript",
|
|
1262
|
+
"template",
|
|
1263
|
+
"head"
|
|
1264
|
+
]);
|
|
1265
|
+
/** Page-level elements left out of the fallback text (a page without `<main>`). */
|
|
1266
|
+
var CHROME = /* @__PURE__ */ new Set([
|
|
1267
|
+
"header",
|
|
1268
|
+
"nav",
|
|
1269
|
+
"footer",
|
|
1270
|
+
"aside"
|
|
1271
|
+
]);
|
|
1272
|
+
/** Elements that continue a word: `Hel<b>lo</b>` is one word. Every other tag ends one. */
|
|
1273
|
+
var INLINE = /* @__PURE__ */ new Set([
|
|
1274
|
+
"a",
|
|
1275
|
+
"abbr",
|
|
1276
|
+
"b",
|
|
1277
|
+
"bdi",
|
|
1278
|
+
"bdo",
|
|
1279
|
+
"big",
|
|
1280
|
+
"cite",
|
|
1281
|
+
"code",
|
|
1282
|
+
"data",
|
|
1283
|
+
"del",
|
|
1284
|
+
"dfn",
|
|
1285
|
+
"em",
|
|
1286
|
+
"font",
|
|
1287
|
+
"i",
|
|
1288
|
+
"ins",
|
|
1289
|
+
"kbd",
|
|
1290
|
+
"mark",
|
|
1291
|
+
"q",
|
|
1292
|
+
"s",
|
|
1293
|
+
"samp",
|
|
1294
|
+
"small",
|
|
1295
|
+
"span",
|
|
1296
|
+
"strike",
|
|
1297
|
+
"strong",
|
|
1298
|
+
"sub",
|
|
1299
|
+
"sup",
|
|
1300
|
+
"time",
|
|
1301
|
+
"tt",
|
|
1302
|
+
"u",
|
|
1303
|
+
"var",
|
|
1304
|
+
"wbr"
|
|
1305
|
+
]);
|
|
1306
|
+
/** Elements whose content is not page text; the scan jumps to the closing tag. */
|
|
1307
|
+
var RAW_TEXT = /* @__PURE__ */ new Set([
|
|
1308
|
+
"style",
|
|
1309
|
+
"textarea",
|
|
1310
|
+
"xmp",
|
|
1311
|
+
"noframes",
|
|
1312
|
+
"noembed",
|
|
1313
|
+
"iframe",
|
|
1314
|
+
"title",
|
|
1315
|
+
"noscript",
|
|
1316
|
+
"script"
|
|
1317
|
+
]);
|
|
1318
|
+
var NAMED_TEXT_ENTITIES = Object.freeze({
|
|
1319
|
+
amp: "&",
|
|
1320
|
+
lt: "<",
|
|
1321
|
+
gt: ">",
|
|
1322
|
+
quot: "\"",
|
|
1323
|
+
apos: "'",
|
|
1324
|
+
nbsp: " ",
|
|
1325
|
+
ensp: " ",
|
|
1326
|
+
emsp: " ",
|
|
1327
|
+
thinsp: " ",
|
|
1328
|
+
hairsp: " ",
|
|
1329
|
+
zwnj: "",
|
|
1330
|
+
zwj: "",
|
|
1331
|
+
shy: "",
|
|
1332
|
+
ndash: "–",
|
|
1333
|
+
mdash: "—",
|
|
1334
|
+
hellip: "…",
|
|
1335
|
+
bull: "•",
|
|
1336
|
+
middot: "·",
|
|
1337
|
+
raquo: "»",
|
|
1338
|
+
laquo: "«",
|
|
1339
|
+
rsquo: "’",
|
|
1340
|
+
lsquo: "‘",
|
|
1341
|
+
rdquo: "”",
|
|
1342
|
+
ldquo: "“",
|
|
1343
|
+
copy: "©",
|
|
1344
|
+
reg: "®",
|
|
1345
|
+
trade: "™",
|
|
1346
|
+
times: "×",
|
|
1347
|
+
euro: "€",
|
|
1348
|
+
pound: "£",
|
|
1349
|
+
yen: "¥",
|
|
1350
|
+
cent: "¢",
|
|
1351
|
+
sect: "§",
|
|
1352
|
+
deg: "°",
|
|
1353
|
+
para: "¶",
|
|
1354
|
+
larr: "←",
|
|
1355
|
+
rarr: "→",
|
|
1356
|
+
uarr: "↑",
|
|
1357
|
+
darr: "↓",
|
|
1358
|
+
check: "✓",
|
|
1359
|
+
hearts: "♥",
|
|
1360
|
+
star: "☆",
|
|
1361
|
+
plusmn: "±",
|
|
1362
|
+
frac12: "½",
|
|
1363
|
+
infin: "∞"
|
|
1364
|
+
});
|
|
1365
|
+
/**
|
|
1366
|
+
* Entities in a text chunk decoded. A named entity not in the list above is almost always an
|
|
1367
|
+
* accented letter (`é`), so it becomes one letter and joins its word.
|
|
1368
|
+
*/
|
|
1369
|
+
function decodeText(raw) {
|
|
1370
|
+
return raw.replace(/&(#x[0-9a-fA-F]{1,6}|#[0-9]{1,7}|[a-zA-Z][a-zA-Z0-9]{1,31});/g, (whole, body) => {
|
|
1371
|
+
if (body.startsWith("#")) {
|
|
1372
|
+
const code = body[1] === "x" || body[1] === "X" ? parseInt(body.slice(2), 16) : Number(body.slice(1));
|
|
1373
|
+
if (!Number.isInteger(code) || code <= 0 || code > 1114111) return whole;
|
|
1374
|
+
if (code >= 55296 && code <= 57343) return " ";
|
|
1375
|
+
return String.fromCodePoint(code);
|
|
1376
|
+
}
|
|
1377
|
+
return NAMED_TEXT_ENTITIES[body] ?? NAMED_TEXT_ENTITIES[body.toLowerCase()] ?? "e";
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
var CLOSING = /* @__PURE__ */ new Map();
|
|
1381
|
+
/** The next `</name` (any case) at or after `from`; -1 when there is none. */
|
|
1382
|
+
function findClosing(html, name, from) {
|
|
1383
|
+
let pattern = CLOSING.get(name);
|
|
1384
|
+
if (pattern === void 0) {
|
|
1385
|
+
pattern = new RegExp(`</${name}`, "ig");
|
|
1386
|
+
CLOSING.set(name, pattern);
|
|
1387
|
+
}
|
|
1388
|
+
pattern.lastIndex = from;
|
|
1389
|
+
return pattern.exec(html)?.index ?? -1;
|
|
1390
|
+
}
|
|
1391
|
+
function isLetter(code) {
|
|
1392
|
+
const lower = code | 32;
|
|
1393
|
+
return lower >= 97 && lower <= 122;
|
|
1394
|
+
}
|
|
1395
|
+
/** The end of a start tag's name: letters, digits, `-`, `:`. */
|
|
1396
|
+
function nameEnd(html, from) {
|
|
1397
|
+
let at = from;
|
|
1398
|
+
for (;;) {
|
|
1399
|
+
const code = html.charCodeAt(at);
|
|
1400
|
+
if (isLetter(code) || code >= 48 && code <= 58 || code === 45) at += 1;
|
|
1401
|
+
else return at;
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
/**
|
|
1405
|
+
* The `>` that ends a start tag: quotes opened right after `=` are skipped, so `title="a>b"`
|
|
1406
|
+
* does not end it. -1 when the tag never ends.
|
|
1407
|
+
*/
|
|
1408
|
+
function findTagEnd(html, from) {
|
|
1409
|
+
let at = from;
|
|
1410
|
+
let afterEquals = false;
|
|
1411
|
+
for (;;) {
|
|
1412
|
+
const code = html.charCodeAt(at);
|
|
1413
|
+
if (Number.isNaN(code)) return -1;
|
|
1414
|
+
if (code === 62) return at;
|
|
1415
|
+
if ((code === 34 || code === 39) && afterEquals) {
|
|
1416
|
+
const close = html.indexOf(code === 34 ? "\"" : "'", at + 1);
|
|
1417
|
+
if (close === -1) return -1;
|
|
1418
|
+
at = close + 1;
|
|
1419
|
+
afterEquals = false;
|
|
1420
|
+
continue;
|
|
1421
|
+
}
|
|
1422
|
+
if (code === 61) afterEquals = true;
|
|
1423
|
+
else if (code !== 32 && code !== 9 && code !== 10 && code !== 13 && code !== 12) afterEquals = false;
|
|
1424
|
+
at += 1;
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
function stripTags(raw) {
|
|
1428
|
+
return raw.replace(/<[^<>]*>/g, " ");
|
|
1429
|
+
}
|
|
1430
|
+
/** Scans a page. Never throws; text it cannot make sense of is left out. */
|
|
1431
|
+
function scanPage(input) {
|
|
1432
|
+
const truncated = input.length > HOME_SCAN_MAX_CHARS;
|
|
1433
|
+
const html = truncated ? input.slice(0, HOME_SCAN_MAX_CHARS) : input;
|
|
1434
|
+
const main = [];
|
|
1435
|
+
const body = [];
|
|
1436
|
+
const visible = [];
|
|
1437
|
+
let visibleLength = 0;
|
|
1438
|
+
const notice = [];
|
|
1439
|
+
let noticeLength = 0;
|
|
1440
|
+
const metaRobots = [];
|
|
1441
|
+
const itemtypes = [];
|
|
1442
|
+
const ldBlocks = [];
|
|
1443
|
+
const ldBounds = /* @__PURE__ */ new Set();
|
|
1444
|
+
let ldScripts = 0;
|
|
1445
|
+
let ldTotal = 0;
|
|
1446
|
+
let ldStopped = false;
|
|
1447
|
+
let mainDepth = 0;
|
|
1448
|
+
let hasMain = false;
|
|
1449
|
+
let roleName = null;
|
|
1450
|
+
let roleDepth = 0;
|
|
1451
|
+
const chrome = /* @__PURE__ */ new Map();
|
|
1452
|
+
let chromeDepth = 0;
|
|
1453
|
+
let skipDepth = 0;
|
|
1454
|
+
let inHead = false;
|
|
1455
|
+
let pendingSeparator = false;
|
|
1456
|
+
const addNotice = (text) => {
|
|
1457
|
+
if (noticeLength >= NOTICE_TEXT_CHARS) return;
|
|
1458
|
+
notice.push(text);
|
|
1459
|
+
noticeLength += text.length;
|
|
1460
|
+
};
|
|
1461
|
+
const emit = (raw) => {
|
|
1462
|
+
if (raw === "" || skipDepth > 0) return;
|
|
1463
|
+
if (inHead && raw.trim() !== "") inHead = false;
|
|
1464
|
+
if (inHead) return;
|
|
1465
|
+
const inMain = mainDepth > 0 || roleDepth > 0;
|
|
1466
|
+
const inBody = chromeDepth === 0;
|
|
1467
|
+
const decoded = raw.includes("&") ? decodeText(raw) : raw;
|
|
1468
|
+
const piece = pendingSeparator ? ` ${decoded}` : decoded;
|
|
1469
|
+
pendingSeparator = false;
|
|
1470
|
+
if (inMain) main.push(piece);
|
|
1471
|
+
if (inBody) body.push(piece);
|
|
1472
|
+
if (visibleLength < NOTICE_TEXT_CHARS) {
|
|
1473
|
+
visible.push(piece);
|
|
1474
|
+
visibleLength += piece.length;
|
|
1475
|
+
}
|
|
1476
|
+
};
|
|
1477
|
+
let textFrom = 0;
|
|
1478
|
+
let at = 0;
|
|
1479
|
+
for (;;) {
|
|
1480
|
+
const open = html.indexOf("<", at);
|
|
1481
|
+
if (open === -1) break;
|
|
1482
|
+
const next = html.charCodeAt(open + 1);
|
|
1483
|
+
if (next === 33 || next === 63) {
|
|
1484
|
+
emit(html.slice(textFrom, open));
|
|
1485
|
+
if (html.startsWith("<!--", open)) {
|
|
1486
|
+
const end = html.indexOf("-->", open + 4);
|
|
1487
|
+
if (end === -1) {
|
|
1488
|
+
textFrom = html.length;
|
|
1489
|
+
break;
|
|
1490
|
+
}
|
|
1491
|
+
at = end + 3;
|
|
1492
|
+
} else {
|
|
1493
|
+
const end = html.indexOf(">", open + 2);
|
|
1494
|
+
if (end === -1) {
|
|
1495
|
+
textFrom = html.length;
|
|
1496
|
+
break;
|
|
1497
|
+
}
|
|
1498
|
+
at = end + 1;
|
|
1499
|
+
}
|
|
1500
|
+
textFrom = at;
|
|
1501
|
+
continue;
|
|
1502
|
+
}
|
|
1503
|
+
const closing = next === 47;
|
|
1504
|
+
const nameFrom = open + (closing ? 2 : 1);
|
|
1505
|
+
if (!isLetter(html.charCodeAt(nameFrom))) {
|
|
1506
|
+
at = open + 1;
|
|
1507
|
+
continue;
|
|
1508
|
+
}
|
|
1509
|
+
const nameTo = nameEnd(html, nameFrom);
|
|
1510
|
+
const name = html.slice(nameFrom, nameTo).toLowerCase();
|
|
1511
|
+
if (open > textFrom) emit(html.slice(textFrom, open));
|
|
1512
|
+
if (closing) {
|
|
1513
|
+
const end = html.indexOf(">", nameTo);
|
|
1514
|
+
if (end === -1) {
|
|
1515
|
+
textFrom = html.length;
|
|
1516
|
+
break;
|
|
1517
|
+
}
|
|
1518
|
+
at = end + 1;
|
|
1519
|
+
textFrom = at;
|
|
1520
|
+
if (name === "main") {
|
|
1521
|
+
if (mainDepth > 0) mainDepth -= 1;
|
|
1522
|
+
} else if (CHROME.has(name)) {
|
|
1523
|
+
const depth = chrome.get(name) ?? 0;
|
|
1524
|
+
if (depth > 0) {
|
|
1525
|
+
chrome.set(name, depth - 1);
|
|
1526
|
+
chromeDepth -= 1;
|
|
1527
|
+
}
|
|
1528
|
+
} else if (name === "template" || name === "svg") {
|
|
1529
|
+
if (skipDepth > 0) skipDepth -= 1;
|
|
1530
|
+
} else if (name === "head") inHead = false;
|
|
1531
|
+
if (name === roleName) {
|
|
1532
|
+
roleDepth -= 1;
|
|
1533
|
+
if (roleDepth === 0) roleName = null;
|
|
1534
|
+
}
|
|
1535
|
+
if (!INLINE.has(name)) pendingSeparator = true;
|
|
1536
|
+
continue;
|
|
1537
|
+
}
|
|
1538
|
+
if (inHead && !HEAD_ELEMENTS.has(name)) inHead = false;
|
|
1539
|
+
const tagEnd = findTagEnd(html, nameTo);
|
|
1540
|
+
if (tagEnd === -1) {
|
|
1541
|
+
textFrom = html.length;
|
|
1542
|
+
break;
|
|
1543
|
+
}
|
|
1544
|
+
const raw = tagEnd === nameTo ? "" : html.slice(nameTo, tagEnd);
|
|
1545
|
+
const selfClosing = html.charCodeAt(tagEnd - 1) === 47;
|
|
1546
|
+
at = tagEnd + 1;
|
|
1547
|
+
textFrom = at;
|
|
1548
|
+
if (RAW_TEXT.has(name)) {
|
|
1549
|
+
const end = findClosing(html, name, at);
|
|
1550
|
+
if (end === -1) {
|
|
1551
|
+
textFrom = html.length;
|
|
1552
|
+
at = html.length;
|
|
1553
|
+
break;
|
|
1554
|
+
}
|
|
1555
|
+
if (name === "script") {
|
|
1556
|
+
if ((attributes(raw).get("type") ?? "").split(";")[0].trim().toLowerCase() === "application/ld+json") {
|
|
1557
|
+
ldScripts += 1;
|
|
1558
|
+
const size = end - at;
|
|
1559
|
+
if (ldStopped) {} else if (ldBlocks.length >= 50) {
|
|
1560
|
+
ldBounds.add("blocks");
|
|
1561
|
+
ldStopped = true;
|
|
1562
|
+
} else if (size > 524288) ldBounds.add("block_size");
|
|
1563
|
+
else if (ldTotal + size > 1048576) {
|
|
1564
|
+
ldBounds.add("total_size");
|
|
1565
|
+
ldStopped = true;
|
|
1566
|
+
} else {
|
|
1567
|
+
ldBlocks.push(html.slice(at, end));
|
|
1568
|
+
ldTotal += size;
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
} else if (name === "noscript" || name === "title") {
|
|
1572
|
+
if (noticeLength < NOTICE_TEXT_CHARS) addNotice(stripTags(html.slice(at, Math.min(end, at + NOTICE_TEXT_CHARS))) + " ");
|
|
1573
|
+
}
|
|
1574
|
+
at = end;
|
|
1575
|
+
textFrom = end;
|
|
1576
|
+
pendingSeparator = true;
|
|
1577
|
+
continue;
|
|
1578
|
+
}
|
|
1579
|
+
if (name === "meta") {
|
|
1580
|
+
if (/name\s*=/i.test(raw)) {
|
|
1581
|
+
const attrs = attributes(raw);
|
|
1582
|
+
if ((attrs.get("name") ?? "").trim().toLowerCase() === "robots") metaRobots.push((attrs.get("content") ?? "").slice(0, MAX_META_CHARS));
|
|
1583
|
+
}
|
|
1584
|
+
pendingSeparator = true;
|
|
1585
|
+
continue;
|
|
1586
|
+
}
|
|
1587
|
+
if (name === "main") {
|
|
1588
|
+
mainDepth += 1;
|
|
1589
|
+
hasMain = true;
|
|
1590
|
+
} else if (CHROME.has(name)) {
|
|
1591
|
+
chrome.set(name, (chrome.get(name) ?? 0) + 1);
|
|
1592
|
+
chromeDepth += 1;
|
|
1593
|
+
} else if ((name === "template" || name === "svg") && !(selfClosing && name === "svg")) skipDepth += 1;
|
|
1594
|
+
else if (name === "head") inHead = true;
|
|
1595
|
+
else if (name === "body") inHead = false;
|
|
1596
|
+
let opensRole = false;
|
|
1597
|
+
if (raw.length > 6 && /itemtype|role/i.test(raw)) {
|
|
1598
|
+
const attrs = attributes(raw);
|
|
1599
|
+
const itemtype = attrs.get("itemtype");
|
|
1600
|
+
if (itemtype !== void 0) {
|
|
1601
|
+
for (const token of itemtype.split(/\s+/)) if (token !== "" && itemtypes.length < MAX_ITEMTYPES) itemtypes.push(token.slice(0, MAX_ITEMTYPE_CHARS));
|
|
1602
|
+
}
|
|
1603
|
+
const role = (attrs.get("role") ?? "").trim().split(/\s+/)[0].toLowerCase();
|
|
1604
|
+
if (roleName === null && role === "main" && !selfClosing) {
|
|
1605
|
+
roleName = name;
|
|
1606
|
+
roleDepth = 1;
|
|
1607
|
+
hasMain = true;
|
|
1608
|
+
opensRole = true;
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
if (!opensRole && roleName !== null && name === roleName && !selfClosing) roleDepth += 1;
|
|
1612
|
+
if (!INLINE.has(name)) pendingSeparator = true;
|
|
1613
|
+
}
|
|
1614
|
+
emit(html.slice(textFrom));
|
|
1615
|
+
return Object.freeze({
|
|
1616
|
+
mainText: main.join(""),
|
|
1617
|
+
bodyText: body.join(""),
|
|
1618
|
+
hasMain,
|
|
1619
|
+
visibleText: visible.join(""),
|
|
1620
|
+
noticeText: notice.join(""),
|
|
1621
|
+
metaRobots,
|
|
1622
|
+
itemtypes,
|
|
1623
|
+
ldBlocks,
|
|
1624
|
+
ldScripts,
|
|
1625
|
+
ldBounds: [...ldBounds],
|
|
1626
|
+
truncated
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
/**
|
|
1630
|
+
* Whether the page has an application root element (see `isAppRoot`): a second, light pass over
|
|
1631
|
+
* the start tags that stops at the first one found.
|
|
1632
|
+
*/
|
|
1633
|
+
function hasAppRoot(input) {
|
|
1634
|
+
const html = input.length > 2097152 ? input.slice(0, HOME_SCAN_MAX_CHARS) : input;
|
|
1635
|
+
let at = 0;
|
|
1636
|
+
for (;;) {
|
|
1637
|
+
const open = html.indexOf("<", at);
|
|
1638
|
+
if (open === -1) return false;
|
|
1639
|
+
const next = html.charCodeAt(open + 1);
|
|
1640
|
+
if (next === 33 || next === 63) {
|
|
1641
|
+
const end = html.startsWith("<!--", open) ? html.indexOf("-->", open + 4) : html.indexOf(">", open + 2);
|
|
1642
|
+
if (end === -1) return false;
|
|
1643
|
+
at = end + (html.startsWith("<!--", open) ? 3 : 1);
|
|
1644
|
+
continue;
|
|
1645
|
+
}
|
|
1646
|
+
if (next === 47 || !isLetter(next)) {
|
|
1647
|
+
at = open + 1;
|
|
1648
|
+
continue;
|
|
1649
|
+
}
|
|
1650
|
+
const nameTo = nameEnd(html, open + 1);
|
|
1651
|
+
const name = html.slice(open + 1, nameTo).toLowerCase();
|
|
1652
|
+
const tagEnd = findTagEnd(html, nameTo);
|
|
1653
|
+
if (tagEnd === -1) return false;
|
|
1654
|
+
at = tagEnd + 1;
|
|
1655
|
+
if (RAW_TEXT.has(name)) {
|
|
1656
|
+
const end = findClosing(html, name, at);
|
|
1657
|
+
if (end === -1) return false;
|
|
1658
|
+
at = end;
|
|
1659
|
+
continue;
|
|
1660
|
+
}
|
|
1661
|
+
if (name === "app-root") return true;
|
|
1662
|
+
const raw = tagEnd === nameTo ? "" : html.slice(nameTo, tagEnd);
|
|
1663
|
+
if (raw.length > 4 && /\bid\s*=|ng-version|ng-app|data-reactroot|data-server-rendered/i.test(raw)) {
|
|
1664
|
+
if (isAppRoot(name, attributes(raw))) return true;
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
// src/facts/json-ld.ts
|
|
1669
|
+
var MAX_LD_DEPTH = 16;
|
|
1670
|
+
var MAX_LD_NODES = 5e3;
|
|
1671
|
+
var MAX_TYPES_PER_NODE = 20;
|
|
1672
|
+
var MAX_TYPE_CHARS = 200;
|
|
1673
|
+
var SCHEMA_ORG_CONTEXT = /^\s*https?:\/\/(?:www\.)?schema\.org(?:[/#?].*)?$/i;
|
|
1674
|
+
/** Whether an `@context` value names schema.org (as a string, in an array, or as `@vocab`). */
|
|
1675
|
+
function namesSchemaOrg(context) {
|
|
1676
|
+
if (typeof context === "string") return SCHEMA_ORG_CONTEXT.test(context);
|
|
1677
|
+
if (Array.isArray(context)) return context.slice(0, 20).some((item) => namesSchemaOrg(item));
|
|
1678
|
+
if (isObject(context)) {
|
|
1679
|
+
const vocab = context["@vocab"];
|
|
1680
|
+
return typeof vocab === "string" && SCHEMA_ORG_CONTEXT.test(vocab);
|
|
1681
|
+
}
|
|
1682
|
+
return false;
|
|
1683
|
+
}
|
|
1684
|
+
function isObject(value) {
|
|
1685
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1686
|
+
}
|
|
1687
|
+
/** A type name without `schema:` and `http(s)://schema.org/`. */
|
|
1688
|
+
function schemaTypeName(raw) {
|
|
1689
|
+
return raw.trim().replace(/^(?:https?:\/\/(?:www\.)?schema\.org\/|schema:)/i, "").slice(0, MAX_TYPE_CHARS);
|
|
1690
|
+
}
|
|
1691
|
+
function typeNames(value) {
|
|
1692
|
+
const list = Array.isArray(value) ? value.slice(0, MAX_TYPES_PER_NODE) : [value];
|
|
1693
|
+
const names = [];
|
|
1694
|
+
for (const item of list) if (typeof item === "string" && item.trim() !== "") names.push(schemaTypeName(item));
|
|
1695
|
+
return names;
|
|
1696
|
+
}
|
|
1697
|
+
/** The block's text without a wrapping `<!-- -->` or `<![CDATA[ ]]>`, and without a byte order mark. */
|
|
1698
|
+
function unwrapBlock(text) {
|
|
1699
|
+
let body = text.charCodeAt(0) === 65279 ? text.slice(1) : text;
|
|
1700
|
+
body = body.trim();
|
|
1701
|
+
if (body.startsWith("<!--") && body.endsWith("-->")) body = body.slice(4, -3).trim();
|
|
1702
|
+
if (body.startsWith("<![CDATA[") && body.endsWith("]]>")) body = body.slice(9, -3).trim();
|
|
1703
|
+
return body;
|
|
1704
|
+
}
|
|
1705
|
+
/** Parses the blocks and walks the data that names schema.org. */
|
|
1706
|
+
function walkJsonLd(blocks, scripts, inheritedBounds) {
|
|
1707
|
+
const bounds = new Set(inheritedBounds);
|
|
1708
|
+
const typed = [];
|
|
1709
|
+
let parsed = 0;
|
|
1710
|
+
let schemaOrgBlocks = 0;
|
|
1711
|
+
let errors = 0;
|
|
1712
|
+
let nodes = 0;
|
|
1713
|
+
let stopped = false;
|
|
1714
|
+
for (const block of blocks) {
|
|
1715
|
+
let data;
|
|
1716
|
+
try {
|
|
1717
|
+
data = JSON.parse(unwrapBlock(block));
|
|
1718
|
+
} catch {
|
|
1719
|
+
errors += 1;
|
|
1720
|
+
continue;
|
|
1721
|
+
}
|
|
1722
|
+
parsed += 1;
|
|
1723
|
+
const roots = Array.isArray(data) ? data.slice(0, 1e3) : [data];
|
|
1724
|
+
let named = false;
|
|
1725
|
+
for (const root of roots) {
|
|
1726
|
+
if (!isObject(root) || !namesSchemaOrg(root["@context"])) continue;
|
|
1727
|
+
named = true;
|
|
1728
|
+
if (stopped) continue;
|
|
1729
|
+
const stack = [{
|
|
1730
|
+
value: root,
|
|
1731
|
+
depth: 1
|
|
1732
|
+
}];
|
|
1733
|
+
while (stack.length > 0 && !stopped) {
|
|
1734
|
+
const { value, depth } = stack.pop();
|
|
1735
|
+
if (depth > 16) {
|
|
1736
|
+
bounds.add("depth");
|
|
1737
|
+
continue;
|
|
1738
|
+
}
|
|
1739
|
+
const children = [];
|
|
1740
|
+
if (Array.isArray(value)) {
|
|
1741
|
+
for (const item of value) if (typeof item === "object" && item !== null) children.push(item);
|
|
1742
|
+
} else if (isObject(value)) {
|
|
1743
|
+
nodes += 1;
|
|
1744
|
+
if (nodes > 5e3) {
|
|
1745
|
+
bounds.add("nodes");
|
|
1746
|
+
stopped = true;
|
|
1747
|
+
break;
|
|
1748
|
+
}
|
|
1749
|
+
if (Object.hasOwn(value, "@type")) {
|
|
1750
|
+
const types = typeNames(value["@type"]);
|
|
1751
|
+
if (types.length > 0) typed.push({
|
|
1752
|
+
types,
|
|
1753
|
+
node: value
|
|
1754
|
+
});
|
|
1755
|
+
}
|
|
1756
|
+
for (const key of Object.keys(value)) {
|
|
1757
|
+
if (key === "@context") continue;
|
|
1758
|
+
const child = value[key];
|
|
1759
|
+
if (typeof child === "object" && child !== null) children.push(child);
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
for (let index = children.length - 1; index >= 0; index -= 1) stack.push({
|
|
1763
|
+
value: children[index],
|
|
1764
|
+
depth: depth + 1
|
|
1765
|
+
});
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
if (named) schemaOrgBlocks += 1;
|
|
1769
|
+
}
|
|
1770
|
+
return Object.freeze({
|
|
1771
|
+
scripts,
|
|
1772
|
+
parsed,
|
|
1773
|
+
schemaOrgBlocks,
|
|
1774
|
+
errors,
|
|
1775
|
+
bounds: [...bounds],
|
|
1776
|
+
typed
|
|
1777
|
+
});
|
|
1778
|
+
}
|
|
1779
|
+
// src/facts/schema-org-organization-types.ts
|
|
1780
|
+
/** The schema.org release the list below was read from. */
|
|
1781
|
+
var SCHEMA_ORG_VERSION = "30.1";
|
|
1782
|
+
/** SHA-256 of that release's vocabulary file (schemaorg-current-https.jsonld). */
|
|
1783
|
+
var SCHEMA_ORG_SHA256 = "0a4b8c4c910fc831ec8695196510973390906a5ee34dc6b862dfec4d21a12f74";
|
|
1784
|
+
/** `Organization` and every schema.org type that is a subtype of it, sorted. */
|
|
1785
|
+
var ORGANIZATION_TYPES = Object.freeze([
|
|
1786
|
+
"AccountingService",
|
|
1787
|
+
"AdultEntertainment",
|
|
1788
|
+
"Airline",
|
|
1789
|
+
"AmusementPark",
|
|
1790
|
+
"AnimalShelter",
|
|
1791
|
+
"ArchiveOrganization",
|
|
1792
|
+
"ArtGallery",
|
|
1793
|
+
"Attorney",
|
|
1794
|
+
"AutoBodyShop",
|
|
1795
|
+
"AutoDealer",
|
|
1796
|
+
"AutoPartsStore",
|
|
1797
|
+
"AutoRental",
|
|
1798
|
+
"AutoRepair",
|
|
1799
|
+
"AutoWash",
|
|
1800
|
+
"AutomatedTeller",
|
|
1801
|
+
"AutomotiveBusiness",
|
|
1802
|
+
"Bakery",
|
|
1803
|
+
"BankOrCreditUnion",
|
|
1804
|
+
"BarOrPub",
|
|
1805
|
+
"BeautySalon",
|
|
1806
|
+
"BedAndBreakfast",
|
|
1807
|
+
"BikeStore",
|
|
1808
|
+
"BookStore",
|
|
1809
|
+
"BowlingAlley",
|
|
1810
|
+
"Brewery",
|
|
1811
|
+
"CafeOrCoffeeShop",
|
|
1812
|
+
"Campground",
|
|
1813
|
+
"Casino",
|
|
1814
|
+
"ChildCare",
|
|
1815
|
+
"ClothingStore",
|
|
1816
|
+
"CollegeOrUniversity",
|
|
1817
|
+
"ComedyClub",
|
|
1818
|
+
"ComputerStore",
|
|
1819
|
+
"Consortium",
|
|
1820
|
+
"ConvenienceStore",
|
|
1821
|
+
"Cooperative",
|
|
1822
|
+
"Corporation",
|
|
1823
|
+
"CovidTestingFacility",
|
|
1824
|
+
"DanceGroup",
|
|
1825
|
+
"DaySpa",
|
|
1826
|
+
"Dentist",
|
|
1827
|
+
"DepartmentStore",
|
|
1828
|
+
"DiagnosticLab",
|
|
1829
|
+
"Distillery",
|
|
1830
|
+
"DryCleaningOrLaundry",
|
|
1831
|
+
"EducationalOrganization",
|
|
1832
|
+
"Electrician",
|
|
1833
|
+
"ElectronicsStore",
|
|
1834
|
+
"ElementarySchool",
|
|
1835
|
+
"EmergencyService",
|
|
1836
|
+
"EmploymentAgency",
|
|
1837
|
+
"EntertainmentBusiness",
|
|
1838
|
+
"ExerciseGym",
|
|
1839
|
+
"FastFoodRestaurant",
|
|
1840
|
+
"FinancialService",
|
|
1841
|
+
"FireStation",
|
|
1842
|
+
"Florist",
|
|
1843
|
+
"FoodEstablishment",
|
|
1844
|
+
"FundingAgency",
|
|
1845
|
+
"FundingScheme",
|
|
1846
|
+
"FurnitureStore",
|
|
1847
|
+
"GardenStore",
|
|
1848
|
+
"GasStation",
|
|
1849
|
+
"GeneralContractor",
|
|
1850
|
+
"GolfCourse",
|
|
1851
|
+
"GovernmentOffice",
|
|
1852
|
+
"GovernmentOrganization",
|
|
1853
|
+
"GroceryStore",
|
|
1854
|
+
"HVACBusiness",
|
|
1855
|
+
"HairSalon",
|
|
1856
|
+
"HardwareStore",
|
|
1857
|
+
"HealthAndBeautyBusiness",
|
|
1858
|
+
"HealthClub",
|
|
1859
|
+
"HighSchool",
|
|
1860
|
+
"HobbyShop",
|
|
1861
|
+
"HomeAndConstructionBusiness",
|
|
1862
|
+
"HomeGoodsStore",
|
|
1863
|
+
"Hospital",
|
|
1864
|
+
"Hostel",
|
|
1865
|
+
"Hotel",
|
|
1866
|
+
"HousePainter",
|
|
1867
|
+
"IceCreamShop",
|
|
1868
|
+
"IndividualPhysician",
|
|
1869
|
+
"InsuranceAgency",
|
|
1870
|
+
"InternetCafe",
|
|
1871
|
+
"JewelryStore",
|
|
1872
|
+
"LegalService",
|
|
1873
|
+
"Library",
|
|
1874
|
+
"LibrarySystem",
|
|
1875
|
+
"LiquorStore",
|
|
1876
|
+
"LocalBusiness",
|
|
1877
|
+
"Locksmith",
|
|
1878
|
+
"LodgingBusiness",
|
|
1879
|
+
"MedicalBusiness",
|
|
1880
|
+
"MedicalClinic",
|
|
1881
|
+
"MedicalOrganization",
|
|
1882
|
+
"MensClothingStore",
|
|
1883
|
+
"MiddleSchool",
|
|
1884
|
+
"MobilePhoneStore",
|
|
1885
|
+
"Motel",
|
|
1886
|
+
"MotorcycleDealer",
|
|
1887
|
+
"MotorcycleRepair",
|
|
1888
|
+
"MovieRentalStore",
|
|
1889
|
+
"MovieTheater",
|
|
1890
|
+
"MovingCompany",
|
|
1891
|
+
"MusicGroup",
|
|
1892
|
+
"MusicStore",
|
|
1893
|
+
"NGO",
|
|
1894
|
+
"NailSalon",
|
|
1895
|
+
"NewsMediaOrganization",
|
|
1896
|
+
"NightClub",
|
|
1897
|
+
"Notary",
|
|
1898
|
+
"OfficeEquipmentStore",
|
|
1899
|
+
"OnlineBusiness",
|
|
1900
|
+
"OnlineMarketplace",
|
|
1901
|
+
"OnlineStore",
|
|
1902
|
+
"Optician",
|
|
1903
|
+
"Organization",
|
|
1904
|
+
"OutletStore",
|
|
1905
|
+
"PawnShop",
|
|
1906
|
+
"PerformingGroup",
|
|
1907
|
+
"PetStore",
|
|
1908
|
+
"Pharmacy",
|
|
1909
|
+
"Physician",
|
|
1910
|
+
"PhysiciansOffice",
|
|
1911
|
+
"Plumber",
|
|
1912
|
+
"PoliceStation",
|
|
1913
|
+
"PoliticalParty",
|
|
1914
|
+
"PostOffice",
|
|
1915
|
+
"Preschool",
|
|
1916
|
+
"ProfessionalService",
|
|
1917
|
+
"Project",
|
|
1918
|
+
"PublicSwimmingPool",
|
|
1919
|
+
"RadioStation",
|
|
1920
|
+
"RealEstateAgent",
|
|
1921
|
+
"RecyclingCenter",
|
|
1922
|
+
"ResearchOrganization",
|
|
1923
|
+
"ResearchProject",
|
|
1924
|
+
"Resort",
|
|
1925
|
+
"Restaurant",
|
|
1926
|
+
"RoofingContractor",
|
|
1927
|
+
"School",
|
|
1928
|
+
"SearchRescueOrganization",
|
|
1929
|
+
"SelfStorage",
|
|
1930
|
+
"ShoeStore",
|
|
1931
|
+
"ShoppingCenter",
|
|
1932
|
+
"SkiResort",
|
|
1933
|
+
"SportingGoodsStore",
|
|
1934
|
+
"SportsActivityLocation",
|
|
1935
|
+
"SportsClub",
|
|
1936
|
+
"SportsOrganization",
|
|
1937
|
+
"SportsTeam",
|
|
1938
|
+
"StadiumOrArena",
|
|
1939
|
+
"Store",
|
|
1940
|
+
"TattooParlor",
|
|
1941
|
+
"TelevisionStation",
|
|
1942
|
+
"TennisComplex",
|
|
1943
|
+
"TheaterGroup",
|
|
1944
|
+
"TireShop",
|
|
1945
|
+
"TouristInformationCenter",
|
|
1946
|
+
"ToyStore",
|
|
1947
|
+
"TravelAgency",
|
|
1948
|
+
"VacationRental",
|
|
1949
|
+
"VeterinaryCare",
|
|
1950
|
+
"WholesaleStore",
|
|
1951
|
+
"Winery",
|
|
1952
|
+
"WorkersUnion"
|
|
1953
|
+
]);
|
|
1954
|
+
// src/facts/profiles.ts
|
|
1955
|
+
/**
|
|
1956
|
+
* Profile kinds of a `sameAs` link, by host. The list is part of the method: a host is the
|
|
1957
|
+
* domain or any name under it (`en.wikipedia.org`, `m.facebook.com`).
|
|
1958
|
+
*/
|
|
1959
|
+
var PROFILE_HOSTS = Object.freeze([
|
|
1960
|
+
{
|
|
1961
|
+
kind: "wikidata",
|
|
1962
|
+
hosts: ["wikidata.org"]
|
|
1963
|
+
},
|
|
1964
|
+
{
|
|
1965
|
+
kind: "wikipedia",
|
|
1966
|
+
hosts: ["wikipedia.org"]
|
|
1967
|
+
},
|
|
1968
|
+
{
|
|
1969
|
+
kind: "linkedin",
|
|
1970
|
+
hosts: ["linkedin.com"]
|
|
1971
|
+
},
|
|
1972
|
+
{
|
|
1973
|
+
kind: "x",
|
|
1974
|
+
hosts: ["x.com", "twitter.com"]
|
|
1975
|
+
},
|
|
1976
|
+
{
|
|
1977
|
+
kind: "facebook",
|
|
1978
|
+
hosts: ["facebook.com", "fb.com"]
|
|
1979
|
+
},
|
|
1980
|
+
{
|
|
1981
|
+
kind: "instagram",
|
|
1982
|
+
hosts: ["instagram.com"]
|
|
1983
|
+
},
|
|
1984
|
+
{
|
|
1985
|
+
kind: "youtube",
|
|
1986
|
+
hosts: ["youtube.com", "youtu.be"]
|
|
1987
|
+
},
|
|
1988
|
+
{
|
|
1989
|
+
kind: "tiktok",
|
|
1990
|
+
hosts: ["tiktok.com"]
|
|
1991
|
+
},
|
|
1992
|
+
{
|
|
1993
|
+
kind: "github",
|
|
1994
|
+
hosts: ["github.com"]
|
|
1995
|
+
},
|
|
1996
|
+
{
|
|
1997
|
+
kind: "crunchbase",
|
|
1998
|
+
hosts: ["crunchbase.com"]
|
|
1999
|
+
},
|
|
2000
|
+
{
|
|
2001
|
+
kind: "pinterest",
|
|
2002
|
+
hosts: ["pinterest.com"]
|
|
2003
|
+
}
|
|
2004
|
+
]);
|
|
2005
|
+
/** The kinds in the order results list them. */
|
|
2006
|
+
var PROFILE_KINDS = Object.freeze([...PROFILE_HOSTS.map((entry) => entry.kind), "other"]);
|
|
2007
|
+
/** The kind of the profile at a host name. */
|
|
2008
|
+
function profileKind(host) {
|
|
2009
|
+
const name = host.toLowerCase();
|
|
2010
|
+
for (const entry of PROFILE_HOSTS) if (entry.hosts.some((domain) => name === domain || name.endsWith(`.${domain}`))) return entry.kind;
|
|
2011
|
+
return "other";
|
|
2012
|
+
}
|
|
2013
|
+
// src/facts/organization.ts
|
|
2014
|
+
/**
|
|
2015
|
+
* The Organization of a home page: which typed nodes are one, which is the primary node, and
|
|
2016
|
+
* what it says about itself.
|
|
2017
|
+
*/
|
|
2018
|
+
var ORGANIZATION_SET = new Set(ORGANIZATION_TYPES);
|
|
2019
|
+
var MAX_SAME_AS_READ = 500;
|
|
2020
|
+
var MAX_SAME_AS_KEPT = 100;
|
|
2021
|
+
var MAX_URL_CHARS = 2048;
|
|
2022
|
+
var NAME_CHARS = 200;
|
|
2023
|
+
/** Whether a (prefix-free) type name is Organization or one of its schema.org subtypes. */
|
|
2024
|
+
function isOrganizationType(name) {
|
|
2025
|
+
return ORGANIZATION_SET.has(name);
|
|
2026
|
+
}
|
|
2027
|
+
/** A string value: a string, the first string of an array, or the `@value` of a value object. */
|
|
2028
|
+
function textOf(value) {
|
|
2029
|
+
if (typeof value === "string") return value.trim() === "" ? null : value.trim();
|
|
2030
|
+
if (Array.isArray(value)) {
|
|
2031
|
+
for (const item of value.slice(0, 10)) {
|
|
2032
|
+
const found = textOf(item);
|
|
2033
|
+
if (found !== null) return found;
|
|
2034
|
+
}
|
|
2035
|
+
return null;
|
|
2036
|
+
}
|
|
2037
|
+
if (isObject(value) && typeof value["@value"] === "string") return textOf(value["@value"]);
|
|
2038
|
+
return null;
|
|
2039
|
+
}
|
|
2040
|
+
/** An absolute http(s) URL, parsed; null otherwise. */
|
|
2041
|
+
function absoluteUrl(text, base) {
|
|
2042
|
+
if (text === null || text === "" || text.length > MAX_URL_CHARS) return null;
|
|
2043
|
+
try {
|
|
2044
|
+
const url = base === void 0 ? new URL(text) : new URL(text, base);
|
|
2045
|
+
return url.protocol === "http:" || url.protocol === "https:" ? url : null;
|
|
2046
|
+
} catch {
|
|
2047
|
+
return null;
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
/** The node's `url` as written, when it is an absolute http(s) URL. */
|
|
2051
|
+
function urlOf(node) {
|
|
2052
|
+
const text = textOf(node["url"]);
|
|
2053
|
+
return absoluteUrl(text) === null ? null : text;
|
|
2054
|
+
}
|
|
2055
|
+
function hasLogo(value, pageUrl) {
|
|
2056
|
+
if (typeof value === "string") return absoluteUrl(value.trim(), pageUrl) !== null;
|
|
2057
|
+
if (Array.isArray(value)) return value.slice(0, 10).some((item) => hasLogo(item, pageUrl));
|
|
2058
|
+
if (isObject(value)) return typeof value["url"] === "string" && absoluteUrl(value["url"].trim(), pageUrl) !== null;
|
|
2059
|
+
return false;
|
|
2060
|
+
}
|
|
2061
|
+
function sameAsValues(value) {
|
|
2062
|
+
const list = Array.isArray(value) ? value.slice(0, MAX_SAME_AS_READ) : [value];
|
|
2063
|
+
const out = [];
|
|
2064
|
+
for (const item of list) if (typeof item === "string") out.push(item);
|
|
2065
|
+
else if (isObject(item) && typeof item["@id"] === "string") out.push(item["@id"]);
|
|
2066
|
+
return out;
|
|
2067
|
+
}
|
|
2068
|
+
/** The Organization nodes among the typed nodes, in document order. */
|
|
2069
|
+
function organizationNodes(typed) {
|
|
2070
|
+
const found = [];
|
|
2071
|
+
for (const { types, node } of typed) {
|
|
2072
|
+
const type = types.find((name) => ORGANIZATION_SET.has(name));
|
|
2073
|
+
if (type !== void 0) found.push({
|
|
2074
|
+
type,
|
|
2075
|
+
node
|
|
2076
|
+
});
|
|
2077
|
+
}
|
|
2078
|
+
return found;
|
|
2079
|
+
}
|
|
2080
|
+
/**
|
|
2081
|
+
* The primary node: the first Organization node whose `url` is on the site's registrable
|
|
2082
|
+
* domain, else the first. Null without any.
|
|
2083
|
+
*/
|
|
2084
|
+
function primaryOrganization(nodes, siteDomain, registrable) {
|
|
2085
|
+
for (const candidate of nodes) {
|
|
2086
|
+
const url = absoluteUrl(urlOf(candidate.node));
|
|
2087
|
+
if (url !== null && registrable(url.hostname) === siteDomain) return candidate;
|
|
2088
|
+
}
|
|
2089
|
+
return nodes[0] ?? null;
|
|
2090
|
+
}
|
|
2091
|
+
/** What the primary node says about itself. */
|
|
2092
|
+
function describeOrganization(primary, pageUrl, siteDomain, registrable) {
|
|
2093
|
+
const node = primary.node;
|
|
2094
|
+
const name = textOf(node["name"]);
|
|
2095
|
+
const url = urlOf(node);
|
|
2096
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2097
|
+
const sameAs = [];
|
|
2098
|
+
const kinds = /* @__PURE__ */ new Set();
|
|
2099
|
+
for (const text of sameAsValues(node["sameAs"])) {
|
|
2100
|
+
const link = absoluteUrl(text.trim());
|
|
2101
|
+
if (link === null) continue;
|
|
2102
|
+
const host = normalizeHost(link.hostname);
|
|
2103
|
+
if (registrable(host) === siteDomain) continue;
|
|
2104
|
+
const key = `${host}${link.pathname.replace(/\/+$/, "")}${link.search}`.toLowerCase();
|
|
2105
|
+
if (seen.has(key)) continue;
|
|
2106
|
+
seen.add(key);
|
|
2107
|
+
if (sameAs.length < MAX_SAME_AS_KEPT) sameAs.push(text.trim());
|
|
2108
|
+
kinds.add(profileKind(host));
|
|
2109
|
+
}
|
|
2110
|
+
return Object.freeze({
|
|
2111
|
+
type: primary.type,
|
|
2112
|
+
name: name === null ? null : name.slice(0, NAME_CHARS),
|
|
2113
|
+
url,
|
|
2114
|
+
nameAndUrl: name !== null && url !== null,
|
|
2115
|
+
logo: hasLogo(node["logo"], pageUrl),
|
|
2116
|
+
sameAs: Object.freeze(sameAs),
|
|
2117
|
+
sameAsKinds: Object.freeze(PROFILE_KINDS.filter((kind) => kinds.has(kind)))
|
|
2118
|
+
});
|
|
2119
|
+
}
|
|
2120
|
+
/** Whether any `potentialAction` of the node is a `SearchAction`. */
|
|
2121
|
+
function hasSearchAction(node) {
|
|
2122
|
+
const value = node["potentialAction"];
|
|
2123
|
+
return (Array.isArray(value) ? value.slice(0, 20) : [value]).some((action) => {
|
|
2124
|
+
if (!isObject(action)) return false;
|
|
2125
|
+
const type = action["@type"];
|
|
2126
|
+
return (Array.isArray(type) ? type : [type]).some((name) => typeof name === "string" && /(?:^|[/:])SearchAction$/.test(name));
|
|
2127
|
+
});
|
|
2128
|
+
}
|
|
2129
|
+
// src/facts/home-page.ts
|
|
2130
|
+
/**
|
|
2131
|
+
* `readHomePage`: every fact the study and `checkSite` read from a home page's HTML, by one
|
|
2132
|
+
* pure function. The page's text is read as served: no script is run.
|
|
2133
|
+
*/
|
|
2134
|
+
/** A home page with at least this many words of main text has "text in the HTML". */
|
|
2135
|
+
var TEXT_IN_HTML_MIN_WORDS = 150;
|
|
2136
|
+
var MAX_TYPES = 200;
|
|
2137
|
+
var SCHEMA_TYPE_NAME = /^[A-Za-z][A-Za-z0-9]*$/;
|
|
2138
|
+
/** `noindex` (or `none`, which means it) in a robots meta or header value, without an agent prefix. */
|
|
2139
|
+
function sayNoindex(value) {
|
|
2140
|
+
return value.split(",").some((token) => {
|
|
2141
|
+
const word = token.trim().toLowerCase();
|
|
2142
|
+
return word === "noindex" || word === "none";
|
|
2143
|
+
});
|
|
2144
|
+
}
|
|
2145
|
+
/**
|
|
2146
|
+
* Whether `X-Robots-Tag` values say noindex to every crawler. A value may start with an agent
|
|
2147
|
+
* (`googlebot: noindex, nofollow`), which then applies to the rest of that value up to the next
|
|
2148
|
+
* agent; such a rule is for one crawler and does not count. `unavailable_after: <date>` is a
|
|
2149
|
+
* directive, not an agent.
|
|
2150
|
+
*/
|
|
2151
|
+
function xRobotsNoindex(values) {
|
|
2152
|
+
if (values === null || values === void 0) return false;
|
|
2153
|
+
const list = typeof values === "string" ? [values] : values;
|
|
2154
|
+
for (const value of list.slice(0, 20)) {
|
|
2155
|
+
let agent = null;
|
|
2156
|
+
for (const token of value.slice(0, 2e3).split(",")) {
|
|
2157
|
+
let directive = token;
|
|
2158
|
+
const prefix = /^\s*([A-Za-z0-9_.-]+)\s*:\s*(.*)$/s.exec(token);
|
|
2159
|
+
if (prefix !== null && prefix[1].toLowerCase() !== "unavailable_after") {
|
|
2160
|
+
agent = prefix[1].toLowerCase();
|
|
2161
|
+
directive = prefix[2];
|
|
2162
|
+
}
|
|
2163
|
+
if (agent === null && sayNoindex(directive)) return true;
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
return false;
|
|
2167
|
+
}
|
|
2168
|
+
/** Reads the facts of a home page. Never throws. */
|
|
2169
|
+
function readHomePage(html, url, options = {}) {
|
|
2170
|
+
const registrable = options.registrableDomain ?? registrableDomain;
|
|
2171
|
+
let host = "";
|
|
2172
|
+
try {
|
|
2173
|
+
host = normalizeHost(new URL(url).hostname);
|
|
2174
|
+
} catch {
|
|
2175
|
+
host = "";
|
|
2176
|
+
}
|
|
2177
|
+
const siteDomain = registrable(host);
|
|
2178
|
+
const bytes = Buffer.byteLength(html, "utf8");
|
|
2179
|
+
const scan = scanPage(html);
|
|
2180
|
+
const mainSource = scan.hasMain ? "main" : "body";
|
|
2181
|
+
const counted = countWords(mainSource === "main" ? scan.mainText : scan.bodyText);
|
|
2182
|
+
const appRoot = hasAppRoot(html);
|
|
2183
|
+
const jsNotice = JS_NOTICE.test(`${scan.noticeText} ${scan.visibleText}`);
|
|
2184
|
+
let content;
|
|
2185
|
+
if (bytes > 5120 && counted.words < 50 && (appRoot || jsNotice)) content = "app_shell";
|
|
2186
|
+
else if (counted.words >= 150) content = "text_in_html";
|
|
2187
|
+
else content = "little_text";
|
|
2188
|
+
const botCheck = isHomeChallenge(null, `${scan.noticeText} ${scan.visibleText}`, counted.words);
|
|
2189
|
+
const metaNoindex = scan.metaRobots.some(sayNoindex);
|
|
2190
|
+
const headerNoindex = xRobotsNoindex(options.xRobotsTag);
|
|
2191
|
+
const walk = walkJsonLd(scan.ldBlocks, scan.ldScripts, scan.ldBounds);
|
|
2192
|
+
const types = [];
|
|
2193
|
+
const typeSet = /* @__PURE__ */ new Set();
|
|
2194
|
+
const addType = (name) => {
|
|
2195
|
+
if (!SCHEMA_TYPE_NAME.test(name) || typeSet.has(name) || typeSet.size >= MAX_TYPES) return;
|
|
2196
|
+
typeSet.add(name);
|
|
2197
|
+
types.push(name);
|
|
2198
|
+
};
|
|
2199
|
+
for (const { types: names } of walk.typed) for (const name of names) addType(name);
|
|
2200
|
+
const microdataTypes = [];
|
|
2201
|
+
for (const token of scan.itemtypes) {
|
|
2202
|
+
if (!/^https?:\/\/(?:www\.)?schema\.org\//i.test(token)) continue;
|
|
2203
|
+
const name = schemaTypeName(token);
|
|
2204
|
+
if (!microdataTypes.includes(name)) microdataTypes.push(name);
|
|
2205
|
+
addType(name);
|
|
2206
|
+
}
|
|
2207
|
+
const primaryNode = primaryOrganization(organizationNodes(walk.typed), siteDomain, registrable);
|
|
2208
|
+
const primary = primaryNode === null ? null : describeOrganization(primaryNode, url, siteDomain, registrable);
|
|
2209
|
+
const microOrg = microdataTypes.find(isOrganizationType) ?? null;
|
|
2210
|
+
const organization = primary !== null ? "json_ld" : microOrg !== null ? "microdata" : "none";
|
|
2211
|
+
const websites = walk.typed.filter(({ types: names }) => names.includes("WebSite"));
|
|
2212
|
+
const searchAction = websites.some(({ node }) => hasSearchAction(node)) || microdataTypes.includes("WebSite") && microdataTypes.includes("SearchAction");
|
|
2213
|
+
return Object.freeze({
|
|
2214
|
+
url,
|
|
2215
|
+
host,
|
|
2216
|
+
bytes,
|
|
2217
|
+
scanTruncated: scan.truncated,
|
|
2218
|
+
mainWords: counted.words,
|
|
2219
|
+
wordsCapped: counted.capped,
|
|
2220
|
+
mainSource,
|
|
2221
|
+
content,
|
|
2222
|
+
appRoot,
|
|
2223
|
+
jsNotice,
|
|
2224
|
+
botCheck,
|
|
2225
|
+
noindex: metaNoindex || headerNoindex,
|
|
2226
|
+
noindexSource: metaNoindex && headerNoindex ? "both" : metaNoindex ? "meta" : headerNoindex ? "header" : null,
|
|
2227
|
+
jsonLd: Object.freeze({
|
|
2228
|
+
scripts: walk.scripts,
|
|
2229
|
+
parsed: walk.parsed,
|
|
2230
|
+
schemaOrgBlocks: walk.schemaOrgBlocks,
|
|
2231
|
+
errors: walk.errors,
|
|
2232
|
+
bounds: walk.bounds
|
|
2233
|
+
}),
|
|
2234
|
+
types: Object.freeze(types),
|
|
2235
|
+
microdataTypes: Object.freeze(microdataTypes),
|
|
2236
|
+
organization,
|
|
2237
|
+
organizationType: primary?.type ?? microOrg,
|
|
2238
|
+
primary,
|
|
2239
|
+
website: websites.length > 0 || microdataTypes.includes("WebSite"),
|
|
2240
|
+
searchAction,
|
|
2241
|
+
faqPage: walk.typed.some(({ types: names }) => names.includes("FAQPage")) || microdataTypes.includes("FAQPage")
|
|
2242
|
+
});
|
|
2243
|
+
}
|
|
2244
|
+
// src/facts/home-response.ts
|
|
2245
|
+
/**
|
|
2246
|
+
* What a fetched answer for the home page means: read, or not read and why. A pure function of
|
|
2247
|
+
* the fetch result, so the check and the study draw the same line.
|
|
2248
|
+
*/
|
|
2249
|
+
var HTML_TYPES = /* @__PURE__ */ new Set(["text/html", "application/xhtml+xml"]);
|
|
2250
|
+
/** Reads the answer for the home page. `not_allowed` is not decided here: no request was made. */
|
|
2251
|
+
function readHomeResponse(result, options = {}) {
|
|
2252
|
+
const base = {
|
|
2253
|
+
requestedUrl: result.requestedUrl,
|
|
2254
|
+
finalUrl: result.finalUrl,
|
|
2255
|
+
status: result.status,
|
|
2256
|
+
redirects: result.redirects.length,
|
|
2257
|
+
truncated: result.truncated,
|
|
2258
|
+
facts: null
|
|
2259
|
+
};
|
|
2260
|
+
const not = (outcome) => Object.freeze({
|
|
2261
|
+
...base,
|
|
2262
|
+
outcome
|
|
2263
|
+
});
|
|
2264
|
+
if (result.outcome !== "response") {
|
|
2265
|
+
if (result.outcome === "redirect_refused") return not("off_site");
|
|
2266
|
+
if (result.outcome === "too_many_redirects" || result.outcome === "redirect_loop" || result.outcome === "invalid_redirect") return not("redirect_error");
|
|
2267
|
+
return not("network");
|
|
2268
|
+
}
|
|
2269
|
+
const status = result.status ?? 0;
|
|
2270
|
+
if (status === 401 || status === 403) return not("forbidden");
|
|
2271
|
+
if (status === 202) return not("challenge");
|
|
2272
|
+
if (status !== 200) return not("error_status");
|
|
2273
|
+
const text = result.rawBody === null ? result.text : decodeHtml(result.rawBody, result.headers["content-type"]?.[0] ?? null, result.truncated).text;
|
|
2274
|
+
if (text === null) return not("not_html");
|
|
2275
|
+
if (result.contentType !== null ? !HTML_TYPES.has(result.contentType) : !looksLikeHtml(null, text)) return not("not_html");
|
|
2276
|
+
const facts = readHomePage(text, result.finalUrl, {
|
|
2277
|
+
xRobotsTag: result.headers["x-robots-tag"] ?? null,
|
|
2278
|
+
...options.registrableDomain === void 0 ? {} : { registrableDomain: options.registrableDomain }
|
|
2279
|
+
});
|
|
2280
|
+
if (facts.botCheck) return not("challenge");
|
|
2281
|
+
if (placeholderMark(text, facts.mainWords) !== null) return not("placeholder");
|
|
2282
|
+
return Object.freeze({
|
|
2283
|
+
...base,
|
|
2284
|
+
outcome: "read",
|
|
2285
|
+
facts
|
|
2286
|
+
});
|
|
2287
|
+
}
|
|
2288
|
+
// src/facts/reader.ts
|
|
2289
|
+
/** The product token this reader uses when it matches robots.txt rules (its user agent's product). */
|
|
2290
|
+
var READER_TOKEN = "QueryRev-AIReadiness";
|
|
2291
|
+
// src/facts/sitemap.ts
|
|
2292
|
+
/**
|
|
2293
|
+
* `readSitemap`: whether a file is a sitemap, and what kind. A file counts only by what it
|
|
2294
|
+
* holds: an HTML page at `/sitemap.xml` is not a sitemap (a site that answers every address with
|
|
2295
|
+
* a page), whatever its status. XML with a DOCTYPE or an ENTITY is refused unread, so no
|
|
2296
|
+
* entity can expand into more than the file holds.
|
|
2297
|
+
*/
|
|
2298
|
+
/** A sitemap is read to this many bytes after decompression (the fetch cap for a sitemap). */
|
|
2299
|
+
var SITEMAP_MAX_BYTES = 5242880;
|
|
2300
|
+
/** `<loc>` entries or text lines counted at most. */
|
|
2301
|
+
var MAX_LOCS_COUNTED = 1e5;
|
|
2302
|
+
/** Addresses looked at to decide whether any is on the site. */
|
|
2303
|
+
var LOCS_CHECKED = 500;
|
|
2304
|
+
var GZIP_MAGIC = [31, 139];
|
|
2305
|
+
function isGzip(bytes) {
|
|
2306
|
+
return bytes.length >= 2 && bytes[0] === GZIP_MAGIC[0] && bytes[1] === GZIP_MAGIC[1];
|
|
2307
|
+
}
|
|
2308
|
+
/** Inflates a gzip stream to at most `cap` bytes; the rest is dropped. Null when it is corrupt. */
|
|
2309
|
+
async function inflateCapped(bytes, cap) {
|
|
2310
|
+
const gunzip = createGunzip();
|
|
2311
|
+
const chunks = [];
|
|
2312
|
+
let kept = 0;
|
|
2313
|
+
let truncated = false;
|
|
2314
|
+
try {
|
|
2315
|
+
Readable.from([Buffer.from(bytes)]).pipe(gunzip);
|
|
2316
|
+
for await (const chunk of gunzip) {
|
|
2317
|
+
const room = cap - kept;
|
|
2318
|
+
if (chunk.length > room) {
|
|
2319
|
+
chunks.push(chunk.subarray(0, room));
|
|
2320
|
+
kept += room;
|
|
2321
|
+
truncated = true;
|
|
2322
|
+
break;
|
|
2323
|
+
}
|
|
2324
|
+
chunks.push(chunk);
|
|
2325
|
+
kept += chunk.length;
|
|
2326
|
+
}
|
|
2327
|
+
} catch {
|
|
2328
|
+
return null;
|
|
2329
|
+
} finally {
|
|
2330
|
+
gunzip.destroy();
|
|
2331
|
+
}
|
|
2332
|
+
return {
|
|
2333
|
+
data: Buffer.concat(chunks, kept),
|
|
2334
|
+
truncated
|
|
2335
|
+
};
|
|
2336
|
+
}
|
|
2337
|
+
function decode(bytes, truncated) {
|
|
2338
|
+
if (bytes.length >= 2 && bytes[0] === 255 && bytes[1] === 254) return new TextDecoder("utf-16le").decode(bytes.subarray(2), { stream: truncated });
|
|
2339
|
+
if (bytes.length >= 2 && bytes[0] === 254 && bytes[1] === 255) return new TextDecoder("utf-16be").decode(bytes.subarray(2), { stream: truncated });
|
|
2340
|
+
return new TextDecoder("utf-8").decode(bytes, { stream: truncated });
|
|
2341
|
+
}
|
|
2342
|
+
function fail(outcome, reason, base, kind = null, locs = 0) {
|
|
2343
|
+
return Object.freeze({
|
|
2344
|
+
outcome,
|
|
2345
|
+
kind,
|
|
2346
|
+
reason,
|
|
2347
|
+
locs,
|
|
2348
|
+
...base
|
|
2349
|
+
});
|
|
2350
|
+
}
|
|
2351
|
+
var SITEMAP_NAMESPACE = "http://www\\.sitemaps\\.org/schemas/sitemap/0\\.9";
|
|
2352
|
+
function escapePrefix(prefix) {
|
|
2353
|
+
return prefix.replace(/[.-]/g, "\\$&");
|
|
2354
|
+
}
|
|
2355
|
+
/**
|
|
2356
|
+
* The last 256 characters before any trailing whitespace, comments and processing instructions
|
|
2357
|
+
* (WordPress SEO plugins end a sitemap with a comment after the closing tag). Linear: each step
|
|
2358
|
+
* moves the end back.
|
|
2359
|
+
*/
|
|
2360
|
+
function tailBeforeComments(xml) {
|
|
2361
|
+
let end = xml.length;
|
|
2362
|
+
for (let step = 0; step < 100; step += 1) {
|
|
2363
|
+
while (end > 0 && /\s/.test(xml.charAt(end - 1))) end -= 1;
|
|
2364
|
+
if (end >= 3 && xml.startsWith("-->", end - 3)) {
|
|
2365
|
+
const start = xml.lastIndexOf("<!--", end - 3);
|
|
2366
|
+
if (start === -1) break;
|
|
2367
|
+
end = start;
|
|
2368
|
+
} else if (end >= 2 && xml.startsWith("?>", end - 2)) {
|
|
2369
|
+
const start = xml.lastIndexOf("<?", end - 2);
|
|
2370
|
+
if (start === -1) break;
|
|
2371
|
+
end = start;
|
|
2372
|
+
} else break;
|
|
2373
|
+
}
|
|
2374
|
+
return xml.slice(Math.max(0, end - 256), end);
|
|
2375
|
+
}
|
|
2376
|
+
var XML_PROLOG = /^(?:\s|<\?[\s\S]*?\?>|<!--[\s\S]*?-->)*/;
|
|
2377
|
+
/** Counts `<loc>` start tags, up to `MAX_LOCS_COUNTED`. */
|
|
2378
|
+
function countLocs(xml) {
|
|
2379
|
+
const pattern = /<loc[\s>]/gi;
|
|
2380
|
+
let count = 0;
|
|
2381
|
+
while (pattern.exec(xml) !== null) {
|
|
2382
|
+
count += 1;
|
|
2383
|
+
if (count >= 1e5) break;
|
|
2384
|
+
}
|
|
2385
|
+
return count;
|
|
2386
|
+
}
|
|
2387
|
+
/**
|
|
2388
|
+
* The sitemap rules on text that is already decoded and inflated. `truncated` says the text was
|
|
2389
|
+
* cut at the cap: it is then read as far as it goes.
|
|
2390
|
+
*/
|
|
2391
|
+
function readSitemapText(text, contentType, siteUrl, options = {}) {
|
|
2392
|
+
const registrable = options.registrableDomain ?? registrableDomain;
|
|
2393
|
+
const truncated = options.truncated === true;
|
|
2394
|
+
const base = {
|
|
2395
|
+
gzip: options.gzip === true,
|
|
2396
|
+
truncated,
|
|
2397
|
+
bytes: Buffer.byteLength(text, "utf8")
|
|
2398
|
+
};
|
|
2399
|
+
let siteDomain = "";
|
|
2400
|
+
try {
|
|
2401
|
+
siteDomain = registrable(new URL(siteUrl).hostname);
|
|
2402
|
+
} catch {
|
|
2403
|
+
siteDomain = "";
|
|
2404
|
+
}
|
|
2405
|
+
const onSite = (candidate) => {
|
|
2406
|
+
try {
|
|
2407
|
+
const url = new URL(candidate);
|
|
2408
|
+
return (url.protocol === "http:" || url.protocol === "https:") && isOnDomain(url.hostname, siteDomain);
|
|
2409
|
+
} catch {
|
|
2410
|
+
return false;
|
|
2411
|
+
}
|
|
2412
|
+
};
|
|
2413
|
+
const body = text.charCodeAt(0) === 65279 ? text.slice(1) : text;
|
|
2414
|
+
if (body.trim() === "") return fail("not_a_sitemap", "empty", base);
|
|
2415
|
+
if (looksLikeHtml(null, body)) return fail("not_a_sitemap", "html", base);
|
|
2416
|
+
const servedAsHtml = contentType === "text/html" || contentType === "application/xhtml+xml";
|
|
2417
|
+
if (body.trimStart().charAt(0) === "<") {
|
|
2418
|
+
if (/<!(?:doctype|entity)\b/i.test(body)) return fail("malformed", "doctype", base);
|
|
2419
|
+
const afterProlog = body.slice(XML_PROLOG.exec(body)[0].length);
|
|
2420
|
+
const root = /^<(?:([A-Za-z][\w.-]*):)?([A-Za-z][\w.-]*)([^>]*)>?/.exec(afterProlog);
|
|
2421
|
+
const rootName = root?.[2]?.toLowerCase();
|
|
2422
|
+
const prefix = root?.[1];
|
|
2423
|
+
const prefixBound = prefix === void 0 || new RegExp(`xmlns:${escapePrefix(prefix)}\\s*=\\s*["']${SITEMAP_NAMESPACE}["']`).test(root?.[3] ?? "");
|
|
2424
|
+
if (rootName !== "urlset" && rootName !== "sitemapindex" || !prefixBound) return fail("not_a_sitemap", servedAsHtml ? "html" : "bad_root", base);
|
|
2425
|
+
const kind = rootName === "urlset" ? "urlset" : "index";
|
|
2426
|
+
const xml = prefix === void 0 ? body : body.replace(new RegExp(`<(/?)${escapePrefix(prefix)}:`, "g"), "<$1");
|
|
2427
|
+
const locs = countLocs(xml);
|
|
2428
|
+
if (locs === 0) return fail("malformed", "no_loc", base, kind);
|
|
2429
|
+
const listed = sitemapLocations(xml, LOCS_CHECKED).locations;
|
|
2430
|
+
if (listed.length === 0) return fail("malformed", "no_loc", base, kind, locs);
|
|
2431
|
+
if (!listed.some(onSite)) return fail("malformed", "off_site", base, kind, locs);
|
|
2432
|
+
if (!truncated && !new RegExp(`</${rootName}\\s*>$`, "i").test(tailBeforeComments(xml))) return fail("malformed", "not_closed", base, kind, locs);
|
|
2433
|
+
return Object.freeze({
|
|
2434
|
+
outcome: "valid",
|
|
2435
|
+
kind,
|
|
2436
|
+
reason: null,
|
|
2437
|
+
locs,
|
|
2438
|
+
...base
|
|
2439
|
+
});
|
|
2440
|
+
}
|
|
2441
|
+
const lines = body.split(/\r\n|\r|\n/);
|
|
2442
|
+
if (truncated) lines.pop();
|
|
2443
|
+
const checked = lines.map((line) => line.trim()).filter((line) => line !== "").slice(0, MAX_LOCS_COUNTED);
|
|
2444
|
+
if (checked.length === 0 || checked.some((line) => !/^https?:\/\/\S+$/i.test(line))) return fail("not_a_sitemap", servedAsHtml ? "html" : "text_lines", base);
|
|
2445
|
+
if (!checked.slice(0, LOCS_CHECKED).some(onSite)) return fail("malformed", "off_site", base, "text", checked.length);
|
|
2446
|
+
return Object.freeze({
|
|
2447
|
+
outcome: "valid",
|
|
2448
|
+
kind: "text",
|
|
2449
|
+
reason: null,
|
|
2450
|
+
locs: checked.length,
|
|
2451
|
+
...base
|
|
2452
|
+
});
|
|
2453
|
+
}
|
|
2454
|
+
/**
|
|
2455
|
+
* Reads a sitemap file from its bytes as served. A gzip file (the `.gz` sitemap, found by its
|
|
2456
|
+
* two first bytes) is inflated to at most `SITEMAP_MAX_BYTES`; a file that goes past the cap is
|
|
2457
|
+
* cut there and reported as `truncated`, and valid as far as it was read. `siteUrl` is the
|
|
2458
|
+
* site's own address: only `<loc>` entries on its registrable domain count.
|
|
2459
|
+
*/
|
|
2460
|
+
async function readSitemap(bytes, contentType, siteUrl, options = {}) {
|
|
2461
|
+
let data = bytes;
|
|
2462
|
+
let truncated = options.truncated === true;
|
|
2463
|
+
let gzip = false;
|
|
2464
|
+
if (isGzip(bytes)) {
|
|
2465
|
+
gzip = true;
|
|
2466
|
+
const inflated = await inflateCapped(bytes, SITEMAP_MAX_BYTES);
|
|
2467
|
+
if (inflated === null) return fail("malformed", "gzip_failed", {
|
|
2468
|
+
gzip: true,
|
|
2469
|
+
truncated: false,
|
|
2470
|
+
bytes: 0
|
|
2471
|
+
});
|
|
2472
|
+
data = inflated.data;
|
|
2473
|
+
truncated = inflated.truncated;
|
|
2474
|
+
} else if (bytes.length > 5242880) {
|
|
2475
|
+
data = bytes.subarray(0, SITEMAP_MAX_BYTES);
|
|
2476
|
+
truncated = true;
|
|
2477
|
+
}
|
|
2478
|
+
const facts = readSitemapText(decode(data, truncated), contentType, siteUrl, {
|
|
2479
|
+
...options,
|
|
2480
|
+
truncated,
|
|
2481
|
+
gzip
|
|
2482
|
+
});
|
|
2483
|
+
return Object.freeze({
|
|
2484
|
+
...facts,
|
|
2485
|
+
bytes: data.length
|
|
2486
|
+
});
|
|
2487
|
+
}
|
|
2488
|
+
// src/facts/findings.ts
|
|
2489
|
+
/**
|
|
2490
|
+
* The findings of a site check, drawn from the facts it gathered. Each finding has a stable id,
|
|
2491
|
+
* a level, one plain sentence about this site, and the evidence it rests on. A finding is given
|
|
2492
|
+
* for a check that applies: Organization details are judged only when an Organization was found,
|
|
2493
|
+
* and the page's own checks only when the home page was read.
|
|
2494
|
+
*/
|
|
2495
|
+
var DOC_BASE = "https://queryrev.com/research/ai-search-readiness#";
|
|
2496
|
+
/** Where each finding is explained: its own anchor where the method has one, else the method. */
|
|
2497
|
+
var ANCHORS = Object.freeze({
|
|
2498
|
+
"home-page-read": "home-pages-read",
|
|
2499
|
+
"ai-search-crawler-access": "readable-by-ai-search",
|
|
2500
|
+
"json-ld-on-home-page": "json-ld-on-home-page",
|
|
2501
|
+
"json-ld-errors": "json-ld-errors",
|
|
2502
|
+
"organization-schema": "organization-schema",
|
|
2503
|
+
"organization-microdata-only": "organization-microdata-only",
|
|
2504
|
+
"organization-name-and-url": "organization-name-and-url",
|
|
2505
|
+
"organization-logo": "organization-logo",
|
|
2506
|
+
"organization-same-as": "organization-same-as",
|
|
2507
|
+
"website-schema": "website-schema",
|
|
2508
|
+
"faq-schema": "faq-schema",
|
|
2509
|
+
"text-in-html": "text-in-html",
|
|
2510
|
+
"app-shell": "app-shell",
|
|
2511
|
+
"little-text": "little-text",
|
|
2512
|
+
"noindex-home-page": "noindex-home-page",
|
|
2513
|
+
"sitemap-in-robots": "sitemap-in-robots",
|
|
2514
|
+
"sitemap-valid": "sitemap-valid",
|
|
2515
|
+
"sitemap-cut": "sitemap-valid",
|
|
2516
|
+
"llms-txt-found": "llms-txt-found"
|
|
2517
|
+
});
|
|
2518
|
+
/** The finding ids that exist, for tests and for tools that list them. */
|
|
2519
|
+
var FINDING_IDS = Object.freeze([
|
|
2520
|
+
"site-refused",
|
|
2521
|
+
"robots-txt",
|
|
2522
|
+
"sitemap-in-robots",
|
|
2523
|
+
"reader-blocked",
|
|
2524
|
+
"ai-search-crawler-access",
|
|
2525
|
+
"ai-training-crawler-access",
|
|
2526
|
+
"home-page-read",
|
|
2527
|
+
"home-truncated",
|
|
2528
|
+
"noindex-home-page",
|
|
2529
|
+
"text-in-html",
|
|
2530
|
+
"app-shell",
|
|
2531
|
+
"little-text",
|
|
2532
|
+
"json-ld-on-home-page",
|
|
2533
|
+
"json-ld-errors",
|
|
2534
|
+
"json-ld-bounds",
|
|
2535
|
+
"organization-schema",
|
|
2536
|
+
"organization-microdata-only",
|
|
2537
|
+
"organization-name-and-url",
|
|
2538
|
+
"organization-logo",
|
|
2539
|
+
"organization-same-as",
|
|
2540
|
+
"website-schema",
|
|
2541
|
+
"faq-schema",
|
|
2542
|
+
"llms-txt-found",
|
|
2543
|
+
"llms-txt-structure",
|
|
2544
|
+
"sitemap-valid",
|
|
2545
|
+
"sitemap-cut"
|
|
2546
|
+
]);
|
|
2547
|
+
function finding(id, level, message, snippet) {
|
|
2548
|
+
message = stripUnsafeCharacters(message);
|
|
2549
|
+
snippet = snippet === void 0 ? void 0 : stripUnsafeCharacters(snippet);
|
|
2550
|
+
const docUrl = `${DOC_BASE}${ANCHORS[id] ?? "method"}`;
|
|
2551
|
+
return Object.freeze(snippet === void 0 || snippet === "" ? {
|
|
2552
|
+
id,
|
|
2553
|
+
level,
|
|
2554
|
+
message,
|
|
2555
|
+
docUrl
|
|
2556
|
+
} : {
|
|
2557
|
+
id,
|
|
2558
|
+
level,
|
|
2559
|
+
message,
|
|
2560
|
+
docUrl,
|
|
2561
|
+
snippet: snippet.slice(0, 300)
|
|
2562
|
+
});
|
|
2563
|
+
}
|
|
2564
|
+
var HOME_NOT_READ = {
|
|
2565
|
+
not_allowed: ["info", `robots.txt keeps ${READER_TOKEN} from the home page, so it was not requested.`],
|
|
2566
|
+
forbidden: ["warn", "The home page answered 401 or 403 to this reader, so it was not read."],
|
|
2567
|
+
challenge: ["warn", "The home page answered with a bot check, so its content was not read."],
|
|
2568
|
+
placeholder: ["fail", "The home page is a placeholder (a parking page or a server default page)."],
|
|
2569
|
+
not_html: ["fail", "The home page did not answer with an HTML document."],
|
|
2570
|
+
error_status: ["fail", "The home page did not answer with a normal 200 page."],
|
|
2571
|
+
off_site: ["warn", "The home page redirects to another site, which was not followed."],
|
|
2572
|
+
redirect_error: ["fail", "The home page redirects in a loop or too many times."],
|
|
2573
|
+
network: ["warn", "The home page could not be reached."]
|
|
2574
|
+
};
|
|
2575
|
+
var SITEMAP_REASON = {
|
|
2576
|
+
html: "the address answers with an HTML page",
|
|
2577
|
+
empty: "the file is empty",
|
|
2578
|
+
doctype: "the XML has a DOCTYPE or ENTITY, which is not read",
|
|
2579
|
+
bad_root: "the XML does not start with urlset or sitemapindex",
|
|
2580
|
+
no_loc: "it lists no address",
|
|
2581
|
+
off_site: "none of the addresses it lists is on this site",
|
|
2582
|
+
not_closed: "the XML is not closed",
|
|
2583
|
+
text_lines: "the text is not a list of addresses",
|
|
2584
|
+
gzip_failed: "the gzip file cannot be inflated"
|
|
2585
|
+
};
|
|
2586
|
+
function pageFindings(page) {
|
|
2587
|
+
const out = [];
|
|
2588
|
+
if (page.noindex) out.push(finding("noindex-home-page", "fail", "The home page tells crawlers not to index it (noindex).", `noindex in ${page.noindexSource === "header" ? "the X-Robots-Tag header" : page.noindexSource === "both" ? "the robots meta tag and the X-Robots-Tag header" : "the robots meta tag"}`));
|
|
2589
|
+
else out.push(finding("noindex-home-page", "pass", "The home page does not say noindex."));
|
|
2590
|
+
const words = `${page.mainWords}${page.wordsCapped ? "+" : ""}`;
|
|
2591
|
+
if (page.content === "text_in_html") out.push(finding("text-in-html", "pass", `The page carries ${words} words of text in the HTML as served.`, `main text: ${words} words (${page.mainSource === "main" ? "main element" : "body without header, navigation and footer"})`));
|
|
2592
|
+
else if (page.content === "app_shell") out.push(finding("app-shell", "fail", `The page is served as an empty application shell: ${words} words of text, and the content needs scripts.`, page.appRoot ? "an application root element is present" : "the page asks for JavaScript"));
|
|
2593
|
+
else out.push(finding("little-text", "warn", `The page carries ${words} words of text in the HTML as served, under 150.`));
|
|
2594
|
+
const ld = page.jsonLd;
|
|
2595
|
+
if (ld.schemaOrgBlocks > 0) out.push(finding("json-ld-on-home-page", "pass", `The home page has ${ld.schemaOrgBlocks} JSON-LD block${ld.schemaOrgBlocks === 1 ? "" : "s"} naming schema.org.`, page.types.slice(0, 12).join(", ")));
|
|
2596
|
+
else out.push(finding("json-ld-on-home-page", "warn", "The home page has no JSON-LD structured data."));
|
|
2597
|
+
if (ld.errors > 0) out.push(finding("json-ld-errors", "warn", `${ld.errors} JSON-LD block${ld.errors === 1 ? "" : "s"} on the home page cannot be parsed as JSON.`));
|
|
2598
|
+
if (ld.bounds.length > 0) out.push(finding("json-ld-bounds", "info", "The structured data is larger than this reader reads; the rest was left out.", ld.bounds.join(", ")));
|
|
2599
|
+
if (page.organization === "json_ld" && page.primary !== null) {
|
|
2600
|
+
const primary = page.primary;
|
|
2601
|
+
out.push(finding("organization-schema", "pass", `The home page describes the organization in JSON-LD (${primary.type}).`, primary.name ?? void 0));
|
|
2602
|
+
out.push(primary.nameAndUrl ? finding("organization-name-and-url", "pass", "The Organization has a name and a url.", `${primary.name ?? ""} ${primary.url ?? ""}`.trim()) : finding("organization-name-and-url", "warn", "The Organization lacks a name or an absolute http(s) url."));
|
|
2603
|
+
out.push(primary.logo ? finding("organization-logo", "pass", "The Organization has a logo.") : finding("organization-logo", "info", "The Organization has no logo."));
|
|
2604
|
+
out.push(primary.sameAs.length > 0 ? finding("organization-same-as", "pass", `The Organization links ${primary.sameAs.length} profile${primary.sameAs.length === 1 ? "" : "s"} elsewhere with sameAs.`, primary.sameAsKinds.join(", ")) : finding("organization-same-as", "warn", "The Organization has no sameAs link to a profile on another site."));
|
|
2605
|
+
} else if (page.organization === "microdata") out.push(finding("organization-microdata-only", "warn", "The organization is described in microdata only; no JSON-LD Organization was found.", page.organizationType ?? void 0));
|
|
2606
|
+
else out.push(finding("organization-schema", "warn", "The home page does not describe the organization in structured data."));
|
|
2607
|
+
out.push(page.website ? finding("website-schema", "pass", page.searchAction ? "The home page has WebSite data with a SearchAction." : "The home page has WebSite data.") : finding("website-schema", "info", "The home page has no WebSite data."));
|
|
2608
|
+
if (page.faqPage) out.push(finding("faq-schema", "pass", "The home page has FAQPage data."));
|
|
2609
|
+
return out;
|
|
2610
|
+
}
|
|
2611
|
+
/** The findings for the facts of a check. */
|
|
2612
|
+
function buildFindings(site) {
|
|
2613
|
+
const out = [];
|
|
2614
|
+
const { robots } = site;
|
|
2615
|
+
if (robots.outcome === "file") {
|
|
2616
|
+
out.push(finding("robots-txt", "pass", `robots.txt found (${robots.bytes} bytes).`));
|
|
2617
|
+
out.push(robots.sitemaps.length > 0 ? finding("sitemap-in-robots", "pass", "robots.txt lists a sitemap.", robots.sitemaps[0]) : finding("sitemap-in-robots", "info", "robots.txt lists no sitemap."));
|
|
2618
|
+
} else if (robots.outcome === "missing") out.push(finding("robots-txt", "info", "There is no robots.txt; nothing is closed to crawlers.", robots.status === null ? void 0 : `status ${robots.status}`));
|
|
2619
|
+
else {
|
|
2620
|
+
out.push(finding("robots-txt", "warn", "robots.txt could not be read, so nothing else was requested.", robots.status === null ? "no answer" : `status ${robots.status}`));
|
|
2621
|
+
return out;
|
|
2622
|
+
}
|
|
2623
|
+
if (!robots.allowsThisReader) out.push(finding("reader-blocked", "info", `robots.txt keeps ${READER_TOKEN} from the home page.`));
|
|
2624
|
+
const searchAndUser = site.crawlerAccess.filter((entry) => entry.purpose !== "training");
|
|
2625
|
+
const closed = searchAndUser.filter((entry) => entry.access === "blocked" || !entry.root);
|
|
2626
|
+
const partial = searchAndUser.filter((entry) => entry.access === "partial" && entry.root);
|
|
2627
|
+
if (closed.length > 0) out.push(finding("ai-search-crawler-access", "fail", `robots.txt keeps ${closed.length} of ${searchAndUser.length} AI search and user-fetch crawlers from the home page.`, closed.map((entry) => entry.token).join(", ")));
|
|
2628
|
+
else if (partial.length > 0) out.push(finding("ai-search-crawler-access", "warn", `robots.txt closes parts of the site to ${partial.length} of ${searchAndUser.length} AI search and user-fetch crawlers.`, partial.map((entry) => entry.token).join(", ")));
|
|
2629
|
+
else out.push(finding("ai-search-crawler-access", "pass", `All ${searchAndUser.length} AI search and user-fetch crawlers on the list can read the home page.`));
|
|
2630
|
+
const training = site.crawlerAccess.filter((entry) => entry.purpose === "training");
|
|
2631
|
+
const trainingClosed = training.filter((entry) => entry.access === "blocked");
|
|
2632
|
+
out.push(finding("ai-training-crawler-access", "info", trainingClosed.length === 0 ? "No AI training crawler on the list is blocked. Blocking them is the site's choice." : `${trainingClosed.length} of ${training.length} AI training crawlers are blocked. Blocking them is the site's choice.`, trainingClosed.map((entry) => entry.token).join(", ")));
|
|
2633
|
+
const home = site.home;
|
|
2634
|
+
if (home !== null) {
|
|
2635
|
+
if (home.outcome === "read" && home.facts !== null) {
|
|
2636
|
+
out.push(finding("home-page-read", "pass", "The home page was read.", `${home.status ?? ""} ${home.finalUrl}`.trim()));
|
|
2637
|
+
if (home.truncated || home.facts.scanTruncated) out.push(finding("home-truncated", "info", "The home page is larger than 2 MiB; it was read as far as the limit."));
|
|
2638
|
+
out.push(...pageFindings(home.facts));
|
|
2639
|
+
} else if (home.outcome !== "read") {
|
|
2640
|
+
const [level, message] = HOME_NOT_READ[home.outcome];
|
|
2641
|
+
out.push(finding("home-page-read", level, message, home.status === null ? void 0 : `status ${home.status}`));
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
const llms = site.llms;
|
|
2645
|
+
if (!llms.asked) out.push(finding("llms-txt-found", "info", `robots.txt keeps ${READER_TOKEN} from /llms.txt, so it was not requested.`));
|
|
2646
|
+
else if (llms.present) {
|
|
2647
|
+
out.push(finding("llms-txt-found", "pass", "An llms.txt is present.", llms.h1 ?? void 0));
|
|
2648
|
+
const missing = [];
|
|
2649
|
+
if (llms.sections === 0) missing.push("a section of links");
|
|
2650
|
+
out.push(missing.length === 0 ? finding("llms-txt-structure", "pass", `The llms.txt has ${llms.sections} section${llms.sections === 1 ? "" : "s"} and ${llms.links} link${llms.links === 1 ? "" : "s"}.`) : finding("llms-txt-structure", "warn", `The llms.txt lacks ${missing.join(" and ")}.`));
|
|
2651
|
+
} else out.push(finding("llms-txt-found", "info", "No llms.txt was found.", llms.verdict === null ? void 0 : llms.verdict));
|
|
2652
|
+
const sitemap = site.sitemap;
|
|
2653
|
+
if (!sitemap.asked) out.push(finding("sitemap-valid", "info", `robots.txt keeps ${READER_TOKEN} from the sitemap, so it was not requested.`));
|
|
2654
|
+
else if (sitemap.facts !== null && sitemap.facts.outcome === "valid") {
|
|
2655
|
+
const facts = sitemap.facts;
|
|
2656
|
+
out.push(finding("sitemap-valid", "pass", `A valid sitemap${facts.kind === "index" ? " index" : ""} lists ${facts.locs} ${facts.kind === "index" ? "sitemap" : "page"}${facts.locs === 1 ? "" : "s"}.`, sitemap.url ?? void 0));
|
|
2657
|
+
if (facts.truncated) out.push(finding("sitemap-cut", "info", "The sitemap is larger than 5 MiB; it is valid as far as it was read."));
|
|
2658
|
+
} else {
|
|
2659
|
+
const reason = sitemap.facts?.reason;
|
|
2660
|
+
out.push(finding("sitemap-valid", "warn", reason === void 0 || reason === null ? "No valid sitemap was found." : `No valid sitemap: ${SITEMAP_REASON[reason]}.`, `${sitemap.url ?? ""}${sitemap.status === null ? "" : ` (status ${sitemap.status})`}`.trim()));
|
|
2661
|
+
}
|
|
2662
|
+
return out;
|
|
2663
|
+
}
|
|
2664
|
+
// src/facts/check-site.ts
|
|
2665
|
+
/**
|
|
2666
|
+
* `checkSite`: reads a site's public files over the guarded fetcher and returns the facts and
|
|
2667
|
+
* stable findings. Requests, in order, each only if robots.txt allows this reader (its own
|
|
2668
|
+
* token) to make it:
|
|
2669
|
+
*
|
|
2670
|
+
* 1. `/robots.txt`
|
|
2671
|
+
* 2. the home page, `/`
|
|
2672
|
+
* 3. `/llms.txt`
|
|
2673
|
+
* 4. the sitemap: the first `Sitemap:` line on the same registrable domain, else `/sitemap.xml`
|
|
2674
|
+
*
|
|
2675
|
+
* A robots.txt that cannot be read ends the check: nothing else is asked. No score is given:
|
|
2676
|
+
* the result is facts and findings with evidence.
|
|
2677
|
+
*/
|
|
2678
|
+
var EMPTY_ROBOTS = parseRobots("");
|
|
2679
|
+
function sameSite(registrable, siteDomain) {
|
|
2680
|
+
return (target) => registrable(target.hostname) === siteDomain;
|
|
2681
|
+
}
|
|
2682
|
+
function robotsRead(result, url) {
|
|
2683
|
+
const base = {
|
|
2684
|
+
url,
|
|
2685
|
+
status: result.status,
|
|
2686
|
+
bytes: result.bytes,
|
|
2687
|
+
truncated: result.truncated
|
|
2688
|
+
};
|
|
2689
|
+
const notRead = () => ({
|
|
2690
|
+
read: Object.freeze({
|
|
2691
|
+
...base,
|
|
2692
|
+
outcome: "not_read",
|
|
2693
|
+
sitemaps: Object.freeze([]),
|
|
2694
|
+
allowsThisReader: false
|
|
2695
|
+
}),
|
|
2696
|
+
robots: null
|
|
2697
|
+
});
|
|
2698
|
+
if (result.outcome !== "response" || result.status === null) return notRead();
|
|
2699
|
+
const status = result.status;
|
|
2700
|
+
if (status >= 200 && status < 300) {
|
|
2701
|
+
if (result.text === null || isChallenge(status, result.text)) return notRead();
|
|
2702
|
+
const robots = parseRobots(result.text, url);
|
|
2703
|
+
return {
|
|
2704
|
+
read: Object.freeze({
|
|
2705
|
+
...base,
|
|
2706
|
+
outcome: "file",
|
|
2707
|
+
sitemaps: Object.freeze([...robots.sitemaps]),
|
|
2708
|
+
allowsThisReader: isAllowedByRobots(robots, READER_TOKEN, "/")
|
|
2709
|
+
}),
|
|
2710
|
+
robots
|
|
2711
|
+
};
|
|
2712
|
+
}
|
|
2713
|
+
if (status >= 400 && status < 500 && status !== 401 && status !== 403 && status !== 429) return {
|
|
2714
|
+
read: Object.freeze({
|
|
2715
|
+
...base,
|
|
2716
|
+
outcome: "missing",
|
|
2717
|
+
sitemaps: Object.freeze([]),
|
|
2718
|
+
allowsThisReader: true
|
|
2719
|
+
}),
|
|
2720
|
+
robots: EMPTY_ROBOTS
|
|
2721
|
+
};
|
|
2722
|
+
return notRead();
|
|
2723
|
+
}
|
|
2724
|
+
var NOT_ASKED_LLMS = Object.freeze({
|
|
2725
|
+
asked: false,
|
|
2726
|
+
present: false,
|
|
2727
|
+
verdict: null,
|
|
2728
|
+
bytes: 0,
|
|
2729
|
+
truncated: false,
|
|
2730
|
+
h1: null,
|
|
2731
|
+
sections: 0,
|
|
2732
|
+
links: 0
|
|
2733
|
+
});
|
|
2734
|
+
var NOT_ASKED_SITEMAP = Object.freeze({
|
|
2735
|
+
asked: false,
|
|
2736
|
+
source: null,
|
|
2737
|
+
url: null,
|
|
2738
|
+
status: null,
|
|
2739
|
+
facts: null
|
|
2740
|
+
});
|
|
2741
|
+
/** The first `Sitemap:` address on the site's registrable domain; null without one. */
|
|
2742
|
+
function sitemapFromRobots(lines, registrable, siteDomain) {
|
|
2743
|
+
for (const line of lines) try {
|
|
2744
|
+
const url = new URL(line);
|
|
2745
|
+
if ((url.protocol === "http:" || url.protocol === "https:") && registrable(url.hostname) === siteDomain) return url.href;
|
|
2746
|
+
} catch {}
|
|
2747
|
+
return null;
|
|
2748
|
+
}
|
|
2749
|
+
function refused(origin, host, why) {
|
|
2750
|
+
const finding = Object.freeze({
|
|
2751
|
+
id: "site-refused",
|
|
2752
|
+
level: "fail",
|
|
2753
|
+
message: "The address cannot be checked: it must be a web site name or an http(s) address.",
|
|
2754
|
+
docUrl: "https://queryrev.com/research/ai-search-readiness#method",
|
|
2755
|
+
snippet: why
|
|
2756
|
+
});
|
|
2757
|
+
return Object.freeze({
|
|
2758
|
+
schemaVersion: 1,
|
|
2759
|
+
origin,
|
|
2760
|
+
host,
|
|
2761
|
+
crawlersCovered: crawlersCoveredLine(),
|
|
2762
|
+
robots: Object.freeze({
|
|
2763
|
+
outcome: "not_read",
|
|
2764
|
+
url: "",
|
|
2765
|
+
status: null,
|
|
2766
|
+
bytes: 0,
|
|
2767
|
+
truncated: false,
|
|
2768
|
+
sitemaps: Object.freeze([]),
|
|
2769
|
+
allowsThisReader: false
|
|
2770
|
+
}),
|
|
2771
|
+
crawlerAccess: Object.freeze([]),
|
|
2772
|
+
home: null,
|
|
2773
|
+
llms: NOT_ASKED_LLMS,
|
|
2774
|
+
sitemap: NOT_ASKED_SITEMAP,
|
|
2775
|
+
findings: Object.freeze([finding])
|
|
2776
|
+
});
|
|
2777
|
+
}
|
|
2778
|
+
/**
|
|
2779
|
+
* Checks a site given as a host name (`example.com`) or an http(s) address (only its scheme and
|
|
2780
|
+
* host are used). Never throws for what a site does or for an address that cannot be checked:
|
|
2781
|
+
* the result says what was read and what was not.
|
|
2782
|
+
*/
|
|
2783
|
+
async function checkSite(input, options = {}) {
|
|
2784
|
+
const registrable = options.registrableDomain ?? registrableDomain;
|
|
2785
|
+
const typed = input.trim();
|
|
2786
|
+
const checked = checkTextUrl(/^[a-z][a-z0-9+.-]*:\/\//i.test(typed) ? typed : `https://${typed}`);
|
|
2787
|
+
if ("refusal" in checked) return refused("", "", checked.refusal);
|
|
2788
|
+
const origin = checked.url.origin;
|
|
2789
|
+
const host = checked.url.hostname;
|
|
2790
|
+
const siteDomain = registrable(host);
|
|
2791
|
+
const owned = options.fetcher === void 0;
|
|
2792
|
+
const fetcher = options.fetcher ?? createTextFetcher();
|
|
2793
|
+
const budget = AbortSignal.timeout(CHECK_BUDGET_MS);
|
|
2794
|
+
const signal = options.signal === void 0 ? budget : AbortSignal.any([options.signal, budget]);
|
|
2795
|
+
const redirectAllowed = sameSite(registrable, siteDomain);
|
|
2796
|
+
try {
|
|
2797
|
+
const robotsUrl = `${origin}/robots.txt`;
|
|
2798
|
+
const { read: robots, robots: parsed } = robotsRead(await fetcher.fetchText(robotsUrl, {
|
|
2799
|
+
kind: "robots",
|
|
2800
|
+
signal,
|
|
2801
|
+
redirectAllowed
|
|
2802
|
+
}), robotsUrl);
|
|
2803
|
+
let home = null;
|
|
2804
|
+
let llms = NOT_ASKED_LLMS;
|
|
2805
|
+
let sitemap = NOT_ASKED_SITEMAP;
|
|
2806
|
+
const access = parsed === null ? [] : crawlerAccess(parsed).map((entry) => Object.freeze({
|
|
2807
|
+
token: entry.crawler.token,
|
|
2808
|
+
purpose: entry.crawler.purpose,
|
|
2809
|
+
access: entry.access,
|
|
2810
|
+
root: entry.root.allowed
|
|
2811
|
+
}));
|
|
2812
|
+
if (parsed !== null) {
|
|
2813
|
+
fetcher.setCrawlDelay(host, crawlDelayFor(parsed, READER_TOKEN));
|
|
2814
|
+
const allowed = (path) => isAllowedByRobots(parsed, READER_TOKEN, path);
|
|
2815
|
+
if (allowed("/")) home = readHomeResponse(await fetcher.fetchText(`${origin}/`, {
|
|
2816
|
+
kind: "page",
|
|
2817
|
+
signal,
|
|
2818
|
+
redirectAllowed
|
|
2819
|
+
}), { registrableDomain: registrable });
|
|
2820
|
+
else home = Object.freeze({
|
|
2821
|
+
outcome: "not_allowed",
|
|
2822
|
+
requestedUrl: `${origin}/`,
|
|
2823
|
+
finalUrl: `${origin}/`,
|
|
2824
|
+
status: null,
|
|
2825
|
+
redirects: 0,
|
|
2826
|
+
truncated: false,
|
|
2827
|
+
facts: null
|
|
2828
|
+
});
|
|
2829
|
+
if (allowed("/llms.txt")) {
|
|
2830
|
+
const result = await fetcher.fetchText(`${origin}/llms.txt`, {
|
|
2831
|
+
kind: "llms",
|
|
2832
|
+
signal,
|
|
2833
|
+
redirectAllowed
|
|
2834
|
+
});
|
|
2835
|
+
const report = validateLlmsTxt({
|
|
2836
|
+
status: result.outcome === "response" ? result.status : null,
|
|
2837
|
+
contentType: result.contentType,
|
|
2838
|
+
body: result.text,
|
|
2839
|
+
truncated: result.truncated
|
|
2840
|
+
});
|
|
2841
|
+
llms = Object.freeze({
|
|
2842
|
+
asked: true,
|
|
2843
|
+
present: report.present,
|
|
2844
|
+
verdict: report.verdict,
|
|
2845
|
+
bytes: report.bytes,
|
|
2846
|
+
truncated: report.truncated,
|
|
2847
|
+
h1: report.h1,
|
|
2848
|
+
sections: report.structure?.sections ?? 0,
|
|
2849
|
+
links: report.structure?.links ?? 0
|
|
2850
|
+
});
|
|
2851
|
+
}
|
|
2852
|
+
const fromRobots = sitemapFromRobots(parsed.sitemaps, registrable, siteDomain);
|
|
2853
|
+
const sitemapUrl = fromRobots ?? `${origin}/sitemap.xml`;
|
|
2854
|
+
let path = "/sitemap.xml";
|
|
2855
|
+
try {
|
|
2856
|
+
const url = new URL(sitemapUrl);
|
|
2857
|
+
path = `${url.pathname}${url.search}`;
|
|
2858
|
+
} catch {}
|
|
2859
|
+
if (isAllowedByRobots(parsed, "QueryRev-AIReadiness", path)) {
|
|
2860
|
+
const result = await fetcher.fetchText(sitemapUrl, {
|
|
2861
|
+
kind: "sitemap",
|
|
2862
|
+
signal,
|
|
2863
|
+
redirectAllowed
|
|
2864
|
+
});
|
|
2865
|
+
const ok = result.outcome === "response" && result.status !== null && result.status >= 200 && result.status < 300 && result.rawBody !== null;
|
|
2866
|
+
sitemap = Object.freeze({
|
|
2867
|
+
asked: true,
|
|
2868
|
+
source: fromRobots === null ? "well_known" : "robots",
|
|
2869
|
+
url: sitemapUrl,
|
|
2870
|
+
status: result.status,
|
|
2871
|
+
facts: ok ? await readSitemap(result.rawBody, result.contentType, origin, {
|
|
2872
|
+
truncated: result.truncated,
|
|
2873
|
+
registrableDomain: registrable
|
|
2874
|
+
}) : null
|
|
2875
|
+
});
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
const site = {
|
|
2879
|
+
schemaVersion: 1,
|
|
2880
|
+
origin,
|
|
2881
|
+
host,
|
|
2882
|
+
crawlersCovered: crawlersCoveredLine(),
|
|
2883
|
+
robots,
|
|
2884
|
+
crawlerAccess: Object.freeze(access),
|
|
2885
|
+
home,
|
|
2886
|
+
llms,
|
|
2887
|
+
sitemap
|
|
2888
|
+
};
|
|
2889
|
+
return Object.freeze({
|
|
2890
|
+
...site,
|
|
2891
|
+
findings: Object.freeze(buildFindings(site))
|
|
2892
|
+
});
|
|
2893
|
+
} finally {
|
|
2894
|
+
if (owned) await fetcher.close();
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
// src/llms/findings.ts
|
|
2898
|
+
/**
|
|
2899
|
+
* The findings of an llms.txt validation, drawn from a `LlmsReport`. Used by the command line
|
|
2900
|
+
* and by any tool that validates the file: each finding has a stable id, a level and one plain
|
|
2901
|
+
* sentence, and nothing is scored.
|
|
2902
|
+
*/
|
|
2903
|
+
var DOC_URL = "https://queryrev.com/research/ai-search-readiness#llms-txt-found";
|
|
2904
|
+
/** The finding ids this module can return. */
|
|
2905
|
+
var LLMS_FINDING_IDS = Object.freeze([
|
|
2906
|
+
"llms-txt-found",
|
|
2907
|
+
"llms-txt-h1",
|
|
2908
|
+
"llms-txt-summary",
|
|
2909
|
+
"llms-txt-sections",
|
|
2910
|
+
"llms-txt-cut"
|
|
2911
|
+
]);
|
|
2912
|
+
function llmsFinding(id, level, message, snippet) {
|
|
2913
|
+
const text = stripUnsafeCharacters(snippet ?? "").slice(0, 300);
|
|
2914
|
+
return Object.freeze(text === "" ? {
|
|
2915
|
+
id,
|
|
2916
|
+
level,
|
|
2917
|
+
message: stripUnsafeCharacters(message),
|
|
2918
|
+
docUrl: DOC_URL
|
|
2919
|
+
} : {
|
|
2920
|
+
id,
|
|
2921
|
+
level,
|
|
2922
|
+
message: stripUnsafeCharacters(message),
|
|
2923
|
+
docUrl: DOC_URL,
|
|
2924
|
+
snippet: text
|
|
2925
|
+
});
|
|
2926
|
+
}
|
|
2927
|
+
var NOT_AN_LLMS = {
|
|
2928
|
+
no_answer: "No answer arrived, so there is no llms.txt to validate.",
|
|
2929
|
+
not_found: "There is no llms.txt: the address answers 404 or 410.",
|
|
2930
|
+
status: "The address does not answer 200, so it is not an llms.txt.",
|
|
2931
|
+
not_text: "The address answers with a type that is not text, so it is not an llms.txt.",
|
|
2932
|
+
html: "The address answers 200 with an HTML page, which is not an llms.txt (a soft 404).",
|
|
2933
|
+
robots: "The address answers 200 with a robots.txt, which is not an llms.txt.",
|
|
2934
|
+
no_h1: "The text does not start with a Markdown H1 (# Name), which llmstxt.org requires."
|
|
2935
|
+
};
|
|
2936
|
+
/** The findings for one validated answer. */
|
|
2937
|
+
function llmsFindings(report) {
|
|
2938
|
+
if (report.verdict !== "llms_txt" || report.structure === null) {
|
|
2939
|
+
const message = NOT_AN_LLMS[report.verdict];
|
|
2940
|
+
return [llmsFinding("llms-txt-found", "fail", message ?? "The answer is not an llms.txt.", report.status === null ? void 0 : `status ${report.status}`)];
|
|
2941
|
+
}
|
|
2942
|
+
const structure = report.structure;
|
|
2943
|
+
const out = [llmsFinding("llms-txt-found", "pass", "The file is an llms.txt.", report.h1 ?? void 0)];
|
|
2944
|
+
out.push(report.h1Count === 1 ? llmsFinding("llms-txt-h1", "pass", "The file has one H1, as llmstxt.org asks.") : llmsFinding("llms-txt-h1", "warn", `The file has ${report.h1Count} H1 headings; llmstxt.org asks for exactly one.`));
|
|
2945
|
+
out.push(structure.summary ? llmsFinding("llms-txt-summary", "pass", "The file has a summary blockquote.") : llmsFinding("llms-txt-summary", "warn", "The file has no summary blockquote (a line starting with \">\") before its first section."));
|
|
2946
|
+
out.push(structure.sections > 0 ? llmsFinding("llms-txt-sections", "pass", `The file has ${structure.sections} H2 section${structure.sections === 1 ? "" : "s"} of links and ${structure.links} link${structure.links === 1 ? "" : "s"} in all.`) : llmsFinding("llms-txt-sections", "warn", "The file has no H2 section that lists links."));
|
|
2947
|
+
if (report.truncated) out.push(llmsFinding("llms-txt-cut", "info", "The file is larger than 1 MiB; it was read as far as that limit."));
|
|
2948
|
+
return out;
|
|
2949
|
+
}
|
|
2950
|
+
export { looksLikeHtml as $, MAX_LOC_TAGS_SCANNED as A, CHALLENGE_MAX_WORDS as B, MAX_LD_DEPTH as C, EMPTY_SHELL_MIN_BYTES as Ct, MAX_LD_BLOCK_CHARS as D, MAX_LD_BLOCKS as E, sitemapLocations as F, isChallenge as G, INTERSTITIAL_MARKS as H, stripUnsafeCharacters as I, decodeHtml as J, isHomeChallenge as K, MAX_WORDS_COUNTED as L, attributes as M, pageMeta as N, MAX_LD_TOTAL_CHARS as O, renderLlmsTxt as P, llmsStructure as Q, countWords as R, SCHEMA_ORG_VERSION as S, EMPTY_SHELL_MAX_WORDS as St, HOME_SCAN_MAX_CHARS as T, isAppRoot as Tt, PLACEHOLDER_MARKS as U, HTML_MARKUP as V, PLACEHOLDER_MAX_WORDS as W, registrableDomain as X, detectCharset as Y, LLMS_H1_MAX as Z, PROFILE_HOSTS as _, parseRobots as _t, robotsRead as a, CRAWLER_PURPOSES as at, ORGANIZATION_TYPES as b, APP_ROOT_SELECTOR as bt, MAX_LOCS_COUNTED as c, crawlerAccess as ct, readSitemapText as d, ROBOTS_MAX_AGE_MS as dt, looksLikeLlmsTxt as et, READER_TOKEN as f, ROBOTS_MAX_BYTES as ft, xRobotsNoindex as g, matchRobots as gt, readHomePage as h, isAllowedByRobots as ht, checkSite as i, CRAWLERS as it, PAGE_SCAN_MAX_CHARS as j, MAIN_SECTION as k, SITEMAP_MAX_BYTES as l, crawlersCoveredLine as lt, TEXT_IN_HTML_MIN_WORDS as m, crawlDelayFor as mt, llmsFinding as n, validateLlmsTxt as nt, FINDING_IDS as o, STUDY_AGENTS as ot, readHomeResponse as p, ROBOTS_MAX_REDIRECTS as pt, placeholderMark as q, llmsFindings as r, AI_AGENTS as rt, buildFindings as s, agentVerdict as st, LLMS_FINDING_IDS as t, looksLikeRobotsTxt as tt, readSitemap as u, verdictsFor as ut, PROFILE_KINDS as v, APP_ROOT_ATTRIBUTES as vt, MAX_LD_NODES as w, JS_NOTICE as wt, SCHEMA_ORG_SHA256 as x, APP_ROOT_TAG as xt, profileKind as y, APP_ROOT_IDS as yt, CHALLENGE_MARKS as z };
|