crawlforge-mcp-server 5.3.1 → 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  </p>
4
4
 
5
5
  <p align="center">
6
- <b>28 web scraping, crawling, deep-research &amp; autonomous-extraction tools for Claude, Cursor &amp; any MCP client.</b><br>
6
+ <b>29 web scraping, crawling, deep-research &amp; autonomous-extraction tools for Claude, Cursor &amp; any MCP client.</b><br>
7
7
  Clean Markdown &amp; structured JSON from any site. Get started with <b>1,000 free credits</b> — no credit card required.
8
8
  </p>
9
9
 
@@ -35,7 +35,7 @@
35
35
 
36
36
  ## 🎯 Why CrawlForge?
37
37
 
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.
38
+ - **29 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
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.
@@ -47,7 +47,7 @@
47
47
 
48
48
  | | **CrawlForge MCP** | Firecrawl | Raw scraping API |
49
49
  |---|:---:|:---:|:---:|
50
- | Native MCP server | ✅ 28 tools | ✅ | ❌ |
50
+ | Native MCP server | ✅ 29 tools | ✅ | ❌ |
51
51
  | Free tier | ✅ 1,000 credits, rollover | Limited | Varies |
52
52
  | Self-hosted / local LLM extraction (Ollama) | ✅ default, $0/token | ❌ | ❌ |
53
53
  | Autonomous agent (no URLs needed) | ✅ `agent` | ✅ | ❌ |
@@ -180,6 +180,7 @@ CrawlForge requires a CrawlForge API key — **every tool is metered and consume
180
180
  | `get_batch_results` | 1 | Retrieve paginated results for a `batch_scrape` job by `batchId` |
181
181
  | `scrape` | 2 | **Unified single-fetch, multi-format extraction.** Pass a `formats` array (markdown/html/rawHtml/text/links/metadata/screenshot/json-schema) plus `onlyMainContent`; one fetch serves every requested format with per-format partial-success warnings |
182
182
  | `scrape_structured` | 2 | Extract structured data with CSS selectors |
183
+ | `extract_embedded_state` | 2 | Read a page's embedded JavaScript state — `__NEXT_DATA__`, React Server Component payloads, Nuxt, Apollo, Redux, `<script type="application/json">` — with a `path` to scope the result. No LLM in the extraction path |
183
184
  | `extract_content` | 2 | Enhanced content extraction |
184
185
  | `map_site` | 2 | Discover and map website structure (optional `search=` ranks the discovered URLs) |
185
186
  | `process_document` | 2 | Multi-format document processing |
