// Live Instagram / Facebook numbers per client, cached on disk. // Per-post insights take ~5 Graph calls per post, so a full refresh is slow; the UI shows the // last snapshot instantly and refreshes on demand (or on the nightly schedule). import fs from "node:fs"; import path from "node:path"; import { DATA_DIR } from "./config.js"; import { getClient, listClients } from "./clients.js"; import * as meta from "../../connector/src/meta.js"; const SNAP_DIR = path.join(DATA_DIR, "snapshots"); const HIST_DIR = path.join(DATA_DIR, "history"); fs.mkdirSync(SNAP_DIR, { recursive: true }); fs.mkdirSync(HIST_DIR, { recursive: true }); const DAY = 86400 * 1000; const inFlight = new Map(); const avg = (xs) => (xs.length ? Math.round(xs.reduce((a, b) => a + b, 0) / xs.length) : null); async function dailySeries(igId, metric, days = 30) { try { const since = Math.floor((Date.now() - days * DAY) / 1000); const r = await meta.graph(`${igId}/insights`, { metric, period: "day", since, until: Math.floor(Date.now() / 1000) }); return (r.data?.[0]?.values || []).map((v) => ({ date: v.end_time.slice(0, 10), value: v.value ?? 0 })); } catch { return []; // follower_count needs 100+ followers; some metrics aren't available on every account } } function derive(posts) { const now = Date.now(); const recent = posts.filter((p) => now - new Date(p.timestamp) < 28 * DAY); const isReel = (p) => p.media_product_type === "REELS" || p.media_type === "VIDEO"; const isCarousel = (p) => p.media_type === "CAROUSEL_ALBUM"; // Posts published in the last 48h haven't finished collecting reach; leave them out of averages. const settled = posts.filter((p) => now - new Date(p.timestamp) > 2 * DAY && p.insights?.reach != null); const sorted = [...posts].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); const gaps = sorted.slice(0, -1).map((p, i) => (new Date(p.timestamp) - new Date(sorted[i + 1].timestamp)) / DAY); return { postsLast28: recent.length, daysSinceLastPost: sorted[0] ? Math.floor((now - new Date(sorted[0].timestamp)) / DAY) : null, longestGapDays: gaps.length ? Math.round(Math.max(...gaps)) : null, formatMix: { reels: posts.filter(isReel).length, carousels: posts.filter(isCarousel).length, images: posts.filter((p) => !isReel(p) && !isCarousel(p)).length, }, avgReach: { reels: avg(settled.filter(isReel).map((p) => p.insights.reach)), carousels: avg(settled.filter(isCarousel).map((p) => p.insights.reach)), images: avg(settled.filter((p) => !isReel(p) && !isCarousel(p)).map((p) => p.insights.reach)), }, savesTotal: posts.reduce((s, p) => s + (p.insights?.saved || 0), 0), sharesTotal: posts.reduce((s, p) => s + (p.insights?.shares || 0), 0), commentsTotal: posts.reduce((s, p) => s + (p.comments_count || 0), 0), }; } async function fetchSnapshot(client) { const account = await meta.accountForLink(client); if (!account) throw new Error("The linked account is no longer connected to the agency's Meta account."); const snap = { slug: client.slug, fetchedAt: new Date().toISOString(), ig: null, fb: null, errors: [] }; if (account.instagram?.id) { try { const igId = account.instagram.id; const insights = await meta.igAccountInsights(igId, { sinceDays: 28 }); const posts = await meta.igRecentMedia(igId, { limit: 20 }); snap.ig = { profile: insights.profile, totals: insights.totals, metricErrors: insights.errors, dailyReach: await dailySeries(igId, "reach"), dailyFollowers: await dailySeries(igId, "follower_count"), posts: posts.map((p) => ({ ...p, caption: (p.caption || "").slice(0, 600) })), derived: derive(posts), }; } catch (e) { snap.errors.push(`Instagram: ${e.message}`); } } if (account.page_id) try { const page = await meta.pageInsights(account, { sinceDays: 28 }); const posts = await meta.pageRecentPosts(account, { limit: 10 }); snap.fb = { page: page.page, totals: page.totals, metricErrors: page.errors, posts: posts.map((p) => ({ ...p, message: (p.message || "").slice(0, 600) })) }; } catch (e) { snap.errors.push(`Facebook: ${e.message}`); } return snap; } function appendHistory(snap) { const file = path.join(HIST_DIR, `${snap.slug}.jsonl`); const today = snap.fetchedAt.slice(0, 10); const lines = fs.existsSync(file) ? fs.readFileSync(file, "utf8").trim().split("\n").filter(Boolean) : []; const row = { date: today, igFollowers: snap.ig?.profile?.followers_count ?? null, igReach28: snap.ig?.totals?.reach ?? null, igInteractions28: snap.ig?.totals?.total_interactions ?? null, igLinkTaps28: snap.ig?.totals?.profile_links_taps ?? null, fbFollowers: snap.fb?.page?.followers_count ?? null, fbViewers28: snap.fb?.totals?.page_total_media_view_unique ?? null, }; const kept = lines.filter((l) => !l.startsWith(`{"date":"${today}"`)); kept.push(JSON.stringify(row)); fs.writeFileSync(file, kept.slice(-400).join("\n") + "\n"); } export function readHistory(slug) { const file = path.join(HIST_DIR, `${slug}.jsonl`); if (!fs.existsSync(file)) return []; return fs.readFileSync(file, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l)); } export function readSnapshot(slug) { try { return JSON.parse(fs.readFileSync(path.join(SNAP_DIR, `${slug}.json`), "utf8")); } catch { return null; } } export function isRefreshing(slug) { return inFlight.has(slug); } /** Fetch fresh numbers (deduplicated: concurrent callers share one fetch). */ export function refreshSnapshot(slug) { if (inFlight.has(slug)) return inFlight.get(slug); const client = getClient(slug); if (!client?.linked) return Promise.reject(Object.assign(new Error("Link this client to a Facebook Page first."), { status: 400 })); const job = fetchSnapshot(client) .then((snap) => { fs.writeFileSync(path.join(SNAP_DIR, `${slug}.json`), JSON.stringify(snap)); appendHistory(snap); return snap; }) .finally(() => inFlight.delete(slug)); inFlight.set(slug, job); return job; } /** Refresh every linked client one at a time (keeps Graph API rate limits happy). */ export async function refreshAll({ olderThanHours = 0 } = {}) { const done = []; for (const c of listClients().filter((x) => x.linked)) { const snap = readSnapshot(c.slug); if (snap && Date.now() - new Date(snap.fetchedAt) < olderThanHours * 3600 * 1000) continue; try { await refreshSnapshot(c.slug); done.push({ slug: c.slug, ok: true }); } catch (e) { done.push({ slug: c.slug, ok: false, error: e.message }); } } return done; } /** Compact per-client summary for the overview grid. */ export function summary(client) { const snap = readSnapshot(client.slug); const ig = snap?.ig; const posts = ig?.posts || []; return { ...client, fetchedAt: snap?.fetchedAt || null, refreshing: isRefreshing(client.slug), errors: snap?.errors || [], igFollowers: ig?.profile?.followers_count ?? null, igReach28: ig?.totals?.reach ?? null, igInteractions28: ig?.totals?.total_interactions ?? null, igLinkTaps28: ig?.totals?.profile_links_taps ?? null, fbFollowers: snap?.fb?.page?.followers_count ?? null, fbViewers28: snap?.fb?.totals?.page_total_media_view_unique ?? null, postsLast28: ig?.derived?.postsLast28 ?? null, daysSinceLastPost: ig?.derived?.daysSinceLastPost ?? null, reachSpark: posts.slice(0, 12).reverse().map((p) => p.insights?.reach ?? 0), }; }