← back to Reidwitlin Landing

scripts/build-rwltd-data.js

399 lines

#!/usr/bin/env node
/**
 * Build data/products.json for the Reid Witlin internal landing from the
 * AUTHORITATIVE Kamatera dw_unified.rwltd_catalog (NOT the Mac2 mirror, which is
 * stale for rwltd — the real images + AI enrichment landed on Kamatera 2026-07-01).
 * Internal, self-contained: NO Shopify URLs, NO rwltd.com URLs are emitted — the
 * landing serves its OWN PDPs + a memo-sample/inquiry CTA. $0 (remote PG read via ssh).
 * Reid Witlin is a Designer Wallcoverings private-label line.
 */
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');

const OUT = path.join(__dirname, '..', 'data', 'products.json');

// tag/colorname -> canonical color bucket (drives the color filter + color sort)
const BUCKET = {
  white: 'white', ivory: 'white', cream: 'white', beige: 'white', 'off-white': 'white', alabaster: 'white', oyster: 'white', vanilla: 'white', sand: 'white',
  grey: 'grey', gray: 'grey', silver: 'grey', neutral: 'grey', taupe: 'grey', slate: 'grey', pewter: 'grey', greige: 'grey', stone: 'grey', 'warm sand': 'grey',
  black: 'black', charcoal: 'black', ebony: 'black', onyx: 'black', 'dark gray': 'grey',
  red: 'red', burgundy: 'red', crimson: 'red', scarlet: 'red', maroon: 'red', brick: 'red',
  orange: 'orange', rust: 'orange', copper: 'orange', terracotta: 'orange', apricot: 'orange', coral: 'orange', spice: 'orange',
  brown: 'brown', tan: 'brown', bronze: 'brown', chocolate: 'brown', chestnut: 'brown', umber: 'brown', cognac: 'brown', mocha: 'brown', walnut: 'brown', tobacco: 'brown', latte: 'brown', java: 'brown', fudge: 'brown',
  gold: 'gold', yellow: 'gold', mustard: 'gold', goldenrod: 'gold', honey: 'gold', amber: 'gold',
  green: 'green', olive: 'green', sage: 'green', emerald: 'green', moss: 'green', forest: 'green', lime: 'green', meadow: 'green',
  teal: 'teal', turquoise: 'teal', aqua: 'teal', ocean: 'teal',
  blue: 'blue', navy: 'blue', indigo: 'blue', cobalt: 'blue', denim: 'blue', admiral: 'blue', midnight: 'blue',
  purple: 'purple', violet: 'purple', lavender: 'purple', plum: 'purple', mauve: 'purple', fuchsia: 'purple',
  pink: 'pink', rose: 'pink', blush: 'pink', magenta: 'pink',
};
const BUCKET_ORDER = ['white','grey','black','pink','red','orange','brown','gold','green','teal','blue','purple'];
const BUCKET_HUE   = { red:0, orange:30, gold:50, green:120, teal:175, blue:215, purple:280, pink:330, brown:25, white:null, grey:null, black:null };

const clean = s => (s == null ? s : String(s)
  .replace(/\bWallpapers\b/gi, 'Wallcoverings').replace(/\bWallpaper\b/gi, 'Wallcovering'));
