← back to Trending Dw

scripts/attach-images.js

195 lines

#!/usr/bin/env node
// attach-images.js — give every trending item a COPYRIGHT-SAFE, OUR-OWN image.
//
// Steve's rule (2026-07-03): trending.designerwallcoverings.com must show REAL images,
// and they must be OUR OWN designs (never a scraped/hotlinked marketplace photo).
//   • covered lanes  -> match to an EXISTING our-own design (pattern-vault: WPB originals
//                       + self-hosted in-house-original webp).
//   • GAP lanes      -> NO image; flag needsGeneration + a settlement-gated brief. Those
//                       are drafted for Steve; generation is metered and never auto-run here.
//
// Sources of "our own designs":
//   1. WPB by-id images (absolute https, public)  -> used directly.
//   2. in-house-original *.webp (local-only paths) -> COPIED into public/assets/pilot/
//      so the public site serves them same-origin (no hotlink fragility).
//
// Verifies every image resolves (abs URL -> 200 + image/*, local -> file exists) before writing.

const fs = require('fs');
const path = require('path');
const https = require('https');

const ROOT = path.join(__dirname, '..');
const PVCAT = path.join(ROOT, '..', 'pattern-vault', 'data', 'catalog.json');
const PVASSETS = path.join(ROOT, '..', 'pattern-vault', 'public');   // in-house webp live under here (/assets/pilot/*)
const DEST_ASSETS = path.join(ROOT, 'public');                       // self-host into our own public/
const BEST = path.join(ROOT, 'data', 'bestsellers.json');
const OURCAT = path.join(ROOT, 'data', 'our-catalog.json');

// Must mirror server.js GAP_STYLES — gap lanes get generation, not an existing image.
const GAP_STYLES = new Set([
  'Animal-Skin Print', 'Art-Deco Geometric', 'Texture-Effect', 'Boho', 'Tropical',
  'Retro Psychedelic', 'Mushroom / Goblincore', 'Paisley / Nomadic', 'Abstract Blur', 'Retro Geometric'
]);

// cute-creature lanes: great in the matchup modal, WRONG as a representative card image for
// a floral/geometric trend — exclude from the PRIMARY card pick (still in our-catalog for matchup).
const ANIMAL_CATS = ['drunk-animals','stoned-animals','designer-zoo-calm','drunk-monkeys','muybridge-plate'];

// trending style -> extra search terms (lane synonyms)
const STYLE_SYNONYMS = {
  'Heritage Floral':   'heritage botanical floral bloom garden',
  'Botanical':         'botanical heritage leaf garden vine',
  'Painterly Floral':  'painterly watercolor floral bloom',
  'Cottagecore Floral':'cottage floral heritage bloom garden',
  'Midnight Flora':    'moody dark nocturne floral bloom',
  'Heritage Trellis':  'heritage trellis lattice vine',
  'Ditsy Micro-Print': 'ditsy heritage small floral',
  'Chinoiserie':       'chinoiserie branch',
  'Chinoiserie Mural': 'chinoiserie mural monterey scenic panoramic',
  'Scenic Mural':      'scenic mural monterey panoramic panel',
  'Toile de Jouy':     'toile scenic pastoral chinoiserie',
  'Damask':            'damask traditional',
  'Geometric':         'geometric lattice block trellis',
  'Pop-Art Geometric': 'geometric block bold',
  'Abstract':          'abstract inspired study smoky',
  'Color Trend':       'inspired study',
  'Calm Neutral':      'neutral greige calm quiet inspired'
};

// item color -> terms that appear in our design titles/styleLines
const COLOR_SYNONYMS = {
  'Forest Green':'green bottle emerald forest', 'Sage':'green sage emerald celadon',
  'Blush':'blush apricot rosewater', 'Black':'black noir', 'Greige':'greige chamois',
  'Terracotta':'terracotta apricot umber', 'Indigo':'indigo prussian blue', 'Navy':'navy prussian blue indigo',
  'Claret':'claret crimson wine', 'Oxblood':'oxblood crimson wine', 'Mustard':'honey brass gold mustard',
  'Cream Gold':'gold brass chamois honey', 'Teal':'teal celadon', 'Plum':'plum amethyst',
  'Cloud White':'white greige neutral', 'Indigo ':'indigo prussian'
};

function norm(pv){
  return pv.map(r => {
    const isAbs = /^https?:\/\//.test(r.img || '');
    const local = !isAbs && (r.img || '').startsWith('/assets/');
    const cat = (r.category || '').toLowerCase();
    return {
      title: r.title,
      cat,
      image: isAbs ? r.img : (local ? r.img : null),        // local paths become same-origin once copied
      srcWebp: local ? path.join(PVASSETS, r.img) : null,
      url: r.wpbUrl || null,
      vendor: isAbs ? "Wallpaper's Back (our original)" : 'DW in-house original',
      keywords: [cat, r.styleLine || '', r.title || ''].join(' ').toLowerCase(),
      isLocal: local,
      animalOnly: ANIMAL_CATS.some(a => cat.includes(a))
    };
  }).filter(r => r.image);   // must have a usable image path
}

