diff --git a/src/infrastructure/reporting/generators.py b/src/infrastructure/reporting/generators.py
index 798362f..4a7f020 100644
--- a/src/infrastructure/reporting/generators.py
+++ b/src/infrastructure/reporting/generators.py
@@ -5,6 +5,7 @@
import asyncio
import base64
+import hashlib
import html
import json
import os
@@ -28,6 +29,10 @@ from .templates import HTMLTemplates
MAX_CONCURRENT_DOWNLOADS = 10
AVATAR_CACHE_EXPIRE_TIME = 259200
+TRANSPARENT_IMAGE_DATA_URI = (
+ "data:image/svg+xml;base64,"
+ "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxIiBoZWlnaHQ9IjEiPjwvc3ZnPg=="
+)
DEFAULT_PROFILE_MAPPING = {
"mbti": {
@@ -360,6 +365,11 @@ class ReportGenerator(IReportGenerator):
html_content = self.html_templates.render_template(
"image_template.html", **render_payload
)
+ html_content = self._reuse_avatars_in_final_html(
+ html_content,
+ render_payload.get("avatar_reuse_registry", {}),
+ render_payload.get("avatar_reuse_aliases", {}),
+ )
# 检查HTML内容是否有效
if not html_content:
@@ -543,6 +553,11 @@ class ReportGenerator(IReportGenerator):
html_content = self.html_templates.render_template(
"html_template.html", **render_data
)
+ html_content = self._reuse_avatars_in_final_html(
+ html_content,
+ render_data.get("avatar_reuse_registry", {}),
+ render_data.get("avatar_reuse_aliases", {}),
+ )
logger.info("使用 html_template.html 渲染成功")
except Exception as e:
logger.warning(
@@ -551,6 +566,11 @@ class ReportGenerator(IReportGenerator):
html_content = self.html_templates.render_template(
"image_template.html", **render_data
)
+ html_content = self._reuse_avatars_in_final_html(
+ html_content,
+ render_data.get("avatar_reuse_registry", {}),
+ render_data.get("avatar_reuse_aliases", {}),
+ )
logger.info("使用 image_template.html 渲染成功")
# 检查HTML内容是否有效
@@ -687,6 +707,8 @@ class ReportGenerator(IReportGenerator):
max_topics = self.config_manager.get_max_topics()
topics_list = []
user_analysis = analysis_result.get("user_analysis")
+ avatar_reuse_registry: dict[str, str] = {}
+ avatar_reuse_aliases: dict[str, str] = {}
for i, topic in enumerate(topics[:max_topics], 1):
# 处理话题详情中的用户引用头像
@@ -696,6 +718,8 @@ class ReportGenerator(IReportGenerator):
nickname_getter,
user_analysis,
avatar_cache_namespace,
+ avatar_reuse_registry,
+ avatar_reuse_aliases,
)
topics_list.append(
{
@@ -717,9 +741,16 @@ class ReportGenerator(IReportGenerator):
profile_mode = self.config_manager.get_profile_display_mode()
profile_mapping_overrides = self._get_profile_mapping_overrides()
for title in user_titles[:max_user_titles]:
+ user_id = str(title.user_id)
# 获取用户头像
avatar_data = await self._get_user_avatar(
- str(title.user_id), avatar_url_getter, avatar_cache_namespace
+ user_id, avatar_url_getter, avatar_cache_namespace
+ )
+ self._register_reusable_avatar(
+ avatar_data,
+ avatar_reuse_registry,
+ avatar_reuse_aliases,
+ avatar_key=self._get_avatar_cache_key(user_id, avatar_cache_namespace),
)
profile_info = self._resolve_profile_info(
title.mbti, profile_mode, profile_mapping_overrides
@@ -743,15 +774,25 @@ class ReportGenerator(IReportGenerator):
max_golden_quotes = self.config_manager.get_max_golden_quotes()
quotes_list = []
for golden_quote in stats.golden_quotes[:max_golden_quotes]:
+ quote_user_id = str(golden_quote.user_id) if golden_quote.user_id else None
avatar_url = (
await self._get_user_avatar(
- str(golden_quote.user_id),
+ quote_user_id,
avatar_url_getter,
avatar_cache_namespace,
)
- if golden_quote.user_id
+ if quote_user_id
else None
)
+ if quote_user_id:
+ self._register_reusable_avatar(
+ avatar_url,
+ avatar_reuse_registry,
+ avatar_reuse_aliases,
+ avatar_key=self._get_avatar_cache_key(
+ quote_user_id, avatar_cache_namespace
+ ),
+ )
# 处理解析锐评中的用户引用头像
processed_reason = await self._render_mentions(
golden_quote.reason,
@@ -759,6 +800,8 @@ class ReportGenerator(IReportGenerator):
nickname_getter,
user_analysis,
avatar_cache_namespace,
+ avatar_reuse_registry,
+ avatar_reuse_aliases,
)
quotes_list.append(
{
@@ -841,6 +884,8 @@ class ReportGenerator(IReportGenerator):
"completion_tokens": stats.token_usage.completion_tokens
if stats.token_usage.completion_tokens
else 0,
+ "avatar_reuse_registry": avatar_reuse_registry,
+ "avatar_reuse_aliases": avatar_reuse_aliases,
}
logger.info(f"渲染数据准备完成,包含 {len(render_data)} 个字段")
@@ -853,6 +898,8 @@ class ReportGenerator(IReportGenerator):
nickname_getter=None,
user_analysis: dict | None = None,
avatar_cache_namespace: str | None = None,
+ avatar_reuse_registry: dict[str, str] | None = None,
+ avatar_reuse_aliases: dict[str, str] | None = None,
) -> Markup:
"""
处理文本,将 [123456] 格式的用户引用替换为头像+名称的胶囊样式
@@ -905,9 +952,28 @@ class ReportGenerator(IReportGenerator):
else str(uid)
)
+ avatar_ref = self._register_reusable_avatar(
+ final_url,
+ avatar_reuse_registry,
+ avatar_reuse_aliases,
+ avatar_key=self._get_avatar_cache_key(uid, avatar_cache_namespace),
+ )
+ if avatar_ref:
+ avatar_html = (
+ f''
+ )
+ else:
+ avatar_html = (
+ f'
'
+ )
+
return Markup(
f''
- f'
'
+ f"{avatar_html}"
f'{html.escape(final_name)}'
""
)
@@ -946,6 +1012,127 @@ class ReportGenerator(IReportGenerator):
# Telegram file URL: .../file/bot/
return re.sub(r"/bot[^/]+/", "/bot/", url)
+ @staticmethod
+ def _build_avatar_ref(avatar_key: str | None, avatar_url: str) -> str:
+ """根据稳定输入生成不暴露平台或用户 ID 的头像引用。"""
+ if avatar_key:
+ digest = hashlib.sha256(avatar_key.encode("utf-8")).hexdigest()[:24]
+ return f"avatar-{digest}"
+
+ digest = hashlib.sha256(avatar_url.encode("utf-8")).hexdigest()[:24]
+ return f"avatar-{digest}"
+
+ @staticmethod
+ def _register_reusable_avatar(
+ avatar_url: str | None,
+ avatar_reuse_registry: dict[str, str] | None,
+ avatar_reuse_aliases: dict[str, str] | None = None,
+ avatar_key: str | None = None,
+ ) -> str | None:
+ """将 Data URI 头像登记为可复用资源,并返回短引用 ID。"""
+ if not avatar_url or avatar_reuse_registry is None:
+ return None
+ if not avatar_url.startswith("data:image/"):
+ return None
+
+ if avatar_reuse_aliases and avatar_url in avatar_reuse_aliases:
+ return avatar_reuse_aliases[avatar_url]
+
+ ref = ReportGenerator._build_avatar_ref(avatar_key, avatar_url)
+ avatar_reuse_registry.setdefault(ref, avatar_url)
+ if avatar_reuse_aliases is not None:
+ avatar_reuse_aliases[avatar_url] = ref
+ return ref
+
+ @staticmethod
+ def _build_avatar_reuse_styles(avatar_reuse_registry: dict[str, str]) -> str:
+ """为头像生成一次性复用样式。"""
+ if not avatar_reuse_registry:
+ return ""
+
+ rules = [
+ '")
+ return "\n".join(rules)
+
+ @staticmethod
+ def _reuse_inline_avatar_img_sources(
+ html_content: str,
+ avatar_reuse_registry: dict[str, str],
+ avatar_reuse_aliases: dict[str, str] | None = None,
+ ) -> str:
+ """将最终 HTML 中的内联 Data URI 头像 img 改为短引用。"""
+ if not html_content:
+ return html_content
+
+ img_src_pattern = re.compile(
+ r'(
]*?\bsrc\s*=\s*)(["\'])(data:image/[^"\']+)(\2)([^>]*>)',
+ re.IGNORECASE | re.DOTALL,
+ )
+
+ def replace(match: re.Match[str]) -> str:
+ prefix, quote_char, data_uri, _, suffix = match.groups()
+ if data_uri == TRANSPARENT_IMAGE_DATA_URI:
+ return match.group(0)
+
+ avatar_ref = (
+ avatar_reuse_aliases.get(data_uri) if avatar_reuse_aliases else None
+ )
+ if not avatar_ref:
+ return match.group(0)
+
+ escaped_ref = html.escape(avatar_ref, quote=True)
+ return (
+ f"{prefix}{quote_char}{TRANSPARENT_IMAGE_DATA_URI}{quote_char}"
+ f' data-avatar-ref="{escaped_ref}"{suffix}'
+ )
+
+ return img_src_pattern.sub(replace, html_content)
+
+ @staticmethod
+ def _reuse_avatars_in_final_html(
+ html_content: str,
+ avatar_reuse_registry: dict[str, str] | None,
+ avatar_reuse_aliases: dict[str, str] | None = None,
+ ) -> str:
+ """复用最终 HTML 中所有内联头像资源,并注入复用样式。"""
+ if not html_content:
+ return html_content
+
+ registry = avatar_reuse_registry if avatar_reuse_registry is not None else {}
+ aliases = avatar_reuse_aliases if avatar_reuse_aliases is not None else {}
+ html_content = ReportGenerator._reuse_inline_avatar_img_sources(
+ html_content, registry, aliases
+ )
+ return ReportGenerator._inject_avatar_reuse_styles(
+ html_content, ReportGenerator._build_avatar_reuse_styles(registry)
+ )
+
+ @staticmethod
+ def _inject_avatar_reuse_styles(html_content: str, avatar_reuse_styles: str) -> str:
+ """将头像复用样式注入最终 HTML。"""
+ if not html_content or not avatar_reuse_styles:
+ return html_content
+
+ head_close = re.search(r"", html_content, re.IGNORECASE)
+ if head_close:
+ return (
+ html_content[: head_close.start()]
+ + avatar_reuse_styles
+ + "\n"
+ + html_content[head_close.start() :]
+ )
+ return avatar_reuse_styles + "\n" + html_content
+
def _get_avatar_cache_key(
self, avatar_id: str, avatar_cache_namespace: str | None = None
) -> str:
diff --git a/src/infrastructure/reporting/templates/HatsuneMiku/html_template.html b/src/infrastructure/reporting/templates/HatsuneMiku/html_template.html
index b2477be..5c69cb4 100644
--- a/src/infrastructure/reporting/templates/HatsuneMiku/html_template.html
+++ b/src/infrastructure/reporting/templates/HatsuneMiku/html_template.html
@@ -1,4 +1,5 @@
-
+{% import "inline_assets.html" as inline_assets %}
+
@@ -283,6 +284,11 @@
gap: 8px;
}
+ .active-peak-block>div:first-child {
+ min-width: 0;
+ flex: 1 1 auto;
+ }
+
.peak-time-text {
font-family: var(--font-num);
font-size: 2.8rem;
@@ -292,6 +298,9 @@
letter-spacing: 1px;
position: relative;
z-index: 2;
+ white-space: nowrap;
+ word-break: keep-all;
+ overflow-wrap: normal;
}
/* 图片容器样式:顶天立地,完全不拉伸 */
@@ -301,6 +310,7 @@
width: auto;
max-width: 45%;
/* 防止图片太宽挤扁文字 */
+ flex: 0 1 150px;
display: flex;
align-items: center;
justify-content: flex-end;
@@ -311,13 +321,13 @@
/* 在水印上方,但在文字下方 */
}
- .peak-image-container img {
+ .miku-peak-activity-image {
height: 100%;
/* 核心:图片高度占满容器 */
- width: auto;
- /* 宽度自适应 */
- object-fit: contain;
+ width: 150px;
+ max-width: 100%;
/* 核心:保持原比例,不拉伸,完整显示在框内 */
+ background: var(--miku-peak-activity-image) center / contain no-repeat;
border-radius: 6px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
}
@@ -457,7 +467,6 @@
.card-common,
.section-title-text,
.stat-num,
- .peak-time-text,
.token-data,
.footer-repo a {
overflow-wrap: anywhere;
@@ -538,15 +547,20 @@
}
.peak-time-text {
- font-size: 2rem;
+ font-size: clamp(1.55rem, 5.6vw, 2rem);
line-height: 1.2;
}
.peak-image-container {
- max-width: 34%;
+ flex-basis: 112px;
+ max-width: 28%;
padding: 12px 0;
}
+ .miku-peak-activity-image {
+ width: 112px;
+ }
+
.miku-activity-chart {
gap: 8px !important;
}
@@ -636,15 +650,18 @@
}
.active-peak-block {
- flex-direction: row;
+ flex-direction: column;
height: auto;
padding: 18px;
- align-items: center;
+ align-items: flex-start;
+ gap: 12px;
}
.peak-image-container {
- max-width: 30%;
- height: 84px;
+ align-self: flex-end;
+ flex-basis: auto;
+ max-width: 100%;
+ height: 72px;
margin-top: 0;
padding: 0;
}
@@ -654,7 +671,8 @@
}
.peak-time-text {
- font-size: 1.55rem;
+ font-size: clamp(1.35rem, 8vw, 1.7rem);
+ white-space: normal;
}
.miku-topic-card {
@@ -691,6 +709,7 @@
}
}
+{{ inline_assets.peak_activity_image_css() | safe }}
@@ -746,8 +765,7 @@
-

+
@@ -858,6 +876,3 @@
-
-
-
diff --git a/src/infrastructure/reporting/templates/HatsuneMiku/image_template.html b/src/infrastructure/reporting/templates/HatsuneMiku/image_template.html
index 819f79b..295d0c0 100644
--- a/src/infrastructure/reporting/templates/HatsuneMiku/image_template.html
+++ b/src/infrastructure/reporting/templates/HatsuneMiku/image_template.html
@@ -1,4 +1,5 @@
-
+{% import "inline_assets.html" as inline_assets %}
+
@@ -283,6 +284,11 @@
gap: 8px;
}
+ .active-peak-block>div:first-child {
+ min-width: 0;
+ flex: 1 1 auto;
+ }
+
.peak-time-text {
font-family: var(--font-num);
font-size: 2.8rem;
@@ -292,6 +298,9 @@
letter-spacing: 1px;
position: relative;
z-index: 2;
+ white-space: nowrap;
+ word-break: keep-all;
+ overflow-wrap: normal;
}
/* 图片容器样式:顶天立地,完全不拉伸 */
@@ -301,6 +310,7 @@
width: auto;
max-width: 45%;
/* 防止图片太宽挤扁文字 */
+ flex: 0 1 150px;
display: flex;
align-items: center;
justify-content: flex-end;
@@ -311,13 +321,13 @@
/* 在水印上方,但在文字下方 */
}
- .peak-image-container img {
+ .miku-peak-activity-image {
height: 100%;
/* 核心:图片高度占满容器 */
- width: auto;
- /* 宽度自适应 */
- object-fit: contain;
+ width: 150px;
+ max-width: 100%;
/* 核心:保持原比例,不拉伸,完整显示在框内 */
+ background: var(--miku-peak-activity-image) center / contain no-repeat;
border-radius: 6px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
}
@@ -444,9 +454,38 @@
color: var(--text-sub);
}
+ @media (max-width: 760px) {
+ .active-peak-block {
+ min-height: 112px;
+ height: auto;
+ padding: 20px;
+ align-items: center;
+ }
+
+ .active-peak-block::after {
+ right: -20px;
+ font-size: 104px;
+ }
+
+ .peak-time-text {
+ font-size: clamp(1.55rem, 5.6vw, 2rem);
+ line-height: 1.2;
+ }
+
+ .peak-image-container {
+ flex-basis: 112px;
+ max-width: 28%;
+ padding: 12px 0;
+ }
+
+ .miku-peak-activity-image {
+ width: 112px;
+ }
+ }
+
@media (max-width: 600px) {
.stats-row {
- grid-template-columns: repeat(2, 1fr);
+ grid-template-columns: repeat(2, minmax(0, 1fr));
}
.footer-top,
@@ -463,17 +502,31 @@
.active-peak-block {
flex-direction: column;
height: auto;
- padding: 25px;
+ padding: 18px;
align-items: flex-start;
+ gap: 12px;
}
.peak-image-container {
+ align-self: flex-end;
+ flex-basis: auto;
max-width: 100%;
- height: auto;
- margin-top: 15px;
+ height: 72px;
+ margin-top: 0;
+ padding: 0;
+ }
+
+ .peak-label {
+ font-size: 0.9rem;
+ }
+
+ .peak-time-text {
+ font-size: clamp(1.35rem, 8vw, 1.7rem);
+ white-space: normal;
}
}
+{{ inline_assets.peak_activity_image_css() | safe }}
@@ -529,8 +582,7 @@
-

+
@@ -642,5 +694,3 @@
-
-
diff --git a/src/infrastructure/reporting/templates/HatsuneMiku/inline_assets.html b/src/infrastructure/reporting/templates/HatsuneMiku/inline_assets.html
new file mode 100644
index 0000000..bb11e84
--- /dev/null
+++ b/src/infrastructure/reporting/templates/HatsuneMiku/inline_assets.html
@@ -0,0 +1,7 @@
+{% macro peak_activity_image_css() -%}
+
+{%- endmacro %}