@zeldrisho/pi-web-fetch 0.7.1 → 0.8.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,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.1] - 2026-09-07
11
+
12
+ ### Security
13
+
14
+ - redact URL user information and common credential-bearing query parameters from progress messages, rendered calls, content source labels, and result URL metadata
15
+
16
+ ## [0.8.0] - 2026-09-01
17
+
18
+ ### Added
19
+
20
+ - Add optional deterministic query-focused extraction that selects matching source sections from the complete cached document, preserves source order, supports continuation over the focused view, and reports honest selection evidence in `details.focus`
21
+ - Add bounded, fence-aware document outlines to result details, including total words, section word counts, omitted-heading counts, and conservative fallback headings for structure lost during extraction
22
+ - Add an opt-in live extraction-quality corpus that checks expected markers and content size while reporting extractor choice and latency
23
+ - Revalidate stale cache entries with `ETag` and `Last-Modified`, reporting cache hits, HTTP 304 revalidations, and misses separately
24
+ - Add explicit bounded diagnostics for JavaScript requirements, bot walls, consent interstitials, and sparse extraction
25
+ - Expose bounded normalized internal and external links with anchor text and omitted counts without fetching linked pages
26
+ - Coordinate requests per origin and retry `429`/`503` responses with bounded `Retry-After` handling or jittered backoff
27
+ - Add focused-ranking baselines for heading, phrase, and stemming experiments
28
+
29
+ ### Fixed
30
+
31
+ - Resolve relative Open Graph, Twitter, and canonical page URLs before Defuddle metadata extraction so successful GitHub release fetches no longer leak `ERR_INVALID_URL` warnings into Pi's TUI
32
+ - Remove leaked style elements, standalone CSS rules, and block at-rules from extracted Markdown while preserving fenced stylesheet examples
33
+ - Expand extraction regressions for GFM tables, malformed issue markup, and content-rich app shells
34
+
10
35
  ## [0.7.1] - 2026-08-28
11
36
 
12
37
  ### Fixed
@@ -110,7 +135,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
110
135
 
