← back to Wine Finder Next

lib/services/wineAggregator.js

288 lines

const klWineScraper = require('./klWineScraper');
const vivinoScraper = require('./vivinoScraper');
const totalWineScraper = require('./totalWineScraper');
const wineSensedScraper = require('./wineSensedScraper');
const xWinesScraper = require('./xWinesScraper');
const ucDavisAVAScraper = require('./ucDavisAVAScraper');
const wineEnthusiastScraper = require('./wineEnthusiastScraper');

class WineAggregator {
  constructor() {
    this.sources = {
      'kl': klWineScraper,
      'vivino': vivinoScraper,
      'totalwine': totalWineScraper,
      'winesensed': wineSensedScraper,
      'xwines': xWinesScraper,
      'ucdavis-ava': ucDavisAVAScraper,
      'wineenthusiast': wineEnthusiastScraper
    };
  }

  // Search all sources and aggregate results
  async searchAll(query, options = {}) {
    const {
      sources = ['kl', 'vivino', 'totalwine', 'winesensed', 'xwines', 'ucdavis-ava', 'wineenthusiast'],
      deduplicate = true,
      sortBy = 'price', // 'price', 'rating', 'name', 'source'
      limit = 100
    } = options;

    const results = [];
    const errors = [];

    // Search all sources in parallel
    const promises = sources.map(async (sourceName) => {
      const scraper = this.sources[sourceName];
      if (!scraper) {
        errors.push({ source: sourceName, error: 'Unknown source' });
        return null;
      }

      try {
        const result = await scraper.search(query);
        if (result.success && result.wines.length > 0) {
          return {
            source: sourceName,
            wines: result.wines,
            count: result.count
          };
        } else if (!result.success) {
          errors.push({ source: sourceName, error: result.error || 'Search failed' });
        }
        return null;
      } catch (error) {
        errors.push({ source: sourceName, error: error.message });
        return null;
      }
    });

    const sourceResults = await Promise.all(promises);

    // Combine all wines
    let allWines = [];
    for (const result of sourceResults) {
      if (result && result.wines) {
        allWines.push(...result.wines);
        results.push({
          source: result.source,
          count: result.count
        });
      }
    }

    // Deduplicate wines by name similarity
    if (deduplicate) {
      allWines = this.deduplicateWines(allWines);
    }

    // Sort wines
    allWines = this.sortWines(allWines, sortBy);

    // Apply limit
    if (limit && allWines.length > limit) {
      allWines = allWines.slice(0, limit);
    }

    return {
      success: true,
      wines: allWines,
      count: allWines.length,
      sources: results,
      errors: errors.length > 0 ? errors : undefined,
      timestamp: new Date().toISOString()
    };
  }

  // Deduplicate wines based on name similarity
  deduplicateWines(wines) {
    const uniqueWines = [];
    const seen = new Map();

    for (const wine of wines) {
      // Normalize name for comparison
      const normalizedName = wine.name
        .toLowerCase()
        .replace(/[^a-z0-9]/g, '')
        .substring(0, 40); // Compare first 40 chars

      if (!seen.has(normalizedName)) {
        seen.set(normalizedName, wine);
        uniqueWines.push(wine);
      } else {
        // If duplicate, merge data or keep better version
        const existing = seen.get(normalizedName);
        const existingIndex = uniqueWines.findIndex(w =>
          w.name.toLowerCase().replace(/[^a-z0-9]/g, '').substring(0, 40) === normalizedName
        );

        if (existingIndex !== -1) {
          // Prefer wine with more complete data
          let shouldReplace = false;

          // Prefer wine with price info
          if (wine.price && !existing.price) {
            shouldReplace = true;
          }
          // If both have prices, keep cheaper one
          else if (wine.price && existing.price && wine.price < existing.price) {
            shouldReplace = true;
          }
          // If no price difference, prefer one with rating
          else if (wine.rating && !existing.rating) {
            shouldReplace = true;
          }
          // If both have ratings, prefer higher rating
          else if (wine.rating && existing.rating && wine.rating > existing.rating) {
            shouldReplace = true;
          }
          // Prefer Vivino (most data) over others
          else if (wine.source === 'Vivino' && existing.source !== 'Vivino') {
            shouldReplace = true;
          }

          if (shouldReplace) {
            uniqueWines[existingIndex] = wine;
            seen.set(normalizedName, wine);
          }
        }
      }
    }

    return uniqueWines;
  }

  // Sort wines by various criteria
  sortWines(wines, sortBy) {
    switch (sortBy) {
      case 'price':
      case 'price-asc':
        return wines.sort((a, b) => {
          if (!a.price) return 1;
          if (!b.price) return -1;
          return a.price - b.price;
        });

      case 'price-desc':
        return wines.sort((a, b) => {
          if (!a.price) return 1;
          if (!b.price) return -1;
          return b.price - a.price;
        });

      case 'rating':
      case 'rating-desc':
        return wines.sort((a, b) => {
          if (!a.rating) return 1;
          if (!b.rating) return -1;
          return b.rating - a.rating; // Descending
        });

      case 'name':
        return wines.sort((a, b) => a.name.localeCompare(b.name));

      case 'source':
        return wines.sort((a, b) => a.source.localeCompare(b.source));

      default:
        return wines;
    }
  }

  // Get best price for a wine across all sources
  async findBestPrice(query) {
    const result = await this.searchAll(query, {
      deduplicate: false,
      sortBy: 'price'
    });

    if (result.wines.length === 0) {
      return {
        success: false,
        wines: [],
        count: 0,
        message: 'No wines found'
      };
    }

    // Group by wine name (fuzzy matching)
    const wineGroups = {};

    for (const wine of result.wines) {
      const key = wine.name.toLowerCase().replace(/[^a-z0-9]/g, '').substring(0, 40);

      if (!wineGroups[key]) {
        wineGroups[key] = [];
      }
      wineGroups[key].push(wine);
    }

    // Find best price for each wine
    const bestPrices = [];
    for (const [key, wines] of Object.entries(wineGroups)) {
      const withPrices = wines.filter(w => w.price);
      if (withPrices.length > 0) {
        withPrices.sort((a, b) => a.price - b.price);
        const cheapest = withPrices[0];
        const alternatives = withPrices.slice(1, 4); // Show top 3 alternatives

        bestPrices.push({
          ...cheapest,
          alternatives: alternatives.map(w => ({
            source: w.source,
            price: w.price,
            url: w.url,
            availability: w.availability
          }))
        });
      }
    }

    return {
      success: true,
      wines: bestPrices,
      count: bestPrices.length,
      timestamp: new Date().toISOString()
    };
  }

  // Search specific source
  async searchSource(source, query) {
    const scraper = this.sources[source];
    if (!scraper) {
      return {
        success: false,
        error: `Unknown source: ${source}`,
        wines: [],
        count: 0
      };
    }

    return await scraper.search(query);
  }

  // Get available sources
  getAvailableSources() {
    return Object.keys(this.sources).map(key => ({
      id: key,
      name: this.getSourceDisplayName(key),
      scraper: this.sources[key]
    }));
  }

  // Get display name for source
  getSourceDisplayName(sourceId) {
    const names = {
      'kl': 'K&L Wine Merchants',
      'vivino': 'Vivino',
      'totalwine': 'Total Wine & More',
      'winesensed': 'WineSensed (UC Davis/NeurIPS - 350k vintages)',
      'xwines': 'X-Wines Dataset (100k wines, 21M ratings)',
      'ucdavis-ava': 'UC Davis AVA (Geographic Regions)'
    };
    return names[sourceId] || sourceId;
  }
}

module.exports = new WineAggregator();