@zeldrisho/pi-web-fetch 0.7.0 → 0.7.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,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.1] - 2026-08-28
11
+
12
+ ### Fixed
13
+
14
+ - Prevent immediately unreachable pinned addresses from emitting uncaught socket errors and terminating Pi under concurrent fetch load
15
+ - 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`
16
+ - 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
17
+
10
18
  ## [0.7.0] - 2026-08-24
11
19
 
12
20
  ### Added
@@ -102,7 +110,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
102
110
 
103
111
  - Add bounded public page fetching ([b979496](https://github.com/zeldrisho/pi-packages/commit/b979496b32de1cead172ba570307e4a4a7b3421d))
104
112
 
105
- [Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.7.0...HEAD
113
+ [Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.7.1...HEAD
114
+ [0.7.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.7.0...pi-web-fetch-v0.7.1
106
115
  [0.7.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.6.1...pi-web-fetch-v0.7.0
107
116
  [0.6.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.6.0...pi-web-fetch-v0.6.1
108
117
  [0.6.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.5.4...pi-web-fetch-v0.6.0
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.7.1",
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/content.ts CHANGED
@@ -4,6 +4,12 @@ const CONTENT_LINE_BUDGET = Math.max(1, DEFAULT_MAX_LINES - 10);
4
4
  const CONTENT_BYTE_BUDGET = Math.max(1_024, DEFAULT_MAX_BYTES - 2_048);
5
5
  const encoder = new TextEncoder();
6
6
 
7
+ /**
8
+ * A complete fetched document with extracted content and metadata.
9
+ *
10
+ * Contains the full markdown representation along with metadata about
11
+ * the extraction method and any special handling that was applied.
12
+ */
7
13
  export interface CompleteDocument {
8
14
  url: string;
9
15
  contentType: string;
@@ -24,6 +30,12 @@ export interface CompleteDocument {
24
30
  markdownAlternateFallback?: boolean;
25
31
  }
26
32
 
33
+ /**
34
+ * A paginated chunk of fetched content with offset tracking.
35
+ *
36
+ * Extends CompleteDocument with pagination metadata to support
37
+ * reading large documents in bounded chunks.
38
+ */
27
39
  export interface FetchResult extends CompleteDocument {
28
40
  offset: number;
29
41
  nextOffset?: number;
@@ -55,6 +67,18 @@ function boundedContentChunk(value: string, offset: number, maxCharacters: numbe
55
67
  return sliceByByteLength(chunk, CONTENT_BYTE_BUDGET);
56
68
  }
57
69
 
70
+ /**
71
+ * Slice a complete document into a bounded content chunk.
72
+ *
73
+ * Extracts a chunk of content starting at the given offset, respecting
74
+ * character, line, and byte limits. Adds pagination metadata and
75
+ * continuation instructions when content is truncated.
76
+ *
77
+ * @param document - Complete document to slice
78
+ * @param offset - Character offset to start reading from
79
+ * @param maxCharacters - Maximum characters to include in the chunk
80
+ * @returns Paginated fetch result with content chunk and metadata
81
+ */
58
82
  export function sliceCompleteDocument(
59
83
  document: CompleteDocument,
60
84
  offset: number,
package/src/extract.ts CHANGED
@@ -76,6 +76,78 @@ 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
+ /**
89
+ * Type guard that checks if an unknown value is a string.
90
+ *
91
+ * @param value - The value to check
92
+ * @returns `true` if the value is a string
93
+ */
94
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- type-guard helper intentionally narrows unknown at call site
95
+ function isString(value: unknown): value is string {
96
+ return typeof value === "string";
97
+ }
98
+
99
+ /** Returns true when a rejection looks like `ERR_INVALID_URL` for a bare pathname. */
100
+ function isBarePathnameInvalidUrl(cause: unknown): boolean {
101
+ if (!(cause instanceof Error)) return false;
102
+ if (!/Invalid URL/i.test(cause.message)) return false;
103
+ // SAFETY: Node's ERR_INVALID_URL extends Error with an `input` string property holding the offending URL.
104
+ const input = (cause as NodeJS.ErrnoException & { input?: unknown }).input;
105
+ if (isString(input) && input.startsWith("/")) return true;
106
+ // SAFETY: Error.stack is an optional string populated by V8; we coerce it for pattern matching.
107
+ const detail = `${cause.message}\n${(cause as Error).stack ?? ""}`;
108
+ // Node's ERR_INVALID_URL prints `input: '/path'` in the message/stack even when
109
+ // `input` is not on the error instance in some Node versions.
110
+ return /input:\s*'\/[^']*'/.test(detail);
111
+ }
112
+
113
+ /**
114
+ * Removes chrome wrappers that pollute Defuddle's article extraction on GitHub releases.
115
+ *
116
+ * @param document - The document from which to remove chrome wrappers
117
+ * @param baseUrl - The absolute URL of the page, used to determine if chrome should be stripped
118
+ */
119
+ function stripChromeWrappers(document: Document, baseUrl: URL): void {
120
+ // Only strip chrome wrappers for GitHub release pages
121
+ const isGitHubRelease =
122
+ baseUrl.hostname === "github.com" && /\/releases(?:\/|$)/.test(baseUrl.pathname);
123
+ if (!isGitHubRelease) return;
124
+ for (const element of document.querySelectorAll("nav, header, footer, aside")) {
125
+ element.remove();
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Collapses consecutive duplicate markdown link lines (e.g. duplicated [Latest] on GitHub releases).
131
+ *
132
+ * @param markdown - The markdown string to process
133
+ * @returns The markdown with adjacent duplicate links removed
134
+ */
135
+ function deduplicateAdjacentLinks(markdown: string): string {
136
+ const lines = markdown.split("\n");
137
+ const out: string[] = [];
138
+ let previousTrimmed: string | undefined;
139
+ for (const line of lines) {
140
+ const trimmed = line.trim();
141
+ const isLinkLine =
142
+ /^\[[^\]]+\]\(<[^>]+>\)$/.test(trimmed) || /^\[[^\]]+\]\([^)]+\)$/.test(trimmed);
143
+ if (isLinkLine && trimmed === previousTrimmed) continue;
144
+ out.push(line);
145
+ previousTrimmed = isLinkLine ? trimmed : undefined;
146
+ }
147
+ // Also collapse duplicate adjacent blocks like a repeated vite-plus tag link paragraph.
148
+ return out.join("\n").replace(/(\[[^\n]+?\([^)]+\)[^\n]*\n)\1/g, "$1");
149
+ }
150
+
79
151
  /**
80
152
  * Runs Defuddle over an already-normalized document with Markdown extraction enabled.
81
153
  *
@@ -94,9 +166,10 @@ export function htmlToMarkdownFallback(html: string): string {
94
166
  * To keep `extractHtmlToMarkdown` from ever propagating such a failure, this
95
167
  * helper arms a scoped `unhandledRejection` listener for the lifetime of the
96
168
  * 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
169
+ * rejection as a Defuddle failure when its message or stack mentions Defuddle
170
+ * or looks like a bare-pathname `ERR_INVALID_URL` occurring within the armed
171
+ * window, so unrelated rejections from other concurrent work are ignored and do
172
+ * not force a spurious fallback to the basic extractor. After Defuddle resolves we
100
173
  * flush a microtask and a macrotask so any rejection Defuddle scheduled settles
101
174
  * inside the armed window; a rejection observed there is re-thrown so the
102
175
  * caller falls back. Deeply-nested timers in Defuddle are out of scope and would
@@ -112,13 +185,11 @@ async function runDefuddle(
112
185
  ): Promise<DefuddleResponse | undefined> {
113
186
  let escapedRejection: unknown = undefined;
114
187
  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
188
  const captureUnhandled = (cause: unknown): void => {
118
189
  if (!armed || escapedRejection !== undefined) return;
119
190
  const detail =
120
191
  cause instanceof Error ? `${cause.message}\n${cause.stack ?? ""}` : String(cause);
121
- if (/defuddle/i.test(detail)) escapedRejection = cause;
192
+ if (/defuddle/i.test(detail) || isBarePathnameInvalidUrl(cause)) escapedRejection = cause;
122
193
  };
123
194
  process.on("unhandledRejection", captureUnhandled);
124
195
  try {
@@ -200,15 +271,17 @@ export async function extractHtmlToMarkdown(
200
271
  }> {
201
272
  let advertised: AdvertisedLinks = {};
202
273
  try {
274
+ assertAbsoluteHttpUrl(baseUrl);
203
275
  const { document } = parseHTML(html);
204
276
  removeMalformedSchemaOrgData(document);
205
277
  normalizeSelectorUnsafeIds(document);
278
+ stripChromeWrappers(document, baseUrl);
206
279
  advertised = readAdvertisedLinks(document, baseUrl);
207
280
  // Pass the full absolute URL so Defuddle resolves relative links (e.g.
208
281
  // `/owner/repo/releases`) and metadata against the real origin instead of
209
282
  // dropping the scheme and host and constructing `new URL(pathname)`.
210
283
  const result = await runDefuddle(document, baseUrl.href);
211
- const markdown = result?.content?.trim() ?? "";
284
+ const markdown = deduplicateAdjacentLinks(result?.content?.trim() ?? "");
212
285
  const trimmedTitle = result?.title?.trim();
213
286
  if (markdown) {
214
287
  return {
package/src/fetch.ts CHANGED
@@ -27,8 +27,21 @@ export function normalizeGitHubRawUrl(rawUrl: string): string {
27
27
  const url = new URL(rawUrl);
28
28
  if (url.protocol !== "https:") return rawUrl;
29
29
  if (url.hostname === "github.com") {
30
- if (!url.pathname.includes("/blob/")) return rawUrl;
31
- return `https://raw.githubusercontent.com${url.pathname.replace("/blob/", "/")}${url.search}`;
30
+ if (url.pathname.includes("/blob/")) {
31
+ return `https://raw.githubusercontent.com${url.pathname.replace("/blob/", "/")}${url.search}`;
32
+ }
33
+ // Parse pathname segments: /owner/repo/tree/ref/path
34
+ const segments = url.pathname.split("/").filter(Boolean);
35
+ if (segments.length >= 4 && segments[2] === "tree") {
36
+ const lastSegment = segments[segments.length - 1];
37
+ if (lastSegment.includes(".")) {
38
+ // Reconstruct path: /owner/repo/ref/path (remove "tree" segment)
39
+ const newPath = `/${segments[0]}/${segments[1]}/${segments.slice(3).join("/")}`;
40
+ return `https://raw.githubusercontent.com${newPath}${url.search}`;
41
+ }
42
+ return rawUrl;
43
+ }
44
+ return rawUrl;
32
45
  }
