523 lines
16 KiB
TypeScript
523 lines
16 KiB
TypeScript
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;
|
||
/** 0–1 */
|
||
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("互動率不錯:可複製高表現貼的結構再測 1~2 則。");
|
||
}
|
||
} else if (current.likes + current.replies > 0) {
|
||
analysis.push("此月有讚/回覆,但瀏覽為 0(Insights 可能尚未回傳或權限不足)。");
|
||
}
|
||
|
||
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}%`;
|
||
}
|