nonebot-plugin-R6States 1.0.0__py3-none-any.whl

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.
@@ -0,0 +1,117 @@
1
+ from nonebot import on_command, logger
2
+ from nonebot.adapters import Message
3
+ from nonebot.params import CommandArg
4
+ from nonebot.plugin import PluginMetadata, get_plugin_config
5
+ from nonebot.adapters.onebot.v11 import MessageEvent, MessageSegment, GroupMessageEvent
6
+
7
+ from .config import Config
8
+ from .service import VALID_PLATFORMS, ServiceError, get_full_stats
9
+ from .formatter import format_full_stats
10
+ from .renderer import render_full_stats
11
+ from .config_mannger import (
12
+ KEY_TTL_DAYS,
13
+ set_apikey,
14
+ resolve_apikey,
15
+ get_apikey_age_days,
16
+ )
17
+
18
+ __plugin_meta__ = PluginMetadata(
19
+ name="彩六数据查询",
20
+ description="查询指定玩家的数据",
21
+ usage="/r6 <id> [平台] /r6key <key> /r6help",
22
+ homepage="https://github.com/Siornya/nonebot-plugin-R6States",
23
+ type="application",
24
+ config=Config,
25
+ supported_adapters={"~onebot.v11"},
26
+ )
27
+
28
+ plugin_config = get_plugin_config(Config)
29
+
30
+ r6 = on_command("r6", aliases={"R6"}, priority=10, block=True)
31
+ r6_key = on_command("r6key", aliases={"R6key", "R6DAPI", "r6dapi"}, priority=5, block=True)
32
+ r6_help = on_command("r6help", aliases={"R6help"}, priority=5, block=True)
33
+
34
+ HELP_TEXT = (
35
+ "彩六数据查询\n"
36
+ "/r6 <id> [平台] 查询玩家数据(平台默认 uplay,可选 psn/xbl)\n"
37
+ "/r6key <key> 设置本群/本人的 r6data API Key\n"
38
+ "/r6help 显示本帮助\n"
39
+ "Key 可在 https://r6data.com/ 免费获取;优先用你的个人 Key,没有则用群 Key"
40
+ )
41
+
42
+
43
+ def _scope_id(event: MessageEvent) -> str:
44
+ """设置 key 的归属:群聊按群、私聊按人。"""
45
+ if isinstance(event, GroupMessageEvent):
46
+ return str(event.group_id)
47
+ return str(event.user_id)
48
+
49
+
50
+ def _lookup_scopes(event: MessageEvent) -> list[str]:
51
+ """查询时的 key 候选顺序:个人优先,个人没设则回退到群。"""
52
+ if isinstance(event, GroupMessageEvent):
53
+ return [str(event.user_id), str(event.group_id)]
54
+ return [str(event.user_id)]
55
+
56
+
57
+ @r6_help.handle()
58
+ async def _():
59
+ await r6_help.finish(HELP_TEXT)
60
+
61
+
62
+ @r6_key.handle()
63
+ async def _(event: MessageEvent, args: Message = CommandArg()):
64
+ key = args.extract_plain_text().strip()
65
+ if not key:
66
+ await r6_key.finish(
67
+ "请在命令后输入 API Key,例如:/r6key abcdef...\n"
68
+ "没有的话可在 https://r6data.com/ 免费获取"
69
+ )
70
+
71
+ scope = _scope_id(event)
72
+ set_apikey(scope, key)
73
+ where = "本群" if isinstance(event, GroupMessageEvent) else "个人"
74
+ await r6_key.finish(f"✅ 已设置{where} API Key(官方有效期约 {KEY_TTL_DAYS} 天)")
75
+
76
+
77
+ @r6.handle()
78
+ async def _(event: MessageEvent, args: Message = CommandArg()):
79
+ tokens = args.extract_plain_text().split()
80
+ if not tokens:
81
+ await r6.finish("用法:/r6 <id> [平台],详见 /r6help")
82
+
83
+ # 末尾 token 若是平台名则作为平台,其余都当作玩家 id。
84
+ platform = "uplay"
85
+ if len(tokens) > 1 and tokens[-1].lower() in VALID_PLATFORMS:
86
+ platform = tokens[-1].lower()
87
+ tokens = tokens[:-1]
88
+
89
+ if len(tokens) > 5:
90
+ await r6.finish("一次最多查询 5 个 id")
91
+
92
+ scopes = _lookup_scopes(event)
93
+
94
+ # key 临近过期的轻提醒(针对实际命中的那个 key,不阻断查询)。
95
+ _, matched = resolve_apikey(scopes)
96
+ age = get_apikey_age_days(matched) if matched else None
97
+ if age is not None and age >= KEY_TTL_DAYS:
98
+ await r6.send(f"⚠️ 当前 API Key 已设置 {age:.0f} 天,可能已过期,如查询失败请 /r6key 重设")
99
+
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
104
+ )
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}")
117
+ await r6.send(f"❌ {player_id}:查询失败")
@@ -0,0 +1,63 @@
1
+ """轻量本地缓存:单 JSON 文件 + 每条目独立 TTL + 异步锁 + 原子写。
2
+
3
+ 设计动机(对比旧的 players.yaml):
4
+ - 旧实现按**整文件** mtime 判 TTL,一次写入会重置所有条目的有效期,过期还整包丢弃。
5
+ - 这里每条目自带写入时间戳,**各自过期**;TTL 由调用方按 endpoint 指定。
6
+ - 写入走 "临时文件 + os.replace" 原子替换,配异步锁,避免并发下半写/相互覆盖。
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import json
12
+ import time
13
+ import asyncio
14
+ import tempfile
15
+ from typing import Any, Optional
16
+ from pathlib import Path
17
+
18
+
19
+ class JSONCache:
20
+ def __init__(self, path: Path) -> None:
21
+ self._path = path
22
+ self._lock = asyncio.Lock()
23
+
24
+ def _load(self) -> dict[str, dict[str, Any]]:
25
+ if not self._path.exists():
26
+ return {}
27
+ try:
28
+ with self._path.open("r", encoding="utf-8") as f:
29
+ data = json.load(f)
30
+ return data if isinstance(data, dict) else {}
31
+ except (json.JSONDecodeError, OSError):
32
+ # 缓存损坏不应影响主流程:当作空缓存重建。
33
+ return {}
34
+
35
+ def _atomic_write(self, data: dict[str, dict[str, Any]]) -> None:
36
+ self._path.parent.mkdir(parents=True, exist_ok=True)
37
+ fd, tmp = tempfile.mkstemp(dir=str(self._path.parent), suffix=".tmp")
38
+ try:
39
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
40
+ json.dump(data, f, ensure_ascii=False, separators=(",", ":"))
41
+ os.replace(tmp, self._path)
42
+ except BaseException:
43
+ try:
44
+ os.unlink(tmp)
45
+ except OSError:
46
+ pass
47
+ raise
48
+
49
+ async def get(self, key: str, ttl: float) -> Optional[Any]:
50
+ """命中且未超过 ttl(秒)则返回缓存值,否则返回 None。"""
51
+ async with self._lock:
52
+ entry = self._load().get(key)
53
+ if not entry:
54
+ return None
55
+ if time.time() - entry.get("ts", 0) > ttl:
56
+ return None
57
+ return entry.get("value")
58
+
59
+ async def set(self, key: str, value: Any) -> None:
60
+ async with self._lock:
61
+ data = self._load()
62
+ data[key] = {"ts": time.time(), "value": value}
63
+ self._atomic_write(data)
@@ -0,0 +1,12 @@
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
@@ -0,0 +1,72 @@
1
+ """按群/按人管理 r6data api-key。
2
+
3
+ 落盘格式(带设置时间戳,用于过期提醒;key 官方有效期约 1 个月)::
4
+
5
+ {"apikeys": {"<id>": {"key": "...", "set_at": 1712345678.0}}}
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import time
11
+ from typing import Any, Optional
12
+
13
+ from .storage import API_KEY_FILE
14
+
15
+ #: 官方 key 有效期约 30 天,超过这个天数就在查询时给个提醒。
16
+ KEY_TTL_DAYS = 30
17
+
18
+
19
+ def load_config() -> dict[str, Any]:
20
+ if not API_KEY_FILE.exists():
21
+ return {"apikeys": {}}
22
+ try:
23
+ with API_KEY_FILE.open("r", encoding="utf-8") as f:
24
+ data = json.load(f)
25
+ except (json.JSONDecodeError, OSError):
26
+ return {"apikeys": {}}
27
+ data.setdefault("apikeys", {})
28
+ return data
29
+
30
+
31
+ def save_config(data: dict[str, Any]) -> None:
32
+ API_KEY_FILE.parent.mkdir(parents=True, exist_ok=True)
33
+ with API_KEY_FILE.open("w", encoding="utf-8") as f:
34
+ json.dump(data, f, ensure_ascii=False, indent=2)
35
+
36
+
37
+ def set_apikey(target_id: str, key: str) -> None:
38
+ data = load_config()
39
+ data["apikeys"][target_id] = {"key": key, "set_at": time.time()}
40
+ save_config(data)
41
+
42
+
43
+ def _entry(target_id: str) -> Optional[dict[str, Any]]:
44
+ raw = load_config()["apikeys"].get(target_id)
45
+ if raw is None:
46
+ return None
47
+ return raw
48
+
49
+
50
+ def get_apikey(target_id: str) -> Optional[str]:
51
+ entry = _entry(target_id)
52
+ return entry["key"] if entry else None
53
+
54
+
55
+ def resolve_apikey(scopes: list[str]) -> tuple[Optional[str], Optional[str]]:
56
+ """按候选顺序找第一个有 key 的归属,返回 (key, 命中的 scope)。
57
+
58
+ 群聊传 [群号, 用户号]:群没设 key 时回退到个人 key。
59
+ """
60
+ for scope in scopes:
61
+ key = get_apikey(scope)
62
+ if key:
63
+ return key, scope
64
+ return None, None
65
+
66
+
67
+ def get_apikey_age_days(target_id: str) -> Optional[float]:
68
+ """key 已设置的天数;旧格式/未设置返回 None。"""
69
+ entry = _entry(target_id)
70
+ if not entry:
71
+ return None
72
+ return (time.time() - entry["set_at"]) / 86400
@@ -0,0 +1,91 @@
1
+ """把 fullStats 返回格式化成可发送文本。
2
+
3
+ fullStats 顶层有三块:
4
+ - ``operators``:干员明细(operator/side/roundsPlayed/winPercent/kd/headshotPercent...)
5
+ - ``platform_families_full_profiles``:各 board(ranked/casual/...)的完整档案
6
+ - ``data``:tracker 风格的 platformInfo / metadata(等级、通行证) / segments
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ #: 默认展示出场最多的前 N 个干员
13
+ TOP_N = 8
14
+
15
+ _SIDE_CN = {"Attacker": "攻", "Defender": "防"}
16
+ _BOARD_CN = {
17
+ "ranked": "排位", "casual": "休闲", "standard": "标准",
18
+ "event": "活动", "warmup": "热身",
19
+ }
20
+
21
+
22
+ def _format_boards(profiles: list[dict[str, Any]]) -> list[str]:
23
+ """各 board 取最新赛季档案,输出概览行。"""
24
+ out: list[str] = []
25
+ for fam in profiles:
26
+ for board in fam.get("board_ids_full_profiles") or []:
27
+ fps = board.get("full_profiles") or []
28
+ if not fps:
29
+ continue
30
+ fp = max(fps, key=lambda f: f.get("season_id", 0))
31
+ p = fp.get("profile") or {}
32
+ wins, losses = p.get("wins", 0), p.get("losses", 0)
33
+ kills, deaths = p.get("kills", 0), p.get("deaths", 0)
34
+ wr = wins / (wins + losses) * 100 if (wins + losses) else 0
35
+ kd = kills / deaths if deaths else float(kills)
36
+ name = _BOARD_CN.get(board.get("board_id", ""), board.get("board_id", "?"))
37
+ out.append(
38
+ f"{name} RP{p.get('rank_points', 0)}(峰{p.get('max_rank_points', 0)}) "
39
+ f"胜负{wins}/{losses}({wr:.0f}%) KD{kd:.2f} 掉线{p.get('abandon', 0)}"
40
+ )
41
+ return out
42
+
43
+
44
+ def _format_operators(operators: list[dict[str, Any]], top_n: int) -> list[str]:
45
+ ranked = sorted(operators, key=lambda o: o.get("roundsPlayed", 0), reverse=True)
46
+ out: list[str] = []
47
+ for op in ranked[:top_n]:
48
+ side = _SIDE_CN.get(op.get("side", ""), op.get("side", "?"))
49
+ out.append(
50
+ f"{op.get('operator', '?')}({side}) "
51
+ f"场{op.get('roundsPlayed', 0)} "
52
+ f"胜{op.get('winPercent', 0)}% "
53
+ f"KD{op.get('kd', 0)}"
54
+ )
55
+ # 聚合:用总量重算,避免对各干员百分比做无权平均
56
+ kills = sum(o.get("kills", 0) for o in operators)
57
+ deaths = sum(o.get("deaths", 0) for o in operators)
58
+ rounds = sum(o.get("roundsPlayed", 0) for o in operators)
59
+ kd = kills / deaths if deaths else float(kills)
60
+ out.append(f"— 合计 {len(operators)} 干员 场{rounds} KD{kd:.2f}")
61
+ return out
62
+
63
+
64
+ def format_full_stats(player_id: str, data: dict[str, Any], top_n: int = TOP_N) -> str:
65
+ info = data.get("data") or {}
66
+ handle = (info.get("platformInfo") or {}).get("platformUserHandle") or player_id
67
+ meta = info.get("metadata") or {}
68
+
69
+ lines = [f"🎯 {handle} 数据快照"]
70
+
71
+ head_bits = []
72
+ if meta.get("clearanceLevel") is not None:
73
+ head_bits.append(f"等级{meta['clearanceLevel']}")
74
+ if meta.get("battlepassLevel") is not None:
75
+ head_bits.append(f"通行证{meta['battlepassLevel']}")
76
+ if head_bits:
77
+ lines.append(" ".join(head_bits))
78
+
79
+ board_lines = _format_boards(data.get("platform_families_full_profiles") or [])
80
+ if board_lines:
81
+ lines.append("— 档案 —")
82
+ lines.extend(board_lines)
83
+
84
+ operators = data.get("operators") or []
85
+ if operators:
86
+ lines.append("— 干员 Top —")
87
+ lines.extend(_format_operators(operators, top_n))
88
+
89
+ if not board_lines and not operators:
90
+ return f"🎯 {handle}:没有查询到数据"
91
+ return "\n".join(lines)
@@ -0,0 +1,491 @@
1
+ """
2
+ r6data.py — Python 移植版 r6-data.js (Players + Game)
3
+
4
+ 对 https://api.r6data.com/api 的薄封装,异步 (httpx)。从官方 npm 包
5
+ `r6-data.js` 移植而来;源码层逻辑长期稳定,只有下面 ``易变常量`` 区域会
6
+ 随赛季/平台调整,跟新版时基本只动那几行。
7
+
8
+ 依赖: pip install httpx
9
+ 用法:
10
+ from .r6data import R6Client
11
+
12
+ r6 = R6Client(api_key="YOUR_KEY")
13
+ info = await r6.players.get_account_info("PlayerName", "uplay")
14
+ ops = await r6.game.get_operators(side="attacker")
15
+ 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
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from typing import Any, Optional, Sequence, Mapping
34
+
35
+ import httpx
36
+
37
+ # ──────────────────────────────────────────────────────────────────────────
38
+ # 易变常量 —— 跟随上游版本时,基本只需要改这一段
39
+ # ──────────────────────────────────────────────────────────────────────────
40
+
41
+ #: 本移植对标的 r6-data.js 版本。启动时与 npm 最新版比对。
42
+ BASED_ON_VERSION = "3.1.7"
43
+
44
+ #: 上游历史上换过一次域名 (r6data.eu -> r6data.com)。如再变,改这里。
45
+ BASE_URL = "https://api.r6data.com/api"
46
+
47
+ #: getRanks 接受的 version 取值;新赛季可能新增 v7...
48
+ VALID_RANK_VERSIONS = ("v1", "v2", "v3", "v4", "v5", "v6")
49
+
50
+ #: getPlayerStats / 对比时 board_id 的合法取值
51
+ VALID_BOARD_IDS = ("casual", "event", "warmup", "standard", "ranked")
52
+
53
+ # 平台 / 平台族(供调用方参考,运行时不强校验)
54
+ PLATFORM_TYPES = ("uplay", "psn", "xbl")
55
+ PLATFORM_FAMILIES = ("pc", "console")
56
+
57
+ __all__ = [
58
+ "R6Client",
59
+ "Players",
60
+ "Game",
61
+ "R6APIError",
62
+ "check_latest_version",
63
+ "BASED_ON_VERSION",
64
+ "BASE_URL",
65
+ ]
66
+
67
+
68
+ # ──────────────────────────────────────────────────────────────────────────
69
+ # 错误类型
70
+ # ──────────────────────────────────────────────────────────────────────────
71
+
72
+ class R6APIError(Exception):
73
+ """非 2xx 响应抛出。``status`` / ``data`` 携带服务端返回内容。"""
74
+
75
+ def __init__(self, message: str, *, status: Optional[int] = None,
76
+ data: Any = None) -> None:
77
+ super().__init__(message)
78
+ self.status = status
79
+ self.data = data
80
+
81
+
82
+ def _clean_params(params: Mapping[str, Any]) -> dict[str, str]:
83
+ """丢弃 None/空串,其余转字符串 —— 对应原库 buildUrlAndParams。"""
84
+ out: dict[str, str] = {}
85
+ for key, value in params.items():
86
+ if value is None or value == "":
87
+ continue
88
+ if isinstance(value, bool):
89
+ out[key] = "true" if value else "false"
90
+ else:
91
+ out[key] = str(value)
92
+ return out
93
+
94
+
95
+ def _validate_board_id(board_id: Optional[str]) -> None:
96
+ if board_id and board_id not in VALID_BOARD_IDS:
97
+ raise ValueError(
98
+ "Invalid board_id. Must be one of: " + ", ".join(VALID_BOARD_IDS)
99
+ )
100
+
101
+
102
+ def _filter_board_profiles(data: Any, board_id: Optional[str]) -> None:
103
+ """原库 filterBoardProfiles:就地把每个 profile 的 board_ids_full_profiles
104
+ 过滤成只剩指定 board_id。"""
105
+ if not board_id or not isinstance(data, dict):
106
+ return
107
+ profiles = data.get("platform_families_full_profiles")
108
+ if not isinstance(profiles, list):
109
+ return
110
+ for profile in profiles:
111
+ boards = profile.get("board_ids_full_profiles")
112
+ if isinstance(boards, list):
113
+ profile["board_ids_full_profiles"] = [
114
+ b for b in boards if b.get("board_id") == board_id
115
+ ]
116
+
117
+
118
+ # ──────────────────────────────────────────────────────────────────────────
119
+ # 资源:Players
120
+ # ──────────────────────────────────────────────────────────────────────────
121
+
122
+ class Players:
123
+ def __init__(self, client: "R6Client") -> None:
124
+ self._client = client
125
+
126
+ async def get_account_info(self, name_on_platform: str,
127
+ platform_type: str) -> Any:
128
+ """玩家账号信息 (type=accountInfo)。"""
129
+ if not name_on_platform or not platform_type:
130
+ raise ValueError("Missing required parameters: name_on_platform, platform_type")
131
+ return await self._client._get("/stats", {
132
+ "type": "accountInfo",
133
+ "nameOnPlatform": name_on_platform,
134
+ "platformType": platform_type,
135
+ })
136
+
137
+ async def get_is_banned(self, name_on_platform: str,
138
+ platform_type: str) -> Any:
139
+ """封禁状态 (type=isBanned)。"""
140
+ if not name_on_platform or not platform_type:
141
+ raise ValueError("Missing required parameters: name_on_platform, platform_type")
142
+ return await self._client._get("/stats", {
143
+ "type": "isBanned",
144
+ "nameOnPlatform": name_on_platform,
145
+ "platformType": platform_type,
146
+ })
147
+
148
+ async def get_player_stats(self, name_on_platform: str, platform_type: str,
149
+ platform_families: str,
150
+ board_id: Optional[str] = None) -> Any:
151
+ """玩家战绩 (type=stats)。给了 board_id 会就地过滤结果。"""
152
+ if not name_on_platform or not platform_type or not platform_families:
153
+ raise ValueError(
154
+ "Missing required parameters: name_on_platform, platform_type, platform_families")
155
+ _validate_board_id(board_id)
156
+ params = {
157
+ "type": "stats",
158
+ "nameOnPlatform": name_on_platform,
159
+ "platformType": platform_type,
160
+ "platform_families": platform_families,
161
+ }
162
+ if board_id:
163
+ params["board_id"] = board_id
164
+ data = await self._client._get("/stats", params)
165
+ _filter_board_profiles(data, board_id)
166
+ return data
167
+
168
+ async def get_player_comparisons(
169
+ self,
170
+ players: Sequence[Mapping[str, str]],
171
+ platform_families: str,
172
+ board_id: Optional[str] = None,
173
+ ) -> dict[str, Any]:
174
+ """对比多名玩家。每个 player 形如
175
+ {"nameOnPlatform": ..., "platformType": ...}。逐个查询并汇总,
176
+ 单个失败不影响其余(对应原库行为)。"""
177
+ if not players or len(players) < 2:
178
+ raise ValueError("At least 2 players are required for comparison")
179
+ if not platform_families:
180
+ raise ValueError("Missing required parameter: platform_families")
181
+ for i, p in enumerate(players):
182
+ if not p.get("nameOnPlatform") or not p.get("platformType"):
183
+ raise ValueError(
184
+ f"Player {i + 1} is missing required fields: nameOnPlatform, platformType")
185
+ _validate_board_id(board_id)
186
+
187
+ comparisons: list[dict[str, Any]] = []
188
+ errors: list[dict[str, Any]] = []
189
+ for player in players:
190
+ params = {
191
+ "type": "stats",
192
+ "nameOnPlatform": player["nameOnPlatform"],
193
+ "platformType": player["platformType"],
194
+ "platform_families": platform_families,
195
+ }
196
+ if board_id:
197
+ params["board_id"] = board_id
198
+ try:
199
+ data = await self._client._get("/stats", params)
200
+ _filter_board_profiles(data, board_id)
201
+ profiles = (data or {}).get("platform_families_full_profiles") or []
202
+ if profiles:
203
+ comparisons.append({"player": player, "stats": data, "success": True})
204
+ else:
205
+ comparisons.append({"player": player, "stats": None,
206
+ "success": False, "error": "No stats found for player"})
207
+ except Exception as exc: # noqa: BLE001 - 单玩家失败需被收集而非中断
208
+ errors.append({"player": player, "error": str(exc)})
209
+ comparisons.append({"player": player, "stats": None,
210
+ "success": False, "error": str(exc)})
211
+
212
+ result: dict[str, Any] = {"comparisons": comparisons}
213
+ if errors:
214
+ result["errors"] = errors
215
+ return result
216
+
217
+ async def get_operator_stats(self, name_on_platform: str, platform_type: str,
218
+ season_year: Optional[str] = None,
219
+ modes: Optional[str] = None) -> Any:
220
+ """干员维度战绩 (type=operatorStats)。modes: ranked|casual|unranked。"""
221
+ if not name_on_platform or not platform_type:
222
+ raise ValueError("Missing required parameters: name_on_platform, platform_type")
223
+ params = {
224
+ "type": "operatorStats",
225
+ "nameOnPlatform": name_on_platform,
226
+ "platformType": platform_type,
227
+ "modes": modes,
228
+ }
229
+ if season_year:
230
+ params["seasonYear"] = season_year
231
+ return await self._client._get("/stats", params)
232
+
233
+ async def get_full_stats(self, name_on_platform: str, platform_type: str,
234
+ season_year: Optional[str] = None,
235
+ modes: Optional[str] = None) -> Any:
236
+ """完整快照 (type=fullStats)。单请求合并三套数据:
237
+
238
+ - ``operators``:等同 operatorStats
239
+ - ``platform_families_full_profiles``:各 board(ranked/casual/...)完整档案
240
+ - ``data``:tracker 风格的 metadata/segments/platformInfo(等级、段位段等)
241
+
242
+ modes: ranked|casual|unranked|quick-match|standard|all(默认 all)。
243
+ season_year: 如 "Y10S4" 或 "all"(默认 all,不按赛季过滤)。
244
+ """
245
+ if not name_on_platform or not platform_type:
246
+ raise ValueError("Missing required parameters: name_on_platform, platform_type")
247
+ params = {
248
+ "type": "fullStats",
249
+ "nameOnPlatform": name_on_platform,
250
+ "platformType": platform_type,
251
+ }
252
+ if season_year:
253
+ params["seasonYear"] = season_year
254
+ if modes:
255
+ params["modes"] = modes
256
+ return await self._client._get("/stats", params)
257
+
258
+ async def get_seasonal_stats(self, name_on_platform: str,
259
+ platform_type: str) -> Any:
260
+ """当前赛季战绩 (type=seasonalStats)。"""
261
+ if not name_on_platform or not platform_type:
262
+ raise ValueError("Missing required parameters: name_on_platform, platform_type")
263
+ return await self._client._get("/stats", {
264
+ "type": "seasonalStats",
265
+ "nameOnPlatform": name_on_platform,
266
+ "platformType": platform_type,
267
+ })
268
+
269
+
270
+ # ──────────────────────────────────────────────────────────────────────────
271
+ # 资源:Game(静态数据)
272
+ # ──────────────────────────────────────────────────────────────────────────
273
+
274
+ class Game:
275
+ def __init__(self, client: "R6Client") -> None:
276
+ self._client = client
277
+
278
+ async def get_game_stats(self) -> Any:
279
+ return await self._client._get("/stats", {"type": "gameStats"})
280
+
281
+ async def get_maps(self, *, name: Optional[str] = None,
282
+ location: Optional[str] = None,
283
+ release_date: Optional[str] = None,
284
+ playlists: Optional[str] = None,
285
+ map_reworked: Optional[bool] = None) -> Any:
286
+ return await self._client._get("/maps", {
287
+ "name": name, "location": location, "releaseDate": release_date,
288
+ "playlists": playlists, "mapReworked": map_reworked,
289
+ })
290
+
291
+ async def get_operators(self, *, name: Optional[str] = None,
292
+ safename: Optional[str] = None,
293
+ realname: Optional[str] = None,
294
+ birthplace: Optional[str] = None,
295
+ age: Optional[int] = None,
296
+ date_of_birth: Optional[str] = None,
297
+ season_introduced: Optional[str] = None,
298
+ health: Optional[int] = None,
299
+ speed: Optional[int] = None,
300
+ unit: Optional[str] = None,
301
+ country_code: Optional[str] = None,
302
+ roles: Optional[str] = None,
303
+ side: Optional[str] = None) -> Any:
304
+ return await self._client._get("/operators", {
305
+ "name": name, "safename": safename, "realname": realname,
306
+ "birthplace": birthplace, "age": age, "date_of_birth": date_of_birth,
307
+ "season_introduced": season_introduced, "health": health,
308
+ "speed": speed, "unit": unit, "country_code": country_code,
309
+ "roles": roles, "side": side,
310
+ })
311
+
312
+ async def get_ranks(self, *, name: Optional[str] = None,
313
+ min_mmr: Optional[int] = None,
314
+ max_mmr: Optional[int] = None,
315
+ version: Optional[str] = None) -> Any:
316
+ if version and version not in VALID_RANK_VERSIONS:
317
+ raise ValueError(
318
+ "Version not valid. Choose between " + ", ".join(VALID_RANK_VERSIONS) + ".")
319
+ return await self._client._get("/ranks", {
320
+ "name": name, "min_mmr": min_mmr, "max_mmr": max_mmr, "version": version,
321
+ })
322
+
323
+ async def get_seasons(self, *, name: Optional[str] = None,
324
+ map: Optional[str] = None,
325
+ operators: Optional[str] = None,
326
+ weapons: Optional[str] = None,
327
+ description: Optional[str] = None,
328
+ code: Optional[str] = None,
329
+ start_date: Optional[str] = None) -> Any:
330
+ return await self._client._get("/seasons", {
331
+ "name": name, "map": map, "operators": operators, "weapons": weapons,
332
+ "description": description, "code": code, "startDate": start_date,
333
+ })
334
+
335
+ async def get_weapons(self, *, name: Optional[str] = None) -> Any:
336
+ return await self._client._get("/weapons", {"name": name})
337
+
338
+ async def get_charms(self, *, name: Optional[str] = None,
339
+ collection: Optional[str] = None,
340
+ rarity: Optional[str] = None,
341
+ availability: Optional[str] = None,
342
+ bundle: Optional[str] = None,
343
+ season: Optional[str] = None) -> Any:
344
+ return await self._client._get("/charms", {
345
+ "name": name, "collection": collection, "rarity": rarity,
346
+ "availability": availability, "bundle": bundle, "season": season,
347
+ })
348
+
349
+ async def get_universal_skins(self, *, name: Optional[str] = None) -> Any:
350
+ return await self._client._get("/universalSkins", {"name": name})
351
+
352
+ async def get_attachment(self, *, name: Optional[str] = None,
353
+ style: Optional[str] = None,
354
+ rarity: Optional[str] = None,
355
+ availability: Optional[str] = None,
356
+ bundle: Optional[str] = None,
357
+ season: Optional[str] = None) -> Any:
358
+ return await self._client._get("/attachment", {
359
+ "name": name, "style": style, "rarity": rarity,
360
+ "availability": availability, "bundle": bundle, "season": season,
361
+ })
362
+
363
+ async def get_search_all(self, query: str) -> Any:
364
+ if not query or not isinstance(query, str):
365
+ raise ValueError("Search query is required and must be a string")
366
+ return await self._client._get("/searchAll", {"q": query})
367
+
368
+ async def get_service_status(self) -> Any:
369
+ return await self._client._get("/serviceStatus", {})
370
+
371
+
372
+ # ──────────────────────────────────────────────────────────────────────────
373
+ # 主客户端
374
+ # ──────────────────────────────────────────────────────────────────────────
375
+
376
+ class R6Client:
377
+ """
378
+ 用法::
379
+
380
+ r6 = R6Client(api_key="...")
381
+ try:
382
+ data = await r6.players.get_player_stats("Name", "uplay", "pc")
383
+ finally:
384
+ await r6.aclose()
385
+
386
+ 也可作为异步上下文管理器::
387
+
388
+ async with R6Client(api_key="...") as r6:
389
+ ...
390
+ """
391
+
392
+ def __init__(self, api_key: str, *, base_url: str = BASE_URL,
393
+ timeout: float = 15.0,
394
+ client: Optional[httpx.AsyncClient] = None) -> None:
395
+ if not api_key:
396
+ raise ValueError("Missing required config parameter: api_key")
397
+ self.api_key = api_key
398
+ self._base_url = base_url.rstrip("/")
399
+ # 允许外部注入共享的 AsyncClient(例如复用 NoneBot 的);否则自建。
400
+ self._http = client or httpx.AsyncClient(timeout=timeout)
401
+ self._owns_http = client is None
402
+
403
+ self.players = Players(self)
404
+ self.game = Game(self)
405
+
406
+ async def __aenter__(self) -> "R6Client":
407
+ return self
408
+
409
+ async def __aexit__(self, *exc: Any) -> None:
410
+ await self.aclose()
411
+
412
+ async def aclose(self) -> None:
413
+ """关闭内部创建的 httpx client(注入的不会被关闭)。"""
414
+ if self._owns_http:
415
+ await self._http.aclose()
416
+
417
+ async def _get(self, path: str, params: Mapping[str, Any]) -> Any:
418
+ url = f"{self._base_url}/{path.lstrip('/')}"
419
+ headers = {
420
+ "Accept": "application/json",
421
+ "Cache-Control": "no-cache",
422
+ "User-Agent": f"r6data.py (port of r6-data.js@{BASED_ON_VERSION})",
423
+ "api-key": self.api_key,
424
+ }
425
+ resp = await self._http.get(url, params=_clean_params(params), headers=headers)
426
+ return self._handle(resp)
427
+
428
+ @staticmethod
429
+ def _handle(resp: httpx.Response) -> Any:
430
+ text = resp.text
431
+ data: Any = None
432
+ if text:
433
+ try:
434
+ data = resp.json()
435
+ except ValueError:
436
+ data = text
437
+ if resp.is_success:
438
+ return data
439
+ if resp.status_code == 401:
440
+ raise R6APIError("Authentication error", status=401, data=data)
441
+ raise R6APIError(
442
+ f"Request failed with status code {resp.status_code}",
443
+ status=resp.status_code, data=data,
444
+ )
445
+
446
+
447
+ # ──────────────────────────────────────────────────────────────────────────
448
+ # 启动时版本检查
449
+ # ──────────────────────────────────────────────────────────────────────────
450
+
451
+ _NPM_LATEST_URL = "https://registry.npmjs.org/r6-data.js/latest"
452
+
453
+
454
+ def _parse_semver(v: str) -> tuple[int, ...]:
455
+ core = v.split("-", 1)[0].split("+", 1)[0]
456
+ parts: list[int] = []
457
+ for piece in core.split("."):
458
+ try:
459
+ parts.append(int(piece))
460
+ except ValueError:
461
+ parts.append(0)
462
+ return tuple(parts)
463
+
464
+
465
+ async def check_latest_version(*, timeout: float = 5.0) -> Optional[str]:
466
+ """查 npm registry 上 r6-data.js 的最新版本号。
467
+
468
+ 成功返回版本字符串(如 "3.1.8");网络/解析失败返回 None(只告警、
469
+ 绝不阻塞启动)。比对建议::
470
+
471
+ latest = await check_latest_version()
472
+ if latest and _parse_semver(latest) > _parse_semver(BASED_ON_VERSION):
473
+ logger.warning(...)
474
+ """
475
+ try:
476
+ async with httpx.AsyncClient(timeout=timeout) as c:
477
+ resp = await c.get(_NPM_LATEST_URL,
478
+ headers={"Accept": "application/json"})
479
+ if resp.is_success:
480
+ version = resp.json().get("version")
481
+ return version if isinstance(version, str) else None
482
+ except Exception: # noqa: BLE001 - 检查失败不应影响主流程
483
+ return None
484
+ return None
485
+
486
+
487
+ def is_outdated(latest: Optional[str]) -> bool:
488
+ """latest 比 BASED_ON_VERSION 新则 True;latest 为 None 时 False。"""
489
+ if not latest:
490
+ return False
491
+ return _parse_semver(latest) > _parse_semver(BASED_ON_VERSION)
@@ -0,0 +1,201 @@
1
+ """把 fullStats 渲染成图片(PNG 字节)。
2
+
3
+ - 双字体:拉丁/数字用 Mona Sans,汉字用 Noto Sans CJK,按字符分段、基线对齐。
4
+ - 2x 超采样:按 SCALE 倍分辨率绘制,避免客户端放大时发虚。
5
+ - 干员区为对齐表格,数字右对齐;攻/防用不同颜色。
6
+ 内容提取复用 formatter 的 _format_boards(档案行);干员直接读结构化字段以便排版。
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from io import BytesIO
11
+ from typing import Any, Optional
12
+ from pathlib import Path
13
+
14
+ from PIL import Image, ImageDraw, ImageFont
15
+
16
+ from .formatter import _format_boards
17
+
18
+ _ASSETS = Path(__file__).parent / "assets"
19
+ _MONA = str(_ASSETS / "MonaSans.ttf")
20
+ _NOTO = str(_ASSETS / "NotoSansSC.ttf")
21
+
22
+ # 配色
23
+ _BG = (24, 27, 33)
24
+ _WHITE = (240, 242, 245)
25
+ _GRAY = (140, 146, 156)
26
+ _DIM = (96, 102, 112)
27
+ _ACCENT = (240, 165, 0)
28
+ _BODY = (210, 214, 220)
29
+ _LINE = (44, 49, 58)
30
+ _ATK = (236, 122, 80) # 攻
31
+ _DEF = (94, 160, 232) # 防
32
+
33
+ _SCALE = 2 # 超采样倍率
34
+ _W = 760 * _SCALE
35
+ _PAD = 36 * _SCALE
36
+
37
+ _font_cache: dict[tuple[str, int, Optional[int]], ImageFont.FreeTypeFont] = {}
38
+
39
+
40
+ def _font(path: str, size: int, weight: Optional[int] = None) -> ImageFont.FreeTypeFont:
41
+ """按字号取字体;weight 给定时尝试调可变字重(失败则忽略)。"""
42
+ key = (path, size * _SCALE, weight)
43
+ if key in _font_cache:
44
+ return _font_cache[key]
45
+ f = ImageFont.truetype(path, size * _SCALE)
46
+ if weight is not None:
47
+ try:
48
+ vals = []
49
+ for ax in f.get_variation_axes():
50
+ name = ax.get("name", b"")
51
+ name = name.decode() if isinstance(name, bytes) else name
52
+ vals.append(weight if name.lower() == "weight" else ax.get("default", 0))
53
+ f.set_variation_by_axes(vals)
54
+ except Exception: # noqa: BLE001 - 非可变字体或轴缺失时退回默认实例
55
+ pass
56
+ _font_cache[key] = f
57
+ return f
58
+
59
+
60
+ def _is_cjk(ch: str) -> bool:
61
+ return "一" <= ch <= "鿿" or " " <= ch <= "〿" or "＀" <= ch <= "￯"
62
+
63
+
64
+ def _runs(text: str) -> list[tuple[str, bool]]:
65
+ out: list[tuple[str, bool]] = []
66
+ cur, cur_cjk = "", None
67
+ for ch in text:
68
+ c = _is_cjk(ch)
69
+ if cur and c != cur_cjk:
70
+ out.append((cur, cur_cjk)) # type: ignore[arg-type]
71
+ cur = ""
72
+ cur, cur_cjk = cur + ch, c
73
+ if cur:
74
+ out.append((cur, cur_cjk)) # type: ignore[arg-type]
75
+ return out
76
+
77
+
78
+ def _measure(text: str, size: int, weight: Optional[int] = None) -> int:
79
+ return sum(
80
+ int(_font(_NOTO if cjk else _MONA, size, weight).getlength(run))
81
+ for run, cjk in _runs(text)
82
+ )
83
+
84
+
85
+ def _draw(draw: ImageDraw.ImageDraw, x: int, baseline: int, text: str,
86
+ size: int, fill: tuple[int, int, int], weight: Optional[int] = None) -> int:
87
+ for run, cjk in _runs(text):
88
+ f = _font(_NOTO if cjk else _MONA, size, weight)
89
+ draw.text((x, baseline), run, font=f, fill=fill, anchor="ls")
90
+ x += int(f.getlength(run))
91
+ return x
92
+
93
+
94
+ def _draw_right(draw: ImageDraw.ImageDraw, right_x: int, baseline: int, text: str,
95
+ size: int, fill: tuple[int, int, int], weight: Optional[int] = None) -> None:
96
+ _draw(draw, right_x - _measure(text, size, weight), baseline, text, size, fill, weight)
97
+
98
+
99
+ def _ascent(size: int) -> int:
100
+ return _font(_NOTO, size).getmetrics()[0]
101
+
102
+
103
+ def render_full_stats(player_id: str, data: dict[str, Any], top_n: int = 8) -> bytes:
104
+ info = data.get("data") or {}
105
+ handle = (info.get("platformInfo") or {}).get("platformUserHandle") or player_id
106
+ meta = info.get("metadata") or {}
107
+
108
+ # 画在足够高的画布上,最后按实际内容裁剪
109
+ img = Image.new("RGB", (_W, 1600 * _SCALE), _BG)
110
+ draw = ImageDraw.Draw(img)
111
+ draw.rectangle([(0, 0), (_W, 6 * _SCALE)], fill=_ACCENT)
112
+
113
+ p = _PAD
114
+ rcol = _W - _PAD
115
+ y = _PAD + 8 * _SCALE
116
+
117
+ # ── 头部:玩家名 + 等级/通行证 ──
118
+ y += _ascent(38)
119
+ _draw(draw, p, y, handle, 38, _WHITE, weight=700)
120
+ bits = []
121
+ if meta.get("clearanceLevel") is not None:
122
+ bits.append(f"Lv {meta['clearanceLevel']}")
123
+ if meta.get("battlepassLevel") is not None:
124
+ bits.append(f"BP {meta['battlepassLevel']}")
125
+ if bits:
126
+ _draw_right(draw, rcol, y, " ".join(bits), 20, _GRAY)
127
+ y += _font(_NOTO, 38).getmetrics()[1] + 14 * _SCALE
128
+
129
+ def section(title: str) -> None:
130
+ nonlocal y
131
+ y += 16 * _SCALE
132
+ draw.line([(p, y), (rcol, y)], fill=_LINE, width=1 * _SCALE)
133
+ y += 14 * _SCALE + _ascent(22)
134
+ _draw(draw, p, y, title, 22, _ACCENT, weight=700)
135
+ y += _font(_NOTO, 22).getmetrics()[1] + 10 * _SCALE
136
+
137
+ # ── 档案 ──
138
+ boards = _format_boards(data.get("platform_families_full_profiles") or [])
139
+ if boards:
140
+ section("档案")
141
+ for line in boards:
142
+ y += _ascent(21)
143
+ _draw(draw, p, y, line, 21, _BODY)
144
+ y += _font(_NOTO, 21).getmetrics()[1] + 8 * _SCALE
145
+
146
+ # ── 干员:攻/防双列,各取出场前 4 ──
147
+ operators = data.get("operators") or []
148
+ if operators:
149
+ section("干员 Top")
150
+ by_rounds = lambda o: o.get("roundsPlayed", 0) # noqa: E731
151
+ atks = sorted((o for o in operators if o.get("side") == "Attacker"),
152
+ key=by_rounds, reverse=True)[:4]
153
+ defs = sorted((o for o in operators if o.get("side") == "Defender"),
154
+ key=by_rounds, reverse=True)[:4]
155
+
156
+ gap = 48 * _SCALE
157
+ col_w = (_W - 2 * _PAD - gap) // 2
158
+ lx, rx = p, p + col_w + gap
159
+
160
+ # 列头:攻 / 防
161
+ y += _ascent(19)
162
+ _draw(draw, lx, y, "攻", 19, _ATK, weight=700)
163
+ _draw(draw, rx, y, "防", 19, _DEF, weight=700)
164
+ y += _font(_NOTO, 19).getmetrics()[1] + 12 * _SCALE
165
+
166
+ def _cell(cx: int, base: int, op: dict[str, Any]) -> None:
167
+ r = cx + col_w
168
+ nx = _draw(draw, cx, base, op.get("operator", "?"), 21, _BODY, weight=600)
169
+ _draw(draw, nx + 6 * _SCALE, base, f"·{op.get('roundsPlayed', 0)}", 15, _DIM)
170
+ _draw_right(draw, r, base, f"{op.get('kd', 0)}", 21, _WHITE, weight=700)
171
+ _draw_right(draw, r - 78 * _SCALE, base, f"{op.get('winPercent', 0)}%", 17, _BODY)
172
+
173
+ for i in range(max(len(atks), len(defs))):
174
+ base = y + _ascent(21)
175
+ if i < len(atks):
176
+ _cell(lx, base, atks[i])
177
+ if i < len(defs):
178
+ _cell(rx, base, defs[i])
179
+ y = base + _font(_NOTO, 21).getmetrics()[1] + 11 * _SCALE
180
+
181
+ # 合计(全部干员)
182
+ kills = sum(o.get("kills", 0) for o in operators)
183
+ deaths = sum(o.get("deaths", 0) for o in operators)
184
+ rounds = sum(o.get("roundsPlayed", 0) for o in operators)
185
+ kd = kills / deaths if deaths else float(kills)
186
+ y += 6 * _SCALE
187
+ draw.line([(p, y), (rcol, y)], fill=_LINE, width=1 * _SCALE)
188
+ y += 12 * _SCALE + _ascent(19)
189
+ _draw(draw, p, y, f"合计 {len(operators)} 干员 · 场{rounds}", 19, _GRAY)
190
+ _draw_right(draw, rcol, y, f"KD {kd:.2f}", 19, _GRAY)
191
+ y += _font(_NOTO, 19).getmetrics()[1]
192
+
193
+ if not boards and not operators:
194
+ y += _ascent(22)
195
+ _draw(draw, p, y, "没有查询到数据", 22, _GRAY)
196
+ y += _font(_NOTO, 22).getmetrics()[1]
197
+
198
+ img = img.crop((0, 0, _W, y + _PAD))
199
+ buf = BytesIO()
200
+ img.save(buf, format="PNG")
201
+ return buf.getvalue()
@@ -0,0 +1,68 @@
1
+ """数据源(API 为主)+ 缓存层。
2
+
3
+ 对外只暴露 ``get_operator_stats``:先查本地缓存,未命中再打 r6data API,
4
+ 成功后回写缓存。错误统一转成 ``ServiceError``,由指令层决定怎么回话。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ from .cache import JSONCache
11
+ from .storage import PLAYER_CACHE_FILE
12
+ from .r6data import R6Client, R6APIError
13
+ from .config_mannger import resolve_apikey
14
+
15
+ #: 玩家快照属于会变的数据,缓存 30 分钟即可显著降低 API 用量。
16
+ FULL_STATS_TTL = 30 * 60
17
+
18
+ VALID_PLATFORMS = ("uplay", "psn", "xbl")
19
+
20
+ #: 免费申请 api-key 的入口,附在缺/失效提示里
21
+ APIKEY_HELP = "可在 https://r6data.com/ 免费获取 Key,再用 /r6key <key> 绑定"
22
+
23
+ _cache = JSONCache(PLAYER_CACHE_FILE)
24
+
25
+
26
+ class ServiceError(Exception):
27
+ def __init__(self, message: str, *, expired: bool = False) -> None:
28
+ super().__init__(message)
29
+ self.message = message
30
+ self.expired = expired
31
+
32
+
33
+ async def get_full_stats(
34
+ player_id: str,
35
+ scopes: list[str],
36
+ platform: str = "uplay",
37
+ season_year: str | None = None,
38
+ modes: str | None = None,
39
+ ) -> dict[str, Any]:
40
+ if platform not in VALID_PLATFORMS:
41
+ raise ServiceError(f"平台无效,可选:{', '.join(VALID_PLATFORMS)}")
42
+
43
+ api_key, _ = resolve_apikey(scopes)
44
+ if not api_key:
45
+ raise ServiceError(f"未设置 API Key。{APIKEY_HELP}")
46
+
47
+ 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)
49
+ if cached is not None:
50
+ return cached
51
+
52
+ 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
+ )
57
+ except R6APIError as e:
58
+ if e.status == 401:
59
+ raise ServiceError(
60
+ f"API Key 鉴权失败,可能已过期(官方有效期约 1 个月)。{APIKEY_HELP}",
61
+ expired=True,
62
+ ) from e
63
+ raise ServiceError(f"API 返回错误({e.status}):{e}") from e
64
+ except Exception as e: # noqa: BLE001 - 网络等异常统一兜底
65
+ raise ServiceError(f"请求失败:{type(e).__name__}") from e
66
+
67
+ await _cache.set(cache_key, data)
68
+ return data
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ DATA_DIR = Path("data") / "r6states"
6
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
7
+
8
+ API_KEY_FILE = DATA_DIR / "api_keys.json"
9
+ PLAYER_CACHE_FILE = DATA_DIR / "cache.json"
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.1
2
+ Name: nonebot-plugin-R6States
3
+ Version: 1.0.0
4
+ Summary: 查询指定玩家的各项数据
5
+ Author-Email: Siornya <wxh200607@outlook.com>
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: nonebot2>=2.3.1
9
+ Requires-Dist: nonebot-adapter-onebot>=2.4.6
10
+ Requires-Dist: httpx>=0.27
11
+ Requires-Dist: pillow>=10.0
12
+ Description-Content-Type: text/markdown
13
+
14
+ # nonebot-plugin-R6States
15
+
16
+ 一个基于 **NoneBot2** 的《彩虹六号:围攻》战绩查询插件
17
+
18
+ ---
19
+
20
+ ## 功能特性
21
+
22
+ * ✅ 通过 QQ 指令查询 R6 玩家战绩
23
+ * ✅ 支持 **单人查询 / 多人查询**
24
+ * ✅ 数据源自 [R6Data API](https://r6data.com/)
25
+ * ✅ 玩家数据缓存半小时,降低 API 用量
26
+ * ✅ 数据分析功能
27
+ * ⚙️ 地图筛选 `-m / --map`
28
+
29
+ ---
30
+
31
+ ## 参考运行环境
32
+
33
+ * **Python 3.12**
34
+ * **NoneBot2**
35
+ * **OneBot v11**
36
+ * **NapCat(反向 WebSocket)**
37
+
38
+ ---
39
+
40
+ ## Usage 使用说明
41
+
42
+ ### 安装
43
+
44
+ 可以直接将 `nonebot_plugin_R6States` 文件夹放入插件目录中。
45
+
46
+ ### 基础指令
47
+
48
+ ```text
49
+ /R6 <player_id>
50
+ ```
51
+
52
+ ### 多人查询
53
+
54
+ ```text
55
+ /R6 -g <id1> <id2> ... <idN>
56
+ ```
57
+
58
+ ### 帮助信息
59
+
60
+ ```text
61
+ /R6help
62
+ ```
63
+
64
+ ### 额外获取地图信息
65
+
66
+ ```text
67
+ -m / --map <map_name>
68
+ ```
69
+
70
+ ---
71
+
72
+ ## 特别提醒
73
+
74
+ * 本插件为 **非育碧官方工具**
75
+ * 所有数据来自R6Data API
76
+ * 设计初衷仅对于个人与学习
77
+ * 请勿用于“超出个人正常使用范围”的用途
@@ -0,0 +1,16 @@
1
+ nonebot_plugin_R6States/__init__.py,sha256=Qf9gGA4MGqxaQELX2hJDMQHzDPBMP6rSZ4w2funIoms,4467
2
+ nonebot_plugin_R6States/assets/MonaSans.ttf,sha256=hKrhDUQnoZR-lrH9mybDEJ_6D1Dy-q6M5GDKHjSIntU,347676
3
+ nonebot_plugin_R6States/assets/NotoSansSC.ttf,sha256=owQYEaeMNhsd5Q-VPIBeAkSVHCHFvUEvcjLvDYma8No,17772300
4
+ nonebot_plugin_R6States/cache.py,sha256=j6uDEJ378kge0igGY9MzyLq9ccjBgPCi80MPFSz-js4,2314
5
+ nonebot_plugin_R6States/config.py,sha256=vqD21-_DqbPHvqLBmn-XXwSHThjmxNYLZoUnbPFqGIA,479
6
+ nonebot_plugin_R6States/config_mannger.py,sha256=ObmYL0KRVFUKMwkcsNy70hc37igfkR6rlQDp2dkILLw,2073
7
+ nonebot_plugin_R6States/formatter.py,sha256=lfi6F9dn1bjkaOyHKAsoIFJq2pQmY-T5TW9mojMxdoI,3627
8
+ nonebot_plugin_R6States/r6data.py,sha256=E5zJVs5kUK7ufK9Y0_8ojsQ0up6UjQmYbOLudSCWb0s,21941
9
+ nonebot_plugin_R6States/renderer.py,sha256=TsGSc0Ap-cch5HXnRS2gfeMn44nRzzsLhK0L8s7CwTA,7498
10
+ nonebot_plugin_R6States/service.py,sha256=5YfX5Ie__IuZptVvWGp92m1TFozPU-9dM220mQgVvP8,2371
11
+ nonebot_plugin_R6States/storage.py,sha256=SOVBSSPrhOiSpV7glJseyrxjBOpARpvLFg-NBZMekcU,230
12
+ nonebot_plugin_r6states-1.0.0.dist-info/METADATA,sha256=22JZ6GwFTrCdUPn4YzzQuGMlf363CtDFx1OSctqblLw,1383
13
+ nonebot_plugin_r6states-1.0.0.dist-info/WHEEL,sha256=VP-D4TPS230sME9Z3vb3INXvo1yt0924YRm5AOsk_dE,90
14
+ nonebot_plugin_r6states-1.0.0.dist-info/entry_points.txt,sha256=6OYgBcLyFCUgeqLgnvMyOJxPCWzgy7se4rLPKtNonMs,34
15
+ nonebot_plugin_r6states-1.0.0.dist-info/licenses/LICENSE,sha256=gPW3tSB4cMTX_vKXHjEqRkaOy2unmqs09opEBaky93M,1064
16
+ nonebot_plugin_r6states-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: pdm-backend (2.4.9)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+
3
+ [gui_scripts]
4
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Siornya
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.