From 854b8c280a7bd37125ac2968ec1ec0838e8a948f Mon Sep 17 00:00:00 2001 From: leko Date: Sun, 26 Apr 2026 14:36:14 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=8A=A5=E5=91=8A=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E4=B8=AD=E7=9A=84=E5=A4=B4=E5=83=8F=E4=B8=8E=E5=86=85?= =?UTF-8?q?=E8=81=94=E5=9B=BE=E7=89=87=E5=A4=8D=E7=94=A8=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Miku=20=E5=B3=B0=E5=80=BC=E5=8C=BA?= =?UTF-8?q?=E5=9D=97=E7=AA=84=E5=B1=8F=E6=8E=92=E7=89=88=20(#178)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 优化报告模板中的头像与内联图片复用,并修复 Miku 峰值区块窄屏排版 * refactor(avatar): improve avatar reference generation to enhance privacy by avoiding exposure of platform or user IDs --- src/infrastructure/reporting/generators.py | 195 +++++++++++++++++- .../templates/HatsuneMiku/html_template.html | 51 +++-- .../templates/HatsuneMiku/image_template.html | 76 +++++-- .../templates/HatsuneMiku/inline_assets.html | 7 + 4 files changed, 294 insertions(+), 35 deletions(-) create mode 100644 src/infrastructure/reporting/templates/HatsuneMiku/inline_assets.html 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 @@
- Peak Activity Visualization +
@@ -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 @@
- Peak Activity Visualization +
@@ -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 %}