nonebot-plugin-dancecube 0.1.2__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.
- nonebot_plugin_dancecube/__init__.py +117 -0
- nonebot_plugin_dancecube/config.py +26 -0
- nonebot_plugin_dancecube/download.py +66 -0
- nonebot_plugin_dancecube/pic.py +343 -0
- nonebot_plugin_dancecube/recording.py +133 -0
- nonebot_plugin_dancecube/tokens.py +150 -0
- nonebot_plugin_dancecube/userinfo.py +34 -0
- nonebot_plugin_dancecube/utils.py +71 -0
- nonebot_plugin_dancecube-0.1.2.dist-info/METADATA +130 -0
- nonebot_plugin_dancecube-0.1.2.dist-info/RECORD +12 -0
- nonebot_plugin_dancecube-0.1.2.dist-info/WHEEL +4 -0
- nonebot_plugin_dancecube-0.1.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from nonebot import on_command, require, get_driver
|
|
2
|
+
from nonebot.adapters.onebot.v11 import Bot, Message, MessageEvent, MessageSegment
|
|
3
|
+
|
|
4
|
+
require("nonebot_plugin_apscheduler")
|
|
5
|
+
require("nonebot_plugin_htmlrender")
|
|
6
|
+
|
|
7
|
+
from .tokens import Token, TokenManager, TokenBuilder
|
|
8
|
+
from .utils import calc_time_difference
|
|
9
|
+
from .recording import MusicInfoManager
|
|
10
|
+
from .userinfo import UserInfo
|
|
11
|
+
from .pic import create_rating_analysis_img, create_ap30_img, create_single_song_record_img, update_official_covers
|
|
12
|
+
from .config import Config, user_tokens_file
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
from nonebot.plugin import PluginMetadata
|
|
16
|
+
|
|
17
|
+
__plugin_meta__ = PluginMetadata(
|
|
18
|
+
name="舞立方插件",
|
|
19
|
+
description="提供舞立方战力分析等基础功能",
|
|
20
|
+
usage="发送【/dc】获取帮助",
|
|
21
|
+
|
|
22
|
+
type="application",
|
|
23
|
+
homepage="https://github.com/1v7w/nonebot-plugin-dancecube",
|
|
24
|
+
|
|
25
|
+
config=Config,
|
|
26
|
+
|
|
27
|
+
supported_adapters={"~onebot.v11"},
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
SUPERUSERS = set(get_driver().config.superusers)
|
|
31
|
+
|
|
32
|
+
dc = on_command('dc', aliases={'dancecube', '舞立方'}, priority=50, block=True)
|
|
33
|
+
|
|
34
|
+
HELP_TEXT = (
|
|
35
|
+
"/dc myrt 获取战力分析\n"
|
|
36
|
+
"/dc myrtall 获取战力分析(包含自制谱)\n"
|
|
37
|
+
"/dc ap30 获取战绩最好的30首ap歌曲\n"
|
|
38
|
+
"/dc song [id] 获取歌曲id=[id]的个人成绩\n"
|
|
39
|
+
"/dc login 获取登录二维码(仅私聊可用)\n"
|
|
40
|
+
"/dc updatecover 更新官方曲目封面(仅超级用户)\n"
|
|
41
|
+
"/dc help 显示本帮助"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def _check_token(qq_userid: int) -> Token | str:
|
|
46
|
+
"""检查用户 token 状态,返回 token 或错误消息"""
|
|
47
|
+
token = await TokenManager(user_tokens_file).get_token_by_qq(qq_userid)
|
|
48
|
+
if token is None:
|
|
49
|
+
return '还没有登录。\n请私聊我发送"/dc login"来获取二维码登录吧。'
|
|
50
|
+
if calc_time_difference(token.expires) < 600:
|
|
51
|
+
return '登录过期。\n请私聊我发送"/dc login"来获取二维码登录吧。'
|
|
52
|
+
return token
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dc.handle()
|
|
56
|
+
async def handle_dc(bot: Bot, event: MessageEvent):
|
|
57
|
+
qq_userid = event.user_id
|
|
58
|
+
args = str(event.get_plaintext()).split(' ')
|
|
59
|
+
cmd = args[1].lower() if len(args) > 1 else None
|
|
60
|
+
|
|
61
|
+
# 帮助
|
|
62
|
+
if cmd in (None, 'help', 'h'):
|
|
63
|
+
await dc.finish(HELP_TEXT)
|
|
64
|
+
|
|
65
|
+
# 登录
|
|
66
|
+
if cmd == 'login':
|
|
67
|
+
if event.message_type != 'private':
|
|
68
|
+
await dc.finish('请私聊发送"/dc login"来使用登录舞立方账号。')
|
|
69
|
+
token_builder = TokenBuilder()
|
|
70
|
+
qr_code_url = await token_builder.get_qrcode()
|
|
71
|
+
await token_builder.get_token(qq_userid)
|
|
72
|
+
await dc.finish(user_id=qq_userid, message=MessageSegment.image(qr_code_url) + "请在2分钟内微信扫描二维码")
|
|
73
|
+
|
|
74
|
+
# 更新官方曲目封面(仅超级用户)
|
|
75
|
+
if cmd == 'updatecover':
|
|
76
|
+
if str(qq_userid) not in SUPERUSERS:
|
|
77
|
+
await dc.finish('仅超级用户可使用此命令。')
|
|
78
|
+
result = await update_official_covers()
|
|
79
|
+
await dc.finish(result)
|
|
80
|
+
|
|
81
|
+
# 以下命令仅限群聊
|
|
82
|
+
if event.message_type != 'group':
|
|
83
|
+
await dc.finish('本命令仅限群聊中使用。')
|
|
84
|
+
|
|
85
|
+
# 检查 token
|
|
86
|
+
token_or_msg = await _check_token(qq_userid)
|
|
87
|
+
if isinstance(token_or_msg, str):
|
|
88
|
+
await dc.finish(token_or_msg)
|
|
89
|
+
|
|
90
|
+
token = token_or_msg
|
|
91
|
+
music_info_manager = MusicInfoManager(token.user_id, token.access_token)
|
|
92
|
+
userinfo = await UserInfo.fetch_user_data(token.access_token, token.user_id)
|
|
93
|
+
|
|
94
|
+
if cmd == 'ap30':
|
|
95
|
+
img_bytes = await create_ap30_img(userinfo, music_info_manager)
|
|
96
|
+
await dc.finish(MessageSegment.image(img_bytes))
|
|
97
|
+
|
|
98
|
+
elif cmd == 'myrt':
|
|
99
|
+
img_bytes = await create_rating_analysis_img(userinfo, music_info_manager)
|
|
100
|
+
await dc.finish(MessageSegment.image(img_bytes))
|
|
101
|
+
|
|
102
|
+
elif cmd == 'myrtall':
|
|
103
|
+
img_bytes = await create_rating_analysis_img(userinfo, music_info_manager, is_official=False)
|
|
104
|
+
await dc.finish(MessageSegment.image(img_bytes))
|
|
105
|
+
|
|
106
|
+
elif cmd == 'song':
|
|
107
|
+
if len(args) < 3:
|
|
108
|
+
await dc.finish('请指定歌曲id,例如: /dc song 10009')
|
|
109
|
+
song_id = args[2]
|
|
110
|
+
success, result = await create_single_song_record_img(userinfo, music_info_manager, song_id)
|
|
111
|
+
if success:
|
|
112
|
+
await dc.finish(MessageSegment.image(result))
|
|
113
|
+
else:
|
|
114
|
+
await dc.finish(str(result))
|
|
115
|
+
|
|
116
|
+
else:
|
|
117
|
+
await dc.finish(HELP_TEXT)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from nonebot import get_driver, get_plugin_config, require
|
|
5
|
+
require("nonebot_plugin_localstore")
|
|
6
|
+
from nonebot_plugin_localstore import get_plugin_data_dir
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
driver = get_driver()
|
|
10
|
+
|
|
11
|
+
class Config(BaseModel):
|
|
12
|
+
botName: str = list(driver.config.nickname)[0] if driver.config.nickname else 'nisky'
|
|
13
|
+
cover_update_cron: str = "0 3 * * *" # 定时更新官方封面的 cron 表达式,默认每天凌晨3点
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
dc_config = get_plugin_config(Config)
|
|
17
|
+
|
|
18
|
+
data_dir: Path = get_plugin_data_dir()
|
|
19
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
|
|
21
|
+
user_tokens_file: Path = data_dir / 'user_tokens.json'
|
|
22
|
+
cover_dir: Path = data_dir / 'cover'
|
|
23
|
+
templates_dir: Path = data_dir / 'templates'
|
|
24
|
+
thumb_dir = cover_dir / "thumb" # 封面缩略图目录(用于网页渲染)
|
|
25
|
+
|
|
26
|
+
thumb_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import functools
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
from PIL import Image
|
|
6
|
+
from io import BytesIO
|
|
7
|
+
|
|
8
|
+
from nonebot.log import logger
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def retry_on_failure(max_retries: int = 3, delay: float = 1, backoff: float = 2):
|
|
13
|
+
"""装饰器:请求失败时自动重试,指数退避"""
|
|
14
|
+
def decorator(func):
|
|
15
|
+
@functools.wraps(func)
|
|
16
|
+
async def wrapper(*args, **kwargs):
|
|
17
|
+
last_exception: Exception | None = None
|
|
18
|
+
for attempt in range(max_retries):
|
|
19
|
+
try:
|
|
20
|
+
return await func(*args, **kwargs)
|
|
21
|
+
except (httpx.HTTPError, httpx.HTTPStatusError) as e:
|
|
22
|
+
last_exception = e
|
|
23
|
+
if attempt < max_retries - 1:
|
|
24
|
+
wait_time = delay * (backoff ** attempt)
|
|
25
|
+
logger.debug(f"第{attempt + 1}次尝试失败,{wait_time}秒后重试: {e}")
|
|
26
|
+
await asyncio.sleep(wait_time)
|
|
27
|
+
raise last_exception # type: ignore[misc]
|
|
28
|
+
return wrapper
|
|
29
|
+
return decorator
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@retry_on_failure(max_retries=3, delay=1, backoff=2)
|
|
33
|
+
async def http_get(url: str, params: dict | None = None, headers: dict | None = None) -> dict | None:
|
|
34
|
+
"""GET 请求,返回 JSON 或 None"""
|
|
35
|
+
async with httpx.AsyncClient(proxy=None) as client:
|
|
36
|
+
rep = await client.get(url, headers=headers, params=params)
|
|
37
|
+
if rep.status_code == 200:
|
|
38
|
+
return rep.json()
|
|
39
|
+
logger.debug(f"GET {url} 返回状态码 {rep.status_code}")
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def http_get_with_token(url: str, params: dict | None = None, token: str = "") -> dict | None:
|
|
44
|
+
"""带 Authorization 的 GET 请求"""
|
|
45
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
46
|
+
return await http_get(url, params=params, headers=headers)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@retry_on_failure(max_retries=3, delay=1, backoff=2)
|
|
50
|
+
async def http_post(url: str, data: dict | None = None) -> dict | None:
|
|
51
|
+
"""POST 请求,返回 JSON 或 None"""
|
|
52
|
+
async with httpx.AsyncClient(proxy=None) as client:
|
|
53
|
+
rep = await client.post(url, data=data)
|
|
54
|
+
if rep.status_code == 200:
|
|
55
|
+
return rep.json()
|
|
56
|
+
logger.debug(f"POST {url} 返回状态码 {rep.status_code}")
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@retry_on_failure(max_retries=3, delay=1, backoff=2)
|
|
61
|
+
async def http_get_image(url: str) -> Image.Image:
|
|
62
|
+
"""下载图片并返回 PIL Image 对象"""
|
|
63
|
+
async with httpx.AsyncClient(proxy=None) as client:
|
|
64
|
+
response = await client.get(url)
|
|
65
|
+
response.raise_for_status()
|
|
66
|
+
return Image.open(BytesIO(response.content))
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from nonebot.log import logger
|
|
6
|
+
from nonebot_plugin_apscheduler import scheduler
|
|
7
|
+
from nonebot_plugin_htmlrender import template_to_pic
|
|
8
|
+
|
|
9
|
+
from .download import http_get, http_get_image
|
|
10
|
+
from .recording import MusicInfoManager, RankMusicInfo, LastPlayMusicInfo
|
|
11
|
+
from .userinfo import UserInfo
|
|
12
|
+
from .utils import LEVEL_TYPE_TO_STR, LEVEL_TYPE_LIST
|
|
13
|
+
from .config import cover_dir, templates_dir, dc_config, thumb_dir, driver
|
|
14
|
+
|
|
15
|
+
# 图片生成配置
|
|
16
|
+
IMAGE_DEVICE_SCALE_FACTOR = 1 # 降低设备缩放因子以减小图片体积
|
|
17
|
+
IMAGE_JPEG_QUALITY = 80 # JPEG 质量(0-100)
|
|
18
|
+
COVER_MAX_SIZE = 300 # 封面图最大边长(像素),用于压缩已下载封面
|
|
19
|
+
|
|
20
|
+
async def get_music_cover_path(music_id: int) -> str:
|
|
21
|
+
"""获取音乐封面缩略图路径(用于网页渲染),如原图不存在则下载,然后生成缩略图。
|
|
22
|
+
下载失败时直接返回默认封面,不复制到音乐封面路径,以便后续定时任务仍可下载真正的封面。"""
|
|
23
|
+
cover_path = cover_dir / f"{music_id}.jpg"
|
|
24
|
+
default_cover_path = cover_dir / "-1.jpg"
|
|
25
|
+
default_thumb_path = thumb_dir / "-1.jpg"
|
|
26
|
+
thumb_path = thumb_dir / f"{music_id}.jpg"
|
|
27
|
+
|
|
28
|
+
# 原图不存在则尝试下载
|
|
29
|
+
if not os.path.exists(cover_path):
|
|
30
|
+
logger.debug(f"下载封面: music {music_id}")
|
|
31
|
+
await _download_and_save_cover(music_id)
|
|
32
|
+
|
|
33
|
+
# 下载失败则返回默认封面缩略图(不复制到音乐封面路径)
|
|
34
|
+
if not os.path.exists(cover_path):
|
|
35
|
+
if not os.path.exists(default_thumb_path):
|
|
36
|
+
_generate_thumbnail(default_cover_path, default_thumb_path)
|
|
37
|
+
return str(default_thumb_path)
|
|
38
|
+
|
|
39
|
+
# 缩略图不存在则从原图生成
|
|
40
|
+
if not os.path.exists(thumb_path):
|
|
41
|
+
_generate_thumbnail(cover_path, thumb_path)
|
|
42
|
+
|
|
43
|
+
return str(thumb_path)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def _download_and_save_cover(music_id: int) -> None:
|
|
47
|
+
"""下载自制谱封面并保存原图"""
|
|
48
|
+
get_goods_info_api = "https://dancedemo.shenghuayule.com/Dance/api/MusicData/GetGoodsInfo"
|
|
49
|
+
rep = await http_get(get_goods_info_api, params={"musicId": music_id})
|
|
50
|
+
if rep is None:
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
for list_file in rep.get("ListFile", []):
|
|
54
|
+
if list_file.get("FileType") == 3:
|
|
55
|
+
image_url = list_file.get("Url")
|
|
56
|
+
img = await http_get_image(image_url)
|
|
57
|
+
_save_cover(img, cover_dir / f"{music_id}.jpg")
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _save_cover(img, save_path) -> None:
|
|
62
|
+
"""保存封面原图(不压缩)"""
|
|
63
|
+
from PIL import Image
|
|
64
|
+
if img.mode in ("RGBA", "P"):
|
|
65
|
+
img = img.convert("RGB")
|
|
66
|
+
img.save(save_path, "JPEG", quality=95, optimize=True)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _generate_thumbnail(src_path, thumb_path, max_size=COVER_MAX_SIZE) -> None:
|
|
70
|
+
"""从原图生成压缩缩略图,用于网页渲染"""
|
|
71
|
+
from PIL import Image
|
|
72
|
+
thumb_dir.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
|
|
74
|
+
img = Image.open(src_path)
|
|
75
|
+
w, h = img.size
|
|
76
|
+
if max(w, h) > max_size:
|
|
77
|
+
ratio = max_size / max(w, h)
|
|
78
|
+
img = img.resize((int(w * ratio), int(h * ratio)), Image.Resampling.LANCZOS)
|
|
79
|
+
|
|
80
|
+
if img.mode in ("RGBA", "P"):
|
|
81
|
+
img = img.convert("RGB")
|
|
82
|
+
img.save(thumb_path, "JPEG", quality=85, optimize=True)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def _generate_score_entry(music_info: RankMusicInfo | LastPlayMusicInfo) -> dict:
|
|
86
|
+
"""生成单曲数据字典"""
|
|
87
|
+
level_type_str = LEVEL_TYPE_TO_STR[music_info.level_type]
|
|
88
|
+
cover_path = await get_music_cover_path(music_info.id)
|
|
89
|
+
return {
|
|
90
|
+
"songName": music_info.name,
|
|
91
|
+
"coverUrl": cover_path,
|
|
92
|
+
"id": music_info.id,
|
|
93
|
+
"difficulty": level_type_str[-2:], # 基础、进阶、专家、大师、传奇
|
|
94
|
+
"level": music_info.level,
|
|
95
|
+
"levelType": level_type_str[:-3], # 经典/show
|
|
96
|
+
"accuracy": music_info.accuracy, # 0.00~100.00
|
|
97
|
+
"rating": int(music_info.rating),
|
|
98
|
+
"playTime": music_info.record_time,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _base_template_data(user_info: UserInfo) -> dict:
|
|
103
|
+
"""生成模板通用数据"""
|
|
104
|
+
return {
|
|
105
|
+
"generatedTime": datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
|
106
|
+
"playerName": user_info.username,
|
|
107
|
+
"avatarUrl": user_info.head_img_url,
|
|
108
|
+
"powerValue": user_info.rating,
|
|
109
|
+
"points": user_info.score,
|
|
110
|
+
"botName": dc_config.botName,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def _render_template(template_name: str, template_data: dict,
|
|
115
|
+
viewport_width: int = 1200, viewport_height: int = 1500) -> bytes:
|
|
116
|
+
"""通用模板渲染方法,包含图片优化"""
|
|
117
|
+
img_bytes = await template_to_pic(
|
|
118
|
+
template_path=str(templates_dir),
|
|
119
|
+
template_name=template_name,
|
|
120
|
+
templates=template_data,
|
|
121
|
+
pages={
|
|
122
|
+
"viewport": {"width": viewport_width, "height": viewport_height},
|
|
123
|
+
},
|
|
124
|
+
wait=2000,
|
|
125
|
+
type="jpeg",
|
|
126
|
+
quality=IMAGE_JPEG_QUALITY,
|
|
127
|
+
device_scale_factor=IMAGE_DEVICE_SCALE_FACTOR,
|
|
128
|
+
)
|
|
129
|
+
return img_bytes
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _take_n(items: list, n: int) -> list:
|
|
133
|
+
"""取列表前 n 项"""
|
|
134
|
+
return items[:n]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def create_rating_analysis_img(user_info: UserInfo, music_info_manager: MusicInfoManager,
|
|
138
|
+
is_official: bool = True) -> bytes:
|
|
139
|
+
"""生成战力分析图片"""
|
|
140
|
+
if is_official:
|
|
141
|
+
await music_info_manager.get_all_rank_official_list()
|
|
142
|
+
all_rank_list = music_info_manager.all_rank_official_list
|
|
143
|
+
else:
|
|
144
|
+
await music_info_manager.get_all_rank_list()
|
|
145
|
+
all_rank_list = music_info_manager.all_rank_list
|
|
146
|
+
|
|
147
|
+
await music_info_manager.get_recent_record_list()
|
|
148
|
+
|
|
149
|
+
template_data = _base_template_data(user_info)
|
|
150
|
+
template_data["pageTitle"] = "舞立方战力分析" if is_official else "舞立方战力分析(含自制谱)"
|
|
151
|
+
template_data["best30"] = [await _generate_score_entry(m) for m in _take_n(all_rank_list, 30)]
|
|
152
|
+
template_data["recent30"] = [await _generate_score_entry(m) for m in _take_n(music_info_manager.recent_record_list, 30)]
|
|
153
|
+
template_data["best30Count"] = len(template_data["best30"])
|
|
154
|
+
template_data["recent30Count"] = len(template_data["recent30"])
|
|
155
|
+
|
|
156
|
+
return await _render_template("myrt.html", template_data)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def create_ap30_img(user_info: UserInfo, music_info_manager: MusicInfoManager) -> bytes:
|
|
160
|
+
"""生成 AP30 图片"""
|
|
161
|
+
await music_info_manager.get_all_rank_list()
|
|
162
|
+
all_ap_list = [x for x in music_info_manager.all_rank_list if x.accuracy == 100]
|
|
163
|
+
|
|
164
|
+
template_data = _base_template_data(user_info)
|
|
165
|
+
template_data["apCount"] = len(all_ap_list)
|
|
166
|
+
template_data["apListCount"] = min(len(all_ap_list), 30)
|
|
167
|
+
template_data["apList"] = [await _generate_score_entry(m) for m in _take_n(all_ap_list, 30)]
|
|
168
|
+
|
|
169
|
+
return await _render_template("ap30.html", template_data)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def create_single_song_record_img(user_info: UserInfo, music_info_manager: MusicInfoManager,
|
|
173
|
+
song_id: str) -> tuple[bool, bytes | str]:
|
|
174
|
+
"""生成单曲成绩图片,返回 (成功标志, 图片bytes或错误消息)"""
|
|
175
|
+
await music_info_manager.get_all_rank_list()
|
|
176
|
+
rank_list = sorted(
|
|
177
|
+
[x for x in music_info_manager.all_rank_list if str(x.id) == str(song_id)],
|
|
178
|
+
key=lambda x: x.level_type,
|
|
179
|
+
)
|
|
180
|
+
if not rank_list:
|
|
181
|
+
return False, f"未找到id:{song_id}游玩记录,请检查歌曲id是否正确。"
|
|
182
|
+
|
|
183
|
+
cover_path = await get_music_cover_path(rank_list[0].id)
|
|
184
|
+
|
|
185
|
+
template_data = _base_template_data(user_info)
|
|
186
|
+
template_data["songName"] = rank_list[0].name
|
|
187
|
+
template_data["songId"] = rank_list[0].id
|
|
188
|
+
template_data["coverUrl"] = cover_path
|
|
189
|
+
template_data["records"] = []
|
|
190
|
+
|
|
191
|
+
# 构建各难度记录
|
|
192
|
+
rank_dict = {r.level_type: r for r in rank_list}
|
|
193
|
+
for lt in LEVEL_TYPE_LIST:
|
|
194
|
+
level_type_str = LEVEL_TYPE_TO_STR[lt]
|
|
195
|
+
difficulty = level_type_str[-2:]
|
|
196
|
+
level_type_prefix = level_type_str[:-3]
|
|
197
|
+
if lt in rank_dict:
|
|
198
|
+
r = rank_dict[lt]
|
|
199
|
+
template_data["records"].append({
|
|
200
|
+
"hasRecord": True,
|
|
201
|
+
"difficulty": difficulty,
|
|
202
|
+
"levelType": level_type_prefix,
|
|
203
|
+
"level": r.level,
|
|
204
|
+
"accuracy": r.accuracy,
|
|
205
|
+
"combo": r.combo,
|
|
206
|
+
"miss": r.miss,
|
|
207
|
+
"rating": int(r.rating),
|
|
208
|
+
"playTime": r.record_time,
|
|
209
|
+
})
|
|
210
|
+
else:
|
|
211
|
+
template_data["records"].append({
|
|
212
|
+
"hasRecord": False,
|
|
213
|
+
"difficulty": difficulty,
|
|
214
|
+
"levelType": level_type_prefix,
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
img_bytes = await _render_template("song.html", template_data,
|
|
218
|
+
viewport_width=800, viewport_height=900)
|
|
219
|
+
return True, img_bytes
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
async def update_official_covers() -> str:
|
|
223
|
+
"""更新官方曲目封面"""
|
|
224
|
+
logger.info("开始更新官方曲目封面")
|
|
225
|
+
|
|
226
|
+
music_ranking_api = "https://dancedemo.shenghuayule.com/Dance/api/User/GetMusicRankingNew"
|
|
227
|
+
page_size = 50
|
|
228
|
+
downloaded = 0
|
|
229
|
+
skipped = 0
|
|
230
|
+
failed = 0
|
|
231
|
+
api_max_retries = 5
|
|
232
|
+
consecutive_failures = 0
|
|
233
|
+
max_consecutive_failures = 3 # 连续失败页数上限,超过则跳到下一类别
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
for music_index in range(2, 7): # 2-6: 国语、粤语、韩语、欧美、其它
|
|
237
|
+
page = 1
|
|
238
|
+
consecutive_failures = 0
|
|
239
|
+
while True:
|
|
240
|
+
# 请求失败时自动重试,直到成功获取数据
|
|
241
|
+
rep = None
|
|
242
|
+
for retry in range(1, api_max_retries + 1):
|
|
243
|
+
rep = await http_get(
|
|
244
|
+
music_ranking_api,
|
|
245
|
+
params={"musicIndex": music_index, "page": page, "pagesize": page_size},
|
|
246
|
+
)
|
|
247
|
+
if rep is not None:
|
|
248
|
+
break
|
|
249
|
+
logger.warning(
|
|
250
|
+
f"获取官方曲目列表失败: musicIndex={music_index}, page={page}, "
|
|
251
|
+
f"第 {retry}/{api_max_retries} 次重试"
|
|
252
|
+
)
|
|
253
|
+
if retry < api_max_retries:
|
|
254
|
+
await asyncio.sleep(retry)
|
|
255
|
+
|
|
256
|
+
if rep is None:
|
|
257
|
+
consecutive_failures += 1
|
|
258
|
+
logger.error(
|
|
259
|
+
f"获取官方曲目列表最终失败: musicIndex={music_index}, page={page},"
|
|
260
|
+
f"连续失败 {consecutive_failures}/{max_consecutive_failures} 次"
|
|
261
|
+
)
|
|
262
|
+
if consecutive_failures >= max_consecutive_failures:
|
|
263
|
+
logger.error(f"连续 {max_consecutive_failures} 页获取失败,跳到下一类别")
|
|
264
|
+
break
|
|
265
|
+
page += 1
|
|
266
|
+
continue
|
|
267
|
+
|
|
268
|
+
consecutive_failures = 0 # 成功后重置连续失败计数
|
|
269
|
+
|
|
270
|
+
music_list = rep.get("List", [])
|
|
271
|
+
if not music_list:
|
|
272
|
+
break # 无更多数据,下一类别
|
|
273
|
+
|
|
274
|
+
for music in music_list:
|
|
275
|
+
music_id = music.get("MusicID")
|
|
276
|
+
cover_url = music.get("Cover")
|
|
277
|
+
cover_path = cover_dir / f"{music_id}.jpg"
|
|
278
|
+
|
|
279
|
+
if os.path.exists(cover_path):
|
|
280
|
+
skipped += 1
|
|
281
|
+
continue
|
|
282
|
+
|
|
283
|
+
if not cover_url:
|
|
284
|
+
failed += 1
|
|
285
|
+
continue
|
|
286
|
+
|
|
287
|
+
# 去掉 Cover URL 末尾的尺寸后缀(如 "/200")以获取原图
|
|
288
|
+
if cover_url.endswith("/200"):
|
|
289
|
+
cover_url = cover_url[:-4]
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
img = await http_get_image(cover_url)
|
|
293
|
+
_save_cover(img, cover_path)
|
|
294
|
+
downloaded += 1
|
|
295
|
+
logger.debug(f"下载官方封面: {music_id}")
|
|
296
|
+
except Exception as e:
|
|
297
|
+
failed += 1
|
|
298
|
+
logger.debug(f"下载官方封面失败: {music_id}, {e}")
|
|
299
|
+
|
|
300
|
+
page += 1
|
|
301
|
+
|
|
302
|
+
except Exception as e:
|
|
303
|
+
logger.error(f"更新官方封面时发生错误: {e}")
|
|
304
|
+
return f"官方曲目封面更新出错:{e}\n已下载 {downloaded} 张,跳过 {skipped} 张,失败 {failed} 张"
|
|
305
|
+
|
|
306
|
+
logger.info(f"官方曲目封面更新完成: 下载 {downloaded}, 跳过 {skipped}, 失败 {failed}")
|
|
307
|
+
return f"官方曲目封面更新完成!\n新下载: {downloaded} 张\n已存在: {skipped} 张\n失败: {failed} 张"
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@driver.on_startup
|
|
311
|
+
async def _register_cover_update_job():
|
|
312
|
+
"""根据配置的 cron 表达式注册定时更新官方封面任务"""
|
|
313
|
+
from apscheduler.triggers.cron import CronTrigger
|
|
314
|
+
trigger = CronTrigger.from_crontab(dc_config.cover_update_cron)
|
|
315
|
+
scheduler.add_job(
|
|
316
|
+
update_official_covers,
|
|
317
|
+
trigger,
|
|
318
|
+
id="update_official_covers",
|
|
319
|
+
replace_existing=True,
|
|
320
|
+
)
|
|
321
|
+
logger.info(f"已注册定时更新官方封面任务,cron: {dc_config.cover_update_cron}")
|
|
322
|
+
|
|
323
|
+
@driver.on_startup
|
|
324
|
+
async def _ensure_default_cover() -> None:
|
|
325
|
+
"""确保默认封面 cover/-1.jpg 存在,如不存在则生成占位图"""
|
|
326
|
+
default_cover_path = cover_dir / "-1.jpg"
|
|
327
|
+
if default_cover_path.exists():
|
|
328
|
+
return
|
|
329
|
+
|
|
330
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
331
|
+
|
|
332
|
+
cover_dir.mkdir(parents=True, exist_ok=True)
|
|
333
|
+
img = Image.new("RGB", (175, 202), color=(180, 180, 180))
|
|
334
|
+
draw = ImageDraw.Draw(img)
|
|
335
|
+
text = "MISSING"
|
|
336
|
+
font = ImageFont.load_default(size=41)
|
|
337
|
+
bbox = draw.textbbox((0, 0), text, font=font)
|
|
338
|
+
text_w, text_h = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
|
339
|
+
x = (175 - text_w) // 2
|
|
340
|
+
y = (202 - text_h) // 2
|
|
341
|
+
draw.text((x, y), text, fill=(80, 80, 80), font=font)
|
|
342
|
+
img.save(default_cover_path, "JPEG", quality=85)
|
|
343
|
+
logger.info("已生成默认占位封面 cover/-1.jpg")
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from .download import http_get_with_token
|
|
2
|
+
from .utils import compute_rating
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class RecordedMusicInfo:
|
|
6
|
+
"""已记录音乐信息基类"""
|
|
7
|
+
|
|
8
|
+
def __init__(self, music_id: int, name: str, cls: int, difficulty: int,
|
|
9
|
+
level: int, level_type: int, accuracy: float,
|
|
10
|
+
score: int, combo: int, miss: int, record_time: str):
|
|
11
|
+
self.id: int = music_id
|
|
12
|
+
self.name: str = name
|
|
13
|
+
self.cls: int = cls
|
|
14
|
+
self.difficulty: int = difficulty # show 为 -1
|
|
15
|
+
self.level: int = level # 1-19
|
|
16
|
+
self.level_type: int = level_type # 经典1x show 10x
|
|
17
|
+
self.accuracy: float = accuracy
|
|
18
|
+
self.score: int = score
|
|
19
|
+
self.combo: int = combo
|
|
20
|
+
self.miss: int = miss
|
|
21
|
+
self.rating: float = compute_rating(level, accuracy)
|
|
22
|
+
self.record_time: str = record_time
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RankMusicInfo(RecordedMusicInfo):
|
|
26
|
+
"""排行榜音乐信息"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, music_id: int, name: str, cls: int, owner_type: int, details: dict):
|
|
29
|
+
super().__init__(
|
|
30
|
+
music_id=music_id,
|
|
31
|
+
name=name,
|
|
32
|
+
cls=cls,
|
|
33
|
+
difficulty=int(details.get("MusicLevOld")),
|
|
34
|
+
level=int(details.get("MusicRank")),
|
|
35
|
+
level_type=int(details.get("MusicLev")),
|
|
36
|
+
accuracy=float(details.get("PlayerPercent")) / 100,
|
|
37
|
+
score=int(details.get("PlayerScore")),
|
|
38
|
+
combo=int(details.get("ComboCount")),
|
|
39
|
+
miss=int(details.get("PlayerMiss")),
|
|
40
|
+
record_time=details.get("RecordTime"),
|
|
41
|
+
)
|
|
42
|
+
self.is_official: bool = owner_type == 1
|
|
43
|
+
self.ranking: int = int(details.get("MusicRanking"))
|
|
44
|
+
|
|
45
|
+
def __str__(self):
|
|
46
|
+
return (f'RankMusicInfo {{"difficulty": {self.difficulty}, "level": {self.level}, '
|
|
47
|
+
f'"level_type": {self.level_type}, "accuracy": {self.accuracy:.2f}, '
|
|
48
|
+
f'"rating": {self.rating:.2f}}}')
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class LastPlayMusicInfo(RecordedMusicInfo):
|
|
52
|
+
"""最近游玩音乐信息"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, music_id: int, name: str, details: dict):
|
|
55
|
+
super().__init__(
|
|
56
|
+
music_id=music_id,
|
|
57
|
+
name=name,
|
|
58
|
+
cls=0,
|
|
59
|
+
difficulty=int(details.get("MusicLevOld")),
|
|
60
|
+
level=int(details.get("MusicLevel")),
|
|
61
|
+
level_type=int(details.get("MusicLev")),
|
|
62
|
+
accuracy=float(details.get("PlayerPercent")) / 100,
|
|
63
|
+
score=int(details.get("PlayerScore")),
|
|
64
|
+
combo=int(details.get("ComboCount")),
|
|
65
|
+
miss=int(details.get("PlayerMiss")),
|
|
66
|
+
record_time=details.get("RecordTime"),
|
|
67
|
+
)
|
|
68
|
+
self.perfect: int = int(details.get("PlayerPerfect"))
|
|
69
|
+
self.great: int = int(details.get("PlayerGreat"))
|
|
70
|
+
self.good: int = int(details.get("PlayerGood"))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class MusicInfoManager:
|
|
74
|
+
"""音乐成绩管理器"""
|
|
75
|
+
|
|
76
|
+
RANK_LIST_API = "https://dancedemo.shenghuayule.com/Dance/api/User/GetMyRankNew"
|
|
77
|
+
RECENT_PLAY_API = "https://dancedemo.shenghuayule.com/Dance/api/User/GetLastPlay"
|
|
78
|
+
|
|
79
|
+
def __init__(self, user_id: str, access_token: str):
|
|
80
|
+
self.user_id: str = user_id
|
|
81
|
+
self.access_token: str = access_token
|
|
82
|
+
self.all_rank_list: list[RankMusicInfo] = []
|
|
83
|
+
self.all_rank_official_list: list[RankMusicInfo] = []
|
|
84
|
+
self.recent_record_list: list[LastPlayMusicInfo] = []
|
|
85
|
+
|
|
86
|
+
async def _fetch_rank_list(self, official_only: bool = False) -> list[RankMusicInfo]:
|
|
87
|
+
"""获取排行榜列表,可按官方谱过滤"""
|
|
88
|
+
result: list[RankMusicInfo] = []
|
|
89
|
+
for cls_index in range(2, 7): # 2-6: 国语、粤语、韩语、欧美、其它
|
|
90
|
+
rep = await http_get_with_token(self.RANK_LIST_API, {"musicIndex": cls_index}, self.access_token)
|
|
91
|
+
if rep is None:
|
|
92
|
+
continue
|
|
93
|
+
for music_info in rep:
|
|
94
|
+
music_id = music_info.get("MusicID")
|
|
95
|
+
name = music_info.get("Name")
|
|
96
|
+
owner_type = int(music_info.get("OwnerType"))
|
|
97
|
+
|
|
98
|
+
if official_only and owner_type != 1:
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
for record_info in music_info.get("ItemRankList"):
|
|
102
|
+
# 官方谱过滤:仅保留等级 1-19
|
|
103
|
+
if official_only:
|
|
104
|
+
rank_val = record_info.get("MusicRank")
|
|
105
|
+
if rank_val is not None and (rank_val > 19 or rank_val < 1):
|
|
106
|
+
continue
|
|
107
|
+
result.append(RankMusicInfo(music_id, name, cls_index, owner_type, record_info))
|
|
108
|
+
|
|
109
|
+
result.sort(key=lambda x: x.rating, reverse=True)
|
|
110
|
+
return result
|
|
111
|
+
|
|
112
|
+
async def get_all_rank_list(self) -> list[RankMusicInfo]:
|
|
113
|
+
"""获取所有排行榜(含自制谱)"""
|
|
114
|
+
self.all_rank_list = await self._fetch_rank_list(official_only=False)
|
|
115
|
+
return self.all_rank_list
|
|
116
|
+
|
|
117
|
+
async def get_all_rank_official_list(self) -> list[RankMusicInfo]:
|
|
118
|
+
"""获取官方谱排行榜"""
|
|
119
|
+
self.all_rank_official_list = await self._fetch_rank_list(official_only=True)
|
|
120
|
+
return self.all_rank_official_list
|
|
121
|
+
|
|
122
|
+
async def get_recent_record_list(self) -> list[LastPlayMusicInfo]:
|
|
123
|
+
"""获取最近游玩记录"""
|
|
124
|
+
rep = await http_get_with_token(self.RECENT_PLAY_API, {}, self.access_token)
|
|
125
|
+
if rep is None:
|
|
126
|
+
self.recent_record_list = []
|
|
127
|
+
return self.recent_record_list
|
|
128
|
+
|
|
129
|
+
self.recent_record_list = [
|
|
130
|
+
LastPlayMusicInfo(item.get("MusicID"), item.get("MusicName"), item)
|
|
131
|
+
for item in rep
|
|
132
|
+
]
|
|
133
|
+
return self.recent_record_list
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime, timedelta
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from nonebot import get_bot
|
|
6
|
+
from nonebot.log import logger
|
|
7
|
+
from nonebot_plugin_apscheduler import scheduler
|
|
8
|
+
|
|
9
|
+
from .download import http_get, http_post
|
|
10
|
+
from .config import user_tokens_file
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
|
|
14
|
+
_tokens_lock = asyncio.Lock()
|
|
15
|
+
|
|
16
|
+
class Token:
|
|
17
|
+
def __init__(self, access_token: str = "", refresh_token: str = "", expires: str = "",
|
|
18
|
+
refresh_token_expires: str = "", user_id: str = "", qq: str = ""):
|
|
19
|
+
self.access_token = access_token
|
|
20
|
+
self.refresh_token = refresh_token
|
|
21
|
+
self.expires = expires
|
|
22
|
+
self.refresh_token_expires = refresh_token_expires
|
|
23
|
+
self.user_id = user_id
|
|
24
|
+
self.qq = qq
|
|
25
|
+
|
|
26
|
+
def to_dict(self) -> dict:
|
|
27
|
+
return {
|
|
28
|
+
"access_token": self.access_token,
|
|
29
|
+
"refresh_token": self.refresh_token,
|
|
30
|
+
"expires": self.expires,
|
|
31
|
+
"refresh_token_expires": self.refresh_token_expires,
|
|
32
|
+
"user_id": self.user_id,
|
|
33
|
+
"qq": self.qq,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_dict(cls, data: dict) -> "Token":
|
|
38
|
+
return cls(
|
|
39
|
+
access_token=data.get("access_token", ""),
|
|
40
|
+
refresh_token=data.get("refresh_token", ""),
|
|
41
|
+
expires=data.get("expires", ""),
|
|
42
|
+
refresh_token_expires=data.get("refresh_token_expires", data.get("refreshExpires", "")),
|
|
43
|
+
user_id=data.get("user_id", data.get("userId", "")),
|
|
44
|
+
qq=data.get("qq", "0"),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TokenBuilder:
|
|
49
|
+
"""处理二维码登录流程"""
|
|
50
|
+
|
|
51
|
+
QRCODE_URL_API = "https://dancedemo.shenghuayule.com/Dance/api/Common/GetQrCode"
|
|
52
|
+
GET_TOKEN_API = "https://dancedemo.shenghuayule.com/Dance/token"
|
|
53
|
+
|
|
54
|
+
def __init__(self):
|
|
55
|
+
self.id: str = ""
|
|
56
|
+
self.qrcode_url: str = ""
|
|
57
|
+
|
|
58
|
+
async def get_qrcode(self) -> str:
|
|
59
|
+
"""获取登录二维码 URL"""
|
|
60
|
+
rep = await http_get(self.QRCODE_URL_API, {"id": ""})
|
|
61
|
+
if rep is None:
|
|
62
|
+
return ""
|
|
63
|
+
self.qrcode_url = rep.get("QrcodeUrl", "")
|
|
64
|
+
self.id = rep.get("ID", "")
|
|
65
|
+
return self.qrcode_url
|
|
66
|
+
|
|
67
|
+
async def get_token(self, qq: int) -> None:
|
|
68
|
+
"""启动定时轮询获取 token 的任务"""
|
|
69
|
+
job_id = f"get_token_job_{qq}"
|
|
70
|
+
|
|
71
|
+
async def _poll_token(job_id: str, client_id: str, qq: int):
|
|
72
|
+
data = {
|
|
73
|
+
"client_type": "qrcode",
|
|
74
|
+
"grant_type": "client_credentials",
|
|
75
|
+
"client_id": client_id,
|
|
76
|
+
}
|
|
77
|
+
rep = await http_post(self.GET_TOKEN_API, data)
|
|
78
|
+
if rep is None:
|
|
79
|
+
return
|
|
80
|
+
token = Token.from_dict(rep)
|
|
81
|
+
token.qq = str(qq)
|
|
82
|
+
await TokenManager(user_tokens_file).update_token(token)
|
|
83
|
+
scheduler.remove_job(job_id)
|
|
84
|
+
await get_bot().send_private_msg(
|
|
85
|
+
user_id=qq,
|
|
86
|
+
message=f"登录成功。登录的舞立方ID:{token.user_id}\n如果不是你的舞立方ID号,请重新登录!",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
scheduler.add_job(
|
|
90
|
+
_poll_token,
|
|
91
|
+
"interval",
|
|
92
|
+
seconds=5,
|
|
93
|
+
args=[job_id, self.id, qq],
|
|
94
|
+
id=job_id,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
async def _cancel_on_timeout(job_id: str, qq: int):
|
|
98
|
+
if scheduler.get_job(job_id):
|
|
99
|
+
logger.info(f"{qq}扫描二维码超时。")
|
|
100
|
+
scheduler.remove_job(job_id)
|
|
101
|
+
await get_bot().send_private_msg(user_id=qq, message="登录失败,请重新发送命令进行登录。")
|
|
102
|
+
|
|
103
|
+
scheduler.add_job(
|
|
104
|
+
_cancel_on_timeout,
|
|
105
|
+
"date",
|
|
106
|
+
run_date=datetime.now() + timedelta(minutes=2),
|
|
107
|
+
args=[job_id, qq],
|
|
108
|
+
id=f"cancel_{job_id}",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class TokenManager:
|
|
113
|
+
"""管理 token 的持久化存储"""
|
|
114
|
+
|
|
115
|
+
def __init__(self, file_path: Path | str):
|
|
116
|
+
self.file_path = file_path
|
|
117
|
+
|
|
118
|
+
def _load_tokens_unsafe(self) -> list[Token]:
|
|
119
|
+
"""不加锁的读取"""
|
|
120
|
+
try:
|
|
121
|
+
with open(self.file_path, "r", encoding="utf-8") as f:
|
|
122
|
+
return [Token.from_dict(item) for item in json.load(f)]
|
|
123
|
+
except FileNotFoundError:
|
|
124
|
+
return []
|
|
125
|
+
|
|
126
|
+
def _save_tokens_unsafe(self, tokens: list[Token]) -> None:
|
|
127
|
+
"""不加锁的写入"""
|
|
128
|
+
data = [token.to_dict() for token in tokens]
|
|
129
|
+
with open(self.file_path, "w", encoding="utf-8") as f:
|
|
130
|
+
json.dump(data, f, indent=4)
|
|
131
|
+
|
|
132
|
+
async def get_token_by_qq(self, qq: int) -> Token | None:
|
|
133
|
+
"""加锁读取指定 QQ 的 token"""
|
|
134
|
+
async with _tokens_lock:
|
|
135
|
+
for token in self._load_tokens_unsafe():
|
|
136
|
+
if token.qq == str(qq):
|
|
137
|
+
return token
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
async def update_token(self, new_token: Token) -> None:
|
|
141
|
+
"""加锁更新 token"""
|
|
142
|
+
async with _tokens_lock:
|
|
143
|
+
tokens = self._load_tokens_unsafe()
|
|
144
|
+
for i, token in enumerate(tokens):
|
|
145
|
+
if token.qq == new_token.qq:
|
|
146
|
+
tokens[i] = new_token
|
|
147
|
+
self._save_tokens_unsafe(tokens)
|
|
148
|
+
return
|
|
149
|
+
tokens.append(new_token)
|
|
150
|
+
self._save_tokens_unsafe(tokens)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from .download import http_get_with_token
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class UserInfo:
|
|
5
|
+
def __init__(self):
|
|
6
|
+
self.user_id: str = ""
|
|
7
|
+
self.head_img_url: str = ""
|
|
8
|
+
self.username: str = ""
|
|
9
|
+
self.rating: int = 0 # 战力值
|
|
10
|
+
self.score: int = 0 # 积分
|
|
11
|
+
self.title_url: str = "" # 头衔
|
|
12
|
+
self.head_img_box_url: str = "" # 头像边框
|
|
13
|
+
|
|
14
|
+
@staticmethod
|
|
15
|
+
async def fetch_user_data(token: str, user_id: str) -> "UserInfo":
|
|
16
|
+
url: str = "https://dancedemo.shenghuayule.com/Dance/api/User/GetInfo"
|
|
17
|
+
query: dict[str, str | bool] = {
|
|
18
|
+
"userId": str(user_id),
|
|
19
|
+
"getNationRank": True,
|
|
20
|
+
}
|
|
21
|
+
user_data = await http_get_with_token(url, query, token)
|
|
22
|
+
user = UserInfo()
|
|
23
|
+
if user_data:
|
|
24
|
+
user.user_id = user_data.get("UserID", user_id)
|
|
25
|
+
user.head_img_url = user_data.get("HeadimgURL", "")
|
|
26
|
+
user.username = user_data.get("UserName", "")
|
|
27
|
+
user.rating = user_data.get("LvRatio", 0)
|
|
28
|
+
user.score = user_data.get("MusicScore", 0)
|
|
29
|
+
user.title_url = str(user_data.get("TitleUrl", "")).removesuffix('/256')
|
|
30
|
+
user.head_img_box_url = str(user_data.get("HeadimgBoxPath", "")).removesuffix('/256')
|
|
31
|
+
return user
|
|
32
|
+
|
|
33
|
+
def __str__(self):
|
|
34
|
+
return f"user_id: {self.user_id}, head_img_url: {self.head_img_url}, username: {self.username}, rating: {self.rating}"
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def calc_time_difference(given_time_str: str) -> int:
|
|
5
|
+
"""获取给定时间与当前时间的差值(秒)"""
|
|
6
|
+
given_time = datetime.strptime(given_time_str, "%Y-%m-%d %H:%M:%S")
|
|
7
|
+
return int((given_time - datetime.now()).total_seconds())
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# 难度类型映射
|
|
11
|
+
LEVEL_TYPE_LIST = [11, 12, 13, 14, 15, 101, 102, 103, 104, 105]
|
|
12
|
+
|
|
13
|
+
LEVEL_TYPE_TO_STR = {
|
|
14
|
+
11: "经典-基础",
|
|
15
|
+
12: "经典-进阶",
|
|
16
|
+
13: "经典-专家",
|
|
17
|
+
14: "经典-大师",
|
|
18
|
+
15: "经典-传奇",
|
|
19
|
+
101: "show+基础",
|
|
20
|
+
102: "show+进阶",
|
|
21
|
+
103: "show+专家",
|
|
22
|
+
104: "show+大师",
|
|
23
|
+
105: "show+传奇",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def compute_rating(level: int, acc: float) -> int:
|
|
28
|
+
"""
|
|
29
|
+
返回 level 级谱面 acc 准度贡献的战力。
|
|
30
|
+
level: 谱面等级(1-19)
|
|
31
|
+
acc: 记录百分比(0-100,两位小数)
|
|
32
|
+
"""
|
|
33
|
+
acc_int = max(0, min(int(acc * 100), 10000))
|
|
34
|
+
if level < 1 or level > 19:
|
|
35
|
+
return 0
|
|
36
|
+
|
|
37
|
+
base_ratings: list[float] = [
|
|
38
|
+
(level + 2) * 100.0, # 100
|
|
39
|
+
(level + 1) * 100.0, # [98, 100)
|
|
40
|
+
level * 100.0, # [95, 98)
|
|
41
|
+
(level - 1) * 100.0, # [90, 95)
|
|
42
|
+
(level - 2 + (19 - level) / 19.0) * 100.0, # [85, 90)
|
|
43
|
+
(level - 3 + 2 * (19 - level) / 19.0) * 100.0, # [80, 85)
|
|
44
|
+
(level - 4 + 3 * (19 - level) / 19.0) * 100.0, # [75, 80)
|
|
45
|
+
(level - 5 + 4 * (19 - level) / 19.0) * 100.0, # [70, 75)
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
if acc_int == 10000:
|
|
49
|
+
base, offset = base_ratings[0], 0.0
|
|
50
|
+
elif acc_int >= 9800:
|
|
51
|
+
base, offset = base_ratings[1], (acc_int - 9800) / 200.0 * 100
|
|
52
|
+
elif acc_int >= 9500:
|
|
53
|
+
base, offset = base_ratings[2], (acc_int - 9500) / 300.0 * 100
|
|
54
|
+
elif acc_int >= 9000:
|
|
55
|
+
base, offset = base_ratings[3], (acc_int - 9000) / 500.0 * 100
|
|
56
|
+
elif acc_int >= 8500:
|
|
57
|
+
base = base_ratings[4]
|
|
58
|
+
offset = (acc_int - 8500) / 500.0 * (base_ratings[3] - base)
|
|
59
|
+
elif acc_int >= 8000:
|
|
60
|
+
base = base_ratings[5]
|
|
61
|
+
offset = (acc_int - 8000) / 500.0 * (base_ratings[4] - base)
|
|
62
|
+
elif acc_int >= 7500:
|
|
63
|
+
base = base_ratings[6]
|
|
64
|
+
offset = (acc_int - 7500) / 500.0 * (base_ratings[5] - base)
|
|
65
|
+
elif acc_int >= 7000:
|
|
66
|
+
base = base_ratings[7]
|
|
67
|
+
offset = (acc_int - 7000) / 500.0 * (base_ratings[6] - base)
|
|
68
|
+
else:
|
|
69
|
+
base, offset = 0.0, 0.0
|
|
70
|
+
|
|
71
|
+
return int(base + offset)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nonebot-plugin-dancecube
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: 一个简单的舞立方插件
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Author: 1v7w
|
|
8
|
+
Author-email: gascd11@163.com
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Requires-Dist: Pillow (>=10.0.0)
|
|
18
|
+
Requires-Dist: httpx (>=0.24.0)
|
|
19
|
+
Requires-Dist: nonebot-adapter-onebot (>=2.2.0)
|
|
20
|
+
Requires-Dist: nonebot2 (>=2.2.0)
|
|
21
|
+
Requires-Dist: nonebot_plugin_apscheduler (>=0.3.0)
|
|
22
|
+
Requires-Dist: nonebot_plugin_htmlrender (>=0.3.0)
|
|
23
|
+
Requires-Dist: nonebot_plugin_localstore (>=0.7.0)
|
|
24
|
+
Requires-Dist: pydantic (>=2.0.0)
|
|
25
|
+
Project-URL: Bug-Tracker, https://github.com/1v7w/nonebot-plugin-dancecube
|
|
26
|
+
Project-URL: Homepage, https://github.com/1v7w/nonebot-plugin-dancecube
|
|
27
|
+
Project-URL: Repository, https://github.com/1v7w/nonebot-plugin-dancecube
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
<div align="center">
|
|
31
|
+
<a href="https://v2.nonebot.dev/store"><img src="https://github.com/A-kirami/nonebot-plugin-dancecube/blob/resources/nbp_logo.png" width="180" height="180" alt="NoneBotPluginLogo"></a>
|
|
32
|
+
<br>
|
|
33
|
+
<p><img src="https://github.com/A-kirami/nonebot-plugin-dancecube/blob/resources/NoneBotPlugin.svg" width="240" alt="NoneBotPluginText"></p>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<div align="center">
|
|
37
|
+
|
|
38
|
+
# nonebot-plugin-dancecube
|
|
39
|
+
|
|
40
|
+
_✨ 舞立方插件:提供舞立方战力分析等基础功能 ✨_
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
<a href="./LICENSE">
|
|
44
|
+
<img src="https://img.shields.io/github/license/owner/nonebot-plugin-dancecube.svg" alt="license">
|
|
45
|
+
</a>
|
|
46
|
+
<a href="https://pypi.python.org/pypi/nonebot-plugin-dancecube">
|
|
47
|
+
<img src="https://img.shields.io/pypi/v/nonebot-plugin-dancecube.svg" alt="pypi">
|
|
48
|
+
</a>
|
|
49
|
+
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
|
|
50
|
+
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
## 📖 介绍
|
|
54
|
+
|
|
55
|
+
目前支持二维码登录、战力分析、战力分析(包含自制谱)、战绩最好的30首ap歌曲、获取指定歌曲id的个人成绩、自动更新官方曲目封面。
|
|
56
|
+
|
|
57
|
+
## 💿 安装
|
|
58
|
+
|
|
59
|
+
<details open>
|
|
60
|
+
<summary>使用 nb-cli 安装</summary>
|
|
61
|
+
在 nonebot2 项目的根目录下打开命令行, 输入以下指令即可安装
|
|
62
|
+
|
|
63
|
+
nb plugin install nonebot-plugin-dancecube
|
|
64
|
+
|
|
65
|
+
</details>
|
|
66
|
+
|
|
67
|
+
<details>
|
|
68
|
+
<summary>使用包管理器安装</summary>
|
|
69
|
+
在 nonebot2 项目的插件目录下, 打开命令行, 根据你使用的包管理器, 输入相应的安装命令
|
|
70
|
+
|
|
71
|
+
<details>
|
|
72
|
+
<summary>pip</summary>
|
|
73
|
+
|
|
74
|
+
pip install nonebot-plugin-dancecube
|
|
75
|
+
</details>
|
|
76
|
+
<details>
|
|
77
|
+
<summary>pdm</summary>
|
|
78
|
+
|
|
79
|
+
pdm add nonebot-plugin-dancecube
|
|
80
|
+
</details>
|
|
81
|
+
<details>
|
|
82
|
+
<summary>poetry</summary>
|
|
83
|
+
|
|
84
|
+
poetry add nonebot-plugin-dancecube
|
|
85
|
+
</details>
|
|
86
|
+
<details>
|
|
87
|
+
<summary>conda</summary>
|
|
88
|
+
|
|
89
|
+
conda install nonebot-plugin-dancecube
|
|
90
|
+
</details>
|
|
91
|
+
|
|
92
|
+
打开 nonebot2 项目根目录下的 `pyproject.toml` 文件, 在 `[tool.nonebot]` 部分追加写入
|
|
93
|
+
|
|
94
|
+
plugins = ["nonebot_plugin_dancecube"]
|
|
95
|
+
|
|
96
|
+
</details>
|
|
97
|
+
|
|
98
|
+
## ⚙️ 配置
|
|
99
|
+
|
|
100
|
+
在 nonebot2 项目的`.env`文件中添加下表中的必填配置
|
|
101
|
+
|
|
102
|
+
| 配置项 | 必填 | 默认值 | 说明 |
|
|
103
|
+
|:-----:|:----:|:----:|:----:|
|
|
104
|
+
| COVER_UPDATE_CRON | 否 | 0 3 * * * | cron格式;默认每天凌晨3点更新官方曲目封面 |
|
|
105
|
+
|SUPERUSERS|否|-|超级用户/管理员|
|
|
106
|
+
|NICKNAME|否|nisky|机器人名字,生成图片最低下会展示|
|
|
107
|
+
|
|
108
|
+
## 🎉 使用
|
|
109
|
+
### 指令表
|
|
110
|
+
| 指令 | 权限 | 需要@ | 范围 | 说明 |
|
|
111
|
+
|:-----:|:----:|:----:|:----:|:----:|
|
|
112
|
+
| /dc | 群员 | 否 | 私聊/群聊 | 获取指令帮助 |
|
|
113
|
+
| /dc login | 群员 | 否 | 私聊 | 二维码登录 |
|
|
114
|
+
| /dc myrt | 群员 | 否 | 群聊 | 获取战力分析 |
|
|
115
|
+
| /dc myrtall | 群员 | 否 | 群聊 | 获取战力分析(含自制谱) |
|
|
116
|
+
| /dc ap30 | 群员 | 否 | 群聊 | 获取ap战绩最好的30首 |
|
|
117
|
+
| /dc updatecover | 超级用户 | 否 | 私聊/群聊 | 更新官方曲目封面 |
|
|
118
|
+
|
|
119
|
+
### 效果图
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
**/dc myrt**
|
|
123
|
+

|
|
124
|
+
|
|
125
|
+
**/dc ap30**
|
|
126
|
+

|
|
127
|
+
|
|
128
|
+
**/dc song 6354**
|
|
129
|
+

|
|
130
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
nonebot_plugin_dancecube/__init__.py,sha256=6C-4iOKIq8Ivbr0jPDZaGZBdkpaGSatwlleJjOKlFd8,4291
|
|
2
|
+
nonebot_plugin_dancecube/config.py,sha256=Ay76Fv1XjeZ0fiEeCFrCw9A8qH2flBkeGuXRfjeyL2Q,864
|
|
3
|
+
nonebot_plugin_dancecube/download.py,sha256=5UShC5K3TZMyMV0Mn5wc-Dd0J9BIFR8kC40VP6jNQ4o,2605
|
|
4
|
+
nonebot_plugin_dancecube/pic.py,sha256=OG48dTSviyy9iebePaPYLJhPaOTX-2e5fTyPYsfGKqQ,14110
|
|
5
|
+
nonebot_plugin_dancecube/recording.py,sha256=lRMmfwcDb1v9BPndVMaCOTjkzPtVqIcHd88_q3I53lg,5673
|
|
6
|
+
nonebot_plugin_dancecube/tokens.py,sha256=ClNdSRXzsPaGU_d0JEkkX0nCdPpZeR0k0w8ibP9YmYQ,5332
|
|
7
|
+
nonebot_plugin_dancecube/userinfo.py,sha256=uEc-v3a0BMjts1ufLknXwJPQcC-0MsGxmqfWTTuvp5A,1486
|
|
8
|
+
nonebot_plugin_dancecube/utils.py,sha256=2cAeHVGrtd2-VPfDzbdUKaRbtj-LUH75CVFep708MoA,2527
|
|
9
|
+
nonebot_plugin_dancecube-0.1.2.dist-info/METADATA,sha256=8RA6x9ihiycm_1hg3XYy35cpBf78QYSeFaa0-18EmhA,6435
|
|
10
|
+
nonebot_plugin_dancecube-0.1.2.dist-info/WHEEL,sha256=Vz2fHgx6HFtSwhs8KvkHLqH5Ea4w1_rner5uNVGCeIE,88
|
|
11
|
+
nonebot_plugin_dancecube-0.1.2.dist-info/licenses/LICENSE,sha256=zGIeWpJbRs2zqspdCKTBN3voS2aBrwXCXEF5KkX_Pxo,1061
|
|
12
|
+
nonebot_plugin_dancecube-0.1.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 1v7w
|
|
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.
|