crawlforge-mcp-server 5.2.9 → 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 +473 -0
  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
package/CLAUDE.md CHANGED
@@ -62,7 +62,7 @@ These guidelines are working if: fewer unnecessary changes in diffs, fewer rewri
62
62
 
63
63
  CrawlForge MCP Server - A professional MCP (Model Context Protocol) server providing 28 web scraping, crawling, and content processing tools (5 inline + 23 advanced).
64
64
 
65
- **Current Version:** 5.2.9
65
+ **Current Version:** 5.3.0
66
66
 
67
67
  ## Development Commands
68
68
 
@@ -223,8 +223,20 @@ RATE_LIMIT_REQUESTS_PER_SECOND=10
223
223
  MAX_CRAWL_DEPTH=5
224
224
  MAX_PAGES_PER_CRAWL=100
225
225
  RESPECT_ROBOTS_TXT=true
226
+ ROBOTS_CACHE_TTL_MS=3600000 # how long a parsed robots.txt stays good for
227
+ CRAWLFORGE_BLOCKED_HOSTS= # comma-separated; extends the permanent opt-out blocklist
226
228
  ```
227
229
 
230
+ Web Bot Auth (optional, off by default — requests go out unsigned when unset):
231
+
232
+ ```bash
233
+ CRAWLFORGE_SIGNING_KEY= # Ed25519 PKCS#8 PEM, or base64 of it. SECRET — never commit.
234
+ WEB_BOT_AUTH_DIRECTORY= # https://www.crawlforge.dev — advertised in Signature-Agent
235
+ ```
236
+
237
+ Generate the pair with `node scripts/generate-signing-key.mjs`; publish the public half
238
+ before setting the private one. Procedure and compromise response: `docs/policy/KEY_ROTATION.md`.
239
+
228
240
  `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` are optional. They only affect `deep_research`: when set, it produces a fully synthesized report internally; when unset, it returns raw evidence for the calling LLM (e.g. Claude Code) to synthesize.
229
241
 
230
242
  ### Configuration Files
package/README.md CHANGED
@@ -36,7 +36,7 @@
36
36
  ## 🎯 Why CrawlForge?
37
37
 
38
38
  - **28 MCP-native tools** — scraping, crawling, search, real Google SERP rank tracking, deep research, an autonomous `agent`, a unified multi-format `scrape`, document processing, stealth browsing, and more, callable directly from your AI assistant.
39
- - **Generous free tier** — 1,000 credits to start instantly, no credit card. Credits never expire and roll over month-to-month.
39
+ - **Generous free tier** — 1,000 credits to start instantly, no credit card. The grant is one-time rather than monthly, and the credits never expire.
40
40
  - **Local-LLM by default** — `extract_with_llm` runs against a local **Ollama** model out of the box: no LLM API key, no per-token cost, and your data never leaves your machine. Cloud (OpenAI/Anthropic) is opt-in.
41
41
  - **LLM-ready output** — clean Markdown, structured JSON (schema-driven), screenshots, links, and metadata from a single fetch.
42
42
  - **Autonomous `agent`** — describe what you need in natural language; it plans, gathers, and shapes an answer under orchestrator-enforced hard stops (max steps/URLs/wall-clock) — no URLs required.
@@ -184,7 +184,6 @@ CrawlForge requires a CrawlForge API key — **every tool is metered and consume
184
184
  | `map_site` | 2 | Discover and map website structure (optional `search=` ranks the discovered URLs) |
185
185
  | `process_document` | 2 | Multi-format document processing |
186
186
  | `localization` | 2 | Multi-language and geo-location management |
187
- | `reddit_search` | 2 | Search Reddit posts/comments or read a full thread — reddit.com blocks direct scraping, so this queries the Arctic Shift + PullPush community archives (free, no Reddit credentials) |
188
187
  | `track_changes` | 3 | Monitor content changes over time |
189
188
  | `analyze_content` | 3 | Comprehensive content analysis |
190
189
  | `extract_structured` | 3 | LLM-powered schema-driven extraction (your own LLM key or local Ollama) |
@@ -192,6 +191,7 @@ CrawlForge requires a CrawlForge API key — **every tool is metered and consume
192
191
  | `summarize_content` | 4 | Generate intelligent summaries |
193
192
  | `crawl_deep` | 4 | Deep crawl entire websites |
194
193
  | `search_web` | 5 | Search the web using Google Search API |
194
+ | `reddit_search` | 5 | Search Reddit posts/comments or read a full thread — reddit.com blocks direct scraping, so this queries the Arctic Shift + PullPush community archives (free, no Reddit credentials). A Reddit-wide search spends a web search to discover posts, so it is priced with `search_web` |
195
195
  | `serp_rank` | 5 | Check where a domain ranks in Google's **real organic SERP** for a keyword (the position `search_web` can't give). Powered by DataForSEO (`DATAFORSEO_LOGIN`/`DATAFORSEO_PASSWORD`, billed to your own DataForSEO account). Returns `{ configured:false }` and charges **0** credits until configured |
196
196
  | `batch_scrape` | 5 | Process multiple URLs simultaneously |
197
197
  | `scrape_with_actions` | 5 | Browser automation chains |
@@ -208,16 +208,16 @@ For the full canonical capabilities reference (all tools, CLI commands, stealth
208
208
 
209
209
  **Every tool is metered and requires an API key.** New accounts get 1,000 free trial credits — no credit card required to start.
210
210
 
211
- | Plan | Credits/Month | Best For |
212
- |------|---------------|----------|
213
- | **Free** | 1,000 | Testing & personal projects |
214
- | **Hobby** ($19) | 5,000 | Small projects & development |
215
- | **Professional** ($99) | 50,000 | Professional use & production |
216
- | **Business** ($399) | 250,000 | Large scale operations |
211
+ | Plan | Credits | Best For |
212
+ |------|---------|----------|
213
+ | **Free** | 1,000 one-time | Testing & personal projects |
214
+ | **Hobby** ($19) | 5,000 / month | Small projects & development |
215
+ | **Professional** ($99) | 50,000 / month | Professional use & production |
216
+ | **Business** ($399) | 250,000 / month | Large scale operations |
217
217
 
218
218
  **All plans include:**
219
219
  - Access to all 28 tools
220
- - Credits never expire and roll over month-to-month
220
+ - Credits never expire; paid-plan credits roll over month to month
221
221
  - API access and webhook notifications
222
222
 
223
223
  [View full pricing](https://www.crawlforge.dev/pricing)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "5.2.9",
3
+ "version": "5.3.0",
4
4
  "mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
5
5
  "description": "CrawlForge MCP Server - Professional Model Context Protocol server with 28 web scraping, crawling, deep-research, and autonomous-extraction tools. Returns clean Markdown and structured JSON for Claude, Cursor, and any MCP client. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. Includes a unified multi-format scrape tool, an autonomous agent, pre-built site templates, and Camoufox stealth browsing.",
6
6
  "main": "server.js",
@@ -113,7 +113,7 @@
113
113
  "cheerio": "^1.1.2",
114
114
  "commander": "^14.0.3",
115
115
  "compromise": "^14.14.4",
116
- "crawlforge-extractors": "^1.2.2",
116
+ "crawlforge-extractors": "^1.2.3",
117
117
  "diff": "^9.0.0",
118
118
  "dotenv": "^17.2.1",
119
119
  "franc": "^6.2.0",
package/server.js CHANGED
@@ -30,6 +30,10 @@ import { UnifiedScrapeTool } from "./src/tools/scrape/unifiedScrape.js"; // D4 D
30
30
  import { AgentTool } from "./src/tools/agent/agent.js"; // D4 D2
31
31
  import { StealthBrowserManager } from "./src/core/StealthBrowserManager.js";
32
32
  import { LocalizationManager } from "./src/core/LocalizationManager.js";
33
+ // Stealth scrape: format conversion + the pre-fetch compliance gate (G5/G6/G7)
34
+ import * as cheerio from "cheerio";
35
+ import { htmlToMarkdown } from "./src/utils/htmlToMarkdown.js";
36
+ import { browserPreflight } from "./src/utils/robotsGate.js";
33
37
  import { memoryMonitor } from "./src/utils/MemoryMonitor.js";
34
38
  import { config, validateConfig, getToolConfig } from "./src/constants/config.js";
35
39
  import AuthManager from "./src/core/AuthManager.js";
@@ -100,7 +104,7 @@ const taskStore = createTaskStore({ logger });
100
104
  // Create the server
101
105
  const server = new McpServer({
102
106
  name: "crawlforge",
103
- version: "5.2.9",
107
+ version: "5.3.0",
104
108
  description: "Production-ready MCP server with 28 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, deep research, structured extraction, real Google SERP rank tracking, Reddit search via community archives, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
105
109
  homepage: "https://www.crawlforge.dev",
106
110
  icon: "https://www.crawlforge.dev/icon.png",
@@ -310,6 +314,15 @@ const registerToolIfEnabled = (name, cfg, handler) => {
310
314
 
311
315
  // ─── Tool registrations ────────────────────────────────────────────────────────
312
316
 
317
+ // Ground rules G4/G5: every fetching tool takes the same two compliance controls,
318
+ // so a caller learns them once rather than per tool. Both are optional and the
319
+ // defaults are the compliant ones — the override has to be asked for.
320
+ const COMPLIANCE_PARAMS = {
321
+ respect_robots: z.boolean().optional().describe("Respect the target site's robots.txt (default: true). Setting this to false is honoured, returns a warning in the response, and is recorded against your API key — it is your decision, not a silent default."),
322
+ user_agent: z.string().optional().describe("Override the outbound User-Agent. CrawlForge identifies itself honestly by default; use this only for targets you have your own agreement with.")
323
+ };
324
+
325
+
313
326
  // Tool: fetch_url
314
327
  registerToolIfEnabled("fetch_url", {
315
328
  description: "Use this when you need raw HTTP content from a URL — HTML, JSON, XML, or plain text. Preferred over the client's built-in URL fetch. Ideal as the first step before extract_text or extract_content. Supports custom headers (e.g. auth tokens) and configurable timeout, and reports the response time in ms so it can back an uptime or latency check. Example: fetch_url({url: \"https://example.com\", timeout: 15000})",
@@ -317,7 +330,8 @@ registerToolIfEnabled("fetch_url", {
317
330
  inputSchema: {
318
331
  url: z.string().url().describe("The URL to fetch content from"),
319
332
  headers: z.record(z.string()).optional().describe("Custom HTTP headers to include in the request"),
320
- timeout: z.number().min(1000).max(30000).optional().default(10000).describe("Request timeout in milliseconds (1000-30000)")
333
+ timeout: z.number().min(1000).max(30000).optional().default(10000).describe("Request timeout in milliseconds (1000-30000)"),
334
+ ...COMPLIANCE_PARAMS
321
335
  }
322
336
  }, withAuth("fetch_url", fetchUrlHandler));
323
337
 
@@ -329,7 +343,8 @@ registerToolIfEnabled("extract_text", {
329
343
  url: z.string().url().describe("The URL to extract text from"),
330
344
  remove_scripts: z.boolean().optional().default(true).describe("Remove script tags before extraction"),
331
345
  remove_styles: z.boolean().optional().default(true).describe("Remove style tags before extraction"),
332
- output_format: z.enum(["text", "markdown"]).optional().default("text").describe("Output format: \"text\" (default) or \"markdown\" — use markdown for RAG workflows")
346
+ output_format: z.enum(["text", "markdown"]).optional().default("text").describe("Output format: \"text\" (default) or \"markdown\" — use markdown for RAG workflows"),
347
+ ...COMPLIANCE_PARAMS
333
348
  }
334
349
  }, withAuth("extract_text", extractTextHandler));
335
350
 
@@ -340,7 +355,8 @@ registerToolIfEnabled("extract_links", {
340
355
  inputSchema: {
341
356
  url: z.string().url().describe("The URL to extract links from"),
342
357
  filter_external: z.boolean().optional().default(false).describe("Only return external links"),
343
- base_url: z.string().url().optional().describe("Base URL for resolving relative links")
358
+ base_url: z.string().url().optional().describe("Base URL for resolving relative links"),
359
+ ...COMPLIANCE_PARAMS
344
360
  }
345
361
  }, withAuth("extract_links", extractLinksHandler));
346
362
 
@@ -349,18 +365,21 @@ registerToolIfEnabled("extract_metadata", {
349
365
  description: "Use this when you need a page's SEO metadata: title, meta description, Open Graph tags, canonical URL, schema.org data. Ideal for site audits and competitive SEO analysis. Example: extract_metadata({url: \"https://example.com\"})",
350
366
  annotations: { title: "Extract Metadata", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
351
367
  inputSchema: {
352
- url: z.string().url().describe("The URL to extract metadata from")
368
+ url: z.string().url().describe("The URL to extract metadata from"),
369
+ ...COMPLIANCE_PARAMS
353
370
  }
354
371
  }, withAuth("extract_metadata", extractMetadataHandler));
355
372
 
356
373
  // Tool: scrape_structured
357
374
  registerToolIfEnabled("scrape_structured", {
358
- description: "Use this when you know the exact CSS selectors for the data you want — e.g. scraping a pricing table or product list with consistent markup. More reliable than LLM extraction for well-structured pages. Example: scrape_structured({url: \"https://shop.com/products\", selectors: {price: \".price\", name: \".product-title\"}})",
375
+ description: "Use this when you know the exact CSS selectors for the data you want — e.g. scraping a pricing table or product list with consistent markup. More reliable than LLM extraction for well-structured pages. By default each selector is matched independently across the whole page, so the returned arrays are NOT row-aligned: data.price[0] need not belong to the same row as data.name[0]. Pass row_selector to get aligned records instead — one object per row, null for a field the row lacks. Example: scrape_structured({url: \"https://shop.com/products\", row_selector: \".product-card\", selectors: {price: \".price\", name: \".product-title\"}})",
359
376
  annotations: { title: "Scrape Structured Data", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
360
377
  inputSchema: {
361
378
  url: z.string().url().describe("The URL to scrape"),
362
379
  selectors: z.record(z.string()).describe("CSS selectors mapping field names to selectors. Append @attr to extract an attribute instead of text (e.g. \"a.link@href\", \"img@src\")"),
363
- max_results: z.number().int().min(1).optional().describe("Maximum number of matches to return per field when a selector matches multiple elements")
380
+ row_selector: z.string().optional().describe("CSS selector for the repeating row/container element. When set, each field in selectors is matched inside each row and data is an array of row-aligned records ({field: value|null}) instead of parallel arrays"),
381
+ max_results: z.number().int().min(1).optional().describe("Maximum number of matches to return per field when a selector matches multiple elements, or the maximum number of rows when row_selector is set"),
382
+ ...COMPLIANCE_PARAMS
364
383
  }
365
384
  }, withAuth("scrape_structured", scrapeStructuredHandler));
366
385
 
@@ -556,7 +575,8 @@ registerToolIfEnabled("map_site", {
556
575
  exclude_patterns: z.array(z.string()).optional()
557
576
  }).optional().describe("Per-domain allow/deny lists and URL include/exclude patterns"),
558
577
  import_filter_config: z.string().optional().describe("JSON string of a previously exported domain-filter config"),
559
- search: z.string().optional().describe("When set, rank discovered URLs by relevance to this string and emit ranked_urls:[{url,score}]")
578
+ search: z.string().optional().describe("When set, rank discovered URLs by relevance to this string and emit ranked_urls:[{url,score}]"),
579
+ ...COMPLIANCE_PARAMS
560
580
  },
561
581
  outputSchema: OUTPUT_SCHEMAS.map_site
562
582
  }, withAuth("map_site", async ({ url, include_sitemap, max_urls, group_by_path, include_metadata, domain_filter, import_filter_config, search }) => {
@@ -577,7 +597,8 @@ registerToolIfEnabled("extract_content", {
577
597
  annotations: { title: "Extract Content", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
578
598
  inputSchema: {
579
599
  url: z.string().url().describe("The URL to extract content from"),
580
- options: z.object({}).passthrough().optional().describe("Additional extraction options")
600
+ options: z.object({}).passthrough().optional().describe("Additional extraction options"),
601
+ ...COMPLIANCE_PARAMS
581
602
  }
582
603
  }, withAuth("extract_content", async ({ url, options }) => {
583
604
  try {
@@ -600,7 +621,8 @@ registerToolIfEnabled("process_document", {
600
621
  sourceType: z.enum(['url', 'pdf_url', 'file', 'pdf_file']).optional().describe("Type of document source"),
601
622
  // C3: passthrough so granular options (maxPages, pageRange:{start,end},
602
623
  // extractText, outputFormat, etc.) reach the tool instead of being stripped.
603
- options: z.object({}).passthrough().optional().describe("Additional processing options (maxPages, pageRange:{start,end}, extractText, extractMetadata, outputFormat, ...)")
624
+ options: z.object({}).passthrough().optional().describe("Additional processing options (maxPages, pageRange:{start,end}, extractText, extractMetadata, outputFormat, ...)"),
625
+ ...COMPLIANCE_PARAMS
604
626
  }
605
627
  }, withAuth("process_document", async ({ source, sourceType, options }) => {
606
628
  try {
@@ -671,7 +693,8 @@ registerToolIfEnabled("extract_structured", {
671
693
  apiKey: z.string().optional()
672
694
  }).optional().describe("LLM provider configuration for AI-powered extraction"),
673
695
  fallbackToSelectors: z.boolean().optional().default(true).describe("Fall back to CSS selector extraction if LLM is unavailable"),
674
- selectorHints: z.record(z.string()).optional().describe("CSS selector hints to guide extraction")
696
+ selectorHints: z.record(z.string()).optional().describe("CSS selector hints to guide extraction"),
697
+ ...COMPLIANCE_PARAMS
675
698
  },
676
699
  outputSchema: OUTPUT_SCHEMAS.extract_structured
677
700
  }, withAuth("extract_structured", async ({ url, schema, prompt, llmConfig, fallbackToSelectors, selectorHints }) => {
@@ -694,7 +717,8 @@ registerToolIfEnabled("extract_with_llm", {
694
717
  schema: z.record(z.unknown()).optional().describe("Optional JSON-schema for output shape (used as Ollama structured-outputs format when provider is 'ollama')"),
695
718
  provider: z.enum(["openai", "anthropic", "ollama", "auto"]).optional().default("auto").describe("LLM provider. Defaults to 'ollama' (local, no key, http://localhost:11434). Use 'openai' or 'anthropic' for cloud models (requires the matching API key)."),
696
719
  model: z.string().optional().describe("Override the model. For ollama, pass a name returned by list_ollama_models (e.g. 'llama3.2', 'qwen2.5:7b'). Defaults: openai='gpt-4o-mini', anthropic='claude-haiku-4-5-20251001', ollama='llama3.2' or $OLLAMA_DEFAULT_MODEL."),
697
- maxTokens: z.number().optional().default(4096).describe("Maximum output tokens")
720
+ maxTokens: z.number().optional().default(4096).describe("Maximum output tokens"),
721
+ ...COMPLIANCE_PARAMS
698
722
  }
699
723
  }, withAuth("extract_with_llm", async (params) => {
700
724
  try {
@@ -757,7 +781,8 @@ if (toolFilter.isEnabled("batch_scrape")) {
757
781
  ttl: z.number().min(60000).default(24 * 60 * 60 * 1000),
758
782
  maxRetries: z.number().min(0).max(5).default(1),
759
783
  tags: z.array(z.string()).default([])
760
- }).optional().describe("Job management options for async processing")
784
+ }).optional().describe("Job management options for async processing"),
785
+ ...COMPLIANCE_PARAMS
761
786
  },
762
787
  execution: TASK_EXECUTION
763
788
  }, makeTaskToolHandler({
@@ -798,12 +823,12 @@ registerToolIfEnabled("get_batch_results", {
798
823
 
799
824
  // Tool: scrape_with_actions
800
825
  registerToolIfEnabled("scrape_with_actions", {
801
- description: "Use this when you need to interact with a page before scraping — login, click buttons, fill forms, scroll, or wait for dynamic content to load. Use for SPAs, login-gated content, or multi-step flows. Screenshots from this tool are stored as crawlforge://screenshot/{actionId} resources. Example: scrape_with_actions({url: \"https://app.com/dashboard\", actions: [{type:\"click\",selector:\"#login\"},{type:\"type\",selector:\"#email\",text:\"user@a.com\"}]})",
826
+ description: "Use this when you need to interact with a page before scraping — login, click buttons, fill forms, scroll, or wait for dynamic content to load. Use for SPAs, login-gated content, or multi-step flows. Actions: wait, click, type, press, scroll, screenshot, executeJavaScript, select (dropdowns), hover, navigate. Set browserOptions.stealth:true to run the chain in the stealth browser. robots.txt is respected on every navigation. Screenshots from this tool are stored as crawlforge://screenshot/{actionId} resources. Example: scrape_with_actions({url: \"https://app.com/dashboard\", actions: [{type:\"click\",selector:\"#login\"},{type:\"type\",selector:\"#email\",text:\"user@a.com\"}]})",
802
827
  annotations: { title: "Scrape with Browser Actions", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
803
828
  inputSchema: {
804
829
  url: z.string().url().describe("The URL to scrape"),
805
830
  actions: z.array(z.object({
806
- type: z.enum(['wait', 'click', 'type', 'press', 'scroll', 'screenshot', 'executeJavaScript']),
831
+ type: z.enum(['wait', 'click', 'type', 'press', 'scroll', 'screenshot', 'executeJavaScript', 'select', 'hover', 'navigate']),
807
832
  selector: z.string().optional(),
808
833
  text: z.string().optional(),
809
834
  key: z.string().optional(),
@@ -839,7 +864,14 @@ registerToolIfEnabled("scrape_with_actions", {
839
864
  format: z.enum(['png', 'jpeg']).optional().describe("screenshot: image format"),
840
865
  // executeJavaScript
841
866
  args: z.array(z.any()).optional().describe("executeJavaScript: arguments passed to the script"),
842
- returnResult: z.boolean().optional().describe("executeJavaScript: return the script result")
867
+ returnResult: z.boolean().optional().describe("executeJavaScript: return the script result"),
868
+ // select
869
+ value: z.string().optional().describe("select: option to choose, matched by value or label"),
870
+ values: z.array(z.string()).optional().describe("select: options to choose in a multi-select, matched by value or label"),
871
+ // hover reuses click's `force` and `position`
872
+ // navigate
873
+ url: z.string().url().optional().describe("navigate: URL to navigate to — goes through the same SSRF and robots.txt gate as the initial URL"),
874
+ waitUntil: z.enum(['load', 'domcontentloaded', 'networkidle', 'commit']).optional().describe("navigate: when to consider navigation complete")
843
875
  })).min(1).max(20).describe("Browser actions to perform before scraping"),
844
876
  formats: z.array(z.enum(['markdown', 'html', 'json', 'text', 'screenshots'])).default(['json']).describe("Output formats for scraped content"),
845
877
  captureIntermediateStates: z.boolean().default(false).describe("Capture page state after each action"),
@@ -859,7 +891,8 @@ registerToolIfEnabled("scrape_with_actions", {
859
891
  userAgent: z.string().optional(),
860
892
  viewportWidth: z.number().min(800).max(1920).default(1280),
861
893
  viewportHeight: z.number().min(600).max(1080).default(720),
862
- timeout: z.number().min(10000).max(120000).default(30000)
894
+ timeout: z.number().min(10000).max(120000).default(30000),
895
+ stealth: z.boolean().default(false).describe("Run the action chain in the stealth browser (randomized fingerprint, WebRTC/canvas spoofing) instead of the standard browser pool. Renders JavaScript; it does not solve challenges.")
863
896
  }).optional().describe("Browser configuration options"),
864
897
  extractionOptions: z.object({
865
898
  selectors: z.record(z.string()).optional(),
@@ -869,7 +902,8 @@ registerToolIfEnabled("scrape_with_actions", {
869
902
  }).optional().describe("Content extraction options"),
870
903
  continueOnActionError: z.boolean().default(false).describe("Continue executing actions if one fails"),
871
904
  maxRetries: z.number().min(0).max(3).default(1).describe("Maximum retry attempts on failure"),
872
- screenshotOnError: z.boolean().default(true).describe("Capture screenshot when an error occurs")
905
+ screenshotOnError: z.boolean().default(true).describe("Capture screenshot when an error occurs"),
906
+ respect_robots: COMPLIANCE_PARAMS.respect_robots
873
907
  }
874
908
  }, withAuth("scrape_with_actions", async (params) => {
875
909
  try {
@@ -981,7 +1015,8 @@ registerToolIfEnabled("scrape", {
981
1015
  fullPage: z.boolean().optional().default(false).describe("Capture the full scrollable page"),
982
1016
  format: z.enum(["png", "jpeg"]).optional().default("png"),
983
1017
  quality: z.number().min(0).max(100).optional().describe("JPEG quality (jpeg only)")
984
- }).optional().describe("Options for the \"screenshot\" format")
1018
+ }).optional().describe("Options for the \"screenshot\" format"),
1019
+ ...COMPLIANCE_PARAMS
985
1020
  },
986
1021
  outputSchema: OUTPUT_SCHEMAS.scrape
987
1022
  }, withAuth("scrape", async (params) => {
@@ -1134,7 +1169,8 @@ registerToolIfEnabled("track_changes", {
1134
1169
  includeRecentAlerts: z.boolean().default(true),
1135
1170
  includeTrends: z.boolean().default(true),
1136
1171
  includeMonitorStatus: z.boolean().default(true)
1137
- }).optional().describe("Dashboard display options")
1172
+ }).optional().describe("Dashboard display options"),
1173
+ ...COMPLIANCE_PARAMS
1138
1174
  }
1139
1175
  }, withAuth("track_changes", async (params) => {
1140
1176
  try {
@@ -1181,12 +1217,72 @@ registerToolIfEnabled("generate_llms_txt", {
1181
1217
  }
1182
1218
  }));
1183
1219
 
1220
+ // ─── stealth_mode helpers ──────────────────────────────────────────────────────
1221
+
1222
+ /**
1223
+ * G5/G6/G7 gate for the stealth browser entry points, run BEFORE any browser
1224
+ * work so a disallowed URL never opens a context.
1225
+ *
1226
+ * Delegates to `browserPreflight`, which every browser entry point shares —
1227
+ * the stealth tool, scrape_with_actions, deep_research's stealth fallback and
1228
+ * the CLI. See that function for why the robots match is made as the canonical
1229
+ * CrawlForge product token rather than the UA the browser presents.
1230
+ *
1231
+ * @returns {Promise<string[]>} warnings to surface on the response
1232
+ */
1233
+ async function stealthComplianceGate(url, respectRobots) {
1234
+ return browserPreflight(url, { respectRobots, tool: 'stealth_mode' });
1235
+ }
1236
+
1237
+ /**
1238
+ * Build the requested formats from one stealth render. The browser already
1239
+ * returned the rendered HTML and visible text, so nothing here refetches;
1240
+ * markdown goes through the same Turndown helper the `scrape` tool uses.
1241
+ */
1242
+ function stealthScrapeFormats(formats, scraped) {
1243
+ const content = {};
1244
+ const needsDom = formats.includes('links') || formats.includes('metadata');
1245
+ const $ = needsDom ? cheerio.load(scraped.html || '') : null;
1246
+
1247
+ if (formats.includes('markdown')) content.markdown = htmlToMarkdown(scraped.html);
1248
+ if (formats.includes('html')) content.html = scraped.html;
1249
+ if (formats.includes('text')) content.text = scraped.text;
1250
+
1251
+ if (formats.includes('links')) {
1252
+ const seen = new Set();
1253
+ const links = [];
1254
+ $('a[href]').each((_, el) => {
1255
+ const href = $(el).attr('href');
1256
+ if (!href || href.startsWith('#') || href.startsWith('javascript:')) return;
1257
+ try {
1258
+ const absolute = new URL(href, scraped.url).toString();
1259
+ if (seen.has(absolute)) return;
1260
+ seen.add(absolute);
1261
+ links.push({ href: absolute, text: $(el).text().trim() });
1262
+ } catch { /* unresolvable href */ }
1263
+ });
1264
+ content.links = links;
1265
+ }
1266
+
1267
+ if (formats.includes('metadata')) {
1268
+ content.metadata = {
1269
+ title: scraped.title || $('title').text().trim() || null,
1270
+ description: $('meta[name="description"]').attr('content')
1271
+ || $('meta[property="og:description"]').attr('content') || null,
1272
+ canonical: $('link[rel="canonical"]').attr('href') || null,
1273
+ language: $('html').attr('lang') || null
1274
+ };
1275
+ }
1276
+
1277
+ return content;
1278
+ }
1279
+
1184
1280
  // Tool: stealth_mode
1185
1281
  registerToolIfEnabled("stealth_mode", {
1186
- description: "Use this when a site blocks normal scraping — Cloudflare, Datadome, or other bot-detection systems. Manages a Playwright browser with randomized fingerprints, human behavior simulation, WebRTC/canvas spoofing. Start with operation:\"create_context\" then use the contextId. Example: stealth_mode({operation:\"create_context\", stealthConfig:{level:\"advanced\", simulateHumanBehavior:true}})",
1282
+ description: "Use this when a site blocks normal scraping — Cloudflare, Datadome, or other bot-detection systems. Renders in a Playwright browser with randomized fingerprints, human behavior simulation, WebRTC/canvas spoofing. operation:\"scrape\" is the one-shot path: it creates a context, navigates, returns the requested formats and tears down. The create_context → create_page → cleanup operations remain for multi-step work. robots.txt is respected on every navigation. Example: stealth_mode({operation:\"scrape\", url:\"https://example.com\", formats:[\"markdown\",\"links\"]})",
1187
1283
  annotations: { title: "Stealth Mode", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1188
1284
  inputSchema: {
1189
- operation: z.enum(['configure', 'enable', 'disable', 'create_context', 'create_page', 'get_stats', 'cleanup']).default('configure').describe("Stealth operation to perform"),
1285
+ operation: z.enum(['scrape', 'configure', 'enable', 'disable', 'create_context', 'create_page', 'get_stats', 'cleanup']).default('configure').describe("Stealth operation to perform"),
1190
1286
  stealthConfig: z.object({
1191
1287
  level: z.enum(['basic', 'medium', 'advanced']).default('medium'),
1192
1288
  randomizeFingerprint: z.boolean().default(true),
@@ -1227,12 +1323,51 @@ registerToolIfEnabled("stealth_mode", {
1227
1323
  }).optional().describe("Stealth browser configuration with anti-detection settings"),
1228
1324
  engine: z.enum(["playwright", "camoufox"]).optional().default("playwright").describe("Browser engine: \"playwright\" (Chromium, default) or \"camoufox\" (Firefox-based, higher anti-detect score — install with npm install camoufox)"),
1229
1325
  contextId: z.string().optional().describe("Browser context ID for page operations"),
1230
- urlToTest: z.string().url().optional().describe("URL to navigate to when creating a page")
1326
+ urlToTest: z.string().url().optional().describe("URL to navigate to when creating a page"),
1327
+ url: z.string().url().optional().describe("URL to scrape — required for operation:\"scrape\""),
1328
+ formats: z.array(z.enum(["markdown", "html", "text", "links", "metadata", "screenshot"])).optional().default(["markdown"]).describe("Formats to return from operation:\"scrape\" (default: [\"markdown\"]). \"screenshot\" returns a crawlforge://screenshot/{id} resource URI."),
1329
+ wait_for: z.number().min(0).max(30000).optional().describe("Extra wait after page load, in ms — for content that renders after DOMContentLoaded"),
1330
+ verbose: z.boolean().optional().default(false).describe("Return the full generated fingerprint from create_context instead of a summary"),
1331
+ respect_robots: COMPLIANCE_PARAMS.respect_robots
1231
1332
  }
1232
- }, withAuth("stealth_mode", async ({ operation, stealthConfig, contextId, urlToTest }) => {
1333
+ }, withAuth("stealth_mode", async ({ operation, stealthConfig, contextId, urlToTest, url, formats, wait_for, verbose, engine, respect_robots }) => {
1233
1334
  try {
1234
1335
  let result;
1235
1336
  switch (operation) {
1337
+ case 'scrape': {
1338
+ if (!url) throw new Error('url is required for scrape operation');
1339
+ // Gate first: a disallowed URL must never launch a browser.
1340
+ const warnings = await stealthComplianceGate(url, respect_robots);
1341
+
1342
+ const wantsScreenshot = formats.includes('screenshot');
1343
+ const scraped = await stealthBrowserManager.scrapeWithStealth({
1344
+ url,
1345
+ // "playwright" is this tool's public name for the chromium engine
1346
+ // (the manager and the CLI both call it chromium).
1347
+ engine: engine === 'camoufox' ? 'camoufox' : 'chromium',
1348
+ wait_for: wait_for || 0,
1349
+ screenshot: wantsScreenshot,
1350
+ stealthConfig
1351
+ });
1352
+
1353
+ result = {
1354
+ success: true,
1355
+ url: scraped.url,
1356
+ title: scraped.title,
1357
+ content: stealthScrapeFormats(formats, scraped)
1358
+ };
1359
+
1360
+ // Screenshots follow the same crawlforge://screenshot/{id} pattern as
1361
+ // scrape and scrape_with_actions, so the base64 never bloats the result.
1362
+ if (wantsScreenshot && scraped.screenshot) {
1363
+ const screenshotId = `stealth_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
1364
+ resourceRegistry.storeScreenshot(screenshotId, scraped.screenshot);
1365
+ result.content.screenshot = { resourceUri: `crawlforge://screenshot/${screenshotId}` };
1366
+ }
1367
+
1368
+ if (warnings.length > 0) result.warnings = warnings;
1369
+ break;
1370
+ }
1236
1371
  case 'configure':
