- hexi core: message handling, rate limiting, cooldown, plugin manager - Custom plugins: BF stats, daily check-in, quotes, persona cards, etc. - Community plugins vendored under hexi/plugins with local fixes - Web admin frontends (learning-chat, persona-admin), unified hexi/web - Tests for rate_limit/cooldown/memes/persona; poetry.lock Co-Authored-By: Claude <noreply@anthropic.com>
339 lines
12 KiB
Python
339 lines
12 KiB
Python
"""使用 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
|