@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.
Files changed (64) hide show
  1. package/README.md +3 -3
  2. package/package.json +4 -4
  3. package/src/cli.js +99 -0
  4. package/src/config.js +29 -0
  5. package/src/dev.js +55 -0
  6. package/src/errors.js +16 -0
  7. package/src/handler.js +4 -0
  8. package/src/host-sampling.js +57 -0
  9. package/src/index.js +23 -0
  10. package/src/index.ts +0 -0
  11. package/src/logger.js +2 -0
  12. package/src/output/format.js +92 -0
  13. package/src/pipeline/cell-text.js +51 -0
  14. package/src/pipeline/context.js +1 -0
  15. package/src/pipeline/dom.js +16 -0
  16. package/src/pipeline/footnotes.js +148 -0
  17. package/src/pipeline/math.js +113 -0
  18. package/src/pipeline/normalize.js +507 -0
  19. package/src/pipeline/readability.js +10 -0
  20. package/src/pipeline/resolver.js +113 -0
  21. package/src/pipeline/sanitize.js +26 -0
  22. package/src/pipeline/tables.js +239 -0
  23. package/src/pipeline/turndown.js +153 -0
  24. package/src/pipeline/urls.js +14 -0
  25. package/src/policy/chunk.js +365 -0
  26. package/src/policy/diagnostics.js +53 -0
  27. package/src/policy/explain.js +104 -0
  28. package/src/policy/fallback.js +68 -0
  29. package/src/policy/gating.js +90 -0
  30. package/src/policy/grid-detector.js +207 -0
  31. package/src/policy/identifiers.js +62 -0
  32. package/src/policy/images.js +49 -0
  33. package/src/policy/list-detector.js +232 -0
  34. package/src/policy/lost-signal.js +44 -0
  35. package/src/policy/markdown.js +58 -0
  36. package/src/policy/metadata.js +216 -0
  37. package/src/policy/outline-chains.js +165 -0
  38. package/src/policy/outline.js +71 -0
  39. package/src/policy/pagination.js +79 -0
  40. package/src/policy/presets.js +100 -0
  41. package/src/policy/section.js +111 -0
  42. package/src/policy/selector-lint.js +143 -0
  43. package/src/policy/sibling-scan.js +92 -0
  44. package/src/policy/text.js +18 -0
  45. package/src/policy/truncate.js +45 -0
  46. package/src/preset-cache.js +165 -0
  47. package/src/resources.js +227 -0
  48. package/src/sampling.js +45 -0
  49. package/src/server.js +73 -0
  50. package/src/tools/chunk_text.js +40 -0
  51. package/src/tools/explain.js +200 -0
  52. package/src/tools/extract.js +263 -0
  53. package/src/tools/extract_grid.js +56 -0
  54. package/src/tools/extract_links.js +98 -0
  55. package/src/tools/extract_list.js +66 -0
  56. package/src/tools/extract_metadata.js +67 -0
  57. package/src/tools/extract_section.js +56 -0
  58. package/src/tools/extract_tables.js +59 -0
  59. package/src/tools/html-source.js +30 -0
  60. package/src/tools/html_to_markdown.js +113 -0
  61. package/src/tools/outline.js +47 -0
  62. package/src/tools/output-schema.js +504 -0
  63. package/src/tools/schemas.js +288 -0
  64. package/src/tools/suggest-preset.js +504 -0
