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