[object Object]

← back to Marketing Command Center

feat(mcc): IG Graph API re-pull to restore durable thumbnails for published IG posts

971082ac77dadf084105177b4d8e421dbeae22dc · 2026-07-22 11:03:03 -0700 · Steve Abrams

scripts/ig-media-repull.js: for every outbox item successfully published to
Instagram (detail[].id = media id, detail[].igId = business account), fetch the
media object's CURRENT media_url/thumbnail_url from Graph v23.0 — a fresh live URL
— and cache the bytes locally keyed by the STABLE media id, then rewrite the
item's mediaUrl to the /cache/media path. Makes published-IG thumbnails durable
regardless of future signed-URL rotation. Token resolved per-igId from
meta-pages.json (per-Page token), falling back to META_ACCESS_TOKEN/IG_ACCESS_TOKEN.

Dry-run by default (read-only Graph calls + report); --apply caches + rewrites the
outbox after backing it up. Items with a dead IG url but NO media id (failed
cross-posts) are reported unrecoverable — the placeholder tile is correct for those.

lib/media-cache.js: factor out fetchImageBuffer + writeAtomic; add exported
cacheAs(url, keyBase) for explicit-key (media-id) caching used by the re-pull.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 971082ac77dadf084105177b4d8e421dbeae22dc
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 22 11:03:03 2026 -0700

    feat(mcc): IG Graph API re-pull to restore durable thumbnails for published IG posts
    
    scripts/ig-media-repull.js: for every outbox item successfully published to
    Instagram (detail[].id = media id, detail[].igId = business account), fetch the
    media object's CURRENT media_url/thumbnail_url from Graph v23.0 — a fresh live URL
    — and cache the bytes locally keyed by the STABLE media id, then rewrite the
    item's mediaUrl to the /cache/media path. Makes published-IG thumbnails durable
    regardless of future signed-URL rotation. Token resolved per-igId from
    meta-pages.json (per-Page token), falling back to META_ACCESS_TOKEN/IG_ACCESS_TOKEN.
    
    Dry-run by default (read-only Graph calls + report); --apply caches + rewrites the
    outbox after backing it up. Items with a dead IG url but NO media id (failed
    cross-posts) are reported unrecoverable — the placeholder tile is correct for those.
    
    lib/media-cache.js: factor out fetchImageBuffer + writeAtomic; add exported
    cacheAs(url, keyBase) for explicit-key (media-id) caching used by the re-pull.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 lib/media-cache.js         |  53 +++++++++++++++------
 scripts/ig-media-repull.js | 114 +++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 153 insertions(+), 14 deletions(-)

diff --git a/lib/media-cache.js b/lib/media-cache.js
index 3660fbd..f6e65af 100644
--- a/lib/media-cache.js
+++ b/lib/media-cache.js
@@ -40,23 +40,48 @@ function cachedUrlIfPresent(key) {
   try { return fs.existsSync(localPathFor(key)) ? `${PUBLIC_PREFIX}/${key}` : null; } catch { return null; }
 }
 
