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