@framers/agentos-ext-web-search 1.1.1 → 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.
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/services/searchProvider.d.ts +35 -1
- package/dist/services/searchProvider.d.ts.map +1 -1
- package/dist/services/searchProvider.js +174 -9
- package/dist/services/searchProvider.js.map +1 -1
- package/dist/tools/factCheck.d.ts.map +1 -1
- package/dist/tools/factCheck.js +7 -0
- package/dist/tools/factCheck.js.map +1 -1
- package/dist/tools/researchAggregator.d.ts.map +1 -1
- package/dist/tools/researchAggregator.js +7 -0
- package/dist/tools/researchAggregator.js.map +1 -1
- package/dist/tools/webSearch.d.ts.map +1 -1
- package/dist/tools/webSearch.js +7 -0
- package/dist/tools/webSearch.js.map +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -1
- package/manifest.json +5 -5
- package/package.json +26 -16
- package/.eslintrc.json +0 -21
- package/CHANGELOG.md +0 -16
- package/src/index.ts +0 -141
- package/src/services/searchProvider.ts +0 -622
- package/src/tools/factCheck.ts +0 -300
- package/src/tools/researchAggregator.ts +0 -289
- package/src/tools/webSearch.ts +0 -129
- package/src/types.ts +0 -41
- package/test/integration.spec.ts +0 -374
- package/test/searchProvider.spec.ts +0 -398
- package/test/webSearch.spec.ts +0 -230
- package/tsconfig.json +0 -26
- package/vitest.config.ts +0 -32
|
@@ -1,622 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Configuration for search providers
|
|
3
|
-
*/
|
|
4
|
-
export interface SearchProviderConfig {
|
|
5
|
-
serperApiKey?: string;
|
|
6
|
-
serpApiKey?: string;
|
|
7
|
-
braveApiKey?: string;
|
|
8
|
-
searxngUrl?: string;
|
|
9
|
-
maxRetries?: number;
|
|
10
|
-
rateLimit?: {
|
|
11
|
-
maxRequests: number;
|
|
12
|
-
windowMs: number;
|
|
13
|
-
};
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Search result structure
|
|
18
|
-
*/
|
|
19
|
-
export interface SearchResult {
|
|
20
|
-
title: string;
|
|
21
|
-
url: string;
|
|
22
|
-
snippet: string;
|
|
23
|
-
position?: number;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Provider-specific response
|
|
28
|
-
*/
|
|
29
|
-
export interface ProviderResponse {
|
|
30
|
-
provider: string;
|
|
31
|
-
results: SearchResult[];
|
|
32
|
-
metadata: {
|
|
33
|
-
query: string;
|
|
34
|
-
timestamp: string;
|
|
35
|
-
responseTime?: number;
|
|
36
|
-
fallback?: boolean;
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* A search result enriched with cross-provider agreement data.
|
|
42
|
-
*/
|
|
43
|
-
export interface MultiSearchResult extends SearchResult {
|
|
44
|
-
/** Which providers returned this URL */
|
|
45
|
-
providers: string[];
|
|
46
|
-
/** Number of providers that returned this URL */
|
|
47
|
-
agreementCount: number;
|
|
48
|
-
/** Confidence score (0-100) based on cross-provider agreement + position */
|
|
49
|
-
confidenceScore: number;
|
|
50
|
-
/** The position this result appeared at in each provider's results */
|
|
51
|
-
providerPositions: Record<string, number>;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Response from a multi-provider parallel search aggregation.
|
|
56
|
-
*/
|
|
57
|
-
export interface MultiSearchResponse {
|
|
58
|
-
results: MultiSearchResult[];
|
|
59
|
-
metadata: {
|
|
60
|
-
query: string;
|
|
61
|
-
timestamp: string;
|
|
62
|
-
totalResponseTime: number;
|
|
63
|
-
providersQueried: string[];
|
|
64
|
-
providersSucceeded: string[];
|
|
65
|
-
providersFailed: string[];
|
|
66
|
-
totalRawResults: number;
|
|
67
|
-
deduplicatedCount: number;
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Service for managing multiple search providers with fallback support
|
|
73
|
-
*
|
|
74
|
-
* @class SearchProviderService
|
|
75
|
-
*
|
|
76
|
-
* @example
|
|
77
|
-
* ```typescript
|
|
78
|
-
* const service = new SearchProviderService({
|
|
79
|
-
* serperApiKey: 'your-key',
|
|
80
|
-
* rateLimit: { maxRequests: 10, windowMs: 60000 }
|
|
81
|
-
* });
|
|
82
|
-
*
|
|
83
|
-
* const results = await service.search('query');
|
|
84
|
-
* ```
|
|
85
|
-
*/
|
|
86
|
-
export class SearchProviderService {
|
|
87
|
-
private config: SearchProviderConfig;
|
|
88
|
-
private rateLimitState: Map<string, { count: number; resetTime: number }>;
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Creates an instance of SearchProviderService
|
|
92
|
-
*
|
|
93
|
-
* @param {SearchProviderConfig} config - Configuration for the service
|
|
94
|
-
*/
|
|
95
|
-
constructor(config: SearchProviderConfig) {
|
|
96
|
-
this.config = {
|
|
97
|
-
serperApiKey: config.serperApiKey,
|
|
98
|
-
serpApiKey: config.serpApiKey,
|
|
99
|
-
braveApiKey: config.braveApiKey,
|
|
100
|
-
searxngUrl: config.searxngUrl,
|
|
101
|
-
maxRetries: config.maxRetries ?? 3,
|
|
102
|
-
rateLimit: config.rateLimit ?? {
|
|
103
|
-
maxRequests: 10,
|
|
104
|
-
windowMs: 60000,
|
|
105
|
-
},
|
|
106
|
-
};
|
|
107
|
-
this.rateLimitState = new Map();
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Performs a search across available providers with automatic fallback
|
|
112
|
-
*
|
|
113
|
-
* @param {string} query - The search query
|
|
114
|
-
* @param {Object} options - Search options
|
|
115
|
-
* @param {number} [options.maxResults=10] - Maximum results to return
|
|
116
|
-
* @param {string} [options.provider] - Specific provider to use
|
|
117
|
-
* @returns {Promise<ProviderResponse>} Search results with metadata
|
|
118
|
-
*
|
|
119
|
-
* @throws {Error} If all providers fail and no fallback is available
|
|
120
|
-
*/
|
|
121
|
-
async search(
|
|
122
|
-
query: string,
|
|
123
|
-
options: {
|
|
124
|
-
maxResults?: number;
|
|
125
|
-
provider?: string;
|
|
126
|
-
category?: string;
|
|
127
|
-
} = {}
|
|
128
|
-
): Promise<ProviderResponse> {
|
|
129
|
-
const startTime = Date.now();
|
|
130
|
-
const maxResults = options.maxResults || 10;
|
|
131
|
-
|
|
132
|
-
// If specific provider requested, try only that one
|
|
133
|
-
if (options.provider) {
|
|
134
|
-
return this.searchWithProvider(query, options.provider, maxResults, startTime, options.category);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// Try providers in order of preference
|
|
138
|
-
const providers = this.getAvailableProviders();
|
|
139
|
-
|
|
140
|
-
for (const provider of providers) {
|
|
141
|
-
try {
|
|
142
|
-
if (await this.checkRateLimit(provider)) {
|
|
143
|
-
return await this.searchWithProvider(query, provider, maxResults, startTime, options.category);
|
|
144
|
-
}
|
|
145
|
-
} catch (error) {
|
|
146
|
-
console.warn(`Provider ${provider} failed:`, error);
|
|
147
|
-
continue;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// Final fallback to DuckDuckGo (no API key required)
|
|
152
|
-
return this.searchDuckDuckGo(query, maxResults, startTime);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Searches using a specific provider
|
|
157
|
-
*
|
|
158
|
-
* @private
|
|
159
|
-
* @param {string} query - Search query
|
|
160
|
-
* @param {string} provider - Provider name
|
|
161
|
-
* @param {number} maxResults - Maximum results
|
|
162
|
-
* @param {number} startTime - Request start timestamp
|
|
163
|
-
* @returns {Promise<ProviderResponse>} Search results
|
|
164
|
-
*/
|
|
165
|
-
private async searchWithProvider(
|
|
166
|
-
query: string,
|
|
167
|
-
provider: string,
|
|
168
|
-
maxResults: number,
|
|
169
|
-
startTime: number,
|
|
170
|
-
category?: string
|
|
171
|
-
): Promise<ProviderResponse> {
|
|
172
|
-
let results: SearchResult[];
|
|
173
|
-
|
|
174
|
-
switch (provider) {
|
|
175
|
-
case 'serper':
|
|
176
|
-
results = await this.searchSerper(query, maxResults);
|
|
177
|
-
break;
|
|
178
|
-
case 'serpapi':
|
|
179
|
-
results = await this.searchSerpApi(query, maxResults);
|
|
180
|
-
break;
|
|
181
|
-
case 'brave':
|
|
182
|
-
results = await this.searchBrave(query, maxResults);
|
|
183
|
-
break;
|
|
184
|
-
case 'searxng':
|
|
185
|
-
results = await this.searchSearXNG(query, maxResults, { categories: category });
|
|
186
|
-
break;
|
|
187
|
-
case 'duckduckgo':
|
|
188
|
-
default:
|
|
189
|
-
results = await this.searchDuckDuckGoAPI(query, maxResults);
|
|
190
|
-
break;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
return {
|
|
194
|
-
provider,
|
|
195
|
-
results: results.slice(0, maxResults),
|
|
196
|
-
metadata: {
|
|
197
|
-
query,
|
|
198
|
-
timestamp: new Date().toISOString(),
|
|
199
|
-
responseTime: Date.now() - startTime
|
|
200
|
-
}
|
|
201
|
-
};
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Gets list of available providers based on configured API keys
|
|
206
|
-
*
|
|
207
|
-
* @returns {string[]} Array of available provider names
|
|
208
|
-
*/
|
|
209
|
-
public getAvailableProviders(): string[] {
|
|
210
|
-
const providers: string[] = [];
|
|
211
|
-
|
|
212
|
-
if (this.config.serperApiKey) providers.push('serper');
|
|
213
|
-
if (this.config.serpApiKey) providers.push('serpapi');
|
|
214
|
-
if (this.config.braveApiKey) providers.push('brave');
|
|
215
|
-
if (this.config.searxngUrl) providers.push('searxng');
|
|
216
|
-
|
|
217
|
-
return providers;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* Checks if a provider is within rate limits
|
|
222
|
-
*
|
|
223
|
-
* @private
|
|
224
|
-
* @param {string} provider - Provider name
|
|
225
|
-
* @returns {Promise<boolean>} True if within limits
|
|
226
|
-
*/
|
|
227
|
-
private async checkRateLimit(provider: string): Promise<boolean> {
|
|
228
|
-
const now = Date.now();
|
|
229
|
-
const state = this.rateLimitState.get(provider);
|
|
230
|
-
|
|
231
|
-
if (!state || now > state.resetTime) {
|
|
232
|
-
this.rateLimitState.set(provider, {
|
|
233
|
-
count: 1,
|
|
234
|
-
resetTime: now + this.config.rateLimit!.windowMs
|
|
235
|
-
});
|
|
236
|
-
return true;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
if (state.count >= this.config.rateLimit!.maxRequests) {
|
|
240
|
-
return false;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
state.count++;
|
|
244
|
-
return true;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* Search using Serper.dev API
|
|
249
|
-
*
|
|
250
|
-
* @private
|
|
251
|
-
* @param {string} query - Search query
|
|
252
|
-
* @param {number} maxResults - Maximum results
|
|
253
|
-
* @returns {Promise<SearchResult[]>} Search results
|
|
254
|
-
*/
|
|
255
|
-
private async searchSerper(query: string, maxResults: number): Promise<SearchResult[]> {
|
|
256
|
-
const response = await fetch('https://google.serper.dev/search', {
|
|
257
|
-
method: 'POST',
|
|
258
|
-
headers: {
|
|
259
|
-
'X-API-KEY': this.config.serperApiKey!,
|
|
260
|
-
'Content-Type': 'application/json'
|
|
261
|
-
},
|
|
262
|
-
body: JSON.stringify({ q: query, num: maxResults })
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
if (!response.ok) throw new Error(`Serper API error: ${response.statusText}`);
|
|
266
|
-
|
|
267
|
-
const data = (await response.json()) as any;
|
|
268
|
-
return (data.organic || []).map((item: any) => ({
|
|
269
|
-
title: item.title,
|
|
270
|
-
url: item.link,
|
|
271
|
-
snippet: item.snippet,
|
|
272
|
-
position: item.position
|
|
273
|
-
}));
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Search using SerpAPI
|
|
278
|
-
*
|
|
279
|
-
* @private
|
|
280
|
-
* @param {string} query - Search query
|
|
281
|
-
* @param {number} maxResults - Maximum results
|
|
282
|
-
* @returns {Promise<SearchResult[]>} Search results
|
|
283
|
-
*/
|
|
284
|
-
private async searchSerpApi(query: string, maxResults: number): Promise<SearchResult[]> {
|
|
285
|
-
const params = new URLSearchParams({
|
|
286
|
-
q: query,
|
|
287
|
-
api_key: this.config.serpApiKey!,
|
|
288
|
-
num: maxResults.toString()
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
const response = await fetch(`https://serpapi.com/search?${params}`);
|
|
292
|
-
if (!response.ok) throw new Error(`SerpAPI error: ${response.statusText}`);
|
|
293
|
-
|
|
294
|
-
const data = (await response.json()) as any;
|
|
295
|
-
return (data.organic_results || []).map((item: any) => ({
|
|
296
|
-
title: item.title,
|
|
297
|
-
url: item.link,
|
|
298
|
-
snippet: item.snippet,
|
|
299
|
-
position: item.position
|
|
300
|
-
}));
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/**
|
|
304
|
-
* Search using Brave Search API
|
|
305
|
-
*
|
|
306
|
-
* @private
|
|
307
|
-
* @param {string} query - Search query
|
|
308
|
-
* @param {number} maxResults - Maximum results
|
|
309
|
-
* @returns {Promise<SearchResult[]>} Search results
|
|
310
|
-
*/
|
|
311
|
-
private async searchBrave(query: string, maxResults: number): Promise<SearchResult[]> {
|
|
312
|
-
const params = new URLSearchParams({
|
|
313
|
-
q: query,
|
|
314
|
-
count: maxResults.toString()
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
const response = await fetch(`https://api.search.brave.com/res/v1/web/search?${params}`, {
|
|
318
|
-
headers: {
|
|
319
|
-
'X-Subscription-Token': this.config.braveApiKey!
|
|
320
|
-
}
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
if (!response.ok) throw new Error(`Brave API error: ${response.statusText}`);
|
|
324
|
-
|
|
325
|
-
const data = (await response.json()) as any;
|
|
326
|
-
return (data.web?.results || []).map((item: any, index: number) => ({
|
|
327
|
-
title: item.title,
|
|
328
|
-
url: item.url,
|
|
329
|
-
snippet: item.description,
|
|
330
|
-
position: index + 1
|
|
331
|
-
}));
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
/**
|
|
335
|
-
* Search using DuckDuckGo (HTML scraping fallback)
|
|
336
|
-
*
|
|
337
|
-
* @private
|
|
338
|
-
* @param {string} query - Search query
|
|
339
|
-
* @param {number} maxResults - Maximum results
|
|
340
|
-
* @param {number} startTime - Request start timestamp
|
|
341
|
-
* @returns {Promise<ProviderResponse>} Search results
|
|
342
|
-
*/
|
|
343
|
-
private async searchDuckDuckGo(
|
|
344
|
-
query: string,
|
|
345
|
-
maxResults: number,
|
|
346
|
-
startTime: number
|
|
347
|
-
): Promise<ProviderResponse> {
|
|
348
|
-
try {
|
|
349
|
-
const results = await this.searchDuckDuckGoAPI(query, maxResults);
|
|
350
|
-
return {
|
|
351
|
-
provider: 'duckduckgo',
|
|
352
|
-
results,
|
|
353
|
-
metadata: {
|
|
354
|
-
query,
|
|
355
|
-
timestamp: new Date().toISOString(),
|
|
356
|
-
responseTime: Date.now() - startTime,
|
|
357
|
-
fallback: true
|
|
358
|
-
}
|
|
359
|
-
};
|
|
360
|
-
} catch (error) {
|
|
361
|
-
// Return empty results rather than throwing
|
|
362
|
-
return {
|
|
363
|
-
provider: 'duckduckgo',
|
|
364
|
-
results: [],
|
|
365
|
-
metadata: {
|
|
366
|
-
query,
|
|
367
|
-
timestamp: new Date().toISOString(),
|
|
368
|
-
responseTime: Date.now() - startTime,
|
|
369
|
-
fallback: true
|
|
370
|
-
}
|
|
371
|
-
};
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
/**
|
|
376
|
-
* Search using DuckDuckGo instant answer API
|
|
377
|
-
*
|
|
378
|
-
* @private
|
|
379
|
-
* @param {string} query - Search query
|
|
380
|
-
* @param {number} maxResults - Maximum results
|
|
381
|
-
* @returns {Promise<SearchResult[]>} Search results
|
|
382
|
-
*/
|
|
383
|
-
private async searchDuckDuckGoAPI(query: string, maxResults: number): Promise<SearchResult[]> {
|
|
384
|
-
// DuckDuckGo instant answer API (limited but free)
|
|
385
|
-
const params = new URLSearchParams({
|
|
386
|
-
q: query,
|
|
387
|
-
format: 'json',
|
|
388
|
-
no_html: '1',
|
|
389
|
-
skip_disambig: '1'
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
const response = await fetch(`https://api.duckduckgo.com/?${params}`);
|
|
393
|
-
const data = (await response.json()) as any;
|
|
394
|
-
|
|
395
|
-
const results: SearchResult[] = [];
|
|
396
|
-
|
|
397
|
-
// Add instant answer if available
|
|
398
|
-
if (data.AbstractText && data.AbstractURL) {
|
|
399
|
-
results.push({
|
|
400
|
-
title: data.Heading || query,
|
|
401
|
-
url: data.AbstractURL,
|
|
402
|
-
snippet: data.AbstractText
|
|
403
|
-
});
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
// Add related topics
|
|
407
|
-
if (data.RelatedTopics) {
|
|
408
|
-
for (const topic of data.RelatedTopics.slice(0, maxResults - 1)) {
|
|
409
|
-
if (topic.FirstURL && topic.Text) {
|
|
410
|
-
results.push({
|
|
411
|
-
title: topic.Text.split(' - ')[0] || topic.Text,
|
|
412
|
-
url: topic.FirstURL,
|
|
413
|
-
snippet: topic.Text
|
|
414
|
-
});
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
return results.slice(0, maxResults);
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
/**
|
|
423
|
-
* Search using a self-hosted SearXNG metasearch instance.
|
|
424
|
-
*
|
|
425
|
-
* @private
|
|
426
|
-
* @param {string} query - Search query
|
|
427
|
-
* @param {number} maxResults - Maximum results
|
|
428
|
-
* @param {Object} [options] - SearXNG-specific options
|
|
429
|
-
* @param {string} [options.categories] - SearXNG categories (e.g. 'general', 'news', 'images')
|
|
430
|
-
* @returns {Promise<SearchResult[]>} Search results
|
|
431
|
-
*/
|
|
432
|
-
private async searchSearXNG(
|
|
433
|
-
query: string,
|
|
434
|
-
maxResults: number,
|
|
435
|
-
options?: { categories?: string }
|
|
436
|
-
): Promise<SearchResult[]> {
|
|
437
|
-
const params = new URLSearchParams({
|
|
438
|
-
q: query,
|
|
439
|
-
format: 'json',
|
|
440
|
-
categories: options?.categories || 'general',
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
const baseUrl = this.config.searxngUrl!.replace(/\/+$/, '');
|
|
444
|
-
const response = await fetch(`${baseUrl}/search?${params}`);
|
|
445
|
-
|
|
446
|
-
if (!response.ok) throw new Error(`SearXNG API error: ${response.statusText}`);
|
|
447
|
-
|
|
448
|
-
const data = (await response.json()) as any;
|
|
449
|
-
return (data.results || []).slice(0, maxResults).map((item: any, index: number) => ({
|
|
450
|
-
title: item.title || '',
|
|
451
|
-
url: item.url || '',
|
|
452
|
-
snippet: item.content || '',
|
|
453
|
-
position: index + 1,
|
|
454
|
-
}));
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
/**
|
|
458
|
-
* Normalizes a URL for deduplication — strips www, tracking params, trailing slash.
|
|
459
|
-
*/
|
|
460
|
-
static normalizeUrl(rawUrl: string): string {
|
|
461
|
-
try {
|
|
462
|
-
const url = new URL(rawUrl);
|
|
463
|
-
url.hostname = url.hostname.replace(/^www\./, '');
|
|
464
|
-
const trackingParams = [
|
|
465
|
-
'utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term',
|
|
466
|
-
'fbclid', 'gclid', 'ref', 'source', 'sxsrf', 'ei', 'ved',
|
|
467
|
-
];
|
|
468
|
-
for (const param of trackingParams) url.searchParams.delete(param);
|
|
469
|
-
url.searchParams.sort();
|
|
470
|
-
url.pathname = url.pathname.replace(/\/+$/, '') || '/';
|
|
471
|
-
return url.toString();
|
|
472
|
-
} catch {
|
|
473
|
-
return rawUrl.toLowerCase().trim();
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
/**
|
|
478
|
-
* Searches ALL available providers in parallel, then merges, deduplicates,
|
|
479
|
-
* and reranks results by cross-provider agreement.
|
|
480
|
-
*/
|
|
481
|
-
async multiSearch(
|
|
482
|
-
query: string,
|
|
483
|
-
options: { maxResults?: number } = {},
|
|
484
|
-
): Promise<MultiSearchResponse> {
|
|
485
|
-
const startTime = Date.now();
|
|
486
|
-
const maxResults = options.maxResults || 10;
|
|
487
|
-
|
|
488
|
-
const providers = [...new Set([...this.getAvailableProviders(), 'duckduckgo'])];
|
|
489
|
-
|
|
490
|
-
const providerResults = await Promise.allSettled(
|
|
491
|
-
providers.map(async (provider) => {
|
|
492
|
-
try {
|
|
493
|
-
if (provider !== 'duckduckgo' && !(await this.checkRateLimit(provider))) {
|
|
494
|
-
throw new Error(`Rate limit exceeded for ${provider}`);
|
|
495
|
-
}
|
|
496
|
-
const response = await this.searchWithProvider(query, provider, maxResults, startTime);
|
|
497
|
-
return { provider, results: response.results, success: true as const };
|
|
498
|
-
} catch {
|
|
499
|
-
return { provider, results: [] as SearchResult[], success: false as const };
|
|
500
|
-
}
|
|
501
|
-
}),
|
|
502
|
-
);
|
|
503
|
-
|
|
504
|
-
const succeeded: string[] = [];
|
|
505
|
-
const failed: string[] = [];
|
|
506
|
-
const allResults: Array<{ provider: string; result: SearchResult; position: number }> = [];
|
|
507
|
-
|
|
508
|
-
for (const outcome of providerResults) {
|
|
509
|
-
if (outcome.status === 'fulfilled') {
|
|
510
|
-
const { provider, results, success } = outcome.value;
|
|
511
|
-
if (success && results.length > 0) {
|
|
512
|
-
succeeded.push(provider);
|
|
513
|
-
results.forEach((result, index) => {
|
|
514
|
-
allResults.push({ provider, result, position: index + 1 });
|
|
515
|
-
});
|
|
516
|
-
} else {
|
|
517
|
-
failed.push(provider);
|
|
518
|
-
}
|
|
519
|
-
} else {
|
|
520
|
-
failed.push('unknown');
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
// Deduplicate and merge by normalized URL
|
|
525
|
-
const merged = new Map<string, MultiSearchResult>();
|
|
526
|
-
|
|
527
|
-
for (const { provider, result, position } of allResults) {
|
|
528
|
-
const key = SearchProviderService.normalizeUrl(result.url);
|
|
529
|
-
|
|
530
|
-
if (merged.has(key)) {
|
|
531
|
-
const existing = merged.get(key)!;
|
|
532
|
-
if (!existing.providers.includes(provider)) {
|
|
533
|
-
existing.providers.push(provider);
|
|
534
|
-
existing.agreementCount = existing.providers.length;
|
|
535
|
-
}
|
|
536
|
-
existing.providerPositions[provider] = position;
|
|
537
|
-
if ((result.snippet?.length || 0) > (existing.snippet?.length || 0)) {
|
|
538
|
-
existing.snippet = result.snippet;
|
|
539
|
-
}
|
|
540
|
-
} else {
|
|
541
|
-
merged.set(key, {
|
|
542
|
-
title: result.title,
|
|
543
|
-
url: result.url,
|
|
544
|
-
snippet: result.snippet,
|
|
545
|
-
position,
|
|
546
|
-
providers: [provider],
|
|
547
|
-
agreementCount: 1,
|
|
548
|
-
confidenceScore: 0,
|
|
549
|
-
providerPositions: { [provider]: position },
|
|
550
|
-
});
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
// Score and rank
|
|
555
|
-
const totalProviders = succeeded.length || 1;
|
|
556
|
-
const scoredResults = Array.from(merged.values()).map((r) => {
|
|
557
|
-
const agreementBonus = (r.agreementCount / totalProviders) * 50;
|
|
558
|
-
const positions = Object.values(r.providerPositions);
|
|
559
|
-
const avgNorm = positions.reduce((s, p) => s + (1 - (p - 1) / maxResults), 0) / positions.length;
|
|
560
|
-
const positionScore = avgNorm * 30;
|
|
561
|
-
r.confidenceScore = Math.round(Math.min(100, agreementBonus + positionScore + 20));
|
|
562
|
-
return r;
|
|
563
|
-
});
|
|
564
|
-
|
|
565
|
-
scoredResults.sort((a, b) => b.confidenceScore - a.confidenceScore);
|
|
566
|
-
|
|
567
|
-
return {
|
|
568
|
-
results: scoredResults.slice(0, maxResults),
|
|
569
|
-
metadata: {
|
|
570
|
-
query,
|
|
571
|
-
timestamp: new Date().toISOString(),
|
|
572
|
-
totalResponseTime: Date.now() - startTime,
|
|
573
|
-
providersQueried: providers,
|
|
574
|
-
providersSucceeded: succeeded,
|
|
575
|
-
providersFailed: failed,
|
|
576
|
-
totalRawResults: allResults.length,
|
|
577
|
-
deduplicatedCount: merged.size,
|
|
578
|
-
},
|
|
579
|
-
};
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
/**
|
|
583
|
-
* Gets recommended search providers with signup information
|
|
584
|
-
*
|
|
585
|
-
* @static
|
|
586
|
-
* @returns {Array} Array of provider recommendations
|
|
587
|
-
*/
|
|
588
|
-
static getRecommendedProviders() {
|
|
589
|
-
return [
|
|
590
|
-
{
|
|
591
|
-
name: 'Serper',
|
|
592
|
-
signupUrl: 'https://serper.dev',
|
|
593
|
-
freeQuota: '2,500 queries/month',
|
|
594
|
-
description: 'Google search results API'
|
|
595
|
-
},
|
|
596
|
-
{
|
|
597
|
-
name: 'SerpAPI',
|
|
598
|
-
signupUrl: 'https://serpapi.com',
|
|
599
|
-
freeQuota: '100 searches/month',
|
|
600
|
-
description: 'Multiple search engines API'
|
|
601
|
-
},
|
|
602
|
-
{
|
|
603
|
-
name: 'Brave Search',
|
|
604
|
-
signupUrl: 'https://brave.com/search-api',
|
|
605
|
-
freeQuota: '2,000 queries/month',
|
|
606
|
-
description: 'Privacy-focused search API'
|
|
607
|
-
},
|
|
608
|
-
{
|
|
609
|
-
name: 'SearXNG',
|
|
610
|
-
signupUrl: 'https://docs.searxng.org (self-hosted)',
|
|
611
|
-
freeQuota: 'Unlimited (self-hosted)',
|
|
612
|
-
description: 'Self-hosted metasearch engine aggregating 244+ search services'
|
|
613
|
-
},
|
|
614
|
-
{
|
|
615
|
-
name: 'DuckDuckGo',
|
|
616
|
-
signupUrl: 'No signup required',
|
|
617
|
-
freeQuota: 'Unlimited (rate limited)',
|
|
618
|
-
description: 'Privacy-focused, no API key needed'
|
|
619
|
-
}
|
|
620
|
-
];
|
|
621
|
-
}
|
|
622
|
-
}
|