← back to New Arrivals Content Engine

scripts/diff-new-arrivals.cjs

135 lines

#!/usr/bin/env node
/**
 * New-Arrivals Content Engine — STEP 1: net-new SKU diff  (READ-ONLY, $0).
 *
 * Officer Idea Council 2026-07-20, idea #3 (vp-dw-marketing). The New Arrivals
 * smart collection (id 167327760435, tag "New Arrival") already refreshes daily
 * via com.steve.dw-new-arrivals-500. That fresh-SKU feed currently dies in the
 * catalog instead of becoming social fuel. This job turns it into a work-list.
 *
 * What it does (read-only):
 *   - GraphQL search for the newest TOP_N active products by created date
 *     (same host/API/query shape as new-arrival-tag-sync.cjs — no full scan).
 *   - Loads yesterday's snapshot and computes NET-NEW = today \ yesterday
 *     (by product id), i.e. products that entered the newest-N window today.
 *   - Writes today's snapshot to data/snapshots/YYYY-MM-DD.json (idempotent).
 *   - Emits data/net-new-latest.json = the net-new products (id, title, handle,
 *     vendor, type, tags, first image URL) for the caption/render stage.
 *
 * It NEVER writes to Shopify, never posts, never spends. Safe to re-run.
 * Cost: $0 (Shopify Admin API has no per-call charge).
 *
 * Usage: node diff-new-arrivals.cjs [--top N] [--date YYYY-MM-DD]
 */
const fs = require('fs');
const path = require('path');

const HOST = 'designer-laboratory-sandbox.myshopify.com';
const API = '2024-10';

const args = process.argv.slice(2);
const TOP_N = parseInt(args.find((_, i, a) => a[i - 1] === '--top') || '2000', 10) || 2000;
const TODAY = args.find((_, i, a) => a[i - 1] === '--date') || new Date().toISOString().slice(0, 10);

const ROOT = path.join(__dirname, '..');
const SNAP_DIR = path.join(ROOT, 'data', 'snapshots');
const OUT = path.join(ROOT, 'data', 'net-new-latest.json');
const LOG = path.join(ROOT, 'data', 'engine.log');

const envPath = path.join(process.env.HOME, 'Projects/secrets-manager/.env');
const env = fs.readFileSync(envPath, 'utf8');
const getEnv = (k) => { const m = env.match(new RegExp('^' + k + '=(.*)$', 'm')); return m ? m[1].trim() : ''; };
const TOKEN = getEnv('SHOPIFY_ADMIN_TOKEN');
if (!TOKEN) { console.error('Missing SHOPIFY_ADMIN_TOKEN'); process.exit(1); }

const stamp = () => new Date().toISOString().replace('T', ' ').slice(0, 19);
function logline(msg) {
  const line = `[${stamp()}] ${msg}`;
  console.log(line);
  try { fs.appendFileSync(LOG, line + '\n'); } catch (_) {}
}
const sleep = (ms) => new Promise(r => setTimeout(r, ms));

function gql(query) {
  return fetch(`https://${HOST}/admin/api/${API}/graphql.json`, {
    method: 'POST',
    headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
    body: JSON.stringify({ query }),
  }).then((r) => r.json());
}

async function fetchNewest(cap) {
  // Active products, newest first. Mirrors the tag-sync target set.
  const out = [];
  let cursor = null;
  while (out.length < cap) {
    const after = cursor ? `, after:"${cursor}"` : '';
    const q = `{ products(first:100, query:"status:active", sortKey:CREATED_AT, reverse:true${after}){
      pageInfo{ hasNextPage endCursor }
      nodes{
        id title handle vendor productType tags createdAt
        featuredImage{ url }
      } } }`;
    let r;
    try { r = await gql(q); } catch (e) { await sleep(2500); continue; }
    if (r.errors) {
      if (JSON.stringify(r.errors).includes('Throttled')) { await sleep(3500); continue; }
      throw new Error(JSON.stringify(r.errors).slice(0, 300));
    }
    const conn = r.data.products;
    for (const n of conn.nodes) {
      out.push({
        id: n.id,
        title: n.title,
        handle: n.handle,
        vendor: n.vendor,
        type: n.productType,
        tags: n.tags || [],
        createdAt: n.createdAt,
        image: n.featuredImage ? n.featuredImage.url : null,
      });
      if (out.length >= cap) break;
    }
    if (!conn.pageInfo.hasNextPage) break;
    cursor = conn.pageInfo.endCursor;
    await sleep(250);
  }
  return out;
}

(async () => {
  logline(`diff start — TOP_N=${TOP_N} date=${TODAY}`);
  const today = await fetchNewest(TOP_N);
  logline(`fetched ${today.length} newest active products`);

  // Persist today's snapshot (id -> minimal record).
  fs.mkdirSync(SNAP_DIR, { recursive: true });
  const todaySnap = path.join(SNAP_DIR, `${TODAY}.json`);
  fs.writeFileSync(todaySnap, JSON.stringify({ date: TODAY, top_n: TOP_N, count: today.length, ids: today.map((p) => p.id) }, null, 2));

  // Find most recent PRIOR snapshot (yesterday, or the newest before today).
  const priors = fs.readdirSync(SNAP_DIR)
    .filter((f) => /^\d{4}-\d{2}-\d{2}\.json$/.test(f) && f < `${TODAY}.json`)
    .sort();
  const prior = priors.length ? priors[priors.length - 1] : null;

  let netNew;
  if (!prior) {
    logline('no prior snapshot — first run; net-new is empty (baseline captured)');
    netNew = [];
  } else {
    const prevIds = new Set(JSON.parse(fs.readFileSync(path.join(SNAP_DIR, prior), 'utf8')).ids);
    netNew = today.filter((p) => !prevIds.has(p.id));
    logline(`prior=${prior} — ${netNew.length} net-new products entered the newest-${TOP_N} window`);
  }

  fs.writeFileSync(OUT, JSON.stringify({
    date: TODAY, prior_snapshot: prior, top_n: TOP_N,
    net_new_count: netNew.length, net_new: netNew,
  }, null, 2));
  logline(`wrote ${OUT}`);
  console.log(`\nNET-NEW today (${TODAY}): ${netNew.length}`);
  for (const p of netNew.slice(0, 10)) console.log(`  • ${p.title}  [${p.vendor} / ${p.type}]  img:${p.image ? 'y' : 'n'}`);
  if (netNew.length > 10) console.log(`  … +${netNew.length - 10} more`);
})().catch((e) => { logline('ERROR ' + e.message); process.exit(1); });