@@ -216,7 +217,7 @@ For the full canonical capabilities reference (all tools, CLI commands, stealth
216
217
  | **Business** ($399) | 250,000 / month | Large scale operations |
217
218
 
218
219
  **All plans include:**
219
- - Access to all 28 tools
220
+ - Access to all 29 tools
220
221
  - Credits never expire; paid-plan credits roll over month to month
221
222
  - API access and webhook notifications
222
223
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "5.3.1",
3
+ "version": "5.4.0",
4
4
  "mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
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.",
5
+ "description": "CrawlForge MCP Server - Professional Model Context Protocol server with 29 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",
7
7
  "bin": {
8
8
  "crawlforge": "src/cli/index.js",
@@ -114,7 +114,7 @@
114
114
  "cheerio": "^1.1.2",
115
115
  "commander": "^14.0.3",
116
116
  "compromise": "^14.14.4",
117
- "crawlforge-extractors": "^1.2.3",
117
+ "crawlforge-extractors": "^1.3.0",
118
118
  "diff": "^9.0.0",
119
119
  "dotenv": "^17.2.1",
120
120
  "franc": "^6.2.0",
package/server.js CHANGED
@@ -52,6 +52,7 @@ import { extractTextHandler } from "./src/tools/basic/extractText.js";
52
52
  import { extractLinksHandler } from "./src/tools/basic/extractLinks.js";
53
53
  import { extractMetadataHandler } from "./src/tools/basic/extractMetadata.js";
54
54
  import { scrapeStructuredHandler } from "./src/tools/basic/scrapeStructured.js";
55
+ import { extractEmbeddedStateHandler } from "./src/tools/extract/extractEmbeddedState.js";
55
56
  // D1.1 Resources + D1.2 Prompts + D1.4 Elicitation
56
57
  import { ResourceRegistry } from "./src/resources/ResourceRegistry.js";
57
58
  import { PROMPTS, getPromptMessages } from "./src/prompts/PromptRegistry.js";
@@ -105,7 +106,7 @@ const taskStore = createTaskStore({ logger });
105
106
  const server = new McpServer({
106
107
  name: "crawlforge",
107
108
  version: "5.3.1",
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.",
109
+ description: "Production-ready MCP server with 29 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, deep research, structured extraction, embedded JavaScript state 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.",
109
110
  homepage: "https://www.crawlforge.dev",
110
111
  icon: "https://www.crawlforge.dev/icon.png",
111
112
  icons: [{ src: "https://www.crawlforge.dev/icon.png", mimeType: "image/png", sizes: ["any"] }],
@@ -141,7 +142,7 @@ server.registerPrompt("getting-started", {
141
142
  role: "user",
142
143
  content: {
143
144
  type: "text",
144
- text: "You have access to CrawlForge MCP with 28 web scraping tools. Key tools:\n\n" +
145
+ text: "You have access to CrawlForge MCP with 29 web scraping tools. Key tools:\n\n" +
145
146
  "- fetch_url: Fetch raw HTML/content from any URL\n" +
146
147
  "- extract_text: Extract clean text from a webpage\n" +
147
148
  "- extract_content: Smart content extraction with readability\n" +
@@ -322,6 +323,11 @@ const COMPLIANCE_PARAMS = {
322
323
  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
  };
324
325
 
326
+ // 3.4: the two tools that let an LLM produce values share one provenance control.
327
+ const VERIFY_NUMBERS_PARAM = {
328
+ verify_numbers: z.boolean().optional().default(true).describe("Numeric provenance guard (default: true): every price or numeric value the LLM returns must appear literally in the page source, else it is returned as null with a reason in `provenance.unverified`. Set false to get the model's raw numbers back, including ones it derived (a count, a sum, a total) rather than read off the page.")
329
+ };
330
+
325
331
 
326
332
  // Tool: fetch_url
327
333
  registerToolIfEnabled("fetch_url", {
@@ -366,10 +372,22 @@ registerToolIfEnabled("extract_metadata", {
366
372
  annotations: { title: "Extract Metadata", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
367
373
  inputSchema: {
368
374
  url: z.string().url().describe("The URL to extract metadata from"),
375
+ json_ld_types: z.array(z.string()).optional().describe("Filter the returned JSON-LD to nodes of these schema.org types, e.g. [\"Product\",\"Offer\"]. Subtypes match their parent: \"Event\" returns MusicEvent, \"Offer\" returns AggregateOffer, \"ItemList\" returns BreadcrumbList. Nodes are found at any depth, including inside @graph and nested inside a parent node. When set, json_ld carries only the matching nodes instead of the raw dump, and json_ld_type_counts reports how many matched per requested type. Documented types: ItemList, Product, Offer, Event, JobPosting, RealEstateListing — any other schema.org type is matched exactly."),
369
376
  ...COMPLIANCE_PARAMS
370
377
  }
371
378
  }, withAuth("extract_metadata", extractMetadataHandler));
372
379
 
380
+ // Tool: extract_embedded_state
381
+ registerToolIfEnabled("extract_embedded_state", {
382
+ description: "Use this when a page's data lives in its embedded JavaScript state rather than its rendered HTML — Next.js (__NEXT_DATA__ and React Server Component payloads), Nuxt, Apollo, Redux (__INITIAL_STATE__, __PRELOADED_STATE__), and <script type=\"application/json\"> blocks. One fetch, exact values, no LLM in the extraction path, so nothing can be fabricated. Payloads are routinely over a megabyte — pass `path` to return one subtree instead of the whole blob. Example: extract_embedded_state({url: \"https://www.ticketmaster.com/discover/concerts\", path: \"next_data.props.pageProps\"})",
383
+ annotations: { title: "Extract Embedded State", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
384
+ inputSchema: {
385
+ url: z.string().url().describe("The URL to read embedded state from"),
386
+ path: z.string().optional().describe("Return only this subtree instead of the whole payload. Dotted keys and array indexes, e.g. \"next_data.props.pageProps\" or \"next_f[0].f\" — not JSONPath (no wildcards, filters or recursion). State payloads are routinely over a megabyte; scope them."),
387
+ ...COMPLIANCE_PARAMS
388
+ }
389
+ }, withAuth("extract_embedded_state", extractEmbeddedStateHandler));
390
+
373
391
  // Tool: scrape_structured
374
392
  registerToolIfEnabled("scrape_structured", {
375
393
  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\"}})",
@@ -579,12 +597,12 @@ registerToolIfEnabled("map_site", {
579
597
  ...COMPLIANCE_PARAMS
580
598
  },
581
599
  outputSchema: OUTPUT_SCHEMAS.map_site
582
- }, withAuth("map_site", async ({ url, include_sitemap, max_urls, group_by_path, include_metadata, domain_filter, import_filter_config, search }) => {
600
+ }, withAuth("map_site", async (params) => {
583
601
  try {
584
- if (!url) {
602
+ if (!params.url) {
585
603
  return { content: [{ type: "text", text: "URL parameter is required" }], isError: true };
586
604
  }
587
- const result = await mapSiteTool.execute({ url, include_sitemap, max_urls, group_by_path, include_metadata, domain_filter, import_filter_config, search });
605
+ const result = await mapSiteTool.execute(params);
588
606
  return dualOutput(result);
589
607
  } catch (error) {
590
608
  return { content: [{ type: "text", text: `Site mapping failed: ${error.message}` }], isError: true };
@@ -600,12 +618,12 @@ registerToolIfEnabled("extract_content", {
600
618
  options: z.object({}).passthrough().optional().describe("Additional extraction options"),
601
619
  ...COMPLIANCE_PARAMS
602
620
  }
603
- }, withAuth("extract_content", async ({ url, options }) => {
621
+ }, withAuth("extract_content", async (params) => {
604
622
  try {
605
- if (!url) {
623
+ if (!params.url) {
606
624
  return { content: [{ type: "text", text: "URL parameter is required" }], isError: true };
607
625
  }
608
- const result = await extractContentTool.execute({ url, options });
626
+ const result = await extractContentTool.execute(params);
609
627
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
610
628
  } catch (error) {
611
629
  return { content: [{ type: "text", text: `Content extraction failed: ${error.message}` }], isError: true };
@@ -624,12 +642,12 @@ registerToolIfEnabled("process_document", {
624
642
  options: z.object({}).passthrough().optional().describe("Additional processing options (maxPages, pageRange:{start,end}, extractText, extractMetadata, outputFormat, ...)"),
625
643
  ...COMPLIANCE_PARAMS
626
644
  }
627
- }, withAuth("process_document", async ({ source, sourceType, options }) => {
645
+ }, withAuth("process_document", async (params) => {
628
646
  try {
629
- if (!source) {
647
+ if (!params.source) {
630
648
  return { content: [{ type: "text", text: "Source parameter is required" }], isError: true };
631
649
  }
632
- const result = await processDocumentTool.execute({ source, sourceType, options });
650
+ const result = await processDocumentTool.execute(params);
633
651
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
634
652
  } catch (error) {
635
653
  return { content: [{ type: "text", text: `Document processing failed: ${error.message}` }], isError: true };
@@ -694,12 +712,16 @@ registerToolIfEnabled("extract_structured", {
694
712
  }).optional().describe("LLM provider configuration for AI-powered extraction"),
695
713
  fallbackToSelectors: z.boolean().optional().default(true).describe("Fall back to CSS selector extraction if LLM is unavailable"),
696
714
  selectorHints: z.record(z.string()).optional().describe("CSS selector hints to guide extraction"),
697
- ...COMPLIANCE_PARAMS
715
+ ...COMPLIANCE_PARAMS,
716
+ ...VERIFY_NUMBERS_PARAM
698
717
  },
699
718
  outputSchema: OUTPUT_SCHEMAS.extract_structured
700
- }, withAuth("extract_structured", async ({ url, schema, prompt, llmConfig, fallbackToSelectors, selectorHints }) => {
719
+ }, withAuth("extract_structured", async (params) => {
701
720
  try {
702
- const result = await extractStructuredTool.execute({ url, schema, prompt, llmConfig, fallbackToSelectors, selectorHints });
721
+ // Forward params whole. This wrapper used to destructure a fixed six, which
722
+ // silently dropped respect_robots and user_agent — both declared here and
723
+ // read by the tool, so the G5 override was accepted and ignored.
724
+ const result = await extractStructuredTool.execute(params);
703
725
  return dualOutput(result);
704
726
  } catch (error) {
705
727
  return { content: [{ type: "text", text: `Structured extraction failed: ${error.message}` }], isError: true };
@@ -718,7 +740,8 @@ registerToolIfEnabled("extract_with_llm", {
718
740
  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)."),
719
741
  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."),
720
742
  maxTokens: z.number().optional().default(4096).describe("Maximum output tokens"),
721
- ...COMPLIANCE_PARAMS
743
+ ...COMPLIANCE_PARAMS,
744
+ ...VERIFY_NUMBERS_PARAM
722
745
  }
723
746
  }, withAuth("extract_with_llm", async (params) => {
724
747
  try {
@@ -1555,11 +1578,12 @@ registerToolIfEnabled("localization", {
1555
1578
 
1556
1579
  // Tool: scrape_template (D3.3 — pre-built site templates)
1557
1580
  registerToolIfEnabled("scrape_template", {
1558
- description: "Use this when you want structured data from a well-known site without writing custom selectors. Pass template:\"list\" to see all available templates. Supports: shopify-product (any Shopify storefront, read from the store's own /products/<handle>.json rather than the rendered page), amazon-product, linkedin-profile, github-repo, youtube-video, tweet, reddit-thread, hacker-news-front-page, producthunt-launch, stackoverflow-question, npm-package (read from the npm registry API rather than the npmjs.com page, which blocks plain fetches). Example: scrape_template({template:\"github-repo\", url:\"https://github.com/user/repo\"})",
1581
+ description: "Use this when you want structured data from a well-known site or platform API without writing custom selectors. Three modes: a template id with a url (scrape_template({template:\"github-repo\", url:\"https://github.com/user/repo\"})); template:\"auto\" with a url, which picks the template from the URL and names its choice in the response; or template:\"list\" to enumerate every template with the URLs it handles. Page templates return one record — e-commerce, social, developer and news sites (shopify-product, amazon-product, github-repo, youtube-video, tweet, reddit-thread, hacker-news-front-page, producthunt-launch, stackoverflow-question, npm-package, linkedin-profile). List connectors return N records from one call and are driven by params instead of a url: job boards (Greenhouse, Lever, Ashby, Workable, Recruitee, Teamtailor) return a company's whole careers board, US government APIs (NHTSA VIN decode, NPI provider registry) answer keyless lookups, and shopify-collection returns a whole collection. Example: scrape_template({template:\"greenhouse-jobs\", params:{company:\"stripe\"}})",
1559
1582
  annotations: { title: "Scrape Template", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1560
1583
  inputSchema: {
1561
- template: z.string().describe("Template ID (e.g. github-repo) or list to enumerate available templates"),
1562
- url: z.string().url().optional().describe("URL to scrape — required unless template is list"),
1584
+ template: z.string().describe("Template ID (e.g. github-repo), \"auto\" to detect one from the url, or \"list\" to enumerate available templates"),
1585
+ url: z.string().url().optional().describe("URL to scrape — required unless template is list, or params drive a list connector"),
1586
+ params: z.record(z.any()).optional().describe("Parameters for a list connector, e.g. {company:\"stripe\"} for greenhouse-jobs or {store:\"www.allbirds.com\", collection:\"mens\"} for shopify-collection. Use template:\"list\" to see which templates take params"),
1563
1587
  timeout: z.number().min(5000).max(60000).optional().default(15000).describe("Request timeout in milliseconds"),
1564
1588
  ...COMPLIANCE_PARAMS
1565
1589
  }
@@ -8,8 +8,9 @@ import { runTool } from '../lib/runTool.js';
8
8
  export function register(program) {
9
9
  program
10
10
  .command('template [id] [target]')
11
- .description('Scrape using a pre-built site template (e.g. amazon-product, github-repo)')
11
+ .description('Scrape using a pre-built site template (e.g. amazon-product, github-repo, or auto to detect one from the URL)')
12
12
  .option('--list', 'List all available templates')
13
+ .option('--params <json>', 'JSON parameters for a list connector, e.g. \'{"company":"stripe"}\'')
13
14
  .action(async (id, target, opts, cmd) => {
14
15
  const globals = cmd.parent.opts();
15
16
  const cliFlags = { json: globals.json, pretty: globals.pretty, quiet: globals.quiet };
@@ -21,11 +22,21 @@ export function register(program) {
21
22
  return;
22
23
  }
23
24
 
24
- if (!id || !target) {
25
- process.stderr.write('Error: template requires <id> and <target>, or use --list\n');
25
+ let params;
26
+ if (opts.params) {
27
+ try {
28
+ params = JSON.parse(opts.params);
29
+ } catch (e) {
30
+ process.stderr.write(`Error parsing --params JSON: ${e.message}\n`);
31
+ process.exit(1);
32
+ }
33
+ }
34
+
35
+ if (!id || (!target && !params)) {
36
+ process.stderr.write('Error: template requires <id> and <target>, or <id> with --params, or use --list\n');
26
37
  process.exit(1);
27
38
  }
28
39
 
29
- await runTool(tool, { template: id, url: target }, cliFlags);
40
+ await runTool(tool, { template: id, url: target, params }, cliFlags);
30
41
  });
31
42
  }
@@ -562,6 +562,7 @@ class AuthManager {
562
562
  process_document: 2,
563
563
  localization: 2,
564
564
  scrape: 2,
565
+ extract_embedded_state: 2,
565
566
  reddit_search: 5, // a Reddit-wide search spends a web search to discover posts, same as search_web
566
567
 
567
568
  // 3 credits
@@ -259,6 +259,17 @@ const extractStructuredShape = {
259
259
  errors: z.array(z.string()).optional()
260
260
  }).passthrough().optional(),
261
261
  extractionNotes: z.array(z.string()).optional(),
262
+ provenance: z.object({
263
+ enabled: z.boolean().optional().describe('Whether the numeric provenance guard ran'),
264
+ verified: z.number().optional().describe('Numeric values found literally in the page source'),
265
+ nulled: z.number().optional().describe('Numeric values replaced with null because the source does not contain them'),
266
+ unverified: z.array(z.object({
267
+ path: z.string().optional().describe('Path to the field, e.g. configurations[2].price'),
268
+ value: z.unknown().optional().describe('The value that was removed'),
269
+ reason: z.string().optional().describe('"not_found_in_source"')
270
+ }).passthrough()).optional(),
271
+ skipped: z.string().optional().describe('"empty_source" when there was nothing to check against')
272
+ }).passthrough().optional(),
262
273
  _cost: costShape
263
274
  };
264
275
 
@@ -8,7 +8,7 @@ metadata:
8
8
 
9
9
  # CrawlForge: Getting Started
10
10
 
11
- CrawlForge is an MCP server with **28 tools** for web scraping, crawling,
11
+ CrawlForge is an MCP server with **29 tools** for web scraping, crawling,
12
12
  extraction, research, change tracking, and AI-compliance. This skill orients you
13
13
  and routes each request to the right specialized skill.
14
14
 
@@ -52,13 +52,13 @@ stored at `~/.crawlforge/config.json`.
52
52
  | Watch a page for changes / monitor pricing | **crawlforge-change-tracking** |
53
53
  | Scrape many URLs, run browser actions, generate llms.txt | **crawlforge-batch-automation** |
54
54
 
55
- ## The 28 tools at a glance
55
+ ## The 29 tools at a glance
56
56
 
57
57
  - **Basic (5):** fetch_url, extract_text, extract_links, extract_metadata, scrape_structured
58
58
  - **Unified (1):** scrape (multi-format single fetch)
59
59
  - **Search & research (5):** search_web, serp_rank, reddit_search, deep_research, agent
60
60
  - **Crawl (2):** crawl_deep, map_site
61
- - **Extract & analyze (7):** extract_content, process_document, summarize_content, analyze_content, extract_structured, extract_with_llm, list_ollama_models
61
+ - **Extract & analyze (8):** extract_content, process_document, summarize_content, analyze_content, extract_structured, extract_with_llm, extract_embedded_state, list_ollama_models
62
62
  - **Batch & automation (4):** batch_scrape, get_batch_results, scrape_with_actions, generate_llms_txt
63
63
  - **Stealth & locale (2):** stealth_mode, localization
64
64
  - **Templates & tracking (2):** scrape_template, track_changes
@@ -21,6 +21,7 @@ metered; there is no free tier. Tools marked "scales" cost more as work grows.
21
21
  |------|-------|
22
22
  | `scrape` | Unified multi-format single fetch. |
23
23
  | `scrape_structured` | CSS-selector extraction. |
24
+ | `extract_embedded_state` | Embedded JS state (`__NEXT_DATA__`, RSC, Nuxt, Apollo, Redux). |
24
25
  | `extract_content` | Readability-cleaned article. |
25
26
  | `map_site` | URL discovery / sitemap. |
26
27
  | `process_document` | PDF / DOCX / TXT parsing. |
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: crawlforge-structured-extraction
3
- description: "Extracts structured JSON and analyzes content with CrawlForge's extract_structured, extract_with_llm, scrape_structured, scrape_template, process_document, analyze_content, summarize_content, and list_ollama_models tools. Use when the user wants to extract specific fields, pull data into a JSON schema, extract by natural-language prompt, scrape with CSS selectors, get product, profile, or repo data from known sites (Amazon, LinkedIn, GitHub, YouTube, Reddit, and more), parse a PDF or DOCX, summarize a page, or analyze sentiment, entities, or keywords. Defaults to local Ollama for LLM extraction; OpenAI and Anthropic optional."
3
+ description: "Extracts structured JSON and analyzes content with CrawlForge's extract_structured, extract_with_llm, extract_embedded_state, scrape_structured, scrape_template, process_document, analyze_content, summarize_content, and list_ollama_models tools. Use when the user wants to extract specific fields, pull data into a JSON schema, extract by natural-language prompt, scrape with CSS selectors, read a page's embedded JavaScript state (__NEXT_DATA__, React Server Components, Nuxt, Apollo, Redux), get product, profile, or repo data from known sites (Amazon, LinkedIn, GitHub, YouTube, Reddit, and more), parse a PDF or DOCX, summarize a page, or analyze sentiment, entities, or keywords. Defaults to local Ollama for LLM extraction; OpenAI and Anthropic optional."
4
4
  metadata:
5
5
  version: 4.8.0
6
6
  source: crawlforge-mcp-server
@@ -17,6 +17,7 @@ extraction method by how predictable the page is and whether an LLM is needed.
17
17
  |-----------------|------|------|
18
18
  | A well-known site (Amazon, GitHub, LinkedIn...) | `scrape_template` | 1 |
19
19
  | Exact CSS selectors for the fields | `scrape_structured` | 2 |
20
+ | The data is in the page's JS state, not its HTML | `extract_embedded_state` | 2 |
20
21
  | A JSON schema to fill (LLM, CSS fallback) | `extract_structured` | 3 |
21
22
  | A natural-language extraction instruction | `extract_with_llm` | 3 |
22
23
  | A PDF / DOCX / TXT to parse | `process_document` | 2 |
@@ -24,8 +25,26 @@ extraction method by how predictable the page is and whether an LLM is needed.
24
25
  | Sentiment / entities / keywords / readability | `analyze_content` | 3 |
25
26
  | To list local LLMs available for extraction | `list_ollama_models` | 1 |
26
27
 
27
- Cheapest-first rule: try `scrape_template` → `scrape_structured` (deterministic)
28
- before reaching for the LLM tools.
28
+ Cheapest-first rule: try `scrape_template` → `scrape_structured` /
29
+ `extract_embedded_state` (all deterministic) before reaching for the LLM tools.
30
+ On a React/Next/Nuxt page the values are usually sitting in the embedded state
31
+ already, exact and typed — that beats asking a model to read them off the render.
32
+
33
+ ## extract_embedded_state — the page's own JS state (cost: 2)
34
+
35
+ ```json
36
+ { "tool": "extract_embedded_state", "params": { "url": "https://www.ticketmaster.com/discover/concerts", "path": "next_data.props.pageProps" } }
37
+ ```
38
+
39
+ Finds `__NEXT_DATA__`, `self.__next_f` (React Server Component payloads),
40
+ `window.__NUXT__`, `__APOLLO_STATE__`, `__INITIAL_STATE__`, `__PRELOADED_STATE__`
41
+ and `<script type="application/json">` blocks, keyed by source name. No LLM in
42
+ the path, so values are exact rather than inferred.
43
+
44
+ These payloads are routinely over a megabyte — pass `path` (dotted keys and
45
+ array indexes, e.g. `next_data.props.pageProps` or `next_f[0].f`) to return one
46
+ subtree. Without it, a large result comes back with a warning naming the biggest
47
+ source and a ready-to-paste path.
29
48
 
30
49
  ## scrape_template — known sites, zero selectors (cost: 1)
31
50
 
@@ -2,28 +2,12 @@
2
2
  * extract_metadata — Extract page metadata (title, description, OG tags, etc.).
3
3
  * Extracted from server.js inline handler.
4
4
  * B1: Parse JSON-LD and microdata; stronger title fallback chain (og:title → <title> → h1).
5
+ * 3.3: json_ld_types promotes JSON-LD from a raw dump to a filtered extraction path.
5
6
  */
6
7
 
7
8
  import { load } from 'cheerio';
8
9
  import { fetchWithTimeout } from './_fetch.js';
9
-
10
- /**
11
- * Parse all JSON-LD blocks from the document.
12
- * @param {import('cheerio').CheerioAPI} $
13
- * @returns {Array}
14
- */
15
- function parseJsonLd($) {
16
- const results = [];
17
- $('script[type="application/ld+json"]').each((_, el) => {
18
- try {
19
- const raw = $(el).html();
20
- if (raw) results.push(JSON.parse(raw));
21
- } catch {
22
- // Skip invalid blocks
23
- }
24
- });
25
- return results;
26
- }
10
+ import { parseJsonLd, filterJsonLdByType } from '../../utils/jsonLd.js';
27
11
 
28
12
  /**
29
13
  * Parse microdata items (elements with itemscope).
@@ -60,9 +44,10 @@ function parseMicrodata($) {
60
44
  }
61
45
 
62
46
  /**
63
- * @param {{ url: string, user_agent?: string, respect_robots?: boolean }} params
47
+ * @param {{ url: string, user_agent?: string, respect_robots?: boolean,
48
+ * json_ld_types?: string[] }} params
64
49
  */
65
- export async function extractMetadataHandler({ url, user_agent, respect_robots }) {
50
+ export async function extractMetadataHandler({ url, user_agent, respect_robots, json_ld_types }) {
66
51
  try {
67
52
  const response = await fetchWithTimeout(url, {
68
53
  userAgent: user_agent,
@@ -115,25 +100,33 @@ export async function extractMetadataHandler({ url, user_agent, respect_robots }
115
100
  const jsonLd = parseJsonLd($);
116
101
  const microdata = parseMicrodata($);
117
102
 
103
+ const result = {
104
+ title,
105
+ description,
106
+ keywords: keywords.split(',').map(k => k.trim()).filter(Boolean),
107
+ canonical_url: canonical,
108
+ author,
109
+ robots,
110
+ viewport,
111
+ charset,
112
+ og_tags: ogTags,
113
+ twitter_tags: twitterTags,
114
+ json_ld: jsonLd,
115
+ microdata,
116
+ url: response.url
117
+ };
118
+
119
+ // With a type filter, json_ld carries only the matching nodes — returning
120
+ // the raw dump as well would double the payload on the large pages that
121
+ // make filtering worth asking for.
122
+ if (json_ld_types?.length) {
123
+ const { items, counts } = filterJsonLdByType(jsonLd, json_ld_types);
124
+ result.json_ld = items;
125
+ result.json_ld_type_counts = counts;
126
+ }
127
+
118
128
  return {
119
- content: [{
120
- type: 'text',
121
- text: JSON.stringify({
122
- title,
123
- description,
124
- keywords: keywords.split(',').map(k => k.trim()).filter(Boolean),
125
- canonical_url: canonical,
126
- author,
127
- robots,
128
- viewport,
129
- charset,
130
- og_tags: ogTags,
131
- twitter_tags: twitterTags,
132
- json_ld: jsonLd,
133
- microdata,
134
- url: response.url
135
- }, null, 2)
136
- }]
129
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
137
130
  };
138
131
  } catch (error) {
139
132
  return {
@@ -0,0 +1,72 @@
1
+ /**
2
+ * extract_embedded_state — return the JSON state a page already ships in its
3
+ * own HTML: __NEXT_DATA__, RSC flight chunks (self.__next_f), __NUXT__,
4
+ * __APOLLO_STATE__, __INITIAL_STATE__, __PRELOADED_STATE__ and
5
+ * <script type="application/json"> blocks.
6
+ *
7
+ * One fetch, exact values, no LLM in the extraction path — the numbers come
8
+ * from the site's own serialized state, so they cannot be fabricated.
9
+ */
10
+
11
+ import { fetchAndParse } from './_fetchAndParse.js';
12
+ import { extractEmbeddedState } from '../../utils/embeddedState.js';
13
+ import { selectJsonPath } from '../../utils/jsonPath.js';
14
+
15
+ // Above this, an unscoped result is big enough to be a problem for the caller
16
+ // (context window, transport) rather than just large. Warn — never truncate:
17
+ // a half-serialized object is worse than a big one, and `path` already gives
18
+ // the caller an exact way to ask for less.
19
+ const LARGE_RESULT_BYTES = 256_000;
20
+
21
+ /**
22
+ * @param {{ url: string, path?: string, user_agent?: string, respect_robots?: boolean }} params
23
+ */
24
+ export async function extractEmbeddedStateHandler({ url, path, user_agent, respect_robots }) {
25
+ try {
26
+ // The raw `html` is used, not `$`: fetchAndParse strips <script> from the
27
+ // parsed tree by default, and every source here lives in a script tag.
28
+ const { html, finalUrl, warnings: fetchWarnings } = await fetchAndParse(url, {
29
+ userAgent: user_agent,
30
+ respectRobots: respect_robots,
31
+ tool: 'extract_embedded_state'
32
+ });
33
+
34
+ const state = extractEmbeddedState(html);
35
+ const warnings = [...fetchWarnings, ...state.warnings];
36
+
37
+ if (state.found.length === 0) {
38
+ warnings.push(
39
+ 'No embedded state found. The page may render entirely on the client, or ship its data in a format this tool does not read.'
40
+ );
41
+ }
42
+
43
+ const data = path ? selectJsonPath(state.data, path) : state.data;
44
+ const bytes = Buffer.byteLength(JSON.stringify(data) ?? '');
45
+
46
+ if (!path && bytes > LARGE_RESULT_BYTES) {
47
+ const largest = state.found.reduce((a, b) => (b.bytes > a.bytes ? b : a));
48
+ warnings.push(
49
+ `Result is ${bytes} bytes; "${largest.name}" alone is ${largest.bytes}. Re-run with path to scope it, e.g. path:"${largest.name}.${Object.keys(state.data[largest.name])[0]}".`
50
+ );
51
+ }
52
+
53
+ return {
54
+ content: [{
55
+ type: 'text',
56
+ text: JSON.stringify({
57
+ url: finalUrl,
58
+ found: state.found,
59
+ path: path || null,
60
+ bytes,
61
+ data,
62
+ warnings
63
+ }, null, 2)
64
+ }]
65
+ };
66
+ } catch (error) {
67
+ return {
68
+ content: [{ type: 'text', text: `Failed to extract embedded state: ${error.message}` }],
69
+ isError: true
70
+ };
71
+ }
72
+ }
@@ -11,6 +11,7 @@ import { LLMManager } from '../../core/llm/LLMManager.js';
11
11
  import { CRAWLFORGE_USER_AGENT } from '../../utils/fetchIdentity.js';
12
12
  import { fetchAndParse, flattenBodyText } from './_fetchAndParse.js';
13
13
  import { extractMainContent } from '../scrape/_mainContent.js';
14
+ import { verifyNumericProvenance } from '../../utils/provenance.js';
14
15
 
15
16
  // Semantic element selectors for well-known field names, tried as a last
16
17
  // resort in the CSS fallback so common fields (e.g. "title") still resolve when
@@ -79,7 +80,8 @@ const ExtractStructuredSchema = z.object({
79
80
  fallbackToSelectors: z.boolean().optional().default(true),
80
81
  selectorHints: z.record(z.string()).optional(),
81
82
  respect_robots: z.boolean().optional(),
82
- user_agent: z.string().optional()
83
+ user_agent: z.string().optional(),
84
+ verify_numbers: z.boolean().optional().default(true)
83
85
  });
84
86
 
85
87
  export class ExtractStructuredTool {
@@ -136,7 +138,7 @@ export class ExtractStructuredTool {
136
138
 
137
139
  try {
138
140
  const validated = ExtractStructuredSchema.parse(params);
139
- const { url, schema, prompt, llmConfig, fallbackToSelectors, selectorHints, respect_robots, user_agent } = validated;
141
+ const { url, schema, prompt, llmConfig, fallbackToSelectors, selectorHints, respect_robots, user_agent, verify_numbers } = validated;
140
142
 
141
143
  // Step 1: Fetch and parse — shared helper strips scripts/styles/iframes/svgs
142
144
  const { html, $, textContent, warnings } = await fetchAndParse(url, {
@@ -180,6 +182,43 @@ export class ExtractStructuredTool {
180
182
  llmErrorMessage = llmError.message;
181
183
  }
182
184
 
185
+ // Step 3b (3.4): numeric provenance. Only the LLM path invents numbers —
186
+ // the CSS and keyword fallbacks can only return text they read off the
187
+ // page — so the guard is scoped to it.
188
+ //
189
+ // It is checked against the FULL source, never `mainContentText()`: on
190
+ // the Apple MacBook Air page Readability keeps the FAQ block and every
191
+ // price is left behind in an embedded JSON blob, so checking against what
192
+ // the model was shown would null every correct price.
193
+ let provenance = { enabled: false };
194
+ if (extractionResult && extractionMethod === 'llm' && verify_numbers) {
195
+ const checked = verifyNumericProvenance(extractionResult.data || {}, `${html}\n${textContent}`);
196
+ // The model's own `valid` flag described the data before the guard ran.
197
+ // A required field the guard nulled is not filled in any more, so that
198
+ // flag cannot stand or the response reports a fabrication as valid.
199
+ const nulledRequired = checked.unverified
200
+ .map((entry) => entry.path)
201
+ .filter((path) => (schema.required || []).includes(path));
202
+ extractionResult = {
203
+ ...extractionResult,
204
+ data: checked.data,
205
+ ...(nulledRequired.length > 0 ? {
206
+ valid: false,
207
+ validationErrors: [
208
+ ...(extractionResult.validationErrors || []),
209
+ ...nulledRequired.map((field) => `Field "${field}" was not found in the page source`)
210
+ ]
211
+ } : {})
212
+ };
213
+ provenance = {
214
+ enabled: true,
215
+ verified: checked.verified,
216
+ nulled: checked.nulled,
217
+ unverified: checked.unverified
218
+ };
219
+ if (checked.skipped) provenance.skipped = checked.skipped;
220
+ }
221
+
183
222
  // Step 4: CSS selector fallback if LLM unavailable or failed
184
223
  if (!extractionResult && fallbackToSelectors !== false) {
185
224
  // D1.4: no LLM configured and the schema demands more than 3 required
@@ -223,6 +262,12 @@ export class ExtractStructuredTool {
223
262
  if (llmErrorMessage) {
224
263
  extractionNotes.push(`LLM extraction failed: ${llmErrorMessage}`);
225
264
  }
265
+ if (provenance.nulled > 0) {
266
+ extractionNotes.push(
267
+ `Numeric provenance: ${provenance.nulled} value(s) the model returned are not in the page source and were replaced with null: ` +
268
+ provenance.unverified.map((u) => `${u.path}=${JSON.stringify(u.value)}`).join(', ')
269
+ );
270
+ }
226
271
 
227
272
  // A required field that came back missing or empty is a failed
228
273
  // extraction, not a successful one carrying a note: surface it at the
@@ -249,6 +294,7 @@ export class ExtractStructuredTool {
249
294
  errors: extractionResult.validationErrors || []
250
295
  },
251
296
  extractionNotes,
297
+ provenance,
252
298
  ...(warnings?.length ? { warnings } : {})
253
299
  };
254
300