← back to Trending Dw
scripts/scout-to-bestsellers.js
129 lines
#!/usr/bin/env node
// scout-to-bestsellers — the LIVE-REFRESH MAPPER (DTD verdict A, 2026-07-06).
//
// Closes the "trending board with frozen data" honesty gap WITHOUT new credentials or
// unattended spend. Exa is reachable only via the design-trend-scout agent (MCP-backed),
// so the live-refresh path is:
// 1. run design-trend-scout (metered Exa pull — GATED, Steve-approved) → writes an intermediate JSON
// 2. node scripts/scout-to-bestsellers.js <intermediate.json> → this mapper ($0, deterministic)
// 3. node scripts/attach-images.js → re-attach our-own images + GAP flags
// This file is step 2: it is pure, offline, and never touches the network — so it is $0 and
// safe to run/test anytime. The ONLY spend is step 1, which stays gated.
//
// INPUT (either shape is accepted):
// (a) per-item scout feed { scannedAt, items:[ {title, company, marketplace, style, color,
// priceBand, signal, signalRank, url} ] } ← preferred, richest
// (b) lane-level trend-board.json { scannedAt, trends:[ {lane, why, priceBand, haveCoverage,
// gap, briefs:[...] } ] } ← the scout's persisted artifact;
// flattened best-effort (one item per lane) so the pipeline still works.
// OUTPUT: data/bestsellers.json { generatedAt, source, count, items:[ ...board shape ] }
//
// Usage:
// node scripts/scout-to-bestsellers.js path/to/scout.json # write bestsellers.json
// node scripts/scout-to-bestsellers.js --self-test # prove the mapper works, no input needed
// node scripts/scout-to-bestsellers.js path/to/scout.json --dry # validate + print, don't write
const fs = require('fs');
const path = require('path');
// color family -> representative swatch hex (superset of seed.js's HEX; unknown colors get a
// deterministic muted fallback so every card still renders a swatch tile).
const HEX = {
'Forest Green':'#2f5d3a','Oxblood':'#6e2b2b','Plum':'#5a3a55','Chartreuse':'#b6c33a',
'Cloud White':'#efece3','Blush':'#e7c3c0','Terracotta':'#c07a4e','Indigo':'#28324f',
'Greige':'#9a9488','Mustard':'#c99a3a','Teal':'#2b6c6b','Black':'#232323','Sage':'#9bab8b',
'Claret':'#76322f','Gold':'#b49555','Navy':'#26364d','Cream Gold':'#cdb87a','Mushroom':'#8a6f5a',
'Pop Pink':'#c85a86','Ombre Blue':'#5b7fa6','Cream':'#eee6d3','Rust':'#a1522d','Olive':'#6b6b3a',
'Charcoal':'#333330','Powder Blue':'#a9c3d6','Burgundy':'#5c2230','Multi':'#8a7f9a'
};
function hexFor(color){
if (HEX[color]) return HEX[color];
// deterministic muted fallback: hash the name to a low-saturation hex (never random — reproducible)
const s = String(color||'');
let h = 0; for (let i=0;i<s.length;i++) h = (h*31 + s.charCodeAt(i)) & 0xffffff;
const r = 90 + (h & 0x3f), g = 90 + ((h>>6) & 0x3f), b = 90 + ((h>>12) & 0x3f);
return '#' + [r,g,b].map(v=>v.toString(16).padStart(2,'0')).join('');
}
const REQ = ['title','style','color','priceBand']; // minimum viable board item
function normItem(o, i, scannedAt){
const missing = REQ.filter(k => !o[k]);
if (missing.length) throw new Error(`item ${i} missing required field(s): ${missing.join(', ')} — ${JSON.stringify(o).slice(0,120)}`);
return {
id: 'TR-' + String(i+1).padStart(3,'0'),
dominantHex: hexFor(o.color),
spottedAt: (scannedAt || new Date().toISOString()).slice(0,10),
isNew: o.isNew !== false,
title: o.title,
company: o.company || 'Independent maker',
marketplace: o.marketplace || 'Google / Retail',
style: o.style,
color: o.color,
priceBand: o.priceBand,
signal: o.signal || 'Trending',
signalRank: Number.isFinite(o.signalRank) ? o.signalRank : 50,
url: o.url || ''
};
}
// flatten the lane-level trend-board.json into per-item board rows (shape (b) fallback)
function flattenTrendBoard(board){
const out = [];
for (const t of (board.trends || [])){
// one representative item per lane; briefs (if present) become extra items so a rich scan yields more rows
const base = { title: t.lane, company: 'Trend lane', marketplace: 'Multiple', style: t.lane,
color: 'Multi', priceBand: t.priceBand || '$$', signal: t.why || 'Selling now',
signalRank: t.gap ? 85 : 60 };
out.push(base);
for (const b of (t.briefs || [])){
out.push({ title: b.title || (t.lane+' original'), company: 'DW original (brief)', marketplace:'Pattern Vault',
style: t.lane, color: b.colorway || b.color || 'Multi', priceBand: b.tier || t.priceBand || '$$',
signal: 'Gap-lane brief', signalRank: 88 });
}
}
return out;
}
function buildItems(input){
const scannedAt = input.scannedAt || input.generatedAt || new Date().toISOString();
let raw;
if (Array.isArray(input.items)) raw = input.items; // shape (a)
else if (Array.isArray(input.trends)) raw = flattenTrendBoard(input); // shape (b)
else throw new Error('input has neither .items[] (per-item feed) nor .trends[] (trend-board)');
if (!raw.length) throw new Error('input produced 0 items — refusing to overwrite the board with an empty set');
return { scannedAt, items: raw.map((o,i)=>normItem(o,i,scannedAt)) };
}
// ---- self-test: proves the mapper end-to-end with a synthetic feed, zero network, zero deps ----
function selfTest(){
const sample = { scannedAt: new Date().toISOString(), items: [
{ title:'Test Heritage Floral', company:'Test Co', marketplace:'Etsy', style:'Heritage Floral', color:'Forest Green', priceBand:'$$$', signal:'bestseller', signalRank:95, url:'https://example.com' },
{ title:'Unknown-Color Item', style:'Boho', color:'Zzz Nonexistent', priceBand:'$$' } // exercises defaults + hex fallback
]};
const built = buildItems(sample);
const ok = built.items.length === 2
&& built.items[0].id === 'TR-001'
&& built.items[1].company === 'Independent maker' // default applied
&& /^#[0-9a-f]{6}$/.test(built.items[1].dominantHex) // fallback hex valid
&& built.items[0].spottedAt === sample.scannedAt.slice(0,10);
console.log(ok ? 'SELF-TEST PASS' : 'SELF-TEST FAIL');
console.log(JSON.stringify(built.items, null, 1));
process.exit(ok ? 0 : 1);
}
function main(){
const args = process.argv.slice(2);
if (args.includes('--self-test')) return selfTest();
const dry = args.includes('--dry');
const inPath = args.find(a => !a.startsWith('--'));
if (!inPath){ console.error('usage: scout-to-bestsellers.js <scout.json> [--dry] | --self-test'); process.exit(2); }
const input = JSON.parse(fs.readFileSync(inPath, 'utf8'));
const built = buildItems(input);
const out = { generatedAt: built.scannedAt.slice(0,10), source: 'design-trend-scout (Exa research) → scout-to-bestsellers mapper', count: built.items.length, items: built.items };
if (dry){ console.log(`[dry] ${out.count} items, generatedAt ${out.generatedAt} — NOT written`); console.log(JSON.stringify(out.items.slice(0,3), null, 1)); return; }
const dest = path.join(__dirname, '..', 'data', 'bestsellers.json');
fs.writeFileSync(dest, JSON.stringify(out, null, 2));
console.log(`wrote ${dest} — ${out.count} items, generatedAt ${out.generatedAt}. Next: node scripts/attach-images.js`);
}
main();