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