← back to Goldleafwallpaper
scripts/ig-poster/post-next.js
217 lines
#!/usr/bin/env node
/*
* post-next.js — post the NEXT RML SKU to Instagram @goldleafwallpaper.
*
* Cadence: one NEW SKU per run; a launchd job fires this every 4 hours. State
* is a rotating cursor over the 61-SKU RML line (data/products.json) — after
* the last SKU it cycles back to the first.
*
* SAFE BY CONSTRUCTION — token-gated dry-run:
* - LIVE only if BOTH GOLDLEAF_IG_TOKEN and GOLDLEAF_IG_USER_ID are set in the
* env AND DRY_RUN is not "1". Otherwise it runs in DRY mode: it builds the
* exact caption + picks the image + advances the cursor + logs what it WOULD
* post, but makes NO Graph API call. This is the "one switch from live"
* design: the day the account is connected (token added), the same
* scheduled job goes live automatically at the next tick.
* - There is no other posting path. No token => no post, ever. This avoids the
* silent "simulation mode looked like it was posting" failure.
*
* Cost: $0 — Meta Graph API publishing is free; everything else is local.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const { buildCaption } = require('./caption');
const { postCncpCard } = require('./cncp-alert');
const { classifyPostError } = require('./classify-error');
const ROOT = path.resolve(__dirname, '../..');
const PRODUCTS = path.join(ROOT, 'data/products.json');
const IG_DIR = path.join(ROOT, 'data/ig');
const STATE_FILE = path.join(IG_DIR, 'queue-state.json');
const LOG_FILE = path.join(IG_DIR, 'post-log.jsonl');
const GRAPH = 'https://graph.facebook.com/v21.0';
const TOKEN = process.env.GOLDLEAF_IG_TOKEN || '';
const IG_USER = process.env.GOLDLEAF_IG_USER_ID || '';
const FORCE_DRY = process.env.DRY_RUN === '1';
const LIVE = Boolean(TOKEN && IG_USER) && !FORCE_DRY;
// The RML product photos carry a branded "PHILLIPE ROMANO — WALLPAPER · FABRICS
// · LOS ANGELES" header banner across the top ~20% (plus an S-#### tag). Steve
// wants the collection posted with NO headers — it looks ugly on the feed. The
// Shopify CDN can crop it off via URL params (keep the bottom-anchored 5:4
// region), so no local processing or image hosting is needed.
function cropHeaderUrl(url) {
const base = String(url).split('?')[0];
return `${base}?width=500&height=400&crop=bottom`;
}
function loadProducts() {
const arr = JSON.parse(fs.readFileSync(PRODUCTS, 'utf8'));
// Only post SKUs that have a usable public image and a dw_sku.
return arr.filter((p) => p.image_url && /^https?:\/\//.test(p.image_url) && p.dw_sku);
}
function loadState(order) {
let state = { order: [], idx: 0, cycles: 0, posted: {} };
if (fs.existsSync(STATE_FILE)) {
try { state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); } catch (_) {}
}
// (Re)seed the order if empty or if the catalog changed size — new SKUs get
// appended to the end so a nightly refresh that adds patterns never resets
// progress or re-posts what's already gone out.
const known = new Set(state.order);
for (const sku of order) if (!known.has(sku)) state.order.push(sku);
state.order = state.order.filter((sku) => order.includes(sku)); // drop retired
if (!Number.isInteger(state.idx)) state.idx = 0;
return state;
}
function saveState(state) {
fs.mkdirSync(IG_DIR, { recursive: true });
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
}
function appendLog(entry) {
fs.mkdirSync(IG_DIR, { recursive: true });
fs.appendFileSync(LOG_FILE, JSON.stringify(entry) + '\n');
}
function nowISO() { return new Date().toISOString(); }
async function publish(imageUrl, caption) {
// Step 1: create a media container.
const createUrl = `${GRAPH}/${IG_USER}/media`;
const createBody = new URLSearchParams({ image_url: imageUrl, caption, access_token: TOKEN });
const cRes = await fetch(createUrl, { method: 'POST', body: createBody });
const cJson = await cRes.json();
if (!cRes.ok || !cJson.id) throw new Error(`container failed: ${JSON.stringify(cJson)}`);
const creationId = cJson.id;
// Step 1.5: poll the container until it finishes processing. Publishing a
// container that is still IN_PROGRESS returns code 9007 ("media is not
// ready"). Poll status_code up to ~30s before giving up.
for (let i = 0; i < 15; i++) {
const sUrl = `${GRAPH}/${creationId}?fields=status_code,status&access_token=${TOKEN}`;
const sJson = await (await fetch(sUrl)).json();
const code = sJson.status_code;
if (code === 'FINISHED') break;
if (code === 'ERROR' || code === 'EXPIRED') throw new Error(`container ${code}: ${JSON.stringify(sJson.status || sJson)}`);
await new Promise((r) => setTimeout(r, 2000));
}
// Step 2: publish the container.
const pubUrl = `${GRAPH}/${IG_USER}/media_publish`;
const pubBody = new URLSearchParams({ creation_id: creationId, access_token: TOKEN });
const pRes = await fetch(pubUrl, { method: 'POST', body: pubBody });
const pJson = await pRes.json();
if (!pRes.ok || !pJson.id) throw new Error(`publish failed: ${JSON.stringify(pJson)}`);
return { creation_id: creationId, media_id: pJson.id };
}
(async () => {
const products = loadProducts();
if (!products.length) { console.error('No postable products found.'); process.exit(1); }
const byId = new Map(products.map((p) => [p.dw_sku, p]));
const order = products.map((p) => p.dw_sku);
const state = loadState(order);
if (state.idx >= state.order.length) { state.idx = 0; state.cycles = (state.cycles || 0) + 1; }
const sku = state.order[state.idx];
const product = byId.get(sku);
const caption = buildCaption(product, state.idx);
const postImageUrl = cropHeaderUrl(product.image_url); // header cropped off
const base = {
ts: nowISO(), mode: LIVE ? 'LIVE' : 'DRY', dw_sku: sku,
mfr_sku: product.mfr_sku || '', image_url: postImageUrl,
cursor: state.idx, cycles: state.cycles || 0, caption,
};
console.log(`\n[goldleaf-ig] ${base.mode} ${sku} (cursor ${state.idx + 1}/${state.order.length}, cycle ${base.cycles})`);
console.log('image (header cropped):', postImageUrl);
console.log('caption:\n' + caption + '\n');
if (LIVE) {
try {
const res = await publish(postImageUrl, caption);
appendLog({ ...base, result: 'ok', ...res });
// Advance the rotating cursor ONLY on a real successful post.
state.idx += 1;
state.posted[sku] = (state.posted[sku] || 0) + 1;
saveState(state);
console.log('POSTED ✓ media_id', res.media_id);
} catch (err) {
const msg = String(err.message || err);
appendLog({ ...base, result: 'error', error: msg });
console.error('POST FAILED:', msg);
// Classify the failure. Meta stamps media-fetch rejections (code 9004 /
// subcode 2207052) as type:"OAuthException" too, so keying the re-auth alert
// off the bare word "OAuth" fired a FALSE "token expired" alert on a healthy
// token and masked the real image-URL bug (TK-11880 grooming, 2026-09-17).
const { isMediaErr, isAuthErr } = classifyPostError(msg);
if (isMediaErr) {
// NOT a token problem — the token is fine; IG could not fetch/accept the
// image URL. Report the real cause; do NOT fire the re-auth alert.
console.error('MEDIA-URI REJECTED (code 9004) — Instagram could not fetch/accept the image URL; '
+ 'the token is unaffected. Check the source image (dimensions/format/transform params): '
+ `${msg.slice(0, 200)}`);
}
// If this is a genuine token/auth failure, alert LOUDLY so the poster can never
// silently stop after the token expires — post a CNCP parking-lot card.
if (isAuthErr) {
// CLAUDE.md TK-11431 amendment 2 — ALERT DELIVERY IS A FINDING, NOT A
// LOG LINE. The previous arm was a hand-rolled fetch wrapped in
// `.catch(() => {})` inside `try {} catch (_) {}`, and then printed
// "posted CNCP card" UNCONDITIONALLY. It could not tell a landed card
// from a swallowed network error, from a swallowed throw, or — the
// case a positive-only test misses entirely — from an HTTP 404/500,
// because fetch() RESOLVES on a non-2xx and so reaches neither catch.
// This is the ONLY alarm on a LIVE Instagram publisher, and
// launchd-job-canary does not cover this exit(2) (scan.sh:66 flags only
// exit 127 or a hard-failure stderr signature, and its BROKEN_RE has no
// alternative matching Meta's wording), so a false success here means
// the poster dies silently. Route through the shared sender, which
// asserts a 2xx and emits a delivery receipt at the chokepoint — then
// report what it ACTUALLY said.
const note = `[@goldleafwallpaper IG poster] token expired — needs re-auth. `
+ `The 4h RML poster stopped: ${msg.slice(0, 200)}. `
+ `Refresh GOLDLEAF_IG_TOKEN in scripts/ig-poster/.env (see CONNECT.md).`;
const alert = postCncpCard('alert://goldleafwallpaper/ig-poster', note);
// Report FIRST, persist second. A throwing appendLog (full disk, bad
// perms) between the two would otherwise swallow the loud stderr line
// this whole change exists to guarantee — the same silencing shape,
// one level up. The write is wrapped for the same reason: bookkeeping
// must never be able to mask the failure it is bookkeeping.
if (alert.delivered) {
console.error('ALERT: token appears expired/invalid — CNCP card DELIVERED (verified 2xx). Re-auth needed.');
} else {
console.error('ALERT NOT DELIVERED: token appears expired/invalid AND the CNCP card FAILED to post'
+ ` (${alert.error}). NOBODY HAS BEEN TOLD — re-auth GOLDLEAF_IG_TOKEN by hand (see CONNECT.md).`);
}
// Durable, project-local record of the ALARM's own fate. The shared
// receipt lands in _shared/data/alert-delivery.jsonl, which
// fleet-health-rollup does not surface for a non-skill project.
try {
appendLog({ ts: nowISO(), dw_sku: sku, result: 'alert', channel: 'cncp',
alert_delivered: alert.delivered, alert_error: alert.error });
} catch (logErr) {
console.error('WARN: could not append the alert receipt to post-log.jsonl:', String(logErr.message || logErr));
}
}
// Do NOT advance the cursor on failure — retry the same SKU next tick.
console.log('cost: $0 (local; Graph publish is free)');
process.exit(2);
}
} else {
// DRY-RUN never advances the cursor, so the first LIVE post starts cleanly
// at the current SKU and walks the full line in order once connected.
appendLog({ ...base, result: 'dry-run' });
const why = FORCE_DRY ? 'DRY_RUN=1' : 'no GOLDLEAF_IG_TOKEN / GOLDLEAF_IG_USER_ID in env';
console.log(`DRY-RUN (${why}) — no Graph API call made. Would post the above.`);
}
console.log('cost: $0 (local; Graph publish is free)');
})();