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.
@@ -5,57 +5,76 @@
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.
11
14
  */
12
15
 
13
16
  import { z } from 'zod';
17
+ import { load } from 'cheerio';
18
+ import { documentVerdict, segmentUnits, rankUnits } from 'crawlforge-extractors';
19
+ import {
20
+ SCRAPE_STRING_FORMATS, JsonFormatSchema, HighlightsFormatSchema, QuestionFormatSchema, FormatSchema,
21
+ scrapeFormatSurcharge
22
+ } from './formats.js';
23
+ import { toPublicUnit, parseChosenIndexes, groundingCheck } from './_highlights.js';
24
+ import { setActualCost } from '../../server/requestContext.js';
25
+ import { fenceUntrusted } from '../../utils/untrustedContent.js';
14
26
  import { fetchAndParse } from '../extract/_fetchAndParse.js';
15
27
  import { extractMainContent, isThinMainContent } from './_mainContent.js';
16
28
  import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
17
29
  import { stripHiddenFromDom } from '../../utils/hiddenContent.js';
18
30
  import { extractBlockText } from '../basic/extractText.js';
19
31
  import { pageTitle } from '../../utils/pageTitle.js';
32
+ import { noteHostBlocked, clearHostBlocked } from '../../utils/hostRateLimiter.js';
20
33
 
21
34
  // ── Schema ────────────────────────────────────────────────────────────────────
22
35
 
23
- const JsonFormatSchema = z.object({
24
- type: z.literal('json'),
25
- schema: z.record(z.any()).optional(),
26
- prompt: z.string().optional()
27
- });
36
+ export { SCRAPE_STRING_FORMATS, JsonFormatSchema, HighlightsFormatSchema, QuestionFormatSchema, FormatSchema };
37
+
38
+ // Above this, a markdown result is the whole page in the context window;
39
+ // the warning names the format that returns only the matching units (1.4).
40
+ const OVERSIZED_MARKDOWN_CHARS = 40000;
41
+ // How many extractive units a question's answer rests on.
42
+ const QUESTION_EVIDENCE_UNITS = 5;
43
+
44
+ const isQueryFormat = (fmt) => Boolean(fmt) && typeof fmt === 'object' && (fmt.type === 'highlights' || fmt.type === 'question');
28
45
 
29
- const FormatSchema = z.union([
30
- z.enum(['markdown', 'html', 'rawHtml', 'text', 'links', 'metadata', 'screenshot', 'branding']),
31
- JsonFormatSchema
32
- ]);
46
+ // The six public fields in tools/list order, each carrying the description
47
+ // the client sees. server.js spreads this with COMPLIANCE_PARAMS instead of
48
+ // keeping its own copy (Phase 0, 0.3).
49
+ export const SCRAPE_INPUT_SHAPE = {
50
+ url: z.string().url().describe('The URL to scrape'),
51
+ formats: z.array(FormatSchema).min(1).optional().default(['markdown']).describe('Formats to return (default: ["markdown"])'),
52
+ onlyMainContent: z.boolean().optional().default(true).describe('Strip boilerplate via Readability (default: true)'),
53
+ // Pass-through to fetchAndParse
54
+ timeoutMs: z.number().min(1000).max(60000).optional().default(15000).describe('Fetch timeout in ms'),
55
+ // Optional, additive: only consulted when 'branding' / 'screenshot' is requested.
56
+ brandingOptions: z.object({
57
+ fetchLinkedCss: z.boolean().optional().default(true).describe('Fetch linked stylesheets for richer color/font extraction'),
58
+ maxStylesheets: z.number().min(0).max(20).optional().default(10).describe('Max linked stylesheets to fetch')
59
+ }).optional().describe('Options for the "branding" format'),
60
+ screenshotOptions: z.object({
61
+ fullPage: z.boolean().optional().default(false).describe('Capture the full scrollable page'),
62
+ format: z.enum(['png', 'jpeg']).optional().default('png'),
63
+ quality: z.number().min(0).max(100).optional().describe('JPEG quality (jpeg only)')
64
+ }).optional().describe('Options for the "screenshot" format')
65
+ };
33
66
 
