← 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
M .gitignoreA scripts/ig-poster/CONNECT.mdA scripts/ig-poster/caption.jsA scripts/ig-poster/post-next.jsA scripts/ig-poster/run.sh
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 →