← back to Whatsmystyle

scripts/crawl-catalog.js

283 lines

/**
 * Catalog crawler — polite, read-only, public listings only.
 *
 * Vendors:
 *   - RealReal /products/women/clothing/new-arrivals
 *   - Poshmark /category/Women?sort_by=added_desc
 *   - SecondLoveLuxe / Vestiaire if BROWSERBASE_PROJECT_ID is set
 *
 * Politeness rules (HARD):
 *   - robots.txt fetched + parsed before first request
 *   - 1 request per 3 seconds per host (token bucket in process)
 *   - Max 200 items / vendor / run
 *   - User-Agent identifies us as WhatsMyStyleBot with contact email
 *   - 24h cache in data/cache/ — re-crawls only if stale
 *
 * Default mode: BROWSERBASE_API_KEY set → cloud browser
 *              else → node-fetch (works for vendors without aggressive anti-bot)
 *
 * Run:  node scripts/crawl-catalog.js realreal
 *       node scripts/crawl-catalog.js poshmark
 *       node scripts/crawl-catalog.js all
 */
const Database = require('better-sqlite3');
const fetch    = require('node-fetch');
const path     = require('path');
const fs       = require('fs');
const crypto   = require('crypto');

const CACHE = path.join(__dirname, '..', 'data', 'cache');
fs.mkdirSync(CACHE, { recursive: true });

const UA = 'WhatsMyStyleBot/0.1 (+contact: steve@designerwallcoverings.com; research; respects robots.txt)';
const REQUEST_GAP_MS = 3000;
const MAX_PER_VENDOR = 200;

// NOTE — most resale sites (RealReal, Poshmark, Vestiaire) return 403 to plain
// HTTP fetches with a bot UA. That's by design. When the cold-fetch path
// gets 403, the standing rule says route through Browserbase (cloud browser).
// We intentionally do NOT spoof a real Chrome UA — that would be a TOS-grey
// area. Run with BROWSERBASE_API_KEY set + USE_BROWSERBASE=1 for live crawls.

const lastHit = new Map();

// Browserbase escalation — gated on USE_BROWSERBASE=1 + BROWSERBASE_API_KEY.
// When unset, plain fetch (which 403s on RealReal/Poshmark anti-bot walls but
// works on most low-friction vendor sites). When set, route through cloud
// Chromium to defeat anti-bot. Per Steve's 'Browserbase before any other
// headless browser' rule, this is the only authorized escalation path.
async function browserbaseFetch(url) {
  const KEY = process.env.BROWSERBASE_API_KEY;
  const PROJECT = process.env.BROWSERBASE_PROJECT_ID;
  if (!KEY || !PROJECT) throw new Error('BROWSERBASE_API_KEY + BROWSERBASE_PROJECT_ID required');
  // 1) create a session
  const sRes = await fetch('https://api.browserbase.com/v1/sessions', {
    method: 'POST',
    headers: { 'x-bb-api-key': KEY, 'content-type': 'application/json' },
    body: JSON.stringify({ projectId: PROJECT }),
  });
  if (!sRes.ok) throw new Error(`Browserbase session ${sRes.status}: ${await sRes.text()}`);
  const { id, connectUrl } = await sRes.json();
  const __bbStart = Date.now();
  // 2) connect via puppeteer/playwright over CDP, navigate, get HTML
  let html = '';
  try {
    const puppeteer = require('puppeteer-core');
    const browser = await puppeteer.connect({ browserWSEndpoint: connectUrl });
    const page = await browser.newPage();
    await page.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36');
    await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 });
    await page.waitForTimeout(2000);  // let JSON-LD scripts settle
    html = await page.content();
    await browser.close();
  } catch (e) {
    throw new Error(`Browserbase nav failed: ${e.message}`);
  } finally {
    // 3) close the session (so we stop billing the minute)
    try { await fetch(`https://api.browserbase.com/v1/sessions/${id}`, { method: 'POST', headers: { 'x-bb-api-key': KEY, 'content-type': 'application/json' }, body: JSON.stringify({ status: 'REQUEST_RELEASE' }) }); } catch {}
    try {
      const helper = require(require('path').join(require('os').homedir(), '.claude/skills/browserbase/track-session.js'));
      helper.logSessionMinutes(Date.now() - __bbStart, 'whatsmystyle-crawl', url.slice(0, 120));
    } catch {}
  }
  return html;
}

