crawlforge-mcp-server 4.9.0 → 5.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.
Files changed (60) hide show
  1. package/CLAUDE.md +6 -5
  2. package/README.md +19 -3
  3. package/package.json +10 -12
  4. package/server.js +315 -214
  5. package/src/core/ActionExecutor.js +117 -33
  6. package/src/core/AgentOrchestrator.js +8 -2
  7. package/src/core/AuthManager.js +51 -17
  8. package/src/core/ChangeTracker.js +26 -10
  9. package/src/core/JobManager.js +9 -1
  10. package/src/core/LocalizationManager.js +19 -6
  11. package/src/core/ResearchOrchestrator.js +173 -35
  12. package/src/core/SnapshotManager.js +162 -165
  13. package/src/core/StealthBrowserManager.js +25 -3
  14. package/src/core/WebhookDispatcher.js +19 -14
  15. package/src/core/analysis/ContentAnalyzer.js +52 -7
  16. package/src/core/crawlers/BFSCrawler.js +27 -3
  17. package/src/core/processing/BrowserProcessor.js +19 -1
  18. package/src/core/processing/PDFProcessor.js +129 -65
  19. package/src/core/queue/QueueManager.js +3 -2
  20. package/src/schemas/toolOutputSchemas.js +269 -0
  21. package/src/server/auth/oauth.js +37 -7
  22. package/src/server/specHygiene.js +192 -0
  23. package/src/server/taskSupport.js +233 -0
  24. package/src/server/toolFilter.js +98 -0
  25. package/src/server/transports/streamableHttp.js +148 -11
  26. package/src/server/withAuth.js +11 -4
  27. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +15 -0
  28. package/src/tools/advanced/ScrapeWithActionsTool.js +43 -52
  29. package/src/tools/advanced/batchScrape/index.js +128 -27
  30. package/src/tools/advanced/batchScrape/worker.js +55 -5
  31. package/src/tools/advanced/scrapeWithActions/recorder.js +3 -0
  32. package/src/tools/basic/_fetch.js +125 -70
  33. package/src/tools/basic/extractLinks.js +14 -12
  34. package/src/tools/basic/scrapeStructured.js +21 -4
  35. package/src/tools/crawl/crawlDeep.js +110 -48
  36. package/src/tools/crawl/mapSite.js +25 -6
  37. package/src/tools/extract/_fetchAndParse.js +98 -1
  38. package/src/tools/extract/extractContent.js +7 -4
  39. package/src/tools/extract/extractStructured.js +125 -84
  40. package/src/tools/extract/extractWithLlm.js +10 -2
  41. package/src/tools/extract/processDocument.js +54 -6
  42. package/src/tools/extract/summarizeContent.js +7 -1
  43. package/src/tools/llmstxt/generateLLMsTxt.js +8 -6
  44. package/src/tools/research/deepResearch.js +51 -31
  45. package/src/tools/scrape/_brandingExtractor.js +49 -11
  46. package/src/tools/scrape/unifiedScrape.js +27 -17
  47. package/src/tools/search/providers/searxng.js +5 -1
  48. package/src/tools/search/ranking/ResultDeduplicator.js +9 -1
  49. package/src/tools/search/ranking/ResultRanker.js +17 -2
  50. package/src/tools/search/searchWeb.js +31 -14
  51. package/src/tools/search/serpRank.js +23 -0
  52. package/src/tools/templates/TemplateRegistry.js +7 -1
  53. package/src/tools/tracking/trackChanges/index.js +87 -26
  54. package/src/tools/tracking/trackChanges/schema.js +2 -2
  55. package/src/utils/CircuitBreaker.js +11 -9
  56. package/src/utils/contentUtils.js +66 -53
  57. package/src/utils/secretMask.js +1 -1
  58. package/src/utils/sitemapParser.js +11 -9
  59. package/src/utils/ssrfGuard.js +212 -40
  60. package/src/utils/urlNormalizer.js +2 -2
