← back to Handbag Auth Nextjs

scripts/crawlAffiliateSites.ts

241 lines

// Automated web crawler for top 20 luxury handbag affiliate sites
// Crawls for new listings, extracts images, prices, and product data

import puppeteer from "puppeteer";
import fs from "fs";
import path from "path";
import crypto from "crypto";

interface CrawledListing {
  id: string;
  brand: string;
  title: string;
  price?: number;
  currency: string;
  imageUrl: string;
  productUrl: string;
  merchant: string;
  condition: "new" | "used" | "auction";
  crawledAt: string;
  description?: string;
}

// Top affiliate merchants with good image policies
const AFFILIATE_MERCHANTS = [
  {
    id: "fashionphile",
    name: "Fashionphile",
    baseUrl: "https://www.fashionphile.com",
    searchUrl: (brand: string) => `https://www.fashionphile.com/shop/bags?designers=${encodeURIComponent(brand)}`,
    allowsImages: true,
    condition: "used" as const,
    selectors: {
      items: ".product-tile",
      title: ".product-name",
      price: ".product-price",
      image: "img[data-src]",
      link: "a.product-link",
    }
  },
  {
    id: "rebag",
    name: "Rebag",
    baseUrl: "https://www.rebag.com",
    searchUrl: (brand: string) => `https://www.rebag.com/shop?brand=${encodeURIComponent(brand)}`,
    allowsImages: true,
    condition: "used" as const,
    selectors: {
      items: "[data-testid='product-card']",
      title: "[data-testid='product-title']",
      price: "[data-testid='product-price']",
      image: "img[src]",
      link: "a[href*='/products/']",
    }
  },
  {
    id: "therealreal",
    name: "The RealReal",
    baseUrl: "https://www.therealreal.com",
    searchUrl: (brand: string) => `https://www.therealreal.com/designers/${brand.toLowerCase().replace(/ /g, "-")}/handbags`,
    allowsImages: true,
    condition: "used" as const,
    selectors: {
      items: ".product-tile",
      title: ".product-title",
      price: ".product-price",
      image: "img.product-image",
      link: "a.product-link",
    }
  },
  {
    id: "vestiaire",
    name: "Vestiaire Collective",
    baseUrl: "https://us.vestiairecollective.com",
    searchUrl: (brand: string) => `https://us.vestiairecollective.com/search/?q=${encodeURIComponent(brand + " bag")}`,
    allowsImages: true,
    condition: "used" as const,
    selectors: {
      items: "[data-testid='productCard']",
      title: "[data-testid='productTitle']",
      price: "[data-testid='price']",
      image: "img[alt]",
      link: "a[href*='/items/']",
    }
  },
  {
    id: "farfetch",
    name: "Farfetch",
    baseUrl: "https://www.farfetch.com",
    searchUrl: (brand: string) => `https://www.farfetch.com/shopping/women/bags-1/items.aspx?designers=${encodeURIComponent(brand)}`,
    allowsImages: true,
    condition: "new" as const,
    selectors: {
      items: "[data-testid='productCard']",
      title: "[data-testid='productTitle']",
      price: "[data-testid='price']",
      image: "img",
      link: "a",
    }
  }
];

function loadTopBrands(): string[] {
  const file = path.resolve("data/top_luxury_brands.json");
  if (!fs.existsSync(file)) {
    return ["HERMÈS", "CHANEL", "LOUIS VUITTON", "DIOR", "GUCCI"];
  }
  const brands = JSON.parse(fs.readFileSync(file, "utf8"));
  return brands.slice(0, 20).map((b: any) => b.brand);
}

function saveCrawledListings(listings: CrawledListing[]) {
  const file = path.resolve("data/crawled_affiliate_listings.json");
  const existing = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : [];

  const combined = [...existing, ...listings];

  // Deduplicate by ID
  const unique = Array.from(new Map(combined.map(l => [l.id, l])).values());

  fs.writeFileSync(file, JSON.stringify(unique, null, 2));
  return unique.length - existing.length;
}

async function crawlMerchant(merchant: typeof AFFILIATE_MERCHANTS[0], brand: string): Promise<CrawledListing[]> {
  console.log(`\nCrawling ${merchant.name} for ${brand}...`);

  const browser = await puppeteer.launch({
    headless: true,
    args: ['--no-sandbox', '--disable-setuid-sandbox']
  });

  try {
    const page = await browser.newPage();
    await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36');

    const url = merchant.searchUrl(brand);
    console.log(`  URL: ${url}`);

    await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });

    // Wait for products to load
    await page.waitForSelector(merchant.selectors.items, { timeout: 10000 }).catch(() => {
      console.log(`  No items found for ${brand} on ${merchant.name}`);
    });

    const listings: CrawledListing[] = await page.evaluate((selectors, merchantData) => {
      const items = document.querySelectorAll(selectors.items);
      const results: CrawledListing[] = [];

      items.forEach((item, idx) => {
        if (idx >= 50) return; // Limit to 50 per brand per merchant

        const titleEl = item.querySelector(selectors.title);
        const priceEl = item.querySelector(selectors.price);
        const imageEl = item.querySelector(selectors.image);
        const linkEl = item.querySelector(selectors.link);

        if (!titleEl || !imageEl || !linkEl) return;

        const title = titleEl.textContent?.trim() || "";
        const priceText = priceEl?.textContent?.trim() || "";
        const price = parseFloat(priceText.replace(/[^0-9.]/g, ""));
        const imageUrl = (imageEl as HTMLImageElement).src || (imageEl as HTMLImageElement).getAttribute('data-src') || "";
        const productUrl = (linkEl as HTMLAnchorElement).href || "";

        if (title && imageUrl && productUrl) {
          results.push({
            id: crypto.createHash('md5').update(productUrl).digest('hex').slice(0, 12),
            brand: merchantData.brand,
            title,
            price: price || undefined,
            currency: "USD",
            imageUrl,
            productUrl,
            merchant: merchantData.merchantName,
            condition: merchantData.condition,
            crawledAt: new Date().toISOString(),
          } as any);
        }
      });

      return results;
    }, merchant.selectors, {
      brand,
      merchantName: merchant.name,
      condition: merchant.condition
    });

    console.log(`  Found ${listings.length} items`);
    await browser.close();
    return listings;

  } catch (error) {
    console.error(`  Error crawling ${merchant.name}:`, error);
    await browser.close();
    return [];
  }
}

async function crawlAll() {
  console.log("=== Starting Affiliate Site Crawler ===\n");
  console.log("Top 20 Luxury Handbag Brands Only\n");

  const brands = loadTopBrands();
  console.log(`Brands: ${brands.join(", ")}\n`);

  let totalListings: CrawledListing[] = [];

  for (const brand of brands) {
    console.log(`\n--- ${brand} ---`);

    for (const merchant of AFFILIATE_MERCHANTS) {
      if (!merchant.allowsImages) continue;

      const listings = await crawlMerchant(merchant, brand);
      totalListings = [...totalListings, ...listings];

      // Rate limiting
      await new Promise(resolve => setTimeout(resolve, 2000));
    }
  }

  const newCount = saveCrawledListings(totalListings);

  console.log(`\n=== Crawl Complete ===`);
  console.log(`Total items crawled: ${totalListings.length}`);
  console.log(`New items added: ${newCount}`);
  console.log(`Saved to: data/crawled_affiliate_listings.json`);
}

const isMainModule = import.meta.url === `file://${process.argv[1]}`;

if (isMainModule) {
  crawlAll().catch((err) => {
    console.error("Crawl failed:", err);
    process.exit(1);
  });
}

export { crawlAll };