- 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>
125 lines
6.1 KiB
JavaScript
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 };
|
|
}
|