@@ -4,6 +4,7 @@ import { ElicitationHelper } from '../../core/ElicitationHelper.js';
4
4
  import { ResearchOrchestrator } from '../../core/ResearchOrchestrator.js';
5
5
  import { getToolConfig } from '../../constants/config.js';
6
6
  import { Logger } from '../../utils/Logger.js';
7
+ import { safeFetch } from '../../utils/ssrfGuard.js';
7
8
 
8
9
  /**
9
10
  * DeepResearchTool - MCP tool for conducting comprehensive multi-stage research
@@ -190,12 +191,15 @@ export class DeepResearchTool {
190
191
  // Format results according to output preference
191
192
  const formattedResults = this.formatResults(researchResults, validated);
192
193
 
193
- // Clean up session
194
+ // Capture startTime before deleting the session — looking it up
195
+ // afterward always returned undefined, so duration was always logged
196
+ // as 0 (Date.now() - undefined = NaN, then `NaN || 0` = 0).
197
+ const startTime = this.activeSessions.get(sessionId)?.startTime;
194
198
  this.activeSessions.delete(sessionId);
195
199
 
196
- this.logger.info('Research completed successfully', {
200
+ this.logger.info('Research completed successfully', {
197
201
  sessionId,
198
- duration: Date.now() - this.activeSessions.get(sessionId)?.startTime || 0,
202
+ duration: startTime ? Date.now() - startTime : 0,
199
203
  findingsCount: researchResults.findings?.length || 0
200
204
  });
201
205
 
@@ -264,7 +268,12 @@ export class DeepResearchTool {
264
268
  if (params.llmConfig) {
265
269
  baseConfig.llmConfig = params.llmConfig;
266
270
  }
267
-
271
+
272
+ // params.cacheResults was previously dropped entirely — only the
273
+ // DeepResearchTool-level default (cacheEnabled) ever reached the
274
+ // orchestrator, so a caller's per-request cacheResults:false was a no-op.
275
+ baseConfig.cacheEnabled = params.cacheResults;
276
+
268
277
  // Every approach must propagate the user's scope params (maxUrls,
269
278
  // timeLimit, concurrency) — only `broad` did before, so non-broad
270
279
  // approaches silently fell back to orchestrator defaults.
@@ -279,29 +288,37 @@ export class DeepResearchTool {
279
288
  // The orchestrator tunes its query expansion to the approach (commercial
280
289
  // vs academic vs current-events); without this it always used academic
281
290
  // variations, which poisoned commercial/comparative searches.
282
- researchApproach: params.researchApproach
291
+ researchApproach: params.researchApproach,
292
+ // The user's explicit choice here was previously dropped entirely —
293
+ // only per-approach hardcoded values (or the orchestrator's own
294
+ // default) ever reached the constructor, so e.g.
295
+ // enableSourceVerification:false still ran verification.
296
+ enableSourceVerification: params.enableSourceVerification,
297
+ enableConflictDetection: params.enableConflictDetection
283
298
  };
284
299
 
285
300
  switch (params.researchApproach) {
286
- case 'academic':
301
+ case 'academic': {
302
+ // Higher weight for academic sources. `rankingWeights` is kept for
303
+ // back-compat; `rankingOptions.weights` is the key SearchWebTool's
304
+ // constructor actually reads (searchWeb.js `rankingOptions`).
305
+ const weights = { authority: 0.4, semantic: 0.3, bm25: 0.2, freshness: 0.1 };
287
306
  return {
288
307
  ...baseConfig,
289
308
  ...scopeConfig,
290
309
  maxDepth: Math.min(params.maxDepth, 8),
291
- enableSourceVerification: true,
292
310
  searchConfig: {
293
311
  ...baseConfig.searchConfig,
294
312
  enableRanking: true,
295
- rankingWeights: {
296
- authority: 0.4, // Higher weight for academic sources
297
- semantic: 0.3,
298
- bm25: 0.2,
299
- freshness: 0.1
300
- }
313
+ rankingWeights: weights,
314
+ rankingOptions: { weights }
301
315
  }
302
316
  };
317
+ }
303
318
 
304
- case 'current_events':
319
+ case 'current_events': {
320
+ // Prioritize recent content.
321
+ const weights = { freshness: 0.4, semantic: 0.3, bm25: 0.2, authority: 0.1 };
305
322
  return {
306
323
  ...baseConfig,
307
324
  ...scopeConfig,
@@ -309,14 +326,11 @@ export class DeepResearchTool {
309
326
  searchConfig: {
310
327
  ...baseConfig.searchConfig,
311
328
  enableRanking: true,
312
- rankingWeights: {
313
- freshness: 0.4, // Prioritize recent content
314
- semantic: 0.3,
315
- bm25: 0.2,
316
- authority: 0.1
317
- }
329
+ rankingWeights: weights,
330
+ rankingOptions: { weights }
318
331
  }
319
332
  };
333
+ }
320
334
 
321
335
  case 'focused':
322
336
  return {
@@ -327,22 +341,22 @@ export class DeepResearchTool {
327
341
  concurrency: Math.min(params.concurrency, 3)
328
342
  };
329
343
 
330
- case 'comparative':
344
+ case 'comparative': {
345
+ // `deduplicationThresholds` is kept for back-compat; `deduplicationOptions.thresholds`
346
+ // is the key SearchWebTool's constructor actually reads.
347
+ const thresholds = { url: 0.9, title: 0.8, content: 0.7 };
331
348
  return {
332
349
  ...baseConfig,
333
350
  ...scopeConfig,
334
- enableConflictDetection: true,
335
351
  maxDepth: params.maxDepth,
336
352
  searchConfig: {
337
353
  ...baseConfig.searchConfig,
338
354
  enableDeduplication: true,
339
- deduplicationThresholds: {
340
- url: 0.9,
341
- title: 0.8,
342
- content: 0.7
343
- }
355
+ deduplicationThresholds: thresholds,
356
+ deduplicationOptions: { thresholds }
344
357
  }
345
358
  };
359
+ }
346
360
 
347
361
  case 'broad':
348
362
  default:
@@ -713,14 +727,15 @@ export class DeepResearchTool {
713
727
  data
714
728
  };
715
729
 
716
- const response = await fetch(webhook.url, {
730
+ const response = await safeFetch(webhook.url, {
717
731
  method: 'POST',
718
732
  headers: {
719
733
  'Content-Type': 'application/json',
720
734
  'User-Agent': 'MCP-WebScraper-DeepResearch/1.0',
721
735
  ...webhook.headers
722
736
  },
723
- body: JSON.stringify(payload)
737
+ body: JSON.stringify(payload),
738
+ signal: AbortSignal.timeout(10000)
724
739
  });
725
740
 
726
741
  if (!response.ok) {
@@ -747,10 +762,15 @@ export class DeepResearchTool {
747
762
  }
748
763
 
749
764
  sanitizeConfigForLogging(config) {
750
- const { webhook, ...safeConfig } = config;
765
+ const { webhook, llmConfig, ...safeConfig } = config;
751
766
  return {
752
767
  ...safeConfig,
753
- webhook: webhook ? { url: webhook.url, events: webhook.events } : undefined
768
+ webhook: webhook ? { url: webhook.url, events: webhook.events } : undefined,
769
+ llmConfig: llmConfig ? {
770
+ ...llmConfig,
771
+ openai: llmConfig.openai ? { ...llmConfig.openai, apiKey: llmConfig.openai.apiKey ? '[redacted]' : undefined } : undefined,
772
+ anthropic: llmConfig.anthropic ? { ...llmConfig.anthropic, apiKey: llmConfig.anthropic.apiKey ? '[redacted]' : undefined } : undefined
773
+ } : undefined
754
774
  };
755
775
  }
756
776
 
@@ -140,19 +140,57 @@ async function collectCssSources($, pageUrl, opts) {
140
140
  if (href) hrefs.push(resolveUrl(href, pageUrl));
141
141
  });
142
142
  const max = clamp(opts.maxStylesheets ?? 10, 0, 20);
143
- for (const href of hrefs.slice(0, max)) {
144
- try {
145
- const res = await safeFetch(href, { signal: AbortSignal.timeout(opts.perFileTimeoutMs ?? 8000) });
146
- if (!res.ok) { warnings.push(`branding: stylesheet ${res.status} ${href}`); continue; }
147
- let text = await res.text();
148
- if (text.length > 512 * 1024) text = text.slice(0, 512 * 1024); // size cap
149
- cssText += '\n' + text;
150
- fetchedUrls.push(href);
151
- } catch (err) {
152
- warnings.push(`branding: could not fetch stylesheet ${href} — ${err.message}`);
143
+ const targets = hrefs.slice(0, max);
144
+ const perFileTimeoutMs = opts.perFileTimeoutMs ?? 8000;
145
+ // Single wall-clock budget for ALL stylesheets combined, not just each
146
+ // one individually — otherwise maxStylesheets slow/unresponsive hosts in
147
+ // series can still block for maxStylesheets * perFileTimeoutMs (up to
148
+ // ~160s at the schema max of 20). Capped to whatever the caller's overall
149
+ // timeoutMs allows, defaulting to 10s.
150
+ const overallTimeoutMs = opts.timeoutMs != null
151
+ ? Math.min(opts.timeoutMs, 10000)
152
+ : (opts.overallTimeoutMs ?? 10000);
153
+ const deadline = Date.now() + overallTimeoutMs;
154
+
155
+ // Small worker pool fetches stylesheets concurrently instead of one at a
156
+ // time; results are written back by index so cssText concatenation order
157
+ // stays deterministic regardless of completion order.
158
+ const concurrency = clamp(opts.stylesheetConcurrency ?? 4, 1, Math.max(targets.length, 1));
159
+ const results = new Array(targets.length);
160
+ let cursor = 0;
161
+
162
+ async function worker() {
163
+ while (true) {
164
+ const remaining = deadline - Date.now();
165
+ if (remaining <= 0) return; // overall deadline hit — leave rest unfetched
166
+ const i = cursor++;
167
+ if (i >= targets.length) return;
168
+ const href = targets[i];
169
+ try {
170
+ const res = await safeFetch(href, { signal: AbortSignal.timeout(Math.min(perFileTimeoutMs, remaining)) });
171
+ if (!res.ok) { results[i] = { warning: `branding: stylesheet ${res.status} ${href}` }; continue; }
172
+ let text = await res.text();
173
+ if (text.length > 512 * 1024) text = text.slice(0, 512 * 1024); // size cap
174
+ results[i] = { text, href };
175
+ } catch (err) {
176
+ results[i] = { warning: `branding: could not fetch stylesheet ${href} — ${err.message}` };
177
+ }
153
178
  }
154
179
  }
180
+
181
+ await Promise.all(Array.from({ length: concurrency }, () => worker()));
182
+
183
+ let skippedByDeadline = 0;
184
+ for (let i = 0; i < targets.length; i++) {
185
+ const r = results[i];
186
+ if (!r) { skippedByDeadline++; continue; }
187
+ if (r.warning) { warnings.push(r.warning); continue; }
188
+ cssText += '\n' + r.text;
189
+ fetchedUrls.push(r.href);
190
+ }
191
+
155
192
  if (hrefs.length > max) warnings.push(`branding: ${hrefs.length - max} stylesheet(s) skipped (maxStylesheets=${max})`);
193
+ if (skippedByDeadline > 0) warnings.push(`branding: ${skippedByDeadline} stylesheet(s) skipped — overall CSS fetch deadline (${overallTimeoutMs}ms) exceeded`);
156
194
  }
157
195
 
158
196
  return { cssText, fetchedUrls, styleBlocks, inlineStyleEls, warnings };
@@ -327,7 +365,7 @@ function extractTokens(cssText, cssVariables) {
327
365
  * Extract the full branding object from a loaded cheerio $.
328
366
  * @param {import('cheerio').CheerioAPI} $
329
367
  * @param {string} pageUrl
330
- * @param {{ fetchLinkedCss?: boolean, maxStylesheets?: number, perFileTimeoutMs?: number }} [opts]
368
+ * @param {{ fetchLinkedCss?: boolean, maxStylesheets?: number, perFileTimeoutMs?: number, timeoutMs?: number, overallTimeoutMs?: number, stylesheetConcurrency?: number }} [opts]
331
369
  * @returns {Promise<object>}
332
370
  */
