@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.
- package/README.md +3 -3
- package/package.json +7 -9
- package/src/cli.js +99 -0
- package/src/cli.ts +134 -0
- package/src/config.js +29 -0
- package/src/config.ts +42 -0
- package/src/dev.js +55 -0
- package/src/dev.ts +77 -0
- package/src/errors.js +16 -0
- package/src/errors.ts +19 -0
- package/src/handler.js +4 -0
- package/src/handler.ts +8 -0
- package/src/host-sampling.js +57 -0
- package/src/host-sampling.ts +86 -0
- package/src/index.js +23 -0
- package/src/index.ts +27 -0
- package/src/logger.js +2 -0
- package/src/logger.ts +3 -0
- package/src/output/format.js +92 -0
- package/src/output/format.ts +132 -0
- package/src/pipeline/cell-text.js +51 -0
- package/src/pipeline/cell-text.ts +56 -0
- package/src/pipeline/context.js +1 -0
- package/src/pipeline/context.ts +88 -0
- package/src/pipeline/dom.js +16 -0
- package/src/pipeline/dom.ts +23 -0
- package/src/pipeline/footnotes.js +148 -0
- package/src/pipeline/footnotes.ts +181 -0
- package/src/pipeline/math.js +113 -0
- package/src/pipeline/math.ts +130 -0
- package/src/pipeline/normalize.js +507 -0
- package/src/pipeline/normalize.ts +594 -0
- package/src/pipeline/readability.js +10 -0
- package/src/pipeline/readability.ts +36 -0
- package/src/pipeline/resolver.js +113 -0
- package/src/pipeline/resolver.ts +143 -0
- package/src/pipeline/sanitize.js +26 -0
- package/src/pipeline/sanitize.ts +42 -0
- package/src/pipeline/tables.js +239 -0
- package/src/pipeline/tables.ts +286 -0
- package/src/pipeline/turndown.js +153 -0
- package/src/pipeline/turndown.ts +191 -0
- package/src/pipeline/urls.js +14 -0
- package/src/pipeline/urls.ts +13 -0
- package/src/policy/chunk.js +365 -0
- package/src/policy/chunk.ts +482 -0
- package/src/policy/diagnostics.js +53 -0
- package/src/policy/diagnostics.ts +96 -0
- package/src/policy/explain.js +104 -0
- package/src/policy/explain.ts +182 -0
- package/src/policy/fallback.js +68 -0
- package/src/policy/fallback.ts +110 -0
- package/src/policy/gating.js +90 -0
- package/src/policy/gating.ts +106 -0
- package/src/policy/grid-detector.js +207 -0
- package/src/policy/grid-detector.ts +313 -0
- package/src/policy/identifiers.js +62 -0
- package/src/policy/identifiers.ts +75 -0
- package/src/policy/images.js +49 -0
- package/src/policy/images.ts +64 -0
- package/src/policy/list-detector.js +232 -0
- package/src/policy/list-detector.ts +308 -0
- package/src/policy/lost-signal.js +44 -0
- package/src/policy/lost-signal.ts +69 -0
- package/src/policy/markdown.js +58 -0
- package/src/policy/markdown.ts +76 -0
- package/src/policy/metadata.js +216 -0
- package/src/policy/metadata.ts +312 -0
- package/src/policy/outline-chains.js +165 -0
- package/src/policy/outline-chains.ts +215 -0
- package/src/policy/outline.js +71 -0
- package/src/policy/outline.ts +89 -0
- package/src/policy/pagination.js +79 -0
- package/src/policy/pagination.ts +104 -0
- package/src/policy/presets.js +100 -0
- package/src/policy/presets.ts +142 -0
- package/src/policy/section.js +111 -0
- package/src/policy/section.ts +127 -0
- package/src/policy/selector-lint.js +143 -0
- package/src/policy/selector-lint.ts +186 -0
- package/src/policy/sibling-scan.js +92 -0
- package/src/policy/sibling-scan.ts +99 -0
- package/src/policy/text.js +18 -0
- package/src/policy/text.ts +36 -0
- package/src/policy/truncate.js +45 -0
- package/src/policy/truncate.ts +59 -0
- package/src/preset-cache.js +165 -0
- package/src/preset-cache.ts +221 -0
- package/src/resources.js +227 -0
- package/src/resources.ts +317 -0
- package/src/sampling.js +45 -0
- package/src/sampling.ts +74 -0
- package/src/server.js +73 -0
- package/src/server.ts +85 -0
- package/src/tools/chunk_text.js +40 -0
- package/src/tools/chunk_text.ts +65 -0
- package/src/tools/explain.js +200 -0
- package/src/tools/explain.ts +299 -0
- package/src/tools/extract.js +263 -0
- package/src/tools/extract.ts +368 -0
- package/src/tools/extract_grid.js +56 -0
- package/src/tools/extract_grid.ts +90 -0
- package/src/tools/extract_links.js +98 -0
- package/src/tools/extract_links.ts +138 -0
- package/src/tools/extract_list.js +66 -0
- package/src/tools/extract_list.ts +97 -0
- package/src/tools/extract_metadata.js +67 -0
- package/src/tools/extract_metadata.ts +102 -0
- package/src/tools/extract_section.js +56 -0
- package/src/tools/extract_section.ts +84 -0
- package/src/tools/extract_tables.js +59 -0
- package/src/tools/extract_tables.ts +101 -0
- package/src/tools/html-source.js +30 -0
- package/src/tools/html-source.ts +40 -0
- package/src/tools/html_to_markdown.js +113 -0
- package/src/tools/html_to_markdown.ts +180 -0
- package/src/tools/outline.js +47 -0
- package/src/tools/outline.ts +77 -0
- package/src/tools/output-schema.js +504 -0
- package/src/tools/output-schema.ts +715 -0
- package/src/tools/schemas.js +288 -0
- package/src/tools/schemas.ts +442 -0
- package/src/tools/suggest-preset.js +504 -0
- package/src/tools/suggest-preset.ts +708 -0
- package/src/types/jsdom.d.ts +15 -0
- package/src/types/turndown-plugin-gfm.d.ts +11 -0
- package/dist/assets/cli-Dfjg7dOQ.js +0 -76
- package/dist/assets/cli-Dfjg7dOQ.js.map +0 -1
- package/dist/assets/extract-CdDp-KwX.js +0 -2880
- package/dist/assets/extract-CdDp-KwX.js.map +0 -1
- package/dist/index.js +0 -2267
- package/dist/index.js.map +0 -1
|
@@ -0,0 +1,138 @@
|
|
|
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 { absolutize } from '../pipeline/urls.ts';
|
|
7
|
+
import { fromLocalPath, withSchemaDefaults } from './html-source.ts';
|
|
8
|
+
import { extractLinksOutputShape } from './output-schema.ts';
|
|
9
|
+
import {
|
|
10
|
+
type ExtractLinksFromHtmlInput,
|
|
11
|
+
extractLinksInputSchema,
|
|
12
|
+
extractLinksInputShape,
|
|
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
|
+
export interface ExtractedLink {
|
|
19
|
+
readonly href: string;
|
|
20
|
+
readonly isExternal: boolean;
|
|
21
|
+
readonly rel: string;
|
|
22
|
+
readonly text: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const MAX_TEXT_LENGTH = 300;
|
|
26
|
+
|
|
27
|
+
function clipText(raw: string): string {
|
|
28
|
+
const collapsed = raw.replace(/\s+/g, ' ').trim();
|
|
29
|
+
return collapsed.length > MAX_TEXT_LENGTH
|
|
30
|
+
? `${collapsed.slice(0, MAX_TEXT_LENGTH)}…`
|
|
31
|
+
: collapsed;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Non-http(s) schemes (mailto/tel/javascript/data) have an opaque origin and
|
|
35
|
+
// would otherwise compare as "different" — treat them as non-external so the
|
|
36
|
+
// isExternal flag tracks real cross-origin navigation only.
|
|
37
|
+
function isWebOrigin(parsed: URL): boolean {
|
|
38
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function resolveExternal(absolutizedHref: string, baseUrl: string): boolean {
|
|
42
|
+
let parsedHref: URL;
|
|
43
|
+
try {
|
|
44
|
+
parsedHref = new URL(absolutizedHref);
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
if (!isWebOrigin(parsedHref)) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
return new URL(baseUrl).origin !== parsedHref.origin;
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Drop <script>/<template> for safety (templates never render, scripts carry no
|
|
59
|
+
// crawl value), but keep nav/footer/main — crawl-relevant links live there.
|
|
60
|
+
function pruneUnsafeRoots(document: Document): void {
|
|
61
|
+
for (const el of document.querySelectorAll('script, template')) {
|
|
62
|
+
el.remove();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function extractLinksFromHtml(
|
|
67
|
+
input: Readonly<ExtractLinksFromHtmlInput>,
|
|
68
|
+
): CallToolResult {
|
|
69
|
+
const { html, baseUrl, sameOriginOnly, selectors } = withSchemaDefaults(
|
|
70
|
+
extractLinksInputSchema,
|
|
71
|
+
input,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const { document } = buildDocument(html, baseUrl);
|
|
75
|
+
applySelectors(document, selectors);
|
|
76
|
+
pruneUnsafeRoots(document);
|
|
77
|
+
|
|
78
|
+
const links: ExtractedLink[] = [];
|
|
79
|
+
for (const anchor of document.querySelectorAll('a')) {
|
|
80
|
+
const rawHref = anchor.getAttribute('href');
|
|
81
|
+
if (!rawHref) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const href = absolutize(rawHref, baseUrl);
|
|
85
|
+
const isExternal = baseUrl ? resolveExternal(href, baseUrl) : false;
|
|
86
|
+
if (sameOriginOnly && isExternal) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
links.push({
|
|
90
|
+
text: clipText(anchor.textContent),
|
|
91
|
+
href,
|
|
92
|
+
rel: anchor.getAttribute('rel') ?? '',
|
|
93
|
+
isExternal,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const content = renderLinksIndex(links);
|
|
98
|
+
return {
|
|
99
|
+
content: [{ text: content, type: 'text' }],
|
|
100
|
+
structuredContent: {
|
|
101
|
+
schemaVersion: 1,
|
|
102
|
+
content,
|
|
103
|
+
links,
|
|
104
|
+
metadata: { baseUrl },
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// One `- [text](href)` line per link; never blank so content[0].text is scannable.
|
|
110
|
+
function renderLinksIndex(links: readonly ExtractedLink[]): string {
|
|
111
|
+
if (links.length === 0) {
|
|
112
|
+
return '(no links found)';
|
|
113
|
+
}
|
|
114
|
+
return links
|
|
115
|
+
.map(link => `- [${link.text || '(no text)'}](${link.href})`)
|
|
116
|
+
.join('\n');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const EXTRACT_LINKS_TOOL_DESCRIPTION = `Return a structured list of anchor links from already-rendered (post-JavaScript) HTML — \`[{text, href, rel, isExternal}]\` in document order, hrefs absolutized against \`baseUrl\`. No Readability scoring, Turndown, or sanitization — links are gathered from the raw parsed DOM so nav/footer/main links survive. Pairs with chrome-devtools for crawl/navigation decisions. The server fetches nothing: \`localPath\` is the only source, and \`baseUrl\` is origin context only (never fetched).`;
|
|
120
|
+
|
|
121
|
+
export const extractLinksHandler = tool(
|
|
122
|
+
'extract_links',
|
|
123
|
+
input => extractLinksFromHtml(fromLocalPath(input)),
|
|
124
|
+
extractLinksInputSchema,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
export function registerExtractLinksTool(server: McpServer): ToolHandle {
|
|
128
|
+
return server.registerTool(
|
|
129
|
+
'extract_links',
|
|
130
|
+
{
|
|
131
|
+
title: 'Extract anchor links',
|
|
132
|
+
description: EXTRACT_LINKS_TOOL_DESCRIPTION,
|
|
133
|
+
inputSchema: extractLinksInputShape,
|
|
134
|
+
outputSchema: extractLinksOutputShape,
|
|
135
|
+
},
|
|
136
|
+
extractLinksHandler,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { tool } from "../handler.js";
|
|
2
|
+
import { buildDocument } from "../pipeline/dom.js";
|
|
3
|
+
import { applySelectors } from "../pipeline/normalize.js";
|
|
4
|
+
import { detectList, } from "../policy/list-detector.js";
|
|
5
|
+
import { fromLocalPath } from "./html-source.js";
|
|
6
|
+
import { extractListOutputShape } from "./output-schema.js";
|
|
7
|
+
import { extractListInputSchema, extractListInputShape, } from "./schemas.js";
|
|
8
|
+
const NOT_A_LIST = 'not a list: no repeated item structure with links';
|
|
9
|
+
function renderItems(result) {
|
|
10
|
+
if (!result.detected || result.items.length === 0) {
|
|
11
|
+
return result.note || NOT_A_LIST;
|
|
12
|
+
}
|
|
13
|
+
return result.items
|
|
14
|
+
.map((item, index) => {
|
|
15
|
+
const head = `${index + 1}. ${item.title} — ${item.url}`;
|
|
16
|
+
return item.snippet ? `${head}\n ${item.snippet}` : head;
|
|
17
|
+
})
|
|
18
|
+
.join('\n');
|
|
19
|
+
}
|
|
20
|
+
function toStructuredItem(item) {
|
|
21
|
+
return {
|
|
22
|
+
score: item.score,
|
|
23
|
+
snippet: item.snippet,
|
|
24
|
+
title: item.title,
|
|
25
|
+
url: item.url,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export function extractListFromHtml(input) {
|
|
29
|
+
const { html, baseUrl, selectors } = input;
|
|
30
|
+
// No Readability/Turndown/normalize: list pages survive on raw DOM shape
|
|
31
|
+
// (sibling TR/LI/ARTICLE clusters), and the article normalizer would
|
|
32
|
+
// discard the very chrome-bearing structure the detector scores against.
|
|
33
|
+
// Chrome stripping (nav/header/footer/aside) lives inside detectList so
|
|
34
|
+
// this tool sees the page as captured.
|
|
35
|
+
const { document } = buildDocument(html, baseUrl);
|
|
36
|
+
applySelectors(document, selectors);
|
|
37
|
+
const result = detectList(document, baseUrl);
|
|
38
|
+
const content = renderItems(result);
|
|
39
|
+
return {
|
|
40
|
+
content: [{ text: content, type: 'text' }],
|
|
41
|
+
structuredContent: {
|
|
42
|
+
schemaVersion: 1,
|
|
43
|
+
content,
|
|
44
|
+
items: result.items.map(toStructuredItem),
|
|
45
|
+
diagnostics: {
|
|
46
|
+
confidence: result.confidence,
|
|
47
|
+
containerSelector: result.containerSelector,
|
|
48
|
+
detected: result.detected,
|
|
49
|
+
itemCount: result.itemCount,
|
|
50
|
+
itemTag: result.itemTag,
|
|
51
|
+
note: result.note,
|
|
52
|
+
},
|
|
53
|
+
metadata: { baseUrl },
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export const EXTRACT_LIST_TOOL_DESCRIPTION = `Detect and extract a list/feed/index structure from already-rendered (post-JavaScript) HTML — for HN-style, search-result, and blog-index pages that Readability cannot turn into one article. Returns \`{items: [{title, url, snippet, score}], diagnostics}\` instead of one article. Strips nav/header/footer/aside + ARIA chrome roles first (the false-positive guard so an article's nav menu doesn't look like a 4-item feed), then finds the container whose direct children form a same-shape sibling cluster of ≥3 elements each carrying a navigation anchor, and the cluster with the most items wins. No Readability, no Turndown, no sanitization. The server fetches nothing: \`localPath\` is the only source, and \`baseUrl\` (optional) is origin context for absolutizing item hrefs.`;
|
|
58
|
+
export const extractListHandler = tool('extract_list', input => extractListFromHtml(fromLocalPath(input)), extractListInputSchema);
|
|
59
|
+
export function registerExtractListTool(server) {
|
|
60
|
+
return server.registerTool('extract_list', {
|
|
61
|
+
title: 'Extract a list/feed (HN/search/blog-index pages)',
|
|
62
|
+
description: EXTRACT_LIST_TOOL_DESCRIPTION,
|
|
63
|
+
inputSchema: extractListInputShape,
|
|
64
|
+
outputSchema: extractListOutputShape,
|
|
65
|
+
}, extractListHandler);
|
|
66
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
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
|
+
detectList,
|
|
8
|
+
type ListDetectionResult,
|
|
9
|
+
type ListItem,
|
|
10
|
+
} from '../policy/list-detector.ts';
|
|
11
|
+
import { fromLocalPath } from './html-source.ts';
|
|
12
|
+
import { extractListOutputShape } from './output-schema.ts';
|
|
13
|
+
import {
|
|
14
|
+
type ExtractListFromHtmlInput,
|
|
15
|
+
extractListInputSchema,
|
|
16
|
+
extractListInputShape,
|
|
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
|
+
const NOT_A_LIST = 'not a list: no repeated item structure with links';
|
|
23
|
+
|
|
24
|
+
function renderItems(result: ListDetectionResult): string {
|
|
25
|
+
if (!result.detected || result.items.length === 0) {
|
|
26
|
+
return result.note || NOT_A_LIST;
|
|
27
|
+
}
|
|
28
|
+
return result.items
|
|
29
|
+
.map((item, index) => {
|
|
30
|
+
const head = `${index + 1}. ${item.title} — ${item.url}`;
|
|
31
|
+
return item.snippet ? `${head}\n ${item.snippet}` : head;
|
|
32
|
+
})
|
|
33
|
+
.join('\n');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function toStructuredItem(item: ListItem) {
|
|
37
|
+
return {
|
|
38
|
+
score: item.score,
|
|
39
|
+
snippet: item.snippet,
|
|
40
|
+
title: item.title,
|
|
41
|
+
url: item.url,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function extractListFromHtml(
|
|
46
|
+
input: Readonly<ExtractListFromHtmlInput>,
|
|
47
|
+
): CallToolResult {
|
|
48
|
+
const { html, baseUrl, selectors } = input;
|
|
49
|
+
|
|
50
|
+
// No Readability/Turndown/normalize: list pages survive on raw DOM shape
|
|
51
|
+
// (sibling TR/LI/ARTICLE clusters), and the article normalizer would
|
|
52
|
+
// discard the very chrome-bearing structure the detector scores against.
|
|
53
|
+
// Chrome stripping (nav/header/footer/aside) lives inside detectList so
|
|
54
|
+
// this tool sees the page as captured.
|
|
55
|
+
const { document } = buildDocument(html, baseUrl);
|
|
56
|
+
applySelectors(document, selectors);
|
|
57
|
+
const result = detectList(document, baseUrl);
|
|
58
|
+
const content = renderItems(result);
|
|
59
|
+
return {
|
|
60
|
+
content: [{ text: content, type: 'text' }],
|
|
61
|
+
structuredContent: {
|
|
62
|
+
schemaVersion: 1,
|
|
63
|
+
content,
|
|
64
|
+
items: result.items.map(toStructuredItem),
|
|
65
|
+
diagnostics: {
|
|
66
|
+
confidence: result.confidence,
|
|
67
|
+
containerSelector: result.containerSelector,
|
|
68
|
+
detected: result.detected,
|
|
69
|
+
itemCount: result.itemCount,
|
|
70
|
+
itemTag: result.itemTag,
|
|
71
|
+
note: result.note,
|
|
72
|
+
},
|
|
73
|
+
metadata: { baseUrl },
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const EXTRACT_LIST_TOOL_DESCRIPTION = `Detect and extract a list/feed/index structure from already-rendered (post-JavaScript) HTML — for HN-style, search-result, and blog-index pages that Readability cannot turn into one article. Returns \`{items: [{title, url, snippet, score}], diagnostics}\` instead of one article. Strips nav/header/footer/aside + ARIA chrome roles first (the false-positive guard so an article's nav menu doesn't look like a 4-item feed), then finds the container whose direct children form a same-shape sibling cluster of ≥3 elements each carrying a navigation anchor, and the cluster with the most items wins. No Readability, no Turndown, no sanitization. The server fetches nothing: \`localPath\` is the only source, and \`baseUrl\` (optional) is origin context for absolutizing item hrefs.`;
|
|
79
|
+
|
|
80
|
+
export const extractListHandler = tool(
|
|
81
|
+
'extract_list',
|
|
82
|
+
input => extractListFromHtml(fromLocalPath(input)),
|
|
83
|
+
extractListInputSchema,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
export function registerExtractListTool(server: McpServer): ToolHandle {
|
|
87
|
+
return server.registerTool(
|
|
88
|
+
'extract_list',
|
|
89
|
+
{
|
|
90
|
+
title: 'Extract a list/feed (HN/search/blog-index pages)',
|
|
91
|
+
description: EXTRACT_LIST_TOOL_DESCRIPTION,
|
|
92
|
+
inputSchema: extractListInputShape,
|
|
93
|
+
outputSchema: extractListOutputShape,
|
|
94
|
+
},
|
|
95
|
+
extractListHandler,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { tool } from "../handler.js";
|
|
2
|
+
import { buildDocument } from "../pipeline/dom.js";
|
|
3
|
+
import { normalizeDocument } from "../pipeline/normalize.js";
|
|
4
|
+
import { resolveMetadata } from "../policy/metadata.js";
|
|
5
|
+
import { fromLocalPath } from "./html-source.js";
|
|
6
|
+
import { extractMetadataOutputShape } from "./output-schema.js";
|
|
7
|
+
import { extractMetadataInputSchema, extractMetadataInputShape, } from "./schemas.js";
|
|
8
|
+
// Bibliographic fields only — wordCount/readingTimeMin/tokenEstimate are
|
|
9
|
+
// meaningless without the extracted body, so a metadata-only caller never
|
|
10
|
+
// sees wordCount: 0.
|
|
11
|
+
const BIBLIOGRAPHIC_KEYS = [
|
|
12
|
+
'title',
|
|
13
|
+
'byline',
|
|
14
|
+
'siteName',
|
|
15
|
+
'lang',
|
|
16
|
+
'publishedTime',
|
|
17
|
+
'excerpt',
|
|
18
|
+
'canonical',
|
|
19
|
+
'baseUrl',
|
|
20
|
+
];
|
|
21
|
+
function pickBibliographic(metadata) {
|
|
22
|
+
const out = {};
|
|
23
|
+
for (const key of BIBLIOGRAPHIC_KEYS) {
|
|
24
|
+
const value = metadata[key];
|
|
25
|
+
if (value !== undefined) {
|
|
26
|
+
out[key] = value;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
// One line per present field; never blank so content[0].text is always scannable.
|
|
32
|
+
function renderMetadataLines(metadata) {
|
|
33
|
+
const lines = Object.entries(metadata).map(([key, value]) => `${key}: ${value}`);
|
|
34
|
+
return lines.length > 0 ? lines.join('\n') : '(no metadata found)';
|
|
35
|
+
}
|
|
36
|
+
export function extractMetadataDocumentFromHtml(input) {
|
|
37
|
+
const { html, baseUrl } = input;
|
|
38
|
+
const { document } = buildDocument(html, baseUrl);
|
|
39
|
+
normalizeDocument(document);
|
|
40
|
+
const resolved = resolveMetadata({
|
|
41
|
+
document,
|
|
42
|
+
textContent: '',
|
|
43
|
+
wordCount: 0,
|
|
44
|
+
readingTimeMin: 0,
|
|
45
|
+
baseUrl,
|
|
46
|
+
});
|
|
47
|
+
const metadata = pickBibliographic(resolved);
|
|
48
|
+
const content = renderMetadataLines(metadata);
|
|
49
|
+
return {
|
|
50
|
+
content: [{ text: content, type: 'text' }],
|
|
51
|
+
structuredContent: {
|
|
52
|
+
schemaVersion: 1,
|
|
53
|
+
content,
|
|
54
|
+
metadata,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
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).`;
|
|
59
|
+
export const extractMetadataHandler = tool('extract_metadata', input => extractMetadataDocumentFromHtml(fromLocalPath(input)), extractMetadataInputSchema);
|
|
60
|
+
export function registerExtractMetadataTool(server) {
|
|
61
|
+
return server.registerTool('extract_metadata', {
|
|
62
|
+
title: 'Extract metadata only (no Readability)',
|
|
63
|
+
description: EXTRACT_METADATA_TOOL_DESCRIPTION,
|
|
64
|
+
inputSchema: extractMetadataInputShape,
|
|
65
|
+
outputSchema: extractMetadataOutputShape,
|
|
66
|
+
}, extractMetadataHandler);
|
|
67
|
+
}
|
|
@@ -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,56 @@
|
|
|
1
|
+
import { ExtractionError } from "../errors.js";
|
|
2
|
+
import { tool } from "../handler.js";
|
|
3
|
+
import { buildDocument } from "../pipeline/dom.js";
|
|
4
|
+
import { normalizeDocument } from "../pipeline/normalize.js";
|
|
5
|
+
import { scopeToHeading } from "../policy/section.js";
|
|
6
|
+
import { extractArticleFromHtml } from "./extract.js";
|
|
7
|
+
import { fromLocalPath } from "./html-source.js";
|
|
8
|
+
import { outputSchemaShape } from "./output-schema.js";
|
|
9
|
+
import { extractSectionInputSchema, extractSectionInputShape, } from "./schemas.js";
|
|
10
|
+
const SECTION_SCOPE_SELECTOR = '[data-rdrm-section-scope]';
|
|
11
|
+
export function extractSectionFromHtml(input) {
|
|
12
|
+
const { html, baseUrl, selector, heading } = input;
|
|
13
|
+
if (selector !== undefined) {
|
|
14
|
+
return extractArticleFromHtml({
|
|
15
|
+
html,
|
|
16
|
+
baseUrl,
|
|
17
|
+
selectors: { include: selector },
|
|
18
|
+
resolvePreset: false,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
// The superRefine on the schema enforces selector/heading XOR, so reaching
|
|
22
|
+
// here means heading is set — but its declared type is still optional, so
|
|
23
|
+
// narrow explicitly rather than asserting.
|
|
24
|
+
if (heading === undefined) {
|
|
25
|
+
throw new ExtractionError('Provide exactly one of `selector` or `heading`.');
|
|
26
|
+
}
|
|
27
|
+
// Heading mode: wrap the matched subtree, re-serialize, and route through
|
|
28
|
+
// extractArticleFromHtml's selectors.include so this tool stays a thin
|
|
29
|
+
// resolver over the existing extraction pipeline — no forked extraction logic.
|
|
30
|
+
// The DOM is parsed+normalized twice: here to scope against a normalized
|
|
31
|
+
// DOM, and again inside the extract worker — which owns detectGating-before-
|
|
32
|
+
// normalize, resolveLazyImages, and applySelectors. Folding scoping into
|
|
33
|
+
// that pipeline would breach its invariants for a non-hot path; accepted.
|
|
34
|
+
const { document } = buildDocument(html, baseUrl);
|
|
35
|
+
normalizeDocument(document);
|
|
36
|
+
if (!scopeToHeading(document, heading)) {
|
|
37
|
+
throw new ExtractionError(`no heading matched: ${heading}`);
|
|
38
|
+
}
|
|
39
|
+
const scoped = `<!DOCTYPE html><html><head></head><body>${document.body.innerHTML}</body></html>`;
|
|
40
|
+
return extractArticleFromHtml({
|
|
41
|
+
html: scoped,
|
|
42
|
+
baseUrl,
|
|
43
|
+
selectors: { include: SECTION_SCOPE_SELECTOR },
|
|
44
|
+
resolvePreset: false,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
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).`;
|
|
48
|
+
export const extractSectionHandler = tool('extract_section', input => extractSectionFromHtml(fromLocalPath(input)), extractSectionInputSchema);
|
|
49
|
+
export function registerExtractSectionTool(server) {
|
|
50
|
+
return server.registerTool('extract_section', {
|
|
51
|
+
title: 'Extract one section by selector or heading',
|
|
52
|
+
description: EXTRACT_SECTION_TOOL_DESCRIPTION,
|
|
53
|
+
inputSchema: extractSectionInputShape,
|
|
54
|
+
outputSchema: outputSchemaShape,
|
|
55
|
+
}, extractSectionHandler);
|
|
56
|
+
}
|
|
@@ -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,59 @@
|
|
|
1
|
+
import { tool } from "../handler.js";
|
|
2
|
+
import { buildDocument } from "../pipeline/dom.js";
|
|
3
|
+
import { applySelectors } from "../pipeline/normalize.js";
|
|
4
|
+
import { parseTableMatrix, renderTable, resolveHeaderKeys, } from "../pipeline/tables.js";
|
|
5
|
+
import { fromLocalPath, withSchemaDefaults } from "./html-source.js";
|
|
6
|
+
import { extractTablesOutputShape } from "./output-schema.js";
|
|
7
|
+
import { extractTablesInputSchema, extractTablesInputShape, } from "./schemas.js";
|
|
8
|
+
const NO_TABLES = '(no tables found)';
|
|
9
|
+
export function extractTables(rawArgs) {
|
|
10
|
+
return extractTablesFromHtml(fromLocalPath(extractTablesInputSchema.parse(rawArgs)));
|
|
11
|
+
}
|
|
12
|
+
export function extractTablesFromHtml(input) {
|
|
13
|
+
const { html, baseUrl, format, selectors } = withSchemaDefaults(extractTablesInputSchema, input);
|
|
14
|
+
// Skip normalizeDocument/Readability on purpose: this tool exists to reach
|
|
15
|
+
// tables that live outside the scored article (nav, aside, boilerplate), which
|
|
16
|
+
// those stages would discard. Table structure is static HTML, so the matrix
|
|
17
|
+
// walk is unaffected by unsanitized scripts/styles.
|
|
18
|
+
const { document } = buildDocument(html, baseUrl);
|
|
19
|
+
applySelectors(document, selectors);
|
|
20
|
+
const tables = [];
|
|
21
|
+
let index = 0;
|
|
22
|
+
for (const table of document.querySelectorAll('table')) {
|
|
23
|
+
const matrix = parseTableMatrix(table);
|
|
24
|
+
if (matrix.length === 0) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const keys = format === 'json' ? resolveHeaderKeys(table, matrix) : undefined;
|
|
28
|
+
const markdown = renderTable(matrix, format, keys);
|
|
29
|
+
tables.push({
|
|
30
|
+
index,
|
|
31
|
+
rows: matrix.length,
|
|
32
|
+
cols: matrix[0]?.length ?? 0,
|
|
33
|
+
markdown,
|
|
34
|
+
});
|
|
35
|
+
index++;
|
|
36
|
+
}
|
|
37
|
+
const content = tables.length > 0
|
|
38
|
+
? tables.map(entry => entry.markdown).join('\n\n')
|
|
39
|
+
: NO_TABLES;
|
|
40
|
+
return {
|
|
41
|
+
content: [{ text: content, type: 'text' }],
|
|
42
|
+
structuredContent: {
|
|
43
|
+
schemaVersion: 1,
|
|
44
|
+
content,
|
|
45
|
+
tables,
|
|
46
|
+
metadata: { baseUrl, format, tableCount: tables.length },
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
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).`;
|
|
51
|
+
export const extractTablesHandler = tool('extract_tables', extractTables);
|
|
52
|
+
export function registerExtractTablesTool(server) {
|
|
53
|
+
return server.registerTool('extract_tables', {
|
|
54
|
+
title: 'Extract every table on the page',
|
|
55
|
+
description: EXTRACT_TABLES_TOOL_DESCRIPTION,
|
|
56
|
+
inputSchema: extractTablesInputShape,
|
|
57
|
+
outputSchema: extractTablesOutputShape,
|
|
58
|
+
}, extractTablesHandler);
|
|
59
|
+
}
|