@zeldrisho/pi-web-fetch 0.7.0 → 0.8.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,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.0] - 2026-09-01
11
+
12
+ ### Added
13
+
14
+ - 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`
15
+ - 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
16
+ - Add an opt-in live extraction-quality corpus that checks expected markers and content size while reporting extractor choice and latency
17
+ - Revalidate stale cache entries with `ETag` and `Last-Modified`, reporting cache hits, HTTP 304 revalidations, and misses separately
18
+ - Add explicit bounded diagnostics for JavaScript requirements, bot walls, consent interstitials, and sparse extraction
19
+ - Expose bounded normalized internal and external links with anchor text and omitted counts without fetching linked pages
20
+ - Coordinate requests per origin and retry `429`/`503` responses with bounded `Retry-After` handling or jittered backoff
21
+ - Add focused-ranking baselines for heading, phrase, and stemming experiments
22
+
23
+ ### Fixed
24
+
25
+ - 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
26
+ - Remove leaked style elements, standalone CSS rules, and block at-rules from extracted Markdown while preserving fenced stylesheet examples
27
+ - Expand extraction regressions for GFM tables, malformed issue markup, and content-rich app shells
28
+
29
+ ## [0.7.1] - 2026-08-28
30
+
31
+ ### Fixed
32
+
33
+ - Prevent immediately unreachable pinned addresses from emitting uncaught socket errors and terminating Pi under concurrent fetch load
34
+ - Fix `https://github.com/voidzero-dev/vite-plus/releases` rendering in pi: harden `runDefuddle` to swallow bare-pathname `ERR_INVALID_URL` detached rejections, guarantee absolute `http(s)` URLs at every extraction/fetch boundary, strip `nav`/`header`/`footer`/`aside` chrome before Defuddle, deduplicate adjacent identical markdown links, and bump `defuddle` to `^0.19.3`
35
+ - Fix GitHub file fetches returning `https://github.com/llms.txt` instead of raw content: bypass `llms.txt` probing and `describedby`/`alternate` fallbacks for `github.com`, `raw.githubusercontent.com`, `gist.github.com` and `gist.githubusercontent.com`, and rewrite `github.com/*/tree/<ref>/*.*` file URLs to `raw.githubusercontent.com` like existing `blob` handling so `tree` file links (e.g. `.../permission-gate.ts`) return clean code
36
+
10
37
  ## [0.7.0] - 2026-08-24
11
38
 
12
39
  ### Added
@@ -102,7 +129,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
102
129
 
