├── .github └── workflows │ └── python-publish.yml ├── .pre-commit-config.yaml ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── __init__.py ├── nonebot_plugin_maimai ├── __init__.py ├── api.py ├── libraries │ ├── __init__.py │ ├── image.py │ ├── maimai_best_40.py │ ├── maimai_best_50.py │ ├── maimaidx_music.py │ └── tool.py └── public.py └── pyproject.toml /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | push: 13 | branches: [ nonebot2.0.0 ] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | deploy: 20 | 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | - name: Set up Python 26 | uses: actions/setup-python@v3 27 | with: 28 | python-version: '3.x' 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install build 33 | - name: Build package 34 | run: python -m build 35 | - name: Publish package 36 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 37 | with: 38 | user: __token__ 39 | password: ${{ secrets.PYPI_API_TOKEN }} 40 | 41 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_install_hook_types: [pre-commit, prepare-commit-msg] 2 | ci: 3 | autofix_commit_msg: ':rotating_light: auto fix by pre-commit hooks' 4 | autofix_prs: true 5 | autoupdate_branch: main 6 | autoupdate_schedule: monthly 7 | autoupdate_commit_msg: ':arrow_up: auto update by pre-commit hooks' 8 | 9 | repos: 10 | - repo: https://github.com/astral-sh/ruff-pre-commit 11 | rev: v0.0.280 12 | hooks: 13 | - id: ruff 14 | args: [--fix, --exit-non-zero-on-fix] 15 | 16 | # - repo: https://github.com/RobertCraigie/pyright-python 17 | # rev: v1.1.318 18 | # hooks: 19 | # - id: pyright 20 | 21 | - repo: https://github.com/psf/black 22 | rev: 23.7.0 23 | hooks: 24 | - id: black 25 | stages: [commit] 26 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-python.python", 4 | "ms-python.vscode-pylance", 5 | "ms-python.isort", 6 | "ms-python.black-formatter" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.languageServer": "Pylance", 3 | "python.analysis.typeCheckingMode": "basic", 4 | "editor.formatOnSave": true, 5 | "[python]": { 6 | "editor.defaultFormatter": "ms-python.black-formatter", 7 | "editor.formatOnSave": true, 8 | "editor.codeActionsOnSave": { 9 | "source.organizeImports": true 10 | } 11 | }, 12 | "isort.args": [ 13 | "--profile", 14 | "black" 15 | ], 16 | "python.formatting.provider": "black" 17 | } 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Diving-Fish 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 | 2 |
3 | NoneBotPluginLogo 4 |
5 |

NoneBotPluginText

