metatroncubeswdev 999e9fbcec Published page: every client's posts with their numbers
- Feed merges Instagram/Facebook snapshot posts (with insights) and everything Quorla published
- Filters (client, platform, 7/30/90 days), sort by interactions/reach/views, via-Quorla badge
- YouTube view/like/comment counts fetched nightly and on demand (1 quota unit per 50 videos)
- Snapshots now keep post thumbnails (IG media/thumbnail url, FB full_picture)

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

125 lines
6.1 KiB
JavaScript

// "Published" feed: every post that went out on a client's accounts, with its numbers.
// Instagram and Facebook come from the nightly snapshots (so posts made outside Quorla show too);
// other platforms come from what Quorla published, with YouTube counts fetched on refresh.
import fs from "node:fs";
import path from "node:path";
import { DATA_DIR } from "./config.js";
import * as clients from "./clients.js";
import * as metrics from "./metrics.js";
import * as sched from "../../connector/src/schedule.js";
import * as extra from "../../connector/src/platforms/index.js";
const STATS_FILE = path.join(DATA_DIR, "post-stats.json");
const readStats = () => { try { return JSON.parse(fs.readFileSync(STATS_FILE, "utf8")); } catch { return { fetchedAt: null, posts: {} }; } };
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
const sum = (...vs) => { const xs = vs.map(num).filter((x) => x !== null); return xs.length ? xs.reduce((a, b) => a + b, 0) : null; };
function igItem(client, p, viaQuorla) {
const i = p.insights || {};
const likes = num(p.like_count), comments = num(p.comments_count), saves = num(i.saved), shares = num(i.shares);
return {
key: `instagram:${p.id}`, client: client.slug, clientName: client.name, platform: "instagram",
id: p.id, at: p.timestamp, caption: p.caption || "", permalink: p.permalink || null,
format: p.media_product_type === "REELS" ? "Reel" : p.media_type === "CAROUSEL_ALBUM" ? "Carousel" : p.media_type === "VIDEO" ? "Video" : "Image",
thumb: p.thumbnail_url || p.media_url || null,
metrics: { reach: num(i.reach), views: num(i.views), likes, comments, saves, shares },
interactions: num(i.total_interactions) ?? sum(likes, comments, saves, shares),
viaQuorla,
};
}
function fbItem(client, p, viaQuorla) {
return {
key: `facebook:${p.id}`, client: client.slug, clientName: client.name, platform: "facebook",
id: p.id, at: p.created_time, caption: p.message || "", permalink: p.permalink_url || null, format: "Post",
thumb: p.picture || null,
metrics: { reach: null, views: null, likes: num(p.reactions), comments: num(p.comments), saves: null, shares: num(p.shares) },
interactions: sum(p.reactions, p.comments, p.shares),
viaQuorla,
};
}
/** All published posts, newest first. Filters: client slug, platform id, days back. */
export function feed({ client, platform, days = 30 } = {}) {
const since = Date.now() - Number(days || 30) * 86400000;
const all = clients.listClients().filter((c) => !client || c.slug === client);
const quorla = sched.listPosts({}).filter((p) => ["published", "partial"].includes(p.status));
const quorlaIds = new Set(quorla.flatMap((p) => Object.values(p.results || {}).map((r) => r?.id).filter(Boolean).map(String)));
const stats = readStats().posts;
const items = [];
for (const c of all) {
const snap = metrics.readSnapshot(c.slug);
for (const p of snap?.ig?.posts || []) items.push(igItem(c, p, quorlaIds.has(String(p.id))));
for (const p of snap?.fb?.posts || []) items.push(fbItem(c, p, quorlaIds.has(String(p.id))));
}
const seen = new Set(items.map((i) => i.key));
const byClient = Object.fromEntries(all.map((c) => [c.slug, c]));
// Platforms the snapshots don't cover, plus Quorla posts newer than the last snapshot.
for (const p of quorla) {
const c = byClient[p.client];
if (!c) continue;
for (const [pl, r] of Object.entries(p.results || {})) {
if (!r?.id || r.cancelled) continue;
const key = `${pl}:${r.id}`;
if (seen.has(key)) continue;
const s = stats[key] || {};
items.push({
key, client: c.slug, clientName: c.name, platform: pl, id: String(r.id),
at: p.publishedAt || p.publishAt, caption: p.options?.[pl]?.caption ?? p.caption ?? "", permalink: r.permalink || null,
format: p.type === "REELS" ? "Video" : p.type === "CAROUSEL" ? "Carousel" : p.type === "TEXT" ? "Text" : "Image",
thumb: s.thumb || null, media: p.media?.[0] || null,
metrics: { reach: null, views: num(s.views), likes: num(s.likes), comments: num(s.comments), saves: null, shares: null },
interactions: sum(s.likes, s.comments),
viaQuorla: true, note: r.inbox ? "Sent to the TikTok app to finish" : null,
});
seen.add(key);
}
}
const list = items
.filter((i) => (!platform || i.platform === platform) && i.at && new Date(i.at).getTime() >= since)
.sort((a, b) => new Date(b.at) - new Date(a.at));
const reachKnown = list.filter((i) => i.metrics.reach !== null);
return {
items: list,
totals: {
posts: list.length,
viaQuorla: list.filter((i) => i.viaQuorla).length,
reach: reachKnown.reduce((s, i) => s + i.metrics.reach, 0),
interactions: list.reduce((s, i) => s + (i.interactions || 0), 0),
avgInteractions: list.length ? Math.round(list.reduce((s, i) => s + (i.interactions || 0), 0) / list.length) : 0,
},
snapshotsAt: Object.fromEntries(all.map((c) => [c.slug, metrics.readSnapshot(c.slug)?.fetchedAt || null])),
statsAt: readStats().fetchedAt,
};
}
/** Fetch counts for Quorla-published posts on platforms with a stats reader (YouTube), last 90 days. */
export async function refreshStats() {
const since = Date.now() - 90 * 86400000;
const want = {};
for (const p of sched.listPosts({})) {
if (!["published", "partial"].includes(p.status) || new Date(p.publishedAt || p.publishAt).getTime() < since) continue;
for (const [pl, r] of Object.entries(p.results || {})) {
if (pl !== "youtube" || !r?.id) continue;
((want[p.client] ||= {})[pl] ||= []).push(String(r.id));
}
}
const out = readStats();
const errors = [];
for (const [client, byPl] of Object.entries(want)) {
for (const [pl, ids] of Object.entries(byPl)) {
try {
const s = await extra.postStats(pl, client, ids);
for (const [id, v] of Object.entries(s)) out.posts[`${pl}:${id}`] = v;
} catch (e) { errors.push(`${client} ${pl}: ${e.message}`); }
}
}
out.fetchedAt = new Date().toISOString();
fs.writeFileSync(STATS_FILE, JSON.stringify(out, null, 1));
return { fetchedAt: out.fetchedAt, errors };
}