← 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
M data/reels.jsonM public/index.htmlM scripts/build-reel.mjsM scripts/cron-run.shA scripts/publish-social.mjs
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=>({'&':'&','<':'<','>':'>'}[c]));
+const escapeAttr = s => String(s||'').replace(/"/g,'"').replace(/\n/g,' ');
+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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 →