33
46
  if (url.hostname === "gist.github.com") {
34
47
  const segments = url.pathname.split("/").filter(Boolean);
@@ -242,11 +255,41 @@ async function documentFromResponse(
242
255
  };
243
256
  }
244
257
 
258
+ /**
259
+ * Validates that a URL string is an absolute http or https URL.
260
+ *
261
+ * @param value - The URL string to validate
262
+ * @returns The parsed URL object
263
+ * @throws If the URL is invalid or not http(s)
264
+ */
265
+ function assertAbsoluteHttpUrlForFetch(value: string): URL {
266
+ let url: URL;
267
+ try {
268
+ url = new URL(value);
269
+ } catch {
270
+ throw new Error(`Invalid URL: ${value}`);
271
+ }
272
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
273
+ throw new Error(`web_fetch only supports http(s) URLs: ${value}`);
274
+ }
275
+ return url;
276
+ }
277
+
278
+ /**
279
+ * Fetches a URL and returns the complete document with extracted content.
280
+ *
281
+ * @param rawUrl - The URL to fetch
282
+ * @param signal - Optional abort signal to cancel the request
283
+ * @param dependencies - Dependencies for network policy validation, transport, and HTML extraction
284
+ * @returns The complete document with extracted markdown content
285
+ * @throws If the request times out, is cancelled, or encounters an error
286
+ */
245
287
  export async function fetchCompleteDocument(
246
288
  rawUrl: string,
247
289
  signal: AbortSignal | undefined,
248
290
  dependencies: FetchRemoteDependencies,
249
291
  ): Promise<CompleteDocument> {
292
+ assertAbsoluteHttpUrlForFetch(normalizeGitHubRawUrl(rawUrl));
250
293
  const controller = new AbortController();
251
294
  const timeoutMs = dependencies.timeoutMs ?? REQUEST_TIMEOUT_MS;
252
295
  const extractHtml = dependencies.extractHtml ?? extractHtmlToMarkdown;
@@ -278,6 +321,16 @@ export async function fetchCompleteDocument(
278
321
  }
279
322
  }
