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