@zeldrisho/pi-web-fetch 0.11.1 → 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 CHANGED
@@ -7,6 +7,19 @@ 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
+
10
23
  ## [0.11.1] - 2026-10-20
11
24
 
12
25
  ### Changed
@@ -233,7 +246,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
233
246
 
234
247
  - Add bounded public page fetching ([b979496](https://github.com/zeldrisho/pi-packages/commit/b979496b32de1cead172ba570307e4a4a7b3421d))
235
248
 
236
- [Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.11.1...HEAD
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
237
251
  [0.11.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.11.0...pi-web-fetch-v0.11.1
238
252
  [0.11.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.10.2...pi-web-fetch-v0.11.0
239
253
  [0.10.2]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.10.1...pi-web-fetch-v0.10.2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeldrisho/pi-web-fetch",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "description": "Pi extension for secure, bounded public web page fetching and Markdown extraction",
5
5
  "keywords": [
6
6
  "pi",
@@ -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,
@@ -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 (!root || root.tagName === "HTML") return;
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.tagName === "BODY" ? [...root.childNodes] : [...document.childNodes];
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) offsets[id] = textOffset;
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, resetLlmsNoticeOrigins } from "./service";
6
+ import { executeWebFetch } from "./service";
7
7
 
8
8
  export { ExpiringLruCache } from "./cache";
9
9
 
@@ -71,10 +71,6 @@ export const webFetchParameters = Type.Object({
71
71
  * @param pi - The extension API instance
72
72
  */
73
73
  export default function (pi: ExtensionAPI) {
74
- // SAFETY: The host provides `on`; minimal extension test doubles may omit it.
75
- (pi as ExtensionAPI & { on?: ExtensionAPI["on"] }).on?.("session_start", () =>
76
- resetLlmsNoticeOrigins(),
77
- );
78
74
  pi.registerTool({
79
75
  name: "web_fetch",
80
76
  label: "Web Fetch",
@@ -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 - Maximum time allowed for the connection attempt.
58
- * @returns The received response.
59
- * @throws If `address` is not a valid IPv4 or IPv6 address.
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: "text/markdown, text/html, text/plain, application/json;q=0.9, */*;q=0.1",
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
- /** Executes a validated web fetch, including caching, focusing, and bounded pagination. */
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 llmsNoticeOrigin = result.llmsTxtIndexUrl
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
- ? ["[The requested URL fragment was not found; showing the page from the beginning.]", ""]
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