← 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)');
})();