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