@v1nvn/readability 0.45.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 +4 -4
- package/src/cli.js +99 -0
- package/src/config.js +29 -0
- package/src/dev.js +55 -0
- package/src/errors.js +16 -0
- package/src/handler.js +4 -0
- package/src/host-sampling.js +57 -0
- package/src/index.js +23 -0
- package/src/index.ts +0 -0
- package/src/logger.js +2 -0
- package/src/output/format.js +92 -0
- package/src/pipeline/cell-text.js +51 -0
- package/src/pipeline/context.js +1 -0
- package/src/pipeline/dom.js +16 -0
- package/src/pipeline/footnotes.js +148 -0
- package/src/pipeline/math.js +113 -0
- package/src/pipeline/normalize.js +507 -0
- package/src/pipeline/readability.js +10 -0
- package/src/pipeline/resolver.js +113 -0
- package/src/pipeline/sanitize.js +26 -0
- package/src/pipeline/tables.js +239 -0
- package/src/pipeline/turndown.js +153 -0
- package/src/pipeline/urls.js +14 -0
- package/src/policy/chunk.js +365 -0
- package/src/policy/diagnostics.js +53 -0
- package/src/policy/explain.js +104 -0
- package/src/policy/fallback.js +68 -0
- package/src/policy/gating.js +90 -0
- package/src/policy/grid-detector.js +207 -0
- package/src/policy/identifiers.js +62 -0
- package/src/policy/images.js +49 -0
- package/src/policy/list-detector.js +232 -0
- package/src/policy/lost-signal.js +44 -0
- package/src/policy/markdown.js +58 -0
- package/src/policy/metadata.js +216 -0
- package/src/policy/outline-chains.js +165 -0
- package/src/policy/outline.js +71 -0
- package/src/policy/pagination.js +79 -0
- package/src/policy/presets.js +100 -0
- package/src/policy/section.js +111 -0
- package/src/policy/selector-lint.js +143 -0
- package/src/policy/sibling-scan.js +92 -0
- package/src/policy/text.js +18 -0
- package/src/policy/truncate.js +45 -0
- package/src/preset-cache.js +165 -0
- package/src/resources.js +227 -0
- package/src/sampling.js +45 -0
- package/src/server.js +73 -0
- package/src/tools/chunk_text.js +40 -0
- package/src/tools/explain.js +200 -0
- package/src/tools/extract.js +263 -0
- package/src/tools/extract_grid.js +56 -0
- package/src/tools/extract_links.js +98 -0
- package/src/tools/extract_list.js +66 -0
- package/src/tools/extract_metadata.js +67 -0
- package/src/tools/extract_section.js +56 -0
- package/src/tools/extract_tables.js +59 -0
- package/src/tools/html-source.js +30 -0
- package/src/tools/html_to_markdown.js +113 -0
- package/src/tools/outline.js +47 -0
- package/src/tools/output-schema.js +504 -0
- package/src/tools/schemas.js +288 -0
- package/src/tools/suggest-preset.js +504 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { ExtractionError } from "../errors.js";
|
|
2
|
+
import { tool } from "../handler.js";
|
|
3
|
+
import { logger } from "../logger.js";
|
|
4
|
+
import { formatPayload } from "../output/format.js";
|
|
5
|
+
import { buildDocument } from "../pipeline/dom.js";
|
|
6
|
+
import { applySelectors, canonicalizeCodeBlocks, normalizeDocument, resolveLazyImages, } from "../pipeline/normalize.js";
|
|
7
|
+
import { isReaderable, parseArticle } from "../pipeline/readability.js";
|
|
8
|
+
import { resolveReadabilityOptions } from "../pipeline/resolver.js";
|
|
9
|
+
import { sanitizeHtml } from "../pipeline/sanitize.js";
|
|
10
|
+
import { toMarkdown } from "../pipeline/turndown.js";
|
|
11
|
+
import { chunkMarkdown } from "../policy/chunk.js";
|
|
12
|
+
import { assembleDiagnostics, TraceCollector } from "../policy/diagnostics.js";
|
|
13
|
+
import { extractViaFallback } from "../policy/fallback.js";
|
|
14
|
+
import { detectGating } from "../policy/gating.js";
|
|
15
|
+
import { collectImageInventory } from "../policy/images.js";
|
|
16
|
+
import { resolveMetadata } from "../policy/metadata.js";
|
|
17
|
+
import { detectPagination } from "../policy/pagination.js";
|
|
18
|
+
import { resolvePreset } from "../policy/presets.js";
|
|
19
|
+
import { computeTextMetrics } from "../policy/text.js";
|
|
20
|
+
import { truncateMarkdown } from "../policy/truncate.js";
|
|
21
|
+
import * as cache from "../resources.js";
|
|
22
|
+
import { fromLocalPath, withSchemaDefaults } from "./html-source.js";
|
|
23
|
+
import { outputSchemaShape } from "./output-schema.js";
|
|
24
|
+
import { extractInputSchema, extractInputShape, } from "./schemas.js";
|
|
25
|
+
// Names the extraction path, not a DOM tag — Readability doesn't expose the source node.
|
|
26
|
+
const EXTRACTED_NODE = 'readability';
|
|
27
|
+
export function extractArticle(rawArgs) {
|
|
28
|
+
return extractArticleFromHtml(fromLocalPath(extractInputSchema.parse(rawArgs)));
|
|
29
|
+
}
|
|
30
|
+
export function extractArticleFromHtml(input) {
|
|
31
|
+
const merged = withSchemaDefaults(extractInputSchema, input);
|
|
32
|
+
const { html, baseUrl, cache: useCache, selectors, extraction, minArticleLength, maxNodes, keepClasses, readabilityOverrides, format, metadataMode, gfm, headingStyle, codeBlockStyle, images, sanitize: shouldSanitize, maxChars, wordsPerMinute, cleanChrome, tables, chunk, imageInventory, debug, resolvePreset: presetsEnabled = true, } = merged;
|
|
33
|
+
// Cache hit short-circuits the pipeline. A clone is returned so the cache
|
|
34
|
+
// field can be overwritten to reflect *this* call's hashes (normalizedHash is
|
|
35
|
+
// stable across re-renders, but originalHash differs by nonce/CSP/…).
|
|
36
|
+
if (useCache) {
|
|
37
|
+
const hit = cache.lookup(html, merged);
|
|
38
|
+
if (hit) {
|
|
39
|
+
const cloned = JSON.parse(JSON.stringify(hit.entry.structuredContent));
|
|
40
|
+
const structuredContent = {
|
|
41
|
+
...cloned,
|
|
42
|
+
diagnostics: {
|
|
43
|
+
...cloned.diagnostics,
|
|
44
|
+
cache: {
|
|
45
|
+
hit: true,
|
|
46
|
+
normalizedHash: hit.normalizedHash,
|
|
47
|
+
originalHash: hit.originalHash,
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
content: [{ text: hit.entry.contentText, type: 'text' }],
|
|
53
|
+
structuredContent,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Stages are timed at the orchestrator boundary so they stay non-overlapping
|
|
58
|
+
// and sum to the pipeline's wall-clock — stripConsent/absolutize live inside
|
|
59
|
+
// normalize/turndown respectively and aren't carved out as their own stages.
|
|
60
|
+
const trace = new TraceCollector(debug);
|
|
61
|
+
const { document, window } = buildDocument(html, baseUrl);
|
|
62
|
+
// Paywall overlays are stripped by normalizeDocument's stripChrome (a Piano
|
|
63
|
+
// modal is role="dialog" + full-viewport fixed), so gating must be detected
|
|
64
|
+
// before normalization. Pagination chrome survives stripChrome and is detected after.
|
|
65
|
+
const { gating, documentElementCount, normalizeCounts, imagesResolved, pagination, preset, } = trace.run('normalize', () => {
|
|
66
|
+
const gating = detectGating(document);
|
|
67
|
+
const documentElementCount = document.querySelectorAll('*').length;
|
|
68
|
+
const normalizeCounts = normalizeDocument(document, { cleanChrome });
|
|
69
|
+
const imagesResolved = resolveLazyImages(document);
|
|
70
|
+
// Detect before applySelectors: a caller's selectors.include could scope the
|
|
71
|
+
// body and hide pagination chrome, but "more content exists" is still true.
|
|
72
|
+
const pagination = detectPagination(document, baseUrl);
|
|
73
|
+
const resolution = presetsEnabled
|
|
74
|
+
? resolvePreset(document, baseUrl, selectors !== undefined)
|
|
75
|
+
: undefined;
|
|
76
|
+
applySelectors(document, resolution?.scope ?? selectors);
|
|
77
|
+
const codeBlocksCanonicalized = canonicalizeCodeBlocks(document);
|
|
78
|
+
if (codeBlocksCanonicalized > 0) {
|
|
79
|
+
logger.debug(`canonicalized ${codeBlocksCanonicalized} code-block language tag(s)`);
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
documentElementCount,
|
|
83
|
+
gating,
|
|
84
|
+
imagesResolved,
|
|
85
|
+
normalizeCounts,
|
|
86
|
+
pagination,
|
|
87
|
+
preset: resolution?.signal,
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
const { readerable, article } = trace.run('readability', () => {
|
|
91
|
+
const readerable = isReaderable(document);
|
|
92
|
+
const readabilityOptions = resolveReadabilityOptions({
|
|
93
|
+
extraction,
|
|
94
|
+
keepClasses,
|
|
95
|
+
maxNodes,
|
|
96
|
+
minArticleLength,
|
|
97
|
+
readabilityOverrides,
|
|
98
|
+
});
|
|
99
|
+
const article = parseArticle(document, readabilityOptions);
|
|
100
|
+
return { article, readerable };
|
|
101
|
+
});
|
|
102
|
+
let markdown;
|
|
103
|
+
let sanitizedHtml;
|
|
104
|
+
let textContent;
|
|
105
|
+
let extractedNode;
|
|
106
|
+
let fallbackUsed;
|
|
107
|
+
let sanitizeCounts;
|
|
108
|
+
if (article?.content) {
|
|
109
|
+
extractedNode = EXTRACTED_NODE;
|
|
110
|
+
fallbackUsed = false;
|
|
111
|
+
textContent = article.textContent ?? '';
|
|
112
|
+
const articleHtml = article.content;
|
|
113
|
+
const sanitized = trace.run('sanitize', () => {
|
|
114
|
+
if (!shouldSanitize) {
|
|
115
|
+
return { counts: { iframes: 0, scripts: 0 }, html: articleHtml };
|
|
116
|
+
}
|
|
117
|
+
const res = sanitizeHtml(articleHtml, window);
|
|
118
|
+
return {
|
|
119
|
+
counts: { iframes: res.iframesRemoved, scripts: res.scriptsRemoved },
|
|
120
|
+
html: res.html,
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
sanitizedHtml = sanitized.html;
|
|
124
|
+
sanitizeCounts = sanitized.counts;
|
|
125
|
+
markdown = trace.run('turndown', () => toMarkdown(sanitizedHtml, {
|
|
126
|
+
codeBlockStyle,
|
|
127
|
+
gfm,
|
|
128
|
+
headingStyle,
|
|
129
|
+
images,
|
|
130
|
+
tables,
|
|
131
|
+
baseUrl,
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
// Cascade only on parse failure, not on isProbablyReaderable.
|
|
136
|
+
// Fallback owns sanitize + turndown internally, so the two stages collapse
|
|
137
|
+
// into one timed block here — keeping the trace non-overlapping.
|
|
138
|
+
const fallback = trace.run('fallback', () => extractViaFallback(document, {
|
|
139
|
+
codeBlockStyle,
|
|
140
|
+
gfm,
|
|
141
|
+
headingStyle,
|
|
142
|
+
images,
|
|
143
|
+
sanitize: shouldSanitize,
|
|
144
|
+
tables,
|
|
145
|
+
baseUrl,
|
|
146
|
+
window,
|
|
147
|
+
}));
|
|
148
|
+
if (!fallback) {
|
|
149
|
+
throw new ExtractionError('Readability returned no article and the selector cascade yielded no usable content.');
|
|
150
|
+
}
|
|
151
|
+
extractedNode = fallback.rootSelector;
|
|
152
|
+
fallbackUsed = true;
|
|
153
|
+
markdown = fallback.markdown;
|
|
154
|
+
sanitizedHtml = fallback.sanitizedHtml;
|
|
155
|
+
textContent = fallback.textContent;
|
|
156
|
+
sanitizeCounts = fallback.sanitization;
|
|
157
|
+
}
|
|
158
|
+
const { metadata } = trace.run('metadata', () => {
|
|
159
|
+
const { wordCount, readingTimeMin } = computeTextMetrics(textContent, wordsPerMinute);
|
|
160
|
+
const metadata = resolveMetadata({
|
|
161
|
+
document,
|
|
162
|
+
readability: article,
|
|
163
|
+
readingTimeMin,
|
|
164
|
+
textContent,
|
|
165
|
+
baseUrl,
|
|
166
|
+
wordCount,
|
|
167
|
+
});
|
|
168
|
+
return { metadata };
|
|
169
|
+
});
|
|
170
|
+
const sanitization = {
|
|
171
|
+
iframes: normalizeCounts.iframes + sanitizeCounts.iframes,
|
|
172
|
+
scripts: normalizeCounts.scripts + sanitizeCounts.scripts,
|
|
173
|
+
};
|
|
174
|
+
const baseDiagnostics = assembleDiagnostics({
|
|
175
|
+
articleHtml: sanitizedHtml,
|
|
176
|
+
boilerplateRemoved: normalizeCounts.boilerplateRemoved,
|
|
177
|
+
chromeRemoved: normalizeCounts.chromeRemoved,
|
|
178
|
+
documentElementCount,
|
|
179
|
+
extractedNode,
|
|
180
|
+
fallbackUsed,
|
|
181
|
+
gated: gating,
|
|
182
|
+
imagesResolved,
|
|
183
|
+
pagination,
|
|
184
|
+
preset,
|
|
185
|
+
readerable,
|
|
186
|
+
sanitization,
|
|
187
|
+
trace: trace.collect(),
|
|
188
|
+
truncated: false,
|
|
189
|
+
window,
|
|
190
|
+
});
|
|
191
|
+
let payload = formatPayload({
|
|
192
|
+
diagnostics: baseDiagnostics,
|
|
193
|
+
format,
|
|
194
|
+
markdown,
|
|
195
|
+
metadata,
|
|
196
|
+
metadataMode,
|
|
197
|
+
sanitizedHtml,
|
|
198
|
+
textContent,
|
|
199
|
+
});
|
|
200
|
+
let truncated = false;
|
|
201
|
+
if (maxChars !== undefined && (format === 'markdown' || format === 'text')) {
|
|
202
|
+
const res = truncateMarkdown(payload, maxChars);
|
|
203
|
+
payload = res.text;
|
|
204
|
+
truncated = res.truncated;
|
|
205
|
+
}
|
|
206
|
+
const diagnostics = truncated
|
|
207
|
+
? { ...baseDiagnostics, truncated }
|
|
208
|
+
: baseDiagnostics;
|
|
209
|
+
// Chunk the final payload (post-format, post-truncation) so token counts
|
|
210
|
+
// reflect what the host sees in content[0].text. HTML/JSON formats carry no
|
|
211
|
+
// markdown body to slice, so `chunks` stays unset for them.
|
|
212
|
+
const chunks = chunk && (format === 'markdown' || format === 'text')
|
|
213
|
+
? chunkMarkdown(payload, chunk)
|
|
214
|
+
: undefined;
|
|
215
|
+
// Runs against sanitizedHtml so the inventory reflects exactly what the host
|
|
216
|
+
// sees in content[0].text — post-sanitization, post-lazy-resolution.
|
|
217
|
+
const imageInventoryEntries = imageInventory
|
|
218
|
+
? collectImageInventory(sanitizedHtml, window, baseUrl)
|
|
219
|
+
: undefined;
|
|
220
|
+
const baseStructuredContent = {
|
|
221
|
+
schemaVersion: 1,
|
|
222
|
+
content: payload,
|
|
223
|
+
metadata,
|
|
224
|
+
diagnostics,
|
|
225
|
+
...(chunks ? { chunks } : {}),
|
|
226
|
+
...(imageInventoryEntries ? { images: imageInventoryEntries } : {}),
|
|
227
|
+
};
|
|
228
|
+
// On cache miss (when cache:true), persist a cache-agnostic copy so the
|
|
229
|
+
// stored entry never carries a stale hit/miss verdict, then surface the
|
|
230
|
+
// signal on the returned object only.
|
|
231
|
+
let structuredContent = baseStructuredContent;
|
|
232
|
+
if (useCache) {
|
|
233
|
+
const stored = cache.storeResult(html, merged, {
|
|
234
|
+
contentText: payload,
|
|
235
|
+
structuredContent: baseStructuredContent,
|
|
236
|
+
});
|
|
237
|
+
structuredContent = {
|
|
238
|
+
...baseStructuredContent,
|
|
239
|
+
diagnostics: {
|
|
240
|
+
...diagnostics,
|
|
241
|
+
cache: {
|
|
242
|
+
hit: false,
|
|
243
|
+
normalizedHash: stored.normalizedHash,
|
|
244
|
+
originalHash: stored.originalHash,
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
content: [{ text: payload, type: 'text' }],
|
|
251
|
+
structuredContent,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
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.`;
|
|
255
|
+
export const extractHandler = tool('extract', extractArticle);
|
|
256
|
+
export function registerExtractTool(server) {
|
|
257
|
+
return server.registerTool('extract', {
|
|
258
|
+
title: 'Extract article to Markdown',
|
|
259
|
+
description: EXTRACT_TOOL_DESCRIPTION,
|
|
260
|
+
inputSchema: extractInputShape,
|
|
261
|
+
outputSchema: outputSchemaShape,
|
|
262
|
+
}, extractHandler);
|
|
263
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { tool } from "../handler.js";
|
|
2
|
+
import { buildDocument } from "../pipeline/dom.js";
|
|
3
|
+
import { applySelectors } from "../pipeline/normalize.js";
|
|
4
|
+
import { renderTable } from "../pipeline/tables.js";
|
|
5
|
+
import { detectGrid } from "../policy/grid-detector.js";
|
|
6
|
+
import { fromLocalPath, withSchemaDefaults } from "./html-source.js";
|
|
7
|
+
import { extractGridOutputShape } from "./output-schema.js";
|
|
8
|
+
import { extractGridInputSchema, extractGridInputShape, } from "./schemas.js";
|
|
9
|
+
const NO_GRID = '(no repeating grid found)';
|
|
10
|
+
export function extractGridFromHtml(input) {
|
|
11
|
+
const { html, baseUrl, format, selectors, rowSelector, cellSelector } = withSchemaDefaults(extractGridInputSchema, input);
|
|
12
|
+
// Same raw-DOM rationale as extract_tables/extract_list: the repeating grid
|
|
13
|
+
// lives in statically-rendered divs that Readability/normalize would discard
|
|
14
|
+
// (nav/aside/boilerplate), and grid shape is unaffected by unsanitized
|
|
15
|
+
// scripts/styles. Chrome stripping lives inside detectGrid (auto mode only)
|
|
16
|
+
// so selector mode sees the page as captured.
|
|
17
|
+
const { document } = buildDocument(html, baseUrl);
|
|
18
|
+
applySelectors(document, selectors);
|
|
19
|
+
const result = detectGrid(document, { rowSelector, cellSelector });
|
|
20
|
+
let content = NO_GRID;
|
|
21
|
+
let grid = { rows: 0, cols: 0, markdown: '' };
|
|
22
|
+
if (result.detected && result.rows.length > 0) {
|
|
23
|
+
const matrix = result.rows.map(row => [...row.cells]);
|
|
24
|
+
const markdown = renderTable(matrix, format);
|
|
25
|
+
content = markdown;
|
|
26
|
+
grid = { rows: result.rowCount, cols: result.colCount, markdown };
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
content: [{ text: content, type: 'text' }],
|
|
30
|
+
structuredContent: {
|
|
31
|
+
schemaVersion: 1,
|
|
32
|
+
content,
|
|
33
|
+
grid,
|
|
34
|
+
diagnostics: {
|
|
35
|
+
confidence: result.confidence,
|
|
36
|
+
containerSelector: result.containerSelector,
|
|
37
|
+
detected: result.detected,
|
|
38
|
+
rowCount: result.rowCount,
|
|
39
|
+
colCount: result.colCount,
|
|
40
|
+
rowTag: result.rowTag,
|
|
41
|
+
note: result.note,
|
|
42
|
+
},
|
|
43
|
+
metadata: { baseUrl, format, detected: result.detected },
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
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.`;
|
|
48
|
+
export const extractGridHandler = tool('extract_grid', input => extractGridFromHtml(fromLocalPath(input)), extractGridInputSchema);
|
|
49
|
+
export function registerExtractGridTool(server) {
|
|
50
|
+
return server.registerTool('extract_grid', {
|
|
51
|
+
title: 'Extract a CSS-grid / div table (the div equivalent of extract_tables)',
|
|
52
|
+
description: EXTRACT_GRID_TOOL_DESCRIPTION,
|
|
53
|
+
inputSchema: extractGridInputShape,
|
|
54
|
+
outputSchema: extractGridOutputShape,
|
|
55
|
+
}, extractGridHandler);
|
|
56
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { tool } from "../handler.js";
|
|
2
|
+
import { buildDocument } from "../pipeline/dom.js";
|
|
3
|
+
import { applySelectors } from "../pipeline/normalize.js";
|
|
4
|
+
import { absolutize } from "../pipeline/urls.js";
|
|
5
|
+
import { fromLocalPath, withSchemaDefaults } from "./html-source.js";
|
|
6
|
+
import { extractLinksOutputShape } from "./output-schema.js";
|
|
7
|
+
import { extractLinksInputSchema, extractLinksInputShape, } from "./schemas.js";
|
|
8
|
+
const MAX_TEXT_LENGTH = 300;
|
|
9
|
+
function clipText(raw) {
|
|
10
|
+
const collapsed = raw.replace(/\s+/g, ' ').trim();
|
|
11
|
+
return collapsed.length > MAX_TEXT_LENGTH
|
|
12
|
+
? `${collapsed.slice(0, MAX_TEXT_LENGTH)}…`
|
|
13
|
+
: collapsed;
|
|
14
|
+
}
|
|
15
|
+
// Non-http(s) schemes (mailto/tel/javascript/data) have an opaque origin and
|
|
16
|
+
// would otherwise compare as "different" — treat them as non-external so the
|
|
17
|
+
// isExternal flag tracks real cross-origin navigation only.
|
|
18
|
+
function isWebOrigin(parsed) {
|
|
19
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
20
|
+
}
|
|
21
|
+
function resolveExternal(absolutizedHref, baseUrl) {
|
|
22
|
+
let parsedHref;
|
|
23
|
+
try {
|
|
24
|
+
parsedHref = new URL(absolutizedHref);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
if (!isWebOrigin(parsedHref)) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
return new URL(baseUrl).origin !== parsedHref.origin;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// Drop <script>/<template> for safety (templates never render, scripts carry no
|
|
40
|
+
// crawl value), but keep nav/footer/main — crawl-relevant links live there.
|
|
41
|
+
function pruneUnsafeRoots(document) {
|
|
42
|
+
for (const el of document.querySelectorAll('script, template')) {
|
|
43
|
+
el.remove();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export function extractLinksFromHtml(input) {
|
|
47
|
+
const { html, baseUrl, sameOriginOnly, selectors } = withSchemaDefaults(extractLinksInputSchema, input);
|
|
48
|
+
const { document } = buildDocument(html, baseUrl);
|
|
49
|
+
applySelectors(document, selectors);
|
|
50
|
+
pruneUnsafeRoots(document);
|
|
51
|
+
const links = [];
|
|
52
|
+
for (const anchor of document.querySelectorAll('a')) {
|
|
53
|
+
const rawHref = anchor.getAttribute('href');
|
|
54
|
+
if (!rawHref) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const href = absolutize(rawHref, baseUrl);
|
|
58
|
+
const isExternal = baseUrl ? resolveExternal(href, baseUrl) : false;
|
|
59
|
+
if (sameOriginOnly && isExternal) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
links.push({
|
|
63
|
+
text: clipText(anchor.textContent),
|
|
64
|
+
href,
|
|
65
|
+
rel: anchor.getAttribute('rel') ?? '',
|
|
66
|
+
isExternal,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
const content = renderLinksIndex(links);
|
|
70
|
+
return {
|
|
71
|
+
content: [{ text: content, type: 'text' }],
|
|
72
|
+
structuredContent: {
|
|
73
|
+
schemaVersion: 1,
|
|
74
|
+
content,
|
|
75
|
+
links,
|
|
76
|
+
metadata: { baseUrl },
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
// One `- [text](href)` line per link; never blank so content[0].text is scannable.
|
|
81
|
+
function renderLinksIndex(links) {
|
|
82
|
+
if (links.length === 0) {
|
|
83
|
+
return '(no links found)';
|
|
84
|
+
}
|
|
85
|
+
return links
|
|
86
|
+
.map(link => `- [${link.text || '(no text)'}](${link.href})`)
|
|
87
|
+
.join('\n');
|
|
88
|
+
}
|
|
89
|
+
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).`;
|
|
90
|
+
export const extractLinksHandler = tool('extract_links', input => extractLinksFromHtml(fromLocalPath(input)), extractLinksInputSchema);
|
|
91
|
+
export function registerExtractLinksTool(server) {
|
|
92
|
+
return server.registerTool('extract_links', {
|
|
93
|
+
title: 'Extract anchor links',
|
|
94
|
+
description: EXTRACT_LINKS_TOOL_DESCRIPTION,
|
|
95
|
+
inputSchema: extractLinksInputShape,
|
|
96
|
+
outputSchema: extractLinksOutputShape,
|
|
97
|
+
}, extractLinksHandler);
|
|
98
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { tool } from "../handler.js";
|
|
2
|
+
import { buildDocument } from "../pipeline/dom.js";
|
|
3
|
+
import { applySelectors } from "../pipeline/normalize.js";
|
|
4
|
+
import { detectList, } from "../policy/list-detector.js";
|
|
5
|
+
import { fromLocalPath } from "./html-source.js";
|
|
6
|
+
import { extractListOutputShape } from "./output-schema.js";
|
|
7
|
+
import { extractListInputSchema, extractListInputShape, } from "./schemas.js";
|
|
8
|
+
const NOT_A_LIST = 'not a list: no repeated item structure with links';
|
|
9
|
+
function renderItems(result) {
|
|
10
|
+
if (!result.detected || result.items.length === 0) {
|
|
11
|
+
return result.note || NOT_A_LIST;
|
|
12
|
+
}
|
|
13
|
+
return result.items
|
|
14
|
+
.map((item, index) => {
|
|
15
|
+
const head = `${index + 1}. ${item.title} — ${item.url}`;
|
|
16
|
+
return item.snippet ? `${head}\n ${item.snippet}` : head;
|
|
17
|
+
})
|
|
18
|
+
.join('\n');
|
|
19
|
+
}
|
|
20
|
+
function toStructuredItem(item) {
|
|
21
|
+
return {
|
|
22
|
+
score: item.score,
|
|
23
|
+
snippet: item.snippet,
|
|
24
|
+
title: item.title,
|
|
25
|
+
url: item.url,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export function extractListFromHtml(input) {
|
|
29
|
+
const { html, baseUrl, selectors } = input;
|
|
30
|
+
// No Readability/Turndown/normalize: list pages survive on raw DOM shape
|
|
31
|
+
// (sibling TR/LI/ARTICLE clusters), and the article normalizer would
|
|
32
|
+
// discard the very chrome-bearing structure the detector scores against.
|
|
33
|
+
// Chrome stripping (nav/header/footer/aside) lives inside detectList so
|
|
34
|
+
// this tool sees the page as captured.
|
|
35
|
+
const { document } = buildDocument(html, baseUrl);
|
|
36
|
+
applySelectors(document, selectors);
|
|
37
|
+
const result = detectList(document, baseUrl);
|
|
38
|
+
const content = renderItems(result);
|
|
39
|
+
return {
|
|
40
|
+
content: [{ text: content, type: 'text' }],
|
|
41
|
+
structuredContent: {
|
|
42
|
+
schemaVersion: 1,
|
|
43
|
+
content,
|
|
44
|
+
items: result.items.map(toStructuredItem),
|
|
45
|
+
diagnostics: {
|
|
46
|
+
confidence: result.confidence,
|
|
47
|
+
containerSelector: result.containerSelector,
|
|
48
|
+
detected: result.detected,
|
|
49
|
+
itemCount: result.itemCount,
|
|
50
|
+
itemTag: result.itemTag,
|
|
51
|
+
note: result.note,
|
|
52
|
+
},
|
|
53
|
+
metadata: { baseUrl },
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export const EXTRACT_LIST_TOOL_DESCRIPTION = `Detect and extract a list/feed/index structure from already-rendered (post-JavaScript) HTML — for HN-style, search-result, and blog-index pages that Readability cannot turn into one article. Returns \`{items: [{title, url, snippet, score}], diagnostics}\` instead of one article. Strips nav/header/footer/aside + ARIA chrome roles first (the false-positive guard so an article's nav menu doesn't look like a 4-item feed), then finds the container whose direct children form a same-shape sibling cluster of ≥3 elements each carrying a navigation anchor, and the cluster with the most items wins. No Readability, no Turndown, no sanitization. The server fetches nothing: \`localPath\` is the only source, and \`baseUrl\` (optional) is origin context for absolutizing item hrefs.`;
|
|
58
|
+
export const extractListHandler = tool('extract_list', input => extractListFromHtml(fromLocalPath(input)), extractListInputSchema);
|
|
59
|
+
export function registerExtractListTool(server) {
|
|
60
|
+
return server.registerTool('extract_list', {
|
|
61
|
+
title: 'Extract a list/feed (HN/search/blog-index pages)',
|
|
62
|
+
description: EXTRACT_LIST_TOOL_DESCRIPTION,
|
|
63
|
+
inputSchema: extractListInputShape,
|
|
64
|
+
outputSchema: extractListOutputShape,
|
|
65
|
+
}, extractListHandler);
|
|
66
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { tool } from "../handler.js";
|
|
2
|
+
import { buildDocument } from "../pipeline/dom.js";
|
|
3
|
+
import { normalizeDocument } from "../pipeline/normalize.js";
|
|
4
|
+
import { resolveMetadata } from "../policy/metadata.js";
|
|
5
|
+
import { fromLocalPath } from "./html-source.js";
|
|
6
|
+
import { extractMetadataOutputShape } from "./output-schema.js";
|
|
7
|
+
import { extractMetadataInputSchema, extractMetadataInputShape, } from "./schemas.js";
|
|
8
|
+
// Bibliographic fields only — wordCount/readingTimeMin/tokenEstimate are
|
|
9
|
+
// meaningless without the extracted body, so a metadata-only caller never
|
|
10
|
+
// sees wordCount: 0.
|
|
11
|
+
const BIBLIOGRAPHIC_KEYS = [
|
|
12
|
+
'title',
|
|
13
|
+
'byline',
|
|
14
|
+
'siteName',
|
|
15
|
+
'lang',
|
|
16
|
+
'publishedTime',
|
|
17
|
+
'excerpt',
|
|
18
|
+
'canonical',
|
|
19
|
+
'baseUrl',
|
|
20
|
+
];
|
|
21
|
+
function pickBibliographic(metadata) {
|
|
22
|
+
const out = {};
|
|
23
|
+
for (const key of BIBLIOGRAPHIC_KEYS) {
|
|
24
|
+
const value = metadata[key];
|
|
25
|
+
if (value !== undefined) {
|
|
26
|
+
out[key] = value;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
// One line per present field; never blank so content[0].text is always scannable.
|
|
32
|
+
function renderMetadataLines(metadata) {
|
|
33
|
+
const lines = Object.entries(metadata).map(([key, value]) => `${key}: ${value}`);
|
|
34
|
+
return lines.length > 0 ? lines.join('\n') : '(no metadata found)';
|
|
35
|
+
}
|
|
36
|
+
export function extractMetadataDocumentFromHtml(input) {
|
|
37
|
+
const { html, baseUrl } = input;
|
|
38
|
+
const { document } = buildDocument(html, baseUrl);
|
|
39
|
+
normalizeDocument(document);
|
|
40
|
+
const resolved = resolveMetadata({
|
|
41
|
+
document,
|
|
42
|
+
textContent: '',
|
|
43
|
+
wordCount: 0,
|
|
44
|
+
readingTimeMin: 0,
|
|
45
|
+
baseUrl,
|
|
46
|
+
});
|
|
47
|
+
const metadata = pickBibliographic(resolved);
|
|
48
|
+
const content = renderMetadataLines(metadata);
|
|
49
|
+
return {
|
|
50
|
+
content: [{ text: content, type: 'text' }],
|
|
51
|
+
structuredContent: {
|
|
52
|
+
schemaVersion: 1,
|
|
53
|
+
content,
|
|
54
|
+
metadata,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export const EXTRACT_METADATA_TOOL_DESCRIPTION = `Return only the bibliographic metadata (title, byline, siteName, lang, publishedTime, excerpt, canonical, baseUrl) of already-rendered (post-JavaScript) HTML without running Readability/Turndown — a fast pre-check for crawlers and citation. Resolves the same metadata cascade as \`extract\` (JSON-LD → OpenGraph → Twitter → <meta> → <time> → <title>), plus <link rel="canonical"> → og:url. The server fetches nothing: \`localPath\` is the only source, and \`baseUrl\` is origin context only (never fetched).`;
|
|
59
|
+
export const extractMetadataHandler = tool('extract_metadata', input => extractMetadataDocumentFromHtml(fromLocalPath(input)), extractMetadataInputSchema);
|
|
60
|
+
export function registerExtractMetadataTool(server) {
|
|
61
|
+
return server.registerTool('extract_metadata', {
|
|
62
|
+
title: 'Extract metadata only (no Readability)',
|
|
63
|
+
description: EXTRACT_METADATA_TOOL_DESCRIPTION,
|
|
64
|
+
inputSchema: extractMetadataInputShape,
|
|
65
|
+
outputSchema: extractMetadataOutputShape,
|
|
66
|
+
}, extractMetadataHandler);
|
|
67
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { ExtractionError } from "../errors.js";
|
|
2
|
+
import { tool } from "../handler.js";
|
|
3
|
+
import { buildDocument } from "../pipeline/dom.js";
|
|
4
|
+
import { normalizeDocument } from "../pipeline/normalize.js";
|
|
5
|
+
import { scopeToHeading } from "../policy/section.js";
|
|
6
|
+
import { extractArticleFromHtml } from "./extract.js";
|
|
7
|
+
import { fromLocalPath } from "./html-source.js";
|
|
8
|
+
import { outputSchemaShape } from "./output-schema.js";
|
|
9
|
+
import { extractSectionInputSchema, extractSectionInputShape, } from "./schemas.js";
|
|
10
|
+
const SECTION_SCOPE_SELECTOR = '[data-rdrm-section-scope]';
|
|
11
|
+
export function extractSectionFromHtml(input) {
|
|
12
|
+
const { html, baseUrl, selector, heading } = input;
|
|
13
|
+
if (selector !== undefined) {
|
|
14
|
+
return extractArticleFromHtml({
|
|
15
|
+
html,
|
|
16
|
+
baseUrl,
|
|
17
|
+
selectors: { include: selector },
|
|
18
|
+
resolvePreset: false,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
// The superRefine on the schema enforces selector/heading XOR, so reaching
|
|
22
|
+
// here means heading is set — but its declared type is still optional, so
|
|
23
|
+
// narrow explicitly rather than asserting.
|
|
24
|
+
if (heading === undefined) {
|
|
25
|
+
throw new ExtractionError('Provide exactly one of `selector` or `heading`.');
|
|
26
|
+
}
|
|
27
|
+
// Heading mode: wrap the matched subtree, re-serialize, and route through
|
|
28
|
+
// extractArticleFromHtml's selectors.include so this tool stays a thin
|
|
29
|
+
// resolver over the existing extraction pipeline — no forked extraction logic.
|
|
30
|
+
// The DOM is parsed+normalized twice: here to scope against a normalized
|
|
31
|
+
// DOM, and again inside the extract worker — which owns detectGating-before-
|
|
32
|
+
// normalize, resolveLazyImages, and applySelectors. Folding scoping into
|
|
33
|
+
// that pipeline would breach its invariants for a non-hot path; accepted.
|
|
34
|
+
const { document } = buildDocument(html, baseUrl);
|
|
35
|
+
normalizeDocument(document);
|
|
36
|
+
if (!scopeToHeading(document, heading)) {
|
|
37
|
+
throw new ExtractionError(`no heading matched: ${heading}`);
|
|
38
|
+
}
|
|
39
|
+
const scoped = `<!DOCTYPE html><html><head></head><body>${document.body.innerHTML}</body></html>`;
|
|
40
|
+
return extractArticleFromHtml({
|
|
41
|
+
html: scoped,
|
|
42
|
+
baseUrl,
|
|
43
|
+
selectors: { include: SECTION_SCOPE_SELECTOR },
|
|
44
|
+
resolvePreset: false,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
export const EXTRACT_SECTION_TOOL_DESCRIPTION = `Extract one section of an already-rendered (post-JavaScript) HTML document and return its Markdown + metadata + diagnostics — a thin resolver over extract’s \`selectors.include\` path, not a new extractor. Pick the section by CSS \`selector\` (passed straight through) OR by \`heading\` text (case-insensitive, first match wins; the section spans from the matched heading to the next same-or-higher-level heading). Exactly one of \`selector\`/\`heading\` is required. The server fetches nothing: \`localPath\` is the only source, and \`baseUrl\` (optional) is origin context only (never fetched).`;
|
|
48
|
+
export const extractSectionHandler = tool('extract_section', input => extractSectionFromHtml(fromLocalPath(input)), extractSectionInputSchema);
|
|
49
|
+
export function registerExtractSectionTool(server) {
|
|
50
|
+
return server.registerTool('extract_section', {
|
|
51
|
+
title: 'Extract one section by selector or heading',
|
|
52
|
+
description: EXTRACT_SECTION_TOOL_DESCRIPTION,
|
|
53
|
+
inputSchema: extractSectionInputShape,
|
|
54
|
+
outputSchema: outputSchemaShape,
|
|
55
|
+
}, extractSectionHandler);
|
|
56
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { tool } from "../handler.js";
|
|
2
|
+
import { buildDocument } from "../pipeline/dom.js";
|
|
3
|
+
import { applySelectors } from "../pipeline/normalize.js";
|
|
4
|
+
import { parseTableMatrix, renderTable, resolveHeaderKeys, } from "../pipeline/tables.js";
|
|
5
|
+
import { fromLocalPath, withSchemaDefaults } from "./html-source.js";
|
|
6
|
+
import { extractTablesOutputShape } from "./output-schema.js";
|
|
7
|
+
import { extractTablesInputSchema, extractTablesInputShape, } from "./schemas.js";
|
|
8
|
+
const NO_TABLES = '(no tables found)';
|
|
9
|
+
export function extractTables(rawArgs) {
|
|
10
|
+
return extractTablesFromHtml(fromLocalPath(extractTablesInputSchema.parse(rawArgs)));
|
|
11
|
+
}
|
|
12
|
+
export function extractTablesFromHtml(input) {
|
|
13
|
+
const { html, baseUrl, format, selectors } = withSchemaDefaults(extractTablesInputSchema, input);
|
|
14
|
+
// Skip normalizeDocument/Readability on purpose: this tool exists to reach
|
|
15
|
+
// tables that live outside the scored article (nav, aside, boilerplate), which
|
|
16
|
+
// those stages would discard. Table structure is static HTML, so the matrix
|
|
17
|
+
// walk is unaffected by unsanitized scripts/styles.
|
|
18
|
+
const { document } = buildDocument(html, baseUrl);
|
|
19
|
+
applySelectors(document, selectors);
|
|
20
|
+
const tables = [];
|
|
21
|
+
let index = 0;
|
|
22
|
+
for (const table of document.querySelectorAll('table')) {
|
|
23
|
+
const matrix = parseTableMatrix(table);
|
|
24
|
+
if (matrix.length === 0) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const keys = format === 'json' ? resolveHeaderKeys(table, matrix) : undefined;
|
|
28
|
+
const markdown = renderTable(matrix, format, keys);
|
|
29
|
+
tables.push({
|
|
30
|
+
index,
|
|
31
|
+
rows: matrix.length,
|
|
32
|
+
cols: matrix[0]?.length ?? 0,
|
|
33
|
+
markdown,
|
|
34
|
+
});
|
|
35
|
+
index++;
|
|
36
|
+
}
|
|
37
|
+
const content = tables.length > 0
|
|
38
|
+
? tables.map(entry => entry.markdown).join('\n\n')
|
|
39
|
+
: NO_TABLES;
|
|
40
|
+
return {
|
|
41
|
+
content: [{ text: content, type: 'text' }],
|
|
42
|
+
structuredContent: {
|
|
43
|
+
schemaVersion: 1,
|
|
44
|
+
content,
|
|
45
|
+
tables,
|
|
46
|
+
metadata: { baseUrl, format, tableCount: tables.length },
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export const EXTRACT_TABLES_TOOL_DESCRIPTION = `Extract every <table> on the page from already-rendered (post-JavaScript) HTML and return each as GFM / CSV / JSON (caller picks). Runs no Readability, Turndown, or sanitization — a page-wide \`querySelectorAll('table')\` walk in front of the same rowspan/colspan-aware matrix serializer used by the \`tables\` option on \`extract\`. Captures tables outside the article body (nav, aside, boilerplate) that the \`tables\` option never sees. The server fetches nothing: \`localPath\` is the only source, and \`baseUrl\` (optional) is origin context only (never fetched).`;
|
|
51
|
+
export const extractTablesHandler = tool('extract_tables', extractTables);
|
|
52
|
+
export function registerExtractTablesTool(server) {
|
|
53
|
+
return server.registerTool('extract_tables', {
|
|
54
|
+
title: 'Extract every table on the page',
|
|
55
|
+
description: EXTRACT_TABLES_TOOL_DESCRIPTION,
|
|
56
|
+
inputSchema: extractTablesInputShape,
|
|
57
|
+
outputSchema: extractTablesOutputShape,
|
|
58
|
+
}, extractTablesHandler);
|
|
59
|
+
}
|