34
67
  export const UnifiedScrapeSchema = z.object({
35
- url: z.string().url(),
36
- formats: z.array(FormatSchema).min(1).default(['markdown']),
37
- onlyMainContent: z.boolean().optional().default(true),
68
+ ...SCRAPE_INPUT_SHAPE,
38
69
  // Remove content a browser would not paint (screen-reader-only labels,
39
70
  // state-gated theme badges) before deriving any format. "linked" also fetches
40
71
  // the page's stylesheets, which is what resolves class-driven display:none;
41
72
  // "inline" uses only the document's own <style> blocks and costs no requests.
42
73
  resolveHiddenContent: z.enum(['linked', 'inline', 'off']).optional().default('linked'),
43
- // Pass-through to fetchAndParse
44
- timeoutMs: z.number().min(1000).max(60000).optional().default(15000),
45
74
  // Compliance overrides, per request: identify as yourself for a target you
46
75
  // have your own agreement with, and take responsibility for ignoring robots.
47
76
  user_agent: z.string().optional(),
48
- respect_robots: z.boolean().optional(),
49
- // Optional, additive: only consulted when 'branding' / 'screenshot' is requested.
50
- brandingOptions: z.object({
51
- fetchLinkedCss: z.boolean().optional().default(true),
52
- maxStylesheets: z.number().min(0).max(20).optional().default(10)
53
- }).optional(),
54
- screenshotOptions: z.object({
55
- fullPage: z.boolean().optional().default(false),
56
- format: z.enum(['png', 'jpeg']).optional().default('png'),
57
- quality: z.number().min(0).max(100).optional()
58
- }).optional()
77
+ respect_robots: z.boolean().optional()
59
78
  });
60
79
 
61
80
  // ── Helpers ───────────────────────────────────────────────────────────────────
@@ -175,6 +194,61 @@ export class UnifiedScrapeTool {
175
194
  // Optional shared ActionExecutor (injected from server.js so we reuse the
176
195
  // existing browser pool rather than spinning up a second one).
177
196
  this._actionExecutor = options.actionExecutor || null;
197
+ this._mcpServer = null;
198
+ }
199
+
200
+ /** Wire the MCP server so the mode:"model" step can fall back to client sampling. */
201
+ setMcpServer(mcpServer) {
202
+ this._mcpServer = mcpServer;
203
+ }
204
+
205
+ /**
206
+ * One completion through the SamplingClient chain (Ollama → server keys →
207
+ * MCP sampling); throws when no route exists. Page text must already be
208
+ * fenced by the caller.
209
+ */
210
+ async _complete(prompt, options) {
211
+ const { SamplingClient } = await import('../../core/SamplingClient.js');
212
+ return new SamplingClient({ mcpServer: this._mcpServer }).complete(prompt, options);
213
+ }
214
+
215
+ /**
216
+ * mode:"model" for highlights: the model picks which of the extractive
217
+ * candidates to keep; it never writes text. Returns the chosen units in
218
+ * extractive order, or the extractive top `limit` when the reply does not
219
+ * name any candidate.
220
+ */
221
+ async _chooseHighlights(candidates, query, limit, warnings) {
222
+ const listing = candidates.map((unit, index) => `[${index}] ${unit.text}`).join('\n');
223
+ const { text } = await this._complete(
224
+ `${fenceUntrusted(listing, 'numbered page excerpts')}\nQuery: ${query}\n` +
225
+ `Reply with the numbers of the ${limit} excerpts most relevant to the query.`,
226
+ {
227
+ maxTokens: Math.max(64, limit * 4),
228
+ systemPrompt: 'You choose which numbered excerpts from a web page answer a query. ' +
229
+ 'Reply with the numbers only, comma-separated, most relevant first. Do not write anything else.'
230
+ }
231
+ );
232
+ const chosen = parseChosenIndexes(text, candidates.length, limit);
233
+ if (chosen.length === 0) {
234
+ warnings.push('highlights: the model named no candidate; the extractive order is returned');
235
+ return candidates.slice(0, limit);
236
+ }
237
+ return chosen.sort((a, b) => a - b).map((index) => candidates[index]);
238
+ }
239
+
240
+ /** mode:"model" for question: an answer written from the fenced evidence alone. */
241
+ async _answerQuestion(evidence, question) {
242
+ const { text } = await this._complete(
243
+ `${fenceUntrusted(evidence.map((unit) => unit.text).join('\n'), 'page evidence')}\nQuestion: ${question}\n` +
244
+ 'Answer from the evidence only; if the evidence does not say, say so. Reply with the answer only.',
245
+ {
246
+ maxTokens: 256,
247
+ systemPrompt: 'You answer a question from evidence quoted from a web page. Use only the evidence. ' +
248
+ 'If the evidence does not contain the answer, say that it does not. Reply with the answer only, no preamble.'
249
+ }
250
+ );
251
+ return text.trim();
178
252
  }
