← back to Handbag Authentication

api/sold-comps-aggregator.js

198 lines

/**
 * Sold Comps Aggregator
 * Aggregates sold data from multiple sources:
 * - eBay USA
 * - eBay Japan
 * - Fashionphile (scraped)
 * - Rebag Clair
 * - Poshmark
 */

const EbaySoldDataFetcher = require('./ebay-sold-data');
const axios = require('axios');
const cheerio = require('cheerio');

class SoldCompsAggregator {
  constructor() {
    this.ebayFetcher = new EbaySoldDataFetcher();
  }

  /**
   * Fetch Fashionphile sold items
   */
  async fetchFashionphileSold(brand, model, limit = 5) {
    try {
      const searchQuery = `${brand} ${model}`;
      const url = `https://www.fashionphile.com/search?query=${encodeURIComponent(searchQuery)}&sold=true`;

      console.log(`Fetching Fashionphile sold: ${url}`);

      const response = await axios.get(url, {
        headers: {
          'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
        },
        timeout: 15000
      });

      const $ = cheerio.load(response.data);
      const soldItems = [];

      // Parse Fashionphile's sold items (adjust selectors as needed)
      $('.product-item, .product-card').slice(0, limit).each((i, elem) => {
        const title = $(elem).find('.product-title, .product-name').text().trim();
        const priceText = $(elem).find('.product-price, .price').text().trim();
        const link = $(elem).find('a').attr('href');
        const image = $(elem).find('img').attr('src');

        const priceMatch = priceText.match(/\$([0-9,]+)/);
        const price = priceMatch ? parseFloat(priceMatch[1].replace(/,/g, '')) : null;

        if (title && price) {
          soldItems.push({
            platform: 'Fashionphile',
            title,
            price,
            currency: 'USD',
            soldDate: 'Recently',
            url: link ? `https://www.fashionphile.com${link}` : url,
            image,
            source: 'fashionphile'
          });
        }
      });

      console.log(`✓ Found ${soldItems.length} Fashionphile sold items`);
      return soldItems;

    } catch (error) {
      console.error('Error fetching Fashionphile:', error.message);
      return [];
    }
  }

  /**
   * Fetch Poshmark sold items
   */
  async fetchPoshmarkSold(brand, model, limit = 5) {
    try {
      const searchQuery = `${brand} ${model}`;
      const url = `https://poshmark.com/search?query=${encodeURIComponent(searchQuery)}&department=Women&availability=sold_out`;

      console.log(`Fetching Poshmark sold: ${url}`);

      const response = await axios.get(url, {
        headers: {
          'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
        },
        timeout: 15000
      });

      const $ = cheerio.load(response.data);
      const soldItems = [];

      $('.tile').slice(0, limit).each((i, elem) => {
        const title = $(elem).find('.tile__title').text().trim();
        const priceText = $(elem).find('.tile__price').text().trim();
        const link = $(elem).find('a').attr('href');
        const image = $(elem).find('img').attr('src');

        const priceMatch = priceText.match(/\$([0-9,]+)/);
        const price = priceMatch ? parseFloat(priceMatch[1].replace(/,/g, '')) : null;

        if (title && price) {
          soldItems.push({
            platform: 'Poshmark',
            title,
            price,
            currency: 'USD',
            soldDate: 'Sold',
            url: link ? `https://poshmark.com${link}` : url,
            image,
            source: 'poshmark'
          });
        }
      });

      console.log(`✓ Found ${soldItems.length} Poshmark sold items`);
      return soldItems;

    } catch (error) {
      console.error('Error fetching Poshmark:', error.message);
      return [];
    }
  }

  /**
   * Aggregate all sold comps from all sources
   */
  async aggregateAllSoldComps(brand, model) {
    console.log(`\n📊 Aggregating sold comps for: ${brand} ${model}`);

    const results = await Promise.allSettled([
      this.ebayFetcher.fetchEbayUSASold(brand, model, 10),
      this.ebayFetcher.fetchEbayJapanSold(brand, model, 10),
      this.fetchFashionphileSold(brand, model, 5),
      this.fetchPoshmarkSold(brand, model, 5)
    ]);

    // Combine all successful results
    const allSold = results
      .filter(r => r.status === 'fulfilled')
      .flatMap(r => r.value);

    // Sort by price (high to low)
    allSold.sort((a, b) => {
      const priceA = a.currency === 'JPY' ? a.price / 150 : a.price;
      const priceB = b.currency === 'JPY' ? b.price / 150 : b.price;
      return priceB - priceA;
    });

    console.log(`✅ Total sold comps: ${allSold.length}`);

    // Calculate stats
    const stats = this.calculateStats(allSold);

    return {
      soldItems: allSold,
      stats,
      sources: {
        ebayUSA: allSold.filter(i => i.source === 'ebay_usa').length,
        ebayJapan: allSold.filter(i => i.source === 'ebay_japan').length,
        fashionphile: allSold.filter(i => i.source === 'fashionphile').length,
        poshmark: allSold.filter(i => i.source === 'poshmark').length
      }
    };
  }

  calculateStats(soldItems) {
    if (soldItems.length === 0) {
      return {
        count: 0,
        avgPrice: 0,
        highPrice: 0,
        lowPrice: 0,
        medianPrice: 0
      };
    }

    const pricesUSD = soldItems.map(item =>
      item.currency === 'JPY' ? item.price / 150 : item.price
    );

    pricesUSD.sort((a, b) => a - b);

    const sum = pricesUSD.reduce((a, b) => a + b, 0);
    const median = pricesUSD[Math.floor(pricesUSD.length / 2)];

    return {
      count: soldItems.length,
      avgPrice: Math.round(sum / pricesUSD.length),
      highPrice: Math.round(Math.max(...pricesUSD)),
      lowPrice: Math.round(Math.min(...pricesUSD)),
      medianPrice: Math.round(median)
    };
  }
}

module.exports = SoldCompsAggregator;