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

+395 -10
View File
@@ -8,7 +8,9 @@ from nonebot.log import logger
from typing import List, Optional, Dict
from datetime import datetime, timezone
import asyncio
import hashlib
import json
from xml.etree import ElementTree
from hexi.core.rate_limit import acquire
from .config import Config
@@ -338,6 +340,249 @@ async def _fetch(
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(
steam_id: int, cache_path: Path, proxy: str = None
) -> PlayerData:
@@ -355,15 +600,30 @@ async def get_user_data(
"description": "No information given.",
"background": default_background,
"avatar": default_avatar,
"avatar_frame": None,
"player_name": "Unknown",
"recent_2_week_play_time": None,
"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()
utc_offset_minutes = int(local_time.utcoffset().total_seconds() / 60)
timezone_cookie_value = f"{utc_offset_minutes},0"
# XML 与主页并行抓取,省一个往返;失败只记日志,字段留空
xml_task = asyncio.create_task(fetch_profile_xml(steam_id, proxy))
try:
async with httpx.AsyncClient(
proxy=proxy,
@@ -384,8 +644,14 @@ async def get_user_data(
response.raise_for_status()
except httpx.RequestError as exc:
logger.error(f"Failed to get user data: {exc}")
await xml_task # 主页失败也要回收任务,避免悬挂
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 = re.search(r"<title>Steam 社区 :: (.*?)</title>", html)
if player_name:
@@ -407,12 +673,15 @@ async def get_user_data(
# remove xml
result["description"] = re.sub(r"<.*?>", "", result["description"])
# background
background_url = re.search(r"background-image: url\( \'(.*?)\' \)", html)
# background:普通背景走 CSS,动画背景用 <video> 的静态首帧;按 URL 缓存
background_url = parse_profile_background_url(html)
if background_url:
background_url = background_url.group(1)
suffix = Path(background_url.split("?")[0]).suffix or ".jpg"
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
@@ -427,6 +696,16 @@ async def get_user_data(
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
# \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(
@@ -437,6 +716,14 @@ async def get_user_data(
play_time_text = play_time_text.group(1)
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 — 先收集信息,再并行下载图片
soup = BeautifulSoup(html, "html.parser")
recent_games = soup.find_all("div", class_="recent_game")
@@ -447,15 +734,22 @@ async def get_user_data(
game_info = {}
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_split = game_info["game_image_url"].split("/")
# 封面 URL 形如 .../steam/apps/{appid}/header.jpg,提取 appid 用于本地化
appid_str = game_info_split[-2]
game_info["appid"] = int(appid_str) if appid_str.isdigit() else None
# 封面 URL 有两种形态(新版多一段 hash):
# .../steam/apps/{appid}/header.jpg
# .../store_item_assets/steam/apps/{appid}/{hash}/capsule_xxx.jpg?t=...
# 取 /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((
game_info, "game_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()
@@ -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]:
"""按 appid 批量查询 clienticon hash,返回 {appid: hash}"""
if isinstance(db_path, str):
@@ -1008,11 +1327,77 @@ async def get_play_time(steam_id: str) -> list[dict]:
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__":
from nonebot.log import logger
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:
f.write(data["background"])