← back to Marketing Command Center
modules/follow-counts/index.js
223 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 { fetchWithTimeout } = require('../../lib/fetch-timeout.js');
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(/\/$/, '');
// Credential sources, tried in order (TK-12327):
// 1. secrets-manager master .env IG_AGENT_AUTH (canonical "Basic ..." header)
// 2. the instagram-agent's OWN .env AUTH_USERNAME/AUTH_PASSWORD
// 3. NORMA_IG_USER/NORMA_IG_PASS from this repo's env (explicit override, last resort)
// A copied NORMA_IG_PASS went stale when Norma rotated its password after the 2026-07-29
// empty-password incident -> every snapshot 403'd "Invalid credentials" (TK-12008). So a
// 401/403 on one source falls through to the next rather than failing the run: a rotation
// that updates only one of secrets-manager / the agent .env can't silently re-break this.
// readEnvFile/normaAuthCandidates live in lib/norma-auth.js (shared with ig-activity, TK-12342).
const { normaAuthCandidates } = require('../../lib/norma-auth.js');
const NORMA_AUTH = normaAuthCandidates();
// ── 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 })
// RunAtLoad fires at login before pm2 has norma-instagram listening -> "fetch failed".
// Retry connection errors (NOT auth/HTTP errors) a few times before giving up.
async function fetchCountsFromNorma(attempts = 4) {
let last;
for (let i = 0; i < attempts; i++) {
last = await fetchCountsFromNormaOnce();
if (last.ok) return last;
console.error(`[follow-counts] ${new Date().toISOString()} attempt ${i + 1}/${attempts}: ${last.error}`);
if (!/^Norma unreachable/.test(last.error || '')) return last;
if (i < attempts - 1) await new Promise(r => setTimeout(r, 15000));
}
return last;
}
async function fetchCountsFromNormaOnce() {
if (!NORMA_AUTH.length) {
return { ok: false, error: 'Norma auth not configured (no IG_AGENT_AUTH in secrets-manager, no instagram-agent .env, no NORMA_IG_PASS)' };
}
let denied;
for (const cand of NORMA_AUTH) {
const r = await fetchCountsWithAuth(cand.header);
if (r.status === 401 || r.status === 403) {
console.error(`[follow-counts] ${new Date().toISOString()} auth via ${cand.source} rejected (HTTP ${r.status}); trying next source`);
denied = { ok: false, error: `${r.error} (auth rejected by every source: ${NORMA_AUTH.map(c => c.source).join(', ')})` };
continue;
}
delete r.status;
return r;
}
return denied;
}
async function fetchCountsWithAuth(authHeader) {
const url = `${NORMA_BASE}/api/skill/monitor`;
try {
const r = await fetchWithTimeout(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
body: JSON.stringify({ period: 'day', push_to_pulse: false }),
});
const j = await r.json().catch(() => ({}));
if (!r.ok || j.success === false) {
return { ok: false, status: r.status, 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();
// On failure write NO row (Steve, TK-12008): a null "SIMULATED" row makes the history
// look complete when it isn't (TK-11431 false-green class) and the per-day upsert let a
// later failed run overwrite a good LIVE reading. The failure belongs in the error log only.
if (!counts.ok) {
return { ok: false, simulated: true, error: counts.error, date, captured: [] };
}
for (const a of accounts) {
const h = a.handle;
if (a.primary === false) continue; // Norma is single-account: no reading for non-primary
hist[h] = hist[h] || [];
const row = {
date, ts,
followers: counts.followers,
following: counts.following,
media: counts.media,
simulated: counts.simulated,
source: 'norma:monitor',
};
// 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,
};