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

+357 -95
View File
@@ -2,12 +2,12 @@ import numpy as np
from functools import lru_cache
from io import BytesIO
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 PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageEnhance
from .utils import hex_to_rgb
from .models import DrawPlayerStatusData, Achievements
from .models import DrawPlayerStatusData, Achievements, ProfileCardExtra
WIDTH = 400
@@ -824,6 +824,7 @@ def draw_game_info(
completed_achievement_number: int,
total_achievement_number: int,
achievement_color: Tuple[int, int, int],
two_week_time: Optional[str] = None,
) -> Image.Image:
bg = Image.new("RGBA", (880, 110 + 64 + 10), (0, 0, 0, 110))
header = header.resize((229, 86), Image.BICUBIC)
@@ -831,11 +832,27 @@ def draw_game_info(
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(
(260, 10),
game_name,
font=get_font(font_regular_path, 26),
font=name_font,
fill=(255, 255, 255),
)
@@ -880,7 +897,7 @@ def draw_game_info(
(int(x), 20),
f"{completed_achievement_number} / {total_achievement_number}",
font=font,
fill=(130, 130, 130),
fill=(185, 185, 185),
)
x += (
font.getlength(f"{completed_achievement_number} / {total_achievement_number}")
@@ -913,168 +930,413 @@ def draw_game_info(
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(
player_bg: Image.Image,
player_avatar: Image.Image,
player_name: str,
player_id: 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],
extra: Optional[ProfileCardExtra] = None,
avatar_frame: Optional[bytes] = None,
):
"""信息卡:通栏头部 + 左栏「游戏动态」+ 右栏「当前在线/计数」(参考 Steam 官方资料页)"""
if isinstance(player_bg, bytes):
player_bg = Image.open(BytesIO(player_bg))
if isinstance(player_avatar, bytes):
player_avatar = Image.open(BytesIO(player_avatar))
extra = extra or {}
bg = recolor_image(
player_bg.crop(
(
(player_bg.width - 960) // 2,
(player_bg.width - CARD_WIDTH) // 2,
0,
(player_bg.width + 960) // 2,
(player_bg.width + CARD_WIDTH) // 2,
player_bg.height,
)
),
10,
10,
)
# 调暗背景
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))
bg = ImageEnhance.Brightness(bg).enhance(0.7)
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(
(280, 48),
player_name,
font=get_font(font_light_path, 40),
fill=(255, 255, 255),
(HEADER_TEXT_X, name_y), player_name, font=name_font, fill=(255, 255, 255)
)
# 画ID
draw.text(
(280, 100),
f"好友代码: {player_id}",
font=get_font(font_regular_path, 19),
fill=(191, 191, 191),
)
# 等级圆牌 / 风控标记紧随昵称
cursor = HEADER_TEXT_X + int(draw.textlength(player_name, font=name_font)) + 12
if extra.get("level") is not None:
cursor = _draw_level_badge(draw, cursor, name_y + 21, extra["level"]) + 10
if extra.get("ban_text"):
_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
offset = 0
line = ""
for idx, char in enumerate(player_description):
line += char
line_width += get_font(font_light_path, 22).getlength(char)
if line_width > 640 or idx == len(player_description) - 1 or char == "\n":
line_width += desc_font.getlength(char)
if (
line_width > max_desc_width
or idx == len(player_description) - 1
or char == "\n"
):
draw.text(
(280, 132 + offset),
(HEADER_TEXT_X, desc_top + offset),
line,
font=get_font(font_light_path, 22),
fill=(255, 255, 255),
font=desc_font,
fill=(235, 235, 235),
)
line = ""
offset += 25
line_width = 0
if offset >= 25 * 4:
offset += 27
if offset >= 27 * 3:
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 = tuple(map(lambda x: x - 30 if x >= 30 else 0, brightest_color))
darkest_color = tuple(
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)
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])
# 成就条底色:取背景亮色的深色变体(压暗一些,保证白色文字和数字读得清)
hsv_achievement_color = rgb_to_hsv(*brightest_color)
achievement_color = tuple(
map(
int,
hsv_to_rgb(
hsv_achievement_color[0],
hsv_achievement_color[1] * 0.85,
hsv_achievement_color[2] * 0.6,
hsv_achievement_color[1] * 0.8,
hsv_achievement_color[2] * 0.35,
),
)
)
game_images: List[Image.Image] = []
for idx, game in enumerate(player_games):
game_image = Image.open(BytesIO(game["game_header"]))
game_info = draw_game_info(
game_image,
game["game_name"],
game["game_time"],
game["last_play_time"],
game["achievements"],
game["completed_achievement_number"],
game["total_achievement_number"],
achievement_color,
cards: List[Image.Image] = []
for game in player_games:
cards.append(
draw_game_info(
Image.open(BytesIO(game["game_header"])),
game["game_name"],
game["game_time"],
game["last_play_time"],
game["achievements"],
game["completed_achievement_number"],
game["total_achievement_number"],
achievement_color,
two_week_time=game.get("two_week_time"),
)
)
game_images.append(game_info)
# 画半透明黑色背景
bg_game = Image.new(
"RGBA", (920, 106 + sum([game_image.height + 26 for game_image in game_images]))
# 背景太矮时先向下补一段(拉伸底部横条),避免内容整体被裁
min_height = body_top + 58 + 110 + CARD_MARGIN
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)
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)
# 画渐变条的文字:最新动态,最近游戏
bg.paste(gradient, (CARD_MARGIN, body_top), gradient)
draw.text(
(34, 279),
"最新动态",
(CARD_MARGIN + 14, body_top + 7),
"游戏动态",
font=get_font(font_light_path, 26),
fill=(255, 255, 255),
)
if player_last_two_weeks_time is not None:
width = get_font(font_light_path, 26).getlength(
player_last_two_weeks_time
)
if player_last_two_weeks_time:
total_font = get_font(font_light_path, 22)
total_width = total_font.getlength(player_last_two_weeks_time)
draw.text(
(960 - width - 34, 279),
(CARD_MARGIN + LEFT_W - total_width - 14, body_top + 9),
player_last_two_weeks_time,
font=get_font(font_light_path, 26),
font=total_font,
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):
bg.paste(
game_image,
((920 - game_image.width) // 2 + 20, y),
game_image.convert("RGBA"),
# ---------- 右栏:当前在线 + 计数 ----------
status_title = status_titles.get(
extra.get("status_kind") or "online", "当前在线"
)
draw.text(
(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