Files

339 lines
12 KiB
Python
Raw Permalink Normal View History

"""使用 Playwright 渲染 HTML 模板生成游玩时长表图片
浏览器为进程级单例,首次调用时懒加载;
任何一步失败都会抛出异常,由调用方(命令处理器)回退到文本格式。
"""
import asyncio
import base64
import datetime
import html
import json
import math
import os
import tempfile
from io import BytesIO
from pathlib import Path
from typing import Any, List, Optional
from PIL import Image
from nonebot.log import logger
from . import draw as draw_util
TEMPLATE_PATH = Path(__file__).parent / "res" / "playtime_template.html"
ECHARTS_PATH = Path(__file__).parent / "res" / "echarts.min.js"
# ---- 主题色板:CSS 变量注入模板 :root,ECharts 颜色同样读 CSS 变量 ----
# 亮色在 #f6f6f7 表面上通过 dataviz 校验;暗色与插件旧卡片风格一致
THEMES: dict[str, dict] = {
"light": {
"--surface": "#f6f6f7",
"--card": "#ffffff",
"--hairline": "#e3e3e6",
"--text-primary": "#1a1a1a",
"--text-secondary": "#52514e",
"--text-muted": "#898781",
"--axis": "#c3c2b7",
"--bar": "#2a78d6",
"--bar-fill-top": "#5598e7",
"--accent": "#2a78d6",
"--game-text": "#242424",
"--fallback-bg": "#f0f0f2",
"pie_colors": [
"#2a78d6", "#eb6834", "#1baf7a", "#eda100",
"#e87ba4", "#008300", "#4a3aa7", "#e34948",
],
"other_color": "#898781",
"badge_colors": ["#c98500", "#898781", "#d95926"], # 金/银/铜
},
"dark": {
"--surface": "#333439",
"--card": "#2e2e33",
"--hairline": "#46464a",
"--text-primary": "#ffffff",
"--text-secondary": "#c3c2b7",
"--text-muted": "#898781",
"--axis": "#383835",
"--bar": "#3987e5",
"--bar-fill-top": "#6aa9e8",
"--accent": "#53a4c4",
"--game-text": "#e8e8e8",
"--fallback-bg": "#2e2e33",
"pie_colors": [
"#3987e5", "#d95926", "#199e70", "#c98500",
"#d55181", "#008300", "#9085e9", "#e66767",
],
"other_color": "#898781",
"badge_colors": ["#c98500", "#898781", "#d95926"], # 金/银/铜
},
}
# 柱状图分桶标签:0 时长(未游玩)单独一桶,便于全库统计
BUCKET_LABELS = ["0", "<1", "1~10", "10~50", "50~100", "100~500", "500+"]
CANVAS_W = 1400
TABLE_ROWS = 20
PIE_TOP = 8
_pw: Optional[Any] = None
_browser: Optional[Any] = None
_browser_lock: Optional[asyncio.Lock] = None
def format_hours(hours: float) -> str:
"""时长人性化:>=100h 取整加千分位, >=1h 保留一位小数, <1h 显示分钟"""
if hours >= 100:
# 四舍五入取整(round 是银行家舍入,1842.5 会得到 1842)
return f"{int(math.floor(hours + 0.5)):,} 小时"
if hours >= 1:
return f"{hours:.1f} 小时"
return f"{max(1, int(round(hours * 60)))} 分钟"
def _truncate_chars(text: str, max_len: int) -> str:
"""按字符数截断,超长以省略号结尾(用于图例等紧凑场景)"""
return text if len(text) <= max_len else text[: max_len - 1] + "…"
def _font_faces() -> str:
"""注入 MiSans 字体(file:// 绝对路径,由 draw.set_font_paths 解析)"""
faces = []
for family, path in (
("MiSansR", draw_util.font_regular_path),
("MiSansB", draw_util.font_bold_path),
):
if path and Path(path).exists():
faces.append(
f"@font-face{{font-family:'{family}';src:url('{Path(path).as_uri()}');}}"
)
return "\n".join(faces)
def _avatar_html(avatar: Optional[Any], nickname: str) -> str:
"""头像转 data URI 内联;缺失时用昵称首字符的占位圆块"""
if avatar is not None:
try:
if isinstance(avatar, Image.Image):
buf = BytesIO()
avatar.convert("RGB").save(buf, format="PNG")
mime, data = "image/png", buf.getvalue()
else:
mime, data = "image/jpeg", bytes(avatar)
uri = "data:" + mime + ";base64," + base64.b64encode(data).decode()
return f'<img class="avatar" src="{uri}">'
except Exception:
pass
char = html.escape(nickname[:1], quote=False) if nickname else "?"
return f'<div class="avatar avatar-fallback">{char}</div>'
def _theme_css(theme: str) -> str:
"""主题 CSS 变量块,覆盖模板 :root 默认(亮色)值"""
t = THEMES.get(theme, THEMES["light"])
vars_css = "".join(
f"{k}:{v};" for k, v in t.items() if k.startswith("--")
)
return f":root{{{vars_css}}}"
def _table_rows(
games: List[dict], start_rank: int, badge_colors: List[str]
) -> str:
"""生成单列表格行(排名徽章 + 游戏名 + 时长)"""
rows = []
for i, g in enumerate(games, start_rank):
if i <= 3:
badge = (
f'<span class="rank-badge" style="background:{badge_colors[i - 1]}">'
f"{i}</span>"
)
else:
badge = f'<span class="rank-plain">{i}</span>'
rows.append(
"<tr>"
f'<td class="rank">{badge}</td>'
f'<td class="game">{html.escape(g["name"], quote=False)}</td>'
f'<td class="hours">{format_hours(g["playtime_hours"])}</td>'
"</tr>"
)
return "\n".join(rows)
def _build_html(
games: List[dict],
all_games: Optional[List[dict]] = None,
steam_name: str = "",
plugin_nick: str = "",
avatar: Optional[Any] = None,
theme: str = "light",
) -> str:
"""把游戏数据填入模板,返回完整 HTML 字符串
Args:
games: 已游玩游戏(playtime_hours > 0, 已排序),用于表格与饼图。
all_games: 全库游戏(含 0 时长),用于柱状图分段统计与总数;缺省时用 games。
steam_name: Steam 昵称(常显)。
plugin_nick: 插件内设置的昵称,设置了才显示。
avatar: 头像图片(PIL Image 或 JPEG bytes),缺失时显示占位块。
theme: 主题 light / dark,默认亮色。
"""
t = THEMES.get(theme, THEMES["light"])
all_games = all_games if all_games is not None else games
total_hours = sum(g["playtime_hours"] for g in games)
top_games = games[:TABLE_ROWS]
rows_left = _table_rows(top_games[:10], 1, t["badge_colors"])
rows_right = _table_rows(top_games[10:], 11, t["badge_colors"])
right_table = (
"<table>"
"<colgroup>"
'<col class="rank"><col><col class="hours">'
"</colgroup>"
"<thead><tr>"
'<th class="rank">排名</th><th>游戏</th><th class="hours">时长</th>'
"</tr></thead>"
f"<tbody>{rows_right}</tbody></table>"
if rows_right
else ""
)
pie_games = games[:PIE_TOP]
other_hours = sum(g["playtime_hours"] for g in games[PIE_TOP:])
pie_items = [
{"name": _truncate_chars(g["name"], 16), "value": round(g["playtime_hours"], 2)}
for g in pie_games
]
if other_hours > 0:
pie_items.append({"name": "其他", "value": round(other_hours, 2)})
pie_colors = t["pie_colors"][: len(pie_games)] + (
[t["other_color"]] if other_hours > 0 else []
)
# 全库按时长分段:0 时长单独一桶,数量差异大时对数坐标避免小桶被压扁
counts = [
sum(1 for g in all_games if g["playtime_hours"] == 0),
sum(1 for g in all_games if 0 < g["playtime_hours"] < 1),
sum(1 for g in all_games if 1 <= g["playtime_hours"] < 10),
sum(1 for g in all_games if 10 <= g["playtime_hours"] < 50),
sum(1 for g in all_games if 50 <= g["playtime_hours"] < 100),
sum(1 for g in all_games if 100 <= g["playtime_hours"] < 500),
sum(1 for g in all_games if g["playtime_hours"] >= 500),
]
data = {
"pie": {"items": pie_items, "total": round(total_hours, 2), "colors": pie_colors},
"bar": {"names": BUCKET_LABELS, "counts": counts},
}
template = TEMPLATE_PATH.read_text(encoding="utf-8")
generated = datetime.datetime.now(
datetime.timezone(datetime.timedelta(hours=8))
).strftime("%Y-%m-%d %H:%M:%S")
footer_parts = []
if len(games) > TABLE_ROWS:
footer_parts.append(f"仅展示 Top {TABLE_ROWS},共 {len(games)} 款游戏")
footer_parts.append(f"生成时间:{generated}")
# 群内昵称:小字挂在 Steam 名后,超长截断,避免画出图片外
plugin_tag = (
f'<span class="nick-tag">{html.escape(_truncate_chars(plugin_nick, 18), quote=False)}</span>'
if plugin_nick
else ""
)
return (
template.replace("__THEME_CSS__", _theme_css(theme))
.replace("__ECHARTS__", ECHARTS_PATH.as_uri())
.replace("__FONT_REGULAR__", _font_url(draw_util.font_regular_path))
.replace("__FONT_BOLD__", _font_url(draw_util.font_bold_path))
.replace("__AVATAR__", _avatar_html(avatar, steam_name))
.replace("__STEAM_NAME__", html.escape(steam_name, quote=False))
.replace("__PLUGIN_TAG__", plugin_tag)
.replace("__GAME_COUNT__", str(len(all_games)))
.replace("__TOTAL_HOURS__", format_hours(total_hours))
.replace("__ROWS_LEFT__", rows_left)
.replace("__RIGHT_TABLE__", right_table)
.replace("__FOOTER__", " · ".join(footer_parts))
# 防注入:JSON 中的 </script> 及 < 均被转义
.replace(
"__DATA__",
json.dumps(data, ensure_ascii=False).replace("<", "\\u003c"),
)
)
def _font_url(path: Optional[str]) -> str:
"""字体 file:// URL;不可用时留空使 @font-face 失效,回退系统字体"""
if path and Path(path).exists():
return Path(path).as_uri()
return ""
async def _ensure_browser():
"""获取进程级单例浏览器(线程安全,幂等)"""
global _pw, _browser, _browser_lock
if _browser_lock is None:
_browser_lock = asyncio.Lock()
async with _browser_lock:
if _browser is None or not _browser.is_connected():
from playwright.async_api import async_playwright
_pw = await async_playwright().start()
_browser = await _pw.chromium.launch(
args=["--force-color-profile=srgb"],
)
return _browser
async def render_play_time_table(
games: List[dict],
all_games: Optional[List[dict]] = None,
steam_name: str = "",
plugin_nick: str = "",
avatar: Optional[Any] = None,
theme: str = "light",
) -> bytes:
"""渲染游玩时长表为 PNG 字节;任何失败抛异常,由调用方提示失败
Args:
games: 已游玩游戏(playtime_hours > 0, 降序),用于表格与饼图。
all_games: 全库游戏(含 0 时长),用于柱状图分段统计与总数;缺省时用 games。
steam_name: Steam 昵称(常显)。
plugin_nick: 插件内设置的昵称,设置了才显示。
avatar: 头像图片(PIL Image 或 JPEG bytes),缺失时显示占位块。
theme: 主题 light / dark,默认亮色。
"""
logger.info(f"游玩时长表:构建 HTML 模板, theme={theme}")
html = _build_html(games, all_games, steam_name, plugin_nick, avatar, theme)
logger.info("游玩时长表:获取浏览器实例")
browser = await _ensure_browser()
page = await browser.new_page(
viewport={"width": CANVAS_W, "height": 1700},
device_scale_factor=2,
)
# 捕获页面 JS 错误,便于定位图表渲染问题
page.on("pageerror", lambda exc: logger.error(f"游玩时长表页面 JS 异常: {exc}"))
page.on(
"console",
lambda msg: logger.error(f"游玩时长表页面 console: {msg.text}")
if msg.type == "error"
else None,
)
tmp_path: Optional[str] = None
try:
fd, tmp_path = tempfile.mkstemp(suffix=".html", prefix="playtime_")
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(html)
logger.info("游玩时长表:页面加载中")
await page.goto(Path(tmp_path).as_uri(), wait_until="load")
await page.wait_for_function("window.__rendered === true", timeout=15000)
await page.evaluate("document.fonts.ready")
img_bytes = await page.screenshot(full_page=True)
logger.info(f"游玩时长表:截图完成, {len(img_bytes)} bytes")
return img_bytes
finally:
await page.close()
if tmp_path:
try:
os.unlink(tmp_path)
except OSError:
pass