Compare commits

...
3 Commits
Author SHA1 Message Date
y0lo 7d64b8a0a2 fix merge conficts 2026-10-09 20:25:21 +08:00
y0lo c012a639be Clone original repository 2026-10-09 20:21:24 +08:00
y0lo 25e4618573 Initial commit 2026-10-09 19:58:08 +08:00
16 changed files with 2602 additions and 2 deletions
+2
View File
@@ -0,0 +1,2 @@
__pycache__/
*.py[cod]
+74
View File
@@ -0,0 +1,74 @@
# 更新日志 (CHANGELOG)
## [v2.3.1](当前版本)
- 移除项目内测试文件与测试相关开发依赖/说明
## [v2.3.0]
- 新段位:对齐 OverFast API 4.13 `CompetitiveDivision`,新增翡翠 `emerald`(白金与钻石之间)、王者 `ultimate`(最高段位);`champion` 改显示为王者并保留兼容旧数据,`top500` 保留
- 自建服务:新增 `api_base_url` 配置项(默认官方 `https://overfast-api.tekrop.fr`),插件所有请求经该地址构造,支持自建 OverFast 实例;非法地址自动回退官方
## [v2.2.0]
- 重构:`main.py` 拆分为 `constants.py`/`utils.py`/`bindings.py`,旧导入路径保留兼容
- 性能:API 客户端默认超时 30s→15s,进程级 GET 缓存(玩家 120s/榜单 600s/英雄 3600s),429/503 按 `retry_after` 自动重试
- 并发:图片/头像下载复用单 session + 6 并发上限,不再每图新建 session
- 泄漏:`image_renderer` 新增 `cleanup_old_images()`(每小时,`_save_image` 自动触发),`owherostats` 图片模式避免 54 行超长文本
- 健壮:玩家 ID 归一化+长度/字符校验+URL quote;图片开关默认与 schema 一致为开启;移除 `COMPETITIVE_ICONS` 重复;日志统一 AstrBot logger
- 杂项:修正 README 仓库地址,删除重复 `logo.png.png`
## [v2.1.0]
- 英雄映射新增第 53 位英雄 D.Mon(重装,第 4 赛季)与第 54 位英雄血律 Doctrine(支援,第 5 赛季),现可通过 /owhero D.Mon、/owhero 血律 等查询
- 英雄中文映射补全至 54 名英雄(与 OverFast API HeroKey 枚举对齐)
## [v2.0.0]
- 删除 /owsearch 与 /owheroes 指令(功能被 /owsummary 与 /owhero 覆盖)
- /owherostats 展示全部英雄胜率(不再截断前 15);/owhero 同步查询并展示该英雄的全服胜率与选取率
- 多账号绑定:每个 QQ 号可绑定多个 OW 账号(配置 max_binds_per_user,默认 3),KV 结构升级并自动迁移旧数据
- 新增 /owbinds 查看绑定列表、/owdefault 设置默认查询账号;/owunbind 支持指定账号解绑
- 新增查询结果图片渲染功能(owsummary/owstats/owcareer/owme),渲染为 OW 风格卡片
- 绑定账号时支持选择平台(PC端/主机端):/owbind <玩家ID> [pc|主机]
- 英雄中文映射补全至 52 名英雄(与 API HeroKey 枚举对齐),新增骇灾、弗蕾娅、无漾、斩仇、安燃、金驭、埃姆雷、瑞稀、飞天猫、西拉
- API 报错结构化解析(含状态码、错误详情、retry_after),完整报错记录到控制台 debug 日志
- 插件会自动将 # 替换为 - 再查询
## [v1.3.0]
- 重构插件
- 重构命令
- 缓存更改为KV缓存
- 添加简单配置界面
- 计划下一版本进行战绩图片渲染
## [v1.2.1]
- 缓存降级增强
- 更改描述页
- 增加英雄查询(独立查询)
- 可以查询单个英雄战绩,可以查询休闲和竞技两个模式
- 绑定提示优化
- 添加主机平台战绩查询
- resp 异常修复
- 错误提示细化
## [v1.1.1]
- 找不到方法解决常玩英雄的两百报错问题,直接删去常玩英雄项目查询
- 职责显示更改为中文
- 竞技段位未定级也显示
## [v1.1.0]
- 修复API域名失效问题
- 增强错误处理
- 添加详细统计信息
- 将API请求分为五段整合后再发出减少超时可能
- 增加申请管控防止过量请求
## [v1.0.0]
- 修复API域名失效问题
- 添加自动重试机制
+1
View File
@@ -16,3 +16,4 @@ LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE A
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE. USE OR OTHER DEALINGS IN THE SOFTWARE.
+68 -2
View File
@@ -1,3 +1,69 @@
# overwatch_bot # AstrBOT 守望先锋国际服查询插件
作者其实是个小白来的,部分由ai创建
# 注意,部分使用steam启动的玩家无法被查询
🎮 **守望先锋·归来国际服查询插件**
[![Version](https://img.shields.io/badge/version-v2.3.1-blue.svg)](https://github.com/TZYCeng/astrbot_plugin_owcx)
[![Python](https://img.shields.io/badge/python-3.8+-green.svg)](https://www.python.org/)
[![AstrBot](https://img.shields.io/badge/AstrBot-4.9+-orange.svg)](https://github.com/AstrBotDevs/AstrBot)
[![License](https://img.shields.io/badge/license-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
## 📦 安装方法
### 方法一:通过AstrBot插件市场
1. 打开AstrBot WebUI
2. 进入插件管理
3. 搜索 "astrbot_plugin_owcx"
4. 点击安装
### 方法二:手动安装
1. 下载插件文件到AstrBot的插件目录
```bash
cd AstrBot/data/plugins
git clone https://github.com/TZYCeng/astrbot_plugin_owcx.git
```
2. 安装依赖
```bash
pip install -r requirements.txt
```
3. 重启AstrBot
## 使用方法
### owsummary
查询玩家摘要信息(头像、竞技段位等)。 用法: /owsummary [玩家ID] 示例: /owsummary TeKrop#2217 或 /owsummary 同志有程#3156 说明: 玩家ID支持直接输入 #,插件会自动处理;省略 ID 则查询绑定的账号(使用绑定平台)
### owstats
查询玩家统计概览(胜率、KDA等)。 用法: /owstats [玩家ID] [游戏模式] 游戏模式: 快速、竞技(默认) 示例: /owstats TeKrop#2217 竞技 说明: 省略 ID 则查询绑定的账号
### owcareer
查询玩家生涯统计(按英雄详细数据)。 用法: /owcareer [玩家ID] <游戏模式> [英雄名] 游戏模式: 快速、竞技 英雄名: 可选,支持中文(如 源氏、安娜)或英文(如 genji, ana) 示例: /owcareer TeKrop#2217 竞技 /owcareer TeKrop#2217 竞技 源氏 /owcareer 竞技 源氏 (已绑定ID后)
### owhero
查询英雄详细信息(含该英雄在当前配置地区/平台/模式下的全服胜率与选取率)。 用法: /owhero <英雄名> 英雄名支持中文(如 源氏、安娜)或英文(如 genji, ana) 示例: /owhero 源氏, /owhero ana
### owherostats
查询全服英雄胜率/选取率排行榜(按地区服务器统计,展示全部英雄)。 用法: /owherostats [角色] [地区] 角色: 坦克、输出、支援(可选) 地区: 亚服(asia)、美服(americas)、欧服(europe),不填则使用配置中的默认地区 平台与游戏模式使用配置文件中的默认值 示例: /owherostats /owherostats 输出 /owherostats 支援 欧服
### owbind
绑定 Overwatch ID 到当前 QQ 号,可选择平台(PC端/主机端),每个 QQ 号可绑定多个账号(数量上限见配置 max_binds_per_user,默认 3 个),新绑定的账号自动成为默认查询账号。 用法: /owbind <玩家ID> [平台] 平台: pc(电脑端)、console(主机端),不填则使用配置中的默认平台 示例: /owbind TeKrop#2217 或 /owbind TeKrop#2217 主机 说明: 旧版本绑定数据自动迁移,无需手动处理
### owunbind
解绑当前 QQ 号绑定的 Overwatch ID。 用法: /owunbind [玩家ID] 说明: 不填玩家ID时解绑当前默认查询账号;解绑默认账号后自动切换到剩余账号
### owbinds
查看当前 QQ 号绑定的所有 Overwatch 账号(含平台与默认标记)。 用法: /owbinds
### owdefault
设置默认查询的 Overwatch 账号,快捷指令省略 ID 时将查询该账号。 用法: /owdefault <玩家ID> 示例: /owdefault TeKrop#2217
### owme
快捷查询默认绑定账号的摘要信息。 用法: /owme 说明: 需要先使用 /owbind 绑定,多账号时查询 /owdefault 设置的账号
## ⚙️ 配置说明
在 AstrBot WebUI 的插件配置中可设置以下选项:
- **默认查询的游戏模式**: 竞技比赛 / 快速游戏
- **默认查询的平台**: pc / console(未绑定平台或绑定时未选择平台时使用)
- **默认查询的地区服务器** (`default_region`): asia(亚服) / americas(美服) / europe(欧服),用于 /owherostats 等按地区统计的查询
- **每QQ号绑定上限** (`max_binds_per_user`): 每个 QQ 号最多可绑定的 Overwatch 账号数量,默认 3
- **图片渲染开关** (`enable_image_render`): 开启后(默认开启),/owsummary、/owstats、/owcareer、/owme、/owherostats 的查询结果会渲染为卡片图片;渲染失败自动回退文字;临时图片每小时自动清理,Docker 内如无中文字体请关闭或挂载 `fonts/font.ttf`
- **报错展示开关** (`show_api_error`): 开启后,查询出错时机器人会把 API 返回的具体报错(含建议重试等待时间)回复给查询者;无论开关与否,完整报错与堆栈都会记录在控制台 debug 日志中
- **API 服务地址** (`api_base_url`): OverFast API 地址,默认官方 `https://overfast-api.tekrop.fr`;自建服务(如 Docker 部署开源 OverFast)填入自建地址即可,非法值自动回退官方
- 图片渲染依赖 Pillow:`pip install Pillow>=9.0.0`(已在 requirements.txt 中)
- 如系统缺少中文字体导致渲染文字异常,可在插件目录下新建 `fonts` 文件夹并放入任意中文字体文件(命名为 `font.ttf` 或 `font.ttc`)
## 📕 更新日志
版本更新记录已迁移至 [CHANGELOG.md](CHANGELOG.md)。
an agent to fetch and analysis overwatch datastics
+5
View File
@@ -0,0 +1,5 @@
"""OW战绩查询插件 - AstrBot Plugin for Overwatch Stats Query."""
from .main import OverwatchStatsPlugin
__all__ = ["OverwatchStatsPlugin"]
+40
View File
@@ -0,0 +1,40 @@
{
"default_gamemode": {
"description": "默认查询的游戏模式",
"type": "string",
"options": ["竞技比赛", "快速游戏"],
"default": "竞技比赛"
},
"default_platform": {
"description": "默认查询的平台(未绑定或绑定时未选择平台时使用)",
"type": "string",
"options": ["pc", "console"],
"default": "pc"
},
"default_region": {
"description": "默认查询的地区(用于英雄胜率/选取率排行榜等查询)",
"type": "string",
"options": ["asia", "americas", "europe"],
"default": "asia"
},
"max_binds_per_user": {
"description": "每个 QQ 号最多可绑定的 Overwatch 账号数量",
"type": "int",
"default": 3
},
"enable_image_render": {
"description": "是否启用查询结果图片渲染(渲染失败时自动回退为文字)",
"type": "bool",
"default": true
},
"show_api_error": {
"description": "是否在查询出错时把 API 返回的具体报错回复给查询者",
"type": "bool",
"default": false
},
"api_base_url": {
"description": "OverFast API 服务地址(官方或自建,如 https://overfast-api.tekrop.fr)",
"type": "string",
"default": "https://overfast-api.tekrop.fr"
}
}
+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,
)
+159
View File
@@ -0,0 +1,159 @@
"""KV 多账号绑定存储,含旧版自动迁移。"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from astrbot.api.event import AstrMessageEvent
_BIND_KEY_PREFIX = "bind_"
def bind_key(qq_id: str) -> str:
return f"{_BIND_KEY_PREFIX}{qq_id}"
def normalize_loaded(raw, default_platform: str) -> dict | None:
"""将 KV 原始值归一化为新版结构;无法识别返回 None;需迁移的旧版返回新结构。"""
empty: dict = {"accounts": [], "default": None}
if raw is None or raw == "":
return empty
if isinstance(raw, dict) and "accounts" in raw:
accounts = []
for item in raw.get("accounts") or []:
if not isinstance(item, dict):
continue
pid = item.get("player_id")
platform = item.get("platform")
if platform not in ("pc", "console"):
platform = default_platform
if pid:
accounts.append({"player_id": pid, "platform": platform})
default = raw.get("default")
if not any(a["player_id"] == default for a in accounts):
default = accounts[0]["player_id"] if accounts else None
return {"accounts": accounts, "default": default}
if isinstance(raw, dict) and raw.get("player_id"):
platform = raw.get("platform")
if platform not in ("pc", "console"):
platform = default_platform
return {
"accounts": [{"player_id": raw["player_id"], "platform": platform}],
"default": raw["player_id"],
}
if isinstance(raw, str) and raw:
return {
"accounts": [{"player_id": raw, "platform": default_platform}],
"default": raw,
}
return None
class BindingStore:
"""依赖 Star 的 get/put/delete_kv_data,需在插件类中混入使用。"""
_default_platform_fallback = "pc"
async def _load_bindings(self, event: "AstrMessageEvent") -> dict:
from astrbot.api import logger as _logger
empty: dict = {"accounts": [], "default": None}
qq_id = event.get_sender_id()
if not qq_id:
return empty
try:
raw = await self.get_kv_data(bind_key(qq_id), None) # type: ignore[attr-defined]
except Exception as e:
_logger.debug(f"读取绑定信息失败: {e}")
return empty
default_platform = getattr(self, "default_platform", self._default_platform_fallback)
result = normalize_loaded(raw, default_platform)
if result is None:
return empty
# 旧版结构需回写迁移
if not (isinstance(raw, dict) and "accounts" in raw):
try:
await self.put_kv_data(bind_key(qq_id), result) # type: ignore[attr-defined]
except Exception as e:
_logger.debug(f"迁移绑定数据失败: {e}")
return result
async def _save_bindings(self, event: "AstrMessageEvent", data: dict) -> bool:
from astrbot.api import logger as _logger
qq_id = event.get_sender_id()
if not qq_id:
return False
try:
await self.put_kv_data(bind_key(qq_id), data) # type: ignore[attr-defined]
return True
except Exception as e:
_logger.error(f"保存绑定信息失败: {e}")
return False
async def _get_binding(self, event: "AstrMessageEvent") -> tuple[str | None, str | None]:
data = await self._load_bindings(event)
accounts = data.get("accounts", [])
if not accounts:
return None, None
default = data.get("default")
for acc in accounts:
if acc.get("player_id") == default:
return acc["player_id"], acc.get("platform")
first = accounts[0]
return first.get("player_id"), first.get("platform")
async def _add_binding(
self, event: "AstrMessageEvent", player_id: str, platform: str = "pc"
) -> tuple[str, dict | None]:
if platform not in ("pc", "console"):
platform = getattr(self, "default_platform", "pc")
data = await self._load_bindings(event)
accounts = data["accounts"]
for acc in accounts:
if acc["player_id"].lower() == player_id.lower():
acc["player_id"] = player_id
acc["platform"] = platform
data["default"] = player_id
if await self._save_bindings(event, data):
return "updated", data
return "error", None
max_binds = max(int(getattr(self, "max_binds_per_user", 3) or 3), 1)
if len(accounts) >= max_binds:
return "limit", None
accounts.append({"player_id": player_id, "platform": platform})
data["default"] = player_id
if await self._save_bindings(event, data):
return "added", data
return "error", None
async def _remove_binding(
self, event: "AstrMessageEvent", player_id: str | None = None
) -> tuple[str, str | None]:
from astrbot.api import logger as _logger
data = await self._load_bindings(event)
accounts = data["accounts"]
if not accounts:
return "empty", None
target = player_id or data.get("default") or accounts[0]["player_id"]
idx = next(
(i for i, a in enumerate(accounts) if a["player_id"].lower() == target.lower()),
None,
)
if idx is None:
return "not_found", None
removed = accounts.pop(idx)
if not accounts:
try:
await self.delete_kv_data(bind_key(event.get_sender_id())) # type: ignore[attr-defined]
except Exception as e:
_logger.error(f"删除绑定信息失败: {e}")
return "error", None
return "removed", removed["player_id"]
if data.get("default") == removed["player_id"]:
data["default"] = accounts[0]["player_id"]
if await self._save_bindings(event, data):
return "removed", removed["player_id"]
return "error", None
+149
View File
@@ -0,0 +1,149 @@
"""共享常量:段位/角色/模式/平台/地区/英雄映射。"""
RANK_MAPPING = {
"bronze": ("青铜", ""),
"silver": ("白银", ""),
"gold": ("黄金", ""),
"platinum": ("铂金", ""),
"emerald": ("翡翠", ""),
"diamond": ("钻石", ""),
"master": ("大师", ""),
"grandmaster": ("宗师", ""),
# API 4.13 起最高段位改名为 ultimate;保留 champion 兼容旧数据/旧自建实例
"champion": ("王者", ""),
"ultimate": ("王者", ""),
"top500": ("五百强", ""),
}
ROLE_MAPPING = {
"tank": ("坦克", ""),
"damage": ("输出", ""),
"support": ("支援", ""),
}
GAMEMODE_MAPPING = {
"quickplay": "快速游戏",
"competitive": "竞技比赛",
}
GAMEMODE_REVERSE_MAPPING = {
"快速": "quickplay",
"快速游戏": "quickplay",
"qp": "quickplay",
"quick": "quickplay",
"quickplay": "quickplay",
"竞技": "competitive",
"竞技模式": "competitive",
"竞技比赛": "competitive",
"排位": "competitive",
"排位赛": "competitive",
"comp": "competitive",
"competitive": "competitive",
}
PLATFORM_MAPPING = {
"pc": "PC端",
"console": "主机端",
}
PLATFORM_REVERSE_MAPPING = {
"pc": "pc",
"电脑": "pc",
"电脑端": "pc",
"端游": "pc",
"console": "console",
"主机": "console",
"主机端": "console",
"ps": "console",
"ps4": "console",
"ps5": "console",
"xbox": "console",
"switch": "console",
"ns": "console",
}
REGION_MAPPING = {
"asia": "亚服",
"americas": "美服",
"europe": "欧服",
}
REGION_REVERSE_MAPPING = {
"asia": "asia",
"亚服": "asia",
"亚洲": "asia",
"亚太": "asia",
"americas": "americas",
"america": "americas",
"美服": "americas",
"美洲": "americas",
"europe": "europe",
"eu": "europe",
"欧服": "europe",
"欧洲": "europe",
}
HERO_NAME_MAPPING = {
"doomfist": "末日铁拳",
"dva": "D.Va",
"dmon": "D.Mon",
"domina": "金驭",
"hazard": "骇灾",
"junker-queen": "渣客女王",
"mauga": "毛加",
"orisa": "奥丽莎",
"ramattra": "拉玛刹",
"reinhardt": "莱因哈特",
"roadhog": "路霸",
"sigma": "西格玛",
"winston": "温斯顿",
"wrecking-ball": "破坏球",
"zarya": "查莉娅",
"anran": "安燃",
"ashe": "艾什",
"bastion": "堡垒",
"cassidy": "卡西迪",
"echo": "回声",
"emre": "埃姆雷",
"freja": "弗蕾娅",
"genji": "源氏",
"hanzo": "半藏",
"junkrat": "狂鼠",
"mei": "美",
"pharah": "法老之鹰",
"reaper": "死神",
"shion": "紫苑",
"sierra": "西拉",
"sojourn": "索杰恩",
"soldier-76": "士兵:76",
"sombra": "黑影",
"symmetra": "秩序之光",
"torbjorn": "托比昂",
"tracer": "猎空",
"vendetta": "斩仇",
"venture": "探奇",
"widowmaker": "黑百合",
"ana": "安娜",
"baptiste": "巴蒂斯特",
"brigitte": "布丽吉塔",
"doctrine": "血律",
"illari": "伊拉锐",
"jetpack-cat": "飞天猫",
"juno": "朱诺",
"kiriko": "雾子",
"lifeweaver": "生命之梭",
"lucio": "卢西奥",
"mercy": "天使",
"mizuki": "瑞稀",
"moira": "莫伊拉",
"wuyang": "无漾",
"zenyatta": "禅雅塔",
}
HERO_NAME_REVERSE_MAPPING = {cn: en for en, cn in HERO_NAME_MAPPING.items()}
ROLE_CN_TO_EN = {
"坦克": "tank", "tank": "tank", "重装": "tank",
"输出": "damage", "damage": "damage",
"支援": "support", "support": "support", "辅助": "support",
}
+63
View File
@@ -0,0 +1,63 @@
# 对话归档:OWCX 插件评估、重构与 v2.3.0 更新
- 归档日期:2026-09-29
- 仓库:`https://github.com/TZYCeng/astrbot_plugin_owcx.git`
- 分支:`main`
- 版本跨度:v2.1.0 → v2.2.0 → v2.3.0
## 初评发现
- `main.py` 约 1904 行,常量、工具与绑定逻辑混杂。
- 图片与头像下载为每次请求单独创建 aiohttp session;临时图片没有清理。
- API 请求缺少缓存与 429/503 重试,默认超时 30 秒。
- 玩家 ID 缺少校验及 URL 编码;重复定义竞技图标;日志实现不统一。
- 图片开关默认值与配置 schema 不一致;README 仓库地址有误,存在重复 logo 文件。
## v2.2.0:重构与健壮性(`9f3deca`)
- 将映射拆至 `constants.py`,纯逻辑拆至 `utils.py`,绑定存储与迁移拆至 `bindings.py`;`main.py` 缩减至约 700 行,并保留旧导入路径兼容。
- API 超时改为 15 秒;增加进程级 GET 缓存(玩家 120 秒、榜单 600 秒、英雄 3600 秒)及 429/503 的 `retry_after` 重试(最多 2 次);对 URL 参数进行 quote。
- 图片/头像下载复用 session,并限制同时下载数为 6。
- 新增旧临时图片清理逻辑及英雄统计卡片渲染;补充玩家 ID 校验;统一使用 AstrBot logger。
- 图片渲染默认值与 schema 统一为开启;修正 README 仓库地址并删除重复 logo 文件。
## v2.3.0:新段位与 API 地址配置化(`c1c813e`)
依据用户提供的 OverFast API 4.13.0 `openapi.json`:
- 段位枚举新增 `emerald`(翡翠)及 `ultimate`(王者)。
- `champion` 保留兼容旧数据并显示为王者;`top500` 保留。
- 54 个 `HeroKey` 与现有英雄映射一致,无需改动。
- `_conf_schema.json` 增加 `api_base_url`,默认 `https://overfast-api.tekrop.fr`,可填自建 OverFast 服务地址。
- `normalize_api_base_url()` 去除首尾空白和尾斜杠;空值或非 HTTP(S) 地址回退官方地址。
- 全部 API 客户端由插件的 `_api_client()` 按配置地址创建,启动日志显示当前 API 地址。
- `metadata.yaml` 版本更新至 v2.3.0。
## 历史提交
| 提交 | 日期 | 内容 |
| --- | --- | --- |
| `9f3deca` | 2026-09-29 | v2.2.0 结构重构、缓存重试、下载复用与健壮性修正 |
| `c1c813e` | 2026-09-29 | v2.3.0 新段位与 API 地址配置化 |
| `fa5d65e` | 2026-09-29 | 开发辅助文件与渲染检查(本次已移除相关文件) |
## 后续关注
- 在真实 AstrBot 环境验证 `/owsummary`、`/owstats`、`/owcareer`、`/owherostats` 及绑定指令。
- 使用真实翡翠/王者账号确认文字与卡片显示。
- 配置自建 OverFast 服务地址后确认请求走向;上游尚未支持 `ultimate` 榜单筛选时不传该筛选值。
- 如上游公布不同正式中文译名,可再调整 `champion` 对应显示。
- 核对新增英雄中文名是否与官方最新译名完全一致。
## 关键文件
| 文件 | 说明 |
| --- | --- |
| `main.py` | `OverwatchStatsPlugin` 主类与指令入口 |
| `api_client.py` | `OverFastAPIClient` / `OverFastAPIError`、缓存、重试与基础 URL |
| `constants.py` | 段位、角色、模式、平台、地区与英雄映射 |
| `utils.py` | 纯逻辑及 API 地址归一化 |
| `bindings.py` | KV 绑定存储与旧数据迁移 |
| `image_renderer.py` | 卡片渲染与临时图片清理 |
| `_conf_schema.json` | 插件配置 schema |
| `CHANGELOG.md` | 版本变更记录 |
+706
View File
@@ -0,0 +1,706 @@
"""
查询结果图片渲染模块。
所有渲染函数返回生成的 PNG 文件路径,调用方负责发送
"""
from __future__ import annotations
import io
import os
import re
import tempfile
from typing import Optional, Sequence
from PIL import Image, ImageDraw, ImageFont
# 常见中文字体不含彩色 emoji 字形,直接绘制会变成方框,
# 因此渲染前统一移除 emoji,改用配色区分内容。
_EMOJI_PATTERN = re.compile(
"["
"\U0001F000-\U0001FAFF" # 各类 emoji 区块
"\U00002600-\U000027BF" # 杂项符号与装饰符号
"\U00002300-\U000023FF" # 杂项技术符号(⏱ 等)
"\U00002B00-\U00002BFF" # 箭头与星形(⭐ 等)
"\U0001F1E6-\U0001F1FF" # 区域指示符
"]\ufe0f?",
flags=re.UNICODE,
)
def _clean_text(text: object) -> str:
"""移除文本中的 emoji 并规整空白,避免字体缺字形显示为方框。"""
if text is None:
return ""
cleaned = _EMOJI_PATTERN.sub("", str(text))
return re.sub(r"\s{2,}", " ", cleaned).strip()
# ===== 配色方案(OW 风格深色主题)=====
BG_COLOR = (24, 26, 33) # 页面背景
CARD_COLOR = (34, 37, 46) # 卡片背景
CARD_COLOR_ALT = (40, 44, 55) # 次级卡片背景
ACCENT_COLOR = (255, 153, 51) # OW 橙
TEXT_MAIN = (240, 240, 245) # 主文本
TEXT_SUB = (205, 210, 224) # 次级文本(提高对比度)
TEXT_DIM = (150, 156, 172) # 弱文本
DIVIDER_COLOR = (60, 64, 78) # 分隔线
ROLE_COLORS = {
"坦克": (96, 165, 250), # 蓝
"输出": (248, 113, 113), # 红
"支援": (74, 222, 128), # 绿
}
# 赞赏等级配色(仿游戏内勋章色调)
ENDORSEMENT_COLORS = {
1: (150, 152, 158),
2: (92, 184, 92),
3: (70, 160, 220),
4: (170, 105, 220),
5: (240, 165, 45),
}
# ===== 字体查找 =====
# 按优先级列出常见的中文字体路径,覆盖 Linux / Windows / macOS
_FONT_CANDIDATES = [
# 插件自带字体目录(用户可自行放置字体文件)
os.path.join(os.path.dirname(os.path.abspath(__file__)), "fonts", "font.ttf"),
os.path.join(os.path.dirname(os.path.abspath(__file__)), "fonts", "font.ttc"),
# Linux 常见
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
"/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
# Windows 常见
"C:/Windows/Fonts/msyh.ttc",
"C:/Windows/Fonts/msyh.ttf",
"C:/Windows/Fonts/simhei.ttf",
"C:/Windows/Fonts/simsun.ttc",
# macOS 常见
"/System/Library/Fonts/PingFang.ttc",
"/System/Library/Fonts/STHeiti Light.ttc",
"/System/Library/Fonts/Hiragino Sans GB.ttc",
]
_font_cache: dict[int, ImageFont.FreeTypeFont] = {}
def _load_font(size: int) -> ImageFont.FreeTypeFont:
"""加载支持中文的字体。
Args:
size: 字号。
Returns:
字体对象。找不到中文字体时返回 Pillow 默认字体。
"""
if size in _font_cache:
return _font_cache[size]
font = None
for path in _FONT_CANDIDATES:
if os.path.exists(path):
try:
font = ImageFont.truetype(path, size)
break
except Exception:
continue
if font is None:
font = ImageFont.load_default()
_font_cache[size] = font
return font
def _text_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.FreeTypeFont) -> int:
"""测量文本宽度。"""
if not text:
return 0
bbox = draw.textbbox((0, 0), text, font=font)
return bbox[2] - bbox[0]
def _text_height(font: ImageFont.FreeTypeFont) -> int:
"""获取字体行高。"""
bbox = font.getbbox("国Ag")
return bbox[3] - bbox[1]
def _fit_text(draw: ImageDraw.ImageDraw, text: object, font: ImageFont.FreeTypeFont, max_width: int) -> str:
"""清理文本并截断到指定宽度内(超出部分以省略号结尾)。"""
text = _clean_text(text)
if _text_width(draw, text, font) <= max_width:
return text
ellipsis = "..."
while text and _text_width(draw, text + ellipsis, font) > max_width:
text = text[:-1]
return (text + ellipsis) if text else ellipsis
def _paste_avatar(card: Image.Image, avatar_bytes: bytes, x: int, y: int, size: int) -> None:
"""将头像以圆形裁剪后粘贴到卡片上。
Args:
card: 目标卡片图像。
avatar_bytes: 头像图片二进制数据。
x, y: 粘贴位置左上角坐标。
size: 头像边长(正方形)。
"""
try:
avatar = Image.open(io.BytesIO(avatar_bytes)).convert("RGBA")
avatar = avatar.resize((size, size), Image.LANCZOS)
# 圆形遮罩
mask = Image.new("L", (size, size), 0)
mask_draw = ImageDraw.Draw(mask)
mask_draw.ellipse((0, 0, size, size), fill=255)
card.paste(avatar, (x, y), mask)
# 描边
border = ImageDraw.Draw(card)
border.ellipse((x, y, x + size, y + size), outline=ACCENT_COLOR, width=3)
except Exception:
# 头像处理失败时静默跳过,不影响整体渲染
pass
def _rounded_rect(
draw: ImageDraw.ImageDraw,
xy: tuple[int, int, int, int],
radius: int,
fill: tuple[int, int, int],
) -> None:
"""绘制圆角矩形。"""
draw.rounded_rectangle(xy, radius=radius, fill=fill)
def _save_image(img: Image.Image) -> str:
"""将图像保存到临时文件并返回路径(顺带清理过期图片防堆积)。"""
try:
cleanup_old_images(max_age_seconds=3600)
except Exception:
pass
fd, path = tempfile.mkstemp(suffix=".png", prefix="ow_render_")
os.close(fd)
img.save(path, format="PNG")
return path
def cleanup_old_images(max_age_seconds: int = 3600) -> int:
"""删除临时目录中超时的本插件渲染图,返回删除数量。"""
import time as _time
tmpdir = tempfile.gettempdir()
now = _time.time()
removed = 0
try:
for name in os.listdir(tmpdir):
if not (name.startswith("ow_render_") and name.endswith(".png")):
continue
full = os.path.join(tmpdir, name)
try:
if now - os.path.getmtime(full) > max_age_seconds:
os.remove(full)
removed += 1
except Exception:
continue
except Exception:
return removed
return removed
# ===== 卡片渲染 =====
def _draw_endorsement_badge(
img: Image.Image, x: int, y: int, level: int, size: int = 34
) -> int:
"""绘制游戏内风格的赞赏等级六边形勋章,返回勋章宽度。
Args:
img: 目标图像。
x, y: 勋章左上角坐标。
level: 赞赏等级(0-5)。
size: 勋章外接尺寸。
Returns:
勋章占用宽度。
"""
import math
level = max(0, min(int(level or 0), 5))
color = ENDORSEMENT_COLORS.get(level, ENDORSEMENT_COLORS[5])
cx, cy = x + size // 2, y + size // 2
r = size // 2
badge = Image.new("RGBA", (size, size), (0, 0, 0, 0))
d = ImageDraw.Draw(badge)
points = [
(cx + r * math.cos(math.radians(60 * i - 30)),
cy + r * math.sin(math.radians(60 * i - 30)))
for i in range(6)
]
d.polygon(points, fill=color)
# 内圈
r2 = r - 4
points2 = [
(cx + r2 * math.cos(math.radians(60 * i - 30)),
cy + r2 * math.sin(math.radians(60 * i - 30)))
for i in range(6)
]
d.polygon(points2, fill=(28, 30, 38))
img.paste(badge, (x, y), badge)
# 等级数字
font = _load_font(int(size * 0.62))
text = str(level)
tw = _text_width(ImageDraw.Draw(img), text, font)
bbox = font.getbbox(text)
th = bbox[3] - bbox[1]
ImageDraw.Draw(img).text(
(cx - tw // 2, cy - th // 2 - bbox[1]), text, font=font, fill=TEXT_MAIN
)
return size
def _draw_rank_section(
img: Image.Image,
y: int,
rank_rows: Sequence[dict],
width: int,
pad: int,
) -> int:
"""绘制竞技段位区(坦克/输出/支援三职责,含段位图标),返回结束 y 坐标。
Args:
img: 目标图像。
y: 起始 y 坐标。
rank_rows: [{"role_text", "rank_text", "rank_icon_bytes"}, ...]。
width: 卡片宽度。
pad: 卡片内边距。
Returns:
绘制结束后的 y 坐标。
"""
font_body = _load_font(26)
draw = ImageDraw.Draw(img)
draw.text((pad + 6, y), "竞技段位", font=font_body, fill=ACCENT_COLOR)
y += _text_height(font_body) + 12
for row in rank_rows:
role_text = _fit_text(draw, row.get("role_text", ""), font_body, 200)
rank_text = _fit_text(draw, row.get("rank_text", ""), font_body, width - pad - 320)
color = TEXT_MAIN
for role_cn, c in ROLE_COLORS.items():
if role_cn in role_text:
color = c
break
draw.text((pad + 20, y + 4), role_text, font=font_body, fill=color)
icon_x = pad + 230
icon_bytes = row.get("rank_icon_bytes")
if icon_bytes:
_paste_square_icon(img, icon_bytes, icon_x, y, 36, radius=18)
draw.text((icon_x + 46, y + 4), rank_text, font=font_body, fill=TEXT_MAIN)
y += 44
return y
def _draw_heroes_section(
img: Image.Image,
y: int,
top_heroes: Sequence[tuple[str, str, Optional[bytes]]],
width: int,
pad: int,
) -> int:
"""绘制常玩英雄区(最多 3 个,含英雄头像、名称与场次),返回结束 y 坐标。
Args:
img: 目标图像。
y: 起始 y 坐标。
top_heroes: [(英雄名, 场次/时长文本, 英雄头像数据), ...]。
width: 卡片宽度。
pad: 卡片内边距。
Returns:
绘制结束后的 y 坐标。
"""
font_body = _load_font(26)
font_hero = _load_font(22)
font_small = _load_font(20)
draw = ImageDraw.Draw(img)
draw.line((pad, y, width - pad, y), fill=DIVIDER_COLOR, width=1)
y += 12
draw.text((pad + 6, y), "常玩英雄", font=font_body, fill=ACCENT_COLOR)
y += _text_height(font_body) + 12
slot_w = (width - pad * 2) // 3
portrait_size = 76
for idx, (hero_name, info_text, portrait_bytes) in enumerate(top_heroes[:3]):
cx = pad + idx * slot_w
if portrait_bytes:
_paste_square_icon(img, portrait_bytes, cx + 4, y, portrait_size, radius=14)
tx = cx + 4 + portrait_size + 12
name_text = _fit_text(draw, hero_name, font_hero, slot_w - portrait_size - 20)
info_text_c = _fit_text(draw, info_text, font_small, slot_w - portrait_size - 20)
draw.text((tx, y + 12), name_text, font=font_hero, fill=TEXT_MAIN)
draw.text((tx, y + 12 + _text_height(font_hero) + 8), info_text_c, font=font_small, fill=TEXT_SUB)
return y + portrait_size + 16
def _banner_mask(width: int, height: int, radius: int) -> Image.Image:
"""生成仅上两角圆润的矩形遮罩(用于名片横幅)。"""
mask = Image.new("L", (width, height), 0)
d = ImageDraw.Draw(mask)
# 先画一个超出底部的圆角矩形,再补齐下半部分,使底部保持直角
d.rounded_rectangle((0, 0, width, height + radius), radius=radius, fill=255)
d.rectangle((0, height - radius, width, height), fill=255)
return mask
def _paste_banner(card: Image.Image, banner_bytes: bytes, xy: tuple[int, int, int, int]) -> None:
"""将名片横幅按游戏内效果缩放裁剪后粘贴到卡片指定区域。
名片为宽幅图片,按区域宽度等比缩放并居中裁剪,上两角保持与卡片一致的圆角。
Args:
card: 目标卡片图像。
banner_bytes: 名片图片二进制数据。
xy: 目标区域 (x1, y1, x2, y2)。
"""
try:
x1, y1, x2, y2 = xy
w, h = x2 - x1, y2 - y1
banner = Image.open(io.BytesIO(banner_bytes)).convert("RGBA")
# 等比缩放至覆盖目标区域后居中裁剪
scale = max(w / banner.width, h / banner.height)
new_size = (int(banner.width * scale + 0.5), int(banner.height * scale + 0.5))
banner = banner.resize(new_size, Image.LANCZOS)
left = (banner.width - w) // 2
top = (banner.height - h) // 2
banner = banner.crop((left, top, left + w, top + h))
# 底部压暗,保证横幅上的文字可读(alpha 混合而非替换)
overlay = Image.new("RGBA", (w, h), (0, 0, 0, 0))
od = ImageDraw.Draw(overlay)
for i in range(h):
alpha = int(110 * (i / h) ** 1.5)
od.line((0, i, w, i), fill=(10, 12, 18, alpha))
banner = Image.alpha_composite(banner, overlay)
mask = _banner_mask(w, h, 18)
card.paste(banner.convert("RGB"), (x1, y1), mask)
except Exception:
# 名片处理失败时静默跳过,不影响整体渲染
pass
def _paste_square_icon(card: Image.Image, icon_bytes: bytes, x: int, y: int, size: int, radius: int = 8) -> None:
"""将方形图标(英雄头像/段位图标)圆角粘贴到卡片上。"""
try:
icon = Image.open(io.BytesIO(icon_bytes)).convert("RGBA")
icon = icon.resize((size, size), Image.LANCZOS)
mask = Image.new("L", (size, size), 0)
mask_draw = ImageDraw.Draw(mask)
mask_draw.rounded_rectangle((0, 0, size, size), radius=radius, fill=255)
card.paste(icon, (x, y), mask)
except Exception:
pass
def render_summary_card(
username: str,
title: str,
endorsement_level: int,
rank_rows: Sequence[dict],
platform_label: str,
avatar_bytes: Optional[bytes] = None,
namecard_bytes: Optional[bytes] = None,
top_heroes: Sequence[tuple[str, str, Optional[bytes]]] = (),
footer_note: Optional[str] = None,
) -> str:
"""渲染玩家摘要卡片(游戏内名片 + 头像组合效果)。
名片作为顶部横幅,头像以圆形叠加在横幅左侧(还原游戏内个人资料页效果);
下方依次为竞技段位(含段位图标)与常玩英雄 TOP3(含英雄头像)。
Args:
username: 玩家名。
title: 头衔(可为空)。
endorsement_level: 赞赏等级。
rank_rows: [{"role_text", "rank_text", "rank_icon_bytes"}, ...]。
platform_label: 平台显示文本,如 "PC端"。
avatar_bytes: 头像图片数据(可选)。
namecard_bytes: 名片横幅图片数据(可选,纯文字模式不展示)。
top_heroes: [(英雄名, 游戏时长文本, 英雄头像数据), ...]。
footer_note: 底部备注(可选)。
Returns:
生成的 PNG 文件路径。
"""
W = 680
PAD = 28
font_title = _load_font(36)
font_sub = _load_font(23)
font_body = _load_font(26)
font_hero = _load_font(22)
font_small = _load_font(20)
username = _fit_text(ImageDraw.Draw(Image.new("RGB", (1, 1))), username, font_title, W - PAD * 2 - 150)
title = _clean_text(title)
platform_label = _clean_text(platform_label)
footer_note = _clean_text(footer_note)
banner_h = 150 if namecard_bytes else 0
avatar_size = 108 if avatar_bytes else 0
# 无名片时保留原有的文本头部高度
header_h = banner_h if namecard_bytes else (max(avatar_size, 96) + 10)
rank_block_h = len(rank_rows) * 44 + 58
heroes_block_h = (150 if top_heroes else 0)
footer_h = 34 if footer_note else 10
H = PAD + header_h + rank_block_h + heroes_block_h + footer_h + 24
img = Image.new("RGB", (W, H), BG_COLOR)
draw = ImageDraw.Draw(img)
_rounded_rect(draw, (10, 10, W - 10, H - 10), 18, CARD_COLOR)
# ===== 头部:名片横幅 + 头像(游戏内效果)=====
if namecard_bytes:
_paste_banner(img, namecard_bytes, (10, 10, W - 10, 10 + banner_h))
else:
draw.rectangle((10, 10, W - 10, 16), fill=ACCENT_COLOR)
text_x = PAD + 10
text_y_center = 10 + (banner_h // 2 if namecard_bytes else header_h // 2)
if avatar_bytes:
av_x = PAD + 8
av_y = text_y_center - avatar_size // 2
_paste_avatar(img, avatar_bytes, av_x, av_y, avatar_size)
text_x = av_x + avatar_size + 18
# 用户名 / 头衔(垂直居中于头部区域)
lines_h = _text_height(font_title) + 10
if title:
lines_h += _text_height(font_sub) + 6
lines_h += 40 # 赞赏勋章行
ty = text_y_center - lines_h // 2
draw.text((text_x, ty), username, font=font_title, fill=TEXT_MAIN)
ty += _text_height(font_title) + 10
if title:
draw.text((text_x, ty), title, font=font_sub, fill=ACCENT_COLOR)
ty += _text_height(font_sub) + 6
# 赞赏等级勋章 + 平台
badge_size = 34
_draw_endorsement_badge(img, text_x, ty, endorsement_level, badge_size)
label = _fit_text(draw, f"赞赏 {endorsement_level} 级 {platform_label}", font_sub, W - text_x - badge_size - 24)
draw.text((text_x + badge_size + 10, ty + (badge_size - _text_height(font_sub)) // 2), label, font=font_sub, fill=TEXT_SUB)
y = 10 + header_h + 12
draw.line((PAD, y, W - PAD, y), fill=DIVIDER_COLOR, width=2)
y += 14
# ===== 竞技段位(坦克/输出/支援三职责,含段位图标)=====
y = _draw_rank_section(img, y, rank_rows, W, PAD)
# ===== 常玩英雄(含英雄头像与场次/时长)=====
if top_heroes:
y += 4
y = _draw_heroes_section(img, y, top_heroes, W, PAD)
if footer_note:
y += 2
draw.line((PAD, y, W - PAD, y), fill=DIVIDER_COLOR, width=1)
y += 10
draw.text((PAD + 6, y), _fit_text(draw, footer_note, font_small, W - PAD * 2 - 12), font=font_small, fill=TEXT_DIM)
return _save_image(img)
def render_stats_card(
player_id: str,
gamemode_label: str,
platform_label: str,
stat_rows: Sequence[tuple[str, str]],
rank_rows: Sequence[dict] = (),
top_heroes: Sequence[tuple[str, str, Optional[bytes]]] = (),
) -> str:
"""渲染玩家统计概览卡片。
Args:
player_id: 玩家 ID。
gamemode_label: 游戏模式显示文本。
platform_label: 平台显示文本。
stat_rows: [(标签, 数值), ...]。
rank_rows: 竞技模式时传入的三职责段位行(含段位图标),可为空。
top_heroes: 常玩英雄 [(名称, 场次文本, 头像数据), ...],可为空。
Returns:
生成的 PNG 文件路径。
"""
W = 720
PAD = 28
font_title = _load_font(30)
font_sub = _load_font(22)
font_body = _load_font(24)
rows = [(_clean_text(label), _clean_text(value)) for label, value in stat_rows]
per_col = (len(rows) + 1) // 2
grid_h = per_col * 54 + 10
rank_h = (len(rank_rows) * 44 + 70) if rank_rows else 0
heroes_h = 158 if top_heroes else 0
H = PAD * 2 + 96 + rank_h + grid_h + heroes_h + 20
img = Image.new("RGB", (W, H), BG_COLOR)
draw = ImageDraw.Draw(img)
_rounded_rect(draw, (10, 10, W - 10, H - 10), 18, CARD_COLOR)
draw.rectangle((10, 10, W - 10, 16), fill=ACCENT_COLOR)
y = PAD + 6
draw.text((PAD + 6, y), "统计概览", font=font_title, fill=ACCENT_COLOR)
y += _text_height(font_title) + 10
sub = _fit_text(
draw,
f"{player_id} | {_clean_text(gamemode_label)} | {_clean_text(platform_label)}",
font_sub,
W - PAD * 2 - 12,
)
draw.text((PAD + 6, y), sub, font=font_sub, fill=TEXT_SUB)
y += _text_height(font_sub) + 14
draw.line((PAD, y, W - PAD, y), fill=DIVIDER_COLOR, width=2)
y += 16
# ===== 竞技段位区(仅竞技模式)=====
if rank_rows:
y = _draw_rank_section(img, y, rank_rows, W, PAD)
draw.line((PAD, y, W - PAD, y), fill=DIVIDER_COLOR, width=1)
y += 14
# ===== 两列网格布局 =====
col_w = (W - PAD * 2 - 20) // 2
for idx, (label, value) in enumerate(rows):
col = idx // per_col
row = idx % per_col
cx = PAD + 6 + col * (col_w + 20)
cy = y + row * 54
_rounded_rect(draw, (cx - 6, cy - 6, cx + col_w - 6, cy + 42), 10, CARD_COLOR_ALT)
label_text = _fit_text(draw, label, font_body, col_w - 20)
draw.text((cx + 8, cy), label_text, font=font_body, fill=TEXT_SUB)
lw = _text_width(draw, label_text, font_body)
value_text = _fit_text(draw, value, font_body, col_w - lw - 34)
draw.text((cx + 8 + lw + 12, cy), value_text, font=font_body, fill=TEXT_MAIN)
y += per_col * 54 + 6
# ===== 常玩英雄(含头像与场次)=====
if top_heroes:
y = _draw_heroes_section(img, y, top_heroes, W, PAD)
return _save_image(img)
def render_career_card(
player_id: str,
gamemode_label: str,
platform_label: str,
hero_filter: Optional[str],
heroes: Sequence[tuple[str, Sequence[tuple[str, str]], Optional[bytes]]],
remaining: int = 0,
) -> str:
"""渲染生涯统计卡片(每个英雄区块带英雄头像)。
Args:
player_id: 玩家 ID。
gamemode_label: 游戏模式显示文本。
platform_label: 平台显示文本。
hero_filter: 英雄筛选显示文本(可选)。
heroes: [(英雄名, [(标签, 数值), ...], 英雄头像数据), ...]。
remaining: 未显示的剩余英雄数量。
Returns:
生成的 PNG 文件路径。
"""
W = 720
PAD = 28
font_title = _load_font(30)
font_sub = _load_font(22)
font_hero = _load_font(26)
font_body = _load_font(23)
# 计算高度
hero_blocks_h = 0
for item in heroes:
rows = item[1]
hero_blocks_h += 44 + max(len(rows), 1) * 36 + 14
extra_h = 40 if remaining > 0 else 0
H = PAD * 2 + 96 + hero_blocks_h + extra_h + 16
img = Image.new("RGB", (W, H), BG_COLOR)
draw = ImageDraw.Draw(img)
_rounded_rect(draw, (10, 10, W - 10, H - 10), 18, CARD_COLOR)
draw.rectangle((10, 10, W - 10, 16), fill=ACCENT_COLOR)
y = PAD + 6
draw.text((PAD + 6, y), "生涯统计", font=font_title, fill=ACCENT_COLOR)
y += _text_height(font_title) + 10
sub = f"{player_id} | {_clean_text(gamemode_label)} | {_clean_text(platform_label)}"
if hero_filter:
sub += f" | 英雄: {_clean_text(hero_filter)}"
draw.text((PAD + 6, y), _fit_text(draw, sub, font_sub, W - PAD * 2 - 12), font=font_sub, fill=TEXT_SUB)
y += _text_height(font_sub) + 14
draw.line((PAD, y, W - PAD, y), fill=DIVIDER_COLOR, width=2)
y += 16
for item in heroes:
hero_name, rows = item[0], item[1]
portrait_bytes = item[2] if len(item) > 2 else None
_rounded_rect(draw, (PAD, y, W - PAD, y + 36 + max(len(rows), 1) * 36 + 8), 12, CARD_COLOR_ALT)
# 英雄头像(若有)+ 英雄名
text_x = PAD + 14
if portrait_bytes:
_paste_square_icon(img, portrait_bytes, PAD + 12, y + 2, 34, radius=10)
text_x = PAD + 12 + 34 + 10
draw.text((text_x, y + 6), _fit_text(draw, hero_name, font_hero, W - text_x - PAD - 12), font=font_hero, fill=ACCENT_COLOR)
y += 44
for label, value in rows:
label_text = _fit_text(draw, label, font_body, 180)
draw.text((PAD + 26, y), label_text, font=font_body, fill=TEXT_SUB)
lw = _text_width(draw, label_text, font_body)
value_text = _fit_text(draw, value, font_body, W - PAD * 2 - 26 - lw - 24)
draw.text((PAD + 26 + lw + 12, y), value_text, font=font_body, fill=TEXT_MAIN)
y += 36
y += 14
if remaining > 0:
draw.text((PAD + 14, y), f"... 还有 {remaining} 个英雄的数据未显示", font=font_sub, fill=TEXT_DIM)
return _save_image(img)
def render_herostats_card(
title: str,
subtitle: str,
rows: Sequence[tuple[str, str]],
footer_note: str | None = None,
) -> str:
"""渲染全服英雄胜率/选取率榜(文本行列表,避免超长单消息)。"""
W = 720
PAD = 28
font_title = _load_font(30)
font_sub = _load_font(22)
font_body = _load_font(23)
H = PAD * 2 + 96 + max(len(rows), 1) * 34 + (40 if footer_note else 0) + 16
img = Image.new("RGB", (W, H), BG_COLOR)
draw = ImageDraw.Draw(img)
_rounded_rect(draw, (10, 10, W - 10, H - 10), 18, CARD_COLOR)
draw.rectangle((10, 10, W - 10, 16), fill=ACCENT_COLOR)
y = PAD + 6
draw.text((PAD + 6, y), _fit_text(draw, title, font_title, W - PAD * 2 - 12), font=font_title, fill=ACCENT_COLOR)
y += _text_height(font_title) + 10
draw.text((PAD + 6, y), _fit_text(draw, subtitle, font_sub, W - PAD * 2 - 12), font=font_sub, fill=TEXT_SUB)
y += _text_height(font_sub) + 14
draw.line((PAD, y, W - PAD, y), fill=DIVIDER_COLOR, width=2)
y += 14
for name, value in rows:
_rounded_rect(draw, (PAD, y, W - PAD, y + 30), 10, CARD_COLOR_ALT)
draw.text((PAD + 14, y + 3), _fit_text(draw, name, font_body, 260), font=font_body, fill=TEXT_SUB)
draw.text((PAD + 300, y + 3), _fit_text(draw, value, font_body, W - PAD * 2 - 300 - 14), font=font_body, fill=TEXT_MAIN)
y += 34
if footer_note:
y += 8
draw.text((PAD + 14, y), _fit_text(draw, footer_note, font_sub, W - PAD * 2 - 28), font=font_sub, fill=TEXT_DIM)
return _save_image(img)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

+746
View File
@@ -0,0 +1,746 @@
"""OW战绩查询插件(重构版:常量/工具/绑定已拆分)。"""
import asyncio
import aiohttp
import astrbot.api.message_components as Comp
from astrbot.api import logger
from astrbot.api.all import AstrBotConfig
from astrbot.api.event import filter, AstrMessageEvent
from astrbot.api.star import Context, Star
from .api_client import OverFastAPIClient
from .bindings import BindingStore
from .constants import (
GAMEMODE_MAPPING,
GAMEMODE_REVERSE_MAPPING,
HERO_NAME_MAPPING,
HERO_NAME_REVERSE_MAPPING,
PLATFORM_MAPPING,
PLATFORM_REVERSE_MAPPING,
RANK_MAPPING,
REGION_MAPPING,
REGION_REVERSE_MAPPING,
ROLE_CN_TO_EN,
ROLE_MAPPING,
)
from .utils import (
DEFAULT_API_BASE_URL,
build_rank_data as _build_rank_data,
extract_top_heroes as _extract_top_heroes,
format_number as _format_number,
format_time_played as _format_time_played,
format_time_short as _format_time_short,
get_hero_name_cn as _get_hero_name_cn,
get_platform_display as _get_platform_display,
get_rank_display as _get_rank_display,
get_region_display as _get_region_display,
get_role_display as _get_role_display,
normalize_api_base_url,
normalize_player_id as _normalize_player_id,
resolve_gamemode as _resolve_gamemode,
resolve_hero_name as _resolve_hero_name,
resolve_platform as _resolve_platform,
resolve_region as _resolve_region,
validate_player_id as _validate_player_id,
)
# 向后兼容:保留旧导入路径
__all__ = [
"OverwatchStatsPlugin",
"RANK_MAPPING",
"ROLE_MAPPING",
"GAMEMODE_MAPPING",
"PLATFORM_MAPPING",
"REGION_MAPPING",
"HERO_NAME_MAPPING",
]
try:
from . import image_renderer
_RENDERER_AVAILABLE = True
try:
image_renderer.cleanup_old_images()
except Exception:
pass
except Exception: # pragma: no cover
image_renderer = None # type: ignore[assignment]
_RENDERER_AVAILABLE = False
_DOWNLOAD_SEM = asyncio.Semaphore(6)
class OverwatchStatsPlugin(BindingStore, Star):
"""OW战绩查询插件主类(基于 OverFast API)。"""
def __init__(self, context: Context, config: AstrBotConfig | None = None) -> None:
super().__init__(context)
self.config = config or {}
raw_gamemode = self.config.get("default_gamemode", "competitive")
resolved = _resolve_gamemode(str(raw_gamemode))
self.default_gamemode: str = resolved if resolved in ("quickplay", "competitive") else "competitive"
raw_platform = self.config.get("default_platform", "pc")
resolved_platform = _resolve_platform(str(raw_platform))
self.default_platform: str = resolved_platform if resolved_platform in ("pc", "console") else "pc"
raw_region = self.config.get("default_region", "asia")
resolved_region = _resolve_region(str(raw_region))
self.default_region: str = resolved_region if resolved_region in ("asia", "americas", "europe") else "asia"
# 与 _conf_schema.json 保持一致,默认开启图片渲染
self.enable_image_render: bool = bool(self.config.get("enable_image_render", True))
self.show_api_error: bool = bool(self.config.get("show_api_error", False))
# OverFast 开源可自建,允许通过配置覆盖 API 地址
self.api_base_url: str = normalize_api_base_url(
str(self.config.get("api_base_url", DEFAULT_API_BASE_URL) or DEFAULT_API_BASE_URL)
)
try:
self.max_binds_per_user: int = max(int(self.config.get("max_binds_per_user", 3) or 3), 1)
except (TypeError, ValueError):
self.max_binds_per_user = 3
if self.enable_image_render and not _RENDERER_AVAILABLE:
logger.warning("已开启图片渲染,但渲染模块加载失败(可能未安装 Pillow),将回退文字输出。pip install Pillow")
logger.info(f"OW战绩查询插件已加载(图片渲染: {'开启' if self.enable_image_render else '关闭'},API: {self.api_base_url})")
def _api_client(self) -> OverFastAPIClient:
"""按配置地址构造 API 客户端(支持自建 OverFast 实例)。"""
return OverFastAPIClient(base_url=self.api_base_url)
def _effective_platform(self, bound_platform: str | None = None) -> str:
if bound_platform in ("pc", "console"):
return bound_platform
return self.default_platform
def _api_error_reply(self, e: Exception, friendly: str) -> str:
logger.debug(f"API 错误详情: {type(e).__name__}: {e}", exc_info=True)
if self.show_api_error:
detail = getattr(e, "detail", "") or str(e)
retry_after = getattr(e, "retry_after", None)
reply = f"查询出错: {detail}"
if retry_after:
reply += f"\n建议等待 {retry_after} 秒后重试"
return reply
return friendly
@staticmethod
async def _download_with_session(session: aiohttp.ClientSession, url: str) -> bytes | None:
if not url:
return None
try:
async with _DOWNLOAD_SEM:
async with session.get(url) as resp:
if resp.status == 200:
return await resp.read()
except Exception as e:
logger.debug(f"下载图片失败: {e}")
return None
@classmethod
async def _download_many(cls, urls: list[str]) -> list[bytes | None]:
if not urls:
return []
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession(timeout=timeout) as session:
return await asyncio.gather(*(cls._download_with_session(session, u) for u in urls))
async def _fetch_hero_portraits(self, hero_keys: list[str]) -> dict[str, bytes]:
if not hero_keys:
return {}
portrait_map: dict[str, str] = {}
try:
async with self._api_client() as client:
heroes_list = await client.list_heroes()
for h in heroes_list or []:
if isinstance(h, dict) and h.get("key") and h.get("portrait"):
portrait_map[h["key"]] = h["portrait"]
except Exception as e:
logger.debug(f"获取英雄头像列表失败: {e}")
return {}
urls = [portrait_map.get(k, "") for k in hero_keys]
results = await self._download_many(urls)
return {k: b for k, b in zip(hero_keys, results) if b}
def _prepare_player_id(self, raw: str, from_binding: bool) -> str:
"""绑定存储已是规范化形式直接用;外部输入归一化+校验。"""
if from_binding:
return raw
norm = _normalize_player_id(raw)
try:
_validate_player_id(norm)
except ValueError as e:
raise ValueError(str(e)) from e
return norm
async def _send_summary_result(
self, event: AstrMessageEvent, full: dict, player_id: str, platform: str,
footer_note: str | None = None, extra_lines: list[str] | None = None,
):
summary = full.get("summary") if isinstance(full.get("summary"), dict) else full
stats = full.get("stats") if isinstance(full.get("stats"), dict) else {}
username = summary.get("username", player_id)
avatar_url = summary.get("avatar") or ""
namecard_url = summary.get("namecard") or ""
endorsement = summary.get("endorsement", {})
endorsement_level = endorsement.get("level", 0) if isinstance(endorsement, dict) else 0
title = summary.get("title") or ""
shown_platform, rank_rows = _build_rank_data(summary, platform)
top_heroes = _extract_top_heroes(stats, platform)
if self.enable_image_render and _RENDERER_AVAILABLE:
try:
urls = [avatar_url, namecard_url] + [r.get("rank_icon", "") for r in rank_rows]
results = await self._download_many(urls)
avatar_bytes, namecard_bytes = results[0], results[1]
render_rows = [
{"role_text": r["role_text"], "rank_text": r["rank_text"], "rank_icon_bytes": b}
for r, b in zip(rank_rows, results[2:])
] or [{"role_text": "段位", "rank_text": "暂无竞技段位数据", "rank_icon_bytes": None}]
portraits = await self._fetch_hero_portraits([k for k, _ in top_heroes])
render_heroes = [(_get_hero_name_cn(k), _format_time_short(v), portraits.get(k)) for k, v in top_heroes]
img_path = image_renderer.render_summary_card(
username=username, title=title, endorsement_level=endorsement_level,
rank_rows=render_rows, platform_label=_get_platform_display(shown_platform),
avatar_bytes=avatar_bytes, namecard_bytes=namecard_bytes,
top_heroes=render_heroes, footer_note=footer_note,
)
yield event.chain_result([Comp.Image.fromFileSystem(img_path)])
return
except Exception as e:
logger.warning(f"摘要图片渲染失败,回退文字: {e}")
logger.debug("堆栈:", exc_info=True)
rank_lines = [f" {r['role_text']}: {r['rank_text']}" for r in rank_rows] or [" 暂无竞技段位数据"]
lines = [f"👤 {username}"]
if title:
lines.append(f"头衔: {title}")
lines.append(f"赞赏等级: {endorsement_level}")
lines.append(f"竞技段位 ({_get_platform_display(shown_platform)}):")
lines.extend(rank_lines)
if top_heroes:
lines.append("常玩英雄: " + "、".join(f"{_get_hero_name_cn(k)}({_format_time_played(v)})" for k, v in top_heroes))
if extra_lines:
lines.extend(extra_lines)
result_text = "\n".join(lines)
if avatar_url:
yield event.chain_result([Comp.Image.fromURL(avatar_url), Comp.Plain(result_text)])
else:
yield event.plain_result(result_text)
@filter.command("owsummary")
async def player_summary(self, event: AstrMessageEvent, player_id: str = ""):
"""查询玩家摘要信息。用法: /owsummary [玩家ID]"""
bound_platform: str | None = None
from_binding = False
if not player_id or not player_id.strip():
bound_id, bound_platform = await self._get_binding(event)
if not bound_id:
yield event.plain_result("你还没有绑定 Overwatch ID。\n用法: /owsummary <玩家ID>\n或 /owbind <玩家ID> [平台] 后直接查询")
return
player_id = bound_id
from_binding = True
platform = self._effective_platform(bound_platform)
try:
player_id = self._prepare_player_id(player_id, from_binding)
except ValueError as e:
yield event.plain_result(f"玩家 ID 非法: {e}")
return
try:
async with self._api_client() as client:
full = await client.get_player_full(player_id)
except ValueError as e:
err = str(e).lower()
if "not found" in err or "未找到" in err or "404" in err:
yield event.plain_result(f"未找到玩家 `{player_id}`,请检查 ID。")
return
yield event.plain_result(self._api_error_reply(e, "获取玩家信息失败,请稍后重试。"))
return
except Exception:
logger.debug("获取玩家摘要错误堆栈:", exc_info=True)
yield event.plain_result("获取玩家信息时出错,请稍后重试。")
return
async for res in self._send_summary_result(event, full, player_id, platform):
yield res
@filter.command("owstats")
async def player_stats(self, event: AstrMessageEvent, player_id: str = "", gamemode: str = ""):
"""查询玩家统计概览。用法: /owstats [玩家ID] [快速|竞技]"""
bound_platform: str | None = None
from_binding = False
if not player_id or not player_id.strip():
bound_id, bound_platform = await self._get_binding(event)
if not bound_id:
yield event.plain_result("你还没有绑定 Overwatch ID。用法: /owstats <玩家ID> [快速|竞技]")
return
player_id = bound_id
from_binding = True
platform = self._effective_platform(bound_platform)
try:
player_id = self._prepare_player_id(player_id, from_binding)
except ValueError as e:
yield event.plain_result(f"玩家 ID 非法: {e}")
return
gamemode = _resolve_gamemode(gamemode or self.default_gamemode) or ""
if gamemode not in ("quickplay", "competitive"):
yield event.plain_result("游戏模式无效。可选: 快速(quickplay)、竞技(competitive)")
return
try:
async with self._api_client() as client:
data = await client.get_player_stats_summary(player_id, gamemode=gamemode, platform=platform)
full: dict = {}
try:
full = await client.get_player_full(player_id)
except Exception as fe:
logger.debug(f"完整数据获取失败: {fe}")
except ValueError as e:
err = str(e).lower()
if "not found" in err or "404" in err:
yield event.plain_result(f"未找到玩家 `{player_id}` 或资料私密。")
return
yield event.plain_result(self._api_error_reply(e, "获取统计失败,请稍后重试。"))
return
except Exception:
logger.debug("获取统计错误堆栈:", exc_info=True)
yield event.plain_result("获取统计信息时出错,请稍后重试。")
return
general = data.get("general", {}) if isinstance(data, dict) else {}
if not general:
yield event.plain_result(f"玩家 `{player_id}` | {GAMEMODE_MAPPING.get(gamemode, gamemode)}暂无统计数据。")
return
average = general.get("average", {}) if isinstance(general.get("average"), dict) else {}
games_played = general.get("games_played", 0) or 0
games_won = general.get("games_won", 0) or 0
games_lost = general.get("games_lost", 0) or 0
winrate = general.get("winrate", 0) or 0
kda = general.get("kda", 0) or 0
eliminations_avg = average.get("eliminations", 0) or 0
deaths_avg = average.get("deaths", 0) or 0
damage_avg = average.get("damage", 0) or 0
healing_avg = average.get("healing", 0) or 0
time_played = general.get("time_played", 0) or 0
summary = full.get("summary") if isinstance(full.get("summary"), dict) else {}
stats_full = full.get("stats") if isinstance(full.get("stats"), dict) else {}
shown_platform, rank_data = _build_rank_data(summary, platform) if summary else (platform, [])
comp_rank_rows = rank_data if gamemode == "competitive" else []
heroes_stats = data.get("heroes", {}) if isinstance(data.get("heroes"), dict) else {}
top_hero_rows = sorted(
((k, int(v.get("games_played") or 0)) for k, v in heroes_stats.items()
if isinstance(v, dict) and (v.get("games_played") or 0) > 0),
key=lambda kv: kv[1], reverse=True,
)[:3]
if not top_hero_rows and stats_full:
top_hero_rows = [(k, 0) for k, _ in _extract_top_heroes(stats_full, platform)]
gamemode_label = GAMEMODE_MAPPING.get(gamemode, gamemode)
if self.enable_image_render and _RENDERER_AVAILABLE:
try:
render_rank_rows = []
if comp_rank_rows:
icons = await self._download_many([r.get("rank_icon", "") for r in comp_rank_rows])
for r, b in zip(comp_rank_rows, icons):
render_rank_rows.append({"role_text": r["role_text"], "rank_text": r["rank_text"], "rank_icon_bytes": b})
portraits = await self._fetch_hero_portraits([k for k, _ in top_hero_rows])
render_heroes = [(_get_hero_name_cn(k), f"{_format_number(g)}场" if g else "常用英雄", portraits.get(k)) for k, g in top_hero_rows]
stat_rows = [
("场次", f"{_format_number(games_played)} ({_format_number(games_won)}胜/{_format_number(games_lost)}负)"),
("胜率", f"{winrate}%"),
("KDA", f"{kda:.2f}" if kda else "N/A"),
("消灭", f"{_format_number(eliminations_avg)}/场" if eliminations_avg else "N/A"),
("死亡", f"{_format_number(deaths_avg)}/场" if deaths_avg else "N/A"),
("伤害", f"{_format_number(damage_avg)}/场" if damage_avg else "N/A"),
("治疗", f"{_format_number(healing_avg)}/场" if healing_avg else "N/A"),
("游戏时间", _format_time_played(time_played)),
]
img_path = image_renderer.render_stats_card(
player_id=player_id, gamemode_label=gamemode_label,
platform_label=_get_platform_display(shown_platform),
stat_rows=stat_rows, rank_rows=render_rank_rows, top_heroes=render_heroes,
)
yield event.chain_result([Comp.Image.fromFileSystem(img_path)])
return
except Exception as e:
logger.warning(f"统计图片渲染失败,回退文字: {e}")
lines = [f"{player_id} | {gamemode_label} | {_get_platform_display(shown_platform)}", "━━━━━━━━━━━━"]
if comp_rank_rows:
lines.append(f"竞技段位 ({_get_platform_display(shown_platform)}):")
lines.extend(f" {r['role_text']}: {r['rank_text']}" for r in comp_rank_rows)
lines.extend([
f"场次: {_format_number(games_played)} ({_format_number(games_won)}胜/{_format_number(games_lost)}负)",
f"胜率: {winrate}%",
f"KDA: {kda:.2f}" if kda else "KDA: N/A",
f"消灭: {_format_number(eliminations_avg)}/场" if eliminations_avg else "消灭: N/A",
f"死亡: {_format_number(deaths_avg)}/场" if deaths_avg else "死亡: N/A",
f"伤害: {_format_number(damage_avg)}/场" if damage_avg else "伤害: N/A",
f"治疗: {_format_number(healing_avg)}/场" if healing_avg else "治疗: N/A",
f"游戏时间: {_format_time_played(time_played)}",
])
if top_hero_rows:
lines.append("常玩英雄: " + "、".join(f"{_get_hero_name_cn(k)}({_format_number(g)}场)" if g else _get_hero_name_cn(k) for k, g in top_hero_rows))
yield event.plain_result("\n".join(lines))
@filter.command("owcareer")
async def player_career(self, event: AstrMessageEvent, player_id: str = "", gamemode: str = "", hero: str = ""):
"""用法: /owcareer [玩家ID] <快速|竞技> [英雄]"""
bound_platform: str | None = None
from_binding = False
if not player_id or not player_id.strip():
bound_id, bound_platform = await self._get_binding(event)
if not bound_id:
yield event.plain_result("用法: /owcareer <玩家ID> <游戏模式> [英雄名]")
return
player_id = bound_id
from_binding = True
elif not gamemode or not gamemode.strip():
resolved = _resolve_gamemode(player_id)
if resolved in ("quickplay", "competitive"):
bound_id, bound_platform = await self._get_binding(event)
if bound_id:
gamemode = player_id
player_id = bound_id
from_binding = True
else:
yield event.plain_result("你还没有绑定 ID。/owbind <玩家ID> 后可 /owcareer 竞技 源氏")
return
platform = self._effective_platform(bound_platform)
resolved_mode = _resolve_gamemode(gamemode)
if not resolved_mode or resolved_mode not in ("quickplay", "competitive"):
yield event.plain_result("请输入有效模式。用法: /owcareer <玩家ID> <快速|竞技> [英雄]")
return
try:
player_id = self._prepare_player_id(player_id, from_binding)
except ValueError as e:
yield event.plain_result(f"玩家 ID 非法: {e}")
return
gamemode = resolved_mode
hero_key = _resolve_hero_name(hero) if hero else None
try:
async with self._api_client() as client:
data = await client.get_player_career_stats(player_id, gamemode=gamemode, platform=platform, hero=hero_key)
except ValueError as e:
err = str(e).lower()
if "not found" in err or "404" in err:
yield event.plain_result(f"未找到玩家 `{player_id}` 或资料私密。")
return
yield event.plain_result(self._api_error_reply(e, "获取生涯统计失败,请稍后重试。"))
return
except Exception:
logger.debug("生涯统计错误堆栈:", exc_info=True)
yield event.plain_result("获取生涯统计时出错,请稍后重试。")
return
if not data or not isinstance(data, dict):
yield event.plain_result(f"玩家 `{player_id}` 暂无生涯统计数据。")
return
hero_cards: list[tuple[str, str, list[tuple[str, str]]]] = []
remaining = 0
for hero_key_raw, categories in data.items():
if not isinstance(categories, dict):
continue
hero_name = _get_hero_name_cn(hero_key_raw)
rows: list[tuple[str, str]] = []
combat = categories.get("combat", {})
if combat:
eliminations = combat.get("eliminations")
deaths = combat.get("deaths")
kd = ""
if eliminations and deaths and int(deaths) > 0:
kd = f" (K/D: {float(eliminations) / float(deaths):.2f})"
elif eliminations:
kd = " (K/D: ∞)"
if eliminations is not None:
rows.append(("消灭", f"{_format_number(eliminations)}{kd}"))
if deaths is not None:
rows.append(("死亡", _format_number(deaths)))
if combat.get("damage_done") is not None:
rows.append(("伤害", _format_number(combat.get("damage_done"))))
if combat.get("healing_done") is not None:
rows.append(("治疗", _format_number(combat.get("healing_done"))))
game_stats = categories.get("game", {})
if game_stats:
gp, gw = game_stats.get("games_played"), game_stats.get("games_won")
if gp is not None and gw is not None:
gl = (gp or 0) - (gw or 0)
wr = (gw / gp * 100) if gp > 0 else 0
rows.append(("场次", f"{_format_number(gp)} ({_format_number(gw)}胜/{_format_number(gl)}负, {wr:.1f}%)"))
if game_stats.get("time_played"):
rows.append(("时间", _format_time_played(game_stats.get("time_played"))))
hero_cards.append((hero_key_raw, hero_name, rows))
if len(hero_cards) >= 8:
remaining = max(len(data) - 8, 0)
break
gamemode_label = GAMEMODE_MAPPING.get(gamemode, gamemode)
hero_filter_name = _get_hero_name_cn(hero_key) if hero_key else None
if self.enable_image_render and _RENDERER_AVAILABLE and hero_cards:
try:
portraits = await self._fetch_hero_portraits([k for k, _, _ in hero_cards])
render_heroes = [(name, rows, portraits.get(key)) for key, name, rows in hero_cards]
img_path = image_renderer.render_career_card(
player_id=player_id, gamemode_label=gamemode_label,
platform_label=_get_platform_display(platform),
hero_filter=hero_filter_name, heroes=render_heroes, remaining=remaining,
)
yield event.chain_result([Comp.Image.fromFileSystem(img_path)])
return
except Exception as e:
logger.warning(f"生涯图片渲染失败,回退文字: {e}")
hero_filter = f" | 英雄: {hero_filter_name}" if hero_filter_name else ""
lines = [f"{player_id} | {gamemode_label} | {_get_platform_display(platform)}{hero_filter}", "━━━━━━━━━━━━"]
for _, hero_name, rows in hero_cards:
lines.append(f"\n{hero_name}")
lines.extend(f" {label}: {value}" for label, value in rows)
if remaining > 0:
lines.append(f"\n... 还有 {remaining} 个英雄未显示")
if not hero_cards:
lines.append("暂无生涯统计数据。")
yield event.plain_result("\n".join(lines))
@filter.command("owhero")
async def hero_info(self, event: AstrMessageEvent, hero_name: str = ""):
"""用法: /owhero <英雄名>"""
if not hero_name or not hero_name.strip():
yield event.plain_result("请输入英雄名称。用法: /owhero <英雄名>")
return
hero_key = _resolve_hero_name(hero_name)
hero_stat: dict | None = None
try:
async with self._api_client() as client:
data = await client.get_hero_info(hero_key)
try:
all_stats = await client.get_heroes_stats(
platform=self.default_platform, gamemode=self.default_gamemode, region=self.default_region)
for item in all_stats or []:
if isinstance(item, dict) and item.get("hero") == hero_key:
hero_stat = item
break
except Exception as se:
logger.debug(f"英雄胜率获取失败: {se}")
except ValueError as e:
err = str(e).lower()
if "not found" in err or "404" in err:
yield event.plain_result(f"未找到英雄 `{hero_key}`。")
return
yield event.plain_result(self._api_error_reply(e, "获取英雄信息失败,请稍后重试。"))
return
except Exception:
logger.debug("英雄信息错误堆栈:", exc_info=True)
yield event.plain_result("获取英雄信息时出错,请稍后重试。")
return
name = data.get("name", hero_key)
description = data.get("description", "")
role = data.get("role", "")
hp = data.get("hitpoints", {}) if isinstance(data.get("hitpoints"), dict) else {}
portrait = data.get("portrait", "")
abilities = data.get("abilities", [])
story_summary = data.get("story", {}).get("summary", "") if isinstance(data.get("story"), dict) else ""
lines = [f"{name}", f"角色: {_get_role_display(role) if role else '未知'}",
f"生命: {hp.get('health', 0)}{' | 护甲: ' + str(hp.get('armor')) if hp.get('armor') else ''}{' | 护盾: ' + str(hp.get('shields')) if hp.get('shields') else ''}"]
if hero_stat:
lines.append(f"全服({_get_region_display(self.default_region)}|{GAMEMODE_MAPPING.get(self.default_gamemode, self.default_gamemode)}): 胜率 {(hero_stat.get('winrate') or 0):.1f}% | 选取率 {(hero_stat.get('pickrate') or 0):.1f}%")
lines.append("━━━━━━━━━━━━")
if abilities:
lines.append("技能:")
for ability in abilities[:8]:
if isinstance(ability, dict):
desc = ability.get("description", "")
if len(desc) > 80:
desc = desc[:77] + "..."
lines.append(f" • {ability.get('name', '')}: {desc}")
if story_summary:
s = story_summary[:197] + "..." if len(story_summary) > 200 else story_summary
lines += ["", "背景故事:", f" {s}"]
result_text = "\n".join(lines)
if portrait:
yield event.chain_result([Comp.Image.fromURL(portrait), Comp.Plain(result_text)])
else:
yield event.plain_result(result_text)
@filter.command("owherostats")
async def heroes_stats(self, event: AstrMessageEvent, role: str = "", region: str = ""):
"""用法: /owherostats [坦克|输出|支援] [亚服|美服|欧服]"""
role_filter: str | None = None
resolved_region: str | None = None
for arg in (role, region):
arg = (arg or "").strip()
if not arg:
continue
lowered = arg.lower()
if lowered in ROLE_CN_TO_EN or arg in ROLE_CN_TO_EN:
if role_filter is not None:
yield event.plain_result("角色参数重复。可选: 坦克、输出、支援")
return
role_filter = ROLE_CN_TO_EN.get(lowered) or ROLE_CN_TO_EN.get(arg)
elif _resolve_region(arg):
if resolved_region is not None:
yield event.plain_result("地区参数重复。可选: 亚服、美服、欧服")
return
resolved_region = _resolve_region(arg)
else:
yield event.plain_result(f"无法识别 `{arg}`。用法: /owherostats [角色] [地区]")
return
region_v = resolved_region or self.default_region
try:
async with self._api_client() as client:
stats = await client.get_heroes_stats(
platform=self.default_platform, gamemode=self.default_gamemode,
region=region_v, role=role_filter, order_by="winrate:desc")
except ValueError as e:
yield event.plain_result(self._api_error_reply(e, "获取英雄统计失败,请稍后重试。"))
return
except Exception:
logger.debug("英雄统计错误堆栈:", exc_info=True)
yield event.plain_result("获取英雄统计时出错,请稍后重试。")
return
if not stats or not isinstance(stats, list):
yield event.plain_result("暂无英雄统计数据。")
return
role_cn = ROLE_MAPPING.get(role_filter, (role_filter,))[0] if role_filter else ""
title = "英雄胜率排行"
subtitle = f"{_get_region_display(region_v)}{' | ' + role_cn if role_cn else ''} | {GAMEMODE_MAPPING.get(self.default_gamemode, self.default_gamemode)} | {_get_platform_display(self.default_platform)}"
rows: list[tuple[str, str]] = []
text_lines = [f"{title} | {subtitle}", "━━━━━━━━━━━━"]
for idx, item in enumerate(stats, 1):
if not isinstance(item, dict):
continue
hn = _get_hero_name_cn(item.get("hero", ""))
wr = item.get("winrate", 0) or 0
pr = item.get("pickrate", 0) or 0
rows.append((f"{idx:>2}. {hn}", f"胜率 {wr:.1f}% | 选取率 {pr:.1f}%"))
text_lines.append(f"{idx:>2}. {hn:<6} 胜率 {wr:.1f}% | 选取率 {pr:.1f}%")
text_lines += ["", "可加参数筛选: /owherostats 输出 欧服"]
if self.enable_image_render and _RENDERER_AVAILABLE:
try:
img_path = image_renderer.render_herostats_card(title, subtitle, rows, "可加参数筛选: /owherostats 输出 欧服")
yield event.chain_result([Comp.Image.fromFileSystem(img_path)])
return
except Exception as e:
logger.warning(f"榜单图片渲染失败,回退文字: {e}")
yield event.plain_result("\n".join(text_lines))
@filter.command("owbind")
async def bind_id(self, event: AstrMessageEvent, player_id: str = "", platform: str = ""):
"""用法: /owbind <玩家ID> [pc|主机]"""
if not player_id or not player_id.strip():
yield event.plain_result(f"请输入玩家 ID。用法: /owbind <玩家ID> [平台]\n最多绑定 {self.max_binds_per_user} 个,用 /owbinds 查看")
return
if platform and platform.strip():
rp = _resolve_platform(platform)
if not rp:
yield event.plain_result("平台无效。可选: pc(电脑端)、console(主机端)")
return
platform_v = rp
else:
platform_v = self.default_platform
try:
player_id = self._prepare_player_id(player_id, False)
except ValueError as e:
yield event.plain_result(f"玩家 ID 非法: {e}")
return
user_name = event.get_sender_name()
current = await self._load_bindings(event)
if not any(a["player_id"].lower() == player_id.lower() for a in current["accounts"]) and len(current["accounts"]) >= self.max_binds_per_user:
yield event.plain_result(f"{user_name} 最多绑定 {self.max_binds_per_user} 个账号。/owunbind 解绑后再试。")
return
try:
async with self._api_client() as client:
await client.get_player_summary(player_id)
except ValueError as e:
err = str(e).lower()
if "not found" in err or "404" in err:
yield event.plain_result(f"未找到玩家 `{player_id}`,请检查 ID。")
return
except Exception:
pass
result, data = await self._add_binding(event, player_id, platform_v)
if result == "limit":
yield event.plain_result(f"最多绑定 {self.max_binds_per_user} 个账号。")
elif result in ("added", "updated"):
action = "绑定" if result == "added" else "更新绑定"
count = len(data["accounts"]) if data else 1
yield event.plain_result(
f"{user_name} 已{action}: `{player_id}`\n平台: {_get_platform_display(platform_v)}(已设默认)\n"
f"已绑定 {count}/{self.max_binds_per_user}\n/owme 快捷查询,/owbinds 查看列表")
else:
yield event.plain_result("绑定失败,请稍后重试。")
@filter.command("owunbind")
async def unbind_id(self, event: AstrMessageEvent, player_id: str = ""):
user_name = event.get_sender_name()
target = _normalize_player_id(player_id) if player_id and player_id.strip() else None
result, removed_id = await self._remove_binding(event, target)
if result == "empty":
yield event.plain_result(f"{user_name} 还没有绑定任何 ID。/owbind <玩家ID> 绑定。")
elif result == "not_found":
yield event.plain_result(f"你没有绑定 `{target}`。/owbinds 查看列表。")
elif result == "removed":
remaining = await self._load_bindings(event)
tip = f"剩余 {len(remaining['accounts'])} 个,默认 `{remaining['default']}`" if remaining["accounts"] else "需重新 /owbind 绑定"
yield event.plain_result(f"已解绑: `{removed_id}`\n{tip}")
else:
yield event.plain_result("解绑失败,请稍后重试。")
@filter.command("owbinds")
async def list_bindings(self, event: AstrMessageEvent):
user_name = event.get_sender_name()
data = await self._load_bindings(event)
accounts = data["accounts"]
if not accounts:
yield event.plain_result(f"{user_name} 还没有绑定 ID。/owbind <玩家ID> 绑定。")
return
default = data.get("default")
lines = [f"{user_name} 的绑定列表 ({len(accounts)}/{self.max_binds_per_user}):", "━━━━━━━━━━━━"]
for idx, acc in enumerate(accounts, 1):
mark = " 默认" if acc["player_id"] == default else ""
lines.append(f"{idx}. `{acc['player_id']}` | {_get_platform_display(acc.get('platform', self.default_platform))}{mark}")
lines += ["", "/owdefault <玩家ID> 切换默认,/owunbind [玩家ID] 解绑"]
yield event.plain_result("\n".join(lines))
@filter.command("owdefault")
async def set_default_binding(self, event: AstrMessageEvent, player_id: str = ""):
user_name = event.get_sender_name()
if not player_id or not player_id.strip():
yield event.plain_result("用法: /owdefault <玩家ID>")
return
target = _normalize_player_id(player_id)
data = await self._load_bindings(event)
if not data["accounts"]:
yield event.plain_result(f"{user_name} 还没有绑定 ID。")
return
matched = next((a for a in data["accounts"] if a["player_id"].lower() == target.lower()), None)
if not matched:
yield event.plain_result(f"你没有绑定 `{target}`。/owbinds 查看列表。")
return
data["default"] = matched["player_id"]
if await self._save_bindings(event, data):
yield event.plain_result(f"已设默认: `{matched['player_id']}`")
else:
yield event.plain_result("设置失败,请稍后重试。")
@filter.command("owme")
async def quick_summary(self, event: AstrMessageEvent):
bound_id, bound_platform = await self._get_binding(event)
user_name = event.get_sender_name()
if not bound_id:
yield event.plain_result(f"{user_name} 还没有绑定 ID。/owbind <玩家ID> 绑定。")
return
platform = self._effective_platform(bound_platform)
try:
async with self._api_client() as client:
full = await client.get_player_full(bound_id)
except ValueError as e:
err = str(e).lower()
if "not found" in err or "未找到" in err or "404" in err:
yield event.plain_result(f"未找到绑定玩家 `{bound_id}`,可能改名/私密,请重绑。")
return
yield event.plain_result(self._api_error_reply(e, "获取玩家信息失败,请稍后重试。"))
return
except Exception:
logger.debug("owme 错误堆栈:", exc_info=True)
yield event.plain_result("获取玩家信息时出错,请稍后重试。")
return
async for res in self._send_summary_result(
event, full, bound_id, platform,
footer_note=f"快捷查询 | {bound_id} | {_get_platform_display(platform)}",
extra_lines=["", "/owstats [模式] 统计,/owcareer <模式> [英雄] 生涯,/owunbind 解绑"],
):
yield res
+6
View File
@@ -0,0 +1,6 @@
name: astrbot_plugin_owcx
display_name: 守望先锋查询
desc: Overwatch 查询插件 - 基于 OverFast API 查询玩家战绩、英雄信息等
version: v2.3.1 # 插件版本号。格式:v1.1.1 或者 v1.1
author: TZYCeng # 作者
repo: https://github.com/TZYCeng/astrbot_plugin_owcx # 插件的仓库地址
+2
View File
@@ -0,0 +1,2 @@
aiohttp>=3.8.0
Pillow>=9.0.0
+224
View File
@@ -0,0 +1,224 @@
"""纯函数工具:归一化/解析/格式化/数据提取,可单测。"""
from __future__ import annotations
import re
from urllib.parse import quote
try:
from .constants import (
GAMEMODE_REVERSE_MAPPING,
HERO_NAME_MAPPING,
HERO_NAME_REVERSE_MAPPING,
PLATFORM_MAPPING,
PLATFORM_REVERSE_MAPPING,
RANK_MAPPING,
REGION_MAPPING,
REGION_REVERSE_MAPPING,
ROLE_MAPPING,
)
except ImportError: # 作为独立模块导入时回退
from constants import (
GAMEMODE_REVERSE_MAPPING,
HERO_NAME_MAPPING,
HERO_NAME_REVERSE_MAPPING,
PLATFORM_MAPPING,
PLATFORM_REVERSE_MAPPING,
RANK_MAPPING,
REGION_MAPPING,
REGION_REVERSE_MAPPING,
ROLE_MAPPING,
)
_PLAYER_ID_RE = re.compile(r"^[A-Za-z0-9\u4e00-\u9fa5_\- ]{3,32}$")
_TIER_ROMAN = ["", "I", "II", "III", "IV", "V"]
DEFAULT_API_BASE_URL = "https://overfast-api.tekrop.fr"
def normalize_api_base_url(raw: str | None) -> str:
"""归一化 API 地址:去空格/去尾斜杠,无效时回退官方地址。"""
url = (raw or "").strip().rstrip("/")
if not url or not url.lower().startswith(("http://", "https://")):
return DEFAULT_API_BASE_URL
return url
def normalize_player_id(player_id: str) -> str:
"""# -> - 并 strip,不做合法性校验。"""
return player_id.replace("#", "-").strip()
def validate_player_id(player_id: str) -> str:
"""归一化 + 合法性校验,非法时抛 ValueError。
允许字母/数字/中文/_/-/空格,长度归一化后 3~32,且必须含 - 分隔符。
返回可直接拼 URL path 的 quote 结果。
"""
norm = normalize_player_id(player_id)
if len(norm) < 3 or len(norm) > 32:
raise ValueError(f"玩家 ID 长度非法: {player_id!r}")
if "/" in player_id or "\\" in player_id or ".." in norm:
raise ValueError(f"玩家 ID 非法: {player_id!r}")
# BattleTag 归一化后应为 Name-XXXX 形式;宽松校验:至少含 - 或纯字母数字
if not _PLAYER_ID_RE.match(norm):
raise ValueError(f"玩家 ID 含非法字符: {player_id!r}")
return norm
def quote_player_id(player_id: str) -> str:
"""返回 URL-safe 的玩家 ID(已归一化+quote)。"""
return quote(validate_player_id(player_id), safe="-")
def get_rank_display(division: str | None, tier: int | None) -> str:
if not division:
return "未定位"
rank_info = RANK_MAPPING.get(division.lower(), (division, ""))
tier_str = _TIER_ROMAN[tier] if tier and 1 <= tier <= 5 else ""
name = rank_info[0]
return f"{name}{' ' + tier_str if tier_str else ''}".strip()
def build_rank_data(summary: dict, platform: str) -> tuple[str, list[dict]]:
comp = summary.get("competitive")
if not isinstance(comp, dict):
comp = summary.get("competitive_ranks")
comp = comp if isinstance(comp, dict) else {}
shown_platform = platform
container = comp.get(platform)
if not isinstance(container, dict) or not container:
other_platform = "console" if platform == "pc" else "pc"
other = comp.get(other_platform)
if isinstance(other, dict) and other:
shown_platform = other_platform
container = other
else:
container = None
rows: list[dict] = []
if container:
for role_key in ["tank", "damage", "support"]:
role_cn = ROLE_MAPPING.get(role_key, (role_key, ""))[0]
role_text = f"{role_cn}"
role_data = container.get(role_key)
if isinstance(role_data, dict) and role_data.get("division"):
rows.append({
"role_text": role_text,
"rank_text": get_rank_display(role_data.get("division"), role_data.get("tier")),
"rank_icon": role_data.get("rank_icon") or "",
})
else:
rows.append({"role_text": role_text, "rank_text": "未定位", "rank_icon": ""})
return shown_platform, rows
def extract_top_heroes(stats: dict, platform: str, count: int = 3) -> list[tuple[str, int]]:
if not isinstance(stats, dict):
return []
for plat in (platform, "console" if platform == "pc" else "pc"):
plat_stats = stats.get(plat)
if not isinstance(plat_stats, dict):
continue
totals: dict[str, int] = {}
for gamemode in ("quickplay", "competitive"):
gm_stats = plat_stats.get(gamemode)
if not isinstance(gm_stats, dict):
continue
comparisons = gm_stats.get("heroes_comparisons")
if not isinstance(comparisons, dict):
continue
time_played = comparisons.get("time_played")
if not isinstance(time_played, dict):
continue
for item in time_played.get("values") or []:
if not isinstance(item, dict):
continue
hero = item.get("hero")
value = item.get("value") or 0
if hero:
try:
totals[hero] = totals.get(hero, 0) + int(value)
except (TypeError, ValueError):
continue
if totals:
return sorted(totals.items(), key=lambda kv: kv[1], reverse=True)[:count]
return []
def get_role_display(role_key: str) -> str:
return ROLE_MAPPING.get(role_key.lower(), (role_key, ""))[0]
def get_hero_name_cn(hero_key: str) -> str:
return HERO_NAME_MAPPING.get(hero_key.lower(), hero_key)
def resolve_hero_name(hero_input: str) -> str:
hero_input = hero_input.strip()
lowered = hero_input.lower()
if lowered in HERO_NAME_MAPPING:
return lowered
if hero_input in HERO_NAME_REVERSE_MAPPING:
return HERO_NAME_REVERSE_MAPPING[hero_input]
for cn_name, en_key in HERO_NAME_REVERSE_MAPPING.items():
if lowered in cn_name.lower() or cn_name.lower() in lowered:
return en_key
return lowered
def resolve_platform(platform_input: str) -> str | None:
if not platform_input:
return None
return PLATFORM_REVERSE_MAPPING.get(platform_input.strip().lower())
def get_platform_display(platform: str) -> str:
return PLATFORM_MAPPING.get(platform, platform)
def resolve_region(region_input: str) -> str | None:
if not region_input:
return None
return REGION_REVERSE_MAPPING.get(region_input.strip().lower())
def get_region_display(region: str) -> str:
return REGION_MAPPING.get(region, region)
def resolve_gamemode(mode_input: str) -> str | None:
if not mode_input:
return None
return GAMEMODE_REVERSE_MAPPING.get(mode_input.strip().lower())
def format_time_played(seconds: int | float | None) -> str:
if not seconds:
return "0小时"
total_minutes = int(seconds) // 60
hours = total_minutes // 60
minutes = total_minutes % 60
if hours > 0 and minutes > 0:
return f"{hours}小时{minutes}分钟"
elif hours > 0:
return f"{hours}小时"
return f"{minutes}分钟"
def format_time_short(seconds: int | float | None) -> str:
if not seconds:
return "0分钟"
total_minutes = int(seconds) // 60
hours = total_minutes / 60
if hours >= 1:
return f"{hours:.1f}小时"
return f"{total_minutes}分钟"
def format_number(num: int | float | None) -> str:
if num is None:
return "0"
if isinstance(num, float):
return f"{num:,.1f}" if num != int(num) else f"{int(num):,}"
return f"{num:,}"