crawlforge-mcp-server 5.6.11 → 5.8.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/CLAUDE.md +5 -4
- package/README.md +8 -5
- package/package.json +3 -3
- package/server.js +41 -16
- package/src/core/AuthManager.js +11 -1
- package/src/core/ResultStore.js +230 -0
- package/src/schemas/toolOutputSchemas.js +84 -13
- package/src/server/fallbackHints.js +1 -0
- package/src/server/inlineThreshold.js +149 -0
- package/src/server/toolFilter.js +11 -4
- package/src/server/withAuth.js +25 -0
- package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +2 -1
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +5 -4
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +4 -1
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +43 -1
- package/src/skills/agent-skills/crawlforge-web-scraping/references/tool-reference.md +27 -1
- package/src/tools/advanced/batchScrape/index.js +23 -43
- package/src/tools/result/readResult.js +147 -0
- package/src/tools/scrape/_highlights.js +79 -0
- package/src/tools/scrape/formats.js +47 -1
- package/src/tools/scrape/unifiedScrape.js +163 -6
|
@@ -18,6 +18,7 @@ import { EventEmitter } from 'events';
|
|
|
18
18
|
import { ElicitationHelper } from '../../../core/ElicitationHelper.js'; // D1.4
|
|
19
19
|
import JobManager from '../../../core/JobManager.js';
|
|
20
20
|
import WebhookDispatcher from '../../../core/WebhookDispatcher.js';
|
|
21
|
+
import { getResultStore } from '../../../core/ResultStore.js';
|
|
21
22
|
import { BatchScrapeSchema } from './schema.js';
|
|
22
23
|
import { scrapeUrlsBatch, processResults, paginateResults } from './queue.js';
|
|
23
24
|
import { sendWebhookNotification } from './reporter.js';
|
|
@@ -35,7 +36,7 @@ export class BatchScrapeTool extends EventEmitter {
|
|
|
35
36
|
maxBatchSize = 50,
|
|
36
37
|
enableResultCaching = true,
|
|
37
38
|
enableLogging = true,
|
|
38
|
-
|
|
39
|
+
resultStore = null
|
|
39
40
|
} = options;
|
|
40
41
|
|
|
41
42
|
this.jobManager = jobManager || new JobManager({
|
|
@@ -54,21 +55,12 @@ export class BatchScrapeTool extends EventEmitter {
|
|
|
54
55
|
this.enableWebhookNotifications = enableWebhookNotifications;
|
|
55
56
|
|
|
56
57
|
this.activeBatches = new Map();
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
this.
|
|
58
|
+
// Phase 2: completed batches live in the shared ResultStore (keyed by
|
|
59
|
+
// batchId) so batch and single results share one eviction budget and TTL.
|
|
60
|
+
this.resultStore = resultStore || getResultStore();
|
|
60
61
|
// D1.4: Elicitation helper (set mcpServer after instantiation if desired)
|
|
61
62
|
this._elicitation = new ElicitationHelper({});
|
|
62
63
|
|
|
63
|
-
// Bound batchResults' lifetime: sweep expired entries periodically (in
|
|
64
|
-
// addition to the on-read eviction in getBatchResults) so cached results —
|
|
65
|
-
// including full HTML bodies when formats includes 'html' — don't
|
|
66
|
-
// accumulate in memory for the life of the process.
|
|
67
|
-
// .unref() so this timer never blocks process exit on its own — matches
|
|
68
|
-
// SnapshotManager's cleanupTimer.
|
|
69
|
-
this._resultsSweepTimer = setInterval(() => this._sweepBatchResults(), 10 * 60 * 1000);
|
|
70
|
-
if (typeof this._resultsSweepTimer.unref === 'function') this._resultsSweepTimer.unref();
|
|
71
|
-
|
|
72
64
|
this.stats = {
|
|
73
65
|
totalBatches: 0,
|
|
74
66
|
completedBatches: 0,
|
|
@@ -232,20 +224,18 @@ export class BatchScrapeTool extends EventEmitter {
|
|
|
232
224
|
}
|
|
233
225
|
|
|
234
226
|
async getBatchResults(batchId, page = 1, pageSize = 25) {
|
|
235
|
-
|
|
227
|
+
// An expired or unknown id reads as null (the store evicts on read) and
|
|
228
|
+
// falls through to the activeBatches/jobManager lookups below.
|
|
229
|
+
const cached = this.resultStore.get(batchId);
|
|
236
230
|
if (cached) {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
// Expired — evict now instead of waiting for the periodic sweep, and
|
|
247
|
-
// fall through to the activeBatches/jobManager lookups below.
|
|
248
|
-
this.batchResults.delete(batchId);
|
|
231
|
+
const { results, mode } = cached.payload;
|
|
232
|
+
const offset = (page - 1) * pageSize;
|
|
233
|
+
return {
|
|
234
|
+
batchId, success: true, status: 'completed', ...(mode ? { mode } : {}),
|
|
235
|
+
results: paginateResults(results, offset, pageSize),
|
|
236
|
+
pagination: { page, pageSize, totalResults: results.length, totalPages: Math.ceil(results.length / pageSize) },
|
|
237
|
+
cached: true, timestamp: cached.createdAt
|
|
238
|
+
};
|
|
249
239
|
}
|
|
250
240
|
|
|
251
241
|
const active = this.activeBatches.get(batchId);
|
|
@@ -321,7 +311,7 @@ export class BatchScrapeTool extends EventEmitter {
|
|
|
321
311
|
return {
|
|
322
312
|
...this.stats,
|
|
323
313
|
activeBatches: this.activeBatches.size,
|
|
324
|
-
cachedResults: this.
|
|
314
|
+
cachedResults: this._cachedBatchIds().length,
|
|
325
315
|
jobManagerStats: this.jobManager ? this.jobManager.getStats() : null,
|
|
326
316
|
webhookStats: this.webhookDispatcher ? this.webhookDispatcher.getStats() : null
|
|
327
317
|
};
|
|
@@ -332,8 +322,7 @@ export class BatchScrapeTool extends EventEmitter {
|
|
|
332
322
|
try { await this.cancelBatch(batchId); } catch (e) { this._log('warn', `Failed to cancel batch ${batchId}: ${e.message}`); }
|
|
333
323
|
}
|
|
334
324
|
this.activeBatches.clear();
|
|
335
|
-
this.
|
|
336
|
-
if (this._resultsSweepTimer) clearInterval(this._resultsSweepTimer);
|
|
325
|
+
for (const batchId of this._cachedBatchIds()) this.resultStore.delete(batchId);
|
|
337
326
|
this.jobManager?.destroy();
|
|
338
327
|
this.webhookDispatcher?.destroy();
|
|
339
328
|
this.removeAllListeners();
|
|
@@ -365,23 +354,14 @@ export class BatchScrapeTool extends EventEmitter {
|
|
|
365
354
|
return this.webhookDispatcher.registerWebhook(webhookConfig.url, config);
|
|
366
355
|
}
|
|
367
356
|
|
|
368
|
-
/**
|
|
369
|
-
* Cache a batch's results, capped to maxCachedBatches. Map preserves
|
|
370
|
-
* insertion order, so the oldest entry is evicted first (LRU by write time).
|
|
371
|
-
*/
|
|
357
|
+
/** Cache a batch's results in the shared store under its batchId. */
|
|
372
358
|
_cacheBatchResult(batchId, results, mode) {
|
|
373
|
-
this.
|
|
374
|
-
while (this.batchResults.size > this.maxCachedBatches) {
|
|
375
|
-
const oldestKey = this.batchResults.keys().next().value;
|
|
376
|
-
this.batchResults.delete(oldestKey);
|
|
377
|
-
}
|
|
359
|
+
this.resultStore.put('batch_scrape', { results, mode }, { key: batchId });
|
|
378
360
|
}
|
|
379
361
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
if (now - entry.timestamp >= entry.ttl) this.batchResults.delete(id);
|
|
384
|
-
}
|
|
362
|
+
/** Live store entries this tool wrote. */
|
|
363
|
+
_cachedBatchIds() {
|
|
364
|
+
return this.resultStore.list().filter((entry) => entry.toolName === 'batch_scrape').map((entry) => entry.handle);
|
|
385
365
|
}
|
|
386
366
|
|
|
387
367
|
_updateAverageBatchTime(batchTime) {
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* read_result — read a stored result by its handle (Phase 2).
|
|
3
|
+
*
|
|
4
|
+
* A tool whose result passed max_inline_chars returned a preview and a
|
|
5
|
+
* result_handle; the whole result is in the ResultStore for an hour. This
|
|
6
|
+
* tool slices, searches, paginates by line or reads a JSON path out of it,
|
|
7
|
+
* so the caller never has to fetch the page again. Extractive only: every
|
|
8
|
+
* operation returns verbatim text with offsets into the same view the
|
|
9
|
+
* preview was cut from (G3).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
import { selectJsonPath } from 'crawlforge-extractors';
|
|
14
|
+
import { getResultStore, RESULT_HANDLE_PATTERN } from '../../core/ResultStore.js';
|
|
15
|
+
import { MAX_INLINE_CHARS_PARAM, resolveMaxInlineChars, resultTextView } from '../../server/inlineThreshold.js';
|
|
16
|
+
import { dualOutput } from '../../server/registerTool.js';
|
|
17
|
+
|
|
18
|
+
const CONTEXT_CHARS = 200;
|
|
19
|
+
const DEFAULT_SLICE_LENGTH = 10000;
|
|
20
|
+
const DEFAULT_LINE_COUNT = 200;
|
|
21
|
+
const MAX_LINE_COUNT = 5000;
|
|
22
|
+
|
|
23
|
+
export const READ_RESULT_INPUT_SHAPE = {
|
|
24
|
+
handle: z.string().regex(RESULT_HANDLE_PATTERN).describe("The result_handle a truncated result returned (res_… or a batch id)"),
|
|
25
|
+
operation: z.enum(['slice', 'search', 'lines', 'json_path']).describe("slice: characters from offset; search: case-insensitive literal query with context and offsets; lines: a page of lines; json_path: one subtree of a JSON result"),
|
|
26
|
+
offset: z.number().int().min(0).optional().describe("slice: first character (default 0); lines: first line index (default 0)"),
|
|
27
|
+
length: z.number().int().min(1).max(200000).optional().describe("slice: characters to return (default 10,000); lines: lines to return (default 200, max 5,000)"),
|
|
28
|
+
query: z.string().min(1).max(500).optional().describe("search: the text to find, matched literally, case-insensitive"),
|
|
29
|
+
max_matches: z.number().int().min(1).max(100).optional().default(20).describe("search: matches to return (default 20)"),
|
|
30
|
+
path: z.string().optional().describe("json_path: dotted keys and array indexes, e.g. \"results[3].content\" — not JSONPath"),
|
|
31
|
+
...MAX_INLINE_CHARS_PARAM
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const fail = (text) => ({ content: [{ type: 'text', text }], isError: true });
|
|
35
|
+
// read_result declares an outputSchema, so a success carries structuredContent too.
|
|
36
|
+
const ok = (object) => dualOutput(object);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {{ handle: string, operation: string, offset?: number, length?: number, query?: string, max_matches?: number, path?: string, max_inline_chars?: number }} params
|
|
40
|
+
*/
|
|
41
|
+
export async function readResultHandler(params) {
|
|
42
|
+
const { handle, operation } = params;
|
|
43
|
+
const entry = getResultStore().get(handle);
|
|
44
|
+
if (!entry) return fail('Unknown or expired result handle (results are kept 1 hour)');
|
|
45
|
+
|
|
46
|
+
const maxChars = resolveMaxInlineChars(params);
|
|
47
|
+
const { view, view_path, text } = resultTextView(entry.payload, entry.meta?.view_path ? [entry.meta.view_path] : []);
|
|
48
|
+
const base = {
|
|
49
|
+
handle,
|
|
50
|
+
tool: entry.toolName,
|
|
51
|
+
operation,
|
|
52
|
+
view,
|
|
53
|
+
view_path,
|
|
54
|
+
total_chars: text.length,
|
|
55
|
+
expires_at: new Date(entry.expiresAt).toISOString()
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
switch (operation) {
|
|
59
|
+
case 'slice': {
|
|
60
|
+
const offset = params.offset ?? 0;
|
|
61
|
+
const length = Math.min(params.length ?? DEFAULT_SLICE_LENGTH, maxChars);
|
|
62
|
+
const slice = text.slice(offset, offset + length);
|
|
63
|
+
return ok({ ...base, offset, length: slice.length, text: slice, has_more: offset + slice.length < text.length });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
case 'search': {
|
|
67
|
+
const { query } = params;
|
|
68
|
+
if (!query) return fail('query is required for operation "search"');
|
|
69
|
+
const maxMatches = params.max_matches ?? 20;
|
|
70
|
+
// A literal (escaped) case-insensitive regex over the ORIGINAL text:
|
|
71
|
+
// offsets must index the verbatim string, and lowercasing a copy can
|
|
72
|
+
// change its length (Turkish İ lowercases to two code units).
|
|
73
|
+
const literal = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
|
74
|
+
const matches = [];
|
|
75
|
+
let total = 0;
|
|
76
|
+
let budget = maxChars;
|
|
77
|
+
for (let m = literal.exec(text); m !== null; m = literal.exec(text)) {
|
|
78
|
+
if (m[0].length === 0) literal.lastIndex++;
|
|
79
|
+
total++;
|
|
80
|
+
if (matches.length >= maxMatches) continue;
|
|
81
|
+
const contextOffset = Math.max(0, m.index - CONTEXT_CHARS);
|
|
82
|
+
const context = text.slice(contextOffset, m.index + m[0].length + CONTEXT_CHARS);
|
|
83
|
+
if (context.length > budget) continue;
|
|
84
|
+
budget -= context.length;
|
|
85
|
+
matches.push({ offset: m.index, length: m[0].length, context_offset: contextOffset, context });
|
|
86
|
+
}
|
|
87
|
+
return ok({ ...base, query, matches, total_matches: total, truncated: total > matches.length });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
case 'lines': {
|
|
91
|
+
const all = text.split('\n');
|
|
92
|
+
const first = params.offset ?? 0;
|
|
93
|
+
const count = Math.min(params.length ?? DEFAULT_LINE_COUNT, MAX_LINE_COUNT);
|
|
94
|
+
let charOffset = 0;
|
|
95
|
+
for (let i = 0; i < Math.min(first, all.length); i++) charOffset += all[i].length + 1;
|
|
96
|
+
const lines = [];
|
|
97
|
+
let chars = 0;
|
|
98
|
+
for (const line of all.slice(first, first + count)) {
|
|
99
|
+
if (chars + line.length + 1 > maxChars && lines.length > 0) break;
|
|
100
|
+
lines.push(line);
|
|
101
|
+
chars += line.length + 1;
|
|
102
|
+
}
|
|
103
|
+
return ok({
|
|
104
|
+
...base,
|
|
105
|
+
first_line: first,
|
|
106
|
+
line_count: lines.length,
|
|
107
|
+
total_lines: all.length,
|
|
108
|
+
char_offset: charOffset,
|
|
109
|
+
lines,
|
|
110
|
+
has_more: first + lines.length < all.length
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
case 'json_path': {
|
|
115
|
+
const { path } = params;
|
|
116
|
+
if (!path) return fail('path is required for operation "json_path"');
|
|
117
|
+
// A text view whose text is itself JSON (a fetch_url body) is read as
|
|
118
|
+
// that JSON; otherwise the stored result object is the subject.
|
|
119
|
+
let subject = entry.payload;
|
|
120
|
+
if (view === 'text') {
|
|
121
|
+
try { subject = JSON.parse(text); } catch { /* not JSON: address the result object */ }
|
|
122
|
+
}
|
|
123
|
+
let value;
|
|
124
|
+
try {
|
|
125
|
+
value = selectJsonPath(subject, path);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
return fail(error.message);
|
|
128
|
+
}
|
|
129
|
+
const valueJson = JSON.stringify(value) ?? 'null';
|
|
130
|
+
if (valueJson.length > maxChars) {
|
|
131
|
+
return ok({
|
|
132
|
+
...base,
|
|
133
|
+
path,
|
|
134
|
+
value: null,
|
|
135
|
+
value_chars: valueJson.length,
|
|
136
|
+
preview: JSON.stringify(value, null, 2).slice(0, maxChars),
|
|
137
|
+
truncated: true,
|
|
138
|
+
warnings: [`Value at "${path}" is ${valueJson.length} chars, over max_inline_chars ${maxChars}; narrow the path to a smaller subtree.`]
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return ok({ ...base, path, value, value_chars: valueJson.length });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
default:
|
|
145
|
+
return fail(`Unknown operation "${operation}"`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* _highlights.js — the pure pieces of `scrape`'s query-scoped formats
|
|
3
|
+
* (Phase 1): the public unit shape, the parser for a model's excerpt
|
|
4
|
+
* choice, and the grounding check a model-mode answer must pass.
|
|
5
|
+
*
|
|
6
|
+
* Nothing here touches the network or a model, so every rule is testable
|
|
7
|
+
* without one.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The unit shape a caller sees. `heading` is a ranking input
|
|
12
|
+
* (crawlforge-extractors counts its terms at half weight), not an output.
|
|
13
|
+
* @param {{ text: string, kind: string, offset: number, length: number, score: number }} unit
|
|
14
|
+
*/
|
|
15
|
+
export function toPublicUnit({ text, kind, offset, length, score }) {
|
|
16
|
+
return { text, kind, offset, length, score };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The candidate indexes a model chose, from a reply such as "3, 0, 7" or
|
|
21
|
+
* "[3] and [7]". Out-of-range and repeated numbers are dropped; at most
|
|
22
|
+
* `limit` survive, in the model's order. Empty when nothing parses, so the
|
|
23
|
+
* caller can fall back to the extractive order.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} reply
|
|
26
|
+
* @param {number} count how many candidates were offered (indexes 0..count-1)
|
|
27
|
+
* @param {number} limit
|
|
28
|
+
* @returns {number[]}
|
|
29
|
+
*/
|
|
30
|
+
export function parseChosenIndexes(reply, count, limit) {
|
|
31
|
+
const chosen = [];
|
|
32
|
+
for (const match of String(reply ?? '').matchAll(/\d+/g)) {
|
|
33
|
+
const index = Number(match[0]);
|
|
34
|
+
if (index < count && !chosen.includes(index)) chosen.push(index);
|
|
35
|
+
if (chosen.length >= limit) break;
|
|
36
|
+
}
|
|
37
|
+
return chosen;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const NUMBER = /\d[\d.,:%]*/g;
|
|
41
|
+
// A capitalised word of two or more letters. Letters only: "Node.js" is
|
|
42
|
+
// "Node" (checked) and "js" (not capitalised).
|
|
43
|
+
const WORD = /\p{L}{2,}/gu;
|
|
44
|
+
// What precedes the first word of a sentence: nothing, or a terminator.
|
|
45
|
+
const SENTENCE_START = /(?:^|[.!?]|\n)[\s"'“‘(\[*_]*$/;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether a model-written answer stays inside its evidence. Every number
|
|
49
|
+
* and every proper noun in `answer` — a capitalised word of at least two
|
|
50
|
+
* letters that is not the first word of a sentence — must appear,
|
|
51
|
+
* case-insensitively, in the evidence or in the question. A price the page
|
|
52
|
+
* never states or a vendor it never names is what "grounded: false" is for.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} answer
|
|
55
|
+
* @param {string} evidence the evidence units' text
|
|
56
|
+
* @param {string} question
|
|
57
|
+
* @returns {{ grounded: boolean, unbacked: string[] }}
|
|
58
|
+
*/
|
|
59
|
+
export function groundingCheck(answer, evidence, question) {
|
|
60
|
+
const text = String(answer ?? '');
|
|
61
|
+
const haystack = `${evidence ?? ''}\n${question ?? ''}`.toLowerCase();
|
|
62
|
+
const unbacked = [];
|
|
63
|
+
const check = (token) => {
|
|
64
|
+
if (!haystack.includes(token.toLowerCase()) && !unbacked.includes(token)) unbacked.push(token);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
for (const match of text.matchAll(NUMBER)) {
|
|
68
|
+
// "$49." at the end of a sentence is the number 49.
|
|
69
|
+
check(match[0].replace(/[.,:]+$/, ''));
|
|
70
|
+
}
|
|
71
|
+
for (const match of text.matchAll(WORD)) {
|
|
72
|
+
const word = match[0];
|
|
73
|
+
if (word[0] !== word[0].toUpperCase() || word[0] === word[0].toLowerCase()) continue;
|
|
74
|
+
if (SENTENCE_START.test(text.slice(0, match.index))) continue;
|
|
75
|
+
check(word);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { grounded: unbacked.length === 0, unbacked };
|
|
79
|
+
}
|
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
* toolOutputSchemas.js separately, so a format added to one copy shipped a
|
|
6
6
|
* schema that did not describe the tool (Phase 0, 0.3). server.js and the
|
|
7
7
|
* output schema import from here; the tool module re-exports.
|
|
8
|
+
*
|
|
9
|
+
* The query-scoped formats (Phase 1) carry their price rule here too, so
|
|
10
|
+
* AuthManager.getToolCost and the tool read the same one.
|
|
8
11
|
*/
|
|
9
12
|
|
|
10
13
|
import { z } from 'zod';
|
|
@@ -17,7 +20,50 @@ export const JsonFormatSchema = z.object({
|
|
|
17
20
|
prompt: z.string().optional().describe('Extraction instruction for the LLM')
|
|
18
21
|
});
|
|
19
22
|
|
|
23
|
+
// "extractive" returns page text verbatim and calls no model; "model" lets a
|
|
24
|
+
// model choose among (highlights) or answer from (question) the extractive
|
|
25
|
+
// units, and is priced separately.
|
|
26
|
+
const QueryModeSchema = z.enum(['extractive', 'model']).optional().default('extractive')
|
|
27
|
+
.describe('"extractive" (default) returns verbatim page text, no model; "model" adds an LLM step (+3 credits)');
|
|
28
|
+
|
|
29
|
+
export const HighlightsFormatSchema = z.object({
|
|
30
|
+
type: z.literal('highlights'),
|
|
31
|
+
query: z.string().min(1).max(500).describe('What to look for; the matching sentences, table rows and code blocks come back verbatim with offsets into the markdown'),
|
|
32
|
+
max_highlights: z.number().int().min(1).max(50).optional().default(10).describe('How many units to return (default 10)'),
|
|
33
|
+
mode: QueryModeSchema
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export const QuestionFormatSchema = z.object({
|
|
37
|
+
type: z.literal('question'),
|
|
38
|
+
question: z.string().min(1).max(500).describe('The question to answer from the page; the evidence units come back verbatim with offsets'),
|
|
39
|
+
mode: QueryModeSchema
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const OBJECT_FORMAT_SCHEMAS = [JsonFormatSchema, HighlightsFormatSchema, QuestionFormatSchema];
|
|
43
|
+
|
|
44
|
+
/** The `type` of each object format, in schema order. */
|
|
45
|
+
export const SCRAPE_OBJECT_FORMATS = OBJECT_FORMAT_SCHEMAS.map((schema) => schema.shape.type.value);
|
|
46
|
+
|
|
20
47
|
export const FormatSchema = z.union([
|
|
21
48
|
z.enum(SCRAPE_STRING_FORMATS),
|
|
22
|
-
|
|
49
|
+
...OBJECT_FORMAT_SCHEMAS
|
|
23
50
|
]);
|
|
51
|
+
|
|
52
|
+
const QUERY_FORMATS = new Set(['highlights', 'question']);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* What the query-scoped formats add to `scrape`'s base price: 1 credit once
|
|
56
|
+
* per call when any `highlights` or `question` format is present, 3 more
|
|
57
|
+
* once when any of them asks for `mode: "model"`. Reads the raw params
|
|
58
|
+
* (getToolCost runs before validation), so anything that is not an array
|
|
59
|
+
* of formats prices as none.
|
|
60
|
+
*
|
|
61
|
+
* @param {unknown} formats
|
|
62
|
+
* @returns {{ query: 0 | 1, model: 0 | 3 }}
|
|
63
|
+
*/
|
|
64
|
+
export function scrapeFormatSurcharge(formats) {
|
|
65
|
+
if (!Array.isArray(formats)) return { query: 0, model: 0 };
|
|
66
|
+
const queryFormats = formats.filter((fmt) => fmt && typeof fmt === 'object' && QUERY_FORMATS.has(fmt.type));
|
|
67
|
+
if (queryFormats.length === 0) return { query: 0, model: 0 };
|
|
68
|
+
return { query: 1, model: queryFormats.some((fmt) => fmt.mode === 'model') ? 3 : 0 };
|
|
69
|
+
}
|
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
* Mirrors the output shape of ScrapeWithActionsTool.generateFormats():
|
|
6
6
|
* content.html, content.rawHtml, content.text, content.markdown,
|
|
7
7
|
* content.links, content.metadata, content.screenshots, content.json
|
|
8
|
+
* plus the query-scoped formats (Phase 1): content.highlights for
|
|
9
|
+
* {type:"highlights"} and content.answer for {type:"question"}, both built
|
|
10
|
+
* from the markdown this same call produces, verbatim with offsets into it.
|
|
8
11
|
*
|
|
9
12
|
* onlyMainContent maps to Readability boilerplate removal (same as extractContent).
|
|
10
13
|
* Partial success: per-format warnings[] never fail the whole call.
|
|
@@ -12,8 +15,14 @@
|
|
|
12
15
|
|
|
13
16
|
import { z } from 'zod';
|
|
14
17
|
import { load } from 'cheerio';
|
|
15
|
-
import { documentVerdict } from 'crawlforge-extractors';
|
|
16
|
-
import {
|
|
18
|
+
import { documentVerdict, segmentUnits, rankUnits } from 'crawlforge-extractors';
|
|
19
|
+
import {
|
|
20
|
+
SCRAPE_STRING_FORMATS, JsonFormatSchema, HighlightsFormatSchema, QuestionFormatSchema, FormatSchema,
|
|
21
|
+
scrapeFormatSurcharge
|
|
22
|
+
} from './formats.js';
|
|
23
|
+
import { toPublicUnit, parseChosenIndexes, groundingCheck } from './_highlights.js';
|
|
24
|
+
import { setActualCost } from '../../server/requestContext.js';
|
|
25
|
+
import { fenceUntrusted } from '../../utils/untrustedContent.js';
|
|
17
26
|
import { fetchAndParse } from '../extract/_fetchAndParse.js';
|
|
18
27
|
import { extractMainContent, isThinMainContent } from './_mainContent.js';
|
|
19
28
|
import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
|
|
@@ -24,7 +33,15 @@ import { noteHostBlocked, clearHostBlocked } from '../../utils/hostRateLimiter.j
|
|
|
24
33
|
|
|
25
34
|
// ── Schema ────────────────────────────────────────────────────────────────────
|
|
26
35
|
|
|
27
|
-
export { SCRAPE_STRING_FORMATS, JsonFormatSchema, FormatSchema };
|
|
36
|
+
export { SCRAPE_STRING_FORMATS, JsonFormatSchema, HighlightsFormatSchema, QuestionFormatSchema, FormatSchema };
|
|
37
|
+
|
|
38
|
+
// Above this, a markdown result is the whole page in the context window;
|
|
39
|
+
// the warning names the format that returns only the matching units (1.4).
|
|
40
|
+
const OVERSIZED_MARKDOWN_CHARS = 40000;
|
|
41
|
+
// How many extractive units a question's answer rests on.
|
|
42
|
+
const QUESTION_EVIDENCE_UNITS = 5;
|
|
43
|
+
|
|
44
|
+
const isQueryFormat = (fmt) => Boolean(fmt) && typeof fmt === 'object' && (fmt.type === 'highlights' || fmt.type === 'question');
|
|
28
45
|
|
|
29
46
|
// The six public fields in tools/list order, each carrying the description
|
|
30
47
|
// the client sees. server.js spreads this with COMPLIANCE_PARAMS instead of
|
|
@@ -177,6 +194,61 @@ export class UnifiedScrapeTool {
|
|
|
177
194
|
// Optional shared ActionExecutor (injected from server.js so we reuse the
|
|
178
195
|
// existing browser pool rather than spinning up a second one).
|
|
179
196
|
this._actionExecutor = options.actionExecutor || null;
|
|
197
|
+
this._mcpServer = null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Wire the MCP server so the mode:"model" step can fall back to client sampling. */
|
|
201
|
+
setMcpServer(mcpServer) {
|
|
202
|
+
this._mcpServer = mcpServer;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* One completion through the SamplingClient chain (Ollama → server keys →
|
|
207
|
+
* MCP sampling); throws when no route exists. Page text must already be
|
|
208
|
+
* fenced by the caller.
|
|
209
|
+
*/
|
|
210
|
+
async _complete(prompt, options) {
|
|
211
|
+
const { SamplingClient } = await import('../../core/SamplingClient.js');
|
|
212
|
+
return new SamplingClient({ mcpServer: this._mcpServer }).complete(prompt, options);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* mode:"model" for highlights: the model picks which of the extractive
|
|
217
|
+
* candidates to keep; it never writes text. Returns the chosen units in
|
|
218
|
+
* extractive order, or the extractive top `limit` when the reply does not
|
|
219
|
+
* name any candidate.
|
|
220
|
+
*/
|
|
221
|
+
async _chooseHighlights(candidates, query, limit, warnings) {
|
|
222
|
+
const listing = candidates.map((unit, index) => `[${index}] ${unit.text}`).join('\n');
|
|
223
|
+
const { text } = await this._complete(
|
|
224
|
+
`${fenceUntrusted(listing, 'numbered page excerpts')}\nQuery: ${query}\n` +
|
|
225
|
+
`Reply with the numbers of the ${limit} excerpts most relevant to the query.`,
|
|
226
|
+
{
|
|
227
|
+
maxTokens: Math.max(64, limit * 4),
|
|
228
|
+
systemPrompt: 'You choose which numbered excerpts from a web page answer a query. ' +
|
|
229
|
+
'Reply with the numbers only, comma-separated, most relevant first. Do not write anything else.'
|
|
230
|
+
}
|
|
231
|
+
);
|
|
232
|
+
const chosen = parseChosenIndexes(text, candidates.length, limit);
|
|
233
|
+
if (chosen.length === 0) {
|
|
234
|
+
warnings.push('highlights: the model named no candidate; the extractive order is returned');
|
|
235
|
+
return candidates.slice(0, limit);
|
|
236
|
+
}
|
|
237
|
+
return chosen.sort((a, b) => a - b).map((index) => candidates[index]);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** mode:"model" for question: an answer written from the fenced evidence alone. */
|
|
241
|
+
async _answerQuestion(evidence, question) {
|
|
242
|
+
const { text } = await this._complete(
|
|
243
|
+
`${fenceUntrusted(evidence.map((unit) => unit.text).join('\n'), 'page evidence')}\nQuestion: ${question}\n` +
|
|
244
|
+
'Answer from the evidence only; if the evidence does not say, say so. Reply with the answer only.',
|
|
245
|
+
{
|
|
246
|
+
maxTokens: 256,
|
|
247
|
+
systemPrompt: 'You answer a question from evidence quoted from a web page. Use only the evidence. ' +
|
|
248
|
+
'If the evidence does not contain the answer, say that it does not. Reply with the answer only, no preamble.'
|
|
249
|
+
}
|
|
250
|
+
);
|
|
251
|
+
return text.trim();
|
|
180
252
|
}
|
|
181
253
|
|
|
182
254
|
/** Lazy-load ExtractWithLlm to avoid pulling in heavy deps unless needed. */
|
|
@@ -293,6 +365,25 @@ export class UnifiedScrapeTool {
|
|
|
293
365
|
return mainHtml;
|
|
294
366
|
}
|
|
295
367
|
|
|
368
|
+
// The markdown format and the query-scoped formats read the same string,
|
|
369
|
+
// produced once, so a highlight's offset always indexes what `markdown`
|
|
370
|
+
// returned at this call's onlyMainContent.
|
|
371
|
+
let markdown = null;
|
|
372
|
+
function getMarkdown() {
|
|
373
|
+
if (markdown === null) {
|
|
374
|
+
markdown = onlyMainContent ? htmlToMarkdown(getMainHtml()) : htmlToMarkdown($.html('body') || html);
|
|
375
|
+
}
|
|
376
|
+
return markdown;
|
|
377
|
+
}
|
|
378
|
+
let units = null;
|
|
379
|
+
function getUnits() {
|
|
380
|
+
if (units === null) units = segmentUnits(getMarkdown());
|
|
381
|
+
return units;
|
|
382
|
+
}
|
|
383
|
+
// Whether a model completed for a mode:"model" format; the model
|
|
384
|
+
// surcharge is charged only then.
|
|
385
|
+
let modelUsed = false;
|
|
386
|
+
|
|
296
387
|
const content = {};
|
|
297
388
|
// The gate's warnings (e.g. a respect_robots override) travel with the
|
|
298
389
|
// per-format ones, so the caller sees the decision in the response.
|
|
@@ -383,13 +474,66 @@ export class UnifiedScrapeTool {
|
|
|
383
474
|
continue;
|
|
384
475
|
}
|
|
385
476
|
|
|
477
|
+
// Query-scoped formats (Phase 1): verbatim units of this call's
|
|
478
|
+
// markdown, ranked against the query. mode:"model" lets a model choose
|
|
479
|
+
// among (highlights) or answer from (question) those units; when no
|
|
480
|
+
// LLM route exists the extractive result stands and the model
|
|
481
|
+
// surcharge is dropped below. An empty match is a warning, not an error.
|
|
482
|
+
if (isQueryFormat(fmt)) {
|
|
483
|
+
const modelUnavailable = (label) =>
|
|
484
|
+
warnings.push(`${label}: mode "model" was unavailable (no Ollama, API key or client sampling); the extractive result is returned at the extractive price`);
|
|
485
|
+
try {
|
|
486
|
+
if (fmt.type === 'highlights') {
|
|
487
|
+
let chosen = rankUnits(getUnits(), fmt.query, { maxUnits: fmt.max_highlights });
|
|
488
|
+
if (fmt.mode === 'model' && chosen.length > 0) {
|
|
489
|
+
const candidates = rankUnits(getUnits(), fmt.query, { maxUnits: fmt.max_highlights * 3 });
|
|
490
|
+
if (candidates.length > fmt.max_highlights) {
|
|
491
|
+
try {
|
|
492
|
+
chosen = await this._chooseHighlights(candidates, fmt.query, fmt.max_highlights, warnings);
|
|
493
|
+
modelUsed = true;
|
|
494
|
+
} catch {
|
|
495
|
+
modelUnavailable('highlights');
|
|
496
|
+
}
|
|
497
|
+
} else {
|
|
498
|
+
warnings.push('highlights: no more candidates than max_highlights, so the model had nothing to choose; charged at the extractive price');
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
content.highlights = chosen.map(toPublicUnit);
|
|
502
|
+
if (chosen.length === 0) warnings.push(`highlights: no sentence, table row or code block matched "${fmt.query}"`);
|
|
503
|
+
} else {
|
|
504
|
+
const evidence = rankUnits(getUnits(), fmt.question, { maxUnits: QUESTION_EVIDENCE_UNITS });
|
|
505
|
+
const evidenceText = evidence.map((unit) => unit.text).join('\n');
|
|
506
|
+
let text = evidenceText;
|
|
507
|
+
let grounded = true;
|
|
508
|
+
if (fmt.mode === 'model' && evidence.length > 0) {
|
|
509
|
+
try {
|
|
510
|
+
text = await this._answerQuestion(evidence, fmt.question);
|
|
511
|
+
modelUsed = true;
|
|
512
|
+
const check = groundingCheck(text, evidenceText, fmt.question);
|
|
513
|
+
grounded = check.grounded;
|
|
514
|
+
if (!grounded) {
|
|
515
|
+
warnings.push(`question: the model's answer has ${check.unbacked.length} token(s) not found in the evidence or the question (${check.unbacked.join(', ')}); grounded: false`);
|
|
516
|
+
}
|
|
517
|
+
} catch {
|
|
518
|
+
modelUnavailable('question');
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
content.answer = { text, grounded, evidence: evidence.map(toPublicUnit) };
|
|
522
|
+
if (evidence.length === 0) warnings.push(`question: no sentence, table row or code block matched "${fmt.question}"`);
|
|
523
|
+
}
|
|
524
|
+
} catch (err) {
|
|
525
|
+
if (fmt.type === 'highlights') content.highlights = [];
|
|
526
|
+
else content.answer = { text: '', grounded: true, evidence: [] };
|
|
527
|
+
warnings.push(`${fmt.type}: ${err.message}`);
|
|
528
|
+
}
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
|
|
386
532
|
// String formats
|
|
387
533
|
switch (fmt) {
|
|
388
534
|
case 'markdown':
|
|
389
535
|
try {
|
|
390
|
-
content.markdown =
|
|
391
|
-
? htmlToMarkdown(getMainHtml())
|
|
392
|
-
: htmlToMarkdown($.html('body') || html);
|
|
536
|
+
content.markdown = getMarkdown();
|
|
393
537
|
} catch (err) {
|
|
394
538
|
content.markdown = '';
|
|
395
539
|
warnings.push(`markdown: ${err.message}`);
|
|
@@ -501,6 +645,19 @@ export class UnifiedScrapeTool {
|
|
|
501
645
|
}
|
|
502
646
|
}
|
|
503
647
|
|
|
648
|
+
const queryScoped = formats.some(isQueryFormat);
|
|
649
|
+
if (queryScoped && !formats.includes('markdown')) {
|
|
650
|
+
warnings.push('offsets index the "markdown" format of this call (same onlyMainContent); add "markdown" to formats to quote with a locator');
|
|
651
|
+
}
|
|
652
|
+
if (!queryScoped && typeof content.markdown === 'string' && content.markdown.length > OVERSIZED_MARKDOWN_CHARS) {
|
|
653
|
+
warnings.push(`markdown: ${content.markdown.length} characters; ask for {type:"highlights", query} (1 extra credit, no model) to get only the matching sentences, table rows and code blocks`);
|
|
654
|
+
}
|
|
655
|
+
// The projection charged the model step; nothing ran, so the charge
|
|
656
|
+
// drops to the extractive price (2 is scrape's base in AuthManager;
|
|
657
|
+
// withAuth clamps to the projection either way).
|
|
658
|
+
const surcharge = scrapeFormatSurcharge(formats);
|
|
659
|
+
if (surcharge.model > 0 && !modelUsed) setActualCost(2 + surcharge.query);
|
|
660
|
+
|
|
504
661
|
return {
|
|
505
662
|
success: true,
|
|
506
663
|
url: finalUrl,
|