← back to Wine Finder
services/vivinoScraper.js
188 lines
const axios = require('axios');
class VivinoScraper {
constructor() {
this.baseUrl = 'https://www.vivino.com/api';
this.recordsPerPage = 25;
}
sanitizeQuery(query) {
return encodeURIComponent(query.trim());
}
async search(query, options = {}) {
try {
const {
countryCode = 'us',
minRating = 1,
priceMin = 0,
priceMax = 5000,
page = 1
} = options;
// Use Vivino's explore API with proper filters
const params = new URLSearchParams({
'country_code': countryCode.toUpperCase(),
'currency_code': 'USD',
'grape_filter': 'varietal',
'min_rating': minRating.toString(),
'order_by': 'price',
'order': 'asc',
'page': page.toString(),
'price_range_min': priceMin.toString(),
'price_range_max': priceMax.toString(),
'wine_type_ids[]': '1' // Required filter - 1 = red wine (we can make this dynamic later)
});
const searchUrl = `${this.baseUrl}/explore/explore?${params.toString()}`;
const response = await axios.get(searchUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'application/json',
'Accept-Language': 'en-US,en;q=0.9'
},
timeout: 10000
});
const wines = [];
const data = response.data;
// Vivino explore API returns explore_vintage.matches array
if (data.explore_vintage && data.explore_vintage.matches && Array.isArray(data.explore_vintage.matches)) {
for (const match of data.explore_vintage.matches) {
const wine = match.vintage?.wine;
const vintage = match.vintage;
if (!wine) continue;
// Extract price
let price = null;
if (match.price && match.price.amount) {
price = parseFloat(match.price.amount);
}
// Extract rating (convert 1-5 scale to 20-100 scale)
let rating = null;
if (wine.statistics && wine.statistics.ratings_average) {
rating = Math.round(wine.statistics.ratings_average * 20);
}
// Extract winery name
let winery = null;
if (wine.winery) {
winery = wine.winery.name;
}
// Extract region
let region = null;
if (wine.region) {
region = wine.region.name;
if (wine.region.country) {
region += `, ${wine.region.country.name}`;
}
}
// Extract image
let image = null;
if (wine.image && wine.image.location) {
image = `https://images.vivino.com/thumbs/${wine.image.location}`;
}
wines.push({
name: wine.name,
price: price,
rating: rating,
vintage: vintage.year || null,
availability: price ? 'Available on Vivino' : 'Check Vivino',
url: `https://www.vivino.com/wines/${wine.id}`,
image: image,
source: 'Vivino',
searchQuery: query,
winery: winery,
region: region,
ratingsCount: wine.statistics?.ratings_count || 0
});
}
}
// Filter results by query text (since Vivino explore API doesn't support text search)
const queryLower = query.toLowerCase();
const filteredWines = wines.filter(wine => {
const nameLower = wine.name.toLowerCase();
const wineryLower = (wine.winery || '').toLowerCase();
const regionLower = (wine.region || '').toLowerCase();
// Check if query terms appear in wine name, winery, or region
const queryTerms = queryLower.split(/\s+/);
return queryTerms.some(term =>
nameLower.includes(term) ||
wineryLower.includes(term) ||
regionLower.includes(term)
);
});
return {
success: true,
wines: filteredWines,
count: filteredWines.length,
totalMatches: data.total || wines.length,
source: 'Vivino',
timestamp: new Date().toISOString()
};
} catch (error) {
console.error('Vivino scraper error:', error.message);
return {
success: false,
wines: [],
count: 0,
error: error.message,
source: 'Vivino',
timestamp: new Date().toISOString()
};
}
}
// Get detailed wine information including taste profile
async getWineDetails(wineId) {
try {
const [wineData, tastesData] = await Promise.all([
axios.get(`${this.baseUrl}/wines/${wineId}`, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
}),
axios.get(`${this.baseUrl}/wines/${wineId}/tastes`, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
}).catch(() => ({ data: { tastes: [] } }))
]);
return {
wine: wineData.data,
tastes: tastesData.data.tastes || {},
success: true
};
} catch (error) {
console.error('Error fetching wine details:', error.message);
return {
success: false,
error: error.message
};
}
}
// Quick search presets
async searchFoxen() {
return this.search('FOXEN');
}
async searchNavarro() {
return this.search('NAVARRO');
}
}
module.exports = new VivinoScraper();