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 = ["Steam 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"Steam 社区 :: (.*?)", html) if player_name: result["player_name"] = player_name.group(1) # description t
\r\n\t\t\t\t\t\t\t\t風が雨が激しくても
思いだすんだ 僕らを照らす光があるよ
今日もいっぱい
明日もいっぱい 力を出しきってみるよ\t\t\t\t\t\t\t
description = re.search( r'
(.*?)
', html, re.DOTALL | re.MULTILINE ) if description: description = description.group(1) description = re.sub(r"
", "\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 avatar_url = re.search(r'\r\n\t\t\t\t\t\t\t\t\t
15.5 小时(过去 2 周)
play_time_text = re.search( r'