@zeldrisho/pi-web-fetch 0.11.0 → 0.12.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 +26 -1
- package/package.json +1 -1
- package/src/acquisition/fetch.ts +19 -3
- package/src/document/extract.ts +13 -2
- package/src/document/fragment-index.ts +70 -1
- package/src/index.ts +4 -10
- package/src/network/network-redirects.ts +1 -1
- package/src/network/network-transport.ts +8 -4
- package/src/service.ts +55 -16
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.12.0] - 2026-10-23
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Show discovered `llms.txt` indexes on every fetch result so the index remains visible when fetching multiple pages from the same origin.
|
|
15
|
+
- Prefer native Markdown representations during content negotiation while retaining supported text formats.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Suggest links to pages containing a requested fragment when it is absent from the current page.
|
|
20
|
+
- Handle empty and text-only HTML fragments without a null-document parser crash.
|
|
21
|
+
- Resolve section wrapper fragments when extracted headings are omitted.
|
|
22
|
+
|
|
23
|
+
## [0.11.1] - 2026-10-20
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- Simplify the model-facing fetch tool and parameter descriptions.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- Retry 404 and 406 responses to Markdown-negotiated requests with HTML preferred, allowing pages whose HTML representation is available to fetch successfully.
|
|
32
|
+
|
|
10
33
|
## [0.11.0] - 2026-10-19
|
|
11
34
|
|
|
12
35
|
### Added
|
|
@@ -223,7 +246,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
223
246
|
|
|
224
247
|
- Add bounded public page fetching ([b979496](https://github.com/zeldrisho/pi-packages/commit/b979496b32de1cead172ba570307e4a4a7b3421d))
|
|
225
248
|
|
|
226
|
-
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.
|
|
249
|
+
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.12.0...HEAD
|
|
250
|
+
[0.12.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.11.1...pi-web-fetch-v0.12.0
|
|
251
|
+
[0.11.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.11.0...pi-web-fetch-v0.11.1
|
|
227
252
|
[0.11.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.10.2...pi-web-fetch-v0.11.0
|
|
228
253
|
[0.10.2]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.10.1...pi-web-fetch-v0.10.2
|
|
229
254
|
[0.10.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.10.0...pi-web-fetch-v0.10.1
|
package/package.json
CHANGED
package/src/acquisition/fetch.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
|
|
9
9
|
export { normalizeGitHubRawUrl, parseLinkHeaderForAgentHints } from "./fetch-metadata.ts";
|
|
10
10
|
|
|
11
|
-
import { awaitWithAbort } from "../abort";
|
|
11
|
+
import { awaitWithAbort } from "../abort.ts";
|
|
12
12
|
import {
|
|
13
13
|
hasAnubisMarkers,
|
|
14
14
|
parseAnubisChallenge,
|
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
type RedirectDependencies,
|
|
32
32
|
} from "../network/network-redirects.ts";
|
|
33
33
|
import type { ValidatedTarget } from "../network/network-policy.ts";
|
|
34
|
-
import { redactUrlForDisplay } from "../redact";
|
|
34
|
+
import { redactUrlForDisplay } from "../redact.ts";
|
|
35
35
|
import {
|
|
36
36
|
decodeResponse,
|
|
37
37
|
decompressResponse,
|
|
@@ -478,13 +478,29 @@ async function fetchDocument(
|
|
|
478
478
|
};
|
|
479
479
|
|
|
480
480
|
try {
|
|
481
|
+
const requestUrl = normalizeGitHubRawUrl(rawUrl);
|
|
482
|
+
|
|
481
483
|
let { target, response } = await requestFollowingRedirects(
|
|
482
|
-
|
|
484
|
+
requestUrl,
|
|
483
485
|
controller.signal,
|
|
484
486
|
requestDependencies,
|
|
485
487
|
conditionalHeaders,
|
|
486
488
|
);
|
|
487
489
|
|
|
490
|
+
// Some servers route Markdown negotiation to a missing representation even
|
|
491
|
+
// though their HTML page exists (for example, Redis documentation URLs).
|
|
492
|
+
// Retry only these representation-related statuses, through the same
|
|
493
|
+
// validated redirect/network boundary, with HTML preferred.
|
|
494
|
+
if ([404, 406].includes(response.statusCode ?? 0)) {
|
|
495
|
+
response.resume();
|
|
496
|
+
({ target, response } = await requestFollowingRedirects(
|
|
497
|
+
target.url,
|
|
498
|
+
controller.signal,
|
|
499
|
+
requestDependencies,
|
|
500
|
+
{ Accept: "text/html, text/plain;q=0.9, */*;q=0.1" },
|
|
501
|
+
));
|
|
502
|
+
}
|
|
503
|
+
|
|
488
504
|
if (
|
|
489
505
|
response.statusCode !== undefined &&
|
|
490
506
|
responseHeader(response, "cf-mitigated")?.trim().toLowerCase() === "challenge"
|
package/src/document/extract.ts
CHANGED
|
@@ -82,11 +82,13 @@ function isElementNode(node: ChildNode): node is Element {
|
|
|
82
82
|
* HTML5 permits those omissions, but body-based extraction (Defuddle and the plain-text
|
|
83
83
|
* fallback) then sees nothing. Rebuild a well-formed `<html>` document, moving the
|
|
84
84
|
* fragment's top-level nodes into `<head>` or `<body>` as appropriate.
|
|
85
|
+
* Empty and text-only documents also receive these wrappers, with text moved
|
|
86
|
+
* into the body. Documents already rooted at `<html>` are left unchanged.
|
|
85
87
|
*/
|
|
86
88
|
function normalizeFragmentDocument(document: Document): void {
|
|
87
89
|
const root = document.documentElement;
|
|
88
90
|
|
|
89
|
-
if (
|
|
91
|
+
if (root?.tagName === "HTML") return;
|
|
90
92
|
|
|
91
93
|
const html = document.createElement("html");
|
|
92
94
|
const head = document.createElement("head");
|
|
@@ -94,9 +96,18 @@ function normalizeFragmentDocument(document: Document): void {
|
|
|
94
96
|
html.appendChild(head);
|
|
95
97
|
html.appendChild(body);
|
|
96
98
|
|
|
97
|
-
const nodes = root
|
|
99
|
+
const nodes = root
|
|
100
|
+
? root.tagName === "BODY"
|
|
101
|
+
? [...root.childNodes]
|
|
102
|
+
: [...document.childNodes]
|
|
103
|
+
: [...document.childNodes];
|
|
98
104
|
|
|
99
105
|
for (const node of nodes) {
|
|
106
|
+
if (node.nodeType === 3) {
|
|
107
|
+
body.appendChild(node);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
100
111
|
if (!isElementNode(node)) continue;
|
|
101
112
|
const tagName = node.tagName;
|
|
102
113
|
|
|
@@ -36,6 +36,53 @@ interface FragmentOffsets {
|
|
|
36
36
|
[fragment: string]: number;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
interface NormalizedMarkdown {
|
|
40
|
+
text: string;
|
|
41
|
+
offsets: number[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Collapses whitespace and lowercases Markdown while retaining the source
|
|
46
|
+
* offset of every resulting character.
|
|
47
|
+
*/
|
|
48
|
+
function normalizeMarkdown(markdown: string): NormalizedMarkdown {
|
|
49
|
+
const characters: string[] = [];
|
|
50
|
+
const offsets: number[] = [];
|
|
51
|
+
let index = 0;
|
|
52
|
+
|
|
53
|
+
while (index < markdown.length) {
|
|
54
|
+
const character = markdown[index] ?? "";
|
|
55
|
+
|
|
56
|
+
if (/\s/u.test(character)) {
|
|
57
|
+
let end = index + 1;
|
|
58
|
+
|
|
59
|
+
while (end < markdown.length && /\s/u.test(markdown[end] ?? "")) end += 1;
|
|
60
|
+
|
|
61
|
+
characters.push(" ");
|
|
62
|
+
offsets.push(index);
|
|
63
|
+
index = end;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (const lowered of character.toLowerCase()) {
|
|
68
|
+
characters.push(lowered);
|
|
69
|
+
offsets.push(index);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
index += 1;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { text: characters.join(""), offsets };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Maps HTML ids, named anchors, and Markdown heading slugs to extracted Markdown positions.
|
|
80
|
+
* HTML anchors match headings or retained text, including section descendants;
|
|
81
|
+
* anchors with no matching content are omitted.
|
|
82
|
+
*
|
|
83
|
+
* @param originalIds - Original ids for elements renamed during extraction; override current ids
|
|
84
|
+
* @returns Zero-based UTF-16 code-unit offsets into `markdown`, keyed by fragment without `#`
|
|
85
|
+
*/
|
|
39
86
|
export function buildFragmentOffsets(
|
|
40
87
|
document: Document,
|
|
41
88
|
markdown: string,
|
|
@@ -58,6 +105,7 @@ export function buildFragmentOffsets(
|
|
|
58
105
|
|
|
59
106
|
const offsets: Record<string, number> = {};
|
|
60
107
|
const used = new Set<number>();
|
|
108
|
+
let normalizedMarkdown: NormalizedMarkdown | undefined;
|
|
61
109
|
|
|
62
110
|
for (const element of document.querySelectorAll<HTMLElement>("[id], a[name]")) {
|
|
63
111
|
const id =
|
|
@@ -82,7 +130,28 @@ export function buildFragmentOffsets(
|
|
|
82
130
|
if (text) {
|
|
83
131
|
const textOffset = markdown.toLowerCase().indexOf(text.toLowerCase());
|
|
84
132
|
|
|
85
|
-
if (textOffset >= 0)
|
|
133
|
+
if (textOffset >= 0) {
|
|
134
|
+
offsets[id] = textOffset;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Section wrapper IDs often precede headings omitted by extraction. Fall
|
|
140
|
+
// back to the first retained paragraph/list/code block inside the section.
|
|
141
|
+
for (const descendant of element.querySelectorAll<HTMLElement>("p, li, pre, blockquote")) {
|
|
142
|
+
const content = (descendant.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
143
|
+
|
|
144
|
+
if (content.length < 12) continue;
|
|
145
|
+
normalizedMarkdown ??= normalizeMarkdown(markdown);
|
|
146
|
+
const normalizedContent = content.toLowerCase();
|
|
147
|
+
const position = normalizedMarkdown.text.indexOf(normalizedContent);
|
|
148
|
+
|
|
149
|
+
if (position >= 0) {
|
|
150
|
+
const offset = normalizedMarkdown.offsets[position];
|
|
151
|
+
|
|
152
|
+
if (offset !== undefined) offsets[id] = offset;
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
86
155
|
}
|
|
87
156
|
}
|
|
88
157
|
}
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { Text } from "@earendil-works/pi-tui";
|
|
|
3
3
|
import { Type } from "typebox";
|
|
4
4
|
import { redactUrlForDisplay } from "./redact";
|
|
5
5
|
import { formatCollapsibleOutput } from "./render";
|
|
6
|
-
import { executeWebFetch
|
|
6
|
+
import { executeWebFetch } from "./service";
|
|
7
7
|
|
|
8
8
|
export { ExpiringLruCache } from "./cache";
|
|
9
9
|
|
|
@@ -61,10 +61,8 @@ export {
|
|
|
61
61
|
|
|
62
62
|
export const webFetchParameters = Type.Object({
|
|
63
63
|
url: Type.String(),
|
|
64
|
-
query: Type.Optional(Type.String({ description: "
|
|
65
|
-
offset: Type.Optional(
|
|
66
|
-
Type.Integer({ description: "Character offset; use nextOffset to continue." }),
|
|
67
|
-
),
|
|
64
|
+
query: Type.Optional(Type.String({ description: "Find sections matching this text." })),
|
|
65
|
+
offset: Type.Optional(Type.Integer({ description: "Start at this character offset." })),
|
|
68
66
|
});
|
|
69
67
|
|
|
70
68
|
/**
|
|
@@ -73,14 +71,10 @@ export const webFetchParameters = Type.Object({
|
|
|
73
71
|
* @param pi - The extension API instance
|
|
74
72
|
*/
|
|
75
73
|
export default function (pi: ExtensionAPI) {
|
|
76
|
-
// SAFETY: The host provides `on`; minimal extension test doubles may omit it.
|
|
77
|
-
(pi as ExtensionAPI & { on?: ExtensionAPI["on"] }).on?.("session_start", () =>
|
|
78
|
-
resetLlmsNoticeOrigins(),
|
|
79
|
-
);
|
|
80
74
|
pi.registerTool({
|
|
81
75
|
name: "web_fetch",
|
|
82
76
|
label: "Web Fetch",
|
|
83
|
-
description: "
|
|
77
|
+
description: "Read a public webpage as Markdown.",
|
|
84
78
|
parameters: webFetchParameters,
|
|
85
79
|
|
|
86
80
|
renderCall(args, theme) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { IncomingMessage } from "node:http";
|
|
2
|
-
import { awaitWithAbort } from "../abort";
|
|
2
|
+
import { awaitWithAbort } from "../abort.ts";
|
|
3
3
|
import type { CookieJar } from "./cookies.ts";
|
|
4
4
|
import { validateRemoteUrl, type ValidatedTarget } from "./network-policy.ts";
|
|
5
5
|
import { requestPinned, responseHeader } from "./network-transport.ts";
|
|
@@ -51,12 +51,15 @@ function responseTooLargeMessage(
|
|
|
51
51
|
/**
|
|
52
52
|
* Sends a request to a specific validated network address.
|
|
53
53
|
*
|
|
54
|
+
* The default Accept header prefers Markdown over other supported text formats.
|
|
55
|
+
*
|
|
54
56
|
* @param target - The validated request target.
|
|
55
57
|
* @param address - The IPv4 or IPv6 address to use for the connection.
|
|
56
58
|
* @param signal - Signal used to cancel the request.
|
|
57
|
-
* @param attemptTimeoutMs -
|
|
58
|
-
* @
|
|
59
|
-
* @
|
|
59
|
+
* @param attemptTimeoutMs - Deadline in milliseconds until response headers arrive.
|
|
60
|
+
* @param headers - Request headers that can override the default Accept and User-Agent headers.
|
|
61
|
+
* @returns The response once headers arrive, regardless of HTTP status; its body is not read.
|
|
62
|
+
* @throws If the address is invalid, the deadline expires, the signal aborts, or the HTTP request fails.
|
|
60
63
|
*/
|
|
61
64
|
async function requestOnce(
|
|
62
65
|
target: ValidatedTarget,
|
|
@@ -113,7 +116,8 @@ async function requestOnce(
|
|
|
113
116
|
lookup,
|
|
114
117
|
signal: controller.signal,
|
|
115
118
|
headers: {
|
|
116
|
-
Accept:
|
|
119
|
+
Accept:
|
|
120
|
+
"text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, application/xhtml+xml;q=0.7, application/json;q=0.7, application/xml;q=0.7, */*;q=0.1",
|
|
117
121
|
"User-Agent": "Mozilla/5.0 (compatible; PiWebFetch/1.0; +https://pi.dev)",
|
|
118
122
|
...headers,
|
|
119
123
|
},
|
package/src/service.ts
CHANGED
|
@@ -193,13 +193,6 @@ export interface WebFetchDetails {
|
|
|
193
193
|
fragment?: { requested: string; matched: boolean; offset?: number };
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
const llmsNoticeOrigins = new Set<string>();
|
|
197
|
-
|
|
198
|
-
/** Clears per-origin notice history so subsequent fetches can show llms.txt index notices again. */
|
|
199
|
-
export function resetLlmsNoticeOrigins(): void {
|
|
200
|
-
llmsNoticeOrigins.clear();
|
|
201
|
-
}
|
|
202
|
-
|
|
203
196
|
interface WebFetchUpdate {
|
|
204
197
|
content: Array<{ type: "text"; text: string }>;
|
|
205
198
|
details: Record<string, never>;
|
|
@@ -286,7 +279,24 @@ interface FetchAcquisition {
|
|
|
286
279
|
|
|
287
280
|
const inflightFetches = new InflightCoalescer<string, FetchAcquisition>(MAX_INFLIGHT_REQUESTS);
|
|
288
281
|
|
|
289
|
-
/**
|
|
282
|
+
/**
|
|
283
|
+
* Executes a validated web fetch, including caching, focusing, and bounded pagination.
|
|
284
|
+
*
|
|
285
|
+
* A URL fragment selects the starting heading, anchor, or source range unless
|
|
286
|
+
* an explicit offset is supplied. A query selects sections before pagination.
|
|
287
|
+
* Missing fragments may suggest up to three links to other pages with that
|
|
288
|
+
* fragment. Discovered llms.txt index URLs appear on every unblocked result,
|
|
289
|
+
* including cache hits. Detected bot-wall documents return a blocked result
|
|
290
|
+
* without page content.
|
|
291
|
+
*
|
|
292
|
+
* @param params - Fetch options; offsets and character limits count UTF-16 code units
|
|
293
|
+
* @param params.offset - Integer from 0 to 20,000,000; applies to the focused view when querying
|
|
294
|
+
* @param params.maxCharacters - Integer from 1,000 to 30,000; defaults to 6,000 before line/byte limits
|
|
295
|
+
* @param signal - Cancels acquisition; fresh cache hits do not consult this signal
|
|
296
|
+
* @param onUpdate - Receives cache or acquisition progress before content is returned
|
|
297
|
+
* @returns Bounded text output and metadata, including cache, fragment, and continuation details
|
|
298
|
+
* @throws For invalid parameter limits; acquisition, cancellation, and progress callback errors propagate
|
|
299
|
+
*/
|
|
290
300
|
export async function executeWebFetch(
|
|
291
301
|
params: WebFetchParameters,
|
|
292
302
|
signal: AbortSignal | undefined,
|
|
@@ -404,6 +414,35 @@ export async function executeWebFetch(
|
|
|
404
414
|
const resolvedFragment = resolveFragmentOffset(document, params.url);
|
|
405
415
|
const fragmentOffset = resolvedFragment.offset;
|
|
406
416
|
const fragmentMatched = resolvedFragment.fragment !== undefined && fragmentOffset !== undefined;
|
|
417
|
+
|
|
418
|
+
const fragmentSuggestions =
|
|
419
|
+
resolvedFragment.fragment !== undefined && !fragmentMatched
|
|
420
|
+
? [
|
|
421
|
+
...(document.links?.internal ?? []).map((link) => link.url),
|
|
422
|
+
...(document.links?.external ?? []).map((link) => link.url),
|
|
423
|
+
...[...document.markdown.matchAll(/\]\(([^)]+)\)/g)].map((match) => match[1]),
|
|
424
|
+
]
|
|
425
|
+
.filter((url) => {
|
|
426
|
+
try {
|
|
427
|
+
const linkedUrl = new URL(url, document.url);
|
|
428
|
+
|
|
429
|
+
if (decodeURIComponent(linkedUrl.hash.slice(1)) !== resolvedFragment.fragment) {
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
linkedUrl.hash = "";
|
|
434
|
+
const sourceUrl = new URL(document.url);
|
|
435
|
+
sourceUrl.hash = "";
|
|
436
|
+
|
|
437
|
+
return linkedUrl.href !== sourceUrl.href;
|
|
438
|
+
} catch {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
})
|
|
442
|
+
.slice(0, 3)
|
|
443
|
+
.map((url) => redactUrlForDisplay(new URL(url, document.url).href))
|
|
444
|
+
: [];
|
|
445
|
+
|
|
407
446
|
const startingOffset = explicitOffset ?? fragmentOffset ?? FETCH_DEFAULT_OFFSET;
|
|
408
447
|
|
|
409
448
|
const hasFragmentRange =
|
|
@@ -460,13 +499,7 @@ export async function executeWebFetch(
|
|
|
460
499
|
const contentKind = classifyContentKind(rawFinalUrl, result.extractor, shellSuspected);
|
|
461
500
|
const confidence = classifyConfidence(result.extractor, shellSuspected, result.markdown.length);
|
|
462
501
|
|
|
463
|
-
const
|
|
464
|
-
? new URL(result.llmsTxtIndexUrl).origin
|
|
465
|
-
: undefined;
|
|
466
|
-
|
|
467
|
-
const showLlmsNotice = Boolean(llmsNoticeOrigin && !llmsNoticeOrigins.has(llmsNoticeOrigin));
|
|
468
|
-
|
|
469
|
-
if (showLlmsNotice && llmsNoticeOrigin) llmsNoticeOrigins.add(llmsNoticeOrigin);
|
|
502
|
+
const showLlmsNotice = Boolean(result.llmsTxtIndexUrl);
|
|
470
503
|
|
|
471
504
|
const output = blocked
|
|
472
505
|
? "Blocked by an anti-bot challenge. Requested page content is unavailable."
|
|
@@ -489,7 +522,13 @@ export async function executeWebFetch(
|
|
|
489
522
|
? [`llms.txt index: ${redactUrlForDisplay(result.llmsTxtIndexUrl)}`, ""]
|
|
490
523
|
: []),
|
|
491
524
|
...(resolvedFragment.fragment !== undefined && !fragmentMatched
|
|
492
|
-
? [
|
|
525
|
+
? [
|
|
526
|
+
"[The requested URL fragment was not found; showing the page from the beginning.]",
|
|
527
|
+
...(fragmentSuggestions.length
|
|
528
|
+
? [`[This page links to the requested fragment: ${fragmentSuggestions.join(", ")}]`]
|
|
529
|
+
: []),
|
|
530
|
+
"",
|
|
531
|
+
]
|
|
493
532
|
: []),
|
|
494
533
|
...(fragmentMatched ? ["[Starting at the requested URL fragment.]", ""] : []),
|
|
495
534
|
...(focused
|