scorchcrawl-mcp 1.0.2 → 1.2.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,789 @@
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
+ // --- CAPTCHA (checked before ACCESS_DENIED since 'challenge' overlaps) ---
30
+ if (matches(lower, ['captcha', 'recaptcha', 'hcaptcha', 'turnstile'])) {
31
+ return {
32
+ code: 'CAPTCHA_REQUIRED',
33
+ message: 'Page requires CAPTCHA verification.',
34
+ suggestions: [
35
+ 'Try proxy: "stealth" to avoid CAPTCHA triggers',
36
+ 'Use scorch_agent which may navigate around CAPTCHAs',
37
+ 'Try scorch_search to find the content from a cached source',
38
+ ],
39
+ originalError: raw,
40
+ };
41
+ }
42
+ // --- HTTP status-based patterns ---
43
+ if (matches(lower, ['403', 'forbidden', 'cloudflare', 'cf-ray', 'challenge', 'access denied'])) {
44
+ return {
45
+ code: 'ACCESS_DENIED',
46
+ message: 'This site blocks automated access.',
47
+ suggestions: [
48
+ 'Try scorch_search to find cached/indexed content instead',
49
+ 'Add waitFor: 5000 to let challenge pages resolve',
50
+ 'Try with proxy: "stealth" for enhanced bot evasion',
51
+ ],
52
+ originalError: raw,
53
+ };
54
+ }
55
+ if (matches(lower, ['404', 'not found', 'page not found', 'does not exist'])) {
56
+ return {
57
+ code: 'NOT_FOUND',
58
+ message: 'Page does not exist at this URL.',
59
+ suggestions: [
60
+ 'Use scorch_map to discover correct URLs on the site',
61
+ 'Check if the URL has a typo or outdated path',
62
+ 'Try scorch_search to find the current location of this content',
63
+ ],
64
+ originalError: raw,
65
+ };
66
+ }
67
+ if (matches(lower, ['429', 'rate limit', 'too many requests', 'throttl'])) {
68
+ return {
69
+ code: 'RATE_LIMITED',
70
+ message: 'Rate limited by the target site.',
71
+ suggestions: [
72
+ 'Wait 30 seconds and retry',
73
+ 'Try a different URL on the same site',
74
+ 'Use scorch_search to find the content from a different source',
75
+ ],
76
+ originalError: raw,
77
+ };
78
+ }
79
+ if (matchesStatusRange(lower, 500, 599)) {
80
+ return {
81
+ code: 'SERVER_ERROR',
82
+ message: 'The target site returned a server error.',
83
+ suggestions: [
84
+ 'Retry once — the site may be experiencing temporary issues',
85
+ 'Try again in a few minutes if it persists',
86
+ ],
87
+ originalError: raw,
88
+ };
89
+ }
90
+ // --- Network/connection errors ---
91
+ if (matches(lower, ['timeout', 'aborterror', 'aborted', 'timed out', 'etimedout'])) {
92
+ return {
93
+ code: 'TIMEOUT',
94
+ message: 'Page took too long to load.',
95
+ suggestions: [
96
+ 'Add onlyMainContent: true to reduce processing time',
97
+ 'Increase waitFor to give JS more time to render',
98
+ 'Try with a simpler format like markdown instead of screenshot',
99
+ ],
100
+ originalError: raw,
101
+ };
102
+ }
103
+ if (matches(lower, ['econnrefused', 'enotfound', 'dns', 'getaddrinfo', 'connect failed'])) {
104
+ // Distinguish between engine-down and remote-site-down
105
+ if (matches(lower, ['localhost', '127.0.0.1', '0.0.0.0', '3002', 'engine', 'api'])) {
106
+ return {
107
+ code: 'ENGINE_UNAVAILABLE',
108
+ message: 'Scraping engine is not running.',
109
+ suggestions: [
110
+ 'Check that Docker services are up: docker compose up -d',
111
+ 'Verify SCORCHCRAWL_API_URL is correct in your config',
112
+ ],
113
+ originalError: raw,
114
+ };
115
+ }
116
+ return {
117
+ code: 'CONNECTION_FAILED',
118
+ message: 'Cannot reach this URL.',
119
+ suggestions: [
120
+ 'Check if the URL is correct and accessible',
121
+ 'The site may be down — try again later',
122
+ 'Try scorch_search to find alternative sources',
123
+ ],
124
+ originalError: raw,
125
+ };
126
+ }
127
+ if (matches(lower, ['ssl', 'tls', 'certificate', 'cert', 'self-signed', 'unable to verify'])) {
128
+ return {
129
+ code: 'TLS_ERROR',
130
+ message: 'SSL certificate issue with this site.',
131
+ suggestions: [
132
+ 'Try with skipTlsVerification: true',
133
+ ],
134
+ originalError: raw,
135
+ };
136
+ }
137
+ if (matches(lower, ['empty', 'no content', 'no markdown', 'no extractable'])) {
138
+ return {
139
+ code: 'EMPTY_CONTENT',
140
+ message: 'Page returned no extractable content.',
141
+ suggestions: [
142
+ 'Use scorch_map with a search param to find the right page',
143
+ 'Add waitFor: 5000 for JavaScript-rendered pages',
144
+ 'Try with formats: ["html"] to see the raw page structure',
145
+ ],
146
+ originalError: raw,
147
+ };
148
+ }
149
+ if (matches(lower, ['spa_skeleton_detected', 'spa detected', 'javascript is required'])) {
150
+ return {
151
+ code: 'SPA_DETECTED',
152
+ message: 'Page requires JavaScript rendering (SPA detected).',
153
+ suggestions: [
154
+ 'Add waitFor: 5000 to allow JavaScript to render',
155
+ 'The engine will automatically retry with Playwright',
156
+ ],
157
+ originalError: raw,
158
+ };
159
+ }
160
+ if (matches(lower, ['geo', 'geoblocked', 'not available in your', 'region', 'country restriction'])) {
161
+ return {
162
+ code: 'GEO_BLOCKED',
163
+ message: 'Content is geo-restricted.',
164
+ suggestions: [
165
+ 'Use location parameter with a different country: location: { country: "US" }',
166
+ 'Try proxy: "stealth" which may route through a different region',
167
+ ],
168
+ originalError: raw,
169
+ };
170
+ }
171
+ if (matches(lower, ['paywall', 'subscribe', 'premium content', 'members only'])) {
172
+ return {
173
+ code: 'PAYWALL',
174
+ message: 'Content is behind a paywall.',
175
+ suggestions: [
176
+ 'Try scorch_search to find freely available alternatives',
177
+ 'Use scorch_map to find non-paywalled pages on the same site',
178
+ ],
179
+ originalError: raw,
180
+ };
181
+ }
182
+ // --- Catch-all ---
183
+ return {
184
+ code: 'UNKNOWN_ERROR',
185
+ message: `Scraping failed: ${truncateString(raw, 200)}`,
186
+ suggestions: [
187
+ 'Try a different URL or format',
188
+ 'Use scorch_search as an alternative data source',
189
+ ],
190
+ originalError: raw,
191
+ };
192
+ }
193
+ /** Normalize any error-like value to a string */
194
+ function normalizeErrorString(err) {
195
+ if (err == null)
196
+ return 'Unknown error';
197
+ if (err instanceof Error) {
198
+ // Include status code from Axios/fetch errors
199
+ const axiosStatus = err?.response?.status;
200
+ const status = err?.status || err?.statusCode || axiosStatus;
201
+ const prefix = status ? `HTTP ${status}: ` : '';
202
+ return `${prefix}${err.message}`;
203
+ }
204
+ if (typeof err === 'string')
205
+ return err;
206
+ try {
207
+ const s = JSON.stringify(err);
208
+ return s || 'Unknown error';
209
+ }
210
+ catch {
211
+ return String(err);
212
+ }
213
+ }
214
+ /** Check if a lowered string contains any of the patterns */
215
+ function matches(lower, patterns) {
216
+ return patterns.some((p) => lower.includes(p));
217
+ }
218
+ /** Check if an error string refers to an HTTP status in a range */
219
+ function matchesStatusRange(lower, from, to) {
220
+ const statusMatch = lower.match(/(?:status\s*(?:code\s*)?|http\s*)(\d{3})/);
221
+ if (statusMatch) {
222
+ const code = parseInt(statusMatch[1], 10);
223
+ return code >= from && code <= to;
224
+ }
225
+ // Also check for bare 5xx patterns
226
+ for (let s = from; s <= to; s++) {
227
+ if (lower.includes(String(s)))
228
+ return true;
229
+ }
230
+ return false;
231
+ }
232
+ /** Safely truncate a string for display */
233
+ function truncateString(s, maxLen) {
234
+ if (s.length <= maxLen)
235
+ return s;
236
+ return s.slice(0, maxLen - 3) + '...';
237
+ }
238
+ // ---------------------------------------------------------------------------
239
+ // Feature 2: Content Truncation
240
+ // ---------------------------------------------------------------------------
241
+ /**
242
+ * Content fields that may need truncation.
243
+ * Metadata, links, and structured data are NEVER truncated.
244
+ */
245
+ const CONTENT_FIELDS = ['markdown', 'html', 'rawHtml'];
246
+ /**
247
+ * For crawl results: max fraction of total limit a single page can use.
248
+ */
249
+ const SINGLE_PAGE_MAX_FRACTION = 0.3;
250
+ /**
251
+ * Truncate a markdown/html string at the nearest paragraph or heading
252
+ * boundary before the character limit.
253
+ */
254
+ export function truncateAtBoundary(content, maxChars) {
255
+ if (content.length <= maxChars) {
256
+ return { text: content, wasTruncated: false };
257
+ }
258
+ // Search backward from maxChars for a good break point
259
+ const searchWindow = content.slice(0, maxChars);
260
+ // Priority 1: Last heading boundary (# at start of line)
261
+ const headingMatch = searchWindow.lastIndexOf('\n#');
262
+ // Priority 2: Last double newline (paragraph boundary)
263
+ const paraMatch = searchWindow.lastIndexOf('\n\n');
264
+ // Priority 3: Last single newline
265
+ const lineMatch = searchWindow.lastIndexOf('\n');
266
+ // Priority 4: Last sentence end
267
+ const sentenceMatch = Math.max(searchWindow.lastIndexOf('. '), searchWindow.lastIndexOf('.\n'));
268
+ // Pick the best break point (prefer heading > paragraph > line > sentence)
269
+ let breakPoint = -1;
270
+ // Only use if it's in the last 30% of the window (don't cut too aggressively)
271
+ const minBreak = Math.floor(maxChars * 0.7);
272
+ if (headingMatch > minBreak)
273
+ breakPoint = headingMatch;
274
+ else if (paraMatch > minBreak)
275
+ breakPoint = paraMatch;
276
+ else if (lineMatch > minBreak)
277
+ breakPoint = lineMatch;
278
+ else if (sentenceMatch > minBreak)
279
+ breakPoint = sentenceMatch + 1; // include the period
280
+ else
281
+ breakPoint = maxChars; // hard cut as last resort
282
+ const truncated = content.slice(0, breakPoint).trimEnd();
283
+ return { text: truncated, wasTruncated: true };
284
+ }
285
+ /**
286
+ * Process a single scrape/crawl data object, truncating content fields
287
+ * while preserving metadata and structured data.
288
+ *
289
+ * Returns the processed data and truncation metadata.
290
+ */
291
+ export function truncateContent(data) {
292
+ if (MAX_CONTENT_CHARS <= 0) {
293
+ const serialized = JSON.stringify(data, null, 2);
294
+ return { result: data, wasTruncated: false, originalLength: serialized.length };
295
+ }
296
+ const serialized = JSON.stringify(data, null, 2);
297
+ if (serialized.length <= MAX_CONTENT_CHARS) {
298
+ return { result: data, wasTruncated: false, originalLength: serialized.length };
299
+ }
300
+ // Deep clone to avoid mutating the original
301
+ const cloned = JSON.parse(serialized);
302
+ let wasTruncated = false;
303
+ // Handle single scrape result (has data.markdown, data.html, etc.)
304
+ if (cloned && typeof cloned === 'object') {
305
+ const target = cloned.data || cloned;
306
+ for (const field of CONTENT_FIELDS) {
307
+ if (typeof target[field] === 'string' && target[field].length > 0) {
308
+ // Budget for this field: total limit minus overhead from other fields
309
+ const otherFieldsSize = serialized.length - target[field].length;
310
+ const fieldBudget = Math.max(MAX_CONTENT_CHARS - otherFieldsSize, Math.floor(MAX_CONTENT_CHARS * 0.5));
311
+ if (target[field].length > fieldBudget) {
312
+ const { text } = truncateAtBoundary(target[field], fieldBudget);
313
+ const originalLen = target[field].length;
314
+ target[field] = text + buildTruncationNotice(text.length, originalLen);
315
+ wasTruncated = true;
316
+ }
317
+ }
318
+ }
319
+ // Handle crawl results (array of pages)
320
+ if (Array.isArray(target)) {
321
+ const result = truncateCrawlArray(target);
322
+ return { result: cloned, wasTruncated: result.wasTruncated, originalLength: serialized.length };
323
+ }
324
+ // Add truncation metadata
325
+ if (wasTruncated) {
326
+ const meta = target.metadata || target;
327
+ meta._truncated = true;
328
+ meta._originalLength = serialized.length;
329
+ }
330
+ }
331
+ return { result: cloned, wasTruncated, originalLength: serialized.length };
332
+ }
333
+ /**
334
+ * Truncate a crawl result array: limit per-page content and total page count.
335
+ */
336
+ function truncateCrawlArray(pages) {
337
+ if (pages.length === 0)
338
+ return { wasTruncated: false };
339
+ const perPageLimit = Math.floor(MAX_CONTENT_CHARS * SINGLE_PAGE_MAX_FRACTION);
340
+ let wasTruncated = false;
341
+ let totalSize = 0;
342
+ for (let i = 0; i < pages.length; i++) {
343
+ const page = pages[i];
344
+ if (!page || typeof page !== 'object')
345
+ continue;
346
+ const target = page.data || page;
347
+ for (const field of CONTENT_FIELDS) {
348
+ if (typeof target[field] === 'string' && target[field].length > perPageLimit) {
349
+ const { text } = truncateAtBoundary(target[field], perPageLimit);
350
+ target[field] = text + buildTruncationNotice(text.length, target[field].length);
351
+ wasTruncated = true;
352
+ }
353
+ }
354
+ totalSize += JSON.stringify(page).length;
355
+ // If total exceeds limit, truncate the array
356
+ if (totalSize > MAX_CONTENT_CHARS && i < pages.length - 1) {
357
+ const originalCount = pages.length;
358
+ pages.length = i + 1;
359
+ pages.push({
360
+ _notice: `Showing ${i + 1} of ${originalCount} crawled pages. Use scorch_check_crawl_status with pagination to see more.`,
361
+ });
362
+ wasTruncated = true;
363
+ break;
364
+ }
365
+ }
366
+ return { wasTruncated };
367
+ }
368
+ /** Build a truncation notice for appending to content */
369
+ function buildTruncationNotice(shownChars, originalChars) {
370
+ 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]`;
371
+ }
372
+ // ---------------------------------------------------------------------------
373
+ // Feature 3: AI Summarization
374
+ // ---------------------------------------------------------------------------
375
+ /**
376
+ * LRU cache for summarization results.
377
+ * Key: sha256(url + first 1000 chars of markdown)
378
+ * Value: { summary, timestamp }
379
+ */
380
+ const summaryCache = new Map();
381
+ const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
382
+ /** Sliding window for summarization rate limiting */
383
+ const summarizationTimestamps = [];
384
+ /** Word count helper */
385
+ export function wordCount(text) {
386
+ return text.split(/\s+/).filter(Boolean).length;
387
+ }
388
+ /** Generate a cache key for summarization */
389
+ function summaryKey(url, markdown) {
390
+ const fingerprint = url + markdown.slice(0, 1000);
391
+ return createHash('sha256').update(fingerprint).digest('hex');
392
+ }
393
+ /** Check if we can make another summarization call (rate limit) */
394
+ function canSummarize() {
395
+ if (SUMMARIZE_MAX_PER_MINUTE <= 0)
396
+ return false;
397
+ const now = Date.now();
398
+ const windowStart = now - 60_000;
399
+ // Purge old entries
400
+ while (summarizationTimestamps.length > 0 && summarizationTimestamps[0] < windowStart) {
401
+ summarizationTimestamps.shift();
402
+ }
403
+ return summarizationTimestamps.length < SUMMARIZE_MAX_PER_MINUTE;
404
+ }
405
+ /** Record a summarization call for rate limiting */
406
+ function recordSummarization() {
407
+ summarizationTimestamps.push(Date.now());
408
+ }
409
+ /** Evict oldest entries if cache exceeds max size */
410
+ function evictCache() {
411
+ if (summaryCache.size <= SUMMARIZE_CACHE_SIZE)
412
+ return;
413
+ // Find and delete the oldest entries
414
+ const entries = [...summaryCache.entries()].sort((a, b) => a[1].timestamp - b[1].timestamp);
415
+ const toDelete = entries.slice(0, entries.length - SUMMARIZE_CACHE_SIZE);
416
+ for (const [key] of toDelete) {
417
+ summaryCache.delete(key);
418
+ }
419
+ }
420
+ /**
421
+ * Summarize markdown content using the Copilot SDK if available.
422
+ * Falls back to truncation on failure or when no LLM is available.
423
+ *
424
+ * @param markdown - The full markdown content to potentially summarize
425
+ * @param url - Source URL (used for cache key)
426
+ * @param getCopilotClientFn - Function to get a CopilotClient instance
427
+ * @param copilotToken - Optional per-user Copilot token
428
+ * @returns Processed markdown with summarization metadata
429
+ */
430
+ export async function summarizeIfNeeded(markdown, url, getCopilotClientFn, copilotToken) {
431
+ // Check if summarization is disabled
432
+ if (SUMMARIZE_AFTER_WORDS <= 0) {
433
+ return { markdown, summarized: false };
434
+ }
435
+ const words = wordCount(markdown);
436
+ if (words <= SUMMARIZE_AFTER_WORDS) {
437
+ return { markdown, summarized: false };
438
+ }
439
+ // Check cache
440
+ const key = summaryKey(url, markdown);
441
+ const cached = summaryCache.get(key);
442
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
443
+ return {
444
+ markdown: cached.summary,
445
+ summarized: true,
446
+ meta: {
447
+ _summarized: true,
448
+ _originalWordCount: words,
449
+ _summaryWordCount: cached.wordCount,
450
+ _summarizedBy: SUMMARIZE_MODEL,
451
+ _cachedResult: true,
452
+ },
453
+ };
454
+ }
455
+ // Check if Copilot client is available
456
+ if (!getCopilotClientFn) {
457
+ return { markdown, summarized: false };
458
+ }
459
+ // Check rate limit
460
+ if (!canSummarize()) {
461
+ return { markdown, summarized: false };
462
+ }
463
+ try {
464
+ const client = await getCopilotClientFn(copilotToken);
465
+ if (!client) {
466
+ return { markdown, summarized: false };
467
+ }
468
+ const targetWords = Math.min(SUMMARIZE_AFTER_WORDS, Math.floor(words * 0.3));
469
+ const sessionConfig = {
470
+ model: SUMMARIZE_MODEL,
471
+ systemMessage: {
472
+ mode: 'replace',
473
+ content: buildSummarizationPrompt(words, targetWords),
474
+ },
475
+ };
476
+ const session = await client.createSession(sessionConfig);
477
+ recordSummarization();
478
+ try {
479
+ const response = await session.sendAndWait({ prompt: markdown }, SUMMARIZE_TIMEOUT_MS);
480
+ // Extract content from response — handle multiple possible shapes
481
+ let summary = '';
482
+ if (response && typeof response === 'object') {
483
+ const r = response;
484
+ // Shape 1: { data: { content: "..." } } (most common)
485
+ if (r.data && typeof r.data === 'object') {
486
+ const d = r.data;
487
+ if (typeof d.content === 'string')
488
+ summary = d.content;
489
+ else if (typeof d.text === 'string')
490
+ summary = d.text;
491
+ }
492
+ // Shape 2: { content: "..." }
493
+ if (!summary && typeof r.content === 'string')
494
+ summary = r.content;
495
+ // Shape 3: { text: "..." }
496
+ if (!summary && typeof r.text === 'string')
497
+ summary = r.text;
498
+ // Shape 4: { message: { content: "..." } }
499
+ if (!summary && r.message && typeof r.message === 'object') {
500
+ const m = r.message;
501
+ if (typeof m.content === 'string')
502
+ summary = m.content;
503
+ }
504
+ }
505
+ else if (typeof response === 'string') {
506
+ summary = response;
507
+ }
508
+ if (!summary || summary.length < 50) {
509
+ // Summarization returned garbage — fall back
510
+ console.error(`[Summarization] Response too short or empty (${summary.length} chars), response keys: ${response ? Object.keys(response).join(', ') : 'null'}`);
511
+ return { markdown, summarized: false };
512
+ }
513
+ const summaryWords = wordCount(summary);
514
+ // Cache the result
515
+ summaryCache.set(key, {
516
+ summary,
517
+ timestamp: Date.now(),
518
+ wordCount: summaryWords,
519
+ });
520
+ evictCache();
521
+ return {
522
+ markdown: summary,
523
+ summarized: true,
524
+ meta: {
525
+ _summarized: true,
526
+ _originalWordCount: words,
527
+ _summaryWordCount: summaryWords,
528
+ _summarizedBy: SUMMARIZE_MODEL,
529
+ },
530
+ };
531
+ }
532
+ finally {
533
+ try {
534
+ await session.destroy();
535
+ }
536
+ catch { /* ignore cleanup */ }
537
+ }
538
+ }
539
+ catch (err) {
540
+ // Summarization failed — fall back silently but log for debugging
541
+ const errMsg = err instanceof Error ? err.message : String(err);
542
+ const errStack = err instanceof Error ? err.stack : '';
543
+ console.error(`[Summarization] Failed for ${url}: ${errMsg}`);
544
+ if (errStack)
545
+ console.error(`[Summarization] Stack: ${errStack}`);
546
+ return {
547
+ markdown,
548
+ summarized: false,
549
+ meta: { _summarizationFailed: true, _summarizationError: errMsg },
550
+ };
551
+ }
552
+ }
553
+ /** Build the system prompt for content summarization */
554
+ function buildSummarizationPrompt(originalWords, targetWords) {
555
+ return `You are a content summarizer for a web scraping tool.
556
+ Summarize the following web page content, preserving:
557
+ - ALL factual data (numbers, dates, names, prices, specifications)
558
+ - Key arguments, conclusions, and findings
559
+ - Structural organization (use headings, lists)
560
+ - Any code snippets or technical details
561
+
562
+ Do NOT:
563
+ - Add opinions or analysis
564
+ - Omit specific data points (numbers, names, URLs)
565
+ - Change the meaning or emphasis of the content
566
+ - Add any preamble like "Here is a summary" — just output the summary directly
567
+
568
+ Original content word count: ${originalWords}
569
+ Target summary: ~${targetWords} words`;
570
+ }
571
+ // ---------------------------------------------------------------------------
572
+ // Combined Processing Pipeline
573
+ // ---------------------------------------------------------------------------
574
+ /**
575
+ * Safe wrapper for tool execution. Catches errors and returns
576
+ * mapped, LLM-friendly error responses.
577
+ */
578
+ export async function safeExecute(fn, context) {
579
+ try {
580
+ return await fn();
581
+ }
582
+ catch (err) {
583
+ const mapped = mapError(err);
584
+ console.error(`[${context.tool}] ${mapped.code}`, {
585
+ url: context.url,
586
+ original: mapped.originalError,
587
+ });
588
+ return JSON.stringify({
589
+ success: false,
590
+ error: mapped.message,
591
+ code: mapped.code,
592
+ suggestions: mapped.suggestions,
593
+ }, null, 2);
594
+ }
595
+ }
596
+ /**
597
+ * Process a successful response: truncate content if needed and
598
+ * optionally summarize via Copilot SDK.
599
+ *
600
+ * Replaces the old `asText()` function.
601
+ */
602
+ export async function processResponse(data, options) {
603
+ // Step 1: Try AI summarization on markdown content
604
+ if (!options?.skipSummarization && options?.getCopilotClientFn) {
605
+ const md = extractMarkdown(data);
606
+ const wc = md ? wordCount(md) : 0;
607
+ console.error(`[Summarization] Check: markdown=${md ? md.length : 0} chars, ${wc} words, threshold=${SUMMARIZE_AFTER_WORDS}`);
608
+ if (md && wc > SUMMARIZE_AFTER_WORDS && SUMMARIZE_AFTER_WORDS > 0) {
609
+ console.error(`[Summarization] Attempting summarization for ${options.url || 'unknown url'} (${wc} words)`);
610
+ const result = await summarizeIfNeeded(md, options.url || '', options.getCopilotClientFn, options.copilotToken);
611
+ if (result.summarized) {
612
+ // Replace the markdown in the data with the summary
613
+ const updated = injectMarkdown(data, result.markdown, result.meta || {});
614
+ return JSON.stringify(updated, null, 2);
615
+ }
616
+ // Summarization was attempted but failed — inject failure metadata and fall through to truncation
617
+ if (result.meta) {
618
+ console.error(`[Summarization] Failed, falling back to truncation. Meta:`, JSON.stringify(result.meta));
619
+ }
620
+ }
621
+ }
622
+ else {
623
+ console.error(`[Summarization] Skipped: skipSummarization=${options?.skipSummarization}, hasCopilotFn=${!!options?.getCopilotClientFn}`);
624
+ }
625
+ // Step 2: Content truncation
626
+ const { result, wasTruncated } = truncateContent(data);
627
+ return JSON.stringify(result, null, 2);
628
+ }
629
+ /**
630
+ * Simple `asText` replacement for backward compatibility.
631
+ * Applies truncation but not summarization.
632
+ */
633
+ export function processResponseSync(data) {
634
+ const { result } = truncateContent(data);
635
+ return JSON.stringify(result, null, 2);
636
+ }
637
+ // ---------------------------------------------------------------------------
638
+ // Helpers for extracting/injecting markdown from nested response objects
639
+ // ---------------------------------------------------------------------------
640
+ /** Extract markdown content from a scrape response, wherever it may be nested */
641
+ function extractMarkdown(data) {
642
+ if (!data || typeof data !== 'object')
643
+ return null;
644
+ const obj = data;
645
+ // Direct: data.markdown
646
+ if (typeof obj.markdown === 'string')
647
+ return obj.markdown;
648
+ // Nested: data.data.markdown
649
+ if (obj.data && typeof obj.data === 'object') {
650
+ const inner = obj.data;
651
+ if (typeof inner.markdown === 'string')
652
+ return inner.markdown;
653
+ }
654
+ return null;
655
+ }
656
+ /** Replace the markdown field in a response and merge metadata */
657
+ function injectMarkdown(data, newMarkdown, meta) {
658
+ const serialized = JSON.stringify(data);
659
+ const cloned = JSON.parse(serialized);
660
+ if (typeof cloned.markdown === 'string') {
661
+ cloned.markdown = newMarkdown;
662
+ Object.assign(cloned.metadata || cloned, meta);
663
+ return cloned;
664
+ }
665
+ if (cloned.data && typeof cloned.data === 'object') {
666
+ if (typeof cloned.data.markdown === 'string') {
667
+ cloned.data.markdown = newMarkdown;
668
+ Object.assign(cloned.data.metadata || cloned.data, meta);
669
+ return cloned;
670
+ }
671
+ }
672
+ return cloned;
673
+ }
674
+ // ---------------------------------------------------------------------------
675
+ // Exports for testing
676
+ // ---------------------------------------------------------------------------
677
+ export { MAX_CONTENT_CHARS, SUMMARIZE_AFTER_WORDS, SUMMARIZE_MODEL };
678
+ export { summaryCache, summarizationTimestamps };
679
+ // ---------------------------------------------------------------------------
680
+ // Feature 4: Response Quality Scoring
681
+ // ---------------------------------------------------------------------------
682
+ /** Known anti-bot / blocked-page signatures */
683
+ const ANTI_BOT_SIGNATURES = [
684
+ // Cloudflare
685
+ 'checking your browser', 'cf-browser-verification', 'just a moment',
686
+ 'cf-challenge-running', 'cf_chl_opt', 'ray id:',
687
+ // DataDome
688
+ 'datadome', 'dd.js',
689
+ // PerimeterX
690
+ 'perimeterx', 'px-captcha', 'human challenge',
691
+ // Incapsula / Imperva
692
+ 'incapsula', 'imperva', '_incapsula_resource',
693
+ // Generic
694
+ 'access denied', 'bot detected', 'automated access',
695
+ 'please verify you are a human', 'captcha',
696
+ // Akamai
697
+ 'akamai', 'ak_bmsc',
698
+ // Shape Security
699
+ 'shape security',
700
+ ];
701
+ /**
702
+ * Score the quality of a scrape response (0-100).
703
+ * Detects anti-bot pages, empty/minimal content, and common failure patterns.
704
+ */
705
+ export function scoreResponseQuality(data) {
706
+ const issues = [];
707
+ let score = 100;
708
+ let antiBotDetected = false;
709
+ let antiBotSystem;
710
+ if (!data || typeof data !== 'object') {
711
+ return { score: 0, antiBotDetected: false, issues: ['No response data'] };
712
+ }
713
+ const obj = data;
714
+ const inner = (obj.data && typeof obj.data === 'object' ? obj.data : obj);
715
+ // Check for explicit failure
716
+ if (obj.success === false) {
717
+ score -= 50;
718
+ issues.push('Response indicates failure');
719
+ }
720
+ // Extract text content for analysis
721
+ const md = (typeof inner.markdown === 'string' ? inner.markdown : '');
722
+ const html = (typeof inner.html === 'string' ? inner.html : typeof inner.rawHtml === 'string' ? inner.rawHtml : '');
723
+ const content = md || html;
724
+ const lower = content.toLowerCase();
725
+ // --- Anti-bot detection ---
726
+ for (const sig of ANTI_BOT_SIGNATURES) {
727
+ if (lower.includes(sig)) {
728
+ antiBotDetected = true;
729
+ // Identify the specific system
730
+ if (sig.includes('cf-') || sig.includes('cloudflare') || sig === 'just a moment' || sig === 'checking your browser' || sig === 'ray id:') {
731
+ antiBotSystem = 'Cloudflare';
732
+ }
733
+ else if (sig.includes('datadome')) {
734
+ antiBotSystem = 'DataDome';
735
+ }
736
+ else if (sig.includes('perimeterx') || sig.includes('px-captcha')) {
737
+ antiBotSystem = 'PerimeterX';
738
+ }
739
+ else if (sig.includes('incapsula') || sig.includes('imperva')) {
740
+ antiBotSystem = 'Imperva/Incapsula';
741
+ }
742
+ else if (sig.includes('akamai') || sig.includes('ak_bmsc')) {
743
+ antiBotSystem = 'Akamai';
744
+ }
745
+ else if (sig.includes('captcha')) {
746
+ antiBotSystem = 'CAPTCHA';
747
+ }
748
+ else {
749
+ antiBotSystem = 'Unknown';
750
+ }
751
+ score -= 40;
752
+ issues.push(`Anti-bot protection detected: ${antiBotSystem}`);
753
+ break;
754
+ }
755
+ }
756
+ // --- Content length analysis ---
757
+ if (content.length === 0) {
758
+ score -= 30;
759
+ issues.push('No content extracted');
760
+ }
761
+ else if (content.length < 100) {
762
+ score -= 20;
763
+ issues.push('Very minimal content (< 100 chars)');
764
+ }
765
+ else if (content.length < 500) {
766
+ score -= 10;
767
+ issues.push('Thin content (< 500 chars)');
768
+ }
769
+ // --- Navigation-only content detection (SPA shells) ---
770
+ const navPatterns = ['sign in', 'log in', 'menu', 'navigation', 'footer', 'header'];
771
+ const navCount = navPatterns.filter(p => lower.includes(p)).length;
772
+ const contentWords = content.split(/\s+/).length;
773
+ if (contentWords < 50 && navCount >= 3) {
774
+ score -= 20;
775
+ issues.push('Content appears to be navigation-only (possible SPA shell)');
776
+ }
777
+ // --- Error page detection ---
778
+ const errorPagePatterns = ['page not found', '404', 'error occurred', 'something went wrong', 'under maintenance'];
779
+ if (errorPagePatterns.some(p => lower.includes(p)) && contentWords < 200) {
780
+ score -= 15;
781
+ issues.push('Content appears to be an error page');
782
+ }
783
+ return {
784
+ score: Math.max(0, Math.min(100, score)),
785
+ antiBotDetected,
786
+ ...(antiBotSystem && { antiBotSystem }),
787
+ issues,
788
+ };
789
+ }