// Lane-dominant scoring: a design must hit the LANE first; color only breaks ties WITHIN the
// right lane. This stops prose color-words in a styleLine ("...forest green + cream...") from
// hijacking the wrong lane (e.g. a heritage trellis landing in a chinoiserie-mural slot).
function score(item, row){
  const styleTerms = [...new Set(((item.style + ' ' + (STYLE_SYNONYMS[item.style] || '')).toLowerCase().match(/[a-z]+/g) || []).filter(t => t.length > 2))];
  const colorTerms = [...new Set(((COLOR_SYNONYMS[item.color] || item.color || '').toLowerCase().match(/[a-z]+/g) || []).filter(t => t.length > 2))];
  const styleHits = styleTerms.filter(t => row.keywords.includes(t)).length;
  if (!styleHits) return 0;                                   // no lane match -> ineligible
  const colorHits = colorTerms.filter(t => row.keywords.includes(t)).length;
  return styleHits * 10 + colorHits;                          // lane dominates; color refines
}

// verify an absolute image URL returns 200 + image/*
function verifyUrl(url){
  return new Promise(resolve => {
    const req = https.get(url, { timeout: 12000 }, res => {
      const ok = res.statusCode === 200 && /^image\//.test(res.headers['content-type'] || '');
      res.destroy(); resolve(ok);
    });
    req.on('error', () => resolve(false));
    req.on('timeout', () => { req.destroy(); resolve(false); });
  });
}

async function main(){
  const pv = JSON.parse(fs.readFileSync(PVCAT, 'utf8'));
  const rows = norm(pv);

  // 1. self-host the local in-house webp assets into our own public/
  let copied = 0;
  for (const r of rows.filter(x => x.isLocal)){
    const dest = path.join(DEST_ASSETS, r.image.replace(/^\//, ''));
    fs.mkdirSync(path.dirname(dest), { recursive: true });
    if (r.srcWebp && fs.existsSync(r.srcWebp)) { fs.copyFileSync(r.srcWebp, dest); copied++; }
  }

  // 2. write normalized our-catalog.json for /api/matchup (strip srcWebp)
  const ourCat = rows.map(({ srcWebp, isLocal, animalOnly, ...keep }) => keep);
  fs.writeFileSync(OURCAT, JSON.stringify(ourCat, null, 0));

  // 3. attach an our-own image to every COVERED item; flag GAP items for generation
  const doc = JSON.parse(fs.readFileSync(BEST, 'utf8'));
  const primary = rows.filter(r => !r.animalOnly);
  const report = { covered: 0, gap: 0, unmatched: [], verifyFail: [] };
  const verifyCache = new Map();

  for (const it of doc.items){
    // reset any prior image state so re-runs are idempotent
    delete it.image; delete it.imageSource; delete it.imageDesign; delete it.isOurOriginal;
    delete it.needsGeneration; delete it.genBrief;

    if (GAP_STYLES.has(it.style)){
      // a generated original may already exist (self-hosted, settlement+seam gated) — use it
      const gapFile = path.join(DEST_ASSETS, 'assets', 'gap', it.id + '.png');
      if (fs.existsSync(gapFile)){
        it.image = '/assets/gap/' + it.id + '.png';
        it.imageSource = 'DW in-house original (generated)';
        it.imageDesign = `Original ${it.style} — ${it.color}`;
        it.isOurOriginal = true;
        report.covered++;
      } else {
        it.needsGeneration = true;
        it.genBrief = `Original ${it.style} in ${it.color} — generated in our own hand from live trend research, settlement-gated. Trend signal: ${it.signal} (rank ${it.signalRank}).`;
        report.gap++;
      }
      continue;
    }

    const ranked = primary.map(r => ({ r, s: score(it, r) })).filter(x => x.s > 0).sort((a, b) => b.s - a.s);
    let chosen = null;
    for (const cand of ranked){
      const url = cand.r.image;
      let ok;
      if (/^https?:\/\//.test(url)){
        if (verifyCache.has(url)) ok = verifyCache.get(url);
        else { ok = await verifyUrl(url); verifyCache.set(url, ok); }
      } else {
        ok = fs.existsSync(path.join(DEST_ASSETS, url.replace(/^\//, '')));
      }
      if (ok){ chosen = cand.r; break; }
      report.verifyFail.push({ item: it.title, url });
    }

    if (chosen){
      it.image = chosen.image;
      it.imageSource = chosen.vendor;
      it.imageDesign = chosen.title;
      it.isOurOriginal = true;
      report.covered++;
    } else {
      report.unmatched.push(`${it.title} (${it.style} / ${it.color})`);
    }
  }

  doc.imagesAttachedAt = doc.generatedAt;   // stamped from seed; refresh re-stamps
  fs.writeFileSync(BEST, JSON.stringify(doc, null, 2));

  console.log(`self-hosted webp copied: ${copied}`);
  console.log(`our-catalog.json rows:   ${ourCat.length}`);
  console.log(`covered w/ our-own image: ${report.covered}`);
  console.log(`gap -> needsGeneration:   ${report.gap}`);
  if (report.unmatched.length) console.log(`UNMATCHED covered (${report.unmatched.length}):\n  - ` + report.unmatched.join('\n  - '));
  if (report.verifyFail.length) console.log(`verify-fail (skipped, tried next): ${report.verifyFail.length}`);
}

main().catch(e => { console.error(e); process.exit(1); });