← back to Designer Wallcoverings

onboarding/sangetsu-lilycolor/lily-image-manifest.cjs

88 lines

#!/usr/bin/env node
/**
 * Lilycolor image MANIFEST builder (OFFLINE, disk-light).
 * Reads each catalog image archive's ZIP central directory via an HTTP Range
 * request on the LAST few MB only — so we enumerate every per-SKU image filename
 * inside a 300MB+ archive WITHOUT downloading the archive. Maps filenames to SKUs
 * and writes a manifest: { sku -> [{zipUrl, name, kind}] }.
 *
 * kind: C = swatch/cut, R = room/real, P = pattern-tile, SP = special.
 * The actual image BYTES are deliberately NOT fetched here (gigabytes) — gated.
 *
 * HARD: read-only network, writes ONLY a local manifest JSON. No publish.
 *   node lily-image-manifest.cjs
 */
const https = require('https');
const fs = require('fs');
const path = require('path');

const HOST = 'https://www.lilycolor.co.jp/interior/catalog/';
// the wallcovering-line archives that match the 5 parsed price books (C+R+P where present)
const ZIPS = [
  'will2026/Lilycolor_will_2026-29_C.zip', 'will2026/Lilycolor_will_2026-29_R.zip', 'will2026/Lilycolor_will_2026-29_P.zip', 'will2026/Lilycolor_will_2026-29_SP.zip',
  'light2025/Lilycolor_LIGHT_2025-28_C.zip', 'light2025/Lilycolor_LIGHT_2025-28_R.zip', 'light2025/Lilycolor_LIGHT_2025-28_P.zip',
  'v_wall2024/Lilycolor_V-wall_2024-27_C.zip', 'v_wall2024/Lilycolor_V-wall_2024-27_R.zip', 'v_wall2024/Lilycolor_V-wall_2024-27_P.zip',
  'materials2024/Lilycolor_materials_2024_260213_C.zip', 'materials2024/Lilycolor_materials_2024_260213_P.zip',
  'lis2023/Lilycolor_Import_Selection_231018_C.zip', 'lis2023/Lilycolor_Import_Selection_231018_R.zip', 'lis2023/Lilycolor_Import_Selection_231018_P.zip',
];
const OUT = path.join(__dirname, 'staging', 'lilycolor-image-manifest.json');
const TAIL = 4 * 1024 * 1024; // last 4MB covers the central directory of even the largest book

function getRange(url, bytesFromEnd) {
  return new Promise((resolve, reject) => {
    https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0 Chrome/120', Range: `bytes=-${bytesFromEnd}` } }, (res) => {
      if (res.statusCode !== 206 && res.statusCode !== 200) { 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);
  });
}

// scan a buffer for ZIP central-directory headers (PK\x01\x02) and pull filenames
function namesFromCentralDir(buf) {
  const names = [];
  const SIG = 0x02014b50;
  for (let i = 0; i + 46 <= buf.length; i++) {
    if (buf.readUInt32LE(i) !== SIG) continue;
    const nameLen = buf.readUInt16LE(i + 28);
    const extraLen = buf.readUInt16LE(i + 30);
    const commentLen = buf.readUInt16LE(i + 32);
    if (nameLen === 0 || nameLen > 300 || i + 46 + nameLen > buf.length) continue;
    const name = buf.slice(i + 46, i + 46 + nameLen).toString('utf8');
    if (/\.jpe?g$/i.test(name)) names.push(name);
    i += 46 + nameLen + extraLen + commentLen - 1;
  }
  return names;
}

const skuFromName = (n) => {
  const base = n.split('/').pop();
  const m = base.match(/^([A-Z]{2,4})-?(\d{3,6})/i);
  return m ? `${m[1].toUpperCase()}${m[2]}` : null;
};
const kindFromName = (n) => (n.match(/_(SP|C|R|P)(?:_\d+)?\.jpe?g$/i) || [])[1]?.toUpperCase() || '?';

(async () => {
  const manifest = {};            // sku -> [{zipUrl,name,kind}]
  const perZip = {};
  for (const z of ZIPS) {
    const url = HOST + encodeURI(z);
    try {
      const buf = await getRange(url, TAIL);
      const names = namesFromCentralDir(buf);
      perZip[z] = names.length;
      for (const name of names) {
        const sku = skuFromName(name);
        if (!sku) continue;
        (manifest[sku] = manifest[sku] || []).push({ zipUrl: url, name: name.split('/').pop(), kind: kindFromName(name) });
      }
      process.stderr.write(`${z}: ${names.length} images\n`);
    } catch (e) { process.stderr.write(`SKIP ${z}: ${e.message}\n`); }
  }
  fs.writeFileSync(OUT, JSON.stringify(manifest));
  const skus = Object.keys(manifest);
  const totalImgs = skus.reduce((a, s) => a + manifest[s].length, 0);
  console.log(JSON.stringify({ skusWithImages: skus.length, totalImages: totalImgs, perZip, out: OUT }, null, 2));
})().catch((e) => { console.error('FAILED:', e.message); process.exit(1); });