├── .gitignore ├── LICENSE ├── README.md ├── __init__.py ├── command ├── __init__.py ├── mai_alias.py ├── mai_base.py ├── mai_guess.py ├── mai_score.py ├── mai_search.py └── mai_table.py ├── libraries ├── image.py ├── maimai_best_50.py ├── maimaidx_api_data.py ├── maimaidx_arcade.py ├── maimaidx_error.py ├── maimaidx_model.py ├── maimaidx_music.py ├── maimaidx_music_info.py ├── maimaidx_player_score.py ├── maimaidx_update_table.py └── tool.py ├── maimai.py ├── maimai_arcade.py ├── maimaidxhelp.png ├── requirements.txt └── static ├── config.json ├── echarts.min.js └── temp_pie.html /.gitignore: -------------------------------------------------------------------------------- 1 | # static 2 | static/ 3 | arcades.json 4 | 5 | __pycache__ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Yuri-YuzuChaN 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # maimaiDX 2 | 3 | [![python3](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/) 4 | [![QQGroup](https://img.shields.io/badge/QQGroup-Join-blue)](https://qm.qq.com/q/gDIf3fGSPe) 5 | [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT) 6 | 7 | 移植自[mai-bot](https://github.com/Diving-Fish/mai-bot) 开源项目,基于 [HoshinoBotV2](https://github.com/Ice-Cirno/HoshinoBot) 的街机音游 **舞萌DX** 的查询插件 8 | 9 | 项目地址:https://github.com/Yuri-YuzuChaN/maimaiDX 10 | 11 | ## 重要更新 12 | 13 | **2025-03-28** 14 | 15 | > [!WARNING] 16 | > 对于这个版本之前的插件和修改版的插件请注意,预更新版本别名库将全部更换成新的 `API` 地址,返回的数据结构均更改,目前旧版 `API` 将再运行一段时间,预计正式更新 `舞萌DX2025` 时将会关闭 17 | 18 | 1. 预更新 `舞萌DX2025` UI,资源全部更换,更新部分依赖和文件,**请重新进行使用方法** 19 | 2. 修改所有 `BOT管理员` 私聊指令为群聊指令:`更新别名库`、`更新maimai数据`、`更新定数表`、`更新完成表` 20 | 21 | ## 使用方法 22 | 23 | 1. 将该项目放在HoshinoBot插件目录 `modules` 下,或者clone本项目 24 | 25 | ``` git 26 | git clone https://github.com/Yuri-YuzuChaN/maimaiDX 27 | ``` 28 | 29 | 2. 下载静态资源文件,将该压缩文件解压后,将 `static` 文件夹复制到插件根目录并覆盖,即 `maimaiDX/static` 30 | 31 | - [私人云盘](https://cloud.yuzuchan.moe/f/1bUn/Resource.7z) 32 | - [AList网盘](https://share.yuzuchan.moe/p/Resource.7z?sign=EvCwaGwJrneyD1Olq00NG3HXNK7fQKpx_sa3Ck9Uzjs=:0) 33 | - [onedrive](https://yuzuai-my.sharepoint.com/:u:/g/personal/yuzu_yuzuchan_moe/EdGUKRSo-VpHjT2noa_9EroBdFZci-tqWjVZzKZRTEeZkw?e=a1TM40) 34 | 35 | 3. 配置可选项,请修改 `maimaiDX/static/config.json` 文件 36 | 37 | 1. 如果您拥有查分器的开发者 `token`,请将 `token` 填入文件中的 `maimaidxtoken` 项 38 | 2. 如果你的服务器或主机不能顺利流畅的访问查分器和别名库的API,请配置代理。均为香港服务器代理中转,例如你的服务器访问查分器很困难,请设置 `maimaidxproberproxy` 为 `true`,别名库同理 39 | 3. 可选,是否将部分图片在保存在内存中,不需要请在设置 `SAVEINMEM` 为 `false` 40 | ``` json 41 | { 42 | "maimaidxtoken": "maimaidxtoken", 43 | "maimaidxproberproxy": true, 44 | "maimaidxaliasproxy": false, 45 | "saveinmem": false 46 | } 47 | ``` 48 | 49 | 4. 安装插件所需模块:`pip install -r requirements.txt` 50 | 5. 安装 `chromium`,**相关依赖已安装,请直接使用该指令执行** 51 | 52 | ``` shell 53 | playwright install --with-deps chromium 54 | ``` 55 | 56 | 6. 安装 `微软雅黑` 字体,解决使用 `ginfo` 指令字体不渲染的问题,例如 `ubuntu`:`apt install fonts-wqy-microhei`,`windows` 平台可跳过 57 | 7. 在 `config/__bot__.py` 模块列表中添加 `maimaiDX` 58 | 8. 重启HoshinoBot 59 | 60 | ## 更新说明 61 | 62 | **2025-03-28** 63 | 64 | 1. 预更新 `舞萌DX2025` UI 65 | 2. 修改所有 `BOT管理员` 私聊指令为群聊指令:`更新别名库`、`更新maimai数据`、`更新定数表`、`更新完成表` 66 | 67 | **2024-07-24** 68 | 69 | 1. 更新部分牌子完成表和 `SyncPlay` 图片 70 | 2. 修复 `新增机厅` 指令 `id` 未增加的问题 71 | 3. 修复 `牌子进度` 指令 `sync` 未匹配的问题 72 | 4. 修复 `别名查歌` 指令查询到已删除的曲目时发生错误的问题 73 | 74 | **2024-06-07** 75 | 76 | 1. 更新至 `舞萌DX 2024` 77 | 2. 更换所有图片绘制,需删除除 `json` 后缀的所有文件,**请重新进行使用方法第二步** 78 | 3. 更改部分 `json` 文件名称,便于识别,具体文件如下,**请务必修改文件名,否则开关文件以及本地别名文件将不会被读取** 79 | - `all_alias.json` 修改为 `music_alias.json` 80 | - `local_alias.json` 修改为 `local_music_alias.json` 81 | - `chart_stats.json` 修改为 `music_chart.json` 82 | - `group_alias.json` 修改为 `group_alias_switch.json` 83 | - `guess_config.json` 修改为 `group_guess_switch.json` 84 | 4. 新增管理员私聊指令 `更新完成表`,用于更新 `BUDDiES` 版本 `双系` 牌子 85 | 5. 新增指令 `完成表`,可查询牌子完成表,例如:`祝极完成表` 86 | 6. 新增指令 `猜曲绘` 87 | 7. 查看谱面支持计算个人加分情况,指令包括 `是什么歌`,`id` 88 | 8. 指令 `mai什么` 支持随机发送推分谱面,指令中需包含 `加分`,`上分` 字样,例如:`今日mai打什么上分` 89 | 9. 修改指令 `分数列表` 和 `进度` 发送方式 90 | 10. 优化所有模块 91 | 92 | **2024-03-12** 93 | 94 | 1. 变更别名服务器地址 95 | 2. 修改所有别名请求以及参数 96 | 3. 开放普通用户申请别名 97 | 98 | **2024-01-14** 99 | 100 | 1. 优先使用本地谱面 101 | 2. 使用 `numpy` 模块重新绘制定数表 102 | 103 | **2023-09-23** 104 | 105 | 1. 重写 `API` 方法 106 | 2. 重写机厅模块 107 | 3. 将同步生成定数表方法修改为异步方法,防止堵塞进程 108 | 4. 将 `当前别名投票` 发送方式修改为图片形式 109 | 5. 本地添加别名单独存储为一个文件,不再添加在暂存别名文件中 110 | 111 | **2023-08-10** 112 | 113 | 1. 新增后缀指令 `定数表`,`完成表`,查询指定等级的定数表和完成表,例如:`13+完成表` 114 | 2. 新增BOT管理员私聊指令 `更新定数表`,用于生成和更新定数表 115 | 3. 新增BOT管理员私聊指令 `更新maimai数据`,用于版本更新手动更新bot已存数据 116 | 4. 拆分并移除 `maimaidx_project.py` 的代码和文件,便于所有功能维护 117 | 5. 修复曲绘不存在时下载错误的问题 118 | 6. 修复猜歌提前发出答案的bug 119 | 7. 修改指令 `minfo` 部分绘图 120 | 121 | **2023-06-15** 122 | 123 | 1. 新增添加本地别名的功能 124 | 125 | **2023-06-09** 126 | 127 | 1. 更新至 `舞萌DX 2023` 128 | 2. 移除指令 `b40` 129 | 3. 更换静态资源 130 | 4. 修改指令 `b50` 部分绘图 131 | 132 | **2023-04-22** 133 | 134 | 1. 限制所有网络请求时长 135 | 2. 新增别名文件本地备份 136 | 3. 新增ginfo指令默认使用紫谱数据 137 | 138 | **2023-04-21** 139 | 140 | 1. 新增BOT管理员私聊指令 `全局关闭别名推送` 和 `全局开启别名推送`,关闭所有群的推送消息,无论先前开启还是关闭 141 | 2. 修复新版本更新后API暂未收录曲目的问题 142 | 3. 新增乐曲游玩总览 `ginfo` 指令 143 | 4. 新增猜歌库根据乐曲游玩次数添加 144 | 5. 新增每日更新机厅信息,删除旧版更新机厅机制 145 | 146 | **2023-04-15** 147 | 148 | 1. 将获取数据的方式由启动Bot时获取改为连接到CQHTTP后获取 149 | 2. 修复因查分器API内容变动而无法启动Bot的问题 150 | 151 | **2023-03-29** 152 | 153 | 1. 重制 `b40/b50` ,`minfo` 和曲目信息的绘图 154 | 2. 修改投票网页端,改成共用网站 155 | 3. 修改垃圾代码 156 | 157 | **2023-03-02** 158 | 159 | 1. 新增 `开启别名推送` 和 `关闭别名推送` 指令 160 | 161 | **2023-02-25** 162 | 163 | 1. 修复猜歌答对后无法结束的问题 164 | 165 | **2023-02-23** 166 | 167 | 1. 投票网页端 168 | 169 | **2023-02-22** 170 | 171 | 1. 修复启动BOT时无法获取所有曲目信息的问题,添加本地缓存 172 | 2. 修改别名库,使用API获取和添加,并同步所有使用该插件的BOT 173 | 3. 修改猜歌和别名功能 174 | 4. 新增指令 `当前别名投票` 和 `同意别名` 175 | 176 | **2023-2-18** 177 | 178 | 1. 别称同步临时解决方案 #47 179 | 180 | **2023-2-15** 181 | 182 | 1. 更新本地缓存水鱼网数据 #43 183 | 184 | **2022-9-14** 185 | 186 | 1. 新增查询单曲指令 `minfo` 187 | 2. 修改查曲绘图 188 | 189 | **2022-8-30** 190 | 191 | 1. 修复新版b40/b50 isinstance bug [#38](https://github.com/Yuri-YuzuChaN/maimaiDX/issues/38) 192 | 2. 修复新版b40/b50 找不到图片问题 193 | 3. 修复安慰分隐性bug 194 | 195 | **2022-08-27** 196 | 197 | 1. 修复b40/b50小数点后四位错误的问题 198 | 199 | **2022-08-25** 200 | 201 | 1. 修复猜歌模块发送曲绘时为未知曲绘的问题 202 | 203 | **2022-08-16** 204 | 205 | 1. 修改 `b40/b50` 指令绘图,如不喜欢请将 `libraries/maimaidx_project.py` 第`6`行 `maimai_best_50` 改成 `maimai_best_40` 206 | 2. 修改查曲绘图 207 | 208 | **2022-07-11** 209 | 210 | 1. 修复指令 `分数列表` 没有提供2022谱面的问题 211 | 212 | **2022-06-23** 213 | 214 | 1. 支持2022 215 | 2. 修改所有曲绘后缀 216 | 3. 修改获取在线文件的路径 217 | 218 | **2022-03-10** 219 | 220 | 1. 新增段位显示,感谢 [Kurokitu](https://github.com/Kurokitu) 提供源码及资源 221 | 222 | **2022-02-13** 223 | 224 | 1. 修复部分新曲没有难易度参考的问题 225 | 226 | **2022-01-27** 227 | 228 | 1. 修复添加/删除别名无效的问题 229 | 230 | **2022-01-16** 231 | 232 | 1. 修复b40/b50查询@Ta人情况下无效的问题 233 | 234 | **2022-01-03** 235 | 236 | 1. 修改获取音乐数据的函数,不在使用同步进程 237 | 2. 不再使用正则表达式获取@人员的QQ号 238 | 3. 不再使用CQ码方式发送图片 239 | 4. 修改大部分源码 240 | 241 | **2021-11-15** 242 | 243 | 1. 在请求获取maimaiDX数据的函数添加 `@retry` 装饰器,遇到请求数据失败的情况时重新尝试请求 244 | 245 | **2021-10-18** 246 | 247 | 1. 添加排卡功能,感谢 [CrazyKid](https://github.com/CrazyKidCN) 248 | 249 | **2021-10-14** 250 | 251 | 1. 更新查看推荐的上分乐曲 252 | 2. 更新查看牌子完成进度 253 | 3. 更新查看等级评价完成进度 254 | 4. 查看水鱼网站的用户ra排行 255 | 256 | **2021-09-29** 257 | 258 | 1. 更新b50、乐曲推荐功能,感谢 [BlueDeer233](https://github.com/BlueDeer233) 259 | 260 | **2021-09-13** 261 | 262 | 1. 更新猜歌功能以及开关,感谢 [BlueDeer233](https://github.com/BlueDeer233) 263 | 264 | 265 | ## 鸣谢 266 | 267 | 感谢 [zhanbao2000](https://github.com/zhanbao2000) 提供的 `nonebot2` 分支 268 | 269 | 感谢 [CrazyKid](https://github.com/CrazyKidCN) 提供的源码支持 270 | 271 | 感谢 [Diving-Fish](https://github.com/Diving-Fish) 提供的源码支持 272 | 273 | 感谢 [BlueDeer233](https://github.com/BlueDeer233) 提供猜歌功能的源码支持 274 | 275 | ## License 276 | 277 | MIT 278 | 279 | 您可以自由使用本项目的代码用于商业或非商业的用途,但必须附带 MIT 授权协议。 280 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Dict, List 3 | 4 | from hoshino import Service, priv 5 | from hoshino.config import NICKNAME 6 | from hoshino.log import new_logger 7 | 8 | ### 必须 9 | log = new_logger('maimaiDX') 10 | loga = new_logger('maimaiDXArcade') 11 | BOTNAME = NICKNAME if isinstance(NICKNAME, str) else list(NICKNAME)[0] 12 | 13 | 14 | SV_HELP = '请使用 帮助maimaiDX 查看帮助' 15 | sv = Service('maimaiDX', manage_priv=priv.ADMIN, enable_on_default=True, help_=SV_HELP) 16 | 17 | public_addr = 'https://www.yuzuchan.moe/vote' 18 | 19 | 20 | # echartsjs 21 | SNAPSHOT_JS = ( 22 | "echarts.getInstanceByDom(document.querySelector('div[_echarts_instance_]'))." 23 | "getDataURL({type: 'PNG', pixelRatio: 2, excludeComponents: ['toolbox']})" 24 | ) 25 | 26 | 27 | # 文件路径 28 | Root: Path = Path(__file__).parent 29 | static: Path = Root / 'static' 30 | 31 | arcades_json: Path = static / 'arcades.json' # 机厅 32 | config_json: Path = static / 'config.json' # token 33 | alias_file: Path = static / 'music_alias.json' # 别名暂存文件 34 | local_alias_file: Path = static / 'local_music_alias.json' # 本地别名文件 35 | music_file: Path = static / 'music_data.json' # 曲目暂存文件 36 | chart_file: Path = static / 'music_chart.json' # 谱面数据暂存文件 37 | guess_file: Path = static / 'group_guess_switch.json' # 猜歌开关群文件 38 | group_alias_file: Path = static / 'group_alias_switch.json' # 别名推送开关群文件 39 | pie_html_file: Path = static / 'temp_pie.html' # 饼图html文件 40 | 41 | 42 | # 静态资源路径 43 | maimaidir: Path = static / 'mai' / 'pic' 44 | coverdir: Path = static / 'mai' / 'cover' 45 | ratingdir: Path = static / 'mai' / 'rating' 46 | platedir: Path = static / 'mai' / 'plate' 47 | 48 | 49 | # 字体路径 50 | SIYUAN: Path = static / 'ResourceHanRoundedCN-Bold.ttf' 51 | SHANGGUMONO: Path = static / 'ShangguMonoSC-Regular.otf' 52 | TBFONT: Path = static / 'Torus SemiBold.otf' 53 | 54 | 55 | # 常用变量 56 | SONGS_PER_PAGE: int = 25 57 | scoreRank: List[str] = ['d', 'c', 'b', 'bb', 'bbb', 'a', 'aa', 'aaa', 's', 's+', 'ss', 'ss+', 'sss', 'sss+'] 58 | score_Rank: List[str] = ['d', 'c', 'b', 'bb', 'bbb', 'a', 'aa', 'aaa', 's', 'sp', 'ss', 'ssp', 'sss', 'sssp'] 59 | score_Rank_l: Dict[str, str] = { 60 | 'd': 'D', 61 | 'c': 'C', 62 | 'b': 'B', 63 | 'bb': 'BB', 64 | 'bbb': 'BBB', 65 | 'a': 'A', 66 | 'aa': 'AA', 67 | 'aaa': 'AAA', 68 | 's': 'S', 69 | 'sp': 'Sp', 70 | 'ss': 'SS', 71 | 'ssp': 'SSp', 72 | 'sss': 'SSS', 73 | 'sssp': 'SSSp' 74 | } 75 | comboRank: List[str] = ['fc', 'fc+', 'ap', 'ap+'] 76 | combo_rank: List[str] = ['fc', 'fcp', 'ap', 'app'] 77 | syncRank: List[str] = ['fs', 'fs+', 'fdx', 'fdx+'] 78 | sync_rank: List[str] = ['fs', 'fsp', 'fsd', 'fsdp'] 79 | sync_rank_p: List[str] = ['fs', 'fsp', 'fdx', 'fdxp'] 80 | diffs: List[str] = ['Basic', 'Advanced', 'Expert', 'Master', 'Re:Master'] 81 | levelList: List[str] = ['1', '2', '3', '4', '5', '6', '7', '7+', '8', '8+', '9', '9+', '10', '10+', '11', '11+', '12', '12+', '13', '13+', '14', '14+', '15'] 82 | achievementList: List[float] = [50.0, 60.0, 70.0, 75.0, 80.0, 90.0, 94.0, 97.0, 98.0, 99.0, 99.5, 100.0, 100.5] 83 | BaseRaSpp: List[float] = [7.0, 8.0, 9.6, 11.2, 12.0, 13.6, 15.2, 16.8, 20.0, 20.3, 20.8, 21.1, 21.6, 22.4] 84 | fcl: Dict[str, str] = {'fc': 'FC', 'fcp': 'FCp', 'ap': 'AP', 'app': 'APp'} 85 | fsl: Dict[str, str] = {'fs': 'FS', 'fsp': 'FSp', 'fsd': 'FSD', 'fdx': 'FSD', 'fsdp': 'FSDp', 'fdxp': 'FSDp', 'sync': 'Sync'} 86 | plate_to_version: Dict[str, str] = { 87 | '初': 'maimai', 88 | '真': 'maimai PLUS', 89 | '超': 'maimai GreeN', 90 | '檄': 'maimai GreeN PLUS', 91 | '橙': 'maimai ORANGE', 92 | '暁': 'maimai ORANGE PLUS', 93 | '晓': 'maimai ORANGE PLUS', 94 | '桃': 'maimai PiNK', 95 | '櫻': 'maimai PiNK PLUS', 96 | '樱': 'maimai PiNK PLUS', 97 | '紫': 'maimai MURASAKi', 98 | '菫': 'maimai MURASAKi PLUS', 99 | '堇': 'maimai MURASAKi PLUS', 100 | '白': 'maimai MiLK', 101 | '雪': 'MiLK PLUS', 102 | '輝': 'maimai FiNALE', 103 | '辉': 'maimai FiNALE', 104 | '熊': 'maimai でらっくす', 105 | '華': 'maimai でらっくす PLUS', 106 | '华': 'maimai でらっくす PLUS', 107 | '爽': 'maimai でらっくす Splash', 108 | '煌': 'maimai でらっくす Splash PLUS', 109 | '宙': 'maimai でらっくす UNiVERSE', 110 | '星': 'maimai でらっくす UNiVERSE PLUS', 111 | '祭': 'maimai でらっくす FESTiVAL', 112 | '祝': 'maimai でらっくす FESTiVAL PLUS', 113 | '双': 'maimai でらっくす BUDDiES', 114 | '宴': 'maimai でらっくす BUDDiES PLUS' 115 | } 116 | platecn = { 117 | '晓': '暁', 118 | '樱': '櫻', 119 | '堇': '菫', 120 | '辉': '輝', 121 | '华': '華' 122 | } 123 | category: Dict[str, str] = { 124 | '流行&动漫': 'anime', 125 | '舞萌': 'maimai', 126 | 'niconico & VOCALOID': 'niconico', 127 | '东方Project': 'touhou', 128 | '其他游戏': 'game', 129 | '音击&中二节奏': 'ongeki', 130 | 'POPSアニメ': 'anime', 131 | 'maimai': 'maimai', 132 | 'niconicoボーカロイド': 'niconico', 133 | '東方Project': 'touhou', 134 | 'ゲームバラエティ': 'game', 135 | 'オンゲキCHUNITHM': 'ongeki', 136 | '宴会場': '宴会场' 137 | } -------------------------------------------------------------------------------- /command/__init__.py: -------------------------------------------------------------------------------- 1 | from .mai_alias import * 2 | from .mai_base import * 3 | from .mai_guess import * 4 | from .mai_score import * 5 | from .mai_search import * 6 | from .mai_table import * 7 | -------------------------------------------------------------------------------- /command/mai_alias.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import re 3 | import traceback 4 | from re import Match 5 | from textwrap import dedent 6 | from typing import List 7 | 8 | from nonebot import NoneBot 9 | 10 | from hoshino.service import priv 11 | from hoshino.typing import CQEvent, MessageSegment 12 | 13 | from .. import SONGS_PER_PAGE, log, public_addr, sv 14 | from ..libraries.image import image_to_base64, text_to_image 15 | from ..libraries.maimaidx_api_data import maiApi 16 | from ..libraries.maimaidx_error import AliasesNotFoundError, ServerError 17 | from ..libraries.maimaidx_music import alias, mai, update_local_alias 18 | from ..libraries.maimaidx_music_info import draw_music_info 19 | 20 | update_alias = sv.on_fullmatch('更新别名库') 21 | alias_switch_on = sv.on_fullmatch('全局开启别名推送') 22 | alias_switch_off = sv.on_fullmatch('全局关闭别名推送') 23 | alias_local_apply = sv.on_prefix(['添加本地别名', '添加本地别称']) 24 | alias_apply = sv.on_prefix(['添加别名', '增加别名', '增添别名', '添加别称']) 25 | alias_agree = sv.on_prefix(['同意别名', '同意别称']) 26 | alias_status = sv.on_prefix(['当前投票', '当前别名投票', '当前别称投票']) 27 | alias_switch = sv.on_suffix(['别名推送', '别称推送']) 28 | alias_song = sv.on_rex(re.compile(r'^(id)?\s?(.+)\s?有什么别[名称]$', re.IGNORECASE)) 29 | alias_apply_status = sv.scheduled_job('interval', minutes=5) 30 | 31 | 32 | @update_alias 33 | async def _(bot: NoneBot, ev: CQEvent): 34 | if not priv.check_priv(ev, priv.SUPERUSER): 35 | return 36 | try: 37 | await mai.get_music_alias() 38 | log.info('手动更新别名库成功') 39 | await bot.send(ev, '手动更新别名库成功') 40 | except: 41 | log.error('手动更新别名库失败') 42 | await bot.send(ev, '手动更新别名库失败') 43 | 44 | 45 | @alias_switch_on 46 | @alias_switch_off 47 | async def _(bot: NoneBot, ev: CQEvent): 48 | if not priv.check_priv(ev, priv.SUPERUSER): 49 | return 50 | if ev.raw_message == '全局关闭别名推送': 51 | await alias.alias_global_change(False) 52 | await bot.send(ev, '已全局关闭maimai别名推送') 53 | elif ev.raw_message == '全局开启别名推送': 54 | await alias.alias_global_change(True) 55 | await bot.send(ev, '已全局开启maimai别名推送') 56 | 57 | 58 | @alias_local_apply 59 | async def _(bot: NoneBot, ev: CQEvent): 60 | args: List[str] = ev.message.extract_plain_text().strip().split() 61 | if len(args) != 2: 62 | await alias_local_apply.finish(ev, '参数错误', at_sender=True) 63 | song_id, alias_name = args 64 | if not mai.total_list.by_id(song_id): 65 | await bot.finish(ev, f'未找到ID为「{song_id}」的曲目', at_sender=True) 66 | try: 67 | server_exist = await maiApi.get_songs_alias(song_id) 68 | if alias_name.lower() in server_exist.Alias: 69 | await bot.send(ev, f'该曲目的别名「{alias_name}」已存在别名服务器,不能重复添加别名', at_sender=True) 70 | await mai.get_music_alias() 71 | await bot.finish(ev, f'别名库更新完成', at_sender=True) 72 | except AliasesNotFoundError: 73 | pass 74 | 75 | local_exist = mai.total_alias_list.by_id(song_id) 76 | if local_exist and alias_name.lower() in local_exist[0].Alias: 77 | await bot.finish(ev, f'本地别名库已存在该别名', at_sender=True) 78 | 79 | issave = await update_local_alias(song_id, alias_name) 80 | if not issave: 81 | msg = '添加本地别名失败' 82 | else: 83 | msg = f'已成功为ID「{song_id}」添加别名「{alias_name}」到本地别名库' 84 | await bot.send(ev, msg, at_sender=True) 85 | 86 | 87 | @alias_apply 88 | async def _(bot: NoneBot, ev: CQEvent): 89 | try: 90 | args: List[str] = ev.message.extract_plain_text().strip().split() 91 | if len(args) != 2: 92 | await bot.finish(ev, '参数错误', at_sender=True) 93 | song_id, alias_name = args 94 | if not (music := mai.total_list.by_id(song_id)): 95 | await bot.finish(ev, f'未找到ID为「{song_id}」的曲目') 96 | try: 97 | isexist = await maiApi.get_songs_alias(song_id) 98 | if alias_name.lower() in isexist.Alias: 99 | await bot.send( 100 | ev, 101 | f'该曲目的别名「{alias_name}」已存在别名服务器,不能重复添加别名,正在进行更新别名库', 102 | at_sender=True 103 | ) 104 | await mai.get_music_alias() 105 | await bot.finish(ev, f'别名库更新完成', at_sender=True) 106 | except AliasesNotFoundError: 107 | pass 108 | 109 | status = await maiApi.post_alias(song_id, alias_name, ev.user_id) 110 | msg = dedent(f'''\ 111 | 您已提交以下别名申请 112 | ID:{song_id} 113 | 别名:{alias_name} 114 | 现在可用使用唯一标签「{status.Tag}」来进行投票,例如:同意别名 {status.Tag} 115 | 浏览 {public_addr} 查看详情 116 | {await draw_music_info(music)} 117 | ''') 118 | except (ServerError, ValueError) as e: 119 | log.error(traceback.format_exc()) 120 | msg = str(e) 121 | await bot.send(ev, msg, at_sender=True) 122 | 123 | 124 | @alias_agree 125 | async def _(bot: NoneBot, ev: CQEvent): 126 | try: 127 | tag: str = ev.message.extract_plain_text().strip().upper() 128 | status = await maiApi.post_agree_user(tag, ev.user_id) 129 | await bot.send(ev, status, at_sender=True) 130 | except ValueError as e: 131 | await bot.send(ev, str(e), at_sender=True) 132 | 133 | 134 | @alias_status 135 | async def _(bot: NoneBot, ev: CQEvent): 136 | try: 137 | args: str = ev.message.extract_plain_text().strip() 138 | status = await maiApi.get_alias_status() 139 | if not status: 140 | await bot.finish(ev, '未查询到正在进行的别名投票', at_sender=True) 141 | 142 | page = max(min(int(args), len(status) // SONGS_PER_PAGE + 1), 1) if args else 1 143 | result = [] 144 | for num, _s in enumerate(status): 145 | if (page - 1) * SONGS_PER_PAGE <= num < page * SONGS_PER_PAGE: 146 | apply_alias = _s.ApplyAlias 147 | if len(_s.ApplyAlias) > 15: 148 | apply_alias = _s.ApplyAlias[:15] + '...' 149 | r = f'{_s.Tag}:\n- ID:{_s.SongID}\n- 别名:{apply_alias}\n- 票数:{_s.AgreeVotes}/{_s.Votes}' 150 | result.append(r) 151 | result.append(f'第{page}页,共{len(status) // SONGS_PER_PAGE + 1}页') 152 | msg = MessageSegment.image(image_to_base64(text_to_image('\n'.join(result)))) 153 | except (ServerError, ValueError) as e: 154 | log.error(traceback.format_exc()) 155 | msg = str(e) 156 | await bot.send(ev, msg, at_sender=True) 157 | 158 | 159 | @alias_song 160 | async def _(bot: NoneBot, ev: CQEvent): 161 | match: Match[str] = ev['match'] 162 | findid = bool(match.group(1)) 163 | name = match.group(2) 164 | aliases = None 165 | if findid and name.isdigit(): 166 | alias_id = mai.total_alias_list.by_id(name) 167 | if not alias_id: 168 | await bot.finish(ev, '未找到此歌曲\n可以使用「添加别名」指令给该乐曲添加别名', at_sender=True) 169 | else: 170 | aliases = alias_id 171 | else: 172 | aliases = mai.total_alias_list.by_alias(name) 173 | if not aliases: 174 | if name.isdigit(): 175 | alias_id = mai.total_alias_list.by_id(name) 176 | if not alias_id: 177 | await bot.finish(ev, '未找到此歌曲\n可以使用「添加别名」指令给该乐曲添加别名', at_sender=True) 178 | else: 179 | aliases = alias_id 180 | else: 181 | await bot.finish(ev, '未找到此歌曲\n可以使用「添加别名」指令给该乐曲添加别名', at_sender=True) 182 | if len(aliases) != 1: 183 | msg = [] 184 | for songs in aliases: 185 | alias_list = '\n'.join(songs.Alias) 186 | msg.append(f'ID:{songs.SongID}\n{alias_list}') 187 | await bot.finish(ev, f'找到{len(aliases)}个相同别名的曲目:\n' + '\n======\n'.join(msg), at_sender=True) 188 | 189 | if len(aliases[0].Alias) == 1: 190 | await bot.finish(ev, '该曲目没有别名', at_sender=True) 191 | 192 | msg = f'该曲目有以下别名:\nID:{aliases[0].SongID}\n' 193 | msg += '\n'.join(aliases[0].Alias) 194 | await bot.send(ev, msg, at_sender=True) 195 | 196 | 197 | @alias_switch 198 | async def _(bot: NoneBot, ev: CQEvent): 199 | args = ev.message.extract_plain_text().strip().lower() 200 | if args == '开启': 201 | msg = await alias.on(ev.group_id) 202 | elif args == '关闭': 203 | msg = await alias.off(ev.group_id) 204 | else: 205 | raise ValueError('matcher type error') 206 | 207 | await bot.send(ev, msg) 208 | 209 | @alias_apply_status 210 | async def _(): 211 | try: 212 | group = await sv.get_enable_groups() 213 | status = await maiApi.get_alias_status() 214 | if not alias.push.global_switch: 215 | await mai.get_music_alias() 216 | return 217 | if status: 218 | msg = ['检测到新的别名申请'] 219 | msg2 = ['以下是已成功添加别名的曲目'] 220 | for _s in status: 221 | if _s.IsNew and (usernum := _s.AgreeVotes) < (votes := _s.Votes): 222 | song_id = str(_s.SongID) 223 | alias_name = _s.ApplyAlias 224 | music = mai.total_list.by_id(song_id) 225 | msg.append(f'{_s.Tag}:\nID:{song_id}\n标题:{music.title}\n别名:{alias_name}\n票数:{usernum}/{votes}') 226 | elif _s.IsEnd: 227 | song_id = str(_s.SongID) 228 | alias_name = _s.ApplyAlias 229 | music = mai.total_list.by_id(song_id) 230 | msg2.append(f'ID:{song_id}\n标题:{music.title}\n别名:{alias_name}') 231 | 232 | if len(msg) != 1 and len(msg2) != 1: 233 | for gid in group.keys(): 234 | if gid in alias.push.disable: 235 | continue 236 | try: 237 | if len(msg) != 1: 238 | await sv.bot.send_group_msg(group_id=gid, message='\n======\n'.join(msg) + f'\n浏览{public_addr}查看详情') 239 | await asyncio.sleep(5) 240 | if len(msg2) != 1: 241 | await sv.bot.send_group_msg(group_id=gid, message='\n======\n'.join(msg2)) 242 | await asyncio.sleep(5) 243 | except: 244 | continue 245 | await mai.get_music_alias() 246 | except (ServerError, ValueError) as e: 247 | log.error(str(e)) -------------------------------------------------------------------------------- /command/mai_base.py: -------------------------------------------------------------------------------- 1 | import random 2 | from re import Match 3 | 4 | from nonebot import NoneBot 5 | from PIL import Image 6 | 7 | from hoshino.service import priv 8 | from hoshino.typing import CQEvent, MessageSegment 9 | 10 | from .. import BOTNAME, Root, log, sv 11 | from ..libraries.image import image_to_base64, music_picture 12 | from ..libraries.maimaidx_api_data import maiApi 13 | from ..libraries.maimaidx_error import * 14 | from ..libraries.maimaidx_music import mai 15 | from ..libraries.maimaidx_music_info import draw_music_info 16 | from ..libraries.maimaidx_player_score import rating_ranking_data 17 | from ..libraries.tool import qqhash 18 | 19 | update_data = sv.on_fullmatch('更新maimai数据') 20 | maimaidxhelp = sv.on_fullmatch(['帮助maimaiDX', '帮助maimaidx']) 21 | maimaidxrepo = sv.on_fullmatch(['项目地址maimaiDX', '项目地址maimaidx']) 22 | mai_today = sv.on_prefix(['今日mai', '今日舞萌', '今日运势']) 23 | mai_what = sv.on_rex(r'.*mai.*什么(.+)?') 24 | random_song = sv.on_rex(r'^[来随给]个((?:dx|sd|标准))?([绿黄红紫白]?)([0-9]+\+?)$') 25 | rating_ranking = sv.on_prefix(['查看排名', '查看排行']) 26 | my_rating_ranking = sv.on_fullmatch('我的排名') 27 | data_update_daily = sv.scheduled_job('cron', hour='4') 28 | 29 | 30 | @update_data 31 | async def _(bot: NoneBot, ev: CQEvent): 32 | if not priv.check_priv(ev, priv.SUPERUSER): 33 | return 34 | await mai.get_music() 35 | await mai.get_music_alias() 36 | await bot.send(ev, 'maimai数据更新完成') 37 | 38 | 39 | @maimaidxhelp 40 | async def _(bot: NoneBot, ev: CQEvent): 41 | await bot.send(ev, MessageSegment.image(image_to_base64(Image.open((Root / 'maimaidxhelp.png')))), at_sender=True) 42 | 43 | 44 | @maimaidxrepo 45 | async def _(bot: NoneBot, ev: CQEvent): 46 | await bot.send(ev, f'项目地址:https://github.com/Yuri-YuzuChaN/maimaiDX\n求star,求宣传~', at_sender=True) 47 | 48 | 49 | @mai_today 50 | async def _(bot: NoneBot, ev: CQEvent): 51 | wm_list = ['拼机', '推分', '越级', '下埋', '夜勤', '练底力', '练手法', '打旧框', '干饭', '抓绝赞', '收歌'] 52 | uid = ev.user_id 53 | h = qqhash(uid) 54 | rp = h % 100 55 | wm_value = [] 56 | for i in range(11): 57 | wm_value.append(h & 3) 58 | h >>= 2 59 | msg = f'\n今日人品值:{rp}\n' 60 | for i in range(11): 61 | if wm_value[i] == 3: 62 | msg += f'宜 {wm_list[i]}\n' 63 | elif wm_value[i] == 0: 64 | msg += f'忌 {wm_list[i]}\n' 65 | music = mai.total_list[h % len(mai.total_list)] 66 | ds = '/'.join([str(_) for _ in music.ds]) 67 | msg += f'{BOTNAME} Bot提醒您:打机时不要大力拍打或滑动哦\n今日推荐歌曲:\n' 68 | msg += f'ID.{music.id} - {music.title}' 69 | msg += MessageSegment.image(image_to_base64(Image.open(music_picture(music.id)))) 70 | msg += ds 71 | await bot.send(ev, msg, at_sender=True) 72 | 73 | 74 | @mai_what 75 | async def _(bot: NoneBot, ev: CQEvent): 76 | match: Match[str] = ev['match'] 77 | music = mai.total_list.random() 78 | user = None 79 | if (point := match.group(1)) and ('推分' in point or '上分' in point or '加分' in point): 80 | try: 81 | user = await maiApi.query_user_b50(qqid=ev.user_id) 82 | r = random.randint(0, 1) 83 | _ra = 0 84 | ignore = [] 85 | if r == 0: 86 | if sd := user.charts.sd: 87 | ignore = [m.song_id for m in sd if m.achievements < 100.5] 88 | _ra = sd[-1].ra 89 | else: 90 | if dx := user.charts.dx: 91 | ignore = [m.song_id for m in dx if m.achievements < 100.5] 92 | _ra = dx[-1].ra 93 | if _ra != 0: 94 | ds = round(_ra / 22.4, 1) 95 | musiclist = mai.total_list.filter(ds=(ds, ds + 1)) 96 | for _m in musiclist: 97 | if int(_m.id) in ignore: 98 | musiclist.remove(_m) 99 | music = musiclist.random() 100 | except (UserNotFoundError, UserDisabledQueryError): 101 | pass 102 | await bot.send(ev, await draw_music_info(music, ev.user_id, user)) 103 | 104 | 105 | @random_song 106 | async def _(bot: NoneBot, ev: CQEvent): 107 | try: 108 | match: Match[str] = ev['match'] 109 | diff = match.group(1) 110 | if diff == 'dx': 111 | tp = ['DX'] 112 | elif diff == 'sd' or diff == '标准': 113 | tp = ['SD'] 114 | else: 115 | tp = ['SD', 'DX'] 116 | level = match.group(3) 117 | if match.group(2) == '': 118 | music_data = mai.total_list.filter(level=level, type=tp) 119 | else: 120 | music_data = mai.total_list.filter(level=level, diff=['绿黄红紫白'.index(match.group(2))], type=tp) 121 | if len(music_data) == 0: 122 | msg = '没有这样的乐曲哦。' 123 | else: 124 | msg = await draw_music_info(music_data.random(), ev.user_id) 125 | except: 126 | msg = '随机命令错误,请检查语法' 127 | await bot.send(ev, msg, at_sender=True) 128 | 129 | 130 | @rating_ranking 131 | async def _(bot: NoneBot, ev: CQEvent): 132 | args: str = ev.message.extract_plain_text().strip() 133 | page = 1 134 | name = '' 135 | if args.isdigit(): 136 | page = int(args) 137 | else: 138 | name = args.lower() 139 | 140 | pic = await rating_ranking_data(name, page) 141 | await bot.send(ev, pic, at_sender=True) 142 | 143 | 144 | @my_rating_ranking 145 | async def _(bot: NoneBot, ev: CQEvent): 146 | try: 147 | user = await maiApi.query_user_b50(qqid=ev.user_id) 148 | rank_data = await maiApi.rating_ranking() 149 | for num, rank in enumerate(rank_data): 150 | if rank.username == user.username: 151 | result = f'您的Rating为「{rank.ra}」,排名第「{num + 1}」名' 152 | await bot.finish(ev, result, at_sender=True) 153 | except (UserNotFoundError, UserNotExistsError, UserDisabledQueryError) as e: 154 | await bot.finish(ev, str(e), at_sender=True) 155 | 156 | 157 | 158 | @data_update_daily 159 | async def _(): 160 | await mai.get_music() 161 | mai.guess() 162 | log.info('maimaiDX数据更新完毕') 163 | -------------------------------------------------------------------------------- /command/mai_guess.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from textwrap import dedent 3 | 4 | from nonebot import NoneBot 5 | 6 | from hoshino.service import priv 7 | from hoshino.typing import CQEvent, MessageSegment 8 | 9 | from .. import sv 10 | from ..libraries.maimaidx_music import guess 11 | from ..libraries.maimaidx_music_info import draw_music_info 12 | 13 | guess_music_start = sv.on_fullmatch('猜歌') 14 | guess_music_pic = sv.on_fullmatch('猜曲绘') 15 | guess_music_reset = sv.on_fullmatch('重置猜歌') 16 | guess_music_switch = sv.on_suffix('mai猜歌') 17 | 18 | 19 | @guess_music_start 20 | async def guess_music(bot: NoneBot, ev: CQEvent): 21 | gid = ev.group_id 22 | if ev.group_id not in guess.switch.enable: 23 | await bot.finish(ev, '该群已关闭猜歌功能,开启请输入 开启mai猜歌') 24 | if gid in guess.Group: 25 | await bot.finish(ev, '该群已有正在进行的猜歌或猜曲绘') 26 | guess.start(gid) 27 | await bot.send(ev, dedent(''' \ 28 | 我将从热门乐曲中选择一首歌,每隔8秒描述它的特征, 29 | 请输入歌曲的 id 标题 或 别名(需bot支持,无需大小写) 进行猜歌(DX乐谱和标准乐谱视为两首歌)。 30 | 猜歌时查歌等其他命令依然可用。 31 | ''')) 32 | await asyncio.sleep(4) 33 | for cycle in range(7): 34 | if ev.group_id not in guess.switch.enable or gid not in guess.Group or guess.Group[gid].end: 35 | break 36 | if cycle < 6: 37 | await bot.send(ev, f'{cycle + 1}/7 这首歌{guess.Group[gid].options[cycle]}') 38 | await asyncio.sleep(8) 39 | else: 40 | await bot.send(ev, f'''7/7 这首歌封面的一部分是:\n{MessageSegment.image(guess.Group[gid].img)}答案将在30秒后揭晓''') 41 | for _ in range(30): 42 | await asyncio.sleep(1) 43 | if gid in guess.Group: 44 | if ev.group_id not in guess.switch.enable or guess.Group[gid].end: 45 | return 46 | else: 47 | return 48 | guess.Group[gid].end = True 49 | answer = f'''答案是:\n{await draw_music_info(guess.Group[gid].music)}''' 50 | guess.end(gid) 51 | await bot.send(ev, answer) 52 | 53 | 54 | @guess_music_pic 55 | async def guess_pic(bot: NoneBot, ev: CQEvent): 56 | gid = ev.group_id 57 | if ev.group_id not in guess.switch.enable: 58 | await bot.finish(ev, '该群已关闭猜歌功能,开启请输入 开启mai猜歌') 59 | if gid in guess.Group: 60 | await bot.finish(ev, '该群已有正在进行的猜歌或猜曲绘') 61 | guess.startpic(gid) 62 | await bot.send(ev, f'以下裁切图片是哪首谱面的曲绘:\n{MessageSegment.image(guess.Group[gid].img)}请在30s内输入答案') 63 | for _ in range(30): 64 | await asyncio.sleep(1) 65 | if gid in guess.Group: 66 | if ev.group_id not in guess.switch.enable or guess.Group[gid].end: 67 | return 68 | else: 69 | return 70 | guess.Group[gid].end = True 71 | answer = f'''答案是:\n{await draw_music_info(guess.Group[gid].music)}''' 72 | guess.end(gid) 73 | await bot.send(ev, answer) 74 | 75 | 76 | @sv.on_message() 77 | async def guess_music_solve(bot: NoneBot, ev: CQEvent): 78 | gid = ev.group_id 79 | if gid not in guess.Group: 80 | return 81 | ans: str = ev.message.extract_plain_text().strip().lower() 82 | if ans.lower() in guess.Group[gid].answer: 83 | guess.Group[gid].end = True 84 | answer = f'''猜对了,答案是:\n{await draw_music_info(guess.Group[gid].music)}''' 85 | guess.end(gid) 86 | await bot.send(ev, answer, at_sender=True) 87 | 88 | 89 | @guess_music_reset 90 | async def reset_guess(bot: NoneBot, ev: CQEvent): 91 | gid = ev.group_id 92 | if not priv.check_priv(ev, priv.ADMIN): 93 | msg = '仅允许管理员重置' 94 | elif gid in guess.Group: 95 | msg = '已重置该群猜歌' 96 | guess.end(gid) 97 | else: 98 | msg = '该群未处在猜歌状态' 99 | await bot.send(ev, msg) 100 | 101 | 102 | @guess_music_switch 103 | async def guess_on_off(bot: NoneBot, ev: CQEvent): 104 | gid = ev.group_id 105 | args: str = ev.message.extract_plain_text().strip() 106 | if not priv.check_priv(ev, priv.ADMIN): 107 | msg = '仅允许管理员开关' 108 | elif args == '开启': 109 | msg = await guess.on(gid) 110 | elif args == '关闭': 111 | msg = await guess.off(gid) 112 | else: 113 | msg = '指令错误' 114 | await bot.send(ev, msg, at_sender=True) -------------------------------------------------------------------------------- /command/mai_score.py: -------------------------------------------------------------------------------- 1 | import re 2 | from textwrap import dedent 3 | 4 | from nonebot import NoneBot 5 | 6 | from hoshino.typing import CQEvent, MessageSegment 7 | 8 | from .. import log, sv 9 | from ..libraries.image import image_to_base64, text_to_image 10 | from ..libraries.maimai_best_50 import generate 11 | from ..libraries.maimaidx_music import mai 12 | from ..libraries.maimaidx_music_info import draw_music_play_data 13 | from ..libraries.maimaidx_player_score import music_global_data 14 | 15 | best50 = sv.on_prefix(['b50', 'B50']) 16 | minfo = sv.on_prefix(['minfo', 'Minfo', 'MINFO', 'info', 'Info', 'INFO']) 17 | ginfo = sv.on_prefix(['ginfo', 'Ginfo', 'GINFO']) 18 | score = sv.on_prefix(['分数线']) 19 | 20 | 21 | @best50 22 | async def _(bot: NoneBot, ev: CQEvent): 23 | qqid = ev.user_id 24 | username: str = ev.message.extract_plain_text().strip() 25 | for i in ev.message: 26 | if i.type == 'at' and i.data['qq'] != 'all': 27 | qqid = int(i.data['qq']) 28 | await bot.send(ev, await generate(qqid, username), at_sender=True) 29 | 30 | 31 | @minfo 32 | async def _(bot: NoneBot, ev: CQEvent): 33 | qqid = ev.user_id 34 | args: str = ev.message.extract_plain_text().strip().lower() 35 | for i in ev.message: 36 | if i.type == 'at' and i.data['qq'] != 'all': 37 | qqid = int(i.data['qq']) 38 | if not args: 39 | await bot.finish(ev, '请输入曲目id或曲名', at_sender=True) 40 | 41 | if mai.total_list.by_id(args): 42 | songs = args 43 | elif by_t := mai.total_list.by_title(args): 44 | songs = by_t.id 45 | else: 46 | alias = mai.total_alias_list.by_alias(args) 47 | if not alias: 48 | await bot.finish(ev, '未找到曲目', at_sender=True) 49 | elif len(alias) != 1: 50 | msg = f'找到相同别名的曲目,请使用以下ID查询:\n' 51 | for songs in alias: 52 | msg += f'{songs.SongID}:{songs.Name}\n' 53 | await bot.finish(ev, msg.strip(), at_sender=True) 54 | else: 55 | songs = str(alias[0].SongID) 56 | pic = await draw_music_play_data(qqid, songs) 57 | await bot.send(ev, pic, at_sender=True) 58 | 59 | 60 | @ginfo 61 | async def _(bot: NoneBot, ev: CQEvent): 62 | args: str = ev.message.extract_plain_text().strip().lower() 63 | if not args: 64 | await bot.finish(ev, '请输入曲目id或曲名', at_sender=True) 65 | if args[0] not in '绿黄红紫白': 66 | level_index = 3 67 | else: 68 | level_index = '绿黄红紫白'.index(args[0]) 69 | args = args[1:].strip() 70 | if not args: 71 | await bot.finish(ev, '请输入曲目id或曲名', at_sender=True) 72 | if mai.total_list.by_id(args): 73 | id = args 74 | elif by_t := mai.total_list.by_title(args): 75 | id = by_t.id 76 | else: 77 | alias = mai.total_alias_list.by_alias(args) 78 | if not alias: 79 | await bot.finish(ev, '未找到曲目', at_sender=True) 80 | elif len(alias) != 1: 81 | msg = f'找到相同别名的曲目,请使用以下ID查询:\n' 82 | for songs in alias: 83 | msg += f'{songs.SongID}:{songs.Name}\n' 84 | await bot.finish(ev, msg.strip(), at_sender=True) 85 | else: 86 | id = str(alias[0].SongID) 87 | 88 | music = mai.total_list.by_id(id) 89 | if not music.stats: 90 | await bot.finish(ev, '该乐曲还没有统计信息', at_sender=True) 91 | if len(music.ds) == 4 and level_index == 4: 92 | await bot.finish(ev, '该乐曲没有这个等级', at_sender=True) 93 | if not music.stats[level_index]: 94 | await bot.finish(ev, '该等级没有统计信息', at_sender=True) 95 | stats = music.stats[level_index] 96 | info = dedent(f'''\ 97 | 游玩次数:{round(stats.cnt)} 98 | 拟合难度:{stats.fit_diff:.2f} 99 | 平均达成率:{stats.avg:.2f}% 100 | 平均 DX 分数:{stats.avg_dx:.1f} 101 | 谱面成绩标准差:{stats.std_dev:.2f}''') 102 | await bot.send(ev, await music_global_data(music, level_index) + info, at_sender=True) 103 | 104 | 105 | @score 106 | async def _(bot: NoneBot, ev: CQEvent): 107 | args: str = ev.message.extract_plain_text().strip() 108 | pro = args.split() 109 | if len(pro) == 1 and pro[0] == '帮助': 110 | msg = dedent('''\ 111 | 此功能为查找某首歌分数线设计。 112 | 命令格式:分数线 <难度+歌曲id> <分数线> 113 | 例如:分数线 紫799 100 114 | 命令将返回分数线允许的 TAP GREAT 容错以及 BREAK 50落等价的 TAP GREAT 数。 115 | 以下为 TAP GREAT 的对应表: 116 | GREAT/GOOD/MISS 117 | TAP 1/2.5/5 118 | HOLD 2/5/10 119 | SLIDE 3/7.5/15 120 | TOUCH 1/2.5/5 121 | BREAK 5/12.5/25(外加200落)''') 122 | await bot.send(ev, MessageSegment.image(image_to_base64(text_to_image(msg))), at_sender=True) 123 | else: 124 | try: 125 | result = re.search(r'([绿黄红紫白])\s?([0-9]+)', args) 126 | level_labels = ['绿', '黄', '红', '紫', '白'] 127 | level_labels2 = ['Basic', 'Advanced', 'Expert', 'Master', 'Re:MASTER'] 128 | level_index = level_labels.index(result.group(1)) 129 | chart_id = result.group(2) 130 | line = float(pro[-1]) 131 | music = mai.total_list.by_id(chart_id) 132 | chart = music.charts[level_index] 133 | tap = int(chart.notes.tap) 134 | slide = int(chart.notes.slide) 135 | hold = int(chart.notes.hold) 136 | touch = int(chart.notes.touch) if len(chart.notes) == 5 else 0 137 | brk = int(chart.notes.brk) 138 | total_score = tap * 500 + slide * 1500 + hold * 1000 + touch * 500 + brk * 2500 139 | break_bonus = 0.01 / brk 140 | break_50_reduce = total_score * break_bonus / 4 141 | reduce = 101 - line 142 | if reduce <= 0 or reduce >= 101: 143 | raise ValueError 144 | msg = dedent(f'''{music.title} {level_labels2[level_index]} 145 | 分数线 {line}% 允许的最多 TAP GREAT 数量为 {(total_score * reduce / 10000):.2f}(每个-{10000 / total_score:.4f}%), 146 | BREAK 50落(一共{brk}个)等价于 {(break_50_reduce / 100):.3f} 个 TAP GREAT(-{break_50_reduce / total_score * 100:.4f}%)''') 147 | await bot.send(ev, msg, at_sender=True) 148 | except (AttributeError, ValueError) as e: 149 | log.exception(e) 150 | await bot.send(ev, '格式错误,输入“分数线 帮助”以查看帮助信息', at_sender=True) -------------------------------------------------------------------------------- /command/mai_search.py: -------------------------------------------------------------------------------- 1 | import re 2 | from re import Match 3 | from typing import List, Tuple 4 | 5 | from nonebot import NoneBot 6 | 7 | from hoshino.typing import CQEvent, MessageSegment 8 | 9 | from .. import SONGS_PER_PAGE, diffs, sv 10 | from ..libraries.image import image_to_base64, text_to_image 11 | from ..libraries.maimaidx_api_data import maiApi 12 | from ..libraries.maimaidx_error import * 13 | from ..libraries.maimaidx_model import AliasStatus 14 | from ..libraries.maimaidx_music import guess, mai 15 | from ..libraries.maimaidx_music_info import draw_music_info 16 | 17 | search_music = sv.on_prefix(['查歌', 'search']) 18 | search_base = sv.on_prefix(['定数查歌', 'search base']) 19 | search_bpm = sv.on_prefix(['bpm查歌', 'search bpm']) 20 | search_artist = sv.on_prefix(['曲师查歌', 'search artist']) 21 | search_charter = sv.on_prefix(['谱师查歌', 'search charter']) 22 | search_alias_song = sv.on_suffix(('是什么歌', '是啥歌')) 23 | query_chart = sv.on_rex(re.compile(r'^id\s?([0-9]+)$', re.IGNORECASE)) 24 | 25 | 26 | def song_level(ds1: float, ds2: float) -> List[Tuple[str, str, float, str]]: 27 | """ 28 | 查询定数范围内的乐曲 29 | 30 | Params: 31 | `ds1`: 定数下限 32 | `ds2`: 定数上限 33 | Return: 34 | `result`: 查询结果 35 | """ 36 | result: List[Tuple[str, str, float, str]] = [] 37 | music_data = mai.total_list.filter(ds=(ds1, ds2)) 38 | for music in sorted(music_data, key=lambda x: int(x.id)): 39 | if int(music.id) >= 100000: 40 | continue 41 | for i in music.diff: 42 | result.append((music.id, music.title, music.ds[i], diffs[i])) 43 | return result 44 | 45 | 46 | @search_music 47 | async def _(bot: NoneBot, ev: CQEvent): 48 | name: str = ev.message.extract_plain_text().strip() 49 | page = 1 50 | if not name: 51 | await bot.finish(ev, '请输入关键词', at_sender=True) 52 | result = mai.total_list.filter(title_search=name) 53 | if len(result) == 0: 54 | await bot.finish(ev, '没有找到这样的乐曲。\n※ 如果是别名请使用「xxx是什么歌」指令来查询哦。', at_sender=True) 55 | if len(result) == 1: 56 | await bot.finish(ev, await draw_music_info(result.random(), ev.user_id)) 57 | 58 | search_result = '' 59 | result.sort(key=lambda i: int(i.id)) 60 | for i, music in enumerate(result): 61 | if (page - 1) * SONGS_PER_PAGE <= i < page * SONGS_PER_PAGE: 62 | search_result += f'{f"「{music.id}」":<7} {music.title}\n' 63 | search_result += f'第「{page}」页,共「{len(result) // SONGS_PER_PAGE + 1}」页。请使用「id xxxxx」查询指定曲目。' 64 | await bot.send(ev, MessageSegment.image(image_to_base64(text_to_image(search_result))), at_sender=True) 65 | 66 | 67 | @search_base 68 | async def _(bot: NoneBot, ev: CQEvent): 69 | args: List[str] = ev.message.extract_plain_text().strip().split() 70 | if len(args) > 3 or len(args) == 0: 71 | await bot.finish(ev, '命令格式为\n定数查歌 「定数」「页数」\n定数查歌 「定数下限」「定数上限」「页数」', at_sender=True) 72 | page = 1 73 | if len(args) == 1: 74 | ds1, ds2 = args[0], args[0] 75 | elif len(args) == 2: 76 | if '.' in args[1]: 77 | ds1, ds2 = args 78 | else: 79 | ds1, ds2 = args[0], args[0] 80 | page = args[1] 81 | else: 82 | ds1, ds2, page = args 83 | page = int(page) 84 | result = song_level(float(ds1), float(ds2)) 85 | if not result: 86 | await bot.finish(ev, f'没有找到这样的乐曲。', at_sender=True) 87 | 88 | search_result = '' 89 | for i, _result in enumerate(result): 90 | id, title, ds, diff = _result 91 | if (page - 1) * SONGS_PER_PAGE <= i < page * SONGS_PER_PAGE: 92 | search_result += f'{f"「{id}」":<7}{f"「{diff}」":<11}{f"「{ds}」"} {title}\n' 93 | search_result += f'第「{page}」页,共「{len(result) // SONGS_PER_PAGE + 1}」页。请使用「id xxxxx」查询指定曲目。' 94 | await bot.send(ev, MessageSegment.image(image_to_base64(text_to_image(search_result))), at_sender=True) 95 | 96 | 97 | @search_bpm 98 | async def search_dx_song_bpm(bot: NoneBot, ev: CQEvent): 99 | if str(ev.group_id) in guess.Group: 100 | await bot.finish(ev, '本群正在猜歌,不要作弊哦~', at_sender=True) 101 | args = ev.message.extract_plain_text().strip().split() 102 | page = 1 103 | if len(args) == 1: 104 | result = mai.total_list.filter(bpm=int(args[0])) 105 | elif len(args) == 2: 106 | if (bpm := int(args[0])) > int(args[1]): 107 | page = int(args[1]) 108 | result = mai.total_list.filter(bpm=bpm) 109 | else: 110 | result = mai.total_list.filter(bpm=(bpm, int(args[1]))) 111 | elif len(args) == 3: 112 | result = mai.total_list.filter(bpm=(int(args[0]), int(args[1]))) 113 | page = int(args[2]) 114 | else: 115 | await bot.finish(ev, '命令格式:\nbpm查歌 「bpm」\nbpm查歌 「bpm下限」「bpm上限」「页数」', at_sender=True) 116 | if not result: 117 | await bot.finish(ev, f'没有找到这样的乐曲。', at_sender=True) 118 | 119 | search_result = '' 120 | page = max(min(page, len(result) // SONGS_PER_PAGE + 1), 1) 121 | result.sort(key=lambda x: int(x.basic_info.bpm)) 122 | 123 | for i, m in enumerate(result): 124 | if (page - 1) * SONGS_PER_PAGE <= i < page * SONGS_PER_PAGE: 125 | search_result += f'{f"「{m.id}」":<7}{f"「BPM {m.basic_info.bpm}」":<9} {m.title} \n' 126 | search_result += f'第「{page}」页,共「{len(result) // SONGS_PER_PAGE + 1}」页。请使用「id xxxxx」查询指定曲目。' 127 | await bot.send(ev, MessageSegment.image(image_to_base64(text_to_image(search_result))), at_sender=True) 128 | 129 | 130 | @search_artist 131 | async def search_dx_song_artist(bot: NoneBot, ev: CQEvent): 132 | if str(ev.group_id) in guess.Group: 133 | await bot.finish(ev, '本群正在猜歌,不要作弊哦~', at_sender=True) 134 | args: List[str] = ev.message.extract_plain_text().strip().split() 135 | page = 1 136 | if len(args) == 1: 137 | name: str = args[0] 138 | elif len(args) == 2: 139 | name: str = args[0] 140 | if args[1].isdigit(): 141 | page = int(args[1]) 142 | else: 143 | await bot.finish(ev, '命令格式:\n曲师查歌「曲师名称」「页数」', at_sender=True) 144 | else: 145 | await bot.finish(ev, '命令格式:\n曲师查歌「曲师名称」「页数」', at_sender=True) 146 | 147 | result = mai.total_list.filter(artist_search=name) 148 | if not result: 149 | await bot.finish(ev, f'没有找到这样的乐曲。', at_sender=True) 150 | 151 | search_result = '' 152 | page = max(min(page, len(result) // SONGS_PER_PAGE + 1), 1) 153 | for i, m in enumerate(result): 154 | if (page - 1) * SONGS_PER_PAGE <= i < page * SONGS_PER_PAGE: 155 | search_result += f'{f"「{m.id}」":<7}{f"「{m.basic_info.artist}」"} - {m.title}\n' 156 | search_result += f'第「{page}」页,共「{len(result) // SONGS_PER_PAGE + 1}」页。请使用「id xxxxx」查询指定曲目。' 157 | await bot.send(ev, MessageSegment.image(image_to_base64(text_to_image(search_result))), at_sender=True) 158 | 159 | 160 | @search_charter 161 | async def search_dx_song_charter(bot: NoneBot, ev: CQEvent): 162 | if str(ev.group_id) in guess.Group: 163 | await bot.finish(ev, '本群正在猜歌,不要作弊哦~', at_sender=True) 164 | args: List[str] = ev.message.extract_plain_text().strip().split() 165 | page = 1 166 | if len(args) == 1: 167 | name: str = args[0] 168 | elif len(args) == 2: 169 | name: str = args[0] 170 | if args[1].isdigit(): 171 | page = int(args[1]) 172 | else: 173 | await bot.finish(ev, '命令格式:\n谱师查歌「谱师名称」「页数」', at_sender=True) 174 | else: 175 | await bot.finish(ev, '命令格式:\n谱师查歌「谱师名称」「页数」', at_sender=True) 176 | 177 | result = mai.total_list.filter(charter_search=name) 178 | if not result: 179 | await bot.finish(ev, f'没有找到这样的乐曲。', at_sender=True) 180 | 181 | search_result = '' 182 | page = max(min(page, len(result) // SONGS_PER_PAGE + 1), 1) 183 | for i, m in enumerate(result): 184 | if (page - 1) * SONGS_PER_PAGE <= i < page * SONGS_PER_PAGE: 185 | diff_charter = zip([diffs[d] for d in m.diff], [m.charts[d].charter for d in m.diff]) 186 | search_result += f'''{f"「{m.id}」":<7}{" ".join([f"{f'「{d}」':<9}{f'「{c}」'}" for d, c in diff_charter])} {m.title}\n''' 187 | search_result += f'第「{page}」页,共「{len(result) // SONGS_PER_PAGE + 1}」页。请使用「id xxxxx」查询指定曲目。' 188 | await bot.send(ev, MessageSegment.image(image_to_base64(text_to_image(search_result))), at_sender=True) 189 | 190 | 191 | @search_alias_song 192 | async def _(bot: NoneBot, ev: CQEvent): 193 | name: str = ev.message.extract_plain_text().strip().lower() 194 | error_msg = f'未找到别名为「{name}」的歌曲\n※ 可以使用「添加别名」指令给该乐曲添加别名\n※ 如果是歌名的一部分,请使用「查歌」指令查询哦。' 195 | # 别名 196 | alias_data = mai.total_alias_list.by_alias(name) 197 | if not alias_data: 198 | try: 199 | obj = await maiApi.get_songs(name) 200 | if obj: 201 | if type(obj[0]) == AliasStatus: 202 | msg = f'未找到别名为「{name}」的歌曲,但找到与此相同别名的投票:\n' 203 | for _s in obj: 204 | msg += f'- {_s.Tag}\n ID {_s.SongID}: {name}\n' 205 | msg += f'※ 可以使用指令「同意别名 {_s.Tag}」进行投票' 206 | await bot.finish(ev, msg.strip(), at_sender=True) 207 | else: 208 | alias_data = obj 209 | except AliasesNotFoundError: 210 | pass 211 | if alias_data: 212 | if len(alias_data) != 1: 213 | msg = f'找到{len(alias_data)}个相同别名的曲目:\n' 214 | for songs in alias_data: 215 | msg += f'{songs.SongID}:{songs.Name}\n' 216 | msg += '※ 请使用「id xxxxx」查询指定曲目' 217 | await bot.finish(ev, msg.strip(), at_sender=True) 218 | else: 219 | music = mai.total_list.by_id(str(alias_data[0].SongID)) 220 | if music: 221 | msg = '您要找的是不是:' + (await draw_music_info(music, ev.user_id)) 222 | else: 223 | msg = error_msg 224 | await bot.finish(ev, msg, at_sender=True) 225 | 226 | # id 227 | if name.isdigit() and (music := mai.total_list.by_id(name)): 228 | await bot.finish(ev, '您要找的是不是:' + (await draw_music_info(music, ev.user_id)), at_sender=True) 229 | if search_id := re.search(r'^id([0-9]*)$', name, re.IGNORECASE): 230 | music = mai.total_list.by_id(search_id.group(1)) 231 | await bot.finish(ev, '您要找的是不是:' + (await draw_music_info(music, ev.user_id)), at_sender=True) 232 | 233 | # 标题 234 | result = mai.total_list.filter(title_search=name) 235 | if len(result) == 0: 236 | await bot.finish(ev, error_msg, at_sender=True) 237 | elif len(result) == 1: 238 | msg = await draw_music_info(result.random(), ev.user_id) 239 | await bot.finish(ev, '您要找的是不是:' + await draw_music_info(result.random(), ev.user_id), at_sender=True) 240 | elif len(result) < 50: 241 | msg = f'未找到别名为「{name}」的歌曲,但找到「{len(result)}」个相似标题的曲目:\n' 242 | for music in sorted(result, key=lambda x: int(x.id)): 243 | msg += f'{f"「{music.id}」":<7} {music.title}\n' 244 | msg += '请使用「id xxxxx」查询指定曲目。' 245 | await bot.finish(ev, msg.strip(), at_sender=True) 246 | else: 247 | await bot.finish(ev, f'结果过多「{len(result)}」条,请缩小查询范围。', at_sender=True) 248 | 249 | 250 | @query_chart 251 | async def _(bot: NoneBot, ev: CQEvent): 252 | match: Match[str] = ev['match'] 253 | id = match.group(1) 254 | music = mai.total_list.by_id(id) 255 | if not music: 256 | msg = f'未找到ID为「{id}」的乐曲' 257 | else: 258 | msg = await draw_music_info(music, ev.user_id) 259 | await bot.send(ev, msg) -------------------------------------------------------------------------------- /command/mai_table.py: -------------------------------------------------------------------------------- 1 | import re 2 | from re import Match 3 | 4 | from nonebot import NoneBot 5 | 6 | from hoshino.typing import CQEvent 7 | 8 | from .. import * 9 | from ..libraries.maimaidx_music_info import ( 10 | draw_plate_table, 11 | draw_rating, 12 | draw_rating_table, 13 | ) 14 | from ..libraries.maimaidx_player_score import ( 15 | level_achievement_list_data, 16 | level_process_data, 17 | player_plate_data, 18 | rise_score_data, 19 | ) 20 | from ..libraries.maimaidx_update_table import update_plate_table, update_rating_table 21 | 22 | update_table = sv.on_fullmatch('更新定数表') 23 | update_plate = sv.on_fullmatch('更新完成表') 24 | rating_table = sv.on_suffix('定数表') 25 | table_pfm = sv.on_suffix('完成表') 26 | rise_score = sv.on_rex(r'^我要在?([0-9]+\+?)?[上加\+]([0-9]+)?分\s?(.+)?') 27 | plate_process = sv.on_rex(r'^([真超檄橙暁晓桃櫻樱紫菫堇白雪輝辉舞霸熊華华爽煌宙星祭祝双宴])([極极将舞神者]舞?)进度\s?(.+)?') 28 | level_process = sv.on_rex(r'^([0-9]+\+?)\s?([abcdsfxp\+]+)\s?([\u4e00-\u9fa5]+)?进度\s?([0-9]+)?\s?(.+)?') 29 | level_achievement_list = sv.on_rex(r'^([0-9]+\.?[0-9]?\+?)分数列表\s?([0-9]+)?\s?(.+)?') 30 | 31 | 32 | @update_table 33 | async def _(bot: NoneBot, ev: CQEvent): 34 | if not priv.check_priv(ev, priv.SUPERUSER): 35 | return 36 | await bot.send(ev, await update_rating_table()) 37 | 38 | 39 | @update_plate 40 | async def _(bot: NoneBot, ev: CQEvent): 41 | if not priv.check_priv(ev, priv.SUPERUSER): 42 | return 43 | await bot.send(ev, await update_plate_table()) 44 | 45 | 46 | @rating_table 47 | async def _(bot: NoneBot, ev: CQEvent): 48 | args: str = ev.message.extract_plain_text().strip() 49 | if args in levelList[:5]: 50 | await bot.send(ev, '只支持查询lv6-15的定数表', at_sender=True) 51 | elif args in levelList[5:]: 52 | path = ratingdir / f'{args}.png' 53 | pic = draw_rating(args, path) 54 | await bot.send(ev, pic) 55 | else: 56 | await bot.send(ev, '无法识别的定数', at_sender=True) 57 | 58 | 59 | @table_pfm 60 | async def _(bot: NoneBot, ev: CQEvent): 61 | qqid = ev.user_id 62 | args: str = ev.message.extract_plain_text().strip() 63 | rating = re.search(r'^([0-9]+\+?)(app|fcp|ap|fc)?', args, re.IGNORECASE) 64 | plate = re.search(r'^([真超檄橙暁晓桃櫻樱紫菫堇白雪輝辉熊華华爽煌舞霸宙星祭祝双宴])([極极将舞神者]舞?)$', args) 65 | if rating: 66 | ra = rating.group(1) 67 | plan = rating.group(2) 68 | if args in levelList[:5]: 69 | await bot.send(ev, '只支持查询lv6-15的完成表', at_sender=True) 70 | elif ra in levelList[5:]: 71 | pic = await draw_rating_table(qqid, ra, True if plan and plan.lower() in combo_rank else False) 72 | await bot.send(ev, pic, at_sender=True) 73 | else: 74 | await bot.send(ev, '无法识别的表格', at_sender=True) 75 | elif plate: 76 | ver = plate.group(1) 77 | plan = plate.group(2) 78 | if ver in platecn: 79 | ver = platecn[ver] 80 | if ver in ['舞', '霸']: 81 | await bot.finish(ev, '暂不支持查询「舞」系和「霸者」的牌子', at_sender=True) 82 | if f'{ver}{plan}' == '真将': 83 | await bot.finish(ev, '真系没有真将哦', at_sender=True) 84 | pic = await draw_plate_table(qqid, ver, plan) 85 | await bot.send(ev, pic, at_sender=True) 86 | else: 87 | await bot.send(ev, '无法识别的表格', at_sender=True) 88 | 89 | 90 | @rise_score 91 | async def _(bot: NoneBot, ev: CQEvent): 92 | qqid = ev.user_id 93 | match: Match[str] = ev['match'] 94 | username = None 95 | score = 0 96 | for i in ev.message: 97 | if i.type == 'at' and i.data['qq'] != 'all': 98 | qqid = int(i.data['qq']) 99 | 100 | if not match: 101 | rating = None 102 | score = None 103 | else: 104 | rating = match.group(1) 105 | if match.group(2): 106 | score = int(match.group(2)) 107 | 108 | if rating and rating not in levelList: 109 | await bot.finish(ev, '无此等级', at_sender=True) 110 | if match.group(3): 111 | username = match.group(3).strip() 112 | if username: 113 | qqid = None 114 | 115 | data = await rise_score_data(qqid, username, rating, score) 116 | await bot.send(ev, data, at_sender=True) 117 | 118 | 119 | @plate_process 120 | async def _(bot: NoneBot, ev: CQEvent): 121 | qqid = ev.user_id 122 | match: Match[str] = ev['match'] 123 | username = '' 124 | for i in ev.message: 125 | if i.type == 'at' and i.data['qq'] != 'all': 126 | qqid = int(i.data['qq']) 127 | 128 | ver = match.group(1) 129 | plan = match.group(2) 130 | if f'{ver}{plan}' == '真将': 131 | await bot.finish(ev, '真系没有真将哦', at_sender=True) 132 | elif match.group(3): 133 | username = match.group(3).strip() 134 | if username: 135 | qqid = None 136 | 137 | data = await player_plate_data(qqid, username, ver, plan) 138 | await bot.send(ev, data, at_sender=True) 139 | 140 | 141 | @level_process 142 | async def _(bot: NoneBot, ev: CQEvent): 143 | qqid = ev.user_id 144 | match: Match[str] = ev['match'] 145 | username = '' 146 | for i in ev.message: 147 | if i.type == 'at' and i.data['qq'] != 'all': 148 | qqid = int(i.data['qq']) 149 | 150 | level = match.group(1) 151 | plan = match.group(2) 152 | category = match.group(3) 153 | page = match.group(4) 154 | username = match.group(5) 155 | if level not in levelList: 156 | await bot.finish(ev, '无此等级', at_sender=True) 157 | if plan.lower() not in scoreRank + comboRank + syncRank: 158 | await bot.finish(ev, '无此评价等级', at_sender=True) 159 | if levelList.index(level) < 11 or (plan.lower() in scoreRank and scoreRank.index(plan.lower()) < 8): 160 | await bot.finish(ev, '兄啊,有点志向好不好', at_sender=True) 161 | if category: 162 | if category in ['已完成', '未完成', '未开始', '未游玩']: 163 | _c = { 164 | '已完成': 'completed', 165 | '未完成': 'unfinished', 166 | '未开始': 'notstarted', 167 | '未游玩': 'notstarted' 168 | } 169 | category = _c[category] 170 | else: 171 | await bot.finish(ev, f'无法指定查询「{category}」', at_sender=True) 172 | else: 173 | category = 'default' 174 | 175 | data = await level_process_data(qqid, username, level, plan, category, int(page) if page else 1) 176 | await bot.send(ev, data, at_sender=True) 177 | 178 | 179 | @level_achievement_list 180 | async def _(bot: NoneBot, ev: CQEvent): 181 | qqid = ev.user_id 182 | match: Match[str] = ev['match'] 183 | username = '' 184 | for i in ev.message: 185 | if i.type == 'at' and i.data['qq'] != 'all': 186 | qqid = int(i.data['qq']) 187 | 188 | rating = match.group(1) 189 | page = match.group(2) 190 | username = match.group(3) 191 | 192 | try: 193 | if '.' in rating: 194 | rating = round(float(rating), 1) 195 | elif rating not in levelList: 196 | await bot.finish(ev, '无此等级', at_sender=True) 197 | except ValueError: 198 | if rating not in levelList: 199 | await bot.finish(ev, '无此等级', at_sender=True) 200 | 201 | data = await level_achievement_list_data(qqid, username, rating, int(page) if page else 1) 202 | await bot.send(ev, data, at_sender=True) -------------------------------------------------------------------------------- /libraries/image.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from io import BytesIO 3 | from typing import Tuple, Union 4 | 5 | import numpy as np 6 | from PIL import Image, ImageDraw, ImageFont, ImageOps 7 | 8 | from .. import SHANGGUMONO, Path, coverdir 9 | 10 | 11 | class DrawText: 12 | 13 | def __init__(self, image: ImageDraw.ImageDraw, font: Path) -> None: 14 | self._img = image 15 | self._font = str(font) 16 | 17 | def get_box(self, text: str, size: int) -> Tuple[float, float, float, float]: 18 | return ImageFont.truetype(self._font, size).getbbox(text) 19 | 20 | def draw( 21 | self, 22 | pos_x: int, 23 | pos_y: int, 24 | size: int, 25 | text: Union[str, int, float], 26 | color: Tuple[int, int, int, int] = (255, 255, 255, 255), 27 | anchor: str = 'lt', 28 | stroke_width: int = 0, 29 | stroke_fill: Tuple[int, int, int, int] = (0, 0, 0, 0), 30 | multiline: bool = False 31 | ) -> None: 32 | font = ImageFont.truetype(self._font, size) 33 | if multiline: 34 | self._img.multiline_text( 35 | (pos_x, pos_y), 36 | str(text), 37 | color, 38 | font, 39 | anchor, 40 | stroke_width=stroke_width, 41 | stroke_fill=stroke_fill 42 | ) 43 | else: 44 | self._img.text( 45 | (pos_x, pos_y), 46 | str(text), 47 | color, 48 | font, 49 | anchor, 50 | stroke_width=stroke_width, 51 | stroke_fill=stroke_fill 52 | ) 53 | 54 | 55 | def tricolor_gradient( 56 | width: int, 57 | height: int, 58 | color1: Tuple[int, int, int] = (124, 129, 255), 59 | color2: Tuple[int, int, int] = (193, 247, 225), 60 | color3: Tuple[int, int, int] = (255, 255, 255) 61 | ) -> Image.Image: 62 | """绘制渐变色""" 63 | array = np.zeros((height, width, 3), dtype=np.uint8) 64 | 65 | for y in range(height): 66 | if y < height * 0.4: 67 | ratio = y / (height * 0.4) 68 | color = (1 - ratio) * np.array(color1) + ratio * np.array(color2) 69 | else: 70 | ratio = (y - height * 0.4) / (height * 0.6) 71 | color = (1 - ratio) * np.array(color2) + ratio * np.array(color3) 72 | array[y, :] = np.clip(color, 0, 255) 73 | 74 | image = Image.fromarray(array).convert('RGBA') 75 | return image 76 | 77 | 78 | def rounded_corners( 79 | image: Image.Image, 80 | radius: int, 81 | corners: Tuple[bool, bool, bool, bool] = (False, False, False, False) 82 | ) -> Image.Image: 83 | """ 84 | 绘制圆角 85 | 86 | Params: 87 | `image`: `PIL.Image.Image` 88 | `radius`: 圆角半径 89 | `corners`: 四个角是否绘制圆角,分别是左上、右上、右下、左下 90 | Returns: 91 | `PIL.Image.Image` 92 | """ 93 | mask = Image.new('L', image.size, 0) 94 | draw = ImageDraw.Draw(mask) 95 | draw.rounded_rectangle((0, 0, image.size[0], image.size[1]), radius, fill=255, corners=corners) 96 | 97 | new_im = ImageOps.fit(image, mask.size) 98 | new_im.putalpha(mask) 99 | 100 | return new_im 101 | 102 | 103 | def music_picture(music_id: Union[int, str]) -> Path: 104 | """ 105 | 获取谱面图片路径 106 | 107 | Params: 108 | `music_id`: 谱面 ID 109 | Returns: 110 | `Path` 111 | """ 112 | music_id = int(music_id) 113 | if (_path := coverdir / f'{music_id}.png').exists(): 114 | return _path 115 | if music_id > 100000: 116 | music_id -= 100000 117 | if (_path := coverdir / f'{music_id}.png').exists(): 118 | return _path 119 | if 1000 < music_id < 10000 or 10000 < music_id <= 11000: 120 | for _id in [music_id + 10000, music_id - 10000]: 121 | if (_path := coverdir / f'{_id}.png').exists(): 122 | return _path 123 | return coverdir / '11000.png' 124 | 125 | 126 | def text_to_image(text: str) -> Image.Image: 127 | font = ImageFont.truetype(str(SHANGGUMONO), 24) 128 | padding = 10 129 | margin = 4 130 | lines = text.strip().split('\n') 131 | max_width = 0 132 | b = 0 133 | for line in lines: 134 | l, t, r, b = font.getbbox(line) 135 | max_width = max(max_width, r) 136 | wa = max_width + padding * 2 137 | ha = b * len(lines) + margin * (len(lines) - 1) + padding * 2 138 | im = Image.new('RGB', (wa, ha), color=(255, 255, 255)) 139 | draw = ImageDraw.Draw(im) 140 | for index, line in enumerate(lines): 141 | draw.text((padding, padding + index * (margin + b)), line, font=font, fill=(0, 0, 0)) 142 | return im 143 | 144 | 145 | def image_to_base64(img: Image.Image, format='PNG') -> str: 146 | output_buffer = BytesIO() 147 | img.save(output_buffer, format) 148 | byte_data = output_buffer.getvalue() 149 | base64_str = base64.b64encode(byte_data).decode() 150 | return 'base64://' + base64_str -------------------------------------------------------------------------------- /libraries/maimai_best_50.py: -------------------------------------------------------------------------------- 1 | import math 2 | import traceback 3 | from io import BytesIO 4 | from typing import Optional, Tuple, Union, overload 5 | 6 | from PIL import Image, ImageDraw 7 | 8 | from hoshino.typing import MessageSegment 9 | 10 | from .. import * 11 | from .image import DrawText, image_to_base64, music_picture 12 | from .maimaidx_api_data import maiApi 13 | from .maimaidx_error import * 14 | from .maimaidx_model import ChartInfo, PlayInfoDefault, PlayInfoDev, UserInfo 15 | from .maimaidx_music import mai 16 | 17 | 18 | class ScoreBaseImage: 19 | 20 | text_color = (124, 129, 255, 255) 21 | t_color = [ 22 | (255, 255, 255, 255), 23 | (255, 255, 255, 255), 24 | (255, 255, 255, 255), 25 | (255, 255, 255, 255), 26 | (138, 0, 226, 255) 27 | ] 28 | id_color = [ 29 | (129, 217, 85, 255), 30 | (245, 189, 21, 255), 31 | (255, 129, 141, 255), 32 | (159, 81, 220, 255), 33 | (138, 0, 226, 255) 34 | ] 35 | bg_color = [ 36 | (111, 212, 61, 255), 37 | (248, 183, 9, 255), 38 | (255, 129, 141, 255), 39 | (159, 81, 220, 255), 40 | (219, 170, 255, 255) 41 | ] 42 | id_diff = [Image.new('RGBA', (55, 10), color) for color in bg_color] 43 | 44 | _diff = [] 45 | _rise = [] 46 | title_bg = None 47 | title_lengthen_bg = None 48 | design_bg = None 49 | aurora_bg = None 50 | shines_bg = None 51 | pattern_bg = None 52 | rainbow_bg = None 53 | rainbow_bottom_bg = None 54 | 55 | @classmethod 56 | def _load_image(cls): 57 | cls._diff = [ 58 | Image.open(maimaidir / 'b50_score_basic.png'), 59 | Image.open(maimaidir / 'b50_score_advanced.png'), 60 | Image.open(maimaidir / 'b50_score_expert.png'), 61 | Image.open(maimaidir / 'b50_score_master.png'), 62 | Image.open(maimaidir / 'b50_score_remaster.png') 63 | ] 64 | cls._rise = [ 65 | Image.open(maimaidir / 'rise_score_basic.png'), 66 | Image.open(maimaidir / 'rise_score_advanced.png'), 67 | Image.open(maimaidir / 'rise_score_expert.png'), 68 | Image.open(maimaidir / 'rise_score_master.png'), 69 | Image.open(maimaidir / 'rise_score_remaster.png') 70 | ] 71 | cls.title_bg = Image.open(maimaidir / 'title.png') 72 | cls.title_lengthen_bg = Image.open(maimaidir / 'title-lengthen.png') 73 | cls.design_bg = Image.open(maimaidir / 'design.png') 74 | cls.aurora_bg = Image.open(maimaidir / 'aurora.png').convert('RGBA').resize((1400, 220)) 75 | cls.shines_bg = Image.open(maimaidir / 'bg_shines.png').convert('RGBA') 76 | cls.pattern_bg = Image.open(maimaidir / 'pattern.png') 77 | cls.rainbow_bg = Image.open(maimaidir / 'rainbow.png').convert('RGBA') 78 | cls.rainbow_bottom_bg = Image.open(maimaidir / 'rainbow_bottom.png').convert('RGBA').resize((1200, 200)) 79 | 80 | 81 | def __init__(self, image: Image.Image = None) -> None: 82 | if not maiApi.config.saveinmem: 83 | self._load_image() 84 | self._im = image 85 | dr = ImageDraw.Draw(self._im) 86 | self._sy = DrawText(dr, SIYUAN) 87 | self._tb = DrawText(dr, TBFONT) 88 | 89 | def whiledraw( 90 | self, 91 | data: Union[List[ChartInfo], List[PlayInfoDefault], List[PlayInfoDev]], 92 | best: bool, 93 | height: int = 0 94 | ) -> None: 95 | """ 96 | 循环绘制成绩 97 | 98 | Params: 99 | `data`: 数据 100 | `dx`: 是否为新版本成绩 101 | `height`: 起始高度 102 | """ 103 | # y为第一排纵向坐标,dy为各行间距 104 | dy = 114 105 | if data and type(data[0]) == ChartInfo: 106 | y = 235 if best else 1085 107 | else: 108 | y = height 109 | for num, info in enumerate(data): 110 | if num % 5 == 0: 111 | x = 16 112 | y += dy if num != 0 else 0 113 | else: 114 | x += 276 115 | 116 | cover = Image.open(music_picture(info.song_id)).resize((75, 75)) 117 | version = Image.open(maimaidir / f'{info.type.upper()}.png').resize((37, 14)) 118 | if info.rate.islower(): 119 | rate = Image.open(maimaidir / f'UI_TTR_Rank_{score_Rank_l[info.rate]}.png').resize((63, 28)) 120 | else: 121 | rate = Image.open(maimaidir / f'UI_TTR_Rank_{info.rate}.png').resize((63, 28)) 122 | 123 | self._im.alpha_composite(self._diff[info.level_index], (x, y)) 124 | self._im.alpha_composite(cover, (x + 12, y + 12)) 125 | self._im.alpha_composite(version, (x + 51, y + 91)) 126 | self._im.alpha_composite(rate, (x + 92, y + 78)) 127 | if info.fc: 128 | fc = Image.open(maimaidir / f'UI_MSS_MBase_Icon_{fcl[info.fc]}.png').resize((34, 34)) 129 | self._im.alpha_composite(fc, (x + 154, y + 77)) 130 | if info.fs: 131 | fs = Image.open(maimaidir / f'UI_MSS_MBase_Icon_{fsl[info.fs]}.png').resize((34, 34)) 132 | self._im.alpha_composite(fs, (x + 185, y + 77)) 133 | 134 | dxscore = sum(mai.total_list.by_id(str(info.song_id)).charts[info.level_index].notes) * 3 135 | dxnum = dxScore(info.dxScore / dxscore * 100) 136 | if dxnum: 137 | self._im.alpha_composite( 138 | Image.open(maimaidir / f'UI_GAM_Gauge_DXScoreIcon_0{dxnum}.png').resize((47, 26)), (x + 217, y + 80) 139 | ) 140 | 141 | self._tb.draw(x + 26, y + 98, 13, info.song_id, self.id_color[info.level_index], anchor='mm') 142 | title = info.title 143 | if coloumWidth(title) > 18: 144 | title = changeColumnWidth(title, 17) + '...' 145 | self._sy.draw(x + 93, y + 14, 14, title, self.t_color[info.level_index], anchor='lm') 146 | self._tb.draw(x + 93, y + 38, 30, f'{info.achievements:.4f}%', self.t_color[info.level_index], anchor='lm') 147 | self._tb.draw(x + 219, y + 65, 15, f'{info.dxScore}/{dxscore}', self.t_color[info.level_index], anchor='mm') 148 | self._tb.draw(x + 93, y + 65, 15, f'{info.ds} -> {info.ra}', self.t_color[info.level_index], anchor='lm') 149 | 150 | 151 | class DrawBest(ScoreBaseImage): 152 | 153 | def __init__(self, UserInfo: UserInfo, qqid: Optional[Union[int, str]] = None) -> None: 154 | super().__init__(Image.open(maimaidir / 'b50_bg.png').convert('RGBA')) 155 | self.userName = UserInfo.nickname 156 | self.plate = UserInfo.plate 157 | self.addRating = UserInfo.additional_rating 158 | self.Rating = UserInfo.rating 159 | self.sdBest = UserInfo.charts.sd 160 | self.dxBest = UserInfo.charts.dx 161 | self.qqid = qqid 162 | 163 | def _findRaPic(self) -> str: 164 | """ 165 | 寻找指定的Rating图片 166 | 167 | Returns: 168 | `str` 返回图片名称 169 | """ 170 | if self.Rating < 1000: 171 | num = '01' 172 | elif self.Rating < 2000: 173 | num = '02' 174 | elif self.Rating < 4000: 175 | num = '03' 176 | elif self.Rating < 7000: 177 | num = '04' 178 | elif self.Rating < 10000: 179 | num = '05' 180 | elif self.Rating < 12000: 181 | num = '06' 182 | elif self.Rating < 13000: 183 | num = '07' 184 | elif self.Rating < 14000: 185 | num = '08' 186 | elif self.Rating < 14500: 187 | num = '09' 188 | elif self.Rating < 15000: 189 | num = '10' 190 | else: 191 | num = '11' 192 | return f'UI_CMN_DXRating_{num}.png' 193 | 194 | def _findMatchLevel(self) -> str: 195 | """ 196 | 寻找匹配等级图片 197 | 198 | Returns: 199 | `str` 返回图片名称 200 | """ 201 | if self.addRating <= 10: 202 | num = f'{self.addRating:02d}' 203 | else: 204 | num = f'{self.addRating + 1:02d}' 205 | return f'UI_DNM_DaniPlate_{num}.png' 206 | 207 | async def draw(self) -> Image.Image: 208 | 209 | logo = Image.open(maimaidir / 'logo.png').resize((249, 120)) 210 | dx_rating = Image.open(maimaidir / self._findRaPic()).resize((186, 35)) 211 | Name = Image.open(maimaidir / 'Name.png') 212 | MatchLevel = Image.open(maimaidir / self._findMatchLevel()).resize((80, 32)) 213 | ClassLevel = Image.open(maimaidir / 'UI_FBR_Class_00.png').resize((90, 54)) 214 | rating = Image.open(maimaidir / 'UI_CMN_Shougou_Rainbow.png').resize((270, 27)) 215 | 216 | self._im.alpha_composite(logo, (14, 60)) 217 | if self.plate: 218 | plate = Image.open(platedir / f'{self.plate}.png').resize((800, 130)) 219 | else: 220 | plate = Image.open(maimaidir / 'UI_Plate_300501.png').resize((800, 130)) 221 | self._im.alpha_composite(plate, (300, 60)) 222 | icon = Image.open(maimaidir / 'UI_Icon_309503.png').resize((120, 120)) 223 | self._im.alpha_composite(icon, (305, 65)) 224 | if self.qqid: 225 | try: 226 | qqLogo = Image.open(BytesIO(await maiApi.qqlogo(qqid=self.qqid))) 227 | self._im.alpha_composite(qqLogo.convert('RGBA').resize((120, 120)), (305, 65)) 228 | except Exception: 229 | pass 230 | self._im.alpha_composite(dx_rating, (435, 72)) 231 | Rating = f'{self.Rating:05d}' 232 | for n, i in enumerate(Rating): 233 | self._im.alpha_composite( 234 | Image.open(maimaidir / f'UI_NUM_Drating_{i}.png').resize((17, 20)), (520 + 15 * n, 80) 235 | ) 236 | self._im.alpha_composite(Name, (435, 115)) 237 | self._im.alpha_composite(MatchLevel, (625, 120)) 238 | self._im.alpha_composite(ClassLevel, (620, 60)) 239 | self._im.alpha_composite(rating, (435, 160)) 240 | 241 | self._sy.draw(445, 135, 25, self.userName, (0, 0, 0, 255), 'lm') 242 | sdrating, dxrating = sum([_.ra for _ in self.sdBest]), sum([_.ra for _ in self.dxBest]) 243 | self._tb.draw( 244 | 570, 172, 17, 245 | f'B35: {sdrating} + B15: {dxrating} = {self.Rating}', 246 | (0, 0, 0, 255), 'mm', 3, (255, 255, 255, 255) 247 | ) 248 | self._sy.draw( 249 | 700, 1570, 27, 250 | f'Designed by Yuri-YuzuChaN & BlueDeer233. Generated by {BOTNAME} BOT', 251 | self.text_color, 'mm', 5, (255, 255, 255, 255) 252 | ) 253 | 254 | self.whiledraw(self.sdBest, True) 255 | self.whiledraw(self.dxBest, False) 256 | 257 | return self._im 258 | 259 | 260 | def dxScore(dx: int) -> int: 261 | """ 262 | 获取DX评分星星数量 263 | 264 | Params: 265 | `dx`: dx百分比 266 | Returns: 267 | `int` 返回星星数量 268 | """ 269 | if dx <= 85: 270 | result = 0 271 | elif dx <= 90: 272 | result = 1 273 | elif dx <= 93: 274 | result = 2 275 | elif dx <= 95: 276 | result = 3 277 | elif dx <= 97: 278 | result = 4 279 | else: 280 | result = 5 281 | return result 282 | 283 | 284 | def getCharWidth(o: int) -> int: 285 | widths = [ 286 | (126, 1), (159, 0), (687, 1), (710, 0), (711, 1), (727, 0), (733, 1), (879, 0), (1154, 1), (1161, 0), 287 | (4347, 1), (4447, 2), (7467, 1), (7521, 0), (8369, 1), (8426, 0), (9000, 1), (9002, 2), (11021, 1), 288 | (12350, 2), (12351, 1), (12438, 2), (12442, 0), (19893, 2), (19967, 1), (55203, 2), (63743, 1), 289 | (64106, 2), (65039, 1), (65059, 0), (65131, 2), (65279, 1), (65376, 2), (65500, 1), (65510, 2), 290 | (120831, 1), (262141, 2), (1114109, 1), 291 | ] 292 | if o == 0xe or o == 0xf: 293 | return 0 294 | for num, wid in widths: 295 | if o <= num: 296 | return wid 297 | return 1 298 | 299 | 300 | def coloumWidth(s: str) -> int: 301 | res = 0 302 | for ch in s: 303 | res += getCharWidth(ord(ch)) 304 | return res 305 | 306 | 307 | def changeColumnWidth(s: str, len: int) -> str: 308 | res = 0 309 | sList = [] 310 | for ch in s: 311 | res += getCharWidth(ord(ch)) 312 | if res <= len: 313 | sList.append(ch) 314 | return ''.join(sList) 315 | 316 | 317 | @overload 318 | def computeRa(ds: float, achievement: float) -> int: 319 | """ 320 | 计算底分 321 | 322 | Params: 323 | `ds`: 定数 324 | `achievement`: 成绩 325 | Returns: 326 | 返回底分 327 | """ 328 | @overload 329 | def computeRa(ds: float, achievement: float, *, onlyrate: bool = False) -> str: 330 | """ 331 | 计算评价 332 | 333 | Params: 334 | `ds`: 定数 335 | `achievement`: 成绩 336 | `onlyrate`: 是否只返回评价 337 | Returns: 338 | 返回评价 339 | """ 340 | @overload 341 | def computeRa(ds: float, achievement: float, *, israte: bool = False) -> Tuple[int, str]: 342 | """ 343 | 计算底分和评价 344 | 345 | Params: 346 | `ds`: 定数 347 | `achievement`: 成绩 348 | `israte`: 是否返回所有数据 349 | Returns: 350 | (底分, 评价) 351 | """ 352 | def computeRa( 353 | ds: float, 354 | achievement: float, 355 | *, 356 | onlyrate: bool = False, 357 | israte: bool = False 358 | ) -> Union[int, Tuple[int, str]]: 359 | if achievement < 50: 360 | baseRa = 7.0 361 | rate = 'D' 362 | elif achievement < 60: 363 | baseRa = 8.0 364 | rate = 'C' 365 | elif achievement < 70: 366 | baseRa = 9.6 367 | rate = 'B' 368 | elif achievement < 75: 369 | baseRa = 11.2 370 | rate = 'BB' 371 | elif achievement < 80: 372 | baseRa = 12.0 373 | rate = 'BBB' 374 | elif achievement < 90: 375 | baseRa = 13.6 376 | rate = 'A' 377 | elif achievement < 94: 378 | baseRa = 15.2 379 | rate = 'AA' 380 | elif achievement < 97: 381 | baseRa = 16.8 382 | rate = 'AAA' 383 | elif achievement < 98: 384 | baseRa = 20.0 385 | rate = 'S' 386 | elif achievement < 99: 387 | baseRa = 20.3 388 | rate = 'Sp' 389 | elif achievement < 99.5: 390 | baseRa = 20.8 391 | rate = 'SS' 392 | elif achievement < 100: 393 | baseRa = 21.1 394 | rate = 'SSp' 395 | elif achievement < 100.5: 396 | baseRa = 21.6 397 | rate = 'SSS' 398 | else: 399 | baseRa = 22.4 400 | rate = 'SSSp' 401 | 402 | if israte: 403 | data = (math.floor(ds * (min(100.5, achievement) / 100) * baseRa), rate) 404 | elif onlyrate: 405 | data = rate 406 | else: 407 | data = math.floor(ds * (min(100.5, achievement) / 100) * baseRa) 408 | 409 | return data 410 | 411 | 412 | async def generate(qqid: Optional[int] = None, username: Optional[str] = None) -> Union[MessageSegment, str]: 413 | """ 414 | 生成b50 415 | 416 | Params: 417 | `qqid`: QQ号 418 | `username`: 用户名 419 | `icon`: 头像 420 | Returns: 421 | `Union[MessageSegment, str]` 422 | """ 423 | try: 424 | if username: 425 | qqid = None 426 | userinfo = await maiApi.query_user_b50(qqid=qqid, username=username) 427 | draw_best = DrawBest(userinfo, qqid) 428 | 429 | msg = MessageSegment.image(image_to_base64(await draw_best.draw())) 430 | except (UserNotFoundError, UserNotExistsError, UserDisabledQueryError) as e: 431 | msg = str(e) 432 | except Exception as e: 433 | log.error(traceback.format_exc()) 434 | msg = f'未知错误:{type(e)}\n请联系Bot管理员' 435 | return msg -------------------------------------------------------------------------------- /libraries/maimaidx_api_data.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any, Dict 3 | 4 | from aiohttp import ClientSession, ClientTimeout 5 | 6 | from .. import config_json 7 | from .maimaidx_error import * 8 | from .maimaidx_model import * 9 | 10 | 11 | class MaiConfig(BaseModel): 12 | 13 | maimaidxtoken: Optional[str] = None 14 | maimaidxproberproxy: bool = False 15 | maimaidxaliasproxy: bool = False 16 | saveinmem: Optional[bool] = True 17 | 18 | 19 | class MaimaiAPI: 20 | 21 | MaiProxyAPI = 'https://proxy.yuzuchan.xyz' 22 | 23 | MaiProberAPI = 'https://www.diving-fish.com/api/maimaidxprober' 24 | MaiCover = 'https://www.diving-fish.com/covers' 25 | MaiAliasAPI = 'https://www.yuzuchan.moe/api/maimaidx' 26 | QQAPI = 'http://q1.qlogo.cn/g' 27 | 28 | def __init__(self) -> None: 29 | """封装Api""" 30 | self.config: MaiConfig = self.load_config() 31 | self.headers = None 32 | self.token = None 33 | self.MaiProberProxyAPI = None 34 | self.MaiAliasProxyAPI = None 35 | 36 | def load_config(self) -> MaiConfig: 37 | return MaiConfig.model_validate(json.load(open(config_json, 'r', encoding='utf-8'))) 38 | 39 | def load_token_proxy(self) -> None: 40 | self.MaiProberProxyAPI = self.MaiProberAPI if not self.config.maimaidxproberproxy else self.MaiProxyAPI + '/maimaidxprober' 41 | self.MaiAliasProxyAPI = self.MaiAliasAPI if not self.config.maimaidxaliasproxy else self.MaiProxyAPI + '/maimaidxaliases' 42 | self.token = self.config.maimaidxtoken 43 | if self.token: 44 | self.headers = {'developer-token': self.token} 45 | 46 | 47 | async def _requestalias(self, method: str, endpoint: str, **kwargs) -> Any: 48 | """ 49 | 别名库通用请求 50 | 51 | Params: 52 | `method`: 请求方式 53 | `endpoint`: 请求接口 54 | `kwargs`: 其它参数 55 | Returns: 56 | `Dict[str, Any]` 返回结果 57 | """ 58 | async with ClientSession(timeout=ClientTimeout(total=30)) as session: 59 | async with session.request(method, self.MaiAliasProxyAPI + endpoint, **kwargs) as res: 60 | if res.status == 200: 61 | data = (await res.json())['content'] 62 | if data == {} or data == []: 63 | raise AliasesNotFoundError 64 | if isinstance(data, str): 65 | return data 66 | elif res.status == 201: 67 | data = await res.json() 68 | elif res.status == 400: 69 | raise EnterError 70 | elif res.status == 500: 71 | raise ServerError 72 | else: 73 | raise UnknownError 74 | return data 75 | 76 | async def _requestmai(self, method: str, endpoint: str, **kwargs) -> Union[Dict[str, Any], List[Dict[str, Any]]]: 77 | """ 78 | 查分器通用请求 79 | 80 | Params: 81 | `method`: 请求方式 82 | `endpoint`: 请求接口 83 | `kwargs`: 其它参数 84 | Returns: 85 | `Dict[str, Any]` 返回结果 86 | """ 87 | async with ClientSession(timeout=ClientTimeout(total=30)) as session: 88 | async with session.request(method, self.MaiProberProxyAPI + endpoint, headers=self.headers, **kwargs) as res: 89 | if res.status == 200: 90 | data = await res.json() 91 | elif res.status == 400: 92 | error: Dict = await res.json() 93 | if 'message' in error: 94 | if error['message'] == 'no such user': 95 | raise UserNotFoundError 96 | elif error['message'] == 'user not exists': 97 | raise UserNotExistsError 98 | else: 99 | raise UserNotFoundError 100 | elif 'msg' in error: 101 | if error['msg'] == '开发者token有误': 102 | raise TokenError 103 | elif error['msg'] == '开发者token被禁用': 104 | raise TokenDisableError 105 | else: 106 | raise TokenNotFoundError 107 | else: 108 | raise UserNotFoundError 109 | elif res.status == 403: 110 | raise UserDisabledQueryError 111 | else: 112 | raise UnknownError 113 | return data 114 | 115 | async def music_data(self): 116 | """获取曲目数据""" 117 | return await self._requestmai('GET', '/music_data') 118 | 119 | async def chart_stats(self): 120 | """获取单曲数据""" 121 | return await self._requestmai('GET', '/chart_stats') 122 | 123 | async def query_user_b50(self, *, qqid: Optional[int] = None, username: Optional[str] = None) -> UserInfo: 124 | """ 125 | 获取玩家B50 126 | 127 | Params: 128 | `qqid`: QQ号 129 | `username`: 用户名 130 | Returns: 131 | `UserInfo` b50数据模型 132 | """ 133 | json = {} 134 | if qqid: 135 | json['qq'] = qqid 136 | if username: 137 | json['username'] = username 138 | json['b50'] = True 139 | 140 | return UserInfo.model_validate(await self._requestmai('POST', '/query/player', json=json)) 141 | 142 | async def query_user_plate( 143 | self, 144 | *, 145 | qqid: Optional[int] = None, 146 | username: Optional[str] = None, 147 | version: Optional[List[str]] = None 148 | ) -> List[PlayInfoDefault]: 149 | """ 150 | 请求用户数据 151 | 152 | Params: 153 | `qqid`: 用户QQ 154 | `username`: 查分器用户名 155 | `version`: 版本 156 | Returns: 157 | `List[PlayInfoDefault]` 数据列表 158 | """ 159 | json = {} 160 | if qqid: 161 | json['qq'] = qqid 162 | if username: 163 | json['username'] = username 164 | if version: 165 | json['version'] = version 166 | result = await self._requestmai('POST', '/query/plate', json=json) 167 | if not result['verlist']: 168 | raise MusicNotPlayError 169 | 170 | return [PlayInfoDefault.model_validate(d) for d in result['verlist']] 171 | 172 | async def query_user_get_dev(self, *, qqid: Optional[int] = None, username: Optional[str] = None) -> UserInfoDev: 173 | """ 174 | 使用开发者接口获取用户数据,请确保拥有和输入了开发者 `token` 175 | 176 | Params: 177 | qqid: 用户QQ 178 | username: 查分器用户名 179 | Returns: 180 | `UserInfoDev` 开发者用户信息 181 | """ 182 | params = {} 183 | if qqid: 184 | params['qq'] = qqid 185 | if username: 186 | params['username'] = username 187 | 188 | result = await self._requestmai('GET', '/dev/player/records', params=params) 189 | return UserInfoDev.model_validate(result) 190 | 191 | async def query_user_post_dev( 192 | self, 193 | *, 194 | qqid: Optional[int] = None, 195 | username: Optional[str] = None, 196 | music_id: Union[str, int, List[Union[str, int]]] 197 | ) -> List[PlayInfoDev]: 198 | """ 199 | 使用开发者接口获取用户指定曲目数据,请确保拥有和输入了开发者 `token` 200 | 201 | Params: 202 | `qqid`: 用户QQ 203 | `username`: 查分器用户名 204 | `music_id`: 曲目id,可以为单个ID或者列表 205 | Returns: 206 | `List[PlayInfoDev]` 开发者成绩列表 207 | """ 208 | json = {} 209 | if qqid: 210 | json['qq'] = qqid 211 | if username: 212 | json['username'] = username 213 | json['music_id'] = music_id 214 | 215 | result = await self._requestmai('POST', '/dev/player/record', json=json) 216 | if result == {}: 217 | raise MusicNotPlayError 218 | 219 | if isinstance(music_id, list): 220 | return [PlayInfoDev.model_validate(d) for k, v in result.items() for d in v] 221 | return [PlayInfoDev.model_validate(d) for d in result[str(music_id)]] 222 | 223 | async def rating_ranking(self) -> List[UserRanking]: 224 | """ 225 | 获取查分器排行榜 226 | 227 | Returns: 228 | `List[UserRanking]` 按`ra`从高到低排序后的查分器排行模型列表 229 | """ 230 | result = await self._requestmai('GET', '/rating_ranking') 231 | return sorted([UserRanking.model_validate(u) for u in result], key=lambda x: x.ra, reverse=True) 232 | 233 | async def get_plate_json(self) -> Dict[str, List[int]]: 234 | """获取所有版本牌子完成需求""" 235 | return await self._requestalias('GET', '/maimaidxplate') 236 | 237 | async def get_alias(self) -> Dict[str, Union[str, int, List[str]]]: 238 | """获取所有别名""" 239 | return await self._requestalias('GET', '/maimaidxalias') 240 | 241 | async def get_songs(self, name: str) -> Union[List[AliasStatus], List[Alias]]: 242 | """ 243 | 使用别名查询曲目。 244 | 状态码为 `201` 时返回值为 `List[AliasStatus]`。 245 | 状态码为 `200` 时返回值为 `List[Alias]`。 246 | 247 | Params: 248 | `name`: 别名 249 | Returns: 250 | `Union[List[AliasStatus], List[Alias]]` 251 | """ 252 | result = await self._requestalias('GET', '/getsongs', params={'name': name}) 253 | if 'status_code' in result: 254 | r = [AliasStatus.model_validate(s) for s in result['content']] 255 | else: 256 | r = [Alias.model_validate(s) for s in result] 257 | return r 258 | 259 | async def get_songs_alias(self, song_id: int) -> Alias: 260 | """ 261 | 使用曲目 `id` 查询别名 262 | 263 | Params: 264 | `song_id`: 曲目 `ID` 265 | Returns: 266 | `Alias` 267 | """ 268 | result = await self._requestalias('GET', '/getsongsalias', params={'song_id': song_id}) 269 | return Alias.model_validate(result) 270 | 271 | async def get_alias_status(self) -> List[AliasStatus]: 272 | """获取当前正在进行的别名投票""" 273 | result = await self._requestalias('GET', '/getaliasstatus') 274 | return [AliasStatus.model_validate(s) for s in result] 275 | 276 | async def post_alias(self, song_id: int, aliasname: str, user_id: int) -> AliasStatus: 277 | """ 278 | 提交别名申请 279 | 280 | Params: 281 | `id`: 曲目 `id` 282 | `aliasname`: 别名 283 | `user_id`: 提交的用户 284 | Returns: 285 | `AliasStatus` 286 | """ 287 | json = { 288 | 'SongID': song_id, 289 | 'ApplyAlias': aliasname, 290 | 'ApplyUID': user_id 291 | } 292 | return AliasStatus.model_validate(await self._requestalias('POST', '/applyalias', json=json)) 293 | 294 | async def post_agree_user(self, tag: str, user_id: int) -> str: 295 | """ 296 | 提交同意投票 297 | 298 | Params: 299 | `tag`: 标签 300 | `user_id`: 同意投票的用户 301 | Returns: 302 | `str` 303 | """ 304 | json = { 305 | 'Tag': tag, 306 | 'AgreeUser': user_id 307 | } 308 | return await self._requestalias('POST', '/agreeuser', json=json) 309 | 310 | async def transfer_music(self): 311 | """中转查分器曲目数据""" 312 | return await self._requestalias('GET', '/maimaidxmusic') 313 | 314 | async def transfer_chart(self): 315 | """中转查分器单曲数据""" 316 | return await self._requestalias('GET', '/maimaidxchartstats') 317 | 318 | async def qqlogo(self, qqid: int = None, icon: str = None) -> Optional[bytes]: 319 | """获取QQ头像""" 320 | async with ClientSession(timeout=ClientTimeout(total=30)) as session: 321 | if qqid: 322 | params = { 323 | 'b': 'qq', 324 | 'nk': qqid, 325 | 's': 100 326 | } 327 | res = await session.request('GET', self.QQAPI, params=params) 328 | elif icon: 329 | res = await session.request('GET', icon) 330 | else: 331 | return None 332 | return await res.read() 333 | 334 | 335 | maiApi = MaimaiAPI() -------------------------------------------------------------------------------- /libraries/maimaidx_arcade.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | import traceback 4 | from typing import Dict, List, Optional, Union 5 | 6 | import aiohttp 7 | from pydantic import BaseModel 8 | 9 | from .. import arcades_json, loga 10 | from .maimaidx_music import writefile 11 | 12 | 13 | class Arcade(BaseModel): 14 | 15 | name: str 16 | location: str 17 | province: str 18 | mall: str 19 | num: int 20 | id: str 21 | alias: List[str] 22 | group: List[int] 23 | person: int 24 | by: str 25 | time: str 26 | 27 | 28 | class ArcadeList(List[Arcade]): 29 | 30 | 31 | async def save_arcade(self): 32 | return await writefile(arcades_json, [_.model_dump() for _ in self]) 33 | 34 | def search_name(self, name: str) -> List[Arcade]: 35 | """模糊查询机厅""" 36 | arcade_list = [] 37 | for arcade in self: 38 | if name in arcade.name: 39 | arcade_list.append(arcade) 40 | elif name in arcade.location: 41 | arcade_list.append(arcade) 42 | elif name in arcade.alias: 43 | arcade_list.append(arcade) 44 | 45 | return arcade_list 46 | 47 | def search_fullname(self, name: str) -> List[Arcade]: 48 | """查询店铺全名机厅""" 49 | arcade_list = [] 50 | for arcade in self: 51 | if name == arcade.name: 52 | arcade_list.append(arcade) 53 | 54 | return arcade_list 55 | 56 | def search_alias(self, alias: str) -> List[Arcade]: 57 | """查询别名机厅""" 58 | arcade_list = [] 59 | for arcade in self: 60 | if alias in arcade.alias: 61 | arcade_list.append(arcade) 62 | 63 | return arcade_list 64 | 65 | def search_id(self, id: str) -> List[Arcade]: 66 | """指定ID查询机厅""" 67 | arcade_list = [] 68 | for arcade in self: 69 | if id == arcade.id: 70 | arcade_list.append(arcade) 71 | 72 | return arcade_list 73 | 74 | def add_arcade(self, arcade: dict) -> bool: 75 | """添加机厅""" 76 | self.append(Arcade(**arcade)) 77 | return True 78 | 79 | def del_arcade(self, arcadeName: str) -> bool: 80 | """删除机厅""" 81 | for arcade in self: 82 | if arcadeName == arcade.name: 83 | self.remove(arcade) 84 | return True 85 | return False 86 | 87 | def group_in_arcade(self, group_id: int, arcadeName: str) -> bool: 88 | """是否已订阅该机厅""" 89 | for arcade in self: 90 | if arcadeName == arcade.name: 91 | if group_id in arcade.group: 92 | return True 93 | return False 94 | 95 | def group_subscribe_arcade(self, group_id: int) -> List[Arcade]: 96 | """已订阅机厅""" 97 | arcade_list = [] 98 | for arcade in self: 99 | if group_id in arcade.group: 100 | arcade_list.append(arcade) 101 | return arcade_list 102 | 103 | @classmethod 104 | def arcade_to_msg(cls, arcade_list: List[Arcade]) -> List[str]: 105 | """机厅人数格式化""" 106 | result = [] 107 | for arcade in arcade_list: 108 | msg = f'''{arcade.name} 109 | - 当前 {arcade.person} 人\n''' 110 | if arcade.num > 1: 111 | msg += f' - 平均 {arcade.person / arcade.num:.2f} 人\n' 112 | if arcade.by: 113 | msg += f' - 由 {arcade.by} 更新于 {arcade.time}' 114 | result.append(msg.strip()) 115 | return result 116 | 117 | 118 | class ArcadeData: 119 | 120 | total: Optional[ArcadeList] 121 | 122 | def __init__(self) -> None: 123 | self.arcades = [] 124 | if arcades_json.exists(): 125 | self.arcades: List[Dict] = json.load(open(arcades_json, 'r', encoding='utf-8')) 126 | self.idList = [] 127 | 128 | def get_by_id(self, id: int) -> Optional[Dict]: 129 | id_list = [c_a['id'] for c_a in self.arcades] 130 | if id in id_list: 131 | return self.arcades[id_list.index(id)] 132 | else: 133 | return None 134 | 135 | async def getArcade(self): 136 | self.total = await download_arcade_info() 137 | self.idList = [int(c_a.id) for c_a in self.total] 138 | 139 | arcade = ArcadeData() 140 | 141 | 142 | async def download_arcade_info(save: bool = True) -> ArcadeList: 143 | try: 144 | async with aiohttp.request('GET', 'http://wc.wahlap.net/maidx/rest/location', timeout=aiohttp.ClientTimeout(total=30)) as req: 145 | if req.status == 200: 146 | data = await req.json() 147 | else: 148 | data = None 149 | loga.error('获取机厅信息失败') 150 | arcadelist = ArcadeList() 151 | if data is not None: 152 | if not arcade.arcades: 153 | for num in range(len(data)): 154 | _arc = data[num] 155 | arcade_dict = { 156 | 'name': _arc['arcadeName'], 157 | 'location': _arc['address'], 158 | 'province': _arc['province'], 159 | 'mall': _arc['mall'], 160 | 'num': _arc['machineCount'], 161 | 'id': _arc['id'], 162 | 'alias': [], 163 | 'group': [], 164 | 'person': 0, 165 | 'by': '', 166 | 'time': '' 167 | } 168 | arcadelist.append(Arcade.model_validate(arcade_dict)) 169 | else: 170 | for num in range(len(data)): 171 | _arc = data[num] 172 | arcade_dict = arcade.get_by_id(_arc['id']) 173 | if arcade_dict is not None: 174 | arcade_dict['name'] = _arc['arcadeName'] 175 | arcade_dict['location'] = _arc['address'] 176 | arcade_dict['province'] = _arc['province'] 177 | arcade_dict['mall'] = _arc['mall'] 178 | arcade_dict['num'] = _arc['machineCount'] 179 | arcade_dict['id'] = _arc['id'] 180 | else: 181 | arcade_dict = { 182 | 'name': _arc['arcadeName'], 183 | 'location': _arc['address'], 184 | 'province': _arc['province'], 185 | 'mall': _arc['mall'], 186 | 'num': _arc['machineCount'], 187 | 'id': _arc['id'], 188 | 'alias': [], 189 | 'group': [], 190 | 'person': 0, 191 | 'by': '', 192 | 'time': '' 193 | } 194 | arcade.arcades.insert(num, arcade_dict) 195 | arcadelist.append(Arcade.model_validate(arcade_dict)) 196 | for n in arcade.arcades: 197 | if int(n['id']) >= 10000: 198 | arcadelist.append(Arcade.model_validate(n)) 199 | else: 200 | for _a in arcade.arcades: 201 | arcadelist.append(Arcade.model_validate(_a)) 202 | if save: 203 | await writefile(arcades_json, [_.model_dump() for _ in arcadelist]) 204 | return arcadelist 205 | except Exception: 206 | loga.error(f'Error: {traceback.format_exc()}') 207 | loga.error('获取机厅信息失败') 208 | 209 | 210 | async def updata_arcade(arcadeName: str, num: str): 211 | if arcadeName.isdigit(): 212 | arcade_list = arcade.total.search_id(arcadeName) 213 | else: 214 | arcade_list = arcade.total.search_fullname(arcadeName) 215 | if arcade_list: 216 | _arcade = arcade_list[0] 217 | _arcade.num = int(num) 218 | msg = f'已修改机厅 [{arcadeName}] 机台数量为 [{num}]' 219 | await arcade.total.save_arcade() 220 | else: 221 | msg = f'未找到机厅:{arcadeName}' 222 | return msg 223 | 224 | 225 | async def update_alias(arcadeName: str, aliasName: str, add_del: bool): 226 | """变更机厅别名""" 227 | change = False 228 | if arcadeName.isdigit(): 229 | arcade_list = arcade.total.search_id(arcadeName) 230 | else: 231 | arcade_list = arcade.total.search_fullname(arcadeName) 232 | if arcade_list: 233 | _arcade = arcade_list[0] 234 | if add_del: 235 | if aliasName not in _arcade.alias: 236 | _arcade.alias.append(aliasName) 237 | msg = f'机厅:{_arcade.name}\n已添加别名:{aliasName}' 238 | change = True 239 | else: 240 | msg = f'机厅:{_arcade.name}\n已拥有别名:{aliasName}\n请勿重复添加' 241 | else: 242 | if aliasName in _arcade.alias: 243 | _arcade.alias.remove(aliasName) 244 | msg = f'机厅:{_arcade.name}\n已删除别名:{aliasName}' 245 | change = True 246 | else: 247 | msg = f'机厅:{_arcade.name}\n未拥有别名:{aliasName}' 248 | else: 249 | msg = f'未找到机厅:{arcadeName}' 250 | if change: 251 | await arcade.total.save_arcade() 252 | return msg 253 | 254 | 255 | async def subscribe(group_id: int, arcadeName: str, sub: bool): 256 | """订阅机厅,`sub` 等于 `True` 为订阅,`False` 为取消订阅""" 257 | change = False 258 | if arcadeName.isdigit(): 259 | arcade_list = arcade.total.search_id(arcadeName) 260 | else: 261 | arcade_list = arcade.total.search_fullname(arcadeName) 262 | if arcade_list: 263 | _arcade = arcade_list[0] 264 | if sub: 265 | if arcade.total.group_in_arcade(group_id, _arcade.name): 266 | msg = f'该群已订阅机厅:{_arcade.name}' 267 | else: 268 | _arcade.group.append(group_id) 269 | msg = f'群:{group_id} 已添加订阅机厅:{_arcade.name}' 270 | change = True 271 | else: 272 | if not arcade.total.group_in_arcade(group_id, _arcade.name): 273 | msg = f'该群未订阅机厅:{_arcade.name},无需取消订阅' 274 | else: 275 | _arcade.group.remove(group_id) 276 | msg = f'群:{group_id} 已取消订阅机厅:{_arcade.name}' 277 | change = True 278 | else: 279 | msg = f'未找到机厅:{arcadeName}' 280 | if change: 281 | await arcade.total.save_arcade() 282 | return msg 283 | 284 | 285 | async def update_person(arcadeList: List[Arcade], userName: str, value: str, person: int): 286 | """变更机厅人数""" 287 | if len(arcadeList) == 1: 288 | _arcade = arcadeList[0] 289 | original_person = _arcade.person 290 | if value in ['+', '+', '增加', '添加', '加']: 291 | if person > 30: 292 | return '请勿乱玩bot,恼!' 293 | _arcade.person += person 294 | elif value in ['-', '-', '减少', '降低', '减']: 295 | if person > 30 or person > _arcade.person: 296 | return '请勿乱玩bot,恼!' 297 | _arcade.person -= person 298 | elif value in ['=', '=', '设置', '设定']: 299 | if abs(_arcade.person - person) > 30: 300 | return '请勿乱玩bot,恼!' 301 | _arcade.person = person 302 | if _arcade.person == original_person: 303 | return f'人数没有变化\n机厅:{_arcade.name}\n当前人数:{_arcade.person}' 304 | else: 305 | _arcade.by = userName 306 | _arcade.time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) 307 | await arcade.total.save_arcade() 308 | return f'机厅:{_arcade.name}\n当前人数:{_arcade.person}\n变更时间:{_arcade.time}' 309 | elif len(arcadeList) > 1: 310 | return '找到多个机厅,请使用id变更人数\n' + '\n'.join([f'{_.id}:{_.name}' for _ in arcadeList]) 311 | else: 312 | return '没有找到指定机厅' 313 | -------------------------------------------------------------------------------- /libraries/maimaidx_error.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | 3 | 4 | class UserNotFoundError(Exception): 5 | 6 | def __str__(self) -> str: 7 | return dedent(''' 8 | 未找到此玩家,请确保此玩家的用户名和查分器中的用户名相同。 9 | 如未绑定,请前往查分器官网进行绑定 10 | https://www.diving-fish.com/maimaidx/prober/ 11 | ''').strip() 12 | 13 | 14 | class UserNotExistsError(Exception): 15 | 16 | def __str__(self) -> str: 17 | return '查询的用户不存在' 18 | 19 | 20 | class UserDisabledQueryError(Exception): 21 | 22 | def __str__(self) -> str: 23 | return '该用户禁止了其他人获取数据或未同意用户协议。' 24 | 25 | 26 | class TokenError(Exception): 27 | 28 | def __str__(self) -> str: 29 | return '开发者Token有误' 30 | 31 | 32 | class TokenDisableError(Exception): 33 | 34 | def __str__(self) -> str: 35 | return '开发者Token被禁用' 36 | 37 | 38 | class TokenNotFoundError(Exception): 39 | 40 | def __str__(self) -> str: 41 | return '请先联系水鱼申请开发者token' 42 | 43 | 44 | class MusicNotPlayError(Exception): 45 | 46 | def __str__(self) -> str: 47 | return '您未游玩该曲目' 48 | 49 | 50 | class ServerError(Exception): 51 | 52 | def __str__(self) -> str: 53 | return '别名服务器错误,请联系插件开发者' 54 | 55 | 56 | class EnterError(Exception): 57 | 58 | def __str__(self) -> str: 59 | return '参数输入错误' 60 | 61 | 62 | class AliasesNotFoundError(Exception): 63 | 64 | def __str__(self) -> str: 65 | return '未找到别名' 66 | 67 | 68 | class UnknownError(Exception): 69 | """未知错误""" -------------------------------------------------------------------------------- /libraries/maimaidx_model.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | from typing import List, Optional, Union 3 | 4 | from pydantic import BaseModel, Field 5 | 6 | 7 | ##### Music 8 | class Stats(BaseModel): 9 | 10 | cnt: Optional[float] = None 11 | diff: Optional[str] = None 12 | fit_diff: Optional[float] = None 13 | avg: Optional[float] = None 14 | avg_dx: Optional[float] = None 15 | std_dev: Optional[float] = None 16 | dist: Optional[List[int]] = None 17 | fc_dist: Optional[List[float]] = None 18 | 19 | 20 | Notes1 = namedtuple('Notes', ['tap', 'hold', 'slide', 'brk']) 21 | Notes2 = namedtuple('Notes', ['tap', 'hold', 'slide', 'touch', 'brk']) 22 | 23 | 24 | class Chart(BaseModel): 25 | 26 | notes: Union[Notes1, Notes2] 27 | charter: str = None 28 | 29 | 30 | class BasicInfo(BaseModel): 31 | 32 | title: str 33 | artist: str 34 | genre: str 35 | bpm: int 36 | release_date: Optional[str] = '' 37 | version: str = Field(alias='from') 38 | is_new: bool 39 | 40 | 41 | class Music(BaseModel): 42 | 43 | id: str 44 | title: str 45 | type: str 46 | ds: List[float] 47 | level: List[str] 48 | cids: List[int] 49 | charts: List[Chart] 50 | basic_info: BasicInfo 51 | stats: Optional[List[Optional[Stats]]] = [] 52 | diff: Optional[List[int]] = [] 53 | 54 | 55 | class RaMusic(BaseModel): 56 | 57 | id: str 58 | ds: float 59 | lv: str 60 | lvp: str 61 | type: str 62 | 63 | 64 | ##### Aliases 65 | class Alias(BaseModel): 66 | 67 | SongID: int 68 | Name: str 69 | Alias: List[str] 70 | 71 | 72 | class AliasStatus(BaseModel): 73 | 74 | Tag: str 75 | SongID: int 76 | ApplyAlias: str 77 | IsNew: bool 78 | IsEnd: bool 79 | Time: str 80 | AgreeVotes: int 81 | Votes: int 82 | 83 | 84 | ##### Guess 85 | class GuessData(BaseModel): 86 | 87 | music: Music 88 | img: str 89 | answer: List[str] 90 | end: bool = False 91 | 92 | 93 | class GuessDefaultData(GuessData): 94 | 95 | options: List[str] 96 | 97 | 98 | class GuessPicData(GuessData): ... 99 | 100 | 101 | class Switch(BaseModel): 102 | 103 | enable: List[int] = [] 104 | disable: List[int] = [] 105 | 106 | 107 | class GuessSwitch(Switch): ... 108 | 109 | 110 | ##### AliasesPush 111 | class AliasesPush(Switch): 112 | 113 | global_switch: bool = Field(True, alias='global') 114 | 115 | 116 | ##### Best50 117 | class PlayInfo(BaseModel): 118 | 119 | achievements: float 120 | fc: str = '' 121 | fs: str = '' 122 | level: str 123 | level_index: int 124 | title: str 125 | type: str 126 | ds: float = 0 127 | dxScore: int = 0 128 | ra: int = 0 129 | rate: str = '' 130 | 131 | 132 | class ChartInfo(PlayInfo): 133 | 134 | level_label: str 135 | song_id: int 136 | 137 | 138 | class Data(BaseModel): 139 | 140 | sd: Optional[List[ChartInfo]] = None 141 | dx: Optional[List[ChartInfo]] = None 142 | 143 | 144 | class _UserInfo(BaseModel): 145 | 146 | additional_rating: Optional[int] 147 | nickname: Optional[str] 148 | plate: Optional[str] = None 149 | rating: Optional[int] 150 | username: Optional[str] 151 | 152 | 153 | class UserInfo(_UserInfo): 154 | 155 | charts: Optional[Data] 156 | 157 | class PlayInfoDefault(PlayInfo): 158 | 159 | song_id: int = Field(alias='id') 160 | table_level: List[int] = [] 161 | 162 | 163 | class PlayInfoDev(ChartInfo): ... 164 | 165 | 166 | class TableData(BaseModel): 167 | 168 | achievements: float 169 | fc: str = '' 170 | 171 | 172 | class PlanInfo(BaseModel): 173 | 174 | completed: Union[PlayInfoDefault, PlayInfoDev] = None 175 | unfinished: Union[PlayInfoDefault, PlayInfoDev] = None 176 | 177 | 178 | class RiseScore(BaseModel): 179 | 180 | song_id: int 181 | title: str 182 | type: str 183 | level_index: int 184 | ds: float 185 | ra: int 186 | rate: str 187 | achievements: float 188 | oldra: Optional[int] = 0 189 | oldrate: Optional[str] = 'D' 190 | oldachievements: Optional[float] = 0 191 | 192 | 193 | ##### Dev 194 | class UserInfoDev(_UserInfo): 195 | 196 | records: Optional[List[PlayInfoDev]] = None 197 | 198 | 199 | ##### Rank 200 | class UserRanking(BaseModel): 201 | 202 | username: str 203 | ra: int -------------------------------------------------------------------------------- /libraries/maimaidx_music.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import random 4 | import traceback 5 | from collections import Counter 6 | from copy import deepcopy 7 | from typing import Tuple 8 | 9 | import numpy as np 10 | from PIL import Image 11 | 12 | from .. import * 13 | from .image import image_to_base64, music_picture 14 | from .maimaidx_api_data import maiApi 15 | from .maimaidx_error import * 16 | from .maimaidx_model import * 17 | from .tool import openfile, writefile 18 | 19 | 20 | def cross( 21 | checker: Union[List[str], List[float]], 22 | elem: Optional[Union[str, float, List[str], List[float], Tuple[float, float]]], 23 | diff: List[int] 24 | ) -> Tuple[bool, List[int]]: 25 | ret = False 26 | diff_ret = [] 27 | if not elem or elem is Ellipsis: 28 | return True, diff 29 | if isinstance(elem, List): 30 | for _j in (range(len(checker)) if diff is Ellipsis else diff): 31 | if _j >= len(checker): 32 | continue 33 | __e = checker[_j] 34 | if __e in elem: 35 | diff_ret.append(_j) 36 | ret = True 37 | elif isinstance(elem, Tuple): 38 | for _j in (range(len(checker)) if diff is Ellipsis else diff): 39 | if _j >= len(checker): 40 | continue 41 | __e = checker[_j] 42 | if elem[0] <= __e <= elem[1]: 43 | diff_ret.append(_j) 44 | ret = True 45 | else: 46 | for _j in (range(len(checker)) if diff is Ellipsis else diff): 47 | if _j >= len(checker): 48 | continue 49 | __e = checker[_j] 50 | if elem == __e: 51 | diff_ret.append(_j) 52 | ret = True 53 | return ret, diff_ret 54 | 55 | 56 | def in_or_equal( 57 | checker: Union[str, int], 58 | elem: Optional[Union[str, float, List[str], List[float], Tuple[float, float]]] 59 | ) -> bool: 60 | if elem is Ellipsis: 61 | return True 62 | if isinstance(elem, List): 63 | return checker in elem 64 | elif isinstance(elem, Tuple): 65 | return elem[0] <= checker <= elem[1] 66 | else: 67 | return checker == elem 68 | 69 | 70 | class MusicList(List[Music]): 71 | 72 | def by_id(self, music_id: Union[str, int]) -> Optional[Music]: 73 | for music in self: 74 | if music.id == str(music_id): 75 | return music 76 | return None 77 | 78 | def by_title(self, music_title: str) -> Optional[Music]: 79 | for music in self: 80 | if music.title == music_title: 81 | return music 82 | return None 83 | 84 | def by_plan(self, level: str) -> Dict[str, Union[PlanInfo, RaMusic, Dict[int, Union[PlanInfo, RaMusic]]]]: 85 | lv = {} 86 | for music in self: 87 | if level not in music.level: 88 | continue 89 | count = Counter(music.level) 90 | if count.get(level) > 1: # 同曲有相同等级 91 | lv[music.id] = { 92 | index: RaMusic( 93 | id=music.id, 94 | ds=music.ds[index], 95 | lv=str(index), 96 | lvp=music.level[index], 97 | type=music.type 98 | ) 99 | for index, _lv in enumerate(music.level) 100 | if _lv == level 101 | } 102 | else: 103 | index = music.level.index(level) 104 | lv[music.id] = RaMusic( 105 | id=music.id, 106 | ds=music.ds[index], 107 | lv=str(index), 108 | lvp=music.level[index], 109 | type=music.type 110 | ) 111 | return lv 112 | 113 | def by_level_list(self) -> Dict[str, Dict[str, List[RaMusic]]]: 114 | levellist = None 115 | _level: Dict[str, Dict[str, List[RaMusic]]] = {} 116 | for lv in levelList[5:]: 117 | if lv == '15': 118 | r = range(1) 119 | elif lv in levelList[:6]: 120 | r = range(9, -1, -1) 121 | elif '+' in lv: 122 | r = range(9, 6, -1) 123 | else: 124 | r = range(6, -1, -1) 125 | levellist = {f'{lv if "+" not in lv else lv[:-1]}.{_}': [] for _ in r} 126 | _level[lv] = levellist 127 | for music in self: 128 | if int(music.id) >= 100000: 129 | continue 130 | for index, ds in enumerate(music.ds): 131 | if ds < 6: continue 132 | ra = RaMusic( 133 | id=music.id, 134 | ds=ds, 135 | lv=str(index), 136 | lvp=music.level[index], 137 | type=music.type 138 | ) 139 | _level[music.level[index]][str(ds)].append(ra) 140 | return _level 141 | 142 | def by_id_list(self, music_id_list: List[int]) -> Optional[List[Music]]: 143 | musicList = [] 144 | for music in self: 145 | if int(music.id) in music_id_list: 146 | musicList.append(music) 147 | return musicList 148 | 149 | def random(self) -> Music: 150 | return random.choice(self) 151 | 152 | def filter( 153 | self, 154 | *, 155 | level: Optional[Union[str, List[str]]] = ..., 156 | ds: Optional[Union[float, List[float], Tuple[float, float]]] = ..., 157 | title_search: Optional[str] = ..., 158 | artist_search: Optional[str] = ..., 159 | charter_search: Optional[str] = ..., 160 | genre: Optional[Union[str, List[str]]] = ..., 161 | bpm: Optional[Union[float, List[float], Tuple[float, float]]] = ..., 162 | type: Optional[Union[str, List[str]]] = ..., 163 | diff: List[int] = ..., 164 | version: Union[str, List[str]] = ... 165 | ) -> 'MusicList': 166 | new_list = MusicList() 167 | for music in self: 168 | diff2 = diff 169 | music = deepcopy(music) 170 | ret, diff2 = cross(music.level, level, diff2) 171 | if not ret: 172 | continue 173 | ret, diff2 = cross(music.ds, ds, diff2) 174 | if not ret: 175 | continue 176 | ret, diff2 = search_charts(music.charts, charter_search, diff2) 177 | if not ret: 178 | continue 179 | if not in_or_equal(music.basic_info.genre, genre): 180 | continue 181 | if not in_or_equal(music.type, type): 182 | continue 183 | if not in_or_equal(music.basic_info.bpm, bpm): 184 | continue 185 | if not in_or_equal(music.basic_info.version, version): 186 | continue 187 | if title_search is not Ellipsis and title_search.lower() not in music.title.lower(): 188 | continue 189 | if artist_search is not Ellipsis and artist_search.lower() not in music.basic_info.artist.lower(): 190 | continue 191 | music.diff = diff2 192 | new_list.append(music) 193 | return new_list 194 | 195 | 196 | def search_charts(checker: List[Chart], elem: str, diff: List[int]) -> Tuple[bool, List[int]]: 197 | ret = False 198 | diff_ret = [] 199 | if not elem or elem is Ellipsis: 200 | return True, diff 201 | for _j in (range(len(checker)) if diff is Ellipsis else diff): 202 | if elem.lower() in checker[_j].charter.lower(): 203 | diff_ret.append(_j) 204 | ret = True 205 | return ret, diff_ret 206 | 207 | 208 | class AliasList(List[Alias]): 209 | 210 | def by_id(self, music_id: Union[str, int]) -> Optional[List[Alias]]: 211 | alias_music = [] 212 | for music in self: 213 | if music.SongID == int(music_id): 214 | alias_music.append(music) 215 | return alias_music 216 | 217 | def by_alias(self, music_alias: str) -> Optional[List[Alias]]: 218 | alias_list = [] 219 | for music in self: 220 | if music_alias in music.Alias: 221 | alias_list.append(music) 222 | return alias_list 223 | 224 | 225 | dataerror = dedent(f''' 226 | 未找到文件,请自行使用浏览器访问 "https://www.diving-fish.com/api/maimaidxprober/music_data" 227 | 将内容保存为 "music_data.json" 存放在 "static" 目录下并重启bot 228 | ''').strip() 229 | charterror = dedent(f''' 230 | 未找到文件,请自行使用浏览器访问 "https://www.diving-fish.com/api/maimaidxprober/chart_stats" 231 | 将内容保存为 "music_chart.json" 存放在 "static" 目录下并重启bot 232 | ''').strip() 233 | aliaserror = dedent(''' 234 | 本地暂存别名文件为空,请自行使用浏览器访问 "https://www.yuzuchan.moe/api/maimaidx/maimaidxalias" 235 | 获取别名数据并保存在 "static/music_alias.json" 文件中并重启bot 236 | ''').strip() 237 | 238 | 239 | async def get_music_list() -> MusicList: 240 | """获取所有数据""" 241 | # MusicData 242 | try: 243 | try: 244 | music_data = await maiApi.music_data() 245 | await writefile(music_file, music_data) 246 | except asyncio.exceptions.TimeoutError: 247 | log.error('从diving-fish获取maimaiDX曲库数据超时,正在使用yuzuapi中转获取曲库数据') 248 | try: 249 | music_data = await maiApi.transfer_music() 250 | await writefile(music_file, music_data) 251 | except ServerError: 252 | log.error('从yuzuapi获取maimaiDX曲库数据失败,请检查网络环境。已切换至本地暂存文件') 253 | music_data = await openfile(music_file) 254 | except Exception: 255 | log.error(f'Error: {traceback.format_exc()}') 256 | log.error('maimaiDX曲库数据获取失败,请检查网络环境。已切换至本地暂存文件') 257 | music_data = await openfile(music_file) 258 | except FileNotFoundError: 259 | log.error(dataerror) 260 | raise FileNotFoundError 261 | 262 | # ChartStats 263 | try: 264 | try: 265 | chart_stats = await maiApi.chart_stats() 266 | await writefile(chart_file, chart_stats) 267 | except asyncio.exceptions.TimeoutError: 268 | log.error('从diving-fish获取maimaiDX数据获取超时,正在使用yuzuapi中转获取单曲数据') 269 | try: 270 | chart_stats = await maiApi.transfer_chart() 271 | await writefile(chart_file, chart_stats) 272 | except ServerError: 273 | log.error('从yuzuapi获取maimaiDX单曲数据获取错误,已切换至本地暂存文件') 274 | chart_stats = await openfile(chart_file) 275 | except Exception: 276 | log.error(f'Error: {traceback.format_exc()}') 277 | log.error('maimaiDX数据获取错误,请检查网络环境,已切换至本地暂存文件') 278 | chart_stats = await openfile(chart_file) 279 | except FileNotFoundError: 280 | log.error(charterror) 281 | raise FileNotFoundError 282 | 283 | total_list = MusicList() 284 | for music in music_data: 285 | if music['id'] in chart_stats['charts']: 286 | _stats = [ 287 | _data if _data else None 288 | for _data in chart_stats['charts'][music['id']] 289 | ] if {} in chart_stats['charts'][music['id']] else \ 290 | chart_stats['charts'][music['id']] 291 | else: 292 | _stats = None 293 | for lv in ['5+', '6+']: 294 | if lv in music['level']: 295 | errorlv = music['level'].index(lv) 296 | music['level'][errorlv] = lv[:-1] 297 | total_list.append(Music(stats=_stats, **music)) 298 | 299 | return total_list 300 | 301 | 302 | async def get_music_alias_list() -> AliasList: 303 | """获取所有别名""" 304 | if local_alias_file.exists(): 305 | local_alias_data = await openfile(local_alias_file) 306 | else: 307 | local_alias_data = {} 308 | alias_data: List[Dict[str, Union[int, str, List[str]]]] = [] 309 | try: 310 | alias_data = await maiApi.get_alias() 311 | await writefile(alias_file, alias_data) 312 | except asyncio.exceptions.TimeoutError: 313 | log.error('获取别名超时。已切换至本地暂存文件') 314 | alias_data = await openfile(alias_file) 315 | if not alias_data: 316 | log.error(aliaserror) 317 | raise ValueError 318 | except ServerError as e: 319 | log.error(e) 320 | alias_data = await openfile(alias_file) 321 | except UnknownError: 322 | log.error('获取所有曲目别名信息错误,请检查网络环境。已切换至本地暂存文件') 323 | alias_data = await openfile(alias_file) 324 | if not alias_data: 325 | log.error(aliaserror) 326 | raise ValueError 327 | 328 | total_alias_list = AliasList() 329 | for _a in filter(lambda x: mai.total_list.by_id(x['SongID']), alias_data): 330 | if (song_id := str(_a['SongID'])) in local_alias_data: 331 | _a['Alias'].extend(local_alias_data[song_id]) 332 | total_alias_list.append(Alias(**_a)) 333 | 334 | return total_alias_list 335 | 336 | 337 | async def update_local_alias(id: str, alias_name: str) -> bool: 338 | try: 339 | if local_alias_file.exists(): 340 | local_alias_data: Dict[str, List[str]] = await openfile(local_alias_file) 341 | else: 342 | local_alias_data: Dict[str, List[str]] = {} 343 | if id not in local_alias_data: 344 | local_alias_data[id] = [] 345 | 346 | local_alias_data[id].append(alias_name.lower()) 347 | mai.total_alias_list.by_id(id)[0].Alias.append(alias_name.lower()) 348 | await writefile(local_alias_file, local_alias_data) 349 | return True 350 | except Exception as e: 351 | log.error(f'添加本地别名失败: {e}') 352 | return False 353 | 354 | 355 | class MaiMusic: 356 | 357 | total_list: MusicList 358 | """曲目数据""" 359 | total_alias_list: AliasList 360 | """别名数据""" 361 | total_plate_id_list: Dict[str, List[int]] 362 | """牌子ID列表数据""" 363 | total_level_data: Dict[str, Dict[str, List[RaMusic]]] 364 | """等级列表数据""" 365 | hot_music_ids: List = [] 366 | """游玩次数超过1w次的曲目数据""" 367 | guess_data: List[Music] 368 | """猜歌数据""" 369 | 370 | def __init__(self) -> None: 371 | """封装所有曲目信息以及猜歌数据,便于更新""" 372 | 373 | async def get_music(self) -> None: 374 | """获取所有曲目数据""" 375 | self.total_list = await get_music_list() 376 | self.total_level_data = self.total_list.by_level_list() 377 | 378 | async def get_music_alias(self) -> None: 379 | """获取所有曲目别名""" 380 | self.total_alias_list = await get_music_alias_list() 381 | 382 | async def get_plate_json(self) -> None: 383 | """获取所有牌子数据""" 384 | self.total_plate_id_list = await maiApi.get_plate_json() 385 | 386 | def guess(self): 387 | """初始化猜歌数据""" 388 | for music in self.total_list: 389 | if music.stats: 390 | count = 0 391 | for stats in music.stats: 392 | if stats: 393 | count += stats.cnt if stats.cnt else 0 394 | if count > 10000: 395 | self.hot_music_ids.append(music.id) 396 | self.guess_data = list(filter(lambda x: x.id in self.hot_music_ids, self.total_list)) 397 | 398 | 399 | mai = MaiMusic() 400 | 401 | 402 | class Guess: 403 | 404 | Group: Dict[int, Union[GuessDefaultData, GuessPicData]] = {} 405 | switch: GuessSwitch 406 | 407 | def __init__(self) -> None: 408 | """猜歌类""" 409 | if not guess_file.exists(): 410 | self.switch = GuessSwitch() 411 | else: 412 | self.switch = GuessSwitch.model_validate( 413 | json.load(open(guess_file, 'r', encoding='utf-8')) 414 | ) 415 | 416 | def start(self, gid: int): 417 | """开始猜歌""" 418 | self.Group[gid] = self.guessData() 419 | 420 | def startpic(self, gid: int): 421 | """开始猜曲绘""" 422 | self.Group[gid] = self.guesspicdata() 423 | 424 | def calculate_frequency_weights(self, image: Image.Image) -> np.ndarray: 425 | """ 426 | 计算图像的频率权重,用于在图像中选择裁剪区域 427 | 428 | Params: 429 | `image`: PIL.Image.Image, 输入图像 430 | Returns: 431 | `np.ndarray` 频率权重矩阵 432 | """ 433 | gray_image = np.array(image.convert('L')) 434 | freq = np.fft.fft2(gray_image) 435 | freq_shift = np.fft.fftshift(freq) 436 | magnitude = np.abs(freq_shift) 437 | normalized_magnitude = magnitude / magnitude.max() 438 | weights = normalized_magnitude ** 2 439 | return weights 440 | 441 | def select_crop_region( 442 | self, 443 | weights: np.ndarray, 444 | crop_width: int, 445 | crop_height: int, 446 | top_p: int 447 | ) -> Tuple[int, int]: 448 | h, w = weights.shape 449 | valid_regions = weights[:h - crop_height + 1, :w - crop_width + 1] 450 | flattened_weights = valid_regions.flatten() 451 | threshold = np.percentile(flattened_weights, top_p) 452 | valid_indices = np.where(flattened_weights >= threshold)[0] 453 | probabilities = flattened_weights[valid_indices] 454 | probabilities /= probabilities.sum() 455 | chosen_index = np.random.choice(valid_indices, p=probabilities) 456 | top_left_y = chosen_index // valid_regions.shape[1] 457 | top_left_x = chosen_index % valid_regions.shape[1] 458 | return top_left_x, top_left_y 459 | 460 | def pic(self, music: Music) -> Image.Image: 461 | """裁切曲绘""" 462 | im = Image.open(music_picture(music.id)) 463 | w, h = im.size 464 | weights = self.calculate_frequency_weights(im) 465 | scale = random.uniform(0.15, 0.4) # 裁剪尺寸范围 可在此修改 466 | w2, h2 = int(w * scale), int(h * scale) 467 | top_p = min(1.3 - np.power(scale, 0.4), 0.95) * 100 468 | x, y = self.select_crop_region(weights, w2, h2, top_p) 469 | im = im.crop((x, y, x + w2, y + h2)) 470 | return im 471 | 472 | def guesspicdata(self) -> GuessPicData: 473 | """猜曲绘数据""" 474 | music = random.choice(mai.guess_data) 475 | pic = self.pic(music) 476 | answer = mai.total_alias_list.by_id(music.id)[0].Alias 477 | answer.append(music.id) 478 | return GuessPicData(music=music, img=image_to_base64(pic), answer=answer, end=False) 479 | 480 | def guessData(self) -> GuessDefaultData: 481 | """猜歌数据""" 482 | music = random.choice(mai.guess_data) 483 | guess_options = random.sample([ 484 | f'的 Expert 难度是 {music.level[2]}', 485 | f'的 Master 难度是 {music.level[3]}', 486 | f'的分类是 {music.basic_info.genre}', 487 | f'的版本是 {music.basic_info.version}', 488 | f'的艺术家是 {music.basic_info.artist}', 489 | f'{"不" if music.type == "SD" else ""}是 DX 谱面', 490 | f'{"没" if len(music.ds) == 4 else ""}有白谱', 491 | f'的 BPM 是 {music.basic_info.bpm}' 492 | ], 6) 493 | answer = mai.total_alias_list.by_id(music.id)[0].Alias 494 | answer.append(music.id) 495 | pic = self.pic(music) 496 | return GuessDefaultData( 497 | music=music, 498 | img=image_to_base64(pic), 499 | answer=answer, 500 | end=False, 501 | options=guess_options 502 | ) 503 | 504 | def end(self, gid: str): 505 | """结束猜歌""" 506 | del self.Group[gid] 507 | 508 | async def on(self, gid: str) -> str: 509 | """开启猜歌""" 510 | if gid not in self.switch.enable: 511 | self.switch.enable.append(gid) 512 | if gid in self.switch.disable: 513 | self.switch.disable.remove(gid) 514 | await writefile(guess_file, self.switch.model_dump()) 515 | return '群猜歌功能已开启' 516 | 517 | async def off(self, gid: str) -> str: 518 | """关闭猜歌""" 519 | if gid not in self.switch.disable: 520 | self.switch.disable.append(gid) 521 | if gid in self.switch.enable: 522 | self.switch.enable.remove(gid) 523 | if gid in self.Group: 524 | self.end(gid) 525 | await writefile(guess_file, self.switch.model_dump()) 526 | return '群猜歌功能已关闭' 527 | 528 | 529 | guess = Guess() 530 | 531 | 532 | class GroupAlias: 533 | 534 | push: AliasesPush 535 | 536 | def __init__(self) -> None: 537 | """别名推送类""" 538 | if not group_alias_file.exists(): 539 | self.push = AliasesPush() 540 | else: 541 | self.push = AliasesPush.model_validate( 542 | json.load(open(group_alias_file, 'r', encoding='utf-8')) 543 | ) 544 | 545 | async def on(self, gid: int) -> str: 546 | """开启推送""" 547 | if gid not in self.push.enable: 548 | self.push.enable.append(gid) 549 | if gid in self.push.disable: 550 | self.push.disable.remove(gid) 551 | await writefile(group_alias_file, self.push.model_dump(by_alias=True)) 552 | return '群别名推送功能已开启' 553 | 554 | async def off(self, gid: int) -> str: 555 | """关闭推送""" 556 | if gid not in self.push.disable: 557 | self.push.disable.append(gid) 558 | if gid in self.push.enable: 559 | self.push.enable.remove(gid) 560 | await writefile(group_alias_file, self.push.model_dump(by_alias=True)) 561 | return '群别名推送功能已关闭' 562 | 563 | async def alias_global_change(self, set: bool): 564 | """修改全局开关""" 565 | self.push.global_switch = set 566 | await writefile(group_alias_file, self.push.model_dump(by_alias=True)) 567 | 568 | 569 | alias = GroupAlias() 570 | -------------------------------------------------------------------------------- /libraries/maimaidx_music_info.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | from .image import rounded_corners 4 | from .maimai_best_50 import * 5 | from .maimaidx_music import Music, mai 6 | 7 | 8 | def newbestscore(song_id: str, lv: int, value: int, bestlist: List[ChartInfo]) -> int: 9 | for v in bestlist: 10 | if song_id == str(v.song_id) and lv == v.level_index: 11 | if value >= v.ra: 12 | return value - v.ra 13 | else: 14 | return 0 15 | return value - bestlist[-1].ra 16 | 17 | 18 | async def draw_music_info(music: Music, qqid: Optional[int] = None, user: Optional[UserInfo] = None) -> MessageSegment: 19 | """ 20 | 查看谱面 21 | 22 | Params: 23 | `music`: 曲目模型 24 | `qqid`: QQID 25 | `user`: 用户模型 26 | Returns: 27 | `MessageSegment` 28 | """ 29 | calc = True 30 | isfull = True 31 | bestlist: List[ChartInfo] = [] 32 | try: 33 | if qqid: 34 | if user is None: 35 | player = await maiApi.query_user_b50(qqid=qqid) 36 | else: 37 | player = user 38 | if music.basic_info.version in list(plate_to_version.values())[-2]: 39 | bestlist = player.charts.dx 40 | isfull = bool(len(bestlist) == 15) 41 | else: 42 | bestlist = player.charts.sd 43 | isfull = bool(len(bestlist) == 35) 44 | else: 45 | calc = False 46 | except (UserNotFoundError, UserNotExistsError, UserDisabledQueryError): 47 | calc = False 48 | except Exception: 49 | calc = False 50 | 51 | im = Image.open(maimaidir / 'song_bg.png').convert('RGBA') 52 | dr = ImageDraw.Draw(im) 53 | mr = DrawText(dr, SIYUAN) 54 | tb = DrawText(dr, TBFONT) 55 | 56 | default_color = (124, 130, 255, 255) 57 | 58 | im.alpha_composite(Image.open(maimaidir / 'logo.png').resize((249, 120)), (65, 25)) 59 | if music.basic_info.is_new: 60 | im.alpha_composite(Image.open(maimaidir / 'UI_CMN_TabTitle_NewSong.png').resize((249, 120)), (940, 100)) 61 | songbg = Image.open(music_picture(music.id)).resize((280, 280)) 62 | im.alpha_composite(rounded_corners(songbg, 17, (True, False, False, True)), (110, 180)) 63 | im.alpha_composite(Image.open(maimaidir / f'{music.basic_info.version}.png').resize((182, 90)), (800, 370)) 64 | im.alpha_composite(Image.open(maimaidir / f'{music.type}.png').resize((80, 30)), (410, 375)) 65 | 66 | title = music.title 67 | if coloumWidth(title) > 40: 68 | title = changeColumnWidth(title, 39) + '...' 69 | mr.draw(405, 220, 28, title, default_color, 'lm') 70 | artist = music.basic_info.artist 71 | if coloumWidth(artist) > 50: 72 | artist = changeColumnWidth(artist, 49) + '...' 73 | mr.draw(407, 265, 20, artist, default_color, 'lm') 74 | tb.draw(460, 330, 30, music.basic_info.bpm, default_color, 'lm') 75 | tb.draw(405, 435, 28, f'ID {music.id}', default_color, 'lm') 76 | mr.draw(665, 435, 24, music.basic_info.genre, default_color, 'mm') 77 | 78 | for num, _ in enumerate(music.level): 79 | if num == 4: 80 | color = (255, 255, 255, 255) 81 | else: 82 | color = (255, 255, 255, 255) 83 | tb.draw(181, 610 + 73 * num, 30, f'{music.level[num]}({music.ds[num]})', color, 'mm') 84 | tb.draw( 85 | 315, 600 + 73 * num, 30, 86 | f'{round(music.stats[num].fit_diff, 2):.2f}' if music.stats and music.stats[num] else '-', 87 | default_color, 'mm' 88 | ) 89 | notes = list(music.charts[num].notes) 90 | tb.draw(437, 600 + 73 * num, 30, sum(notes), default_color, 'mm') 91 | if len(notes) == 4: 92 | notes.insert(3, '-') 93 | for n, c in enumerate(notes): 94 | tb.draw(556 + 119 * n, 600 + 73 * num, 30, c, default_color, 'mm') 95 | if num > 1: 96 | charter = music.charts[num].charter 97 | if coloumWidth(charter) > 19: 98 | charter = changeColumnWidth(charter, 18) + '...' 99 | mr.draw(372, 1030 + 47 * (num - 2), 18, charter, default_color, 'mm') 100 | ra = sorted([computeRa(music.ds[num], r) for r in achievementList[-6:]], reverse=True) 101 | for _n, value in enumerate(ra): 102 | size = 25 103 | if not calc: 104 | rating = value 105 | elif not isfull: 106 | size = 20 107 | rating = f'{value}(+{value})' 108 | elif value > bestlist[-1].ra: 109 | new = newbestscore(music.id, num, value, bestlist) 110 | if new == 0: 111 | rating = value 112 | else: 113 | size = 20 114 | rating = f'{value}(+{new})' 115 | else: 116 | rating = value 117 | tb.draw(536 + 101 * _n, 1030 + 47 * (num - 2), size, rating, default_color, 'mm') 118 | mr.draw(600, 1212, 22, f'Designed by Yuri-YuzuChaN & BlueDeer233. Generated by {BOTNAME} BOT', default_color, 'mm') 119 | return MessageSegment.image(image_to_base64(im)) 120 | 121 | 122 | async def draw_music_play_data(qqid: int, music_id: str) -> Union[str, MessageSegment]: 123 | """ 124 | 谱面游玩 125 | 126 | Params: 127 | `qqid`: QQID 128 | `music_id`: 曲目ID 129 | Returns: 130 | `Union[str, MessageSegment]` 131 | """ 132 | try: 133 | diff: List[Union[None, PlayInfoDev, PlayInfoDefault]] 134 | if maiApi.token: 135 | data = await maiApi.query_user_post_dev(qqid=qqid, music_id=music_id) 136 | if not data: 137 | raise MusicNotPlayError 138 | 139 | music = mai.total_list.by_id(music_id) 140 | diff = [None for _ in music.ds] 141 | for _d in data: 142 | diff[_d.level_index] = _d 143 | dev = True 144 | else: 145 | version = list(set(_v for _v in plate_to_version.values())) 146 | data = await maiApi.query_user_plate(qqid=qqid, version=version) 147 | 148 | music = mai.total_list.by_id(music_id) 149 | _temp = [None for _ in music.ds] 150 | diff = copy.deepcopy(_temp) 151 | 152 | for _d in data: 153 | if _d.song_id == int(music_id): 154 | diff[_d.level_index] = _d 155 | if diff == _temp: 156 | raise MusicNotPlayError 157 | dev = False 158 | 159 | im = Image.open(maimaidir / 'info_bg.png').convert('RGBA') 160 | 161 | dr = ImageDraw.Draw(im) 162 | tb = DrawText(dr, TBFONT) 163 | mr = DrawText(dr, SIYUAN) 164 | 165 | im.alpha_composite(Image.open(maimaidir / 'logo.png').resize((249, 120)), (0, 34)) 166 | cover = Image.open(music_picture(music_id)) 167 | im.alpha_composite(cover.resize((300, 300)), (100, 260)) 168 | im.alpha_composite(Image.open(maimaidir / f'info-{category[music.basic_info.genre]}.png'), (100, 260)) 169 | im.alpha_composite(Image.open(maimaidir / f'{music.basic_info.version}.png').resize((183, 90)), (295, 205)) 170 | im.alpha_composite(Image.open(maimaidir / f'{music.type}.png').resize((55, 20)), (350, 560)) 171 | 172 | color = (124, 129, 255, 255) 173 | artist = music.basic_info.artist 174 | if coloumWidth(artist) > 58: 175 | artist = changeColumnWidth(artist, 57) + '...' 176 | mr.draw(255, 595, 12, artist, color, 'mm') 177 | title = music.title 178 | if coloumWidth(title) > 38: 179 | title = changeColumnWidth(title, 37) + '...' 180 | mr.draw(255, 622, 18, title, color, 'mm') 181 | tb.draw(160, 720, 22, music.id, color, 'mm') 182 | tb.draw(380, 720, 22, music.basic_info.bpm, color, 'mm') 183 | 184 | y = 100 185 | for num, info in enumerate(diff): 186 | im.alpha_composite(Image.open(maimaidir / f'd-{num}.png'), (650, 235 + y * num)) 187 | if info: 188 | im.alpha_composite(Image.open(maimaidir / 'ra-dx.png'), (850, 272 + y * num)) 189 | if dev: 190 | dxscore = info.dxScore 191 | _dxscore = sum(music.charts[num].notes) * 3 192 | dxnum = dxScore(dxscore / _dxscore * 100) 193 | rating, rate = info.ra, score_Rank_l[info.rate] 194 | if dxnum != 0: 195 | im.alpha_composite( 196 | Image.open(maimaidir / f'UI_GAM_Gauge_DXScoreIcon_0{dxnum}.png').resize((32, 19)), 197 | (851, 296 + y * num) 198 | ) 199 | tb.draw(916, 304 + y * num, 13, f'{dxscore}/{_dxscore}', color, 'mm') 200 | else: 201 | rating, rate = computeRa(music.ds[num], info.achievements, israte=True) 202 | 203 | im.alpha_composite(Image.open(maimaidir / 'fcfs.png'), (965, 265 + y * num)) 204 | if info.fc: 205 | im.alpha_composite( 206 | Image.open(maimaidir / f'UI_CHR_PlayBonus_{fcl[info.fc]}.png').resize((65, 65)), 207 | (960, 261 + y * num) 208 | ) 209 | if info.fs: 210 | im.alpha_composite( 211 | Image.open(maimaidir / f'UI_CHR_PlayBonus_{fsl[info.fs]}.png').resize((65, 65)), 212 | (1025, 261 + y * num) 213 | ) 214 | im.alpha_composite(Image.open(maimaidir / 'ra.png'), (1350, 405 + y * num)) 215 | im.alpha_composite( 216 | Image.open(maimaidir / f'UI_TTR_Rank_{rate}.png').resize((100, 45)), 217 | (737, 272 + y * num) 218 | ) 219 | 220 | tb.draw(510, 292 + y * num, 42, f'{info.achievements:.4f}%', color, 'lm') 221 | tb.draw(685, 248 + y * num, 25, music.ds[num], anchor='mm') 222 | tb.draw(915, 283 + y * num, 18, rating, color, 'mm') 223 | else: 224 | tb.draw(685, 248 + y * num, 25, music.ds[num], anchor='mm') 225 | mr.draw(800, 302 + y * num, 30, '未游玩', color, 'mm') 226 | if len(diff) == 4: 227 | mr.draw(800, 302 + y * 4, 30, '没有该难度', color, 'mm') 228 | 229 | mr.draw(600, 827, 22, f'Designed by Yuri-YuzuChaN & BlueDeer233. Generated by {BOTNAME} Bot', color, 'mm') 230 | msg = MessageSegment.image(image_to_base64(im)) 231 | 232 | except (UserNotFoundError, UserNotExistsError, UserDisabledQueryError, MusicNotPlayError) as e: 233 | msg = str(e) 234 | except Exception as e: 235 | log.error(traceback.format_exc()) 236 | msg = f'未知错误:{type(e)}\n请联系Bot管理员' 237 | return msg 238 | 239 | 240 | def calc_achievements_fc(scorelist: Union[List[float], List[str]], lvlist_num: int, isfc: bool = False) -> int: 241 | r = -1 242 | obj = range(4) if isfc else achievementList[-6:] 243 | for __f in obj: 244 | if len(list(filter(lambda x: x >= __f, scorelist))) == lvlist_num: 245 | r += 1 246 | else: 247 | break 248 | return r 249 | 250 | 251 | def draw_rating(rating: str, path: Path) -> MessageSegment: 252 | """ 253 | 绘制指定定数表文字 254 | 255 | Params: 256 | `rating`: 定数 257 | `path`: 路径 258 | Returns: 259 | `MessageSegment` 260 | """ 261 | im = Image.open(path) 262 | dr = ImageDraw.Draw(im) 263 | sy = DrawText(dr, SIYUAN) 264 | sy.draw(700, 100, 65, f'Level.{rating} 定数表', (124, 129, 255, 255), 'mm', 5, (255, 255, 255, 255)) 265 | return MessageSegment.image(image_to_base64(im)) 266 | 267 | 268 | async def draw_rating_table(qqid: int, rating: str, isfc: bool = False) -> Union[MessageSegment, str]: 269 | """绘制定数表""" 270 | try: 271 | version = list(set(_v for _v in plate_to_version.values())) 272 | obj = await maiApi.query_user_plate(qqid=qqid, version=version) 273 | 274 | statistics = { 275 | 'clear': 0, 276 | 'sync': 0, 277 | 's': 0, 278 | 'sp': 0, 279 | 'ss': 0, 280 | 'ssp': 0, 281 | 'sss': 0, 282 | 'sssp': 0, 283 | 'fc': 0, 284 | 'fcp': 0, 285 | 'ap': 0, 286 | 'app': 0, 287 | 'fs': 0, 288 | 'fsp': 0, 289 | 'fsd': 0, 290 | 'fsdp': 0, 291 | } 292 | fromid = {} 293 | 294 | sp = score_Rank[-6:] 295 | for _d in obj: 296 | if _d.level != rating: 297 | continue 298 | if (id := str(_d.song_id)) not in fromid: 299 | fromid[id] = {} 300 | fromid[id][str(_d.level_index)] = { 301 | 'achievements': _d.achievements, 302 | 'fc': _d.fc, 303 | 'level': _d.level 304 | } 305 | rate = computeRa(_d.ds, _d.achievements, onlyrate=True).lower() 306 | if _d.achievements >= 80: 307 | statistics['clear'] += 1 308 | if rate in sp: 309 | r_index = sp.index(rate) 310 | for _r in range(r_index + 1): 311 | statistics[sp[_r]] += 1 312 | if _d.fc: 313 | fc_index = combo_rank.index(_d.fc) 314 | for _f in range(fc_index + 1): 315 | statistics[combo_rank[_f]] += 1 316 | if _d.fs: 317 | if _d.fs == 'sync': 318 | statistics[_d.fs] += 1 319 | else: 320 | fs_index = sync_rank.index(_d.fs) 321 | for _s in range(fs_index + 1): 322 | statistics[sync_rank[_s]] += 1 323 | 324 | achievements_fc_list: List[Union[float, List[float]]] = [] 325 | lvlist = mai.total_level_data[rating] 326 | lvnum = sum([len(v) for v in lvlist.values()]) 327 | 328 | rating_bg = Image.open(maimaidir / 'rating_bg.png') 329 | unfinished_bg = Image.open(maimaidir / 'unfinished_bg.png') 330 | complete_bg = Image.open(maimaidir / 'complete_bg.png') 331 | 332 | bg = ratingdir / f'{rating}.png' 333 | 334 | im = Image.open(bg).convert('RGBA') 335 | dr = ImageDraw.Draw(im) 336 | sy = DrawText(dr, SIYUAN) 337 | tb = DrawText(dr, TBFONT) 338 | 339 | im.alpha_composite(rating_bg, (600, 25)) 340 | sy.draw(305, 60, 65, f'Level.{rating}', (124, 129, 255, 255), 'mm', 5, (255, 255, 255, 255)) 341 | sy.draw(305, 130, 65, '定数表', (124, 129, 255, 255), 'mm', 5, (255, 255, 255, 255)) 342 | tb.draw(700, 127, 45, lvnum, (124, 129, 255, 255), 'mm', 5, (255, 255, 255, 255)) 343 | 344 | y = 22 345 | for n, v in enumerate(statistics): 346 | if n % 8 == 0: 347 | x = 824 348 | y += 56 349 | else: 350 | x += 64 351 | tb.draw(x, y, 20, statistics[v], (124, 129, 255, 255), 'mm', 2, (255, 255, 255, 255)) 352 | 353 | y = 118 354 | for ra in lvlist: 355 | x = 158 356 | y += 20 357 | for num, music in enumerate(lvlist[ra]): 358 | if num % 14 == 0: 359 | x = 158 360 | y += 85 361 | else: 362 | x += 85 363 | if music.id in fromid and music.lv in fromid[music.id]: 364 | if not isfc: 365 | score = fromid[music.id][music.lv]['achievements'] 366 | achievements_fc_list.append(score) 367 | rate = computeRa(music.ds, score, onlyrate=True) 368 | rank = Image.open(maimaidir / f'UI_TTR_Rank_{rate}.png').resize((78, 35)) 369 | if score >= 100: 370 | im.alpha_composite(complete_bg, (x + 2, y - 18)) 371 | else: 372 | im.alpha_composite(unfinished_bg, (x + 2, y - 18)) 373 | im.alpha_composite(rank, (x, y - 5)) 374 | continue 375 | if _fc := fromid[music.id][music.lv]['fc']: 376 | achievements_fc_list.append(combo_rank.index(_fc)) 377 | fc = Image.open(maimaidir / f'UI_MSS_MBase_Icon_{fcl[_fc]}.png').resize((50, 50)) 378 | im.alpha_composite(complete_bg, (x + 2, y - 18)) 379 | im.alpha_composite(fc, (x + 15, y - 12)) 380 | 381 | if len(achievements_fc_list) == lvnum: 382 | r = calc_achievements_fc(achievements_fc_list, lvnum, isfc) 383 | if r != -1: 384 | pic = fcl[combo_rank[r]] if isfc else score_Rank_l[score_Rank[-6:][r]] 385 | im.alpha_composite(Image.open(maimaidir / f'UI_MSS_Allclear_Icon_{pic}.png'), (40, 40)) 386 | 387 | msg = MessageSegment.image(image_to_base64(im)) 388 | except (UserNotFoundError, UserNotExistsError, UserDisabledQueryError) as e: 389 | msg = str(e) 390 | except Exception as e: 391 | log.error(traceback.format_exc()) 392 | msg = f'未知错误:{type(e)}\n请联系Bot管理员' 393 | return msg 394 | 395 | 396 | async def draw_plate_table(qqid: int, version: str, plan: str) -> Union[MessageSegment, str]: 397 | """ 398 | 绘制完成表 399 | 400 | Params: 401 | `qqid`: QQID 402 | `version`: 版本 403 | `plan`: 计划 404 | Returns: 405 | `Union[MessageSegment, str]` 406 | """ 407 | try: 408 | if version in platecn: 409 | version = platecn[version] 410 | if version == '真': 411 | ver = [plate_to_version['真']] + [plate_to_version['初']] 412 | _ver = version 413 | elif version in ['熊', '华', '華']: 414 | ver = [plate_to_version['熊']] 415 | _ver = '熊&华' 416 | elif version in ['爽', '煌']: 417 | ver = [plate_to_version['爽']] 418 | _ver = '爽&煌' 419 | elif version in ['宙', '星']: 420 | ver = [plate_to_version['宙']] 421 | _ver = '宙&星' 422 | elif version in ['祭', '祝']: 423 | ver = [plate_to_version['祭']] 424 | _ver = '祭&祝' 425 | elif version in ['双', '宴']: 426 | ver = [plate_to_version['双']] 427 | _ver = '双&宴' 428 | else: 429 | ver = [plate_to_version[version]] 430 | _ver = version 431 | 432 | music_id_list = mai.total_plate_id_list[_ver] 433 | music = mai.total_list.by_id_list(music_id_list) 434 | plate_total_num = len(music_id_list) 435 | playerdata: List[PlayInfoDefault] = [] 436 | 437 | obj = await maiApi.query_user_plate(qqid=qqid, version=ver) 438 | for _d in obj: 439 | if _d.song_id not in music_id_list: 440 | continue 441 | _music = mai.total_list.by_id(_d.song_id) 442 | _d.table_level = _music.level 443 | _d.ds = _music.ds[_d.level_index] 444 | playerdata.append(_d) 445 | 446 | ra: Dict[str, Dict[str, List[Optional[PlayInfoDefault]]]] = {} 447 | """ 448 | { 449 | "14+": { 450 | "365": [None, None, None, PlayInfoDefault, None], 451 | ... 452 | }, 453 | "14": { 454 | ... 455 | } 456 | } 457 | """ 458 | music.sort(key=lambda x: x.ds[3], reverse=True) 459 | number = 4 if version not in ['霸', '舞'] else 5 460 | for _m in music: 461 | if _m.level[3] not in ra: 462 | ra[_m.level[3]] = {} 463 | ra[_m.level[3]][_m.id] = [None for _ in range(number)] 464 | for _d in playerdata: 465 | if number == 4 and _d.level_index == 4: 466 | continue 467 | ra[_d.table_level[3]][str(_d.song_id)][_d.level_index] = _d 468 | 469 | finished_bg = [Image.open(maimaidir / f't-{_}.png') for _ in range(4)] 470 | unfinished_bg = Image.open(maimaidir / 'unfinished_bg_2.png') 471 | complete_bg = Image.open(maimaidir / 'complete_bg_2.png') 472 | 473 | im = Image.open(platedir / f'{version}.png') 474 | draw = ImageDraw.Draw(im) 475 | tr = DrawText(draw, TBFONT) 476 | mr = DrawText(draw, SIYUAN) 477 | 478 | im.alpha_composite(Image.open(maimaidir / 'plate_num.png'), (185, 20)) 479 | im.alpha_composite( 480 | Image.open(platedir / f'{version}{"極" if plan == "极" else plan}.png').resize((1000, 161)), 481 | (200, 35) 482 | ) 483 | lv: List[set[int]] = [set() for _ in range(number)] 484 | y = 245 485 | # if plan == '者': 486 | # for level in ra: 487 | # x = 200 488 | # y += 15 489 | # for num, _id in enumerate(ra[level]): 490 | # if num % 10 == 0: 491 | # x = 200 492 | # y += 115 493 | # else: 494 | # x += 115 495 | # f: List[int] = [] 496 | # for num, play in enumerate(ra[level][_id]): 497 | # if play.achievements or not play.achievements >= 80: continue 498 | # fc = Image.open(maimaidir / f'UI_MSS_MBase_Icon_{fcl[play.fc]}.png') 499 | # im.alpha_composite(fc, (x, y)) 500 | # f.append(n) 501 | # for n in f: 502 | # im.alpha_composite(finished_bg[n], (x + 5 + 25 * n, y + 67)) 503 | if plan == '极' or plan == '極': 504 | for level in ra: 505 | x = 200 506 | y += 15 507 | for num, _id in enumerate(ra[level]): 508 | if num % 10 == 0: 509 | x = 200 510 | y += 115 511 | else: 512 | x += 115 513 | f: List[int] = [] 514 | for n, play in enumerate(ra[level][_id]): 515 | if play is None or not play.fc: continue 516 | if n == 3: 517 | im.alpha_composite(complete_bg, (x, y)) 518 | fc = Image.open(maimaidir / f'UI_CHR_PlayBonus_{fcl[play.fc]}.png').resize((75, 75)) 519 | im.alpha_composite(fc, (x + 13, y + 3)) 520 | lv[n].add(play.song_id) 521 | f.append(n) 522 | for n in f: 523 | im.alpha_composite(finished_bg[n], (x + 5 + 25 * n, y + 67)) 524 | if plan == '将': 525 | for level in ra: 526 | x = 200 527 | y += 15 528 | for num, _id in enumerate(ra[level]): 529 | if num % 10 == 0: 530 | x = 200 531 | y += 115 532 | else: 533 | x += 115 534 | f: List[int] = [] 535 | for n, play in enumerate(ra[level][_id]): 536 | if play is None or play.achievements < 100: continue 537 | if n == 3: 538 | im.alpha_composite(complete_bg if play.achievements >= 100 else unfinished_bg, (x, y)) 539 | rate = computeRa(play.ds, play.achievements, onlyrate=True) 540 | rank = Image.open(maimaidir / f'UI_TTR_Rank_{rate}.png').resize((102, 46)) 541 | im.alpha_composite(rank, (x - 1, y + 15)) 542 | lv[n].add(play.song_id) 543 | f.append(n) 544 | for n in f: 545 | im.alpha_composite(finished_bg[n], (x + 5 + 25 * n, y + 67)) 546 | if plan == '神': 547 | _fc = ['ap', 'app'] 548 | for level in ra: 549 | x = 200 550 | y += 15 551 | for num, _id in enumerate(ra[level]): 552 | if num % 10 == 0: 553 | x = 200 554 | y += 115 555 | else: 556 | x += 115 557 | f: List[int] = [] 558 | for n, play in enumerate(ra[level][_id]): 559 | if play is None or play.fc not in _fc: continue 560 | if n == 3: 561 | im.alpha_composite(complete_bg, (x, y)) 562 | ap = Image.open(maimaidir / f'UI_CHR_PlayBonus_{fcl[play.fc]}.png').resize((75, 75)) 563 | im.alpha_composite(ap, (x + 13, y + 3)) 564 | lv[n].add(play.song_id) 565 | f.append(n) 566 | for n in f: 567 | im.alpha_composite(finished_bg[n], (x + 5 + 25 * n, y + 67)) 568 | if plan == '舞舞': 569 | fs = ['fsd', 'fdx', 'fsdp', 'fdxp'] 570 | for level in ra: 571 | x = 200 572 | y += 15 573 | for num, _id in enumerate(ra[level]): 574 | if num % 10 == 0: 575 | x = 200 576 | y += 115 577 | else: 578 | x += 115 579 | f: List[int] = [] 580 | for n, play in enumerate(ra[level][_id]): 581 | if play is None or play.fs not in fs: 582 | continue 583 | if n == 3: 584 | im.alpha_composite(complete_bg, (x, y)) 585 | fsd = Image.open(maimaidir / f'UI_CHR_PlayBonus_{fsl[play.fs]}.png').resize((75, 75)) 586 | im.alpha_composite(fsd, (x + 13, y + 3)) 587 | lv[n].add(play.song_id) 588 | f.append(n) 589 | for n in f: 590 | im.alpha_composite(finished_bg[n], (x + 5 + 25 * n, y + 67)) 591 | 592 | color = ScoreBaseImage.id_color.copy() 593 | color.insert(0, (124, 129, 255, 255)) 594 | for num in range(len(lv) + 1): 595 | if num == 0: 596 | v = set.intersection(*lv) 597 | _v = f'{len(v)}/{plate_total_num}' 598 | else: 599 | _v = len(lv[num - 1]) 600 | if _v == plate_total_num: 601 | mr.draw(390 + 200 * num, 270, 35, '完成', color[num], 'rm', 4, (255, 255, 255, 255)) 602 | else: 603 | tr.draw(390 + 200 * num, 270, 40, _v, color[num], 'rm', 4, (255, 255, 255, 255)) 604 | 605 | msg = MessageSegment.image(image_to_base64(im)) 606 | except (UserNotFoundError, UserNotExistsError, UserDisabledQueryError) as e: 607 | msg = str(e) 608 | except Exception as e: 609 | log.error(traceback.format_exc()) 610 | msg = f'未知错误:{type(e)}\n请联系Bot管理员' 611 | return msg -------------------------------------------------------------------------------- /libraries/maimaidx_player_score.py: -------------------------------------------------------------------------------- 1 | import random 2 | import time 3 | import traceback 4 | from typing import Callable 5 | 6 | import pyecharts.options as opts 7 | from pyecharts.charts import Pie 8 | 9 | from hoshino.typing import MessageSegment 10 | 11 | from .. import * 12 | from .image import * 13 | from .maimai_best_50 import ScoreBaseImage, changeColumnWidth, coloumWidth, computeRa 14 | from .maimaidx_api_data import * 15 | from .maimaidx_model import PlanInfo, PlayInfoDefault, PlayInfoDev, RaMusic 16 | from .maimaidx_music import Music, mai 17 | from .tool import run_chrome_to_base64 18 | 19 | Filter = Tuple[ 20 | List[PlayInfoDefault], 21 | List[PlayInfoDefault], 22 | List[PlayInfoDefault], 23 | List[PlayInfoDefault], 24 | List[PlayInfoDefault] 25 | ] 26 | Condition = Callable[[PlayInfoDefault], bool] 27 | 28 | 29 | async def music_global_data(music: Music, level_index: int) -> MessageSegment: 30 | """ 31 | 绘制曲目游玩详情 32 | 33 | Params: 34 | `music`: :class:Music 35 | `level_index`: 难度 36 | Returns: 37 | `MessageSegment` 38 | """ 39 | stats = music.stats[level_index] 40 | fc_data_pair = [list(z) for z in zip([c.upper() if c else 'Not FC' for c in [''] + comboRank], stats.fc_dist)] 41 | acc_data_pair = [list(z) for z in zip([s.upper() for s in scoreRank], stats.dist)] 42 | 43 | initopts = opts.InitOpts(width='1000px', height='800px', bg_color='#fff', js_host='./') 44 | labelopts = opts.LabelOpts( 45 | position='outside', 46 | formatter='{a|{a}}{abg|}\n{hr|}\n {b|{b}: }{c} {per|{d}%} ', 47 | background_color='#eee', 48 | border_color='#aaa', 49 | border_width=1, 50 | border_radius=4, 51 | rich={ 52 | 'a': {'color': '#999', 'lineHeight': 22, 'align': 'center'}, 53 | 'abg': { 54 | 'backgroundColor': '#e3e3e3', 55 | 'width': '100%', 56 | 'align': 'right', 57 | 'height': 22, 58 | 'borderRadius': [4, 4, 0, 0], 59 | }, 60 | 'hr': { 61 | 'borderColor': '#aaa', 62 | 'width': '100%', 63 | 'borderWidth': 0.5, 64 | 'height': 0, 65 | }, 66 | 'b': {'fontSize': 16, 'lineHeight': 33}, 67 | 'per': { 68 | 'color': '#eee', 69 | 'backgroundColor': '#334455', 70 | 'padding': [2, 4], 71 | 'borderRadius': 2, 72 | }, 73 | }, 74 | ) 75 | titleopts = opts.TitleOpts( 76 | title=f'{music.id} {music.title} 「{diffs[level_index]}」', 77 | pos_left='center', 78 | pos_top='20', 79 | title_textstyle_opts=opts.TextStyleOpts(color='#2c343c'), 80 | ) 81 | legendopts = opts.LegendOpts(pos_left=15, pos_top=10, orient='vertical') 82 | 83 | pie = Pie(initopts) 84 | pie.add('全连等级', fc_data_pair, radius=[0, '30%'], label_opts=labelopts) 85 | pie.add('达成率等级', acc_data_pair, radius=['50%', '70%'], is_clockwise=True, label_opts=labelopts) 86 | pie.set_global_opts(title_opts=titleopts, legend_opts=legendopts) 87 | pie.set_series_opts(tooltip_opts=opts.TooltipOpts(trigger='item', formatter='{a}
{b}: {c} ({d}%)')) 88 | pie.render(str(pie_html_file)) 89 | base64 = await run_chrome_to_base64() 90 | 91 | return MessageSegment.image(base64) 92 | 93 | 94 | class DrawScore(ScoreBaseImage): 95 | 96 | def __init__(self, image: Image.Image = None) -> None: 97 | super().__init__(image) 98 | self._im.alpha_composite(self.aurora_bg) 99 | self._im.alpha_composite(self.shines_bg, (34, 0)) 100 | self._im.alpha_composite(self.rainbow_bg, (319, self._im.size[1] - 643)) 101 | self._im.alpha_composite(self.rainbow_bottom_bg, (100, self._im.size[1] - 343)) 102 | for h in range((self._im.size[1] // 358) + 1): 103 | self._im.alpha_composite(self.pattern_bg, (0, (358 + 7) * h)) 104 | 105 | def whilepic(self, data: List[RaMusic], y: int = 200): 106 | """ 107 | 循环绘制谱面 108 | 109 | Params: 110 | `data`: `谱面数据` 111 | `y`: `Y轴偏移` 112 | """ 113 | dy = 65 114 | x = 0 115 | for n, v in enumerate(data): 116 | if n % 20 == 0: 117 | x = 55 118 | y += dy if n != 0 else 0 119 | else: 120 | x += 65 121 | cover = Image.open(music_picture(v.id)).resize((55, 55)) 122 | self._im.alpha_composite(cover, (x, y)) 123 | self._im.alpha_composite(self.id_diff[int(v.lv)], (x, y + 45)) 124 | self._tb.draw(x + 27, y + 50, 10, v.id, self.t_color[int(v.lv)], 'mm') 125 | 126 | def whilerisepic(self, data: List[RiseScore], low_score: int, isdx: bool): 127 | """ 128 | 循环绘制上分推荐数据 129 | 130 | Params: 131 | `data`: `上分数据` 132 | `low_score`: `最低分` 133 | `isdx`: `是否DX版本` 134 | """ 135 | y = 120 136 | for index, _d in enumerate(data): 137 | x = 200 if isdx else 700 138 | y += 140 if index != 0 else 0 139 | 140 | rate = Image.open(maimaidir / f'UI_TTR_Rank_{_d.rate}.png').resize((63, 28)) 141 | 142 | self._im.alpha_composite(self._rise[_d.level_index], (x + 30, y)) 143 | self._im.alpha_composite(Image.open(music_picture(_d.song_id)).resize((80, 80)), (x + 55, y + 40)) 144 | self._im.alpha_composite(Image.open(maimaidir / f'{_d.type.upper()}.png').resize((60, 22)), (x + 240, y + 114)) 145 | if _d.oldrate: 146 | oldrate = Image.open(maimaidir / f'UI_TTR_Rank_{_d.oldrate}.png').resize((63, 28)) 147 | self._im.alpha_composite(oldrate, (x + 145, y + 82)) 148 | self._im.alpha_composite(rate, (x + 305, y + 82)) 149 | 150 | title = _d.title 151 | if coloumWidth(title) > 26: 152 | title = changeColumnWidth(title, 25) + '...' 153 | self._sy.draw(x + 142, y + 44, 17, title, self.t_color[_d.level_index], 'lm') 154 | self._tb.draw(x + 145, y + 124, 18, f'ID: {_d.song_id}', self.id_color[_d.level_index], 'lm') 155 | self._tb.draw(x + 210, y + 71, 25, f'{_d.oldachievements:.4f}%', self.t_color[_d.level_index], anchor='mm') 156 | self._tb.draw(x + 245, y + 96, 17, f'Ra: {_d.oldra}', self.t_color[_d.level_index], anchor='mm') 157 | self._tb.draw(x + 370, y + 71, 25, f'{_d.achievements:.4f}%', self.t_color[_d.level_index], anchor='mm') 158 | self._tb.draw(x + 415, y + 96, 17, f'Ra: {_d.ra}', self.t_color[_d.level_index], anchor='mm') 159 | self._tb.draw(x + 315, y + 124, 18, f'ds:{_d.ds}', self.id_color[_d.level_index], anchor='lm') 160 | if _d.oldra > low_score: 161 | new_ra = _d.ra - _d.oldra 162 | else: 163 | new_ra = _d.ra - low_score 164 | self._tb.draw(x + 390, y + 124, 18, f'Ra +{new_ra}', self.id_color[_d.level_index], 'lm') 165 | 166 | def draw_rise(self, sd: List[RiseScore], sd_score: int, dx: List[RiseScore], dx_score: int) -> Image.Image: 167 | """ 168 | 绘制上分数据表 169 | 170 | Params: 171 | `sd`: `旧版本谱面` 172 | `sd_score`: `旧版本最低分` 173 | `sd`: `新版本谱面` 174 | `dx_score`: `新版本最低分` 175 | Returns: 176 | `Image.Image` 177 | """ 178 | title_bg = self.title_bg.copy().resize((273, 80)) 179 | self._im.alpha_composite(title_bg, (314, 30)) 180 | self._sy.draw(450, 68, 18, '旧版本谱面推荐', self.text_color, 'mm') 181 | self.whilerisepic(sd, sd_score, True) 182 | self._im.alpha_composite(title_bg, (814, 30)) 183 | self._sy.draw(950, 68, 18, '新版本谱面推荐', self.text_color, 'mm') 184 | self.whilerisepic(dx, dx_score, False) 185 | 186 | height = self._im.size[1] 187 | self._im.alpha_composite(self.design_bg.resize((800, 72)), (300, height - 110)) 188 | self._sy.draw(700, height - 76, 18, f'Designed by Yuri-YuzuChaN & BlueDeer233. Generated by {BOTNAME} BOT', self.text_color, 'mm') 189 | return self._im 190 | 191 | def draw_plan( 192 | self, 193 | completed: Union[List[PlayInfoDefault], List[PlayInfoDev]], 194 | completed_y: int, 195 | unfinished: Union[List[PlayInfoDefault], List[PlayInfoDev]], 196 | unfinished_y: int, 197 | notstarted: List[RaMusic], 198 | plan: str, 199 | completed_len: int 200 | ) -> Image.Image: 201 | """ 202 | 绘制进度表 203 | 204 | Params: 205 | `completed`: `已完成谱面` 206 | `completed_y`: `已完成谱面高度` 207 | `unfinished`: `未完成谱面` 208 | `unfinished_y`: `未完成谱面高度` 209 | `notstarted`: `未游玩谱面` 210 | `plan`: `目标` 211 | `completed_len`: `已完成谱面数量` 212 | Returns: 213 | `Image.Image` 214 | """ 215 | max = len(completed + unfinished + notstarted) 216 | 217 | self._im.alpha_composite(self.title_lengthen_bg, (475, 30)) 218 | self._im.alpha_composite(self.title_lengthen_bg, (475, 30 + completed_y)) 219 | self._im.alpha_composite(self.title_lengthen_bg, (475, 30 + completed_y + unfinished_y)) 220 | 221 | self._sy.draw(700, 77, 22, f'已完成谱面「{len(completed)}」个', self.text_color, 'mm') 222 | self._sy.draw(700, 77 + completed_y, 22, f'未完成谱面「{len(unfinished)}」个', self.text_color, 'mm') 223 | self._sy.draw(700, 77 + completed_y + unfinished_y, 22, f'未游玩谱面「{len(notstarted)}」个', self.text_color, 'mm') 224 | 225 | self.whiledraw(completed[:completed_len], True, 140) 226 | self.whiledraw(unfinished[:30], True, 140 + completed_y) 227 | self.whilepic(notstarted[:100], 140 + completed_y + unfinished_y) 228 | 229 | self._im.alpha_composite(self.design_bg, (200, self._im.size[1] - 113)) 230 | pagemsg = f'共计「{max}」个谱面,剩余「{len(unfinished + notstarted)}」个谱面未完成「{plan.upper()}」' 231 | self._sy.draw(700, self._im.size[1] - 70, 25, pagemsg, self.text_color, 'mm') 232 | return self._im 233 | 234 | def draw_category( 235 | self, 236 | category: str, 237 | data: Union[List[PlayInfoDefault], List[PlayInfoDev], List[RaMusic]], 238 | page: int = 1, 239 | end_page: int = 1 240 | ) -> Image.Image: 241 | """ 242 | 绘制指定进度表 243 | 244 | Params: 245 | `category`: `类别` 246 | `data`: `数据` 247 | `page`: `页数` 248 | `end_page`: `总页数` 249 | Returns: 250 | `Image.Image` 251 | """ 252 | lendata = len(data) 253 | newdata = data[(page - 1) * 80: page * 80] 254 | self._im.alpha_composite(self.title_lengthen_bg, (475, 30)) 255 | if category == 'completed' or category == 'unfinished': 256 | txt = '已完成' if category == 'completed' else '未完成' 257 | self._sy.draw(700, 77, 28, f'{txt}谱面', self.text_color, 'mm') 258 | self.whiledraw(newdata, True, 140) 259 | self._im.alpha_composite(self.design_bg, (200, self._im.size[1] - 113)) 260 | 261 | pagemsg = f'{txt}谱面共计「{lendata}」个,' 262 | pagemsg += f'展示第「{(page - 1) * 80 + 1}-{80 * (page - 1) + len(newdata)}」个,' 263 | pagemsg += f'当前第「{page} / {end_page}」页' 264 | self._sy.draw(700, self._im.size[1] - 70, 25, pagemsg, self.text_color, 'mm') 265 | else: 266 | self._sy.draw(700, 77, 28, '未游玩谱面', self.text_color, 'mm') 267 | self.whilepic(data) 268 | self._im.alpha_composite(self.design_bg, (200, self._im.size[1] - 113)) 269 | self._sy.draw(700, self._im.size[1] - 70, 25, f'未游玩谱面共计「{len(data)}」个', self.text_color, 'mm') 270 | return self._im 271 | 272 | def draw_scorelist( 273 | self, 274 | rating: Union[str, float], 275 | data: Union[List[PlayInfoDefault], List[PlayInfoDev]], 276 | page: int = 1, 277 | end_page: int = 1 278 | ) -> Image.Image: 279 | """ 280 | 绘制分数列表 281 | 282 | Params: 283 | `rating`: `定数` 284 | `data`: `数据` 285 | `page`: `页数` 286 | `end_page`: `总页数` 287 | Returns: 288 | `Image.Image` 289 | """ 290 | lendata = len(data) 291 | newdata = data[(page - 1) * 80: page * 80] 292 | r = len(newdata) // 20 + (0 if len(newdata) % 20 == 0 else 1) 293 | for n in range(r): 294 | y = (109 * 4 + 140) * n 295 | self._im.alpha_composite(self.title_lengthen_bg, (475, 30 + y)) 296 | start = (20 * n + 1) + 80 * (page - 1) 297 | self._sy.draw(700, 77 + y, 28, f'No.{start}- No.{start + len(newdata[n * 20: (n + 1) * 20]) - 1}', self.text_color, 'mm') 298 | self.whiledraw(newdata[n * 20: (n + 1) * 20], True, 140 + y) 299 | self._im.alpha_composite(self.design_bg, (200, self._im.size[1] - 113)) 300 | 301 | pagemsg = f'「{rating}」共计「{lendata}」个成绩,' 302 | pagemsg += f'展示第「{(page - 1) * 80 + 1}-{80 * (page - 1) + len(newdata)}」个,' 303 | pagemsg += f'当前第「{page} / {end_page}」页' 304 | self._sy.draw(700, self._im.size[1] - 70, 25, pagemsg, self.text_color, 'mm') 305 | return self._im 306 | 307 | 308 | def get_rise_score_list( 309 | old_records: Dict[int, Dict[str, Union[int, float]]], 310 | type: str, 311 | info: List[ChartInfo], 312 | level: Optional[str] = None, 313 | score: Optional[int] = None 314 | ) -> Tuple[List[RiseScore], int]: 315 | """ 316 | 随机获取加分曲目 317 | 318 | Params: 319 | `type`: 版本 320 | `info`: 游玩成绩列表 321 | `level`: 等级 322 | `score`: 分数 323 | Returns: 324 | `Tuple[List[RiseScore], int]` 325 | """ 326 | ignore = [m.song_id for m in info if m.achievements >= 100.5] 327 | ra = info[-1].ra 328 | music: List[RiseScore] = [] 329 | if score is None: 330 | ss_ds = round(ra / 20.8, 1) 331 | else: 332 | ss_ds = round((ra + score) / 20.8, 1) 333 | sssp_ds = round(ra / 22.4, 1) 334 | ds = (sssp_ds + 0.1, ss_ds + 0.1) 335 | version = list(plate_to_version.values())[-2] if type == 'DX' else list(plate_to_version.values())[:-2] 336 | musiclist = mai.total_list.filter(level=level, ds=ds, version=version) 337 | for _m in musiclist: 338 | if (song_id := int(_m.id)) in ignore: 339 | continue 340 | if song_id >= 100000: 341 | continue 342 | for index in _m.diff: 343 | for r in achievementList[-4:]: 344 | basera, rate = computeRa(_m.ds[index], r, israte=True) 345 | if basera <= ra: 346 | continue 347 | if score and basera - score < ra: 348 | continue 349 | if song_id in old_records and old_records[song_id]['level_index'] == index: 350 | oldra, oldrate = computeRa(_m.ds[index], old_records[song_id]['achievements'], israte=True) 351 | if oldra >= basera: 352 | continue 353 | ss = RiseScore( 354 | song_id=song_id, 355 | title=_m.title, 356 | type=_m.type, 357 | level_index=index, 358 | ds=_m.ds[index], 359 | ra=basera, 360 | rate=rate, 361 | achievements=r, 362 | oldra=oldra, 363 | oldrate=oldrate, 364 | oldachievements=old_records[song_id]['achievements'] 365 | ) 366 | else: 367 | ss = RiseScore( 368 | song_id=song_id, 369 | title=_m.title, 370 | type=_m.type, 371 | level_index=index, 372 | ds=_m.ds[index], 373 | ra=basera, 374 | rate=rate, 375 | achievements=r 376 | ) 377 | music.append(ss) 378 | break 379 | if not music: 380 | return music, 0 381 | new = random.sample(music, musiclen if 0 < (musiclen := len(musiclist)) < 5 else 5) 382 | new.sort(key=lambda x: x.song_id, reverse=True) 383 | return new, ra 384 | 385 | 386 | async def rise_score_data( 387 | qqid: int, 388 | username: Optional[str] = None, 389 | level: Optional[str] = None, 390 | score: Optional[int] = None 391 | ) -> Union[MessageSegment, str]: 392 | """ 393 | 上分数据 394 | 395 | Params: 396 | `qqid`: 用户QQ 397 | `username`: 查分器用户名 398 | `level`: 定数 399 | `score`: 分数 400 | Returns: 401 | `Union[Image.Image, str]` 402 | """ 403 | try: 404 | user = await maiApi.query_user_b50(qqid=qqid, username=username) 405 | records = await maiApi.query_user_plate(qqid=qqid, username=username, version=list(plate_to_version.values())) 406 | old_records: Dict[int, Dict[str, Union[int, float]]] = { 407 | m.song_id: { 408 | 'level_index': m.level_index, 409 | 'achievements': m.achievements 410 | } for m in records 411 | } 412 | 413 | sd, sd_low_score = get_rise_score_list(old_records, 'SD', user.charts.sd, level, score) 414 | dx, dx_low_score = get_rise_score_list(old_records, 'DX', user.charts.dx, level, score) 415 | 416 | if not sd and not dx: 417 | return '没有推荐的铺面' 418 | 419 | lensd, lendx = len(sd), len(dx) 420 | 421 | h = max(lensd, lendx) 422 | height = h * 140 + 110 + 150 423 | image = tricolor_gradient(1400, height) 424 | 425 | ds = DrawScore(image) 426 | im = ds.draw_rise(sd, sd_low_score, dx, dx_low_score).crop((200, 0, 1200, height)) 427 | 428 | msg = MessageSegment.image(image_to_base64(im)) 429 | except (UserNotFoundError, UserNotExistsError, UserDisabledQueryError) as e: 430 | msg = str(e) 431 | except Exception as e: 432 | log.error(traceback.format_exc()) 433 | msg = f'未知错误:{type(e)}\n请联系Bot管理员' 434 | 435 | return msg 436 | 437 | 438 | def plate_message( 439 | result: str, 440 | plan: str, 441 | music_list: List[PlayInfoDefault], 442 | played: List[Tuple[int, int]] 443 | ) -> Union[MessageSegment, str]: 444 | """ 445 | Params: 446 | `result`: 结果 447 | `plan`: 目标 448 | `music_list`: 谱面列表 449 | `played`: 已游玩谱面 450 | Returns: 451 | `Union[MessageSegment, str]` 452 | """ 453 | for n, m in enumerate(music_list): 454 | self_record = '' 455 | if (m.song_id, m.level_index) in played: 456 | if plan in ['将', '者']: 457 | self_record = f'{m.achievements}%' 458 | if plan in ['極', '极', '神']: 459 | self_record = m.fc 460 | if plan in '舞舞': 461 | self_record = m.fs 462 | result += f'No.{n + 1:02d} {f"「{m.song_id}」":>7} {f"「{diffs[m.level_index]}」":>11} 「{m.ds}」 {m.title} {self_record}\n' 463 | if len(music_list) > 10: 464 | result = MessageSegment.image(image_to_base64(text_to_image(result.strip()))) 465 | return result 466 | 467 | 468 | async def player_plate_data(qqid: int, username: str, version: str, plan: str) -> Union[MessageSegment, str]: 469 | """ 470 | 查看牌子进度 471 | 472 | Params: 473 | `qqid`: 用户QQ 474 | `username`: 查分器用户名 475 | `version`: 版本 476 | `plan`: 目标 477 | Returns: 478 | `Union[MessageSegment, str]` 479 | """ 480 | if version in platecn: 481 | version = platecn[version] 482 | if version == '真': 483 | ver = [plate_to_version['真']] + [plate_to_version['初']] 484 | _ver = version 485 | elif version in ['霸', '舞']: 486 | ver = list(set(_v for _v in list(plate_to_version.values())[:-9])) 487 | _ver = '舞' 488 | elif version in ['熊', '华', '華']: 489 | ver = [plate_to_version['熊']] 490 | _ver = '熊&华' 491 | elif version in ['爽', '煌']: 492 | ver = [plate_to_version['爽']] 493 | _ver = '爽&煌' 494 | elif version in ['宙', '星']: 495 | ver = [plate_to_version['宙']] 496 | _ver = '宙&星' 497 | elif version in ['祭', '祝']: 498 | ver = [plate_to_version['祭']] 499 | _ver = '祭&祝' 500 | elif version in ['双', '宴']: 501 | ver = [plate_to_version['双']] 502 | _ver = '双&宴' 503 | else: 504 | ver = [plate_to_version[version]] 505 | _ver = version 506 | 507 | try: 508 | verlist = await maiApi.query_user_plate(qqid=qqid, username=username, version=ver) 509 | except (UserNotFoundError, UserNotExistsError, UserDisabledQueryError) as e: 510 | return str(e) 511 | 512 | if plan in ['将', '者']: 513 | achievement = 100 if plan == '将' else 80 514 | callable_: Condition = lambda x: x.achievements < achievement 515 | elif plan in ['極', '极']: 516 | callable_: Condition = lambda x: not x.fc 517 | elif plan == '舞舞': 518 | callable_: Condition = lambda x: x.fs not in ['fsd', 'fsdp'] 519 | elif plan == '神': 520 | callable_: Condition = lambda x: x.fc not in ['ap', 'app'] 521 | else: 522 | raise ValueError 523 | 524 | unfinished_model_list: Filter = ([], [], [], [], []) 525 | unfinished: List[Tuple[int, int]] = [] 526 | played: List[Tuple[int, int]] = [] 527 | remaster: List[int] = [] 528 | 529 | # 已游玩未完成曲目 530 | plate_id_list = mai.total_plate_id_list[_ver] 531 | if version in ['舞', '霸']: 532 | remaster = mai.total_plate_id_list['舞ReMASTER'] 533 | for music in verlist: 534 | if music.song_id not in plate_id_list: 535 | continue 536 | if music.level_index == 4 and music.song_id not in remaster: 537 | continue 538 | if callable_(music): 539 | unfinished.append((music.song_id, music.level_index)) 540 | played.append((music.song_id, music.level_index)) 541 | else: 542 | for music in verlist: 543 | if music.song_id not in plate_id_list: 544 | continue 545 | if callable_(music): 546 | unfinished.append((music.song_id, music.level_index)) 547 | played.append((music.song_id, music.level_index)) 548 | 549 | # 未游玩未完成曲目 550 | for music in mai.total_list: 551 | if int(music.id) not in plate_id_list: 552 | continue 553 | info = PlayInfoDefault( 554 | achievements=0, 555 | level='', 556 | level_index=0, 557 | title=music.title, 558 | type=music.type, 559 | id=int(music.id) 560 | ) 561 | range_ = range(5 if version in ['舞', '霸'] and int(music.id) in remaster else 4) 562 | for level_index in range_: 563 | if (m := (info.song_id, level_index)) not in played or m in unfinished: 564 | _info = info.model_copy() 565 | _info.level = music.level[level_index] 566 | _info.ds = music.ds[level_index] 567 | _info.level_index = level_index 568 | unfinished_model_list[level_index].append(_info) 569 | 570 | basic, advanced, expert, master, re_master = unfinished_model_list 571 | 572 | ramain = basic + advanced + expert + master + re_master 573 | ramain.sort(key=lambda x: x.ds, reverse=True) 574 | difficult = [_m for _m in ramain if _m.ds > 13.6] 575 | 576 | appellation = username if username else '您' 577 | result = dedent(f'''\ 578 | {appellation}的「{version}{plan}」剩余进度如下: 579 | Basic剩余「{len(basic)}」首 580 | Advanced剩余「{len(advanced)}」首 581 | Expert剩余「{len(expert)}」首 582 | Master剩余「{len(master)}」首 583 | ''') 584 | if version in ['舞', '霸']: 585 | result += f'Re:Master剩余「{len(re_master)}」首\n' 586 | 587 | if len(difficult) > 0: 588 | if len(difficult) < 60: 589 | result += '剩余定数大于13.6的曲目:\n' 590 | result = plate_message(result, plan, difficult, played) 591 | else: 592 | result += f'还有{len(difficult)}首大于13.6定数的曲目,加油推分捏!\n' 593 | elif len(ramain) > 0: 594 | if len(ramain) < 60: 595 | result += '剩余曲目:\n' 596 | result = plate_message(result, plan, ramain, played) 597 | else: 598 | result += '已经没有定数大于13.6的曲目了,加油清谱捏!\n' 599 | else: 600 | result = f'已经没有剩余的的曲目了,恭喜{appellation}完成「{version}{plan}」!' 601 | return result 602 | 603 | 604 | def calc(data: dict) -> Union[PlayInfoDefault, PlayInfoDev]: 605 | if not maiApi.token: 606 | _m = mai.total_list.by_id(data['id']) 607 | ds: float = _m.ds[data['level_index']] 608 | a: float = data['achievements'] 609 | ra, rate = computeRa(ds, a, israte=True) 610 | info = PlayInfoDefault(**data, ds=ds, ra=ra, rate=rate) 611 | else: 612 | info = PlayInfoDev(**data) 613 | return info 614 | 615 | 616 | async def level_process_data( 617 | qqid: int, 618 | username: Optional[str], 619 | level: str, 620 | plan: str, 621 | category: str = 'default', 622 | page: int = 1 623 | ) -> Union[MessageSegment, str]: 624 | """ 625 | 查看谱面等级进度 626 | 627 | Params: 628 | `qqid`: 用户QQ 629 | `username`: 查分器用户名 630 | `level`: 定数 631 | `plan`: 评价等级 632 | Returns: 633 | `Union[MessageSegment, str]` 634 | """ 635 | try: 636 | if maiApi.token: 637 | devobj = await maiApi.query_user_get_dev(qqid=qqid, username=username) 638 | obj = devobj.records 639 | else: 640 | version = list(set(_v for _v in list(plate_to_version.values()))) 641 | obj = await maiApi.query_user_plate(qqid=qqid, username=username, version=version) 642 | music = mai.total_list.by_plan(level) 643 | 644 | planlist = [0, 0, 0] 645 | plannum = 0 646 | if plan.lower() in scoreRank: 647 | plannum = 0 648 | planlist[0] = achievementList[scoreRank.index(plan.lower()) - 1] 649 | elif plan.lower() in comboRank: 650 | plannum = 1 651 | planlist[1] = comboRank.index(plan.lower()) 652 | elif plan.lower() in syncRank: 653 | plannum = 2 654 | planlist[2] = syncRank.index(plan.lower()) 655 | else: 656 | raise 657 | 658 | for _d in obj: 659 | if isinstance(_d, PlayInfoDefault): 660 | _m = mai.total_list.by_id(_d.song_id) 661 | ds: float = _m.ds[_d.level_index] 662 | a: float = _d.achievements 663 | ra, rate = computeRa(ds, a, israte=True) 664 | _d.ra = ra 665 | _d.rate = rate 666 | if (song_id := str(_d.song_id)) in music and _d.level == level: 667 | if isinstance(music[song_id], Dict): 668 | music[song_id][_d.level_index] = PlanInfo() 669 | _p = music[song_id][_d.level_index] 670 | else: 671 | music[song_id] = PlanInfo() 672 | _p = music[song_id] 673 | 674 | if (plannum == 0 and _d.achievements >= planlist[plannum]) or \ 675 | (plannum == 1 and _d.fc and combo_rank.index(_d.fc) >= planlist[plannum]) or \ 676 | (plannum == 2 and _d.fs and (sync_rank.index(_d.fs) >= planlist[plannum] if _d.fs and _d.fs in sync_rank else sync_rank_p.index(_d.fs) >= planlist[plannum])): 677 | _p.completed = _d 678 | else: 679 | _p.unfinished = _d 680 | 681 | notplayed: List[RaMusic] = [] 682 | completed: Union[List[PlayInfoDefault], List[PlayInfoDev]] = [] 683 | unfinished: Union[List[PlayInfoDefault], List[PlayInfoDev]] = [] 684 | for m in music: 685 | play = music[m] 686 | if isinstance(play, Dict): 687 | for index, p in play.items(): 688 | if isinstance(p, RaMusic): 689 | notplayed.append(p) 690 | elif p.completed: 691 | completed.append(p.completed) 692 | elif p.unfinished: 693 | unfinished.append(p.unfinished) 694 | elif isinstance(play, PlanInfo): 695 | if play.completed: 696 | completed.append(play.completed) 697 | if play.unfinished: 698 | unfinished.append(play.unfinished) 699 | else: 700 | notplayed.append(play) 701 | completed.sort(key=lambda x: x.achievements if plannum == 0 else x.fc if plannum == 1 else x.fs, reverse=True) 702 | unfinished.sort(key=lambda x: x.achievements if plannum == 0 else x.fc if plannum == 1 else x.fs, reverse=True) 703 | notplayed.sort(key=lambda x: x.ds, reverse=True) 704 | 705 | if category == 'default': 706 | completed_len = 60 if len(unfinished) == 0 and len(notplayed) == 0 else 30 707 | clen = len(completed[:completed_len]) 708 | completed_y = (clen // 5 + (0 if clen % 5 == 0 else 1)) * 109 + 140 709 | ulen = len(unfinished[:30]) 710 | unfinished_y = (ulen // 5 + (0 if ulen % 5 == 0 else 1)) * 109 + 140 711 | nlen = len(notplayed[:100]) 712 | notstarted_y = (nlen // 20 + (0 if nlen % 20 == 0 else 1)) * 65 + 140 713 | image = tricolor_gradient(1400, 150 + completed_y + unfinished_y + notstarted_y) 714 | dp = DrawScore(image) 715 | im = dp.draw_plan(completed, completed_y, unfinished, unfinished_y, notplayed, plan, completed_len) 716 | elif category == 'completed' or category == 'unfinished': 717 | data = completed if category == 'completed' else unfinished 718 | lendata = len(data) 719 | end_page_num = lendata // 80 + 1 720 | if page > end_page_num: 721 | return f'超出页数,您的成绩共计「{end_page_num}」页,请重新输入' 722 | topage = len(data[(page - 1) * 80: page * 80]) 723 | plc = (topage // 5 + (0 if topage % 5 == 0 else 1)) * 109 724 | image = tricolor_gradient(1400, 240 + plc + 120) 725 | dp = DrawScore(image) 726 | im = dp.draw_category(category, data, page, end_page_num) 727 | else: 728 | lennotstarted = len(notplayed) 729 | pln = (lennotstarted // 20 + (0 if lennotstarted % 20 == 0 else 1)) * 65 730 | image = tricolor_gradient(1400, 240 + pln + 120) 731 | dp = DrawScore(image) 732 | im = dp.draw_category(category, notplayed) 733 | 734 | msg = MessageSegment.image(image_to_base64(im)) 735 | except (UserNotFoundError, UserNotExistsError, UserDisabledQueryError) as e: 736 | msg = str(e) 737 | except Exception as e: 738 | log.error(traceback.format_exc()) 739 | msg = f'未知错误:{type(e)}\n请联系Bot管理员' 740 | return msg 741 | 742 | 743 | async def level_achievement_list_data( 744 | qqid: int, 745 | username: Optional[str], 746 | rating: Union[str, float], 747 | page: int = 1 748 | ) -> Union[MessageSegment, str]: 749 | """ 750 | 查看分数列表 751 | 752 | Params: 753 | `qqid` : 用户QQ 754 | `username` : 查分器用户名 755 | `rating` : 定数 756 | `page` : 页数 757 | `nickname` : 用户昵称 758 | Returns: 759 | `Union[MessageSegment, str] 760 | """ 761 | try: 762 | data: Union[List[PlayInfoDefault], List[PlayInfoDev]] = [] 763 | if maiApi.token: 764 | obj = await maiApi.query_user_get_dev(qqid=qqid, username=username) 765 | data = obj.records 766 | else: 767 | version = list(set(_v for _v in list(plate_to_version.values()))) 768 | obj = await maiApi.query_user_plate(qqid=qqid, username=username, version=version) 769 | for _d in obj: 770 | music = mai.total_list.by_id(_d.song_id) 771 | _d.ds = music.ds[_d.level_index] 772 | _d.ra, _d.rate = computeRa(_d.ds, _d.achievements, israte=True) 773 | data = obj 774 | 775 | if isinstance(rating, str): 776 | newdata = sorted(list(filter(lambda x: x.level == rating, data)), key=lambda z: z.achievements, reverse=True) 777 | else: 778 | newdata = sorted(list(filter(lambda x: x.ds == rating, data)), key=lambda z: z.achievements, reverse=True) 779 | 780 | lendata = len(newdata) 781 | end_page_num = lendata // 80 + 1 782 | if page > end_page_num: 783 | return f'超出页数,您的成绩共计「{end_page_num}」页,请重新输入' 784 | 785 | topage = len(newdata[(page - 1) * 80: page * 80]) 786 | line = topage // 5 + (0 if topage % 5 == 0 else 1) 787 | if page < end_page_num: 788 | plc = line * 109 + 140 * 4 789 | elif topage <= 20: 790 | plc = 4 * 109 + 140 791 | elif topage <= 40: 792 | plc = line * 109 + 140 * 2 793 | elif topage <= 60: 794 | plc = line * 109 + 140 * 3 795 | else: 796 | plc = line * 109 + 140 * 4 797 | 798 | image = tricolor_gradient(1400, 150 + plc) 799 | 800 | sc = DrawScore(image) 801 | im = sc.draw_scorelist(rating, newdata, page, end_page_num) 802 | msg = MessageSegment.image(image_to_base64(im)) 803 | except (UserNotFoundError, UserNotExistsError, UserDisabledQueryError) as e: 804 | msg = str(e) 805 | except Exception as e: 806 | log.error(traceback.format_exc()) 807 | msg = f'未知错误:{type(e)}\n请联系Bot管理员' 808 | return msg 809 | 810 | 811 | async def rating_ranking_data(name: str, page: int) -> Union[MessageSegment, str]: 812 | """ 813 | 查看查分器排行榜 814 | 815 | Params: 816 | `name`: 指定用户名 817 | `page`: 页数 818 | Returns: 819 | `Union[MessageSegment, str]` 820 | """ 821 | try: 822 | rank_data = await maiApi.rating_ranking() 823 | 824 | _time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) 825 | if name != '': 826 | if name in [r.username.lower() for r in rank_data]: 827 | rank_index = [r.username.lower() for r in rank_data].index(name) + 1 828 | nickname = rank_data[rank_index - 1].username 829 | data = f'截止至 {_time}\n玩家 {nickname} 在查分器已注册用户ra排行第{rank_index}' 830 | else: 831 | data = '未找到该玩家' 832 | else: 833 | user_num = len(rank_data) 834 | msg = f'截止至 {_time},查分器已注册用户ra排行:\n' 835 | if page * 50 > user_num: 836 | page = user_num // 50 + 1 837 | end = page * 50 if page * 50 < user_num else user_num 838 | for i, ranker in enumerate(rank_data[(page - 1) * 50:end]): 839 | msg += f'No.{i + 1 + (page - 1) * 50:02d}.「{ranker.ra}」 {ranker.username} \n' 840 | msg += f'第「{page}」页,共「{user_num // 50 + 1}」页' 841 | data = MessageSegment.image(image_to_base64(text_to_image(msg.strip()))) 842 | except Exception as e: 843 | log.error(traceback.format_exc()) 844 | data = f'未知错误:{type(e)}\n请联系Bot管理员' 845 | return data -------------------------------------------------------------------------------- /libraries/maimaidx_update_table.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import time 3 | 4 | import aiofiles 5 | 6 | from .image import tricolor_gradient 7 | from .maimai_best_50 import * 8 | from .maimaidx_music import Music, mai 9 | 10 | 11 | async def update_rating_table() -> str: 12 | """更新定数表""" 13 | try: 14 | dx = Image.open(maimaidir / 'DX.png').convert('RGBA').resize((44, 16)) 15 | diff = [Image.new('RGBA', (75, 16), color) for color in ScoreBaseImage.bg_color] 16 | atime = 0 17 | for lv in levelList[5:]: 18 | _otime = time.time() 19 | picname = ratingdir / f'{lv}.png' 20 | lvlist = mai.total_level_data[lv] 21 | lines = 0 22 | for _lv in lvlist: 23 | musicnum = len(lvlist[_lv]) 24 | if musicnum == 0: 25 | r = 1 26 | else: 27 | remainder = musicnum % 14 28 | r = (musicnum // 14) + (1 if remainder else 0) 29 | lines += r 30 | 31 | if '+' in lv: 32 | f = 3 33 | elif lv == '6': 34 | f = 10 35 | else: 36 | f = 7 37 | 38 | linesheight = 85 * lines 39 | """ 40 | `85` 为曲绘高度 `80` + 间隔 `5` 41 | `lines` 为行数 42 | """ 43 | 44 | width, height = 1400, 325 + f * 20 + linesheight 45 | """ 46 | `325` 为顶部文字和底部图片高度 + 上下间隔高度 47 | `f * 20` 为等级数量 `f` * 等级间隔 `20` 48 | `linesheight` 为各等级曲绘和间隔总和高度 49 | """ 50 | 51 | im = tricolor_gradient(width, height) 52 | 53 | im.alpha_composite(ScoreBaseImage.aurora_bg) 54 | im.alpha_composite(ScoreBaseImage.shines_bg, (34, 0)) 55 | im.alpha_composite(ScoreBaseImage.rainbow_bg, (319, height - 643)) 56 | im.alpha_composite(ScoreBaseImage.rainbow_bottom_bg, (100, height - 343)) 57 | for h in range((height // 358) + 1): 58 | im.alpha_composite(ScoreBaseImage.pattern_bg, (0, (358 + 7) * h)) 59 | 60 | dr = ImageDraw.Draw(im) 61 | sy = DrawText(dr, SIYUAN) 62 | ts = DrawText(dr, TBFONT) 63 | im.alpha_composite(Image.open(maimaidir / 'design.png'), (200, height - 113)) 64 | sy.draw( 65 | 700, 66 | height - 70, 67 | 22, 68 | f'Designed by Yuri-YuzuChaN & BlueDeer233. Generated by {BOTNAME} BOT', 69 | ScoreBaseImage.text_color, 70 | 'mm' 71 | ) 72 | y = 100 73 | for _lv in lvlist: 74 | x = 160 75 | y += 20 76 | im.alpha_composite( 77 | Image.open(maimaidir / 'UI_CMN_Chara_Level_S_01.png').resize((80, 80)), (50, y + 80) 78 | ) 79 | ts.draw(88, y + 120, 35, _lv, anchor='mm') 80 | for num, music in enumerate(lvlist[_lv]): 81 | if num % 14 == 0: 82 | x = 160 83 | y += 85 84 | else: 85 | x += 85 86 | cover = Image.open(music_picture(music.id)).resize((75, 75)) 87 | im.alpha_composite(cover, (x, y)) 88 | if music.type == 'DX': 89 | im.alpha_composite(dx, (x + 31, y)) 90 | im.alpha_composite(diff[int(music.lv)], (x, y + 59)) 91 | ts.draw(x + 37, y + 67, 13, music.id, ScoreBaseImage.t_color[int(music.lv)], 'mm') 92 | if not lvlist[_lv]: 93 | y += 85 94 | 95 | by = BytesIO() 96 | im.save(by, 'PNG') 97 | async with aiofiles.open(picname, 'wb') as f: 98 | await f.write(by.getbuffer()) 99 | _ntime = int(time.time() - _otime) 100 | atime += _ntime 101 | log.info(f'lv.{lv} 定数表更新完成,耗时:{_ntime}s') 102 | log.info(f'定数表更新完成,共耗时{atime}s') 103 | return f'定数表更新完成,共耗时{atime}s' 104 | except Exception as e: 105 | log.error(traceback.format_exc()) 106 | return f'定数表更新失败,Error: {e}' 107 | 108 | 109 | async def update_plate_table() -> str: 110 | """更新完成表""" 111 | try: 112 | version = list(_ for _ in plate_to_version.keys())[1:] 113 | # version.append('霸') 114 | # version.append('舞') 115 | id_bg = Image.new('RGBA', (100, 20), (124, 129, 255, 255)) 116 | rlv: Dict[str, List[Music]] = {} 117 | for _ in list(reversed(levelList)): 118 | rlv[_] = [] 119 | for _v in version: 120 | if _v in platecn: 121 | _v = platecn[_v] 122 | if _v in ['熊', '华', '華']: 123 | _ver = '熊&华' 124 | elif _v in ['爽', '煌']: 125 | _ver = '爽&煌' 126 | elif _v in ['宙', '星']: 127 | _ver = '宙&星' 128 | elif _v in ['祭', '祝']: 129 | _ver = '祭&祝' 130 | elif _v in ['双', '宴']: 131 | _ver = '双&宴' 132 | else: 133 | _ver = _v 134 | 135 | music_id_list = mai.total_plate_id_list[_ver] 136 | music = mai.total_list.by_id_list(music_id_list) 137 | ralv = copy.deepcopy(rlv) 138 | 139 | for m in music: 140 | ralv[m.level[3]].append(m) 141 | 142 | lines = 0 143 | interval = 0 144 | for _ in ralv: 145 | musicnum = len(ralv[_]) 146 | if musicnum == 0: 147 | continue 148 | interval += 1 149 | remainder = musicnum % 10 150 | lines += (musicnum // 10) + (1 if remainder else 0) 151 | 152 | linesheight = 115 * lines + (interval - 1) * 15 153 | """ 154 | `linesheight`: 各等级曲绘和间隔总和高度 155 | 156 | - `115` 为曲绘高度 `100` + 间隔 `15` 157 | - `lines` 为行数 158 | - `interval` 为各等级间隔行数 159 | - `(interval - 1) * 15` 为各等级间隔高度,各等级之间间隔为 `30`,所以只加 `15` 160 | """ 161 | width, height = 1400, 150 + linesheight + 360 162 | """ 163 | `150` 为底部图片 `design` 高度 + 上下间隔高度 164 | `linesheight` 为各等级曲绘和间隔总和高度 165 | `360` 为顶部图片 `` 高度 + 上下间隔高度 166 | """ 167 | 168 | im = tricolor_gradient(width, height) 169 | 170 | im.alpha_composite(ScoreBaseImage.aurora_bg) 171 | im.alpha_composite(ScoreBaseImage.shines_bg, (34, 0)) 172 | im.alpha_composite(ScoreBaseImage.rainbow_bg, (319, height - 643)) 173 | im.alpha_composite(ScoreBaseImage.rainbow_bottom_bg, (100, height - 343)) 174 | for h in range((height // 358) + 1): 175 | im.alpha_composite(ScoreBaseImage.pattern_bg, (0, (358 + 7) * h)) 176 | 177 | dr = ImageDraw.Draw(im) 178 | ts = DrawText(dr, TBFONT) 179 | sy = DrawText(dr, SIYUAN) 180 | im.alpha_composite(Image.open(maimaidir / 'design.png'), (200, height - 113)) 181 | sy.draw( 182 | 700, 183 | height - 70, 184 | 22, 185 | f'Designed by Yuri-YuzuChaN & BlueDeer233. Generated by {BOTNAME} BOT', 186 | ScoreBaseImage.text_color, 187 | 'mm' 188 | ) 189 | y = 245 190 | for r in ralv: 191 | if _v in ['霸', '舞']: 192 | ralv[r].sort(key=lambda x: x.ds[-1], reverse=True) 193 | else: 194 | ralv[r].sort(key=lambda x: x.ds[3], reverse=True) 195 | if ralv[r]: 196 | y += 15 197 | im.alpha_composite( 198 | Image.open(maimaidir / 'UI_CMN_Chara_Level_S_01.png'), (65, y + 115) 199 | ) 200 | ts.draw(113, y + 164, 35, r, anchor='mm') 201 | x = 200 202 | for num, music in enumerate(ralv[r]): 203 | if num % 10 == 0: 204 | x = 200 205 | y += 115 206 | else: 207 | x += 115 208 | cover = music_picture(music.id) 209 | im.alpha_composite(Image.open(cover).resize((100, 100)), (x, y)) 210 | im.alpha_composite(id_bg, (x, y + 80)) 211 | ts.draw(x + 50, y + 88, 20, music.id, anchor='mm') 212 | 213 | by = BytesIO() 214 | im.save(by, 'PNG') 215 | async with aiofiles.open(platedir / f'{_v}.png', 'wb') as f: 216 | await f.write(by.getbuffer()) 217 | log.info(f'{_v}代牌子更新完成') 218 | return f'完成表更新完成' 219 | except Exception as e: 220 | log.error(traceback.format_exc()) 221 | return f'完成表更新失败,Error: {e}' -------------------------------------------------------------------------------- /libraries/tool.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import time 4 | from pathlib import Path 5 | from typing import Any, Union 6 | 7 | import aiofiles 8 | from playwright.async_api import async_playwright 9 | 10 | from .. import SNAPSHOT_JS, pie_html_file 11 | 12 | 13 | def qqhash(qq: int): 14 | days = int(time.strftime("%d", time.localtime(time.time()))) + 31 * int( 15 | time.strftime("%m", time.localtime(time.time()))) + 77 16 | return (days * qq) >> 8 17 | 18 | 19 | async def openfile(file: Path) -> Union[dict, list]: 20 | async with aiofiles.open(file, 'r', encoding='utf-8') as f: 21 | data = json.loads(await f.read()) 22 | return data 23 | 24 | 25 | async def writefile(file: Path, data: Any) -> bool: 26 | async with aiofiles.open(file, 'w', encoding='utf-8') as f: 27 | await f.write(json.dumps(data, ensure_ascii=False, indent=4)) 28 | return True 29 | 30 | async def run_chrome_to_base64() -> str: 31 | async with async_playwright() as p: 32 | browers = await p.chromium.launch(headless=True) 33 | page = await browers.new_page(java_script_enabled=True) 34 | await page.goto('file://' + str(pie_html_file)) 35 | await asyncio.sleep(2) 36 | 37 | content: str = await page.evaluate(SNAPSHOT_JS) 38 | await browers.close() 39 | 40 | content_array = content.split(',') 41 | if len(content_array) != 2: 42 | raise OSError(content_array) 43 | 44 | return 'base64://' + content_array[-1] -------------------------------------------------------------------------------- /maimai.py: -------------------------------------------------------------------------------- 1 | from nonebot import on_startup 2 | 3 | from .command import * 4 | from .libraries.maimai_best_50 import ScoreBaseImage 5 | from .libraries.maimaidx_api_data import maiApi 6 | from .libraries.maimaidx_music import mai 7 | 8 | 9 | @on_startup 10 | async def _(): 11 | """ 12 | bot启动时开始获取所有数据 13 | """ 14 | if maiApi.config.maimaidxproberproxy: 15 | log.info('正在使用代理服务器访问查分器') 16 | if maiApi.config.maimaidxaliasproxy: 17 | log.info('正在使用代理服务器访问别名服务器') 18 | maiApi.load_token_proxy() 19 | log.info('正在获取maimai所有曲目信息') 20 | await mai.get_music() 21 | log.info('正在获取maimai牌子数据') 22 | await mai.get_plate_json() 23 | log.info('正在获取maimai所有曲目别名信息') 24 | await mai.get_music_alias() 25 | mai.guess() 26 | log.info('maimai数据获取完成') 27 | 28 | if maiApi.config.saveinmem: 29 | ScoreBaseImage._load_image() 30 | log.info('已将图片保存在内存中') 31 | 32 | if not list(ratingdir.iterdir()): 33 | log.warning( 34 | '注意!注意!检测到定数表文件夹为空!' 35 | '可能导致「定数表」「完成表」指令无法使用,' 36 | '请及时私聊BOT使用指令「更新定数表」进行生成。' 37 | ) 38 | plate_list = [name for name in list(plate_to_version.keys())[1:]] 39 | platedir_list = [f.name.split('.')[0] for f in platedir.iterdir()] 40 | notin = set(plate_list) - set(platedir_list) 41 | if notin: 42 | anyname = ','.join(notin) 43 | log.warning( 44 | f'注意!注意!未检测到牌子文件夹中的牌子:{anyname},' 45 | '可能导致这些牌子的「完成表」指令无法使用,' 46 | '请及时私聊BOT使用指令「更新完成表」进行生成。' 47 | ) -------------------------------------------------------------------------------- /maimai_arcade.py: -------------------------------------------------------------------------------- 1 | from re import Match 2 | 3 | from nonebot import NoneBot, on_startup 4 | 5 | from hoshino import Service, priv 6 | from hoshino.typing import CQEvent, MessageSegment 7 | 8 | from .libraries.image import image_to_base64, text_to_image 9 | from .libraries.maimaidx_arcade import * 10 | 11 | sv_help= """排卡指令如下: 12 | 添加机厅 <店名> <地址> <机台数量> 添加机厅信息 13 | 删除机厅 <店名> 删除机厅信息 14 | 修改机厅 <店名> 数量 <数量> ... 修改机厅信息 15 | 添加机厅别名 <店名> <别名> 16 | 订阅机厅 <店名> 订阅机厅,简化后续指令 17 | 查看订阅 查看群组订阅机厅的信息 18 | 取消订阅机厅 <店名> 取消群组机厅订阅 19 | 查找机厅,查询机厅,机厅查找,机厅查询 <关键词> 查询对应机厅信息 20 | <店名/别名>人数设置,设定,=,增加,加,+,减少,减,-<人数> 操作排卡人数 21 | <店名/别名>有多少人,有几人,有几卡,几人,几卡 查看排卡人数 22 | 机厅几人 查看已订阅机厅排卡人数""" 23 | 24 | SV_HELP = '请使用 帮助maimaiDX排卡 查看帮助' 25 | sv_arcade = Service('maimaiDX排卡', manage_priv=priv.ADMIN, enable_on_default=False, help_=SV_HELP) 26 | 27 | 28 | @on_startup 29 | async def _(): 30 | loga.info('正在获取maimai所有机厅信息') 31 | await arcade.getArcade() 32 | loga.info('maimai机厅数据获取完成') 33 | 34 | 35 | @sv_arcade.on_fullmatch(['帮助maimaiDX排卡', '帮助maimaidx排卡']) 36 | async def dx_arcade_help(bot: NoneBot, ev: CQEvent): 37 | await bot.send(ev, MessageSegment.image(image_to_base64(text_to_image(sv_help))), at_sender=True) 38 | 39 | 40 | @sv_arcade.on_prefix(['添加机厅', '新增机厅']) 41 | async def add_arcade(bot: NoneBot, ev: CQEvent): 42 | args: List[str] = ev.message.extract_plain_text().strip().split() 43 | if not priv.check_priv(ev, priv.SUPERUSER): 44 | msg = '仅允许主人添加机厅\n请使用 来杯咖啡+内容 联系主人' 45 | elif len(args) == 1 and args[0] in ['帮助', 'help', '指令帮助']: 46 | msg = '添加机厅指令格式:添加机厅 <店名> <位置> <机台数量> <别称1> <别称2> ...' 47 | elif len(args) >= 3: 48 | if not args[2].isdigit(): 49 | msg = '格式错误:添加机厅 <店名> <地址> <机台数量> [别称1] [别称2] ...' 50 | else: 51 | if not arcade.total.search_fullname(args[0]): 52 | aid = sorted(arcade.idList, reverse=True) 53 | if (sid := aid[0]) >= 10000: 54 | sid += 1 55 | else: 56 | sid = 10000 57 | arcade_dict = { 58 | 'name': args[0], 59 | 'location': args[1], 60 | 'province': '', 61 | 'mall': '', 62 | 'num': int(args[2]) if len(args) > 2 else 1, 63 | 'id': str(sid), 64 | 'alias': args[3:] if len(args) > 3 else [], 65 | 'group': [], 66 | 'person': 0, 67 | 'by': '', 68 | 'time': '' 69 | } 70 | arcade.total.add_arcade(arcade_dict) 71 | await arcade.total.save_arcade() 72 | msg = f'机厅:{args[0]} 添加成功' 73 | else: 74 | msg = f'机厅:{args[0]} 已存在,无法添加机厅' 75 | else: 76 | msg = '格式错误:添加机厅 <店名> <地址> <机台数量> [别称1] [别称2] ...' 77 | 78 | await bot.send(ev, msg, at_sender=True) 79 | 80 | 81 | @sv_arcade.on_prefix(['删除机厅', '移除机厅']) 82 | async def delele_arcade(bot: NoneBot, ev: CQEvent): 83 | name = ev.message.extract_plain_text().strip() 84 | if not priv.check_priv(ev, priv.SUPERUSER): 85 | msg = '仅允许主人删除机厅\n请使用 来杯咖啡+内容 联系主人' 86 | elif not name: 87 | msg = '格式错误:删除机厅 <店名>,店名需全名' 88 | else: 89 | if not arcade.total.search_fullname(name): 90 | msg = f'未找到机厅:{name}' 91 | else: 92 | arcade.total.del_arcade(name) 93 | await arcade.total.save_arcade() 94 | msg = f'机厅:{name} 删除成功' 95 | await bot.send(ev, msg, at_sender=True) 96 | 97 | 98 | @sv_arcade.on_prefix(['添加机厅别名', '删除机厅别名']) 99 | async def _(bot: NoneBot, ev: CQEvent): 100 | args: List[str] = ev.message.extract_plain_text().strip().split() 101 | a = True if ev.prefix == '添加机厅别名' else False 102 | if len(args) != 2: 103 | msg = '格式错误:添加/删除机厅别名 <店名> <别名>' 104 | elif not args[0].isdigit() and len(_arc := arcade.total.search_fullname(args[0])) > 1: 105 | msg = '找到多个相同店名的机厅,请使用店铺ID更改机厅别名\n' + '\n'.join([ f'{_.id}:{_.name}' for _ in _arc ]) 106 | else: 107 | msg = await update_alias(args[0], args[1], a) 108 | await bot.send(ev, msg, at_sender=True) 109 | 110 | 111 | @sv_arcade.on_prefix(['修改机厅', '编辑机厅']) 112 | async def modify_arcade(bot: NoneBot, ev: CQEvent): 113 | args: List[str] = ev.message.extract_plain_text().strip().split() 114 | if not priv.check_priv(ev, priv.ADMIN): 115 | msg = '仅允许管理员修改机厅信息' 116 | elif not args[0].isdigit() and len(_arc := arcade.total.search_fullname(args[0])) > 1: 117 | msg = '找到多个相同店名的机厅,请使用店铺ID修改机厅\n' + '\n'.join([ f'{_.id}:{_.name}' for _ in _arc ]) 118 | elif args[1] == '数量' and len(args) == 3 and args[2].isdigit(): 119 | msg = await updata_arcade(args[0], args[2]) 120 | else: 121 | msg = '格式错误:修改机厅 <店名> [数量] <数量>' 122 | 123 | await bot.send(ev, msg, at_sender=True) 124 | 125 | 126 | @sv_arcade.on_rex(r'^(订阅机厅|取消订阅机厅|取消订阅)\s(.+)', normalize=False) 127 | async def _(bot: NoneBot, ev: CQEvent): 128 | match: Match[str] = ev['match'] 129 | gid = ev.group_id 130 | sub = True if match.group(1) == '订阅机厅' else False 131 | name = match.group(2) 132 | if not priv.check_priv(ev, priv.ADMIN): 133 | msg = '仅允许管理员订阅和取消订阅' 134 | elif not name.isdigit() and len(_arc := arcade.total.search_fullname(name)) > 1: 135 | msg = f'找到多个相同店名的机厅,请使用店铺ID订阅\n' + '\n'.join([ f'{_.id}:{_.name}' for _ in _arc ]) 136 | else: 137 | msg = await subscribe(gid, name, sub) 138 | 139 | await bot.send(ev, msg, at_sender=True) 140 | 141 | 142 | @sv_arcade.on_fullmatch(['查看订阅', '查看订阅机厅']) 143 | async def check_subscribe(bot: NoneBot, ev: CQEvent): 144 | gid = int(ev.group_id) 145 | arcadeList = arcade.total.group_subscribe_arcade(group_id=gid) 146 | if arcadeList: 147 | result = [f'群{gid}订阅机厅信息如下:'] 148 | for a in arcadeList: 149 | alias = "\n ".join(a.alias) 150 | result.append(f'''店名:{a.name} 151 | - 地址:{a.location} 152 | - 数量:{a.num} 153 | - 别名:{alias}''') 154 | msg = '\n'.join(result) 155 | else: 156 | msg = '该群未订阅任何机厅' 157 | await bot.send(ev, msg, at_sender=True) 158 | 159 | 160 | @sv_arcade.on_prefix(['查找机厅', '查询机厅', '机厅查找', '机厅查询', '搜素机厅', '机厅搜素']) 161 | async def search_arcade(bot: NoneBot, ev: CQEvent): 162 | name: str = ev.message.extract_plain_text().strip() 163 | if not name: 164 | await bot.finish(ev, '格式错误:查找机厅 <关键词>', at_sender=True) 165 | elif arcade_list := arcade.total.search_name(name): 166 | result = ['为您找到以下机厅:\n'] 167 | for a in arcade_list: 168 | result.append(f'''店名:{a.name} 169 | - 地址:{a.location} 170 | - ID:{a.id} 171 | - 数量:{a.num}''') 172 | if len(arcade_list) < 5: 173 | await bot.send(ev, '\n==========\n'.join(result), at_sender=True) 174 | else: 175 | await bot.send(ev, MessageSegment.image(image_to_base64(text_to_image('\n'.join(result)))), at_sender=True) 176 | else: 177 | await bot.send(ev, '没有这样的机厅哦', at_sender=True) 178 | 179 | 180 | @sv_arcade.on_rex(r'^(.+)?\s?(设置|设定|=|=|增加|添加|加|+|\+|减少|降低|减|-|-)\s?([0-9]+|+|\+|-|-)(人|卡)?$') 181 | async def arcade_person(bot: NoneBot, ev: CQEvent): 182 | try: 183 | match: Match[str] = ev['match'] 184 | gid = ev.group_id 185 | nickname = ev.sender['nickname'] 186 | if not match.group(3).isdigit() and match.group(3) not in ['+', '+', '-', '-']: 187 | await bot.finish(ev, '请输入正确的数字', at_sender=True) 188 | arcade_list = arcade.total.group_subscribe_arcade(group_id=gid) 189 | if not arcade_list: 190 | await bot.finish(ev, '该群未订阅机厅,无法更改机厅人数', at_sender=True) 191 | value = match.group(2) 192 | person = int(match.group(3)) 193 | if match.group(1): 194 | if '人数' in match.group(1) or '卡' in match.group(1): 195 | arcadeName = match.group(1)[:-2] if '人数' in match.group(1) else match.group(1)[:-1] 196 | else: 197 | arcadeName = match.group(1) 198 | _arcade = [] 199 | for _a in arcade_list: 200 | if arcadeName == _a.name: 201 | _arcade.append(_a) 202 | break 203 | if arcadeName in _a.alias: 204 | _arcade.append(_a) 205 | break 206 | if not _arcade: 207 | msg = '已订阅的机厅中未找到该机厅' 208 | else: 209 | msg = await update_person(_arcade, nickname, value, person) 210 | 211 | await bot.send(ev, msg, at_sender=True) 212 | except: 213 | pass 214 | 215 | 216 | @sv_arcade.on_fullmatch(['机厅几人', 'jtj']) 217 | async def arcade_query_multiple(bot: NoneBot, ev: CQEvent): 218 | gid = ev.group_id 219 | arcade_list = arcade.total.group_subscribe_arcade(gid) 220 | if arcade_list: 221 | result = arcade.total.arcade_to_msg(arcade_list) 222 | await bot.send(ev, '\n'.join(result)) 223 | else: 224 | await bot.finish(ev, '该群未订阅任何机厅', at_sender=True) 225 | 226 | 227 | @sv_arcade.on_suffix(['有多少人', '有几人', '有几卡', '多少人', '多少卡', '几人', 'jr', '几卡']) 228 | async def arcade_query_person(bot: NoneBot, ev: CQEvent): 229 | gid = ev.group_id 230 | name = ev.message.extract_plain_text().strip().lower() 231 | result = None 232 | if name: 233 | arcade_list = arcade.total.search_name(name) 234 | if not arcade_list: 235 | await bot.finish(ev, '没有这样的机厅哦', at_sender=True) 236 | result = arcade.total.arcade_to_msg(arcade_list) 237 | await bot.send(ev, '\n'.join(result)) 238 | else: 239 | arcade_list = arcade.total.group_subscribe_arcade(gid) 240 | if arcade_list: 241 | result = arcade.total.arcade_to_msg(arcade_list) 242 | await bot.send(ev, '\n'.join(result)) 243 | else: 244 | await bot.send(ev, '该群未订阅任何机厅,请使用 订阅机厅 <名称> 指令订阅机厅', at_sender=True) 245 | 246 | 247 | @sv_arcade.scheduled_job('cron', hour='3') 248 | async def _(): 249 | try: 250 | await download_arcade_info() 251 | for _ in arcade.total: 252 | _.person = 0 253 | _.by = '自动清零' 254 | _.time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) 255 | await arcade.total.save_arcade() 256 | except: 257 | return 258 | loga.info('maimaiDX排卡数据更新完毕') -------------------------------------------------------------------------------- /maimaidxhelp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yuri-YuzuChaN/maimaiDX/6586b56da0da7041051b74e807427b496f3c9e85/maimaidxhelp.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiofiles>=23.2.1 2 | aiohttp>=3.8.5 3 | Pillow>=10.0.0 4 | pydantic>=2.4.2 5 | pyecharts>=2.0.4 6 | pytest-playwright>=0.7.0 7 | numpy>=1.24.4 -------------------------------------------------------------------------------- /static/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "maimaidxtoken": "", 3 | "maimaidxproberproxy": false, 4 | "maimaidxaliasproxy": false 5 | } -------------------------------------------------------------------------------- /static/temp_pie.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Awesome-pyecharts 6 | 7 | 8 | 9 | 10 |
11 | 454 | 455 | 456 | --------------------------------------------------------------------------------