[object Object]

← 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 →