@v1nvn/readability 0.43.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +3 -3
  2. package/package.json +7 -9
  3. package/src/cli.ts +134 -0
  4. package/src/config.ts +42 -0
  5. package/src/dev.ts +77 -0
  6. package/src/errors.ts +19 -0
  7. package/src/handler.ts +8 -0
  8. package/src/host-sampling.ts +86 -0
  9. package/src/index.ts +27 -0
  10. package/src/logger.ts +3 -0
  11. package/src/output/format.ts +132 -0
  12. package/src/pipeline/cell-text.ts +56 -0
  13. package/src/pipeline/context.ts +88 -0
  14. package/src/pipeline/dom.ts +23 -0
  15. package/src/pipeline/footnotes.ts +181 -0
  16. package/src/pipeline/math.ts +130 -0
  17. package/src/pipeline/normalize.ts +594 -0
  18. package/src/pipeline/readability.ts +36 -0
  19. package/src/pipeline/resolver.ts +143 -0
  20. package/src/pipeline/sanitize.ts +42 -0
  21. package/src/pipeline/tables.ts +286 -0
  22. package/src/pipeline/turndown.ts +191 -0
  23. package/src/pipeline/urls.ts +13 -0
  24. package/src/policy/chunk.ts +482 -0
  25. package/src/policy/diagnostics.ts +96 -0
  26. package/src/policy/explain.ts +182 -0
  27. package/src/policy/fallback.ts +110 -0
  28. package/src/policy/gating.ts +106 -0
  29. package/src/policy/grid-detector.ts +313 -0
  30. package/src/policy/identifiers.ts +75 -0
  31. package/src/policy/images.ts +64 -0
  32. package/src/policy/list-detector.ts +308 -0
  33. package/src/policy/lost-signal.ts +69 -0
  34. package/src/policy/markdown.ts +76 -0
  35. package/src/policy/metadata.ts +312 -0
  36. package/src/policy/outline-chains.ts +215 -0
  37. package/src/policy/outline.ts +89 -0
  38. package/src/policy/pagination.ts +104 -0
  39. package/src/policy/presets.ts +142 -0
  40. package/src/policy/section.ts +127 -0
  41. package/src/policy/selector-lint.ts +186 -0
  42. package/src/policy/sibling-scan.ts +99 -0
  43. package/src/policy/text.ts +36 -0
  44. package/src/policy/truncate.ts +59 -0
  45. package/src/preset-cache.ts +221 -0
  46. package/src/resources.ts +317 -0
  47. package/src/sampling.ts +74 -0
  48. package/src/server.ts +85 -0
  49. package/src/tools/chunk_text.ts +65 -0
  50. package/src/tools/explain.ts +299 -0
  51. package/src/tools/extract.ts +368 -0
  52. package/src/tools/extract_grid.ts +90 -0
  53. package/src/tools/extract_links.ts +138 -0
  54. package/src/tools/extract_list.ts +97 -0
  55. package/src/tools/extract_metadata.ts +102 -0
  56. package/src/tools/extract_section.ts +84 -0
  57. package/src/tools/extract_tables.ts +101 -0
  58. package/src/tools/html-source.ts +40 -0
  59. package/src/tools/html_to_markdown.ts +180 -0
  60. package/src/tools/outline.ts +77 -0
  61. package/src/tools/output-schema.ts +715 -0
  62. package/src/tools/schemas.ts +442 -0
  63. package/src/tools/suggest-preset.ts +708 -0
  64. package/src/types/jsdom.d.ts +15 -0
  65. package/src/types/turndown-plugin-gfm.d.ts +11 -0
  66. package/dist/assets/cli-BE_Ml3rh.js +0 -76
  67. package/dist/assets/cli-BE_Ml3rh.js.map +0 -1
  68. package/dist/assets/extract-Cyv5qfGN.js +0 -2880
  69. package/dist/assets/extract-Cyv5qfGN.js.map +0 -1
  70. package/dist/index.js +0 -2267
  71. package/dist/index.js.map +0 -1
