← back to Marketing Command Center

scripts/ig-media-repull.js

135 lines

#!/usr/bin/env node
// ig-media-repull — restore durable thumbnails for published Instagram posts.
//
// WHY: board cards store a mediaUrl that can be an expiring IG/fbcdn signed URL.
// Once expired it 403s for everyone and the tile goes blank; it cannot be
// refetched from that dead URL. But for a post that was SUCCESSFULLY published to
// Instagram we hold its media id (outbox item.detail[].id + .igId). The Graph API
// returns that media object's CURRENT media_url / thumbnail_url on demand — a
// fresh, live URL — which we download once and cache locally (keyed by the stable
// media id) so the thumbnail is durable regardless of future URL rotation.
//
// Usage:
//   node scripts/ig-media-repull.js            # DRY RUN — report only, no writes
//   node scripts/ig-media-repull.js --apply    # cache bytes + rewrite outbox (backs up first)
//
// Recoverable = published-IG item WITH a media id. Items whose mediaUrl is a dead
// IG URL but have NO media id (e.g. failed FB cross-posts) are unrecoverable and
// are reported as such — they correctly fall back to the placeholder tile.

const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..');
const mediaCache = require(path.join(ROOT, 'lib', 'media-cache'));

const GRAPH = 'https://graph.facebook.com/v23.0';
const APPLY = process.argv.includes('--apply');

// --- load .env the same way server.js does (existing process.env wins) ---
try {
  for (const line of fs.readFileSync(path.join(ROOT, '.env'), 'utf8').split('\n')) {
    const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)$/);
    if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
  }
} catch { /* no .env */ }
const env = k => (process.env[k] || '').trim();

const OUTBOX = path.join(ROOT, 'data', 'channels-outbox.json');
const META_PAGES = path.join(ROOT, 'data', 'meta-pages.json');
const readJSON = (p, d) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return d; } };

const pages = (readJSON(META_PAGES, { pages: [] }).pages) || [];
const tokenForIg = igId => {
  const pg = pages.find(p => String(p.igId) === String(igId) && p.token);
  return (pg && pg.token) || env('META_ACCESS_TOKEN') || env('IG_ACCESS_TOKEN') || '';
};

// Only images actually at risk of blanking: a missing mediaUrl, or an expiring
// IG/fbcdn signed URL. A stable non-IG URL (e.g. cdn.shopify.com) already works
// and is durable — never clobber it with a lower-res square IG thumbnail.
const atRisk = url => !url || /(^|\.)(cdninstagram\.com|fbcdn\.net)/i.test((() => { try { return new URL(url).host; } catch { return ''; } })());

// Extract every at-risk {item, mediaId, igId} where a post was published to IG.
function igPublishTargets(items) {
  const out = [];
  for (const it of items) {
    if (!atRisk(it.mediaUrl)) continue; // current image is stable/working — leave it
    const dets = Array.isArray(it.detail) ? it.detail : [];
    for (const d of dets) {
      if (d && d.id && (d.igId || /instagram/i.test(it.channel || ''))) out.push({ it, mediaId: String(d.id), igId: d.igId ? String(d.igId) : null });
    }
  }
  return out;
}

async function graphMedia(mediaId, token) {
  const url = `${GRAPH}/${mediaId}?fields=media_url,thumbnail_url,media_type,permalink&access_token=${encodeURIComponent(token)}`;
  const r = await fetch(url);
  const j = await r.json().catch(() => ({}));
  if (!r.ok || j.error) return { err: (j.error && j.error.message) || `HTTP ${r.status}` };
  return j;
}

(async () => {
  const items = readJSON(OUTBOX, []);
  if (!Array.isArray(items)) { console.error('outbox is not an array'); process.exit(1); }

  const targets = igPublishTargets(items);
  console.log(`mode: ${APPLY ? 'APPLY (will cache + rewrite outbox)' : 'DRY RUN (read-only)'}`);
  console.log(`outbox items: ${items.length} | published-IG media targets: ${targets.length}\n`);

  let recovered = 0, alreadyLocal = 0, graphErr = 0, dead = 0;
  const doneMedia = new Map(); // mediaId -> local path (dedupe)

  for (const { it, mediaId, igId } of targets) {
    if (typeof it.mediaUrl === 'string' && it.mediaUrl.startsWith(mediaCache.PUBLIC_PREFIX + '/')) { alreadyLocal++; continue; }
    const token = tokenForIg(igId);
    if (!token) { console.log(`  media ${mediaId}: NO TOKEN (igId ${igId}) — skip`); graphErr++; continue; }

    let local = doneMedia.get(mediaId);
    if (!local) {
      const m = await graphMedia(mediaId, token);
      if (m.err) { console.log(`  media ${mediaId}: graph error — ${m.err}`); graphErr++; continue; }
      const fresh = m.media_type === 'VIDEO' ? (m.thumbnail_url || m.media_url) : (m.media_url || m.thumbnail_url);
      if (!fresh) { console.log(`  media ${mediaId}: no media_url/thumbnail_url in Graph response`); graphErr++; continue; }
      if (!APPLY) { console.log(`  media ${mediaId}: WOULD recover (fresh Graph URL OK, type=${m.media_type})`); recovered++; continue; }
      local = await mediaCache.cacheAs(fresh, 'ig' + mediaId);
      if (!local) { console.log(`  media ${mediaId}: fresh URL fetched but download failed`); dead++; continue; }
      doneMedia.set(mediaId, local);
      console.log(`  media ${mediaId}: cached → ${local}`);
    }
    if (APPLY) { it.mediaUrl = local; recovered++; }
  }

  // Report unrecoverable dead-IG-url items (no media id to re-pull).
  const unrec = items.filter(it => /cdninstagram|fbcdn/.test(it.mediaUrl || '') &&
    !(Array.isArray(it.detail) && it.detail.some(d => d && d.id)));
  if (unrec.length) {
    console.log(`\nunrecoverable (dead IG url, no media id — likely failed posts; placeholder is correct):`);
    unrec.forEach(it => console.log(`  ${it.id} [${it.channel}/${it.status}] ${(it.caption || '').slice(0, 48)}`));
  }

  if (APPLY && recovered > 0) {
    const bak = OUTBOX + '.bak-' + Date.now();
    fs.copyFileSync(OUTBOX, bak);
    fs.writeFileSync(OUTBOX, JSON.stringify(items, null, 2));
    console.log(`\nAPPLIED: rewrote ${recovered} item mediaUrl(s). Backup: ${path.basename(bak)}`);
  }

  // Persist a small status file so the board panel can surface job health at a
  // glance (last run time + result + ok flag). ok = the run itself completed
  // without Graph/download errors (recovered=0 is healthy — it means nothing was
  // at risk, not a failure). Written on every run, dry or apply.
  try {
    const status = {
      ranAt: new Date().toISOString(),
      mode: APPLY ? 'apply' : 'dry-run',
      recovered, alreadyLocal, graphErr, downloadDead: dead, unrecoverable: unrec.length,
      ok: graphErr === 0 && dead === 0,
    };
    fs.writeFileSync(path.join(ROOT, 'data', 'ig-repull-status.json'), JSON.stringify(status, null, 2));
  } catch (e) { console.error('status write failed:', e.message); }

  console.log(`\nsummary: recovered=${recovered} alreadyLocal=${alreadyLocal} graphErr=${graphErr} downloadDead=${dead} unrecoverable=${unrec.length}`);
})().catch(e => { console.error('FATAL', e); process.exit(1); });