@zeldrisho/pi-web-fetch 0.8.2 → 0.9.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,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.1] - 2026-09-22
11
+
12
+ ### Changed
13
+
14
+ - Upgrade Defuddle to `^0.19.4` for improved metadata and Markdown extraction, HTML sanitization, SVG safety, and support for declarative shadow DOM and MathML content
15
+
16
+ ### Fixed
17
+
18
+ - Extract HTML correctly when servers send duplicated equivalent `Content-Type` media types, and reject conflicting media-type declarations instead of returning raw HTML
19
+
20
+ ## [0.9.0] - 2026-09-17
21
+
22
+ ### Added
23
+
24
+ - Support URL fragments for starting fetched content at matching headings or anchors; explicit offsets take precedence and fragment variants share cached page acquisitions
25
+
10
26
  ## [0.8.2] - 2026-09-12
11
27
 
12
28
  ### Fixed
@@ -142,7 +158,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
142
158
 
143
159
  - Add bounded public page fetching ([b979496](https://github.com/zeldrisho/pi-packages/commit/b979496b32de1cead172ba570307e4a4a7b3421d))
144
160
 
145
- [Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.8.2...HEAD
161
+ [Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.9.1...HEAD
162
+ [0.9.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.9.0...pi-web-fetch-v0.9.1
163
+ [0.9.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.8.2...pi-web-fetch-v0.9.0
146
164
  [0.8.2]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.8.1...pi-web-fetch-v0.8.2
147
165
  [0.8.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.8.0...pi-web-fetch-v0.8.1
148
166
  [0.8.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.7.1...pi-web-fetch-v0.8.0
package/README.md CHANGED
@@ -14,7 +14,7 @@ pi install -l npm:@zeldrisho/pi-web-fetch
14
14
 
15
15
  `web_fetch` accepts public HTTP and HTTPS URLs and supports HTML, Markdown, plain text, JSON, and XML. HTML is converted with Defuddle, with a basic text extractor as fallback. URLs with credentials, local/private/reserved targets, unsafe redirects, responses over 5 MiB, or unsupported content types are rejected.
16
16
 
17
- `maxCharacters` bounds returned Markdown (default 6,000); it does not change the raw download limit. `offset` is a character offset into extracted content. For long pages, pass `query` to get a deterministic, source-ordered focused view selected locally after the complete page is fetched and cached. `details.focus` reports matched, total, and omitted sections plus character counts. Omit `query` to read the complete document. Use a returned `nextOffset` for continuation.
17
+ `maxCharacters` bounds returned Markdown (default 6,000); it does not change the raw download limit. `offset` is a character offset into extracted content. A URL fragment such as `https://github.com/shadcn-ui/lint#get-started` starts at the matching heading or anchor (the complete page is still fetched and cached); an explicit `offset` takes precedence. For long pages, pass `query` to get a deterministic, source-ordered focused view selected locally after the complete page is fetched and cached. `details.focus` reports matched, total, and omitted sections plus character counts. Omit `query` to read the complete document. Use a returned `nextOffset` for continuation.
18
18
 
19
19
  Results include `details.truncation`: complete output has `{ truncated: false, strategy: "none" }`; continuation output has `strategy: "continuation"` and a valid `nextOffset`. Compatibility fields `details.truncated` and `details.nextOffset` remain available. Pi's interactive preview is collapsed by default (`Ctrl+O` expands it); tool output is also capped at 2,000 lines / 50 KiB.
20
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeldrisho/pi-web-fetch",
3
- "version": "0.8.2",
3
+ "version": "0.9.1",
4
4
  "description": "Pi extension for secure, bounded public web page fetching and Markdown extraction",
5
5
  "keywords": [
6
6
  "pi",
@@ -22,25 +22,23 @@
22
22
  },
23
23
  "files": [
24
24
  "src",
25
- "README.md",
26
- "CHANGELOG.md",
27
- "LICENSE"
25
+ "CHANGELOG.md"
28
26
  ],
29
27
  "type": "module",
30
28
  "publishConfig": {
31
29
  "access": "public"
32
30
  },
33
31
  "dependencies": {
34
- "defuddle": "^0.19.3",
32
+ "defuddle": "^0.19.4",
35
33
  "linkedom": "^0.18.13"
36
34
  },
37
35
  "devDependencies": {
38
- "@earendil-works/pi-coding-agent": "^0.84.2",
39
- "@earendil-works/pi-tui": "^0.84.2",
40
- "typebox": "^1.3.16",
36
+ "@earendil-works/pi-coding-agent": "^0.85.1",
37
+ "@earendil-works/pi-tui": "^0.85.1",
38
+ "typebox": "^1.3.30",
41
39
  "typescript": "^7.0.2",
42
- "vite": "npm:@voidzero-dev/vite-plus-core@0.3.0",
43
- "vite-plus": "0.3.0"
40
+ "vite": "npm:@voidzero-dev/vite-plus-core@0.3.3",
41
+ "vite-plus": "0.3.3"
44
42
  },
45
43
  "peerDependencies": {
46
44
  "@earendil-works/pi-coding-agent": "*",
package/src/abort.ts CHANGED
@@ -8,12 +8,14 @@
8
8
  export function awaitWithAbort<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
9
9
  return new Promise((resolve, reject) => {
10
10
  let settled = false;
11
+
11
12
  const finish = (callback: () => void): void => {
12
13
  if (settled) return;
13
14
  settled = true;
14
15
  signal.removeEventListener("abort", abort);
15
16
  callback();
16
17
  };
18
+
17
19
  const abort = (): void => {
18
20
  const error = new Error("Operation aborted.");
19
21
  error.name = "AbortError";
@@ -24,6 +26,7 @@ export function awaitWithAbort<T>(operation: Promise<T>, signal: AbortSignal): P
24
26
  (value) => finish(() => resolve(value)),
25
27
  (error) => finish(() => reject(error)),
26
28
  );
29
+
27
30
  if (signal.aborted) abort();
28
31
  else signal.addEventListener("abort", abort, { once: true });
29
32
  });
package/src/cache.ts CHANGED
@@ -48,9 +48,11 @@ export function stableKeyHash(key: string): string {
48
48
  export function resolveCachePath(directory: string, keyPath: string): string {
49
49
  const base = resolve(directory);
50
50
  const full = resolve(base, keyPath);
51
+
51
52
  if (full !== base && !full.startsWith(base + sep)) {
52
53
  throw new Error(`Refusing to write cache entry outside ${base}: ${keyPath}`);
53
54
  }
55
+
54
56
  return full;
55
57
  }
56
58
 
@@ -63,10 +65,12 @@ export function resolveCachePath(directory: string, keyPath: string): string {
63
65
  function encodeExpiresAt(expiresAt: number): Uint8Array {
64
66
  const out = new Uint8Array(8);
65
67
  let value = BigInt(Math.round(expiresAt));
68
+
66
69
  for (let index = 7; index >= 0; index -= 1) {
67
70
  out[index] = Number(value & 0xffn);
68
71
  value >>= 8n;
69
72
  }
73
+
70
74
  return out;
71
75
  }
72
76
 
@@ -78,7 +82,9 @@ function encodeExpiresAt(expiresAt: number): Uint8Array {
78
82
  */
79
83
  function decodeExpiresAt(bytes: Uint8Array): number {
80
84
  let value = 0n;
85
+
81
86
  for (let index = 0; index < 8; index += 1) value = (value << 8n) | BigInt(bytes[index]);
87
+
82
88
  return Number(value);
83
89
  }
84
90
 
@@ -123,19 +129,26 @@ export class ExpiringLruCache<K, V> {
123
129
  */
124
130
  get(key: K): V | undefined {
125
131
  const entry = this.#entries.get(key);
132
+
126
133
  if (entry) {
127
134
  if (entry.expiresAt <= this.now()) {
128
135
  this.#delete(key);
136
+
129
137
  return undefined;
130
138
  }
139
+
131
140
  this.#entries.delete(key);
132
141
  this.#entries.set(key, entry);
142
+
133
143
  return entry.value;
134
144
  }
145
+
135
146
  if (this.persistence) {
136
147
  const loaded = this.#loadFromDisk(key);
148
+
137
149
  if (loaded !== undefined) return loaded.value;
138
150
  }
151
+
139
152
  return undefined;
140
153
  }
141
154
 
@@ -150,13 +163,16 @@ export class ExpiringLruCache<K, V> {
150
163
  set(key: K, value: V, expiresAt: number): boolean {
151
164
  this.#delete(key);
152
165
  const size = this.sizeOf(value);
166
+
153
167
  if (size > this.maxBytes) return false;
154
168
 
155
169
  this.#entries.set(key, { expiresAt, size, value });
156
170
  this.#byteSize += size;
157
171
  this.#evict();
158
172
  const stored = this.#entries.has(key);
173
+
159
174
  if (stored && this.persistence) this.#writeToDisk(key, value, expiresAt);
175
+
160
176
  return stored;
161
177
  }
162
178
 
@@ -164,6 +180,7 @@ export class ExpiringLruCache<K, V> {
164
180
  let bytes: Uint8Array;
165
181
  const path = resolveCachePath(this.persistence!.directory, this.persistence!.keyToPath(key));
166
182
  let fileSize: number;
183
+
167
184
  try {
168
185
  // Serialized metadata can exceed the value budget, but never allow an
169
186
  // unbounded cache file to be read into memory.
@@ -171,37 +188,50 @@ export class ExpiringLruCache<K, V> {
171
188
  } catch {
172
189
  return undefined;
173
190
  }
191
+
174
192
  if (fileSize < 8 || fileSize > this.maxBytes * 2 + 65_536) {
175
193
  this.#removeFromDisk(key);
194
+
176
195
  return undefined;
177
196
  }
197
+
178
198
  try {
179
199
  bytes = readFileSync(path);
180
200
  } catch {
181
201
  return undefined;
182
202
  }
203
+
183
204
  let entry: ExpiringCacheEntry<V>;
205
+
184
206
  try {
185
207
  const expiresAt = decodeExpiresAt(bytes);
186
208
  const value = this.persistence!.deserialize(bytes.subarray(8));
209
+
187
210
  if (this.persistence!.validate && !this.persistence!.validate(value)) {
188
211
  throw new Error("invalid cache entry");
189
212
  }
213
+
190
214
  const size = this.sizeOf(value);
215
+
191
216
  if (!Number.isFinite(size) || size < 0 || size > this.maxBytes)
192
217
  throw new Error("oversized cache entry");
193
218
  entry = { expiresAt, size, value };
194
219
  } catch {
195
220
  this.#removeFromDisk(key);
221
+
196
222
  return undefined;
197
223
  }
224
+
198
225
  if (entry.expiresAt <= this.now()) {
199
226
  this.#removeFromDisk(key);
227
+
200
228
  return undefined;
201
229
  }
230
+
202
231
  this.#entries.set(key, entry);
203
232
  this.#byteSize += entry.size;
204
233
  this.#evict();
234
+
205
235
  return this.#entries.get(key);
206
236
  }
207
237
 
@@ -211,10 +241,12 @@ export class ExpiringLruCache<K, V> {
211
241
  mkdirSync(directory, { recursive: true, mode: 0o700 });
212
242
  chmodSync(directory, 0o700);
213
243
  const payload = this.persistence!.serialize(value);
244
+
214
245
  if (payload.byteLength > this.maxBytes * 2 + 65_536)
215
246
  throw new Error("oversized cache payload");
216
247
  const path = resolveCachePath(directory, this.persistence!.keyToPath(key));
217
248
  const temp = `${path}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
249
+
218
250
  try {
219
251
  writeFileSync(temp, Buffer.concat([encodeExpiresAt(expiresAt), payload]), { mode: 0o600 });
220
252
  chmodSync(temp, 0o600);
@@ -241,27 +273,35 @@ export class ExpiringLruCache<K, V> {
241
273
 
242
274
  #maintainDisk(): void {
243
275
  const persistence = this.persistence!;
276
+
244
277
  try {
245
278
  const files = readdirSync(persistence.directory);
246
279
  const candidates: Array<{ path: string; size: number; mtime: number }> = [];
280
+
247
281
  for (const file of files) {
248
282
  const path = resolveCachePath(persistence.directory, file);
283
+
249
284
  if (file.endsWith(".tmp")) {
250
285
  try {
251
286
  unlinkSync(path);
252
287
  } catch {
253
288
  // Best effort cleanup.
254
289
  }
290
+
255
291
  continue;
256
292
  }
293
+
257
294
  try {
258
295
  const stat = statSync(path);
296
+
259
297
  if (!stat.isFile() || stat.size < 8 || stat.size > this.maxBytes * 2 + 65_536) {
260
298
  unlinkSync(path);
261
299
  continue;
262
300
  }
301
+
263
302
  const descriptor = openSync(path, "r");
264
303
  const header = new Uint8Array(8);
304
+
265
305
  try {
266
306
  if (
267
307
  readSync(descriptor, header, 0, 8, 0) !== 8 ||
@@ -273,17 +313,22 @@ export class ExpiringLruCache<K, V> {
273
313
  } finally {
274
314
  closeSync(descriptor);
275
315
  }
316
+
276
317
  candidates.push({ path, size: stat.size, mtime: stat.mtimeMs });
277
318
  } catch {
278
319
  // Ignore races and inaccessible files in this best-effort sweep.
279
320
  }
280
321
  }
322
+
281
323
  candidates.sort((a, b) => a.mtime - b.mtime);
282
324
  let total = candidates.reduce((sum, candidate) => sum + candidate.size, 0);
325
+
283
326
  while (candidates.length > this.maxEntries || total > this.maxBytes * 2 + 65_536) {
284
327
  const oldest = candidates.shift();
328
+
285
329
  if (!oldest) break;
286
330
  total -= oldest.size;
331
+
287
332
  try {
288
333
  unlinkSync(oldest.path);
289
334
  } catch {
@@ -298,6 +343,7 @@ export class ExpiringLruCache<K, V> {
298
343
  #evict(): void {
299
344
  while (this.#entries.size > this.maxEntries || this.#byteSize > this.maxBytes) {
300
345
  const oldest = this.#entries.keys().next().value;
346
+
301
347
  if (oldest === undefined) break;
302
348
  this.#delete(oldest);
303
349
  }
@@ -305,12 +351,16 @@ export class ExpiringLruCache<K, V> {
305
351
 
306
352
  #delete(key: K): void {
307
353
  const entry = this.#entries.get(key);
354
+
308
355
  if (!entry) {
309
356
  if (this.persistence) this.#removeFromDisk(key);
357
+
310
358
  return;
311
359
  }
360
+
312
361
  this.#entries.delete(key);
313
362
  this.#byteSize -= entry.size;
363
+
314
364
  if (this.persistence) this.#removeFromDisk(key);
315
365
  }
316
366
  }
package/src/content.ts CHANGED
@@ -2,7 +2,9 @@ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-
2
2
  import type { ExtractedLinks, ExtractionDiagnostics } from "./evidence";
3
3
 
4
4
  const CONTENT_LINE_BUDGET = Math.max(1, DEFAULT_MAX_LINES - 10);
5
+
5
6
  const CONTENT_BYTE_BUDGET = Math.max(1_024, DEFAULT_MAX_BYTES - 2_048);
7
+
6
8
  const encoder = new TextEncoder();
7
9
 
8
10
  /**
@@ -14,6 +16,8 @@ const encoder = new TextEncoder();
14
16
  export interface CompleteDocument {
15
17
  url: string;
16
18
  contentType: string;
19
+ /** Character offsets for document fragments, derived from HTML anchors/headings. */
20
+ fragmentOffsets?: Record<string, number>;
17
21
  markdown: string;
18
22
  title?: string;
19
23
  extractor: "defuddle" | "basic" | "raw";
@@ -52,27 +56,37 @@ export interface FetchResult extends CompleteDocument {
52
56
  truncated: boolean;
53
57
  }
54
58
 
59
+ /** Returns the longest prefix whose UTF-8 representation fits within the byte limit. */
55
60
  function sliceByByteLength(value: string, maxBytes: number): string {
56
61
  if (encoder.encode(value).byteLength <= maxBytes) return value;
57
62
  let low = 0;
58
63
  let high = value.length;
64
+
59
65
  while (low < high) {
60
66
  const middle = Math.ceil((low + high) / 2);
67
+
61
68
  if (encoder.encode(value.slice(0, middle)).byteLength <= maxBytes) low = middle;
62
69
  else high = middle - 1;
63
70
  }
71
+
64
72
  if (low > 0 && /[\uD800-\uDBFF]/.test(value[low - 1])) low -= 1;
73
+
65
74
  return value.slice(0, low);
66
75
  }
67
76
 
77
+ /** Slices content while enforcing the configured character, line, and byte budgets. */
68
78
  function boundedContentChunk(value: string, offset: number, maxCharacters: number): string {
69
79
  let chunk = value.slice(offset, offset + maxCharacters);
70
80
  let newline = -1;
81
+
71
82
  for (let lines = 1; lines < CONTENT_LINE_BUDGET; lines += 1) {
72
83
  newline = chunk.indexOf("\n", newline + 1);
84
+
73
85
  if (newline === -1) break;
74
86
  }
87
+
75
88
  if (newline !== -1) chunk = chunk.slice(0, newline);
89
+
76
90
  return sliceByByteLength(chunk, CONTENT_BYTE_BUDGET);
77
91
  }
78
92
 
@@ -97,11 +111,13 @@ export function sliceCompleteDocument(
97
111
  let markdown = boundedContentChunk(document.markdown, offset, maxCharacters);
98
112
  const end = offset + markdown.length;
99
113
  const truncated = end < totalCharacters;
114
+
100
115
  if (truncated) {
101
116
  markdown += `\n\n[Content truncated. Continue with offset=${end} to read the next chunk.]`;
102
117
  } else if (offset > 0) {
103
118
  markdown += "\n\n[End of page content.]";
104
119
  }
120
+
105
121
  return {
106
122
  ...document,
107
123
  markdown,
package/src/evidence.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { parseHTML } from "linkedom";
2
2
 
3
3
  const MAX_LINKS = 16;
4
+
4
5
  const MAX_ANCHOR_CHARACTERS = 120;
5
6
 
6
7
  /** Bounded, machine-readable reasons that an HTML extraction may be incomplete. */
@@ -28,12 +29,14 @@ export interface ExtractedLinks {
28
29
  }
29
30
 
30
31
  const JAVASCRIPT_MARKERS = [/please\s+enable\s+javascript/i, /javascript\s+(?:is\s+)?required/i];
32
+
31
33
  const BOT_MARKERS = [
32
34
  /are\s+you\s+a\s+robot/i,
33
35
  /verify\s+you\s+are\s+human/i,
34
36
  /checking\s+your\s+browser/i,
35
37
  /<title>\s*just\s+a\s+moment/i,
36
38
  ];
39
+
37
40
  const CONSENT_MARKERS = [
38
41
  /manage\s+(?:your\s+)?consent/i,
39
42
  /your\s+(?:privacy\s+)?consent/i,
@@ -78,6 +81,7 @@ export function hasExtractionWarning(diagnostics: ExtractionDiagnostics): boolea
78
81
  */
79
82
  function normalizedAnchor(value: string): string {
80
83
  const text = value.replace(/\s+/g, " ").trim();
84
+
81
85
  return text.length > MAX_ANCHOR_CHARACTERS
82
86
  ? `${text.slice(0, MAX_ANCHOR_CHARACTERS - 1)}…`
83
87
  : text;
@@ -94,25 +98,31 @@ export function extractDocumentLinks(raw: string, pageUrl: URL): ExtractedLinks
94
98
 
95
99
  for (const element of document.querySelectorAll<HTMLAnchorElement>("a[href]")) {
96
100
  const href = element.getAttribute("href");
101
+
97
102
  if (!href) continue;
98
103
  let target: URL;
104
+
99
105
  try {
100
106
  target = new URL(href, pageUrl);
101
107
  } catch {
102
108
  continue;
103
109
  }
110
+
104
111
  if (!["http:", "https:"].includes(target.protocol) || target.username || target.password)
105
112
  continue;
106
113
  target.hash = "";
107
114
  const anchorText = normalizedAnchor(element.textContent ?? "");
108
115
  const key = `${target.href}\0${anchorText}`;
116
+
109
117
  if (seen.has(key)) continue;
110
118
  seen.add(key);
111
119
  const isInternal = target.origin === pageUrl.origin;
112
120
  const bucket = isInternal ? internal : external;
121
+
113
122
  if (bucket.length < MAX_LINKS) bucket.push({ url: target.href, anchorText });
114
123
  else if (isInternal) omittedInternal += 1;
115
124
  else omittedExternal += 1;
116
125
  }
126
+
117
127
  return { internal, external, omittedInternal, omittedExternal };
118
128
  }