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
|
@@ -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,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
|
+
}
|