@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.
@@ -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
- }