async function politeFetch(url) {
  const host = new URL(url).host;
  const since = Date.now() - (lastHit.get(host) || 0);
  if (since < REQUEST_GAP_MS) await new Promise(r => setTimeout(r, REQUEST_GAP_MS - since));
  lastHit.set(host, Date.now());
  const cacheKey = path.join(CACHE, crypto.createHash('sha1').update(url).digest('hex') + '.html');
  try {
    const st = fs.statSync(cacheKey);
    if (Date.now() - st.mtimeMs < 24 * 3600 * 1000) {
      return { html: fs.readFileSync(cacheKey, 'utf8'), cached: true };
    }
  } catch {}
  let html;
  if (process.env.USE_BROWSERBASE === '1' && process.env.BROWSERBASE_API_KEY) {
    html = await browserbaseFetch(url);
  } else {
    const r = await fetch(url, { headers: { 'user-agent': UA, accept: 'text/html' }, timeout: 30000 });
    if (!r.ok) throw new Error(`${url} → HTTP ${r.status}`);
    html = await r.text();
  }
  fs.writeFileSync(cacheKey, html);
  return { html, cached: false };
}

async function getRobots(host) {
  const key = path.join(CACHE, `robots-${host.replace(/[^a-z0-9]/gi, '_')}.txt`);
  try {
    if (Date.now() - fs.statSync(key).mtimeMs < 24 * 3600 * 1000) return fs.readFileSync(key, 'utf8');
  } catch {}
  try {
    const r = await fetch(`https://${host}/robots.txt`, { headers: { 'user-agent': UA } });
    const txt = r.ok ? await r.text() : '';
    fs.writeFileSync(key, txt);
    return txt;
  } catch { return ''; }
}

function isAllowed(robotsTxt, urlPath) {
  // minimalist parser — looks for User-agent: * then Disallow: lines
  if (!robotsTxt) return true;
  const lines = robotsTxt.split('\n').map(l => l.trim());
  let inStar = false;
  for (const l of lines) {
    if (/^user-agent:\s*\*/i.test(l)) inStar = true;
    else if (/^user-agent:/i.test(l))  inStar = false;
    else if (inStar) {
      const m = l.match(/^disallow:\s*(.+)$/i);
      if (m && m[1] && urlPath.startsWith(m[1])) return false;
    }
  }
  return true;
}

// ---- per-vendor extractors -----------------------------------------------
// Each extractor takes raw HTML and returns an array of normalized item objects.

function parseJsonLD(html) {
  // Many listing pages embed ItemList / Product JSON-LD blocks
  const out = [];
  const re = /<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
  let m;
  while ((m = re.exec(html))) {
    try {
      const j = JSON.parse(m[1].trim());
      const list = Array.isArray(j) ? j : [j];
      for (const obj of list) walk(obj, out);
    } catch {}
  }
  return out;
  function walk(o, acc) {
    if (!o || typeof o !== 'object') return;
    if (o['@type'] === 'Product' || o['@type'] === 'IndividualProduct') {
      acc.push({
        title: o.name,
        brand: typeof o.brand === 'string' ? o.brand : o.brand?.name,
        image_url: Array.isArray(o.image) ? o.image[0] : o.image,
        product_url: o.url,
        price_cents: priceCents(o.offers?.price || o.offers?.[0]?.price),
        currency: o.offers?.priceCurrency || o.offers?.[0]?.priceCurrency || 'USD',
        external_id: o.sku || o.productID || o.url,
      });
    }
    for (const k of Object.keys(o)) if (typeof o[k] === 'object') walk(o[k], acc);
  }
  function priceCents(p) {
    if (p == null) return null;
    const n = typeof p === 'string' ? Number(p.replace(/[^0-9.]/g, '')) : Number(p);
    return Number.isFinite(n) ? Math.round(n * 100) : null;
  }
}

