crawlforge-mcp-server 5.2.8 → 5.3.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.
Files changed (71) hide show
  1. package/CLAUDE.md +13 -1
  2. package/README.md +9 -9
  3. package/package.json +2 -2
  4. package/server.js +175 -26
  5. package/src/cli/commands/stealth.js +7 -1
  6. package/src/constants/config.js +2 -1
  7. package/src/core/ActionExecutor.js +168 -16
  8. package/src/core/AlertNotificationSystem.js +2 -1
  9. package/src/core/AuthManager.js +19 -1
  10. package/src/core/ChangeTracker.js +34 -6
  11. package/src/core/LLMsTxtAnalyzer.js +94 -12
  12. package/src/core/LocalizationManager.js +2 -1
  13. package/src/core/ResearchOrchestrator.js +407 -86
  14. package/src/core/StealthBrowserManager.js +186 -105
  15. package/src/core/WebhookDispatcher.js +3 -4
  16. package/src/core/analysis/ContentAnalyzer.js +41 -15
  17. package/src/core/analysis/sentenceUtils.js +16 -5
  18. package/src/core/crawlers/BFSCrawler.js +44 -21
  19. package/src/core/llm/LLMManager.js +517 -13
  20. package/src/core/processing/BrowserProcessor.js +27 -0
  21. package/src/core/processing/ContentProcessor.js +11 -39
  22. package/src/core/processing/PDFProcessor.js +2 -3
  23. package/src/core/research/claimFilters.js +235 -0
  24. package/src/schemas/toolOutputSchemas.js +5 -1
  25. package/src/security/wave3-security.js +2 -1
  26. package/src/server/requestContext.js +23 -0
  27. package/src/server/withAuth.js +21 -5
  28. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
  29. package/src/tools/advanced/ScrapeWithActionsTool.js +49 -1
  30. package/src/tools/advanced/batchScrape/schema.js +4 -0
  31. package/src/tools/advanced/batchScrape/worker.js +19 -10
  32. package/src/tools/basic/_fetch.js +19 -15
  33. package/src/tools/basic/extractLinks.js +8 -3
  34. package/src/tools/basic/extractMetadata.js +7 -3
  35. package/src/tools/basic/extractText.js +8 -3
  36. package/src/tools/basic/fetchUrl.js +7 -3
  37. package/src/tools/basic/scrapeStructured.js +76 -3
  38. package/src/tools/crawl/_sessionContext.js +10 -2
  39. package/src/tools/crawl/crawlDeep.js +29 -12
  40. package/src/tools/crawl/mapSite.js +39 -14
  41. package/src/tools/extract/_fetchAndParse.js +23 -8
  42. package/src/tools/extract/analyzeContent.js +5 -3
  43. package/src/tools/extract/extractContent.js +18 -4
  44. package/src/tools/extract/extractStructured.js +66 -12
  45. package/src/tools/extract/extractWithLlm.js +51 -4
  46. package/src/tools/extract/processDocument.js +45 -78
  47. package/src/tools/extract/summarizeContent.js +35 -1
  48. package/src/tools/llmstxt/generateLLMsTxt.js +19 -4
  49. package/src/tools/research/deepResearch.js +2 -1
  50. package/src/tools/scrape/_brandingExtractor.js +42 -3
  51. package/src/tools/scrape/_mainContent.js +105 -0
  52. package/src/tools/scrape/unifiedScrape.js +21 -14
  53. package/src/tools/search/adapters/redditOfficialApi.js +7 -6
  54. package/src/tools/search/redditSearch.js +6 -3
  55. package/src/tools/search/searchWeb.js +26 -3
  56. package/src/tools/templates/ScrapeTemplateTool.js +17 -6
  57. package/src/tools/tracking/trackChanges/differ.js +26 -3
  58. package/src/tools/tracking/trackChanges/index.js +12 -5
  59. package/src/tools/tracking/trackChanges/notifier.js +3 -1
  60. package/src/tools/tracking/trackChanges/schema.js +3 -0
  61. package/src/utils/complianceAudit.js +72 -0
  62. package/src/utils/contentUtils.js +12 -1
  63. package/src/utils/domainFilter.js +38 -19
  64. package/src/utils/fetchIdentity.js +62 -0
  65. package/src/utils/hostBlocklist.js +81 -0
  66. package/src/utils/hostRateLimiter.js +101 -2
  67. package/src/utils/robotsChecker.js +90 -43
  68. package/src/utils/robotsGate.js +206 -0
  69. package/src/utils/sitemapParser.js +33 -15
  70. package/src/utils/ssrfProtection.js +2 -1
  71. package/src/utils/webBotAuth.js +193 -0
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Claim admission filters for deep_research synthesis.
3
+ *
4
+ * Claims are extractive sentences pulled out of source pages by the summarize
5
+ * tool, so whatever the page starts with is a candidate claim. On a live run
6
+ * (2026-08-28, "What anti-bot systems do major websites use in 2026") the top
7
+ * finding was an arXiv front-matter block beginning "DOI: XXXXXXX.XXXXXXX" —
8
+ * document furniture, not a research claim. These predicates reject that class
9
+ * of text before it can become a key finding.
10
+ */
11
+
12
+ // Document furniture: DOI stubs, retrieval notes, ACM/arXiv front matter,
13
+ // copyright rows, contact emails. Matched anywhere in the candidate claim.
14
+ const FRONT_MATTER_PATTERNS = [
15
+ /\bdoi\s*:/i,
16
+ /\bdoi\.org\//i,
17
+ /\barxiv\s*:\s*\d/i,
18
+ /\bccs concepts?\b/i,
19
+ /\bacm reference format\b/i,
20
+ /\bretrieved (?:from|on)\b/i,
21
+ /\bissn\b|\bisbn\b/i,
22
+ /\ball rights reserved\b/i,
23
+ /\bcopyright\s*(?:©|\(c\)|\d{4})/i,
24
+ /[\w.+-]+@[\w-]+\.[a-z]{2,}/i,
25
+ // "Permission to make digital or hard copies…" — the ACM licence block.
26
+ /\bpermission to make digital\b/i,
27
+ // Bot-challenge interstitials. A research run on anti-bot systems scores
28
+ // these as highly relevant — they are literally about bot detection — so the
29
+ // relevance gate cannot catch them (live 2026-08-28: "Checking your browser.
30
+ // This only takes a moment." was the top finding). Phrases are specific to
31
+ // the challenge chrome; a claim that merely discusses CAPTCHAs is untouched.
32
+ /\bchecking your browser\b/i,
33
+ /\bjust a moment\b/i,
34
+ /\bi am not a robot\b/i,
35
+ /\bthis check is for\b/i,
36
+ /\bverify (?:you are|that you are) (?:a )?human\b/i,
37
+ /\bplease enable (?:javascript|cookies)\b/i,
38
+ /\bray id\b/i,
39
+ /\bddos protection by\b/i,
40
+ /\bperformance (?:&|and) security by\b/i
41
+ ];
42
+
43
+ // Tokens that mark an affiliation line rather than a sentence about the topic.
44
+ const AFFILIATION_TOKENS = new Set([
45
+ 'university', 'universities', 'institute', 'institut', 'department', 'dept',
46
+ 'faculty', 'college', 'school', 'laboratory', 'laboratoire', 'academy',
47
+ 'inc', 'llc', 'ltd', 'gmbh', 'corp', 'corporation', 'foundation'
48
+ ]);
49
+
50
+ // Closed-class finite verbs. A declarative claim contains one of these or an
51
+ // inflected lexical verb (see hasFiniteVerb).
52
+ const AUXILIARY_AND_MODAL_VERBS = new Set([
53
+ 'is', 'are', 'was', 'were', 'am', 'be', 'been', 'being',
54
+ 'has', 'have', 'had', 'do', 'does', 'did',
55
+ 'can', 'could', 'will', 'would', 'shall', 'should', 'may', 'might', 'must'
56
+ ]);
57
+
58
+ // Frequent base-form verbs. A plural subject takes an uninflected verb
59
+ // ("Akamai and Imperva rely on device fingerprinting"), which the -s/-ed test
60
+ // below cannot see.
61
+ const COMMON_BASE_VERBS = new Set([
62
+ 'use', 'rely', 'block', 'detect', 'deploy', 'prevent', 'provide', 'offer',
63
+ 'include', 'require', 'allow', 'make', 'run', 'work', 'help', 'need', 'show',
64
+ 'report', 'find', 'remain', 'become', 'appear', 'support', 'handle',
65
+ 'protect', 'track', 'monitor', 'serve', 'apply', 'target', 'return', 'know',
66
+ 'take', 'give', 'keep', 'add', 'build', 'create', 'enable', 'identify',
67
+ 'reduce', 'increase', 'mean', 'occur', 'exist', 'vary', 'differ', 'depend',
68
+ 'contain', 'combine', 'measure', 'generate'
69
+ ]);
70
+
71
+ // Lowercase words that end in -s/-es/-ed but are not verbs; without these the
72
+ // inflection test in hasFiniteVerb reads plural nouns as verbs.
73
+ const NOT_VERBS = new Set([
74
+ 'as', 'its', 'this', 'thus', 'less', 'gas', 'plus', 'versus', 'yes',
75
+ 'always', 'perhaps', 'various', 'previous', 'obvious', 'serious', 'numerous',
76
+ 'analysis', 'access', 'process', 'business', 'address', 'success', 'progress'
77
+ ]);
78
+
79
+ // Marketing register. Present in a vendor's own copy, rare in a factual claim.
80
+ const PROMOTIONAL_CUES = [
81
+ /\bbest\b/i, /\bleading\b/i, /\bindustry[- ]leading\b/i, /\bworld[- ]class\b/i,
82
+ /\bfastest\b/i, /\bmost (?:accurate|reliable|powerful|advanced)\b/i,
83
+ /\btrusted by\b/i, /\breliable\b/i, /\beffortless/i, /\bseamless/i,
84
+ /\bpowerful\b/i, /\bsolution\b/i, /\bunlimited\b/i, /\bguarantee/i,
85
+ /\bget started\b/i, /\bsign up\b/i, /\bfree trial\b/i, /\bno credit card\b/i,
86
+ /\bstart (?:scraping|crawling|building)\b/i, /\bcontact sales\b/i,
87
+ /\bpricing\b/i, /\bplans start\b/i, /\b99\.9\d*%\b/i,
88
+ /\bmost widely used\b/i, /\bmost popular\b/i
89
+ ];
90
+
91
+ // A named commercial offering: a capitalized name bound to a product noun
92
+ // ("Zyte API", "Scrapy Cloud", "Managed Data service"), or a third-person
93
+ // possessive standing in for one ("their API"). This is the SUBJECT half of a
94
+ // recommendation. Nothing here names a company — the test is grammatical.
95
+ const NAMED_OFFERING_PATTERNS = [
96
+ /\b[A-Z][A-Za-z0-9.\-]+\s+(?:APIs?|SDKs?|[Pp]latform|[Pp]roduct|[Ss]ervice|[Ss]uite|[Tt]oolkit|[Cc]loud)\b/,
97
+ /\b(?:their|its|our)\s+(?:api|sdk|platform|product|service|suite|toolkit|cloud|tool)\b/i,
98
+ /\bflagship product\b/i
99
+ ];
100
+
101
+ // The PREDICATE half: an offering credited with doing the work for you, or
102
+ // ranked above its peers. Deliberately excludes plain capability verbs
103
+ // ("detects", "blocks", "scores", "protects"), which is how factual sentences
104
+ // about anti-bot systems read — those must survive to answer the question.
105
+ const PITCH_PREDICATE_PATTERNS = [
106
+ /\bdesigned to\b/i, /\bbuilt to\b/i, /\bpurpose[- ]built\b/i,
107
+ /\boffers?\b/i, /\bprovides?\b/i, /\bdelivers?\b/i,
108
+ /\b(?:lets|helps|allows) you\b/i, /\bso you can\b/i,
109
+ /\bautomates?\b/i, /\bunblocks?\b/i, /\bhandles? (?:everything|it all|the (?:complexity|hard part))/i,
110
+ /\branked (?:#\s*1|number one|first)\b/i, /\ball[- ]in[- ]one\b/i,
111
+ /\bout of the box\b/i, /\bno code (?:required|needed)\b/i,
112
+ /\bstarts? at \$/i, /\bfree tier\b/i
113
+ ];
114
+
115
+ /** Text that is document furniture rather than a claim about the topic. */
116
+ export function isFrontMatter(text) {
117
+ return FRONT_MATTER_PATTERNS.some(pattern => pattern.test(text));
118
+ }
119
+
120
+ /**
121
+ * Does the text contain a finite verb?
122
+ *
123
+ * Deliberately permissive: a closed list of auxiliaries, modals and frequent
124
+ * base-form verbs, plus any lowercase token inflected as -s/-es/-ed. It
125
+ * rejects verbless fragments
126
+ * (author rows, keyword lists, DOI stubs), not bad grammar. Capitalized tokens
127
+ * are never counted, so "Jane Doe, University of Somewhere" has no verb while
128
+ * "Cloudflare uses TLS fingerprinting" does.
129
+ */
130
+ export function hasFiniteVerb(text) {
131
+ const tokens = text.split(/[^A-Za-z'-]+/).filter(Boolean);
132
+
133
+ return tokens.some(token => {
134
+ const lower = token.toLowerCase();
135
+ if (AUXILIARY_AND_MODAL_VERBS.has(lower)) return true;
136
+ // Lexical verbs only in their lowercase form — a capitalized token here is
137
+ // a proper noun ("Systems Inc"), not a verb.
138
+ if (token !== lower || NOT_VERBS.has(lower)) return false;
139
+ return COMMON_BASE_VERBS.has(lower) || /^[a-z]{3,}(?:s|es|ed)$/.test(lower);
140
+ });
141
+ }
142
+
143
+ /**
144
+ * Share of tokens that look like proper nouns or affiliation words, ignoring
145
+ * the first token (a normal sentence starts capitalized).
146
+ */
147
+ export function properNounDensity(text) {
148
+ const tokens = text.split(/[^A-Za-z'-]+/).filter(Boolean).slice(1);
149
+ if (tokens.length === 0) return 0;
150
+
151
+ const marked = tokens.filter(
152
+ token => /^[A-Z]/.test(token) || AFFILIATION_TOKENS.has(token.toLowerCase())
153
+ ).length;
154
+
155
+ return marked / tokens.length;
156
+ }
157
+
158
+ // Above this share of proper-noun/affiliation tokens the text reads as an
159
+ // author or affiliation block rather than a sentence about the topic.
160
+ const PROPER_NOUN_DENSITY_MAX = 0.5;
161
+
162
+ /** A candidate claim is admissible when it is a verb-bearing, non-front-matter sentence. */
163
+ export function isAdmissibleClaim(text) {
164
+ if (typeof text !== 'string' || text.trim().length === 0) return false;
165
+ if (isFrontMatter(text)) return false;
166
+ if (!hasFiniteVerb(text)) return false;
167
+ return properNounDensity(text) <= PROPER_NOUN_DENSITY_MAX;
168
+ }
169
+
170
+ /** Brand token of a URL's host: "https://scrape.do/blog" -> "scrape". */
171
+ export function brandFromUrl(url) {
172
+ try {
173
+ const host = new URL(url).hostname.toLowerCase().replace(/^www\./, '');
174
+ const labels = host.split('.');
175
+ // Drop the public suffix; for "scrape.do" that leaves "scrape", for
176
+ // "api.datadome.co.uk" it leaves "datadome".
177
+ const brand = labels.length > 2 && labels[labels.length - 2].length <= 3
178
+ ? labels[labels.length - 3]
179
+ : labels[labels.length - 2];
180
+ return brand || null;
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Is this claim a vendor describing its own product in marketing register?
188
+ *
189
+ * Signal: the text refers to the site it came from (its brand token, or a
190
+ * first-person "we/our") AND uses at least one marketing cue. That is enough
191
+ * to stop "Scrape.do offers reliable scraping with effective anti-bot bypass"
192
+ * being laundered into a research conclusion.
193
+ *
194
+ * On its own it does not catch one vendor pitching another vendor's product —
195
+ * see looksLikeVendorPage + isProductPitch, which cover that case.
196
+ */
197
+ export function isVendorSelfPromotion(text, sourceUrl) {
198
+ if (typeof text !== 'string' || text.length === 0) return false;
199
+
200
+ const brand = brandFromUrl(sourceUrl);
201
+ const selfReference =
202
+ (brand && brand.length >= 3 && new RegExp(`\\b${brand}\\b`, 'i').test(text)) ||
203
+ /\b(?:we|our|us)\b/i.test(text);
204
+
205
+ return Boolean(selfReference) && PROMOTIONAL_CUES.some(cue => cue.test(text));
206
+ }
207
+
208
+ /**
209
+ * Is this claim a recommendation rather than evidence?
210
+ *
211
+ * A recommendation has a shape: its subject is a named commercial offering and
212
+ * its predicate credits that offering with doing the work or ranking above its
213
+ * peers. "Their flagship product, Zyte API, is a web scraping API designed to
214
+ * unblock, render, and extract data from any website" has both halves; "CF-RAY
215
+ * in the response header means Cloudflare" and "Modern anti-bot systems
216
+ * fingerprint your TLS handshake" have neither, and both must survive — they
217
+ * are the answer to an anti-bot question.
218
+ *
219
+ * The test is grammatical and domain-independent: no company or product name
220
+ * appears anywhere in this module, and a page describing its own product and a
221
+ * page describing a peer's are treated identically.
222
+ *
223
+ * It does NOT catch a recommendation written without a product noun ("Scrapy is
224
+ * what most teams reach for"), and it will flag a factual sentence that
225
+ * describes a named product as doing work for you — including an anti-bot
226
+ * vendor's own product page. Both halves must match, which is what keeps
227
+ * ordinary claims about detection systems out of it.
228
+ */
229
+ export function isProductRecommendation(text) {
230
+ if (typeof text !== 'string' || text.length === 0) return false;
231
+
232
+ return NAMED_OFFERING_PATTERNS.some(pattern => pattern.test(text)) &&
233
+ PITCH_PREDICATE_PATTERNS.some(pattern => pattern.test(text));
234
+ }
235
+
@@ -246,6 +246,7 @@ const searchWebShape = {
246
246
  // ── extract_structured ───────────────────────────────────────────────────────
247
247
 
248
248
  const extractStructuredShape = {
249
+ success: z.boolean().optional().describe('False when the extraction errored or a required field came back missing or empty'),
249
250
  url: z.string().optional(),
250
251
  data: z.record(z.unknown()).optional().describe('Extracted fields matching the requested schema'),
251
252
  extraction_method: z.string().optional().describe('"llm" | "css_fallback" | "keyword_fallback" | "none"'),
@@ -290,7 +291,10 @@ const crawlDeepShape = {
290
291
  stats: z.unknown().optional(),
291
292
  site_structure: z.object({
292
293
  total_pages: z.number().optional(),
293
- depth_distribution: z.record(z.number()).optional(),
294
+ depth_distribution: z.record(z.number()).optional()
295
+ .describe('Pages per crawl depth (links from the start URL)'),
296
+ path_depth_distribution: z.record(z.number()).optional()
297
+ .describe('Pages per URL path-segment depth'),
294
298
  path_patterns: z.record(z.number()).optional(),
295
299
  file_types: z.record(z.number()).optional(),
296
300
  subdomains: z.array(z.string()).optional()
@@ -9,6 +9,7 @@ import { createHash, randomBytes, timingSafeEqual } from 'crypto';
9
9
  import path from 'path';
10
10
  import { URL } from 'url';
11
11
  import DOMPurify from 'isomorphic-dompurify';
12
+ import { identityHeaders } from '../utils/fetchIdentity.js';
12
13
 
13
14
  // Security configuration
14
15
  const SECURITY_CONFIG = {
@@ -125,7 +126,7 @@ export class SSRFProtection {
125
126
  const fetchOptions = {
126
127
  timeout: options.timeout || 30000,
127
128
  headers: {
128
- 'User-Agent': 'CrawlForge/3.0 (Security-Hardened)',
129
+ ...identityHeaders({ role: 'health-check' }),
129
130
  ...options.headers
130
131
  },
131
132
  ...options
@@ -24,3 +24,26 @@ export const requestContext = new AsyncLocalStorage();
24
24
  export function isInternalRequest() {
25
25
  return requestContext.getStore()?.internal === true;
26
26
  }
27
+
28
+ /**
29
+ * Record that the compliance gate refused this invocation before anything was
30
+ * fetched — robots.txt disallowed the path, or the host is on the permanent
31
+ * blocklist.
32
+ *
33
+ * withAuth reads this when it decides the charge. The flag rather than the
34
+ * error's `code` is deliberate: tool handlers catch their own errors and
35
+ * return `{ isError: true }` with only a message, so the typed error never
36
+ * reaches withAuth. It also survives both routes a refusal can take — thrown,
37
+ * or swallowed into an isError result.
38
+ *
39
+ * @param {string} code 'ROBOTS_DISALLOWED' | 'HOST_BLOCKED'
40
+ */
41
+ export function markPreflightRefusal(code) {
42
+ const store = requestContext.getStore();
43
+ if (store) store.preflightRefusal = code;
44
+ }
45
+
46
+ /** The refusal code recorded for this invocation, or null. */
47
+ export function preflightRefusal() {
48
+ return requestContext.getStore()?.preflightRefusal ?? null;
49
+ }
@@ -15,7 +15,7 @@
15
15
 
16
16
  import { createHash } from 'node:crypto';
17
17
  import { recordToolInvocation } from '../observability/tracing.js';
18
- import { isInternalRequest } from './requestContext.js';
18
+ import { isInternalRequest, preflightRefusal, requestContext } from './requestContext.js';
19
19
 
20
20
  export function hashParams(params) {
21
21
  try {
@@ -33,7 +33,7 @@ export function hashParams(params) {
33
33
  */
34
34
  export function makeWithAuth({ authManager, logger, metrics = null }) {
35
35
  return function withAuth(toolName, handler) {
36
- return async (params) => {
36
+ const invoke = async (params) => {
37
37
  const startTime = Date.now();
38
38
  const paramHash = hashParams(params);
39
39
  const creatorMode = authManager.isCreatorMode();
@@ -82,7 +82,14 @@ export function makeWithAuth({ authManager, logger, metrics = null }) {
82
82
  // it as an error, honoring CLAUDE.md's "half credits on error" contract.
83
83
  const isErrorResult = result?.isError === true;
84
84
  outcome = isErrorResult ? 'error' : 'success';
85
- const charge = creditCost === 0
85
+ // A pre-flight refusal (robots.txt disallowed the path, or the host is
86
+ // blocklisted) means we fetched nothing at all, so it costs nothing —
87
+ // not even the half-credit error rate, which exists for work that ran
88
+ // and then failed. Only when the refusal actually sank the call: a
89
+ // multi-URL tool that skipped one disallowed URL and still returned a
90
+ // result did real work and bills for it.
91
+ const refused = isErrorResult && preflightRefusal() !== null;
92
+ const charge = creditCost === 0 || refused
86
93
  ? 0
87
94
  : (isErrorResult ? Math.max(1, Math.floor(creditCost * 0.5)) : creditCost);
88
95
 
@@ -121,7 +128,7 @@ export function makeWithAuth({ authManager, logger, metrics = null }) {
121
128
  // DataForSEO is unconfigured — a no-op). Emit NO usage event at all so
122
129
  // the backend has nothing to (re-)price; reporting 0 would still create
123
130
  // a serp_rank record the backend could recompute to full cost.
124
- if (!creatorMode && creditCost > 0) {
131
+ if (!creatorMode && creditCost > 0 && !refused) {
125
132
  await authManager.reportUsage(toolName, charge, params, isErrorResult ? 500 : 200, Date.now() - startTime);
126
133
  }
127
134
 
@@ -132,7 +139,7 @@ export function makeWithAuth({ authManager, logger, metrics = null }) {
132
139
  // Half-charge on error — but never charge a free (0-cost) call, never
133
140
  // let Math.max(1, …) floor a 0 up to 1 credit, and never bill at all
134
141
  // if the handler never ran (e.g. the credit check itself threw).
135
- if (!creatorMode && creditCost > 0 && handlerStarted) {
142
+ if (!creatorMode && creditCost > 0 && handlerStarted && preflightRefusal() === null) {
136
143
  await authManager.reportUsage(
137
144
  toolName,
138
145
  Math.max(1, Math.floor(creditCost * 0.5)),
@@ -183,5 +190,14 @@ export function makeWithAuth({ authManager, logger, metrics = null }) {
183
190
  }, thrown);
184
191
  }
185
192
  };
193
+
194
+ // Every invocation runs in its own context so the compliance gate can stamp
195
+ // a refusal where the billing decision can see it. Any outer store (the
196
+ // HTTP transport's `internal` flag) is spread in, not replaced — and stdio
197
+ // callers, who have no transport-provided store, get one here.
198
+ return async (params) => requestContext.run(
199
+ { ...(requestContext.getStore() ?? {}), preflightRefusal: null },
200
+ () => invoke(params)
201
+ );
186
202
  };
187
203
  }
@@ -30,7 +30,7 @@ skill (`scrape` / `extract_content`) instead.
30
30
  | Need | Tool | Cost |
31
31
  |------|------|------|
32
32
  | A ranked list of result URLs + snippets | `search_web` | 5 |
33
- | Reddit posts, comments, or a full thread | `reddit_search` | 2 |
33
+ | Reddit posts, comments, or a full thread | `reddit_search` | 5 |
34
34
  | A direct answer, agent decides what to read | `agent` | 8 (scales) |
35
35
  | A synthesized, multi-source, cited report | `deep_research` | 10+ (scales) |
36
36
 
@@ -83,6 +83,33 @@ const ScrollActionSchema = BaseActionSchema.extend({
83
83
  y: z.number().min(0).optional()
84
84
  });
85
85
 
86
+ const SelectActionSchema = BaseActionSchema.extend({
87
+ type: z.literal('select'),
88
+ selector: z.string(),
89
+ // A plain string matches an <option> by value OR label — see ActionExecutor's
90
+ // SelectActionSchema.
91
+ value: z.string().optional(),
92
+ values: z.array(z.string()).optional()
93
+ }).refine(data => data.value !== undefined || (data.values && data.values.length > 0), {
94
+ message: 'Select action requires value or values'
95
+ });
96
+
97
+ const HoverActionSchema = BaseActionSchema.extend({
98
+ type: z.literal('hover'),
99
+ selector: z.string(),
100
+ force: z.boolean().default(false),
101
+ position: z.object({
102
+ x: z.number(),
103
+ y: z.number()
104
+ }).optional()
105
+ });
106
+
107
+ const NavigateActionSchema = BaseActionSchema.extend({
108
+ type: z.literal('navigate'),
109
+ url: z.string().url(),
110
+ waitUntil: z.enum(['load', 'domcontentloaded', 'networkidle', 'commit']).optional()
111
+ });
112
+
86
113
  const ScreenshotActionSchema = BaseActionSchema.extend({
87
114
  type: z.literal('screenshot'),
88
115
  selector: z.string().optional(),
@@ -104,6 +131,9 @@ const ActionSchema = z.union([
104
131
  TypeActionSchema,
105
132
  PressActionSchema,
106
133
  ScrollActionSchema,
134
+ SelectActionSchema,
135
+ HoverActionSchema,
136
+ NavigateActionSchema,
107
137
  ScreenshotActionSchema,
108
138
  ExecuteJavaScriptActionSchema
109
139
  ]);
@@ -145,7 +175,11 @@ const ScrapeWithActionsSchema = z.object({
145
175
  userAgent: z.string().optional(),
146
176
  viewportWidth: z.number().min(800).max(1920).default(1280),
147
177
  viewportHeight: z.number().min(600).max(1080).default(720),
148
- timeout: z.number().min(10000).max(120000).default(30000)
178
+ timeout: z.number().min(10000).max(120000).default(30000),
179
+ // Run the chain in the stealth browser (StealthBrowserManager) instead of
180
+ // the standard pool. executeSession turns this into the stealthMode object
181
+ // ActionExecutor/BrowserProcessor read.
182
+ stealth: z.boolean().default(false)
149
183
  }).optional(),
150
184
 
151
185
  // Content extraction options
@@ -156,6 +190,10 @@ const ScrapeWithActionsSchema = z.object({
156
190
  includeImages: z.boolean().default(true)
157
191
  }).optional(),
158
192
 
193
+ // Per-request robots.txt override (G5). Undefined leaves the configured
194
+ // default in force; false is honoured, warned about and audited by the gate.
195
+ respect_robots: z.boolean().optional(),
196
+
159
197
  // Error handling
160
198
  continueOnActionError: z.boolean().default(false),
161
199
  maxRetries: z.number().min(0).max(3).default(1),
@@ -341,6 +379,16 @@ export class ScrapeWithActionsTool extends EventEmitter {
341
379
  ...params.browserOptions
342
380
  };
343
381
 
382
+ // `stealth: true` is the caller-facing switch; everything downstream reads
383
+ // browserOptions.stealthMode.
384
+ if (browserOptions.stealth) {
385
+ browserOptions.stealthMode = { enabled: true };
386
+ }
387
+
388
+ // The robots override rides with the browser options so ActionExecutor's
389
+ // gate sees it on the initial load and on every `navigate` action.
390
+ browserOptions.respectRobots = params.respect_robots;
391
+
344
392
  // Build action chain with form auto-fill if provided
345
393
  let actionChain = [...params.actions];
346
394
 
@@ -23,6 +23,10 @@ export const BatchScrapeSchema = z.object({
23
23
  signingSecret: z.string().optional()
24
24
  }).optional(),
25
25
  extractionSchema: z.record(z.string()).optional(),
26
+ // Compliance overrides, per request: identify as yourself for a target you
27
+ // have your own agreement with, and take responsibility for ignoring robots.
28
+ user_agent: z.string().optional(),
29
+ respect_robots: z.boolean().optional(),
26
30
  maxConcurrency: z.number().min(1).max(20).default(10),
27
31
  delayBetweenRequests: z.number().min(0).max(10000).default(100),
28
32
  includeMetadata: z.boolean().default(true),
@@ -7,33 +7,37 @@
7
7
  import { load } from 'cheerio';
8
8
  import { config as appConfig } from '../../../constants/config.js';
9
9
  import { ssrfGuard, isSsrfError } from '../../../utils/ssrfGuard.js';
10
- import { throttleHost } from '../../../utils/hostRateLimiter.js';
10
+ import { noteRetryAfter } from '../../../utils/hostRateLimiter.js';
11
+ import { preflightFetch } from '../../../utils/robotsGate.js';
11
12
  import { htmlToMarkdown } from '../../../utils/htmlToMarkdown.js';
12
13
 
13
- const USER_AGENT = 'MCP-WebScraper-BatchTool/1.0.0';
14
-
15
14
  /**
16
15
  * Fetch a URL with AbortController timeout (SSRF-guarded + per-host throttled).
17
16
  * The timeout stays live through the body read (not just until headers
18
17
  * arrive), and the body is size-capped, so a server that sends headers then
19
18
  * drips the body can't hold a semaphore slot indefinitely or exhaust memory.
20
- * Returns { response, html } rather than the raw Response.
19
+ * Returns { response, html, warnings } rather than the raw Response.
21
20
  */
22
21
  export async function fetchUrl(url, options = {}) {
23
- const { timeout = 15000, headers = {} } = options;
22
+ const { timeout = 15000, headers = {}, userAgent, respectRobots, apiKey } = options;
24
23
  const maxBodySize = appConfig.fetch.maxBodySize;
25
24
  const guard = ssrfGuard(url); // SSRF pre-flight (throws before connecting)
26
- await throttleHost(url);
25
+ // robots.txt / blocklist gate + per-host throttle. Throws if it refuses,
26
+ // which scrapeUrl turns into a failure for this URL alone.
27
+ const gate = await preflightFetch(url, { userAgent, respectRobots, apiKey, tool: 'batch_scrape' });
27
28
  const controller = new AbortController();
28
29
  const timeoutId = setTimeout(() => controller.abort(), timeout);
29
30
  try {
30
31
  const response = await fetch(url, {
31
32
  signal: controller.signal,
32
- headers: { 'User-Agent': USER_AGENT, ...headers },
33
+ headers: { ...gate.headers, ...headers },
33
34
  ...guard
34
35
  });
36
+ if (response.status === 429 || response.status === 503) {
37
+ noteRetryAfter(url, response.headers?.get?.('retry-after'));
38
+ }
35
39
  const html = await readBodyCapped(response, maxBodySize);
36
- return { response, html };
40
+ return { response, html, warnings: gate.warnings };
37
41
  } catch (error) {
38
42
  if (isSsrfError(error)) throw new Error(error.cause?.message || error.message);
39
43
  if (error.name === 'AbortError') throw new Error(`Request timeout after ${timeout}ms`);
@@ -93,9 +97,12 @@ async function readBodyCapped(response, maxBodySize) {
93
97
  export async function scrapeUrl(config, options, defaultTimeout) {
94
98
  const startTime = Date.now();
95
99
  try {
96
- const { response, html } = await fetchUrl(config.url, {
100
+ const { response, html, warnings } = await fetchUrl(config.url, {
97
101
  headers: config.headers,
98
- timeout: config.timeout || defaultTimeout
102
+ timeout: config.timeout || defaultTimeout,
103
+ userAgent: options.user_agent,
104
+ respectRobots: options.respect_robots,
105
+ apiKey: options.apiKey
99
106
  });
100
107
 
101
108
  if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
@@ -115,6 +122,8 @@ export async function scrapeUrl(config, options, defaultTimeout) {
115
122
  }
116
123
  };
117
124
 
125
+ if (warnings.length > 0) result.warnings = warnings;
126
+
118
127
  if (options.extractionSchema || config.selectors) {
119
128
  result.extracted = extractStructuredData($, { ...config.selectors, ...options.extractionSchema });
120
129
  }
@@ -1,18 +1,13 @@
1
1
  /**
2
2
  * Shared HTTP fetch helper for basic tools.
3
- * Applies an AbortController timeout and a default User-Agent.
3
+ * Applies an AbortController timeout and the shared pre-fetch gate.
4
4
  */
5
5
 
6
6
  import { readBody } from 'crawlforge-extractors';
7
7
  import { config } from '../../constants/config.js';
8
- import { createRequire } from 'module';
9
8
  import { ssrfGuard, isSsrfError } from '../../utils/ssrfGuard.js';
10
- import { throttleHost } from '../../utils/hostRateLimiter.js';
11
-
12
- // Derive User-Agent from package version so it reflects the actual release.
13
- const _require = createRequire(import.meta.url);
14
- const _pkg = _require('../../../package.json');
15
- const CRAWLFORGE_UA = `CrawlForge/${_pkg.version} (+https://crawlforge.dev)`;
9
+ import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
10
+ import { preflightFetch } from '../../utils/robotsGate.js';
16
11
 
17
12
  /**
18
13
  * Fetch a URL with a configurable timeout and body-size cap.
@@ -23,19 +18,21 @@ const CRAWLFORGE_UA = `CrawlForge/${_pkg.version} (+https://crawlforge.dev)`;
23
18
  * default 25 MB).
24
19
  *
25
20
  * @param {string} url
26
- * @param {{ timeout?: number, headers?: Record<string,string> }} [options]
27
- * @returns {Promise<Response & { _body: string }>}
21
+ * @param {{ timeout?: number, headers?: Record<string,string>, userAgent?: string,
22
+ * respectRobots?: boolean, tool?: string, apiKey?: string }} [options]
23
+ * @returns {Promise<Response & { _body: string, _warnings: string[] }>}
28
24
  */
29
25
  export async function fetchWithTimeout(url, options = {}) {
30
- const { timeout = 10000, headers = {} } = options;
26
+ const { timeout = 10000, headers = {}, userAgent, respectRobots, tool, apiKey } = options;
31
27
  const maxBodySize = config.fetch.maxBodySize;
32
28
 
33
29
  // SSRF pre-flight (protocol / metadata host). Throws a clear error before any
34
30
  // connection is attempted; `guard.dispatcher` enforces IP rules at connect time.
35
31
  const guard = ssrfGuard(url);
36
32
 
37
- // Per-host politeness throttle (before the timeout window starts).
38
- await throttleHost(url);
33
+ // robots.txt / blocklist gate + per-host politeness throttle, before the
34
+ // timeout window starts. Throws if the gate refuses the URL.
35
+ const gate = await preflightFetch(url, { userAgent, respectRobots, tool, apiKey });
39
36
 
40
37
  const controller = new AbortController();
41
38
  const timeoutId = setTimeout(() => controller.abort(), timeout);
@@ -57,7 +54,7 @@ export async function fetchWithTimeout(url, options = {}) {
57
54
  response = await fetch(url, {
58
55
  signal: controller.signal,
59
56
  headers: {
60
- 'User-Agent': CRAWLFORGE_UA,
57
+ ...gate.headers,
61
58
  ...headers
62
59
  },
63
60
  ...guard
@@ -72,6 +69,12 @@ export async function fetchWithTimeout(url, options = {}) {
72
69
  throw error;
73
70
  }
74
71
 
72
+ // A host asking us to back off is honoured on the *next* request to it,
73
+ // rather than retrying straight into the wall.
74
+ if (response.status === 429 || response.status === 503) {
75
+ noteRetryAfter(url, response.headers?.get?.('retry-after'));
76
+ }
77
+
75
78
  // Reading is delegated to crawlforge-extractors so the REST API applies
76
79
  // the same cap and the same charset handling to the same page.
77
80
  let bodyText;
@@ -89,7 +92,8 @@ export async function fetchWithTimeout(url, options = {}) {
89
92
  text: () => Promise.resolve(bodyText),
90
93
  json: () => Promise.resolve(JSON.parse(bodyText)),
91
94
  _body: bodyText,
92
- _responseTime: Date.now() - startedAt
95
+ _responseTime: Date.now() - startedAt,
96
+ _warnings: gate.warnings
93
97
  });
94
98
  } finally {
95
99
  clearTimeout(timeoutId);
@@ -7,11 +7,16 @@ import { load } from 'cheerio';
7
7
  import { fetchWithTimeout } from './_fetch.js';
8
8
 
9
9
  /**
10
- * @param {{ url: string, filter_external?: boolean, base_url?: string }} params
10
+ * @param {{ url: string, filter_external?: boolean, base_url?: string,
11
+ * user_agent?: string, respect_robots?: boolean }} params
11
12
  */
12
- export async function extractLinksHandler({ url, filter_external, base_url }) {
13
+ export async function extractLinksHandler({ url, filter_external, base_url, user_agent, respect_robots }) {
13
14
  try {
14
- const response = await fetchWithTimeout(url);
15
+ const response = await fetchWithTimeout(url, {
16
+ userAgent: user_agent,
17
+ respectRobots: respect_robots,
18
+ tool: 'extract_links'
19
+ });
15
20
  if (!response.ok) {
16
21
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
17
22
  }
@@ -60,11 +60,15 @@ function parseMicrodata($) {
60
60
  }
61
61
 
62
62
  /**
63
- * @param {{ url: string }} params
63
+ * @param {{ url: string, user_agent?: string, respect_robots?: boolean }} params
64
64
  */
65
- export async function extractMetadataHandler({ url }) {
65
+ export async function extractMetadataHandler({ url, user_agent, respect_robots }) {
66
66
  try {
67
- const response = await fetchWithTimeout(url);
67
+ const response = await fetchWithTimeout(url, {
68
+ userAgent: user_agent,
69
+ respectRobots: respect_robots,
70
+ tool: 'extract_metadata'
71
+ });
68
72
  if (!response.ok) {
69
73
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
70
74
  }