@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,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,30 @@
1
+ import { readFileSync } from 'node:fs';
2
+ export function readHtmlFile(localPath) {
3
+ const raw = readFileSync(localPath, 'utf8');
4
+ const trimmed = raw.trim();
5
+ // A top-level JSON string scalar is a chrome-devtools capture (its JSON-encoded
6
+ // return value written to disk), not raw markup — unwrap it before parsing.
7
+ if (trimmed.length < 2 || trimmed.at(0) !== '"' || trimmed.at(-1) !== '"') {
8
+ return raw;
9
+ }
10
+ try {
11
+ const parsed = JSON.parse(trimmed);
12
+ return typeof parsed === 'string' ? parsed : raw;
13
+ }
14
+ catch {
15
+ return raw;
16
+ }
17
+ }
18
+ export function fromLocalPath(input) {
19
+ const { localPath, ...rest } = input;
20
+ return { ...rest, html: readHtmlFile(localPath) };
21
+ }
22
+ const schemaDefaults = new WeakMap();
23
+ export function withSchemaDefaults(schema, input) {
24
+ let defaults = schemaDefaults.get(schema);
25
+ if (defaults === undefined) {
26
+ defaults = schema.parse({ localPath: '' });
27
+ schemaDefaults.set(schema, defaults);
28
+ }
29
+ return { ...defaults, ...input };
30
+ }
@@ -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,113 @@
1
+ import { tool } from "../handler.js";
2
+ import { formatPayload } from "../output/format.js";
3
+ import { buildDocument } from "../pipeline/dom.js";
4
+ import { applySelectors, normalizeDocument, resolveLazyImages, } from "../pipeline/normalize.js";
5
+ import { sanitizeHtml } from "../pipeline/sanitize.js";
6
+ import { toMarkdown } from "../pipeline/turndown.js";
7
+ import { assembleDiagnostics, TraceCollector } from "../policy/diagnostics.js";
8
+ import { computeTextMetrics, nonEmpty } from "../policy/text.js";
9
+ import { truncateMarkdown } from "../policy/truncate.js";
10
+ import { fromLocalPath, withSchemaDefaults } from "./html-source.js";
11
+ import { outputSchemaShape } from "./output-schema.js";
12
+ import { htmlToMarkdownInputSchema, htmlToMarkdownInputShape, } from "./schemas.js";
13
+ const EXTRACTED_NODE = 'fragment';
14
+ export function htmlToMarkdownFromHtml(input) {
15
+ const { html, baseUrl, selectors, format, metadataMode, gfm, headingStyle, codeBlockStyle, images, sanitize: shouldSanitize, maxChars, wordsPerMinute, cleanChrome, tables, debug, } = withSchemaDefaults(htmlToMarkdownInputSchema, input);
16
+ const trace = new TraceCollector(debug);
17
+ const { document, window } = buildDocument(html, baseUrl);
18
+ const { documentElementCount, normalizeCounts, imagesResolved, textContent, rawHtml, } = trace.run('normalize', () => {
19
+ const documentElementCount = document.querySelectorAll('*').length;
20
+ const normalizeCounts = normalizeDocument(document, { cleanChrome });
21
+ const imagesResolved = resolveLazyImages(document);
22
+ applySelectors(document, selectors);
23
+ const body = document.body;
24
+ return {
25
+ documentElementCount,
26
+ imagesResolved,
27
+ normalizeCounts,
28
+ rawHtml: body.innerHTML,
29
+ textContent: body.textContent,
30
+ };
31
+ });
32
+ const { html: sanitizedHtml, counts: sanitizeCounts } = trace.run('sanitize', () => {
33
+ if (!shouldSanitize) {
34
+ return { counts: { iframes: 0, scripts: 0 }, html: rawHtml };
35
+ }
36
+ const res = sanitizeHtml(rawHtml, window);
37
+ return {
38
+ counts: { iframes: res.iframesRemoved, scripts: res.scriptsRemoved },
39
+ html: res.html,
40
+ };
41
+ });
42
+ const markdown = trace.run('turndown', () => toMarkdown(sanitizedHtml, {
43
+ codeBlockStyle,
44
+ gfm,
45
+ headingStyle,
46
+ images,
47
+ tables,
48
+ baseUrl,
49
+ }));
50
+ const { metadata } = trace.run('metadata', () => {
51
+ const firstHeading = nonEmpty(document.body.querySelector('h1, h2, h3, h4, h5, h6')?.textContent);
52
+ const metadata = {
53
+ title: firstHeading,
54
+ baseUrl,
55
+ ...computeTextMetrics(textContent, wordsPerMinute),
56
+ };
57
+ return { metadata };
58
+ });
59
+ const sanitization = {
60
+ iframes: normalizeCounts.iframes + sanitizeCounts.iframes,
61
+ scripts: normalizeCounts.scripts + sanitizeCounts.scripts,
62
+ };
63
+ const baseDiagnostics = assembleDiagnostics({
64
+ articleHtml: sanitizedHtml,
65
+ boilerplateRemoved: normalizeCounts.boilerplateRemoved,
66
+ chromeRemoved: normalizeCounts.chromeRemoved,
67
+ documentElementCount,
68
+ extractedNode: EXTRACTED_NODE,
69
+ fallbackUsed: true,
70
+ imagesResolved,
71
+ sanitization,
72
+ trace: trace.collect(),
73
+ truncated: false,
74
+ window,
75
+ });
76
+ let payload = formatPayload({
77
+ diagnostics: baseDiagnostics,
78
+ format,
79
+ markdown,
80
+ metadata,
81
+ metadataMode,
82
+ sanitizedHtml,
83
+ textContent,
84
+ });
85
+ let truncated = false;
86
+ if (maxChars !== undefined && (format === 'markdown' || format === 'text')) {
87
+ const res = truncateMarkdown(payload, maxChars);
88
+ payload = res.text;
89
+ truncated = res.truncated;
90
+ }
91
+ const diagnostics = truncated
92
+ ? { ...baseDiagnostics, truncated }
93
+ : baseDiagnostics;
94
+ return {
95
+ content: [{ text: payload, type: 'text' }],
96
+ structuredContent: {
97
+ schemaVersion: 1,
98
+ content: payload,
99
+ metadata,
100
+ diagnostics,
101
+ },
102
+ };
103
+ }
104
+ 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.`;
105
+ export const htmlToMarkdownHandler = tool('html_to_markdown', input => htmlToMarkdownFromHtml(fromLocalPath(input)), htmlToMarkdownInputSchema);
106
+ export function registerHtmlToMarkdownTool(server) {
107
+ return server.registerTool('html_to_markdown', {
108
+ title: 'Convert HTML fragment to Markdown',
109
+ description: HTML_TO_MARKDOWN_TOOL_DESCRIPTION,
110
+ inputSchema: htmlToMarkdownInputShape,
111
+ outputSchema: outputSchemaShape,
112
+ }, htmlToMarkdownHandler);
113
+ }
@@ -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,47 @@
1
+ import { tool } from "../handler.js";
2
+ import { buildDocument } from "../pipeline/dom.js";
3
+ import { applySelectors, normalizeDocument } from "../pipeline/normalize.js";
4
+ import { resolveOutline } from "../policy/outline.js";
5
+ import { fromLocalPath } from "./html-source.js";
6
+ import { outlineOutputShape } from "./output-schema.js";
7
+ import { outlineInputSchema, outlineInputShape, } from "./schemas.js";
8
+ // One line per heading, nested by depth; never blank so content[0].text is always scannable.
9
+ function renderOutlineToc(outline) {
10
+ if (outline.length === 0) {
11
+ return '(no headings found)';
12
+ }
13
+ return outline
14
+ .map(entry => `${' '.repeat(entry.level - 1)}- ${entry.text}`)
15
+ .join('\n');
16
+ }
17
+ export function outlineDocumentFromHtml(input) {
18
+ const { html, baseUrl, selectors } = input;
19
+ const { document } = buildDocument(html, baseUrl);
20
+ normalizeDocument(document);
21
+ applySelectors(document, selectors);
22
+ const outline = resolveOutline(document);
23
+ const title = document.title.trim() ||
24
+ document.querySelector('h1')?.textContent.replace(/\s+/g, ' ').trim() ||
25
+ undefined;
26
+ const metadata = { title, baseUrl };
27
+ const content = renderOutlineToc(outline);
28
+ return {
29
+ content: [{ text: content, type: 'text' }],
30
+ structuredContent: {
31
+ schemaVersion: 1,
32
+ content,
33
+ outline,
34
+ metadata,
35
+ },
36
+ };
37
+ }
38
+ 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).`;
39
+ export const outlineHandler = tool('outline', input => outlineDocumentFromHtml(fromLocalPath(input)), outlineInputSchema);
40
+ export function registerOutlineTool(server) {
41
+ return server.registerTool('outline', {
42
+ title: 'Get document outline (heading TOC)',
43
+ description: OUTLINE_TOOL_DESCRIPTION,
44
+ inputSchema: outlineInputShape,
45
+ outputSchema: outlineOutputShape,
46
+ }, outlineHandler);
47
+ }
@@ -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
+ }