Add HeXi bot codebase: custom plugins, web frontends, tests
- 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>
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
"""使用 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
|
||||
Reference in New Issue
Block a user