nonebot-plugin-R6States 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.
@@ -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,64 @@
1
+ # nonebot-plugin-R6States
2
+
3
+ 一个基于 **NoneBot2** 的《彩虹六号:围攻》战绩查询插件
4
+
5
+ ---
6
+
7
+ ## 功能特性
8
+
9
+ * ✅ 通过 QQ 指令查询 R6 玩家战绩
10
+ * ✅ 支持 **单人查询 / 多人查询**
11
+ * ✅ 数据源自 [R6Data API](https://r6data.com/)
12
+ * ✅ 玩家数据缓存半小时,降低 API 用量
13
+ * ✅ 数据分析功能
14
+ * ⚙️ 地图筛选 `-m / --map`
15
+
16
+ ---
17
+
18
+ ## 参考运行环境
19
+
20
+ * **Python 3.12**
21
+ * **NoneBot2**
22
+ * **OneBot v11**
23
+ * **NapCat(反向 WebSocket)**
24
+
25
+ ---
26
+
27
+ ## Usage 使用说明
28
+
29
+ ### 安装
30
+
31
+ 可以直接将 `nonebot_plugin_R6States` 文件夹放入插件目录中。
32
+
33
+ ### 基础指令
34
+
35
+ ```text
36
+ /R6 <player_id>
37
+ ```
38
+
39
+ ### 多人查询
40
+
41
+ ```text
42
+ /R6 -g <id1> <id2> ... <idN>
43
+ ```
44
+
45
+ ### 帮助信息
46
+
47
+ ```text
48
+ /R6help
49
+ ```
50
+
51
+ ### 额外获取地图信息
52
+
53
+ ```text
54
+ -m / --map <map_name>
55
+ ```
56
+
57
+ ---
58
+
59
+ ## 特别提醒
60
+
61
+ * 本插件为 **非育碧官方工具**
62
+ * 所有数据来自R6Data API
63
+ * 设计初衷仅对于个人与学习
64
+ * 请勿用于“超出个人正常使用范围”的用途
@@ -0,0 +1,102 @@
1
+ [project]
2
+ name = "nonebot-plugin-R6States"
3
+ version = "1.0.0"
4
+ description = "查询指定玩家的各项数据"
5
+ authors = [
6
+ { name = "Siornya", email = "wxh200607@outlook.com" },
7
+ ]
8
+ dependencies = [
9
+ "nonebot2>=2.3.1",
10
+ "nonebot-adapter-onebot>=2.4.6",
11
+ "httpx>=0.27",
12
+ "pillow>=10.0",
13
+ ]
14
+ requires-python = ">=3.10"
15
+ readme = "README.md"
16
+
17
+ [project.license]
18
+ text = "MIT"
19
+
20
+ [build-system]
21
+ requires = [
22
+ "pdm-backend",
23
+ ]
24
+ build-backend = "pdm.backend"
25
+
26
+ [tool.pdm]
27
+ distribution = true
28
+
29
+ [tool.pdm.build]
30
+ includes = [
31
+ "src",
32
+ ]
33
+
34
+ [tool.pdm.dev-dependencies]
35
+ dev = [
36
+ "black>=24.4.2",
37
+ "isort>=5.13.2",
38
+ "ruff>=0.4.6",
39
+ ]
40
+
41
+ [tool.pdm.scripts.format]
42
+ composite = [
43
+ "isort . ",
44
+ "black . ",
45
+ "ruff check .",
46
+ ]
47
+
48
+ [tool.black]
49
+ line-length = 90
50
+ target-version = [
51
+ "py39",
52
+ "py310",
53
+ "py311",
54
+ "py312",
55
+ ]
56
+ include = "\\.pyi?$"
57
+ extend-exclude = ""
58
+
59
+ [tool.isort]
60
+ profile = "black"
61
+ line_length = 90
62
+ length_sort = true
63
+ skip_gitignore = true
64
+ force_sort_within_sections = true
65
+ extra_standard_library = [
66
+ "typing_extensions",
67
+ ]
68
+
69
+ [tool.ruff]
70
+ line-length = 90
71
+ target-version = "py39"
72
+
73
+ [tool.ruff.lint]
74
+ select = [
75
+ "E",
76
+ "W",
77
+ "F",
78
+ "UP",
79
+ "C",
80
+ "T",
81
+ "PYI",
82
+ "PT",
83
+ "Q",
84
+ ]
85
+ ignore = [
86
+ "C901",
87
+ "T201",
88
+ "E731",
89
+ "E402",
90
+ ]
91
+
92
+ [tool.pyright]
93
+ pythonVersion = "3.9"
94
+ pythonPlatform = "All"
95
+ typeCheckingMode = "basic"
96
+
97
+ [tool.nonebot]
98
+ adapters = []
99
+ plugin_dirs = [
100
+ "src",
101
+ ]
102
+ builtin_plugins = []
@@ -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)