@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,69 @@
1
+ import type { GatingSignal } from './gating.ts';
2
+
3
+ import { countWords } from './text.ts';
4
+
5
+ // Below this the extraction cannot be the article a reader came for. The
6
+ // measured corpus floor: the smallest healthy extract is gatwick at 435 words;
7
+ // the smallest known-lost one (WIRED's metered barrier) still carries 180.
8
+ export const NEAR_EMPTY_WORDS = 120;
9
+
10
+ // Curated junk signatures over the EXTRACTED text, not the page — gating's
11
+ // METERED_TEXT_RE reads the whole document, which is a different question.
12
+ // Each entry is measured on a real capture: the first two are the Daily Mail
13
+ // player controls that survive inside itemprop="articleBody" (dailymail-a66),
14
+ // the third is the metered barrier WIRED prints when the article itself was
15
+ // truncated server-side.
16
+ const DEBRIS_PROBES: readonly {
17
+ readonly label: string;
18
+ readonly pattern: RegExp;
19
+ }[] = [
20
+ { label: 'player-controls', pattern: /Loaded:\s*\d+%/ },
21
+ { label: 'player-controls', pattern: /Duration Time \d+:\d\d/ },
22
+ {
23
+ label: 'metered-barrier',
24
+ pattern: /read your last free article|subscribe to continue reading/i,
25
+ },
26
+ ];
27
+
28
+ export interface LostEvidence {
29
+ readonly debrisProbes: readonly string[];
30
+ readonly fallbackUsed: boolean;
31
+ readonly gatedReason?: string;
32
+ readonly nearEmpty: boolean;
33
+ readonly reasons: readonly string[];
34
+ readonly wordCount: number;
35
+ }
36
+
37
+ // The verdict the suggest loop fires on. Gating alone never fires it — vendor
38
+ // SDK classes (paywall-ineligible feed badges, piano offer headers) report on
39
+ // free articles, and the pairing is enforced by construction: `gatedReason` is
40
+ // carried as evidence for the prompt and the report, and only
41
+ // fallback/near-empty/debris count as reasons.
42
+ export function assessLostSignal(input: {
43
+ contentText: string;
44
+ fallbackUsed: boolean;
45
+ gated?: GatingSignal;
46
+ }): LostEvidence {
47
+ const wordCount = countWords(input.contentText);
48
+ const nearEmpty = wordCount < NEAR_EMPTY_WORDS;
49
+ const debrisProbes = [
50
+ ...new Set(
51
+ DEBRIS_PROBES.filter(probe => probe.pattern.test(input.contentText)).map(
52
+ probe => probe.label,
53
+ ),
54
+ ),
55
+ ];
56
+ const reasons = [
57
+ ...(input.fallbackUsed ? ['fallback-used'] : []),
58
+ ...(nearEmpty ? ['near-empty'] : []),
59
+ ...debrisProbes.map(label => `debris:${label}`),
60
+ ];
61
+ return {
62
+ debrisProbes,
63
+ fallbackUsed: input.fallbackUsed,
64
+ gatedReason: input.gated?.likely ? input.gated.reason : undefined,
65
+ nearEmpty,
66
+ reasons,
67
+ wordCount,
68
+ };
69
+ }
@@ -0,0 +1,58 @@
1
+ import remarkGfm from 'remark-gfm';
2
+ import remarkParse from 'remark-parse';
3
+ import { unified } from 'unified';
4
+ const processor = unified().use(remarkParse).use(remarkGfm);
5
+ const HEADING_MARKERS = /^#{1,6}\s+/;
6
+ export function parseBlocks(source) {
7
+ const tree = processor.parse(source);
8
+ const blocks = [];
9
+ for (const node of tree.children) {
10
+ const start = node.position?.start.offset;
11
+ const end = node.position?.end.offset;
12
+ if (start === undefined || end === undefined) {
13
+ continue;
14
+ }
15
+ const kind = node.type === 'code'
16
+ ? 'code'
17
+ : node.type === 'heading'
18
+ ? 'heading'
19
+ : 'other';
20
+ const depth = node.type === 'heading' ? node.depth : 0;
21
+ blocks.push({ depth, end, kind, start });
22
+ }
23
+ return blocks;
24
+ }
25
+ export function headingText(raw) {
26
+ return raw.replace(HEADING_MARKERS, '').trim();
27
+ }
28
+ // Hard-cap a run to <= maxChars pieces: pack whole lines, then hard-split any
29
+ // single line that alone exceeds the budget.
30
+ export function hardSplitLines(text, maxChars) {
31
+ const pieces = [];
32
+ let buffer = '';
33
+ function flush() {
34
+ if (buffer) {
35
+ pieces.push(buffer);
36
+ buffer = '';
37
+ }
38
+ }
39
+ for (const line of text.split('\n')) {
40
+ if (line.length > maxChars) {
41
+ flush();
42
+ for (let i = 0; i < line.length; i += maxChars) {
43
+ pieces.push(line.slice(i, i + maxChars));
44
+ }
45
+ continue;
46
+ }
47
+ const candidate = buffer ? `${buffer}\n${line}` : line;
48
+ if (candidate.length > maxChars) {
49
+ flush();
50
+ buffer = line;
51
+ }
52
+ else {
53
+ buffer = candidate;
54
+ }
55
+ }
56
+ flush();
57
+ return pieces;
58
+ }
@@ -0,0 +1,76 @@
1
+ import remarkGfm from 'remark-gfm';
2
+ import remarkParse from 'remark-parse';
3
+ import { unified } from 'unified';
4
+
5
+ export type MarkdownBlockKind = 'code' | 'heading' | 'other';
6
+
7
+ // A top-level CommonMark block located by source offset. remark is used as a
8
+ // boundary finder, never a serializer: callers slice the *original* string at
9
+ // these offsets so truncate/chunk return byte-for-byte slices of the Turndown
10
+ // output rather than a reflowed re-stringification.
11
+ export interface MarkdownBlock {
12
+ readonly depth: number;
13
+ readonly end: number;
14
+ readonly kind: MarkdownBlockKind;
15
+ readonly start: number;
16
+ }
17
+
18
+ const processor = unified().use(remarkParse).use(remarkGfm);
19
+
20
+ const HEADING_MARKERS = /^#{1,6}\s+/;
21
+
22
+ export function parseBlocks(source: string): MarkdownBlock[] {
23
+ const tree = processor.parse(source);
24
+ const blocks: MarkdownBlock[] = [];
25
+ for (const node of tree.children) {
26
+ const start = node.position?.start.offset;
27
+ const end = node.position?.end.offset;
28
+ if (start === undefined || end === undefined) {
29
+ continue;
30
+ }
31
+ const kind: MarkdownBlockKind =
32
+ node.type === 'code'
33
+ ? 'code'
34
+ : node.type === 'heading'
35
+ ? 'heading'
36
+ : 'other';
37
+ const depth = node.type === 'heading' ? node.depth : 0;
38
+ blocks.push({ depth, end, kind, start });
39
+ }
40
+ return blocks;
41
+ }
42
+
43
+ export function headingText(raw: string): string {
44
+ return raw.replace(HEADING_MARKERS, '').trim();
45
+ }
46
+
47
+ // Hard-cap a run to <= maxChars pieces: pack whole lines, then hard-split any
48
+ // single line that alone exceeds the budget.
49
+ export function hardSplitLines(text: string, maxChars: number): string[] {
50
+ const pieces: string[] = [];
51
+ let buffer = '';
52
+ function flush(): void {
53
+ if (buffer) {
54
+ pieces.push(buffer);
55
+ buffer = '';
56
+ }
57
+ }
58
+ for (const line of text.split('\n')) {
59
+ if (line.length > maxChars) {
60
+ flush();
61
+ for (let i = 0; i < line.length; i += maxChars) {
62
+ pieces.push(line.slice(i, i + maxChars));
63
+ }
64
+ continue;
65
+ }
66
+ const candidate = buffer ? `${buffer}\n${line}` : line;
67
+ if (candidate.length > maxChars) {
68
+ flush();
69
+ buffer = line;
70
+ } else {
71
+ buffer = candidate;
72
+ }
73
+ }
74
+ flush();
75
+ return pieces;
76
+ }
@@ -0,0 +1,216 @@
1
+ import { estimateTokens, nonEmpty } from "./text.js";
2
+ const ARTICLE_TYPES = new Set([
3
+ 'Article',
4
+ 'BlogPosting',
5
+ 'NewsArticle',
6
+ 'Report',
7
+ 'ScholarlyArticle',
8
+ 'SocialMediaPosting',
9
+ 'TechArticle',
10
+ 'WebPage',
11
+ ]);
12
+ function first(...values) {
13
+ for (const value of values) {
14
+ const picked = nonEmpty(value);
15
+ if (picked) {
16
+ return picked;
17
+ }
18
+ }
19
+ return undefined;
20
+ }
21
+ function metaProperty(document, property) {
22
+ return nonEmpty(document
23
+ .querySelector(`meta[property="${property}"]`)
24
+ ?.getAttribute('content') ?? undefined);
25
+ }
26
+ function metaName(document, name) {
27
+ return nonEmpty(document.querySelector(`meta[name="${name}"]`)?.getAttribute('content') ??
28
+ undefined);
29
+ }
30
+ // Flatten ld+json scripts (a script may hold an array or a @graph array).
31
+ // Invalid JSON is skipped — never crash extraction over malformed metadata.
32
+ function parseJsonLd(document) {
33
+ const nodes = document.querySelectorAll('script[type="application/ld+json"]');
34
+ const out = [];
35
+ nodes.forEach(node => {
36
+ const raw = node.textContent;
37
+ if (!raw.trim()) {
38
+ return;
39
+ }
40
+ let parsed;
41
+ try {
42
+ parsed = JSON.parse(raw);
43
+ }
44
+ catch {
45
+ return;
46
+ }
47
+ collectObjects(parsed, out);
48
+ });
49
+ return out;
50
+ }
51
+ function collectObjects(value, out) {
52
+ if (!value || typeof value !== 'object') {
53
+ return;
54
+ }
55
+ if (Array.isArray(value)) {
56
+ for (const item of value) {
57
+ collectObjects(item, out);
58
+ }
59
+ return;
60
+ }
61
+ // `in` guard: the index signature types values as JsonLdValue (no undefined),
62
+ // so a `!== undefined` check has no overlap.
63
+ if ('@graph' in value) {
64
+ collectObjects(value['@graph'], out);
65
+ }
66
+ out.push(value);
67
+ }
68
+ function typeMatches(type) {
69
+ const types = Array.isArray(type) ? type : [type];
70
+ return types.some(t => typeof t === 'string' && ARTICLE_TYPES.has(t));
71
+ }
72
+ function pickArticleNode(candidates) {
73
+ if (candidates.length === 0) {
74
+ return undefined;
75
+ }
76
+ return candidates.find(node => typeMatches(node['@type'])) ?? candidates[0];
77
+ }
78
+ // Preference order for the primary structured object: richest schema.org
79
+ // main-entity first. Article types are the tail fallback — Readability already
80
+ // captured the article body, so they are low-value-but-recognizable. Generic
81
+ // graph scaffolding (WebSite/Organization/BreadcrumbList) is intentionally NOT
82
+ // listed: emitting it as `structured` would be noise, and we prefer undefined
83
+ // over a misleading trivial node.
84
+ const STRUCTURED_PRIORITY = [
85
+ 'Recipe',
86
+ 'Product',
87
+ 'Event',
88
+ 'HowTo',
89
+ 'Course',
90
+ 'Movie',
91
+ 'Book',
92
+ 'MusicRecording',
93
+ 'JobPosting',
94
+ 'FAQPage',
95
+ ...ARTICLE_TYPES,
96
+ ];
97
+ function nodeTypeList(node) {
98
+ const type = node['@type'];
99
+ if (Array.isArray(type)) {
100
+ return type.filter((t) => typeof t === 'string');
101
+ }
102
+ return typeof type === 'string' ? [type] : [];
103
+ }
104
+ // Iterate priority types in order so a Recipe wins over an earlier Article
105
+ // node, and document order wins among candidates of the same type.
106
+ function pickStructuredObject(candidates) {
107
+ for (const priorityType of STRUCTURED_PRIORITY) {
108
+ const hit = candidates.find(node => nodeTypeList(node).includes(priorityType));
109
+ if (hit) {
110
+ return hit;
111
+ }
112
+ }
113
+ return undefined;
114
+ }
115
+ // Strip @context (always https://schema.org, pure noise) and normalize a
116
+ // multi-typed node to a "+"-joined string so the host sees a single type label.
117
+ // Data fields are kept verbatim — the host wants the real schema.org object.
118
+ function cleanStructured(obj) {
119
+ const out = {};
120
+ for (const [key, value] of Object.entries(obj)) {
121
+ if (key === '@context') {
122
+ continue;
123
+ }
124
+ if (key === '@type') {
125
+ const types = Array.isArray(value)
126
+ ? value.filter((t) => typeof t === 'string')
127
+ : typeof value === 'string'
128
+ ? [value]
129
+ : [];
130
+ out[key] = types.join('+');
131
+ continue;
132
+ }
133
+ out[key] = value;
134
+ }
135
+ return out;
136
+ }
137
+ // Author may be a string, a Person/Organization object, an array, or a
138
+ // {@list: [...]} container; reduce to a comma-joined byline.
139
+ function resolveJsonLdAuthor(author) {
140
+ const names = [];
141
+ function visit(value) {
142
+ if (typeof value === 'string') {
143
+ const trimmed = value.trim();
144
+ if (trimmed) {
145
+ names.push(trimmed);
146
+ }
147
+ return;
148
+ }
149
+ if (!value || typeof value !== 'object') {
150
+ return;
151
+ }
152
+ if (Array.isArray(value)) {
153
+ value.forEach(visit);
154
+ return;
155
+ }
156
+ // {@list: [...]} is an ordering wrapper; unwrap it.
157
+ const list = value['@list'];
158
+ if (Array.isArray(list)) {
159
+ list.forEach(visit);
160
+ return;
161
+ }
162
+ const name = value.name;
163
+ if (typeof name === 'string') {
164
+ const trimmed = name.trim();
165
+ if (trimmed) {
166
+ names.push(trimmed);
167
+ }
168
+ }
169
+ }
170
+ visit(author);
171
+ return names.length > 0 ? names.join(', ') : undefined;
172
+ }
173
+ function asString(value) {
174
+ return typeof value === 'string' ? nonEmpty(value) : undefined;
175
+ }
176
+ function field(obj, key) {
177
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
178
+ return undefined;
179
+ }
180
+ return obj[key];
181
+ }
182
+ export function resolveMetadata(input) {
183
+ const { document, readability } = input;
184
+ const jsonLdObjects = parseJsonLd(document);
185
+ const jsonLd = pickArticleNode(jsonLdObjects);
186
+ const structuredRaw = pickStructuredObject(jsonLdObjects);
187
+ const htmlLang = nonEmpty(document.documentElement.getAttribute('lang') ?? undefined);
188
+ const titleFromTitleTag = nonEmpty(document.title);
189
+ const title = first(asString(jsonLd?.headline), metaProperty(document, 'og:title'), metaName(document, 'twitter:title'), readability?.title ?? undefined, titleFromTitleTag);
190
+ const byline = first(jsonLd ? resolveJsonLdAuthor(jsonLd.author) : undefined, metaProperty(document, 'article:author'), metaName(document, 'author'), readability?.byline ?? undefined);
191
+ const siteName = first(asString(field(field(jsonLd, 'publisher'), 'name')), metaProperty(document, 'og:site_name'), readability?.siteName ?? undefined);
192
+ const lang = first(asString(jsonLd?.inLanguage), htmlLang, readability?.lang ?? undefined);
193
+ const publishedTime = first(asString(jsonLd?.datePublished), metaProperty(document, 'article:published_time'),
194
+ // First <time datetime> is the canonical publish time when rendered inline.
195
+ nonEmpty(document.querySelector('time[datetime]')?.getAttribute('datetime') ??
196
+ undefined), readability?.publishedTime ?? undefined);
197
+ const excerpt = first(asString(jsonLd?.description), metaProperty(document, 'og:description'), metaName(document, 'twitter:description'), metaName(document, 'description'), readability?.excerpt ?? undefined);
198
+ // canonical/og:url are absolute by spec; returned raw so callers can tell
199
+ // the declared canonical apart from the input baseUrl (origin context only).
200
+ const canonical = first(nonEmpty(document.querySelector('link[rel="canonical"]')?.getAttribute('href') ??
201
+ undefined), metaProperty(document, 'og:url'));
202
+ return {
203
+ title,
204
+ byline,
205
+ siteName,
206
+ lang,
207
+ publishedTime,
208
+ excerpt,
209
+ canonical,
210
+ baseUrl: input.baseUrl,
211
+ wordCount: input.wordCount,
212
+ readingTimeMin: input.readingTimeMin,
213
+ ...estimateTokens(input.textContent),
214
+ ...(structuredRaw ? { structured: cleanStructured(structuredRaw) } : {}),
215
+ };
216
+ }