← back to Wine Finder Next

lib/services/totalWineScraper.js

187 lines

const axios = require('axios');
const cheerio = require('cheerio');
const wineImageService = require('./wineImageService');

class TotalWineScraper {
  constructor() {
    this.baseUrl = 'https://www.totalwine.com';
    this.searchUrl = 'https://www.totalwine.com/search';
  }

  sanitizeQuery(query) {
    return encodeURIComponent(query.trim());
  }

  async search(query) {
    try {
      const sanitizedQuery = this.sanitizeQuery(query);
      const url = `${this.searchUrl}?text=${sanitizedQuery}`;

      const response = await axios.get(url, {
        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': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
          'Accept-Language': 'en-US,en;q=0.9',
          'Connection': 'keep-alive'
        },
        timeout: 15000
      });

      const $ = cheerio.load(response.data);
      const wines = [];

      // Try multiple selector patterns for Total Wine's product cards
      const productSelectors = [
        'article.product-card',
        'div.product-item',
        '[data-testid="product-card"]',
        '.plp-product-card',
        '.productCard'
      ];

      let $products = $([]);
      for (const selector of productSelectors) {
        $products = $(selector);
        if ($products.length > 0) break;
      }

      $products.each((index, element) => {
        try {
          const $item = $(element);

          // Extract name - try multiple selectors
          let name = $item.find('h3.title a, h2.product-title, .product-name, [data-testid="product-name"]')
            .first().text().trim();

          if (!name) {
            name = $item.find('a[href*="/p/"]').first().attr('aria-label') ||
                   $item.find('a[href*="/p/"]').first().text().trim();
          }

          // Extract price
          let price = null;
          const priceSelectors = [
            'span.price',
            '.product-price',
            '[data-testid="product-price"]',
            '.productPrice',
            'span[class*="price"]'
          ];

          for (const selector of priceSelectors) {
            const priceText = $item.find(selector).first().text().trim();
            const priceMatch = priceText.match(/\$?([\d,]+\.?\d*)/);
            if (priceMatch) {
              price = parseFloat(priceMatch[1].replace(/,/g, ''));
              break;
            }
          }

          // If no price found, try broader search
          if (!price) {
            const allText = $item.text();
            const priceMatch = allText.match(/\$\s?([\d,]+\.?\d*)/);
            if (priceMatch) {
              price = parseFloat(priceMatch[1].replace(/,/g, ''));
            }
          }

          // Extract size
          let size = null;
          const sizeText = $item.text();
          const sizeMatch = sizeText.match(/(\d+\.?\d*\s?(?:ml|ML|L|l|oz|OZ))/i);
          if (sizeMatch) {
            size = sizeMatch[1];
          }

          // Extract availability
          let availability = 'Check website';
          const stockText = $item.find('.inventory, .stock, .availability, [class*="stock"]').text().toLowerCase();
          if (stockText.includes('in stock') || stockText.includes('available')) {
            availability = 'In Stock';
          } else if (stockText.includes('limited')) {
            availability = 'Limited Quantity';
          } else if (stockText.includes('out of stock') || stockText.includes('unavailable')) {
            availability = 'Out of Stock';
          }

          // Extract URL
          let url = $item.find('a[href*="/p/"]').first().attr('href');
          if (url && !url.startsWith('http')) {
            url = this.baseUrl + url;
          }

          // Extract vintage
          let vintage = null;
          const vintageMatch = name.match(/\b(19|20)\d{2}\b/);
          if (vintageMatch) {
            vintage = vintageMatch[0];
          }

          // Extract rating if available
          let rating = null;
          const ratingText = $item.find('.rating, .score, [class*="rating"]').text();
          const ratingMatch = ratingText.match(/(\d+\.?\d*)\s*(?:out of|\/)\s*5/i);
          if (ratingMatch) {
            rating = Math.round(parseFloat(ratingMatch[1]) * 20); // Convert to 100 scale
          }

          // Extract image - Total Wine has affiliate program, so images are OK to use!
          let image = $item.find('img').first().attr('src') || $item.find('img').first().attr('data-src');
          if (image && !image.startsWith('http') && !image.startsWith('data:')) {
            image = this.baseUrl + image;
          }

          if (name && price) {
            wines.push({
              name,
              price,
              rating,
              vintage,
              availability,
              url: url || null,
              image: image || null,  // Real bottle photos from affiliate partner
              source: 'Total Wine & More',
              searchQuery: query,
              size: size
            });
          }
        } catch (err) {
          console.error('Error parsing Total Wine item:', err.message);
        }
      });

      return {
        success: true,
        wines,
        count: wines.length,
        source: 'Total Wine & More',
        timestamp: new Date().toISOString()
      };

    } catch (error) {
      console.error('Total Wine scraper error:', error.message);

      return {
        success: false,
        wines: [],
        count: 0,
        error: error.message,
        source: 'Total Wine & More',
        timestamp: new Date().toISOString()
      };
    }
  }

  // Quick search presets
  async searchFoxen() {
    return this.search('FOXEN');
  }

  async searchNavarro() {
    return this.search('NAVARRO');
  }
}

module.exports = new TotalWineScraper();