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