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 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 26 web scraping, crawling, and content processing tools (5 inline + 21 advanced).
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.8.1
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 (26 total)
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 (21):**
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>26 web scraping, crawling, deep-research &amp; autonomous-extraction tools for Claude, Cursor &amp; any MCP client.</b><br>
6
+ <b>27 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
- - **26 MCP-native tools** — scraping, crawling, search, deep research, an autonomous `agent`, a unified multi-format `scrape`, document processing, stealth browsing, and more, callable directly from your AI assistant.
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 | ✅ 26 tools | ✅ | ❌ |
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 26 tools
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.8.1",
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 26 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 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.8.1",
93
- description: "Production-ready MCP server with 26 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, deep research, structured extraction, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
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 26 web scraping tools. Key tools:\n\n" +
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 (26): ${allTools.join(", ")}`);
1351
+ console.error(`Tools available (27): ${allTools.join(", ")}`);
1321
1352
 
1322
1353
  // Start memory monitoring in development
1323
1354
  if (config.server.nodeEnv === "development") {
@@ -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).';
@@ -59,7 +59,16 @@ export function makeWithAuth({ authManager, logger, metrics = null }) {
59
59
  }
60
60
 
61
61
  const result = await handler(params);
62
- outcome = 'success';
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: creditCost,
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
- if (!creatorMode) {
90
- await authManager.reportUsage(toolName, creditCost, params, 200, Date.now() - startTime);
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
- if (!creatorMode) {
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 26 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."
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 **26 tools** for web scraping, crawling,
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 26 tools at a glance
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 (3):** search_web, deep_research, agent
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;