crawlforge-mcp-server 6.20.1 → 6.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/CLAUDE.md +74 -296
  2. package/README.md +2 -2
  3. package/package.json +1 -2
  4. package/server.js +6 -4
  5. package/src/core/crawlers/BFSCrawler.js +1 -1
  6. package/src/schemas/toolOutputSchemas.js +1 -1
  7. package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
  8. package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
  9. package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +1 -1
  10. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
  11. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +1 -1
  12. package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +3 -3
  13. package/src/skills/agent-skills/crawlforge-stealth-browsing/references/engine-selection.md +2 -2
  14. package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
  15. package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +3 -3
  16. package/src/tools/crawl/mapSite.js +2 -1
  17. package/src/tools/scrape/unifiedScrape.js +4 -2
  18. package/src/tools/search/adapters/braveSearch.js +182 -0
  19. package/src/tools/search/adapters/crawlforgeSearch.js +5 -2
  20. package/src/tools/search/adapters/searchProviderFactory.js +20 -11
  21. package/src/tools/search/providers/searxng.js +5 -0
  22. package/src/tools/search/redditSearch.js +1 -1
  23. package/src/tools/search/searchWeb.js +27 -13
  24. package/src/tools/search/serpRank.js +54 -1
  25. package/src/utils/ErrorHandlingConfig.js +1 -1
  26. package/src/utils/urlNormalizer.js +2 -1
  27. package/src/utils/inputValidation.js +0 -766
  28. package/src/utils/securityMiddleware.js +0 -416
@@ -2,7 +2,7 @@
2
2
  name: crawlforge-web-scraping
3
3
  description: "Scrapes web pages and returns clean Markdown, HTML, plain text, links, or metadata using CrawlForge's scrape, fetch_url, extract_content, extract_text, extract_links, extract_metadata, map_site, and crawl_deep tools. Use when the user wants to scrape a URL, fetch a page, get the markdown or text of a website, extract links or metadata, read an article, map a site's URLs, generate a sitemap, or crawl a whole website or documentation site. Prefer the unified scrape tool to get multiple formats in one call; use map_site to discover URLs and crawl_deep to walk an entire site."
4
4
  metadata:
5
- version: 5.6.6
5
+ version: 6.21.1
6
6
  source: crawlforge-mcp-server
7
7
  ---
8
8
 
@@ -142,8 +142,8 @@ crawlforge scrape https://example.com/article --extract --format markdown
142
142
  { "tool": "fetch_url", "params": { "url": "https://example.com", "timeout": 15000 } }
