refactor: 报告中的群员头像重构为 diskcache 缓存,过期时间3天;优化代码可读性; (#118 @oculr)

* refactor: 报告中的群员头像重构为diskcache缓存,过期时间3天;优化代码可读性;

* refactor: 进一步优化头像处理逻辑与资源管理生命周期

- **头像处理性能优化**:
  - 将 Base64 转换重构为同步方法,消除微型 CPU 计算任务的异步上下文切换开销。
  - 增强图片 MIME 类型识别能力,通过分析文件头支持 WebP 和 JPEG 魔数检测。
  - 改善下载失败处理:移除了失败结果的持久化缓存(Negative Caching),确保在网络波动恢复后能自动重新尝试获取。
- **资源与生命周期管理**:
  - 完善插件终止流程:增加了显式的缓存关闭逻辑,并与插件卸载钩子集成,确保正确释放持久化层的文件句柄。
- **鲁棒性与调试增强**:
  - 加强对第三方头像获取器返回值的 URL 协议校验,并补充了相应的警告日志。
  - 优化了 Base64 转换等环节的异常日志记录格式,增加了对堆栈追踪的支持。
- **代码整洁度**:
  - 统一并清理了重复的模块导入,移除了方法内部冗余的库导入。
  - 全量应用 Ruff 格式化规范,优化了导入顺序及整体代码布局。

---------

Co-authored-by: SXP-Simon <sxp20061207@163.com>
This commit is contained in:
oculr
2026-03-22 23:36:28 +08:00
committed by GitHub
co-authored by SXP-Simon
parent 51d14dad0b
commit f40243492f
3 changed files with 140 additions and 127 deletions
+9 -5
View File
@@ -14,7 +14,7 @@ from astrbot.api import AstrBotConfig
from astrbot.api import logger as astrbot_logger from astrbot.api import logger as astrbot_logger
from astrbot.api.event import AstrMessageEvent, filter from astrbot.api.event import AstrMessageEvent, filter
from astrbot.api.event.filter import PermissionType from astrbot.api.event.filter import PermissionType
from astrbot.api.star import Context, Star from astrbot.api.star import Context, Star, StarTools
from astrbot.core.message.components import File from astrbot.core.message.components import File
from .src.application.commands.template_command_service import ( from .src.application.commands.template_command_service import (
@@ -83,7 +83,9 @@ class GroupDailyAnalysis(Star):
self.bot_manager.set_context(context) self.bot_manager.set_context(context)
self.bot_manager.set_plugin_instance(self) self.bot_manager.set_plugin_instance(self)
self.history_manager = HistoryManager(self) self.history_manager = HistoryManager(self)
self.report_generator = ReportGenerator(self.config_manager) self.report_generator = ReportGenerator(
self.config_manager, StarTools.get_data_dir()
)
# Telegram 注册表 (持久层) # Telegram 注册表 (持久层)
self.telegram_group_registry = TelegramGroupRegistry(self) self.telegram_group_registry = TelegramGroupRegistry(self)
@@ -252,6 +254,8 @@ class GroupDailyAnalysis(Star):
# 3. 释放实例属性引用 (使用 type: ignore 允许 None 赋值) # 3. 释放实例属性引用 (使用 type: ignore 允许 None 赋值)
self.auto_scheduler = None # type: ignore self.auto_scheduler = None # type: ignore
self.bot_manager = None # type: ignore self.bot_manager = None # type: ignore
if self.report_generator:
self.report_generator.close()
self.report_generator = None # type: ignore self.report_generator = None # type: ignore
self.config_manager = None # type: ignore self.config_manager = None # type: ignore
self.message_processing_service = None # type: ignore self.message_processing_service = None # type: ignore
@@ -547,7 +551,7 @@ class GroupDailyAnalysis(Star):
output_format = self.config_manager.get_output_format() output_format = self.config_manager.get_output_format()
# 定义获取回调 # 定义获取回调
async def avatar_getter(user_id: str) -> str | None: async def avatar_url_getter(user_id: str) -> str | None:
return await adapter.get_user_avatar_url(user_id) return await adapter.get_user_avatar_url(user_id)
async def nickname_getter(user_id: str) -> str | None: async def nickname_getter(user_id: str) -> str | None:
@@ -564,7 +568,7 @@ class GroupDailyAnalysis(Star):
analysis_result, analysis_result,
group_id, group_id,
self.html_render, self.html_render,
avatar_getter=avatar_getter, avatar_url_getter=avatar_url_getter,
nickname_getter=nickname_getter, nickname_getter=nickname_getter,
) )
@@ -592,7 +596,7 @@ class GroupDailyAnalysis(Star):
pdf_path = await self.report_generator.generate_pdf_report( pdf_path = await self.report_generator.generate_pdf_report(
analysis_result, analysis_result,
group_id, group_id,
avatar_getter=avatar_getter, avatar_url_getter=avatar_url_getter,
nickname_getter=nickname_getter, nickname_getter=nickname_getter,
) )
if pdf_path: if pdf_path:
+2 -1
View File
@@ -1 +1,2 @@
playwright>=1.40.0 playwright>=1.40.0
diskcache
+129 -121
View File
@@ -11,17 +11,22 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
import aiohttp import aiohttp
from diskcache import Cache
from ...domain.repositories.report_repository import IReportGenerator from ...domain.repositories.report_repository import IReportGenerator
from ...utils.logger import logger from ...utils.logger import logger
from ..visualization.activity_charts import ActivityVisualizer from ..visualization.activity_charts import ActivityVisualizer
from .templates import HTMLTemplates from .templates import HTMLTemplates
MAX_CONCURRENT_DOWNLOADS = 10
AVATAR_CACHE_EXPIRE_TIME = 259200
class ReportGenerator(IReportGenerator): class ReportGenerator(IReportGenerator):
"""报告生成器""" """报告生成器"""
def __init__(self, config_manager): def __init__(self, config_manager, data_dir):
self._avatar_session = None
self.config_manager = config_manager self.config_manager = config_manager
self.activity_visualizer = ActivityVisualizer() self.activity_visualizer = ActivityVisualizer()
self.html_templates = HTMLTemplates(config_manager) # 实例化HTML模板管理器 self.html_templates = HTMLTemplates(config_manager) # 实例化HTML模板管理器
@@ -31,7 +36,10 @@ class ReportGenerator(IReportGenerator):
self._render_semaphore = asyncio.Semaphore(max_concurrent) self._render_semaphore = asyncio.Semaphore(max_concurrent)
# 运行时缓存,用于在一次分析任务中避免重复下载同一个头像 # 运行时缓存,用于在一次分析任务中避免重复下载同一个头像
self._runtime_avatar_cache = {} # user_id -> base64_uri self._avatar_cache = Cache(str(data_dir / "avatar")) # user_id -> base64_uri
self._avatar_session_concurrent_semaphore = asyncio.Semaphore(
MAX_CONCURRENT_DOWNLOADS
)
self._avatar_session = None self._avatar_session = None
async def generate_image_report( async def generate_image_report(
@@ -39,7 +47,7 @@ class ReportGenerator(IReportGenerator):
analysis_result: dict, analysis_result: dict,
group_id: str, group_id: str,
html_render_func, html_render_func,
avatar_getter=None, avatar_url_getter=None,
nickname_getter=None, nickname_getter=None,
) -> tuple[str | None, str | None]: ) -> tuple[str | None, str | None]:
""" """
@@ -49,7 +57,8 @@ class ReportGenerator(IReportGenerator):
analysis_result: 分析结果字典 analysis_result: 分析结果字典
group_id: 群组ID group_id: 群组ID
html_render_func: HTML渲染函数 html_render_func: HTML渲染函数
avatar_getter: 异步回调函数,接收 user_id 返回 avatar_url/data avatar_url_getter: 异步回调函数,接收 user_id 返回 avatar_url/data
nickname_getter: 昵称获取函数
Returns: Returns:
tuple[str | None, str | None]: (image_url, html_content) tuple[str | None, str | None]: (image_url, html_content)
@@ -60,7 +69,7 @@ class ReportGenerator(IReportGenerator):
render_payload = await self._prepare_render_data( render_payload = await self._prepare_render_data(
analysis_result, analysis_result,
chart_template="activity_chart.html", chart_template="activity_chart.html",
avatar_getter=avatar_getter, avatar_url_getter=avatar_url_getter,
nickname_getter=nickname_getter, nickname_getter=nickname_getter,
) )
@@ -190,13 +199,12 @@ class ReportGenerator(IReportGenerator):
if self._avatar_session: if self._avatar_session:
await self._avatar_session.close() await self._avatar_session.close()
self._avatar_session = None self._avatar_session = None
self._runtime_avatar_cache.clear()
async def generate_pdf_report( async def generate_pdf_report(
self, self,
analysis_result: dict, analysis_result: dict,
group_id: str, group_id: str,
avatar_getter=None, avatar_url_getter=None,
nickname_getter=None, nickname_getter=None,
) -> str | None: ) -> str | None:
"""生成PDF格式的分析报告""" """生成PDF格式的分析报告"""
@@ -216,7 +224,7 @@ class ReportGenerator(IReportGenerator):
render_data = await self._prepare_render_data( render_data = await self._prepare_render_data(
analysis_result, analysis_result,
chart_template="activity_chart_pdf.html", chart_template="activity_chart_pdf.html",
avatar_getter=avatar_getter, avatar_url_getter=avatar_url_getter,
nickname_getter=nickname_getter, nickname_getter=nickname_getter,
) )
logger.info(f"PDF 渲染数据准备完成,包含 {len(render_data)} 个字段") logger.info(f"PDF 渲染数据准备完成,包含 {len(render_data)} 个字段")
@@ -289,7 +297,7 @@ class ReportGenerator(IReportGenerator):
self, self,
analysis_result: dict, analysis_result: dict,
chart_template: str = "activity_chart.html", chart_template: str = "activity_chart.html",
avatar_getter=None, avatar_url_getter=None,
nickname_getter=None, nickname_getter=None,
) -> dict: ) -> dict:
"""准备渲染数据""" """准备渲染数据"""
@@ -306,7 +314,7 @@ class ReportGenerator(IReportGenerator):
for i, topic in enumerate(topics[:max_topics], 1): for i, topic in enumerate(topics[:max_topics], 1):
# 处理话题详情中的用户引用头像 # 处理话题详情中的用户引用头像
processed_detail = await self._render_mentions( processed_detail = await self._render_mentions(
topic.detail, avatar_getter, nickname_getter, user_analysis topic.detail, avatar_url_getter, nickname_getter, user_analysis
) )
topics_list.append( topics_list.append(
{ {
@@ -327,7 +335,9 @@ class ReportGenerator(IReportGenerator):
titles_list = [] titles_list = []
for title in user_titles[:max_user_titles]: for title in user_titles[:max_user_titles]:
# 获取用户头像 # 获取用户头像
avatar_data = await self._get_user_avatar(str(title.user_id), avatar_getter) avatar_data = await self._get_user_avatar(
str(title.user_id), avatar_url_getter
)
title_data = { title_data = {
"name": title.name, "name": title.name,
"title": title.title, "title": title.title,
@@ -347,13 +357,13 @@ class ReportGenerator(IReportGenerator):
quotes_list = [] quotes_list = []
for quote in stats.golden_quotes[:max_golden_quotes]: for quote in stats.golden_quotes[:max_golden_quotes]:
avatar_url = ( avatar_url = (
await self._get_user_avatar(str(quote.user_id), avatar_getter) await self._get_user_avatar(str(quote.user_id), avatar_url_getter)
if quote.user_id if quote.user_id
else None else None
) )
# 处理解析锐评中的用户引用头像 # 处理解析锐评中的用户引用头像
processed_reason = await self._render_mentions( processed_reason = await self._render_mentions(
quote.reason, avatar_getter, nickname_getter, user_analysis quote.reason, avatar_url_getter, nickname_getter, user_analysis
) )
quotes_list.append( quotes_list.append(
{ {
@@ -408,7 +418,7 @@ class ReportGenerator(IReportGenerator):
async def _render_mentions( async def _render_mentions(
self, self,
text: str, text: str,
avatar_getter, avatar_url_getter,
nickname_getter=None, nickname_getter=None,
user_analysis: dict | None = None, user_analysis: dict | None = None,
) -> str: ) -> str:
@@ -425,7 +435,7 @@ class ReportGenerator(IReportGenerator):
async def replacer(match): async def replacer(match):
uid = match.group(1) uid = match.group(1)
url = await self._get_user_avatar( url = await self._get_user_avatar(
uid, avatar_getter uid, avatar_url_getter
) # 内部已有缓存,无需顶层并发获取 ) # 内部已有缓存,无需顶层并发获取
name = None name = None
@@ -528,140 +538,138 @@ class ReportGenerator(IReportGenerator):
# Telegram file URL: .../file/bot<token>/<file_path> # Telegram file URL: .../file/bot<token>/<file_path>
return re.sub(r"/bot[^/]+/", "/bot<redacted>/", url) return re.sub(r"/bot[^/]+/", "/bot<redacted>/", url)
async def _get_user_avatar(self, user_id: str, avatar_getter=None) -> str: async def _get_user_avatar(self, avatar_id: str, avatar_url_getter=None) -> str:
""" """
获取用户头像的 Base64 Data URI。 获取用户头像的 Base64 Data URI。
增加了运行时内存缓存,避免单次生成任务中重复下载 使用磁盘缓存,支持跨任务复用。获取失败时不缓存结果,以便后续请求重试
""" """
# 0. 检查运行时缓存 # 1. 检查缓存 (仅包含成功的头像数据)
if user_id in self._runtime_avatar_cache: if avatar_id in self._avatar_cache:
return self._runtime_avatar_cache[user_id] return self._avatar_cache[avatar_id]
res = await self._get_user_avatar_internal(user_id, avatar_getter) # 2. 尝试获取头像字节流
self._runtime_avatar_cache[user_id] = res avatar_bytes = await self._get_user_avatar_bytes(avatar_id, avatar_url_getter)
return res
async def _get_user_avatar_internal(self, user_id: str, avatar_getter=None) -> str: if not avatar_bytes:
# 获取失败时返回默认头像,但不存入缓存,以便下次重试
logger.warning(f"获取用户头像失败 {avatar_id},本次将使用回退头像")
return self._get_default_avatar_base64()
# 3. 获取成功:转换并缓存
avatar = self._b64_with_mime(avatar_bytes)
if avatar:
self._avatar_cache.set(avatar_id, avatar, expire=AVATAR_CACHE_EXPIRE_TIME)
return avatar
# 最终兜底
return self._get_default_avatar_base64()
def _b64_with_mime(self, _bytes: bytes) -> str | None:
"""将字节数据转换为 Base64 Data URI,并自动识别 MIME 类型。"""
try:
b64 = base64.b64encode(_bytes).decode("utf-8")
# 简单判断 mime type
mime = "image/jpeg"
if _bytes.startswith(b"\x89PNG"):
mime = "image/png"
elif _bytes.startswith(b"GIF8"):
mime = "image/gif"
elif _bytes.startswith(b"RIFF") and b"WEBP" in _bytes[8:16]:
mime = "image/webp"
elif _bytes.startswith(b"\xff\xd8"):
mime = "image/jpeg"
return f"data:{mime};base64,{b64}"
except Exception as e:
logger.error(f"base64 转换失败: {e}", exc_info=True)
return None
async def _get_user_avatar_bytes(
self, user_id: str, avatar_url_getter=None
) -> bytes | None:
"""核心头像获取逻辑""" """核心头像获取逻辑"""
import base64 file_content = None
if not self._avatar_session: if not self._avatar_session:
self._avatar_session = aiohttp.ClientSession( self._avatar_session = aiohttp.ClientSession(
trust_env=True, timeout=aiohttp.ClientTimeout(total=15) trust_env=True, timeout=aiohttp.ClientTimeout(total=15)
) )
async with self._avatar_session_concurrent_semaphore:
try: avatar_url = None
# 1. 准备缓存目录 if avatar_url_getter:
# 使用 plugin_data 目录以确保持久化和标准结构
temp_dir = Path(
"data/plugin_data/astrbot_plugin_qq_group_daily_analysis/cache/avatars"
)
if not temp_dir.exists():
await asyncio.to_thread(temp_dir.mkdir, parents=True, exist_ok=True)
# 使用小尺寸 (40px) 以优化性能
file_name = f"{user_id}_40.jpg"
file_path = temp_dir / file_name
file_content = None
# 2. 检查缓存
if file_path.exists() and file_path.stat().st_size > 0:
# 异步读取缓存
try: try:
file_content = await asyncio.to_thread(file_path.read_bytes) # avatar_url_getter 应该返回 URL
except Exception: result = await avatar_url_getter(user_id)
pass if result:
if result.startswith("http"):
# 3. 如果无缓存,获取 URL 并下载
if not file_content:
avatar_url = None
if avatar_getter:
try:
# avatar_getter 应该返回 URL
result = await avatar_getter(user_id)
if result and result.startswith("http"):
avatar_url = result avatar_url = result
except Exception as e:
logger.warning(f"使用 custom avatar_getter 获取头像失败: {e}")
# 4. Fallback URL (仅针对看起来像 QQ 号的 ID)
if not avatar_url:
if user_id.isdigit() and 5 <= len(user_id) <= 12:
# 强制使用 spec=40
avatar_url = (
f"https://q4.qlogo.cn/headimg_dl?dst_uin={user_id}&spec=40"
)
else:
# 其他平台若无 URL,无法获取头像
return self._get_default_avatar_base64()
# 5. 下载并保存
safe_avatar_url = self._safe_url_for_log(avatar_url)
try:
async with self._avatar_session.get(avatar_url) as response:
if response.status == 200:
content = await response.read()
if content:
# 校验文件头
is_valid_image = False
if content.startswith(b"\xff\xd8"): # JPEG
is_valid_image = True
elif content.startswith(b"\x89PNG\r\n\x1a\n"): # PNG
is_valid_image = True
elif content.startswith(b"GIF8"): # GIF
is_valid_image = True
elif (
content.startswith(b"RIFF")
and b"WEBP" in content[:16]
): # WebP
is_valid_image = True
if is_valid_image:
await asyncio.to_thread(
file_path.write_bytes, content
)
file_content = content
else:
logger.warning(
f"下载的头像数据格式无效 ({safe_avatar_url})"
)
else: else:
logger.warning( logger.warning(
f"下载头像失败 {safe_avatar_url}: {response.status}" f"custom avatar_url_getter 返回了非 HTTP URL: {result[:50]}..."
) )
except Exception as e: except Exception as e:
logger.warning(f"下载头像网络错误 {safe_avatar_url}: {e}") logger.warning(f"使用 custom avatar_url_getter 获取头像失败: {e}")
# 6. 转换为 Base64 Data URI if not avatar_url:
if file_content: if user_id.isdigit() and 5 <= len(user_id) <= 12:
b64 = base64.b64encode(file_content).decode("utf-8") # 强制使用 spec=40
# 简单判断 mime type avatar_url = (
mime = "image/jpeg" f"https://q4.qlogo.cn/headimg_dl?dst_uin={user_id}&spec=40"
if file_content.startswith(b"\x89PNG"): )
mime = "image/png" else:
elif file_content.startswith(b"GIF8"): # 其他平台若无 URL,无法获取头像
mime = "image/gif" return None
elif file_content.startswith(b"RIFF"):
mime = "image/webp"
return f"data:{mime};base64,{b64}" # 5. 下载并保存
safe_avatar_url = self._safe_url_for_log(avatar_url)
try:
async with self._avatar_session.get(avatar_url) as response:
if response.status == 200:
content = await response.read()
if content:
# 校验文件头
is_valid_image = False
if content.startswith(b"\xff\xd8"): # JPEG
is_valid_image = True
elif content.startswith(b"\x89PNG\r\n\x1a\n"): # PNG
is_valid_image = True
elif content.startswith(b"GIF8"): # GIF
is_valid_image = True
elif (
content.startswith(b"RIFF") and b"WEBP" in content[:16]
): # WebP
is_valid_image = True
return self._get_default_avatar_base64() if is_valid_image:
file_content = content
else:
logger.warning(
f"下载的头像数据格式无效 ({safe_avatar_url})"
)
else:
logger.warning(
f"下载头像失败 {safe_avatar_url}: {response.status}"
)
except Exception as e:
logger.warning(f"下载头像网络错误 {safe_avatar_url}: {e}")
except Exception as e: return file_content
logger.error(f"获取用户头像失败 {user_id}: {e}")
return self._get_default_avatar_base64()
def _get_default_avatar_base64(self) -> str: def _get_default_avatar_base64(self) -> str:
"""返回默认头像 (灰色圆形占位符)""" """返回默认头像 (灰色圆形占位符)"""
import base64
# 一个简单的灰色圆圈 SVG 转 Base64 # 一个简单的灰色圆圈 SVG 转 Base64
svg = '<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="50" r="50" fill="#ddd"/></svg>' svg = '<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="50" r="50" fill="#ddd"/></svg>'
b64 = base64.b64encode(svg.encode("utf-8")).decode("utf-8") b64 = base64.b64encode(svg.encode("utf-8")).decode("utf-8")
return f"data:image/svg+xml;base64,{b64}" return f"data:image/svg+xml;base64,{b64}"
def close(self):
"""释放资源,关闭缓存和 session"""
try:
if self._avatar_cache:
self._avatar_cache.close()
logger.debug("头像缓存已关闭")
except Exception as e:
logger.warning(f"关闭头像缓存失败: {e}")
async def _html_to_pdf(self, html_content: str, output_path: str) -> bool: async def _html_to_pdf(self, html_content: str, output_path: str) -> bool:
"""将 HTML 内容转换为 PDF 文件""" """将 HTML 内容转换为 PDF 文件"""
try: try: