metatroncubeswdev 444201e4ec Link-in-bio page per client with click counts
- Public page /l/<address>: Instagram name, picture and bio (or custom), link buttons, latest 6 posts
- Every click counted per link per day through /l/<address>/go/<id>; optional UTM tags for Google Analytics
- Client page → Link in bio: address, links (reorder with buttons), colours, clicks in the last 30 days
- Snapshots now keep the Instagram profile picture

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:20:52 -04:00

492 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Thin Meta Graph API client for Facebook Pages + Instagram (via Facebook Login / System User token).
import { loadEnv } from "./env.js";
loadEnv();
const VERSION = process.env.META_API_VERSION || "v23.0";
const BASE = `https://graph.facebook.com/${VERSION}`;
function systemToken() {
const t = process.env.META_SYSTEM_USER_TOKEN;
if (!t || t.startsWith("PASTE")) {
throw new Error("META_SYSTEM_USER_TOKEN is missing. Put it in the .env file in the project root.");
}
return t;
}
export async function graph(pathname, params = {}, { method = "GET", token } = {}) {
const url = new URL(`${BASE}/${pathname.replace(/^\//, "")}`);
const body = new URLSearchParams();
const all = { ...params, access_token: token || systemToken() };
for (const [k, v] of Object.entries(all)) {
if (v === undefined || v === null) continue;
(method === "GET" ? url.searchParams : body).set(k, String(v));
}
const res = await fetch(url, method === "GET" ? {} : { method, body });
const json = await res.json().catch(() => ({}));
if (!res.ok || json.error) {
const e = json.error || {};
const err = new Error(`Meta API error on ${pathname}: ${e.message || res.statusText} (code ${e.code ?? res.status}${e.error_subcode ? "/" + e.error_subcode : ""})`);
err.meta = e;
throw err;
}
return json;
}
// ---------- Accounts ----------
let accountCache = null;
/** All Pages the system user can access, with page tokens and linked Instagram accounts. */
export async function listAccounts({ refresh = false } = {}) {
if (accountCache && !refresh) return accountCache;
const out = [];
let next = "me/accounts";
let params = {
fields: "id,name,category,access_token,tasks,instagram_business_account{id,username,name,followers_count,media_count}",
limit: 100,
};
while (next) {
const page = await graph(next, params);
for (const p of page.data || []) {
out.push({
page_id: p.id,
page_name: p.name,
category: p.category,
tasks: p.tasks,
page_token: p.access_token,
instagram: p.instagram_business_account || null,
});
}
const nextUrl = page.paging?.next;
if (!nextUrl) break;
const u = new URL(nextUrl);
next = u.pathname.replace(`/${VERSION}/`, "");
params = Object.fromEntries([...u.searchParams].filter(([k]) => k !== "access_token"));
}
// Instagram accounts assigned to the system user directly, with no Facebook Page.
try {
const onPages = new Set(out.map((a) => a.instagram?.id).filter(Boolean));
const ig = await graph("me/assigned_instagram_accounts", { fields: "id,username,name,followers_count,media_count", limit: 100 });
for (const i of ig.data || []) {
if (onPages.has(i.id)) continue;
out.push({ page_id: null, page_name: i.name || i.username, category: "Instagram only", tasks: null, page_token: null, instagram: i, ig_only: true });
}
} catch { /* older setups: Pages only */ }
accountCache = out;
return out;
}
/** Stable id for a connected account: the Page id, or "ig:<id>" for an Instagram account with no Facebook Page. */
export const accountKey = (a) => a.page_id || `ig:${a.instagram?.id}`;
/** The connected account a client's link (client.json) points at: by Page, or by Instagram id when there's no Page. */
export async function accountForLink({ pageId, igId } = {}) {
const pick = (list) => (pageId ? list.find((a) => a.page_id === pageId) : igId ? list.find((a) => a.instagram?.id === igId) : null);
return pick(await listAccounts()) || pick(await listAccounts({ refresh: true })) || null;
}
function needPage(account) {
if (!account.page_id) throw Object.assign(new Error(`${account.page_name} is Instagram-only (no Facebook Page), so Facebook features aren't available for it.`), { status: 400 });
}
/** Find an account by page id, IG id, page name or IG username (case-insensitive, partial ok). */
export async function findAccount(query) {
const q = String(query).toLowerCase().replace(/^@/, "");
const accts = await listAccounts();
return (
accts.find((a) => a.page_id === q || a.instagram?.id === q) ||
accts.find((a) => a.instagram?.username?.toLowerCase() === q || a.page_name.toLowerCase() === q) ||
accts.find((a) => a.page_name.toLowerCase().includes(q) || a.instagram?.username?.toLowerCase().includes(q)) ||
null
);
}
export function publicAccount(a) {
const { page_token, ...rest } = a;
return rest;
}
// Request metrics one by one so a deprecated/unsupported metric doesn't kill the whole call.
async function metricsOneByOne(objectId, metrics, extra, token) {
const results = {};
const errors = {};
for (const m of metrics) {
try {
const r = await graph(`${objectId}/insights`, { metric: m, ...extra }, { token });
results[m] = r.data?.[0] ?? null;
} catch (e) {
errors[m] = e.message;
}
}
return { results, errors };
}
const days = (n) => Math.floor(Date.now() / 1000) - n * 86400;
// ---------- Instagram ----------
export const IG_ACCOUNT_METRICS = ["reach", "views", "accounts_engaged", "total_interactions", "likes", "comments", "shares", "saves", "follows_and_unfollows", "profile_links_taps"];
export async function igAccountInsights(igId, { sinceDays = 28, metrics = IG_ACCOUNT_METRICS } = {}) {
const profile = await graph(igId, { fields: "id,username,name,biography,followers_count,follows_count,media_count,website,profile_picture_url" });
// total_value metrics allow max 30-day windows
const window = { period: "day", metric_type: "total_value", since: days(Math.min(sinceDays, 30)), until: days(0) };
const plain = metrics.filter((m) => m !== "follows_and_unfollows");
const { results, errors } = await metricsOneByOne(igId, plain, window);
const totals = Object.fromEntries(Object.entries(results).map(([k, v]) => [k, v?.total_value?.value ?? null]));
if (metrics.includes("follows_and_unfollows")) {
// This metric only returns data with a follow_type breakdown
const f = await metricsOneByOne(igId, ["follows_and_unfollows"], { ...window, breakdown: "follow_type" });
const rows = f.results.follows_and_unfollows?.total_value?.breakdowns?.[0]?.results || [];
const get = (t) => rows.find((r) => r.dimension_values?.[0] === t)?.value ?? 0;
if (f.results.follows_and_unfollows) {
totals.new_follows = get("FOLLOWER");
totals.unfollows = get("NON_FOLLOWER");
}
Object.assign(errors, f.errors);
}
return { profile, window_days: Math.min(sinceDays, 30), totals, errors };
}
export async function igRecentMedia(igId, { limit = 12, withInsights = true } = {}) {
const r = await graph(`${igId}/media`, {
fields: "id,caption,media_type,media_product_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count",
limit,
});
const media = r.data || [];
if (withInsights) {
for (const m of media) {
const metrics = ["reach", "views", "saved", "shares", "total_interactions"];
const { results, errors } = await metricsOneByOne(m.id, metrics, {});
m.insights = Object.fromEntries(Object.entries(results).map(([k, v]) => [k, v?.values?.[0]?.value ?? null]));
if (Object.keys(errors).length) m.insight_errors = errors;
}
}
return media;
}
export async function igComments(mediaId, { limit = 50 } = {}) {
const r = await graph(`${mediaId}/comments`, { fields: "id,text,username,timestamp,like_count,replies{id,text,username,timestamp}", limit });
return r.data || [];
}
/** Publish an image or reel to Instagram (2-step container flow). media_url must be a public URL. */
export async function igPublish(igId, { mediaUrl, caption, type = "IMAGE" }) {
const params = { caption };
if (type === "REELS") Object.assign(params, { media_type: "REELS", video_url: mediaUrl });
else params.image_url = mediaUrl;
const container = await graph(`${igId}/media`, params, { method: "POST" });
// Wait for video processing
if (type === "REELS") {
for (let i = 0; i < 30; i++) {
const s = await graph(container.id, { fields: "status_code" });
if (s.status_code === "FINISHED") break;
if (s.status_code === "ERROR") throw new Error("Instagram could not process the video.");
await new Promise((r) => setTimeout(r, 5000));
}
}
return graph(`${igId}/media_publish`, { creation_id: container.id }, { method: "POST" });
}
// ---------- Facebook Page ----------
export const PAGE_METRICS = ["page_post_engagements", "page_views_total", "page_daily_follows_unique", "page_media_view", "page_total_media_view_unique"];
export async function pageInsights(account, { sinceDays = 28, metrics = PAGE_METRICS } = {}) {
needPage(account);
const info = await graph(account.page_id, { fields: "id,name,fan_count,followers_count,link" }, { token: account.page_token });
const window = { period: "day", since: days(Math.min(sinceDays, 90)), until: days(0) };
const { results, errors } = await metricsOneByOne(account.page_id, metrics, window, account.page_token);
const totals = Object.fromEntries(
Object.entries(results).map(([k, v]) => [k, (v?.values || []).reduce((s, x) => s + (typeof x.value === "number" ? x.value : 0), 0)])
);
return { page: info, window_days: Math.min(sinceDays, 90), totals, errors };
}
export async function pageRecentPosts(account, { limit = 10 } = {}) {
needPage(account);
const r = await graph(
`${account.page_id}/posts`,
{ fields: "id,message,created_time,permalink_url,full_picture,shares,reactions.summary(total_count).limit(0),comments.summary(total_count).limit(0)", limit },
{ token: account.page_token }
);
return (r.data || []).map((p) => ({
id: p.id,
created_time: p.created_time,
permalink_url: p.permalink_url,
message: p.message,
picture: p.full_picture || null,
reactions: p.reactions?.summary?.total_count ?? 0,
comments: p.comments?.summary?.total_count ?? 0,
shares: p.shares?.count ?? 0,
}));
}
export async function pagePublish(account, { message, imageUrl, link }) {
needPage(account);
if (imageUrl) return graph(`${account.page_id}/photos`, { url: imageUrl, caption: message }, { method: "POST", token: account.page_token });
return graph(`${account.page_id}/feed`, { message, link }, { method: "POST", token: account.page_token });
}
// =====================================================================
// Added 2026-10-17: engagement, discovery, audience, richer publishing
// =====================================================================
import fsSync from "node:fs";
import pathMod from "node:path";
const GRAPH_VIDEO = `https://graph-video.facebook.com/${VERSION}`;
/** Multipart POST (file uploads). `file` = local path. */
async function graphUpload(pathname, fields, filePath, { token, fileField = "source", base = BASE } = {}) {
const form = new FormData();
for (const [k, v] of Object.entries(fields || {})) if (v !== undefined && v !== null) form.set(k, String(v));
form.set("access_token", token || systemToken());
const buf = fsSync.readFileSync(filePath);
form.set(fileField, new Blob([buf]), pathMod.basename(filePath));
const res = await fetch(`${base}/${pathname.replace(/^\//, "")}`, { method: "POST", body: form });
const json = await res.json().catch(() => ({}));
if (!res.ok || json.error) throw new Error(`Meta upload error on ${pathname}: ${json.error?.message || res.statusText}`);
return json;
}
const isUrl = (s) => /^https?:\/\//i.test(String(s || ""));
const isVideo = (s) => /\.(mp4|mov|m4v)$/i.test(String(s || "").split("?")[0]);
// ---------- Comments (Instagram + Facebook) ----------
export async function igReplyComment(commentId, message) {
return graph(`${commentId}/replies`, { message }, { method: "POST" });
}
export async function igHideComment(commentId, hide = true) {
return graph(commentId, { hide }, { method: "POST" });
}
export async function fbPostComments(account, postId, { limit = 50 } = {}) {
const r = await graph(`${postId}/comments`, {
fields: "id,message,from{id,name},created_time,comment_count,is_hidden,comments.limit(5){id,message,from{id,name},created_time}",
filter: "toplevel", order: "reverse_chronological", limit,
}, { token: account.page_token });
return r.data || [];
}
export async function fbReplyComment(account, commentId, message) {
return graph(`${commentId}/comments`, { message }, { method: "POST", token: account.page_token });
}
export async function fbHideComment(account, commentId, hide = true) {
return graph(commentId, { is_hidden: hide }, { method: "POST", token: account.page_token });
}
/** Unified inbox: recent comments on recent posts, newest first, flagged if nobody from the account replied. */
export async function commentInbox(account, { days = 14, posts = 12 } = {}) {
const since = Date.now() - days * 86400000;
const out = [];
if (account.instagram) {
const igUser = account.instagram.username;
const media = (await graph(`${account.instagram.id}/media`, { fields: "id,caption,permalink,timestamp,comments_count", limit: posts })).data || [];
for (const m of media.filter((x) => x.comments_count > 0)) {
const cs = await graph(`${m.id}/comments`, { fields: "id,text,username,timestamp,hidden,replies{id,text,username,timestamp}", limit: 50 }).then((r) => r.data || []).catch(() => []);
for (const c of cs) {
if (new Date(c.timestamp) < since || c.username === igUser) continue;
const replied = (c.replies?.data || []).some((r) => r.username === igUser);
out.push({ platform: "instagram", id: c.id, from: c.username, text: c.text, at: c.timestamp, hidden: !!c.hidden, replied, post: { id: m.id, permalink: m.permalink, caption: (m.caption || "").slice(0, 80) } });
}
}
}
const fbPosts = account.page_id ? await pageRecentPosts(account, { limit: posts }).catch(() => []) : [];
for (const p of fbPosts.filter((x) => x.comments > 0)) {
const cs = await fbPostComments(account, p.id).catch(() => []);
for (const c of cs) {
if (new Date(c.created_time) < since || c.from?.id === account.page_id) continue;
const replied = (c.comments?.data || []).some((r) => r.from?.id === account.page_id);
out.push({ platform: "facebook", id: c.id, from: c.from?.name || "Facebook user", text: c.message, at: c.created_time, hidden: !!c.is_hidden, replied, post: { id: p.id, permalink: p.permalink_url, caption: (p.message || "").slice(0, 80) } });
}
}
return out.sort((a, b) => b.at.localeCompare(a.at));
}
// ---------- Mentions, competitors, audience ----------
/** Posts where other accounts tagged this Instagram account. */
export async function igMentions(igId, { limit = 25 } = {}) {
const r = await graph(`${igId}/tags`, { fields: "id,caption,media_type,permalink,timestamp,username,like_count,comments_count", limit });
return r.data || [];
}
/** Public stats and recent posts of any other Instagram business/creator account (competitor research). */
export async function igBusinessDiscovery(igId, username, { posts = 12 } = {}) {
const u = String(username).replace(/^@/, "");
const r = await graph(igId, {
fields: `business_discovery.username(${u}){username,name,biography,website,followers_count,follows_count,media_count,media.limit(${posts}){id,caption,like_count,comments_count,media_product_type,media_type,timestamp,permalink}}`,
});
const b = r.business_discovery;
const media = b?.media?.data || [];
const avg = (k) => (media.length ? Math.round(media.reduce((s, m) => s + (m[k] || 0), 0) / media.length) : 0);
return { ...b, media, avg_likes: avg("like_count"), avg_comments: avg("comments_count") };
}
/** Follower demographics: top cities, countries, age and gender. */
export async function igAudience(igId) {
const out = {}; const errors = {};
for (const breakdown of ["city", "country", "age", "gender"]) {
let r = null;
for (const extra of [{}, { timeframe: "this_month" }, { timeframe: "last_30_days" }]) {
try { r = await graph(`${igId}/insights`, { metric: "follower_demographics", period: "lifetime", metric_type: "total_value", breakdown, ...extra }); break; }
catch (e) { errors[breakdown] = e.message; }
}
const rows = r?.data?.[0]?.total_value?.breakdowns?.[0]?.results || [];
if (rows.length) { delete errors[breakdown]; out[breakdown] = rows.map((x) => ({ key: x.dimension_values?.join(" / "), value: x.value })).sort((a, b) => b.value - a.value).slice(0, 10); }
}
// Hours when followers are online (may be unavailable on newer API versions).
try {
const r = await graph(`${igId}/insights`, { metric: "online_followers", period: "lifetime" });
const v = r.data?.[0]?.values?.slice(-1)[0]?.value;
if (v) out.online_hours_utc = v;
} catch (e) { errors.online_followers = e.message; }
return { ...out, errors };
}
// ---------- Direct messages (needs instagram_manage_messages / pages_messaging) ----------
// Instagram-only accounts (no Page) go through the Instagram account itself with the system user token.
export async function listConversations(account, { platform = "instagram", limit = 20 } = {}) {
if (!account.page_id && platform !== "instagram") needPage(account);
const node = account.page_id || account.instagram?.id;
const r = await graph(`${node}/conversations`, {
platform, limit, fields: "id,updated_time,participants,messages.limit(3){id,message,from,created_time}",
}, { token: account.page_token || undefined });
return r.data || [];
}
export async function sendMessage(account, recipientId, text, { platform = "instagram" } = {}) {
if (!account.page_id && platform !== "instagram") needPage(account);
const node = account.page_id || account.instagram?.id;
return graph(`${node}/messages`, {
recipient: JSON.stringify({ id: recipientId }), message: JSON.stringify({ text }), messaging_type: "RESPONSE",
...(platform === "instagram" ? { platform: "instagram" } : {}),
}, { method: "POST", token: account.page_token || undefined });
}
// ---------- Media hosting helpers ----------
/**
* Turn a local image into a public URL by uploading it as an UNPUBLISHED photo (never shown on the Page).
* Uses the client's own Page; Instagram-only clients use the agency's own Page (META_MEDIA_PAGE_ID in .env).
* Never another client's Page: their admins would see the upload in their media library.
*/
async function mediaPageFor(account) {
if (account.page_token) return account;
const id = process.env.META_MEDIA_PAGE_ID;
if (!id) throw new Error("Instagram-only clients need META_MEDIA_PAGE_ID in .env (the agency's own Facebook Page ID) to post images.");
const host = (await listAccounts()).find((a) => a.page_id === id && a.page_token);
if (!host) throw new Error("META_MEDIA_PAGE_ID doesn't match a Facebook Page the agency's system user can manage.");
return host;
}
export async function hostImage(account, filePath) {
const host = await mediaPageFor(account);
const up = await graphUpload(`${host.page_id}/photos`, { published: false }, filePath, { token: host.page_token });
const info = await graph(up.id, { fields: "images" }, { token: host.page_token });
const best = (info.images || []).sort((a, b) => b.width - a.width)[0];
if (!best?.source) throw new Error("Couldn't get a public URL for the uploaded image.");
return best.source;
}
async function waitForContainer(id, { tries = 60, every = 5000 } = {}) {
for (let i = 0; i < tries; i++) {
const s = await graph(id, { fields: "status_code,status" });
if (s.status_code === "FINISHED") return;
if (s.status_code === "ERROR" || s.status_code === "EXPIRED") throw new Error(`Instagram couldn't process the media: ${s.status || s.status_code}`);
await new Promise((r) => setTimeout(r, every));
}
throw new Error("Instagram is still processing the video after 5 minutes. Try again later.");
}
/** Upload a local video straight to Instagram (resumable upload). Returns a container id. */
async function igUploadLocalVideo(igId, filePath, params) {
const c = await graph(`${igId}/media`, { ...params, upload_type: "resumable" }, { method: "POST" });
const size = fsSync.statSync(filePath).size;
const res = await fetch(c.uri, {
method: "POST",
headers: { Authorization: `OAuth ${systemToken()}`, offset: "0", file_size: String(size) },
body: fsSync.readFileSync(filePath),
});
const j = await res.json().catch(() => ({}));
if (!res.ok || j.success === false) throw new Error(`Video upload failed: ${j.debug_info?.message || j.error?.message || res.statusText}`);
return c.id;
}
/**
* Publish anything to Instagram.
* type: IMAGE | REELS | CAROUSEL | STORY. media: array of local paths or public URLs.
* collaborators: array of usernames (Collab post invites).
*/
export async function igPublishAny(account, { type = "IMAGE", media = [], caption = "", collaborators = [], coverUrl, shareToFeed = true }) {
const igId = account.instagram?.id;
if (!igId) throw new Error(`${account.page_name} has no Instagram account linked.`);
const collab = collaborators.length ? JSON.stringify(collaborators.map((c) => String(c).replace(/^@/, ""))) : undefined;
const asUrl = async (m) => (isUrl(m) ? m : isVideo(m) ? null : hostImage(account, m));
let creationId;
if (type === "CAROUSEL") {
if (media.length < 2 || media.length > 10) throw new Error("A carousel needs 2–10 items.");
const children = [];
for (const m of media) {
if (isVideo(m)) {
const id = isUrl(m)
? (await graph(`${igId}/media`, { media_type: "VIDEO", video_url: m, is_carousel_item: true }, { method: "POST" })).id
: await igUploadLocalVideo(igId, m, { media_type: "VIDEO", is_carousel_item: true });
await waitForContainer(id); children.push(id);
} else {
children.push((await graph(`${igId}/media`, { image_url: await asUrl(m), is_carousel_item: true }, { method: "POST" })).id);
}
}
creationId = (await graph(`${igId}/media`, { media_type: "CAROUSEL", children: children.join(","), caption, collaborators: collab }, { method: "POST" })).id;
} else if (type === "REELS" || (type === "STORY" && isVideo(media[0]))) {
const base = type === "REELS" ? { media_type: "REELS", caption, share_to_feed: shareToFeed, collaborators: collab, cover_url: coverUrl } : { media_type: "STORIES" };
creationId = isUrl(media[0])
? (await graph(`${igId}/media`, { ...base, video_url: media[0] }, { method: "POST" })).id
: await igUploadLocalVideo(igId, media[0], base);
await waitForContainer(creationId);
} else if (type === "STORY") {
creationId = (await graph(`${igId}/media`, { media_type: "STORIES", image_url: await asUrl(media[0]) }, { method: "POST" })).id;
} else {
creationId = (await graph(`${igId}/media`, { image_url: await asUrl(media[0]), caption, collaborators: collab }, { method: "POST" })).id;
}
const pub = await graph(`${igId}/media_publish`, { creation_id: creationId }, { method: "POST" });
const info = await graph(pub.id, { fields: "permalink" }).catch(() => ({}));
return { id: pub.id, permalink: info.permalink };
}
/**
* Publish (or natively schedule) on a Facebook Page.
* scheduledAt: Date — if given and 10+ minutes ahead, Facebook holds and publishes it (shows in Meta Planner).
*/
export async function fbPublishAny(account, { message = "", media = [], link, scheduledAt } = {}) {
needPage(account);
const sched = scheduledAt && scheduledAt - Date.now() > 10 * 60000
? { published: false, scheduled_publish_time: Math.floor(scheduledAt / 1000) } : {};
const tok = { token: account.page_token };
const first = media[0];
if (first && isVideo(first)) {
const fields = { description: message, ...sched };
return isUrl(first)
? graph(`${account.page_id}/videos`, { ...fields, file_url: first }, { method: "POST", ...tok })
: graphUpload(`${account.page_id}/videos`, fields, first, { ...tok, base: GRAPH_VIDEO });
}
if (media.length > 1) {
const ids = [];
for (const m of media) {
const up = isUrl(m)
? await graph(`${account.page_id}/photos`, { url: m, published: false, temporary: Boolean(sched.scheduled_publish_time) }, { method: "POST", ...tok })
: await graphUpload(`${account.page_id}/photos`, { published: false, temporary: Boolean(sched.scheduled_publish_time) }, m, tok);
ids.push({ media_fbid: up.id });
}
const f = { message, ...sched }; ids.forEach((x, i) => (f[`attached_media[${i}]`] = JSON.stringify(x)));
return graph(`${account.page_id}/feed`, f, { method: "POST", ...tok });
}
if (first) {
const fields = { caption: message, ...sched };
return isUrl(first)
? graph(`${account.page_id}/photos`, { ...fields, url: first }, { method: "POST", ...tok })
: graphUpload(`${account.page_id}/photos`, fields, first, tok);
}
return graph(`${account.page_id}/feed`, { message, link, ...sched }, { method: "POST", ...tok });
}
/** Facebook posts already scheduled natively (Meta Planner). */
export async function fbScheduledPosts(account) {
needPage(account);
const r = await graph(`${account.page_id}/scheduled_posts`, { fields: "id,message,scheduled_publish_time,created_time" }, { token: account.page_token });
return r.data || [];
}