143
143
  ```
144
144
 
145
- Returns headers + body. Good for JSON/XML APIs or as a first step before
146
- `extract_text` / `extract_content`. Supports `headers` (e.g. auth tokens).
145
+ Returns headers + body. Use it for JSON/XML APIs; for HTML you will read, one
146
+ `scrape` call replaces a `fetch_url` + `extract_*` pair. Supports `headers` (e.g. auth tokens).
147
147
 
148
148
  ## extract_content vs extract_text
149
149
 
@@ -333,9 +333,10 @@ export class MapSiteTool {
333
333
  // Extract all links
334
334
  $('a[href]').each((_, element) => {
335
335
  const href = $(element).attr('href');
336
- if (href && !href.startsWith('#') && !href.startsWith('javascript:')) {
336
+ if (href && !href.startsWith('#')) {
337
337
  try {
338
338
  const absoluteUrl = new URL(href, url);
339
+ if (['javascript:', 'data:', 'vbscript:'].includes(absoluteUrl.protocol)) return;
339
340
  // Only include URLs from the same domain
340
341
  if (absoluteUrl.origin === new URL(baseUrl).origin) {
341
342
  const urlString = absoluteUrl.toString();
@@ -111,9 +111,11 @@ function extractLinksFromDom($, pageUrl, docBaseUrl) {
111
111
  const href = $(el).attr('href');
112
112
  const text = $(el).text().trim();
113
113
  if (!href) return;
114
- if (href.startsWith('#') || href.startsWith('javascript:')) return;
114
+ if (href.startsWith('#')) return;
115
115
  try {
116
- const absoluteUrl = new URL(href, resolveBase).toString();
116
+ const resolved = new URL(href, resolveBase);
117
+ if (['javascript:', 'data:', 'vbscript:'].includes(resolved.protocol)) return;
118
+ const absoluteUrl = resolved.toString();
117
119
  const isExternal = new URL(absoluteUrl).origin !== pageOrigin;
118
120
  if (!seen.has(absoluteUrl)) {
119
121
  seen.add(absoluteUrl);
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Brave Search API Adapter
3
+ *
4
+ * Creator Mode's direct search backend when BRAVE_SEARCH_API_KEY is set,
5
+ * replacing Google Custom Search, which Google discontinues on 2027-01-01.
6
+ * Production users never reach this: their searches go through the CrawlForge
7
+ * API, which runs the same Brave mapping server-side
8
+ * (crawlforge-website src/lib/search/providers/brave.ts).
9
+ *
10
+ * Takes the Custom Search parameters SearchWebTool sends and returns Custom
11
+ * Search's response shape, so the rest of the pipeline is unchanged.
12
+ *
13
+ * Checked against the live API on 2026-10-19:
14
+ * - `count` is at most 20, and `offset` is a PAGE index from 0 to 9. A caller's
15
+ * `start` need not fall on a page boundary, so `bravePage` picks a page size
16
+ * whose page holds the whole window, and the extra results are sliced off.
17
+ * - `search_lang` and `country` are validated: `zh`, `pt` and `EN` are a 422
18
+ * where `zh-hans`, `pt-br` and `en` pass. Common forms are normalised, and a
19
+ * locale still refused is retried once without it.
20
+ * - `description` carries `<strong>` highlighting; there is no total count.
21
+ */
22
+
23
+ const ENDPOINT = 'https://api.search.brave.com/res/v1/web/search';
24
+ const MAX_COUNT = 20;
25
+ const MAX_PAGE = 9;
26
+ /** Custom Search's depth, which search_web documents: nothing past the 100th result. */
27
+ const MAX_RESULTS = 100;
28
+ const TIMEOUT_MS = 15000;
29
+
30
+ const FRESHNESS = { d1: 'pd', w1: 'pw', m1: 'pm', y1: 'py' };
31
+
32
+ const LANGUAGE_ALIASES = {
33
+ zh: 'zh-hans', 'zh-cn': 'zh-hans', 'zh-sg': 'zh-hans',
34
+ 'zh-tw': 'zh-hant', 'zh-hk': 'zh-hant',
35
+ pt: 'pt-br',
36
+ };
37
+ const REGIONAL_LANGUAGES = new Set(['zh-hans', 'zh-hant', 'pt-br', 'pt-pt', 'en-gb']);
38
+
39
+ const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
40
+
41
+ /** `lang_en`, `lang_zh-CN`, `pt` → the code Brave validates; undefined to send none. */
42
+ export function braveLanguage(lr) {
43
+ const code = String(lr ?? '').replace(/^lang_/, '').trim().toLowerCase();
44
+ if (!code) return undefined;
45
+ if (LANGUAGE_ALIASES[code]) return LANGUAGE_ALIASES[code];
46
+ if (REGIONAL_LANGUAGES.has(code)) return code;
47
+ return code.split('-')[0];
48
+ }
49
+
50
+ /**
51
+ * The Brave page that holds results `first` to `first + wanted - 1` (zero-based):
52
+ * the smallest page size whose page contains the whole window. Every window of
53
+ * up to 10 within the first 100 results fits one; a wider one that fits none
54
+ * gets the 20-result page holding `first`, and so only part of the window.
55
+ */
56
+ export function bravePage(first, wanted) {
57
+ for (let count = wanted; count <= MAX_COUNT; count++) {
58
+ const page = Math.floor(first / count);
59
+ if (page <= MAX_PAGE && Math.floor((first + wanted - 1) / count) === page) {
60
+ return { count, page };
61
+ }
62
+ }
63
+ return { count: MAX_COUNT, page: Math.floor(first / MAX_COUNT) };
64
+ }
65
+
66
+ /** Brave's highlighted HTML as the plain text Custom Search puts in `snippet`. */
67
+ export function plainText(html) {
68
+ return String(html ?? '')
69
+ .replace(/<[^>]*>/g, '')
70
+ .replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (entity, name) => {
71
+ if (name[0] === '#') {
72
+ const code = name[1] === 'x' || name[1] === 'X' ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10);
73
+ return code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : entity;
74
+ }
75
+ return ENTITIES[name.toLowerCase()] ?? entity;
76
+ })
77
+ .replace(/\s+/g, ' ')
78
+ .trim();
79
+ }
80
+
81
+ function toItem(result) {
82
+ if (!result?.url) return [];
83
+ const pagemap = {};
84
+ if (result.thumbnail?.src) pagemap.cse_thumbnail = [{ src: result.thumbnail.src }];
85
+ if (result.page_age) pagemap.metatags = [{ 'article:published_time': result.page_age }];
86
+
87
+ let displayLink = result.meta_url?.hostname;
88
+ if (!displayLink) {
89
+ try { displayLink = new URL(result.url).hostname; } catch { displayLink = ''; }
90
+ }
91
+ return [{
92
+ title: plainText(result.title),
93
+ link: result.url,
94
+ snippet: plainText(result.description),
95
+ displayLink,
96
+ formattedUrl: result.url,
97
+ htmlTitle: result.title ?? '',
98
+ htmlSnippet: result.description ?? '',
99
+ ...(Object.keys(pagemap).length > 0 ? { pagemap } : {}),
100
+ }];
101
+ }
102
+
103
+ export class BraveSearchAdapter {
104
+ constructor(apiKey) {
105
+ if (!apiKey) {
106
+ throw new Error('Brave Search API key is required');
107
+ }
108
+ this.apiKey = apiKey;
109
+ }
110
+
111
+ /**
112
+ * @param {Object} params - Custom Search parameters, as SearchWebTool sends them
113
+ * (query, num, start, lr, safe, dateRestrict, cr)
114
+ * @returns {Promise<Object>} Custom Search-shaped results, plus `provider: 'brave'`
115
+ */
116
+ async search(params) {
117
+ const startedAt = Date.now();
118
+ const first = Math.max(0, (params.start || 1) - 1);
119
+ const wanted = Math.min(Math.max(1, params.num || 10), MAX_COUNT, MAX_RESULTS - first);
120
+ if (wanted <= 0) return this.toResult([], startedAt);
121
+ const { count, page } = bravePage(first, wanted);
122
+
123
+ const base = new URLSearchParams({
124
+ q: params.query,
125
+ count: String(count),
126
+ safesearch: params.safe === 'active' ? 'strict' : 'off',
127
+ });
128
+ if (page > 0) base.append('offset', String(page));
129
+ if (FRESHNESS[params.dateRestrict]) base.append('freshness', FRESHNESS[params.dateRestrict]);
130
+
131
+ const localized = new URLSearchParams(base);
132
+ const language = braveLanguage(params.lr);
133
+ const country = String(params.cr ?? '').replace(/^country/, '').trim().toUpperCase();
134
+ if (language) localized.append('search_lang', language);
135
+ if (/^[A-Z]{2}$/.test(country)) localized.append('country', country);
136
+
137
+ let response = await this.send(localized);
138
+ if (response.status === 422 && localized.toString() !== base.toString()) {
139
+ response = await this.send(base);
140
+ }
141
+
142
+ if (!response.ok) {
143
+ let detail = response.statusText;
144
+ try { detail = (await response.json())?.error?.detail || detail; } catch { /* no body */ }
145
+ throw new Error(
146
+ response.status === 401 || response.status === 403
147
+ ? 'Invalid Brave Search API key'
148
+ : response.status === 429
149
+ ? 'Brave Search API rate limit or credit balance reached. Try again later.'
150
+ : `Brave Search failed with status ${response.status}: ${detail}`
151
+ );
152
+ }
153
+
154
+ const data = await response.json();
155
+ const skip = first - page * count;
156
+ return this.toResult((data?.web?.results ?? []).slice(skip, skip + wanted).flatMap(toItem), startedAt);
157
+ }
158
+
159
+ async send(params) {
160
+ try {
161
+ return await fetch(`${ENDPOINT}?${params.toString()}`, {
162
+ method: 'GET',
163
+ headers: { Accept: 'application/json', 'X-Subscription-Token': this.apiKey },
164
+ signal: AbortSignal.timeout(TIMEOUT_MS),
165
+ });
166
+ } catch (error) {
167
+ throw new Error(`Network error connecting to Brave Search API: ${error.message}`);
168
+ }
169
+ }
170
+
171
+ toResult(items, startedAt) {
172
+ return {
173
+ items,
174
+ searchInformation: { totalResults: null, searchTime: (Date.now() - startedAt) / 1000 },
175
+ queries: {},
176
+ context: {},
177
+ provider: 'brave',
178
+ };
179
+ }
180
+ }
181
+
182
+ export default BraveSearchAdapter;
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * CrawlForge Search Adapter
3
3
  *
4
- * Proxies search requests through CrawlForge.dev API which uses Google Search.
4
+ * Proxies search requests through the CrawlForge.dev API, which runs them on its
5
+ * configured backend (Brave Search API since 2026-10) and reports which in `provider`.
5
6
  * Users only need their CrawlForge API key - no Google credentials required.
6
7
  *
7
8
  * Credit Cost: 5 credits per search (SEARCH_WEB_CREDITS in ../batchSearch.js)
@@ -90,7 +91,9 @@ export class CrawlForgeSearchAdapter {
90
91
  searchTime: 0
91
92
  },
92
93
  queries: data.queries || {},
93
- context: data.context || {}
94
+ context: data.context || {},
95
+ // 'brave' | 'google'; absent from deployments older than 2026-10-19
96
+ provider: data.provider
94
97
  };
95
98
  } catch (error) {
96
99
  // Network errors or fetch failures
@@ -2,12 +2,14 @@
2
2
  * Search Provider Factory
3
3
  *
4
4
  * Creates search adapter instances.
5
- * - Production: Uses CrawlForge proxy for Google Search (users with CrawlForge API key)
6
- * - Creator Mode: Uses Google Search API directly (requires GOOGLE_API_KEY & GOOGLE_SEARCH_ENGINE_ID)
5
+ * - Production: Uses the CrawlForge API's web search (users with CrawlForge API key)
6
+ * - Creator Mode: Uses Brave Search API directly when BRAVE_SEARCH_API_KEY is set, else Google
7
+ * Custom Search (GOOGLE_API_KEY & GOOGLE_SEARCH_ENGINE_ID; discontinued by Google on 2027-01-01)
7
8
  */
8
9
 
9
10
  import { CrawlForgeSearchAdapter } from './crawlforgeSearch.js';
10
11
  import { GoogleSearchAdapter } from './googleSearch.js';
12
+ import { BraveSearchAdapter } from './braveSearch.js';
11
13
  import { SEARCH_WEB_CREDITS } from '../batchSearch.js';
12
14
 
13
15
  export class SearchProviderFactory {
@@ -19,18 +21,25 @@ export class SearchProviderFactory {
19
21
  * @param {boolean} options.creatorMode - Whether Creator Mode is enabled
20
22
  * @param {string} options.googleApiKey - Google API key (for Creator Mode)
21
23
  * @param {string} options.googleSearchEngineId - Google Search Engine ID (for Creator Mode)
22
- * @returns {CrawlForgeSearchAdapter|GoogleSearchAdapter} Search adapter instance
24
+ * @returns {CrawlForgeSearchAdapter|BraveSearchAdapter|GoogleSearchAdapter} Search adapter instance
23
25
  */
24
26
  static createAdapter(apiKey, options = {}) {
25
- // In Creator Mode without CrawlForge API key, use Google Search API directly
27
+ // In Creator Mode without CrawlForge API key, call a search API directly:
28
+ // Brave when its key is set, Google Custom Search until that is switched off.
26
29
  if (!apiKey && options.creatorMode) {
30
+ const braveApiKey = options.braveApiKey || process.env.BRAVE_SEARCH_API_KEY;
31
+ if (braveApiKey) {
32
+ console.error('🔍 Creator Mode: Using Brave Search API directly');
33
+ return new BraveSearchAdapter(braveApiKey);
34
+ }
35
+
27
36
  const googleApiKey = options.googleApiKey || process.env.GOOGLE_API_KEY;
28
37
  const googleSearchEngineId = options.googleSearchEngineId || process.env.GOOGLE_SEARCH_ENGINE_ID;
29
38
 
30
39
  if (!googleApiKey || !googleSearchEngineId) {
31
40
  throw new Error(
32
- 'Creator Mode requires Google Search API credentials. ' +
33
- 'Set GOOGLE_API_KEY and GOOGLE_SEARCH_ENGINE_ID environment variables.\n' +
41
+ 'Creator Mode requires search API credentials. ' +
42
+ 'Set BRAVE_SEARCH_API_KEY, or GOOGLE_API_KEY and GOOGLE_SEARCH_ENGINE_ID.\n' +
34
43
  'Get credentials at:\n' +
35
44
  ' - API Key: https://console.cloud.google.com/apis/credentials\n' +
36
45
  ' - Search Engine ID: https://programmablesearchengine.google.com/'
@@ -72,8 +81,9 @@ export class SearchProviderFactory {
72
81
  // CrawlForge API key available
73
82
  if (apiKey) return true;
74
83
 
75
- // Creator Mode with Google credentials
84
+ // Creator Mode with Brave or Google credentials
76
85
  if (options.creatorMode) {
86
+ if (options.braveApiKey || process.env.BRAVE_SEARCH_API_KEY) return true;
77
87
  const googleApiKey = options.googleApiKey || process.env.GOOGLE_API_KEY;
78
88
  const googleSearchEngineId = options.googleSearchEngineId || process.env.GOOGLE_SEARCH_ENGINE_ID;
79
89
  return !!(googleApiKey && googleSearchEngineId);
@@ -141,8 +151,8 @@ export class SearchProviderFactory {
141
151
  rateLimit: 'Based on your CrawlForge plan',
142
152
  creditCost: `${SEARCH_WEB_CREDITS} credits per search`,
143
153
  features: [
144
- 'Google Search results via CrawlForge proxy',
145
- 'No Google API credentials needed',
154
+ 'Web search results via the CrawlForge API (Brave Search API)',
155
+ 'No search API credentials needed',
146
156
  'Full text search',
147
157
  'Image metadata',
148
158
  'Exact phrase matching',
@@ -154,14 +164,13 @@ export class SearchProviderFactory {
154
164
  'Country targeting',
155
165
  'Safe search',
156
166
  'Localization support',
157
- 'Related searches',
158
167
  'Rich snippets'
159
168
  ],
160
169
  benefits: [
161
170
  'Simplified authentication (one API key)',
162
171
  'Unified billing through CrawlForge',
163
172
  'Enterprise-grade reliability',
164
- 'No Google API quota management',
173
+ 'No search API quota management',
165
174
  'Built-in rate limiting',
166
175
  'Credit-based pricing'
167
176
  ]
@@ -9,6 +9,11 @@
9
9
  *
10
10
  * Result shape is normalised to match the CrawlForge/Google adapter format so
11
11
  * the rest of the search pipeline (ranking, deduplication, caching) is unaffected.
12
+ *
13
+ * SearXNG's `google` engine scrapes google.com, so it meets Google's
14
+ * anti-scraping measures: 429s, CAPTCHAs, and decoy result pages that look
15
+ * valid but are unrelated to the query (seen 2026-10 by SEO rank trackers).
16
+ * Prefer the `brave` and `duckduckgo` engines in settings.yml.
12
17
  */
13
18
 
14
19
  /**
@@ -166,7 +166,7 @@ export class RedditSearchTool {
166
166
  order = ['web_discovery'];
167
167
  } else if (v.source === 'reddit_api') {
168
168
  if (!this.officialConfigured) {
169
- throw new Error('source:"reddit_api" needs Reddit app credentials — set REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET (create a "script" app at https://www.reddit.com/prefs/apps)');
169
+ throw new Error('source:"reddit_api" needs Reddit app credentials — set REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET. Reddit stopped issuing new app credentials without an approval ticket on 2025-11-11, so this needs an existing app or an approved request (see docs/reddit-access-and-oauth.md). Without it, drop source and auto reads the community archives');
170
170
  }
171
171
  if (v.mode === 'comments') {
172
172
  throw new Error('the official Reddit API has no comment full-text search — use comments mode with source:"arctic_shift" (scoped) or "web_discovery" (unscoped), or read a whole thread with mode:"thread"');
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { SearchProviderFactory } from './adapters/searchProviderFactory.js';
3
+ import { BraveSearchAdapter } from './adapters/braveSearch.js';
3
4
  import { CacheManager } from '../../core/cache/CacheManager.js';
4
5
  import { QueryExpander } from './queryExpander.js';
5
6
  import { ResultRanker } from './ranking/ResultRanker.js';
@@ -8,6 +9,14 @@ import { SearchResultCache } from './ranking/SearchResultCache.js';
8
9
  import LocalizationManager from '../../core/LocalizationManager.js';
9
10
  import { isCreatorModeVerified } from '../../core/creatorMode.js';
10
11
  import { searchViaSearxng } from './providers/searxng.js';
12
+
13
+ /** The CrawlForge API's `provider` field, as a backend name. */
14
+ const CRAWLFORGE_BACKENDS = { brave: 'Brave Search API', google: 'Google Custom Search API' };
15
+
16
+ /** The backend the CrawlForge API reported serving a search with. */
17
+ function crawlforgeBackend(provider) {
18
+ return CRAWLFORGE_BACKENDS[provider] ?? 'CrawlForge web search';
19
+ }
11
20
  import { MAX_SEARCH_QUERIES, SEARCH_WEB_CREDITS, EXACTLY_ONE_QUERY_MESSAGE } from './batchSearch.js';
12
21
  import { setActualCost } from '../../server/requestContext.js';
13
22
 
@@ -394,7 +403,7 @@ export class SearchWebTool {
394
403
  effective_query: usedQuery !== validated.query ? usedQuery : undefined,
395
404
  expanded_queries: queriesToTry.length > 1 ? queriesToTry : undefined,
396
405
  results: processedResults,
397
- total_results: bestResults.searchInformation?.totalResults || 0,
406
+ total_results: bestResults.searchInformation?.totalResults ?? null,
398
407
  search_time: bestResults.searchInformation?.searchTime || 0,
399
408
  offset: localizedParams.offset,
400
409
  limit: localizedParams.limit,
@@ -405,12 +414,12 @@ export class SearchWebTool {
405
414
  // is not repeated here: it is the same on every call, and a
406
415
  // queries:[...] batch carried one copy per query (R24 4.4).
407
416
  provider: this.isCreatorModeFallback ? {
408
- name: 'google',
409
- backend: 'Google Custom Search API (Creator Mode)',
410
- note: 'Using Google Search API directly. Production users use CrawlForge API.'
417
+ name: this.creatorBackendName(),
418
+ backend: `${CRAWLFORGE_BACKENDS[this.creatorBackendName()]} (Creator Mode)`,
419
+ note: 'Calling the search API directly. Production users use CrawlForge API.'
411
420
  } : {
412
421
  name: 'crawlforge',
413
- backend: 'Google Search'
422
+ backend: crawlforgeBackend(bestResults?.provider)
414
423
  },
415
424
 
416
425
  // Add localization information
@@ -528,7 +537,7 @@ export class SearchWebTool {
528
537
  return {
529
538
  query: validated.query,
530
539
  results: processedResults,
531
- total_results: adapterResult.searchInformation?.totalResults || 0,
540
+ total_results: adapterResult.searchInformation?.totalResults ?? null,
532
541
  search_time: adapterResult.searchInformation?.searchTime || 0,
533
542
  offset: validated.offset,
534
543
  limit: validated.limit,
@@ -664,12 +673,12 @@ export class SearchWebTool {
664
673
  getStats() {
665
674
  return {
666
675
  provider: this.isCreatorModeFallback ? {
667
- name: 'google',
668
- backend: 'Google Custom Search API (Creator Mode)',
669
- note: 'Using Google Search API directly'
676
+ name: this.creatorBackendName(),
677
+ backend: `${CRAWLFORGE_BACKENDS[this.creatorBackendName()]} (Creator Mode)`,
678
+ note: 'Calling the search API directly'
670
679
  } : {
671
680
  name: 'crawlforge',
672
- backend: 'Google Search',
681
+ backend: 'Web search via CrawlForge API',
673
682
  capabilities: SearchProviderFactory.getProviderCapabilities('crawlforge')
674
683
  },
675
684
  creatorMode: this.isCreatorModeFallback || false,
@@ -680,12 +689,17 @@ export class SearchWebTool {
680
689
  };
681
690
  }
682
691
 
692
+ /** Which search API Creator Mode calls directly: 'brave' or 'google'. */
693
+ creatorBackendName() {
694
+ return this.searchAdapter instanceof BraveSearchAdapter ? 'brave' : 'google';
695
+ }
696
+
683
697
  getProviderInfo() {
684
698
  return {
685
- activeProvider: this.isCreatorModeFallback ? 'google' : 'crawlforge',
699
+ activeProvider: this.isCreatorModeFallback ? this.creatorBackendName() : 'crawlforge',
686
700
  backend: this.isCreatorModeFallback
687
- ? 'Google Custom Search API (Creator Mode)'
688
- : 'Google Search via CrawlForge API',
701
+ ? `${CRAWLFORGE_BACKENDS[this.creatorBackendName()]} (Creator Mode)`
702
+ : 'Web search via CrawlForge API',
689
703
  capabilities: SearchProviderFactory.getProviderCapabilities(
690
704
  this.isCreatorModeFallback ? 'google' : 'crawlforge'
691
705
  ),
@@ -2,7 +2,7 @@
2
2
  * serp_rank tool
3
3
  *
4
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
5
+ * keyword — the SERP position that search_web, a general web search, cannot
6
6
  * give you. Backed by the DataForSEO SERP adapter.
7
7
  *
8
8
  * Honesty rule: if DataForSEO is not configured, this returns an explained
@@ -12,6 +12,46 @@
12
12
  import { z } from 'zod';
13
13
  import { DataForSEOSearchAdapter } from './adapters/dataforseoSearch.js';
14
14
 
15
+ /** Words too common to tell a relevant result from an unrelated one. */
16
+ const STOPWORDS = new Set([
17
+ 'the', 'and', 'for', 'with', 'how', 'what', 'why', 'who', 'when', 'where',
18
+ 'are', 'you', 'your', 'best', 'from', 'near', 'can', 'does', 'that', 'this',
19
+ ]);
20
+
21
+ /** Below this share of top results mentioning the keyword, a SERP is flagged. */
22
+ const DECOY_THRESHOLD = 0.2;
23
+ /** Fewer results than this is too little to judge. */
24
+ const DECOY_MIN_RESULTS = 3;
25
+
26
+ /** The keyword's distinctive words: lowercased, 3+ characters, stopwords out. */
27
+ function keywordTokens(keyword) {
28
+ const words = String(keyword).toLowerCase().split(/[^\p{L}\p{N}]+/u);
29
+ return [...new Set(words.filter((w) => w.length >= 3 && !STOPWORDS.has(w)))];
30
+ }
31
+
32
+ /**
33
+ * Whether a SERP looks like a decoy. Google serves some scrapers real-looking
34
+ * results that have nothing to do with the query, and DataForSEO passes such
35
+ * a page on as a valid one: on 2026-10-19 "crawlforge" came back as 19
36
+ * crawl/MCP pages, none naming CrawlForge, while Search Console had the site
37
+ * at #1 and a second lookup a minute later did too. So check whether the top
38
+ * results mention the keyword at all.
39
+ *
40
+ * @returns {{ checked: number, matching: number, suspect: boolean } | null}
41
+ * null when the keyword has no usable word or there are too few results.
42
+ */
43
+ export function assessSerpRelevance(keyword, results) {
44
+ const tokens = keywordTokens(keyword);
45
+ const top = results.slice(0, RESULTS_LIMIT);
46
+ if (tokens.length === 0 || top.length < DECOY_MIN_RESULTS) return null;
47
+
48
+ const matching = top.filter((r) => {
49
+ const text = `${r.title ?? ''} ${r.url ?? ''} ${r.snippet ?? ''}`.toLowerCase();
50
+ return tokens.some((t) => text.includes(t));
51
+ }).length;
52
+ return { checked: top.length, matching, suspect: matching / top.length < DECOY_THRESHOLD };
53
+ }
54
+
15
55
  /** How many top organic results to return as the SERP listing (`results`).
16
56
  * The first Google page is ~10; bounding it keeps the tool payload small while
17
57
  * still surfacing the competitors that matter. */
@@ -121,6 +161,9 @@ export class SerpRankTool {
121
161
  snippet: it.snippet,
122
162
  }));
123
163
 
164
+ const relevance = assessSerpRelevance(validated.keyword, results);
165
+ const suspect = relevance?.suspect ?? false;
166
+
124
167
  return {
125
168
  configured: true,
126
169
  keyword: validated.keyword,
@@ -140,6 +183,16 @@ export class SerpRankTool {
140
183
  checkUrl: meta.checkUrl, // link to view the real SERP
141
184
  cost: meta.cost, // USD charged by DataForSEO for this lookup
142
185
  checkedAt: new Date().toISOString(),
186
+ // true when the top results barely mention the keyword: a likely decoy SERP
187
+ suspect,
188
+ ...(suspect
189
+ ? {
190
+ warnings: [
191
+ `These results look unrelated to the keyword (${relevance.matching} of the top ${relevance.checked} mention it). ` +
192
+ 'Google may have served a decoy results page. Re-run the lookup, or check Search Console, before acting on this rank.',
193
+ ],
194
+ }
195
+ : {}),
143
196
  };
144
197
  }
145
198
  }
@@ -38,7 +38,7 @@ export const DEFAULT_CONFIGS = {
38
38
  }
39
39
  },
40
40
 
41
- // API operations (Google Search, etc.)
41
+ // API operations (web search, etc.)
42
42
  api: {
43
43
  retry: {
44
44
  maxRetries: 4,
@@ -94,9 +94,10 @@ export function extractLinks(html, baseUrl) {
94
94
  while ((match = hrefRegex.exec(html)) !== null) {
95
95
  const href = match[1];
96
96
 
97
- if (href && !href.startsWith('#') && !href.startsWith('javascript:')) {
97
+ if (href && !href.startsWith('#')) {
98
98
  try {
99
99
  const absoluteUrl = new URL(href, baseUrl);
100
+ if (['javascript:', 'data:', 'vbscript:'].includes(absoluteUrl.protocol)) continue;
100
101
  links.add(absoluteUrl.toString());
101
102
  } catch {
102
103
  // Invalid URL, skip it