@zeldrisho/pi-web-fetch 0.14.1 → 0.16.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/CHANGELOG.md +28 -1
- package/README.md +3 -5
- package/package.json +5 -2
- package/src/acquisition/fetch.ts +167 -12
- package/src/acquisition/llms.ts +42 -11
- package/src/challenges/aws-waf-resolver.ts +806 -0
- package/src/challenges/aws-waf-solver.ts +65 -0
- package/src/challenges/aws-waf-worker.ts +14 -0
- package/src/challenges/aws-waf.ts +34 -0
- package/src/document/content.ts +4 -0
- package/src/document/focus.ts +117 -11
- package/src/document/openapi.ts +80 -0
- package/src/document/source-format.ts +180 -0
- package/src/index.ts +7 -0
- package/src/network/cookies.ts +41 -0
- package/src/network/network-redirects.ts +15 -2
- package/src/network/network-transport.ts +21 -3
- package/src/service.ts +23 -5
- package/workers/aws-waf-solver.js +56 -0
- package/workers/aws-waf-worker.js +14 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.16.0] - 2026-10-28
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Automatically resolve supported AWS WAF challenges when fetching pages.
|
|
15
|
+
|
|
16
|
+
## [0.15.0] - 2026-10-28
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Return minified JavaScript, JSON, and YAML responses as indented, fenced code blocks.
|
|
21
|
+
- Identify OpenAPI and Swagger JSON and YAML documents and JavaScript bundles containing literal specs as `openapi-spec`.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- Focus large fenced source documents using line-aligned excerpts.
|
|
26
|
+
- Shorten documentation index notices.
|
|
27
|
+
- Probe one more ancestor directory for `/llms.txt`, so locale-prefixed documentation sites resolve to their section index.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- Report AWS WAF challenge responses as blocks instead of empty pages.
|
|
32
|
+
- Fetch Markdown documentation that static hosts serve as `application/octet-stream`.
|
|
33
|
+
- Fall back to a discovered documentation index when a bot challenge withholds the requested page.
|
|
34
|
+
|
|
10
35
|
## [0.14.1] - 2026-10-25
|
|
11
36
|
|
|
12
37
|
### Changed
|
|
@@ -270,7 +295,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
270
295
|
|
|
271
296
|
- Add bounded public page fetching ([b979496](https://github.com/zeldrisho/pi-packages/commit/b979496b32de1cead172ba570307e4a4a7b3421d))
|
|
272
297
|
|
|
273
|
-
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.
|
|
298
|
+
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.16.0...HEAD
|
|
299
|
+
[0.16.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.15.0...pi-web-fetch-v0.16.0
|
|
300
|
+
[0.15.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.14.1...pi-web-fetch-v0.15.0
|
|
274
301
|
[0.14.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.14.0...pi-web-fetch-v0.14.1
|
|
275
302
|
[0.14.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.13.0...pi-web-fetch-v0.14.0
|
|
276
303
|
[0.13.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.12.0...pi-web-fetch-v0.13.0
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ Use `pi config` to enable or disable the extension, and `pi list` to see configu
|
|
|
15
15
|
|
|
16
16
|
## Usage
|
|
17
17
|
|
|
18
|
-
The `web_fetch` tool accepts public HTTP and HTTPS URLs and supports HTML, Markdown, plain text, JSON, and XML. HTML is converted to Markdown; images are omitted. URLs with credentials, local or private network targets, unsafe redirects, downloads over 5 MiB, and unsupported content types are rejected.
|
|
18
|
+
The `web_fetch` tool accepts public HTTP and HTTPS URLs and supports HTML, Markdown, plain text, JSON, and XML. HTML is converted to Markdown; images are omitted. Minified JavaScript, JSON, and YAML are returned as indented code blocks so long single-line payloads stay readable. URLs with credentials, local or private network targets, unsafe redirects, downloads over 5 MiB, and unsupported content types are rejected.
|
|
19
19
|
|
|
20
20
|
| Parameter | Description |
|
|
21
21
|
| --------- | --------------------------------------------------------------------------------------------------------- |
|
|
@@ -23,15 +23,13 @@ The `web_fetch` tool accepts public HTTP and HTTPS URLs and supports HTML, Markd
|
|
|
23
23
|
| `query` | Optional; selects matching sections from a long page. Sections remain in source order. |
|
|
24
24
|
| `offset` | Optional character offset for continuing through extracted content; takes precedence over a URL fragment. |
|
|
25
25
|
|
|
26
|
-
Output is bounded to 6,000 characters by default. For long pages, use the returned `nextOffset` to continue.
|
|
26
|
+
Output is bounded to 6,000 characters by default. For long pages, use the returned `nextOffset` to continue. Use `query` to focus on matching sections, or omit it to read the complete document. Pi's preview is collapsed by default; press `Ctrl+O` to expand it.
|
|
27
27
|
|
|
28
28
|
For example, a URL such as `https://github.com/shadcn-ui/lint#get-started` starts at the matching heading. GitHub `blob` links and bare gists are converted to raw source URLs; repository roots use their README. GitHub directory listings are not supported.
|
|
29
29
|
|
|
30
30
|
### Page alternatives and bot challenges
|
|
31
31
|
|
|
32
|
-
The tool can discover advertised Markdown versions and `/llms.txt` indexes.
|
|
33
|
-
|
|
34
|
-
Pages with HTML nested beyond 512 elements are rejected.
|
|
32
|
+
The tool can discover advertised Markdown versions and `/llms.txt` indexes. An index URL appears in the result when found; follow it if the page is insufficient. The tool can solve Anubis and supported AWS WAF proof-of-work challenges. If an AWS WAF or Cloudflare challenge remains blocked, it may fall back to the site's documentation index. Unsupported challenges and JavaScript CAPTCHAs return a blocked result; there is no browser fallback.
|
|
35
33
|
|
|
36
34
|
### Caching
|
|
37
35
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeldrisho/pi-web-fetch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Pi extension for secure, bounded public web page fetching and Markdown extraction",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
@@ -30,8 +30,11 @@
|
|
|
30
30
|
"access": "public"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
+
"acorn": "^8.19.0",
|
|
34
|
+
"acorn-walk": "^8.3.5",
|
|
33
35
|
"defuddle": "^0.19.5",
|
|
34
|
-
"linkedom": "^0.18.13"
|
|
36
|
+
"linkedom": "^0.18.13",
|
|
37
|
+
"yaml": "^2.9.1"
|
|
35
38
|
},
|
|
36
39
|
"devDependencies": {
|
|
37
40
|
"@earendil-works/pi-coding-agent": "^1.4.0",
|
package/src/acquisition/fetch.ts
CHANGED
|
@@ -14,6 +14,13 @@ import {
|
|
|
14
14
|
parseAnubisChallenge,
|
|
15
15
|
solveAnubisChallenge,
|
|
16
16
|
} from "../challenges/anubis.ts";
|
|
17
|
+
import {
|
|
18
|
+
AWS_WAF_TOKEN_COOKIE,
|
|
19
|
+
isAwsWafChallengeResponse,
|
|
20
|
+
type AwsWafResolver,
|
|
21
|
+
type AwsWafSolution,
|
|
22
|
+
} from "../challenges/aws-waf.ts";
|
|
23
|
+
import { nativeAwsWafResolver } from "../challenges/aws-waf-resolver.ts";
|
|
17
24
|
import {
|
|
18
25
|
sliceCompleteDocument,
|
|
19
26
|
type CompleteDocument,
|
|
@@ -27,6 +34,8 @@ import {
|
|
|
27
34
|
} from "../document/evidence.ts";
|
|
28
35
|
import { extractHtmlToMarkdown } from "../document/extract.ts";
|
|
29
36
|
import { assertConversionDepth } from "../document/complexity.ts";
|
|
37
|
+
import { formatMinifiedSource } from "../document/source-format.ts";
|
|
38
|
+
import { isOpenapiSource } from "../document/openapi.ts";
|
|
30
39
|
import {
|
|
31
40
|
requestFollowingRedirects,
|
|
32
41
|
type RedirectDependencies,
|
|
@@ -87,6 +96,10 @@ export interface FetchRemoteDependencies extends RedirectDependencies {
|
|
|
87
96
|
timeoutMs?: number;
|
|
88
97
|
/** When false, an Anubis interstitial is reported as blocked instead of solved. Defaults to true. */
|
|
89
98
|
solveAnubisChallenges?: boolean;
|
|
99
|
+
/** When false, an AWS WAF challenge is reported as blocked instead of solved. Defaults to true. */
|
|
100
|
+
solveAwsWafChallenges?: boolean;
|
|
101
|
+
/** Resolver used for AWS WAF challenges; defaults to the in-process resolver. */
|
|
102
|
+
awsWafResolver?: AwsWafResolver;
|
|
90
103
|
}
|
|
91
104
|
|
|
92
105
|
/**
|
|
@@ -154,17 +167,47 @@ function isHtmlContentType(
|
|
|
154
167
|
return value === "text/html" || value === "application/xhtml+xml";
|
|
155
168
|
}
|
|
156
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Path suffixes whose content is read as plain text when a server labels it
|
|
172
|
+
* `application/octet-stream`. Static hosts commonly serve Markdown documentation
|
|
173
|
+
* with that generic binary type; restricting the exception to these suffixes keeps
|
|
174
|
+
* real binary downloads out of extraction.
|
|
175
|
+
*/
|
|
176
|
+
const TEXT_DOCUMENT_PATH_PATTERN = /\.(?:md|markdown|mdx|txt)$/i;
|
|
177
|
+
|
|
157
178
|
/** Narrows a media type to those web_fetch is willing to read. */
|
|
158
|
-
function isAllowedContentType(value: string | undefined): value is string {
|
|
159
|
-
return
|
|
160
|
-
|
|
161
|
-
);
|
|
179
|
+
function isAllowedContentType(value: string | undefined, pathname: string): value is string {
|
|
180
|
+
if (value === undefined) return false;
|
|
181
|
+
|
|
182
|
+
if (value.startsWith("text/") || ALLOWED_CONTENT_TYPES.includes(value)) return true;
|
|
183
|
+
|
|
184
|
+
return value === "application/octet-stream" && TEXT_DOCUMENT_PATH_PATTERN.test(pathname);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Identifies a page a bot-protection layer refused to serve.
|
|
189
|
+
*
|
|
190
|
+
* The response carries no page content, so callers may substitute a site index the
|
|
191
|
+
* same page would otherwise have been extracted from.
|
|
192
|
+
*/
|
|
193
|
+
export class ChallengeBlockedError extends Error {
|
|
194
|
+
constructor(message: string) {
|
|
195
|
+
super(message);
|
|
196
|
+
this.name = "ChallengeBlockedError";
|
|
197
|
+
}
|
|
162
198
|
}
|
|
163
199
|
|
|
164
200
|
/** Throws the established error for a response whose status is not successful. */
|
|
165
201
|
function assertSuccessfulResponse(response: IncomingMessage): void {
|
|
166
202
|
const status = response.statusCode ?? 0;
|
|
167
203
|
|
|
204
|
+
if (isAwsWafChallengeResponse(response)) {
|
|
205
|
+
response.resume();
|
|
206
|
+
throw new ChallengeBlockedError(
|
|
207
|
+
`web_fetch was blocked by an AWS WAF anti-bot challenge (HTTP ${status}).`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
168
211
|
if (status >= 200 && status < 300) return;
|
|
169
212
|
|
|
170
213
|
const cloudflareChallenge =
|
|
@@ -173,7 +216,9 @@ function assertSuccessfulResponse(response: IncomingMessage): void {
|
|
|
173
216
|
response.resume();
|
|
174
217
|
|
|
175
218
|
if (cloudflareChallenge) {
|
|
176
|
-
throw new
|
|
219
|
+
throw new ChallengeBlockedError(
|
|
220
|
+
`web_fetch was blocked by Cloudflare's anti-bot challenge (HTTP ${status}).`,
|
|
221
|
+
);
|
|
177
222
|
}
|
|
178
223
|
|
|
179
224
|
const authenticationHint = [401, 403, 404].includes(status)
|
|
@@ -243,6 +288,8 @@ async function documentFromDecoded(
|
|
|
243
288
|
let extractor: CompleteDocument["extractor"] = "raw";
|
|
244
289
|
let describedBy: string | undefined = linkHints.describedBy;
|
|
245
290
|
let markdownAlternateUrl: string | undefined = linkHints.markdownAlternate;
|
|
291
|
+
let sourceLanguage: string | undefined;
|
|
292
|
+
const openapiSpec = isOpenapiSource(raw, contentType);
|
|
246
293
|
|
|
247
294
|
if (isHtmlContentType(contentType)) {
|
|
248
295
|
const extracted = await awaitWithAbort(extractHtml(raw, target.url), signal);
|
|
@@ -254,12 +301,23 @@ async function documentFromDecoded(
|
|
|
254
301
|
markdownAlternateUrl = markdownAlternateUrl ?? extracted.markdownAlternateLink;
|
|
255
302
|
} else if (contentType === "application/json") {
|
|
256
303
|
try {
|
|
257
|
-
|
|
304
|
+
const parsed: unknown = JSON.parse(raw);
|
|
305
|
+
|
|
306
|
+
markdown = `\`\`\`json\n${JSON.stringify(parsed, null, 2)}\n\`\`\``;
|
|
307
|
+
sourceLanguage = "json";
|
|
258
308
|
} catch {
|
|
259
|
-
|
|
309
|
+
// Minified or JSON-like bodies are still worth structuring even when they do
|
|
310
|
+
// not parse as strict JSON.
|
|
311
|
+
const formatted = formatMinifiedSource(raw, contentType);
|
|
312
|
+
|
|
313
|
+
markdown = formatted?.markdown ?? raw;
|
|
314
|
+
sourceLanguage = formatted?.language;
|
|
260
315
|
}
|
|
261
316
|
} else {
|
|
262
|
-
|
|
317
|
+
const formatted = formatMinifiedSource(raw, contentType);
|
|
318
|
+
|
|
319
|
+
markdown = formatted?.markdown ?? raw;
|
|
320
|
+
sourceLanguage = formatted?.language;
|
|
263
321
|
|
|
264
322
|
const markdownContent = /\bmarkdown\b|\.md(?:$|[?#])/i.test(
|
|
265
323
|
contentTypeHeader + target.url.pathname,
|
|
@@ -306,6 +364,8 @@ async function documentFromDecoded(
|
|
|
306
364
|
cachedAt: Date.now(),
|
|
307
365
|
llmsTxtDescribedBy: describedBy,
|
|
308
366
|
markdownAlternateUrl,
|
|
367
|
+
sourceLanguage,
|
|
368
|
+
openapiSpec,
|
|
309
369
|
};
|
|
310
370
|
}
|
|
311
371
|
|
|
@@ -334,7 +394,7 @@ async function documentFromResponse(
|
|
|
334
394
|
const contentTypeHeader = responseContentTypeHeader(response);
|
|
335
395
|
const contentType = normalizeContentType(contentTypeHeader);
|
|
336
396
|
|
|
337
|
-
if (!isAllowedContentType(contentType)) {
|
|
397
|
+
if (!isAllowedContentType(contentType, target.url.pathname)) {
|
|
338
398
|
response.destroy();
|
|
339
399
|
throw new Error(
|
|
340
400
|
`web_fetch does not support ${contentType ?? (contentTypeHeader || "this content type")}.`,
|
|
@@ -368,6 +428,7 @@ async function documentFromAcquisition(
|
|
|
368
428
|
dependencies: FetchRemoteDependencies,
|
|
369
429
|
allowSolve: boolean,
|
|
370
430
|
): Promise<CompleteDocument> {
|
|
431
|
+
assertSuccessfulResponse(response);
|
|
371
432
|
const status = response.statusCode ?? 0;
|
|
372
433
|
const contentTypeHeader = responseContentTypeHeader(response);
|
|
373
434
|
const contentType = normalizeContentType(contentTypeHeader);
|
|
@@ -443,6 +504,37 @@ function assertAbsoluteHttpUrlForFetch(value: string): URL {
|
|
|
443
504
|
return url;
|
|
444
505
|
}
|
|
445
506
|
|
|
507
|
+
/**
|
|
508
|
+
* Attempts one WAF token resolution while preserving caller cancellation.
|
|
509
|
+
* Solver failures are intentionally converted into the normal blocked path.
|
|
510
|
+
*/
|
|
511
|
+
async function resolveAwsWafToken(
|
|
512
|
+
resolver: AwsWafResolver,
|
|
513
|
+
target: ValidatedTarget,
|
|
514
|
+
signal: AbortSignal,
|
|
515
|
+
network: RedirectDependencies,
|
|
516
|
+
budgetMs: number,
|
|
517
|
+
): Promise<AwsWafSolution | undefined> {
|
|
518
|
+
if (budgetMs <= 0) return undefined;
|
|
519
|
+
|
|
520
|
+
const controller = new AbortController();
|
|
521
|
+
const timeout = setTimeout(() => controller.abort(), budgetMs);
|
|
522
|
+
const resolverSignal = AbortSignal.any([signal, controller.signal]);
|
|
523
|
+
|
|
524
|
+
try {
|
|
525
|
+
return await awaitWithAbort(
|
|
526
|
+
resolver.resolve({ url: target.url, signal: resolverSignal, network }),
|
|
527
|
+
resolverSignal,
|
|
528
|
+
);
|
|
529
|
+
} catch (error) {
|
|
530
|
+
if (signal.aborted) throw error;
|
|
531
|
+
|
|
532
|
+
return undefined;
|
|
533
|
+
} finally {
|
|
534
|
+
clearTimeout(timeout);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
446
538
|
/**
|
|
447
539
|
* Fetches a URL and returns the complete document with extracted content.
|
|
448
540
|
*
|
|
@@ -458,10 +550,12 @@ async function fetchDocument(
|
|
|
458
550
|
dependencies: FetchRemoteDependencies,
|
|
459
551
|
cached?: CompleteDocument,
|
|
460
552
|
allowEmptyContent = false,
|
|
553
|
+
retryRepresentationMiss = true,
|
|
461
554
|
): Promise<{ document: CompleteDocument; revalidated: boolean }> {
|
|
462
555
|
assertAbsoluteHttpUrlForFetch(normalizeGitHubRawUrl(rawUrl));
|
|
463
556
|
const controller = new AbortController();
|
|
464
557
|
const timeoutMs = dependencies.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
558
|
+
const deadline = Date.now() + timeoutMs;
|
|
465
559
|
const extractHtml = dependencies.extractHtml ?? extractHtmlToMarkdown;
|
|
466
560
|
let timedOut = false;
|
|
467
561
|
|
|
@@ -499,8 +593,10 @@ async function fetchDocument(
|
|
|
499
593
|
// Some servers route Markdown negotiation to a missing representation even
|
|
500
594
|
// though their HTML page exists (for example, Redis documentation URLs).
|
|
501
595
|
// Retry only these representation-related statuses, through the same
|
|
502
|
-
// validated redirect/network boundary, with HTML preferred.
|
|
503
|
-
|
|
596
|
+
// validated redirect/network boundary, with HTML preferred. Callers that probe
|
|
597
|
+
// for one exact representation opt out: HTML from that URL is never the
|
|
598
|
+
// representation they are looking for.
|
|
599
|
+
if (retryRepresentationMiss && [404, 406].includes(response.statusCode ?? 0)) {
|
|
504
600
|
response.resume();
|
|
505
601
|
({ target, response } = await requestFollowingRedirects(
|
|
506
602
|
target.url,
|
|
@@ -526,6 +622,48 @@ async function fetchDocument(
|
|
|
526
622
|
}
|
|
527
623
|
}
|
|
528
624
|
|
|
625
|
+
if (
|
|
626
|
+
isAwsWafChallengeResponse(response) &&
|
|
627
|
+
requestDependencies.solveAwsWafChallenges !== false
|
|
628
|
+
) {
|
|
629
|
+
const resolver = requestDependencies.awsWafResolver ?? nativeAwsWafResolver;
|
|
630
|
+
|
|
631
|
+
if (resolver) {
|
|
632
|
+
response.resume();
|
|
633
|
+
|
|
634
|
+
const solution = await resolveAwsWafToken(
|
|
635
|
+
resolver,
|
|
636
|
+
target,
|
|
637
|
+
controller.signal,
|
|
638
|
+
requestDependencies,
|
|
639
|
+
Math.floor(Math.max(0, deadline - Date.now()) / 2),
|
|
640
|
+
);
|
|
641
|
+
|
|
642
|
+
if (solution) {
|
|
643
|
+
const stored = requestDependencies.cookies?.set(
|
|
644
|
+
AWS_WAF_TOKEN_COOKIE,
|
|
645
|
+
solution.token,
|
|
646
|
+
target.url,
|
|
647
|
+
);
|
|
648
|
+
|
|
649
|
+
if (stored) {
|
|
650
|
+
// Retry once inside the same validated redirect and cookie boundary.
|
|
651
|
+
// Keep the browser identity that earned the token, and report a
|
|
652
|
+
// second challenge as blocked instead of looping.
|
|
653
|
+
const retryDependencies = solution.userAgent
|
|
654
|
+
? { ...requestDependencies, userAgent: solution.userAgent }
|
|
655
|
+
: requestDependencies;
|
|
656
|
+
|
|
657
|
+
({ target, response } = await requestFollowingRedirects(
|
|
658
|
+
target.url,
|
|
659
|
+
controller.signal,
|
|
660
|
+
retryDependencies,
|
|
661
|
+
));
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
529
667
|
if (response.statusCode === 304 && cached) {
|
|
530
668
|
response.resume();
|
|
531
669
|
const etag = responseHeader(response, "etag") ?? cached.validators?.etag;
|
|
@@ -566,13 +704,30 @@ async function fetchDocument(
|
|
|
566
704
|
}
|
|
567
705
|
}
|
|
568
706
|
|
|
707
|
+
/**
|
|
708
|
+
* Fetches a complete document for one URL.
|
|
709
|
+
*
|
|
710
|
+
* @param allowEmptyContent - Whether an empty body is acceptable
|
|
711
|
+
* @param retryRepresentationMiss - Whether a 404/406 may be retried as HTML. Probe
|
|
712
|
+
* callers that want one exact representation pass false.
|
|
713
|
+
*/
|
|
569
714
|
export async function fetchCompleteDocument(
|
|
570
715
|
rawUrl: string,
|
|
571
716
|
signal: AbortSignal | undefined,
|
|
572
717
|
dependencies: FetchRemoteDependencies,
|
|
573
718
|
allowEmptyContent = false,
|
|
719
|
+
retryRepresentationMiss = true,
|
|
574
720
|
): Promise<CompleteDocument> {
|
|
575
|
-
return (
|
|
721
|
+
return (
|
|
722
|
+
await fetchDocument(
|
|
723
|
+
rawUrl,
|
|
724
|
+
signal,
|
|
725
|
+
dependencies,
|
|
726
|
+
undefined,
|
|
727
|
+
allowEmptyContent,
|
|
728
|
+
retryRepresentationMiss,
|
|
729
|
+
)
|
|
730
|
+
).document;
|
|
576
731
|
}
|
|
577
732
|
|
|
578
733
|
/** Revalidates a stale representation with its ETag and Last-Modified validators. */
|
package/src/acquisition/llms.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { ExpiringLruCache } from "../cache";
|
|
2
2
|
import type { CompleteDocument } from "../document/content.ts";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
ChallengeBlockedError,
|
|
5
|
+
fetchCompleteDocument,
|
|
6
|
+
type FetchRemoteDependencies,
|
|
7
|
+
} from "./fetch.ts";
|
|
4
8
|
import { InflightCoalescer } from "../inflight";
|
|
5
9
|
|
|
6
10
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1_000;
|
|
@@ -97,8 +101,13 @@ interface LlmsTxtProbe {
|
|
|
97
101
|
|
|
98
102
|
const MAX_LLMS_TXT_PROBE_ENTRIES = 512;
|
|
99
103
|
|
|
100
|
-
/**
|
|
101
|
-
|
|
104
|
+
/**
|
|
105
|
+
* Deepest ancestor-directory `/llms.txt` probed, e.g. `/a/b/c` also probes
|
|
106
|
+
* `/a/b/llms.txt`. Locale-prefixed documentation sites publish their section index
|
|
107
|
+
* one level below the locale root, so two levels are needed to reach
|
|
108
|
+
* `/en/docs/llms.txt` from `/en/docs/catalog/...`.
|
|
109
|
+
*/
|
|
110
|
+
const MAX_LLMS_TXT_DIRECTORY_DEPTH = 2;
|
|
102
111
|
|
|
103
112
|
const llmsTxtProbes = new ExpiringLruCache<string, LlmsTxtProbe>(
|
|
104
113
|
MAX_LLMS_TXT_PROBE_ENTRIES,
|
|
@@ -179,10 +188,16 @@ async function probeUsableRawText(
|
|
|
179
188
|
if (existing) return existing;
|
|
180
189
|
|
|
181
190
|
try {
|
|
182
|
-
const document = await fetchCompleteDocument(
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
191
|
+
const document = await fetchCompleteDocument(
|
|
192
|
+
candidate.toString(),
|
|
193
|
+
sharedSignal,
|
|
194
|
+
{ ...dependencies, solveAnubisChallenges: false, solveAwsWafChallenges: false },
|
|
195
|
+
// An index probe never accepts an empty body.
|
|
196
|
+
false,
|
|
197
|
+
// A probe wants exactly this representation; retrying as HTML only burns
|
|
198
|
+
// a request on a URL that is already known to be missing.
|
|
199
|
+
false,
|
|
200
|
+
);
|
|
186
201
|
|
|
187
202
|
const available = isUsableLlmsTxtIndex(document) ? document : undefined;
|
|
188
203
|
const result = { document: available, expires: Date.now() + CACHE_TTL_MS };
|
|
@@ -226,8 +241,8 @@ function isSafeToProbe(candidate: URL | undefined, primaryUrl: string): boolean
|
|
|
226
241
|
*
|
|
227
242
|
* - Probes the site's `/llms.txt` index once per origin per TTL window, in parallel with
|
|
228
243
|
* the primary fetch, so later calls can advertise it.
|
|
229
|
-
* - Serves the index instead of the page when the page looks like an app shell
|
|
230
|
-
* too little readable text.
|
|
244
|
+
* - Serves the index instead of the page when the page looks like an app shell, carries
|
|
245
|
+
* too little readable text, or was withheld by a bot-protection challenge.
|
|
231
246
|
* - Otherwise annotates the page with the index URL so agents can discover sibling pages.
|
|
232
247
|
*
|
|
233
248
|
* A missing or useless index can never degrade the primary fetch.
|
|
@@ -247,16 +262,32 @@ export async function fetchDocumentWithLlmsTxtSupport(
|
|
|
247
262
|
|
|
248
263
|
const candidates = buildLlmsTxtCandidateUrls(rawUrl);
|
|
249
264
|
|
|
250
|
-
|
|
265
|
+
// A withheld page must not discard an index that is still usable, so the pair is
|
|
266
|
+
// settled instead of rejected together.
|
|
267
|
+
const [primarySettled, indexSettled] = await Promise.allSettled([
|
|
251
268
|
fetchCompleteDocument(rawUrl, signal, dependencies, true),
|
|
252
269
|
candidates.length > 0
|
|
253
270
|
? ensureLlmsTxtIndex(candidates, signal, dependencies)
|
|
254
271
|
: Promise.resolve(undefined),
|
|
255
272
|
]);
|
|
256
273
|
|
|
274
|
+
// An index probe only rejects when the caller cancels, which must still surface.
|
|
275
|
+
if (indexSettled.status === "rejected") throw indexSettled.reason;
|
|
276
|
+
|
|
277
|
+
if (primarySettled.status === "rejected") {
|
|
278
|
+
// A bot-protection layer replaced the page with a challenge that carries no
|
|
279
|
+
// content; the discovered index is the only remaining pointer to that content.
|
|
280
|
+
if (indexSettled.value && primarySettled.reason instanceof ChallengeBlockedError)
|
|
281
|
+
return { ...indexSettled.value.document, llmsTxtFallback: true };
|
|
282
|
+
|
|
283
|
+
throw primarySettled.reason;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const primary = primarySettled.value;
|
|
287
|
+
|
|
257
288
|
// A `describedby` advertisement names the covering index authoritatively per
|
|
258
289
|
// llmstxt.org v2, so it outranks the blind root/section probes whenever usable.
|
|
259
|
-
let index =
|
|
290
|
+
let index = indexSettled.value;
|
|
260
291
|
|
|
261
292
|
if (primary.llmsTxtDescribedBy) {
|
|
262
293
|
const describedBy = toAbsoluteUrl(primary.llmsTxtDescribedBy);
|