@@ -0,0 +1,102 @@
1
+ import type { Metadata } from '../pipeline/context.ts';
2
+ import type { ToolHandle } from '../server.ts';
3
+
4
+ import { tool } from '../handler.ts';
5
+ import { buildDocument } from '../pipeline/dom.ts';
6
+ import { normalizeDocument } from '../pipeline/normalize.ts';
7
+ import { resolveMetadata } from '../policy/metadata.ts';
8
+ import { fromLocalPath } from './html-source.ts';
9
+ import { extractMetadataOutputShape } from './output-schema.ts';
10
+ import {
11
+ type ExtractMetadataFromHtmlInput,
12
+ extractMetadataInputSchema,
13
+ extractMetadataInputShape,
14
+ } from './schemas.ts';
15
+
16
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
17
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
18
+
19
+ // Bibliographic fields only — wordCount/readingTimeMin/tokenEstimate are
20
+ // meaningless without the extracted body, so a metadata-only caller never
21
+ // sees wordCount: 0.
22
+ const BIBLIOGRAPHIC_KEYS = [
23
+ 'title',
24
+ 'byline',
25
+ 'siteName',
26
+ 'lang',
27
+ 'publishedTime',
28
+ 'excerpt',
29
+ 'canonical',
30
+ 'baseUrl',
31
+ ] as const satisfies readonly (keyof Metadata)[];
32
+
33
+ function pickBibliographic(
34
+ metadata: Readonly<Metadata>,
35
+ ): Record<string, string> {
36
+ const out: Record<string, string> = {};
37
+ for (const key of BIBLIOGRAPHIC_KEYS) {
38
+ const value = metadata[key];
39
+ if (value !== undefined) {
40
+ out[key] = value;
41
+ }
42
+ }
43
+ return out;
44
+ }
45
+
46
+ // One line per present field; never blank so content[0].text is always scannable.
47
+ function renderMetadataLines(
48
+ metadata: Readonly<Record<string, string>>,
49
+ ): string {
50
+ const lines = Object.entries(metadata).map(
51
+ ([key, value]) => `${key}: ${value}`,
52
+ );
53
+ return lines.length > 0 ? lines.join('\n') : '(no metadata found)';
54
+ }
55
+
56
+ export function extractMetadataDocumentFromHtml(
57
+ input: Readonly<ExtractMetadataFromHtmlInput>,
58
+ ): CallToolResult {
59
+ const { html, baseUrl } = input;
60
+
61
+ const { document } = buildDocument(html, baseUrl);
62
+ normalizeDocument(document);
63
+ const resolved = resolveMetadata({
64
+ document,
65
+ textContent: '',
66
+ wordCount: 0,
67
+ readingTimeMin: 0,
68
+ baseUrl,
69
+ });
70
+
71
+ const metadata = pickBibliographic(resolved);
72
+ const content = renderMetadataLines(metadata);
73
+ return {
74
+ content: [{ text: content, type: 'text' }],
75
+ structuredContent: {
76
+ schemaVersion: 1,
77
+ content,
78
+ metadata,
79
+ },
80
+ };
81
+ }
82
+
83
+ export const EXTRACT_METADATA_TOOL_DESCRIPTION = `Return only the bibliographic metadata (title, byline, siteName, lang, publishedTime, excerpt, canonical, baseUrl) of already-rendered (post-JavaScript) HTML without running Readability/Turndown — a fast pre-check for crawlers and citation. Resolves the same metadata cascade as \`extract\` (JSON-LD → OpenGraph → Twitter → <meta> → <time> → <title>), plus <link rel="canonical"> → og:url. The server fetches nothing: \`localPath\` is the only source, and \`baseUrl\` is origin context only (never fetched).`;
84
+
85
+ export const extractMetadataHandler = tool(
86
+ 'extract_metadata',
87
+ input => extractMetadataDocumentFromHtml(fromLocalPath(input)),
88
+ extractMetadataInputSchema,
89
+ );
90
+
91
+ export function registerExtractMetadataTool(server: McpServer): ToolHandle {
92
+ return server.registerTool(
93
+ 'extract_metadata',
94
+ {
95
+ title: 'Extract metadata only (no Readability)',
96
+ description: EXTRACT_METADATA_TOOL_DESCRIPTION,
97
+ inputSchema: extractMetadataInputShape,
98
+ outputSchema: extractMetadataOutputShape,
99
+ },
100
+ extractMetadataHandler,
101
+ );
102
+ }
@@ -0,0 +1,84 @@
1
+ import type { ToolHandle } from '../server.ts';
2
+
3
+ import { ExtractionError } from '../errors.ts';
4
+ import { tool } from '../handler.ts';
5
+ import { buildDocument } from '../pipeline/dom.ts';
6
+ import { normalizeDocument } from '../pipeline/normalize.ts';
7
+ import { scopeToHeading } from '../policy/section.ts';
8
+ import { extractArticleFromHtml } from './extract.ts';
9
+ import { fromLocalPath } from './html-source.ts';
10
+ import { outputSchemaShape } from './output-schema.ts';
11
+ import {
12
+ type ExtractSectionFromHtmlInput,
13
+ extractSectionInputSchema,
14
+ extractSectionInputShape,
15
+ } from './schemas.ts';
16
+
17
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
18
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
19
+
20
+ const SECTION_SCOPE_SELECTOR = '[data-rdrm-section-scope]';
21
+
22
+ export function extractSectionFromHtml(
23
+ input: Readonly<ExtractSectionFromHtmlInput>,
24
+ ): CallToolResult {
25
+ const { html, baseUrl, selector, heading } = input;
26
+
27
+ if (selector !== undefined) {
28
+ return extractArticleFromHtml({
29
+ html,
30
+ baseUrl,
31
+ selectors: { include: selector },
32
+ resolvePreset: false,
33
+ });
34
+ }
35
+ // The superRefine on the schema enforces selector/heading XOR, so reaching
36
+ // here means heading is set — but its declared type is still optional, so
37
+ // narrow explicitly rather than asserting.
38
+ if (heading === undefined) {
39
+ throw new ExtractionError(
40
+ 'Provide exactly one of `selector` or `heading`.',
41
+ );
42
+ }
43
+
44
+ // Heading mode: wrap the matched subtree, re-serialize, and route through
45
+ // extractArticleFromHtml's selectors.include so this tool stays a thin
46
+ // resolver over the existing extraction pipeline — no forked extraction logic.
47
+ // The DOM is parsed+normalized twice: here to scope against a normalized
48
+ // DOM, and again inside the extract worker — which owns detectGating-before-
49
+ // normalize, resolveLazyImages, and applySelectors. Folding scoping into
50
+ // that pipeline would breach its invariants for a non-hot path; accepted.
51
+ const { document } = buildDocument(html, baseUrl);
52
+ normalizeDocument(document);
53
+ if (!scopeToHeading(document, heading)) {
54
+ throw new ExtractionError(`no heading matched: ${heading}`);
55
+ }
56
+ const scoped = `<!DOCTYPE html><html><head></head><body>${document.body.innerHTML}</body></html>`;
57
+ return extractArticleFromHtml({
58
+ html: scoped,
59
+ baseUrl,
60
+ selectors: { include: SECTION_SCOPE_SELECTOR },
61
+ resolvePreset: false,
62
+ });
63
+ }
64
+
65
+ export const EXTRACT_SECTION_TOOL_DESCRIPTION = `Extract one section of an already-rendered (post-JavaScript) HTML document and return its Markdown + metadata + diagnostics — a thin resolver over extract’s \`selectors.include\` path, not a new extractor. Pick the section by CSS \`selector\` (passed straight through) OR by \`heading\` text (case-insensitive, first match wins; the section spans from the matched heading to the next same-or-higher-level heading). Exactly one of \`selector\`/\`heading\` is required. The server fetches nothing: \`localPath\` is the only source, and \`baseUrl\` (optional) is origin context only (never fetched).`;
66
+
67
+ export const extractSectionHandler = tool(
68
+ 'extract_section',
69
+ input => extractSectionFromHtml(fromLocalPath(input)),
70
+ extractSectionInputSchema,
71
+ );
72
+
73
+ export function registerExtractSectionTool(server: McpServer): ToolHandle {
74
+ return server.registerTool(
75
+ 'extract_section',
76
+ {
77
+ title: 'Extract one section by selector or heading',
78
+ description: EXTRACT_SECTION_TOOL_DESCRIPTION,
79
+ inputSchema: extractSectionInputShape,
80
+ outputSchema: outputSchemaShape,
81
+ },
82
+ extractSectionHandler,
83
+ );
84
+ }
@@ -0,0 +1,101 @@
1
+ import type { ToolHandle } from '../server.ts';
2
+
3
+ import { tool } from '../handler.ts';
4
+ import { buildDocument } from '../pipeline/dom.ts';
5
+ import { applySelectors } from '../pipeline/normalize.ts';
6
+ import {
7
+ parseTableMatrix,
8
+ renderTable,
9
+ resolveHeaderKeys,
10
+ } from '../pipeline/tables.ts';
11
+ import { fromLocalPath, withSchemaDefaults } from './html-source.ts';
12
+ import { extractTablesOutputShape } from './output-schema.ts';
13
+ import {
14
+ type ExtractTablesFromHtmlInput,
15
+ extractTablesInputSchema,
16
+ extractTablesInputShape,
17
+ } from './schemas.ts';
18
+
19
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
20
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
21
+
22
+ interface ExtractedTable {
23
+ readonly cols: number;
24
+ readonly index: number;
25
+ readonly markdown: string;
26
+ readonly rows: number;
27
+ }
28
+
29
+ const NO_TABLES = '(no tables found)';
30
+
31
+ export function extractTables(rawArgs: unknown): CallToolResult {
32
+ return extractTablesFromHtml(
33
+ fromLocalPath(extractTablesInputSchema.parse(rawArgs)),
34
+ );
35
+ }
36
+
37
+ export function extractTablesFromHtml(
38
+ input: Readonly<ExtractTablesFromHtmlInput>,
39
+ ): CallToolResult {
40
+ const { html, baseUrl, format, selectors } = withSchemaDefaults(
41
+ extractTablesInputSchema,
42
+ input,
43
+ );
44
+
45
+ // Skip normalizeDocument/Readability on purpose: this tool exists to reach
46
+ // tables that live outside the scored article (nav, aside, boilerplate), which
47
+ // those stages would discard. Table structure is static HTML, so the matrix
48
+ // walk is unaffected by unsanitized scripts/styles.
49
+ const { document } = buildDocument(html, baseUrl);
50
+ applySelectors(document, selectors);
51
+
52
+ const tables: ExtractedTable[] = [];
53
+ let index = 0;
54
+ for (const table of document.querySelectorAll('table')) {
55
+ const matrix = parseTableMatrix(table);
56
+ if (matrix.length === 0) {
57
+ continue;
58
+ }
59
+ const keys =
60
+ format === 'json' ? resolveHeaderKeys(table, matrix) : undefined;
61
+ const markdown = renderTable(matrix, format, keys);
62
+ tables.push({
63
+ index,
64
+ rows: matrix.length,
65
+ cols: matrix[0]?.length ?? 0,
66
+ markdown,
67
+ });
68
+ index++;
69
+ }
70
+
71
+ const content =
72
+ tables.length > 0
73
+ ? tables.map(entry => entry.markdown).join('\n\n')
74
+ : NO_TABLES;
75
+ return {
76
+ content: [{ text: content, type: 'text' }],
77
+ structuredContent: {
78
+ schemaVersion: 1,
79
+ content,
80
+ tables,
81
+ metadata: { baseUrl, format, tableCount: tables.length },
82
+ },
83
+ };
84
+ }
85
+
86
+ export const EXTRACT_TABLES_TOOL_DESCRIPTION = `Extract every <table> on the page from already-rendered (post-JavaScript) HTML and return each as GFM / CSV / JSON (caller picks). Runs no Readability, Turndown, or sanitization — a page-wide \`querySelectorAll('table')\` walk in front of the same rowspan/colspan-aware matrix serializer used by the \`tables\` option on \`extract\`. Captures tables outside the article body (nav, aside, boilerplate) that the \`tables\` option never sees. The server fetches nothing: \`localPath\` is the only source, and \`baseUrl\` (optional) is origin context only (never fetched).`;
87
+
88
+ export const extractTablesHandler = tool('extract_tables', extractTables);
89
+
90
+ export function registerExtractTablesTool(server: McpServer): ToolHandle {
91
+ return server.registerTool(
92
+ 'extract_tables',
93
+ {
94
+ title: 'Extract every table on the page',
95
+ description: EXTRACT_TABLES_TOOL_DESCRIPTION,
96
+ inputSchema: extractTablesInputShape,
97
+ outputSchema: extractTablesOutputShape,
98
+ },
99
+ extractTablesHandler,
100
+ );
101
+ }
@@ -0,0 +1,40 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ import type { FromHtmlInput } from './schemas.ts';
4
+
5
+ export function readHtmlFile(localPath: string): string {
6
+ const raw = readFileSync(localPath, 'utf8');
7
+ const trimmed = raw.trim();
8
+ // A top-level JSON string scalar is a chrome-devtools capture (its JSON-encoded
9
+ // return value written to disk), not raw markup — unwrap it before parsing.
10
+ if (trimmed.length < 2 || trimmed.at(0) !== '"' || trimmed.at(-1) !== '"') {
11
+ return raw;
12
+ }
13
+ try {
14
+ const parsed: unknown = JSON.parse(trimmed);
15
+ return typeof parsed === 'string' ? parsed : raw;
16
+ } catch {
17
+ return raw;
18
+ }
19
+ }
20
+
21
+ export function fromLocalPath<T extends { localPath: string }>(
22
+ input: T,
23
+ ): FromHtmlInput<T> {
24
+ const { localPath, ...rest } = input;
25
+ return { ...rest, html: readHtmlFile(localPath) };
26
+ }
27
+
28
+ const schemaDefaults = new WeakMap<object, unknown>();
29
+
30
+ export function withSchemaDefaults<
31
+ T extends { localPath: string },
32
+ I extends FromHtmlInput<T>,
33
+ >(schema: { parse(raw: unknown): T }, input: I): I & T {
34
+ let defaults = schemaDefaults.get(schema);
35
+ if (defaults === undefined) {
36
+ defaults = schema.parse({ localPath: '' });
37
+ schemaDefaults.set(schema, defaults);
38
+ }
39
+ return { ...(defaults as T), ...input };
40
+ }
@@ -0,0 +1,180 @@
1
+ import type { SanitizationDiagnostics } from '../pipeline/context.ts';
2
+ import type { ToolHandle } from '../server.ts';
3
+
4
+ import { tool } from '../handler.ts';
5
+ import { formatPayload } from '../output/format.ts';
6
+ import { buildDocument } from '../pipeline/dom.ts';
7
+ import {
8
+ applySelectors,
9
+ normalizeDocument,
10
+ resolveLazyImages,
11
+ } from '../pipeline/normalize.ts';
12
+ import { sanitizeHtml } from '../pipeline/sanitize.ts';
13
+ import { toMarkdown } from '../pipeline/turndown.ts';
14
+ import { assembleDiagnostics, TraceCollector } from '../policy/diagnostics.ts';
15
+ import { computeTextMetrics, nonEmpty } from '../policy/text.ts';
16
+ import { truncateMarkdown } from '../policy/truncate.ts';
17
+ import { fromLocalPath, withSchemaDefaults } from './html-source.ts';
18
+ import { outputSchemaShape } from './output-schema.ts';
19
+ import {
20
+ type HtmlToMarkdownFromHtmlInput,
21
+ htmlToMarkdownInputSchema,
22
+ htmlToMarkdownInputShape,
23
+ } from './schemas.ts';
24
+
25
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
26
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
27
+
28
+ const EXTRACTED_NODE = 'fragment';
29
+
30
+ export function htmlToMarkdownFromHtml(
31
+ input: Readonly<HtmlToMarkdownFromHtmlInput>,
32
+ ): CallToolResult {
33
+ const {
34
+ html,
35
+ baseUrl,
36
+ selectors,
37
+ format,
38
+ metadataMode,
39
+ gfm,
40
+ headingStyle,
41
+ codeBlockStyle,
42
+ images,
43
+ sanitize: shouldSanitize,
44
+ maxChars,
45
+ wordsPerMinute,
46
+ cleanChrome,
47
+ tables,
48
+ debug,
49
+ } = withSchemaDefaults(htmlToMarkdownInputSchema, input);
50
+
51
+ const trace = new TraceCollector(debug);
52
+
53
+ const { document, window } = buildDocument(html, baseUrl);
54
+
55
+ const {
56
+ documentElementCount,
57
+ normalizeCounts,
58
+ imagesResolved,
59
+ textContent,
60
+ rawHtml,
61
+ } = trace.run('normalize', () => {
62
+ const documentElementCount = document.querySelectorAll('*').length;
63
+ const normalizeCounts = normalizeDocument(document, { cleanChrome });
64
+ const imagesResolved = resolveLazyImages(document);
65
+ applySelectors(document, selectors);
66
+ const body = document.body;
67
+ return {
68
+ documentElementCount,
69
+ imagesResolved,
70
+ normalizeCounts,
71
+ rawHtml: body.innerHTML,
72
+ textContent: body.textContent,
73
+ };
74
+ });
75
+
76
+ const { html: sanitizedHtml, counts: sanitizeCounts } = trace.run(
77
+ 'sanitize',
78
+ (): { counts: SanitizationDiagnostics; html: string } => {
79
+ if (!shouldSanitize) {
80
+ return { counts: { iframes: 0, scripts: 0 }, html: rawHtml };
81
+ }
82
+ const res = sanitizeHtml(rawHtml, window);
83
+ return {
84
+ counts: { iframes: res.iframesRemoved, scripts: res.scriptsRemoved },
85
+ html: res.html,
86
+ };
87
+ },
88
+ );
89
+ const markdown = trace.run('turndown', () =>
90
+ toMarkdown(sanitizedHtml, {
91
+ codeBlockStyle,
92
+ gfm,
93
+ headingStyle,
94
+ images,
95
+ tables,
96
+ baseUrl,
97
+ }),
98
+ );
99
+
100
+ const { metadata } = trace.run('metadata', () => {
101
+ const firstHeading = nonEmpty(
102
+ document.body.querySelector('h1, h2, h3, h4, h5, h6')?.textContent,
103
+ );
104
+ const metadata = {
105
+ title: firstHeading,
106
+ baseUrl,
107
+ ...computeTextMetrics(textContent, wordsPerMinute),
108
+ };
109
+ return { metadata };
110
+ });
111
+
112
+ const sanitization: SanitizationDiagnostics = {
113
+ iframes: normalizeCounts.iframes + sanitizeCounts.iframes,
114
+ scripts: normalizeCounts.scripts + sanitizeCounts.scripts,
115
+ };
116
+ const baseDiagnostics = assembleDiagnostics({
117
+ articleHtml: sanitizedHtml,
118
+ boilerplateRemoved: normalizeCounts.boilerplateRemoved,
119
+ chromeRemoved: normalizeCounts.chromeRemoved,
120
+ documentElementCount,
121
+ extractedNode: EXTRACTED_NODE,
122
+ fallbackUsed: true,
123
+ imagesResolved,
124
+ sanitization,
125
+ trace: trace.collect(),
126
+ truncated: false,
127
+ window,
128
+ });
129
+
130
+ let payload = formatPayload({
131
+ diagnostics: baseDiagnostics,
132
+ format,
133
+ markdown,
134
+ metadata,
135
+ metadataMode,
136
+ sanitizedHtml,
137
+ textContent,
138
+ });
139
+
140
+ let truncated = false;
141
+ if (maxChars !== undefined && (format === 'markdown' || format === 'text')) {
142
+ const res = truncateMarkdown(payload, maxChars);
143
+ payload = res.text;
144
+ truncated = res.truncated;
145
+ }
146
+ const diagnostics = truncated
147
+ ? { ...baseDiagnostics, truncated }
148
+ : baseDiagnostics;
149
+
150
+ return {
151
+ content: [{ text: payload, type: 'text' }],
152
+ structuredContent: {
153
+ schemaVersion: 1,
154
+ content: payload,
155
+ metadata,
156
+ diagnostics,
157
+ },
158
+ };
159
+ }
160
+
161
+ export const HTML_TO_MARKDOWN_TOOL_DESCRIPTION = `Convert an arbitrary HTML fragment to Markdown WITHOUT Readability article extraction (e.g. a snippet already isolated via chrome-devtools). Same Turndown + DOMPurify path as \`extract\`. The server fetches nothing: \`localPath\` is the only source, and \`baseUrl\` (optional) absolutizes relative links.`;
162
+
163
+ export const htmlToMarkdownHandler = tool(
164
+ 'html_to_markdown',
165
+ input => htmlToMarkdownFromHtml(fromLocalPath(input)),
166
+ htmlToMarkdownInputSchema,
167
+ );
168
+
169
+ export function registerHtmlToMarkdownTool(server: McpServer): ToolHandle {
170
+ return server.registerTool(
171
+ 'html_to_markdown',
172
+ {
173
+ title: 'Convert HTML fragment to Markdown',
174
+ description: HTML_TO_MARKDOWN_TOOL_DESCRIPTION,
175
+ inputSchema: htmlToMarkdownInputShape,
176
+ outputSchema: outputSchemaShape,
177
+ },
178
+ htmlToMarkdownHandler,
179
+ );
180
+ }
@@ -0,0 +1,77 @@
1
+ import type { ToolHandle } from '../server.ts';
2
+
3
+ import { tool } from '../handler.ts';
4
+ import { buildDocument } from '../pipeline/dom.ts';
5
+ import { applySelectors, normalizeDocument } from '../pipeline/normalize.ts';
6
+ import { resolveOutline } from '../policy/outline.ts';
7
+ import { fromLocalPath } from './html-source.ts';
8
+ import { outlineOutputShape } from './output-schema.ts';
9
+ import {
10
+ type OutlineFromHtmlInput,
11
+ outlineInputSchema,
12
+ outlineInputShape,
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
+ // One line per heading, nested by depth; never blank so content[0].text is always scannable.
19
+ function renderOutlineToc(
20
+ outline: readonly { level: number; text: string }[],
21
+ ): string {
22
+ if (outline.length === 0) {
23
+ return '(no headings found)';
24
+ }
25
+ return outline
26
+ .map(entry => `${' '.repeat(entry.level - 1)}- ${entry.text}`)
27
+ .join('\n');
28
+ }
29
+
30
+ export function outlineDocumentFromHtml(
31
+ input: Readonly<OutlineFromHtmlInput>,
32
+ ): CallToolResult {
33
+ const { html, baseUrl, selectors } = input;
34
+
35
+ const { document } = buildDocument(html, baseUrl);
36
+ normalizeDocument(document);
37
+ applySelectors(document, selectors);
38
+ const outline = resolveOutline(document);
39
+
40
+ const title =
41
+ document.title.trim() ||
42
+ document.querySelector('h1')?.textContent.replace(/\s+/g, ' ').trim() ||
43
+ undefined;
44
+ const metadata = { title, baseUrl };
45
+
46
+ const content = renderOutlineToc(outline);
47
+ return {
48
+ content: [{ text: content, type: 'text' }],
49
+ structuredContent: {
50
+ schemaVersion: 1,
51
+ content,
52
+ outline,
53
+ metadata,
54
+ },
55
+ };
56
+ }
57
+
58
+ export const OUTLINE_TOOL_DESCRIPTION = `Return the document outline (h1-h6 headings with stable anchor ids) of already-rendered (post-JavaScript) HTML as a cheap pre-check before full extraction. No Readability scoring, no Turndown, no sanitization — a pure heading walk. The server fetches nothing: \`localPath\` is the only source, and \`baseUrl\` is origin context only (never fetched).`;
59
+
60
+ export const outlineHandler = tool(
61
+ 'outline',
62
+ input => outlineDocumentFromHtml(fromLocalPath(input)),
63
+ outlineInputSchema,
64
+ );
65
+
66
+ export function registerOutlineTool(server: McpServer): ToolHandle {
67
+ return server.registerTool(
68
+ 'outline',
69
+ {
70
+ title: 'Get document outline (heading TOC)',
71
+ description: OUTLINE_TOOL_DESCRIPTION,
72
+ inputSchema: outlineInputShape,
73
+ outputSchema: outlineOutputShape,
74
+ },
75
+ outlineHandler,
76
+ );
77
+ }