@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.
@@ -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
- maxRetries: 3,
66
- rateLimit: {
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
- private getAvailableProviders(): string[] {
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
+ }