metatroncubeswdev 9e263cd1f8 Best time to post, learned from each client's own Instagram reach
- Last 120 days of posts, reach vs the client's median, by weekday and 3-hour window (client time zone)
- Needs 8+ posts and 2+ per slot; shown on Published (client filter) and as the New post time hint
- Falls back to general market times when there isn't enough data

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

155 lines
8.2 KiB
JavaScript
Raw Permalink 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.

// "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 };
}
const WINDOWS = [[6, 9], [9, 12], [12, 15], [15, 18], [18, 21], [21, 24]];
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const median = (xs) => { const s = [...xs].sort((a, b) => a - b); const m = s.length >> 1; return s.length ? (s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2) : null; };
/**
* When this client's Instagram posts do best, from their own last 120 days: each post's reach divided by the
* client's median reach, grouped by weekday and 3-hour window (client's local time). Needs 8+ posts with reach.
*/
export function bestTimes(slug, timezone) {
const posts = feed({ client: slug, platform: "instagram", days: 120 }).items.filter((i) => i.metrics.reach != null && i.metrics.reach > 0);
if (posts.length < 8) return { basedOn: posts.length, slots: [], times: [] };
const base = median(posts.map((p) => p.metrics.reach));
const fmt = new Intl.DateTimeFormat("en-CA", { timeZone: timezone, weekday: "short", hour: "2-digit", minute: "2-digit", hour12: false });
const groups = {};
for (const p of posts) {
const parts = Object.fromEntries(fmt.formatToParts(new Date(p.at)).map((x) => [x.type, x.value]));
const hour = Number(parts.hour) % 24, minute = Number(parts.minute);
const w = WINDOWS.findIndex(([a, b]) => hour >= a && hour < b);
if (w < 0) continue;
const day = WEEKDAYS.indexOf(parts.weekday.slice(0, 3));
(groups[`${day}-${w}`] ||= { day, w, ratios: [], minutes: [] }).ratios.push(p.metrics.reach / base);
groups[`${day}-${w}`].minutes.push(hour * 60 + minute);
}
const hhmm = (m) => `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(Math.round(m % 60 / 15) * 15 % 60).padStart(2, "0")}`;
const slots = Object.values(groups).filter((g) => g.ratios.length >= 2)
.map((g) => ({ day: WEEKDAYS[g.day], window: `${String(WINDOWS[g.w][0]).padStart(2, "0")}:00–${String(WINDOWS[g.w][1] % 24).padStart(2, "0")}:00`, time: hhmm(median(g.minutes)), posts: g.ratios.length, lift: Math.round((median(g.ratios) - 1) * 100) }))
.filter((s) => s.lift > 0).sort((a, b) => b.lift - a.lift).slice(0, 3);
return { basedOn: posts.length, medianReach: base, slots, times: [...new Set(slots.map((s) => s.time))] };
}