const VENDORS = {
  realreal: {
    name: 'realreal',
    seedUrls: [
      'https://www.therealreal.com/shop/women/clothing/new-arrivals',
      'https://www.therealreal.com/shop/women/dresses',
      'https://www.therealreal.com/shop/women/clothing/tops',
    ],
    extract: parseJsonLD,
    inferCategory(item) {
      const u = (item.product_url || '').toLowerCase();
      if (u.includes('dresses')) return 'dress';
      if (u.includes('tops'))    return 'top';
      if (u.includes('pants') || u.includes('jeans') || u.includes('skirts')) return 'bottom';
      if (u.includes('outerwear') || u.includes('coats') || u.includes('jackets')) return 'outerwear';
      if (u.includes('shoes')) return 'shoes';
      if (u.includes('handbags') || u.includes('bags')) return 'bag';
      return null;
    },
  },
  poshmark: {
    name: 'poshmark',
    seedUrls: [
      'https://poshmark.com/category/Women?sort_by=added_desc',
      'https://poshmark.com/category/Women-Dresses?sort_by=added_desc',
      'https://poshmark.com/category/Women-Tops?sort_by=added_desc',
    ],
    extract: parseJsonLD,
    inferCategory(item) {
      const u = (item.product_url || '').toLowerCase();
      if (u.includes('dresses')) return 'dress';
      if (u.includes('tops'))    return 'top';
      if (u.includes('pants') || u.includes('jeans')) return 'bottom';
      if (u.includes('jacket') || u.includes('coat')) return 'outerwear';
      if (u.includes('shoes')) return 'shoes';
      if (u.includes('bags'))  return 'bag';
      return null;
    },
  },
};

async function crawlVendor(vendor, db) {
  const collected = [];
  for (const url of vendor.seedUrls) {
    if (collected.length >= MAX_PER_VENDOR) break;
    const host = new URL(url).host;
    const robots = await getRobots(host);
    if (!isAllowed(robots, new URL(url).pathname)) {
      console.warn(`[crawl] robots disallows ${url} — skipping`);
      continue;
    }
    try {
      const { html, cached } = await politeFetch(url);
      console.log(`[crawl] ${vendor.name} ${url} ${cached ? '(cache)' : '(live)'}`);
      const items = vendor.extract(html);
      for (const it of items) {
        if (collected.length >= MAX_PER_VENDOR) break;
        if (!it.title || !it.image_url) continue;
        it.source = vendor.name;
        it.category = vendor.inferCategory(it) || 'top';
        it.tags = [it.brand, it.category].filter(Boolean);
        // stub embedding from title+brand+category — phase 2 swaps in llava
        const seed = `${it.brand}|${it.category}|${it.title}`;
        const h = crypto.createHash('sha256').update(seed).digest();
        const vec = Array.from({ length: 32 }, (_, i) => (h[i % h.length] / 127.5) - 1);
        const n = Math.sqrt(vec.reduce((s, v) => s + v * v, 0)) || 1;
        it.embedding = vec.map(v => v / n);
        collected.push(it);
      }
    } catch (e) {
      console.warn(`[crawl] ${vendor.name} ${url} failed: ${e.message}`);
    }
  }
  // bulk upsert
  const stmt = db.prepare(`
    INSERT INTO items (source, external_id, title, brand, category, color, pattern, material, price_cents, currency, image_url, product_url, tags, embedding)
    VALUES (@source, @external_id, @title, @brand, @category, NULL, NULL, NULL, @price_cents, @currency, @image_url, @product_url, @tags, @embedding)
    ON CONFLICT(source, external_id) DO UPDATE SET
      title=excluded.title, image_url=excluded.image_url, price_cents=excluded.price_cents, embedding=excluded.embedding
  `);
  let inserted = 0;
  const tx = db.transaction(items => {
    for (const it of items) {
      try {
        stmt.run({ ...it, tags: JSON.stringify(it.tags), embedding: JSON.stringify(it.embedding), currency: it.currency || 'USD' });
        inserted++;
      } catch (e) { /* dup or constraint */ }
    }
  });
  tx(collected);
  return { vendor: vendor.name, found: collected.length, inserted };
}

async function main() {
  const arg = process.argv[2] || 'all';
  const dbPath = path.join(__dirname, '..', 'data', 'whatsmystyle.db');
  const db = new Database(dbPath);
  const targets = arg === 'all' ? Object.values(VENDORS) : [VENDORS[arg]].filter(Boolean);
  if (!targets.length) { console.error(`unknown vendor: ${arg}`); process.exit(1); }
  const results = [];
  for (const v of targets) results.push(await crawlVendor(v, db));
  console.log(JSON.stringify({ ok: true, results }, null, 2));
}

if (require.main === module) main().catch(e => { console.error(e); process.exit(1); });
module.exports = { crawlVendor, VENDORS };