@framers/agentos-ext-web-search 1.1.0 → 1.1.1
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/README.md +31 -188
- package/dist/index.d.ts +14 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +52 -50
- package/dist/index.js.map +1 -1
- package/dist/services/searchProvider.d.ts +54 -2
- package/dist/services/searchProvider.d.ts.map +1 -1
- package/dist/services/searchProvider.js +168 -17
- package/dist/services/searchProvider.js.map +1 -1
- package/dist/tools/factCheck.d.ts +46 -88
- package/dist/tools/factCheck.d.ts.map +1 -1
- package/dist/tools/factCheck.js +134 -170
- package/dist/tools/factCheck.js.map +1 -1
- package/dist/tools/researchAggregator.d.ts +37 -91
- package/dist/tools/researchAggregator.d.ts.map +1 -1
- package/dist/tools/researchAggregator.js +134 -175
- package/dist/tools/researchAggregator.js.map +1 -1
- package/dist/tools/webSearch.d.ts +31 -59
- package/dist/tools/webSearch.d.ts.map +1 -1
- package/dist/tools/webSearch.js +69 -87
- package/dist/tools/webSearch.js.map +1 -1
- package/dist/types.js +1 -2
- package/dist/types.js.map +1 -1
- package/manifest.json +11 -6
- package/package.json +2 -2
- package/src/index.ts +64 -46
- package/src/services/searchProvider.ts +231 -25
- package/src/tools/factCheck.ts +206 -224
- package/src/tools/researchAggregator.ts +209 -217
- package/src/tools/webSearch.ts +103 -102
- package/test/integration.spec.ts +240 -39
- package/test/searchProvider.spec.ts +263 -18
- package/test/webSearch.spec.ts +129 -27
- package/tsconfig.json +7 -2
|
@@ -5,6 +5,7 @@ export interface SearchProviderConfig {
|
|
|
5
5
|
serperApiKey?: string;
|
|
6
6
|
serpApiKey?: string;
|
|
7
7
|
braveApiKey?: string;
|
|
8
|
+
searxngUrl?: string;
|
|
8
9
|
maxRetries?: number;
|
|
9
10
|
rateLimit?: {
|
|
10
11
|
maxRequests: number;
|
|
@@ -36,6 +37,37 @@ export interface ProviderResponse {
|
|
|
36
37
|
};
|
|
37
38
|
}
|
|
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
|
+
|
|
39
71
|
/**
|
|
40
72
|
* Service for managing multiple search providers with fallback support
|
|
41
73
|
*
|
|
@@ -62,12 +94,15 @@ export class SearchProviderService {
|
|
|
62
94
|
*/
|
|
63
95
|
constructor(config: SearchProviderConfig) {
|
|
64
96
|
this.config = {
|
|
65
|
-
|
|
66
|
-
|
|
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 ?? {
|
|
67
103
|
maxRequests: 10,
|
|
68
|
-
windowMs: 60000
|
|
104
|
+
windowMs: 60000,
|
|
69
105
|
},
|
|
70
|
-
...config
|
|
71
106
|
};
|
|
72
107
|
this.rateLimitState = new Map();
|
|
73
108
|
}
|
|
@@ -84,10 +119,11 @@ export class SearchProviderService {
|
|
|
84
119
|
* @throws {Error} If all providers fail and no fallback is available
|
|
85
120
|
*/
|
|
86
121
|
async search(
|
|
87
|
-
query: string,
|
|
88
|
-
options: {
|
|
89
|
-
maxResults?: number;
|
|
90
|
-
provider?: string
|
|
122
|
+
query: string,
|
|
123
|
+
options: {
|
|
124
|
+
maxResults?: number;
|
|
125
|
+
provider?: string;
|
|
126
|
+
category?: string;
|
|
91
127
|
} = {}
|
|
92
128
|
): Promise<ProviderResponse> {
|
|
93
129
|
const startTime = Date.now();
|
|
@@ -95,23 +131,23 @@ export class SearchProviderService {
|
|
|
95
131
|
|
|
96
132
|
// If specific provider requested, try only that one
|
|
97
133
|
if (options.provider) {
|
|
98
|
-
return this.searchWithProvider(query, options.provider, maxResults, startTime);
|
|
134
|
+
return this.searchWithProvider(query, options.provider, maxResults, startTime, options.category);
|
|
99
135
|
}
|
|
100
|
-
|
|
136
|
+
|
|
101
137
|
// Try providers in order of preference
|
|
102
138
|
const providers = this.getAvailableProviders();
|
|
103
|
-
|
|
139
|
+
|
|
104
140
|
for (const provider of providers) {
|
|
105
141
|
try {
|
|
106
142
|
if (await this.checkRateLimit(provider)) {
|
|
107
|
-
return await this.searchWithProvider(query, provider, maxResults, startTime);
|
|
143
|
+
return await this.searchWithProvider(query, provider, maxResults, startTime, options.category);
|
|
108
144
|
}
|
|
109
145
|
} catch (error) {
|
|
110
146
|
console.warn(`Provider ${provider} failed:`, error);
|
|
111
147
|
continue;
|
|
112
148
|
}
|
|
113
149
|
}
|
|
114
|
-
|
|
150
|
+
|
|
115
151
|
// Final fallback to DuckDuckGo (no API key required)
|
|
116
152
|
return this.searchDuckDuckGo(query, maxResults, startTime);
|
|
117
153
|
}
|
|
@@ -130,10 +166,11 @@ export class SearchProviderService {
|
|
|
130
166
|
query: string,
|
|
131
167
|
provider: string,
|
|
132
168
|
maxResults: number,
|
|
133
|
-
startTime: number
|
|
169
|
+
startTime: number,
|
|
170
|
+
category?: string
|
|
134
171
|
): Promise<ProviderResponse> {
|
|
135
172
|
let results: SearchResult[];
|
|
136
|
-
|
|
173
|
+
|
|
137
174
|
switch (provider) {
|
|
138
175
|
case 'serper':
|
|
139
176
|
results = await this.searchSerper(query, maxResults);
|
|
@@ -144,6 +181,9 @@ export class SearchProviderService {
|
|
|
144
181
|
case 'brave':
|
|
145
182
|
results = await this.searchBrave(query, maxResults);
|
|
146
183
|
break;
|
|
184
|
+
case 'searxng':
|
|
185
|
+
results = await this.searchSearXNG(query, maxResults, { categories: category });
|
|
186
|
+
break;
|
|
147
187
|
case 'duckduckgo':
|
|
148
188
|
default:
|
|
149
189
|
results = await this.searchDuckDuckGoAPI(query, maxResults);
|
|
@@ -163,17 +203,17 @@ export class SearchProviderService {
|
|
|
163
203
|
|
|
164
204
|
/**
|
|
165
205
|
* Gets list of available providers based on configured API keys
|
|
166
|
-
*
|
|
167
|
-
* @private
|
|
206
|
+
*
|
|
168
207
|
* @returns {string[]} Array of available provider names
|
|
169
208
|
*/
|
|
170
|
-
|
|
209
|
+
public getAvailableProviders(): string[] {
|
|
171
210
|
const providers: string[] = [];
|
|
172
211
|
|
|
173
212
|
if (this.config.serperApiKey) providers.push('serper');
|
|
174
213
|
if (this.config.serpApiKey) providers.push('serpapi');
|
|
175
214
|
if (this.config.braveApiKey) providers.push('brave');
|
|
176
|
-
|
|
215
|
+
if (this.config.searxngUrl) providers.push('searxng');
|
|
216
|
+
|
|
177
217
|
return providers;
|
|
178
218
|
}
|
|
179
219
|
|
|
@@ -224,7 +264,7 @@ export class SearchProviderService {
|
|
|
224
264
|
|
|
225
265
|
if (!response.ok) throw new Error(`Serper API error: ${response.statusText}`);
|
|
226
266
|
|
|
227
|
-
const data = await response.json();
|
|
267
|
+
const data = (await response.json()) as any;
|
|
228
268
|
return (data.organic || []).map((item: any) => ({
|
|
229
269
|
title: item.title,
|
|
230
270
|
url: item.link,
|
|
@@ -251,7 +291,7 @@ export class SearchProviderService {
|
|
|
251
291
|
const response = await fetch(`https://serpapi.com/search?${params}`);
|
|
252
292
|
if (!response.ok) throw new Error(`SerpAPI error: ${response.statusText}`);
|
|
253
293
|
|
|
254
|
-
const data = await response.json();
|
|
294
|
+
const data = (await response.json()) as any;
|
|
255
295
|
return (data.organic_results || []).map((item: any) => ({
|
|
256
296
|
title: item.title,
|
|
257
297
|
url: item.link,
|
|
@@ -282,7 +322,7 @@ export class SearchProviderService {
|
|
|
282
322
|
|
|
283
323
|
if (!response.ok) throw new Error(`Brave API error: ${response.statusText}`);
|
|
284
324
|
|
|
285
|
-
const data = await response.json();
|
|
325
|
+
const data = (await response.json()) as any;
|
|
286
326
|
return (data.web?.results || []).map((item: any, index: number) => ({
|
|
287
327
|
title: item.title,
|
|
288
328
|
url: item.url,
|
|
@@ -350,7 +390,7 @@ export class SearchProviderService {
|
|
|
350
390
|
});
|
|
351
391
|
|
|
352
392
|
const response = await fetch(`https://api.duckduckgo.com/?${params}`);
|
|
353
|
-
const data = await response.json();
|
|
393
|
+
const data = (await response.json()) as any;
|
|
354
394
|
|
|
355
395
|
const results: SearchResult[] = [];
|
|
356
396
|
|
|
@@ -379,9 +419,169 @@ export class SearchProviderService {
|
|
|
379
419
|
return results.slice(0, maxResults);
|
|
380
420
|
}
|
|
381
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
|
+
|
|
382
582
|
/**
|
|
383
583
|
* Gets recommended search providers with signup information
|
|
384
|
-
*
|
|
584
|
+
*
|
|
385
585
|
* @static
|
|
386
586
|
* @returns {Array} Array of provider recommendations
|
|
387
587
|
*/
|
|
@@ -405,6 +605,12 @@ export class SearchProviderService {
|
|
|
405
605
|
freeQuota: '2,000 queries/month',
|
|
406
606
|
description: 'Privacy-focused search API'
|
|
407
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
|
+
},
|
|
408
614
|
{
|
|
409
615
|
name: 'DuckDuckGo',
|
|
410
616
|
signupUrl: 'No signup required',
|
|
@@ -413,4 +619,4 @@ export class SearchProviderService {
|
|
|
413
619
|
}
|
|
414
620
|
];
|
|
415
621
|
}
|
|
416
|
-
}
|
|
622
|
+
}
|