← back to Dw Domain Fleet
scripts/gen-hero-allocation.js
150 lines
#!/usr/bin/env node
/**
* gen-hero-allocation.js
*
* Builds data/hero-allocation.json — a globally-disjoint allocation of clean
* hero images across every site in sites/*.json.
*
* Why: every site's server.js previously derived its hero from the same
* "newest products in the niche pool" slice. Thin-niche sites fall back to the
* shared all-wallcovering pool, so 18+ sites rendered the IDENTICAL lead image.
* This precomputes one disjoint set of 8 clean heroes per site so NO image is
* reused on any two sites — niche-relevant where possible, deterministic, and
* inspectable.
*
* Algorithm:
* 1. Build each site's candidate pool exactly like server.js (niche slice,
* broadened to all Wallcovering when the niche is < 24).
* 2. Keep only "clean" images (valid https URL, not an obvious swatch/thumb).
* 3. Allocate MOST-CONSTRAINED sites first (smallest pool) so niche sites
* claim their best-fit images before broad sites consume the shared pool.
* 4. Greedy claim: each site takes the first HERO_COUNT images from its pool
* not already globally claimed, starting at a per-site seeded rotation
* offset (variety) and sorted newest-first (freshness). Backfill any
* shortfall from the global clean pool with the same rule.
*/
const fs = require('fs');
const path = require('path');
const catalog = require('../shared/catalog');
const { sortProducts } = require('../shared/sort');
const ROOT = path.join(__dirname, '..');
const SITES_DIR = path.join(ROOT, 'sites');
const OUT = path.join(ROOT, 'data', 'hero-allocation.json');
const HERO_COUNT = 8;
// deterministic 32-bit string hash (FNV-1a)
function hash(str) {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
}
// Is this a usable, clean hero image? Reject empties, non-https, and obvious
// tiny swatch/icon files so heroes stay full-bleed quality.
function isCleanImg(url) {
if (!url || typeof url !== 'string') return false;
if (!/^https:\/\//i.test(url)) return false;
const lower = url.toLowerCase();
if (/(_swatch|_thumb|-thumb|icon|sprite|placeholder|\.svg)/.test(lower)) return false;
return true;
}
// Collapse colorway variants to a base pattern so one site's rotation doesn't
// show the same design four times in different colors. "Dune Road - Camel ..."
// and "Dune Road - Russet ..." share key "dune road".
function patternKey(p) {
const t = (p.title || p.handle || '').toLowerCase();
return t.split(/\s[-–|]\s/)[0].replace(/\s+/g, ' ').trim() || t;
}
function poolFor(cfg) {
const niche = catalog.nicheSlice({
pos: cfg.niche.pos || [],
neg: cfg.niche.neg || [],
types: cfg.niche.types || [],
limit: 6000,
});
const pool = niche.length >= 24
? niche
: catalog.nicheSlice({ pos: [], neg: cfg.niche.neg || [], types: ['Wallcovering'], limit: 6000 });
// newest-first, clean only, dedup by image_url
const seen = new Set();
return sortProducts(pool, 'newest')
.filter(p => isCleanImg(p.image_url))
.filter(p => (seen.has(p.image_url) ? false : (seen.add(p.image_url), true)));
}
function main() {
const files = fs.readdirSync(SITES_DIR).filter(f => f.endsWith('.json')).sort();
const sites = files.map(f => {
const cfg = JSON.parse(fs.readFileSync(path.join(SITES_DIR, f), 'utf8'));
return { slug: cfg.slug, cfg, pool: poolFor(cfg) };
});
// global clean pool (newest-first) for backfill
const globalSeen = new Set();
const GLOBAL = sortProducts(
catalog.nicheSlice({ pos: [], neg: [], types: ['Wallcovering'], limit: 100000 }),
'newest'
).filter(p => isCleanImg(p.image_url))
.filter(p => (globalSeen.has(p.image_url) ? false : (globalSeen.add(p.image_url), true)));
// most-constrained first
const order = [...sites].sort((a, b) => a.pool.length - b.pool.length);
const claimed = new Set(); // global image_url claim — guarantees cross-site disjointness
const alloc = {};
// Pick up to `need` products from `products`, starting at a seeded rotation
// offset. Globally disjoint (skip claimed). `usedKeys` enforces distinct base
// patterns WITHIN this site; `relaxKeys` lets a second pass fill any shortfall
// by allowing repeat patterns (still distinct images).
const pickFrom = (products, seed, need, usedKeys) => {
const out = [];
if (!products.length) return out;
for (const relaxKeys of [false, true]) {
if (out.length >= need) break;
const start = hash(seed + (relaxKeys ? ':relax' : '')) % products.length;
for (let i = 0; i < products.length && out.length < need; i++) {
const p = products[(start + i) % products.length];
if (claimed.has(p.image_url)) continue;
const k = patternKey(p);
if (!relaxKeys && usedKeys.has(k)) continue;
claimed.add(p.image_url);
usedKeys.add(k);
out.push(p.image_url);
}
}
return out;
};
for (const site of order) {
const usedKeys = new Set();
let heroes = pickFrom(site.pool, site.slug, HERO_COUNT, usedKeys);
if (heroes.length < HERO_COUNT) {
// backfill from global clean pool (still globally disjoint)
heroes = heroes.concat(pickFrom(GLOBAL, site.slug + ':backfill', HERO_COUNT - heroes.length, usedKeys));
}
alloc[site.slug] = heroes;
}
// verification
const all = [];
Object.values(alloc).forEach(a => all.push(...a));
const uniq = new Set(all);
const dup = all.length - uniq.size;
const short = Object.entries(alloc).filter(([, a]) => a.length < HERO_COUNT);
fs.writeFileSync(OUT, JSON.stringify(alloc, null, 0));
console.log(`[hero-alloc] ${sites.length} sites · ${all.length} hero slots · ${uniq.size} unique · ${dup} duplicates`);
if (short.length) console.log(`[hero-alloc] WARN short sites: ${short.map(([s, a]) => s + '(' + a.length + ')').join(', ')}`);
if (dup !== 0) { console.error('[hero-alloc] FATAL: duplicate hero images across sites'); process.exit(1); }
console.log(`[hero-alloc] wrote ${path.relative(ROOT, OUT)} — ZERO cross-site duplicates ✓`);
}
main();