← back to Dw Marketing Reels
scripts/publish-ig.mjs
136 lines
#!/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] : '');
const argAccount = (process.argv.find(a => a.startsWith('--account=')) || '').split('=')[1]
|| (process.argv.includes('--account') ? process.argv[process.argv.indexOf('--account') + 1] : '');
// Resolve a selected account (id OR handle) → its ig_user_id, on the ONE shared
// durable META token. The 35 postable DW accounts all publish on that single token
// (accounts.json token_source = META_ACCESS_TOKEN, shared, never-expiring); only the
// target ig_user_id changes per account. Reads the managed layer first
// (ig-accounts.json carries ig_user_id), then the canonical mirror by handle.
function resolveAccountUid(sel) {
if (!sel) return null;
const key = String(sel).toLowerCase();
const load = f => { try { return JSON.parse(readFileSync(join(ROOT, 'data', f), 'utf8')); } catch { return null; } };
const managed = load('ig-accounts.json') || [];
const m = managed.find(a => String(a.id).toLowerCase() === key || String(a.handle || '').toLowerCase() === key);
if (m && m.ig_user_id) return { uid: m.ig_user_id, label: m.label || m.handle, status: m.status, note: m.note };
const canon = (load('dw-canonical-accounts.json') || {}).accounts || [];
const c = canon.find(a => String(a.handle || '').toLowerCase() === key
|| 'ig-' + String(a.handle || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-') === key);
if (c && c.ig_user_id) return { uid: c.ig_user_id, label: c.name || c.handle, status: 'ready' };
return null;
}
// 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');
let { uid, tok } = creds();
if (!tok) die('IG_ACCESS_TOKEN not found (env or Norma agent .env)');
// Pick the target account: explicit --account, else the reel's saved igAccount
// (set in the console via /api/reel-account), else the env default (main DW).
const selected = argAccount || reel.igAccount || '';
const acct = resolveAccountUid(selected);
let acctLabel = 'env default (main DW)';
if (acct) {
// Refuse non-postable accounts BEFORE the media/Graph calls, with the real reason —
// otherwise the ig_user_id resolves but the Graph post fails with an opaque
// "Object does not exist" (e.g. @grassclothwallpaper, unlinked from the Page; TK-10447).
if (acct.status && acct.status !== 'ready')
die(`account "${selected}" is not postable (status: ${acct.status}). ${acct.note || 'Re-link/create it in Meta Business Manager, then re-run scripts/build-ig-accounts.js.'}`);
uid = acct.uid; acctLabel = acct.label;
}
else if (selected) die(`account not resolvable to an ig_user_id: ${selected}`);
if (!uid) die('IG_USER_ID not found (no account selected and no env IG_USER_ID)');
const mediaUrl = `${PUBLIC_BASE}/reels/${encodeURIComponent(reel.file)}`;
console.log(`reel: ${reel.file}`);
console.log(`media: ${mediaUrl}`);
console.log(`account: ${acctLabel} 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)');