@zeldrisho/pi-web-fetch 0.9.2 → 0.9.3

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/src/fetch.ts CHANGED
@@ -1,4 +1,13 @@
1
1
  import type { IncomingMessage } from "node:http";
2
+ import {
3
+ lineFragmentOffsets,
4
+ markdownFragmentOffsets,
5
+ normalizeGitHubRawUrl,
6
+ parseLinkHeaderForAgentHints,
7
+ } from "./fetch-metadata";
8
+
9
+ export { normalizeGitHubRawUrl, parseLinkHeaderForAgentHints } from "./fetch-metadata";
10
+
2
11
  import { awaitWithAbort } from "./abort";
3
12
  import { sliceCompleteDocument, type CompleteDocument, type FetchResult } from "./content";
4
13
  import { diagnoseExtraction, extractDocumentLinks, hasExtractionWarning } from "./evidence";
@@ -14,63 +23,6 @@ import {
14
23
  responseHeaderValues,
15
24
  } from "./network-transport";
16
25
 
17
- /**
18
- * Rewrites GitHub source URLs to their raw counterparts so file contents are fetched as
19
- * clean plain text instead of Defuddle's noisy rendered view:
20
- *
21
- * - `github.com` `blob` URLs become their `raw.githubusercontent.com` counterpart.
22
- * - Bare gist pages (`gist.github.com/<user>/<id>`) get `/raw` appended; the redirect to
23
- * `gist.githubusercontent.com` is followed by the normal redirect policy.
24
- *
25
- * @param rawUrl - The URL to normalize
26
- * @returns The rewritten raw URL, or the input unchanged for non-GitHub and non-source URLs
27
- */
28
- export function normalizeGitHubRawUrl(rawUrl: string): string {
29
- try {
30
- const url = new URL(rawUrl);
31
-
32
- if (url.protocol !== "https:") return rawUrl;
33
-
34
- if (url.hostname === "github.com") {
35
- if (url.pathname.includes("/blob/")) {
36
- return `https://raw.githubusercontent.com${url.pathname.replace("/blob/", "/")}${url.search}`;
37
- }
38
-
39
- // Parse pathname segments: /owner/repo/tree/ref/path
40
- const segments = url.pathname.split("/").filter(Boolean);
41
-
42
- if (segments.length >= 4 && segments[2] === "tree") {
43
- const lastSegment = segments[segments.length - 1];
44
-
45
- if (lastSegment.includes(".")) {
46
- // Reconstruct path: /owner/repo/ref/path (remove "tree" segment)
47
- const newPath = `/${segments[0]}/${segments[1]}/${segments.slice(3).join("/")}`;
48
-
49
- return `https://raw.githubusercontent.com${newPath}${url.search}`;
50
- }
51
-
52
- return rawUrl;
53
- }
54
-
55
- return rawUrl;
56
- }
57
-
58
- if (url.hostname === "gist.github.com") {
59
- const segments = url.pathname.split("/").filter(Boolean);
60
-
61
- // Only rewrite bare gist pages. Subpages such as `/revisions`, `/forks`, or an
62
- // explicit `/raw` path already point at a usable resource and stay untouched.
63
- if (segments.length !== 2) return rawUrl;
64
-
65
- return `https://gist.github.com/${segments[0]}/${segments[1]}/raw${url.search}`;
66
- }
67
-
68
- return rawUrl;
69
- } catch {
70
- return rawUrl;
71
- }
72
- }
73
-
74
26
  /** @deprecated Prefer diagnoseExtraction for explicit extraction-quality signals. */
