← back to Dw Domain Fleet
scripts/gen-pattern-grid.js
136 lines
#!/usr/bin/env node
/**
* gen-pattern-grid.js — build data/pattern-grid.json (TK-11322)
*
* A globally-diverse allocation of 20 PATTERNS per site for the monetize-home
* embedded grid. Each entry is { name, img }:
* - img : a clean https product image (served vendor-neutral via /img at runtime)
* - name: the design name with the VENDOR STRIPPED and leak-checked — never the
* raw title (which carries "… | Morris & Co."). If a name can't be proven
* vendor-free it's dropped (empty), so that tile renders image-only.
*
* Source: data/catalog.json (read directly — NOT via shared/catalog, whose isJunk
* drops every row in this snapshot because they all carry the display_variant tag).
* Showroom-only vendors/tags are excluded (standing addressable-not-discoverable rule).
* Algorithm mirrors gen-hero-allocation: most-constrained sites claim first, greedy
* globally-disjoint by pattern key, backfill from the global wallcovering pool.
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..');
const CATALOG = path.join(ROOT, 'data', 'catalog.json');
const SITES_DIR = path.join(ROOT, 'sites');
const OUT = path.join(ROOT, 'data', 'pattern-grid.json');
const N = 20; // patterns per site
const raw = JSON.parse(fs.readFileSync(CATALOG, 'utf8'));
// Showroom-only vendors — never discoverable on a microsite grid.
let SHOWROOM = [];
try { SHOWROOM = JSON.parse(fs.readFileSync(path.join(ROOT, 'config', 'showroom-vendors.json'), 'utf8')).map(v => v.toLowerCase()); } catch {}
// Private-label / upstream names that must NEVER surface customer-facing (dw-leak-scanner set).
const LEAK = ['wallquest', 'chesapeake', 'nextwall', 'seabrook', 'brewster', 'command54', 'command 54',
'desima', 'carlsten', 'nicolette mayer', 'momentum', 'versa', 'greenland', 'rigo', 'tokiwa', 'mdc'];
const isCleanImg = u => !!u && /^https:\/\//i.test(u) &&
!/(_swatch|_thumb|-thumb|icon|sprite|placeholder|\.svg)/i.test(u.toLowerCase());
const isShowroom = p => {
if (p.vendor && SHOWROOM.includes(String(p.vendor).trim().toLowerCase())) return true;
const tags = Array.isArray(p.tags) ? p.tags : (typeof p.tags === 'string' ? p.tags.split(',') : []);
return tags.some(t => String(t).trim().toLowerCase() === 'showroom');
};
// Collapse colorway variants to one pattern: "Dune Road - Camel …" & "Dune Road - Russet …" -> "dune road".
const patternKey = p => {
const t = (p.title || p.handle || '').toLowerCase();
return t.split(/\s[-–|]\s/)[0].replace(/\s+/g, ' ').trim() || t;
};
// Vendor-stripped display name, leak-checked. Returns '' if it can't be proven clean.
function safeName(p) {
const raw = (p.title || '').trim();
if (!raw) return '';
// take the segment before the first " - " / " – " / " | " separator (design name; vendor is after)
let name = raw.split(/\s[-–|]\s/)[0].replace(/\s+/g, ' ').trim();
if (!name || name.length < 2 || name.length > 60) return '';
const low = name.toLowerCase();
// must not contain the product's own vendor tokens
const vendorTokens = String(p.vendor || '').toLowerCase().split(/[^a-z0-9]+/).filter(w => w.length >= 3);
if (vendorTokens.some(w => low.includes(w))) return '';
// must not contain any private-label upstream name
if (LEAK.some(v => low.includes(v))) return '';
return name;
}
// canonical-ish type check
function typeOK(p, types) {
if (!types || !types.length) return true;
const t = (p.product_type || '').toLowerCase();
return types.some(x => t.includes(String(x).toLowerCase().replace(/s$/, '')));
}
function matchesNiche(p, niche) {
const hay = ((p.title || '') + ' ' + (Array.isArray(p.tags) ? p.tags.join(' ') : '') + ' ' + (p.product_type || '')).toLowerCase();
if (!typeOK(p, niche.types)) return false;
if (niche.neg && niche.neg.some(n => hay.includes(String(n).toLowerCase()))) return false;
if (niche.pos && niche.pos.length && !niche.pos.some(pp => hay.includes(String(pp).toLowerCase()))) return false;
return true;
}
const byNewest = (a, b) => String(b.created_at || '').localeCompare(String(a.created_at || ''));
// dedup a product list to one representative per pattern key, newest-first
function dedupPatterns(list) {
const seen = new Set(); const out = [];
for (const p of list.slice().sort(byNewest)) {
const k = patternKey(p);
if (seen.has(k)) continue;
seen.add(k); out.push(p);
}
return out;
}
const baseClean = raw.filter(p => isCleanImg(p.image_url) && !isShowroom(p) && p.handle);
// global wallcovering pool for backfill
const globalPool = dedupPatterns(baseClean.filter(p => typeOK(p, ['Wallcovering'])));
const sites = fs.readdirSync(SITES_DIR).filter(f => f.endsWith('.json'))
.map(f => JSON.parse(fs.readFileSync(path.join(SITES_DIR, f), 'utf8')))
.filter(j => j.monetize);
// build each site's candidate pool (niche, broadened to all wallcovering when thin)
const withPools = sites.map(cfg => {
let pool = dedupPatterns(baseClean.filter(p => matchesNiche(p, cfg.niche || {})));
if (pool.length < 24) pool = dedupPatterns(baseClean.filter(p => typeOK(p, ['Wallcovering'])));
return { slug: cfg.slug, cfg, pool };
});
// allocate most-constrained first, globally-disjoint by pattern key, backfill from global
withPools.sort((a, b) => a.pool.length - b.pool.length);
const usedKeys = new Set();
const result = {};
function claim(site) {
const picks = [];
const take = (list) => {
for (const p of list) {
if (picks.length >= N) break;
const k = patternKey(p);
if (usedKeys.has(k)) continue;
usedKeys.add(k);
picks.push({ name: safeName(p), img: p.image_url });
}
};
take(site.pool);
if (picks.length < N) take(globalPool); // backfill (still globally-disjoint)
return picks;
}
for (const s of withPools) result[s.slug] = claim(s);
fs.writeFileSync(OUT, JSON.stringify(result, null, 2) + '\n');
const counts = Object.values(result).map(a => a.length);
const named = Object.values(result).flat().filter(x => x.name).length;
const total = Object.values(result).flat().length;
console.log(`[pattern-grid] ${sites.length} sites · ${counts.filter(c => c === N).length} full(${N}) · min ${Math.min(...counts)} · ${named}/${total} tiles named (${total - named} image-only after leak-check)`);