page-markdown 0.1.3 → 0.1.5
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 +186 -110
- package/README.md +106 -14
- package/dist/types/clean.d.ts +23 -0
- package/dist/types/convert.d.ts +19 -0
- package/dist/types/extract.d.ts +37 -0
- package/dist/types/facts.d.ts +14 -0
- package/dist/types/hidden.d.ts +14 -0
- package/dist/types/index.d.ts +98 -0
- package/dist/types/metadata.d.ts +18 -0
- package/dist/types/refine.d.ts +31 -0
- package/dist/types/sanitize.d.ts +26 -0
- package/dist/types/standardize.d.ts +15 -0
- package/dist/types/types.d.ts +154 -0
- package/package.json +18 -6
- package/src/clean.js +21 -9
- package/src/convert.js +78 -26
- package/src/extract.js +76 -70
- package/src/facts.js +99 -96
- package/src/hidden.js +46 -44
- package/src/index.js +21 -5
- package/src/metadata.js +79 -78
- package/src/refine.js +109 -104
- package/src/sanitize.js +196 -193
- package/src/standardize.js +41 -15
- package/src/types.js +118 -0
package/src/extract.js
CHANGED
|
@@ -1,70 +1,76 @@
|
|
|
1
|
-
// Stage 3: pick the readable content. Default: Mozilla Readability on a copy
|
|
2
|
-
// of the standardized clone, falling back to the whole body when it finds
|
|
3
|
-
// nothing or too little.
|
|
4
|
-
// The class only: skips isProbablyReaderable, which the pipeline does not use.
|
|
5
|
-
import Readability from "@mozilla/readability/Readability.js";
|
|
6
|
-
|
|
7
|
-
export const DEFAULT_MIN_EXTRACT_CHARS = 100;
|
|
8
|
-
|
|
9
|
-
function textLength(html = "") {
|
|
10
|
-
return String(html).replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim().length;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function bodyHtml(doc) {
|
|
14
|
-
return (doc.body || doc.documentElement)?.innerHTML || "";
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function copyDocument(doc) {
|
|
18
|
-
const copy = doc.cloneNode(true);
|
|
19
|
-
if (copy && copy.nodeType === 9 && copy.documentElement) return copy;
|
|
20
|
-
// Implementations whose Document.cloneNode is shallow: reparse.
|
|
21
|
-
const Parser = doc.defaultView?.DOMParser || globalThis.DOMParser;
|
|
22
|
-
return new Parser().parseFromString(doc.documentElement.outerHTML, "text/html");
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* @typedef {{ html: string, method: string, fallbackReason?: string, title?: string,
|
|
27
|
-
* byline?: string, published?: string, siteName?: string, lang?: string, excerpt?: string }} Extraction
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
1
|
+
// Stage 3: pick the readable content. Default: Mozilla Readability on a copy
|
|
2
|
+
// of the standardized clone, falling back to the whole body when it finds
|
|
3
|
+
// nothing or too little.
|
|
4
|
+
// The class only: skips isProbablyReaderable, which the pipeline does not use.
|
|
5
|
+
import Readability from "@mozilla/readability/Readability.js";
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_MIN_EXTRACT_CHARS = 100;
|
|
8
|
+
|
|
9
|
+
function textLength(html = "") {
|
|
10
|
+
return String(html).replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim().length;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function bodyHtml(doc) {
|
|
14
|
+
return (doc.body || doc.documentElement)?.innerHTML || "";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function copyDocument(doc) {
|
|
18
|
+
const copy = doc.cloneNode(true);
|
|
19
|
+
if (copy && copy.nodeType === 9 && copy.documentElement) return copy;
|
|
20
|
+
// Implementations whose Document.cloneNode is shallow: reparse.
|
|
21
|
+
const Parser = doc.defaultView?.DOMParser || globalThis.DOMParser;
|
|
22
|
+
return new Parser().parseFromString(doc.documentElement.outerHTML, "text/html");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {{ html: string, method: "readability"|"full_page"|(string & {}), fallbackReason?: string, title?: string,
|
|
27
|
+
* byline?: string, published?: string, siteName?: string, lang?: string, excerpt?: string }} Extraction
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Receives a copy of the standardized document. Returning nothing, empty HTML
|
|
32
|
+
* or too little text falls back to the full page; so does throwing.
|
|
33
|
+
* @typedef {(doc: Document) => Extraction|null|undefined} Extractor
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {Document} doc standardized inert document (not mutated)
|
|
38
|
+
* @param {{ extract?: "readable"|"full"|Extractor,
|
|
39
|
+
* minExtractChars?: number, readability?: import("./types.js").ReadabilityOptions }} [options]
|
|
40
|
+
* @returns {Extraction}
|
|
41
|
+
*/
|
|
42
|
+
export function extractContent(doc, options = {}) {
|
|
43
|
+
const mode = options.extract ?? "readable";
|
|
44
|
+
const full = () => ({ html: bodyHtml(doc), method: "full_page" });
|
|
45
|
+
|
|
46
|
+
if (mode === "full") return full();
|
|
47
|
+
|
|
48
|
+
const minChars = options.minExtractChars ?? DEFAULT_MIN_EXTRACT_CHARS;
|
|
49
|
+
let result = null;
|
|
50
|
+
let failure = "";
|
|
51
|
+
try {
|
|
52
|
+
if (typeof mode === "function") {
|
|
53
|
+
result = mode(copyDocument(doc));
|
|
54
|
+
} else {
|
|
55
|
+
const article = new Readability(copyDocument(doc), { keepClasses: true, ...options.readability }).parse();
|
|
56
|
+
result = article && {
|
|
57
|
+
html: article.content || "",
|
|
58
|
+
method: "readability",
|
|
59
|
+
title: article.title || "",
|
|
60
|
+
byline: article.byline || "",
|
|
61
|
+
published: article.publishedTime || "",
|
|
62
|
+
siteName: article.siteName || "",
|
|
63
|
+
lang: article.lang || "",
|
|
64
|
+
excerpt: article.excerpt || "",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
} catch (error) {
|
|
68
|
+
failure = `extractor_error: ${/** @type {any} */ (error)?.message || error}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!result?.html) return { ...full(), fallbackReason: failure || "extractor_found_nothing" };
|
|
72
|
+
if (textLength(result.html) < minChars) {
|
|
73
|
+
return { ...full(), fallbackReason: "extracted_text_below_minimum", title: result.title };
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
}
|
package/src/facts.js
CHANGED
|
@@ -1,96 +1,99 @@
|
|
|
1
|
-
// Short label/value facts the extractor left out: package sidebars (version,
|
|
2
|
-
// license, downloads), price and spec boxes, definition lists, two-column
|
|
3
|
-
// tables. Readability keeps the article and drops these asides, which is right
|
|
4
|
-
// for reading and wrong when the question is "which version?". Opt-in.
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
* @param {
|
|
59
|
-
* @
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const articleCompact = compact(article);
|
|
65
|
-
const pairs = Array.from(candidatePairs(doc), ([label, value, el]) => [squash(label).replace(/\s*[::¶#§]\s*$/, ""), value, el]);
|
|
66
|
-
// A "value" that is the next section's label is a heading run, not a fact.
|
|
67
|
-
const labels = new Set(pairs.map(([label]) => label));
|
|
68
|
-
const facts = [];
|
|
69
|
-
const seen = new Set();
|
|
70
|
-
for (const [label, value, el] of pairs) {
|
|
71
|
-
if (!label || !value || label.length > MAX_LABEL_CHARS || label === value || labels.has(value)) continue;
|
|
72
|
-
if (el.closest(CHROME)) continue;
|
|
73
|
-
const key = `${label}\u0000${value}`;
|
|
74
|
-
if (seen.has(key)) continue;
|
|
75
|
-
seen.add(key);
|
|
76
|
-
// Already in the article: short values as a whole word, longer ones
|
|
77
|
-
// ignoring spacing and citation marks.
|
|
78
|
-
const bare = compact(value);
|
|
79
|
-
if (bare.length >= 6 ? articleCompact.includes(bare)
|
|
80
|
-
: new RegExp(`(^|[^\\w])${escapeRegExp(value)}($|[^\\w])`).test(article)) continue;
|
|
81
|
-
facts.push({ label, value });
|
|
82
|
-
if (facts.length >= MAX_FACTS) break;
|
|
83
|
-
}
|
|
84
|
-
return facts;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function escapeMarkdown(text) {
|
|
88
|
-
return text.replace(/([\\`*_[\]<>|])/g, "\\$1");
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
1
|
+
// Short label/value facts the extractor left out: package sidebars (version,
|
|
2
|
+
// license, downloads), price and spec boxes, definition lists, two-column
|
|
3
|
+
// tables. Readability keeps the article and drops these asides, which is right
|
|
4
|
+
// for reading and wrong when the question is "which version?". Opt-in.
|
|
5
|
+
import { htmlText } from "./refine.js";
|
|
6
|
+
|
|
7
|
+
const MAX_FACTS = 24;
|
|
8
|
+
const MAX_LABEL_CHARS = 40;
|
|
9
|
+
const MAX_VALUE_CHARS = 100;
|
|
10
|
+
const CHROME = "nav, footer, header, form, [role='navigation'], [role='contentinfo'], [role='banner'], [role='search']";
|
|
11
|
+
|
|
12
|
+
function squash(text) {
|
|
13
|
+
return String(text || "").replace(/\s+/g, " ").trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// A sentence is prose, not a fact: "Returns a signal that aborts later."
|
|
17
|
+
function isSentence(text) {
|
|
18
|
+
return /[.!?]$/.test(text) && text.split(" ").length >= 4;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function shortValue(el) {
|
|
22
|
+
if (!el || el.querySelector("h1, h2, h3, h4, h5, h6, table, pre")) return "";
|
|
23
|
+
// A list of links is navigation, not a fact.
|
|
24
|
+
const links = el.tagName === "A" ? [el] : el.querySelectorAll("a");
|
|
25
|
+
if (links.length > 1) return "";
|
|
26
|
+
const text = squash(el.textContent);
|
|
27
|
+
// So is a lone link, unless its text is data: a domain, path or number.
|
|
28
|
+
if (links.length === 1 && squash(links[0].textContent) === text && !/[\d./@]/.test(text)) return "";
|
|
29
|
+
return text.length <= MAX_VALUE_CHARS && !isSentence(text) ? text : "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function* candidatePairs(doc) {
|
|
33
|
+
// Heading followed by a short value: <h3>Version</h3><p>1.2.3</p>.
|
|
34
|
+
for (const h of doc.querySelectorAll("h2, h3, h4, h5, h6")) {
|
|
35
|
+
yield [h.textContent, shortValue(h.nextElementSibling), h];
|
|
36
|
+
}
|
|
37
|
+
for (const dt of doc.querySelectorAll("dt")) {
|
|
38
|
+
const dd = dt.nextElementSibling;
|
|
39
|
+
if (dd?.tagName === "DD") yield [dt.textContent, shortValue(dd), dt];
|
|
40
|
+
}
|
|
41
|
+
// Two-cell rows: | Label | Value |.
|
|
42
|
+
for (const row of doc.querySelectorAll("tr")) {
|
|
43
|
+
const cells = Array.from(row.children).filter((c) => c.tagName === "TH" || c.tagName === "TD");
|
|
44
|
+
if (cells.length === 2) yield [cells[0].textContent, shortValue(cells[1]), row];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// "1.0.1 December 17, 2004 [5]" and "1.0.1December 17, 2004" compare equal.
|
|
49
|
+
function compact(text) {
|
|
50
|
+
return text.replace(/\[\d+\]/g, "").replace(/\s+/g, "");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function escapeRegExp(text) {
|
|
54
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {Document} doc standardized clone
|
|
59
|
+
* @param {string} articleHtml the extracted article
|
|
60
|
+
* @returns {Array<{ label: string, value: string }>} facts absent from the article
|
|
61
|
+
*/
|
|
62
|
+
export function collectPageFacts(doc, articleHtml) {
|
|
63
|
+
const article = squash(htmlText(articleHtml));
|
|
64
|
+
const articleCompact = compact(article);
|
|
65
|
+
const pairs = Array.from(candidatePairs(doc), ([label, value, el]) => [squash(label).replace(/\s*[::¶#§]\s*$/, ""), value, el]);
|
|
66
|
+
// A "value" that is the next section's label is a heading run, not a fact.
|
|
67
|
+
const labels = new Set(pairs.map(([label]) => label));
|
|
68
|
+
const facts = [];
|
|
69
|
+
const seen = new Set();
|
|
70
|
+
for (const [label, value, el] of pairs) {
|
|
71
|
+
if (!label || !value || label.length > MAX_LABEL_CHARS || label === value || labels.has(value)) continue;
|
|
72
|
+
if (el.closest(CHROME)) continue;
|
|
73
|
+
const key = `${label}\u0000${value}`;
|
|
74
|
+
if (seen.has(key)) continue;
|
|
75
|
+
seen.add(key);
|
|
76
|
+
// Already in the article: short values as a whole word, longer ones
|
|
77
|
+
// ignoring spacing and citation marks.
|
|
78
|
+
const bare = compact(value);
|
|
79
|
+
if (bare.length >= 6 ? articleCompact.includes(bare)
|
|
80
|
+
: new RegExp(`(^|[^\\w])${escapeRegExp(value)}($|[^\\w])`).test(article)) continue;
|
|
81
|
+
facts.push({ label, value });
|
|
82
|
+
if (facts.length >= MAX_FACTS) break;
|
|
83
|
+
}
|
|
84
|
+
return facts;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function escapeMarkdown(text) {
|
|
88
|
+
return text.replace(/([\\`*_[\]<>|])/g, "\\$1");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* "## Page facts" list, or "" when there are none.
|
|
93
|
+
* @param {readonly import("./types.js").PageFact[]} facts
|
|
94
|
+
*/
|
|
95
|
+
export function pageFactsMarkdown(facts) {
|
|
96
|
+
if (!facts.length) return "";
|
|
97
|
+
const lines = facts.map(({ label, value }) => `- ${escapeMarkdown(label)}: ${escapeMarkdown(value)}`);
|
|
98
|
+
return `## Page facts\n\n${lines.join("\n")}`;
|
|
99
|
+
}
|
package/src/hidden.js
CHANGED
|
@@ -1,44 +1,46 @@
|
|
|
1
|
-
// Optional last stage: remove characters that hide text from people but not
|
|
2
|
-
// from models (tag characters, variation-selector runs, invisible operators
|
|
3
|
-
// found by text-sanctifier's run finder. Legitimate sequences are kept: the
|
|
4
|
-
// England, Scotland and Wales flags and a lone emoji or text presentation
|
|
5
|
-
// selector. Unlike the clean stage, this also applies inside fenced code.
|
|
6
|
-
import { findHiddenRuns } from "text-sanctifier/hidden";
|
|
7
|
-
|
|
8
|
-
const NOTES = {
|
|
9
|
-
tag: "Hidden tag-character text removed",
|
|
10
|
-
variation_selector: "Variation selector runs removed",
|
|
11
|
-
invisible_operator: "Invisible operator runs removed",
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
* @
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
let
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
at
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
*
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
entry.chars
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
1
|
+
// Optional last stage: remove characters that hide text from people but not
|
|
2
|
+
// from models (tag characters, variation-selector runs, invisible operators,
|
|
3
|
+
// reserved tag-plane code points), found by text-sanctifier's run finder. Legitimate sequences are kept: the
|
|
4
|
+
// England, Scotland and Wales flags and a lone emoji or text presentation
|
|
5
|
+
// selector. Unlike the clean stage, this also applies inside fenced code.
|
|
6
|
+
import { findHiddenRuns } from "text-sanctifier/hidden";
|
|
7
|
+
|
|
8
|
+
const NOTES = {
|
|
9
|
+
tag: "Hidden tag-character text removed",
|
|
10
|
+
variation_selector: "Variation selector runs removed",
|
|
11
|
+
invisible_operator: "Invisible operator runs removed",
|
|
12
|
+
reserved_tag_plane: "Reserved tag-plane runs removed",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} text
|
|
17
|
+
* @returns {{ text: string, runs: import("./types.js").HiddenRun[] }}
|
|
18
|
+
*/
|
|
19
|
+
export function removeHiddenText(text) {
|
|
20
|
+
const source = String(text ?? "");
|
|
21
|
+
const { runs } = findHiddenRuns(source);
|
|
22
|
+
if (!runs.length) return { text: source, runs };
|
|
23
|
+
let out = "";
|
|
24
|
+
let at = 0;
|
|
25
|
+
for (const run of runs) {
|
|
26
|
+
out += source.slice(at, run.offset);
|
|
27
|
+
at = run.end;
|
|
28
|
+
}
|
|
29
|
+
return { text: out + source.slice(at), runs };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One omission per kind of run: `count` runs, `chars` code points.
|
|
34
|
+
* @param {ReadonlyArray<Pick<import("./types.js").HiddenRun, "kind"|"count">>} runs
|
|
35
|
+
* @returns {import("./types.js").HiddenTextOmission[]}
|
|
36
|
+
*/
|
|
37
|
+
export function hiddenTextOmissions(runs) {
|
|
38
|
+
const byKind = new Map();
|
|
39
|
+
for (const run of runs) {
|
|
40
|
+
const entry = byKind.get(run.kind) || { kind: `hidden_${run.kind}`, count: 0, chars: 0, note: NOTES[run.kind] };
|
|
41
|
+
entry.count += 1;
|
|
42
|
+
entry.chars += run.count;
|
|
43
|
+
byKind.set(run.kind, entry);
|
|
44
|
+
}
|
|
45
|
+
return Array.from(byKind.values());
|
|
46
|
+
}
|
package/src/index.js
CHANGED
|
@@ -10,7 +10,7 @@ import { recoverLeadParagraph, chooseHeadline } from "./refine.js";
|
|
|
10
10
|
import { collectPageFacts, pageFactsMarkdown } from "./facts.js";
|
|
11
11
|
import { removeHiddenText, hiddenTextOmissions } from "./hidden.js";
|
|
12
12
|
|
|
13
|
-
export const VERSION = "0.1.
|
|
13
|
+
export const VERSION = "0.1.5";
|
|
14
14
|
|
|
15
15
|
export { sanitizeToInertDocument, DEFAULT_SANITIZE_OPTIONS, isAdvertisementElement, createInertDocument } from "./sanitize.js";
|
|
16
16
|
export { standardizeDocument } from "./standardize.js";
|
|
@@ -21,6 +21,7 @@ export { collectMetadata } from "./metadata.js";
|
|
|
21
21
|
export { recoverLeadParagraph, chooseHeadline } from "./refine.js";
|
|
22
22
|
export { collectPageFacts, pageFactsMarkdown } from "./facts.js";
|
|
23
23
|
export { removeHiddenText, hiddenTextOmissions } from "./hidden.js";
|
|
24
|
+
export * from "./types.js";
|
|
24
25
|
|
|
25
26
|
function documentOf(input) {
|
|
26
27
|
if (input?.nodeType === 9) return input;
|
|
@@ -34,6 +35,16 @@ function toMarkdown(html, mode) {
|
|
|
34
35
|
return { markdown: applyMode(markdown, mode), removedJsonLines, convertedChars: converted.length };
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
// What relative links resolve against: the page's <base href>, else its URL.
|
|
39
|
+
function baseUrl(doc, url) {
|
|
40
|
+
const href = doc.querySelector?.("base[href]")?.getAttribute("href");
|
|
41
|
+
try {
|
|
42
|
+
return href ? new URL(href, url || undefined).href : url;
|
|
43
|
+
} catch {
|
|
44
|
+
return url;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
37
48
|
// A top-level heading in the prose; a "# comment" line in fenced code is not one.
|
|
38
49
|
function hasTopHeading(markdown) {
|
|
39
50
|
let found = false;
|
|
@@ -48,7 +59,7 @@ function hasTopHeading(markdown) {
|
|
|
48
59
|
* @typedef {object} PageMarkdownOptions
|
|
49
60
|
* @property {string} [url] page URL for absolute links (default: the document's URL)
|
|
50
61
|
* @property {"full"|"no_links"|"text_only"} [mode="full"]
|
|
51
|
-
* @property {"readable"|"full"|(
|
|
62
|
+
* @property {"readable"|"full"|import("./extract.js").Extractor} [extract="readable"]
|
|
52
63
|
* "readable": Readability with full-page fallback; "full": the whole body;
|
|
53
64
|
* or a custom extractor receiving a copy of the standardized document.
|
|
54
65
|
* @property {number} [minExtractChars=100] below this, fall back to the full page
|
|
@@ -61,8 +72,8 @@ function hasTopHeading(markdown) {
|
|
|
61
72
|
* variation-selector runs and invisible operators from the Markdown (fenced
|
|
62
73
|
* code included), fullPageMarkdown and metadata; omissions count the runs
|
|
63
74
|
* removed from the Markdown
|
|
64
|
-
* @property {
|
|
65
|
-
* @property {
|
|
75
|
+
* @property {import("./types.js").SanitizeOptions} [sanitize] see DEFAULT_SANITIZE_OPTIONS
|
|
76
|
+
* @property {import("./types.js").ReadabilityOptions} [readability] options passed to Readability
|
|
66
77
|
*/
|
|
67
78
|
|
|
68
79
|
/**
|
|
@@ -71,15 +82,17 @@ function hasTopHeading(markdown) {
|
|
|
71
82
|
*
|
|
72
83
|
* @param {Document|Element} input
|
|
73
84
|
* @param {PageMarkdownOptions} [options]
|
|
85
|
+
* @returns {import("./types.js").PageMarkdownResult}
|
|
74
86
|
*/
|
|
75
87
|
export function pageToMarkdown(input, options = {}) {
|
|
76
88
|
const doc = documentOf(input);
|
|
77
89
|
const url = options.url || doc.URL || doc.location?.href || "";
|
|
78
90
|
const mode = options.mode || "full";
|
|
91
|
+
/** @type {string[]} */
|
|
79
92
|
const warnings = [];
|
|
80
93
|
|
|
81
94
|
const { document: inert, omissions } = sanitizeToInertDocument(input, options.sanitize);
|
|
82
|
-
const standardization = standardizeDocument(inert, { url });
|
|
95
|
+
const standardization = standardizeDocument(inert, { url: baseUrl(doc, url) });
|
|
83
96
|
const extraction = extractContent(inert, {
|
|
84
97
|
extract: options.extract,
|
|
85
98
|
minExtractChars: options.minExtractChars,
|
|
@@ -106,6 +119,7 @@ export function pageToMarkdown(input, options = {}) {
|
|
|
106
119
|
pageFacts = collectPageFacts(inert, extractedHtml);
|
|
107
120
|
if (pageFacts.length) main.markdown = `${main.markdown}\n\n${pageFactsMarkdown(pageFacts)}`;
|
|
108
121
|
}
|
|
122
|
+
/** @type {import("./types.js").PageMarkdownOmission[]} */
|
|
109
123
|
const omissionList = [];
|
|
110
124
|
if (omissions.shadowHosts) {
|
|
111
125
|
omissionList.push({ kind: "shadow_content", count: omissions.shadowHosts, note: "Open shadow content omitted: capture limit" });
|
|
@@ -114,6 +128,7 @@ export function pageToMarkdown(input, options = {}) {
|
|
|
114
128
|
omissionList.push({ kind: "json_line", count: main.removedJsonLines, note: "Large single-line JSON documents removed" });
|
|
115
129
|
}
|
|
116
130
|
|
|
131
|
+
/** @type {import("./types.js").PageMarkdownResult} */
|
|
117
132
|
const result = {
|
|
118
133
|
version: VERSION,
|
|
119
134
|
markdown: main.markdown,
|
|
@@ -158,5 +173,6 @@ export function pageToMarkdown(input, options = {}) {
|
|
|
158
173
|
if (omissions.shadowHosts) {
|
|
159
174
|
result.markdown += `\n\n_Open shadow content omitted: capture limit (${omissions.shadowHosts} host(s))._`;
|
|
160
175
|
}
|
|
176
|
+
result.stats.markdownChars = result.markdown.length;
|
|
161
177
|
return result;
|
|
162
178
|
}
|