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.
Files changed (16) hide show
  1. {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.1}/PKG-INFO +17 -34
  2. {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.1}/README.md +16 -33
  3. {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.1}/pyproject.toml +1 -1
  4. {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.1}/src/nonebot_plugin_R6States/__init__.py +44 -26
  5. {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.1}/src/nonebot_plugin_R6States/cache.py +7 -15
  6. nonebot_plugin_r6states-1.1.1/src/nonebot_plugin_R6States/config.py +17 -0
  7. {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.1}/src/nonebot_plugin_R6States/config_mannger.py +0 -11
  8. {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.1}/src/nonebot_plugin_R6States/formatter.py +35 -4
  9. {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.1}/src/nonebot_plugin_R6States/r6data.py +1 -25
  10. {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.1}/src/nonebot_plugin_R6States/renderer.py +15 -15
  11. {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.1}/src/nonebot_plugin_R6States/service.py +35 -11
  12. nonebot_plugin_r6states-1.0.0/src/nonebot_plugin_R6States/config.py +0 -12
  13. {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.1}/LICENSE +0 -0
  14. {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.1}/src/nonebot_plugin_R6States/assets/MonaSans.ttf +0 -0
  15. {nonebot_plugin_r6states-1.0.0 → nonebot_plugin_r6states-1.1.1}/src/nonebot_plugin_R6States/assets/NotoSansSC.ttf +0 -0
  16. {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.0.0
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
- * ✅ 玩家数据缓存半小时,降低 API 用量
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
- 可以直接将 `nonebot_plugin_R6States` 文件夹放入插件目录中。
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
- ```text
49
- /R6 <player_id>
50
- ```
37
+ 数据查询:`/R6 <player_ids...>`
38
+ 其他指令与帮助信息:`/R6help`
51
39
 
52
- ### 多人查询
40
+ ## 环境配置
53
41
 
54
- ```text
55
- /R6 -g <id1> <id2> ... <idN>
56
- ```
42
+ CURRENT_SEASON = "Y11S2"
57
43
 
58
- ### 帮助信息
44
+ R6_OUTPUT_IMAGE = True
59
45
 
60
- ```text
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
- * ✅ 玩家数据缓存半小时,降低 API 用量
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
- 可以直接将 `nonebot_plugin_R6States` 文件夹放入插件目录中。
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
- ```text
36
- /R6 <player_id>
37
- ```
24
+ 数据查询:`/R6 <player_ids...>`
25
+ 其他指令与帮助信息:`/R6help`
38
26
 
39
- ### 多人查询
27
+ ## 环境配置
40
28
 
41
- ```text
42
- /R6 -g <id1> <id2> ... <idN>
43
- ```
29
+ CURRENT_SEASON = "Y11S2"
44
30
 
45
- ### 帮助信息
31
+ R6_OUTPUT_IMAGE = True
46
32
 
47
- ```text
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,6 +1,6 @@
1
1
  [project]
2
2
  name = "nonebot-plugin-R6States"
3
- version = "1.0.0"
3
+ version = "1.1.1"
4
4
  description = "查询指定玩家的各项数据"
5
5
  authors = [
6
6
  { name = "Siornya", email = "wxh200607@outlook.com" },
@@ -1,11 +1,13 @@
1
- from nonebot import on_command, logger
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> [平台] /r6key <key> /r6help",
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> [平台] 查询玩家数据(平台默认 uplay,可选 psn/xbl)\n"
44
+ "/r6 <id...> [平台] 查询玩家数据(最多5个,空格分隔;平台默认 uplay,可选 psn/xbl)\n"
37
45
  "/r6key <key> 设置本群/本人的 r6data API Key\n"
38
- "/r6help 显示本帮助\n"
39
- "Key 可在 https://r6data.com/ 免费获取;优先用你的个人 Key,没有则用群 Key"
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 abcdef...\n"
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> [平台],详见 /r6help")
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
- for player_id in tokens:
101
- try:
102
- data = await get_full_stats(
103
- player_id, scopes, platform, season_year=plugin_config.current_season
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
- if plugin_config.r6_output_image:
106
- try:
107
- png = render_full_stats(player_id, data)
108
- await r6.send(MessageSegment.image(png))
109
- continue
110
- except Exception as e: # noqa: BLE001 - 渲染失败回退文本
111
- logger.warning(f"图片渲染失败,回退文本: {type(e).__name__}: {e}")
112
- await r6.send(format_full_stats(player_id, data))
113
- except ServiceError as e:
114
- await r6.send(f"❌ {player_id}:{e.message}")
115
- except Exception as e: # noqa: BLE001
116
- logger.error(f"查询 {player_id} 出错: {type(e).__name__}: {e}")
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))
@@ -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, ttl: float) -> Optional[Any]:
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] = {"ts": time.time(), "value": value}
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
- name = _BOARD_CN.get(board.get("board_id", ""), board.get("board_id", "?"))
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{p.get('rank_points', 0)}({p.get('max_rank_points', 0)}) "
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.py Python 移植版 r6-data.js (Players + Game)
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], top_n: int = 8) -> bytes:
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)[:4]
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)[:4]
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, "", 19, _ATK, weight=700)
163
- _draw(draw, rx, y, "", 19, _DEF, weight=700)
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
- 对外只暴露 ``get_operator_stats``:先查本地缓存,未命中再打 r6data API
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
+ 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, ttl=FULL_STATS_TTL)
69
+ cached = await _cache.get(cache_key)
49
70
  if cached is not None:
50
71
  return cached
51
72
 
52
73
  try:
53
- async with R6Client(api_key=api_key) as r6:
54
- data = await r6.players.get_full_stats(
55
- player_id, platform, season_year=season_year, modes=modes
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
- await _cache.set(cache_key, data)
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