@v1nvn/readability 0.43.0 → 0.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +7 -9
- package/src/cli.ts +134 -0
- package/src/config.ts +42 -0
- package/src/dev.ts +77 -0
- package/src/errors.ts +19 -0
- package/src/handler.ts +8 -0
- package/src/host-sampling.ts +86 -0
- package/src/index.ts +27 -0
- package/src/logger.ts +3 -0
- package/src/output/format.ts +132 -0
- package/src/pipeline/cell-text.ts +56 -0
- package/src/pipeline/context.ts +88 -0
- package/src/pipeline/dom.ts +23 -0
- package/src/pipeline/footnotes.ts +181 -0
- package/src/pipeline/math.ts +130 -0
- package/src/pipeline/normalize.ts +594 -0
- package/src/pipeline/readability.ts +36 -0
- package/src/pipeline/resolver.ts +143 -0
- package/src/pipeline/sanitize.ts +42 -0
- package/src/pipeline/tables.ts +286 -0
- package/src/pipeline/turndown.ts +191 -0
- package/src/pipeline/urls.ts +13 -0
- package/src/policy/chunk.ts +482 -0
- package/src/policy/diagnostics.ts +96 -0
- package/src/policy/explain.ts +182 -0
- package/src/policy/fallback.ts +110 -0
- package/src/policy/gating.ts +106 -0
- package/src/policy/grid-detector.ts +313 -0
- package/src/policy/identifiers.ts +75 -0
- package/src/policy/images.ts +64 -0
- package/src/policy/list-detector.ts +308 -0
- package/src/policy/lost-signal.ts +69 -0
- package/src/policy/markdown.ts +76 -0
- package/src/policy/metadata.ts +312 -0
- package/src/policy/outline-chains.ts +215 -0
- package/src/policy/outline.ts +89 -0
- package/src/policy/pagination.ts +104 -0
- package/src/policy/presets.ts +142 -0
- package/src/policy/section.ts +127 -0
- package/src/policy/selector-lint.ts +186 -0
- package/src/policy/sibling-scan.ts +99 -0
- package/src/policy/text.ts +36 -0
- package/src/policy/truncate.ts +59 -0
- package/src/preset-cache.ts +221 -0
- package/src/resources.ts +317 -0
- package/src/sampling.ts +74 -0
- package/src/server.ts +85 -0
- package/src/tools/chunk_text.ts +65 -0
- package/src/tools/explain.ts +299 -0
- package/src/tools/extract.ts +368 -0
- package/src/tools/extract_grid.ts +90 -0
- package/src/tools/extract_links.ts +138 -0
- package/src/tools/extract_list.ts +97 -0
- package/src/tools/extract_metadata.ts +102 -0
- package/src/tools/extract_section.ts +84 -0
- package/src/tools/extract_tables.ts +101 -0
- package/src/tools/html-source.ts +40 -0
- package/src/tools/html_to_markdown.ts +180 -0
- package/src/tools/outline.ts +77 -0
- package/src/tools/output-schema.ts +715 -0
- package/src/tools/schemas.ts +442 -0
- package/src/tools/suggest-preset.ts +708 -0
- package/src/types/jsdom.d.ts +15 -0
- package/src/types/turndown-plugin-gfm.d.ts +11 -0
- package/dist/assets/cli-BE_Ml3rh.js +0 -76
- package/dist/assets/cli-BE_Ml3rh.js.map +0 -1
- package/dist/assets/extract-Cyv5qfGN.js +0 -2880
- package/dist/assets/extract-Cyv5qfGN.js.map +0 -1
- package/dist/index.js +0 -2267
- package/dist/index.js.map +0 -1
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
import type { GatingSignal } from '../policy/gating.ts';
|
|
4
|
+
import type { PresetScope } from '../policy/presets.ts';
|
|
5
|
+
import type { SelectorViolation } from '../policy/selector-lint.ts';
|
|
6
|
+
import type { ToolHandle } from '../server.ts';
|
|
7
|
+
import type { StructuredContent } from './output-schema.ts';
|
|
8
|
+
|
|
9
|
+
import { tool } from '../handler.ts';
|
|
10
|
+
import { sampleJson, SuggestParseError } from '../host-sampling.ts';
|
|
11
|
+
import { buildDocument } from '../pipeline/dom.ts';
|
|
12
|
+
import { normalizeDocument, resolveLazyImages } from '../pipeline/normalize.ts';
|
|
13
|
+
import { assessLostSignal } from '../policy/lost-signal.ts';
|
|
14
|
+
import { buildChainOutline } from '../policy/outline-chains.ts';
|
|
15
|
+
import {
|
|
16
|
+
addPreset,
|
|
17
|
+
normalizeSiteKey,
|
|
18
|
+
presetForSite,
|
|
19
|
+
removePreset,
|
|
20
|
+
} from '../policy/presets.ts';
|
|
21
|
+
import { lintProposal } from '../policy/selector-lint.ts';
|
|
22
|
+
import { savePreset } from '../preset-cache.ts';
|
|
23
|
+
import { extractArticleFromHtml } from './extract.ts';
|
|
24
|
+
import { readHtmlFile } from './html-source.ts';
|
|
25
|
+
import { localPathField } from './schemas.ts';
|
|
26
|
+
|
|
27
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
28
|
+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
29
|
+
|
|
30
|
+
const 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.
|
|
31
|
+
|
|
32
|
+
Rules:
|
|
33
|
+
- "include": exactly ONE selector that matches an element inside <body> on THIS page. A non-matching selector silently does nothing.
|
|
34
|
+
- "exclude": selectors naming debris containers (video players, carousels, caption figures, comment blocks, promo modules).
|
|
35
|
+
- "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.
|
|
36
|
+
- 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).
|
|
37
|
+
- Prefer itemprop, role, data-testid, ids, and stable class-name stems; quote attribute values exactly as rendered in the material.
|
|
38
|
+
- The material shows every text-bearing node as a CSS chain with text sizes. It deliberately carries no article prose.
|
|
39
|
+
Return strict JSON only — no prose, no code fences.`;
|
|
40
|
+
|
|
41
|
+
const suggestPresetInputShape = {
|
|
42
|
+
localPath: localPathField,
|
|
43
|
+
baseUrl: z
|
|
44
|
+
.url()
|
|
45
|
+
.describe(
|
|
46
|
+
'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.',
|
|
47
|
+
),
|
|
48
|
+
secondPath: localPathField
|
|
49
|
+
.optional()
|
|
50
|
+
.describe(
|
|
51
|
+
'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.',
|
|
52
|
+
),
|
|
53
|
+
maxSamplingCalls: z
|
|
54
|
+
.number()
|
|
55
|
+
.int()
|
|
56
|
+
.min(1)
|
|
57
|
+
.max(8)
|
|
58
|
+
.describe(
|
|
59
|
+
'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.',
|
|
60
|
+
)
|
|
61
|
+
.default(4),
|
|
62
|
+
} as const;
|
|
63
|
+
|
|
64
|
+
const suggestPresetInputSchema = z.object(suggestPresetInputShape);
|
|
65
|
+
|
|
66
|
+
const proposalSchema = z.object({
|
|
67
|
+
detectors: z.array(z.string().min(1)).min(1).max(6),
|
|
68
|
+
scope: z.object({
|
|
69
|
+
include: z.string().min(1),
|
|
70
|
+
exclude: z.array(z.string().min(1)).max(12).optional(),
|
|
71
|
+
}),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const excludeProposalSchema = z.object({
|
|
75
|
+
exclude: z.array(z.string().min(1)).max(12),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const MAX_SAMPLING_TOKENS = 8192;
|
|
79
|
+
|
|
80
|
+
export const 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.`;
|
|
81
|
+
|
|
82
|
+
const triggerShape = {
|
|
83
|
+
fired: z.boolean().describe('Whether the suggest loop ran at all.'),
|
|
84
|
+
reasons: z
|
|
85
|
+
.array(z.string())
|
|
86
|
+
.describe(
|
|
87
|
+
'Why the loop ran (the lost signals observed on the baseline extraction), or why it refused.',
|
|
88
|
+
),
|
|
89
|
+
wordCount: z
|
|
90
|
+
.number()
|
|
91
|
+
.describe(
|
|
92
|
+
'Word count of the baseline extraction; 0 when the loop never ran.',
|
|
93
|
+
),
|
|
94
|
+
debrisProbes: z
|
|
95
|
+
.array(z.string())
|
|
96
|
+
.describe('Debris signatures matched in the baseline extraction text.'),
|
|
97
|
+
gatedReason: z
|
|
98
|
+
.string()
|
|
99
|
+
.optional()
|
|
100
|
+
.describe('The gating signal reported by the baseline extraction, if any.'),
|
|
101
|
+
} as const;
|
|
102
|
+
|
|
103
|
+
const suggestPresetOutputShape = {
|
|
104
|
+
schemaVersion: z.literal(1).describe('Structured-content schema version.'),
|
|
105
|
+
content: z.string().describe('Human-readable report of the run.'),
|
|
106
|
+
trigger: z.object(triggerShape).describe('Baseline lost-signal verdict.'),
|
|
107
|
+
preset: z
|
|
108
|
+
.object({
|
|
109
|
+
site: z.string().describe('The host the preset is keyed by.'),
|
|
110
|
+
detectors: z
|
|
111
|
+
.array(z.string())
|
|
112
|
+
.describe('Template fingerprints that must keep matching.'),
|
|
113
|
+
scope: z
|
|
114
|
+
.object({
|
|
115
|
+
include: z.string().optional(),
|
|
116
|
+
exclude: z.array(z.string()).optional(),
|
|
117
|
+
})
|
|
118
|
+
.describe('The accepted selector scope.'),
|
|
119
|
+
})
|
|
120
|
+
.optional()
|
|
121
|
+
.describe(
|
|
122
|
+
'The preset that converged, present only when one was accepted and verified.',
|
|
123
|
+
),
|
|
124
|
+
verification: z
|
|
125
|
+
.object({
|
|
126
|
+
applied: z
|
|
127
|
+
.boolean()
|
|
128
|
+
.describe(
|
|
129
|
+
'Whether the stored preset resolved and applied during the verification extraction.',
|
|
130
|
+
),
|
|
131
|
+
converged: z
|
|
132
|
+
.boolean()
|
|
133
|
+
.describe('Whether the verification extraction came back clean.'),
|
|
134
|
+
verifiedPages: z
|
|
135
|
+
.number()
|
|
136
|
+
.int()
|
|
137
|
+
.min(1)
|
|
138
|
+
.max(2)
|
|
139
|
+
.describe(
|
|
140
|
+
'Pages the preset verified on: 2 only when secondPath was given and the preset verified on that capture too.',
|
|
141
|
+
),
|
|
142
|
+
wordCountBefore: z.number().describe('Baseline extraction word count.'),
|
|
143
|
+
wordCountAfter: z
|
|
144
|
+
.number()
|
|
145
|
+
.describe('Verification extraction word count.'),
|
|
146
|
+
})
|
|
147
|
+
.optional()
|
|
148
|
+
.describe('Result of extracting with the preset actually stored.'),
|
|
149
|
+
secondVerification: z
|
|
150
|
+
.object({
|
|
151
|
+
applied: z
|
|
152
|
+
.boolean()
|
|
153
|
+
.describe(
|
|
154
|
+
'Whether the preset resolved and applied on the second capture.',
|
|
155
|
+
),
|
|
156
|
+
converged: z
|
|
157
|
+
.boolean()
|
|
158
|
+
.describe('Whether the second-capture extraction came back clean.'),
|
|
159
|
+
wordCountAfter: z
|
|
160
|
+
.number()
|
|
161
|
+
.describe('Second-capture extraction word count.'),
|
|
162
|
+
})
|
|
163
|
+
.optional()
|
|
164
|
+
.describe(
|
|
165
|
+
'Verification on the secondPath capture; absent when secondPath was not given or the capture belongs to another site.',
|
|
166
|
+
),
|
|
167
|
+
persistence: z
|
|
168
|
+
.object({
|
|
169
|
+
persisted: z
|
|
170
|
+
.boolean()
|
|
171
|
+
.describe(
|
|
172
|
+
'Whether the preset file was written to the local preset cache directory.',
|
|
173
|
+
),
|
|
174
|
+
path: z.string().optional().describe('The file written, when persisted.'),
|
|
175
|
+
reason: z
|
|
176
|
+
.string()
|
|
177
|
+
.optional()
|
|
178
|
+
.describe('Why nothing was persisted, when it was not.'),
|
|
179
|
+
})
|
|
180
|
+
.optional()
|
|
181
|
+
.describe(
|
|
182
|
+
'Outcome of the disk write; absent when the run never accepted a preset.',
|
|
183
|
+
),
|
|
184
|
+
sampling: z
|
|
185
|
+
.object({
|
|
186
|
+
calls: z.number().int().describe('Sampling calls actually made.'),
|
|
187
|
+
budget: z.number().int().describe('The budget this run was given.'),
|
|
188
|
+
budgetExhausted: z
|
|
189
|
+
.boolean()
|
|
190
|
+
.describe('Whether the budget ran out before the loop could converge.'),
|
|
191
|
+
})
|
|
192
|
+
.describe('Sampling accounting for this run.'),
|
|
193
|
+
} as const;
|
|
194
|
+
|
|
195
|
+
export function registerSuggestPresetTool(server: McpServer): ToolHandle {
|
|
196
|
+
return server.registerTool(
|
|
197
|
+
'suggest_preset',
|
|
198
|
+
{
|
|
199
|
+
title: 'Suggest a site preset using the host model',
|
|
200
|
+
description: SUGGEST_PRESET_TOOL_DESCRIPTION,
|
|
201
|
+
inputSchema: suggestPresetInputShape,
|
|
202
|
+
outputSchema: suggestPresetOutputShape,
|
|
203
|
+
},
|
|
204
|
+
tool(
|
|
205
|
+
'suggest_preset',
|
|
206
|
+
args => runSuggestLoop(args, server),
|
|
207
|
+
suggestPresetInputSchema,
|
|
208
|
+
),
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
interface Trigger {
|
|
213
|
+
debrisProbes: readonly string[];
|
|
214
|
+
fired: boolean;
|
|
215
|
+
gatedReason?: string;
|
|
216
|
+
reasons: readonly string[];
|
|
217
|
+
wordCount: number;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
interface AcceptedPreset {
|
|
221
|
+
detectors: readonly string[];
|
|
222
|
+
scope: PresetScope;
|
|
223
|
+
site: string;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
interface Verification {
|
|
227
|
+
applied: boolean;
|
|
228
|
+
converged: boolean;
|
|
229
|
+
verifiedPages: 1 | 2;
|
|
230
|
+
wordCountAfter: number;
|
|
231
|
+
wordCountBefore: number;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
interface SecondVerification {
|
|
235
|
+
applied: boolean;
|
|
236
|
+
converged: boolean;
|
|
237
|
+
wordCountAfter: number;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
interface LoopState {
|
|
241
|
+
budget: number;
|
|
242
|
+
calls: number;
|
|
243
|
+
// A sampling attempt was blocked by the budget — distinct from a run that
|
|
244
|
+
// simply never needed the remaining calls.
|
|
245
|
+
exhausted: boolean;
|
|
246
|
+
persistence?: { path?: string; persisted: boolean; reason?: string };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export async function runSuggestLoop(
|
|
250
|
+
args: {
|
|
251
|
+
baseUrl: string;
|
|
252
|
+
localPath: string;
|
|
253
|
+
maxSamplingCalls: number;
|
|
254
|
+
secondPath?: string;
|
|
255
|
+
},
|
|
256
|
+
server: McpServer,
|
|
257
|
+
): Promise<CallToolResult> {
|
|
258
|
+
const site = normalizeSiteKey(args.baseUrl);
|
|
259
|
+
if (!site) {
|
|
260
|
+
throw new Error(`baseUrl does not name a site: ${args.baseUrl}`);
|
|
261
|
+
}
|
|
262
|
+
const html = readHtmlFile(args.localPath);
|
|
263
|
+
const secondHtml = args.secondPath
|
|
264
|
+
? readHtmlFile(args.secondPath)
|
|
265
|
+
: undefined;
|
|
266
|
+
const state: LoopState = {
|
|
267
|
+
budget: args.maxSamplingCalls,
|
|
268
|
+
calls: 0,
|
|
269
|
+
exhausted: false,
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
if (presetForSite(site)) {
|
|
273
|
+
return report(state, {
|
|
274
|
+
trigger: {
|
|
275
|
+
debrisProbes: [],
|
|
276
|
+
fired: false,
|
|
277
|
+
reasons: ['preset-exists'],
|
|
278
|
+
wordCount: 0,
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const baseline = extractText(html, args.baseUrl);
|
|
284
|
+
const canonicalSite = normalizeSiteKey(baseline.metadata.canonical);
|
|
285
|
+
if (canonicalSite && canonicalSite !== site) {
|
|
286
|
+
throw new Error(
|
|
287
|
+
`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.`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
const baselineEvidence = assessLostSignal({
|
|
291
|
+
contentText: baseline.content,
|
|
292
|
+
fallbackUsed: baseline.diagnostics.fallbackUsed ?? false,
|
|
293
|
+
gated: baseline.diagnostics.gated,
|
|
294
|
+
});
|
|
295
|
+
const trigger: Trigger = {
|
|
296
|
+
debrisProbes: baselineEvidence.debrisProbes,
|
|
297
|
+
fired: baselineEvidence.reasons.length > 0,
|
|
298
|
+
gatedReason: baselineEvidence.gatedReason,
|
|
299
|
+
reasons: baselineEvidence.reasons,
|
|
300
|
+
wordCount: baselineEvidence.wordCount,
|
|
301
|
+
};
|
|
302
|
+
if (!trigger.fired) {
|
|
303
|
+
return report(state, { trigger });
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const core = await proposeCore(
|
|
307
|
+
html,
|
|
308
|
+
args.baseUrl,
|
|
309
|
+
site,
|
|
310
|
+
baseline,
|
|
311
|
+
trigger,
|
|
312
|
+
state,
|
|
313
|
+
server,
|
|
314
|
+
);
|
|
315
|
+
if (!core) {
|
|
316
|
+
return report(state, { trigger });
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const excludes = await proposeExcludes(
|
|
320
|
+
html,
|
|
321
|
+
args.baseUrl,
|
|
322
|
+
core,
|
|
323
|
+
state,
|
|
324
|
+
server,
|
|
325
|
+
);
|
|
326
|
+
const preset: AcceptedPreset = {
|
|
327
|
+
detectors: core.detectors,
|
|
328
|
+
site,
|
|
329
|
+
scope: {
|
|
330
|
+
exclude: excludes.length > 0 ? excludes : undefined,
|
|
331
|
+
include: core.scope.include,
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
addPreset({
|
|
336
|
+
detectors: preset.detectors,
|
|
337
|
+
scope: preset.scope,
|
|
338
|
+
site: preset.site,
|
|
339
|
+
});
|
|
340
|
+
const verification = extractText(html, args.baseUrl);
|
|
341
|
+
const verificationEvidence = assessLostSignal({
|
|
342
|
+
contentText: verification.content,
|
|
343
|
+
fallbackUsed: verification.diagnostics.fallbackUsed ?? false,
|
|
344
|
+
gated: verification.diagnostics.gated,
|
|
345
|
+
});
|
|
346
|
+
const applied = verification.presetSignal?.applied === true;
|
|
347
|
+
const converged = applied && verificationEvidence.reasons.length === 0;
|
|
348
|
+
const verdict: Verification = {
|
|
349
|
+
applied,
|
|
350
|
+
converged,
|
|
351
|
+
verifiedPages: 1,
|
|
352
|
+
wordCountAfter: verificationEvidence.wordCount,
|
|
353
|
+
wordCountBefore: trigger.wordCount,
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
if (!converged) {
|
|
357
|
+
removePreset(preset.site);
|
|
358
|
+
state.persistence = { persisted: false, reason: 'not-converged' };
|
|
359
|
+
return report(state, { preset, trigger, verification: verdict });
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
let secondVerification: SecondVerification | undefined;
|
|
363
|
+
if (secondHtml) {
|
|
364
|
+
const second = extractText(secondHtml, args.baseUrl);
|
|
365
|
+
const secondCanonicalSite = normalizeSiteKey(second.metadata.canonical);
|
|
366
|
+
if (secondCanonicalSite && secondCanonicalSite !== site) {
|
|
367
|
+
removePreset(preset.site);
|
|
368
|
+
state.persistence = {
|
|
369
|
+
persisted: false,
|
|
370
|
+
reason: 'second-page-canonical-mismatch',
|
|
371
|
+
};
|
|
372
|
+
return report(state, { preset, trigger, verification: verdict });
|
|
373
|
+
}
|
|
374
|
+
const secondEvidence = assessLostSignal({
|
|
375
|
+
contentText: second.content,
|
|
376
|
+
fallbackUsed: second.diagnostics.fallbackUsed ?? false,
|
|
377
|
+
gated: second.diagnostics.gated,
|
|
378
|
+
});
|
|
379
|
+
const secondApplied = second.presetSignal?.applied === true;
|
|
380
|
+
const secondConverged =
|
|
381
|
+
secondApplied && secondEvidence.reasons.length === 0;
|
|
382
|
+
secondVerification = {
|
|
383
|
+
applied: secondApplied,
|
|
384
|
+
converged: secondConverged,
|
|
385
|
+
wordCountAfter: secondEvidence.wordCount,
|
|
386
|
+
};
|
|
387
|
+
if (!secondConverged) {
|
|
388
|
+
removePreset(preset.site);
|
|
389
|
+
state.persistence = {
|
|
390
|
+
persisted: false,
|
|
391
|
+
reason: 'second-page-not-converged',
|
|
392
|
+
};
|
|
393
|
+
return report(state, {
|
|
394
|
+
preset,
|
|
395
|
+
secondVerification,
|
|
396
|
+
trigger,
|
|
397
|
+
verification: verdict,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
verdict.verifiedPages = 2;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
state.persistence = savePreset(preset);
|
|
404
|
+
return report(state, {
|
|
405
|
+
preset,
|
|
406
|
+
secondVerification,
|
|
407
|
+
trigger,
|
|
408
|
+
verification: verdict,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function report(
|
|
413
|
+
state: LoopState,
|
|
414
|
+
parts: {
|
|
415
|
+
preset?: AcceptedPreset;
|
|
416
|
+
secondVerification?: SecondVerification;
|
|
417
|
+
trigger: Trigger;
|
|
418
|
+
verification?: Verification;
|
|
419
|
+
},
|
|
420
|
+
): CallToolResult {
|
|
421
|
+
const lines: string[] = [];
|
|
422
|
+
if (!parts.trigger.fired) {
|
|
423
|
+
lines.push(
|
|
424
|
+
parts.trigger.reasons.length > 0
|
|
425
|
+
? `No suggest loop run: ${parts.trigger.reasons.join(', ')}.`
|
|
426
|
+
: `No suggest loop run: the baseline extraction looks healthy (${parts.trigger.wordCount} words, no lost signal).`,
|
|
427
|
+
);
|
|
428
|
+
} else {
|
|
429
|
+
lines.push(
|
|
430
|
+
`Lost signals on the baseline extraction (${parts.trigger.wordCount} words): ${parts.trigger.reasons.join(', ')}.`,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
if (state.calls > 0) {
|
|
434
|
+
lines.push(
|
|
435
|
+
`Sampling: ${state.calls}/${state.budget} call(s)${state.exhausted ? ' — budget exhausted' : ''}.`,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
if (parts.verification) {
|
|
439
|
+
lines.push(
|
|
440
|
+
`Verification with the preset stored: applied=${parts.verification.applied}, converged=${parts.verification.converged}, ${parts.verification.wordCountAfter} words, pages verified: ${parts.verification.verifiedPages}.`,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
if (parts.secondVerification) {
|
|
444
|
+
lines.push(
|
|
445
|
+
`Second-page verification: applied=${parts.secondVerification.applied}, converged=${parts.secondVerification.converged}, ${parts.secondVerification.wordCountAfter} words.`,
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
if (parts.preset) {
|
|
449
|
+
lines.push(
|
|
450
|
+
`Accepted preset for ${parts.preset.site}: include=${parts.preset.scope.include ?? '(none)'}, exclude=${(parts.preset.scope.exclude ?? []).join(', ') || '(none)'}, detectors=${parts.preset.detectors.join(', ')}.`,
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
if (state.persistence) {
|
|
454
|
+
lines.push(
|
|
455
|
+
state.persistence.persisted
|
|
456
|
+
? `Persisted to ${state.persistence.path}.`
|
|
457
|
+
: `Not persisted: ${state.persistence.reason}.`,
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return {
|
|
462
|
+
content: [{ text: lines.join('\n'), type: 'text' }],
|
|
463
|
+
structuredContent: {
|
|
464
|
+
content: lines.join('\n'),
|
|
465
|
+
persistence: state.persistence,
|
|
466
|
+
preset: parts.preset,
|
|
467
|
+
sampling: {
|
|
468
|
+
budget: state.budget,
|
|
469
|
+
budgetExhausted: state.exhausted,
|
|
470
|
+
calls: state.calls,
|
|
471
|
+
},
|
|
472
|
+
schemaVersion: 1 as const,
|
|
473
|
+
secondVerification: parts.secondVerification,
|
|
474
|
+
trigger: parts.trigger,
|
|
475
|
+
verification: parts.verification,
|
|
476
|
+
},
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
interface CoreProposal {
|
|
481
|
+
detectors: readonly string[];
|
|
482
|
+
scope: PresetScope & { include: string };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function coreProposalUserText(
|
|
486
|
+
site: string,
|
|
487
|
+
title: string,
|
|
488
|
+
evidence: {
|
|
489
|
+
debrisProbes: readonly string[];
|
|
490
|
+
gatedReason?: string;
|
|
491
|
+
wordCount: number;
|
|
492
|
+
},
|
|
493
|
+
outline: { text: string; truncated: boolean },
|
|
494
|
+
rejection: string | undefined,
|
|
495
|
+
): string {
|
|
496
|
+
return [
|
|
497
|
+
`SITE: ${site}`,
|
|
498
|
+
`PAGE: ${title || '(untitled)'}`,
|
|
499
|
+
`BASELINE: ${evidence.wordCount} words; debris probes: ${evidence.debrisProbes.join(', ') || 'none'}; gating: ${evidence.gatedReason ?? 'none'}. The extraction lost to page junk — propose a preset.`,
|
|
500
|
+
'',
|
|
501
|
+
'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):',
|
|
502
|
+
outline.text,
|
|
503
|
+
...(outline.truncated
|
|
504
|
+
? [
|
|
505
|
+
'(material truncated at the budget — the largest own-text chains come first)',
|
|
506
|
+
]
|
|
507
|
+
: []),
|
|
508
|
+
...(rejection
|
|
509
|
+
? [
|
|
510
|
+
'',
|
|
511
|
+
'YOUR PREVIOUS PROPOSAL WAS REJECTED:',
|
|
512
|
+
rejection,
|
|
513
|
+
'Return corrected strict JSON.',
|
|
514
|
+
]
|
|
515
|
+
: ['']),
|
|
516
|
+
'Return JSON: {"detectors": ["..."], "scope": {"include": "...", "exclude": ["..."]}}',
|
|
517
|
+
].join('\n');
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async function proposeCore(
|
|
521
|
+
html: string,
|
|
522
|
+
baseUrl: string,
|
|
523
|
+
site: string,
|
|
524
|
+
baseline: { metadata: { title?: string } },
|
|
525
|
+
evidence: Trigger,
|
|
526
|
+
state: LoopState,
|
|
527
|
+
server: McpServer,
|
|
528
|
+
): Promise<CoreProposal | undefined> {
|
|
529
|
+
const outline = buildChainOutline({
|
|
530
|
+
baseUrl,
|
|
531
|
+
cleanChrome: true,
|
|
532
|
+
html,
|
|
533
|
+
mode: 'page',
|
|
534
|
+
});
|
|
535
|
+
let rejection: string | undefined;
|
|
536
|
+
while (state.calls < state.budget) {
|
|
537
|
+
state.calls += 1;
|
|
538
|
+
let reply: unknown;
|
|
539
|
+
try {
|
|
540
|
+
reply = await sampleJson(server, {
|
|
541
|
+
maxTokens: MAX_SAMPLING_TOKENS,
|
|
542
|
+
systemPrompt: SUGGEST_SYSTEM_PROMPT,
|
|
543
|
+
userText: coreProposalUserText(
|
|
544
|
+
site,
|
|
545
|
+
baseline.metadata.title ?? '',
|
|
546
|
+
evidence,
|
|
547
|
+
outline,
|
|
548
|
+
rejection,
|
|
549
|
+
),
|
|
550
|
+
});
|
|
551
|
+
} catch (err) {
|
|
552
|
+
if (err instanceof SuggestParseError) {
|
|
553
|
+
rejection = err.message;
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
throw err;
|
|
557
|
+
}
|
|
558
|
+
const parsed = proposalSchema.safeParse(reply);
|
|
559
|
+
if (!parsed.success) {
|
|
560
|
+
rejection = `the reply must parse as {"detectors": [...], "scope": {"include": "...", "exclude": [...]}} — ${parsed.error.issues[0]?.message}`;
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
const violations = lintProposal({
|
|
564
|
+
detectors: parsed.data.detectors,
|
|
565
|
+
document: normalizedDocument(html, baseUrl),
|
|
566
|
+
scope: parsed.data.scope,
|
|
567
|
+
});
|
|
568
|
+
if (violations.length > 0) {
|
|
569
|
+
rejection = renderViolations(violations);
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
return {
|
|
573
|
+
detectors: parsed.data.detectors,
|
|
574
|
+
scope: {
|
|
575
|
+
exclude: parsed.data.scope.exclude,
|
|
576
|
+
include: parsed.data.scope.include,
|
|
577
|
+
},
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
state.exhausted = true;
|
|
581
|
+
return undefined;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function proposeExcludes(
|
|
585
|
+
html: string,
|
|
586
|
+
baseUrl: string,
|
|
587
|
+
core: CoreProposal,
|
|
588
|
+
state: LoopState,
|
|
589
|
+
server: McpServer,
|
|
590
|
+
): Promise<readonly string[]> {
|
|
591
|
+
if (state.calls >= state.budget) {
|
|
592
|
+
// Documented skip: the round-one scope stands, nothing was blocked.
|
|
593
|
+
return core.scope.exclude ?? [];
|
|
594
|
+
}
|
|
595
|
+
const outline = buildChainOutline({
|
|
596
|
+
baseUrl,
|
|
597
|
+
cleanChrome: true,
|
|
598
|
+
html,
|
|
599
|
+
mode: { scope: { include: core.scope.include } },
|
|
600
|
+
});
|
|
601
|
+
let rejection: string | undefined;
|
|
602
|
+
while (state.calls < state.budget) {
|
|
603
|
+
state.calls += 1;
|
|
604
|
+
let reply: unknown;
|
|
605
|
+
try {
|
|
606
|
+
reply = await sampleJson(server, {
|
|
607
|
+
maxTokens: MAX_SAMPLING_TOKENS,
|
|
608
|
+
systemPrompt: SUGGEST_SYSTEM_PROMPT,
|
|
609
|
+
userText: [
|
|
610
|
+
`ACCEPTED SO FAR: {"detectors": ${JSON.stringify(core.detectors)}, "scope": {"include": ${JSON.stringify(core.scope.include)}}}`,
|
|
611
|
+
'',
|
|
612
|
+
'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:',
|
|
613
|
+
outline.text,
|
|
614
|
+
...(rejection
|
|
615
|
+
? [
|
|
616
|
+
'',
|
|
617
|
+
'YOUR PREVIOUS PROPOSAL WAS REJECTED:',
|
|
618
|
+
rejection,
|
|
619
|
+
'Return corrected strict JSON.',
|
|
620
|
+
]
|
|
621
|
+
: ['']),
|
|
622
|
+
'Propose excludes for the debris groups (video players, carousels, caption figures, comment blocks, promos) — never for prose paragraphs. Return [] if none. Return JSON: {"exclude": ["..."]}',
|
|
623
|
+
].join('\n'),
|
|
624
|
+
});
|
|
625
|
+
} catch (err) {
|
|
626
|
+
if (err instanceof SuggestParseError) {
|
|
627
|
+
rejection = err.message;
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
throw err;
|
|
631
|
+
}
|
|
632
|
+
const parsed = excludeProposalSchema.safeParse(reply);
|
|
633
|
+
if (!parsed.success) {
|
|
634
|
+
rejection = `the reply must parse as {"exclude": ["..."]} — ${parsed.error.issues[0]?.message}`;
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
const violations = lintProposal({
|
|
638
|
+
detectors: core.detectors,
|
|
639
|
+
document: normalizedDocument(html, baseUrl),
|
|
640
|
+
scope: { exclude: parsed.data.exclude, include: core.scope.include },
|
|
641
|
+
});
|
|
642
|
+
if (violations.length > 0) {
|
|
643
|
+
rejection = renderViolations(violations);
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
const merged = [...(core.scope.exclude ?? [])];
|
|
647
|
+
for (const selector of parsed.data.exclude) {
|
|
648
|
+
if (!merged.includes(selector)) {
|
|
649
|
+
merged.push(selector);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return merged;
|
|
653
|
+
}
|
|
654
|
+
state.exhausted = true;
|
|
655
|
+
return core.scope.exclude ?? [];
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function renderViolations(violations: readonly SelectorViolation[]): string {
|
|
659
|
+
return violations
|
|
660
|
+
.map(
|
|
661
|
+
violation =>
|
|
662
|
+
`- ${violation.kind}: ${violation.selector} — ${violation.detail}`,
|
|
663
|
+
)
|
|
664
|
+
.join('\n');
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// The tree proposals are validated against is normalized exactly as the
|
|
668
|
+
// extract pipeline normalizes it — preset validation at extraction time runs
|
|
669
|
+
// on the same tree, so a lint pass here is a faithful rehearsal.
|
|
670
|
+
function normalizedDocument(html: string, baseUrl: string): Document {
|
|
671
|
+
const { document } = buildDocument(html, baseUrl);
|
|
672
|
+
normalizeDocument(document, { cleanChrome: true });
|
|
673
|
+
resolveLazyImages(document);
|
|
674
|
+
return document;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function extractText(
|
|
678
|
+
html: string,
|
|
679
|
+
baseUrl: string,
|
|
680
|
+
): {
|
|
681
|
+
content: string;
|
|
682
|
+
diagnostics: {
|
|
683
|
+
fallbackUsed?: boolean;
|
|
684
|
+
gated?: GatingSignal;
|
|
685
|
+
};
|
|
686
|
+
metadata: { canonical?: string; title?: string };
|
|
687
|
+
presetSignal?: { applied: boolean };
|
|
688
|
+
} {
|
|
689
|
+
const result = extractArticleFromHtml({
|
|
690
|
+
baseUrl,
|
|
691
|
+
cache: false,
|
|
692
|
+
format: 'text',
|
|
693
|
+
html,
|
|
694
|
+
});
|
|
695
|
+
if (result.isError || !result.structuredContent) {
|
|
696
|
+
throw new Error('extraction failed inside the suggest loop');
|
|
697
|
+
}
|
|
698
|
+
const structured = result.structuredContent as StructuredContent;
|
|
699
|
+
return {
|
|
700
|
+
content: structured.content,
|
|
701
|
+
diagnostics: {
|
|
702
|
+
fallbackUsed: structured.diagnostics.fallbackUsed,
|
|
703
|
+
gated: structured.diagnostics.gated,
|
|
704
|
+
},
|
|
705
|
+
metadata: structured.metadata,
|
|
706
|
+
presetSignal: structured.diagnostics.preset,
|
|
707
|
+
};
|
|
708
|
+
}
|