feat(steam-info): 资料页状态/最近游玩/ban 描述增强,时长图渲染与定时任务迁移

- steam.py / draw.py / handlers 扩充:个人资料页状态构建、最近游玩、
  游戏头像与中文游戏名查询、ban 描述
- 定时播报迁入 hexi.core.scheduler
- 新增 tests/test_steam_profile_parse.py(资料 HTML/XML 解析)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
sansenhoshiandClaude Code committed 2026-10-13 17:53:22 +08:00
1 parent 0ac1958932
commit 6af60a7d55
6 files changed
+1298 -125

No files matched your search

@@ -34,20 +34,31 @@ from .. import (
get_steam_users_info,
get_target,
get_user_data,
get_recently_played,
fetch_game_header_image,
image_to_bytes,
key_select,
localize_game_name,
simplize_steam_player_data,
build_profile_status,
describe_ban,
_fill_missing_icon_hashes,
_query_game_names,
_query_game_name_ch_by_en,
_upsert_game_icon_hashes,
update_steamdb_apps,
)
# 信息卡「游戏动态」最多展示的条数(按近两周时长排序,列表本身最多 8 条)
MAX_ACTIVITY_GAMES = 4
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)
# steaminfo 会额外调一次 Steam API(近两周时长),按群冷却防止被刷配额
info = cooldown(30, scope="group", hint="群里刚有人查过,{secs} 秒后再试")(
on_command("steaminfo", aliases={"steam信息"}, priority=10)
)
check = cooldown(30, scope="group", hint="群里刚有人查过,{secs} 秒后再试")(
on_command("steamcheck", aliases={"查看steam", "查steam", "谁在玩游戏"}, priority=10)
)
@@ -128,29 +139,118 @@ async def info_handle(bot: Bot, event: Event, target: Target = Depends(get_targe
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,
# 资料页抓取与近两周列表 API 并行;后者是可选增强,无配额时自动跳过
player_data, recent_games = await asyncio.gather(
get_user_data(steam_id, cache_path, config.proxy),
get_recently_played(steam_id, config.proxy),
)
for g in player_data["game_data"]:
g["game_name"] = localize_game_name(g["game_name"], g.get("appid"), names)
draw_data = [
recent_games = recent_games or []
# 游戏动态以最近两周列表为准(资料页只有最近几款),资料页解析的
# 成就/最后运行日期按 appid 合并进来;列表没有的游戏用资料页顺序补齐
page_games = {
g["appid"]: g for g in player_data["game_data"] if g.get("appid")
}
rows = list(recent_games)
known_appids = {r["appid"] for r in rows}
rows += [
{
"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"),
"appid": appid,
"name": game["game_name"],
"two_week_hours": None,
"total_hours": None,
}
for game in player_data["game_data"]
for appid, game in page_games.items()
if appid not in known_appids
]
rows = rows[:MAX_ACTIVITY_GAMES]
# 缺封面的行(不在资料页里)从公开 CDN 并发补,带缓存
missing_appids = [r["appid"] for r in rows if r["appid"] not in page_games]
cdn_headers = {}
if missing_appids:
fetched = await asyncio.gather(
*(
fetch_game_header_image(appid, cache_path, config.proxy)
for appid in missing_appids
)
)
cdn_headers = dict(zip(missing_appids, fetched))
names = await get_game_cn_names([r["appid"] for r in rows], db_path)
draw_data = []
for row in rows:
page = page_games.get(row["appid"], {})
header = page.get("game_image") or cdn_headers.get(row["appid"])
if header is None:
logger.warning(f"游戏 {row['appid']} 封面获取失败,本条跳过")
continue
if row["total_hours"] is not None:
game_time = f"{row['total_hours']:.1f}"
else:
game_time = str(page.get("play_time") or "")
draw_data.append(
{
"game_header": header,
"game_name": localize_game_name(
page.get("game_name") or row["name"], row["appid"], names
),
"game_time": f"{game_time} 小时",
"last_play_time": page.get("last_played") or "",
"achievements": page.get("achievements") or [],
"completed_achievement_number": page.get(
"completed_achievement_number"
),
"total_achievement_number": page.get("total_achievement_number"),
"two_week_time": (
f"近两周 {row['two_week_hours']:.1f} 小时"
if row["two_week_hours"]
else None
),
}
)
# 近两周合计:API 列表求和最准;没有列表(资料不公开/无配额)时退回页面文案
if recent_games:
total_hours = sum(r["two_week_hours"] for r in recent_games)
two_week_total = (
f"{total_hours:.1f} 小时(过去 2 周)" if total_hours > 0 else "近两周未游玩"
)
else:
two_week_total = player_data["recent_2_week_play_time"]
# 状态行/风控标记:资料页「当前正在游戏」优先,其次资料 XML;
# XML 兜底路径的英文游戏名用映射表反查中文
status = build_profile_status(
player_data["online_state"],
player_data["state_message"],
player_data["privacy_state"],
player_data["current_game"],
)
if status.get("game_name_en"):
cn_name = _query_game_name_ch_by_en(db_path, status["game_name_en"])
if cn_name:
status["text"] = cn_name
ban = describe_ban(
player_data["vac_banned"],
player_data["trade_ban_state"],
player_data["limited_account"],
)
extra = {
"level": player_data["level"],
"counts": player_data["counts"],
"member_since": player_data["member_since"],
"status_text": status["text"],
"status_kind": status["kind"],
"ban_text": ban["text"] if ban else None,
"ban_kind": ban["kind"] if ban else None,
}
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,
two_week_total, draw_data,
extra=extra,
avatar_frame=player_data.get("avatar_frame"),
)
await info.finish(await UniMessage(Image(raw=image_to_bytes(image))).export(bot))