scorchcrawl-mcp 1.2.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,643 @@
1
+ /**
2
+ * Response Processing Utilities
3
+ *
4
+ * Three layers of intelligent response handling for MCP tool results:
5
+ * 1. Error Mapping — classifies errors and returns actionable guidance
6
+ * 2. Content Truncation — smart paragraph-boundary truncation with notices
7
+ * 3. AI Summarization — Copilot SDK-powered content compression (optional)
8
+ *
9
+ * No competitor MCP scraper implements any of these. This is a ScorchCrawl
10
+ * differentiator.
11
+ */
12
+ import { createHash } from 'node:crypto';
13
+ // ---------------------------------------------------------------------------
14
+ // Configuration (from environment)
15
+ // ---------------------------------------------------------------------------
16
+ const MAX_CONTENT_CHARS = parseInt(process.env.SCORCHCRAWL_MAX_CONTENT_CHARS || '25000', 10);
17
+ const SUMMARIZE_AFTER_WORDS = parseInt(process.env.SCORCHCRAWL_SUMMARIZE_AFTER_WORDS || '5000', 10);
18
+ const SUMMARIZE_MODEL = process.env.SCORCHCRAWL_SUMMARIZE_MODEL || 'gpt-4o';
19
+ const SUMMARIZE_CACHE_SIZE = parseInt(process.env.SCORCHCRAWL_SUMMARIZE_CACHE_SIZE || '100', 10);
20
+ const SUMMARIZE_MAX_PER_MINUTE = parseInt(process.env.SCORCHCRAWL_SUMMARIZE_MAX_PER_MINUTE || '10', 10);
21
+ const SUMMARIZE_TIMEOUT_MS = parseInt(process.env.SCORCHCRAWL_SUMMARIZE_TIMEOUT_MS || '300000', 10);
22
+ /**
23
+ * Classify a raw error (from fetch, SDK, or engine) into a structured
24
+ * MappedError with actionable guidance for the LLM.
25
+ */
26
+ export function mapError(err) {
27
+ const raw = normalizeErrorString(err);
28
+ const lower = raw.toLowerCase();
29
+ // --- HTTP status-based patterns ---
30
+ if (matches(lower, ['403', 'forbidden', 'cloudflare', 'cf-ray', 'challenge', 'access denied'])) {
31
+ return {
32
+ code: 'ACCESS_DENIED',
33
+ message: 'This site blocks automated access.',
34
+ suggestions: [
35
+ 'Try scorch_search to find cached/indexed content instead',
36
+ 'Add waitFor: 5000 to let challenge pages resolve',
37
+ 'Try with proxy: "stealth" for enhanced bot evasion',
38
+ ],
39
+ originalError: raw,
40
+ };
41
+ }
42
+ if (matches(lower, ['404', 'not found', 'page not found', 'does not exist'])) {
43
+ return {
44
+ code: 'NOT_FOUND',
45
+ message: 'Page does not exist at this URL.',
46
+ suggestions: [
47
+ 'Use scorch_map to discover correct URLs on the site',
48
+ 'Check if the URL has a typo or outdated path',
49
+ 'Try scorch_search to find the current location of this content',
50
+ ],
51
+ originalError: raw,
52
+ };
53
+ }
54
+ if (matches(lower, ['429', 'rate limit', 'too many requests', 'throttl'])) {
55
+ return {
56
+ code: 'RATE_LIMITED',
57
+ message: 'Rate limited by the target site.',
58
+ suggestions: [
59
+ 'Wait 30 seconds and retry',
60
+ 'Try a different URL on the same site',
61
+ 'Use scorch_search to find the content from a different source',
62
+ ],
63
+ originalError: raw,
64
+ };
65
+ }
66
+ if (matchesStatusRange(lower, 500, 599)) {
67
+ return {
68
+ code: 'SERVER_ERROR',
69
+ message: 'The target site returned a server error.',
70
+ suggestions: [
71
+ 'Retry once — the site may be experiencing temporary issues',
72
+ 'Try again in a few minutes if it persists',
73
+ ],
74
+ originalError: raw,
75
+ };
76
+ }
77
+ // --- Network/connection errors ---
78
+ if (matches(lower, ['timeout', 'aborterror', 'aborted', 'timed out', 'etimedout'])) {
79
+ return {
80
+ code: 'TIMEOUT',
81
+ message: 'Page took too long to load.',
82
+ suggestions: [
83
+ 'Add onlyMainContent: true to reduce processing time',
84
+ 'Increase waitFor to give JS more time to render',
85
+ 'Try with a simpler format like markdown instead of screenshot',
86
+ ],
87
+ originalError: raw,
88
+ };
89
+ }
90
+ if (matches(lower, ['econnrefused', 'enotfound', 'dns', 'getaddrinfo', 'connect failed'])) {
91
+ // Distinguish between engine-down and remote-site-down
92
+ if (matches(lower, ['localhost', '127.0.0.1', '0.0.0.0', '3002', 'engine', 'api'])) {
93
+ return {
94
+ code: 'ENGINE_UNAVAILABLE',
95
+ message: 'Scraping engine is not running.',
96
+ suggestions: [
97
+ 'Check that Docker services are up: docker compose up -d',
98
+ 'Verify SCORCHCRAWL_API_URL is correct in your config',
99
+ ],
100
+ originalError: raw,
101
+ };
102
+ }
103
+ return {
104
+ code: 'CONNECTION_FAILED',
105
+ message: 'Cannot reach this URL.',
106
+ suggestions: [
107
+ 'Check if the URL is correct and accessible',
108
+ 'The site may be down — try again later',
109
+ 'Try scorch_search to find alternative sources',
110
+ ],
111
+ originalError: raw,
112
+ };
113
+ }
114
+ if (matches(lower, ['ssl', 'tls', 'certificate', 'cert', 'self-signed', 'unable to verify'])) {
115
+ return {
116
+ code: 'TLS_ERROR',
117
+ message: 'SSL certificate issue with this site.',
118
+ suggestions: [
119
+ 'Try with skipTlsVerification: true',
120
+ ],
121
+ originalError: raw,
122
+ };
123
+ }
124
+ if (matches(lower, ['empty', 'no content', 'no markdown', 'no extractable'])) {
125
+ return {
126
+ code: 'EMPTY_CONTENT',
127
+ message: 'Page returned no extractable content.',
128
+ suggestions: [
129
+ 'Use scorch_map with a search param to find the right page',
130
+ 'Add waitFor: 5000 for JavaScript-rendered pages',
131
+ 'Try with formats: ["html"] to see the raw page structure',
132
+ ],
133
+ originalError: raw,
134
+ };
135
+ }
136
+ if (matches(lower, ['spa_skeleton_detected', 'spa detected', 'javascript is required'])) {
137
+ return {
138
+ code: 'SPA_DETECTED',
139
+ message: 'Page requires JavaScript rendering (SPA detected).',
140
+ suggestions: [
141
+ 'Add waitFor: 5000 to allow JavaScript to render',
142
+ 'The engine will automatically retry with Playwright',
143
+ ],
144
+ originalError: raw,
145
+ };
146
+ }
147
+ // --- Catch-all ---
148
+ return {
149
+ code: 'UNKNOWN_ERROR',
150
+ message: `Scraping failed: ${truncateString(raw, 200)}`,
151
+ suggestions: [
152
+ 'Try a different URL or format',
153
+ 'Use scorch_search as an alternative data source',
154
+ ],
155
+ originalError: raw,
156
+ };
157
+ }
158
+ /** Normalize any error-like value to a string */
159
+ function normalizeErrorString(err) {
160
+ if (err == null)
161
+ return 'Unknown error';
162
+ if (err instanceof Error) {
163
+ // Include status code from Axios/fetch errors
164
+ const axiosStatus = err?.response?.status;
165
+ const status = err?.status || err?.statusCode || axiosStatus;
166
+ const prefix = status ? `HTTP ${status}: ` : '';
167
+ return `${prefix}${err.message}`;
168
+ }
169
+ if (typeof err === 'string')
170
+ return err;
171
+ try {
172
+ const s = JSON.stringify(err);
173
+ return s || 'Unknown error';
174
+ }
175
+ catch {
176
+ return String(err);
177
+ }
178
+ }
179
+ /** Check if a lowered string contains any of the patterns */
180
+ function matches(lower, patterns) {
181
+ return patterns.some((p) => lower.includes(p));
182
+ }
183
+ /** Check if an error string refers to an HTTP status in a range */
184
+ function matchesStatusRange(lower, from, to) {
185
+ const statusMatch = lower.match(/(?:status\s*(?:code\s*)?|http\s*)(\d{3})/);
186
+ if (statusMatch) {
187
+ const code = parseInt(statusMatch[1], 10);
188
+ return code >= from && code <= to;
189
+ }
190
+ // Also check for bare 5xx patterns
191
+ for (let s = from; s <= to; s++) {
192
+ if (lower.includes(String(s)))
193
+ return true;
194
+ }
195
+ return false;
196
+ }
197
+ /** Safely truncate a string for display */
198
+ function truncateString(s, maxLen) {
199
+ if (s.length <= maxLen)
200
+ return s;
201
+ return s.slice(0, maxLen - 3) + '...';
202
+ }
203
+ // ---------------------------------------------------------------------------
204
+ // Feature 2: Content Truncation
205
+ // ---------------------------------------------------------------------------
206
+ /**
207
+ * Content fields that may need truncation.
208
+ * Metadata, links, and structured data are NEVER truncated.
209
+ */
210
+ const CONTENT_FIELDS = ['markdown', 'html', 'rawHtml'];
211
+ /**
212
+ * For crawl results: max fraction of total limit a single page can use.
213
+ */
214
+ const SINGLE_PAGE_MAX_FRACTION = 0.3;
215
+ /**
216
+ * Truncate a markdown/html string at the nearest paragraph or heading
217
+ * boundary before the character limit.
218
+ */
219
+ export function truncateAtBoundary(content, maxChars) {
220
+ if (content.length <= maxChars) {
221
+ return { text: content, wasTruncated: false };
222
+ }
223
+ // Search backward from maxChars for a good break point
224
+ const searchWindow = content.slice(0, maxChars);
225
+ // Priority 1: Last heading boundary (# at start of line)
226
+ const headingMatch = searchWindow.lastIndexOf('\n#');
227
+ // Priority 2: Last double newline (paragraph boundary)
228
+ const paraMatch = searchWindow.lastIndexOf('\n\n');
229
+ // Priority 3: Last single newline
230
+ const lineMatch = searchWindow.lastIndexOf('\n');
231
+ // Priority 4: Last sentence end
232
+ const sentenceMatch = Math.max(searchWindow.lastIndexOf('. '), searchWindow.lastIndexOf('.\n'));
233
+ // Pick the best break point (prefer heading > paragraph > line > sentence)
234
+ let breakPoint = -1;
235
+ // Only use if it's in the last 30% of the window (don't cut too aggressively)
236
+ const minBreak = Math.floor(maxChars * 0.7);
237
+ if (headingMatch > minBreak)
238
+ breakPoint = headingMatch;
239
+ else if (paraMatch > minBreak)
240
+ breakPoint = paraMatch;
241
+ else if (lineMatch > minBreak)
242
+ breakPoint = lineMatch;
243
+ else if (sentenceMatch > minBreak)
244
+ breakPoint = sentenceMatch + 1; // include the period
245
+ else
246
+ breakPoint = maxChars; // hard cut as last resort
247
+ const truncated = content.slice(0, breakPoint).trimEnd();
248
+ return { text: truncated, wasTruncated: true };
249
+ }
250
+ /**
251
+ * Process a single scrape/crawl data object, truncating content fields
252
+ * while preserving metadata and structured data.
253
+ *
254
+ * Returns the processed data and truncation metadata.
255
+ */
256
+ export function truncateContent(data) {
257
+ if (MAX_CONTENT_CHARS <= 0) {
258
+ const serialized = JSON.stringify(data, null, 2);
259
+ return { result: data, wasTruncated: false, originalLength: serialized.length };
260
+ }
261
+ const serialized = JSON.stringify(data, null, 2);
262
+ if (serialized.length <= MAX_CONTENT_CHARS) {
263
+ return { result: data, wasTruncated: false, originalLength: serialized.length };
264
+ }
265
+ // Deep clone to avoid mutating the original
266
+ const cloned = JSON.parse(serialized);
267
+ let wasTruncated = false;
268
+ // Handle single scrape result (has data.markdown, data.html, etc.)
269
+ if (cloned && typeof cloned === 'object') {
270
+ const target = cloned.data || cloned;
271
+ for (const field of CONTENT_FIELDS) {
272
+ if (typeof target[field] === 'string' && target[field].length > 0) {
273
+ // Budget for this field: total limit minus overhead from other fields
274
+ const otherFieldsSize = serialized.length - target[field].length;
275
+ const fieldBudget = Math.max(MAX_CONTENT_CHARS - otherFieldsSize, Math.floor(MAX_CONTENT_CHARS * 0.5));
276
+ if (target[field].length > fieldBudget) {
277
+ const { text } = truncateAtBoundary(target[field], fieldBudget);
278
+ const originalLen = target[field].length;
279
+ target[field] = text + buildTruncationNotice(text.length, originalLen);
280
+ wasTruncated = true;
281
+ }
282
+ }
283
+ }
284
+ // Handle crawl results (array of pages)
285
+ if (Array.isArray(target)) {
286
+ const result = truncateCrawlArray(target);
287
+ return { result: cloned, wasTruncated: result.wasTruncated, originalLength: serialized.length };
288
+ }
289
+ // Add truncation metadata
290
+ if (wasTruncated) {
291
+ const meta = target.metadata || target;
292
+ meta._truncated = true;
293
+ meta._originalLength = serialized.length;
294
+ }
295
+ }
296
+ return { result: cloned, wasTruncated, originalLength: serialized.length };
297
+ }
298
+ /**
299
+ * Truncate a crawl result array: limit per-page content and total page count.
300
+ */
301
+ function truncateCrawlArray(pages) {
302
+ if (pages.length === 0)
303
+ return { wasTruncated: false };
304
+ const perPageLimit = Math.floor(MAX_CONTENT_CHARS * SINGLE_PAGE_MAX_FRACTION);
305
+ let wasTruncated = false;
306
+ let totalSize = 0;
307
+ for (let i = 0; i < pages.length; i++) {
308
+ const page = pages[i];
309
+ if (!page || typeof page !== 'object')
310
+ continue;
311
+ const target = page.data || page;
312
+ for (const field of CONTENT_FIELDS) {
313
+ if (typeof target[field] === 'string' && target[field].length > perPageLimit) {
314
+ const { text } = truncateAtBoundary(target[field], perPageLimit);
315
+ target[field] = text + buildTruncationNotice(text.length, target[field].length);
316
+ wasTruncated = true;
317
+ }
318
+ }
319
+ totalSize += JSON.stringify(page).length;
320
+ // If total exceeds limit, truncate the array
321
+ if (totalSize > MAX_CONTENT_CHARS && i < pages.length - 1) {
322
+ const originalCount = pages.length;
323
+ pages.length = i + 1;
324
+ pages.push({
325
+ _notice: `Showing ${i + 1} of ${originalCount} crawled pages. Use scorch_check_crawl_status with pagination to see more.`,
326
+ });
327
+ wasTruncated = true;
328
+ break;
329
+ }
330
+ }
331
+ return { wasTruncated };
332
+ }
333
+ /** Build a truncation notice for appending to content */
334
+ function buildTruncationNotice(shownChars, originalChars) {
335
+ return `\n\n---\n[Content truncated. Showing ~${Math.round(shownChars / 1000)}k of ~${Math.round(originalChars / 1000)}k characters.\nTo get specific information, try:\n- Use JSON format with a schema to extract only the data you need\n- Add onlyMainContent: true to exclude navigation/footers\n- Use scorch_map to find a more specific page URL]`;
336
+ }
337
+ // ---------------------------------------------------------------------------
338
+ // Feature 3: AI Summarization
339
+ // ---------------------------------------------------------------------------
340
+ /**
341
+ * LRU cache for summarization results.
342
+ * Key: sha256(url + first 1000 chars of markdown)
343
+ * Value: { summary, timestamp }
344
+ */
345
+ const summaryCache = new Map();
346
+ const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
347
+ /** Sliding window for summarization rate limiting */
348
+ const summarizationTimestamps = [];
349
+ /** Word count helper */
350
+ export function wordCount(text) {
351
+ return text.split(/\s+/).filter(Boolean).length;
352
+ }
353
+ /** Generate a cache key for summarization */
354
+ function summaryKey(url, markdown) {
355
+ const fingerprint = url + markdown.slice(0, 1000);
356
+ return createHash('sha256').update(fingerprint).digest('hex');
357
+ }
358
+ /** Check if we can make another summarization call (rate limit) */
359
+ function canSummarize() {
360
+ if (SUMMARIZE_MAX_PER_MINUTE <= 0)
361
+ return false;
362
+ const now = Date.now();
363
+ const windowStart = now - 60_000;
364
+ // Purge old entries
365
+ while (summarizationTimestamps.length > 0 && summarizationTimestamps[0] < windowStart) {
366
+ summarizationTimestamps.shift();
367
+ }
368
+ return summarizationTimestamps.length < SUMMARIZE_MAX_PER_MINUTE;
369
+ }
370
+ /** Record a summarization call for rate limiting */
371
+ function recordSummarization() {
372
+ summarizationTimestamps.push(Date.now());
373
+ }
374
+ /** Evict oldest entries if cache exceeds max size */
375
+ function evictCache() {
376
+ if (summaryCache.size <= SUMMARIZE_CACHE_SIZE)
377
+ return;
378
+ // Find and delete the oldest entries
379
+ const entries = [...summaryCache.entries()].sort((a, b) => a[1].timestamp - b[1].timestamp);
380
+ const toDelete = entries.slice(0, entries.length - SUMMARIZE_CACHE_SIZE);
381
+ for (const [key] of toDelete) {
382
+ summaryCache.delete(key);
383
+ }
384
+ }
385
+ /**
386
+ * Summarize markdown content using the Copilot SDK if available.
387
+ * Falls back to truncation on failure or when no LLM is available.
388
+ *
389
+ * @param markdown - The full markdown content to potentially summarize
390
+ * @param url - Source URL (used for cache key)
391
+ * @param getCopilotClientFn - Function to get a CopilotClient instance
392
+ * @param copilotToken - Optional per-user Copilot token
393
+ * @returns Processed markdown with summarization metadata
394
+ */
395
+ export async function summarizeIfNeeded(markdown, url, getCopilotClientFn, copilotToken) {
396
+ // Check if summarization is disabled
397
+ if (SUMMARIZE_AFTER_WORDS <= 0) {
398
+ return { markdown, summarized: false };
399
+ }
400
+ const words = wordCount(markdown);
401
+ if (words <= SUMMARIZE_AFTER_WORDS) {
402
+ return { markdown, summarized: false };
403
+ }
404
+ // Check cache
405
+ const key = summaryKey(url, markdown);
406
+ const cached = summaryCache.get(key);
407
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
408
+ return {
409
+ markdown: cached.summary,
410
+ summarized: true,
411
+ meta: {
412
+ _summarized: true,
413
+ _originalWordCount: words,
414
+ _summaryWordCount: cached.wordCount,
415
+ _summarizedBy: SUMMARIZE_MODEL,
416
+ _cachedResult: true,
417
+ },
418
+ };
419
+ }
420
+ // Check if Copilot client is available
421
+ if (!getCopilotClientFn) {
422
+ return { markdown, summarized: false };
423
+ }
424
+ // Check rate limit
425
+ if (!canSummarize()) {
426
+ return { markdown, summarized: false };
427
+ }
428
+ try {
429
+ const client = await getCopilotClientFn(copilotToken);
430
+ if (!client) {
431
+ return { markdown, summarized: false };
432
+ }
433
+ const targetWords = Math.min(SUMMARIZE_AFTER_WORDS, Math.floor(words * 0.3));
434
+ const sessionConfig = {
435
+ model: SUMMARIZE_MODEL,
436
+ systemMessage: {
437
+ mode: 'replace',
438
+ content: buildSummarizationPrompt(words, targetWords),
439
+ },
440
+ };
441
+ const session = await client.createSession(sessionConfig);
442
+ recordSummarization();
443
+ try {
444
+ const response = await session.sendAndWait({ prompt: markdown }, SUMMARIZE_TIMEOUT_MS);
445
+ // Extract content from response — handle multiple possible shapes
446
+ let summary = '';
447
+ if (response && typeof response === 'object') {
448
+ const r = response;
449
+ // Shape 1: { data: { content: "..." } } (most common)
450
+ if (r.data && typeof r.data === 'object') {
451
+ const d = r.data;
452
+ if (typeof d.content === 'string')
453
+ summary = d.content;
454
+ else if (typeof d.text === 'string')
455
+ summary = d.text;
456
+ }
457
+ // Shape 2: { content: "..." }
458
+ if (!summary && typeof r.content === 'string')
459
+ summary = r.content;
460
+ // Shape 3: { text: "..." }
461
+ if (!summary && typeof r.text === 'string')
462
+ summary = r.text;
463
+ // Shape 4: { message: { content: "..." } }
464
+ if (!summary && r.message && typeof r.message === 'object') {
465
+ const m = r.message;
466
+ if (typeof m.content === 'string')
467
+ summary = m.content;
468
+ }
469
+ }
470
+ else if (typeof response === 'string') {
471
+ summary = response;
472
+ }
473
+ if (!summary || summary.length < 50) {
474
+ // Summarization returned garbage — fall back
475
+ console.error(`[Summarization] Response too short or empty (${summary.length} chars), response keys: ${response ? Object.keys(response).join(', ') : 'null'}`);
476
+ return { markdown, summarized: false };
477
+ }
478
+ const summaryWords = wordCount(summary);
479
+ // Cache the result
480
+ summaryCache.set(key, {
481
+ summary,
482
+ timestamp: Date.now(),
483
+ wordCount: summaryWords,
484
+ });
485
+ evictCache();
486
+ return {
487
+ markdown: summary,
488
+ summarized: true,
489
+ meta: {
490
+ _summarized: true,
491
+ _originalWordCount: words,
492
+ _summaryWordCount: summaryWords,
493
+ _summarizedBy: SUMMARIZE_MODEL,
494
+ },
495
+ };
496
+ }
497
+ finally {
498
+ try {
499
+ await session.destroy();
500
+ }
501
+ catch { /* ignore cleanup */ }
502
+ }
503
+ }
504
+ catch (err) {
505
+ // Summarization failed — fall back silently but log for debugging
506
+ const errMsg = err instanceof Error ? err.message : String(err);
507
+ const errStack = err instanceof Error ? err.stack : '';
508
+ console.error(`[Summarization] Failed for ${url}: ${errMsg}`);
509
+ if (errStack)
510
+ console.error(`[Summarization] Stack: ${errStack}`);
511
+ return {
512
+ markdown,
513
+ summarized: false,
514
+ meta: { _summarizationFailed: true, _summarizationError: errMsg },
515
+ };
516
+ }
517
+ }
518
+ /** Build the system prompt for content summarization */
519
+ function buildSummarizationPrompt(originalWords, targetWords) {
520
+ return `You are a content summarizer for a web scraping tool.
521
+ Summarize the following web page content, preserving:
522
+ - ALL factual data (numbers, dates, names, prices, specifications)
523
+ - Key arguments, conclusions, and findings
524
+ - Structural organization (use headings, lists)
525
+ - Any code snippets or technical details
526
+
527
+ Do NOT:
528
+ - Add opinions or analysis
529
+ - Omit specific data points (numbers, names, URLs)
530
+ - Change the meaning or emphasis of the content
531
+ - Add any preamble like "Here is a summary" — just output the summary directly
532
+
533
+ Original content word count: ${originalWords}
534
+ Target summary: ~${targetWords} words`;
535
+ }
536
+ // ---------------------------------------------------------------------------
537
+ // Combined Processing Pipeline
538
+ // ---------------------------------------------------------------------------
539
+ /**
540
+ * Safe wrapper for tool execution. Catches errors and returns
541
+ * mapped, LLM-friendly error responses.
542
+ */
543
+ export async function safeExecute(fn, context) {
544
+ try {
545
+ return await fn();
546
+ }
547
+ catch (err) {
548
+ const mapped = mapError(err);
549
+ console.error(`[${context.tool}] ${mapped.code}`, {
550
+ url: context.url,
551
+ original: mapped.originalError,
552
+ });
553
+ return JSON.stringify({
554
+ success: false,
555
+ error: mapped.message,
556
+ code: mapped.code,
557
+ suggestions: mapped.suggestions,
558
+ }, null, 2);
559
+ }
560
+ }
561
+ /**
562
+ * Process a successful response: truncate content if needed and
563
+ * optionally summarize via Copilot SDK.
564
+ *
565
+ * Replaces the old `asText()` function.
566
+ */
567
+ export async function processResponse(data, options) {
568
+ // Step 1: Try AI summarization on markdown content
569
+ if (!options?.skipSummarization && options?.getCopilotClientFn) {
570
+ const md = extractMarkdown(data);
571
+ const wc = md ? wordCount(md) : 0;
572
+ console.error(`[Summarization] Check: markdown=${md ? md.length : 0} chars, ${wc} words, threshold=${SUMMARIZE_AFTER_WORDS}`);
573
+ if (md && wc > SUMMARIZE_AFTER_WORDS && SUMMARIZE_AFTER_WORDS > 0) {
574
+ console.error(`[Summarization] Attempting summarization for ${options.url || 'unknown url'} (${wc} words)`);
575
+ const result = await summarizeIfNeeded(md, options.url || '', options.getCopilotClientFn, options.copilotToken);
576
+ if (result.summarized) {
577
+ // Replace the markdown in the data with the summary
578
+ const updated = injectMarkdown(data, result.markdown, result.meta || {});
579
+ return JSON.stringify(updated, null, 2);
580
+ }
581
+ // Summarization was attempted but failed — inject failure metadata and fall through to truncation
582
+ if (result.meta) {
583
+ console.error(`[Summarization] Failed, falling back to truncation. Meta:`, JSON.stringify(result.meta));
584
+ }
585
+ }
586
+ }
587
+ else {
588
+ console.error(`[Summarization] Skipped: skipSummarization=${options?.skipSummarization}, hasCopilotFn=${!!options?.getCopilotClientFn}`);
589
+ }
590
+ // Step 2: Content truncation
591
+ const { result, wasTruncated } = truncateContent(data);
592
+ return JSON.stringify(result, null, 2);
593
+ }
594
+ /**
595
+ * Simple `asText` replacement for backward compatibility.
596
+ * Applies truncation but not summarization.
597
+ */
598
+ export function processResponseSync(data) {
599
+ const { result } = truncateContent(data);
600
+ return JSON.stringify(result, null, 2);
601
+ }
602
+ // ---------------------------------------------------------------------------
603
+ // Helpers for extracting/injecting markdown from nested response objects
604
+ // ---------------------------------------------------------------------------
605
+ /** Extract markdown content from a scrape response, wherever it may be nested */
606
+ function extractMarkdown(data) {
607
+ if (!data || typeof data !== 'object')
608
+ return null;
609
+ const obj = data;
610
+ // Direct: data.markdown
611
+ if (typeof obj.markdown === 'string')
612
+ return obj.markdown;
613
+ // Nested: data.data.markdown
614
+ if (obj.data && typeof obj.data === 'object') {
615
+ const inner = obj.data;
616
+ if (typeof inner.markdown === 'string')
617
+ return inner.markdown;
618
+ }
619
+ return null;
620
+ }
621
+ /** Replace the markdown field in a response and merge metadata */
622
+ function injectMarkdown(data, newMarkdown, meta) {
623
+ const serialized = JSON.stringify(data);
624
+ const cloned = JSON.parse(serialized);
625
+ if (typeof cloned.markdown === 'string') {
626
+ cloned.markdown = newMarkdown;
627
+ Object.assign(cloned.metadata || cloned, meta);
628
+ return cloned;
629
+ }
630
+ if (cloned.data && typeof cloned.data === 'object') {
631
+ if (typeof cloned.data.markdown === 'string') {
632
+ cloned.data.markdown = newMarkdown;
633
+ Object.assign(cloned.data.metadata || cloned.data, meta);
634
+ return cloned;
635
+ }
636
+ }
637
+ return cloned;
638
+ }
639
+ // ---------------------------------------------------------------------------
640
+ // Exports for testing
641
+ // ---------------------------------------------------------------------------
642
+ export { MAX_CONTENT_CHARS, SUMMARIZE_AFTER_WORDS, SUMMARIZE_MODEL };
643
+ export { summaryCache, summarizationTimestamps };
package/package.json CHANGED
@@ -1,20 +1,19 @@
1
1
  {
2
2
  "name": "scorchcrawl-mcp",
3
- "version": "1.2.1",
3
+ "version": "2.0.0",
4
4
  "description": "Open-source Copilot SDK-compliant MCP server for web scraping with stealth bot-detection bypass.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "scorchcrawl-mcp": "dist/index.js"
8
8
  },
9
9
  "files": [
10
- "dist/index.js"
10
+ "dist"
11
11
  ],
12
12
  "publishConfig": {
13
13
  "access": "public"
14
14
  },
15
15
  "scripts": {
16
- "build": "tsc --noEmit && esbuild src/index.ts --bundle --platform=node --target=node18 --format=esm --outfile=dist/index.js --external:effect --external:@valibot/to-json-schema --external:sury --minify --keep-names --banner:js='#!/usr/bin/env node\nimport{createRequire as __cr}from\"module\";const require=__cr(import.meta.url);' && chmod 755 dist/index.js",
17
- "build:tsc": "tsc",
16
+ "build": "tsc && node -e \"require('fs').chmodSync('dist/index.js', '755')\"",
18
17
  "start": "node dist/index.js",
19
18
  "start:server": "HTTP_STREAMABLE_SERVER=true node dist/index.js",
20
19
  "dev": "tsc --watch",
@@ -63,7 +62,6 @@
63
62
  "@types/node": "^22.0.0",
64
63
  "@types/turndown": "^5.0.5",
65
64
  "@types/uuid": "^10.0.0",
66
- "esbuild": "^0.25.0",
67
65
  "vitest": "^3.2.1"
68
66
  }
69
67
  }