← back to Marketing Command Center
scripts/refresh-vendor-posts.js
102 lines
#!/usr/bin/env node
// Populate data/vendor-posts-cache.json with each vendor's last 10 IG posts via
// Instagram Business Discovery. Reusable CLI — mirrors the module's /posts/refresh
// but runs standalone (no server needed) so the panel can be filled from cron/CLI.
//
// Token: prefers a never-expiring PAGE token. Reads META_ACCESS_TOKEN, then
// IG_ACCESS_TOKEN, from the MCC .env first, then ~/Projects/secrets-manager/.env.
// Handles BOTH page tokens (me == Page) and user tokens (me/accounts).
//
// Usage: node scripts/refresh-vendor-posts.js [handle] ($0 — Graph API is free)
const fs = require('fs');
const path = require('path');
const os = require('os');
const ROOT = path.join(__dirname, '..');
const GRAPH = 'https://graph.facebook.com/v23.0';
const DATA = path.join(ROOT, 'data', 'vendor-instagram.json');
const CACHE = path.join(ROOT, 'data', 'vendor-posts-cache.json');
function readEnvVal(file, key) {
try { return (fs.readFileSync(file, 'utf8').match(new RegExp('^' + key + '=(.+)$', 'm')) || [])[1]; }
catch { return null; }
}
// Candidate tokens, best-first: never-expiring PAGE token (IG_ACCESS_TOKEN)
// preferred over the historically short-lived META_ACCESS_TOKEN. Deduped.
function tokenCandidates() {
const files = [path.join(ROOT, '.env'), path.join(os.homedir(), 'Projects/secrets-manager/.env')];
const out = [];
for (const k of ['IG_ACCESS_TOKEN', 'META_ACCESS_TOKEN']) for (const f of files) {
const v = readEnvVal(f, k);
if (v && !/TOKEN\s*$/i.test(v) && v.startsWith('EAA') && !out.includes(v)) out.push(v);
}
return out;
}
const handleOf = r => String(r.handle || '').replace(/^@/, '').trim();
const sleep = ms => new Promise(r => setTimeout(r, ms));
// Resolve the DW IG business account id, supporting page OR user tokens.
async function resolveIgNode(tok) {
// page-token path: me == the Page
let r = await fetch(`${GRAPH}/me?fields=name,instagram_business_account{id,username}&access_token=${encodeURIComponent(tok)}`);
let j = await r.json();
if (!j.error && j.instagram_business_account) return { id: j.instagram_business_account.id, username: j.instagram_business_account.username };
// user-token path: list Pages, find one with a linked IG account
r = await fetch(`${GRAPH}/me/accounts?fields=name,instagram_business_account{id,username}&limit=100&access_token=${encodeURIComponent(tok)}`);
j = await r.json();
if (j.error) throw new Error(j.error.message);
const p = (j.data || []).find(p => p.instagram_business_account);
if (!p) throw new Error('no IG business account linked to this token');
return { id: p.instagram_business_account.id, username: p.instagram_business_account.username };
}
async function fetchPosts(igId, tok, username) {
const fields = `business_discovery.username(${username}){username,followers_count,media_count,media.limit(10){id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count}}`;
const r = await fetch(`${GRAPH}/${igId}?fields=${encodeURIComponent(fields)}&access_token=${encodeURIComponent(tok)}`);
const j = await r.json();
if (j.error) throw new Error(j.error.message);
const bd = j.business_discovery || {};
return {
followers_count: bd.followers_count ?? null,
media_count: bd.media_count ?? null,
posts: (bd.media?.data || []).map(m => ({
id: m.id, caption: (m.caption || '').slice(0, 280), media_type: m.media_type,
image: m.media_type === 'VIDEO' ? (m.thumbnail_url || m.media_url) : m.media_url,
permalink: m.permalink, timestamp: m.timestamp,
likes: m.like_count ?? null, comments: m.comments_count ?? null,
})),
};
}
(async () => {
const cands = tokenCandidates();
if (!cands.length) { console.error('No usable Meta token found (IG_ACCESS_TOKEN / META_ACCESS_TOKEN).'); process.exit(1); }
const only = (process.argv[2] || '').replace(/^@/, '').trim();
const roster = JSON.parse(fs.readFileSync(DATA, 'utf8'));
// Use the first candidate that actually resolves the IG node (skips expired ones).
let tok, ig;
for (const c of cands) {
try { ig = await resolveIgNode(c); tok = c; break; }
catch (e) { console.log(` (token …${c.slice(-4)} unusable: ${e.message.slice(0, 60)})`); }
}
if (!tok) { console.error('All candidate tokens failed to resolve an IG node.'); process.exit(1); }
console.log(`Discovery node: @${ig.username} (${ig.id})`);
const cache = (() => { try { return JSON.parse(fs.readFileSync(CACHE, 'utf8')); } catch { return {}; } })();
const targets = roster.filter(r => { const h = handleOf(r); return h && r.handle !== 'none found' && (!only || h === only); });
let ok = 0, failed = 0;
for (const r of targets) {
const h = handleOf(r);
try {
const data = await fetchPosts(ig.id, tok, h);
cache[h] = { ...data, fetchedAt: new Date().toISOString(), error: null };
ok++; console.log(` ✓ @${h} — ${data.posts.length} posts, ${data.followers_count} followers`);
} catch (e) {
cache[h] = { ...(cache[h] || {}), error: e.message, fetchedAt: new Date().toISOString() };
failed++; console.log(` ✗ @${h} — ${e.message}`);
}
fs.writeFileSync(CACHE, JSON.stringify(cache, null, 2));
await sleep(500);
}
console.log(`Done: ${ok} ok, ${failed} failed of ${targets.length}. Cache → ${CACHE}`);
})().catch(e => { console.error('FATAL', e.message); process.exit(1); });