page-markdown 0.1.4 → 0.2.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/src/index.js CHANGED
@@ -1,162 +1,200 @@
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.4";
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
- }
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, escapeMarkdown } from "./facts.js";
11
+ import { removeHiddenText, hiddenTextOmissions } from "./hidden.js";
12
+
13
+ export const VERSION = "0.2.0";
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, DEFAULT_MIN_EXTRACT_SHARE } 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
+ export * from "./types.js";
25
+
26
+ function documentOf(input) {
27
+ if (input?.nodeType === 9) return input;
28
+ if (input?.nodeType === 1) return input.ownerDocument;
29
+ throw new TypeError("page-markdown: expected a Document or Element");
30
+ }
31
+
32
+ function toMarkdown(html, mode) {
33
+ const converted = htmlToMarkdown(html);
34
+ const { markdown, removedJsonLines } = cleanMarkdown(converted);
35
+ return { markdown: applyMode(markdown, mode), removedJsonLines, convertedChars: converted.length };
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
+
48
+ // A top-level heading in the prose; a "# comment" line in fenced code is not one.
49
+ function hasTopHeading(markdown) {
50
+ let found = false;
51
+ mapOutsideFences(markdown, (prose) => {
52
+ found ||= /^# \S/m.test(prose);
53
+ return prose;
54
+ });
55
+ return found;
56
+ }
57
+
58
+ /**
59
+ * @typedef {object} PageMarkdownOptions
60
+ * @property {string} [url] page URL for absolute links (default: the document's URL)
61
+ * @property {"full"|"no_links"|"text_only"} [mode="full"]
62
+ * @property {"readable"|"full"|import("./extract.js").Extractor} [extract="readable"]
63
+ * "readable": Readability with full-page fallback; "full": the whole body;
64
+ * or a custom extractor receiving a copy of the standardized document.
65
+ * @property {number} [minExtractChars=100] below this, fall back to the page's <main>
66
+ * if it marks one and it holds enough, else to the full page
67
+ * @property {number} [minExtractShare=0.4] when Readability keeps less than this
68
+ * share of the page's main content, use the main content instead; 0 turns it off
69
+ * @property {boolean} [includeFullPage=false] also return the full-page Markdown
70
+ * @property {boolean} [includeTitle=true] start readable output with "# <headline>"
71
+ * @property {boolean} [recoverLead=true] restore a dropped lead paragraph that matches the meta description
72
+ * @property {boolean} [pageFacts=false] append "## Page facts": short label/value pairs
73
+ * (sidebar versions, prices, spec rows) that the extracted article left out
74
+ * @property {boolean} [removeHiddenText=false] remove tag-character text,
75
+ * variation-selector runs and invisible operators from the Markdown (fenced
76
+ * code included), fullPageMarkdown and metadata; omissions count the runs
77
+ * removed from the Markdown
78
+ * @property {import("./types.js").SanitizeOptions} [sanitize] see DEFAULT_SANITIZE_OPTIONS
79
+ * @property {import("./types.js").ReadabilityOptions} [readability] options passed to Readability
80
+ */
81
+
82
+ /**
83
+ * Converts a page (or an element of it) to Markdown.
84
+ * Pure with respect to the input: the live document is only read.
85
+ *
86
+ * @param {Document|Element} input
87
+ * @param {PageMarkdownOptions} [options]
88
+ * @returns {import("./types.js").PageMarkdownResult}
89
+ */
90
+ export function pageToMarkdown(input, options = {}) {
91
+ const doc = documentOf(input);
92
+ const url = options.url || doc.URL || doc.location?.href || "";
93
+ const mode = options.mode || "full";
94
+ /** @type {string[]} */
95
+ const warnings = [];
96
+
97
+ const { document: inert, omissions } = sanitizeToInertDocument(input, options.sanitize);
98
+ const standardization = standardizeDocument(inert, { url: baseUrl(doc, url) });
99
+ const extraction = extractContent(inert, {
100
+ extract: options.extract,
101
+ minExtractChars: options.minExtractChars,
102
+ minExtractShare: options.minExtractShare,
103
+ readability: options.readability,
104
+ });
105
+ const fallbackTarget = extraction.method === "main_content" ? "the main content" : "the full page";
106
+ if (extraction.fallbackReason) {
107
+ warnings.push(`extraction fell back to ${fallbackTarget}: ${extraction.fallbackReason}`);
108
+ }
109
+ if (omissions.shadowHosts) warnings.push(`shadow content cut off at the capture limit in ${omissions.shadowHosts} host(s); the page's main content may be missing`);
110
+
111
+ const pageMetadata = collectMetadata(doc, { url });
112
+ let extractedHtml = extraction.html;
113
+ let leadRecovered = false;
114
+ if (extraction.method !== "full_page" && options.recoverLead !== false) {
115
+ ({ html: extractedHtml, recovered: leadRecovered } = recoverLeadParagraph(extractedHtml, inert, pageMetadata.description));
116
+ }
117
+ const headline = chooseHeadline(inert, { ...pageMetadata, extractedTitle: extraction.title });
118
+
119
+ const main = toMarkdown(extractedHtml, mode);
120
+ // Readability lifts the article's H1 out as its title; put a clean heading
121
+ // back so the capture stands alone, unless the body still has a top-level one.
122
+ if (options.includeTitle !== false && ["readability", "main_content"].includes(extraction.method) && mode !== "text_only") {
123
+ if (headline && !hasTopHeading(main.markdown)) main.markdown = `# ${headline}\n\n${main.markdown}`;
124
+ }
125
+ let pageFacts = [];
126
+ if (options.pageFacts && extraction.method !== "full_page" && mode !== "text_only") {
127
+ pageFacts = collectPageFacts(inert, extractedHtml);
128
+ if (pageFacts.length) main.markdown = `${main.markdown}\n\n${pageFactsMarkdown(pageFacts)}`;
129
+ }
130
+ /** @type {import("./types.js").PageMarkdownOmission[]} */
131
+ const omissionList = [];
132
+ if (omissions.shadowHosts) {
133
+ omissionList.push({ kind: "shadow_content", count: omissions.shadowHosts, note: "Open shadow content omitted: capture limit" });
134
+ }
135
+ if (main.removedJsonLines) {
136
+ omissionList.push({ kind: "json_line", count: main.removedJsonLines, note: "Large single-line JSON documents removed" });
137
+ }
138
+ if (standardization.omittedTabs.length) {
139
+ const count = standardization.omittedTabs.length;
140
+ const labels = standardization.omittedTabs.filter(Boolean).map(escapeMarkdown).join(", ");
141
+ omissionList.push({ kind: "inactive_tabs", count, note: labels ? `Other tabs not included: ${labels}` : `${count} other tab(s) not included` });
142
+ }
143
+ if (extraction.commentsOmitted) {
144
+ omissionList.push({ kind: "comments", count: 1, note: "Comment section not included" });
145
+ }
146
+
147
+ /** @type {import("./types.js").PageMarkdownResult} */
148
+ const result = {
149
+ version: VERSION,
150
+ markdown: main.markdown,
151
+ metadata: {
152
+ ...pageMetadata,
153
+ ...(headline ? { headline } : {}),
154
+ ...(extraction.title ? { extractedTitle: extraction.title } : {}),
155
+ ...(extraction.byline ? { byline: extraction.byline } : {}),
156
+ },
157
+ extraction: {
158
+ method: extraction.method,
159
+ ...(extraction.fallbackReason ? { fallbackReason: extraction.fallbackReason } : {}),
160
+ ...(leadRecovered ? { leadRecovered: true } : {}),
161
+ ...(pageFacts.length ? { pageFacts: pageFacts.length } : {}),
162
+ },
163
+ omissions: omissionList,
164
+ warnings,
165
+ stats: {
166
+ mode,
167
+ extractedHtmlChars: extraction.html.length,
168
+ convertedChars: main.convertedChars,
169
+ markdownChars: main.markdown.length,
170
+ pageTextChars: (inert.body?.textContent || "").replace(/\s+/g, " ").trim().length,
171
+ standardization,
172
+ },
173
+ };
174
+
175
+ if (options.includeFullPage) {
176
+ const full = extraction.method === "full_page"
177
+ ? main
178
+ : toMarkdown((inert.body || inert.documentElement).innerHTML || "", mode);
179
+ result.fullPageMarkdown = full.markdown;
180
+ }
181
+ if (options.removeHiddenText) {
182
+ const cleaned = removeHiddenText(result.markdown);
183
+ result.markdown = cleaned.text;
184
+ result.omissions.push(...hiddenTextOmissions(cleaned.runs));
185
+ if (result.fullPageMarkdown !== undefined) result.fullPageMarkdown = removeHiddenText(result.fullPageMarkdown).text;
186
+ for (const [key, value] of Object.entries(result.metadata)) {
187
+ if (typeof value === "string") result.metadata[key] = removeHiddenText(value).text;
188
+ }
189
+ }
190
+ // A caller may pass on only the Markdown, so what changes the content says so
191
+ // in it too. Tab labels are page text: hidden-text removal applies to them.
192
+ const notices = [
193
+ ...(extraction.fallbackReason ? [`Captured ${fallbackTarget} instead of the article (\`${extraction.fallbackReason}\`)`] : []),
194
+ ...omissionList.filter(({ kind }) => ["json_line", "inactive_tabs", "comments"].includes(kind)).map((omission) => omission.note),
195
+ ...(omissions.shadowHosts ? [`Open shadow content omitted: capture limit (${omissions.shadowHosts} host(s))`] : []),
196
+ ].map((notice) => (options.removeHiddenText ? removeHiddenText(notice).text : notice));
197
+ for (const notice of notices) result.markdown += `\n\n_${notice}._`;
198
+ result.stats.markdownChars = result.markdown.length;
199
+ return result;
200
+ }
package/src/metadata.js CHANGED
@@ -1,78 +1,79 @@
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
- }
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
+ /** @returns {string} */
46
+ function nameOf(value) {
47
+ if (!value) return "";
48
+ if (typeof value === "string") return clean(value);
49
+ if (Array.isArray(value)) return value.map(nameOf).filter(Boolean).join(", ");
50
+ return clean(value.name || "");
51
+ }
52
+
53
+ const ARTICLE_TYPES = /Article|BlogPosting|NewsArticle|Report|TechArticle|WebPage|ScholarlyArticle/i;
54
+
55
+ /**
56
+ * @param {Document} doc the original page document
57
+ * @param {{ url?: string }} [options]
58
+ */
59
+ export function collectMetadata(doc, options = {}) {
60
+ const url = options.url || doc.URL || doc.location?.href || "";
61
+ const ld = jsonLdNodes(doc);
62
+ const article = ld.find((node) => ARTICLE_TYPES.test(String(node["@type"] || ""))) || {};
63
+ const canonicalHref = doc.querySelector?.("link[rel='canonical']")?.getAttribute?.("href") || "";
64
+ let hostname = "";
65
+ try { hostname = url ? new URL(url).hostname : ""; } catch { /* keep empty */ }
66
+
67
+ return {
68
+ url,
69
+ canonicalUrl: absolute(canonicalHref, url) || url,
70
+ documentTitle: clean(doc.title || doc.querySelector?.("title")?.textContent || ""),
71
+ title: meta(doc, ["meta[property='og:title']", "meta[name='twitter:title']"]) || clean(article.headline || "") || clean(doc.title || ""),
72
+ description: meta(doc, ["meta[name='description']", "meta[property='og:description']", "meta[name='twitter:description']"]),
73
+ author: meta(doc, ["meta[name='author']", "meta[property='article:author']", "meta[name='byl']"]) || nameOf(article.author),
74
+ published: meta(doc, ["meta[property='article:published_time']", "meta[name='date']", "meta[itemprop='datePublished']"]) || clean(article.datePublished || ""),
75
+ modified: meta(doc, ["meta[property='article:modified_time']", "meta[itemprop='dateModified']", "meta[name='last-modified']"]) || clean(article.dateModified || ""),
76
+ siteName: meta(doc, ["meta[property='og:site_name']", "meta[name='application-name']"]) || nameOf(article.publisher) || hostname,
77
+ lang: clean(doc.documentElement?.getAttribute?.("lang") || "", 40),
78
+ };
79
+ }