← back to Designer Wallcoverings
onboarding/sangetsu-lilycolor/lily-merge.cjs
85 lines
#!/usr/bin/env node
/**
* Merge the Lilycolor trade PRICE-BOOK specs (lily-pricebook-parser output) with the
* shop.lilycolor.co.jp feed IMAGES into one unified per-SKU staging row (OFFLINE).
*
* Pricebook = full trade line + price + specs (no images).
* Shop feed = clean per-SKU images (subset of the line).
* Join on a normalized SKU key (uppercase, alphanumerics only, strip sample/nori suffix).
*
* HARD: writes ONLY to local staging. No Shopify, no dw_unified, no publish.
* node lily-merge.cjs
*/
const fs = require('fs');
const path = require('path');
const DIR = path.join(__dirname, 'staging');
const PB = path.join(DIR, 'lilycolor-pricebook-staging.jsonl');
const FEED = path.join(DIR, 'lilycolor-staging.jsonl');
const MANIFEST = path.join(DIR, 'lilycolor-image-manifest.json');
const OUT = path.join(DIR, 'lilycolor-unified-staging.jsonl');
const rd = (f) => fs.readFileSync(f, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
const norm = (s) => String(s || '').toUpperCase().replace(/[^A-Z0-9]/g, '').replace(/(?:SAMPLE|NORI)$/, '');
// index shop-feed images by normalized SKU
const feedImg = new Map();
for (const p of rd(FEED)) {
const k = norm(p.mfr_sku);
if (!k || !(p.images && p.images.length)) continue;
if (!feedImg.has(k)) feedImg.set(k, { images: p.images, title_ja: p.title_ja, handle: p.handle });
}
// official per-SKU image manifest (filenames inside the catalog zips; bytes deferred)
const manifest = fs.existsSync(MANIFEST) ? JSON.parse(fs.readFileSync(MANIFEST, 'utf8')) : {};
// PATTERN-FIRST ordering. The swatch (pattern/texture) must lead; the room/model
// scene goes last. Shop-feed room shots are named `<SKU>_model.jpg`; swatches are
// `<SKU>_<uuid>.jpg`. Also prefer images that match THIS exact SKU (feed products
// bundle sibling colorways' images together).
const isRoom = (name) => /_model\.|_room|_scene|_R(?:_\d+)?\.|_RS\./i.test(name);
function orderPreviews(images, sku) {
const skuKey = norm(sku);
const rank = (u) => {
const base = u.split('/').pop().split('?')[0];
const mine = norm((base.match(/^([A-Za-z]+-?\d+)/) || [])[1]) === skuKey;
const room = isRoom(base);
return (room ? 2 : 0) + (mine ? 0 : 1); // mine-swatch < other-swatch < mine-room < other-room
};
return [...images].sort((a, b) => rank(a) - rank(b));
}
// manifest kind priority: C swatch, P pattern-tile, SP special — all BEFORE R room
const KIND_RANK = { C: 0, P: 1, SP: 2, R: 3, '?': 4 };
const orderManifest = (ms) => [...ms].sort((a, b) => (KIND_RANK[a.kind] ?? 9) - (KIND_RANK[b.kind] ?? 9));
const out = [];
let withAnyImg = 0, withPreview = 0;
for (const r of rd(PB)) {
const k = norm(r.mfr_sku);
const f = feedImg.get(k);
const mImgs = orderManifest(manifest[r.mfr_sku] || manifest[k] || []); // pattern-first
const previews = orderPreviews((f && f.images) || [], r.mfr_sku); // pattern-first, this-SKU-first
if (previews.length) withPreview++;
if (previews.length || mImgs.length) withAnyImg++;
out.push({
...r,
title_ja: (f && f.title_ja) || null,
title_en: null, // TODO JA->EN at gated enrichment
preview_images: previews, // displayable remote URLs (shop feed, $0 disk)
image_manifest: mImgs, // ALL official swatch/room/pattern shots (bytes gated)
image_count: mImgs.length || previews.length,
image_kinds: [...new Set(mImgs.map((i) => i.kind))],
image_bytes_fetched: false, // gated: pull actual bytes later / on import box
shop_handle: (f && f.handle) || null,
has_image: !!(previews.length || mImgs.length),
});
}
fs.writeFileSync(OUT, out.map((r) => JSON.stringify(r)).join('\n') + '\n');
console.log(JSON.stringify({
pricebookSkus: out.length,
withAnyImage: withAnyImg,
withDisplayablePreview: withPreview,
imageCoveragePct: Math.round((withAnyImg / out.length) * 100),
totalManifestImages: out.reduce((a, r) => a + r.image_manifest.length, 0),
out: OUT,
}, null, 2));