@v1nvn/readability 0.44.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.
Files changed (132) hide show
  1. package/README.md +3 -3
  2. package/package.json +7 -9
  3. package/src/cli.js +99 -0
  4. package/src/cli.ts +134 -0
  5. package/src/config.js +29 -0
  6. package/src/config.ts +42 -0
  7. package/src/dev.js +55 -0
  8. package/src/dev.ts +77 -0
  9. package/src/errors.js +16 -0
  10. package/src/errors.ts +19 -0
  11. package/src/handler.js +4 -0
  12. package/src/handler.ts +8 -0
  13. package/src/host-sampling.js +57 -0
  14. package/src/host-sampling.ts +86 -0
  15. package/src/index.js +23 -0
  16. package/src/index.ts +27 -0
  17. package/src/logger.js +2 -0
  18. package/src/logger.ts +3 -0
  19. package/src/output/format.js +92 -0
  20. package/src/output/format.ts +132 -0
  21. package/src/pipeline/cell-text.js +51 -0
  22. package/src/pipeline/cell-text.ts +56 -0
  23. package/src/pipeline/context.js +1 -0
  24. package/src/pipeline/context.ts +88 -0
  25. package/src/pipeline/dom.js +16 -0
  26. package/src/pipeline/dom.ts +23 -0
  27. package/src/pipeline/footnotes.js +148 -0
  28. package/src/pipeline/footnotes.ts +181 -0
  29. package/src/pipeline/math.js +113 -0
  30. package/src/pipeline/math.ts +130 -0
  31. package/src/pipeline/normalize.js +507 -0
  32. package/src/pipeline/normalize.ts +594 -0
  33. package/src/pipeline/readability.js +10 -0
  34. package/src/pipeline/readability.ts +36 -0
  35. package/src/pipeline/resolver.js +113 -0
  36. package/src/pipeline/resolver.ts +143 -0
  37. package/src/pipeline/sanitize.js +26 -0
  38. package/src/pipeline/sanitize.ts +42 -0
  39. package/src/pipeline/tables.js +239 -0
  40. package/src/pipeline/tables.ts +286 -0
  41. package/src/pipeline/turndown.js +153 -0
  42. package/src/pipeline/turndown.ts +191 -0
  43. package/src/pipeline/urls.js +14 -0
  44. package/src/pipeline/urls.ts +13 -0
  45. package/src/policy/chunk.js +365 -0
  46. package/src/policy/chunk.ts +482 -0
  47. package/src/policy/diagnostics.js +53 -0
  48. package/src/policy/diagnostics.ts +96 -0
  49. package/src/policy/explain.js +104 -0
  50. package/src/policy/explain.ts +182 -0
  51. package/src/policy/fallback.js +68 -0
  52. package/src/policy/fallback.ts +110 -0
  53. package/src/policy/gating.js +90 -0
  54. package/src/policy/gating.ts +106 -0
  55. package/src/policy/grid-detector.js +207 -0
  56. package/src/policy/grid-detector.ts +313 -0
  57. package/src/policy/identifiers.js +62 -0
  58. package/src/policy/identifiers.ts +75 -0
  59. package/src/policy/images.js +49 -0
  60. package/src/policy/images.ts +64 -0
  61. package/src/policy/list-detector.js +232 -0
  62. package/src/policy/list-detector.ts +308 -0
  63. package/src/policy/lost-signal.js +44 -0
  64. package/src/policy/lost-signal.ts +69 -0
  65. package/src/policy/markdown.js +58 -0
  66. package/src/policy/markdown.ts +76 -0
  67. package/src/policy/metadata.js +216 -0
  68. package/src/policy/metadata.ts +312 -0
  69. package/src/policy/outline-chains.js +165 -0
  70. package/src/policy/outline-chains.ts +215 -0
  71. package/src/policy/outline.js +71 -0
  72. package/src/policy/outline.ts +89 -0
  73. package/src/policy/pagination.js +79 -0
  74. package/src/policy/pagination.ts +104 -0
  75. package/src/policy/presets.js +100 -0
  76. package/src/policy/presets.ts +142 -0
  77. package/src/policy/section.js +111 -0
  78. package/src/policy/section.ts +127 -0
  79. package/src/policy/selector-lint.js +143 -0
  80. package/src/policy/selector-lint.ts +186 -0
  81. package/src/policy/sibling-scan.js +92 -0
  82. package/src/policy/sibling-scan.ts +99 -0
  83. package/src/policy/text.js +18 -0
  84. package/src/policy/text.ts +36 -0
  85. package/src/policy/truncate.js +45 -0
  86. package/src/policy/truncate.ts +59 -0
  87. package/src/preset-cache.js +165 -0
  88. package/src/preset-cache.ts +221 -0
  89. package/src/resources.js +227 -0
  90. package/src/resources.ts +317 -0
  91. package/src/sampling.js +45 -0
  92. package/src/sampling.ts +74 -0
  93. package/src/server.js +73 -0
  94. package/src/server.ts +85 -0
  95. package/src/tools/chunk_text.js +40 -0
  96. package/src/tools/chunk_text.ts +65 -0
  97. package/src/tools/explain.js +200 -0
  98. package/src/tools/explain.ts +299 -0
  99. package/src/tools/extract.js +263 -0
  100. package/src/tools/extract.ts +368 -0
  101. package/src/tools/extract_grid.js +56 -0
  102. package/src/tools/extract_grid.ts +90 -0
  103. package/src/tools/extract_links.js +98 -0
  104. package/src/tools/extract_links.ts +138 -0
  105. package/src/tools/extract_list.js +66 -0
  106. package/src/tools/extract_list.ts +97 -0
  107. package/src/tools/extract_metadata.js +67 -0
  108. package/src/tools/extract_metadata.ts +102 -0
  109. package/src/tools/extract_section.js +56 -0
  110. package/src/tools/extract_section.ts +84 -0
  111. package/src/tools/extract_tables.js +59 -0
  112. package/src/tools/extract_tables.ts +101 -0
  113. package/src/tools/html-source.js +30 -0
  114. package/src/tools/html-source.ts +40 -0
  115. package/src/tools/html_to_markdown.js +113 -0
  116. package/src/tools/html_to_markdown.ts +180 -0
  117. package/src/tools/outline.js +47 -0
  118. package/src/tools/outline.ts +77 -0
  119. package/src/tools/output-schema.js +504 -0
  120. package/src/tools/output-schema.ts +715 -0
  121. package/src/tools/schemas.js +288 -0
  122. package/src/tools/schemas.ts +442 -0
  123. package/src/tools/suggest-preset.js +504 -0
  124. package/src/tools/suggest-preset.ts +708 -0
  125. package/src/types/jsdom.d.ts +15 -0
  126. package/src/types/turndown-plugin-gfm.d.ts +11 -0
  127. package/dist/assets/cli-Dfjg7dOQ.js +0 -76
  128. package/dist/assets/cli-Dfjg7dOQ.js.map +0 -1
  129. package/dist/assets/extract-CdDp-KwX.js +0 -2880
  130. package/dist/assets/extract-CdDp-KwX.js.map +0 -1
  131. package/dist/index.js +0 -2267
  132. package/dist/index.js.map +0 -1
