@zeldrisho/pi-web-fetch 0.9.1 → 0.9.3
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/CHANGELOG.md +20 -1
- package/README.md +2 -4
- package/package.json +1 -1
- package/src/extract.ts +4 -215
- package/src/fetch-metadata.ts +226 -0
- package/src/fetch.ts +22 -141
- package/src/fragment-index.ts +99 -0
- package/src/fragments.ts +93 -0
- package/src/index.ts +3 -14
- package/src/llms.ts +294 -0
- package/src/markdown-cleanup.ts +114 -0
- package/src/service.ts +19 -315
package/src/llms.ts
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { ExpiringLruCache } from "./cache";
|
|
2
|
+
import type { CompleteDocument } from "./content";
|
|
3
|
+
import { fetchCompleteDocument, type FetchRemoteDependencies } from "./fetch";
|
|
4
|
+
import { InflightCoalescer } from "./inflight";
|
|
5
|
+
|
|
6
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1_000;
|
|
7
|
+
|
|
8
|
+
const LLMS_TXT_MIN_MARKDOWN_CHARACTERS = 200;
|
|
9
|
+
|
|
10
|
+
const encoder = new TextEncoder();
|
|
11
|
+
|
|
12
|
+
function safeUrl(value: string): URL | undefined {
|
|
13
|
+
try {
|
|
14
|
+
return new URL(value);
|
|
15
|
+
} catch {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Builds candidate `/llms.txt` URLs for a page: the site-root index plus each ancestor
|
|
22
|
+
* directory index up to `MAX_LLMS_TXT_DIRECTORY_DEPTH` levels deep, mirroring sites that
|
|
23
|
+
* publish per-section indexes (for example `developers.cloudflare.com/r2/llms.txt`).
|
|
24
|
+
* Returns them ordered shallow → deep, and an empty list for non-HTTP(S) targets or when
|
|
25
|
+
* the page itself is already a `llms.txt` path, so a fallback never retries itself.
|
|
26
|
+
*/
|
|
27
|
+
function isGitHubLikeHost(hostname: string): boolean {
|
|
28
|
+
return (
|
|
29
|
+
hostname === "github.com" ||
|
|
30
|
+
hostname === "raw.githubusercontent.com" ||
|
|
31
|
+
hostname === "gist.github.com" ||
|
|
32
|
+
hostname === "gist.githubusercontent.com"
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build candidate /llms.txt URLs to probe for a given page.
|
|
38
|
+
*
|
|
39
|
+
* Generates a list of potential /llms.txt index URLs by checking the site root
|
|
40
|
+
* and ancestor directories up to a maximum depth. Returns URLs ordered from
|
|
41
|
+
* shallowest to deepest.
|
|
42
|
+
*
|
|
43
|
+
* @param rawUrl - The original page URL
|
|
44
|
+
* @returns Array of candidate /llms.txt URLs to probe (empty for non-HTTP/S or GitHub)
|
|
45
|
+
*/
|
|
46
|
+
export function buildLlmsTxtCandidateUrls(rawUrl: string): URL[] {
|
|
47
|
+
try {
|
|
48
|
+
const url = new URL(rawUrl);
|
|
49
|
+
|
|
50
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return [];
|
|
51
|
+
|
|
52
|
+
if (isGitHubLikeHost(url.hostname)) return [];
|
|
53
|
+
|
|
54
|
+
if (url.pathname === "/llms.txt" || url.pathname.endsWith("/llms.txt")) return [];
|
|
55
|
+
|
|
56
|
+
// Ancestor directories only, one level deep: for `/r2/buckets/x` probe
|
|
57
|
+
// `/llms.txt` and `/r2/llms.txt`, never deeper. A trailing slash marks the final
|
|
58
|
+
// segment as a directory itself, so `/r2/` still probes `/r2/llms.txt`.
|
|
59
|
+
const trimmedDirectory = url.pathname.endsWith("/")
|
|
60
|
+
? url.pathname
|
|
61
|
+
: url.pathname.replace(/\/[^/]*$/, "/");
|
|
62
|
+
|
|
63
|
+
const directories = ["/"];
|
|
64
|
+
const parts = trimmedDirectory.split("/").filter(Boolean);
|
|
65
|
+
|
|
66
|
+
for (let depth = 1; depth <= Math.min(MAX_LLMS_TXT_DIRECTORY_DEPTH, parts.length); depth += 1) {
|
|
67
|
+
directories.push(`/${parts.slice(0, depth).join("/")}/`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return directories.map((pathname) => {
|
|
71
|
+
const candidate = new URL(url.origin);
|
|
72
|
+
candidate.pathname = `${pathname}llms.txt`;
|
|
73
|
+
|
|
74
|
+
return candidate;
|
|
75
|
+
});
|
|
76
|
+
} catch {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isLowQualityDocument(document: CompleteDocument): boolean {
|
|
82
|
+
// Sparse extraction alone is not proof of an empty app shell: server-rendered pages
|
|
83
|
+
// may include useful content in streaming/framework markup that article extraction
|
|
84
|
+
// does not fully recognize. Keep that page rather than replacing it with a site index.
|
|
85
|
+
const diagnostics = document.extractionDiagnostics;
|
|
86
|
+
|
|
87
|
+
return Boolean(
|
|
88
|
+
diagnostics?.javascriptRequired || diagnostics?.botWall || diagnostics?.consentInterstitial,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A probed `/llms.txt` outcome for one candidate URL; an absent document means "unavailable". */
|
|
93
|
+
interface LlmsTxtProbe {
|
|
94
|
+
document?: CompleteDocument;
|
|
95
|
+
expires: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const MAX_LLMS_TXT_PROBE_ENTRIES = 512;
|
|
99
|
+
|
|
100
|
+
/** Deepest ancestor-directory `/llms.txt` probed, e.g. `/r2/x/y` probes `/r2/llms.txt`. */
|
|
101
|
+
const MAX_LLMS_TXT_DIRECTORY_DEPTH = 1;
|
|
102
|
+
|
|
103
|
+
const llmsTxtProbes = new ExpiringLruCache<string, LlmsTxtProbe>(
|
|
104
|
+
MAX_LLMS_TXT_PROBE_ENTRIES,
|
|
105
|
+
8 * 1024 * 1024,
|
|
106
|
+
(probe) => encoder.encode(JSON.stringify(probe)).byteLength,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const llmsTxtProbeRequests = new InflightCoalescer<string, LlmsTxtProbe>(
|
|
110
|
+
MAX_LLMS_TXT_PROBE_ENTRIES,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
function rememberLlmsTxtProbe(origin: string, probe: LlmsTxtProbe): void {
|
|
114
|
+
llmsTxtProbes.set(origin, probe, probe.expires);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isUsableLlmsTxtIndex(document: CompleteDocument): boolean {
|
|
118
|
+
return (
|
|
119
|
+
document.extractor === "raw" &&
|
|
120
|
+
!document.shellSuspected &&
|
|
121
|
+
document.markdown.length >= LLMS_TXT_MIN_MARKDOWN_CHARACTERS
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function toAbsoluteUrl(value: string): URL | undefined {
|
|
126
|
+
try {
|
|
127
|
+
return new URL(value);
|
|
128
|
+
} catch {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Returns the deepest usable `/llms.txt` index among the candidates, probing each
|
|
135
|
+
* uncached candidate at most once per TTL window (negative results are cached too).
|
|
136
|
+
* Any failure is treated as absence so a missing index can never degrade the primary
|
|
137
|
+
* fetch.
|
|
138
|
+
*/
|
|
139
|
+
async function ensureLlmsTxtIndex(
|
|
140
|
+
candidates: URL[],
|
|
141
|
+
signal: AbortSignal | undefined,
|
|
142
|
+
dependencies: FetchRemoteDependencies,
|
|
143
|
+
): Promise<{ url: string; document: CompleteDocument } | undefined> {
|
|
144
|
+
const documents = await Promise.all(
|
|
145
|
+
candidates.map((candidate) => probeUsableRawText(candidate, signal, dependencies)),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// Candidates are ordered shallow → deep; prefer the deepest usable index because a
|
|
149
|
+
// section index is the more relevant table of contents for the requested page.
|
|
150
|
+
for (let depth = documents.length - 1; depth >= 0; depth -= 1) {
|
|
151
|
+
const document = documents[depth];
|
|
152
|
+
|
|
153
|
+
if (document) return { url: candidates[depth].href, document };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Probes one URL at most once per TTL window (negative results are cached too) and
|
|
161
|
+
* returns its document only when it is usable raw text. Used for `/llms.txt` indexes
|
|
162
|
+
* and for advertised Markdown versions of a page. Any failure is treated as absence so
|
|
163
|
+
* a missing resource can never degrade the primary fetch.
|
|
164
|
+
*/
|
|
165
|
+
async function probeUsableRawText(
|
|
166
|
+
candidate: URL,
|
|
167
|
+
signal: AbortSignal | undefined,
|
|
168
|
+
dependencies: FetchRemoteDependencies,
|
|
169
|
+
): Promise<CompleteDocument | undefined> {
|
|
170
|
+
const cached = llmsTxtProbes.get(candidate.href);
|
|
171
|
+
|
|
172
|
+
if (cached) return cached.document;
|
|
173
|
+
|
|
174
|
+
const probe = await llmsTxtProbeRequests.run(
|
|
175
|
+
candidate.href,
|
|
176
|
+
async (sharedSignal) => {
|
|
177
|
+
const existing = llmsTxtProbes.get(candidate.href);
|
|
178
|
+
|
|
179
|
+
if (existing) return existing;
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const document = await fetchCompleteDocument(
|
|
183
|
+
candidate.toString(),
|
|
184
|
+
sharedSignal,
|
|
185
|
+
dependencies,
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const available = isUsableLlmsTxtIndex(document) ? document : undefined;
|
|
189
|
+
const result = { document: available, expires: Date.now() + CACHE_TTL_MS };
|
|
190
|
+
rememberLlmsTxtProbe(candidate.href, result);
|
|
191
|
+
|
|
192
|
+
return result;
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (sharedSignal?.aborted) throw error;
|
|
195
|
+
const result = { expires: Date.now() + CACHE_TTL_MS };
|
|
196
|
+
rememberLlmsTxtProbe(candidate.href, result);
|
|
197
|
+
|
|
198
|
+
return result;
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
signal,
|
|
202
|
+
"web_fetch was cancelled.",
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
return probe.document;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Validates that a candidate URL is safe to probe: must use HTTP(S) and match the primary
|
|
210
|
+
* page's origin. This prevents SSRF-like issues where a fetched page could embed meta tags
|
|
211
|
+
* pointing to arbitrary/internal/cross-origin URLs.
|
|
212
|
+
*/
|
|
213
|
+
function isSafeToProbe(candidate: URL | undefined, primaryUrl: string): boolean {
|
|
214
|
+
if (!candidate) return false;
|
|
215
|
+
const protocol = candidate.protocol;
|
|
216
|
+
|
|
217
|
+
if (protocol !== "http:" && protocol !== "https:") return false;
|
|
218
|
+
const primaryParsed = safeUrl(primaryUrl);
|
|
219
|
+
|
|
220
|
+
if (!primaryParsed) return false;
|
|
221
|
+
|
|
222
|
+
return candidate.origin === primaryParsed.origin;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Fetches a page with llms.txt awareness:
|
|
227
|
+
*
|
|
228
|
+
* - Probes the site's `/llms.txt` index once per origin per TTL window, in parallel with
|
|
229
|
+
* the primary fetch, so later calls can advertise it.
|
|
230
|
+
* - Serves the index instead of the page when the page looks like an app shell or carries
|
|
231
|
+
* too little readable text.
|
|
232
|
+
* - Otherwise annotates the page with the index URL so agents can discover sibling pages.
|
|
233
|
+
*
|
|
234
|
+
* A missing or useless index can never degrade the primary fetch.
|
|
235
|
+
*/
|
|
236
|
+
export async function fetchDocumentWithLlmsTxtSupport(
|
|
237
|
+
rawUrl: string,
|
|
238
|
+
signal: AbortSignal | undefined,
|
|
239
|
+
dependencies: FetchRemoteDependencies,
|
|
240
|
+
): Promise<CompleteDocument> {
|
|
241
|
+
try {
|
|
242
|
+
if (isGitHubLikeHost(new URL(rawUrl).hostname)) {
|
|
243
|
+
return fetchCompleteDocument(rawUrl, signal, dependencies);
|
|
244
|
+
}
|
|
245
|
+
} catch {
|
|
246
|
+
// Invalid URL falls through to fetchCompleteDocument which will throw.
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const candidates = buildLlmsTxtCandidateUrls(rawUrl);
|
|
250
|
+
|
|
251
|
+
const [primary, blindIndex] = await Promise.all([
|
|
252
|
+
fetchCompleteDocument(rawUrl, signal, dependencies),
|
|
253
|
+
candidates.length > 0
|
|
254
|
+
? ensureLlmsTxtIndex(candidates, signal, dependencies)
|
|
255
|
+
: Promise.resolve(undefined),
|
|
256
|
+
]);
|
|
257
|
+
|
|
258
|
+
// A `describedby` advertisement names the covering index authoritatively per
|
|
259
|
+
// llmstxt.org v2, so it outranks the blind root/section probes whenever usable.
|
|
260
|
+
let index = blindIndex;
|
|
261
|
+
|
|
262
|
+
if (primary.llmsTxtDescribedBy) {
|
|
263
|
+
const describedBy = toAbsoluteUrl(primary.llmsTxtDescribedBy);
|
|
264
|
+
|
|
265
|
+
const described =
|
|
266
|
+
describedBy && isSafeToProbe(describedBy, primary.url)
|
|
267
|
+
? await probeUsableRawText(describedBy, signal, dependencies)
|
|
268
|
+
: undefined;
|
|
269
|
+
|
|
270
|
+
if (described && describedBy) index = { url: describedBy.href, document: described };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (isLowQualityDocument(primary)) {
|
|
274
|
+
// The page's own advertised Markdown version is strictly better than an index.
|
|
275
|
+
const markdownAlternate = primary.markdownAlternateUrl
|
|
276
|
+
? toAbsoluteUrl(primary.markdownAlternateUrl)
|
|
277
|
+
: undefined;
|
|
278
|
+
|
|
279
|
+
const alternate =
|
|
280
|
+
markdownAlternate && isSafeToProbe(markdownAlternate, primary.url)
|
|
281
|
+
? await probeUsableRawText(markdownAlternate, signal, dependencies)
|
|
282
|
+
: undefined;
|
|
283
|
+
|
|
284
|
+
if (alternate && markdownAlternate) {
|
|
285
|
+
return { ...alternate, markdownAlternateFallback: true };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (index) return { ...index.document, llmsTxtFallback: true };
|
|
289
|
+
} else if (index) {
|
|
290
|
+
return { ...primary, llmsTxtIndexUrl: index.url };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return primary;
|
|
294
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
const CSS_BLOCK_AT_RULE = /^\s*@(container|font-face|keyframes|layer|media|page|supports)\b/i;
|
|
2
|
+
|
|
3
|
+
const STANDALONE_CSS_RULE =
|
|
4
|
+
/^\s*(?:[.#][-_a-zA-Z]|\*\s*[.#:[>+~]|(?:html|body|main|article|nav|header|footer|aside)(?:\b|[.#:[>+~]))[^{}]*\{[^{}]*\}\s*$/i;
|
|
5
|
+
|
|
6
|
+
const COMPLETE_STYLE_ELEMENT = /<style\b[^>]*>[\s\S]*?<\/style\s*>/gi;
|
|
7
|
+
|
|
8
|
+
const CLOSING_STYLE_ELEMENT = /<\/style\s*>/i;
|
|
9
|
+
|
|
10
|
+
const OPENING_STYLE_ELEMENT = /<style\b[^>]*>/i;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Iteratively removes complete style elements from a string to prevent nested fragment recreation.
|
|
14
|
+
*
|
|
15
|
+
* @param value - The string to strip style elements from
|
|
16
|
+
* @returns The string with all complete style elements removed
|
|
17
|
+
*/
|
|
18
|
+
function stripCompleteStyleElements(value: string): string {
|
|
19
|
+
let cleaned = value;
|
|
20
|
+
|
|
21
|
+
while (true) {
|
|
22
|
+
const next = cleaned.replace(COMPLETE_STYLE_ELEMENT, "");
|
|
23
|
+
|
|
24
|
+
if (next === cleaned) return cleaned;
|
|
25
|
+
cleaned = next;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Calculates the net brace depth change in a string (opening braces minus closing braces).
|
|
31
|
+
*
|
|
32
|
+
* @param value - The string to analyze
|
|
33
|
+
* @returns The difference between opening and closing brace counts
|
|
34
|
+
*/
|
|
35
|
+
function braceDelta(value: string): number {
|
|
36
|
+
return (value.match(/{/g)?.length ?? 0) - (value.match(/}/g)?.length ?? 0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Removes leaked stylesheet fragments from extracted Markdown while preserving fenced examples.
|
|
41
|
+
*
|
|
42
|
+
* The matcher is deliberately conservative: it removes style elements, recognized block at-rules,
|
|
43
|
+
* and complete standalone selector/declaration lines. Prose that merely discusses CSS and fenced
|
|
44
|
+
* CSS/SCSS/Less examples remain untouched.
|
|
45
|
+
*/
|
|
46
|
+
export function stripExtractedCssCruft(markdown: string): string {
|
|
47
|
+
const output: string[] = [];
|
|
48
|
+
let fence: { character: string; length: number } | undefined;
|
|
49
|
+
let styleElement = false;
|
|
50
|
+
let cssBlockDepth = 0;
|
|
51
|
+
|
|
52
|
+
for (const originalLine of markdown.split("\n")) {
|
|
53
|
+
const fenceMatch = /^[ \t]{0,3}(`{3,}|~{3,})/.exec(originalLine);
|
|
54
|
+
|
|
55
|
+
if (!fence && fenceMatch) {
|
|
56
|
+
fence = { character: fenceMatch[1][0], length: fenceMatch[1].length };
|
|
57
|
+
output.push(originalLine);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (fence) {
|
|
62
|
+
output.push(originalLine);
|
|
63
|
+
|
|
64
|
+
if (
|
|
65
|
+
fenceMatch &&
|
|
66
|
+
fenceMatch[1][0] === fence.character &&
|
|
67
|
+
fenceMatch[1].length >= fence.length &&
|
|
68
|
+
!originalLine.slice(fenceMatch[0].length).trim()
|
|
69
|
+
) {
|
|
70
|
+
fence = undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let line = originalLine;
|
|
77
|
+
|
|
78
|
+
if (styleElement) {
|
|
79
|
+
const close = CLOSING_STYLE_ELEMENT.exec(line);
|
|
80
|
+
|
|
81
|
+
if (!close) continue;
|
|
82
|
+
line = line.slice(close.index + close[0].length);
|
|
83
|
+
styleElement = false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
line = stripCompleteStyleElements(line);
|
|
87
|
+
const open = OPENING_STYLE_ELEMENT.exec(line);
|
|
88
|
+
|
|
89
|
+
if (open) {
|
|
90
|
+
line = line.slice(0, open.index);
|
|
91
|
+
styleElement = true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (cssBlockDepth > 0) {
|
|
95
|
+
cssBlockDepth += braceDelta(line);
|
|
96
|
+
|
|
97
|
+
if (cssBlockDepth < 0) cssBlockDepth = 0;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (CSS_BLOCK_AT_RULE.test(line)) {
|
|
102
|
+
cssBlockDepth = Math.max(0, braceDelta(line));
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (STANDALONE_CSS_RULE.test(line)) continue;
|
|
107
|
+
output.push(line);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return output
|
|
111
|
+
.join("\n")
|
|
112
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
113
|
+
.trim();
|
|
114
|
+
}
|