← 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
M lib/media-cache.jsA scripts/ig-media-repull.js
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 →