webpeel 0.21.89 → 0.22.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.
Files changed (45) hide show
  1. package/dist/core/cross-verify.d.ts +27 -0
  2. package/dist/core/cross-verify.js +93 -0
  3. package/dist/core/google-serp-parser.d.ts +82 -0
  4. package/dist/core/google-serp-parser.js +287 -0
  5. package/dist/core/search-engines.d.ts +25 -0
  6. package/dist/core/search-engines.js +182 -0
  7. package/dist/core/search-provider.d.ts +5 -1
  8. package/dist/core/search-provider.js +15 -2
  9. package/dist/core/vertical-search.d.ts +53 -0
  10. package/dist/core/vertical-search.js +231 -0
  11. package/dist/index.d.ts +5 -0
  12. package/dist/index.js +4 -0
  13. package/dist/server/app.js +1 -1
  14. package/dist/server/routes/search.js +199 -3
  15. package/dist/server/routes/smart-search/handlers/cars.d.ts +2 -0
  16. package/dist/server/routes/smart-search/handlers/cars.js +99 -0
  17. package/dist/server/routes/smart-search/handlers/flights.d.ts +2 -0
  18. package/dist/server/routes/smart-search/handlers/flights.js +69 -0
  19. package/dist/server/routes/smart-search/handlers/general.d.ts +2 -0
  20. package/dist/server/routes/smart-search/handlers/general.js +390 -0
  21. package/dist/server/routes/smart-search/handlers/hotels.d.ts +2 -0
  22. package/dist/server/routes/smart-search/handlers/hotels.js +85 -0
  23. package/dist/server/routes/smart-search/handlers/products.d.ts +2 -0
  24. package/dist/server/routes/smart-search/handlers/products.js +213 -0
  25. package/dist/server/routes/smart-search/handlers/rental.d.ts +2 -0
  26. package/dist/server/routes/smart-search/handlers/rental.js +151 -0
  27. package/dist/server/routes/smart-search/handlers/restaurants.d.ts +2 -0
  28. package/dist/server/routes/smart-search/handlers/restaurants.js +205 -0
  29. package/dist/server/routes/smart-search/index.d.ts +19 -0
  30. package/dist/server/routes/smart-search/index.js +508 -0
  31. package/dist/server/routes/smart-search/intent.d.ts +3 -0
  32. package/dist/server/routes/smart-search/intent.js +109 -0
  33. package/dist/server/routes/smart-search/llm.d.ts +8 -0
  34. package/dist/server/routes/smart-search/llm.js +101 -0
  35. package/dist/server/routes/smart-search/sources/reddit.d.ts +18 -0
  36. package/dist/server/routes/smart-search/sources/reddit.js +34 -0
  37. package/dist/server/routes/smart-search/sources/yelp.d.ts +25 -0
  38. package/dist/server/routes/smart-search/sources/yelp.js +171 -0
  39. package/dist/server/routes/smart-search/sources/youtube.d.ts +8 -0
  40. package/dist/server/routes/smart-search/sources/youtube.js +9 -0
  41. package/dist/server/routes/smart-search/types.d.ts +30 -0
  42. package/dist/server/routes/smart-search/types.js +1 -0
  43. package/dist/server/routes/smart-search/utils.d.ts +12 -0
  44. package/dist/server/routes/smart-search/utils.js +97 -0
  45. package/package.json +1 -1
@@ -9,6 +9,9 @@ import { peel } from '../../index.js';
9
9
  import { simpleFetch } from '../../core/fetcher.js';
10
10
  import { searchCache } from '../../core/fetch-cache.js';
11
11
  import { getSearchProvider, getBestSearchProvider, } from '../../core/search-provider.js';
12
+ import { BaiduSearchProvider, YandexSearchProvider, NaverSearchProvider, YahooJapanSearchProvider } from '../../core/search-engines.js';
13
+ import { crossVerifySearch } from '../../core/cross-verify.js';
14
+ import { searchShopping, searchNews as searchNewsVertical, searchImages as searchImagesVertical, searchVideos, } from '../../core/vertical-search.js';
12
15
  import { getSourceCredibility } from '../../core/source-credibility.js';
13
16
  import { checkAndSendDualAlert } from '../email-service.js';
