page-markdown 0.0.0-stage → 0.1.3

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/convert.js ADDED
@@ -0,0 +1,219 @@
1
+ // Stage 4: HTML to Markdown with Turndown plus GitHub-flavoured rules:
2
+ // strikethrough, task-list checkboxes and tables. Every data table becomes a
3
+ // GFM table, never raw HTML.
4
+ import TurndownService from "turndown";
5
+
6
+ export const TURNDOWN_OPTIONS = Object.freeze({
7
+ headingStyle: "atx",
8
+ bulletListMarker: "-",
9
+ codeBlockStyle: "fenced",
10
+ emDelimiter: "_",
11
+ strongDelimiter: "**",
12
+ linkStyle: "inlined",
13
+ hr: "---",
14
+ });
15
+
16
+ function gfmInlineRules(turndown) {
17
+ turndown.addRule("pmStrikethrough", {
18
+ filter: ["del", "s", "strike"],
19
+ replacement: (content) => `~~${content}~~`,
20
+ });
21
+ // A checkbox at the start of a list item (directly, or in a label or span).
22
+ turndown.addRule("pmTaskListItem", {
23
+ filter(node) {
24
+ const isCheckbox = (node.nodeName === "INPUT" && String(node.getAttribute("type")).toLowerCase() === "checkbox")
25
+ || node.getAttribute?.("role") === "checkbox";
26
+ if (!isCheckbox) return false;
27
+ const parent = node.parentNode;
28
+ return parent?.nodeName === "LI"
29
+ || (["LABEL", "SPAN"].includes(parent?.nodeName) && parent.parentNode?.nodeName === "LI");
30
+ },
31
+ replacement(content, node) {
32
+ const checked = node.nodeName === "INPUT"
33
+ ? Boolean(node.checked) || node.hasAttribute?.("checked")
34
+ : node.getAttribute("aria-checked") === "true";
35
+ return checked ? "[x] " : "[ ] ";
36
+ },
37
+ });
38
+ }
39
+
40
+ function closest(node, name) {
41
+ for (let parent = node.parentNode; parent; parent = parent.parentNode) {
42
+ if (parent.nodeName === name) return parent;
43
+ }
44
+ return null;
45
+ }
46
+
47
+ function tableRows(table) {
48
+ const rows = [];
49
+ const walk = (node) => {
50
+ for (const child of Array.from(node.childNodes || [])) {
51
+ if (child.nodeName === "TR") rows.push(child);
52
+ else if (["THEAD", "TBODY", "TFOOT"].includes(child.nodeName)) walk(child);
53
+ }
54
+ };
55
+ walk(table);
56
+ return rows;
57
+ }
58
+
59
+ function rowCells(row) {
60
+ return Array.from(row.childNodes || []).filter((node) => node.nodeName === "TD" || node.nodeName === "TH");
61
+ }
62
+
63
+ function spanOf(cell) {
64
+ const span = Number.parseInt(cell.getAttribute?.("colspan") || "1", 10);
65
+ return Number.isFinite(span) && span > 1 ? Math.min(span, 50) : 1;
66
+ }
67
+
68
+ // Layout tables (nesting, a single cell, a single column) read better as
69
+ // blocks than as a grid.
70
+ function isLayoutTable(table) {
71
+ if (table.querySelector?.("table")) return true;
72
+ const rows = tableRows(table);
73
+ if (!rows.length) return true;
74
+ const widest = Math.max(...rows.map((row) => rowCells(row).reduce((sum, cell) => sum + spanOf(cell), 0)));
75
+ return widest <= 1;
76
+ }
77
+
78
+ // A GFM cell is one line, so a data table holding code blocks is rendered as
79
+ // blocks too, each cell under its column's header: the code stays fenced.
80
+ function isBlockTable(table) {
81
+ return isLayoutTable(table) || Boolean(table.querySelector?.("pre"));
82
+ }
83
+
84
+ function isBlockCell(node) {
85
+ const table = closest(node, "TABLE");
86
+ return !table || isBlockTable(table);
87
+ }
88
+
89
+ function labelText(cell) {
90
+ return String(cell.textContent || "").replace(/\s+/g, " ").trim();
91
+ }
92
+
93
+ // A first row of two or more <th>: column headers. (A single spanning <th>
94
+ // is a title, not a header.)
95
+ function columnHeaders(table) {
96
+ const first = tableRows(table)[0];
97
+ const cells = first ? rowCells(first) : [];
98
+ return cells.length > 1 && cells.every((cell) => cell.nodeName === "TH") ? first : null;
99
+ }
100
+
101
+ function rowHeader(row) {
102
+ const [first, ...rest] = rowCells(row);
103
+ return first?.nodeName === "TH" && rest.length ? first : null;
104
+ }
105
+
106
+ // The label for a cell of a block-rendered data table: its column's header
107
+ // (colspans counted), else its row's header. null when the cell is itself a
108
+ // label; "" for layout tables and unlabelled cells.
109
+ function cellLabel(cell) {
110
+ const table = closest(cell, "TABLE");
111
+ if (!table || isLayoutTable(table)) return "";
112
+ const row = cell.parentNode;
113
+ const header = columnHeaders(table);
114
+ if (header) {
115
+ if (row === header) return null;
116
+ let column = 0;
117
+ for (const other of rowCells(row)) {
118
+ if (other === cell) break;
119
+ column += spanOf(other);
120
+ }
121
+ let start = 0;
122
+ for (const th of rowCells(header)) {
123
+ if (column < start + spanOf(th)) return labelText(th);
124
+ start += spanOf(th);
125
+ }
126
+ return "";
127
+ }
128
+ const th = rowHeader(row);
129
+ if (!th) return "";
130
+ return cell === th ? null : labelText(th);
131
+ }
132
+
133
+ // A GFM cell is one line: collapse breaks and block structure to spaces,
134
+ // and escape pipes.
135
+ function cellText(content) {
136
+ const text = String(content)
137
+ .replace(/\n+/g, " ")
138
+ .replace(/\s{2,}/g, " ")
139
+ .trim();
140
+ // Every pipe: Turndown escapes a literal backslash as "\\", so "\|" in the
141
+ // content is not an escaped pipe.
142
+ return text.replace(/\|/g, "\\|");
143
+ }
144
+
145
+ function tableRules(turndown) {
146
+ turndown.addRule("pmTableCell", {
147
+ filter: ["th", "td"],
148
+ replacement(content, node) {
149
+ if (isBlockCell(node)) {
150
+ // Header cells become the labels on the other cells.
151
+ const label = cellLabel(node);
152
+ if (label === null) return "";
153
+ return label ? `\n\n**${createTurndown().escape(label)}**\n\n${content}\n\n` : `\n\n${content}\n\n`;
154
+ }
155
+ const pad = " | ".repeat(spanOf(node) - 1);
156
+ return ` ${cellText(content)}${pad} |`;
157
+ },
158
+ });
159
+ turndown.addRule("pmTableRow", {
160
+ filter: "tr",
161
+ replacement(content, node) {
162
+ if (isBlockCell(node)) return `\n${content}\n`;
163
+ return `\n|${content}`;
164
+ },
165
+ });
166
+ turndown.addRule("pmTableSection", {
167
+ filter: ["thead", "tbody", "tfoot"],
168
+ replacement(content) {
169
+ return content;
170
+ },
171
+ });
172
+ turndown.addRule("pmTable", {
173
+ filter: "table",
174
+ replacement(content, node) {
175
+ if (isBlockTable(node)) return `\n\n${content}\n\n`;
176
+ const lines = content.split("\n").filter((line) => line.startsWith("|"));
177
+ if (!lines.length) return "\n\n";
178
+ const width = Math.max(...lines.map((line) => line.split(/(?<!\\)\|/).length - 2));
179
+ const padded = lines.map((line) => {
180
+ const cells = line.split(/(?<!\\)\|/).length - 2;
181
+ return cells < width ? `${line}${" |".repeat(width - cells)}` : line;
182
+ });
183
+ // GFM needs a header row; the first row serves when the table has none.
184
+ const separator = `|${" --- |".repeat(width)}`;
185
+ const caption = node.querySelector?.("caption")?.textContent?.trim();
186
+ const body = [padded[0], separator, ...padded.slice(1)].join("\n");
187
+ return `\n\n${caption ? `${cellText(caption)}\n\n` : ""}${body}\n\n`;
188
+ },
189
+ });
190
+ turndown.addRule("pmTableCaption", {
191
+ filter: "caption",
192
+ replacement() {
193
+ return "";
194
+ },
195
+ });
196
+ }
197
+
198
+ let shared = null;
199
+
200
+ /** A configured Turndown instance (cached). */
201
+ export function createTurndown() {
202
+ if (shared) return shared;
203
+ const turndown = new TurndownService({ ...TURNDOWN_OPTIONS });
204
+ gfmInlineRules(turndown);
205
+ tableRules(turndown);
206
+ // Elements that never carry readable text. (Buttons stay: accordions put
207
+ // headings in them.)
208
+ turndown.remove(["script", "style", "noscript", "template", "iframe", "svg", "canvas"]);
209
+ shared = turndown;
210
+ return turndown;
211
+ }
212
+
213
+ /**
214
+ * @param {string} html
215
+ * @returns {string}
216
+ */
217
+ export function htmlToMarkdown(html) {
218
+ return createTurndown().turndown(String(html || ""));
219
+ }
package/src/extract.js ADDED
@@ -0,0 +1,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: string, fallbackReason?: string, title?: string,
27
+ * byline?: string, published?: string, siteName?: string, lang?: string, excerpt?: string }} Extraction
28
+ */
29
+
30
+ /**
31
+ * @param {Document} doc standardized inert document (not mutated)
32
+ * @param {{ extract?: "readable"|"full"|((doc: Document) => Extraction|null),
33
+ * minExtractChars?: number, readability?: object }} [options]
34
+ * @returns {Extraction}
35
+ */
36
+ export function extractContent(doc, options = {}) {
37
+ const mode = options.extract ?? "readable";
38
+ const full = () => ({ html: bodyHtml(doc), method: "full_page" });
39
+
40
+ if (mode === "full") return full();
41
+
42
+ const minChars = options.minExtractChars ?? DEFAULT_MIN_EXTRACT_CHARS;
43
+ let result = null;
44
+ let failure = "";
45
+ try {
46
+ if (typeof mode === "function") {
47
+ result = mode(copyDocument(doc));
48
+ } else {
49
+ const article = new Readability(copyDocument(doc), { keepClasses: true, ...options.readability }).parse();
50
+ result = article && {
51
+ html: article.content || "",
52
+ method: "readability",
53
+ title: article.title || "",
54
+ byline: article.byline || "",
55
+ published: article.publishedTime || "",
56
+ siteName: article.siteName || "",
57
+ lang: article.lang || "",
58
+ excerpt: article.excerpt || "",
59
+ };
60
+ }
61
+ } catch (error) {
62
+ failure = `extractor_error: ${error?.message || error}`;
63
+ }
64
+
65
+ if (!result?.html) return { ...full(), fallbackReason: failure || "extractor_found_nothing" };
66
+ if (textLength(result.html) < minChars) {
67
+ return { ...full(), fallbackReason: "extracted_text_below_minimum", title: result.title };
68
+ }
69
+ return result;
70
+ }
package/src/facts.js ADDED
@@ -0,0 +1,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
+
6
+ const MAX_FACTS = 24;
7
+ const MAX_LABEL_CHARS = 40;
8
+ const MAX_VALUE_CHARS = 100;
9
+ const CHROME = "nav, footer, header, form, [role='navigation'], [role='contentinfo'], [role='banner'], [role='search']";
10
+
11
+ function squash(text) {
12
+ return String(text || "").replace(/\s+/g, " ").trim();
13
+ }
14
+
15
+ // A sentence is prose, not a fact: "Returns a signal that aborts later."
16
+ function isSentence(text) {
17
+ return /[.!?]$/.test(text) && text.split(" ").length >= 4;
18
+ }
19
+
20
+ function shortValue(el) {
21
+ if (!el || el.querySelector("h1, h2, h3, h4, h5, h6, table, pre")) return "";
22
+ // A list of links is navigation, not a fact.
23
+ const links = el.tagName === "A" ? [el] : el.querySelectorAll("a");
24
+ if (links.length > 1) return "";
25
+ const text = squash(el.textContent);
26
+ // So is a lone link, unless its text is data: a domain, path or number.
27
+ if (links.length === 1 && squash(links[0].textContent) === text && !/[\d./@]/.test(text)) return "";
28
+ return text.length <= MAX_VALUE_CHARS && !isSentence(text) ? text : "";
29
+ }
30
+
31
+ function* candidatePairs(doc) {
32
+ // Heading followed by a short value: <h3>Version</h3><p>1.2.3</p>.
33
+ for (const h of doc.querySelectorAll("h2, h3, h4, h5, h6")) {
34
+ yield [h.textContent, shortValue(h.nextElementSibling), h];
35
+ }
36
+ for (const dt of doc.querySelectorAll("dt")) {
37
+ const dd = dt.nextElementSibling;
38
+ if (dd?.tagName === "DD") yield [dt.textContent, shortValue(dd), dt];
39
+ }
40
+ // Two-cell rows: | Label | Value |.
41
+ for (const row of doc.querySelectorAll("tr")) {
42
+ const cells = Array.from(row.children).filter((c) => c.tagName === "TH" || c.tagName === "TD");
43
+ if (cells.length === 2) yield [cells[0].textContent, shortValue(cells[1]), row];
44
+ }
45
+ }
46
+
47
+ // "1.0.1 December 17, 2004 [5]" and "1.0.1December 17, 2004" compare equal.
48
+ function compact(text) {
49
+ return text.replace(/\[\d+\]/g, "").replace(/\s+/g, "");
50
+ }
51
+
52
+ function escapeRegExp(text) {
53
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
54
+ }
55
+
56
+ /**
57
+ * @param {Document} doc standardized clone
58
+ * @param {string} articleHtml the extracted article
59
+ * @returns {Array<{ label: string, value: string }>} facts absent from the article
60
+ */
61
+ export function collectPageFacts(doc, articleHtml) {
62
+ const article = squash(String(articleHtml || "").replace(/<[^>]*>/g, " ").replace(/&nbsp;/g, " ")
63
+ .replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, "&"));
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
+ /** "## Page facts" list, or "" when there are none. */
92
+ export function pageFactsMarkdown(facts) {
93
+ if (!facts.length) return "";
94
+ const lines = facts.map(({ label, value }) => `- ${escapeMarkdown(label)}: ${escapeMarkdown(value)}`);
95
+ return `## Page facts\n\n${lines.join("\n")}`;
96
+ }
package/src/hidden.js ADDED
@@ -0,0 +1,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
+ // 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
+ * @param {string} text
16
+ * @returns {{ text: string, runs: Array<{ kind: string, count: number }> }}
17
+ */
18
+ export function removeHiddenText(text) {
19
+ const source = String(text ?? "");
20
+ const { runs } = findHiddenRuns(source);
21
+ if (!runs.length) return { text: source, runs };
22
+ let out = "";
23
+ let at = 0;
24
+ for (const run of runs) {
25
+ out += source.slice(at, run.offset);
26
+ at = run.end;
27
+ }
28
+ return { text: out + source.slice(at), runs };
29
+ }
30
+
31
+ /**
32
+ * One omission per kind of run: `count` runs, `chars` code points.
33
+ * @param {Array<{ kind: string, count: number }>} runs
34
+ */
35
+ export function hiddenTextOmissions(runs) {
36
+ const byKind = new Map();
37
+ for (const run of runs) {
38
+ const entry = byKind.get(run.kind) || { kind: `hidden_${run.kind}`, count: 0, chars: 0, note: NOTES[run.kind] };
39
+ entry.count += 1;
40
+ entry.chars += run.count;
41
+ byKind.set(run.kind, entry);
42
+ }
43
+ return Array.from(byKind.values());
44
+ }
package/src/index.js ADDED
@@ -0,0 +1,162 @@
1
+ // page-markdown: a page's DOM in, clean Markdown and metadata out.
2
+ // sanitize → standardize → extract → convert → clean (→ mode) (→ hidden text)
3
+ import { sanitizeToInertDocument } from "./sanitize.js";
4
+ import { standardizeDocument } from "./standardize.js";
5
+ import { extractContent } from "./extract.js";
6
+ import { htmlToMarkdown } from "./convert.js";
7
+ import { cleanMarkdown, applyMode, mapOutsideFences } from "./clean.js";
8
+ import { collectMetadata } from "./metadata.js";
9
+ import { recoverLeadParagraph, chooseHeadline } from "./refine.js";
10
+ import { collectPageFacts, pageFactsMarkdown } from "./facts.js";
11
+ import { removeHiddenText, hiddenTextOmissions } from "./hidden.js";
12
+
13
+ export const VERSION = "0.1.3";
14
+
15
+ export { sanitizeToInertDocument, DEFAULT_SANITIZE_OPTIONS, isAdvertisementElement, createInertDocument } from "./sanitize.js";
16
+ export { standardizeDocument } from "./standardize.js";
17
+ export { extractContent, DEFAULT_MIN_EXTRACT_CHARS } from "./extract.js";
18
+ export { htmlToMarkdown, createTurndown, TURNDOWN_OPTIONS } from "./convert.js";
19
+ export { cleanMarkdown, applyMode, mapOutsideFences } from "./clean.js";
20
+ export { collectMetadata } from "./metadata.js";
21
+ export { recoverLeadParagraph, chooseHeadline } from "./refine.js";
22
+ export { collectPageFacts, pageFactsMarkdown } from "./facts.js";
23
+ export { removeHiddenText, hiddenTextOmissions } from "./hidden.js";
24
+
25
+ function documentOf(input) {
26
+ if (input?.nodeType === 9) return input;
27
+ if (input?.nodeType === 1) return input.ownerDocument;
28
+ throw new TypeError("page-markdown: expected a Document or Element");
29
+ }
30
+
31
+ function toMarkdown(html, mode) {
32
+ const converted = htmlToMarkdown(html);
33
+ const { markdown, removedJsonLines } = cleanMarkdown(converted);
34
+ return { markdown: applyMode(markdown, mode), removedJsonLines, convertedChars: converted.length };
35
+ }
36
+
37
+ // A top-level heading in the prose; a "# comment" line in fenced code is not one.
38
+ function hasTopHeading(markdown) {
39
+ let found = false;
40
+ mapOutsideFences(markdown, (prose) => {
41
+ found ||= /^# \S/m.test(prose);
42
+ return prose;
43
+ });
44
+ return found;
45
+ }
46
+
47
+ /**
48
+ * @typedef {object} PageMarkdownOptions
49
+ * @property {string} [url] page URL for absolute links (default: the document's URL)
50
+ * @property {"full"|"no_links"|"text_only"} [mode="full"]
51
+ * @property {"readable"|"full"|((doc: Document) => object|null)} [extract="readable"]
52
+ * "readable": Readability with full-page fallback; "full": the whole body;
53
+ * or a custom extractor receiving a copy of the standardized document.
54
+ * @property {number} [minExtractChars=100] below this, fall back to the full page
55
+ * @property {boolean} [includeFullPage=false] also return the full-page Markdown
56
+ * @property {boolean} [includeTitle=true] start readable output with "# <headline>"
57
+ * @property {boolean} [recoverLead=true] restore a dropped lead paragraph that matches the meta description
58
+ * @property {boolean} [pageFacts=false] append "## Page facts": short label/value pairs
59
+ * (sidebar versions, prices, spec rows) that the extracted article left out
60
+ * @property {boolean} [removeHiddenText=false] remove tag-character text,
61
+ * variation-selector runs and invisible operators from the Markdown (fenced
62
+ * code included), fullPageMarkdown and metadata; omissions count the runs
63
+ * removed from the Markdown
64
+ * @property {object} [sanitize] see DEFAULT_SANITIZE_OPTIONS
65
+ * @property {object} [readability] options passed to Readability
66
+ */
67
+
68
+ /**
69
+ * Converts a page (or an element of it) to Markdown.
70
+ * Pure with respect to the input: the live document is only read.
71
+ *
72
+ * @param {Document|Element} input
73
+ * @param {PageMarkdownOptions} [options]
74
+ */
75
+ export function pageToMarkdown(input, options = {}) {
76
+ const doc = documentOf(input);
77
+ const url = options.url || doc.URL || doc.location?.href || "";
78
+ const mode = options.mode || "full";
79
+ const warnings = [];
80
+
81
+ const { document: inert, omissions } = sanitizeToInertDocument(input, options.sanitize);
82
+ const standardization = standardizeDocument(inert, { url });
83
+ const extraction = extractContent(inert, {
84
+ extract: options.extract,
85
+ minExtractChars: options.minExtractChars,
86
+ readability: options.readability,
87
+ });
88
+ if (extraction.fallbackReason) warnings.push(`extraction fell back to the full page: ${extraction.fallbackReason}`);
89
+
90
+ const pageMetadata = collectMetadata(doc, { url });
91
+ let extractedHtml = extraction.html;
92
+ let leadRecovered = false;
93
+ if (extraction.method !== "full_page" && options.recoverLead !== false) {
94
+ ({ html: extractedHtml, recovered: leadRecovered } = recoverLeadParagraph(extractedHtml, inert, pageMetadata.description));
95
+ }
96
+ const headline = chooseHeadline(inert, { ...pageMetadata, extractedTitle: extraction.title });
97
+
98
+ const main = toMarkdown(extractedHtml, mode);
99
+ // Readability lifts the article's H1 out as its title; put a clean heading
100
+ // back so the capture stands alone, unless the body still has a top-level one.
101
+ if (options.includeTitle !== false && extraction.method === "readability" && mode !== "text_only") {
102
+ if (headline && !hasTopHeading(main.markdown)) main.markdown = `# ${headline}\n\n${main.markdown}`;
103
+ }
104
+ let pageFacts = [];
105
+ if (options.pageFacts && extraction.method !== "full_page" && mode !== "text_only") {
106
+ pageFacts = collectPageFacts(inert, extractedHtml);
107
+ if (pageFacts.length) main.markdown = `${main.markdown}\n\n${pageFactsMarkdown(pageFacts)}`;
108
+ }
109
+ const omissionList = [];
110
+ if (omissions.shadowHosts) {
111
+ omissionList.push({ kind: "shadow_content", count: omissions.shadowHosts, note: "Open shadow content omitted: capture limit" });
112
+ }
113
+ if (main.removedJsonLines) {
114
+ omissionList.push({ kind: "json_line", count: main.removedJsonLines, note: "Large single-line JSON documents removed" });
115
+ }
116
+
117
+ const result = {
118
+ version: VERSION,
119
+ markdown: main.markdown,
120
+ metadata: {
121
+ ...pageMetadata,
122
+ ...(headline ? { headline } : {}),
123
+ ...(extraction.title ? { extractedTitle: extraction.title } : {}),
124
+ ...(extraction.byline ? { byline: extraction.byline } : {}),
125
+ },
126
+ extraction: {
127
+ method: extraction.method,
128
+ ...(extraction.fallbackReason ? { fallbackReason: extraction.fallbackReason } : {}),
129
+ ...(leadRecovered ? { leadRecovered: true } : {}),
130
+ ...(pageFacts.length ? { pageFacts: pageFacts.length } : {}),
131
+ },
132
+ omissions: omissionList,
133
+ warnings,
134
+ stats: {
135
+ mode,
136
+ extractedHtmlChars: extraction.html.length,
137
+ convertedChars: main.convertedChars,
138
+ markdownChars: main.markdown.length,
139
+ standardization,
140
+ },
141
+ };
142
+
143
+ if (options.includeFullPage) {
144
+ const full = extraction.method === "full_page"
145
+ ? main
146
+ : toMarkdown((inert.body || inert.documentElement).innerHTML || "", mode);
147
+ result.fullPageMarkdown = full.markdown;
148
+ }
149
+ if (options.removeHiddenText) {
150
+ const cleaned = removeHiddenText(result.markdown);
151
+ result.markdown = cleaned.text;
152
+ result.omissions.push(...hiddenTextOmissions(cleaned.runs));
153
+ if (result.fullPageMarkdown !== undefined) result.fullPageMarkdown = removeHiddenText(result.fullPageMarkdown).text;
154
+ for (const [key, value] of Object.entries(result.metadata)) {
155
+ if (typeof value === "string") result.metadata[key] = removeHiddenText(value).text;
156
+ }
157
+ }
158
+ if (omissions.shadowHosts) {
159
+ result.markdown += `\n\n_Open shadow content omitted: capture limit (${omissions.shadowHosts} host(s))._`;
160
+ }
161
+ return result;
162
+ }
@@ -0,0 +1,78 @@
1
+ // Page metadata from the original document (read-only): meta tags, JSON-LD,
2
+ // canonical link and language. The sanitized clone has no <meta> or scripts.
3
+
4
+ function clean(value, limit = 500) {
5
+ return String(value ?? "").replace(/\s+/g, " ").trim().slice(0, limit);
6
+ }
7
+
8
+ function meta(doc, selectors) {
9
+ for (const selector of selectors) {
10
+ const value = doc.querySelector?.(selector)?.getAttribute?.("content");
11
+ if (value && value.trim()) return clean(value);
12
+ }
13
+ return "";
14
+ }
15
+
16
+ function absolute(value, base) {
17
+ const raw = clean(value, 2000);
18
+ if (!raw) return "";
19
+ try {
20
+ return base ? new URL(raw, base).href : raw;
21
+ } catch {
22
+ return raw;
23
+ }
24
+ }
25
+
26
+ function jsonLdNodes(doc) {
27
+ const nodes = [];
28
+ for (const script of Array.from(doc.querySelectorAll?.('script[type="application/ld+json"]') || [])) {
29
+ try {
30
+ const data = JSON.parse(script.textContent || "");
31
+ const queue = Array.isArray(data) ? [...data] : [data];
32
+ while (queue.length) {
33
+ const node = queue.shift();
34
+ if (!node || typeof node !== "object") continue;
35
+ nodes.push(node);
36
+ if (Array.isArray(node["@graph"])) queue.push(...node["@graph"]);
37
+ }
38
+ } catch {
39
+ // Malformed JSON-LD is common; ignore it.
40
+ }
41
+ }
42
+ return nodes;
43
+ }
44
+
45
+ function nameOf(value) {
46
+ if (!value) return "";
47
+ if (typeof value === "string") return clean(value);
48
+ if (Array.isArray(value)) return value.map(nameOf).filter(Boolean).join(", ");
49
+ return clean(value.name || "");
50
+ }
51
+
52
+ const ARTICLE_TYPES = /Article|BlogPosting|NewsArticle|Report|TechArticle|WebPage|ScholarlyArticle/i;
53
+
54
+ /**
55
+ * @param {Document} doc the original page document
56
+ * @param {{ url?: string }} [options]
57
+ */
58
+ export function collectMetadata(doc, options = {}) {
59
+ const url = options.url || doc.URL || doc.location?.href || "";
60
+ const ld = jsonLdNodes(doc);
61
+ const article = ld.find((node) => ARTICLE_TYPES.test(String(node["@type"] || ""))) || {};
62
+ const canonicalHref = doc.querySelector?.("link[rel='canonical']")?.getAttribute?.("href") || "";
63
+ let hostname = "";
64
+ try { hostname = url ? new URL(url).hostname : ""; } catch { /* keep empty */ }
65
+
66
+ return {
67
+ url,
68
+ canonicalUrl: absolute(canonicalHref, url) || url,
69
+ documentTitle: clean(doc.title || doc.querySelector?.("title")?.textContent || ""),
70
+ title: meta(doc, ["meta[property='og:title']", "meta[name='twitter:title']"]) || clean(article.headline || "") || clean(doc.title || ""),
71
+ description: meta(doc, ["meta[name='description']", "meta[property='og:description']", "meta[name='twitter:description']"]),
72
+ author: meta(doc, ["meta[name='author']", "meta[property='article:author']", "meta[name='byl']"]) || nameOf(article.author),
73
+ published: meta(doc, ["meta[property='article:published_time']", "meta[name='date']", "meta[itemprop='datePublished']"]) || clean(article.datePublished || ""),
74
+ modified: meta(doc, ["meta[property='article:modified_time']", "meta[itemprop='dateModified']", "meta[name='last-modified']"]) || clean(article.dateModified || ""),
75
+ siteName: meta(doc, ["meta[property='og:site_name']", "meta[name='application-name']"]) || nameOf(article.publisher) || hostname,
76
+ lang: clean(doc.documentElement?.getAttribute?.("lang") || "", 40),
77
+ };
78
+ }