@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.
Files changed (71) hide show
  1. package/README.md +3 -3
  2. package/package.json +7 -9
  3. package/src/cli.ts +134 -0
  4. package/src/config.ts +42 -0
  5. package/src/dev.ts +77 -0
  6. package/src/errors.ts +19 -0
  7. package/src/handler.ts +8 -0
  8. package/src/host-sampling.ts +86 -0
  9. package/src/index.ts +27 -0
  10. package/src/logger.ts +3 -0
  11. package/src/output/format.ts +132 -0
  12. package/src/pipeline/cell-text.ts +56 -0
  13. package/src/pipeline/context.ts +88 -0
  14. package/src/pipeline/dom.ts +23 -0
  15. package/src/pipeline/footnotes.ts +181 -0
  16. package/src/pipeline/math.ts +130 -0
  17. package/src/pipeline/normalize.ts +594 -0
  18. package/src/pipeline/readability.ts +36 -0
  19. package/src/pipeline/resolver.ts +143 -0
  20. package/src/pipeline/sanitize.ts +42 -0
  21. package/src/pipeline/tables.ts +286 -0
  22. package/src/pipeline/turndown.ts +191 -0
  23. package/src/pipeline/urls.ts +13 -0
  24. package/src/policy/chunk.ts +482 -0
  25. package/src/policy/diagnostics.ts +96 -0
  26. package/src/policy/explain.ts +182 -0
  27. package/src/policy/fallback.ts +110 -0
  28. package/src/policy/gating.ts +106 -0
  29. package/src/policy/grid-detector.ts +313 -0
  30. package/src/policy/identifiers.ts +75 -0
  31. package/src/policy/images.ts +64 -0
  32. package/src/policy/list-detector.ts +308 -0
  33. package/src/policy/lost-signal.ts +69 -0
  34. package/src/policy/markdown.ts +76 -0
  35. package/src/policy/metadata.ts +312 -0
  36. package/src/policy/outline-chains.ts +215 -0
  37. package/src/policy/outline.ts +89 -0
  38. package/src/policy/pagination.ts +104 -0
  39. package/src/policy/presets.ts +142 -0
  40. package/src/policy/section.ts +127 -0
  41. package/src/policy/selector-lint.ts +186 -0
  42. package/src/policy/sibling-scan.ts +99 -0
  43. package/src/policy/text.ts +36 -0
  44. package/src/policy/truncate.ts +59 -0
  45. package/src/preset-cache.ts +221 -0
  46. package/src/resources.ts +317 -0
  47. package/src/sampling.ts +74 -0
  48. package/src/server.ts +85 -0
  49. package/src/tools/chunk_text.ts +65 -0
  50. package/src/tools/explain.ts +299 -0
  51. package/src/tools/extract.ts +368 -0
  52. package/src/tools/extract_grid.ts +90 -0
  53. package/src/tools/extract_links.ts +138 -0
  54. package/src/tools/extract_list.ts +97 -0
  55. package/src/tools/extract_metadata.ts +102 -0
  56. package/src/tools/extract_section.ts +84 -0
  57. package/src/tools/extract_tables.ts +101 -0
  58. package/src/tools/html-source.ts +40 -0
  59. package/src/tools/html_to_markdown.ts +180 -0
  60. package/src/tools/outline.ts +77 -0
  61. package/src/tools/output-schema.ts +715 -0
  62. package/src/tools/schemas.ts +442 -0
  63. package/src/tools/suggest-preset.ts +708 -0
  64. package/src/types/jsdom.d.ts +15 -0
  65. package/src/types/turndown-plugin-gfm.d.ts +11 -0
  66. package/dist/assets/cli-BE_Ml3rh.js +0 -76
  67. package/dist/assets/cli-BE_Ml3rh.js.map +0 -1
  68. package/dist/assets/extract-Cyv5qfGN.js +0 -2880
  69. package/dist/assets/extract-Cyv5qfGN.js.map +0 -1
  70. package/dist/index.js +0 -2267
  71. 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
+ }