@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 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.14.1...HEAD
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. A query selects a focused view of the fetched page; omit it to read the complete document. Pi's preview renders Markdown with the active theme and is collapsed by default (`Ctrl+O` expands it). The call preview shows the URL; the focus query and character offset are not repeated there.
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. When an index is discovered, its URL is shown in the result so you can follow it if the fetched page is insufficient; when the index replaces the requested page, the result explains that fallback. Anubis proof-of-work challenges are supported; unsupported challenges and JavaScript CAPTCHAs return a blocked result. There is no browser fallback. Challenge cookies are not reused between fetches.
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.14.1",
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",
@@ -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
- value !== undefined && (value.startsWith("text/") || ALLOWED_CONTENT_TYPES.includes(value))
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 Error(`web_fetch was blocked by Cloudflare's anti-bot challenge (HTTP ${status}).`);
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
- markdown = `\`\`\`json\n${JSON.stringify(JSON.parse(raw), null, 2)}\n\`\`\``;
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
- markdown = raw;
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
- markdown = raw;
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
- if ([404, 406].includes(response.statusCode ?? 0)) {
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 (await fetchDocument(rawUrl, signal, dependencies, undefined, allowEmptyContent)).document;
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. */
@@ -1,6 +1,10 @@
1
1
  import { ExpiringLruCache } from "../cache";
2
2
  import type { CompleteDocument } from "../document/content.ts";
3
- import { fetchCompleteDocument, type FetchRemoteDependencies } from "./fetch.ts";
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
- /** Deepest ancestor-directory `/llms.txt` probed, e.g. `/r2/x/y` probes `/r2/llms.txt`. */
101
- const MAX_LLMS_TXT_DIRECTORY_DEPTH = 1;
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(candidate.toString(), sharedSignal, {
183
- ...dependencies,
184
- solveAnubisChallenges: false,
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 or carries
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
- const [primary, blindIndex] = await Promise.all([
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 = blindIndex;
290
+ let index = indexSettled.value;
260
291
 
261
292
  if (primary.llmsTxtDescribedBy) {
262
293
  const describedBy = toAbsoluteUrl(primary.llmsTxtDescribedBy);