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.
- package/CLAUDE.md +6 -5
- package/README.md +19 -3
- package/package.json +10 -12
- package/server.js +315 -214
- package/src/core/ActionExecutor.js +117 -33
- package/src/core/AgentOrchestrator.js +8 -2
- package/src/core/AuthManager.js +51 -17
- package/src/core/ChangeTracker.js +26 -10
- package/src/core/JobManager.js +9 -1
- package/src/core/LocalizationManager.js +19 -6
- package/src/core/ResearchOrchestrator.js +173 -35
- package/src/core/SnapshotManager.js +162 -165
- package/src/core/StealthBrowserManager.js +25 -3
- package/src/core/WebhookDispatcher.js +19 -14
- package/src/core/analysis/ContentAnalyzer.js +52 -7
- package/src/core/crawlers/BFSCrawler.js +27 -3
- package/src/core/processing/BrowserProcessor.js +19 -1
- package/src/core/processing/PDFProcessor.js +129 -65
- package/src/core/queue/QueueManager.js +3 -2
- package/src/schemas/toolOutputSchemas.js +269 -0
- package/src/server/auth/oauth.js +37 -7
- package/src/server/specHygiene.js +192 -0
- package/src/server/taskSupport.js +233 -0
- package/src/server/toolFilter.js +98 -0
- package/src/server/transports/streamableHttp.js +148 -11
- package/src/server/withAuth.js +11 -4
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +15 -0
- package/src/tools/advanced/ScrapeWithActionsTool.js +43 -52
- package/src/tools/advanced/batchScrape/index.js +128 -27
- package/src/tools/advanced/batchScrape/worker.js +55 -5
- package/src/tools/advanced/scrapeWithActions/recorder.js +3 -0
- package/src/tools/basic/_fetch.js +125 -70
- package/src/tools/basic/extractLinks.js +14 -12
- package/src/tools/basic/scrapeStructured.js +21 -4
- package/src/tools/crawl/crawlDeep.js +110 -48
- package/src/tools/crawl/mapSite.js +25 -6
- package/src/tools/extract/_fetchAndParse.js +98 -1
- package/src/tools/extract/extractContent.js +7 -4
- package/src/tools/extract/extractStructured.js +125 -84
- package/src/tools/extract/extractWithLlm.js +10 -2
- package/src/tools/extract/processDocument.js +54 -6
- package/src/tools/extract/summarizeContent.js +7 -1
- package/src/tools/llmstxt/generateLLMsTxt.js +8 -6
- package/src/tools/research/deepResearch.js +51 -31
- package/src/tools/scrape/_brandingExtractor.js +49 -11
- package/src/tools/scrape/unifiedScrape.js +27 -17
- package/src/tools/search/providers/searxng.js +5 -1
- package/src/tools/search/ranking/ResultDeduplicator.js +9 -1
- package/src/tools/search/ranking/ResultRanker.js +17 -2
- package/src/tools/search/searchWeb.js +31 -14
- package/src/tools/search/serpRank.js +23 -0
- package/src/tools/templates/TemplateRegistry.js +7 -1
- package/src/tools/tracking/trackChanges/index.js +87 -26
- package/src/tools/tracking/trackChanges/schema.js +2 -2
- package/src/utils/CircuitBreaker.js +11 -9
- package/src/utils/contentUtils.js +66 -53
- package/src/utils/secretMask.js +1 -1
- package/src/utils/sitemapParser.js +11 -9
- package/src/utils/ssrfGuard.js +212 -40
- 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
|
-
//
|
|
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() -
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
290
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
238
|
+
usedQuery = queriesToTry[i];
|
|
232
239
|
}
|
|
233
240
|
} catch (error) {
|
|
234
241
|
searchError = error;
|
|
235
|
-
|
|
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 ===
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
},
|