crawlforge-mcp-server 4.8.1 → 4.9.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/CLAUDE.md +9 -6
- package/README.md +5 -4
- package/package.json +2 -2
- package/server.js +36 -5
- package/src/core/AuthManager.js +17 -0
- package/src/server/withAuth.js +20 -5
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +4 -4
- package/src/tools/search/adapters/dataforseoSearch.js +155 -0
- package/src/tools/search/serpRank.js +119 -0
package/CLAUDE.md
CHANGED
|
@@ -60,9 +60,9 @@ These guidelines are working if: fewer unnecessary changes in diffs, fewer rewri
|
|
|
60
60
|
|
|
61
61
|
## Project Overview
|
|
62
62
|
|
|
63
|
-
CrawlForge MCP Server - A professional MCP (Model Context Protocol) server providing
|
|
63
|
+
CrawlForge MCP Server - A professional MCP (Model Context Protocol) server providing 27 web scraping, crawling, and content processing tools (5 inline + 22 advanced).
|
|
64
64
|
|
|
65
|
-
**Current Version:** 4.
|
|
65
|
+
**Current Version:** 4.9.0
|
|
66
66
|
|
|
67
67
|
## Development Commands
|
|
68
68
|
|
|
@@ -166,18 +166,21 @@ Tools are organized in subdirectories by category:
|
|
|
166
166
|
- `crawl/` - crawlDeep, mapSite
|
|
167
167
|
- `extract/` - analyzeContent, extractContent, extractStructured, extractWithLlm, listOllamaModels, processDocument, summarizeContent
|
|
168
168
|
- `research/` - deepResearch
|
|
169
|
-
- `search/` - searchWeb (proxied through CrawlForge.dev API)
|
|
169
|
+
- `search/` - searchWeb (proxied through CrawlForge.dev API), serpRank (real Google organic rank via DataForSEO; adapter in `search/adapters/dataforseoSearch.js`)
|
|
170
170
|
- `templates/` - ScrapeTemplateTool (10 pre-built site templates, v4.0.0)
|
|
171
171
|
- `tracking/` - trackChanges
|
|
172
172
|
- `llmstxt/` - generateLLMsTxt
|
|
173
173
|
|
|
174
|
-
### Available MCP Tools (
|
|
174
|
+
### Available MCP Tools (27 total)
|
|
175
175
|
|
|
176
176
|
**Basic Tools (server.js inline, 5):**
|
|
177
177
|
fetch_url, extract_text, extract_links, extract_metadata, scrape_structured
|
|
178
178
|
|
|
179
|
-
**Advanced Tools (
|
|
180
|
-
search_web, crawl_deep, map_site, extract_content, process_document, summarize_content, analyze_content, extract_structured, extract_with_llm, list_ollama_models, batch_scrape, scrape_with_actions, deep_research, track_changes, generate_llms_txt, stealth_mode, localization, scrape_template, scrape, agent
|
|
179
|
+
**Advanced Tools (22):**
|
|
180
|
+
search_web, serp_rank, crawl_deep, map_site, extract_content, process_document, summarize_content, analyze_content, extract_structured, extract_with_llm, list_ollama_models, batch_scrape, scrape_with_actions, deep_research, track_changes, generate_llms_txt, stealth_mode, localization, scrape_template, scrape, agent
|
|
181
|
+
|
|
182
|
+
**serp_rank (DataForSEO):**
|
|
183
|
+
- `serp_rank` — reports where a target domain ranks in Google's REAL organic results for a keyword (the SERP position Google Custom Search / `search_web` cannot give). Backed by the DataForSEO Google Organic SERP API (Live Advanced, `POST /v3/serp/google/organic/live/advanced`, HTTP Basic auth). Credentials via `DATAFORSEO_LOGIN` / `DATAFORSEO_PASSWORD`, billed to the user's own DataForSEO account (~US$0.002/call), separate from CrawlForge credits. When unconfigured it returns `{ configured:false }` and charges **0** credits; when configured, **Cost: 5**. Never fabricates a rank.
|
|
181
184
|
|
|
182
185
|
**v4.6.0 additions (Phase D):**
|
|
183
186
|
- `scrape` — single fetch + one cheerio load dispatching a `formats` array (markdown/html/rawHtml/text/links/metadata/screenshot/json-schema) + `onlyMainContent`; partial-success via per-format `warnings[]`. Cost: 2.
|
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
|
-
<b>
|
|
6
|
+
<b>27 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
|
+
- **27 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. Credits never expire and roll over month-to-month.
|
|
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 | ✅ 27 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` | ✅ | ❌ |
|
|
@@ -176,6 +176,7 @@ CrawlForge requires a CrawlForge API key — **every tool is metered and consume
|
|
|
176
176
|
| `summarize_content` | 4 | Generate intelligent summaries |
|
|
177
177
|
| `crawl_deep` | 4 | Deep crawl entire websites |
|
|
178
178
|
| `search_web` | 5 | Search the web using Google Search API |
|
|
179
|
+
| `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 |
|
|
179
180
|
| `batch_scrape` | 5 | Process multiple URLs simultaneously |
|
|
180
181
|
| `scrape_with_actions` | 5 | Browser automation chains |
|
|
181
182
|
| `generate_llms_txt` | 5 | Generate AI interaction guidelines |
|
|
@@ -199,7 +200,7 @@ For the full canonical capabilities reference (all tools, CLI commands, stealth
|
|
|
199
200
|
| **Business** ($399) | 250,000 | Large scale operations |
|
|
200
201
|
|
|
201
202
|
**All plans include:**
|
|
202
|
-
- Access to all
|
|
203
|
+
- Access to all 27 tools
|
|
203
204
|
- Credits never expire and roll over month-to-month
|
|
204
205
|
- API access and webhook notifications
|
|
205
206
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.9.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 27 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",
|
package/server.js
CHANGED
|
@@ -9,6 +9,7 @@ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mc
|
|
|
9
9
|
import { z } from "zod";
|
|
10
10
|
import { logger } from "./src/utils/Logger.js";
|
|
11
11
|
import { SearchWebTool } from "./src/tools/search/searchWeb.js";
|
|
12
|
+
import { SerpRankTool } from "./src/tools/search/serpRank.js";
|
|
12
13
|
import { CrawlDeepTool } from "./src/tools/crawl/crawlDeep.js";
|
|
13
14
|
import { MapSiteTool } from "./src/tools/crawl/mapSite.js";
|
|
14
15
|
import { ExtractContentTool } from "./src/tools/extract/extractContent.js";
|
|
@@ -89,8 +90,8 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
|
|
|
89
90
|
// Create the server
|
|
90
91
|
const server = new McpServer({
|
|
91
92
|
name: "crawlforge",
|
|
92
|
-
version: "4.
|
|
93
|
-
description: "Production-ready MCP server with
|
|
93
|
+
version: "4.9.0",
|
|
94
|
+
description: "Production-ready MCP server with 27 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, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
|
|
94
95
|
homepage: "https://www.crawlforge.dev",
|
|
95
96
|
icon: "https://www.crawlforge.dev/icon.png"
|
|
96
97
|
});
|
|
@@ -104,11 +105,12 @@ server.prompt("getting-started", {
|
|
|
104
105
|
role: "user",
|
|
105
106
|
content: {
|
|
106
107
|
type: "text",
|
|
107
|
-
text: "You have access to CrawlForge MCP with
|
|
108
|
+
text: "You have access to CrawlForge MCP with 27 web scraping tools. Key tools:\n\n" +
|
|
108
109
|
"- fetch_url: Fetch raw HTML/content from any URL\n" +
|
|
109
110
|
"- extract_text: Extract clean text from a webpage\n" +
|
|
110
111
|
"- extract_content: Smart content extraction with readability\n" +
|
|
111
112
|
"- search_web: Search the web and get structured results\n" +
|
|
113
|
+
"- serp_rank: Check where a domain ranks in Google's real organic SERP for a keyword\n" +
|
|
112
114
|
"- crawl_deep: Crawl a website following links to a specified depth\n" +
|
|
113
115
|
"- map_site: Discover all pages on a website\n" +
|
|
114
116
|
"- batch_scrape: Scrape multiple URLs in parallel\n" +
|
|
@@ -139,6 +141,10 @@ const withAuth = makeWithAuth({ authManager: AuthManager, logger, metrics });
|
|
|
139
141
|
|
|
140
142
|
// Initialize tools
|
|
141
143
|
const searchWebTool = new SearchWebTool(getToolConfig("search_web"));
|
|
144
|
+
// serp_rank uses DataForSEO credentials (DATAFORSEO_LOGIN / DATAFORSEO_PASSWORD),
|
|
145
|
+
// separate from CrawlForge billing — no getToolConfig needed. Degrades gracefully
|
|
146
|
+
// when unconfigured (returns { configured: false } instead of throwing).
|
|
147
|
+
const serpRankTool = new SerpRankTool();
|
|
142
148
|
const crawlDeepTool = new CrawlDeepTool(getToolConfig('crawl_deep'));
|
|
143
149
|
const mapSiteTool = new MapSiteTool(getToolConfig('map_site'));
|
|
144
150
|
const extractContentTool = new ExtractContentTool();
|
|
@@ -363,6 +369,31 @@ server.registerTool("search_web", {
|
|
|
363
369
|
}
|
|
364
370
|
}));
|
|
365
371
|
|
|
372
|
+
// Tool: serp_rank — REAL Google organic rank for a target domain (via DataForSEO)
|
|
373
|
+
server.registerTool("serp_rank", {
|
|
374
|
+
description: "Use this to check where a domain ranks in Google's ORGANIC results for a keyword — real SERP position, not Custom Search order. Returns the target's organic rank, the ranking URL, and every position it holds. Example: serp_rank({keyword: \"managed wordpress hosting\", target: \"dashboardhosting.com\", location_name: \"United States\"})",
|
|
375
|
+
annotations: { title: "SERP Rank Check", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
376
|
+
inputSchema: {
|
|
377
|
+
keyword: z.string().describe("The search query to check ranking for"),
|
|
378
|
+
target: z.string().describe("Domain or URL to locate in the results (e.g. 'example.com')"),
|
|
379
|
+
location_name: z.string().optional().describe("Location, e.g. 'United States' or 'London,England,United Kingdom'"),
|
|
380
|
+
location_code: z.number().optional().describe("Numeric DataForSEO location code (overrides location_name)"),
|
|
381
|
+
language_code: z.string().optional().describe("Language code (e.g. 'en')"),
|
|
382
|
+
device: z.enum(["desktop", "mobile"]).optional().describe("Device to emulate"),
|
|
383
|
+
depth: z.number().min(10).max(200).optional().describe("How many results to scan, 10-200 (100 = 1 page of cost)")
|
|
384
|
+
}
|
|
385
|
+
}, withAuth("serp_rank", async ({ keyword, target, location_name, location_code, language_code, device, depth }) => {
|
|
386
|
+
try {
|
|
387
|
+
if (!keyword || !target) {
|
|
388
|
+
return { content: [{ type: "text", text: "Both 'keyword' and 'target' are required" }], isError: true };
|
|
389
|
+
}
|
|
390
|
+
const result = await serpRankTool.execute({ keyword, target, location_name, location_code, language_code, device, depth });
|
|
391
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
392
|
+
} catch (error) {
|
|
393
|
+
return { content: [{ type: "text", text: `SERP rank check failed: ${error.message}` }], isError: true };
|
|
394
|
+
}
|
|
395
|
+
}));
|
|
396
|
+
|
|
366
397
|
// Tool: crawl_deep
|
|
367
398
|
server.registerTool("crawl_deep", {
|
|
368
399
|
description: "Use this when you need to discover and optionally extract content from many pages within a site — e.g. building a knowledge base, indexing docs, or auditing all pages. Use map_site first to estimate scope, then crawl_deep for content. Example: crawl_deep({url: \"https://docs.example.com\", max_depth: 3, max_pages: 200, extract_content: true})",
|
|
@@ -1309,7 +1340,7 @@ async function runServer() {
|
|
|
1309
1340
|
|
|
1310
1341
|
const allTools = [
|
|
1311
1342
|
"fetch_url", "extract_text", "extract_links", "extract_metadata", "scrape_structured",
|
|
1312
|
-
"search_web", "crawl_deep", "map_site",
|
|
1343
|
+
"search_web", "serp_rank", "crawl_deep", "map_site",
|
|
1313
1344
|
"extract_content", "process_document", "summarize_content", "analyze_content",
|
|
1314
1345
|
"batch_scrape", "get_batch_results", "scrape_with_actions",
|
|
1315
1346
|
"deep_research", "track_changes", "generate_llms_txt",
|
|
@@ -1317,7 +1348,7 @@ async function runServer() {
|
|
|
1317
1348
|
"list_ollama_models", "scrape_template", // D3.3
|
|
1318
1349
|
"scrape", "agent" // D4
|
|
1319
1350
|
];
|
|
1320
|
-
console.error(`Tools available (
|
|
1351
|
+
console.error(`Tools available (27): ${allTools.join(", ")}`);
|
|
1321
1352
|
|
|
1322
1353
|
// Start memory monitoring in development
|
|
1323
1354
|
if (config.server.nodeEnv === "development") {
|
package/src/core/AuthManager.js
CHANGED
|
@@ -542,6 +542,7 @@ class AuthManager {
|
|
|
542
542
|
scrape_with_actions: 5,
|
|
543
543
|
batch_scrape: 5,
|
|
544
544
|
search_web: 5,
|
|
545
|
+
serp_rank: 5,
|
|
545
546
|
generate_llms_txt: 5,
|
|
546
547
|
|
|
547
548
|
// 8 credits
|
|
@@ -551,6 +552,17 @@ class AuthManager {
|
|
|
551
552
|
deep_research: 10
|
|
552
553
|
};
|
|
553
554
|
|
|
555
|
+
// serp_rank calls DataForSEO, billed to the user's OWN DataForSEO account
|
|
556
|
+
// (separate from CrawlForge credits). When DataForSEO isn't configured the
|
|
557
|
+
// tool short-circuits to a no-op { configured:false } result — never charge
|
|
558
|
+
// CrawlForge credits for that. (Backend note: crawlforge-website's
|
|
559
|
+
// TOOL_CREDIT_COSTS must also carry serp_rank:5; if the backend re-derives
|
|
560
|
+
// cost server-side it cannot see this install's DATAFORSEO_* env, so the
|
|
561
|
+
// free-when-unconfigured guarantee is enforced client-side here.)
|
|
562
|
+
if (tool === 'serp_rank' && !(process.env.DATAFORSEO_LOGIN && process.env.DATAFORSEO_PASSWORD)) {
|
|
563
|
+
return 0;
|
|
564
|
+
}
|
|
565
|
+
|
|
554
566
|
return costs[tool] ?? 1;
|
|
555
567
|
}
|
|
556
568
|
|
|
@@ -595,6 +607,11 @@ class AuthManager {
|
|
|
595
607
|
case 'extract_with_llm':
|
|
596
608
|
note = 'External LLM API call billed by your LLM provider, separate from the credit cost.';
|
|
597
609
|
break;
|
|
610
|
+
case 'serp_rank':
|
|
611
|
+
note = projected === 0
|
|
612
|
+
? 'DataForSEO not configured — no-op, no credits charged. Set DATAFORSEO_LOGIN/PASSWORD to enable.'
|
|
613
|
+
: 'DataForSEO SERP API (~US$0.002/call) billed to your own DataForSEO account, separate from the credit cost.';
|
|
614
|
+
break;
|
|
598
615
|
case 'scrape': {
|
|
599
616
|
projected = base;
|
|
600
617
|
note = 'Fixed cost per invocation. json format may incur external LLM cost (billed by your provider).';
|
package/src/server/withAuth.js
CHANGED
|
@@ -59,7 +59,16 @@ export function makeWithAuth({ authManager, logger, metrics = null }) {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
const result = await handler(params);
|
|
62
|
-
|
|
62
|
+
|
|
63
|
+
// Tools catch their own failures and return { isError:true } rather than
|
|
64
|
+
// throwing (the shared pattern in server.js). That is still an ERROR
|
|
65
|
+
// outcome: bill it the half-credit error rate — not full — and log/metric
|
|
66
|
+
// it as an error, honoring CLAUDE.md's "half credits on error" contract.
|
|
67
|
+
const isErrorResult = result?.isError === true;
|
|
68
|
+
outcome = isErrorResult ? 'error' : 'success';
|
|
69
|
+
const charge = creditCost === 0
|
|
70
|
+
? 0
|
|
71
|
+
: (isErrorResult ? Math.max(1, Math.floor(creditCost * 0.5)) : creditCost);
|
|
63
72
|
|
|
64
73
|
// D3.5: Surface cost transparency in all tool responses
|
|
65
74
|
try {
|
|
@@ -67,7 +76,7 @@ export function makeWithAuth({ authManager, logger, metrics = null }) {
|
|
|
67
76
|
const remainingCredits = creatorMode ? Infinity : (authManager.creditCache ? [...authManager.creditCache.values()][0] ?? null : null);
|
|
68
77
|
const costMeta = {
|
|
69
78
|
projected: creditCost,
|
|
70
|
-
actual:
|
|
79
|
+
actual: creatorMode ? 0 : charge,
|
|
71
80
|
remaining_credits: remainingCredits,
|
|
72
81
|
projection_note: projection.note
|
|
73
82
|
};
|
|
@@ -86,15 +95,21 @@ export function makeWithAuth({ authManager, logger, metrics = null }) {
|
|
|
86
95
|
// Cost injection must never break the request path
|
|
87
96
|
}
|
|
88
97
|
|
|
89
|
-
|
|
90
|
-
|
|
98
|
+
// creditCost === 0 means a genuinely free call (e.g. serp_rank when
|
|
99
|
+
// DataForSEO is unconfigured — a no-op). Emit NO usage event at all so
|
|
100
|
+
// the backend has nothing to (re-)price; reporting 0 would still create
|
|
101
|
+
// a serp_rank record the backend could recompute to full cost.
|
|
102
|
+
if (!creatorMode && creditCost > 0) {
|
|
103
|
+
await authManager.reportUsage(toolName, charge, params, isErrorResult ? 500 : 200, Date.now() - startTime);
|
|
91
104
|
}
|
|
92
105
|
|
|
93
106
|
return result;
|
|
94
107
|
} catch (error) {
|
|
95
108
|
outcome = 'error';
|
|
96
109
|
thrown = error;
|
|
97
|
-
|
|
110
|
+
// Half-charge on error — but never charge a free (0-cost) call, and
|
|
111
|
+
// never let Math.max(1, …) floor a 0 up to 1 credit.
|
|
112
|
+
if (!creatorMode && creditCost > 0) {
|
|
98
113
|
await authManager.reportUsage(
|
|
99
114
|
toolName,
|
|
100
115
|
Math.max(1, Math.floor(creditCost * 0.5)),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: crawlforge-getting-started
|
|
3
|
-
description: "Orientation and tool-selection guide for the CrawlForge MCP server's
|
|
3
|
+
description: "Orientation and tool-selection guide for the CrawlForge MCP server's 27 web tools. Use when the user is getting started with CrawlForge, asks which CrawlForge tool to use, how to set up the API key, how skills or the CLI work, what a tool costs in credits, or when one tool fails and a fallback is needed. Routes requests to the right specialized skill (web scraping, deep research, stealth, structured extraction, change tracking, batch automation), and explains MCP-tools-vs-CLI, the Ollama-first LLM fallback chain, and per-tool credit costs."
|
|
4
4
|
metadata:
|
|
5
5
|
version: 4.8.0
|
|
6
6
|
source: crawlforge-mcp-server
|
|
@@ -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 **27 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
|
|
|
@@ -36,11 +36,11 @@ stored at `~/.crawlforge/config.json`.
|
|
|
36
36
|
| Watch a page for changes / monitor pricing | **crawlforge-change-tracking** |
|
|
37
37
|
| Scrape many URLs, run browser actions, generate llms.txt | **crawlforge-batch-automation** |
|
|
38
38
|
|
|
39
|
-
## The
|
|
39
|
+
## The 27 tools at a glance
|
|
40
40
|
|
|
41
41
|
- **Basic (5):** fetch_url, extract_text, extract_links, extract_metadata, scrape_structured
|
|
42
42
|
- **Unified (1):** scrape (multi-format single fetch)
|
|
43
|
-
- **Search & research (
|
|
43
|
+
- **Search & research (4):** search_web, serp_rank, deep_research, agent
|
|
44
44
|
- **Crawl (2):** crawl_deep, map_site
|
|
45
45
|
- **Extract & analyze (7):** extract_content, process_document, summarize_content, analyze_content, extract_structured, extract_with_llm, list_ollama_models
|
|
46
46
|
- **Batch & automation (4):** batch_scrape, get_batch_results, scrape_with_actions, generate_llms_txt
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataForSEO SERP Adapter
|
|
3
|
+
*
|
|
4
|
+
* Talks to the DataForSEO Google Organic SERP API to obtain REAL organic
|
|
5
|
+
* ranking positions (rank_group / rank_absolute) — the data that Google Custom
|
|
6
|
+
* Search (used by search_web) cannot provide. Used by the serp_rank tool.
|
|
7
|
+
*
|
|
8
|
+
* Auth: HTTP Basic (login:password) from DATAFORSEO_LOGIN / DATAFORSEO_PASSWORD.
|
|
9
|
+
* Get credentials at: https://app.dataforseo.com/api-access
|
|
10
|
+
*
|
|
11
|
+
* Endpoint (Live Advanced, synchronous — one request, one response):
|
|
12
|
+
* POST https://api.dataforseo.com/v3/serp/google/organic/live/advanced
|
|
13
|
+
*
|
|
14
|
+
* Cost: ~US$0.002 per 100 results (depth) on Live Advanced. For high-volume
|
|
15
|
+
* scheduled tracking, DataForSEO's task-based "Standard" queue (task_post →
|
|
16
|
+
* tasks_ready → task_get) is cheaper; swap the endpoint + poll if cost matters.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export class DataForSEOSearchAdapter {
|
|
20
|
+
constructor(login, password, options = {}) {
|
|
21
|
+
if (!login || !password) {
|
|
22
|
+
throw new Error('DataForSEO credentials are required (login + password).');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
this.login = login;
|
|
26
|
+
this.password = password;
|
|
27
|
+
this.apiBaseUrl = options.apiBaseUrl || 'https://api.dataforseo.com';
|
|
28
|
+
// Live Advanced is synchronous and usually answers in a few seconds; cap it
|
|
29
|
+
// so a hung connection can't wedge the tool. Overridable for tests/self-host.
|
|
30
|
+
this.timeoutMs = options.timeoutMs ?? 30000;
|
|
31
|
+
// HTTP Basic auth header, computed once.
|
|
32
|
+
this.authHeader = 'Basic ' + Buffer.from(`${login}:${password}`).toString('base64');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Run one Google organic SERP lookup and return normalized organic results.
|
|
37
|
+
* @param {Object} params - Lookup parameters
|
|
38
|
+
* @param {string} params.keyword - The search query to rank for (required)
|
|
39
|
+
* @param {string} [params.locationName='United States'] - Human location name
|
|
40
|
+
* @param {number} [params.locationCode] - Numeric DataForSEO location code (overrides locationName)
|
|
41
|
+
* @param {string} [params.languageCode='en'] - Language code
|
|
42
|
+
* @param {('desktop'|'mobile')} [params.device='desktop'] - Device to emulate
|
|
43
|
+
* @param {number} [params.depth=100] - How many results to scan (100 = one page of cost)
|
|
44
|
+
* @returns {Promise<{items: Array<Object>, meta: Object}>} Normalized organic results + metadata
|
|
45
|
+
*/
|
|
46
|
+
async search(params) {
|
|
47
|
+
const {
|
|
48
|
+
keyword,
|
|
49
|
+
locationName = 'United States',
|
|
50
|
+
locationCode,
|
|
51
|
+
languageCode = 'en',
|
|
52
|
+
device = 'desktop',
|
|
53
|
+
depth = 100,
|
|
54
|
+
} = params;
|
|
55
|
+
|
|
56
|
+
if (!keyword) {
|
|
57
|
+
throw new Error('keyword is required for a SERP lookup');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// DataForSEO accepts an ARRAY of task objects; we send exactly one.
|
|
61
|
+
const task = {
|
|
62
|
+
keyword,
|
|
63
|
+
language_code: languageCode,
|
|
64
|
+
device,
|
|
65
|
+
depth,
|
|
66
|
+
};
|
|
67
|
+
if (locationCode != null) {
|
|
68
|
+
task.location_code = locationCode;
|
|
69
|
+
} else {
|
|
70
|
+
task.location_name = locationName;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let response;
|
|
74
|
+
try {
|
|
75
|
+
response = await fetch(`${this.apiBaseUrl}/v3/serp/google/organic/live/advanced`, {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: {
|
|
78
|
+
'Authorization': this.authHeader,
|
|
79
|
+
'Content-Type': 'application/json',
|
|
80
|
+
},
|
|
81
|
+
body: JSON.stringify([task]),
|
|
82
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
83
|
+
});
|
|
84
|
+
} catch (error) {
|
|
85
|
+
// Timeout (AbortError) or network / DNS / fetch failure — surface clearly.
|
|
86
|
+
if (error.name === 'TimeoutError' || error.name === 'AbortError') {
|
|
87
|
+
throw new Error(`DataForSEO request timed out after ${this.timeoutMs}ms`);
|
|
88
|
+
}
|
|
89
|
+
throw new Error(`Network error connecting to DataForSEO: ${error.message}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
// HTTP-level failure (auth, funds, rate limit, server error).
|
|
94
|
+
let detail = `${response.status} ${response.statusText}`;
|
|
95
|
+
if (response.status === 401) {
|
|
96
|
+
detail = 'Invalid DataForSEO credentials (check DATAFORSEO_LOGIN / DATAFORSEO_PASSWORD).';
|
|
97
|
+
} else if (response.status === 402) {
|
|
98
|
+
detail = 'DataForSEO account has insufficient funds. Top up at https://app.dataforseo.com/';
|
|
99
|
+
} else if (response.status === 429) {
|
|
100
|
+
detail = 'DataForSEO rate limit exceeded. Slow down or raise your plan limits.';
|
|
101
|
+
}
|
|
102
|
+
throw new Error(`DataForSEO request failed: ${detail}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const data = await response.json();
|
|
106
|
+
|
|
107
|
+
// DataForSEO returns HTTP 200 even for logical errors — 20000 means OK.
|
|
108
|
+
if (!data || data.status_code !== 20000) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`DataForSEO error ${data?.status_code ?? 'unknown'}: ${data?.status_message ?? 'no message'}`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const task0 = Array.isArray(data.tasks) ? data.tasks[0] : undefined;
|
|
115
|
+
if (!task0 || task0.status_code !== 20000) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`DataForSEO task error ${task0?.status_code ?? 'unknown'}: ${task0?.status_message ?? 'no result returned'}`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const result0 = Array.isArray(task0.result) ? task0.result[0] : undefined;
|
|
122
|
+
const rawItems = result0 && Array.isArray(result0.items) ? result0.items : [];
|
|
123
|
+
|
|
124
|
+
// Keep only true organic results and normalize to a small, stable shape.
|
|
125
|
+
// rank_group = position within the organic results (the "rank" most people mean)
|
|
126
|
+
// rank_absolute = position across ALL SERP elements (ads, snippets, packs, …)
|
|
127
|
+
const items = rawItems
|
|
128
|
+
.filter((it) => it && it.type === 'organic')
|
|
129
|
+
.map((it) => ({
|
|
130
|
+
position: it.rank_group ?? null,
|
|
131
|
+
rankAbsolute: it.rank_absolute ?? null,
|
|
132
|
+
domain: (it.domain || '').toLowerCase(),
|
|
133
|
+
url: it.url || null,
|
|
134
|
+
title: it.title || null,
|
|
135
|
+
snippet: it.description || null,
|
|
136
|
+
}));
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
items,
|
|
140
|
+
meta: {
|
|
141
|
+
keyword,
|
|
142
|
+
location: locationCode != null ? locationCode : locationName,
|
|
143
|
+
languageCode,
|
|
144
|
+
device,
|
|
145
|
+
depth,
|
|
146
|
+
organicCount: items.length,
|
|
147
|
+
seResultsCount: result0?.se_results_count ?? null,
|
|
148
|
+
checkUrl: result0?.check_url ?? null, // Google URL to eyeball the real SERP
|
|
149
|
+
cost: data.cost ?? task0.cost ?? null, // USD charged by DataForSEO for this call
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export default DataForSEOSearchAdapter;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* serp_rank tool
|
|
3
|
+
*
|
|
4
|
+
* Reports where a target domain ranks in Google's REAL organic results for a
|
|
5
|
+
* keyword — the SERP position that Google Custom Search (search_web) cannot
|
|
6
|
+
* give you. Backed by the DataForSEO SERP adapter.
|
|
7
|
+
*
|
|
8
|
+
* Honesty rule: if DataForSEO is not configured, this returns an explained
|
|
9
|
+
* { configured: false } result — it never fabricates a rank.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
import { DataForSEOSearchAdapter } from './adapters/dataforseoSearch.js';
|
|
14
|
+
|
|
15
|
+
const SerpRankSchema = z.object({
|
|
16
|
+
keyword: z.string().min(1),
|
|
17
|
+
target: z.string().min(1), // domain or URL to locate in the SERP
|
|
18
|
+
location_name: z.string().optional().default('United States'),
|
|
19
|
+
location_code: z.number().int().optional(),
|
|
20
|
+
language_code: z.string().optional().default('en'),
|
|
21
|
+
device: z.enum(['desktop', 'mobile']).optional().default('desktop'),
|
|
22
|
+
depth: z.number().int().min(10).max(200).optional().default(100), // DataForSEO caps depth at 200
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** Reduce a domain or URL to a bare, comparable host: "https://www.Example.com/x" → "example.com". */
|
|
26
|
+
function toBareDomain(value) {
|
|
27
|
+
return String(value)
|
|
28
|
+
.trim()
|
|
29
|
+
.toLowerCase()
|
|
30
|
+
.replace(/^https?:\/\//, '')
|
|
31
|
+
.replace(/^www\./, '')
|
|
32
|
+
.split('/')[0]
|
|
33
|
+
.split('?')[0];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** True when a SERP result's domain is the target domain (or a subdomain of it). */
|
|
37
|
+
function domainMatches(resultDomain, target) {
|
|
38
|
+
const d = toBareDomain(resultDomain);
|
|
39
|
+
return d === target || d.endsWith(`.${target}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class SerpRankTool {
|
|
43
|
+
constructor(options = {}) {
|
|
44
|
+
// DataForSEO uses its own credentials, separate from CrawlForge billing.
|
|
45
|
+
// The server can start without them — we degrade gracefully at execute().
|
|
46
|
+
this.login = options.login || process.env.DATAFORSEO_LOGIN || null;
|
|
47
|
+
this.password = options.password || process.env.DATAFORSEO_PASSWORD || null;
|
|
48
|
+
this.apiBaseUrl = options.apiBaseUrl; // optional override for tests / self-host
|
|
49
|
+
this.timeoutMs = options.timeoutMs; // optional override for tests / self-host
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async execute(params) {
|
|
53
|
+
const validated = SerpRankSchema.parse(params);
|
|
54
|
+
|
|
55
|
+
// Graceful degradation — NEVER fake a rank. If unconfigured, explain the fix.
|
|
56
|
+
if (!this.login || !this.password) {
|
|
57
|
+
return {
|
|
58
|
+
configured: false,
|
|
59
|
+
keyword: validated.keyword,
|
|
60
|
+
target: toBareDomain(validated.target),
|
|
61
|
+
note:
|
|
62
|
+
'SERP rank tracking is not configured. Set DATAFORSEO_LOGIN and ' +
|
|
63
|
+
'DATAFORSEO_PASSWORD to enable real Google organic rank lookups. ' +
|
|
64
|
+
'Get credentials at https://app.dataforseo.com/api-access',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const adapter = new DataForSEOSearchAdapter(this.login, this.password, {
|
|
69
|
+
apiBaseUrl: this.apiBaseUrl,
|
|
70
|
+
timeoutMs: this.timeoutMs,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const { items, meta } = await adapter.search({
|
|
74
|
+
keyword: validated.keyword,
|
|
75
|
+
locationName: validated.location_name,
|
|
76
|
+
locationCode: validated.location_code,
|
|
77
|
+
languageCode: validated.language_code,
|
|
78
|
+
device: validated.device,
|
|
79
|
+
depth: validated.depth,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const target = toBareDomain(validated.target);
|
|
83
|
+
|
|
84
|
+
// A domain can rank multiple times — collect every organic hit, best first.
|
|
85
|
+
const matches = items
|
|
86
|
+
.filter((it) => domainMatches(it.domain, target))
|
|
87
|
+
.sort((a, b) => (a.position ?? Infinity) - (b.position ?? Infinity))
|
|
88
|
+
.map((it) => ({
|
|
89
|
+
position: it.position,
|
|
90
|
+
rankAbsolute: it.rankAbsolute,
|
|
91
|
+
url: it.url,
|
|
92
|
+
title: it.title,
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
const best = matches[0] || null;
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
configured: true,
|
|
99
|
+
keyword: validated.keyword,
|
|
100
|
+
target,
|
|
101
|
+
found: matches.length > 0,
|
|
102
|
+
position: best ? best.position : null, // organic rank; null = not within top `depth`
|
|
103
|
+
rankAbsolute: best ? best.rankAbsolute : null,
|
|
104
|
+
url: best ? best.url : null,
|
|
105
|
+
title: best ? best.title : null,
|
|
106
|
+
allPositions: matches, // every place the domain ranks on this SERP
|
|
107
|
+
location: meta.location,
|
|
108
|
+
device: meta.device,
|
|
109
|
+
depthScanned: meta.depth,
|
|
110
|
+
organicResults: meta.organicCount,
|
|
111
|
+
seResultsCount: meta.seResultsCount,
|
|
112
|
+
checkUrl: meta.checkUrl, // link to view the real SERP
|
|
113
|
+
cost: meta.cost, // USD charged by DataForSEO for this lookup
|
|
114
|
+
checkedAt: new Date().toISOString(),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export default SerpRankTool;
|