mirror of
https://github.com/Nezumi-2711/astrbot_plugin_qq_group_daily_analysis.git
synced 2026-09-22 13:38:43 +00:00
[v1.7.0] (解耦化) 优化项目结构,修复部分提示内容
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
# QQ群日常分析插件
|
||||
|
||||
|
||||
[](https://github.com/SXP-Simon/astrbot-qq-group-daily-analysis)
|
||||
[](https://github.com/SXP-Simon/astrbot-qq-group-daily-analysis)
|
||||
[](https://github.com/AstrBotDevs/AstrBot)
|
||||
[](LICENSE)
|
||||
|
||||
@@ -137,6 +137,10 @@ _✨ 一个基于AstrBot的智能群聊分析插件,能够生成精美的群
|
||||
### v1.4.0
|
||||
- PDF 版本情况说明更新
|
||||
|
||||
### v1.7.0
|
||||
- 修复提示
|
||||
- 解耦化
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
+1
-1
@@ -13,6 +13,6 @@ help: | # 插件的帮助信息
|
||||
命令:
|
||||
/群分析 [天数] - 分析群聊活动
|
||||
/分析设置 [操作] - 管理设置(enable/disable/status/test)
|
||||
version: v1.6.0 # 插件版本号。格式:v1.1.1 或者 v1.1
|
||||
version: v1.7.0 # 插件版本号。格式:v1.1.1 或者 v1.1
|
||||
author: SXP-Simon # 作者
|
||||
repo: https://github.com/SXP-Simon/astrbot-qq-group-daily-analysis # 插件的仓库地址
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
QQ群日常分析插件 - 源代码包
|
||||
"""
|
||||
|
||||
__author__ = "SXP-Simon"
|
||||
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
分析模块
|
||||
包含LLM分析和统计分析功能
|
||||
"""
|
||||
|
||||
from .llm_analyzer import LLMAnalyzer
|
||||
from .statistics import UserAnalyzer
|
||||
|
||||
__all__ = [
|
||||
'LLMAnalyzer',
|
||||
'UserAnalyzer'
|
||||
]
|
||||
@@ -0,0 +1,470 @@
|
||||
"""
|
||||
LLM分析器模块
|
||||
负责使用LLM进行话题分析、用户称号分析和金句分析
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Tuple
|
||||
from astrbot.api import logger
|
||||
from ...src.models.data_models import SummaryTopic, UserTitle, GoldenQuote, TokenUsage
|
||||
|
||||
|
||||
class LLMAnalyzer:
|
||||
"""LLM分析器"""
|
||||
|
||||
def __init__(self, context, config_manager):
|
||||
self.context = context
|
||||
self.config_manager = config_manager
|
||||
|
||||
async def analyze_topics(self, messages: List[Dict]) -> Tuple[List[SummaryTopic], TokenUsage]:
|
||||
"""使用LLM分析话题"""
|
||||
try:
|
||||
# 提取文本消息
|
||||
text_messages = []
|
||||
for msg in messages:
|
||||
sender = msg.get("sender", {})
|
||||
nickname = sender.get("nickname", "") or sender.get("card", "")
|
||||
msg_time = datetime.fromtimestamp(msg.get("time", 0)).strftime("%H:%M")
|
||||
|
||||
for content in msg.get("message", []):
|
||||
if content.get("type") == "text":
|
||||
text = content.get("data", {}).get("text", "").strip()
|
||||
if text and len(text) > 2 and not text.startswith(("/")):
|
||||
text_messages.append({
|
||||
"sender": nickname,
|
||||
"time": msg_time,
|
||||
"content": text
|
||||
})
|
||||
|
||||
if not text_messages:
|
||||
return [], TokenUsage()
|
||||
|
||||
# 构建LLM提示词,清理消息内容
|
||||
def clean_message_content(content):
|
||||
"""清理消息内容,移除可能影响JSON解析的字符"""
|
||||
# 替换中文引号
|
||||
content = content.replace('"', '"').replace('"', '"')
|
||||
content = content.replace(''', "'").replace(''', "'")
|
||||
# 移除或替换其他特殊字符
|
||||
content = content.replace('\n', ' ').replace('\r', ' ')
|
||||
content = content.replace('\t', ' ')
|
||||
# 移除可能的控制字符
|
||||
content = re.sub(r'[\x00-\x1f\x7f-\x9f]', '', content)
|
||||
return content.strip()
|
||||
|
||||
messages_text = "\n".join([
|
||||
f"[{msg['time']}] {msg['sender']}: {clean_message_content(msg['content'])}"
|
||||
for msg in text_messages
|
||||
])
|
||||
|
||||
max_topics = self.config_manager.get_max_topics()
|
||||
prompt = f"""
|
||||
你是一个帮我进行群聊信息总结的助手,生成总结内容时,你需要严格遵守下面的几个准则:
|
||||
请分析接下来提供的群聊记录,提取出最多{max_topics}个主要话题。
|
||||
|
||||
对于每个话题,请提供:
|
||||
1. 话题名称(突出主题内容,尽量简明扼要)
|
||||
2. 主要参与者(最多5人)
|
||||
3. 话题详细描述(包含关键信息和结论)
|
||||
|
||||
注意:
|
||||
- 对于比较有价值的点,稍微用一两句话详细讲讲,比如不要生成 "Nolan 和 SOV 讨论了 galgame 中关于性符号的衍生情况" 这种宽泛的内容,而是生成更加具体的讨论内容,让其他人只看这个消息就能知道讨论中有价值的,有营养的信息。
|
||||
- 对于其中的部分信息,你需要特意提到主题施加的主体是谁,是哪个群友做了什么事情,而不要直接生成和群友没有关系的语句。
|
||||
- 对于每一条总结,尽量讲清楚前因后果,以及话题的结论,是什么,为什么,怎么做,如果用户没有讲到细节,则可以不用这么做。
|
||||
|
||||
群聊记录:
|
||||
{messages_text}
|
||||
|
||||
重要:必须返回标准JSON格式,严格遵守以下规则:
|
||||
1. 只使用英文双引号 " 不要使用中文引号 " "
|
||||
2. 字符串内容中的引号必须转义为 \"
|
||||
3. 多个对象之间用逗号分隔
|
||||
4. 数组元素之间用逗号分隔
|
||||
5. 不要在JSON外添加任何文字说明
|
||||
6. 描述内容避免使用特殊符号,用普通文字表达
|
||||
|
||||
请严格按照以下JSON格式返回,确保可以被标准JSON解析器解析:
|
||||
[
|
||||
{{
|
||||
"topic": "话题名称",
|
||||
"contributors": ["用户1", "用户2"],
|
||||
"detail": "话题描述内容"
|
||||
}},
|
||||
{{
|
||||
"topic": "另一个话题",
|
||||
"contributors": ["用户3", "用户4"],
|
||||
"detail": "另一个话题的描述"
|
||||
}}
|
||||
]
|
||||
|
||||
注意:返回的内容必须是纯JSON,不要包含markdown代码块标记或其他格式
|
||||
"""
|
||||
|
||||
# 调用LLM
|
||||
provider = self.context.get_using_provider()
|
||||
if not provider:
|
||||
logger.warning("未配置LLM提供商,跳过话题分析")
|
||||
return [], TokenUsage()
|
||||
|
||||
response = await provider.text_chat(
|
||||
prompt=prompt,
|
||||
max_tokens=10000,
|
||||
temperature=0.6
|
||||
)
|
||||
|
||||
# 提取token使用统计
|
||||
token_usage = TokenUsage()
|
||||
if response.raw_completion and hasattr(response.raw_completion, 'usage'):
|
||||
usage = response.raw_completion.usage
|
||||
token_usage.prompt_tokens = usage.prompt_tokens
|
||||
token_usage.completion_tokens = usage.completion_tokens
|
||||
token_usage.total_tokens = usage.total_tokens
|
||||
|
||||
# 解析响应
|
||||
if hasattr(response, 'completion_text'):
|
||||
result_text = response.completion_text
|
||||
else:
|
||||
result_text = str(response)
|
||||
|
||||
# 尝试解析JSON
|
||||
try:
|
||||
# 提取JSON部分
|
||||
json_match = re.search(r'\[.*?\]', result_text, re.DOTALL)
|
||||
if json_match:
|
||||
json_text = json_match.group()
|
||||
logger.debug(f"话题分析JSON原文: {json_text[:500]}...")
|
||||
|
||||
# 强化JSON清理和修复
|
||||
json_text = self._fix_json(json_text)
|
||||
logger.debug(f"修复后的JSON: {json_text[:300]}...")
|
||||
|
||||
topics_data = json.loads(json_text)
|
||||
topics = [SummaryTopic(**topic) for topic in topics_data[:max_topics]]
|
||||
logger.info(f"话题分析成功,解析到 {len(topics)} 个话题")
|
||||
return topics, token_usage
|
||||
else:
|
||||
logger.warning(f"话题分析响应中未找到JSON格式,响应内容: {result_text[:200]}...")
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"话题分析JSON解析失败: {e}")
|
||||
logger.debug(f"修复后的JSON: {json_text if 'json_text' in locals() else 'N/A'}")
|
||||
logger.debug(f"原始响应: {result_text}")
|
||||
|
||||
# 如果JSON解析失败,尝试用正则表达式提取话题信息
|
||||
topics = self._extract_topics_with_regex(result_text, max_topics)
|
||||
if topics:
|
||||
logger.info(f"正则表达式提取成功,获得 {len(topics)} 个话题")
|
||||
return topics, token_usage
|
||||
else:
|
||||
# 最后的降级方案
|
||||
logger.info("正则表达式提取失败,使用默认话题...")
|
||||
return [SummaryTopic(
|
||||
topic="群聊讨论",
|
||||
contributors=["群友"],
|
||||
detail="今日群聊内容丰富,涵盖多个话题"
|
||||
)], token_usage
|
||||
|
||||
return [], token_usage
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"话题分析失败: {e}")
|
||||
return [], TokenUsage()
|
||||
|
||||
def _fix_json(self, text: str) -> str:
|
||||
"""修复JSON格式问题"""
|
||||
# 移除markdown代码块标记
|
||||
text = re.sub(r'```json\s*', '', text)
|
||||
text = re.sub(r'```\s*$', '', text)
|
||||
|
||||
# 基础清理
|
||||
text = text.replace('\n', ' ').replace('\r', ' ')
|
||||
text = re.sub(r'\s+', ' ', text)
|
||||
|
||||
# 替换中文引号为英文引号
|
||||
text = text.replace('"', '"').replace('"', '"')
|
||||
text = text.replace(''', "'").replace(''', "'")
|
||||
|
||||
# 处理字符串内容中的特殊字符
|
||||
# 转义字符串内的双引号
|
||||
def escape_quotes_in_strings(match):
|
||||
content = match.group(1)
|
||||
# 转义内部的双引号
|
||||
content = content.replace('"', '\\"')
|
||||
return f'"{content}"'
|
||||
|
||||
# 先处理字段值中的引号
|
||||
text = re.sub(r'"([^"]*(?:"[^"]*)*)"', escape_quotes_in_strings, text)
|
||||
|
||||
# 修复截断的JSON
|
||||
if not text.endswith(']'):
|
||||
last_complete = text.rfind('}')
|
||||
if last_complete > 0:
|
||||
text = text[:last_complete + 1] + ']'
|
||||
|
||||
# 修复常见的JSON格式问题
|
||||
# 1. 修复缺失的逗号
|
||||
text = re.sub(r'}\s*{', '}, {', text)
|
||||
|
||||
# 2. 确保字段名有引号
|
||||
text = re.sub(r'([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:', r'\1"\2":', text)
|
||||
|
||||
# 3. 移除多余的逗号
|
||||
text = re.sub(r',\s*}', '}', text)
|
||||
text = re.sub(r',\s*]', ']', text)
|
||||
|
||||
return text
|
||||
|
||||
def _extract_topics_with_regex(self, result_text: str, max_topics: int) -> List[SummaryTopic]:
|
||||
"""使用正则表达式提取话题信息"""
|
||||
try:
|
||||
topics = []
|
||||
|
||||
# 更强的正则表达式提取话题信息,处理转义字符
|
||||
# 匹配每个完整的话题对象
|
||||
topic_pattern = r'\{\s*"topic":\s*"([^"]+)"\s*,\s*"contributors":\s*\[([^\]]+)\]\s*,\s*"detail":\s*"([^"]*(?:\\.[^"]*)*)"\s*\}'
|
||||
matches = re.findall(topic_pattern, result_text, re.DOTALL)
|
||||
|
||||
if not matches:
|
||||
# 尝试更宽松的匹配
|
||||
topic_pattern = r'"topic":\s*"([^"]+)"[^}]*"contributors":\s*\[([^\]]+)\][^}]*"detail":\s*"([^"]*(?:\\.[^"]*)*)"'
|
||||
matches = re.findall(topic_pattern, result_text, re.DOTALL)
|
||||
|
||||
for match in matches[:max_topics]:
|
||||
topic_name = match[0].strip()
|
||||
contributors_str = match[1].strip()
|
||||
detail = match[2].strip()
|
||||
|
||||
# 清理detail中的转义字符
|
||||
detail = detail.replace('\\"', '"').replace('\\n', ' ').replace('\\t', ' ')
|
||||
|
||||
# 解析参与者列表
|
||||
contributors = []
|
||||
for contrib in re.findall(r'"([^"]+)"', contributors_str):
|
||||
contributors.append(contrib.strip())
|
||||
|
||||
if not contributors:
|
||||
contributors = ["群友"]
|
||||
|
||||
topics.append(SummaryTopic(
|
||||
topic=topic_name,
|
||||
contributors=contributors[:5], # 最多5个参与者
|
||||
detail=detail
|
||||
))
|
||||
|
||||
return topics
|
||||
except Exception as e:
|
||||
logger.error(f"正则表达式提取失败: {e}")
|
||||
return []
|
||||
|
||||
async def analyze_user_titles(self, messages: List[Dict], user_analysis: Dict) -> Tuple[List[UserTitle], TokenUsage]:
|
||||
"""使用LLM分析用户称号"""
|
||||
try:
|
||||
# 准备用户数据
|
||||
user_summaries = []
|
||||
for user_id, stats in user_analysis.items():
|
||||
if stats["message_count"] < 5: # 过滤活跃度太低的用户
|
||||
continue
|
||||
|
||||
# 分析用户特征
|
||||
night_messages = sum(stats["hours"][h] for h in range(0, 6))
|
||||
day_messages = stats["message_count"] - night_messages
|
||||
avg_chars = stats["char_count"] / stats["message_count"] if stats["message_count"] > 0 else 0
|
||||
|
||||
user_summaries.append({
|
||||
"name": stats["nickname"],
|
||||
"qq": int(user_id),
|
||||
"message_count": stats["message_count"],
|
||||
"avg_chars": round(avg_chars, 1),
|
||||
"emoji_ratio": round(stats["emoji_count"] / stats["message_count"], 2),
|
||||
"night_ratio": round(night_messages / stats["message_count"], 2),
|
||||
"reply_ratio": round(stats["reply_count"] / stats["message_count"], 2)
|
||||
})
|
||||
|
||||
if not user_summaries:
|
||||
return [], TokenUsage()
|
||||
|
||||
# 按消息数量排序,取前N名
|
||||
max_user_titles = self.config_manager.get_max_user_titles()
|
||||
user_summaries.sort(key=lambda x: x["message_count"], reverse=True)
|
||||
user_summaries = user_summaries[:max_user_titles]
|
||||
|
||||
# 构建LLM提示词
|
||||
users_text = "\n".join([
|
||||
f"- {user['name']} (QQ:{user['qq']}): "
|
||||
f"发言{user['message_count']}条, 平均{user['avg_chars']}字, "
|
||||
f"表情比例{user['emoji_ratio']}, 夜间发言比例{user['night_ratio']}, "
|
||||
f"回复比例{user['reply_ratio']}"
|
||||
for user in user_summaries
|
||||
])
|
||||
|
||||
prompt = f"""
|
||||
请为以下群友分配合适的称号和MBTI类型。每个人只能有一个称号,每个称号只能给一个人。
|
||||
|
||||
可选称号:
|
||||
- 龙王: 发言频繁但内容轻松的人
|
||||
- 技术专家: 经常讨论技术话题的人
|
||||
- 夜猫子: 经常在深夜发言的人
|
||||
- 表情包军火库: 经常发表情的人
|
||||
- 沉默终结者: 经常开启话题的人
|
||||
- 评论家: 平均发言长度很长的人
|
||||
- 阳角: 在群里很有影响力的人
|
||||
- 互动达人: 经常回复别人的人
|
||||
- ... (你可以自行进行拓展添加)
|
||||
|
||||
用户数据:
|
||||
{users_text}
|
||||
|
||||
请以JSON格式返回,格式如下:
|
||||
[
|
||||
{{
|
||||
"name": "用户名",
|
||||
"qq": 123456789,
|
||||
"title": "称号",
|
||||
"mbti": "MBTI类型",
|
||||
"reason": "获得此称号的原因"
|
||||
}}
|
||||
]
|
||||
"""
|
||||
|
||||
# 调用LLM
|
||||
provider = self.context.get_using_provider()
|
||||
if not provider:
|
||||
logger.warning("未配置LLM提供商,跳过用户称号分析")
|
||||
return [], TokenUsage()
|
||||
|
||||
response = await provider.text_chat(
|
||||
prompt=prompt,
|
||||
max_tokens=1500,
|
||||
temperature=0.5
|
||||
)
|
||||
|
||||
# 提取token使用统计
|
||||
token_usage = TokenUsage()
|
||||
if response.raw_completion and hasattr(response.raw_completion, 'usage'):
|
||||
usage = response.raw_completion.usage
|
||||
token_usage.prompt_tokens = usage.prompt_tokens
|
||||
token_usage.completion_tokens = usage.completion_tokens
|
||||
token_usage.total_tokens = usage.total_tokens
|
||||
|
||||
# 解析响应
|
||||
if hasattr(response, 'completion_text'):
|
||||
result_text = response.completion_text
|
||||
else:
|
||||
result_text = str(response)
|
||||
|
||||
# 尝试解析JSON
|
||||
try:
|
||||
json_match = re.search(r'\[.*\]', result_text, re.DOTALL)
|
||||
if json_match:
|
||||
titles_data = json.loads(json_match.group())
|
||||
return [UserTitle(**title) for title in titles_data], token_usage
|
||||
except:
|
||||
pass
|
||||
|
||||
return [], token_usage
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"用户称号分析失败: {e}")
|
||||
return [], TokenUsage()
|
||||
|
||||
async def analyze_golden_quotes(self, messages: List[Dict]) -> Tuple[List[GoldenQuote], TokenUsage]:
|
||||
"""使用LLM分析群聊金句"""
|
||||
try:
|
||||
# 提取有趣的文本消息
|
||||
interesting_messages = []
|
||||
for msg in messages:
|
||||
sender = msg.get("sender", {})
|
||||
nickname = sender.get("nickname", "") or sender.get("card", "")
|
||||
msg_time = datetime.fromtimestamp(msg.get("time", 0)).strftime("%H:%M")
|
||||
|
||||
for content in msg.get("message", []):
|
||||
if content.get("type") == "text":
|
||||
text = content.get("data", {}).get("text", "").strip()
|
||||
# 过滤长度适中、可能有趣的消息
|
||||
if 5 <= len(text) <= 100 and not text.startswith(("http", "www", "/")):
|
||||
interesting_messages.append({
|
||||
"sender": nickname,
|
||||
"time": msg_time,
|
||||
"content": text
|
||||
})
|
||||
|
||||
if not interesting_messages:
|
||||
return [], TokenUsage()
|
||||
|
||||
# 构建LLM提示词
|
||||
messages_text = "\n".join([
|
||||
f"[{msg['time']}] {msg['sender']}: {msg['content']}"
|
||||
for msg in interesting_messages
|
||||
])
|
||||
|
||||
# 计算金句数量
|
||||
max_golden_quotes = self.config_manager.get_max_golden_quotes()
|
||||
|
||||
prompt = f"""
|
||||
请从以下群聊记录中挑选出{max_golden_quotes}句最具冲击力、最令人惊叹的"金句"。这些金句需满足:
|
||||
- 核心标准:**逆天的神人发言**,即具备颠覆常识的脑洞、逻辑跳脱的表达或强烈反差感的原创内容
|
||||
- 典型特征:包含某些争议话题元素、夸张类比、反常规结论、一本正经的"胡说八道"或突破语境的清奇思路,并且具备一定的冲击力,让人印象深刻。
|
||||
|
||||
对于每个金句,请提供:
|
||||
1. 原文内容(完整保留发言细节)
|
||||
2. 发言人昵称
|
||||
3. 选择理由(具体说明其"逆天"之处,如逻辑颠覆点/脑洞角度/反差感/争议话题元素)
|
||||
|
||||
此外,我将对你进行严格约束:
|
||||
- 优先筛选 **逆天指数最高** 的内容:发情、性压抑话题 > 争议话题 > 元素级 > 颠覆认知级 > 逻辑跳脱级 > 趣味调侃级,剔除单纯玩梗或网络热词堆砌的普通发言
|
||||
- 重点标记包含极端类比、反常识论证或无厘头结论的内容,并且包含一定的争议话题元素。
|
||||
|
||||
群聊记录:
|
||||
{messages_text}
|
||||
|
||||
请以JSON格式返回,格式如下:
|
||||
[
|
||||
{{
|
||||
"content": "金句原文",
|
||||
"sender": "发言人昵称",
|
||||
"reason": "选择这句话的理由(需明确说明逆天特质)"
|
||||
}}
|
||||
]
|
||||
"""
|
||||
|
||||
# 调用LLM
|
||||
provider = self.context.get_using_provider()
|
||||
if not provider:
|
||||
logger.warning("未配置LLM提供商,跳过金句分析")
|
||||
return [], TokenUsage()
|
||||
|
||||
response = await provider.text_chat(
|
||||
prompt=prompt,
|
||||
max_tokens=1500,
|
||||
temperature=0.7
|
||||
)
|
||||
|
||||
# 提取token使用统计
|
||||
token_usage = TokenUsage()
|
||||
if response.raw_completion and hasattr(response.raw_completion, 'usage'):
|
||||
usage = response.raw_completion.usage
|
||||
token_usage.prompt_tokens = usage.prompt_tokens
|
||||
token_usage.completion_tokens = usage.completion_tokens
|
||||
token_usage.total_tokens = usage.total_tokens
|
||||
|
||||
# 解析响应
|
||||
if hasattr(response, 'completion_text'):
|
||||
result_text = response.completion_text
|
||||
else:
|
||||
result_text = str(response)
|
||||
|
||||
# 尝试解析JSON
|
||||
try:
|
||||
json_match = re.search(r'\[.*\]', result_text, re.DOTALL)
|
||||
if json_match:
|
||||
quotes_data = json.loads(json_match.group())
|
||||
return [GoldenQuote(**quote) for quote in quotes_data[:max_golden_quotes]], token_usage
|
||||
except:
|
||||
pass
|
||||
|
||||
return [], token_usage
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"金句分析失败: {e}")
|
||||
return [], TokenUsage()
|
||||
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
统计分析模块
|
||||
负责用户活跃度分析和其他统计功能
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Dict
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
class UserAnalyzer:
|
||||
"""用户分析器"""
|
||||
|
||||
def __init__(self, config_manager):
|
||||
self.config_manager = config_manager
|
||||
|
||||
def analyze_users(self, messages: List[Dict]) -> Dict[str, Dict]:
|
||||
"""分析用户活跃度"""
|
||||
user_stats = defaultdict(lambda: {
|
||||
"message_count": 0,
|
||||
"char_count": 0,
|
||||
"emoji_count": 0,
|
||||
"nickname": "",
|
||||
"hours": defaultdict(int),
|
||||
"reply_count": 0
|
||||
})
|
||||
|
||||
for msg in messages:
|
||||
sender = msg.get("sender", {})
|
||||
user_id = str(sender.get("user_id", ""))
|
||||
nickname = sender.get("nickname", "") or sender.get("card", "")
|
||||
|
||||
user_stats[user_id]["message_count"] += 1
|
||||
user_stats[user_id]["nickname"] = nickname
|
||||
|
||||
# 统计时间分布
|
||||
msg_time = datetime.fromtimestamp(msg.get("time", 0))
|
||||
user_stats[user_id]["hours"][msg_time.hour] += 1
|
||||
|
||||
# 处理消息内容
|
||||
for content in msg.get("message", []):
|
||||
if content.get("type") == "text":
|
||||
text = content.get("data", {}).get("text", "")
|
||||
user_stats[user_id]["char_count"] += len(text)
|
||||
elif content.get("type") == "face":
|
||||
user_stats[user_id]["emoji_count"] += 1
|
||||
elif content.get("type") == "reply":
|
||||
user_stats[user_id]["reply_count"] += 1
|
||||
|
||||
return dict(user_stats)
|
||||
|
||||
def get_top_users(self, user_analysis: Dict[str, Dict], limit: int = 10) -> List[Dict]:
|
||||
"""获取最活跃的用户"""
|
||||
users = []
|
||||
for user_id, stats in user_analysis.items():
|
||||
users.append({
|
||||
"user_id": user_id,
|
||||
"nickname": stats["nickname"],
|
||||
"message_count": stats["message_count"],
|
||||
"char_count": stats["char_count"],
|
||||
"emoji_count": stats["emoji_count"],
|
||||
"reply_count": stats["reply_count"]
|
||||
})
|
||||
|
||||
# 按消息数量排序
|
||||
users.sort(key=lambda x: x["message_count"], reverse=True)
|
||||
return users[:limit]
|
||||
|
||||
def get_user_activity_pattern(self, user_analysis: Dict[str, Dict], user_id: str) -> Dict:
|
||||
"""获取用户活动模式"""
|
||||
if user_id not in user_analysis:
|
||||
return {}
|
||||
|
||||
stats = user_analysis[user_id]
|
||||
hours = stats["hours"]
|
||||
|
||||
# 找出最活跃的时间段
|
||||
most_active_hour = max(hours.items(), key=lambda x: x[1])[0] if hours else 0
|
||||
|
||||
# 计算夜间活跃度
|
||||
night_messages = sum(hours[h] for h in range(0, 6))
|
||||
night_ratio = night_messages / stats["message_count"] if stats["message_count"] > 0 else 0
|
||||
|
||||
return {
|
||||
"most_active_hour": most_active_hour,
|
||||
"night_ratio": night_ratio,
|
||||
"hourly_distribution": dict(hours)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
核心功能模块
|
||||
"""
|
||||
|
||||
from .config import ConfigManager
|
||||
from .message_handler import MessageHandler
|
||||
|
||||
__all__ = [
|
||||
'ConfigManager',
|
||||
'MessageHandler'
|
||||
]
|
||||
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
配置管理模块
|
||||
负责处理插件配置和PDF依赖检查
|
||||
"""
|
||||
|
||||
import sys
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
from astrbot.api import logger, AstrBotConfig
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
"""配置管理器"""
|
||||
|
||||
def __init__(self, config: AstrBotConfig):
|
||||
self.config = config
|
||||
self._pyppeteer_available = False
|
||||
self._pyppeteer_version = None
|
||||
self._check_pyppeteer_availability()
|
||||
|
||||
def get_enabled_groups(self) -> List[str]:
|
||||
"""获取启用的群组列表"""
|
||||
return self.config.get("enabled_groups", [])
|
||||
|
||||
def get_max_messages(self) -> int:
|
||||
"""获取最大消息数量"""
|
||||
return self.config.get("max_messages", 1000)
|
||||
|
||||
def get_analysis_days(self) -> int:
|
||||
"""获取分析天数"""
|
||||
return self.config.get("analysis_days", 1)
|
||||
|
||||
def get_auto_analysis_time(self) -> str:
|
||||
"""获取自动分析时间"""
|
||||
return self.config.get("auto_analysis_time", "09:00")
|
||||
|
||||
def get_enable_auto_analysis(self) -> bool:
|
||||
"""获取是否启用自动分析"""
|
||||
return self.config.get("enable_auto_analysis", False)
|
||||
|
||||
def get_output_format(self) -> str:
|
||||
"""获取输出格式"""
|
||||
return self.config.get("output_format", "image")
|
||||
|
||||
def get_min_messages_threshold(self) -> int:
|
||||
"""获取最小消息阈值"""
|
||||
return self.config.get("min_messages_threshold", 50)
|
||||
|
||||
def get_topic_analysis_enabled(self) -> bool:
|
||||
"""获取是否启用话题分析"""
|
||||
return self.config.get("topic_analysis_enabled", True)
|
||||
|
||||
def get_user_title_analysis_enabled(self) -> bool:
|
||||
"""获取是否启用用户称号分析"""
|
||||
return self.config.get("user_title_analysis_enabled", True)
|
||||
|
||||
def get_max_topics(self) -> int:
|
||||
"""获取最大话题数量"""
|
||||
return self.config.get("max_topics", 5)
|
||||
|
||||
def get_max_user_titles(self) -> int:
|
||||
"""获取最大用户称号数量"""
|
||||
return self.config.get("max_user_titles", 8)
|
||||
|
||||
def get_max_golden_quotes(self) -> int:
|
||||
"""获取最大金句数量"""
|
||||
return self.config.get("max_golden_quotes", 5)
|
||||
|
||||
def get_max_query_rounds(self) -> int:
|
||||
"""获取最大查询轮数"""
|
||||
return self.config.get("max_query_rounds", 35)
|
||||
|
||||
def get_pdf_output_dir(self) -> str:
|
||||
"""获取PDF输出目录"""
|
||||
return self.config.get("pdf_output_dir", "data/plugins/astrbot-qq-group-daily-analysis/reports")
|
||||
|
||||
def get_pdf_filename_format(self) -> str:
|
||||
"""获取PDF文件名格式"""
|
||||
return self.config.get("pdf_filename_format", "群聊分析报告_{group_id}_{date}.pdf")
|
||||
|
||||
def set_output_format(self, format_type: str):
|
||||
"""设置输出格式"""
|
||||
self.config["output_format"] = format_type
|
||||
self.config.save_config()
|
||||
|
||||
def set_enabled_groups(self, groups: List[str]):
|
||||
"""设置启用的群组列表"""
|
||||
self.config["enabled_groups"] = groups
|
||||
self.config.save_config()
|
||||
|
||||
def set_max_messages(self, count: int):
|
||||
"""设置最大消息数量"""
|
||||
self.config["max_messages"] = count
|
||||
self.config.save_config()
|
||||
|
||||
def set_analysis_days(self, days: int):
|
||||
"""设置分析天数"""
|
||||
self.config["analysis_days"] = days
|
||||
self.config.save_config()
|
||||
|
||||
def set_auto_analysis_time(self, time_str: str):
|
||||
"""设置自动分析时间"""
|
||||
self.config["auto_analysis_time"] = time_str
|
||||
self.config.save_config()
|
||||
|
||||
def set_enable_auto_analysis(self, enabled: bool):
|
||||
"""设置是否启用自动分析"""
|
||||
self.config["enable_auto_analysis"] = enabled
|
||||
self.config.save_config()
|
||||
|
||||
def set_min_messages_threshold(self, threshold: int):
|
||||
"""设置最小消息阈值"""
|
||||
self.config["min_messages_threshold"] = threshold
|
||||
self.config.save_config()
|
||||
|
||||
def set_topic_analysis_enabled(self, enabled: bool):
|
||||
"""设置是否启用话题分析"""
|
||||
self.config["topic_analysis_enabled"] = enabled
|
||||
self.config.save_config()
|
||||
|
||||
def set_user_title_analysis_enabled(self, enabled: bool):
|
||||
"""设置是否启用用户称号分析"""
|
||||
self.config["user_title_analysis_enabled"] = enabled
|
||||
self.config.save_config()
|
||||
|
||||
def set_max_topics(self, count: int):
|
||||
"""设置最大话题数量"""
|
||||
self.config["max_topics"] = count
|
||||
self.config.save_config()
|
||||
|
||||
def set_max_user_titles(self, count: int):
|
||||
"""设置最大用户称号数量"""
|
||||
self.config["max_user_titles"] = count
|
||||
self.config.save_config()
|
||||
|
||||
def set_max_golden_quotes(self, count: int):
|
||||
"""设置最大金句数量"""
|
||||
self.config["max_golden_quotes"] = count
|
||||
self.config.save_config()
|
||||
|
||||
def set_max_query_rounds(self, rounds: int):
|
||||
"""设置最大查询轮数"""
|
||||
self.config["max_query_rounds"] = rounds
|
||||
self.config.save_config()
|
||||
|
||||
def set_pdf_output_dir(self, directory: str):
|
||||
"""设置PDF输出目录"""
|
||||
self.config["pdf_output_dir"] = directory
|
||||
self.config.save_config()
|
||||
|
||||
def set_pdf_filename_format(self, format_str: str):
|
||||
"""设置PDF文件名格式"""
|
||||
self.config["pdf_filename_format"] = format_str
|
||||
self.config.save_config()
|
||||
|
||||
def add_enabled_group(self, group_id: str):
|
||||
"""添加启用的群组"""
|
||||
enabled_groups = self.get_enabled_groups()
|
||||
if group_id not in enabled_groups:
|
||||
enabled_groups.append(group_id)
|
||||
self.config["enabled_groups"] = enabled_groups
|
||||
self.config.save_config()
|
||||
|
||||
def remove_enabled_group(self, group_id: str):
|
||||
"""移除启用的群组"""
|
||||
enabled_groups = self.get_enabled_groups()
|
||||
if group_id in enabled_groups:
|
||||
enabled_groups.remove(group_id)
|
||||
self.config["enabled_groups"] = enabled_groups
|
||||
self.config.save_config()
|
||||
|
||||
@property
|
||||
def pyppeteer_available(self) -> bool:
|
||||
"""检查pyppeteer是否可用"""
|
||||
return self._pyppeteer_available
|
||||
|
||||
@property
|
||||
def pyppeteer_version(self) -> Optional[str]:
|
||||
"""获取pyppeteer版本"""
|
||||
return self._pyppeteer_version
|
||||
|
||||
def _check_pyppeteer_availability(self):
|
||||
"""检查 pyppeteer 可用性"""
|
||||
try:
|
||||
import pyppeteer
|
||||
from pyppeteer import launch
|
||||
self._pyppeteer_available = True
|
||||
|
||||
# 检查版本
|
||||
try:
|
||||
self._pyppeteer_version = pyppeteer.__version__
|
||||
logger.info(f"使用 pyppeteer {self._pyppeteer_version} 作为 PDF 引擎")
|
||||
except AttributeError:
|
||||
self._pyppeteer_version = "unknown"
|
||||
logger.info("使用 pyppeteer (版本未知) 作为 PDF 引擎")
|
||||
|
||||
except ImportError:
|
||||
self._pyppeteer_available = False
|
||||
self._pyppeteer_version = None
|
||||
logger.warning("pyppeteer 未安装,PDF 功能将不可用。请使用 /安装PDF 命令安装 pyppeteer==1.0.2")
|
||||
|
||||
def reload_pyppeteer(self) -> bool:
|
||||
"""重新加载 pyppeteer 模块"""
|
||||
try:
|
||||
logger.info("开始重新加载 pyppeteer 模块...")
|
||||
|
||||
# 移除所有 pyppeteer 相关模块
|
||||
modules_to_remove = [mod for mod in sys.modules.keys() if mod.startswith('pyppeteer')]
|
||||
logger.info(f"移除模块: {modules_to_remove}")
|
||||
for mod in modules_to_remove:
|
||||
del sys.modules[mod]
|
||||
|
||||
# 强制重新导入
|
||||
try:
|
||||
import pyppeteer
|
||||
from pyppeteer import launch
|
||||
|
||||
# 更新全局变量
|
||||
self._pyppeteer_available = True
|
||||
try:
|
||||
self._pyppeteer_version = pyppeteer.__version__
|
||||
logger.info(f"重新加载成功,pyppeteer 版本: {self._pyppeteer_version}")
|
||||
except AttributeError:
|
||||
self._pyppeteer_version = "unknown"
|
||||
logger.info("重新加载成功,pyppeteer 版本未知")
|
||||
|
||||
return True
|
||||
|
||||
except ImportError as e:
|
||||
logger.info(f"pyppeteer 重新导入需要重启 AstrBot 才能生效")
|
||||
logger.info("💡 提示:pyppeteer 安装成功,但需要重启 AstrBot 后才能使用 PDF 功能")
|
||||
self._pyppeteer_available = False
|
||||
self._pyppeteer_version = None
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.info(f"pyppeteer 重新导入需要重启 AstrBot 才能生效")
|
||||
logger.info("💡 提示:pyppeteer 安装成功,但需要重启 AstrBot 后才能使用 PDF 功能")
|
||||
self._pyppeteer_available = False
|
||||
self._pyppeteer_version = None
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"重新加载 pyppeteer 时出错: {e}")
|
||||
return False
|
||||
|
||||
def save_config(self):
|
||||
"""保存配置到AstrBot配置系统"""
|
||||
try:
|
||||
self.config.save_config()
|
||||
logger.info("配置已保存")
|
||||
except Exception as e:
|
||||
logger.error(f"保存配置失败: {e}")
|
||||
|
||||
def reload_config(self):
|
||||
"""重新加载配置"""
|
||||
try:
|
||||
# 重新从AstrBot配置系统读取所有配置
|
||||
logger.info("重新加载配置...")
|
||||
# 配置会自动从self.config中重新读取
|
||||
logger.info("配置重载完成")
|
||||
except Exception as e:
|
||||
logger.error(f"重新加载配置失败: {e}")
|
||||
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
消息处理模块
|
||||
负责群聊消息的获取、过滤和预处理
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
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
|
||||
|
||||
|
||||
class MessageHandler:
|
||||
"""消息处理器"""
|
||||
|
||||
def __init__(self, config_manager):
|
||||
self.config_manager = config_manager
|
||||
self.bot_qq_id = None
|
||||
|
||||
async def set_bot_qq_id(self, bot_instance):
|
||||
"""设置机器人QQ号"""
|
||||
try:
|
||||
if bot_instance and not self.bot_qq_id:
|
||||
login_info = await bot_instance.api.call_action("get_login_info")
|
||||
self.bot_qq_id = str(login_info.get("user_id", ""))
|
||||
logger.info(f"获取到机器人QQ号: {self.bot_qq_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"获取机器人QQ号失败: {e}")
|
||||
|
||||
async def fetch_group_messages(self, bot_instance, group_id: str, days: int) -> List[Dict]:
|
||||
"""获取群聊消息记录"""
|
||||
try:
|
||||
if not bot_instance or not group_id:
|
||||
logger.error(f"群 {group_id} 无效的客户端或群组ID")
|
||||
return []
|
||||
|
||||
# 计算时间范围
|
||||
end_time = datetime.now()
|
||||
start_time = end_time - timedelta(days=days)
|
||||
|
||||
messages = []
|
||||
message_seq = 0
|
||||
query_rounds = 0
|
||||
max_rounds = self.config_manager.get_max_query_rounds()
|
||||
max_messages = self.config_manager.get_max_messages()
|
||||
consecutive_failures = 0
|
||||
max_failures = 3
|
||||
|
||||
logger.info(f"开始获取群 {group_id} 近 {days} 天的消息记录")
|
||||
logger.info(f"时间范围: {start_time.strftime('%Y-%m-%d %H:%M:%S')} 到 {end_time.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
|
||||
while len(messages) < max_messages and query_rounds < max_rounds:
|
||||
try:
|
||||
payloads = {
|
||||
"group_id": group_id,
|
||||
"message_seq": message_seq,
|
||||
"count": 200,
|
||||
"reverseOrder": True,
|
||||
}
|
||||
|
||||
result = await bot_instance.api.call_action("get_group_msg_history", **payloads)
|
||||
|
||||
if not result or "messages" not in result:
|
||||
logger.warning(f"群 {group_id} API返回无效结果: {result}")
|
||||
consecutive_failures += 1
|
||||
if consecutive_failures >= max_failures:
|
||||
break
|
||||
continue
|
||||
|
||||
round_messages = result.get("messages", [])
|
||||
|
||||
if not round_messages:
|
||||
logger.info(f"群 {group_id} 没有更多消息,结束获取")
|
||||
break
|
||||
|
||||
# 重置失败计数
|
||||
consecutive_failures = 0
|
||||
|
||||
# 过滤时间范围内的消息
|
||||
valid_messages_in_round = 0
|
||||
oldest_msg_time = None
|
||||
|
||||
for msg in round_messages:
|
||||
try:
|
||||
msg_time = datetime.fromtimestamp(msg.get("time", 0))
|
||||
oldest_msg_time = msg_time
|
||||
|
||||
# 过滤掉机器人自己的消息
|
||||
sender_id = str(msg.get("sender", {}).get("user_id", ""))
|
||||
if self.bot_qq_id and sender_id == self.bot_qq_id:
|
||||
continue
|
||||
|
||||
if msg_time >= start_time and msg_time <= end_time:
|
||||
messages.append(msg)
|
||||
valid_messages_in_round += 1
|
||||
except Exception as msg_error:
|
||||
logger.warning(f"群 {group_id} 处理单条消息失败: {msg_error}")
|
||||
continue
|
||||
|
||||
# 如果最老的消息时间已经超出范围,停止获取
|
||||
if oldest_msg_time and oldest_msg_time < start_time:
|
||||
logger.info(f"群 {group_id} 已获取到时间范围外的消息,停止获取。共获取 {len(messages)} 条消息")
|
||||
break
|
||||
|
||||
if valid_messages_in_round == 0:
|
||||
logger.warning(f"群 {group_id} 本轮未获取到有效消息")
|
||||
break
|
||||
|
||||
message_seq = round_messages[0]["message_id"]
|
||||
query_rounds += 1
|
||||
|
||||
# 添加延迟避免请求过快
|
||||
if query_rounds % 5 == 0:
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"群 {group_id} 获取消息失败 (第{query_rounds+1}轮): {e}")
|
||||
consecutive_failures += 1
|
||||
if consecutive_failures >= max_failures:
|
||||
logger.error(f"群 {group_id} 连续失败 {max_failures} 次,停止获取")
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
|
||||
logger.info(f"群 {group_id} 消息获取完成,共获取 {len(messages)} 条消息,查询轮数: {query_rounds}")
|
||||
return messages
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"群 {group_id} 获取群聊消息记录失败: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
def calculate_statistics(self, messages: List[Dict]) -> GroupStatistics:
|
||||
"""计算基础统计数据"""
|
||||
total_chars = 0
|
||||
participants = set()
|
||||
hour_counts = defaultdict(int)
|
||||
emoji_count = 0
|
||||
|
||||
for msg in messages:
|
||||
sender_id = str(msg.get("sender", {}).get("user_id", ""))
|
||||
participants.add(sender_id)
|
||||
|
||||
# 统计时间分布
|
||||
msg_time = datetime.fromtimestamp(msg.get("time", 0))
|
||||
hour_counts[msg_time.hour] += 1
|
||||
|
||||
# 处理消息内容
|
||||
for content in msg.get("message", []):
|
||||
if content.get("type") == "text":
|
||||
text = content.get("data", {}).get("text", "")
|
||||
total_chars += len(text)
|
||||
elif content.get("type") == "face":
|
||||
emoji_count += 1
|
||||
|
||||
# 找出最活跃时段
|
||||
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"
|
||||
|
||||
return GroupStatistics(
|
||||
message_count=len(messages),
|
||||
total_characters=total_chars,
|
||||
participant_count=len(participants),
|
||||
most_active_period=most_active_period,
|
||||
golden_quotes=[],
|
||||
emoji_count=emoji_count,
|
||||
token_usage=TokenUsage()
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
数据模型模块
|
||||
"""
|
||||
|
||||
from .data_models import (
|
||||
SummaryTopic,
|
||||
UserTitle,
|
||||
GoldenQuote,
|
||||
TokenUsage,
|
||||
GroupStatistics
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'SummaryTopic',
|
||||
'UserTitle',
|
||||
'GoldenQuote',
|
||||
'TokenUsage',
|
||||
'GroupStatistics'
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
数据模型定义
|
||||
包含所有分析相关的数据结构
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List
|
||||
|
||||
|
||||
@dataclass
|
||||
class SummaryTopic:
|
||||
"""话题总结数据结构"""
|
||||
topic: str
|
||||
contributors: List[str]
|
||||
detail: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserTitle:
|
||||
"""用户称号数据结构"""
|
||||
name: str
|
||||
qq: int
|
||||
title: str
|
||||
mbti: str
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class GoldenQuote:
|
||||
"""群聊金句数据结构"""
|
||||
content: str
|
||||
sender: str
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class TokenUsage:
|
||||
"""Token使用统计"""
|
||||
prompt_tokens: int = 0
|
||||
completion_tokens: int = 0
|
||||
total_tokens: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class GroupStatistics:
|
||||
"""群聊统计数据结构"""
|
||||
message_count: int
|
||||
total_characters: int
|
||||
participant_count: int
|
||||
most_active_period: str
|
||||
golden_quotes: List[GoldenQuote]
|
||||
emoji_count: int
|
||||
token_usage: TokenUsage = field(default_factory=TokenUsage)
|
||||
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
报告生成模块
|
||||
包含HTML、PDF、文本报告生成功能
|
||||
"""
|
||||
|
||||
from .generators import ReportGenerator
|
||||
from .templates import HTMLTemplates
|
||||
|
||||
__all__ = [
|
||||
'ReportGenerator',
|
||||
'HTMLTemplates'
|
||||
]
|
||||
@@ -0,0 +1,323 @@
|
||||
"""
|
||||
报告生成器模块
|
||||
负责生成各种格式的分析报告
|
||||
"""
|
||||
|
||||
import base64
|
||||
import aiohttp
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional
|
||||
from pathlib import Path
|
||||
from astrbot.api import logger
|
||||
from .templates import HTMLTemplates
|
||||
|
||||
|
||||
class ReportGenerator:
|
||||
"""报告生成器"""
|
||||
|
||||
def __init__(self, config_manager):
|
||||
self.config_manager = config_manager
|
||||
|
||||
async def generate_image_report(self, analysis_result: Dict, group_id: str, html_render_func) -> Optional[str]:
|
||||
"""生成图片格式的分析报告"""
|
||||
try:
|
||||
# 准备渲染数据
|
||||
render_payload = await self._prepare_render_data(analysis_result)
|
||||
|
||||
# 使用AstrBot内置的HTML渲染服务(直接传递模板和数据)
|
||||
image_url = await html_render_func(HTMLTemplates.get_image_template(), render_payload)
|
||||
return image_url
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"生成图片报告失败: {e}")
|
||||
return None
|
||||
|
||||
async def generate_pdf_report(self, analysis_result: Dict, group_id: str) -> Optional[str]:
|
||||
"""生成PDF格式的分析报告"""
|
||||
try:
|
||||
# 确保输出目录存在
|
||||
output_dir = Path(self.config_manager.get_pdf_output_dir())
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 生成文件名
|
||||
current_date = datetime.now().strftime('%Y%m%d')
|
||||
filename = self.config_manager.get_pdf_filename_format().format(
|
||||
group_id=group_id,
|
||||
date=current_date
|
||||
)
|
||||
pdf_path = output_dir / filename
|
||||
|
||||
# 准备渲染数据
|
||||
render_data = await self._prepare_render_data(analysis_result)
|
||||
logger.info(f"PDF 渲染数据准备完成,包含 {len(render_data)} 个字段")
|
||||
|
||||
# 生成 HTML 内容(PDF模板使用{}占位符)
|
||||
html_content = self._render_html_template(HTMLTemplates.get_pdf_template(), render_data, use_jinja_style=False)
|
||||
logger.info(f"HTML 内容生成完成,长度: {len(html_content)} 字符")
|
||||
|
||||
# 转换为 PDF
|
||||
success = await self._html_to_pdf(html_content, str(pdf_path))
|
||||
|
||||
if success:
|
||||
return str(pdf_path.absolute())
|
||||
else:
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"生成 PDF 报告失败: {e}")
|
||||
return None
|
||||
|
||||
def generate_text_report(self, analysis_result: Dict) -> str:
|
||||
"""生成文本格式的分析报告"""
|
||||
stats = analysis_result["statistics"]
|
||||
topics = analysis_result["topics"]
|
||||
user_titles = analysis_result["user_titles"]
|
||||
|
||||
report = f"""
|
||||
🎯 群聊日常分析报告
|
||||
📅 {datetime.now().strftime('%Y年%m月%d日')}
|
||||
|
||||
📊 基础统计
|
||||
• 消息总数: {stats.message_count}
|
||||
• 参与人数: {stats.participant_count}
|
||||
• 总字符数: {stats.total_characters}
|
||||
• 表情数量: {stats.emoji_count}
|
||||
• 最活跃时段: {stats.most_active_period}
|
||||
|
||||
💬 热门话题
|
||||
"""
|
||||
|
||||
max_topics = self.config_manager.get_max_topics()
|
||||
for i, topic in enumerate(topics[:max_topics], 1):
|
||||
contributors_str = "、".join(topic.contributors)
|
||||
report += f"{i}. {topic.topic}\n"
|
||||
report += f" 参与者: {contributors_str}\n"
|
||||
report += f" {topic.detail}\n\n"
|
||||
|
||||
report += "🏆 群友称号\n"
|
||||
max_user_titles = self.config_manager.get_max_user_titles()
|
||||
for title in user_titles[:max_user_titles]:
|
||||
report += f"• {title.name} - {title.title} ({title.mbti})\n"
|
||||
report += f" {title.reason}\n\n"
|
||||
|
||||
report += "💬 群圣经\n"
|
||||
max_golden_quotes = self.config_manager.get_max_golden_quotes()
|
||||
for i, quote in enumerate(stats.golden_quotes[:max_golden_quotes], 1):
|
||||
report += f"{i}. \"{quote.content}\" —— {quote.sender}\n"
|
||||
report += f" {quote.reason}\n\n"
|
||||
|
||||
return report
|
||||
|
||||
async def _prepare_render_data(self, analysis_result: Dict) -> Dict:
|
||||
"""准备渲染数据"""
|
||||
stats = analysis_result["statistics"]
|
||||
topics = analysis_result["topics"]
|
||||
user_titles = analysis_result["user_titles"]
|
||||
|
||||
# 构建话题HTML
|
||||
topics_html = ""
|
||||
max_topics = self.config_manager.get_max_topics()
|
||||
for i, topic in enumerate(topics[:max_topics], 1):
|
||||
contributors_str = "、".join(topic.contributors)
|
||||
topics_html += f"""
|
||||
<div class="topic-item">
|
||||
<div class="topic-header">
|
||||
<span class="topic-number">{i}</span>
|
||||
<span class="topic-title">{topic.topic}</span>
|
||||
</div>
|
||||
<div class="topic-contributors">参与者: {contributors_str}</div>
|
||||
<div class="topic-detail">{topic.detail}</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
# 构建用户称号HTML(包含头像)
|
||||
titles_html = ""
|
||||
max_user_titles = self.config_manager.get_max_user_titles()
|
||||
for title in user_titles[:max_user_titles]:
|
||||
# 获取用户头像
|
||||
avatar_data = await self._get_user_avatar(str(title.qq))
|
||||
avatar_html = f'<img src="{avatar_data}" class="user-avatar" alt="头像">' if avatar_data else '<div class="user-avatar-placeholder">👤</div>'
|
||||
|
||||
titles_html += f"""
|
||||
<div class="user-title">
|
||||
<div class="user-info">
|
||||
{avatar_html}
|
||||
<div class="user-details">
|
||||
<div class="user-name">{title.name}</div>
|
||||
<div class="user-badges">
|
||||
<div class="user-title-badge">{title.title}</div>
|
||||
<div class="user-mbti">{title.mbti}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-reason">{title.reason}</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
# 构建金句HTML
|
||||
quotes_html = ""
|
||||
max_golden_quotes = self.config_manager.get_max_golden_quotes()
|
||||
for quote in stats.golden_quotes[:max_golden_quotes]:
|
||||
quotes_html += f"""
|
||||
<div class="quote-item">
|
||||
<div class="quote-content">"{quote.content}"</div>
|
||||
<div class="quote-author">—— {quote.sender}</div>
|
||||
<div class="quote-reason">{quote.reason}</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
# 返回扁平化的渲染数据
|
||||
return {
|
||||
"current_date": datetime.now().strftime('%Y年%m月%d日'),
|
||||
"current_datetime": datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||
"message_count": stats.message_count,
|
||||
"participant_count": stats.participant_count,
|
||||
"total_characters": stats.total_characters,
|
||||
"emoji_count": stats.emoji_count,
|
||||
"most_active_period": stats.most_active_period,
|
||||
"topics_html": topics_html,
|
||||
"titles_html": titles_html,
|
||||
"quotes_html": quotes_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模板渲染,支持两种占位符格式
|
||||
|
||||
Args:
|
||||
template: HTML模板字符串
|
||||
data: 渲染数据
|
||||
use_jinja_style: 是否使用Jinja2风格的{{ }}占位符,否则使用{}占位符
|
||||
"""
|
||||
result = template
|
||||
|
||||
# 调试:记录渲染数据
|
||||
logger.info(f"渲染数据键: {list(data.keys())}, 使用Jinja风格: {use_jinja_style}")
|
||||
|
||||
for key, value in data.items():
|
||||
if use_jinja_style:
|
||||
# 图片模板使用{{ }}占位符
|
||||
placeholder = f"{{{{ {key} }}}}"
|
||||
else:
|
||||
# PDF模板使用{}占位符
|
||||
placeholder = f"{{{key}}}"
|
||||
|
||||
# 调试:记录替换过程
|
||||
if placeholder in result:
|
||||
logger.debug(f"替换 {placeholder} -> {str(value)[:100]}...")
|
||||
result = result.replace(placeholder, str(value))
|
||||
|
||||
# 检查是否还有未替换的占位符
|
||||
import re
|
||||
if use_jinja_style:
|
||||
remaining_placeholders = re.findall(r'\{\{[^}]+\}\}', result)
|
||||
else:
|
||||
remaining_placeholders = re.findall(r'\{[^}]+\}', result)
|
||||
|
||||
if remaining_placeholders:
|
||||
logger.warning(f"未替换的占位符: {remaining_placeholders[:10]}")
|
||||
|
||||
return result
|
||||
|
||||
async def _get_user_avatar(self, user_id: str) -> Optional[str]:
|
||||
"""获取用户头像的base64编码"""
|
||||
try:
|
||||
avatar_url = f"https://q4.qlogo.cn/headimg_dl?dst_uin={user_id}&spec=640"
|
||||
async with aiohttp.ClientSession() as client:
|
||||
response = await client.get(avatar_url)
|
||||
response.raise_for_status()
|
||||
avatar_data = await response.read()
|
||||
# 转换为base64编码
|
||||
avatar_base64 = base64.b64encode(avatar_data).decode('utf-8')
|
||||
return f"data:image/jpeg;base64,{avatar_base64}"
|
||||
except Exception as e:
|
||||
logger.error(f"获取用户头像失败 {user_id}: {e}")
|
||||
return None
|
||||
|
||||
async def _html_to_pdf(self, html_content: str, output_path: str) -> bool:
|
||||
"""将 HTML 内容转换为 PDF 文件"""
|
||||
try:
|
||||
# 确保 pyppeteer 可用
|
||||
if not self.config_manager.pyppeteer_available:
|
||||
logger.error("pyppeteer 不可用,无法生成 PDF")
|
||||
return False
|
||||
|
||||
# 动态导入 pyppeteer
|
||||
import pyppeteer
|
||||
from pyppeteer import launch
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 尝试启动浏览器,如果 Chromium 不存在会自动下载
|
||||
logger.info("启动浏览器进行 PDF 转换")
|
||||
|
||||
# 配置浏览器启动参数,避免 Chromium 下载问题
|
||||
launch_options = {
|
||||
'headless': True,
|
||||
'args': [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu',
|
||||
'--no-first-run',
|
||||
'--disable-extensions',
|
||||
'--disable-default-apps'
|
||||
]
|
||||
}
|
||||
|
||||
# 如果是 Windows 系统,尝试使用系统 Chrome
|
||||
if sys.platform.startswith('win'):
|
||||
# 常见的 Chrome 安装路径
|
||||
chrome_paths = [
|
||||
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
||||
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
|
||||
r"C:\Users\{}\AppData\Local\Google\Chrome\Application\chrome.exe".format(os.environ.get('USERNAME', '')),
|
||||
]
|
||||
|
||||
for chrome_path in chrome_paths:
|
||||
if Path(chrome_path).exists():
|
||||
launch_options['executablePath'] = chrome_path
|
||||
logger.info(f"使用系统 Chrome: {chrome_path}")
|
||||
break
|
||||
|
||||
browser = await launch(**launch_options)
|
||||
page = await browser.newPage()
|
||||
|
||||
# 设置页面内容 (pyppeteer 1.0.2 版本的 API)
|
||||
await page.setContent(html_content)
|
||||
# 等待页面加载完成
|
||||
try:
|
||||
await page.waitForSelector('body', {'timeout': 10000})
|
||||
except Exception:
|
||||
# 如果等待失败,继续执行(可能页面已经加载完成)
|
||||
pass
|
||||
|
||||
# 导出 PDF
|
||||
await page.pdf({
|
||||
'path': output_path,
|
||||
'format': 'A4',
|
||||
'printBackground': True,
|
||||
'margin': {
|
||||
'top': '10mm',
|
||||
'right': '10mm',
|
||||
'bottom': '10mm',
|
||||
'left': '10mm'
|
||||
},
|
||||
'scale': 0.8
|
||||
})
|
||||
|
||||
await browser.close()
|
||||
logger.info(f"PDF 生成成功: {output_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if "Chromium downloadable not found" in error_msg:
|
||||
logger.error("Chromium 下载失败,建议安装 pyppeteer2 或使用系统 Chrome")
|
||||
elif "No usable sandbox" in error_msg:
|
||||
logger.error("沙盒权限问题,已尝试禁用沙盒")
|
||||
else:
|
||||
logger.error(f"HTML 转 PDF 失败: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
HTML模板模块
|
||||
严格按照main-backup中的实现,包含图片报告和PDF报告的不同HTML模板
|
||||
"""
|
||||
|
||||
|
||||
class HTMLTemplates:
|
||||
"""HTML模板管理类"""
|
||||
|
||||
@staticmethod
|
||||
def get_image_template() -> str:
|
||||
"""获取图片报告的HTML模板(使用{{ }}占位符)"""
|
||||
return """<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>群聊日常分析报告</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Noto Sans SC', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
min-height: 100vh; padding: 20px; line-height: 1.6; color: #1a1a1a;
|
||||
}
|
||||
.container { max-width: 1200px; margin: 0 auto; background: #ffffff; border-radius: 16px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08); overflow: hidden; }
|
||||
.header { background: linear-gradient(135deg, #4299e1 0%, #667eea 100%); color: #ffffff; padding: 48px 40px; text-align: center; border-radius: 24px 24px 0 0; }
|
||||
.header h1 { font-size: 2.5em; font-weight: 300; margin-bottom: 12px; letter-spacing: -1px; }
|
||||
.header .date { font-size: 1em; opacity: 0.8; font-weight: 300; letter-spacing: 0.5px; }
|
||||
.content { padding: 32px; }
|
||||
.topics-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; margin-bottom: 32px; align-items: start; }
|
||||
.users-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin-bottom: 32px; align-items: start; }
|
||||
.section { margin-bottom: 0; }
|
||||
.full-width-section { grid-column: 1 / -1; margin-bottom: 32px; }
|
||||
.section-title { font-size: 1.3em; font-weight: 600; margin-bottom: 20px; color: #4a5568; letter-spacing: -0.3px; display: flex; align-items: center; gap: 8px; border-bottom: 2px solid #e2e8f0; padding-bottom: 8px; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin-bottom: 32px; }
|
||||
.stat-card { background: linear-gradient(135deg, #f7fafc 0%, #edf2f7 100%); padding: 32px 24px; text-align: center; border-radius: 20px; border: 1px solid #e2e8f0; transition: all 0.3s ease; }
|
||||
.stat-card:hover { background: linear-gradient(135deg, #ffffff 0%, #f7fafc 100%); transform: translateY(-4px); box-shadow: 0 12px 32px rgba(102, 126, 234, 0.15); }
|
||||
.stat-number { font-size: 2.5em; font-weight: 300; color: #4299e1; margin-bottom: 8px; display: block; letter-spacing: -1px; }
|
||||
.stat-label { font-size: 0.8em; color: #666666; font-weight: 400; text-transform: uppercase; letter-spacing: 1px; }
|
||||
.active-period { background: linear-gradient(135deg, #4299e1 0%, #667eea 100%); color: #ffffff; padding: 32px; text-align: center; margin: 48px 0; border-radius: 20px; box-shadow: 0 8px 24px rgba(66, 153, 225, 0.3); }
|
||||
.active-period .time { font-size: 2.5em; font-weight: 200; margin-bottom: 8px; letter-spacing: -1px; }
|
||||
.active-period .label { font-size: 0.8em; opacity: 0.8; font-weight: 300; text-transform: uppercase; letter-spacing: 1px; }
|
||||
.topic-item { background: #ffffff; padding: 20px; margin-bottom: 0; border-radius: 12px; border: 1px solid #e5e5e5; transition: all 0.3s ease; display: flex; flex-direction: column; }
|
||||
.topic-item:hover { background: #f8f9fa; transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); }
|
||||
.topic-header { display: flex; align-items: center; margin-bottom: 20px; }
|
||||
.topic-number { background: linear-gradient(135deg, #3182ce 0%, #2c5282 100%); color: #ffffff; width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 500; margin-right: 16px; font-size: 0.9em; box-shadow: 0 4px 12px rgba(49, 130, 206, 0.3); }
|
||||
.topic-title { font-weight: 600; color: #2d3748; font-size: 1.1em; letter-spacing: -0.3px; }
|
||||
.topic-contributors { color: #666666; font-size: 0.8em; margin-bottom: 16px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.topic-detail { color: #333333; line-height: 1.6; font-size: 0.9em; font-weight: 300; }
|
||||
.user-title { background: #ffffff; padding: 16px; margin-bottom: 0; border-radius: 12px; border: 1px solid #e5e5e5; display: flex; align-items: flex-start; justify-content: space-between; transition: all 0.3s ease; min-height: 80px; }
|
||||
.user-title:hover { background: #f8f9fa; transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); }
|
||||
.user-info { display: flex; align-items: center; flex: 1; }
|
||||
.user-avatar { width: 40px; height: 40px; border-radius: 50%; margin-right: 16px; border: 2px solid #f0f0f0; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); }
|
||||
.user-avatar-placeholder { width: 40px; height: 40px; border-radius: 50%; background: linear-gradient(135deg, #f0f0f0 0%, #e2e8f0 100%); display: flex; align-items: center; justify-content: center; margin-right: 16px; font-size: 1em; color: #999999; border: 2px solid #e5e5e5; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); }
|
||||
.user-details { flex: 1; }
|
||||
.user-name { font-weight: 600; color: #2d3748; margin-bottom: 12px; font-size: 1em; letter-spacing: -0.2px; }
|
||||
.user-badges { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.user-title-badge { background: linear-gradient(135deg, #4299e1 0%, #3182ce 100%); color: #ffffff; padding: 6px 16px; border-radius: 20px; font-size: 0.75em; font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px; box-shadow: 0 2px 8px rgba(66, 153, 225, 0.3); }
|
||||
.user-mbti { background: linear-gradient(135deg, #667eea 0%, #5a67d8 100%); color: #ffffff; padding: 6px 12px; border-radius: 16px; font-weight: 500; font-size: 0.75em; text-transform: uppercase; letter-spacing: 1px; box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3); }
|
||||
.user-reason { color: #666666; font-size: 0.8em; text-align: right; line-height: 1.4; font-weight: 300; margin-left: 16px; flex: 1; word-wrap: break-word; overflow-wrap: break-word; }
|
||||
.quote-item { background: linear-gradient(135deg, #faf5ff 0%, #f7fafc 100%); padding: 16px; margin-bottom: 16px; border-radius: 12px; border: 1px solid #e2e8f0; position: relative; transition: all 0.3s ease; }
|
||||
.quote-item:hover { background: linear-gradient(135deg, #ffffff 0%, #faf5ff 100%); transform: translateY(-2px); box-shadow: 0 8px 24px rgba(102, 126, 234, 0.15); }
|
||||
.quote-content { font-size: 1.1em; color: #2d3748; font-weight: 500; line-height: 1.6; margin-bottom: 12px; font-style: italic; letter-spacing: 0.2px; }
|
||||
.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; }
|
||||
@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>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📊 群聊日常分析报告</h1>
|
||||
<div class="date">{{ current_date }}</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="section full-width-section">
|
||||
<h2 class="section-title">📈 基础统计</h2>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card"><div class="stat-number">{{ message_count }}</div><div class="stat-label">消息总数</div></div>
|
||||
<div class="stat-card"><div class="stat-number">{{ participant_count }}</div><div class="stat-label">参与人数</div></div>
|
||||
<div class="stat-card"><div class="stat-number">{{ total_characters }}</div><div class="stat-label">总字符数</div></div>
|
||||
<div class="stat-card"><div class="stat-number">{{ emoji_count }}</div><div class="stat-label">表情数量</div></div>
|
||||
</div>
|
||||
<div class="active-period">
|
||||
<div class="time">{{ most_active_period }}</div>
|
||||
<div class="label">最活跃时段</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2 class="section-title">💬 热门话题</h2>
|
||||
<div class="topics-grid">{{ topics_html | safe }}</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2 class="section-title">🏆 群友称号</h2>
|
||||
<div class="users-grid">{{ titles_html | safe }}</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2 class="section-title">💬 群圣经</h2>
|
||||
{{ quotes_html | safe }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
由 AstrBot QQ群日常分析插件 生成 | {{ current_datetime }} | SXP-Simon/astrbot-qq-group-daily-analysis<br>
|
||||
<small style="opacity: 0.8; font-size: 0.9em;">🤖 AI分析消耗:{{ total_tokens }} tokens (输入: {{ prompt_tokens }}, 输出: {{ completion_tokens }})</small>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
@staticmethod
|
||||
def get_pdf_template() -> str:
|
||||
"""获取PDF报告的HTML模板(使用{}占位符)"""
|
||||
return """<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>群聊日常分析报告</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Microsoft YaHei', 'SimHei', sans-serif; background: #ffffff; color: #1a1a1a; line-height: 1.6; font-size: 14px; }
|
||||
.container { max-width: 800px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: linear-gradient(135deg, #4299e1 0%, #667eea 100%); color: #ffffff; padding: 30px; text-align: center; border-radius: 12px; margin-bottom: 30px; }
|
||||
.header h1 { font-size: 28px; font-weight: 600; margin-bottom: 8px; }
|
||||
.header .date { font-size: 16px; opacity: 0.9; }
|
||||
.section { margin-bottom: 40px; page-break-inside: avoid; }
|
||||
.section-title { font-size: 20px; font-weight: 600; margin-bottom: 20px; color: #4a5568; border-bottom: 2px solid #4299e1; padding-bottom: 8px; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 15px; margin-bottom: 30px; }
|
||||
.stat-card { background: #f8f9ff; padding: 20px; text-align: center; border-radius: 8px; border: 1px solid #e2e8f0; }
|
||||
.stat-number { font-size: 24px; font-weight: 600; color: #4299e1; margin-bottom: 5px; }
|
||||
.stat-label { font-size: 12px; color: #666666; text-transform: uppercase; }
|
||||
.active-period { background: linear-gradient(135deg, #4299e1 0%, #667eea 100%); color: #ffffff; padding: 25px; text-align: center; margin: 30px 0; border-radius: 8px; }
|
||||
.active-period .time { font-size: 28px; font-weight: 300; margin-bottom: 5px; }
|
||||
.active-period .label { font-size: 14px; opacity: 0.9; }
|
||||
.topic-item { background: #ffffff; padding: 20px; margin-bottom: 15px; border-radius: 8px; border: 1px solid #e2e8f0; page-break-inside: avoid; }
|
||||
.topic-header { display: flex; align-items: center; margin-bottom: 12px; }
|
||||
.topic-number { background: #4299e1; color: #ffffff; width: 24px; height: 24px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 600; margin-right: 12px; font-size: 12px; }
|
||||
.topic-title { font-weight: 600; color: #2d3748; font-size: 16px; }
|
||||
.topic-contributors { color: #666666; font-size: 12px; margin-bottom: 10px; }
|
||||
.topic-detail { color: #333333; line-height: 1.6; font-size: 14px; }
|
||||
.user-title { background: #ffffff; padding: 20px; margin-bottom: 15px; border-radius: 8px; border: 1px solid #e2e8f0; display: flex; align-items: flex-start; justify-content: space-between; page-break-inside: avoid; }
|
||||
.user-info { display: flex; align-items: center; flex: 1; }
|
||||
.user-details { flex: 1; }
|
||||
.user-name { font-weight: 600; color: #2d3748; margin-bottom: 8px; font-size: 16px; }
|
||||
.user-badges { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.user-title-badge { background: #4299e1; color: #ffffff; padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 500; }
|
||||
.user-mbti { background: #667eea; color: #ffffff; padding: 4px 8px; border-radius: 8px; font-weight: 500; font-size: 12px; }
|
||||
.user-reason { color: #666666; font-size: 12px; max-width: 200px; text-align: right; line-height: 1.4; }
|
||||
.user-avatar { width: 40px; height: 40px; border-radius: 50%; margin-right: 15px; border: 2px solid #e2e8f0; object-fit: cover; flex-shrink: 0; }
|
||||
.user-avatar-placeholder { width: 40px; height: 40px; border-radius: 50%; background: #f0f0f0; display: flex; align-items: center; justify-content: center; margin-right: 15px; font-size: 18px; color: #666666; flex-shrink: 0; }
|
||||
.quote-item { background: #faf5ff; padding: 20px; margin-bottom: 15px; border-radius: 8px; border: 1px solid #e2e8f0; page-break-inside: avoid; }
|
||||
.quote-content { font-size: 16px; color: #2d3748; font-weight: 500; line-height: 1.6; margin-bottom: 10px; font-style: italic; }
|
||||
.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; }
|
||||
@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>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📊 群聊日常分析报告</h1>
|
||||
<div class="date">{current_date}</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2 class="section-title">📈 基础统计</h2>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card"><div class="stat-number">{message_count}</div><div class="stat-label">消息总数</div></div>
|
||||
<div class="stat-card"><div class="stat-number">{participant_count}</div><div class="stat-label">参与人数</div></div>
|
||||
<div class="stat-card"><div class="stat-number">{total_characters}</div><div class="stat-label">总字符数</div></div>
|
||||
<div class="stat-card"><div class="stat-number">{emoji_count}</div><div class="stat-label">表情数量</div></div>
|
||||
</div>
|
||||
<div class="active-period">
|
||||
<div class="time">{most_active_period}</div>
|
||||
<div class="label">最活跃时段</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2 class="section-title">💬 热门话题</h2>
|
||||
{topics_html}
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2 class="section-title">🏆 群友称号</h2>
|
||||
{titles_html}
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2 class="section-title">💬 群圣经</h2>
|
||||
{quotes_html}
|
||||
</div>
|
||||
<div class="footer">
|
||||
由 AstrBot QQ群日常分析插件 生成 | {current_datetime} | SXP-Simon/astrbot-qq-group-daily-analysis<br>
|
||||
<small style="opacity: 0.8; font-size: 0.9em;">🤖 AI分析消耗:{total_tokens} tokens (输入: {prompt_tokens}, 输出: {completion_tokens})</small>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
调度和自动化模块
|
||||
包含定时任务和自动分析功能
|
||||
"""
|
||||
|
||||
from .auto_scheduler import AutoScheduler
|
||||
|
||||
__all__ = [
|
||||
'AutoScheduler'
|
||||
]
|
||||
@@ -0,0 +1,317 @@
|
||||
"""
|
||||
自动调度器模块
|
||||
负责定时任务和自动分析功能
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from astrbot.api import logger
|
||||
|
||||
|
||||
class AutoScheduler:
|
||||
"""自动调度器"""
|
||||
|
||||
def __init__(self, config_manager, message_handler, analyzer, report_generator, html_render_func=None):
|
||||
self.config_manager = config_manager
|
||||
self.message_handler = message_handler
|
||||
self.analyzer = analyzer
|
||||
self.report_generator = report_generator
|
||||
self.html_render_func = html_render_func
|
||||
self.scheduler_task = None
|
||||
self.bot_instance = None
|
||||
self.last_execution_date = None # 记录上次执行日期,防止重复执行
|
||||
|
||||
def set_bot_instance(self, bot_instance):
|
||||
"""设置bot实例"""
|
||||
self.bot_instance = bot_instance
|
||||
# 同时设置消息处理器的bot实例
|
||||
asyncio.create_task(self.message_handler.set_bot_qq_id(bot_instance))
|
||||
|
||||
async def start_scheduler(self):
|
||||
"""启动定时任务调度器"""
|
||||
if not self.config_manager.get_enable_auto_analysis():
|
||||
logger.info("自动分析功能未启用")
|
||||
return
|
||||
|
||||
# 延迟启动,给系统时间初始化
|
||||
await asyncio.sleep(10)
|
||||
|
||||
logger.info(f"启动定时任务调度器,自动分析时间: {self.config_manager.get_auto_analysis_time()}")
|
||||
|
||||
self.scheduler_task = asyncio.create_task(self._scheduler_loop())
|
||||
|
||||
async def stop_scheduler(self):
|
||||
"""停止定时任务调度器"""
|
||||
if self.scheduler_task and not self.scheduler_task.done():
|
||||
self.scheduler_task.cancel()
|
||||
logger.info("已停止定时任务调度器")
|
||||
|
||||
async def restart_scheduler(self):
|
||||
"""重启定时任务调度器"""
|
||||
await self.stop_scheduler()
|
||||
if self.config_manager.get_enable_auto_analysis():
|
||||
await self.start_scheduler()
|
||||
|
||||
async def _scheduler_loop(self):
|
||||
"""调度器主循环"""
|
||||
while True:
|
||||
try:
|
||||
now = datetime.now()
|
||||
target_time = datetime.strptime(self.config_manager.get_auto_analysis_time(), "%H:%M").replace(
|
||||
year=now.year, month=now.month, day=now.day
|
||||
)
|
||||
|
||||
# 如果今天的目标时间已过,设置为明天
|
||||
if now >= target_time:
|
||||
target_time += timedelta(days=1)
|
||||
|
||||
# 计算等待时间
|
||||
wait_seconds = (target_time - now).total_seconds()
|
||||
logger.info(f"定时分析将在 {target_time.strftime('%Y-%m-%d %H:%M:%S')} 执行,等待 {wait_seconds:.0f} 秒")
|
||||
|
||||
# 等待到目标时间
|
||||
await asyncio.sleep(wait_seconds)
|
||||
|
||||
# 执行自动分析
|
||||
if self.config_manager.get_enable_auto_analysis():
|
||||
# 检查是否今天已经执行过
|
||||
today = now.date()
|
||||
if self.last_execution_date == today:
|
||||
logger.info(f"今天 {today} 已经执行过自动分析,跳过执行")
|
||||
# 等待到明天再检查
|
||||
await asyncio.sleep(3600) # 等待1小时后再检查
|
||||
continue
|
||||
|
||||
logger.info("开始执行定时分析")
|
||||
await self._run_auto_analysis()
|
||||
self.last_execution_date = today # 记录执行日期
|
||||
logger.info(f"定时分析执行完成,记录执行日期: {today}")
|
||||
else:
|
||||
logger.info("自动分析已禁用,跳过执行")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"定时任务调度器错误: {e}")
|
||||
# 等待5分钟后重试
|
||||
await asyncio.sleep(300)
|
||||
|
||||
async def _run_auto_analysis(self):
|
||||
"""执行自动分析"""
|
||||
try:
|
||||
logger.info("开始执行自动群聊分析")
|
||||
|
||||
# 为每个启用的群执行分析
|
||||
enabled_groups = self.config_manager.get_enabled_groups()
|
||||
for group_id in enabled_groups:
|
||||
try:
|
||||
logger.info(f"为群 {group_id} 执行自动分析")
|
||||
await self._perform_auto_analysis_for_group(group_id)
|
||||
except Exception as e:
|
||||
logger.error(f"群 {group_id} 自动分析失败: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"自动分析执行失败: {e}")
|
||||
|
||||
async def _perform_auto_analysis_for_group(self, group_id: str):
|
||||
"""为指定群执行自动分析"""
|
||||
try:
|
||||
if not self.bot_instance:
|
||||
logger.warning(f"群 {group_id} 自动分析跳过:未获取到bot实例")
|
||||
return
|
||||
|
||||
logger.info(f"开始为群 {group_id} 执行自动分析")
|
||||
|
||||
# 获取群聊消息
|
||||
analysis_days = self.config_manager.get_analysis_days()
|
||||
messages = await self.message_handler.fetch_group_messages(self.bot_instance, group_id, analysis_days)
|
||||
if not messages:
|
||||
logger.warning(f"群 {group_id} 未获取到足够的消息记录")
|
||||
return
|
||||
|
||||
# 检查消息数量
|
||||
min_threshold = self.config_manager.get_min_messages_threshold()
|
||||
if len(messages) < min_threshold:
|
||||
logger.warning(f"群 {group_id} 消息数量不足({len(messages)}条),跳过分析")
|
||||
return
|
||||
|
||||
logger.info(f"群 {group_id} 获取到 {len(messages)} 条消息,开始分析")
|
||||
|
||||
# 进行分析
|
||||
analysis_result = await self.analyzer.analyze_messages(messages, group_id)
|
||||
if not analysis_result:
|
||||
logger.error(f"群 {group_id} 分析失败")
|
||||
return
|
||||
|
||||
# 生成并发送报告
|
||||
await self._send_analysis_report(group_id, analysis_result)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"群 {group_id} 自动分析执行失败: {e}", exc_info=True)
|
||||
|
||||
async def _send_analysis_report(self, group_id: str, analysis_result: dict):
|
||||
"""发送分析报告到群"""
|
||||
try:
|
||||
output_format = self.config_manager.get_output_format()
|
||||
|
||||
if output_format == "image":
|
||||
if self.html_render_func:
|
||||
# 使用图片格式
|
||||
logger.info(f"群 {group_id} 自动分析使用图片报告格式")
|
||||
image_url = await self.report_generator.generate_image_report(analysis_result, group_id, self.html_render_func)
|
||||
if image_url:
|
||||
await self._send_image_message(group_id, image_url)
|
||||
logger.info(f"群 {group_id} 图片报告发送成功")
|
||||
else:
|
||||
# 图片生成失败,回退到文本
|
||||
logger.warning(f"群 {group_id} 图片报告生成失败,回退到文本报告")
|
||||
text_report = self.report_generator.generate_text_report(analysis_result)
|
||||
await self._send_text_message(group_id, f"📊 每日群聊分析报告:\n\n{text_report}")
|
||||
else:
|
||||
# 没有html_render函数,回退到文本报告
|
||||
logger.warning(f"群 {group_id} 缺少html_render函数,回退到文本报告")
|
||||
text_report = self.report_generator.generate_text_report(analysis_result)
|
||||
await self._send_text_message(group_id, f"📊 每日群聊分析报告:\n\n{text_report}")
|
||||
|
||||
elif output_format == "pdf":
|
||||
if not self.config_manager.pyppeteer_available:
|
||||
logger.warning(f"群 {group_id} PDF功能不可用,回退到文本报告")
|
||||
text_report = self.report_generator.generate_text_report(analysis_result)
|
||||
await self._send_text_message(group_id, f"📊 每日群聊分析报告:\n\n{text_report}")
|
||||
else:
|
||||
pdf_path = await self.report_generator.generate_pdf_report(analysis_result, group_id)
|
||||
if pdf_path:
|
||||
await self._send_pdf_file(group_id, pdf_path)
|
||||
logger.info(f"群 {group_id} 自动分析完成,已发送PDF报告")
|
||||
else:
|
||||
logger.error(f"群 {group_id} PDF报告生成失败,回退到文本报告")
|
||||
text_report = self.report_generator.generate_text_report(analysis_result)
|
||||
await self._send_text_message(group_id, f"📊 每日群聊分析报告:\n\n{text_report}")
|
||||
else:
|
||||
text_report = self.report_generator.generate_text_report(analysis_result)
|
||||
await self._send_text_message(group_id, f"📊 每日群聊分析报告:\n\n{text_report}")
|
||||
|
||||
logger.info(f"群 {group_id} 自动分析完成,已发送报告")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送分析报告到群 {group_id} 失败: {e}")
|
||||
|
||||
async def _send_image_message(self, group_id: str, image_url: str):
|
||||
"""发送图片消息到群"""
|
||||
try:
|
||||
if not self.bot_instance:
|
||||
logger.error(f"群 {group_id} 发送图片失败:缺少bot实例")
|
||||
return
|
||||
|
||||
# 发送图片消息到群
|
||||
await self.bot_instance.api.call_action(
|
||||
"send_group_msg",
|
||||
group_id=group_id,
|
||||
message=[{
|
||||
"type": "text",
|
||||
"data": {"text": "📊 每日群聊分析报告已生成:"}
|
||||
}, {
|
||||
"type": "image",
|
||||
"data": {"url": image_url}
|
||||
}]
|
||||
)
|
||||
logger.info(f"群 {group_id} 图片消息发送成功")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送图片消息到群 {group_id} 失败: {e}")
|
||||
|
||||
async def _send_text_message(self, group_id: str, text_content: str):
|
||||
"""发送文本消息到群"""
|
||||
try:
|
||||
if not self.bot_instance:
|
||||
logger.error(f"群 {group_id} 发送文本失败:缺少bot实例")
|
||||
return
|
||||
|
||||
# 发送文本消息到群
|
||||
await self.bot_instance.api.call_action(
|
||||
"send_group_msg",
|
||||
group_id=group_id,
|
||||
message=text_content
|
||||
)
|
||||
logger.info(f"群 {group_id} 文本消息发送成功")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送文本消息到群 {group_id} 失败: {e}")
|
||||
|
||||
async def _send_pdf_file(self, group_id: str, pdf_path: str):
|
||||
"""发送PDF文件到群"""
|
||||
try:
|
||||
if not self.bot_instance:
|
||||
logger.error(f"群 {group_id} 发送PDF失败:缺少bot实例")
|
||||
return
|
||||
|
||||
# 发送PDF文件到群
|
||||
await self.bot_instance.api.call_action(
|
||||
"send_group_msg",
|
||||
group_id=group_id,
|
||||
message=[{
|
||||
"type": "text",
|
||||
"data": {"text": "📊 每日群聊分析报告已生成:"}
|
||||
}, {
|
||||
"type": "file",
|
||||
"data": {"file": pdf_path}
|
||||
}]
|
||||
)
|
||||
logger.info(f"群 {group_id} PDF文件发送成功")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送PDF文件到群 {group_id} 失败: {e}")
|
||||
# 发送失败提示
|
||||
try:
|
||||
await self.bot_instance.api.call_action(
|
||||
"send_group_msg",
|
||||
group_id=group_id,
|
||||
message=f"📊 每日群聊分析报告已生成,但发送PDF文件失败。PDF文件路径:{pdf_path}"
|
||||
)
|
||||
except Exception as e2:
|
||||
logger.error(f"发送PDF失败提示到群 {group_id} 也失败: {e2}")
|
||||
|
||||
async def _send_text_message(self, group_id: str, message: str):
|
||||
"""发送文本消息到群"""
|
||||
try:
|
||||
if not self.bot_instance:
|
||||
return
|
||||
|
||||
await self.bot_instance.api.call_action(
|
||||
"send_group_msg",
|
||||
group_id=group_id,
|
||||
message=message
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送文本消息到群 {group_id} 失败: {e}")
|
||||
|
||||
async def _send_pdf_file(self, group_id: str, pdf_path: str):
|
||||
"""发送PDF文件到群"""
|
||||
try:
|
||||
if not self.bot_instance:
|
||||
return
|
||||
|
||||
await self.bot_instance.api.call_action(
|
||||
"send_group_msg",
|
||||
group_id=group_id,
|
||||
message=[{
|
||||
"type": "text",
|
||||
"data": {"text": "📊 每日群聊分析报告已生成:"}
|
||||
}, {
|
||||
"type": "file",
|
||||
"data": {"file": pdf_path}
|
||||
}]
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送PDF文件到群 {group_id} 失败: {e}")
|
||||
# 如果发送PDF失败,尝试发送提示信息
|
||||
try:
|
||||
await self.bot_instance.api.call_action(
|
||||
"send_group_msg",
|
||||
group_id=group_id,
|
||||
message=f"📊 每日群聊分析报告已生成,但发送PDF文件失败。PDF文件路径:{pdf_path}"
|
||||
)
|
||||
except Exception as e2:
|
||||
logger.error(f"发送PDF失败提示到群 {group_id} 也失败: {e2}")
|
||||
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
工具函数模块
|
||||
包含PDF处理和通用工具函数
|
||||
"""
|
||||
|
||||
from .pdf_utils import PDFInstaller
|
||||
from .helpers import MessageAnalyzer
|
||||
|
||||
__all__ = [
|
||||
'PDFInstaller',
|
||||
'MessageAnalyzer'
|
||||
]
|
||||
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
通用工具函数模块
|
||||
包含消息分析和其他通用功能
|
||||
"""
|
||||
|
||||
from typing import List, Dict
|
||||
from ...src.models.data_models import GroupStatistics, SummaryTopic, UserTitle, GoldenQuote, TokenUsage
|
||||
from ...src.core.message_handler import MessageHandler
|
||||
from ...src.analysis.llm_analyzer import LLMAnalyzer
|
||||
from ...src.analysis.statistics import UserAnalyzer
|
||||
|
||||
|
||||
class MessageAnalyzer:
|
||||
"""消息分析器 - 整合所有分析功能"""
|
||||
|
||||
def __init__(self, context, config_manager):
|
||||
self.context = context
|
||||
self.config_manager = config_manager
|
||||
self.message_handler = MessageHandler(config_manager)
|
||||
self.llm_analyzer = LLMAnalyzer(context, config_manager)
|
||||
self.user_analyzer = UserAnalyzer(config_manager)
|
||||
|
||||
async def set_bot_instance(self, bot_instance):
|
||||
"""设置bot实例"""
|
||||
await self.message_handler.set_bot_qq_id(bot_instance)
|
||||
|
||||
async def analyze_messages(self, messages: List[Dict], group_id: str) -> Dict:
|
||||
"""完整的消息分析流程"""
|
||||
try:
|
||||
# 基础统计
|
||||
statistics = self.message_handler.calculate_statistics(messages)
|
||||
|
||||
# 用户分析
|
||||
user_analysis = self.user_analyzer.analyze_users(messages)
|
||||
|
||||
# LLM分析
|
||||
topics = []
|
||||
user_titles = []
|
||||
golden_quotes = []
|
||||
total_token_usage = TokenUsage()
|
||||
|
||||
# 话题分析
|
||||
if self.config_manager.get_topic_analysis_enabled():
|
||||
topics, topic_tokens = await self.llm_analyzer.analyze_topics(messages)
|
||||
total_token_usage.prompt_tokens += topic_tokens.prompt_tokens
|
||||
total_token_usage.completion_tokens += topic_tokens.completion_tokens
|
||||
total_token_usage.total_tokens += topic_tokens.total_tokens
|
||||
|
||||
# 用户称号分析
|
||||
if self.config_manager.get_user_title_analysis_enabled():
|
||||
user_titles, title_tokens = await self.llm_analyzer.analyze_user_titles(messages, user_analysis)
|
||||
total_token_usage.prompt_tokens += title_tokens.prompt_tokens
|
||||
total_token_usage.completion_tokens += title_tokens.completion_tokens
|
||||
total_token_usage.total_tokens += title_tokens.total_tokens
|
||||
|
||||
# 金句分析
|
||||
golden_quotes, quote_tokens = await self.llm_analyzer.analyze_golden_quotes(messages)
|
||||
total_token_usage.prompt_tokens += quote_tokens.prompt_tokens
|
||||
total_token_usage.completion_tokens += quote_tokens.completion_tokens
|
||||
total_token_usage.total_tokens += quote_tokens.total_tokens
|
||||
|
||||
# 更新统计数据
|
||||
statistics.golden_quotes = golden_quotes
|
||||
statistics.token_usage = total_token_usage
|
||||
|
||||
return {
|
||||
"statistics": statistics,
|
||||
"topics": topics,
|
||||
"user_titles": user_titles,
|
||||
"user_analysis": user_analysis
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
from astrbot.api import logger
|
||||
logger.error(f"消息分析失败: {e}")
|
||||
return None
|
||||
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
PDF工具模块
|
||||
负责PDF相关的安装和管理功能
|
||||
"""
|
||||
|
||||
import sys
|
||||
import asyncio
|
||||
from astrbot.api import logger
|
||||
|
||||
|
||||
class PDFInstaller:
|
||||
"""PDF功能安装器"""
|
||||
|
||||
@staticmethod
|
||||
async def install_pyppeteer(config_manager):
|
||||
"""安装pyppeteer依赖"""
|
||||
try:
|
||||
logger.info("开始安装 pyppeteer...")
|
||||
|
||||
# 使用asyncio安装pyppeteer和兼容的websockets版本
|
||||
logger.info("安装 pyppeteer==1.0.2 和兼容的依赖...")
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
sys.executable, "-m", "pip", "install",
|
||||
"pyppeteer==1.0.2", "websockets==10.4",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode == 0:
|
||||
logger.info("pyppeteer 安装成功")
|
||||
logger.info(f"安装输出: {stdout.decode()}")
|
||||
|
||||
# 重新加载pyppeteer模块
|
||||
success = config_manager.reload_pyppeteer()
|
||||
if success:
|
||||
return "✅ pyppeteer 安装成功!PDF 功能现已可用。"
|
||||
else:
|
||||
return "⚠️ pyppeteer 安装完成,但重新加载失败。请重启 AstrBot 以使用 PDF 功能。"
|
||||
else:
|
||||
error_msg = stderr.decode()
|
||||
logger.error(f"pyppeteer 安装失败: {error_msg}")
|
||||
return f"❌ pyppeteer 安装失败: {error_msg}"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"安装 pyppeteer 时出错: {e}")
|
||||
return f"❌ 安装过程中出错: {str(e)}"
|
||||
|
||||
@staticmethod
|
||||
def get_pdf_status(config_manager) -> str:
|
||||
"""获取PDF功能状态"""
|
||||
if config_manager.pyppeteer_available:
|
||||
version = config_manager.pyppeteer_version or "未知版本"
|
||||
return f"✅ PDF 功能可用 (pyppeteer {version})"
|
||||
else:
|
||||
return "❌ PDF 功能不可用 - 需要安装 pyppeteer"
|
||||
Reference in New Issue
Block a user