[object Object]

← 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

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 →