scorchcrawl-mcp 1.2.1 → 2.0.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.
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Re-export of FastMCP (MIT-licensed, originally "firecrawl-fastmcp" by Frank Fiegel).
3
+ * We re-export from this local path so our codebase doesn't reference
4
+ * the upstream package name in import statements.
5
+ */
6
+ export { FastMCP } from 'firecrawl-fastmcp';
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Re-export of the Firecrawl JS SDK (MIT-licensed, @mendable/firecrawl-js).
3
+ * This is the HTTP client that talks to our local engine API.
4
+ * We re-export here so our source files import from a local path
5
+ * without referencing the upstream package name.
6
+ */
7
+ import FirecrawlApp from '@mendable/firecrawl-js';
8
+ export default FirecrawlApp;
9
+ export { FirecrawlApp as ScorchClient };
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Local Scraper — fetches pages through the LOCAL machine's IP and converts to markdown.
3
+ * Used when SCORCHCRAWL_LOCAL_PROXY=true is set, so scraping traffic exits through
4
+ * the user's residential IP instead of the server's datacenter IP.
5
+ *
6
+ * Falls back to the remote ScorchCrawl API for features that need server-side
7
+ * processing (search, crawl, extract, agent, JSON schema extraction).
8
+ *
9
+ * SPA Detection:
10
+ * When the fetched HTML looks like a Single Page Application shell
11
+ * (minimal text, loading indicators, heavy JS bundles), the scraper returns
12
+ * a `SPA_SKELETON_DETECTED` error so the caller can retry via the engine's
13
+ * Playwright-backed scraper which executes JavaScript.
14
+ */
15
+ import TurndownService from 'turndown';
16
+ import * as cheerio from 'cheerio';
17
+ // Lazy-init singleton
18
+ let _turndown = null;
19
+ function getTurndown() {
20
+ if (!_turndown) {
21
+ _turndown = new TurndownService({
22
+ headingStyle: 'atx',
23
+ codeBlockStyle: 'fenced',
24
+ bulletListMarker: '-',
25
+ });
26
+ // Strip script/style/nav/footer tags
27
+ _turndown.remove(['script', 'style', 'noscript', 'iframe']);
28
+ }
29
+ return _turndown;
30
+ }
31
+ // ---------------------------------------------------------------------------
32
+ // SPA / JS-rendered page detection
33
+ // ---------------------------------------------------------------------------
34
+ /**
35
+ * Common phrases found in SPA shell HTML before JS hydrates the page.
36
+ * Matched case-insensitively against the visible body text.
37
+ */
38
+ const SPA_LOADING_PATTERNS = [
39
+ 'loading...',
40
+ 'loading…',
41
+ 'please wait',
42
+ 'just a moment',
43
+ 'checking your browser',
44
+ 'one moment please',
45
+ 'redirecting',
46
+ 'enable javascript',
47
+ 'javascript is required',
48
+ 'javascript must be enabled',
49
+ 'this app requires javascript',
50
+ 'you need to enable javascript',
51
+ 'noscript',
52
+ ];
53
+ /**
54
+ * CSS selectors whose sole presence (with no other meaningful content)
55
+ * strongly indicates a JS-only SPA shell.
56
+ */
57
+ const SPA_ROOT_SELECTORS = [
58
+ '#root', // React (CRA, Vite)
59
+ '#app', // Vue
60
+ '#__next', // Next.js
61
+ '#__nuxt', // Nuxt
62
+ '#svelte', // SvelteKit
63
+ 'app-root', // Angular
64
+ '#___gatsby', // Gatsby
65
+ '#main-app', // misc
66
+ ];
67
+ /** Minimum characters of visible text for a page to be considered "real" content. */
68
+ const MIN_MEANINGFUL_TEXT_LENGTH = 200;
69
+ /** Ratio: if (script bytes / total HTML bytes) exceeds this, it's likely a SPA shell. */
70
+ const SCRIPT_HEAVY_RATIO = 0.65;
71
+ /**
72
+ * Inspect raw HTML + extracted text to decide if the page is a SPA shell
73
+ * that hasn't been hydrated (no JS execution happened).
74
+ *
75
+ * Returns a short reason string if SPA-like, or `null` if the page looks real.
76
+ */
77
+ export function detectSPASkeleton(rawHtml, _bodyText, $) {
78
+ // Get visible text only (strip script, style, noscript content)
79
+ const $clone = cheerio.load($.html());
80
+ $clone('script, style, noscript').remove();
81
+ const visibleText = $clone('body').text() || '';
82
+ const trimmedText = visibleText.replace(/\s+/g, ' ').trim();
83
+ const lowerText = trimmedText.toLowerCase();
84
+ // 1. Very little visible text — likely a shell that JS would populate
85
+ if (trimmedText.length < MIN_MEANINGFUL_TEXT_LENGTH) {
86
+ // Check for SPA root containers
87
+ for (const sel of SPA_ROOT_SELECTORS) {
88
+ const el = $(sel);
89
+ if (el.length > 0) {
90
+ const innerText = el.text().replace(/\s+/g, ' ').trim();
91
+ if (innerText.length < MIN_MEANINGFUL_TEXT_LENGTH) {
92
+ return `SPA root container "${sel}" with minimal content (${innerText.length} chars)`;
93
+ }
94
+ }
95
+ }
96
+ // Check for loading phrases in the sparse text
97
+ for (const pattern of SPA_LOADING_PATTERNS) {
98
+ if (lowerText.includes(pattern)) {
99
+ return `Loading indicator detected: "${pattern}"`;
100
+ }
101
+ }
102
+ // Even without a known root, < 50 chars of body text is almost certainly a shell
103
+ if (trimmedText.length < 50) {
104
+ return `Near-empty body text (${trimmedText.length} chars)`;
105
+ }
106
+ }
107
+ // 2. Loading phrases in an otherwise short page (< 500 chars)
108
+ if (trimmedText.length < 500) {
109
+ for (const pattern of SPA_LOADING_PATTERNS) {
110
+ if (lowerText.includes(pattern)) {
111
+ return `Short page with loading indicator: "${pattern}"`;
112
+ }
113
+ }
114
+ }
115
+ // 3. Script-heavy pages: mostly <script> tags, very little content
116
+ const scriptContent = $('script')
117
+ .toArray()
118
+ .reduce((sum, el) => sum + ($(el).html()?.length || 0), 0);
119
+ const htmlLength = rawHtml.length;
120
+ if (htmlLength > 1000 &&
121
+ scriptContent / htmlLength > SCRIPT_HEAVY_RATIO &&
122
+ trimmedText.length < MIN_MEANINGFUL_TEXT_LENGTH) {
123
+ return `Script-heavy page (${Math.round((scriptContent / htmlLength) * 100)}% scripts, ${trimmedText.length} chars text)`;
124
+ }
125
+ return null;
126
+ }
127
+ /**
128
+ * Fetches a URL locally (through the user's IP) and converts to markdown.
129
+ */
130
+ export async function localScrape(url, options = {}) {
131
+ const timeout = options.timeout || 30000;
132
+ // Determine requested formats
133
+ const formats = (options.formats || ['markdown']).map((f) => typeof f === 'string' ? f : f.type);
134
+ const wantMarkdown = formats.includes('markdown');
135
+ const wantHtml = formats.includes('html');
136
+ const wantRawHtml = formats.includes('rawHtml');
137
+ const wantLinks = formats.includes('links');
138
+ // Needs JSON/screenshot/branding? Can't do locally — return null to fall back
139
+ const needsServerSide = formats.some((f) => f === 'json' || f === 'screenshot' || f === 'branding' || f === 'summary');
140
+ if (needsServerSide) {
141
+ return { success: false, error: 'FORMAT_NEEDS_SERVER' };
142
+ }
143
+ try {
144
+ const controller = new AbortController();
145
+ const timer = setTimeout(() => controller.abort(), timeout);
146
+ const fetchOptions = {
147
+ signal: controller.signal,
148
+ headers: {
149
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
150
+ Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
151
+ 'Accept-Language': 'en-US,en;q=0.9',
152
+ 'Accept-Encoding': 'gzip, deflate, br',
153
+ 'Cache-Control': 'no-cache',
154
+ ...(options.headers || {}),
155
+ },
156
+ redirect: 'follow',
157
+ };
158
+ // Node 18+ native TLS rejection control
159
+ if (options.skipTlsVerification) {
160
+ fetchOptions.dispatcher = undefined; // handled below
161
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
162
+ }
163
+ const response = await fetch(url, fetchOptions);
164
+ clearTimeout(timer);
165
+ if (options.skipTlsVerification) {
166
+ delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
167
+ }
168
+ const rawHtml = await response.text();
169
+ const statusCode = response.status;
170
+ const contentType = response.headers.get('content-type') || 'text/html';
171
+ // Parse with Cheerio
172
+ const $ = cheerio.load(rawHtml);
173
+ // Extract metadata
174
+ const title = $('title').first().text().trim() ||
175
+ $('meta[property="og:title"]').attr('content') ||
176
+ '';
177
+ const description = $('meta[name="description"]').attr('content') ||
178
+ $('meta[property="og:description"]').attr('content') ||
179
+ '';
180
+ const language = $('html').attr('lang') || '';
181
+ // Remove unwanted elements if onlyMainContent
182
+ if (options.onlyMainContent) {
183
+ $('nav, header, footer, aside, .sidebar, .menu, .navigation, .breadcrumb, .cookie-banner, .ad, .advertisement, [role="navigation"], [role="banner"], [role="complementary"]').remove();
184
+ }
185
+ // Apply excludeTags
186
+ if (options.excludeTags?.length) {
187
+ $(options.excludeTags.join(', ')).remove();
188
+ }
189
+ // Get the target HTML content
190
+ let targetHtml;
191
+ if (options.includeTags?.length) {
192
+ targetHtml = options.includeTags
193
+ .map((sel) => $(sel).html() || '')
194
+ .filter(Boolean)
195
+ .join('\n');
196
+ }
197
+ else if (options.onlyMainContent) {
198
+ // Try to find main content area
199
+ const mainSelectors = [
200
+ 'main',
201
+ 'article',
202
+ '[role="main"]',
203
+ '.main-content',
204
+ '.content',
205
+ '#content',
206
+ '#main',
207
+ ];
208
+ let mainHtml = '';
209
+ for (const sel of mainSelectors) {
210
+ const el = $(sel).first();
211
+ if (el.length && (el.html()?.length || 0) > 100) {
212
+ mainHtml = el.html() || '';
213
+ break;
214
+ }
215
+ }
216
+ targetHtml = mainHtml || $('body').html() || rawHtml;
217
+ }
218
+ else {
219
+ targetHtml = $('body').html() || rawHtml;
220
+ }
221
+ // Build response
222
+ const data = {
223
+ metadata: {
224
+ title,
225
+ description: description || undefined,
226
+ language: language || undefined,
227
+ sourceURL: url,
228
+ url: response.url || url,
229
+ statusCode,
230
+ contentType,
231
+ proxyUsed: 'local',
232
+ },
233
+ };
234
+ if (wantMarkdown) {
235
+ data.markdown = getTurndown().turndown(targetHtml);
236
+ }
237
+ if (wantHtml) {
238
+ data.html = targetHtml;
239
+ }
240
+ if (wantRawHtml) {
241
+ data.rawHtml = rawHtml;
242
+ }
243
+ if (wantLinks) {
244
+ const links = [];
245
+ $('a[href]').each((_, el) => {
246
+ const href = $(el).attr('href');
247
+ if (href && !href.startsWith('#') && !href.startsWith('javascript:')) {
248
+ try {
249
+ links.push(new URL(href, url).href);
250
+ }
251
+ catch {
252
+ links.push(href);
253
+ }
254
+ }
255
+ });
256
+ data.links = [...new Set(links)];
257
+ }
258
+ // --- SPA detection: check if the fetched content is a JS-only shell ---
259
+ const bodyText = $('body').text() || '';
260
+ const spaReason = detectSPASkeleton(rawHtml, bodyText, $);
261
+ if (spaReason) {
262
+ return { success: false, error: 'SPA_SKELETON_DETECTED', data };
263
+ }
264
+ return { success: true, data };
265
+ }
266
+ catch (err) {
267
+ if (err.name === 'AbortError') {
268
+ return { success: false, error: `Timeout after ${timeout}ms` };
269
+ }
270
+ return { success: false, error: err.message || String(err) };
271
+ }
272
+ }
273
+ /**
274
+ * Check if local proxy mode is enabled.
275
+ * Controlled by:
276
+ * - SCORCHCRAWL_LOCAL_PROXY=true env var
277
+ * - ?localProxy=true query param in SCORCHCRAWL_API_URL
278
+ */
279
+ export function isLocalProxyEnabled() {
280
+ // Check env var
281
+ if (process.env.SCORCHCRAWL_LOCAL_PROXY?.toLowerCase() === 'true' ||
282
+ process.env.SCORCHCRAWL_LOCAL_PROXY === '1') {
283
+ return true;
284
+ }
285
+ // Check URL query param
286
+ const apiUrl = process.env.SCORCHCRAWL_API_URL;
287
+ if (apiUrl) {
288
+ try {
289
+ const parsed = new URL(apiUrl);
290
+ if (parsed.searchParams.get('localProxy') === 'true' ||
291
+ parsed.searchParams.get('localProxy') === '1') {
292
+ return true;
293
+ }
294
+ }
295
+ catch {
296
+ // ignore invalid URL
297
+ }
298
+ }
299
+ return false;
300
+ }
301
+ /**
302
+ * Returns SCORCHCRAWL_API_URL without the localProxy query param
303
+ * (so the scraping SDK doesn't pass it to the API).
304
+ */
305
+ export function getCleanApiUrl() {
306
+ const apiUrl = process.env.SCORCHCRAWL_API_URL;
307
+ if (!apiUrl)
308
+ return undefined;
309
+ try {
310
+ const parsed = new URL(apiUrl);
311
+ parsed.searchParams.delete('localProxy');
312
+ const cleaned = parsed.toString();
313
+ // Remove trailing ? if no other params
314
+ return cleaned.replace(/\?$/, '');
315
+ }
316
+ catch {
317
+ return apiUrl;
318
+ }
319
+ }