103
130
  - Add bounded public page fetching ([b979496](https://github.com/zeldrisho/pi-packages/commit/b979496b32de1cead172ba570307e4a4a7b3421d))
104
131
 
105
- [Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.7.0...HEAD
132
+ [Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.8.0...HEAD
133
+ [0.8.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.7.1...pi-web-fetch-v0.8.0
134
+ [0.7.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.7.0...pi-web-fetch-v0.7.1
106
135
  [0.7.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.6.1...pi-web-fetch-v0.7.0
107
136
  [0.6.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.6.0...pi-web-fetch-v0.6.1
108
137
  [0.6.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.5.4...pi-web-fetch-v0.6.0
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.0",
3
+ "version": "0.8.0",
4
4
  "description": "Pi extension for secure, bounded public web page fetching and Markdown extraction",
5
5
  "keywords": [
6
6
  "pi",
@@ -31,7 +31,7 @@
31
31
  "access": "public"
32
32
  },
33
33
  "dependencies": {
34
- "defuddle": "^0.19.2",
34
+ "defuddle": "^0.19.3",
35
35
  "linkedom": "^0.18.13"
36
36
  },
37
37
  "devDependencies": {
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,17 +1,32 @@
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);
5
6
  const encoder = new TextEncoder();
6
7
 
8
+ /**
9
+ * A complete fetched document with extracted content and metadata.
10
+ *
11
+ * Contains the full markdown representation along with metadata about
12
+ * the extraction method and any special handling that was applied.
13
+ */
7
14
  export interface CompleteDocument {
8
15
  url: string;
9
16
  contentType: string;
10
17
  markdown: string;
11
18
  title?: string;
12
19
  extractor: "defuddle" | "basic" | "raw";
13
- /** True when the page appears to be an app shell, bot wall, or consent page. */
20
+ /** @deprecated Use extractionDiagnostics for the individual quality signals. */
14
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;
15
30
  /** True when this document is the site's /llms.txt served instead of a low-quality page. */
16
31
  llmsTxtFallback?: boolean;
17
32
  /** Set when the site publishes a usable /llms.txt index and this document is not that index. */
@@ -24,6 +39,12 @@ export interface CompleteDocument {
24
39
  markdownAlternateFallback?: boolean;
25
40
  }
26
41
 
42
+ /**
43
+ * A paginated chunk of fetched content with offset tracking.
44
+ *
45
+ * Extends CompleteDocument with pagination metadata to support
46
+ * reading large documents in bounded chunks.
47
+ */
27
48
  export interface FetchResult extends CompleteDocument {
28
49
  offset: number;
29
50
  nextOffset?: number;
@@ -55,6 +76,18 @@ function boundedContentChunk(value: string, offset: number, maxCharacters: numbe
55
76
  return sliceByByteLength(chunk, CONTENT_BYTE_BUDGET);
56
77
  }
57
78
 
79
+ /**
80
+ * Slice a complete document into a bounded content chunk.
81
+ *
82
+ * Extracts a chunk of content starting at the given offset, respecting
83
+ * character, line, and byte limits. Adds pagination metadata and
84
+ * continuation instructions when content is truncated.
85
+ *
86
+ * @param document - Complete document to slice
87
+ * @param offset - Character offset to start reading from
88
+ * @param maxCharacters - Maximum characters to include in the chunk
89
+ * @returns Paginated fetch result with content chunk and metadata
90
+ */
58
91
  export function sliceCompleteDocument(
59
92
  document: CompleteDocument,
60
93
  offset: number,
@@ -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
@@ -76,6 +76,201 @@ export function htmlToMarkdownFallback(html: string): string {
76
76
  .trim();
77
77
  }
78
78
 
79
+ /** Asserts that a URL is absolute http(s) and returns it, or throws for bare pathnames. */
80
+ export function assertAbsoluteHttpUrl(value: string | URL): URL {
81
+ const url = value instanceof URL ? value : new URL(value);
82
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
83
+ throw new TypeError(`Expected absolute http(s) URL, got ${url.protocol}: ${url.href}`);
84
+ }
85
+ return url;
86
+ }
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
+
113
+ /**
114
+ * Type guard that checks if an unknown value is a string.
115
+ *
116
+ * @param value - The value to check
117
+ * @returns `true` if the value is a string
118
+ */
119
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- type-guard helper intentionally narrows unknown at call site
120
+ function isString(value: unknown): value is string {
121
+ return typeof value === "string";
122
+ }
123
+
124
+ /** Returns true when a rejection looks like `ERR_INVALID_URL` for a bare pathname. */
125
+ function isBarePathnameInvalidUrl(cause: unknown): boolean {
126
+ if (!(cause instanceof Error)) return false;
127
+ if (!/Invalid URL/i.test(cause.message)) return false;
128
+ // SAFETY: Node's ERR_INVALID_URL extends Error with an `input` string property holding the offending URL.
129
+ const input = (cause as NodeJS.ErrnoException & { input?: unknown }).input;
130
+ if (isString(input) && input.startsWith("/")) return true;
131
+ // SAFETY: Error.stack is an optional string populated by V8; we coerce it for pattern matching.
132
+ const detail = `${cause.message}\n${(cause as Error).stack ?? ""}`;
133
+ // Node's ERR_INVALID_URL prints `input: '/path'` in the message/stack even when
134
+ // `input` is not on the error instance in some Node versions.
135
+ return /input:\s*'\/[^']*'/.test(detail);
136
+ }
137
+
138
+ /**
139
+ * Removes chrome wrappers that pollute Defuddle's article extraction on GitHub releases.
140
+ *
141
+ * @param document - The document from which to remove chrome wrappers
142
+ * @param baseUrl - The absolute URL of the page, used to determine if chrome should be stripped
143
+ */
144
+ function stripChromeWrappers(document: Document, baseUrl: URL): void {
145
+ // Only strip chrome wrappers for GitHub release pages
146
+ const isGitHubRelease =
147
+ baseUrl.hostname === "github.com" && /\/releases(?:\/|$)/.test(baseUrl.pathname);
148
+ if (!isGitHubRelease) return;
149
+ for (const element of document.querySelectorAll("nav, header, footer, aside")) {
150
+ element.remove();
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Collapses consecutive duplicate markdown link lines (e.g. duplicated [Latest] on GitHub releases).
156
+ *
157
+ * @param markdown - The markdown string to process
158
+ * @returns The markdown with adjacent duplicate links removed
159
+ */
160
+ function deduplicateAdjacentLinks(markdown: string): string {
161
+ const lines = markdown.split("\n");
162
+ const out: string[] = [];
163
+ let previousTrimmed: string | undefined;
164
+ for (const line of lines) {
165
+ const trimmed = line.trim();
166
+ const isLinkLine =
167
+ /^\[[^\]]+\]\(<[^>]+>\)$/.test(trimmed) || /^\[[^\]]+\]\([^)]+\)$/.test(trimmed);
168
+ if (isLinkLine && trimmed === previousTrimmed) continue;
169
+ out.push(line);
170
+ previousTrimmed = isLinkLine ? trimmed : undefined;
171
+ }
172
+ // Also collapse duplicate adjacent blocks like a repeated vite-plus tag link paragraph.
173
+ return out.join("\n").replace(/(\[[^\n]+?\([^)]+\)[^\n]*\n)\1/g, "$1");
174
+ }
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
+
79
274
  /**
80
275
  * Runs Defuddle over an already-normalized document with Markdown extraction enabled.
81
276
  *
@@ -94,9 +289,10 @@ export function htmlToMarkdownFallback(html: string): string {
94
289
  * To keep `extractHtmlToMarkdown` from ever propagating such a failure, this
95
290
  * helper arms a scoped `unhandledRejection` listener for the lifetime of the
96
291
  * call. The listener is scoped, not process-wide in effect: it only treats a
97
- * rejection as a Defuddle failure when its message or stack mentions Defuddle,
98
- * so unrelated rejections from other concurrent work are ignored and do not
99
- * force a spurious fallback to the basic extractor. After Defuddle resolves we
292
+ * rejection as a Defuddle failure when its message or stack mentions Defuddle
293
+ * or looks like a bare-pathname `ERR_INVALID_URL` occurring within the armed
294
+ * window, so unrelated rejections from other concurrent work are ignored and do
295
+ * not force a spurious fallback to the basic extractor. After Defuddle resolves we
100
296
  * flush a microtask and a macrotask so any rejection Defuddle scheduled settles
101
297
  * inside the armed window; a rejection observed there is re-thrown so the
102
298
  * caller falls back. Deeply-nested timers in Defuddle are out of scope and would
@@ -112,13 +308,11 @@ async function runDefuddle(
112
308
  ): Promise<DefuddleResponse | undefined> {
113
309
  let escapedRejection: unknown = undefined;
114
310
  let armed = true;
115
- // Only attribute a rejection to Defuddle when it mentions Defuddle. This keeps
116
- // the guard scoped so unrelated concurrent rejections are ignored.
117
311
  const captureUnhandled = (cause: unknown): void => {
118
312
  if (!armed || escapedRejection !== undefined) return;
119
313
  const detail =
120
314
  cause instanceof Error ? `${cause.message}\n${cause.stack ?? ""}` : String(cause);
121
- if (/defuddle/i.test(detail)) escapedRejection = cause;
315
+ if (/defuddle/i.test(detail) || isBarePathnameInvalidUrl(cause)) escapedRejection = cause;
122
316
  };
123
317
  process.on("unhandledRejection", captureUnhandled);
124
318
  try {
@@ -200,15 +394,23 @@ export async function extractHtmlToMarkdown(
200
394
  }> {
201
395
  let advertised: AdvertisedLinks = {};
202
396
  try {
397
+ assertAbsoluteHttpUrl(baseUrl);
203
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);
204
403
  removeMalformedSchemaOrgData(document);
205
404
  normalizeSelectorUnsafeIds(document);
405
+ stripChromeWrappers(document, baseUrl);
206
406
  advertised = readAdvertisedLinks(document, baseUrl);
207
407
  // Pass the full absolute URL so Defuddle resolves relative links (e.g.
208
408
  // `/owner/repo/releases`) and metadata against the real origin instead of
209
409
  // dropping the scheme and host and constructing `new URL(pathname)`.
210
410
  const result = await runDefuddle(document, baseUrl.href);
211
- const markdown = result?.content?.trim() ?? "";
411
+ const markdown = stripExtractedCssCruft(
412
+ deduplicateAdjacentLinks(result?.content?.trim() ?? ""),
413
+ );
212
414
  const trimmedTitle = result?.title?.trim();
213
415
  if (markdown) {
214
416
  return {
@@ -221,5 +423,9 @@ export async function extractHtmlToMarkdown(
221
423
  } catch {
222
424
  // Fall through to the basic converter for malformed or unsupported pages.
223
425
  }
224
- return { markdown: htmlToMarkdownFallback(html), extractor: "basic", ...advertised };
426
+ return {
427
+ markdown: stripExtractedCssCruft(htmlToMarkdownFallback(html)),
428
+ extractor: "basic",
429
+ ...advertised,
430
+ };
225
431
  }