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:
1 parent
0ac1958932
commit
6af60a7d55
6 files changed
+1298
-125
No files matched your search
@@ -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()
|
||||
# 空值多为 占位(如未公开的库存),没有数字就不展示
|
||||
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"])
|
||||
|
||||
Reference in new issue
Block a user