← back to Wine Finder Next
lib/services/wineAggregator.js
288 lines
const klWineScraper = require('./klWineScraper');
const vivinoScraper = require('./vivinoScraper');
const totalWineScraper = require('./totalWineScraper');
const wineSensedScraper = require('./wineSensedScraper');
const xWinesScraper = require('./xWinesScraper');
const ucDavisAVAScraper = require('./ucDavisAVAScraper');
const wineEnthusiastScraper = require('./wineEnthusiastScraper');
class WineAggregator {
constructor() {
this.sources = {
'kl': klWineScraper,
'vivino': vivinoScraper,
'totalwine': totalWineScraper,
'winesensed': wineSensedScraper,
'xwines': xWinesScraper,
'ucdavis-ava': ucDavisAVAScraper,
'wineenthusiast': wineEnthusiastScraper
};
}
// Search all sources and aggregate results
async searchAll(query, options = {}) {
const {
sources = ['kl', 'vivino', 'totalwine', 'winesensed', 'xwines', 'ucdavis-ava', 'wineenthusiast'],
deduplicate = true,
sortBy = 'price', // 'price', 'rating', 'name', 'source'
limit = 100
} = options;
const results = [];
const errors = [];
// Search all sources in parallel
const promises = sources.map(async (sourceName) => {
const scraper = this.sources[sourceName];
if (!scraper) {
errors.push({ source: sourceName, error: 'Unknown source' });
return null;
}
try {
const result = await scraper.search(query);
if (result.success && result.wines.length > 0) {
return {
source: sourceName,
wines: result.wines,
count: result.count
};
} else if (!result.success) {
errors.push({ source: sourceName, error: result.error || 'Search failed' });
}
return null;
} catch (error) {
errors.push({ source: sourceName, error: error.message });
return null;
}
});
const sourceResults = await Promise.all(promises);
// Combine all wines
let allWines = [];
for (const result of sourceResults) {
if (result && result.wines) {
allWines.push(...result.wines);
results.push({
source: result.source,
count: result.count
});
}
}
// Deduplicate wines by name similarity
if (deduplicate) {
allWines = this.deduplicateWines(allWines);
}
// Sort wines
allWines = this.sortWines(allWines, sortBy);
// Apply limit
if (limit && allWines.length > limit) {
allWines = allWines.slice(0, limit);
}
return {
success: true,
wines: allWines,
count: allWines.length,
sources: results,
errors: errors.length > 0 ? errors : undefined,
timestamp: new Date().toISOString()
};
}
// Deduplicate wines based on name similarity
deduplicateWines(wines) {
const uniqueWines = [];
const seen = new Map();
for (const wine of wines) {
// Normalize name for comparison
const normalizedName = wine.name
.toLowerCase()
.replace(/[^a-z0-9]/g, '')
.substring(0, 40); // Compare first 40 chars
if (!seen.has(normalizedName)) {
seen.set(normalizedName, wine);
uniqueWines.push(wine);
} else {
// If duplicate, merge data or keep better version
const existing = seen.get(normalizedName);
const existingIndex = uniqueWines.findIndex(w =>
w.name.toLowerCase().replace(/[^a-z0-9]/g, '').substring(0, 40) === normalizedName
);
if (existingIndex !== -1) {
// Prefer wine with more complete data
let shouldReplace = false;
// Prefer wine with price info
if (wine.price && !existing.price) {
shouldReplace = true;
}
// If both have prices, keep cheaper one
else if (wine.price && existing.price && wine.price < existing.price) {
shouldReplace = true;
}
// If no price difference, prefer one with rating
else if (wine.rating && !existing.rating) {
shouldReplace = true;
}
// If both have ratings, prefer higher rating
else if (wine.rating && existing.rating && wine.rating > existing.rating) {
shouldReplace = true;
}
// Prefer Vivino (most data) over others
else if (wine.source === 'Vivino' && existing.source !== 'Vivino') {
shouldReplace = true;
}
if (shouldReplace) {
uniqueWines[existingIndex] = wine;
seen.set(normalizedName, wine);
}
}
}
}
return uniqueWines;
}
// Sort wines by various criteria
sortWines(wines, sortBy) {
switch (sortBy) {
case 'price':
case 'price-asc':
return wines.sort((a, b) => {
if (!a.price) return 1;
if (!b.price) return -1;
return a.price - b.price;
});
case 'price-desc':
return wines.sort((a, b) => {
if (!a.price) return 1;
if (!b.price) return -1;
return b.price - a.price;
});
case 'rating':
case 'rating-desc':
return wines.sort((a, b) => {
if (!a.rating) return 1;
if (!b.rating) return -1;
return b.rating - a.rating; // Descending
});
case 'name':
return wines.sort((a, b) => a.name.localeCompare(b.name));
case 'source':
return wines.sort((a, b) => a.source.localeCompare(b.source));
default:
return wines;
}
}
// Get best price for a wine across all sources
async findBestPrice(query) {
const result = await this.searchAll(query, {
deduplicate: false,
sortBy: 'price'
});
if (result.wines.length === 0) {
return {
success: false,
wines: [],
count: 0,
message: 'No wines found'
};
}
// Group by wine name (fuzzy matching)
const wineGroups = {};
for (const wine of result.wines) {
const key = wine.name.toLowerCase().replace(/[^a-z0-9]/g, '').substring(0, 40);
if (!wineGroups[key]) {
wineGroups[key] = [];
}
wineGroups[key].push(wine);
}
// Find best price for each wine
const bestPrices = [];
for (const [key, wines] of Object.entries(wineGroups)) {
const withPrices = wines.filter(w => w.price);
if (withPrices.length > 0) {
withPrices.sort((a, b) => a.price - b.price);
const cheapest = withPrices[0];
const alternatives = withPrices.slice(1, 4); // Show top 3 alternatives
bestPrices.push({
...cheapest,
alternatives: alternatives.map(w => ({
source: w.source,
price: w.price,
url: w.url,
availability: w.availability
}))
});
}
}
return {
success: true,
wines: bestPrices,
count: bestPrices.length,
timestamp: new Date().toISOString()
};
}
// Search specific source
async searchSource(source, query) {
const scraper = this.sources[source];
if (!scraper) {
return {
success: false,
error: `Unknown source: ${source}`,
wines: [],
count: 0
};
}
return await scraper.search(query);
}
// Get available sources
getAvailableSources() {
return Object.keys(this.sources).map(key => ({
id: key,
name: this.getSourceDisplayName(key),
scraper: this.sources[key]
}));
}
// Get display name for source
getSourceDisplayName(sourceId) {
const names = {
'kl': 'K&L Wine Merchants',
'vivino': 'Vivino',
'totalwine': 'Total Wine & More',
'winesensed': 'WineSensed (UC Davis/NeurIPS - 350k vintages)',
'xwines': 'X-Wines Dataset (100k wines, 21M ratings)',
'ucdavis-ava': 'UC Davis AVA (Geographic Regions)'
};
return names[sourceId] || sourceId;
}
}
module.exports = new WineAggregator();