← back to Whatsmystyle
scripts/clothing-apis/shopify.js
149 lines
/**
* Shopify /products.json adapter — zero key, paginated, public.
*
* Every Shopify-powered DTC storefront exposes /products.json by default.
* The endpoint is documented (Shopify's intentional API for the embedded
* Buy Button etc.), returns JSON, and accepts ?limit + ?page query args.
*
* Pull pattern: 250 per page (Shopify's cap), back off if the brand returns
* 429 or 5xx, identify ourselves in User-Agent, stop at MAX_PAGES per brand.
*
* Maps a Shopify product → WhatsMyStyle `items` row:
* id → external_id (composite: shopify:<domain>:<product-id>)
* source → 'shopify:<domain>'
* title → title
* vendor → brand
* product_type→ category (lowercased, normalized to top/bottom/dress/etc.)
* tags → tags (JSON array)
* variants[0].price → price_cents (× 100)
* images[0].src → image_url
* handle → product_url (https://<domain>/products/<handle>)
*
* One brand per call. Idempotent — uses INSERT OR IGNORE so re-running adds
* only new SKUs and updates existing ones aren't trampled.
*/
const fetch = global.fetch || require('node-fetch');
const UA = 'WhatsMyStyleBot/0.1 (catalog importer; steve@designerwallcoverings.com)';
const PAGE_SIZE = 250;
const MAX_PAGES_PER_BRAND = 4; // 4 × 250 = 1000 cap per brand per run
const PAGE_DELAY_MS = 600; // be nice; ~1.6 RPS
// Heuristic mapping from Shopify product_type → our category enum.
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(productType, tags) {
const text = `${productType || ''} ${(tags || []).join(' ')}`;
for (const [re, cat] of CATEGORY_MAP) if (re.test(text)) return cat;
return null;
}
async function fetchOnePage(domain, page) {
const url = `https://${domain}/products.json?limit=${PAGE_SIZE}&page=${page}`;
const r = await fetch(url, { headers: { 'User-Agent': UA, 'Accept': 'application/json' } });
if (r.status === 404) return { products: [], end: true, status: 404 };
if (r.status === 429) return { products: [], end: true, status: 429 };
if (!r.ok) throw new Error(`shopify ${domain} page ${page} → ${r.status}`);
// Tick 19: detect non-Shopify sites that return 200 + HTML (Next.js SPAs,
// custom backends). Without this, we'd silently treat the homepage HTML as
// an empty product list and report "ok: true, fetched: 0".
const ct = (r.headers.get('content-type') || '').toLowerCase();
if (!ct.includes('json')) {
return { products: [], end: true, status: 'not-shopify' };
}
let j;
try { j = await r.json(); }
catch { return { products: [], end: true, status: 'invalid-json' }; }
if (!Array.isArray(j.products)) return { products: [], end: true, status: 'no-products-array' };
return { products: j.products, end: j.products.length < PAGE_SIZE, status: r.status };
}
async function fetchAllPages(domain) {
const all = [];
for (let page = 1; page <= MAX_PAGES_PER_BRAND; page++) {
const { products, end, status } = await fetchOnePage(domain, page);
if (status !== 200) {
return { ok: false, status, products: all };
}
all.push(...products);
if (end) break;
await new Promise(r => setTimeout(r, PAGE_DELAY_MS));
}
return { ok: true, products: all };
}
function rowFromProduct(domain, brand, sustainTier, proGrade, p) {
const variant = (p.variants || [])[0] || {};
const price = Number(variant.price);
const price_cents = Number.isFinite(price) ? Math.round(price * 100) : null;
const image = (p.images || [])[0]?.src || p.image?.src || null;
const category = normalizeCategory(p.product_type, p.tags);
// Skip products with no image OR no price OR no recognized category — they
// can't render in the duel grid anyway.
if (!image || !price_cents || !category) return null;
return {
source: `shopify:${domain}`,
external_id: `shopify:${domain}:${p.id}`,
title: (p.title || '').trim().slice(0, 200),
brand: brand || p.vendor || domain,
category,
color: null,
pattern: null,
material: null,
price_cents,
currency: 'USD',
image_url: image,
product_url: `https://${domain}/products/${p.handle}`,
tags: JSON.stringify(p.tags || []),
pro_grade: proGrade,
_sustain: sustainTier, // not a column on items; importer maps to sustainability via brand match
};
}
async function importBrand(db, brandEntry) {
const { name, domain, sustain_tier, pro_grade } = brandEntry;
const t0 = Date.now();
const { ok, status, products } = await fetchAllPages(domain);
// Prepare a single-statement upsert. items has UNIQUE(source, external_id),
// so INSERT OR IGNORE is the idempotent path.
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++;
}
});
const rows = (products || []).map(p => rowFromProduct(domain, name, sustain_tier, pro_grade, p));
tx(rows);
return {
brand: name, domain, ok, status,
fetched: (products || []).length,
imported,
skipped,
ms: Date.now() - t0,
};
}
module.exports = { importBrand, fetchAllPages, normalizeCategory };