@ariangibson/firecrawl-lite-mcp-server 1.1.2 → 1.4.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/README.md +362 -181
- package/dist/browser.js +145 -0
- package/dist/config.js +67 -0
- package/dist/firecrawlApi.js +86 -0
- package/dist/htmlToMarkdown.js +263 -0
- package/dist/index.js +362 -359
- package/dist/scraper.js +138 -0
- package/dist/utils.js +124 -0
- package/package.json +13 -7
package/dist/scraper.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// The scraper: everything behind "give me this page as Markdown / as a PNG".
|
|
2
|
+
//
|
|
3
|
+
// Interface: createScraper(config, deps?) -> { scrape, screenshot }.
|
|
4
|
+
// Behind it: URL validation, proxy and user-agent rotation, retries, the
|
|
5
|
+
// browser session, scroll/settle heuristics, and HTML -> Markdown. Tests
|
|
6
|
+
// exercise all of that through the same two methods callers use, by passing
|
|
7
|
+
// a stub `launchBrowser` and fake `sleep`/`now`.
|
|
8
|
+
import { withBrowserPage, withRetries, randomBetween, defaultBrowserDeps, } from './browser.js';
|
|
9
|
+
import { createRotator } from './config.js';
|
|
10
|
+
import { htmlToMarkdown, htmlToText } from './htmlToMarkdown.js';
|
|
11
|
+
import { DEFAULT_USER_AGENT, isValidUrl, sanitizeUrl } from './utils.js';
|
|
12
|
+
const INVALID_URL_ERROR = 'Invalid URL format. Only HTTP and HTTPS URLs are allowed.';
|
|
13
|
+
function emptyScrape(url, error) {
|
|
14
|
+
return { url, title: '', content: '', markdown: '', html: '', success: false, error };
|
|
15
|
+
}
|
|
16
|
+
function errorMessage(error) {
|
|
17
|
+
return error instanceof Error ? error.message : String(error);
|
|
18
|
+
}
|
|
19
|
+
// Poll until the rendered text stops growing (or maxWaitMs elapses), so pages
|
|
20
|
+
// that inject content after load — setTimeout, XHR/AJAX, lazy hydration — are
|
|
21
|
+
// captured. Fast/static pages return after the first couple of stable reads.
|
|
22
|
+
export async function waitForContentToSettle(page, maxWaitMs, deps) {
|
|
23
|
+
const intervalMs = 600;
|
|
24
|
+
const requiredStableReads = 2;
|
|
25
|
+
let lastLength = -1;
|
|
26
|
+
let stableReads = 0;
|
|
27
|
+
const start = deps.now();
|
|
28
|
+
while (deps.now() - start < maxWaitMs) {
|
|
29
|
+
let length = 0;
|
|
30
|
+
try {
|
|
31
|
+
length = await page.evaluate(() => document.body?.innerText?.length || 0);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
break; // navigation/detached — stop waiting
|
|
35
|
+
}
|
|
36
|
+
if (length === lastLength) {
|
|
37
|
+
if (++stableReads >= requiredStableReads)
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
stableReads = 0;
|
|
42
|
+
lastLength = length;
|
|
43
|
+
}
|
|
44
|
+
await deps.sleep(intervalMs);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function createScraper(config, overrides = {}) {
|
|
48
|
+
const deps = { ...defaultBrowserDeps, now: Date.now, ...overrides };
|
|
49
|
+
// Each browser session advances the proxy / user-agent rotation.
|
|
50
|
+
const nextProxy = createRotator(config.proxy.urls);
|
|
51
|
+
const nextUserAgent = createRotator(config.scraping.userAgents);
|
|
52
|
+
function nextSession(overrides = {}) {
|
|
53
|
+
return {
|
|
54
|
+
userAgent: nextUserAgent() ?? DEFAULT_USER_AGENT,
|
|
55
|
+
viewport: { width: config.scraping.viewportWidth, height: config.scraping.viewportHeight },
|
|
56
|
+
proxy: { url: nextProxy(), username: config.proxy.username, password: config.proxy.password },
|
|
57
|
+
delayMin: config.scraping.delayMin,
|
|
58
|
+
delayMax: config.scraping.delayMax,
|
|
59
|
+
...overrides,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
// Single scrape attempt using the next proxy/user agent in rotation
|
|
63
|
+
async function scrapeOnce(url, onlyMainContent) {
|
|
64
|
+
if (!isValidUrl(url))
|
|
65
|
+
return emptyScrape(url, INVALID_URL_ERROR);
|
|
66
|
+
const sanitizedUrl = sanitizeUrl(url);
|
|
67
|
+
try {
|
|
68
|
+
return await withBrowserPage(sanitizedUrl, nextSession(), async (page) => {
|
|
69
|
+
// Scroll to the bottom to trigger lazy-loaded / scroll-triggered content,
|
|
70
|
+
// then back up (also looks human-like).
|
|
71
|
+
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
72
|
+
await deps.sleep(randomBetween(500, 1500));
|
|
73
|
+
await page.evaluate(() => window.scrollTo(0, 0));
|
|
74
|
+
// Wait for the DOM to stabilize. Adapts to AJAX/lazy content (a stable page
|
|
75
|
+
// exits in ~1-2s) while capping the wait for never-settling SPAs. Tunable
|
|
76
|
+
// via SCRAPE_SETTLE_MAX_MS for sites with long setTimeout-injected content.
|
|
77
|
+
await waitForContentToSettle(page, config.scraping.settleMaxMs, deps);
|
|
78
|
+
const title = await page.title();
|
|
79
|
+
// Get the fully rendered HTML, then derive clean Markdown and text from it.
|
|
80
|
+
const html = await page.content();
|
|
81
|
+
const markdown = htmlToMarkdown(html, { onlyMainContent, baseUrl: url });
|
|
82
|
+
const content = htmlToText(html, { onlyMainContent, baseUrl: url });
|
|
83
|
+
return { url, title, content, markdown, html, success: true };
|
|
84
|
+
}, deps);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
return emptyScrape(url, errorMessage(error));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Single screenshot attempt using the next proxy/user agent in rotation
|
|
91
|
+
async function screenshotOnce(url, width, height, fullPage) {
|
|
92
|
+
if (!isValidUrl(url))
|
|
93
|
+
return { success: false, error: INVALID_URL_ERROR };
|
|
94
|
+
const sanitizedUrl = sanitizeUrl(url);
|
|
95
|
+
const session = nextSession({ viewport: { width, height }, logLabel: 'for screenshot' });
|
|
96
|
+
try {
|
|
97
|
+
return await withBrowserPage(sanitizedUrl, session, async (page) => {
|
|
98
|
+
// Simulate human scrolling behavior, then scroll back up slightly
|
|
99
|
+
await page.evaluate(() => {
|
|
100
|
+
window.scrollBy(0, Math.floor(Math.random() * 300) + 100);
|
|
101
|
+
});
|
|
102
|
+
await deps.sleep(randomBetween(500, 1500));
|
|
103
|
+
await page.evaluate(() => {
|
|
104
|
+
window.scrollBy(0, -Math.floor(Math.random() * 100) - 50);
|
|
105
|
+
});
|
|
106
|
+
// Final wait for any dynamic content
|
|
107
|
+
await deps.sleep(1000);
|
|
108
|
+
// Return as base64 for remote deployment compatibility
|
|
109
|
+
const screenshotBuffer = Buffer.from(await page.screenshot({ fullPage, type: 'png' }));
|
|
110
|
+
const base64Data = screenshotBuffer.toString('base64');
|
|
111
|
+
return {
|
|
112
|
+
success: true,
|
|
113
|
+
dataUrl: `data:image/png;base64,${base64Data}`,
|
|
114
|
+
base64: base64Data,
|
|
115
|
+
metadata: {
|
|
116
|
+
format: 'png',
|
|
117
|
+
sizeKB: Math.round(screenshotBuffer.length / 1024),
|
|
118
|
+
dimensions: `${width}x${height}`,
|
|
119
|
+
fullPage,
|
|
120
|
+
timestamp: new Date(deps.now()).toISOString(),
|
|
121
|
+
url: sanitizedUrl,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}, deps);
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
return { success: false, error: errorMessage(error) };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
scrape(url, onlyMainContent = true) {
|
|
132
|
+
return withRetries(() => scrapeOnce(url, onlyMainContent), { maxAttempts: config.retry.maxAttempts, label: 'Scrape', url }, (error) => emptyScrape(url, error));
|
|
133
|
+
},
|
|
134
|
+
screenshot(url, { width = 1920, height = 1080, fullPage = false } = {}) {
|
|
135
|
+
return withRetries(() => screenshotOnce(url, width, height, fullPage), { maxAttempts: config.retry.maxAttempts, label: 'Screenshot', url }, (error) => ({ success: false, error }));
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
package/dist/utils.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Pure, side-effect-free helpers shared by the server and unit tests.
|
|
2
|
+
// Keeping these out of index.ts means they can be imported and tested
|
|
3
|
+
// without booting the HTTP/MCP server.
|
|
4
|
+
export const DEFAULT_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
|
5
|
+
// Default LLM request parameters (used when the matching env var is unset)
|
|
6
|
+
export const DEFAULT_LLM_TEMPERATURE = 0.1;
|
|
7
|
+
export const DEFAULT_LLM_MAX_TOKENS = 2000;
|
|
8
|
+
// Input validation utilities
|
|
9
|
+
export function isValidUrl(url) {
|
|
10
|
+
try {
|
|
11
|
+
const parsedUrl = new URL(url);
|
|
12
|
+
// Only allow http and https protocols
|
|
13
|
+
return ['http:', 'https:'].includes(parsedUrl.protocol);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function sanitizeUrl(url) {
|
|
20
|
+
// Remove any potentially dangerous characters
|
|
21
|
+
return url.trim().replace(/[<>'"]/g, '');
|
|
22
|
+
}
|
|
23
|
+
export function validatePrompt(prompt) {
|
|
24
|
+
// Basic prompt validation - prevent extremely long prompts
|
|
25
|
+
return prompt.length > 0 && prompt.length < 10000;
|
|
26
|
+
}
|
|
27
|
+
// Parse proxy URL with range support (e.g. https://example.com:10001-10010)
|
|
28
|
+
export function parseProxyUrls(proxyUrl) {
|
|
29
|
+
if (!proxyUrl)
|
|
30
|
+
return [];
|
|
31
|
+
// Check for port range syntax: https://example.com:10001-10010
|
|
32
|
+
const rangeMatch = proxyUrl.match(/^(https?:\/\/[^:]+):(\d+)-(\d+)$/);
|
|
33
|
+
if (rangeMatch) {
|
|
34
|
+
const [, baseUrl, startPort, endPort] = rangeMatch;
|
|
35
|
+
const start = parseInt(startPort, 10);
|
|
36
|
+
const end = parseInt(endPort, 10);
|
|
37
|
+
const proxies = [];
|
|
38
|
+
for (let port = start; port <= end; port++) {
|
|
39
|
+
proxies.push(`${baseUrl}:${port}`);
|
|
40
|
+
}
|
|
41
|
+
return proxies;
|
|
42
|
+
}
|
|
43
|
+
// Single proxy URL
|
|
44
|
+
return [proxyUrl];
|
|
45
|
+
}
|
|
46
|
+
// Parse user agent env value, which may be a JSON array or a single string
|
|
47
|
+
export function parseUserAgents(userAgentEnv, defaultUserAgent = DEFAULT_USER_AGENT) {
|
|
48
|
+
if (!userAgentEnv)
|
|
49
|
+
return [defaultUserAgent];
|
|
50
|
+
// Try to parse as JSON array first
|
|
51
|
+
try {
|
|
52
|
+
const parsed = JSON.parse(userAgentEnv);
|
|
53
|
+
if (Array.isArray(parsed) && parsed.length > 0) {
|
|
54
|
+
const filtered = parsed.filter((ua) => typeof ua === 'string' && ua.trim().length > 0);
|
|
55
|
+
return filtered.length > 0 ? filtered : [defaultUserAgent];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Not JSON, treat as single user agent
|
|
60
|
+
}
|
|
61
|
+
// Single user agent string
|
|
62
|
+
return [userAgentEnv.trim()];
|
|
63
|
+
}
|
|
64
|
+
// Read LLM settings from the environment, applying sensible defaults.
|
|
65
|
+
// Optional tuning params (reasoning effort, top_p) are left undefined when
|
|
66
|
+
// unset so they are omitted from the request rather than sent as null.
|
|
67
|
+
export function parseLlmConfig(env = process.env) {
|
|
68
|
+
const maxTokens = Number(env.LLM_MAX_TOKENS);
|
|
69
|
+
const temperature = Number(env.LLM_TEMPERATURE);
|
|
70
|
+
const topP = Number(env.LLM_TOP_P);
|
|
71
|
+
return {
|
|
72
|
+
apiKey: env.LLM_API_KEY,
|
|
73
|
+
providerBaseUrl: env.LLM_PROVIDER_BASE_URL,
|
|
74
|
+
model: env.LLM_MODEL,
|
|
75
|
+
reasoningEffort: env.LLM_REASONING_EFFORT?.trim() || undefined,
|
|
76
|
+
maxTokens: env.LLM_MAX_TOKENS && Number.isFinite(maxTokens) && maxTokens > 0
|
|
77
|
+
? maxTokens
|
|
78
|
+
: DEFAULT_LLM_MAX_TOKENS,
|
|
79
|
+
temperature: env.LLM_TEMPERATURE !== undefined && Number.isFinite(temperature)
|
|
80
|
+
? temperature
|
|
81
|
+
: DEFAULT_LLM_TEMPERATURE,
|
|
82
|
+
topP: env.LLM_TOP_P !== undefined && Number.isFinite(topP) ? topP : undefined,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
// Parse the JSON object an LLM returns, tolerating common formatting quirks:
|
|
86
|
+
// markdown code fences (```json ... ```), surrounding prose, and leading/
|
|
87
|
+
// trailing whitespace. Throws if no valid JSON can be recovered.
|
|
88
|
+
export function parseLlmJson(text) {
|
|
89
|
+
const trimmed = text.trim();
|
|
90
|
+
// Strip a wrapping markdown code fence if present (```json ... ``` or ``` ... ```).
|
|
91
|
+
const fenceMatch = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
|
92
|
+
const unfenced = fenceMatch ? fenceMatch[1].trim() : trimmed;
|
|
93
|
+
try {
|
|
94
|
+
return JSON.parse(unfenced);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Fall back to the first balanced {...} or [...] span in the text.
|
|
98
|
+
const start = unfenced.search(/[{[]/);
|
|
99
|
+
const lastObj = unfenced.lastIndexOf('}');
|
|
100
|
+
const lastArr = unfenced.lastIndexOf(']');
|
|
101
|
+
const end = Math.max(lastObj, lastArr);
|
|
102
|
+
if (start !== -1 && end > start) {
|
|
103
|
+
return JSON.parse(unfenced.slice(start, end + 1));
|
|
104
|
+
}
|
|
105
|
+
throw new Error('No valid JSON found in LLM response');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// Build the chat-completions request body, only including optional tuning
|
|
109
|
+
// parameters when they have been explicitly configured.
|
|
110
|
+
export function buildLlmRequestBody(model, messages, llm) {
|
|
111
|
+
const body = {
|
|
112
|
+
model,
|
|
113
|
+
messages,
|
|
114
|
+
temperature: llm.temperature,
|
|
115
|
+
max_tokens: llm.maxTokens,
|
|
116
|
+
};
|
|
117
|
+
if (llm.topP !== undefined) {
|
|
118
|
+
body.top_p = llm.topP;
|
|
119
|
+
}
|
|
120
|
+
if (llm.reasoningEffort) {
|
|
121
|
+
body.reasoning_effort = llm.reasoningEffort;
|
|
122
|
+
}
|
|
123
|
+
return body;
|
|
124
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ariangibson/firecrawl-lite-mcp-server",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Privacy-first,
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"description": "Privacy-first, single-process web scraping for AI agents: MCP server plus a Firecrawl-compatible REST API, powered by local browser automation and your own LLM key",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"firecrawl-lite-mcp-server": "dist/index.js"
|
|
@@ -13,26 +13,32 @@
|
|
|
13
13
|
"build": "tsc",
|
|
14
14
|
"start": "node dist/index.js",
|
|
15
15
|
"dev": "tsc && node dist/index.js",
|
|
16
|
-
"
|
|
16
|
+
"test": "tsx --test tests/*.test.ts",
|
|
17
|
+
"lint": "tsc --noEmit",
|
|
18
|
+
"postinstall": "node -e \"try { require('puppeteer').executablePath(); } catch (e) { console.log('Installing Chrome for Puppeteer...'); require('child_process').execSync('npx puppeteer browsers install chrome', {stdio: 'inherit'}); }\""
|
|
17
19
|
},
|
|
18
20
|
"license": "MIT",
|
|
19
21
|
"dependencies": {
|
|
20
22
|
"@modelcontextprotocol/sdk": "^1.17.3",
|
|
21
|
-
"@types/cheerio": "^0.22.35",
|
|
22
23
|
"axios": "^1.11.0",
|
|
23
24
|
"cheerio": "^1.1.2",
|
|
24
25
|
"dotenv": "^16.4.7",
|
|
25
26
|
"express": "^5.1.0",
|
|
26
27
|
"puppeteer": "^24.17.1",
|
|
27
|
-
"
|
|
28
|
+
"puppeteer-extra": "^3.3.6",
|
|
29
|
+
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
|
30
|
+
"turndown": "^7.2.4",
|
|
31
|
+
"turndown-plugin-gfm": "^1.0.2"
|
|
28
32
|
},
|
|
29
33
|
"devDependencies": {
|
|
30
34
|
"@types/express": "^5.0.1",
|
|
31
|
-
"@types/node": "^
|
|
35
|
+
"@types/node": "^22.0.0",
|
|
36
|
+
"@types/turndown": "^5.0.6",
|
|
37
|
+
"tsx": "^4.19.2",
|
|
32
38
|
"typescript": "^5.9.2"
|
|
33
39
|
},
|
|
34
40
|
"engines": {
|
|
35
|
-
"node": ">=
|
|
41
|
+
"node": ">=20.0.0"
|
|
36
42
|
},
|
|
37
43
|
"repository": {
|
|
38
44
|
"type": "git",
|