75
27
  export function detectAppShell(raw: string, markdown: string): boolean {
76
28
  return hasExtractionWarning(diagnoseExtraction(raw, markdown));
@@ -78,176 +30,12 @@ export function detectAppShell(raw: string, markdown: string): boolean {
78
30
 
79
31
  const REQUEST_TIMEOUT_MS = 20_000;
80
32
 
81
- /** Indexes GitHub-style Markdown heading slugs without treating fenced examples as headings. */
82
- function markdownFragmentOffsets(markdown: string) {
83
- const offsets: Record<string, number> = {};
84
- const duplicates = new Map<string, number>();
85
- const lines = markdown.split("\n");
86
- let offset = 0;
87
- let fence: { marker: string; length: number } | undefined;
88
-
89
- const addHeading = (text: string, headingOffset: number) => {
90
- let withoutHtml = "";
91
-
92
- for (let index = 0; index < text.length; index += 1) {
93
- if (text[index] !== "<") {
94
- withoutHtml += text[index];
95
- continue;
96
- }
97
-
98
- const tagEnd = text.indexOf(">", index + 1);
99
-
100
- if (tagEnd < 0) {
101
- withoutHtml += text.slice(index + 1);
102
- break;
103
- }
104
-
105
- index = tagEnd;
106
- }
107
-
108
- const base = withoutHtml
109
- .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
110
- .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
111
- .replace(/[`*_~]/g, "")
112
- .normalize("NFKD")
113
- .replace(/[\u0300-\u036f]/g, "")
114
- .toLowerCase()
115
- .replace(/[^\p{Letter}\p{Number} _-]/gu, "")
116
- .trim()
117
- .replace(/[ _]+/g, "-");
118
-
119
- if (!base) return;
120
-
121
- const duplicate = duplicates.get(base) ?? 0;
122
- duplicates.set(base, duplicate + 1);
123
- offsets[duplicate ? `${base}-${duplicate}` : base] = headingOffset;
124
- };
125
-
126
- for (let index = 0; index < lines.length; index += 1) {
127
- const line = lines[index];
128
- const fenceMatch = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
129
-
130
- if (fenceMatch) {
131
- const marker = fenceMatch[1][0];
132
-
133
- if (!fence) fence = { marker, length: fenceMatch[1].length };
134
- else if (fence.marker === marker && fenceMatch[1].length >= fence.length) fence = undefined;
135
- } else if (!fence) {
136
- const atxHeading = /^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/.exec(line);
137
- const setextHeading = /^\s{0,3}(=+|-+)\s*$/.test(lines[index + 1] ?? "");
138
-
139
- if (atxHeading) addHeading(atxHeading[1], offset);
140
- else if (setextHeading) addHeading(line.trim(), offset);
141
- }
142
-
143
- offset += line.length + 1;
144
- }
145
-
146
- return offsets;
147
- }
148
-
149
- /** Indexes GitHub line anchors such as `#L78` in plain-text source files. */
150
- function lineFragmentOffsets(text: string) {
151
- const offsets: Record<string, number> = {};
152
- let offset = 0;
153
-
154
- for (const [index, line] of text.split("\n").entries()) {
155
- offsets[`L${index + 1}`] = offset;
156
- offset += line.length + 1;
157
- }
158
-
159
- return offsets;
160
- }
161
-
162
33
  /** Agent-discovery hints advertised in an HTTP `Link:` header. */
163
34
  export interface LinkHeaderAgentHints {
164
35
  describedBy?: string;
165
36
  markdownAlternate?: string;
166
37
  }
167
38
 
168
- /**
169
- * Parses an HTTP `Link:` header for the agent-discovery link relations defined by
170
- * llmstxt.org v2: `rel="describedby"` (the covering `llms.txt`) and
171
- * `rel="alternate" type="text/markdown"` (the Markdown version of the resource).
172
- *
173
- * @param header - The raw `Link:` header value
174
- * @param baseUrl - URL used to resolve relative references
175
- * @returns The first advertised target of each relation, when present
176
- */
177
- export function parseLinkHeaderForAgentHints(
178
- header: string,
179
- baseUrl: string | URL,
180
- ): LinkHeaderAgentHints {
181
- // Split on commas outside <...> and quoted strings, since URIs and quoted values may
182
- // themselves contain commas.
183
- const directives: string[] = [];
184
- let current = "";
185
- let inQuotes = false;
186
- let inAngleBrackets = false;
187
-
188
- for (const character of header) {
189
- if (character === '"') inQuotes = !inQuotes;
190
-
191
- if (character === "<") inAngleBrackets = true;
192
-
193
- if (character === ">" && !inQuotes) inAngleBrackets = false;
194
-
195
- if (character === "," && !inQuotes && !inAngleBrackets) {
196
- directives.push(current);
197
- current = "";
198
- } else {
199
- current += character;
200
- }
201
- }
202
-
203
- directives.push(current);
204
-
205
- let describedBy: string | undefined;
206
- let markdownAlternate: string | undefined;
207
-
208
- for (const directive of directives) {
209
- const match = /^\s*<([^>]*)>\s*(.*)$/.exec(directive);
210
-
211
- if (!match) continue;
212
- const [, rawTarget, rawParameters] = match;
213
- const parameters = new Map<string, string>();
214
-
215
- for (const parameter of rawParameters.split(";")) {
216
- const equals = parameter.indexOf("=");
217
-
218
- if (equals === -1) continue;
219
- const key = parameter.slice(0, equals).trim().toLowerCase();
220
-
221
- const value = parameter
222
- .slice(equals + 1)
223
- .trim()
224
- .replace(/^"|"$/g, "");
225
-
226
- parameters.set(key, value);
227
- }
228
-
229
- const relations = (parameters.get("rel") ?? "").toLowerCase().split(/\s+/);
230
- const type = (parameters.get("type") ?? "").toLowerCase();
231
- let resolved: string | undefined;
232
-
233
- try {
234
- resolved = new URL(rawTarget.trim(), baseUrl).href;
235
- } catch {
236
- continue;
237
- }
238
-
239
- if (!resolved) continue;
240
-
241
- if (!describedBy && relations.includes("describedby")) describedBy = resolved;
242
-
243
- if (!markdownAlternate && relations.includes("alternate") && type === "text/markdown") {
244
- markdownAlternate = resolved;
245
- }
246
- }
247
-
248
- return { describedBy, markdownAlternate };
249
- }
250
-
251
39
  export interface FetchRemoteDependencies extends RedirectDependencies {
252
40
  extractHtml?: typeof extractHtmlToMarkdown;
253
41
  timeoutMs?: number;
@@ -0,0 +1,99 @@
1
+ function stripMarkupForFragmentSlug(value: string): string {
2
+ let result = "";
3
+
4
+ for (let index = 0; index < value.length; index += 1) {
5
+ if (value[index] !== "<") {
6
+ result += value[index];
7
+ continue;
8
+ }
9
+
10
+ const tagEnd = value.indexOf(">", index + 1);
11
+
12
+ if (tagEnd < 0) {
13
+ // Preserve the old behavior for an unterminated tag: only discard `<`.
14
+ result += value.slice(index + 1);
15
+ break;
16
+ }
17
+
18
+ index = tagEnd;
19
+ }
20
+
21
+ return result;
22
+ }
23
+
24
+ function fragmentSlug(value: string): string {
25
+ return stripMarkupForFragmentSlug(value)
26
+ .normalize("NFKD")
27
+ .replace(/[\u0300-\u036f]/g, "")
28
+ .toLowerCase()
29
+ .replace(/[^\p{Letter}\p{Number} _-]/gu, "")
30
+ .trim()
31
+ .replace(/[ _]+/g, "-");
32
+ }
33
+
34
+ /** Finds the extracted Markdown position corresponding to HTML fragment IDs. */
35
+ interface FragmentOffsets {
36
+ [fragment: string]: number;
37
+ }
38
+
39
+ export function buildFragmentOffsets(
40
+ document: Document,
41
+ markdown: string,
42
+ originalIds?: ReadonlyMap<HTMLElement, string>,
43
+ ): FragmentOffsets {
44
+ const lines = markdown.split("\n");
45
+
46
+ const headings = lines
47
+ .map((line, index) => {
48
+ const match = /^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/.exec(line);
49
+
50
+ return match
51
+ ? {
52
+ slug: fragmentSlug(match[1]),
53
+ offset: lines.slice(0, index).join("\n").length + (index ? 1 : 0),
54
+ }
55
+ : undefined;
56
+ })
57
+ .filter((heading): heading is { slug: string; offset: number } => Boolean(heading));
58
+
59
+ const offsets: Record<string, number> = {};
60
+ const used = new Set<number>();
61
+
62
+ for (const element of document.querySelectorAll<HTMLElement>("[id], a[name]")) {
63
+ const id =
64
+ originalIds?.get(element) ?? element.getAttribute("id") ?? element.getAttribute("name");
65
+
66
+ if (!id) continue;
67
+ const slug = fragmentSlug(id);
68
+ const textSlug = fragmentSlug(element.textContent ?? "");
69
+
70
+ const heading = headings.find(
71
+ (candidate) =>
72
+ candidate.slug === slug ||
73
+ (textSlug && candidate.slug === textSlug && !used.has(candidate.offset)),
74
+ );
75
+
76
+ if (heading) {
77
+ offsets[id] = heading.offset;
78
+ used.add(heading.offset);
79
+ } else {
80
+ const text = (element.textContent ?? "").trim();
81
+
82
+ if (text) {
83
+ const textOffset = markdown.toLowerCase().indexOf(text.toLowerCase());
84
+
85
+ if (textOffset >= 0) offsets[id] = textOffset;
86
+ }
87
+ }
88
+ }
89
+
90
+ // Most Markdown producers preserve headings but discard arbitrary HTML IDs.
91
+ // Resolve GitHub-style heading links even when no matching HTML ID survived.
92
+ for (const heading of headings) {
93
+ const base = heading.slug;
94
+
95
+ if (base && offsets[base] === undefined) offsets[base] = heading.offset;
96
+ }
97
+
98
+ return offsets;
99
+ }
@@ -0,0 +1,93 @@
1
+ import type { CompleteDocument } from "./content";
2
+
3
+ /** Removes a URL fragment before acquisition so fragment variants share cache entries. */
4
+ export function urlWithoutFragment(rawUrl: string): string {
5
+ try {
6
+ const url = new URL(rawUrl);
7
+ url.hash = "";
8
+
9
+ return url.href;
10
+ } catch {
11
+ return rawUrl;
12
+ }
13
+ }
14
+
15
+ interface ResolvedFragment {
16
+ fragment?: string;
17
+ offset?: number;
18
+ endOffset?: number;
19
+ }
20
+
21
+ /** Converts a one-based source line and column into a Markdown character offset. */
22
+ function sourceOffset(markdown: string, lineNumber: number, columnNumber = 1): number | undefined {
23
+ if (lineNumber < 1 || columnNumber < 1) return undefined;
24
+
25
+ const lines = markdown.split("\n");
26
+
27
+ if (lineNumber > lines.length) return undefined;
28
+
29
+ let offset = 0;
30
+
31
+ for (let index = 0; index < lineNumber - 1; index += 1) offset += lines[index].length + 1;
32
+
33
+ return offset + Math.min(columnNumber - 1, lines[lineNumber - 1].length);
34
+ }
35
+
36
+ /** Resolves heading and source-location URL fragments against a fetched document. */
37
+ export function resolveFragmentOffset(
38
+ document: CompleteDocument,
39
+ rawUrl: string,
40
+ ): ResolvedFragment {
41
+ let fragment: string | undefined;
42
+
43
+ try {
44
+ const hash = new URL(rawUrl).hash;
45
+
46
+ if (hash) {
47
+ const rawFragment = hash.slice(1);
48
+
49
+ try {
50
+ fragment = decodeURIComponent(rawFragment);
51
+ } catch {
52
+ fragment = rawFragment;
53
+ }
54
+ }
55
+ } catch {
56
+ return {};
57
+ }
58
+
59
+ if (!fragment) return {};
60
+
61
+ const lineAnchor = /^L(\d+)(?:C(\d+))?(?:-L?(\d+)(?:C(\d+))?)?$/i.exec(fragment);
62
+
63
+ if (lineAnchor) {
64
+ const [, firstLine, firstColumn, lastLine, lastColumn] = lineAnchor;
65
+ const offset = sourceOffset(document.markdown, Number(firstLine), Number(firstColumn ?? 1));
66
+
67
+ if (offset === undefined) return { fragment };
68
+
69
+ if (lastLine) {
70
+ const endLine = Number(lastLine);
71
+ const endColumn = lastColumn === undefined ? undefined : Number(lastColumn);
72
+ const lines = document.markdown.split("\n");
73
+ const line = lines[endLine - 1];
74
+
75
+ if (line === undefined) return { fragment };
76
+
77
+ const endOffset =
78
+ endColumn === undefined
79
+ ? sourceOffset(document.markdown, endLine, line.length + 1)
80
+ : sourceOffset(document.markdown, endLine, endColumn + 1);
81
+
82
+ return endOffset !== undefined && endOffset >= offset
83
+ ? { fragment, offset, endOffset }
84
+ : { fragment };
85
+ }
86
+
87
+ return { fragment, offset };
88
+ }
89
+
90
+ const offsets = document.fragmentOffsets ?? {};
91
+
92
+ return { fragment, offset: offsets[fragment] ?? offsets[fragment.toLowerCase()] };
93
+ }
package/src/index.ts CHANGED
@@ -2,13 +2,10 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Text } from "@earendil-works/pi-tui";
3
3
  import { Type } from "typebox";
4
4
  import {
5
- FETCH_DEFAULT_MAX_CHARACTERS,
6
5
  FETCH_DEFAULT_OFFSET,
7
- FETCH_MAX_CHARACTERS,
8
6
  FETCH_MAX_OFFSET_CHARACTERS,
9
7
  FETCH_MAX_QUERY_CHARACTERS,
10
8
  FETCH_MAX_URL_CHARACTERS,
11
- FETCH_MIN_MAX_CHARACTERS,
12
9
  } from "./limits";
13
10
  import { redactUrlForDisplay } from "./redact";
14
11
  import { formatCollapsibleOutput } from "./render";
@@ -54,28 +51,20 @@ export const webFetchParameters = Type.Object({
54
51
  url: Type.String({
55
52
  minLength: 1,
56
53
  maxLength: FETCH_MAX_URL_CHARACTERS,
57
- description: "Public HTTP or HTTPS URL to fetch",
54
+ description: "HTTP or HTTPS page URL",
58
55
  }),
59
56
  query: Type.Optional(
60
57
  Type.String({
61
58
  minLength: 1,
62
59
  maxLength: FETCH_MAX_QUERY_CHARACTERS,
63
- description:
64
- "Optional focus query; returns matching source sections in document order (offsets apply to the focused view)",
60
+ description: "Focus on matching sections; offsets then apply to this focused view",
65
61
  }),
66
62
  ),
67
63
  offset: Type.Optional(
68
64
  Type.Integer({
69
65
  minimum: 0,
70
66
  maximum: FETCH_MAX_OFFSET_CHARACTERS,
71
- description: `Extracted-content character offset to start reading from (default: ${FETCH_DEFAULT_OFFSET}; use nextOffset to continue)`,
72
- }),
73
- ),
74
- maxCharacters: Type.Optional(
75
- Type.Integer({
76
- minimum: FETCH_MIN_MAX_CHARACTERS,
77
- maximum: FETCH_MAX_CHARACTERS,
78
- description: `Maximum returned content characters (default: ${FETCH_DEFAULT_MAX_CHARACTERS})`,
67
+ description: `Character offset in extracted content (default: ${FETCH_DEFAULT_OFFSET}; continue with nextOffset)`,
79
68
  }),
80
69
  ),
81
70
  });