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

@@ -16,7 +16,7 @@ require("nonebot_plugin_localstore")
require("nonebot_plugin_apscheduler") require("nonebot_plugin_apscheduler")
from nonebot_plugin_alconna.uniseg import get_target as uni_get_target from nonebot_plugin_alconna.uniseg import get_target as uni_get_target
import nonebot_plugin_localstore as store import nonebot_plugin_localstore as store
from nonebot_plugin_apscheduler import scheduler from hexi.core.scheduler import scheduled_job
from nonebot_plugin_alconna import Text, Image, UniMessage, Target from nonebot_plugin_alconna import Text, Image, UniMessage, Target
from nonebot.adapters.onebot.v11 import Bot from nonebot.adapters.onebot.v11 import Bot
from .config import Config from .config import Config
@@ -29,13 +29,18 @@ from .steam import (
get_steam_users_info, get_steam_users_info,
init_api_key_manager, init_api_key_manager,
get_play_time as fetch_play_time, get_play_time as fetch_play_time,
get_recently_played,
fetch_game_header_image,
get_game_cn_names, get_game_cn_names,
localize_game_name, localize_game_name,
migrate_game_names_json, migrate_game_names_json,
update_steamdb_apps, update_steamdb_apps,
build_profile_status,
describe_ban,
_is_full_sync_done, _is_full_sync_done,
_query_game_names, _query_game_names,
_query_game_icon_hashes, _query_game_icon_hashes,
_query_game_name_ch_by_en,
_upsert_game_names, _upsert_game_names,
_upsert_game_icon_hashes, _upsert_game_icon_hashes,
) )
@@ -464,7 +469,7 @@ async def update_steam_info():
_broadcast_lock = asyncio.Lock() _broadcast_lock = asyncio.Lock()
@scheduler.scheduled_job( @scheduled_job(
"interval", minutes=config.steam_request_interval / 60, id="update_steam_info" "interval", minutes=config.steam_request_interval / 60, id="update_steam_info"
) )
async def fetch_and_broadcast_steam_info(): async def fetch_and_broadcast_steam_info():
+357 -95
View File
@@ -2,12 +2,12 @@ import numpy as np
from functools import lru_cache from functools import lru_cache
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import List, Dict, Tuple from typing import List, Dict, Tuple, Optional
from colorsys import rgb_to_hsv, hsv_to_rgb from colorsys import rgb_to_hsv, hsv_to_rgb
from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageEnhance from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageEnhance
from .utils import hex_to_rgb from .utils import hex_to_rgb
from .models import DrawPlayerStatusData, Achievements from .models import DrawPlayerStatusData, Achievements, ProfileCardExtra
WIDTH = 400 WIDTH = 400
@@ -824,6 +824,7 @@ def draw_game_info(
completed_achievement_number: int, completed_achievement_number: int,
total_achievement_number: int, total_achievement_number: int,
achievement_color: Tuple[int, int, int], achievement_color: Tuple[int, int, int],
two_week_time: Optional[str] = None,
) -> Image.Image: ) -> Image.Image:
bg = Image.new("RGBA", (880, 110 + 64 + 10), (0, 0, 0, 110)) bg = Image.new("RGBA", (880, 110 + 64 + 10), (0, 0, 0, 110))
header = header.resize((229, 86), Image.BICUBIC) header = header.resize((229, 86), Image.BICUBIC)
@@ -831,11 +832,27 @@ def draw_game_info(
draw = ImageDraw.Draw(bg) draw = ImageDraw.Draw(bg)
# 近两周时长(可选):右上角绿色小字,游戏名超宽时先截断避让
two_week_width = 0
if two_week_time:
font_two_week = get_font(font_light_path, 20)
two_week_width = int(draw.textlength(two_week_time, font=font_two_week))
draw.text(
(bg.width - two_week_width - 10, 22),
two_week_time,
font=font_two_week,
fill=hex_to_rgb("91c257"),
)
# 画游戏名 # 画游戏名
name_font = get_font(font_regular_path, 26)
max_name_width = bg.width - 260 - 20 - two_week_width - (30 if two_week_width else 0)
if draw.textlength(game_name, font=name_font) > max_name_width:
game_name = _truncate_with_ellipsis(draw, game_name, name_font, max_name_width)
draw.text( draw.text(
(260, 10), (260, 10),
game_name, game_name,
font=get_font(font_regular_path, 26), font=name_font,
fill=(255, 255, 255), fill=(255, 255, 255),
) )
@@ -880,7 +897,7 @@ def draw_game_info(
(int(x), 20), (int(x), 20),
f"{completed_achievement_number} / {total_achievement_number}", f"{completed_achievement_number} / {total_achievement_number}",
font=font, font=font,
fill=(130, 130, 130), fill=(185, 185, 185),
) )
x += ( x += (
font.getlength(f"{completed_achievement_number} / {total_achievement_number}") font.getlength(f"{completed_achievement_number} / {total_achievement_number}")
@@ -913,168 +930,413 @@ def draw_game_info(
return bg return bg
# 信息卡尺寸/排版常量(调整版式优先动这里)
CARD_WIDTH = 1200 # 卡片条宽度,背景按此居中裁切
CARD_MARGIN = 20 # 左右安全边距
LEFT_W = 880 # 左栏(游戏动态)宽度
RIGHT_X = CARD_MARGIN + LEFT_W + CARD_MARGIN # 920:右栏起点
RIGHT_W = CARD_WIDTH - RIGHT_X - CARD_MARGIN # 260:右栏宽度
AVATAR_SIZE = 160
AVATAR_X = 30 # 头像框约 1.36×头像,需留出外溢空间
HEADER_TEXT_X = AVATAR_X + AVATAR_SIZE + 30 # 220:头部文字统一左对齐点
GAME_GAP = 16 # 游戏卡片间距
COUNT_ROW_MAX_STEP = 44 # 右栏计数条目间距上限(面板矮时自动收紧)
AVATAR_FRAME_SCALE = 1.36 # 头像框相对头像的缩放(对齐 Steam 内孔比例)
# 状态行配色,key 为 ProfileCardExtra.status_kind
status_row_colors = {
"in-game": hex_to_rgb("91c257"),
"online": hex_to_rgb("6dcef5"),
"away": hex_to_rgb("e0a948"),
"offline": hex_to_rgb("969697"),
"private": hex_to_rgb("969697"),
}
# 风控标记配色,key 为 ProfileCardExtra.ban_kind
ban_tag_colors = {
"vac": hex_to_rgb("c4453c"),
"trade": hex_to_rgb("c98a2e"),
"limited": hex_to_rgb("67665c"),
}
# 右栏状态区标题,key 为 ProfileCardExtra.status_kind
status_titles = {
"in-game": "当前正在游戏",
"online": "当前在线",
"away": "当前在线",
"offline": "当前离线",
"private": "当前状态",
}
def _draw_kv_parts(
draw: ImageDraw.ImageDraw,
x: int,
y: int,
parts: List[Tuple[str, tuple]],
font,
) -> int:
"""按 (文本, 颜色) 依次绘制,返回右边缘 x(间距由文本自带的空格控制)"""
for text, color in parts:
draw.text((x, y), text, font=font, fill=color)
x += int(draw.textlength(text, font=font))
return x
def _draw_status_row(
draw: ImageDraw.ImageDraw,
x: int,
y: int,
text: str,
kind: str = "online",
second_line: str = "",
) -> None:
"""状态行:彩色圆点 + 文案(游戏中/在线/离线/资料私密),过长可折第二行"""
color = status_row_colors.get(kind, status_row_colors["online"])
font = get_font(font_regular_path, 17)
draw.ellipse((x, y + 4, x + 11, y + 15), fill=color)
draw.text((x + 19, y), text, font=font, fill=color)
if second_line:
draw.text((x + 19, y + 22), second_line, font=font, fill=color)
def _split_two_lines(
draw: ImageDraw.ImageDraw, text: str, font, max_width: int
) -> List[str]:
"""超宽文本折成最多两行:优先在最后一个放得下的分隔符处断,否则省略号截断"""
if draw.textlength(text, font=font) <= max_width:
return [text]
break_at = None
for idx, char in enumerate(text):
if char in "::" or char == " ":
if draw.textlength(text[: idx + 1], font=font) <= max_width:
break_at = idx + 1
if break_at is None:
return [_truncate_with_ellipsis(draw, text, font, max_width)]
second = text[break_at:].strip()
if draw.textlength(second, font=font) > max_width:
second = _truncate_with_ellipsis(draw, second, font, max_width)
return [text[:break_at].strip(), second]
def _draw_level_badge(
draw: ImageDraw.ImageDraw, x: int, center_y: int, level: int
) -> int:
"""Steam 风格等级圆牌,返回右边缘 x"""
diameter = 34
top = center_y - diameter // 2
draw.ellipse(
(x, top, x + diameter, top + diameter),
fill=hex_to_rgb("1b2838"),
outline=hex_to_rgb("66c0f4"),
width=2,
)
font = get_font(font_bold_path, 18)
text = str(level)
text_width = draw.textlength(text, font=font)
draw.text(
(x + (diameter - text_width) / 2, top + (diameter - font.size) / 2 - 3),
text,
font=font,
fill=(255, 255, 255),
)
return x + diameter
def _draw_ban_tag(
draw: ImageDraw.ImageDraw, x: int, y: int, text: str, kind: str
) -> int:
"""风控标记圆角标签,返回右边缘 x"""
font = get_font(font_regular_path, 13)
padding_x, padding_y = 9, 4
width = draw.textlength(text, font=font) + padding_x * 2
height = font.size + padding_y * 2
draw.rounded_rectangle(
(x, y, x + width, y + height),
radius=height // 2,
fill=ban_tag_colors.get(kind, ban_tag_colors["limited"]),
)
draw.text(
(x + padding_x, y + padding_y - 1),
text,
font=font,
fill=(255, 255, 255),
)
return int(x + width)
def draw_player_status( def draw_player_status(
player_bg: Image.Image, player_bg: Image.Image,
player_avatar: Image.Image, player_avatar: Image.Image,
player_name: str, player_name: str,
player_id: str, player_id: str,
player_description: str, player_description: str,
player_last_two_weeks_time: str, # e.g. 10.2 小时 player_last_two_weeks_time: str, # e.g. 10.2 小时(过去 2 周)
player_games: List[DrawPlayerStatusData], player_games: List[DrawPlayerStatusData],
extra: Optional[ProfileCardExtra] = None,
avatar_frame: Optional[bytes] = None,
): ):
"""信息卡:通栏头部 + 左栏「游戏动态」+ 右栏「当前在线/计数」(参考 Steam 官方资料页)"""
if isinstance(player_bg, bytes): if isinstance(player_bg, bytes):
player_bg = Image.open(BytesIO(player_bg)) player_bg = Image.open(BytesIO(player_bg))
if isinstance(player_avatar, bytes): if isinstance(player_avatar, bytes):
player_avatar = Image.open(BytesIO(player_avatar)) player_avatar = Image.open(BytesIO(player_avatar))
extra = extra or {}
bg = recolor_image( bg = recolor_image(
player_bg.crop( player_bg.crop(
( (
(player_bg.width - 960) // 2, (player_bg.width - CARD_WIDTH) // 2,
0, 0,
(player_bg.width + 960) // 2, (player_bg.width + CARD_WIDTH) // 2,
player_bg.height, player_bg.height,
) )
), ),
10, 10,
10, 10,
) )
# 调暗背景 bg = ImageEnhance.Brightness(bg).enhance(0.7)
enhancer = ImageEnhance.Brightness(bg)
bg = enhancer.enhance(0.7)
# bg.size = (960, 1020)
player_avatar = player_avatar.resize((200, 200))
bg.paste(player_avatar, (40, 40))
draw = ImageDraw.Draw(bg) draw = ImageDraw.Draw(bg)
# 画头像外框 # ---------- 头部:头像(+头像框)/ 昵称+等级+风控 / 简介 / 好友代码·注册时间 ----------
draw.rectangle((40, 40, 240, 240), outline=(83, 164, 196), width=3) avatar = player_avatar.convert("RGB").resize(
(AVATAR_SIZE, AVATAR_SIZE), Image.BICUBIC
)
bg.paste(avatar_with_border(avatar), (AVATAR_X - 2, AVATAR_X - 2))
if avatar_frame:
try:
frame = Image.open(BytesIO(avatar_frame)).convert("RGBA")
frame_size = int(AVATAR_SIZE * AVATAR_FRAME_SCALE)
frame = frame.resize((frame_size, frame_size), Image.LANCZOS)
frame_pos = AVATAR_X + (AVATAR_SIZE - frame_size) // 2
bg.paste(frame, (frame_pos, frame_pos), frame)
except Exception:
pass # 头像框只是装饰,画不出来不影响整卡
# 画昵称 # 好友代码/注册时间的标签色:比正文灰稍亮一档(#969696 在深色底上偏暗)
text_gray = hex_to_rgb("b4b4b4")
name_font = get_font(font_light_path, 38)
reserved = 34 + 10 if extra.get("level") is not None else 0
if extra.get("ban_text"):
ban_font = get_font(font_regular_path, 13)
reserved += int(draw.textlength(extra["ban_text"], font=ban_font)) + 18 + 10
max_name_width = CARD_WIDTH - CARD_MARGIN - HEADER_TEXT_X - reserved
if draw.textlength(player_name, font=name_font) > max_name_width:
player_name = _truncate_with_ellipsis(
draw, player_name, name_font, max_name_width
)
name_y = AVATAR_X + 6
draw.text( draw.text(
(280, 48), (HEADER_TEXT_X, name_y), player_name, font=name_font, fill=(255, 255, 255)
player_name,
font=get_font(font_light_path, 40),
fill=(255, 255, 255),
) )
# 画ID # 等级圆牌 / 风控标记紧随昵称
draw.text( cursor = HEADER_TEXT_X + int(draw.textlength(player_name, font=name_font)) + 12
(280, 100), if extra.get("level") is not None:
f"好友代码: {player_id}", cursor = _draw_level_badge(draw, cursor, name_y + 21, extra["level"]) + 10
font=get_font(font_regular_path, 19), if extra.get("ban_text"):
fill=(191, 191, 191), _draw_ban_tag(
) draw, cursor, name_y + 10, extra["ban_text"], extra.get("ban_kind") or "limited"
)
# 画简介 # 简介(最多 3 行)
desc_font = get_font(font_light_path, 20)
desc_top = name_y + 60
max_desc_width = CARD_WIDTH - CARD_MARGIN - HEADER_TEXT_X
line_width = 0 line_width = 0
offset = 0 offset = 0
line = "" line = ""
for idx, char in enumerate(player_description): for idx, char in enumerate(player_description):
line += char line += char
line_width += get_font(font_light_path, 22).getlength(char) line_width += desc_font.getlength(char)
if line_width > 640 or idx == len(player_description) - 1 or char == "\n": if (
line_width > max_desc_width
or idx == len(player_description) - 1
or char == "\n"
):
draw.text( draw.text(
(280, 132 + offset), (HEADER_TEXT_X, desc_top + offset),
line, line,
font=get_font(font_light_path, 22), font=desc_font,
fill=(255, 255, 255), fill=(235, 235, 235),
) )
line = "" line = ""
offset += 25
line_width = 0 line_width = 0
if offset >= 25 * 4: offset += 27
if offset >= 27 * 3:
break break
# 画游戏 # 好友代码 / 注册时间
meta_font = get_font(font_regular_path, 16)
meta_y = desc_top + offset + 14
meta_parts = [("好友代码: ", text_gray), (player_id, (255, 255, 255))]
if extra.get("member_since"):
meta_parts += [
(" 注册时间: ", text_gray),
(extra["member_since"], (255, 255, 255)),
]
_draw_kv_parts(draw, HEADER_TEXT_X, meta_y, meta_parts, meta_font)
header_h = max(AVATAR_X + AVATAR_SIZE + 26, meta_y + 22) + 14
draw.rectangle(
(CARD_MARGIN, header_h - 1, CARD_WIDTH - CARD_MARGIN, header_h),
fill=hex_to_rgb("333439"),
)
# ---------- 左栏:游戏动态 ----------
body_top = header_h + 16
brightest_color, darkest_color = get_brightest_and_darkest_color(player_bg) brightest_color, darkest_color = get_brightest_and_darkest_color(player_bg)
brightest_color = tuple(map(lambda x: x - 30 if x >= 30 else 0, brightest_color)) brightest_color = tuple(map(lambda x: x - 30 if x >= 30 else 0, brightest_color))
darkest_color = tuple( darkest_color = tuple(
map(lambda x: x + 30 if x <= 255 - 30 else 255, darkest_color) map(lambda x: x + 30 if x <= 255 - 30 else 255, darkest_color)
) )
brightest_color = (brightest_color[0], brightest_color[1], brightest_color[2], 128) # 成就条底色:取背景亮色的深色变体(压暗一些,保证白色文字和数字读得清)
brightest_color = random_color_offset(brightest_color, 20) hsv_achievement_color = rgb_to_hsv(*brightest_color)
darkest_color = (darkest_color[0], darkest_color[1], darkest_color[2], 128)
darkest_color = random_color_offset(darkest_color, 20)
# 画游戏信息
hsv_achievement_color = rgb_to_hsv(*brightest_color[:3])
achievement_color = tuple( achievement_color = tuple(
map( map(
int, int,
hsv_to_rgb( hsv_to_rgb(
hsv_achievement_color[0], hsv_achievement_color[0],
hsv_achievement_color[1] * 0.85, hsv_achievement_color[1] * 0.8,
hsv_achievement_color[2] * 0.6, hsv_achievement_color[2] * 0.35,
), ),
) )
) )
game_images: List[Image.Image] = []
for idx, game in enumerate(player_games): cards: List[Image.Image] = []
game_image = Image.open(BytesIO(game["game_header"])) for game in player_games:
game_info = draw_game_info( cards.append(
game_image, draw_game_info(
game["game_name"], Image.open(BytesIO(game["game_header"])),
game["game_time"], game["game_name"],
game["last_play_time"], game["game_time"],
game["achievements"], game["last_play_time"],
game["completed_achievement_number"], game["achievements"],
game["total_achievement_number"], game["completed_achievement_number"],
achievement_color, game["total_achievement_number"],
achievement_color,
two_week_time=game.get("two_week_time"),
)
) )
game_images.append(game_info)
# 画半透明黑色背景 # 背景太矮时先向下补一段(拉伸底部横条),避免内容整体被裁
bg_game = Image.new( min_height = body_top + 58 + 110 + CARD_MARGIN
"RGBA", (920, 106 + sum([game_image.height + 26 for game_image in game_images])) if bg.height < min_height:
strip = bg.crop((0, bg.height - 8, CARD_WIDTH, bg.height)).resize(
(CARD_WIDTH, min_height - bg.height), Image.BICUBIC
)
canvas = Image.new("RGB", (CARD_WIDTH, min_height))
canvas.paste(bg, (0, 0))
canvas.paste(strip, (0, bg.height))
bg = canvas
draw = ImageDraw.Draw(bg)
# 按可用高度筛出画得下的游戏卡片(至少画第一条)
y = body_top + 58
max_bottom = bg.height - CARD_MARGIN
shown_cards: List[Image.Image] = []
for card in cards:
if shown_cards and y + card.height > max_bottom:
break
shown_cards.append(card)
y += card.height + GAME_GAP
left_bottom = (y - GAME_GAP + 12) if shown_cards else (body_top + 66)
# 左右两栏等高的半透明底板
left_panel = Image.new("RGBA", (LEFT_W, left_bottom - body_top), (0, 0, 0, 120))
bg.paste(left_panel, (CARD_MARGIN, body_top), left_panel)
right_panel = Image.new("RGBA", (RIGHT_W, left_bottom - body_top), (0, 0, 0, 120))
bg.paste(right_panel, (RIGHT_X, body_top), right_panel)
# 左栏标题条(渐变)+ 近两周总时长
gradient = create_gradient_image(
(LEFT_W, 50),
(brightest_color[0], brightest_color[1], brightest_color[2], 128),
(darkest_color[0], darkest_color[1], darkest_color[2], 128),
) )
draw_game = ImageDraw.Draw(bg_game) bg.paste(gradient, (CARD_MARGIN, body_top), gradient)
draw_game.rectangle(
(
0,
0,
920,
bg_game.height,
),
fill=(0, 0, 0, 120),
)
bg.paste(bg_game, (20, 272), bg_game)
# 画渐变条
gradient = create_gradient_image((920, 50), brightest_color, darkest_color)
bg.paste(gradient, (20, 272), gradient)
# 画渐变条的文字:最新动态,最近游戏
draw.text( draw.text(
(34, 279), (CARD_MARGIN + 14, body_top + 7),
"最新动态", "游戏动态",
font=get_font(font_light_path, 26), font=get_font(font_light_path, 26),
fill=(255, 255, 255), fill=(255, 255, 255),
) )
if player_last_two_weeks_time is not None: if player_last_two_weeks_time:
width = get_font(font_light_path, 26).getlength( total_font = get_font(font_light_path, 22)
player_last_two_weeks_time total_width = total_font.getlength(player_last_two_weeks_time)
)
draw.text( draw.text(
(960 - width - 34, 279), (CARD_MARGIN + LEFT_W - total_width - 14, body_top + 9),
player_last_two_weeks_time, player_last_two_weeks_time,
font=get_font(font_light_path, 26), font=total_font,
fill=(255, 255, 255), fill=(255, 255, 255),
) )
y = body_top + 58
for card in shown_cards:
bg.paste(card, (CARD_MARGIN, y), card.convert("RGBA"))
y += card.height + GAME_GAP
y = 350 # ---------- 右栏:当前在线 + 计数 ----------
for idx, game_image in enumerate(game_images): status_title = status_titles.get(
bg.paste( extra.get("status_kind") or "online", "当前在线"
game_image, )
((920 - game_image.width) // 2 + 20, y), draw.text(
game_image.convert("RGBA"), (RIGHT_X + 20, body_top + 14),
status_title,
font=get_font(font_light_path, 20),
fill=hex_to_rgb("c5d6d4"),
)
status_extra_h = 0
if extra.get("status_text"):
status_font = get_font(font_regular_path, 17)
status_lines = _split_two_lines(
draw, extra["status_text"], status_font, RIGHT_W - 60
) )
y += game_image.height + 26 status_extra_h = 22 * (len(status_lines) - 1)
_draw_status_row(
draw,
RIGHT_X + 20,
body_top + 46,
status_lines[0],
extra.get("status_kind") or "online",
status_lines[1] if len(status_lines) > 1 else "",
)
divider_y = body_top + 76 + status_extra_h
draw.rectangle(
(RIGHT_X + 20, divider_y, RIGHT_X + RIGHT_W - 20, divider_y + 1),
fill=hex_to_rgb("333439"),
)
player_bg.paste(bg, ((player_bg.width - 960) // 2, 0), bg.convert("RGBA")) count_rows = list((extra.get("counts") or {}).items())
panel_h = left_bottom - body_top - status_extra_h
step = COUNT_ROW_MAX_STEP
if count_rows:
step = min(COUNT_ROW_MAX_STEP, max(26, (panel_h - 114) // len(count_rows)))
row_font = get_font(font_regular_path, 15)
ry = divider_y + 14
for label, value in count_rows:
if ry + 20 > left_bottom - 12: # 面板放不下就不画,保证不越界
break
draw.text((RIGHT_X + 20, ry), label, font=row_font, fill=hex_to_rgb("8f989b"))
value_text = str(value)
draw.text(
(RIGHT_X + RIGHT_W - 20 - row_font.getlength(value_text), ry),
value_text,
font=row_font,
fill=(255, 255, 255),
)
ry += step
# 内容高于背景时(极端情况),背景向下拉伸补足后再贴回
if bg.height > player_bg.height:
strip = player_bg.crop(
(0, player_bg.height - 8, player_bg.width, player_bg.height)
).resize((player_bg.width, bg.height - player_bg.height), Image.BICUBIC)
canvas = Image.new("RGB", (player_bg.width, bg.height))
canvas.paste(player_bg, (0, 0))
canvas.paste(strip, (0, player_bg.height))
player_bg = canvas
player_bg.paste(bg, ((player_bg.width - CARD_WIDTH) // 2, 0), bg.convert("RGBA"))
return player_bg return player_bg
@@ -34,20 +34,31 @@ from .. import (
get_steam_users_info, get_steam_users_info,
get_target, get_target,
get_user_data, get_user_data,
get_recently_played,
fetch_game_header_image,
image_to_bytes, image_to_bytes,
key_select, key_select,
localize_game_name, localize_game_name,
simplize_steam_player_data, simplize_steam_player_data,
build_profile_status,
describe_ban,
_fill_missing_icon_hashes, _fill_missing_icon_hashes,
_query_game_names, _query_game_names,
_query_game_name_ch_by_en,
_upsert_game_icon_hashes, _upsert_game_icon_hashes,
update_steamdb_apps, update_steamdb_apps,
) )
# 信息卡「游戏动态」最多展示的条数(按近两周时长排序,列表本身最多 8 条)
MAX_ACTIVITY_GAMES = 4
help = on_command("steamhelp", aliases={"steam帮助"}, priority=10) help = on_command("steamhelp", aliases={"steam帮助"}, priority=10)
bind = on_command("steambind", aliases={"绑定steam"}, priority=10) bind = on_command("steambind", aliases={"绑定steam"}, priority=10)
unbind = on_command("steamunbind", 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} 秒后再试")( check = cooldown(30, scope="group", hint="群里刚有人查过,{secs} 秒后再试")(
on_command("steamcheck", aliases={"查看steam", "查steam", "谁在玩游戏"}, priority=10) 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_id = user_data["steam_id"]
steam_friend_code = str(int(steam_id) - STEAM_ID_OFFSET) steam_friend_code = str(int(steam_id) - STEAM_ID_OFFSET)
player_data = await get_user_data(steam_id, cache_path, config.proxy) # 资料页抓取与近两周列表 API 并行;后者是可选增强,无配额时自动跳过
names = await get_game_cn_names( player_data, recent_games = await asyncio.gather(
[g.get("appid") for g in player_data["game_data"] if g.get("appid")], get_user_data(steam_id, cache_path, config.proxy),
db_path, get_recently_played(steam_id, config.proxy),
) )
for g in player_data["game_data"]: recent_games = recent_games or []
g["game_name"] = localize_game_name(g["game_name"], g.get("appid"), names)
draw_data = [ # 游戏动态以最近两周列表为准(资料页只有最近几款),资料页解析的
# 成就/最后运行日期按 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"], "appid": appid,
"game_name": game["game_name"], "name": game["game_name"],
"game_time": f"{game['play_time']} 小时", "two_week_hours": None,
"last_play_time": game["last_played"], "total_hours": None,
"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"] 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( image = draw_player_status(
player_data["background"], player_data["avatar"], player_data["player_name"], player_data["background"], player_data["avatar"], player_data["player_name"],
str(steam_friend_code), player_data["description"], 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)) await info.finish(await UniMessage(Image(raw=image_to_bytes(image))).export(bot))
@@ -1,4 +1,4 @@
from typing import TypedDict, List from typing import TypedDict, List, Optional, Dict
class Player(TypedDict): class Player(TypedDict):
@@ -57,9 +57,38 @@ class PlayerData(TypedDict):
player_name: str player_name: str
background: bytes background: bytes
avatar: bytes avatar: bytes
avatar_frame: Optional[bytes] # 头像框(社区物品)PNG,无则 None
description: str description: str
recent_2_week_play_time: str recent_2_week_play_time: str
game_data: List[GameData] game_data: List[GameData]
# 以下字段来自主页 HTML 解析 + 社区 ?xml=1(不占 API 配额),缺失时为默认值
level: Optional[int]
counts: Dict[str, int]
member_since: Optional[str]
online_state: Optional[str]
state_message: Optional[str]
current_game: Optional[str] # 资料页「当前正在游戏」的游戏名(已本地化)
privacy_state: Optional[str]
vac_banned: bool
trade_ban_state: Optional[str]
limited_account: bool
hours_2wk: Optional[float]
class ProfileCardExtra(TypedDict, total=False):
"""信息卡头部增强字段(缺失的键不绘制对应元素)
status_kind / ban_kind 决定配色,取值见 draw.py 的 status_row_colors /
ban_tag_colors;status_kind 为 private 时表示资料私密降级提示。
"""
level: Optional[int]
counts: Dict[str, int]
member_since: Optional[str]
status_text: Optional[str]
status_kind: Optional[str]
ban_text: Optional[str]
ban_kind: Optional[str]
class DrawPlayerStatusData(TypedDict): class DrawPlayerStatusData(TypedDict):
@@ -70,6 +99,7 @@ class DrawPlayerStatusData(TypedDict):
achievements: List[Achievements] achievements: List[Achievements]
completed_achievement_number: int completed_achievement_number: int
total_achievement_number: int total_achievement_number: int
two_week_time: Optional[str] # e.g. 近两周 3.2 小时,缺失为 None
__all__ = [ __all__ = [
@@ -78,5 +108,6 @@ __all__ = [
"PlayerSummariesResponse", "PlayerSummariesResponse",
"ProcessedPlayer", "ProcessedPlayer",
"PlayerSummariesProcessedResponse", "PlayerSummariesProcessedResponse",
"ProfileCardExtra",
"DrawPlayerStatusData", "DrawPlayerStatusData",
] ]
+395 -10
View File
@@ -8,7 +8,9 @@ from nonebot.log import logger
from typing import List, Optional, Dict from typing import List, Optional, Dict
from datetime import datetime, timezone from datetime import datetime, timezone
import asyncio import asyncio
import hashlib
import json import json
from xml.etree import ElementTree
from hexi.core.rate_limit import acquire from hexi.core.rate_limit import acquire
from .config import Config from .config import Config
@@ -338,6 +340,249 @@ async def _fetch(
return default return default
# 交易封禁 XML 取值 -> 展示文案(未知取值原样展示)
TRADE_BAN_TEXT = {
"Trade Banned": "交易封禁",
"Probation": "交易限制",
}
# 默认在线状态文案 -> 中文(自定义状态原样展示)
STATE_MESSAGE_TEXT = {
"Online": "在线",
"Away": "离开",
}
# 计数行展示顺序(页面本身顺序不稳定,这里固定;未列出的标签按页面顺序排后面)
COUNT_LABEL_ORDER = ("游戏", "评测", "徽章", "好友", "截图", "创意工坊项目", "指南")
def _url_digest(url: str) -> str:
"""URL → 短摘要,用于生成缓存文件名"""
return hashlib.md5(url.encode()).hexdigest()[:10]
def parse_profile_background_url(html: str) -> Optional[str]:
"""解析资料页背景图 URL(纯函数,便于单测)
普通背景在 CSS 的 background-image 里;动画背景没有 CSS 图,
但 <video poster="..."> 上带着 1920×1080 的静态首帧,同样可用。
"""
candidates = [
m.group(1).strip()
for m in re.finditer(
r"background-image:\s*url\(\s*['\"]?(.*?)['\"]?\s*\)", html
)
]
# 背景图都来自社区物品(/items/);页面里其他 url() 不一定是背景,优先挑这类
for url in candidates:
if "/items/" in url and not url.lower().endswith((".webm", ".mp4")):
return url
for url in candidates:
if not url.lower().endswith((".webm", ".mp4")):
return url
poster = re.search(r"<video[^>]*\bposter=\"([^\"]+)\"", html)
if poster:
return poster.group(1)
return None
def parse_avatar_frame_url(html: str) -> Optional[str]:
"""解析头像框(社区物品)图片 URL,优先 prefers-reduced-motion 的静态版本"""
soup = BeautifulSoup(html, "html.parser")
frame = soup.select_one("div.profile_avatar_frame")
if frame is None:
return None
source = frame.select_one('source[media*="reduced-motion"]')
if source and source.get("srcset"):
return source["srcset"].split()[0]
img = frame.select_one("img")
if img is None:
return None
if img.get("src"):
return img["src"]
srcset = img.get("srcset") or ""
return srcset.split()[0] if srcset else None
def _parse_member_since(value: Optional[str]) -> Optional[str]:
"""注册时间归一化为 YYYY-MM-DD;无法识别时原样返回"""
if not value:
return None
for fmt in ("%B %d, %Y", "%Y年%m月%d日"):
try:
return datetime.strptime(value, fmt).strftime("%Y-%m-%d")
except ValueError:
continue
return value
def parse_profile_xml(text: str) -> dict:
"""解析社区资料 XML(纯函数,便于单测)
私密资料只返回极少节点,缺失字段按 None/False 处理;解析失败返回空 dict。
"""
try:
root = ElementTree.fromstring(text)
except ElementTree.ParseError:
return {}
def _text(tag: str) -> Optional[str]:
node = root.find(tag)
if node is None or node.text is None:
return None
return node.text.strip() or None
def _flag(tag: str) -> bool:
return _text(tag) == "1"
hours = _text("hoursPlayed2Wk")
try:
hours_2wk = float(hours) if hours else None
except ValueError:
hours_2wk = None
trade_ban = _text("tradeBanState")
return {
"member_since": _parse_member_since(_text("memberSince")),
"online_state": _text("onlineState"),
"state_message": _text("stateMessage"),
"vac_banned": _flag("vacBanned"),
"trade_ban_state": None if trade_ban in (None, "None") else trade_ban,
"limited_account": _flag("isLimitedAccount"),
"hours_2wk": hours_2wk,
"privacy_state": _text("privacyState"),
}
def parse_profile_extras(html: str) -> dict:
"""从主页 HTML 解析等级与计数行(纯函数,便于单测)"""
soup = BeautifulSoup(html, "html.parser")
level = None
level_node = soup.select_one("div.persona_level span.friendPlayerLevelNum")
if level_node and level_node.text.strip().isdigit():
level = int(level_node.text.strip())
counts: Dict[str, int] = {}
for link in soup.select("div.profile_count_link"):
label_node = link.select_one(".count_link_label")
total_node = link.select_one(".profile_count_link_total")
if label_node is None or total_node is None:
continue
label = label_node.text.strip()
# 空值多为 &nbsp; 占位(如未公开的库存),没有数字就不展示
value = total_node.text.replace("\xa0", "").replace(",", "").strip()
if not value.isdigit():
continue
counts[label] = int(value)
# 按展示顺序固定排列,未列出的标签按页面顺序排后面
ordered: Dict[str, int] = {
label: counts[label] for label in COUNT_LABEL_ORDER if label in counts
}
ordered.update({k: v for k, v in counts.items() if k not in ordered})
return {"level": level, "counts": ordered}
def parse_in_game_name(html: str) -> Optional[str]:
"""解析资料页「当前正在游戏」的游戏名(纯函数,便于单测)
这块比社区 XML 的 onlineState 更新更及时,且随 Accept-Language 本地化。
"""
soup = BeautifulSoup(html, "html.parser")
node = soup.select_one("div.profile_in_game_name")
if node is None:
return None
return node.get_text(strip=True) or None
def _extract_in_game_name(state_message: Optional[str]) -> str:
"""从 XML stateMessage 提取游戏名
实测两种写法都有:"In-Game: Team Fortress 2" / "In-Game<br/>VRChat"。
"""
text = re.sub(r"<[^>]+>", " ", state_message or "").strip()
if text.lower().startswith("in-game"):
text = text[len("In-Game"):].lstrip(":: ").strip()
return text or "未知游戏"
def build_profile_status(
online_state: Optional[str],
state_message: Optional[str],
privacy_state: Optional[str],
current_game: Optional[str] = None,
) -> dict:
"""整理卡片状态行文案(kind 决定配色)
优先级:资料页「当前正在游戏」> XML onlineState/stateMessage。
返回 dict:text / kind;中文名缺失时带 game_name_en 供调用方本地化。
"""
if privacy_state and privacy_state != "public":
return {"text": "资料私密,部分信息不可见", "kind": "private"}
if current_game:
# 资料页文案已按 zh-CN 本地化,直接用;卡片会配「当前正在游戏」标题
return {"text": current_game, "kind": "in-game"}
if online_state == "in-game":
name_en = _extract_in_game_name(state_message)
return {
"text": name_en,
"kind": "in-game",
"game_name_en": name_en,
}
if online_state == "online":
# 默认状态文案是英文(Online/Away),换成中文;自定义状态原样展示
text = STATE_MESSAGE_TEXT.get(state_message or "", state_message or "在线")
return {"text": text, "kind": "online"}
if online_state == "offline":
return {"text": "离线", "kind": "offline"}
return {"text": state_message or "", "kind": "online"}
def describe_ban(
vac_banned: bool,
trade_ban_state: Optional[str],
limited_account: bool,
) -> Optional[dict]:
"""风控标记(优先级 VAC > 交易封禁 > 受限账号),无异常返回 None"""
if vac_banned:
return {"text": "VAC 封禁", "kind": "vac"}
if trade_ban_state:
return {
"text": TRADE_BAN_TEXT.get(trade_ban_state, trade_ban_state),
"kind": "trade",
}
if limited_account:
return {"text": "受限账号", "kind": "limited"}
return None
async def fetch_profile_xml(steam_id, proxy: str = None) -> dict:
"""抓取社区资料 XML(memberSince/在线状态/封禁/近两周时长),不占 API 配额
与主页同属社区页面请求;失败或私密资料时字段缺失,调用方按空值降级。
"""
url = f"https://steamcommunity.com/profiles/{steam_id}/?xml=1"
try:
async with httpx.AsyncClient(
proxy=proxy,
timeout=httpx.Timeout(20.0),
follow_redirects=True,
headers={"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8"},
) as client:
response = await client.get(url)
if response.status_code != 200:
logger.warning(
f"获取 Steam 资料 XML 失败: HTTP {response.status_code}"
)
return {}
return parse_profile_xml(response.text)
except Exception as exc:
# 增强项失败不影响主卡片,任何异常都降级为空结果
logger.warning(f"获取 Steam 资料 XML 失败: {exc}")
return {}
async def get_user_data( async def get_user_data(
steam_id: int, cache_path: Path, proxy: str = None steam_id: int, cache_path: Path, proxy: str = None
) -> PlayerData: ) -> PlayerData:
@@ -355,15 +600,30 @@ async def get_user_data(
"description": "No information given.", "description": "No information given.",
"background": default_background, "background": default_background,
"avatar": default_avatar, "avatar": default_avatar,
"avatar_frame": None,
"player_name": "Unknown", "player_name": "Unknown",
"recent_2_week_play_time": None, "recent_2_week_play_time": None,
"game_data": [], "game_data": [],
# 资料 XML(社区接口,不占 API 配额)解锁的字段,缺失保持默认值
"level": None,
"counts": {},
"member_since": None,
"online_state": None,
"state_message": None,
"current_game": None,
"privacy_state": None,
"vac_banned": False,
"trade_ban_state": None,
"limited_account": False,
"hours_2wk": None,
} }
local_time = datetime.now(timezone.utc).astimezone() local_time = datetime.now(timezone.utc).astimezone()
utc_offset_minutes = int(local_time.utcoffset().total_seconds() / 60) utc_offset_minutes = int(local_time.utcoffset().total_seconds() / 60)
timezone_cookie_value = f"{utc_offset_minutes},0" timezone_cookie_value = f"{utc_offset_minutes},0"
# XML 与主页并行抓取,省一个往返;失败只记日志,字段留空
xml_task = asyncio.create_task(fetch_profile_xml(steam_id, proxy))
try: try:
async with httpx.AsyncClient( async with httpx.AsyncClient(
proxy=proxy, proxy=proxy,
@@ -384,8 +644,14 @@ async def get_user_data(
response.raise_for_status() response.raise_for_status()
except httpx.RequestError as exc: except httpx.RequestError as exc:
logger.error(f"Failed to get user data: {exc}") logger.error(f"Failed to get user data: {exc}")
await xml_task # 主页失败也要回收任务,避免悬挂
return result return result
result.update(await xml_task)
result.update(parse_profile_extras(html))
# 资料页的「当前正在游戏」比 XML 更新更及时(且已本地化)
result["current_game"] = parse_in_game_name(html)
# player name # player name
player_name = re.search(r"<title>Steam 社区 :: (.*?)</title>", html) player_name = re.search(r"<title>Steam 社区 :: (.*?)</title>", html)
if player_name: if player_name:
@@ -407,12 +673,15 @@ async def get_user_data(
# remove xml # remove xml
result["description"] = re.sub(r"<.*?>", "", result["description"]) result["description"] = re.sub(r"<.*?>", "", result["description"])
# background # background:普通背景走 CSS,动画背景用 <video> 的静态首帧;按 URL 缓存
background_url = re.search(r"background-image: url\( \'(.*?)\' \)", html) background_url = parse_profile_background_url(html)
if background_url: if background_url:
background_url = background_url.group(1) suffix = Path(background_url.split("?")[0]).suffix or ".jpg"
result["background"] = await _fetch( result["background"] = await _fetch(
background_url, default_background, proxy=proxy background_url,
default_background,
cache_file=cache_path / f"bg_{_url_digest(background_url)}{suffix}",
proxy=proxy,
) )
# avatar # avatar
@@ -427,6 +696,16 @@ async def get_user_data(
avatar_url, default_avatar, cache_file=avatar_file, proxy=proxy avatar_url, default_avatar, cache_file=avatar_file, proxy=proxy
) )
# avatar frame(社区物品,取静态版本);拿不到就不画
frame_url = parse_avatar_frame_url(html)
if frame_url:
result["avatar_frame"] = await _fetch(
frame_url,
b"",
cache_file=cache_path / f"frame_{_url_digest(frame_url)}.png",
proxy=proxy,
)
# recent 2 week play time # recent 2 week play time
# \t<div class="recentgame_quicklinks recentgame_recentplaytime">\r\n\t\t\t\t\t\t\t\t\t<div>15.5 小时(过去 2 周)</div> # \t<div class="recentgame_quicklinks recentgame_recentplaytime">\r\n\t\t\t\t\t\t\t\t\t<div>15.5 小时(过去 2 周)</div>
play_time_text = re.search( play_time_text = re.search(
@@ -437,6 +716,14 @@ async def get_user_data(
play_time_text = play_time_text.group(1) play_time_text = play_time_text.group(1)
result["recent_2_week_play_time"] = play_time_text result["recent_2_week_play_time"] = play_time_text
# 页面没写近两周时长时才用 XML 的 hoursPlayed2Wk 兜底
# (部分账号 XML 恒为 0.0,与页面/API 不符,不能优先采信)
if result["recent_2_week_play_time"] is None and result["hours_2wk"] is not None:
hours_2wk = result["hours_2wk"]
result["recent_2_week_play_time"] = (
"近两周未游玩" if hours_2wk <= 0 else f"{hours_2wk:g} 小时(过去 2 周)"
)
# game data — 先收集信息,再并行下载图片 # game data — 先收集信息,再并行下载图片
soup = BeautifulSoup(html, "html.parser") soup = BeautifulSoup(html, "html.parser")
recent_games = soup.find_all("div", class_="recent_game") recent_games = soup.find_all("div", class_="recent_game")
@@ -447,15 +734,22 @@ async def get_user_data(
game_info = {} game_info = {}
game_info["game_name"] = game.find("div", class_="game_name").text.strip() game_info["game_name"] = game.find("div", class_="game_name").text.strip()
game_info["game_image_url"] = game.find("img", class_="game_capsule")["src"] game_info["game_image_url"] = game.find("img", class_="game_capsule")["src"]
game_info_split = game_info["game_image_url"].split("/") # 封面 URL 有两种形态(新版多一段 hash):
# 封面 URL 形如 .../steam/apps/{appid}/header.jpg,提取 appid 用于本地化 # .../steam/apps/{appid}/header.jpg
appid_str = game_info_split[-2] # .../store_item_assets/steam/apps/{appid}/{hash}/capsule_xxx.jpg?t=...
game_info["appid"] = int(appid_str) if appid_str.isdigit() else None # 取 /apps/ 后的数字段,别用 [-2](新版会取到 hash 导致 appid=None)
appid_match = re.search(r"/apps/(\d+)/", game_info["game_image_url"])
game_info["appid"] = int(appid_match.group(1)) if appid_match else None
if game_info["appid"] is not None:
header_name = f"header_{game_info['appid']}.jpg"
else:
url_digest = hashlib.md5(game_info["game_image_url"].encode()).hexdigest()
header_name = f"header_{url_digest[:10]}.jpg"
download_tasks.append(( download_tasks.append((
game_info, "game_image", game_info, "game_image",
game_info["game_image_url"], default_header_image, game_info["game_image_url"], default_header_image,
cache_path / f"header_{game_info_split[-2]}.jpg", proxy, cache_path / header_name, proxy,
)) ))
play_time_text = game.find("div", class_="game_info_details").text.strip() play_time_text = game.find("div", class_="game_info_details").text.strip()
@@ -581,6 +875,31 @@ def _query_game_names(db_path: Optional[Path], appids: list[int]) -> dict[str, d
} }
def _query_game_name_ch_by_en(db_path: Optional[Path], name_en: str) -> Optional[str]:
"""按英文名反查中文名(仅本地映射表,用于 XML 状态里的英文游戏名)
name_en 默认没有索引,首次调用时顺带建,避免每次全表扫描。
"""
if isinstance(db_path, str):
db_path = Path(db_path)
if not name_en or db_path is None:
return None
try:
with closing(_get_game_db_conn(db_path)) as conn:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_game_names_name_en "
"ON game_names(name_en)"
)
row = conn.execute(
"SELECT name_ch FROM game_names "
"WHERE name_en = ? AND name_ch != '' LIMIT 1",
(name_en,),
).fetchone()
return row[0] if row else None
except sqlite3.Error:
return None
def _query_game_icon_hashes(db_path: Optional[Path], appids: list[int]) -> dict[int, str]: def _query_game_icon_hashes(db_path: Optional[Path], appids: list[int]) -> dict[int, str]:
"""按 appid 批量查询 clienticon hash,返回 {appid: hash}""" """按 appid 批量查询 clienticon hash,返回 {appid: hash}"""
if isinstance(db_path, str): if isinstance(db_path, str):
@@ -1008,11 +1327,77 @@ async def get_play_time(steam_id: str) -> list[dict]:
return game_list return game_list
async def get_recently_played(
steam_id: str,
proxy: str = None,
wait: bool = True,
timeout: float = 10,
) -> Optional[list[dict]]:
"""获取最近两周玩过的游戏(含两周/总时长),顺序即 Steam 返回的近两周时长降序
返回 [{"appid", "name", "two_week_hours", "total_hours"}];
无 API 配额返回 None(调用方跳过该增强项);接口失败/资料不公开返回 []。
"""
if not await acquire_steam_quota(wait=wait, timeout=timeout):
logger.warning("Steam API 配额已满,近两周时长查询被跳过")
return None
try:
manager = get_api_key_manager()
except RuntimeError:
logger.error("API key manager 未初始化,无法获取近两周时长")
return []
api_key = await manager.get_next_key()
url = (
"https://api.steampowered.com/IPlayerService/GetRecentlyPlayedGames/v1/"
f"?key={api_key}&steamid={steam_id}"
)
try:
async with httpx.AsyncClient(
proxy=proxy, timeout=httpx.Timeout(30.0)
) as client:
response = await client.get(url)
except Exception as e:
logger.error(f"获取近两周时长失败: {e}")
return []
if response.status_code != 200:
reason = (response.text or "").strip().replace("\n", " ")[:200]
logger.warning(
f"近两周时长 API 失败,状态码: {response.status_code} | {reason}"
)
return []
games = response.json().get("response", {}).get("games") or []
return [
{
"appid": int(g["appid"]),
"name": g.get("name") or "Unknown",
"two_week_hours": round(g.get("playtime_2weeks", 0) / 60, 1),
"total_hours": round(g.get("playtime_forever", 0) / 60, 1),
}
for g in games
if g.get("appid")
]
async def fetch_game_header_image(
appid: int, cache_path: Path, proxy: str = None
) -> Optional[bytes]:
"""按 appid 从公开 CDN 取 header.jpg(资料页没给封面的游戏用),带磁盘缓存"""
url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{appid}/header.jpg"
data = await _fetch(
url, b"", cache_file=cache_path / f"header_{appid}.jpg", proxy=proxy
)
return data or None
if __name__ == "__main__": if __name__ == "__main__":
from nonebot.log import logger from nonebot.log import logger
import asyncio import asyncio
data = asyncio.run(get_user_data(76561199135038179, None)) data = asyncio.run(get_user_data(76561198402219863, None))
with open("bg.jpg", "wb") as f: with open("bg.jpg", "wb") as f:
f.write(data["background"]) f.write(data["background"])
+390
View File
@@ -0,0 +1,390 @@
"""Steam 信息卡解析层 (steam.py 纯函数) 单元测试
steam.py 只依赖 httpx/bs4/nonebot.log + 相对导入的 config/models,不碰
NoneBot 运行时;仿照 test_mc_servers.py 造一棵一次性包骨架(`__path__` 指向
插件目录),相对导入就能自动落到真实文件上,且不触发插件包 __init__.py。
测的是资料页 HTML 与社区 ?xml=1 的解析、状态/风控文案整理,全离线。
样本取自真实抓取结果(76561199135038179 公开资料)裁剪而成。
"""
import importlib.util
import sys
import types
from pathlib import Path
import pytest
_PLUGIN_DIR = (
Path(__file__).resolve().parents[1]
/ "hexi"
/ "plugins"
/ "nonebot_plugin_steam_info"
)
_PKG = "steam_parse_testpkg"
PROFILE_XML = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?><profile>
<steamID64>76561199135038179</steamID64>
<steamID><![CDATA[zhaomaoniu]]></steamID>
<onlineState>online</onlineState>
<stateMessage><![CDATA[Online]]></stateMessage>
<privacyState>public</privacyState>
<vacBanned>0</vacBanned>
<tradeBanState>None</tradeBanState>
<isLimitedAccount>0</isLimitedAccount>
<memberSince>January 29, 2021</memberSince>
<hoursPlayed2Wk>0.0</hoursPlayed2Wk>
</profile>"""
PROFILE_XML_IN_GAME = """<profile>
<onlineState>in-game</onlineState>
<stateMessage><![CDATA[In-Game: Team Fortress 2]]></stateMessage>
<privacyState>public</privacyState>
<memberSince>January 29, 2021</memberSince>
</profile>"""
PROFILE_XML_PRIVATE = """<profile>
<steamID64>76561199135038179</steamID64>
<privacyState>private</privacyState>
</profile>"""
PROFILE_XML_BANNED = """<profile>
<onlineState>offline</onlineState>
<privacyState>public</privacyState>
<vacBanned>1</vacBanned>
<tradeBanState>Trade Banned</tradeBanState>
<isLimitedAccount>1</isLimitedAccount>
<hoursPlayed2Wk>7.5</hoursPlayed2Wk>
</profile>"""
PROFILE_HTML = """
<div class="profile_header_badgeinfo">
<div class="persona_name persona_level">
<div class="friendPlayerLevel lvl_0"><span class="friendPlayerLevelNum">
7
</span></div> 级
</div>
</div>
<div class="profile_item_links">
<div class="profile_count_link ellipsis">
<a href="https://steamcommunity.com/profiles/76561199135038179/games/">
<span class="count_link_label">游戏</span>&nbsp;
<span class="profile_count_link_total"> 1,234 </span>
</a>
</div>
<div class="profile_count_link ellipsis">
<a href="https://steamcommunity.com/profiles/76561199135038179/inventory/">
<span class="count_link_label">库存</span>&nbsp;
<span class="profile_count_link_total"> &nbsp; </span>
</a>
</div>
<div class="profile_count_link ellipsis">
<a href="https://steamcommunity.com/profiles/76561199135038179/recommended/">
<span class="count_link_label">评测</span>&nbsp;
<span class="profile_count_link_total"> 2 </span>
</a>
</div>
<div class="profile_count_link ellipsis">
<a href="https://steamcommunity.com/profiles/76561199135038179/badges/">
<span class="count_link_label">徽章</span>&nbsp;
<span class="profile_count_link_total"> 7 </span>
</a>
</div>
<div class="profile_count_link ellipsis">
<a href="https://steamcommunity.com/profiles/76561199135038179/friends/">
<span class="count_link_label">好友</span>&nbsp;
<span class="profile_count_link_total"> 12 </span>
</a>
</div>
</div>
"""
def _load_by_path(name: str, path: Path):
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module)
return module
_STUB_MODULES = ("hexi", "hexi.core", "hexi.core.rate_limit")
def _install_rate_limit_stub() -> dict:
"""steam.py 顶层 `from hexi.core.rate_limit import acquire`,而 hexi.core 包
__init__ 会触发 NoneBot 运行时初始化;纯函数测试用不到限流,塞一个 no-op
acquire 并屏蔽包 __init__(沿用 test_rate_limit.py 的隔离思路)。返回现场用于还原。
"""
saved = {name: sys.modules.get(name) for name in _STUB_MODULES}
hexi_dir = _PLUGIN_DIR.parents[1]
hexi = types.ModuleType("hexi")
hexi.__path__ = [str(hexi_dir)] # type: ignore[attr-defined]
core = types.ModuleType("hexi.core")
core.__path__ = [str(hexi_dir / "core")] # type: ignore[attr-defined]
rate_limit = types.ModuleType("hexi.core.rate_limit")
async def _acquire(*_args, **_kwargs) -> bool:
return True
rate_limit.acquire = _acquire # type: ignore[attr-defined]
sys.modules["hexi"] = hexi
sys.modules["hexi.core"] = core
sys.modules["hexi.core.rate_limit"] = rate_limit
return saved
def _restore_modules(saved: dict) -> None:
for name, module in saved.items():
if module is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = module
@pytest.fixture(scope="module")
def steam():
"""一次性包骨架 + 真实 steam.py(不触发插件 __init__.py)"""
pkg = types.ModuleType(_PKG)
pkg.__path__ = [str(_PLUGIN_DIR)] # type: ignore[attr-defined]
sys.modules[_PKG] = pkg
saved = _install_rate_limit_stub()
try:
yield _load_by_path(f"{_PKG}.steam", _PLUGIN_DIR / "steam.py")
finally:
_restore_modules(saved)
for name in list(sys.modules):
if name == _PKG or name.startswith(f"{_PKG}."):
sys.modules.pop(name, None)
def test_parse_profile_xml_full(steam):
data = steam.parse_profile_xml(PROFILE_XML)
assert data["member_since"] == "2021-01-29"
assert data["online_state"] == "online"
assert data["state_message"] == "Online"
assert data["vac_banned"] is False
assert data["trade_ban_state"] is None
assert data["limited_account"] is False
assert data["hours_2wk"] == 0.0
assert data["privacy_state"] == "public"
def test_parse_profile_xml_in_game(steam):
data = steam.parse_profile_xml(PROFILE_XML_IN_GAME)
assert data["online_state"] == "in-game"
assert data["state_message"] == "In-Game: Team Fortress 2"
def test_parse_profile_xml_private_profile(steam):
data = steam.parse_profile_xml(PROFILE_XML_PRIVATE)
assert data["privacy_state"] == "private"
assert data["member_since"] is None
assert data["online_state"] is None
assert data["hours_2wk"] is None
assert data["vac_banned"] is False
def test_parse_profile_xml_bans_and_hours(steam):
data = steam.parse_profile_xml(PROFILE_XML_BANNED)
assert data["vac_banned"] is True
assert data["trade_ban_state"] == "Trade Banned"
assert data["limited_account"] is True
assert data["hours_2wk"] == 7.5
def test_parse_profile_xml_invalid_text(steam):
assert steam.parse_profile_xml("not xml at all") == {}
def test_member_since_localized_fallback(steam):
"""本地化/未知格式:能识别的归一化,认不出就原样保留"""
xml_zh = "<profile><memberSince>2021年1月29日</memberSince></profile>"
assert steam.parse_profile_xml(xml_zh)["member_since"] == "2021-01-29"
xml_odd = "<profile><memberSince>29/01/2021</memberSince></profile>"
assert steam.parse_profile_xml(xml_odd)["member_since"] == "29/01/2021"
def test_parse_profile_extras(steam):
data = steam.parse_profile_extras(PROFILE_HTML)
assert data["level"] == 7
# 库存是空占位被跳过;千位逗号被去掉
assert data["counts"] == {"游戏": 1234, "评测": 2, "徽章": 7, "好友": 12}
def test_parse_profile_extras_missing(steam):
data = steam.parse_profile_extras("<html><body>无资料</body></html>")
assert data["level"] is None
assert data["counts"] == {}
IN_GAME_HTML = """
<div class="responsive_status_info">
<div class="profile_in_game persona in-game">
<div class="profile_in_game_header">当前正在游戏</div>
<div class="profile_in_game_name"> Subnautica 2:异星水域 </div>
<div class="profile_in_game_joingame"><a href="steam://joinlobby/1962700/xyz/">加入游戏</a></div>
</div>
</div>
"""
def test_build_profile_status_in_game(steam):
# XML 两种写法都要认:"In-Game: X" 和 "In-Game<br/>X"
status = steam.build_profile_status(
"in-game", "In-Game: Team Fortress 2", "public"
)
assert status["text"] == "Team Fortress 2"
assert status["kind"] == "in-game"
assert status["game_name_en"] == "Team Fortress 2"
status = steam.build_profile_status("in-game", "In-Game<br/>VRChat", "public")
assert status["text"] == "VRChat"
assert status["game_name_en"] == "VRChat"
def test_parse_in_game_name(steam):
assert steam.parse_in_game_name(IN_GAME_HTML) == "Subnautica 2:异星水域"
assert steam.parse_in_game_name(PROFILE_HTML) is None
def test_build_profile_status_prefers_page_in_game(steam):
"""资料页「当前正在游戏」优先于 XML(XML 会滞后,且资料页已本地化)"""
status = steam.build_profile_status(
"online", "Online", "public", current_game="Subnautica 2:异星水域"
)
assert status == {"text": "Subnautica 2:异星水域", "kind": "in-game"}
def test_build_profile_status_online_and_offline(steam):
# 默认英文状态换中文;自定义状态原样保留
assert steam.build_profile_status("online", "Online", "public") == {
"text": "在线",
"kind": "online",
}
assert steam.build_profile_status("online", "摸鱼中", "public") == {
"text": "摸鱼中",
"kind": "online",
}
assert steam.build_profile_status("offline", None, "public") == {
"text": "离线",
"kind": "offline",
}
def test_build_profile_status_private_overrides(steam):
status = steam.build_profile_status("offline", None, "private")
assert status == {"text": "资料私密,部分信息不可见", "kind": "private"}
BACKDROP_CSS = """
<style>
.profile_header_bg { background-image: url( 'https://shared.fastly.steamstatic.com/community_assets/images/items/2458530/abc.jpg' ); }
</style>
"""
BACKDROP_CSS_TIGHT = """
<style>
body { background-image: url("https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/items/123/def.png"); }
</style>
"""
BACKDROP_CSS_WITH_DECOY = """
<style>
.btn { background-image: url('https://example.com/logo.png'); }
.bg { background-image: url('https://shared.fastly.steamstatic.com/community_assets/images/items/999/real.jpg'); }
</style>
"""
BACKDROP_ANIMATED = """
<div class="profile_animated_background">
<video playsinline autoplay muted loop poster="https://shared.fastly.steamstatic.com/community_assets/images/items/1173010/aaa.jpg">
<source src="https://shared.fastly.steamstatic.com/community_assets/images/items/1173010/bbb.webm" type="video/webm">
<source src="https://shared.fastly.steamstatic.com/community_assets/images/items/1173010/ccc.mp4" type="video/mp4">
</video>
</div>
"""
AVATAR_FRAME = """
<div class="playerAvatar profile_header_size online">
<div class="playerAvatarAutoSizeInner">
<div class="profile_avatar_frame">
<picture>
<source media="(prefers-reduced-motion: reduce)" srcset="https://shared.fastly.steamstatic.com/community_assets/images/items/1263950/static.png"></source>
<source srcset="https://shared.fastly.steamstatic.com/community_assets/images/items/1263950/animated.png"></source>
<img src="https://shared.fastly.steamstatic.com/community_assets/images/items/1263950/animated.png">
</picture>
</div>
<picture>
<img srcset="https://avatars.fastly.steamstatic.com/aaa_full.jpg">
</picture>
</div>
</div>
"""
AVATAR_FRAME_IMG_ONLY = """
<div class="playerAvatar">
<div class="profile_avatar_frame">
<img src="https://shared.fastly.steamstatic.com/community_assets/images/items/1/only.png">
</div>
</div>
"""
def test_background_css_variants(steam):
assert steam.parse_profile_background_url(BACKDROP_CSS) == (
"https://shared.fastly.steamstatic.com/community_assets/images/items/2458530/abc.jpg"
)
assert steam.parse_profile_background_url(BACKDROP_CSS_TIGHT) == (
"https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/items/123/def.png"
)
def test_background_prefers_community_item(steam):
"""页面里其他 url() 不一定是背景,优先挑 /items/ 的社区物品图"""
assert steam.parse_profile_background_url(BACKDROP_CSS_WITH_DECOY) == (
"https://shared.fastly.steamstatic.com/community_assets/images/items/999/real.jpg"
)
def test_background_animated_falls_back_to_poster(steam):
"""动画背景没有 CSS 图,取 <video poster> 的静态首帧"""
assert steam.parse_profile_background_url(BACKDROP_ANIMATED) == (
"https://shared.fastly.steamstatic.com/community_assets/images/items/1173010/aaa.jpg"
)
assert steam.parse_profile_background_url("<html><body>无背景</body></html>") is None
def test_avatar_frame_prefers_static_source(steam):
assert steam.parse_avatar_frame_url(AVATAR_FRAME) == (
"https://shared.fastly.steamstatic.com/community_assets/images/items/1263950/static.png"
)
def test_avatar_frame_img_fallback(steam):
assert steam.parse_avatar_frame_url(AVATAR_FRAME_IMG_ONLY) == (
"https://shared.fastly.steamstatic.com/community_assets/images/items/1/only.png"
)
# 没有头像框(普通头像的 <picture>)不该误判
assert steam.parse_avatar_frame_url(PROFILE_HTML) is None
def test_describe_ban_priority(steam):
assert steam.describe_ban(True, "Trade Banned", True) == {
"text": "VAC 封禁",
"kind": "vac",
}
assert steam.describe_ban(False, "Trade Banned", False) == {
"text": "交易封禁",
"kind": "trade",
}
assert steam.describe_ban(False, "Probation", False) == {
"text": "交易限制",
"kind": "trade",
}
assert steam.describe_ban(False, None, True) == {
"text": "受限账号",
"kind": "limited",
}
assert steam.describe_ban(False, None, False) is None