← 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 };