-async function download(u, key) {
-  if (inflight.has(key)) return;
-  inflight.add(key);
+// Fetch + validate an image URL. Returns a Buffer or null (expired/blocked/non-image).
+async function fetchImageBuffer(u) {
+  const ctrl = new AbortController();
+  const t = setTimeout(() => ctrl.abort(), 8000);
   try {
-    const ctrl = new AbortController();
-    const t = setTimeout(() => ctrl.abort(), 6000);
-    const r = await fetch(u, { signal: ctrl.signal, headers: { 'User-Agent': 'Mozilla/5.0 MCP-media-cache' } }).finally(() => clearTimeout(t));
-    if (!r.ok) return; // expired/blocked — nothing we can do; leave original URL
+    const r = await fetch(u, { signal: ctrl.signal, headers: { 'User-Agent': 'Mozilla/5.0 MCP-media-cache' } });
+    if (!r.ok) return null; // expired/blocked — nothing we can do
     const ct = r.headers.get('content-type') || '';
-    if (!/^image\//i.test(ct)) return;
+    if (!/^image\//i.test(ct)) return null;
     const buf = Buffer.from(await r.arrayBuffer());
-    if (buf.length < 64) return; // guard against IG's 21-byte 403 body slipping through
-    fs.mkdirSync(CACHE_DIR, { recursive: true });
-    fs.writeFileSync(localPathFor(key) + '.tmp', buf);
-    fs.renameSync(localPathFor(key) + '.tmp', localPathFor(key)); // atomic publish
-  } catch { /* best-effort */ } finally { inflight.delete(key); }
+    if (buf.length < 64) return null; // guard against IG's 21-byte 403 body slipping through
+    return buf;
+  } catch { return null; } finally { clearTimeout(t); }
+}
+
+function writeAtomic(key, buf) {
+  fs.mkdirSync(CACHE_DIR, { recursive: true });
+  fs.writeFileSync(localPathFor(key) + '.tmp', buf);
+  fs.renameSync(localPathFor(key) + '.tmp', localPathFor(key)); // atomic publish
+}
+
+async function download(u, key) {
+  if (inflight.has(key)) return;
+  inflight.add(key);
+  try { const buf = await fetchImageBuffer(u); if (buf) writeAtomic(key, buf); }
+  finally { inflight.delete(key); }
+}
+
+// Explicit-key cache: download `u` and store under `<keyBase>.<ext>` (ext inferred
+// from the URL, default jpg). Used by the IG Graph re-pull, which keys by the
+// STABLE IG media id so a durable copy survives future URL-signature rotation.
+// Returns the public /cache/media path on success, or null (URL dead/blocked).
+async function cacheAs(u, keyBase) {
+  if (!isRemote(u)) return isLocalCacheUrl(u) ? u : null;
+  const ext = ((() => { try { return new URL(u).pathname; } catch { return u; } })().match(/\.(jpg|jpeg|png|webp|gif)$/i) || [, 'jpg'])[1].toLowerCase();
+  const key = String(keyBase).replace(/[^a-z0-9_-]/gi, '') + '.' + ext;
+  const buf = await fetchImageBuffer(u);
+  if (!buf) return null;
+  writeAtomic(key, buf);
+  return `${PUBLIC_PREFIX}/${key}`;
 }
+function isLocalCacheUrl(u) { return typeof u === 'string' && u.startsWith(PUBLIC_PREFIX + '/'); }
 
 // Return a local URL if cached; else trigger a background fetch and return the
 // original (so this response is instant and later ones self-heal).
@@ -78,4 +103,4 @@ function localizeItems(items, field = 'mediaUrl') {
   return items;
 }
 
-module.exports = { localize, localizeItems, CACHE_DIR, PUBLIC_PREFIX };
+module.exports = { localize, localizeItems, cacheAs, CACHE_DIR, PUBLIC_PREFIX };
diff --git a/scripts/ig-media-repull.js b/scripts/ig-media-repull.js
new file mode 100644
index 0000000..b090347
--- /dev/null
+++ b/scripts/ig-media-repull.js
@@ -0,0 +1,114 @@
+#!/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') || '';
+};
+
+// Extract every {item, mediaId, igId} where a post was published to IG.
+function igPublishTargets(items) {
+  const out = [];
+  for (const it of items) {
+    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)}`);
+  }
+
+  console.log(`\nsummary: recovered=${recovered} alreadyLocal=${alreadyLocal} graphErr=${graphErr} downloadDead=${dead} unrecoverable=${unrec.length}`);
+})().catch(e => { console.error('FATAL', e); process.exit(1); });

← 2ffb36f fix(mcc): durable local cache for expiring IG/fbcdn post ima  ·  back to Marketing Command Center  ·  fix(mcc): IG re-pull only touches at-risk images, never clob 214b1bb →