14
17
  import { localSearch } from '../../core/local-search.js';
@@ -34,10 +37,10 @@ export function createSearchRouter(authStore) {
34
37
  // scrapeResults=true: fetches full page content for each result (like Firecrawl's scrape_options).
35
38
  // Adds `content` field to each result. Significantly increases response time and credits used.
36
39
  // Documented in OpenAPI spec under /v1/search parameters.
37
- const { q, count, scrapeResults, enrich, sources, categories, tbs, country, location, local, language } = req.query;
40
+ const { q, count, scrapeResults, enrich, sources, categories, tbs, country, location, local, language, structured } = req.query;
38
41
  // --- Search provider (new: BYOK Brave support) ---
39
42
  const providerParam = (req.query.provider || '').toLowerCase() || 'auto';
40
- const validProviders = ['duckduckgo', 'brave', 'stealth', 'google'];
43
+ const validProviders = ['duckduckgo', 'brave', 'stealth', 'google', 'baidu', 'yandex', 'naver', 'yahoo_japan'];
41
44
  const providerId = validProviders.includes(providerParam)
42
45
  ? providerParam
43
46
  : providerParam === 'auto' ? 'auto' : 'duckduckgo';
@@ -119,7 +122,8 @@ export function createSearchRouter(authStore) {
119
122
  }
120
123
  // Build cache key (include all parameters)
121
124
  const enrichCount = enrich ? Math.min(Math.max(parseInt(enrich, 10) || 0, 0), 5) : 0;
122
- const cacheKey = `search:${providerId}:${q}:${resultCount}:${sourcesStr}:${shouldScrape}:${enrichCount}:${categoriesStr}:${tbsStr}:${countryStr}:${locationStr}`;
125
+ const isStructured = structured === 'true' || structured === '1';
126
+ const cacheKey = `search:${providerId}:${q}:${resultCount}:${sourcesStr}:${shouldScrape}:${enrichCount}:${categoriesStr}:${tbsStr}:${countryStr}:${locationStr}:${isStructured}`;
123
127
  const sharedCacheKey = searchCache.getKey(cacheKey, {});
124
128
  // Check cache (local LRU first, then shared singleton)
125
129
  const cached = cache.get(cacheKey);
@@ -160,6 +164,22 @@ export function createSearchRouter(authStore) {
160
164
  searchProvider = best.provider;
161
165
  effectiveApiKey = searchApiKey || best.apiKey;
162
166
  }
167
+ else if (providerId === 'baidu') {
168
+ searchProvider = new BaiduSearchProvider();
169
+ effectiveApiKey = undefined;
170
+ }
171
+ else if (providerId === 'yandex') {
172
+ searchProvider = new YandexSearchProvider();
173
+ effectiveApiKey = undefined;
174
+ }
175
+ else if (providerId === 'naver') {
176
+ searchProvider = new NaverSearchProvider();
177
+ effectiveApiKey = undefined;
178
+ }
179
+ else if (providerId === 'yahoo_japan') {
180
+ searchProvider = new YahooJapanSearchProvider();
181
+ effectiveApiKey = undefined;
182
+ }
163
183
  else {
164
184
  searchProvider = getSearchProvider(providerId);
165
185
  effectiveApiKey = searchApiKey || undefined;
@@ -170,12 +190,14 @@ export function createSearchRouter(authStore) {
170
190
  tbs: tbsStr || undefined,
171
191
  country: countryStr || undefined,
172
192
  location: locationStr || undefined,
193
+ structured: isStructured,
173
194
  });
174
195
  // Map to SearchResult (with optional content field)
175
196
  let results = providerResults.map(r => ({
176
197
  title: r.title,
177
198
  url: r.url,
178
199
  snippet: r.snippet,
200
+ ...(r.serp ? { serp: r.serp } : {}),
179
201
  }));
180
202
  // Apply category filtering if specified
181
203
  if (categoriesStr) {
@@ -458,5 +480,179 @@ export function createSearchRouter(authStore) {
458
480
  });
459
481
  }
460
482
  });
