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