@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 +15 -1
- package/README.md +3 -5
- package/package.json +1 -1
- package/src/acquisition/fetch.ts +86 -1
- package/src/acquisition/llms.ts +1 -1
- package/src/cache.ts +20 -0
- 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/index.ts +21 -8
- 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/render.ts +34 -9
- package/src/service.ts +5 -4
- package/workers/aws-waf-solver.js +56 -0
- package/workers/aws-waf-worker.js +14 -0
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.
|
|
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.
|
|
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/
|
|
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.
|
|
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
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,
|
|
@@ -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 (
|
|
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;
|
package/src/acquisition/llms.ts
CHANGED
|
@@ -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
|