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