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