diff --git a/README.md b/README.md index 0642f57..8e5f1de 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/main.py b/main.py index 1c6452b..493f749 100644 --- a/main.py +++ b/main.py @@ -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""") diff --git a/metadata.yaml b/metadata.yaml index f0d50db..1f66982 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -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 # 插件的仓库地址 diff --git a/src/core/message_handler.py b/src/core/message_handler.py index 75ee04e..0d51d82 100644 --- a/src/core/message_handler.py +++ b/src/core/message_handler.py @@ -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() ) \ No newline at end of file diff --git a/src/models/data_models.py b/src/models/data_models.py index 9f00cc3..c6210c3 100644 --- a/src/models/data_models.py +++ b/src/models/data_models.py @@ -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) \ No newline at end of file diff --git a/src/reports/generators.py b/src/reports/generators.py index 86fd3b4..240bba6 100644 --- a/src/reports/generators.py +++ b/src/reports/generators.py @@ -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: """ + # 生成活跃度可视化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模板渲染,支持两种占位符格式 diff --git a/src/reports/templates.py b/src/reports/templates.py index 4077f46..1266f10 100644 --- a/src/reports/templates.py +++ b/src/reports/templates.py @@ -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; } } @@ -90,6 +187,16 @@ class HTMLTemplates:
最活跃时段
+ + +
+
+
+
⏱️ 24小时活跃度分布
+
+
+ {{ hourly_chart_html | safe }} +

💬 热门话题

{{ topics_html | safe }}
@@ -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); } } @@ -178,7 +351,15 @@ class HTMLTemplates:
{most_active_period}
最活跃时段
+ +
+
+
⏱️ 活跃度分布
+
+ {hourly_chart_html} +
+

💬 热门话题

{topics_html} diff --git a/src/visualization/__init__.py b/src/visualization/__init__.py new file mode 100644 index 0000000..de8e4e7 --- /dev/null +++ b/src/visualization/__init__.py @@ -0,0 +1,7 @@ +""" +可视化模块 +""" + +from .activity_charts import ActivityVisualizer + +__all__ = ['ActivityVisualizer'] diff --git a/src/visualization/activity_charts.py b/src/visualization/activity_charts.py new file mode 100644 index 0000000..63d921d --- /dev/null +++ b/src/visualization/activity_charts.py @@ -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""" +
+ {hour:02d}:00 +
+
+ ({count}) +
+
+
+ """ + elif count > 0: + # 活动数较少,数值显示在条形图外部 + html_segment = f""" +
+ {hour:02d}:00 +
+
+ ({count}) +
+
+ """ + else: + # 无活动 + html_segment = f""" +
+ {hour:02d}:00 +
+ ({count}) +
+
+ """ + html_parts.append(html_segment) + + return "".join(html_parts) + +