crawlforge-mcp-server 5.6.10 → 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.
@@ -8,9 +8,10 @@ CrawlForge credits.
8
8
  | Param | Type | Default | Notes |
9
9
  |-------|------|---------|-------|
10
10
  | `url` | string (URL) | — | Required. |
11
- | `formats` | array | `["markdown"]` | Any of `markdown`, `html`, `rawHtml`, `text`, `links`, `metadata`, `screenshot`, or `{type:"json", schema?, prompt?}`. |
11
+ | `formats` | array | `["markdown"]` | Any of `markdown`, `html`, `rawHtml`, `text`, `links`, `metadata`, `branding`, `screenshot`, `{type:"json", schema?, prompt?}`, `{type:"highlights", query, max_highlights?, mode?}` (the matching sentences, table rows and code blocks, verbatim, with offsets into the markdown; +1 credit once per call) or `{type:"question", question, mode?}` (an answer assembled from the evidence units; same +1). `mode:"model"` adds 3 credits and needs an LLM route. |
12
12
  | `onlyMainContent` | boolean | `true` | Strip boilerplate via Readability. |
13
13
  | `timeoutMs` | number | `15000` | 1000–60000. |
14
+ | `max_inline_chars` | number | `40000` | 1,000–10,000,000. A larger result returns `preview` + `result_handle` for `read_result` (below). |
14
15
 
15
16
  Partial success: a format that fails produces a `warnings[]` entry rather than
16
17
  failing the whole call. `{type:"json"}` may incur external LLM cost (billed by
@@ -23,6 +24,7 @@ your provider).
23
24
  | `url` | string (URL) | — | Required. |
24
25
  | `headers` | object | — | Custom HTTP headers (e.g. auth tokens). |
25
26
  | `timeout` | number | `10000` | 1000–30000 ms. |
27
+ | `max_inline_chars` | number | `40000` | 1,000–10,000,000. A larger result returns `preview` + `result_handle` for `read_result` (below). |
26
28
 
27
29
  ## extract_text (cost: 1)
28
30
 
@@ -53,6 +55,7 @@ your provider).
53
55
  |-------|------|-------|
54
56
  | `url` | string (URL) | Required. Readability-cleaned article body (markdown). |
55
57
  | `options` | object | Additional extraction options. |
58
+ | `max_inline_chars` | number | Default 40000 (1,000–10,000,000). A larger result returns `preview` + `result_handle` for `read_result` (below). |
56
59
 
57
60
  ## map_site (cost: 2)
58
61
 
@@ -82,10 +85,33 @@ your provider).
82
85
  | `concurrency` | number | — | 1–20 concurrent requests. |
83
86
  | `enable_link_analysis` | boolean | — | Compute PageRank over crawled pages. |
84
87
  | `session` | object | — | Shared cookie jar for login-then-crawl. |
88
+ | `max_inline_chars` | number | `40000` | 1,000–10,000,000. A larger result returns `preview` + `result_handle` for `read_result` (below). |
85
89
 
86
90
  *Server default honors `RESPECT_ROBOTS_TXT`. Crawls projected over ~500 pages
87
91
  trigger an elicitation confirmation.
88
92
 
93
+ ## read_result (cost: 1)
94
+
95
+ Reads a result a tool returned with `truncated: true` and a `result_handle`
96
+ (kept 1 hour, on the local machine; an unknown or expired handle is an error).
97
+
98
+ | Param | Type | Default | Notes |
99
+ |-------|------|---------|-------|
100
+ | `handle` | string | — | Required. The `result_handle` (`res_…`). |
101
+ | `operation` | enum | — | Required. `slice`, `search`, `lines` or `json_path`. |
102
+ | `offset` | number | — | `slice`: character offset; `lines`: first line (default 0). |
103
+ | `length` | number | — | `slice`: characters (default 10,000); `lines`: lines (default 200, at most 5,000). |
104
+ | `query` | string | — | `search`: case-insensitive literal substring. |
105
+ | `max_matches` | number | `20` | `search`: 1–100. |
106
+ | `path` | string | — | `json_path`: path into the stored result object (or the parsed body when the stored text is JSON, e.g. a `fetch_url` body). |
107
+ | `max_inline_chars` | number | `40000` | 1,000–10,000,000. |
108
+
109
+ Every response carries `handle`, `tool`, `operation`, `view` (`text` or `json`),
110
+ `view_path`, `total_chars` and `expires_at`. `slice` returns `text` + `has_more`;
111
+ `search` returns `matches[{offset, length, context_offset, context}]` (200
112
+ characters of context each side) + `total_matches`; `lines` returns `lines[]`
113
+ + `total_lines`, `char_offset`, `has_more`; `json_path` returns `value`.
114
+
89
115
  ## CLI quick map
90
116
 
91
117
  | Tool | CLI |
