← back to Corkwallcovering
scripts/enrich-hex.mjs
123 lines
#!/usr/bin/env node
// Per-SKU dominant hex extraction.
// For each product, downloads the image, scales to 1x1 via sips, reads the
// pixel, writes back to data/products.json as `dominant_hex_real`.
//
// Usage:
// node scripts/enrich-hex.mjs
//
// Idempotent — products already carrying `dominant_hex_real` are skipped
// unless `--force` is passed.
import fs from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { execSync } from 'node:child_process';
import os from 'node:os';
const DATA = path.resolve('data/products.json');
const FORCE = process.argv.includes('--force');
const TMP = await fs.mkdtemp(path.join(os.tmpdir(), 'gw-hex-'));
const sh = (c) => execSync(c, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
function rgbToHex(r, g, b) {
const h = (n) => Math.max(0, Math.min(255, n | 0)).toString(16).padStart(2, '0');
return '#' + h(r) + h(g) + h(b);
}
// Use sips → tiff → parse pixel via plist.
// Faster + more reliable: use sips to scale to 1x1, then read raw RGBA from tiff.
async function dominantHexForUrl(url, idx) {
const ext = (url.split('?')[0].split('.').pop() || 'jpg').slice(0, 4).toLowerCase();
const inFile = path.join(TMP, `in-${idx}.${ext === 'png' ? 'png' : 'jpg'}`);
const outFile = path.join(TMP, `out-${idx}.png`);
try {
sh(`curl -fsSL --max-time 15 -A 'Mozilla/5.0' '${url.replace(/'/g, "'\\''")}' -o '${inFile}'`);
if (!existsSync(inFile)) return null;
// sips: resample to 1x1, write png
sh(`/usr/bin/sips -z 1 1 -s format png '${inFile}' --out '${outFile}'`);
if (!existsSync(outFile)) return null;
// Read first IDAT-decoded pixel via Node's built-in (no native deps): use
// Image data via PNG parser. We'll piggyback on a minimal PNG read by
// decoding through a small zlib + filter step. Simpler: use sips one more
// time to spit out raw bytes via "sips -g all" giving width/height, then
// ask sips to save as TIFF and read first 3 bytes.
const tiff = path.join(TMP, `out-${idx}.tiff`);
sh(`/usr/bin/sips -s format tiff '${outFile}' --out '${tiff}'`);
const buf = await fs.readFile(tiff);
// TIFF header: II*\0 (LE) or MM\0* (BE). Walk IFD to find StripOffsets +
// BitsPerSample + SamplesPerPixel. Quick & dirty: scan for the first
// StripOffsets tag (0x0111) and read pointer.
return parseTiffFirstPixel(buf);
} catch (e) {
return null;
} finally {
for (const f of [inFile, outFile, path.join(TMP, `out-${idx}.tiff`)]) {
try { await fs.unlink(f); } catch {}
}
}
}
function parseTiffFirstPixel(buf) {
if (buf.length < 8) return null;
const le = buf[0] === 0x49 && buf[1] === 0x49;
const be = buf[0] === 0x4d && buf[1] === 0x4d;
if (!le && !be) return null;
const u16 = (o) => le ? buf.readUInt16LE(o) : buf.readUInt16BE(o);
const u32 = (o) => le ? buf.readUInt32LE(o) : buf.readUInt32BE(o);
const ifd0 = u32(4);
const nEntries = u16(ifd0);
let stripOffset = null, samplesPerPixel = 3, bitsPerSample = 8;
for (let i = 0; i < nEntries; i++) {
const o = ifd0 + 2 + i * 12;
const tag = u16(o);
const type = u16(o + 2);
const count = u32(o + 4);
const valOff = u32(o + 8);
if (tag === 0x0111 /*StripOffsets*/) {
stripOffset = (count === 1 && type === 4) ? valOff
: (count === 1 && type === 3) ? (valOff & 0xffff)
: valOff; // value is offset to array, but for 1x1 strip count=1
} else if (tag === 0x0102 /*BitsPerSample*/) {
bitsPerSample = (count === 1) ? (type === 3 ? (valOff & 0xffff) : valOff) : 8;
} else if (tag === 0x0115 /*SamplesPerPixel*/) {
samplesPerPixel = (type === 3 ? (valOff & 0xffff) : valOff);
}
}
if (stripOffset == null || stripOffset + 3 > buf.length) return null;
if (bitsPerSample !== 8) return null;
const r = buf[stripOffset];
const g = buf[stripOffset + 1];
const b = buf[stripOffset + 2];
return rgbToHex(r, g, b);
}
(async () => {
const raw = await fs.readFile(DATA, 'utf8');
const arr = JSON.parse(raw);
let touched = 0, skipped = 0, failed = 0;
for (let i = 0; i < arr.length; i++) {
const p = arr[i];
if (!FORCE && p.dominant_hex_real) { skipped++; continue; }
const url = p.image_url || p.image || (p.images && p.images[0]) || null;
if (!url) { failed++; continue; }
process.stdout.write(`[${i + 1}/${arr.length}] ${p.sku || p.handle || ''} ... `);
const hex = await dominantHexForUrl(url, i);
if (hex) {
p.dominant_hex_real = hex;
touched++;
console.log(hex);
} else {
failed++;
console.log('FAIL');
}
if ((i + 1) % 10 === 0) {
await fs.writeFile(DATA, JSON.stringify(arr, null, 2));
}
}
await fs.writeFile(DATA, JSON.stringify(arr, null, 2));
await fs.rm(TMP, { recursive: true, force: true }).catch(() => {});
console.log(`\nDone. touched=${touched} skipped=${skipped} failed=${failed}`);
})();