483
+ // ── GET /v1/search/shopping ──────────────────────────────────────────────
484
+ router.get('/v1/search/shopping', async (req, res) => {
485
+ const authId = req.auth?.keyInfo?.accountId || req.user?.userId;
486
+ if (!authId) {
487
+ res.status(401).json({ success: false, error: { type: 'authentication_required', message: 'API key required.' } });
488
+ return;
489
+ }
490
+ const { q, count, country, language } = req.query;
491
+ if (!q || typeof q !== 'string') {
492
+ res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Missing required "q" parameter.' } });
493
+ return;
494
+ }
495
+ const resultCount = count ? Math.min(Math.max(parseInt(count, 10) || 10, 1), 40) : 10;
496
+ const startTime = Date.now();
497
+ try {
498
+ const results = await searchShopping({
499
+ query: q,
500
+ count: resultCount,
501
+ country: country,
502
+ language: language,
503
+ });
504
+ const elapsed = Date.now() - startTime;
505
+ const pgStore = authStore;
506
+ if (req.auth?.keyInfo?.key && typeof pgStore.trackUsage === 'function') {
507
+ await pgStore.trackUsage(req.auth.keyInfo.key, 'search').catch(() => { });
508
+ }
509
+ res.setHeader('X-Credits-Used', '1');
510
+ res.setHeader('X-Processing-Time', elapsed.toString());
511
+ res.json({ success: true, data: { results, query: q, count: results.length, elapsed } });
512
+ }
513
+ catch (err) {
514
+ console.error('[search/shopping] error:', err);
515
+ res.status(500).json({ success: false, error: { type: 'search_failed', message: 'Shopping search failed.' } });
516
+ }
517
+ });
518
+ // ── GET /v1/search/news ──────────────────────────────────────────────────
519
+ router.get('/v1/search/news', async (req, res) => {
520
+ const authId = req.auth?.keyInfo?.accountId || req.user?.userId;
521
+ if (!authId) {
522
+ res.status(401).json({ success: false, error: { type: 'authentication_required', message: 'API key required.' } });
523
+ return;
524
+ }
525
+ const { q, count, language, freshness } = req.query;
526
+ if (!q || typeof q !== 'string') {
527
+ res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Missing required "q" parameter.' } });
528
+ return;
529
+ }
530
+ const resultCount = count ? Math.min(Math.max(parseInt(count, 10) || 10, 1), 40) : 10;
531
+ const startTime = Date.now();
532
+ try {
533
+ const results = await searchNewsVertical({
534
+ query: q,
535
+ count: resultCount,
536
+ language: language,
537
+ freshness: freshness,
538
+ });
539
+ const elapsed = Date.now() - startTime;
540
+ const pgStore = authStore;
541
+ if (req.auth?.keyInfo?.key && typeof pgStore.trackUsage === 'function') {
542
+ await pgStore.trackUsage(req.auth.keyInfo.key, 'search').catch(() => { });
543
+ }
544
+ res.setHeader('X-Credits-Used', '1');
545
+ res.setHeader('X-Processing-Time', elapsed.toString());
546
+ res.json({ success: true, data: { results, query: q, count: results.length, elapsed } });
547
+ }
548
+ catch (err) {
549
+ console.error('[search/news] error:', err);
550
+ res.status(500).json({ success: false, error: { type: 'search_failed', message: 'News search failed.' } });
551
+ }
552
+ });
553
+ // ── GET /v1/search/images ────────────────────────────────────────────────
554
+ router.get('/v1/search/images', async (req, res) => {
555
+ const authId = req.auth?.keyInfo?.accountId || req.user?.userId;
556
+ if (!authId) {
557
+ res.status(401).json({ success: false, error: { type: 'authentication_required', message: 'API key required.' } });
558
+ return;
559
+ }
560
+ const { q, count, country, language } = req.query;
561
+ if (!q || typeof q !== 'string') {
562
+ res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Missing required "q" parameter.' } });
563
+ return;
564
+ }
565
+ const resultCount = count ? Math.min(Math.max(parseInt(count, 10) || 20, 1), 50) : 20;
566
+ const startTime = Date.now();
567
+ try {
568
+ const results = await searchImagesVertical({
569
+ query: q,
570
+ count: resultCount,
571
+ country: country,
572
+ language: language,
573
+ });
574
+ const elapsed = Date.now() - startTime;
575
+ const pgStore = authStore;
576
+ if (req.auth?.keyInfo?.key && typeof pgStore.trackUsage === 'function') {
577
+ await pgStore.trackUsage(req.auth.keyInfo.key, 'search').catch(() => { });
578
+ }
579
+ res.setHeader('X-Credits-Used', '1');
580
+ res.setHeader('X-Processing-Time', elapsed.toString());
581
+ res.json({ success: true, data: { results, query: q, count: results.length, elapsed } });
582
+ }
583
+ catch (err) {
584
+ console.error('[search/images] error:', err);
585
+ res.status(500).json({ success: false, error: { type: 'search_failed', message: 'Image search failed.' } });
586
+ }
587
+ });
588
+ // ── GET /v1/search/verify ────────────────────────────────────────────────
589
+ // Cross-source verification: searches multiple engines and computes consensus
590
+ // GET /v1/search/verify?q=...&engines=google,duckduckgo,baidu&count=10
591
+ router.get('/v1/search/verify', async (req, res) => {
592
+ const authId = req.auth?.keyInfo?.accountId || req.user?.userId;
593
+ if (!authId) {
594
+ res.status(401).json({ success: false, error: { type: 'authentication_required', message: 'API key required.' } });
595
+ return;
596
+ }
597
+ const { q, engines, count } = req.query;
598
+ if (!q || typeof q !== 'string') {
599
+ res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Missing required "q" parameter.' } });
600
+ return;
601
+ }
602
+ const engineList = engines
603
+ ? engines.split(',').map(e => e.trim()).filter(Boolean)
604
+ : undefined;
605
+ const resultCount = count ? Math.min(Math.max(parseInt(count, 10) || 10, 1), 20) : 10;
606
+ const startTime = Date.now();
607
+ try {
608
+ const result = await crossVerifySearch(q, { engines: engineList, count: resultCount });
609
+ const elapsed = Date.now() - startTime;
610
+ const pgStore = authStore;
611
+ if (req.auth?.keyInfo?.key && typeof pgStore.trackUsage === 'function') {
612
+ await pgStore.trackUsage(req.auth.keyInfo.key, 'search').catch(() => { });
613
+ }
614
+ res.setHeader('X-Credits-Used', '1');
615
+ res.setHeader('X-Processing-Time', elapsed.toString());
616
+ res.json({ success: true, data: result });
617
+ }
618
+ catch (err) {
619
+ console.error('[search/verify] error:', err);
620
+ res.status(500).json({ success: false, error: { type: 'search_failed', message: 'Cross-verify search failed.' } });
621
+ }
622
+ });
623
+ // ── GET /v1/search/videos ────────────────────────────────────────────────
624
+ router.get('/v1/search/videos', async (req, res) => {
625
+ const authId = req.auth?.keyInfo?.accountId || req.user?.userId;
626
+ if (!authId) {
627
+ res.status(401).json({ success: false, error: { type: 'authentication_required', message: 'API key required.' } });
628
+ return;
629
+ }
630
+ const { q, count, language } = req.query;
631
+ if (!q || typeof q !== 'string') {
632
+ res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Missing required "q" parameter.' } });
633
+ return;
634
+ }
635
+ const resultCount = count ? Math.min(Math.max(parseInt(count, 10) || 10, 1), 20) : 10;
636
+ const startTime = Date.now();
637
+ try {
638
+ const results = await searchVideos({
639
+ query: q,
640
+ count: resultCount,
641
+ language: language,
642
+ });
643
+ const elapsed = Date.now() - startTime;
644
+ const pgStore = authStore;
645
+ if (req.auth?.keyInfo?.key && typeof pgStore.trackUsage === 'function') {
646
+ await pgStore.trackUsage(req.auth.keyInfo.key, 'search').catch(() => { });
647
+ }
648
+ res.setHeader('X-Credits-Used', '1');
649
+ res.setHeader('X-Processing-Time', elapsed.toString());
650
+ res.json({ success: true, data: { results, query: q, count: results.length, elapsed } });
651
+ }
652
+ catch (err) {
653
+ console.error('[search/videos] error:', err);
654
+ res.status(500).json({ success: false, error: { type: 'search_failed', message: 'Video search failed.' } });
655
+ }
656
+ });
461
657
  return router;
