@v1nvn/readability 0.45.0 → 0.45.1
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/README.md +3 -3
- package/package.json +4 -4
- package/src/cli.js +99 -0
- package/src/config.js +29 -0
- package/src/dev.js +55 -0
- package/src/errors.js +16 -0
- package/src/handler.js +4 -0
- package/src/host-sampling.js +57 -0
- package/src/index.js +23 -0
- package/src/index.ts +0 -0
- package/src/logger.js +2 -0
- package/src/output/format.js +92 -0
- package/src/pipeline/cell-text.js +51 -0
- package/src/pipeline/context.js +1 -0
- package/src/pipeline/dom.js +16 -0
- package/src/pipeline/footnotes.js +148 -0
- package/src/pipeline/math.js +113 -0
- package/src/pipeline/normalize.js +507 -0
- package/src/pipeline/readability.js +10 -0
- package/src/pipeline/resolver.js +113 -0
- package/src/pipeline/sanitize.js +26 -0
- package/src/pipeline/tables.js +239 -0
- package/src/pipeline/turndown.js +153 -0
- package/src/pipeline/urls.js +14 -0
- package/src/policy/chunk.js +365 -0
- package/src/policy/diagnostics.js +53 -0
- package/src/policy/explain.js +104 -0
- package/src/policy/fallback.js +68 -0
- package/src/policy/gating.js +90 -0
- package/src/policy/grid-detector.js +207 -0
- package/src/policy/identifiers.js +62 -0
- package/src/policy/images.js +49 -0
- package/src/policy/list-detector.js +232 -0
- package/src/policy/lost-signal.js +44 -0
- package/src/policy/markdown.js +58 -0
- package/src/policy/metadata.js +216 -0
- package/src/policy/outline-chains.js +165 -0
- package/src/policy/outline.js +71 -0
- package/src/policy/pagination.js +79 -0
- package/src/policy/presets.js +100 -0
- package/src/policy/section.js +111 -0
- package/src/policy/selector-lint.js +143 -0
- package/src/policy/sibling-scan.js +92 -0
- package/src/policy/text.js +18 -0
- package/src/policy/truncate.js +45 -0
- package/src/preset-cache.js +165 -0
- package/src/resources.js +227 -0
- package/src/sampling.js +45 -0
- package/src/server.js +73 -0
- package/src/tools/chunk_text.js +40 -0
- package/src/tools/explain.js +200 -0
- package/src/tools/extract.js +263 -0
- package/src/tools/extract_grid.js +56 -0
- package/src/tools/extract_links.js +98 -0
- package/src/tools/extract_list.js +66 -0
- package/src/tools/extract_metadata.js +67 -0
- package/src/tools/extract_section.js +56 -0
- package/src/tools/extract_tables.js +59 -0
- package/src/tools/html-source.js +30 -0
- package/src/tools/html_to_markdown.js +113 -0
- package/src/tools/outline.js +47 -0
- package/src/tools/output-schema.js +504 -0
- package/src/tools/schemas.js +288 -0
- package/src/tools/suggest-preset.js +504 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Math renderers leave the original LaTeX in a small, engine-agnostic pocket:
|
|
2
|
+
// the `<annotation encoding="application/x-tex">` child of a `<math>` element.
|
|
3
|
+
// KaTeX wraps it inside `.katex`, arXiv/LaTeXML (ar5iv) and MDN ship it in a
|
|
4
|
+
// bare `<math display="…">`, and MathJax v2/v3 do not use `<annotation>` at
|
|
5
|
+
// all — their source lives in `<script type="math/tex">` that the
|
|
6
|
+
// script-removal pass strips. Move each into one controlled marker span BEFORE
|
|
7
|
+
// normalize's `<script>` removal so every engine shares a single turndown rule
|
|
8
|
+
// that emits the LaTeX verbatim.
|
|
9
|
+
const MARKER_CLASS = 'rdrm-math';
|
|
10
|
+
const DISPLAY_ATTR = 'data-display';
|
|
11
|
+
const KATEX_CLASS = 'katex';
|
|
12
|
+
const KATEX_DISPLAY_CLASS = 'katex-display';
|
|
13
|
+
const TEX_ANNOTATION_SELECTOR = 'annotation[encoding="application/x-tex"]';
|
|
14
|
+
const MATHJAX_INLINE_TYPE = 'math/tex';
|
|
15
|
+
const MATHJAX_DISPLAY_TYPE = 'math/tex; mode=display';
|
|
16
|
+
// arXiv/LaTeXML (`ltx_*`) and common docs-site display-math wrappers. A bare
|
|
17
|
+
// `<math display="block">` also flags display, but pages often omit the
|
|
18
|
+
// attribute and signal block context only through the wrapper class.
|
|
19
|
+
const MATHML_DISPLAY_SELECTOR = '.ltx_equation, .ltx_displaymath, .equation-display, .math-display';
|
|
20
|
+
// Placeholder for a math container whose annotation and alttext are both empty
|
|
21
|
+
// or absent; rendered markup has no recoverable LaTeX, so emit a token rather
|
|
22
|
+
// than crash.
|
|
23
|
+
const BROKEN_PLACEHOLDER = '[?]';
|
|
24
|
+
function createMarker(document, tex, display) {
|
|
25
|
+
const marker = document.createElement('span');
|
|
26
|
+
marker.className = MARKER_CLASS;
|
|
27
|
+
marker.setAttribute(DISPLAY_ATTR, display ? 'true' : 'false');
|
|
28
|
+
marker.textContent = tex;
|
|
29
|
+
return marker;
|
|
30
|
+
}
|
|
31
|
+
// Drives off the annotation, not the wrapper, so one pass recovers LaTeX from
|
|
32
|
+
// every engine that emits `<annotation encoding="application/x-tex">` — KaTeX
|
|
33
|
+
// inside `.katex`, arXiv/MDN inside a bare `<math>`. The container replaced is
|
|
34
|
+
// engine-specific: the `.katex` span (which also drops the `.katex-html`
|
|
35
|
+
// rendered sibling), or the bare `<math>` element. Replacing a container
|
|
36
|
+
// detaches other annotations that lived inside it, so disconnected nodes
|
|
37
|
+
// reached later in document order are skipped. arXiv carries the LaTeX in
|
|
38
|
+
// `<math alttext="…">` as well, which is the fallback when the annotation text
|
|
39
|
+
// is empty.
|
|
40
|
+
function convertAnnotations(document) {
|
|
41
|
+
const annotations = document.querySelectorAll(TEX_ANNOTATION_SELECTOR);
|
|
42
|
+
for (const annotation of Array.from(annotations)) {
|
|
43
|
+
if (!annotation.isConnected) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const katex = annotation.closest(`.${KATEX_CLASS}`);
|
|
48
|
+
const math = annotation.closest('math');
|
|
49
|
+
let container = annotation;
|
|
50
|
+
let display = false;
|
|
51
|
+
if (katex) {
|
|
52
|
+
container = katex;
|
|
53
|
+
display = katex.closest(`.${KATEX_DISPLAY_CLASS}`) !== null;
|
|
54
|
+
}
|
|
55
|
+
else if (math) {
|
|
56
|
+
container = math;
|
|
57
|
+
display =
|
|
58
|
+
math.getAttribute('display') === 'block' ||
|
|
59
|
+
math.closest(MATHML_DISPLAY_SELECTOR) !== null;
|
|
60
|
+
}
|
|
61
|
+
const tex = annotation.textContent.trim() ||
|
|
62
|
+
(math?.getAttribute('alttext') ?? '').trim();
|
|
63
|
+
container.replaceWith(createMarker(document, tex || BROKEN_PLACEHOLDER, display));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
annotation.replaceWith(createMarker(document, BROKEN_PLACEHOLDER, false));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// A `.katex` that survived the annotation pass had no recoverable annotation
|
|
71
|
+
// (stripped or never present). Its `.katex-html` rendered tree carries no
|
|
72
|
+
// recoverable LaTeX, so swap the whole span for a placeholder marker rather
|
|
73
|
+
// than let rendered spans leak into turndown.
|
|
74
|
+
function convertOrphanedKatex(document) {
|
|
75
|
+
for (const katex of Array.from(document.getElementsByClassName(KATEX_CLASS))) {
|
|
76
|
+
if (!katex.isConnected) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const display = katex.closest(`.${KATEX_DISPLAY_CLASS}`) !== null;
|
|
80
|
+
katex.replaceWith(createMarker(document, BROKEN_PLACEHOLDER, display));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// MathJax v2/v3 inline + display source scripts. These `<script>` nodes are
|
|
84
|
+
// removed by normalize's generic script-removal AND by DOMPurify, so they must
|
|
85
|
+
// be converted here, before that loop runs. MathJax scripts do not use
|
|
86
|
+
// `<annotation>`, so they are handled separately from `convertAnnotations`.
|
|
87
|
+
function convertMathJax(document) {
|
|
88
|
+
const scripts = document.querySelectorAll(`script[type="${MATHJAX_INLINE_TYPE}"], script[type="${MATHJAX_DISPLAY_TYPE}"]`);
|
|
89
|
+
for (const script of Array.from(scripts)) {
|
|
90
|
+
if (!script.isConnected) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
const rawType = script.getAttribute('type') ?? '';
|
|
95
|
+
const display = rawType.includes('mode=display');
|
|
96
|
+
const tex = script.textContent.trim();
|
|
97
|
+
script.replaceWith(createMarker(document, tex || BROKEN_PLACEHOLDER, display));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
script.replaceWith(createMarker(document, BROKEN_PLACEHOLDER, false));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// Walks the pre-normalize document and rewrites every math container —
|
|
105
|
+
// annotation-bearing (KaTeX, bare MathML) or MathJax script — into a
|
|
106
|
+
// `<span class="rdrm-math" data-display="…">LATEX</span>` marker. Idempotent:
|
|
107
|
+
// markers carry no `.katex`/`<math>`/annotation, so a second pass is a no-op.
|
|
108
|
+
// Defensive per node so malformed markup never aborts the pass.
|
|
109
|
+
export function extractMath(document) {
|
|
110
|
+
convertAnnotations(document);
|
|
111
|
+
convertOrphanedKatex(document);
|
|
112
|
+
convertMathJax(document);
|
|
113
|
+
}
|
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
import { extractMath } from "./math.js";
|
|
2
|
+
import { KNOWN_LANGUAGE_TOKENS } from "./resolver.js";
|
|
3
|
+
const NONCE_ATTR = 'nonce';
|
|
4
|
+
export function normalizeDocument(document, options) {
|
|
5
|
+
// MathJax source lives in `<script type="math/tex">`, which the script-removal
|
|
6
|
+
// loop below strips — extract math into markers first so both engines share
|
|
7
|
+
// one turndown rule downstream.
|
|
8
|
+
extractMath(document);
|
|
9
|
+
const baseEls = document.querySelectorAll('base');
|
|
10
|
+
// Preserve <script type="application/ld+json">: structured metadata consumed by the cascade.
|
|
11
|
+
const scriptEls = document.querySelectorAll('script:not([type="application/ld+json"])');
|
|
12
|
+
baseEls.forEach(el => {
|
|
13
|
+
el.remove();
|
|
14
|
+
});
|
|
15
|
+
scriptEls.forEach(el => {
|
|
16
|
+
el.remove();
|
|
17
|
+
});
|
|
18
|
+
const withNonce = document.querySelectorAll(`[${NONCE_ATTR}]`);
|
|
19
|
+
withNonce.forEach(el => {
|
|
20
|
+
el.removeAttribute(NONCE_ATTR);
|
|
21
|
+
});
|
|
22
|
+
const chromeRemoved = options?.cleanChrome === false ? 0 : stripChrome(document);
|
|
23
|
+
const boilerplateRemoved = stripBoilerplate(document);
|
|
24
|
+
return {
|
|
25
|
+
boilerplateRemoved,
|
|
26
|
+
chromeRemoved,
|
|
27
|
+
iframes: 0,
|
|
28
|
+
scripts: scriptEls.length,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
// Conservative, vendor-specific consent SDK selectors. Curated, not greedy —
|
|
32
|
+
// intentionally NOT `[class*="consent"]` (that eats real article widgets).
|
|
33
|
+
const CONSENT_SELECTORS = [
|
|
34
|
+
'[role="dialog"]',
|
|
35
|
+
'[aria-modal="true"]',
|
|
36
|
+
'#onetrust-banner-sdk',
|
|
37
|
+
'#onetrust-consent-sdk',
|
|
38
|
+
'#onetrust-pc-sdk',
|
|
39
|
+
'.cc-window',
|
|
40
|
+
'.cc-banner',
|
|
41
|
+
'.cc-revoke',
|
|
42
|
+
'.osano-cm-window',
|
|
43
|
+
'.osano-cm-dialog',
|
|
44
|
+
'.qc-cmp2-container',
|
|
45
|
+
'.qc-cmp-ui-container',
|
|
46
|
+
'#sp_message_container',
|
|
47
|
+
'[id^="sp_message_container_"]',
|
|
48
|
+
'#didomi-host',
|
|
49
|
+
'.didomi-popup-container',
|
|
50
|
+
'#truste-consent-track',
|
|
51
|
+
'#consent_blackbar',
|
|
52
|
+
'#cookie-banner',
|
|
53
|
+
'.cookie-banner',
|
|
54
|
+
'#consent-banner',
|
|
55
|
+
'.consent-banner',
|
|
56
|
+
'.cookie-bar',
|
|
57
|
+
'.gdpr-banner',
|
|
58
|
+
'.privacy-banner',
|
|
59
|
+
];
|
|
60
|
+
// jsdom has no layout, so "covers the viewport" must be inferred from inline
|
|
61
|
+
// style. Requiring BOTH axes full is what protects a fixed nav bar: a nav is
|
|
62
|
+
// full-width but its height is a fixed px (not 100%/100vh), and inset:0 is not
|
|
63
|
+
// used. Dropping either axis from the conjunction nukes legit chrome.
|
|
64
|
+
function isFullViewportOverlay(style) {
|
|
65
|
+
if (!/position\s*:\s*(?:fixed|sticky)/i.test(style)) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
const zIndexMatch = /z-index\s*:\s*(\d+)/i.exec(style);
|
|
69
|
+
if (!zIndexMatch || Number.parseInt(zIndexMatch[1], 10) < 1000) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
// `inset:0` is shorthand for top/right/bottom/left = 0, so it satisfies BOTH
|
|
73
|
+
// axes at once and is checked up front.
|
|
74
|
+
if (/inset\s*:\s*0/i.test(style)) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
const widthFull = /width\s*:\s*100(?:%|vw)/i.test(style) ||
|
|
78
|
+
(/left\s*:\s*0/i.test(style) && /right\s*:\s*0/i.test(style));
|
|
79
|
+
const heightFull = /height\s*:\s*100(?:%|vh)/i.test(style) ||
|
|
80
|
+
(/top\s*:\s*0/i.test(style) && /bottom\s*:\s*0/i.test(style));
|
|
81
|
+
return widthFull && heightFull;
|
|
82
|
+
}
|
|
83
|
+
// Removes before Readability scores them — these poison density math and leak
|
|
84
|
+
// into the article. Inline-style matches require the full isFullViewportOverlay
|
|
85
|
+
// conjunction; `position:fixed` alone never qualifies.
|
|
86
|
+
export function stripChrome(document) {
|
|
87
|
+
let removed = 0;
|
|
88
|
+
const consentEls = document.querySelectorAll(CONSENT_SELECTORS.join(','));
|
|
89
|
+
for (const el of consentEls) {
|
|
90
|
+
if (!el.isConnected) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
el.remove();
|
|
94
|
+
removed++;
|
|
95
|
+
}
|
|
96
|
+
const styledEls = document.querySelectorAll('[style]');
|
|
97
|
+
for (const el of styledEls) {
|
|
98
|
+
if (!el.isConnected) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (isFullViewportOverlay(el.getAttribute('style') ?? '')) {
|
|
102
|
+
el.remove();
|
|
103
|
+
removed++;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return removed;
|
|
107
|
+
}
|
|
108
|
+
// Unambiguous boilerplate signatures. Bare `related`/`subscribe` are
|
|
109
|
+
// intentionally excluded — they match legit content (related-products section
|
|
110
|
+
// on a store, a "subscribe to RSS" line inside an article) far too often.
|
|
111
|
+
// Substring matching means `newsletter` also covers `newsletter-signup`, etc.;
|
|
112
|
+
// the longer spellings are kept as explicit documentation of intended targets.
|
|
113
|
+
const BOILERPLATE_TOKENS = [
|
|
114
|
+
'newsletter',
|
|
115
|
+
'newsletter-signup',
|
|
116
|
+
'mailing-list',
|
|
117
|
+
'email-signup',
|
|
118
|
+
'subscribe-form',
|
|
119
|
+
'signup-form',
|
|
120
|
+
'related-posts',
|
|
121
|
+
'related-post',
|
|
122
|
+
'read-next',
|
|
123
|
+
'more-from',
|
|
124
|
+
'you-might-also',
|
|
125
|
+
'recommended-posts',
|
|
126
|
+
'recommended',
|
|
127
|
+
];
|
|
128
|
+
function signatureMatches(el) {
|
|
129
|
+
const signature = `${el.getAttribute('class') ?? ''} ${el.getAttribute('id') ?? ''}`.toLowerCase();
|
|
130
|
+
return BOILERPLATE_TOKENS.some(token => signature.includes(token));
|
|
131
|
+
}
|
|
132
|
+
// A boilerplate block is always a structural CONTAINER. A heading or paragraph
|
|
133
|
+
// can carry a token in its `id` (an anchor target like `id="related-posts"`) or
|
|
134
|
+
// `class`, and individually such a leaf passes the footprint guard — so without
|
|
135
|
+
// a container restriction the rule deletes real article content (e.g. an in-body
|
|
136
|
+
// section heading whose id happens to match). Reject non-containers early.
|
|
137
|
+
const BOILERPLATE_CONTAINER_TAGS = new Set([
|
|
138
|
+
'ASIDE',
|
|
139
|
+
'DIV',
|
|
140
|
+
'FORM',
|
|
141
|
+
'NAV',
|
|
142
|
+
'OL',
|
|
143
|
+
'SECTION',
|
|
144
|
+
'UL',
|
|
145
|
+
]);
|
|
146
|
+
// Strips "related posts" / newsletter signup / "read next" blocks Readability
|
|
147
|
+
// sometimes retains. Subtractive and high-risk, so a token hit alone is never
|
|
148
|
+
// enough: the footprint guard requires the candidate to be a small fraction of
|
|
149
|
+
// the body. The body length is snapshotted once so every candidate is judged
|
|
150
|
+
// against the same baseline — recomputing after each removal would make the
|
|
151
|
+
// threshold depend on iteration order. A boilerplate block is always a small
|
|
152
|
+
// slice of a real article; capping at 25% guarantees the worst case is leaving
|
|
153
|
+
// a small block in, never deleting a section that is itself a substantial part
|
|
154
|
+
// of the content. Idempotent and order-independent.
|
|
155
|
+
//
|
|
156
|
+
// Only outermost boilerplate roots are candidates. A single signature CONTAINER
|
|
157
|
+
// (e.g. <aside class="newsletter-subscribe">) puts the token on every descendant
|
|
158
|
+
// ("newsletter-title", "newsletter-submit", …); stripping those leaves
|
|
159
|
+
// independently mangles the container when the footprint guard preserves the
|
|
160
|
+
// root — a newsletter form shorn of its title and submit button. The ancestor
|
|
161
|
+
// walk rejects any element whose class+id already matched on an ancestor.
|
|
162
|
+
// querySelectorAll yields outer-before-inner order, and removing a root detaches
|
|
163
|
+
// its descendants (caught by !isConnected above), so this walk uniformly rejects
|
|
164
|
+
// leaves of both removed roots and preserved roots.
|
|
165
|
+
export function stripBoilerplate(document) {
|
|
166
|
+
const bodyLength = document.body.textContent.length;
|
|
167
|
+
const limit = 0.25 * bodyLength;
|
|
168
|
+
let removed = 0;
|
|
169
|
+
for (const el of document.querySelectorAll('[class],[id]')) {
|
|
170
|
+
if (!el.isConnected) {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (!BOILERPLATE_CONTAINER_TAGS.has(el.tagName)) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (!signatureMatches(el)) {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
let ancestor = el.parentElement;
|
|
180
|
+
while (ancestor && !signatureMatches(ancestor)) {
|
|
181
|
+
ancestor = ancestor.parentElement;
|
|
182
|
+
}
|
|
183
|
+
if (ancestor) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (el.textContent.length >= limit) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
el.remove();
|
|
190
|
+
removed++;
|
|
191
|
+
}
|
|
192
|
+
return removed;
|
|
193
|
+
}
|
|
194
|
+
// Scope the document by CSS selector: drop every `exclude` match, then (if
|
|
195
|
+
// `include` matches) replace the body with the first matching subtree.
|
|
196
|
+
export function applySelectors(document, selectors) {
|
|
197
|
+
if (!selectors) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (selectors.exclude) {
|
|
201
|
+
for (const selector of selectors.exclude) {
|
|
202
|
+
document.querySelectorAll(selector).forEach(el => {
|
|
203
|
+
el.remove();
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (selectors.include) {
|
|
208
|
+
const body = document.body;
|
|
209
|
+
const root = body.querySelector(selectors.include);
|
|
210
|
+
if (root && root !== body) {
|
|
211
|
+
body.innerHTML = '';
|
|
212
|
+
body.appendChild(root);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// Matched anywhere in a src (case-insensitive) to flag a lazy-load placeholder.
|
|
217
|
+
const PLACEHOLDER_TOKENS = [
|
|
218
|
+
'placeholder',
|
|
219
|
+
'blank',
|
|
220
|
+
'spacer',
|
|
221
|
+
'lazy',
|
|
222
|
+
'loading',
|
|
223
|
+
'1x1',
|
|
224
|
+
'transparent',
|
|
225
|
+
'pixel',
|
|
226
|
+
'dummy',
|
|
227
|
+
];
|
|
228
|
+
export function isPlaceholderSrc(src) {
|
|
229
|
+
if (!src) {
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
if (src.startsWith('data:')) {
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
const lowered = src.toLowerCase();
|
|
236
|
+
return PLACEHOLDER_TOKENS.some(token => lowered.includes(token));
|
|
237
|
+
}
|
|
238
|
+
function usableAttr(el, names) {
|
|
239
|
+
for (const name of names) {
|
|
240
|
+
const value = el.getAttribute(name);
|
|
241
|
+
if (value) {
|
|
242
|
+
return value;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
// Largest-descriptor candidate wins (`Nw`/`Nx`, never mixed in valid srcset);
|
|
248
|
+
// first candidate wins when none carry a descriptor. Malformed entries are skipped.
|
|
249
|
+
function pickLargestSrcset(srcset) {
|
|
250
|
+
let bestUrl;
|
|
251
|
+
let bestValue = -1;
|
|
252
|
+
let firstUrl;
|
|
253
|
+
let sawDescriptor = false;
|
|
254
|
+
for (const raw of srcset.split(',')) {
|
|
255
|
+
const entry = raw.trim();
|
|
256
|
+
if (!entry) {
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const splitAt = entry.search(/\s/);
|
|
260
|
+
const rawUrl = splitAt === -1 ? entry : entry.slice(0, splitAt);
|
|
261
|
+
const descriptor = splitAt === -1 ? '' : entry.slice(splitAt).trim();
|
|
262
|
+
const url = rawUrl.length >= 2 &&
|
|
263
|
+
((rawUrl.startsWith('"') && rawUrl.endsWith('"')) ||
|
|
264
|
+
(rawUrl.startsWith("'") && rawUrl.endsWith("'")))
|
|
265
|
+
? rawUrl.slice(1, -1)
|
|
266
|
+
: rawUrl;
|
|
267
|
+
if (!url) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (firstUrl === undefined) {
|
|
271
|
+
firstUrl = url;
|
|
272
|
+
}
|
|
273
|
+
if (!descriptor) {
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const value = /^(\d+(?:\.\d+)?)[wx]$/.exec(descriptor);
|
|
277
|
+
if (!value) {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
sawDescriptor = true;
|
|
281
|
+
const numeric = Number.parseFloat(value[1]);
|
|
282
|
+
if (numeric > bestValue) {
|
|
283
|
+
bestValue = numeric;
|
|
284
|
+
bestUrl = url;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return sawDescriptor ? bestUrl : firstUrl;
|
|
288
|
+
}
|
|
289
|
+
// Skip media-constrained <source>: art-direction crops for specific viewports,
|
|
290
|
+
// not the default image the page shows.
|
|
291
|
+
function resolveFromPicture(img) {
|
|
292
|
+
const picture = img.closest('picture');
|
|
293
|
+
if (!picture) {
|
|
294
|
+
return undefined;
|
|
295
|
+
}
|
|
296
|
+
for (const source of picture.querySelectorAll('source')) {
|
|
297
|
+
if (source.hasAttribute('media')) {
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
const srcset = source.getAttribute('srcset');
|
|
301
|
+
if (srcset) {
|
|
302
|
+
const url = pickLargestSrcset(srcset);
|
|
303
|
+
if (url) {
|
|
304
|
+
return url;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
// Precedence (first usable wins): data-src → <picture><source srcset> → own
|
|
311
|
+
// srcset (largest) → data-original → data-lazy-src.
|
|
312
|
+
function resolveRealSource(img) {
|
|
313
|
+
const dataSrc = usableAttr(img, ['data-src']);
|
|
314
|
+
if (dataSrc) {
|
|
315
|
+
return dataSrc;
|
|
316
|
+
}
|
|
317
|
+
const fromPicture = resolveFromPicture(img);
|
|
318
|
+
if (fromPicture) {
|
|
319
|
+
return fromPicture;
|
|
320
|
+
}
|
|
321
|
+
const ownSrcset = img.getAttribute('srcset');
|
|
322
|
+
if (ownSrcset) {
|
|
323
|
+
const url = pickLargestSrcset(ownSrcset);
|
|
324
|
+
if (url) {
|
|
325
|
+
return url;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return usableAttr(img, ['data-original', 'data-lazy-src']);
|
|
329
|
+
}
|
|
330
|
+
// Swap placeholder src values for the real source before Readability clones.
|
|
331
|
+
// Only a placeholder-like current src is rewritten, so a real src is left alone
|
|
332
|
+
// even if a data-src also exists. Idempotent.
|
|
333
|
+
export function resolveLazyImages(document) {
|
|
334
|
+
let resolved = 0;
|
|
335
|
+
for (const img of document.querySelectorAll('img')) {
|
|
336
|
+
const currentSrc = img.getAttribute('src') ?? '';
|
|
337
|
+
if (!isPlaceholderSrc(currentSrc)) {
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const real = resolveRealSource(img);
|
|
341
|
+
if (real && real !== currentSrc) {
|
|
342
|
+
img.setAttribute('src', real);
|
|
343
|
+
resolved++;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return resolved;
|
|
347
|
+
}
|
|
348
|
+
// Readability keeps a class only by literal `classesToPreserve.includes(cls)`
|
|
349
|
+
// equality, so non-canonical code-block conventions — GitHub's
|
|
350
|
+
// `highlight-source-js`, sandpack's `sp-javascript`, SyntaxHighlighter's
|
|
351
|
+
// `brush: js` — are stripped before turndown runs and the fence loses its
|
|
352
|
+
// language tag. Rewrite each convention to the canonical
|
|
353
|
+
// `<code class="language-X">` form before Readability clones the document.
|
|
354
|
+
const HIGHLIGHT_SOURCE_PREFIX = 'highlight-source-';
|
|
355
|
+
const LANGUAGE_PREFIX = 'language-';
|
|
356
|
+
const LANG_PREFIX = 'lang-';
|
|
357
|
+
const SP_PREFIX = 'sp-';
|
|
358
|
+
const BRUSH_RE = /brush:\s*([A-Za-z][\w-]*)/;
|
|
359
|
+
const BOGUS_TOKENS = new Set(['', 'highlight', 'source', 'sp']);
|
|
360
|
+
function isValidToken(token) {
|
|
361
|
+
return !BOGUS_TOKENS.has(token);
|
|
362
|
+
}
|
|
363
|
+
function hasLanguageClass(el) {
|
|
364
|
+
for (const cls of el.classList) {
|
|
365
|
+
if (cls !== LANGUAGE_PREFIX && cls.startsWith(LANGUAGE_PREFIX)) {
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
// Pool the <pre>'s own classes, then ancestors up to the highlight wrapper
|
|
372
|
+
// (capped at two levels), then any descendant <code>'s classes — in that order.
|
|
373
|
+
function collectClassSources(pre, code) {
|
|
374
|
+
const sources = [
|
|
375
|
+
{ classes: [...pre.classList], raw: pre.getAttribute('class') ?? '' },
|
|
376
|
+
];
|
|
377
|
+
let depth = 0;
|
|
378
|
+
let el = pre.parentElement;
|
|
379
|
+
while (el && depth < 2) {
|
|
380
|
+
sources.push({
|
|
381
|
+
classes: [...el.classList],
|
|
382
|
+
raw: el.getAttribute('class') ?? '',
|
|
383
|
+
});
|
|
384
|
+
depth++;
|
|
385
|
+
if (el.tagName === 'DIV' &&
|
|
386
|
+
[...el.classList].some(c => c.startsWith('highlight'))) {
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
el = el.parentElement;
|
|
390
|
+
}
|
|
391
|
+
if (code) {
|
|
392
|
+
sources.push({
|
|
393
|
+
classes: [...code.classList],
|
|
394
|
+
raw: code.getAttribute('class') ?? '',
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
return sources;
|
|
398
|
+
}
|
|
399
|
+
function resolveCodeToken(pre, code) {
|
|
400
|
+
const sources = collectClassSources(pre, code);
|
|
401
|
+
function findByPrefix(prefix) {
|
|
402
|
+
for (const src of sources) {
|
|
403
|
+
for (const cls of src.classes) {
|
|
404
|
+
if (cls.startsWith(prefix)) {
|
|
405
|
+
const token = cls.slice(prefix.length).toLowerCase();
|
|
406
|
+
if (isValidToken(token)) {
|
|
407
|
+
return token;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
// Convention priority: highlight-source > language > lang > sp > brush.
|
|
415
|
+
// `brush:` is a class-attribute value, not a single class token, so it cannot
|
|
416
|
+
// be matched by prefix and is parsed from the raw attribute string instead.
|
|
417
|
+
const highlightToken = findByPrefix(HIGHLIGHT_SOURCE_PREFIX);
|
|
418
|
+
if (highlightToken) {
|
|
419
|
+
return { token: highlightToken, fromHighlightSource: true };
|
|
420
|
+
}
|
|
421
|
+
const languageToken = findByPrefix(LANGUAGE_PREFIX);
|
|
422
|
+
if (languageToken) {
|
|
423
|
+
return { token: languageToken, fromHighlightSource: false };
|
|
424
|
+
}
|
|
425
|
+
const langToken = findByPrefix(LANG_PREFIX);
|
|
426
|
+
if (langToken) {
|
|
427
|
+
return { token: langToken, fromHighlightSource: false };
|
|
428
|
+
}
|
|
429
|
+
// Sandpack emits infra classes alongside the language class (sp-cm,
|
|
430
|
+
// sp-pristine, sp-pre-placeholder); unlike the single-token highlight-source
|
|
431
|
+
// / language / lang conventions, SP must pick the class whose token is a
|
|
432
|
+
// recognized language or it grabs an infra string the preserve list strips.
|
|
433
|
+
for (const src of sources) {
|
|
434
|
+
for (const cls of src.classes) {
|
|
435
|
+
if (!cls.startsWith(SP_PREFIX)) {
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
const token = cls.slice(SP_PREFIX.length).toLowerCase();
|
|
439
|
+
if (KNOWN_LANGUAGE_TOKENS.has(token)) {
|
|
440
|
+
return { token, fromHighlightSource: false };
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
for (const src of sources) {
|
|
445
|
+
const match = BRUSH_RE.exec(src.raw);
|
|
446
|
+
if (match) {
|
|
447
|
+
const token = match[1].toLowerCase();
|
|
448
|
+
if (isValidToken(token)) {
|
|
449
|
+
return { token, fromHighlightSource: false };
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
function canonicalizePre(pre) {
|
|
456
|
+
const code = pre.querySelector('code');
|
|
457
|
+
// turndown reads the language from the <code> class; if one is present the
|
|
458
|
+
// block is already canonical and rewriting it can only drop information.
|
|
459
|
+
if (code && hasLanguageClass(code)) {
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
const match = resolveCodeToken(pre, code);
|
|
463
|
+
if (!match) {
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
let target = code;
|
|
467
|
+
if (!target) {
|
|
468
|
+
target = pre.ownerDocument.createElement('code');
|
|
469
|
+
while (pre.firstChild) {
|
|
470
|
+
target.appendChild(pre.firstChild);
|
|
471
|
+
}
|
|
472
|
+
pre.appendChild(target);
|
|
473
|
+
}
|
|
474
|
+
const classes = [`language-${match.token}`];
|
|
475
|
+
if (target.classList.contains('hljs')) {
|
|
476
|
+
classes.push('hljs');
|
|
477
|
+
}
|
|
478
|
+
target.setAttribute('class', classes.join(' '));
|
|
479
|
+
// GitHub carries the language on a <div class="highlight"> wrapper rather than
|
|
480
|
+
// the <pre>; once that hint is moved onto <code>, hoist the <pre> out so the
|
|
481
|
+
// block stands alone instead of inside a div Readability scores separately.
|
|
482
|
+
if (match.fromHighlightSource) {
|
|
483
|
+
const parent = pre.parentElement;
|
|
484
|
+
if (parent?.tagName === 'DIV' &&
|
|
485
|
+
[...parent.classList].some(c => c.startsWith('highlight'))) {
|
|
486
|
+
parent.replaceWith(pre);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
491
|
+
export function canonicalizeCodeBlocks(document) {
|
|
492
|
+
let count = 0;
|
|
493
|
+
for (const pre of document.querySelectorAll('pre')) {
|
|
494
|
+
if (!pre.isConnected) {
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
try {
|
|
498
|
+
if (canonicalizePre(pre)) {
|
|
499
|
+
count++;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
// Defensive: malformed markup must not break the extract pipeline.
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return count;
|
|
507
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { isProbablyReaderable, Readability } from '@mozilla/readability';
|
|
2
|
+
export function isReaderable(document) {
|
|
3
|
+
return isProbablyReaderable(document);
|
|
4
|
+
}
|
|
5
|
+
export function parseArticle(document, options) {
|
|
6
|
+
// Readability mutates its input document; clone so the pipeline's doc is untouched.
|
|
7
|
+
const clone = document.cloneNode(true);
|
|
8
|
+
const reader = new Readability(clone, options);
|
|
9
|
+
return reader.parse();
|
|
10
|
+
}
|