const titleCase = s => (s || '').replace(/\w\S*/g, t => t.charAt(0).toUpperCase() + t.slice(1).toLowerCase());
// Drop apostrophes BEFORE the char-class collapse so "I'm Crushed" -> "im-crushed"
// (matching the vendor mfr_sku), not "i-m-crushed" (which broke the prefix strip
// and doubled titles into "I'm Crushed Im Crushed Beige").
const slug = s => (s || '').toLowerCase().replace(/['’`]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');

function bucketFrom(candidates) {
  for (const c of candidates) {
    if (!c) continue;
    const key = String(c).toLowerCase().trim();
    if (BUCKET[key]) return BUCKET[key];
    for (const w of key.split(/[\s\-/]+/)) if (BUCKET[w]) return BUCKET[w];
  }
  return null;
}

// --- Colorway + color-bucket recovery -------------------------------------
// rwltd_catalog.color_name is UNRELIABLE: whole series were stamped a single
// color upstream (every "Aura" colorway == "Blue", hue 215, on brown/gold/red
// swatches). The TRUE colorway lives in mfr_sku (aura-muddy-gold) and the TRUE
// hue lives in the AI-vision color_hex. So: trust PIXELS + SKU over the label.
const HEX_RE = /^#?([0-9a-fA-F]{6})$/;
function hexToHsl(hex) {
  const m = hex && String(hex).match(HEX_RE); if (!m) return null;
  const n = m[1], r = parseInt(n.slice(0, 2), 16) / 255, g = parseInt(n.slice(2, 4), 16) / 255, b = parseInt(n.slice(4, 6), 16) / 255;
  const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn, l = (mx + mn) / 2;
  let h = 0, s = 0;
  if (d > 1e-6) {
    s = d / (1 - Math.abs(2 * l - 1));
    if (mx === r) h = ((g - b) / d) % 6; else if (mx === g) h = (b - r) / d + 2; else h = (r - g) / d + 4;
    h *= 60; if (h < 0) h += 360;
  }
  return { h: Math.round(h), s, l };
}
// Classify a hex into the same 12-bucket vocabulary the color filter uses.
// Returns { bucket, hue } — hue is the real degree for chromatic colors, null
// for neutrals (so the color-wheel sort stays granular instead of "all 215").
// Neutral test uses CHROMA (max-min), NOT HSL saturation: HSL 's' spikes toward
// 1.0 on pale/near-black colors (#F5F5DC beige reports s=0.56) and misfiles them
// as chromatic. Chroma is lightness-independent, so beige/taupe/slate read neutral.
function bucketFromHex(hex) {
  const c = hexToHsl(hex); if (!c) return null;
  const m = String(hex).match(HEX_RE); const n = m[1];
  const R = parseInt(n.slice(0, 2), 16) / 255, G = parseInt(n.slice(2, 4), 16) / 255, B = parseInt(n.slice(4, 6), 16) / 255;
  const chroma = Math.max(R, G, B) - Math.min(R, G, B);
  const { h, l } = c;
  if (l >= 0.88) return { bucket: 'white', hue: null };
  if (l <= 0.12) return { bucket: 'black', hue: null };
  if (chroma < 0.12) {
    // Near-neutral by chroma — EXCEPT a muted MID-tone with a clearly COOL
    // direction (green→purple, 120–300°) is a jewel tone (navy "Admiral",
    // deep "Turquoise"), not a grey. Warm low-chroma (beige/taupe, <120°) and
    // near-white/near-black stay neutral. Cool jewel tones fall through to the
    // chromatic classifier below and keep their real hue.
    const jewel = chroma >= 0.06 && l > 0.22 && l < 0.82 && h >= 120 && h < 300;
    if (!jewel) return { bucket: l >= 0.72 ? 'white' : l <= 0.24 ? 'black' : 'grey', hue: null };
  }
  let bucket;
  // A LIGHT, DESATURATED warm color (h 15–70°) is a cream/beige/tan, not a vivid
  // orange or gold — perceived colorfulness needs more chroma at high lightness.
  const warmPaleNeutral = l >= 0.62 && chroma < 0.22;
  if (h < 15 || h >= 345)      bucket = 'red';
  else if (h < 36)             bucket = (l < 0.45 ? 'brown' : warmPaleNeutral ? 'white' : 'orange');
  else if (h < 70)             bucket = (l < 0.30 ? 'brown' : warmPaleNeutral ? 'white' : 'gold');
  else if (h < 160)            bucket = 'green';
  else if (h < 195)            bucket = 'teal';
  else if (h < 255)            bucket = 'blue';
  else if (h < 290)            bucket = 'purple';
  else                         bucket = 'pink';
  return { bucket, hue: h };
}
// Recover the colorway label from mfr_sku by stripping the series-slug prefix
// (aura-muddy-gold → "Muddy Gold"). Falls back to null for pure-numeric codes.
function colorwayFromSku(mfrSku, series) {
  if (!mfrSku) return null;
  let tail = String(mfrSku).toLowerCase();
  const ss = slug(series || '');
  if (ss && tail.startsWith(ss + '-')) tail = tail.slice(ss.length + 1);
  tail = tail.replace(/[-_]+/g, ' ').trim();
  if (!tail || !/[a-z]/.test(tail)) return null; // variant code, not a color name
  return titleCase(tail);
}
// Tidy a colorway label: collapse AI "family/family" slashes (color_name like
// "white/off-white") to the single most-specific segment, drop stray slashes.
function tidyLabel(s) {
  if (!s) return s;
  let out = String(s).trim();
  if (out.includes('/')) {
    const parts = out.split('/').map(x => x.trim()).filter(Boolean).sort((a, b) => b.length - a.length);
    out = parts[0] || out;
  }
  return titleCase(out);
}

// --- Per-colorway swatch recovery -----------------------------------------
// The scrape stored each series' FULL swatch set in every colorway's image
// list, so no image looked "unique" — but the colorway-specific swatch is
// there, named after the colorway (Cassis.jpg, Bloodline_Fawn.jpg,
// 18695-Beige-300x300.jpg) or the mfr_sku variant number (Acquisitions_160).
// Match on the filename to isolate the TRUE colorway swatch — no re-scrape.
const _imgToks = im => im.split('/').pop().split('?')[0].replace(/\.[a-z0-9]+$/i, '')
  .toLowerCase().split(/[^a-z0-9]+/).filter(t => t && !/^\d+x\d+$/.test(t) && t !== 'scaled');
// Mood/room/lifestyle shots are styled scenes, NOT color-true swatches — never match them.
const _NOISE = new Set(['mood', 'moodshot', 'shot', 'room', 'roomshot', 'lifestyle', 'scene', 'moodboard']);
const _isSwatchFile = toks => !toks.some(t => _NOISE.has(t));
// Token-sequence match (not endsWith — that false-matched "Gold"->marigold.jpg). Strip
// the SERIES tokens first so a series name ("Linen Like") can't stand in for a colorway
// ("Linen"), which otherwise let every sibling's file match via the shared "linen" token.
function imgMatchesColorway(im, color, series) {
  const cwToks = String(color || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
  if (!cwToks.length || cwToks.join('').length < 3) return false;
  let f = _imgToks(im);
  if (!_isSwatchFile(f)) return false;
  f = f.filter(x => !/^\d+$/.test(x));
  for (const s of String(series || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)) {
    const i = f.indexOf(s); if (i >= 0) f.splice(i, 1); // remove each series token once
  }
  if (cwToks.length === 1) return f.includes(cwToks[0]);
  for (let i = 0; i + cwToks.length <= f.length; i++) if (cwToks.every((t, j) => f[i + j] === t)) return true;
  return false;
}
function imgMatchesVariant(im, mfrSku) {
  const parts = String(mfrSku || '').split('-'); const v = parts[parts.length - 1];
  if (!/^\d{1,4}$/.test(v)) return false; // only numeric variant codes (Acquisitions_160)
  const f = _imgToks(im);
  if (!_isSwatchFile(f)) return false;   // excludes bouclette_knotty_MOOD_SHOT_3 (kinky-copy-3)
  return f.includes(v);
}

// Pull the authoritative rows from Kamatera as one JSON blob (real-image rows only).
const SQL = `
SELECT json_agg(r) FROM (
  SELECT mfr_sku, dw_sku, shopify_handle, pattern_name, color_name, image_url, all_images,
         ai_description, ai_colors, ai_tags, ai_styles, ai_patterns, color_hex, dominant_color_hex,
         width, spec_width, width_inches, pattern_repeat, spec_repeat, price, cost, created_at
  FROM rwltd_catalog
  WHERE image_url LIKE 'http%' AND COALESCE(image_rejected,false) = false
  ORDER BY pattern_name, color_name, mfr_sku
) r`;

function fetchRows() {
  const oneLine = SQL.replace(/\s+/g, ' ').trim();       // psql -c chokes on embedded newlines over ssh
  const cmd = `ssh -o ConnectTimeout=25 -o BatchMode=yes my-server ${JSON.stringify(`sudo -u postgres psql -d dw_unified -tAc ${JSON.stringify(oneLine)}`)}`;
  const out = execSync(cmd, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }).trim();
  return JSON.parse(out || '[]');
}

function parseArr(v) {
  if (!v) return [];
  if (Array.isArray(v)) return v;
  try { const j = JSON.parse(v); return Array.isArray(j) ? j : []; } catch { return []; }
}

// Vendor ops meta from the LOCAL dw_unified mirror — registry (discount/pricing
// model) + fmpro FileMaker mirror (phone / email / our account #). The account
// field sometimes holds a note ("20% Discount per Jordan 9/23", "On File") —
// only accept short clean account-number-shaped values.
async function fetchVendorMeta(vendorCode, fmName) {
  const { Pool } = require('pg');
  const PW = (() => {
    try { const m = fs.readFileSync(require('os').homedir() + '/Projects/secrets-manager/.env', 'utf8').match(/^DW_ADMIN_DB_PASSWORD=(.*)$/m); return m ? m[1].replace(/^["']|["']$/g, '').trim() : ''; } catch { return ''; }
  })();
  const pool = new Pool({ host: '127.0.0.1', port: 5432, user: 'dw_admin', database: 'dw_unified', password: PW, max: 1 });
  const vr = (await pool.query(`SELECT vendor_name, vendor_discount_pct, pricing_unit, pricing_model, pricing_notes, sample_price FROM vendor_registry WHERE vendor_code=$1`, [vendorCode])).rows[0] || {};
  const fm = (await pool.query(`SELECT phone, email_1, account_num FROM fmpro WHERE vendor_name ILIKE $1 AND (phone IS NOT NULL OR email_1 IS NOT NULL) LIMIT 1`, [fmName])).rows[0] || {};
  await pool.end();
  const acct = (fm.account_num || '').trim();
  const acctOk = /^[A-Za-z0-9][A-Za-z0-9\-\/. ]{1,19}$/.test(acct) && !/discount|resale|on file|%/i.test(acct);
  return {
    name: vr.vendor_name || fmName,
    phone: fm.phone || null,
    email: fm.email_1 || null,
    account_number: acctOk ? acct : null,
    discount_pct: vr.vendor_discount_pct != null ? Number(vr.vendor_discount_pct) : null,
    pricing_unit: vr.pricing_unit || null,
    pricing_model: vr.pricing_model || null,
    pricing_notes: vr.pricing_notes || null,
    sample_price: vr.sample_price != null ? Number(vr.sample_price) : null,
  };
}

async function main() {
  const rows = fetchRows();
  const vendorMeta = await fetchVendorMeta('rwltd', 'REID WITLin');
  const products = [];
  let dropped = 0;
  for (const r of rows) {
    let imgs = parseArr(r.all_images).filter(x => typeof x === 'string' && x.startsWith('http'));
    if (r.image_url && !imgs.includes(r.image_url)) imgs.unshift(r.image_url);
    imgs = [...new Set(imgs)];
    if (imgs.length === 0) { dropped++; continue; }

    const aiColors = parseArr(r.ai_colors);            // [{name,hex,percentage}]
    const colorNames = aiColors.map(c => c && c.name).filter(Boolean);
    const aiTags = parseArr(r.ai_tags);
    const styles = parseArr(r.ai_styles);
    const pattern = titleCase(clean(r.pattern_name || ''));
    // TRUE colorway from mfr_sku (reliable), NOT the mis-scraped color_name.
    let colorway = tidyLabel(colorwayFromSku(r.mfr_sku, r.pattern_name) || clean(r.color_name || ''));
    // Solo-colorway series (sku == series slug, e.g. "By Hand Smooth"): the label
    // echoes the pattern — drop it so the title isn't "By Hand Smooth By Hand Smooth".
    if (colorway && pattern && colorway.toLowerCase().replace(/[^a-z0-9]/g, '') === pattern.toLowerCase().replace(/[^a-z0-9]/g, '')) colorway = '';
    // TRUE hue/bucket from the AI-vision hex (pixels); text words only as fallback.
    const hex = r.color_hex || r.dominant_color_hex || (aiColors[0] && aiColors[0].hex) || null;
    const hexBucket = bucketFromHex(hex);
    const bucket = (hexBucket && hexBucket.bucket) || bucketFrom([colorway, r.color_name, ...colorNames, ...aiTags]);
    const hue = (hexBucket && hexBucket.hue != null) ? hexBucket.hue : (bucket ? BUCKET_HUE[bucket] : null);
    const title = clean(pattern
      ? (colorway ? `${pattern} ${colorway} | Reid Witlin` : `${pattern} | Reid Witlin`)
      : `${r.mfr_sku} | Reid Witlin`);
    const handleBase = r.shopify_handle && r.shopify_handle.trim() ? r.shopify_handle.trim() : slug(pattern || r.mfr_sku);

    products.push({
      handle: `${handleBase}--${slug(r.dw_sku || r.mfr_sku)}`,
      dw_sku: r.dw_sku || null,
      sku: r.mfr_sku,
      title,
      display_eyebrow: pattern || null,
      display_name: colorway || pattern || title,
      series: pattern || null,
      color: colorway || null,
      book: (styles[0] ? titleCase(styles[0]) : 'Reid Witlin'),
      color_bucket: bucket,
      hue,
      hex,
      sat: null, val: bucket === 'white' ? 1 : bucket === 'black' ? 0 : 0.5,
      width: clean(r.spec_width || r.width || (r.width_inches ? `${r.width_inches}"` : null)) || null,
      length: null,
      repeat: clean(r.pattern_repeat || r.spec_repeat) || null,
      material: null,
      match: null,
      price: r.price != null && Number(r.price) > 0 ? Number(r.price) : null,
      cost: r.cost != null && Number(r.cost) > 0 ? Number(r.cost) : null,
      price_code: null,
      body_html: clean(r.ai_description || ''),
      swatch: imgs[0] || null,
      room: imgs[1] || imgs[0] || null,
      images: imgs.slice(0, 12),
      tags: [...new Set([...(styles.map(titleCase)), ...parseArr(r.ai_patterns).map(titleCase)])].slice(0, 12),
      published_at: r.created_at,
      inquiry_sku: r.dw_sku || r.mfr_sku,
    });
  }

  // Gallery de-contamination — a SECOND pass, needs the full set to know which
  // images are shared. Only ~444/996 colorways have a truly unique swatch; the
  // rest reuse series-generic shots that render the WRONG color. So each PDP
  // shows only imagery UNIQUE to that colorway; where nothing is unique we keep
  // the single representative swatch (never a thumbnail strip of other colorways).
  const imgUse = {};
  for (const p of products) for (const im of p.images) imgUse[im] = (imgUse[im] || 0) + 1;
  // PASS A — resolve each product's real colorway swatch (name > variant), and
  // record which image is a CONFIRMED specific swatch for which colorway.
  const claimed = new Map(); // image -> colorway it is a confirmed swatch for
  let byName = 0, byVariant = 0;
  for (const p of products) {
    let real = p.images.find(im => imgMatchesColorway(im, p.color, p.series));
    if (real) byName++;
    else { real = p.images.find(im => imgMatchesVariant(im, p.sku)); if (real) byVariant++; }
    p._real = real || null;
    if (real) claimed.set(real, p.color || '');
  }
  // PASS B — assign galleries. Matched -> the real swatch. Else a colorway-unique
  // image. Else a representative swatch — but NEVER a swatch that PASS A confirmed
  // belongs to a DIFFERENT colorway (that would show a sibling's real photo under
  // a "no photo" note: turn-up-cherry showing canary.jpg). Those become honest 0-image.
  let byUnique = 0, representative = 0, siblingBlocked = 0;
  for (const p of products) {
    if (p._real) { p.images = [p._real]; p.swatch = p._real; p.room = p._real; delete p.image_note; delete p._real; continue; }
    delete p._real;
    const uniq = p.images.filter(im => imgUse[im] === 1);
    if (uniq.length) { p.images = uniq; p.swatch = uniq[0]; p.room = uniq[1] || uniq[0]; byUnique++; continue; }
    const cand = p.swatch || p.images[0] || null;
    const claimedBy = cand ? claimed.get(cand) : null;
    if (cand && claimedBy && claimedBy !== (p.color || '')) {
      // the only image is another colorway's confirmed swatch — show none, don't lie
      siblingBlocked++;
      p.images = []; p.swatch = null; p.room = null;
      p.image_note = 'no colorway-specific photo available';
    } else {
      representative++;
      p.images = cand ? [cand] : [];
      p.room = cand || null;
      p.image_note = 'representative — no colorway-specific photo on file';
    }
  }
  console.log(`  gallery: ${byName} by colorway-name, ${byVariant} by variant#, ${byUnique} unique, ${representative} representative, ${siblingBlocked} sibling-swatch blocked (was 551 rep)`);

  // Colorway-label repair — a THIRD pass, also needs the full set. Some series
  // were stamped one bogus color_name upstream (all "Dream Weaver" == "Off-white")
  // with no recoverable colorway in mfr_sku. Their images are right; only the
  // label lies. Relabel from the pixel-derived colour family so each card's text
  // matches its image. Handles/SKUs untouched — links still hit the same product.
  const { relabelUnreliableSeries } = require('../lib/relabel-colorways');
  const relabel = relabelUnreliableSeries(products);
  if (relabel.productsFixed) console.log(`  colorway labels: relabelled ${relabel.productsFixed} in ${relabel.seriesFixed} mis-stamped series (${relabel.series.join(', ')})`);

  // Source-image backfill — a FOURTH pass, BEFORE the own-photo partition. The
  // scrape lost the per-colorway image mapping; the free rwltd.com Shopify feed
  // still carries an authoritative per-variant featured_image. Recover it so a
  // full rebuild PRESERVES the ~190 recovered photos (auto-restore) instead of
  // regressing every no-own-photo colorway to DRAFT. $0, public feed. If the feed
  // is unreachable, this no-ops (products keep their image_note → drafted) so a
  // rebuild degrades safely rather than failing.
  try {
    const { backfillFromSourceFeed } = require('../lib/backfill-source-images');
    const backfill = await backfillFromSourceFeed(products);
    // Guard the SILENT failure mode: a bot-block / rate-limit / empty CDN response
    // returns HTTP 200 with 0 products (no throw) — which would quietly re-draft
    // ~190 colorways. The real feed has hundreds of patterns; <50 means the feed
    // lied. Throw so the catch logs a LOUD skip instead of silently gutting the
    // catalog. (Contrarian catch, 2026-07-07.)
    if (backfill.sourcePatterns < 50) throw new Error(`source feed suspicious: only ${backfill.sourcePatterns} patterns returned (bot-block/empty?) — refusing to silently skip backfill`);
    console.log(`  source-image backfill: ${backfill.backfilled} colorways got an authoritative photo (feed ${backfill.sourcePatterns} patterns / ${backfill.indexSize} keys)`);
    if (backfill.conflicts.length) console.log(`  ${backfill.conflicts.length} already-imaged differ from source (kept ours): ${backfill.conflicts.map(c => c.handle.split('--')[0]).join(', ')}`);
  } catch (e) {
    // Distinguish a real feed problem from a wiring/integration bug — the catch
    // must not relabel a code error as a network blip (Contrarian Hole 3).
    const kind = /suspicious|ENOTFOUND|ETIMEDOUT|ECONNRESET|socket|getaddrinfo|JSON/i.test(e.message) ? 'feed problem' : 'INTEGRATION ERROR';
    console.log(`  source-image backfill SKIPPED (${kind}: ${e.message}) — no-photo colorways stay drafted`);
  }

  // HARD RULE (Steve, 2026-07-07): all SKUs must have their OWN photo or go to DRAFT.
  // A shared/representative (pattern-level) stand-in does NOT count. `image_note` is
  // set in PASS B exactly when a colorway lacks its own confirmed/unique swatch, so it
  // is the definitive "no own photo" signal. Partition on it: own-photo colorways are
  // PUBLISHED (served), the rest are DRAFTED (kept in the snapshot for audit + auto-
  // restore on a future rebuild once a real photo lands, but never served). The server
  // only ever reads snapshot.products, so drafts are hidden from the grid AND their PDPs.
  const published = products.filter(p => !p.image_note);
  const drafted   = products.filter(p =>  p.image_note);

  const facets = { books: {}, series: {}, colors: {} };
  for (const p of published) {
    if (p.book) facets.books[p.book] = (facets.books[p.book] || 0) + 1;
    if (p.series) facets.series[p.series] = (facets.series[p.series] || 0) + 1;
    if (p.color_bucket) facets.colors[p.color_bucket] = (facets.colors[p.color_bucket] || 0) + 1;
  }
  const orderedColors = BUCKET_ORDER.filter(b => facets.colors[b]).map(b => [b, facets.colors[b]]);

  const snapshot = {
    built_at: new Date().toISOString(),
    source: 'Kamatera dw_unified.rwltd_catalog (INTERNAL — Reid Witlin private label, not on Shopify)',
    count: published.length,
    dropped_no_image: dropped,
    drafted_no_own_photo: drafted.length, // hidden from live per the own-photo rule; auto-restore when a real photo lands
    vendor: vendorMeta,
    facets: {
      total: published.length,
      books: Object.entries(facets.books).sort((a, b) => b[1] - a[1]),
      series: Object.entries(facets.series).sort((a, b) => b[1] - a[1]).slice(0, 300),
      colors: orderedColors,
    },
    products: published,
    drafts: drafted,
  };
  fs.writeFileSync(OUT, JSON.stringify(snapshot));
  console.log(`products.json -> ${OUT}`);
  console.log(`  published (own photo): ${published.length} | drafted (no own photo): ${drafted.length} | dropped (no image): ${dropped}`);
  console.log(`  color buckets: ${orderedColors.map(c => c[0] + ':' + c[1]).join(', ')}`);
  console.log(`  patterns: ${snapshot.facets.series.length} | books: ${snapshot.facets.books.length}`);
}
main().catch(e => { console.error(e); process.exit(1); });