← back to Dw Marketing Reels
Add self-contained scripts/publish-ig.mjs — post a reel to IG from the marketing project via the FB-graph flow (durable page token), no Norma dependency; SOCIAL_LIVE_ARMED-gated with a public-media reachability guard
15079e25b6c5f77070cbe4d71e5874338c7fc0fa · 2026-07-15 12:34:15 -0700 · Steve Abrams
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files touched
Diff
commit 15079e25b6c5f77070cbe4d71e5874338c7fc0fa
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Jul 15 12:34:15 2026 -0700
Add self-contained scripts/publish-ig.mjs — post a reel to IG from the marketing project via the FB-graph flow (durable page token), no Norma dependency; SOCIAL_LIVE_ARMED-gated with a public-media reachability guard
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
scripts/publish-ig.mjs | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 98 insertions(+)
diff --git a/scripts/publish-ig.mjs b/scripts/publish-ig.mjs
new file mode 100644
index 0000000..a9d6d54
--- /dev/null
+++ b/scripts/publish-ig.mjs
@@ -0,0 +1,98 @@
+#!/usr/bin/env node
+// Self-contained "publish a reel to Instagram from the marketing project."
+// Uses the Facebook-Login Graph flow (graph.facebook.com) with the durable
+// page token — no dependency on Norma's agent being live. Proven path
+// (2026-07-14, reel Dax4CTpmw51).
+//
+// SAFETY: gated by SOCIAL_LIVE_ARMED=1. Without it, runs a DRY check that
+// resolves creds + account + media URL and reports readiness, posting NOTHING.
+//
+// Usage:
+// node scripts/publish-ig.mjs # newest reel, DRY (unless armed)
+// node scripts/publish-ig.mjs --file X.mp4 # a specific reel
+// SOCIAL_LIVE_ARMED=1 node scripts/publish-ig.mjs --file X.mp4 # real post
+//
+// Creds (env first, else the co-located Norma agent .env on this machine):
+// IG_USER_ID, IG_ACCESS_TOKEN (page token, durable)
+import { readFileSync, writeFileSync, existsSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+import { homedir } from 'node:os';
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+const MAN = join(ROOT, 'data', 'reels.json');
+const ARMED = process.env.SOCIAL_LIVE_ARMED === '1';
+const PUBLIC_BASE = process.env.PUBLIC_BASE || 'https://marketing.designerwallcoverings.com';
+const V = 'https://graph.facebook.com/v21.0';
+const argFile = (process.argv.find(a => a.startsWith('--file=')) || '').split('=')[1]
+ || (process.argv.includes('--file') ? process.argv[process.argv.indexOf('--file') + 1] : '');
+
+// resolve creds: env → co-located Norma agent .env (same machine)
+function creds() {
+ let uid = process.env.IG_USER_ID, tok = process.env.IG_ACCESS_TOKEN;
+ if (!uid || !tok) {
+ const p = join(homedir(), 'Projects/Norma/agents/instagram-agent/.env');
+ if (existsSync(p)) {
+ for (const line of readFileSync(p, 'utf8').split('\n')) {
+ const m = line.match(/^(IG_USER_ID|IG_ACCESS_TOKEN)=(.+)$/);
+ if (m) { if (m[1] === 'IG_USER_ID') uid ||= m[2]; else tok ||= m[2]; }
+ }
+ }
+ }
+ return { uid, tok };
+}
+const j = async (u, o) => { const r = await fetch(u, o); const t = await r.text(); let d; try { d = JSON.parse(t); } catch { d = { raw: t }; } return { ok: r.ok, d }; };
+const die = m => { console.error('✗', m); process.exit(1); };
+
+const reels = existsSync(MAN) ? JSON.parse(readFileSync(MAN, 'utf8')) : [];
+const list = Array.isArray(reels) ? reels : (reels.reels || []);
+const reel = argFile ? list.find(r => r.file === argFile) : list[0];
+if (!reel) die(argFile ? `reel not found: ${argFile}` : 'no reels in manifest');
+
+const { uid, tok } = creds();
+if (!uid || !tok) die('IG_USER_ID / IG_ACCESS_TOKEN not found (env or Norma agent .env)');
+const mediaUrl = `${PUBLIC_BASE}/reels/${encodeURIComponent(reel.file)}`;
+
+console.log(`reel: ${reel.file}`);
+console.log(`media: ${mediaUrl}`);
+console.log(`account: ig_user_id ${uid} token …${tok.slice(-4)}`);
+
+// verify the public media is fetchable (Meta must be able to pull it)
+{ const r = await fetch(mediaUrl, { method: 'HEAD' }); console.log(`media reachable: ${r.status}`); if (r.status !== 200) die('public media not 200 — deploy the reel first'); }
+
+if (!ARMED) {
+ console.log('\n⏸ DRY (SOCIAL_LIVE_ARMED != 1) — everything resolves; nothing posted.');
+ console.log(' To post for real: SOCIAL_LIVE_ARMED=1 node scripts/publish-ig.mjs --file ' + reel.file);
+ process.exit(0);
+}
+
+// live publish: container → poll → media_publish → permalink
+console.log('\n→ creating reel container…');
+let r = await j(`${V}/${uid}/media`, { method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ media_type: 'REELS', video_url: mediaUrl, caption: reel.caption, access_token: tok }) });
+if (!r.ok || !r.d.id) die('container create failed: ' + JSON.stringify(r.d));
+const cid = r.d.id;
+let status = '';
+for (let i = 0; i < 40; i++) {
+ await new Promise(s => setTimeout(s, 5000));
+ const s = await j(`${V}/${cid}?fields=status_code&access_token=${tok}`);
+ status = s.d.status_code || '';
+ process.stdout.write(` [${i}] ${status}\n`);
+ if (status === 'FINISHED') break;
+ if (status === 'ERROR' || status === 'EXPIRED') die('processing ' + status + ': ' + JSON.stringify(s.d));
+}
+if (status !== 'FINISHED') die('not FINISHED (last: ' + status + ')');
+console.log('→ publishing…');
+r = await j(`${V}/${uid}/media_publish`, { method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ creation_id: cid, access_token: tok }) });
+if (!r.ok || !r.d.id) die('media_publish failed: ' + JSON.stringify(r.d));
+const mediaId = r.d.id;
+r = await j(`${V}/${mediaId}?fields=permalink&access_token=${tok}`);
+const permalink = r.d.permalink || '(pending)';
+console.log(`\n🎉 PUBLISHED: ${permalink} (media ${mediaId})`);
+
+// record status in the manifest
+reel.publish = reel.publish || {};
+reel.publish.instagram = { status: 'posted', mediaId, permalink, at: new Date().toISOString() };
+writeFileSync(MAN, JSON.stringify(reels, null, 2));
+console.log(' manifest updated (publish.instagram.status = posted)');
← c5afbac auto-save: 2026-07-15T07:35:12 (2 files) — data/new-arrivals
·
back to Dw Marketing Reels
·
chore: v0.4.0 (session close) — FB-graph IG publish + publis 2cd59c0 →