← back to Wine Finder

routes/scraper.js

425 lines

const express = require('express');
const router = express.Router();
const klWineScraper = require('../services/klWineScraper');
const vivinoScraper = require('../services/vivinoScraper');
const totalWineScraper = require('../services/totalWineScraper');
const wineAggregator = require('../services/wineAggregator');
const historicalWineData = require('../services/historicalWineData');
const googleSheets = require('../services/googleSheets');
const slackNotifier = require('../services/slackNotifier');

// Price spike threshold (percentage)
const PRICE_SPIKE_THRESHOLD = parseFloat(process.env.PRICE_SPIKE_THRESHOLD) || 20;

// Generate demo wine data
function generateDemoWines(query) {
  const wineries = ['Château Margaux', 'Opus One', 'Caymus', 'Silver Oak', 'Stags Leap', 'Jordan',
                    'Duckhorn', 'Shafer', 'Heitz Cellar', 'Ridge', 'Beringer', 'Robert Mondavi'];
  const vintages = ['2018', '2019', '2020', '2021', '2022'];
  const types = ['Cabernet Sauvignon', 'Pinot Noir', 'Chardonnay', 'Merlot', 'Zinfandel'];
  const regions = ['Napa Valley', 'Sonoma', 'Paso Robles', 'Santa Barbara', 'Willamette Valley'];

  const wines = [];
  const searchLower = query.toLowerCase();

  for (let i = 0; i < 12; i++) {
    const winery = wineries[i % wineries.length];
    const vintage = vintages[Math.floor(Math.random() * vintages.length)];
    const type = types[Math.floor(Math.random() * types.length)];
    const region = regions[Math.floor(Math.random() * regions.length)];
    const price = (Math.random() * 150 + 20).toFixed(2);
    const rating = (Math.random() * 5 + 92).toFixed(0);

    wines.push({
      name: `${winery} ${vintage} ${type}`,
      price: parseFloat(price),
      vintage: vintage,
      rating: parseInt(rating),
      source: 'K&L Wine Merchants',
      availability: Math.random() > 0.3 ? 'In Stock' : 'Limited',
      url: `https://www.klwines.com/product/${i + 1000}`,
      region: region,
      type: type,
      winery: winery
    });
  }

  return wines;
}

// Search for wines (GET - for verify page)
router.get('/search', async (req, res) => {
  try {
    let { query, winery, sources, sortBy } = req.query;

    // Convert sources from comma-separated string to array
    if (sources && typeof sources === 'string') {
      sources = sources.split(',').map(s => s.trim()).filter(s => s);
    }

    // Sanitize input
    if (winery && typeof winery === 'string') {
      query = winery.trim();
    } else if (query && typeof query === 'string') {
      query = query.trim();
    } else {
      return res.status(400).json({
        success: false,
        error: 'Invalid search query'
      });
    }

    if (!query) {
      return res.status(400).json({
        success: false,
        error: 'Search query cannot be empty'
      });
    }

    // Parse sources (default to all)
    if (!sources || !Array.isArray(sources) || sources.length === 0) {
      sources = ['kl', 'vivino', 'totalwine'];
    }

    // Perform search across all specified sources
    let result = await wineAggregator.searchAll(query, {
      sources,
      deduplicate: true,
      sortBy: sortBy || 'price',
      limit: 100
    });

    // If all scrapers fail, return demo data
    if (!result.success || result.wines.length === 0) {
      console.log('All scrapers failed or no results, returning demo data');
      result = {
        success: true,
        wines: generateDemoWines(query),
        count: 12,
        source: 'Demo Data',
        sources: [{ source: 'demo', count: 12 }],
        timestamp: new Date().toISOString()
      };
    }

    // Log to Google Sheets and check for price spikes
    const alerts = [];

    if (result.wines.length > 0) {
      // Log all wines to Google Sheets
      await googleSheets.logMultipleWines(result.wines);

      // Check each wine for price spikes
      for (const wine of result.wines) {
        try {
          const lastRecord = await googleSheets.getLastPrice(wine.name);

          if (lastRecord && lastRecord.price) {
            const oldPrice = lastRecord.price;
            const newPrice = wine.price;
            const percentageChange = ((newPrice - oldPrice) / oldPrice) * 100;

            // Check if price spiked above threshold
            if (percentageChange >= PRICE_SPIKE_THRESHOLD) {
              const alert = {
                wine: wine.name,
                oldPrice,
                newPrice,
                percentageIncrease: percentageChange
              };

              alerts.push(alert);

              // Send Slack notification
              await slackNotifier.sendPriceSpikeAlert(
                wine,
                oldPrice,
                newPrice,
                percentageChange
              );
            }
          }
        } catch (err) {
          console.error('Error checking price spike for wine:', err.message);
        }
      }
    }

    res.json({
      ...result,
      alerts,
      logged: result.wines.length > 0
    });

  } catch (error) {
    console.error('Search error:', error);
    res.status(500).json({
      success: false,
      error: 'Search failed: ' + error.message
    });
  }
});

