- 群分析: 新增 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>
1020 lines
39 KiB
Python
1020 lines
39 KiB
Python
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"])
|