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