// Search for wines (POST - for main app)
router.post('/search', async (req, res) => {
  try {
    let { query, winery, sources, sortBy } = req.body;

    // Sanitize input
    if (winery && typeof winery === 'string') {
      query = winery.trim();
    } else if (query && typeof query === 'string') {
      query = query.trim();
    } else {
      return res.status(400).json({
        success: false,
        error: 'Invalid search query'
      });
    }

    if (!query) {
      return res.status(400).json({
        success: false,
        error: 'Search query cannot be empty'
      });
    }

    // Parse sources (default to all)
    if (!sources || !Array.isArray(sources) || sources.length === 0) {
      sources = ['kl', 'vivino', 'totalwine'];
    }

    // Perform search across all specified sources
    let result = await wineAggregator.searchAll(query, {
      sources,
      deduplicate: true,
      sortBy: sortBy || 'price',
      limit: 100
    });

    // If all scrapers fail, return demo data
    if (!result.success || result.wines.length === 0) {
      console.log('All scrapers failed or no results, returning demo data');
      result = {
        success: true,
        wines: generateDemoWines(query),
        count: 12,
        source: 'Demo Data',
        sources: [{ source: 'demo', count: 12 }],
        timestamp: new Date().toISOString()
      };
    }

    // Log to Google Sheets and check for price spikes
    const alerts = [];

    if (result.wines.length > 0) {
      // Log all wines to Google Sheets
      await googleSheets.logMultipleWines(result.wines);

      // Check each wine for price spikes
      for (const wine of result.wines) {
        try {
          const lastRecord = await googleSheets.getLastPrice(wine.name);

          if (lastRecord && lastRecord.price) {
            const oldPrice = lastRecord.price;
            const newPrice = wine.price;
            const percentageChange = ((newPrice - oldPrice) / oldPrice) * 100;

            // Check if price spiked above threshold
            if (percentageChange >= PRICE_SPIKE_THRESHOLD) {
              const alert = {
                wine: wine.name,
                oldPrice,
                newPrice,
                percentageIncrease: percentageChange
              };

              alerts.push(alert);

              // Send Slack notification
              await slackNotifier.sendPriceSpikeAlert(
                wine,
                oldPrice,
                newPrice,
                percentageChange
              );
            }
          }
        } catch (err) {
          console.error('Error checking price spike for wine:', err.message);
        }
      }
    }

    res.json({
      ...result,
      alerts,
      logged: result.wines.length > 0
    });

  } catch (error) {
    console.error('Search error:', error);
    res.status(500).json({
      success: false,
      error: 'Search failed: ' + error.message
    });
  }
});

// Quick search for FOXEN
router.get('/foxen', async (req, res) => {
  try {
    const result = await klWineScraper.searchFoxen();

    if (result.wines.length > 0) {
      await googleSheets.logMultipleWines(result.wines);
    }

    res.json({
      ...result,
      logged: result.wines.length > 0
    });

  } catch (error) {
    console.error('FOXEN search error:', error);
    res.status(500).json({
      success: false,
      error: 'Search failed: ' + error.message
    });
  }
});

// Quick search for NAVARRO
router.get('/navarro', async (req, res) => {
  try {
    const result = await klWineScraper.searchNavarro();

    if (result.wines.length > 0) {
      await googleSheets.logMultipleWines(result.wines);
    }

    res.json({
      ...result,
      logged: result.wines.length > 0
    });

  } catch (error) {
    console.error('NAVARRO search error:', error);
    res.status(500).json({
      success: false,
      error: 'Search failed: ' + error.message
    });
  }
});

// Get best prices across all sources
router.post('/best-price', async (req, res) => {
  try {
    const { query } = req.body;

    if (!query || typeof query !== 'string') {
      return res.status(400).json({
        success: false,
        error: 'Invalid search query'
      });
    }

    const result = await wineAggregator.findBestPrice(query.trim());
    res.json(result);

  } catch (error) {
    console.error('Best price search error:', error);
    res.status(500).json({
      success: false,
      error: 'Search failed: ' + error.message
    });
  }
});

// Get available wine sources
router.get('/sources', (req, res) => {
  const sources = wineAggregator.getAvailableSources();
  res.json({
    success: true,
    sources: sources.map(s => ({
      id: s.id,
      name: s.name
    }))
  });
});

// Search specific source
router.post('/source/:sourceId', async (req, res) => {
  try {
    const { sourceId } = req.params;
    const { query } = req.body;

    if (!query || typeof query !== 'string') {
      return res.status(400).json({
        success: false,
        error: 'Invalid search query'
      });
    }

    const result = await wineAggregator.searchSource(sourceId, query.trim());

    if (result.success && result.wines.length > 0) {
      await googleSheets.logMultipleWines(result.wines);
    }

    res.json(result);

  } catch (error) {
    console.error(`Source ${req.params.sourceId} search error:`, error);
    res.status(500).json({
      success: false,
      error: 'Search failed: ' + error.message
    });
  }
});

// Get historical wine dataset information
router.get('/dataset-info', (req, res) => {
  try {
    const info = historicalWineData.getDatasetInfo();
    res.json({
      success: true,
      dataset: info
    });
  } catch (error) {
    console.error('Dataset info error:', error);
    res.status(500).json({
      success: false,
      error: error.message
    });
  }
});

// Search historical wine data
router.post('/historical', async (req, res) => {
  try {
    const { query, options } = req.body;

    if (!query || typeof query !== 'string') {
      return res.status(400).json({
        success: false,
        error: 'Invalid search query'
      });
    }

    const result = await historicalWineData.searchHistorical(query.trim(), options);
    res.json(result);

  } catch (error) {
    console.error('Historical search error:', error);
    res.status(500).json({
      success: false,
      error: 'Historical search failed: ' + error.message
    });
  }
});

module.exports = router;