- Connector lists Instagram accounts assigned to the system user without a
Page; numbers, inbox, mentions and publishing work for them
- Clients link by Page or Instagram account ("Link an account"); Facebook
features explain the client has no Page instead of failing
- Drafts for Instagram-only clients drop Facebook automatically
- Images for Instagram-only clients are hosted via the agency's own Page
(META_MEDIA_PAGE_ID, unpublished); never falls back to a client's Page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
177 lines
7.4 KiB
JavaScript
177 lines
7.4 KiB
JavaScript
// 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),
|
|
};
|
|
}
|