@zeldrisho/pi-web-fetch 0.9.1 → 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/CHANGELOG.md +20 -1
- package/README.md +2 -4
- package/package.json +1 -1
- package/src/extract.ts +4 -215
- package/src/fetch-metadata.ts +226 -0
- package/src/fetch.ts +22 -141
- package/src/fragment-index.ts +99 -0
- package/src/fragments.ts +93 -0
- package/src/index.ts +3 -14
- package/src/llms.ts +294 -0
- package/src/markdown-cleanup.ts +114 -0
- package/src/service.ts +19 -315
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));
|
|
@@ -84,89 +36,6 @@ export interface LinkHeaderAgentHints {
|
|
|
84
36
|
markdownAlternate?: string;
|
|
85
37
|
}
|
|
86
38
|
|
|
87
|
-
/**
|
|
88
|
-
* Parses an HTTP `Link:` header for the agent-discovery link relations defined by
|
|
89
|
-
* llmstxt.org v2: `rel="describedby"` (the covering `llms.txt`) and
|
|
90
|
-
* `rel="alternate" type="text/markdown"` (the Markdown version of the resource).
|
|
91
|
-
*
|
|
92
|
-
* @param header - The raw `Link:` header value
|
|
93
|
-
* @param baseUrl - URL used to resolve relative references
|
|
94
|
-
* @returns The first advertised target of each relation, when present
|
|
95
|
-
*/
|
|
96
|
-
export function parseLinkHeaderForAgentHints(
|
|
97
|
-
header: string,
|
|
98
|
-
baseUrl: string | URL,
|
|
99
|
-
): LinkHeaderAgentHints {
|
|
100
|
-
// Split on commas outside <...> and quoted strings, since URIs and quoted values may
|
|
101
|
-
// themselves contain commas.
|
|
102
|
-
const directives: string[] = [];
|
|
103
|
-
let current = "";
|
|
104
|
-
let inQuotes = false;
|
|
105
|
-
let inAngleBrackets = false;
|
|
106
|
-
|
|
107
|
-
for (const character of header) {
|
|
108
|
-
if (character === '"') inQuotes = !inQuotes;
|
|
109
|
-
|
|
110
|
-
if (character === "<") inAngleBrackets = true;
|
|
111
|
-
|
|
112
|
-
if (character === ">" && !inQuotes) inAngleBrackets = false;
|
|
113
|
-
|
|
114
|
-
if (character === "," && !inQuotes && !inAngleBrackets) {
|
|
115
|
-
directives.push(current);
|
|
116
|
-
current = "";
|
|
117
|
-
} else {
|
|
118
|
-
current += character;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
directives.push(current);
|
|
123
|
-
|
|
124
|
-
let describedBy: string | undefined;
|
|
125
|
-
let markdownAlternate: string | undefined;
|
|
126
|
-
|
|
127
|
-
for (const directive of directives) {
|
|
128
|
-
const match = /^\s*<([^>]*)>\s*(.*)$/.exec(directive);
|
|
129
|
-
|
|
130
|
-
if (!match) continue;
|
|
131
|
-
const [, rawTarget, rawParameters] = match;
|
|
132
|
-
const parameters = new Map<string, string>();
|
|
133
|
-
|
|
134
|
-
for (const parameter of rawParameters.split(";")) {
|
|
135
|
-
const equals = parameter.indexOf("=");
|
|
136
|
-
|
|
137
|
-
if (equals === -1) continue;
|
|
138
|
-
const key = parameter.slice(0, equals).trim().toLowerCase();
|
|
139
|
-
|
|
140
|
-
const value = parameter
|
|
141
|
-
.slice(equals + 1)
|
|
142
|
-
.trim()
|
|
143
|
-
.replace(/^"|"$/g, "");
|
|
144
|
-
|
|
145
|
-
parameters.set(key, value);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const relations = (parameters.get("rel") ?? "").toLowerCase().split(/\s+/);
|
|
149
|
-
const type = (parameters.get("type") ?? "").toLowerCase();
|
|
150
|
-
let resolved: string | undefined;
|
|
151
|
-
|
|
152
|
-
try {
|
|
153
|
-
resolved = new URL(rawTarget.trim(), baseUrl).href;
|
|
154
|
-
} catch {
|
|
155
|
-
continue;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
if (!resolved) continue;
|
|
159
|
-
|
|
160
|
-
if (!describedBy && relations.includes("describedby")) describedBy = resolved;
|
|
161
|
-
|
|
162
|
-
if (!markdownAlternate && relations.includes("alternate") && type === "text/markdown") {
|
|
163
|
-
markdownAlternate = resolved;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
return { describedBy, markdownAlternate };
|
|
168
|
-
}
|
|
169
|
-
|
|
170
39
|
export interface FetchRemoteDependencies extends RedirectDependencies {
|
|
171
40
|
extractHtml?: typeof extractHtmlToMarkdown;
|
|
172
41
|
timeoutMs?: number;
|
|
@@ -292,7 +161,19 @@ async function documentFromResponse(
|
|
|
292
161
|
} catch {
|
|
293
162
|
markdown = raw;
|
|
294
163
|
}
|
|
295
|
-
} else
|
|
164
|
+
} else {
|
|
165
|
+
markdown = raw;
|
|
166
|
+
|
|
167
|
+
const markdownContent = /\bmarkdown\b|\.md(?:$|[?#])/i.test(
|
|
168
|
+
contentTypeHeader + target.url.pathname,
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
if (contentType.startsWith("text/") || markdownContent) {
|
|
172
|
+
fragmentOffsets = lineFragmentOffsets(markdown);
|
|
173
|
+
|
|
174
|
+
if (markdownContent) Object.assign(fragmentOffsets, markdownFragmentOffsets(markdown));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
296
177
|
|
|
297
178
|
const isHtml = contentType === "text/html" || contentType === "application/xhtml+xml";
|
|
298
179
|
const extractionDiagnostics = isHtml ? diagnoseExtraction(raw, markdown) : undefined;
|
|
@@ -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
|
+
}
|
package/src/fragments.ts
ADDED
|
@@ -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: "
|
|
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: `
|
|
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
|
});
|