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/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
|
-
|
|
46
|
-
|
|
47
|
-
if (
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
* @param {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
+
}
|
package/src/refine.js
CHANGED
|
@@ -1,104 +1,109 @@
|
|
|
1
|
-
// Corrections after extraction, using the standardized clone and the page's
|
|
2
|
-
// metadata: restore a lead paragraph the extractor dropped, and choose a clean
|
|
3
|
-
// title heading.
|
|
4
|
-
|
|
5
|
-
const MIN_LEAD_CHARS = 40;
|
|
6
|
-
const MATCH_CHARS = 60;
|
|
7
|
-
const SEPARATORS = [" | ", " – ", " — ", " - ", " · ", " :: ", " » "];
|
|
8
|
-
|
|
9
|
-
/** Lowercase, collapse whitespace, drop a trailing ellipsis. */
|
|
10
|
-
function norm(text) {
|
|
11
|
-
return String(text || "").replace(/\s+/g, " ").replace(/(\.\.\.|…)\s*$/, "").trim().toLowerCase();
|
|
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
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
if (
|
|
79
|
-
result = result.slice(
|
|
80
|
-
changed = true;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
*
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
1
|
+
// Corrections after extraction, using the standardized clone and the page's
|
|
2
|
+
// metadata: restore a lead paragraph the extractor dropped, and choose a clean
|
|
3
|
+
// title heading.
|
|
4
|
+
|
|
5
|
+
const MIN_LEAD_CHARS = 40;
|
|
6
|
+
const MATCH_CHARS = 60;
|
|
7
|
+
const SEPARATORS = [" | ", " – ", " — ", " - ", " · ", " :: ", " » "];
|
|
8
|
+
|
|
9
|
+
/** Lowercase, collapse whitespace, drop a trailing ellipsis. */
|
|
10
|
+
function norm(text) {
|
|
11
|
+
return String(text || "").replace(/\s+/g, " ").replace(/(\.\.\.|…)\s*$/, "").trim().toLowerCase();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The text of serialized HTML: tags to spaces, the entities serializers write decoded. */
|
|
15
|
+
export function htmlText(html) {
|
|
16
|
+
return String(html || "").replace(/<[^>]*>/g, " ").replace(/ /g, " ")
|
|
17
|
+
.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Tags became spaces in htmlText, so "<a>Foo</a>, the" reads "Foo , the".
|
|
21
|
+
const withoutSpaces = (text) => text.replace(/\s+/g, "");
|
|
22
|
+
|
|
23
|
+
function startsAlike(a, b) {
|
|
24
|
+
const n = Math.min(MATCH_CHARS, a.length, b.length);
|
|
25
|
+
return n >= MIN_LEAD_CHARS && a.slice(0, n) === b.slice(0, n);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Many sites state their summary as both the meta description and the first
|
|
30
|
+
* paragraph. Extractors can drop that paragraph when it sits in a section
|
|
31
|
+
* without a heading (MDN). If the description matches a paragraph in the page
|
|
32
|
+
* that the extracted HTML lacks, put that paragraph first.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} html extracted HTML
|
|
35
|
+
* @param {Document} doc standardized clone
|
|
36
|
+
* @param {string} description meta description
|
|
37
|
+
* @returns {{ html: string, recovered: boolean }}
|
|
38
|
+
*/
|
|
39
|
+
export function recoverLeadParagraph(html, doc, description) {
|
|
40
|
+
const lead = norm(description);
|
|
41
|
+
if (lead.length < MIN_LEAD_CHARS) return { html, recovered: false };
|
|
42
|
+
const extracted = withoutSpaces(norm(htmlText(html)));
|
|
43
|
+
const paragraphs = Array.from(doc.querySelectorAll?.("p") || []).slice(0, 300);
|
|
44
|
+
for (const p of paragraphs) {
|
|
45
|
+
const text = norm(p.textContent);
|
|
46
|
+
if (text.length < MIN_LEAD_CHARS || !startsAlike(text, lead)) continue;
|
|
47
|
+
if (extracted.includes(withoutSpaces(text.slice(0, 80)))) return { html, recovered: false };
|
|
48
|
+
return { html: `${p.outerHTML}\n${html}`, recovered: true };
|
|
49
|
+
}
|
|
50
|
+
return { html, recovered: false };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function cleanHeading(text) {
|
|
54
|
+
return String(text || "")
|
|
55
|
+
.replace(/\s+/g, " ")
|
|
56
|
+
.replace(/\s*[¶#§]\s*$/, "")
|
|
57
|
+
.trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function firstHeading(doc) {
|
|
61
|
+
const scoped = doc.querySelector?.("main h1, article h1, [role='main'] h1");
|
|
62
|
+
return cleanHeading((scoped || doc.querySelector?.("h1"))?.textContent);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Drops site-name segments at either end: "Page | Site", "Site - Page".
|
|
66
|
+
function stripSiteSuffix(title, siteName) {
|
|
67
|
+
let result = String(title || "").trim();
|
|
68
|
+
const site = norm(siteName);
|
|
69
|
+
if (!site) return result;
|
|
70
|
+
const isSite = (segment) => {
|
|
71
|
+
const value = norm(segment);
|
|
72
|
+
return Boolean(value) && (value.includes(site) || site.includes(value));
|
|
73
|
+
};
|
|
74
|
+
for (let changed = true; changed;) {
|
|
75
|
+
changed = false;
|
|
76
|
+
for (const separator of SEPARATORS) {
|
|
77
|
+
const last = result.lastIndexOf(separator);
|
|
78
|
+
if (last > 0 && isSite(result.slice(last + separator.length))) {
|
|
79
|
+
result = result.slice(0, last).trim();
|
|
80
|
+
changed = true;
|
|
81
|
+
}
|
|
82
|
+
const first = result.indexOf(separator);
|
|
83
|
+
if (first > 0 && isSite(result.slice(0, first))) {
|
|
84
|
+
result = result.slice(first + separator.length).trim();
|
|
85
|
+
changed = true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The heading for the capture: the page's own H1 when the document title or
|
|
94
|
+
* og:title contains it (and it is not just the site name); otherwise og:title;
|
|
95
|
+
* otherwise the title without a trailing site-name segment.
|
|
96
|
+
*
|
|
97
|
+
* @param {Document} doc standardized clone
|
|
98
|
+
* @param {{ documentTitle?: string, title?: string, siteName?: string, extractedTitle?: string }} [meta]
|
|
99
|
+
*/
|
|
100
|
+
export function chooseHeadline(doc, meta = {}) {
|
|
101
|
+
const h1 = firstHeading(doc);
|
|
102
|
+
const titles = [meta.documentTitle, meta.title, meta.extractedTitle].map(norm).filter(Boolean);
|
|
103
|
+
if (h1 && h1.length <= 200 && norm(h1) !== norm(meta.siteName) && titles.some((t) => t.includes(norm(h1)))) {
|
|
104
|
+
return h1;
|
|
105
|
+
}
|
|
106
|
+
const og = String(meta.title || "").trim();
|
|
107
|
+
if (og && og !== String(meta.documentTitle || "").trim()) return stripSiteSuffix(og, meta.siteName);
|
|
108
|
+
return stripSiteSuffix(meta.extractedTitle || meta.documentTitle || og, meta.siteName);
|
|
109
|
+
}
|