[v1.9.0] (活跃度可视化) 生成 24h 时间段活跃情况分析

This commit is contained in:
回归天空
2025-09-03 16:38:56 +08:00
parent 347cb94203
commit a2bebc257a
9 changed files with 393 additions and 4 deletions
+4 -1
View File
@@ -3,7 +3,7 @@
# QQ群日常分析插件
[![Plugin Version](https://img.shields.io/badge/Latest_Version-v1.8.0-blue.svg?style=for-the-badge&color=76bad9)](https://github.com/SXP-Simon/astrbot-qq-group-daily-analysis)
[![Plugin Version](https://img.shields.io/badge/Latest_Version-v1.9.0-blue.svg?style=for-the-badge&color=76bad9)](https://github.com/SXP-Simon/astrbot-qq-group-daily-analysis)
[![AstrBot](https://img.shields.io/badge/AstrBot-Plugin-ff69b4?style=for-the-badge)](https://github.com/AstrBotDevs/AstrBot)
[![License](https://img.shields.io/badge/License-MIT-green.svg?style=for-the-badge)](LICENSE)
@@ -144,6 +144,9 @@ _✨ 一个基于AstrBot的智能群聊分析插件,能够生成精美的群
### v1.8.0
- 修复表情统计情况
### v1.9.0
- 生成 24h 时间段活跃情况分析
## 许可证
MIT License
+3 -1
View File
@@ -216,6 +216,8 @@ class QQGroupDailyAnalysis(Star):
logger.error(f"群分析失败: {e}", exc_info=True)
yield event.plain_result(f"❌ 分析失败: {str(e)}。请检查网络连接和LLM配置,或联系管理员")
@filter.command("设置格式")
@filter.permission_type(PermissionType.ADMIN)
async def set_output_format(self, event: AiocqhttpMessageEvent, format_type: str = ""):
@@ -366,7 +368,7 @@ class QQGroupDailyAnalysis(Star):
• 最大查询轮数: {max_rounds}
💡 可用命令: enable, disable, status, reload, test
💡 支持的输出格式: image, text, pdf
💡 支持的输出格式: image, text, pdf (图片和PDF包含活跃度可视化)
💡 其他命令: /设置格式, /安装PDF""")
+1 -1
View File
@@ -13,6 +13,6 @@ help: | # 插件的帮助信息
命令:
/群分析 [天数] - 分析群聊活动
/分析设置 [操作] - 管理设置(enable/disable/status/test
version: v1.8.0 # 插件版本号。格式:v1.1.1 或者 v1.1
version: v1.9.0 # 插件版本号。格式:v1.1.1 或者 v1.1
author: SXP-Simon # 作者
repo: https://github.com/SXP-Simon/astrbot-qq-group-daily-analysis # 插件的仓库地址
+7 -1
View File
@@ -8,7 +8,8 @@ from datetime import datetime, timedelta
from typing import List, Dict, Optional
from collections import defaultdict
from astrbot.api import logger
from ...src.models.data_models import GroupStatistics, TokenUsage, EmojiStatistics
from ...src.models.data_models import GroupStatistics, TokenUsage, EmojiStatistics, ActivityVisualization
from ...src.visualization.activity_charts import ActivityVisualizer
class MessageHandler:
@@ -16,6 +17,7 @@ class MessageHandler:
def __init__(self, config_manager):
self.config_manager = config_manager
self.activity_visualizer = ActivityVisualizer()
self.bot_qq_id = None
async def set_bot_qq_id(self, bot_instance):
@@ -189,6 +191,9 @@ class MessageHandler:
most_active_hour = max(hour_counts.items(), key=lambda x: x[1])[0] if hour_counts else 0
most_active_period = f"{most_active_hour:02d}:00-{(most_active_hour+1)%24:02d}:00"
# 生成活跃度可视化数据
activity_visualization = self.activity_visualizer.generate_activity_visualization(messages)
return GroupStatistics(
message_count=len(messages),
total_characters=total_chars,
@@ -197,5 +202,6 @@ class MessageHandler:
golden_quotes=[],
emoji_count=emoji_statistics.total_emoji_count, # 保持向后兼容
emoji_statistics=emoji_statistics,
activity_visualization=activity_visualization,
token_usage=TokenUsage()
)
+11
View File
@@ -57,6 +57,16 @@ class EmojiStatistics:
return self.face_count + self.mface_count + self.bface_count + self.sface_count + self.other_emoji_count
@dataclass
class ActivityVisualization:
"""活跃度可视化数据结构"""
hourly_activity: dict = field(default_factory=dict) # {hour: count}
daily_activity: dict = field(default_factory=dict) # {date: count}
user_activity_ranking: list = field(default_factory=list) # 用户活跃度排行
peak_hours: list = field(default_factory=list) # 高峰时段
activity_heatmap_data: dict = field(default_factory=dict) # 热力图数据
@dataclass
class GroupStatistics:
"""群聊统计数据结构"""
@@ -67,4 +77,5 @@ class GroupStatistics:
golden_quotes: List[GoldenQuote]
emoji_count: int # 保持向后兼容
emoji_statistics: EmojiStatistics = field(default_factory=EmojiStatistics)
activity_visualization: ActivityVisualization = field(default_factory=ActivityVisualization)
token_usage: TokenUsage = field(default_factory=TokenUsage)
+14
View File
@@ -10,6 +10,7 @@ from typing import Dict, Optional
from pathlib import Path
from astrbot.api import logger
from .templates import HTMLTemplates
from ..visualization.activity_charts import ActivityVisualizer
class ReportGenerator:
@@ -17,6 +18,7 @@ class ReportGenerator:
def __init__(self, config_manager):
self.config_manager = config_manager
self.activity_visualizer = ActivityVisualizer()
async def generate_image_report(self, analysis_result: Dict, group_id: str, html_render_func) -> Optional[str]:
"""生成图片格式的分析报告"""
@@ -32,6 +34,8 @@ class ReportGenerator:
logger.error(f"生成图片报告失败: {e}")
return None
async def generate_pdf_report(self, analysis_result: Dict, group_id: str) -> Optional[str]:
"""生成PDF格式的分析报告"""
try:
@@ -113,6 +117,7 @@ class ReportGenerator:
stats = analysis_result["statistics"]
topics = analysis_result["topics"]
user_titles = analysis_result["user_titles"]
activity_viz = stats.activity_visualization
# 构建话题HTML
topics_html = ""
@@ -166,6 +171,11 @@ class ReportGenerator:
</div>
"""
# 生成活跃度可视化HTML
hourly_chart_html = self.activity_visualizer.generate_hourly_chart_html(
activity_viz.hourly_activity
)
# 返回扁平化的渲染数据
return {
"current_date": datetime.now().strftime('%Y年%m月%d'),
@@ -178,11 +188,15 @@ class ReportGenerator:
"topics_html": topics_html,
"titles_html": titles_html,
"quotes_html": quotes_html,
"hourly_chart_html": hourly_chart_html,
"total_tokens": stats.token_usage.total_tokens,
"prompt_tokens": stats.token_usage.prompt_tokens,
"completion_tokens": stats.token_usage.completion_tokens
}
def _render_html_template(self, template: str, data: Dict, use_jinja_style: bool = False) -> str:
"""HTML模板渲染,支持两种占位符格式
+181
View File
@@ -66,6 +66,103 @@ class HTMLTemplates:
.quote-author { font-size: 0.9em; color: #4299e1; font-weight: 600; margin-bottom: 8px; text-align: right; }
.quote-reason { font-size: 0.8em; color: #666666; font-style: normal; background: rgba(66, 153, 225, 0.1); padding: 8px 12px; border-radius: 12px; border-left: 3px solid #4299e1; }
.footer { background: linear-gradient(135deg, #3182ce 0%, #2c5282 100%); color: #ffffff; text-align: center; padding: 32px; font-size: 0.8em; font-weight: 300; letter-spacing: 0.5px; opacity: 0.9; }
/* 活跃度可视化样式 - 重新设计 */
.activity-section {
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
padding: 40px;
border-radius: 20px;
margin: 40px 0;
border: 1px solid #e2e8f0;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
}
.activity-chart-container {
background: #ffffff;
padding: 32px;
border-radius: 16px;
border: 1px solid #e2e8f0;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
}
.chart-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 32px;
padding-bottom: 16px;
border-bottom: 2px solid #f0f2f5;
}
.chart-title {
font-size: 1.4em;
font-weight: 600;
color: #2d3748;
display: flex;
align-items: center;
gap: 12px;
}
.chart-subtitle {
color: #7f8c8d;
font-size: 0.9em;
font-weight: 400;
}
.hour-bar-container {
display: flex;
align-items: center;
margin: 10px 0;
height: 20px;
transition: all 0.2s ease;
}
.hour-bar-container:hover {
background: #f8f9fa;
border-radius: 8px;
padding: 0 8px;
}
.hour-label {
width: 55px;
text-align: left;
color: #4a5568;
font-size: 13px;
font-weight: 500;
flex-shrink: 0;
}
.bar-wrapper {
display: flex;
align-items: center;
flex-grow: 1;
gap: 12px;
min-width: 0;
}
.bar {
height: 10px;
background: linear-gradient(90deg, #4299e1 0%, #667eea 100%);
border-radius: 6px;
transition: all 0.3s ease-out;
display: flex;
justify-content: flex-end;
align-items: center;
overflow: hidden;
box-shadow: 0 2px 8px rgba(66, 153, 225, 0.2);
}
.bar:hover {
transform: scaleY(1.2);
box-shadow: 0 4px 12px rgba(66, 153, 225, 0.3);
}
.hourly-value-outside {
color: #4a5568;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
min-width: 30px;
text-align: right;
}
.hourly-value-inside {
color: white;
font-size: 11px;
padding: 0 8px;
font-weight: 600;
white-space: nowrap;
text-shadow: 0 1px 2px rgba(0,0,0,0.2);
}
@media (min-width: 1400px) { .container { max-width: 1400px; } .topics-grid { grid-template-columns: repeat(3, 1fr); } .users-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 768px) { body { padding: 10px; } .container { margin: 0; max-width: 100%; } .header { padding: 24px 20px; } .header h1 { font-size: 1.8em; } .content { padding: 20px; } .topics-grid { grid-template-columns: 1fr; } .users-grid { grid-template-columns: 1fr; } .stats-grid { grid-template-columns: 1fr 1fr; gap: 12px; } .stat-card { padding: 20px 16px; } .topic-item { padding: 20px; } .user-title { flex-direction: column; align-items: flex-start; gap: 12px; padding: 16px; min-height: auto; } .user-info { width: 100%; } .user-reason { text-align: left; max-width: none; margin-left: 0; margin-top: 8px; } }
</style>
@@ -90,6 +187,16 @@ class HTMLTemplates:
<div class="label">最活跃时段</div>
</div>
</div>
<!-- 活跃度可视化部分 - 重新设计 -->
<div class="activity-chart-container">
<div class="chart-header">
<div>
<div class="chart-title">⏱️ 24小时活跃度分布</div>
</div>
</div>
{{ hourly_chart_html | safe }}
</div>
<div class="section">
<h2 class="section-title">💬 热门话题</h2>
<div class="topics-grid">{{ topics_html | safe }}</div>
@@ -157,6 +264,72 @@ class HTMLTemplates:
.quote-author { font-size: 14px; color: #4299e1; font-weight: 600; margin-bottom: 8px; text-align: right; }
.quote-reason { font-size: 12px; color: #666666; background: rgba(66, 153, 225, 0.1); padding: 8px 12px; border-radius: 6px; border-left: 3px solid #4299e1; }
.footer { background: #f8f9ff; color: #666666; text-align: center; padding: 20px; font-size: 12px; border-radius: 8px; margin-top: 40px; }
/* PDF活跃度可视化样式 - 集成版本 */
.activity-chart-container {
background: #f8f9ff;
padding: 20px;
border-radius: 8px;
border: 1px solid #e2e8f0;
margin-top: 20px;
page-break-inside: avoid;
}
.chart-header {
margin-bottom: 15px;
padding-bottom: 8px;
border-bottom: 1px solid #e2e8f0;
}
.chart-title {
font-size: 16px;
font-weight: 600;
color: #2d3748;
}
.hour-bar-container {
display: flex;
align-items: center;
margin: 6px 0;
height: 16px;
}
.hour-label {
width: 45px;
text-align: left;
color: #4a5568;
font-size: 11px;
font-weight: 500;
flex-shrink: 0;
}
.bar-wrapper {
display: flex;
align-items: center;
flex-grow: 1;
gap: 8px;
min-width: 0;
}
.bar {
height: 6px;
background-color: #4299e1;
border-radius: 3px;
display: flex;
justify-content: flex-end;
align-items: center;
overflow: hidden;
}
.hourly-value-outside {
color: #4a5568;
font-size: 10px;
font-weight: 600;
flex-shrink: 0;
min-width: 25px;
text-align: right;
}
.hourly-value-inside {
color: white;
font-size: 9px;
padding: 0 4px;
font-weight: 600;
white-space: nowrap;
}
@media print { body { font-size: 12px; } .container { padding: 10px; } .header { padding: 20px; } .section { margin-bottom: 30px; } .stats-grid { grid-template-columns: repeat(2, 1fr); } }
</style>
</head>
@@ -178,7 +351,15 @@ class HTMLTemplates:
<div class="time">{most_active_period}</div>
<div class="label">最活跃时段</div>
</div>
<!-- 活跃度可视化部分 - 集成到基础统计 -->
<div class="activity-chart-container">
<div class="chart-header">
<div class="chart-title">⏱️ 活跃度分布</div>
</div>
{hourly_chart_html}
</div>
</div>
<div class="section">
<h2 class="section-title">💬 热门话题</h2>
{topics_html}
+7
View File
@@ -0,0 +1,7 @@
"""
可视化模块
"""
from .activity_charts import ActivityVisualizer
__all__ = ['ActivityVisualizer']
+165
View File
@@ -0,0 +1,165 @@
"""
群聊活跃度可视化模块
参考 astrbot_plugin_github_analyzer 的实现方式
"""
from datetime import datetime, timedelta
from collections import defaultdict
from typing import Dict, List, Any
from ..models.data_models import ActivityVisualization
class ActivityVisualizer:
"""活跃度可视化器"""
def __init__(self):
pass
def generate_activity_visualization(self, messages: List[Dict]) -> ActivityVisualization:
"""生成活跃度可视化数据 - 专注于小时级别分析"""
hourly_activity = defaultdict(int)
user_activity = defaultdict(int)
emoji_activity = defaultdict(int) # 每小时表情统计
# 分析消息数据
for msg in messages:
# 时间分析 - 只关注小时
msg_time = datetime.fromtimestamp(msg.get("time", 0))
hour = msg_time.hour
# # 用户分析
# sender = msg.get("sender", {})
# user_id = str(sender.get("user_id", ""))
# nickname = sender.get("nickname", "") or sender.get("card", "")
# 统计每小时消息数
hourly_activity[hour] += 1
# # 统计用户活跃度
# user_activity[user_id] = {
# "nickname": nickname,
# "count": user_activity.get(user_id, {}).get("count", 0) + 1
# }
# 统计每小时表情数
for content in msg.get("message", []):
if content.get("type") in ["face", "mface", "bface", "sface"]:
emoji_activity[hour] += 1
elif content.get("type") == "image":
data = content.get("data", {})
summary = data.get("summary", "")
if "动画表情" in summary or "表情" in summary:
emoji_activity[hour] += 1
# 生成用户活跃度排行
user_ranking = []
for user_id, data in user_activity.items():
user_ranking.append({
"user_id": user_id,
"nickname": data["nickname"],
"message_count": data["count"]
})
user_ranking.sort(key=lambda x: x["message_count"], reverse=True)
# 找出高峰时段(活跃度最高的3个小时)
peak_hours = sorted(hourly_activity.items(), key=lambda x: x[1], reverse=True)[:3]
peak_hours = [{"hour": hour, "count": count} for hour, count in peak_hours]
return ActivityVisualization(
hourly_activity=dict(hourly_activity),
daily_activity={}, # 不使用日期分析
user_activity_ranking=user_ranking[:10], # 前10名
peak_hours=peak_hours,
activity_heatmap_data=self._generate_hourly_heatmap_data(hourly_activity, emoji_activity)
)
def _generate_hourly_heatmap_data(self, hourly_activity: dict, emoji_activity: dict) -> dict:
"""生成小时级热力图数据"""
# 计算活跃度等级
max_hourly = max(hourly_activity.values()) if hourly_activity else 1
max_emoji = max(emoji_activity.values()) if emoji_activity else 1
return {
"hourly_max": max_hourly,
"emoji_max": max_emoji,
"hourly_normalized": {
hour: (count / max_hourly) * 100
for hour, count in hourly_activity.items()
},
"emoji_normalized": {
hour: (emoji_activity.get(hour, 0) / max_emoji) * 100
for hour in range(24)
},
"activity_levels": self._calculate_activity_levels(hourly_activity)
}
def _calculate_activity_levels(self, hourly_activity: dict) -> dict:
"""计算活跃度等级"""
if not hourly_activity:
return {}
max_count = max(hourly_activity.values())
levels = {}
for hour in range(24):
count = hourly_activity.get(hour, 0)
if count == 0:
level = "inactive"
elif count <= max_count * 0.3:
level = "low"
elif count <= max_count * 0.7:
level = "medium"
else:
level = "high"
levels[hour] = level
return levels
def generate_hourly_chart_html(self, hourly_activity: dict) -> str:
"""生成每小时活动分布的HTML图表"""
html_parts = []
max_activity = max(hourly_activity.values()) if hourly_activity else 1
threshold_percentage = 20 # 数值显示阈值
for hour in range(24):
count = hourly_activity.get(hour, 0)
percentage = (count / max_activity) * 100 if max_activity > 0 else 0
if count > 0 and percentage >= threshold_percentage:
# 活动数较多,数值显示在条形图内部
html_segment = f"""
<div class="hour-bar-container">
<span class="hour-label">{hour:02d}:00</span>
<div class="bar-wrapper">
<div class="bar" style="width: {percentage}%;">
<span class="hourly-value-inside">({count})</span>
</div>
</div>
</div>
"""
elif count > 0:
# 活动数较少,数值显示在条形图外部
html_segment = f"""
<div class="hour-bar-container">
<span class="hour-label">{hour:02d}:00</span>
<div class="bar-wrapper">
<div class="bar" style="width: {percentage}%;"></div>
<span class="hourly-value-outside">({count})</span>
</div>
</div>
"""
else:
# 无活动
html_segment = f"""
<div class="hour-bar-container">
<span class="hour-label">{hour:02d}:00</span>
<div class="bar-wrapper">
<span class="hourly-value-outside">({count})</span>
</div>
</div>
"""
html_parts.append(html_segment)
return "".join(html_parts)