crawlforge-mcp-server 5.8.0 → 5.10.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 +1 -1
- package/package.json +2 -2
- package/server.js +55 -20
- package/src/core/AuthManager.js +37 -5
- package/src/schemas/toolOutputSchemas.js +30 -0
- package/src/server/fallbackHints.js +34 -9
- package/src/server/inlineThreshold.js +5 -0
- package/src/server/redaction.js +469 -0
- package/src/server/withAuth.js +42 -2
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +1 -0
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +5 -1
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +5 -0
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +30 -1
- package/src/tools/scrape/escalation.js +62 -0
- package/src/tools/scrape/unifiedScrape.js +191 -58
- package/src/tools/search/batchSearch.js +50 -0
- package/src/tools/search/searchWeb.js +44 -0
- package/src/utils/secretMask.js +11 -7
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* escalation.js — the one declaration of `scrape`'s opt-in auto-escalation.
|
|
3
|
+
*
|
|
4
|
+
* `escalate: true` asks the tool to run the stealth browser itself when the
|
|
5
|
+
* plain fetch comes back walled (Phase 3). The plain fetch always runs first
|
|
6
|
+
* (G1: never start with stealth), so the escalation is a second stage, not a
|
|
7
|
+
* different tool: it reuses the same compliance gate and the same
|
|
8
|
+
* StealthBrowserManager `stealth_mode` uses, and adds no new evasion.
|
|
9
|
+
*
|
|
10
|
+
* The two fields and the price rule live here so `server.js`, the tool
|
|
11
|
+
* module, `AuthManager.getToolCost` and the output schema read one
|
|
12
|
+
* declaration (G5) — the same arrangement `formats.js` already has for the
|
|
13
|
+
* query-format surcharge.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
|
|
18
|
+
/** What an escalated call adds to `scrape`'s price: the stealth browser's own. */
|
|
19
|
+
export const SCRAPE_ESCALATION_CREDITS = 5;
|
|
20
|
+
|
|
21
|
+
/** The two public fields, in tools/list order, with the text the client sees. */
|
|
22
|
+
export const SCRAPE_ESCALATION_SHAPE = {
|
|
23
|
+
escalate: z.boolean().optional().default(false).describe('When the plain fetch comes back blocked (403/429/challenge page/empty shell), retry once in the stealth browser and return its content instead of the block. Projected at 2+5; the actual charge stays at the base price when the plain fetch succeeded. Default: false'),
|
|
24
|
+
escalate_engine: z.enum(['playwright', 'camoufox']).optional().default('playwright').describe('Stealth engine for the escalated retry (default: "playwright")')
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What escalation adds to `scrape`'s base price: 5 when the caller asked for
|
|
29
|
+
* it, nothing otherwise. Reads the RAW param — getToolCost runs before
|
|
30
|
+
* validation, so anything that is not exactly `true` prices as 0.
|
|
31
|
+
*
|
|
32
|
+
* @param {unknown} escalate the raw `escalate` param
|
|
33
|
+
* @returns {0 | 5}
|
|
34
|
+
*/
|
|
35
|
+
export function scrapeEscalationSurcharge(escalate) {
|
|
36
|
+
return escalate === true ? SCRAPE_ESCALATION_CREDITS : 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether a second, browser-backed attempt could plausibly change this
|
|
41
|
+
* outcome.
|
|
42
|
+
*
|
|
43
|
+
* A named vendor's wall qualifies, and so does a bare 403 or 429 — an
|
|
44
|
+
* IP-reputation, WAF or User-Agent block, which a real browser and fingerprint
|
|
45
|
+
* often do pass (travel.state.gov answers some networks with exactly that). So
|
|
46
|
+
* does any failure that arrived with a 2xx: an empty shell, a JavaScript-only
|
|
47
|
+
* document or a soft-error placeholder is precisely what rendering fixes.
|
|
48
|
+
*
|
|
49
|
+
* A 404 is not a wall and a 5xx is the server's own failure. Escalating either
|
|
50
|
+
* would spend the browser's 5 credits to be told the same thing, so `escalate`
|
|
51
|
+
* leaves them as the plain fetch reported them (G4 — never charge for work
|
|
52
|
+
* that cannot help).
|
|
53
|
+
*
|
|
54
|
+
* @param {{ blocked?: unknown } | null | undefined} verdict
|
|
55
|
+
* @param {number | null | undefined} status
|
|
56
|
+
* @returns {boolean}
|
|
57
|
+
*/
|
|
58
|
+
export function aBrowserMightPass(verdict, status) {
|
|
59
|
+
if (verdict && verdict.blocked) return true;
|
|
60
|
+
if (status === 403 || status === 429) return true;
|
|
61
|
+
return status === null || status === undefined || (status >= 200 && status < 300);
|
|
62
|
+
}
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
SCRAPE_STRING_FORMATS, JsonFormatSchema, HighlightsFormatSchema, QuestionFormatSchema, FormatSchema,
|
|
21
21
|
scrapeFormatSurcharge
|
|
22
22
|
} from './formats.js';
|
|
23
|
+
import { SCRAPE_ESCALATION_SHAPE, SCRAPE_ESCALATION_CREDITS, aBrowserMightPass } from './escalation.js';
|
|
23
24
|
import { toPublicUnit, parseChosenIndexes, groundingCheck } from './_highlights.js';
|
|
24
25
|
import { setActualCost } from '../../server/requestContext.js';
|
|
25
26
|
import { fenceUntrusted } from '../../utils/untrustedContent.js';
|
|
@@ -29,11 +30,15 @@ import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
|
|
|
29
30
|
import { stripHiddenFromDom } from '../../utils/hiddenContent.js';
|
|
30
31
|
import { extractBlockText } from '../basic/extractText.js';
|
|
31
32
|
import { pageTitle } from '../../utils/pageTitle.js';
|
|
32
|
-
import { noteHostBlocked, clearHostBlocked } from '../../utils/hostRateLimiter.js';
|
|
33
|
+
import { noteHostBlocked, clearHostBlocked, getHostBlock } from '../../utils/hostRateLimiter.js';
|
|
33
34
|
|
|
34
35
|
// ── Schema ────────────────────────────────────────────────────────────────────
|
|
35
36
|
|
|
36
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;
|
|
37
42
|
|
|
38
43
|
// Above this, a markdown result is the whole page in the context window;
|
|
39
44
|
// the warning names the format that returns only the matching units (1.4).
|
|
@@ -61,7 +66,9 @@ export const SCRAPE_INPUT_SHAPE = {
|
|
|
61
66
|
fullPage: z.boolean().optional().default(false).describe('Capture the full scrollable page'),
|
|
62
67
|
format: z.enum(['png', 'jpeg']).optional().default('png'),
|
|
63
68
|
quality: z.number().min(0).max(100).optional().describe('JPEG quality (jpeg only)')
|
|
64
|
-
}).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
|
|
65
72
|
};
|
|
66
73
|
|
|
67
74
|
export const UnifiedScrapeSchema = z.object({
|
|
@@ -194,6 +201,15 @@ export class UnifiedScrapeTool {
|
|
|
194
201
|
// Optional shared ActionExecutor (injected from server.js so we reuse the
|
|
195
202
|
// existing browser pool rather than spinning up a second one).
|
|
196
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;
|
|
197
213
|
this._mcpServer = null;
|
|
198
214
|
}
|
|
199
215
|
|
|
@@ -278,59 +294,183 @@ export class UnifiedScrapeTool {
|
|
|
278
294
|
const validated = UnifiedScrapeSchema.parse(params);
|
|
279
295
|
const { url, formats, onlyMainContent, timeoutMs, brandingOptions, screenshotOptions, resolveHiddenContent } = validated;
|
|
280
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
|
+
|
|
281
326
|
// Single fetch
|
|
282
|
-
let html, $,
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
+
}
|
|
296
376
|
}
|
|
297
377
|
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
)
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
+
}
|
|
323
447
|
}
|
|
324
|
-
|
|
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();
|
|
325
460
|
return {
|
|
326
461
|
success: false,
|
|
327
462
|
url: finalUrl,
|
|
328
|
-
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 } : {}),
|
|
329
466
|
title,
|
|
330
|
-
error: verdict
|
|
331
|
-
|
|
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,
|
|
332
472
|
content: {},
|
|
333
|
-
warnings:
|
|
473
|
+
warnings: warnings.length > 0 ? warnings : undefined
|
|
334
474
|
};
|
|
335
475
|
}
|
|
336
476
|
|
|
@@ -380,14 +520,7 @@ export class UnifiedScrapeTool {
|
|
|
380
520
|
if (units === null) units = segmentUnits(getMarkdown());
|
|
381
521
|
return units;
|
|
382
522
|
}
|
|
383
|
-
// Whether a model completed for a mode:"model" format; the model
|
|
384
|
-
// surcharge is charged only then.
|
|
385
|
-
let modelUsed = false;
|
|
386
|
-
|
|
387
523
|
const content = {};
|
|
388
|
-
// The gate's warnings (e.g. a respect_robots override) travel with the
|
|
389
|
-
// per-format ones, so the caller sees the decision in the response.
|
|
390
|
-
const warnings = [...fetchWarnings];
|
|
391
524
|
|
|
392
525
|
// Kept for the rawHtml format, which must survive the strip below.
|
|
393
526
|
const pristineHtml = html;
|
|
@@ -652,15 +785,15 @@ export class UnifiedScrapeTool {
|
|
|
652
785
|
if (!queryScoped && typeof content.markdown === 'string' && content.markdown.length > OVERSIZED_MARKDOWN_CHARS) {
|
|
653
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`);
|
|
654
787
|
}
|
|
655
|
-
//
|
|
656
|
-
//
|
|
657
|
-
//
|
|
658
|
-
|
|
659
|
-
if (surcharge.model > 0 && !modelUsed) setActualCost(2 + surcharge.query);
|
|
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();
|
|
660
792
|
|
|
661
793
|
return {
|
|
662
794
|
success: true,
|
|
663
795
|
url: finalUrl,
|
|
796
|
+
...escalationFields,
|
|
664
797
|
content,
|
|
665
798
|
warnings: warnings.length > 0 ? warnings : undefined
|
|
666
799
|
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* batchSearch.js — the one declaration of `search_web`'s `queries` param
|
|
3
|
+
* (Phase 5, 5.1).
|
|
4
|
+
*
|
|
5
|
+
* `search_web` takes one query. A caller comparing five phrasings, or
|
|
6
|
+
* checking a term across five sites, pays five round-trips for what is one
|
|
7
|
+
* unit of work. `queries` accepts 1-10 of them in a single call; each still
|
|
8
|
+
* runs through the SAME `execute()` pipeline — expansion, cache, dedupe,
|
|
9
|
+
* rank — so a batch entry and a single call are the same result.
|
|
10
|
+
*
|
|
11
|
+
* The batch wrapper sits ABOVE the provider short-circuit in `execute()`, so
|
|
12
|
+
* the SearXNG branch (which is not part of the adapter factory) inherits
|
|
13
|
+
* `queries` without a second implementation.
|
|
14
|
+
*
|
|
15
|
+
* The field and the price rule live here so `server.js`, the tool module and
|
|
16
|
+
* `AuthManager.getToolCost` read one declaration (G5) — the same arrangement
|
|
17
|
+
* `escalation.js` has for `scrape`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { z } from 'zod';
|
|
21
|
+
|
|
22
|
+
/** How many queries one call may carry. */
|
|
23
|
+
export const MAX_SEARCH_QUERIES = 10;
|
|
24
|
+
|
|
25
|
+
/** search_web's price for ONE query, as AuthManager's table spells it. */
|
|
26
|
+
export const SEARCH_WEB_CREDITS = 5;
|
|
27
|
+
|
|
28
|
+
/** The public field, with the text the client sees. */
|
|
29
|
+
export const SEARCH_QUERIES_PARAM = {
|
|
30
|
+
queries: z.array(z.string().min(1)).min(1).max(MAX_SEARCH_QUERIES).optional().describe(`Run 1-${MAX_SEARCH_QUERIES} searches in one call instead of ${MAX_SEARCH_QUERIES} round-trips; every other parameter applies to each. Results come back in results_by_query, one entry per query, in order. Costs 5 per query. Use this OR query, not both`)
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Rejected when a call carries both `query` and `queries`, or neither. */
|
|
34
|
+
export const EXACTLY_ONE_QUERY_MESSAGE =
|
|
35
|
+
`search_web needs exactly one of "query" (a single search) or "queries" (1-${MAX_SEARCH_QUERIES} searches in one call), not both and not neither`;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* How many searches this call will run, for pricing. Reads the RAW param —
|
|
39
|
+
* getToolCost runs before validation — so anything that is not a non-empty
|
|
40
|
+
* array prices as the single-query form, and a caller who sends more than the
|
|
41
|
+
* schema allows never produces a silly projection (the schema rejects that
|
|
42
|
+
* call before it is billed at all).
|
|
43
|
+
*
|
|
44
|
+
* @param {unknown} queries the raw `queries` param
|
|
45
|
+
* @returns {number} 1..MAX_SEARCH_QUERIES
|
|
46
|
+
*/
|
|
47
|
+
export function searchQueryCount(queries) {
|
|
48
|
+
if (!Array.isArray(queries) || queries.length === 0) return 1;
|
|
49
|
+
return Math.min(queries.length, MAX_SEARCH_QUERIES);
|
|
50
|
+
}
|
|
@@ -8,6 +8,10 @@ import { SearchResultCache } from './ranking/SearchResultCache.js';
|
|
|
8
8
|
import LocalizationManager from '../../core/LocalizationManager.js';
|
|
9
9
|
import { isCreatorModeVerified } from '../../core/creatorMode.js';
|
|
10
10
|
import { searchViaSearxng } from './providers/searxng.js';
|
|
11
|
+
import { MAX_SEARCH_QUERIES, SEARCH_WEB_CREDITS, EXACTLY_ONE_QUERY_MESSAGE } from './batchSearch.js';
|
|
12
|
+
import { setActualCost } from '../../server/requestContext.js';
|
|
13
|
+
|
|
14
|
+
const BatchQueriesSchema = z.array(z.string().min(1)).min(1).max(MAX_SEARCH_QUERIES);
|
|
11
15
|
|
|
12
16
|
const SearchWebSchema = z.object({
|
|
13
17
|
query: z.string().min(1),
|
|
@@ -126,7 +130,47 @@ export class SearchWebTool {
|
|
|
126
130
|
});
|
|
127
131
|
}
|
|
128
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Run 1-10 queries through the single-query pipeline and key the results by
|
|
135
|
+
* query (5.1). Sequential: each entry is a separate billed backend search,
|
|
136
|
+
* and firing ten at once at the provider buys nothing but rate limits.
|
|
137
|
+
*
|
|
138
|
+
* @param {object} params the call's params, `queries` included
|
|
139
|
+
* @returns {Promise<{queries: string[], count: number, results_by_query: object[]}>}
|
|
140
|
+
*/
|
|
141
|
+
async _executeBatch(params) {
|
|
142
|
+
const { queries, query: _ignored, ...rest } = params;
|
|
143
|
+
const parsed = BatchQueriesSchema.parse(queries);
|
|
144
|
+
|
|
145
|
+
const results_by_query = [];
|
|
146
|
+
let ran = 0;
|
|
147
|
+
for (const query of parsed) {
|
|
148
|
+
try {
|
|
149
|
+
results_by_query.push({ query, ...await this.execute({ ...rest, query }) });
|
|
150
|
+
ran++;
|
|
151
|
+
} catch (error) {
|
|
152
|
+
// One failed query does not sink the other nine.
|
|
153
|
+
results_by_query.push({ query, error: error.message });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// The projection is 5 per query; a query that failed before it reached a
|
|
158
|
+
// backend did no work and must not be billed for one (G4). The projection
|
|
159
|
+
// is the ceiling, so this only ever lowers the charge.
|
|
160
|
+
setActualCost(SEARCH_WEB_CREDITS * ran);
|
|
161
|
+
|
|
162
|
+
return { queries: parsed, count: parsed.length, results_by_query };
|
|
163
|
+
}
|
|
164
|
+
|
|
129
165
|
async execute(params) {
|
|
166
|
+
// Batch form (5.1). Handled above the provider short-circuit below, so
|
|
167
|
+
// the SearXNG branch runs each query through the same path the default
|
|
168
|
+
// provider does.
|
|
169
|
+
const hasQuery = params?.query !== undefined;
|
|
170
|
+
const hasQueries = params?.queries !== undefined;
|
|
171
|
+
if (hasQuery === hasQueries) throw new Error(EXACTLY_ONE_QUERY_MESSAGE);
|
|
172
|
+
if (hasQueries) return await this._executeBatch(params);
|
|
173
|
+
|
|
130
174
|
try {
|
|
131
175
|
const validated = SearchWebSchema.parse(params);
|
|
132
176
|
|
package/src/utils/secretMask.js
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* logger.error('fetch failed', maskSecrets({ apiKey, url, error }));
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { redactPii } from 'crawlforge-extractors';
|
|
13
|
+
|
|
12
14
|
const SECRET_KEYS_RE = /api[_-]?key|apikey|x-api-key|password|passwd|secret|token|authorization|auth|credential|login|formautofill|private[_-]?key|access[_-]?key|proxy_url|proxyurl|cookie/i;
|
|
13
15
|
|
|
14
16
|
const MASK = '[REDACTED]';
|
|
@@ -97,16 +99,18 @@ export function maskError(error) {
|
|
|
97
99
|
|
|
98
100
|
/**
|
|
99
101
|
* Heuristic: redact strings that look like API keys / tokens embedded in text.
|
|
102
|
+
*
|
|
103
|
+
* The patterns moved to crawlforge-extractors' `redactPii` (Phase 5, 5.3), so
|
|
104
|
+
* the two surfaces run one implementation and page text gets the same
|
|
105
|
+
* treatment log lines already got. The SECRET class there is this heuristic,
|
|
106
|
+
* ported verbatim and asserted byte-for-byte against it upstream: the label
|
|
107
|
+
* survives and only the value is replaced, which is what keeps an error
|
|
108
|
+
* message saying WHICH credential the request carried.
|
|
109
|
+
*
|
|
100
110
|
* @param {string} str
|
|
101
111
|
* @returns {string}
|
|
102
112
|
*/
|
|
103
113
|
function redactSecretsFromString(str) {
|
|
104
114
|
if (typeof str !== 'string') return str;
|
|
105
|
-
return str
|
|
106
|
-
.replace(/(Bearer\s+)\S+/gi, `$1${MASK}`)
|
|
107
|
-
.replace(/(api[_-]?key\s*[:=]\s*)\S+/gi, `$1${MASK}`)
|
|
108
|
-
.replace(/(x-api-key\s*[:=]\s*)\S+/gi, `$1${MASK}`)
|
|
109
|
-
.replace(/(password\s*[:=]\s*)\S+/gi, `$1${MASK}`)
|
|
110
|
-
.replace(/(secret\s*[:=]\s*)\S+/gi, `$1${MASK}`)
|
|
111
|
-
.replace(/(token\s*[:=]\s*)\S+/gi, `$1${MASK}`);
|
|
115
|
+
return redactPii(str, { entities: ['SECRET'], replaceStyle: 'mask' }).text;
|
|
112
116
|
}
|