crawlforge-mcp-server 6.20.0 → 6.21.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 +74 -296
- package/README.md +2 -2
- package/package.json +3 -3
- package/server.js +6 -4
- package/src/core/crawlers/BFSCrawler.js +1 -1
- package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +3 -3
- package/src/skills/agent-skills/crawlforge-stealth-browsing/references/engine-selection.md +2 -2
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +3 -3
- package/src/tools/crawl/mapSite.js +2 -1
- package/src/tools/scrape/unifiedScrape.js +4 -2
- package/src/tools/search/adapters/braveSearch.js +160 -0
- package/src/tools/search/adapters/crawlforgeSearch.js +5 -2
- package/src/tools/search/adapters/searchProviderFactory.js +20 -11
- package/src/tools/search/providers/searxng.js +5 -0
- package/src/tools/search/redditSearch.js +1 -1
- package/src/tools/search/searchWeb.js +25 -11
- package/src/tools/search/serpRank.js +54 -1
- package/src/utils/ErrorHandlingConfig.js +1 -1
- package/src/utils/urlNormalizer.js +2 -1
- package/src/utils/inputValidation.js +0 -766
- package/src/utils/securityMiddleware.js +0 -416
|
@@ -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('#')
|
|
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('#')
|
|
114
|
+
if (href.startsWith('#')) return;
|
|
115
115
|
try {
|
|
116
|
-
const
|
|
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,160 @@
|
|
|
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.
|
|
15
|
+
* - `search_lang` and `country` are validated: `zh`, `pt` and `EN` are a 422
|
|
16
|
+
* where `zh-hans`, `pt-br` and `en` pass. Common forms are normalised, and a
|
|
17
|
+
* locale still refused is retried once without it.
|
|
18
|
+
* - `description` carries `<strong>` highlighting; there is no total count.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const ENDPOINT = 'https://api.search.brave.com/res/v1/web/search';
|
|
22
|
+
const MAX_COUNT = 20;
|
|
23
|
+
const MAX_PAGE = 9;
|
|
24
|
+
const TIMEOUT_MS = 15000;
|
|
25
|
+
|
|
26
|
+
const FRESHNESS = { d1: 'pd', w1: 'pw', m1: 'pm', y1: 'py' };
|
|
27
|
+
|
|
28
|
+
const LANGUAGE_ALIASES = {
|
|
29
|
+
zh: 'zh-hans', 'zh-cn': 'zh-hans', 'zh-sg': 'zh-hans',
|
|
30
|
+
'zh-tw': 'zh-hant', 'zh-hk': 'zh-hant',
|
|
31
|
+
pt: 'pt-br',
|
|
32
|
+
};
|
|
33
|
+
const REGIONAL_LANGUAGES = new Set(['zh-hans', 'zh-hant', 'pt-br', 'pt-pt', 'en-gb']);
|
|
34
|
+
|
|
35
|
+
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
|
|
36
|
+
|
|
37
|
+
/** `lang_en`, `lang_zh-CN`, `pt` → the code Brave validates; undefined to send none. */
|
|
38
|
+
export function braveLanguage(lr) {
|
|
39
|
+
const code = String(lr ?? '').replace(/^lang_/, '').trim().toLowerCase();
|
|
40
|
+
if (!code) return undefined;
|
|
41
|
+
if (LANGUAGE_ALIASES[code]) return LANGUAGE_ALIASES[code];
|
|
42
|
+
if (REGIONAL_LANGUAGES.has(code)) return code;
|
|
43
|
+
return code.split('-')[0];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Brave's highlighted HTML as the plain text Custom Search puts in `snippet`. */
|
|
47
|
+
export function plainText(html) {
|
|
48
|
+
return String(html ?? '')
|
|
49
|
+
.replace(/<[^>]*>/g, '')
|
|
50
|
+
.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (entity, name) => {
|
|
51
|
+
if (name[0] === '#') {
|
|
52
|
+
const code = name[1] === 'x' || name[1] === 'X' ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10);
|
|
53
|
+
return code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : entity;
|
|
54
|
+
}
|
|
55
|
+
return ENTITIES[name.toLowerCase()] ?? entity;
|
|
56
|
+
})
|
|
57
|
+
.replace(/\s+/g, ' ')
|
|
58
|
+
.trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function toItem(result) {
|
|
62
|
+
if (!result?.url) return [];
|
|
63
|
+
const pagemap = {};
|
|
64
|
+
if (result.thumbnail?.src) pagemap.cse_thumbnail = [{ src: result.thumbnail.src }];
|
|
65
|
+
if (result.page_age) pagemap.metatags = [{ 'article:published_time': result.page_age }];
|
|
66
|
+
|
|
67
|
+
let displayLink = result.meta_url?.hostname;
|
|
68
|
+
if (!displayLink) {
|
|
69
|
+
try { displayLink = new URL(result.url).hostname; } catch { displayLink = ''; }
|
|
70
|
+
}
|
|
71
|
+
return [{
|
|
72
|
+
title: plainText(result.title),
|
|
73
|
+
link: result.url,
|
|
74
|
+
snippet: plainText(result.description),
|
|
75
|
+
displayLink,
|
|
76
|
+
formattedUrl: result.url,
|
|
77
|
+
htmlTitle: result.title ?? '',
|
|
78
|
+
htmlSnippet: result.description ?? '',
|
|
79
|
+
...(Object.keys(pagemap).length > 0 ? { pagemap } : {}),
|
|
80
|
+
}];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export class BraveSearchAdapter {
|
|
84
|
+
constructor(apiKey) {
|
|
85
|
+
if (!apiKey) {
|
|
86
|
+
throw new Error('Brave Search API key is required');
|
|
87
|
+
}
|
|
88
|
+
this.apiKey = apiKey;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {Object} params - Custom Search parameters, as SearchWebTool sends them
|
|
93
|
+
* (query, num, start, lr, safe, dateRestrict, cr)
|
|
94
|
+
* @returns {Promise<Object>} Custom Search-shaped results, plus `provider: 'brave'`
|
|
95
|
+
*/
|
|
96
|
+
async search(params) {
|
|
97
|
+
const startedAt = Date.now();
|
|
98
|
+
const count = Math.min(Math.max(1, params.num || 10), MAX_COUNT);
|
|
99
|
+
const page = Math.floor(Math.max(0, (params.start || 1) - 1) / count);
|
|
100
|
+
if (page > MAX_PAGE) return this.toResult([], startedAt);
|
|
101
|
+
|
|
102
|
+
const base = new URLSearchParams({
|
|
103
|
+
q: params.query,
|
|
104
|
+
count: String(count),
|
|
105
|
+
safesearch: params.safe === 'active' ? 'strict' : 'off',
|
|
106
|
+
});
|
|
107
|
+
if (page > 0) base.append('offset', String(page));
|
|
108
|
+
if (FRESHNESS[params.dateRestrict]) base.append('freshness', FRESHNESS[params.dateRestrict]);
|
|
109
|
+
|
|
110
|
+
const localized = new URLSearchParams(base);
|
|
111
|
+
const language = braveLanguage(params.lr);
|
|
112
|
+
const country = String(params.cr ?? '').replace(/^country/, '').trim().toUpperCase();
|
|
113
|
+
if (language) localized.append('search_lang', language);
|
|
114
|
+
if (/^[A-Z]{2}$/.test(country)) localized.append('country', country);
|
|
115
|
+
|
|
116
|
+
let response = await this.send(localized);
|
|
117
|
+
if (response.status === 422 && localized.toString() !== base.toString()) {
|
|
118
|
+
response = await this.send(base);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!response.ok) {
|
|
122
|
+
let detail = response.statusText;
|
|
123
|
+
try { detail = (await response.json())?.error?.detail || detail; } catch { /* no body */ }
|
|
124
|
+
throw new Error(
|
|
125
|
+
response.status === 401 || response.status === 403
|
|
126
|
+
? 'Invalid Brave Search API key'
|
|
127
|
+
: response.status === 429
|
|
128
|
+
? 'Brave Search API rate limit or credit balance reached. Try again later.'
|
|
129
|
+
: `Brave Search failed with status ${response.status}: ${detail}`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const data = await response.json();
|
|
134
|
+
return this.toResult((data?.web?.results ?? []).flatMap(toItem), startedAt);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async send(params) {
|
|
138
|
+
try {
|
|
139
|
+
return await fetch(`${ENDPOINT}?${params.toString()}`, {
|
|
140
|
+
method: 'GET',
|
|
141
|
+
headers: { Accept: 'application/json', 'X-Subscription-Token': this.apiKey },
|
|
142
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
143
|
+
});
|
|
144
|
+
} catch (error) {
|
|
145
|
+
throw new Error(`Network error connecting to Brave Search API: ${error.message}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
toResult(items, startedAt) {
|
|
150
|
+
return {
|
|
151
|
+
items,
|
|
152
|
+
searchInformation: { totalResults: null, searchTime: (Date.now() - startedAt) / 1000 },
|
|
153
|
+
queries: {},
|
|
154
|
+
context: {},
|
|
155
|
+
provider: 'brave',
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
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
|
|
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
|
|
6
|
-
* - Creator Mode: Uses
|
|
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,
|
|
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
|
|
33
|
-
'Set GOOGLE_API_KEY and GOOGLE_SEARCH_ENGINE_ID
|
|
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
|
-
'
|
|
145
|
-
'No
|
|
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
|
|
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
|
|
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
|
|
|
@@ -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:
|
|
409
|
-
backend:
|
|
410
|
-
note: '
|
|
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:
|
|
422
|
+
backend: crawlforgeBackend(bestResults?.provider)
|
|
414
423
|
},
|
|
415
424
|
|
|
416
425
|
// Add localization information
|
|
@@ -664,12 +673,12 @@ export class SearchWebTool {
|
|
|
664
673
|
getStats() {
|
|
665
674
|
return {
|
|
666
675
|
provider: this.isCreatorModeFallback ? {
|
|
667
|
-
name:
|
|
668
|
-
backend:
|
|
669
|
-
note: '
|
|
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: '
|
|
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 ?
|
|
699
|
+
activeProvider: this.isCreatorModeFallback ? this.creatorBackendName() : 'crawlforge',
|
|
686
700
|
backend: this.isCreatorModeFallback
|
|
687
|
-
?
|
|
688
|
-
: '
|
|
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
|
|
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
|
}
|
|
@@ -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('#')
|
|
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
|