Files
HeXi/hexi/plugins/nonebot_plugin_steam_info/steam.py
T
sansenhoshiandClaude Code 51b08ccb68 feat: 群分析本地消息库 / 处理中占位图 / Web 日志窗口重构
- 群分析: 新增 history_store 只读本地消息源(读 learning_chat 落库,含 uninfo
  昵称补齐/去重/截断判定),适配器优先读本地库、失败回退 OneBot 分页;分页
  锚点字段回退链修复 NapCat 传 message_seq 翻页断裂;新增「本地记录」开关
- core/message_utils: 新增 common_proc_reply 占位图通用回复(引用消息 + 处理中
  动图,支持后台任务显式指定 target),群分析/战况/倒放改用
- web_hub + web: 日志页改固定窗口滚动 + 翻页锚定 + 自动换行,SSE 日志轮转发
  reset 帧,入口 HTML no-cache,行数统计增量缓存,CPU 改非阻塞采样,登录信息缓存
- 插件内 CLAUDE.md / DESIGN.md 不入库(.gitignore),galgame_card 两份文档取消
  跟踪(文件保留在磁盘)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 18:59:01 +08:00

1020 lines
39 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 json
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
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,
"player_name": "Unknown",
"recent_2_week_play_time": None,
"game_data": [],
}
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"
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}")
return result
# 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
background_url = re.search(r"background-image: url\( \'(.*?)\' \)", html)
if background_url:
background_url = background_url.group(1)
result["background"] = await _fetch(
background_url, default_background, 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
)
# 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
# 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"]
game_info_split = game_info["game_image_url"].split("/")
# 封面 URL 形如 .../steam/apps/{appid}/header.jpg,提取 appid 用于本地化
appid_str = game_info_split[-2]
game_info["appid"] = int(appid_str) if appid_str.isdigit() else None
download_tasks.append((
game_info, "game_image",
game_info["game_image_url"], default_header_image,
cache_path / f"header_{game_info_split[-2]}.jpg", proxy,
))
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_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
if __name__ == "__main__":
from nonebot.log import logger
import asyncio
data = asyncio.run(get_user_data(76561199135038179, None))
with open("bg.jpg", "wb") as f:
f.write(data["background"])
logger.info(data["description"])