@v1nvn/readability 0.43.0 → 0.45.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.
Files changed (71) hide show
  1. package/README.md +3 -3
  2. package/package.json +7 -9
  3. package/src/cli.ts +134 -0
  4. package/src/config.ts +42 -0
  5. package/src/dev.ts +77 -0
  6. package/src/errors.ts +19 -0
  7. package/src/handler.ts +8 -0
  8. package/src/host-sampling.ts +86 -0
  9. package/src/index.ts +27 -0
  10. package/src/logger.ts +3 -0
  11. package/src/output/format.ts +132 -0
  12. package/src/pipeline/cell-text.ts +56 -0
  13. package/src/pipeline/context.ts +88 -0
  14. package/src/pipeline/dom.ts +23 -0
  15. package/src/pipeline/footnotes.ts +181 -0
  16. package/src/pipeline/math.ts +130 -0
  17. package/src/pipeline/normalize.ts +594 -0
  18. package/src/pipeline/readability.ts +36 -0
  19. package/src/pipeline/resolver.ts +143 -0
  20. package/src/pipeline/sanitize.ts +42 -0
  21. package/src/pipeline/tables.ts +286 -0
  22. package/src/pipeline/turndown.ts +191 -0
  23. package/src/pipeline/urls.ts +13 -0
  24. package/src/policy/chunk.ts +482 -0
  25. package/src/policy/diagnostics.ts +96 -0
  26. package/src/policy/explain.ts +182 -0
  27. package/src/policy/fallback.ts +110 -0
  28. package/src/policy/gating.ts +106 -0
  29. package/src/policy/grid-detector.ts +313 -0
  30. package/src/policy/identifiers.ts +75 -0
  31. package/src/policy/images.ts +64 -0
  32. package/src/policy/list-detector.ts +308 -0
  33. package/src/policy/lost-signal.ts +69 -0
  34. package/src/policy/markdown.ts +76 -0
  35. package/src/policy/metadata.ts +312 -0
  36. package/src/policy/outline-chains.ts +215 -0
  37. package/src/policy/outline.ts +89 -0
  38. package/src/policy/pagination.ts +104 -0
  39. package/src/policy/presets.ts +142 -0
  40. package/src/policy/section.ts +127 -0
  41. package/src/policy/selector-lint.ts +186 -0
  42. package/src/policy/sibling-scan.ts +99 -0
  43. package/src/policy/text.ts +36 -0
  44. package/src/policy/truncate.ts +59 -0
  45. package/src/preset-cache.ts +221 -0
  46. package/src/resources.ts +317 -0
  47. package/src/sampling.ts +74 -0
  48. package/src/server.ts +85 -0
  49. package/src/tools/chunk_text.ts +65 -0
  50. package/src/tools/explain.ts +299 -0
  51. package/src/tools/extract.ts +368 -0
  52. package/src/tools/extract_grid.ts +90 -0
  53. package/src/tools/extract_links.ts +138 -0
  54. package/src/tools/extract_list.ts +97 -0
  55. package/src/tools/extract_metadata.ts +102 -0
  56. package/src/tools/extract_section.ts +84 -0
  57. package/src/tools/extract_tables.ts +101 -0
  58. package/src/tools/html-source.ts +40 -0
  59. package/src/tools/html_to_markdown.ts +180 -0
  60. package/src/tools/outline.ts +77 -0
  61. package/src/tools/output-schema.ts +715 -0
  62. package/src/tools/schemas.ts +442 -0
  63. package/src/tools/suggest-preset.ts +708 -0
  64. package/src/types/jsdom.d.ts +15 -0
  65. package/src/types/turndown-plugin-gfm.d.ts +11 -0
  66. package/dist/assets/cli-BE_Ml3rh.js +0 -76
  67. package/dist/assets/cli-BE_Ml3rh.js.map +0 -1
  68. package/dist/assets/extract-Cyv5qfGN.js +0 -2880
  69. package/dist/assets/extract-Cyv5qfGN.js.map +0 -1
  70. package/dist/index.js +0 -2267
  71. package/dist/index.js.map +0 -1
