scorchcrawl-mcp 1.2.0 → 1.2.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.
package/dist/bundle.js DELETED
@@ -1,2777 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- // src/index.ts
4
- import dotenv from "dotenv";
5
-
6
- // src/lib/fastmcp/index.ts
7
- import { FastMCP } from "firecrawl-fastmcp";
8
-
9
- // src/index.ts
10
- import { z } from "zod";
11
-
12
- // src/lib/scorch-client/index.ts
13
- import FirecrawlApp from "@mendable/firecrawl-js";
14
- var scorch_client_default = FirecrawlApp;
15
-
16
- // src/local-scraper.ts
17
- import TurndownService from "turndown";
18
- import * as cheerio from "cheerio";
19
- var _turndown = null;
20
- function getTurndown() {
21
- if (!_turndown) {
22
- _turndown = new TurndownService({
23
- headingStyle: "atx",
24
- codeBlockStyle: "fenced",
25
- bulletListMarker: "-"
26
- });
27
- _turndown.remove(["script", "style", "noscript", "iframe"]);
28
- }
29
- return _turndown;
30
- }
31
- var SPA_LOADING_PATTERNS = [
32
- "loading...",
33
- "loading\u2026",
34
- "please wait",
35
- "just a moment",
36
- "checking your browser",
37
- "one moment please",
38
- "redirecting",
39
- "enable javascript",
40
- "javascript is required",
41
- "javascript must be enabled",
42
- "this app requires javascript",
43
- "you need to enable javascript",
44
- "noscript"
45
- ];
46
- var SPA_ROOT_SELECTORS = [
47
- "#root",
48
- // React (CRA, Vite)
49
- "#app",
50
- // Vue
51
- "#__next",
52
- // Next.js
53
- "#__nuxt",
54
- // Nuxt
55
- "#svelte",
56
- // SvelteKit
57
- "app-root",
58
- // Angular
59
- "#___gatsby",
60
- // Gatsby
61
- "#main-app"
62
- // misc
63
- ];
64
- var MIN_MEANINGFUL_TEXT_LENGTH = 200;
65
- var SCRIPT_HEAVY_RATIO = 0.65;
66
- function detectSPASkeleton(rawHtml, _bodyText, $) {
67
- const $clone = cheerio.load($.html());
68
- $clone("script, style, noscript").remove();
69
- const visibleText = $clone("body").text() || "";
70
- const trimmedText = visibleText.replace(/\s+/g, " ").trim();
71
- const lowerText = trimmedText.toLowerCase();
72
- if (trimmedText.length < MIN_MEANINGFUL_TEXT_LENGTH) {
73
- for (const sel of SPA_ROOT_SELECTORS) {
74
- const el = $(sel);
75
- if (el.length > 0) {
76
- const innerText = el.text().replace(/\s+/g, " ").trim();
77
- if (innerText.length < MIN_MEANINGFUL_TEXT_LENGTH) {
78
- return `SPA root container "${sel}" with minimal content (${innerText.length} chars)`;
79
- }
80
- }
81
- }
82
- for (const pattern of SPA_LOADING_PATTERNS) {
83
- if (lowerText.includes(pattern)) {
84
- return `Loading indicator detected: "${pattern}"`;
85
- }
86
- }
87
- if (trimmedText.length < 50) {
88
- return `Near-empty body text (${trimmedText.length} chars)`;
89
- }
90
- }
91
- if (trimmedText.length < 500) {
92
- for (const pattern of SPA_LOADING_PATTERNS) {
93
- if (lowerText.includes(pattern)) {
94
- return `Short page with loading indicator: "${pattern}"`;
95
- }
96
- }
97
- }
98
- const scriptContent = $("script").toArray().reduce((sum, el) => sum + ($(el).html()?.length || 0), 0);
99
- const htmlLength = rawHtml.length;
100
- if (htmlLength > 1e3 && scriptContent / htmlLength > SCRIPT_HEAVY_RATIO && trimmedText.length < MIN_MEANINGFUL_TEXT_LENGTH) {
101
- return `Script-heavy page (${Math.round(scriptContent / htmlLength * 100)}% scripts, ${trimmedText.length} chars text)`;
102
- }
103
- return null;
104
- }
105
- async function localScrape(url, options = {}) {
106
- const timeout = options.timeout || 3e4;
107
- const formats = (options.formats || ["markdown"]).map(
108
- (f) => typeof f === "string" ? f : f.type
109
- );
110
- const wantMarkdown = formats.includes("markdown");
111
- const wantHtml = formats.includes("html");
112
- const wantRawHtml = formats.includes("rawHtml");
113
- const wantLinks = formats.includes("links");
114
- const needsServerSide = formats.some(
115
- (f) => f === "json" || f === "screenshot" || f === "branding" || f === "summary"
116
- );
117
- if (needsServerSide) {
118
- return { success: false, error: "FORMAT_NEEDS_SERVER" };
119
- }
120
- try {
121
- const controller = new AbortController();
122
- const timer = setTimeout(() => controller.abort(), timeout);
123
- const fetchOptions = {
124
- signal: controller.signal,
125
- headers: {
126
- "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",
127
- Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
128
- "Accept-Language": "en-US,en;q=0.9",
129
- "Accept-Encoding": "gzip, deflate, br",
130
- "Cache-Control": "no-cache",
131
- ...options.headers || {}
132
- },
133
- redirect: "follow"
134
- };
135
- if (options.skipTlsVerification) {
136
- fetchOptions.dispatcher = void 0;
137
- process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
138
- }
139
- const response = await fetch(url, fetchOptions);
140
- clearTimeout(timer);
141
- if (options.skipTlsVerification) {
142
- delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
143
- }
144
- const rawHtml = await response.text();
145
- const statusCode = response.status;
146
- const contentType = response.headers.get("content-type") || "text/html";
147
- const $ = cheerio.load(rawHtml);
148
- const title = $("title").first().text().trim() || $('meta[property="og:title"]').attr("content") || "";
149
- const description = $('meta[name="description"]').attr("content") || $('meta[property="og:description"]').attr("content") || "";
150
- const language = $("html").attr("lang") || "";
151
- if (options.onlyMainContent) {
152
- $(
153
- 'nav, header, footer, aside, .sidebar, .menu, .navigation, .breadcrumb, .cookie-banner, .ad, .advertisement, [role="navigation"], [role="banner"], [role="complementary"]'
154
- ).remove();
155
- }
156
- if (options.excludeTags?.length) {
157
- $(options.excludeTags.join(", ")).remove();
158
- }
159
- let targetHtml;
160
- if (options.includeTags?.length) {
161
- targetHtml = options.includeTags.map((sel) => $(sel).html() || "").filter(Boolean).join("\n");
162
- } else if (options.onlyMainContent) {
163
- const mainSelectors = [
164
- "main",
165
- "article",
166
- '[role="main"]',
167
- ".main-content",
168
- ".content",
169
- "#content",
170
- "#main"
171
- ];
172
- let mainHtml = "";
173
- for (const sel of mainSelectors) {
174
- const el = $(sel).first();
175
- if (el.length && (el.html()?.length || 0) > 100) {
176
- mainHtml = el.html() || "";
177
- break;
178
- }
179
- }
180
- targetHtml = mainHtml || $("body").html() || rawHtml;
181
- } else {
182
- targetHtml = $("body").html() || rawHtml;
183
- }
184
- const data = {
185
- metadata: {
186
- title,
187
- description: description || void 0,
188
- language: language || void 0,
189
- sourceURL: url,
190
- url: response.url || url,
191
- statusCode,
192
- contentType,
193
- proxyUsed: "local"
194
- }
195
- };
196
- if (wantMarkdown) {
197
- data.markdown = getTurndown().turndown(targetHtml);
198
- }
199
- if (wantHtml) {
200
- data.html = targetHtml;
201
- }
202
- if (wantRawHtml) {
203
- data.rawHtml = rawHtml;
204
- }
205
- if (wantLinks) {
206
- const links = [];
207
- $("a[href]").each((_, el) => {
208
- const href = $(el).attr("href");
209
- if (href && !href.startsWith("#") && !href.startsWith("javascript:")) {
210
- try {
211
- links.push(new URL(href, url).href);
212
- } catch {
213
- links.push(href);
214
- }
215
- }
216
- });
217
- data.links = [...new Set(links)];
218
- }
219
- const bodyText = $("body").text() || "";
220
- const spaReason = detectSPASkeleton(rawHtml, bodyText, $);
221
- if (spaReason) {
222
- return { success: false, error: "SPA_SKELETON_DETECTED", data };
223
- }
224
- return { success: true, data };
225
- } catch (err) {
226
- if (err.name === "AbortError") {
227
- return { success: false, error: `Timeout after ${timeout}ms` };
228
- }
229
- return { success: false, error: err.message || String(err) };
230
- }
231
- }
232
- function isLocalProxyEnabled() {
233
- if (process.env.SCORCHCRAWL_LOCAL_PROXY?.toLowerCase() === "true" || process.env.SCORCHCRAWL_LOCAL_PROXY === "1") {
234
- return true;
235
- }
236
- const apiUrl = process.env.SCORCHCRAWL_API_URL;
237
- if (apiUrl) {
238
- try {
239
- const parsed = new URL(apiUrl);
240
- if (parsed.searchParams.get("localProxy") === "true" || parsed.searchParams.get("localProxy") === "1") {
241
- return true;
242
- }
243
- } catch {
244
- }
245
- }
246
- return false;
247
- }
248
- function getCleanApiUrl() {
249
- const apiUrl = process.env.SCORCHCRAWL_API_URL;
250
- if (!apiUrl) return void 0;
251
- try {
252
- const parsed = new URL(apiUrl);
253
- parsed.searchParams.delete("localProxy");
254
- const cleaned = parsed.toString();
255
- return cleaned.replace(/\?$/, "");
256
- } catch {
257
- return apiUrl;
258
- }
259
- }
260
-
261
- // src/copilot-agent.ts
262
- import { CopilotClient } from "@github/copilot-sdk";
263
- import { v4 as uuidv4 } from "uuid";
264
-
265
- // src/rate-limiter.ts
266
- function buildRateLimitConfig() {
267
- return {
268
- maxGlobalConcurrency: envInt("RATE_LIMIT_MAX_GLOBAL_CONCURRENCY", 10),
269
- maxPerUserConcurrency: envInt("RATE_LIMIT_MAX_PER_USER_CONCURRENCY", 3),
270
- rateLimitWindowMs: envInt("RATE_LIMIT_WINDOW_MS", 6e4),
271
- maxRequestsPerWindow: envInt("RATE_LIMIT_MAX_REQUESTS_PER_WINDOW", 20),
272
- maxGlobalRequestsPerWindow: envInt("RATE_LIMIT_MAX_GLOBAL_REQUESTS_PER_WINDOW", 60),
273
- quotaRejectThresholdPercent: envInt("RATE_LIMIT_QUOTA_REJECT_PERCENT", 5),
274
- staleJobTimeoutMs: envInt("RATE_LIMIT_STALE_JOB_TIMEOUT_MS", 10 * 60 * 1e3),
275
- gcIntervalMs: envInt("RATE_LIMIT_GC_INTERVAL_MS", 6e4)
276
- };
277
- }
278
- function envInt(key, fallback) {
279
- const v = process.env[key];
280
- if (v == null) return fallback;
281
- const n = parseInt(v, 10);
282
- return Number.isFinite(n) ? n : fallback;
283
- }
284
- var ConcurrencyTracker = class {
285
- constructor(config) {
286
- this.config = config;
287
- }
288
- globalActive = 0;
289
- perUser = /* @__PURE__ */ new Map();
290
- /** Check if a new job is allowed for the given user key */
291
- canAcquire(userKey) {
292
- if (this.globalActive >= this.config.maxGlobalConcurrency) {
293
- return {
294
- allowed: false,
295
- reason: `Server is at maximum capacity (${this.config.maxGlobalConcurrency} concurrent jobs). Please retry shortly.`,
296
- retryAfterSeconds: 10
297
- };
298
- }
299
- const userCount = this.perUser.get(userKey) || 0;
300
- if (userCount >= this.config.maxPerUserConcurrency) {
301
- return {
302
- allowed: false,
303
- reason: `You already have ${userCount} concurrent agent jobs (max ${this.config.maxPerUserConcurrency}). Wait for a job to complete before starting another.`,
304
- retryAfterSeconds: 15
305
- };
306
- }
307
- return { allowed: true };
308
- }
309
- acquire(userKey) {
310
- this.globalActive++;
311
- this.perUser.set(userKey, (this.perUser.get(userKey) || 0) + 1);
312
- }
313
- release(userKey) {
314
- this.globalActive = Math.max(0, this.globalActive - 1);
315
- const cur = this.perUser.get(userKey) || 0;
316
- if (cur <= 1) {
317
- this.perUser.delete(userKey);
318
- } else {
319
- this.perUser.set(userKey, cur - 1);
320
- }
321
- }
322
- /** Current stats (for observability / health endpoint) */
323
- stats() {
324
- return {
325
- global: this.globalActive,
326
- perUser: Object.fromEntries(this.perUser)
327
- };
328
- }
329
- };
330
- var SlidingWindowRateLimiter = class {
331
- constructor(config) {
332
- this.config = config;
333
- }
334
- /** Map<userKey, timestamp[]> */
335
- perUser = /* @__PURE__ */ new Map();
336
- globalTimestamps = [];
337
- check(userKey) {
338
- const now = Date.now();
339
- const windowStart = now - this.config.rateLimitWindowMs;
340
- this.globalTimestamps = this.globalTimestamps.filter((t) => t > windowStart);
341
- if (this.globalTimestamps.length >= this.config.maxGlobalRequestsPerWindow) {
342
- const oldest = this.globalTimestamps[0];
343
- const retryAfter = Math.ceil((oldest + this.config.rateLimitWindowMs - now) / 1e3);
344
- return {
345
- allowed: false,
346
- reason: `Global rate limit reached (${this.config.maxGlobalRequestsPerWindow} requests per ${this.config.rateLimitWindowMs / 1e3}s window). Please wait.`,
347
- retryAfterSeconds: Math.max(1, retryAfter)
348
- };
349
- }
350
- let userTs = this.perUser.get(userKey) || [];
351
- userTs = userTs.filter((t) => t > windowStart);
352
- this.perUser.set(userKey, userTs);
353
- if (userTs.length >= this.config.maxRequestsPerWindow) {
354
- const oldest = userTs[0];
355
- const retryAfter = Math.ceil((oldest + this.config.rateLimitWindowMs - now) / 1e3);
356
- return {
357
- allowed: false,
358
- reason: `Rate limit reached (${this.config.maxRequestsPerWindow} requests per ${this.config.rateLimitWindowMs / 1e3}s window). Please wait.`,
359
- retryAfterSeconds: Math.max(1, retryAfter)
360
- };
361
- }
362
- return { allowed: true };
363
- }
364
- /** Record that a request was made */
365
- record(userKey) {
366
- const now = Date.now();
367
- this.globalTimestamps.push(now);
368
- const userTs = this.perUser.get(userKey) || [];
369
- userTs.push(now);
370
- this.perUser.set(userKey, userTs);
371
- }
372
- /** Purge old timestamps (called by GC) */
373
- gc() {
374
- const cutoff = Date.now() - this.config.rateLimitWindowMs;
375
- this.globalTimestamps = this.globalTimestamps.filter((t) => t > cutoff);
376
- for (const [key, ts] of this.perUser) {
377
- const filtered = ts.filter((t) => t > cutoff);
378
- if (filtered.length === 0) {
379
- this.perUser.delete(key);
380
- } else {
381
- this.perUser.set(key, filtered);
382
- }
383
- }
384
- }
385
- };
386
- var QuotaMonitor = class {
387
- constructor(config) {
388
- this.config = config;
389
- }
390
- perUser = /* @__PURE__ */ new Map();
391
- /** Update quota snapshot for a user (call from session event handler) */
392
- update(userKey, snapshot) {
393
- const existing = this.perUser.get(userKey);
394
- this.perUser.set(userKey, {
395
- remainingPercent: snapshot.remainingPercent ?? existing?.remainingPercent ?? 100,
396
- usedRequests: snapshot.usedRequests ?? existing?.usedRequests ?? 0,
397
- entitlementRequests: snapshot.entitlementRequests ?? existing?.entitlementRequests ?? -1,
398
- isUnlimited: snapshot.isUnlimited ?? existing?.isUnlimited ?? false,
399
- resetDate: snapshot.resetDate ?? existing?.resetDate,
400
- lastUpdated: Date.now()
401
- });
402
- }
403
- /** Check if the user has sufficient quota to start a new agent job */
404
- check(userKey) {
405
- const info = this.perUser.get(userKey);
406
- if (!info) return { allowed: true };
407
- if (info.isUnlimited) return { allowed: true };
408
- if (Date.now() - info.lastUpdated > 5 * 60 * 1e3) {
409
- return { allowed: true };
410
- }
411
- if (info.remainingPercent <= this.config.quotaRejectThresholdPercent) {
412
- const resetMsg = info.resetDate ? ` Quota resets on ${info.resetDate}.` : "";
413
- return {
414
- allowed: false,
415
- reason: `Copilot quota nearly exhausted (${info.remainingPercent.toFixed(1)}% remaining, ${info.usedRequests}/${info.entitlementRequests} used).${resetMsg} Upgrade your plan or wait for reset.`
416
- };
417
- }
418
- return { allowed: true };
419
- }
420
- /** Get quota info for a user (for diagnostics) */
421
- get(userKey) {
422
- return this.perUser.get(userKey);
423
- }
424
- /** Purge old entries (called by GC) */
425
- gc() {
426
- const cutoff = Date.now() - 30 * 60 * 1e3;
427
- for (const [key, info] of this.perUser) {
428
- if (info.lastUpdated < cutoff) {
429
- this.perUser.delete(key);
430
- }
431
- }
432
- }
433
- };
434
- var RateLimitGuard = class {
435
- config;
436
- concurrency;
437
- rateLimit;
438
- quota;
439
- gcTimer = null;
440
- constructor(config) {
441
- this.config = config || buildRateLimitConfig();
442
- this.concurrency = new ConcurrencyTracker(this.config);
443
- this.rateLimit = new SlidingWindowRateLimiter(this.config);
444
- this.quota = new QuotaMonitor(this.config);
445
- this.gcTimer = setInterval(() => {
446
- this.rateLimit.gc();
447
- this.quota.gc();
448
- }, this.config.gcIntervalMs);
449
- if (this.gcTimer.unref) this.gcTimer.unref();
450
- }
451
- /**
452
- * Check ALL rate-limit gates in priority order.
453
- * Returns the first rejection, or { allowed: true } if all pass.
454
- */
455
- check(userKey) {
456
- const concResult = this.concurrency.canAcquire(userKey);
457
- if (!concResult.allowed) return concResult;
458
- const rateResult = this.rateLimit.check(userKey);
459
- if (!rateResult.allowed) return rateResult;
460
- const quotaResult = this.quota.check(userKey);
461
- if (!quotaResult.allowed) return quotaResult;
462
- return { allowed: true };
463
- }
464
- /** Record that a request was admitted and is now in-flight */
465
- acquire(userKey) {
466
- this.concurrency.acquire(userKey);
467
- this.rateLimit.record(userKey);
468
- }
469
- /** Release concurrency slot when job finishes */
470
- release(userKey) {
471
- this.concurrency.release(userKey);
472
- }
473
- /** Stats for observability */
474
- stats() {
475
- return {
476
- concurrency: this.concurrency.stats(),
477
- config: {
478
- maxGlobalConcurrency: this.config.maxGlobalConcurrency,
479
- maxPerUserConcurrency: this.config.maxPerUserConcurrency,
480
- maxRequestsPerWindow: this.config.maxRequestsPerWindow,
481
- rateLimitWindowMs: this.config.rateLimitWindowMs,
482
- quotaRejectThresholdPercent: this.config.quotaRejectThresholdPercent
483
- }
484
- };
485
- }
486
- /** Clean shutdown */
487
- shutdown() {
488
- if (this.gcTimer) {
489
- clearInterval(this.gcTimer);
490
- this.gcTimer = null;
491
- }
492
- }
493
- };
494
- function buildErrorHook(jobId, logger) {
495
- return (input) => {
496
- const log = logger || console;
497
- log.warn(`[RateLimit] Agent ${jobId} error`, {
498
- context: input.errorContext,
499
- recoverable: input.recoverable,
500
- error: input.error.substring(0, 200)
501
- });
502
- const lowerErr = input.error.toLowerCase();
503
- if (lowerErr.includes("quota") || lowerErr.includes("402") || lowerErr.includes("not licensed") || lowerErr.includes("authentication")) {
504
- return { errorHandling: "abort", suppressOutput: false };
505
- }
506
- if (lowerErr.includes("rate limit") || lowerErr.includes("429")) {
507
- return {
508
- errorHandling: "abort",
509
- suppressOutput: false,
510
- userNotification: "Rate limit reached. Please retry later."
511
- };
512
- }
513
- if (input.errorContext === "model_call" && input.recoverable) {
514
- return { errorHandling: "retry", retryCount: 2 };
515
- }
516
- if (input.errorContext === "tool_execution") {
517
- return { errorHandling: "skip" };
518
- }
519
- return { errorHandling: "abort" };
520
- };
521
- }
522
- function findStaleJobs(jobs, timeoutMs) {
523
- const now = Date.now();
524
- const stale = [];
525
- for (const job of jobs) {
526
- if (job.status === "processing" && now - job.createdAt > timeoutMs) {
527
- stale.push(job.id);
528
- }
529
- }
530
- return stale;
531
- }
532
-
533
- // src/copilot-agent.ts
534
- function parseAllowedModels() {
535
- const envModels = process.env.COPILOT_AGENT_MODELS;
536
- if (!envModels) {
537
- return ["gpt-4.1", "gpt-4o", "gpt-5-mini", "grok-code-fast-1"];
538
- }
539
- return envModels.split(",").map((m) => m.trim()).filter(Boolean);
540
- }
541
- function getDefaultModel() {
542
- const defaultFromEnv = process.env.COPILOT_AGENT_DEFAULT_MODEL;
543
- if (defaultFromEnv) return defaultFromEnv.trim();
544
- const allowed = parseAllowedModels();
545
- return allowed[0] || "gpt-4.1";
546
- }
547
- function buildAgentConfig() {
548
- const config = {
549
- allowedModels: parseAllowedModels(),
550
- defaultModel: getDefaultModel()
551
- };
552
- if (process.env.COPILOT_AGENT_PROVIDER_TYPE && process.env.COPILOT_AGENT_PROVIDER_BASE_URL) {
553
- config.provider = {
554
- type: process.env.COPILOT_AGENT_PROVIDER_TYPE,
555
- baseUrl: process.env.COPILOT_AGENT_PROVIDER_BASE_URL,
556
- apiKey: process.env.COPILOT_AGENT_PROVIDER_API_KEY
557
- };
558
- }
559
- return config;
560
- }
561
- function buildScrapingTools(client, origin) {
562
- return [
563
- {
564
- name: "web_scrape",
565
- description: "Scrape content from a single URL. Returns markdown content by default. Use formats parameter to request JSON extraction with a schema, or other formats like html, screenshot, links. If the result is empty, blocked, or shows anti-bot content, use web_screenshot to visually inspect the page.",
566
- parameters: {
567
- type: "object",
568
- properties: {
569
- url: { type: "string", description: "The URL to scrape" },
570
- formats: {
571
- type: "array",
572
- description: 'Output formats: "markdown", "html", "links", or JSON extraction object',
573
- items: { type: "string" }
574
- },
575
- onlyMainContent: {
576
- type: "boolean",
577
- description: "Extract only the main content, excluding nav/footer/etc"
578
- },
579
- waitFor: {
580
- type: "number",
581
- description: "Wait time in ms for JS-rendered pages (default 0)"
582
- }
583
- },
584
- required: ["url"]
585
- },
586
- handler: async (args2) => {
587
- try {
588
- const a = args2;
589
- const { url, ...opts } = a;
590
- const res = await client.scrape(String(url), {
591
- ...opts,
592
- origin
593
- });
594
- return {
595
- textResultForLlm: JSON.stringify(res, null, 2),
596
- resultType: "success"
597
- };
598
- } catch (err) {
599
- return {
600
- textResultForLlm: `Scrape failed: ${err.message || err}`,
601
- resultType: "failure",
602
- error: String(err.message || err)
603
- };
604
- }
605
- }
606
- },
607
- {
608
- name: "web_screenshot",
609
- description: "Take a screenshot of a web page for visual inspection. Use this when web_scrape returns empty, blocked, or anti-bot content. The screenshot is passed directly to your vision capability so you can analyze what is on the page \u2014 cookie popups, CAPTCHAs, anti-bot walls, or the actual rendered content.",
610
- parameters: {
611
- type: "object",
612
- properties: {
613
- url: { type: "string", description: "The URL to screenshot" },
614
- fullPage: {
615
- type: "boolean",
616
- description: "Capture the full scrollable page (default: false, viewport only)"
617
- },
618
- waitFor: {
619
- type: "number",
620
- description: "Wait time in ms for JS rendering before screenshot (default 3000)"
621
- }
622
- },
623
- required: ["url"]
624
- },
625
- handler: async (args2) => {
626
- try {
627
- const a = args2;
628
- const url = String(a.url);
629
- const fullPage = a.fullPage === true;
630
- const waitFor = typeof a.waitFor === "number" ? a.waitFor : 3e3;
631
- const screenshotFormat = {
632
- type: "screenshot",
633
- fullPage
634
- };
635
- const res = await client.scrape(url, {
636
- formats: [screenshotFormat],
637
- waitFor,
638
- origin
639
- });
640
- const resAny = res;
641
- const screenshot = resAny?.screenshot || resAny?.data?.screenshot;
642
- const metadata = resAny?.metadata || resAny?.data?.metadata || {};
643
- if (screenshot) {
644
- const imgBytes = Buffer.from(screenshot, "base64").length;
645
- const description = [
646
- `Screenshot of: ${url}`,
647
- `Title: ${metadata.title || "Unknown"}`,
648
- `Status: ${metadata.statusCode || "Unknown"}`,
649
- `Size: ${Math.round(imgBytes / 1024)}KB`,
650
- "",
651
- "Analyze the attached image to determine:",
652
- "- Is there a cookie consent popup blocking content?",
653
- "- Is there a CAPTCHA or anti-bot challenge?",
654
- "- Is the page content actually rendered?",
655
- "- What text/data is visible?"
656
- ].join("\n");
657
- return {
658
- textResultForLlm: description,
659
- binaryResultsForLlm: [{
660
- data: screenshot,
661
- mimeType: "image/png",
662
- type: "image",
663
- description: `Screenshot of ${url}`
664
- }],
665
- resultType: "success"
666
- };
667
- }
668
- return {
669
- textResultForLlm: `Screenshot request completed but no image was returned. Page metadata: ${JSON.stringify(metadata, null, 2)}`,
670
- resultType: "success"
671
- };
672
- } catch (err) {
673
- return {
674
- textResultForLlm: `Screenshot failed: ${err.message || err}. The page may be unreachable or the screenshot service unavailable.`,
675
- resultType: "failure",
676
- error: String(err.message || err)
677
- };
678
- }
679
- }
680
- },
681
- {
682
- name: "web_search",
683
- description: "Search the web for information. Returns search results with titles, URLs, and snippets. Supports search operators like site:, inurl:, intitle:, and quoted phrases.",
684
- parameters: {
685
- type: "object",
686
- properties: {
687
- query: { type: "string", description: "The search query" },
688
- limit: {
689
- type: "number",
690
- description: "Maximum number of results (default 5)"
691
- }
692
- },
693
- required: ["query"]
694
- },
695
- handler: async (args2) => {
696
- try {
697
- const a = args2;
698
- const { query, ...opts } = a;
699
- const res = await client.search(String(query), {
700
- ...opts,
701
- origin
702
- });
703
- return {
704
- textResultForLlm: JSON.stringify(res, null, 2),
705
- resultType: "success"
706
- };
707
- } catch (err) {
708
- return {
709
- textResultForLlm: `Search failed: ${err.message || err}`,
710
- resultType: "failure",
711
- error: String(err.message || err)
712
- };
713
- }
714
- }
715
- },
716
- {
717
- name: "web_map",
718
- description: "Map a website to discover all indexed URLs. Use the search parameter to find specific pages. Useful when you need to find the right page before scraping.",
719
- parameters: {
720
- type: "object",
721
- properties: {
722
- url: { type: "string", description: "The website URL to map" },
723
- search: {
724
- type: "string",
725
- description: "Optional search query to filter URLs"
726
- },
727
- limit: {
728
- type: "number",
729
- description: "Maximum number of URLs to return"
730
- }
731
- },
732
- required: ["url"]
733
- },
734
- handler: async (args2) => {
735
- try {
736
- const a = args2;
737
- const { url, ...opts } = a;
738
- const res = await client.map(String(url), {
739
- ...opts,
740
- origin
741
- });
742
- return {
743
- textResultForLlm: JSON.stringify(res, null, 2),
744
- resultType: "success"
745
- };
746
- } catch (err) {
747
- return {
748
- textResultForLlm: `Map failed: ${err.message || err}`,
749
- resultType: "failure",
750
- error: String(err.message || err)
751
- };
752
- }
753
- }
754
- },
755
- {
756
- name: "web_extract",
757
- description: "Extract structured information from web pages using LLM capabilities. Provide URLs, a prompt describing what to extract, and an optional JSON schema for the output format.",
758
- parameters: {
759
- type: "object",
760
- properties: {
761
- urls: {
762
- type: "array",
763
- items: { type: "string" },
764
- description: "URLs to extract information from"
765
- },
766
- prompt: {
767
- type: "string",
768
- description: "Description of what information to extract"
769
- },
770
- schema: {
771
- type: "object",
772
- description: "JSON schema for structured output"
773
- }
774
- },
775
- required: ["urls"]
776
- },
777
- handler: async (args2) => {
778
- try {
779
- const a = args2;
780
- const res = await client.extract({
781
- urls: a.urls,
782
- prompt: a.prompt,
783
- schema: a.schema,
784
- origin
785
- });
786
- return {
787
- textResultForLlm: JSON.stringify(res, null, 2),
788
- resultType: "success"
789
- };
790
- } catch (err) {
791
- return {
792
- textResultForLlm: `Extract failed: ${err.message || err}`,
793
- resultType: "failure",
794
- error: String(err.message || err)
795
- };
796
- }
797
- }
798
- }
799
- ];
800
- }
801
- var agentJobs = /* @__PURE__ */ new Map();
802
- var rateLimitGuard = new RateLimitGuard(buildRateLimitConfig());
803
- setInterval(() => {
804
- const staleIds = findStaleJobs(agentJobs.values(), rateLimitGuard.config.staleJobTimeoutMs);
805
- for (const id of staleIds) {
806
- const job = agentJobs.get(id);
807
- if (job) {
808
- job.status = "failed";
809
- job.error = `Job timed out after ${rateLimitGuard.config.staleJobTimeoutMs / 1e3}s without completing.`;
810
- job.completedAt = Date.now();
811
- rateLimitGuard.release(job._userKey || "");
812
- console.warn(`[RateLimit] Reaped stale job ${id}`);
813
- }
814
- }
815
- }, rateLimitGuard.config.gcIntervalMs);
816
- var clientCache = /* @__PURE__ */ new Map();
817
- var MAX_CLIENT_AGE_MS = 30 * 60 * 1e3;
818
- setInterval(() => {
819
- const now = Date.now();
820
- for (const [key, entry] of clientCache) {
821
- if (now - entry.lastUsed > MAX_CLIENT_AGE_MS) {
822
- try {
823
- entry.client.stop();
824
- } catch {
825
- }
826
- clientCache.delete(key);
827
- }
828
- }
829
- }, 5 * 60 * 1e3);
830
- async function getCopilotClient(userToken) {
831
- const cacheKey = userToken || "";
832
- const existing = clientCache.get(cacheKey);
833
- if (existing) {
834
- existing.lastUsed = Date.now();
835
- return existing.client;
836
- }
837
- const clientOpts = {};
838
- if (process.env.COPILOT_CLI_PATH) {
839
- clientOpts.cliPath = process.env.COPILOT_CLI_PATH;
840
- }
841
- if (process.env.COPILOT_CLI_URL) {
842
- clientOpts.cliUrl = process.env.COPILOT_CLI_URL;
843
- }
844
- const token = userToken || process.env.GITHUB_TOKEN;
845
- if (token) {
846
- clientOpts.githubToken = token;
847
- }
848
- const client = new CopilotClient(clientOpts);
849
- clientCache.set(cacheKey, { client, lastUsed: Date.now() });
850
- return client;
851
- }
852
- async function startAgent(request, scrapingClient, origin, config, copilotToken) {
853
- const jobId = uuidv4();
854
- const userKey = copilotToken || "__server__";
855
- const gate = rateLimitGuard.check(userKey);
856
- if (!gate.allowed) {
857
- return {
858
- id: jobId,
859
- status: "rate_limited",
860
- rateLimited: true,
861
- retryAfterSeconds: gate.retryAfterSeconds,
862
- error: gate.reason
863
- };
864
- }
865
- const requestedModel = request.model || config.defaultModel;
866
- if (!config.allowedModels.includes(requestedModel)) {
867
- return {
868
- id: jobId,
869
- status: "failed",
870
- error: `Model "${requestedModel}" is not in the allowed list: ${config.allowedModels.join(", ")}`
871
- };
872
- }
873
- rateLimitGuard.acquire(userKey);
874
- const job = {
875
- id: jobId,
876
- status: "processing",
877
- prompt: request.prompt,
878
- createdAt: Date.now(),
879
- _userKey: userKey
880
- };
881
- agentJobs.set(jobId, job);
882
- try {
883
- await runAgentJob(job, request, scrapingClient, origin, config, requestedModel, copilotToken);
884
- } catch (err) {
885
- job.status = "failed";
886
- job.error = String(err.message || err);
887
- job.completedAt = Date.now();
888
- } finally {
889
- rateLimitGuard.release(userKey);
890
- }
891
- return {
892
- id: jobId,
893
- status: job.status,
894
- data: job.result || void 0,
895
- error: job.error || void 0,
896
- duration: job.completedAt ? `${((job.completedAt - job.createdAt) / 1e3).toFixed(1)}s` : void 0
897
- };
898
- }
899
- async function runAgentJob(job, request, scrapingClient, origin, config, model, copilotToken) {
900
- try {
901
- job.progress = "Initializing Copilot SDK agent...";
902
- const client = await getCopilotClient(copilotToken);
903
- const tools = buildScrapingTools(scrapingClient, origin);
904
- const sessionConfig = {
905
- model,
906
- tools,
907
- // Disable all built-in tools - we only want our scorchcrawl tools
908
- availableTools: tools.map((t) => t.name),
909
- systemMessage: {
910
- mode: "replace",
911
- content: buildSystemPrompt(request)
912
- }
913
- };
914
- if (config.provider) {
915
- sessionConfig.provider = {
916
- type: config.provider.type,
917
- baseUrl: config.provider.baseUrl,
918
- ...config.provider.apiKey && { apiKey: config.provider.apiKey }
919
- };
920
- }
921
- job.progress = `Creating agent session with model: ${model}`;
922
- const session = await client.createSession(sessionConfig);
923
- try {
924
- const errorHook = buildErrorHook(job.id);
925
- if (typeof session.registerHooks === "function") {
926
- session.registerHooks({ onErrorOccurred: errorHook });
927
- }
928
- } catch {
929
- }
930
- try {
931
- const userKey = copilotToken || "__server__";
932
- session.on?.("assistant.usage", (event) => {
933
- const snapshots = event?.data?.quotaSnapshots || event?.quotaSnapshots;
934
- if (snapshots) {
935
- const snap = snapshots.chat || Object.values(snapshots)[0];
936
- if (snap) {
937
- rateLimitGuard.quota.update(userKey, {
938
- remainingPercent: snap.remainingPercentage ?? snap.percent_remaining ?? 100,
939
- usedRequests: snap.usedRequests ?? snap.remaining ?? 0,
940
- entitlementRequests: snap.entitlementRequests ?? snap.entitlement ?? -1,
941
- isUnlimited: snap.isUnlimitedEntitlement ?? snap.unlimited ?? false,
942
- resetDate: snap.resetDate ?? void 0
943
- });
944
- }
945
- }
946
- });
947
- } catch {
948
- }
949
- try {
950
- job.progress = "Agent is researching...";
951
- let userPrompt = request.prompt;
952
- if (request.urls && request.urls.length > 0) {
953
- userPrompt += `
954
-
955
- Focus on these URLs:
956
- ${request.urls.map((u) => `- ${u}`).join("\n")}`;
957
- }
958
- if (request.schema) {
959
- userPrompt += `
960
-
961
- Return the results in this JSON schema format:
962
- ${JSON.stringify(request.schema, null, 2)}`;
963
- }
964
- const timeoutMs = request.timeout || (process.env.AGENT_TIMEOUT_MS ? parseInt(process.env.AGENT_TIMEOUT_MS, 10) : void 0) || void 0;
965
- const response = await session.sendAndWait({ prompt: userPrompt }, timeoutMs);
966
- job.status = "completed";
967
- job.completedAt = Date.now();
968
- job.result = {
969
- success: true,
970
- data: response?.data?.content || "No response generated",
971
- model
972
- };
973
- } finally {
974
- try {
975
- await session.destroy();
976
- } catch {
977
- }
978
- }
979
- } catch (err) {
980
- job.status = "failed";
981
- job.error = `Agent error: ${err.message || err}`;
982
- job.completedAt = Date.now();
983
- }
984
- }
985
- function buildSystemPrompt(request) {
986
- return `You are an autonomous web research agent. Your job is to browse the internet, search for information, navigate through pages, and extract structured data based on the user's query.
987
-
988
- You have access to the following web tools:
989
- - **web_search**: Search the web for information. Start with this for broad queries.
990
- - **web_scrape**: Scrape content from a specific URL. Use this to get page content.
991
- - **web_screenshot**: Take a screenshot of a page. The image is sent directly to you for vision analysis. Use this when scrape returns empty/blocked content.
992
- - **web_map**: Map a website to discover all its URLs. Use this to find specific pages on a site.
993
- - **web_extract**: Extract structured data from URLs using LLM. Use this for structured extraction.
994
-
995
- ## Research Strategy
996
-
997
- 1. **Start with search** to find relevant URLs if no specific URLs are provided.
998
- 2. **Map websites** to discover relevant pages when you need to explore a site's structure.
999
- 3. **Scrape specific pages** to get their content.
1000
- 4. **If scrape fails or returns empty/blocked content**, use **web_screenshot** to visually inspect the page and determine what's blocking (cookie popup, CAPTCHA, anti-bot, JS rendering issue).
1001
- 5. **Extract structured data** when you need specific fields from pages.
1002
-
1003
- ## Visual Inspection Fallback
1004
-
1005
- When web_scrape returns any of these, ALWAYS follow up with web_screenshot:
1006
- - Empty or very short content (< 100 characters)
1007
- - Anti-bot messages ("Checking your browser", "Access denied", "Just a moment")
1008
- - Cookie consent popup HTML instead of actual content
1009
- - CAPTCHA challenges
1010
- - Generic error pages
1011
-
1012
- The screenshot helps you understand what the user would see in a browser, and report back what's actually on the page even when text extraction fails.
1013
-
1014
- ## Guidelines
1015
-
1016
- - Be thorough: check multiple sources when possible.
1017
- - Be efficient: don't scrape pages that aren't relevant.
1018
- - If a page fails to load or returns empty content, try an alternative approach (screenshot, different URL, waitFor parameter).
1019
- - Always provide your final answer with all the information you gathered.
1020
- - If the user provided a JSON schema, format your final response to match that schema.
1021
- - Cite your sources with URLs when providing information.
1022
- - When reporting screenshot results, describe what you observed on the page.
1023
-
1024
- ## Important
1025
-
1026
- - You MUST use the provided tools to gather information. Do not make up or hallucinate data.
1027
- - If you cannot find the requested information after reasonable effort, say so clearly.
1028
- - Provide complete, well-structured responses.`;
1029
- }
1030
- async function shutdownAgent() {
1031
- rateLimitGuard.shutdown();
1032
- for (const [, entry] of clientCache) {
1033
- try {
1034
- await entry.client.stop();
1035
- } catch {
1036
- }
1037
- }
1038
- clientCache.clear();
1039
- }
1040
-
1041
- // src/response-utils.ts
1042
- import { createHash } from "node:crypto";
1043
- var MAX_CONTENT_CHARS = parseInt(
1044
- process.env.SCORCHCRAWL_MAX_CONTENT_CHARS || "25000",
1045
- 10
1046
- );
1047
- var SUMMARIZE_AFTER_WORDS = parseInt(
1048
- process.env.SCORCHCRAWL_SUMMARIZE_AFTER_WORDS || "5000",
1049
- 10
1050
- );
1051
- var SUMMARIZE_MODEL = process.env.SCORCHCRAWL_SUMMARIZE_MODEL || "gpt-4o";
1052
- var SUMMARIZE_CACHE_SIZE = parseInt(
1053
- process.env.SCORCHCRAWL_SUMMARIZE_CACHE_SIZE || "100",
1054
- 10
1055
- );
1056
- var SUMMARIZE_MAX_PER_MINUTE = parseInt(
1057
- process.env.SCORCHCRAWL_SUMMARIZE_MAX_PER_MINUTE || "10",
1058
- 10
1059
- );
1060
- var SUMMARIZE_TIMEOUT_MS = parseInt(
1061
- process.env.SCORCHCRAWL_SUMMARIZE_TIMEOUT_MS || "300000",
1062
- 10
1063
- );
1064
- function mapError(err) {
1065
- const raw = normalizeErrorString(err);
1066
- const lower = raw.toLowerCase();
1067
- if (matches(lower, ["captcha", "recaptcha", "hcaptcha", "turnstile"])) {
1068
- return {
1069
- code: "CAPTCHA_REQUIRED",
1070
- message: "Page requires CAPTCHA verification.",
1071
- suggestions: [
1072
- 'Try proxy: "stealth" to avoid CAPTCHA triggers',
1073
- "Use scorch_agent which may navigate around CAPTCHAs",
1074
- "Try scorch_search to find the content from a cached source"
1075
- ],
1076
- originalError: raw
1077
- };
1078
- }
1079
- if (matches(lower, ["403", "forbidden", "cloudflare", "cf-ray", "challenge", "access denied"])) {
1080
- return {
1081
- code: "ACCESS_DENIED",
1082
- message: "This site blocks automated access.",
1083
- suggestions: [
1084
- "Try scorch_search to find cached/indexed content instead",
1085
- "Add waitFor: 5000 to let challenge pages resolve",
1086
- 'Try with proxy: "stealth" for enhanced bot evasion'
1087
- ],
1088
- originalError: raw
1089
- };
1090
- }
1091
- if (matches(lower, ["404", "not found", "page not found", "does not exist"])) {
1092
- return {
1093
- code: "NOT_FOUND",
1094
- message: "Page does not exist at this URL.",
1095
- suggestions: [
1096
- "Use scorch_map to discover correct URLs on the site",
1097
- "Check if the URL has a typo or outdated path",
1098
- "Try scorch_search to find the current location of this content"
1099
- ],
1100
- originalError: raw
1101
- };
1102
- }
1103
- if (matches(lower, ["429", "rate limit", "too many requests", "throttl"])) {
1104
- return {
1105
- code: "RATE_LIMITED",
1106
- message: "Rate limited by the target site.",
1107
- suggestions: [
1108
- "Wait 30 seconds and retry",
1109
- "Try a different URL on the same site",
1110
- "Use scorch_search to find the content from a different source"
1111
- ],
1112
- originalError: raw
1113
- };
1114
- }
1115
- if (matchesStatusRange(lower, 500, 599)) {
1116
- return {
1117
- code: "SERVER_ERROR",
1118
- message: "The target site returned a server error.",
1119
- suggestions: [
1120
- "Retry once \u2014 the site may be experiencing temporary issues",
1121
- "Try again in a few minutes if it persists"
1122
- ],
1123
- originalError: raw
1124
- };
1125
- }
1126
- if (matches(lower, ["timeout", "aborterror", "aborted", "timed out", "etimedout"])) {
1127
- return {
1128
- code: "TIMEOUT",
1129
- message: "Page took too long to load.",
1130
- suggestions: [
1131
- "Add onlyMainContent: true to reduce processing time",
1132
- "Increase waitFor to give JS more time to render",
1133
- "Try with a simpler format like markdown instead of screenshot"
1134
- ],
1135
- originalError: raw
1136
- };
1137
- }
1138
- if (matches(lower, ["econnrefused", "enotfound", "dns", "getaddrinfo", "connect failed"])) {
1139
- if (matches(lower, ["localhost", "127.0.0.1", "0.0.0.0", "3002", "engine", "api"])) {
1140
- return {
1141
- code: "ENGINE_UNAVAILABLE",
1142
- message: "Scraping engine is not running.",
1143
- suggestions: [
1144
- "Check that Docker services are up: docker compose up -d",
1145
- "Verify SCORCHCRAWL_API_URL is correct in your config"
1146
- ],
1147
- originalError: raw
1148
- };
1149
- }
1150
- return {
1151
- code: "CONNECTION_FAILED",
1152
- message: "Cannot reach this URL.",
1153
- suggestions: [
1154
- "Check if the URL is correct and accessible",
1155
- "The site may be down \u2014 try again later",
1156
- "Try scorch_search to find alternative sources"
1157
- ],
1158
- originalError: raw
1159
- };
1160
- }
1161
- if (matches(lower, ["ssl", "tls", "certificate", "cert", "self-signed", "unable to verify"])) {
1162
- return {
1163
- code: "TLS_ERROR",
1164
- message: "SSL certificate issue with this site.",
1165
- suggestions: [
1166
- "Try with skipTlsVerification: true"
1167
- ],
1168
- originalError: raw
1169
- };
1170
- }
1171
- if (matches(lower, ["empty", "no content", "no markdown", "no extractable"])) {
1172
- return {
1173
- code: "EMPTY_CONTENT",
1174
- message: "Page returned no extractable content.",
1175
- suggestions: [
1176
- "Use scorch_map with a search param to find the right page",
1177
- "Add waitFor: 5000 for JavaScript-rendered pages",
1178
- 'Try with formats: ["html"] to see the raw page structure'
1179
- ],
1180
- originalError: raw
1181
- };
1182
- }
1183
- if (matches(lower, ["spa_skeleton_detected", "spa detected", "javascript is required"])) {
1184
- return {
1185
- code: "SPA_DETECTED",
1186
- message: "Page requires JavaScript rendering (SPA detected).",
1187
- suggestions: [
1188
- "Add waitFor: 5000 to allow JavaScript to render",
1189
- "The engine will automatically retry with Playwright"
1190
- ],
1191
- originalError: raw
1192
- };
1193
- }
1194
- if (matches(lower, ["geo", "geoblocked", "not available in your", "region", "country restriction"])) {
1195
- return {
1196
- code: "GEO_BLOCKED",
1197
- message: "Content is geo-restricted.",
1198
- suggestions: [
1199
- 'Use location parameter with a different country: location: { country: "US" }',
1200
- 'Try proxy: "stealth" which may route through a different region'
1201
- ],
1202
- originalError: raw
1203
- };
1204
- }
1205
- if (matches(lower, ["paywall", "subscribe", "premium content", "members only"])) {
1206
- return {
1207
- code: "PAYWALL",
1208
- message: "Content is behind a paywall.",
1209
- suggestions: [
1210
- "Try scorch_search to find freely available alternatives",
1211
- "Use scorch_map to find non-paywalled pages on the same site"
1212
- ],
1213
- originalError: raw
1214
- };
1215
- }
1216
- return {
1217
- code: "UNKNOWN_ERROR",
1218
- message: `Scraping failed: ${truncateString(raw, 200)}`,
1219
- suggestions: [
1220
- "Try a different URL or format",
1221
- "Use scorch_search as an alternative data source"
1222
- ],
1223
- originalError: raw
1224
- };
1225
- }
1226
- function normalizeErrorString(err) {
1227
- if (err == null) return "Unknown error";
1228
- if (err instanceof Error) {
1229
- const axiosStatus = err?.response?.status;
1230
- const status = err?.status || err?.statusCode || axiosStatus;
1231
- const prefix = status ? `HTTP ${status}: ` : "";
1232
- return `${prefix}${err.message}`;
1233
- }
1234
- if (typeof err === "string") return err;
1235
- try {
1236
- const s = JSON.stringify(err);
1237
- return s || "Unknown error";
1238
- } catch {
1239
- return String(err);
1240
- }
1241
- }
1242
- function matches(lower, patterns) {
1243
- return patterns.some((p) => lower.includes(p));
1244
- }
1245
- function matchesStatusRange(lower, from, to) {
1246
- const statusMatch = lower.match(/(?:status\s*(?:code\s*)?|http\s*)(\d{3})/);
1247
- if (statusMatch) {
1248
- const code = parseInt(statusMatch[1], 10);
1249
- return code >= from && code <= to;
1250
- }
1251
- for (let s = from; s <= to; s++) {
1252
- if (lower.includes(String(s))) return true;
1253
- }
1254
- return false;
1255
- }
1256
- function truncateString(s, maxLen) {
1257
- if (s.length <= maxLen) return s;
1258
- return s.slice(0, maxLen - 3) + "...";
1259
- }
1260
- var CONTENT_FIELDS = ["markdown", "html", "rawHtml"];
1261
- var SINGLE_PAGE_MAX_FRACTION = 0.3;
1262
- function truncateAtBoundary(content, maxChars) {
1263
- if (content.length <= maxChars) {
1264
- return { text: content, wasTruncated: false };
1265
- }
1266
- const searchWindow = content.slice(0, maxChars);
1267
- const headingMatch = searchWindow.lastIndexOf("\n#");
1268
- const paraMatch = searchWindow.lastIndexOf("\n\n");
1269
- const lineMatch = searchWindow.lastIndexOf("\n");
1270
- const sentenceMatch = Math.max(
1271
- searchWindow.lastIndexOf(". "),
1272
- searchWindow.lastIndexOf(".\n")
1273
- );
1274
- let breakPoint = -1;
1275
- const minBreak = Math.floor(maxChars * 0.7);
1276
- if (headingMatch > minBreak) breakPoint = headingMatch;
1277
- else if (paraMatch > minBreak) breakPoint = paraMatch;
1278
- else if (lineMatch > minBreak) breakPoint = lineMatch;
1279
- else if (sentenceMatch > minBreak) breakPoint = sentenceMatch + 1;
1280
- else breakPoint = maxChars;
1281
- const truncated = content.slice(0, breakPoint).trimEnd();
1282
- return { text: truncated, wasTruncated: true };
1283
- }
1284
- function truncateContent(data) {
1285
- if (MAX_CONTENT_CHARS <= 0) {
1286
- const serialized2 = JSON.stringify(data, null, 2);
1287
- return { result: data, wasTruncated: false, originalLength: serialized2.length };
1288
- }
1289
- const serialized = JSON.stringify(data, null, 2);
1290
- if (serialized.length <= MAX_CONTENT_CHARS) {
1291
- return { result: data, wasTruncated: false, originalLength: serialized.length };
1292
- }
1293
- const cloned = JSON.parse(serialized);
1294
- let wasTruncated = false;
1295
- if (cloned && typeof cloned === "object") {
1296
- const target = cloned.data || cloned;
1297
- for (const field of CONTENT_FIELDS) {
1298
- if (typeof target[field] === "string" && target[field].length > 0) {
1299
- const otherFieldsSize = serialized.length - target[field].length;
1300
- const fieldBudget = Math.max(
1301
- MAX_CONTENT_CHARS - otherFieldsSize,
1302
- Math.floor(MAX_CONTENT_CHARS * 0.5)
1303
- // at least 50% of limit
1304
- );
1305
- if (target[field].length > fieldBudget) {
1306
- const { text } = truncateAtBoundary(target[field], fieldBudget);
1307
- const originalLen = target[field].length;
1308
- target[field] = text + buildTruncationNotice(text.length, originalLen);
1309
- wasTruncated = true;
1310
- }
1311
- }
1312
- }
1313
- if (Array.isArray(target)) {
1314
- const result = truncateCrawlArray(target);
1315
- return { result: cloned, wasTruncated: result.wasTruncated, originalLength: serialized.length };
1316
- }
1317
- if (wasTruncated) {
1318
- const meta = target.metadata || target;
1319
- meta._truncated = true;
1320
- meta._originalLength = serialized.length;
1321
- }
1322
- }
1323
- return { result: cloned, wasTruncated, originalLength: serialized.length };
1324
- }
1325
- function truncateCrawlArray(pages) {
1326
- if (pages.length === 0) return { wasTruncated: false };
1327
- const perPageLimit = Math.floor(MAX_CONTENT_CHARS * SINGLE_PAGE_MAX_FRACTION);
1328
- let wasTruncated = false;
1329
- let totalSize = 0;
1330
- for (let i = 0; i < pages.length; i++) {
1331
- const page = pages[i];
1332
- if (!page || typeof page !== "object") continue;
1333
- const target = page.data || page;
1334
- for (const field of CONTENT_FIELDS) {
1335
- if (typeof target[field] === "string" && target[field].length > perPageLimit) {
1336
- const { text } = truncateAtBoundary(target[field], perPageLimit);
1337
- target[field] = text + buildTruncationNotice(text.length, target[field].length);
1338
- wasTruncated = true;
1339
- }
1340
- }
1341
- totalSize += JSON.stringify(page).length;
1342
- if (totalSize > MAX_CONTENT_CHARS && i < pages.length - 1) {
1343
- const originalCount = pages.length;
1344
- pages.length = i + 1;
1345
- pages.push({
1346
- _notice: `Showing ${i + 1} of ${originalCount} crawled pages. Use scorch_check_crawl_status with pagination to see more.`
1347
- });
1348
- wasTruncated = true;
1349
- break;
1350
- }
1351
- }
1352
- return { wasTruncated };
1353
- }
1354
- function buildTruncationNotice(shownChars, originalChars) {
1355
- return `
1356
-
1357
- ---
1358
- [Content truncated. Showing ~${Math.round(shownChars / 1e3)}k of ~${Math.round(originalChars / 1e3)}k characters.
1359
- To get specific information, try:
1360
- - Use JSON format with a schema to extract only the data you need
1361
- - Add onlyMainContent: true to exclude navigation/footers
1362
- - Use scorch_map to find a more specific page URL]`;
1363
- }
1364
- var summaryCache = /* @__PURE__ */ new Map();
1365
- var CACHE_TTL_MS = 30 * 60 * 1e3;
1366
- var summarizationTimestamps = [];
1367
- function wordCount(text) {
1368
- return text.split(/\s+/).filter(Boolean).length;
1369
- }
1370
- function summaryKey(url, markdown) {
1371
- const fingerprint = url + markdown.slice(0, 1e3);
1372
- return createHash("sha256").update(fingerprint).digest("hex");
1373
- }
1374
- function canSummarize() {
1375
- if (SUMMARIZE_MAX_PER_MINUTE <= 0) return false;
1376
- const now = Date.now();
1377
- const windowStart = now - 6e4;
1378
- while (summarizationTimestamps.length > 0 && summarizationTimestamps[0] < windowStart) {
1379
- summarizationTimestamps.shift();
1380
- }
1381
- return summarizationTimestamps.length < SUMMARIZE_MAX_PER_MINUTE;
1382
- }
1383
- function recordSummarization() {
1384
- summarizationTimestamps.push(Date.now());
1385
- }
1386
- function evictCache() {
1387
- if (summaryCache.size <= SUMMARIZE_CACHE_SIZE) return;
1388
- const entries = [...summaryCache.entries()].sort(
1389
- (a, b) => a[1].timestamp - b[1].timestamp
1390
- );
1391
- const toDelete = entries.slice(0, entries.length - SUMMARIZE_CACHE_SIZE);
1392
- for (const [key] of toDelete) {
1393
- summaryCache.delete(key);
1394
- }
1395
- }
1396
- async function summarizeIfNeeded(markdown, url, getCopilotClientFn, copilotToken) {
1397
- if (SUMMARIZE_AFTER_WORDS <= 0) {
1398
- return { markdown, summarized: false };
1399
- }
1400
- const words = wordCount(markdown);
1401
- if (words <= SUMMARIZE_AFTER_WORDS) {
1402
- return { markdown, summarized: false };
1403
- }
1404
- const key = summaryKey(url, markdown);
1405
- const cached = summaryCache.get(key);
1406
- if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
1407
- return {
1408
- markdown: cached.summary,
1409
- summarized: true,
1410
- meta: {
1411
- _summarized: true,
1412
- _originalWordCount: words,
1413
- _summaryWordCount: cached.wordCount,
1414
- _summarizedBy: SUMMARIZE_MODEL,
1415
- _cachedResult: true
1416
- }
1417
- };
1418
- }
1419
- if (!getCopilotClientFn) {
1420
- return { markdown, summarized: false };
1421
- }
1422
- if (!canSummarize()) {
1423
- return { markdown, summarized: false };
1424
- }
1425
- try {
1426
- const client = await getCopilotClientFn(copilotToken);
1427
- if (!client) {
1428
- return { markdown, summarized: false };
1429
- }
1430
- const targetWords = Math.min(SUMMARIZE_AFTER_WORDS, Math.floor(words * 0.3));
1431
- const sessionConfig = {
1432
- model: SUMMARIZE_MODEL,
1433
- systemMessage: {
1434
- mode: "replace",
1435
- content: buildSummarizationPrompt(words, targetWords)
1436
- }
1437
- };
1438
- const session = await client.createSession(sessionConfig);
1439
- recordSummarization();
1440
- try {
1441
- const response = await session.sendAndWait({ prompt: markdown }, SUMMARIZE_TIMEOUT_MS);
1442
- let summary = "";
1443
- if (response && typeof response === "object") {
1444
- const r = response;
1445
- if (r.data && typeof r.data === "object") {
1446
- const d = r.data;
1447
- if (typeof d.content === "string") summary = d.content;
1448
- else if (typeof d.text === "string") summary = d.text;
1449
- }
1450
- if (!summary && typeof r.content === "string") summary = r.content;
1451
- if (!summary && typeof r.text === "string") summary = r.text;
1452
- if (!summary && r.message && typeof r.message === "object") {
1453
- const m = r.message;
1454
- if (typeof m.content === "string") summary = m.content;
1455
- }
1456
- } else if (typeof response === "string") {
1457
- summary = response;
1458
- }
1459
- if (!summary || summary.length < 50) {
1460
- console.error(`[Summarization] Response too short or empty (${summary.length} chars), response keys: ${response ? Object.keys(response).join(", ") : "null"}`);
1461
- return { markdown, summarized: false };
1462
- }
1463
- const summaryWords = wordCount(summary);
1464
- summaryCache.set(key, {
1465
- summary,
1466
- timestamp: Date.now(),
1467
- wordCount: summaryWords
1468
- });
1469
- evictCache();
1470
- return {
1471
- markdown: summary,
1472
- summarized: true,
1473
- meta: {
1474
- _summarized: true,
1475
- _originalWordCount: words,
1476
- _summaryWordCount: summaryWords,
1477
- _summarizedBy: SUMMARIZE_MODEL
1478
- }
1479
- };
1480
- } finally {
1481
- try {
1482
- await session.destroy();
1483
- } catch {
1484
- }
1485
- }
1486
- } catch (err) {
1487
- const errMsg = err instanceof Error ? err.message : String(err);
1488
- const errStack = err instanceof Error ? err.stack : "";
1489
- console.error(`[Summarization] Failed for ${url}: ${errMsg}`);
1490
- if (errStack) console.error(`[Summarization] Stack: ${errStack}`);
1491
- return {
1492
- markdown,
1493
- summarized: false,
1494
- meta: { _summarizationFailed: true, _summarizationError: errMsg }
1495
- };
1496
- }
1497
- }
1498
- function buildSummarizationPrompt(originalWords, targetWords) {
1499
- return `You are a content summarizer for a web scraping tool.
1500
- Summarize the following web page content, preserving:
1501
- - ALL factual data (numbers, dates, names, prices, specifications)
1502
- - Key arguments, conclusions, and findings
1503
- - Structural organization (use headings, lists)
1504
- - Any code snippets or technical details
1505
-
1506
- Do NOT:
1507
- - Add opinions or analysis
1508
- - Omit specific data points (numbers, names, URLs)
1509
- - Change the meaning or emphasis of the content
1510
- - Add any preamble like "Here is a summary" \u2014 just output the summary directly
1511
-
1512
- Original content word count: ${originalWords}
1513
- Target summary: ~${targetWords} words`;
1514
- }
1515
- async function safeExecute(fn, context) {
1516
- try {
1517
- return await fn();
1518
- } catch (err) {
1519
- const mapped = mapError(err);
1520
- console.error(`[${context.tool}] ${mapped.code}`, {
1521
- url: context.url,
1522
- original: mapped.originalError
1523
- });
1524
- return JSON.stringify(
1525
- {
1526
- success: false,
1527
- error: mapped.message,
1528
- code: mapped.code,
1529
- suggestions: mapped.suggestions
1530
- },
1531
- null,
1532
- 2
1533
- );
1534
- }
1535
- }
1536
- async function processResponse(data, options) {
1537
- if (!options?.skipSummarization && options?.getCopilotClientFn) {
1538
- const md = extractMarkdown(data);
1539
- const wc = md ? wordCount(md) : 0;
1540
- console.error(`[Summarization] Check: markdown=${md ? md.length : 0} chars, ${wc} words, threshold=${SUMMARIZE_AFTER_WORDS}`);
1541
- if (md && wc > SUMMARIZE_AFTER_WORDS && SUMMARIZE_AFTER_WORDS > 0) {
1542
- console.error(`[Summarization] Attempting summarization for ${options.url || "unknown url"} (${wc} words)`);
1543
- const result2 = await summarizeIfNeeded(
1544
- md,
1545
- options.url || "",
1546
- options.getCopilotClientFn,
1547
- options.copilotToken
1548
- );
1549
- if (result2.summarized) {
1550
- const updated = injectMarkdown(data, result2.markdown, result2.meta || {});
1551
- return JSON.stringify(updated, null, 2);
1552
- }
1553
- if (result2.meta) {
1554
- console.error(`[Summarization] Failed, falling back to truncation. Meta:`, JSON.stringify(result2.meta));
1555
- }
1556
- }
1557
- } else {
1558
- console.error(`[Summarization] Skipped: skipSummarization=${options?.skipSummarization}, hasCopilotFn=${!!options?.getCopilotClientFn}`);
1559
- }
1560
- const { result, wasTruncated } = truncateContent(data);
1561
- return JSON.stringify(result, null, 2);
1562
- }
1563
- function processResponseSync(data) {
1564
- const { result } = truncateContent(data);
1565
- return JSON.stringify(result, null, 2);
1566
- }
1567
- function extractMarkdown(data) {
1568
- if (!data || typeof data !== "object") return null;
1569
- const obj = data;
1570
- if (typeof obj.markdown === "string") return obj.markdown;
1571
- if (obj.data && typeof obj.data === "object") {
1572
- const inner = obj.data;
1573
- if (typeof inner.markdown === "string") return inner.markdown;
1574
- }
1575
- return null;
1576
- }
1577
- function injectMarkdown(data, newMarkdown, meta) {
1578
- const serialized = JSON.stringify(data);
1579
- const cloned = JSON.parse(serialized);
1580
- if (typeof cloned.markdown === "string") {
1581
- cloned.markdown = newMarkdown;
1582
- Object.assign(cloned.metadata || cloned, meta);
1583
- return cloned;
1584
- }
1585
- if (cloned.data && typeof cloned.data === "object") {
1586
- if (typeof cloned.data.markdown === "string") {
1587
- cloned.data.markdown = newMarkdown;
1588
- Object.assign(cloned.data.metadata || cloned.data, meta);
1589
- return cloned;
1590
- }
1591
- }
1592
- return cloned;
1593
- }
1594
- var ANTI_BOT_SIGNATURES = [
1595
- // Cloudflare
1596
- "checking your browser",
1597
- "cf-browser-verification",
1598
- "just a moment",
1599
- "cf-challenge-running",
1600
- "cf_chl_opt",
1601
- "ray id:",
1602
- // DataDome
1603
- "datadome",
1604
- "dd.js",
1605
- // PerimeterX
1606
- "perimeterx",
1607
- "px-captcha",
1608
- "human challenge",
1609
- // Incapsula / Imperva
1610
- "incapsula",
1611
- "imperva",
1612
- "_incapsula_resource",
1613
- // Generic
1614
- "access denied",
1615
- "bot detected",
1616
- "automated access",
1617
- "please verify you are a human",
1618
- "captcha",
1619
- // Akamai
1620
- "akamai",
1621
- "ak_bmsc",
1622
- // Shape Security
1623
- "shape security"
1624
- ];
1625
- function scoreResponseQuality(data) {
1626
- const issues = [];
1627
- let score = 100;
1628
- let antiBotDetected = false;
1629
- let antiBotSystem;
1630
- if (!data || typeof data !== "object") {
1631
- return { score: 0, antiBotDetected: false, issues: ["No response data"] };
1632
- }
1633
- const obj = data;
1634
- const inner = obj.data && typeof obj.data === "object" ? obj.data : obj;
1635
- if (obj.success === false) {
1636
- score -= 50;
1637
- issues.push("Response indicates failure");
1638
- }
1639
- const md = typeof inner.markdown === "string" ? inner.markdown : "";
1640
- const html = typeof inner.html === "string" ? inner.html : typeof inner.rawHtml === "string" ? inner.rawHtml : "";
1641
- const content = md || html;
1642
- const lower = content.toLowerCase();
1643
- for (const sig of ANTI_BOT_SIGNATURES) {
1644
- if (lower.includes(sig)) {
1645
- antiBotDetected = true;
1646
- if (sig.includes("cf-") || sig.includes("cloudflare") || sig === "just a moment" || sig === "checking your browser" || sig === "ray id:") {
1647
- antiBotSystem = "Cloudflare";
1648
- } else if (sig.includes("datadome")) {
1649
- antiBotSystem = "DataDome";
1650
- } else if (sig.includes("perimeterx") || sig.includes("px-captcha")) {
1651
- antiBotSystem = "PerimeterX";
1652
- } else if (sig.includes("incapsula") || sig.includes("imperva")) {
1653
- antiBotSystem = "Imperva/Incapsula";
1654
- } else if (sig.includes("akamai") || sig.includes("ak_bmsc")) {
1655
- antiBotSystem = "Akamai";
1656
- } else if (sig.includes("captcha")) {
1657
- antiBotSystem = "CAPTCHA";
1658
- } else {
1659
- antiBotSystem = "Unknown";
1660
- }
1661
- score -= 40;
1662
- issues.push(`Anti-bot protection detected: ${antiBotSystem}`);
1663
- break;
1664
- }
1665
- }
1666
- if (content.length === 0) {
1667
- score -= 30;
1668
- issues.push("No content extracted");
1669
- } else if (content.length < 100) {
1670
- score -= 20;
1671
- issues.push("Very minimal content (< 100 chars)");
1672
- } else if (content.length < 500) {
1673
- score -= 10;
1674
- issues.push("Thin content (< 500 chars)");
1675
- }
1676
- const navPatterns = ["sign in", "log in", "menu", "navigation", "footer", "header"];
1677
- const navCount = navPatterns.filter((p) => lower.includes(p)).length;
1678
- const contentWords = content.split(/\s+/).length;
1679
- if (contentWords < 50 && navCount >= 3) {
1680
- score -= 20;
1681
- issues.push("Content appears to be navigation-only (possible SPA shell)");
1682
- }
1683
- const errorPagePatterns = ["page not found", "404", "error occurred", "something went wrong", "under maintenance"];
1684
- if (errorPagePatterns.some((p) => lower.includes(p)) && contentWords < 200) {
1685
- score -= 15;
1686
- issues.push("Content appears to be an error page");
1687
- }
1688
- return {
1689
- score: Math.max(0, Math.min(100, score)),
1690
- antiBotDetected,
1691
- ...antiBotSystem && { antiBotSystem },
1692
- issues
1693
- };
1694
- }
1695
-
1696
- // src/scrape-cache.ts
1697
- import { createHash as createHash2 } from "node:crypto";
1698
- var SCRAPE_CACHE_TTL_MS = parseInt(
1699
- process.env.SCORCHCRAWL_SCRAPE_CACHE_TTL_MS || "120000",
1700
- 10
1701
- );
1702
- var SCRAPE_CACHE_MAX = parseInt(
1703
- process.env.SCORCHCRAWL_SCRAPE_CACHE_MAX || "50",
1704
- 10
1705
- );
1706
- var scrapeCache = /* @__PURE__ */ new Map();
1707
- function scrapeCacheKey(url, options) {
1708
- const fingerprint = JSON.stringify({ url, ...options });
1709
- return createHash2("sha256").update(fingerprint).digest("hex");
1710
- }
1711
- function getCachedScrape(key) {
1712
- const entry = scrapeCache.get(key);
1713
- if (!entry) return null;
1714
- if (Date.now() - entry.timestamp > SCRAPE_CACHE_TTL_MS) {
1715
- scrapeCache.delete(key);
1716
- return null;
1717
- }
1718
- return entry.response;
1719
- }
1720
- function setCachedScrape(key, response) {
1721
- scrapeCache.set(key, { response, timestamp: Date.now() });
1722
- if (scrapeCache.size > SCRAPE_CACHE_MAX) {
1723
- const oldest = scrapeCache.keys().next().value;
1724
- if (oldest) scrapeCache.delete(oldest);
1725
- }
1726
- }
1727
-
1728
- // src/index.ts
1729
- dotenv.config({ debug: false, quiet: true });
1730
- function extractApiKey(headers) {
1731
- const headerAuth = headers["authorization"];
1732
- const headerApiKey = headers["x-scorchcrawl-api-key"] || headers["x-api-key"];
1733
- if (headerApiKey) {
1734
- return Array.isArray(headerApiKey) ? headerApiKey[0] : headerApiKey;
1735
- }
1736
- if (typeof headerAuth === "string" && headerAuth.toLowerCase().startsWith("bearer ")) {
1737
- return headerAuth.slice(7).trim();
1738
- }
1739
- return void 0;
1740
- }
1741
- function extractCopilotToken(headers) {
1742
- const copilotHeader = headers["x-copilot-token"] || headers["x-github-token"];
1743
- if (copilotHeader) {
1744
- return Array.isArray(copilotHeader) ? copilotHeader[0] : copilotHeader;
1745
- }
1746
- return process.env.GITHUB_TOKEN || void 0;
1747
- }
1748
- function removeEmptyTopLevel(obj) {
1749
- const out = {};
1750
- for (const [k, v] of Object.entries(obj)) {
1751
- if (v == null) continue;
1752
- if (typeof v === "string" && v.trim() === "") continue;
1753
- if (Array.isArray(v) && v.length === 0) continue;
1754
- if (typeof v === "object" && !Array.isArray(v) && Object.keys(v).length === 0)
1755
- continue;
1756
- out[k] = v;
1757
- }
1758
- return out;
1759
- }
1760
- var ConsoleLogger = class {
1761
- shouldLog = process.env.CLOUD_SERVICE === "true" || process.env.SSE_LOCAL === "true" || process.env.HTTP_STREAMABLE_SERVER === "true";
1762
- debug(...args2) {
1763
- if (this.shouldLog) {
1764
- console.debug("[DEBUG]", (/* @__PURE__ */ new Date()).toISOString(), ...args2);
1765
- }
1766
- }
1767
- error(...args2) {
1768
- if (this.shouldLog) {
1769
- console.error("[ERROR]", (/* @__PURE__ */ new Date()).toISOString(), ...args2);
1770
- }
1771
- }
1772
- info(...args2) {
1773
- if (this.shouldLog) {
1774
- console.log("[INFO]", (/* @__PURE__ */ new Date()).toISOString(), ...args2);
1775
- }
1776
- }
1777
- log(...args2) {
1778
- if (this.shouldLog) {
1779
- console.log("[LOG]", (/* @__PURE__ */ new Date()).toISOString(), ...args2);
1780
- }
1781
- }
1782
- warn(...args2) {
1783
- if (this.shouldLog) {
1784
- console.warn("[WARN]", (/* @__PURE__ */ new Date()).toISOString(), ...args2);
1785
- }
1786
- }
1787
- };
1788
- var server = new FastMCP({
1789
- name: "scorchcrawl",
1790
- version: "1.0.0",
1791
- logger: new ConsoleLogger(),
1792
- roots: { enabled: false },
1793
- authenticate: async (request) => {
1794
- const copilotToken = extractCopilotToken(request.headers);
1795
- if (process.env.CLOUD_SERVICE === "true") {
1796
- const apiKey = extractApiKey(request.headers);
1797
- if (!apiKey) {
1798
- throw new Error("API key is required");
1799
- }
1800
- return { scraperApiKey: apiKey, copilotToken };
1801
- } else {
1802
- if (!process.env.SCORCHCRAWL_API_KEY && !process.env.SCORCHCRAWL_API_URL) {
1803
- console.error(
1804
- "Either SCORCHCRAWL_API_KEY or SCORCHCRAWL_API_URL must be provided"
1805
- );
1806
- process.exit(1);
1807
- }
1808
- return { scraperApiKey: process.env.SCORCHCRAWL_API_KEY, copilotToken };
1809
- }
1810
- },
1811
- // Lightweight health endpoint for LB checks
1812
- health: {
1813
- enabled: true,
1814
- message: "ok",
1815
- path: "/health",
1816
- status: 200
1817
- }
1818
- });
1819
- function createClient(apiKey) {
1820
- const cleanUrl = getCleanApiUrl();
1821
- const config = {
1822
- ...cleanUrl && {
1823
- apiUrl: cleanUrl
1824
- }
1825
- };
1826
- if (apiKey) {
1827
- config.apiKey = apiKey;
1828
- }
1829
- return new scorch_client_default(config);
1830
- }
1831
- var ORIGIN = "scorchcrawl-mcp";
1832
- var SAFE_MODE = process.env.CLOUD_SERVICE === "true";
1833
- function getClient(session) {
1834
- if (process.env.CLOUD_SERVICE === "true") {
1835
- if (!session || !session.scraperApiKey) {
1836
- throw new Error("Unauthorized");
1837
- }
1838
- return createClient(session.scraperApiKey);
1839
- }
1840
- if (!process.env.SCORCHCRAWL_API_URL && (!session || !session.scraperApiKey)) {
1841
- throw new Error(
1842
- "Unauthorized: API key is required when not using a self-hosted instance"
1843
- );
1844
- }
1845
- return createClient(session?.scraperApiKey);
1846
- }
1847
- function asText(data) {
1848
- return processResponseSync(data);
1849
- }
1850
- var safeActionTypes = ["wait", "screenshot", "scroll", "scrape"];
1851
- var otherActions = [
1852
- "click",
1853
- "write",
1854
- "press",
1855
- "executeJavascript",
1856
- "generatePDF"
1857
- ];
1858
- var allActionTypes = [...safeActionTypes, ...otherActions];
1859
- var allowedActionTypes = SAFE_MODE ? safeActionTypes : allActionTypes;
1860
- var scrapeParamsSchema = z.object({
1861
- url: z.string().url(),
1862
- formats: z.array(
1863
- z.union([
1864
- z.enum([
1865
- "markdown",
1866
- "html",
1867
- "rawHtml",
1868
- "screenshot",
1869
- "links",
1870
- "summary",
1871
- "changeTracking",
1872
- "branding"
1873
- ]),
1874
- z.object({
1875
- type: z.literal("json"),
1876
- prompt: z.string().optional(),
1877
- schema: z.record(z.string(), z.any()).optional()
1878
- }),
1879
- z.object({
1880
- type: z.literal("screenshot"),
1881
- fullPage: z.boolean().optional(),
1882
- quality: z.number().optional(),
1883
- viewport: z.object({ width: z.number(), height: z.number() }).optional()
1884
- })
1885
- ])
1886
- ).optional(),
1887
- parsers: z.array(
1888
- z.union([
1889
- z.enum(["pdf"]),
1890
- z.object({
1891
- type: z.enum(["pdf"]),
1892
- maxPages: z.number().int().min(1).max(1e4).optional()
1893
- })
1894
- ])
1895
- ).optional(),
1896
- onlyMainContent: z.boolean().optional(),
1897
- includeTags: z.array(z.string()).optional(),
1898
- excludeTags: z.array(z.string()).optional(),
1899
- waitFor: z.number().optional(),
1900
- ...SAFE_MODE ? {} : {
1901
- actions: z.array(
1902
- z.object({
1903
- type: z.enum(allowedActionTypes),
1904
- selector: z.string().optional(),
1905
- milliseconds: z.number().optional(),
1906
- text: z.string().optional(),
1907
- key: z.string().optional(),
1908
- direction: z.enum(["up", "down"]).optional(),
1909
- script: z.string().optional(),
1910
- fullPage: z.boolean().optional()
1911
- })
1912
- ).optional()
1913
- },
1914
- mobile: z.boolean().optional(),
1915
- skipTlsVerification: z.boolean().optional(),
1916
- removeBase64Images: z.boolean().optional(),
1917
- location: z.object({
1918
- country: z.string().optional(),
1919
- languages: z.array(z.string()).optional()
1920
- }).optional(),
1921
- storeInCache: z.boolean().optional(),
1922
- zeroDataRetention: z.boolean().optional(),
1923
- maxAge: z.number().optional(),
1924
- proxy: z.enum(["basic", "stealth", "enhanced", "auto"]).optional()
1925
- });
1926
- server.addTool({
1927
- name: "scorch_scrape",
1928
- annotations: {
1929
- title: "Scrape Web Page",
1930
- readOnlyHint: true,
1931
- destructiveHint: false,
1932
- idempotentHint: true,
1933
- openWorldHint: true
1934
- },
1935
- description: `
1936
- Scrape content from a single URL with advanced options.
1937
- This is the most powerful, fastest and most reliable scraper tool, if available you should always default to using this tool for any web scraping needs.
1938
-
1939
- **Best for:** Single page content extraction, when you know exactly which page contains the information.
1940
- **Not recommended for:** Multiple pages (call scrape multiple times or use crawl), unknown page location (use search).
1941
- **Common mistakes:** Using markdown format when extracting specific data points (use JSON instead).
1942
- **Other Features:** Use 'branding' format to extract brand identity (colors, fonts, typography, spacing, UI components) for design analysis or style replication.
1943
-
1944
- **CRITICAL - Format Selection (you MUST follow this):**
1945
- When the user asks for SPECIFIC data points, you MUST use JSON format with a schema. Only use markdown when the user needs the ENTIRE page content.
1946
-
1947
- **Use JSON format when user asks for:**
1948
- - Parameters, fields, or specifications (e.g., "get the header parameters", "what are the required fields")
1949
- - Prices, numbers, or structured data (e.g., "extract the pricing", "get the product details")
1950
- - API details, endpoints, or technical specs (e.g., "find the authentication endpoint")
1951
- - Lists of items or properties (e.g., "list the features", "get all the options")
1952
- - Any specific piece of information from a page
1953
-
1954
- **Use markdown format ONLY when:**
1955
- - User wants to read/summarize an entire article or blog post
1956
- - User needs to see all content on a page without specific extraction
1957
- - User explicitly asks for the full page content
1958
-
1959
- **Handling JavaScript-rendered pages (SPAs):**
1960
- If JSON extraction returns empty, minimal, or just navigation content, the page is likely JavaScript-rendered or the content is on a different URL. Try these steps IN ORDER:
1961
- 1. **Add waitFor parameter:** Set \`waitFor: 5000\` to \`waitFor: 10000\` to allow JavaScript to render before extraction
1962
- 2. **Try a different URL:** If the URL has a hash fragment (#section), try the base URL or look for a direct page URL
1963
- 3. **Use scorch_map to find the correct page:** Large documentation sites or SPAs often spread content across multiple URLs. Use \`scorch_map\` with a \`search\` parameter to discover the specific page containing your target content, then scrape that URL directly.
1964
- Example: If scraping "https://docs.example.com/reference" fails to find webhook parameters, use \`scorch_map\` with \`{"url": "https://docs.example.com/reference", "search": "webhook"}\` to find URLs like "/reference/webhook-events", then scrape that specific page.
1965
- 4. **Use scorch_agent:** As a last resort for heavily dynamic pages where map+scrape still fails, use the agent which can autonomously navigate and research
1966
-
1967
- **Usage Example (JSON format - REQUIRED for specific data extraction):**
1968
- \`\`\`json
1969
- {
1970
- "name": "scorch_scrape",
1971
- "arguments": {
1972
- "url": "https://example.com/api-docs",
1973
- "formats": [{
1974
- "type": "json",
1975
- "prompt": "Extract the header parameters for the authentication endpoint",
1976
- "schema": {
1977
- "type": "object",
1978
- "properties": {
1979
- "parameters": {
1980
- "type": "array",
1981
- "items": {
1982
- "type": "object",
1983
- "properties": {
1984
- "name": { "type": "string" },
1985
- "type": { "type": "string" },
1986
- "required": { "type": "boolean" },
1987
- "description": { "type": "string" }
1988
- }
1989
- }
1990
- }
1991
- }
1992
- }
1993
- }]
1994
- }
1995
- }
1996
- \`\`\`
1997
- **Usage Example (markdown format - ONLY when full content genuinely needed):**
1998
- \`\`\`json
1999
- {
2000
- "name": "scorch_scrape",
2001
- "arguments": {
2002
- "url": "https://example.com/article",
2003
- "formats": ["markdown"],
2004
- "onlyMainContent": true
2005
- }
2006
- }
2007
- \`\`\`
2008
- **Usage Example (branding format - extract brand identity):**
2009
- \`\`\`json
2010
- {
2011
- "name": "scorch_scrape",
2012
- "arguments": {
2013
- "url": "https://example.com",
2014
- "formats": ["branding"]
2015
- }
2016
- }
2017
- \`\`\`
2018
- **Branding format:** Extracts comprehensive brand identity (colors, fonts, typography, spacing, logo, UI components) for design analysis or style replication.
2019
- **Performance:** Add maxAge parameter for 500% faster scrapes using cached data.
2020
- **Returns:** JSON structured data, markdown, branding profile, or other formats as specified.
2021
- ${SAFE_MODE ? "**Safe Mode:** Read-only content extraction. Interactive actions (click, write, executeJavascript) are disabled for security." : ""}
2022
- `,
2023
- parameters: scrapeParamsSchema,
2024
- execute: async (args2, { session, log }) => {
2025
- const { url, ...options } = args2;
2026
- const cleaned = removeEmptyTopLevel(options);
2027
- return safeExecute(async () => {
2028
- const cacheKey = scrapeCacheKey(String(url), cleaned);
2029
- const cached = getCachedScrape(cacheKey);
2030
- if (cached) {
2031
- log.info("Returning cached scrape result", { url: String(url) });
2032
- return cached;
2033
- }
2034
- if (isLocalProxyEnabled()) {
2035
- log.info("Scraping URL via LOCAL PROXY (user IP)", { url: String(url) });
2036
- const localResult = await localScrape(String(url), cleaned);
2037
- if (!localResult.success && localResult.error === "FORMAT_NEEDS_SERVER") {
2038
- log.info("Format requires server-side processing, falling back to API", { url: String(url) });
2039
- const client2 = getClient(session);
2040
- const res2 = await client2.scrape(String(url), {
2041
- ...cleaned,
2042
- origin: ORIGIN
2043
- });
2044
- return await processResponse(res2, {
2045
- url: String(url),
2046
- getCopilotClientFn: getCopilotClient,
2047
- copilotToken: session?.copilotToken
2048
- });
2049
- }
2050
- if (!localResult.success && localResult.error === "SPA_SKELETON_DETECTED") {
2051
- const waitMs = cleaned.waitFor || 5e3;
2052
- log.info(
2053
- `SPA skeleton detected, retrying via engine with waitFor=${waitMs}ms`,
2054
- { url: String(url) }
2055
- );
2056
- const client2 = getClient(session);
2057
- const res2 = await client2.scrape(String(url), {
2058
- ...cleaned,
2059
- waitFor: waitMs,
2060
- origin: ORIGIN
2061
- });
2062
- return await processResponse(res2, {
2063
- url: String(url),
2064
- getCopilotClientFn: getCopilotClient,
2065
- copilotToken: session?.copilotToken
2066
- });
2067
- }
2068
- if (!localResult.success && localResult.error) {
2069
- const mapped = mapError(localResult.error);
2070
- return JSON.stringify({
2071
- success: false,
2072
- error: mapped.message,
2073
- code: mapped.code,
2074
- suggestions: mapped.suggestions
2075
- }, null, 2);
2076
- }
2077
- return await processResponse(localResult, {
2078
- url: String(url),
2079
- getCopilotClientFn: getCopilotClient,
2080
- copilotToken: session?.copilotToken
2081
- });
2082
- }
2083
- const client = getClient(session);
2084
- log.info("Scraping URL", { url: String(url) });
2085
- const res = await client.scrape(String(url), {
2086
- ...cleaned,
2087
- origin: ORIGIN
2088
- });
2089
- const quality = scoreResponseQuality(res);
2090
- if (quality.score < 30 && !cleaned.waitFor) {
2091
- log.info("Low quality response detected, retrying with waitFor=5000", {
2092
- url: String(url),
2093
- score: quality.score,
2094
- issues: quality.issues
2095
- });
2096
- const retryRes = await client.scrape(String(url), {
2097
- ...cleaned,
2098
- waitFor: 5e3,
2099
- origin: ORIGIN
2100
- });
2101
- const retryQuality = scoreResponseQuality(retryRes);
2102
- const bestRes = retryQuality.score >= quality.score ? retryRes : res;
2103
- const bestQuality = retryQuality.score >= quality.score ? retryQuality : quality;
2104
- const result2 = await processResponse(bestRes, {
2105
- url: String(url),
2106
- getCopilotClientFn: getCopilotClient,
2107
- copilotToken: session?.copilotToken
2108
- });
2109
- const parsed2 = JSON.parse(result2);
2110
- parsed2._quality = bestQuality;
2111
- const enriched2 = JSON.stringify(parsed2, null, 2);
2112
- setCachedScrape(cacheKey, enriched2);
2113
- return enriched2;
2114
- }
2115
- const result = await processResponse(res, {
2116
- url: String(url),
2117
- getCopilotClientFn: getCopilotClient,
2118
- copilotToken: session?.copilotToken
2119
- });
2120
- const parsed = JSON.parse(result);
2121
- parsed._quality = quality;
2122
- const enriched = JSON.stringify(parsed, null, 2);
2123
- setCachedScrape(cacheKey, enriched);
2124
- return enriched;
2125
- }, { tool: "scorch_scrape", url: String(url) });
2126
- }
2127
- });
2128
- server.addTool({
2129
- name: "scorch_batch_scrape",
2130
- annotations: {
2131
- title: "Batch Scrape URLs",
2132
- readOnlyHint: true,
2133
- destructiveHint: false,
2134
- idempotentHint: true,
2135
- openWorldHint: true
2136
- },
2137
- description: `
2138
- Scrape multiple URLs in a single request. Much faster than calling scorch_scrape multiple times.
2139
-
2140
- **Best for:** Extracting content from 2-50 known URLs in one batch.
2141
- **Not recommended for:** Single URL (use scorch_scrape); unknown URLs (use scorch_search or scorch_map first).
2142
-
2143
- **Usage Example:**
2144
- \`\`\`json
2145
- {
2146
- "name": "scorch_batch_scrape",
2147
- "arguments": {
2148
- "urls": ["https://example.com/page1", "https://example.com/page2"],
2149
- "formats": ["markdown"],
2150
- "onlyMainContent": true
2151
- }
2152
- }
2153
- \`\`\`
2154
- **Returns:** Results for each URL with individual success/failure status.
2155
- `,
2156
- parameters: z.object({
2157
- urls: z.array(z.string().url()).min(1).max(50),
2158
- formats: scrapeParamsSchema.shape.formats.optional(),
2159
- onlyMainContent: z.boolean().optional(),
2160
- includeTags: z.array(z.string()).optional(),
2161
- excludeTags: z.array(z.string()).optional(),
2162
- waitFor: z.number().optional(),
2163
- mobile: z.boolean().optional(),
2164
- skipTlsVerification: z.boolean().optional()
2165
- }),
2166
- execute: async (args2, { session, log }) => {
2167
- const { urls, ...options } = args2;
2168
- const cleaned = removeEmptyTopLevel(options);
2169
- return safeExecute(async () => {
2170
- const client = getClient(session);
2171
- log.info("Batch scraping URLs", { count: urls.length });
2172
- const res = await client.batchScrape(urls, {
2173
- ...cleaned,
2174
- origin: ORIGIN
2175
- });
2176
- return await processResponse(res, {
2177
- skipSummarization: true
2178
- });
2179
- }, { tool: "scorch_batch_scrape" });
2180
- }
2181
- });
2182
- server.addTool({
2183
- name: "scorch_map",
2184
- annotations: {
2185
- title: "Map Website URLs",
2186
- readOnlyHint: true,
2187
- destructiveHint: false,
2188
- idempotentHint: true,
2189
- openWorldHint: true
2190
- },
2191
- description: `
2192
- Map a website to discover all indexed URLs on the site.
2193
-
2194
- **Best for:** Discovering URLs on a website before deciding what to scrape; finding specific sections or pages within a large site; locating the correct page when scrape returns empty or incomplete results.
2195
- **Not recommended for:** When you already know which specific URL you need (use scrape); when you need the content of the pages (use scrape after mapping).
2196
- **Common mistakes:** Using crawl to discover URLs instead of map; jumping straight to scorch_agent when scrape fails instead of using map first to find the right page.
2197
-
2198
- **IMPORTANT - Use map before agent:** If \`scorch_scrape\` returns empty, minimal, or irrelevant content, use \`scorch_map\` with the \`search\` parameter to find the specific page URL containing your target content. This is faster and cheaper than using \`scorch_agent\`. Only use the agent as a last resort after map+scrape fails.
2199
-
2200
- **Prompt Example:** "Find the webhook documentation page on this API docs site."
2201
- **Usage Example (discover all URLs):**
2202
- \`\`\`json
2203
- {
2204
- "name": "scorch_map",
2205
- "arguments": {
2206
- "url": "https://example.com"
2207
- }
2208
- }
2209
- \`\`\`
2210
- **Usage Example (search for specific content - RECOMMENDED when scrape fails):**
2211
- \`\`\`json
2212
- {
2213
- "name": "scorch_map",
2214
- "arguments": {
2215
- "url": "https://docs.example.com/api",
2216
- "search": "webhook events"
2217
- }
2218
- }
2219
- \`\`\`
2220
- **Returns:** Array of URLs found on the site, filtered by search query if provided.
2221
- `,
2222
- parameters: z.object({
2223
- url: z.string().url(),
2224
- search: z.string().optional(),
2225
- sitemap: z.enum(["include", "skip", "only"]).optional(),
2226
- includeSubdomains: z.boolean().optional(),
2227
- limit: z.number().optional(),
2228
- ignoreQueryParameters: z.boolean().optional()
2229
- }),
2230
- execute: async (args2, { session, log }) => {
2231
- const { url, ...options } = args2;
2232
- return safeExecute(async () => {
2233
- const client = getClient(session);
2234
- const cleaned = removeEmptyTopLevel(options);
2235
- log.info("Mapping URL", { url: String(url) });
2236
- const res = await client.map(String(url), {
2237
- ...cleaned,
2238
- origin: ORIGIN
2239
- });
2240
- return asText(res);
2241
- }, { tool: "scorch_map", url: String(url) });
2242
- }
2243
- });
2244
- server.addTool({
2245
- name: "scorch_search",
2246
- annotations: {
2247
- title: "Web Search",
2248
- readOnlyHint: true,
2249
- destructiveHint: false,
2250
- idempotentHint: false,
2251
- openWorldHint: true
2252
- },
2253
- description: `
2254
- Search the web and optionally extract content from search results. This is the most powerful web search tool available, and if available you should always default to using this tool for any web search needs.
2255
-
2256
- The query also supports search operators, that you can use if needed to refine the search:
2257
- | Operator | Functionality | Examples |
2258
- ---|-|-|
2259
- | \`""\` | Non-fuzzy matches a string of text | \`"ScorchCrawl"\`
2260
- | \`-\` | Excludes certain keywords or negates other operators | \`-bad\`, \`-site:example.com\`
2261
- | \`site:\` | Only returns results from a specified website | \`site:example.com\`
2262
- | \`inurl:\` | Only returns results that include a word in the URL | \`inurl:example\`
2263
- | \`allinurl:\` | Only returns results that include multiple words in the URL | \`allinurl:git example\`
2264
- | \`intitle:\` | Only returns results that include a word in the title of the page | \`intitle:ScorchCrawl\`
2265
- | \`allintitle:\` | Only returns results that include multiple words in the title of the page | \`allintitle:example playground\`
2266
- | \`related:\` | Only returns results that are related to a specific domain | \`related:example.com\`
2267
- | \`imagesize:\` | Only returns images with exact dimensions | \`imagesize:1920x1080\`
2268
- | \`larger:\` | Only returns images larger than specified dimensions | \`larger:1920x1080\`
2269
-
2270
- **Best for:** Finding specific information across multiple websites, when you don't know which website has the information; when you need the most relevant content for a query.
2271
- **Not recommended for:** When you need to search the filesystem. When you already know which website to scrape (use scrape); when you need comprehensive coverage of a single website (use map or crawl.
2272
- **Common mistakes:** Using crawl or map for open-ended questions (use search instead).
2273
- **Prompt Example:** "Find the latest research papers on AI published in 2023."
2274
- **Sources:** web, images, news, default to web unless needed images or news.
2275
- **Scrape Options:** Only use scrapeOptions when you think it is absolutely necessary. When you do so default to a lower limit to avoid timeouts, 5 or lower.
2276
- **Optimal Workflow:** Search first using scorch_search without formats, then after fetching the results, use the scrape tool to get the content of the relevantpage(s) that you want to scrape
2277
-
2278
- **Usage Example without formats (Preferred):**
2279
- \`\`\`json
2280
- {
2281
- "name": "scorch_search",
2282
- "arguments": {
2283
- "query": "top AI companies",
2284
- "limit": 5,
2285
- "sources": [
2286
- { "type": "web" }
2287
- ]
2288
- }
2289
- }
2290
- \`\`\`
2291
- **Usage Example with formats:**
2292
- \`\`\`json
2293
- {
2294
- "name": "scorch_search",
2295
- "arguments": {
2296
- "query": "latest AI research papers 2023",
2297
- "limit": 5,
2298
- "lang": "en",
2299
- "country": "us",
2300
- "sources": [
2301
- { "type": "web" },
2302
- { "type": "images" },
2303
- { "type": "news" }
2304
- ],
2305
- "scrapeOptions": {
2306
- "formats": ["markdown"],
2307
- "onlyMainContent": true
2308
- }
2309
- }
2310
- }
2311
- \`\`\`
2312
- **Returns:** Array of search results (with optional scraped content).
2313
- `,
2314
- parameters: z.object({
2315
- query: z.string().min(1),
2316
- limit: z.number().optional(),
2317
- tbs: z.string().optional(),
2318
- filter: z.string().optional(),
2319
- location: z.string().optional(),
2320
- sources: z.array(z.object({ type: z.enum(["web", "images", "news"]) })).optional(),
2321
- scrapeOptions: scrapeParamsSchema.omit({ url: true }).partial().optional(),
2322
- enterprise: z.array(z.enum(["default", "anon", "zdr"])).optional()
2323
- }),
2324
- execute: async (args2, { session, log }) => {
2325
- const { query, ...opts } = args2;
2326
- return safeExecute(async () => {
2327
- const client = getClient(session);
2328
- const cleaned = removeEmptyTopLevel(opts);
2329
- log.info("Searching", { query: String(query) });
2330
- const res = await client.search(query, {
2331
- ...cleaned,
2332
- origin: ORIGIN
2333
- });
2334
- return await processResponse(res, {
2335
- skipSummarization: true
2336
- });
2337
- }, { tool: "scorch_search" });
2338
- }
2339
- });
2340
- server.addTool({
2341
- name: "scorch_crawl",
2342
- annotations: {
2343
- title: "Crawl Website",
2344
- readOnlyHint: true,
2345
- destructiveHint: false,
2346
- idempotentHint: false,
2347
- openWorldHint: true
2348
- },
2349
- description: `
2350
- Starts a crawl job on a website and extracts content from all pages.
2351
-
2352
- **Best for:** Extracting content from multiple related pages, when you need comprehensive coverage.
2353
- **Not recommended for:** Extracting content from a single page (use scrape); when token limits are a concern (use map + batch_scrape); when you need fast results (crawling can be slow).
2354
- **Warning:** Crawl responses can be very large and may exceed token limits. Limit the crawl depth and number of pages, or use map + batch_scrape for better control.
2355
- **Common mistakes:** Setting limit or maxDiscoveryDepth too high (causes token overflow) or too low (causes missing pages); using crawl for a single page (use scrape instead). Using a /* wildcard is not recommended.
2356
- **Prompt Example:** "Get all blog posts from the first two levels of example.com/blog."
2357
- **Usage Example:**
2358
- \`\`\`json
2359
- {
2360
- "name": "scorch_crawl",
2361
- "arguments": {
2362
- "url": "https://example.com/blog/*",
2363
- "maxDiscoveryDepth": 5,
2364
- "limit": 20,
2365
- "allowExternalLinks": false,
2366
- "deduplicateSimilarURLs": true,
2367
- "sitemap": "include"
2368
- }
2369
- }
2370
- \`\`\`
2371
- **Returns:** Operation ID for status checking; use scorch_check_crawl_status to check progress.
2372
- ${SAFE_MODE ? "**Safe Mode:** Read-only crawling. Webhooks and interactive actions are disabled for security." : ""}
2373
- `,
2374
- parameters: z.object({
2375
- url: z.string(),
2376
- prompt: z.string().optional(),
2377
- excludePaths: z.array(z.string()).optional(),
2378
- includePaths: z.array(z.string()).optional(),
2379
- maxDiscoveryDepth: z.number().optional(),
2380
- sitemap: z.enum(["skip", "include", "only"]).optional(),
2381
- limit: z.number().optional(),
2382
- allowExternalLinks: z.boolean().optional(),
2383
- allowSubdomains: z.boolean().optional(),
2384
- crawlEntireDomain: z.boolean().optional(),
2385
- delay: z.number().optional(),
2386
- maxConcurrency: z.number().optional(),
2387
- ...SAFE_MODE ? {} : {
2388
- webhook: z.union([
2389
- z.string(),
2390
- z.object({
2391
- url: z.string(),
2392
- headers: z.record(z.string(), z.string()).optional()
2393
- })
2394
- ]).optional()
2395
- },
2396
- deduplicateSimilarURLs: z.boolean().optional(),
2397
- ignoreQueryParameters: z.boolean().optional(),
2398
- scrapeOptions: scrapeParamsSchema.omit({ url: true }).partial().optional()
2399
- }),
2400
- execute: async (args2, { session, log }) => {
2401
- const { url, ...options } = args2;
2402
- return safeExecute(async () => {
2403
- const client = getClient(session);
2404
- const cleaned = removeEmptyTopLevel(options);
2405
- log.info("Starting crawl", { url: String(url) });
2406
- const res = await client.crawl(String(url), {
2407
- ...cleaned,
2408
- origin: ORIGIN
2409
- });
2410
- return await processResponse(res, {
2411
- url: String(url),
2412
- skipSummarization: true
2413
- });
2414
- }, { tool: "scorch_crawl", url: String(url) });
2415
- }
2416
- });
2417
- server.addTool({
2418
- name: "scorch_check_crawl_status",
2419
- annotations: {
2420
- title: "Check Crawl Status",
2421
- readOnlyHint: true,
2422
- destructiveHint: false,
2423
- idempotentHint: true,
2424
- openWorldHint: false
2425
- },
2426
- description: `
2427
- Check the status of a crawl job.
2428
-
2429
- **Usage Example:**
2430
- \`\`\`json
2431
- {
2432
- "name": "scorch_check_crawl_status",
2433
- "arguments": {
2434
- "id": "550e8400-e29b-41d4-a716-446655440000"
2435
- }
2436
- }
2437
- \`\`\`
2438
- **Returns:** Status and progress of the crawl job, including results if available.
2439
- `,
2440
- parameters: z.object({ id: z.string() }),
2441
- execute: async (args2, { session }) => {
2442
- return safeExecute(async () => {
2443
- const client = getClient(session);
2444
- const res = await client.getCrawlStatus(args2.id);
2445
- return asText(res);
2446
- }, { tool: "scorch_check_crawl_status" });
2447
- }
2448
- });
2449
- server.addTool({
2450
- name: "scorch_extract",
2451
- annotations: {
2452
- title: "Extract Structured Data",
2453
- readOnlyHint: true,
2454
- destructiveHint: false,
2455
- idempotentHint: true,
2456
- openWorldHint: true
2457
- },
2458
- description: `
2459
- Extract structured information from web pages using LLM capabilities. Supports both cloud AI and self-hosted LLM extraction.
2460
-
2461
- **Best for:** Extracting specific structured data like prices, names, details from web pages.
2462
- **Not recommended for:** When you need the full content of a page (use scrape); when you're not looking for specific structured data.
2463
- **Arguments:**
2464
- - urls: Array of URLs to extract information from
2465
- - prompt: Custom prompt for the LLM extraction
2466
- - schema: JSON schema for structured data extraction
2467
- - allowExternalLinks: Allow extraction from external links
2468
- - enableWebSearch: Enable web search for additional context
2469
- - includeSubdomains: Include subdomains in extraction
2470
- **Prompt Example:** "Extract the product name, price, and description from these product pages."
2471
- **Usage Example:**
2472
- \`\`\`json
2473
- {
2474
- "name": "scorch_extract",
2475
- "arguments": {
2476
- "urls": ["https://example.com/page1", "https://example.com/page2"],
2477
- "prompt": "Extract product information including name, price, and description",
2478
- "schema": {
2479
- "type": "object",
2480
- "properties": {
2481
- "name": { "type": "string" },
2482
- "price": { "type": "number" },
2483
- "description": { "type": "string" }
2484
- },
2485
- "required": ["name", "price"]
2486
- },
2487
- "allowExternalLinks": false,
2488
- "enableWebSearch": false,
2489
- "includeSubdomains": false
2490
- }
2491
- }
2492
- \`\`\`
2493
- **Returns:** Extracted structured data as defined by your schema.
2494
- `,
2495
- parameters: z.object({
2496
- urls: z.array(z.string()),
2497
- prompt: z.string().optional(),
2498
- schema: z.record(z.string(), z.any()).optional(),
2499
- allowExternalLinks: z.boolean().optional(),
2500
- enableWebSearch: z.boolean().optional(),
2501
- includeSubdomains: z.boolean().optional()
2502
- }),
2503
- execute: async (args2, { session, log }) => {
2504
- const a = args2;
2505
- return safeExecute(async () => {
2506
- const client = getClient(session);
2507
- log.info("Extracting from URLs", {
2508
- count: Array.isArray(a.urls) ? a.urls.length : 0
2509
- });
2510
- const extractBody = removeEmptyTopLevel({
2511
- urls: a.urls,
2512
- prompt: a.prompt,
2513
- schema: a.schema || void 0,
2514
- allowExternalLinks: a.allowExternalLinks,
2515
- enableWebSearch: a.enableWebSearch,
2516
- includeSubdomains: a.includeSubdomains,
2517
- origin: ORIGIN
2518
- });
2519
- const res = await client.extract(extractBody);
2520
- return await processResponse(res, { skipSummarization: true });
2521
- }, { tool: "scorch_extract" });
2522
- }
2523
- });
2524
- var agentConfig = buildAgentConfig();
2525
- var allowedModelsList = agentConfig.allowedModels.join(", ");
2526
- server.addTool({
2527
- name: "scorch_agent",
2528
- annotations: {
2529
- title: "AI Research Agent",
2530
- readOnlyHint: true,
2531
- destructiveHint: false,
2532
- idempotentHint: false,
2533
- openWorldHint: true
2534
- },
2535
- description: `
2536
- Autonomous web research agent powered by GitHub Copilot SDK. This is a separate AI agent layer that independently browses the internet, searches for information, navigates through pages, and extracts structured data based on your query. You describe what you need, and the agent figures out where to find it.
2537
-
2538
- **How it works:** The agent uses GitHub Copilot SDK to orchestrate web research using scorchcrawl tools (scrape, search, map, extract). It runs synchronously \u2014 the tool call blocks until the agent finishes and returns the complete result.
2539
-
2540
- **Available models:** ${allowedModelsList}
2541
-
2542
- **Expected wait times:**
2543
- - Simple queries with provided URLs: 30 seconds - 1 minute
2544
- - Complex research across multiple sites: 2-5 minutes
2545
- - Deep research tasks: 5+ minutes
2546
-
2547
- **Best for:** Complex research tasks where you don't know the exact URLs; multi-source data gathering; finding information scattered across the web; extracting data from JavaScript-heavy SPAs that fail with regular scrape.
2548
- **Not recommended for:** Simple single-page scraping where you know the URL (use scrape with JSON format instead - faster and cheaper).
2549
-
2550
- **Arguments:**
2551
- - prompt: Natural language description of the data you want (required, max 10,000 characters)
2552
- - urls: Optional array of URLs to focus the agent on specific pages
2553
- - schema: Optional JSON schema for structured output
2554
- - model: Optional model override (must be one of: ${allowedModelsList})
2555
- - timeout: Optional timeout in milliseconds (default: AGENT_TIMEOUT_MS env var or 60000). Increase for complex research tasks.
2556
-
2557
- **Prompt Example:** "Find the founders of ScorchCrawl and their backgrounds"
2558
- **Usage Example:**
2559
- \`\`\`json
2560
- {
2561
- "name": "scorch_agent",
2562
- "arguments": {
2563
- "prompt": "Find the top 5 AI startups founded in 2024 and their funding amounts",
2564
- "schema": {
2565
- "type": "object",
2566
- "properties": {
2567
- "startups": {
2568
- "type": "array",
2569
- "items": {
2570
- "type": "object",
2571
- "properties": {
2572
- "name": { "type": "string" },
2573
- "funding": { "type": "string" },
2574
- "founded": { "type": "string" }
2575
- }
2576
- }
2577
- }
2578
- }
2579
- }
2580
- }
2581
- }
2582
- \`\`\`
2583
- **Usage Example (with URLs - agent focuses on specific pages):**
2584
- \`\`\`json
2585
- {
2586
- "name": "scorch_agent",
2587
- "arguments": {
2588
- "urls": ["https://docs.example.com", "https://example.com/pricing"],
2589
- "prompt": "Compare the features and pricing information from these pages"
2590
- }
2591
- }
2592
- \`\`\`
2593
- **Returns:** The complete research result directly, including extracted data, model used, and duration.
2594
- `,
2595
- parameters: z.object({
2596
- prompt: z.string().min(1).max(1e4),
2597
- urls: z.array(z.string().url()).optional(),
2598
- schema: z.record(z.string(), z.any()).optional(),
2599
- model: z.string().optional(),
2600
- timeout: z.number().int().min(5e3).max(6e5).optional().describe("Timeout in milliseconds (5000-600000). Default: AGENT_TIMEOUT_MS env var or 60000.")
2601
- }),
2602
- execute: async (args2, { session, log }) => {
2603
- const client = getClient(session);
2604
- const a = args2;
2605
- const requestedModel = a.model;
2606
- if (requestedModel && !agentConfig.allowedModels.includes(requestedModel)) {
2607
- return asText({
2608
- success: false,
2609
- error: `Model "${requestedModel}" is not allowed. Available models: ${allowedModelsList}`
2610
- });
2611
- }
2612
- log.info("Starting Copilot SDK agent", {
2613
- prompt: a.prompt.substring(0, 100),
2614
- urlCount: Array.isArray(a.urls) ? a.urls.length : 0,
2615
- model: requestedModel || agentConfig.defaultModel
2616
- });
2617
- try {
2618
- const result = await startAgent(
2619
- {
2620
- prompt: a.prompt,
2621
- urls: a.urls,
2622
- schema: a.schema || void 0,
2623
- model: requestedModel,
2624
- timeout: a.timeout
2625
- },
2626
- client,
2627
- ORIGIN,
2628
- agentConfig,
2629
- session?.copilotToken
2630
- );
2631
- if (result.rateLimited) {
2632
- return asText({
2633
- success: false,
2634
- rateLimited: true,
2635
- error: result.error,
2636
- retryAfterSeconds: result.retryAfterSeconds,
2637
- hint: "Wait for the specified duration and retry."
2638
- });
2639
- }
2640
- return asText(result);
2641
- } catch (err) {
2642
- log.error("Failed to start agent", { error: err.message });
2643
- return asText({
2644
- success: false,
2645
- error: `Failed to start agent: ${err.message || err}`
2646
- });
2647
- }
2648
- }
2649
- });
2650
- server.addTool({
2651
- name: "scorch_agent_models",
2652
- annotations: {
2653
- title: "List Agent Models",
2654
- readOnlyHint: true,
2655
- destructiveHint: false,
2656
- idempotentHint: true,
2657
- openWorldHint: false
2658
- },
2659
- description: `
2660
- List the available models for the Copilot SDK agent. These models are configured via the COPILOT_AGENT_MODELS environment variable.
2661
-
2662
- **Returns:** List of allowed models and the current default model.
2663
- `,
2664
- parameters: z.object({}),
2665
- execute: async () => {
2666
- return asText({
2667
- allowedModels: parseAllowedModels(),
2668
- defaultModel: getDefaultModel()
2669
- });
2670
- }
2671
- });
2672
- server.addTool({
2673
- name: "scorch_screenshot",
2674
- annotations: {
2675
- title: "Screenshot Web Page",
2676
- readOnlyHint: true,
2677
- destructiveHint: false,
2678
- idempotentHint: true,
2679
- openWorldHint: true
2680
- },
2681
- description: `
2682
- Take a screenshot of a web page for visual inspection. Returns a base64-encoded PNG image.
2683
-
2684
- **Best for:** Debugging why scrape returns empty/blocked content; seeing cookie popups, CAPTCHAs, anti-bot challenges; inspecting JS-rendered pages; visual verification of page state.
2685
- **Not recommended for:** Extracting text content (use scorch_scrape instead).
2686
-
2687
- **When to use:**
2688
- - After scorch_scrape returns empty, minimal, or anti-bot content
2689
- - To verify that a page is rendering correctly
2690
- - To see cookie consent popups or CAPTCHA challenges
2691
- - To inspect visual-only content (charts, infographics)
2692
-
2693
- **Usage Example:**
2694
- \`\`\`json
2695
- {
2696
- "name": "scorch_screenshot",
2697
- "arguments": {
2698
- "url": "https://example.com",
2699
- "fullPage": false,
2700
- "waitFor": 3000
2701
- }
2702
- }
2703
- \`\`\`
2704
- **Returns:** Base64-encoded PNG screenshot with page metadata (title, status code).
2705
- `,
2706
- parameters: z.object({
2707
- url: z.string().url(),
2708
- fullPage: z.boolean().optional(),
2709
- waitFor: z.number().optional(),
2710
- quality: z.number().min(1).max(100).optional(),
2711
- viewport: z.object({
2712
- width: z.number(),
2713
- height: z.number()
2714
- }).optional()
2715
- }),
2716
- execute: async (args2, { session, log }) => {
2717
- const a = args2;
2718
- const url = String(a.url);
2719
- const fullPage = a.fullPage === true;
2720
- const waitFor = typeof a.waitFor === "number" ? a.waitFor : 3e3;
2721
- return safeExecute(async () => {
2722
- const client = getClient(session);
2723
- log.info("Taking screenshot", { url });
2724
- const screenshotFormat = {
2725
- type: "screenshot",
2726
- fullPage
2727
- };
2728
- if (a.quality) screenshotFormat.quality = a.quality;
2729
- if (a.viewport) screenshotFormat.viewport = a.viewport;
2730
- const res = await client.scrape(url, {
2731
- formats: [screenshotFormat],
2732
- waitFor,
2733
- origin: ORIGIN
2734
- });
2735
- const resAny = res;
2736
- const screenshot = resAny?.screenshot || resAny?.data?.screenshot;
2737
- const metadata = resAny?.metadata || resAny?.data?.metadata || {};
2738
- return JSON.stringify({
2739
- success: !!screenshot,
2740
- url,
2741
- metadata: {
2742
- title: metadata.title || null,
2743
- statusCode: metadata.statusCode || null,
2744
- description: metadata.description || null
2745
- },
2746
- screenshot: screenshot || null,
2747
- screenshotSizeKB: screenshot ? Math.round(screenshot.length / 1024) : 0
2748
- }, null, 2);
2749
- }, { tool: "scorch_screenshot", url });
2750
- }
2751
- });
2752
- process.on("SIGINT", async () => {
2753
- await shutdownAgent();
2754
- process.exit(0);
2755
- });
2756
- process.on("SIGTERM", async () => {
2757
- await shutdownAgent();
2758
- process.exit(0);
2759
- });
2760
- var PORT = Number(process.env.PORT || 3e3);
2761
- var HOST = process.env.CLOUD_SERVICE === "true" ? "0.0.0.0" : process.env.HOST || "localhost";
2762
- var args;
2763
- if (process.env.CLOUD_SERVICE === "true" || process.env.SSE_LOCAL === "true" || process.env.HTTP_STREAMABLE_SERVER === "true") {
2764
- args = {
2765
- transportType: "httpStream",
2766
- httpStream: {
2767
- port: PORT,
2768
- host: HOST,
2769
- stateless: true
2770
- }
2771
- };
2772
- } else {
2773
- args = {
2774
- transportType: "stdio"
2775
- };
2776
- }
2777
- await server.start(args);