@@ -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
- maxCachedBatches = 20
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
- this.batchResults = new Map();
58
- this.maxCachedBatches = maxCachedBatches;
59
- this.resultCacheTtl = 3600000; // 1 hour — matches the ttl stored per cache entry
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
- const cached = this.batchResults.get(batchId);
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
- if (Date.now() - cached.timestamp < cached.ttl) {
238
- const offset = (page - 1) * pageSize;
239
- return {
240
- batchId, success: true, status: 'completed', ...(cached.mode ? { mode: cached.mode } : {}),
241
- results: paginateResults(cached.results, offset, pageSize),
242
- pagination: { page, pageSize, totalResults: cached.results.length, totalPages: Math.ceil(cached.results.length / pageSize) },
243
- cached: true, timestamp: cached.timestamp
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.batchResults.size,
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.batchResults.clear();
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.batchResults.set(batchId, { results, mode, timestamp: Date.now(), ttl: this.resultCacheTtl });
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
- _sweepBatchResults() {
381
- const now = Date.now();
382
- for (const [id, entry] of this.batchResults) {
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) {
@@ -6,8 +6,8 @@
6
6
  * extractContent.js (uses native fetch directly but can adopt this)
7
7
  * processDocument.js (URL sources)
8
8
  *
9
- * Returns { html, $, textContent, finalUrl, warnings } so callers don't repeat
10
- * the same fetch/cheerio/cleanup boilerplate.
9
+ * Returns { html, $, textContent, finalUrl, warnings, status } so callers don't
10
+ * repeat the same fetch/cheerio/cleanup boilerplate.
11
11
  */
12
12
 
13
13
  import { load } from 'cheerio';
@@ -124,7 +124,8 @@ export function flattenBodyText($) {
124
124
  * @param {string} [options.apiKey] — hashed into the audit row
125
125
  * @param {number} [options.timeoutMs]
126
126
  * @param {string[]} [options.stripTags] — additional tags to strip (default: script, style, noscript, iframe, svg)
127
- * @returns {Promise<{ html: string, $: import('cheerio').CheerioAPI, textContent: string, finalUrl: string, warnings: string[] }>}
127
+ * @param {boolean} [options.errorDocuments] — return a non-2xx HTML/text body with its status instead of throwing
128
+ * @returns {Promise<{ html: string, $: import('cheerio').CheerioAPI, textContent: string, finalUrl: string, warnings: string[], status: number }>}
128
129
  */
129
130
  export async function fetchAndParse(url, options = {}) {
130
131
  const {
@@ -133,7 +134,8 @@ export async function fetchAndParse(url, options = {}) {
133
134
  tool,
134
135
  apiKey,
135
136
  timeoutMs = DEFAULT_TIMEOUT_MS,
136
- stripTags = ['script', 'style', 'noscript', 'iframe', 'svg']
137
+ stripTags = ['script', 'style', 'noscript', 'iframe', 'svg'],
138
+ errorDocuments = false
137
139
  } = options;
138
140
 
139
141
  // robots.txt / blocklist gate + per-host throttle. Throws if it refuses.
@@ -148,17 +150,24 @@ export async function fetchAndParse(url, options = {}) {
148
150
  signal: AbortSignal.timeout(timeoutMs)
149
151
  });
150
152
 
153
+ const contentType = response.headers?.get?.('content-type') || null;
154
+ const classification = classifyContentType(contentType);
155
+
151
156
  if (!response.ok) {
152
157
  // Honour a back-off the host asked for before giving up on this request.
153
158
  if (response.status === 429 || response.status === 503) {
154
159
  noteRetryAfter(url, response.headers?.get?.('retry-after'));
155
160
  }
156
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
161
+ // A real challenge page reaches a plain fetch as a 403 with the wall in
162
+ // the body (producthunt.com, travel.state.gov, edmunds.com, 2026-09-05).
163
+ // scrape opts in to read that body so its verdict can name the vendor
164
+ // (Phase 0, 0.1); every other caller keeps the throw, and a binary error
165
+ // body still throws.
166
+ if (!errorDocuments || classification === 'binary') {
167
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
168
+ }
157
169
  }
158
170
 
159
- const contentType = response.headers?.get?.('content-type') || null;
160
- const classification = classifyContentType(contentType);
161
-
162
171
  if (classification === 'binary') {
163
172
  throw new Error(
164
173
  `Unsupported content type "${contentType}" — this looks like binary content, not HTML/text. Use process_document for PDFs/documents/binary files.`
@@ -171,7 +180,7 @@ export async function fetchAndParse(url, options = {}) {
171
180
  // HTML parser risks misinterpreting substrings (e.g. a "<script>" value
172
181
  // inside a JSON string) as real tags and stripping/mangling content.
173
182
  if (classification === 'text') {
174
- return { html, $: load(''), textContent: html.trim(), finalUrl: response.url, warnings };
183
+ return { html, $: load(''), textContent: html.trim(), finalUrl: response.url, warnings, status: response.status };
175
184
  }
176
185
 
177
186
  const $ = load(html);
@@ -182,5 +191,5 @@ export async function fetchAndParse(url, options = {}) {
182
191
 
183
192
  const textContent = flattenBodyText($);
184
193
 
185
- return { html, $, textContent, finalUrl: response.url, warnings };
194
+ return { html, $, textContent, finalUrl: response.url, warnings, status: response.status };
186
195
  }
@@ -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
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * formats.js — the one declaration of what `scrape` can return.
3
+ *
4
+ * The formats enum used to be spelled in server.js, unifiedScrape.js and
5
+ * toolOutputSchemas.js separately, so a format added to one copy shipped a
6
+ * schema that did not describe the tool (Phase 0, 0.3). server.js and the
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.
11
+ */
12
+
13
+ import { z } from 'zod';
14
+
15
+ export const SCRAPE_STRING_FORMATS = ['markdown', 'html', 'rawHtml', 'text', 'links', 'metadata', 'screenshot', 'branding'];
16
+
17
+ export const JsonFormatSchema = z.object({
18
+ type: z.literal('json'),
19
+ schema: z.record(z.any()).optional().describe('JSON schema for extraction'),
20
+ prompt: z.string().optional().describe('Extraction instruction for the LLM')
21
+ });
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
+
47
+ export const FormatSchema = z.union([
48
+ z.enum(SCRAPE_STRING_FORMATS),
49
+ ...OBJECT_FORMAT_SCHEMAS
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
+ }