Clone original repository

This commit is contained in:
2026-10-09 20:21:24 +08:00
parent 25e4618573
commit c012a639be
16 changed files with 2627 additions and 1 deletions
+357
View File
@@ -0,0 +1,357 @@
"""
提供对 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,
)