[object Object]

← back to Dw Marketing Reels

Auto-post to IG/TikTok (fail-safe pending-creds) + captions + publish-status panel

20c8b48d16f95f71e9e359e95523775bcfd370ee · 2026-07-12 11:21:59 -0700 · Steve Abrams

Files touched

Diff

commit 20c8b48d16f95f71e9e359e95523775bcfd370ee
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Jul 12 11:21:59 2026 -0700

    Auto-post to IG/TikTok (fail-safe pending-creds) + captions + publish-status panel
---
 data/reels.json            | 25 ++++++++++++++++
 public/index.html          | 32 ++++++++++++++++++++
 scripts/build-reel.mjs     | 22 +++++++++++++-
 scripts/cron-run.sh        |  9 ++++--
 scripts/publish-social.mjs | 75 ++++++++++++++++++++++++++++++++++++++++++++++
 5 files changed, 160 insertions(+), 3 deletions(-)

diff --git a/data/reels.json b/data/reels.json
index be54f64..eecfa15 100644
--- a/data/reels.json
+++ b/data/reels.json
@@ -11,6 +11,31 @@
       "Tenaya / Cream",
       "Tenaya / Spa Blue",
       "Tenaya / Sage"
+    ],
+    "publish": {
+      "instagram": {
+        "status": "pending-creds",
+        "note": "IG_AGENT_AUTH not set (route via secrets skill)",
+        "at": "2026-07-12T18:20:10.697Z"
+      },
+      "tiktok": {
+        "status": "pending-reconnect",
+        "note": "TikTok channel not connected",
+        "at": "2026-07-12T18:20:10.697Z"
+      }
+    },
+    "caption": "New arrivals just landed ✨  Katamari, Tenaya, Katamari & more from Thibaut.\n\nDiscover the latest wallcoverings — shop the collection at designerwallcoverings.com 🔗\n\n#DesignerWallcoverings #NewArrivals #Wallpaper #Wallcovering #InteriorDesign #InteriorDecor #HomeDesign #LuxuryInteriors #DesignInspo #Thibaut",
+    "hashtags": [
+      "#DesignerWallcoverings",
+      "#NewArrivals",
+      "#Wallpaper",
+      "#Wallcovering",
+      "#InteriorDesign",
+      "#InteriorDecor",
+      "#HomeDesign",
+      "#LuxuryInteriors",
+      "#DesignInspo",
+      "#Thibaut"
     ]
   }
 ]
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
index c7bd454..2dd836b 100644
--- a/public/index.html
+++ b/public/index.html
@@ -27,6 +27,14 @@
   .reel .titles { font-size:12.5px; color:#4c463c; margin-top:6px; max-height:60px; overflow:hidden; }
   .reel .row { display:flex; gap:10px; margin-top:10px; align-items:center; }
   .reel a { font-size:12px; color:var(--gold); text-decoration:none; letter-spacing:.06em; text-transform:uppercase; }
+  .badges { display:flex; gap:6px; margin:8px 0; flex-wrap:wrap; }
+  .badge { font-size:11px; padding:2px 8px; border-radius:999px; letter-spacing:.02em; }
+  .badge.ok { background:#e5f3e8; color:#1f7a3d; } .badge.sim { background:#eef0f7; color:#4b52a0; }
+  .badge.wait { background:#f6efe0; color:#8a6d2f; } .badge.err { background:#fbe6e4; color:#a3382c; }
+  .cap-box { background:#faf7f0; border:1px solid var(--line); border-radius:8px; padding:8px 10px; margin:8px 0; }
+  .cap-txt { font-size:12px; color:#4c463c; white-space:pre-wrap; max-height:96px; overflow:auto; }
+  button.tiny { font-size:11px; padding:5px 10px; margin-top:8px; }
+  .copied { background:#e5f3e8 !important; color:#1f7a3d !important; border-color:#1f7a3d !important; }
   .controls { display:flex; gap:22px; align-items:center; flex-wrap:wrap; margin:6px 0 16px; }
   .controls label { font-size:12px; letter-spacing:.14em; text-transform:uppercase; color:#8a8272; margin-right:8px; }
   select, input[type=range] { font:inherit; }
@@ -76,6 +84,19 @@
 <script>
 const $ = s => document.querySelector(s);
 const fmt = iso => { try { return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); } catch { return iso||''; } };
+const escapeHtml = s => String(s||'').replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
+const escapeAttr = s => String(s||'').replace(/"/g,'&quot;').replace(/\n/g,'&#10;');
+const STMAP = { posted:['✓ posted','ok'], simulated:['◐ simulated','sim'], 'pending-creds':['◷ needs creds','wait'],
+  'pending-reconnect':['◷ reconnect','wait'], pending:['◷ pending','wait'], error:['✗ error','err'], skipped:['– skipped','wait'] };
+function pubBadges(pub){
+  if(!pub) return '';
+  const ch = { instagram:'IG', tiktok:'TT' };
+  const bits = Object.entries(ch).map(([k,lbl])=>{
+    const st = (pub[k]&&pub[k].status)||'pending'; const [txt,cls]=STMAP[st]||[st,'wait'];
+    return `<span class="badge ${cls}" title="${escapeAttr((pub[k]&&pub[k].note)||st)}">${lbl} ${txt}</span>`;
+  });
+  return `<div class="badges">${bits.join('')}</div>`;
+}
 
 // ---- reels gallery ----
 async function loadReels() {
@@ -86,11 +107,22 @@ async function loadReels() {
       <div class="body">
         <div class="when" title="${r.created_at}">🕓 ${fmt(r.created_at)}</div>
         <div class="titles">${(r.titles||[]).join(' · ')}</div>
+        ${pubBadges(r.publish)}
+        ${r.caption ? `<div class="cap-box"><div class="cap-txt">${escapeHtml(r.caption)}</div>
+          <button class="ghost tiny copy" data-cap="${escapeAttr(r.caption)}">Copy caption</button></div>` : ''}
         <div class="row"><a href="reels/${encodeURIComponent(r.file)}" download>Download</a>
           <span style="font-size:12px;color:#9a917f">${r.products} products · ${r.seconds}s</span></div>
       </div>
     </div>`).join('') : '<p style="color:#8a8272">No reels yet — hit “Generate reel”.</p>';
 }
+// copy caption (delegated)
+$('#reels').addEventListener('click', e => {
+  const b = e.target.closest('.copy'); if (!b) return;
+  navigator.clipboard.writeText(b.dataset.cap || '').then(() => {
+    const o = b.textContent; b.textContent = '✓ Copied'; b.classList.add('copied');
+    setTimeout(() => { b.textContent = o; b.classList.remove('copied'); }, 1500);
+  });
+});
 
 // ---- new arrivals grid (sort + density, persisted) ----
 let NA = [];
diff --git a/scripts/build-reel.mjs b/scripts/build-reel.mjs
index cee6084..5e4b583 100644
--- a/scripts/build-reel.mjs
+++ b/scripts/build-reel.mjs
@@ -15,6 +15,23 @@ const INTRO = 2.0, OUTRO = 2.6;
 
 const esc = s => String(s || '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
 
+// On-brand social caption + hashtags from the reel's products ($0 local, deterministic).
+function captionFor(items) {
+  const pats = [...new Set(items.map(i => i.pattern).filter(Boolean))];
+  const vendors = [...new Set(items.map(i => i.vendor).filter(Boolean))];
+  const lead = pats.slice(0, 3).join(', ');
+  const body = `New arrivals just landed ✨  ${lead}${pats.length > 3 ? ' & more' : ''}`
+    + `${vendors.length ? ` from ${vendors.slice(0, 3).join(', ')}` : ''}.`
+    + `\n\nDiscover the latest wallcoverings — shop the collection at designerwallcoverings.com 🔗`;
+  const tagify = s => '#' + String(s).replace(/[^a-z0-9]+/gi, '');
+  const tags = [...new Set([
+    '#DesignerWallcoverings', '#NewArrivals', '#Wallpaper', '#Wallcovering',
+    '#InteriorDesign', '#InteriorDecor', '#HomeDesign', '#LuxuryInteriors', '#DesignInspo',
+    ...vendors.slice(0, 3).map(tagify),
+  ])].slice(0, 14);
+  return { text: `${body}\n\n${tags.join(' ')}`, hashtags: tags };
+}
+
 async function dl(url, dest) {
   try {
     const r = await fetch(url, { headers: { 'User-Agent': 'dw-marketing-reels' } });
@@ -165,8 +182,11 @@ ${anim}
     // manifest for the gallery
     const manPath = join(ROOT, 'data', 'reels.json');
     const man = existsSync(manPath) ? JSON.parse(readFileSync(manPath, 'utf8')) : [];
+    const cap = captionFor(items);
     man.unshift({ file: outName, created_at: new Date().toISOString(), products: items.length,
-      seconds: total, titles: items.map(i => `${i.pattern} / ${i.color}`) });
+      seconds: total, titles: items.map(i => `${i.pattern} / ${i.color}`),
+      caption: cap.text, hashtags: cap.hashtags,
+      publish: { instagram: { status: 'pending' }, tiktok: { status: 'pending' } } });
     writeFileSync(manPath, JSON.stringify(man, null, 2));
     console.log(`✓ reel -> reels/${outName}`);
   } else {
diff --git a/scripts/cron-run.sh b/scripts/cron-run.sh
index f7868e3..fa8d800 100755
--- a/scripts/cron-run.sh
+++ b/scripts/cron-run.sh
@@ -1,11 +1,16 @@
 #!/bin/zsh
-# Nightly: refresh the New Arrivals feed, render a fresh reel locally ($0), then
-# push the rendered reels + data up to the Kamatera console so the live gallery updates.
+# Nightly: refresh feed → render reel locally ($0) → auto-post to social (fail-safe:
+# simulates/queues until creds land) → push reels + data to the Kamatera console.
 export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
 export HYPERFRAMES_SKIP_SKILLS=1
 cd "$(dirname "$0")/.."
+[ -f .env ] && set -a && source .env && set +a
+
 node scripts/fetch-new-arrivals.mjs && node scripts/build-reel.mjs || exit 1
 
+# auto-post (Steve: auto-post on the nightly schedule). Runs on Mac3 where Norma/tiktok live.
+SOCIAL_AUTOPOST="${SOCIAL_AUTOPOST:-1}" node scripts/publish-social.mjs
+
 PUSH_HOST="${PUSH_HOST:-root@45.61.58.125}"
 PUSH_PATH="${PUSH_PATH:-/root/public-projects/dw-marketing-reels}"
 if ssh -o BatchMode=yes -o ConnectTimeout=6 "$PUSH_HOST" "test -d $PUSH_PATH" 2>/dev/null; then
diff --git a/scripts/publish-social.mjs b/scripts/publish-social.mjs
new file mode 100644
index 0000000..630d21a
--- /dev/null
+++ b/scripts/publish-social.mjs
@@ -0,0 +1,75 @@
+#!/usr/bin/env node
+// Auto-publish the newest reel to Instagram (via Norma's instagram-agent :9810
+// /api/skill/reel) and TikTok (via the DW tiktok skill). Config-driven and
+// FAIL-SAFE: if a channel's creds aren't wired yet it records 'pending-creds' /
+// 'pending-reconnect' and posts NOTHING — it never hard-fails the nightly job,
+// and it flips to live automatically once creds land. Runs on Mac3 (where the
+// agents live), then the status is pushed to the marketing.dw console.
+//
+// Enable with SOCIAL_AUTOPOST=1. Channels via SOCIAL_CHANNELS=instagram,tiktok.
+// IG:   IG_AGENT_URL (default http://127.0.0.1:9810) + IG_AGENT_AUTH (Basic/Bearer header value)
+// Media: PUBLIC_BASE (default https://marketing.designerwallcoverings.com) — NOTE: reels are
+//        Basic-Auth gated, so Meta can't fetch them yet; wiring an unauthenticated media URL
+//        (or signed link) is the remaining go-live step. Until then IG stays simulated/pending.
+import { readFileSync, writeFileSync, existsSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+const MAN = join(ROOT, 'data', 'reels.json');
+const AUTOPOST = process.env.SOCIAL_AUTOPOST === '1';
+const CHANNELS = (process.env.SOCIAL_CHANNELS || 'instagram,tiktok').split(',').map(s => s.trim());
+const IG_URL = process.env.IG_AGENT_URL || 'http://127.0.0.1:9810';
+const IG_AUTH = process.env.IG_AGENT_AUTH || '';
+const PUBLIC_BASE = process.env.PUBLIC_BASE || 'https://marketing.designerwallcoverings.com';
+const now = () => new Date().toISOString();
+
+async function withTimeout(p, ms = 12000) {
+  return Promise.race([p, new Promise((_, r) => setTimeout(() => r(new Error('timeout')), ms))]);
+}
+
+async function postInstagram(reel) {
+  if (!IG_AUTH) return { status: 'pending-creds', note: 'IG_AGENT_AUTH not set (route via secrets skill)', at: now() };
+  const mediaUrl = `${PUBLIC_BASE}/reels/${encodeURIComponent(reel.file)}`;
+  try {
+    const res = await withTimeout(fetch(`${IG_URL}/api/skill/reel`, {
+      method: 'POST',
+      headers: { 'content-type': 'application/json', authorization: IG_AUTH },
+      body: JSON.stringify({ caption: reel.caption, mediaUrl, source: 'dw-marketing-reels' }),
+    }));
+    const txt = await res.text().catch(() => '');
+    let body = {}; try { body = JSON.parse(txt); } catch {}
+    if (!res.ok) return { status: res.status === 401 ? 'pending-creds' : 'error', http: res.status, at: now() };
+    const simulated = body.simulated || /simulat/i.test(txt);
+    return { status: simulated ? 'simulated' : 'posted', postId: body.id || body.postId || null, at: now() };
+  } catch (e) { return { status: 'error', note: e.message, at: now() }; }
+}
+
+async function postTikTok(reel) {
+  // The tiktok skill needs the DW TikTok channel reconnected (OAuth) before the
+  // Content Posting API will accept uploads. Until TIKTOK_ACCESS_TOKEN is present, queue.
+  if (!process.env.TIKTOK_ACCESS_TOKEN) return { status: 'pending-reconnect', note: 'TikTok channel not connected', at: now() };
+  return { status: 'pending-reconnect', note: 'connected-token present but uploader wiring is a follow-up', at: now() };
+}
+
+async function main() {
+  if (!existsSync(MAN)) { console.log('no reels.json — nothing to publish'); return; }
+  const reels = JSON.parse(readFileSync(MAN, 'utf8'));
+  const reel = reels[0];
+  if (!reel) { console.log('no reels'); return; }
+
+  if (!AUTOPOST) {
+    console.log('SOCIAL_AUTOPOST!=1 → dry mode; not publishing. Newest reel:', reel.file);
+    return;
+  }
+
+  reel.publish = reel.publish || {};
+  if (CHANNELS.includes('instagram')) reel.publish.instagram = await postInstagram(reel);
+  if (CHANNELS.includes('tiktok'))    reel.publish.tiktok    = await postTikTok(reel);
+
+  writeFileSync(MAN, JSON.stringify(reels, null, 2));
+  console.log(`publish ${reel.file}:`);
+  for (const [ch, st] of Object.entries(reel.publish)) console.log(`  ${ch}: ${st.status}${st.note ? ' — ' + st.note : ''}`);
+}
+
+main().catch(e => { console.error('publish error:', e.message); process.exit(0); }); // never hard-fail the nightly job

← a1ba7d4 Build-gate for read-only public deploy + nightly push to Kam  ·  back to Dw Marketing Reels  ·  Public media exemption: GET /reels/*.mp4 unauthenticated (co 1584e5c →