@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/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: stripExtractedCssCruft(htmlToMarkdownFallback(html)),
608
+ markdown: fallbackMarkdown,
428
609
  extractor: "basic",
610
+ fragmentOffsets,
429
611
  ...advertised,
430
612
  };
431
613
  }