← back to Handbag Auth Nextjs

claude-automation/ingestion.ts

217 lines

#!/usr/bin/env ts-node
/**
 * Claude Code Automation: Handbag Data Ingestion
 * Runs every 0.9 seconds to fetch latest handbag listings
 */

import axios from 'axios'
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

interface Listing {
  sku: string
  brand: string
  model: string
  price: number
  condition: string
  imageUrl: string
  source: string
  affiliateLink?: string
}

class HandbagIngestion {
  private sources = [
    { name: 'Fashionphile', baseUrl: 'https://www.fashionphile.com', affiliateId: 'luxvault' },
    { name: 'TheRealReal', baseUrl: 'https://www.therealreal.com', affiliateId: 'luxvault' },
    { name: 'Rebag', baseUrl: 'https://shop.rebag.com', affiliateId: 'luxvault' },
    { name: 'Vestiaire', baseUrl: 'https://www.vestiairecollective.com', affiliateId: 'luxvault' },
    { name: 'StockX', baseUrl: 'https://stockx.com/handbags', affiliateId: 'luxvault' }
  ]

  private topBrands = [
    'Hermès', 'Chanel', 'Louis Vuitton', 'Dior', 'Gucci',
    'Prada', 'Fendi', 'Bottega Veneta', 'Celine', 'Goyard',
    'Balenciaga', 'Saint Laurent', 'Valentino', 'Burberry', 'Loewe',
    'Givenchy', 'Chloé', 'Mulberry', 'Proenza Schouler', 'The Row'
  ]

  private topModels = [
    'Birkin', 'Kelly', 'Constance', 'Classic Flap', 'Boy Bag',
    'Neverfull', 'Speedy', 'Alma', 'Saddle', 'Lady Dior',
    'Marmont', 'Jackie', 'Baguette', 'Peekaboo', 'Cassette',
    'Cabat', 'City', 'Puzzle', 'Belt Bag', 'Tote'
  ]

  async scrapeSource(source: any): Promise<Listing[]> {
    try {
      // This is a simplified version - in production, use proper web scraping
      // with Playwright/Puppeteer for JavaScript-heavy sites

      const listings: Listing[] = []

      // Mock data for demonstration
      // Real implementation would scrape actual websites
      for (let i = 0; i < 20; i++) {
        const brand = this.topBrands[Math.floor(Math.random() * this.topBrands.length)]
        const model = this.topModels[Math.floor(Math.random() * this.topModels.length)]

        listings.push({
          sku: `${brand.replace(/\s+/g, '_')}_${model.replace(/\s+/g, '_')}_${Date.now()}_${i}`,
          brand,
          model,
          price: Math.floor(Math.random() * 50000) + 1000,
          condition: ['New', 'Excellent', 'Very Good', 'Good'][Math.floor(Math.random() * 4)],
          imageUrl: `https://placeholder.com/handbag-${i}.jpg`,
          source: source.name,
          affiliateLink: `${source.baseUrl}/product-${i}?aff=${source.affiliateId}`
        })
      }

      return listings
    } catch (error) {
      console.error(`Error scraping ${source.name}:`, error)
      return []
    }
  }

  async normalizeListing(listing: Listing): Promise<Listing> {
    // Normalize brand and model names
    listing.brand = listing.brand.trim().toUpperCase()
    listing.model = listing.model.trim()

    // Normalize condition
    const conditionMap: Record<string, string> = {
      'NEW': 'New',
      'EXCELLENT': 'Excellent',
      'VERY GOOD': 'Very Good',
      'GOOD': 'Good'
    }
    listing.condition = conditionMap[listing.condition.toUpperCase()] || listing.condition

    return listing
  }

  async detectAnomalies(listing: Listing): Promise<boolean> {
    // Check if price is anomalously low
    // Query historical prices for this SKU
    const historicalPrices = await prisma.listing.findMany({
      where: {
        brand: listing.brand,
        model: listing.model
      },
      orderBy: { crawledAt: 'desc' },
      take: 30,
      select: { priceUsd: true }
    })

    if (historicalPrices.length > 0) {
      const avgPrice = historicalPrices.reduce((sum, p) => sum + (p.priceUsd || 0), 0) / historicalPrices.length
      const priceDropPercent = ((avgPrice - listing.price) / avgPrice) * 100

      if (priceDropPercent > 20) {
        console.log(`🚨 Anomaly detected: ${listing.brand} ${listing.model} is ${priceDropPercent.toFixed(1)}% below average`)
        return true
      }
    }

    return false
  }

  async storeListing(listing: Listing): Promise<void> {
    try {
      // Try to find existing listing first
      const existing = await prisma.listing.findFirst({
        where: { productUrl: listing.affiliateLink || '' }
      })

      if (existing) {
        await prisma.listing.update({
          where: { id: existing.id },
          data: {
            priceUsd: listing.price,
            condition: listing.condition,
            imageUrl: listing.imageUrl,
            crawledAt: new Date().toISOString()
          }
        })
      } else {
        await prisma.listing.create({
          data: {
          brand: listing.brand,
          title: `${listing.brand} ${listing.model}`,
          model: listing.model,
          priceJpy: listing.price * 150, // Convert USD to JPY (mock rate)
          priceUsd: listing.price,
          condition: listing.condition,
          imageUrl: listing.imageUrl,
          productUrl: listing.affiliateLink || '',
          listingType: 'buy-now',
          source: listing.source,
          crawledAt: new Date().toISOString()
          }
        })
      }
    } catch (error) {
      console.error('Error storing listing:', error)
    }
  }

  async run(): Promise<void> {
    console.log(`[${new Date().toISOString()}] 🔄 Starting handbag ingestion...`)

    const startTime = Date.now()
    let totalListings = 0
    let anomalies = 0
    let errors = 0

    for (const source of this.sources) {
      try {
        const listings = await this.scrapeSource(source)

        for (const listing of listings) {
          const normalized = await this.normalizeListing(listing)
          const isAnomaly = await this.detectAnomalies(normalized)

          if (isAnomaly) anomalies++

          await this.storeListing(normalized)
          totalListings++
        }
      } catch (error) {
        errors++
        console.error(`Error processing source ${source.name}:`, error)
      }
    }

    const duration = Date.now() - startTime

    console.log(`✅ Ingestion complete: ${totalListings} listings, ${anomalies} anomalies, ${errors} errors (${duration}ms)`)

    // Send Slack notification if configured
    if (process.env.SLACK_WEBHOOK_URL) {
      await this.notifySlack(totalListings, anomalies, errors, duration)
    }
  }

  async notifySlack(total: number, anomalies: number, errors: number, duration: number): Promise<void> {
    try {
      await axios.post(process.env.SLACK_WEBHOOK_URL!, {
        text: `📊 Handbag Ingestion Report\n• Total: ${total} listings\n• Anomalies: ${anomalies}\n• Errors: ${errors}\n• Duration: ${duration}ms`
      })
    } catch (error) {
      console.error('Failed to send Slack notification:', error)
    }
  }
}

// Run continuously every 0.9 seconds
const ingestion = new HandbagIngestion()

setInterval(async () => {
  await ingestion.run()
}, 900) // 0.9 seconds

// Initial run
ingestion.run()