[object Object]

← back to Goldleafwallpaper

Add @goldleafwallpaper 4h RML Instagram poster (token-gated dry-run, one-switch-from-live)

8e0b68f340bd66369335127145f0e0af5df8ff99 · 2026-08-10 11:36:59 -0700 · Steve Abrams

Files touched

Diff

commit 8e0b68f340bd66369335127145f0e0af5df8ff99
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 10 11:36:59 2026 -0700

    Add @goldleafwallpaper 4h RML Instagram poster (token-gated dry-run, one-switch-from-live)
---
 .gitignore                     |   3 +
 scripts/ig-poster/CONNECT.md   |  53 ++++++++++++++++
 scripts/ig-poster/caption.js   |  73 ++++++++++++++++++++++
 scripts/ig-poster/post-next.js | 136 +++++++++++++++++++++++++++++++++++++++++
 scripts/ig-poster/run.sh       |  10 +++
 5 files changed, 275 insertions(+)

diff --git a/.gitignore b/.gitignore
index ac0ff9a..f2e3ad1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,6 @@ build/
 *.bak
 *.pre-*
 .next/
+
+# IG poster runtime state (cursor + post log)
+data/ig/
diff --git a/scripts/ig-poster/CONNECT.md b/scripts/ig-poster/CONNECT.md
new file mode 100644
index 0000000..c4089a6
--- /dev/null
+++ b/scripts/ig-poster/CONNECT.md
@@ -0,0 +1,53 @@
+# @goldleafwallpaper Instagram poster — connect card
+
+**Status:** BUILT + running every 4h in **DRY-RUN**. It posts nothing until the
+account is connected. The moment the two env vars below exist, the *same*
+scheduled job goes LIVE automatically at the next tick — one switch, no code change.
+
+## What it does
+- Posts **one new RML SKU every 4 hours** (00/04/08/12/16/20) to @goldleafwallpaper.
+- Rotates through all 61 `RML-` patterns in `data/products.json`, then cycles.
+- Each post = the product's Shopify image + an info caption (motif · colorway ·
+  "trade source · free memo samples" · link-in-bio · rotating hashtags).
+- Advances the cursor only on success; a failed post retries the same SKU next tick.
+- Cost: **$0** — Meta Graph publishing is free.
+
+## One-time setup (only you can do this — needs a Meta login)
+Instagram allows automated posting **only** via the Meta Graph API, which requires:
+
+1. **Convert @goldleafwallpaper to a Business (or Creator) account** in the IG app:
+   Settings → Account type → Switch to professional → Business.
+2. **Link it to a Facebook Page** (create a "Gold Leaf Wallpaper" Page if none):
+   IG app → Settings → Business → connect the Page.
+3. In **developers.facebook.com** → your app (reuse the DW/Norma app or make one) →
+   add the Instagram Graph API product, and generate a **long-lived Page access
+   token** with `instagram_basic` + `instagram_content_publish` + `pages_show_list`.
+4. Get the **IG user-id**: `GET /me/accounts` → your Page → `GET /{page-id}?fields=instagram_business_account`.
+
+> Prefer to hand it off? Reply "run the Meta connect" and I'll drive the whole
+> portal with the `portal-driver` skill after your single Meta login, and capture
+> the token + IG user-id for you.
+
+## Flip it live
+Drop the two values into `scripts/ig-poster/.env` (gitignored) — or better, route
+them through the `secrets` skill so they're stored canonically:
+
+```
+GOLDLEAF_IG_TOKEN=<long-lived page token>
+GOLDLEAF_IG_USER_ID=<ig business account id>
+```
+
+Then confirm the next run posts for real:
+
+```
+cd ~/Projects/goldleafwallpaper && node scripts/ig-poster/post-next.js
+```
+
+A `LIVE` line + `POSTED ✓ media_id …` means it's live. To force a dry preview any
+time: `DRY_RUN=1 node scripts/ig-poster/post-next.js`.
+
+## Operate
+- Log: `~/.claude/logs/goldleaf-ig-poster.log` (launchd) and `data/ig/post-log.jsonl`.
+- Pause: `launchctl bootout gui/$(id -u)/com.steve.goldleaf-ig-poster`
+- Resume: `launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.steve.goldleaf-ig-poster.plist`
+- Reset rotation to SKU #1: `rm data/ig/queue-state.json`
diff --git a/scripts/ig-poster/caption.js b/scripts/ig-poster/caption.js
new file mode 100644
index 0000000..797ac8b
--- /dev/null
+++ b/scripts/ig-poster/caption.js
@@ -0,0 +1,73 @@
+// caption.js — build the per-SKU Instagram "info" caption for @goldleafwallpaper.
+// Input fields are empty of price by design (the RML line is trade/quote-only),
+// so captions lead with FINISH + COLORWAY + STYLE and always carry the
+// free-memo-sample CTA + link-in-bio, matching the site + IG bio voice.
+
+// Colorway words worth surfacing (order = display priority).
+const COLOR_PRIORITY = [
+  'Gold', 'Brass', 'Bronze', 'Champagne', 'Silver', 'Platinum', 'Pewter',
+  'Copper', 'Black', 'Charcoal', 'Chocolate', 'Bronze', 'Cream', 'Beige',
+  'Taupe', 'White', 'Gray', 'Grey', 'Yellow', 'Green', 'Blue', 'Red',
+];
+// Style words worth surfacing; generic/structural tags are demoted (used only
+// if nothing more descriptive is present).
+// NB: "Renaissance" is deliberately omitted — it's the collection name and
+// already leads line 1, so line 2 surfaces the actual motif instead of repeating.
+const STYLE_PRIORITY = [
+  'Organic Modern', 'Art Deco', 'Botanical', 'Floral',
+  'Geometric', 'Abstract', 'Damask', 'Stripe',
+];
+const STYLE_FALLBACK = ['Textured', 'Metallic', 'Solid', 'Pattern'];
+
+// A rotating set of on-brand hashtag blocks so every post isn't identical
+// (IG down-ranks copy-paste captions). Selected by SKU index, deterministic.
+const HASHTAG_BLOCKS = [
+  '#goldwallpaper #metalleaf #luxurywallcovering #interiordesign #interiordesigner #luxuryinteriors #wallcovering #metallicwallpaper #designertrade #interiorstyling',
+  '#goldleaf #metallicwallpaper #wallcovering #interiordesign #luxuryhome #designerwallpaper #hospitalitydesign #walldecor #interiordesigners #trade',
+  '#metalleafwallpaper #goldwallpaper #luxuryinteriors #interiordesign #wallcoverings #designtrade #accentwall #interiordecor #wallpaperlove #bespokeinteriors',
+];
+
+function pick(list, priority, fallback) {
+  const set = new Set(list);
+  const hit = priority.find((w) => set.has(w));
+  if (hit) return hit;
+  if (fallback) return fallback.find((w) => set.has(w)) || null;
+  return null;
+}
+
+// Build up to two colorway words into a phrase, e.g. "gold & black".
+function colorwayPhrase(tags) {
+  const set = new Set(tags);
+  const picks = [];
+  for (const c of COLOR_PRIORITY) {
+    if (set.has(c) && !picks.includes(c)) picks.push(c);
+    if (picks.length === 2) break;
+  }
+  if (!picks.length) return 'metallic';
+  return picks.map((c) => c.toLowerCase()).join(' & ');
+}
+
+function buildCaption(product, index = 0) {
+  const tags = (product.tags || '').split(',').map((s) => s.trim()).filter(Boolean);
+  const dwSku = product.dw_sku || '';
+  const material = product.material || 'Metal Leaf';
+  const colorway = colorwayPhrase(tags);
+  const style = pick(tags, STYLE_PRIORITY, STYLE_FALLBACK);
+
+  const styleClause = style ? `${style} ` : '';
+  const line1 = '✦ Renaissance Metal Leaf'; // ✦
+  const line2 = `${cap(styleClause)}metal-leaf wallcovering in ${colorway} — hand-applied, light-catching, made to carry the wall.`;
+  const line3 = dwSku
+    ? `SKU ${dwSku} · trade source · free designer memo samples.`
+    : 'Trade source · free designer memo samples.';
+  const line4 = '📍 goldleafwallpaper.com — link in bio'; // 📍
+  const tagsBlock = HASHTAG_BLOCKS[index % HASHTAG_BLOCKS.length];
+
+  return [line1, line2, line3, line4, '.', tagsBlock].join('\n');
+}
+
+function cap(s) {
+  return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
+}
+
+module.exports = { buildCaption };
diff --git a/scripts/ig-poster/post-next.js b/scripts/ig-poster/post-next.js
new file mode 100644
index 0000000..f2a2ef6
--- /dev/null
+++ b/scripts/ig-poster/post-next.js
@@ -0,0 +1,136 @@
+#!/usr/bin/env node
+/*
+ * post-next.js — post the NEXT RML SKU to Instagram @goldleafwallpaper.
+ *
+ * Cadence: one NEW SKU per run; a launchd job fires this every 4 hours. State
+ * is a rotating cursor over the 61-SKU RML line (data/products.json) — after
+ * the last SKU it cycles back to the first.
+ *
+ * SAFE BY CONSTRUCTION — token-gated dry-run:
+ *   - LIVE only if BOTH GOLDLEAF_IG_TOKEN and GOLDLEAF_IG_USER_ID are set in the
+ *     env AND DRY_RUN is not "1". Otherwise it runs in DRY mode: it builds the
+ *     exact caption + picks the image + advances the cursor + logs what it WOULD
+ *     post, but makes NO Graph API call. This is the "one switch from live"
+ *     design: the day the account is connected (token added), the same
+ *     scheduled job goes live automatically at the next tick.
+ *   - There is no other posting path. No token => no post, ever. This avoids the
+ *     silent "simulation mode looked like it was posting" failure.
+ *
+ * Cost: $0 — Meta Graph API publishing is free; everything else is local.
+ */
+'use strict';
+const fs = require('fs');
+const path = require('path');
+const { buildCaption } = require('./caption');
+
+const ROOT = path.resolve(__dirname, '../..');
+const PRODUCTS = path.join(ROOT, 'data/products.json');
+const IG_DIR = path.join(ROOT, 'data/ig');
+const STATE_FILE = path.join(IG_DIR, 'queue-state.json');
+const LOG_FILE = path.join(IG_DIR, 'post-log.jsonl');
+
+const GRAPH = 'https://graph.facebook.com/v21.0';
+const TOKEN = process.env.GOLDLEAF_IG_TOKEN || '';
+const IG_USER = process.env.GOLDLEAF_IG_USER_ID || '';
+const FORCE_DRY = process.env.DRY_RUN === '1';
+const LIVE = Boolean(TOKEN && IG_USER) && !FORCE_DRY;
+
+function loadProducts() {
+  const arr = JSON.parse(fs.readFileSync(PRODUCTS, 'utf8'));
+  // Only post SKUs that have a usable public image and a dw_sku.
+  return arr.filter((p) => p.image_url && /^https?:\/\//.test(p.image_url) && p.dw_sku);
+}
+
+function loadState(order) {
+  let state = { order: [], idx: 0, cycles: 0, posted: {} };
+  if (fs.existsSync(STATE_FILE)) {
+    try { state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); } catch (_) {}
+  }
+  // (Re)seed the order if empty or if the catalog changed size — new SKUs get
+  // appended to the end so a nightly refresh that adds patterns never resets
+  // progress or re-posts what's already gone out.
+  const known = new Set(state.order);
+  for (const sku of order) if (!known.has(sku)) state.order.push(sku);
+  state.order = state.order.filter((sku) => order.includes(sku)); // drop retired
+  if (!Number.isInteger(state.idx)) state.idx = 0;
+  return state;
+}
+
+function saveState(state) {
+  fs.mkdirSync(IG_DIR, { recursive: true });
+  fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
+}
+
+function appendLog(entry) {
+  fs.mkdirSync(IG_DIR, { recursive: true });
+  fs.appendFileSync(LOG_FILE, JSON.stringify(entry) + '\n');
+}
+
+function nowISO() { return new Date().toISOString(); }
+
+async function publish(imageUrl, caption) {
+  // Step 1: create a media container.
+  const createUrl = `${GRAPH}/${IG_USER}/media`;
+  const createBody = new URLSearchParams({ image_url: imageUrl, caption, access_token: TOKEN });
+  const cRes = await fetch(createUrl, { method: 'POST', body: createBody });
+  const cJson = await cRes.json();
+  if (!cRes.ok || !cJson.id) throw new Error(`container failed: ${JSON.stringify(cJson)}`);
+  const creationId = cJson.id;
+
+  // Step 2: publish the container.
+  const pubUrl = `${GRAPH}/${IG_USER}/media_publish`;
+  const pubBody = new URLSearchParams({ creation_id: creationId, access_token: TOKEN });
+  const pRes = await fetch(pubUrl, { method: 'POST', body: pubBody });
+  const pJson = await pRes.json();
+  if (!pRes.ok || !pJson.id) throw new Error(`publish failed: ${JSON.stringify(pJson)}`);
+  return { creation_id: creationId, media_id: pJson.id };
+}
+
+(async () => {
+  const products = loadProducts();
+  if (!products.length) { console.error('No postable products found.'); process.exit(1); }
+  const byId = new Map(products.map((p) => [p.dw_sku, p]));
+  const order = products.map((p) => p.dw_sku);
+
+  const state = loadState(order);
+  if (state.idx >= state.order.length) { state.idx = 0; state.cycles = (state.cycles || 0) + 1; }
+  const sku = state.order[state.idx];
+  const product = byId.get(sku);
+  const caption = buildCaption(product, state.idx);
+
+  const base = {
+    ts: nowISO(), mode: LIVE ? 'LIVE' : 'DRY', dw_sku: sku,
+    mfr_sku: product.mfr_sku || '', image_url: product.image_url,
+    cursor: state.idx, cycles: state.cycles || 0, caption,
+  };
+
+  console.log(`\n[goldleaf-ig] ${base.mode}  ${sku}  (cursor ${state.idx + 1}/${state.order.length}, cycle ${base.cycles})`);
+  console.log('image:', product.image_url);
+  console.log('caption:\n' + caption + '\n');
+
+  if (LIVE) {
+    try {
+      const res = await publish(product.image_url, caption);
+      appendLog({ ...base, result: 'ok', ...res });
+      // Advance the rotating cursor ONLY on a real successful post.
+      state.idx += 1;
+      state.posted[sku] = (state.posted[sku] || 0) + 1;
+      saveState(state);
+      console.log('POSTED ✓ media_id', res.media_id);
+    } catch (err) {
+      appendLog({ ...base, result: 'error', error: String(err.message || err) });
+      console.error('POST FAILED:', err.message || err);
+      // Do NOT advance the cursor on failure — retry the same SKU next tick.
+      console.log('cost: $0 (local; Graph publish is free)');
+      process.exit(2);
+    }
+  } else {
+    // DRY-RUN never advances the cursor, so the first LIVE post starts cleanly
+    // at the current SKU and walks the full line in order once connected.
+    appendLog({ ...base, result: 'dry-run' });
+    const why = FORCE_DRY ? 'DRY_RUN=1' : 'no GOLDLEAF_IG_TOKEN / GOLDLEAF_IG_USER_ID in env';
+    console.log(`DRY-RUN (${why}) — no Graph API call made. Would post the above.`);
+  }
+
+  console.log('cost: $0 (local; Graph publish is free)');
+})();
diff --git a/scripts/ig-poster/run.sh b/scripts/ig-poster/run.sh
new file mode 100755
index 0000000..13f19b1
--- /dev/null
+++ b/scripts/ig-poster/run.sh
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+# run.sh — launchd entrypoint for the @goldleafwallpaper 4-hour poster.
+# launchd starts jobs with a minimal PATH (no Homebrew node) and no env, so we
+# restore PATH and source the gitignored .env (which is where the Meta token
+# lives once connected). No .env / no token => post-next.js runs in DRY mode.
+set -u
+export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+[ -f "$HERE/.env" ] && set -a && . "$HERE/.env" && set +a
+exec node "$HERE/post-next.js"

← 3c56540 Harden form recipient default → info@designerwallcoverings.c  ·  back to Goldleafwallpaper  ·  ig-poster: poll media container status_code until FINISHED b 9c92498 →