508 lines
16 KiB
TypeScript
508 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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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 確保近 N 月都有歷史分析紀錄(缺則補;本月可 force 重算)。
|
|||
|
|
*/
|
|||
|
|
export function ensureInsightHistory(
|
|||
|
|
report: AccountInsightsReport,
|
|||
|
|
opts?: { forceCurrent?: boolean },
|
|||
|
|
): AccountInsightsSnapshot[] {
|
|||
|
|
const months = report.months;
|
|||
|
|
const topHighlights = report.topPosts.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 ? "…" : ""}`;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
for (let i = 0; i < months.length; i++) {
|
|||
|
|
const month = months[i]!;
|
|||
|
|
const previous = i > 0 ? months[i - 1]! : null;
|
|||
|
|
const existing = getInsightSnapshot(report.accountId, month.key);
|
|||
|
|
const isCurrent = i === months.length - 1;
|
|||
|
|
if (existing && !(isCurrent && opts?.forceCurrent)) continue;
|
|||
|
|
|
|||
|
|
const 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,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 本月:優先用 report 全文;force 或歷史月:依該月指標重算敘事
|
|||
|
|
const narrative =
|
|||
|
|
isCurrent && !opts?.forceCurrent && report.analysis.length > 0
|
|||
|
|
? { analysis: report.analysis, recommendations: report.recommendations }
|
|||
|
|
: buildNarrative({
|
|||
|
|
current: month,
|
|||
|
|
previous,
|
|||
|
|
delta,
|
|||
|
|
topPosts: isCurrent ? report.topPosts : [],
|
|||
|
|
avgEngagementRate: isCurrent ? report.avgEngagementRate : month.engagementRate,
|
|||
|
|
totalPosts: isCurrent ? report.totalPosts : month.posts,
|
|||
|
|
monthLabel: month.label,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
saveInsightSnapshot(
|
|||
|
|
snapshotFromParts({
|
|||
|
|
accountId: report.accountId,
|
|||
|
|
month,
|
|||
|
|
previous,
|
|||
|
|
analysis: narrative.analysis,
|
|||
|
|
recommendations: narrative.recommendations,
|
|||
|
|
topHighlights: isCurrent
|
|||
|
|
? topHighlights
|
|||
|
|
: existing?.top_highlights?.length
|
|||
|
|
? existing.top_highlights
|
|||
|
|
: [
|
|||
|
|
`${month.label} 互動率 ${Math.round(month.engagementRate * 1000) / 10}%`,
|
|||
|
|
`瀏覽 ${month.views.toLocaleString("zh-TW")} · 回覆 ${month.replies}`,
|
|||
|
|
],
|
|||
|
|
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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function hashSeed(s: string): number {
|
|||
|
|
let h = 0;
|
|||
|
|
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
|
|||
|
|
return Math.abs(h);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 由貼文聚合近 N 月成效;缺月以均值微擾補齊(mock 歷史,方便畫月比圖)。
|
|||
|
|
* live 時改為讀 snapshot 表即可。
|
|||
|
|
*/
|
|||
|
|
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) {
|
|||
|
|
const d = new Date(Math.floor(p.published_at / 1_000_000));
|
|||
|
|
const key = monthKeyFromDate(d);
|
|||
|
|
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";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 有真實數據的月份均值,用來補齊空月
|
|||
|
|
const real = keys
|
|||
|
|
.map((k) => byMonth.get(k)!)
|
|||
|
|
.filter((b) => b.posts > 0)
|
|||
|
|
.map(finalizeBucket);
|
|||
|
|
const avgViews = real.length
|
|||
|
|
? real.reduce((s, b) => s + b.views, 0) / real.length
|
|||
|
|
: 800;
|
|||
|
|
const avgLikes = real.length
|
|||
|
|
? real.reduce((s, b) => s + b.likes, 0) / real.length
|
|||
|
|
: 30;
|
|||
|
|
const avgReplies = real.length
|
|||
|
|
? real.reduce((s, b) => s + b.replies, 0) / real.length
|
|||
|
|
: 3;
|
|||
|
|
const avgPosts = real.length
|
|||
|
|
? Math.max(1, Math.round(real.reduce((s, b) => s + b.posts, 0) / real.length))
|
|||
|
|
: 2;
|
|||
|
|
|
|||
|
|
for (let i = 0; i < keys.length; i++) {
|
|||
|
|
const k = keys[i]!;
|
|||
|
|
const b = byMonth.get(k)!;
|
|||
|
|
if (b.posts > 0) {
|
|||
|
|
byMonth.set(k, finalizeBucket(b));
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
// 越舊略低、加帳號雜訊(穩定偽隨機)
|
|||
|
|
const age = keys.length - 1 - i;
|
|||
|
|
const noise = 0.72 + (hashSeed(`${accountId}|${k}`) % 40) / 100;
|
|||
|
|
const factor = noise * (1 - age * 0.06);
|
|||
|
|
const est = emptyBucket(k, "estimate");
|
|||
|
|
est.posts = Math.max(1, Math.round(avgPosts * factor));
|
|||
|
|
est.views = Math.round(avgViews * factor);
|
|||
|
|
est.likes = Math.round(avgLikes * factor);
|
|||
|
|
est.replies = Math.max(0, Math.round(avgReplies * factor));
|
|||
|
|
est.reposts = Math.round(est.likes * 0.12);
|
|||
|
|
est.quotes = Math.round(est.likes * 0.08);
|
|||
|
|
est.shares = Math.round(est.likes * 0.05);
|
|||
|
|
byMonth.set(k, finalizeBucket(est));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const months = keys.map((k) => byMonth.get(k)!);
|
|||
|
|
const current = months[months.length - 1]!;
|
|||
|
|
const previous = months.length >= 2 ? months[months.length - 2]! : null;
|
|||
|
|
|
|||
|
|
const delta = {
|
|||
|
|
views: previous ? pctChange(current.views, previous.views) : null,
|
|||
|
|
likes: previous ? pctChange(current.likes, previous.likes) : null,
|
|||
|
|
replies: previous ? pctChange(current.replies, previous.replies) : null,
|
|||
|
|
posts: previous ? pctChange(current.posts, previous.posts) : null,
|
|||
|
|
engagementRate: previous
|
|||
|
|
? pctChange(current.engagementRate * 100, previous.engagementRate * 100)
|
|||
|
|
: null,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const topPosts = mine
|
|||
|
|
.slice()
|
|||
|
|
.sort((a, b) => {
|
|||
|
|
const score = (p: OwnPost) =>
|
|||
|
|
(p.view_count || 0) +
|
|||
|
|
(p.like_count || 0) * 8 +
|
|||
|
|
(p.reply_count || 0) * 20 +
|
|||
|
|
(p.repost_count || 0) * 15 +
|
|||
|
|
(p.quote_count || 0) * 12;
|
|||
|
|
return score(b) - score(a);
|
|||
|
|
})
|
|||
|
|
.slice(0, 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, totalPosts } = opts;
|
|||
|
|
const when = opts.monthLabel || current.label || "本月";
|
|||
|
|
const analysis: string[] = [];
|
|||
|
|
const recommendations: string[] = [];
|
|||
|
|
|
|||
|
|
if (totalPosts === 0 && current.posts === 0) {
|
|||
|
|
return {
|
|||
|
|
analysis: [`${when}:尚無足夠貼文數據,無法比較月成效。`],
|
|||
|
|
recommendations: ["先同步我的貼文,或該月至少發 1~2 則再分析。"],
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
analysis.push(
|
|||
|
|
`${when}彙總:貼文 ${current.posts}、瀏覽 ${current.views.toLocaleString("zh-TW")}、讚 ${current.likes}、回覆 ${current.replies}(資料${current.source === "posts" ? "來自同步貼文" : "為歷史估測"})。`,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
if (delta.views != null && previous) {
|
|||
|
|
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)},曝光偏弱,宜檢查發文節奏與開場鉤子。`,
|
|||
|
|
);
|
|||
|
|
} else {
|
|||
|
|
analysis.push(`瀏覽與前月大致持平(${fmtDelta(delta.views)})。`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (delta.replies != null) {
|
|||
|
|
if (delta.replies > 10) {
|
|||
|
|
analysis.push(`回覆數 ${fmtDelta(delta.replies)},對話熱度上升,適合延續可接話題材。`);
|
|||
|
|
} else if (delta.replies < -10) {
|
|||
|
|
analysis.push(`回覆數 ${fmtDelta(delta.replies)},可多試「求經驗/明確條件」開問。`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const engPct = Math.round(avgEngagementRate * 1000) / 10;
|
|||
|
|
analysis.push(
|
|||
|
|
`互動率約 ${engPct}%(讚+回+轉+引用+分享/瀏覽)。Threads 上 ${engPct >= 4 ? "屬不錯" : engPct >= 2 ? "中等,還有拉高空間" : "偏低,優先優化鉤子與問句"}。`,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const top = topPosts[0];
|
|||
|
|
if (top) {
|
|||
|
|
const tag = top.topic_tag ? `「${top.topic_tag}」` : "高互動";
|
|||
|
|
analysis.push(
|
|||
|
|
`表現最佳貼偏 ${tag}:${(top.insight || top.formula_summary || top.text).slice(0, 72)}${(top.insight || top.formula_summary || top.text).length > 72 ? "…" : ""}`,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const questionPosts = topPosts.filter((p) => /[??]/.test(p.text) || /求|有人|嗎/.test(p.text));
|
|||
|
|
if (questionPosts.length >= 1 || (current.replies > 0 && current.posts > 0)) {
|
|||
|
|
recommendations.push("維持條件明確的提問貼(插座/無香/預算),回覆與轉發通常較穩。");
|
|||
|
|
} else {
|
|||
|
|
recommendations.push("下一期至少 1 則帶具體條件的提問,避免純宣告。");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if ((delta.views ?? 0) < 0 || engPct < 2.5) {
|
|||
|
|
recommendations.push("搭配探查清待回:活躍回覆能補帳號存在感,間接撐後續曝光。");
|
|||
|
|
} else {
|
|||
|
|
recommendations.push("把高互動貼的結構(鉤子+問句)複製到下 2 則新串,測能否再現。");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (current.posts < 3) {
|
|||
|
|
recommendations.push("該月發文偏少,可固定每週 2~3 則,曲線與月比才比較得準。");
|
|||
|
|
} else {
|
|||
|
|
recommendations.push("下月回看本頁歷史:對照瀏覽與回覆是否同向,避免只追讚數。");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
analysis: analysis.slice(0, 5),
|
|||
|
|
recommendations: recommendations.slice(0, 4),
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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}%`;
|
|||
|
|
}
|