179
253
 
180
254
  /** Lazy-load ExtractWithLlm to avoid pulling in heavy deps unless needed. */
@@ -205,19 +279,61 @@ export class UnifiedScrapeTool {
205
279
  const { url, formats, onlyMainContent, timeoutMs, brandingOptions, screenshotOptions, resolveHiddenContent } = validated;
206
280
 
207
281
  // Single fetch
208
- let html, $, finalUrl, fetchWarnings;
282
+ let html, $, finalUrl, fetchWarnings, status;
209
283
  try {
210
- ({ html, $, finalUrl, warnings: fetchWarnings } = await fetchAndParse(url, {
284
+ ({ html, $, finalUrl, warnings: fetchWarnings, status } = await fetchAndParse(url, {
211
285
  timeoutMs,
212
286
  userAgent: validated.user_agent,
213
287
  respectRobots: validated.respect_robots,
214
288
  tool: 'scrape',
215
- stripTags: [] // we handle boilerplate ourselves
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
216
293
  }));
217
294
  } catch (err) {
218
295
  throw new Error(`scrape: fetch failed for ${url}: ${err.message}`);
219
296
  }
220
297
 
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);
323
+ }
324
+ if (!verdict.success) {
325
+ return {
326
+ success: false,
327
+ url: finalUrl,
328
+ status,
329
+ title,
330
+ error: verdict.error,
331
+ ...(verdict.blocked ? { blocked: verdict.blocked } : {}),
332
+ content: {},
333
+ warnings: fetchWarnings.length > 0 ? fetchWarnings : undefined
334
+ };
335
+ }
336
+
221
337
  // Resolve <base href> once per document (if present) so link resolution
222
338
  // matches how a browser would navigate, instead of always using finalUrl.
223
339
  let docBaseUrl = finalUrl;
@@ -249,6 +365,25 @@ export class UnifiedScrapeTool {
249
365
  return mainHtml;
250
366
  }
251
367
 
368
+ // The markdown format and the query-scoped formats read the same string,
369
+ // produced once, so a highlight's offset always indexes what `markdown`
370
+ // returned at this call's onlyMainContent.
371
+ let markdown = null;
372
+ function getMarkdown() {
373
+ if (markdown === null) {
374
+ markdown = onlyMainContent ? htmlToMarkdown(getMainHtml()) : htmlToMarkdown($.html('body') || html);
375
+ }
376
+ return markdown;
377
+ }
378
+ let units = null;
379
+ function getUnits() {
380
+ if (units === null) units = segmentUnits(getMarkdown());
381
+ return units;
382
+ }
383
+ // Whether a model completed for a mode:"model" format; the model
384
+ // surcharge is charged only then.
385
+ let modelUsed = false;
386
+
252
387
  const content = {};
253
388
  // The gate's warnings (e.g. a respect_robots override) travel with the
254
389
  // per-format ones, so the caller sees the decision in the response.
@@ -339,13 +474,66 @@ export class UnifiedScrapeTool {
339
474
  continue;
340
475
  }
341
476
 
477
+ // Query-scoped formats (Phase 1): verbatim units of this call's
478
+ // markdown, ranked against the query. mode:"model" lets a model choose
479
+ // among (highlights) or answer from (question) those units; when no
480
+ // LLM route exists the extractive result stands and the model
481
+ // surcharge is dropped below. An empty match is a warning, not an error.
482
+ if (isQueryFormat(fmt)) {
483
+ const modelUnavailable = (label) =>
484
+ warnings.push(`${label}: mode "model" was unavailable (no Ollama, API key or client sampling); the extractive result is returned at the extractive price`);
485
+ try {
486
+ if (fmt.type === 'highlights') {
487
+ let chosen = rankUnits(getUnits(), fmt.query, { maxUnits: fmt.max_highlights });
488
+ if (fmt.mode === 'model' && chosen.length > 0) {
489
+ const candidates = rankUnits(getUnits(), fmt.query, { maxUnits: fmt.max_highlights * 3 });
490
+ if (candidates.length > fmt.max_highlights) {
491
+ try {
492
+ chosen = await this._chooseHighlights(candidates, fmt.query, fmt.max_highlights, warnings);
493
+ modelUsed = true;
494
+ } catch {
495
+ modelUnavailable('highlights');
496
+ }
497
+ } else {
498
+ warnings.push('highlights: no more candidates than max_highlights, so the model had nothing to choose; charged at the extractive price');
499
+ }
500
+ }
501
+ content.highlights = chosen.map(toPublicUnit);
502
+ if (chosen.length === 0) warnings.push(`highlights: no sentence, table row or code block matched "${fmt.query}"`);
503
+ } else {
504
+ const evidence = rankUnits(getUnits(), fmt.question, { maxUnits: QUESTION_EVIDENCE_UNITS });
505
+ const evidenceText = evidence.map((unit) => unit.text).join('\n');
506
+ let text = evidenceText;
507
+ let grounded = true;
508
+ if (fmt.mode === 'model' && evidence.length > 0) {
509
+ try {
510
+ text = await this._answerQuestion(evidence, fmt.question);
511
+ modelUsed = true;
512
+ const check = groundingCheck(text, evidenceText, fmt.question);
513
+ grounded = check.grounded;
514
+ if (!grounded) {
515
+ warnings.push(`question: the model's answer has ${check.unbacked.length} token(s) not found in the evidence or the question (${check.unbacked.join(', ')}); grounded: false`);
516
+ }
517
+ } catch {
518
+ modelUnavailable('question');
519
+ }
520
+ }
521
+ content.answer = { text, grounded, evidence: evidence.map(toPublicUnit) };
522
+ if (evidence.length === 0) warnings.push(`question: no sentence, table row or code block matched "${fmt.question}"`);
523
+ }
524
+ } catch (err) {
525
+ if (fmt.type === 'highlights') content.highlights = [];
526
+ else content.answer = { text: '', grounded: true, evidence: [] };
527
+ warnings.push(`${fmt.type}: ${err.message}`);
528
+ }
529
+ continue;
530
+ }
531
+
342
532
  // String formats
343
533
  switch (fmt) {
344
534
  case 'markdown':
345
535
  try {
346
- content.markdown = onlyMainContent
347
- ? htmlToMarkdown(getMainHtml())
348
- : htmlToMarkdown($.html('body') || html);
536
+ content.markdown = getMarkdown();
349
537
  } catch (err) {
350
538
  content.markdown = '';
351
539
  warnings.push(`markdown: ${err.message}`);
@@ -457,6 +645,19 @@ export class UnifiedScrapeTool {
457
645
  }
458
646
  }
459
647
 
648
+ const queryScoped = formats.some(isQueryFormat);
649
+ if (queryScoped && !formats.includes('markdown')) {
650
+ warnings.push('offsets index the "markdown" format of this call (same onlyMainContent); add "markdown" to formats to quote with a locator');
651
+ }
652
+ if (!queryScoped && typeof content.markdown === 'string' && content.markdown.length > OVERSIZED_MARKDOWN_CHARS) {
653
+ warnings.push(`markdown: ${content.markdown.length} characters; ask for {type:"highlights", query} (1 extra credit, no model) to get only the matching sentences, table rows and code blocks`);
654
+ }
655
+ // The projection charged the model step; nothing ran, so the charge
656
+ // drops to the extractive price (2 is scrape's base in AuthManager;
657
+ // withAuth clamps to the projection either way).
658
+ const surcharge = scrapeFormatSurcharge(formats);
659
+ if (surcharge.model > 0 && !modelUsed) setActualCost(2 + surcharge.query);
660
+
460
661
  return {
461
662
  success: true,
462
663
  url: finalUrl,
@@ -1,79 +1,10 @@
1
1
  /**
2
- * challengeDetection.js — recognise a bot-wall interstitial served as a page.
2
+ * challengeDetection.js — re-export of the challenge-vendor tables.
3
3
  *
4
- * Cloudflare, Amazon, DataDome, PerimeterX and Akamai all answer a blocked
5
- * request with HTTP 200 and a page of their own: a title, some prose and a
6
- * challenge script. Reported as a successful scrape, that page hides the
7
- * block — producthunt.com came back "success:true, title: Just a moment..."
8
- * for three regression rounds (R10 Q1 → R15, 2026-09-04). A title match is
9
- * definitive; a script or form marker is definitive only on a short page,
10
- * because a real page can legitimately embed a Turnstile widget.
4
+ * The tables moved to crawlforge-extractors 1.7.0 so the REST `scrape` route
5
+ * reaches the same verdict as the MCP server (Phase 0, 0.1). Callers keep
6
+ * importing from here.
11
7
  */
12
8
 
13
- const SHORT_PAGE_CHARS = 4000;
14
-
15
- const CHALLENGES = [
16
- {
17
- vendor: 'cloudflare',
18
- title: /^just a moment/i,
19
- markers: /challenges\.cloudflare\.com|cf-chl-|_cf_chl_opt|cf_chl_rc_|window\._cf_chl/i,
20
- evidence: 'a Cloudflare challenge script'
21
- },
22
- {
23
- vendor: 'amazon',
24
- // The robot check is the only Amazon page whose form posts to validateCaptcha.
25
- definitive: /action="[^"]*validateCaptcha/i,
26
- evidence: 'the validateCaptcha form'
27
- },
28
- {
29
- vendor: 'datadome',
30
- markers: /captcha-delivery\.com\/captcha|geo\.captcha-delivery\.com|dd\.captcha/i,
31
- evidence: 'a DataDome captcha frame'
32
- },
33
- {
34
- vendor: 'perimeterx',
35
- markers: /px-captcha|_pxCaptcha|human-challenge/i,
36
- evidence: 'a PerimeterX / HUMAN challenge element'
37
- },
38
- {
39
- vendor: 'akamai',
40
- title: /^access denied/i,
41
- markers: /errors\.edgesuite\.net/i,
42
- evidence: 'an Akamai access-denied page'
43
- },
44
- {
45
- // Vercel's Attack Challenge Mode answers with HTTP 429, an
46
- // x-vercel-mitigated: challenge header and a JavaScript interstitial
47
- // titled "Vercel Security Checkpoint" (lesswrong.com, hashicorp.com,
48
- // bombas.com, R17 2026-09-04). Chromium solves it and reloads; camoufox
49
- // was left on the interstitial, which then read as a successful scrape.
50
- vendor: 'vercel',
51
- title: /^vercel security checkpoint/i,
52
- markers: /vercel\.link\/security-checkpoint|_vercel\/challenge|x-vercel-challenge-token/i,
53
- evidence: 'a Vercel Security Checkpoint page'
54
- }
55
- ];
56
-
57
- /**
58
- * @param {{ title?: string, html?: string, text?: string }} page
59
- * @returns {{ vendor: string, evidence: string } | null}
60
- */
61
- export function detectChallengePage({ title = '', html = '', text = '' } = {}) {
62
- const cleanTitle = (title || '').trim();
63
- const visible = (text || '').replace(/\s+/g, ' ').trim();
64
- const shortPage = visible.length < SHORT_PAGE_CHARS;
65
- for (const challenge of CHALLENGES) {
66
- if (challenge.title && challenge.title.test(cleanTitle)) {
67
- return { vendor: challenge.vendor, evidence: `title "${cleanTitle}"` };
68
- }
69
- if (challenge.definitive && challenge.definitive.test(html)) {
70
- return { vendor: challenge.vendor, evidence: challenge.evidence };
71
- }
72
- if (shortPage && challenge.markers && challenge.markers.test(html)) {
73
- return { vendor: challenge.vendor, evidence: `${challenge.evidence} on a ${visible.length}-character page` };
74
- }
75
- }
76
- return null;
77
- }
78
-
79
- export default detectChallengePage;
9
+ export { detectChallengePage } from 'crawlforge-extractors';
10
+ export { detectChallengePage as default } from 'crawlforge-extractors';
@@ -18,6 +18,11 @@
18
18
  * request to that host waits instead of retrying straight into the wall.
19
19
  * Both are host-scoped and survive RATE_LIMIT_PER_DOMAIN=false: an operator
20
20
  * turning off our own throttle is not a licence to ignore the site's.
21
+ *
22
+ * The same per-host state remembers the bot-defence vendor that last walled
23
+ * the host (Phase 0, 0.6). The scrape verdict writes it; Phase 3 reads it
24
+ * before fetching. In-memory only, bounded to HOST_STATE_MAX hosts with the
25
+ * least recently touched dropped first.
21
26
  */
22
27
  import { RateLimiter } from './rateLimiter.js';
23
28
  import { config } from '../constants/config.js';
@@ -34,7 +39,10 @@ function limiter() {
34
39
  return _limiter;
35
40
  }
36
41
 
37
- /** host → { lastRequestAt, notBefore } */
42
+ /** Hosts remembered at once; past this the least recently touched is dropped. */
43
+ export const HOST_STATE_MAX = 1000;
44
+
45
+ /** host → { lastRequestAt, notBefore, blockedUntil, vendor }, least recently touched first */
38
46
  const hostState = new Map();
39
47
 
40
48
  function hostOf(url) {
@@ -47,10 +55,14 @@ function hostOf(url) {
47
55
 
48
56
  function stateFor(host) {
49
57
  let state = hostState.get(host);
50
- if (!state) {
51
- state = { lastRequestAt: 0, notBefore: 0 };
52
- hostState.set(host, state);
58
+ if (state) {
59
+ // Re-insert so Map order stays least recently touched first.
60
+ hostState.delete(host);
61
+ } else {
62
+ state = { lastRequestAt: 0, notBefore: 0, blockedUntil: 0, vendor: null };
63
+ if (hostState.size >= HOST_STATE_MAX) hostState.delete(hostState.keys().next().value);
53
64
  }
65
+ hostState.set(host, state);
54
66
  return state;
55
67
  }
56
68
 
@@ -105,6 +117,44 @@ export function getHostBackoffMs(url) {
105
117
  return state ? Math.max(0, state.notBefore - Date.now()) : 0;
106
118
  }
107
119
 
120
+ /**
121
+ * Remember that this URL's host answered with a bot wall. Nothing reads it
122
+ * yet — Phase 3 will, before fetching.
123
+ * @param {string} url
124
+ * @param {string} vendor
125
+ * @param {{ ttlMs?: number, now?: number }} [options] `now` for deterministic tests
126
+ */
127
+ export function noteHostBlocked(url, vendor, { ttlMs = 24 * 60 * 60 * 1000, now = Date.now() } = {}) {
128
+ const host = hostOf(url);
129
+ if (!host) return;
130
+ const state = stateFor(host);
131
+ state.blockedUntil = now + ttlMs;
132
+ state.vendor = vendor;
133
+ }
134
+
135
+ /** Forget a block after a clean fetch of the same host. */
136
+ export function clearHostBlocked(url) {
137
+ const host = hostOf(url);
138
+ const state = host ? hostState.get(host) : null;
139
+ if (!state) return;
140
+ state.blockedUntil = 0;
141
+ state.vendor = null;
142
+ }
143
+
144
+ /**
145
+ * The remembered block for this URL's host, or null when there is none or
146
+ * it has expired.
147
+ * @param {string} url
148
+ * @param {number} [now] epoch ms, for deterministic tests
149
+ * @returns {{ vendor: string, blockedUntil: number } | null}
150
+ */
151
+ export function getHostBlock(url, now = Date.now()) {
152
+ const host = hostOf(url);
153
+ const state = host ? hostState.get(host) : null;
154
+ if (!state || state.blockedUntil <= now) return null;
155
+ return { vendor: state.vendor, blockedUntil: state.blockedUntil };
156
+ }
157
+
108
158
  /**
109
159
  * Wait (if necessary) until another request to this URL's host is allowed.
110
160
  * Never throws — a limiter failure must not block a legitimate fetch.
@@ -1,16 +1,23 @@
1
1
  /**
2
2
  * secretMask -- redact sensitive values from objects/strings before they reach logs.
3
3
  *
4
+ * Masks by key name (SECRET_KEYS_RE) and, structurally, the text a
5
+ * scrape_with_actions type/fill/press step sends to the page.
6
+ *
4
7
  * Usage:
5
8
  * import { maskSecrets, maskString } from './secretMask.js';
6
9
  * logger.error('fetch failed', maskSecrets({ apiKey, url, error }));
7
10
  */
8
11
 
9
- const SECRET_KEYS_RE = /api[_-]?key|apikey|x-api-key|password|passwd|secret|token|authorization|auth|credential|private[_-]?key|access[_-]?key|proxy_url|proxyurl|cookie/i;
12
+ 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;
10
13
 
11
14
  const MASK = '[REDACTED]';
12
15
  const PARTIAL_MASK_LEN = 4; // show last N chars of long secrets
13
16
 
17
+ // scrape_with_actions: these action types send the text/value they carry to the page.
18
+ const TYPED_ACTION_TYPES = new Set(['type', 'fill', 'press']);
19
+ const TYPED_ACTION_FIELDS = ['text', 'value'];
20
+
14
21
  /**
15
22
  * Mask a single string value.
16
23
  * Shows last 4 chars if string is long enough to give context, else full mask.
@@ -23,6 +30,23 @@ export function maskString(value) {
23
30
  return `${MASK}...${value.slice(-PARTIAL_MASK_LEN)}`;
24
31
  }
25
32
 
33
+ /**
34
+ * Typed text hides under neutral keys (actions[].text), so key matching misses it.
35
+ * Fully masked: the last-4 tail that tells API keys apart would only leak a password.
36
+ * Selectors and key names stay. Returns a copy; never mutates.
37
+ * @param {*} action
38
+ * @returns {*}
39
+ */
40
+ function maskTypedAction(action) {
41
+ if (action === null || typeof action !== 'object' || Array.isArray(action)) return action;
42
+ if (typeof action.type !== 'string' || !TYPED_ACTION_TYPES.has(action.type.toLowerCase())) return action;
43
+ const result = { ...action };
44
+ for (const field of TYPED_ACTION_FIELDS) {
45
+ if (field in result) result[field] = MASK;
46
+ }
47
+ return result;
48
+ }
49
+
26
50
  /**
27
51
  * Deep-clone obj and redact any key whose name matches SECRET_KEYS_RE.
28
52
  * Handles plain objects, arrays, and primitive values.
@@ -43,6 +67,8 @@ export function maskSecrets(obj, depth = 0) {
43
67
  for (const [key, value] of Object.entries(obj)) {
44
68
  if (SECRET_KEYS_RE.test(key)) {
45
69
  result[key] = typeof value === 'string' ? maskString(value) : MASK;
70
+ } else if (key === 'actions' && Array.isArray(value)) {
71
+ result[key] = value.map(item => maskSecrets(maskTypedAction(item), depth + 1));
46
72
  } else {
47
73
  result[key] = maskSecrets(value, depth + 1);
48
74
  }
@@ -1,82 +1,10 @@
1
1
  /**
2
- * stealthVerdict.js — decide what a stealth-rendered document is.
2
+ * stealthVerdict.js — re-export of the document verdict.
3
3
  *
4
- * A browser scrape that reached a document is not a scrape that succeeded.
5
- * Round 18 (2026-09-04, pricing sweep) returned success:true for Edmunds'
6
- * "403 - Access Denied" page, Lufthansa's "Page not found", Home Depot's
7
- * "Error Page" and Hilton's "Something went wrong" — each an HTTP error or a
8
- * soft block served with a title and a few lines of prose, so the R15
9
- * challenge-vendor check and the R16 empty-document check both passed it.
10
- * The HTTP status of the navigation and a short error-titled document are
11
- * now failures too; the content is still returned so the caller can read
12
- * what the site served.
4
+ * `stealthDocumentVerdict` moved to crawlforge-extractors 1.7.0 as
5
+ * `documentVerdict` — the same behaviour and messages for the stealth callers,
6
+ * plus a `fetcher` option so the plain `scrape` path on both surfaces reaches
7
+ * the same verdict (Phase 0, 0.1). Callers keep importing from here.
13
8
  */
14
9
 
15
- import { detectChallengePage } from './challengeDetection.js';
16
-
17
- // A document this short with one of these titles is an error placeholder,
18
- // not a page. Real pages with these words in a longer title (a news story
19
- // about an outage) carry far more text than the cap.
20
- const ERROR_TITLE = /^(?:(?:\d{3}\s*[-–—|:]\s*)?(?:error(?: page)?|access denied|forbidden|(?:page )?not found|service unavailable|internal server error|bad gateway|something went wrong|oops!?[^\n]{0,60}))$/i;
21
- export const SOFT_ERROR_MAX_CHARS = 1500;
22
-
23
- /**
24
- * @param {{ url?: string, title?: string, text?: string, html?: string, status?: number|null }} scraped
25
- * @param {{ waitedMs?: number, allowEmpty?: boolean }} [options]
26
- * @returns {{ success: boolean, status: number|null, error?: string, blocked?: { vendor: string, evidence: string } }}
27
- */
28
- export function stealthDocumentVerdict(scraped, { waitedMs = 0, allowEmpty = false } = {}) {
29
- const status = Number.isInteger(scraped?.status) ? scraped.status : null;
30
- const url = scraped?.url || '';
31
- const title = String(scraped?.title || '').trim();
32
- const text = String(scraped?.text || '').trim();
33
-
34
- const challenge = detectChallengePage(scraped || {});
35
- if (challenge) {
36
- return {
37
- success: false,
38
- status,
39
- blocked: challenge,
40
- error: `${challenge.vendor} served a challenge page instead of the content (${challenge.evidence}); the stealth browser did not pass it.`
41
- };
42
- }
43
-
44
- if (status !== null && status >= 400) {
45
- const why = status === 403
46
- ? 'A 403 with no challenge vendor on the page is an IP-reputation or WAF block; the site will not serve this network.'
47
- : status === 404
48
- ? 'The site says the URL does not exist — check the path.'
49
- : status === 429
50
- ? 'The site is rate-limiting this network; wait before retrying.'
51
- : 'Retry later; the server, not the page, failed.';
52
- return {
53
- success: false,
54
- status,
55
- error: `HTTP ${status}: ${url} answered with an error page${title ? ` titled "${title}"` : ''}, not the resource; the content returned is that page. ${why}`
56
- };
57
- }
58
-
59
- if (!title && !text) {
60
- if (allowEmpty) return { success: true, status };
61
- return {
62
- success: false,
63
- status,
64
- error:
65
- `The stealth browser reached ${url} but the document rendered no title and no text` +
66
- ` after ${waitedMs}ms of extra wait (${(scraped?.html || '').length} bytes of HTML).` +
67
- ' A JavaScript-rendered page needs a longer wait_for; an empty document means the server sent nothing to render.'
68
- };
69
- }
70
-
71
- if (text.length < SOFT_ERROR_MAX_CHARS && ERROR_TITLE.test(title)) {
72
- return {
73
- success: false,
74
- status,
75
- error:
76
- `${url} rendered an error page titled "${title}" (${text.length} characters of text) instead of the resource` +
77
- ' — a soft block or an application error. Retry later, or with a longer wait_for if the site paints content after a placeholder.'
78
- };
79
- }
80
-
81
- return { success: true, status };
82
- }
10
+ export { documentVerdict as stealthDocumentVerdict, SOFT_ERROR_MAX_CHARS } from 'crawlforge-extractors';