@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.
- package/README.md +3 -3
- package/package.json +7 -9
- package/src/cli.ts +134 -0
- package/src/config.ts +42 -0
- package/src/dev.ts +77 -0
- package/src/errors.ts +19 -0
- package/src/handler.ts +8 -0
- package/src/host-sampling.ts +86 -0
- package/src/index.ts +27 -0
- package/src/logger.ts +3 -0
- package/src/output/format.ts +132 -0
- package/src/pipeline/cell-text.ts +56 -0
- package/src/pipeline/context.ts +88 -0
- package/src/pipeline/dom.ts +23 -0
- package/src/pipeline/footnotes.ts +181 -0
- package/src/pipeline/math.ts +130 -0
- package/src/pipeline/normalize.ts +594 -0
- package/src/pipeline/readability.ts +36 -0
- package/src/pipeline/resolver.ts +143 -0
- package/src/pipeline/sanitize.ts +42 -0
- package/src/pipeline/tables.ts +286 -0
- package/src/pipeline/turndown.ts +191 -0
- package/src/pipeline/urls.ts +13 -0
- package/src/policy/chunk.ts +482 -0
- package/src/policy/diagnostics.ts +96 -0
- package/src/policy/explain.ts +182 -0
- package/src/policy/fallback.ts +110 -0
- package/src/policy/gating.ts +106 -0
- package/src/policy/grid-detector.ts +313 -0
- package/src/policy/identifiers.ts +75 -0
- package/src/policy/images.ts +64 -0
- package/src/policy/list-detector.ts +308 -0
- package/src/policy/lost-signal.ts +69 -0
- package/src/policy/markdown.ts +76 -0
- package/src/policy/metadata.ts +312 -0
- package/src/policy/outline-chains.ts +215 -0
- package/src/policy/outline.ts +89 -0
- package/src/policy/pagination.ts +104 -0
- package/src/policy/presets.ts +142 -0
- package/src/policy/section.ts +127 -0
- package/src/policy/selector-lint.ts +186 -0
- package/src/policy/sibling-scan.ts +99 -0
- package/src/policy/text.ts +36 -0
- package/src/policy/truncate.ts +59 -0
- package/src/preset-cache.ts +221 -0
- package/src/resources.ts +317 -0
- package/src/sampling.ts +74 -0
- package/src/server.ts +85 -0
- package/src/tools/chunk_text.ts +65 -0
- package/src/tools/explain.ts +299 -0
- package/src/tools/extract.ts +368 -0
- package/src/tools/extract_grid.ts +90 -0
- package/src/tools/extract_links.ts +138 -0
- package/src/tools/extract_list.ts +97 -0
- package/src/tools/extract_metadata.ts +102 -0
- package/src/tools/extract_section.ts +84 -0
- package/src/tools/extract_tables.ts +101 -0
- package/src/tools/html-source.ts +40 -0
- package/src/tools/html_to_markdown.ts +180 -0
- package/src/tools/outline.ts +77 -0
- package/src/tools/output-schema.ts +715 -0
- package/src/tools/schemas.ts +442 -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-BE_Ml3rh.js +0 -76
- package/dist/assets/cli-BE_Ml3rh.js.map +0 -1
- package/dist/assets/extract-Cyv5qfGN.js +0 -2880
- package/dist/assets/extract-Cyv5qfGN.js.map +0 -1
- package/dist/index.js +0 -2267
- package/dist/index.js.map +0 -1
package/README.md
CHANGED
|
@@ -10,9 +10,9 @@ User-facing docs: [root README](../../README.md).
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
npm install @v1nvn/readability@0.
|
|
13
|
+
npm install @v1nvn/readability@0.45.0
|
|
14
14
|
# or run on demand:
|
|
15
|
-
npx @v1nvn/readability@0.
|
|
15
|
+
npx @v1nvn/readability@0.45.0
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
Requires Node >= 22.22.2 (jsdom 30's floor). Build from source:
|
|
@@ -58,7 +58,7 @@ Add to your MCP client config (Claude Code, Claude Desktop, etc.):
|
|
|
58
58
|
"mcpServers": {
|
|
59
59
|
"readability": {
|
|
60
60
|
"command": "npx",
|
|
61
|
-
"args": ["-y", "@v1nvn/readability@0.
|
|
61
|
+
"args": ["-y", "@v1nvn/readability@0.45.0"],
|
|
62
62
|
},
|
|
63
63
|
},
|
|
64
64
|
}
|
package/package.json
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@v1nvn/readability",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.45.0",
|
|
4
4
|
"description": "MCP server that turns rendered (post-JS) HTML into clean Markdown + metadata via Readability, Turndown, and DOMPurify.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "
|
|
7
|
-
"bin": "
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"bin": "./src/index.ts",
|
|
8
8
|
"scripts": {
|
|
9
|
-
"build": "vite build",
|
|
10
9
|
"bench": "vite-node test/bench/run.ts",
|
|
11
10
|
"dev": "vite-node src/dev.ts",
|
|
12
|
-
"start": "node
|
|
11
|
+
"start": "node src/index.ts",
|
|
13
12
|
"test": "vitest run",
|
|
14
13
|
"test:watch": "vitest",
|
|
15
14
|
"test:update-goldens": "UPDATE_GOLDENS=1 vitest run",
|
|
@@ -33,19 +32,18 @@
|
|
|
33
32
|
"directory": "packages/readability"
|
|
34
33
|
},
|
|
35
34
|
"files": [
|
|
36
|
-
"
|
|
35
|
+
"src"
|
|
37
36
|
],
|
|
38
37
|
"publishConfig": {
|
|
39
38
|
"access": "public"
|
|
40
39
|
},
|
|
41
40
|
"engines": {
|
|
42
|
-
"node": ">=
|
|
41
|
+
"node": ">=23.6"
|
|
43
42
|
},
|
|
44
43
|
"dependencies": {
|
|
45
44
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
46
45
|
"@mozilla/readability": "^0.6.0",
|
|
47
|
-
"@v1nvn/agentic-core": "^0.
|
|
48
|
-
"commander": "^15.0.0",
|
|
46
|
+
"@v1nvn/agentic-core": "^0.45.0",
|
|
49
47
|
"dompurify": "^3.4.12",
|
|
50
48
|
"jsdom": "^30.0.1",
|
|
51
49
|
"remark-gfm": "^4.0.1",
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CliSpec,
|
|
3
|
+
describeError,
|
|
4
|
+
HelpRequested,
|
|
5
|
+
parseCli,
|
|
6
|
+
runCli,
|
|
7
|
+
} from '@v1nvn/agentic-core';
|
|
8
|
+
|
|
9
|
+
import { extractArticleFromHtml } from './tools/extract.ts';
|
|
10
|
+
import { readHtmlFile } from './tools/html-source.ts';
|
|
11
|
+
|
|
12
|
+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
13
|
+
import type { Readable } from 'node:stream';
|
|
14
|
+
|
|
15
|
+
type CliFormat = 'html' | 'json' | 'md';
|
|
16
|
+
|
|
17
|
+
export interface ParsedArgs {
|
|
18
|
+
readonly file: string | undefined;
|
|
19
|
+
readonly format: CliFormat;
|
|
20
|
+
readonly maxChars: number | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const FORMATS: readonly CliFormat[] = ['html', 'json', 'md'];
|
|
24
|
+
|
|
25
|
+
function parseMaxChars(value: string): number {
|
|
26
|
+
const n = Number(value);
|
|
27
|
+
if (!Number.isInteger(n)) {
|
|
28
|
+
throw new Error('must be an integer');
|
|
29
|
+
}
|
|
30
|
+
return n;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const spec: CliSpec = {
|
|
34
|
+
name: 'readability',
|
|
35
|
+
usageExit: 2,
|
|
36
|
+
commands: [
|
|
37
|
+
{
|
|
38
|
+
name: 'extract',
|
|
39
|
+
description: 'extract the readable article from an HTML file',
|
|
40
|
+
argument: {
|
|
41
|
+
flags: '[file]',
|
|
42
|
+
description: 'HTML file; stdin when omitted',
|
|
43
|
+
},
|
|
44
|
+
options: [
|
|
45
|
+
{
|
|
46
|
+
flags: '--format <fmt>',
|
|
47
|
+
description: 'output format',
|
|
48
|
+
choices: FORMATS,
|
|
49
|
+
default: 'md',
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
flags: '--max-chars <n>',
|
|
53
|
+
description: 'truncate the output',
|
|
54
|
+
parse: parseMaxChars,
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
run: async (options, args) => {
|
|
58
|
+
try {
|
|
59
|
+
const html = await readHtml(args.at(0), process.stdin);
|
|
60
|
+
// json reuses the markdown pipeline; the structured object is serialized below.
|
|
61
|
+
const pipelineFormat =
|
|
62
|
+
options.format === 'html' ? 'html' : 'markdown';
|
|
63
|
+
const result = extractArticleFromHtml({
|
|
64
|
+
html,
|
|
65
|
+
format: pipelineFormat,
|
|
66
|
+
...(typeof options.maxChars === 'number'
|
|
67
|
+
? { maxChars: options.maxChars }
|
|
68
|
+
: {}),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
if (result.isError) {
|
|
72
|
+
process.stderr.write(`${payloadText(result)}\n`);
|
|
73
|
+
return 1;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (options.format === 'json') {
|
|
77
|
+
process.stdout.write(
|
|
78
|
+
`${JSON.stringify(result.structuredContent, null, 2)}\n`,
|
|
79
|
+
);
|
|
80
|
+
} else {
|
|
81
|
+
process.stdout.write(`${payloadText(result)}\n`);
|
|
82
|
+
}
|
|
83
|
+
return 0;
|
|
84
|
+
} catch (err) {
|
|
85
|
+
process.stderr.write(`${describeError(err)}\n`);
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export function parseArgs(
|
|
94
|
+
argv: readonly string[],
|
|
95
|
+
): HelpRequested | ParsedArgs | undefined {
|
|
96
|
+
const parsed = parseCli(spec, argv);
|
|
97
|
+
if (parsed === undefined || parsed instanceof HelpRequested) {
|
|
98
|
+
return parsed;
|
|
99
|
+
}
|
|
100
|
+
const { format, maxChars } = parsed.options as {
|
|
101
|
+
format: CliFormat;
|
|
102
|
+
maxChars: number | undefined;
|
|
103
|
+
};
|
|
104
|
+
return { file: parsed.args.at(0), format, maxChars };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The stream is injected rather than reading process.stdin directly so the
|
|
108
|
+
// path is testable; chunks may be Buffer or string.
|
|
109
|
+
export async function readHtml(
|
|
110
|
+
file: string | undefined,
|
|
111
|
+
stream: Readable,
|
|
112
|
+
): Promise<string> {
|
|
113
|
+
if (file !== undefined) {
|
|
114
|
+
return readHtmlFile(file);
|
|
115
|
+
}
|
|
116
|
+
const chunks: string[] = [];
|
|
117
|
+
for await (const chunk of stream) {
|
|
118
|
+
if (typeof chunk === 'string') {
|
|
119
|
+
chunks.push(chunk);
|
|
120
|
+
} else {
|
|
121
|
+
chunks.push(Buffer.from(chunk as Uint8Array).toString('utf8'));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return chunks.join('');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function payloadText(result: CallToolResult): string {
|
|
128
|
+
const first = result.content.at(0);
|
|
129
|
+
return first !== undefined && 'text' in first ? first.text : '';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function runExtract(argv: readonly string[]): Promise<number> {
|
|
133
|
+
return runCli(spec, argv);
|
|
134
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import pkg from '../package.json' with { type: 'json' };
|
|
2
|
+
|
|
3
|
+
export interface ServerConfig {
|
|
4
|
+
readonly description: string;
|
|
5
|
+
readonly instructions: string;
|
|
6
|
+
readonly name: 'readability';
|
|
7
|
+
readonly title: string;
|
|
8
|
+
readonly version: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const SERVER_TITLE = 'Readability MCP';
|
|
12
|
+
|
|
13
|
+
const SERVER_DESCRIPTION =
|
|
14
|
+
'Turn already-rendered (post-JavaScript) HTML into clean, LLM-friendly Markdown plus metadata, via Mozilla Readability, Turndown, and DOMPurify. Makes no outbound requests — input is the rendered HTML, read from a file path (localPath) so the page bytes never enter the model context.';
|
|
15
|
+
|
|
16
|
+
const SERVER_INSTRUCTIONS = `Every tool reads already-rendered HTML from a file path (\`localPath\`) — e.g. \`document.documentElement.outerHTML\` written to disk by a browser/devtools capture — so the page bytes never enter the model context; \`chunk_text\` alone takes text. The server never fetches URLs: \`baseUrl\` only absolutizes links.
|
|
17
|
+
|
|
18
|
+
Pick the tool by page shape:
|
|
19
|
+
- extract: the default for article-like pages → Markdown + metadata + diagnostics. \`cache: true\` exposes the result as a \`readability://page/{hash}\` resource; \`chunk\` adds token-bounded chunks.
|
|
20
|
+
- extract_list: feed, index, search, or HN-style pages Readability cannot turn into one article.
|
|
21
|
+
- extract_tables: every \`<table>\` on the page, including ones outside the article body.
|
|
22
|
+
- extract_grid: div-rendered tables in SPAs.
|
|
23
|
+
- extract_section: one section, by CSS selector or heading text.
|
|
24
|
+
- extract_links: anchors from the raw DOM, for crawl and navigation decisions.
|
|
25
|
+
- extract_metadata, outline: cheap pre-checks (bibliographic metadata; h1-h6 heading TOC) before full extraction.
|
|
26
|
+
- html_to_markdown: a fragment already isolated, converted with no Readability scoring.
|
|
27
|
+
- explain: post-mortem when \`extract\` picked the wrong root.
|
|
28
|
+
- chunk_text: split already-extracted text for embedding/RAG.
|
|
29
|
+
|
|
30
|
+
When the client advertises MCP \`sampling\`, two more tools appear: \`summarize\` (the host's model summarizes text) and \`suggest_preset\` (after \`extract\` reports gating, a fallback, a near-empty result, or debris, the host's model proposes site selectors; the server verifies them and caches a per-site preset).
|
|
31
|
+
|
|
32
|
+
Failures come back as \`isError: true\` results, never thrown.`;
|
|
33
|
+
|
|
34
|
+
export function loadConfig(): ServerConfig {
|
|
35
|
+
return {
|
|
36
|
+
name: 'readability',
|
|
37
|
+
version: pkg.version,
|
|
38
|
+
title: SERVER_TITLE,
|
|
39
|
+
description: SERVER_DESCRIPTION,
|
|
40
|
+
instructions: SERVER_INSTRUCTIONS,
|
|
41
|
+
};
|
|
42
|
+
}
|
package/src/dev.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Dev-only hot reload: tool implementations reload on file change without
|
|
2
|
+
// restarting the process; the harness in core owns the vite/runner/watch loop.
|
|
3
|
+
// Never bundled into dist (build entry is src/index.ts). Diagnostics to stderr.
|
|
4
|
+
|
|
5
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
6
|
+
import { describeError } from '@v1nvn/agentic-core';
|
|
7
|
+
import { runDevServer } from '@v1nvn/agentic-core/vite';
|
|
8
|
+
import { dirname, resolve } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
import type { PresetCacheReport } from './preset-cache.ts';
|
|
12
|
+
|
|
13
|
+
import { logger } from './logger.ts';
|
|
14
|
+
|
|
15
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
16
|
+
|
|
17
|
+
interface Handle {
|
|
18
|
+
remove(): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Loaded through Vite (not imported statically) so the runner controls its
|
|
22
|
+
// cache and can re-evaluate it on change.
|
|
23
|
+
interface RuntimeModule {
|
|
24
|
+
createMcpServer(): McpServer;
|
|
25
|
+
loadPresets(env?: NodeJS.ProcessEnv): PresetCacheReport | undefined;
|
|
26
|
+
registerCapabilityGatedTools(server: McpServer): Handle[];
|
|
27
|
+
registerResources(server: McpServer): Handle[];
|
|
28
|
+
registerTools(server: McpServer): Handle[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
|
|
33
|
+
runDevServer<RuntimeModule>({
|
|
34
|
+
entry: resolve(here, 'server.ts'),
|
|
35
|
+
log: logger,
|
|
36
|
+
start: async mod => {
|
|
37
|
+
const server = mod.createMcpServer();
|
|
38
|
+
mod.loadPresets();
|
|
39
|
+
let handles = [
|
|
40
|
+
...mod.registerTools(server),
|
|
41
|
+
...mod.registerResources(server),
|
|
42
|
+
];
|
|
43
|
+
// Capability-gated tools (sampling) defer to the `initialized`
|
|
44
|
+
// notification: `connect()` returns before the client advertises
|
|
45
|
+
// capabilities, so a direct call here would observe
|
|
46
|
+
// `getClientCapabilities() === undefined`. The hook fires once per
|
|
47
|
+
// connection, after the initialize handshake but before any `tools/list`.
|
|
48
|
+
let gatedHandles: Handle[] = [];
|
|
49
|
+
server.server.oninitialized = () => {
|
|
50
|
+
gatedHandles = mod.registerCapabilityGatedTools(server);
|
|
51
|
+
};
|
|
52
|
+
const transport = new StdioServerTransport();
|
|
53
|
+
await server.connect(transport);
|
|
54
|
+
return {
|
|
55
|
+
closers: [server],
|
|
56
|
+
reload: next => {
|
|
57
|
+
for (const handle of [...handles, ...gatedHandles]) {
|
|
58
|
+
handle.remove();
|
|
59
|
+
}
|
|
60
|
+
handles = [
|
|
61
|
+
...next.registerTools(server),
|
|
62
|
+
...next.registerResources(server),
|
|
63
|
+
];
|
|
64
|
+
// The re-imported module graph starts with an empty preset store;
|
|
65
|
+
// refill it alongside the re-registrations. The client is already
|
|
66
|
+
// past `initialized` by the time a file change fires, so the gate
|
|
67
|
+
// can re-evaluate directly.
|
|
68
|
+
next.loadPresets();
|
|
69
|
+
gatedHandles = next.registerCapabilityGatedTools(server);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
}).catch((error: unknown) => {
|
|
74
|
+
logger.error(`dev failed: ${describeError(error)}`);
|
|
75
|
+
// eslint-disable-next-line n/no-process-exit
|
|
76
|
+
process.exit(1);
|
|
77
|
+
});
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describeError, type ErrorResult } from '@v1nvn/agentic-core';
|
|
2
|
+
|
|
3
|
+
export class ExtractionError extends Error {
|
|
4
|
+
public override readonly cause: unknown;
|
|
5
|
+
|
|
6
|
+
public constructor(message: string, options: { cause?: unknown } = {}) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'ExtractionError';
|
|
9
|
+
this.cause = options.cause;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function toErrorResult(err: unknown): ErrorResult {
|
|
14
|
+
const label = err instanceof ExtractionError ? err.name : 'Error';
|
|
15
|
+
return {
|
|
16
|
+
isError: true,
|
|
17
|
+
content: [{ type: 'text', text: `${label}: ${describeError(err)}` }],
|
|
18
|
+
};
|
|
19
|
+
}
|
package/src/handler.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { toolHandler } from '@v1nvn/agentic-core';
|
|
2
|
+
|
|
3
|
+
import { toErrorResult } from './errors.ts';
|
|
4
|
+
import { logger } from './logger.ts';
|
|
5
|
+
|
|
6
|
+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
7
|
+
|
|
8
|
+
export const tool = toolHandler<CallToolResult>(logger, toErrorResult);
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// The one seam to the HOST's model via MCP `sampling/createMessage`
|
|
2
|
+
// (server→client request). The server never embeds a model and never calls a
|
|
3
|
+
// provider directly — every LLM call is delegated to the connected client,
|
|
4
|
+
// which picks the model and may prompt the user first. Gating on the
|
|
5
|
+
// capability happens at registration (server.ts), not here.
|
|
6
|
+
|
|
7
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
8
|
+
|
|
9
|
+
// Host models — and the human-in-the-loop approval MCP allows before each
|
|
10
|
+
// call — routinely take longer than the SDK's 60s request default.
|
|
11
|
+
const SAMPLING_TIMEOUT_MS = 300_000;
|
|
12
|
+
|
|
13
|
+
export interface HostSampleInput {
|
|
14
|
+
readonly maxTokens: number;
|
|
15
|
+
readonly systemPrompt: string;
|
|
16
|
+
readonly userText: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function sampleText(
|
|
20
|
+
server: McpServer,
|
|
21
|
+
args: HostSampleInput,
|
|
22
|
+
): Promise<string> {
|
|
23
|
+
const result = await server.server.createMessage(
|
|
24
|
+
{
|
|
25
|
+
messages: [
|
|
26
|
+
{
|
|
27
|
+
role: 'user',
|
|
28
|
+
content: { type: 'text', text: args.userText },
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
systemPrompt: args.systemPrompt,
|
|
32
|
+
maxTokens: args.maxTokens,
|
|
33
|
+
},
|
|
34
|
+
{ timeout: SAMPLING_TIMEOUT_MS },
|
|
35
|
+
);
|
|
36
|
+
if (result.content.type !== 'text') {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`host sampling returned non-text content (${result.content.type})`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return result.content.text;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// createMessage has no structured-output field, so JSON proposals are
|
|
45
|
+
// negotiated in prose: the prompt demands strict JSON, and the reply is
|
|
46
|
+
// tolerated past code fences before the caller's schema takes over.
|
|
47
|
+
export class SuggestParseError extends Error {
|
|
48
|
+
readonly rawText: string;
|
|
49
|
+
|
|
50
|
+
constructor(message: string, rawText: string) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.rawText = rawText;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function extractJsonReply(text: string): unknown {
|
|
57
|
+
const withoutFences = text.replace(
|
|
58
|
+
/^[\s\S]*?```(?:json)?\s*\n?([\s\S]*?)\n?```[\s\S]*$/,
|
|
59
|
+
'$1',
|
|
60
|
+
);
|
|
61
|
+
const trimmed = withoutFences.trim();
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(trimmed);
|
|
64
|
+
} catch {
|
|
65
|
+
const start = trimmed.indexOf('{');
|
|
66
|
+
const end = trimmed.lastIndexOf('}');
|
|
67
|
+
if (start !== -1 && end > start) {
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(trimmed.slice(start, end + 1));
|
|
70
|
+
} catch {
|
|
71
|
+
// Fall through to the error below.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
throw new SuggestParseError(
|
|
75
|
+
`host sampling returned text that does not parse as JSON (${trimmed.slice(0, 80)}…)`,
|
|
76
|
+
trimmed,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function sampleJson(
|
|
82
|
+
server: McpServer,
|
|
83
|
+
args: HostSampleInput,
|
|
84
|
+
): Promise<unknown> {
|
|
85
|
+
return extractJsonReply(await sampleText(server, args));
|
|
86
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { describeError, shutdownOnSignals } from '@v1nvn/agentic-core';
|
|
5
|
+
|
|
6
|
+
import { createServer } from './server.ts';
|
|
7
|
+
|
|
8
|
+
if (process.argv[2] === 'extract') {
|
|
9
|
+
void import('./cli.ts')
|
|
10
|
+
.then(m => m.runExtract(process.argv.slice(2)))
|
|
11
|
+
.then(code => {
|
|
12
|
+
// eslint-disable-next-line n/no-process-exit
|
|
13
|
+
process.exit(code);
|
|
14
|
+
})
|
|
15
|
+
.catch((err: unknown) => {
|
|
16
|
+
process.stderr.write(`${describeError(err)}\n`);
|
|
17
|
+
// eslint-disable-next-line n/no-process-exit
|
|
18
|
+
process.exit(1);
|
|
19
|
+
});
|
|
20
|
+
} else {
|
|
21
|
+
const server: McpServer = createServer();
|
|
22
|
+
const transport = new StdioServerTransport();
|
|
23
|
+
|
|
24
|
+
await server.connect(transport);
|
|
25
|
+
|
|
26
|
+
shutdownOnSignals([server]);
|
|
27
|
+
}
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { stringify as stringifyYaml } from 'yaml';
|
|
2
|
+
|
|
3
|
+
import type { Diagnostics, Metadata } from '../pipeline/context.ts';
|
|
4
|
+
|
|
5
|
+
import { headingText, parseBlocks } from '../policy/markdown.ts';
|
|
6
|
+
|
|
7
|
+
export type Format = 'html' | 'json' | 'markdown' | 'text';
|
|
8
|
+
export type MetadataMode = 'json' | 'none' | 'yaml';
|
|
9
|
+
|
|
10
|
+
export interface FormatInput {
|
|
11
|
+
readonly diagnostics: Diagnostics;
|
|
12
|
+
readonly format: Format;
|
|
13
|
+
readonly markdown: string;
|
|
14
|
+
readonly metadata: Metadata;
|
|
15
|
+
readonly metadataMode: MetadataMode;
|
|
16
|
+
readonly sanitizedHtml: string;
|
|
17
|
+
readonly textContent: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface JsonObject {
|
|
21
|
+
readonly content: string;
|
|
22
|
+
readonly diagnostics: Diagnostics;
|
|
23
|
+
readonly metadata: Metadata;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Readability demotes the article <h1> to <h2> inside `content` and mirrors its
|
|
27
|
+
// text into the title; drop the echoed sub-heading so the title prints once.
|
|
28
|
+
function dropEchoedTitle(body: string, title: string): string {
|
|
29
|
+
const blocks = parseBlocks(body);
|
|
30
|
+
if (blocks.length === 0) {
|
|
31
|
+
return body;
|
|
32
|
+
}
|
|
33
|
+
const first = blocks[0];
|
|
34
|
+
if (
|
|
35
|
+
first.kind === 'heading' &&
|
|
36
|
+
headingText(body.slice(first.start, first.end)) === title.trim()
|
|
37
|
+
) {
|
|
38
|
+
return body.slice(first.end).replace(/^\n+/, '');
|
|
39
|
+
}
|
|
40
|
+
return body;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function renderMarkdown(input: Readonly<FormatInput>): string {
|
|
44
|
+
const title = input.metadata.title?.trim();
|
|
45
|
+
let body = input.markdown;
|
|
46
|
+
if (title) {
|
|
47
|
+
body = dropEchoedTitle(body, title);
|
|
48
|
+
return `# ${title}\n\n${body}`.replace(/\n+$/, '\n');
|
|
49
|
+
}
|
|
50
|
+
return body.replace(/\n+$/, '\n');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Stable key order for deterministic frontmatter. `structured` is deliberately
|
|
54
|
+
// absent: it is a nested object, not a scalar, and would dump a whole JSON-LD
|
|
55
|
+
// graph into frontmatter. It reaches the host via format=json and
|
|
56
|
+
// structuredContent.metadata, which serialize the full metadata object.
|
|
57
|
+
const METADATA_KEYS = [
|
|
58
|
+
'title',
|
|
59
|
+
'byline',
|
|
60
|
+
'siteName',
|
|
61
|
+
'lang',
|
|
62
|
+
'publishedTime',
|
|
63
|
+
'excerpt',
|
|
64
|
+
'canonical',
|
|
65
|
+
'baseUrl',
|
|
66
|
+
'wordCount',
|
|
67
|
+
'readingTimeMin',
|
|
68
|
+
'tokenEstimate',
|
|
69
|
+
'estimator',
|
|
70
|
+
] as const satisfies readonly (keyof Metadata)[];
|
|
71
|
+
|
|
72
|
+
function pickMetadata(
|
|
73
|
+
metadata: Readonly<Metadata>,
|
|
74
|
+
): Record<string, number | string> {
|
|
75
|
+
const picked: Record<string, number | string> = {};
|
|
76
|
+
for (const key of METADATA_KEYS) {
|
|
77
|
+
const value = metadata[key];
|
|
78
|
+
if (value !== undefined) {
|
|
79
|
+
picked[key] = value;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return picked;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// lineWidth: 0 disables folding so each field stays on one line.
|
|
86
|
+
function yamlFrontmatter(metadata: Readonly<Metadata>): string {
|
|
87
|
+
return `---\n${stringifyYaml(pickMetadata(metadata), { lineWidth: 0 })}---\n`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function jsonFrontmatter(metadata: Readonly<Metadata>): string {
|
|
91
|
+
return (
|
|
92
|
+
'```json\n' + JSON.stringify(pickMetadata(metadata), null, 2) + '\n```\n'
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function withFrontmatter(
|
|
97
|
+
payload: string,
|
|
98
|
+
mode: MetadataMode,
|
|
99
|
+
metadata: Readonly<Metadata>,
|
|
100
|
+
): string {
|
|
101
|
+
if (mode === 'yaml') {
|
|
102
|
+
return `${yamlFrontmatter(metadata)}${payload}`;
|
|
103
|
+
}
|
|
104
|
+
if (mode === 'json') {
|
|
105
|
+
return `${jsonFrontmatter(metadata)}\n${payload}`;
|
|
106
|
+
}
|
|
107
|
+
return payload;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function formatPayload(input: Readonly<FormatInput>): string {
|
|
111
|
+
let payload: string;
|
|
112
|
+
switch (input.format) {
|
|
113
|
+
case 'html':
|
|
114
|
+
return input.sanitizedHtml;
|
|
115
|
+
case 'json': {
|
|
116
|
+
const body: JsonObject = {
|
|
117
|
+
metadata: input.metadata,
|
|
118
|
+
content: input.markdown,
|
|
119
|
+
diagnostics: input.diagnostics,
|
|
120
|
+
};
|
|
121
|
+
return JSON.stringify(body, null, 2);
|
|
122
|
+
}
|
|
123
|
+
case 'text':
|
|
124
|
+
payload = input.textContent;
|
|
125
|
+
break;
|
|
126
|
+
case 'markdown':
|
|
127
|
+
default:
|
|
128
|
+
payload = renderMarkdown(input);
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
return withFrontmatter(payload, input.metadataMode, input.metadata);
|
|
132
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Resolve a table/grid cell element to the string that represents it in the
|
|
2
|
+
// matrix. Shared by tables.ts (the <table> serializer) and
|
|
3
|
+
// policy/grid-detector.ts (the div-grid detector) so both paths project a
|
|
4
|
+
// cell the same way: whitespace-normalized visible text, with two fallbacks.
|
|
5
|
+
//
|
|
6
|
+
// 1. Tooltip/badge/aria wrappers carry chrome, not data — they are stripped
|
|
7
|
+
// from a clone so a label cell reads "Market Capitalization" rather than
|
|
8
|
+
// concatenating every descendant. But stripping must never empty a cell: a
|
|
9
|
+
// badge/aria element that holds a cell's only text is data, not chrome, so
|
|
10
|
+
// when stripping would blank the cell the full text is kept.
|
|
11
|
+
// 2. A cell whose only content is an icon link has no visible text at all; its
|
|
12
|
+
// value lives in the anchor's href, which is surfaced as a last resort.
|
|
13
|
+
const CELL_CHROME_SELECTOR =
|
|
14
|
+
'[aria-label], [data-tooltip], [data-toggle="tooltip"], .tooltip, .badge';
|
|
15
|
+
|
|
16
|
+
function normalize(text: string): string {
|
|
17
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// All non-empty hrefs reachable from the cell, including the cell itself when it
|
|
21
|
+
// is an anchor (a div-grid "cell" can be an <a>). Descendant-only search would
|
|
22
|
+
// miss a link-cell whose element IS the anchor.
|
|
23
|
+
function nonEmptyHrefs(cell: Element): string[] {
|
|
24
|
+
const anchors: Element[] =
|
|
25
|
+
cell.tagName === 'A'
|
|
26
|
+
? [cell, ...Array.from(cell.querySelectorAll('a[href]'))]
|
|
27
|
+
: Array.from(cell.querySelectorAll('a[href]'));
|
|
28
|
+
const hrefs: string[] = [];
|
|
29
|
+
for (const a of anchors) {
|
|
30
|
+
const href = (a.getAttribute('href') ?? '').trim();
|
|
31
|
+
if (href !== '') {
|
|
32
|
+
hrefs.push(href);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return hrefs;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function resolveCellText(cell: Element): string {
|
|
39
|
+
const full = normalize(cell.textContent);
|
|
40
|
+
let resolved = full;
|
|
41
|
+
if (full !== '') {
|
|
42
|
+
const clone = cell.cloneNode(true) as Element;
|
|
43
|
+
clone.querySelectorAll(CELL_CHROME_SELECTOR).forEach(el => {
|
|
44
|
+
el.remove();
|
|
45
|
+
});
|
|
46
|
+
const stripped = normalize(clone.textContent);
|
|
47
|
+
resolved = stripped !== '' ? stripped : full;
|
|
48
|
+
}
|
|
49
|
+
if (resolved === '') {
|
|
50
|
+
const hrefs = nonEmptyHrefs(cell);
|
|
51
|
+
if (hrefs.length > 0) {
|
|
52
|
+
return hrefs.join(' ');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return resolved;
|
|
56
|
+
}
|