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