← back to Whatsmystyle

scripts/clothing-apis/woocommerce.js

123 lines

/**
 * WooCommerce Store API adapter — zero key, paginated, public.
 *
 * Endpoint: GET https://<domain>/wp-json/wc/store/v1/products?per_page=N&page=P
 *
 * The Store API is read-only and public on every default WooCommerce
 * install. Some stores firewall /wp-json/ — in that case the probe returns
 * HTML (Apache default 404) instead of JSON, which we detect like the
 * Shopify adapter does and treat as "not WooCommerce".
 *
 * Tick 20 probing showed most large DTC fashion brands (Reformation, Pact,
 * Frank+Oak, Burton, Kotn) no longer expose this — they're all headless or
 * fully custom. Adapter still useful for the long-tail of small/indie
 * boutiques (~30% of the global WC install base is fashion-adjacent).
 *
 * Add a brand:
 *   brands.json → woocommerce_dtc[] push { id, name, domain, sustain_tier, pro_grade }
 *
 * Same row shape + INSERT OR IGNORE idempotence as the Shopify adapter so
 * the UNIQUE(source, external_id) dedupe just works.
 */
const fetch = global.fetch || require('node-fetch');

const UA = 'WhatsMyStyleBot/0.1 (catalog importer; steve@designerwallcoverings.com)';
const PAGE_SIZE = 100;        // WooCommerce Store API hard cap is 100
const MAX_PAGES = 10;
const PAGE_DELAY_MS = 700;

const CATEGORY_MAP = [
  [/^t.?shirt|tee|tank|cami|blouse|shirt|button[- ]?down|top$/i, 'top'],
  [/sweater|knit|cardigan|pullover|jumper|hoodie/i, 'top'],
  [/pant|trouser|jean|chino|legging|short/i, 'bottom'],
  [/skirt|midi$|mini$/i, 'bottom'],
  [/dress|gown|jumpsuit|romper/i, 'dress'],
  [/coat|jacket|parka|trench|blazer|puffer|outerwear/i, 'outerwear'],
  [/shoe|sneaker|boot|sandal|loafer|heel|flat$/i, 'shoes'],
  [/bag|tote|purse|backpack|crossbody|clutch/i, 'bag'],
  [/scarf|hat|belt|sunglasses|jewelry|earring|necklace|ring$|bracelet|accessor/i, 'accessory'],
];
function normalizeCategory(text) {
  for (const [re, cat] of CATEGORY_MAP) if (re.test(text)) return cat;
  return null;
}

async function fetchOnePage(domain, page) {
  const url = `https://${domain}/wp-json/wc/store/v1/products?per_page=${PAGE_SIZE}&page=${page}`;
  const r = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' }, redirect: 'follow' });
  if (r.status === 404 || r.status === 403) return { products: [], end: true, status: r.status };
  if (r.status === 429) return { products: [], end: true, status: 429 };
  if (!r.ok) throw new Error(`woo ${domain} page ${page} → ${r.status}`);
  const ct = (r.headers.get('content-type') || '').toLowerCase();
  if (!ct.includes('json')) return { products: [], end: true, status: 'not-woo' };
  let j;
  try { j = await r.json(); }
  catch { return { products: [], end: true, status: 'invalid-json' }; }
  if (!Array.isArray(j)) return { products: [], end: true, status: 'no-products-array' };
  return { products: j, end: j.length < PAGE_SIZE, status: r.status };
}

function rowFromProduct(domain, brand, sustainTier, proGrade, p) {
  const price = Number(p.prices?.price || 0);                 // Store API returns price as a string in the minor unit (cents) for most currencies
  const minorMultiplier = Number(p.prices?.currency_minor_unit ?? 2);
  const price_cents = Number.isFinite(price) && price > 0
    ? Math.round(price / Math.pow(10, Math.max(0, minorMultiplier - 2)))   // already in cents when minor=2
    : null;
  const image = p.images?.[0]?.src || p.featured_image || null;
  const text = `${p.name || ''} ${(p.categories || []).map(c => c.name).join(' ')}`;
  const category = normalizeCategory(text);
  if (!image || !price_cents || !category) return null;

  return {
    source: `woocommerce:${domain}`,
    external_id: `woo:${domain}:${p.id}`,
    title: (p.name || '').trim().slice(0, 200),
    brand: brand || domain,
    category,
    color: null,
    pattern: null,
    material: null,
    price_cents,
    currency: p.prices?.currency_code || 'USD',
    image_url: image,
    product_url: p.permalink || `https://${domain}/?p=${p.id}`,
    tags: JSON.stringify((p.categories || []).map(c => c.name)),
    pro_grade: proGrade,
  };
}

async function importBrand(db, brandEntry) {
  const { name, domain, sustain_tier, pro_grade } = brandEntry;
  const t0 = Date.now();

  const all = [];
  for (let page = 1; page <= MAX_PAGES; page++) {
    const { products, end, status } = await fetchOnePage(domain, page);
    if (status !== 200) return { brand: name, domain, ok: false, status, fetched: all.length, imported: 0, ms: Date.now() - t0 };
    all.push(...products);
    if (end) break;
    await new Promise(r => setTimeout(r, PAGE_DELAY_MS));
  }

  const insert = db.prepare(`
    INSERT OR IGNORE INTO items
      (source, external_id, title, brand, category, price_cents, currency, image_url, product_url, tags, pro_grade)
    VALUES
      (@source, @external_id, @title, @brand, @category, @price_cents, @currency, @image_url, @product_url, @tags, @pro_grade)
  `);
  let imported = 0, skipped = 0;
  const tx = db.transaction((rows) => {
    for (const r of rows) {
      if (!r) { skipped++; continue; }
      const res = insert.run(r);
      if (res.changes > 0) imported++;
      else skipped++;
    }
  });
  tx(all.map(p => rowFromProduct(domain, name, sustain_tier, pro_grade, p)));

  return { brand: name, domain, ok: true, fetched: all.length, imported, skipped, ms: Date.now() - t0 };
}

module.exports = { importBrand };