crawlforge-mcp-server 5.3.0 → 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 +7 -4
- package/package.json +4 -3
- package/server.js +43 -19
- package/src/cli/commands/template.js +15 -4
- package/src/core/AuthManager.js +1 -0
- package/src/core/ResearchOrchestrator.js +16 -22
- package/src/core/llm/LLMManager.js +26 -3
- package/src/core/llm/OllamaProvider.js +14 -5
- package/src/schemas/toolOutputSchemas.js +11 -0
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +5 -0
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +3 -3
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -0
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +22 -3
- package/src/tools/basic/extractMetadata.js +31 -38
- package/src/tools/extract/extractEmbeddedState.js +72 -0
- package/src/tools/extract/extractStructured.js +48 -2
- package/src/tools/extract/extractWithLlm.js +29 -2
- package/src/tools/templates/ScrapeTemplateTool.js +107 -18
- package/src/utils/embeddedState.js +304 -0
- package/src/utils/jsonLd.js +122 -0
- package/src/utils/jsonPath.js +80 -0
- package/src/utils/ollamaConfig.js +36 -2
- package/src/utils/provenance.js +197 -0
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
|
-
<b>
|
|
6
|
+
<b>29 web scraping, crawling, deep-research & autonomous-extraction tools for Claude, Cursor & any MCP client.</b><br>
|
|
7
7
|
Clean Markdown & 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
|
-
- **
|
|
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 | ✅
|
|
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
|
|
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
|
|
|
@@ -238,6 +239,8 @@ export CRAWLFORGE_API_URL="https://api.crawlforge.dev"
|
|
|
238
239
|
# and deep_research all use Ollama when no cloud key is set
|
|
239
240
|
export OLLAMA_BASE_URL="http://localhost:11434" # default; set https://ollama.com for Ollama Cloud
|
|
240
241
|
export OLLAMA_DEFAULT_MODEL="gemma3:4b" # optional; unset = pick the best installed model automatically
|
|
242
|
+
# deep_research judges claims with gemma3:12b when it is installed (ollama pull gemma3:12b);
|
|
243
|
+
# conflict detection is on only with that model, or a cloud provider
|
|
241
244
|
export OLLAMA_EMBEDDING_MODEL="nomic-embed-text" # default: OLLAMA_DEFAULT_MODEL; used for semantic ranking in deep_research
|
|
242
245
|
export OLLAMA_API_KEY="..." # only for authenticated endpoints (required by Ollama Cloud; a local instance needs none)
|
|
243
246
|
export DISABLE_OLLAMA="true" # skip Ollama entirely and use CSS/keyword fallbacks
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "5.
|
|
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
|
|
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",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"test:real-world": "node test-real-world.js",
|
|
24
24
|
"test:all": "bash run-all-tests.sh",
|
|
25
25
|
"skills:gen": "node scripts/generate-skill-md.mjs",
|
|
26
|
+
"sweep": "node scripts/tool-sweep.mjs",
|
|
26
27
|
"postinstall": "echo '\nCrawlForge MCP Server installed!\n\nQuick start: run \"npx crawlforge init\" to configure your API key, install skills, and register the MCP server with your AI clients.\nOr run \"npx crawlforge-setup\" to configure your API key only.\n'",
|
|
27
28
|
"docker:build": "docker build -t crawlforge .",
|
|
28
29
|
"docker:dev": "docker-compose up crawlforge-dev",
|
|
@@ -113,7 +114,7 @@
|
|
|
113
114
|
"cheerio": "^1.1.2",
|
|
114
115
|
"commander": "^14.0.3",
|
|
115
116
|
"compromise": "^14.14.4",
|
|
116
|
-
"crawlforge-extractors": "^1.
|
|
117
|
+
"crawlforge-extractors": "^1.3.0",
|
|
117
118
|
"diff": "^9.0.0",
|
|
118
119
|
"dotenv": "^17.2.1",
|
|
119
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";
|
|
@@ -104,8 +105,8 @@ const taskStore = createTaskStore({ logger });
|
|
|
104
105
|
// Create the server
|
|
105
106
|
const server = new McpServer({
|
|
106
107
|
name: "crawlforge",
|
|
107
|
-
version: "5.3.
|
|
108
|
-
description: "Production-ready MCP server with
|
|
108
|
+
version: "5.3.1",
|
|
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
|
|
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 (
|
|
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(
|
|
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 (
|
|
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(
|
|
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 (
|
|
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(
|
|
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 (
|
|
719
|
+
}, withAuth("extract_structured", async (params) => {
|
|
701
720
|
try {
|
|
702
|
-
|
|
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.
|
|
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
|
-
|
|
25
|
-
|
|
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
|
}
|
package/src/core/AuthManager.js
CHANGED
|
@@ -49,26 +49,17 @@ const MAX_FINDING_SHARE_PER_SOURCE = 0.4;
|
|
|
49
49
|
// many findings have to be diverse before depth matters.
|
|
50
50
|
const SUMMARY_SLICE = 5;
|
|
51
51
|
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
// pairs
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
// Zero conflicts is the honest answer: a research tool that invents
|
|
64
|
-
// contradictions between sources that agree is worse than one that reports
|
|
65
|
-
// none. Semantic grouping (which this replaced the lexical key with) DID make
|
|
66
|
-
// detection structurally reachable — that half of the question is answered —
|
|
67
|
-
// and consensus, which needed the same grouping, now works. The judgement
|
|
68
|
-
// itself lives in LLMManager.findContradictions, is unit-tested, and becomes
|
|
69
|
-
// useful the moment a model that can do natural-language inference is wired
|
|
70
|
-
// in; a purpose-built NLI cross-encoder is the documented next step.
|
|
71
|
-
const ENABLE_LLM_CONFLICT_DETECTION = false;
|
|
52
|
+
// Conflict detection runs only when the judging model is one measured not to
|
|
53
|
+
// invent disagreement. The default 4B local model, measured 2026-08-28 against
|
|
54
|
+
// a live run's own claims, named 29, 13 and 28 non-contradictions at batch
|
|
55
|
+
// sizes 30, 8 and 1; the consistency-veto control cut that to 7 but then
|
|
56
|
+
// missed "X does not use Y" against "X uses Y" outright. Replaying the same
|
|
57
|
+
// claims through gemma3:12b (three runs): 0 false contradictions on 27 real
|
|
58
|
+
// pairs and every planted one caught. So the gate is the model, not a flag:
|
|
59
|
+
// LLMManager.canJudgeContradictions() answers from JUDGEMENT_MODELS, and a
|
|
60
|
+
// machine without such a model reports zero conflicts — the honest answer,
|
|
61
|
+
// since a research tool that invents contradictions between sources that
|
|
62
|
+
// agree is worse than one that reports none.
|
|
72
63
|
|
|
73
64
|
// Contradiction checking is quadratic in a group's size, and every candidate
|
|
74
65
|
// pair costs prompt tokens in the one batched call. Compare a group's most
|
|
@@ -1441,8 +1432,8 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
1441
1432
|
* sentence-shape repair for that, so there is no fallback path here.
|
|
1442
1433
|
*/
|
|
1443
1434
|
async detectInformationConflicts(claimGroups, topic) {
|
|
1444
|
-
if (!ENABLE_LLM_CONFLICT_DETECTION) return [];
|
|
1445
1435
|
if (!this.enableLLMFeatures) return [];
|
|
1436
|
+
if (!(await this.llmManager.canJudgeContradictions())) return [];
|
|
1446
1437
|
|
|
1447
1438
|
const pairs = [];
|
|
1448
1439
|
for (const group of claimGroups) {
|
|
@@ -1469,7 +1460,10 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
1469
1460
|
try {
|
|
1470
1461
|
contradicting = await this.llmManager.findContradictions(
|
|
1471
1462
|
candidates.map(({ a, b }) => ({ a: a.claim, b: b.claim })),
|
|
1472
|
-
topic
|
|
1463
|
+
topic,
|
|
1464
|
+
// The judge's own default examined 30; every candidate formed here is
|
|
1465
|
+
// meant to be judged, so the caps agree.
|
|
1466
|
+
{ maxPairs: MAX_CONFLICT_PAIRS }
|
|
1473
1467
|
);
|
|
1474
1468
|
this.metrics.llmAnalysisCalls++;
|
|
1475
1469
|
} catch (error) {
|
|
@@ -2,6 +2,7 @@ import { OpenAIProvider } from './OpenAIProvider.js';
|
|
|
2
2
|
import { AnthropicProvider } from './AnthropicProvider.js';
|
|
3
3
|
import { OllamaProvider } from './OllamaProvider.js';
|
|
4
4
|
import { Logger } from '../../utils/Logger.js';
|
|
5
|
+
import { isJudgementModel } from '../../utils/ollamaConfig.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* LLM Manager
|
|
@@ -136,6 +137,25 @@ export class LLMManager {
|
|
|
136
137
|
}
|
|
137
138
|
}
|
|
138
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Whether conflict detection may run: only a model measured not to invent
|
|
142
|
+
* contradictions between sources that agree is asked (JUDGEMENT_MODELS in
|
|
143
|
+
* ollamaConfig.js). A cloud provider is assumed capable — the measurement
|
|
144
|
+
* that gated this off was of a 4B local model, and cloud models were not
|
|
145
|
+
* measured; that assumption is deliberate. A pinned OLLAMA_DEFAULT_MODEL is
|
|
146
|
+
* judged by the same list, so pinning the extraction winner keeps the gate
|
|
147
|
+
* closed rather than routing around the measurement.
|
|
148
|
+
*/
|
|
149
|
+
async canJudgeContradictions() {
|
|
150
|
+
if (!this.defaultProvider) return false;
|
|
151
|
+
if (this.defaultProvider !== 'ollama') return true;
|
|
152
|
+
try {
|
|
153
|
+
return isJudgementModel(await this.getProvider('ollama').resolveModel('judgement'));
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
139
159
|
/**
|
|
140
160
|
* Generate embeddings with fallback support
|
|
141
161
|
*/
|
|
@@ -503,7 +523,8 @@ Rate these ${batch.length} sentences:`;
|
|
|
503
523
|
systemPrompt,
|
|
504
524
|
maxTokens: 100 + batch.length * 20,
|
|
505
525
|
temperature: 0.1,
|
|
506
|
-
format: scoreSchema
|
|
526
|
+
format: scoreSchema,
|
|
527
|
+
role: 'judgement'
|
|
507
528
|
});
|
|
508
529
|
|
|
509
530
|
const cleaned = response.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '').trim();
|
|
@@ -633,7 +654,8 @@ Group these ${batch.length} sentences:`;
|
|
|
633
654
|
systemPrompt,
|
|
634
655
|
maxTokens: 200 + batch.length * 10,
|
|
635
656
|
temperature: 0.1,
|
|
636
|
-
format: groupSchema
|
|
657
|
+
format: groupSchema,
|
|
658
|
+
role: 'judgement'
|
|
637
659
|
});
|
|
638
660
|
|
|
639
661
|
const cleaned = response.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '').trim();
|
|
@@ -805,7 +827,8 @@ ${question.replace('${n}', String(batch.length))}`;
|
|
|
805
827
|
systemPrompt,
|
|
806
828
|
maxTokens: 100 + batch.length * 6,
|
|
807
829
|
temperature: 0.1,
|
|
808
|
-
format: schema
|
|
830
|
+
format: schema,
|
|
831
|
+
role: 'judgement'
|
|
809
832
|
});
|
|
810
833
|
|
|
811
834
|
const cleaned = response.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '').trim();
|
|
@@ -14,28 +14,37 @@ export class OllamaProvider extends LLMProvider {
|
|
|
14
14
|
super(options);
|
|
15
15
|
|
|
16
16
|
// Resolved lazily: choosing the best installed model needs an HTTP call.
|
|
17
|
+
// An explicit model applies to every role; otherwise each role resolves
|
|
18
|
+
// (and caches) its own choice.
|
|
17
19
|
this.model = options.model || null;
|
|
20
|
+
this.modelByRole = new Map();
|
|
18
21
|
this.embeddingModel = options.embeddingModel || process.env.OLLAMA_EMBEDDING_MODEL || null;
|
|
19
22
|
this.timeout = options.timeout || 120000;
|
|
20
23
|
}
|
|
21
24
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
/**
|
|
26
|
+
* The model to use, selecting the best installed one for the role on first
|
|
27
|
+
* use. Extraction and judgement have different winners — see JUDGEMENT_MODELS.
|
|
28
|
+
* @param {'default'|'judgement'} [role]
|
|
29
|
+
*/
|
|
30
|
+
async resolveModel(role = 'default') {
|
|
31
|
+
if (this.model) return this.model;
|
|
32
|
+
if (!this.modelByRole.has(role)) this.modelByRole.set(role, await selectOllamaModel(role));
|
|
33
|
+
return this.modelByRole.get(role);
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
async generateCompletion(prompt, options = {}) {
|
|
29
|
-
const model = await this.resolveModel();
|
|
30
37
|
const {
|
|
31
38
|
maxTokens = 1000,
|
|
32
39
|
temperature = 0.7,
|
|
33
40
|
systemPrompt = null,
|
|
41
|
+
role = 'default',
|
|
34
42
|
// 'json' constrains the model to emit a parseable object, or pass a JSON
|
|
35
43
|
// Schema to constrain the shape as well. Small local models otherwise
|
|
36
44
|
// wrap JSON in prose and the caller's JSON.parse fails.
|
|
37
45
|
format = null
|
|
38
46
|
} = options;
|
|
47
|
+
const model = await this.resolveModel(role);
|
|
39
48
|
|
|
40
49
|
const messages = [];
|
|
41
50
|
if (systemPrompt) {
|
|
@@ -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
|
|
|
@@ -127,5 +127,10 @@ With no LLM configured, `deep_research` returns structured **raw evidence** for
|
|
|
127
127
|
the calling assistant (e.g. Claude Code) to synthesize — this is expected, do
|
|
128
128
|
not suggest adding API keys.
|
|
129
129
|
|
|
130
|
+
On local Ollama, claim judgement (relevance, grouping, contradiction) uses
|
|
131
|
+
`gemma3:12b` when it is installed and the extraction model otherwise; conflicts
|
|
132
|
+
are reported only with that model or a cloud provider, so `conflictsFound: 0`
|
|
133
|
+
on a machine without it is expected, not a failure.
|
|
134
|
+
|
|
130
135
|
See [research workflows](references/workflows.md) for pipelines, depth tiers,
|
|
131
136
|
and parameter detail.
|
|
@@ -8,7 +8,7 @@ metadata:
|
|
|
8
8
|
|
|
9
9
|
# CrawlForge: Getting Started
|
|
10
10
|
|
|
11
|
-
CrawlForge is an MCP server with **
|
|
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
|
|
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 (
|
|
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`
|
|
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
|
|