111
136
  - Add bounded public page fetching ([b979496](https://github.com/zeldrisho/pi-packages/commit/b979496b32de1cead172ba570307e4a4a7b3421d))
112
137
 
113
- [Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.7.1...HEAD
138
+ [Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.8.1...HEAD
139
+ [0.8.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.8.0...pi-web-fetch-v0.8.1
140
+ [0.8.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.7.1...pi-web-fetch-v0.8.0
114
141
  [0.7.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.7.0...pi-web-fetch-v0.7.1
115
142
  [0.7.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.6.1...pi-web-fetch-v0.7.0
116
143
  [0.6.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.6.0...pi-web-fetch-v0.6.1
package/README.md CHANGED
@@ -20,6 +20,8 @@ The `web_fetch` tool accepts public HTTP and HTTPS URLs. It supports textual con
20
20
 
21
21
  For safety, the tool blocks URLs containing credentials, local hostnames, private or reserved network targets, unsafe redirects, raw responses larger than 5 MiB, and unsupported content types. The `maxCharacters` parameter controls returned Markdown length; it does not change the raw download limit.
22
22
 
23
+ For long pages, the optional `query` parameter returns a deterministic focused view containing source sections that match the query. Filtering is performed locally after the complete page has been fetched and cached; it does not use an LLM or alter the cached document. Matching sections retain source order, and `details.focus` reports matched, total, and omitted section counts plus original and focused character counts. Continuation offsets apply to the focused view when `query` is present. Omit `query` to read the complete document, especially when verifying context or when no sections match.
24
+
23
25
  In Pi's interactive UI, fetched content uses Pi's standard collapsed preview; use the configured tool-expansion shortcut (`Ctrl+O` by default) to show all visible tool output. Output sent to the agent remains bounded: each call returns at most `maxCharacters` characters of extracted Markdown (default 6,000) and is additionally capped by Pi's 2,000-line / 50 KiB tool-output limit, so fetching cannot bloat the conversation context. The `offset` parameter is a character offset into extracted content, not a byte range into the remote response. When a result is truncated, call the tool again with the returned `nextOffset` as `offset` to continue reading. Fetched and extracted pages are cached in byte-bounded memory for a limited time so continuation requests can reuse the same content. Concurrent requests for the same URL share one fetch; cancelling one caller does not cancel work still needed by another.
24
26
 
25
27
  Every result includes `details.truncation`. Complete output reports `{ truncated: false, strategy: "none" }`. Truncated output reports `strategy: "continuation"` and a valid `nextOffset`. The existing top-level `details.truncated` and `details.nextOffset` fields remain available.
@@ -54,9 +56,17 @@ Directory and tree listings (`https://github.com/<owner>/<repo>/tree/...`) are a
54
56
 
55
57
  ### Caching and evidence
56
58
 
57
- Fetched and extracted pages are cached in byte-bounded memory and also persisted to a private, cross-session disk cache (24h TTL, files created `0700`/`0600`) so identical requests reuse the same content across Pi sessions. Concurrent requests for the same URL share one fetch; cancelling one caller does not cancel work still needed by another.
59
+ Fetched and extracted pages are cached in byte-bounded memory and also persisted to a private, cross-session disk cache (files created `0700`/`0600`). Entries are fresh for 24 hours and retained stale for bounded conditional revalidation. Stale entries use their `ETag` and `Last-Modified` validators through the same URL validation, DNS pinning, redirect, timeout, and response-limit boundary as an initial fetch. `details.cacheStatus` distinguishes `hit`, `revalidated` (HTTP 304), and `miss`; the compatibility `cached` flag is true for hits and revalidations.
60
+
61
+ Requests are coordinated per origin. Responses with status `429` or `503` are retried at most twice, honoring a bounded `Retry-After` value or using bounded jittered exponential backoff. Each caller retains independent cancellation while queued or backing off.
62
+
63
+ Each result includes honest-evidence `details`: `requestedUrl` and `finalUrl` (after any rewrite or redirect), `contentKind` (a coarse classification such as `article`, `code-file`, `repository-readme`, `raw-text`, or `markup-shell`), and `confidence` (`high`/`medium`/`low`). For HTML, `details.extractionDiagnostics` separately reports JavaScript requirements, bot walls, consent interstitials, and sparse extraction; `shellSuspected` remains only as a deprecated compatibility summary.
64
+
65
+ `details.links` exposes at most 16 normalized internal and 16 external HTTP(S) links with bounded anchor text and omitted counts. Unsafe schemes and credential-bearing URLs are rejected. Links are evidence from the fetched document only; `web_fetch` never fetches linked pages implicitly.
66
+
67
+ `details.outline` provides bounded document-shape metadata: total words, heading count, the first 12 headings with section word counts, and the number of omitted headings. It ignores headings inside fenced code and conservatively infers short title lines only when extraction produced no Markdown headings. Heading text is untrusted remote content and is capped before being exposed.
58
68
 
59
- Each result includes honest-evidence `details`: `requestedUrl` and `finalUrl` (after any rewrite or redirect), `contentKind` (a coarse classification such as `article`, `code-file`, `repository-readme`, `raw-text`, or `markup-shell`), `shellSuspected` (true when the page looks like an app shell, bot wall, or consent page), and `confidence` (`high`/`medium`/`low`) derived from the extractor, content length, and `shellSuspected`.
69
+ Extraction regressions cover tables, malformed issue-like markup, and app-shell false positives. Before changing focused-section ranking, run `vp exec jiti scripts/benchmark-focus.ts` to record heading, phrase, and stemming baselines.
60
70
 
61
71
  ## Uninstall
62
72
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeldrisho/pi-web-fetch",
3
- "version": "0.7.1",
3
+ "version": "0.8.1",
4
4
  "description": "Pi extension for secure, bounded public web page fetching and Markdown extraction",
5
5
  "keywords": [
6
6
  "pi",
package/src/cache.ts CHANGED
@@ -40,6 +40,12 @@ export function resolveCachePath(directory: string, keyPath: string): string {
40
40
  return full;
41
41
  }
42
42
 
43
+ /**
44
+ * Encodes an expiration timestamp as an 8-byte big-endian buffer.
45
+ *
46
+ * @param expiresAt - Expiration timestamp in milliseconds
47
+ * @returns 8-byte buffer containing the encoded timestamp
48
+ */
43
49
  function encodeExpiresAt(expiresAt: number): Uint8Array {
44
50
  const out = new Uint8Array(8);
45
51
  let value = BigInt(Math.round(expiresAt));
@@ -50,6 +56,12 @@ function encodeExpiresAt(expiresAt: number): Uint8Array {
50
56
  return out;
51
57
  }
52
58
 
59
+ /**
60
+ * Decodes an 8-byte big-endian buffer to an expiration timestamp.
61
+ *
62
+ * @param bytes - 8-byte buffer containing the encoded timestamp
63
+ * @returns Expiration timestamp in milliseconds
64
+ */
53
65
  function decodeExpiresAt(bytes: Uint8Array): number {
54
66
  let value = 0n;
55
67
  for (let index = 0; index < 8; index += 1) value = (value << 8n) | BigInt(bytes[index]);
@@ -69,14 +81,30 @@ export class ExpiringLruCache<K, V> {
69
81
  readonly persistence?: CachePersistence<K, V>,
70
82
  ) {}
71
83
 
84
+ /**
85
+ * Gets the total byte size of all cached values.
86
+ *
87
+ * @returns Current aggregate byte size
88
+ */
72
89
  get byteSize(): number {
73
90
  return this.#byteSize;
74
91
  }
75
92
 
93
+ /**
94
+ * Gets the number of cached entries.
95
+ *
96
+ * @returns Current entry count
97
+ */
76
98
  get size(): number {
77
99
  return this.#entries.size;
78
100
  }
79
101
 
102
+ /**
103
+ * Retrieves a value from the cache, loading from disk if needed.
104
+ *
105
+ * @param key - Cache key
106
+ * @returns Cached value or undefined if not found or expired
107
+ */
80
108
  get(key: K): V | undefined {
81
109
  const entry = this.#entries.get(key);
82
110
  if (entry) {
@@ -95,6 +123,14 @@ export class ExpiringLruCache<K, V> {
95
123
  return undefined;
96
124
  }
97
125
 
126
+ /**
127
+ * Stores a value in the cache with an expiration time.
128
+ *
129
+ * @param key - Cache key
130
+ * @param value - Value to store
131
+ * @param expiresAt - Expiration timestamp in milliseconds
132
+ * @returns True if the value was stored, false if it was too large
133
+ */
98
134
  set(key: K, value: V, expiresAt: number): boolean {
99
135
  this.#delete(key);
100
136
  const size = this.sizeOf(value);
package/src/content.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
2
+ import type { ExtractedLinks, ExtractionDiagnostics } from "./evidence";
2
3
 
3
4
  const CONTENT_LINE_BUDGET = Math.max(1, DEFAULT_MAX_LINES - 10);
4
5
  const CONTENT_BYTE_BUDGET = Math.max(1_024, DEFAULT_MAX_BYTES - 2_048);
@@ -16,8 +17,16 @@ export interface CompleteDocument {
16
17
  markdown: string;
17
18
  title?: string;
18
19
  extractor: "defuddle" | "basic" | "raw";
19
- /** True when the page appears to be an app shell, bot wall, or consent page. */
20
+ /** @deprecated Use extractionDiagnostics for the individual quality signals. */
20
21
  shellSuspected: boolean;
22
+ /** Explicit bounded extraction-quality evidence for HTML documents. */
23
+ extractionDiagnostics?: ExtractionDiagnostics;
24
+ /** Bounded normalized links extracted from this document; linked pages were not fetched. */
25
+ links?: ExtractedLinks;
26
+ /** HTTP validators retained for stale-cache revalidation. */
27
+ validators?: { etag?: string; lastModified?: string };
28
+ /** Time at which this representation was fetched or successfully revalidated. */
29
+ cachedAt?: number;
21
30
  /** True when this document is the site's /llms.txt served instead of a low-quality page. */
22
31
  llmsTxtFallback?: boolean;
23
32
  /** Set when the site publishes a usable /llms.txt index and this document is not that index. */
@@ -0,0 +1,118 @@
1
+ import { parseHTML } from "linkedom";
2
+
3
+ const MAX_LINKS = 16;
4
+ const MAX_ANCHOR_CHARACTERS = 120;
5
+
6
+ /** Bounded, machine-readable reasons that an HTML extraction may be incomplete. */
7
+ export interface ExtractionDiagnostics {
8
+ javascriptRequired: boolean;
9
+ botWall: boolean;
10
+ consentInterstitial: boolean;
11
+ sparseExtraction: boolean;
12
+ rawCharacters: number;
13
+ extractedCharacters: number;
14
+ }
15
+
16
+ /** One normalized link found in the fetched document. */
17
+ export interface ExtractedLink {
18
+ url: string;
19
+ anchorText: string;
20
+ }
21
+
22
+ /** Bounded links from the fetched document. Linked pages are never fetched implicitly. */
23
+ export interface ExtractedLinks {
24
+ internal: ExtractedLink[];
25
+ external: ExtractedLink[];
26
+ omittedInternal: number;
27
+ omittedExternal: number;
28
+ }
29
+
30
+ const JAVASCRIPT_MARKERS = [/please\s+enable\s+javascript/i, /javascript\s+(?:is\s+)?required/i];
31
+ const BOT_MARKERS = [
32
+ /are\s+you\s+a\s+robot/i,
33
+ /verify\s+you\s+are\s+human/i,
34
+ /checking\s+your\s+browser/i,
35
+ /<title>\s*just\s+a\s+moment/i,
36
+ ];
37
+ const CONSENT_MARKERS = [
38
+ /manage\s+(?:your\s+)?consent/i,
39
+ /your\s+(?:privacy\s+)?consent/i,
40
+ /consent\s+to\s+(?:our\s+use\s+of\s+cookies|cookies)/i,
41
+ /accept\s+(?:all\s+)?cookies/i,
42
+ /we\s+use\s+cookies/i,
43
+ ];
44
+
45
+ /** Produces explicit, bounded extraction-quality signals instead of one opaque shell flag. */
46
+ export function diagnoseExtraction(raw: string, markdown: string): ExtractionDiagnostics {
47
+ return {
48
+ javascriptRequired: JAVASCRIPT_MARKERS.some((marker) => marker.test(raw)),
49
+ botWall: BOT_MARKERS.some((marker) => marker.test(raw)),
50
+ consentInterstitial: CONSENT_MARKERS.some((marker) => marker.test(raw)),
51
+ sparseExtraction:
52
+ raw.length > 4_000 && markdown.length < 1_024 && markdown.length < raw.length * 0.008,
53
+ rawCharacters: raw.length,
54
+ extractedCharacters: markdown.length,
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Checks if extraction diagnostics indicate any warning conditions.
60
+ *
61
+ * @param diagnostics - Extraction diagnostics to check
62
+ * @returns True if any warning condition is present, false otherwise
63
+ */
64
+ export function hasExtractionWarning(diagnostics: ExtractionDiagnostics): boolean {
65
+ return (
66
+ diagnostics.javascriptRequired ||
67
+ diagnostics.botWall ||
68
+ diagnostics.consentInterstitial ||
69
+ diagnostics.sparseExtraction
70
+ );
71
+ }
72
+
73
+ /**
74
+ * Normalizes and truncates anchor text for link extraction.
75
+ *
76
+ * @param value - Raw anchor text
77
+ * @returns Normalized text, truncated to MAX_ANCHOR_CHARACTERS if needed
78
+ */
79
+ function normalizedAnchor(value: string): string {
80
+ const text = value.replace(/\s+/g, " ").trim();
81
+ return text.length > MAX_ANCHOR_CHARACTERS
82
+ ? `${text.slice(0, MAX_ANCHOR_CHARACTERS - 1)}…`
83
+ : text;
84
+ }
85
+
86
+ /** Extracts a bounded set of safe HTTP(S) links without making any network requests. */
87
+ export function extractDocumentLinks(raw: string, pageUrl: URL): ExtractedLinks {
88
+ const { document } = parseHTML(raw);
89
+ const internal: ExtractedLink[] = [];
90
+ const external: ExtractedLink[] = [];
91
+ let omittedInternal = 0;
92
+ let omittedExternal = 0;
93
+ const seen = new Set<string>();
94
+
95
+ for (const element of document.querySelectorAll<HTMLAnchorElement>("a[href]")) {
96
+ const href = element.getAttribute("href");
97
+ if (!href) continue;
98
+ let target: URL;
99
+ try {
100
+ target = new URL(href, pageUrl);
101
+ } catch {
102
+ continue;
103
+ }
104
+ if (!["http:", "https:"].includes(target.protocol) || target.username || target.password)
105
+ continue;
106
+ target.hash = "";
107
+ const anchorText = normalizedAnchor(element.textContent ?? "");
108
+ const key = `${target.href}\0${anchorText}`;
109
+ if (seen.has(key)) continue;
110
+ seen.add(key);
111
+ const isInternal = target.origin === pageUrl.origin;
112
+ const bucket = isInternal ? internal : external;
113
+ if (bucket.length < MAX_LINKS) bucket.push({ url: target.href, anchorText });
114
+ else if (isInternal) omittedInternal += 1;
115
+ else omittedExternal += 1;
116
+ }
117
+ return { internal, external, omittedInternal, omittedExternal };
118
+ }
package/src/extract.ts CHANGED
@@ -85,6 +85,31 @@ export function assertAbsoluteHttpUrl(value: string | URL): URL {
85
85
  return url;
86
86
  }
87
87
 
88
+ /** Resolves page-URL metadata that Defuddle otherwise parses without a base URL. */
89
+ function resolveDocumentRelativeMetadataUrls(document: Document, pageUrl: URL): void {
90
+ for (const meta of document.querySelectorAll<HTMLMetaElement>("meta[content]")) {
91
+ const key = (meta.getAttribute("property") ?? meta.getAttribute("name"))?.toLowerCase();
92
+ if (key !== "og:url" && key !== "twitter:url") continue;
93
+ const content = meta.getAttribute("content");
94
+ if (!content) continue;
95
+ try {
96
+ meta.setAttribute("content", new URL(content, pageUrl).href);
97
+ } catch {
98
+ meta.removeAttribute("content");
99
+ }
100
+ }
101
+
102
+ for (const link of document.querySelectorAll<HTMLLinkElement>('link[rel="canonical"][href]')) {
103
+ const href = link.getAttribute("href");
104
+ if (!href) continue;
105
+ try {
106
+ link.setAttribute("href", new URL(href, pageUrl).href);
107
+ } catch {
108
+ link.removeAttribute("href");
109
+ }
110
+ }
111
+ }
112
+
88
113
  /**
89
114
  * Type guard that checks if an unknown value is a string.
90
115
  *
@@ -148,6 +173,104 @@ function deduplicateAdjacentLinks(markdown: string): string {
148
173
  return out.join("\n").replace(/(\[[^\n]+?\([^)]+\)[^\n]*\n)\1/g, "$1");
149
174
  }
150
175
 
176
+ const CSS_BLOCK_AT_RULE = /^\s*@(container|font-face|keyframes|layer|media|page|supports)\b/i;
177
+ const STANDALONE_CSS_RULE =
178
+ /^\s*(?:[.#][-_a-zA-Z]|\*\s*[.#:[>+~]|(?:html|body|main|article|nav|header|footer|aside)(?:\b|[.#:[>+~]))[^{}]*\{[^{}]*\}\s*$/i;
179
+ const COMPLETE_STYLE_ELEMENT = /<style\b[^>]*>[\s\S]*?<\/style\s*>/gi;
180
+ const CLOSING_STYLE_ELEMENT = /<\/style\s*>/i;
181
+ const OPENING_STYLE_ELEMENT = /<style\b[^>]*>/i;
182
+
183
+ /**
184
+ * Iteratively removes complete style elements from a string to prevent nested fragment recreation.
185
+ *
186
+ * @param value - The string to strip style elements from
187
+ * @returns The string with all complete style elements removed
188
+ */
189
+ function stripCompleteStyleElements(value: string): string {
190
+ let cleaned = value;
191
+ while (true) {
192
+ const next = cleaned.replace(COMPLETE_STYLE_ELEMENT, "");
193
+ if (next === cleaned) return cleaned;
194
+ cleaned = next;
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Calculates the net brace depth change in a string (opening braces minus closing braces).
200
+ *
201
+ * @param value - The string to analyze
202
+ * @returns The difference between opening and closing brace counts
203
+ */
204
+ function braceDelta(value: string): number {
205
+ return (value.match(/{/g)?.length ?? 0) - (value.match(/}/g)?.length ?? 0);
206
+ }
207
+
208
+ /**
209
+ * Removes leaked stylesheet fragments from extracted Markdown while preserving fenced examples.
210
+ *
211
+ * The matcher is deliberately conservative: it removes style elements, recognized block at-rules,
212
+ * and complete standalone selector/declaration lines. Prose that merely discusses CSS and fenced
213
+ * CSS/SCSS/Less examples remain untouched.
214
+ */
215
+ export function stripExtractedCssCruft(markdown: string): string {
216
+ const output: string[] = [];
217
+ let fence: { character: string; length: number } | undefined;
218
+ let styleElement = false;
219
+ let cssBlockDepth = 0;
220
+
221
+ for (const originalLine of markdown.split("\n")) {
222
+ const fenceMatch = /^[ \t]{0,3}(`{3,}|~{3,})/.exec(originalLine);
223
+ if (!fence && fenceMatch) {
224
+ fence = { character: fenceMatch[1][0], length: fenceMatch[1].length };
225
+ output.push(originalLine);
226
+ continue;
227
+ }
228
+ if (fence) {
229
+ output.push(originalLine);
230
+ if (
231
+ fenceMatch &&
232
+ fenceMatch[1][0] === fence.character &&
233
+ fenceMatch[1].length >= fence.length &&
234
+ !originalLine.slice(fenceMatch[0].length).trim()
235
+ ) {
236
+ fence = undefined;
237
+ }
238
+ continue;
239
+ }
240
+
241
+ let line = originalLine;
242
+ if (styleElement) {
243
+ const close = CLOSING_STYLE_ELEMENT.exec(line);
244
+ if (!close) continue;
245
+ line = line.slice(close.index + close[0].length);
246
+ styleElement = false;
247
+ }
248
+ line = stripCompleteStyleElements(line);
249
+ const open = OPENING_STYLE_ELEMENT.exec(line);
250
+ if (open) {
251
+ line = line.slice(0, open.index);
252
+ styleElement = true;
253
+ }
254
+
255
+ if (cssBlockDepth > 0) {
256
+ cssBlockDepth += braceDelta(line);
257
+ if (cssBlockDepth < 0) cssBlockDepth = 0;
258
+ continue;
259
+ }
260
+ if (CSS_BLOCK_AT_RULE.test(line)) {
261
+ cssBlockDepth = Math.max(0, braceDelta(line));
262
+ continue;
263
+ }
264
+ if (STANDALONE_CSS_RULE.test(line)) continue;
265
+ output.push(line);
266
+ }
267
+
268
+ return output
269
+ .join("\n")
270
+ .replace(/\n{3,}/g, "\n\n")
271
+ .trim();
272
+ }
273
+
151
274
  /**
152
275
  * Runs Defuddle over an already-normalized document with Markdown extraction enabled.
153
276
  *
@@ -273,6 +396,10 @@ export async function extractHtmlToMarkdown(
273
396
  try {
274
397
  assertAbsoluteHttpUrl(baseUrl);
275
398
  const { document } = parseHTML(html);
399
+ // Defuddle parses URL metadata without using its explicit page URL as the
400
+ // base. Resolve relative values first so it does not log ERR_INVALID_URL
401
+ // through Pi's TUI even though extraction succeeds.
402
+ resolveDocumentRelativeMetadataUrls(document, baseUrl);
276
403
  removeMalformedSchemaOrgData(document);
277
404
  normalizeSelectorUnsafeIds(document);
278
405
  stripChromeWrappers(document, baseUrl);
@@ -281,7 +408,9 @@ export async function extractHtmlToMarkdown(
281
408
  // `/owner/repo/releases`) and metadata against the real origin instead of
282
409
  // dropping the scheme and host and constructing `new URL(pathname)`.
283
410
  const result = await runDefuddle(document, baseUrl.href);
284
- const markdown = deduplicateAdjacentLinks(result?.content?.trim() ?? "");
411
+ const markdown = stripExtractedCssCruft(
412
+ deduplicateAdjacentLinks(result?.content?.trim() ?? ""),
413
+ );
285
414
  const trimmedTitle = result?.title?.trim();
286
415
  if (markdown) {
287
416
  return {
@@ -294,5 +423,9 @@ export async function extractHtmlToMarkdown(
294
423
  } catch {
295
424
  // Fall through to the basic converter for malformed or unsupported pages.
296
425
  }
297
- return { markdown: htmlToMarkdownFallback(html), extractor: "basic", ...advertised };
426
+ return {
427
+ markdown: stripExtractedCssCruft(htmlToMarkdownFallback(html)),
428
+ extractor: "basic",
429
+ ...advertised,
430
+ };
298
431
  }
package/src/fetch.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import type { IncomingMessage } from "node:http";
2
2
  import { awaitWithAbort } from "./abort";
3
3
  import { sliceCompleteDocument, type CompleteDocument, type FetchResult } from "./content";
4
+ import { diagnoseExtraction, extractDocumentLinks, hasExtractionWarning } from "./evidence";
4
5
  import { extractHtmlToMarkdown } from "./extract";
5
6
  import { requestFollowingRedirects, type RedirectDependencies } from "./network-redirects";
6
7
  import type { ValidatedTarget } from "./network-policy";
8
+ import { redactUrlForDisplay } from "./redact";
7
9
  import {
8
10
  decodeResponse,
9
11
  FETCH_MAX_BYTES,
@@ -56,37 +58,9 @@ export function normalizeGitHubRawUrl(rawUrl: string): string {
56
58
  }
57
59
  }
58
60
 
59
- const APP_SHELL_MARKERS = [
60
- /please\s+enable\s+javascript/i,
61
- /enable\s+javascript/i,
62
- // Consent is only treated as an interstitial signal when it appears in a
63
- // cookie/consent-banner phrase. A bare "consent" matches ordinary prose
64
- // (e.g. privacy articles) and must not flag readable content as a shell.
65
- /manage\s+(your\s+)?consent/i,
66
- /your\s+(privacy\s+)?consent/i,
67
- /consent\s+to\s+(our\s+use\s+of\s+cookies|cookies)/i,
68
- /accept\s+(all\s+)?cookies/i,
69
- /we\s+use\s+cookies/i,
70
- /are\s+you\s+a\s+robot/i,
71
- /verify\s+you\s+are\s+human/i,
72
- /checking\s+your\s+browser/i,
73
- /<title>\s*just\s+a\s+moment/i,
74
- ];
75
-
76
- /**
77
- * Detects pages that are likely app shells, bot walls, or consent interstitials rather than
78
- * readable content.
79
- *
80
- * @param raw - The raw response body
81
- * @param markdown - The extracted Markdown
82
- * @returns True when the extracted text is suspiciously sparse relative to the raw page
83
- */
61
+ /** @deprecated Prefer diagnoseExtraction for explicit extraction-quality signals. */
84
62
  export function detectAppShell(raw: string, markdown: string): boolean {
85
- if (APP_SHELL_MARKERS.some((marker) => marker.test(raw))) return true;
86
- // Require the extracted text to be both absolutely tiny and a very small
87
- // fraction of the raw payload, so content-rich pages (e.g. React/Next.js SPAs
88
- // whose raw HTML is dominated by inline scripts) are not mistaken for shells.
89
- return raw.length > 4000 && markdown.length < 1024 && markdown.length < raw.length * 0.008;
63
+ return hasExtractionWarning(diagnoseExtraction(raw, markdown));
90
64
  }
91
65
 
92
66
  const REQUEST_TIMEOUT_MS = 20_000;
@@ -238,10 +212,13 @@ async function documentFromResponse(
238
212
  }
239
213
  } else markdown = raw.trim();
240
214
 
241
- const shellSuspected =
242
- contentType === "text/html" || contentType === "application/xhtml+xml"
243
- ? detectAppShell(raw, markdown)
244
- : false;
215
+ const isHtml = contentType === "text/html" || contentType === "application/xhtml+xml";
216
+ const extractionDiagnostics = isHtml ? diagnoseExtraction(raw, markdown) : undefined;
217
+ const shellSuspected = extractionDiagnostics
218
+ ? hasExtractionWarning(extractionDiagnostics)
219
+ : false;
220
+ const etag = responseHeader(response, "etag");
221
+ const lastModified = responseHeader(response, "last-modified");
245
222
 
246
223
  return {
247
224
  url: target.url.toString(),
@@ -250,6 +227,10 @@ async function documentFromResponse(
250
227
  title,
251
228
  extractor,
252
229
  shellSuspected,
230
+ extractionDiagnostics,
231
+ links: isHtml ? extractDocumentLinks(raw, target.url) : undefined,
232
+ validators: etag || lastModified ? { etag, lastModified } : undefined,
233
+ cachedAt: Date.now(),
253
234
  llmsTxtDescribedBy: describedBy,
254
235
  markdownAlternateUrl,
255
236
  };
@@ -267,10 +248,10 @@ function assertAbsoluteHttpUrlForFetch(value: string): URL {
267
248
  try {
268
249
  url = new URL(value);
269
250
  } catch {
270
- throw new Error(`Invalid URL: ${value}`);
251
+ throw new Error("web_fetch received an invalid URL.");
271
252
  }
272
253
  if (url.protocol !== "http:" && url.protocol !== "https:") {
273
- throw new Error(`web_fetch only supports http(s) URLs: ${value}`);
254
+ throw new Error(`web_fetch only supports http(s) URLs: ${redactUrlForDisplay(url)}`);
274
255
  }
275
256
  return url;
276
257
  }
@@ -284,11 +265,12 @@ function assertAbsoluteHttpUrlForFetch(value: string): URL {
284
265
  * @returns The complete document with extracted markdown content
285
266
  * @throws If the request times out, is cancelled, or encounters an error
286
267
  */
287
- export async function fetchCompleteDocument(
268
+ async function fetchDocument(
288
269
  rawUrl: string,
289
270
  signal: AbortSignal | undefined,
290
271
  dependencies: FetchRemoteDependencies,
291
- ): Promise<CompleteDocument> {
272
+ cached?: CompleteDocument,
273
+ ): Promise<{ document: CompleteDocument; revalidated: boolean }> {
292
274
  assertAbsoluteHttpUrlForFetch(normalizeGitHubRawUrl(rawUrl));
293
275
  const controller = new AbortController();
294
276
  const timeoutMs = dependencies.timeoutMs ?? REQUEST_TIMEOUT_MS;
@@ -300,17 +282,36 @@ export async function fetchCompleteDocument(
300
282
  }, timeoutMs);
301
283
  const cancel = () => controller.abort();
302
284
  signal?.addEventListener("abort", cancel, { once: true });
285
+ const conditionalHeaders: Record<string, string> = {};
286
+ if (cached?.validators?.etag) conditionalHeaders["If-None-Match"] = cached.validators.etag;
287
+ if (cached?.validators?.lastModified)
288
+ conditionalHeaders["If-Modified-Since"] = cached.validators.lastModified;
303
289
 
304
290
  try {
305
291
  const { target, response } = await requestFollowingRedirects(
306
292
  normalizeGitHubRawUrl(rawUrl),
307
293
  controller.signal,
308
- {
309
- validateUrl: dependencies.validateUrl,
310
- request: dependencies.request,
311
- },
294
+ dependencies,
295
+ conditionalHeaders,
312
296
  );
313
- return await documentFromResponse(target, response, controller.signal, extractHtml);
297
+ if (response.statusCode === 304 && cached) {
298
+ response.resume();
299
+ const etag = responseHeader(response, "etag") ?? cached.validators?.etag;
300
+ const lastModified =
301
+ responseHeader(response, "last-modified") ?? cached.validators?.lastModified;
302
+ return {
303
+ document: {
304
+ ...cached,
305
+ validators: etag || lastModified ? { etag, lastModified } : undefined,
306
+ cachedAt: Date.now(),
307
+ },
308
+ revalidated: true,
309
+ };
310
+ }
311
+ return {
312
+ document: await documentFromResponse(target, response, controller.signal, extractHtml),
313
+ revalidated: false,
314
+ };
314
315
  } catch (error) {
315
316
  if (timedOut) throw new Error(`web_fetch timed out after ${timeoutMs / 1000} seconds.`);
316
317
  if (signal?.aborted) throw new Error("web_fetch was cancelled.");
@@ -321,6 +322,24 @@ export async function fetchCompleteDocument(
321
322
  }
322
323
  }
323
324
 
325
+ export async function fetchCompleteDocument(
326
+ rawUrl: string,
327
+ signal: AbortSignal | undefined,
328
+ dependencies: FetchRemoteDependencies,
329
+ ): Promise<CompleteDocument> {
330
+ return (await fetchDocument(rawUrl, signal, dependencies)).document;
331
+ }
332
+
333
+ /** Revalidates a stale representation with its ETag and Last-Modified validators. */
334
+ export async function revalidateCompleteDocument(
335
+ rawUrl: string,
336
+ cached: CompleteDocument,
337
+ signal: AbortSignal | undefined,
338
+ dependencies: FetchRemoteDependencies,
339
+ ): Promise<{ document: CompleteDocument; revalidated: boolean }> {
340
+ return fetchDocument(rawUrl, signal, dependencies, cached);
341
+ }
342
+
324
343
  /**
325
344
  * Fetches a URL and returns a slice of the content starting at the given offset.
326
345
  *