358 lines
13 KiB
Python
358 lines
13 KiB
Python
"""
|
||
提供对 OverFast API (https://overfast-api.tekrop.fr) 的所有异步 HTTP 调用封装,
|
||
用于查询 Overwatch 玩家战绩、英雄信息等数据。
|
||
"""
|
||
|
||
import asyncio
|
||
import time
|
||
from typing import Any
|
||
from urllib.parse import quote
|
||
|
||
import aiohttp
|
||
|
||
try: # 统一使用 AstrBot 日志;独立单测时回退标准 logging
|
||
from astrbot.api import logger
|
||
except Exception: # pragma: no cover
|
||
import logging
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class OverFastAPIError(ValueError):
|
||
"""OverFast API 请求错误。
|
||
|
||
携带 HTTP 状态码、服务端返回的错误详情以及重试等待时间,
|
||
继承 ValueError 以兼容既有的错误捕获逻辑。
|
||
|
||
Attributes:
|
||
status_code: HTTP 状态码。
|
||
detail: 服务端返回的原始错误文本。
|
||
retry_after: 服务端建议的重试等待秒数(可能为 None)。
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
message: str,
|
||
status_code: int | None = None,
|
||
detail: str = "",
|
||
retry_after: int | None = None,
|
||
) -> None:
|
||
super().__init__(message)
|
||
self.status_code = status_code
|
||
self.detail = detail
|
||
self.retry_after = retry_after
|
||
|
||
|
||
# 进程级 GET 缓存:key -> (expire_ts, data),降低限流风险
|
||
_CACHE: dict[str, tuple[float, Any]] = {}
|
||
|
||
|
||
def _cache_get(key: str) -> Any | None:
|
||
item = _CACHE.get(key)
|
||
if not item:
|
||
return None
|
||
expire, data = item
|
||
if expire < time.monotonic():
|
||
_CACHE.pop(key, None)
|
||
return None
|
||
return data
|
||
|
||
|
||
def _cache_set(key: str, data: Any, ttl: float) -> None:
|
||
if ttl <= 0:
|
||
return
|
||
# 简单容量保护
|
||
if len(_CACHE) > 512:
|
||
_CACHE.clear()
|
||
_CACHE[key] = (time.monotonic() + ttl, data)
|
||
|
||
|
||
def clear_cache() -> None:
|
||
_CACHE.clear()
|
||
|
||
|
||
class OverFastAPIClient:
|
||
"""OverFast API 异步客户端。
|
||
|
||
使用示例:
|
||
async with OverFastAPIClient() as client:
|
||
result = await client.search_players("TeKrop")
|
||
summary = await client.get_player_summary("TeKrop-2217")
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
base_url: str = "https://overfast-api.tekrop.fr",
|
||
timeout: float = 15.0,
|
||
) -> None:
|
||
"""初始化 OverFast API 客户端。
|
||
|
||
Args:
|
||
base_url: API 基础 URL,官方实例或自建实例(如 Docker 自部署)。
|
||
插件侧通过 `api_base_url` 配置项传入。
|
||
timeout: 请求超时时间(秒),默认 15 秒(聊天场景不宜过长)。
|
||
"""
|
||
self.base_url = base_url.rstrip("/")
|
||
self.timeout = aiohttp.ClientTimeout(total=timeout)
|
||
self._session: aiohttp.ClientSession | None = None
|
||
|
||
@property
|
||
def session(self) -> aiohttp.ClientSession:
|
||
"""获取当前 HTTP session。
|
||
|
||
Returns:
|
||
aiohttp.ClientSession 实例。
|
||
|
||
Raises:
|
||
RuntimeError: 如果 session 未初始化(未使用 async with)。
|
||
"""
|
||
if self._session is None or self._session.closed:
|
||
raise RuntimeError(
|
||
"HTTP session 未初始化。请使用 `async with OverFastAPIClient() as client:`"
|
||
)
|
||
return self._session
|
||
|
||
async def __aenter__(self) -> "OverFastAPIClient":
|
||
"""异步上下文管理器入口,创建 HTTP session。"""
|
||
self._session = aiohttp.ClientSession(timeout=self.timeout)
|
||
return self
|
||
|
||
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
||
"""异步上下文管理器出口,确保 session 被关闭。"""
|
||
if self._session and not self._session.closed:
|
||
await self._session.close()
|
||
self._session = None
|
||
|
||
@staticmethod
|
||
def _quote_segment(seg: str) -> str:
|
||
return quote(seg, safe="-")
|
||
|
||
async def _request(
|
||
self,
|
||
endpoint: str,
|
||
params: dict | None = None,
|
||
*,
|
||
use_cache: bool = False,
|
||
cache_ttl: float = 300.0,
|
||
max_retries: int = 2,
|
||
) -> dict | list:
|
||
"""发送 GET 请求到 OverFast API(含缓存/限流重试)。
|
||
|
||
Args:
|
||
endpoint: API 端点路径(不含 base_url),如 `/players`。
|
||
params: 查询参数字典,值为 None 的键会被自动过滤。
|
||
use_cache: 是否启用进程级 GET 缓存。
|
||
cache_ttl: 缓存秒数。
|
||
max_retries: 429/503 时的最大重试次数。
|
||
|
||
Raises:
|
||
OverFastAPIError: 当返回 HTTP 4xx/5xx 错误时。
|
||
"""
|
||
filtered_params = {k: v for k, v in (params or {}).items() if v is not None}
|
||
url = f"{self.base_url}/{endpoint.lstrip('/')}"
|
||
logger.debug(f"OverFast API 请求: GET {url} params={filtered_params}")
|
||
|
||
cache_key = ""
|
||
if use_cache:
|
||
sorted_items = sorted((k, str(v)) for k, v in filtered_params.items())
|
||
cache_key = f"GET {url} {sorted_items}"
|
||
cached = _cache_get(cache_key)
|
||
if cached is not None:
|
||
logger.debug(f"OverFast API 缓存命中: {url}")
|
||
return cached
|
||
|
||
last_err: OverFastAPIError | None = None
|
||
for attempt in range(max_retries + 1):
|
||
async with self.session.get(url, params=filtered_params) as response:
|
||
if response.status >= 400:
|
||
error_text = ""
|
||
retry_after: int | None = None
|
||
try:
|
||
error_json = await response.json()
|
||
if isinstance(error_json, dict):
|
||
error_text = str(error_json.get("error", ""))
|
||
ra = error_json.get("retry_after")
|
||
if isinstance(ra, (int, float)):
|
||
retry_after = int(ra)
|
||
if not error_text and "detail" in error_json:
|
||
error_text = str(error_json["detail"])
|
||
if not error_text:
|
||
error_text = str(error_json)
|
||
else:
|
||
error_text = str(error_json)
|
||
except Exception:
|
||
error_text = await response.text() or f"HTTP {response.status}"
|
||
|
||
logger.debug(
|
||
f"OverFast API 错误: GET {url} params={filtered_params} "
|
||
f"-> HTTP {response.status}, error={error_text}, retry_after={retry_after}"
|
||
)
|
||
|
||
if response.status == 404:
|
||
raise OverFastAPIError(
|
||
f"未找到请求的资源: {error_text}",
|
||
status_code=404, detail=error_text, retry_after=retry_after,
|
||
)
|
||
if response.status in (429, 503) and attempt < max_retries:
|
||
wait = retry_after if retry_after else (2 ** attempt)
|
||
wait = min(max(wait, 1), 10)
|
||
logger.debug(f"OverFast API 限流,{wait}s 后重试 ({attempt + 1}/{max_retries})")
|
||
await asyncio.sleep(wait)
|
||
last_err = OverFastAPIError(
|
||
f"请求过于频繁: {error_text}",
|
||
status_code=response.status, detail=error_text,
|
||
retry_after=retry_after,
|
||
)
|
||
continue
|
||
if response.status == 429:
|
||
raise OverFastAPIError(
|
||
f"请求过于频繁,请稍后重试: {error_text}",
|
||
status_code=429, detail=error_text, retry_after=retry_after,
|
||
)
|
||
if response.status == 503:
|
||
raise OverFastAPIError(
|
||
f"服务暂时不可用(可能被限流): {error_text}",
|
||
status_code=503, detail=error_text, retry_after=retry_after,
|
||
)
|
||
raise OverFastAPIError(
|
||
f"API 错误 (HTTP {response.status}): {error_text}",
|
||
status_code=response.status, detail=error_text, retry_after=retry_after,
|
||
)
|
||
|
||
try:
|
||
data = await response.json()
|
||
except aiohttp.ContentTypeError as e:
|
||
raw_text = await response.text()
|
||
raise ValueError(
|
||
f"无法解析 API 响应为 JSON: {e}. 原始响应: {raw_text[:500]}"
|
||
) from e
|
||
|
||
logger.debug(f"OverFast API 响应: {type(data).__name__}")
|
||
if use_cache:
|
||
_cache_set(cache_key, data, cache_ttl)
|
||
return data
|
||
|
||
assert last_err is not None
|
||
raise last_err
|
||
|
||
async def search_players(
|
||
self,
|
||
name: str,
|
||
order_by: str = "name:asc",
|
||
offset: int = 0,
|
||
limit: int = 20,
|
||
) -> dict:
|
||
"""搜索玩家。"""
|
||
return await self._request( # type: ignore[return-value]
|
||
"/players",
|
||
params={"name": name, "order_by": order_by, "offset": offset, "limit": limit},
|
||
use_cache=True,
|
||
cache_ttl=120,
|
||
)
|
||
|
||
async def get_player_summary(self, player_id: str) -> dict:
|
||
"""获取玩家摘要信息。"""
|
||
pid = self._quote_segment(player_id)
|
||
return await self._request(f"/players/{pid}/summary", use_cache=True, cache_ttl=120) # type: ignore[return-value]
|
||
|
||
async def get_player_full(self, player_id: str) -> dict:
|
||
"""获取玩家完整数据(摘要 + 统计数据)。"""
|
||
pid = self._quote_segment(player_id)
|
||
return await self._request(f"/players/{pid}", use_cache=True, cache_ttl=120) # type: ignore[return-value]
|
||
|
||
async def get_player_stats_summary(
|
||
self,
|
||
player_id: str,
|
||
gamemode: str | None = None,
|
||
platform: str | None = None,
|
||
) -> dict:
|
||
"""获取玩家统计摘要。"""
|
||
pid = self._quote_segment(player_id)
|
||
return await self._request( # type: ignore[return-value]
|
||
f"/players/{pid}/stats/summary",
|
||
params={"gamemode": gamemode, "platform": platform},
|
||
use_cache=True,
|
||
cache_ttl=120,
|
||
)
|
||
|
||
async def get_player_career_stats(
|
||
self,
|
||
player_id: str,
|
||
gamemode: str,
|
||
platform: str | None = None,
|
||
hero: str | None = None,
|
||
) -> dict:
|
||
"""获取玩家生涯统计(按英雄分类的详细数据)。"""
|
||
pid = self._quote_segment(player_id)
|
||
return await self._request( # type: ignore[return-value]
|
||
f"/players/{pid}/stats/career",
|
||
params={"gamemode": gamemode, "platform": platform, "hero": hero},
|
||
use_cache=True,
|
||
cache_ttl=120,
|
||
)
|
||
|
||
async def get_heroes_stats(
|
||
self,
|
||
platform: str,
|
||
gamemode: str,
|
||
region: str,
|
||
role: str | None = None,
|
||
map_key: str | None = None,
|
||
competitive_division: str | None = None,
|
||
order_by: str = "hero:asc",
|
||
) -> list:
|
||
"""获取英雄统计数据(全服英雄选取率/胜率排行榜)。
|
||
|
||
competitive_division 可选 bronze/silver/gold/platinum/emerald/diamond/master/grandmaster
|
||
(API 4.13 段位;ultimate 为玩家段位新值,榜单筛选暂不支持)。
|
||
"""
|
||
return await self._request( # type: ignore[return-value]
|
||
"/heroes/stats",
|
||
params={
|
||
"platform": platform,
|
||
"gamemode": gamemode,
|
||
"region": region,
|
||
"role": role,
|
||
"map": map_key,
|
||
"competitive_division": competitive_division,
|
||
"order_by": order_by,
|
||
},
|
||
use_cache=True,
|
||
cache_ttl=600,
|
||
)
|
||
|
||
async def get_hero_info(self, hero_key: str) -> dict:
|
||
"""获取英雄详细信息。"""
|
||
hk = self._quote_segment(hero_key.strip().lower())
|
||
return await self._request(f"/heroes/{hk}", use_cache=True, cache_ttl=3600) # type: ignore[return-value]
|
||
|
||
async def list_heroes(
|
||
self,
|
||
role: str | None = None,
|
||
gamemode: str | None = None,
|
||
) -> list:
|
||
"""获取英雄列表。"""
|
||
return await self._request( # type: ignore[return-value]
|
||
"/heroes",
|
||
params={"role": role, "gamemode": gamemode},
|
||
use_cache=True,
|
||
cache_ttl=3600,
|
||
)
|
||
|
||
async def list_roles(self) -> list:
|
||
"""获取所有角色类型。"""
|
||
return await self._request("/roles", use_cache=True, cache_ttl=3600) # type: ignore[return-value]
|
||
|
||
async def list_gamemodes(self) -> list:
|
||
"""获取所有游戏模式。"""
|
||
return await self._request("/gamemodes", use_cache=True, cache_ttl=3600) # type: ignore[return-value]
|
||
|
||
async def list_maps(self, gamemode: str | None = None) -> list:
|
||
"""获取所有地图。"""
|
||
return await self._request( # type: ignore[return-value]
|
||
"/maps",
|
||
params={"gamemode": gamemode},
|
||
use_cache=True,
|
||
cache_ttl=3600,
|
||
)
|