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.
@@ -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, $, finalUrl, fetchWarnings, status;
283
- try {
284
- ({ html, $, finalUrl, warnings: fetchWarnings, status } = await fetchAndParse(url, {
285
- timeoutMs,
286
- userAgent: validated.user_agent,
287
- respectRobots: validated.respect_robots,
288
- tool: 'scrape',
289
- stripTags: [], // we handle boilerplate ourselves
290
- // A real wall reaches a plain fetch as a 403 with the challenge in
291
- // the body; the verdict below needs that body and the status.
292
- errorDocuments: true
293
- }));
294
- } catch (err) {
295
- throw new Error(`scrape: fetch failed for ${url}: ${err.message}`);
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
- // A bot wall, an HTTP error page, an empty shell or an error placeholder
299
- // arrives with a title and prose of its own; reported as a successful
300
- // scrape it hid the block for three rounds (producthunt.com, R10 Q1 →
301
- // R15). The stealth path has named these since 5.6.2; this path never
302
- // looked, and threw away every non-2xx body before it could (Phase 0,
303
- // 0.1). Scripts stay in $ for the rawHtml and metadata formats,
304
- // so the text is measured on a copy without them — the vendor tables
305
- // count the characters a reader would see, as the browser path does.
306
- // Nothing below runs on a failed verdict: a screenshot would launch a
307
- // browser on the wall and branding would fetch its stylesheets.
308
- const title = pageTitle($);
309
- const $visible = load(html);
310
- $visible('script, style, noscript, template').remove();
311
- const verdict = documentVerdict(
312
- { url: finalUrl, title, text: $visible('body').text(), html, status },
313
- { fetcher: 'a plain fetch', rendered: false, contentReturned: false }
314
- );
315
- // Host memory (0.6): remember the vendor that walled this host, keyed by
316
- // the URL the caller passed as well as the final one when a redirect
317
- // moved hosts, since a later call looks it up by what it was given.
318
- const hosts = [finalUrl, url];
319
- if (verdict.blocked) {
320
- for (const h of hosts) noteHostBlocked(h, verdict.blocked.vendor);
321
- } else if (verdict.success) {
322
- for (const h of hosts) clearHostBlocked(h);
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
- if (!verdict.success) {
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.error,
331
- ...(verdict.blocked ? { blocked: verdict.blocked } : {}),
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: fetchWarnings.length > 0 ? fetchWarnings : undefined
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
- // 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);
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
 
@@ -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
  }