← back to Designer Wallcoverings
Lily: selective ZIP-range swatch fetcher -> Henry + viewer serves local pattern swatches (/img/lily/:sku), live-reload as they land
698dfc9dbc6ca9611708d67f8324e3c330c1ad71 · 2026-06-30 05:32:25 -0700 · Steve
Files touched
A onboarding/sangetsu-lilycolor/lily-image-fetch.cjsM onboarding/sangetsu-lilycolor/viewer/server.js
Diff
commit 698dfc9dbc6ca9611708d67f8324e3c330c1ad71
Author: Steve <steve@designerwallcoverings.com>
Date: Tue Jun 30 05:32:25 2026 -0700
Lily: selective ZIP-range swatch fetcher -> Henry + viewer serves local pattern swatches (/img/lily/:sku), live-reload as they land
---
onboarding/sangetsu-lilycolor/lily-image-fetch.cjs | 92 ++++++++++++++++++++++
onboarding/sangetsu-lilycolor/viewer/server.js | 15 +++-
2 files changed, 106 insertions(+), 1 deletion(-)
diff --git a/onboarding/sangetsu-lilycolor/lily-image-fetch.cjs b/onboarding/sangetsu-lilycolor/lily-image-fetch.cjs
new file mode 100644
index 00000000..c14cb0b9
--- /dev/null
+++ b/onboarding/sangetsu-lilycolor/lily-image-fetch.cjs
@@ -0,0 +1,92 @@
+#!/usr/bin/env node
+/**
+ * Selective Lilycolor swatch fetcher — random-access ONE image out of a remote
+ * 100–400MB catalog zip via HTTP Range, using offsets from the zip central directory.
+ * Pulls the PATTERN swatch (_C, else _P/_SP) per SKU. JPEGs are STORED (method 0) in
+ * these zips, so we write the bytes straight out (deflate path handled just in case).
+ *
+ * Lands images on the HENRY 2TB drive (internal disk is tight) — idempotent (skips
+ * already-fetched). Read-only network; no Shopify/dw_unified/publish.
+ * node lily-image-fetch.cjs [limit] (default: all manifest SKUs lacking a fetched swatch)
+ */
+const https = require('https');
+const zlib = require('zlib');
+const fs = require('fs');
+const path = require('path');
+
+const DIR = path.join(__dirname, 'staging');
+const MANIFEST = path.join(DIR, 'lilycolor-image-manifest.json');
+const OUT_DIR = '/Volumes/Henry/dw-lily-images';
+const TAIL = 4 * 1024 * 1024;
+const KIND_PREF = { C: 0, P: 1, SP: 2, R: 3 };
+
+function getBuf(url, headers) {
+ return new Promise((resolve, reject) => {
+ https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0 Chrome/120', ...headers } }, (res) => {
+ if (res.statusCode >= 400) { res.resume(); return reject(new Error(`HTTP ${res.statusCode}`)); }
+ const chunks = []; res.on('data', (c) => chunks.push(c)); res.on('end', () => resolve(Buffer.concat(chunks)));
+ }).on('error', reject);
+ });
+}
+
+// Parse the central directory tail -> map filename -> {offset, compSize, method}
+function parseCentralDir(buf) {
+ const map = {};
+ for (let i = 0; i + 46 <= buf.length; i++) {
+ if (buf.readUInt32LE(i) !== 0x02014b50) continue;
+ const method = buf.readUInt16LE(i + 10);
+ const compSize = buf.readUInt32LE(i + 20);
+ const nameLen = buf.readUInt16LE(i + 28);
+ const extraLen = buf.readUInt16LE(i + 30);
+ const commentLen = buf.readUInt16LE(i + 32);
+ const offset = buf.readUInt32LE(i + 42);
+ if (nameLen === 0 || nameLen > 300 || i + 46 + nameLen > buf.length) continue;
+ const name = buf.slice(i + 46, i + 46 + nameLen).toString('utf8').split('/').pop();
+ if (/\.jpe?g$/i.test(name)) map[name] = { offset, compSize, method };
+ i += 46 + nameLen + extraLen + commentLen - 1;
+ }
+ return map;
+}
+
+async function fetchFile(zipUrl, rec) {
+ // local header: 30 fixed + nameLen + extraLen, then data (compSize). Range over the lot (+slack for extra).
+ const start = rec.offset;
+ const end = rec.offset + 30 + 300 + 64 + rec.compSize; // generous slack
+ const buf = await getBuf(zipUrl, { Range: `bytes=${start}-${end}` });
+ if (buf.readUInt32LE(0) !== 0x04034b50) throw new Error('bad local header');
+ const nameLen = buf.readUInt16LE(26), extraLen = buf.readUInt16LE(28);
+ const dataStart = 30 + nameLen + extraLen;
+ const data = buf.slice(dataStart, dataStart + rec.compSize);
+ return rec.method === 8 ? zlib.inflateRawSync(data) : data;
+}
+
+(async () => {
+ if (!fs.existsSync('/Volumes/Henry')) { console.error('Henry drive not mounted at /Volumes/Henry'); process.exit(1); }
+ fs.mkdirSync(OUT_DIR, { recursive: true });
+ const manifest = JSON.parse(fs.readFileSync(MANIFEST, 'utf8'));
+ const limit = parseInt(process.argv[2] || '999999', 10);
+
+ // group target files by zip so we read each central directory once
+ const cdCache = {};
+ const targets = []; // {sku, zipUrl, name}
+ for (const [sku, imgs] of Object.entries(manifest)) {
+ if (fs.existsSync(path.join(OUT_DIR, `${sku}.jpg`))) continue; // idempotent
+ const best = [...imgs].sort((a, b) => (KIND_PREF[a.kind] ?? 9) - (KIND_PREF[b.kind] ?? 9))[0];
+ if (best) targets.push({ sku, zipUrl: best.zipUrl, name: best.name });
+ if (targets.length >= limit) break;
+ }
+ console.error(`fetching ${targets.length} swatches -> ${OUT_DIR}`);
+ let ok = 0, fail = 0;
+ for (const t of targets) {
+ try {
+ if (!cdCache[t.zipUrl]) cdCache[t.zipUrl] = parseCentralDir(await getBuf(t.zipUrl, { Range: `bytes=-${TAIL}` }));
+ const rec = cdCache[t.zipUrl][t.name];
+ if (!rec) { fail++; continue; }
+ const bytes = await fetchFile(t.zipUrl, rec);
+ fs.writeFileSync(path.join(OUT_DIR, `${t.sku}.jpg`), bytes);
+ ok++;
+ if (ok % 50 === 0) process.stderr.write(` ${ok}/${targets.length}\n`);
+ } catch (e) { fail++; if (fail <= 5) process.stderr.write(`FAIL ${t.sku}: ${e.message}\n`); }
+ }
+ console.log(JSON.stringify({ fetched: ok, failed: fail, dir: OUT_DIR }, null, 2));
+})().catch((e) => { console.error('FAILED:', e.message); process.exit(1); });
diff --git a/onboarding/sangetsu-lilycolor/viewer/server.js b/onboarding/sangetsu-lilycolor/viewer/server.js
index 5e3f810e..1039a41b 100644
--- a/onboarding/sangetsu-lilycolor/viewer/server.js
+++ b/onboarding/sangetsu-lilycolor/viewer/server.js
@@ -16,6 +16,7 @@ const DIR = path.join(__dirname, '..', 'staging');
const LILY = path.join(DIR, 'lilycolor-unified-staging.jsonl'); // trade pricebook + specs + price + image manifest
const SANG = path.join(DIR, 'sangetsu-staging.jsonl');
const PUBLIC = path.join(__dirname, 'public');
+const HENRY_IMG = '/Volumes/Henry/dw-lily-images'; // selectively-fetched pattern swatches
const readJsonl = (f) => (fs.existsSync(f)
? fs.readFileSync(f, 'utf8').trim().split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean)
@@ -37,7 +38,8 @@ function buildRows() {
fire: (r.fire_ratings && r.fire_ratings[0]) || null,
price_yen: r.list_price_yen || null, // Japan LIST price (¥), not confirmed net cost
repeat: (r.repeat_tate_cm && r.repeat_yoko_cm) ? `${r.repeat_tate_cm}×${r.repeat_yoko_cm}cm` : null,
- image: (r.preview_images && r.preview_images[0]) || null, // displayable now (shop CDN)
+ // prefer the locally-fetched clean swatch on Henry; else shop-CDN swatch-first preview
+ image: (fs.existsSync(path.join(HENRY_IMG, `${r.mfr_sku}.jpg`)) ? `/img/lily/${r.mfr_sku}` : (r.preview_images && r.preview_images[0])) || null,
image_count: r.image_count || 0,
image_kinds: r.image_kinds || [],
url: r.shop_handle ? `https://shop.lilycolor.co.jp/products/${r.shop_handle}` : null,
@@ -69,6 +71,10 @@ let ROWS = buildRows();
const reload = () => { ROWS = buildRows(); };
fs.watchFile(SANG, { interval: 4000 }, reload); // pick up Sangetsu staging as it grows
fs.watchFile(LILY, { interval: 8000 }, reload);
+let lastImgCount = -1; // re-resolve primary images as swatches land on Henry
+setInterval(() => {
+ try { const n = fs.existsSync(HENRY_IMG) ? fs.readdirSync(HENRY_IMG).length : 0; if (n !== lastImgCount) { lastImgCount = n; reload(); } } catch {}
+}, 6000);
const sorters = {
newest: null, // server natural order
@@ -97,6 +103,13 @@ const server = http.createServer((req, res) => {
res.end(JSON.stringify({ total: rows.length, counts, offset, limit, rows: page }));
return;
}
+ // locally-fetched Lily pattern swatches from Henry
+ if (u.pathname.startsWith('/img/lily/')) {
+ const sku = decodeURIComponent(u.pathname.slice('/img/lily/'.length)).replace(/[^A-Za-z0-9-]/g, '');
+ const f = path.join(HENRY_IMG, `${sku}.jpg`);
+ if (fs.existsSync(f)) { res.writeHead(200, { 'Content-Type': 'image/jpeg', 'Cache-Control': 'max-age=86400' }); return res.end(fs.readFileSync(f)); }
+ res.writeHead(404); return res.end('no image');
+ }
// static
let p = u.pathname === '/' ? '/index.html' : u.pathname;
const file = path.join(PUBLIC, path.normalize(p).replace(/^(\.\.[/\\])+/, ''));
← 700c3531 auto-save: 2026-06-30T05:28:32 (4 files) — onboarding/sanget
·
back to Designer Wallcoverings
·
auto-save: 2026-06-30T05:58:45 (5 files) — pending-approval/ fc41898a →