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): 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(); 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}%`; }