@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,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.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { describeError, shutdownOnSignals } from '@v1nvn/agentic-core';
|
|
4
|
+
import { createServer } from "./server.js";
|
|
5
|
+
if (process.argv[2] === 'extract') {
|
|
6
|
+
void import("./cli.js")
|
|
7
|
+
.then(m => m.runExtract(process.argv.slice(2)))
|
|
8
|
+
.then(code => {
|
|
9
|
+
// eslint-disable-next-line n/no-process-exit
|
|
10
|
+
process.exit(code);
|
|
11
|
+
})
|
|
12
|
+
.catch((err) => {
|
|
13
|
+
process.stderr.write(`${describeError(err)}\n`);
|
|
14
|
+
// eslint-disable-next-line n/no-process-exit
|
|
15
|
+
process.exit(1);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
const server = createServer();
|
|
20
|
+
const transport = new StdioServerTransport();
|
|
21
|
+
await server.connect(transport);
|
|
22
|
+
shutdownOnSignals([server]);
|
|
23
|
+
}
|
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.js
ADDED
package/src/logger.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { stringify as stringifyYaml } from 'yaml';
|
|
2
|
+
import { headingText, parseBlocks } from "../policy/markdown.js";
|
|
3
|
+
// Readability demotes the article <h1> to <h2> inside `content` and mirrors its
|
|
4
|
+
// text into the title; drop the echoed sub-heading so the title prints once.
|
|
5
|
+
function dropEchoedTitle(body, title) {
|
|
6
|
+
const blocks = parseBlocks(body);
|
|
7
|
+
if (blocks.length === 0) {
|
|
8
|
+
return body;
|
|
9
|
+
}
|
|
10
|
+
const first = blocks[0];
|
|
11
|
+
if (first.kind === 'heading' &&
|
|
12
|
+
headingText(body.slice(first.start, first.end)) === title.trim()) {
|
|
13
|
+
return body.slice(first.end).replace(/^\n+/, '');
|
|
14
|
+
}
|
|
15
|
+
return body;
|
|
16
|
+
}
|
|
17
|
+
function renderMarkdown(input) {
|
|
18
|
+
const title = input.metadata.title?.trim();
|
|
19
|
+
let body = input.markdown;
|
|
20
|
+
if (title) {
|
|
21
|
+
body = dropEchoedTitle(body, title);
|
|
22
|
+
return `# ${title}\n\n${body}`.replace(/\n+$/, '\n');
|
|
23
|
+
}
|
|
24
|
+
return body.replace(/\n+$/, '\n');
|
|
25
|
+
}
|
|
26
|
+
// Stable key order for deterministic frontmatter. `structured` is deliberately
|
|
27
|
+
// absent: it is a nested object, not a scalar, and would dump a whole JSON-LD
|
|
28
|
+
// graph into frontmatter. It reaches the host via format=json and
|
|
29
|
+
// structuredContent.metadata, which serialize the full metadata object.
|
|
30
|
+
const METADATA_KEYS = [
|
|
31
|
+
'title',
|
|
32
|
+
'byline',
|
|
33
|
+
'siteName',
|
|
34
|
+
'lang',
|
|
35
|
+
'publishedTime',
|
|
36
|
+
'excerpt',
|
|
37
|
+
'canonical',
|
|
38
|
+
'baseUrl',
|
|
39
|
+
'wordCount',
|
|
40
|
+
'readingTimeMin',
|
|
41
|
+
'tokenEstimate',
|
|
42
|
+
'estimator',
|
|
43
|
+
];
|
|
44
|
+
function pickMetadata(metadata) {
|
|
45
|
+
const picked = {};
|
|
46
|
+
for (const key of METADATA_KEYS) {
|
|
47
|
+
const value = metadata[key];
|
|
48
|
+
if (value !== undefined) {
|
|
49
|
+
picked[key] = value;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return picked;
|
|
53
|
+
}
|
|
54
|
+
// lineWidth: 0 disables folding so each field stays on one line.
|
|
55
|
+
function yamlFrontmatter(metadata) {
|
|
56
|
+
return `---\n${stringifyYaml(pickMetadata(metadata), { lineWidth: 0 })}---\n`;
|
|
57
|
+
}
|
|
58
|
+
function jsonFrontmatter(metadata) {
|
|
59
|
+
return ('```json\n' + JSON.stringify(pickMetadata(metadata), null, 2) + '\n```\n');
|
|
60
|
+
}
|
|
61
|
+
function withFrontmatter(payload, mode, metadata) {
|
|
62
|
+
if (mode === 'yaml') {
|
|
63
|
+
return `${yamlFrontmatter(metadata)}${payload}`;
|
|
64
|
+
}
|
|
65
|
+
if (mode === 'json') {
|
|
66
|
+
return `${jsonFrontmatter(metadata)}\n${payload}`;
|
|
67
|
+
}
|
|
68
|
+
return payload;
|
|
69
|
+
}
|
|
70
|
+
export function formatPayload(input) {
|
|
71
|
+
let payload;
|
|
72
|
+
switch (input.format) {
|
|
73
|
+
case 'html':
|
|
74
|
+
return input.sanitizedHtml;
|
|
75
|
+
case 'json': {
|
|
76
|
+
const body = {
|
|
77
|
+
metadata: input.metadata,
|
|
78
|
+
content: input.markdown,
|
|
79
|
+
diagnostics: input.diagnostics,
|
|
80
|
+
};
|
|
81
|
+
return JSON.stringify(body, null, 2);
|
|
82
|
+
}
|
|
83
|
+
case 'text':
|
|
84
|
+
payload = input.textContent;
|
|
85
|
+
break;
|
|
86
|
+
case 'markdown':
|
|
87
|
+
default:
|
|
88
|
+
payload = renderMarkdown(input);
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
return withFrontmatter(payload, input.metadataMode, input.metadata);
|
|
92
|
+
}
|
|
@@ -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,51 @@
|
|
|
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 = '[aria-label], [data-tooltip], [data-toggle="tooltip"], .tooltip, .badge';
|
|
14
|
+
function normalize(text) {
|
|
15
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
16
|
+
}
|
|
17
|
+
// All non-empty hrefs reachable from the cell, including the cell itself when it
|
|
18
|
+
// is an anchor (a div-grid "cell" can be an <a>). Descendant-only search would
|
|
19
|
+
// miss a link-cell whose element IS the anchor.
|
|
20
|
+
function nonEmptyHrefs(cell) {
|
|
21
|
+
const anchors = cell.tagName === 'A'
|
|
22
|
+
? [cell, ...Array.from(cell.querySelectorAll('a[href]'))]
|
|
23
|
+
: Array.from(cell.querySelectorAll('a[href]'));
|
|
24
|
+
const hrefs = [];
|
|
25
|
+
for (const a of anchors) {
|
|
26
|
+
const href = (a.getAttribute('href') ?? '').trim();
|
|
27
|
+
if (href !== '') {
|
|
28
|
+
hrefs.push(href);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return hrefs;
|
|
32
|
+
}
|
|
33
|
+
export function resolveCellText(cell) {
|
|
34
|
+
const full = normalize(cell.textContent);
|
|
35
|
+
let resolved = full;
|
|
36
|
+
if (full !== '') {
|
|
37
|
+
const clone = cell.cloneNode(true);
|
|
38
|
+
clone.querySelectorAll(CELL_CHROME_SELECTOR).forEach(el => {
|
|
39
|
+
el.remove();
|
|
40
|
+
});
|
|
41
|
+
const stripped = normalize(clone.textContent);
|
|
42
|
+
resolved = stripped !== '' ? stripped : full;
|
|
43
|
+
}
|
|
44
|
+
if (resolved === '') {
|
|
45
|
+
const hrefs = nonEmptyHrefs(cell);
|
|
46
|
+
if (hrefs.length > 0) {
|
|
47
|
+
return hrefs.join(' ');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return resolved;
|
|
51
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ReadabilityParseResult } from './readability.ts';
|
|
2
|
+
|
|
3
|
+
interface GatingSignal {
|
|
4
|
+
readonly likely: boolean;
|
|
5
|
+
readonly reason: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface PaginationSignal {
|
|
9
|
+
readonly nextUrl?: string;
|
|
10
|
+
readonly selector?: string;
|
|
11
|
+
readonly type: 'infinite' | 'paginated';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface PresetSignal {
|
|
15
|
+
readonly applied: boolean;
|
|
16
|
+
readonly reason?: 'detectors-missed' | 'overridden';
|
|
17
|
+
readonly site: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface StructuredData {
|
|
21
|
+
readonly '@type'?: string;
|
|
22
|
+
readonly [key: string]: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Metadata {
|
|
26
|
+
readonly baseUrl?: string;
|
|
27
|
+
readonly byline?: string;
|
|
28
|
+
readonly canonical?: string;
|
|
29
|
+
readonly estimator?: string;
|
|
30
|
+
readonly excerpt?: string;
|
|
31
|
+
readonly lang?: string;
|
|
32
|
+
readonly publishedTime?: string;
|
|
33
|
+
readonly readingTimeMin?: number;
|
|
34
|
+
readonly siteName?: string;
|
|
35
|
+
readonly structured?: StructuredData;
|
|
36
|
+
readonly title?: string;
|
|
37
|
+
readonly tokenEstimate?: number;
|
|
38
|
+
readonly wordCount?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SanitizationDiagnostics {
|
|
42
|
+
readonly iframes: number;
|
|
43
|
+
readonly scripts: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface TraceStage {
|
|
47
|
+
readonly ms: number;
|
|
48
|
+
readonly stage: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface CacheSignal {
|
|
52
|
+
readonly hit: boolean;
|
|
53
|
+
readonly normalizedHash: string;
|
|
54
|
+
readonly originalHash: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface Diagnostics {
|
|
58
|
+
readonly boilerplateRemoved?: number;
|
|
59
|
+
readonly cache?: CacheSignal;
|
|
60
|
+
readonly chromeRemoved?: number;
|
|
61
|
+
readonly extractedNode?: string;
|
|
62
|
+
readonly fallbackUsed: boolean;
|
|
63
|
+
readonly gated?: GatingSignal;
|
|
64
|
+
readonly imagesResolved?: number;
|
|
65
|
+
readonly pagination?: PaginationSignal;
|
|
66
|
+
readonly preset?: PresetSignal;
|
|
67
|
+
readonly readerable?: boolean;
|
|
68
|
+
readonly removedNodes?: number;
|
|
69
|
+
readonly sanitization?: SanitizationDiagnostics;
|
|
70
|
+
readonly trace?: readonly TraceStage[];
|
|
71
|
+
readonly truncated: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ExtractionContext {
|
|
75
|
+
readonly article?: null | Readonly<ReadabilityParseResult>;
|
|
76
|
+
readonly baseUrl?: string;
|
|
77
|
+
readonly diagnostics?: Diagnostics;
|
|
78
|
+
readonly document?: Document;
|
|
79
|
+
readonly documentElementCount?: number;
|
|
80
|
+
readonly html: string;
|
|
81
|
+
readonly markdown?: string;
|
|
82
|
+
readonly metadata?: Metadata;
|
|
83
|
+
readonly readerable?: boolean;
|
|
84
|
+
readonly rootSelector?: string;
|
|
85
|
+
readonly sanitizedHtml?: string;
|
|
86
|
+
// Must be the same window the document came from — DOMPurify is bound to it.
|
|
87
|
+
readonly window?: Window;
|
|
88
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { JSDOM } from 'jsdom';
|
|
2
|
+
// Built only from caller-supplied `html`; `baseUrl` is origin context for
|
|
3
|
+
// absolutization, never fetched. Parse and the downstream Readability walk run
|
|
4
|
+
// synchronously on the event loop — no worker isolation or wall-clock timeout
|
|
5
|
+
// guards them. A worker pool was deferred rather than rejected: the synchronous
|
|
6
|
+
// parse can't be interrupted in-process, and `worker_threads` can't load the dev
|
|
7
|
+
// hot-reload loop's in-memory Vite modules. `maxNodes` is the only input-size
|
|
8
|
+
// bound until a holistic worker strategy lands; a standalone `timeout` option
|
|
9
|
+
// would mislead (it can't interrupt the parse), so it ships with the worker.
|
|
10
|
+
export function buildDocument(html, baseUrl) {
|
|
11
|
+
const dom = new JSDOM(html, { url: baseUrl });
|
|
12
|
+
return { document: dom.window.document, window: dom.window };
|
|
13
|
+
}
|
|
14
|
+
export function isElement(node) {
|
|
15
|
+
return node.nodeType === 1;
|
|
16
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { JSDOM } from 'jsdom';
|
|
2
|
+
|
|
3
|
+
export interface BuiltDocument {
|
|
4
|
+
readonly document: Document;
|
|
5
|
+
readonly window: Window;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Built only from caller-supplied `html`; `baseUrl` is origin context for
|
|
9
|
+
// absolutization, never fetched. Parse and the downstream Readability walk run
|
|
10
|
+
// synchronously on the event loop — no worker isolation or wall-clock timeout
|
|
11
|
+
// guards them. A worker pool was deferred rather than rejected: the synchronous
|
|
12
|
+
// parse can't be interrupted in-process, and `worker_threads` can't load the dev
|
|
13
|
+
// hot-reload loop's in-memory Vite modules. `maxNodes` is the only input-size
|
|
14
|
+
// bound until a holistic worker strategy lands; a standalone `timeout` option
|
|
15
|
+
// would mislead (it can't interrupt the parse), so it ships with the worker.
|
|
16
|
+
export function buildDocument(html: string, baseUrl?: string): BuiltDocument {
|
|
17
|
+
const dom = new JSDOM(html, { url: baseUrl });
|
|
18
|
+
return { document: dom.window.document, window: dom.window };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isElement(node: Node): node is Element {
|
|
22
|
+
return node.nodeType === 1;
|
|
23
|
+
}
|