feat: implement stats history, growth tracking, and mermaid trend charts

This commit is contained in:
fujie
2026-02-11 11:41:12 +08:00
parent 0b9c242c0f
commit a63d3e89ff
7 changed files with 378 additions and 129 deletions

View File

@@ -65,6 +65,129 @@ class OpenWebUIStats:
"Content-Type": "application/json",
}
)
self.history_file = Path("docs/stats-history.json")
# 定义下载类别的判定(这些类别会计入总浏览量/下载量统计)
DOWNLOADABLE_TYPES = [
"action",
"filter",
"pipe",
"toolkit",
"function",
"prompt",
"model",
]
def load_history(self) -> list:
"""从文件加载历史记录"""
if self.history_file.exists():
try:
with open(self.history_file, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
print(f"⚠️ 无法加载历史记录: {e}")
return []
def save_history(self, stats: dict):
"""保存当前快照到历史记录"""
history = self.load_history()
today = get_beijing_time().strftime("%Y-%m-%d")
# 构造快照
snapshot = {
"date": today,
"total_posts": stats["total_posts"],
"total_downloads": stats["total_downloads"],
"total_views": stats["total_views"],
"total_upvotes": stats["total_upvotes"],
"followers": stats.get("user", {}).get("followers", 0),
"points": stats.get("user", {}).get("total_points", 0),
}
# 如果今天已存在,则更新;否则追加
for i, item in enumerate(history):
if item.get("date") == today:
history[i] = snapshot
break
else:
history.append(snapshot)
# 只保留最近 90 天的历史
history = history[-90:]
with open(self.history_file, "w", encoding="utf-8") as f:
json.dump(history, f, ensure_ascii=False, indent=2)
print(f"✅ 历史快照已更新 ({today})")
def get_stat_delta(self, stats: dict) -> dict:
"""计算相对于上次记录的增长"""
history = self.load_history()
if len(history) < 2:
return {}
# 获取上一次的快照(倒数第二个,因为当前可能已经存入倒数第一个)
# 或者如果还没存入,就是倒数第一个
today = get_beijing_time().strftime("%Y-%m-%d")
prev = None
for item in reversed(history):
if item.get("date") != today:
prev = item
break
if not prev:
return {}
return {
"downloads": stats["total_downloads"] - prev.get("total_downloads", 0),
"views": stats["total_views"] - prev.get("total_views", 0),
"upvotes": stats["total_upvotes"] - prev.get("total_upvotes", 0),
"followers": stats.get("user", {}).get("followers", 0)
- prev.get("followers", 0),
"points": stats.get("user", {}).get("total_points", 0)
- prev.get("points", 0),
}
def _resolve_post_type(self, post: dict) -> str:
"""解析帖子类别"""
top_type = post.get("type")
function_data = post.get("data", {}) or {}
function_obj = function_data.get("function", {}) or {}
meta = function_obj.get("meta", {}) or {}
manifest = meta.get("manifest", {}) or {}
# 类别识别优先级:
if top_type == "review":
return "review"
post_type = "unknown"
if meta.get("type"):
post_type = meta.get("type")
elif function_obj.get("type"):
post_type = function_obj.get("type")
elif top_type:
post_type = top_type
elif not meta and not function_obj:
post_type = "post"
# 统一和启发式识别逻辑
if post_type == "unknown" and function_obj:
post_type = "action"
if post_type == "action" or post_type == "unknown":
all_metadata = (
post.get("title", "")
+ json.dumps(meta, ensure_ascii=False)
+ json.dumps(manifest, ensure_ascii=False)
).lower()
if "filter" in all_metadata:
post_type = "filter"
elif "pipe" in all_metadata:
post_type = "pipe"
elif "toolkit" in all_metadata:
post_type = "toolkit"
return post_type
def _parse_user_id_from_token(self, token: str) -> str:
"""从 JWT Token 中解析用户 ID"""
@@ -84,6 +207,30 @@ class OpenWebUIStats:
print(f"⚠️ 无法从 Token 解析用户 ID: {e}")
return ""
def generate_mermaid_chart(self) -> str:
"""生成 Mermaid 增长趋势图"""
history = self.load_history()
if len(history) < 3: # 数据太少不显示图表
return ""
# 只取最近 14 天的数据用于展示
data = history[-14:]
dates = [item["date"][-5:] for item in data] # 只取 MM-DD
downloads = [str(item["total_downloads"]) for item in data]
mm = []
mm.append("### 📈 增长趋势 (14天)")
mm.append("")
mm.append("```mermaid")
mm.append("xychart-beta")
mm.append(f' title "Downloads Trend"')
mm.append(f" x-axis [{', '.join(f'\"{d}\"' for d in dates)}]")
mm.append(f' y-axis "Downloads"')
mm.append(f" line [{', '.join(downloads)}]")
mm.append("```")
mm.append("")
return "\n".join(mm)
def get_user_posts(self, sort: str = "new", page: int = 1) -> list:
"""
获取用户发布的帖子列表
@@ -148,22 +295,26 @@ class OpenWebUIStats:
}
for post in posts:
post_type = self._resolve_post_type(post)
function_data = post.get("data", {}) or {}
function_obj = function_data.get("function", {}) or {}
meta = function_obj.get("meta", {}) or {}
manifest = meta.get("manifest", {}) or {}
# 累计统计
stats["total_downloads"] += post.get("downloads", 0)
stats["total_views"] += post.get("views", 0)
post_downloads = post.get("downloads", 0)
post_views = post.get("views", 0)
stats["total_downloads"] += post_downloads
stats["total_upvotes"] += post.get("upvotes", 0)
stats["total_downvotes"] += post.get("downvotes", 0)
stats["total_saves"] += post.get("saveCount", 0)
stats["total_comments"] += post.get("commentCount", 0)
# 解析 data 字段 - 正确路径: data.function.meta
function_data = post.get("data", {})
if function_data is None:
function_data = {}
function_data = function_data.get("function", {})
meta = function_data.get("meta", {})
manifest = meta.get("manifest", {})
post_type = meta.get("type", function_data.get("type", "unknown"))
# 关键:总浏览量不包括不可以下载的类型 (如 post, review)
if post_type in self.DOWNLOADABLE_TYPES or post_downloads > 0:
stats["total_views"] += post_views
if post_type not in stats["by_type"]:
stats["by_type"][post_type] = 0
@@ -249,19 +400,24 @@ class OpenWebUIStats:
stats: 统计数据
lang: 语言 ("zh" 中文, "en" 英文)
"""
# 获取增量数据
delta = self.get_stat_delta(stats)
# 中英文文本
texts = {
"zh": {
"title": "# 📊 OpenWebUI 社区统计报告",
"updated": f"> 📅 更新时间: {get_beijing_time().strftime('%Y-%m-%d %H:%M')}",
"overview_title": "## 📈 总览",
"overview_header": "| 指标 | 数值 |",
"overview_header": "| 指标 | 数值 | 增长 (24h) |",
"posts": "📝 发布数量",
"downloads": "⬇️ 总下载量",
"views": "👁️ 总浏览量",
"upvotes": "👍 总点赞数",
"saves": "💾 总收藏数",
"comments": "💬 总评论数",
"author_points": "⭐ 作者总积分",
"author_followers": "👥 粉丝数量",
"type_title": "## 📂 按类型分类",
"list_title": "## 📋 发布列表",
"list_header": "| 排名 | 标题 | 类型 | 版本 | 下载 | 浏览 | 点赞 | 收藏 | 更新日期 |",
@@ -270,13 +426,15 @@ class OpenWebUIStats:
"title": "# 📊 OpenWebUI Community Stats Report",
"updated": f"> 📅 Updated: {get_beijing_time().strftime('%Y-%m-%d %H:%M')}",
"overview_title": "## 📈 Overview",
"overview_header": "| Metric | Value |",
"overview_header": "| Metric | Value | Growth (24h) |",
"posts": "📝 Total Posts",
"downloads": "⬇️ Total Downloads",
"views": "👁️ Total Views",
"upvotes": "👍 Total Upvotes",
"saves": "💾 Total Saves",
"comments": "💬 Total Comments",
"author_points": "⭐ Author Points",
"author_followers": "👥 Followers",
"type_title": "## 📂 By Type",
"list_title": "## 📋 Posts List",
"list_header": "| Rank | Title | Type | Version | Downloads | Views | Upvotes | Saves | Updated |",
@@ -291,17 +449,44 @@ class OpenWebUIStats:
md.append(t["updated"])
md.append("")
# 插入趋势图
chart = self.generate_mermaid_chart()
if chart:
md.append(chart)
md.append("")
# 总览
def fmt_delta(key: str) -> str:
val = delta.get(key, 0)
if val > 0:
return f"**+{val}** 🚀"
return "-"
md.append(t["overview_title"])
md.append("")
md.append(t["overview_header"])
md.append("|------|------|")
md.append(f"| {t['posts']} | {stats['total_posts']} |")
md.append(f"| {t['downloads']} | {stats['total_downloads']} |")
md.append(f"| {t['views']} | {stats['total_views']} |")
md.append(f"| {t['upvotes']} | {stats['total_upvotes']} |")
md.append(f"| {t['saves']} | {stats['total_saves']} |")
md.append(f"| {t['comments']} | {stats['total_comments']} |")
md.append("|------|------|:---:|")
md.append(f"| {t['posts']} | {stats['total_posts']} | - |")
md.append(
f"| {t['downloads']} | {stats['total_downloads']} | {fmt_delta('downloads')} |"
)
md.append(f"| {t['views']} | {stats['total_views']} | {fmt_delta('views')} |")
md.append(
f"| {t['upvotes']} | {stats['total_upvotes']} | {fmt_delta('upvotes')} |"
)
md.append(f"| {t['saves']} | {stats['total_saves']} | - |")
md.append(f"| {t['comments']} | {stats['total_comments']} | - |")
# 作者信息
user = stats.get("user", {})
if user:
md.append(
f"| {t['author_points']} | {user.get('total_points', 0)} | {fmt_delta('points')} |"
)
md.append(
f"| {t['author_followers']} | {user.get('followers', 0)} | {fmt_delta('followers')} |"
)
md.append("")
# 按类型分类
@@ -581,6 +766,9 @@ def main():
# 生成统计
stats = stats_client.generate_stats(posts)
# 保存历史快照
stats_client.save_history(stats)
# 打印到终端
stats_client.print_stats(stats)