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