├── .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 |

4 |
5 |

6 |
7 |
8 |
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"