← back to Wine Finder Next
scripts/15min-price-tracker.js
260 lines
#!/usr/bin/env node
/**
* 15-MINUTE WINE PRICE TRACKER
* Crawls all wine sources every 15 minutes
* Detects flash deals (price changes in last 15 min)
* Logs to database for historical charting
*/
const axios = require('axios');
const fs = require('fs').promises;
const path = require('path');
const DATA_DIR = '/root/Projects/wine-finder-next/data';
const PRICE_HISTORY_FILE = path.join(DATA_DIR, 'price-history.jsonl');
const FLASH_DEALS_FILE = path.join(DATA_DIR, 'flash-deals.json');
const BEST_DEALS_FILE = path.join(DATA_DIR, 'best-deals.json');
// Comprehensive wine list to track
const TRACKED_WINES = [
'cabernet sauvignon', 'pinot noir', 'merlot', 'syrah', 'zinfandel',
'chardonnay', 'sauvignon blanc', 'pinot grigio', 'riesling',
'napa valley', 'bordeaux', 'burgundy', 'chianti', 'rioja',
'caymus', 'silver oak', 'opus one', 'jordan', 'stags leap'
];
const API_URL = 'http://localhost:7201/api/search';
async function ensureDataDir() {
try {
await fs.mkdir(DATA_DIR, { recursive: true });
} catch (err) {
console.error('Error creating data directory:', err);
}
}
async function crawlWine(query) {
try {
const response = await axios.get(API_URL, {
params: {
q: query,
sources: 'vivino,totalwine,kl',
limit: 50
},
timeout: 30000
});
return response.data.wines || [];
} catch (error) {
console.error(`Error crawling "${query}":`, error.message);
return [];
}
}
async function loadPreviousPrices() {
try {
const content = await fs.readFile(PRICE_HISTORY_FILE, 'utf-8');
const lines = content.trim().split('\n');
// Get prices from last 15 minutes
const fifteenMinAgo = Date.now() - (15 * 60 * 1000);
const recentPrices = new Map();
for (const line of lines) {
const entry = JSON.parse(line);
const timestamp = new Date(entry.timestamp).getTime();
if (timestamp >= fifteenMinAgo) {
const key = `${entry.name}|${entry.source}`;
if (!recentPrices.has(key) || timestamp > new Date(recentPrices.get(key).timestamp).getTime()) {
recentPrices.set(key, entry);
}
}
}
return recentPrices;
} catch (err) {
return new Map();
}
}
async function logPriceHistory(wines) {
const timestamp = new Date().toISOString();
const entries = wines.map(wine => ({
timestamp,
name: wine.name,
price: wine.price,
source: wine.source,
winery: wine.winery,
region: wine.region,
url: wine.url,
image: wine.image
}));
const lines = entries.map(e => JSON.stringify(e)).join('\n') + '\n';
await fs.appendFile(PRICE_HISTORY_FILE, lines);
}
function detectFlashDeals(currentWines, previousPrices) {
const flashDeals = [];
for (const wine of currentWines) {
const key = `${wine.name}|${wine.source}`;
const previous = previousPrices.get(key);
if (previous && previous.price > wine.price) {
const discount = previous.price - wine.price;
const discountPercent = ((discount / previous.price) * 100).toFixed(1);
flashDeals.push({
...wine,
previousPrice: previous.price,
currentPrice: wine.price,
discount: discount,
discountPercent: parseFloat(discountPercent),
detectedAt: new Date().toISOString()
});
}
}
return flashDeals.sort((a, b) => b.discountPercent - a.discountPercent);
}
async function calculateBestDeals(wines) {
try {
// Load all historical prices
const content = await fs.readFile(PRICE_HISTORY_FILE, 'utf-8');
const lines = content.trim().split('\n');
// Group prices by wine
const priceHistory = new Map();
for (const line of lines) {
const entry = JSON.parse(line);
const key = `${entry.name}|${entry.source}`;
if (!priceHistory.has(key)) {
priceHistory.set(key, []);
}
priceHistory.get(key).push({
price: entry.price,
timestamp: entry.timestamp
});
}
// Calculate best deals
const bestDeals = [];
for (const wine of wines) {
const key = `${wine.name}|${wine.source}`;
const history = priceHistory.get(key) || [];
if (history.length > 1) {
const prices = history.map(h => h.price);
const avgPrice = prices.reduce((a, b) => a + b, 0) / prices.length;
const maxPrice = Math.max(...prices);
const currentPrice = wine.price;
const savingsVsAvg = avgPrice - currentPrice;
const savingsVsMax = maxPrice - currentPrice;
const dealScore = ((savingsVsMax / maxPrice) * 100);
if (dealScore > 5) { // Only show deals > 5%
bestDeals.push({
...wine,
avgPrice: parseFloat(avgPrice.toFixed(2)),
maxPrice: parseFloat(maxPrice.toFixed(2)),
currentPrice: parseFloat(currentPrice),
savingsVsAvg: parseFloat(savingsVsAvg.toFixed(2)),
savingsVsMax: parseFloat(savingsVsMax.toFixed(2)),
dealScore: parseFloat(dealScore.toFixed(1)),
historicalDataPoints: history.length
});
}
}
}
return bestDeals.sort((a, b) => b.dealScore - a.dealScore).slice(0, 20);
} catch (err) {
console.error('Error calculating best deals:', err);
return [];
}
}
async function main() {
console.log('======================================');
console.log('15-MINUTE WINE PRICE TRACKER');
console.log('Started:', new Date().toISOString());
console.log('======================================\n');
await ensureDataDir();
// Load previous prices for flash deal detection
const previousPrices = await loadPreviousPrices();
console.log(`Loaded ${previousPrices.size} previous prices from last 15 minutes\n`);
// Crawl all wines
const allWines = [];
let searchCount = 0;
for (const query of TRACKED_WINES) {
searchCount++;
console.log(`[${searchCount}/${TRACKED_WINES.length}] Crawling: ${query}`);
const wines = await crawlWine(query);
allWines.push(...wines);
console.log(` Found: ${wines.length} wines`);
// Rate limiting
await new Promise(resolve => setTimeout(resolve, 2000));
}
console.log(`\nTotal wines crawled: ${allWines.length}`);
// Deduplicate wines
const uniqueWines = Array.from(
new Map(allWines.map(w => [`${w.name}|${w.source}`, w])).values()
);
console.log(`Unique wines: ${uniqueWines.length}`);
// Log all prices to history
await logPriceHistory(uniqueWines);
console.log('✓ Logged prices to history');
// Detect flash deals (price changes in last 15 min)
const flashDeals = detectFlashDeals(uniqueWines, previousPrices);
await fs.writeFile(FLASH_DEALS_FILE, JSON.stringify({
timestamp: new Date().toISOString(),
count: flashDeals.length,
deals: flashDeals
}, null, 2));
console.log(`\n🔥 FLASH DEALS: ${flashDeals.length}`);
flashDeals.slice(0, 5).forEach((deal, i) => {
console.log(` ${i + 1}. ${deal.name} - ${deal.discountPercent}% off`);
console.log(` Was: $${deal.previousPrice} | Now: $${deal.currentPrice}`);
});
// Calculate best deals from historical data
const bestDeals = await calculateBestDeals(uniqueWines);
await fs.writeFile(BEST_DEALS_FILE, JSON.stringify({
timestamp: new Date().toISOString(),
count: bestDeals.length,
deals: bestDeals
}, null, 2));
console.log(`\n💎 BEST DEALS (Historical): ${bestDeals.length}`);
bestDeals.slice(0, 5).forEach((deal, i) => {
console.log(` ${i + 1}. ${deal.name} - ${deal.dealScore}% below historic high`);
console.log(` Current: $${deal.currentPrice} | Avg: $${deal.avgPrice} | Max: $${deal.maxPrice}`);
});
console.log('\n======================================');
console.log('Completed:', new Date().toISOString());
console.log('======================================');
}
main().catch(console.error);