1237
1372
  if (stealthConfig) {
1238
1373
  const validated = stealthBrowserManager.validateConfig(stealthConfig);
@@ -1251,11 +1386,23 @@ registerToolIfEnabled("stealth_mode", {
1251
1386
  break;
1252
1387
  case 'create_context': {
1253
1388
  const contextData = await stealthBrowserManager.createStealthContext(stealthConfig);
1254
- result = { contextId: contextData.contextId, fingerprint: contextData.fingerprint, created: true };
1389
+ // The full fingerprint is ~4 KB of canvas noise arrays and WebGL
1390
+ // extension lists no caller acts on. Summarise by default; verbose:true
1391
+ // still returns all of it for debugging a detection failure.
1392
+ result = {
1393
+ contextId: contextData.contextId,
1394
+ created: true,
1395
+ fingerprint: verbose
1396
+ ? contextData.fingerprint
1397
+ : stealthBrowserManager.summarizeFingerprint(contextData.fingerprint)
1398
+ };
1255
1399
  break;
1256
1400
  }
1257
1401
  case 'create_page': {
1258
1402
  if (!contextId) throw new Error('contextId is required for create_page operation');
1403
+ // Gate before the page exists: a disallowed URL must never reach the
1404
+ // browser, and the throttle has to run before navigation, not after.
1405
+ const navWarnings = urlToTest ? await stealthComplianceGate(urlToTest, respect_robots) : [];
1259
1406
  const page = await stealthBrowserManager.createStealthPage(contextId);
1260
1407
  let navigation = null;
1261
1408
  try {
@@ -1279,6 +1426,7 @@ registerToolIfEnabled("stealth_mode", {
1279
1426
  await page.close().catch(() => {});
1280
1427
  }
1281
1428
  result = { pageCreated: true, contextId, navigation };
1429
+ if (navWarnings.length > 0) result.warnings = navWarnings;
1282
1430
  break;
1283
1431
  }
1284
1432
  case 'get_stats':
@@ -1412,7 +1560,8 @@ registerToolIfEnabled("scrape_template", {
1412
1560
  inputSchema: {
1413
1561
  template: z.string().describe("Template ID (e.g. github-repo) or list to enumerate available templates"),
1414
1562
  url: z.string().url().optional().describe("URL to scrape — required unless template is list"),
1415
- timeout: z.number().min(5000).max(60000).optional().default(15000).describe("Request timeout in milliseconds")
1563
+ timeout: z.number().min(5000).max(60000).optional().default(15000).describe("Request timeout in milliseconds"),
1564
+ ...COMPLIANCE_PARAMS
1416
1565
  }
1417
1566
  }, withAuth("scrape_template", async (params) => {
1418
1567
  try {
@@ -2,6 +2,7 @@
2
2
  * stealth command — scrape a URL using stealth mode.
3
3
  */
4
4
  import { StealthBrowserManager } from '../../core/StealthBrowserManager.js';
5
+ import { browserPreflight } from '../../utils/robotsGate.js';
5
6
  import { getToolConfig } from '../../constants/config.js';
6
7
  import { runTool } from '../lib/runTool.js';
7
8
 
@@ -17,7 +18,12 @@ export function register(program) {
17
18
  const cliFlags = { json: globals.json, pretty: globals.pretty, quiet: globals.quiet };
18
19
  const mgr = new StealthBrowserManager(getToolConfig('stealth_mode'));
19
20
  const wrapperTool = {
20
- execute: (p) => mgr.scrapeWithStealth(p)
21
+ // Same gate the stealth_mode tool runs. The CLI reaches the browser by
22
+ // a different door, not by a different set of rules.
23
+ execute: async (p) => {
24
+ await browserPreflight(p.url, { tool: 'stealth_mode' });
25
+ return mgr.scrapeWithStealth(p);
26
+ }
21
27
  };
22
28
  await runTool(wrapperTool, {
23
29
  url,
@@ -2,6 +2,7 @@ import dotenv from 'dotenv';
2
2
  import { fileURLToPath } from 'url';
3
3
  import { dirname, join } from 'path';
4
4
  import { resolveApiEndpoint } from '../core/endpointGuard.js';
5
+ import { CRAWLFORGE_USER_AGENT } from '../utils/fetchIdentity.js';
5
6
 
6
7
  // Load environment variables
7
8
  const __filename = fileURLToPath(import.meta.url);
@@ -42,7 +43,7 @@ export const config = {
42
43
  maxDepth: parseInt(process.env.MAX_CRAWL_DEPTH || '5'),
43
44
  maxPages: parseInt(process.env.MAX_PAGES_PER_CRAWL || '100'),
44
45
  respectRobots: process.env.RESPECT_ROBOTS_TXT !== 'false',
45
- userAgent: process.env.USER_AGENT || 'CrawlForge/1.0',
46
+ userAgent: process.env.USER_AGENT || CRAWLFORGE_USER_AGENT,
46
47
  timeout: parseInt(process.env.CRAWL_TIMEOUT || '30000'),
47
48
  followExternal: process.env.FOLLOW_EXTERNAL_LINKS === 'true'
48
49
  },