← 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();