6 |
7 | 8 |
9 | 10 | # nonebot_plugin_maimai 11 | 12 | _✨maimaiDX,nonebot2插件版本✨_ 13 | 14 | 15 | GitHub stars 16 | 17 | 18 | GitHub issues 19 | 20 | 21 | QQ Chat Group 22 | 23 | python 24 | NoneBot 25 |
26 | 27 | ## 说明(已更新图片素材) 28 | 29 | 从[mai-bot](https://github.com/Diving-Fish/mai-bot)适配nonebot2插件,测试环境nonebot2.1.0 30 | 31 | 修改部分: 32 | 33 | - b40/b50可以艾特人查询 34 | - static文件可以放maimai插件文件夹中,或机器人路径下/data/maimai/static 35 | - (可循)env设置 `maimai_font`,是str对象的`字体` 36 | - 新增指令`搜手元`,`搜理论`,`搜谱面确认`,后面带上搜索的对象 37 | - 新增指令检查mai资源可以初始化下载,或者强制检查mai资源强制下载覆盖 38 | 39 | 我做的适配有问题请冲我来不要打扰原作者捏,可以提iss或者[加群qq](https://jq.qq.com/?_wv=1027&k=l82tMuPG)反馈, 40 | 41 | ## env(可选) 42 | 43 | maimai_font = 'simsun.ttc' # 替换你有的字体 44 | 45 | ## 前置步骤(和原项目一样) 46 | 47 | 安装(仍选其一): 48 | 49 | pip3 install nonebot_plugin_maimai 50 | nb plugin install nonebot_plugin_maimai 51 | git clone https://github.com/Agnes4m/nonebot_plugin_maimai.git 52 | 53 | 您需要从[此链接](https://www.diving-fish.com/maibot/static.zip)下载资源文件并,并将其static文件解压到:(以下方法2选1) 54 | 55 | - pypi`nonebot_plugin_maimai`文件夹中 - 最终路径类似是/path/to/nonebot_plugin_maimai/static 56 | - 机器人目录下 - 最终路径类似是/path/to/data/maimai/static中。其中bot.py文件在/path/to位置 57 | 58 | > 资源文件仅供学习交流使用,请自觉在下载 24 小时内删除资源文件。 59 | 60 | ## FAQ 61 | 62 | 配置 nonebot 或 cq-http 过程中出错? 63 | > 请查阅 以及 中的文档。 64 | 65 | 部分消息发不出来? 66 | > 被风控了。解决方式:换号或者让这个号保持登陆状态和一定的聊天频率,持续一段时间。 67 | 68 | ## 说明 69 | 70 | 本 bot 提供了如下功能: 71 | 72 | 命令 | 功能 73 | --- | --- 74 | help | 查看帮助文档 75 | 今日舞萌 | 查看今天的舞萌运势 76 | XXXmaimaiXXX什么 | 随机一首歌 77 | 随个[dx/标准][绿黄红紫白]<难度> | 随机一首指定条件的乐曲 78 | 查歌<乐曲标题的一部分> | 查询符合条件的乐曲 79 | [绿黄红紫白]id<歌曲编号> | 查询乐曲信息或谱面信息 80 | 定数查歌 <定数>
定数查歌 <定数下限> <定数上限> | 查询定数对应的乐曲 81 | 分数线 <难度+歌曲id> <分数线> | 展示歌曲的分数线 82 | 搜<手元><理论><谱面确认> | 从b站获取对应的手元视频 83 | 84 | ## 原作者 85 | 86 | [Diving-Fish](https://github.com/Diving-Fish),感谢大佬为音游人的无私奉献 87 | 88 | ## License 89 | 90 | MIT 91 | 92 | 您可以自由使用本项目的代码用于商业或非商业的用途,但必须附带 MIT 授权协议。 93 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from nonebot import load_plugin 4 | 5 | dir_ = Path(__file__).parent 6 | load_plugin(str(dir_ / "nonebot_plugin_maimai")) 7 | -------------------------------------------------------------------------------- /nonebot_plugin_maimai/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot import get_driver, on_command, on_regex, require 2 | from nonebot.adapters import Event 3 | from nonebot.adapters.onebot.v11 import Message, MessageSegment 4 | from nonebot.params import CommandArg, EventMessage 5 | from nonebot.permission import SUPERUSER 6 | from nonebot.plugin import PluginMetadata 7 | 8 | require("nonebot_plugin_txt2img") 9 | require("nonebot_plugin_saa") 10 | import re 11 | from typing import Any 12 | 13 | from .api import bind_site, show_all # noqa: F401 14 | from .libraries.image import * 15 | from .libraries.maimai_best_40 import generate 16 | from .libraries.maimai_best_50 import generate50 17 | from .libraries.maimaidx_music import * 18 | from .libraries.tool import hash_ 19 | from .public import * 20 | 21 | try: 22 | import ujson as json 23 | except ImportError: 24 | import json 25 | 26 | driver = get_driver() 27 | try: 28 | nickname = next(iter(driver.config.nickname)) 29 | except Exception: 30 | nickname = "宁宁" 31 | 32 | 33 | __version__ = "0.4.4" 34 | __plugin_meta__ = PluginMetadata( 35 | name="舞萌maimai-bot", 36 | description="移植mai-bot,适用nonebot2的Maimai插件", 37 | usage="指令:舞萌帮助", 38 | type="application", 39 | homepage="https://github.com/Agnes4m/maimai_plugin", 40 | supported_adapters={"~onebot.v11"}, 41 | extra={ 42 | "version": __version__, 43 | "author": "Agnes4m ", 44 | }, 45 | ) 46 | 47 | 48 | def song_txt(music: Music): 49 | return Message( 50 | [ 51 | MessageSegment("text", {"text": f"{music.id}. {music.title}\n"}), 52 | MessageSegment( 53 | "image", 54 | { 55 | "file": f"https://www.diving-fish.com/covers/{get_cover_len5_id(music.id)}.png", 56 | }, 57 | ), 58 | MessageSegment("text", {"text": f"\n{'/'.join(music.level)}"}), # type: ignore 59 | ], 60 | ) 61 | 62 | 63 | def inner_level_q(ds1, ds2=None): 64 | result_set = [] 65 | diff_label = ["Bas", "Adv", "Exp", "Mst", "ReM"] 66 | if ds2 is not None: 67 | music_data = total_list.filter(ds=(ds1, ds2)) 68 | else: 69 | music_data = total_list.filter(ds=ds1) 70 | for music in sorted(music_data, key=lambda i: int(i["id"])): # type: ignore 71 | for i in music.diff: 72 | result_set.append( 73 | ( 74 | music["id"], 75 | music["title"], 76 | music["ds"][i], 77 | diff_label[i], 78 | music["level"][i], 79 | ), 80 | ) 81 | return result_set 82 | 83 | 84 | inner_level = on_command("inner_level ", aliases={"定数查歌 "}) 85 | 86 | 87 | @inner_level.handle() 88 | async def _(matcher: Matcher, message: Message = CommandArg()): 89 | argv = str(message).strip().split(" ") 90 | if len(argv) > 2 or len(argv) == 0: 91 | await inner_level.finish("命令格式为\n定数查歌 <定数>\n定数查歌 <定数下限> <定数上限>") 92 | if len(argv) == 1: 93 | result_set = inner_level_q(float(argv[0])) 94 | else: 95 | result_set = inner_level_q(float(argv[0]), float(argv[1])) 96 | if len(result_set) > 50: 97 | await inner_level.finish(f"结果过多({len(result_set)} 条),请缩小搜索范围。") 98 | s = "" 99 | for elem in result_set: 100 | s += f"{elem[0]}. {elem[1]} {elem[3]} {elem[4]}({elem[2]})\n" 101 | await matcher.finish(s.strip()) 102 | 103 | 104 | spec_rand = on_regex(r"^随个(?:dx|sd|标准)?[绿黄红紫白]?[0-9]+\+?") 105 | 106 | 107 | @spec_rand.handle() 108 | async def _(matcher: Matcher, message: Message = EventMessage()): 109 | # level_labels = ["绿", "黄", "红", "紫", "白"] 110 | regex = "随个((?:dx|sd|标准))?([绿黄红紫白]?)([0-9]+\+?)" # type: ignore 111 | res = re.match(regex, str(message).lower()) 112 | try: 113 | if res: 114 | if res.groups()[0] == "dx": 115 | tp = ["DX"] 116 | elif res.groups()[0] == "sd" or res.groups()[0] == "标准": 117 | tp = ["SD"] 118 | else: 119 | tp = ["SD", "DX"] 120 | level = res.groups()[2] 121 | if res.groups()[1] == "": 122 | music_data = total_list.filter(level=level, type=tp) 123 | else: 124 | music_data = total_list.filter( 125 | level=level, 126 | diff=["绿黄红紫白".index(res.groups()[1])], 127 | type=tp, 128 | ) 129 | if len(music_data) == 0 or music_data is None: # type: ignore 130 | rand_result = "没有这样的乐曲哦。" 131 | else: 132 | rand_result = song_txt(music_data.random()) 133 | await matcher.send(rand_result) 134 | except Exception as e: 135 | print(e) 136 | await matcher.finish("随机命令错误,请检查语法") 137 | 138 | 139 | mr = on_regex(r".*maimai.*什么") 140 | 141 | 142 | @mr.handle() 143 | async def _( 144 | matcher: Matcher, 145 | ): 146 | await matcher.finish(song_txt(total_list.random())) 147 | 148 | 149 | search_music = on_regex(r"^查歌.+") 150 | 151 | 152 | @search_music.handle() 153 | async def _(matcher: Matcher, message: Message = EventMessage()): 154 | regex = "查歌(.+)" 155 | name = re.match(regex, str(message)).groups()[0].strip() # type: ignore 156 | if name == "": 157 | return 158 | res = total_list.filter(title_search=name) 159 | if res is None: 160 | return 161 | if len(res) == 0: 162 | await search_music.send("没有找到这样的乐曲。") 163 | elif len(res) < 50: 164 | search_result = "" 165 | for music in sorted(res, key=lambda i: int(i["id"])): 166 | search_result += f"{music['id']}. {music['title']}\n" 167 | await matcher.finish( 168 | Message([MessageSegment("text", {"text": search_result.strip()})]), 169 | ) 170 | else: 171 | await matcher.send(f"结果过多({len(res)} 条),请缩小查询范围。") 172 | 173 | 174 | query_chart = on_regex(r"^([绿黄红紫白]?)id([0-9]+)") 175 | 176 | 177 | @query_chart.handle() 178 | async def _(matcher: Matcher, message: Message = EventMessage()): 179 | regex = "([绿黄红紫白]?)id([0-9]+)" 180 | groups = re.match(regex, str(message)).groups() # type: ignore 181 | level_labels = ["绿", "黄", "红", "紫", "白"] 182 | if groups[0] != "": 183 | try: 184 | level_index = level_labels.index(groups[0]) 185 | level_name = ["Basic", "Advanced", "Expert", "Master", "Re: MASTER"] 186 | name = groups[1] 187 | music = total_list.by_id(name) 188 | if music: 189 | chart = music["charts"][level_index] 190 | ds = music["ds"][level_index] 191 | level = music["level"][level_index] 192 | file = f"https://www.diving-fish.com/covers/{get_cover_len5_id(music['id'])}.png" 193 | if len(chart["notes"]) == 4: 194 | msg = f"""{level_name[level_index]} {level}({ds}) 195 | TAP: {chart['notes'][0]} 196 | HOLD: {chart['notes'][1]} 197 | SLIDE: {chart['notes'][2]} 198 | BREAK: {chart['notes'][3]} 199 | 谱师: {chart['charter']}""" 200 | else: 201 | msg = f"""{level_name[level_index]} {level}({ds}) 202 | TAP: {chart['notes'][0]} 203 | HOLD: {chart['notes'][1]} 204 | SLIDE: {chart['notes'][2]} 205 | TOUCH: {chart['notes'][3]} 206 | BREAK: {chart['notes'][4]} 207 | 谱师: {chart['charter']}""" 208 | await matcher.send( 209 | Message( 210 | [ 211 | MessageSegment( 212 | "text", 213 | {"text": f"{music['id']}. {music['title']}\n"}, 214 | ), 215 | MessageSegment("image", {"file": f"{file}"}), 216 | MessageSegment("text", {"text": msg}), 217 | ], 218 | ), 219 | ) 220 | except Exception: 221 | await matcher.send("未找到该谱面") 222 | else: 223 | name = groups[1] 224 | music = total_list.by_id(name) 225 | try: 226 | if not music: 227 | return 228 | file = f"https://www.diving-fish.com/covers/{get_cover_len5_id(music['id'])}.png" 229 | await query_chart.send( 230 | Message( 231 | [ 232 | MessageSegment( 233 | "text", 234 | {"text": f"{music['id']}. {music['title']}\n"}, 235 | ), 236 | MessageSegment("image", {"file": f"{file}"}), 237 | MessageSegment( 238 | "text", 239 | { 240 | "text": f"艺术家: {music['basic_info']['artist']}\n分类: {music['basic_info']['genre']}\nBPM: {music['basic_info']['bpm']}\n版本: {music['basic_info']['from']}\n难度: {'/'.join(music['level'])}", 241 | }, 242 | ), 243 | ], 244 | ), 245 | ) 246 | except Exception: 247 | await matcher.send("未找到该乐曲") 248 | 249 | 250 | wm_list = ["拼机", "推分", "越级", "下埋", "夜勤", "练底力", "练手法", "打旧框", "干饭", "抓绝赞", "收歌"] 251 | 252 | 253 | jrwm = on_command("今日舞萌", aliases={"今日mai"}) 254 | 255 | 256 | @jrwm.handle() 257 | async def _(event: Event, matcher: Matcher): 258 | qq = int(event.get_user_id()) 259 | h = hash_(qq) 260 | rp = h % 100 261 | wm_value = [] 262 | for i in range(11): # noqa: B007 263 | wm_value.append(h & 3) 264 | h >>= 2 265 | s = f"今日人品值:{rp}\n" 266 | for i in range(11): 267 | if wm_value[i] == 3: 268 | s += f"宜 {wm_list[i]}\n" 269 | elif wm_value[i] == 0: 270 | s += f"忌 {wm_list[i]}\n" 271 | s += f"{nickname}提醒您:打机时不要大力拍打或滑动哦\n今日推荐歌曲:" 272 | music = total_list[h % len(total_list)] 273 | await matcher.finish( 274 | Message([MessageSegment("text", {"text": s}), *song_txt(music)]), 275 | ) 276 | 277 | 278 | query_score = on_command("分数线") 279 | 280 | 281 | @query_score.handle() 282 | async def _(matcher: Matcher, message: Message = CommandArg()): 283 | r = "([绿黄红紫白])(id)?([0-9]+)" 284 | argv = str(message).strip().split(" ") 285 | if len(argv) == 1 and argv[0] == "帮助": 286 | s = """此功能为查找某首歌分数线设计。 287 | 命令格式:分数线 <难度+歌曲id> <分数线> 288 | 例如:分数线 紫799 100 289 | 命令将返回分数线允许的 TAP GREAT 容错以及 BREAK 50落等价的 TAP GREAT 数。 290 | 以下为 TAP GREAT 的对应表: 291 | GREAT/GOOD/MISS 292 | TAP\t1/2.5/5 293 | HOLD\t2/5/10 294 | SLIDE\t3/7.5/15 295 | TOUCH\t1/2.5/5 296 | BREAK\t5/12.5/25(外加200落)""" 297 | await matcher.send( 298 | Message( 299 | [ 300 | MessageSegment( 301 | "image", 302 | { 303 | "file": f"base64://{str(image_to_base64(text_to_image(s)), encoding='utf-8')}", 304 | }, 305 | ), 306 | ], 307 | ), 308 | ) 309 | elif len(argv) == 2: 310 | try: 311 | grp = re.match(r, argv[0]).groups() # type: ignore 312 | level_labels = ["绿", "黄", "红", "紫", "白"] 313 | level_labels2 = ["Basic", "Advanced", "Expert", "Master", "Re:MASTER"] 314 | level_index = level_labels.index(grp[0]) 315 | chart_id = grp[2] 316 | line = float(argv[1]) 317 | music = total_list.by_id(chart_id) 318 | if not music: 319 | return 320 | chart: Dict[str, Any] = music["charts"][level_index] 321 | tap = int(chart["notes"][0]) 322 | slide = int(chart["notes"][2]) 323 | hold = int(chart["notes"][1]) 324 | touch = int(chart["notes"][3]) if len(chart["notes"]) == 5 else 0 325 | brk = int(chart["notes"][-1]) 326 | total_score = ( 327 | 500 * tap + slide * 1500 + hold * 1000 + touch * 500 + brk * 2500 328 | ) 329 | break_bonus = 0.01 / brk 330 | break_50_reduce = total_score * break_bonus / 4 331 | reduce = 101 - line 332 | if reduce <= 0 or reduce >= 101: 333 | raise ValueError # noqa: TRY301 334 | await matcher.send( 335 | f"""{music['title']} {level_labels2[level_index]} 336 | 分数线 {line}% 允许的最多 TAP GREAT 数量为 {(total_score * reduce / 10000):.2f}(每个-{10000 / total_score:.4f}%), 337 | BREAK 50落(一共{brk}个)等价于 {(break_50_reduce / 100):.3f} 个 TAP GREAT(-{break_50_reduce / total_score * 100:.4f}%)""", 338 | ) 339 | except Exception: 340 | await matcher.send("格式错误,输入“分数线 帮助”以查看帮助信息") 341 | 342 | 343 | best_40_pic = on_command("b40") 344 | 345 | 346 | @best_40_pic.handle() 347 | async def _(event: Event, matcher: Matcher, message: Message = CommandArg()): 348 | username = str(message).strip() 349 | at = await get_message_at(event.json()) 350 | usr_id = at_to_usrid(at) 351 | if at: 352 | payload = {"qq": usr_id} 353 | elif username == "": 354 | payload = {"qq": str(event.get_user_id())} 355 | else: 356 | payload = {"username": username} 357 | img, success = await generate(payload) 358 | if success == 400: 359 | await matcher.send("未找到此玩家,请确保此玩家的用户名和查分器中的用户名相同。") 360 | elif success == 403: 361 | await matcher.send("该用户禁止了其他人获取数据。") 362 | else: 363 | await matcher.send( 364 | Message( 365 | [ 366 | MessageSegment( 367 | "image", 368 | { 369 | "file": f"base64://{str(image_to_base64(img), encoding='utf-8')}", 370 | }, 371 | ), 372 | ], 373 | ), 374 | ) 375 | 376 | 377 | best_50_pic = on_command("b50") 378 | 379 | 380 | @best_50_pic.handle() 381 | async def _(event: Event, matcher: Matcher, message: Message = CommandArg()): 382 | username = str(message).strip() 383 | at = await get_message_at(event.json()) 384 | usr_id = at_to_usrid(at) 385 | if at: 386 | payload = {"qq": usr_id, "b50": True} 387 | elif username == "": 388 | payload = {"qq": str(event.get_user_id()), "b50": True} 389 | else: 390 | payload = {"username": username, "b50": True} 391 | img, success = await generate50(payload) 392 | if success == 400: 393 | await matcher.send("未找到此玩家,请确保此玩家的用户名和查分器中的用户名相同。") 394 | elif success == 403: 395 | await matcher.send("该用户禁止了其他人获取数据。") 396 | else: 397 | await matcher.send( 398 | Message( 399 | [ 400 | MessageSegment( 401 | "image", 402 | { 403 | "file": f"base64://{str(image_to_base64(img), encoding='utf-8')}", 404 | }, 405 | ), 406 | ], 407 | ), 408 | ) 409 | 410 | 411 | async def get_message_at(data: str) -> list: 412 | """ 413 | 获取at列表 414 | :param data: event.json() 415 | 抄的groupmate_waifu 416 | """ 417 | qq_list = [] 418 | datas: Dict[str, Any] = json.loads(data) 419 | try: 420 | for msg in datas["message"]: 421 | if msg["type"] == "at": 422 | qq_list.append(int(msg["data"]["qq"])) 423 | return qq_list # noqa: TRY300 424 | except Exception: 425 | return [] 426 | 427 | 428 | def at_to_usrid(ats: List[str]): 429 | """at对象变qqid否则返回usr_id""" 430 | if ats != []: 431 | at: str = ats[0] 432 | usr_id: str = at 433 | return usr_id 434 | return None 435 | 436 | 437 | check_mai_data = on_command("检查mai资源", permission=SUPERUSER) 438 | force_check_mai_data = on_command("强制检查mai资源", permission=SUPERUSER) 439 | 440 | 441 | @check_mai_data.handle() 442 | async def _( 443 | matcher: Matcher, 444 | ): 445 | await check_mai_data.send("正在尝试下载,大概需要2-3分钟") 446 | logger.info("开始检查资源") 447 | await matcher.send(await check_mai()) 448 | 449 | 450 | @force_check_mai_data.handle() 451 | async def _( 452 | matcher: Matcher, 453 | ): 454 | await matcher.send("正在尝试下载,大概需要2-3分钟") 455 | logger.info("开始检查资源") 456 | await matcher.send(await check_mai(force=True)) 457 | -------------------------------------------------------------------------------- /nonebot_plugin_maimai/api.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | from pathlib import Path 4 | from typing import Dict, List 5 | from urllib.parse import urlencode 6 | 7 | import aiohttp 8 | from nonebot import on_command 9 | from nonebot.adapters import Event, Message 10 | from nonebot.matcher import Matcher 11 | from nonebot.params import CommandArg 12 | from pydantic import BaseModel 13 | 14 | from .libraries.tool import STATIC 15 | 16 | base_url = "http://api.place.fanyu.site" 17 | 18 | 19 | bind_site = on_command("maibind", aliases={"绑定机厅"}, priority=5, block=True) 20 | show_all = on_command("maicheck", aliases={"查卡"}, priority=5, block=True) 21 | 22 | 23 | @bind_site.handle() 24 | async def _(matcher: Matcher, event: Event, arg: Message = CommandArg()): 25 | usr_id = event.get_user_id() 26 | msgs = arg.extract_plain_text() 27 | msg_list = msgs.split() 28 | if len(msg_list) == 1: 29 | msg = msg_list[0] 30 | alias_name = "" 31 | elif len(msg_list) == 2: 32 | alias_name = msg_list[-1] 33 | msg = msg_list[0] 34 | else: 35 | await matcher.finish("参数错误,应当为1-2个参数") 36 | return 37 | try: 38 | with Path(STATIC).parent.joinpath("site.json").open( 39 | mode="r", 40 | encoding="utf-8", 41 | ) as f: 42 | data_site: List[Dict[str, str]] = json.loads(f.read()) 43 | except Exception: 44 | data_site = [] 45 | this_site = {} 46 | for one_site in data_site: 47 | if msg in one_site["arcadeName"] or msg in one_site["id"]: 48 | this_site = one_site 49 | break 50 | if not this_site: 51 | await matcher.finish("未找到机厅,请输入微信小程序所显示机厅名称或者id") 52 | 53 | output_params = await bind_place( 54 | BindPlaceInput( 55 | place_id=int(this_site["placeId"]), 56 | group_id=114514, 57 | machine_count=int(this_site["machineCount"]), 58 | place_name=this_site["arcadeName"], 59 | alias_name=alias_name if alias_name else this_site["arcadeName"], 60 | api_key="LmRwE3B0tfWUS8D5TqVpPXrJzjIyYFCObN6", 61 | ), 62 | ) 63 | print(output_params) 64 | if output_params["code"] == 200: 65 | place_id = output_params.get("place_id") 66 | alias_name = output_params.get("alias_name") 67 | result = output_params.get("result") 68 | await matcher.send(f"{result}:place_id:{place_id},alias_name:, {alias_name}") 69 | else: 70 | result = output_params.get("result") 71 | await matcher.send(f"{result}") 72 | try: 73 | with Path(STATIC).parent.joinpath("player.json").open( 74 | "r", 75 | encoding="utf-8", 76 | ) as f: 77 | player_json: Dict[str, List[str]] = json.loads(f.read()) 78 | except Exception: 79 | player_json = {} 80 | if usr_id in player_json: 81 | player_json[usr_id].append(this_site["placeId"]) 82 | else: 83 | player_json[usr_id] = [this_site["placeId"]] 84 | with Path(STATIC).parent.joinpath("player.json").open("w", encoding="utf-8") as f: 85 | json.dump(player_json, f, ensure_ascii=False) 86 | 87 | 88 | @show_all.handle() 89 | async def _( 90 | matcher: Matcher, 91 | event: Event, 92 | ): 93 | usr_id = event.get_user_id() 94 | try: 95 | with Path(STATIC).parent.joinpath("player.json").open( 96 | "r", 97 | encoding="utf-8", 98 | ) as f: 99 | player_json: Dict[str, List[str]] = json.loads(f.read()) 100 | except Exception: 101 | player_json = {} 102 | site_list = player_json.get(usr_id) 103 | if site_list is None: 104 | await matcher.finish("用户暂未绑定") 105 | return 106 | send_msg = "" 107 | for one_site_id in site_list: 108 | msg_data = await get_place_count( 109 | GetPlaceCountInput( 110 | place_id=int(one_site_id), 111 | group_id=114514, 112 | api_key="LmRwE3B0tfWUS8D5TqVpPXrJzjIyYFCObN6", 113 | ), 114 | ) 115 | send_msg += f"【{msg_data.place_count}人 | {msg_data.machine_count}机】| {msg_data.place_name} 最后更新:{msg_data.last_update_datetime}/n" 116 | await matcher.finish(send_msg) 117 | 118 | 119 | async def update_pl(): 120 | async with aiohttp.ClientSession() as session: 121 | urls = "http://wc.wahlap.net/maidx/rest/location" 122 | async with session.get(urls) as response: 123 | result = await response.json() 124 | if result: 125 | with Path(STATIC).parent.joinpath("site.json").open( 126 | mode="w", 127 | encoding="utf-8", 128 | ) as f: 129 | json.dump(result, f, ensure_ascii=False) 130 | 131 | 132 | class BindPlaceInput(BaseModel): 133 | place_id: int 134 | group_id: int 135 | machine_count: int 136 | place_name: str 137 | alias_name: str 138 | api_key: str 139 | 140 | 141 | async def bind_place(input_params: BindPlaceInput) -> dict: 142 | bind_url = f"{base_url}/bind_place" 143 | query_params = { 144 | "place_id": input_params.place_id, 145 | "group_id": input_params.group_id, 146 | "machine_count": input_params.machine_count, 147 | "place_name": input_params.place_name, 148 | "alias_name": input_params.alias_name, 149 | "api_key": input_params.api_key, 150 | } 151 | url = f"{bind_url}?{urlencode(query_params)}" 152 | 153 | async with aiohttp.ClientSession() as session: 154 | async with session.get(url) as response: 155 | result: Dict[str, str] = await response.json() 156 | print(result) 157 | # result = json.loads(result) 158 | return {"code": result.get("code"), "result": result.get("result")} 159 | 160 | 161 | async def ex_bind(): 162 | input_params = BindPlaceInput( 163 | place_id=1027, 164 | group_id=114514, 165 | machine_count=2, 166 | place_name="wawawa", 167 | alias_name="aaaa", 168 | api_key="LmRwE3B0tfWUS8D5TqVpPXrJzjIyYFCObN6", 169 | ) 170 | await bind_place(input_params) 171 | 172 | # if output_params["code"] == 200: 173 | # place_id = output_params["result"].get('place_id') 174 | # alias_name = output_params.result.get('alias_name') 175 | # print('绑定成功:place_id:', place_id, 'alias_name:', alias_name) 176 | # else: 177 | # print('绑定失败,错误代码:', output_params["code"]) 178 | 179 | 180 | class GetPlaceCountInput(BaseModel): 181 | place_id: int 182 | group_id: int 183 | api_key: str 184 | 185 | 186 | class Log(BaseModel): 187 | user_id: str 188 | update_datetime: str 189 | set_place_count: int 190 | group_id: int 191 | 192 | 193 | class GetPlaceCountOutput(BaseModel): 194 | code: int 195 | result: str 196 | place_name: str 197 | place_count: int 198 | place_id: int 199 | machine_count: int 200 | last_update_datetime: str 201 | logs: List[Log] 202 | 203 | 204 | async def get_place_count(input_params: GetPlaceCountInput) -> GetPlaceCountOutput: 205 | get_url = f"{base_url}/get_place_count" 206 | 207 | params = { 208 | "place_id": input_params.place_id, 209 | "group_id": input_params.group_id, 210 | "api_key": input_params.api_key, 211 | } 212 | url = f"{get_url}?{urlencode(params)}" 213 | async with aiohttp.ClientSession() as session: 214 | async with session.get(url) as response: 215 | data: dict = await response.json() 216 | # data = response.json() 217 | print(data) 218 | logs = [] 219 | for log_data in data["logs"]: 220 | log = Log( 221 | user_id=log_data["user_id"], 222 | update_datetime=log_data["update_datetime"], 223 | set_place_count=log_data["set_place_count"], 224 | group_id=log_data["group_id"], 225 | ) 226 | logs.append(log) 227 | 228 | return GetPlaceCountOutput( 229 | code=data["code"], 230 | result=data["result"], 231 | place_name=data["place_name"], 232 | place_count=data["place_count"], 233 | place_id=data["place_id"], 234 | machine_count=data["machine_count"], 235 | last_update_datetime=data["last_update_datetime"], 236 | logs=logs, 237 | ) 238 | 239 | 240 | # asyncio.run(update_pl()) 241 | if __name__ == "__main__": 242 | asyncio.run(ex_bind()) 243 | -------------------------------------------------------------------------------- /nonebot_plugin_maimai/libraries/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Agnes4m/maimai_plugin/3c2e49cd260a059b95d0e18ea09323af0206e5c0/nonebot_plugin_maimai/libraries/__init__.py -------------------------------------------------------------------------------- /nonebot_plugin_maimai/libraries/image.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from io import BytesIO 3 | 4 | from PIL import Image, ImageDraw, ImageFont 5 | 6 | from .tool import STATIC 7 | 8 | path = STATIC + "/high_eq_image.png" 9 | fontpath = STATIC + "/msyh.ttc" 10 | 11 | 12 | def draw_text(img_pil, text, offset_x): 13 | draw = ImageDraw.Draw(img_pil) 14 | font = ImageFont.truetype(fontpath, 48) 15 | width, height = draw.textsize(text, font) 16 | x = 5 17 | if width > 390: 18 | font = ImageFont.truetype(fontpath, int(390 * 48 / width)) 19 | width, height = draw.textsize(text, font) 20 | else: 21 | x = int((400 - width) / 2) 22 | draw.rectangle( 23 | (x + offset_x - 2, 360, x + 2 + width + offset_x, 360 + height * 1.2), 24 | fill=(0, 0, 0, 255), 25 | ) 26 | draw.text((x + offset_x, 360), text, font=font, fill=(255, 255, 255, 255)) 27 | 28 | 29 | def text_to_image(text): 30 | font = ImageFont.truetype(fontpath, 24) 31 | padding = 10 32 | margin = 4 33 | text_list = text.split("\n") 34 | max_width = 0 35 | h = 0 36 | for text in text_list: 37 | w, h = font.getsize(text) 38 | max_width = max(max_width, w) 39 | wa = max_width + padding * 2 40 | ha = h * len(text_list) + margin * (len(text_list) - 1) + padding * 2 41 | i = Image.new("RGB", (wa, ha), color=(255, 255, 255)) 42 | draw = ImageDraw.Draw(i) 43 | for j in range(len(text_list)): 44 | text = text_list[j] 45 | draw.text((padding, padding + j * (margin + h)), text, font=font, fill=(0, 0, 0)) 46 | return i 47 | 48 | 49 | def image_to_base64(img, format="PNG"): # noqa: A002 50 | output_buffer = BytesIO() 51 | img.save(output_buffer, format) 52 | byte_data = output_buffer.getvalue() 53 | return base64.b64encode(byte_data) 54 | -------------------------------------------------------------------------------- /nonebot_plugin_maimai/libraries/maimai_best_40.py: -------------------------------------------------------------------------------- 1 | # Author: xyb, Diving_Fish 2 | # rewrite Anges Digital 3 | 4 | import math 5 | from pathlib import Path 6 | from typing import Dict, List, Optional, Tuple 7 | 8 | import aiohttp 9 | from PIL import Image, ImageDraw, ImageFilter, ImageFont 10 | 11 | from .maimaidx_music import get_cover_len5_id, total_list 12 | from .tool import STATIC 13 | 14 | scoreRank = "D C B BB BBB A AA AAA S S+ SS SS+ SSS SSS+".split(" ") 15 | combo = " FC FC+ AP AP+".split(" ") 16 | diffs = "Basic Advanced Expert Master Re:Master".split(" ") 17 | 18 | 19 | class ChartInfo(object): 20 | def __init__( 21 | self, 22 | idNum: str, 23 | diff: int, 24 | tp: str, 25 | achievement: float, 26 | ra: int, 27 | comboId: int, 28 | scoreId: int, 29 | title: str, 30 | ds: float, 31 | lv: str, 32 | ): 33 | self.idNum = idNum 34 | self.diff = diff 35 | self.tp = tp 36 | self.achievement = achievement 37 | self.ra = ra 38 | self.comboId = comboId 39 | self.scoreId = scoreId 40 | self.title = title 41 | self.ds = ds 42 | self.lv = lv 43 | 44 | def __str__(self): 45 | return ( 46 | "%-50s" % f"{self.title} [{self.tp}]" 47 | + f"{self.ds}\t{diffs[self.diff]}\t{self.ra}" 48 | ) 49 | 50 | def __eq__(self, other): 51 | return self.ra == other.ra 52 | 53 | def __lt__(self, other): 54 | return self.ra < other.ra 55 | 56 | @classmethod 57 | def from_json(cls, data): 58 | rate = [ 59 | "d", 60 | "c", 61 | "b", 62 | "bb", 63 | "bbb", 64 | "a", 65 | "aa", 66 | "aaa", 67 | "s", 68 | "sp", 69 | "ss", 70 | "ssp", 71 | "sss", 72 | "sssp", 73 | ] 74 | ri = rate.index(data["rate"]) 75 | fc = ["", "fc", "fcp", "ap", "app"] 76 | fi = fc.index(data["fc"]) 77 | return cls( 78 | idNum=total_list.by_title(data["title"]).id, # type: ignore 79 | title=data["title"], 80 | diff=data["level_index"], 81 | ra=data["ra"], 82 | ds=data["ds"], 83 | comboId=fi, 84 | scoreId=ri, 85 | lv=data["level"], 86 | achievement=data["achievements"], 87 | tp=data["type"], 88 | ) 89 | 90 | 91 | class BestList(object): 92 | def __init__(self, size: int): 93 | self.data = [] 94 | self.size = size 95 | 96 | def push(self, elem: ChartInfo): 97 | if len(self.data) >= self.size and elem < self.data[-1]: 98 | return 99 | self.data.append(elem) 100 | self.data.sort() 101 | self.data.reverse() 102 | while len(self.data) > self.size: 103 | del self.data[-1] 104 | 105 | def pop(self): 106 | del self.data[-1] 107 | 108 | def __str__(self): 109 | return "[\n\t" + ", \n\t".join([str(ci) for ci in self.data]) + "\n]" 110 | 111 | def __len__(self): 112 | return len(self.data) 113 | 114 | def __getitem__(self, index): 115 | return self.data[index] 116 | 117 | 118 | class DrawBest(object): 119 | def __init__( 120 | self, 121 | sdBest: BestList, 122 | dxBest: BestList, 123 | userName: str, 124 | playerRating: int, 125 | musicRating: int, 126 | ): 127 | self.sdBest = sdBest 128 | self.dxBest = dxBest 129 | self.userName = self._stringQ2B(userName) 130 | self.playerRating = playerRating 131 | self.musicRating = musicRating 132 | self.rankRating = self.playerRating - self.musicRating 133 | self.pic_dir = STATIC + "/mai/pic/" 134 | self.cover_dir = STATIC + "/mai/cover/" 135 | self.img = Image.open(self.pic_dir + "UI_TTR_BG_Base_Plus.png").convert("RGBA") 136 | self.ROWS_IMG = [2] 137 | for i in range(6): 138 | self.ROWS_IMG.append(116 + 96 * i) 139 | self.COLOUMS_IMG = [] 140 | for i in range(6): 141 | self.COLOUMS_IMG.append(2 + 172 * i) 142 | for i in range(4): 143 | self.COLOUMS_IMG.append(888 + 172 * i) 144 | self.draw() 145 | 146 | def _Q2B(self, uchar): 147 | """单个字符 全角转半角""" 148 | inside_code = ord(uchar) 149 | if inside_code == 0x3000: 150 | inside_code = 0x0020 151 | else: 152 | inside_code -= 0xFEE0 153 | if inside_code < 0x0020 or inside_code > 0x7E: # 转完之后不是半角字符返回原来的字符 154 | return uchar 155 | return chr(inside_code) 156 | 157 | def _stringQ2B(self, ustring): 158 | """把字符串全角转半角""" 159 | return "".join([self._Q2B(uchar) for uchar in ustring]) 160 | 161 | def _getCharWidth(self, o) -> int: 162 | widths = [ 163 | (126, 1), 164 | (159, 0), 165 | (687, 1), 166 | (710, 0), 167 | (711, 1), 168 | (727, 0), 169 | (733, 1), 170 | (879, 0), 171 | (1154, 1), 172 | (1161, 0), 173 | (4347, 1), 174 | (4447, 2), 175 | (7467, 1), 176 | (7521, 0), 177 | (8369, 1), 178 | (8426, 0), 179 | (9000, 1), 180 | (9002, 2), 181 | (11021, 1), 182 | (12350, 2), 183 | (12351, 1), 184 | (12438, 2), 185 | (12442, 0), 186 | (19893, 2), 187 | (19967, 1), 188 | (55203, 2), 189 | (63743, 1), 190 | (64106, 2), 191 | (65039, 1), 192 | (65059, 0), 193 | (65131, 2), 194 | (65279, 1), 195 | (65376, 2), 196 | (65500, 1), 197 | (65510, 2), 198 | (120831, 1), 199 | (262141, 2), 200 | (1114109, 1), 201 | ] 202 | if o == 0xE or o == 0xF: 203 | return 0 204 | for num, wid in widths: 205 | if o <= num: 206 | return wid 207 | return 1 208 | 209 | def _coloumWidth(self, s: str): 210 | res = 0 211 | for ch in s: 212 | res += self._getCharWidth(ord(ch)) 213 | return res 214 | 215 | def _changeColumnWidth(self, s: str, lens: int) -> str: 216 | res = 0 217 | sList = [] 218 | for ch in s: 219 | res += self._getCharWidth(ord(ch)) 220 | if res <= lens: 221 | sList.append(ch) 222 | return "".join(sList) 223 | 224 | def _resizePic(self, img: Image.Image, time: float): 225 | return img.resize((int(img.size[0] * time), int(img.size[1] * time))) 226 | 227 | def _findRaPic(self) -> str: 228 | num = "10" 229 | if self.playerRating < 1000: 230 | num = "01" 231 | elif self.playerRating < 2000: 232 | num = "02" 233 | elif self.playerRating < 3000: 234 | num = "03" 235 | elif self.playerRating < 4000: 236 | num = "04" 237 | elif self.playerRating < 5000: 238 | num = "05" 239 | elif self.playerRating < 6000: 240 | num = "06" 241 | elif self.playerRating < 7000: 242 | num = "07" 243 | elif self.playerRating < 8000: 244 | num = "08" 245 | elif self.playerRating < 8500: 246 | num = "09" 247 | return f"UI_CMN_DXRating_S_{num}.png" 248 | 249 | def _drawRating(self, ratingBaseImg: Image.Image): 250 | COLOUMS_RATING = [86, 100, 115, 130, 145] 251 | theRa = self.playerRating 252 | i = 4 253 | while theRa: 254 | digit = theRa % 10 255 | theRa = theRa // 10 256 | digitImg = Image.open(self.pic_dir + f"UI_NUM_Drating_{digit}.png").convert( 257 | "RGBA", 258 | ) 259 | digitImg = self._resizePic(digitImg, 0.6) 260 | ratingBaseImg.paste( 261 | digitImg, 262 | (COLOUMS_RATING[i] - 2, 9), 263 | mask=digitImg.split()[3], 264 | ) 265 | i = i - 1 266 | return ratingBaseImg 267 | 268 | def _drawBestList(self, img: Image.Image, sdBest: BestList, dxBest: BestList): 269 | itemW = 164 270 | itemH = 88 271 | Color = [ 272 | (69, 193, 36), 273 | (255, 186, 1), 274 | (255, 90, 102), 275 | (134, 49, 200), 276 | (217, 197, 233), 277 | ] 278 | levelTriagle = [(itemW, 0), (itemW - 27, 0), (itemW, 27)] 279 | rankPic = "D C B BB BBB A AA AAA S Sp SS SSp SSS SSSp".split(" ") 280 | comboPic = " FC FCp AP APp".split(" ") 281 | imgDraw = ImageDraw.Draw(img) # noqa: F841 282 | titleFontName = STATIC + "/adobe_simhei.otf" 283 | for num in range(len(sdBest)): 284 | i = num // 5 285 | j = num % 5 286 | chartInfo = sdBest[num] 287 | pngPath = self.cover_dir + f"{get_cover_len5_id(chartInfo.idNum)}.png" 288 | if not Path(pngPath).is_file(): 289 | pngPath = self.cover_dir + "01000.png" 290 | temp = Image.open(pngPath).convert("RGB") 291 | temp = self._resizePic(temp, itemW / temp.size[0]) 292 | temp = temp.crop( 293 | (0, (temp.size[1] - itemH) / 2, itemW, (temp.size[1] + itemH) / 2), # type: ignore 294 | ) 295 | temp = temp.filter(ImageFilter.GaussianBlur(3)) 296 | temp = temp.point(lambda p: int(p * 0.72)) 297 | 298 | tempDraw = ImageDraw.Draw(temp) 299 | tempDraw.polygon(levelTriagle, Color[chartInfo.diff]) 300 | font = ImageFont.truetype(titleFontName, 16, encoding="utf-8") 301 | title = chartInfo.title 302 | if self._coloumWidth(title) > 15: 303 | title = self._changeColumnWidth(title, 14) + "..." 304 | tempDraw.text((8, 8), title, "white", font) 305 | font = ImageFont.truetype(titleFontName, 14, encoding="utf-8") 306 | 307 | tempDraw.text((7, 28), f'{"%.4f" % chartInfo.achievement}%', "white", font) 308 | rankImg = Image.open( 309 | self.pic_dir + f"UI_GAM_Rank_{rankPic[chartInfo.scoreId]}.png", 310 | ).convert("RGBA") 311 | rankImg = self._resizePic(rankImg, 0.3) 312 | temp.paste(rankImg, (88, 28), rankImg.split()[3]) 313 | if chartInfo.comboId: 314 | comboImg = Image.open( 315 | self.pic_dir 316 | + f"UI_MSS_MBase_Icon_{comboPic[chartInfo.comboId]}_S.png", 317 | ).convert("RGBA") 318 | comboImg = self._resizePic(comboImg, 0.45) 319 | temp.paste(comboImg, (119, 27), comboImg.split()[3]) 320 | font = ImageFont.truetype(STATIC + "/adobe_simhei.otf", 12, encoding="utf-8") 321 | tempDraw.text( 322 | (8, 44), 323 | f"Base: {chartInfo.ds} -> {chartInfo.ra}", 324 | "white", 325 | font, 326 | ) 327 | font = ImageFont.truetype(STATIC + "/adobe_simhei.otf", 18, encoding="utf-8") 328 | tempDraw.text((8, 60), f"#{num + 1}", "white", font) 329 | 330 | recBase = Image.new("RGBA", (itemW, itemH), "black") 331 | recBase = recBase.point(lambda p: int(p * 0.8)) 332 | img.paste(recBase, (self.COLOUMS_IMG[j] + 5, self.ROWS_IMG[i + 1] + 5)) 333 | img.paste(temp, (self.COLOUMS_IMG[j] + 4, self.ROWS_IMG[i + 1] + 4)) 334 | for num in range(len(sdBest), sdBest.size): 335 | i = num // 5 336 | j = num % 5 337 | temp = Image.open(self.cover_dir + "01000.png").convert("RGB") 338 | temp = self._resizePic(temp, itemW / temp.size[0]) 339 | temp = temp.crop( 340 | (0, (temp.size[1] - itemH) / 2, itemW, (temp.size[1] + itemH) / 2), # type: ignore 341 | ) 342 | temp = temp.filter(ImageFilter.GaussianBlur(1)) 343 | img.paste(temp, (self.COLOUMS_IMG[j] + 4, self.ROWS_IMG[i + 1] + 4)) 344 | for num in range(len(dxBest)): 345 | i = num // 3 346 | j = num % 3 347 | chartInfo = dxBest[num] 348 | pngPath = self.cover_dir + f"{get_cover_len5_id(chartInfo.idNum)}.png" 349 | if not Path(pngPath).is_file(): 350 | pngPath = self.cover_dir + "01000.png" 351 | temp = Image.open(pngPath).convert("RGB") 352 | temp = self._resizePic(temp, itemW / temp.size[0]) 353 | temp = temp.crop( 354 | (0, (temp.size[1] - itemH) / 2, itemW, (temp.size[1] + itemH) / 2), # type: ignore 355 | ) 356 | temp = temp.filter(ImageFilter.GaussianBlur(3)) 357 | temp = temp.point(lambda p: int(p * 0.72)) 358 | 359 | tempDraw = ImageDraw.Draw(temp) 360 | tempDraw.polygon(levelTriagle, Color[chartInfo.diff]) 361 | font = ImageFont.truetype(titleFontName, 16, encoding="utf-8") 362 | title = chartInfo.title 363 | if self._coloumWidth(title) > 15: 364 | title = self._changeColumnWidth(title, 14) + "..." 365 | tempDraw.text((8, 8), title, "white", font) 366 | font = ImageFont.truetype(titleFontName, 14, encoding="utf-8") 367 | 368 | tempDraw.text((7, 28), f'{"%.4f" % chartInfo.achievement}%', "white", font) 369 | rankImg = Image.open( 370 | self.pic_dir + f"UI_GAM_Rank_{rankPic[chartInfo.scoreId]}.png", 371 | ).convert("RGBA") 372 | rankImg = self._resizePic(rankImg, 0.3) 373 | temp.paste(rankImg, (88, 28), rankImg.split()[3]) 374 | if chartInfo.comboId: 375 | comboImg = Image.open( 376 | self.pic_dir 377 | + f"UI_MSS_MBase_Icon_{comboPic[chartInfo.comboId]}_S.png", 378 | ).convert("RGBA") 379 | comboImg = self._resizePic(comboImg, 0.45) 380 | temp.paste(comboImg, (119, 27), comboImg.split()[3]) 381 | font = ImageFont.truetype(STATIC + "/adobe_simhei.otf", 12, encoding="utf-8") 382 | tempDraw.text( 383 | (8, 44), 384 | f"Base: {chartInfo.ds} -> {chartInfo.ra}", 385 | "white", 386 | font, 387 | ) 388 | font = ImageFont.truetype(STATIC + "/adobe_simhei.otf", 18, encoding="utf-8") 389 | tempDraw.text((8, 60), f"#{num + 1}", "white", font) 390 | 391 | recBase = Image.new("RGBA", (itemW, itemH), "black") 392 | recBase = recBase.point(lambda p: int(p * 0.8)) 393 | img.paste(recBase, (self.COLOUMS_IMG[j + 6] + 5, self.ROWS_IMG[i + 1] + 5)) 394 | img.paste(temp, (self.COLOUMS_IMG[j + 6] + 4, self.ROWS_IMG[i + 1] + 4)) 395 | for num in range(len(dxBest), dxBest.size): 396 | i = num // 3 397 | j = num % 3 398 | temp = Image.open(self.cover_dir + "01000.png").convert("RGB") 399 | temp = self._resizePic(temp, itemW / temp.size[0]) 400 | temp = temp.crop( 401 | (0, (temp.size[1] - itemH) / 2, itemW, (temp.size[1] + itemH) / 2), # type: ignore 402 | ) 403 | temp = temp.filter(ImageFilter.GaussianBlur(1)) 404 | img.paste(temp, (self.COLOUMS_IMG[j + 6] + 4, self.ROWS_IMG[i + 1] + 4)) 405 | 406 | def draw(self): 407 | splashLogo = Image.open( 408 | self.pic_dir + "UI_CMN_TabTitle_MaimaiTitle_Ver214.png", 409 | ).convert("RGBA") 410 | splashLogo = self._resizePic(splashLogo, 0.65) 411 | self.img.paste(splashLogo, (10, 10), mask=splashLogo.split()[3]) 412 | 413 | ratingBaseImg = Image.open(self.pic_dir + self._findRaPic()).convert("RGBA") 414 | ratingBaseImg = self._drawRating(ratingBaseImg) 415 | ratingBaseImg = self._resizePic(ratingBaseImg, 0.85) 416 | self.img.paste(ratingBaseImg, (240, 8), mask=ratingBaseImg.split()[3]) 417 | 418 | namePlateImg = Image.open(self.pic_dir + "UI_TST_PlateMask.png").convert("RGBA") 419 | namePlateImg = namePlateImg.resize((285, 40)) 420 | namePlateDraw = ImageDraw.Draw(namePlateImg) 421 | font1 = ImageFont.truetype(STATIC + "/msyh.ttc", 28, encoding="unic") 422 | namePlateDraw.text((12, 4), " ".join(list(self.userName)), "black", font1) 423 | nameDxImg = Image.open(self.pic_dir + "UI_CMN_Name_DX.png").convert("RGBA") 424 | nameDxImg = self._resizePic(nameDxImg, 0.9) 425 | namePlateImg.paste(nameDxImg, (230, 4), mask=nameDxImg.split()[3]) 426 | self.img.paste(namePlateImg, (240, 40), mask=namePlateImg.split()[3]) 427 | 428 | shougouImg = Image.open(self.pic_dir + "UI_CMN_Shougou_Rainbow.png").convert( 429 | "RGBA", 430 | ) 431 | shougouDraw = ImageDraw.Draw(shougouImg) 432 | font2 = ImageFont.truetype(STATIC + "/adobe_simhei.otf", 14, encoding="utf-8") 433 | playCountInfo = f"底分: {self.musicRating} + 段位分: {self.rankRating}" 434 | shougouImgW, shougouImgH = shougouImg.size 435 | playCountInfoW, playCountInfoH = shougouDraw.textsize(playCountInfo, font2) 436 | textPos = ( 437 | (shougouImgW - playCountInfoW - font2.getoffset(playCountInfo)[0]) / 2, 438 | 5, 439 | ) 440 | shougouDraw.text((textPos[0] - 1, textPos[1]), playCountInfo, "black", font2) 441 | shougouDraw.text((textPos[0] + 1, textPos[1]), playCountInfo, "black", font2) 442 | shougouDraw.text((textPos[0], textPos[1] - 1), playCountInfo, "black", font2) 443 | shougouDraw.text((textPos[0], textPos[1] + 1), playCountInfo, "black", font2) 444 | shougouDraw.text((textPos[0] - 1, textPos[1] - 1), playCountInfo, "black", font2) 445 | shougouDraw.text((textPos[0] + 1, textPos[1] - 1), playCountInfo, "black", font2) 446 | shougouDraw.text((textPos[0] - 1, textPos[1] + 1), playCountInfo, "black", font2) 447 | shougouDraw.text((textPos[0] + 1, textPos[1] + 1), playCountInfo, "black", font2) 448 | shougouDraw.text(textPos, playCountInfo, "white", font2) 449 | shougouImg = self._resizePic(shougouImg, 1.05) 450 | self.img.paste(shougouImg, (240, 83), mask=shougouImg.split()[3]) 451 | 452 | self._drawBestList(self.img, self.sdBest, self.dxBest) 453 | 454 | authorBoardImg = Image.open(self.pic_dir + "UI_CMN_MiniDialog_01.png").convert( 455 | "RGBA", 456 | ) 457 | authorBoardImg = self._resizePic(authorBoardImg, 0.35) 458 | authorBoardDraw = ImageDraw.Draw(authorBoardImg) 459 | authorBoardDraw.text( 460 | (31, 28), 461 | " Generated By\nXybBot & Chiyuki", 462 | "black", 463 | font2, 464 | ) 465 | self.img.paste(authorBoardImg, (1224, 19), mask=authorBoardImg.split()[3]) 466 | 467 | dxImg = Image.open(self.pic_dir + "UI_RSL_MBase_Parts_01.png").convert("RGBA") 468 | self.img.paste(dxImg, (890, 65), mask=dxImg.split()[3]) 469 | sdImg = Image.open(self.pic_dir + "UI_RSL_MBase_Parts_02.png").convert("RGBA") 470 | self.img.paste(sdImg, (758, 65), mask=sdImg.split()[3]) 471 | 472 | # self.img.show() 473 | 474 | def getDir(self): 475 | return self.img 476 | 477 | 478 | def computeRa(ds: float, achievement: float) -> int: 479 | baseRa = 15.0 480 | if achievement >= 50 and achievement < 60: 481 | baseRa = 5.0 482 | elif achievement < 70: 483 | baseRa = 6.0 484 | elif achievement < 75: 485 | baseRa = 7.0 486 | elif achievement < 80: 487 | baseRa = 7.5 488 | elif achievement < 90: 489 | baseRa = 8.0 490 | elif achievement < 94: 491 | baseRa = 9.0 492 | elif achievement < 97: 493 | baseRa = 9.4 494 | elif achievement < 98: 495 | baseRa = 10.0 496 | elif achievement < 99: 497 | baseRa = 11.0 498 | elif achievement < 99.5: 499 | baseRa = 12.0 500 | elif achievement < 99.99: 501 | baseRa = 13.0 502 | elif achievement < 100: 503 | baseRa = 13.5 504 | elif achievement < 100.5: 505 | baseRa = 14.0 506 | 507 | return math.floor(ds * (min(100.5, achievement) / 100) * baseRa) 508 | 509 | 510 | async def generate(payload: Dict) -> Tuple[Optional[Image.Image], int]: 511 | async with aiohttp.request( 512 | "POST", 513 | "https://www.diving-fish.com/api/maimaidxprober/query/player", 514 | json=payload, 515 | ) as resp: 516 | if resp.status == 400: 517 | return None, 400 518 | if resp.status == 403: 519 | return None, 403 520 | sd_best = BestList(25) 521 | dx_best = BestList(15) 522 | obj = await resp.json() 523 | dx: List[Dict] = obj["charts"]["dx"] 524 | sd: List[Dict] = obj["charts"]["sd"] 525 | for c in sd: 526 | sd_best.push(ChartInfo.from_json(c)) 527 | for c in dx: 528 | dx_best.push(ChartInfo.from_json(c)) 529 | pic = DrawBest( 530 | sd_best, 531 | dx_best, 532 | obj["nickname"], 533 | obj["rating"] + obj["additional_rating"], 534 | obj["rating"], 535 | ).getDir() 536 | return pic, 0 537 | -------------------------------------------------------------------------------- /nonebot_plugin_maimai/libraries/maimai_best_50.py: -------------------------------------------------------------------------------- 1 | import math 2 | from pathlib import Path 3 | from typing import Dict, List, Optional, Tuple 4 | 5 | import aiohttp 6 | from PIL import Image, ImageDraw, ImageFilter, ImageFont 7 | 8 | from .maimaidx_music import get_cover_len5_id, total_list 9 | from .tool import STATIC 10 | 11 | scorerank = "D C B BB BBB A AA AAA S S+ SS SS+ SSS SSS+".split(" ") 12 | combo = " FC FC+ AP AP+".split(" ") 13 | diffs = "Basic Advanced Expert Master Re:Master".split(" ") 14 | 15 | 16 | class ChartInfo(object): 17 | def __init__( 18 | self, 19 | idNum: str, 20 | diff: int, 21 | tp: str, 22 | achievement: float, 23 | ra: int, # noqa: ARG002 24 | comboId: int, 25 | scoreId: int, 26 | title: str, 27 | ds: float, 28 | lv: str, 29 | ): 30 | self.idNum = idNum 31 | self.diff = diff 32 | self.tp = tp 33 | self.achievement = achievement 34 | self.ra = computeRa(ds, achievement) 35 | self.comboId = comboId 36 | self.scoreId = scoreId 37 | self.title = title 38 | self.ds = ds 39 | self.lv = lv 40 | 41 | def __str__(self): 42 | return ( 43 | "%-50s" % f"{self.title} [{self.tp}]" 44 | + f"{self.ds}\t{diffs[self.diff]}\t{self.ra}" 45 | ) 46 | 47 | def __eq__(self, other): 48 | return self.ra == other.ra 49 | 50 | def __lt__(self, other): 51 | return self.ra < other.ra 52 | 53 | @classmethod 54 | def from_json(cls, data): 55 | rate = [ 56 | "d", 57 | "c", 58 | "b", 59 | "bb", 60 | "bbb", 61 | "a", 62 | "aa", 63 | "aaa", 64 | "s", 65 | "sp", 66 | "ss", 67 | "ssp", 68 | "sss", 69 | "sssp", 70 | ] 71 | ri = rate.index(data["rate"]) 72 | fc = ["", "fc", "fcp", "ap", "app"] 73 | fi = fc.index(data["fc"]) 74 | return cls( 75 | idNum=total_list.by_title(data["title"]).id, # type: ignore 76 | title=data["title"], 77 | diff=data["level_index"], 78 | ra=data["ra"], 79 | ds=data["ds"], 80 | comboId=fi, 81 | scoreId=ri, 82 | lv=data["level"], 83 | achievement=data["achievements"], 84 | tp=data["type"], 85 | ) 86 | 87 | 88 | class BestList(object): 89 | def __init__(self, size: int): 90 | self.data = [] 91 | self.size = size 92 | 93 | def push(self, elem: ChartInfo): 94 | if len(self.data) >= self.size and elem < self.data[-1]: 95 | return 96 | self.data.append(elem) 97 | self.data.sort() 98 | self.data.reverse() 99 | while len(self.data) > self.size: 100 | del self.data[-1] 101 | 102 | def pop(self): 103 | del self.data[-1] 104 | 105 | def __str__(self): 106 | return "[\n\t" + ", \n\t".join([str(ci) for ci in self.data]) + "\n]" 107 | 108 | def __len__(self): 109 | return len(self.data) 110 | 111 | def __getitem__(self, index): 112 | return self.data[index] 113 | 114 | 115 | class DrawBest(object): 116 | def __init__(self, sdBest: BestList, dxBest: BestList, userName: str): 117 | self.sdBest = sdBest 118 | self.dxBest = dxBest 119 | self.userName = self._stringQ2B(userName) 120 | self.sdRating = 0 121 | self.dxRating = 0 122 | for sd in sdBest: 123 | self.sdRating += computeRa(sd.ds, sd.achievement) 124 | for dx in dxBest: 125 | self.dxRating += computeRa(dx.ds, dx.achievement) 126 | self.playerRating = self.sdRating + self.dxRating 127 | self.pic_dir = STATIC + "/mai/pic/" 128 | self.cover_dir = STATIC + "/mai/cover/" 129 | self.img = Image.open(self.pic_dir + "UI_TTR_BG_Base_Plus.png").convert("RGBA") 130 | self.ROWS_IMG = [2] 131 | for i in range(6): 132 | self.ROWS_IMG.append(116 + 96 * i) 133 | self.COLOUMS_IMG = [] 134 | for i in range(8): 135 | self.COLOUMS_IMG.append(2 + 138 * i) 136 | for i in range(4): 137 | self.COLOUMS_IMG.append(988 + 138 * i) 138 | self.draw() 139 | 140 | def _Q2B(self, uchar): 141 | """单个字符 全角转半角""" 142 | inside_code = ord(uchar) 143 | if inside_code == 0x3000: 144 | inside_code = 0x0020 145 | else: 146 | inside_code -= 0xFEE0 147 | if inside_code < 0x0020 or inside_code > 0x7E: # 转完之后不是半角字符返回原来的字符 148 | return uchar 149 | return chr(inside_code) 150 | 151 | def _stringQ2B(self, ustring): 152 | """把字符串全角转半角""" 153 | return "".join([self._Q2B(uchar) for uchar in ustring]) 154 | 155 | def _getCharWidth(self, o) -> int: 156 | widths = [ 157 | (126, 1), 158 | (159, 0), 159 | (687, 1), 160 | (710, 0), 161 | (711, 1), 162 | (727, 0), 163 | (733, 1), 164 | (879, 0), 165 | (1154, 1), 166 | (1161, 0), 167 | (4347, 1), 168 | (4447, 2), 169 | (7467, 1), 170 | (7521, 0), 171 | (8369, 1), 172 | (8426, 0), 173 | (9000, 1), 174 | (9002, 2), 175 | (11021, 1), 176 | (12350, 2), 177 | (12351, 1), 178 | (12438, 2), 179 | (12442, 0), 180 | (19893, 2), 181 | (19967, 1), 182 | (55203, 2), 183 | (63743, 1), 184 | (64106, 2), 185 | (65039, 1), 186 | (65059, 0), 187 | (65131, 2), 188 | (65279, 1), 189 | (65376, 2), 190 | (65500, 1), 191 | (65510, 2), 192 | (120831, 1), 193 | (262141, 2), 194 | (1114109, 1), 195 | ] 196 | if o == 0xE or o == 0xF: 197 | return 0 198 | for num, wid in widths: 199 | if o <= num: 200 | return wid 201 | return 1 202 | 203 | def _coloumWidth(self, s: str): 204 | res = 0 205 | for ch in s: 206 | res += self._getCharWidth(ord(ch)) 207 | return res 208 | 209 | def _changeColumnWidth(self, s: str, lens: int) -> str: 210 | res = 0 211 | sList = [] 212 | for ch in s: 213 | res += self._getCharWidth(ord(ch)) 214 | if res <= lens: 215 | sList.append(ch) 216 | return "".join(sList) 217 | 218 | def _resizePic(self, img: Image.Image, time: float): 219 | return img.resize((int(img.size[0] * time), int(img.size[1] * time))) 220 | 221 | def _findRaPic(self) -> str: 222 | num = "10" 223 | if self.playerRating < 1000: 224 | num = "01" 225 | elif self.playerRating < 2000: 226 | num = "02" 227 | elif self.playerRating < 4000: 228 | num = "03" 229 | elif self.playerRating < 7000: 230 | num = "04" 231 | elif self.playerRating < 10000: 232 | num = "05" 233 | elif self.playerRating < 12000: 234 | num = "06" 235 | elif self.playerRating < 13000: 236 | num = "07" 237 | elif self.playerRating < 14000: 238 | num = "08" 239 | elif self.playerRating < 15000: 240 | num = "09" 241 | return f"UI_CMN_DXRating_S_{num}.png" 242 | 243 | def _drawRating(self, ratingBaseImg: Image.Image): 244 | COLOUMS_RATING = [86, 100, 115, 130, 145] 245 | theRa = self.playerRating 246 | i = 4 247 | while theRa: 248 | digit = theRa % 10 249 | theRa = theRa // 10 250 | digitImg = Image.open(self.pic_dir + f"UI_NUM_Drating_{digit}.png").convert( 251 | "RGBA", 252 | ) 253 | digitImg = self._resizePic(digitImg, 0.6) 254 | ratingBaseImg.paste( 255 | digitImg, 256 | (COLOUMS_RATING[i] - 2, 9), 257 | mask=digitImg.split()[3], 258 | ) 259 | i = i - 1 260 | return ratingBaseImg 261 | 262 | def _drawBestList(self, img: Image.Image, sdBest: BestList, dxBest: BestList): 263 | itemW = 131 264 | itemH = 88 265 | Color = [ 266 | (69, 193, 36), 267 | (255, 186, 1), 268 | (255, 90, 102), 269 | (134, 49, 200), 270 | (217, 197, 233), 271 | ] 272 | levelTriagle = [(itemW, 0), (itemW - 27, 0), (itemW, 27)] 273 | rankPic = "D C B BB BBB A AA AAA S Sp SS SSp SSS SSSp".split(" ") 274 | comboPic = " FC FCp AP APp".split(" ") 275 | ImageDraw.Draw(img) 276 | titleFontName = STATIC + "/adobe_simhei.otf" 277 | for num in range(len(sdBest)): 278 | i = num // 7 279 | j = num % 7 280 | chartInfo = sdBest[num] 281 | pngPath = self.cover_dir + f"{get_cover_len5_id(chartInfo.idNum)}.png" 282 | if not Path(pngPath).is_file(): 283 | pngPath = self.cover_dir + "01000.png" 284 | temp = Image.open(pngPath).convert("RGB") 285 | temp = self._resizePic(temp, itemW / temp.size[0]) 286 | temp = temp.crop( 287 | (0, (temp.size[1] - itemH) / 2, itemW, (temp.size[1] + itemH) / 2), # type: ignore 288 | ) 289 | temp = temp.filter(ImageFilter.GaussianBlur(3)) 290 | temp = temp.point(lambda p: int(p * 0.72)) 291 | 292 | tempDraw = ImageDraw.Draw(temp) 293 | tempDraw.polygon(levelTriagle, Color[chartInfo.diff]) 294 | font = ImageFont.truetype(titleFontName, 16, encoding="utf-8") 295 | title = chartInfo.title 296 | if self._coloumWidth(title) > 15: 297 | title = self._changeColumnWidth(title, 12) + "..." 298 | tempDraw.text((8, 8), title, "white", font) 299 | font = ImageFont.truetype(titleFontName, 12, encoding="utf-8") 300 | 301 | tempDraw.text((7, 28), f'{"%.4f" % chartInfo.achievement}%', "white", font) 302 | rankImg = Image.open( 303 | self.pic_dir + f"UI_GAM_Rank_{rankPic[chartInfo.scoreId]}.png", 304 | ).convert("RGBA") 305 | rankImg = self._resizePic(rankImg, 0.3) 306 | temp.paste(rankImg, (72, 28), rankImg.split()[3]) 307 | if chartInfo.comboId: 308 | comboImg = Image.open( 309 | self.pic_dir 310 | + f"UI_MSS_MBase_Icon_{comboPic[chartInfo.comboId]}_S.png", 311 | ).convert("RGBA") 312 | comboImg = self._resizePic(comboImg, 0.45) 313 | temp.paste(comboImg, (103, 27), comboImg.split()[3]) 314 | font = ImageFont.truetype(STATIC + "/adobe_simhei.otf", 12, encoding="utf-8") 315 | tempDraw.text( 316 | (8, 44), 317 | f"Base: {chartInfo.ds} -> {computeRa(chartInfo.ds, chartInfo.achievement)}", 318 | "white", 319 | font, 320 | ) 321 | font = ImageFont.truetype(STATIC + "/adobe_simhei.otf", 18, encoding="utf-8") 322 | tempDraw.text((8, 60), f"#{num + 1}", "white", font) 323 | 324 | recBase = Image.new("RGBA", (itemW, itemH), "black") 325 | recBase = recBase.point(lambda p: int(p * 0.8)) 326 | img.paste(recBase, (self.COLOUMS_IMG[j] + 5, self.ROWS_IMG[i + 1] + 5)) 327 | img.paste(temp, (self.COLOUMS_IMG[j] + 4, self.ROWS_IMG[i + 1] + 4)) 328 | for num in range(len(sdBest), sdBest.size): 329 | i = num // 7 330 | j = num % 7 331 | temp = Image.open(self.cover_dir + "01000.png").convert("RGB") 332 | temp = self._resizePic(temp, itemW / temp.size[0]) 333 | temp = temp.crop( 334 | (0, (temp.size[1] - itemH) / 2, itemW, (temp.size[1] + itemH) / 2), # type: ignore 335 | ) 336 | temp = temp.filter(ImageFilter.GaussianBlur(1)) 337 | img.paste(temp, (self.COLOUMS_IMG[j] + 4, self.ROWS_IMG[i + 1] + 4)) 338 | for num in range(len(dxBest)): 339 | i = num // 3 340 | j = num % 3 341 | chartInfo = dxBest[num] 342 | pngPath = self.cover_dir + f"{get_cover_len5_id(chartInfo.idNum)}.png" 343 | if not Path(pngPath).is_file(): 344 | pngPath = self.cover_dir + "01000.png" 345 | temp = Image.open(pngPath).convert("RGB") 346 | temp = self._resizePic(temp, itemW / temp.size[0]) 347 | temp = temp.crop( 348 | (0, (temp.size[1] - itemH) / 2, itemW, (temp.size[1] + itemH) / 2), # type: ignore 349 | ) 350 | temp = temp.filter(ImageFilter.GaussianBlur(3)) 351 | temp = temp.point(lambda p: int(p * 0.72)) 352 | 353 | tempDraw = ImageDraw.Draw(temp) 354 | tempDraw.polygon(levelTriagle, Color[chartInfo.diff]) 355 | font = ImageFont.truetype(titleFontName, 14, encoding="utf-8") 356 | title = chartInfo.title 357 | if self._coloumWidth(title) > 13: 358 | title = self._changeColumnWidth(title, 12) + "..." 359 | tempDraw.text((8, 8), title, "white", font) 360 | font = ImageFont.truetype(titleFontName, 12, encoding="utf-8") 361 | 362 | tempDraw.text((7, 28), f'{"%.4f" % chartInfo.achievement}%', "white", font) 363 | rankImg = Image.open( 364 | self.pic_dir + f"UI_GAM_Rank_{rankPic[chartInfo.scoreId]}.png", 365 | ).convert("RGBA") 366 | rankImg = self._resizePic(rankImg, 0.3) 367 | temp.paste(rankImg, (72, 28), rankImg.split()[3]) 368 | if chartInfo.comboId: 369 | comboImg = Image.open( 370 | self.pic_dir 371 | + f"UI_MSS_MBase_Icon_{comboPic[chartInfo.comboId]}_S.png", 372 | ).convert("RGBA") 373 | comboImg = self._resizePic(comboImg, 0.45) 374 | temp.paste(comboImg, (103, 27), comboImg.split()[3]) 375 | font = ImageFont.truetype(STATIC + "/adobe_simhei.otf", 12, encoding="utf-8") 376 | tempDraw.text( 377 | (8, 44), 378 | f"Base: {chartInfo.ds} -> {chartInfo.ra}", 379 | "white", 380 | font, 381 | ) 382 | font = ImageFont.truetype(STATIC + "/adobe_simhei.otf", 18, encoding="utf-8") 383 | tempDraw.text((8, 60), f"#{num + 1}", "white", font) 384 | 385 | recBase = Image.new("RGBA", (itemW, itemH), "black") 386 | recBase = recBase.point(lambda p: int(p * 0.8)) 387 | img.paste(recBase, (self.COLOUMS_IMG[j + 8] + 5, self.ROWS_IMG[i + 1] + 5)) 388 | img.paste(temp, (self.COLOUMS_IMG[j + 8] + 4, self.ROWS_IMG[i + 1] + 4)) 389 | for num in range(len(dxBest), dxBest.size): 390 | i = num // 3 391 | j = num % 3 392 | temp = Image.open(self.cover_dir + "01000.png").convert("RGB") 393 | temp = self._resizePic(temp, itemW / temp.size[0]) 394 | temp = temp.crop( 395 | (0, (temp.size[1] - itemH) / 2, itemW, (temp.size[1] + itemH) / 2), # type: ignore 396 | ) 397 | temp = temp.filter(ImageFilter.GaussianBlur(1)) 398 | img.paste(temp, (self.COLOUMS_IMG[j + 8] + 4, self.ROWS_IMG[i + 1] + 4)) 399 | 400 | def draw(self): 401 | splashLogo = Image.open( 402 | self.pic_dir + "UI_CMN_TabTitle_MaimaiTitle_Ver214.png", 403 | ).convert("RGBA") 404 | splashLogo = self._resizePic(splashLogo, 0.65) 405 | self.img.paste(splashLogo, (10, 10), mask=splashLogo.split()[3]) 406 | 407 | ratingBaseImg = Image.open(self.pic_dir + self._findRaPic()).convert("RGBA") 408 | ratingBaseImg = self._drawRating(ratingBaseImg) 409 | ratingBaseImg = self._resizePic(ratingBaseImg, 0.85) 410 | self.img.paste(ratingBaseImg, (240, 8), mask=ratingBaseImg.split()[3]) 411 | 412 | namePlateImg = Image.open(self.pic_dir + "UI_TST_PlateMask.png").convert("RGBA") 413 | namePlateImg = namePlateImg.resize((285, 40)) 414 | namePlateDraw = ImageDraw.Draw(namePlateImg) 415 | font1 = ImageFont.truetype(STATIC + "/msyh.ttc", 28, encoding="unic") 416 | namePlateDraw.text((12, 4), " ".join(list(self.userName)), "black", font1) 417 | nameDxImg = Image.open(self.pic_dir + "UI_CMN_Name_DX.png").convert("RGBA") 418 | nameDxImg = self._resizePic(nameDxImg, 0.9) 419 | namePlateImg.paste(nameDxImg, (230, 4), mask=nameDxImg.split()[3]) 420 | self.img.paste(namePlateImg, (240, 40), mask=namePlateImg.split()[3]) 421 | 422 | shougouImg = Image.open(self.pic_dir + "UI_CMN_Shougou_Rainbow.png").convert( 423 | "RGBA", 424 | ) 425 | shougouDraw = ImageDraw.Draw(shougouImg) 426 | font2 = ImageFont.truetype(STATIC + "/adobe_simhei.otf", 14, encoding="utf-8") 427 | playCountInfo = ( 428 | f"SD: {self.sdRating} + DX: {self.dxRating} = {self.playerRating}" 429 | ) 430 | shougouImgW, shougouImgH = shougouImg.size 431 | playCountInfoW, playCountInfoH = shougouDraw.textsize(playCountInfo, font2) 432 | textPos = ( 433 | (shougouImgW - playCountInfoW - font2.getoffset(playCountInfo)[0]) / 2, 434 | 5, 435 | ) 436 | shougouDraw.text((textPos[0] - 1, textPos[1]), playCountInfo, "black", font2) 437 | shougouDraw.text((textPos[0] + 1, textPos[1]), playCountInfo, "black", font2) 438 | shougouDraw.text((textPos[0], textPos[1] - 1), playCountInfo, "black", font2) 439 | shougouDraw.text((textPos[0], textPos[1] + 1), playCountInfo, "black", font2) 440 | shougouDraw.text((textPos[0] - 1, textPos[1] - 1), playCountInfo, "black", font2) 441 | shougouDraw.text((textPos[0] + 1, textPos[1] - 1), playCountInfo, "black", font2) 442 | shougouDraw.text((textPos[0] - 1, textPos[1] + 1), playCountInfo, "black", font2) 443 | shougouDraw.text((textPos[0] + 1, textPos[1] + 1), playCountInfo, "black", font2) 444 | shougouDraw.text(textPos, playCountInfo, "white", font2) 445 | shougouImg = self._resizePic(shougouImg, 1.05) 446 | self.img.paste(shougouImg, (240, 83), mask=shougouImg.split()[3]) 447 | 448 | self._drawBestList(self.img, self.sdBest, self.dxBest) 449 | 450 | authorBoardImg = Image.open(self.pic_dir + "UI_CMN_MiniDialog_01.png").convert( 451 | "RGBA", 452 | ) 453 | authorBoardImg = self._resizePic(authorBoardImg, 0.35) 454 | authorBoardDraw = ImageDraw.Draw(authorBoardImg) 455 | authorBoardDraw.text( 456 | (31, 28), 457 | " Generated By\nXybBot & Chiyuki", 458 | "black", 459 | font2, 460 | ) 461 | self.img.paste(authorBoardImg, (1224, 19), mask=authorBoardImg.split()[3]) 462 | 463 | dxImg = Image.open(self.pic_dir + "UI_RSL_MBase_Parts_01.png").convert("RGBA") 464 | self.img.paste(dxImg, (988, 65), mask=dxImg.split()[3]) 465 | sdImg = Image.open(self.pic_dir + "UI_RSL_MBase_Parts_02.png").convert("RGBA") 466 | self.img.paste(sdImg, (865, 65), mask=sdImg.split()[3]) 467 | 468 | # self.img.show() 469 | 470 | def getDir(self): 471 | return self.img 472 | 473 | 474 | def computeRa(ds: float, achievement: float) -> int: 475 | baseRa = 22.4 476 | if achievement < 50: 477 | baseRa = 7.0 478 | elif achievement < 60: 479 | baseRa = 8.0 480 | elif achievement < 70: 481 | baseRa = 9.6 482 | elif achievement < 75: 483 | baseRa = 11.2 484 | elif achievement < 80: 485 | baseRa = 12.0 486 | elif achievement < 90: 487 | baseRa = 13.6 488 | elif achievement < 94: 489 | baseRa = 15.2 490 | elif achievement < 97: 491 | baseRa = 16.8 492 | elif achievement < 98: 493 | baseRa = 20.0 494 | elif achievement < 99: 495 | baseRa = 20.3 496 | elif achievement < 99.5: 497 | baseRa = 20.8 498 | elif achievement < 100: 499 | baseRa = 21.1 500 | elif achievement < 100.5: 501 | baseRa = 21.6 502 | 503 | return math.floor(ds * (min(100.5, achievement) / 100) * baseRa) 504 | 505 | 506 | async def generate50(payload: Dict) -> Tuple[Optional[Image.Image], int]: 507 | async with aiohttp.request( 508 | "POST", 509 | "https://www.diving-fish.com/api/maimaidxprober/query/player", 510 | json=payload, 511 | ) as resp: 512 | if resp.status == 400: 513 | return None, 400 514 | if resp.status == 403: 515 | return None, 403 516 | sd_best = BestList(35) 517 | dx_best = BestList(15) 518 | obj = await resp.json() 519 | dx: List[Dict] = obj["charts"]["dx"] 520 | sd: List[Dict] = obj["charts"]["sd"] 521 | for c in sd: 522 | sd_best.push(ChartInfo.from_json(c)) 523 | for c in dx: 524 | dx_best.push(ChartInfo.from_json(c)) 525 | pic = DrawBest(sd_best, dx_best, obj["nickname"]).getDir() 526 | return pic, 0 527 | -------------------------------------------------------------------------------- /nonebot_plugin_maimai/libraries/maimaidx_music.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import random 3 | from copy import deepcopy 4 | from typing import Any, Dict, List, Optional, Tuple, Union 5 | 6 | import aiohttp 7 | 8 | 9 | def get_cover_len5_id(mid) -> str: 10 | mid = int(mid) 11 | if mid > 10000 and mid <= 11000: 12 | mid -= 10000 13 | return f"{mid:05d}" 14 | 15 | 16 | def cross(checker: List[Any], elem: Optional[Union[Any, List[Any]]], diff): 17 | ret = False 18 | diff_ret = [] 19 | if not elem or elem is Ellipsis: 20 | return True, diff 21 | if isinstance(elem, List): 22 | for _j in range(len(checker)) if diff is Ellipsis else diff: 23 | if _j >= len(checker): 24 | continue 25 | __e = checker[_j] 26 | if __e in elem: 27 | diff_ret.append(_j) 28 | ret = True 29 | elif isinstance(elem, Tuple): 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 elem[0] <= __e <= elem[1]: 35 | diff_ret.append(_j) 36 | ret = True 37 | else: 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 == __e: 43 | return True, [_j] 44 | return ret, diff_ret 45 | 46 | 47 | def in_or_equal(checker: Any, elem: Optional[Union[Any, List[Any]]]): 48 | if elem is Ellipsis: 49 | return True 50 | if isinstance(elem, List): 51 | return checker in elem 52 | elif isinstance(elem, Tuple): 53 | return elem[0] <= checker <= elem[1] 54 | else: 55 | return checker == elem 56 | 57 | 58 | class Chart(Dict): 59 | tap: Optional[int] = None 60 | slide: Optional[int] = None 61 | hold: Optional[int] = None 62 | touch: Optional[int] = None 63 | brk: Optional[int] = None 64 | charter: Optional[int] = None 65 | 66 | def __getattribute__(self, item): 67 | if item == "tap": 68 | return self["notes"][0] 69 | elif item == "hold": 70 | return self["notes"][1] 71 | elif item == "slide": 72 | return self["notes"][2] 73 | elif item == "touch": 74 | return self["notes"][3] if len(self["notes"]) == 5 else 0 75 | elif item == "brk": 76 | return self["notes"][-1] 77 | elif item == "charter": 78 | return self["charter"] 79 | return super().__getattribute__(item) 80 | 81 | 82 | class Music(Dict): 83 | id: Optional[str] = None 84 | title: Optional[str] = None 85 | ds: Optional[List[float]] = None 86 | level: Optional[List[str]] = None 87 | genre: Optional[str] = None 88 | type: Optional[str] = None 89 | bpm: Optional[float] = None 90 | version: Optional[str] = None 91 | charts: Optional[Chart] = None 92 | release_date: Optional[str] = None 93 | artist: Optional[str] = None 94 | 95 | diff: List[int] = [] 96 | 97 | def __getattribute__(self, item): 98 | if item in {"genre", "artist", "release_date", "bpm", "version"}: 99 | if item == "version": 100 | return self["basic_info"]["from"] 101 | return self["basic_info"][item] 102 | elif item in self: 103 | return self[item] 104 | return super().__getattribute__(item) 105 | 106 | 107 | class MusicList(List[Music]): 108 | def by_id(self, music_id: str) -> Optional[Music]: 109 | for music in self: 110 | if music.id == music_id: 111 | return music 112 | return None 113 | 114 | def by_title(self, music_title: str) -> Optional[Music]: 115 | for music in self: 116 | if music.title == music_title: 117 | return music 118 | return None 119 | 120 | def random(self): 121 | return random.choice(self) 122 | 123 | def filter( 124 | self, 125 | *, 126 | level: Optional[Union[str, List[str]]] = ..., 127 | ds: Optional[Union[float, List[float], Tuple[float, float]]] = ..., 128 | title_search: Optional[str] = ..., 129 | genre: Optional[Union[str, List[str]]] = ..., 130 | bpm: Optional[Union[float, List[float], Tuple[float, float]]] = ..., 131 | type: Optional[Union[str, List[str]]] = ..., 132 | diff: List[int] = ..., 133 | ): 134 | new_list = MusicList() 135 | for music in self: 136 | diff2 = diff 137 | music = deepcopy(music) 138 | if not music.level: 139 | return 140 | if not music.ds: 141 | return 142 | if not title_search: 143 | return 144 | if not music.title: 145 | return 146 | ret, diff2 = cross(music.level, level, diff2) 147 | if not ret: 148 | continue 149 | ret, diff2 = cross(music.ds, ds, diff2) 150 | if not ret: 151 | continue 152 | if not in_or_equal(music.genre, genre): 153 | continue 154 | if not in_or_equal(music.type, type): 155 | continue 156 | if not in_or_equal(music.bpm, bpm): 157 | continue 158 | if ( 159 | title_search is not Ellipsis 160 | and title_search.lower() not in music.title.lower() 161 | ): 162 | continue 163 | music.diff = diff2 164 | new_list.append(music) 165 | return new_list 166 | 167 | 168 | async def main(): 169 | global obj, total_list 170 | 171 | async def fetch_json(url): 172 | async with aiohttp.ClientSession() as session: 173 | async with session.get(url) as response: 174 | return await response.json() 175 | 176 | obj = await fetch_json("https://www.diving-fish.com/api/maimaidxprober/music_data") 177 | total_list = MusicList(obj) 178 | for __i in range(len(total_list)): 179 | total_list[__i] = Music(total_list[__i]) 180 | if total_list[__i].charts is None: 181 | return None 182 | for __j in range(len(total_list[__i].charts)): # type: ignore 183 | total_list[__i].charts[__j] = Chart(total_list[__i].charts[__j]) 184 | 185 | 186 | asyncio.run(main()) 187 | -------------------------------------------------------------------------------- /nonebot_plugin_maimai/libraries/tool.py: -------------------------------------------------------------------------------- 1 | import time 2 | from pathlib import Path 3 | 4 | try: 5 | STATIC = str(Path().joinpath("data/maimai")) 6 | except FileNotFoundError: 7 | STATIC = str(Path(__file__).parent.parent) 8 | 9 | 10 | def hash_(qq: int): 11 | days = ( 12 | int(time.strftime("%d", time.localtime(time.time()))) 13 | + 31 * int(time.strftime("%m", time.localtime(time.time()))) 14 | + 77 15 | ) 16 | return (days * qq) >> 8 17 | -------------------------------------------------------------------------------- /nonebot_plugin_maimai/public.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import random 4 | import re 5 | import subprocess 6 | import zipfile 7 | from pathlib import Path 8 | from typing import Dict, List, Set, Union 9 | 10 | import aiohttp 11 | import httpx 12 | from bs4 import BeautifulSoup 13 | from nonebot import get_driver, on_command 14 | from nonebot.adapters.onebot.v11 import Message, MessageSegment 15 | 16 | # from nonebot.exception import IgnoredException 17 | from nonebot.log import logger 18 | from nonebot.matcher import Matcher 19 | 20 | # from nonebot.message import event_preprocessor 21 | from nonebot.params import CommandArg, RawCommand 22 | from nonebot_plugin_txt2img import Txt2Img 23 | from pydantic import BaseModel, Extra 24 | 25 | from .api import update_pl 26 | from .libraries.image import * 27 | 28 | 29 | class Config(BaseModel): 30 | """基本配置""" 31 | 32 | bot_nickname: str = "宁宁" 33 | maimai_font: str = "simsun.ttc" 34 | master_id: Union[List[str], Set[str]] = get_driver().config.superusers 35 | b_cookie: str = "b_nut=1649576401; buvid3=1E315685-39D7-CED8-F3F1-243C09E1F2E402464infoc; i-wanna-go-back=-1; buvid_fp_plain=undefined; CURRENT_BLACKGAP=0; LIVE_BUVID=AUTO8116495836832058; blackside_state=0; PVID=1; buvid4=1978384A-7128-E8DD-7067-595341C2F6BB02464-022041015-GcxPOTfDq8w%2FtuBv55%2BLdQ%3D%3D; rpdid=|(YuRll)|~l0J'uYY)mJJRl|; CURRENT_FNVAL=4048; header_theme_version=CLOSE; fingerprint=6b47357ae6c97f2cdf90243e8c57b973; CURRENT_PID=eada9510-d0f2-11ed-b243-8b1406a7fccc; DedeUserID=60824233; DedeUserID__ckMd5=7cfd5a1f149fedcc; b_ut=5; _uuid=1E6E8C21-4FC6-8F106-EE71-4210107CEF105DF35899infoc; FEED_LIVE_VERSION=V8; nostalgia_conf=-1; bp_video_offset_60824233=792868838749241300; CURRENT_QUALITY=64; home_feed_column=5; SESSDATA=b8c82b67%2C1704203767%2Ce49ff%2A71-fAq4c-tiCsv2xLJKAbdwkmTP4VbOeQWX9DmlCZBeKungWYZDVcsCMNDVohyiVIpNtJ4kQAAIQA; bili_jct=ac45f014bd11b174389d07077744f044; buvid_fp=a275cb625909c14200ae434eefcc8d94; browser_resolution=1492-771" 36 | 37 | class Config: 38 | extra = Extra.ignore 39 | 40 | 41 | config = Config.parse_obj(get_driver().config) 42 | 43 | headers = { 44 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.71 Safari/537.36 SE 2.X MetaSr 1.0", 45 | "cookie": config.b_cookie, 46 | } 47 | 48 | 49 | # @event_preprocessor 50 | # async def preprocessor(bot: Bot, event:Event, state): 51 | # if ( 52 | # hasattr(event, "message_type") 53 | # and event.message_type == "private" 54 | # and event.sub_type != "friend" 55 | # ): 56 | # raise IgnoredException("not reply group temp message") 57 | 58 | 59 | help_msg = on_command("help", aliases={"舞萌帮助", "mai帮助"}) 60 | 61 | 62 | @help_msg.handle() 63 | async def _(): 64 | help_str = """可用命令如下: 65 | 今日舞萌 查看今天的舞萌运势 66 | XXXmaimaiXXX什么 随机一首歌 67 | 随个[dx/标准][绿黄红紫白]<难度> 随机一首指定条件的乐曲 68 | 查歌<乐曲标题的一部分> 查询符合条件的乐曲 69 | [绿黄红紫白]id<歌曲编号> 查询乐曲信息或谱面信息 70 | <歌曲别名>是什么歌 查询乐曲别名对应的乐曲 71 | 定数查歌 <定数> 查询定数对应的乐曲 72 | 定数查歌 <定数下限> <定数上限> 73 | 分数线 <难度+歌曲id> <分数线> 详情请输入“分数线 帮助”查看 74 | 搜<手元><理论><谱面确认>""" 75 | # await help.send(Message([ 76 | # MessageSegment("image", { 77 | # "file": f"base64://{str(image_to_base64(text_to_image(help_str)), encoding='utf-8')}" 78 | # }) 79 | # ])) 80 | title = "可用命令如下:" 81 | txt2img = Txt2Img() 82 | txt2img.set_font_size(font_size=32) 83 | pic = txt2img.draw(title, help_str) 84 | try: 85 | await help.send(MessageSegment.image(pic)) 86 | except Exception: 87 | await help.send(help_str) 88 | 89 | 90 | search = on_command("搜手元", aliases={"搜理论", "搜谱面确认"}) 91 | 92 | 93 | @search.handle() 94 | async def _(matcher: Matcher, command: str = RawCommand(), arg: Message = CommandArg()): 95 | keyword = command.replace("搜", "") 96 | msgs = arg.extract_plain_text() 97 | if not msgs: 98 | await matcher.finish("请把要搜索的内容放在后面哦") 99 | data_list: List[Dict[str, Dict[str, str]]] = await get_target(keyword + msgs) 100 | msg = data_list 101 | 102 | choice_dict = random.randint(1, len(data_list)) 103 | # result_img = await data_to_img(data_list) 104 | # img = BytesIO() 105 | # result_img.save(img,format="png") 106 | # img_bytes = img.getvalue() 107 | # await matcher.send(MessageSegment.image(img_bytes)) 108 | 109 | # @search.got("tap",prompt="请输入需要的序号") 110 | # async def _(state: T_State,matcher:Matcher ): 111 | # tags:Message = state['tap'] 112 | # tag = tags.extract_plain_text() 113 | # if tag.isdigit() and int(tag) in range(1, 10): 114 | print(msg[choice_dict]) 115 | # msg:List[Dict[str,Dict[str,str]]] = state['msg'] 116 | Url = msg[int(choice_dict) - 1]["url"]["视频链接:"] 117 | title = msg[int(choice_dict) - 1]["data"]["视频标题:"] 118 | pic = msg[int(choice_dict) - 1]["url"]["封面:"] 119 | await matcher.send(title + MessageSegment.image(pic) + Url) 120 | # try: 121 | Url = Url.replace("\n", "").replace("\r", "") 122 | # await b_to_url(Url, matcher=matcher, video_title=title) 123 | # await matcher.finish(MessageSegment.video(Url)) 124 | # except Exception as E: 125 | # logger.warning(E) 126 | # await matcher.finish(Url) 127 | 128 | 129 | async def fetch_page(url): 130 | # print(headers) 131 | async with aiohttp.ClientSession(headers=headers) as session: 132 | async with session.get(url) as response: 133 | return await response.text() 134 | 135 | 136 | async def get_target(keyword: str): 137 | mainurl = "https://search.bilibili.com/all?keyword=" + keyword 138 | content = await fetch_page(mainurl) 139 | mainsoup = BeautifulSoup(content, "html.parser") 140 | viedoNum = 1 141 | msg_list: List[Dict[str, Dict[str, str]]] = [] 142 | for item in mainsoup.find_all("div", class_="bili-video-card"): 143 | item: BeautifulSoup 144 | msg: Dict[str, Dict[str, str]] = {"data": {}, "url": {}} 145 | # try: 146 | msg["data"]["序号:"] = "第" + viedoNum.__str__() + "个视频:" 147 | val = item.find("div", class_="bili-video-card__info--right") 148 | if val: 149 | msg["data"]["视频标题:"] = val.find("h3", class_="bili-video-card__info--tit")[ # type: ignore 150 | "title" 151 | ] # type: ignore 152 | msg["url"]["视频链接:"] = "https:" + val.find("a")["href"] + "\n" # type: ignore 153 | try: 154 | msg["data"]["up主:"] = item.find( 155 | "span", 156 | class_="bili-video-card__info--author", 157 | ).text.strip() # type: ignore 158 | msg["data"]["视频观看量:"] = item.select( 159 | "span.bili-video-card__stats--item span", 160 | )[0].text.strip() 161 | except (AttributeError, IndexError): 162 | continue 163 | 164 | msg["data"]["弹幕量:"] = item.select("span.bili-video-card__stats--item span")[ 165 | 1 166 | ].text.strip() 167 | msg["data"]["上传时间:"] = item.find( 168 | "span", 169 | class_="bili-video-card__info--date", 170 | ).text.strip() # type: ignore 171 | msg["data"]["视频时长:"] = item.find( 172 | "span", 173 | class_="bili-video-card__stats__duration", 174 | ).text.strip() # type: ignore 175 | msg["url"]["封面:"] = "https:" + item.find("img").get("src") # type: ignore 176 | # except: 177 | # continue 178 | msg_list.append(msg) 179 | if viedoNum == 9: 180 | break 181 | viedoNum += 1 182 | return msg_list 183 | 184 | 185 | def getDownloadUrl(url: str): 186 | """ 187 | 爬取下载链接 188 | :param url: 189 | :return: 190 | """ 191 | with httpx.Client(follow_redirects=True) as client: 192 | resp = client.get(url, headers=headers) 193 | print(resp.text) 194 | info = re.search( 195 | r"