Files
HeXi/hexi/plugins/nonebot_plugin_steam_info/steam.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

1405 lines
53 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.
import re
import sqlite3
from contextlib import closing
import httpx
from pathlib import Path
from bs4 import BeautifulSoup
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
from .models import PlayerSummaries, PlayerData
STEAM_ID_OFFSET = 76561197960265728
# Steam Web API 总限频:单 key 极限约 200 req/5min(40 req/min),
# 双 key 合计 400 req/5min;且相邻两次请求的间隔不能太短(限流按短窗口计)。
# 保守取 1 req/2s(两 key 合计 150 req/5min,留 ~60% 余量),
# capacity=1 保证任意两次请求间隔 >= 2s,杜绝短窗口突发。
# 所有端点(GetPlayerSummaries/GetOwnedGames/GetAppList)共享同一配额。
STEAM_API_RATE = 1 / 2
STEAM_API_CAPACITY = 1
async def acquire_steam_quota(wait: bool = True, timeout: float = 60) -> bool:
"""获取一次 Steam API 调用配额;wait=False 时超限立即返回 False"""
return await acquire(
"steam_api",
rate=STEAM_API_RATE,
capacity=STEAM_API_CAPACITY,
wait=wait,
timeout=timeout,
)
class ApiKeyManager:
"""API Key 状态管理器(SQLite 持久化,替换旧 JSON 状态文件)"""
def __init__(self, api_keys: List[str], db_path: Path):
self.api_keys = api_keys
self._db_path = Path(db_path)
self._lock = asyncio.Lock()
self._init_state()
def _connect(self) -> sqlite3.Connection:
self._db_path.parent.mkdir(parents=True, exist_ok=True)
# isolation_level=None:自动提交模式,确保写入持久化
return sqlite3.connect(str(self._db_path), isolation_level=None)
def _init_state(self):
"""建表并同步 key 列表(旧 JSON 迁移由 init_api_key_manager 触发)"""
with closing(self._connect()) as conn:
conn.execute(
"CREATE TABLE IF NOT EXISTS api_key_state ("
"key TEXT PRIMARY KEY,"
"call_count INTEGER NOT NULL DEFAULT 0,"
"last_called TEXT,"
"created_at TEXT)"
)
self._sync_keys()
def migrate_legacy_json(self, legacy_paths: List[Optional[Path]]) -> None:
"""迁移旧 JSON 状态文件到 SQLite,成功后改名 .bak(只迁移第一个存在的)"""
for json_path in legacy_paths:
if json_path is None or not Path(json_path).is_file():
continue
try:
data = json.loads(Path(json_path).read_text("utf-8"))
if not isinstance(data, dict):
continue
with closing(self._connect()) as conn:
for key, entry in data.items():
conn.execute(
"INSERT OR REPLACE INTO api_key_state "
"(key, call_count, last_called, created_at) "
"VALUES (?, ?, ?, ?)",
(str(key),
int(entry.get("call_count", 0)),
entry.get("last_called"),
entry.get("created_at")),
)
Path(json_path).rename(Path(json_path).with_suffix(".json.bak"))
logger.info(f"已迁移 API key 状态 {json_path} -> SQLite")
return
except Exception as e:
logger.error(f"迁移 API key 状态 {json_path} 失败: {e}")
def _sync_keys(self):
"""与配置同步:新增未记录的 key,移除已删除的 key"""
with closing(self._connect()) as conn:
existing = {r[0] for r in conn.execute("SELECT key FROM api_key_state")}
for key in self.api_keys:
if key not in existing:
conn.execute(
"INSERT INTO api_key_state (key, call_count, created_at) "
"VALUES (?, 0, ?)",
(key, datetime.now().isoformat()),
)
logger.info(f"发现新的 API key,已添加: {key[:10]}...")
for key in existing - set(self.api_keys):
conn.execute("DELETE FROM api_key_state WHERE key = ?", (key,))
logger.info(f"已移除不存在的 API key: {key[:10]}...")
async def get_next_key(self) -> str:
"""获取下一个 API key,优先未使用过的,然后选择最久未使用的"""
async with self._lock:
with closing(self._connect()) as conn:
state = {
k: lc
for k, lc in conn.execute(
"SELECT key, last_called FROM api_key_state"
)
}
unused = [k for k in self.api_keys if state.get(k) is None]
if unused:
selected_key = unused[0]
logger.debug(f"📍 选择未使用过的 key: {selected_key[:10]}...")
else:
selected_key = min(
self.api_keys,
key=lambda k: state.get(k) or "1970-01-01",
)
logger.debug(f"📍 选择最久未使用的 key: {selected_key[:10]}...")
conn.execute(
"UPDATE api_key_state SET call_count = call_count + 1, "
"last_called = ? WHERE key = ?",
(datetime.now().isoformat(), selected_key),
)
return selected_key
async def get_stats(self) -> Dict:
"""获取使用统计"""
async with self._lock:
with closing(self._connect()) as conn:
rows = conn.execute(
"SELECT key, call_count, last_called FROM api_key_state"
).fetchall()
return {
f"{k[:10]}...": {"call_count": c, "last_called": lc}
for k, c, lc in rows
}
async def reset_stats(self):
"""重置所有统计"""
async with self._lock:
with closing(self._connect()) as conn:
conn.execute("DELETE FROM api_key_state")
for key in self.api_keys:
conn.execute(
"INSERT INTO api_key_state (key, call_count, created_at) "
"VALUES (?, 0, ?)",
(key, datetime.now().isoformat()),
)
logger.info("✓ 已重置所有 API key 统计")
# 全局 manager 实例
_api_key_manager: Optional[ApiKeyManager] = None
def init_api_key_manager(config: Config, db_path: Path) -> ApiKeyManager:
"""从配置初始化 API key 管理器(状态持久化到 SQLite)"""
logger.info(f"开始初始化 apikey: {config.steam_api_key}, 数据库: {db_path}")
global _api_key_manager
_api_key_manager = ApiKeyManager(api_keys=config.steam_api_key, db_path=db_path)
# 迁移旧 JSON 状态(配置路径 + 插件 data 目录常见位置),成功后自动改名 .bak
legacy_paths = [
Path(config.steam_api_key_state_path)
if config.steam_api_key_state_path
else None,
Path(__file__).parent / "data" / "steam_config.json",
Path(__file__).parent / "data" / "api_keys_state.json",
]
_api_key_manager.migrate_legacy_json(legacy_paths)
return _api_key_manager
def get_api_key_manager() -> ApiKeyManager:
"""获取全局 API key 管理器"""
if _api_key_manager is None:
raise RuntimeError("API key manager 未初始化,请先调用 init_api_key_manager()")
return _api_key_manager
def get_steam_id(steam_id_or_steam_friends_code: str) -> str:
if not steam_id_or_steam_friends_code.isdigit():
return None
id_ = int(steam_id_or_steam_friends_code)
if id_ < STEAM_ID_OFFSET:
return str(id_ + STEAM_ID_OFFSET)
return steam_id_or_steam_friends_code
def format_api_key_stats(stats: Dict) -> str:
"""格式化 API key 使用统计为可读日志文本
stats 结构: {key: {"call_count": int, "last_called": str | None}}
"""
lines = ["\nSteam API key 使用统计:"]
for key, info in stats.items():
call_count = info.get("call_count", 0)
last_called = info.get("last_called")
if last_called:
last_called = str(last_called).replace("T", " ")[:19]
else:
last_called = "从未调用"
lines.append(f" {key}: 调用 {call_count} 次 | 最近调用: {last_called}")
return "\n".join(lines)
async def get_steam_users_info(
steam_ids: List[str],
steam_api_key: List[str], # 保留用于兼容,但实际会使用 manager
proxy: str = None,
wait: bool = True, # 无配额时是否等待;播报等得起,用户命令快速失败
) -> PlayerSummaries:
"""获取 Steam 用户信息;无 API 配额时返回 None(调用方自行提示)"""
if not steam_ids:
return {"response": {"players": []}}
if not await acquire_steam_quota(wait=wait):
logger.warning("Steam API 配额已满,本次查询被跳过")
return None
manager = get_api_key_manager() # 从全局获取
if len(steam_ids) > 100:
result: dict = {"response": {"players": []}}
tasks = []
for i in range(0, len(steam_ids), 100):
if i > 0:
# 每批实际发一次请求:入口配额只买了第一批,后续批逐批购买
if not await acquire_steam_quota(wait=wait):
logger.warning("Steam API 配额已满,剩余批次被跳过")
break
batch_ids = steam_ids[i:i + 100]
batch_key = await manager.get_next_key()
tasks.append(
_fetch_steam_batch(batch_ids, batch_key, proxy)
)
batch_results = await asyncio.gather(*tasks)
for batch_players in batch_results:
result["response"]["players"].extend(batch_players)
stats = await manager.get_stats()
logger.info(format_api_key_stats(stats))
return result
api_key = await manager.get_next_key()
players = await _fetch_steam_batch(steam_ids, api_key, proxy)
stats = await manager.get_stats()
logger.info(format_api_key_stats(stats))
return {"response": {"players": players}}
async def _fetch_steam_batch(
steam_ids: List[str], api_key: str, proxy: str = None
) -> List[dict]:
"""获取单批 Steam 用户信息(≤100 个 ID),双端点重试"""
api_endpoints = [
'https://community.steam-api.com/ISteamUser/GetPlayerSummaries/v0002/',
'https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/',
]
steamids_param = ",".join(steam_ids)
timeout = httpx.Timeout(30.0)
async with httpx.AsyncClient(proxy=proxy, timeout=timeout) as client:
for endpoint in api_endpoints:
try:
response = await client.get(
endpoint,
params={"key": api_key, "steamids": steamids_param},
)
logger.info(
f"✓ Steam API 响应 {response.status_code}, "
f"key: {api_key[:10]}..."
)
if response.status_code == 200:
data = response.json()
return data.get("response", {}).get("players", [])
else:
# Steam 错误响应体含明确原因(Access is denied /
# Rate limit exceeded 等),不打出来无法区分 key 问题与限流
reason = (response.text or "").strip().replace("\n", " ")[:200]
logger.warning(
f"✗ key {api_key[:10]}... 端点 {endpoint} "
f"返回 {response.status_code} | {reason}"
)
except httpx.RequestError as exc:
logger.warning(
f"✗ 请求错误 - key: {api_key[:10]}..., "
f"端点: {endpoint}, 错误: {exc}"
)
except Exception as exc:
logger.error(
f"✗ 未预期错误 - key: {api_key[:10]}..., "
f"端点: {endpoint}, 错误: {exc}"
)
logger.error("✗ 所有端点均失败")
return []
async def _fetch(
url: str, default: bytes, cache_file: Optional[Path] = None, proxy: str = None
) -> bytes:
if cache_file is not None:
try:
if cache_file.exists():
return await asyncio.to_thread(cache_file.read_bytes)
except Exception:
pass
try:
async with httpx.AsyncClient(proxy=proxy) as client:
response = await client.get(url)
if response.status_code == 200:
content = response.content
if cache_file is not None:
try:
await asyncio.to_thread(cache_file.write_bytes, content)
except Exception:
pass
return content
else:
response.raise_for_status()
except Exception as exc:
logger.error(f"Failed to get image: {exc}")
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:
url = f"https://steamcommunity.com/profiles/{steam_id}"
default_background = (Path(__file__).parent / "res/bg_dots.png").read_bytes()
default_avatar = (Path(__file__).parent / "res/unknown_avatar.jpg").read_bytes()
default_achievement_image = (
Path(__file__).parent / "res/default_achievement_image.png"
).read_bytes()
default_header_image = (
Path(__file__).parent / "res/default_header_image.jpg"
).read_bytes()
result = {
"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,
headers={
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6"
},
cookies={"timezoneOffset": timezone_cookie_value},
) as client:
response = await client.get(url)
if response.status_code == 200:
html = response.text
elif response.status_code == 302:
url = response.headers["Location"]
response = await client.get(url)
if response.status_code == 200:
html = response.text
else:
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:
result["player_name"] = player_name.group(1)
# description t<div class="profile_summary">\r\n\t\t\t\t\t\t\t\t風が雨が激しくても<br>思いだすんだ 僕らを照らす光があるよ<br>今日もいっぱい<br>明日もいっぱい 力を出しきってみるよ\t\t\t\t\t\t\t</div>
description = re.search(
r'<div class="profile_summary">(.*?)</div>', html, re.DOTALL | re.MULTILINE
)
if description:
description = description.group(1)
description = re.sub(r"<br>", "\n", description)
description = re.sub(r"\t", "", description)
result["description"] = description.strip()
# remove emoji
result["description"] = re.sub(r"ː.*?ː", "", result["description"])
# remove xml
result["description"] = re.sub(r"<.*?>", "", result["description"])
# background:普通背景走 CSS,动画背景用 <video> 的静态首帧;按 URL 缓存
background_url = parse_profile_background_url(html)
if background_url:
suffix = Path(background_url.split("?")[0]).suffix or ".jpg"
result["background"] = await _fetch(
background_url,
default_background,
cache_file=cache_path / f"bg_{_url_digest(background_url)}{suffix}",
proxy=proxy,
)
# avatar
# \t<link rel="image_src" href="https://avatars.akamai.steamstatic.com/3ade30f61c3d2cc0b8c80aaf567b573cd022c405_full.jpg">
avatar_url = re.search(r'<link rel="image_src" href="(.*?)"', html)
if avatar_url:
avatar_url = avatar_url.group(1)
# https://avatars.akamai.steamstatic.com/3ade30f61c3d2cc0b8c80aaf567b573cd022c405_full.jpg
avatar_url_split = avatar_url.split("/")
avatar_file = cache_path / f"avatar_{avatar_url_split[-1].split('_')[0]}.jpg"
result["avatar"] = await _fetch(
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(
r'<div class="recentgame_quicklinks recentgame_recentplaytime">\s*<div>(.*?)</div>',
html,
)
if play_time_text:
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")
game_data = []
download_tasks = [] # (target_dict, key, url, default, cache_file, proxy)
for game in recent_games:
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"]
# 封面 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 / header_name, proxy,
))
play_time_text = game.find("div", class_="game_info_details").text.strip()
play_time = re.search(r"总时数\s*(.*?)\s*小时", play_time_text)
game_info["play_time"] = play_time.group(1) if play_time else ""
last_played = re.search(r"最后运行日期:(.*) 日", play_time_text)
game_info["last_played"] = (
"最后运行日期:" + last_played.group(1) + " 日"
if last_played else "当前正在游戏"
)
achievements = []
for achievement in game.find_all("div", class_="game_info_achievement"):
if "plus_more" in achievement.get("class", []):
continue
ach_info = {
"name": achievement["data-tooltip-text"],
"image_url": achievement.find("img")["src"],
}
ach_split = ach_info["image_url"].split("/")
download_tasks.append((
ach_info, "image",
ach_info["image_url"], default_achievement_image,
cache_path / f"achievement_{ach_split[-2]}_{ach_split[-1]}", proxy,
))
achievements.append(ach_info)
game_info["achievements"] = achievements
summary = game.find("span", class_="game_info_achievement_summary")
if summary is not None:
remain_text = summary.find("span", class_="ellipsis").text
parts = remain_text.split("/")
game_info["completed_achievement_number"] = int(parts[0].strip())
game_info["total_achievement_number"] = int(parts[1].strip())
game_data.append(game_info)
# 并行下载所有图片
if download_tasks:
fetched = await asyncio.gather(*(
_fetch(url, default, cache_file=cf, proxy=p)
for _, _, url, default, cf, p in download_tasks
))
for (target, key, _, _, _, _), img_data in zip(download_tasks, fetched):
target[key] = img_data
result["game_data"] = game_data
return result
# 游戏中文名进程内缓存
GAME_NAMES_CACHE: dict[int, str] = {}
def _get_game_db_conn(db_path: Path) -> sqlite3.Connection:
"""打开游戏名映射表数据库(自动建表)"""
db_path.parent.mkdir(parents=True, exist_ok=True)
# isolation_level=None:自动提交模式,配合 closing 确保写入持久化
conn = sqlite3.connect(str(db_path), isolation_level=None)
conn.execute(
"CREATE TABLE IF NOT EXISTS game_names ("
"appid INTEGER PRIMARY KEY,"
"name_en TEXT NOT NULL DEFAULT '',"
"name_ch TEXT NOT NULL DEFAULT '',"
"clienticon TEXT NOT NULL DEFAULT '',"
"last_modified INTEGER NOT NULL DEFAULT 0)"
)
# 旧库迁移:补充 clienticon 列
cols = [row[1] for row in conn.execute("PRAGMA table_info(game_names)")]
if "clienticon" not in cols:
conn.execute("ALTER TABLE game_names ADD COLUMN clienticon TEXT NOT NULL DEFAULT ''")
conn.execute(
"CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)"
)
return conn
def _mark_full_sync(db_path: Path) -> None:
"""标记首次全量同步完成"""
if isinstance(db_path, str):
db_path = Path(db_path)
with closing(_get_game_db_conn(db_path)) as conn:
conn.execute(
"INSERT INTO meta (key, value) VALUES ('full_sync_done', '1') "
"ON CONFLICT(key) DO UPDATE SET value = '1'"
)
def _is_full_sync_done(db_path: Optional[Path]) -> bool:
"""首次全量同步是否已完成"""
if isinstance(db_path, str):
db_path = Path(db_path)
if db_path is None or not db_path.is_file():
return False
try:
with closing(sqlite3.connect(str(db_path))) as conn:
row = conn.execute(
"SELECT value FROM meta WHERE key = 'full_sync_done'"
).fetchone()
return row is not None and row[0] == "1"
except sqlite3.Error:
return False
def _query_game_names(db_path: Optional[Path], appids: list[int]) -> dict[str, dict]:
"""按 appid 批量查询映射表,返回 {appid: {"name_en": str, "name_ch": str}}"""
if isinstance(db_path, str):
db_path = Path(db_path)
if db_path is None or not appids:
return {}
placeholders = ",".join("?" * len(appids))
with closing(_get_game_db_conn(db_path)) as conn: # 确保表存在
rows = conn.execute(
f"SELECT appid, name_en, name_ch FROM game_names "
f"WHERE appid IN ({placeholders})",
list(appids),
).fetchall()
return {
str(appid): {"name_en": en or "", "name_ch": ch or ""}
for appid, en, ch in rows
}
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):
db_path = Path(db_path)
if db_path is None or not appids:
return {}
placeholders = ",".join("?" * len(appids))
with closing(_get_game_db_conn(db_path)) as conn: # 确保表存在
rows = conn.execute(
f"SELECT appid, clienticon FROM game_names "
f"WHERE appid IN ({placeholders}) AND clienticon != ''",
list(appids),
).fetchall()
return {appid: icon for appid, icon in rows}
def _upsert_game_icon_hashes(db_path: Path, entries: dict[int, str]) -> None:
"""批量 UPSERT clienticon hash:仅当新值非空时更新,保留已有数据"""
if isinstance(db_path, str):
db_path = Path(db_path)
if not entries:
return
items = [(int(aid), icon) for aid, icon in entries.items() if icon]
if not items:
return
sql = (
"INSERT INTO game_names (appid, clienticon, last_modified) "
"VALUES (?, ?, 0) "
"ON CONFLICT(appid) DO UPDATE SET "
"clienticon = CASE WHEN excluded.clienticon != '' "
"THEN excluded.clienticon ELSE game_names.clienticon END"
)
db_path.parent.mkdir(parents=True, exist_ok=True)
with closing(_get_game_db_conn(db_path)) as conn:
conn.executemany(sql, items)
conn.commit()
async def fetch_clienticon_hash_from_store(appid: int) -> str | None:
"""从 Steam 商店页的 apphub_AppIcon 提取 clienticon hash
商店页 img src 形如:
https://shared.akamai.steamstatic.com/community_assets/images/apps/{appid}/{hash}.jpg
该方式不消耗 API 配额,适合作为 DB 缺失时的稳定兜底。
"""
url = f"https://store.steampowered.com/app/{appid}/"
try:
async with httpx.AsyncClient(
timeout=20,
follow_redirects=True,
headers={"Accept-Language": "zh-CN,zh;q=0.9"},
) as client:
resp = await client.get(url)
if resp.status_code != 200:
return None
match = re.search(
rf"community_assets/images/apps/{appid}/([0-9a-f]{{40}})\.jpg",
resp.text,
)
return match.group(1) if match else None
except Exception:
return None
def _upsert_game_names(db_path: Path, entries: dict[str, dict]) -> None:
"""批量 UPSERT 映射表:仅当新值非空时更新对应字段(保留已有数据)
事务分批 executemany 写入并输出进度,避免逐条自动提交导致全量入库"卡住"。
"""
if isinstance(db_path, str):
db_path = Path(db_path)
if not entries:
return
items = [
(
int(aid),
entry.get("name_en", ""),
entry.get("name_ch", ""),
int(entry.get("last_modified", 0)),
)
for aid, entry in entries.items()
]
sql = (
"INSERT INTO game_names (appid, name_en, name_ch, last_modified) "
"VALUES (?, ?, ?, ?) "
"ON CONFLICT(appid) DO UPDATE SET "
"name_en = CASE WHEN excluded.name_en != '' "
"THEN excluded.name_en ELSE game_names.name_en END, "
"name_ch = CASE WHEN excluded.name_ch != '' "
"THEN excluded.name_ch ELSE game_names.name_ch END, "
"last_modified = CASE WHEN excluded.last_modified > 0 "
"THEN excluded.last_modified ELSE game_names.last_modified END"
)
db_path.parent.mkdir(parents=True, exist_ok=True)
with closing(sqlite3.connect(str(db_path))) as conn:
conn.execute(
"CREATE TABLE IF NOT EXISTS game_names ("
"appid INTEGER PRIMARY KEY,"
"name_en TEXT NOT NULL DEFAULT '',"
"name_ch TEXT NOT NULL DEFAULT '',"
"last_modified INTEGER NOT NULL DEFAULT 0)"
)
batch_size = 20000
for i in range(0, len(items), batch_size):
conn.executemany(sql, items[i : i + batch_size])
conn.commit()
logger.info(
f"游戏名映射表入库进度: {min(i + batch_size, len(items))}/{len(items)}"
)
def migrate_game_names_json(json_path: Path, db_path: Path) -> bool:
"""一次性迁移旧游戏名映射(JSON 或旧 game_names.db)到统一 SQLite,成功返回 True"""
if isinstance(json_path, str):
json_path = Path(json_path)
if isinstance(db_path, str):
db_path = Path(db_path)
entries: dict[str, dict] = {}
# 旧 JSON 映射表
if json_path.is_file():
try:
raw = json.loads(json_path.read_text(encoding="utf-8"))
for aid, value in raw.items():
if isinstance(value, str):
entries[aid] = {"name_en": "", "name_ch": value}
else:
entries[aid] = {
"name_en": value.get("name_en", "") or "",
"name_ch": value.get("name_ch", "") or "",
}
json_path.rename(json_path.with_suffix(".json.bak"))
except (json.JSONDecodeError, OSError):
pass
# 旧独立 game_names.db(含 GetAppList 全量英文名)
old_db = db_path.parent / "game_names.db"
if old_db.is_file():
try:
with closing(sqlite3.connect(str(old_db))) as conn:
rows = conn.execute(
"SELECT appid, name_en, name_ch, last_modified FROM game_names"
).fetchall()
for appid, en, ch, lm in rows:
entries.setdefault(str(appid), {"name_en": "", "name_ch": ""})
if en:
entries[str(appid)]["name_en"] = en
if ch:
entries[str(appid)]["name_ch"] = ch
if lm:
entries[str(appid)]["last_modified"] = lm
old_db.rename(old_db.with_suffix(".db.bak"))
except sqlite3.Error:
pass
if entries:
_upsert_game_names(db_path, entries)
return True
return False
async def update_steamdb_apps(steamdb_path: Path, api_key: str) -> int:
"""全量同步 Steam Store app 列表到映射表
分页拉取完整 app 列表(不做增量),保证表里始终有全部游戏。
返回本次同步的条数。
"""
if isinstance(steamdb_path, str):
steamdb_path = Path(steamdb_path)
logger.info("Steam App 列表全量同步")
app_map: dict[str, tuple[str, int]] = {}
last_appid = 0
async with httpx.AsyncClient(timeout=30) as client:
while True:
# GetAppList 分页每页都消耗配额,逐页限频(凌晨任务等得起,最多 5 分钟)
if not await acquire_steam_quota(wait=True, timeout=300):
logger.warning("Steam API 配额已满,App 列表同步终止")
break
params = {
"key": api_key,
"max_results": 50000,
}
if last_appid:
params["last_appid"] = last_appid
try:
resp = await client.get(
"https://api.steampowered.com/IStoreService/GetAppList/v1/",
params=params,
)
resp.raise_for_status()
data = resp.json().get("response", {})
except Exception as e:
logger.warning(f"拉取 Steam App 列表失败: {e}")
break
apps = data.get("apps") or []
for a in apps:
app_map[str(a["appid"])] = (a["name"], a.get("last_modified", 0))
logger.info(f"app 列表拉取进度: {len(app_map)} 条")
if not data.get("have_more_results") or not apps:
break
last_appid = data.get("last_appid") or apps[-1]["appid"]
if app_map:
_upsert_game_names(
steamdb_path,
{
aid: {"name_en": name, "name_ch": "", "last_modified": lm}
for aid, (name, lm) in app_map.items()
},
)
# 全量同步完成,写入标记(幂等)
_mark_full_sync(steamdb_path)
return len(app_map)
async def get_game_cn_names(
appids: list[int],
cache_path: Optional[Path] = None,
fetch_missing: bool = True, # 中文缺失时是否联网补全;高频调用(如播报)传 False
) -> dict[int, str]:
"""获取游戏显示名:映射表中文优先、英文兜底;中文缺失时按需查 Steam API 补全
映射表结构 {appid: {"name_en": str, "name_ch": str}},
查询到的中文名写回映射表持久化,重复查询零请求。
fetch_missing=False 时只查本地映射表,缺失中文名保持英文/原名,
联网补全留给低频的用户命令路径,避免高频请求被封禁 IP。
"""
if isinstance(cache_path, str):
cache_path = Path(cache_path)
remaining = [a for a in appids if a not in GAME_NAMES_CACHE]
game_map = _query_game_names(cache_path, remaining) if remaining else {}
result: dict[int, str] = {
a: GAME_NAMES_CACHE[a] for a in appids if a in GAME_NAMES_CACHE
}
to_fetch: list[int] = []
updates: dict[str, dict] = {}
for aid in remaining:
entry = game_map.get(str(aid))
if entry and entry["name_ch"]:
result[aid] = entry["name_ch"]
GAME_NAMES_CACHE[aid] = entry["name_ch"]
else:
# 中文缺失:先用英文兜底显示,同时加入待查列表补中文。
# 表里有没有这个游戏都照常查:有中文名则写入,没有则中文留空,
# 游戏本身由全量同步保证在表内。
if entry and entry["name_en"]:
result[aid] = entry["name_en"]
to_fetch.append(aid)
if to_fetch and fetch_missing:
# appdetails 仅支持单 appid 查询,频率过高会被 Steam 临时封禁 IP:
# 单次最多补 30 个、串行请求、每请求间隔 1.5s,其余下次查询再补
to_fetch = to_fetch[:30]
semaphore = asyncio.Semaphore(1)
async def _fetch_one(aid: int):
async with semaphore:
await asyncio.sleep(1.5) # 限速,防止 IP 被封禁
for attempt in range(3):
try:
resp = await client.get(
"https://store.steampowered.com/api/appdetails",
params={
"appids": str(aid),
"l": "schinese",
"filters": "basic",
},
)
if resp.status_code == 429:
# 限频,指数退避后重试
await asyncio.sleep(1 + attempt * 2)
continue
data = resp.json()
break
except Exception as e:
logger.warning(f"获取游戏 {aid} 中文名失败: {e}")
return None
else:
logger.warning(f"游戏 {aid} 中文名获取被限频,跳过")
return None
entry = (data or {}).get(str(aid)) or {}
if entry.get("success") and entry.get("data", {}).get("name"):
return aid, entry["data"]["name"]
return None
async with httpx.AsyncClient(timeout=20) as client:
results = await asyncio.gather(*(_fetch_one(a) for a in to_fetch))
for r in results:
if r is None:
continue
aid, name = r
GAME_NAMES_CACHE[aid] = name
result[aid] = name
updates[str(aid)] = {"name_ch": name}
if updates and cache_path:
try:
_upsert_game_names(cache_path, updates)
except sqlite3.Error as e:
logger.warning(f"游戏名映射表写入失败: {e}")
return result
_CJK_RE = re.compile(r"[一-鿿]")
def localize_game_name(name: str, appid: Optional[int], names: dict[int, str]) -> str:
"""游戏名本地化:原名已是中文则保留原名;否则中文映射优先,缺失回退原名"""
if name and _CJK_RE.search(name):
# 原名就是中文(如国产游戏),不做替换
return name
if appid is not None and appid in names:
return names[appid]
return name
# 非游戏软件/工具 appid(如 SteamVR、Redistributables),混入时长表会污染排名。
# GetOwnedGames 不返回 app_type 字段,只能靠已知 appid + 名称关键字过滤。
TOOL_APPIDS = {
215, # Source SDK
218, # Source SDK Base 2007
220, # Source SDK Base 2013
2450, # Source SDK Base 2013 Multiplayer
250820, # SteamVR
323370, # SteamVR Performance Test
593110, # SteamVR Home
228980, # Steamworks Common Redistributables
}
TOOL_NAME_KEYWORDS = (
"sdk", "redistribut", "workshop tool", "steamvr",
"dedicated server", "vulkan",
)
def _is_software(game: dict) -> bool:
"""判断是否为非游戏软件/工具"""
if game.get("appid") in TOOL_APPIDS:
return True
name = (game.get("name") or "").lower()
return any(k in name for k in TOOL_NAME_KEYWORDS)
async def get_play_time(steam_id: str) -> list[dict]:
"""获取 Steam 游戏时长(异步),返回按游玩时长降序排列的游戏列表
- 含 0 时长游戏:柱状图需要统计全库,调用方自行过滤已游玩部分。
- 排除非游戏软件/工具(SteamVR 等),避免污染时长表。
- 无 API 配额时返回 None(调用方提示"请求过于频繁")。
"""
if not await acquire_steam_quota(wait=False):
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()
api_url = (
f"https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/"
f"?key={api_key}&steamid={steam_id}&include_appinfo=true"
# include_played_free_games 必须用数字 1(字符串 true 可能不被解析,
# 导致 SteamVR 这类免费工具时带时不带);skip_unvetted_apps=false
# 避免未审核 app(含部分游戏)在响应中忽隐忽现
f"&include_played_free_games=1&skip_unvetted_apps=false"
)
timeout = httpx.Timeout(30.0)
for attempt in range(2):
try:
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.get(api_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 []
data = response.json()
games = data.get("response", {}).get("games", [])
game_count = data.get("response", {}).get("game_count", 0)
if games or game_count == 0:
break
# 响应声称有游戏却返回空列表:疑似限流/截断,重试一次
logger.warning(
f"GetOwnedGames 返回空列表但 game_count={game_count}, 第 {attempt + 1} 次重试"
)
await asyncio.sleep(1.5)
else:
logger.warning("GetOwnedGames 重试后仍返回空列表,放弃")
return []
game_list = sorted(
(
{"name": g.get("name", "Unknown"),
"appid": g.get("appid"),
"playtime_hours": round(g.get("playtime_forever", 0) / 60, 2),
"clienticon": g.get("img_icon_url", "") or ""}
for g in games
if not _is_software(g)
),
key=lambda x: x["playtime_hours"],
reverse=True,
)
for i, g in enumerate(game_list, 1):
g["rank"] = i
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(76561198402219863, None))
with open("bg.jpg", "wb") as f:
f.write(data["background"])
logger.info(data["description"])