← back to Marketing Command Center
modules/follow-counts/index.js
184 lines
// Followers / Following — per-DW-Instagram-account follower_count + follows_count
// (COUNTS ONLY, never username lists — the IG Graph API does not return follower/
// following username lists, only the aggregate followers_count / follows_count),
// with a once-daily snapshot so growth-over-time can be charted.
//
// DATA SOURCE (single source of truth): Norma's instagram-agent on :9810. Its
// `monitor` skill already issues the canonical Graph call
// GET https://graph.facebook.com/v23.0/{ig_user_id}
// ?fields=followers_count,follows_count,media_count&access_token=TOKEN
// and returns { account:{ followers_count, follows_count, media_count }, simulated }.
// We REUSE that plumbing/token rather than mint a new one. Until Norma's
// IG_USER_ID / IG_ACCESS_TOKEN are configured it returns simulated:true, in which
// case the panel labels the data "awaiting live IG creds" — we persist the shape,
// not fake live numbers.
//
// NO scraping, NO unofficial endpoints — official Graph API counts only.
const fs = require('fs');
const path = require('path');
const DATA = path.join(__dirname, '..', '..', 'data');
const ACCOUNTS = path.join(DATA, 'follow-counts-accounts.json');
const HISTORY = path.join(DATA, 'follow-counts-history.json');
// ── Norma instagram-agent (reuse its token/IG-user-id/simulation) ─────────────
const NORMA_BASE = (process.env.NORMA_IG_BASE || 'http://127.0.0.1:9810').replace(/\/$/, '');
const NORMA_USER = process.env.NORMA_IG_USER || 'admin';
const NORMA_PASS = process.env.NORMA_IG_PASS || ''; // empty by default (admin:)
const normaAuth = 'Basic ' + Buffer.from(`${NORMA_USER}:${NORMA_PASS}`).toString('base64');
// ── account roster ────────────────────────────────────────────────────────────
// The DW-owned IG accounts this tab tracks. Each: { handle, label, igUserId? }.
// igUserId is optional — when Norma is wired to a single IG account it resolves
// the id itself; the handle is the human label + history key. Seeded with DW's
// primary account; Norma currently drives a single business account.
const DEFAULT_ACCOUNTS = [
{ handle: 'designerwallcoverings', label: 'Designer Wallcoverings', primary: true },
];
function loadAccounts() {
try { return JSON.parse(fs.readFileSync(ACCOUNTS, 'utf8')); }
catch { return DEFAULT_ACCOUNTS; }
}
function saveAccounts(a) {
fs.mkdirSync(DATA, { recursive: true });
fs.writeFileSync(ACCOUNTS, JSON.stringify(a, null, 2));
}
// ── history store: { [handle]: [ {date,ts,followers,following,media,simulated} ] } ─
function loadHistory() {
try { return JSON.parse(fs.readFileSync(HISTORY, 'utf8')); }
catch { return {}; }
}
function saveHistory(h) {
fs.mkdirSync(DATA, { recursive: true });
fs.writeFileSync(HISTORY, JSON.stringify(h, null, 2));
}
const todayKey = (d = new Date()) => d.toISOString().slice(0, 10); // YYYY-MM-DD (UTC)
// Ask Norma for the current account counts. Returns
// { ok, followers, following, media, simulated, checked_at } (or { ok:false, error })
async function fetchCountsFromNorma() {
const url = `${NORMA_BASE}/api/skill/monitor`;
try {
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: normaAuth },
body: JSON.stringify({ period: 'day', push_to_pulse: false }),
});
const j = await r.json().catch(() => ({}));
if (!r.ok || j.success === false) {
return { ok: false, error: j.error || `Norma monitor HTTP ${r.status}` };
}
const acct = (j.result && j.result.account) || {};
return {
ok: true,
followers: acct.followers_count ?? null,
following: acct.follows_count ?? null,
media: acct.media_count ?? null,
simulated: !!(j.result && j.result.simulated),
checked_at: (j.result && j.result.checked_at) || new Date().toISOString(),
};
} catch (e) {
return { ok: false, error: `Norma unreachable: ${e.message}` };
}
}
// Capture a snapshot for every roster account. Idempotent per UTC day: a second
// call the same day OVERWRITES that day's row (keeps the latest reading, never
// duplicates the date) so growth points stay one-per-day.
async function captureSnapshot() {
const accounts = loadAccounts();
const hist = loadHistory();
const date = todayKey();
const ts = new Date().toISOString();
const results = [];
// Norma currently drives a single IG account, so one monitor call covers the
// primary; if multiple accounts are configured the same reading applies to the
// primary only and others record a no-data point until Norma is multi-account.
const counts = await fetchCountsFromNorma();
for (const a of accounts) {
const h = a.handle;
hist[h] = hist[h] || [];
let row;
if (a.primary !== false && counts.ok) {
row = {
date, ts,
followers: counts.followers,
following: counts.following,
media: counts.media,
simulated: counts.simulated,
source: 'norma:monitor',
};
} else {
row = { date, ts, followers: null, following: null, media: null, simulated: true,
source: counts.ok ? 'no-account-binding' : 'norma-error',
error: counts.ok ? undefined : counts.error };
}
// upsert by date
const idx = hist[h].findIndex(r => r.date === date);
if (idx >= 0) hist[h][idx] = row; else hist[h].push(row);
hist[h].sort((x, y) => x.date.localeCompare(y.date));
results.push({ handle: h, ...row });
}
saveHistory(hist);
return { ok: counts.ok, simulated: counts.ok ? counts.simulated : true,
error: counts.ok ? undefined : counts.error, date, captured: results };
}
module.exports = {
id: 'follow-counts',
title: 'Followers / Following',
icon: '👥',
mount(router) {
// Roster + each account's latest snapshot + delta vs the previous snapshot.
router.get('/accounts', (_req, res) => {
const accounts = loadAccounts();
const hist = loadHistory();
const rows = accounts.map(a => {
const series = hist[a.handle] || [];
const last = series[series.length - 1] || null;
const prev = series.length > 1 ? series[series.length - 2] : null;
const delta = (last && prev && last.followers != null && prev.followers != null)
? { followers: last.followers - prev.followers, following: (last.following ?? 0) - (prev.following ?? 0) }
: null;
return {
handle: a.handle, label: a.label || a.handle, primary: a.primary !== false,
latest: last, previous: prev, delta, points: series.length,
};
});
const anySim = rows.some(r => !r.latest || r.latest.simulated);
res.json({ accounts: rows, awaitingCreds: anySim,
source: 'instagram graph api · followers_count + follows_count (counts only) via norma :9810' });
});
// Daily series for one account, for the growth chart.
router.get('/history', (req, res) => {
const handle = String(req.query.handle || '').replace(/^@/, '').trim();
const hist = loadHistory();
res.json({ handle, series: hist[handle] || [] });
});
// Capture today's snapshot now (also the endpoint the daily launchd job hits).
router.post('/snapshot', async (_req, res) => {
try { res.json(await captureSnapshot()); }
catch (e) { res.status(500).json({ ok: false, error: e.message }); }
});
// Manage the roster (add/remove a tracked DW account).
router.get('/roster', (_req, res) => res.json({ accounts: loadAccounts() }));
router.post('/roster', (req, res) => {
const { handle, label, primary } = req.body || {};
const h = String(handle || '').replace(/^@/, '').trim();
if (!h) return res.status(400).json({ ok: false, error: 'handle required' });
const accounts = loadAccounts();
if (accounts.some(a => a.handle === h)) return res.json({ ok: true, accounts, note: 'already present' });
accounts.push({ handle: h, label: label || h, primary: !!primary });
saveAccounts(accounts);
res.json({ ok: true, accounts });
});
},
// exported so a CLI / launchd wrapper can capture without going through HTTP
captureSnapshot,
};