@zeldrisho/pi-web-fetch 0.15.0 → 0.16.1

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,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.16.1] - 2026-10-28
11
+
12
+ ### Changed
13
+
14
+ - Show focus queries in tool calls, and display expansion and continuation notices with clearer spacing.
15
+
16
+ ## [0.16.0] - 2026-10-28
17
+
18
+ ### Added
19
+
20
+ - Automatically resolve supported AWS WAF challenges when fetching pages.
21
+
10
22
  ## [0.15.0] - 2026-10-28
11
23
 
12
24
  ### Added
@@ -289,7 +301,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
289
301
 
290
302
  - Add bounded public page fetching ([b979496](https://github.com/zeldrisho/pi-packages/commit/b979496b32de1cead172ba570307e4a4a7b3421d))
291
303
 
292
- [Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.15.0...HEAD
304
+ [Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.16.1...HEAD
305
+ [0.16.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.16.0...pi-web-fetch-v0.16.1
306
+ [0.16.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.15.0...pi-web-fetch-v0.16.0
293
307
  [0.15.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.14.1...pi-web-fetch-v0.15.0
294
308
  [0.14.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.14.0...pi-web-fetch-v0.14.1
295
309
  [0.14.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.13.0...pi-web-fetch-v0.14.0
package/README.md CHANGED
@@ -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. Large fenced source files can be focused in line-aligned excerpts; excerpts are not necessarily valid standalone source. OpenAPI and Swagger JSON or YAML documents and JavaScript bundles containing literal specs can be reported as `openapi-spec`; detection is best-effort and limited to source bodies up to 512 KiB. 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
- 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.
28
+ For example, a URL such as `https://github.com/zeldrisho/pi-packages/blob/main/packages/pi-web-fetch/README.md#usage` 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. Markdown files served with a generic binary content type are read as text. Anubis proof-of-work challenges are supported, and a page withheld by an AWS WAF or Cloudflare bot challenge falls back to the site's documentation index when one exists. Other 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.15.0",
3
+ "version": "0.16.1",
4
4
  "description": "Pi extension for secure, bounded public web page fetching and Markdown extraction",
5
5
  "keywords": [
6
6
  "pi",
@@ -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,
@@ -89,6 +96,10 @@ export interface FetchRemoteDependencies extends RedirectDependencies {
89
96
  timeoutMs?: number;
90
97
  /** When false, an Anubis interstitial is reported as blocked instead of solved. Defaults to true. */
91
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;
92
103
  }
93
104
 
94
105
  /**
@@ -190,7 +201,7 @@ export class ChallengeBlockedError extends Error {
190
201
  function assertSuccessfulResponse(response: IncomingMessage): void {
191
202
  const status = response.statusCode ?? 0;
192
203
 
193
- if (responseHeader(response, "x-amzn-waf-action")?.trim().toLowerCase() === "challenge") {
204
+ if (isAwsWafChallengeResponse(response)) {
194
205
  response.resume();
195
206
  throw new ChallengeBlockedError(
196
207
  `web_fetch was blocked by an AWS WAF anti-bot challenge (HTTP ${status}).`,
@@ -493,6 +504,37 @@ function assertAbsoluteHttpUrlForFetch(value: string): URL {
493
504
  return url;
494
505
  }
495
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
+
496
538
  /**
497
539
  * Fetches a URL and returns the complete document with extracted content.
498
540
  *
@@ -513,6 +555,7 @@ async function fetchDocument(
513
555
  assertAbsoluteHttpUrlForFetch(normalizeGitHubRawUrl(rawUrl));
514
556
  const controller = new AbortController();
515
557
  const timeoutMs = dependencies.timeoutMs ?? REQUEST_TIMEOUT_MS;
558
+ const deadline = Date.now() + timeoutMs;
516
559
  const extractHtml = dependencies.extractHtml ?? extractHtmlToMarkdown;
517
560
  let timedOut = false;
518
561
 
@@ -579,6 +622,48 @@ async function fetchDocument(
579
622
  }
580
623
  }
581
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
+
582
667
  if (response.statusCode === 304 && cached) {
583
668
  response.resume();
584
669
  const etag = responseHeader(response, "etag") ?? cached.validators?.etag;
@@ -191,7 +191,7 @@ async function probeUsableRawText(
191
191
  const document = await fetchCompleteDocument(
192
192
  candidate.toString(),
193
193
  sharedSignal,
194
- { ...dependencies, solveAnubisChallenges: false },
194
+ { ...dependencies, solveAnubisChallenges: false, solveAwsWafChallenges: false },
195
195
  // An index probe never accepts an empty body.
196
196
  false,
197
197
  // A probe wants exactly this representation; retrying as HTML only burns
package/src/cache.ts CHANGED
@@ -39,6 +39,26 @@ export function stableKeyHash(key: string): string {
39
39
  return createHash("sha256").update(key).digest("hex");
40
40
  }
41
41
 
42
+ /** Hash canonical JSON, sorting object keys recursively while preserving array order. */
43
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- JSON serialization defines the accepted values.
44
+ export function canonicalKey(value: unknown): string {
45
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- JSON replacer receives arbitrary JSON values.
46
+ const json = JSON.stringify(value, (_key, entry: unknown) => {
47
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof -- recursively sort JSON objects.
48
+ if (entry !== null && typeof entry === "object" && !Array.isArray(entry)) {
49
+ return Object.fromEntries(
50
+ Object.entries(entry).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
51
+ );
52
+ }
53
+
54
+ return entry;
55
+ });
56
+
57
+ if (json === undefined) throw new TypeError("Cache key must be JSON serializable");
58
+
59
+ return stableKeyHash(json);
60
+ }
61
+
42
62
  /**
43
63
  * Resolves a cache entry's on-disk path and refuses to escape the cache
44
64
  * directory, guarding against a `keyToPath` that returns `..` segments. The