333
371
  export async function extractBranding($, pageUrl, opts = {}) {
@@ -52,29 +52,26 @@ export const UnifiedScrapeSchema = z.object({
52
52
 
53
53
  /**
54
54
  * Extract links from a loaded cheerio $ and the page URL.
55
+ * @param {import('cheerio').CheerioAPI} $
56
+ * @param {string} pageUrl - final URL of the fetched page (used for origin comparison)
57
+ * @param {string} [docBaseUrl] - resolution base for relative hrefs; defaults to pageUrl.
58
+ * Pass the resolved <base href> here when the document declares one.
55
59
  */
56
- function extractLinksFromDom($, pageUrl) {
60
+ function extractLinksFromDom($, pageUrl, docBaseUrl) {
57
61
  const links = [];
58
62
  const seen = new Set();
59
63
  let pageOrigin = '';
60
64
  try { pageOrigin = new URL(pageUrl).origin; } catch { /* ignore */ }
65
+ const resolveBase = docBaseUrl || pageUrl;
61
66
 
62
67
  $('a[href]').each((_, el) => {
63
68
  const href = $(el).attr('href');
64
69
  const text = $(el).text().trim();
65
70
  if (!href) return;
71
+ if (href.startsWith('#') || href.startsWith('javascript:')) return;
66
72
  try {
67
- let absoluteUrl;
68
- let isExternal = false;
69
- if (href.startsWith('http://') || href.startsWith('https://')) {
70
- absoluteUrl = href;
71
- isExternal = new URL(href).origin !== pageOrigin;
72
- } else if (href.startsWith('#') || href.startsWith('javascript:')) {
73
- return;
74
- } else {
75
- absoluteUrl = new URL(href, pageUrl).toString();
76
- isExternal = false;
77
- }
73
+ const absoluteUrl = new URL(href, resolveBase).toString();
74
+ const isExternal = new URL(absoluteUrl).origin !== pageOrigin;
78
75
  if (!seen.has(absoluteUrl)) {
79
76
  seen.add(absoluteUrl);
80
77
  links.push({ href: absoluteUrl, text, is_external: isExternal, original_href: href });
@@ -208,6 +205,14 @@ export class UnifiedScrapeTool {
208
205
  throw new Error(`scrape: fetch failed for ${url}: ${err.message}`);
209
206
  }
210
207
 
208
+ // Resolve <base href> once per document (if present) so link resolution
209
+ // matches how a browser would navigate, instead of always using finalUrl.
210
+ let docBaseUrl = finalUrl;
211
+ try {
212
+ const baseHref = $('base[href]').first().attr('href');
213
+ if (baseHref) docBaseUrl = new URL(baseHref, finalUrl).toString();
214
+ } catch { /* ignore invalid <base href>, fall back to finalUrl */ }
215
+
211
216
  // For onlyMainContent: extract main-content html via Readability once
212
217
  let mainHtml = null;
213
218
  function getMainHtml() {
@@ -279,15 +284,18 @@ export class UnifiedScrapeTool {
279
284
 
280
285
  case 'text':
281
286
  try {
287
+ const { load } = await import('cheerio');
282
288
  if (onlyMainContent) {
283
289
  // Plain text from Readability main content via cheerio
284
- const { load } = await import('cheerio');
285
290
  const $main = load(getMainHtml());
286
291
  $main('script, style').remove();
287
292
  content.text = extractBlockText($main);
288
293
  } else {
289
- $('script, style').remove();
290
- content.text = extractBlockText($);
294
+ // Strip script/style on a clone, not the shared $, so other
295
+ // formats reading $ later aren't affected by format ordering.
296
+ const $clone = load($.html());
297
+ $clone('script, style').remove();
298
+ content.text = extractBlockText($clone);
291
299
  }
292
300
  } catch (err) {
293
301
  content.text = '';
@@ -297,7 +305,7 @@ export class UnifiedScrapeTool {
297
305
 
298
306
  case 'links':
299
307
  try {
300
- content.links = extractLinksFromDom($, finalUrl);
308
+ content.links = extractLinksFromDom($, finalUrl, docBaseUrl);
301
309
  } catch (err) {
302
310
  content.links = { links: [], total_count: 0, internal_count: 0, external_count: 0 };
303
311
  warnings.push(`links: ${err.message}`);
@@ -348,7 +356,9 @@ export class UnifiedScrapeTool {
348
356
  { headless: true, timeout: 30000 }
349
357
  );
350
358
  content.screenshots = Array.isArray(r?.screenshots) ? r.screenshots : [];
351
- if (content.screenshots.length === 0) {
359
+ if (r?.success === false) {
360
+ warnings.push(`screenshot: ${r.error || 'capture failed'}`);
361
+ } else if (content.screenshots.length === 0) {
352
362
  warnings.push('screenshot: capture produced no image');
353
363
  }
354
364
  } catch (err) {
@@ -92,9 +92,13 @@ export async function searchViaSearxng(opts = {}) {
92
92
  let response;
93
93
  try {
94
94
  response = await fetch(url.toString(), {
95
- headers: { Accept: 'application/json' }
95
+ headers: { Accept: 'application/json' },
96
+ signal: AbortSignal.timeout(15000)
96
97
  });
97
98
  } catch (err) {
99
+ if (err.name === 'TimeoutError' || err.name === 'AbortError') {
100
+ throw new Error('SearXNG request failed: timed out after 15000ms');
101
+ }
98
102
  throw new Error(`SearXNG request failed: ${err.message}`);
99
103
  }
100
104
 
@@ -88,7 +88,15 @@ export class ResultDeduplicator {
88
88
  return results;
89
89
  }
90
90
 
91
- const dedupeOptions = { ...this.options, ...options };
91
+ // Deep-merge thresholds instead of replacing wholesale — a caller
92
+ // passing a partial thresholds object (e.g. {url: 0.8}) previously wiped
93
+ // out the other thresholds, leaving them `undefined` and silently
94
+ // disabling title/content/combined duplicate detection.
95
+ const dedupeOptions = {
96
+ ...this.options,
97
+ ...options,
98
+ thresholds: { ...this.options.thresholds, ...(options.thresholds || {}) }
99
+ };
92
100
  this.stats.totalProcessed += results.length;
93
101
 
94
102
  // Generate cache key for deduplication computation
@@ -73,8 +73,19 @@ export class ResultRanker {
73
73
  return [];
74
74
  }
75
75
 
76
- const rankingOptions = { ...this.options, ...options };
77
-
76
+ // Deep-merge nested option objects instead of replacing them wholesale —
77
+ // a caller passing a partial weights object (e.g. {bm25: 0.7}) previously
78
+ // wiped out the other three weights, leaving them `undefined` and making
79
+ // computeFinalScore return NaN for every result.
80
+ const rankingOptions = {
81
+ ...this.options,
82
+ ...options,
83
+ weights: { ...this.options.weights, ...(options.weights || {}) },
84
+ bm25: { ...this.options.bm25, ...(options.bm25 || {}) },
85
+ authority: { ...this.options.authority, ...(options.authority || {}) },
86
+ freshness: { ...this.options.freshness, ...(options.freshness || {}) }
87
+ };
88
+
78
89
  // Generate cache key for ranking computation
79
90
  const cacheKey = this.cache ? this.cache.generateKey('ranking', {
80
91
  query,
@@ -179,6 +190,10 @@ export class ResultRanker {
179
190
 
180
191
  // Tokenize query and content
181
192
  const queryTerms = this.tokenize(query.toLowerCase());
193
+ // Queries whose tokens are all <=1 char after tokenization (e.g. "C#")
194
+ // tokenize to []; without this guard `score / queryTerms.length` below
195
+ // is 0/0 = NaN, which then poisons finalScore for every result.
196
+ if (queryTerms.length === 0) return 0;
182
197
  const contentTerms = this.tokenize(content);
183
198
  const contentLength = contentTerms.length;
184
199
 
@@ -186,12 +186,18 @@ export class SearchWebTool {
186
186
  }
187
187
  }
188
188
 
189
- // Try searches with expanded queries, starting with the best one
189
+ // Try searches with expanded queries, starting with the best one.
190
+ // Each retry (triggered when the previous query returned zero items) is
191
+ // a separate billed backend search — cap attempts so one search_web
192
+ // call can't silently fan out into up to maxExpansions backend requests.
193
+ const MAX_SEARCH_ATTEMPTS = 2;
194
+ const queriesToTry = searchQueries.slice(0, MAX_SEARCH_ATTEMPTS);
190
195
  let bestResults = null;
191
196
  let usedQuery = validated.query;
192
197
  let searchError = null;
193
-
194
- for (let i = 0; i < searchQueries.length; i++) {
198
+ let searchAttempts = 0;
199
+
200
+ for (let i = 0; i < queriesToTry.length; i++) {
195
201
  try {
196
202
  // Build search query with modifiers
197
203
  let searchQuery = searchQueries[i];
@@ -219,32 +225,34 @@ export class SearchWebTool {
219
225
  };
220
226
 
221
227
  const results = await this.searchAdapter.search(searchParams);
222
-
228
+ searchAttempts++;
229
+
223
230
  // Check if we got good results
224
231
  if (results.items && results.items.length > 0) {
225
232
  bestResults = results;
226
- usedQuery = searchQueries[i];
233
+ usedQuery = queriesToTry[i];
227
234
  break;
228
235
  } else if (i === 0) {
229
236
  // Save results from first query even if no items (might be the original query)
230
237
  bestResults = results;
231
- usedQuery = searchQueries[i];
238
+ usedQuery = queriesToTry[i];
232
239
  }
233
240
  } catch (error) {
234
241
  searchError = error;
235
- console.warn(`Search failed for query "${searchQueries[i]}":`, error.message);
236
-
242
+ searchAttempts++;
243
+ console.warn(`Search failed for query "${queriesToTry[i]}":`, error.message);
244
+
237
245
  // If this is the last query and we haven't found results, throw the error
238
- if (i === searchQueries.length - 1 && !bestResults) {
246
+ if (i === queriesToTry.length - 1 && !bestResults) {
239
247
  throw error;
240
248
  }
241
249
  }
242
250
  }
243
-
251
+
244
252
  if (!bestResults) {
245
253
  throw searchError || new Error('All search queries failed');
246
254
  }
247
-
255
+
248
256
  // Process and enrich results
249
257
  let processedResults = await this.processResults(bestResults);
250
258
 
@@ -282,7 +290,10 @@ export class SearchWebTool {
282
290
 
283
291
  rankingInfo = {
284
292
  algorithmsUsed: ['bm25', 'semantic', 'authority', 'freshness'],
285
- weightsApplied: this.resultRanker.options.weights,
293
+ // rankingDetails.weights carries the actually-applied (merged) weights
294
+ // for this call; this.resultRanker.options.weights is only the
295
+ // constructor default and previously misreported partial overrides.
296
+ weightsApplied: processedResults[0]?.rankingDetails?.weights || this.resultRanker.options.weights,
286
297
  totalResults: processedResults.length
287
298
  };
288
299
  }
@@ -341,7 +352,11 @@ export class SearchWebTool {
341
352
  query_expansion: localizedParams.expand_query && expandedQueries.length > 1 ? {
342
353
  original_query: validated.query,
343
354
  expanded_count: expandedQueries.length,
344
- used_query: usedQuery
355
+ used_query: usedQuery,
356
+ // Backend searches actually issued for this call (capped at
357
+ // MAX_SEARCH_ATTEMPTS) — surfaces the retry/billing cost that was
358
+ // previously invisible.
359
+ search_attempts: searchAttempts
345
360
  } : null,
346
361
  localization_applied: !!validated.localization
347
362
  }
@@ -411,7 +426,9 @@ export class SearchWebTool {
411
426
  );
412
427
  rankingInfo = {
413
428
  algorithmsUsed: ['bm25', 'semantic', 'authority', 'freshness'],
414
- weightsApplied: this.resultRanker.options.weights,
429
+ // See execute()'s equivalent block: rankingDetails.weights carries the
430
+ // actually-applied (merged) weights for this call.
431
+ weightsApplied: processedResults[0]?.rankingDetails?.weights || this.resultRanker.options.weights,
415
432
  totalResults: processedResults.length
416
433
  };
417
434
  }
@@ -12,6 +12,11 @@
12
12
  import { z } from 'zod';
13
13
  import { DataForSEOSearchAdapter } from './adapters/dataforseoSearch.js';
14
14
 
15
+ /** How many top organic results to return as the SERP listing (`results`).
16
+ * The first Google page is ~10; bounding it keeps the tool payload small while
17
+ * still surfacing the competitors that matter. */
18
+ const RESULTS_LIMIT = 10;
19
+
15
20
  const SerpRankSchema = z.object({
16
21
  keyword: z.string().min(1),
17
22
  target: z.string().min(1), // domain or URL to locate in the SERP
@@ -94,6 +99,23 @@ export class SerpRankTool {
94
99
 
95
100
  const best = matches[0] || null;
96
101
 
102
+ // The SERP listing itself — the top organic competitors as Google actually
103
+ // ranks them, not just the target. Bounded to the first page so the payload
104
+ // stays small; each item already carries { position, rankAbsolute, domain,
105
+ // url, title, snippet } from the adapter. This is what "SERP results" means.
106
+ const results = items
107
+ .slice()
108
+ .sort((a, b) => (a.position ?? Infinity) - (b.position ?? Infinity))
109
+ .slice(0, RESULTS_LIMIT)
110
+ .map((it) => ({
111
+ position: it.position,
112
+ rankAbsolute: it.rankAbsolute,
113
+ domain: it.domain,
114
+ url: it.url,
115
+ title: it.title,
116
+ snippet: it.snippet,
117
+ }));
118
+
97
119
  return {
98
120
  configured: true,
99
121
  keyword: validated.keyword,
@@ -104,6 +126,7 @@ export class SerpRankTool {
104
126
  url: best ? best.url : null,
105
127
  title: best ? best.title : null,
106
128
  allPositions: matches, // every place the domain ranks on this SERP
129
+ results, // the top organic results (the SERP listing), best-first, capped
107
130
  location: meta.location,
108
131
  device: meta.device,
109
132
  depthScanned: meta.depth,
@@ -113,7 +113,13 @@ const TEMPLATES = [
113
113
  description: attr($, 'meta[property="og:description"]', 'content'),
114
114
  thumbnail: attr($, 'meta[property="og:image"]', 'content'),
115
115
  duration: attr($, 'meta[itemprop="duration"]', 'content'),
116
- video_id: new URL($('link[rel="canonical"]').attr('href') || 'https://youtube.com').searchParams.get('v')
116
+ video_id: (() => {
117
+ try {
118
+ return new URL($('link[rel="canonical"]').attr('href') || 'https://youtube.com').searchParams.get('v');
119
+ } catch {
120
+ return null;
121
+ }
122
+ })()
117
123
  };
118
124
  }
119
125
  },