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/CHANGELOG.md +149 -0
- package/README.md +139 -20
- package/dist/types/clean.d.ts +23 -0
- package/dist/types/convert.d.ts +19 -0
- package/dist/types/extract.d.ts +42 -0
- package/dist/types/facts.d.ts +15 -0
- package/dist/types/hidden.d.ts +14 -0
- package/dist/types/index.d.ts +107 -0
- package/dist/types/metadata.d.ts +18 -0
- package/dist/types/refine.d.ts +31 -0
- package/dist/types/sanitize.d.ts +28 -0
- package/dist/types/standardize.d.ts +16 -0
- package/dist/types/types.d.ts +167 -0
- package/package.json +17 -5
- package/src/clean.js +21 -9
- package/src/convert.js +142 -27
- package/src/extract.js +211 -70
- package/src/facts.js +102 -96
- package/src/hidden.js +46 -45
- package/src/index.js +200 -162
- package/src/metadata.js +79 -78
- package/src/refine.js +115 -104
- package/src/sanitize.js +217 -193
- package/src/standardize.js +150 -17
- package/src/types.js +122 -0
package/src/extract.js
CHANGED
|
@@ -1,70 +1,211 @@
|
|
|
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
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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, and to the page's main content when it keeps only a
|
|
4
|
+
// small share of that (forum threads, product grids), brings the site's menu
|
|
5
|
+
// along, or keeps too few of its sections (product pages).
|
|
6
|
+
// The class only: skips isProbablyReaderable, which the pipeline does not use.
|
|
7
|
+
import Readability from "@mozilla/readability/Readability.js";
|
|
8
|
+
import { htmlText } from "./refine.js";
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_MIN_EXTRACT_CHARS = 100;
|
|
11
|
+
export const DEFAULT_MIN_EXTRACT_SHARE = 0.4;
|
|
12
|
+
// A small share of a short page may be all there is: don't fall back for less.
|
|
13
|
+
const MIN_MISSED_CHARS = 500;
|
|
14
|
+
export const PAGE_CHROME = "nav, footer, form, [role='navigation'], [role='banner'], [role='contentinfo'], [role='search']";
|
|
15
|
+
const MAIN = "main, [role='main']";
|
|
16
|
+
const COMMENTS = "#comments, #disqus_thread, .comments, .commentlist, .comment-list, .comment, [id^='comment-']";
|
|
17
|
+
const HIDDEN_STYLE = /(?:^|;)\s*(?:display\s*:\s*none|visibility\s*:\s*hidden)/i;
|
|
18
|
+
const TOGGLE = "button, summary, [aria-expanded]";
|
|
19
|
+
// Shorter hidden text beside a button is a status message ("Added"), not an answer.
|
|
20
|
+
const MIN_ANSWER_CHARS = 40;
|
|
21
|
+
const MENU_SAMPLE_CHARS = 30;
|
|
22
|
+
// Fewer h2s than this, and a page has no sections to lose.
|
|
23
|
+
const MIN_SECTIONS = 3;
|
|
24
|
+
const SECTION_SHARE_FACTOR = 1.5;
|
|
25
|
+
|
|
26
|
+
// As far as a clone can tell, with no stylesheets. Not aria-hidden: shops put
|
|
27
|
+
// the price people see in it, beside a longer one for screen readers.
|
|
28
|
+
export function isHiddenElement(el) {
|
|
29
|
+
return el.hasAttribute("hidden") || HIDDEN_STYLE.test(el.getAttribute("style") || "");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function textLength(html = "") {
|
|
33
|
+
return String(html).replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim().length;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function bodyHtml(doc) {
|
|
37
|
+
return (doc.body || doc.documentElement)?.innerHTML || "";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// An accordion's answer, hidden until its button is pressed: FAQs on shop pages.
|
|
41
|
+
function isCollapsedAnswer(el) {
|
|
42
|
+
return Boolean(el.previousElementSibling?.matches(TOGGLE)) && (el.textContent || "").trim().length >= MIN_ANSWER_CHARS;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// <main> or else the body, without navigation, other page chrome or anything
|
|
46
|
+
// hidden, except code behind a "Show code" toggle and collapsed answers. A header
|
|
47
|
+
// inside <main> usually belongs to the content; one in the body doesn't.
|
|
48
|
+
function mainContentRoot(doc) {
|
|
49
|
+
const main = doc.querySelector(MAIN);
|
|
50
|
+
const root = (main || doc.body || doc.documentElement).cloneNode(true);
|
|
51
|
+
for (const el of Array.from(root.querySelectorAll(`${PAGE_CHROME}, aside, template, [role='complementary']${main ? "" : ", header"}`))) el.remove();
|
|
52
|
+
for (const el of Array.from(root.querySelectorAll("[hidden], [style]"))) {
|
|
53
|
+
if (isHiddenElement(el) && !el.querySelector("pre") && !isCollapsedAnswer(el)) el.remove();
|
|
54
|
+
}
|
|
55
|
+
for (const link of Array.from(root.querySelectorAll("a[href^='#']"))) {
|
|
56
|
+
if (/^\s*skip\b/i.test(link.textContent || "")) link.remove();
|
|
57
|
+
}
|
|
58
|
+
return root;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const withoutSpaces = (text) => text.replace(/\s+/g, "");
|
|
62
|
+
|
|
63
|
+
// An article above its comments, where Readability kept the article: what's left
|
|
64
|
+
// without them. null for a thread, where what Readability kept is itself a comment.
|
|
65
|
+
function articleWithoutComments(root, articleHtml) {
|
|
66
|
+
const comments = Array.from(root.querySelectorAll(COMMENTS));
|
|
67
|
+
if (!comments.length) return null;
|
|
68
|
+
const kept = withoutSpaces(htmlText(articleHtml));
|
|
69
|
+
const sample = kept.slice(kept.length >> 1, (kept.length >> 1) + 60);
|
|
70
|
+
if (comments.some((el) => withoutSpaces(el.textContent || "").includes(sample))) return null;
|
|
71
|
+
for (const el of comments) el.remove();
|
|
72
|
+
return root;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Readability chose a container above <main>, and the site's menu came with it.
|
|
76
|
+
function keptSiteMenu(doc, kept) {
|
|
77
|
+
const main = doc.querySelector(MAIN);
|
|
78
|
+
if (!main) return false;
|
|
79
|
+
const text = withoutSpaces(main.textContent || "");
|
|
80
|
+
if (!kept.includes(text.slice(text.length >> 1, (text.length >> 1) + MENU_SAMPLE_CHARS))) return false;
|
|
81
|
+
return Array.from(doc.querySelectorAll("nav, [role='navigation']")).some((nav) => {
|
|
82
|
+
const menu = withoutSpaces(nav.textContent || "").slice(0, MENU_SAMPLE_CHARS);
|
|
83
|
+
return !main.contains(nav) && menu.length === MENU_SAMPLE_CHARS && kept.includes(menu);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// A heading that is one link names another page, not a section of this one.
|
|
88
|
+
function isStoryCardTitle(h2) {
|
|
89
|
+
const text = withoutSpaces(h2.textContent || "");
|
|
90
|
+
return Boolean(h2.closest("a")) || Array.from(h2.querySelectorAll("a:not([href^='#'])")).some((a) => withoutSpaces(a.textContent || "") === text);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function lostMostSections(root, kept) {
|
|
94
|
+
const headings = Array.from(root.querySelectorAll("h2")).filter((h2) => !isStoryCardTitle(h2)).map((h2) => withoutSpaces(h2.textContent || "")).filter(Boolean);
|
|
95
|
+
return headings.length >= MIN_SECTIONS && headings.filter((heading) => !kept.includes(heading)).length * 2 > headings.length;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Layout utilities, which say nothing about content: Tailwind's scroll-mt-2 and
|
|
99
|
+
// overflow-hidden, md:hidden.
|
|
100
|
+
const LAYOUT_CLASS = /^(?:\S+:)?(?:-?scroll-[mp][a-z]?-\S+|overflow(?:-[xy])?-(?:hidden|scroll)|(?<=:)hidden)$/;
|
|
101
|
+
|
|
102
|
+
// Readability scores class and id names, and removes what looks like clutter:
|
|
103
|
+
// Docusaurus's heading "anchorTargetHideOnScrollNavbar" matches "scroll", and so
|
|
104
|
+
// does the scroll-mt-2 on every code block of Docker's docs.
|
|
105
|
+
function withoutMisleadingNames(doc) {
|
|
106
|
+
for (const heading of Array.from(doc.querySelectorAll("h1, h2, h3, h4, h5, h6"))) {
|
|
107
|
+
heading.removeAttribute("class");
|
|
108
|
+
heading.removeAttribute("id");
|
|
109
|
+
}
|
|
110
|
+
for (const el of Array.from(doc.querySelectorAll("[class]"))) {
|
|
111
|
+
const names = String(el.getAttribute("class")).split(/\s+/);
|
|
112
|
+
const kept = names.filter((name) => !LAYOUT_CLASS.test(name));
|
|
113
|
+
if (kept.length < names.length) el.setAttribute("class", kept.join(" "));
|
|
114
|
+
}
|
|
115
|
+
// Readability removes what's aria-hidden, and icons in table cells usually are.
|
|
116
|
+
for (const icon of Array.from(doc.querySelectorAll("td svg[aria-hidden], th svg[aria-hidden]"))) icon.removeAttribute("aria-hidden");
|
|
117
|
+
return doc;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function copyDocument(doc) {
|
|
121
|
+
const copy = doc.cloneNode(true);
|
|
122
|
+
if (copy && copy.nodeType === 9 && copy.documentElement) return copy;
|
|
123
|
+
// Implementations whose Document.cloneNode is shallow: reparse.
|
|
124
|
+
const Parser = doc.defaultView?.DOMParser || globalThis.DOMParser;
|
|
125
|
+
return new Parser().parseFromString(doc.documentElement.outerHTML, "text/html");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @typedef {{ html: string, method: "readability"|"main_content"|"full_page"|(string & {}), fallbackReason?: string, title?: string,
|
|
130
|
+
* byline?: string, published?: string, siteName?: string, lang?: string, excerpt?: string, commentsOmitted?: boolean }} Extraction
|
|
131
|
+
*/
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Receives a copy of the standardized document. Returning nothing, empty HTML
|
|
135
|
+
* or too little text falls back to the full page; so does throwing.
|
|
136
|
+
* @typedef {(doc: Document) => Extraction|null|undefined} Extractor
|
|
137
|
+
*/
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @param {Document} doc standardized inert document (not mutated)
|
|
141
|
+
* @param {{ extract?: "readable"|"full"|Extractor,
|
|
142
|
+
* minExtractChars?: number, minExtractShare?: number, readability?: import("./types.js").ReadabilityOptions }} [options]
|
|
143
|
+
* @returns {Extraction}
|
|
144
|
+
*/
|
|
145
|
+
export function extractContent(doc, options = {}) {
|
|
146
|
+
const mode = options.extract ?? "readable";
|
|
147
|
+
const full = () => ({ html: bodyHtml(doc), method: "full_page" });
|
|
148
|
+
|
|
149
|
+
if (mode === "full") return full();
|
|
150
|
+
|
|
151
|
+
const minChars = options.minExtractChars ?? DEFAULT_MIN_EXTRACT_CHARS;
|
|
152
|
+
let result = null;
|
|
153
|
+
let failure = "";
|
|
154
|
+
try {
|
|
155
|
+
if (typeof mode === "function") {
|
|
156
|
+
result = mode(copyDocument(doc));
|
|
157
|
+
} else {
|
|
158
|
+
const article = new Readability(withoutMisleadingNames(copyDocument(doc)), { keepClasses: true, ...options.readability }).parse();
|
|
159
|
+
result = article && {
|
|
160
|
+
html: article.content || "",
|
|
161
|
+
method: "readability",
|
|
162
|
+
title: article.title || "",
|
|
163
|
+
byline: article.byline || "",
|
|
164
|
+
published: article.publishedTime || "",
|
|
165
|
+
siteName: article.siteName || "",
|
|
166
|
+
lang: article.lang || "",
|
|
167
|
+
excerpt: article.excerpt || "",
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
} catch (error) {
|
|
171
|
+
failure = `extractor_error: ${/** @type {any} */ (error)?.message || error}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Too little from the default extractor: the page's own <main>, if it marks
|
|
175
|
+
// one and it holds enough, beats a full page that opens with site navigation.
|
|
176
|
+
const tooLittle = (fallbackReason, title) => {
|
|
177
|
+
if (typeof mode !== "function" && doc.querySelector(MAIN)) {
|
|
178
|
+
const html = mainContentRoot(doc).innerHTML || "";
|
|
179
|
+
if (textLength(html) >= minChars) return { html, method: "main_content", fallbackReason, title };
|
|
180
|
+
}
|
|
181
|
+
return { ...full(), fallbackReason, ...(title === undefined ? {} : { title }) };
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
if (!result?.html) return tooLittle(failure || "extractor_found_nothing");
|
|
185
|
+
if (textLength(result.html) < minChars) return tooLittle("extracted_text_below_minimum", result.title);
|
|
186
|
+
if (result.method === "readability") {
|
|
187
|
+
const share = options.minExtractShare ?? DEFAULT_MIN_EXTRACT_SHARE;
|
|
188
|
+
const kept = textLength(result.html);
|
|
189
|
+
const isSmall = (html) => {
|
|
190
|
+
const available = textLength(html);
|
|
191
|
+
return kept < available * share && available - kept >= MIN_MISSED_CHARS;
|
|
192
|
+
};
|
|
193
|
+
const keptText = withoutSpaces(htmlText(result.html));
|
|
194
|
+
// Section loss counts only where it takes more of the page than a related-links block.
|
|
195
|
+
const misses = (el) => {
|
|
196
|
+
const available = el.innerHTML || "";
|
|
197
|
+
return isSmall(available) || (kept < textLength(available) * share * SECTION_SHARE_FACTOR && lostMostSections(el, keptText));
|
|
198
|
+
};
|
|
199
|
+
const root = mainContentRoot(doc);
|
|
200
|
+
const html = root.innerHTML || "";
|
|
201
|
+
const reason = keptSiteMenu(doc, keptText) && textLength(html) >= minChars ? "extracted_site_menu"
|
|
202
|
+
: isSmall(html) ? "extracted_small_share_of_page"
|
|
203
|
+
: misses(root) ? "extracted_few_sections_of_page" : "";
|
|
204
|
+
if (reason) {
|
|
205
|
+
const article = reason === "extracted_site_menu" ? null : articleWithoutComments(root, result.html);
|
|
206
|
+
if (article && !misses(article)) return { ...result, commentsOmitted: true };
|
|
207
|
+
return { ...result, html, method: "main_content", fallbackReason: reason };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return result;
|
|
211
|
+
}
|
package/src/facts.js
CHANGED
|
@@ -1,96 +1,102 @@
|
|
|
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
|
-
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
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
|
-
function
|
|
53
|
-
return text.replace(
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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 { PAGE_CHROME } from "./extract.js";
|
|
6
|
+
import { htmlText } from "./refine.js";
|
|
7
|
+
|
|
8
|
+
const MAX_FACTS = 24;
|
|
9
|
+
const MAX_LABEL_CHARS = 40;
|
|
10
|
+
const MAX_VALUE_CHARS = 100;
|
|
11
|
+
const CHROME = `${PAGE_CHROME}, header, [id*='cookie'], [class*='cookie'], [id*='consent'], [class*='consent'], [id*='onetrust'], [class*='onetrust']`;
|
|
12
|
+
|
|
13
|
+
function squash(text) {
|
|
14
|
+
return String(text || "").replace(/\s+/g, " ").trim();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// A sentence is prose, not a fact: "Returns a signal that aborts later."
|
|
18
|
+
function isSentence(text) {
|
|
19
|
+
return /[.!?]$/.test(text) && text.split(" ").length >= 4;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function shortValue(el) {
|
|
23
|
+
if (!el || el.querySelector("h1, h2, h3, h4, h5, h6, table, pre, form, button, input, select, textarea")) return "";
|
|
24
|
+
// A list of links is navigation, not a fact.
|
|
25
|
+
const links = el.tagName === "A" ? [el] : el.querySelectorAll("a");
|
|
26
|
+
if (links.length > 1) return "";
|
|
27
|
+
const text = squash(el.textContent);
|
|
28
|
+
// So is a lone link, unless its text is data: a domain, path or number.
|
|
29
|
+
if (links.length === 1 && squash(links[0].textContent) === text && !/[\d./@]/.test(text)) return "";
|
|
30
|
+
return text.length <= MAX_VALUE_CHARS && !isSentence(text) ? text : "";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function* candidatePairs(doc) {
|
|
34
|
+
// Heading followed by a short value: <h3>Version</h3><p>1.2.3</p>.
|
|
35
|
+
for (const h of doc.querySelectorAll("h2, h3, h4, h5, h6")) {
|
|
36
|
+
// A linked heading titles something else: another story's teaser, a card.
|
|
37
|
+
if (h.closest("a") || h.querySelector("a")) continue;
|
|
38
|
+
yield [h.textContent, shortValue(h.nextElementSibling), h];
|
|
39
|
+
}
|
|
40
|
+
for (const dt of doc.querySelectorAll("dt")) {
|
|
41
|
+
const dd = dt.nextElementSibling;
|
|
42
|
+
if (dd?.tagName === "DD") yield [dt.textContent, shortValue(dd), dt];
|
|
43
|
+
}
|
|
44
|
+
// Two-cell rows: | Label | Value |.
|
|
45
|
+
for (const row of doc.querySelectorAll("tr")) {
|
|
46
|
+
const cells = Array.from(row.children).filter((c) => c.tagName === "TH" || c.tagName === "TD");
|
|
47
|
+
if (cells.length === 2) yield [cells[0].textContent, shortValue(cells[1]), row];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// "1.0.1 December 17, 2004 [5]" and "1.0.1December 17, 2004" compare equal.
|
|
52
|
+
function compact(text) {
|
|
53
|
+
return text.replace(/\[\d+\]/g, "").replace(/\s+/g, "");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function escapeRegExp(text) {
|
|
57
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {Document} doc standardized clone
|
|
62
|
+
* @param {string} articleHtml the extracted article
|
|
63
|
+
* @returns {Array<{ label: string, value: string }>} facts absent from the article
|
|
64
|
+
*/
|
|
65
|
+
export function collectPageFacts(doc, articleHtml) {
|
|
66
|
+
const article = squash(htmlText(articleHtml));
|
|
67
|
+
const articleCompact = compact(article);
|
|
68
|
+
const pairs = Array.from(candidatePairs(doc), ([label, value, el]) => [squash(label).replace(/\s*[::¶#§]\s*$/, ""), value, el]);
|
|
69
|
+
// A "value" that is the next section's label is a heading run, not a fact.
|
|
70
|
+
const labels = new Set(pairs.map(([label]) => label));
|
|
71
|
+
const facts = [];
|
|
72
|
+
const seen = new Set();
|
|
73
|
+
for (const [label, value, el] of pairs) {
|
|
74
|
+
if (!label || !value || label.length > MAX_LABEL_CHARS || label === value || labels.has(value)) continue;
|
|
75
|
+
if (el.closest(CHROME)) continue;
|
|
76
|
+
const key = `${label}\u0000${value}`;
|
|
77
|
+
if (seen.has(key)) continue;
|
|
78
|
+
seen.add(key);
|
|
79
|
+
// Already in the article: short values as a whole word, longer ones
|
|
80
|
+
// ignoring spacing and citation marks.
|
|
81
|
+
const bare = compact(value);
|
|
82
|
+
if (bare.length >= 6 ? articleCompact.includes(bare)
|
|
83
|
+
: new RegExp(`(^|[^\\w])${escapeRegExp(value)}($|[^\\w])`).test(article)) continue;
|
|
84
|
+
facts.push({ label, value });
|
|
85
|
+
if (facts.length >= MAX_FACTS) break;
|
|
86
|
+
}
|
|
87
|
+
return facts;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function escapeMarkdown(text) {
|
|
91
|
+
return text.replace(/([\\`*_[\]<>|])/g, "\\$1");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* "## Page facts" list, or "" when there are none.
|
|
96
|
+
* @param {readonly import("./types.js").PageFact[]} facts
|
|
97
|
+
*/
|
|
98
|
+
export function pageFactsMarkdown(facts) {
|
|
99
|
+
if (!facts.length) return "";
|
|
100
|
+
const lines = facts.map(({ label, value }) => `- ${escapeMarkdown(label)}: ${escapeMarkdown(value)}`);
|
|
101
|
+
return `## Page facts\n\n${lines.join("\n")}`;
|
|
102
|
+
}
|
package/src/hidden.js
CHANGED
|
@@ -1,45 +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
|
-
// 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:
|
|
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 {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
entry.count
|
|
41
|
-
entry.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
+
}
|