← back to Dw Marketing Reels

scripts/suggest-videos.mjs

121 lines

#!/usr/bin/env node
// suggest-videos.mjs — the "hypergen" daily idea engine for the DW Marketing console.
//
// Every day this proposes FOUR video ideas (the "4 suggested videos a day") built from
// the live New Arrivals catalog (data/new-arrivals.json). Each idea maps 1:1 onto a real
// HyperFrames render job the Video Studio already knows how to run (reels-producer /
// product-launch-video), so "Use it" in the console just queues that gated job.
//
// HARD RULES honoured here:
//   • $0 local — pure heuristics over the catalog, no paid LLM/API call.
//   • Keep ALL history — suggestions are append-only, grouped by day; nothing is deleted.
//   • Idempotent per day — a given local (PT) date is only ever filled to 4 once; re-runs
//     top up a short day but never duplicate or clobber a suggestion already USED.
//
// Usage:  node scripts/suggest-videos.mjs [--force]
//   --force  replace today's UNUSED suggestions and regenerate (used ones are preserved).
import { readFile, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
const NA_FILE = join(ROOT, 'data', 'new-arrivals.json');
const OUT_FILE = join(ROOT, 'data', 'video-suggestions.json');
const NA_URL = 'https://designerwallcoverings.com/collections/new-arrivals';
const FORCE = process.argv.includes('--force');

// Local (America/Los_Angeles) calendar date, e.g. "2026-07-22" — the day this drop belongs to.
function ptDate(d = new Date()) {
  return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Los_Angeles' }).format(d);
}
async function readJson(fp, fallback) {
  return existsSync(fp) ? JSON.parse(await readFile(fp, 'utf8')) : fallback;
}
// Stable-ish id without Math.random (harness-friendly): date + slot + short hash of the seed.
function makeId(date, slot, seed) {
  let h = 0; for (const c of String(seed)) h = (h * 31 + c.charCodeAt(0)) >>> 0;
  return `sg-${date}-${slot}-${h.toString(36)}`;
}
const ref = it => ({ pattern: it.pattern || it.title || 'Untitled', image: it.image_hi || it.image || '', url: it.url || '' });

// Build the four ideas for `date` from the catalog `items`.
function buildIdeas(date, items, createdISO) {
  const pick = (arr, n) => arr.slice(0, n);
  // vendor with the most fresh arrivals → the "spotlight" of the day
  const byVendor = {};
  for (const it of items) { const v = it.vendor || 'Assorted'; (byVendor[v] ||= []).push(it); }
  const topVendor = Object.entries(byVendor).sort((a, b) => b[1].length - a[1].length)[0] || ['Assorted', items];
  // highest-priced fresh arrival → the "statement piece"
  const priced = items.filter(it => Number(it.price) > 0).sort((a, b) => Number(b.price) - Number(a.price));
  const statement = priced[0] || items[0] || null;

  const ideas = [];
  const push = (slot, o) => ideas.push({
    id: makeId(date, slot, o.seed), date, slot, created_at: createdISO,
    kind: o.kind, format: o.format, url: o.url || '',
    title: o.title, idea: o.idea, hero_image: o.hero || '', refs: (o.refs || []).map(ref),
    used: false, used_at: null, used_where: null,
  });

  // 1 — the flagship weekly drop reel (9:16)
  if (items.length) push(1, {
    seed: 'drop:' + date, kind: 'new-arrivals-reel', format: '9:16',
    title: `New Arrivals reel — the ${date} drop`,
    idea: `A 9:16 Instagram reel of this week's freshest 6–8 arrivals, varied by color & scale. Lead with the most striking image, jewel-tone first. Ready-to-post DW caption.`,
    hero: (items[0].image_hi || items[0].image), refs: pick(items, 6),
  });
  // 2 — vendor spotlight, square feed grid (1:1)
  if (topVendor[1].length) push(2, {
    seed: 'vendor:' + topVendor[0] + ':' + date, kind: 'new-arrivals-reel', format: '1:1',
    title: `Feed grid — ${topVendor[0]} spotlight`,
    idea: `A 1:1 feed post spotlighting ${topVendor[0]} (${topVendor[1].length} new this drop). Tight 4–6 product montage, brand-forward. Caption names the house.`,
    hero: (topVendor[1][0].image_hi || topVendor[1][0].image), refs: pick(topVendor[1], 6),
  });
  // 3 — collection tour straight off the live storefront URL (9:16)
  push(3, {
    seed: 'tour:' + date, kind: 'url-reel', format: '9:16', url: NA_URL,
    title: 'Collection tour — New Arrivals page',
    idea: `A 9:16 promo tour built from the live New Arrivals collection page (${NA_URL}). Cinematic scroll of the whole drop, DW wordmark open/close.`,
    hero: (items[0] && (items[0].image_hi || items[0].image)) || '', refs: pick(items, 4),
  });
  // 4 — statement-piece spotlight from its own product URL (9:16)
  if (statement) push(4, {
    seed: 'statement:' + (statement.url || statement.pattern) + ':' + date, kind: 'url-reel', format: '9:16', url: statement.url,
    title: `Statement piece — ${statement.pattern}`,
    idea: `A 9:16 hero spotlight of "${statement.pattern}"${statement.vendor ? ' by ' + statement.vendor : ''}${Number(statement.price) ? ` ($${statement.price})` : ''} — the drop's showpiece. Slow push-in on the mural, one-line hook.`,
    hero: (statement.image_hi || statement.image), refs: [statement],
  });
  return ideas;
}

(async () => {
  const na = await readJson(NA_FILE, { items: [] });
  const items = na.items || [];
  if (!items.length) { console.log('suggest-videos: no New Arrivals in the feed — nothing to suggest.'); return; }

  const all = await readJson(OUT_FILE, []);
  const date = ptDate();
  const createdISO = new Date().toISOString();
  const todays = all.filter(s => s.date === date);

  if (FORCE) {
    // drop today's UNUSED ideas, keep anything already used, then rebuild
    const keep = all.filter(s => s.date !== date || s.used);
    const fresh = buildIdeas(date, items, createdISO).filter(n => !keep.some(k => k.slot === n.slot && k.date === date));
    const next = [...keep, ...fresh];
    await writeFile(OUT_FILE, JSON.stringify(next, null, 2));
    console.log(`suggest-videos: [force] ${date} → ${fresh.length} regenerated, ${next.length} total kept.`);
    return;
  }
  if (todays.length >= 4) { console.log(`suggest-videos: ${date} already has ${todays.length} suggestions — no-op.`); return; }

  // top up today's slots that don't exist yet
  const have = new Set(todays.map(s => s.slot));
  const add = buildIdeas(date, items, createdISO).filter(n => !have.has(n.slot));
  if (!add.length) { console.log(`suggest-videos: ${date} nothing to add.`); return; }
  const next = [...all, ...add];
  await writeFile(OUT_FILE, JSON.stringify(next, null, 2));
  console.log(`suggest-videos: ${date} +${add.length} suggestion(s) → ${next.length} total (all history retained).`);
})();