thread-master/apps/web/src/lib/accountInsights.ts

523 lines
16 KiB
TypeScript
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.

import type { OwnPost } from "../domain/types";
import { newId } from "./id";
import { readJson, writeJson } from "./storage";
import { nowUnixNano } from "./time";
import { KEYS } from "../data/mock/keys";
export type MonthBucket = {
/** YYYY-MM */
key: string;
/** 顯示2026/4 */
label: string;
posts: number;
views: number;
likes: number;
replies: number;
reposts: number;
quotes: number;
shares: number;
/** 01 */
engagementRate: number;
/** 真實貼文聚合 or 補齊 mock 歷史 */
source: "posts" | "estimate";
};
export type AccountInsightsReport = {
accountId: string;
months: MonthBucket[];
current: MonthBucket;
previous: MonthBucket | null;
/** 相對上月變化 %null = 無法比) */
delta: {
views: number | null;
likes: number | null;
replies: number | null;
posts: number | null;
engagementRate: number | null;
};
topPosts: OwnPost[];
/** 分析結論 */
analysis: string[];
/** 可執行建議 */
recommendations: string[];
totalPosts: number;
/** 互動率平均(有 views 的貼) */
avgEngagementRate: number;
};
/** 單月分析封存:可回看每個月的結論與建議 */
export type AccountInsightsSnapshot = {
id: string;
account_id: string;
/** YYYY-MM */
month_key: string;
month_label: string;
/** 產生/覆寫分析的時間 unix ns */
analyzed_at: number;
metrics: {
posts: number;
views: number;
likes: number;
replies: number;
engagementRate: number;
source: MonthBucket["source"];
};
delta: {
views: number | null;
likes: number | null;
replies: number | null;
posts: number | null;
engagementRate: number | null;
};
analysis: string[];
recommendations: string[];
/** 當月 Top 貼摘要(封存文字,不依貼文刪除而消失) */
top_highlights: string[];
};
export function monthKeyFromDate(d: Date = new Date()): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
}
export function monthLabel(key: string): string {
const [y, m] = key.split("-");
return `${y}/${Number(m)}`;
}
function loadAllSnapshots(): AccountInsightsSnapshot[] {
return readJson(KEYS.accountInsightHistory, [] as AccountInsightsSnapshot[]);
}
function saveAllSnapshots(list: AccountInsightsSnapshot[]): void {
writeJson(KEYS.accountInsightHistory, list.slice(0, 240));
}
export function listInsightSnapshots(accountId: string): AccountInsightsSnapshot[] {
return loadAllSnapshots()
.filter((s) => s.account_id === accountId)
.slice()
.sort((a, b) => (a.month_key < b.month_key ? 1 : a.month_key > b.month_key ? -1 : 0));
}
export function getInsightSnapshot(
accountId: string,
monthKey: string,
): AccountInsightsSnapshot | null {
return (
loadAllSnapshots().find((s) => s.account_id === accountId && s.month_key === monthKey) || null
);
}
/** 寫入/覆寫某帳某月分析 */
export function saveInsightSnapshot(snap: AccountInsightsSnapshot): AccountInsightsSnapshot {
const list = loadAllSnapshots().filter(
(s) => !(s.account_id === snap.account_id && s.month_key === snap.month_key),
);
list.unshift(snap);
saveAllSnapshots(list);
return snap;
}
/** 刪除某帳某月分析(空月不該留假結論) */
export function removeInsightSnapshot(accountId: string, monthKey: string): void {
const list = loadAllSnapshots().filter(
(s) => !(s.account_id === accountId && s.month_key === monthKey),
);
saveAllSnapshots(list);
}
/** 該月是否有足夠真實數據可寫結論(至少 1 則貼文) */
export function monthHasRealData(month: Pick<MonthBucket, "posts">): boolean {
return (month.posts || 0) > 0;
}
function snapshotFromParts(opts: {
accountId: string;
month: MonthBucket;
previous: MonthBucket | null;
analysis: string[];
recommendations: string[];
topHighlights: string[];
forceNewId?: boolean;
existingId?: string;
}): AccountInsightsSnapshot {
const delta = {
views: opts.previous ? pctChange(opts.month.views, opts.previous.views) : null,
likes: opts.previous ? pctChange(opts.month.likes, opts.previous.likes) : null,
replies: opts.previous ? pctChange(opts.month.replies, opts.previous.replies) : null,
posts: opts.previous ? pctChange(opts.month.posts, opts.previous.posts) : null,
engagementRate: opts.previous
? pctChange(opts.month.engagementRate * 100, opts.previous.engagementRate * 100)
: null,
};
return {
id: opts.existingId || newId("ain"),
account_id: opts.accountId,
month_key: opts.month.key,
month_label: opts.month.label,
analyzed_at: nowUnixNano(),
metrics: {
posts: opts.month.posts,
views: opts.month.views,
likes: opts.month.likes,
replies: opts.month.replies,
engagementRate: opts.month.engagementRate,
source: opts.month.source,
},
delta,
analysis: opts.analysis,
recommendations: opts.recommendations,
top_highlights: opts.topHighlights,
};
}
/**
* 依目前 report 維護分析歷史。
* - 該月 posts=0不寫結論並清掉舊的假 snapshot
* - 有貼文才建/更新分析(本月可 force 重算)
*/
export function ensureInsightHistory(
report: AccountInsightsReport,
opts?: { forceCurrent?: boolean },
): AccountInsightsSnapshot[] {
const months = report.months;
const monthKeys = new Set(months.map((m) => m.key));
// 清掉已不在視窗外、或空月卻殘留假結論的紀錄
for (const snap of listInsightSnapshots(report.accountId)) {
const bucket = months.find((m) => m.key === snap.month_key);
if (!monthKeys.has(snap.month_key)) continue;
if (bucket && !monthHasRealData(bucket)) {
removeInsightSnapshot(report.accountId, snap.month_key);
}
// 舊假資料metrics.posts=0 卻還有分析句
if ((snap.metrics?.posts || 0) <= 0 && (snap.analysis?.length || 0) > 0) {
removeInsightSnapshot(report.accountId, snap.month_key);
}
}
for (let i = 0; i < months.length; i++) {
const month = months[i]!;
// 沒有真實貼文 → 不亂下結論
if (!monthHasRealData(month)) {
removeInsightSnapshot(report.accountId, month.key);
continue;
}
// 月比只在「前月也有真實貼文」時才有意義
const rawPrev = i > 0 ? months[i - 1]! : null;
const previous = rawPrev && monthHasRealData(rawPrev) ? rawPrev : null;
const existing = getInsightSnapshot(report.accountId, month.key);
const isCurrent = i === months.length - 1;
if (existing && !(isCurrent && opts?.forceCurrent)) continue;
const monthTop = topPostsForMonth(
// report.topPosts 是全帳本月 top歷史月用 metrics 敘事 + 空 highlights 即可
isCurrent ? report.topPosts : [],
month.key,
3,
);
// 若是本月topPosts 已是本月;仍再 filter 保險
const highlights = (isCurrent ? report.topPosts : monthTop).slice(0, 3).map((p) => {
const head = (p.insight || p.formula_summary || p.text).slice(0, 80);
return `${p.topic_tag ? `${p.topic_tag}` : ""}${head}${head.length >= 80 ? "…" : ""}`;
});
const narrative = buildNarrative({
current: month,
previous,
delta: {
views: previous ? pctChange(month.views, previous.views) : null,
likes: previous ? pctChange(month.likes, previous.likes) : null,
replies: previous ? pctChange(month.replies, previous.replies) : null,
posts: previous ? pctChange(month.posts, previous.posts) : null,
engagementRate: previous
? pctChange(month.engagementRate * 100, previous.engagementRate * 100)
: null,
},
topPosts: isCurrent ? report.topPosts : [],
avgEngagementRate: isCurrent ? report.avgEngagementRate : month.engagementRate,
totalPosts: month.posts,
monthLabel: month.label,
});
// 仍無任何可說的結論 → 不存 snapshot
if (narrative.analysis.length === 0 && narrative.recommendations.length === 0) {
removeInsightSnapshot(report.accountId, month.key);
continue;
}
saveInsightSnapshot(
snapshotFromParts({
accountId: report.accountId,
month,
previous,
analysis: narrative.analysis,
recommendations: narrative.recommendations,
topHighlights: highlights,
existingId: existing?.id,
}),
);
}
return listInsightSnapshots(report.accountId);
}
function engagementOf(p: OwnPost): number {
const views = p.view_count || 0;
if (views <= 0) return 0;
const eng =
(p.like_count || 0) +
(p.reply_count || 0) +
(p.repost_count || 0) +
(p.quote_count || 0) +
(p.share_count || 0);
return eng / views;
}
/** published_at unix ns → YYYY-MM */
export function monthKeyOfPost(p: OwnPost): string {
const ms = p.published_at > 0 ? Math.floor(p.published_at / 1_000_000) : Date.now();
return monthKeyFromDate(new Date(ms));
}
export function filterPostsByMonth(posts: OwnPost[], monthKey: string): OwnPost[] {
return posts.filter((p) => monthKeyOfPost(p) === monthKey);
}
export function scoreOwnPost(p: OwnPost): number {
return (
(p.view_count || 0) +
(p.like_count || 0) * 8 +
(p.reply_count || 0) * 20 +
(p.repost_count || 0) * 15 +
(p.quote_count || 0) * 12
);
}
/** 指定月份表現較佳貼文 */
export function topPostsForMonth(posts: OwnPost[], monthKey: string, limit = 5): OwnPost[] {
return filterPostsByMonth(posts, monthKey)
.slice()
.sort((a, b) => scoreOwnPost(b) - scoreOwnPost(a))
.slice(0, limit);
}
function emptyBucket(key: string, source: MonthBucket["source"] = "estimate"): MonthBucket {
return {
key,
label: monthLabel(key),
posts: 0,
views: 0,
likes: 0,
replies: 0,
reposts: 0,
quotes: 0,
shares: 0,
engagementRate: 0,
source,
};
}
function finalizeBucket(b: MonthBucket): MonthBucket {
const eng =
b.likes + b.replies + b.reposts + b.quotes + b.shares;
return {
...b,
engagementRate: b.views > 0 ? eng / b.views : 0,
};
}
function lastNMonthKeys(n: number, from = new Date()): string[] {
const keys: string[] = [];
const d = new Date(from.getFullYear(), from.getMonth(), 1);
for (let i = n - 1; i >= 0; i--) {
const x = new Date(d.getFullYear(), d.getMonth() - i, 1);
keys.push(monthKeyFromDate(x));
}
return keys;
}
function pctChange(curr: number, prev: number): number | null {
if (prev <= 0 && curr <= 0) return 0;
if (prev <= 0) return curr > 0 ? 100 : null;
return Math.round(((curr - prev) / prev) * 1000) / 10;
}
/**
* 由已同步貼文聚合近 N 月成效(只算真實數據;無貼文的月份為 0不造假估測
*/
export function buildAccountInsights(
accountId: string,
posts: OwnPost[],
monthCount = 6,
): AccountInsightsReport {
const mine = posts.filter((p) => p.account_id === accountId);
const keys = lastNMonthKeys(monthCount);
const byMonth = new Map<string, MonthBucket>();
for (const k of keys) byMonth.set(k, emptyBucket(k, "posts"));
for (const p of mine) {
// published_at = unix ns異常 0 則歸入本月,避免整批落在窗外
const ms =
p.published_at > 0 ? Math.floor(p.published_at / 1_000_000) : Date.now();
const key = monthKeyFromDate(new Date(ms));
if (!byMonth.has(key)) continue;
const b = byMonth.get(key)!;
b.posts += 1;
b.views += p.view_count || 0;
b.likes += p.like_count || 0;
b.replies += p.reply_count || 0;
b.reposts += p.repost_count || 0;
b.quotes += p.quote_count || 0;
b.shares += p.share_count || 0;
b.source = "posts";
}
for (const k of keys) {
byMonth.set(k, finalizeBucket(byMonth.get(k)!));
}
const months = keys.map((k) => byMonth.get(k)!);
const current = months[months.length - 1]!;
// 前月沒貼文就不做月比(避免 0→有數 顯示 +100% 假結論)
const rawPrev = months.length >= 2 ? months[months.length - 2]! : null;
const previous = rawPrev && monthHasRealData(rawPrev) ? rawPrev : null;
const delta = {
views: previous && monthHasRealData(current) ? pctChange(current.views, previous.views) : null,
likes: previous && monthHasRealData(current) ? pctChange(current.likes, previous.likes) : null,
replies:
previous && monthHasRealData(current) ? pctChange(current.replies, previous.replies) : null,
posts: previous && monthHasRealData(current) ? pctChange(current.posts, previous.posts) : null,
engagementRate:
previous && monthHasRealData(current)
? pctChange(current.engagementRate * 100, previous.engagementRate * 100)
: null,
};
const topPosts = topPostsForMonth(mine, current.key, 5);
const withViews = mine.filter((p) => (p.view_count || 0) > 0);
const avgEngagementRate = withViews.length
? withViews.reduce((s, p) => s + engagementOf(p), 0) / withViews.length
: current.engagementRate;
const { analysis, recommendations } = buildNarrative({
current,
previous,
delta,
topPosts,
avgEngagementRate,
totalPosts: mine.length,
monthLabel: current.label,
});
return {
accountId,
months,
current,
previous,
delta,
topPosts,
analysis,
recommendations,
totalPosts: mine.length,
avgEngagementRate,
};
}
/**
* 只根據該月真實指標寫結論;沒貼文/沒可比數字就不硬寫。
*/
function buildNarrative(opts: {
current: MonthBucket;
previous: MonthBucket | null;
delta: AccountInsightsReport["delta"];
topPosts: OwnPost[];
avgEngagementRate: number;
totalPosts: number;
monthLabel?: string;
}): { analysis: string[]; recommendations: string[] } {
const { current, previous, delta, topPosts, avgEngagementRate } = opts;
const when = opts.monthLabel || current.label || "本月";
const analysis: string[] = [];
const recommendations: string[] = [];
// 空月:完全不說話
if (!monthHasRealData(current)) {
return { analysis: [], recommendations: [] };
}
analysis.push(
`${when}彙總:貼文 ${current.posts}、瀏覽 ${current.views.toLocaleString("zh-TW")}、讚 ${current.likes}、回覆 ${current.replies}(來自已同步貼文)。`,
);
// 僅在前月也有貼文時才比
if (previous && monthHasRealData(previous) && delta.views != null) {
if (delta.views > 8) {
analysis.push(
`瀏覽較前月 ${fmtDelta(delta.views)}${previous.views.toLocaleString("zh-TW")}${current.views.toLocaleString("zh-TW")})。`,
);
} else if (delta.views < -8) {
analysis.push(
`瀏覽較前月 ${fmtDelta(delta.views)}${previous.views.toLocaleString("zh-TW")}${current.views.toLocaleString("zh-TW")})。`,
);
} else {
analysis.push(`瀏覽與前月大致持平(${fmtDelta(delta.views)})。`);
}
}
if (previous && monthHasRealData(previous) && delta.replies != null) {
if (delta.replies > 10) {
analysis.push(`回覆數 ${fmtDelta(delta.replies)},對話熱度上升。`);
} else if (delta.replies < -10) {
analysis.push(`回覆數 ${fmtDelta(delta.replies)}`);
}
}
// 有瀏覽才談互動率,避免 0 瀏覽硬算 0% 再亂評
if (current.views > 0) {
const engPct = Math.round(avgEngagementRate * 1000) / 10;
analysis.push(
`互動率約 ${engPct}%(讚+回+轉+引用+分享/瀏覽)。`,
);
if (engPct < 2) {
recommendations.push("互動率偏低:可多試帶明確條件的提問收尾。");
} else if (engPct >= 4) {
recommendations.push("互動率不錯:可複製高表現貼的結構再測 12 則。");
}
} else if (current.likes + current.replies > 0) {
analysis.push("此月有讚/回覆,但瀏覽為 0Insights 可能尚未回傳或權限不足)。");
}
const top = topPosts[0];
if (top) {
const snippet = (top.insight || top.formula_summary || top.text).slice(0, 72);
analysis.push(
`表現較佳之一:${snippet}${snippet.length >= 72 ? "…" : ""}`,
);
}
if (current.posts < 3) {
recommendations.push("該月貼文偏少,樣本小,月比僅供參考。");
}
return {
analysis: analysis.slice(0, 5),
recommendations: recommendations.slice(0, 3),
};
}
export function fmtDelta(n: number | null | undefined): string {
if (n == null || Number.isNaN(n)) return "—";
if (n > 0) return `+${n}%`;
if (n < 0) return `${n}%`;
return "0%";
}
export function fmtEngRate(rate: number): string {
return `${Math.round(rate * 1000) / 10}%`;
}