← 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();