← back to Commercialrealestate

scripts/refresh-firm-ig.js

113 lines

#!/usr/bin/env node
// Populate data/firm-ig-cache.json with each brokerage firm's last 3 Instagram
// posts via Instagram Business Discovery (the ToS-clean path — reads only public
// business/creator accounts). Reuses the never-expiring Page token from the
// MCC .env or the secrets master. $0 — Graph API is free.
//
// Usage: node scripts/refresh-firm-ig.js
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 MAP = path.join(ROOT, 'data', 'firm-ig-map.json');
const CACHE = path.join(ROOT, 'data', 'firm-ig-cache.json');
const THUMBS = path.join(ROOT, 'data', 'firm-ig-thumbs');

// Download an IG CDN image to a local same-origin file (IG blocks cross-origin
// hotlinking + the CDN URLs expire, so we cache the bytes at refresh time and
// serve a local path). Returns the web path or null on failure.
async function cacheThumb(url, handle, i) {
  if (!url) return null;
  try {
    const r = await fetch(url);
    if (!r.ok) return null;
    const buf = Buffer.from(await r.arrayBuffer());
    if (!buf.length) return null;
    fs.mkdirSync(THUMBS, { recursive: true });
    const rel = `data/firm-ig-thumbs/${handle}-${i}.jpg`;
    fs.writeFileSync(path.join(ROOT, rel), buf);
    return rel;
  } catch { return null; }
}

function readEnvVal(file, key) {
  try { return (fs.readFileSync(file, 'utf8').match(new RegExp('^' + key + '=(.+)$', 'm')) || [])[1]; }
  catch { return null; }
}
function tokenCandidates() {
  const files = [
    path.join(os.homedir(), 'Projects/marketing-command-center/.env'),
    path.join(os.homedir(), 'Projects/secrets-manager/.env'),
    path.join(ROOT, '.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 sleep = ms => new Promise(r => setTimeout(r, ms));

async function resolveIgNode(tok) {
  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 };
  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(3){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,
    posts: (bd.media?.data || []).slice(0, 3).map(m => ({
      caption: (m.caption || '').slice(0, 200), 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 (IG_ACCESS_TOKEN / META_ACCESS_TOKEN).'); process.exit(1); }
  const handles = Object.keys(JSON.parse(fs.readFileSync(MAP, 'utf8')).handles || {});
  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,50)})`); } }
  if (!tok) { console.error('No candidate token resolved an IG node.'); process.exit(1); }
  console.log(`Discovery node: @${ig.username}`);
  const cache = (() => { try { return JSON.parse(fs.readFileSync(CACHE, 'utf8')); } catch { return {}; } })();
  let ok = 0, failed = 0;
  for (const h of handles) {
    try {
      const data = await fetchPosts(ig.id, tok, h);
      // Cache each thumbnail locally; fall back to the remote URL if download fails.
      let thumbed = 0;
      for (let i = 0; i < data.posts.length; i++) {
        const local = await cacheThumb(data.posts[i].image, h, i);
        if (local) { data.posts[i].image = local; thumbed++; }
      }
      cache[h] = { ...data, fetchedAt: new Date().toISOString(), error: null };
      ok++; console.log(`  ✓ @${h} — ${data.posts.length} posts (${thumbed} thumbs cached), ${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(400);
  }
  console.log(`Done: ${ok} ok, ${failed} failed of ${handles.length}. → ${CACHE}`);
})().catch(e => { console.error('FATAL', e.message); process.exit(1); });