280
323
 
324
+ /**
325
+ * Fetches a URL and returns a slice of the content starting at the given offset.
326
+ *
327
+ * @param rawUrl - The URL to fetch
328
+ * @param offset - The character offset to start slicing from
329
+ * @param maxCharacters - The maximum number of characters to return
330
+ * @param signal - Optional abort signal to cancel the request
331
+ * @param dependencies - Dependencies for network policy validation, transport, and HTML extraction
332
+ * @returns The sliced fetch result with content and metadata
333
+ */
281
334
  export async function fetchRemoteContent(
282
335
  rawUrl: string,
283
336
  offset: number,
package/src/index.ts CHANGED
@@ -51,6 +51,14 @@ export const webFetchParameters = Type.Object({
51
51
  ),
52
52
  });
53
53
 
54
+ /**
55
+ * Pi web fetch extension that registers the web_fetch tool.
56
+ *
57
+ * Provides a tool for fetching public HTTP(S) pages and converting them to
58
+ * Markdown with support for pagination via offset/nextOffset parameters.
59
+ *
60
+ * @param pi - The extension API instance
61
+ */
54
62
  export default function (pi: ExtensionAPI) {
55
63
  pi.registerTool({
56
64
  name: "web_fetch",
package/src/limits.ts CHANGED
@@ -1,6 +1,17 @@
1
+ /** Default starting offset for content extraction (characters). */
1
2
  export const FETCH_DEFAULT_OFFSET = 0;
3
+
4
+ /** Minimum allowed value for maxCharacters parameter. */
2
5
  export const FETCH_MIN_MAX_CHARACTERS = 1_000;
6
+
7
+ /** Default maximum characters to return per fetch. */
3
8
  export const FETCH_DEFAULT_MAX_CHARACTERS = 6_000;
9
+
10
+ /** Maximum allowed characters to return per fetch. */
4
11
  export const FETCH_MAX_CHARACTERS = 30_000;
12
+
13
+ /** Maximum allowed offset for content extraction (characters). */
5
14
  export const FETCH_MAX_OFFSET_CHARACTERS = 20_000_000;
15
+
16
+ /** Maximum allowed length for input URLs (characters). */
6
17
  export const FETCH_MAX_URL_CHARACTERS = 2_048;
@@ -1,11 +1,13 @@
1
1
  import { lookup as dnsLookup } from "node:dns/promises";
2
2
  import { BlockList, isIP } from "node:net";
3
3
 
4
- // Reviewed 2026-08-03 against the IANA IPv4 and IPv6 Special-Purpose Address Registries:
5
- // https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml
6
- // https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry.xhtml
7
- // Keep non-global, documentation, benchmarking, multicast, and reserved space blocked. The explicit
8
- // endpoints make registry reviews and boundary tests auditable without changing the pinning flow.
4
+ /**
5
+ * IPv4 address ranges that are blocked from network requests.
6
+ *
7
+ * Reviewed 2026-08-03 against the IANA IPv4 Special-Purpose Address Registry:
8
+ * https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml
9
+ * Includes non-global, documentation, benchmarking, multicast, and reserved ranges.
10
+ */
9
11
  export const BLOCKED_IPV4_RANGES = [
10
12
  ["0.0.0.0", 8, "0.0.0.0", "0.255.255.255", "current network"],
11
13
  ["10.0.0.0", 8, "10.0.0.0", "10.255.255.255", "private use"],
@@ -27,6 +29,13 @@ export const BLOCKED_IPV4_RANGES = [
27
29
  ["240.0.0.0", 4, "240.0.0.0", "255.255.255.255", "reserved"],
28
30
  ] as const;
29
31
 
32
+ /**
33
+ * IPv6 address ranges that are blocked from network requests.
34
+ *
35
+ * Reviewed 2026-08-03 against the IANA IPv6 Special-Purpose Address Registry:
36
+ * https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry.xhtml
37
+ * Includes non-global, documentation, benchmarking, multicast, and reserved ranges.
38
+ */
30
39
  export const BLOCKED_IPV6_RANGES = [
31
40
  ["::", 128, "::", "::", "unspecified"],
32
41
  ["::1", 128, "::1", "::1", "loopback"],
@@ -68,6 +77,12 @@ for (const [network, prefix] of GLOBALLY_REACHABLE_IPV6_EXCEPTIONS) {
68
77
  allowedIPv6Addresses.addSubnet(network, prefix, "ipv6");
69
78
  }
70
79
 
80
+ /**
81
+ * A validated and resolved network target ready for connection.
82
+ *
83
+ * Contains the target URL, preferred address, and all resolved addresses
84
+ * for the hostname.
85
+ */
71
86
  export interface ValidatedTarget {
72
87
  url: URL;
73
88
  /** The preferred address used for the first connection attempt. */
@@ -80,6 +95,7 @@ export interface ValidatedTarget {
80
95
  addresses?: string[];
81
96
  }
82
97
 
98
+ /** Function type for resolving a hostname to IP addresses. */
83
99
  export type ResolveAddresses = (hostname: string) => Promise<string[]>;
84
100
 
85
101
  /**
@@ -3,8 +3,10 @@ import { awaitWithAbort } from "./abort";
3
3
  import { validateRemoteUrl, type ValidatedTarget } from "./network-policy";
4
4
  import { requestPinned, responseHeader } from "./network-transport";
5
5
 
6
+ /** Maximum number of HTTP redirects to follow before aborting. */
6
7
  export const FETCH_MAX_REDIRECTS = 5;
7
8
 
9
+ /** Optional dependencies for redirect handling (used for testing). */
8
10
  export interface RedirectDependencies {
9
11
  validateUrl?: (value: string | URL) => Promise<ValidatedTarget>;
10
12
  request?: (target: ValidatedTarget, signal: AbortSignal) => Promise<IncomingMessage>;
@@ -4,6 +4,7 @@ import { isIP, type LookupFunction } from "node:net";
4
4
  import { formatSize } from "@earendil-works/pi-coding-agent";
5
5
  import type { ValidatedTarget } from "./network-policy";
6
6
 
7
+ /** Maximum size in bytes for raw HTTP response bodies. */
7
8
  export const FETCH_MAX_BYTES = 5 * 1_024 * 1_024;
8
9
 
9
10
  /**
@@ -63,8 +64,13 @@ async function requestOnce(
63
64
  const family = isIP(address);
64
65
  if (family !== 4 && family !== 6) throw new Error(`web_fetch could not resolve ${address}.`);
65
66
  const lookup: LookupFunction = (_hostname, options, callback) => {
66
- if (options.all) callback(null, [{ address, family }]);
67
- else callback(null, address, family);
67
+ // Match dns.lookup's asynchronous callback contract. Calling back inline
68
+ // lets an immediately unreachable address emit a socket error before
69
+ // node:http has finished installing its forwarding listeners.
70
+ queueMicrotask(() => {
71
+ if (options.all) callback(null, [{ address, family }]);
72
+ else callback(null, address, family);
73
+ });
68
74
  };
69
75
  const request = target.url.protocol === "https:" ? httpsRequest : httpRequest;
70
76
  return await new Promise((resolve, reject) => {
@@ -161,6 +167,18 @@ export function responseHeader(response: IncomingMessage, name: string): string
161
167
  return Array.isArray(value) ? value[0] : value;
162
168
  }
163
169
 
170
+ /**
171
+ * Read and buffer the complete response body.
172
+ *
173
+ * Enforces the maximum byte limit and respects abort signals during the read.
174
+ * Destroys the response stream if limits are exceeded or abort is signaled.
175
+ *
176
+ * @param response - HTTP response to read
177
+ * @param maxBytes - Maximum allowed response size in bytes
178
+ * @param signal - Optional abort signal to cancel the read
179
+ * @returns Buffered response body as bytes
180
+ * @throws When response exceeds maxBytes or abort signal fires
181
+ */
164
182
  export async function readResponseBytes(
165
183
  response: IncomingMessage,
166
184
  maxBytes: number,
@@ -208,6 +226,16 @@ export async function readResponseBytes(
208
226
  return output;
209
227
  }
210
228
 
229
+ /**
230
+ * Decode HTTP response bytes to a string using the charset from Content-Type.
231
+ *
232
+ * Extracts the charset parameter from the Content-Type header and uses it
233
+ * to decode the response. Falls back to UTF-8 if charset is missing or invalid.
234
+ *
235
+ * @param bytes - Raw response bytes to decode
236
+ * @param contentTypeHeader - Value of the Content-Type response header
237
+ * @returns Decoded response string
238
+ */
211
239
  export function decodeResponse(bytes: Uint8Array, contentTypeHeader: string): string {
212
240
  const charset = contentTypeHeader.match(/(?:^|;)\s*charset\s*=\s*["']?([^;"'\s]+)/i)?.[1];
213
241
  try {
package/src/service.ts CHANGED
@@ -47,6 +47,17 @@ function safeUrl(value: string): URL | undefined {
47
47
  }
48
48
  }
49
49
 
50
+ /**
51
+ * Classify the kind of content returned by a fetch operation.
52
+ *
53
+ * Determines the content type based on URL patterns, extractor used,
54
+ * and whether an app shell was detected.
55
+ *
56
+ * @param url - The fetched URL
57
+ * @param extractor - Extraction method used
58
+ * @param shellSuspected - Whether the page appears to be an app shell
59
+ * @returns Content classification
60
+ */
50
61
  export function classifyContentKind(
51
62
  url: string,
52
63
  extractor: CompleteDocument["extractor"],
@@ -69,6 +80,17 @@ export function classifyContentKind(
69
80
  return "unknown";
70
81
  }
71
82
 
83
+ /**
84
+ * Classify confidence level for fetched content quality.
85
+ *
86
+ * Determines how confident we are that the extracted content accurately
87
+ * represents the source page, based on extraction method and content length.
88
+ *
89
+ * @param extractor - Extraction method used
90
+ * @param shellSuspected - Whether the page appears to be an app shell
91
+ * @param markdownLength - Length of extracted markdown content
92
+ * @returns Confidence level (high, medium, or low)
93
+ */
72
94
  export function classifyConfidence(
73
95
  extractor: CompleteDocument["extractor"],
74
96
  shellSuspected: boolean,
@@ -87,10 +109,30 @@ export function classifyConfidence(
87
109
  * Returns them ordered shallow → deep, and an empty list for non-HTTP(S) targets or when
88
110
  * the page itself is already a `llms.txt` path, so a fallback never retries itself.
89
111
  */
112
+ function isGitHubLikeHost(hostname: string): boolean {
113
+ return (
114
+ hostname === "github.com" ||
115
+ hostname === "raw.githubusercontent.com" ||
116
+ hostname === "gist.github.com" ||
117
+ hostname === "gist.githubusercontent.com"
118
+ );
119
+ }
120
+
121
+ /**
122
+ * Build candidate /llms.txt URLs to probe for a given page.
123
+ *
124
+ * Generates a list of potential /llms.txt index URLs by checking the site root
125
+ * and ancestor directories up to a maximum depth. Returns URLs ordered from
126
+ * shallowest to deepest.
127
+ *
128
+ * @param rawUrl - The original page URL
129
+ * @returns Array of candidate /llms.txt URLs to probe (empty for non-HTTP/S or GitHub)
130
+ */
90
131
  export function buildLlmsTxtCandidateUrls(rawUrl: string): URL[] {
91
132
  try {
92
133
  const url = new URL(rawUrl);
93
134
  if (url.protocol !== "https:" && url.protocol !== "http:") return [];
135
+ if (isGitHubLikeHost(url.hostname)) return [];
94
136
  if (url.pathname === "/llms.txt" || url.pathname.endsWith("/llms.txt")) return [];
95
137
  // Ancestor directories only, one level deep: for `/r2/buckets/x` probe
96
138
  // `/llms.txt` and `/r2/llms.txt`, never deeper. A trailing slash marks the final
@@ -232,6 +274,13 @@ async function fetchDocumentWithLlmsTxtSupport(
232
274
  signal: AbortSignal | undefined,
233
275
  dependencies: FetchRemoteDependencies,
234
276
  ): Promise<CompleteDocument> {
277
+ try {
278
+ if (isGitHubLikeHost(new URL(rawUrl).hostname)) {
279
+ return fetchCompleteDocument(rawUrl, signal, dependencies);
280
+ }
281
+ } catch {
282
+ // Invalid URL falls through to fetchCompleteDocument which will throw.
283
+ }
235
284
  const candidates = buildLlmsTxtCandidateUrls(rawUrl);
236
285
  const [primary, blindIndex] = await Promise.all([
237
286
  fetchCompleteDocument(rawUrl, signal, dependencies),
@@ -277,18 +326,21 @@ function resolveCacheDirectory(name: string): string {
277
326
  return base;
278
327
  }
279
328
 
329
+ /** Parameters for a web fetch operation. */
280
330
  export interface WebFetchParameters {
281
331
  url: string;
282
332
  offset?: number;
283
333
  maxCharacters?: number;
284
334
  }
285
335
 
336
+ /** Details about content truncation and pagination strategy. */
286
337
  export interface WebFetchTruncationDetails {
287
338
  truncated: boolean;
288
339
  strategy: "continuation" | "none";
289
340
  nextOffset?: number;
290
341
  }
291
342
 
343
+ /** Comprehensive metadata about a web fetch result. */
292
344
  export interface WebFetchDetails {
293
345
  url: string;
294
346
  requestedUrl: string;