@@ -0,0 +1,365 @@
1
+ // Token-bounded chunking of extracted markdown for RAG/embedding. Chunk
2
+ // `tokenCount` uses the shared chars/4 estimator (policy/text.ts), keeping it
3
+ // directly comparable to `metadata.tokenEstimate`. This is the server-side
4
+ // chunking path for large documents; transport-level streaming was rejected —
5
+ // MCP tool-result progress isn't universally host-supported, and this option
6
+ // already keeps a multi-MB page from returning as one payload.
7
+ import { hardSplitLines, headingText, parseBlocks } from "./markdown.js";
8
+ import { estimateTokens } from "./text.js";
9
+ const HEADING_FIRST_LINE = /^#{1,6}\s/;
10
+ function splitBlocks(markdown) {
11
+ const parts = markdown.split(/\n{2,}/);
12
+ const blocks = [];
13
+ let heading = '';
14
+ for (const part of parts) {
15
+ const text = part.trim();
16
+ if (!text) {
17
+ continue;
18
+ }
19
+ if (HEADING_FIRST_LINE.test(text)) {
20
+ heading = headingText(text);
21
+ }
22
+ blocks.push({ headingContext: heading, text });
23
+ }
24
+ return blocks;
25
+ }
26
+ // Char strategy may break a code block — the semantic strategy avoids that.
27
+ function splitOversizedBlock(block, maxChars) {
28
+ return hardSplitLines(block.text, maxChars).map(text => ({
29
+ headingContext: block.headingContext,
30
+ text,
31
+ }));
32
+ }
33
+ function toUnits(blocks, maxChars) {
34
+ const units = [];
35
+ for (const block of blocks) {
36
+ if (block.text.length <= maxChars) {
37
+ units.push(block);
38
+ }
39
+ else {
40
+ units.push(...splitOversizedBlock(block, maxChars));
41
+ }
42
+ }
43
+ return units;
44
+ }
45
+ function chunkMarkdownChar(markdown, options) {
46
+ if (!markdown) {
47
+ return [];
48
+ }
49
+ const maxTokens = Math.max(1, Math.floor(options.maxTokens));
50
+ const maxChars = maxTokens * 4;
51
+ const overlapTokens = Math.max(0, Math.floor(options.overlap));
52
+ // Overlap must be strictly less than maxChars — otherwise a chunk's tail
53
+ // could consume the entire budget and we'd loop forever re-emitting it.
54
+ const overlapChars = Math.min(overlapTokens * 4, maxChars - 1);
55
+ const blocks = splitBlocks(markdown);
56
+ if (blocks.length === 0) {
57
+ return [];
58
+ }
59
+ const units = toUnits(blocks, maxChars);
60
+ if (units.length === 0) {
61
+ return [];
62
+ }
63
+ const chunks = [];
64
+ let i = 0;
65
+ let overlapText = '';
66
+ let overlapHeading = '';
67
+ while (i < units.length) {
68
+ const spans = [];
69
+ let chunkText = '';
70
+ if (overlapText) {
71
+ chunkText = overlapText;
72
+ spans.push({
73
+ end: overlapText.length,
74
+ headingContext: overlapHeading,
75
+ start: 0,
76
+ });
77
+ }
78
+ // Force at least one new unit per chunk so the loop terminates. If carrying
79
+ // the overlap would push the first new unit past maxChars, drop the overlap
80
+ // for this chunk (the unit was pre-split to <= maxChars so it always fits alone).
81
+ const firstUnit = units[i];
82
+ const sepLen = chunkText ? 2 : 0;
83
+ if (chunkText &&
84
+ chunkText.length + sepLen + firstUnit.text.length > maxChars) {
85
+ chunkText = '';
86
+ spans.length = 0;
87
+ }
88
+ {
89
+ const sep = chunkText ? '\n\n' : '';
90
+ const start = chunkText.length + sep.length;
91
+ chunkText = chunkText + sep + firstUnit.text;
92
+ spans.push({
93
+ end: chunkText.length,
94
+ headingContext: firstUnit.headingContext,
95
+ start,
96
+ });
97
+ i += 1;
98
+ }
99
+ while (i < units.length) {
100
+ const unit = units[i];
101
+ const candidate = `${chunkText}\n\n${unit.text}`;
102
+ if (candidate.length > maxChars) {
103
+ break;
104
+ }
105
+ const start = chunkText.length + 2;
106
+ chunkText = candidate;
107
+ spans.push({
108
+ end: chunkText.length,
109
+ headingContext: unit.headingContext,
110
+ start,
111
+ });
112
+ i += 1;
113
+ }
114
+ const text = chunkText.trim();
115
+ if (text) {
116
+ chunks.push({
117
+ headingContext: spans[0]?.headingContext ?? '',
118
+ index: chunks.length,
119
+ text,
120
+ tokenCount: estimateTokens(text).tokenEstimate,
121
+ });
122
+ }
123
+ if (overlapChars > 0 && i < units.length) {
124
+ const overlapStart = Math.max(0, chunkText.length - overlapChars);
125
+ const carrier = spans.find(span => overlapStart >= span.start && overlapStart < span.end) ?? spans.find(span => span.start >= overlapStart);
126
+ overlapHeading = carrier?.headingContext ?? '';
127
+ overlapText = chunkText.slice(overlapStart).replace(/^\n+/, '');
128
+ }
129
+ else {
130
+ overlapText = '';
131
+ overlapHeading = '';
132
+ }
133
+ }
134
+ return chunks;
135
+ }
136
+ function parseSemanticUnits(markdown) {
137
+ const units = [];
138
+ const stack = [];
139
+ function context() {
140
+ return stack.map(h => h.text).join(' > ');
141
+ }
142
+ for (const block of parseBlocks(markdown)) {
143
+ const text = markdown.slice(block.start, block.end);
144
+ if (block.kind === 'heading') {
145
+ while (stack.length > 0 && stack[stack.length - 1].level >= block.depth) {
146
+ stack.pop();
147
+ }
148
+ stack.push({ level: block.depth, text: headingText(text) });
149
+ units.push({ headingContext: context(), kind: 'heading', text });
150
+ continue;
151
+ }
152
+ units.push({
153
+ headingContext: context(),
154
+ kind: block.kind === 'code' ? 'code' : 'text',
155
+ text,
156
+ });
157
+ }
158
+ return units;
159
+ }
160
+ // A section = a heading unit plus its following non-heading units until the
161
+ // next heading. Pre-first-heading content forms its own section with an empty
162
+ // hierarchy path.
163
+ function groupSections(units) {
164
+ const sections = [];
165
+ let current = [];
166
+ let currentContext = '';
167
+ function flush() {
168
+ if (current.length > 0) {
169
+ sections.push({ headingContext: currentContext, units: current });
170
+ current = [];
171
+ }
172
+ }
173
+ for (const unit of units) {
174
+ if (unit.kind === 'heading') {
175
+ flush();
176
+ current = [unit];
177
+ currentContext = unit.headingContext;
178
+ }
179
+ else {
180
+ if (current.length === 0) {
181
+ currentContext = unit.headingContext;
182
+ }
183
+ current.push(unit);
184
+ }
185
+ }
186
+ flush();
187
+ return sections;
188
+ }
189
+ function joinedLength(units) {
190
+ if (units.length === 0) {
191
+ return 0;
192
+ }
193
+ let total = units[0].text.length;
194
+ for (let i = 1; i < units.length; i++) {
195
+ total += 2 + units[i].text.length;
196
+ }
197
+ return total;
198
+ }
199
+ // Hard-split a text unit that alone exceeds the budget — line-aware first, then
200
+ // hard-split any oversized line. Only ever applied to paragraphs; code blocks
201
+ // are never passed through here.
202
+ function splitOversizedTextUnit(unit, maxChars) {
203
+ return hardSplitLines(unit.text, maxChars).map(text => ({
204
+ headingContext: unit.headingContext,
205
+ kind: 'text',
206
+ text,
207
+ }));
208
+ }
209
+ // Pack sections into chunk groups <= maxChars, preferring heading boundaries.
210
+ // A section that fits is never split; an oversized section is split into
211
+ // sub-chunks by body unit (paragraphs may split; code blocks never do).
212
+ function buildBaseGroups(sections, maxChars) {
213
+ const groups = [];
214
+ let current = [];
215
+ let currentLen = 0;
216
+ function emit() {
217
+ if (current.length > 0) {
218
+ groups.push(current);
219
+ current = [];
220
+ currentLen = 0;
221
+ }
222
+ }
223
+ function append(unit) {
224
+ const sep = current.length > 0 ? 2 : 0;
225
+ current.push(unit);
226
+ currentLen += sep + unit.text.length;
227
+ }
228
+ for (const section of sections) {
229
+ const sectionLen = joinedLength(section.units);
230
+ const sep = current.length > 0 ? 2 : 0;
231
+ if (currentLen + sep + sectionLen <= maxChars) {
232
+ for (const unit of section.units) {
233
+ append(unit);
234
+ }
235
+ continue;
236
+ }
237
+ // Doesn't fit merged — close the current group and retry the section alone
238
+ // so it has first dibs on the next group (it may still merge with a later
239
+ // section that fits behind it).
240
+ emit();
241
+ if (sectionLen <= maxChars) {
242
+ for (const unit of section.units) {
243
+ append(unit);
244
+ }
245
+ continue;
246
+ }
247
+ // Oversized section: pack body units under the heading; split oversized
248
+ // paragraphs, emit oversized code blocks whole.
249
+ const [headingUnit, ...body] = section.units;
250
+ current.push(headingUnit);
251
+ currentLen = headingUnit.text.length;
252
+ for (const unit of body) {
253
+ if (unit.kind === 'code' && unit.text.length > maxChars) {
254
+ // Semantic never splits a code fence: a fence cut mid-way is
255
+ // unrecoverable, so the block is emitted as its own chunk even though
256
+ // it exceeds the token budget. Deliberate tradeoff vs. the char cap.
257
+ emit();
258
+ groups.push([unit]);
259
+ continue;
260
+ }
261
+ const sepNow = current.length > 0 ? 2 : 0;
262
+ if (unit.text.length <= maxChars &&
263
+ currentLen + sepNow + unit.text.length <= maxChars) {
264
+ append(unit);
265
+ continue;
266
+ }
267
+ const pieces = unit.text.length > maxChars
268
+ ? splitOversizedTextUnit(unit, maxChars)
269
+ : [unit];
270
+ for (const piece of pieces) {
271
+ const pieceSep = current.length > 0 ? 2 : 0;
272
+ if (current.length > 0 &&
273
+ currentLen + pieceSep + piece.text.length > maxChars) {
274
+ emit();
275
+ }
276
+ append(piece);
277
+ }
278
+ }
279
+ // Leave the trailing sub-chunk in `current` so a following section can
280
+ // merge into it if it fits.
281
+ }
282
+ emit();
283
+ return groups;
284
+ }
285
+ // Carry the trailing text (non-code) units from the previous group as the
286
+ // prefix of the next, up to overlapChars. Never carry into or out of a code
287
+ // block: a trailing code run is skipped, then contiguous text units before it
288
+ // are carried (stop at the first heading or code block encountered).
289
+ function applySemanticOverlap(groups, overlapChars) {
290
+ const result = [];
291
+ for (let i = 0; i < groups.length; i++) {
292
+ const group = groups[i];
293
+ const carrier = [];
294
+ if (i > 0 && overlapChars > 0) {
295
+ const prev = groups[i - 1];
296
+ let end = prev.length;
297
+ while (end > 0 && prev[end - 1].kind === 'code') {
298
+ end -= 1;
299
+ }
300
+ let carrierLen = 0;
301
+ for (let j = end - 1; j >= 0; j--) {
302
+ const unit = prev[j];
303
+ if (unit.kind !== 'text') {
304
+ break;
305
+ }
306
+ const sep = carrier.length > 0 ? 2 : 0;
307
+ if (carrierLen + sep + unit.text.length > overlapChars) {
308
+ break;
309
+ }
310
+ carrier.unshift(unit);
311
+ carrierLen += sep + unit.text.length;
312
+ }
313
+ }
314
+ const units = carrier.length === 0 ? group : [...carrier, ...group];
315
+ result.push({
316
+ // The chunk's heading context follows its first non-carrier unit, so a
317
+ // heading-leading chunk keeps its heading's path even with overlap.
318
+ headingContext: group[0]?.headingContext ?? '',
319
+ units,
320
+ });
321
+ }
322
+ return result;
323
+ }
324
+ function chunkMarkdownSemantic(markdown, options) {
325
+ if (!markdown) {
326
+ return [];
327
+ }
328
+ const maxTokens = Math.max(1, Math.floor(options.maxTokens));
329
+ const maxChars = maxTokens * 4;
330
+ const overlapTokens = Math.max(0, Math.floor(options.overlap));
331
+ const overlapChars = Math.min(overlapTokens * 4, maxChars - 1);
332
+ const units = parseSemanticUnits(markdown);
333
+ if (units.length === 0) {
334
+ return [];
335
+ }
336
+ const sections = groupSections(units);
337
+ const baseGroups = buildBaseGroups(sections, maxChars);
338
+ const groups = applySemanticOverlap(baseGroups, overlapChars);
339
+ const chunks = [];
340
+ for (const group of groups) {
341
+ if (group.units.length === 0) {
342
+ continue;
343
+ }
344
+ const text = group.units
345
+ .map(unit => unit.text)
346
+ .join('\n\n')
347
+ .trim();
348
+ if (!text) {
349
+ continue;
350
+ }
351
+ chunks.push({
352
+ headingContext: group.headingContext,
353
+ index: chunks.length,
354
+ text,
355
+ tokenCount: estimateTokens(text).tokenEstimate,
356
+ });
357
+ }
358
+ return chunks;
359
+ }
360
+ export function chunkMarkdown(markdown, options) {
361
+ if (options.strategy === 'semantic') {
362
+ return chunkMarkdownSemantic(markdown, options);
363
+ }
364
+ return chunkMarkdownChar(markdown, options);
365
+ }
@@ -0,0 +1,53 @@
1
+ function countElements(html, window) {
2
+ if (!window || !html) {
3
+ return 0;
4
+ }
5
+ const template = window.document.createElement('div');
6
+ template.innerHTML = html;
7
+ return template.querySelectorAll('*').length;
8
+ }
9
+ export function assembleDiagnostics(input) {
10
+ const articleElementCount = countElements(input.articleHtml ?? '', input.window);
11
+ const removedNodes = Math.max(0, (input.documentElementCount ?? 0) - articleElementCount);
12
+ return {
13
+ readerable: input.readerable,
14
+ extractedNode: input.extractedNode,
15
+ fallbackUsed: input.fallbackUsed ?? false,
16
+ gated: input.gated,
17
+ imagesResolved: input.imagesResolved,
18
+ pagination: input.pagination,
19
+ removedNodes,
20
+ boilerplateRemoved: input.boilerplateRemoved,
21
+ chromeRemoved: input.chromeRemoved,
22
+ sanitization: input.sanitization,
23
+ trace: input.trace,
24
+ truncated: input.truncated ?? false,
25
+ ...(input.cache ? { cache: input.cache } : {}),
26
+ ...(input.preset ? { preset: input.preset } : {}),
27
+ };
28
+ }
29
+ // Emits trace entries only when enabled, so the debug-off hot path allocates
30
+ // nothing (the fn runs bare). Used by the orchestrator tools to time pipeline
31
+ // stages without threading timers into lower layers.
32
+ export class TraceCollector {
33
+ enabled;
34
+ entries = [];
35
+ constructor(enabled) {
36
+ this.enabled = enabled;
37
+ }
38
+ collect() {
39
+ return this.enabled ? this.entries : undefined;
40
+ }
41
+ run(stage, fn) {
42
+ if (!this.enabled) {
43
+ return fn();
44
+ }
45
+ const start = performance.now();
46
+ try {
47
+ return fn();
48
+ }
49
+ finally {
50
+ this.entries.push({ ms: performance.now() - start, stage });
51
+ }
52
+ }
53
+ }
@@ -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,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,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
+ }