@@ -0,0 +1,104 @@
1
+ import { Readability } from '@mozilla/readability';
2
+ import { buildDocument } from "../pipeline/dom.js";
3
+ import { applySelectors, normalizeDocument, resolveLazyImages, } from "../pipeline/normalize.js";
4
+ import { isReaderable } from "../pipeline/readability.js";
5
+ import { resolveReadabilityOptions } from "../pipeline/resolver.js";
6
+ import { assembleDiagnostics } from "./diagnostics.js";
7
+ import { detectGating } from "./gating.js";
8
+ import { detectPagination } from "./pagination.js";
9
+ import { describeSelector } from "./sibling-scan.js";
10
+ const DEFAULT_SNAPSHOT_MAX = 4000;
11
+ const DEFAULT_TOP_N = 5;
12
+ function readScored(el) {
13
+ const stamp = el
14
+ .readability;
15
+ const score = stamp?.contentScore;
16
+ if (typeof score !== 'number') {
17
+ return null;
18
+ }
19
+ const className = typeof el.className === 'string' ? el.className : '';
20
+ const candidate = {
21
+ className,
22
+ id: el.id,
23
+ score,
24
+ tag: el.tagName,
25
+ textLength: el.textContent.trim().length,
26
+ };
27
+ // Two classes cap the hint's width — Readability's expando is invisible to
28
+ // CSS and an nth-child chain would be brittle against the host's live DOM,
29
+ // so tag + id + a couple of classes is all a human needs to pick the node
30
+ // out of a small candidate list.
31
+ return { ...candidate, selector: describeSelector(el, 2) };
32
+ }
33
+ function truncateSnapshot(html, max) {
34
+ if (html.length <= max) {
35
+ return { html, truncated: false };
36
+ }
37
+ return { html: html.slice(0, max), truncated: true };
38
+ }
39
+ export function buildExplainReport(options) {
40
+ const { html, selectors, snapshotMaxChars = DEFAULT_SNAPSHOT_MAX, topN = DEFAULT_TOP_N, baseUrl, } = options;
41
+ const { document, window } = buildDocument(html, baseUrl);
42
+ // Mirror the extract pipeline's ordering: gating detection must precede
43
+ // normalization (chrome stripping would remove the overlay), and pagination
44
+ // detection runs before applySelectors (a caller's include could hide the
45
+ // sentinel but not the "more content exists" signal).
46
+ const gating = detectGating(document);
47
+ const documentElementCount = document.querySelectorAll('*').length;
48
+ const normalizeCounts = normalizeDocument(document);
49
+ resolveLazyImages(document);
50
+ const pagination = detectPagination(document, baseUrl);
51
+ applySelectors(document, selectors);
52
+ const snapshot = truncateSnapshot(document.body.innerHTML, snapshotMaxChars);
53
+ const readerable = isReaderable(document);
54
+ const readabilityOptions = resolveReadabilityOptions({});
55
+ // Clone so the normalized doc (and the snapshot above) is preserved untouched
56
+ // — Readability restructures its input during parse.
57
+ const clone = document.cloneNode(true);
58
+ // Grab node references before parse: Readability detaches scored candidates
59
+ // from the live tree while restructuring, so a post-parse querySelectorAll
60
+ // finds almost nothing. The object refs held here keep their `readability`
61
+ // stamps after being detached — that retention is what surfaces the real
62
+ // per-candidate scores without forking the library or parsing its debug log.
63
+ const heldNodes = Array.from(clone.querySelectorAll('*'));
64
+ const reader = new Readability(clone, readabilityOptions);
65
+ const article = reader.parse();
66
+ const parseSucceeded = !!article?.content;
67
+ const scored = [];
68
+ for (const el of heldNodes) {
69
+ const entry = readScored(el);
70
+ if (entry) {
71
+ scored.push(entry);
72
+ }
73
+ }
74
+ scored.sort((a, b) => b.score - a.score);
75
+ const candidates = scored.slice(0, Math.max(0, topN));
76
+ const chosenRoot = scored[0] ?? null;
77
+ const diagnostics = assembleDiagnostics({
78
+ articleHtml: article?.content ?? '',
79
+ boilerplateRemoved: normalizeCounts.boilerplateRemoved,
80
+ chromeRemoved: normalizeCounts.chromeRemoved,
81
+ documentElementCount,
82
+ extractedNode: 'readability',
83
+ fallbackUsed: false,
84
+ gated: gating,
85
+ pagination,
86
+ readerable,
87
+ window,
88
+ });
89
+ return {
90
+ candidates,
91
+ chosenRoot,
92
+ fallbackUsed: diagnostics.fallbackUsed,
93
+ gating: diagnostics.gated,
94
+ pagination: diagnostics.pagination,
95
+ parseSucceeded,
96
+ readerable: diagnostics.readerable ?? false,
97
+ removedNodes: {
98
+ boilerplate: diagnostics.boilerplateRemoved ?? 0,
99
+ chrome: diagnostics.chromeRemoved ?? 0,
100
+ total: diagnostics.removedNodes ?? 0,
101
+ },
102
+ snapshot,
103
+ };
104
+ }
@@ -0,0 +1,182 @@
1
+ import { Readability } from '@mozilla/readability';
2
+
3
+ import type { GatingSignal } from './gating.ts';
4
+ import type { PaginationSignal } from './pagination.ts';
5
+
6
+ import { buildDocument } from '../pipeline/dom.ts';
7
+ import {
8
+ applySelectors,
9
+ normalizeDocument,
10
+ resolveLazyImages,
11
+ type SelectorScope,
12
+ } from '../pipeline/normalize.ts';
13
+ import { isReaderable } from '../pipeline/readability.ts';
14
+ import { resolveReadabilityOptions } from '../pipeline/resolver.ts';
15
+ import { assembleDiagnostics } from './diagnostics.ts';
16
+ import { detectGating } from './gating.ts';
17
+ import { detectPagination } from './pagination.ts';
18
+ import { describeSelector } from './sibling-scan.ts';
19
+
20
+ // Readability stamps `{ contentScore }` on candidate DOM nodes under a
21
+ // `readability` expando (Readability.js:894/1272/1288). The property is
22
+ // untyped upstream, so reach it through `unknown` rather than a guessed shape.
23
+ interface ReadabilityExpando {
24
+ readonly contentScore?: number;
25
+ }
26
+
27
+ export interface ExplainCandidate {
28
+ readonly className: string;
29
+ readonly id: string;
30
+ readonly score: number;
31
+ readonly selector: string;
32
+ readonly tag: string;
33
+ readonly textLength: number;
34
+ }
35
+
36
+ export interface ExplainRemovedNodes {
37
+ readonly boilerplate: number;
38
+ readonly chrome: number;
39
+ readonly total: number;
40
+ }
41
+
42
+ export interface ExplainSnapshot {
43
+ readonly html: string;
44
+ readonly truncated: boolean;
45
+ }
46
+
47
+ export interface ExplainReport {
48
+ readonly candidates: readonly ExplainCandidate[];
49
+ readonly chosenRoot: ExplainCandidate | null;
50
+ readonly fallbackUsed: boolean;
51
+ readonly gating: GatingSignal | undefined;
52
+ readonly pagination: PaginationSignal | undefined;
53
+ readonly parseSucceeded: boolean;
54
+ readonly readerable: boolean;
55
+ readonly removedNodes: ExplainRemovedNodes;
56
+ readonly snapshot: ExplainSnapshot;
57
+ }
58
+
59
+ export interface BuildExplainOptions {
60
+ readonly baseUrl?: string;
61
+ readonly html: string;
62
+ readonly selectors?: Readonly<SelectorScope>;
63
+ readonly snapshotMaxChars?: number;
64
+ readonly topN?: number;
65
+ }
66
+
67
+ const DEFAULT_SNAPSHOT_MAX = 4000;
68
+ const DEFAULT_TOP_N = 5;
69
+
70
+ function readScored(el: Element): ExplainCandidate | null {
71
+ const stamp = (el as unknown as { readability?: ReadabilityExpando })
72
+ .readability;
73
+ const score = stamp?.contentScore;
74
+ if (typeof score !== 'number') {
75
+ return null;
76
+ }
77
+ const className = typeof el.className === 'string' ? el.className : '';
78
+ const candidate = {
79
+ className,
80
+ id: el.id,
81
+ score,
82
+ tag: el.tagName,
83
+ textLength: el.textContent.trim().length,
84
+ };
85
+ // Two classes cap the hint's width — Readability's expando is invisible to
86
+ // CSS and an nth-child chain would be brittle against the host's live DOM,
87
+ // so tag + id + a couple of classes is all a human needs to pick the node
88
+ // out of a small candidate list.
89
+ return { ...candidate, selector: describeSelector(el, 2) };
90
+ }
91
+
92
+ function truncateSnapshot(html: string, max: number): ExplainSnapshot {
93
+ if (html.length <= max) {
94
+ return { html, truncated: false };
95
+ }
96
+ return { html: html.slice(0, max), truncated: true };
97
+ }
98
+
99
+ export function buildExplainReport(
100
+ options: Readonly<BuildExplainOptions>,
101
+ ): ExplainReport {
102
+ const {
103
+ html,
104
+ selectors,
105
+ snapshotMaxChars = DEFAULT_SNAPSHOT_MAX,
106
+ topN = DEFAULT_TOP_N,
107
+ baseUrl,
108
+ } = options;
109
+
110
+ const { document, window } = buildDocument(html, baseUrl);
111
+
112
+ // Mirror the extract pipeline's ordering: gating detection must precede
113
+ // normalization (chrome stripping would remove the overlay), and pagination
114
+ // detection runs before applySelectors (a caller's include could hide the
115
+ // sentinel but not the "more content exists" signal).
116
+ const gating = detectGating(document);
117
+ const documentElementCount = document.querySelectorAll('*').length;
118
+ const normalizeCounts = normalizeDocument(document);
119
+ resolveLazyImages(document);
120
+ const pagination = detectPagination(document, baseUrl);
121
+ applySelectors(document, selectors);
122
+
123
+ const snapshot = truncateSnapshot(document.body.innerHTML, snapshotMaxChars);
124
+
125
+ const readerable = isReaderable(document);
126
+ const readabilityOptions = resolveReadabilityOptions({});
127
+
128
+ // Clone so the normalized doc (and the snapshot above) is preserved untouched
129
+ // — Readability restructures its input during parse.
130
+ const clone = document.cloneNode(true) as Document;
131
+ // Grab node references before parse: Readability detaches scored candidates
132
+ // from the live tree while restructuring, so a post-parse querySelectorAll
133
+ // finds almost nothing. The object refs held here keep their `readability`
134
+ // stamps after being detached — that retention is what surfaces the real
135
+ // per-candidate scores without forking the library or parsing its debug log.
136
+ const heldNodes = Array.from(clone.querySelectorAll('*'));
137
+
138
+ const reader = new Readability(clone, readabilityOptions);
139
+ const article = reader.parse();
140
+ const parseSucceeded = !!article?.content;
141
+
142
+ const scored: ExplainCandidate[] = [];
143
+ for (const el of heldNodes) {
144
+ const entry = readScored(el);
145
+ if (entry) {
146
+ scored.push(entry);
147
+ }
148
+ }
149
+ scored.sort((a, b) => b.score - a.score);
150
+
151
+ const candidates = scored.slice(0, Math.max(0, topN));
152
+ const chosenRoot = scored[0] ?? null;
153
+
154
+ const diagnostics = assembleDiagnostics({
155
+ articleHtml: article?.content ?? '',
156
+ boilerplateRemoved: normalizeCounts.boilerplateRemoved,
157
+ chromeRemoved: normalizeCounts.chromeRemoved,
158
+ documentElementCount,
159
+ extractedNode: 'readability',
160
+ fallbackUsed: false,
161
+ gated: gating,
162
+ pagination,
163
+ readerable,
164
+ window,
165
+ });
166
+
167
+ return {
168
+ candidates,
169
+ chosenRoot,
170
+ fallbackUsed: diagnostics.fallbackUsed,
171
+ gating: diagnostics.gated,
172
+ pagination: diagnostics.pagination,
173
+ parseSucceeded,
174
+ readerable: diagnostics.readerable ?? false,
175
+ removedNodes: {
176
+ boilerplate: diagnostics.boilerplateRemoved ?? 0,
177
+ chrome: diagnostics.chromeRemoved ?? 0,
178
+ total: diagnostics.removedNodes ?? 0,
179
+ },
180
+ snapshot,
181
+ };
182
+ }
@@ -0,0 +1,68 @@
1
+ import { sanitizeHtml } from "../pipeline/sanitize.js";
2
+ import { toMarkdown } from "../pipeline/turndown.js";
3
+ // Below this a block is treated as not text-dense (nav/boilerplate-ish).
4
+ const MIN_DENSE_BLOCK_CHARS = 200;
5
+ function convert(element, options) {
6
+ const rawHtml = element.outerHTML;
7
+ let sanitizedHtml = rawHtml;
8
+ let sanitization = { iframes: 0, scripts: 0 };
9
+ if (options.sanitize) {
10
+ const res = sanitizeHtml(rawHtml, options.window);
11
+ sanitizedHtml = res.html;
12
+ sanitization = { iframes: res.iframesRemoved, scripts: res.scriptsRemoved };
13
+ }
14
+ const markdown = toMarkdown(sanitizedHtml, {
15
+ codeBlockStyle: options.codeBlockStyle,
16
+ gfm: options.gfm,
17
+ headingStyle: options.headingStyle,
18
+ images: options.images,
19
+ tables: options.tables,
20
+ baseUrl: options.baseUrl,
21
+ });
22
+ const textContent = element.textContent;
23
+ if (markdown.trim().length === 0) {
24
+ return undefined;
25
+ }
26
+ return { markdown, sanitization, sanitizedHtml, textContent };
27
+ }
28
+ function largestTextDenseBlock(document) {
29
+ let best;
30
+ let bestLen = 0;
31
+ const candidates = document.querySelectorAll('div, section');
32
+ candidates.forEach(el => {
33
+ const len = el.textContent.trim().length;
34
+ if (len > bestLen && len >= MIN_DENSE_BLOCK_CHARS) {
35
+ best = el;
36
+ bestLen = len;
37
+ }
38
+ });
39
+ return best;
40
+ }
41
+ export function extractViaFallback(document, options) {
42
+ const orderedSelectors = [
43
+ 'article',
44
+ 'main',
45
+ '[role=main]',
46
+ ];
47
+ for (const selector of orderedSelectors) {
48
+ const root = document.querySelector(selector);
49
+ if (root) {
50
+ const converted = convert(root, options);
51
+ if (converted) {
52
+ return { ...converted, rootSelector: selector };
53
+ }
54
+ }
55
+ }
56
+ const dense = largestTextDenseBlock(document);
57
+ if (dense) {
58
+ const converted = convert(dense, options);
59
+ if (converted) {
60
+ return { ...converted, rootSelector: 'largest-block' };
61
+ }
62
+ }
63
+ const converted = convert(document.body, options);
64
+ if (converted) {
65
+ return { ...converted, rootSelector: 'body' };
66
+ }
67
+ return null;
68
+ }
@@ -0,0 +1,110 @@
1
+ import type { SanitizationDiagnostics } from '../pipeline/context.ts';
2
+ import type { ImageMode, TableFormat } from '../pipeline/turndown.ts';
3
+
4
+ import { sanitizeHtml } from '../pipeline/sanitize.ts';
5
+ import { toMarkdown } from '../pipeline/turndown.ts';
6
+
7
+ export interface FallbackOptions {
8
+ readonly baseUrl?: string;
9
+ readonly codeBlockStyle?: 'fenced' | 'indented';
10
+ readonly gfm?: boolean;
11
+ readonly headingStyle?: 'atx' | 'setext';
12
+ readonly images?: ImageMode;
13
+ readonly sanitize: boolean;
14
+ readonly tables?: TableFormat;
15
+ readonly window: Window;
16
+ }
17
+
18
+ export interface FallbackResult {
19
+ readonly markdown: string;
20
+ readonly rootSelector: string;
21
+ readonly sanitization: SanitizationDiagnostics;
22
+ readonly sanitizedHtml: string;
23
+ readonly textContent: string;
24
+ }
25
+
26
+ // Below this a block is treated as not text-dense (nav/boilerplate-ish).
27
+ const MIN_DENSE_BLOCK_CHARS = 200;
28
+
29
+ function convert(
30
+ element: Element,
31
+ options: Readonly<FallbackOptions>,
32
+ ):
33
+ | undefined
34
+ | {
35
+ markdown: string;
36
+ sanitization: SanitizationDiagnostics;
37
+ sanitizedHtml: string;
38
+ textContent: string;
39
+ } {
40
+ const rawHtml = element.outerHTML;
41
+ let sanitizedHtml = rawHtml;
42
+ let sanitization: SanitizationDiagnostics = { iframes: 0, scripts: 0 };
43
+ if (options.sanitize) {
44
+ const res = sanitizeHtml(rawHtml, options.window);
45
+ sanitizedHtml = res.html;
46
+ sanitization = { iframes: res.iframesRemoved, scripts: res.scriptsRemoved };
47
+ }
48
+ const markdown = toMarkdown(sanitizedHtml, {
49
+ codeBlockStyle: options.codeBlockStyle,
50
+ gfm: options.gfm,
51
+ headingStyle: options.headingStyle,
52
+ images: options.images,
53
+ tables: options.tables,
54
+ baseUrl: options.baseUrl,
55
+ });
56
+ const textContent = element.textContent;
57
+ if (markdown.trim().length === 0) {
58
+ return undefined;
59
+ }
60
+ return { markdown, sanitization, sanitizedHtml, textContent };
61
+ }
62
+
63
+ function largestTextDenseBlock(document: Document): Element | undefined {
64
+ let best: Element | undefined;
65
+ let bestLen = 0;
66
+ const candidates = document.querySelectorAll('div, section');
67
+ candidates.forEach(el => {
68
+ const len = el.textContent.trim().length;
69
+ if (len > bestLen && len >= MIN_DENSE_BLOCK_CHARS) {
70
+ best = el;
71
+ bestLen = len;
72
+ }
73
+ });
74
+ return best;
75
+ }
76
+
77
+ export function extractViaFallback(
78
+ document: Document,
79
+ options: Readonly<FallbackOptions>,
80
+ ): FallbackResult | null {
81
+ const orderedSelectors: readonly string[] = [
82
+ 'article',
83
+ 'main',
84
+ '[role=main]',
85
+ ];
86
+ for (const selector of orderedSelectors) {
87
+ const root = document.querySelector(selector);
88
+ if (root) {
89
+ const converted = convert(root, options);
90
+ if (converted) {
91
+ return { ...converted, rootSelector: selector };
92
+ }
93
+ }
94
+ }
95
+
96
+ const dense = largestTextDenseBlock(document);
97
+ if (dense) {
98
+ const converted = convert(dense, options);
99
+ if (converted) {
100
+ return { ...converted, rootSelector: 'largest-block' };
101
+ }
102
+ }
103
+
104
+ const converted = convert(document.body, options);
105
+ if (converted) {
106
+ return { ...converted, rootSelector: 'body' };
107
+ }
108
+
109
+ return null;
110
+ }
@@ -0,0 +1,90 @@
1
+ // Curated vendor/structural paywall selectors — intentionally NOT a greedy
2
+ // `[class*="subscribe"]` (that catches newsletter CTAs on clean articles and
3
+ // false-positives). Each entry names a known paywall surface.
4
+ const PAYWALL_SELECTORS = [
5
+ '.piano',
6
+ '#piano',
7
+ '.tp-modal',
8
+ '.tp-active',
9
+ '[class*="subscribe-wall"]',
10
+ '[id*="subscribe-wall"]',
11
+ '[class*="metered-wall"]',
12
+ '[id*="metered-wall"]',
13
+ '.leaky-paywall',
14
+ ];
15
+ // Class/id substring hits are verdicts, not signals. Measured on real captures:
16
+ // Daily Mail free articles carry `<html class="… paywall-ineligible">` plus ~240
17
+ // `is-paywalled` / `is-paywall-processed` feed badges — state about other
18
+ // articles or processing markers, all matching `[class*="paywall"]`; Corriere's
19
+ // free articles carry an `offer-header-piano` subscription header matching
20
+ // `[class*="piano"]`. Real surfaces name the gate as the head noun (WIRED's
21
+ // `paywall-modal`, the camel-cased `PaywallModalWrapper`).
22
+ const PAYWALL_ATTR_CANDIDATES = '[class*="paywall"], [id*="paywall"], [class*="piano"], [id*="piano"]';
23
+ const NEGATION_SEGMENTS = new Set([
24
+ 'bypass',
25
+ 'disabled',
26
+ 'exempt',
27
+ 'free',
28
+ 'ineligible',
29
+ 'no',
30
+ 'non',
31
+ 'not',
32
+ 'off',
33
+ 'optout',
34
+ 'without',
35
+ ]);
36
+ function namesGateSurface(classAndId, noun) {
37
+ for (const token of classAndId.split(/\s+/)) {
38
+ const segments = token
39
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
40
+ .toLowerCase()
41
+ .split(/[^a-z]+/)
42
+ .filter(Boolean);
43
+ if (segments[0] !== noun) {
44
+ continue;
45
+ }
46
+ if (segments.some(segment => NEGATION_SEGMENTS.has(segment))) {
47
+ continue;
48
+ }
49
+ return true;
50
+ }
51
+ return false;
52
+ }
53
+ // Phrases that essentially never appear on a fully-unlocked article. Bare
54
+ // "Subscribe" nav links / newsletter CTAs are intentionally excluded — they
55
+ // are ubiquitous and would mislead the host into discarding complete content.
56
+ const METERED_TEXT_RE = /(\d+)\s*(?:free\s*)?(?:articles?|stories?)\s*(?:left|remaining)|you\s+have\s+reached\s+(?:your\s+)?(?:free\s+)?(?:article\s+|story\s+)?limit|subscribe\s+to\s+(?:continue\s+)?reading|read\s+the\s+full\s+(?:article|story)|unlock\s+(?:this|full|all)\s+(?:article|story|content)|keep\s+reading\s+with/i;
57
+ function findPaywallOverlay(document) {
58
+ for (const el of document.querySelectorAll(PAYWALL_ATTR_CANDIDATES)) {
59
+ const classAndId = `${el.getAttribute('class') ?? ''} ${el.getAttribute('id') ?? ''}`;
60
+ if (!el.isConnected) {
61
+ continue;
62
+ }
63
+ if (namesGateSurface(classAndId, 'paywall') ||
64
+ namesGateSurface(classAndId, 'piano')) {
65
+ return { likely: true, reason: 'paywall overlay' };
66
+ }
67
+ }
68
+ for (const selector of PAYWALL_SELECTORS) {
69
+ const el = document.querySelector(selector);
70
+ if (el?.isConnected) {
71
+ return { likely: true, reason: 'paywall overlay' };
72
+ }
73
+ }
74
+ return undefined;
75
+ }
76
+ // One textContent read is cheaper than a per-element scan of a large doc and
77
+ // avoids ordering a query per selector. jsdom computes body.textContent once.
78
+ function findMeteredMessage(document) {
79
+ const text = document.body.textContent;
80
+ if (METERED_TEXT_RE.test(text)) {
81
+ return { likely: true, reason: 'metered paywall message' };
82
+ }
83
+ return undefined;
84
+ }
85
+ // Reads the DOM only — never fetches, authenticates, or mutates the document.
86
+ // Conservative by design: a false positive misleads the host into treating
87
+ // complete content as truncated, which is worse than a miss.
88
+ export function detectGating(document) {
89
+ return findPaywallOverlay(document) ?? findMeteredMessage(document);
90
+ }
@@ -0,0 +1,106 @@
1
+ export interface GatingSignal {
2
+ readonly likely: boolean;
3
+ readonly reason: string;
4
+ }
5
+
6
+ // Curated vendor/structural paywall selectors — intentionally NOT a greedy
7
+ // `[class*="subscribe"]` (that catches newsletter CTAs on clean articles and
8
+ // false-positives). Each entry names a known paywall surface.
9
+ const PAYWALL_SELECTORS = [
10
+ '.piano',
11
+ '#piano',
12
+ '.tp-modal',
13
+ '.tp-active',
14
+ '[class*="subscribe-wall"]',
15
+ '[id*="subscribe-wall"]',
16
+ '[class*="metered-wall"]',
17
+ '[id*="metered-wall"]',
18
+ '.leaky-paywall',
19
+ ] as const;
20
+
21
+ // Class/id substring hits are verdicts, not signals. Measured on real captures:
22
+ // Daily Mail free articles carry `<html class="… paywall-ineligible">` plus ~240
23
+ // `is-paywalled` / `is-paywall-processed` feed badges — state about other
24
+ // articles or processing markers, all matching `[class*="paywall"]`; Corriere's
25
+ // free articles carry an `offer-header-piano` subscription header matching
26
+ // `[class*="piano"]`. Real surfaces name the gate as the head noun (WIRED's
27
+ // `paywall-modal`, the camel-cased `PaywallModalWrapper`).
28
+ const PAYWALL_ATTR_CANDIDATES =
29
+ '[class*="paywall"], [id*="paywall"], [class*="piano"], [id*="piano"]';
30
+
31
+ const NEGATION_SEGMENTS = new Set([
32
+ 'bypass',
33
+ 'disabled',
34
+ 'exempt',
35
+ 'free',
36
+ 'ineligible',
37
+ 'no',
38
+ 'non',
39
+ 'not',
40
+ 'off',
41
+ 'optout',
42
+ 'without',
43
+ ]);
44
+
45
+ function namesGateSurface(classAndId: string, noun: string): boolean {
46
+ for (const token of classAndId.split(/\s+/)) {
47
+ const segments = token
48
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
49
+ .toLowerCase()
50
+ .split(/[^a-z]+/)
51
+ .filter(Boolean);
52
+ if (segments[0] !== noun) {
53
+ continue;
54
+ }
55
+ if (segments.some(segment => NEGATION_SEGMENTS.has(segment))) {
56
+ continue;
57
+ }
58
+ return true;
59
+ }
60
+ return false;
61
+ }
62
+
63
+ // Phrases that essentially never appear on a fully-unlocked article. Bare
64
+ // "Subscribe" nav links / newsletter CTAs are intentionally excluded — they
65
+ // are ubiquitous and would mislead the host into discarding complete content.
66
+ const METERED_TEXT_RE =
67
+ /(\d+)\s*(?:free\s*)?(?:articles?|stories?)\s*(?:left|remaining)|you\s+have\s+reached\s+(?:your\s+)?(?:free\s+)?(?:article\s+|story\s+)?limit|subscribe\s+to\s+(?:continue\s+)?reading|read\s+the\s+full\s+(?:article|story)|unlock\s+(?:this|full|all)\s+(?:article|story|content)|keep\s+reading\s+with/i;
68
+
69
+ function findPaywallOverlay(document: Document): GatingSignal | undefined {
70
+ for (const el of document.querySelectorAll(PAYWALL_ATTR_CANDIDATES)) {
71
+ const classAndId = `${el.getAttribute('class') ?? ''} ${el.getAttribute('id') ?? ''}`;
72
+ if (!el.isConnected) {
73
+ continue;
74
+ }
75
+ if (
76
+ namesGateSurface(classAndId, 'paywall') ||
77
+ namesGateSurface(classAndId, 'piano')
78
+ ) {
79
+ return { likely: true, reason: 'paywall overlay' };
80
+ }
81
+ }
82
+ for (const selector of PAYWALL_SELECTORS) {
83
+ const el = document.querySelector(selector);
84
+ if (el?.isConnected) {
85
+ return { likely: true, reason: 'paywall overlay' };
86
+ }
87
+ }
88
+ return undefined;
89
+ }
90
+
91
+ // One textContent read is cheaper than a per-element scan of a large doc and
92
+ // avoids ordering a query per selector. jsdom computes body.textContent once.
93
+ function findMeteredMessage(document: Document): GatingSignal | undefined {
94
+ const text = document.body.textContent;
95
+ if (METERED_TEXT_RE.test(text)) {
96
+ return { likely: true, reason: 'metered paywall message' };
97
+ }
98
+ return undefined;
99
+ }
100
+
101
+ // Reads the DOM only — never fetches, authenticates, or mutates the document.
102
+ // Conservative by design: a false positive misleads the host into treating
103
+ // complete content as truncated, which is worse than a miss.
104
+ export function detectGating(document: Document): GatingSignal | undefined {
105
+ return findPaywallOverlay(document) ?? findMeteredMessage(document);
106
+ }