← back to Wine Finder

services/klWineScraper.js

180 lines

const axios = require('axios');
const cheerio = require('cheerio');

class KLWineScraper {
  constructor() {
    this.baseUrl = 'https://www.klwines.com';
    this.searchUrl = 'https://www.klwines.com/Products';
  }

  // Sanitize search query
  sanitizeQuery(query) {
    return encodeURIComponent(query.trim());
  }

  // Parse wine data from search results
  async search(query) {
    try {
      const sanitizedQuery = this.sanitizeQuery(query);
      const url = `${this.searchUrl}?searchText=${sanitizedQuery}&searchType=Contains`;

      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.5',
          'Accept-Encoding': 'gzip, deflate, br',
          'Connection': 'keep-alive',
          'Upgrade-Insecure-Requests': '1'
        },
        timeout: 15000
      });

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

      // Parse K&L wine listings
      // K&L uses product tiles with specific classes
      $('.result-item, .product-item, .tf-item').each((index, element) => {
        try {
          const $item = $(element);

          // Extract wine name/title
          let name = $item.find('.result-title, .product-title, h3 a, .tf-title').first().text().trim();
          if (!name) {
            name = $item.find('a[href*="/p/"]').first().text().trim();
          }

          // Extract price
          let price = null;
          let priceText = $item.find('.price, .product-price, .tf-price, .our-price').first().text().trim();

          // Try alternative price selectors
          if (!priceText) {
            priceText = $item.find('span:contains("$"), div:contains("$")').first().text().trim();
          }

          // Extract numeric price
          const priceMatch = priceText.match(/\$?([\d,]+\.?\d*)/);
          if (priceMatch) {
            price = parseFloat(priceMatch[1].replace(/,/g, ''));
          }

          // Extract rating
          let rating = null;
          const ratingText = $item.find('.rating, .score, .points').text();
          const ratingMatch = ratingText.match(/(\d+)\s*(?:points?|pts?)?/i);
          if (ratingMatch) {
            rating = parseInt(ratingMatch[1]);
          }

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

          // Extract image
          let image = $item.find('img').first().attr('src');
          if (image && !image.startsWith('http') && !image.startsWith('data:')) {
            image = this.baseUrl + image;
          }

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

          // Extract availability/location
          let availability = $item.find('.inventory, .stock, .availability').text().trim();

          // Only add if we have at least a name and price
          if (name && price) {
            wines.push({
              name,
              price,
              rating,
              vintage,
              availability: availability || 'Check website',
              url: url || null,
              image: image || null,
              source: 'K&L Wine Merchants',
              searchQuery: query
            });
          }
        } catch (err) {
          console.error('Error parsing individual wine item:', err.message);
        }
      });

      // If no results with the above selectors, try a more general approach
      if (wines.length === 0) {
        $('tr[data-product], div[data-product]').each((index, element) => {
          try {
            const $item = $(element);

            const name = $item.find('a[href*="/p/"]').text().trim();
            const priceText = $item.text();
            const priceMatch = priceText.match(/\$?([\d,]+\.?\d*)/);
            const price = priceMatch ? parseFloat(priceMatch[1].replace(/,/g, '')) : null;

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

            if (name && price) {
              wines.push({
                name,
                price,
                rating: null,
                vintage: null,
                availability: 'Check website',
                url,
                image: null,
                source: 'K&L Wine Merchants',
                searchQuery: query
              });
            }
          } catch (err) {
            console.error('Error parsing alternative wine item:', err.message);
          }
        });
      }

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

    } catch (error) {
      console.error('K&L Wine scraper error:', error.message);

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

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

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

module.exports = new KLWineScraper();