@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
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import type { SanitizationDiagnostics } from '../pipeline/context.ts';
|
|
2
|
+
import type { ToolHandle } from '../server.ts';
|
|
3
|
+
|
|
4
|
+
import { ExtractionError } from '../errors.ts';
|
|
5
|
+
import { tool } from '../handler.ts';
|
|
6
|
+
import { logger } from '../logger.ts';
|
|
7
|
+
import { formatPayload } from '../output/format.ts';
|
|
8
|
+
import { buildDocument } from '../pipeline/dom.ts';
|
|
9
|
+
import {
|
|
10
|
+
applySelectors,
|
|
11
|
+
canonicalizeCodeBlocks,
|
|
12
|
+
normalizeDocument,
|
|
13
|
+
resolveLazyImages,
|
|
14
|
+
} from '../pipeline/normalize.ts';
|
|
15
|
+
import { isReaderable, parseArticle } from '../pipeline/readability.ts';
|
|
16
|
+
import { resolveReadabilityOptions } from '../pipeline/resolver.ts';
|
|
17
|
+
import { sanitizeHtml } from '../pipeline/sanitize.ts';
|
|
18
|
+
import { toMarkdown } from '../pipeline/turndown.ts';
|
|
19
|
+
import { chunkMarkdown } from '../policy/chunk.ts';
|
|
20
|
+
import { assembleDiagnostics, TraceCollector } from '../policy/diagnostics.ts';
|
|
21
|
+
import { extractViaFallback } from '../policy/fallback.ts';
|
|
22
|
+
import { detectGating } from '../policy/gating.ts';
|
|
23
|
+
import { collectImageInventory } from '../policy/images.ts';
|
|
24
|
+
import { resolveMetadata } from '../policy/metadata.ts';
|
|
25
|
+
import { detectPagination } from '../policy/pagination.ts';
|
|
26
|
+
import { resolvePreset } from '../policy/presets.ts';
|
|
27
|
+
import { computeTextMetrics } from '../policy/text.ts';
|
|
28
|
+
import { truncateMarkdown } from '../policy/truncate.ts';
|
|
29
|
+
import * as cache from '../resources.ts';
|
|
30
|
+
import { fromLocalPath, withSchemaDefaults } from './html-source.ts';
|
|
31
|
+
import { outputSchemaShape } from './output-schema.ts';
|
|
32
|
+
import {
|
|
33
|
+
type ExtractFromHtmlInput,
|
|
34
|
+
extractInputSchema,
|
|
35
|
+
extractInputShape,
|
|
36
|
+
} from './schemas.ts';
|
|
37
|
+
|
|
38
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
39
|
+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
40
|
+
|
|
41
|
+
// Names the extraction path, not a DOM tag — Readability doesn't expose the source node.
|
|
42
|
+
const EXTRACTED_NODE = 'readability';
|
|
43
|
+
|
|
44
|
+
export function extractArticle(rawArgs: unknown): CallToolResult {
|
|
45
|
+
return extractArticleFromHtml(
|
|
46
|
+
fromLocalPath(extractInputSchema.parse(rawArgs)),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Internal worker option, never in the zod schema: the only caller that turns
|
|
51
|
+
// preset resolution off is extract_section, whose re-serialized subtree document
|
|
52
|
+
// carries none of the site fingerprints a preset validates against.
|
|
53
|
+
export interface ExtractWorkerInput extends ExtractFromHtmlInput {
|
|
54
|
+
readonly resolvePreset?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function extractArticleFromHtml(
|
|
58
|
+
input: Readonly<ExtractWorkerInput>,
|
|
59
|
+
): CallToolResult {
|
|
60
|
+
const merged = withSchemaDefaults(extractInputSchema, input);
|
|
61
|
+
const {
|
|
62
|
+
html,
|
|
63
|
+
baseUrl,
|
|
64
|
+
cache: useCache,
|
|
65
|
+
selectors,
|
|
66
|
+
extraction,
|
|
67
|
+
minArticleLength,
|
|
68
|
+
maxNodes,
|
|
69
|
+
keepClasses,
|
|
70
|
+
readabilityOverrides,
|
|
71
|
+
format,
|
|
72
|
+
metadataMode,
|
|
73
|
+
gfm,
|
|
74
|
+
headingStyle,
|
|
75
|
+
codeBlockStyle,
|
|
76
|
+
images,
|
|
77
|
+
sanitize: shouldSanitize,
|
|
78
|
+
maxChars,
|
|
79
|
+
wordsPerMinute,
|
|
80
|
+
cleanChrome,
|
|
81
|
+
tables,
|
|
82
|
+
chunk,
|
|
83
|
+
imageInventory,
|
|
84
|
+
debug,
|
|
85
|
+
resolvePreset: presetsEnabled = true,
|
|
86
|
+
} = merged;
|
|
87
|
+
|
|
88
|
+
// Cache hit short-circuits the pipeline. A clone is returned so the cache
|
|
89
|
+
// field can be overwritten to reflect *this* call's hashes (normalizedHash is
|
|
90
|
+
// stable across re-renders, but originalHash differs by nonce/CSP/…).
|
|
91
|
+
if (useCache) {
|
|
92
|
+
const hit = cache.lookup(html, merged);
|
|
93
|
+
if (hit) {
|
|
94
|
+
const cloned = JSON.parse(
|
|
95
|
+
JSON.stringify(hit.entry.structuredContent),
|
|
96
|
+
) as {
|
|
97
|
+
[key: string]: unknown;
|
|
98
|
+
diagnostics: Record<string, unknown>;
|
|
99
|
+
};
|
|
100
|
+
const structuredContent = {
|
|
101
|
+
...cloned,
|
|
102
|
+
diagnostics: {
|
|
103
|
+
...cloned.diagnostics,
|
|
104
|
+
cache: {
|
|
105
|
+
hit: true,
|
|
106
|
+
normalizedHash: hit.normalizedHash,
|
|
107
|
+
originalHash: hit.originalHash,
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
return {
|
|
112
|
+
content: [{ text: hit.entry.contentText, type: 'text' }],
|
|
113
|
+
structuredContent,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Stages are timed at the orchestrator boundary so they stay non-overlapping
|
|
119
|
+
// and sum to the pipeline's wall-clock — stripConsent/absolutize live inside
|
|
120
|
+
// normalize/turndown respectively and aren't carved out as their own stages.
|
|
121
|
+
const trace = new TraceCollector(debug);
|
|
122
|
+
|
|
123
|
+
const { document, window } = buildDocument(html, baseUrl);
|
|
124
|
+
// Paywall overlays are stripped by normalizeDocument's stripChrome (a Piano
|
|
125
|
+
// modal is role="dialog" + full-viewport fixed), so gating must be detected
|
|
126
|
+
// before normalization. Pagination chrome survives stripChrome and is detected after.
|
|
127
|
+
const {
|
|
128
|
+
gating,
|
|
129
|
+
documentElementCount,
|
|
130
|
+
normalizeCounts,
|
|
131
|
+
imagesResolved,
|
|
132
|
+
pagination,
|
|
133
|
+
preset,
|
|
134
|
+
} = trace.run('normalize', () => {
|
|
135
|
+
const gating = detectGating(document);
|
|
136
|
+
const documentElementCount = document.querySelectorAll('*').length;
|
|
137
|
+
const normalizeCounts = normalizeDocument(document, { cleanChrome });
|
|
138
|
+
const imagesResolved = resolveLazyImages(document);
|
|
139
|
+
// Detect before applySelectors: a caller's selectors.include could scope the
|
|
140
|
+
// body and hide pagination chrome, but "more content exists" is still true.
|
|
141
|
+
const pagination = detectPagination(document, baseUrl);
|
|
142
|
+
const resolution = presetsEnabled
|
|
143
|
+
? resolvePreset(document, baseUrl, selectors !== undefined)
|
|
144
|
+
: undefined;
|
|
145
|
+
applySelectors(document, resolution?.scope ?? selectors);
|
|
146
|
+
const codeBlocksCanonicalized = canonicalizeCodeBlocks(document);
|
|
147
|
+
if (codeBlocksCanonicalized > 0) {
|
|
148
|
+
logger.debug(
|
|
149
|
+
`canonicalized ${codeBlocksCanonicalized} code-block language tag(s)`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
documentElementCount,
|
|
154
|
+
gating,
|
|
155
|
+
imagesResolved,
|
|
156
|
+
normalizeCounts,
|
|
157
|
+
pagination,
|
|
158
|
+
preset: resolution?.signal,
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const { readerable, article } = trace.run('readability', () => {
|
|
163
|
+
const readerable = isReaderable(document);
|
|
164
|
+
const readabilityOptions = resolveReadabilityOptions({
|
|
165
|
+
extraction,
|
|
166
|
+
keepClasses,
|
|
167
|
+
maxNodes,
|
|
168
|
+
minArticleLength,
|
|
169
|
+
readabilityOverrides,
|
|
170
|
+
});
|
|
171
|
+
const article = parseArticle(document, readabilityOptions);
|
|
172
|
+
return { article, readerable };
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
let markdown: string;
|
|
176
|
+
let sanitizedHtml: string;
|
|
177
|
+
let textContent: string;
|
|
178
|
+
let extractedNode: string;
|
|
179
|
+
let fallbackUsed: boolean;
|
|
180
|
+
let sanitizeCounts: SanitizationDiagnostics;
|
|
181
|
+
|
|
182
|
+
if (article?.content) {
|
|
183
|
+
extractedNode = EXTRACTED_NODE;
|
|
184
|
+
fallbackUsed = false;
|
|
185
|
+
textContent = article.textContent ?? '';
|
|
186
|
+
const articleHtml = article.content;
|
|
187
|
+
const sanitized = trace.run(
|
|
188
|
+
'sanitize',
|
|
189
|
+
(): {
|
|
190
|
+
counts: SanitizationDiagnostics;
|
|
191
|
+
html: string;
|
|
192
|
+
} => {
|
|
193
|
+
if (!shouldSanitize) {
|
|
194
|
+
return { counts: { iframes: 0, scripts: 0 }, html: articleHtml };
|
|
195
|
+
}
|
|
196
|
+
const res = sanitizeHtml(articleHtml, window);
|
|
197
|
+
return {
|
|
198
|
+
counts: { iframes: res.iframesRemoved, scripts: res.scriptsRemoved },
|
|
199
|
+
html: res.html,
|
|
200
|
+
};
|
|
201
|
+
},
|
|
202
|
+
);
|
|
203
|
+
sanitizedHtml = sanitized.html;
|
|
204
|
+
sanitizeCounts = sanitized.counts;
|
|
205
|
+
markdown = trace.run('turndown', () =>
|
|
206
|
+
toMarkdown(sanitizedHtml, {
|
|
207
|
+
codeBlockStyle,
|
|
208
|
+
gfm,
|
|
209
|
+
headingStyle,
|
|
210
|
+
images,
|
|
211
|
+
tables,
|
|
212
|
+
baseUrl,
|
|
213
|
+
}),
|
|
214
|
+
);
|
|
215
|
+
} else {
|
|
216
|
+
// Cascade only on parse failure, not on isProbablyReaderable.
|
|
217
|
+
// Fallback owns sanitize + turndown internally, so the two stages collapse
|
|
218
|
+
// into one timed block here — keeping the trace non-overlapping.
|
|
219
|
+
const fallback = trace.run('fallback', () =>
|
|
220
|
+
extractViaFallback(document, {
|
|
221
|
+
codeBlockStyle,
|
|
222
|
+
gfm,
|
|
223
|
+
headingStyle,
|
|
224
|
+
images,
|
|
225
|
+
sanitize: shouldSanitize,
|
|
226
|
+
tables,
|
|
227
|
+
baseUrl,
|
|
228
|
+
window,
|
|
229
|
+
}),
|
|
230
|
+
);
|
|
231
|
+
if (!fallback) {
|
|
232
|
+
throw new ExtractionError(
|
|
233
|
+
'Readability returned no article and the selector cascade yielded no usable content.',
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
extractedNode = fallback.rootSelector;
|
|
237
|
+
fallbackUsed = true;
|
|
238
|
+
markdown = fallback.markdown;
|
|
239
|
+
sanitizedHtml = fallback.sanitizedHtml;
|
|
240
|
+
textContent = fallback.textContent;
|
|
241
|
+
sanitizeCounts = fallback.sanitization;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const { metadata } = trace.run('metadata', () => {
|
|
245
|
+
const { wordCount, readingTimeMin } = computeTextMetrics(
|
|
246
|
+
textContent,
|
|
247
|
+
wordsPerMinute,
|
|
248
|
+
);
|
|
249
|
+
const metadata = resolveMetadata({
|
|
250
|
+
document,
|
|
251
|
+
readability: article,
|
|
252
|
+
readingTimeMin,
|
|
253
|
+
textContent,
|
|
254
|
+
baseUrl,
|
|
255
|
+
wordCount,
|
|
256
|
+
});
|
|
257
|
+
return { metadata };
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const sanitization: SanitizationDiagnostics = {
|
|
261
|
+
iframes: normalizeCounts.iframes + sanitizeCounts.iframes,
|
|
262
|
+
scripts: normalizeCounts.scripts + sanitizeCounts.scripts,
|
|
263
|
+
};
|
|
264
|
+
const baseDiagnostics = assembleDiagnostics({
|
|
265
|
+
articleHtml: sanitizedHtml,
|
|
266
|
+
boilerplateRemoved: normalizeCounts.boilerplateRemoved,
|
|
267
|
+
chromeRemoved: normalizeCounts.chromeRemoved,
|
|
268
|
+
documentElementCount,
|
|
269
|
+
extractedNode,
|
|
270
|
+
fallbackUsed,
|
|
271
|
+
gated: gating,
|
|
272
|
+
imagesResolved,
|
|
273
|
+
pagination,
|
|
274
|
+
preset,
|
|
275
|
+
readerable,
|
|
276
|
+
sanitization,
|
|
277
|
+
trace: trace.collect(),
|
|
278
|
+
truncated: false,
|
|
279
|
+
window,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
let payload = formatPayload({
|
|
283
|
+
diagnostics: baseDiagnostics,
|
|
284
|
+
format,
|
|
285
|
+
markdown,
|
|
286
|
+
metadata,
|
|
287
|
+
metadataMode,
|
|
288
|
+
sanitizedHtml,
|
|
289
|
+
textContent,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
let truncated = false;
|
|
293
|
+
if (maxChars !== undefined && (format === 'markdown' || format === 'text')) {
|
|
294
|
+
const res = truncateMarkdown(payload, maxChars);
|
|
295
|
+
payload = res.text;
|
|
296
|
+
truncated = res.truncated;
|
|
297
|
+
}
|
|
298
|
+
const diagnostics = truncated
|
|
299
|
+
? { ...baseDiagnostics, truncated }
|
|
300
|
+
: baseDiagnostics;
|
|
301
|
+
|
|
302
|
+
// Chunk the final payload (post-format, post-truncation) so token counts
|
|
303
|
+
// reflect what the host sees in content[0].text. HTML/JSON formats carry no
|
|
304
|
+
// markdown body to slice, so `chunks` stays unset for them.
|
|
305
|
+
const chunks =
|
|
306
|
+
chunk && (format === 'markdown' || format === 'text')
|
|
307
|
+
? chunkMarkdown(payload, chunk)
|
|
308
|
+
: undefined;
|
|
309
|
+
|
|
310
|
+
// Runs against sanitizedHtml so the inventory reflects exactly what the host
|
|
311
|
+
// sees in content[0].text — post-sanitization, post-lazy-resolution.
|
|
312
|
+
const imageInventoryEntries = imageInventory
|
|
313
|
+
? collectImageInventory(sanitizedHtml, window, baseUrl)
|
|
314
|
+
: undefined;
|
|
315
|
+
|
|
316
|
+
const baseStructuredContent = {
|
|
317
|
+
schemaVersion: 1 as const,
|
|
318
|
+
content: payload,
|
|
319
|
+
metadata,
|
|
320
|
+
diagnostics,
|
|
321
|
+
...(chunks ? { chunks } : {}),
|
|
322
|
+
...(imageInventoryEntries ? { images: imageInventoryEntries } : {}),
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
// On cache miss (when cache:true), persist a cache-agnostic copy so the
|
|
326
|
+
// stored entry never carries a stale hit/miss verdict, then surface the
|
|
327
|
+
// signal on the returned object only.
|
|
328
|
+
let structuredContent = baseStructuredContent;
|
|
329
|
+
if (useCache) {
|
|
330
|
+
const stored = cache.storeResult(html, merged, {
|
|
331
|
+
contentText: payload,
|
|
332
|
+
structuredContent: baseStructuredContent,
|
|
333
|
+
});
|
|
334
|
+
structuredContent = {
|
|
335
|
+
...baseStructuredContent,
|
|
336
|
+
diagnostics: {
|
|
337
|
+
...diagnostics,
|
|
338
|
+
cache: {
|
|
339
|
+
hit: false,
|
|
340
|
+
normalizedHash: stored.normalizedHash,
|
|
341
|
+
originalHash: stored.originalHash,
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
content: [{ text: payload, type: 'text' }],
|
|
349
|
+
structuredContent,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export const EXTRACT_TOOL_DESCRIPTION = `Extract the main article from already-rendered (post-JavaScript) HTML and return clean Markdown plus metadata and diagnostics. The server fetches nothing: \`localPath\` (a file holding the rendered HTML, e.g. \`document.documentElement.outerHTML\` written to disk by a browser/devtools capture) is the only source, and \`baseUrl\` (optional) is used solely to absolutize relative links.`;
|
|
354
|
+
|
|
355
|
+
export const extractHandler = tool('extract', extractArticle);
|
|
356
|
+
|
|
357
|
+
export function registerExtractTool(server: McpServer): ToolHandle {
|
|
358
|
+
return server.registerTool(
|
|
359
|
+
'extract',
|
|
360
|
+
{
|
|
361
|
+
title: 'Extract article to Markdown',
|
|
362
|
+
description: EXTRACT_TOOL_DESCRIPTION,
|
|
363
|
+
inputSchema: extractInputShape,
|
|
364
|
+
outputSchema: outputSchemaShape,
|
|
365
|
+
},
|
|
366
|
+
extractHandler,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
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 { renderTable } from '../pipeline/tables.ts';
|
|
7
|
+
import { detectGrid } from '../policy/grid-detector.ts';
|
|
8
|
+
import { fromLocalPath, withSchemaDefaults } from './html-source.ts';
|
|
9
|
+
import { extractGridOutputShape } from './output-schema.ts';
|
|
10
|
+
import {
|
|
11
|
+
type ExtractGridFromHtmlInput,
|
|
12
|
+
extractGridInputSchema,
|
|
13
|
+
extractGridInputShape,
|
|
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
|
+
interface GridEntry {
|
|
20
|
+
readonly cols: number;
|
|
21
|
+
readonly markdown: string;
|
|
22
|
+
readonly rows: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const NO_GRID = '(no repeating grid found)';
|
|
26
|
+
|
|
27
|
+
export function extractGridFromHtml(
|
|
28
|
+
input: Readonly<ExtractGridFromHtmlInput>,
|
|
29
|
+
): CallToolResult {
|
|
30
|
+
const { html, baseUrl, format, selectors, rowSelector, cellSelector } =
|
|
31
|
+
withSchemaDefaults(extractGridInputSchema, input);
|
|
32
|
+
|
|
33
|
+
// Same raw-DOM rationale as extract_tables/extract_list: the repeating grid
|
|
34
|
+
// lives in statically-rendered divs that Readability/normalize would discard
|
|
35
|
+
// (nav/aside/boilerplate), and grid shape is unaffected by unsanitized
|
|
36
|
+
// scripts/styles. Chrome stripping lives inside detectGrid (auto mode only)
|
|
37
|
+
// so selector mode sees the page as captured.
|
|
38
|
+
const { document } = buildDocument(html, baseUrl);
|
|
39
|
+
applySelectors(document, selectors);
|
|
40
|
+
const result = detectGrid(document, { rowSelector, cellSelector });
|
|
41
|
+
|
|
42
|
+
let content = NO_GRID;
|
|
43
|
+
let grid: GridEntry = { rows: 0, cols: 0, markdown: '' };
|
|
44
|
+
if (result.detected && result.rows.length > 0) {
|
|
45
|
+
const matrix = result.rows.map(row => [...row.cells]);
|
|
46
|
+
const markdown = renderTable(matrix, format);
|
|
47
|
+
content = markdown;
|
|
48
|
+
grid = { rows: result.rowCount, cols: result.colCount, markdown };
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
content: [{ text: content, type: 'text' }],
|
|
52
|
+
structuredContent: {
|
|
53
|
+
schemaVersion: 1,
|
|
54
|
+
content,
|
|
55
|
+
grid,
|
|
56
|
+
diagnostics: {
|
|
57
|
+
confidence: result.confidence,
|
|
58
|
+
containerSelector: result.containerSelector,
|
|
59
|
+
detected: result.detected,
|
|
60
|
+
rowCount: result.rowCount,
|
|
61
|
+
colCount: result.colCount,
|
|
62
|
+
rowTag: result.rowTag,
|
|
63
|
+
note: result.note,
|
|
64
|
+
},
|
|
65
|
+
metadata: { baseUrl, format, detected: result.detected },
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const EXTRACT_GRID_TOOL_DESCRIPTION = `Detect and extract a CSS-grid / div "table" from already-rendered (post-JavaScript) HTML — the div equivalent of \`extract_tables\` for SPAs that render data into repeating \`<div>\` rows instead of \`<table>\`. Supports auto-detect (find the container whose direct children form the largest same-shape sibling group of ≥3 rows, each row a set of ≥2 direct element-children) and explicit \`rowSelector\` + \`cellSelector\` selector mode (cells scoped to each row subtree). Renders the matrix through the SAME gfm/csv/json renderer as \`extract_tables\`. Runs no Readability, no Turndown, no sanitization — the server fetches nothing: \`localPath\` is the only source, and \`baseUrl\` (optional) is origin context only.`;
|
|
71
|
+
|
|
72
|
+
export const extractGridHandler = tool(
|
|
73
|
+
'extract_grid',
|
|
74
|
+
input => extractGridFromHtml(fromLocalPath(input)),
|
|
75
|
+
extractGridInputSchema,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
export function registerExtractGridTool(server: McpServer): ToolHandle {
|
|
79
|
+
return server.registerTool(
|
|
80
|
+
'extract_grid',
|
|
81
|
+
{
|
|
82
|
+
title:
|
|
83
|
+
'Extract a CSS-grid / div table (the div equivalent of extract_tables)',
|
|
84
|
+
description: EXTRACT_GRID_TOOL_DESCRIPTION,
|
|
85
|
+
inputSchema: extractGridInputShape,
|
|
86
|
+
outputSchema: extractGridOutputShape,
|
|
87
|
+
},
|
|
88
|
+
extractGridHandler,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -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,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
|
+
}
|