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.
- package/dist/core/cross-verify.d.ts +27 -0
- package/dist/core/cross-verify.js +93 -0
- package/dist/core/google-serp-parser.d.ts +82 -0
- package/dist/core/google-serp-parser.js +287 -0
- package/dist/core/search-engines.d.ts +25 -0
- package/dist/core/search-engines.js +182 -0
- package/dist/core/search-provider.d.ts +5 -1
- package/dist/core/search-provider.js +15 -2
- package/dist/core/vertical-search.d.ts +53 -0
- package/dist/core/vertical-search.js +231 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/server/app.js +1 -1
- package/dist/server/routes/search.js +199 -3
- package/dist/server/routes/smart-search/handlers/cars.d.ts +2 -0
- package/dist/server/routes/smart-search/handlers/cars.js +99 -0
- package/dist/server/routes/smart-search/handlers/flights.d.ts +2 -0
- package/dist/server/routes/smart-search/handlers/flights.js +69 -0
- package/dist/server/routes/smart-search/handlers/general.d.ts +2 -0
- package/dist/server/routes/smart-search/handlers/general.js +390 -0
- package/dist/server/routes/smart-search/handlers/hotels.d.ts +2 -0
- package/dist/server/routes/smart-search/handlers/hotels.js +85 -0
- package/dist/server/routes/smart-search/handlers/products.d.ts +2 -0
- package/dist/server/routes/smart-search/handlers/products.js +213 -0
- package/dist/server/routes/smart-search/handlers/rental.d.ts +2 -0
- package/dist/server/routes/smart-search/handlers/rental.js +151 -0
- package/dist/server/routes/smart-search/handlers/restaurants.d.ts +2 -0
- package/dist/server/routes/smart-search/handlers/restaurants.js +205 -0
- package/dist/server/routes/smart-search/index.d.ts +19 -0
- package/dist/server/routes/smart-search/index.js +508 -0
- package/dist/server/routes/smart-search/intent.d.ts +3 -0
- package/dist/server/routes/smart-search/intent.js +109 -0
- package/dist/server/routes/smart-search/llm.d.ts +8 -0
- package/dist/server/routes/smart-search/llm.js +101 -0
- package/dist/server/routes/smart-search/sources/reddit.d.ts +18 -0
- package/dist/server/routes/smart-search/sources/reddit.js +34 -0
- package/dist/server/routes/smart-search/sources/yelp.d.ts +25 -0
- package/dist/server/routes/smart-search/sources/yelp.js +171 -0
- package/dist/server/routes/smart-search/sources/youtube.d.ts +8 -0
- package/dist/server/routes/smart-search/sources/youtube.js +9 -0
- package/dist/server/routes/smart-search/types.d.ts +30 -0
- package/dist/server/routes/smart-search/types.js +1 -0
- package/dist/server/routes/smart-search/utils.d.ts +12 -0
- package/dist/server/routes/smart-search/utils.js +97 -0
- 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
|
|
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,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,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
|
+
}
|