← back to Reidwitlin Landing

lib/backfill-source-images.js

90 lines

/**
 * Authoritative per-colorway image backfill from the FREE rwltd.com Shopify feed.
 *
 * The mis-scraped rwltd_catalog flattened every pattern's images into every
 * colorway, leaving ~197 colorways with no colorway-specific photo. The SOURCE
 * store (rwltd.com/products.json, public, $0) carries a per-VARIANT
 * featured_image — ground-truth colorway->photo. Key the feed by
 * slug("<pattern title>-<variant option>") (handles numeric counters AND named
 * colorways) and backfill each no-photo product's real image, clearing its
 * image_note so the own-photo-or-draft partition auto-publishes it.
 *
 * Shared by scripts/build-rwltd-data.js (so a full rebuild PRESERVES the recovered
 * photos instead of regressing to 197 no-photo) and scripts/backfill-source-images.js
 * (the standalone DB-free patcher). $0 — public feed, no auth/Browserbase.
 */
const https = require('https');

const MAX_PAGES = 6; // rwltd.com typically 3–5 pages; 6 is a safe ceiling

// MUST match build-rwltd-data.js's slug: drop apostrophes BEFORE the char-class
// collapse so "I'm Crushed" -> "im-crushed" (matching the vendor mfr_sku), not
// "i-m-crushed". A divergence here silently breaks the feed-key<->sku lookup for
// any apostrophe-titled pattern. (Contrarian catch, 2026-07-07.)
const slug = s => String(s || '').toLowerCase().replace(/['’`]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');

function get(url) {
  // Shopify serves an empty body to the default node UA — send a browser UA.
  const opts = { headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36' } };
  return new Promise((res, rej) => {
    https.get(url, opts, r => {
      if (r.statusCode >= 300 && r.statusCode < 400 && r.headers.location) return res(get(r.headers.location));
      let d = '';
      r.on('data', c => { d += c; });
      r.on('end', () => res(d));
    }).on('error', rej);
  });
}

// Pull the full source feed and build slug("pattern-option") -> featured_image src.
async function fetchSourceImageIndex(base = 'https://rwltd.com/products.json?limit=250') {
  const feed = [];
  for (let page = 1; page <= MAX_PAGES; page++) {
    let ps = [];
    try { ps = JSON.parse(await get(`${base}&page=${page}`)).products || []; } catch { break; }
    if (!ps.length) break;
    feed.push(...ps);
  }
  const idx = {};
  for (const p of feed) {
    for (const v of (p.variants || [])) {
      const fi = v.featured_image?.src;
      if (fi) idx[slug(`${p.title}-${v.option1}`)] = fi;
    }
  }
  return { idx, patterns: feed.length };
}

/**
 * Mutates `products` in place: for any colorway that lacks its own photo
 * (image_note set or no images) and matches a source variant, set the real
 * image and clear image_note. Returns { backfilled, conflicts } — conflicts are
 * already-imaged products whose current swatch DIFFERS from source (reported,
 * NOT auto-changed; e.g. Front Royal Fig/Java where source has a variant swap).
 */
async function backfillFromSourceFeed(products, opts = {}) {
  const { idx, patterns } = await fetchSourceImageIndex(opts.base);
  let backfilled = 0;
  const conflicts = [];
  for (const p of products) {
    const src = idx[slug(p.sku)];
    if (!src) continue;
    const noPhoto = p.image_note || !(p.images && p.images.length);
    if (noPhoto) {
      p.images = [src];
      p.swatch = src;
      p.room = src;
      delete p.image_note;
      p.image_source = 'rwltd-variant';
      backfilled++;
    } else {
      const cur = (p.swatch || '').split('?')[0];
      const s0 = src.split('?')[0];
      if (cur !== s0) conflicts.push({ handle: p.handle, color: p.color, ours: cur.split('/').pop(), source: s0.split('/').pop() });
    }
  }
  return { backfilled, conflicts, sourcePatterns: patterns, indexSize: Object.keys(idx).length };
}

module.exports = { backfillFromSourceFeed, fetchSourceImageIndex, slug };