@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/service.ts
CHANGED
|
@@ -9,12 +9,10 @@ import {
|
|
|
9
9
|
} from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import { ExpiringLruCache, stableKeyHash, type CachePersistence } from "./cache";
|
|
11
11
|
import { sliceCompleteDocument, type CompleteDocument } from "./content";
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
revalidateCompleteDocument,
|
|
15
|
-
type FetchRemoteDependencies,
|
|
16
|
-
} from "./fetch";
|
|
12
|
+
import { revalidateCompleteDocument, type FetchRemoteDependencies } from "./fetch";
|
|
13
|
+
import { fetchDocumentWithLlmsTxtSupport } from "./llms";
|
|
17
14
|
import { focusMarkdown, type FocusDetails } from "./focus";
|
|
15
|
+
import { resolveFragmentOffset, urlWithoutFragment } from "./fragments";
|
|
18
16
|
import { InflightCoalescer } from "./inflight";
|
|
19
17
|
import { createDocumentOutline, type DocumentOutline } from "./outline";
|
|
20
18
|
import { redactUrlForDisplay } from "./redact";
|
|
@@ -38,8 +36,6 @@ const CACHE_MAX_MARKDOWN_BYTES = 20 * 1_024 * 1_024;
|
|
|
38
36
|
const MAX_INFLIGHT_REQUESTS = 100;
|
|
39
37
|
|
|
40
38
|
/** Minimum extracted length for both the low-quality trigger and llms.txt acceptance. */
|
|
41
|
-
const LLMS_TXT_MIN_MARKDOWN_CHARACTERS = 200;
|
|
42
|
-
|
|
43
39
|
const encoder = new TextEncoder();
|
|
44
40
|
|
|
45
41
|
/** Coarse classification of what kind of page a fetch returned. */
|
|
@@ -133,277 +129,7 @@ export function classifyConfidence(
|
|
|
133
129
|
return markdownLength >= 200 ? "medium" : "low";
|
|
134
130
|
}
|
|
135
131
|
|
|
136
|
-
|
|
137
|
-
* Builds candidate `/llms.txt` URLs for a page: the site-root index plus each ancestor
|
|
138
|
-
* directory index up to `MAX_LLMS_TXT_DIRECTORY_DEPTH` levels deep, mirroring sites that
|
|
139
|
-
* publish per-section indexes (for example `developers.cloudflare.com/r2/llms.txt`).
|
|
140
|
-
* Returns them ordered shallow → deep, and an empty list for non-HTTP(S) targets or when
|
|
141
|
-
* the page itself is already a `llms.txt` path, so a fallback never retries itself.
|
|
142
|
-
*/
|
|
143
|
-
function isGitHubLikeHost(hostname: string): boolean {
|
|
144
|
-
return (
|
|
145
|
-
hostname === "github.com" ||
|
|
146
|
-
hostname === "raw.githubusercontent.com" ||
|
|
147
|
-
hostname === "gist.github.com" ||
|
|
148
|
-
hostname === "gist.githubusercontent.com"
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Build candidate /llms.txt URLs to probe for a given page.
|
|
154
|
-
*
|
|
155
|
-
* Generates a list of potential /llms.txt index URLs by checking the site root
|
|
156
|
-
* and ancestor directories up to a maximum depth. Returns URLs ordered from
|
|
157
|
-
* shallowest to deepest.
|
|
158
|
-
*
|
|
159
|
-
* @param rawUrl - The original page URL
|
|
160
|
-
* @returns Array of candidate /llms.txt URLs to probe (empty for non-HTTP/S or GitHub)
|
|
161
|
-
*/
|
|
162
|
-
export function buildLlmsTxtCandidateUrls(rawUrl: string): URL[] {
|
|
163
|
-
try {
|
|
164
|
-
const url = new URL(rawUrl);
|
|
165
|
-
|
|
166
|
-
if (url.protocol !== "https:" && url.protocol !== "http:") return [];
|
|
167
|
-
|
|
168
|
-
if (isGitHubLikeHost(url.hostname)) return [];
|
|
169
|
-
|
|
170
|
-
if (url.pathname === "/llms.txt" || url.pathname.endsWith("/llms.txt")) return [];
|
|
171
|
-
|
|
172
|
-
// Ancestor directories only, one level deep: for `/r2/buckets/x` probe
|
|
173
|
-
// `/llms.txt` and `/r2/llms.txt`, never deeper. A trailing slash marks the final
|
|
174
|
-
// segment as a directory itself, so `/r2/` still probes `/r2/llms.txt`.
|
|
175
|
-
const trimmedDirectory = url.pathname.endsWith("/")
|
|
176
|
-
? url.pathname
|
|
177
|
-
: url.pathname.replace(/\/[^/]*$/, "/");
|
|
178
|
-
|
|
179
|
-
const directories = ["/"];
|
|
180
|
-
const parts = trimmedDirectory.split("/").filter(Boolean);
|
|
181
|
-
|
|
182
|
-
for (let depth = 1; depth <= Math.min(MAX_LLMS_TXT_DIRECTORY_DEPTH, parts.length); depth += 1) {
|
|
183
|
-
directories.push(`/${parts.slice(0, depth).join("/")}/`);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
return directories.map((pathname) => {
|
|
187
|
-
const candidate = new URL(url.origin);
|
|
188
|
-
candidate.pathname = `${pathname}llms.txt`;
|
|
189
|
-
|
|
190
|
-
return candidate;
|
|
191
|
-
});
|
|
192
|
-
} catch {
|
|
193
|
-
return [];
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function isLowQualityDocument(document: CompleteDocument): boolean {
|
|
198
|
-
return (
|
|
199
|
-
document.shellSuspected ||
|
|
200
|
-
(document.extractor !== "raw" && document.markdown.length < LLMS_TXT_MIN_MARKDOWN_CHARACTERS)
|
|
201
|
-
);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/** A probed `/llms.txt` outcome for one candidate URL; an absent document means "unavailable". */
|
|
205
|
-
interface LlmsTxtProbe {
|
|
206
|
-
document?: CompleteDocument;
|
|
207
|
-
expires: number;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const MAX_LLMS_TXT_PROBE_ENTRIES = 512;
|
|
211
|
-
|
|
212
|
-
/** Deepest ancestor-directory `/llms.txt` probed, e.g. `/r2/x/y` probes `/r2/llms.txt`. */
|
|
213
|
-
const MAX_LLMS_TXT_DIRECTORY_DEPTH = 1;
|
|
214
|
-
|
|
215
|
-
const llmsTxtProbes = new ExpiringLruCache<string, LlmsTxtProbe>(
|
|
216
|
-
MAX_LLMS_TXT_PROBE_ENTRIES,
|
|
217
|
-
8 * 1024 * 1024,
|
|
218
|
-
(probe) => encoder.encode(JSON.stringify(probe)).byteLength,
|
|
219
|
-
);
|
|
220
|
-
|
|
221
|
-
const llmsTxtProbeRequests = new InflightCoalescer<string, LlmsTxtProbe>(
|
|
222
|
-
MAX_LLMS_TXT_PROBE_ENTRIES,
|
|
223
|
-
);
|
|
224
|
-
|
|
225
|
-
function rememberLlmsTxtProbe(origin: string, probe: LlmsTxtProbe): void {
|
|
226
|
-
llmsTxtProbes.set(origin, probe, probe.expires);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function isUsableLlmsTxtIndex(document: CompleteDocument): boolean {
|
|
230
|
-
return (
|
|
231
|
-
document.extractor === "raw" &&
|
|
232
|
-
!document.shellSuspected &&
|
|
233
|
-
document.markdown.length >= LLMS_TXT_MIN_MARKDOWN_CHARACTERS
|
|
234
|
-
);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function toAbsoluteUrl(value: string): URL | undefined {
|
|
238
|
-
try {
|
|
239
|
-
return new URL(value);
|
|
240
|
-
} catch {
|
|
241
|
-
return undefined;
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* Returns the deepest usable `/llms.txt` index among the candidates, probing each
|
|
247
|
-
* uncached candidate at most once per TTL window (negative results are cached too).
|
|
248
|
-
* Any failure is treated as absence so a missing index can never degrade the primary
|
|
249
|
-
* fetch.
|
|
250
|
-
*/
|
|
251
|
-
async function ensureLlmsTxtIndex(
|
|
252
|
-
candidates: URL[],
|
|
253
|
-
signal: AbortSignal | undefined,
|
|
254
|
-
dependencies: FetchRemoteDependencies,
|
|
255
|
-
): Promise<{ url: string; document: CompleteDocument } | undefined> {
|
|
256
|
-
const documents = await Promise.all(
|
|
257
|
-
candidates.map((candidate) => probeUsableRawText(candidate, signal, dependencies)),
|
|
258
|
-
);
|
|
259
|
-
|
|
260
|
-
// Candidates are ordered shallow → deep; prefer the deepest usable index because a
|
|
261
|
-
// section index is the more relevant table of contents for the requested page.
|
|
262
|
-
for (let depth = documents.length - 1; depth >= 0; depth -= 1) {
|
|
263
|
-
const document = documents[depth];
|
|
264
|
-
|
|
265
|
-
if (document) return { url: candidates[depth].href, document };
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
return undefined;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* Probes one URL at most once per TTL window (negative results are cached too) and
|
|
273
|
-
* returns its document only when it is usable raw text. Used for `/llms.txt` indexes
|
|
274
|
-
* and for advertised Markdown versions of a page. Any failure is treated as absence so
|
|
275
|
-
* a missing resource can never degrade the primary fetch.
|
|
276
|
-
*/
|
|
277
|
-
async function probeUsableRawText(
|
|
278
|
-
candidate: URL,
|
|
279
|
-
signal: AbortSignal | undefined,
|
|
280
|
-
dependencies: FetchRemoteDependencies,
|
|
281
|
-
): Promise<CompleteDocument | undefined> {
|
|
282
|
-
const cached = llmsTxtProbes.get(candidate.href);
|
|
283
|
-
|
|
284
|
-
if (cached) return cached.document;
|
|
285
|
-
|
|
286
|
-
const probe = await llmsTxtProbeRequests.run(
|
|
287
|
-
candidate.href,
|
|
288
|
-
async (sharedSignal) => {
|
|
289
|
-
const existing = llmsTxtProbes.get(candidate.href);
|
|
290
|
-
|
|
291
|
-
if (existing) return existing;
|
|
292
|
-
|
|
293
|
-
try {
|
|
294
|
-
const document = await fetchCompleteDocument(
|
|
295
|
-
candidate.toString(),
|
|
296
|
-
sharedSignal,
|
|
297
|
-
dependencies,
|
|
298
|
-
);
|
|
299
|
-
|
|
300
|
-
const available = isUsableLlmsTxtIndex(document) ? document : undefined;
|
|
301
|
-
const result = { document: available, expires: Date.now() + CACHE_TTL_MS };
|
|
302
|
-
rememberLlmsTxtProbe(candidate.href, result);
|
|
303
|
-
|
|
304
|
-
return result;
|
|
305
|
-
} catch (error) {
|
|
306
|
-
if (sharedSignal?.aborted) throw error;
|
|
307
|
-
const result = { expires: Date.now() + CACHE_TTL_MS };
|
|
308
|
-
rememberLlmsTxtProbe(candidate.href, result);
|
|
309
|
-
|
|
310
|
-
return result;
|
|
311
|
-
}
|
|
312
|
-
},
|
|
313
|
-
signal,
|
|
314
|
-
"web_fetch was cancelled.",
|
|
315
|
-
);
|
|
316
|
-
|
|
317
|
-
return probe.document;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/**
|
|
321
|
-
* Validates that a candidate URL is safe to probe: must use HTTP(S) and match the primary
|
|
322
|
-
* page's origin. This prevents SSRF-like issues where a fetched page could embed meta tags
|
|
323
|
-
* pointing to arbitrary/internal/cross-origin URLs.
|
|
324
|
-
*/
|
|
325
|
-
function isSafeToProbe(candidate: URL | undefined, primaryUrl: string): boolean {
|
|
326
|
-
if (!candidate) return false;
|
|
327
|
-
const protocol = candidate.protocol;
|
|
328
|
-
|
|
329
|
-
if (protocol !== "http:" && protocol !== "https:") return false;
|
|
330
|
-
const primaryParsed = safeUrl(primaryUrl);
|
|
331
|
-
|
|
332
|
-
if (!primaryParsed) return false;
|
|
333
|
-
|
|
334
|
-
return candidate.origin === primaryParsed.origin;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
/**
|
|
338
|
-
* Fetches a page with llms.txt awareness:
|
|
339
|
-
*
|
|
340
|
-
* - Probes the site's `/llms.txt` index once per origin per TTL window, in parallel with
|
|
341
|
-
* the primary fetch, so later calls can advertise it.
|
|
342
|
-
* - Serves the index instead of the page when the page looks like an app shell or carries
|
|
343
|
-
* too little readable text.
|
|
344
|
-
* - Otherwise annotates the page with the index URL so agents can discover sibling pages.
|
|
345
|
-
*
|
|
346
|
-
* A missing or useless index can never degrade the primary fetch.
|
|
347
|
-
*/
|
|
348
|
-
async function fetchDocumentWithLlmsTxtSupport(
|
|
349
|
-
rawUrl: string,
|
|
350
|
-
signal: AbortSignal | undefined,
|
|
351
|
-
dependencies: FetchRemoteDependencies,
|
|
352
|
-
): Promise<CompleteDocument> {
|
|
353
|
-
try {
|
|
354
|
-
if (isGitHubLikeHost(new URL(rawUrl).hostname)) {
|
|
355
|
-
return fetchCompleteDocument(rawUrl, signal, dependencies);
|
|
356
|
-
}
|
|
357
|
-
} catch {
|
|
358
|
-
// Invalid URL falls through to fetchCompleteDocument which will throw.
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
const candidates = buildLlmsTxtCandidateUrls(rawUrl);
|
|
362
|
-
|
|
363
|
-
const [primary, blindIndex] = await Promise.all([
|
|
364
|
-
fetchCompleteDocument(rawUrl, signal, dependencies),
|
|
365
|
-
candidates.length > 0
|
|
366
|
-
? ensureLlmsTxtIndex(candidates, signal, dependencies)
|
|
367
|
-
: Promise.resolve(undefined),
|
|
368
|
-
]);
|
|
369
|
-
|
|
370
|
-
// A `describedby` advertisement names the covering index authoritatively per
|
|
371
|
-
// llmstxt.org v2, so it outranks the blind root/section probes whenever usable.
|
|
372
|
-
let index = blindIndex;
|
|
373
|
-
|
|
374
|
-
if (primary.llmsTxtDescribedBy) {
|
|
375
|
-
const describedBy = toAbsoluteUrl(primary.llmsTxtDescribedBy);
|
|
376
|
-
|
|
377
|
-
const described =
|
|
378
|
-
describedBy && isSafeToProbe(describedBy, primary.url)
|
|
379
|
-
? await probeUsableRawText(describedBy, signal, dependencies)
|
|
380
|
-
: undefined;
|
|
381
|
-
|
|
382
|
-
if (described && describedBy) index = { url: describedBy.href, document: described };
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
if (isLowQualityDocument(primary)) {
|
|
386
|
-
// The page's own advertised Markdown version is strictly better than an index.
|
|
387
|
-
const markdownAlternate = primary.markdownAlternateUrl
|
|
388
|
-
? toAbsoluteUrl(primary.markdownAlternateUrl)
|
|
389
|
-
: undefined;
|
|
390
|
-
|
|
391
|
-
const alternate =
|
|
392
|
-
markdownAlternate && isSafeToProbe(markdownAlternate, primary.url)
|
|
393
|
-
? await probeUsableRawText(markdownAlternate, signal, dependencies)
|
|
394
|
-
: undefined;
|
|
395
|
-
|
|
396
|
-
if (alternate && markdownAlternate) {
|
|
397
|
-
return { ...alternate, markdownAlternateFallback: true };
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
if (index) return { ...index.document, llmsTxtFallback: true };
|
|
401
|
-
} else if (index) {
|
|
402
|
-
return { ...primary, llmsTxtIndexUrl: index.url };
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
return primary;
|
|
406
|
-
}
|
|
132
|
+
export { buildLlmsTxtCandidateUrls } from "./llms";
|
|
407
133
|
|
|
408
134
|
/** Resolves the private, cross-session cache directory for a web tool. */
|
|
409
135
|
function resolveCacheDirectory(name: string): string {
|
|
@@ -553,40 +279,6 @@ interface FetchAcquisition {
|
|
|
553
279
|
|
|
554
280
|
const inflightFetches = new InflightCoalescer<string, FetchAcquisition>(MAX_INFLIGHT_REQUESTS);
|
|
555
281
|
|
|
556
|
-
/** Removes a URL fragment before acquisition so fragment variants share cache entries. */
|
|
557
|
-
function urlWithoutFragment(rawUrl: string): string {
|
|
558
|
-
try {
|
|
559
|
-
const url = new URL(rawUrl);
|
|
560
|
-
url.hash = "";
|
|
561
|
-
|
|
562
|
-
return url.href;
|
|
563
|
-
} catch {
|
|
564
|
-
return rawUrl;
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
interface ResolvedFragment {
|
|
569
|
-
fragment?: string;
|
|
570
|
-
offset?: number;
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
function resolveFragmentOffset(document: CompleteDocument, rawUrl: string): ResolvedFragment {
|
|
574
|
-
let fragment: string | undefined;
|
|
575
|
-
|
|
576
|
-
try {
|
|
577
|
-
const hash = new URL(rawUrl).hash;
|
|
578
|
-
|
|
579
|
-
if (hash) fragment = decodeURIComponent(hash.slice(1));
|
|
580
|
-
} catch {
|
|
581
|
-
return {};
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
if (!fragment) return {};
|
|
585
|
-
const offsets = document.fragmentOffsets ?? {};
|
|
586
|
-
|
|
587
|
-
return { fragment, offset: offsets[fragment] ?? offsets[fragment.toLowerCase()] };
|
|
588
|
-
}
|
|
589
|
-
|
|
590
282
|
/** Executes a validated web fetch, including caching, focusing, and bounded pagination. */
|
|
591
283
|
export async function executeWebFetch(
|
|
592
284
|
params: WebFetchParameters,
|
|
@@ -688,19 +380,31 @@ export async function executeWebFetch(
|
|
|
688
380
|
const fragmentMatched = resolvedFragment.fragment !== undefined && fragmentOffset !== undefined;
|
|
689
381
|
const startingOffset = explicitOffset ?? fragmentOffset ?? FETCH_DEFAULT_OFFSET;
|
|
690
382
|
|
|
383
|
+
const hasFragmentRange =
|
|
384
|
+
explicitOffset === undefined &&
|
|
385
|
+
fragmentOffset !== undefined &&
|
|
386
|
+
resolvedFragment.endOffset !== undefined;
|
|
387
|
+
|
|
691
388
|
const fragmentDocument =
|
|
692
389
|
explicitOffset === undefined && fragmentOffset !== undefined
|
|
693
|
-
? {
|
|
390
|
+
? {
|
|
391
|
+
...document,
|
|
392
|
+
markdown: hasFragmentRange
|
|
393
|
+
? document.markdown.slice(fragmentOffset, resolvedFragment.endOffset)
|
|
394
|
+
: document.markdown.slice(fragmentOffset),
|
|
395
|
+
}
|
|
694
396
|
: document;
|
|
695
397
|
|
|
696
398
|
const focused =
|
|
697
399
|
params.query === undefined ? undefined : focusMarkdown(fragmentDocument.markdown, params.query);
|
|
698
400
|
|
|
699
|
-
const outputDocument = focused
|
|
401
|
+
const outputDocument = focused
|
|
402
|
+
? { ...fragmentDocument, markdown: focused.markdown }
|
|
403
|
+
: fragmentDocument;
|
|
700
404
|
|
|
701
405
|
const resultOffset = focused
|
|
702
406
|
? (explicitOffset ?? 0)
|
|
703
|
-
: (explicitOffset ??
|
|
407
|
+
: (explicitOffset ?? (fragmentMatched ? 0 : startingOffset));
|
|
704
408
|
|
|
705
409
|
const result = sliceCompleteDocument(outputDocument, resultOffset, maxCharacters);
|
|
706
410
|
|