← back to Marketing Command Center

modules/vendors/index.js

140 lines

// Vendor IG Reporting — directory of DW's + all vendor Instagram accounts, for
// competitive/social reporting. Reads data/vendor-instagram.json (curated; refresh
// via research). Now also fetches each account's LAST 10 POSTS via Instagram
// Business Discovery (DW's own IG business account can read any public IG business
// account's recent media). Posts are cached to data/vendor-posts-cache.json; the
// grid renders from cache, and POST /posts/refresh re-crawls. Read-only re: writes.
const fs = require('fs');
const path = require('path');

const GRAPH = 'https://graph.facebook.com/v23.0';
const DATA = path.join(__dirname, '..', '..', 'data', 'vendor-instagram.json');
const CACHE = path.join(__dirname, '..', '..', 'data', 'vendor-posts-cache.json');

function load() { try { return JSON.parse(fs.readFileSync(DATA, 'utf8')); } catch { return []; } }
function loadCache() { try { return JSON.parse(fs.readFileSync(CACHE, 'utf8')); } catch { return {}; } }
function saveCache(c) { fs.writeFileSync(CACHE, JSON.stringify(c, null, 2)); }
function envFrom(file, k) { try { return (fs.readFileSync(file, 'utf8').match(new RegExp('^' + k + '=(.+)$', 'm')) || [])[1]; } catch { return null; } }
// Resolve a usable Meta token — prefer a never-expiring PAGE token. Checks
// META_ACCESS_TOKEN then IG_ACCESS_TOKEN, in MCC .env then the secrets master.
function metaToken() {
  const files = [path.join(__dirname, '..', '..', '.env'), path.join(require('os').homedir(), 'Projects/secrets-manager/.env')];
  for (const k of ['IG_ACCESS_TOKEN', 'META_ACCESS_TOKEN']) for (const f of files) {
    const v = envFrom(f, k);
    if (v && !/TOKEN\s*$/i.test(v) && v.startsWith('EAA')) return v;
  }
  return null;
}
function followersToNum(f) {
  if (!f) return 0;
  const m = String(f).trim().match(/^([\d.]+)\s*([kKmM]?)/);
  if (!m) return 0;
  const n = parseFloat(m[1]); const u = m[2].toLowerCase();
  return Math.round(n * (u === 'm' ? 1e6 : u === 'k' ? 1e3 : 1));
}
const handleOf = r => String(r.handle || '').replace(/^@/, '').trim();
const sleep = ms => new Promise(r => setTimeout(r, ms));

// Find a DW-owned IG business account id to use as the business_discovery node.
let _igNode = null;
async function discoveringIg(token) {
  if (_igNode) return _igNode;
  // page-token path: me == the Page itself
  let r = await fetch(`${GRAPH}/me?fields=name,instagram_business_account{id,username}&access_token=${encodeURIComponent(token)}`);
  let j = await r.json();
  if (!j.error && j.instagram_business_account) {
    _igNode = { id: j.instagram_business_account.id, username: j.instagram_business_account.username };
    return _igNode;
  }
  // 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(token)}`);
  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 any Page');
  _igNode = { id: p.instagram_business_account.id, username: p.instagram_business_account.username };
  return _igNode;
}

async function fetchPosts(igId, token, 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(token)}`);
  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,
    })),
  };
}

module.exports = {
  id: 'vendors',
  title: 'Vendor IG Reporting',
  icon: '📷',
  mount(router) {
    router.get('/accounts', (_req, res) => {
      const cache = loadCache();
      const rows = load().map(r => {
        const h = handleOf(r);
        const c = cache[h] || null;
        return {
          ...r,
          followersNum: followersToNum(r.followersVerified || r.followers),
          followersDisplay: r.followersVerified || r.followers,
          verifiedLive: r.live === true && !!r.followersVerified,
          hasIG: r.handle && r.handle !== 'none found',
          posts: c?.posts || [],
          postsFetchedAt: c?.fetchedAt || null,
          postsError: c?.error || null,
        };
      });
      const withIG = rows.filter(r => r.hasIG);
      const reach = withIG.reduce((s, r) => s + r.followersNum, 0);
      const withPosts = rows.filter(r => r.posts && r.posts.length).length;
      res.json({
        accounts: rows,
        stats: {
          total: rows.length, withIG: withIG.length, missing: rows.length - withIG.length,
          totalReach: reach, withPosts,
          postsFetchedAt: Object.values(cache).map(c => c.fetchedAt).filter(Boolean).sort().pop() || null,
          dw: rows.find(r => r.vendorCode === 'dw') || null,
        },
      });
    });

    // Re-crawl last-10 posts for all (or ?handle=) vendors via business_discovery.
    router.post('/posts/refresh', async (req, res) => {
      const token = metaToken();
      if (!token) return res.json({ ok: false, error: 'No usable Meta token (META_ACCESS_TOKEN / IG_ACCESS_TOKEN)' });
      let ig;
      try { ig = await discoveringIg(token); }
      catch (e) { return res.json({ ok: false, error: 'token/IG check failed: ' + e.message + (/expired|session/i.test(e.message) ? ' — paste a fresh long-lived META_ACCESS_TOKEN' : '') }); }
      const only = (req.query.handle || '').replace(/^@/, '').trim();
      const cache = loadCache();
      const targets = load().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, token, h);
          cache[h] = { ...data, fetchedAt: new Date().toISOString(), error: null };
          ok++;
        } catch (e) {
          cache[h] = { ...(cache[h] || {}), error: e.message, fetchedAt: new Date().toISOString() };
          failed++;
        }
        saveCache(cache);
        await sleep(600); // be gentle on Graph rate limits
      }
      res.json({ ok: true, discoveringIg: '@' + ig.username, refreshed: ok, failed, total: targets.length });
    });
  },
};