462
658
  }
@@ -0,0 +1,2 @@
1
+ import type { SearchIntent, SmartSearchResult } from '../types.js';
2
+ export declare function handleCarSearch(intent: SearchIntent): Promise<SmartSearchResult>;
@@ -0,0 +1,99 @@
1
+ import { peel } from '../../../../index.js';
2
+ import { getBestSearchProvider } from '../../../../core/search-provider.js';
3
+ import { addAffiliateTag, parsePrice } from '../utils.js';
4
+ import { callLLMQuick, sanitizeSearchQuery, PROMPT_INJECTION_DEFENSE } from '../llm.js';
5
+ export async function handleCarSearch(intent) {
6
+ const t0 = Date.now();
7
+ // Build a clean keyword: strip buying signals, price amounts, and common noise words
8
+ // NOTE: keep "car"/"cars" — they're needed for Cars.com search!
9
+ const keyword = intent.query
10
+ .replace(/\b(buy|cheap|cheapest|under|budget|price|used|new|for sale|listing|deal|best|good|find|search|looking for|want|need|in|near|around)\b/gi, '')
11
+ .replace(/[$]\d[\d,]*/g, '') // strip $30000, $30,000 etc.
12
+ .replace(/\b\d{4,}\b/g, '') // strip standalone 4+ digit numbers (prices, not model years)
13
+ // Remove location words that were already extracted to zip
14
+ .replace(/\b(long island|nassau|suffolk|manhattan|brooklyn|queens|bronx|nyc|new york|los angeles|chicago|houston|miami|boston|seattle|san francisco|washington dc)\b/gi, '')
15
+ .replace(/\s+/g, ' ')
16
+ .trim();
17
+ const params = new URLSearchParams({
18
+ keyword,
19
+ sort: 'list_price',
20
+ stock_type: 'all',
21
+ zip: intent.params.zip || '10001',
22
+ maximum_distance: '50',
23
+ });
24
+ if (intent.params.maxPrice)
25
+ params.set('list_price_max', intent.params.maxPrice);
26
+ const carSearchUrl = `https://www.cars.com/shopping/results/?${params.toString()}`;
27
+ // Search MULTIPLE car sites in parallel — first one with real listings wins
28
+ const { provider } = getBestSearchProvider();
29
+ const [carsComSettled, carGurusSettled, autotraderSettled, redditSettled] = await Promise.allSettled([
30
+ peel(carSearchUrl, { timeout: 15000 }),
31
+ provider.searchWeb(`${keyword} ${intent.params.maxPrice ? 'under $' + intent.params.maxPrice : ''} site:cargurus.com price listing`, { count: 3 }),
32
+ provider.searchWeb(`${keyword} ${intent.params.maxPrice ? 'under $' + intent.params.maxPrice : ''} site:autotrader.com price listing`, { count: 3 }),
33
+ provider.searchWeb(`${keyword} reddit review reliable problems`, { count: 3 }),
34
+ ]);
35
+ // Cars.com peel gives structured listings (best quality)
36
+ const carsComResult = carsComSettled.status === 'fulfilled' ? carsComSettled.value : null;
37
+ let carListings = carsComResult?.domainData?.structured?.listings || [];
38
+ // If Cars.com peel failed, combine results from CarGurus + Autotrader
39
+ if (carListings.length === 0) {
40
+ const carGurusResults = carGurusSettled.status === 'fulfilled' ? carGurusSettled.value : [];
41
+ const autotraderResults = autotraderSettled.status === 'fulfilled' ? autotraderSettled.value : [];
42
+ const allSearchResults = [...carGurusResults, ...autotraderResults];
43
+ carListings = allSearchResults
44
+ .filter(r => r.url && r.title)
45
+ .map(r => {
46
+ const textToSearch = `${r.title || ''} ${r.snippet || ''}`;
47
+ const price = parsePrice(textToSearch);
48
+ const isGenericPage = /\b(for sale near|cars for sale|search|browse|find)\b/i.test(r.title || '') && !price;
49
+ if (isGenericPage)
50
+ return null;
51
+ const yearMatch = (r.title || '').match(/\b(20\d{2}|19\d{2})\b/);
52
+ return {
53
+ title: r.title?.replace(/\s*[-|–—].*$/, '').trim() || 'Car Listing',
54
+ price,
55
+ year: yearMatch ? yearMatch[1] : '',
56
+ url: addAffiliateTag(r.url),
57
+ snippet: r.snippet || '',
58
+ source: (() => { try {
59
+ return new URL(r.url).hostname.replace('www.', '');
60
+ }
61
+ catch {
62
+ return '';
63
+ } })(),
64
+ };
65
+ })
66
+ .filter(Boolean)
67
+ .slice(0, 10);
68
+ }
69
+ const redditResults = redditSettled.status === 'fulfilled' ? redditSettled.value : [];
70
+ // AI synthesis: summarize top listings + Reddit input
71
+ let answer;
72
+ if (process.env.OLLAMA_URL) {
73
+ const listingSummary = carListings.slice(0, 5).map((l) => `${l.title || l.name || 'Car'}: ${l.price || 'price N/A'}, ${l.mileage || ''} miles`).join(', ');
74
+ const redditSnippets = redditResults.slice(0, 2).map(r => r.snippet || '').join(' ');
75
+ const aiPrompt = `${PROMPT_INJECTION_DEFENSE}You are a car buying advisor. The user searched: "${sanitizeSearchQuery(intent.query)}". Here are the top listings: ${listingSummary || 'no listings found'}. Reddit says: ${redditSnippets || 'no community input'}. Give a 2-3 sentence recommendation about the best value. Mention specific prices and models. Cite sources inline as [1], [2], etc. if available. Max 200 words.`;
76
+ const aiText = await callLLMQuick(aiPrompt, { maxTokens: 250, timeoutMs: 5000, temperature: 0.4 });
77
+ if (aiText && aiText.length > 20)
78
+ answer = aiText;
79
+ }
80
+ const content = carListings.length > 0
81
+ ? `# 🚗 Cars — ${intent.query}\n\n${carListings.map((l, i) => `${i + 1}. **${l.title || l.name}** — ${l.price || 'see price'}${l.mileage ? ` · ${String(l.mileage).replace(/\s*mi$/i, '')} mi` : ''}\n ${l.snippet || ''}`).join('\n\n')}`
82
+ : (carsComResult?.content || `# 🚗 Cars — ${intent.query}\n\nNo listings found. Try a different search.`);
83
+ return {
84
+ type: 'cars',
85
+ source: 'Cars.com + CarGurus + Autotrader + Reddit',
86
+ sourceUrl: carSearchUrl,
87
+ content,
88
+ title: carsComResult?.title || `Cars — ${intent.query}`,
89
+ domainData: carsComResult?.domainData,
90
+ structured: carsComResult?.domainData?.structured || (carListings.length > 0 ? { listings: carListings } : undefined),
91
+ tokens: carsComResult?.tokens || content.split(/\s+/).length,
92
+ fetchTimeMs: Date.now() - t0,
93
+ ...(answer !== undefined ? { answer } : {}),
94
+ sources: [
95
+ { type: 'cars', url: carSearchUrl, count: carListings.length },
96
+ { type: 'reddit', threads: redditResults.map(r => ({ title: r.title, url: r.url, snippet: r.snippet })) },
97
+ ],
98
+ };
99
+ }
@@ -0,0 +1,2 @@
1
+ import type { SearchIntent, SmartSearchResult } from '../types.js';
2
+ export declare function handleFlightSearch(intent: SearchIntent): Promise<SmartSearchResult>;
@@ -0,0 +1,69 @@
1
+ import { getBestSearchProvider } from '../../../../core/search-provider.js';
2
+ import { addAffiliateTag } from '../utils.js';
3
+ import { callLLMQuick, sanitizeSearchQuery, PROMPT_INJECTION_DEFENSE } from '../llm.js';
4
+ export async function handleFlightSearch(intent) {
5
+ const t0 = Date.now();
6
+ const gfUrl = `https://www.google.com/travel/flights?q=Flights+${encodeURIComponent(intent.query)}+one+way`;
7
+ // Search for actual flight prices + Reddit tips in parallel
8
+ const { provider: searchProvider } = getBestSearchProvider();
9
+ const [kayakSettled, skyscannerSettled, momondoSettled, googleSettled, redditSettled] = await Promise.allSettled([
10
+ searchProvider.searchWeb(`${intent.query} cheapest price site:kayak.com`, { count: 2 }),
11
+ searchProvider.searchWeb(`${intent.query} cheapest flights site:skyscanner.com`, { count: 3 }),
12
+ searchProvider.searchWeb(`${intent.query} cheap flights site:momondo.com OR site:cheapflights.com`, { count: 3 }),
13
+ searchProvider.searchWeb(`${intent.query} flights site:google.com/travel`, { count: 2 }),
14
+ searchProvider.searchWeb(`${intent.query} flights reddit tips cheap`, { count: 3 }),
15
+ ]);
16
+ const flightResults = [
17
+ ...(kayakSettled.status === 'fulfilled' ? kayakSettled.value : []),
18
+ ...(skyscannerSettled.status === 'fulfilled' ? skyscannerSettled.value : []),
19
+ ...(momondoSettled.status === 'fulfilled' ? momondoSettled.value : []),
20
+ ...(googleSettled.status === 'fulfilled' ? googleSettled.value : []),
21
+ ];
22
+ const redditResults = redditSettled.status === 'fulfilled' ? redditSettled.value : [];
23
+ // Build content from search results + static booking links as fallback
24
+ const searchSection = flightResults.length > 0
25
+ ? `## 🔍 Flight Results\n\n${flightResults.slice(0, 6).map((r, i) => `${i + 1}. **[${r.title}](${r.url})**\n ${r.snippet || ''}`).join('\n\n')}\n\n`
26
+ : '';
27
+ const content = `# ✈️ Flights — ${intent.query}
28
+
29
+ ${searchSection}## 📌 Book Directly
30
+
31
+ 1. **[Google Flights](${gfUrl})**
32
+ Direct link to Google Flights search
33
+
34
+ 2. **[Kayak](https://www.kayak.com/flights?a=help)**
35
+ Compare prices across all airlines
36
+
37
+ 3. **[Expedia](https://www.expedia.com/Flights)**
38
+ Flights, hotels, bundles
39
+
40
+ 4. **[Skyscanner](https://www.skyscanner.com/)**
41
+ Popular international flight search
42
+
43
+ 5. **[Momondo](https://www.momondo.com/)**
44
+ Meta-search with lowest prices
45
+
46
+ ---
47
+ `;
48
+ // AI synthesis from search results + Reddit tips
49
+ let answer;
50
+ if (process.env.OLLAMA_URL) {
51
+ const flightInfo = flightResults.slice(0, 5).map(r => `${r.title}: ${r.snippet || ''}`).join('\n');
52
+ const redditSnippets = redditResults.slice(0, 2).map(r => `${r.title}: ${r.snippet || ''}`).join('\n');
53
+ const aiPrompt = `${PROMPT_INJECTION_DEFENSE}You are a flight booking advisor. ONLY use information from the sources below. Do NOT make up prices, airlines, or routes not mentioned. User searched: "${sanitizeSearchQuery(intent.query)}". Web results: ${flightInfo || 'no results found'}. Reddit tips: ${redditSnippets || 'none'}. Give a 2-3 sentence tip about cheapest flights for this route based ONLY on the sources. Mention actual prices found and booking sites. Max 200 words. Cite sources inline as [1], [2], [3].`;
54
+ const aiText = await callLLMQuick(aiPrompt, { maxTokens: 250, timeoutMs: 5000, temperature: 0.4 });
55
+ if (aiText && aiText.length > 20)
56
+ answer = aiText;
57
+ }
58
+ return {
59
+ type: 'flights',
60
+ source: 'Flight Search',
61
+ sourceUrl: gfUrl,
62
+ content,
63
+ title: `Flights — ${intent.query}`,
64
+ structured: { listings: flightResults.slice(0, 6).map(r => ({ title: r.title, url: addAffiliateTag(r.url), snippet: r.snippet })) },
65
+ tokens: content.split(' ').length,
66
+ fetchTimeMs: Date.now() - t0,
67
+ ...(answer !== undefined ? { answer } : {}),
68
+ };
69
+ }
@@ -0,0 +1,2 @@
1
+ import type { SmartSearchResult } from '../types.js';
2
+ export declare function handleGeneralSearch(query: string): Promise<SmartSearchResult>;