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/refine.js ADDED
@@ -0,0 +1,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
+ function textOf(html) {
15
+ return norm(String(html || "").replace(/<[^>]*>/g, " ").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&"));
16
+ }
17
+
18
+ function startsAlike(a, b) {
19
+ const n = Math.min(MATCH_CHARS, a.length, b.length);
20
+ return n >= MIN_LEAD_CHARS && a.slice(0, n) === b.slice(0, n);
21
+ }
22
+
23
+ /**
24
+ * Many sites state their summary as both the meta description and the first
25
+ * paragraph. Extractors can drop that paragraph when it sits in a section
26
+ * without a heading (MDN). If the description matches a paragraph in the page
27
+ * that the extracted HTML lacks, put that paragraph first.
28
+ *
29
+ * @param {string} html extracted HTML
30
+ * @param {Document} doc standardized clone
31
+ * @param {string} description meta description
32
+ * @returns {{ html: string, recovered: boolean }}
33
+ */
34
+ export function recoverLeadParagraph(html, doc, description) {
35
+ const lead = norm(description);
36
+ if (lead.length < MIN_LEAD_CHARS) return { html, recovered: false };
37
+ const extracted = textOf(html);
38
+ const paragraphs = Array.from(doc.querySelectorAll?.("p") || []).slice(0, 300);
39
+ for (const p of paragraphs) {
40
+ const text = norm(p.textContent);
41
+ if (text.length < MIN_LEAD_CHARS || !startsAlike(text, lead)) continue;
42
+ if (extracted.includes(text.slice(0, 80))) return { html, recovered: false };
43
+ return { html: `${p.outerHTML}\n${html}`, recovered: true };
44
+ }
45
+ return { html, recovered: false };
46
+ }
47
+
48
+ function cleanHeading(text) {
49
+ return String(text || "")
50
+ .replace(/\s+/g, " ")
51
+ .replace(/\s*[¶#§]\s*$/, "")
52
+ .trim();
53
+ }
54
+
55
+ function firstHeading(doc) {
56
+ const scoped = doc.querySelector?.("main h1, article h1, [role='main'] h1");
57
+ return cleanHeading((scoped || doc.querySelector?.("h1"))?.textContent);
58
+ }
59
+
60
+ // Drops site-name segments at either end: "Page | Site", "Site - Page".
61
+ function stripSiteSuffix(title, siteName) {
62
+ let result = String(title || "").trim();
63
+ const site = norm(siteName);
64
+ if (!site) return result;
65
+ const isSite = (segment) => {
66
+ const value = norm(segment);
67
+ return Boolean(value) && (value.includes(site) || site.includes(value));
68
+ };
69
+ for (let changed = true; changed;) {
70
+ changed = false;
71
+ for (const separator of SEPARATORS) {
72
+ const last = result.lastIndexOf(separator);
73
+ if (last > 0 && isSite(result.slice(last + separator.length))) {
74
+ result = result.slice(0, last).trim();
75
+ changed = true;
76
+ }
77
+ const first = result.indexOf(separator);
78
+ if (first > 0 && isSite(result.slice(0, first))) {
79
+ result = result.slice(first + separator.length).trim();
80
+ changed = true;
81
+ }
82
+ }
83
+ }
84
+ return result;
85
+ }
86
+
87
+ /**
88
+ * The heading for the capture: the page's own H1 when the document title or
89
+ * og:title contains it (and it is not just the site name); otherwise og:title;
90
+ * otherwise the title without a trailing site-name segment.
91
+ *
92
+ * @param {Document} doc standardized clone
93
+ * @param {{ documentTitle?: string, title?: string, siteName?: string, extractedTitle?: string }} meta
94
+ */
95
+ export function chooseHeadline(doc, meta = {}) {
96
+ const h1 = firstHeading(doc);
97
+ const titles = [meta.documentTitle, meta.title, meta.extractedTitle].map(norm).filter(Boolean);
98
+ if (h1 && h1.length <= 200 && norm(h1) !== norm(meta.siteName) && titles.some((t) => t.includes(norm(h1)))) {
99
+ return h1;
100
+ }
101
+ const og = String(meta.title || "").trim();
102
+ if (og && og !== String(meta.documentTitle || "").trim()) return stripSiteSuffix(og, meta.siteName);
103
+ return stripSiteSuffix(meta.extractedTitle || meta.documentTitle || og, meta.siteName);
104
+ }
@@ -0,0 +1,193 @@
1
+ // Stage 1: clone the page into an inert document, inline open shadow roots
2
+ // within limits, and strip non-content. The live page is never modified.
3
+
4
+ export const DEFAULT_SANITIZE_OPTIONS = Object.freeze({
5
+ stripScripts: true,
6
+ stripStyles: true,
7
+ stripNoscript: true,
8
+ stripMeta: true,
9
+ stripLinkTags: true,
10
+ stripAds: true,
11
+ // App-specific overlays or widgets to drop, e.g. "[data-app-overlay]".
12
+ extraStripSelectors: [],
13
+ shadowNodeLimit: 5000,
14
+ shadowCharLimit: 500000,
15
+ shadowDepthLimit: 128,
16
+ });
17
+
18
+ const STRIP_RULES = {
19
+ stripScripts: "script",
20
+ stripStyles: "style",
21
+ stripNoscript: "noscript",
22
+ stripMeta: "meta",
23
+ stripLinkTags: "link",
24
+ };
25
+
26
+ // Advertising placements: explicit ad markup, plus custom elements named for ad
27
+ // slots (e.g. MDN's <mdn-placement-sidebar>).
28
+ const AD_SELECTOR = [
29
+ "ins.adsbygoogle", ".adsbygoogle", ".advertisement",
30
+ "[data-ad-slot]", "[data-ad-client]", "[data-ad-unit]",
31
+ "[aria-label='Advertisement' i]", "[aria-label='Advertisements' i]", "[aria-label='Sponsored' i]",
32
+ "iframe[src*='doubleclick.net']", "iframe[src*='googlesyndication.com']", "iframe[id^='google_ads_iframe']",
33
+ ].join(", ");
34
+ const AD_ELEMENT_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*-(?:placement|advert|advertisement|ads?|sponsor|sponsored)(?:-[a-z0-9]+)*$/;
35
+
36
+ /** @param {any} node */
37
+ export function isAdvertisementElement(node) {
38
+ if (node?.nodeType !== 1) return false;
39
+ if (AD_ELEMENT_NAME.test(node.localName || "")) return true;
40
+ try {
41
+ return Boolean(node.matches?.(AD_SELECTOR));
42
+ } catch {
43
+ // DOM implementations without case-insensitive attribute selectors.
44
+ return Boolean(node.matches?.(AD_SELECTOR.replace(/ i\]/g, "]")));
45
+ }
46
+ }
47
+
48
+ function stripSelectors(options) {
49
+ const selectors = Object.entries(STRIP_RULES)
50
+ .filter(([flag]) => options[flag])
51
+ .map(([, selector]) => selector);
52
+ for (const extra of options.extraStripSelectors || []) {
53
+ if (typeof extra === "string" && extra.trim()) selectors.push(extra.trim());
54
+ }
55
+ return selectors;
56
+ }
57
+
58
+ function isStripped(node, selectors, options) {
59
+ if (node.nodeType !== 1) return false;
60
+ if (selectors.some((selector) => node.matches?.(selector))) return true;
61
+ return Boolean(options.stripAds && isAdvertisementElement(node));
62
+ }
63
+
64
+ /**
65
+ * Creates an empty HTML document unconnected to the page: no scripts run and
66
+ * no custom elements upgrade in it.
67
+ * @param {Document} doc
68
+ */
69
+ export function createInertDocument(doc) {
70
+ const impl = doc?.implementation;
71
+ if (typeof impl?.createHTMLDocument === "function") return impl.createHTMLDocument("");
72
+ const Parser = doc?.defaultView?.DOMParser || globalThis.DOMParser;
73
+ if (typeof Parser === "function") {
74
+ return new Parser().parseFromString("<!doctype html><html><head></head><body></body></html>", "text/html");
75
+ }
76
+ throw new Error("page-markdown: cannot create an inert document (no createHTMLDocument or DOMParser)");
77
+ }
78
+
79
+ const SHADOW_LIMIT = Symbol("shadow limit");
80
+
81
+ function positiveLimit(value, name) {
82
+ if (!Number.isSafeInteger(value) || value < 1) {
83
+ throw new RangeError(`page-markdown: ${name} must be a positive integer`);
84
+ }
85
+ return value;
86
+ }
87
+
88
+ /**
89
+ * Clones `root` (a Document or Element) into a new inert document.
90
+ * Open shadow roots replace their host's light children (native slots supply
91
+ * assigned children). Limits apply only to shadow content; a host over the
92
+ * limit is emptied and counted as an omission.
93
+ *
94
+ * @param {Document|Element} root
95
+ * @param {Partial<typeof DEFAULT_SANITIZE_OPTIONS>} [options]
96
+ * @returns {{ document: Document, omissions: { shadowHosts: number } }}
97
+ */
98
+ export function sanitizeToInertDocument(root, options = {}) {
99
+ const opts = { ...DEFAULT_SANITIZE_OPTIONS, ...options };
100
+ const limits = {
101
+ nodes: positiveLimit(opts.shadowNodeLimit, "shadowNodeLimit"),
102
+ chars: positiveLimit(opts.shadowCharLimit, "shadowCharLimit"),
103
+ depth: positiveLimit(opts.shadowDepthLimit, "shadowDepthLimit"),
104
+ };
105
+ const selectors = stripSelectors(opts);
106
+ const sourceDoc = root.nodeType === 9 ? root : root.ownerDocument;
107
+ const source = root.nodeType === 9 ? root.documentElement : root;
108
+ const inert = createInertDocument(sourceDoc);
109
+
110
+ function shallow(node) {
111
+ if (node.nodeType !== 1) return inert.importNode(node, false);
112
+ const name = node.prefix ? `${node.prefix}:${node.localName}` : node.localName;
113
+ const copy = node.namespaceURI
114
+ ? inert.createElementNS(node.namespaceURI, name)
115
+ : inert.createElement(name);
116
+ for (const attr of Array.from(node.attributes || [])) {
117
+ // HTML allows colon-bearing names without a namespace (framework
118
+ // directives); setAttributeNS rejects those.
119
+ if (attr.namespaceURI) copy.setAttributeNS(attr.namespaceURI, attr.name, attr.value);
120
+ else copy.setAttribute(attr.name, attr.value);
121
+ }
122
+ return copy;
123
+ }
124
+
125
+ let nodes = 0;
126
+ let chars = 0;
127
+ let shadowHosts = 0;
128
+
129
+ function isAssigningSlot(node) {
130
+ return node.nodeType === 1 && node.localName === "slot"
131
+ && typeof node.assignedNodes === "function" && Boolean(node.getRootNode?.().host);
132
+ }
133
+
134
+ function shadowNode(node, depth) {
135
+ if (++nodes > limits.nodes || depth > limits.depth) throw SHADOW_LIMIT;
136
+ if (isStripped(node, selectors, opts)) return inert.createDocumentFragment();
137
+ const copy = shallow(node);
138
+ chars += node.nodeType === 1 ? (copy.outerHTML || "").length : (node.nodeValue || "").length * 6;
139
+ if (chars > limits.chars) throw SHADOW_LIMIT;
140
+ let children = node.childNodes;
141
+ let target = copy;
142
+ if (isAssigningSlot(node)) {
143
+ const assigned = node.assignedNodes({ flatten: true });
144
+ children = assigned.length ? assigned : node.childNodes;
145
+ target = inert.createDocumentFragment();
146
+ } else if (node.shadowRoot) {
147
+ children = node.shadowRoot.childNodes;
148
+ } else if (node.nodeType === 1 && node.localName === "template" && node.content) {
149
+ children = node.content.childNodes;
150
+ target = copy.content || copy;
151
+ }
152
+ for (const child of Array.from(children || [])) target.appendChild(shadowNode(child, depth + 1));
153
+ return isAssigningSlot(node) ? target : copy;
154
+ }
155
+
156
+ const clone = shallow(source);
157
+ // Iterative light-DOM walk: ordinary pages get no depth limit.
158
+ const pending = [[source, clone]];
159
+ while (pending.length) {
160
+ const [live, copy] = pending.pop();
161
+ if (live.shadowRoot) {
162
+ const fragment = inert.createDocumentFragment();
163
+ try {
164
+ for (const child of Array.from(live.shadowRoot.childNodes)) fragment.appendChild(shadowNode(child, 1));
165
+ while (copy.firstChild) copy.removeChild(copy.firstChild);
166
+ copy.appendChild(fragment);
167
+ } catch (error) {
168
+ if (error !== SHADOW_LIMIT) throw error;
169
+ while (copy.firstChild) copy.removeChild(copy.firstChild);
170
+ shadowHosts += 1;
171
+ }
172
+ continue;
173
+ }
174
+ const children = live.content?.childNodes || live.childNodes;
175
+ const target = copy.content || copy;
176
+ const pairs = [];
177
+ for (const child of Array.from(children || [])) {
178
+ if (isStripped(child, selectors, opts)) continue;
179
+ const childCopy = shallow(child);
180
+ target.appendChild(childCopy);
181
+ pairs.push([child, childCopy]);
182
+ }
183
+ for (let i = pairs.length - 1; i >= 0; i -= 1) pending.push(pairs[i]);
184
+ }
185
+
186
+ if (clone.localName === "html") {
187
+ inert.removeChild(inert.documentElement);
188
+ inert.appendChild(clone);
189
+ } else {
190
+ inert.body.appendChild(clone);
191
+ }
192
+ return { document: inert, omissions: { shadowHosts } };
193
+ }
@@ -0,0 +1,154 @@
1
+ // Stage 2: normalise markup on the inert clone so extraction and conversion
2
+ // see consistent structure. Runs before extraction.
3
+
4
+ const LAZY_SRC_ATTRS = ["data-src", "data-lazy-src", "data-original", "data-lazy", "data-url"];
5
+ const LANGUAGE_CLASS = [
6
+ /(?:^|\s)(?:language|lang)-([\w+#.-]+)/i,
7
+ /(?:^|\s)highlight-(?:source-)?([\w+#.-]+)/i,
8
+ /(?:^|\s)mw-highlight-lang-([\w+#.-]+)/i,
9
+ /(?:^|\s)brush:\s*([\w+#.-]+)/i,
10
+ ];
11
+ // Only normalise names that are clearly the same language; keep site names otherwise.
12
+ const LANGUAGE_ALIASES = { python3: "python", py3: "python" };
13
+ const NON_LANGUAGES = new Set(["default", "none", "text", "plain", "plaintext", "nohighlight", "notranslate"]);
14
+ // Wrappers sites put around a heading next to "edit" or permalink widgets.
15
+ const EDIT_WIDGETS = ".mw-editsection, .headerlink, .anchor, .heading-anchor, a.anchor, [aria-hidden='true'] a";
16
+ // Content without text that a heading wrapper may still hold.
17
+ const MEDIA = "img, picture, video, audio";
18
+
19
+ function resolveUrl(value, base) {
20
+ const raw = String(value || "").trim();
21
+ if (!raw || raw.startsWith("#") || /^(mailto|tel|data):/i.test(raw)) return raw;
22
+ try {
23
+ return base ? new URL(raw, base).href : raw;
24
+ } catch {
25
+ return raw;
26
+ }
27
+ }
28
+
29
+ function languageFrom(el, maxDepth = 3) {
30
+ for (let node = el, depth = 0; node && node.nodeType === 1 && depth <= maxDepth; node = node.parentElement, depth += 1) {
31
+ const cls = node.getAttribute?.("class") || "";
32
+ for (const pattern of LANGUAGE_CLASS) {
33
+ const match = cls.match(pattern);
34
+ if (match) {
35
+ const lang = match[1].toLowerCase();
36
+ if (!NON_LANGUAGES.has(lang)) return LANGUAGE_ALIASES[lang] || lang;
37
+ }
38
+ }
39
+ const dataLang = node.getAttribute?.("data-lang") || node.getAttribute?.("data-language");
40
+ if (dataLang && !NON_LANGUAGES.has(dataLang.toLowerCase())) return dataLang.toLowerCase();
41
+ }
42
+ return "";
43
+ }
44
+
45
+ function hasMeaningfulContent(el) {
46
+ if ((el.textContent || "").trim()) return true;
47
+ return Boolean(el.querySelector?.("img[alt]:not([alt='']), svg title"));
48
+ }
49
+
50
+ /**
51
+ * Lazy images: copy a lazy-load attribute into src when src is missing or a
52
+ * data: placeholder. Then drop images that are still inline data.
53
+ */
54
+ function standardizeImages(doc, base, stats) {
55
+ for (const img of Array.from(doc.querySelectorAll("img"))) {
56
+ const src = img.getAttribute("src") || "";
57
+ if (!src || src.startsWith("data:")) {
58
+ const lazy = LAZY_SRC_ATTRS.map((attr) => img.getAttribute(attr)).find((v) => v && !v.startsWith("data:"));
59
+ if (lazy) {
60
+ img.setAttribute("src", lazy);
61
+ stats.lazyImagesResolved += 1;
62
+ }
63
+ }
64
+ const current = img.getAttribute("src") || "";
65
+ if (!current || current.startsWith("data:")) {
66
+ img.remove();
67
+ stats.inlineImagesDropped += 1;
68
+ continue;
69
+ }
70
+ img.setAttribute("src", resolveUrl(current, base));
71
+ }
72
+ }
73
+
74
+ function standardizeLinks(doc, base, stats) {
75
+ for (const a of Array.from(doc.querySelectorAll("a"))) {
76
+ const href = a.getAttribute("href") || "";
77
+ if (/^\s*javascript:/i.test(href)) {
78
+ a.replaceWith(...Array.from(a.childNodes));
79
+ stats.scriptLinksUnwrapped += 1;
80
+ continue;
81
+ }
82
+ if (!hasMeaningfulContent(a)) {
83
+ a.remove();
84
+ stats.emptyLinksRemoved += 1;
85
+ continue;
86
+ }
87
+ if (href) a.setAttribute("href", resolveUrl(href, base));
88
+ }
89
+ }
90
+
91
+ /** <pre> without <code> (and code without a language) gets a language-tagged <code>. */
92
+ function standardizeCode(doc, stats) {
93
+ for (const pre of Array.from(doc.querySelectorAll("pre"))) {
94
+ const lang = languageFrom(pre);
95
+ let code = Array.from(pre.children || []).length === 1 && pre.firstElementChild?.localName === "code"
96
+ ? pre.firstElementChild
97
+ : null;
98
+ if (!code) {
99
+ code = doc.createElement("code");
100
+ code.textContent = pre.textContent || "";
101
+ while (pre.firstChild) pre.removeChild(pre.firstChild);
102
+ pre.appendChild(code);
103
+ stats.preWrapped += 1;
104
+ }
105
+ if (lang && !/(?:^|\s)language-/.test(code.getAttribute("class") || "")) {
106
+ code.setAttribute("class", `${code.getAttribute("class") || ""} language-${lang}`.trim());
107
+ }
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Headings wrapped with edit/permalink widgets (Wikipedia's div.mw-heading,
113
+ * Sphinx headerlinks): drop the widgets, then replace a wrapper whose only
114
+ * remaining element is the heading with the heading itself.
115
+ */
116
+ function standardizeHeadings(doc, stats) {
117
+ for (const widget of Array.from(doc.querySelectorAll(EDIT_WIDGETS))) {
118
+ if (!(widget.textContent || "").trim() || widget.matches?.(".mw-editsection")) {
119
+ widget.remove();
120
+ stats.headingWidgetsRemoved += 1;
121
+ }
122
+ }
123
+ for (const heading of Array.from(doc.querySelectorAll("h1, h2, h3, h4, h5, h6"))) {
124
+ const parent = heading.parentElement;
125
+ if (!parent || parent.localName !== "div") continue;
126
+ const others = Array.from(parent.children).filter((el) => el !== heading);
127
+ if (others.every((el) => !(el.textContent || "").trim() && !el.matches?.(MEDIA) && !el.querySelector?.(MEDIA))) {
128
+ parent.replaceWith(heading);
129
+ stats.headingsUnwrapped += 1;
130
+ }
131
+ }
132
+ }
133
+
134
+ /**
135
+ * @param {Document} doc the inert clone (mutated in place)
136
+ * @param {{ url?: string }} [options]
137
+ */
138
+ export function standardizeDocument(doc, options = {}) {
139
+ const base = options.url || "";
140
+ const stats = {
141
+ lazyImagesResolved: 0,
142
+ inlineImagesDropped: 0,
143
+ scriptLinksUnwrapped: 0,
144
+ emptyLinksRemoved: 0,
145
+ preWrapped: 0,
146
+ headingWidgetsRemoved: 0,
147
+ headingsUnwrapped: 0,
148
+ };
149
+ standardizeHeadings(doc, stats);
150
+ standardizeCode(doc, stats);
151
+ standardizeImages(doc, base, stats);
152
+ standardizeLinks(doc, base, stats);
153
+ return stats;
154
+ }