// "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 }; }