@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,299 @@
1
+ import { z } from 'zod';
2
+
3
+ import type { ExplainReport } from '../policy/explain.ts';
4
+ import type { ToolHandle } from '../server.ts';
5
+
6
+ import { tool } from '../handler.ts';
7
+ import { buildExplainReport } from '../policy/explain.ts';
8
+ import { fromLocalPath, withSchemaDefaults } from './html-source.ts';
9
+ import {
10
+ type FromHtmlInput,
11
+ localPathField,
12
+ selectorsSchema,
13
+ } from './schemas.ts';
14
+
15
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
16
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
17
+
18
+ // Schemas live with the tool rather than in schemas.ts/output-schema.ts to keep
19
+ // the explain surface in one file (the report shape is explain-specific and not
20
+ // shared with other tools). `selectorsSchema` is reused verbatim from extract so
21
+ // the two tools agree on the include/exclude contract.
22
+ const explainInputShape = {
23
+ localPath: localPathField,
24
+ baseUrl: z
25
+ .url()
26
+ .describe(
27
+ 'Base URL for absolutizing relative links during pagination/gating detection. NEVER fetched — origin context only.',
28
+ )
29
+ .optional(),
30
+ selectors: selectorsSchema,
31
+ topN: z
32
+ .number()
33
+ .int()
34
+ .min(1)
35
+ .max(20)
36
+ .describe(
37
+ 'Maximum number of scored candidate nodes to return (highest first). Default 5.',
38
+ )
39
+ .default(5),
40
+ } as const;
41
+
42
+ const explainInputSchema = z.object(explainInputShape);
43
+
44
+ type ExplainInput = z.infer<typeof explainInputSchema>;
45
+ type ExplainFromHtmlInput = FromHtmlInput<ExplainInput>;
46
+
47
+ const candidateSchema = z
48
+ .object({
49
+ className: z
50
+ .string()
51
+ .describe(
52
+ "The candidate's class attribute (raw, unsplit). Empty string when absent.",
53
+ ),
54
+ id: z.string().describe("The candidate's id attribute, or empty string."),
55
+ score: z
56
+ .number()
57
+ .describe(
58
+ "Readability's actual contentScore for this node (link-density-scaled). Higher is better; the top entry is Readability's raw top candidate before its parent-walking/only-child adjustments.",
59
+ ),
60
+ selector: z
61
+ .string()
62
+ .describe(
63
+ "A CSS-ish hint (tag#id.class1.class2) for locating the node in the host DOM. NOT a unique locator — Readability's score lives on a JS expando invisible to CSS.",
64
+ ),
65
+ tag: z
66
+ .string()
67
+ .describe('Uppercase DOM tag name (e.g. "ARTICLE", "MAIN", "DIV").'),
68
+ textLength: z
69
+ .number()
70
+ .int()
71
+ .min(0)
72
+ .describe(
73
+ 'Trimmed textContent length of the candidate node, for eyeballing content density.',
74
+ ),
75
+ })
76
+ .describe(
77
+ 'One scored candidate node Readability considered, with its real contentScore.',
78
+ );
79
+
80
+ const explainOutputShape = {
81
+ schemaVersion: z
82
+ .literal(1)
83
+ .describe(
84
+ 'Structured-content schema version. Bumps only on breaking shape changes to this object.',
85
+ ),
86
+ content: z
87
+ .string()
88
+ .describe(
89
+ 'Readable rendering of the report (chosen root, ranked candidates, removal counts, gating/pagination, snapshot head) so content[0].text is always scannable.',
90
+ ),
91
+ chosenRoot: candidateSchema
92
+ .nullable()
93
+ .describe(
94
+ 'The highest-scoring candidate Readability computed — its raw top pick before parent-walking/only-child post-processing. Null when Readability scored nothing (e.g. empty input).',
95
+ ),
96
+ candidates: z
97
+ .array(candidateSchema)
98
+ .describe(
99
+ "Scored candidate nodes (highest first), capped at topN. These are Readability's real contentScore values, not a self-computed heuristic.",
100
+ ),
101
+ readerable: z
102
+ .boolean()
103
+ .describe(
104
+ 'Readability isProbablyReaderable verdict on the normalized document.',
105
+ ),
106
+ parseSucceeded: z
107
+ .boolean()
108
+ .describe(
109
+ 'True when reader.parse() returned article content. False signals that `extract` would fall back to its selector cascade.',
110
+ ),
111
+ fallbackUsed: z
112
+ .boolean()
113
+ .describe(
114
+ 'Always false for explain — this tool runs only the Readability path it is diagnosing, never the fallback cascade.',
115
+ ),
116
+ gating: z
117
+ .object({
118
+ likely: z
119
+ .boolean()
120
+ .describe(
121
+ 'True when heuristics strongly suggest the content is paywalled or truncated.',
122
+ ),
123
+ reason: z
124
+ .string()
125
+ .describe(
126
+ 'Short label naming the detected signal (e.g. "paywall overlay").',
127
+ ),
128
+ })
129
+ .nullable()
130
+ .describe(
131
+ 'Likely paywall / gating signal detected before normalization. Null when none.',
132
+ ),
133
+ pagination: z
134
+ .object({
135
+ type: z
136
+ .enum(['infinite', 'paginated'])
137
+ .describe('Kind of pagination signal detected.'),
138
+ nextUrl: z
139
+ .string()
140
+ .optional()
141
+ .describe(
142
+ 'Absolute URL of the detected next page (paginated only). Never fetched.',
143
+ ),
144
+ selector: z
145
+ .string()
146
+ .optional()
147
+ .describe(
148
+ 'CSS selector of the load-more / infinite-scroll sentinel (infinite only).',
149
+ ),
150
+ })
151
+ .nullable()
152
+ .describe('Detected pagination / infinite-scroll signal. Null when none.'),
153
+ removedNodes: z
154
+ .object({
155
+ boilerplate: z
156
+ .number()
157
+ .int()
158
+ .min(0)
159
+ .describe(
160
+ 'Boilerplate blocks (related-posts, newsletter signup) stripped before Readability.',
161
+ ),
162
+ chrome: z
163
+ .number()
164
+ .int()
165
+ .min(0)
166
+ .describe(
167
+ 'Browser-chrome nodes stripped (scrollbars, consent banners, overlays).',
168
+ ),
169
+ total: z
170
+ .number()
171
+ .int()
172
+ .min(0)
173
+ .describe(
174
+ 'Net element count removed across the whole pipeline (delta vs. the parsed document).',
175
+ ),
176
+ })
177
+ .describe(
178
+ 'Breakdown of nodes removed before Readability saw the document, reused from the extract diagnostics path.',
179
+ ),
180
+ snapshot: z
181
+ .object({
182
+ html: z
183
+ .string()
184
+ .describe(
185
+ 'The sanitized-by-normalization (post chrome/boilerplate/script strip) HTML fed to Readability — "what Readability saw". Not DOMPurify-sanitized (that runs on Readability\'s output in `extract`), so it may still carry inline event handlers (`onerror`/`onclick`/…); it is diagnostic data — do not render verbatim.',
186
+ ),
187
+ truncated: z
188
+ .boolean()
189
+ .describe(
190
+ 'True when the snapshot was cut at snapshotMaxChars (default 4000).',
191
+ ),
192
+ })
193
+ .describe('Pre-Readability HTML snapshot of the normalized document body.'),
194
+ } as const;
195
+
196
+ const explainOutput = z.object(explainOutputShape);
197
+
198
+ function formatGating(report: ExplainReport): string {
199
+ const g = report.gating;
200
+ if (!g) {
201
+ return 'none';
202
+ }
203
+ return `${g.reason}${g.likely ? '' : ' (weak)'}`;
204
+ }
205
+
206
+ function formatPagination(report: ExplainReport): string {
207
+ const p = report.pagination;
208
+ if (!p) {
209
+ return 'none';
210
+ }
211
+ if (p.type === 'paginated') {
212
+ return `paginated -> ${p.nextUrl ?? '(no href)'}`;
213
+ }
214
+ return `infinite (${p.selector ?? 'sentinel'})`;
215
+ }
216
+
217
+ function renderText(report: ExplainReport): string {
218
+ const lines: string[] = [];
219
+ const root = report.chosenRoot;
220
+ lines.push(
221
+ `readerable: ${report.readerable ? 'yes' : 'no'} parse: ${report.parseSucceeded ? 'ok' : 'fail'} fallback: ${report.fallbackUsed ? 'yes' : 'no'}`,
222
+ );
223
+ lines.push(
224
+ `chosen root: ${root ? `${root.selector} (score ${root.score.toFixed(2)}, ${root.textLength} chars)` : '(no candidate scored)'}`,
225
+ );
226
+ lines.push(`top candidates (${report.candidates.length}):`);
227
+ if (report.candidates.length === 0) {
228
+ lines.push(' (none)');
229
+ }
230
+ report.candidates.forEach((c, i) => {
231
+ lines.push(
232
+ ` ${i + 1}. ${c.selector} score ${c.score.toFixed(2)} (${c.textLength} chars)`,
233
+ );
234
+ });
235
+ const r = report.removedNodes;
236
+ lines.push(
237
+ `removed: total=${r.total} (chrome=${r.chrome}, boilerplate=${r.boilerplate})`,
238
+ );
239
+ lines.push(`gating: ${formatGating(report)}`);
240
+ lines.push(`pagination: ${formatPagination(report)}`);
241
+ lines.push(
242
+ `snapshot (${report.snapshot.html.length} chars${report.snapshot.truncated ? ', truncated' : ''}):`,
243
+ );
244
+ lines.push(report.snapshot.html);
245
+ return lines.join('\n');
246
+ }
247
+
248
+ export function explainFromHtml(
249
+ input: Readonly<ExplainFromHtmlInput>,
250
+ ): CallToolResult {
251
+ const { html, baseUrl, selectors, topN } = withSchemaDefaults(
252
+ explainInputSchema,
253
+ input,
254
+ );
255
+ const report = buildExplainReport({
256
+ html,
257
+ selectors,
258
+ topN,
259
+ baseUrl,
260
+ });
261
+ const content = renderText(report);
262
+ return {
263
+ content: [{ text: content, type: 'text' }],
264
+ structuredContent: {
265
+ schemaVersion: 1,
266
+ content,
267
+ chosenRoot: report.chosenRoot,
268
+ candidates: report.candidates,
269
+ readerable: report.readerable,
270
+ parseSucceeded: report.parseSucceeded,
271
+ fallbackUsed: report.fallbackUsed,
272
+ gating: report.gating ?? null,
273
+ pagination: report.pagination ?? null,
274
+ removedNodes: report.removedNodes,
275
+ snapshot: report.snapshot,
276
+ },
277
+ };
278
+ }
279
+
280
+ export const EXPLAIN_TOOL_DESCRIPTION = `Post-mortem diagnostics for extraction: shows WHY Readability picked what it picked. Returns the chosen root, the ranked candidate nodes with their REAL Readability contentScore values (read off the DOM expando Readability stamps during scoring), a categorized removed-nodes breakdown, gating/pagination signals, and a snapshot of the normalized HTML fed to Readability. Runs the same normalize + Readability pipeline as \`extract\` (no fallback cascade, no Turndown). The server fetches nothing: \`localPath\` is the only source, and \`baseUrl\` (optional) is origin context only.`;
281
+
282
+ export const explainHandler = tool(
283
+ 'explain',
284
+ input => explainFromHtml(fromLocalPath(input)),
285
+ explainInputSchema,
286
+ );
287
+
288
+ export function registerExplainTool(server: McpServer): ToolHandle {
289
+ return server.registerTool(
290
+ 'explain',
291
+ {
292
+ title: 'Explain why Readability picked what it picked',
293
+ description: EXPLAIN_TOOL_DESCRIPTION,
294
+ inputSchema: explainInputShape,
295
+ outputSchema: explainOutput,
296
+ },
297
+ explainHandler,
298
+ );
299
+ }
@@ -0,0 +1,263 @@
1
+ import { ExtractionError } from "../errors.js";
2
+ import { tool } from "../handler.js";
3
+ import { logger } from "../logger.js";
4
+ import { formatPayload } from "../output/format.js";
5
+ import { buildDocument } from "../pipeline/dom.js";
6
+ import { applySelectors, canonicalizeCodeBlocks, normalizeDocument, resolveLazyImages, } from "../pipeline/normalize.js";
7
+ import { isReaderable, parseArticle } from "../pipeline/readability.js";
8
+ import { resolveReadabilityOptions } from "../pipeline/resolver.js";
9
+ import { sanitizeHtml } from "../pipeline/sanitize.js";
10
+ import { toMarkdown } from "../pipeline/turndown.js";
11
+ import { chunkMarkdown } from "../policy/chunk.js";
12
+ import { assembleDiagnostics, TraceCollector } from "../policy/diagnostics.js";
13
+ import { extractViaFallback } from "../policy/fallback.js";
14
+ import { detectGating } from "../policy/gating.js";
15
+ import { collectImageInventory } from "../policy/images.js";
16
+ import { resolveMetadata } from "../policy/metadata.js";
17
+ import { detectPagination } from "../policy/pagination.js";
18
+ import { resolvePreset } from "../policy/presets.js";
19
+ import { computeTextMetrics } from "../policy/text.js";
20
+ import { truncateMarkdown } from "../policy/truncate.js";
21
+ import * as cache from "../resources.js";
22
+ import { fromLocalPath, withSchemaDefaults } from "./html-source.js";
23
+ import { outputSchemaShape } from "./output-schema.js";
24
+ import { extractInputSchema, extractInputShape, } from "./schemas.js";
25
+ // Names the extraction path, not a DOM tag — Readability doesn't expose the source node.
26
+ const EXTRACTED_NODE = 'readability';
27
+ export function extractArticle(rawArgs) {
28
+ return extractArticleFromHtml(fromLocalPath(extractInputSchema.parse(rawArgs)));
29
+ }
30
+ export function extractArticleFromHtml(input) {
31
+ const merged = withSchemaDefaults(extractInputSchema, input);
32
+ const { html, baseUrl, cache: useCache, selectors, extraction, minArticleLength, maxNodes, keepClasses, readabilityOverrides, format, metadataMode, gfm, headingStyle, codeBlockStyle, images, sanitize: shouldSanitize, maxChars, wordsPerMinute, cleanChrome, tables, chunk, imageInventory, debug, resolvePreset: presetsEnabled = true, } = merged;
33
+ // Cache hit short-circuits the pipeline. A clone is returned so the cache
34
+ // field can be overwritten to reflect *this* call's hashes (normalizedHash is
35
+ // stable across re-renders, but originalHash differs by nonce/CSP/…).
36
+ if (useCache) {
37
+ const hit = cache.lookup(html, merged);
38
+ if (hit) {
39
+ const cloned = JSON.parse(JSON.stringify(hit.entry.structuredContent));
40
+ const structuredContent = {
41
+ ...cloned,
42
+ diagnostics: {
43
+ ...cloned.diagnostics,
44
+ cache: {
45
+ hit: true,
46
+ normalizedHash: hit.normalizedHash,
47
+ originalHash: hit.originalHash,
48
+ },
49
+ },
50
+ };
51
+ return {
52
+ content: [{ text: hit.entry.contentText, type: 'text' }],
53
+ structuredContent,
54
+ };
55
+ }
56
+ }
57
+ // Stages are timed at the orchestrator boundary so they stay non-overlapping
58
+ // and sum to the pipeline's wall-clock — stripConsent/absolutize live inside
59
+ // normalize/turndown respectively and aren't carved out as their own stages.
60
+ const trace = new TraceCollector(debug);
61
+ const { document, window } = buildDocument(html, baseUrl);
62
+ // Paywall overlays are stripped by normalizeDocument's stripChrome (a Piano
63
+ // modal is role="dialog" + full-viewport fixed), so gating must be detected
64
+ // before normalization. Pagination chrome survives stripChrome and is detected after.
65
+ const { gating, documentElementCount, normalizeCounts, imagesResolved, pagination, preset, } = trace.run('normalize', () => {
66
+ const gating = detectGating(document);
67
+ const documentElementCount = document.querySelectorAll('*').length;
68
+ const normalizeCounts = normalizeDocument(document, { cleanChrome });
69
+ const imagesResolved = resolveLazyImages(document);
70
+ // Detect before applySelectors: a caller's selectors.include could scope the
71
+ // body and hide pagination chrome, but "more content exists" is still true.
72
+ const pagination = detectPagination(document, baseUrl);
73
+ const resolution = presetsEnabled
74
+ ? resolvePreset(document, baseUrl, selectors !== undefined)
75
+ : undefined;
76
+ applySelectors(document, resolution?.scope ?? selectors);
77
+ const codeBlocksCanonicalized = canonicalizeCodeBlocks(document);
78
+ if (codeBlocksCanonicalized > 0) {
79
+ logger.debug(`canonicalized ${codeBlocksCanonicalized} code-block language tag(s)`);
80
+ }
81
+ return {
82
+ documentElementCount,
83
+ gating,
84
+ imagesResolved,
85
+ normalizeCounts,
86
+ pagination,
87
+ preset: resolution?.signal,
88
+ };
89
+ });
90
+ const { readerable, article } = trace.run('readability', () => {
91
+ const readerable = isReaderable(document);
92
+ const readabilityOptions = resolveReadabilityOptions({
93
+ extraction,
94
+ keepClasses,
95
+ maxNodes,
96
+ minArticleLength,
97
+ readabilityOverrides,
98
+ });
99
+ const article = parseArticle(document, readabilityOptions);
100
+ return { article, readerable };
101
+ });
102
+ let markdown;
103
+ let sanitizedHtml;
104
+ let textContent;
105
+ let extractedNode;
106
+ let fallbackUsed;
107
+ let sanitizeCounts;
108
+ if (article?.content) {
109
+ extractedNode = EXTRACTED_NODE;
110
+ fallbackUsed = false;
111
+ textContent = article.textContent ?? '';
112
+ const articleHtml = article.content;
113
+ const sanitized = trace.run('sanitize', () => {
114
+ if (!shouldSanitize) {
115
+ return { counts: { iframes: 0, scripts: 0 }, html: articleHtml };
116
+ }
117
+ const res = sanitizeHtml(articleHtml, window);
118
+ return {
119
+ counts: { iframes: res.iframesRemoved, scripts: res.scriptsRemoved },
120
+ html: res.html,
121
+ };
122
+ });
123
+ sanitizedHtml = sanitized.html;
124
+ sanitizeCounts = sanitized.counts;
125
+ markdown = trace.run('turndown', () => toMarkdown(sanitizedHtml, {
126
+ codeBlockStyle,
127
+ gfm,
128
+ headingStyle,
129
+ images,
130
+ tables,
131
+ baseUrl,
132
+ }));
133
+ }
134
+ else {
135
+ // Cascade only on parse failure, not on isProbablyReaderable.
136
+ // Fallback owns sanitize + turndown internally, so the two stages collapse
137
+ // into one timed block here — keeping the trace non-overlapping.
138
+ const fallback = trace.run('fallback', () => extractViaFallback(document, {
139
+ codeBlockStyle,
140
+ gfm,
141
+ headingStyle,
142
+ images,
143
+ sanitize: shouldSanitize,
144
+ tables,
145
+ baseUrl,
146
+ window,
147
+ }));
148
+ if (!fallback) {
149
+ throw new ExtractionError('Readability returned no article and the selector cascade yielded no usable content.');
150
+ }
151
+ extractedNode = fallback.rootSelector;
152
+ fallbackUsed = true;
153
+ markdown = fallback.markdown;
154
+ sanitizedHtml = fallback.sanitizedHtml;
155
+ textContent = fallback.textContent;
156
+ sanitizeCounts = fallback.sanitization;
157
+ }
158
+ const { metadata } = trace.run('metadata', () => {
159
+ const { wordCount, readingTimeMin } = computeTextMetrics(textContent, wordsPerMinute);
160
+ const metadata = resolveMetadata({
161
+ document,
162
+ readability: article,
163
+ readingTimeMin,
164
+ textContent,
165
+ baseUrl,
166
+ wordCount,
167
+ });
168
+ return { metadata };
169
+ });
170
+ const sanitization = {
171
+ iframes: normalizeCounts.iframes + sanitizeCounts.iframes,
172
+ scripts: normalizeCounts.scripts + sanitizeCounts.scripts,
173
+ };
174
+ const baseDiagnostics = assembleDiagnostics({
175
+ articleHtml: sanitizedHtml,
176
+ boilerplateRemoved: normalizeCounts.boilerplateRemoved,
177
+ chromeRemoved: normalizeCounts.chromeRemoved,
178
+ documentElementCount,
179
+ extractedNode,
180
+ fallbackUsed,
181
+ gated: gating,
182
+ imagesResolved,
183
+ pagination,
184
+ preset,
185
+ readerable,
186
+ sanitization,
187
+ trace: trace.collect(),
188
+ truncated: false,
189
+ window,
190
+ });
191
+ let payload = formatPayload({
192
+ diagnostics: baseDiagnostics,
193
+ format,
194
+ markdown,
195
+ metadata,
196
+ metadataMode,
197
+ sanitizedHtml,
198
+ textContent,
199
+ });
200
+ let truncated = false;
201
+ if (maxChars !== undefined && (format === 'markdown' || format === 'text')) {
202
+ const res = truncateMarkdown(payload, maxChars);
203
+ payload = res.text;
204
+ truncated = res.truncated;
205
+ }
206
+ const diagnostics = truncated
207
+ ? { ...baseDiagnostics, truncated }
208
+ : baseDiagnostics;
209
+ // Chunk the final payload (post-format, post-truncation) so token counts
210
+ // reflect what the host sees in content[0].text. HTML/JSON formats carry no
211
+ // markdown body to slice, so `chunks` stays unset for them.
212
+ const chunks = chunk && (format === 'markdown' || format === 'text')
213
+ ? chunkMarkdown(payload, chunk)
214
+ : undefined;
215
+ // Runs against sanitizedHtml so the inventory reflects exactly what the host
216
+ // sees in content[0].text — post-sanitization, post-lazy-resolution.
217
+ const imageInventoryEntries = imageInventory
218
+ ? collectImageInventory(sanitizedHtml, window, baseUrl)
219
+ : undefined;
220
+ const baseStructuredContent = {
221
+ schemaVersion: 1,
222
+ content: payload,
223
+ metadata,
224
+ diagnostics,
225
+ ...(chunks ? { chunks } : {}),
226
+ ...(imageInventoryEntries ? { images: imageInventoryEntries } : {}),
227
+ };
228
+ // On cache miss (when cache:true), persist a cache-agnostic copy so the
229
+ // stored entry never carries a stale hit/miss verdict, then surface the
230
+ // signal on the returned object only.
231
+ let structuredContent = baseStructuredContent;
232
+ if (useCache) {
233
+ const stored = cache.storeResult(html, merged, {
234
+ contentText: payload,
235
+ structuredContent: baseStructuredContent,
236
+ });
237
+ structuredContent = {
238
+ ...baseStructuredContent,
239
+ diagnostics: {
240
+ ...diagnostics,
241
+ cache: {
242
+ hit: false,
243
+ normalizedHash: stored.normalizedHash,
244
+ originalHash: stored.originalHash,
245
+ },
246
+ },
247
+ };
248
+ }
249
+ return {
250
+ content: [{ text: payload, type: 'text' }],
251
+ structuredContent,
252
+ };
253
+ }
254
+ export const EXTRACT_TOOL_DESCRIPTION = `Extract the main article from already-rendered (post-JavaScript) HTML and return clean Markdown plus metadata and diagnostics. The server fetches nothing: \`localPath\` (a file holding the rendered HTML, e.g. \`document.documentElement.outerHTML\` written to disk by a browser/devtools capture) is the only source, and \`baseUrl\` (optional) is used solely to absolutize relative links.`;
255
+ export const extractHandler = tool('extract', extractArticle);
256
+ export function registerExtractTool(server) {
257
+ return server.registerTool('extract', {
258
+ title: 'Extract article to Markdown',
259
+ description: EXTRACT_TOOL_DESCRIPTION,
260
+ inputSchema: extractInputShape,
261
+ outputSchema: outputSchemaShape,
262
+ }, extractHandler);
263
+ }