crawlforge-mcp-server 5.6.11 → 5.9.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 +59 -18
- package/src/core/AuthManager.js +16 -1
- package/src/core/ResultStore.js +230 -0
- package/src/schemas/toolOutputSchemas.js +89 -13
- package/src/server/fallbackHints.js +35 -9
- 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 +6 -4
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +8 -1
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +5 -0
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +73 -2
- 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/escalation.js +62 -0
- package/src/tools/scrape/formats.js +47 -1
- package/src/tools/scrape/unifiedScrape.js +345 -55
|
@@ -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,19 +15,38 @@
|
|
|
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 { SCRAPE_ESCALATION_SHAPE, SCRAPE_ESCALATION_CREDITS, aBrowserMightPass } from './escalation.js';
|
|
24
|
+
import { toPublicUnit, parseChosenIndexes, groundingCheck } from './_highlights.js';
|
|
25
|
+
import { setActualCost } from '../../server/requestContext.js';
|
|
26
|
+
import { fenceUntrusted } from '../../utils/untrustedContent.js';
|
|
17
27
|
import { fetchAndParse } from '../extract/_fetchAndParse.js';
|
|
18
28
|
import { extractMainContent, isThinMainContent } from './_mainContent.js';
|
|
19
29
|
import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
|
|
20
30
|
import { stripHiddenFromDom } from '../../utils/hiddenContent.js';
|
|
21
31
|
import { extractBlockText } from '../basic/extractText.js';
|
|
22
32
|
import { pageTitle } from '../../utils/pageTitle.js';
|
|
23
|
-
import { noteHostBlocked, clearHostBlocked } from '../../utils/hostRateLimiter.js';
|
|
33
|
+
import { noteHostBlocked, clearHostBlocked, getHostBlock } from '../../utils/hostRateLimiter.js';
|
|
24
34
|
|
|
25
35
|
// ── Schema ────────────────────────────────────────────────────────────────────
|
|
26
36
|
|
|
27
|
-
export { SCRAPE_STRING_FORMATS, JsonFormatSchema, FormatSchema };
|
|
37
|
+
export { SCRAPE_STRING_FORMATS, JsonFormatSchema, HighlightsFormatSchema, QuestionFormatSchema, FormatSchema };
|
|
38
|
+
export { SCRAPE_ESCALATION_SHAPE, SCRAPE_ESCALATION_CREDITS };
|
|
39
|
+
|
|
40
|
+
/** scrape's base price, as AuthManager's table spells it. */
|
|
41
|
+
const SCRAPE_BASE_CREDITS = 2;
|
|
42
|
+
|
|
43
|
+
// Above this, a markdown result is the whole page in the context window;
|
|
44
|
+
// the warning names the format that returns only the matching units (1.4).
|
|
45
|
+
const OVERSIZED_MARKDOWN_CHARS = 40000;
|
|
46
|
+
// How many extractive units a question's answer rests on.
|
|
47
|
+
const QUESTION_EVIDENCE_UNITS = 5;
|
|
48
|
+
|
|
49
|
+
const isQueryFormat = (fmt) => Boolean(fmt) && typeof fmt === 'object' && (fmt.type === 'highlights' || fmt.type === 'question');
|
|
28
50
|
|
|
29
51
|
// The six public fields in tools/list order, each carrying the description
|
|
30
52
|
// the client sees. server.js spreads this with COMPLIANCE_PARAMS instead of
|
|
@@ -44,7 +66,9 @@ export const SCRAPE_INPUT_SHAPE = {
|
|
|
44
66
|
fullPage: z.boolean().optional().default(false).describe('Capture the full scrollable page'),
|
|
45
67
|
format: z.enum(['png', 'jpeg']).optional().default('png'),
|
|
46
68
|
quality: z.number().min(0).max(100).optional().describe('JPEG quality (jpeg only)')
|
|
47
|
-
}).optional().describe('Options for the "screenshot" format')
|
|
69
|
+
}).optional().describe('Options for the "screenshot" format'),
|
|
70
|
+
// Opt-in second stage (Phase 3): the plain fetch still runs first.
|
|
71
|
+
...SCRAPE_ESCALATION_SHAPE
|
|
48
72
|
};
|
|
49
73
|
|
|
50
74
|
export const UnifiedScrapeSchema = z.object({
|
|
@@ -177,6 +201,70 @@ export class UnifiedScrapeTool {
|
|
|
177
201
|
// Optional shared ActionExecutor (injected from server.js so we reuse the
|
|
178
202
|
// existing browser pool rather than spinning up a second one).
|
|
179
203
|
this._actionExecutor = options.actionExecutor || null;
|
|
204
|
+
// Optional escalation stage (Phase 3), injected from server.js the same
|
|
205
|
+
// way. The tool module must never import StealthBrowserManager itself:
|
|
206
|
+
// that would pull a browser dependency into every unit test that loads
|
|
207
|
+
// `scrape`. Signature:
|
|
208
|
+
// ({ url, engine, respectRobots }) =>
|
|
209
|
+
// { html, url, title, text, status, engine, warnings? }
|
|
210
|
+
// The injected function owns the compliance gate (robots + blocklist) and
|
|
211
|
+
// the engine-name mapping, exactly as the stealth_mode tool does.
|
|
212
|
+
this._escalateScrape = options.escalateScrape || null;
|
|
213
|
+
this._mcpServer = null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Wire the MCP server so the mode:"model" step can fall back to client sampling. */
|
|
217
|
+
setMcpServer(mcpServer) {
|
|
218
|
+
this._mcpServer = mcpServer;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* One completion through the SamplingClient chain (Ollama → server keys →
|
|
223
|
+
* MCP sampling); throws when no route exists. Page text must already be
|
|
224
|
+
* fenced by the caller.
|
|
225
|
+
*/
|
|
226
|
+
async _complete(prompt, options) {
|
|
227
|
+
const { SamplingClient } = await import('../../core/SamplingClient.js');
|
|
228
|
+
return new SamplingClient({ mcpServer: this._mcpServer }).complete(prompt, options);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* mode:"model" for highlights: the model picks which of the extractive
|
|
233
|
+
* candidates to keep; it never writes text. Returns the chosen units in
|
|
234
|
+
* extractive order, or the extractive top `limit` when the reply does not
|
|
235
|
+
* name any candidate.
|
|
236
|
+
*/
|
|
237
|
+
async _chooseHighlights(candidates, query, limit, warnings) {
|
|
238
|
+
const listing = candidates.map((unit, index) => `[${index}] ${unit.text}`).join('\n');
|
|
239
|
+
const { text } = await this._complete(
|
|
240
|
+
`${fenceUntrusted(listing, 'numbered page excerpts')}\nQuery: ${query}\n` +
|
|
241
|
+
`Reply with the numbers of the ${limit} excerpts most relevant to the query.`,
|
|
242
|
+
{
|
|
243
|
+
maxTokens: Math.max(64, limit * 4),
|
|
244
|
+
systemPrompt: 'You choose which numbered excerpts from a web page answer a query. ' +
|
|
245
|
+
'Reply with the numbers only, comma-separated, most relevant first. Do not write anything else.'
|
|
246
|
+
}
|
|
247
|
+
);
|
|
248
|
+
const chosen = parseChosenIndexes(text, candidates.length, limit);
|
|
249
|
+
if (chosen.length === 0) {
|
|
250
|
+
warnings.push('highlights: the model named no candidate; the extractive order is returned');
|
|
251
|
+
return candidates.slice(0, limit);
|
|
252
|
+
}
|
|
253
|
+
return chosen.sort((a, b) => a - b).map((index) => candidates[index]);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** mode:"model" for question: an answer written from the fenced evidence alone. */
|
|
257
|
+
async _answerQuestion(evidence, question) {
|
|
258
|
+
const { text } = await this._complete(
|
|
259
|
+
`${fenceUntrusted(evidence.map((unit) => unit.text).join('\n'), 'page evidence')}\nQuestion: ${question}\n` +
|
|
260
|
+
'Answer from the evidence only; if the evidence does not say, say so. Reply with the answer only.',
|
|
261
|
+
{
|
|
262
|
+
maxTokens: 256,
|
|
263
|
+
systemPrompt: 'You answer a question from evidence quoted from a web page. Use only the evidence. ' +
|
|
264
|
+
'If the evidence does not contain the answer, say that it does not. Reply with the answer only, no preamble.'
|
|
265
|
+
}
|
|
266
|
+
);
|
|
267
|
+
return text.trim();
|
|
180
268
|
}
|
|
181
269
|
|
|
182
270
|
/** Lazy-load ExtractWithLlm to avoid pulling in heavy deps unless needed. */
|
|
@@ -206,59 +294,183 @@ export class UnifiedScrapeTool {
|
|
|
206
294
|
const validated = UnifiedScrapeSchema.parse(params);
|
|
207
295
|
const { url, formats, onlyMainContent, timeoutMs, brandingOptions, screenshotOptions, resolveHiddenContent } = validated;
|
|
208
296
|
|
|
297
|
+
// The gate's warnings (e.g. a respect_robots override) travel with the
|
|
298
|
+
// per-format ones, so the caller sees the decision in the response.
|
|
299
|
+
const warnings = [];
|
|
300
|
+
// Whether a model completed for a mode:"model" format, and whether the
|
|
301
|
+
// stealth stage ran; the surcharge for each is charged only then.
|
|
302
|
+
let modelUsed = false;
|
|
303
|
+
let escalationRan = false;
|
|
304
|
+
let stealthEngine = null;
|
|
305
|
+
|
|
306
|
+
// One charge computation for every return path (3.2). The projection is
|
|
307
|
+
// the ceiling a caller saw before the call, so each stage that did not
|
|
308
|
+
// run only ever lowers the bill (G4): the model surcharge when nothing
|
|
309
|
+
// completed, the escalation surcharge when the plain fetch sufficed.
|
|
310
|
+
const surcharge = scrapeFormatSurcharge(formats);
|
|
311
|
+
const reportCost = () => setActualCost(
|
|
312
|
+
SCRAPE_BASE_CREDITS +
|
|
313
|
+
surcharge.query +
|
|
314
|
+
(modelUsed ? surcharge.model : 0) +
|
|
315
|
+
(escalationRan ? SCRAPE_ESCALATION_CREDITS : 0)
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
// Opt-in second stage (Phase 3): the plain fetch runs first and the
|
|
319
|
+
// stealth browser only follows a blocked verdict.
|
|
320
|
+
const escalate = validated.escalate === true;
|
|
321
|
+
// 3.3: a host that walled us within the last 24 hours (0.6's memory) has
|
|
322
|
+
// a doomed plain fetch ahead of it. Read ONLY when escalating — an
|
|
323
|
+
// ordinary scrape must behave exactly as it did before.
|
|
324
|
+
const remembered = escalate ? getHostBlock(url) : null;
|
|
325
|
+
|
|
209
326
|
// Single fetch
|
|
210
|
-
let html, $,
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
327
|
+
let html, $, fetchWarnings, status, title, verdict;
|
|
328
|
+
let finalUrl = url;
|
|
329
|
+
if (remembered) {
|
|
330
|
+
warnings.push(
|
|
331
|
+
`escalate: ${remembered.vendor || 'a bot wall'} walled this host within the last 24 hours; the plain fetch was skipped and the stealth browser ran first`
|
|
332
|
+
);
|
|
333
|
+
} else {
|
|
334
|
+
try {
|
|
335
|
+
({ html, $, finalUrl, warnings: fetchWarnings, status } = await fetchAndParse(url, {
|
|
336
|
+
timeoutMs,
|
|
337
|
+
userAgent: validated.user_agent,
|
|
338
|
+
respectRobots: validated.respect_robots,
|
|
339
|
+
tool: 'scrape',
|
|
340
|
+
stripTags: [], // we handle boilerplate ourselves
|
|
341
|
+
// A real wall reaches a plain fetch as a 403 with the challenge in
|
|
342
|
+
// the body; the verdict below needs that body and the status.
|
|
343
|
+
errorDocuments: true
|
|
344
|
+
}));
|
|
345
|
+
} catch (err) {
|
|
346
|
+
throw new Error(`scrape: fetch failed for ${url}: ${err.message}`);
|
|
347
|
+
}
|
|
348
|
+
warnings.push(...fetchWarnings);
|
|
349
|
+
|
|
350
|
+
// A bot wall, an HTTP error page, an empty shell or an error placeholder
|
|
351
|
+
// arrives with a title and prose of its own; reported as a successful
|
|
352
|
+
// scrape it hid the block for three rounds (producthunt.com, R10 Q1 →
|
|
353
|
+
// R15). The stealth path has named these since 5.6.2; this path never
|
|
354
|
+
// looked, and threw away every non-2xx body before it could (Phase 0,
|
|
355
|
+
// 0.1). Scripts stay in $ for the rawHtml and metadata formats,
|
|
356
|
+
// so the text is measured on a copy without them — the vendor tables
|
|
357
|
+
// count the characters a reader would see, as the browser path does.
|
|
358
|
+
// Nothing below runs on a failed verdict: a screenshot would launch a
|
|
359
|
+
// browser on the wall and branding would fetch its stylesheets.
|
|
360
|
+
title = pageTitle($);
|
|
361
|
+
const $visible = load(html);
|
|
362
|
+
$visible('script, style, noscript, template').remove();
|
|
363
|
+
verdict = documentVerdict(
|
|
364
|
+
{ url: finalUrl, title, text: $visible('body').text(), html, status },
|
|
365
|
+
{ fetcher: 'a plain fetch', rendered: false, contentReturned: false }
|
|
366
|
+
);
|
|
367
|
+
// Host memory (0.6): remember the vendor that walled this host, keyed by
|
|
368
|
+
// the URL the caller passed as well as the final one when a redirect
|
|
369
|
+
// moved hosts, since a later call looks it up by what it was given.
|
|
370
|
+
const hosts = [finalUrl, url];
|
|
371
|
+
if (verdict.blocked) {
|
|
372
|
+
for (const h of hosts) noteHostBlocked(h, verdict.blocked.vendor);
|
|
373
|
+
} else if (verdict.success) {
|
|
374
|
+
for (const h of hosts) clearHostBlocked(h);
|
|
375
|
+
}
|
|
224
376
|
}
|
|
225
377
|
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
)
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
378
|
+
// ── Escalation (Phase 3) ─────────────────────────────────────────────
|
|
379
|
+
// Opt-in, and second by construction: the plain fetch above has already
|
|
380
|
+
// run and only a blocked verdict gets here (G1 — never start with
|
|
381
|
+
// stealth). The stage reuses the compliance gate and the browser
|
|
382
|
+
// `stealth_mode` already drives, so it adds no evasion of its own.
|
|
383
|
+
// The vendor the plain fetch hit, or the one this host is remembered for
|
|
384
|
+
// when that fetch was skipped.
|
|
385
|
+
const vendorDetected = remembered ? (remembered.vendor ?? null) : (verdict?.blocked?.vendor ?? null);
|
|
386
|
+
// Set when the stage was asked for and could not run at all; it is the
|
|
387
|
+
// only thing left to report when the plain fetch was skipped as well.
|
|
388
|
+
let escalationError = null;
|
|
389
|
+
|
|
390
|
+
// Only a failure a browser could plausibly change is worth the second
|
|
391
|
+
// stage: a wall or a bare 403/429, or a page that failed with a 2xx (an
|
|
392
|
+
// empty shell renders). A 404 or a 5xx escalates nothing — see
|
|
393
|
+
// aBrowserMightPass.
|
|
394
|
+
if (escalate && (remembered || (!verdict.success && aBrowserMightPass(verdict, status)))) {
|
|
395
|
+
if (!this._escalateScrape) {
|
|
396
|
+
escalationError = 'no stealth stage is wired into this server build';
|
|
397
|
+
warnings.push(`escalate: ${escalationError}`);
|
|
398
|
+
} else {
|
|
399
|
+
try {
|
|
400
|
+
const stealth = await this._escalateScrape({
|
|
401
|
+
url: remembered ? url : finalUrl,
|
|
402
|
+
engine: validated.escalate_engine,
|
|
403
|
+
respectRobots: validated.respect_robots
|
|
404
|
+
});
|
|
405
|
+
escalationRan = true;
|
|
406
|
+
stealthEngine = stealth.engine || validated.escalate_engine;
|
|
407
|
+
if (Array.isArray(stealth.warnings)) warnings.push(...stealth.warnings);
|
|
408
|
+
|
|
409
|
+
// The stealth render replaces the document every format below is
|
|
410
|
+
// built from, so an escalated page goes through the SAME formats
|
|
411
|
+
// loop a plain fetch does — highlights and question included.
|
|
412
|
+
html = stealth.html || '';
|
|
413
|
+
finalUrl = stealth.url || finalUrl;
|
|
414
|
+
status = stealth.status ?? null;
|
|
415
|
+
$ = load(html);
|
|
416
|
+
title = stealth.title || pageTitle($);
|
|
417
|
+
const $rendered = load(html);
|
|
418
|
+
$rendered('script, style, noscript, template').remove();
|
|
419
|
+
// Re-run the verdict on what the browser rendered: a wall that
|
|
420
|
+
// survives the stealth pass must not come back as a success.
|
|
421
|
+
verdict = documentVerdict(
|
|
422
|
+
{ url: finalUrl, title, text: stealth.text || $rendered('body').text(), html, status },
|
|
423
|
+
{ waitedMs: stealth.gracedMs || 0, fetcher: 'the stealth browser', rendered: true, contentReturned: false }
|
|
424
|
+
);
|
|
425
|
+
// Escalation fires on ANY failed verdict, not only a named vendor —
|
|
426
|
+
// an empty shell and an error placeholder are exactly the cases a
|
|
427
|
+
// browser fixes — so the wording must not call every one a block.
|
|
428
|
+
warnings.push(
|
|
429
|
+
`escalate: the plain fetch ${vendorDetected ? `was blocked by ${vendorDetected}` : 'did not return the page'}; ` +
|
|
430
|
+
`the ${stealthEngine} stealth browser ${verdict.success ? 'returned it' : 'did not get it either'}`
|
|
431
|
+
);
|
|
432
|
+
// The host memory is deliberately left alone here. It records what
|
|
433
|
+
// a PLAIN fetch met, which is what 3.3 reads before deciding to
|
|
434
|
+
// skip one; a stealth success says nothing about that, and only a
|
|
435
|
+
// clean plain fetch clears the entry (0.6).
|
|
436
|
+
} catch (err) {
|
|
437
|
+
// The gate refuses a disallowed URL by throwing, after stamping
|
|
438
|
+
// markPreflightRefusal('ROBOTS_DISALLOWED') on the request context.
|
|
439
|
+
// That flag makes withAuth bill the WHOLE call zero — plain fetch
|
|
440
|
+
// included — which is the intended outcome: we never rendered the
|
|
441
|
+
// page we were refused. Caught here so a refusal is a warning on
|
|
442
|
+
// the verdict we already have rather than a throw out of the tool.
|
|
443
|
+
escalationError = err.message;
|
|
444
|
+
warnings.push(`escalate: the stealth retry did not run — ${escalationError}`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
251
447
|
}
|
|
252
|
-
|
|
448
|
+
|
|
449
|
+
// `escalated` is reported only to a caller who asked to escalate: a call
|
|
450
|
+
// that never asked keeps exactly the result shape it had before.
|
|
451
|
+
const escalationFields = escalate
|
|
452
|
+
? {
|
|
453
|
+
escalated: escalationRan,
|
|
454
|
+
...(escalationRan ? { stealth: { engine: stealthEngine, vendor_detected: vendorDetected } } : {})
|
|
455
|
+
}
|
|
456
|
+
: {};
|
|
457
|
+
|
|
458
|
+
if (!verdict || !verdict.success) {
|
|
459
|
+
reportCost();
|
|
253
460
|
return {
|
|
254
461
|
success: false,
|
|
255
462
|
url: finalUrl,
|
|
256
|
-
status,
|
|
463
|
+
// A stealth render reports no status when nothing navigated, and a
|
|
464
|
+
// skipped plain fetch reports none at all.
|
|
465
|
+
...(typeof status === 'number' ? { status } : {}),
|
|
257
466
|
title,
|
|
258
|
-
error: verdict
|
|
259
|
-
|
|
467
|
+
error: verdict
|
|
468
|
+
? verdict.error
|
|
469
|
+
: `The plain fetch was skipped because ${vendorDetected || 'a bot wall'} walled this host within the last 24 hours, and the stealth retry did not run: ${escalationError}`,
|
|
470
|
+
...(verdict?.blocked ? { blocked: verdict.blocked } : {}),
|
|
471
|
+
...escalationFields,
|
|
260
472
|
content: {},
|
|
261
|
-
warnings:
|
|
473
|
+
warnings: warnings.length > 0 ? warnings : undefined
|
|
262
474
|
};
|
|
263
475
|
}
|
|
264
476
|
|
|
@@ -293,10 +505,22 @@ export class UnifiedScrapeTool {
|
|
|
293
505
|
return mainHtml;
|
|
294
506
|
}
|
|
295
507
|
|
|
508
|
+
// The markdown format and the query-scoped formats read the same string,
|
|
509
|
+
// produced once, so a highlight's offset always indexes what `markdown`
|
|
510
|
+
// returned at this call's onlyMainContent.
|
|
511
|
+
let markdown = null;
|
|
512
|
+
function getMarkdown() {
|
|
513
|
+
if (markdown === null) {
|
|
514
|
+
markdown = onlyMainContent ? htmlToMarkdown(getMainHtml()) : htmlToMarkdown($.html('body') || html);
|
|
515
|
+
}
|
|
516
|
+
return markdown;
|
|
517
|
+
}
|
|
518
|
+
let units = null;
|
|
519
|
+
function getUnits() {
|
|
520
|
+
if (units === null) units = segmentUnits(getMarkdown());
|
|
521
|
+
return units;
|
|
522
|
+
}
|
|
296
523
|
const content = {};
|
|
297
|
-
// The gate's warnings (e.g. a respect_robots override) travel with the
|
|
298
|
-
// per-format ones, so the caller sees the decision in the response.
|
|
299
|
-
const warnings = [...fetchWarnings];
|
|
300
524
|
|
|
301
525
|
// Kept for the rawHtml format, which must survive the strip below.
|
|
302
526
|
const pristineHtml = html;
|
|
@@ -383,13 +607,66 @@ export class UnifiedScrapeTool {
|
|
|
383
607
|
continue;
|
|
384
608
|
}
|
|
385
609
|
|
|
610
|
+
// Query-scoped formats (Phase 1): verbatim units of this call's
|
|
611
|
+
// markdown, ranked against the query. mode:"model" lets a model choose
|
|
612
|
+
// among (highlights) or answer from (question) those units; when no
|
|
613
|
+
// LLM route exists the extractive result stands and the model
|
|
614
|
+
// surcharge is dropped below. An empty match is a warning, not an error.
|
|
615
|
+
if (isQueryFormat(fmt)) {
|
|
616
|
+
const modelUnavailable = (label) =>
|
|
617
|
+
warnings.push(`${label}: mode "model" was unavailable (no Ollama, API key or client sampling); the extractive result is returned at the extractive price`);
|
|
618
|
+
try {
|
|
619
|
+
if (fmt.type === 'highlights') {
|
|
620
|
+
let chosen = rankUnits(getUnits(), fmt.query, { maxUnits: fmt.max_highlights });
|
|
621
|
+
if (fmt.mode === 'model' && chosen.length > 0) {
|
|
622
|
+
const candidates = rankUnits(getUnits(), fmt.query, { maxUnits: fmt.max_highlights * 3 });
|
|
623
|
+
if (candidates.length > fmt.max_highlights) {
|
|
624
|
+
try {
|
|
625
|
+
chosen = await this._chooseHighlights(candidates, fmt.query, fmt.max_highlights, warnings);
|
|
626
|
+
modelUsed = true;
|
|
627
|
+
} catch {
|
|
628
|
+
modelUnavailable('highlights');
|
|
629
|
+
}
|
|
630
|
+
} else {
|
|
631
|
+
warnings.push('highlights: no more candidates than max_highlights, so the model had nothing to choose; charged at the extractive price');
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
content.highlights = chosen.map(toPublicUnit);
|
|
635
|
+
if (chosen.length === 0) warnings.push(`highlights: no sentence, table row or code block matched "${fmt.query}"`);
|
|
636
|
+
} else {
|
|
637
|
+
const evidence = rankUnits(getUnits(), fmt.question, { maxUnits: QUESTION_EVIDENCE_UNITS });
|
|
638
|
+
const evidenceText = evidence.map((unit) => unit.text).join('\n');
|
|
639
|
+
let text = evidenceText;
|
|
640
|
+
let grounded = true;
|
|
641
|
+
if (fmt.mode === 'model' && evidence.length > 0) {
|
|
642
|
+
try {
|
|
643
|
+
text = await this._answerQuestion(evidence, fmt.question);
|
|
644
|
+
modelUsed = true;
|
|
645
|
+
const check = groundingCheck(text, evidenceText, fmt.question);
|
|
646
|
+
grounded = check.grounded;
|
|
647
|
+
if (!grounded) {
|
|
648
|
+
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`);
|
|
649
|
+
}
|
|
650
|
+
} catch {
|
|
651
|
+
modelUnavailable('question');
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
content.answer = { text, grounded, evidence: evidence.map(toPublicUnit) };
|
|
655
|
+
if (evidence.length === 0) warnings.push(`question: no sentence, table row or code block matched "${fmt.question}"`);
|
|
656
|
+
}
|
|
657
|
+
} catch (err) {
|
|
658
|
+
if (fmt.type === 'highlights') content.highlights = [];
|
|
659
|
+
else content.answer = { text: '', grounded: true, evidence: [] };
|
|
660
|
+
warnings.push(`${fmt.type}: ${err.message}`);
|
|
661
|
+
}
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
|
|
386
665
|
// String formats
|
|
387
666
|
switch (fmt) {
|
|
388
667
|
case 'markdown':
|
|
389
668
|
try {
|
|
390
|
-
content.markdown =
|
|
391
|
-
? htmlToMarkdown(getMainHtml())
|
|
392
|
-
: htmlToMarkdown($.html('body') || html);
|
|
669
|
+
content.markdown = getMarkdown();
|
|
393
670
|
} catch (err) {
|
|
394
671
|
content.markdown = '';
|
|
395
672
|
warnings.push(`markdown: ${err.message}`);
|
|
@@ -501,9 +778,22 @@ export class UnifiedScrapeTool {
|
|
|
501
778
|
}
|
|
502
779
|
}
|
|
503
780
|
|
|
781
|
+
const queryScoped = formats.some(isQueryFormat);
|
|
782
|
+
if (queryScoped && !formats.includes('markdown')) {
|
|
783
|
+
warnings.push('offsets index the "markdown" format of this call (same onlyMainContent); add "markdown" to formats to quote with a locator');
|
|
784
|
+
}
|
|
785
|
+
if (!queryScoped && typeof content.markdown === 'string' && content.markdown.length > OVERSIZED_MARKDOWN_CHARS) {
|
|
786
|
+
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`);
|
|
787
|
+
}
|
|
788
|
+
// What this call actually spent: the model step and the escalation each
|
|
789
|
+
// drop out when they did not run (withAuth clamps to the projection
|
|
790
|
+
// either way, so this can only lower the charge).
|
|
791
|
+
reportCost();
|
|
792
|
+
|
|
504
793
|
return {
|
|
505
794
|
success: true,
|
|
506
795
|
url: finalUrl,
|
|
796
|
+
...escalationFields,
|
|
507
797
|
content,
|
|
508
798
|
warnings: warnings.length > 0 ? warnings : undefined
|
|
509
799
|
};
|