crawlforge-mcp-server 6.19.1 → 6.20.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/package.json +1 -1
- package/server.js +2 -2
- package/src/core/ActionExecutor.js +8 -1
- package/src/core/AuthManager.js +3 -2
- package/src/tools/scrape/formats.js +17 -0
- package/src/tools/scrape/unifiedScrape.js +4 -1
- package/src/tools/search/adapters/dataforseoSearch.js +4 -4
- package/src/tools/search/serpRank.js +1 -1
- package/src/utils/hostRateLimiter.js +4 -2
- package/src/utils/robotsGate.js +27 -0
- package/src/utils/secretMask.js +37 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.20.0",
|
|
4
4
|
"mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
|
|
5
5
|
"description": "CrawlForge MCP Server - Professional Model Context Protocol server with 31 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",
|
package/server.js
CHANGED
|
@@ -113,7 +113,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
|
|
|
113
113
|
// Create the server
|
|
114
114
|
const server = new McpServer({
|
|
115
115
|
name: "crawlforge",
|
|
116
|
-
version: "6.
|
|
116
|
+
version: "6.20.0",
|
|
117
117
|
description: "Production-ready MCP server with 31 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, stateful browser sessions with element refs, deep research, structured extraction, embedded JavaScript state extraction, real Google SERP rank tracking, Reddit search via community archives, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
|
|
118
118
|
homepage: "https://www.crawlforge.dev",
|
|
119
119
|
icon: "https://www.crawlforge.dev/icon.png",
|
|
@@ -597,7 +597,7 @@ registerToolIfEnabled("serp_rank", {
|
|
|
597
597
|
location_code: z.number().optional().describe("Numeric DataForSEO location code (overrides location_name)"),
|
|
598
598
|
language_code: z.string().optional().describe("Language code (e.g. 'en')"),
|
|
599
599
|
device: z.enum(["desktop", "mobile"]).optional().describe("Device to emulate"),
|
|
600
|
-
depth: z.number().min(10).max(200).optional().describe("How many results to scan, 10-200 (default
|
|
600
|
+
depth: z.number().min(10).max(200).optional().describe("How many results to scan, 10-200 (default 30; DataForSEO bills ~$0.002 per 10 and gets slower the deeper it goes)")
|
|
601
601
|
},
|
|
602
602
|
outputSchema: OUTPUT_SCHEMAS.serp_rank
|
|
603
603
|
}, withAuth("serp_rank", async ({ keyword, target, location_name, location_code, language_code, device, depth }) => {
|
|
@@ -8,12 +8,16 @@ import BrowserProcessor from './processing/BrowserProcessor.js';
|
|
|
8
8
|
import { EventEmitter } from 'events';
|
|
9
9
|
import { createHash } from 'node:crypto';
|
|
10
10
|
import { assertUrlAllowed, assertNavigationAllowed } from '../utils/ssrfGuard.js';
|
|
11
|
-
import { browserPreflight, redirectGate, pageMoveGate, gateRefusalCode } from '../utils/robotsGate.js';
|
|
11
|
+
import { browserPreflight, redirectGate, pageMoveGate, gateRefusalCode, paceBrowserAction } from '../utils/robotsGate.js';
|
|
12
12
|
import { isRef, resolveRef, captureSnapshot } from './browser/snapshot.js';
|
|
13
13
|
import { settlePage } from './browser/settle.js';
|
|
14
14
|
import { handleConsent } from './browser/consent.js';
|
|
15
15
|
import { stealthDocumentVerdict } from '../utils/stealthVerdict.js';
|
|
16
16
|
|
|
17
|
+
// Actions that can make the page send a request (follow a link, submit a form,
|
|
18
|
+
// trigger a load): each waits out the page host's Crawl-delay first.
|
|
19
|
+
const REQUESTING_ACTION_TYPES = new Set(['click', 'press', 'select', 'check']);
|
|
20
|
+
|
|
17
21
|
// executeJavaScript hardening limits (only relevant when the deploy-time flag
|
|
18
22
|
// ALLOW_JAVASCRIPT_EXECUTION=true is set; JS execution stays off by default).
|
|
19
23
|
const JS_MAX_SCRIPT_LENGTH = parseInt(process.env.JS_MAX_SCRIPT_LENGTH || '10000', 10);
|
|
@@ -799,6 +803,9 @@ export class ActionExecutor extends EventEmitter {
|
|
|
799
803
|
// A navigate leaves the page it is on and gates the URL it is sent to.
|
|
800
804
|
if (action.type !== 'navigate') await this.assertPageAllowed(page, browserOptions);
|
|
801
805
|
const urlBefore = page.url();
|
|
806
|
+
// A navigate waits its turn in its own pre-flight; these can send the page
|
|
807
|
+
// somewhere too, and the request goes out before anything can ask.
|
|
808
|
+
if (REQUESTING_ACTION_TYPES.has(action.type)) await paceBrowserAction(urlBefore);
|
|
802
809
|
const actionResult = await this.executeActionInternal(page, action, executionContext);
|
|
803
810
|
await this.assertPageAllowed(page, browserOptions);
|
|
804
811
|
// A click that followed a link or a submit that posted a form is a
|
package/src/core/AuthManager.js
CHANGED
|
@@ -11,7 +11,7 @@ import { randomUUID } from 'crypto';
|
|
|
11
11
|
import { isCreatorModeVerified } from './creatorMode.js';
|
|
12
12
|
import { resolveApiEndpoint } from './endpointGuard.js';
|
|
13
13
|
import { logger } from '../utils/Logger.js';
|
|
14
|
-
import { maskSecrets } from '../utils/secretMask.js';
|
|
14
|
+
import { maskSecrets, maskLocalPaths } from '../utils/secretMask.js';
|
|
15
15
|
import { scrapeFormatSurcharge } from '../tools/scrape/formats.js';
|
|
16
16
|
import { scrapeEscalationSurcharge } from '../tools/scrape/escalation.js';
|
|
17
17
|
import { agentEscalationSurcharge } from '../tools/agent/escalation.js';
|
|
@@ -385,7 +385,8 @@ class AuthManager {
|
|
|
385
385
|
creditsUsed,
|
|
386
386
|
// Never send raw tool params to the backend — they can carry third-party
|
|
387
387
|
// API keys, auth headers, or webhook secrets. Mask before it leaves the process.
|
|
388
|
-
|
|
388
|
+
// Local file paths too: they name the customer's clients and folders.
|
|
389
|
+
requestData: maskLocalPaths(maskSecrets(requestData)),
|
|
389
390
|
responseStatus,
|
|
390
391
|
processingTime,
|
|
391
392
|
timestamp: new Date().toISOString(),
|
|
@@ -51,6 +51,23 @@ export const FormatSchema = z.union([
|
|
|
51
51
|
|
|
52
52
|
const QUERY_FORMATS = new Set(['highlights', 'question']);
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* True when `formats` holds at most one `highlights` and one `question`. The
|
|
56
|
+
* result has one slot of each, so a second entry overwrote the first while
|
|
57
|
+
* the add-on still charged; the schema refuses it instead.
|
|
58
|
+
*
|
|
59
|
+
* @param {Array<unknown>} formats
|
|
60
|
+
* @returns {boolean}
|
|
61
|
+
*/
|
|
62
|
+
export function oneOfEachQueryFormat(formats) {
|
|
63
|
+
return [...QUERY_FORMATS].every(
|
|
64
|
+
(type) => formats.filter((fmt) => fmt && typeof fmt === 'object' && fmt.type === type).length <= 1
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const ONE_OF_EACH_QUERY_FORMAT_MESSAGE =
|
|
69
|
+
"At most one 'highlights' and one 'question' format per call; make another call for another query";
|
|
70
|
+
|
|
54
71
|
/**
|
|
55
72
|
* What the query-scoped formats add to `scrape`'s base price: 1 credit once
|
|
56
73
|
* per call when any `highlights` or `question` format is present, 3 more
|
|
@@ -19,6 +19,7 @@ import { segmentUnits, rankUnits } from 'crawlforge-extractors';
|
|
|
19
19
|
import { stealthDocumentVerdict as documentVerdict } from '../../utils/stealthVerdict.js';
|
|
20
20
|
import {
|
|
21
21
|
SCRAPE_STRING_FORMATS, JsonFormatSchema, HighlightsFormatSchema, QuestionFormatSchema, FormatSchema,
|
|
22
|
+
oneOfEachQueryFormat, ONE_OF_EACH_QUERY_FORMAT_MESSAGE,
|
|
22
23
|
scrapeFormatSurcharge
|
|
23
24
|
} from './formats.js';
|
|
24
25
|
import { SCRAPE_ESCALATION_SHAPE, SCRAPE_ESCALATION_CREDITS, aBrowserMightPass } from './escalation.js';
|
|
@@ -57,7 +58,9 @@ const isQueryFormat = (fmt) => Boolean(fmt) && typeof fmt === 'object' && (fmt.t
|
|
|
57
58
|
// keeping its own copy (Phase 0, 0.3).
|
|
58
59
|
export const SCRAPE_INPUT_SHAPE = {
|
|
59
60
|
url: z.string().url().describe('The URL to scrape'),
|
|
60
|
-
formats: z.array(FormatSchema).min(1)
|
|
61
|
+
formats: z.array(FormatSchema).min(1)
|
|
62
|
+
.refine(oneOfEachQueryFormat, ONE_OF_EACH_QUERY_FORMAT_MESSAGE)
|
|
63
|
+
.optional().default(['markdown']).describe('Formats to return (default: ["markdown"]); at most one highlights and one question format per call'),
|
|
61
64
|
onlyMainContent: z.boolean().optional().default(true).describe('Strip boilerplate via Readability (default: true)'),
|
|
62
65
|
// Pass-through to fetchAndParse
|
|
63
66
|
timeoutMs: z.number().min(1000).max(60000).optional().default(15000).describe('Fetch timeout in ms'),
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
* POST https://api.dataforseo.com/v3/serp/google/organic/live/advanced
|
|
13
13
|
*
|
|
14
14
|
* Cost: US$0.002 per 10 results of `depth` on Live Advanced — measured live,
|
|
15
|
-
* not estimated: depth 10 bills $0.002,
|
|
16
|
-
* depth
|
|
15
|
+
* not estimated: depth 10 bills $0.002, depth 20 bills $0.004, and depth 100
|
|
16
|
+
* bills $0.02, so the depth 30 default is $0.006. Deeper scans are also slower (see the timeout note
|
|
17
17
|
* below), so raise `depth` only when a rank below the default is worth paying
|
|
18
18
|
* for. For high-volume scheduled tracking, DataForSEO's task-based "Standard"
|
|
19
19
|
* queue (task_post → tasks_ready → task_get) is cheaper; swap the endpoint +
|
|
@@ -51,7 +51,7 @@ export class DataForSEOSearchAdapter {
|
|
|
51
51
|
* @param {number} [params.locationCode] - Numeric DataForSEO location code (overrides locationName)
|
|
52
52
|
* @param {string} [params.languageCode='en'] - Language code
|
|
53
53
|
* @param {('desktop'|'mobile')} [params.device='desktop'] - Device to emulate
|
|
54
|
-
* @param {number} [params.depth=
|
|
54
|
+
* @param {number} [params.depth=30] - How many results to scan (billed per 10)
|
|
55
55
|
* @returns {Promise<{items: Array<Object>, meta: Object}>} Normalized organic results + metadata
|
|
56
56
|
*/
|
|
57
57
|
async search(params) {
|
|
@@ -61,7 +61,7 @@ export class DataForSEOSearchAdapter {
|
|
|
61
61
|
locationCode,
|
|
62
62
|
languageCode = 'en',
|
|
63
63
|
device = 'desktop',
|
|
64
|
-
depth =
|
|
64
|
+
depth = 30,
|
|
65
65
|
} = params;
|
|
66
66
|
|
|
67
67
|
if (!keyword) {
|
|
@@ -27,7 +27,7 @@ const SerpRankSchema = z.object({
|
|
|
27
27
|
// DataForSEO bills per 10 results scanned and gets slower the deeper it goes,
|
|
28
28
|
// so the default stays shallow: 20 covers Google's first two pages for $0.004.
|
|
29
29
|
// Raise it (max 200, their cap) when a deeper position is worth the spend.
|
|
30
|
-
depth: z.number().int().min(10).max(200).optional().default(
|
|
30
|
+
depth: z.number().int().min(10).max(200).optional().default(30),
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
/** Reduce a domain or URL to a bare, comparable host: "https://www.Example.com/x" → "example.com".
|
|
@@ -175,9 +175,11 @@ export async function throttleHost(url, options = {}) {
|
|
|
175
175
|
waits.push(state.lastRequestAt + crawlDelayMs - Date.now());
|
|
176
176
|
}
|
|
177
177
|
const wait = Math.max(0, ...waits);
|
|
178
|
+
// Recorded before the sleep, as the time this request will go out: a
|
|
179
|
+
// concurrent caller then queues a delay behind it instead of reading the
|
|
180
|
+
// same lastRequestAt and waking at the same moment.
|
|
181
|
+
state.lastRequestAt = Date.now() + wait;
|
|
178
182
|
if (wait > 0) await sleep(wait);
|
|
179
|
-
|
|
180
|
-
state.lastRequestAt = Date.now();
|
|
181
183
|
}
|
|
182
184
|
|
|
183
185
|
if (config.rateLimit.perDomain === false) return; // our own throttle disabled
|
package/src/utils/robotsGate.js
CHANGED
|
@@ -362,6 +362,33 @@ export async function browserPreflight(url, options = {}) {
|
|
|
362
362
|
return [...decision.warnings, ...crawlDelayWarning(url, decision.crawlDelayMs)];
|
|
363
363
|
}
|
|
364
364
|
|
|
365
|
+
/**
|
|
366
|
+
* Wait the page's host out before a browser action that can send a request:
|
|
367
|
+
* a click on a link, a submit, an Enter press, a select or checkbox wired to
|
|
368
|
+
* a load. The browser makes that request the moment the action runs and the
|
|
369
|
+
* gate only sees where it led afterwards (`alreadyRequested`), so the wait
|
|
370
|
+
* has to come first. Spaced by the CURRENT page's host — where the action will
|
|
371
|
+
* lead is not known until it has run.
|
|
372
|
+
*
|
|
373
|
+
* Reads Crawl-delay from the cached robots.txt with no allow/deny decision
|
|
374
|
+
* and no audit row: the page itself already passed the gate, and an override
|
|
375
|
+
* is recorded once per URL, not once per click. An action that sends nothing
|
|
376
|
+
* still takes a slot; overspacing a polite crawl is the safe way to be wrong.
|
|
377
|
+
*
|
|
378
|
+
* @param {string} url the page's current URL
|
|
379
|
+
* @returns {Promise<void>}
|
|
380
|
+
*/
|
|
381
|
+
export async function paceBrowserAction(url) {
|
|
382
|
+
if (!/^https?:/i.test(url || '')) return;
|
|
383
|
+
let crawlDelayMs = 0;
|
|
384
|
+
try {
|
|
385
|
+
crawlDelayMs = (await checkerFor(resolveUserAgent()).fetchCrawlDelay(url)) * 1000;
|
|
386
|
+
} catch {
|
|
387
|
+
// Unreadable robots.txt asks for no delay (see robotsPreflight).
|
|
388
|
+
}
|
|
389
|
+
await throttleHost(url, { crawlDelayMs });
|
|
390
|
+
}
|
|
391
|
+
|
|
365
392
|
/** Test/diagnostic hook: drop every cached robots.txt. */
|
|
366
393
|
export function _resetRobotsGate() {
|
|
367
394
|
checkers.clear();
|
package/src/utils/secretMask.js
CHANGED
|
@@ -81,6 +81,43 @@ export function maskSecrets(obj, depth = 0) {
|
|
|
81
81
|
return obj;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// A string that names a place on the customer's disk: home-relative, a file
|
|
85
|
+
// URL, a Windows drive or UNC share, or an absolute POSIX path under a root
|
|
86
|
+
// that holds user files. A bare "/" or a URL path like "/pricing" is not one.
|
|
87
|
+
const LOCAL_PATH_RE = /^(?:~[\\/]|file:\/\/|[A-Za-z]:[\\/]|\\\\|\/(?:Users|home|root|tmp|private|var|mnt|Volumes|media|opt|srv)\/)/;
|
|
88
|
+
// process_document reads `source` from disk for these, relative paths included.
|
|
89
|
+
const LOCAL_SOURCE_TYPES = new Set(['file', 'pdf_file']);
|
|
90
|
+
|
|
91
|
+
/** "[local file]" plus the extension, so usage stays countable by file type. */
|
|
92
|
+
function maskPath(value) {
|
|
93
|
+
const ext = /\.([A-Za-z0-9]{1,8})$/.exec(value.split(/[\\/]/).pop() || '');
|
|
94
|
+
return ext ? `[local file].${ext[1].toLowerCase()}` : '[local file]';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Deep-clone obj with every local file path reduced to its extension. For the
|
|
99
|
+
* usage report, which leaves the machine: a path names the customer's clients
|
|
100
|
+
* and folders, and maskSecrets matches key names, so it never saw one. Local
|
|
101
|
+
* logs keep the path. Does NOT mutate the original.
|
|
102
|
+
* @param {*} obj
|
|
103
|
+
* @param {number} depth - internal recursion guard
|
|
104
|
+
* @returns {*}
|
|
105
|
+
*/
|
|
106
|
+
export function maskLocalPaths(obj, depth = 0) {
|
|
107
|
+
if (depth > 10) return obj;
|
|
108
|
+
if (typeof obj === 'string') return LOCAL_PATH_RE.test(obj) ? maskPath(obj) : obj;
|
|
109
|
+
if (Array.isArray(obj)) return obj.map(item => maskLocalPaths(item, depth + 1));
|
|
110
|
+
if (obj !== null && typeof obj === 'object') {
|
|
111
|
+
const localSource = LOCAL_SOURCE_TYPES.has(obj.sourceType) && typeof obj.source === 'string';
|
|
112
|
+
const result = {};
|
|
113
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
114
|
+
result[key] = key === 'source' && localSource ? maskPath(value) : maskLocalPaths(value, depth + 1);
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
return obj;
|
|
119
|
+
}
|
|
120
|
+
|
|
84
121
|
/**
|
|
85
122
|
* Redact secrets from an Error's message and stack.
|
|
86
123
|
* Returns a new plain-object representation safe for logging.
|