Files
HeXi/tests/test_steam_profile_parse.py
T
sansenhoshiandClaude Code 6af60a7d55 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>
2026-10-13 17:53:22 +08:00

391 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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