nonebot-plugin-R6States 1.0.0__tar.gz → 1.1.1__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.1}/PKG-INFO +17 -34
- {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.1}/README.md +16 -33
- {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.1}/pyproject.toml +1 -1
- {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.1}/src/nonebot_plugin_R6States/__init__.py +44 -26
- {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.1}/src/nonebot_plugin_R6States/cache.py +7 -15
- nonebot_plugin_r6states-1.1.1/src/nonebot_plugin_R6States/config.py +17 -0
- {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.1}/src/nonebot_plugin_R6States/config_mannger.py +0 -11
- {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.1}/src/nonebot_plugin_R6States/formatter.py +35 -4
- {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.1}/src/nonebot_plugin_R6States/r6data.py +1 -25
- {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.1}/src/nonebot_plugin_R6States/renderer.py +15 -15
- {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.1}/src/nonebot_plugin_R6States/service.py +35 -11
- 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.1}/LICENSE +0 -0
- {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.1}/src/nonebot_plugin_R6States/assets/MonaSans.ttf +0 -0
- {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.1}/src/nonebot_plugin_R6States/assets/NotoSansSC.ttf +0 -0
- {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.1}/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.1
|
|
4
4
|
Summary: 查询指定玩家的各项数据
|
|
5
5
|
Author-Email: Siornya <wxh200607@outlook.com>
|
|
6
6
|
License: MIT
|
|
@@ -15,59 +15,42 @@ Description-Content-Type: text/markdown
|
|
|
15
15
|
|
|
16
16
|
一个基于 **NoneBot2** 的《彩虹六号:围攻》战绩查询插件
|
|
17
17
|
|
|
18
|
-
---
|
|
19
|
-
|
|
20
18
|
## 功能特性
|
|
21
19
|
|
|
22
20
|
* ✅ 通过 QQ 指令查询 R6 玩家战绩
|
|
23
21
|
* ✅ 支持 **单人查询 / 多人查询**
|
|
24
22
|
* ✅ 数据源自 [R6Data API](https://r6data.com/)
|
|
25
|
-
* ✅
|
|
23
|
+
* ✅ 玩家数据缓存,降低 API 用量
|
|
26
24
|
* ✅ 数据分析功能
|
|
27
25
|
* ⚙️ 地图筛选 `-m / --map`
|
|
28
26
|
|
|
29
|
-
---
|
|
30
|
-
|
|
31
|
-
## 参考运行环境
|
|
32
|
-
|
|
33
|
-
* **Python 3.12**
|
|
34
|
-
* **NoneBot2**
|
|
35
|
-
* **OneBot v11**
|
|
36
|
-
* **NapCat(反向 WebSocket)**
|
|
37
|
-
|
|
38
|
-
---
|
|
39
|
-
|
|
40
27
|
## Usage 使用说明
|
|
41
28
|
|
|
42
29
|
### 安装
|
|
43
30
|
|
|
44
|
-
|
|
31
|
+
- (推荐)使用nb安装`nb plugin install nonebot-plugin-R6States`
|
|
32
|
+
- 使用pip安装`pip install nonebot-plugin-R6States`
|
|
33
|
+
- 下载release放到`plugins`文件夹中
|
|
45
34
|
|
|
46
|
-
###
|
|
35
|
+
### 指令
|
|
47
36
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
```
|
|
37
|
+
数据查询:`/R6 <player_ids...>`
|
|
38
|
+
其他指令与帮助信息:`/R6help`
|
|
51
39
|
|
|
52
|
-
|
|
40
|
+
## 环境配置
|
|
53
41
|
|
|
54
|
-
|
|
55
|
-
/R6 -g <id1> <id2> ... <idN>
|
|
56
|
-
```
|
|
42
|
+
CURRENT_SEASON = "Y11S2"
|
|
57
43
|
|
|
58
|
-
|
|
44
|
+
R6_OUTPUT_IMAGE = True
|
|
59
45
|
|
|
60
|
-
|
|
61
|
-
/R6help
|
|
62
|
-
```
|
|
46
|
+
R6_CACHE_MINUTES = 45
|
|
63
47
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
```text
|
|
67
|
-
-m / --map <map_name>
|
|
68
|
-
```
|
|
48
|
+
## 参考运行环境
|
|
69
49
|
|
|
70
|
-
|
|
50
|
+
* **Python 3.12**
|
|
51
|
+
* **NoneBot2**
|
|
52
|
+
* **OneBot v11**
|
|
53
|
+
* **NapCat(反向 WebSocket)**
|
|
71
54
|
|
|
72
55
|
## 特别提醒
|
|
73
56
|
|
|
@@ -2,59 +2,42 @@
|
|
|
2
2
|
|
|
3
3
|
一个基于 **NoneBot2** 的《彩虹六号:围攻》战绩查询插件
|
|
4
4
|
|
|
5
|
-
---
|
|
6
|
-
|
|
7
5
|
## 功能特性
|
|
8
6
|
|
|
9
7
|
* ✅ 通过 QQ 指令查询 R6 玩家战绩
|
|
10
8
|
* ✅ 支持 **单人查询 / 多人查询**
|
|
11
9
|
* ✅ 数据源自 [R6Data API](https://r6data.com/)
|
|
12
|
-
* ✅
|
|
10
|
+
* ✅ 玩家数据缓存,降低 API 用量
|
|
13
11
|
* ✅ 数据分析功能
|
|
14
12
|
* ⚙️ 地图筛选 `-m / --map`
|
|
15
13
|
|
|
16
|
-
---
|
|
17
|
-
|
|
18
|
-
## 参考运行环境
|
|
19
|
-
|
|
20
|
-
* **Python 3.12**
|
|
21
|
-
* **NoneBot2**
|
|
22
|
-
* **OneBot v11**
|
|
23
|
-
* **NapCat(反向 WebSocket)**
|
|
24
|
-
|
|
25
|
-
---
|
|
26
|
-
|
|
27
14
|
## Usage 使用说明
|
|
28
15
|
|
|
29
16
|
### 安装
|
|
30
17
|
|
|
31
|
-
|
|
18
|
+
- (推荐)使用nb安装`nb plugin install nonebot-plugin-R6States`
|
|
19
|
+
- 使用pip安装`pip install nonebot-plugin-R6States`
|
|
20
|
+
- 下载release放到`plugins`文件夹中
|
|
32
21
|
|
|
33
|
-
###
|
|
22
|
+
### 指令
|
|
34
23
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
```
|
|
24
|
+
数据查询:`/R6 <player_ids...>`
|
|
25
|
+
其他指令与帮助信息:`/R6help`
|
|
38
26
|
|
|
39
|
-
|
|
27
|
+
## 环境配置
|
|
40
28
|
|
|
41
|
-
|
|
42
|
-
/R6 -g <id1> <id2> ... <idN>
|
|
43
|
-
```
|
|
29
|
+
CURRENT_SEASON = "Y11S2"
|
|
44
30
|
|
|
45
|
-
|
|
31
|
+
R6_OUTPUT_IMAGE = True
|
|
46
32
|
|
|
47
|
-
|
|
48
|
-
/R6help
|
|
49
|
-
```
|
|
33
|
+
R6_CACHE_MINUTES = 45
|
|
50
34
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
```text
|
|
54
|
-
-m / --map <map_name>
|
|
55
|
-
```
|
|
35
|
+
## 参考运行环境
|
|
56
36
|
|
|
57
|
-
|
|
37
|
+
* **Python 3.12**
|
|
38
|
+
* **NoneBot2**
|
|
39
|
+
* **OneBot v11**
|
|
40
|
+
* **NapCat(反向 WebSocket)**
|
|
58
41
|
|
|
59
42
|
## 特别提醒
|
|
60
43
|
|
|
@@ -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,33 @@ 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
|
-
|
|
106
|
+
# 并发取数(单个失败不连累其余),再按原顺序逐个发送
|
|
107
|
+
ttl = plugin_config.r6_cache_minutes * 60
|
|
108
|
+
results = await asyncio.gather(
|
|
109
|
+
*(
|
|
110
|
+
get_full_stats(
|
|
111
|
+
pid, scopes, platform,
|
|
112
|
+
season_year=plugin_config.current_season, ttl=ttl,
|
|
104
113
|
)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
logger.error(f"查询 {player_id} 出错: {type(
|
|
114
|
+
for pid in tokens
|
|
115
|
+
),
|
|
116
|
+
return_exceptions=True,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
for player_id, data in zip(tokens, results):
|
|
120
|
+
if isinstance(data, ServiceError):
|
|
121
|
+
logger.warning(f"查询 {player_id} 失败: {data.message}")
|
|
122
|
+
await r6.send(f"❌ {player_id}:{data.message}")
|
|
123
|
+
continue
|
|
124
|
+
if isinstance(data, BaseException):
|
|
125
|
+
logger.error(f"查询 {player_id} 出错: {type(data).__name__}: {data}")
|
|
117
126
|
await r6.send(f"❌ {player_id}:查询失败")
|
|
127
|
+
continue
|
|
128
|
+
if plugin_config.r6_output_image:
|
|
129
|
+
try:
|
|
130
|
+
png = render_full_stats(player_id, data)
|
|
131
|
+
await r6.send(MessageSegment.image(png))
|
|
132
|
+
continue
|
|
133
|
+
except Exception as e: # noqa: BLE001 - 渲染失败回退文本
|
|
134
|
+
logger.warning(f"图片渲染失败,回退文本: {type(e).__name__}: {e}")
|
|
135
|
+
await r6.send(format_full_stats(player_id, data))
|
{nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.1}/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,17 @@
|
|
|
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
|
|
14
|
+
|
|
15
|
+
# 玩家数据本地缓存时长(分钟)。越大越省 API 用量,但数据越旧。
|
|
16
|
+
# 在 .env 用 R6_CACHE_MINUTES 覆盖
|
|
17
|
+
r6_cache_minutes: int = 45
|
|
@@ -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
|
|
@@ -7,20 +7,44 @@ fullStats 顶层有三块:
|
|
|
7
7
|
"""
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
-
from typing import Any
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
from datetime import timezone, datetime, timedelta
|
|
11
12
|
|
|
12
13
|
#: 默认展示出场最多的前 N 个干员
|
|
13
14
|
TOP_N = 8
|
|
14
15
|
|
|
16
|
+
#: 展示时间用东八区(CN bot)
|
|
17
|
+
_CST = timezone(timedelta(hours=8))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def fetched_label(data: dict[str, Any]) -> Optional[str]:
|
|
21
|
+
"""数据实际取回时刻(随缓存保存),格式 '更新 06-14 17:30';无则返回 None。"""
|
|
22
|
+
ts = data.get("_fetched_at")
|
|
23
|
+
if not ts:
|
|
24
|
+
return None
|
|
25
|
+
return "更新 " + datetime.fromtimestamp(ts, _CST).strftime("%m-%d %H:%M")
|
|
26
|
+
|
|
15
27
|
_SIDE_CN = {"Attacker": "攻", "Defender": "防"}
|
|
16
28
|
_BOARD_CN = {
|
|
17
29
|
"ranked": "排位", "casual": "休闲", "standard": "标准",
|
|
18
30
|
"event": "活动", "warmup": "热身",
|
|
19
31
|
}
|
|
20
32
|
|
|
33
|
+
#: 1000 起每档 100 分、每大段 5 小级;<1000 未定级,>=4500 冠军。
|
|
34
|
+
_RANK_TIERS = ("紫铜", "青铜", "白银", "黄金", "铂金", "翡翠", "钻石")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def rank_name(mmr: int) -> str:
|
|
38
|
+
if mmr < 1000:
|
|
39
|
+
return "未定级"
|
|
40
|
+
if mmr >= 4500:
|
|
41
|
+
return "冠军"
|
|
42
|
+
idx = (mmr - 1000) // 100
|
|
43
|
+
return f"{_RANK_TIERS[idx // 5]}{5 - idx % 5}"
|
|
44
|
+
|
|
21
45
|
|
|
22
46
|
def _format_boards(profiles: list[dict[str, Any]]) -> list[str]:
|
|
23
|
-
"""各 board
|
|
47
|
+
"""各 board 取最新赛季档案,输出概览行;排位行补段位名。"""
|
|
24
48
|
out: list[str] = []
|
|
25
49
|
for fam in profiles:
|
|
26
50
|
for board in fam.get("board_ids_full_profiles") or []:
|
|
@@ -33,9 +57,12 @@ def _format_boards(profiles: list[dict[str, Any]]) -> list[str]:
|
|
|
33
57
|
kills, deaths = p.get("kills", 0), p.get("deaths", 0)
|
|
34
58
|
wr = wins / (wins + losses) * 100 if (wins + losses) else 0
|
|
35
59
|
kd = kills / deaths if deaths else float(kills)
|
|
36
|
-
|
|
60
|
+
bid = board.get("board_id", "")
|
|
61
|
+
name = _BOARD_CN.get(bid, bid or "?")
|
|
62
|
+
rp = p.get("rank_points", 0)
|
|
63
|
+
rank_str = f"{rank_name(rp)} " if bid == "ranked" else ""
|
|
37
64
|
out.append(
|
|
38
|
-
f"{name} RP{
|
|
65
|
+
f"{name} {rank_str}RP{rp}({p.get('max_rank_points', 0)}) "
|
|
39
66
|
f"胜负{wins}/{losses}({wr:.0f}%) KD{kd:.2f} 掉线{p.get('abandon', 0)}"
|
|
40
67
|
)
|
|
41
68
|
return out
|
|
@@ -88,4 +115,8 @@ def format_full_stats(player_id: str, data: dict[str, Any], top_n: int = TOP_N)
|
|
|
88
115
|
|
|
89
116
|
if not board_lines and not operators:
|
|
90
117
|
return f"🎯 {handle}:没有查询到数据"
|
|
118
|
+
|
|
119
|
+
label = fetched_label(data)
|
|
120
|
+
if label:
|
|
121
|
+
lines.append(label)
|
|
91
122
|
return "\n".join(lines)
|
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
"""
|
|
2
|
-
r6data.
|
|
2
|
+
对 https://api.r6data.com/api 的封装,从官方 npm 包 `r6-data.js` 移植修改而来。
|
|
3
3
|
|
|
4
|
-
对 https://api.r6data.com/api 的薄封装,异步 (httpx)。从官方 npm 包
|
|
5
|
-
`r6-data.js` 移植而来;源码层逻辑长期稳定,只有下面 ``易变常量`` 区域会
|
|
6
|
-
随赛季/平台调整,跟新版时基本只动那几行。
|
|
7
|
-
|
|
8
|
-
依赖: pip install httpx
|
|
9
4
|
用法:
|
|
10
5
|
from .r6data import R6Client
|
|
11
6
|
|
|
@@ -13,31 +8,12 @@ r6data.py — Python 移植版 r6-data.js (Players + Game)
|
|
|
13
8
|
info = await r6.players.get_account_info("PlayerName", "uplay")
|
|
14
9
|
ops = await r6.game.get_operators(side="attacker")
|
|
15
10
|
await r6.aclose()
|
|
16
|
-
|
|
17
|
-
NoneBot 启动时检查版本(贴进插件 __init__.py):
|
|
18
|
-
from nonebot import get_driver, logger
|
|
19
|
-
from .r6data import check_latest_version, BASED_ON_VERSION
|
|
20
|
-
|
|
21
|
-
@get_driver().on_startup
|
|
22
|
-
async def _r6_version_check():
|
|
23
|
-
latest = await check_latest_version()
|
|
24
|
-
if latest and latest != BASED_ON_VERSION:
|
|
25
|
-
logger.warning(
|
|
26
|
-
f"r6data 移植基于 r6-data.js@{BASED_ON_VERSION}, "
|
|
27
|
-
f"上游最新为 {latest},建议核对是否有接口变化。"
|
|
28
|
-
)
|
|
29
11
|
"""
|
|
30
12
|
|
|
31
13
|
from __future__ import annotations
|
|
32
|
-
|
|
33
14
|
from typing import Any, Optional, Sequence, Mapping
|
|
34
|
-
|
|
35
15
|
import httpx
|
|
36
16
|
|
|
37
|
-
# ──────────────────────────────────────────────────────────────────────────
|
|
38
|
-
# 易变常量 —— 跟随上游版本时,基本只需要改这一段
|
|
39
|
-
# ──────────────────────────────────────────────────────────────────────────
|
|
40
|
-
|
|
41
17
|
#: 本移植对标的 r6-data.js 版本。启动时与 npm 最新版比对。
|
|
42
18
|
BASED_ON_VERSION = "3.1.7"
|
|
43
19
|
|
|
@@ -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
|
|
@@ -13,7 +6,7 @@ from pathlib import Path
|
|
|
13
6
|
|
|
14
7
|
from PIL import Image, ImageDraw, ImageFont
|
|
15
8
|
|
|
16
|
-
from .formatter import _format_boards
|
|
9
|
+
from .formatter import _format_boards, fetched_label
|
|
17
10
|
|
|
18
11
|
_ASSETS = Path(__file__).parent / "assets"
|
|
19
12
|
_MONA = str(_ASSETS / "MonaSans.ttf")
|
|
@@ -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:
|
|
@@ -195,6 +188,13 @@ def render_full_stats(player_id: str, data: dict[str, Any], top_n: int = 8) -> b
|
|
|
195
188
|
_draw(draw, p, y, "没有查询到数据", 22, _GRAY)
|
|
196
189
|
y += _font(_NOTO, 22).getmetrics()[1]
|
|
197
190
|
|
|
191
|
+
# 底部:数据实际取回时间(右下角,小字)
|
|
192
|
+
label = fetched_label(data)
|
|
193
|
+
if label:
|
|
194
|
+
y += 14 * _SCALE + _ascent(14)
|
|
195
|
+
_draw_right(draw, rcol, y, label, 14, _DIM)
|
|
196
|
+
y += _font(_NOTO, 14).getmetrics()[1]
|
|
197
|
+
|
|
198
198
|
img = img.crop((0, 0, _W, y + _PAD))
|
|
199
199
|
buf = BytesIO()
|
|
200
200
|
img.save(buf, format="PNG")
|
|
@@ -1,20 +1,21 @@
|
|
|
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
|
-
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
9
13
|
|
|
10
14
|
from .cache import JSONCache
|
|
11
15
|
from .storage import PLAYER_CACHE_FILE
|
|
12
16
|
from .r6data import R6Client, R6APIError
|
|
13
17
|
from .config_mannger import resolve_apikey
|
|
14
18
|
|
|
15
|
-
#: 玩家快照属于会变的数据,缓存 30 分钟即可显著降低 API 用量。
|
|
16
|
-
FULL_STATS_TTL = 30 * 60
|
|
17
|
-
|
|
18
19
|
VALID_PLATFORMS = ("uplay", "psn", "xbl")
|
|
19
20
|
|
|
20
21
|
#: 免费申请 api-key 的入口,附在缺/失效提示里
|
|
@@ -22,6 +23,24 @@ APIKEY_HELP = "可在 https://r6data.com/ 免费获取 Key,再用 /r6key <key>
|
|
|
22
23
|
|
|
23
24
|
_cache = JSONCache(PLAYER_CACHE_FILE)
|
|
24
25
|
|
|
26
|
+
#: 进程级共享 httpx 客户端,注入给每个 R6Client,避免每次查询重建连接池/握手。
|
|
27
|
+
_http: Optional[httpx.AsyncClient] = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _client() -> httpx.AsyncClient:
|
|
31
|
+
global _http
|
|
32
|
+
if _http is None or _http.is_closed:
|
|
33
|
+
_http = httpx.AsyncClient(timeout=15.0)
|
|
34
|
+
return _http
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def aclose() -> None:
|
|
38
|
+
"""关闭共享 httpx 客户端(在 nonebot on_shutdown 调用)。"""
|
|
39
|
+
global _http
|
|
40
|
+
if _http is not None and not _http.is_closed:
|
|
41
|
+
await _http.aclose()
|
|
42
|
+
_http = None
|
|
43
|
+
|
|
25
44
|
|
|
26
45
|
class ServiceError(Exception):
|
|
27
46
|
def __init__(self, message: str, *, expired: bool = False) -> None:
|
|
@@ -36,6 +55,8 @@ async def get_full_stats(
|
|
|
36
55
|
platform: str = "uplay",
|
|
37
56
|
season_year: str | None = None,
|
|
38
57
|
modes: str | None = None,
|
|
58
|
+
*,
|
|
59
|
+
ttl: float,
|
|
39
60
|
) -> dict[str, Any]:
|
|
40
61
|
if platform not in VALID_PLATFORMS:
|
|
41
62
|
raise ServiceError(f"平台无效,可选:{', '.join(VALID_PLATFORMS)}")
|
|
@@ -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,8 @@ 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
|
-
|
|
88
|
+
# 打上实际取数时间,随数据一起缓存(缓存命中时展示的就是这个原始取数时刻)
|
|
89
|
+
if isinstance(data, dict):
|
|
90
|
+
data["_fetched_at"] = time.time()
|
|
91
|
+
await _cache.set(cache_key, data, ttl)
|
|
68
92
|
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
|