← back to New Arrivals Content Engine
New-Arrivals Content Engine: read-only diff + local draft writer (council idea #3)
1167d9eed4b46e57c63953255a55361a93451f84 · 2026-07-20 07:19:48 -0700 · Steve
Files touched
A .gitignoreA README.mdA scripts/diff-new-arrivals.cjsA scripts/draft-social.cjs
Diff
commit 1167d9eed4b46e57c63953255a55361a93451f84
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Jul 20 07:19:48 2026 -0700
New-Arrivals Content Engine: read-only diff + local draft writer (council idea #3)
---
.gitignore | 10 ++++
README.md | 34 +++++++++++
scripts/diff-new-arrivals.cjs | 134 ++++++++++++++++++++++++++++++++++++++++++
scripts/draft-social.cjs | 100 +++++++++++++++++++++++++++++++
4 files changed, 278 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1f10a33
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+data/renders/*.png
+data/renders/*.mp4
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..fd48d24
--- /dev/null
+++ b/README.md
@@ -0,0 +1,34 @@
+# New-Arrivals Content Engine
+
+Officer Idea Council 2026-07-20, **idea #3** (vp-dw-marketing). Turns the daily
+"New Arrivals" SKU feed (already maintained by `com.steve.dw-new-arrivals-500`)
+into staged social **drafts** — never auto-posts.
+
+## Pipeline
+1. `scripts/diff-new-arrivals.cjs` — READ-ONLY. Fetches newest-N active products
+ from Shopify, diffs vs yesterday's snapshot, writes `data/net-new-latest.json`
+ + `data/snapshots/YYYY-MM-DD.json`. $0, idempotent, no Shopify writes.
+2. `scripts/draft-social.cjs` — LOCAL. Drafts IG/TikTok/Pinterest captions for the
+ top net-new products (image-preferred) into `data/review-queue/*.json`,
+ `status:"draft"`. Captions generated locally from tag vocab — no paid API.
+ Room-render is an OPTIONAL $0-local hook (reels-producer) the reviewer triggers.
+
+## Run
+```
+node scripts/diff-new-arrivals.cjs # read-only, safe to re-run
+node scripts/draft-social.cjs --pick 5 # writes drafts only
+```
+
+## Gates (hard)
+- **No posting.** Nothing here sends to any platform or schedules a live send.
+ Publishing an approved draft is Steve-gated (reply "go" / live session).
+- Outbound social copy must carry the Wallcovering-safe / private-label-leak rules
+ before any publish — captions are drafted conservatively but a leak-scan +
+ compliance pass is required at the publish step.
+- $0: Shopify Admin API reads and local caption text. Only optional room-render
+ (local, $0) and the gated publish stage touch anything metered/outward.
+
+## Next (deferred, gated)
+- Wire `reels-producer` render as a queued step.
+- Daily launchd wrapper (`diff` → `draft`) once the first-day pool size is known.
+- Review UI over `data/review-queue/` with approve → (gated) publish.
diff --git a/scripts/diff-new-arrivals.cjs b/scripts/diff-new-arrivals.cjs
new file mode 100644
index 0000000..6c33be8
--- /dev/null
+++ b/scripts/diff-new-arrivals.cjs
@@ -0,0 +1,134 @@
+#!/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); });
diff --git a/scripts/draft-social.cjs b/scripts/draft-social.cjs
new file mode 100644
index 0000000..12aa4ab
--- /dev/null
+++ b/scripts/draft-social.cjs
@@ -0,0 +1,100 @@
+#!/usr/bin/env node
+/**
+ * New-Arrivals Content Engine — STEP 2: draft social posts (LOCAL, $0, NO POST).
+ *
+ * Reads data/net-new-latest.json (from diff-new-arrivals.cjs), picks the top
+ * PICK net-new products (prefers ones WITH a featured image), and writes a
+ * DRAFT post package per product into data/review-queue/ — one JSON file each,
+ * status:"draft". Captions are generated locally from tag-derived style/color
+ * vocabulary (no paid API, no LLM call), and are Wallcovering-safe (no medical/
+ * performance/superlative claims that trip the outbound-comms gate).
+ *
+ * HARD RAIL: this NEVER posts to any platform and never schedules a live send.
+ * Publishing is Steve-gated. The room-render/reels step is left as an OPTIONAL
+ * $0-local hook (render_hint) the reviewer can trigger via reels-producer.
+ *
+ * Usage: node draft-social.cjs [--pick 5]
+ */
+const fs = require('fs');
+const path = require('path');
+
+const args = process.argv.slice(2);
+const PICK = parseInt(args.find((_, i, a) => a[i - 1] === '--pick') || '5', 10) || 5;
+
+const ROOT = path.join(__dirname, '..');
+const IN = path.join(ROOT, 'data', 'net-new-latest.json');
+const QUEUE = path.join(ROOT, 'data', 'review-queue');
+const LOG = path.join(ROOT, 'data', 'engine.log');
+
+if (!fs.existsSync(IN)) { console.error('Run diff-new-arrivals.cjs first — no net-new-latest.json'); process.exit(1); }
+const data = JSON.parse(fs.readFileSync(IN, 'utf8'));
+const stamp = () => new Date().toISOString().replace('T', ' ').slice(0, 19);
+const log = (m) => { const l = `[${stamp()}] ${m}`; console.log(l); try { fs.appendFileSync(LOG, l + '\n'); } catch (_) {} };
+
+// --- local vocab: derive a style + color read from tags/title (deterministic) ---
+const STYLE_WORDS = ['Traditional', 'Modern', 'Floral', 'Damask', 'Geometric', 'Grasscloth',
+ 'Botanical', 'Stripe', 'Toile', 'Chinoiserie', 'Textured', 'Metallic', 'Abstract'];
+const COLOR_WORDS = ['Alabaster', 'Oatmeal', 'Greige', 'Celadon', 'Indigo', 'Charcoal', 'Blush',
+ 'Emerald', 'Navy', 'Gold', 'Ivory', 'Sage', 'Terracotta', 'Slate', 'Cream'];
+
+function pickWord(list, hay) {
+ const h = hay.toLowerCase();
+ return list.find((w) => h.includes(w.toLowerCase())) || null;
+}
+
+function caption(p) {
+ const hay = `${p.title} ${p.tags.join(' ')} ${p.type}`;
+ const style = pickWord(STYLE_WORDS, hay);
+ const color = pickWord(COLOR_WORDS, hay);
+ const kind = (p.type || 'Wallcovering').replace(/s$/, '');
+ const lead = `New arrival: ${p.title}`;
+ const desc = [
+ style ? `${style.toLowerCase()} ${kind.toLowerCase()}` : `${kind.toLowerCase()}`,
+ color ? `in a ${color.toLowerCase()} palette` : null,
+ ].filter(Boolean).join(' ');
+ const base = `${lead} — a ${desc}, just added at Designer Wallcoverings.`;
+ const tagset = ['#wallcovering', '#interiordesign', '#designerwallcoverings',
+ style ? `#${style.toLowerCase()}` : null, '#newarrival'].filter(Boolean);
+ return {
+ instagram: `${base}\nSwatch samples available. Link in bio.\n${tagset.join(' ')}`,
+ tiktok: `${lead} ✨ ${desc}. #wallcovering #interiordesign #fyp`,
+ pinterest: { title: `${p.title} — ${style || kind}`, description: base },
+ derived: { style, color, kind },
+ };
+}
+
+function slug(s) { return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60); }
+
+// prefer products with an image; keep created order otherwise
+const ranked = [...data.net_new].sort((a, b) => (b.image ? 1 : 0) - (a.image ? 1 : 0));
+const picks = ranked.slice(0, PICK);
+
+fs.mkdirSync(QUEUE, { recursive: true });
+let written = 0;
+for (const p of picks) {
+ const cap = caption(p);
+ const numericId = String(p.id).split('/').pop();
+ const draft = {
+ status: 'draft', // NEVER auto-posts; reviewer flips to approved
+ created: stamp(),
+ date: data.date,
+ product: { id: p.id, title: p.title, handle: p.handle, vendor: p.vendor, type: p.type, image: p.image },
+ product_url: `https://www.designerwallcoverings.com/products/${p.handle}`,
+ captions: cap,
+ platforms: ['instagram', 'tiktok', 'pinterest'],
+ asset: {
+ featured_image: p.image,
+ render_hint: p.image
+ ? `reels-producer: $0 local slideshow/room-render from ${p.image}`
+ : 'no featured image — needs asset before publish',
+ render_status: 'pending', // reviewer triggers reels-producer if wanted
+ },
+ gate: 'PUBLISH IS STEVE-GATED — this file is a DRAFT only. No send performed.',
+ };
+ const fp = path.join(QUEUE, `${data.date}__${slug(p.title || numericId)}.json`);
+ fs.writeFileSync(fp, JSON.stringify(draft, null, 2));
+ written++;
+}
+
+log(`drafted ${written} DRAFT post packages into ${QUEUE} (net-new pool=${data.net_new_count}, no posts sent)`);
+console.log(`\n${written} draft(s) written to data/review-queue/ — review, then approve to publish (gated).`);
(oldest)
·
back to New Arrivals Content Engine
·
Treat generated data as runtime-only; keep dir scaffolding 1d0bd39 →