@zeldrisho/pi-web-fetch 0.9.0 → 0.9.2
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 +19 -1
- package/package.json +7 -7
- package/src/fetch.ts +148 -12
- package/src/network-transport.ts +19 -0
- package/src/service.ts +62 -3
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.9.2] - 2026-09-23
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Resolve GitHub Markdown heading fragments and source line, line-range, and column anchors in raw files
|
|
15
|
+
|
|
16
|
+
## [0.9.1] - 2026-09-22
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Upgrade Defuddle to `^0.19.4` for improved metadata and Markdown extraction, HTML sanitization, SVG safety, and support for declarative shadow DOM and MathML content
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Extract HTML correctly when servers send duplicated equivalent `Content-Type` media types, and reject conflicting media-type declarations instead of returning raw HTML
|
|
25
|
+
|
|
10
26
|
## [0.9.0] - 2026-09-17
|
|
11
27
|
|
|
12
28
|
### Added
|
|
@@ -148,7 +164,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
148
164
|
|
|
149
165
|
- Add bounded public page fetching ([b979496](https://github.com/zeldrisho/pi-packages/commit/b979496b32de1cead172ba570307e4a4a7b3421d))
|
|
150
166
|
|
|
151
|
-
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.9.
|
|
167
|
+
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.9.2...HEAD
|
|
168
|
+
[0.9.2]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.9.1...pi-web-fetch-v0.9.2
|
|
169
|
+
[0.9.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.9.0...pi-web-fetch-v0.9.1
|
|
152
170
|
[0.9.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.8.2...pi-web-fetch-v0.9.0
|
|
153
171
|
[0.8.2]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.8.1...pi-web-fetch-v0.8.2
|
|
154
172
|
[0.8.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.8.0...pi-web-fetch-v0.8.1
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeldrisho/pi-web-fetch",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"description": "Pi extension for secure, bounded public web page fetching and Markdown extraction",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
@@ -29,16 +29,16 @@
|
|
|
29
29
|
"access": "public"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"defuddle": "^0.19.
|
|
32
|
+
"defuddle": "^0.19.4",
|
|
33
33
|
"linkedom": "^0.18.13"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
37
|
-
"@earendil-works/pi-tui": "^0.
|
|
38
|
-
"typebox": "^1.3.
|
|
36
|
+
"@earendil-works/pi-coding-agent": "^0.85.1",
|
|
37
|
+
"@earendil-works/pi-tui": "^0.85.1",
|
|
38
|
+
"typebox": "^1.3.30",
|
|
39
39
|
"typescript": "^7.0.2",
|
|
40
|
-
"vite": "npm:@voidzero-dev/vite-plus-core@0.3.
|
|
41
|
-
"vite-plus": "0.3.
|
|
40
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@0.3.3",
|
|
41
|
+
"vite-plus": "0.3.3"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
44
|
"@earendil-works/pi-coding-agent": "*",
|
package/src/fetch.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
FETCH_MAX_BYTES,
|
|
12
12
|
readResponseBytes,
|
|
13
13
|
responseHeader,
|
|
14
|
+
responseHeaderValues,
|
|
14
15
|
} from "./network-transport";
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -77,6 +78,87 @@ export function detectAppShell(raw: string, markdown: string): boolean {
|
|
|
77
78
|
|
|
78
79
|
const REQUEST_TIMEOUT_MS = 20_000;
|
|
79
80
|
|
|
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
|
+
|
|
80
162
|
/** Agent-discovery hints advertised in an HTTP `Link:` header. */
|
|
81
163
|
export interface LinkHeaderAgentHints {
|
|
82
164
|
describedBy?: string;
|
|
@@ -171,6 +253,43 @@ export interface FetchRemoteDependencies extends RedirectDependencies {
|
|
|
171
253
|
timeoutMs?: number;
|
|
172
254
|
}
|
|
173
255
|
|
|
256
|
+
/**
|
|
257
|
+
* Returns a usable media type from a response header.
|
|
258
|
+
*
|
|
259
|
+
* Some servers incorrectly emit an equivalent media type more than once (for example,
|
|
260
|
+
* `text/html,text/html; charset=utf-8`). Treat that as the declared type, but reject
|
|
261
|
+
* conflicting media types rather than guessing which representation to parse.
|
|
262
|
+
*/
|
|
263
|
+
function normalizeContentType(header: string): string | undefined {
|
|
264
|
+
const mediaTypes: string[] = [];
|
|
265
|
+
let current = "";
|
|
266
|
+
let quoted = false;
|
|
267
|
+
|
|
268
|
+
for (const character of header) {
|
|
269
|
+
if (character === '"') quoted = !quoted;
|
|
270
|
+
|
|
271
|
+
if (character === "," && !quoted) {
|
|
272
|
+
mediaTypes.push(current);
|
|
273
|
+
current = "";
|
|
274
|
+
} else current += character;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
mediaTypes.push(current);
|
|
278
|
+
const normalized: string[] = [];
|
|
279
|
+
|
|
280
|
+
for (const value of mediaTypes) {
|
|
281
|
+
const mediaType = value.split(";", 1)[0].trim().toLowerCase();
|
|
282
|
+
|
|
283
|
+
if (mediaType) normalized.push(mediaType);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (normalized.length === 0 || normalized.some((value) => value !== normalized[0])) {
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return normalized[0];
|
|
291
|
+
}
|
|
292
|
+
|
|
174
293
|
/**
|
|
175
294
|
* Converts a successful HTTP response into a complete document.
|
|
176
295
|
*
|
|
@@ -200,8 +319,10 @@ async function documentFromResponse(
|
|
|
200
319
|
throw new Error(`web_fetch returned HTTP ${status}.${authenticationHint}`);
|
|
201
320
|
}
|
|
202
321
|
|
|
203
|
-
const contentTypeHeader =
|
|
204
|
-
|
|
322
|
+
const contentTypeHeader =
|
|
323
|
+
responseHeaderValues(response, "content-type").join(", ") || "text/plain";
|
|
324
|
+
|
|
325
|
+
const contentType = normalizeContentType(contentTypeHeader);
|
|
205
326
|
|
|
206
327
|
const linkHints = parseLinkHeaderForAgentHints(
|
|
207
328
|
responseHeader(response, "link") ?? "",
|
|
@@ -209,18 +330,21 @@ async function documentFromResponse(
|
|
|
209
330
|
);
|
|
210
331
|
|
|
211
332
|
const allowed =
|
|
212
|
-
contentType
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
333
|
+
contentType !== undefined &&
|
|
334
|
+
(contentType.startsWith("text/") ||
|
|
335
|
+
[
|
|
336
|
+
"application/json",
|
|
337
|
+
"application/markdown",
|
|
338
|
+
"application/x-markdown",
|
|
339
|
+
"application/xml",
|
|
340
|
+
"application/xhtml+xml",
|
|
341
|
+
].includes(contentType));
|
|
220
342
|
|
|
221
343
|
if (!allowed) {
|
|
222
344
|
response.destroy();
|
|
223
|
-
throw new Error(
|
|
345
|
+
throw new Error(
|
|
346
|
+
`web_fetch does not support ${contentType ?? (contentTypeHeader || "this content type")}.`,
|
|
347
|
+
);
|
|
224
348
|
}
|
|
225
349
|
|
|
226
350
|
const raw = decodeResponse(
|
|
@@ -249,7 +373,19 @@ async function documentFromResponse(
|
|
|
249
373
|
} catch {
|
|
250
374
|
markdown = raw;
|
|
251
375
|
}
|
|
252
|
-
} else
|
|
376
|
+
} else {
|
|
377
|
+
markdown = raw;
|
|
378
|
+
|
|
379
|
+
const markdownContent = /\bmarkdown\b|\.md(?:$|[?#])/i.test(
|
|
380
|
+
contentTypeHeader + target.url.pathname,
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
if (contentType.startsWith("text/") || markdownContent) {
|
|
384
|
+
fragmentOffsets = lineFragmentOffsets(markdown);
|
|
385
|
+
|
|
386
|
+
if (markdownContent) Object.assign(fragmentOffsets, markdownFragmentOffsets(markdown));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
253
389
|
|
|
254
390
|
const isHtml = contentType === "text/html" || contentType === "application/xhtml+xml";
|
|
255
391
|
const extractionDiagnostics = isHtml ? diagnoseExtraction(raw, markdown) : undefined;
|
package/src/network-transport.ts
CHANGED
|
@@ -188,6 +188,25 @@ export function responseHeader(response: IncomingMessage, name: string): string
|
|
|
188
188
|
return Array.isArray(value) ? value[0] : value;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
export function responseHeaderValues(response: IncomingMessage, name: string): string[] {
|
|
192
|
+
const values: string[] = [];
|
|
193
|
+
const lowerName = name.toLowerCase();
|
|
194
|
+
|
|
195
|
+
const rawHeaders = response.rawHeaders ?? [];
|
|
196
|
+
|
|
197
|
+
for (let index = 0; index + 1 < rawHeaders.length; index += 2) {
|
|
198
|
+
if (rawHeaders[index].toLowerCase() === lowerName) {
|
|
199
|
+
values.push(rawHeaders[index + 1]);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (values.length > 0) return values;
|
|
204
|
+
|
|
205
|
+
const value = response.headers[name];
|
|
206
|
+
|
|
207
|
+
return value === undefined ? [] : Array.isArray(value) ? value : [value];
|
|
208
|
+
}
|
|
209
|
+
|
|
191
210
|
/**
|
|
192
211
|
* Read and buffer the complete response body.
|
|
193
212
|
*
|
package/src/service.ts
CHANGED
|
@@ -568,8 +568,25 @@ function urlWithoutFragment(rawUrl: string): string {
|
|
|
568
568
|
interface ResolvedFragment {
|
|
569
569
|
fragment?: string;
|
|
570
570
|
offset?: number;
|
|
571
|
+
endOffset?: number;
|
|
571
572
|
}
|
|
572
573
|
|
|
574
|
+
/** Converts a one-based source line and column into a Markdown character offset. */
|
|
575
|
+
function sourceOffset(markdown: string, lineNumber: number, columnNumber = 1): number | undefined {
|
|
576
|
+
if (lineNumber < 1 || columnNumber < 1) return undefined;
|
|
577
|
+
|
|
578
|
+
const lines = markdown.split("\n");
|
|
579
|
+
|
|
580
|
+
if (lineNumber > lines.length) return undefined;
|
|
581
|
+
|
|
582
|
+
let offset = 0;
|
|
583
|
+
|
|
584
|
+
for (let index = 0; index < lineNumber - 1; index += 1) offset += lines[index].length + 1;
|
|
585
|
+
|
|
586
|
+
return offset + Math.min(columnNumber - 1, lines[lineNumber - 1].length);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** Resolves heading and source-location URL fragments against a fetched document. */
|
|
573
590
|
function resolveFragmentOffset(document: CompleteDocument, rawUrl: string): ResolvedFragment {
|
|
574
591
|
let fragment: string | undefined;
|
|
575
592
|
|
|
@@ -582,6 +599,36 @@ function resolveFragmentOffset(document: CompleteDocument, rawUrl: string): Reso
|
|
|
582
599
|
}
|
|
583
600
|
|
|
584
601
|
if (!fragment) return {};
|
|
602
|
+
|
|
603
|
+
const lineAnchor = /^L(\d+)(?:C(\d+))?(?:-L?(\d+)(?:C(\d+))?)?$/i.exec(fragment);
|
|
604
|
+
|
|
605
|
+
if (lineAnchor) {
|
|
606
|
+
const [, firstLine, firstColumn, lastLine, lastColumn] = lineAnchor;
|
|
607
|
+
const offset = sourceOffset(document.markdown, Number(firstLine), Number(firstColumn ?? 1));
|
|
608
|
+
|
|
609
|
+
if (offset === undefined) return { fragment };
|
|
610
|
+
|
|
611
|
+
if (lastLine) {
|
|
612
|
+
const endLine = Number(lastLine);
|
|
613
|
+
const endColumn = lastColumn === undefined ? undefined : Number(lastColumn);
|
|
614
|
+
const lines = document.markdown.split("\n");
|
|
615
|
+
const line = lines[endLine - 1];
|
|
616
|
+
|
|
617
|
+
if (line === undefined) return { fragment };
|
|
618
|
+
|
|
619
|
+
const endOffset =
|
|
620
|
+
endColumn === undefined
|
|
621
|
+
? sourceOffset(document.markdown, endLine, line.length + 1)
|
|
622
|
+
: sourceOffset(document.markdown, endLine, endColumn + 1);
|
|
623
|
+
|
|
624
|
+
return endOffset !== undefined && endOffset >= offset
|
|
625
|
+
? { fragment, offset, endOffset }
|
|
626
|
+
: { fragment };
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
return { fragment, offset };
|
|
630
|
+
}
|
|
631
|
+
|
|
585
632
|
const offsets = document.fragmentOffsets ?? {};
|
|
586
633
|
|
|
587
634
|
return { fragment, offset: offsets[fragment] ?? offsets[fragment.toLowerCase()] };
|
|
@@ -688,19 +735,31 @@ export async function executeWebFetch(
|
|
|
688
735
|
const fragmentMatched = resolvedFragment.fragment !== undefined && fragmentOffset !== undefined;
|
|
689
736
|
const startingOffset = explicitOffset ?? fragmentOffset ?? FETCH_DEFAULT_OFFSET;
|
|
690
737
|
|
|
738
|
+
const hasFragmentRange =
|
|
739
|
+
explicitOffset === undefined &&
|
|
740
|
+
fragmentOffset !== undefined &&
|
|
741
|
+
resolvedFragment.endOffset !== undefined;
|
|
742
|
+
|
|
691
743
|
const fragmentDocument =
|
|
692
744
|
explicitOffset === undefined && fragmentOffset !== undefined
|
|
693
|
-
? {
|
|
745
|
+
? {
|
|
746
|
+
...document,
|
|
747
|
+
markdown: hasFragmentRange
|
|
748
|
+
? document.markdown.slice(fragmentOffset, resolvedFragment.endOffset)
|
|
749
|
+
: document.markdown.slice(fragmentOffset),
|
|
750
|
+
}
|
|
694
751
|
: document;
|
|
695
752
|
|
|
696
753
|
const focused =
|
|
697
754
|
params.query === undefined ? undefined : focusMarkdown(fragmentDocument.markdown, params.query);
|
|
698
755
|
|
|
699
|
-
const outputDocument = focused
|
|
756
|
+
const outputDocument = focused
|
|
757
|
+
? { ...fragmentDocument, markdown: focused.markdown }
|
|
758
|
+
: fragmentDocument;
|
|
700
759
|
|
|
701
760
|
const resultOffset = focused
|
|
702
761
|
? (explicitOffset ?? 0)
|
|
703
|
-
: (explicitOffset ??
|
|
762
|
+
: (explicitOffset ?? (fragmentMatched ? 0 : startingOffset));
|
|
704
763
|
|
|
705
764
|
const result = sliceCompleteDocument(outputDocument, resultOffset, maxCharacters);
|
|
706
765
|
|