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