@zeldrisho/pi-web-fetch 0.8.2 → 0.9.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 +19 -1
- package/README.md +1 -1
- package/package.json +8 -10
- package/src/abort.ts +3 -0
- package/src/cache.ts +50 -0
- package/src/content.ts +16 -0
- package/src/evidence.ts +10 -0
- package/src/extract.ts +183 -1
- package/src/fetch.ts +108 -11
- 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 +46 -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 +156 -17
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
|
}
|