← back to Dw Domain Fleet

scripts/gen-standalone-heroes.js

144 lines

#!/usr/bin/env node
/**
 * gen-standalone-heroes.js
 *
 * The standalone DW sister sites (decade sites, material sites, hospitality
 * cluster, etc.) each serve a STATIC public/hero-bg.jpg. ~51 of 59 of them
 * serve byte-identical template images (31 share one 1.9MB file). This assigns
 * every standalone site a UNIQUE, clean, niche-relevant catalog image —
 * globally disjoint from the 344 generic-fleet heroes (data/hero-allocation.json)
 * AND from each other.
 *
 * Input : data/standalone-hero-paths.tsv  (domain \t diskpath \t md5 \t size)
 * Output: data/standalone-hero-allocation.json  { domain: {url,title,path} }
 *
 * A companion deploy step downloads each url over the prod side into the disk
 * path, replacing the duplicated template hero.
 */
const fs = require('fs');
const path = require('path');
const catalog = require('../shared/catalog');
const { sortProducts } = require('../shared/sort');

const ROOT = path.join(__dirname, '..');
const PATHS_TSV = path.join(ROOT, 'data', 'standalone-hero-paths.tsv');
const OUT = path.join(ROOT, 'data', 'standalone-hero-allocation.json');

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;
}
function isCleanImg(url) {
  if (!url || typeof url !== 'string' || !/^https:\/\//i.test(url)) return false;
  const l = url.toLowerCase();
  if (/(_swatch|_thumb|-thumb|icon|sprite|placeholder|\.svg)/.test(l)) return false;
  return true;
}
// prefer lifestyle/room shots (/files/) over square product swatches (/products/)
function heroScore(p) {
  return (/\/files\//.test(p.image_url) ? 1000 : 0);
}

// Map a site slug to niche keywords so its hero matches the material/theme.
function nicheKeywords(slug) {
  const s = slug.replace(/\.(com|org|net)$/, '');
  const map = [
    ['silk', ['silk']], ['linen', ['linen']], ['cork', ['cork']],
    ['grasscloth', ['grasscloth', 'grass cloth']], ['grass', ['grasscloth']],
    ['raffia', ['raffia']], ['jute', ['jute']], ['mica', ['mica']],
    ['metallic', ['metallic', 'metal']], ['glitter', ['glitter', 'mica', 'metallic']],
    ['goldleaf', ['gold', 'leaf', 'metallic']], ['silverleaf', ['silver', 'leaf', 'metallic']],
    ['suede', ['suede', 'velvet']], ['flock', ['flock', 'flocked', 'velvet']],
    ['embroider', ['embroider', 'stitch']], ['chinoiserie', ['chinoiserie', 'floral', 'scenic']],
    ['blockprint', ['block print', 'print']], ['screenprint', ['screen', 'print']],
    ['handcraft', ['handcraft', 'artisan', 'textured']], ['handmade', ['handcraft', 'artisan']],
    ['mylar', ['mylar', 'metallic', 'vinyl']], ['vinyl', ['vinyl']],
    ['pastel', ['pastel', 'soft']], ['recycled', ['recycled', 'sustainable', 'natural']],
    ['restoration', ['traditional', 'damask']], ['retro', ['retro', 'geometric', 'mod']],
    ['saloon', ['damask', 'bordeaux', 'dramatic']], ['string', ['string', 'textured', 'natural']],
    ['textile', ['textile', 'fabric', 'woven']], ['fabric', ['fabric', 'textile']],
    ['museum', ['archival', 'traditional', 'classic']],
    ['glassbeaded', ['beaded', 'glass', 'metallic']], ['glassbead', ['beaded', 'glass']],
    ['naturaltextile', ['natural', 'textile', 'woven']], ['naturalwall', ['natural', 'grasscloth', 'textured']],
    ['hospitality', ['hospitality', 'contract', 'dramatic']], ['hotel', ['hospitality', 'contract']],
    ['healthcare', ['contract', 'performance', 'vinyl']], ['restaurant', ['dramatic', 'hospitality']],
    ['contract', ['contract', 'commercial', 'performance']], ['commercial', ['contract', 'commercial']],
    ['apartment', ['modern', 'neutral']], ['architectural', ['contract', 'modern', 'textured']],
    ['aged', ['distressed', 'traditional', 'vintage']], ['green', ['green', 'botanical', 'natural']],
    ['madagascar', ['grasscloth', 'natural', 'textured']],
    ['custommural', ['mural', 'scenic']], ['mural', ['mural', 'scenic']],
    ['selfadhesive', ['peel', 'modern']], ['best', ['floral', 'classic']],
    ['carmel', ['coastal', 'neutral', 'natural']], ['monterey', ['coastal', 'natural']],
    ['malibu', ['coastal', 'blue', 'natural']],
  ];
  for (const [k, kw] of map) if (s.includes(k)) return kw;
  // decade sites (1800s..1980s) + generic → traditional/classic
  if (/^\d{3,4}s?wallpaper/.test(s) || /^\d{4}/.test(s)) return ['traditional', 'classic', 'damask', 'floral'];
  return [];
}

function main() {
  const rows = fs.readFileSync(PATHS_TSV, 'utf8').trim().split('\n').map(l => {
    const [domain, diskpath, md5, size] = l.split('\t');
    return { domain, diskpath, md5, size: +size };
  });

  // seed claimed set with the 344 generic-fleet heroes so standalone never collides with them
  const claimed = new Set();
  try {
    const gen = JSON.parse(fs.readFileSync(path.join(ROOT, 'data', 'hero-allocation.json'), 'utf8'));
    Object.values(gen).forEach(arr => arr.forEach(u => claimed.add(u)));
  } catch (e) { console.warn('WARN: generic hero-allocation.json not found — standalone may collide with generic'); }

  const cleanGlobal = catalog.nicheSlice({ pos: [], neg: [], types: ['Wallcovering'], limit: 100000 })
    .filter(p => isCleanImg(p.image_url));

  // most-constrained (smallest niche pool) first
  const prepared = rows.map(r => {
    const kw = nicheKeywords(r.domain);
    let pool = kw.length
      ? catalog.nicheSlice({ pos: kw, neg: [], types: [], limit: 6000 }).filter(p => isCleanImg(p.image_url))
      : cleanGlobal.slice();
    if (pool.length < 6) pool = cleanGlobal.slice();
    // hero-quality first (lifestyle shots), then newest
    pool = sortProducts(pool, 'newest').sort((a, b) => heroScore(b) - heroScore(a));
    return { ...r, kw, pool };
  }).sort((a, b) => a.pool.length - b.pool.length);

  const alloc = {};
  const pick = (pool, seed) => {
    if (!pool.length) return null;
    const start = hash(seed) % pool.length;
    for (let i = 0; i < pool.length; i++) {
      const p = pool[(start + i) % pool.length];
      if (claimed.has(p.image_url)) continue;
      claimed.add(p.image_url);
      return p;
    }
    return null;
  };

  for (const site of prepared) {
    let p = pick(site.pool, site.domain);
    if (!p) p = pick(cleanGlobal, site.domain + ':global'); // last resort
    if (!p) { console.error('FATAL no image for', site.domain); process.exit(1); }
    alloc[site.domain] = { url: p.image_url, title: p.title || '', path: site.diskpath, niche: site.kw.join(',') };
  }

  // verify uniqueness across standalone AND vs generic
  const urls = Object.values(alloc).map(a => a.url);
  const dup = urls.length - new Set(urls).size;
  fs.writeFileSync(OUT, JSON.stringify(alloc, null, 2));
  console.log(`[standalone] ${rows.length} sites · ${new Set(urls).size} unique heroes · ${dup} dup`);
  if (dup !== 0) { console.error('FATAL duplicates'); process.exit(1); }
  // also confirm zero overlap with generic
  const gen = (() => { try { return JSON.parse(fs.readFileSync(path.join(ROOT, 'data', 'hero-allocation.json'), 'utf8')); } catch { return {}; } })();
  const genSet = new Set(Object.values(gen).flat());
  const overlap = urls.filter(u => genSet.has(u)).length;
  console.log(`[standalone] overlap with generic-fleet heroes: ${overlap} (must be 0)`);
  if (overlap !== 0) process.exit(1);
  console.log(`[standalone] wrote ${path.relative(ROOT, OUT)} ✓`);
}
main();