@v1nvn/readability-mcp 0.17.0 → 0.19.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 +52 -3
- package/dist/assets/{cli-C4RkP1lV.js → cli-CQl4_iKO.js} +4 -2
- package/dist/assets/{cli-C4RkP1lV.js.map → cli-CQl4_iKO.js.map} +1 -1
- package/dist/assets/{extract-BWRBFiUP.js → extract-BF4w5w8k.js} +1935 -1822
- package/dist/assets/extract-BF4w5w8k.js.map +1 -0
- package/dist/index.js +869 -16
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/dist/assets/extract-BWRBFiUP.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,9 +1,867 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { $ as
|
|
2
|
+
import { $ as resolveLazyImages, A as selectorsSchema, B as absolutize, C as extractTablesInputSchema, D as localPathField, E as htmlToMarkdownInputShape, F as detectGating, G as sanitizeHtml, H as renderTable, I as TraceCollector, J as computeTextMetrics, K as isReaderable, L as assembleDiagnostics, M as truncateMarkdown, N as detectPagination, O as outlineInputSchema, P as resolveMetadata, Q as normalizeDocument, R as chunkMarkdown, S as extractSectionInputShape, T as htmlToMarkdownInputSchema, U as resolveHeaderKeys, V as parseTableMatrix, W as resolveCellText, X as nonEmpty, Y as countWords, Z as applySelectors, _ as extractListInputSchema, a as extractLinksOutputShape, at as registerResources, b as extractMetadataInputShape, c as extractTablesOutputShape, ct as presetForSite, d as chunkTextInputSchema, dt as logger, et as resolveReadabilityOptions, f as chunkTextInputShape, ft as loadConfig, g as extractLinksInputShape, h as extractLinksInputSchema, i as extractGridOutputShape, it as toErrorResult, j as readHtmlFile, k as outlineInputShape, l as outlineOutputShape, lt as removePreset, m as extractGridInputShape, n as registerExtractTool, nt as isElement, o as extractListOutputShape, ot as addPreset, p as extractGridInputSchema, q as formatPayload, r as chunkTextOutputShape, rt as ExtractionError, s as extractMetadataOutputShape, st as normalizeSiteKey, t as extractArticleFromHtml, tt as buildDocument, u as outputSchemaShape, ut as selectorMisses, v as extractListInputShape, w as extractTablesInputShape, x as extractSectionInputSchema, y as extractMetadataInputSchema, z as toMarkdown } from "./assets/extract-BF4w5w8k.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
5
8
|
import { z } from "zod";
|
|
6
9
|
import { Readability } from "@mozilla/readability";
|
|
10
|
+
//#region src/preset-cache.ts
|
|
11
|
+
var PRESETS_DIR_ENV = "READABILITY_MCP_PRESETS_DIR";
|
|
12
|
+
var presetFileSchema = z.object({
|
|
13
|
+
site: z.string().min(1),
|
|
14
|
+
detectors: z.array(z.string().min(1)).min(1),
|
|
15
|
+
scope: z.object({
|
|
16
|
+
include: z.string().min(1).optional(),
|
|
17
|
+
exclude: z.array(z.string().min(1)).min(1).optional()
|
|
18
|
+
}).refine((scope) => scope.include !== void 0 || scope.exclude !== void 0, { message: "scope must carry include or exclude" })
|
|
19
|
+
});
|
|
20
|
+
function resolvePresetsDir(env = process.env) {
|
|
21
|
+
const override = env[PRESETS_DIR_ENV];
|
|
22
|
+
if (override !== void 0) return override.trim() === "" ? void 0 : override;
|
|
23
|
+
const root = env.XDG_CACHE_HOME || join(homedir(), process.platform === "darwin" ? "Library/Caches" : ".cache");
|
|
24
|
+
return join(root, "readability-mcp", "presets");
|
|
25
|
+
}
|
|
26
|
+
function loadPresetDir(dir) {
|
|
27
|
+
let names;
|
|
28
|
+
try {
|
|
29
|
+
names = readdirSync(dir).filter((name) => name.endsWith(".json")).sort();
|
|
30
|
+
} catch (err) {
|
|
31
|
+
if (err.code === "ENOENT") logger.debug(`no preset directory at ${dir}`);
|
|
32
|
+
else logger.warn(`preset directory unreadable: ${dir}`);
|
|
33
|
+
return {
|
|
34
|
+
loaded: 0,
|
|
35
|
+
pruned: 0,
|
|
36
|
+
skipped: 0
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const ranked = rankPresetFiles(dir, names);
|
|
40
|
+
const pruned = pruneRanked(dir, ranked.slice(64));
|
|
41
|
+
let loaded = 0;
|
|
42
|
+
let skipped = 0;
|
|
43
|
+
for (const { name } of ranked.slice(0, 64)) if (loadPresetFile(join(dir, name))) loaded++;
|
|
44
|
+
else skipped++;
|
|
45
|
+
if (loaded > 0) logger.info(`loaded ${loaded} site preset(s) from ${dir}`);
|
|
46
|
+
if (pruned > 0) logger.info(`pruned ${pruned} preset file(s) beyond the 64-file bound`);
|
|
47
|
+
return {
|
|
48
|
+
loaded,
|
|
49
|
+
pruned,
|
|
50
|
+
skipped
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function loadPresets(env = process.env) {
|
|
54
|
+
const dir = resolvePresetsDir(env);
|
|
55
|
+
return dir ? loadPresetDir(dir) : void 0;
|
|
56
|
+
}
|
|
57
|
+
function rankPresetFiles(dir, names) {
|
|
58
|
+
const ranked = [];
|
|
59
|
+
for (const name of names) try {
|
|
60
|
+
ranked.push({
|
|
61
|
+
name,
|
|
62
|
+
mtimeMs: statSync(join(dir, name)).mtimeMs
|
|
63
|
+
});
|
|
64
|
+
} catch {}
|
|
65
|
+
ranked.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
66
|
+
return ranked;
|
|
67
|
+
}
|
|
68
|
+
function pruneRanked(dir, ranked) {
|
|
69
|
+
let pruned = 0;
|
|
70
|
+
for (const { name } of ranked) try {
|
|
71
|
+
unlinkSync(join(dir, name));
|
|
72
|
+
pruned++;
|
|
73
|
+
} catch (err) {
|
|
74
|
+
logger.warn(`could not prune preset file ${name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
75
|
+
}
|
|
76
|
+
return pruned;
|
|
77
|
+
}
|
|
78
|
+
function savePreset(preset, env = process.env) {
|
|
79
|
+
const dir = resolvePresetsDir(env);
|
|
80
|
+
if (!dir) return {
|
|
81
|
+
persisted: false,
|
|
82
|
+
reason: "preset-directory-disabled"
|
|
83
|
+
};
|
|
84
|
+
const shape = presetFileSchema.safeParse(preset);
|
|
85
|
+
if (!shape.success) return {
|
|
86
|
+
persisted: false,
|
|
87
|
+
reason: shape.error.issues[0]?.message ?? "invalid preset shape"
|
|
88
|
+
};
|
|
89
|
+
const key = normalizeSiteKey(preset.site);
|
|
90
|
+
if (!key) return {
|
|
91
|
+
persisted: false,
|
|
92
|
+
reason: "site-is-not-a-hostname"
|
|
93
|
+
};
|
|
94
|
+
const path = join(dir, `${key}.json`);
|
|
95
|
+
try {
|
|
96
|
+
mkdirSync(dir, { recursive: true });
|
|
97
|
+
writeFileSync(path, `${JSON.stringify(shape.data, null, 2)}\n`);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
return {
|
|
100
|
+
persisted: false,
|
|
101
|
+
reason: err instanceof Error ? err.message : String(err)
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
pruneRanked(dir, rankPresetFiles(dir, readdirSync(dir).filter((name) => name.endsWith(".json"))).slice(64));
|
|
105
|
+
return {
|
|
106
|
+
path,
|
|
107
|
+
persisted: true
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function loadPresetFile(path) {
|
|
111
|
+
let parsed;
|
|
112
|
+
try {
|
|
113
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
114
|
+
} catch (err) {
|
|
115
|
+
logger.warn(`${path}: not readable JSON (${err instanceof Error ? err.message : String(err)}), preset skipped`);
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
const result = presetFileSchema.safeParse(parsed);
|
|
119
|
+
if (!result.success) {
|
|
120
|
+
logger.warn(`${path}: ${result.error.issues[0]?.message}, preset skipped`);
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
if (!normalizeSiteKey(result.data.site)) {
|
|
124
|
+
logger.warn(`${path}: site is not a hostname, preset skipped`);
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
addPreset(result.data);
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
//#endregion
|
|
131
|
+
//#region src/host-sampling.ts
|
|
132
|
+
var SAMPLING_TIMEOUT_MS = 3e5;
|
|
133
|
+
async function sampleText$1(server, args) {
|
|
134
|
+
const result = await server.server.createMessage({
|
|
135
|
+
messages: [{
|
|
136
|
+
role: "user",
|
|
137
|
+
content: {
|
|
138
|
+
type: "text",
|
|
139
|
+
text: args.userText
|
|
140
|
+
}
|
|
141
|
+
}],
|
|
142
|
+
systemPrompt: args.systemPrompt,
|
|
143
|
+
maxTokens: args.maxTokens
|
|
144
|
+
}, { timeout: SAMPLING_TIMEOUT_MS });
|
|
145
|
+
if (result.content.type !== "text") throw new Error(`host sampling returned non-text content (${result.content.type})`);
|
|
146
|
+
return result.content.text;
|
|
147
|
+
}
|
|
148
|
+
var SuggestParseError = class extends Error {
|
|
149
|
+
rawText;
|
|
150
|
+
constructor(message, rawText) {
|
|
151
|
+
super(message);
|
|
152
|
+
this.rawText = rawText;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
function extractJsonReply(text) {
|
|
156
|
+
const trimmed = text.replace(/^[\s\S]*?```(?:json)?\s*\n?([\s\S]*?)\n?```[\s\S]*$/, "$1").trim();
|
|
157
|
+
try {
|
|
158
|
+
return JSON.parse(trimmed);
|
|
159
|
+
} catch {
|
|
160
|
+
const start = trimmed.indexOf("{");
|
|
161
|
+
const end = trimmed.lastIndexOf("}");
|
|
162
|
+
if (start !== -1 && end > start) try {
|
|
163
|
+
return JSON.parse(trimmed.slice(start, end + 1));
|
|
164
|
+
} catch {}
|
|
165
|
+
throw new SuggestParseError(`host sampling returned text that does not parse as JSON (${trimmed.slice(0, 80)}…)`, trimmed);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async function sampleJson(server, args) {
|
|
169
|
+
return extractJsonReply(await sampleText$1(server, args));
|
|
170
|
+
}
|
|
171
|
+
var DEBRIS_PROBES = [
|
|
172
|
+
{
|
|
173
|
+
label: "player-controls",
|
|
174
|
+
pattern: /Loaded:\s*\d+%/
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
label: "player-controls",
|
|
178
|
+
pattern: /Duration Time \d+:\d\d/
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
label: "metered-barrier",
|
|
182
|
+
pattern: /read your last free article|subscribe to continue reading/i
|
|
183
|
+
}
|
|
184
|
+
];
|
|
185
|
+
function assessLostSignal(input) {
|
|
186
|
+
const wordCount = countWords(input.contentText);
|
|
187
|
+
const nearEmpty = wordCount < 120;
|
|
188
|
+
const debrisProbes = [...new Set(DEBRIS_PROBES.filter((probe) => probe.pattern.test(input.contentText)).map((probe) => probe.label))];
|
|
189
|
+
const reasons = [
|
|
190
|
+
...input.fallbackUsed ? ["fallback-used"] : [],
|
|
191
|
+
...nearEmpty ? ["near-empty"] : [],
|
|
192
|
+
...debrisProbes.map((label) => `debris:${label}`)
|
|
193
|
+
];
|
|
194
|
+
return {
|
|
195
|
+
debrisProbes,
|
|
196
|
+
fallbackUsed: input.fallbackUsed,
|
|
197
|
+
gatedReason: input.gated?.likely ? input.gated.reason : void 0,
|
|
198
|
+
nearEmpty,
|
|
199
|
+
reasons,
|
|
200
|
+
wordCount
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
var PRICE_TABLE = {
|
|
204
|
+
"Upper:Lower": .2,
|
|
205
|
+
"Lower:Upper": .5,
|
|
206
|
+
"Symbol:Upper": .4,
|
|
207
|
+
"Symbol:Lower": .3,
|
|
208
|
+
"Symbol:Digit": .9,
|
|
209
|
+
"Upper:Digit": 1.4,
|
|
210
|
+
"Lower:Digit": 1.3,
|
|
211
|
+
"Digit:Upper": 1.4,
|
|
212
|
+
"Digit:Lower": 1.5,
|
|
213
|
+
"Digit:Symbol": 1.2,
|
|
214
|
+
"Upper:Symbol": .2,
|
|
215
|
+
"Lower:Symbol": .2,
|
|
216
|
+
"Digit:Digit": 1.2,
|
|
217
|
+
"Symbol:Symbol": .3,
|
|
218
|
+
"Upper:Upper": .1,
|
|
219
|
+
"Lower:Lower": .1,
|
|
220
|
+
"Other:Other": 0
|
|
221
|
+
};
|
|
222
|
+
function classifyChar(ch) {
|
|
223
|
+
if (/[A-Z]/.test(ch)) return "Upper";
|
|
224
|
+
if (/[a-z]/.test(ch)) return "Lower";
|
|
225
|
+
if (/[0-9]/.test(ch)) return "Digit";
|
|
226
|
+
if (/[-_]/.test(ch)) return "Symbol";
|
|
227
|
+
return "Other";
|
|
228
|
+
}
|
|
229
|
+
function transitionPrice(from, to) {
|
|
230
|
+
return PRICE_TABLE[`${from}:${to}`] ?? 1;
|
|
231
|
+
}
|
|
232
|
+
function gibberishScore(value) {
|
|
233
|
+
if (value.length < 2) return 0;
|
|
234
|
+
let absolute = 0;
|
|
235
|
+
let previous = null;
|
|
236
|
+
for (const ch of value) {
|
|
237
|
+
const current = classifyChar(ch);
|
|
238
|
+
if (previous !== null) absolute += transitionPrice(previous, current);
|
|
239
|
+
previous = current;
|
|
240
|
+
}
|
|
241
|
+
return absolute / Math.max(1, value.length - 1);
|
|
242
|
+
}
|
|
243
|
+
function exceedsThreshold(value) {
|
|
244
|
+
return value.length >= 4 && gibberishScore(value) >= .3;
|
|
245
|
+
}
|
|
246
|
+
function isGeneratedIdentifier(value) {
|
|
247
|
+
if (exceedsThreshold(value)) return true;
|
|
248
|
+
return value.split(/[-_]/).some((segment) => exceedsThreshold(segment));
|
|
249
|
+
}
|
|
250
|
+
var SAMPLE_MAX_CHARS = 80;
|
|
251
|
+
var NON_CONTENT_SELECTOR = "script, style, noscript, template";
|
|
252
|
+
var HOP_ATTRIBUTE_WHITELIST = [
|
|
253
|
+
"itemprop",
|
|
254
|
+
"role",
|
|
255
|
+
"data-testid"
|
|
256
|
+
];
|
|
257
|
+
function buildChainOutline(input) {
|
|
258
|
+
const { document } = buildDocument(input.html, input.baseUrl);
|
|
259
|
+
normalizeDocument(document, { cleanChrome: input.cleanChrome });
|
|
260
|
+
resolveLazyImages(document);
|
|
261
|
+
const scope = input.mode === "page" ? void 0 : input.mode.scope;
|
|
262
|
+
if (scope) applySelectors(document, scope);
|
|
263
|
+
const scopeRoot = scope?.include ? document.body.querySelector(scope.include) ?? void 0 : void 0;
|
|
264
|
+
const min = scope ? 40 : 200;
|
|
265
|
+
const candidates = [];
|
|
266
|
+
for (const element of (scopeRoot ?? document.body).querySelectorAll("*")) {
|
|
267
|
+
if (element.closest(NON_CONTENT_SELECTOR)) continue;
|
|
268
|
+
const own = ownTextLength(element);
|
|
269
|
+
if (own >= min) candidates.push({
|
|
270
|
+
element,
|
|
271
|
+
own
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
return scope ? groupedOutline(candidates, scopeRoot) : flatOutline(candidates);
|
|
275
|
+
}
|
|
276
|
+
function flatOutline(candidates) {
|
|
277
|
+
const lines = [];
|
|
278
|
+
let truncated = false;
|
|
279
|
+
for (const candidate of [...candidates].sort((a, b) => b.own - a.own)) {
|
|
280
|
+
if (lines.length >= 60) {
|
|
281
|
+
truncated = true;
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
const line = ancestorsUpTo(candidate.element, void 0).map((element) => hopWithSizes(element)).join(" > ");
|
|
285
|
+
if (lines.join("\n---\n").length + line.length > 6e3) {
|
|
286
|
+
truncated = true;
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
lines.push(line);
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
chainCount: lines.length,
|
|
293
|
+
text: lines.join("\n---\n"),
|
|
294
|
+
truncated
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
function groupedOutline(candidates, scopeRoot) {
|
|
298
|
+
const groups = /* @__PURE__ */ new Map();
|
|
299
|
+
for (const candidate of candidates) {
|
|
300
|
+
const chain = ancestorsUpTo(candidate.element, scopeRoot).map((element) => renderHop(element));
|
|
301
|
+
if (scopeRoot) chain.push(renderHop(scopeRoot));
|
|
302
|
+
const key = chain.join(" > ");
|
|
303
|
+
const existing = groups.get(key);
|
|
304
|
+
if (existing) {
|
|
305
|
+
existing.count += 1;
|
|
306
|
+
existing.min = Math.min(existing.min, candidate.own);
|
|
307
|
+
existing.max = Math.max(existing.max, candidate.own);
|
|
308
|
+
} else groups.set(key, {
|
|
309
|
+
chain: key,
|
|
310
|
+
count: 1,
|
|
311
|
+
min: candidate.own,
|
|
312
|
+
max: candidate.own,
|
|
313
|
+
sample: sampleText(candidate.element)
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
const lines = [];
|
|
317
|
+
let truncated = false;
|
|
318
|
+
const sorted = [...groups.values()].sort((a, b) => a.min - b.min);
|
|
319
|
+
for (const group of sorted) {
|
|
320
|
+
if (lines.length >= 60) {
|
|
321
|
+
truncated = true;
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
const line = `${group.chain} (own:${group.min}..${group.max}, ×${group.count}) sample: "${group.sample}"`;
|
|
325
|
+
if (lines.join("\n").length + line.length > 6e3) {
|
|
326
|
+
truncated = true;
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
lines.push(line);
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
chainCount: lines.length,
|
|
333
|
+
text: lines.join("\n"),
|
|
334
|
+
truncated
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
function ancestorsUpTo(element, stop) {
|
|
338
|
+
const hops = [];
|
|
339
|
+
let node = element;
|
|
340
|
+
const body = element.ownerDocument.body;
|
|
341
|
+
while (node && node !== stop && node !== body) {
|
|
342
|
+
hops.push(node);
|
|
343
|
+
node = node.parentElement;
|
|
344
|
+
}
|
|
345
|
+
return hops;
|
|
346
|
+
}
|
|
347
|
+
function hopWithSizes(element) {
|
|
348
|
+
const own = ownTextLength(element);
|
|
349
|
+
const all = element.textContent.length;
|
|
350
|
+
return `${renderHop(element)} (own:${own}, all:${all})`;
|
|
351
|
+
}
|
|
352
|
+
function ownTextLength(element) {
|
|
353
|
+
let own = 0;
|
|
354
|
+
for (const node of element.childNodes) if (node.nodeType === node.TEXT_NODE) own += (node.textContent ?? "").length;
|
|
355
|
+
return own;
|
|
356
|
+
}
|
|
357
|
+
function renderHop(element) {
|
|
358
|
+
let hop = element.tagName.toLowerCase();
|
|
359
|
+
const id = element.id;
|
|
360
|
+
if (id && !isGeneratedIdentifier(id)) hop += `#${id}`;
|
|
361
|
+
const classes = (element.getAttribute("class") ?? "").split(/\s+/).filter(Boolean).filter((className) => !isGeneratedIdentifier(className));
|
|
362
|
+
if (classes.length > 0) hop += `.${classes.join(".")}`;
|
|
363
|
+
for (const attribute of HOP_ATTRIBUTE_WHITELIST) {
|
|
364
|
+
const value = element.getAttribute(attribute);
|
|
365
|
+
if (value) hop += `[${attribute}="${value}"]`;
|
|
366
|
+
}
|
|
367
|
+
return hop;
|
|
368
|
+
}
|
|
369
|
+
function sampleText(element) {
|
|
370
|
+
const text = element.textContent.trim().replace(/\s+/g, " ");
|
|
371
|
+
return text.length > SAMPLE_MAX_CHARS ? `${text.slice(0, SAMPLE_MAX_CHARS)}…` : text;
|
|
372
|
+
}
|
|
373
|
+
//#endregion
|
|
374
|
+
//#region src/policy/selector-lint.ts
|
|
375
|
+
var POSITIONAL_PSEUDO_RE = /:(?:nth(?:-last)?(?:-child|-of-type)|first(?:-child|-of-type)|last(?:-child|-of-type)|only(?:-child|-of-type))\b/i;
|
|
376
|
+
var CONTAINS_PSEUDO_RE = /:contains\s*\(/i;
|
|
377
|
+
function quotedSegmentsOut(selector) {
|
|
378
|
+
return selector.replace(/(["'])(?:\\.|(?!\1)[\s\S])*\1/g, "\"\"");
|
|
379
|
+
}
|
|
380
|
+
function identifierTokens(selector) {
|
|
381
|
+
return [...quotedSegmentsOut(selector).matchAll(/[.#]([A-Za-z0-9_-]+)/g)].map((match) => match[1]);
|
|
382
|
+
}
|
|
383
|
+
function lintSelectorText(selector) {
|
|
384
|
+
if (CONTAINS_PSEUDO_RE.test(selector)) return {
|
|
385
|
+
kind: "contains-pseudo",
|
|
386
|
+
selector,
|
|
387
|
+
detail: ":contains is not standard CSS and changes the result silently"
|
|
388
|
+
};
|
|
389
|
+
const positional = POSITIONAL_PSEUDO_RE.exec(selector);
|
|
390
|
+
if (positional) return {
|
|
391
|
+
kind: "positional-pseudo",
|
|
392
|
+
selector,
|
|
393
|
+
detail: `${positional[0]} depends on this page's child order, not the site's layout`
|
|
394
|
+
};
|
|
395
|
+
for (const token of identifierTokens(selector)) if (isGeneratedIdentifier(token)) return {
|
|
396
|
+
kind: "generated-identifier",
|
|
397
|
+
selector,
|
|
398
|
+
detail: `"${token}" reads as a generated hash that changes on deploy`
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
function lintProposal({ document, detectors, scope }) {
|
|
402
|
+
const violations = [];
|
|
403
|
+
const selectors = [
|
|
404
|
+
...detectors,
|
|
405
|
+
...scope.include ? [scope.include] : [],
|
|
406
|
+
...scope.exclude ?? []
|
|
407
|
+
];
|
|
408
|
+
for (const selector of selectors) {
|
|
409
|
+
const violation = lintSelectorText(selector);
|
|
410
|
+
if (violation) violations.push(violation);
|
|
411
|
+
}
|
|
412
|
+
let includeRoot;
|
|
413
|
+
if (scope.include && !violations.some((violation) => violation.selector === scope.include)) try {
|
|
414
|
+
includeRoot = document.body.querySelector(scope.include) ?? void 0;
|
|
415
|
+
} catch {
|
|
416
|
+
violations.push({
|
|
417
|
+
kind: "unparseable",
|
|
418
|
+
selector: scope.include,
|
|
419
|
+
detail: "the selector engine rejects this selector"
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
for (const detector of detectors) {
|
|
423
|
+
if (violations.some((violation) => violation.selector === detector)) continue;
|
|
424
|
+
if (selectorMisses(document, detector)) violations.push({
|
|
425
|
+
kind: "no-match",
|
|
426
|
+
selector: detector,
|
|
427
|
+
detail: "detector matches nothing on this page"
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
if (scope.include && !includeRoot && !violations.some((violation) => violation.selector === scope.include)) violations.push({
|
|
431
|
+
kind: "no-match",
|
|
432
|
+
selector: scope.include,
|
|
433
|
+
detail: "include matches nothing inside <body>, where applySelectors searches"
|
|
434
|
+
});
|
|
435
|
+
for (const selector of scope.exclude ?? []) {
|
|
436
|
+
if (violations.some((violation) => violation.selector === selector)) continue;
|
|
437
|
+
try {
|
|
438
|
+
if (document.querySelectorAll(selector).length === 0) violations.push({
|
|
439
|
+
kind: "no-match",
|
|
440
|
+
selector,
|
|
441
|
+
detail: "exclude matches nothing on this page"
|
|
442
|
+
});
|
|
443
|
+
} catch {
|
|
444
|
+
violations.push({
|
|
445
|
+
kind: "unparseable",
|
|
446
|
+
selector,
|
|
447
|
+
detail: "the selector engine rejects this selector"
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
if (includeRoot) for (const selector of scope.exclude ?? []) {
|
|
452
|
+
if (violations.some((violation) => violation.selector === selector)) continue;
|
|
453
|
+
try {
|
|
454
|
+
for (const match of document.querySelectorAll(selector)) if (match.contains(includeRoot)) {
|
|
455
|
+
violations.push({
|
|
456
|
+
kind: "exclude-shadows-include",
|
|
457
|
+
selector,
|
|
458
|
+
detail: "this exclude removes the include root itself"
|
|
459
|
+
});
|
|
460
|
+
break;
|
|
461
|
+
}
|
|
462
|
+
} catch {}
|
|
463
|
+
}
|
|
464
|
+
return violations;
|
|
465
|
+
}
|
|
466
|
+
//#endregion
|
|
467
|
+
//#region src/tools/suggest-preset.ts
|
|
468
|
+
var SUGGEST_SYSTEM_PROMPT = `You propose site-level CSS presets for a web-article extractor. The extractor removes every "exclude" match document-wide, then keeps the FIRST match of "include" in document order as the entire page body (the search runs inside <body>). An exclude matching an ancestor of the include root destroys it.
|
|
469
|
+
|
|
470
|
+
Rules:
|
|
471
|
+
- "include": exactly ONE selector that matches an element inside <body> on THIS page. A non-matching selector silently does nothing.
|
|
472
|
+
- "exclude": selectors naming debris containers (video players, carousels, caption figures, comment blocks, promo modules).
|
|
473
|
+
- "detectors": 1-3 selectors naming stable site-template containers that also match on other pages of the same site. Their loss is what retires the preset, so they must be structural, not per-page.
|
|
474
|
+
- Forbidden — statically rejected and fed back to you: :nth-child and every positional pseudo (:first-child, :last-of-type, :only-child, ...), :contains, and generated hash class names (css-1a2b3c, content_1tsiE, BoxStyles_x__Wo6Z4).
|
|
475
|
+
- Prefer itemprop, role, data-testid, ids, and stable class-name stems; quote attribute values exactly as rendered in the material.
|
|
476
|
+
- The material shows every text-bearing node as a CSS chain with text sizes. It deliberately carries no article prose.
|
|
477
|
+
Return strict JSON only — no prose, no code fences.`;
|
|
478
|
+
var suggestPresetInputShape = {
|
|
479
|
+
localPath: localPathField,
|
|
480
|
+
baseUrl: z.url().describe("A URL of the site the page belongs to — its host names the preset. Required: without a host there is no site key to store a preset under, and presets only resolve for pages whose site matches."),
|
|
481
|
+
secondPath: localPathField.optional().describe("A second capture of the SAME site. The converged preset must verify on it too — detectors match, extraction clean — before anything is persisted; without it the preset is verified on the proposal page alone."),
|
|
482
|
+
maxSamplingCalls: z.number().int().min(1).max(8).describe("Upper bound on host-model sampling calls for this run. Round one (include + detectors) and round two (excludes) each consume a call, rejected proposals consume retries. A budget of 1 skips round two.").default(4)
|
|
483
|
+
};
|
|
484
|
+
var suggestPresetInputSchema = z.object(suggestPresetInputShape);
|
|
485
|
+
var proposalSchema = z.object({
|
|
486
|
+
detectors: z.array(z.string().min(1)).min(1).max(6),
|
|
487
|
+
scope: z.object({
|
|
488
|
+
include: z.string().min(1),
|
|
489
|
+
exclude: z.array(z.string().min(1)).max(12).optional()
|
|
490
|
+
})
|
|
491
|
+
});
|
|
492
|
+
var excludeProposalSchema = z.object({ exclude: z.array(z.string().min(1)).max(12) });
|
|
493
|
+
var MAX_SAMPLING_TOKENS = 1024;
|
|
494
|
+
var SUGGEST_PRESET_TOOL_DESCRIPTION = `Ask the HOST's model to propose a site preset (detectors + selectors) for a page whose extraction was lost, then verify it: proposals are validated deterministically, applied through the real pipeline, and a converged preset is stored in memory and persisted to the local preset cache so later extractions of the same site apply it automatically. Runs a bounded two-round loop over MCP \`sampling/createMessage\` — the server embeds no model. The tool is only listed when the connected client advertises the sampling capability, and it refuses to run when the baseline extraction looks healthy: call it after \`extract\` reports gated content, a fallback extraction, a near-empty result, or visible debris such as video-player controls.`;
|
|
495
|
+
var triggerShape = {
|
|
496
|
+
fired: z.boolean().describe("Whether the suggest loop ran at all."),
|
|
497
|
+
reasons: z.array(z.string()).describe("Why the loop ran (the lost signals observed on the baseline extraction), or why it refused."),
|
|
498
|
+
wordCount: z.number().describe("Word count of the baseline extraction; 0 when the loop never ran."),
|
|
499
|
+
debrisProbes: z.array(z.string()).describe("Debris signatures matched in the baseline extraction text."),
|
|
500
|
+
gatedReason: z.string().optional().describe("The gating signal reported by the baseline extraction, if any.")
|
|
501
|
+
};
|
|
502
|
+
var suggestPresetOutputShape = {
|
|
503
|
+
schemaVersion: z.literal(1).describe("Structured-content schema version."),
|
|
504
|
+
content: z.string().describe("Human-readable report of the run."),
|
|
505
|
+
trigger: z.object(triggerShape).describe("Baseline lost-signal verdict."),
|
|
506
|
+
preset: z.object({
|
|
507
|
+
site: z.string().describe("The host the preset is keyed by."),
|
|
508
|
+
detectors: z.array(z.string()).describe("Template fingerprints that must keep matching."),
|
|
509
|
+
scope: z.object({
|
|
510
|
+
include: z.string().optional(),
|
|
511
|
+
exclude: z.array(z.string()).optional()
|
|
512
|
+
}).describe("The accepted selector scope.")
|
|
513
|
+
}).optional().describe("The preset that converged, present only when one was accepted and verified."),
|
|
514
|
+
verification: z.object({
|
|
515
|
+
applied: z.boolean().describe("Whether the stored preset resolved and applied during the verification extraction."),
|
|
516
|
+
converged: z.boolean().describe("Whether the verification extraction came back clean."),
|
|
517
|
+
verifiedPages: z.number().int().min(1).max(2).describe("Pages the preset verified on: 2 only when secondPath was given and the preset verified on that capture too."),
|
|
518
|
+
wordCountBefore: z.number().describe("Baseline extraction word count."),
|
|
519
|
+
wordCountAfter: z.number().describe("Verification extraction word count.")
|
|
520
|
+
}).optional().describe("Result of extracting with the preset actually stored."),
|
|
521
|
+
secondVerification: z.object({
|
|
522
|
+
applied: z.boolean().describe("Whether the preset resolved and applied on the second capture."),
|
|
523
|
+
converged: z.boolean().describe("Whether the second-capture extraction came back clean."),
|
|
524
|
+
wordCountAfter: z.number().describe("Second-capture extraction word count.")
|
|
525
|
+
}).optional().describe("Verification on the secondPath capture; absent when secondPath was not given or the capture belongs to another site."),
|
|
526
|
+
persistence: z.object({
|
|
527
|
+
persisted: z.boolean().describe("Whether the preset file was written to the local preset cache directory."),
|
|
528
|
+
path: z.string().optional().describe("The file written, when persisted."),
|
|
529
|
+
reason: z.string().optional().describe("Why nothing was persisted, when it was not.")
|
|
530
|
+
}).optional().describe("Outcome of the disk write; absent when the run never accepted a preset."),
|
|
531
|
+
sampling: z.object({
|
|
532
|
+
calls: z.number().int().describe("Sampling calls actually made."),
|
|
533
|
+
budget: z.number().int().describe("The budget this run was given."),
|
|
534
|
+
budgetExhausted: z.boolean().describe("Whether the budget ran out before the loop could converge.")
|
|
535
|
+
}).describe("Sampling accounting for this run.")
|
|
536
|
+
};
|
|
537
|
+
function registerSuggestPresetTool(server) {
|
|
538
|
+
return server.registerTool("suggest_preset", {
|
|
539
|
+
title: "Suggest a site preset using the host model",
|
|
540
|
+
description: SUGGEST_PRESET_TOOL_DESCRIPTION,
|
|
541
|
+
inputSchema: suggestPresetInputShape,
|
|
542
|
+
outputSchema: suggestPresetOutputShape
|
|
543
|
+
}, async (rawArgs) => {
|
|
544
|
+
const args = suggestPresetInputSchema.parse(rawArgs);
|
|
545
|
+
try {
|
|
546
|
+
return await runSuggestLoop(args, server);
|
|
547
|
+
} catch (err) {
|
|
548
|
+
logger.error(`suggest_preset failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
549
|
+
return toErrorResult(err);
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
async function runSuggestLoop(args, server) {
|
|
554
|
+
const site = normalizeSiteKey(args.baseUrl);
|
|
555
|
+
if (!site) throw new Error(`baseUrl does not name a site: ${args.baseUrl}`);
|
|
556
|
+
const html = readHtmlFile(args.localPath);
|
|
557
|
+
const secondHtml = args.secondPath ? readHtmlFile(args.secondPath) : void 0;
|
|
558
|
+
const state = {
|
|
559
|
+
budget: args.maxSamplingCalls,
|
|
560
|
+
calls: 0,
|
|
561
|
+
exhausted: false
|
|
562
|
+
};
|
|
563
|
+
if (presetForSite(site)) return report(state, { trigger: {
|
|
564
|
+
debrisProbes: [],
|
|
565
|
+
fired: false,
|
|
566
|
+
reasons: ["preset-exists"],
|
|
567
|
+
wordCount: 0
|
|
568
|
+
} });
|
|
569
|
+
const baseline = extractText(html, args.baseUrl);
|
|
570
|
+
const canonicalSite = normalizeSiteKey(baseline.metadata.canonical);
|
|
571
|
+
if (canonicalSite && canonicalSite !== site) throw new Error(`baseUrl names ${site} but the page's canonical URL names ${canonicalSite} — the capture does not belong to the site the preset would be keyed by.`);
|
|
572
|
+
const baselineEvidence = assessLostSignal({
|
|
573
|
+
contentText: baseline.content,
|
|
574
|
+
fallbackUsed: baseline.diagnostics.fallbackUsed ?? false,
|
|
575
|
+
gated: baseline.diagnostics.gated
|
|
576
|
+
});
|
|
577
|
+
const trigger = {
|
|
578
|
+
debrisProbes: baselineEvidence.debrisProbes,
|
|
579
|
+
fired: baselineEvidence.reasons.length > 0,
|
|
580
|
+
gatedReason: baselineEvidence.gatedReason,
|
|
581
|
+
reasons: baselineEvidence.reasons,
|
|
582
|
+
wordCount: baselineEvidence.wordCount
|
|
583
|
+
};
|
|
584
|
+
if (!trigger.fired) return report(state, { trigger });
|
|
585
|
+
const core = await proposeCore(html, args.baseUrl, site, baseline, trigger, state, server);
|
|
586
|
+
if (!core) return report(state, { trigger });
|
|
587
|
+
const excludes = await proposeExcludes(html, args.baseUrl, core, state, server);
|
|
588
|
+
const preset = {
|
|
589
|
+
detectors: core.detectors,
|
|
590
|
+
site,
|
|
591
|
+
scope: {
|
|
592
|
+
exclude: excludes.length > 0 ? excludes : void 0,
|
|
593
|
+
include: core.scope.include
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
addPreset({
|
|
597
|
+
detectors: preset.detectors,
|
|
598
|
+
scope: preset.scope,
|
|
599
|
+
site: preset.site
|
|
600
|
+
});
|
|
601
|
+
const verification = extractText(html, args.baseUrl);
|
|
602
|
+
const verificationEvidence = assessLostSignal({
|
|
603
|
+
contentText: verification.content,
|
|
604
|
+
fallbackUsed: verification.diagnostics.fallbackUsed ?? false,
|
|
605
|
+
gated: verification.diagnostics.gated
|
|
606
|
+
});
|
|
607
|
+
const applied = verification.presetSignal?.applied === true;
|
|
608
|
+
const converged = applied && verificationEvidence.reasons.length === 0;
|
|
609
|
+
const verdict = {
|
|
610
|
+
applied,
|
|
611
|
+
converged,
|
|
612
|
+
verifiedPages: 1,
|
|
613
|
+
wordCountAfter: verificationEvidence.wordCount,
|
|
614
|
+
wordCountBefore: trigger.wordCount
|
|
615
|
+
};
|
|
616
|
+
if (!converged) {
|
|
617
|
+
removePreset(preset.site);
|
|
618
|
+
state.persistence = {
|
|
619
|
+
persisted: false,
|
|
620
|
+
reason: "not-converged"
|
|
621
|
+
};
|
|
622
|
+
return report(state, {
|
|
623
|
+
preset,
|
|
624
|
+
trigger,
|
|
625
|
+
verification: verdict
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
let secondVerification;
|
|
629
|
+
if (secondHtml) {
|
|
630
|
+
const second = extractText(secondHtml, args.baseUrl);
|
|
631
|
+
const secondCanonicalSite = normalizeSiteKey(second.metadata.canonical);
|
|
632
|
+
if (secondCanonicalSite && secondCanonicalSite !== site) {
|
|
633
|
+
removePreset(preset.site);
|
|
634
|
+
state.persistence = {
|
|
635
|
+
persisted: false,
|
|
636
|
+
reason: "second-page-canonical-mismatch"
|
|
637
|
+
};
|
|
638
|
+
return report(state, {
|
|
639
|
+
preset,
|
|
640
|
+
trigger,
|
|
641
|
+
verification: verdict
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
const secondEvidence = assessLostSignal({
|
|
645
|
+
contentText: second.content,
|
|
646
|
+
fallbackUsed: second.diagnostics.fallbackUsed ?? false,
|
|
647
|
+
gated: second.diagnostics.gated
|
|
648
|
+
});
|
|
649
|
+
const secondApplied = second.presetSignal?.applied === true;
|
|
650
|
+
const secondConverged = secondApplied && secondEvidence.reasons.length === 0;
|
|
651
|
+
secondVerification = {
|
|
652
|
+
applied: secondApplied,
|
|
653
|
+
converged: secondConverged,
|
|
654
|
+
wordCountAfter: secondEvidence.wordCount
|
|
655
|
+
};
|
|
656
|
+
if (!secondConverged) {
|
|
657
|
+
removePreset(preset.site);
|
|
658
|
+
state.persistence = {
|
|
659
|
+
persisted: false,
|
|
660
|
+
reason: "second-page-not-converged"
|
|
661
|
+
};
|
|
662
|
+
return report(state, {
|
|
663
|
+
preset,
|
|
664
|
+
secondVerification,
|
|
665
|
+
trigger,
|
|
666
|
+
verification: verdict
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
verdict.verifiedPages = 2;
|
|
670
|
+
}
|
|
671
|
+
state.persistence = savePreset(preset);
|
|
672
|
+
return report(state, {
|
|
673
|
+
preset,
|
|
674
|
+
secondVerification,
|
|
675
|
+
trigger,
|
|
676
|
+
verification: verdict
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
function report(state, parts) {
|
|
680
|
+
const lines = [];
|
|
681
|
+
if (!parts.trigger.fired) lines.push(parts.trigger.reasons.length > 0 ? `No suggest loop run: ${parts.trigger.reasons.join(", ")}.` : `No suggest loop run: the baseline extraction looks healthy (${parts.trigger.wordCount} words, no lost signal).`);
|
|
682
|
+
else lines.push(`Lost signals on the baseline extraction (${parts.trigger.wordCount} words): ${parts.trigger.reasons.join(", ")}.`);
|
|
683
|
+
if (state.calls > 0) lines.push(`Sampling: ${state.calls}/${state.budget} call(s)${state.exhausted ? " — budget exhausted" : ""}.`);
|
|
684
|
+
if (parts.verification) lines.push(`Verification with the preset stored: applied=${parts.verification.applied}, converged=${parts.verification.converged}, ${parts.verification.wordCountAfter} words, pages verified: ${parts.verification.verifiedPages}.`);
|
|
685
|
+
if (parts.secondVerification) lines.push(`Second-page verification: applied=${parts.secondVerification.applied}, converged=${parts.secondVerification.converged}, ${parts.secondVerification.wordCountAfter} words.`);
|
|
686
|
+
if (parts.preset) lines.push(`Accepted preset for ${parts.preset.site}: include=${parts.preset.scope.include ?? "(none)"}, exclude=${(parts.preset.scope.exclude ?? []).join(", ") || "(none)"}, detectors=${parts.preset.detectors.join(", ")}.`);
|
|
687
|
+
if (state.persistence) lines.push(state.persistence.persisted ? `Persisted to ${state.persistence.path}.` : `Not persisted: ${state.persistence.reason}.`);
|
|
688
|
+
return {
|
|
689
|
+
content: [{
|
|
690
|
+
text: lines.join("\n"),
|
|
691
|
+
type: "text"
|
|
692
|
+
}],
|
|
693
|
+
structuredContent: {
|
|
694
|
+
content: lines.join("\n"),
|
|
695
|
+
persistence: state.persistence,
|
|
696
|
+
preset: parts.preset,
|
|
697
|
+
sampling: {
|
|
698
|
+
budget: state.budget,
|
|
699
|
+
budgetExhausted: state.exhausted,
|
|
700
|
+
calls: state.calls
|
|
701
|
+
},
|
|
702
|
+
schemaVersion: 1,
|
|
703
|
+
secondVerification: parts.secondVerification,
|
|
704
|
+
trigger: parts.trigger,
|
|
705
|
+
verification: parts.verification
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
function coreProposalUserText(site, title, evidence, outline, rejection) {
|
|
710
|
+
return [
|
|
711
|
+
`SITE: ${site}`,
|
|
712
|
+
`PAGE: ${title || "(untitled)"}`,
|
|
713
|
+
`BASELINE: ${evidence.wordCount} words; debris probes: ${evidence.debrisProbes.join(", ") || "none"}; gating: ${evidence.gatedReason ?? "none"}. The extraction lost to page junk — propose a preset.`,
|
|
714
|
+
"",
|
|
715
|
+
"MATERIAL — ancestor chains of every node holding at least 200 chars of its own text, hop format tag#id.classes[attr=\"value\"] (own:X, all:Y):",
|
|
716
|
+
outline.text,
|
|
717
|
+
...outline.truncated ? ["(material truncated at the budget — the largest own-text chains come first)"] : [],
|
|
718
|
+
...rejection ? [
|
|
719
|
+
"",
|
|
720
|
+
"YOUR PREVIOUS PROPOSAL WAS REJECTED:",
|
|
721
|
+
rejection,
|
|
722
|
+
"Return corrected strict JSON."
|
|
723
|
+
] : [""],
|
|
724
|
+
"Return JSON: {\"detectors\": [\"...\"], \"scope\": {\"include\": \"...\", \"exclude\": [\"...\"]}}"
|
|
725
|
+
].join("\n");
|
|
726
|
+
}
|
|
727
|
+
async function proposeCore(html, baseUrl, site, baseline, evidence, state, server) {
|
|
728
|
+
const outline = buildChainOutline({
|
|
729
|
+
baseUrl,
|
|
730
|
+
cleanChrome: true,
|
|
731
|
+
html,
|
|
732
|
+
mode: "page"
|
|
733
|
+
});
|
|
734
|
+
let rejection;
|
|
735
|
+
while (state.calls < state.budget) {
|
|
736
|
+
state.calls += 1;
|
|
737
|
+
let reply;
|
|
738
|
+
try {
|
|
739
|
+
reply = await sampleJson(server, {
|
|
740
|
+
maxTokens: MAX_SAMPLING_TOKENS,
|
|
741
|
+
systemPrompt: SUGGEST_SYSTEM_PROMPT,
|
|
742
|
+
userText: coreProposalUserText(site, baseline.metadata.title ?? "", evidence, outline, rejection)
|
|
743
|
+
});
|
|
744
|
+
} catch (err) {
|
|
745
|
+
if (err instanceof SuggestParseError) {
|
|
746
|
+
rejection = err.message;
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
throw err;
|
|
750
|
+
}
|
|
751
|
+
const parsed = proposalSchema.safeParse(reply);
|
|
752
|
+
if (!parsed.success) {
|
|
753
|
+
rejection = `the reply must parse as {"detectors": [...], "scope": {"include": "...", "exclude": [...]}} — ${parsed.error.issues[0]?.message}`;
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
const violations = lintProposal({
|
|
757
|
+
detectors: parsed.data.detectors,
|
|
758
|
+
document: normalizedDocument(html, baseUrl),
|
|
759
|
+
scope: parsed.data.scope
|
|
760
|
+
});
|
|
761
|
+
if (violations.length > 0) {
|
|
762
|
+
rejection = renderViolations(violations);
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
return {
|
|
766
|
+
detectors: parsed.data.detectors,
|
|
767
|
+
scope: {
|
|
768
|
+
exclude: parsed.data.scope.exclude,
|
|
769
|
+
include: parsed.data.scope.include
|
|
770
|
+
}
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
state.exhausted = true;
|
|
774
|
+
}
|
|
775
|
+
async function proposeExcludes(html, baseUrl, core, state, server) {
|
|
776
|
+
if (state.calls >= state.budget) return core.scope.exclude ?? [];
|
|
777
|
+
const outline = buildChainOutline({
|
|
778
|
+
baseUrl,
|
|
779
|
+
cleanChrome: true,
|
|
780
|
+
html,
|
|
781
|
+
mode: { scope: { include: core.scope.include } }
|
|
782
|
+
});
|
|
783
|
+
let rejection;
|
|
784
|
+
while (state.calls < state.budget) {
|
|
785
|
+
state.calls += 1;
|
|
786
|
+
let reply;
|
|
787
|
+
try {
|
|
788
|
+
reply = await sampleJson(server, {
|
|
789
|
+
maxTokens: MAX_SAMPLING_TOKENS,
|
|
790
|
+
systemPrompt: SUGGEST_SYSTEM_PROMPT,
|
|
791
|
+
userText: [
|
|
792
|
+
`ACCEPTED SO FAR: {"detectors": ${JSON.stringify(core.detectors)}, "scope": {"include": ${JSON.stringify(core.scope.include)}}}`,
|
|
793
|
+
"",
|
|
794
|
+
"MATERIAL — text blocks remaining inside the included subtree after the include was applied, grouped by chain, smallest own text first, each with a text sample:",
|
|
795
|
+
outline.text,
|
|
796
|
+
...rejection ? [
|
|
797
|
+
"",
|
|
798
|
+
"YOUR PREVIOUS PROPOSAL WAS REJECTED:",
|
|
799
|
+
rejection,
|
|
800
|
+
"Return corrected strict JSON."
|
|
801
|
+
] : [""],
|
|
802
|
+
"Propose excludes for the debris groups (video players, carousels, caption figures, comment blocks, promos) — never for prose paragraphs. Return [] if none. Return JSON: {\"exclude\": [\"...\"]}"
|
|
803
|
+
].join("\n")
|
|
804
|
+
});
|
|
805
|
+
} catch (err) {
|
|
806
|
+
if (err instanceof SuggestParseError) {
|
|
807
|
+
rejection = err.message;
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
throw err;
|
|
811
|
+
}
|
|
812
|
+
const parsed = excludeProposalSchema.safeParse(reply);
|
|
813
|
+
if (!parsed.success) {
|
|
814
|
+
rejection = `the reply must parse as {"exclude": ["..."]} — ${parsed.error.issues[0]?.message}`;
|
|
815
|
+
continue;
|
|
816
|
+
}
|
|
817
|
+
const violations = lintProposal({
|
|
818
|
+
detectors: core.detectors,
|
|
819
|
+
document: normalizedDocument(html, baseUrl),
|
|
820
|
+
scope: {
|
|
821
|
+
exclude: parsed.data.exclude,
|
|
822
|
+
include: core.scope.include
|
|
823
|
+
}
|
|
824
|
+
});
|
|
825
|
+
if (violations.length > 0) {
|
|
826
|
+
rejection = renderViolations(violations);
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
const merged = [...core.scope.exclude ?? []];
|
|
830
|
+
for (const selector of parsed.data.exclude) if (!merged.includes(selector)) merged.push(selector);
|
|
831
|
+
return merged;
|
|
832
|
+
}
|
|
833
|
+
state.exhausted = true;
|
|
834
|
+
return core.scope.exclude ?? [];
|
|
835
|
+
}
|
|
836
|
+
function renderViolations(violations) {
|
|
837
|
+
return violations.map((violation) => `- ${violation.kind}: ${violation.selector} — ${violation.detail}`).join("\n");
|
|
838
|
+
}
|
|
839
|
+
function normalizedDocument(html, baseUrl) {
|
|
840
|
+
const { document } = buildDocument(html, baseUrl);
|
|
841
|
+
normalizeDocument(document, { cleanChrome: true });
|
|
842
|
+
resolveLazyImages(document);
|
|
843
|
+
return document;
|
|
844
|
+
}
|
|
845
|
+
function extractText(html, baseUrl) {
|
|
846
|
+
const result = extractArticleFromHtml({
|
|
847
|
+
baseUrl,
|
|
848
|
+
cache: false,
|
|
849
|
+
format: "text",
|
|
850
|
+
html
|
|
851
|
+
});
|
|
852
|
+
if (result.isError || !result.structuredContent) throw new Error("extraction failed inside the suggest loop");
|
|
853
|
+
const structured = result.structuredContent;
|
|
854
|
+
return {
|
|
855
|
+
content: structured.content,
|
|
856
|
+
diagnostics: {
|
|
857
|
+
fallbackUsed: structured.diagnostics.fallbackUsed,
|
|
858
|
+
gated: structured.diagnostics.gated
|
|
859
|
+
},
|
|
860
|
+
metadata: structured.metadata,
|
|
861
|
+
presetSignal: structured.diagnostics.preset
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
//#endregion
|
|
7
865
|
//#region src/sampling.ts
|
|
8
866
|
var SUMMARIZE_SYSTEM_PROMPT = "Summarize the user-supplied text concisely while preserving its key points, entities, and any decisive conclusions. Output only the summary prose — no preamble, no headings unless the source had them.";
|
|
9
867
|
var summarizeInputShape = {
|
|
@@ -13,19 +871,11 @@ var summarizeInputShape = {
|
|
|
13
871
|
var summarizeInputSchema = z.object(summarizeInputShape);
|
|
14
872
|
var SUMMARIZE_TOOL_DESCRIPTION = `Summarize text using the HOST's model via MCP \`sampling/createMessage\` — the server embeds no model and calls no provider directly. Hand it the output of \`extract\`, \`extract_section\`, \`html_to_markdown\`, or any markdown/text string; the host picks the model and may ask the user to approve the sampling request (human-in-the-loop per MCP). The tool is only listed when the connected client advertises the sampling capability.`;
|
|
15
873
|
async function summarizeWithHost(server, args) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
role: "user",
|
|
19
|
-
content: {
|
|
20
|
-
type: "text",
|
|
21
|
-
text: args.text
|
|
22
|
-
}
|
|
23
|
-
}],
|
|
874
|
+
return sampleText$1(server, {
|
|
875
|
+
maxTokens: args.maxTokens,
|
|
24
876
|
systemPrompt: SUMMARIZE_SYSTEM_PROMPT,
|
|
25
|
-
|
|
877
|
+
userText: args.text
|
|
26
878
|
});
|
|
27
|
-
if (result.content.type !== "text") throw new Error(`host sampling returned non-text content (${result.content.type}); summarize expects a text response`);
|
|
28
|
-
return result.content.text;
|
|
29
879
|
}
|
|
30
880
|
function registerSummarizeTool(server) {
|
|
31
881
|
return server.registerTool("summarize", {
|
|
@@ -46,7 +896,7 @@ function registerSummarizeTool(server) {
|
|
|
46
896
|
});
|
|
47
897
|
}
|
|
48
898
|
function registerSamplingTools(server) {
|
|
49
|
-
return [registerSummarizeTool(server)];
|
|
899
|
+
return [registerSummarizeTool(server), registerSuggestPresetTool(server)];
|
|
50
900
|
}
|
|
51
901
|
//#endregion
|
|
52
902
|
//#region src/tools/chunk_text.ts
|
|
@@ -1078,7 +1928,8 @@ function extractSectionFromHtml(input) {
|
|
|
1078
1928
|
if (selector !== void 0) return extractArticleFromHtml({
|
|
1079
1929
|
html,
|
|
1080
1930
|
baseUrl,
|
|
1081
|
-
selectors: { include: selector }
|
|
1931
|
+
selectors: { include: selector },
|
|
1932
|
+
resolvePreset: false
|
|
1082
1933
|
});
|
|
1083
1934
|
if (heading === void 0) throw new ExtractionError("Provide exactly one of `selector` or `heading`.");
|
|
1084
1935
|
const { document } = buildDocument(html, baseUrl);
|
|
@@ -1088,7 +1939,8 @@ function extractSectionFromHtml(input) {
|
|
|
1088
1939
|
return extractArticleFromHtml({
|
|
1089
1940
|
html: scoped,
|
|
1090
1941
|
baseUrl,
|
|
1091
|
-
selectors: { include: SECTION_SCOPE_SELECTOR }
|
|
1942
|
+
selectors: { include: SECTION_SCOPE_SELECTOR },
|
|
1943
|
+
resolvePreset: false
|
|
1092
1944
|
});
|
|
1093
1945
|
}
|
|
1094
1946
|
var 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).`;
|
|
@@ -1392,6 +2244,7 @@ function registerCapabilityGatedTools(server) {
|
|
|
1392
2244
|
}
|
|
1393
2245
|
function createServer() {
|
|
1394
2246
|
const server = createMcpServer();
|
|
2247
|
+
loadPresets();
|
|
1395
2248
|
registerTools(server);
|
|
1396
2249
|
registerResources(server);
|
|
1397
2250
|
server.server.oninitialized = () => {
|
|
@@ -1401,7 +2254,7 @@ function createServer() {
|
|
|
1401
2254
|
}
|
|
1402
2255
|
//#endregion
|
|
1403
2256
|
//#region src/index.ts
|
|
1404
|
-
if (process.argv[2] === "extract") import("./assets/cli-
|
|
2257
|
+
if (process.argv[2] === "extract") import("./assets/cli-CQl4_iKO.js").then((m) => m.runCli(process.argv.slice(2))).then((code) => {
|
|
1405
2258
|
process.exit(code);
|
|
1406
2259
|
}).catch((err) => {
|
|
1407
2260
|
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|