@zeldrisho/pi-web-fetch 0.8.1 → 0.9.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 +16 -1
- package/README.md +13 -50
- package/package.json +4 -6
- package/src/abort.ts +3 -0
- package/src/cache.ts +155 -8
- package/src/content.ts +16 -0
- package/src/evidence.ts +10 -0
- package/src/extract.ts +183 -1
- package/src/fetch.ts +54 -0
- package/src/focus.ts +17 -0
- package/src/index.ts +10 -1
- package/src/inflight.ts +10 -0
- package/src/network-policy.ts +17 -0
- package/src/network-redirects.ts +35 -0
- package/src/network-transport.ts +27 -0
- package/src/network.ts +1 -0
- package/src/outline.ts +38 -0
- package/src/redact.ts +7 -0
- package/src/render.ts +3 -0
- package/src/service.ts +252 -35
package/src/extract.ts
CHANGED
|
@@ -3,6 +3,106 @@ import type { DefuddleResponse } from "defuddle/node";
|
|
|
3
3
|
|
|
4
4
|
const RAW_ID_SELECTOR_SAFE = /^-?[_a-zA-Z][-_a-zA-Z0-9]*$/;
|
|
5
5
|
|
|
6
|
+
function stripMarkupForFragmentSlug(value: string): string {
|
|
7
|
+
let result = "";
|
|
8
|
+
|
|
9
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
10
|
+
if (value[index] !== "<") {
|
|
11
|
+
result += value[index];
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const tagEnd = value.indexOf(">", index + 1);
|
|
16
|
+
|
|
17
|
+
if (tagEnd < 0) {
|
|
18
|
+
// Preserve the old behavior for an unterminated tag: only discard `<`.
|
|
19
|
+
result += value.slice(index + 1);
|
|
20
|
+
break;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
index = tagEnd;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function fragmentSlug(value: string): string {
|
|
30
|
+
return stripMarkupForFragmentSlug(value)
|
|
31
|
+
.normalize("NFKD")
|
|
32
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
33
|
+
.toLowerCase()
|
|
34
|
+
.replace(/[^\p{Letter}\p{Number} _-]/gu, "")
|
|
35
|
+
.trim()
|
|
36
|
+
.replace(/[ _]+/g, "-");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Finds the extracted Markdown position corresponding to HTML fragment IDs. */
|
|
40
|
+
interface FragmentOffsets {
|
|
41
|
+
[fragment: string]: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function buildFragmentOffsets(
|
|
45
|
+
document: Document,
|
|
46
|
+
markdown: string,
|
|
47
|
+
originalIds?: ReadonlyMap<HTMLElement, string>,
|
|
48
|
+
): FragmentOffsets {
|
|
49
|
+
const lines = markdown.split("\n");
|
|
50
|
+
|
|
51
|
+
const headings = lines
|
|
52
|
+
.map((line, index) => {
|
|
53
|
+
const match = /^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/.exec(line);
|
|
54
|
+
|
|
55
|
+
return match
|
|
56
|
+
? {
|
|
57
|
+
slug: fragmentSlug(match[1]),
|
|
58
|
+
offset: lines.slice(0, index).join("\n").length + (index ? 1 : 0),
|
|
59
|
+
}
|
|
60
|
+
: undefined;
|
|
61
|
+
})
|
|
62
|
+
.filter((heading): heading is { slug: string; offset: number } => Boolean(heading));
|
|
63
|
+
|
|
64
|
+
const offsets: Record<string, number> = {};
|
|
65
|
+
const used = new Set<number>();
|
|
66
|
+
|
|
67
|
+
for (const element of document.querySelectorAll<HTMLElement>("[id], a[name]")) {
|
|
68
|
+
const id =
|
|
69
|
+
originalIds?.get(element) ?? element.getAttribute("id") ?? element.getAttribute("name");
|
|
70
|
+
|
|
71
|
+
if (!id) continue;
|
|
72
|
+
const slug = fragmentSlug(id);
|
|
73
|
+
const textSlug = fragmentSlug(element.textContent ?? "");
|
|
74
|
+
|
|
75
|
+
const heading = headings.find(
|
|
76
|
+
(candidate) =>
|
|
77
|
+
candidate.slug === slug ||
|
|
78
|
+
(textSlug && candidate.slug === textSlug && !used.has(candidate.offset)),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
if (heading) {
|
|
82
|
+
offsets[id] = heading.offset;
|
|
83
|
+
used.add(heading.offset);
|
|
84
|
+
} else {
|
|
85
|
+
const text = (element.textContent ?? "").trim();
|
|
86
|
+
|
|
87
|
+
if (text) {
|
|
88
|
+
const textOffset = markdown.toLowerCase().indexOf(text.toLowerCase());
|
|
89
|
+
|
|
90
|
+
if (textOffset >= 0) offsets[id] = textOffset;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Most Markdown producers preserve headings but discard arbitrary HTML IDs.
|
|
96
|
+
// Resolve GitHub-style heading links even when no matching HTML ID survived.
|
|
97
|
+
for (const heading of headings) {
|
|
98
|
+
const base = heading.slug;
|
|
99
|
+
|
|
100
|
+
if (base && offsets[base] === undefined) offsets[base] = heading.offset;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return offsets;
|
|
104
|
+
}
|
|
105
|
+
|
|
6
106
|
/** Removes schema.org scripts that Defuddle would report directly to the process console. */
|
|
7
107
|
function removeMalformedSchemaOrgData(document: Document): void {
|
|
8
108
|
for (const script of document.querySelectorAll<HTMLScriptElement>(
|
|
@@ -13,6 +113,7 @@ function removeMalformedSchemaOrgData(document: Document): void {
|
|
|
13
113
|
.replace(/^\s*<!\[CDATA\[([\s\S]*?)\]\]>\s*$/, "$1")
|
|
14
114
|
.replace(/^\s*(\*\/|\/\*)\s*|\s*(\*\/|\/\*)\s*$/g, "")
|
|
15
115
|
.trim();
|
|
116
|
+
|
|
16
117
|
try {
|
|
17
118
|
if (JSON.parse(jsonContent) === null) script.remove();
|
|
18
119
|
} catch {
|
|
@@ -28,29 +129,38 @@ function removeMalformedSchemaOrgData(document: Document): void {
|
|
|
28
129
|
*/
|
|
29
130
|
function normalizeSelectorUnsafeIds(document: Document): void {
|
|
30
131
|
const replacements = new Map<string, string>();
|
|
132
|
+
|
|
31
133
|
const occupiedIds = new Set(
|
|
32
134
|
[...document.querySelectorAll<HTMLElement>("[id]")].map((element) => element.id),
|
|
33
135
|
);
|
|
136
|
+
|
|
34
137
|
let replacementIndex = 0;
|
|
35
138
|
|
|
36
139
|
for (const element of document.querySelectorAll<HTMLElement>("[id]")) {
|
|
37
140
|
const id = element.id;
|
|
141
|
+
|
|
38
142
|
if (!id || RAW_ID_SELECTOR_SAFE.test(id)) continue;
|
|
39
143
|
|
|
40
144
|
let replacement: string;
|
|
145
|
+
|
|
41
146
|
do {
|
|
42
147
|
replacement = `defuddle-safe-id-${replacementIndex++}`;
|
|
43
148
|
} while (occupiedIds.has(replacement));
|
|
149
|
+
|
|
44
150
|
occupiedIds.add(replacement);
|
|
151
|
+
|
|
45
152
|
if (!replacements.has(id)) replacements.set(id, replacement);
|
|
46
153
|
element.id = replacement;
|
|
47
154
|
}
|
|
48
155
|
|
|
49
156
|
if (replacements.size === 0) return;
|
|
157
|
+
|
|
50
158
|
for (const anchor of document.querySelectorAll<HTMLAnchorElement>('a[href^="#"]')) {
|
|
51
159
|
const href = anchor.getAttribute("href");
|
|
160
|
+
|
|
52
161
|
if (!href) continue;
|
|
53
162
|
const replacement = replacements.get(href.slice(1));
|
|
163
|
+
|
|
54
164
|
if (replacement) anchor.setAttribute("href", `#${replacement}`);
|
|
55
165
|
}
|
|
56
166
|
}
|
|
@@ -63,11 +173,13 @@ function normalizeSelectorUnsafeIds(document: Document): void {
|
|
|
63
173
|
*/
|
|
64
174
|
export function htmlToMarkdownFallback(html: string): string {
|
|
65
175
|
const { document } = parseHTML(html);
|
|
176
|
+
|
|
66
177
|
for (const element of document.querySelectorAll(
|
|
67
178
|
"script, style, svg, noscript, template, iframe, nav, header, footer, aside, form",
|
|
68
179
|
)) {
|
|
69
180
|
element.remove();
|
|
70
181
|
}
|
|
182
|
+
|
|
71
183
|
return document.body.textContent
|
|
72
184
|
.replace(/[ \t]+\n/g, "\n")
|
|
73
185
|
.replace(/\n[ \t]+/g, "\n")
|
|
@@ -79,9 +191,11 @@ export function htmlToMarkdownFallback(html: string): string {
|
|
|
79
191
|
/** Asserts that a URL is absolute http(s) and returns it, or throws for bare pathnames. */
|
|
80
192
|
export function assertAbsoluteHttpUrl(value: string | URL): URL {
|
|
81
193
|
const url = value instanceof URL ? value : new URL(value);
|
|
194
|
+
|
|
82
195
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
83
196
|
throw new TypeError(`Expected absolute http(s) URL, got ${url.protocol}: ${url.href}`);
|
|
84
197
|
}
|
|
198
|
+
|
|
85
199
|
return url;
|
|
86
200
|
}
|
|
87
201
|
|
|
@@ -89,9 +203,12 @@ export function assertAbsoluteHttpUrl(value: string | URL): URL {
|
|
|
89
203
|
function resolveDocumentRelativeMetadataUrls(document: Document, pageUrl: URL): void {
|
|
90
204
|
for (const meta of document.querySelectorAll<HTMLMetaElement>("meta[content]")) {
|
|
91
205
|
const key = (meta.getAttribute("property") ?? meta.getAttribute("name"))?.toLowerCase();
|
|
206
|
+
|
|
92
207
|
if (key !== "og:url" && key !== "twitter:url") continue;
|
|
93
208
|
const content = meta.getAttribute("content");
|
|
209
|
+
|
|
94
210
|
if (!content) continue;
|
|
211
|
+
|
|
95
212
|
try {
|
|
96
213
|
meta.setAttribute("content", new URL(content, pageUrl).href);
|
|
97
214
|
} catch {
|
|
@@ -101,7 +218,9 @@ function resolveDocumentRelativeMetadataUrls(document: Document, pageUrl: URL):
|
|
|
101
218
|
|
|
102
219
|
for (const link of document.querySelectorAll<HTMLLinkElement>('link[rel="canonical"][href]')) {
|
|
103
220
|
const href = link.getAttribute("href");
|
|
221
|
+
|
|
104
222
|
if (!href) continue;
|
|
223
|
+
|
|
105
224
|
try {
|
|
106
225
|
link.setAttribute("href", new URL(href, pageUrl).href);
|
|
107
226
|
} catch {
|
|
@@ -124,12 +243,15 @@ function isString(value: unknown): value is string {
|
|
|
124
243
|
/** Returns true when a rejection looks like `ERR_INVALID_URL` for a bare pathname. */
|
|
125
244
|
function isBarePathnameInvalidUrl(cause: unknown): boolean {
|
|
126
245
|
if (!(cause instanceof Error)) return false;
|
|
246
|
+
|
|
127
247
|
if (!/Invalid URL/i.test(cause.message)) return false;
|
|
128
248
|
// SAFETY: Node's ERR_INVALID_URL extends Error with an `input` string property holding the offending URL.
|
|
129
249
|
const input = (cause as NodeJS.ErrnoException & { input?: unknown }).input;
|
|
250
|
+
|
|
130
251
|
if (isString(input) && input.startsWith("/")) return true;
|
|
131
252
|
// SAFETY: Error.stack is an optional string populated by V8; we coerce it for pattern matching.
|
|
132
253
|
const detail = `${cause.message}\n${(cause as Error).stack ?? ""}`;
|
|
254
|
+
|
|
133
255
|
// Node's ERR_INVALID_URL prints `input: '/path'` in the message/stack even when
|
|
134
256
|
// `input` is not on the error instance in some Node versions.
|
|
135
257
|
return /input:\s*'\/[^']*'/.test(detail);
|
|
@@ -145,7 +267,9 @@ function stripChromeWrappers(document: Document, baseUrl: URL): void {
|
|
|
145
267
|
// Only strip chrome wrappers for GitHub release pages
|
|
146
268
|
const isGitHubRelease =
|
|
147
269
|
baseUrl.hostname === "github.com" && /\/releases(?:\/|$)/.test(baseUrl.pathname);
|
|
270
|
+
|
|
148
271
|
if (!isGitHubRelease) return;
|
|
272
|
+
|
|
149
273
|
for (const element of document.querySelectorAll("nav, header, footer, aside")) {
|
|
150
274
|
element.remove();
|
|
151
275
|
}
|
|
@@ -161,23 +285,31 @@ function deduplicateAdjacentLinks(markdown: string): string {
|
|
|
161
285
|
const lines = markdown.split("\n");
|
|
162
286
|
const out: string[] = [];
|
|
163
287
|
let previousTrimmed: string | undefined;
|
|
288
|
+
|
|
164
289
|
for (const line of lines) {
|
|
165
290
|
const trimmed = line.trim();
|
|
291
|
+
|
|
166
292
|
const isLinkLine =
|
|
167
293
|
/^\[[^\]]+\]\(<[^>]+>\)$/.test(trimmed) || /^\[[^\]]+\]\([^)]+\)$/.test(trimmed);
|
|
294
|
+
|
|
168
295
|
if (isLinkLine && trimmed === previousTrimmed) continue;
|
|
169
296
|
out.push(line);
|
|
170
297
|
previousTrimmed = isLinkLine ? trimmed : undefined;
|
|
171
298
|
}
|
|
299
|
+
|
|
172
300
|
// Also collapse duplicate adjacent blocks like a repeated vite-plus tag link paragraph.
|
|
173
301
|
return out.join("\n").replace(/(\[[^\n]+?\([^)]+\)[^\n]*\n)\1/g, "$1");
|
|
174
302
|
}
|
|
175
303
|
|
|
176
304
|
const CSS_BLOCK_AT_RULE = /^\s*@(container|font-face|keyframes|layer|media|page|supports)\b/i;
|
|
305
|
+
|
|
177
306
|
const STANDALONE_CSS_RULE =
|
|
178
307
|
/^\s*(?:[.#][-_a-zA-Z]|\*\s*[.#:[>+~]|(?:html|body|main|article|nav|header|footer|aside)(?:\b|[.#:[>+~]))[^{}]*\{[^{}]*\}\s*$/i;
|
|
308
|
+
|
|
179
309
|
const COMPLETE_STYLE_ELEMENT = /<style\b[^>]*>[\s\S]*?<\/style\s*>/gi;
|
|
310
|
+
|
|
180
311
|
const CLOSING_STYLE_ELEMENT = /<\/style\s*>/i;
|
|
312
|
+
|
|
181
313
|
const OPENING_STYLE_ELEMENT = /<style\b[^>]*>/i;
|
|
182
314
|
|
|
183
315
|
/**
|
|
@@ -188,8 +320,10 @@ const OPENING_STYLE_ELEMENT = /<style\b[^>]*>/i;
|
|
|
188
320
|
*/
|
|
189
321
|
function stripCompleteStyleElements(value: string): string {
|
|
190
322
|
let cleaned = value;
|
|
323
|
+
|
|
191
324
|
while (true) {
|
|
192
325
|
const next = cleaned.replace(COMPLETE_STYLE_ELEMENT, "");
|
|
326
|
+
|
|
193
327
|
if (next === cleaned) return cleaned;
|
|
194
328
|
cleaned = next;
|
|
195
329
|
}
|
|
@@ -220,13 +354,16 @@ export function stripExtractedCssCruft(markdown: string): string {
|
|
|
220
354
|
|
|
221
355
|
for (const originalLine of markdown.split("\n")) {
|
|
222
356
|
const fenceMatch = /^[ \t]{0,3}(`{3,}|~{3,})/.exec(originalLine);
|
|
357
|
+
|
|
223
358
|
if (!fence && fenceMatch) {
|
|
224
359
|
fence = { character: fenceMatch[1][0], length: fenceMatch[1].length };
|
|
225
360
|
output.push(originalLine);
|
|
226
361
|
continue;
|
|
227
362
|
}
|
|
363
|
+
|
|
228
364
|
if (fence) {
|
|
229
365
|
output.push(originalLine);
|
|
366
|
+
|
|
230
367
|
if (
|
|
231
368
|
fenceMatch &&
|
|
232
369
|
fenceMatch[1][0] === fence.character &&
|
|
@@ -235,18 +372,23 @@ export function stripExtractedCssCruft(markdown: string): string {
|
|
|
235
372
|
) {
|
|
236
373
|
fence = undefined;
|
|
237
374
|
}
|
|
375
|
+
|
|
238
376
|
continue;
|
|
239
377
|
}
|
|
240
378
|
|
|
241
379
|
let line = originalLine;
|
|
380
|
+
|
|
242
381
|
if (styleElement) {
|
|
243
382
|
const close = CLOSING_STYLE_ELEMENT.exec(line);
|
|
383
|
+
|
|
244
384
|
if (!close) continue;
|
|
245
385
|
line = line.slice(close.index + close[0].length);
|
|
246
386
|
styleElement = false;
|
|
247
387
|
}
|
|
388
|
+
|
|
248
389
|
line = stripCompleteStyleElements(line);
|
|
249
390
|
const open = OPENING_STYLE_ELEMENT.exec(line);
|
|
391
|
+
|
|
250
392
|
if (open) {
|
|
251
393
|
line = line.slice(0, open.index);
|
|
252
394
|
styleElement = true;
|
|
@@ -254,13 +396,16 @@ export function stripExtractedCssCruft(markdown: string): string {
|
|
|
254
396
|
|
|
255
397
|
if (cssBlockDepth > 0) {
|
|
256
398
|
cssBlockDepth += braceDelta(line);
|
|
399
|
+
|
|
257
400
|
if (cssBlockDepth < 0) cssBlockDepth = 0;
|
|
258
401
|
continue;
|
|
259
402
|
}
|
|
403
|
+
|
|
260
404
|
if (CSS_BLOCK_AT_RULE.test(line)) {
|
|
261
405
|
cssBlockDepth = Math.max(0, braceDelta(line));
|
|
262
406
|
continue;
|
|
263
407
|
}
|
|
408
|
+
|
|
264
409
|
if (STANDALONE_CSS_RULE.test(line)) continue;
|
|
265
410
|
output.push(line);
|
|
266
411
|
}
|
|
@@ -308,13 +453,18 @@ async function runDefuddle(
|
|
|
308
453
|
): Promise<DefuddleResponse | undefined> {
|
|
309
454
|
let escapedRejection: unknown = undefined;
|
|
310
455
|
let armed = true;
|
|
456
|
+
|
|
311
457
|
const captureUnhandled = (cause: unknown): void => {
|
|
312
458
|
if (!armed || escapedRejection !== undefined) return;
|
|
459
|
+
|
|
313
460
|
const detail =
|
|
314
461
|
cause instanceof Error ? `${cause.message}\n${cause.stack ?? ""}` : String(cause);
|
|
462
|
+
|
|
315
463
|
if (/defuddle/i.test(detail) || isBarePathnameInvalidUrl(cause)) escapedRejection = cause;
|
|
316
464
|
};
|
|
465
|
+
|
|
317
466
|
process.on("unhandledRejection", captureUnhandled);
|
|
467
|
+
|
|
318
468
|
try {
|
|
319
469
|
const { Defuddle } = await import("defuddle/node");
|
|
320
470
|
const result = await Defuddle(document, pageUrl, { markdown: true, useAsync: false });
|
|
@@ -322,7 +472,9 @@ async function runDefuddle(
|
|
|
322
472
|
// rejection is observed by the guard instead of reaching the harness.
|
|
323
473
|
await Promise.resolve();
|
|
324
474
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
475
|
+
|
|
325
476
|
if (escapedRejection !== undefined) throw escapedRejection;
|
|
477
|
+
|
|
326
478
|
return result;
|
|
327
479
|
} catch {
|
|
328
480
|
return undefined;
|
|
@@ -350,19 +502,25 @@ function readAdvertisedLinks(
|
|
|
350
502
|
): AdvertisedLinks {
|
|
351
503
|
const resolve = (href: string | null): string | undefined => {
|
|
352
504
|
if (!href) return undefined;
|
|
505
|
+
|
|
353
506
|
try {
|
|
354
507
|
return new URL(href, baseUrl.href).href;
|
|
355
508
|
} catch {
|
|
356
509
|
return undefined;
|
|
357
510
|
}
|
|
358
511
|
};
|
|
512
|
+
|
|
359
513
|
let describedByLink: string | undefined;
|
|
360
514
|
let markdownAlternateLink: string | undefined;
|
|
515
|
+
|
|
361
516
|
for (const link of document.querySelectorAll("link")) {
|
|
362
517
|
const href = resolve(link.getAttribute("href"));
|
|
518
|
+
|
|
363
519
|
if (!href) continue;
|
|
364
520
|
const tokens = (link.getAttribute("rel") ?? "").toLowerCase().split(/\s+/);
|
|
521
|
+
|
|
365
522
|
if (!describedByLink && tokens.includes("describedby")) describedByLink = href;
|
|
523
|
+
|
|
366
524
|
if (
|
|
367
525
|
!markdownAlternateLink &&
|
|
368
526
|
tokens.includes("alternate") &&
|
|
@@ -371,6 +529,7 @@ function readAdvertisedLinks(
|
|
|
371
529
|
markdownAlternateLink = href;
|
|
372
530
|
}
|
|
373
531
|
}
|
|
532
|
+
|
|
374
533
|
return { describedByLink, markdownAlternateLink };
|
|
375
534
|
}
|
|
376
535
|
|
|
@@ -389,10 +548,12 @@ export async function extractHtmlToMarkdown(
|
|
|
389
548
|
markdown: string;
|
|
390
549
|
title?: string;
|
|
391
550
|
extractor: "defuddle" | "basic";
|
|
551
|
+
fragmentOffsets?: Record<string, number>;
|
|
392
552
|
describedByLink?: string;
|
|
393
553
|
markdownAlternateLink?: string;
|
|
394
554
|
}> {
|
|
395
555
|
let advertised: AdvertisedLinks = {};
|
|
556
|
+
|
|
396
557
|
try {
|
|
397
558
|
assertAbsoluteHttpUrl(baseUrl);
|
|
398
559
|
const { document } = parseHTML(html);
|
|
@@ -401,6 +562,12 @@ export async function extractHtmlToMarkdown(
|
|
|
401
562
|
// through Pi's TUI even though extraction succeeds.
|
|
402
563
|
resolveDocumentRelativeMetadataUrls(document, baseUrl);
|
|
403
564
|
removeMalformedSchemaOrgData(document);
|
|
565
|
+
const originalIds = new Map<HTMLElement, string>();
|
|
566
|
+
|
|
567
|
+
for (const element of document.querySelectorAll<HTMLElement>("[id]")) {
|
|
568
|
+
originalIds.set(element, element.id);
|
|
569
|
+
}
|
|
570
|
+
|
|
404
571
|
normalizeSelectorUnsafeIds(document);
|
|
405
572
|
stripChromeWrappers(document, baseUrl);
|
|
406
573
|
advertised = readAdvertisedLinks(document, baseUrl);
|
|
@@ -408,24 +575,39 @@ export async function extractHtmlToMarkdown(
|
|
|
408
575
|
// `/owner/repo/releases`) and metadata against the real origin instead of
|
|
409
576
|
// dropping the scheme and host and constructing `new URL(pathname)`.
|
|
410
577
|
const result = await runDefuddle(document, baseUrl.href);
|
|
578
|
+
|
|
411
579
|
const markdown = stripExtractedCssCruft(
|
|
412
580
|
deduplicateAdjacentLinks(result?.content?.trim() ?? ""),
|
|
413
581
|
);
|
|
582
|
+
|
|
414
583
|
const trimmedTitle = result?.title?.trim();
|
|
584
|
+
|
|
415
585
|
if (markdown) {
|
|
416
586
|
return {
|
|
417
587
|
markdown,
|
|
418
588
|
title: trimmedTitle || undefined,
|
|
419
589
|
extractor: "defuddle",
|
|
590
|
+
fragmentOffsets: buildFragmentOffsets(document, markdown, originalIds),
|
|
420
591
|
...advertised,
|
|
421
592
|
};
|
|
422
593
|
}
|
|
423
594
|
} catch {
|
|
424
595
|
// Fall through to the basic converter for malformed or unsupported pages.
|
|
425
596
|
}
|
|
597
|
+
|
|
598
|
+
const fallbackMarkdown = stripExtractedCssCruft(htmlToMarkdownFallback(html));
|
|
599
|
+
let fragmentOffsets: Record<string, number> = {};
|
|
600
|
+
|
|
601
|
+
try {
|
|
602
|
+
fragmentOffsets = buildFragmentOffsets(parseHTML(html).document, fallbackMarkdown);
|
|
603
|
+
} catch {
|
|
604
|
+
// Malformed HTML has already been reduced to plain text; no anchors are available.
|
|
605
|
+
}
|
|
606
|
+
|
|
426
607
|
return {
|
|
427
|
-
markdown:
|
|
608
|
+
markdown: fallbackMarkdown,
|
|
428
609
|
extractor: "basic",
|
|
610
|
+
fragmentOffsets,
|
|
429
611
|
...advertised,
|
|
430
612
|
};
|
|
431
613
|
}
|
package/src/fetch.ts
CHANGED
|
@@ -27,31 +27,43 @@ import {
|
|
|
27
27
|
export function normalizeGitHubRawUrl(rawUrl: string): string {
|
|
28
28
|
try {
|
|
29
29
|
const url = new URL(rawUrl);
|
|
30
|
+
|
|
30
31
|
if (url.protocol !== "https:") return rawUrl;
|
|
32
|
+
|
|
31
33
|
if (url.hostname === "github.com") {
|
|
32
34
|
if (url.pathname.includes("/blob/")) {
|
|
33
35
|
return `https://raw.githubusercontent.com${url.pathname.replace("/blob/", "/")}${url.search}`;
|
|
34
36
|
}
|
|
37
|
+
|
|
35
38
|
// Parse pathname segments: /owner/repo/tree/ref/path
|
|
36
39
|
const segments = url.pathname.split("/").filter(Boolean);
|
|
40
|
+
|
|
37
41
|
if (segments.length >= 4 && segments[2] === "tree") {
|
|
38
42
|
const lastSegment = segments[segments.length - 1];
|
|
43
|
+
|
|
39
44
|
if (lastSegment.includes(".")) {
|
|
40
45
|
// Reconstruct path: /owner/repo/ref/path (remove "tree" segment)
|
|
41
46
|
const newPath = `/${segments[0]}/${segments[1]}/${segments.slice(3).join("/")}`;
|
|
47
|
+
|
|
42
48
|
return `https://raw.githubusercontent.com${newPath}${url.search}`;
|
|
43
49
|
}
|
|
50
|
+
|
|
44
51
|
return rawUrl;
|
|
45
52
|
}
|
|
53
|
+
|
|
46
54
|
return rawUrl;
|
|
47
55
|
}
|
|
56
|
+
|
|
48
57
|
if (url.hostname === "gist.github.com") {
|
|
49
58
|
const segments = url.pathname.split("/").filter(Boolean);
|
|
59
|
+
|
|
50
60
|
// Only rewrite bare gist pages. Subpages such as `/revisions`, `/forks`, or an
|
|
51
61
|
// explicit `/raw` path already point at a usable resource and stay untouched.
|
|
52
62
|
if (segments.length !== 2) return rawUrl;
|
|
63
|
+
|
|
53
64
|
return `https://gist.github.com/${segments[0]}/${segments[1]}/raw${url.search}`;
|
|
54
65
|
}
|
|
66
|
+
|
|
55
67
|
return rawUrl;
|
|
56
68
|
} catch {
|
|
57
69
|
return rawUrl;
|
|
@@ -90,10 +102,14 @@ export function parseLinkHeaderForAgentHints(
|
|
|
90
102
|
let current = "";
|
|
91
103
|
let inQuotes = false;
|
|
92
104
|
let inAngleBrackets = false;
|
|
105
|
+
|
|
93
106
|
for (const character of header) {
|
|
94
107
|
if (character === '"') inQuotes = !inQuotes;
|
|
108
|
+
|
|
95
109
|
if (character === "<") inAngleBrackets = true;
|
|
110
|
+
|
|
96
111
|
if (character === ">" && !inQuotes) inAngleBrackets = false;
|
|
112
|
+
|
|
97
113
|
if (character === "," && !inQuotes && !inAngleBrackets) {
|
|
98
114
|
directives.push(current);
|
|
99
115
|
current = "";
|
|
@@ -101,39 +117,52 @@ export function parseLinkHeaderForAgentHints(
|
|
|
101
117
|
current += character;
|
|
102
118
|
}
|
|
103
119
|
}
|
|
120
|
+
|
|
104
121
|
directives.push(current);
|
|
105
122
|
|
|
106
123
|
let describedBy: string | undefined;
|
|
107
124
|
let markdownAlternate: string | undefined;
|
|
125
|
+
|
|
108
126
|
for (const directive of directives) {
|
|
109
127
|
const match = /^\s*<([^>]*)>\s*(.*)$/.exec(directive);
|
|
128
|
+
|
|
110
129
|
if (!match) continue;
|
|
111
130
|
const [, rawTarget, rawParameters] = match;
|
|
112
131
|
const parameters = new Map<string, string>();
|
|
132
|
+
|
|
113
133
|
for (const parameter of rawParameters.split(";")) {
|
|
114
134
|
const equals = parameter.indexOf("=");
|
|
135
|
+
|
|
115
136
|
if (equals === -1) continue;
|
|
116
137
|
const key = parameter.slice(0, equals).trim().toLowerCase();
|
|
138
|
+
|
|
117
139
|
const value = parameter
|
|
118
140
|
.slice(equals + 1)
|
|
119
141
|
.trim()
|
|
120
142
|
.replace(/^"|"$/g, "");
|
|
143
|
+
|
|
121
144
|
parameters.set(key, value);
|
|
122
145
|
}
|
|
146
|
+
|
|
123
147
|
const relations = (parameters.get("rel") ?? "").toLowerCase().split(/\s+/);
|
|
124
148
|
const type = (parameters.get("type") ?? "").toLowerCase();
|
|
125
149
|
let resolved: string | undefined;
|
|
150
|
+
|
|
126
151
|
try {
|
|
127
152
|
resolved = new URL(rawTarget.trim(), baseUrl).href;
|
|
128
153
|
} catch {
|
|
129
154
|
continue;
|
|
130
155
|
}
|
|
156
|
+
|
|
131
157
|
if (!resolved) continue;
|
|
158
|
+
|
|
132
159
|
if (!describedBy && relations.includes("describedby")) describedBy = resolved;
|
|
160
|
+
|
|
133
161
|
if (!markdownAlternate && relations.includes("alternate") && type === "text/markdown") {
|
|
134
162
|
markdownAlternate = resolved;
|
|
135
163
|
}
|
|
136
164
|
}
|
|
165
|
+
|
|
137
166
|
return { describedBy, markdownAlternate };
|
|
138
167
|
}
|
|
139
168
|
|
|
@@ -160,20 +189,25 @@ async function documentFromResponse(
|
|
|
160
189
|
extractHtml: typeof extractHtmlToMarkdown,
|
|
161
190
|
): Promise<CompleteDocument> {
|
|
162
191
|
const status = response.statusCode ?? 0;
|
|
192
|
+
|
|
163
193
|
if (status < 200 || status >= 300) {
|
|
164
194
|
response.resume();
|
|
195
|
+
|
|
165
196
|
const authenticationHint = [401, 403, 404].includes(status)
|
|
166
197
|
? " The page may be missing, private, or require authentication."
|
|
167
198
|
: "";
|
|
199
|
+
|
|
168
200
|
throw new Error(`web_fetch returned HTTP ${status}.${authenticationHint}`);
|
|
169
201
|
}
|
|
170
202
|
|
|
171
203
|
const contentTypeHeader = responseHeader(response, "content-type") ?? "text/plain";
|
|
172
204
|
const contentType = contentTypeHeader.split(";", 1)[0].trim().toLowerCase();
|
|
205
|
+
|
|
173
206
|
const linkHints = parseLinkHeaderForAgentHints(
|
|
174
207
|
responseHeader(response, "link") ?? "",
|
|
175
208
|
target.url,
|
|
176
209
|
);
|
|
210
|
+
|
|
177
211
|
const allowed =
|
|
178
212
|
contentType.startsWith("text/") ||
|
|
179
213
|
[
|
|
@@ -183,6 +217,7 @@ async function documentFromResponse(
|
|
|
183
217
|
"application/xml",
|
|
184
218
|
"application/xhtml+xml",
|
|
185
219
|
].includes(contentType);
|
|
220
|
+
|
|
186
221
|
if (!allowed) {
|
|
187
222
|
response.destroy();
|
|
188
223
|
throw new Error(`web_fetch does not support ${contentType || "this content type"}.`);
|
|
@@ -192,15 +227,19 @@ async function documentFromResponse(
|
|
|
192
227
|
await readResponseBytes(response, FETCH_MAX_BYTES, signal),
|
|
193
228
|
contentTypeHeader,
|
|
194
229
|
);
|
|
230
|
+
|
|
195
231
|
let markdown: string;
|
|
196
232
|
let title: string | undefined;
|
|
233
|
+
let fragmentOffsets: Record<string, number> | undefined;
|
|
197
234
|
let extractor: CompleteDocument["extractor"] = "raw";
|
|
198
235
|
let describedBy: string | undefined = linkHints.describedBy;
|
|
199
236
|
let markdownAlternateUrl: string | undefined = linkHints.markdownAlternate;
|
|
237
|
+
|
|
200
238
|
if (contentType === "text/html" || contentType === "application/xhtml+xml") {
|
|
201
239
|
const extracted = await awaitWithAbort(extractHtml(raw, target.url), signal);
|
|
202
240
|
markdown = extracted.markdown;
|
|
203
241
|
title = extracted.title;
|
|
242
|
+
fragmentOffsets = extracted.fragmentOffsets;
|
|
204
243
|
extractor = extracted.extractor;
|
|
205
244
|
describedBy = describedBy ?? extracted.describedByLink;
|
|
206
245
|
markdownAlternateUrl = markdownAlternateUrl ?? extracted.markdownAlternateLink;
|
|
@@ -214,9 +253,11 @@ async function documentFromResponse(
|
|
|
214
253
|
|
|
215
254
|
const isHtml = contentType === "text/html" || contentType === "application/xhtml+xml";
|
|
216
255
|
const extractionDiagnostics = isHtml ? diagnoseExtraction(raw, markdown) : undefined;
|
|
256
|
+
|
|
217
257
|
const shellSuspected = extractionDiagnostics
|
|
218
258
|
? hasExtractionWarning(extractionDiagnostics)
|
|
219
259
|
: false;
|
|
260
|
+
|
|
220
261
|
const etag = responseHeader(response, "etag");
|
|
221
262
|
const lastModified = responseHeader(response, "last-modified");
|
|
222
263
|
|
|
@@ -224,6 +265,7 @@ async function documentFromResponse(
|
|
|
224
265
|
url: target.url.toString(),
|
|
225
266
|
contentType,
|
|
226
267
|
markdown: markdown.replace(/<\/untrusted_web_content>/gi, "</untrusted_web_content>"),
|
|
268
|
+
fragmentOffsets,
|
|
227
269
|
title,
|
|
228
270
|
extractor,
|
|
229
271
|
shellSuspected,
|
|
@@ -245,14 +287,17 @@ async function documentFromResponse(
|
|
|
245
287
|
*/
|
|
246
288
|
function assertAbsoluteHttpUrlForFetch(value: string): URL {
|
|
247
289
|
let url: URL;
|
|
290
|
+
|
|
248
291
|
try {
|
|
249
292
|
url = new URL(value);
|
|
250
293
|
} catch {
|
|
251
294
|
throw new Error("web_fetch received an invalid URL.");
|
|
252
295
|
}
|
|
296
|
+
|
|
253
297
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
254
298
|
throw new Error(`web_fetch only supports http(s) URLs: ${redactUrlForDisplay(url)}`);
|
|
255
299
|
}
|
|
300
|
+
|
|
256
301
|
return url;
|
|
257
302
|
}
|
|
258
303
|
|
|
@@ -276,14 +321,18 @@ async function fetchDocument(
|
|
|
276
321
|
const timeoutMs = dependencies.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
277
322
|
const extractHtml = dependencies.extractHtml ?? extractHtmlToMarkdown;
|
|
278
323
|
let timedOut = false;
|
|
324
|
+
|
|
279
325
|
const timeout = setTimeout(() => {
|
|
280
326
|
timedOut = true;
|
|
281
327
|
controller.abort();
|
|
282
328
|
}, timeoutMs);
|
|
329
|
+
|
|
283
330
|
const cancel = () => controller.abort();
|
|
284
331
|
signal?.addEventListener("abort", cancel, { once: true });
|
|
285
332
|
const conditionalHeaders: Record<string, string> = {};
|
|
333
|
+
|
|
286
334
|
if (cached?.validators?.etag) conditionalHeaders["If-None-Match"] = cached.validators.etag;
|
|
335
|
+
|
|
287
336
|
if (cached?.validators?.lastModified)
|
|
288
337
|
conditionalHeaders["If-Modified-Since"] = cached.validators.lastModified;
|
|
289
338
|
|
|
@@ -294,11 +343,14 @@ async function fetchDocument(
|
|
|
294
343
|
dependencies,
|
|
295
344
|
conditionalHeaders,
|
|
296
345
|
);
|
|
346
|
+
|
|
297
347
|
if (response.statusCode === 304 && cached) {
|
|
298
348
|
response.resume();
|
|
299
349
|
const etag = responseHeader(response, "etag") ?? cached.validators?.etag;
|
|
350
|
+
|
|
300
351
|
const lastModified =
|
|
301
352
|
responseHeader(response, "last-modified") ?? cached.validators?.lastModified;
|
|
353
|
+
|
|
302
354
|
return {
|
|
303
355
|
document: {
|
|
304
356
|
...cached,
|
|
@@ -308,12 +360,14 @@ async function fetchDocument(
|
|
|
308
360
|
revalidated: true,
|
|
309
361
|
};
|
|
310
362
|
}
|
|
363
|
+
|
|
311
364
|
return {
|
|
312
365
|
document: await documentFromResponse(target, response, controller.signal, extractHtml),
|
|
313
366
|
revalidated: false,
|
|
314
367
|
};
|
|
315
368
|
} catch (error) {
|
|
316
369
|
if (timedOut) throw new Error(`web_fetch timed out after ${timeoutMs / 1000} seconds.`);
|
|
370
|
+
|
|
317
371
|
if (signal?.aborted) throw new Error("web_fetch was cancelled.");
|
|
318
372
|
throw error;
|
|
319
373
|
} finally {
|