nonebot-plugin-R6States 1.0.0__tar.gz → 1.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.0}/PKG-INFO +3 -9
- {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.0}/README.md +2 -8
- {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.0}/pyproject.toml +1 -1
- {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.0}/src/nonebot_plugin_R6States/__init__.py +41 -27
- {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.0}/src/nonebot_plugin_R6States/cache.py +7 -15
- nonebot_plugin_r6states-1.1.0/src/nonebot_plugin_R6States/config.py +13 -0
- {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.0}/src/nonebot_plugin_R6States/config_mannger.py +0 -11
- {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.0}/src/nonebot_plugin_R6States/formatter.py +18 -3
- {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.0}/src/nonebot_plugin_R6States/renderer.py +7 -14
- {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.0}/src/nonebot_plugin_R6States/service.py +31 -10
- nonebot_plugin_r6states-1.0.0/src/nonebot_plugin_R6States/config.py +0 -12
- {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.0}/LICENSE +0 -0
- {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.0}/src/nonebot_plugin_R6States/assets/MonaSans.ttf +0 -0
- {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.0}/src/nonebot_plugin_R6States/assets/NotoSansSC.ttf +0 -0
- {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.0}/src/nonebot_plugin_R6States/r6data.py +0 -0
- {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.0}/src/nonebot_plugin_R6States/storage.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: nonebot-plugin-R6States
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: 查询指定玩家的各项数据
|
|
5
5
|
Author-Email: Siornya <wxh200607@outlook.com>
|
|
6
6
|
License: MIT
|
|
@@ -43,16 +43,10 @@ Description-Content-Type: text/markdown
|
|
|
43
43
|
|
|
44
44
|
可以直接将 `nonebot_plugin_R6States` 文件夹放入插件目录中。
|
|
45
45
|
|
|
46
|
-
###
|
|
46
|
+
### 数据查询
|
|
47
47
|
|
|
48
48
|
```text
|
|
49
|
-
/R6 <
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
### 多人查询
|
|
53
|
-
|
|
54
|
-
```text
|
|
55
|
-
/R6 -g <id1> <id2> ... <idN>
|
|
49
|
+
/R6 <player_ids...>
|
|
56
50
|
```
|
|
57
51
|
|
|
58
52
|
### 帮助信息
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from nonebot import on_command, logger, get_driver
|
|
2
4
|
from nonebot.adapters import Message
|
|
3
5
|
from nonebot.params import CommandArg
|
|
4
6
|
from nonebot.plugin import PluginMetadata, get_plugin_config
|
|
5
7
|
from nonebot.adapters.onebot.v11 import MessageEvent, MessageSegment, GroupMessageEvent
|
|
6
8
|
|
|
7
9
|
from .config import Config
|
|
8
|
-
from .service import VALID_PLATFORMS, ServiceError, get_full_stats
|
|
10
|
+
from .service import VALID_PLATFORMS, ServiceError, aclose, get_full_stats
|
|
9
11
|
from .formatter import format_full_stats
|
|
10
12
|
from .renderer import render_full_stats
|
|
11
13
|
from .config_mannger import (
|
|
@@ -18,7 +20,7 @@ from .config_mannger import (
|
|
|
18
20
|
__plugin_meta__ = PluginMetadata(
|
|
19
21
|
name="彩六数据查询",
|
|
20
22
|
description="查询指定玩家的数据",
|
|
21
|
-
usage="/r6 <id
|
|
23
|
+
usage="/r6 <id...> [平台] /r6key <key> /r6help",
|
|
22
24
|
homepage="https://github.com/Siornya/nonebot-plugin-R6States",
|
|
23
25
|
type="application",
|
|
24
26
|
config=Config,
|
|
@@ -27,28 +29,32 @@ __plugin_meta__ = PluginMetadata(
|
|
|
27
29
|
|
|
28
30
|
plugin_config = get_plugin_config(Config)
|
|
29
31
|
|
|
32
|
+
|
|
33
|
+
@get_driver().on_shutdown
|
|
34
|
+
async def _shutdown():
|
|
35
|
+
await aclose()
|
|
36
|
+
|
|
37
|
+
|
|
30
38
|
r6 = on_command("r6", aliases={"R6"}, priority=10, block=True)
|
|
31
39
|
r6_key = on_command("r6key", aliases={"R6key", "R6DAPI", "r6dapi"}, priority=5, block=True)
|
|
32
40
|
r6_help = on_command("r6help", aliases={"R6help"}, priority=5, block=True)
|
|
33
41
|
|
|
34
42
|
HELP_TEXT = (
|
|
35
43
|
"彩六数据查询\n"
|
|
36
|
-
"/r6 <id
|
|
44
|
+
"/r6 <id...> [平台] 查询玩家数据(最多5个,空格分隔;平台默认 uplay,可选 psn/xbl)\n"
|
|
37
45
|
"/r6key <key> 设置本群/本人的 r6data API Key\n"
|
|
38
|
-
"/r6help
|
|
39
|
-
"
|
|
46
|
+
"/r6help 显示帮助\n"
|
|
47
|
+
"聊群中API KEY优先用个人 Key,没有则用群 Key"
|
|
40
48
|
)
|
|
41
49
|
|
|
42
50
|
|
|
43
51
|
def _scope_id(event: MessageEvent) -> str:
|
|
44
|
-
"""设置 key 的归属:群聊按群、私聊按人。"""
|
|
45
52
|
if isinstance(event, GroupMessageEvent):
|
|
46
53
|
return str(event.group_id)
|
|
47
54
|
return str(event.user_id)
|
|
48
55
|
|
|
49
56
|
|
|
50
57
|
def _lookup_scopes(event: MessageEvent) -> list[str]:
|
|
51
|
-
"""查询时的 key 候选顺序:个人优先,个人没设则回退到群。"""
|
|
52
58
|
if isinstance(event, GroupMessageEvent):
|
|
53
59
|
return [str(event.user_id), str(event.group_id)]
|
|
54
60
|
return [str(event.user_id)]
|
|
@@ -64,7 +70,7 @@ async def _(event: MessageEvent, args: Message = CommandArg()):
|
|
|
64
70
|
key = args.extract_plain_text().strip()
|
|
65
71
|
if not key:
|
|
66
72
|
await r6_key.finish(
|
|
67
|
-
"请在命令后输入 API Key,例如:/r6key
|
|
73
|
+
"请在命令后输入 API Key,例如:/r6key <key>\n"
|
|
68
74
|
"没有的话可在 https://r6data.com/ 免费获取"
|
|
69
75
|
)
|
|
70
76
|
|
|
@@ -78,7 +84,7 @@ async def _(event: MessageEvent, args: Message = CommandArg()):
|
|
|
78
84
|
async def _(event: MessageEvent, args: Message = CommandArg()):
|
|
79
85
|
tokens = args.extract_plain_text().split()
|
|
80
86
|
if not tokens:
|
|
81
|
-
await r6.finish("用法:/r6 <id
|
|
87
|
+
await r6.finish("用法:/r6 <id...> [平台],详见 /r6help")
|
|
82
88
|
|
|
83
89
|
# 末尾 token 若是平台名则作为平台,其余都当作玩家 id。
|
|
84
90
|
platform = "uplay"
|
|
@@ -97,21 +103,29 @@ async def _(event: MessageEvent, args: Message = CommandArg()):
|
|
|
97
103
|
if age is not None and age >= KEY_TTL_DAYS:
|
|
98
104
|
await r6.send(f"⚠️ 当前 API Key 已设置 {age:.0f} 天,可能已过期,如查询失败请 /r6key 重设")
|
|
99
105
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
await r6.send(
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
logger.error(f"查询 {player_id} 出错: {type(e).__name__}: {e}")
|
|
106
|
+
# 并发取数(单个失败不连累其余),再按原顺序逐个发送
|
|
107
|
+
results = await asyncio.gather(
|
|
108
|
+
*(
|
|
109
|
+
get_full_stats(pid, scopes, platform, season_year=plugin_config.current_season)
|
|
110
|
+
for pid in tokens
|
|
111
|
+
),
|
|
112
|
+
return_exceptions=True,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
for player_id, data in zip(tokens, results):
|
|
116
|
+
if isinstance(data, ServiceError):
|
|
117
|
+
logger.warning(f"查询 {player_id} 失败: {data.message}")
|
|
118
|
+
await r6.send(f"❌ {player_id}:{data.message}")
|
|
119
|
+
continue
|
|
120
|
+
if isinstance(data, BaseException):
|
|
121
|
+
logger.error(f"查询 {player_id} 出错: {type(data).__name__}: {data}")
|
|
117
122
|
await r6.send(f"❌ {player_id}:查询失败")
|
|
123
|
+
continue
|
|
124
|
+
if plugin_config.r6_output_image:
|
|
125
|
+
try:
|
|
126
|
+
png = render_full_stats(player_id, data)
|
|
127
|
+
await r6.send(MessageSegment.image(png))
|
|
128
|
+
continue
|
|
129
|
+
except Exception as e: # noqa: BLE001 - 渲染失败回退文本
|
|
130
|
+
logger.warning(f"图片渲染失败,回退文本: {type(e).__name__}: {e}")
|
|
131
|
+
await r6.send(format_full_stats(player_id, data))
|
{nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.0}/src/nonebot_plugin_R6States/cache.py
RENAMED
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
"""轻量本地缓存:单 JSON 文件 + 每条目独立 TTL + 异步锁 + 原子写。
|
|
2
|
-
|
|
3
|
-
设计动机(对比旧的 players.yaml):
|
|
4
|
-
- 旧实现按**整文件** mtime 判 TTL,一次写入会重置所有条目的有效期,过期还整包丢弃。
|
|
5
|
-
- 这里每条目自带写入时间戳,**各自过期**;TTL 由调用方按 endpoint 指定。
|
|
6
|
-
- 写入走 "临时文件 + os.replace" 原子替换,配异步锁,避免并发下半写/相互覆盖。
|
|
7
|
-
"""
|
|
8
1
|
from __future__ import annotations
|
|
9
2
|
|
|
10
3
|
import os
|
|
@@ -29,7 +22,6 @@ class JSONCache:
|
|
|
29
22
|
data = json.load(f)
|
|
30
23
|
return data if isinstance(data, dict) else {}
|
|
31
24
|
except (json.JSONDecodeError, OSError):
|
|
32
|
-
# 缓存损坏不应影响主流程:当作空缓存重建。
|
|
33
25
|
return {}
|
|
34
26
|
|
|
35
27
|
def _atomic_write(self, data: dict[str, dict[str, Any]]) -> None:
|
|
@@ -46,18 +38,18 @@ class JSONCache:
|
|
|
46
38
|
pass
|
|
47
39
|
raise
|
|
48
40
|
|
|
49
|
-
async def get(self, key: str
|
|
50
|
-
"""命中且未超过 ttl(秒)则返回缓存值,否则返回 None。"""
|
|
41
|
+
async def get(self, key: str) -> Optional[Any]:
|
|
51
42
|
async with self._lock:
|
|
52
43
|
entry = self._load().get(key)
|
|
53
|
-
if not entry:
|
|
54
|
-
return None
|
|
55
|
-
if time.time() - entry.get("ts", 0) > ttl:
|
|
44
|
+
if not entry or entry.get("exp", 0) < time.time():
|
|
56
45
|
return None
|
|
57
46
|
return entry.get("value")
|
|
58
47
|
|
|
59
|
-
async def set(self, key: str, value: Any) -> None:
|
|
48
|
+
async def set(self, key: str, value: Any, ttl: float) -> None:
|
|
49
|
+
"""写入并带上过期时间;顺手剔除已过期条目,避免文件无限增长。"""
|
|
50
|
+
now = time.time()
|
|
60
51
|
async with self._lock:
|
|
61
52
|
data = self._load()
|
|
62
|
-
data[key] = {"
|
|
53
|
+
data[key] = {"exp": now + ttl, "value": value}
|
|
54
|
+
data = {k: v for k, v in data.items() if v.get("exp", 0) > now}
|
|
63
55
|
self._atomic_write(data)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Config(BaseModel):
|
|
7
|
+
# 当前赛季代码,查询默认按此赛季过滤。填 "all" 则不过滤、查生涯。
|
|
8
|
+
# 在 .env 里用 CURRENT_SEASON 覆盖
|
|
9
|
+
current_season: str = "Y11S2"
|
|
10
|
+
|
|
11
|
+
# 查询结果渲染成图片,失败时自动回退文本
|
|
12
|
+
# 在 .env 用 R6_OUTPUT_IMAGE 覆盖
|
|
13
|
+
r6_output_image: bool = True
|
|
@@ -1,9 +1,3 @@
|
|
|
1
|
-
"""按群/按人管理 r6data api-key。
|
|
2
|
-
|
|
3
|
-
落盘格式(带设置时间戳,用于过期提醒;key 官方有效期约 1 个月)::
|
|
4
|
-
|
|
5
|
-
{"apikeys": {"<id>": {"key": "...", "set_at": 1712345678.0}}}
|
|
6
|
-
"""
|
|
7
1
|
from __future__ import annotations
|
|
8
2
|
|
|
9
3
|
import json
|
|
@@ -53,10 +47,6 @@ def get_apikey(target_id: str) -> Optional[str]:
|
|
|
53
47
|
|
|
54
48
|
|
|
55
49
|
def resolve_apikey(scopes: list[str]) -> tuple[Optional[str], Optional[str]]:
|
|
56
|
-
"""按候选顺序找第一个有 key 的归属,返回 (key, 命中的 scope)。
|
|
57
|
-
|
|
58
|
-
群聊传 [群号, 用户号]:群没设 key 时回退到个人 key。
|
|
59
|
-
"""
|
|
60
50
|
for scope in scopes:
|
|
61
51
|
key = get_apikey(scope)
|
|
62
52
|
if key:
|
|
@@ -65,7 +55,6 @@ def resolve_apikey(scopes: list[str]) -> tuple[Optional[str], Optional[str]]:
|
|
|
65
55
|
|
|
66
56
|
|
|
67
57
|
def get_apikey_age_days(target_id: str) -> Optional[float]:
|
|
68
|
-
"""key 已设置的天数;旧格式/未设置返回 None。"""
|
|
69
58
|
entry = _entry(target_id)
|
|
70
59
|
if not entry:
|
|
71
60
|
return None
|
|
@@ -18,9 +18,21 @@ _BOARD_CN = {
|
|
|
18
18
|
"event": "活动", "warmup": "热身",
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
#: 1000 起每档 100 分、每大段 5 小级;<1000 未定级,>=4500 冠军。
|
|
22
|
+
_RANK_TIERS = ("紫铜", "青铜", "白银", "黄金", "铂金", "翡翠", "钻石")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def rank_name(mmr: int) -> str:
|
|
26
|
+
if mmr < 1000:
|
|
27
|
+
return "未定级"
|
|
28
|
+
if mmr >= 4500:
|
|
29
|
+
return "冠军"
|
|
30
|
+
idx = (mmr - 1000) // 100
|
|
31
|
+
return f"{_RANK_TIERS[idx // 5]}{5 - idx % 5}"
|
|
32
|
+
|
|
21
33
|
|
|
22
34
|
def _format_boards(profiles: list[dict[str, Any]]) -> list[str]:
|
|
23
|
-
"""各 board
|
|
35
|
+
"""各 board 取最新赛季档案,输出概览行;排位行补段位名。"""
|
|
24
36
|
out: list[str] = []
|
|
25
37
|
for fam in profiles:
|
|
26
38
|
for board in fam.get("board_ids_full_profiles") or []:
|
|
@@ -33,9 +45,12 @@ def _format_boards(profiles: list[dict[str, Any]]) -> list[str]:
|
|
|
33
45
|
kills, deaths = p.get("kills", 0), p.get("deaths", 0)
|
|
34
46
|
wr = wins / (wins + losses) * 100 if (wins + losses) else 0
|
|
35
47
|
kd = kills / deaths if deaths else float(kills)
|
|
36
|
-
|
|
48
|
+
bid = board.get("board_id", "")
|
|
49
|
+
name = _BOARD_CN.get(bid, bid or "?")
|
|
50
|
+
rp = p.get("rank_points", 0)
|
|
51
|
+
rank_str = f"{rank_name(rp)} " if bid == "ranked" else ""
|
|
37
52
|
out.append(
|
|
38
|
-
f"{name} RP{
|
|
53
|
+
f"{name} {rank_str}RP{rp}({p.get('max_rank_points', 0)}) "
|
|
39
54
|
f"胜负{wins}/{losses}({wr:.0f}%) KD{kd:.2f} 掉线{p.get('abandon', 0)}"
|
|
40
55
|
)
|
|
41
56
|
return out
|
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
"""把 fullStats 渲染成图片(PNG 字节)。
|
|
2
|
-
|
|
3
|
-
- 双字体:拉丁/数字用 Mona Sans,汉字用 Noto Sans CJK,按字符分段、基线对齐。
|
|
4
|
-
- 2x 超采样:按 SCALE 倍分辨率绘制,避免客户端放大时发虚。
|
|
5
|
-
- 干员区为对齐表格,数字右对齐;攻/防用不同颜色。
|
|
6
|
-
内容提取复用 formatter 的 _format_boards(档案行);干员直接读结构化字段以便排版。
|
|
7
|
-
"""
|
|
8
1
|
from __future__ import annotations
|
|
9
2
|
|
|
10
3
|
from io import BytesIO
|
|
@@ -33,12 +26,12 @@ _DEF = (94, 160, 232) # 防
|
|
|
33
26
|
_SCALE = 2 # 超采样倍率
|
|
34
27
|
_W = 760 * _SCALE
|
|
35
28
|
_PAD = 36 * _SCALE
|
|
29
|
+
_PER_SIDE = 4 # 攻/防各展示几个干员
|
|
36
30
|
|
|
37
31
|
_font_cache: dict[tuple[str, int, Optional[int]], ImageFont.FreeTypeFont] = {}
|
|
38
32
|
|
|
39
33
|
|
|
40
34
|
def _font(path: str, size: int, weight: Optional[int] = None) -> ImageFont.FreeTypeFont:
|
|
41
|
-
"""按字号取字体;weight 给定时尝试调可变字重(失败则忽略)。"""
|
|
42
35
|
key = (path, size * _SCALE, weight)
|
|
43
36
|
if key in _font_cache:
|
|
44
37
|
return _font_cache[key]
|
|
@@ -100,7 +93,7 @@ def _ascent(size: int) -> int:
|
|
|
100
93
|
return _font(_NOTO, size).getmetrics()[0]
|
|
101
94
|
|
|
102
95
|
|
|
103
|
-
def render_full_stats(player_id: str, data: dict[str, Any]
|
|
96
|
+
def render_full_stats(player_id: str, data: dict[str, Any]) -> bytes:
|
|
104
97
|
info = data.get("data") or {}
|
|
105
98
|
handle = (info.get("platformInfo") or {}).get("platformUserHandle") or player_id
|
|
106
99
|
meta = info.get("metadata") or {}
|
|
@@ -149,18 +142,18 @@ def render_full_stats(player_id: str, data: dict[str, Any], top_n: int = 8) -> b
|
|
|
149
142
|
section("干员 Top")
|
|
150
143
|
by_rounds = lambda o: o.get("roundsPlayed", 0) # noqa: E731
|
|
151
144
|
atks = sorted((o for o in operators if o.get("side") == "Attacker"),
|
|
152
|
-
key=by_rounds, reverse=True)[:
|
|
145
|
+
key=by_rounds, reverse=True)[:_PER_SIDE]
|
|
153
146
|
defs = sorted((o for o in operators if o.get("side") == "Defender"),
|
|
154
|
-
key=by_rounds, reverse=True)[:
|
|
147
|
+
key=by_rounds, reverse=True)[:_PER_SIDE]
|
|
155
148
|
|
|
156
149
|
gap = 48 * _SCALE
|
|
157
150
|
col_w = (_W - 2 * _PAD - gap) // 2
|
|
158
151
|
lx, rx = p, p + col_w + gap
|
|
159
152
|
|
|
160
|
-
#
|
|
153
|
+
# 列头
|
|
161
154
|
y += _ascent(19)
|
|
162
|
-
_draw(draw, lx, y, "
|
|
163
|
-
_draw(draw, rx, y, "
|
|
155
|
+
_draw(draw, lx, y, "进攻方", 19, _ATK, weight=700)
|
|
156
|
+
_draw(draw, rx, y, "防守方", 19, _DEF, weight=700)
|
|
164
157
|
y += _font(_NOTO, 19).getmetrics()[1] + 12 * _SCALE
|
|
165
158
|
|
|
166
159
|
def _cell(cx: int, base: int, op: dict[str, Any]) -> None:
|
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
"""数据源(API 为主)+ 缓存层。
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
对外暴露 ``get_full_stats``:先查本地缓存,未命中再打 r6data 的 fullStats,
|
|
4
4
|
成功后回写缓存。错误统一转成 ``ServiceError``,由指令层决定怎么回话。
|
|
5
|
+
复用一个进程级共享的 httpx 客户端(连接池复用),指令层须在 on_shutdown 调 ``aclose``。
|
|
5
6
|
"""
|
|
6
7
|
from __future__ import annotations
|
|
7
8
|
|
|
8
|
-
from typing import Any
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
9
12
|
|
|
10
13
|
from .cache import JSONCache
|
|
11
14
|
from .storage import PLAYER_CACHE_FILE
|
|
12
15
|
from .r6data import R6Client, R6APIError
|
|
13
16
|
from .config_mannger import resolve_apikey
|
|
14
17
|
|
|
15
|
-
#: 玩家快照属于会变的数据,缓存
|
|
16
|
-
FULL_STATS_TTL =
|
|
18
|
+
#: 玩家快照属于会变的数据,缓存 45 分钟即可显著降低 API 用量。
|
|
19
|
+
FULL_STATS_TTL = 45 * 60
|
|
17
20
|
|
|
18
21
|
VALID_PLATFORMS = ("uplay", "psn", "xbl")
|
|
19
22
|
|
|
@@ -22,6 +25,24 @@ APIKEY_HELP = "可在 https://r6data.com/ 免费获取 Key,再用 /r6key <key>
|
|
|
22
25
|
|
|
23
26
|
_cache = JSONCache(PLAYER_CACHE_FILE)
|
|
24
27
|
|
|
28
|
+
#: 进程级共享 httpx 客户端,注入给每个 R6Client,避免每次查询重建连接池/握手。
|
|
29
|
+
_http: Optional[httpx.AsyncClient] = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _client() -> httpx.AsyncClient:
|
|
33
|
+
global _http
|
|
34
|
+
if _http is None or _http.is_closed:
|
|
35
|
+
_http = httpx.AsyncClient(timeout=15.0)
|
|
36
|
+
return _http
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def aclose() -> None:
|
|
40
|
+
"""关闭共享 httpx 客户端(在 nonebot on_shutdown 调用)。"""
|
|
41
|
+
global _http
|
|
42
|
+
if _http is not None and not _http.is_closed:
|
|
43
|
+
await _http.aclose()
|
|
44
|
+
_http = None
|
|
45
|
+
|
|
25
46
|
|
|
26
47
|
class ServiceError(Exception):
|
|
27
48
|
def __init__(self, message: str, *, expired: bool = False) -> None:
|
|
@@ -45,15 +66,15 @@ async def get_full_stats(
|
|
|
45
66
|
raise ServiceError(f"未设置 API Key。{APIKEY_HELP}")
|
|
46
67
|
|
|
47
68
|
cache_key = f"fullStats:{platform}:{season_year or 'all'}:{modes or 'all'}:{player_id.lower()}"
|
|
48
|
-
cached = await _cache.get(cache_key
|
|
69
|
+
cached = await _cache.get(cache_key)
|
|
49
70
|
if cached is not None:
|
|
50
71
|
return cached
|
|
51
72
|
|
|
52
73
|
try:
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
74
|
+
r6 = R6Client(api_key=api_key, client=_client())
|
|
75
|
+
data = await r6.players.get_full_stats(
|
|
76
|
+
player_id, platform, season_year=season_year, modes=modes
|
|
77
|
+
)
|
|
57
78
|
except R6APIError as e:
|
|
58
79
|
if e.status == 401:
|
|
59
80
|
raise ServiceError(
|
|
@@ -64,5 +85,5 @@ async def get_full_stats(
|
|
|
64
85
|
except Exception as e: # noqa: BLE001 - 网络等异常统一兜底
|
|
65
86
|
raise ServiceError(f"请求失败:{type(e).__name__}") from e
|
|
66
87
|
|
|
67
|
-
await _cache.set(cache_key, data)
|
|
88
|
+
await _cache.set(cache_key, data, FULL_STATS_TTL)
|
|
68
89
|
return data
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from pydantic import BaseModel
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
class Config(BaseModel):
|
|
7
|
-
#: 当前赛季代码(如 "Y10S4")。查询默认按此赛季过滤;填 "all" 则不过滤、查生涯。
|
|
8
|
-
#: 在 .env 里用 CURRENT_SEASON=Y10S4 覆盖;每赛季更新一次即可。
|
|
9
|
-
current_season: str = "Y11S2"
|
|
10
|
-
|
|
11
|
-
#: 查询结果是否渲染成图片(失败时自动回退文本)。.env 用 R6_OUTPUT_IMAGE=false 关闭。
|
|
12
|
-
r6_output_image: bool = True
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|