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
@@ -0,0 +1,390 @@
1
+ import { peel } from '../../../../index.js';
2
+ import { getBestSearchProvider, } from '../../../../core/search-provider.js';
3
+ import { getSourceCredibility } from '../../../../core/source-credibility.js';
4
+ import { callLLMQuick, sanitizeSearchQuery, PROMPT_INJECTION_DEFENSE } from '../llm.js';
5
+ export async function handleGeneralSearch(query) {
6
+ const t0 = Date.now();
7
+ // Equipment rental / service business enhancement via Google Places
8
+ const GOOGLE_PLACES_KEY = process.env.GOOGLE_PLACES_API_KEY;
9
+ const isEquipmentRental = /\b(rent|rental|renting|hire|lease)\b/.test(query) && /\b(forklift|dumpster|pressure washer|generator|excavator|bobcat|crane|scaffolding|tent|truck|van|trailer|equipment|tool|power tool)\b/.test(query);
10
+ const isServiceBusiness = /\b(plumber|electrician|mechanic|dentist|doctor|lawyer|locksmith|handyman|contractor|vet|salon|barber|spa|gym|daycare|moving|storage|cleaning|pest control|roofing|hvac|landscaping)\b/.test(query) && /\b(near|in|around|open|best|cheap|emergency|24.hour)\b/.test(query);
11
+ const isGasStation = /\b(gas|gasoline|fuel|gas station|petrol|diesel)\b/.test(query) && /\b(cheap|cheapest|price|near|closest|best)\b/.test(query);
12
+ const isTravelBooking = /\b(cruise|vacation|resort|all.inclusive|trip|package|tour|excursion|safari|honeymoon|disneyland|disney world|disney cruise|universal|theme park|spring break)\b/.test(query) && /\b(cheap|cheapest|price|ticket|book|deal|cost|per person)\b/.test(query);
13
+ let localBusinesses = [];
14
+ // ── Try Places API (New) for gas stations (has fuel prices) ──────────────
15
+ if (isGasStation && GOOGLE_PLACES_KEY) {
16
+ try {
17
+ const newApiRes = await fetch('https://places.googleapis.com/v1/places:searchText', {
18
+ method: 'POST',
19
+ headers: {
20
+ 'Content-Type': 'application/json',
21
+ 'X-Goog-Api-Key': GOOGLE_PLACES_KEY,
22
+ 'X-Goog-FieldMask': 'places.displayName,places.formattedAddress,places.fuelOptions,places.rating,places.userRatingCount,places.currentOpeningHours,places.googleMapsUri,places.location',
23
+ },
24
+ body: JSON.stringify({ textQuery: query, maxResultCount: 10 }),
25
+ signal: AbortSignal.timeout(5000),
26
+ });
27
+ if (newApiRes.ok) {
28
+ const data = await newApiRes.json();
29
+ if (data.places?.length > 0) {
30
+ const shortDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
31
+ const dayMap = { Monday: 'Mon', Tuesday: 'Tue', Wednesday: 'Wed', Thursday: 'Thu', Friday: 'Fri', Saturday: 'Sat', Sunday: 'Sun' };
32
+ const today = shortDays[new Date().getDay()];
33
+ localBusinesses = data.places.map((p) => {
34
+ // Parse fuel prices
35
+ const fuelPrices = {};
36
+ if (p.fuelOptions?.fuelPrices) {
37
+ for (const fp of p.fuelOptions.fuelPrices) {
38
+ const price = fp.price ? `$${fp.price.units || 0}.${String(fp.price.nanos || 0).padStart(9, '0').substring(0, 2)}` : null;
39
+ if (price) {
40
+ const typeMap = {
41
+ 'REGULAR_UNLEADED': 'Regular',
42
+ 'MIDGRADE': 'Midgrade',
43
+ 'PREMIUM': 'Premium',
44
+ 'DIESEL': 'Diesel',
45
+ 'E85': 'E85',
46
+ };
47
+ fuelPrices[typeMap[fp.type] || fp.type] = price;
48
+ }
49
+ }
50
+ }
51
+ // Parse hours
52
+ const hours = {};
53
+ if (p.currentOpeningHours?.weekdayDescriptions) {
54
+ for (const desc of p.currentOpeningHours.weekdayDescriptions) {
55
+ const colonIdx = desc.indexOf(':');
56
+ if (colonIdx > 0) {
57
+ const dayFull = desc.substring(0, colonIdx).trim();
58
+ const timeStr = desc.substring(colonIdx + 1).trim();
59
+ if (dayMap[dayFull])
60
+ hours[dayMap[dayFull]] = timeStr;
61
+ }
62
+ }
63
+ }
64
+ return {
65
+ name: p.displayName?.text || 'Gas Station',
66
+ address: p.formattedAddress || '',
67
+ rating: p.rating,
68
+ reviewCount: p.userRatingCount || 0,
69
+ isOpenNow: p.currentOpeningHours?.openNow,
70
+ todayHours: hours[today] || '',
71
+ googleMapsUrl: p.googleMapsUri || '',
72
+ fuelPrices,
73
+ latitude: p.location?.latitude,
74
+ longitude: p.location?.longitude,
75
+ businessStatus: 'OPERATIONAL',
76
+ };
77
+ });
78
+ console.log(`[smart-search] Places API (New) returned ${localBusinesses.length} gas stations`);
79
+ }
80
+ }
81
+ }
82
+ catch { /* New API failed — fall through to legacy */ }
83
+ }
84
+ // ── Legacy Google Places search (used when Places API New is unavailable or non-gas queries) ──
85
+ if (localBusinesses.length === 0 && (isEquipmentRental || isServiceBusiness || isGasStation) && GOOGLE_PLACES_KEY) {
86
+ try {
87
+ // Use Google Places Text Search
88
+ const findRes = await fetch(`https://maps.googleapis.com/maps/api/place/textsearch/json?query=${encodeURIComponent(query)}&key=${GOOGLE_PLACES_KEY}`, { signal: AbortSignal.timeout(5000) });
89
+ if (findRes.ok) {
90
+ const findData = await findRes.json();
91
+ if (findData.status === 'OK' && findData.results?.length > 0) {
92
+ // Get details for top 3 (hours, phone, etc.)
93
+ const top5 = findData.results.slice(0, 5);
94
+ const details = await Promise.allSettled(top5.slice(0, 3).map(async (place) => {
95
+ const detailRes = await fetch(`https://maps.googleapis.com/maps/api/place/details/json?place_id=${place.place_id}&fields=name,formatted_phone_number,opening_hours,rating,user_ratings_total,url,formatted_address,website,business_status&key=${GOOGLE_PLACES_KEY}`, { signal: AbortSignal.timeout(3000) });
96
+ if (!detailRes.ok)
97
+ return null;
98
+ const detailData = await detailRes.json();
99
+ return detailData.result || null;
100
+ }));
101
+ localBusinesses = top5.map((place, i) => {
102
+ const detail = details[i]?.status === 'fulfilled' ? details[i].value : null;
103
+ const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
104
+ const today = dayNames[new Date().getDay()];
105
+ const todayHours = detail?.opening_hours?.weekday_text?.find((h) => {
106
+ const dayMap = { Monday: 'Mon', Tuesday: 'Tue', Wednesday: 'Wed', Thursday: 'Thu', Friday: 'Fri', Saturday: 'Sat', Sunday: 'Sun' };
107
+ return Object.entries(dayMap).some(([full, short]) => h.startsWith(full) && short === today);
108
+ })?.split(': ').slice(1).join(': ') || '';
109
+ return {
110
+ name: detail?.name || place.name,
111
+ address: detail?.formatted_address || place.formatted_address || '',
112
+ phone: detail?.formatted_phone_number || '',
113
+ rating: detail?.rating || place.rating,
114
+ reviewCount: detail?.user_ratings_total || place.user_ratings_total || 0,
115
+ isOpenNow: detail?.opening_hours?.open_now ?? place.opening_hours?.open_now,
116
+ todayHours,
117
+ website: detail?.website || '',
118
+ googleMapsUrl: detail?.url || '',
119
+ mapEmbedUrl: `https://www.google.com/maps/embed/v1/place?q=place_id:${place.place_id}&key=${GOOGLE_PLACES_KEY}`,
120
+ latitude: place.geometry?.location?.lat,
121
+ longitude: place.geometry?.location?.lng,
122
+ businessStatus: detail?.business_status || place.business_status || 'OPERATIONAL',
123
+ };
124
+ }).filter((b) => b.businessStatus === 'OPERATIONAL');
125
+ }
126
+ }
127
+ }
128
+ catch { /* Google Places failed — continue with web search */ }
129
+ }
130
+ const { provider: searchProvider } = getBestSearchProvider();
131
+ const rawResults = await searchProvider.searchWeb(query, { count: 10 });
132
+ const searchMs = Date.now() - t0;
133
+ const getDomain = (url) => {
134
+ try {
135
+ return new URL(url).hostname.replace(/^www\./, '');
136
+ }
137
+ catch {
138
+ return '';
139
+ }
140
+ };
141
+ const tierOrder = { official: 0, established: 1, community: 2, new: 3, suspicious: 4 };
142
+ let results = rawResults
143
+ .map((r) => {
144
+ const cred = getSourceCredibility(r.url);
145
+ return {
146
+ title: r.title,
147
+ url: r.url,
148
+ snippet: r.snippet,
149
+ domain: getDomain(r.url),
150
+ credibility: cred,
151
+ };
152
+ })
153
+ .sort((a, b) => {
154
+ const aTier = tierOrder[a.credibility?.tier || 'new'] ?? 3;
155
+ const bTier = tierOrder[b.credibility?.tier || 'new'] ?? 3;
156
+ return aTier - bTier;
157
+ })
158
+ .map((r, i) => ({ ...r, rank: i + 1 }));
159
+ // Enrich top 5 results — 6s timeout so LLM has more to work with
160
+ const tPeel = Date.now();
161
+ const top5 = results.slice(0, 5);
162
+ console.log(`[smart-search] handleGeneralSearch: enriching ${top5.length} pages via peel`);
163
+ const enriched = await Promise.allSettled(top5.map(async (r) => {
164
+ try {
165
+ const peeled = await peel(r.url, { timeout: 4000, maxTokens: 2000 });
166
+ return {
167
+ url: r.url,
168
+ content: peeled.content?.substring(0, 2000),
169
+ title: peeled.title || r.title,
170
+ fetchTimeMs: peeled.elapsed,
171
+ metadata: peeled.metadata,
172
+ structured: peeled.domainData?.structured,
173
+ };
174
+ }
175
+ catch {
176
+ return { url: r.url, content: null, title: r.title, fetchTimeMs: 0, metadata: undefined, structured: undefined };
177
+ }
178
+ }));
179
+ const peelMs = Date.now() - tPeel;
180
+ // Check if any peel succeeded; if none did, skip LLM and return raw results
181
+ const anyPeelSucceeded = enriched.some((s) => s.status === 'fulfilled' && s.value.content !== null);
182
+ for (const settled of enriched) {
183
+ if (settled.status === 'fulfilled' && settled.value.content) {
184
+ const match = results.find((r) => r.url === settled.value.url);
185
+ if (match) {
186
+ match.content = settled.value.content;
187
+ match.fetchTimeMs = settled.value.fetchTimeMs;
188
+ }
189
+ }
190
+ }
191
+ let content = results
192
+ .map((r, i) => `${i + 1}. **${r.title}**\n ${r.url}\n ${r.snippet}`)
193
+ .join('\n\n');
194
+ // For equipment rentals and gas stations, also search for pricing data
195
+ let pricingInfo = '';
196
+ if (isGasStation) {
197
+ try {
198
+ const locMatch = query.match(/\b(?:in|near|around)\s+([a-z\s]+?)(?:\s+(?:under|below|cheap|\$).*)?$/i);
199
+ const gasLocation = locMatch ? locMatch[1].trim() : 'New York';
200
+ const gasPriceResults = await searchProvider.searchWeb(`gas prices ${gasLocation} per gallon today cheapest gasbuddy`, { count: 3 });
201
+ const gasPrices = [];
202
+ for (const r of gasPriceResults) {
203
+ const text = `${r.title || ''} ${r.snippet || ''}`;
204
+ const priceMatches = text.match(/\$\d+\.\d{2}/g);
205
+ if (priceMatches)
206
+ gasPrices.push(...priceMatches);
207
+ }
208
+ if (gasPrices.length > 0) {
209
+ const uniquePrices = [...new Set(gasPrices)].sort((a, b) => parseFloat(a.slice(1)) - parseFloat(b.slice(1)));
210
+ pricingInfo = `\n\n## ⛽ Gas Prices (${gasLocation})\n${uniquePrices.slice(0, 8).map(p => `- ${p}/gal`).join('\n')}`;
211
+ // Add pricing snippets for AI
212
+ for (const r of gasPriceResults.slice(0, 2)) {
213
+ if (r.snippet?.match(/\$/)) {
214
+ results.push({
215
+ title: r.title,
216
+ url: r.url,
217
+ snippet: r.snippet,
218
+ domain: getDomain(r.url),
219
+ content: r.snippet,
220
+ isPricing: true,
221
+ });
222
+ }
223
+ }
224
+ }
225
+ }
226
+ catch { /* gas price search failed — non-fatal */ }
227
+ }
228
+ else if (isTravelBooking) {
229
+ try {
230
+ // Search specifically for prices + comparison across providers
231
+ const travelPriceResults = await searchProvider.searchWeb(`${query} price per person comparison cheapest 2026 site:cruisefever.net OR site:cruisecritic.com OR site:vacationstogo.com OR site:costcotravel.com OR site:kayak.com`, { count: 3 });
232
+ const travelPrices = [];
233
+ for (const r of travelPriceResults) {
234
+ const text = `${r.title || ''} ${r.snippet || ''}`;
235
+ const priceMatches = text.match(/\$[\d,]+(?:\s*(?:per person|pp|\/person))?/gi);
236
+ if (priceMatches)
237
+ travelPrices.push(...priceMatches.slice(0, 4));
238
+ }
239
+ if (travelPrices.length > 0) {
240
+ pricingInfo = `\n\n## 💰 Pricing Found\n${[...new Set(travelPrices)].slice(0, 8).map(p => `- ${p}`).join('\n')}`;
241
+ }
242
+ // Peel the top comparison page for detailed data
243
+ const comparisonPage = travelPriceResults[0];
244
+ if (comparisonPage?.url) {
245
+ try {
246
+ const peeled = await peel(comparisonPage.url, { timeout: 6000, maxTokens: 3000 });
247
+ if (peeled.content && peeled.content.length > 200) {
248
+ results.push({
249
+ title: comparisonPage.title,
250
+ url: comparisonPage.url,
251
+ snippet: comparisonPage.snippet,
252
+ domain: getDomain(comparisonPage.url),
253
+ content: peeled.content.substring(0, 3000),
254
+ isPricing: true,
255
+ });
256
+ }
257
+ }
258
+ catch { /* peel failed — use snippet */ }
259
+ }
260
+ // Add remaining results for sources
261
+ for (const r of travelPriceResults.slice(1, 3)) {
262
+ if (r.snippet) {
263
+ results.push({
264
+ title: r.title,
265
+ url: r.url,
266
+ snippet: r.snippet,
267
+ domain: getDomain(r.url),
268
+ content: r.snippet,
269
+ isPricing: true,
270
+ });
271
+ }
272
+ }
273
+ }
274
+ catch { /* travel price search failed */ }
275
+ }
276
+ else if (isEquipmentRental) {
277
+ try {
278
+ const pricingResults = await searchProvider.searchWeb(`${query} cost price per day rate 2025`, { count: 3 });
279
+ const prices = [];
280
+ for (const r of pricingResults) {
281
+ const text = `${r.title || ''} ${r.snippet || ''}`;
282
+ // Extract price ranges like "$140-$160 per day" or "$210 to $1,200"
283
+ const priceMatches = text.match(/\$[\d,]+(?:\s*[-–to]+\s*\$[\d,]+)?(?:\s*(?:per|\/)\s*(?:day|week|month|hour))?/gi);
284
+ if (priceMatches) {
285
+ prices.push(...priceMatches.slice(0, 3));
286
+ }
287
+ }
288
+ if (prices.length > 0) {
289
+ pricingInfo = `\n\n## 💰 Typical Pricing\n${[...new Set(prices)].slice(0, 6).map(p => `- ${p}`).join('\n')}`;
290
+ // Also add pricing snippets to the sources for AI to reference
291
+ for (const r of pricingResults.slice(0, 2)) {
292
+ if (r.snippet?.match(/\$/)) {
293
+ results.push({
294
+ title: r.title,
295
+ url: r.url,
296
+ snippet: r.snippet,
297
+ domain: getDomain(r.url),
298
+ content: r.snippet,
299
+ isPricing: true,
300
+ });
301
+ }
302
+ }
303
+ }
304
+ }
305
+ catch { /* pricing search failed — non-fatal */ }
306
+ }
307
+ // If we found local businesses via Google Places, prepend them
308
+ if (localBusinesses.length > 0) {
309
+ const localContent = localBusinesses.map((b, i) => {
310
+ const status = b.isOpenNow ? '🟢 Open Now' : '🔴 Closed';
311
+ return `${i + 1}. **${b.name}** ⭐${b.rating || '?'} (${b.reviewCount} reviews) — ${status}${b.todayHours ? ` · 🕐 ${b.todayHours}` : ''}
312
+ 📍 ${b.address}${b.phone ? ` · 📞 ${b.phone}` : ''}${b.website ? ` · [Website](${b.website})` : ''}${b.googleMapsUrl ? ` · [📍 Map](${b.googleMapsUrl})` : ''}`;
313
+ }).join('\n\n');
314
+ content = `## 📍 Nearby Businesses\n\n${localContent}${pricingInfo}\n\n---\n\n## 🔍 Web Results\n\n${content}`;
315
+ // Also add to results array for structured rendering
316
+ results.unshift(...localBusinesses.map((b, i) => ({
317
+ title: b.name,
318
+ url: b.googleMapsUrl || b.website || '#',
319
+ snippet: `⭐${b.rating || '?'} (${b.reviewCount} reviews) · ${b.isOpenNow ? '🟢 Open' : '🔴 Closed'}${b.todayHours ? ' · ' + b.todayHours : ''} · ${b.address}${b.phone ? ' · 📞 ' + b.phone : ''}${b.fuelPrices && Object.keys(b.fuelPrices).length > 0 ? ' · ⛽ ' + Object.entries(b.fuelPrices).map(([type, price]) => type + ': ' + price + '/gal').join(' | ') : ''}`,
320
+ domain: 'google.com/maps',
321
+ rank: i + 1,
322
+ isLocalBusiness: true,
323
+ isOpenNow: b.isOpenNow,
324
+ ...(b.fuelPrices && Object.keys(b.fuelPrices).length > 0 ? { fuelPrices: b.fuelPrices } : {}),
325
+ })));
326
+ }
327
+ // Build sources array from successfully peeled results (all 5)
328
+ const sources = enriched
329
+ .filter((s) => s.status === 'fulfilled' && s.value.content !== null)
330
+ .map((s) => {
331
+ const v = s.value;
332
+ return {
333
+ title: v.title,
334
+ url: v.url,
335
+ domain: getDomain(v.url),
336
+ };
337
+ });
338
+ // ── AI Synthesis via Qwen/Ollama ──────────────────────────────────────
339
+ let answer;
340
+ let llmMs = 0;
341
+ const ollamaUrl = process.env.OLLAMA_URL;
342
+ // Only call LLM if at least one page was successfully peeled
343
+ if (ollamaUrl && anyPeelSucceeded) {
344
+ try {
345
+ // Build numbered source content for the LLM
346
+ const sourceContent = enriched
347
+ .map((s, i) => {
348
+ if (s.status !== 'fulfilled' || !s.value.content)
349
+ return null;
350
+ const v = s.value;
351
+ // Include structured data if available
352
+ const structuredInfo = v.structured ? `\nKey data: ${JSON.stringify(v.structured).substring(0, 300)}` : '';
353
+ return `[${i + 1}] ${v.title}\nURL: ${v.url}${structuredInfo}\n\n${v.content?.substring(0, 1500) || ''}`;
354
+ })
355
+ .filter(Boolean)
356
+ .join('\n\n---\n\n');
357
+ const systemPrompt = `${PROMPT_INJECTION_DEFENSE}Answer the query using these sources. Be specific with names, numbers, dates, and prices. Bold key facts. Cite sources inline as [1], [2], [3] etc. At the end, list Sources with their URLs. If sources disagree, note the difference.${isEquipmentRental ? ' IMPORTANT: Include specific rental prices/rates per day or week if available in the sources. Mention the cheapest option.' : ''}${isServiceBusiness ? ' IMPORTANT: Include business hours, phone numbers, and whether they are open now.' : ''}${isGasStation ? ' IMPORTANT: Include gas prices per gallon if available. Mention the cheapest station, its address, and current price. Sort by price.' : ''}${isTravelBooking ? ' IMPORTANT: List specific prices per person for different cruise lines/options. Format as a comparison: cruise line, ship name, duration, departure port, price. Sort cheapest first. Include dates if available.' : ''} Max 200 words.`;
358
+ // Truncate source content to 1500 chars total for better quality answers
359
+ const truncatedSources = sourceContent.substring(0, 1500);
360
+ const userMessage = `Query: ${sanitizeSearchQuery(query)}\n\nSources:\n${truncatedSources}`;
361
+ const tLlm = Date.now();
362
+ const text = await callLLMQuick(`${systemPrompt}\n\n${userMessage}`, { maxTokens: 250, timeoutMs: 5000, temperature: 0.3 });
363
+ console.log(`[smart-search] Ollama answered: ${text.length} chars`);
364
+ if (text) {
365
+ answer = text;
366
+ }
367
+ llmMs = Date.now() - tLlm;
368
+ }
369
+ catch (err) {
370
+ // Graceful degradation: LLM failure → return raw results without answer
371
+ console.warn('General search LLM synthesis failed (graceful fallback):', err.message);
372
+ }
373
+ }
374
+ const mapUrl = localBusinesses.length > 0 && GOOGLE_PLACES_KEY
375
+ ? `https://www.google.com/maps/embed/v1/search?q=${encodeURIComponent(query)}&key=${GOOGLE_PLACES_KEY}`
376
+ : undefined;
377
+ return {
378
+ type: 'general',
379
+ source: 'Web Search',
380
+ sourceUrl: `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`,
381
+ content,
382
+ results,
383
+ tokens: content.split(/\s+/).length,
384
+ fetchTimeMs: Date.now() - t0,
385
+ ...(answer !== undefined ? { answer } : {}),
386
+ ...(sources.length > 0 ? { sources } : {}),
387
+ timing: { searchMs, peelMs, llmMs },
388
+ ...(mapUrl ? { mapUrl } : {}),
389
+ };
390
+ }
@@ -0,0 +1,2 @@
1
+ import type { SearchIntent, SmartSearchResult } from '../types.js';
2
+ export declare function handleHotelSearch(intent: SearchIntent): Promise<SmartSearchResult>;
@@ -0,0 +1,85 @@
1
+ import { getBestSearchProvider } from '../../../../core/search-provider.js';
2
+ import { addAffiliateTag, parsePrice, extractPriceValue } from '../utils.js';
3
+ import { callLLMQuick, sanitizeSearchQuery, PROMPT_INJECTION_DEFENSE } from '../llm.js';
4
+ export async function handleHotelSearch(intent) {
5
+ const t0 = Date.now();
6
+ const ghUrl = `https://www.google.com/travel/hotels?q=${encodeURIComponent(intent.query)}`;
7
+ // Extract location from query: "hotels in boston" → "boston"
8
+ const hotelLocMatch = intent.query.match(/\b(?:in|near|at|around)\s+(.+?)(?:\s+(?:under|below|for|cheap|\$|from|per).*)?$/i);
9
+ const hotelLocation = hotelLocMatch ? hotelLocMatch[1].trim() : intent.query.replace(/\b(hotel|hotels|motel|stay|accommodation|lodging|inn|resort|airbnb|hostel|book|cheap|best)\b/gi, '').trim();
10
+ // Search for actual hotel prices + Reddit tips in parallel
11
+ const { provider: searchProvider } = getBestSearchProvider();
12
+ const [bookingSettled, kayakSettled, expediaSettled, tripadvisorSettled, redditSettled] = await Promise.allSettled([
13
+ searchProvider.searchWeb(`hotel ${hotelLocation} price per night site:booking.com`, { count: 3 }),
14
+ searchProvider.searchWeb(`hotel ${hotelLocation} cheapest site:kayak.com`, { count: 3 }),
15
+ searchProvider.searchWeb(`hotel ${hotelLocation} deals site:expedia.com OR site:hotels.com`, { count: 3 }),
16
+ searchProvider.searchWeb(`hotel ${hotelLocation} best rated site:tripadvisor.com`, { count: 2 }),
17
+ searchProvider.searchWeb(`best hotel ${hotelLocation} reddit tips deal`, { count: 3 }),
18
+ ]);
19
+ const hotelResults = [
20
+ ...(bookingSettled.status === 'fulfilled' ? bookingSettled.value : []),
21
+ ...(kayakSettled.status === 'fulfilled' ? kayakSettled.value : []),
22
+ ...(expediaSettled.status === 'fulfilled' ? expediaSettled.value : []),
23
+ ...(tripadvisorSettled.status === 'fulfilled' ? tripadvisorSettled.value : []),
24
+ ];
25
+ const redditResults = redditSettled.status === 'fulfilled' ? redditSettled.value : [];
26
+ // Parse prices and sort by price
27
+ const parsedHotels = hotelResults
28
+ .map(r => {
29
+ const textToSearch = `${r.title || ''} ${r.snippet || ''}`;
30
+ const price = parsePrice(textToSearch);
31
+ const priceValue = extractPriceValue(price);
32
+ return { ...r, price, priceValue };
33
+ })
34
+ .sort((a, b) => {
35
+ const aVal = a.priceValue ?? Infinity;
36
+ const bVal = b.priceValue ?? Infinity;
37
+ return aVal - bVal;
38
+ });
39
+ // Build content from search results + static booking links as fallback
40
+ const searchSection = parsedHotels.length > 0
41
+ ? `## 🔍 Hotel Results\n\n${parsedHotels.slice(0, 6).map((r, i) => `${i + 1}. **[${r.title}](${r.url})**${r.price ? ` — ${r.price}/night` : ''}\n ${r.snippet || ''}`).join('\n\n')}\n\n`
42
+ : '';
43
+ const content = `# 🏨 Hotels — ${intent.query}
44
+
45
+ ${searchSection}## 📌 Book Directly
46
+
47
+ 1. **[Booking.com](https://www.booking.com)**
48
+ Largest selection, competitive prices
49
+
50
+ 2. **[Hotels.com](https://www.hotels.com)**
51
+ Free night rewards program
52
+
53
+ 3. **[Expedia](https://www.expedia.com/Hotels)**
54
+ Bundle with flights for discounts
55
+
56
+ 4. **[Airbnb](https://www.airbnb.com)**
57
+ Apartments, houses, unique stays
58
+
59
+ 5. **[Google Hotels](${ghUrl})**
60
+ Compare prices across all sites
61
+
62
+ ---
63
+ `;
64
+ // AI synthesis from search results + Reddit tips
65
+ let answer;
66
+ if (process.env.OLLAMA_URL) {
67
+ const hotelInfo = parsedHotels.slice(0, 5).map(r => `${r.title}${r.price ? `: ${r.price}/night` : ''} — ${r.snippet || ''}`).join('\n');
68
+ const redditSnippets = redditResults.slice(0, 2).map(r => `${r.title}: ${r.snippet || ''}`).join('\n');
69
+ const aiPrompt = `${PROMPT_INJECTION_DEFENSE}You are a hotel booking advisor. ONLY use information from the sources below. Do NOT make up hotel names or prices not mentioned. User searched: "${sanitizeSearchQuery(intent.query)}". Hotels found: ${hotelInfo || 'no results found'}. Reddit tips: ${redditSnippets || 'none'}. Give a 2-3 sentence recommendation based ONLY on the sources. Mention the cheapest option and actual price if available. Max 200 words. Cite sources inline as [1], [2], [3].`;
70
+ const aiText = await callLLMQuick(aiPrompt, { maxTokens: 250, timeoutMs: 5000, temperature: 0.4 });
71
+ if (aiText && aiText.length > 20)
72
+ answer = aiText;
73
+ }
74
+ return {
75
+ type: 'hotels',
76
+ source: 'Hotel Search',
77
+ sourceUrl: ghUrl,
78
+ content,
79
+ title: `Hotels — ${intent.query}`,
80
+ structured: { listings: parsedHotels.slice(0, 6).map(r => ({ title: r.title, url: addAffiliateTag(r.url), snippet: r.snippet, price: r.price })) },
81
+ tokens: content.split(' ').length,
82
+ fetchTimeMs: Date.now() - t0,
83
+ ...(answer !== undefined ? { answer } : {}),
84
+ };
85
+ }
@@ -0,0 +1,2 @@
1
+ import type { SearchIntent, SmartSearchResult } from '../types.js';
2
+ export declare function handleProductSearch(intent: SearchIntent): Promise<SmartSearchResult>;