2026-09-01 13:13:40 +08:00
|
|
|
|
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
|
2026-10-13 17:53:22 +08:00
|
|
|
|
import hashlib
|
2026-09-01 13:13:40 +08:00
|
|
|
|
import json
|
2026-10-13 17:53:22 +08:00
|
|
|
|
from xml.etree import ElementTree
|
2026-09-08 14:25:32 +08:00
|
|
|
|
from hexi.core.rate_limit import acquire
|
2026-09-01 13:13:40 +08:00
|
|
|
|
|
|
|
|
|
|
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}}
|
|
|
|
|
|
"""
|
2026-09-14 18:59:01 +08:00
|
|
|
|
lines = ["\nSteam API key 使用统计:"]
|
2026-09-01 13:13:40 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-13 17:53:22 +08:00
|
|
|
|
# 交易封禁 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 {}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-01 13:13:40 +08:00
|
|
|
|
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,
|
2026-10-13 17:53:22 +08:00
|
|
|
|
"avatar_frame": None,
|
2026-09-01 13:13:40 +08:00
|
|
|
|
"player_name": "Unknown",
|
|
|
|
|
|
"recent_2_week_play_time": None,
|
|
|
|
|
|
"game_data": [],
|
2026-10-13 17:53:22 +08:00
|
|
|
|
# 资料 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,
|
2026-09-01 13:13:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
|
2026-10-13 17:53:22 +08:00
|
|
|
|
# XML 与主页并行抓取,省一个往返;失败只记日志,字段留空
|
|
|
|
|
|
xml_task = asyncio.create_task(fetch_profile_xml(steam_id, proxy))
|
2026-09-01 13:13:40 +08:00
|
|
|
|
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}")
|
2026-10-13 17:53:22 +08:00
|
|
|
|
await xml_task # 主页失败也要回收任务,避免悬挂
|
2026-09-01 13:13:40 +08:00
|
|
|
|
return result
|
|
|
|
|
|
|
2026-10-13 17:53:22 +08:00
|
|
|
|
result.update(await xml_task)
|
|
|
|
|
|
result.update(parse_profile_extras(html))
|
|
|
|
|
|
# 资料页的「当前正在游戏」比 XML 更新更及时(且已本地化)
|
|
|
|
|
|
result["current_game"] = parse_in_game_name(html)
|
|
|
|
|
|
|
2026-09-01 13:13:40 +08:00
|
|
|
|
# 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"])
|
|
|
|
|
|
|
2026-10-13 17:53:22 +08:00
|
|
|
|
# background:普通背景走 CSS,动画背景用 <video> 的静态首帧;按 URL 缓存
|
|
|
|
|
|
background_url = parse_profile_background_url(html)
|
2026-09-01 13:13:40 +08:00
|
|
|
|
if background_url:
|
2026-10-13 17:53:22 +08:00
|
|
|
|
suffix = Path(background_url.split("?")[0]).suffix or ".jpg"
|
2026-09-01 13:13:40 +08:00
|
|
|
|
result["background"] = await _fetch(
|
2026-10-13 17:53:22 +08:00
|
|
|
|
background_url,
|
|
|
|
|
|
default_background,
|
|
|
|
|
|
cache_file=cache_path / f"bg_{_url_digest(background_url)}{suffix}",
|
|
|
|
|
|
proxy=proxy,
|
2026-09-01 13:13:40 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-10-13 17:53:22 +08:00
|
|
|
|
# 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,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-09-01 13:13:40 +08:00
|
|
|
|
# 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
|
|
|
|
|
|
|
2026-10-13 17:53:22 +08:00
|
|
|
|
# 页面没写近两周时长时才用 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 周)"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-09-01 13:13:40 +08:00
|
|
|
|
# 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"]
|
2026-10-13 17:53:22 +08:00
|
|
|
|
# 封面 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"
|
2026-09-01 13:13:40 +08:00
|
|
|
|
download_tasks.append((
|
|
|
|
|
|
game_info, "game_image",
|
|
|
|
|
|
game_info["game_image_url"], default_header_image,
|
2026-10-13 17:53:22 +08:00
|
|
|
|
cache_path / header_name, proxy,
|
2026-09-01 13:13:40 +08:00
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-13 17:53:22 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-01 13:13:40 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-13 17:53:22 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-01 13:13:40 +08:00
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
from nonebot.log import logger
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
|
2026-10-13 17:53:22 +08:00
|
|
|
|
data = asyncio.run(get_user_data(76561198402219863, None))
|
2026-09-01 13:13:40 +08:00
|
|
|
|
|
|
|
|
|
|
with open("bg.jpg", "wb") as f:
|
|
|
|
|
|
f.write(data["background"])
|
|
|
|
|
|
logger.info(data["description"])
|