@@ -0,0 +1,88 @@
1
+ import type { ReadabilityParseResult } from './readability.ts';
2
+
3
+ interface GatingSignal {
4
+ readonly likely: boolean;
5
+ readonly reason: string;
6
+ }
7
+
8
+ interface PaginationSignal {
9
+ readonly nextUrl?: string;
10
+ readonly selector?: string;
11
+ readonly type: 'infinite' | 'paginated';
12
+ }
13
+
14
+ interface PresetSignal {
15
+ readonly applied: boolean;
16
+ readonly reason?: 'detectors-missed' | 'overridden';
17
+ readonly site: string;
18
+ }
19
+
20
+ export interface StructuredData {
21
+ readonly '@type'?: string;
22
+ readonly [key: string]: unknown;
23
+ }
24
+
25
+ export interface Metadata {
26
+ readonly baseUrl?: string;
27
+ readonly byline?: string;
28
+ readonly canonical?: string;
29
+ readonly estimator?: string;
30
+ readonly excerpt?: string;
31
+ readonly lang?: string;
32
+ readonly publishedTime?: string;
33
+ readonly readingTimeMin?: number;
34
+ readonly siteName?: string;
35
+ readonly structured?: StructuredData;
36
+ readonly title?: string;
37
+ readonly tokenEstimate?: number;
38
+ readonly wordCount?: number;
39
+ }
40
+
41
+ export interface SanitizationDiagnostics {
42
+ readonly iframes: number;
43
+ readonly scripts: number;
44
+ }
45
+
46
+ export interface TraceStage {
47
+ readonly ms: number;
48
+ readonly stage: string;
49
+ }
50
+
51
+ export interface CacheSignal {
52
+ readonly hit: boolean;
53
+ readonly normalizedHash: string;
54
+ readonly originalHash: string;
55
+ }
56
+
57
+ export interface Diagnostics {
58
+ readonly boilerplateRemoved?: number;
59
+ readonly cache?: CacheSignal;
60
+ readonly chromeRemoved?: number;
61
+ readonly extractedNode?: string;
62
+ readonly fallbackUsed: boolean;
63
+ readonly gated?: GatingSignal;
64
+ readonly imagesResolved?: number;
65
+ readonly pagination?: PaginationSignal;
66
+ readonly preset?: PresetSignal;
67
+ readonly readerable?: boolean;
68
+ readonly removedNodes?: number;
69
+ readonly sanitization?: SanitizationDiagnostics;
70
+ readonly trace?: readonly TraceStage[];
71
+ readonly truncated: boolean;
72
+ }
73
+
74
+ export interface ExtractionContext {
75
+ readonly article?: null | Readonly<ReadabilityParseResult>;
76
+ readonly baseUrl?: string;
77
+ readonly diagnostics?: Diagnostics;
78
+ readonly document?: Document;
79
+ readonly documentElementCount?: number;
80
+ readonly html: string;
81
+ readonly markdown?: string;
82
+ readonly metadata?: Metadata;
83
+ readonly readerable?: boolean;
84
+ readonly rootSelector?: string;
85
+ readonly sanitizedHtml?: string;
86
+ // Must be the same window the document came from — DOMPurify is bound to it.
87
+ readonly window?: Window;
88
+ }
@@ -0,0 +1,23 @@
1
+ import { JSDOM } from 'jsdom';
2
+
3
+ export interface BuiltDocument {
4
+ readonly document: Document;
5
+ readonly window: Window;
6
+ }
7
+
8
+ // Built only from caller-supplied `html`; `baseUrl` is origin context for
9
+ // absolutization, never fetched. Parse and the downstream Readability walk run
10
+ // synchronously on the event loop — no worker isolation or wall-clock timeout
11
+ // guards them. A worker pool was deferred rather than rejected: the synchronous
12
+ // parse can't be interrupted in-process, and `worker_threads` can't load the dev
13
+ // hot-reload loop's in-memory Vite modules. `maxNodes` is the only input-size
14
+ // bound until a holistic worker strategy lands; a standalone `timeout` option
15
+ // would mislead (it can't interrupt the parse), so it ships with the worker.
16
+ export function buildDocument(html: string, baseUrl?: string): BuiltDocument {
17
+ const dom = new JSDOM(html, { url: baseUrl });
18
+ return { document: dom.window.document, window: dom.window };
19
+ }
20
+
21
+ export function isElement(node: Node): node is Element {
22
+ return node.nodeType === 1;
23
+ }
@@ -0,0 +1,181 @@
1
+ import { buildDocument } from './dom.ts';
2
+
3
+ export interface FootnoteResult {
4
+ readonly footnoteDefs: readonly string[];
5
+ readonly html: string;
6
+ }
7
+
8
+ // jsdom-parsing every turndown call would dominate hot paths; this pre-check
9
+ // gates on a string scan so footnote-less articles pay nothing.
10
+ const FOOTNOTE_SIGNAL_RE =
11
+ /cite_note|cite_ref|class="footnotes"|class="references"|data-footnote|role="doc-endnote|<sup\b[^>]*>\s*<a\s[^>]*href="#/i;
12
+
13
+ // First match wins per group; an `<ol class="footnotes">` is the common case.
14
+ const DEFINITION_CONTAINER_SELECTORS = [
15
+ 'ol.footnotes',
16
+ 'ol[class*="footnotes"]',
17
+ 'ol.references',
18
+ 'ol[class*="references"]',
19
+ 'section[class*="footnote"]',
20
+ 'div[class*="footnote"]',
21
+ '[role="doc-endnotes"]',
22
+ '[role="doc-bibliography"]',
23
+ ] as const;
24
+
25
+ const STANDALONE_DEF_ID_RE = /^(?:fn|cite_note|footnote|note)[:_-]/i;
26
+
27
+ interface RefHit {
28
+ readonly defId: string;
29
+ readonly n: number;
30
+ readonly sup: Element;
31
+ }
32
+
33
+ // Some sites prepend a back-to-text caret or "Jump to" label to each definition.
34
+ // Stripping only a leading occurrence keeps accidental mid-text carets intact.
35
+ const BACKREF_LEADING_RE = /^(?:↑\s?|↩\s?|\^\s|Jump to\s*)/;
36
+
37
+ function cleanDefText(text: string): string {
38
+ return text.replace(/\s+/g, ' ').trim().replace(BACKREF_LEADING_RE, '');
39
+ }
40
+
41
+ function collectDefinitions(document: Document): {
42
+ readonly containers: ReadonlySet<Element>;
43
+ readonly defs: ReadonlyMap<string, string>;
44
+ readonly standaloneIds: ReadonlySet<string>;
45
+ } {
46
+ const containers = new Set<Element>();
47
+ const defs = new Map<string, string>();
48
+ const standaloneIds = new Set<string>();
49
+
50
+ for (const selector of DEFINITION_CONTAINER_SELECTORS) {
51
+ let matched: NodeListOf<Element>;
52
+ try {
53
+ matched = document.querySelectorAll(selector);
54
+ } catch {
55
+ continue;
56
+ }
57
+ for (const container of Array.from(matched)) {
58
+ if (!containers.has(container)) {
59
+ containers.add(container);
60
+ }
61
+ for (const item of Array.from(
62
+ container.querySelectorAll('li, [role="doc-endnote"]'),
63
+ )) {
64
+ const id = item.id;
65
+ if (!id || defs.has(id)) {
66
+ continue;
67
+ }
68
+ const text = cleanDefText(item.textContent);
69
+ if (text) {
70
+ defs.set(id, text);
71
+ }
72
+ }
73
+ }
74
+ }
75
+
76
+ // Some sites scatter `<li id="fn1">` outside any container; collect those too.
77
+ for (const li of Array.from(document.querySelectorAll('li[id]'))) {
78
+ const id = li.id;
79
+ if (!id || defs.has(id) || !STANDALONE_DEF_ID_RE.test(id)) {
80
+ continue;
81
+ }
82
+ const text = cleanDefText(li.textContent);
83
+ if (text) {
84
+ defs.set(id, text);
85
+ standaloneIds.add(id);
86
+ }
87
+ }
88
+
89
+ return { containers, defs, standaloneIds };
90
+ }
91
+
92
+ export function processFootnotes(html: string): FootnoteResult | null {
93
+ if (!html || !FOOTNOTE_SIGNAL_RE.test(html)) {
94
+ return null;
95
+ }
96
+
97
+ let document: Document;
98
+ try {
99
+ document = buildDocument(html).document;
100
+ } catch {
101
+ return null;
102
+ }
103
+
104
+ const { containers, defs, standaloneIds } = collectDefinitions(document);
105
+ if (defs.size === 0) {
106
+ return null;
107
+ }
108
+
109
+ const refHits: RefHit[] = [];
110
+ const defIdToNumber = new Map<string, number>();
111
+ for (const sup of Array.from(document.querySelectorAll('sup'))) {
112
+ if (!sup.isConnected) {
113
+ continue;
114
+ }
115
+ try {
116
+ const anchor = sup.querySelector('a[href^="#"]');
117
+ if (!anchor) {
118
+ continue;
119
+ }
120
+ const frag = (anchor.getAttribute('href') ?? '').slice(1);
121
+ if (!frag || !defs.has(frag)) {
122
+ continue;
123
+ }
124
+ let n = defIdToNumber.get(frag);
125
+ if (n === undefined) {
126
+ n = defIdToNumber.size + 1;
127
+ defIdToNumber.set(frag, n);
128
+ }
129
+ refHits.push({ defId: frag, n, sup });
130
+ } catch {
131
+ // Defensive: malformed <sup> must not abort the whole pass.
132
+ }
133
+ }
134
+
135
+ if (refHits.length === 0) {
136
+ return null;
137
+ }
138
+
139
+ for (const { n, sup } of refHits) {
140
+ sup.replaceWith(document.createTextNode(`[^${n}]`));
141
+ }
142
+
143
+ // Drop the rendered references list so it doesn't duplicate the appended
144
+ // `[^N]:` block. Removing containers also takes care of the standalone <li>
145
+ // elements that lived inside them; only out-of-container standalones need
146
+ // individual removal here.
147
+ for (const container of containers) {
148
+ if (container.isConnected) {
149
+ container.remove();
150
+ }
151
+ }
152
+ for (const id of standaloneIds) {
153
+ const el = document.getElementById(id);
154
+ if (el?.isConnected) {
155
+ el.remove();
156
+ }
157
+ }
158
+
159
+ const numberToDefId = new Map<number, string>();
160
+ for (const [id, n] of defIdToNumber) {
161
+ numberToDefId.set(n, id);
162
+ }
163
+ const footnoteDefs: string[] = [];
164
+ for (let n = 1; n <= numberToDefId.size; n++) {
165
+ const id = numberToDefId.get(n);
166
+ if (id === undefined) {
167
+ break;
168
+ }
169
+ const text = defs.get(id);
170
+ if (text !== undefined) {
171
+ footnoteDefs.push(text);
172
+ }
173
+ }
174
+
175
+ // buildDocument wraps both full documents and bare fragments in a <body>;
176
+ // serializing its innerHTML returns the equivalent of the input region.
177
+ return {
178
+ footnoteDefs,
179
+ html: document.body.innerHTML,
180
+ };
181
+ }
@@ -0,0 +1,130 @@
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
+
10
+ const MARKER_CLASS = 'rdrm-math';
11
+ const DISPLAY_ATTR = 'data-display';
12
+ const KATEX_CLASS = 'katex';
13
+ const KATEX_DISPLAY_CLASS = 'katex-display';
14
+ const TEX_ANNOTATION_SELECTOR = 'annotation[encoding="application/x-tex"]';
15
+ const MATHJAX_INLINE_TYPE = 'math/tex';
16
+ const MATHJAX_DISPLAY_TYPE = 'math/tex; mode=display';
17
+ // arXiv/LaTeXML (`ltx_*`) and common docs-site display-math wrappers. A bare
18
+ // `<math display="block">` also flags display, but pages often omit the
19
+ // attribute and signal block context only through the wrapper class.
20
+ const MATHML_DISPLAY_SELECTOR =
21
+ '.ltx_equation, .ltx_displaymath, .equation-display, .math-display';
22
+ // Placeholder for a math container whose annotation and alttext are both empty
23
+ // or absent; rendered markup has no recoverable LaTeX, so emit a token rather
24
+ // than crash.
25
+ const BROKEN_PLACEHOLDER = '[?]';
26
+
27
+ function createMarker(
28
+ document: Document,
29
+ tex: string,
30
+ display: boolean,
31
+ ): HTMLElement {
32
+ const marker = document.createElement('span');
33
+ marker.className = MARKER_CLASS;
34
+ marker.setAttribute(DISPLAY_ATTR, display ? 'true' : 'false');
35
+ marker.textContent = tex;
36
+ return marker;
37
+ }
38
+
39
+ // Drives off the annotation, not the wrapper, so one pass recovers LaTeX from
40
+ // every engine that emits `<annotation encoding="application/x-tex">` — KaTeX
41
+ // inside `.katex`, arXiv/MDN inside a bare `<math>`. The container replaced is
42
+ // engine-specific: the `.katex` span (which also drops the `.katex-html`
43
+ // rendered sibling), or the bare `<math>` element. Replacing a container
44
+ // detaches other annotations that lived inside it, so disconnected nodes
45
+ // reached later in document order are skipped. arXiv carries the LaTeX in
46
+ // `<math alttext="…">` as well, which is the fallback when the annotation text
47
+ // is empty.
48
+ function convertAnnotations(document: Document): void {
49
+ const annotations = document.querySelectorAll(TEX_ANNOTATION_SELECTOR);
50
+ for (const annotation of Array.from(annotations)) {
51
+ if (!annotation.isConnected) {
52
+ continue;
53
+ }
54
+ try {
55
+ const katex = annotation.closest(`.${KATEX_CLASS}`);
56
+ const math = annotation.closest('math');
57
+ let container: Element = annotation;
58
+ let display = false;
59
+ if (katex) {
60
+ container = katex;
61
+ display = katex.closest(`.${KATEX_DISPLAY_CLASS}`) !== null;
62
+ } else if (math) {
63
+ container = math;
64
+ display =
65
+ math.getAttribute('display') === 'block' ||
66
+ math.closest(MATHML_DISPLAY_SELECTOR) !== null;
67
+ }
68
+ const tex =
69
+ annotation.textContent.trim() ||
70
+ (math?.getAttribute('alttext') ?? '').trim();
71
+ container.replaceWith(
72
+ createMarker(document, tex || BROKEN_PLACEHOLDER, display),
73
+ );
74
+ } catch {
75
+ annotation.replaceWith(createMarker(document, BROKEN_PLACEHOLDER, false));
76
+ }
77
+ }
78
+ }
79
+
80
+ // A `.katex` that survived the annotation pass had no recoverable annotation
81
+ // (stripped or never present). Its `.katex-html` rendered tree carries no
82
+ // recoverable LaTeX, so swap the whole span for a placeholder marker rather
83
+ // than let rendered spans leak into turndown.
84
+ function convertOrphanedKatex(document: Document): void {
85
+ for (const katex of Array.from(
86
+ document.getElementsByClassName(KATEX_CLASS),
87
+ )) {
88
+ if (!katex.isConnected) {
89
+ continue;
90
+ }
91
+ const display = katex.closest(`.${KATEX_DISPLAY_CLASS}`) !== null;
92
+ katex.replaceWith(createMarker(document, BROKEN_PLACEHOLDER, display));
93
+ }
94
+ }
95
+
96
+ // MathJax v2/v3 inline + display source scripts. These `<script>` nodes are
97
+ // removed by normalize's generic script-removal AND by DOMPurify, so they must
98
+ // be converted here, before that loop runs. MathJax scripts do not use
99
+ // `<annotation>`, so they are handled separately from `convertAnnotations`.
100
+ function convertMathJax(document: Document): void {
101
+ const scripts = document.querySelectorAll(
102
+ `script[type="${MATHJAX_INLINE_TYPE}"], script[type="${MATHJAX_DISPLAY_TYPE}"]`,
103
+ );
104
+ for (const script of Array.from(scripts)) {
105
+ if (!script.isConnected) {
106
+ continue;
107
+ }
108
+ try {
109
+ const rawType = script.getAttribute('type') ?? '';
110
+ const display = rawType.includes('mode=display');
111
+ const tex = script.textContent.trim();
112
+ script.replaceWith(
113
+ createMarker(document, tex || BROKEN_PLACEHOLDER, display),
114
+ );
115
+ } catch {
116
+ script.replaceWith(createMarker(document, BROKEN_PLACEHOLDER, false));
117
+ }
118
+ }
119
+ }
120
+
121
+ // Walks the pre-normalize document and rewrites every math container —
122
+ // annotation-bearing (KaTeX, bare MathML) or MathJax script — into a
123
+ // `<span class="rdrm-math" data-display="…">LATEX</span>` marker. Idempotent:
124
+ // markers carry no `.katex`/`<math>`/annotation, so a second pass is a no-op.
125
+ // Defensive per node so malformed markup never aborts the pass.
126
+ export function extractMath(document: Document): void {
127
+ convertAnnotations(document);
128
+ convertOrphanedKatex(document);
129
+ convertMathJax(document);
130
+ }