← back to Dw Domain Fleet

scripts/pull-catalog.js

101 lines

#!/usr/bin/env node
/**
 * pull-catalog.js — pull the full live designerwallcoverings.com Shopify catalog
 * into data/catalog.json. All 66 fleet sites filter from this single snapshot.
 *
 * Live /products.json image URLs are guaranteed 200 at fetch time (dw-site-build
 * standing rule: dw_unified snapshots have ~97% stale image URLs).
 */
const fs = require('fs');
const path = require('path');
const https = require('https');
// Canonical showroom-vendor primitive (list + logic in fix-live-board/config).
// Showroom-only vendors (e.g. Phillip Jeffries) are addressable-but-not-discoverable —
// they must never enter the shared fleet pool. Never hardcode a vendor name; edit
// showroom-vendors.json to change the set. TK-11186.
const { isShowroomVendor } = require(path.join(process.env.HOME, 'Projects/fix-live-board/config/showroom-vendor.cjs'));

// CATALOG_OUT lets a verification run write to a scratch path and diff before
// anything touches the file the fleet actually serves. Defaults to the real one.
const OUT = process.env.CATALOG_OUT || path.join(__dirname, '..', 'data', 'catalog.json');

// A variant is the memo sample (not the sellable good) when its SKU ends in
// -Sample/_Sample/" Sample" (DW's convention, case-varying: DWQC-600488-Sample,
// DWTT-81110-SAMPLE) or its variant title is exactly "Sample". Anchored so a
// pattern legitimately named e.g. "Sampler" is never swallowed.
function isSampleVariant(v) {
  if (!v) return false;
  return /(^|[-_ ])sample$/i.test(String(v.sku || '')) ||
         /^sample$/i.test(String(v.title || '').trim());
}

function get(url) {
  return new Promise((resolve, reject) => {
    https.get(url, { headers: { 'User-Agent': 'dw-domain-fleet/1.0' } }, (res) => {
      let body = '';
      res.on('data', (c) => (body += c));
      res.on('end', () => {
        try { resolve(JSON.parse(body)); }
        catch (e) { reject(new Error('bad JSON from ' + url)); }
      });
    }).on('error', reject);
  });
}

// Exported so scripts/test-pull-catalog.js can exercise the variant-selection
// rule directly; guarded by require.main so importing it never fires a live pull.
function pickVariant(p) {
  const variants = Array.isArray(p && p.variants) ? p.variants : [];
  return variants.find(v => !isSampleVariant(v)) || variants[0] || {};
}
module.exports = { isSampleVariant, pickVariant };

// Only pull when run directly, so `require()` in tests never fires a live fetch.
if (require.main === module) (async () => {
  const all = [];
  for (let page = 1; page <= 60; page++) {
    const url = `https://designerwallcoverings.com/products.json?limit=250&page=${page}`;
    let data;
    try { data = await get(url); }
    catch (e) { console.error(`page ${page}: ${e.message}`); break; }
    const prods = (data && data.products) || [];
    if (!prods.length) break;
    for (const p of prods) {
      // Showroom-only vendors never enter the shared fleet catalog (TK-11186).
      if (isShowroomVendor(p.vendor)) continue;
      const img = (p.images && p.images[0] && p.images[0].src) || '';
      // Flatten to the SELLABLE variant, not blindly to variants[0] (TK-11463).
      // DW puts the $4.25 memo Sample at position 1 on most products, so
      // variants[0] is frequently the sample and not the good being sold. Taking
      // it verbatim recorded e.g. sku "DWQW-61260-Sample" / price "4.25" for a
      // roll that actually sells at $150.43 — 903 products store-wide. That SKU
      // is what the fleet card prints as the pattern's identity (render.js card()
      // data-sku + the "Designer Wallcoverings · <sku>" meta line) and that price
      // is what the mandated Price ↑/↓ sort orders on, so those rows advertised a
      // sample's identity and sorted at $4.25 while selling for $150–$533.
      // Prefer the first non-sample variant; fall back to variants[0] when EVERY
      // variant is a sample, so the 3,585 genuinely sample-only products keep
      // their correct -Sample SKU and $4.25 price rather than being mislabelled
      // the other way.
      const v0 = pickVariant(p);
      all.push({
        id: p.id,
        title: p.title || '',
        handle: p.handle || '',
        vendor: p.vendor || '',
        product_type: p.product_type || '',
        tags: Array.isArray(p.tags) ? p.tags : String(p.tags || '').split(',').map(s => s.trim()).filter(Boolean),
        image_url: img,
        sku: v0.sku || '',
        price: v0.price || '',
        created_at: p.created_at || '',
        updated_at: p.updated_at || ''
      });
    }
    process.stdout.write(`page ${page}: +${prods.length} (total ${all.length})\r`);
    await new Promise(r => setTimeout(r, 250));
  }
  fs.writeFileSync(OUT, JSON.stringify(all));
  console.log(`\nWrote ${all.length} products → ${OUT}`);
})();