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>
@@ -0,0 +1,964 @@
|
||||
import asyncio
|
||||
import time
|
||||
import httpx
|
||||
import nonebot
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from nonebot.log import logger
|
||||
from PIL import Image as PILImage
|
||||
from nonebot.params import Depends
|
||||
from nonebot.params import CommandArg
|
||||
from nonebot import on_command, require
|
||||
from nonebot.permission import SUPERUSER
|
||||
from typing import Optional, List, Dict
|
||||
from nonebot.adapters import Message, Event
|
||||
from nonebot.plugin import PluginMetadata, inherit_supported_adapters
|
||||
|
||||
require("nonebot_plugin_alconna")
|
||||
require("nonebot_plugin_localstore")
|
||||
require("nonebot_plugin_apscheduler")
|
||||
from nonebot_plugin_alconna.uniseg import get_target as uni_get_target
|
||||
import nonebot_plugin_localstore as store
|
||||
from nonebot_plugin_apscheduler import scheduler
|
||||
from nonebot_plugin_alconna import Text, Image, UniMessage, Target, At
|
||||
from nonebot.adapters.onebot.v11 import Bot, MessageEvent, MessageSegment
|
||||
from hexi.plugins.nonebot_plugin_hexi_core.cooldown import cooldown
|
||||
from .config import Config
|
||||
from .models import ProcessedPlayer
|
||||
from .data_source import BindData, SteamInfoData, ParentData, DisableParentData
|
||||
from .steam import (
|
||||
get_steam_id,
|
||||
get_user_data,
|
||||
STEAM_ID_OFFSET,
|
||||
get_steam_users_info,
|
||||
init_api_key_manager,
|
||||
get_play_time as fetch_play_time,
|
||||
get_game_cn_names,
|
||||
localize_game_name,
|
||||
migrate_game_names_json,
|
||||
update_steamdb_apps,
|
||||
_is_full_sync_done,
|
||||
_query_game_names,
|
||||
_query_game_icon_hashes,
|
||||
_upsert_game_names,
|
||||
_upsert_game_icon_hashes,
|
||||
)
|
||||
from .draw import (
|
||||
check_font,
|
||||
set_font_paths,
|
||||
draw_start_gaming,
|
||||
draw_player_status,
|
||||
draw_friends_status,
|
||||
vertically_concatenate_images,
|
||||
)
|
||||
from .utils import (
|
||||
fetch_avatar,
|
||||
get_game_icons,
|
||||
image_to_bytes,
|
||||
simplize_steam_player_data,
|
||||
)
|
||||
|
||||
__plugin_meta__ = PluginMetadata(
|
||||
name="Steam信息",
|
||||
description="播报绑定的 Steam 好友状态",
|
||||
usage="""
|
||||
steamhelp: 查看帮助
|
||||
steambind [Steam ID 或 Steam 好友代码]: 绑定 Steam ID
|
||||
steamunbind: 解绑 Steam ID
|
||||
steaminfo (可选)[@某人 或 Steam ID 或 Steam好友代码]: 查看 Steam 主页
|
||||
steamcheck: 查看 Steam 好友状态
|
||||
steamenable: 启用 Steam 播报
|
||||
steamdisable: 禁用 Steam 播报
|
||||
steamplaytime (可选)[Steam ID 或 Steam好友代码]: 查看游玩时长排行
|
||||
steamdbupdate: 更新 SteamDB 游戏名映射(仅限超级管理员)
|
||||
(群名/群头像/群内昵称均由机器人自动获取,无需手动设置)
|
||||
""".strip(),
|
||||
type="application",
|
||||
homepage="https://github.com/zhaomaoniu/nonebot-plugin-steam-info",
|
||||
config=Config,
|
||||
supported_adapters=inherit_supported_adapters("nonebot_plugin_alconna"),
|
||||
)
|
||||
|
||||
help = on_command("steamhelp", aliases={"steam帮助"}, priority=10)
|
||||
bind = on_command("steambind", aliases={"绑定steam"}, priority=10)
|
||||
unbind = on_command("steamunbind", aliases={"解绑steam"}, priority=10)
|
||||
info = on_command("steaminfo", aliases={"steam信息"}, priority=10)
|
||||
# 谁在玩游戏:全群共享 CD,防一人狂点挤占 API 配额(steam.py 内另有总配额限频)
|
||||
check = cooldown(30, scope="group", hint="群里刚有人查过,{secs} 秒后再试")(
|
||||
on_command("steamcheck", aliases={"查看steam", "查steam", "谁在玩游戏"}, priority=10)
|
||||
)
|
||||
enable = on_command("steamenable", aliases={"启用steam"}, priority=10)
|
||||
disable = on_command("steamdisable", aliases={"禁用steam"}, priority=10)
|
||||
# 游戏时长表:单用户 15s CD
|
||||
get_play_time = cooldown(15, hint="查询太频繁了,{secs} 秒后再试")(
|
||||
on_command("steamplaytime", aliases={"游戏时长表"}, priority=10)
|
||||
)
|
||||
# 全量拉取 SteamDB 较重,仅限超级管理员触发
|
||||
update_steamdb = on_command(
|
||||
"steamdbupdate", aliases={"更新steamdb"}, priority=10, permission=SUPERUSER
|
||||
)
|
||||
|
||||
if hasattr(nonebot, "get_plugin_config"):
|
||||
config = nonebot.get_plugin_config(Config)
|
||||
else:
|
||||
from nonebot import get_driver
|
||||
|
||||
config = Config.parse_obj(get_driver().config)
|
||||
|
||||
set_font_paths(
|
||||
config.steam_font_regular_path,
|
||||
config.steam_font_light_path,
|
||||
config.steam_font_bold_path,
|
||||
)
|
||||
|
||||
# 全部数据整合进单一 SQLite(绑定/播报状态/群信息/禁用/游戏名映射),
|
||||
# 存插件目录 data/ 下,迁移只拷一个文件;头像缓存保留在 localstore
|
||||
_plugin_data_dir = Path(__file__).resolve().parent / "data"
|
||||
_plugin_data_dir.mkdir(parents=True, exist_ok=True)
|
||||
db_path = _plugin_data_dir / "steam.db"
|
||||
# 一次性迁移旧 JSON 游戏名映射到 SQLite(其余数据类内部自带迁移)
|
||||
migrate_game_names_json(_plugin_data_dir / "game_names.json", db_path)
|
||||
avatar_path = store.get_cache_dir("nonebot_plugin_steam_info")
|
||||
cache_path = avatar_path
|
||||
|
||||
bind_data = BindData(db_path)
|
||||
steam_info_data = SteamInfoData(db_path)
|
||||
parent_data = ParentData(db_path)
|
||||
disable_parent_data = DisableParentData(db_path)
|
||||
|
||||
try:
|
||||
check_font()
|
||||
except FileNotFoundError as e:
|
||||
logger.error(
|
||||
f"{e}, nonebot_plugin_steam_info 无法使用,请参照 `https://github.com/zhaomaoniu/nonebot-plugin-steam-info` 配置字体文件"
|
||||
)
|
||||
|
||||
try:
|
||||
config = nonebot.get_plugin_config(Config)
|
||||
init_api_key_manager(config, db_path)
|
||||
logger.success("Steam API key 管理器已初始化")
|
||||
except Exception as e:
|
||||
logger.error(f"初始化失败: {e}")
|
||||
|
||||
# 统一配置标准:把常见可调配置暴露给 Web 管理台
|
||||
from hexi.config_standard import register_model_config # noqa: E402
|
||||
|
||||
register_model_config(
|
||||
__name__,
|
||||
config,
|
||||
fields=[
|
||||
"steam_api_key",
|
||||
"steam_request_interval",
|
||||
"steam_broadcast_type",
|
||||
"steam_playtime_theme",
|
||||
"steam_disable_broadcast_on_startup",
|
||||
"proxy",
|
||||
],
|
||||
types={
|
||||
"steam_api_key": "text",
|
||||
"steam_broadcast_type": "enum",
|
||||
"steam_playtime_theme": "enum",
|
||||
},
|
||||
options={
|
||||
"steam_broadcast_type": [
|
||||
{"value": "all", "label": "全部(带图)"},
|
||||
{"value": "part", "label": "部分(开始游戏图)"},
|
||||
{"value": "none", "label": "仅文字"},
|
||||
],
|
||||
"steam_playtime_theme": [
|
||||
{"value": "light", "label": "亮色"},
|
||||
{"value": "dark", "label": "暗色"},
|
||||
],
|
||||
},
|
||||
labels={
|
||||
"steam_api_key": "Steam Web API Key",
|
||||
"steam_request_interval": "播报轮询间隔(秒)",
|
||||
"steam_broadcast_type": "播报类型",
|
||||
"steam_playtime_theme": "时长表主题",
|
||||
"steam_disable_broadcast_on_startup": "启动时不播报",
|
||||
"proxy": "代理",
|
||||
},
|
||||
descriptions={
|
||||
"steam_api_key": "每行一个,可多 key 轮询;改后需重启生效",
|
||||
"steam_request_interval": "播报轮询周期(分钟=值/60)",
|
||||
"steam_broadcast_type": "控制播报附带图片的粒度",
|
||||
"steam_playtime_theme": "游玩时长表图片主题",
|
||||
"steam_disable_broadcast_on_startup": "True 时 bot 连接不触发首次播报",
|
||||
"proxy": "如 http://127.0.0.1:7890",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
async def get_target(event: Event, bot: Bot) -> Optional[Target]:
|
||||
target = uni_get_target(event)
|
||||
|
||||
if target.private:
|
||||
# 不支持私聊消息
|
||||
return None
|
||||
|
||||
return target
|
||||
|
||||
|
||||
async def _refresh_parent_info(bot: Bot, parent_id: str) -> None:
|
||||
"""从 QQ 自动获取群名+群头像并更新缓存(steamupdate 指令已移除,一律自动)"""
|
||||
name = None
|
||||
try:
|
||||
group_info = await bot.get_group_info(group_id=int(parent_id))
|
||||
name = group_info.get("group_name") or None
|
||||
except Exception as e:
|
||||
logger.warning(f"获取群 {parent_id} 信息失败: {e}")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(f"http://p.qlogo.cn/gh/{parent_id}/{parent_id}/640")
|
||||
if resp.status_code == 200 and resp.content:
|
||||
avatar = PILImage.open(BytesIO(resp.content))
|
||||
parent_data.update(parent_id, avatar, name or str(parent_id))
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"下载群 {parent_id} 头像失败: {e}")
|
||||
if name:
|
||||
parent_data.update_name(parent_id, name)
|
||||
|
||||
|
||||
async def get_parent_info(bot: Bot, parent_id: str):
|
||||
"""获取群名+群头像:缓存新鲜直接返回,否则从 QQ 刷新(7 天过期)"""
|
||||
if not parent_data.is_fresh(parent_id):
|
||||
await _refresh_parent_info(bot, parent_id)
|
||||
return parent_data.get(parent_id)
|
||||
|
||||
|
||||
async def get_group_nickname(bot: Bot, parent_id: str, user_id: str) -> str:
|
||||
"""获取群内昵称:群名片优先,无名片用群昵称;失败返回空串"""
|
||||
try:
|
||||
info = await bot.get_group_member_info(
|
||||
group_id=int(parent_id), user_id=int(user_id)
|
||||
)
|
||||
return info.get("card") or info.get("nickname") or ""
|
||||
except Exception as e:
|
||||
logger.warning(f"获取群成员 {parent_id}/{user_id} 信息失败: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
async def _fill_missing_icon_hashes(players: list[dict]) -> None:
|
||||
"""按需补 clienticon hash
|
||||
|
||||
对"多人同玩"且本地没有 icon hash 的游戏,用正在玩该游戏的绑定用户 steamid
|
||||
调一次 GetOwnedGames 拿 img_icon_url 写回映射表,避免依赖每日同步才有图标。
|
||||
"""
|
||||
if not players:
|
||||
return
|
||||
|
||||
game_counts: dict[int, int] = {}
|
||||
for p in players:
|
||||
if p.get("gameid"):
|
||||
gid = int(p["gameid"])
|
||||
game_counts[gid] = game_counts.get(gid, 0) + 1
|
||||
|
||||
missing = [
|
||||
gid
|
||||
for gid, cnt in game_counts.items()
|
||||
if cnt >= 2 and not _query_game_icon_hashes(db_path, [gid]).get(gid)
|
||||
]
|
||||
if not missing:
|
||||
return
|
||||
|
||||
missing_set = set(missing)
|
||||
reps: dict[int, str] = {}
|
||||
for p in players:
|
||||
if p.get("gameid") and int(p["gameid"]) in missing_set:
|
||||
reps.setdefault(int(p["gameid"]), p["steamid"])
|
||||
|
||||
for steam_id in set(reps.values()):
|
||||
games = await fetch_play_time(steam_id)
|
||||
if games is None:
|
||||
# 配额不足,等下次再补
|
||||
continue
|
||||
entries = {
|
||||
int(g["appid"]): g.get("clienticon")
|
||||
for g in games
|
||||
if g.get("appid") and g.get("clienticon")
|
||||
}
|
||||
if entries:
|
||||
_upsert_game_icon_hashes(db_path, entries)
|
||||
|
||||
|
||||
def format_play_duration(start_time: Optional[int]) -> str:
|
||||
"""根据游戏开始时间计算游玩时长,缺少开始时间时返回占位文案"""
|
||||
if start_time is None:
|
||||
return "未知时长"
|
||||
elapsed = time.time() - start_time
|
||||
hours = int(elapsed / 3600)
|
||||
minutes = int(elapsed % 3600 / 60)
|
||||
return f"{hours} 小时 {minutes} 分钟" if hours > 0 else f"{minutes} 分钟"
|
||||
|
||||
|
||||
async def broadcast_steam_info(
|
||||
parent_id: str,
|
||||
old_players: List[ProcessedPlayer],
|
||||
new_players: List[ProcessedPlayer],
|
||||
):
|
||||
if disable_parent_data.is_disabled(parent_id):
|
||||
return None
|
||||
|
||||
bot = nonebot.get_bot()
|
||||
|
||||
play_data = steam_info_data.compare(old_players, new_players)
|
||||
|
||||
# 游戏名本地化(中文名优先):只替换播报用的副本,不修改已存储的玩家数据,
|
||||
# 否则下一轮对比时同一游戏的中英文名不一致会被误判为换游戏,反复通报
|
||||
appids = [
|
||||
int(p["gameid"])
|
||||
for entry in play_data
|
||||
for p in (entry["player"], entry.get("old_player"))
|
||||
if p and p.get("gameid")
|
||||
]
|
||||
# 播报每 1~5 分钟一轮:只查本地映射表,不联网补全中文名
|
||||
# (appdetails 高频会被封 IP;缺中文名的游戏留到用户命令查询时再补)
|
||||
names = await get_game_cn_names(appids, db_path, fetch_missing=False)
|
||||
|
||||
def _localized_copy(p: Optional[ProcessedPlayer]) -> Optional[ProcessedPlayer]:
|
||||
if p and p.get("gameid"):
|
||||
return {
|
||||
**p,
|
||||
"gameextrainfo": localize_game_name(
|
||||
p.get("gameextrainfo", ""), int(p["gameid"]), names
|
||||
),
|
||||
}
|
||||
return p
|
||||
|
||||
for entry in play_data:
|
||||
for key in ("player", "old_player"):
|
||||
if entry.get(key):
|
||||
entry[key] = _localized_copy(entry[key])
|
||||
|
||||
msg = []
|
||||
for entry in play_data:
|
||||
player: ProcessedPlayer = entry["player"]
|
||||
old_player: ProcessedPlayer = entry.get("old_player")
|
||||
|
||||
if entry["type"] == "start":
|
||||
msg.append(f"{player['personaname']} 开始玩 {player['gameextrainfo']} 了")
|
||||
elif entry["type"] == "stop":
|
||||
time_str = format_play_duration(old_player["game_start_time"])
|
||||
msg.append(
|
||||
f"{player['personaname']} 玩了 {time_str} {old_player['gameextrainfo']} 后不玩了"
|
||||
)
|
||||
elif entry["type"] == "change":
|
||||
time_str = format_play_duration(old_player["game_start_time"])
|
||||
msg.append(
|
||||
f"{player['personaname']} 玩了 {time_str} {old_player['gameextrainfo']} 后开始玩 {player['gameextrainfo']} 了"
|
||||
)
|
||||
elif entry["type"] == "error":
|
||||
msg.append(
|
||||
f"出现错误!{player['personaname']}\nNew: {player.get('gameextrainfo')}\nOld: {old_player.get('gameextrainfo')}"
|
||||
)
|
||||
else:
|
||||
logger.error(f"未知的播报类型: {entry['type']}")
|
||||
|
||||
if msg == []:
|
||||
return 0
|
||||
|
||||
if config.steam_broadcast_type == "all":
|
||||
# 与 steamcheck 一样:所有游戏都取图标(单人也要图标),商店页优先
|
||||
game_ids: list[int] = []
|
||||
seen: set[int] = set()
|
||||
for p in new_players:
|
||||
if p.get("gameid"):
|
||||
gid = int(p["gameid"])
|
||||
if gid not in seen:
|
||||
seen.add(gid)
|
||||
game_ids.append(gid)
|
||||
game_icons = (
|
||||
await get_game_icons(game_ids, config.proxy, db_path) if game_ids else {}
|
||||
)
|
||||
|
||||
steam_status_data = []
|
||||
for player in (_localized_copy(p) for p in new_players):
|
||||
data = await simplize_steam_player_data(
|
||||
player, config.proxy, avatar_path, names
|
||||
)
|
||||
entry = bind_data.get_by_steam_id(parent_id, player["steamid"])
|
||||
# 绑定的玩家展示群内昵称,未绑定/获取失败则用 Steam 昵称
|
||||
data["nickname"] = (
|
||||
await get_group_nickname(bot, parent_id, entry["user_id"])
|
||||
if entry is not None
|
||||
else None
|
||||
)
|
||||
data["game_icon"] = game_icons.get(data["gameid"])
|
||||
steam_status_data.append(data)
|
||||
|
||||
parent_avatar, parent_name = await get_parent_info(bot, parent_id)
|
||||
image = draw_friends_status(parent_avatar, parent_name, steam_status_data)
|
||||
uni_msg = UniMessage([Text("\n".join(msg)), Image(raw=image_to_bytes(image))])
|
||||
elif config.steam_broadcast_type == "part":
|
||||
images = []
|
||||
for entry in play_data:
|
||||
if entry["type"] != "start":
|
||||
continue
|
||||
user_data = bind_data.get_by_steam_id(
|
||||
parent_id, entry["player"]["steamid"]
|
||||
)
|
||||
nickname = (
|
||||
await get_group_nickname(bot, parent_id, user_data["user_id"])
|
||||
if user_data is not None
|
||||
else None
|
||||
)
|
||||
images.append(
|
||||
draw_start_gaming(
|
||||
(await fetch_avatar(entry["player"], avatar_path, config.proxy)),
|
||||
entry["player"]["personaname"],
|
||||
entry["player"]["gameextrainfo"],
|
||||
nickname,
|
||||
)
|
||||
)
|
||||
if images == []:
|
||||
uni_msg = UniMessage([Text("\n".join(msg))])
|
||||
else:
|
||||
image = (
|
||||
vertically_concatenate_images(images) if len(images) > 1 else images[0]
|
||||
)
|
||||
uni_msg = UniMessage(
|
||||
[Text("\n".join(msg)), Image(raw=image_to_bytes(image))]
|
||||
)
|
||||
elif config.steam_broadcast_type == "none":
|
||||
uni_msg = UniMessage([Text("\n".join(msg))])
|
||||
else:
|
||||
logger.error(f"未知的播报类型: {config.steam_broadcast_type}")
|
||||
return None
|
||||
try:
|
||||
logger.info(f"播报发送: 群 {parent_id}, {len(msg)} 条: {msg}")
|
||||
await uni_msg.send(
|
||||
Target(parent_id, parent_id, True, False, "", bot.adapter.get_name()), bot
|
||||
)
|
||||
return len(msg)
|
||||
except Exception as e:
|
||||
logger.error(f"✗ 播报发送失败 群 {parent_id}: {type(e).__name__}: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
# 初始化计数器
|
||||
current_key_index = 0
|
||||
|
||||
|
||||
def key_select_2():
|
||||
global current_key_index
|
||||
# 获取key列表
|
||||
keys = config.steam_api_key
|
||||
# 获取当前使用的 key
|
||||
current_key: str = keys[current_key_index]
|
||||
# 更新计数器
|
||||
current_key_index = (current_key_index + 1) % len(keys)
|
||||
|
||||
return [current_key]
|
||||
|
||||
|
||||
async def update_steam_info():
|
||||
steam_ids = bind_data.get_all_steam_id()
|
||||
api_keys = config.steam_api_key
|
||||
if not api_keys:
|
||||
logger.warning("没有可用的 key。")
|
||||
current_key = key_select_2()
|
||||
logger.info(f"播报轮询: 开始拉取 {len(steam_ids)} 个绑定玩家, key={current_key}")
|
||||
# 播报等得起,等待配额最多 60s;配额不足跳过本轮
|
||||
steam_info = await get_steam_users_info(
|
||||
steam_ids, current_key, config.proxy, wait=True
|
||||
)
|
||||
if steam_info is None:
|
||||
logger.warning("✗ 播报轮询: Steam API 配额已满,跳过本轮")
|
||||
return None
|
||||
players = steam_info["response"]["players"]
|
||||
logger.info(f"✓ 播报轮询: 成功拉取 {len(players)} 个玩家状态")
|
||||
|
||||
old_players_dict: Dict[str, List[ProcessedPlayer]] = {}
|
||||
|
||||
for parent_id in bind_data.content.keys():
|
||||
steam_ids = bind_data.get_all(parent_id)
|
||||
old_players_dict[parent_id] = steam_info_data.get_players(steam_ids)
|
||||
|
||||
steam_info_data.update_by_players(steam_info["response"]["players"])
|
||||
steam_info_data.save()
|
||||
|
||||
return bind_data, old_players_dict
|
||||
|
||||
|
||||
# 播报互斥锁:定时任务与启动/重连钩子共用,防止并发拉取导致
|
||||
# steam_info 表双写与重复广播
|
||||
_broadcast_lock = asyncio.Lock()
|
||||
|
||||
|
||||
@scheduler.scheduled_job(
|
||||
"interval", minutes=config.steam_request_interval / 60, id="update_steam_info"
|
||||
)
|
||||
async def fetch_and_broadcast_steam_info():
|
||||
try:
|
||||
async with _broadcast_lock:
|
||||
result = await update_steam_info()
|
||||
if result is None: # API 配额不足,跳过本轮播报
|
||||
return
|
||||
bind_data, old_players_dict = result
|
||||
|
||||
total_changes = 0
|
||||
total_sent = 0
|
||||
for parent_id in bind_data.content.keys():
|
||||
old_players = old_players_dict[parent_id]
|
||||
new_players = steam_info_data.get_players(
|
||||
bind_data.get_all(parent_id)
|
||||
)
|
||||
changes = await broadcast_steam_info(
|
||||
parent_id, old_players, new_players
|
||||
)
|
||||
if changes:
|
||||
total_changes += changes
|
||||
total_sent += 1
|
||||
|
||||
if total_changes:
|
||||
logger.info(
|
||||
f"✓ 播报轮询完成: {total_changes} 条变化, "
|
||||
f"{total_sent}/{len(bind_data.content)} 个群有播报"
|
||||
)
|
||||
else:
|
||||
logger.info("✓ 播报轮询完成: 无状态变化,本轮不播报")
|
||||
except Exception as e:
|
||||
# 兜底:任何异常都要打堆栈,避免定时任务静默失败
|
||||
logger.error(f"✗ 播报轮询异常: {type(e).__name__}: {e}", exc_info=True)
|
||||
|
||||
|
||||
async def _update_steam_info_once(bot):
|
||||
"""启动/重连时的首轮拉取(与定时任务互斥,只拉取不广播)"""
|
||||
async with _broadcast_lock:
|
||||
await update_steam_info()
|
||||
|
||||
|
||||
if not config.steam_disable_broadcast_on_startup:
|
||||
nonebot.get_driver().on_bot_connect(_update_steam_info_once)
|
||||
else:
|
||||
logger.info("已禁用启动时的 Steam 播报")
|
||||
|
||||
|
||||
@help.handle()
|
||||
async def help_handle():
|
||||
await help.finish(__plugin_meta__.usage)
|
||||
|
||||
|
||||
@bind.handle()
|
||||
async def bind_handle(
|
||||
event: Event, target: Target = Depends(get_target), cmd_arg: Message = CommandArg()
|
||||
):
|
||||
parent_id = target.parent_id or target.id
|
||||
|
||||
arg = cmd_arg.extract_plain_text()
|
||||
|
||||
if not arg.isdigit():
|
||||
await bind.finish(
|
||||
"请输入正确的 Steam ID 或 Steam好友代码,格式: steambind [Steam ID 或 Steam好友代码]"
|
||||
)
|
||||
|
||||
steam_id = get_steam_id(arg)
|
||||
|
||||
if user_data := bind_data.get(parent_id, event.get_user_id()):
|
||||
user_data["steam_id"] = steam_id
|
||||
bind_data.save()
|
||||
|
||||
await bind.finish(f"已更新你的 Steam ID 为 {steam_id}")
|
||||
else:
|
||||
bind_data.add(
|
||||
parent_id,
|
||||
{"user_id": event.get_user_id(), "steam_id": steam_id, "nickname": None},
|
||||
)
|
||||
bind_data.save()
|
||||
|
||||
await bind.finish(f"已绑定你的 Steam ID 为 {steam_id}")
|
||||
|
||||
|
||||
@unbind.handle()
|
||||
async def unbind_handle(event: Event, target: Target = Depends(get_target)):
|
||||
parent_id = target.parent_id or target.id
|
||||
user_id = event.get_user_id()
|
||||
|
||||
if bind_data.get(parent_id, user_id) is not None:
|
||||
bind_data.remove(parent_id, user_id)
|
||||
bind_data.save()
|
||||
|
||||
await unbind.finish("已解绑 Steam ID")
|
||||
else:
|
||||
await unbind.finish("未绑定 Steam ID")
|
||||
|
||||
|
||||
@info.handle()
|
||||
async def info_handle(
|
||||
bot: Bot,
|
||||
event: Event,
|
||||
target: Target = Depends(get_target),
|
||||
arg: Message = CommandArg(),
|
||||
):
|
||||
parent_id = target.parent_id or target.id
|
||||
|
||||
uni_arg = await UniMessage.generate(message=arg, event=event, bot=bot)
|
||||
at = uni_arg[At]
|
||||
|
||||
if len(at) != 0:
|
||||
user_id: str = at[0].target
|
||||
user_data = bind_data.get(parent_id, user_id)
|
||||
if user_data is None:
|
||||
await info.finish("该用户未绑定 Steam ID")
|
||||
steam_id = user_data["steam_id"]
|
||||
steam_friend_code = str(int(steam_id) - STEAM_ID_OFFSET)
|
||||
elif arg.extract_plain_text().strip() != "":
|
||||
steam_id = int(arg.extract_plain_text().strip())
|
||||
if steam_id < STEAM_ID_OFFSET:
|
||||
steam_friend_code = steam_id
|
||||
steam_id += STEAM_ID_OFFSET
|
||||
else:
|
||||
steam_friend_code = steam_id - STEAM_ID_OFFSET
|
||||
else:
|
||||
user_data = bind_data.get(parent_id, event.get_user_id())
|
||||
|
||||
if user_data is None:
|
||||
await info.finish(
|
||||
"未绑定 Steam ID, 请使用 “steambind [Steam ID 或 Steam好友代码]” 绑定 Steam ID"
|
||||
)
|
||||
|
||||
steam_id = user_data["steam_id"]
|
||||
steam_friend_code = str(int(steam_id) - STEAM_ID_OFFSET)
|
||||
|
||||
player_data = await get_user_data(steam_id, cache_path, config.proxy)
|
||||
|
||||
# 游戏名本地化(中文名优先)
|
||||
names = await get_game_cn_names(
|
||||
[g.get("appid") for g in player_data["game_data"] if g.get("appid")],
|
||||
db_path,
|
||||
)
|
||||
for g in player_data["game_data"]:
|
||||
g["game_name"] = localize_game_name(g["game_name"], g.get("appid"), names)
|
||||
|
||||
draw_data = [
|
||||
{
|
||||
"game_header": game["game_image"],
|
||||
"game_name": game["game_name"],
|
||||
"game_time": f"{game['play_time']} 小时",
|
||||
"last_play_time": game["last_played"],
|
||||
"achievements": game["achievements"],
|
||||
"completed_achievement_number": game.get("completed_achievement_number"),
|
||||
"total_achievement_number": game.get("total_achievement_number"),
|
||||
}
|
||||
for game in player_data["game_data"]
|
||||
]
|
||||
|
||||
image = draw_player_status(
|
||||
player_data["background"],
|
||||
player_data["avatar"],
|
||||
player_data["player_name"],
|
||||
str(steam_friend_code),
|
||||
player_data["description"],
|
||||
player_data["recent_2_week_play_time"],
|
||||
draw_data,
|
||||
)
|
||||
|
||||
await info.finish(
|
||||
await UniMessage(
|
||||
Image(raw=image_to_bytes(image)),
|
||||
).export(bot)
|
||||
)
|
||||
|
||||
|
||||
# 初始化计数器
|
||||
current_key_index_1 = 0
|
||||
|
||||
|
||||
def key_select():
|
||||
global current_key_index_1
|
||||
# 获取key列表
|
||||
keys = config.steam_api_key
|
||||
# 获取当前使用的 key
|
||||
current_key: str = keys[current_key_index_1]
|
||||
# 更新计数器
|
||||
current_key_index_1 = (current_key_index_1 + 1) % len(keys)
|
||||
|
||||
return [current_key]
|
||||
|
||||
|
||||
@check.handle()
|
||||
async def check_handle(
|
||||
bot: Bot,
|
||||
target: Target = Depends(get_target),
|
||||
arg: Message = CommandArg(),
|
||||
):
|
||||
if arg.extract_plain_text().strip() != "":
|
||||
return None
|
||||
|
||||
parent_id = target.parent_id or target.id
|
||||
|
||||
steam_ids = bind_data.get_all(parent_id)
|
||||
|
||||
current_key = key_select()
|
||||
|
||||
logger.info(f"当前调用的key:{current_key}")
|
||||
# 用户命令快速失败,不挤占播报配额
|
||||
steam_info = await get_steam_users_info(
|
||||
steam_ids, current_key, config.proxy, wait=False
|
||||
)
|
||||
if steam_info is None:
|
||||
await check.finish("Steam API 请求过于频繁,请稍后再试")
|
||||
|
||||
logger.debug(f"{parent_id} Players info: {steam_info}")
|
||||
|
||||
# 游戏名本地化(中文名优先)
|
||||
players = steam_info["response"]["players"]
|
||||
names = await get_game_cn_names(
|
||||
[int(p["gameid"]) for p in players if p.get("gameid")],
|
||||
db_path,
|
||||
)
|
||||
for p in players:
|
||||
if p.get("gameid"):
|
||||
p["gameextrainfo"] = localize_game_name(
|
||||
p.get("gameextrainfo", ""), int(p["gameid"]), names
|
||||
)
|
||||
|
||||
# 统计所有游玩中的游戏(单人也要显示游戏图标),商店页优先抓取 clienticon
|
||||
game_ids: list[int] = []
|
||||
seen: set[int] = set()
|
||||
for p in players:
|
||||
if p.get("gameid"):
|
||||
gid = int(p["gameid"])
|
||||
if gid not in seen:
|
||||
seen.add(gid)
|
||||
game_ids.append(gid)
|
||||
game_icons = (
|
||||
await get_game_icons(game_ids, config.proxy, db_path) if game_ids else {}
|
||||
)
|
||||
|
||||
# 后台用正在玩的 steamid 补剩余缺的 hash(不阻塞当前出图;商店页为主要手段)
|
||||
asyncio.create_task(_fill_missing_icon_hashes(players))
|
||||
|
||||
parent_avatar, parent_name = await get_parent_info(bot, parent_id)
|
||||
|
||||
steam_status_data = []
|
||||
for player in players:
|
||||
data = await simplize_steam_player_data(
|
||||
player, config.proxy, avatar_path, names
|
||||
)
|
||||
entry = bind_data.get_by_steam_id(parent_id, player["steamid"])
|
||||
data["nickname"] = (
|
||||
await get_group_nickname(bot, parent_id, entry["user_id"])
|
||||
if entry is not None
|
||||
else None
|
||||
)
|
||||
data["game_icon"] = game_icons.get(data["gameid"])
|
||||
steam_status_data.append(data)
|
||||
|
||||
image = draw_friends_status(parent_avatar, parent_name, steam_status_data)
|
||||
|
||||
await target.send(UniMessage(Image(raw=image_to_bytes(image))))
|
||||
|
||||
|
||||
@enable.handle()
|
||||
async def enable_handle(target: Target = Depends(get_target)):
|
||||
parent_id = target.parent_id or target.id
|
||||
|
||||
disable_parent_data.remove(parent_id)
|
||||
disable_parent_data.save()
|
||||
|
||||
await enable.finish("已启用 Steam 播报")
|
||||
|
||||
|
||||
@disable.handle()
|
||||
async def disable_handle(target: Target = Depends(get_target)):
|
||||
parent_id = target.parent_id or target.id
|
||||
|
||||
disable_parent_data.add(parent_id)
|
||||
disable_parent_data.save()
|
||||
|
||||
await disable.finish("已禁用 Steam 播报")
|
||||
|
||||
|
||||
async def _auto_sync_game_names():
|
||||
"""启动时自动同步游戏名映射表:未全量同步过则全量拉取,已标记完成则跳过"""
|
||||
try:
|
||||
if _is_full_sync_done(db_path):
|
||||
logger.info("游戏名映射表已全量同步过,跳过启动同步")
|
||||
return
|
||||
api_key = config.steam_api_key[0]
|
||||
count = await update_steamdb_apps(db_path, api_key)
|
||||
logger.info(f"游戏名映射表首次同步完成: {count} 条")
|
||||
except Exception as e:
|
||||
logger.error(f"游戏名映射表启动同步失败: {e}")
|
||||
|
||||
|
||||
@scheduler.scheduled_job("cron", hour=5, minute=0, id="sync_missing_games")
|
||||
async def _sync_missing_games():
|
||||
"""扫描绑定用户游戏库,自动补全映射表缺失的游戏
|
||||
|
||||
英文名从 GetOwnedGames 响应直接取(零额外请求),中文名留给查询时按需补全。
|
||||
"""
|
||||
try:
|
||||
all_games: dict[str, str] = {}
|
||||
all_icons: dict[int, str] = {}
|
||||
for parent_id in bind_data.content.keys():
|
||||
for steam_id in bind_data.get_all(parent_id):
|
||||
games = await fetch_play_time(steam_id)
|
||||
if games is None: # API 配额不足,跳过该用户
|
||||
logger.warning(
|
||||
f"Steam API 配额已满,跳过 {steam_id} 的游戏同步"
|
||||
)
|
||||
continue
|
||||
for g in games:
|
||||
if g.get("appid"):
|
||||
all_games.setdefault(str(g["appid"]), g["name"])
|
||||
if g.get("clienticon"):
|
||||
all_icons.setdefault(int(g["appid"]), g["clienticon"])
|
||||
if not all_games:
|
||||
return
|
||||
known = _query_game_names(db_path, [int(a) for a in all_games])
|
||||
missing = {
|
||||
aid: name for aid, name in all_games.items() if aid not in known
|
||||
}
|
||||
if missing:
|
||||
_upsert_game_names(
|
||||
db_path,
|
||||
{
|
||||
aid: {"name_en": name, "name_ch": ""}
|
||||
for aid, name in missing.items()
|
||||
},
|
||||
)
|
||||
logger.info(f"补全映射表缺失游戏: {len(missing)} 个")
|
||||
if all_icons:
|
||||
_upsert_game_icon_hashes(db_path, all_icons)
|
||||
logger.info(f"补全映射表游戏图标: {len(all_icons)} 个")
|
||||
except Exception as e:
|
||||
logger.error(f"补全映射表缺失游戏失败: {e}")
|
||||
|
||||
|
||||
@scheduler.scheduled_job(
|
||||
"cron", hour=4, minute=30, id="sync_game_names"
|
||||
)
|
||||
async def _daily_sync_game_names():
|
||||
"""每日全量同步游戏名映射表(保证表内始终有全部游戏)"""
|
||||
try:
|
||||
api_key = config.steam_api_key[0]
|
||||
count = await update_steamdb_apps(db_path, api_key)
|
||||
logger.info(f"游戏名映射表每日全量同步完成: {count} 条")
|
||||
except Exception as e:
|
||||
logger.error(f"游戏名映射表每日同步失败: {e}")
|
||||
|
||||
|
||||
async def _on_bot_connect(bot):
|
||||
"""bot 连接后后台执行映射表首次同步(不阻塞连接流程)"""
|
||||
asyncio.create_task(_auto_sync_game_names())
|
||||
|
||||
|
||||
nonebot.get_driver().on_bot_connect(_on_bot_connect)
|
||||
|
||||
|
||||
@update_steamdb.handle()
|
||||
async def update_steamdb_handle():
|
||||
await update_steamdb.send("正在拉取 Steam 全量 app 列表...")
|
||||
try:
|
||||
api_key = config.steam_api_key[0]
|
||||
count = await update_steamdb_apps(db_path, api_key)
|
||||
except Exception as e:
|
||||
logger.error(f"SteamDB 数据更新失败: {e}")
|
||||
await update_steamdb.finish(f"SteamDB 数据更新失败: {e}")
|
||||
if count:
|
||||
await update_steamdb.finish(f"Steam 数据更新完成,共 {count} 个游戏")
|
||||
else:
|
||||
await update_steamdb.finish("Steam 数据拉取失败,稍后重试")
|
||||
|
||||
|
||||
@get_play_time.handle()
|
||||
async def get_play_time_handle(
|
||||
bot: Bot,
|
||||
event: MessageEvent,
|
||||
target: Target = Depends(get_target),
|
||||
cmd_arg: Message = CommandArg(),
|
||||
):
|
||||
parent_id = target.parent_id or target.id
|
||||
user_data = None
|
||||
|
||||
logger.info(f"游玩时长表:进入处理, parent={parent_id}")
|
||||
arg_text = cmd_arg.extract_plain_text().strip()
|
||||
|
||||
if arg_text:
|
||||
if not arg_text.isdigit():
|
||||
await get_play_time.finish(
|
||||
"请输入正确的 Steam ID 或 Steam好友代码,格式: steamplaytime [Steam ID 或 Steam好友代码]"
|
||||
)
|
||||
steam_id = get_steam_id(arg_text)
|
||||
else:
|
||||
user_data = bind_data.get(parent_id, event.get_user_id())
|
||||
if user_data is None:
|
||||
await get_play_time.finish(
|
||||
"未绑定 Steam ID,请使用 steamplaytime [Steam ID 或 Steam好友代码] 查询,或先使用 steambind 绑定"
|
||||
)
|
||||
steam_id = user_data["steam_id"]
|
||||
await get_play_time.send("正在获取数据,请耐心等待")
|
||||
logger.info(f"游玩时长表:开始获取数据, steam_id={steam_id}")
|
||||
games = await fetch_play_time(steam_id)
|
||||
if games is None:
|
||||
await get_play_time.finish("Steam API 请求过于频繁,请稍后再试")
|
||||
logger.info(f"游玩时长表:数据获取完成, 共 {len(games)} 款游戏")
|
||||
|
||||
if not games:
|
||||
await get_play_time.finish("没有获取到游玩数据(游戏可能未公开)")
|
||||
|
||||
# 顺手把 GetOwnedGames 返回的 clienticon hash 写入映射表,供 steamcheck 图标使用
|
||||
icon_entries = {
|
||||
int(g["appid"]): g.get("clienticon")
|
||||
for g in games
|
||||
if g.get("appid") and g.get("clienticon")
|
||||
}
|
||||
if icon_entries:
|
||||
_upsert_game_icon_hashes(db_path, icon_entries)
|
||||
|
||||
# 游戏名本地化:仅查本地映射表,有中文用中文,没有保持 API 原始名(不联网补全)
|
||||
appids = [g["appid"] for g in games if g.get("appid")]
|
||||
game_map = _query_game_names(db_path, appids) if appids else {}
|
||||
names = {
|
||||
int(aid): entry["name_ch"]
|
||||
for aid, entry in game_map.items()
|
||||
if entry.get("name_ch")
|
||||
}
|
||||
for g in games:
|
||||
g["name"] = localize_game_name(g["name"], g.get("appid"), names)
|
||||
|
||||
# 分离已游玩(表格/饼图)与全量(柱状图按时长分段,含 0 时长)
|
||||
played = [g for g in games if g["playtime_hours"] > 0]
|
||||
if not played:
|
||||
await get_play_time.finish("没有游玩记录(游戏可能未公开或全部未游玩)")
|
||||
for i, g in enumerate(played, 1):
|
||||
g["rank"] = i
|
||||
|
||||
# 渲染 HTML 图片(Playwright);失败则提示并结束
|
||||
try:
|
||||
from .html_playtime import render_play_time_table
|
||||
|
||||
# 头像与昵称:Steam 昵称常显,插件昵称(群昵称)设置了才显示
|
||||
steam_name, plugin_nick, avatar = "", "", None
|
||||
try:
|
||||
player_data = await get_user_data(steam_id, cache_path, config.proxy)
|
||||
steam_name = player_data.get("player_name", "")
|
||||
avatar = player_data.get("avatar")
|
||||
except Exception as e:
|
||||
logger.warning(f"获取玩家资料失败,省略头像昵称: {e}")
|
||||
if user_data:
|
||||
plugin_nick = await get_group_nickname(
|
||||
bot, parent_id, user_data["user_id"]
|
||||
)
|
||||
|
||||
logger.info(f"游玩时长表:开始渲染 HTML 图片, steam={steam_name}, nick={plugin_nick}")
|
||||
img_bytes = await render_play_time_table(
|
||||
played, games, steam_name, plugin_nick, avatar,
|
||||
theme=config.steam_playtime_theme,
|
||||
)
|
||||
logger.info(f"游玩时长表:渲染完成, {len(img_bytes)} bytes")
|
||||
except Exception as e:
|
||||
logger.error(f"渲染游玩时长表图片失败: {e}", exc_info=True)
|
||||
await get_play_time.finish("游玩时长表渲染失败,请稍后重试")
|
||||
|
||||
# 发送移出 try:finish 成功后抛的 StopPropagation 不能被 except 捕获
|
||||
# (否则图片发出后还会再补一句"渲染失败"),与 info 指令一致先 export
|
||||
# 回复原指令消息,群消息多时不易混淆
|
||||
uni_msg = MessageSegment.reply(event.message_id) + await UniMessage(
|
||||
Image(raw=img_bytes)
|
||||
).export(bot)
|
||||
await get_play_time.finish(uni_msg)
|
||||
@@ -0,0 +1,33 @@
|
||||
from typing import Optional, Union, List
|
||||
from pydantic import BaseModel, validator
|
||||
|
||||
|
||||
class Config(BaseModel):
|
||||
steam_api_key: Union[str, List[str]]
|
||||
proxy: Optional[str] = None
|
||||
steam_request_interval: int = 300 # seconds
|
||||
steam_broadcast_type: str = "part" # all, part, none
|
||||
steam_disable_broadcast_on_startup: bool = False
|
||||
steam_font_regular_path: Optional[str] = "hexi/resource/fonts/MiSans-Regular.ttf"
|
||||
steam_font_light_path: Optional[str] = "hexi/resource/fonts/MiSans-Light.ttf"
|
||||
steam_font_bold_path: Optional[str] = "hexi/resource/fonts/MiSans-Bold.ttf"
|
||||
|
||||
# 仅用于迁移旧 JSON 状态文件(运行时 API key 状态已持久化到 SQLite)
|
||||
steam_api_key_state_path: Optional[str] = "hexi/data/api_keys_state.json"
|
||||
|
||||
# 游玩时长表图片主题: light / dark,默认亮色
|
||||
steam_playtime_theme: str = "light"
|
||||
|
||||
@validator("steam_playtime_theme")
|
||||
def check_theme(cls, v):
|
||||
v = (v or "light").strip().lower()
|
||||
if v not in ("light", "dark"):
|
||||
raise ValueError("steam_playtime_theme 只能是 light 或 dark")
|
||||
return v
|
||||
|
||||
@validator("steam_api_key", pre=True)
|
||||
def ensure_list(cls, v):
|
||||
if isinstance(v, str):
|
||||
return [v]
|
||||
return v
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
import json
|
||||
import sqlite3
|
||||
from contextlib import closing
|
||||
import time
|
||||
from pathlib import Path
|
||||
import httpx
|
||||
from PIL import Image
|
||||
from nonebot.log import logger
|
||||
from typing import Any, List, Dict, Optional, Tuple
|
||||
|
||||
from .models import Player, ProcessedPlayer
|
||||
|
||||
|
||||
def _game_key(player: Player) -> Optional[tuple]:
|
||||
"""玩家当前游戏的稳定标识:gameid 优先,非 Steam 游戏(无 gameid)回退到游戏名"""
|
||||
gameid = player.get("gameid")
|
||||
if gameid:
|
||||
return ("appid", str(gameid))
|
||||
name = player.get("gameextrainfo")
|
||||
if name:
|
||||
return ("name", name)
|
||||
return None
|
||||
|
||||
|
||||
def _connect(db_path: Path) -> sqlite3.Connection:
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# isolation_level=None:自动提交模式,配合 closing 确保写入持久化
|
||||
return sqlite3.connect(str(db_path), isolation_level=None)
|
||||
|
||||
|
||||
class BindData:
|
||||
"""绑定数据:parent_id + user_id -> {user_id, steam_id, nickname}"""
|
||||
|
||||
def __init__(self, db_path: Path) -> None:
|
||||
self.content: Dict[str, List[Dict[str, str]]] = {}
|
||||
self._db_path = Path(db_path)
|
||||
|
||||
with closing(_connect(self._db_path)) as conn:
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS bind_data ("
|
||||
"parent_id TEXT NOT NULL,"
|
||||
"user_id TEXT NOT NULL,"
|
||||
"steam_id TEXT NOT NULL,"
|
||||
"nickname TEXT,"
|
||||
"PRIMARY KEY (parent_id, user_id))"
|
||||
)
|
||||
self._migrate_json()
|
||||
self._load()
|
||||
|
||||
def _migrate_json(self) -> None:
|
||||
json_path = self._db_path.parent / "bind_data.json"
|
||||
if json_path.is_file():
|
||||
try:
|
||||
data = json.loads(json_path.read_text("utf-8"))
|
||||
with closing(_connect(self._db_path)) as conn:
|
||||
for pid, entries in data.items():
|
||||
for entry in entries:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO bind_data "
|
||||
"(parent_id, user_id, steam_id, nickname) VALUES (?, ?, ?, ?)",
|
||||
(
|
||||
pid,
|
||||
entry.get("user_id", ""),
|
||||
entry.get("steam_id", ""),
|
||||
entry.get("nickname"),
|
||||
),
|
||||
)
|
||||
json_path.rename(json_path.with_suffix(".json.bak"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _load(self) -> None:
|
||||
with closing(_connect(self._db_path)) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT parent_id, user_id, steam_id, nickname FROM bind_data"
|
||||
).fetchall()
|
||||
self.content = {}
|
||||
for pid, uid, sid, nick in rows:
|
||||
self.content.setdefault(pid, []).append(
|
||||
{"user_id": uid, "steam_id": sid, "nickname": nick}
|
||||
)
|
||||
|
||||
def save(self) -> None:
|
||||
with closing(_connect(self._db_path)) as conn:
|
||||
conn.execute("DELETE FROM bind_data")
|
||||
for pid, entries in self.content.items():
|
||||
for entry in entries:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO bind_data "
|
||||
"(parent_id, user_id, steam_id, nickname) VALUES (?, ?, ?, ?)",
|
||||
(
|
||||
pid,
|
||||
entry.get("user_id", ""),
|
||||
entry.get("steam_id", ""),
|
||||
entry.get("nickname"),
|
||||
),
|
||||
)
|
||||
|
||||
def add(self, parent_id: str, content: Dict[str, str]) -> None:
|
||||
if parent_id not in self.content:
|
||||
self.content[parent_id] = [content]
|
||||
else:
|
||||
self.content[parent_id].append(content)
|
||||
|
||||
def remove(self, parent_id: str, user_id: str) -> None:
|
||||
if parent_id not in self.content:
|
||||
return
|
||||
for data in self.content[parent_id]:
|
||||
if data["user_id"] == user_id:
|
||||
self.content[parent_id].remove(data)
|
||||
break
|
||||
|
||||
def update(self, parent_id: str, content: Dict[str, str]) -> None:
|
||||
self.content[parent_id] = content
|
||||
|
||||
def get(self, parent_id: str, user_id: str) -> Optional[Dict[str, str]]:
|
||||
if parent_id not in self.content:
|
||||
return None
|
||||
for data in self.content[parent_id]:
|
||||
if data["user_id"] == user_id:
|
||||
return data
|
||||
return None
|
||||
|
||||
def get_by_steam_id(
|
||||
self, parent_id: str, steam_id: str
|
||||
) -> Optional[Dict[str, str]]:
|
||||
if parent_id not in self.content:
|
||||
return None
|
||||
for data in self.content[parent_id]:
|
||||
if data["steam_id"] == steam_id:
|
||||
return data
|
||||
return None
|
||||
|
||||
def get_all(self, parent_id: str) -> List[str]:
|
||||
if parent_id not in self.content:
|
||||
return []
|
||||
result = []
|
||||
for data in self.content[parent_id]:
|
||||
if not data["steam_id"] in result:
|
||||
result.append(data["steam_id"])
|
||||
return result
|
||||
|
||||
def get_all_steam_id(self) -> List[str]:
|
||||
result = []
|
||||
for parent_id in self.content:
|
||||
for data in self.content[parent_id]:
|
||||
if not data["steam_id"] in result:
|
||||
result.append(data["steam_id"])
|
||||
return result
|
||||
|
||||
|
||||
class SteamInfoData:
|
||||
"""播报状态:steamid -> 玩家数据(JSON 列存储,保留完整结构)"""
|
||||
|
||||
def __init__(self, db_path: Path) -> None:
|
||||
self.content: List[ProcessedPlayer] = []
|
||||
self._by_id: Dict[str, ProcessedPlayer] = {}
|
||||
self._db_path = Path(db_path)
|
||||
|
||||
with closing(_connect(self._db_path)) as conn:
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS steam_info ("
|
||||
"steamid TEXT PRIMARY KEY,"
|
||||
"data TEXT NOT NULL)"
|
||||
)
|
||||
self._migrate_json()
|
||||
self._load()
|
||||
|
||||
def _migrate_json(self) -> None:
|
||||
json_path = self._db_path.parent / "steam_info.json"
|
||||
if json_path.is_file():
|
||||
try:
|
||||
data = json.loads(json_path.read_text("utf-8"))
|
||||
if isinstance(data, list):
|
||||
with closing(_connect(self._db_path)) as conn:
|
||||
for player in data:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO steam_info (steamid, data) VALUES (?, ?)",
|
||||
(player["steamid"], json.dumps(player, ensure_ascii=False)),
|
||||
)
|
||||
json_path.rename(json_path.with_suffix(".json.bak"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _load(self) -> None:
|
||||
with closing(_connect(self._db_path)) as conn:
|
||||
rows = conn.execute("SELECT data FROM steam_info").fetchall()
|
||||
self.content = [json.loads(r[0]) for r in rows]
|
||||
self._rebuild_index()
|
||||
|
||||
def _rebuild_index(self) -> None:
|
||||
"""重建 steamid -> 玩家 索引,get_player 由线性扫描变 O(1)"""
|
||||
self._by_id = {p["steamid"]: p for p in self.content}
|
||||
|
||||
def save(self) -> None:
|
||||
with closing(_connect(self._db_path)) as conn:
|
||||
conn.execute("DELETE FROM steam_info")
|
||||
for player in self.content:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO steam_info (steamid, data) VALUES (?, ?)",
|
||||
(player["steamid"], json.dumps(player, ensure_ascii=False)),
|
||||
)
|
||||
|
||||
def update(self, player: ProcessedPlayer) -> None:
|
||||
self.content.append(player)
|
||||
self._by_id[player["steamid"]] = player
|
||||
|
||||
def update_by_players(self, players: List[Player]):
|
||||
# 将 Player 转换为 ProcessedPlayer
|
||||
processed_players = []
|
||||
for player in players:
|
||||
old_player = self.get_player(player["steamid"])
|
||||
|
||||
if old_player is None:
|
||||
if player.get("gameextrainfo") is not None:
|
||||
player["game_start_time"] = int(time.time())
|
||||
else:
|
||||
player["game_start_time"] = None
|
||||
processed_players.append(player)
|
||||
else:
|
||||
old_key = _game_key(old_player)
|
||||
new_key = _game_key(player)
|
||||
if new_key is not None and old_key is None:
|
||||
# 开始游戏
|
||||
player["game_start_time"] = int(time.time())
|
||||
elif new_key is None and old_key is not None:
|
||||
# 结束游戏
|
||||
player["game_start_time"] = None
|
||||
elif new_key is not None and old_key is not None:
|
||||
# 继续游戏:游戏不变则沿用开始时间,换游戏则从新游戏开始时重新计时
|
||||
if new_key == old_key:
|
||||
player["game_start_time"] = old_player["game_start_time"]
|
||||
else:
|
||||
player["game_start_time"] = int(time.time())
|
||||
else:
|
||||
player["game_start_time"] = None
|
||||
processed_players.append(player)
|
||||
|
||||
self.content = processed_players
|
||||
self._rebuild_index()
|
||||
|
||||
def get_player(self, steam_id: str) -> Optional[Player]:
|
||||
return self._by_id.get(steam_id)
|
||||
|
||||
def get_players(self, steam_ids: List[str]) -> List[Player]:
|
||||
id_set = set(steam_ids)
|
||||
result = []
|
||||
for player in self.content:
|
||||
if player["steamid"] in id_set:
|
||||
result.append(player)
|
||||
return result
|
||||
|
||||
def compare(
|
||||
self, old_players: List[Player], new_players: List[Player]
|
||||
) -> List[Dict[str, Any]]:
|
||||
result = []
|
||||
|
||||
old_players_dict = {player["steamid"]: player for player in old_players}
|
||||
|
||||
for player in new_players:
|
||||
old_player = old_players_dict.get(player["steamid"])
|
||||
if old_player is None:
|
||||
continue
|
||||
|
||||
new_game = _game_key(player)
|
||||
old_game = _game_key(old_player)
|
||||
|
||||
if new_game == old_game:
|
||||
continue
|
||||
|
||||
if new_game is not None and old_game is None:
|
||||
entry_type = "start"
|
||||
elif new_game is None and old_game is not None:
|
||||
entry_type = "stop"
|
||||
elif new_game is not None and old_game is not None:
|
||||
entry_type = "change"
|
||||
else:
|
||||
entry_type = "error"
|
||||
|
||||
result.append(
|
||||
{
|
||||
"type": entry_type,
|
||||
"player": player,
|
||||
"old_player": old_player,
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class ParentData:
|
||||
"""群信息:parent_id -> 群名 + 更新时间,头像图片存文件
|
||||
|
||||
群名/群头像一律从 QQ 自动获取并缓存(steamupdate 指令已移除),
|
||||
updated_at 用于判断缓存是否过期(默认 7 天自动刷新)。
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Path) -> None:
|
||||
self.content: Dict[str, str] = {}
|
||||
self._updated_at: Dict[str, int] = {}
|
||||
self._db_path = Path(db_path)
|
||||
self._avatar_dir = self._db_path.parent / "avatars"
|
||||
|
||||
with closing(_connect(self._db_path)) as conn:
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS parent_data ("
|
||||
"parent_id TEXT PRIMARY KEY,"
|
||||
"name TEXT NOT NULL,"
|
||||
"updated_at INTEGER)"
|
||||
)
|
||||
# 旧表迁移:补充 updated_at 列
|
||||
cols = [row[1] for row in conn.execute("PRAGMA table_info(parent_data)")]
|
||||
if "updated_at" not in cols:
|
||||
conn.execute("ALTER TABLE parent_data ADD COLUMN updated_at INTEGER")
|
||||
self._migrate_json()
|
||||
self._load()
|
||||
|
||||
def _migrate_json(self) -> None:
|
||||
json_path = self._db_path.parent / "parent_data.json"
|
||||
if json_path.is_file():
|
||||
try:
|
||||
data = json.loads(json_path.read_text("utf-8"))
|
||||
self.content = {pid: name for pid, name in data.items()}
|
||||
self.save()
|
||||
json_path.rename(json_path.with_suffix(".json.bak"))
|
||||
except Exception:
|
||||
pass
|
||||
# 迁移旧头像文件(data 根目录 -> avatars/ 子目录)
|
||||
self._avatar_dir.mkdir(parents=True, exist_ok=True)
|
||||
for old in self._db_path.parent.glob("*.png"):
|
||||
try:
|
||||
old.rename(self._avatar_dir / old.name)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _load(self) -> None:
|
||||
with closing(_connect(self._db_path)) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT parent_id, name, updated_at FROM parent_data"
|
||||
).fetchall()
|
||||
self.content = {pid: name for pid, name, _ in rows}
|
||||
self._updated_at = {pid: ts or 0 for pid, _, ts in rows}
|
||||
|
||||
def save(self) -> None:
|
||||
with closing(_connect(self._db_path)) as conn:
|
||||
conn.execute("DELETE FROM parent_data")
|
||||
for pid, name in self.content.items():
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO parent_data "
|
||||
"(parent_id, name, updated_at) VALUES (?, ?, ?)",
|
||||
(pid, name, self._updated_at.get(pid, 0)),
|
||||
)
|
||||
|
||||
def _download_avatar(self, parent_id: str, avatar_path: Path) -> None:
|
||||
"""头像缓存丢失时从 QQ 群头像接口重新下载"""
|
||||
try:
|
||||
url = f"http://p.qlogo.cn/gh/{parent_id}/{parent_id}/640"
|
||||
resp = httpx.get(url, timeout=10)
|
||||
if resp.status_code == 200 and resp.content:
|
||||
self._avatar_dir.mkdir(parents=True, exist_ok=True)
|
||||
avatar_path.write_bytes(resp.content)
|
||||
except Exception as e:
|
||||
logger.warning(f"群头像重新下载失败 {parent_id}: {e}")
|
||||
|
||||
def update(self, parent_id: str, avatar: Image.Image, name: str) -> None:
|
||||
self.content[parent_id] = name
|
||||
self._updated_at[parent_id] = int(time.time())
|
||||
self.save()
|
||||
# 保存图片
|
||||
self._avatar_dir.mkdir(parents=True, exist_ok=True)
|
||||
avatar.save(self._avatar_dir / f"{parent_id}.png")
|
||||
|
||||
def update_name(self, parent_id: str, name: str) -> None:
|
||||
"""只更新群名,不动头像(头像下载失败时的降级路径)"""
|
||||
self.content[parent_id] = name
|
||||
self._updated_at[parent_id] = int(time.time())
|
||||
self.save()
|
||||
|
||||
def is_fresh(self, parent_id: str, ttl: int = 7 * 24 * 3600) -> bool:
|
||||
"""缓存是否新鲜(默认 7 天内自动刷新过)"""
|
||||
return (
|
||||
parent_id in self.content
|
||||
and int(time.time()) - self._updated_at.get(parent_id, 0) < ttl
|
||||
)
|
||||
|
||||
def get(self, parent_id: str) -> Tuple[Image.Image, str]:
|
||||
if parent_id not in self.content:
|
||||
return (
|
||||
Image.open(Path(__file__).parent / "res/unknown_avatar.jpg"),
|
||||
parent_id,
|
||||
)
|
||||
avatar_path = self._avatar_dir / f"{parent_id}.png"
|
||||
if not avatar_path.is_file():
|
||||
# 头像缓存丢失,自动重新下载
|
||||
self._download_avatar(parent_id, avatar_path)
|
||||
if avatar_path.is_file():
|
||||
return Image.open(avatar_path), self.content[parent_id]
|
||||
# 下载失败,降级默认头像
|
||||
return (
|
||||
Image.open(Path(__file__).parent / "res/unknown_avatar.jpg"),
|
||||
self.content[parent_id],
|
||||
)
|
||||
|
||||
|
||||
class DisableParentData:
|
||||
"""储存禁用 Steam 通知的 parent"""
|
||||
|
||||
def __init__(self, db_path: Path) -> None:
|
||||
self.content: List[str] = []
|
||||
self._db_path = Path(db_path)
|
||||
|
||||
with closing(_connect(self._db_path)) as conn:
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS disable_parent_data ("
|
||||
"parent_id TEXT PRIMARY KEY)"
|
||||
)
|
||||
self._migrate_json()
|
||||
self._load()
|
||||
|
||||
def _migrate_json(self) -> None:
|
||||
json_path = self._db_path.parent / "disable_parent_data.json"
|
||||
if json_path.is_file():
|
||||
try:
|
||||
data = json.loads(json_path.read_text("utf-8"))
|
||||
if isinstance(data, list):
|
||||
self.content = [str(pid) for pid in data]
|
||||
self.save()
|
||||
json_path.rename(json_path.with_suffix(".json.bak"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _load(self) -> None:
|
||||
with closing(_connect(self._db_path)) as conn:
|
||||
rows = conn.execute("SELECT parent_id FROM disable_parent_data").fetchall()
|
||||
self.content = [r[0] for r in rows]
|
||||
|
||||
def save(self) -> None:
|
||||
with closing(_connect(self._db_path)) as conn:
|
||||
conn.execute("DELETE FROM disable_parent_data")
|
||||
for pid in self.content:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO disable_parent_data (parent_id) VALUES (?)",
|
||||
(pid,),
|
||||
)
|
||||
|
||||
def add(self, parent_id: str) -> None:
|
||||
if parent_id not in self.content:
|
||||
self.content.append(parent_id)
|
||||
self.save()
|
||||
|
||||
def remove(self, parent_id: str) -> None:
|
||||
if parent_id in self.content:
|
||||
self.content.remove(parent_id)
|
||||
self.save()
|
||||
|
||||
def is_disabled(self, parent_id: str) -> bool:
|
||||
return parent_id in self.content
|
||||
@@ -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
|
||||
@@ -0,0 +1,82 @@
|
||||
from typing import TypedDict, List
|
||||
|
||||
|
||||
class Player(TypedDict):
|
||||
steamid: str
|
||||
communityvisibilitystate: int
|
||||
profilestate: int
|
||||
personaname: str
|
||||
profileurl: str
|
||||
avatar: str
|
||||
avatarmedium: str
|
||||
avatarfull: str
|
||||
avatarhash: str
|
||||
lastlogoff: int
|
||||
personastate: int
|
||||
realname: str
|
||||
primaryclanid: str
|
||||
timecreated: int
|
||||
personastateflags: int
|
||||
gameextrainfo: str
|
||||
gameid: str
|
||||
|
||||
|
||||
class PlayerSummariesResponse(TypedDict):
|
||||
players: List[Player]
|
||||
|
||||
|
||||
class PlayerSummaries(TypedDict):
|
||||
response: PlayerSummariesResponse
|
||||
|
||||
|
||||
class ProcessedPlayer(Player):
|
||||
game_start_time: int # Unix timestamp
|
||||
|
||||
|
||||
class PlayerSummariesProcessedResponse(TypedDict):
|
||||
players: List[ProcessedPlayer]
|
||||
|
||||
|
||||
class Achievements(TypedDict):
|
||||
name: str
|
||||
image: bytes
|
||||
|
||||
|
||||
class GameData(TypedDict):
|
||||
game_name: str
|
||||
play_time: str # e.g. 10.2
|
||||
last_played: str # e.g. 10 月 2 日
|
||||
game_image: bytes
|
||||
achievements: List[Achievements]
|
||||
completed_achievement_number: int
|
||||
total_achievement_number: int
|
||||
|
||||
|
||||
class PlayerData(TypedDict):
|
||||
steamid: str
|
||||
player_name: str
|
||||
background: bytes
|
||||
avatar: bytes
|
||||
description: str
|
||||
recent_2_week_play_time: str
|
||||
game_data: List[GameData]
|
||||
|
||||
|
||||
class DrawPlayerStatusData(TypedDict):
|
||||
game_name: str
|
||||
game_time: str # e.g. 10.2 小时(过去 2 周)
|
||||
last_play_time: str # e.g. 10 月 2 日
|
||||
game_header: bytes
|
||||
achievements: List[Achievements]
|
||||
completed_achievement_number: int
|
||||
total_achievement_number: int
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Player",
|
||||
"PlayerSummaries",
|
||||
"PlayerSummariesResponse",
|
||||
"ProcessedPlayer",
|
||||
"PlayerSummariesProcessedResponse",
|
||||
"DrawPlayerStatusData",
|
||||
]
|
||||
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 552 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 867 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
@@ -0,0 +1,343 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
@font-face { font-family: 'MiSansR'; src: url('__FONT_REGULAR__'); }
|
||||
@font-face { font-family: 'MiSansB'; src: url('__FONT_BOLD__'); }
|
||||
|
||||
:root {
|
||||
--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;
|
||||
}
|
||||
|
||||
/* 主题覆盖(light/dark 由 Python 注入) */
|
||||
__THEME_CSS__
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
width: 1400px;
|
||||
background: var(--surface);
|
||||
font-family: 'MiSansR', 'Microsoft YaHei', sans-serif;
|
||||
padding: 28px 36px 36px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ---- 头部:头像 + 昵称 ---- */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid var(--hairline);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar-fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--fallback-bg);
|
||||
font-family: 'MiSansB', 'MiSansR', 'Microsoft YaHei', sans-serif;
|
||||
font-size: 34px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.nickname {
|
||||
font-family: 'MiSansB', 'MiSansR', 'Microsoft YaHei', sans-serif;
|
||||
font-size: 32px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nick-tag {
|
||||
font-family: 'MiSansR', 'Microsoft YaHei', sans-serif;
|
||||
font-size: 16px;
|
||||
color: var(--text-muted);
|
||||
margin-left: 8px;
|
||||
vertical-align: baseline;
|
||||
min-width: 0;
|
||||
max-width: 260px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stats {
|
||||
font-size: 20px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* ---- 双列排行表格 ---- */
|
||||
.tables {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.col {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
col.rank { width: 72px; }
|
||||
col.hours { width: 150px; }
|
||||
|
||||
th {
|
||||
font-size: 18px;
|
||||
font-weight: normal;
|
||||
color: var(--text-muted);
|
||||
text-align: left;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
td {
|
||||
font-size: 25px;
|
||||
padding: 12px 8px;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
tr:last-child td { border-bottom: none; }
|
||||
|
||||
.rank { width: 72px; }
|
||||
|
||||
.rank-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
font-size: 20px;
|
||||
font-family: 'MiSansB', 'MiSansR', 'Microsoft YaHei', sans-serif;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.rank-plain {
|
||||
display: inline-block;
|
||||
width: 34px;
|
||||
text-align: center;
|
||||
font-size: 25px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.game {
|
||||
color: var(--game-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hours {
|
||||
width: 150px;
|
||||
text-align: right;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- 图表卡片 ---- */
|
||||
.card {
|
||||
background: var(--card);
|
||||
border-radius: 16px;
|
||||
padding: 16px 20px 12px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-family: 'MiSansB', 'MiSansR', 'Microsoft YaHei', sans-serif;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.card-sub {
|
||||
font-size: 18px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 330px;
|
||||
}
|
||||
|
||||
/* ---- 底部 ---- */
|
||||
.footer {
|
||||
font-size: 18px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 20px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
__AVATAR__
|
||||
<div>
|
||||
<div class="nickname">__STEAM_NAME____PLUGIN_TAG__</div>
|
||||
<div class="stats">共 __GAME_COUNT__ 款游戏 · 总时长 __TOTAL_HOURS__</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tables">
|
||||
<div class="col">
|
||||
<table>
|
||||
<colgroup>
|
||||
<col class="rank">
|
||||
<col>
|
||||
<col class="hours">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="rank">排名</th>
|
||||
<th>游戏</th>
|
||||
<th class="hours">时长</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
__ROWS_LEFT__
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col">
|
||||
__RIGHT_TABLE__
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">各时长段游戏数量</div>
|
||||
<div class="card-sub">全库按时长分段,0 = 未游玩 · 对数轴</div>
|
||||
<div class="chart" id="bar"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">时长占比</div>
|
||||
<div class="card-sub">Top 8 游戏 + 其余聚合</div>
|
||||
<div class="chart" id="pie"></div>
|
||||
</div>
|
||||
|
||||
<div class="footer">__FOOTER__</div>
|
||||
|
||||
<script src="__ECHARTS__"></script>
|
||||
<script>
|
||||
const DATA = __DATA__;
|
||||
|
||||
// 主题色从 CSS 变量读取(亮/暗由 Python 注入 :root 覆盖)
|
||||
function cssVar(name) {
|
||||
return getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(name).trim() || '#000';
|
||||
}
|
||||
|
||||
// 各时长段游戏数量柱状图(对数轴:0 时长大桶不会压扁其他桶)
|
||||
const bar = echarts.init(document.getElementById('bar'));
|
||||
bar.setOption({
|
||||
animation: false,
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: cssVar('--card'),
|
||||
borderColor: cssVar('--hairline'),
|
||||
textStyle: { color: cssVar('--text-primary'), fontSize: 14 },
|
||||
},
|
||||
grid: { left: 8, right: 16, top: 30, bottom: 30, containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: DATA.bar.names,
|
||||
axisLine: { lineStyle: { color: cssVar('--axis') } },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: cssVar('--text-muted'), fontSize: 15 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'log',
|
||||
show: false,
|
||||
splitLine: { lineStyle: { color: cssVar('--hairline') } },
|
||||
},
|
||||
series: [{
|
||||
type: 'bar',
|
||||
barWidth: '45%',
|
||||
barMaxWidth: 110,
|
||||
data: DATA.bar.counts,
|
||||
itemStyle: { color: cssVar('--bar'), borderRadius: [5, 5, 0, 0] },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
color: cssVar('--text-primary'),
|
||||
fontSize: 16,
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
// 时长占比环形饼图(禁用动画:截图可能截到入场动画早期帧导致整图空白)
|
||||
const pie = echarts.init(document.getElementById('pie'));
|
||||
pie.setOption({
|
||||
animation: false,
|
||||
backgroundColor: 'transparent',
|
||||
color: DATA.pie.colors,
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
backgroundColor: cssVar('--card'),
|
||||
borderColor: cssVar('--hairline'),
|
||||
textStyle: { color: cssVar('--text-primary'), fontSize: 14 },
|
||||
},
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
right: 10,
|
||||
top: 'center',
|
||||
itemWidth: 12,
|
||||
itemHeight: 12,
|
||||
textStyle: { color: cssVar('--text-secondary'), fontSize: 14 },
|
||||
formatter: function (name) {
|
||||
try {
|
||||
const item = (DATA.pie.items || []).find(i => i.name === name);
|
||||
if (!item || !DATA.pie.total) return name;
|
||||
return name + ' ' + (item.value / DATA.pie.total * 100).toFixed(1) + '%';
|
||||
} catch (e) {
|
||||
return name;
|
||||
}
|
||||
},
|
||||
},
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['48%', '68%'],
|
||||
center: ['37%', '50%'],
|
||||
itemStyle: { borderColor: cssVar('--card'), borderWidth: 2 },
|
||||
label: {
|
||||
color: cssVar('--text-primary'),
|
||||
fontSize: 14,
|
||||
formatter: function (p) {
|
||||
return p.percent >= 5 ? p.percent.toFixed(1) + '%' : '';
|
||||
},
|
||||
},
|
||||
labelLine: { show: false },
|
||||
data: DATA.pie.items,
|
||||
}],
|
||||
});
|
||||
|
||||
window.__rendered = true;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 533 B |
|
After Width: | Height: | Size: 536 B |
@@ -0,0 +1,212 @@
|
||||
import asyncio
|
||||
import time
|
||||
import pytz
|
||||
import httpx
|
||||
import datetime
|
||||
import calendar
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
from .models import Player
|
||||
from .steam import (
|
||||
_query_game_icon_hashes,
|
||||
_upsert_game_icon_hashes,
|
||||
fetch_clienticon_hash_from_store,
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_avatar(avatar_url: str, proxy: str = None) -> Image.Image:
|
||||
default_avatar = Image.open(Path(__file__).parent / "res/unknown_avatar.jpg")
|
||||
try:
|
||||
async with httpx.AsyncClient(proxy=proxy, timeout=10) as client:
|
||||
response = await client.get(avatar_url)
|
||||
if response.status_code != 200:
|
||||
return default_avatar
|
||||
return Image.open(BytesIO(response.content))
|
||||
except Exception:
|
||||
# 下载失败(连接超时/网络异常),降级默认头像,不中断命令
|
||||
return default_avatar
|
||||
|
||||
|
||||
# 游戏图标缓存目录(插件目录下 data/game_icons/)
|
||||
GAME_ICON_DIR = Path(__file__).parent / "data" / "game_icons"
|
||||
|
||||
|
||||
async def _download_steam_icon(
|
||||
url: str, appid: int, proxy: str, cache_suffix: str
|
||||
) -> Optional[Image.Image]:
|
||||
"""从 steam CDN 下载图标并缓存到 data/game_icons/{appid}_{cache_suffix}.jpg"""
|
||||
try:
|
||||
async with httpx.AsyncClient(proxy=proxy, timeout=10) as client:
|
||||
resp = await client.get(url)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
content = resp.content
|
||||
GAME_ICON_DIR.mkdir(parents=True, exist_ok=True)
|
||||
cache_path = GAME_ICON_DIR / f"{appid}_{cache_suffix}.jpg"
|
||||
await asyncio.to_thread(cache_path.write_bytes, content)
|
||||
return Image.open(BytesIO(content))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def get_game_icon(
|
||||
appid: int, proxy: str = None, db_path: Optional[Path] = None
|
||||
) -> Optional[Image.Image]:
|
||||
"""获取游戏图标(Steam clienticon .jpg),按 appid 缓存到插件 data/game_icons/ 目录
|
||||
|
||||
- 优先用 game_names 表里的 clienticon hash 下载 32x32 小图标
|
||||
- DB 缺失时从商店页抓 hash
|
||||
- 取不到就返回 None,绘制时留空占位(不再回退 header.jpg)
|
||||
"""
|
||||
if not appid:
|
||||
return None
|
||||
|
||||
icon_path = GAME_ICON_DIR / f"{appid}_icon.jpg"
|
||||
if icon_path.is_file():
|
||||
try:
|
||||
return Image.open(icon_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
icon_hash = None
|
||||
if db_path is not None:
|
||||
icon_hash = _query_game_icon_hashes(db_path, [appid]).get(appid)
|
||||
if not icon_hash:
|
||||
# DB 缺失时,从商店页 apphub_AppIcon 稳定抓取 clienticon hash
|
||||
icon_hash = await fetch_clienticon_hash_from_store(appid)
|
||||
if icon_hash and db_path is not None:
|
||||
_upsert_game_icon_hashes(db_path, {appid: icon_hash})
|
||||
if icon_hash:
|
||||
icon = await _download_steam_icon(
|
||||
f"https://shared.akamai.steamstatic.com/community_assets/images/apps/{appid}/{icon_hash}.jpg",
|
||||
appid,
|
||||
proxy,
|
||||
"icon",
|
||||
)
|
||||
if icon is None:
|
||||
icon = await _download_steam_icon(
|
||||
f"https://shared.fastly.steamstatic.com/community_assets/images/apps/{appid}/{icon_hash}.jpg",
|
||||
appid,
|
||||
proxy,
|
||||
"icon",
|
||||
)
|
||||
if icon is None:
|
||||
# 备用:旧社区 CDN 路径(部分早期游戏仍在此处)
|
||||
icon = await _download_steam_icon(
|
||||
f"https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/{appid}/{icon_hash}.jpg",
|
||||
appid,
|
||||
proxy,
|
||||
"icon",
|
||||
)
|
||||
if icon is not None:
|
||||
return icon
|
||||
|
||||
# 没有 clienticon 就留空占位,不回退 header
|
||||
return None
|
||||
|
||||
|
||||
async def get_game_icons(
|
||||
appids: list[int], proxy: str = None, db_path: Optional[Path] = None
|
||||
) -> dict[int, Image.Image]:
|
||||
"""批量获取游戏图标;失败自动跳过,返回 {appid: Image.Image}"""
|
||||
if not appids:
|
||||
return {}
|
||||
results = await asyncio.gather(*(get_game_icon(a, proxy, db_path) for a in appids))
|
||||
return {a: icon for a, icon in zip(appids, results) if icon is not None}
|
||||
|
||||
|
||||
async def fetch_avatar(
|
||||
player: Player, avatar_dir: Optional[Path], proxy: str = None
|
||||
) -> Image.Image:
|
||||
if avatar_dir is not None:
|
||||
avatar_path = (
|
||||
avatar_dir / f"avatar_{player['steamid']}_{player['avatarhash']}.png"
|
||||
)
|
||||
|
||||
if avatar_path.exists():
|
||||
avatar = Image.open(avatar_path)
|
||||
else:
|
||||
avatar = await _fetch_avatar(player["avatarfull"], proxy)
|
||||
|
||||
avatar.save(avatar_path)
|
||||
else:
|
||||
avatar = await _fetch_avatar(player["avatarfull"], proxy)
|
||||
|
||||
return avatar
|
||||
|
||||
|
||||
async def simplize_steam_player_data(
|
||||
player: Player,
|
||||
proxy: str = None,
|
||||
avatar_dir: Path = None,
|
||||
game_names: Optional[Dict[int, str]] = None,
|
||||
) -> Dict[str, str]:
|
||||
avatar = await fetch_avatar(player, avatar_dir, proxy)
|
||||
gameid = int(player["gameid"]) if player.get("gameid") else None
|
||||
game_name = game_names.get(gameid) if (gameid is not None and game_names) else None
|
||||
if not game_name:
|
||||
game_name = player.get("gameextrainfo")
|
||||
|
||||
if player["personastate"] == 0:
|
||||
if not player.get("lastlogoff"):
|
||||
status = "离线"
|
||||
else:
|
||||
time_logged_off = player["lastlogoff"] # Unix timestamp
|
||||
time_to_now = calendar.timegm(time.gmtime()) - time_logged_off
|
||||
|
||||
# 将时间转换为自然语言
|
||||
if time_to_now < 60:
|
||||
status = "上次在线 刚刚"
|
||||
elif time_to_now < 3600:
|
||||
status = f"上次在线 {time_to_now // 60} 分钟前"
|
||||
elif time_to_now < 86400:
|
||||
status = f"上次在线 {time_to_now // 3600} 小时前"
|
||||
elif time_to_now < 2592000:
|
||||
status = f"上次在线 {time_to_now // 86400} 天前"
|
||||
elif time_to_now < 31536000:
|
||||
status = f"上次在线 {time_to_now // 2592000} 个月前"
|
||||
else:
|
||||
status = f"上次在线 {time_to_now // 31536000} 年前"
|
||||
elif player["personastate"] in [1, 2, 4]:
|
||||
status = (
|
||||
"在线" if player.get("gameextrainfo") is None else player["gameextrainfo"]
|
||||
)
|
||||
elif player["personastate"] == 3:
|
||||
status = (
|
||||
"离开" if player.get("gameextrainfo") is None else player["gameextrainfo"]
|
||||
)
|
||||
elif player["personastate"] in [5, 6]:
|
||||
status = "在线"
|
||||
else:
|
||||
status = "未知"
|
||||
|
||||
return {
|
||||
"steamid": player["steamid"],
|
||||
"avatar": avatar,
|
||||
"name": player["personaname"],
|
||||
"status": status,
|
||||
"personastate": player["personastate"],
|
||||
"gameid": gameid,
|
||||
"game_name": game_name,
|
||||
}
|
||||
|
||||
|
||||
def image_to_bytes(image: Image.Image) -> bytes:
|
||||
with BytesIO() as bio:
|
||||
image.save(bio, format="PNG")
|
||||
return bio.getvalue()
|
||||
|
||||
|
||||
def hex_to_rgb(hex_color: str):
|
||||
return tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4))
|
||||
|
||||
|
||||
def convert_timestamp_to_beijing_time(timestamp: int) -> str:
|
||||
beijing_timezone = pytz.timezone("Asia/Shanghai")
|
||||
date_utc = datetime.datetime.fromtimestamp(timestamp, pytz.utc)
|
||||
date_beijing = date_utc.astimezone(beijing_timezone)
|
||||
return date_beijing.strftime("%Y-%m-%d %H:%M:%S")
|
||||
# example: 2021-09-06 21:00:00
|
||||