nonebot-plugin-gspanel-lite 0.1.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,288 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import asyncio
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from json import JSONDecodeError
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
import aiohttp
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from nonebot import on_command, require
|
|
12
|
+
from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, Message, MessageSegment
|
|
13
|
+
from nonebot.log import logger
|
|
14
|
+
from nonebot.matcher import Matcher
|
|
15
|
+
from nonebot.params import ArgPlainText, CommandArg
|
|
16
|
+
from nonebot.plugin import PluginMetadata
|
|
17
|
+
except ImportError:
|
|
18
|
+
on_command = None
|
|
19
|
+
require = None
|
|
20
|
+
Bot = None
|
|
21
|
+
GroupMessageEvent = None
|
|
22
|
+
Message = None
|
|
23
|
+
MessageSegment = None
|
|
24
|
+
logger = None
|
|
25
|
+
Matcher = None
|
|
26
|
+
ArgPlainText = None
|
|
27
|
+
CommandArg = None
|
|
28
|
+
PluginMetadata = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
if PluginMetadata is not None:
|
|
32
|
+
__plugin_meta__ = PluginMetadata(
|
|
33
|
+
name="GSPanel Lite",
|
|
34
|
+
description="通过 Enka 查询原神 UID 公开展示信息",
|
|
35
|
+
usage="发送 /uid <原神UID> 查询原神公开角色展示信息",
|
|
36
|
+
type="application",
|
|
37
|
+
homepage="https://github.com/WhyPilotXia/nonebot-plugin-gspanel-lite",
|
|
38
|
+
supported_adapters={"~onebot.v11"},
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
DEFAULT_TIMEOUT = aiohttp.ClientTimeout(total=20)
|
|
43
|
+
HEADERS = {
|
|
44
|
+
"User-Agent": (
|
|
45
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
46
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
47
|
+
"Chrome/125.0 Safari/537.36"
|
|
48
|
+
),
|
|
49
|
+
"Accept": "text/html,application/json;q=0.9,*/*;q=0.8",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class EnkaQueryError(Exception):
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def fetch_text(session: aiohttp.ClientSession, url: str) -> str:
|
|
58
|
+
async with session.get(url, headers=HEADERS) as response:
|
|
59
|
+
text = await response.text(encoding="utf-8", errors="replace")
|
|
60
|
+
if response.status >= 400:
|
|
61
|
+
body = text[:300].replace("\n", " ")
|
|
62
|
+
raise EnkaQueryError(f"请求失败 HTTP {response.status}: {body}")
|
|
63
|
+
return text
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def extract_const_data_value(html: str) -> str:
|
|
67
|
+
match = re.search(r"const\s+data\s*=", html)
|
|
68
|
+
if not match:
|
|
69
|
+
raise EnkaQueryError("HTML 中没有找到 const data。")
|
|
70
|
+
|
|
71
|
+
start = html.find("[", match.end())
|
|
72
|
+
if start == -1:
|
|
73
|
+
raise EnkaQueryError("const data 后没有找到数组开头。")
|
|
74
|
+
|
|
75
|
+
depth = 0
|
|
76
|
+
quote: Optional[str] = None
|
|
77
|
+
escaped = False
|
|
78
|
+
|
|
79
|
+
for index in range(start, len(html)):
|
|
80
|
+
char = html[index]
|
|
81
|
+
if quote:
|
|
82
|
+
if escaped:
|
|
83
|
+
escaped = False
|
|
84
|
+
elif char == "\\":
|
|
85
|
+
escaped = True
|
|
86
|
+
elif char == quote:
|
|
87
|
+
quote = None
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
if char in ("'", '"', "`"):
|
|
91
|
+
quote = char
|
|
92
|
+
elif char in "[{(":
|
|
93
|
+
depth += 1
|
|
94
|
+
elif char in "]})":
|
|
95
|
+
depth -= 1
|
|
96
|
+
if depth == 0:
|
|
97
|
+
return html[start:index + 1]
|
|
98
|
+
|
|
99
|
+
raise EnkaQueryError("const data 数组没有正常闭合。")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def js_object_literal_to_json(js_text: str) -> str:
|
|
103
|
+
text = js_text
|
|
104
|
+
text = re.sub(r"\bvoid\s+0\b", "null", text)
|
|
105
|
+
text = re.sub(r"\bundefined\b", "null", text)
|
|
106
|
+
text = re.sub(r":\s*(\.\d+)", r": 0\1", text)
|
|
107
|
+
text = re.sub(r"(?P<prefix>[{,\s])(?P<key>[A-Za-z_$][\w$]*)\s*:", r'\g<prefix>"\g<key>":', text)
|
|
108
|
+
text = re.sub(r",\s*([}\]])", r"\1", text)
|
|
109
|
+
return text
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def find_profile_data(value: Any) -> Optional[dict[str, Any]]:
|
|
113
|
+
if isinstance(value, dict):
|
|
114
|
+
data = value.get("data")
|
|
115
|
+
if isinstance(data, dict) and isinstance(data.get("playerInfo"), dict):
|
|
116
|
+
return data
|
|
117
|
+
for child in value.values():
|
|
118
|
+
result = find_profile_data(child)
|
|
119
|
+
if result:
|
|
120
|
+
return result
|
|
121
|
+
elif isinstance(value, list):
|
|
122
|
+
for child in value:
|
|
123
|
+
result = find_profile_data(child)
|
|
124
|
+
if result:
|
|
125
|
+
return result
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def find_player_info(value: Any) -> Optional[dict[str, Any]]:
|
|
130
|
+
profile_data = find_profile_data(value)
|
|
131
|
+
if profile_data:
|
|
132
|
+
return profile_data.get("playerInfo")
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def parse_profile_data_from_html(html: str) -> dict[str, Any]:
|
|
137
|
+
const_data = extract_const_data_value(html)
|
|
138
|
+
json_text = js_object_literal_to_json(const_data)
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
data = json.loads(json_text)
|
|
142
|
+
except JSONDecodeError as exc:
|
|
143
|
+
preview = json_text[max(0, exc.pos - 120):exc.pos + 180].replace("\n", " ")
|
|
144
|
+
raise EnkaQueryError(f"HTML 中 const data 解析失败: {exc}; 附近内容: {preview}") from exc
|
|
145
|
+
|
|
146
|
+
profile_data = find_profile_data(data)
|
|
147
|
+
if not profile_data:
|
|
148
|
+
raise EnkaQueryError("const data 中没有找到 playerInfo/avatarInfoList。")
|
|
149
|
+
return profile_data
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def format_value(value: Any, limit: int = 80) -> str:
|
|
153
|
+
text = json.dumps(value, ensure_ascii=False) if isinstance(value, (dict, list)) else str(value)
|
|
154
|
+
if len(text) > limit:
|
|
155
|
+
return text[:limit] + "..."
|
|
156
|
+
return text
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def format_profile_summary(profile_data: dict[str, Any]) -> str:
|
|
160
|
+
player_info = profile_data.get("playerInfo", {})
|
|
161
|
+
avatar_list = profile_data.get("avatarInfoList", [])
|
|
162
|
+
|
|
163
|
+
lines = []
|
|
164
|
+
for key, value in player_info.items():
|
|
165
|
+
lines.append(f"{key} {format_value(value)}")
|
|
166
|
+
|
|
167
|
+
if isinstance(avatar_list, list):
|
|
168
|
+
lines.append(f"avatarInfoList_count {len(avatar_list)}")
|
|
169
|
+
for index, avatar in enumerate(avatar_list[:3], start=1):
|
|
170
|
+
avatar_id = avatar.get("avatarId") if isinstance(avatar, dict) else None
|
|
171
|
+
equip_count = len(avatar.get("equipList", [])) if isinstance(avatar, dict) else 0
|
|
172
|
+
lines.append(f"avatar_{index} avatarId={avatar_id} equipList_count={equip_count}")
|
|
173
|
+
|
|
174
|
+
return "\n".join(lines) if lines else "没有可展示的数据。"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def get_profile_data(uid: str) -> dict[str, Any]:
|
|
178
|
+
uid = uid.strip()
|
|
179
|
+
if not uid:
|
|
180
|
+
raise EnkaQueryError("UID 不能为空。")
|
|
181
|
+
|
|
182
|
+
async with aiohttp.ClientSession(timeout=DEFAULT_TIMEOUT) as session:
|
|
183
|
+
html = await fetch_text(session, f"https://enka.network/u/{uid}/")
|
|
184
|
+
return parse_profile_data_from_html(html)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
async def get_player_info(uid: str) -> dict[str, Any]:
|
|
188
|
+
profile_data = await get_profile_data(uid)
|
|
189
|
+
player_info = profile_data.get("playerInfo")
|
|
190
|
+
if not isinstance(player_info, dict):
|
|
191
|
+
raise EnkaQueryError("没有找到 playerInfo。")
|
|
192
|
+
return player_info
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def getuid(uid: str) -> str:
|
|
196
|
+
try:
|
|
197
|
+
profile_data = await get_profile_data(uid)
|
|
198
|
+
except EnkaQueryError as exc:
|
|
199
|
+
return str(exc)
|
|
200
|
+
return format_profile_summary(profile_data)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
async def render_enka_page(uid: str) -> bytes:
|
|
204
|
+
if require is not None:
|
|
205
|
+
require("nonebot_plugin_htmlrender")
|
|
206
|
+
|
|
207
|
+
from nonebot_plugin_htmlrender import get_new_page
|
|
208
|
+
|
|
209
|
+
uid = uid.strip()
|
|
210
|
+
if not uid:
|
|
211
|
+
raise EnkaQueryError("UID 不能为空。")
|
|
212
|
+
|
|
213
|
+
async with get_new_page(
|
|
214
|
+
viewport={"width": 1700, "height": 1200},
|
|
215
|
+
device_scale_factor=1,
|
|
216
|
+
) as page:
|
|
217
|
+
try:
|
|
218
|
+
# "load" 或 "domcontentloaded"比 networkidle 容易达成得多
|
|
219
|
+
await page.goto(
|
|
220
|
+
f"https://enka.network/u/{uid}/",
|
|
221
|
+
wait_until="load",
|
|
222
|
+
timeout=15000
|
|
223
|
+
)
|
|
224
|
+
except Exception as e:
|
|
225
|
+
if logger:
|
|
226
|
+
logger.warning(f"{e} UID {uid} 页面加载超时,尝试强行截图...")
|
|
227
|
+
|
|
228
|
+
# 无论成功还是超时,都在这里等 3~4 秒,给残留的异步组件/文字渲染留出时间
|
|
229
|
+
await page.wait_for_timeout(4000)
|
|
230
|
+
|
|
231
|
+
return await page.screenshot(full_page=True, type="png")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
if on_command is not None and __name__ != "__main__":
|
|
235
|
+
try:
|
|
236
|
+
uid_matcher = on_command("uid")
|
|
237
|
+
|
|
238
|
+
@uid_matcher.handle()
|
|
239
|
+
async def _handle(matcher: Matcher, uid_arg: Message = CommandArg()):
|
|
240
|
+
if uid_arg.extract_plain_text():
|
|
241
|
+
matcher.set_arg("uid", uid_arg)
|
|
242
|
+
|
|
243
|
+
@uid_matcher.got("uid", prompt="你想查询哪个原神 UID?")
|
|
244
|
+
async def _(bot: Bot, event: GroupMessageEvent, uid: str = ArgPlainText("uid")):
|
|
245
|
+
try:
|
|
246
|
+
await bot.call_api(
|
|
247
|
+
"set_msg_emoji_like",
|
|
248
|
+
group_id=event.group_id,
|
|
249
|
+
message_id=event.message_id,
|
|
250
|
+
emoji_id="318",
|
|
251
|
+
set=True,
|
|
252
|
+
)
|
|
253
|
+
except Exception as e:
|
|
254
|
+
if logger:
|
|
255
|
+
logger.warning(e)
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
image = await render_enka_page(uid)
|
|
259
|
+
await uid_matcher.send(MessageSegment.image(image))
|
|
260
|
+
return
|
|
261
|
+
except Exception as e:
|
|
262
|
+
if logger:
|
|
263
|
+
logger.opt(exception=e).warning("Enka 页面渲染失败,回退到文本解析")
|
|
264
|
+
|
|
265
|
+
info = await getuid(uid=uid)
|
|
266
|
+
await uid_matcher.send(info)
|
|
267
|
+
except ValueError:
|
|
268
|
+
uid_matcher = None
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
async def cli_main() -> None:
|
|
272
|
+
parser = argparse.ArgumentParser(description="独立测试 Enka 原神 UID 展示请求和解析。")
|
|
273
|
+
parser.add_argument("uid", nargs="?", default="218847690", help="原神 UID,默认 218847690")
|
|
274
|
+
parser.add_argument("--raw", action="store_true", help="输出完整 profile data JSON,包括 avatarInfoList")
|
|
275
|
+
parser.add_argument("--player", action="store_true", help="只输出 playerInfo JSON")
|
|
276
|
+
args = parser.parse_args()
|
|
277
|
+
|
|
278
|
+
profile_data = await get_profile_data(args.uid)
|
|
279
|
+
if args.player:
|
|
280
|
+
print(json.dumps(profile_data["playerInfo"], ensure_ascii=False, indent=2))
|
|
281
|
+
elif args.raw:
|
|
282
|
+
print(json.dumps(profile_data, ensure_ascii=False, indent=2))
|
|
283
|
+
else:
|
|
284
|
+
print(format_profile_summary(profile_data))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
if __name__ == "__main__":
|
|
288
|
+
asyncio.run(cli_main())
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nonebot-plugin-gspanel-lite
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A lightweight NoneBot2 plugin for querying Genshin Impact UID profile information via Enka.
|
|
5
|
+
Project-URL: Homepage, https://github.com/WhyPilotXia/nonebot-plugin-gspanel-lite
|
|
6
|
+
Project-URL: Repository, https://github.com/WhyPilotXia/nonebot-plugin-gspanel-lite
|
|
7
|
+
Project-URL: Issues, https://github.com/WhyPilotXia/nonebot-plugin-gspanel-lite/issues
|
|
8
|
+
Author: WhyPilotXia
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: enka,genshin,nonebot,nonebot2,onebot
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Framework :: Robot Framework
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Communications :: Chat
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Requires-Dist: aiohttp>=3.9.0
|
|
25
|
+
Requires-Dist: nonebot-adapter-onebot>=2.4.0
|
|
26
|
+
Requires-Dist: nonebot-plugin-htmlrender>=0.3.0
|
|
27
|
+
Requires-Dist: nonebot2>=2.4.0
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# nonebot-plugin-gspanel-lite
|
|
31
|
+
|
|
32
|
+
轻量级原神 UID 信息查询插件,通过 Enka 展示公开角色信息。
|
|
33
|
+
|
|
34
|
+
## 安装
|
|
35
|
+
|
|
36
|
+
使用 NB-CLI 安装:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
nb plugin install nonebot-plugin-gspanel-lite
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
也可以使用 pip 安装:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install nonebot-plugin-gspanel-lite
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## 使用
|
|
49
|
+
|
|
50
|
+
发送命令:
|
|
51
|
+
|
|
52
|
+
```text
|
|
53
|
+
/uid 218847690
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
插件会优先渲染 Enka 页面截图,渲染失败时回退到文本解析结果。
|
|
57
|
+
|
|
58
|
+
## 配置
|
|
59
|
+
|
|
60
|
+
无需配置。
|
|
61
|
+
|
|
62
|
+
## 效果图
|
|
63
|
+
|
|
64
|
+
<img width="569" height="675" alt="image" src="https://github.com/user-attachments/assets/f63a6907-dd3c-43e7-bb27-eea529be18bf" />
|
|
65
|
+
|
|
66
|
+
<img width="1280" height="1600" alt="f2057300831fee73b3fcbea30dd3ff04_720" src="https://github.com/user-attachments/assets/113ff4a3-9bfd-4f29-9f8a-b23e2c8cd2ce" />
|
|
67
|
+
|
|
68
|
+
## 文本回退示例
|
|
69
|
+
|
|
70
|
+
```text
|
|
71
|
+
nickname lingu李玥
|
|
72
|
+
level 57
|
|
73
|
+
signature 刚入坑求大佬带带
|
|
74
|
+
worldLevel 8
|
|
75
|
+
nameCardId 210192
|
|
76
|
+
finishAchievementNum 648
|
|
77
|
+
towerFloorIndex 8
|
|
78
|
+
towerLevelIndex 3
|
|
79
|
+
avatarInfoList_count 12
|
|
80
|
+
avatar_1 avatarId=10000073 equipList_count=6
|
|
81
|
+
avatar_2 avatarId=10000098 equipList_count=6
|
|
82
|
+
avatar_3 avatarId=10000052 equipList_count=6
|
|
83
|
+
```
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
nonebot_plugin_gspanel_lite/__init__.py,sha256=7sbdPEXgX3l1Xs7K5gLIko3W0V5D2XeN2RhTYIlHs7Y,9604
|
|
2
|
+
nonebot_plugin_gspanel_lite-0.1.0.dist-info/METADATA,sha256=IufCVdJ8t_2Zn1jCZDZ-JUNntw310EnAsITwtzC3Tu4,2412
|
|
3
|
+
nonebot_plugin_gspanel_lite-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
4
|
+
nonebot_plugin_gspanel_lite-0.1.0.dist-info/licenses/LICENSE,sha256=cndtoMMrrO4BCkDQ3Tsp_ukCVpuEi0sT1klgKR8BVqc,1082
|
|
5
|
+
nonebot_plugin_gspanel_lite-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 JSON
|
|
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.
|