← back to Norma
agents/instagram-agent/post-to.js
315 lines
#!/usr/bin/env node
/**
* post-to.js — post to ANY of Steve's Instagram accounts from one command.
*
* Every IG account is addressed by handle; credentials are resolved from
* accounts.json (regenerate with `node build-registry.js`) and the shared
* never-expiring META_ACCESS_TOKEN.
*
* SAFETY GATE: publishing to a live account is outward-facing, so this tool
* runs a DRY RUN by default — it resolves the account, validates inputs, and
* (for images) creates the media container, but STOPS before /media_publish.
* Pass --confirm to actually publish.
*
* Usage:
* node post-to.js list
* node post-to.js <account> --image <public_url> --caption "text" # dry run
* node post-to.js <account> --image <public_url> --caption "text" --confirm # LIVE
* node post-to.js <account> --reel <public_mp4_url> --caption "text" --confirm
* node post-to.js <account> --story <public_url> --confirm
* node post-to.js --all --image <public_url> --caption "text" --confirm # every account
*
* <account> = handle (@velvetwallpaper / velvetwallpaper), page name, or ig_user_id.
*/
const accounts = require('./accounts');
const content = require('./content');
const jewelryGate = require('./jewelry-gate');
// JEWELRY-COUNTER EXCLUSION (Steve, 2026-09-22): no image showing a jewelry
// display case may be published. Classify every candidate image URL at post
// time and drop jewelry:true (and any the classifier can't decide — fail-closed).
// Room-setting images are NEVER dropped. Returns the filtered URL list; throws
// if nothing postable survives. Set JEWELRY_GATE=0 only for an explicit,
// deliberate bypass (never in the cadence).
async function jewelryFilter(urls, handle) {
if (process.env.JEWELRY_GATE === '0') return urls;
const { postable, blocked } = await jewelryGate.gateUrls(urls);
if (blocked.length) {
for (const b of blocked) console.log(` ⊘ @${handle} — excluded ${b.reason} image (${b.scene || ''}): ${b.url}`);
}
if (!postable.length) {
throw new Error(`all ${urls.length} candidate image(s) excluded by jewelry gate (${blocked.map((b) => b.reason).join(',')}) — nothing to post`);
}
return postable;
}
function parseArgs(argv) {
const a = { _: [] };
for (let i = 0; i < argv.length; i++) {
const t = argv[i];
if (t.startsWith('--')) {
const key = t.slice(2);
const next = argv[i + 1];
if (next === undefined || next.startsWith('--')) { a[key] = true; }
else { a[key] = next; i++; }
} else { a._.push(t); }
}
return a;
}
async function graph(host, version, pathPart, params) {
const url = `${host}/${version}/${pathPart}`;
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
const j = await r.json();
if (j.error) throw new Error(j.error.message);
return j;
}
async function graphGet(host, version, pathPart, token, fields) {
const url = `${host}/${version}/${pathPart}?fields=${encodeURIComponent(fields)}&access_token=${encodeURIComponent(token)}`;
const r = await fetch(url);
const j = await r.json();
if (j.error) throw new Error(j.error.message);
return j;
}
/** Poll a container until FINISHED (throws on ERROR / timeout). */
async function waitFinished(host, ver, containerId, token, { tries = 30, delay = 3000 } = {}) {
for (let i = 0; i < tries; i++) {
const s = await fetch(`${host}/${ver}/${containerId}?fields=status_code&access_token=${encodeURIComponent(token)}`);
const sj = await s.json();
if (sj.status_code === 'FINISHED') return;
if (sj.status_code === 'ERROR') throw new Error('container processing error');
await new Promise((res) => setTimeout(res, delay));
}
throw new Error('container never reached FINISHED');
}
/** Confirm the token can actually reach an account (read its profile fields). */
async function verifyOne(acct) {
const { ig_user_id: id, access_token: token, graph_host: host, graph_version: ver } = acct;
const p = await graphGet(host, ver, id, token, 'username,followers_count,media_count');
return { handle: acct.handle, ig_user_id: id, username: p.username,
followers: p.followers_count, posts: p.media_count, reachable: true };
}
/** Carousel (2–10 images): child containers → parent CAROUSEL container → publish. */
async function publishCarousel(acct, imageUrls, caption, dry) {
const { ig_user_id: id, access_token: token, graph_host: host, graph_version: ver } = acct;
if (imageUrls.length < 2 || imageUrls.length > 10) {
throw new Error(`carousel needs 2–10 images (got ${imageUrls.length})`);
}
if (dry) {
return { handle: acct.handle, ig_user_id: id, kind: 'CAROUSEL', dry_run: true,
would_post: { children: imageUrls.length, caption } };
}
// Step 1: a child container per image (is_carousel_item)
const childIds = [];
for (const url of imageUrls) {
const c = await graph(host, ver, `${id}/media`, { image_url: url, is_carousel_item: true, access_token: token });
await waitFinished(host, ver, c.id, token, { tries: 15, delay: 2000 });
childIds.push(c.id);
}
// Step 2: parent CAROUSEL container
const parent = await graph(host, ver, `${id}/media`, {
media_type: 'CAROUSEL', children: childIds.join(','), caption, access_token: token,
});
await waitFinished(host, ver, parent.id, token, { tries: 15, delay: 2000 });
// Step 3: publish
const pub = await graph(host, ver, `${id}/media_publish`, { creation_id: parent.id, access_token: token });
let permalink = null;
try { permalink = (await graphGet(host, ver, pub.id, token, 'permalink')).permalink || null; } catch { /* ignore */ }
return { handle: acct.handle, ig_user_id: id, kind: 'CAROUSEL', children: childIds.length,
media_id: pub.id, permalink, posted: true };
}
async function publishOne(acct, opts) {
const dry = !opts.confirm;
// Carousel: --images "url1,url2,..." (2–10 images)
if (opts.images) {
let urls = String(opts.images).split(',').map((s) => s.trim()).filter(Boolean);
urls = await jewelryFilter(urls, acct.handle); // drop any jewelry-counter slide
// A carousel needs ≥2 images; if the gate leaves exactly one, post it as a
// single image rather than failing the whole post.
if (urls.length === 1) return publishOne(acct, { ...opts, images: undefined, image: urls[0] });
return publishCarousel(acct, urls, opts.caption || '', dry);
}
const { ig_user_id: id, access_token: token, graph_host: host, graph_version: ver } = acct;
const kind = opts.reel ? 'REELS' : opts.story ? 'STORIES' : 'IMAGE';
// Gate single still-image posts (feed image, or a photo story). Video kinds
// (--reel, an .mp4 story) are gated in the reel pipeline (publish-social.mjs).
if (opts.image) { [opts.image] = await jewelryFilter([opts.image], acct.handle); }
else if (opts.story && !/\.mp4($|\?)/i.test(opts.story)) { [opts.story] = await jewelryFilter([opts.story], acct.handle); }
// Build the media-container params for the requested kind
const container = { access_token: token };
if (opts.reel) { container.media_type = 'REELS'; container.video_url = opts.reel; }
else if (opts.story) {
container.media_type = 'STORIES';
if (/\.mp4($|\?)/i.test(opts.story)) container.video_url = opts.story;
else container.image_url = opts.story;
} else { container.image_url = opts.image; }
if (opts.caption && kind !== 'STORIES') container.caption = opts.caption;
if (dry) {
return { handle: acct.handle, ig_user_id: id, kind, dry_run: true,
would_post: { ...container, access_token: '***' } };
}
// Step 1: create container
const c = await graph(host, ver, `${id}/media`, container);
// Step 2: ALL kinds need to reach FINISHED before publish — images fetch the
// remote URL first (IN_PROGRESS for ~1 poll); video kinds transcode (longer).
// Publishing early yields "Media ID is not available".
{
const tries = kind === 'IMAGE' ? 15 : 45;
const delay = kind === 'IMAGE' ? 2000 : 4000;
let finished = false;
for (let i = 0; i < tries; i++) {
const s = await fetch(`${host}/${ver}/${c.id}?fields=status_code&access_token=${encodeURIComponent(token)}`);
const sj = await s.json();
if (sj.status_code === 'FINISHED') { finished = true; break; }
if (sj.status_code === 'ERROR') throw new Error(`container processing error for @${acct.handle}`);
await new Promise((res) => setTimeout(res, delay));
}
if (!finished) throw new Error(`container never reached FINISHED for @${acct.handle}`);
}
// Step 3: publish
const pub = await graph(host, ver, `${id}/media_publish`, { creation_id: c.id, access_token: token });
// permalink (best effort)
let permalink = null;
try {
const p = await fetch(`${host}/${ver}/${pub.id}?fields=permalink&access_token=${encodeURIComponent(token)}`);
permalink = (await p.json()).permalink || null;
} catch { /* ignore */ }
return { handle: acct.handle, ig_user_id: id, kind, media_id: pub.id, permalink, posted: true };
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const cmd = args._[0];
if (cmd === 'list' || args.list) {
const all = accounts.list().sort((a, b) => (b.followers || 0) - (a.followers || 0));
console.log(`${all.length} postable Instagram accounts:\n`);
for (const a of all) {
const stat = a.followers != null ? ` ${String(a.followers).padStart(6)} followers` : '';
console.log(` @${(a.handle || '').padEnd(28)}${stat} ${a.page_name}`);
}
return;
}
// verify — prove the token can reach an account (or all of them). Read-only.
if (cmd === 'verify' || args.verify || args['verify-all']) {
const targets = (args['verify-all'] || args.all || (cmd === 'verify' && !args._[1]))
? accounts.list().map((a) => accounts.resolve(a.handle))
: [accounts.resolve(args._[1] || cmd)].filter(Boolean);
if (!targets.length) { console.error('Unknown account. Try: node post-to.js list'); process.exit(1); }
console.log(`Verifying ${targets.length} account(s) — read-only:\n`);
let ok = 0;
for (const t of targets) {
try { const v = await verifyOne(t); ok++; console.log(` ✓ @${(v.handle||'').padEnd(26)} followers=${v.followers} posts=${v.posts}`); }
catch (e) { console.log(` ✗ @${(t.handle||'').padEnd(26)} ${e.message}`); }
}
console.log(`\n${ok}/${targets.length} reachable with the shared token.`);
return;
}
// --product <handle-or-url>: pull a real DW product → fills --image + --caption
if (args.product) {
try {
const prod = await content.resolveProduct(args.product);
// Carousel when a room-setting image exists (room first, product swatch second); else single image.
if (!args.image && !args.images && prod.images && prod.images.length > 1) {
args.images = prod.images.join(',');
console.log(`Product: ${prod.title}\n carousel: ${prod.images.length} slides (room setting → product)\n`);
} else {
args.image = args.image || prod.image_url;
console.log(`Product: ${prod.title}\n image: ${prod.image_url}\n`);
}
if (!args.caption) args.caption = prod.caption;
args._productTitle = prod.title;
args._productHandle = String(args.product).replace(/^@/, '');
args._imageUrl = prod.image_url;
} catch (e) { console.error(`--product failed: ${e.message}`); process.exit(1); }
}
if (!args.image && !args.images && !args.reel && !args.story) {
console.error('Nothing to post. Provide --product <handle> | --image <url> | --images "u1,u2,..." | --reel <mp4Url> | --story <url>.');
console.error('Run `node post-to.js list` to see accounts, or `verify <account>` to health-check.');
process.exit(1);
}
// Resolve target accounts
let targets;
if (args.all) {
targets = accounts.list().map((a) => accounts.resolve(a.handle));
} else {
if (!cmd) { console.error('Specify an account (or --all).'); process.exit(1); }
const one = accounts.resolve(cmd);
if (!one) { console.error(`Unknown account "${cmd}". Try: node post-to.js list`); process.exit(1); }
targets = [one];
}
const missing = targets.filter((t) => !t.has_token);
if (missing.length) { console.error(`No token resolved for: ${missing.map((m) => m.handle).join(', ')}`); process.exit(1); }
const dry = !args.confirm;
console.log(dry
? `DRY RUN (no --confirm) → ${targets.length} account(s). Nothing will be published.\n`
: `LIVE POST → ${targets.length} account(s).\n`);
const results = [];
for (let ti = 0; ti < targets.length; ti++) {
const t = targets[ti];
try {
const r = await publishOne(t, args);
results.push(r);
if (!dry) {
try {
const fs = require('fs'), path = require('path');
const dir = path.join(__dirname, 'data');
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.appendFileSync(path.join(dir, 'post-ledger.jsonl'), JSON.stringify({
ts: new Date().toISOString(), handle: t.handle, page_name: t.page_name || null,
product_handle: args._productHandle || null, product_title: args._productTitle || null,
image_url: args._imageUrl || args.image || null,
// ALL published slides (carousel = room + swatch), so the cadence's image-dedup guard
// sees the room photo too — not just the swatch (Cody gate, /yoloforever cycle 1).
images: args.images || args._imageUrl || args.image || null,
permalink: r.permalink || null, media_id: r.media_id || null, kind: r.kind || null,
}) + '\n');
} catch (_) { /* ledger is best-effort; never block a post */ }
}
console.log(dry
? ` ✓ @${t.handle} — dry-run OK (${r.kind})`
: ` ✓ @${t.handle} — posted ${r.kind} ${r.permalink || r.media_id}`);
} catch (e) {
results.push({ handle: t.handle, error: e.message });
console.log(` ✗ @${t.handle} — ${e.message}`);
}
// Throttle live fan-out so a --all sweep doesn't hammer the Graph API.
// (Each account has its own ~50-posts/24h publishing limit; 1/account is safe.)
if (!dry && ti < targets.length - 1) await new Promise((res) => setTimeout(res, 1500));
}
const ok = results.filter((r) => !r.error).length;
console.log(`\n${ok}/${results.length} ${dry ? 'validated' : 'posted'}.`);
if (dry) console.log('Add --confirm to publish for real.');
// Refresh the marketing.dw "IG Network Activity" feed after any live post.
if (!dry && ok) {
try {
require('child_process').execFileSync(process.execPath, [require('path').join(__dirname, 'gen-ig-activity.js')], { stdio: 'ignore' });
} catch (_) { /* feed refresh is best-effort */ }
}
}
main().catch((e) => { console.error('FAILED:', e.message); process.exit(1); });