← back to Dw Photo Capture
scripts/build-color-index.cjs
100 lines
#!/usr/bin/env node
/**
* build-color-index.cjs — generate the catalog-wide color index for the PDP
* color-dot "index of many products within 10% tolerance" feature.
*
* Source of truth: dw_unified.product_colors (one dominant color per product,
* with a PRECOMPUTED CIELAB lab_l/a/b) for the COLOR, JOINED to dw_unified.
* shopify_products for LIVE TRUTH (current status + current featured image).
* product_colors.status/image_url are populated by manual per-vendor extracts
* and go stale (an archived product can still read status='active' with a dead
* CDN image_url), which used to leak broken cards + 404 links into the PDP color
* grid. We therefore gate on shopify_products.status='active' (synced from live
* Shopify) and prefer its current image_url. We emit ONE row per active product
* with a real hex + LAB + handle + image, so the /apps/color-index endpoint can
* do a pure perceptual ΔE filter with no DB dependency at request time.
*
* Output: data/color-index.json → { generated_at, count, items:[
* { h:"handle", t:"title", v:"vendor", x:"#rrggbb", l:LAB_L, a:LAB_A, b:LAB_B, i:"image_url", p:"product_type" } ] }
* Short keys keep the file small (~60k rows).
*
* Run where pg resolves (dwphoto's symlinked node_modules has it):
* cd ~/Projects/dw-photo-capture && node scripts/build-color-index.cjs
* Refreshes are cheap + idempotent; safe to schedule. $0 (local PG).
*/
const fs = require('fs');
const path = require('path');
const { Pool } = require('pg');
// Same connection convention as the DW cadence scripts (Unix socket, peer auth).
const pool = new Pool(
process.env.DATABASE_URL
? { connectionString: process.env.DATABASE_URL }
: { host: '/tmp', database: 'dw_unified' }
);
const OUT = path.join(__dirname, '..', 'data', 'color-index.json');
(async () => {
const t0 = Date.now();
// Gate on LIVE truth: only handles that are active in shopify_products (synced
// from live Shopify) survive, so archived/deleted products can't leak a dead
// handle (404 link) or a stale image into the index. status is case-mixed
// ('ACTIVE' + 'active'); match case-insensitively. shopify_products is one row
// per variant, so DISTINCT ON (pc.handle) collapses to one row per product,
// keeping the most-recently-synced variant's featured image. The displayed
// image prefers the fresh shopify_products.image_url, falling back to the
// color-extract's image_url only if the mirror lacks one.
const q = `
SELECT DISTINCT ON (pc.handle)
pc.handle, pc.title, pc.vendor, pc.hex, pc.lab_l, pc.lab_a, pc.lab_b,
sp.product_type,
COALESCE(NULLIF(sp.image_url, ''), pc.image_url) AS image_url
FROM product_colors pc
JOIN shopify_products sp ON sp.handle = pc.handle
WHERE lower(sp.status) = 'active'
-- Must be PUBLISHED to the Online Store, not merely active in admin: an
-- active-but-unpublished product 404s on the storefront, so its /products/
-- <handle> link would be dead. online_store_published = live-visible.
AND sp.online_store_published = true
-- Harm-reduction: only surface products with a REAL roll variant to buy.
-- ~23k products are sample-only (whole-vendor gaps — Koroseal, Phillipe
-- Romano, Phillip Jeffries …) with no roll variant, so their PDP shows the
-- $4.25 memo Sample as the "Full Roll" price. Shop-by-color must not lead a
-- shopper to a $4.25 sample-only page. (Real fix = source vendor costs +
-- add roll variants; until then, exclude them here.)
AND sp.has_product_variant IS TRUE
AND pc.lab_l IS NOT NULL AND pc.lab_a IS NOT NULL AND pc.lab_b IS NOT NULL
AND pc.handle IS NOT NULL AND pc.handle <> ''
AND COALESCE(NULLIF(sp.image_url, ''), pc.image_url) IS NOT NULL
AND COALESCE(NULLIF(sp.image_url, ''), pc.image_url) <> ''
AND pc.hex ~* '^#?[0-9a-f]{6}$'
ORDER BY pc.handle, sp.updated_at_shopify DESC NULLS LAST
`;
const { rows } = await pool.query(q);
await pool.end();
const items = rows.map(r => ({
h: r.handle,
t: (r.title || '').slice(0, 120),
v: (r.vendor || '').slice(0, 60),
x: r.hex.startsWith('#') ? r.hex.toLowerCase() : ('#' + r.hex.toLowerCase()),
l: Math.round(r.lab_l * 10) / 10,
a: Math.round(r.lab_a * 10) / 10,
b: Math.round(r.lab_b * 10) / 10,
i: r.image_url,
p: (r.product_type || '').slice(0, 40)
}));
const payload = {
generated_at: new Date().toISOString(),
count: items.length,
// NAMED TUNABLE tolerance the endpoint reads too (kept here for provenance).
tolerance_pct: 0.10,
items
};
fs.writeFileSync(OUT, JSON.stringify(payload));
const kb = Math.round(fs.statSync(OUT).size / 1024);
console.log(`color-index: ${items.length} products → ${OUT} (${kb} KB) in ${Date.now() - t0}ms`);
})().catch(e => { console.error('build-color-index FAILED:', e.message); process.exit(1); });