├── .github ├── FUNDING.yml └── workflows │ └── pypi-publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── nonebot_plugin_bawiki ├── __init__.py ├── command │ ├── __init__.py │ ├── arona.py │ ├── calender.py │ ├── clear_cache.py │ ├── craft.py │ ├── emoji.py │ ├── event.py │ ├── furniture.py │ ├── gacha.py │ ├── level_guide.py │ ├── logo_generate.py │ ├── manga.py │ ├── raid.py │ ├── raid_data_cn.py │ ├── stu_fav.py │ ├── stu_rank.py │ ├── stu_wiki_gamekee.py │ ├── stu_wiki_schale.py │ ├── time_atk.py │ ├── update_future.py │ └── voice.py ├── compat.py ├── config.py ├── data │ ├── __init__.py │ ├── arona.py │ ├── bawiki.py │ ├── gacha.py │ ├── gamekee.py │ ├── logo_generate.py │ ├── playwright.py │ ├── schaledb.py │ └── shittim_chest.py ├── help │ ├── __init__.py │ ├── const.py │ ├── manual.py │ └── pic_menu.py ├── resource │ ├── __init__.py │ └── res │ │ ├── gacha │ │ ├── gacha_bg.webp │ │ ├── gacha_bg_old.webp │ │ ├── gacha_card_bg.png │ │ ├── gacha_card_mask.png │ │ ├── gacha_new.png │ │ ├── gacha_pickup.png │ │ ├── gacha_star.png │ │ └── gacha_stu_err.png │ │ ├── gamekee │ │ └── gamekee_util.js │ │ ├── general │ │ ├── calender_banner.png │ │ └── gradient.webp │ │ ├── index.html │ │ ├── logo │ │ ├── ba_logo.js │ │ ├── cross.png │ │ └── halo.png │ │ ├── schale │ │ ├── schale_util.css │ │ └── schale_util.js │ │ └── shittim │ │ ├── assets │ │ ├── BG_AronaRoom_In.webp │ │ ├── Card_Bg.png │ │ ├── Common_Icon_Asist.png │ │ ├── diamond.webp │ │ ├── frame │ │ │ ├── Explosion.png │ │ │ ├── Mystic.png │ │ │ └── Pierce.png │ │ ├── gold.webp │ │ ├── role │ │ │ ├── DamageDealer.png │ │ │ ├── Healer.png │ │ │ ├── Supporter.png │ │ │ └── Tanker.png │ │ ├── silver.webp │ │ └── star │ │ │ ├── 2.png │ │ │ ├── 3.png │ │ │ ├── 4.png │ │ │ └── 5.png │ │ ├── css │ │ ├── shittim.css │ │ └── shittim_util.css │ │ ├── js │ │ └── shittim_util.js │ │ └── templates │ │ ├── base.html.jinja │ │ ├── components.html.jinja │ │ ├── content_raid_rank.html.jinja │ │ └── content_rank_detail.html.jinja └── util.py ├── pdm.lock └── pyproject.toml /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | custom: ["https://afdian.net/@lgc2333/"] 4 | -------------------------------------------------------------------------------- /.github/workflows/pypi-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python 🐍 distributions 📦 to PyPI 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | build-n-publish: 10 | name: Use PDM to Build and publish Python 🐍 distributions 📦 to PyPI 11 | runs-on: ubuntu-latest 12 | 13 | permissions: 14 | # IMPORTANT: this permission is mandatory for trusted publishing 15 | id-token: write 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@master 20 | with: 21 | submodules: true 22 | 23 | - name: Setup PDM 24 | uses: pdm-project/setup-pdm@v3 25 | 26 | - name: Build and Publish distribution 📦 to PyPI 27 | run: pdm publish 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | # /.vscode/ 3 | /testnb2/ 4 | /dist/ 5 | venv/ 6 | poetry.lock 7 | .pdm-python 8 | build/ 9 | __pycache__/ 10 | /bawiki-data/ 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 LgCookie 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 | BAWiki 8 | 9 | # NoneBot-Plugin-BAWiki 10 | 11 | _✨ 基于 NoneBot2 的碧蓝档案 Wiki 插件 ✨_ 12 | 13 | python 14 | 15 | pdm-managed 16 | 17 | 18 | wakatime 19 | 20 | 21 |
22 | 23 | 24 | Pydantic Version 1 Or 2 25 | 26 | 27 | license 28 | 29 | 30 | pypi 31 | 32 | 33 | pypi download 34 | 35 | 36 |
37 | 38 | 39 | NoneBot Registry 40 | 41 | 42 | Supported Adapters 43 | 44 | 45 |
46 | 47 | ## 💬 前言 48 | 49 | 诚邀各位帮忙更新插件数据源仓库!能帮这个小小插件贡献微薄之力,鄙人感激不尽!! 50 | [点击跳转 bawiki-data 查看详细贡献说明](https://github.com/lgc2333/bawiki-data) 51 | 52 | ### Tip 53 | 54 | - 本插件并不自带 `balogo` 指令需要的字体,请自行下载并安装到系统: 55 | [RoGSanSrfStd-Bd.otf](https://raw.githubusercontent.com/lgc-NB2Dev/readme/main/bawiki/RoGSanSrfStd-Bd.otf)、[GlowSansSC-Normal-v0.93.zip](https://github.com/welai/glow-sans/releases/download/v0.93/GlowSansSC-Normal-v0.93.zip) 56 | 57 | ## 📖 介绍 58 | 59 | 一个碧蓝档案的 Wiki 插件,主要数据来源为 [GameKee](https://ba.gamekee.com/) 与 [SchaleDB](https://lonqie.github.io/SchaleDB/) 60 | 插件灵感来源:[ba_calender](https://f.xiaolz.cn/forum.php?mod=viewthread&tid=145) 61 | 62 | ## 💿 安装 63 | 64 | 以下提到的方法 任选**其一** 即可 65 | 66 |
67 | [推荐] 使用 nb-cli 安装 68 | 在 nonebot2 项目的根目录下打开命令行, 输入以下指令即可安装 69 | 70 | ```bash 71 | nb plugin install nonebot-plugin-bawiki 72 | ``` 73 | 74 |
75 | 76 |
77 | 使用包管理器安装 78 | 在 nonebot2 项目的插件目录下, 打开命令行, 根据你使用的包管理器, 输入相应的安装命令 79 | 80 |
81 | pip 82 | 83 | ```bash 84 | pip install nonebot-plugin-bawiki 85 | ``` 86 | 87 |
88 |
89 | pdm 90 | 91 | ```bash 92 | pdm add nonebot-plugin-bawiki 93 | ``` 94 | 95 |
96 |
97 | poetry 98 | 99 | ```bash 100 | poetry add nonebot-plugin-bawiki 101 | ``` 102 | 103 |
104 |
105 | conda 106 | 107 | ```bash 108 | conda install nonebot-plugin-bawiki 109 | ``` 110 | 111 |
112 | 113 | 打开 nonebot2 项目根目录下的 `pyproject.toml` 文件, 在 `[tool.nonebot]` 部分的 `plugins` 项里追加写入 114 | 115 | ```toml 116 | [tool.nonebot] 117 | plugins = [ 118 | # ... 119 | "nonebot_plugin_bawiki" 120 | ] 121 | ``` 122 | 123 |
124 | 125 | ## ⚙️ 配置 126 | 127 | 在 nonebot2 项目的 `.env` 文件中添加下表中的配置 128 | 129 | | 配置项 | 必填 | 默认值 | 说明 | 130 | | :--------------------------: | :--: | :-----: | :-----------------------------------------------------------------------------------: | 131 | | `BA_PROXY` | 否 | `None` | 访问各种数据源时使用的代理 | 132 | | `BA_GACHA_COOL_DOWN` | 否 | `0` | 每群每人的抽卡冷却,单位秒 | 133 | | `BA_VOICE_USE_CARD` | 否 | `False` | 是否使用自定义音乐卡片发送角色语音 | 134 | | `BA_USE_FORWARD_MSG` | 否 | `True` | 是否使用合并转发发送部分消息 | 135 | | `BA_SCREENSHOT_TIMEOUT` | 否 | `60` | 网页截图超时,单位秒 | 136 | | `BA_DISABLE_CLASSIC_GACHA` | 否 | `False` | 抽卡次数 10 次以下时是否不使用经典抽卡样式 | 137 | | `BA_GACHA_MAX` | 否 | `200` | 单次抽卡最大次数 | 138 | | `BA_ILLEGAL_LIMIT` | 否 | `3` | 用户在长对话中非法操作多少次后直接结束对话,填 `0` 以禁用此功能 | 139 | | `BA_ARONA_SET_ALIAS_ONLY_SU` | 否 | `False` | 是否只有超级用户才能修改 `arona` 指令所用的别名 | 140 | | `BA_GAMEKEE_URL` | 否 | ... | GameKee 数据源的地址 | 141 | | `BA_SCHALE_URL` | 否 | ... | SchaleDB Json 数据源的地址 | 142 | | `BA_BAWIKI_DB_URL` | 否 | ... | bawiki-data 的地址 | 143 | | `BA_ARONA_API_URL` | 否 | ... | Arona Bot 数据源的地址 | 144 | | `BA_ARONA_CDN_URL` | 否 | ... | Arona Bot 图片 CDN 地址 | 145 | | `BA_SHITTIM_API_URL` | 否 | ... | 什亭之匣 API 地址 | 146 | | `BA_SHITTIM_URL` | 否 | ... | 什亭之匣网址 | 147 | | `BA_SHITTIM_DATA_URL` | 否 | ... | 什亭之匣数据地址 | 148 | | `BA_SHITTIM_KEY` | 否 | `None` | 什亭之匣 API Key(获取途径 [看这里](https://arona.icu/about)) | 149 | | `BA_SHITTIM_REQUEST_DELAY` | 否 | `0` | 请求什亭之匣 API 后的等待时间,用于测试时限制 QPS | 150 | | `BA_REQ_RETRY` | 否 | `1` | 每次请求的重试次数
当值为 `1` 时,总共会请求两次(请求一次,重试一次),以此类推 | 151 | | `BA_REQ_CACHE_TTL` | 否 | `10800` | 请求缓存的过期时间,单位秒 | 152 | | `BA_SHITTIM_REQ_CACHE_TTL` | 否 | `600` | 什亭之匣相关请求缓存的过期时间,单位秒 | 153 | | `BA_REQ_TIMEOUT` | 否 | `10.0` | 请求超时,单位秒,为 `None` 表示永不超时 | 154 | | `BA_AUTO_CLEAR_CACHE_PATH` | 否 | `False` | 是否在插件每次加载时自动清理缓存文件夹 | 155 | 156 | 166 | 167 | ## 🎉 使用 168 | 169 | ### 指令表 170 | 171 | 兼容 [nonebot-plugin-PicMenu](https://github.com/hamo-reid/nonebot_plugin_PicMenu) 172 | 173 | **现在 BAWiki 会自动帮你把 PicMenu 的字体设为系统已安装的字体,再也不需要麻烦的手动配置了,好耶~** 174 | 175 | 如果你不想用 PicMenu 的话,那么使用 `ba帮助` 指令即可; 176 | 如果装载了 PicMenu,`ba帮助` 指令会调用 PicMenu 来生成帮助图片并发送 177 | 178 | ## 📞 联系 179 | 180 | QQ:3076823485 181 | Telegram:[@lgc2333](https://t.me/lgc2333) 182 | 吹水群:[1105946125](https://jq.qq.com/?_wv=1027&k=Z3n1MpEp) 183 | 邮箱: 184 | 185 | ## 💡 鸣谢 186 | 187 | ### [GameKee](https://ba.gamekee.com/) &
[SchaleDB](https://lonqie.github.io/SchaleDB/) &
[Arona Bot](https://doc.arona.diyigemt.com/api/) &
[什亭之匣](https://arona.icu/) 188 | 189 | - 插件数据源提供 190 | 191 | ### [nulla2011/Bluearchive-logo](https://github.com/nulla2011/Bluearchive-logo) 192 | 193 | - 蔚蓝档案标题生成器 194 | 195 | 200 | 201 | ### `bawiki-data` 数据源贡献列表 202 | 203 | - 见 [bawiki-data](https://github.com/lgc-NB2Dev/bawiki-data) 204 | 205 | ## 💰 赞助 206 | 207 | **[赞助我](https://blog.lgc2333.top/donate)** 208 | 209 | 感谢大家的赞助!你们的赞助将是我继续创作的动力! 210 | 211 | ## 📝 更新日志 212 | 213 | ### 0.11.3 214 | 215 | - 修复中文括号转换错误的问题 216 | 217 | ### 0.11.2 218 | 219 | - 小重构小修复 220 | 221 | ### 0.11.1 222 | 223 | - 修复 `ba档线` 指令的问题 \([#56](https://github.com/lgc-NB2Dev/nonebot-plugin-bawiki/pull/56)\) 224 | 225 | ### 0.11.0 226 | 227 | - 适配 Pydantic V2 \([#55](https://github.com/lgc-NB2Dev/nonebot-plugin-bawiki/pull/55)\) 228 | 229 | ### 0.10.4 & 0.10.5 230 | 231 | - 修复 `ba爱丽丝的伙伴` 和 `ba小心卷狗` 显示的赛季标题错误问题 232 | 233 | ### 0.10.3 234 | 235 | - 删除指令 `ba总力排名` 236 | - 其他小更改 237 | 238 | ### 0.10.2 239 | 240 | - 为 `ba小心卷狗` 和 `ba爱丽丝的伙伴` 指令图添加难度显示 241 | - 修改帮助文案,新增指令别名 `ba总力档线` -> `ba档线`、`ba总力排名` -> `ba排名` 242 | 243 | ### 0.10.1 244 | 245 | - 修复 `ba总力档线` 指令返回图片中更新时间显示时区错误的问题 246 | - 新增配置项 `BA_ILLEGAL_LIMIT`、`BA_ARONA_SET_ALIAS_ONLY_SU`、`BA_SHITTIM_REQ_CACHE_TTL` 247 | 248 | ### 0.10.0 249 | 250 | - 新增 [什亭之匣](https://arona.icu/) 相关内容 251 | - 为 Arona 指令添加了添加、删除别名功能 252 | - 前瞻图默认列表个数改为 `3` 253 | - 为 `ba语音` 和 `ba漫画` 指令加上了列表选择 254 | - `ba学生wiki` 指令现在不显示学生语音列表了 255 | - 修改了 SchaleDB 的学生生日展示样式 256 | - 内置帮助指令以图片方式展示结果 257 | - 更新主线攻略查询地址 258 | - 配置项更改: 259 | - 添加 `BA_USE_FORWARD_MSG` 260 | - 添加 `BA_REQ_RETRY` 261 | - 添加 `BA_REQ_CACHE_TTL` 262 | - 添加 `BA_REQ_TIMEOUT` 263 | - 添加 `BA_SHITTIM_URL` 264 | - 添加 `BA_SHITTIM_API_URL` 265 | - 添加 `BA_SHITTIM_DATA_URL` 266 | - 添加 `BA_SHITTIM_KEY` 267 | - 添加 `BA_SHITTIM_REQUEST_DELAY` 268 | - 删除 `BA_CLEAR_REQ_CACHE_INTERVAL` 269 | - 重命名 `BA_AUTO_CLEAR_ARONA_CACHE` -> `BA_AUTO_CLEAR_CACHE_PATH` 270 | - 其他代码重构,Bug 修复 ~~,新增了一些 Bug(可能)~~ 271 | 272 |
273 | 未来将更新(点击展开) 274 | 275 | ### 1.0.0 276 | 277 | - 使用 `nonebot-plugin-alconna` 实现多适配器支持 278 | - 使用 `playwright` 重构现有的 Pillow 绘图 279 | 280 |
281 | 282 |
283 | 历史更新日志(点击展开) 284 | 285 | ### 0.9.7 286 | 287 | - 修复 `balogo` 的 fallback 字体的字重问题 288 | 289 | ### 0.9.6 290 | 291 | - 新增指令 `balogo` 292 | 293 | ### 0.9.5 294 | 295 | - 修复由于 SchaleDB 数据结构变动导致的一些 Bug 296 | - 抽卡总结图现在有半透明和圆角了 297 | 298 | ### 0.9.4 299 | 300 | - 修复了三星爆率过高的 bug ([#47](https://github.com/lgc-NB2Dev/nonebot-plugin-bawiki/pull/47)) 301 | 302 | ### 0.9.3 303 | 304 | - 微调 `ba日程表` 指令:GameKee 源的日程表现在可以分服务器展示了,顺便修复了 SchaleDB 源日程的 Bug,详见指令帮助 305 | - 现在在抽卡次数为 10 次以下时,默认使用经典抽卡样式(旧版的还原游戏的抽卡样式) 306 | - 配置项变更: 307 | - 添加 `BA_DISABLE_CLASSIC_GACHA` 308 | 309 | ### 0.9.2 310 | 311 | - `ba切换卡池` 指令现在不带参数时会显示所有卡池以供切换了 312 | 313 | ### 0.9.1 314 | 315 | - 重构抽卡绘图部分、数据源没有池子数据时自动使用常驻池 316 | - 将阿罗娜的回复变得更二次元了 317 | - 配置项变更: 318 | - 添加 `BA_GACHA_MAX` 319 | 320 | ### 0.9.0 321 | 322 | - 更新了 SchaleDB 页面的截图处理方式,现在可以支持源站与任何镜像了 323 | - 添加国服前瞻获取,详见指令 `ba千里眼` 帮助 324 | - 由于 CDN 域名过期,修改了默认源到原源 325 | - 尝试修复 [#43](https://github.com/lgc-NB2Dev/nonebot-plugin-bawiki/issues/43) 与 [#46](https://github.com/lgc-NB2Dev/nonebot-plugin-bawiki/issues/46) 326 | - 配置项变更: 327 | - 删除 `BA_SCHALE_MIRROR_URL` 328 | - 添加 `BA_SCREENSHOT_TIMEOUT` 329 | 330 | ### 0.8.6 331 | 332 | - 修复 [#39](https://github.com/lgc-NB2Dev/nonebot-plugin-bawiki/issues/39) 333 | - 尝试修复 [#45](https://github.com/lgc-NB2Dev/nonebot-plugin-bawiki/issues/45) 334 | 335 | ### 0.8.5 336 | 337 | - 修复 [#41](https://github.com/lgc-NB2Dev/nonebot-plugin-bawiki/issues/41) 338 | - 配置项 `BA_AUTO_CLEAR_ARONA_CACHE` 默认值改为 `False` 339 | 340 | ### 0.8.4 341 | 342 | - 现在会对 GameKee 的日程表分页了 343 | - `ba羁绊` 指令带图发送失败时会提醒用户 344 | - 修复 `ba学生wiki` 截图失败的 bug,同时优化截图样式 345 | - 漫画获取不再依赖 bawiki-data 数据源,现在直接从 GameKee 现爬;加入了搜索漫画功能,并且图片过多会使用合并转发的方式发送 346 | 347 | ### 0.8.3 348 | 349 | - 修改缓存路径 350 | 351 | ### 0.8.2 352 | 353 | - 修改了 `ba语音` 指令的特性,兼容了有中配语音的学生,请查看该指令帮助获取详细信息 354 | - 删除了 `arona` 指令模糊搜索展示类别的功能,因为模糊搜索时 `type` 固定为 `0` 了 355 | 356 | ### 0.8.1 357 | 358 | - 使用 `arona` 指令模糊搜索的时候会显示图片类别了 359 | 360 | ### 0.8.0 361 | 362 | - 整理项目结构 363 | - 添加内置帮助指令 `ba帮助` 364 | - 添加 Arona Bot 数据源指令 `arona` 365 | - 添加了配置项 `BA_ARONA_API_URL`、`BA_ARONA_CDN_URL`、`BA_CLEAR_REQ_CACHE_INTERVAL`、`BA_AUTO_CLEAR_ARONA_CACHE` 366 | - 其他小更改(更换 `aiohttp` 为 `httpx` 等) 367 | 368 | ### 0.7.10 369 | 370 | - 添加指令 `ba关卡` 371 | 372 | ### 0.7.9 373 | 374 | - 添加配置项 `BA_VOICE_USE_CARD` 375 | 376 | ### 0.7.8 377 | 378 | - 🎉 NoneBot 2.0 🚀 379 | 380 | ### 0.7.7 381 | 382 | - 修复 bug 383 | 384 | ### 0.7.6 385 | 386 | - 修复卡池为空不会提示的 bug 387 | 388 | ### 0.7.5 389 | 390 | - 插件可以自动帮你配置 PicMenu 的字体了 391 | - 给抽卡新增了冷却 392 | 393 | ### 0.7.2 ~ 0.7.4 394 | 395 | - 修复 bug 396 | 397 | ### 0.7.1 398 | 399 | - 更改配置项名称 400 | 401 | ### 0.7.0 402 | 403 | - 修复 SchaleDB 源日程表出错的问题 404 | - 添加了几个配置项,现在可以在 `.env` 文件中修改数据源链接了 405 | - 修改了默认数据源链接 406 | - 买了七牛云的 CDN,设置的数据缓存 12 小时。不知道现在速度怎么样…… 407 | 希望不要有人故意搞我…… 408 | 感谢大佬借用的已备案域名 [cyberczy.xyz](http://cyberczy.xyz/)! 409 | - 其他小更改 410 | 411 | ### 0.6.4 412 | 413 | - 修复由于 `imageutils` 接口改动造成的绘图失败的 bug 414 | 415 | ### 0.6.3 416 | 417 | - 使用 `require` 加载依赖插件 418 | 419 | ### 0.6.2 420 | 421 | - 修改日程表、羁绊查询的图片背景 422 | - 加上日程表条目的圆角 423 | - 更改 GameKee 日程表的排序方式 424 | 425 | ### 0.6.1 426 | 427 | - 修复一处 Py 3.8 无法运行的代码 428 | 429 | ### 0.6.0 430 | 431 | - 新指令 `ba抽卡` `ba切换卡池` `ba表情` `ba漫画` 432 | - 更改 SchaleDB 日程表触发单国际服的指令判断(由包含`国际服`改为包含`国`) 433 | 434 | ### 0.5.2 435 | 436 | - 新指令`ba语音` 437 | - 修复`ba综合战术考试`的一些问题 438 | 439 | ### 0.5.1 440 | 441 | - 新指令`ba互动家具` 442 | - `ba国际服千里眼`指令的日期参数如果小于当前日期则会将日期向前推一年 443 | - `ba日程表`的 SchaleDB 源如果没获取到数据则不会绘画那一部分 444 | - `ba国际服千里眼`日期匹配 bug 修复 445 | 446 | ### 0.5.0 447 | 448 | - 新数据源 [bawiki-data](http://github.com/lgc2333/bawiki-data) 449 | - 新指令`ba角评`;`ba总力战`;`ba活动`;`ba综合战术考试`;`ba制造`;`ba国际服千里眼`;`ba清空缓存` 450 | - 将`bal2d`指令改为`ba羁绊`别名 451 | - 将`ba日程表`指令从网页截图改为 Pillow 画图;并修改了指令的参数解析方式 452 | - 更改了`ba羁绊`指令的画图方式及底图 453 | - 更改学生别名的匹配方式 454 | - 学生别名等常量现在从 [bawiki-data](http://github.com/lgc2333/bawiki-data) 在线获取 455 | - 新增请求接口的缓存机制,每 3 小时清空一次缓存 456 | - 新增`PROXY`配置项 457 | - 更改三级菜单排版 458 | 459 | ### 0.4.2 460 | 461 | - `ba羁绊` `baL2D` 的 L2D 预览图改为实时从 GameKee 抓取 462 | 463 | ### 0.4.1 464 | 465 | - 优化带括号学生名称的别名匹配 466 | 467 | ### 0.4.0 468 | 469 | - `ba日程表`的`SchaleDB`数据源 470 | - `ba学生图鉴` `ba羁绊` 数据源更换为`SchaleDB` 471 | - 原`ba学生图鉴`修改为`ba学生wiki` 472 | 473 | ### 0.3.0 474 | 475 | - 新指令 `baL2D` 476 | - 新指令 `ba羁绊` 477 | 478 | ### 0.2.2 479 | 480 | - 添加学生别名判断 481 | - 修改日程表图片宽度 482 | 483 | ### 0.2.1 484 | 485 | - 修改页面加载等待的事件,可能修复截图失败的问题 486 | 487 | ### 0.2.0 488 | 489 | - 新指令 `ba新学生` (详情使用 [nonebot-plugin-PicMenu](https://github.com/hamo-reid/nonebot_plugin_PicMenu) 查看) 490 | 491 | ### 0.1.1 492 | 493 | - 日程表改为以图片形式发送 494 | - 日程表不会显示未开始的活动了 495 | - 小 bug 修复 496 | - ~~移除了 herobrine~~ 497 | 498 |
499 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot import require 2 | from nonebot.plugin import PluginMetadata 3 | 4 | require("nonebot_plugin_apscheduler") 5 | require("nonebot_plugin_htmlrender") 6 | 7 | from .command import load_commands # noqa: E402 8 | from .config import Cfg as Cfg # noqa: E402 9 | from .help import extra, register_help_cmd, usage # noqa: E402 10 | 11 | __version__ = "0.11.3" 12 | __plugin_meta__ = PluginMetadata( 13 | name="BAWiki", 14 | description="碧蓝档案Wiki插件", 15 | usage=usage, 16 | homepage="https://github.com/lgc-NB2Dev/nonebot-plugin-bawiki", 17 | type="application", 18 | config=Cfg, 19 | supported_adapters={"~onebot.v11"}, 20 | extra={"License": "MIT", "Author": "LgCookie"}, 21 | ) 22 | 23 | if extra: 24 | __plugin_meta__.extra.update(extra) 25 | 26 | register_help_cmd() 27 | load_commands() 28 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/command/__init__.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | from pathlib import Path 3 | from typing import List, TypedDict 4 | 5 | from nonebot import logger 6 | from pypinyin import lazy_pinyin 7 | 8 | 9 | class HelpDict(TypedDict): 10 | func: str 11 | trigger_method: str 12 | trigger_condition: str 13 | brief_des: str 14 | detail_des: str 15 | 16 | 17 | HelpList = List[HelpDict] 18 | 19 | help_list: HelpList = [] 20 | 21 | 22 | def sort_help(): 23 | help_list.sort(key=lambda x: "".join(lazy_pinyin(x["func"]))) 24 | 25 | 26 | def append_and_sort_help(help_dict: HelpDict): 27 | help_list.append(help_dict) 28 | sort_help() 29 | 30 | 31 | def load_commands(): 32 | for module in Path(__file__).parent.iterdir(): 33 | if module.name.startswith("_"): 34 | continue 35 | 36 | module = importlib.import_module(f".{module.stem}", __package__) 37 | assert module 38 | 39 | if not hasattr(module, "help_list"): 40 | logger.warning(f"Command module `{module.__name__}` has no `help_list`") 41 | else: 42 | help_list.extend(module.help_list) 43 | 44 | sort_help() 45 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/command/arona.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, List, NoReturn 2 | 3 | from nonebot import logger, on_command, on_shell_command 4 | from nonebot.adapters.onebot.v11 import Message, MessageEvent, MessageSegment 5 | from nonebot.exception import ParserExit 6 | from nonebot.matcher import Matcher 7 | from nonebot.params import ArgPlainText, CommandArg, ShellCommandArgs 8 | from nonebot.permission import SUPERUSER 9 | from nonebot.rule import ArgumentParser, Namespace 10 | from nonebot.typing import T_State 11 | 12 | from ..config import config 13 | from ..data.arona import ImageModel, get_image, search, search_exact, set_alias 14 | from ..help import FT_E, FT_S 15 | from ..util import IllegalOperationFinisher 16 | 17 | if TYPE_CHECKING: 18 | from . import HelpList 19 | 20 | help_list: "HelpList" = [ 21 | { 22 | "func": "Arona数据源攻略", 23 | "trigger_method": "指令", 24 | "trigger_condition": "arona", 25 | "brief_des": "从 Arona Bot 数据源中搜索攻略图", 26 | "detail_des": ( 27 | "从 Arona Bot 数据源中搜索攻略图,支持模糊搜索\n" 28 | "感谢 diyigemt 佬的开放 API 数据源\n" 29 | " \n" 30 | f"可以使用 {FT_S}arona设置别名{FT_E} 指令来为某关键词设置别名\n" 31 | f"使用方式:{FT_S}arona设置别名 原名 别名1 别名2 ...{FT_E}\n" 32 | "\n" 33 | f"可以使用 {FT_S}arona删除别名{FT_E} 指令来删除已设置的关键词的别名\n" 34 | f"使用方式:{FT_S}arona删除别名 别名1 别名2 ...{FT_E}\n" 35 | " \n" 36 | "可以搜索的内容:\n" 37 | f"- {FT_S}学生攻略图{FT_E}(星野,白子 等)\n" 38 | f"- {FT_S}主线地图{FT_E}(1-1,H1-1 等)\n" 39 | f"- {FT_S}杂图{FT_E}(使用 {FT_S}arona 杂图{FT_E} 可以获取图片列表)\n" 40 | " \n" 41 | "可以用这些指令触发:\n" 42 | f"- {FT_S}arona{FT_E}\n" 43 | f"- {FT_S}蓝色恶魔{FT_E}\n" 44 | f"- {FT_S}Arona{FT_E}\n" 45 | f"- {FT_S}ARONA{FT_E}\n" 46 | f"- {FT_S}阿罗娜{FT_E}\n" 47 | " \n" 48 | "指令示例:\n" 49 | f"- {FT_S}arona{FT_E}(会向你提问需要搜索什么)\n" 50 | f"- {FT_S}arona 国际服未来视{FT_E}(精确搜索)\n" 51 | f"- {FT_S}arona 国际服{FT_E}(模糊搜索)" 52 | ), 53 | }, 54 | ] 55 | 56 | 57 | illegal_finisher = IllegalOperationFinisher("坏老师,一直逗我,不理你了,哼!") 58 | 59 | ARONA_PREFIXES = ["arona", "蓝色恶魔", "Arona", "ARONA", "阿罗娜"] 60 | cmd_arona = on_command(ARONA_PREFIXES[0], aliases=set(ARONA_PREFIXES[1:]), priority=2) 61 | 62 | 63 | async def send_image(matcher: Matcher, img: ImageModel) -> NoReturn: 64 | try: 65 | res = await get_image(img.path, img.hash) 66 | except Exception: 67 | logger.exception("Arona数据源图片获取失败") 68 | await matcher.finish("呜呜,阿罗娜在获取图片的时候遇到了点问题 QAQ") 69 | 70 | await matcher.finish(MessageSegment.image(res)) 71 | 72 | 73 | @cmd_arona.handle() 74 | async def _(matcher: Matcher, arg: Message = CommandArg()): 75 | if arg.extract_plain_text().strip(): 76 | matcher.set_arg("param", arg) 77 | 78 | 79 | @cmd_arona.got("param", prompt="老师,请发送想要搜索的内容~") 80 | async def _(matcher: Matcher, state: T_State, param: str = ArgPlainText()): 81 | param = param.strip() 82 | if not param: 83 | await illegal_finisher() 84 | await matcher.reject("老师真是的,快给我发送你想要搜索的内容吧!") 85 | 86 | try: 87 | res = await search(param) 88 | except Exception: 89 | logger.exception("Arona数据源搜索失败") 90 | await matcher.finish("呜呜,阿罗娜在搜索结果的时候遇到了点问题 QAQ") 91 | 92 | if not res: 93 | await matcher.finish("抱歉老师,阿罗娜没有找到相关结果……") 94 | 95 | if len(res) == 1: 96 | await send_image(matcher, res[0]) 97 | 98 | state["res"] = res 99 | list_txt = "\n".join(f"{i}. {r.name}" for i, r in enumerate(res, 1)) 100 | await matcher.pause( 101 | f"老师!阿罗娜帮您找到了多个可能的结果,请发送序号来选择吧~\n{list_txt}\nTip:发送 0 取消选择", 102 | ) 103 | 104 | 105 | @cmd_arona.handle() 106 | async def _(event: MessageEvent, matcher: Matcher, state: T_State): 107 | index_str = event.get_plaintext().strip() 108 | res: List[ImageModel] = state["res"] 109 | 110 | if index_str == "0": 111 | await matcher.finish("OK,阿罗娜已经取消老师的选择了~") 112 | 113 | if not index_str.isdigit(): 114 | await illegal_finisher() 115 | await matcher.reject("不要再逗我了,老师!快发送你要选择的序号吧 quq") 116 | 117 | index = int(index_str) 118 | if not (0 <= index <= len(res)): 119 | await illegal_finisher() 120 | await matcher.reject("抱歉,阿罗娜找不到老师发送的序号哦,请老师重新发送一下吧") 121 | 122 | param = res[index - 1].name 123 | try: 124 | final_res = await search(param) 125 | assert final_res 126 | except Exception: 127 | logger.exception("Arona数据源搜索失败") 128 | await matcher.finish("呜呜,阿罗娜在搜索结果的时候遇到了点问题 QAQ") 129 | 130 | await send_image(matcher, final_res[0]) 131 | 132 | 133 | ARONA_SET_ALIAS_COMMANDS = [f"{p}设置别名" for p in ARONA_PREFIXES] 134 | ARONA_DEL_ALIAS_COMMANDS = [f"{p}删除别名" for p in ARONA_PREFIXES] 135 | 136 | cmd_arona_set_alias_parser = ArgumentParser(ARONA_SET_ALIAS_COMMANDS[0]) 137 | cmd_arona_set_alias_parser.add_argument("name", help="原名") 138 | cmd_arona_set_alias_parser.add_argument("aliases", nargs="+", help="别名,可以提供多个") 139 | cmd_arona_set_alias = on_shell_command( 140 | ARONA_SET_ALIAS_COMMANDS[0], 141 | aliases=set(ARONA_SET_ALIAS_COMMANDS[1:]), 142 | parser=cmd_arona_set_alias_parser, 143 | permission=SUPERUSER if config.ba_arona_set_alias_only_su else None, 144 | ) 145 | 146 | cmd_aro_del_alias_parser = ArgumentParser(ARONA_DEL_ALIAS_COMMANDS[0]) 147 | cmd_aro_del_alias_parser.add_argument("aliases", nargs="+", help="别名,可以提供多个") 148 | cmd_arona_del_alias = on_shell_command( 149 | ARONA_DEL_ALIAS_COMMANDS[0], 150 | aliases=set(ARONA_DEL_ALIAS_COMMANDS[1:]), 151 | parser=cmd_aro_del_alias_parser, 152 | permission=SUPERUSER if config.ba_arona_set_alias_only_su else None, 153 | ) 154 | 155 | 156 | @cmd_arona_set_alias.handle() 157 | @cmd_arona_del_alias.handle() 158 | async def _(matcher: Matcher, foo: ParserExit = ShellCommandArgs()): 159 | await matcher.finish(foo.message) 160 | 161 | 162 | @cmd_arona_set_alias.handle() 163 | async def _(matcher: Matcher, args: Namespace = ShellCommandArgs()): 164 | try: 165 | assert isinstance(args.name, str) 166 | assert all(isinstance(a, str) for a in args.aliases) 167 | except AssertionError: 168 | await matcher.finish("请老师发送纯文本消息的说") 169 | 170 | name: str = args.name.strip() 171 | aliases: List[str] = [a.strip().lower() for a in args.aliases] 172 | 173 | try: 174 | resp = await search_exact(name) 175 | except Exception: 176 | logger.exception(f"Arona数据源搜索失败 {args.name}") 177 | await matcher.finish("抱歉,阿罗娜在尝试查找原名是否存在时遇到了一点小问题……") 178 | 179 | if (not resp) or (len(resp) > 1): 180 | await matcher.finish( 181 | "啊咧?阿罗娜找不到老师提供的原名,请老师检查一下您提供的名称是否正确", 182 | ) 183 | 184 | ret_dict = set_alias(name, aliases) 185 | message = "\n".join( 186 | ( 187 | "好的,阿罗娜已经成功帮你操作了以下别名~", 188 | *( 189 | ( 190 | f"成功将别名 {k} 指向的原名从 {v} 更改为 {name}" 191 | if v 192 | else f"成功设置 {k} 为 {name} 的别名" 193 | ) 194 | for k, v in ret_dict.items() 195 | ), 196 | ), 197 | ) 198 | await matcher.finish(message) 199 | 200 | 201 | @cmd_arona_del_alias.handle() 202 | async def _(matcher: Matcher, args: Namespace = ShellCommandArgs()): 203 | try: 204 | assert all(isinstance(a, str) for a in args.aliases) 205 | except AssertionError: 206 | await matcher.finish("请老师发送纯文本消息的说") 207 | 208 | aliases: List[str] = [a.strip().lower() for a in args.aliases] 209 | 210 | ret_dict = set_alias(None, aliases) 211 | message = "\n".join( 212 | ( 213 | "好的,阿罗娜已经成功帮你操作了以下别名~", 214 | *( 215 | ( 216 | f"成功删除指向原名 {v} 的别名 {k} " 217 | if v 218 | else f"已设置的别名中未找到 {k}" 219 | ) 220 | for k, v in ret_dict.items() 221 | ), 222 | ), 223 | ) 224 | await matcher.finish(message) 225 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/command/calender.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import TYPE_CHECKING, List, Literal 3 | 4 | from nonebot import on_command 5 | from nonebot.adapters.onebot.v11 import Bot, Message, MessageEvent, MessageSegment 6 | from nonebot.log import logger 7 | from nonebot.matcher import Matcher 8 | from nonebot.params import CommandArg 9 | 10 | from ..config import config 11 | from ..data.gamekee import game_kee_calender 12 | from ..data.schaledb import schale_calender 13 | from ..help import FT_E, FT_S 14 | from ..util import send_forward_msg 15 | 16 | if TYPE_CHECKING: 17 | from . import HelpList 18 | 19 | help_list: "HelpList" = [ 20 | { 21 | "func": "日程表", 22 | "trigger_method": "指令", 23 | "trigger_condition": "ba日程表", 24 | "brief_des": "查看活动日程表", 25 | "detail_des": ( 26 | "查看当前未结束的卡池、活动以及起止时间\n" 27 | "默认展示来自GameKee源的所有服务器日程\n" 28 | "可以使用下方的指令参数指定数据源和展示的服务器\n" 29 | " \n" 30 | "可以在指令后带参数,每个参数请使用空格分隔\n" 31 | "参数列表:\n" 32 | "- 使用SchaleDB数据源:\n" 33 | f" {FT_S}夏莱{FT_E} / {FT_S}沙勒{FT_E} / {FT_S}s{FT_E} / {FT_S}schale{FT_E} / {FT_S}schaledb{FT_E}\n" 34 | "- 展示日服日程:\n" 35 | f" {FT_S}日{FT_E} / {FT_S}日服{FT_E} / {FT_S}j{FT_E} / {FT_S}jp{FT_E} / {FT_S}japan{FT_E}\n" 36 | "- 展示国际服日程:\n" 37 | f" {FT_S}国际{FT_E} / {FT_S}国际服{FT_E} / {FT_S}g{FT_E} / {FT_S}gl{FT_E} / {FT_S}global{FT_E}\n" 38 | "- 展示国服日程:\n" 39 | f" {FT_S}国服{FT_E} / {FT_S}c{FT_E} / {FT_S}cn{FT_E} / {FT_S}china{FT_E} / {FT_S}chinese{FT_E}\n" 40 | " \n" 41 | "指令示例:\n" 42 | f"- {FT_S}ba日程表{FT_E} (GameKee源)\n" 43 | f"- {FT_S}ba日程表 schale{FT_E} (SchaleDB源,所有服务器)\n" 44 | f"- {FT_S}ba日程表 schale 日服 国际服{FT_E} (SchaleDB源,日服和国际服)" 45 | ), 46 | }, 47 | ] 48 | 49 | 50 | cmd_calender = on_command("ba日程表") 51 | 52 | 53 | @cmd_calender.handle() 54 | async def _( 55 | bot: Bot, 56 | event: MessageEvent, 57 | matcher: Matcher, 58 | cmd_arg: Message = CommandArg(), 59 | ): 60 | args: List[str] = cmd_arg.extract_plain_text().strip().lower().split() 61 | 62 | gamekee = True 63 | servers: List[Literal["Jp", "Global", "Cn"]] = [] 64 | 65 | if any((x in ("夏莱", "沙勒", "s", "schale", "schaledb")) for x in args): 66 | gamekee = False 67 | if any((x in ("日", "日服", "j", "jp", "japan")) for x in args): 68 | servers.append("Jp") 69 | if any((x in ("国际", "国际服", "g", "gl", "global")) for x in args): 70 | servers.append("Global") 71 | if any((x in ("国服", "c", "cn", "china", "chinese")) for x in args): 72 | servers.append("Cn") 73 | 74 | if not servers: 75 | servers = ["Jp", "Global", "Cn"] 76 | 77 | if gamekee: 78 | task = game_kee_calender(servers) 79 | else: 80 | task = asyncio.gather(*(schale_calender(x) for x in servers)) 81 | 82 | await matcher.send("正在绘制图片,请稍等") 83 | try: 84 | pics = await task 85 | except Exception: 86 | logger.exception("绘制日程表图片出错") 87 | await matcher.finish("绘制日程表图片出错,请检查后台输出") 88 | 89 | if not pics: 90 | await matcher.finish("没有获取到日程表数据") 91 | 92 | messages = [MessageSegment.image(x) for x in pics] 93 | 94 | if config.ba_use_forward_msg: 95 | await send_forward_msg(bot, event, messages) 96 | return 97 | 98 | await matcher.finish(Message(messages)) 99 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/command/clear_cache.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import TYPE_CHECKING 3 | 4 | from nonebot import on_command 5 | from nonebot.matcher import Matcher 6 | from nonebot.permission import SUPERUSER 7 | 8 | from ..help import FT_E, FT_S 9 | from ..resource import CACHE_DIR 10 | from ..util import clear_wrapped_alru_cache 11 | 12 | if TYPE_CHECKING: 13 | from . import HelpList 14 | 15 | help_list: "HelpList" = [ 16 | { 17 | "func": "清空缓存", 18 | "trigger_method": "超级用户 指令", 19 | "trigger_condition": "ba清空缓存", 20 | "brief_des": "清空插件请求缓存", 21 | "detail_des": ( 22 | "手动清空插件请求网络缓存下来的数据,如API返回的数据\n" 23 | f"注:该指令只能由{FT_S}超级用户{FT_E}触发\n" 24 | " \n" 25 | "可以用这些指令触发:\n" 26 | f"- {FT_S}ba清空缓存{FT_E}\n" 27 | f"- {FT_S}ba清除缓存{FT_E}" 28 | ), 29 | }, 30 | ] 31 | 32 | 33 | def clear_cache_dir() -> int: 34 | counter = 0 35 | 36 | def run(path: Path): 37 | nonlocal counter 38 | for p in path.iterdir(): 39 | if p.is_dir(): 40 | run(p) 41 | else: 42 | p.unlink() 43 | counter += 1 44 | 45 | run(CACHE_DIR) 46 | return counter 47 | 48 | 49 | cmd_clear_cache = on_command("ba清空缓存", aliases={"ba清除缓存"}, permission=SUPERUSER) 50 | 51 | 52 | @cmd_clear_cache.handle() 53 | async def _(matcher: Matcher): 54 | req_count = clear_wrapped_alru_cache() 55 | cache_count = clear_cache_dir() 56 | await matcher.finish(f"已清除 {req_count} 项请求缓存与 {cache_count} 项文件缓存~") 57 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/command/craft.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from nonebot import on_command 4 | from nonebot.adapters.onebot.v11 import Message 5 | from nonebot.log import logger 6 | from nonebot.matcher import Matcher 7 | 8 | from ..data.bawiki import db_wiki_craft 9 | from ..help import FT_E, FT_S 10 | 11 | if TYPE_CHECKING: 12 | from . import HelpList 13 | 14 | help_list: "HelpList" = [ 15 | { 16 | "func": "制造一图流", 17 | "trigger_method": "指令", 18 | "trigger_condition": "ba制造", 19 | "brief_des": "查询制造功能机制图", 20 | "detail_des": ( 21 | "发送游戏内制造功能的一图流介绍\n" 22 | "图片作者 B站@夜猫咪喵喵猫\n" 23 | " \n" 24 | "可以用这些指令触发:\n" 25 | f"- {FT_S}ba制造{FT_E}\n" 26 | f"- {FT_S}ba制作{FT_E}\n" 27 | f"- {FT_S}ba合成{FT_E}" 28 | ), 29 | }, 30 | ] 31 | 32 | 33 | cmd_craft_wiki = on_command("ba制造", aliases={"ba合成", "ba制作"}) 34 | 35 | 36 | @cmd_craft_wiki.handle() 37 | async def _(matcher: Matcher): 38 | try: 39 | im = await db_wiki_craft() 40 | except Exception: 41 | logger.exception("获取合成wiki图片错误") 42 | await matcher.finish("获取图片失败,请检查后台输出") 43 | 44 | await matcher.finish(Message(im)) 45 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/command/emoji.py: -------------------------------------------------------------------------------- 1 | import random 2 | from typing import TYPE_CHECKING 3 | 4 | from nonebot import on_command 5 | from nonebot.adapters.onebot.v11 import MessageSegment 6 | from nonebot.log import logger 7 | from nonebot.matcher import Matcher 8 | 9 | from ..data.bawiki import db_get, db_get_emoji 10 | from ..util import RespType 11 | 12 | if TYPE_CHECKING: 13 | from . import HelpList 14 | 15 | help_list: "HelpList" = [ 16 | { 17 | "func": "抽表情", 18 | "trigger_method": "指令", 19 | "trigger_condition": "ba表情", 20 | "brief_des": "随机发送一个国际服社团聊天表情", 21 | "detail_des": "随机发送一个国际服社团聊天表情\n来源:解包", 22 | }, 23 | ] 24 | 25 | 26 | cmd_random_emoji = on_command("ba表情") 27 | 28 | 29 | @cmd_random_emoji.handle() 30 | async def _(matcher: Matcher): 31 | try: 32 | emojis = await db_get_emoji() 33 | emo = await db_get(random.choice(emojis), resp_type=RespType.BYTES) 34 | except Exception: 35 | logger.exception("获取表情失败") 36 | await matcher.finish("获取表情失败,请检查后台输出") 37 | await matcher.finish(MessageSegment.image(emo)) 38 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/command/event.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import TYPE_CHECKING 3 | 4 | from nonebot import on_command 5 | from nonebot.adapters.onebot.v11 import Message 6 | from nonebot.log import logger 7 | from nonebot.matcher import Matcher 8 | from nonebot.params import CommandArg 9 | 10 | from ..data.bawiki import db_get_event_alias, db_wiki_event 11 | from ..data.schaledb import find_current_event, schale_get_config 12 | from ..help import FT_E, FT_S 13 | from ..util import recover_alia, splice_msg 14 | 15 | if TYPE_CHECKING: 16 | from . import HelpList 17 | 18 | help_list: "HelpList" = [ 19 | { 20 | "func": "活动一图流", 21 | "trigger_method": "指令", 22 | "trigger_condition": "ba活动", 23 | "brief_des": "查询活动攻略图", 24 | "detail_des": ( 25 | "发送当前或指定活动一图流攻略图,可能会附带活动特殊机制等\n" 26 | "图片作者 B站@夜猫咪喵喵猫\n" 27 | " \n" 28 | "指令默认发送日服和国际服当前的活动攻略\n" 29 | "指令后面跟`日`或`j`开头的文本代表查询日服当前活动攻略,带以`国际`或`g`、`国`或`c`开头的文本同理\n" 30 | "跟其他文本则代表指定活动名称\n" 31 | " \n" 32 | "指令示例:\n" 33 | f"- {FT_S}ba活动{FT_E}\n" 34 | f"- {FT_S}ba活动 日{FT_E}\n" 35 | f"- {FT_S}ba活动 温泉浴场{FT_E}" 36 | ), 37 | }, 38 | ] 39 | 40 | 41 | cmd_event_wiki = on_command("ba活动") 42 | 43 | 44 | @cmd_event_wiki.handle() 45 | async def _(matcher: Matcher, cmd_arg: Message = CommandArg()): 46 | arg = cmd_arg.extract_plain_text().lower().strip() 47 | 48 | keys = { 49 | 0: ("日", "j"), 50 | 1: ("国际", "g"), 51 | 2: ("国", "c"), 52 | } 53 | 54 | server = [] 55 | for k, v in keys.items(): 56 | if (not arg) or arg.startswith(v): 57 | server.append(k) 58 | for kw in v: 59 | arg = arg.replace(kw, "", 1) 60 | 61 | events = [] 62 | if server: 63 | try: 64 | common = await schale_get_config() 65 | for s in server: 66 | ev = common["Regions"][s]["CurrentEvents"] 67 | if e := find_current_event(ev): 68 | events.append((e[0]["event"]) % 10000) 69 | except Exception: 70 | logger.exception("获取当前活动失败") 71 | await matcher.finish("获取当前活动失败") 72 | 73 | if not events: 74 | await matcher.finish("当前服务器没有正在进行的活动") 75 | 76 | else: 77 | events.append(recover_alia(arg, await db_get_event_alias())) 78 | 79 | try: 80 | ret = await asyncio.gather(*[db_wiki_event(x) for x in events]) 81 | except Exception: 82 | logger.exception("获取活动wiki出错") 83 | await matcher.finish("获取图片出错,请检查后台输出") 84 | 85 | await matcher.finish(splice_msg(ret)) 86 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/command/furniture.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from nonebot import on_command 4 | from nonebot.adapters.onebot.v11 import Message 5 | from nonebot.log import logger 6 | from nonebot.matcher import Matcher 7 | 8 | from ..data.bawiki import db_wiki_furniture 9 | 10 | if TYPE_CHECKING: 11 | from . import HelpList 12 | 13 | help_list: "HelpList" = [ 14 | { 15 | "func": "互动家具总览", 16 | "trigger_method": "指令", 17 | "trigger_condition": "ba互动家具", 18 | "brief_des": "查询互动家具总览图", 19 | "detail_des": "发送咖啡厅内所有互动家具以及对应学生的总览图\n图片作者 B站@夜猫咪喵喵猫", 20 | }, 21 | ] 22 | 23 | 24 | cmd_furniture_wiki = on_command("ba互动家具") 25 | 26 | 27 | @cmd_furniture_wiki.handle() 28 | async def _(matcher: Matcher): 29 | try: 30 | im = await db_wiki_furniture() 31 | except Exception: 32 | logger.exception("获取互动家具wiki图片错误") 33 | await matcher.finish("获取图片失败,请检查后台输出") 34 | 35 | await matcher.finish(Message(im)) 36 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/command/gacha.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import TYPE_CHECKING, Dict, List, Optional 3 | 4 | from nonebot import on_command 5 | from nonebot.adapters.onebot.v11 import Message, MessageEvent, MessageSegment 6 | from nonebot.log import logger 7 | from nonebot.matcher import Matcher 8 | from nonebot.params import CommandArg 9 | from nonebot.typing import T_State 10 | 11 | from ..config import config 12 | from ..data.bawiki import db_get_gacha_data, db_get_stu_alias 13 | from ..data.gacha import gacha, get_gacha_cool_down, set_gacha_cool_down 14 | from ..data.schaledb import schale_get_stu_dict 15 | from ..help import FT_E, FT_S 16 | from ..util import recover_alia 17 | 18 | if TYPE_CHECKING: 19 | from . import HelpList 20 | 21 | help_list: "HelpList" = [ 22 | { 23 | "func": "模拟抽卡", 24 | "trigger_method": "指令", 25 | "trigger_condition": "ba抽卡", 26 | "brief_des": "模拟抽卡", 27 | "detail_des": ( 28 | "模拟抽卡\n" 29 | f"可以使用 {FT_S}ba切换卡池{FT_E} 指令来切换卡池\n" 30 | "可以指定抽卡次数,默认10次\n" 31 | " \n" 32 | "可以用这些指令触发:\n" 33 | f"- {FT_S}ba抽卡{FT_E}\n" 34 | f"- {FT_S}ba招募{FT_E}\n" 35 | " \n" 36 | "指令示例:\n" 37 | f"- {FT_S}ba抽卡{FT_E}\n" 38 | f"- {FT_S}ba抽卡 20{FT_E}" 39 | ), 40 | }, 41 | { 42 | "func": "切换卡池", 43 | "trigger_method": "指令", 44 | "trigger_condition": "ba切换卡池", 45 | "brief_des": "设置模拟抽卡的UP池", 46 | "detail_des": ( 47 | "设置模拟抽卡功能的UP池角色\n" 48 | "当不带参数时,会展示所有池子以供切换\n" 49 | f"当参数为 {FT_S}常驻{FT_E} 时,切换到常驻池(没有UP)\n" 50 | "可以自定义池子UP角色,支持2星与3星角色,参数中学生名称用空格分隔,支持部分学生别名\n" 51 | " \n" 52 | "指令示例:\n" 53 | f"- {FT_S}ba切换卡池{FT_E}\n" 54 | f"- {FT_S}ba切换卡池 常驻{FT_E}\n" 55 | f"- {FT_S}ba切换卡池 小桃 小绿{FT_E}" 56 | ), 57 | }, 58 | ] 59 | 60 | 61 | cmd_change_pool = on_command("ba切换卡池") 62 | cmd_gacha_once = on_command("ba抽卡", aliases={"ba招募"}) 63 | 64 | 65 | @dataclass() 66 | class GachaPool: 67 | name: str 68 | pool: List[int] 69 | 70 | 71 | STATIC_POOL = GachaPool(name="常驻池", pool=[]) 72 | gacha_pool_index: Dict[str, GachaPool] = {} 73 | 74 | 75 | def get_1st_pool(data: dict) -> Optional[GachaPool]: 76 | if not data: 77 | return None 78 | 79 | pool_data = data["current_pools"] 80 | if not pool_data: 81 | return None 82 | 83 | pool = pool_data[0] 84 | return GachaPool(name=pool["name"], pool=pool["pool"]) 85 | 86 | 87 | @cmd_change_pool.handle() 88 | async def _( 89 | matcher: Matcher, 90 | event: MessageEvent, 91 | state: T_State, 92 | cmd_arg: Message = CommandArg(), 93 | ): 94 | arg = cmd_arg.extract_plain_text().strip().lower() 95 | qq = event.get_user_id() 96 | 97 | if arg: 98 | if "常驻" in arg: 99 | current = STATIC_POOL 100 | else: 101 | pool = [] 102 | try: 103 | stu_li = await schale_get_stu_dict() 104 | stu_alias = await db_get_stu_alias() 105 | except Exception: 106 | logger.exception("获取学生列表或别名失败") 107 | await matcher.finish("获取学生列表或别名失败,请检查后台输出") 108 | 109 | for i in arg.split(): 110 | if not (stu := stu_li.get(recover_alia(i, stu_alias))): 111 | await matcher.finish(f"未找到学生 {i}") 112 | if stu["StarGrade"] == 1: 113 | await matcher.finish("不能UP一星角色") 114 | pool.append(stu) 115 | 116 | current = GachaPool( 117 | name=f"自定义卡池({'、'.join([x['Name'] for x in pool])})", 118 | pool=[x["Id"] for x in pool], 119 | ) 120 | 121 | else: 122 | try: 123 | gacha_data = await db_get_gacha_data() 124 | except Exception: 125 | logger.exception("获取抽卡基本数据失败") 126 | await matcher.finish("获取抽卡基本数据失败,请检查后台输出") 127 | 128 | pool_data = gacha_data["current_pools"] 129 | first_pool = get_1st_pool(gacha_data) 130 | if not first_pool: 131 | await matcher.finish("当前没有可切换的卡池") 132 | 133 | pool_obj = gacha_pool_index.get(qq) or first_pool 134 | if not pool_obj: 135 | await matcher.finish("当前没有UP池可供切换") 136 | 137 | if len(pool_data) == 1: 138 | current = first_pool 139 | else: 140 | state["pools"] = pool_data 141 | pools_str = "\n".join( 142 | f"{i}. {x['name']}" for i, x in enumerate(pool_data, 1) 143 | ) 144 | await matcher.pause( 145 | f"请选择要切换的卡池:\n{pools_str}\nTip: 发送 0 取消选择", 146 | ) 147 | 148 | if current: 149 | gacha_pool_index[qq] = current 150 | await matcher.finish(f"已切换到卡池 {current.name}") 151 | 152 | 153 | @cmd_change_pool.handle() 154 | async def _(matcher: Matcher, event: MessageEvent, state: T_State): 155 | index_str = event.get_plaintext().strip() 156 | if index_str == "0": 157 | await matcher.finish("已取消选择") 158 | 159 | if not ( 160 | index_str.isdigit() and (1 <= (index := int(index_str)) <= len(state["pools"])) 161 | ): 162 | await matcher.reject("请输入有效的序号") 163 | 164 | pools: List[Dict] = state["pools"] 165 | current = GachaPool(**pools[index - 1]) 166 | 167 | gacha_pool_index[event.get_user_id()] = current 168 | await matcher.finish(f"已切换到卡池 {current.name}") 169 | 170 | 171 | @cmd_gacha_once.handle() 172 | async def _( 173 | matcher: Matcher, 174 | event: MessageEvent, 175 | cmd_arg: Message = CommandArg(), 176 | ): 177 | session_id = event.get_session_id() 178 | 179 | if cool_down := get_gacha_cool_down(session_id): 180 | await matcher.finish(f"你先别急,先等 {cool_down} 秒再来抽吧qwq") 181 | 182 | gacha_times = 10 183 | arg = cmd_arg.extract_plain_text().strip().lower() 184 | if arg: 185 | if not arg.isdigit(): 186 | await matcher.finish("请输入有效的整数") 187 | gacha_times = int(arg) 188 | if not ( 189 | gacha_times >= 1 190 | and (config.ba_gacha_max < 1 or gacha_times <= config.ba_gacha_max) 191 | ): 192 | await matcher.finish(f"请输入有效的抽卡次数,在1~{config.ba_gacha_max}之间") 193 | 194 | try: 195 | gacha_data = await db_get_gacha_data() 196 | except Exception: 197 | logger.exception("获取抽卡基本数据失败") 198 | await matcher.finish("获取抽卡基本数据失败,请检查后台输出") 199 | 200 | pool_obj = gacha_pool_index.get(qq := event.get_user_id()) or get_1st_pool( 201 | gacha_data, 202 | ) 203 | if not pool_obj: 204 | await matcher.send("数据源内没有提供当期UP池,已自动切换到常驻池") 205 | pool_obj = STATIC_POOL 206 | gacha_pool_index[qq] = STATIC_POOL 207 | 208 | set_gacha_cool_down(session_id) 209 | try: 210 | img = await gacha(qq, gacha_times, gacha_data, pool_obj.pool) 211 | except Exception: 212 | logger.exception("抽卡错误") 213 | await matcher.finish("抽卡出错了,请检查后台输出") 214 | 215 | await matcher.finish( 216 | MessageSegment.at(event.user_id) + f"当前抽取卡池:{pool_obj.name}" + img, 217 | ) 218 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/command/level_guide.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from nonebot import on_command 4 | from nonebot.adapters.onebot.v11 import Message, MessageSegment 5 | from nonebot.log import logger 6 | from nonebot.matcher import Matcher 7 | from nonebot.params import CommandArg 8 | 9 | from ..data.gamekee import extract_content_pic, get_level_list 10 | from ..help import FT_E, FT_S 11 | from ..util import RespType as Rt, async_req 12 | 13 | if TYPE_CHECKING: 14 | from . import HelpList 15 | 16 | help_list: "HelpList" = [ 17 | { 18 | "func": "关卡攻略", 19 | "trigger_method": "指令", 20 | "trigger_condition": "ba关卡", 21 | "brief_des": "获取关卡攻略", 22 | "detail_des": ( 23 | "获取指定关卡攻略\n" 24 | "来源:GameKee\n" 25 | " \n" 26 | "指令示例:\n" 27 | f"- {FT_S}ba关卡 1-1{FT_E}\n" 28 | f"- {FT_S}ba关卡 H1-1{FT_E}" 29 | ), 30 | }, 31 | ] 32 | 33 | 34 | cmd_level_guide = on_command("ba关卡") 35 | 36 | 37 | @cmd_level_guide.handle() 38 | async def _(matcher: Matcher, arg: Message = CommandArg()): 39 | arg_str = arg.extract_plain_text().strip().upper() 40 | if not arg_str: 41 | await matcher.finish("请输入关卡名称") 42 | 43 | try: 44 | levels = await get_level_list() 45 | except Exception: 46 | logger.exception("获取关卡列表失败") 47 | await matcher.finish("获取关卡列表失败,请检查后台输出") 48 | 49 | if arg_str not in levels: 50 | await matcher.finish("未找到该关卡,请检查关卡名称是否正确") 51 | 52 | cid = levels[arg_str] 53 | try: 54 | imgs = await extract_content_pic(cid) 55 | except Exception: 56 | logger.exception("获取攻略图片失败") 57 | await matcher.finish("获取攻略图片失败,请检查后台输出") 58 | 59 | msg = Message() 60 | msg += f"https://ba.gamekee.com/{cid}.html\n" 61 | msg += [MessageSegment.image(await async_req(x, resp_type=Rt.BYTES)) for x in imgs] 62 | await matcher.finish(msg) 63 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/command/logo_generate.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from nonebot import logger, on_shell_command 4 | from nonebot.adapters.onebot.v11 import MessageSegment 5 | from nonebot.exception import ParserExit 6 | from nonebot.matcher import Matcher 7 | from nonebot.params import ShellCommandArgs 8 | from nonebot.rule import ArgumentParser, Namespace 9 | 10 | from ..data.logo_generate import get_logo 11 | from ..help import FT_E, FT_S 12 | 13 | if TYPE_CHECKING: 14 | from . import HelpList 15 | 16 | 17 | help_list: "HelpList" = [ 18 | { 19 | "func": "标题生成器", 20 | "trigger_method": "指令", 21 | "trigger_condition": "balogo", 22 | "brief_des": "生成 BA Logo 样式的图片", 23 | "detail_des": ( 24 | "生成 BA Logo 样式的图片\n" 25 | "感谢 nulla2011/Bluearchive-logo 项目以 MIT 协议开源了图片绘制代码\n" 26 | " \n" 27 | "可以用这些指令触发:\n" 28 | f"- {FT_S}balogo{FT_E}\n" 29 | f"- {FT_S}baLogo{FT_E}\n" 30 | f"- {FT_S}baLOGO{FT_E}\n" 31 | f"- {FT_S}ba标题{FT_E}\n" 32 | " \n" 33 | "指令示例:\n" 34 | f"- {FT_S}balogo Blue Archive{FT_E}\n" 35 | f"- {FT_S}balogo -T Schale SenSei{FT_E}(使用参数 -T 加上背景)\n" 36 | f'- {FT_S}balogo "我是" "秦始皇"{FT_E}(包含空格的文本请使用引号包裹)' 37 | ), 38 | }, 39 | ] 40 | 41 | 42 | parser = ArgumentParser("balogo", add_help=False) 43 | parser.add_argument("text_l") 44 | parser.add_argument("text_r") 45 | parser.add_argument("-T", "--no-transparent", action="store_true") 46 | 47 | cmd_ba_logo = on_shell_command( 48 | "balogo", 49 | aliases={"baLogo", "baLOGO", "ba标题"}, 50 | parser=parser, 51 | ) 52 | 53 | 54 | @cmd_ba_logo.handle() 55 | async def _(matcher: Matcher, err: ParserExit = ShellCommandArgs()): 56 | if err.message: 57 | await matcher.finish(f"参数解析失败:{err.message}") 58 | 59 | 60 | @cmd_ba_logo.handle() 61 | async def _(matcher: Matcher, arg: Namespace = ShellCommandArgs()): 62 | try: 63 | b64_url = await get_logo(arg.text_l, arg.text_r, (not arg.no_transparent)) 64 | except Exception: 65 | logger.exception("Error when generating image") 66 | await matcher.finish("遇到错误,请检查后台输出") 67 | await matcher.finish(MessageSegment.image(b64_url)) 68 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/command/manga.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import random 3 | from io import BytesIO 4 | from typing import TYPE_CHECKING, List 5 | 6 | from nonebot import on_command 7 | from nonebot.adapters.onebot.v11 import Bot, Message, MessageEvent, MessageSegment 8 | from nonebot.log import logger 9 | from nonebot.matcher import Matcher 10 | from nonebot.params import CommandArg, EventMessage 11 | from nonebot.typing import T_State 12 | from pil_utils import BuildImage 13 | 14 | from ..config import config 15 | from ..data.gamekee import MangaMetadata, get_manga_content, get_manga_list 16 | from ..help import FT_E, FT_S 17 | from ..util import ( 18 | IllegalOperationFinisher, 19 | RespType as Rt, 20 | async_req, 21 | send_forward_msg, 22 | split_list, 23 | ) 24 | 25 | if TYPE_CHECKING: 26 | from . import HelpList 27 | 28 | help_list: "HelpList" = [ 29 | { 30 | "func": "随机漫画", 31 | "trigger_method": "指令", 32 | "trigger_condition": "ba漫画", 33 | "brief_des": "发送一话官推/同人漫画", 34 | "detail_des": ( 35 | "从GameKee爬取BA官推/同人漫画并发送\n" 36 | "可以使用GameKee主页漫画图书馆的漫画名字和链接标题搜索\n" 37 | "不带参数时,会从所有漫画中随机抽取一话发送\n" 38 | " \n" 39 | "指令示例:\n" 40 | f"- {FT_S}ba漫画{FT_E}\n" 41 | f"- {FT_S}ba漫画 蔚蓝档案四格漫画{FT_E}\n" 42 | f"- {FT_S}ba漫画 布噜布噜档案 第一话{FT_E}" 43 | ), 44 | }, 45 | ] 46 | 47 | 48 | KEY_MANGA_LIST = "manga_list" 49 | KEY_SELECTED_MANGA = "selected_manga" 50 | 51 | illegal_finisher = IllegalOperationFinisher("非法操作次数过多,已退出选择") 52 | 53 | cmd_random_manga = on_command("ba漫画") 54 | 55 | 56 | @cmd_random_manga.handle() 57 | async def _(matcher: Matcher, state: T_State, arg_msg: Message = CommandArg()): 58 | try: 59 | manga_list = await get_manga_list() 60 | except Exception: 61 | logger.exception("获取漫画列表失败") 62 | await matcher.finish("获取漫画列表失败,请检查后台输出") 63 | 64 | arg = arg_msg.extract_plain_text().strip().split() 65 | if arg: 66 | manga_list = [ 67 | x 68 | for x in manga_list 69 | if all((kw in x.category or kw in x.name) for kw in arg) 70 | ] 71 | 72 | if not manga_list: 73 | await matcher.finish("未找到对应关键词的漫画") 74 | 75 | manga_total = len(manga_list) 76 | state[KEY_MANGA_LIST] = manga_list 77 | if manga_total == 1: 78 | state[KEY_SELECTED_MANGA] = manga_list[0] 79 | elif not arg: 80 | index = random.randint(0, manga_total - 1) 81 | state[KEY_SELECTED_MANGA] = manga_list[index] 82 | else: 83 | if manga_total > 5: 84 | manga_list = manga_list[:5] 85 | state[KEY_MANGA_LIST] = manga_list 86 | list_msg = "\n".join( 87 | f"{i}. 【{x.category}】{x.name}" for i, x in enumerate(manga_list, 1) 88 | ) 89 | too_much_tip = "\nTip:结果过多,仅显示前五个" if manga_total > 5 else "" 90 | await matcher.pause( 91 | f"找到了多个结果,请发送序号选择,发送 0 退出选择:\n{list_msg}{too_much_tip}", 92 | ) 93 | 94 | 95 | @cmd_random_manga.handle() 96 | async def _(matcher: Matcher, state: T_State, message: Message = EventMessage()): 97 | if KEY_SELECTED_MANGA in state: 98 | return 99 | 100 | arg = message.extract_plain_text().strip() 101 | if arg == "0": 102 | await matcher.finish("已退出选择") 103 | 104 | index = int(arg) if arg.isdigit() else None 105 | manga_list: List[MangaMetadata] = state[KEY_MANGA_LIST] 106 | if (not index) or (index > len(manga_list)): 107 | await illegal_finisher() 108 | await matcher.reject("请输入正确的序号") 109 | 110 | state[KEY_SELECTED_MANGA] = manga_list[index - 1] 111 | 112 | 113 | @cmd_random_manga.handle() 114 | async def _( 115 | bot: Bot, 116 | event: MessageEvent, 117 | matcher: Matcher, 118 | state: T_State, 119 | ): 120 | manga: MangaMetadata = state[KEY_SELECTED_MANGA] 121 | 122 | async def get_pic(url: str): 123 | p = await async_req(url, resp_type=Rt.BYTES) 124 | if url.endswith(".webp"): 125 | p = BuildImage.open(BytesIO(p)).save_png() 126 | return p 127 | 128 | try: 129 | content = await get_manga_content(manga.cid) 130 | pics = await asyncio.gather(*[get_pic(x) for x in content.images]) 131 | except Exception: 132 | logger.exception(f"获取 CID {manga.cid} 漫画失败") 133 | await matcher.finish("获取漫画失败,请检查后台输出") 134 | 135 | image_sum = len(pics) 136 | image_seg = [MessageSegment.image(x) for x in pics] 137 | if (not config.ba_use_forward_msg) or image_sum <= 2: 138 | header = ( 139 | f"https://ba.gamekee.com/{manga.cid}.html\n" 140 | f"【{manga.category}】{content.title}" 141 | ) 142 | if content.content: 143 | header = f"{header}\n\n{content.content}" 144 | 145 | chunks = list(split_list(image_seg, 9)) 146 | max_page = len(chunks) 147 | for i, chunk in enumerate(chunks, 1): 148 | msg = Message() 149 | if i == 1: 150 | msg += header 151 | msg += chunk 152 | if max_page > 1: 153 | msg += f"第 {i} / {max_page} 页(共 {image_sum} P)" 154 | await matcher.send(msg) 155 | return 156 | 157 | headers = [Message() + f"【{manga.category}】{content.title}"] 158 | if content.content: 159 | headers.append(Message() + content.content) 160 | images = [Message(x) for x in image_seg] 161 | footer = Message() + f"https://ba.gamekee.com/{manga.cid}.html" 162 | await send_forward_msg(bot, event, [*headers, *images, footer]) 163 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/command/raid.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from argparse import Namespace 3 | from typing import TYPE_CHECKING 4 | 5 | from nonebot import on_shell_command 6 | from nonebot.exception import ParserExit 7 | from nonebot.log import logger 8 | from nonebot.matcher import Matcher 9 | from nonebot.params import ShellCommandArgs 10 | from nonebot.rule import ArgumentParser 11 | 12 | from ..data.bawiki import db_get_raid_alias, db_get_terrain_alias, db_wiki_raid 13 | from ..data.schaledb import find_current_event, schale_get_config 14 | from ..help import FT_E, FT_S 15 | from ..util import recover_alia, splice_msg 16 | 17 | if TYPE_CHECKING: 18 | from . import HelpList 19 | 20 | help_list: "HelpList" = [ 21 | { 22 | "func": "总力战一图流", 23 | "trigger_method": "指令", 24 | "trigger_condition": "ba总力战", 25 | "brief_des": "查询总力战推荐配队/Boss机制", 26 | "detail_des": ( 27 | "发送当前或指定总力战Boss的配队/机制一图流攻略图\n" 28 | "支持部分Boss别名\n" 29 | "图片作者 B站@夜猫咪喵喵猫\n" 30 | " \n" 31 | f"使用 {FT_S}ba总力战 -h{FT_E} 查询指令用法\n" 32 | " \n" 33 | "指令示例:\n" 34 | f"- {FT_S}ba总力战{FT_E}(日服&国际服当前总力战Boss配队攻略)\n" 35 | f"- {FT_S}ba总力战 -s j{FT_E}(日服当前总力战Boss配队攻略)\n" 36 | f"- {FT_S}ba总力战 -s j -w{FT_E}(日服当前总力战Boss机制图)\n" 37 | f"- {FT_S}ba总力战 寿司{FT_E}(Kaiten FX Mk.0 配队攻略)\n" 38 | f"- {FT_S}ba总力战 寿司 -t 屋外{FT_E}(Kaiten FX Mk.0 屋外战配队攻略)" 39 | ), 40 | }, 41 | ] 42 | 43 | 44 | raid_wiki_parser = ArgumentParser("ba总力战") 45 | raid_wiki_parser.add_argument( 46 | "name", 47 | nargs="?", 48 | default=None, 49 | help="总力战Boss名称,不指定默认取当前服务器总力战Boss", 50 | ) 51 | raid_wiki_parser.add_argument( 52 | "-s", 53 | "--server", 54 | nargs="*", 55 | help="服务器名称,`j`或`日`代表日服,`g`或`国际`代表国际服,`c`或`国`代表国服,可指定多个,默认全选", 56 | default=["j", "g", "c"], 57 | ) 58 | raid_wiki_parser.add_argument( 59 | "-t", 60 | "--terrain", 61 | help="指定总力战环境,不指定默认全选,不带Boss名称该参数无效", 62 | ) 63 | raid_wiki_parser.add_argument( 64 | "-w", 65 | "--wiki", 66 | action="store_true", 67 | help="发送该总力战Boss的技能机制而不是配队推荐", 68 | ) 69 | 70 | cmd_raid_wiki = on_shell_command("ba总力战", parser=raid_wiki_parser) 71 | 72 | 73 | @cmd_raid_wiki.handle() 74 | async def _(matcher: Matcher, foo: ParserExit = ShellCommandArgs()): 75 | im = "" 76 | if foo.status != 0: 77 | im = "参数错误\n" 78 | await matcher.finish(f"{im}{foo.message}") 79 | 80 | 81 | @cmd_raid_wiki.handle() 82 | async def _(matcher: Matcher, args: Namespace = ShellCommandArgs()): 83 | if not args.server: 84 | await matcher.finish("请指定server参数") 85 | 86 | keys = { 87 | 0: ("日", "j"), 88 | 1: ("国际", "g"), 89 | 2: ("国", "c"), 90 | } 91 | 92 | server = set() 93 | for s in args.server: 94 | for i, k in keys.items(): 95 | if s in k: 96 | server.add(i) 97 | server = list(server) 98 | server.sort() 99 | 100 | tasks = [] 101 | if not args.name: 102 | try: 103 | common = await schale_get_config() 104 | for s in server: 105 | raid = common["Regions"][s]["CurrentRaid"] 106 | if (r := find_current_event(raid)) and (raid := r[0]["raid"]) < 1000: 107 | tasks.append( 108 | db_wiki_raid(raid, [s], args.wiki, r[0].get("terrain")), 109 | ) 110 | except Exception: 111 | logger.exception("获取当前总力战失败") 112 | await matcher.finish("获取当前总力战失败") 113 | 114 | if not tasks: 115 | await matcher.finish("目前服务器没有正在进行的总力战,请手动指定") 116 | else: 117 | tasks.append( 118 | db_wiki_raid( 119 | recover_alia(args.name, await db_get_raid_alias()), 120 | server, 121 | args.wiki, 122 | ( 123 | recover_alia(args.terrain, await db_get_terrain_alias()) 124 | if args.terrain 125 | else None 126 | ), 127 | ), 128 | ) 129 | 130 | try: 131 | ret = await asyncio.gather(*tasks) 132 | except Exception: 133 | logger.exception("获取总力战wiki失败") 134 | await matcher.finish("获取图片失败,请检查后台输出") 135 | 136 | await matcher.finish(splice_msg(ret)) 137 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/command/raid_data_cn.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import TYPE_CHECKING, Awaitable, Callable, Dict, Optional, Tuple 3 | 4 | from nonebot import logger, on_command 5 | from nonebot.adapters.onebot.v11 import MessageSegment 6 | from nonebot.adapters.onebot.v11.message import Message 7 | from nonebot.matcher import Matcher 8 | from nonebot.params import CommandArg 9 | from nonebot.typing import T_State 10 | 11 | from ..config import config 12 | from ..data.shittim_chest import ( 13 | RAID_ANALYSIS_URL, 14 | RANK_DATA_TYPE_NAME_MAP, 15 | SERVER_NAME_MAP, 16 | RankDataType, 17 | RankRecord, 18 | Season, 19 | ServerType, 20 | async_iter_all, 21 | get_alice_friends, 22 | get_diligent_achievers, 23 | get_participation_chart_data, 24 | get_raid_chart_data, 25 | get_rank_list, 26 | get_rank_list_by_last_rank, 27 | get_rank_list_top, 28 | get_season_list, 29 | render_raid_analysis, 30 | render_raid_rank, 31 | render_rank_detail, 32 | ) 33 | from ..help import FT_E, FT_S 34 | 35 | if TYPE_CHECKING: 36 | from . import HelpList 37 | 38 | help_list: "HelpList" = [ 39 | { 40 | "func": "国服总力数据", 41 | "trigger_method": "指令", 42 | "trigger_condition": "ba帮助 国服总力", 43 | "brief_des": "从什亭之匣查询国服总力相关数据", 44 | "detail_des": ( 45 | "查询国服总力相关数据\n" 46 | f"数据来源:什亭之匣({config.ba_shittim_url})\n" 47 | " \n" 48 | "下面指令中的可选参数介绍,每个参数间需以空格分隔:\n" 49 | f"- {FT_S}服务器名称{FT_E} 可选值 “官服” 或 “B服”,默认为官服;\n" 50 | f"- {FT_S}期数序号{FT_E} 可以使用指令 {FT_S}ba总力列表{FT_E} 查询,默认为最新一期;\n" 51 | " \n" 52 | "使用以下指令查询总力档线:(可选参数:服务器名称、期数序号)\n" 53 | f"- {FT_S}ba总力档线{FT_E}\n" 54 | f"- {FT_S}ba档线{FT_E}\n" 55 | " \n" 56 | "使用以下指令查询总力统计概览:(无参数)\n" 57 | f"- {FT_S}ba总力统计{FT_E}\n" 58 | " \n" 59 | "使用以下指令查询往期总力第 1 名:(可选参数:服务器名称)\n" 60 | f"- {FT_S}ba小心卷狗{FT_E}\n" 61 | " \n" 62 | "使用以下指令查询往期总力第 20001 名:(可选参数:服务器名称)\n" 63 | f"- {FT_S}ba爱丽丝的伙伴{FT_E}\n" 64 | f"- {FT_S}ba爱丽丝伙伴{FT_E}" 65 | ), 66 | }, 67 | ] 68 | 69 | 70 | def parse_args(raw_arg: str) -> Tuple[ServerType, Optional[int]]: 71 | args = raw_arg.strip().split() 72 | 73 | server = ServerType.Official 74 | season: Optional[int] = None 75 | 76 | for arg in args: 77 | if server_id := next( 78 | (k for k, v in SERVER_NAME_MAP.items() if v.lower() == arg.lower()), 79 | None, 80 | ): 81 | server = ServerType(server_id) 82 | elif arg.isdigit(): 83 | season = int(arg) 84 | else: 85 | raise ValueError(f"参数 `{arg}` 无效") 86 | 87 | return server, season 88 | 89 | 90 | async def get_season_by_index(season_index: Optional[int]) -> Season: 91 | seasons = await get_season_list() 92 | return ( 93 | seasons[0] 94 | if season_index is None 95 | else next(x for x in seasons if x.season == season_index) 96 | ) 97 | 98 | 99 | cmd_raid_list = on_command("ba总力列表") 100 | 101 | 102 | @cmd_raid_list.handle() 103 | async def _(matcher: Matcher): 104 | try: 105 | seasons = await get_season_list() 106 | except Exception: 107 | logger.exception("Error when getting rank data") 108 | await matcher.finish("获取数据时出错,请检查后台输出") 109 | 110 | if not seasons: 111 | await matcher.finish("暂无数据") 112 | 113 | # seasons.sort(key=lambda season: season.season, reverse=True) 114 | await matcher.finish( 115 | "\n".join( 116 | f"第 {season.season} 期 - {season.season_map.value} {season.boss}" 117 | for season in seasons 118 | ), 119 | ) 120 | 121 | 122 | cmd_raid_score = on_command( 123 | "ba总力档线", 124 | aliases={"ba档线"}, 125 | state={"data_type": RankDataType.Score}, 126 | ) 127 | 128 | 129 | @cmd_raid_score.handle() 130 | async def _(matcher: Matcher, state: T_State, arg_msg: Message = CommandArg()): 131 | try: 132 | server, season_index = parse_args(arg_msg.extract_plain_text()) 133 | except ValueError as e: 134 | await matcher.finish(str(e)) 135 | 136 | data_type: RankDataType = state["data_type"] 137 | 138 | server_name = SERVER_NAME_MAP[server.value] 139 | data_type_name = RANK_DATA_TYPE_NAME_MAP[data_type.value] 140 | 141 | try: 142 | season = await get_season_by_index(season_index) 143 | season_index = season.season 144 | ( 145 | rank_list_top, 146 | rank_list_by_last_rank, 147 | rank_list, 148 | raid_chart, 149 | participation_chart, 150 | ) = await asyncio.gather( 151 | get_rank_list_top(server, season_index), 152 | get_rank_list_by_last_rank(server, season_index), 153 | async_iter_all(get_rank_list(server, data_type, season_index)), 154 | get_raid_chart_data(server, season_index), 155 | get_participation_chart_data(server, season_index), 156 | ) 157 | except StopIteration: 158 | await matcher.finish("期数不存在") 159 | except Exception: 160 | logger.exception("Error when getting rank data") 161 | await matcher.finish("获取数据时出错,请检查后台输出") 162 | 163 | if not rank_list: 164 | await matcher.finish(f"暂无{data_type_name}数据") 165 | 166 | try: 167 | img = await render_raid_rank( 168 | server_name, 169 | data_type_name, 170 | season, 171 | rank_list_top, 172 | rank_list_by_last_rank, 173 | rank_list, 174 | raid_chart, 175 | participation_chart, 176 | ) 177 | except Exception: 178 | logger.exception("Error when rendering image") 179 | await matcher.finish("渲染图片时出错,请检查后台输出") 180 | 181 | await matcher.finish(MessageSegment.image(img)) 182 | 183 | 184 | cmd_raid_analysis = on_command("ba总力统计") 185 | 186 | 187 | @cmd_raid_analysis.handle() 188 | async def _(matcher: Matcher): 189 | try: 190 | img = await render_raid_analysis() 191 | except Exception: 192 | logger.exception("Error when rendering image") 193 | await matcher.finish("渲染图片时出错,请检查后台输出") 194 | 195 | await matcher.finish(MessageSegment.image(img) + f"详情请访问 {RAID_ANALYSIS_URL}") 196 | 197 | 198 | cmd_alice_friends = on_command( 199 | "ba爱丽丝的伙伴", 200 | aliases={"ba爱丽丝伙伴"}, 201 | state={"func": get_alice_friends, "title": "爱丽丝的伙伴"}, 202 | ) 203 | cmd_diligent_achievers = on_command( 204 | "ba小心卷狗", 205 | state={"func": get_diligent_achievers, "title": "小心卷狗"}, 206 | ) 207 | 208 | 209 | @cmd_alice_friends.handle() 210 | @cmd_diligent_achievers.handle() 211 | async def _(matcher: Matcher, state: T_State, arg_msg: Message = CommandArg()): 212 | try: 213 | server, _ = parse_args(arg_msg.extract_plain_text()) 214 | except ValueError as e: 215 | await matcher.finish(str(e)) 216 | 217 | title: str = f'{state["title"]}({SERVER_NAME_MAP[server.value]})' 218 | func: Callable[[ServerType], Awaitable[Dict[int, RankRecord]]] = state["func"] 219 | 220 | try: 221 | season_list, rank_list = await asyncio.gather(get_season_list(), func(server)) 222 | except Exception: 223 | logger.exception("Error when getting rank data") 224 | await matcher.finish("获取数据时出错,请检查后台输出") 225 | 226 | try: 227 | img = await render_rank_detail(title, season_list, rank_list) 228 | except Exception: 229 | logger.exception("Error when rendering image") 230 | await matcher.finish("渲染图片时出错,请检查后台输出") 231 | 232 | await matcher.finish(MessageSegment.image(img)) 233 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/command/stu_fav.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import TYPE_CHECKING 3 | 4 | from nonebot import on_command 5 | from nonebot.adapters.onebot.v11 import ActionFailed, Message, MessageSegment 6 | from nonebot.log import logger 7 | from nonebot.matcher import Matcher 8 | from nonebot.params import CommandArg 9 | 10 | from ..config import config 11 | from ..data.bawiki import db_get_extra_l2d_list, recover_stu_alia, schale_to_gamekee 12 | from ..data.gamekee import game_kee_get_stu_cid_li, game_kee_grab_l2d 13 | from ..data.schaledb import draw_fav_li, get_fav_li, schale_get_stu_dict 14 | from ..help import FT_E, FT_S 15 | from ..util import RespType as Rt, async_req 16 | 17 | if TYPE_CHECKING: 18 | from . import HelpList 19 | 20 | help_list: "HelpList" = [ 21 | { 22 | "func": "羁绊查询", 23 | "trigger_method": "指令", 24 | "trigger_condition": "ba羁绊", 25 | "brief_des": "查询学生解锁L2D需求的羁绊等级", 26 | "detail_des": ( 27 | "使用学生名称查询该学生解锁L2D看板需求的羁绊等级以及L2D预览," 28 | "或者使用羁绊等级级数查询哪些学生达到该等级时解锁L2D看板\n" 29 | "使用学生名称查询时支持部分学生别名\n" 30 | " \n" 31 | "可以用这些指令触发:\n" 32 | f"- {FT_S}ba羁绊{FT_E}\n" 33 | f"- {FT_S}ba好感度{FT_E}\n" 34 | f"- {FT_S}bal2d{FT_E}\n" 35 | f"- {FT_S}balive2d{FT_E}\n" 36 | " \n" 37 | "指令示例:\n" 38 | f"- {FT_S}ba羁绊 xcw{FT_E}\n" 39 | f"- {FT_S}ba羁绊 9{FT_E}" 40 | ), 41 | }, 42 | ] 43 | 44 | 45 | cmd_fav = on_command( 46 | "ba好感度", 47 | aliases={"ba羁绊", "bal2d", "baL2D", "balive2d", "baLive2D"}, 48 | ) 49 | 50 | 51 | @cmd_fav.handle() 52 | async def _(matcher: Matcher, cmd_arg: Message = CommandArg()): 53 | async def check_size(url: str) -> bool: 54 | headers = await async_req(url, method="HEAD", resp_type=Rt.HEADERS) 55 | size = headers.get("Content-Length", 0) 56 | if not size: 57 | logger.debug(f"HEAD {url} resp header has no Content-Length") 58 | return True 59 | ok = int(size) < 1024 * 1024 * 10 60 | if not ok: 61 | logger.warning(f"{url} size too large ({size * 1024:.2f} KB > 10240 KB)") 62 | return ok 63 | 64 | async def get_l2d(stu_name: str): 65 | if r := (await db_get_extra_l2d_list()).get(stu_name): 66 | return [f"{config.ba_bawiki_db_url}{x}" for x in r] 67 | 68 | cid = (await game_kee_get_stu_cid_li()).get(stu_name) 69 | if cid: 70 | l2d_list = await game_kee_grab_l2d(cid) 71 | checked_resp = await asyncio.gather( 72 | *(check_size(x) for x in l2d_list), 73 | ) 74 | return [x for x, y in zip(l2d_list, checked_resp) if y] 75 | 76 | return None 77 | 78 | arg = cmd_arg.extract_plain_text().strip() 79 | if not arg: 80 | await matcher.finish("请提供学生名称或所需的羁绊等级") 81 | 82 | # 好感度等级 83 | if arg.isdigit(): 84 | arg = int(arg) 85 | 86 | try: 87 | li = await get_fav_li(arg) 88 | except Exception: 89 | logger.exception("获取 SchaleDB 学生数据失败") 90 | await matcher.finish("获取 SchaleDB 学生数据失败,请检查后台输出") 91 | 92 | if not li: 93 | await matcher.finish(f"没有学生在羁绊等级{arg}时解锁L2D") 94 | 95 | try: 96 | p = await draw_fav_li(li) 97 | except Exception: 98 | logger.exception("绘制图片出错") 99 | await matcher.finish("绘制图片出错,请检查后台输出") 100 | 101 | await matcher.finish( 102 | f"羁绊等级 {arg} 时解锁L2D的学生有以下这些:" + MessageSegment.image(p), 103 | ) 104 | 105 | # 学生名称 106 | arg = await recover_stu_alia(arg) 107 | 108 | try: 109 | ret = await schale_get_stu_dict() 110 | except Exception: 111 | logger.exception("获取学生列表出错") 112 | await matcher.finish("获取学生列表表出错,请检查后台输出") 113 | 114 | if stu := ret.get(arg): 115 | if not (lvl := stu["MemoryLobby"]): 116 | await matcher.finish("该学生没有L2D") 117 | 118 | try: 119 | pics = await get_l2d(await schale_to_gamekee(arg)) 120 | except Exception: 121 | logger.exception("下载 L2D 图片出错") 122 | await matcher.finish("获取 L2D 图片列表出错,请检查后台输出") 123 | 124 | im = Message() + f'{stu["Name"]} 在羁绊等级 {lvl[0]} 时即可解锁L2D\n' 125 | if pics: 126 | try: 127 | images = await asyncio.gather( 128 | *(async_req(x, resp_type=Rt.BYTES) for x in pics), 129 | ) 130 | except Exception: 131 | logger.exception("下载 L2D 图片出错") 132 | await matcher.finish("下载 L2D 图片出错,请检查后台输出") 133 | image_seg = "L2D预览:" + Message(MessageSegment.image(x) for x in images) 134 | 135 | else: 136 | im += ( 137 | "没找到该学生的L2D看板\n" 138 | "可能原因:\n" 139 | "- GameKee页面爬取不到角色L2D图片\n" 140 | "- GameKee和插件没有收录该学生的L2D\n" 141 | ) 142 | await matcher.finish(im) 143 | 144 | try: 145 | await matcher.finish(im + image_seg) 146 | except ActionFailed as e: 147 | if image_seg: 148 | logger.warning(f"Failed to send message: ActionFailed: {e}") 149 | await matcher.finish(im + "抱歉,L2D 图片被风控了,或许是因为太涩了……") 150 | raise 151 | 152 | await matcher.finish("未找到学生") 153 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/command/stu_rank.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from nonebot import on_command 4 | from nonebot.adapters.onebot.v11 import Message 5 | from nonebot.log import logger 6 | from nonebot.matcher import Matcher 7 | from nonebot.params import CommandArg 8 | 9 | from ..data.bawiki import db_wiki_stu, recover_stu_alia 10 | from ..help import FT_E, FT_S 11 | 12 | if TYPE_CHECKING: 13 | from . import HelpList 14 | 15 | help_list: "HelpList" = [ 16 | { 17 | "func": "学生评价", 18 | "trigger_method": "指令", 19 | "trigger_condition": "ba角评", 20 | "brief_des": "查询学生角评一图流", 21 | "detail_des": ( 22 | "发送一张指定角色的评价图\n" 23 | "支持部分学生别名\n" 24 | "角评图作者 B站@夜猫咪喵喵猫\n" 25 | " \n" 26 | "可以使用 `all` / `总览` / `全部` 参数 查看全学生角评一图流\n" 27 | " \n" 28 | "可以用这些指令触发:\n" 29 | f"- {FT_S}ba学生评价{FT_E}\n" 30 | f"- {FT_S}ba角评{FT_E}\n" 31 | " \n" 32 | "指令示例:\n" 33 | f"- {FT_S}ba学生评价 白子{FT_E}\n" 34 | f"- {FT_S}ba角评 xcw{FT_E}\n" 35 | f"- {FT_S}ba角评 总览{FT_E}" 36 | ), 37 | }, 38 | ] 39 | 40 | 41 | cmd_stu_rank = on_command("ba学生评价", aliases={"ba角评"}) 42 | 43 | 44 | @cmd_stu_rank.handle() 45 | async def _(matcher: Matcher, cmd_arg: Message = CommandArg()): 46 | arg = cmd_arg.extract_plain_text().strip() 47 | if not arg: 48 | await matcher.finish("请提供学生名称") 49 | 50 | if arg == "总览" or arg == "全部" or arg.lower() == "all": 51 | arg = "all" 52 | else: 53 | arg = await recover_stu_alia(arg) 54 | 55 | try: 56 | im = await db_wiki_stu(arg) 57 | except Exception: 58 | logger.exception("获取角评出错") 59 | await matcher.finish("获取角评出错,请检查后台输出") 60 | 61 | await matcher.finish(im) 62 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/command/stu_wiki_gamekee.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from nonebot import on_command 4 | from nonebot.adapters.onebot.v11 import Bot, Message, MessageEvent, MessageSegment 5 | from nonebot.log import logger 6 | from nonebot.matcher import Matcher 7 | from nonebot.params import CommandArg 8 | 9 | from ..config import config 10 | from ..data.bawiki import recover_stu_alia 11 | from ..data.gamekee import ( 12 | game_kee_get_page, 13 | game_kee_get_stu_cid_li, 14 | game_kee_page_url, 15 | ) 16 | from ..help import FT_E, FT_S 17 | from ..util import send_forward_msg 18 | 19 | if TYPE_CHECKING: 20 | from . import HelpList 21 | 22 | help_list: "HelpList" = [ 23 | { 24 | "func": "学生Wiki", 25 | "trigger_method": "指令", 26 | "trigger_condition": "ba学生wiki", 27 | "brief_des": "查询学生详情(GameKee)", 28 | "detail_des": ( 29 | "访问对应学生GameKee Wiki页面并截图,支持部分学生别名\n" 30 | " \n" 31 | "指令示例:\n" 32 | f"- {FT_S}ba学生wiki 白子{FT_E}\n" 33 | f"- {FT_S}ba学生wiki xcw{FT_E}" 34 | ), 35 | }, 36 | ] 37 | 38 | 39 | cmd_stu_wiki = on_command("ba学生wiki", aliases={"ba学生Wiki", "ba学生WIKI"}) 40 | 41 | 42 | @cmd_stu_wiki.handle() 43 | async def _( 44 | bot: Bot, 45 | event: MessageEvent, 46 | matcher: Matcher, 47 | cmd_arg: Message = CommandArg(), 48 | ): 49 | arg = cmd_arg.extract_plain_text().strip() 50 | if not arg: 51 | await matcher.finish("请提供学生名称") 52 | 53 | try: 54 | ret = await game_kee_get_stu_cid_li() 55 | except Exception: 56 | logger.exception("获取学生列表出错") 57 | await matcher.finish("获取学生列表出错,请检查后台输出") 58 | 59 | if not ret: 60 | await matcher.finish("没有获取到学生列表数据") 61 | 62 | if not (sid := ret.get(await recover_stu_alia(arg, game_kee=True))): 63 | await matcher.finish("未找到该学生") 64 | 65 | url = game_kee_page_url(sid) 66 | await matcher.send(f"请稍等,正在截取Wiki页面……\n{url}") 67 | 68 | try: 69 | images = await game_kee_get_page(url) 70 | except Exception: 71 | logger.exception(f"截取wiki页面出错 {url}") 72 | await matcher.finish("截取页面出错,请检查后台输出") 73 | 74 | img_seg = [MessageSegment.image(x) for x in images] 75 | if config.ba_use_forward_msg: 76 | await send_forward_msg(bot, event, img_seg) 77 | else: 78 | await matcher.finish(Message(img_seg)) 79 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/command/stu_wiki_schale.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from nonebot import on_command 4 | from nonebot.adapters.onebot.v11 import Message, MessageSegment 5 | from nonebot.log import logger 6 | from nonebot.matcher import Matcher 7 | from nonebot.params import CommandArg 8 | 9 | from ..config import config 10 | from ..data.bawiki import recover_stu_alia 11 | from ..data.schaledb import schale_get_stu_dict, schale_get_stu_info 12 | from ..help import FT_E, FT_S 13 | 14 | if TYPE_CHECKING: 15 | from . import HelpList 16 | 17 | help_list: "HelpList" = [ 18 | { 19 | "func": "学生图鉴", 20 | "trigger_method": "指令", 21 | "trigger_condition": "ba学生图鉴", 22 | "brief_des": "查询学生详情(SchaleDB)", 23 | "detail_des": ( 24 | "访问对应学生SchaleDB页面并截图,支持部分学生别名\n" 25 | " \n" 26 | "指令示例:\n" 27 | f"- {FT_S}ba学生图鉴 白子{FT_E}\n" 28 | f"- {FT_S}ba学生图鉴 xcw{FT_E}" 29 | ), 30 | }, 31 | ] 32 | 33 | 34 | cmd_stu_schale = on_command("ba学生图鉴") 35 | 36 | 37 | @cmd_stu_schale.handle() 38 | async def _(matcher: Matcher, cmd_arg: Message = CommandArg()): 39 | arg = cmd_arg.extract_plain_text().strip() 40 | if not arg: 41 | await matcher.finish("请提供学生名称") 42 | 43 | try: 44 | ret = await schale_get_stu_dict() 45 | except Exception: 46 | logger.exception("获取学生列表出错") 47 | await matcher.finish("获取学生列表表出错,请检查后台输出") 48 | 49 | if not ret: 50 | await matcher.finish("没有获取到学生列表数据") 51 | 52 | if not (data := ret.get(await recover_stu_alia(arg))): 53 | await matcher.finish("未找到该学生") 54 | 55 | stu_name = data["PathName"] 56 | await matcher.send( 57 | f"请稍等,正在截取SchaleDB页面~\n{config.ba_schale_url}?chara={stu_name}", 58 | ) 59 | 60 | try: 61 | img = MessageSegment.image(await schale_get_stu_info(stu_name)) 62 | except Exception: 63 | logger.exception(f"截取schale db页面出错 chara={stu_name}") 64 | await matcher.finish("截取页面出错,请检查后台输出") 65 | 66 | await matcher.finish(img) 67 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/command/time_atk.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import TYPE_CHECKING 3 | 4 | from nonebot import on_command 5 | from nonebot.adapters.onebot.v11 import Message 6 | from nonebot.log import logger 7 | from nonebot.matcher import Matcher 8 | from nonebot.params import CommandArg 9 | 10 | from ..data.bawiki import db_wiki_time_atk 11 | from ..data.schaledb import find_current_event, schale_get_config 12 | from ..help import FT_E, FT_S 13 | from ..util import splice_msg 14 | 15 | if TYPE_CHECKING: 16 | from . import HelpList 17 | 18 | help_list: "HelpList" = [ 19 | { 20 | "func": "综合战术考试一图流", 21 | "trigger_method": "指令", 22 | "trigger_condition": "ba综合战术考试", 23 | "brief_des": "查询综合战术考试攻略图", 24 | "detail_des": ( 25 | "发送当前或指定综合战术考试一图流攻略图\n" 26 | "图片作者 B站@夜猫咪喵喵猫\n" 27 | " \n" 28 | "指令默认发送日服和国际服当前的综合战术考试攻略\n" 29 | "指令后面跟`日`或`j`开头的文本代表查询日服当前综合战术考试攻略,带以`国际`或`g`、`国`或`c`开头的文本同理\n" 30 | "跟整数则代表指定第几个综合战术考试\n" 31 | " \n" 32 | "p.s. 综合战术考试 和 合同火力演习 其实是一个东西,翻译不同而已~\n" 33 | " \n" 34 | "可以用这些指令触发:\n" 35 | f"- {FT_S}ba综合战术考试{FT_E}\n" 36 | f"- {FT_S}ba合同火力演习{FT_E}\n" 37 | f"- {FT_S}ba战术考试{FT_E}\n" 38 | f"- {FT_S}ba火力演习{FT_E}\n" 39 | " \n" 40 | "指令示例:\n" 41 | f"- {FT_S}ba综合战术考试{FT_E}\n" 42 | f"- {FT_S}ba综合战术考试 日{FT_E}\n" 43 | f"- {FT_S}ba综合战术考试 8{FT_E}" 44 | ), 45 | }, 46 | ] 47 | 48 | 49 | cmd_time_atk_wiki = on_command( 50 | "ba综合战术考试", 51 | aliases={"ba合同火力演习", "ba战术考试", "ba火力演习"}, 52 | ) 53 | 54 | 55 | @cmd_time_atk_wiki.handle() 56 | async def _(matcher: Matcher, cmd_arg: Message = CommandArg()): 57 | arg = cmd_arg.extract_plain_text().lower().strip() 58 | 59 | keys = { 60 | 0: ("日", "j"), 61 | 1: ("国际", "g"), 62 | 2: ("国", "c"), 63 | } 64 | 65 | server = [] 66 | for k, v in keys.items(): 67 | if (not arg) or arg.startswith(v): 68 | server.append(k) 69 | for kw in v: 70 | arg = arg.replace(kw, "", 1) 71 | 72 | events = [] 73 | if server: 74 | try: 75 | common = await schale_get_config() 76 | for s in server: 77 | raid = common["Regions"][s]["CurrentRaid"] 78 | if (r := find_current_event(raid)) and (raid := r[0]["raid"]) >= 1000: 79 | events.append(raid) 80 | except Exception: 81 | logger.exception("获取当前综合战术考试失败") 82 | await matcher.finish("获取当前综合战术考试失败") 83 | 84 | if not events: 85 | await matcher.finish("当前服务器没有正在进行的综合战术考试") 86 | 87 | else: 88 | if (not str(arg).isdigit()) or ((arg := int(arg)) < 1): 89 | await matcher.finish( 90 | "综合战术考试ID需为整数,从1开始,代表第1个综合战术考试", 91 | ) 92 | events.append(arg) 93 | 94 | try: 95 | ret = await asyncio.gather(*[db_wiki_time_atk(x) for x in events]) 96 | except Exception: 97 | logger.exception("获取综合战术考试wiki出错") 98 | await matcher.finish("获取图片出错,请检查后台输出") 99 | 100 | await matcher.finish(splice_msg(ret)) 101 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/command/update_future.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from contextlib import suppress 3 | from typing import TYPE_CHECKING, Literal, Optional 4 | 5 | from nonebot import on_command 6 | from nonebot.adapters.onebot.v11 import Message 7 | from nonebot.matcher import Matcher 8 | from nonebot.params import CommandArg 9 | from nonebot.typing import T_State 10 | 11 | from ..data.bawiki import db_future 12 | from ..help import FT_E, FT_S 13 | 14 | if TYPE_CHECKING: 15 | from . import HelpList 16 | 17 | help_list: "HelpList" = [ 18 | { 19 | "func": "更新前瞻", 20 | "trigger_method": "指令", 21 | "trigger_condition": "ba千里眼", 22 | "brief_des": "查询国际服/国服未来的卡池与活动", 23 | "detail_des": ( 24 | "发送当前或指定日期的国际服/国服未来卡池与活动列表\n" 25 | "图片作者 B站@夜猫咪喵喵猫\n" 26 | " \n" 27 | "参数可以指定起始日期以及列表个数,但同时指定时请将日期放在列表个数前面,以空格分隔\n" 28 | "参数中含有 `全` 或 `a` 时会发送整张前瞻图\n" 29 | " \n" 30 | "参数以 `国际服` 或 `国际` 或 `global` 或 `g` 开头时会发送 国际服 前瞻图\n" 31 | "参数以 `国服` 或 `国` 或 `chinese` 或 `c` 开头时会发送 国服 前瞻图\n" 32 | "如果参数不以这些词开头时,默认发送国际服前瞻图\n" 33 | " \n" 34 | "日期格式可以为(Y代表4位数年,m代表月,d代表日):\n" 35 | f"- {FT_S}Y/m/d{FT_E};{FT_S}m/d{FT_E}\n" 36 | f"- {FT_S}Y-m-d{FT_E};{FT_S}m-d{FT_E}\n" 37 | f"- {FT_S}Y年m月d日{FT_E};{FT_S}m月d日{FT_E}\n" 38 | " \n" 39 | "可以用这些指令触发:\n" 40 | f"- {FT_S}ba千里眼{FT_E}\n" 41 | f"- {FT_S}ba前瞻{FT_E}\n" 42 | " \n" 43 | "有以下指令别名:\n" 44 | f"- {FT_S}ba国服千里眼{FT_E} 或 {FT_S}ba国服前瞻{FT_E} -> {FT_S}ba千里眼 国服{FT_E}\n" 45 | f"- {FT_S}ba国际服千里眼{FT_E} 或 {FT_S}ba国际服前瞻{FT_E} -> {FT_S}ba千里眼 国际服{FT_E}\n" 46 | " \n" 47 | "指令示例:\n" 48 | f"- {FT_S}ba千里眼{FT_E}\n" 49 | f"- {FT_S}ba千里眼 all{FT_E}\n" 50 | f"- {FT_S}ba千里眼 3{FT_E}\n" 51 | f"- {FT_S}ba千里眼 11/15{FT_E}\n" 52 | f"- {FT_S}ba千里眼 11/15 3{FT_E}\n" 53 | f"- {FT_S}ba千里眼 国服{FT_E}\n" 54 | f"- {FT_S}ba千里眼 国际服 11/15{FT_E}" 55 | ), 56 | }, 57 | ] 58 | 59 | 60 | GLOBAL_PFX = ("国际服", "国际", "global", "g") 61 | CHINESE_PFX = ("国服", "国", "chinese", "c") 62 | 63 | cmd_future = on_command("ba千里眼", aliases={"ba前瞻"}) 64 | cmd_global_future = on_command( 65 | "ba国际服千里眼", 66 | aliases={"ba国际服前瞻"}, 67 | state={"future_type": "global"}, 68 | ) 69 | cmd_chinese_future = on_command( 70 | "ba国服千里眼", 71 | aliases={"ba国服前瞻"}, 72 | state={"future_type": "chinese"}, 73 | ) 74 | 75 | 76 | @cmd_future.handle() 77 | @cmd_global_future.handle() 78 | @cmd_chinese_future.handle() 79 | async def _(matcher: Matcher, state: T_State, arg: Message = CommandArg()): 80 | args = arg.extract_plain_text().lower().strip() 81 | 82 | future_type: Optional[Literal["global", "chinese"]] = state.get("future_type") 83 | if not future_type: 84 | used_global_pfx = next(filter(args.startswith, GLOBAL_PFX), None) 85 | used_chinese_pfx = next(filter(args.startswith, CHINESE_PFX), None) 86 | 87 | used_pfx = next((x for x in (used_chinese_pfx, used_global_pfx) if x), None) 88 | if used_pfx: 89 | args = args[len(used_pfx) :].strip() 90 | 91 | is_chinese = used_chinese_pfx 92 | future_type = "chinese" if is_chinese else "global" 93 | 94 | if "全" in args or "a" in args: 95 | await matcher.finish(await db_future(future_type, all_img=True)) 96 | 97 | args = args.split() 98 | num = 3 99 | date = None 100 | if (args_len := len(args)) == 1: 101 | if args[0].isdigit(): 102 | num = args[0] 103 | else: 104 | date = args[0] 105 | elif args_len > 1: 106 | date = args[0].strip() 107 | num = args[-1].strip() 108 | 109 | if date: 110 | parsed_date = None 111 | for f in ["%Y/%m/%d", "%Y-%m-%d", "%Y年%m月%d日", "%m/%d", "%m-%d", "%m月%d日"]: 112 | with suppress(ValueError): 113 | parsed_date = datetime.datetime.strptime( 114 | date.replace(" ", ""), 115 | f, 116 | ).astimezone() 117 | break 118 | if not parsed_date: 119 | await matcher.finish("日期格式不正确!") 120 | date = parsed_date 121 | if date.year == 1900: 122 | now = ( 123 | datetime.datetime.now() 124 | .astimezone() 125 | .replace( 126 | hour=0, 127 | minute=0, 128 | second=0, 129 | microsecond=0, 130 | ) 131 | ) 132 | date = date.replace(year=now.year) 133 | if date < now: 134 | date = date.replace(year=now.year + 1) 135 | 136 | if isinstance(num, str) and ((not num.isdigit()) or (num := int(num)) < 1): 137 | await matcher.finish("前瞻项目数量格式不正确!") 138 | 139 | await matcher.finish(await db_future(future_type, date or None, num)) 140 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/command/voice.py: -------------------------------------------------------------------------------- 1 | import random 2 | from typing import TYPE_CHECKING, List 3 | 4 | from nonebot import on_command 5 | from nonebot.adapters.onebot.v11 import Message, MessageSegment 6 | from nonebot.log import logger 7 | from nonebot.matcher import Matcher 8 | from nonebot.params import CommandArg, EventMessage 9 | from nonebot.typing import T_State 10 | 11 | from ..config import config 12 | from ..data.bawiki import recover_stu_alia, schale_to_gamekee 13 | from ..data.gamekee import GameKeeVoice, game_kee_get_stu_li, game_kee_get_voice 14 | from ..help import FT_E, FT_S 15 | from ..util import IllegalOperationFinisher, RespType as Rt, async_req 16 | 17 | if TYPE_CHECKING: 18 | from . import HelpList 19 | 20 | help_list: "HelpList" = [ 21 | { 22 | "func": "学生语音", 23 | "trigger_method": "指令", 24 | "trigger_condition": "ba语音", 25 | "brief_des": "发送学生语音", 26 | "detail_des": ( 27 | "从GameKee爬取学生语音并发送\n" 28 | "可以用 语音名称 或 中文或日文台词 搜索角色语音\n" 29 | " \n" 30 | "默认获取日配语音,如果角色有中配,则可以在指令后方带上 `中配` 来获取\n" 31 | "如果找不到中配语音对应的台词,则会展示日配台词\n" 32 | " \n" 33 | "指令示例:\n" 34 | f"- {FT_S}ba语音 忧{FT_E}\n" 35 | f"- {FT_S}ba语音 美游 被cc{FT_E}\n" 36 | f"- {FT_S}ba语音 水大叔 好热{FT_E}\n" 37 | f"- {FT_S}ba语音中配 白子{FT_E}\n" 38 | f"- {FT_S}ba语音中配 大叔 睡午觉{FT_E}" 39 | ), 40 | }, 41 | ] 42 | 43 | 44 | KEY_VOICE_LIST = "voice_list" 45 | KEY_SELECTED_VOICE = "selected_voice" 46 | KEY_VOICE_TYPE = "voice_type" 47 | KEY_ORIGINAL_STU_NAME = "original_stu_name" 48 | KEY_STU_ICON = "stu_icon" 49 | 50 | illegal_finisher = IllegalOperationFinisher("非法操作次数过多,已退出选择") 51 | 52 | cmd_voice = on_command("ba语音") 53 | 54 | 55 | @cmd_voice.handle() 56 | async def _(matcher: Matcher, state: T_State, cmd_arg: Message = CommandArg()): 57 | arg = cmd_arg.extract_plain_text().strip() 58 | is_chinese = False 59 | if arg.startswith("中配"): 60 | arg = arg[2:].strip() 61 | is_chinese = True 62 | 63 | if not arg: 64 | await matcher.finish("请提供学生名称") 65 | 66 | arg = arg.split() 67 | arg_len = len(arg) 68 | name = " ".join(arg[:-1]) if arg_len > 1 else arg[0] 69 | v_type = arg[-1].strip().lower() if arg_len > 1 else None 70 | 71 | try: 72 | ret = await game_kee_get_stu_li() 73 | except Exception: 74 | logger.exception("获取学生列表出错") 75 | await matcher.finish("获取学生列表出错,请检查后台输出") 76 | 77 | if not ret: 78 | await matcher.finish("没有获取到学生列表数据") 79 | 80 | try: 81 | org_stu_name = await recover_stu_alia(name, game_kee=True) 82 | stu_name = await schale_to_gamekee(org_stu_name) 83 | except Exception: 84 | logger.exception("还原学生别名失败") 85 | await matcher.finish("还原学生别名失败,请检查后台输出") 86 | 87 | if not (stu_info := ret.get(stu_name)): 88 | await matcher.finish("未找到该学生") 89 | 90 | voices = await game_kee_get_voice(stu_info["content_id"], is_chinese) 91 | if v_type: 92 | voices = [ 93 | x 94 | for x in voices 95 | if ( 96 | (v_type in x.title.lower()) 97 | or (v_type in x.jp.lower()) 98 | or (v_type in x.cn.lower()) 99 | ) 100 | ] 101 | if not voices: 102 | await matcher.finish("没找到符合要求的语音捏") 103 | 104 | state[KEY_VOICE_LIST] = voices 105 | state[KEY_VOICE_TYPE] = "中配" if is_chinese else "日配" 106 | state[KEY_ORIGINAL_STU_NAME] = org_stu_name 107 | state[KEY_STU_ICON] = f'http:{stu_info["icon"]}' 108 | voice_total = len(voices) 109 | if voice_total == 1: 110 | state[KEY_SELECTED_VOICE] = voices[0] 111 | elif not v_type: 112 | index = random.randint(0, voice_total - 1) 113 | state[KEY_SELECTED_VOICE] = voices[index] 114 | else: 115 | if voice_total > 5: 116 | voices = voices[:5] 117 | state[KEY_VOICE_LIST] = voices 118 | list_msg = "\n".join(f"{i}. {x.title}:{x.cn}" for i, x in enumerate(voices, 1)) 119 | too_much_tip = "\nTip:结果过多,仅显示前五个" if voice_total > 5 else "" 120 | await matcher.pause( 121 | f"找到了多个结果,请发送序号选择,发送 0 退出选择:\n{list_msg}{too_much_tip}", 122 | ) 123 | 124 | 125 | @cmd_voice.handle() 126 | async def _(matcher: Matcher, state: T_State, message: Message = EventMessage()): 127 | if KEY_SELECTED_VOICE in state: 128 | return 129 | 130 | arg = message.extract_plain_text().strip() 131 | if arg == "0": 132 | await matcher.finish("已退出选择") 133 | 134 | index = int(arg) if arg.isdigit() else None 135 | voice_list: List[GameKeeVoice] = state[KEY_VOICE_LIST] 136 | if (not index) or (index > len(voice_list)): 137 | await illegal_finisher() 138 | await matcher.finish("序号错误,请重新选择") 139 | 140 | state[KEY_SELECTED_VOICE] = voice_list[index - 1] 141 | 142 | 143 | @cmd_voice.handle() 144 | async def _(matcher: Matcher, state: T_State): 145 | v: GameKeeVoice = state[KEY_SELECTED_VOICE] 146 | voice_type: str = state[KEY_VOICE_TYPE] 147 | org_stu_name: str = state[KEY_ORIGINAL_STU_NAME] 148 | stu_icon: str = state[KEY_STU_ICON] 149 | 150 | im = [f"学生 {org_stu_name} {voice_type}语音 {v.title}"] 151 | if v.jp or v.cn: 152 | im.append("-=-=-=-=-=-=-=-") 153 | if v.jp: 154 | im.append(v.jp) 155 | if v.cn: 156 | im.append(v.cn) 157 | await matcher.send("\n".join(im)) 158 | 159 | if config.ba_voice_use_card: 160 | await matcher.finish( 161 | MessageSegment( 162 | "music", 163 | { 164 | "type": "custom", 165 | "subtype": "163", 166 | "url": v.url, 167 | "voice": v.url, 168 | "title": v.title, 169 | "content": org_stu_name, 170 | "image": stu_icon, 171 | }, 172 | ), 173 | ) 174 | 175 | v_data = await async_req(v.url, resp_type=Rt.BYTES) 176 | await matcher.finish(MessageSegment.record(v_data)) 177 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/compat.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, overload 2 | 3 | from nonebot.compat import PYDANTIC_V2 4 | 5 | __all__ = ("field_validator", "model_validator") 6 | 7 | if PYDANTIC_V2: 8 | from pydantic import ( 9 | field_validator as field_validator, # type: ignore 10 | model_validator as model_validator, 11 | ) 12 | else: 13 | from pydantic import root_validator, validator 14 | 15 | @overload 16 | def model_validator(*, mode: Literal["before"]): 17 | ... 18 | 19 | @overload 20 | def model_validator(*, mode: Literal["after"]): 21 | ... 22 | 23 | def model_validator(*, mode: Literal["before", "after"]): 24 | return root_validator(pre=mode == "before", allow_reuse=True) # type: ignore 25 | 26 | def field_validator( 27 | __field: str, 28 | *fields, 29 | mode: Literal["before", "after"] = "after", 30 | ): 31 | return validator(__field, *fields, pre=mode == "before", allow_reuse=True) 32 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/config.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from typing_extensions import Annotated 3 | 4 | from nonebot import get_plugin_config 5 | from pydantic import BaseModel, Field, HttpUrl 6 | 7 | 8 | class Cfg(BaseModel): 9 | ba_proxy: Optional[str] = None 10 | ba_shittim_proxy: Optional[str] = None 11 | ba_gacha_cool_down: int = 0 12 | ba_voice_use_card: bool = False 13 | ba_use_forward_msg: bool = True 14 | ba_screenshot_timeout: int = 60 15 | ba_disable_classic_gacha: bool = False 16 | ba_gacha_max: int = 200 17 | ba_illegal_limit: int = 3 18 | ba_arona_set_alias_only_su: bool = False 19 | 20 | ba_gamekee_url: Annotated[str, HttpUrl] = Field("https://ba.gamekee.com/") 21 | ba_schale_url: Annotated[str, HttpUrl] = Field("https://schale.gg/") 22 | ba_bawiki_db_url: Annotated[str, HttpUrl] = Field("https://bawiki.lgc2333.top/") 23 | ba_arona_api_url: Annotated[str, HttpUrl] = Field("https://arona.diyigemt.com/") 24 | ba_arona_cdn_url: Annotated[str, HttpUrl] = Field("https://arona.cdn.diyigemt.com/") 25 | ba_shittim_url: Annotated[str, HttpUrl] = Field("https://arona.icu/") 26 | ba_shittim_api_url: Annotated[str, HttpUrl] = Field("https://api.arona.icu/") 27 | ba_shittim_data_url: Annotated[str, HttpUrl] = Field("https://data.ba.benx1n.com/") 28 | 29 | ba_shittim_key: Optional[str] = None 30 | ba_shittim_request_delay: float = 0 31 | 32 | ba_req_retry: int = 1 33 | ba_req_cache_ttl: int = 10800 # 3 hrs 34 | ba_shittim_req_cache_ttl: int = 600 # 10 mins 35 | ba_req_timeout: Optional[float] = 10.0 36 | ba_auto_clear_cache_path: bool = False 37 | 38 | 39 | config = get_plugin_config(Cfg) 40 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/data/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-bawiki/b1eda32e5665f1bd8898ece57ca71f0516002e76/nonebot_plugin_bawiki/data/__init__.py -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/data/arona.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import json 3 | import shutil 4 | from typing import Dict, List, Optional 5 | 6 | import anyio 7 | from pydantic import BaseModel 8 | 9 | from ..config import config 10 | from ..resource import CACHE_DIR, DATA_DIR 11 | from ..util import RespType as Rt, async_req 12 | 13 | ARONA_CACHE_DIR = CACHE_DIR / "arona" 14 | if config.ba_auto_clear_cache_path and ARONA_CACHE_DIR.exists(): 15 | shutil.rmtree(ARONA_CACHE_DIR) 16 | if not ARONA_CACHE_DIR.exists(): 17 | ARONA_CACHE_DIR.mkdir(parents=True) 18 | 19 | ARONA_ALIAS_PATH = DATA_DIR / "arona_alias.json" # {"alias": "name"} 20 | if not ARONA_ALIAS_PATH.exists(): 21 | ARONA_ALIAS_PATH.write_text("{}", encoding="u8") 22 | 23 | 24 | class ImageModel(BaseModel): 25 | name: str 26 | path: str 27 | hash: str # noqa: A003 28 | type: int # noqa: A003 29 | 30 | 31 | class ImageAPIResult(BaseModel): 32 | status: int 33 | data: Optional[List[ImageModel]] 34 | message: str 35 | 36 | 37 | async def get_image(path: str, hash_str: Optional[str] = None) -> bytes: 38 | if not path.startswith("/"): 39 | raise ValueError("path must start with /") 40 | 41 | if hash_str: 42 | file_path = anyio.Path(ARONA_CACHE_DIR / hash_str) 43 | if await file_path.exists(): 44 | return await file_path.read_bytes() 45 | 46 | content = await async_req( 47 | f"image{path}", 48 | base_urls=config.ba_arona_cdn_url, 49 | resp_type=Rt.BYTES, 50 | ) 51 | 52 | if not hash_str: 53 | hash_str = hashlib.md5(content).hexdigest() # noqa: S324 54 | 55 | file_path = anyio.Path(ARONA_CACHE_DIR / hash_str) 56 | await file_path.write_bytes(content) 57 | 58 | return content 59 | 60 | 61 | async def search_exact(name: str) -> Optional[List[ImageModel]]: 62 | resp: dict = await async_req( 63 | "api/v1/image", 64 | base_urls=config.ba_arona_api_url, 65 | params={"name": name}, 66 | ) 67 | return ImageAPIResult(**resp).data 68 | 69 | 70 | async def search(name: str) -> Optional[List[ImageModel]]: 71 | alias_dict = json.loads(ARONA_ALIAS_PATH.read_text(encoding="u8")) 72 | recovered = alias_dict[lowered] if (lowered := name.lower()) in alias_dict else None 73 | if recovered and (resp := await search_exact(recovered)): 74 | return resp 75 | return await search_exact(name) 76 | 77 | 78 | def set_alias(name: Optional[str], alias: List[str]) -> Dict[str, Optional[str]]: 79 | data = json.loads(ARONA_ALIAS_PATH.read_text(encoding="u8")) 80 | original_dict: Dict[str, Optional[str]] = {} 81 | 82 | for a in alias: 83 | original_dict[a] = data.get(a) 84 | if name is None: 85 | data.pop(a, None) 86 | else: 87 | data[a] = name 88 | 89 | ARONA_ALIAS_PATH.write_text(json.dumps(data, ensure_ascii=False), encoding="u8") 90 | return original_dict 91 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/data/bawiki.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import datetime 3 | from io import BytesIO 4 | from typing import Any, Dict, List, Literal, Optional, cast 5 | from typing_extensions import Unpack 6 | 7 | from nonebot import logger 8 | from nonebot.adapters.onebot.v11 import Message, MessageSegment 9 | from pil_utils import BuildImage 10 | 11 | from ..config import config 12 | from ..util import AsyncReqKwargs, RespType as Rt, async_req, recover_alia 13 | 14 | 15 | async def db_get(suffix: str, **kwargs: Unpack[AsyncReqKwargs]) -> Any: 16 | kwargs["base_urls"] = config.ba_bawiki_db_url 17 | return await async_req(suffix, **kwargs) 18 | 19 | 20 | async def db_get_wiki_data() -> Dict[str, Any]: 21 | return await db_get("data/wiki.json") 22 | 23 | 24 | async def db_get_stu_alias() -> Dict[str, List[str]]: 25 | return await db_get("data/stu_alias.json") 26 | 27 | 28 | async def db_get_schale_to_gamekee() -> Dict[str, str]: 29 | return await db_get("data/schale_to_gamekee.json") 30 | 31 | 32 | async def db_get_extra_l2d_list() -> Dict[str, List[str]]: 33 | return await db_get("data/extra_l2d_list.json") 34 | 35 | 36 | async def db_get_raid_alias() -> Dict[str, List[str]]: 37 | return await db_get("data/raid_alias.json") 38 | 39 | 40 | async def db_get_terrain_alias() -> Dict[str, List[str]]: 41 | return await db_get("data/terrain_alias.json") 42 | 43 | 44 | async def db_get_event_alias() -> Dict[str, List[str]]: 45 | return await db_get("data/event_alias.json") 46 | 47 | 48 | async def db_get_gacha_data() -> Dict[str, Any]: 49 | return await db_get("data/gacha.json") 50 | 51 | 52 | async def db_get_emoji() -> List[str]: 53 | return await db_get("data/emoji.json") 54 | 55 | 56 | async def schale_to_gamekee(o: str) -> str: 57 | diff = await db_get_schale_to_gamekee() 58 | if o in diff: 59 | o = diff[o] 60 | return o.replace("(", "(").replace(")", ")") 61 | 62 | 63 | async def recover_stu_alia(a: str, game_kee: bool = False) -> str: 64 | ret = recover_alia(a, await db_get_stu_alias()) 65 | 66 | if game_kee: 67 | ret = await schale_to_gamekee(ret) 68 | 69 | return ret 70 | 71 | 72 | async def db_wiki_stu(name: str): 73 | wiki = (await db_get_wiki_data())["student"] 74 | if not (url := wiki.get(name)): 75 | return "没有找到该角色的角评,可能是学生名称错误或者插件还未收录该角色角评" 76 | return MessageSegment.image(await db_get(url, resp_type=Rt.BYTES)) 77 | 78 | 79 | async def db_wiki_raid( 80 | raid_id: Any, 81 | servers: Optional[List[int]] = None, 82 | is_wiki: bool = False, 83 | terrain: Optional[str] = None, 84 | ): 85 | if not servers: 86 | servers = [0, 1] 87 | wiki = (await db_get_wiki_data())["raid"] 88 | 89 | if not (boss := wiki.get(str(raid_id))): 90 | logger.warning(f"Raid boss {raid_id} not found") 91 | return "没有找到该总力战Boss" 92 | 93 | terrain_raid = None 94 | if terrain: 95 | if t := boss["terrains"].get( 96 | recover_alia(terrain, await db_get_terrain_alias()), 97 | ): 98 | terrain_raid = t 99 | else: 100 | logger.warning(f"Raid boss {raid_id} terrain {terrain} not found") 101 | return "还没有进行过该环境的总力战" 102 | 103 | img = [] 104 | if is_wiki: 105 | if not (wiki_url := boss.get("wiki")): 106 | logger.warning(f"Raid boss {raid_id} wiki not found") 107 | return "该总力战Boss暂无机制介绍" 108 | img.append(wiki_url) 109 | else: 110 | img_ = [terrain_raid] if terrain_raid else list(boss["terrains"].values()) 111 | for i in img_: 112 | img.extend(i[s] for s in servers) 113 | 114 | return [ 115 | MessageSegment.image(x) 116 | for x in await asyncio.gather(*(db_get(x, resp_type=Rt.BYTES) for x in img)) 117 | ] 118 | 119 | 120 | async def db_wiki_event(event_id: Any): 121 | event_id = str(event_id) 122 | wiki = (await db_get_wiki_data())["event"] 123 | if not (ev := wiki.get(event_id)): 124 | logger.warning(f"Event {event_id} not found") 125 | return f"没有找到 ID 为 {event_id} 的活动" 126 | return Message( 127 | MessageSegment.image(x) 128 | for x in await asyncio.gather(*(db_get(x, resp_type=Rt.BYTES) for x in ev)) 129 | ) 130 | 131 | 132 | async def db_wiki_time_atk(raid_id: int): 133 | if raid_id >= 1000: 134 | raid_id = int(raid_id / 1000) 135 | wiki = (await db_get_wiki_data())["time_atk"] 136 | if raid_id > len(wiki): 137 | logger.warning(f"Time atk {raid_id} not found") 138 | return f"没有找到该综合战术考试(目前共有{len(wiki)}个综合战术考试)" 139 | raid_id -= 1 140 | 141 | return MessageSegment.image(await db_get(wiki[raid_id], resp_type=Rt.BYTES)) 142 | 143 | 144 | async def db_wiki_craft(): 145 | wiki = (await db_get_wiki_data())["craft"] 146 | return [ 147 | MessageSegment.image(x) 148 | for x in await asyncio.gather(*(db_get(y, resp_type=Rt.BYTES) for y in wiki)) 149 | ] 150 | 151 | 152 | async def db_wiki_furniture(): 153 | wiki = (await db_get_wiki_data())["furniture"] 154 | return [ 155 | MessageSegment.image(x) 156 | for x in await asyncio.gather(*(db_get(y, resp_type=Rt.BYTES) for y in wiki)) 157 | ] 158 | 159 | 160 | async def db_future( 161 | future_type: Literal["global", "chinese"], 162 | date: Optional[datetime.datetime] = None, 163 | num: int = 1, 164 | all_img: bool = False, 165 | ): 166 | data = (await db_get_wiki_data())[f"{future_type}_future"] 167 | img = cast(bytes, await db_get(data["img"], resp_type=Rt.BYTES)) 168 | 169 | if all_img: 170 | return MessageSegment.image(img) 171 | 172 | compare_date = date or datetime.datetime.now().astimezone() 173 | index = -1 174 | for i, v in enumerate(parts := data["parts"]): 175 | start, end = [ 176 | datetime.datetime.strptime(x, "%Y/%m/%d").astimezone() for x in v["date"] 177 | ] 178 | if start <= compare_date < end: 179 | index = i 180 | 181 | if not date: 182 | index += 1 183 | 184 | if index == -1: 185 | return "没有找到符合日期的部分" 186 | 187 | sliced_parts = parts[index : index + num] 188 | 189 | if (pl := len(sliced_parts)) < num: 190 | return f"抱歉,目前后面还没有这么长的前瞻列表……(目前后面还有 {pl} 个)" 191 | 192 | banner_start, banner_end = data["banner"] 193 | pos_start = sliced_parts[0]["part"][0] 194 | pos_end = sliced_parts[-1]["part"][1] 195 | 196 | img = BuildImage.open(BytesIO(img)) 197 | width = img.width 198 | banner = img.crop((0, banner_start, width, banner_end)) 199 | content = img.crop((0, pos_start, width, pos_end)) 200 | 201 | bg = ( 202 | BuildImage.new("RGB", (width, banner.height + content.height)) 203 | .paste(banner) 204 | .paste(content, (0, banner.height)) 205 | ) 206 | return MessageSegment.image(bg.save("PNG")) 207 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/data/gacha.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import random 4 | import time 5 | from dataclasses import dataclass 6 | from io import BytesIO 7 | from typing import Any, Dict, List, Optional, cast 8 | 9 | import anyio 10 | from nonebot import logger 11 | from nonebot.adapters.onebot.v11 import MessageSegment 12 | from nonebot.compat import model_dump 13 | from pil_utils import BuildImage, Text2Image 14 | from pydantic import BaseModel, Field 15 | 16 | from ..config import config 17 | from ..resource import ( 18 | CALENDER_BANNER_PATH, 19 | DATA_DIR, 20 | GACHA_BG_OLD_PATH, 21 | GACHA_BG_PATH, 22 | GACHA_CARD_BG_PATH, 23 | GACHA_CARD_MASK_PATH, 24 | GACHA_NEW_PATH, 25 | GACHA_PICKUP_PATH, 26 | GACHA_STAR_PATH, 27 | GACHA_STU_ERR_PATH, 28 | ) 29 | from ..util import RespType, read_image, split_list 30 | from .schaledb import schale_get, schale_get_stu_dict 31 | 32 | GACHA_DATA_PATH = DATA_DIR / "gacha.json" 33 | if not GACHA_DATA_PATH.exists(): 34 | GACHA_DATA_PATH.write_text("{}") 35 | 36 | 37 | COOL_DOWN_DICT: Dict[str, float] = {} 38 | 39 | 40 | class GachaData(BaseModel): 41 | collected: List[int] = Field(default_factory=list) 42 | 43 | 44 | @dataclass() 45 | class GachaStudent: 46 | id: int # noqa: A003 47 | name: str 48 | star: int 49 | new: bool 50 | pickup: bool 51 | count: int 52 | 53 | 54 | @dataclass() 55 | class RegularGachaInfo: 56 | student: GachaStudent 57 | counts: List[int] 58 | 59 | 60 | def get_gacha_cool_down(session_id: str) -> int: 61 | now = time.time() 62 | 63 | if last := COOL_DOWN_DICT.get(session_id): 64 | remain = config.ba_gacha_cool_down - round(now - last) 65 | return max(remain, 0) 66 | 67 | return 0 68 | 69 | 70 | def set_gacha_cool_down(session_id: str): 71 | COOL_DOWN_DICT[session_id] = time.time() 72 | 73 | 74 | async def set_gacha_data(user_id: str, data: GachaData): 75 | path = anyio.Path(GACHA_DATA_PATH) 76 | j = json.loads(await path.read_text(encoding="u8")) 77 | j[user_id] = model_dump(data) 78 | await path.write_text(json.dumps(j), encoding="u8") 79 | 80 | 81 | async def get_gacha_data(user_id: str) -> GachaData: 82 | j = await anyio.Path(GACHA_DATA_PATH).read_text(encoding="u8") 83 | data: Dict[str, Any] = json.loads(j) 84 | if not (user_data := data.get(user_id)): 85 | return GachaData() 86 | return GachaData(**user_data) 87 | 88 | 89 | def format_count(count: int) -> str: 90 | trans_dict = {1: "st", 2: "nd", 3: "rd"} 91 | default_suffix = "th" 92 | 93 | if count % 100 in (10 + x for x in trans_dict): 94 | return f"{count}{default_suffix}" 95 | 96 | return f"{count}{trans_dict.get(count % 10, default_suffix)}" 97 | 98 | 99 | async def get_student_icon(student_id: int) -> BuildImage: 100 | try: 101 | stu_img = await schale_get( 102 | f"images/student/icon/{student_id}.webp", 103 | resp_type=RespType.BYTES, 104 | ) 105 | stu_img = BuildImage.open(BytesIO(stu_img)) 106 | except Exception: 107 | logger.exception(f"学生数据获取失败 {student_id}") 108 | stu_img = await read_image(GACHA_STU_ERR_PATH) 109 | 110 | return stu_img.resize((64, 64), keep_ratio=True).circle() 111 | 112 | 113 | async def get_student_card( 114 | student: GachaStudent, 115 | draw_count: bool = True, 116 | ) -> BuildImage: 117 | bg = await read_image(GACHA_CARD_BG_PATH) 118 | 119 | try: 120 | stu_img = await schale_get( 121 | f"images/student/collection/{student.id}.webp", 122 | resp_type=RespType.BYTES, 123 | ) 124 | stu_img = BuildImage.open(BytesIO(stu_img)) 125 | except Exception: 126 | logger.exception(f"学生数据获取失败 {student.id}") 127 | stu_img = await read_image(GACHA_STU_ERR_PATH) 128 | 129 | mask = (await read_image(GACHA_CARD_MASK_PATH)).convert("RGBA") 130 | card_img = BuildImage.new("RGBA", mask.size, (0, 0, 0, 0)) 131 | card_img.image.paste( 132 | stu_img.resize(mask.size, keep_ratio=True).image, 133 | mask=mask.image, 134 | ) 135 | 136 | bg = bg.paste(card_img, (26, 13), alpha=True) 137 | 138 | star_img = await read_image(GACHA_STAR_PATH) 139 | star_x_offset = int(26 + (159 - 30 * student.star) / 2) 140 | star_y_offset = 198 141 | for i in range(student.star): 142 | bg = bg.paste( 143 | star_img, 144 | (star_x_offset + i * 30, star_y_offset), 145 | alpha=True, 146 | ) 147 | 148 | font_x_offset = 45 149 | font_y_offset = 2 150 | 151 | if student.new: 152 | bg = bg.paste( 153 | await read_image(GACHA_NEW_PATH), 154 | (font_x_offset, font_y_offset), 155 | alpha=True, 156 | ) 157 | font_x_offset -= 2 158 | font_y_offset += 29 159 | 160 | if student.pickup: 161 | font_x_offset -= 4 162 | font_y_offset -= 4 163 | bg = bg.paste( 164 | await read_image(GACHA_PICKUP_PATH), 165 | (font_x_offset, font_y_offset), 166 | alpha=True, 167 | ) 168 | 169 | if draw_count: 170 | bg.draw_text( 171 | (29, 195), 172 | format_count(student.count), 173 | fontsize=16, 174 | style="italic", 175 | fill="white", 176 | ) 177 | 178 | return bg 179 | 180 | 181 | def collect_regular_info( 182 | regular_students: List[GachaStudent], 183 | ) -> List[RegularGachaInfo]: 184 | students: Dict[int, List[GachaStudent]] = {} 185 | for stu in regular_students: 186 | if stu.id not in students: 187 | students[stu.id] = [] 188 | students[stu.id].append(stu) 189 | 190 | return [ 191 | RegularGachaInfo(student=stu[0], counts=[x.count for x in stu]) 192 | for stu in students.values() 193 | ] 194 | 195 | 196 | async def draw_summary_gacha_img(result: List[GachaStudent]) -> BuildImage: 197 | important_result: List[GachaStudent] = [] 198 | regular_result: List[GachaStudent] = [] 199 | for res in result: 200 | if res.star >= 3: 201 | important_result.append(res) 202 | else: 203 | regular_result.append(res) 204 | 205 | regular_collected = collect_regular_info(regular_result) 206 | 207 | regular_collected.sort(key=lambda x: len(x.counts), reverse=True) 208 | regular_collected.sort(key=lambda x: x.student.new, reverse=True) 209 | regular_collected.sort(key=lambda x: x.student.star, reverse=True) 210 | regular_collected.sort(key=lambda x: x.student.pickup, reverse=True) 211 | 212 | important_pics = list( 213 | split_list( 214 | await asyncio.gather(*[get_student_card(x) for x in important_result]), 215 | 5, 216 | ), 217 | ) 218 | regular_icons: List[BuildImage] = await asyncio.gather( 219 | *[get_student_icon(x.student.id) for x in regular_collected], 220 | ) 221 | 222 | padding = 50 223 | part_width = 256 * 5 + padding * 2 224 | img_width = part_width + padding * 2 225 | 226 | def gen_important() -> BuildImage: 227 | img_size = 256 228 | title_txt = Text2Image.from_text( 229 | f"3★学生(共计{len(important_result)}次)", 230 | 80, 231 | weight="bold", 232 | fill="black", 233 | ) 234 | title_height = title_txt.height 235 | 236 | if not important_pics: 237 | empty_txt = Text2Image.from_text( 238 | "诶~老师怎么一个3★学生都没招募到?真是杂鱼呢~", 239 | 40, 240 | fill="black", 241 | ) 242 | bg = BuildImage.new( 243 | "RGBA", 244 | ( 245 | part_width, 246 | title_height + padding + empty_txt.height + padding * 2, 247 | ), 248 | (255, 255, 255, 70), 249 | ) 250 | title_txt.draw_on_image(bg.image, (padding, padding)) 251 | empty_txt.draw_on_image(bg.image, (padding, padding * 2 + title_height)) 252 | return bg 253 | 254 | # else: 255 | bg = BuildImage.new( 256 | "RGBA", 257 | ( 258 | part_width, 259 | (img_size * len(important_pics)) + title_height + padding * 3, 260 | ), 261 | (255, 255, 255, 70), 262 | ) 263 | title_txt.draw_on_image(bg.image, (padding, padding)) 264 | for i, row in enumerate(important_pics): 265 | for j, pic in enumerate(row): 266 | bg = bg.paste( 267 | pic, 268 | ( 269 | padding + j * pic.width, 270 | padding * 2 + title_height + i * img_size, 271 | ), 272 | alpha=True, 273 | ) 274 | return bg 275 | 276 | def gen_regular() -> BuildImage: 277 | gap_size = 10 278 | img_size = 64 279 | 280 | title_txt = Text2Image.from_text( 281 | f"3★以下学生(共计{len(regular_result)}次)", 282 | 80, 283 | weight="bold", 284 | fill="black", 285 | ) 286 | title_height = title_txt.height 287 | 288 | bg = BuildImage.new( 289 | "RGBA", 290 | ( 291 | part_width, 292 | ( 293 | ((img_size + gap_size) * len(regular_collected) - gap_size) 294 | + title_height 295 | + padding * 3 296 | ), 297 | ), 298 | (255, 255, 255, 70), 299 | ) 300 | title_txt.draw_on_image(bg.image, (padding, padding)) 301 | 302 | for i, icon in enumerate(regular_icons): 303 | bg = bg.paste( 304 | icon, 305 | (padding, padding * 2 + title_height + i * (img_size + gap_size)), 306 | ) 307 | for i, info in enumerate(regular_collected): 308 | student = info.student 309 | info_tip = f"{student.name} ({student.star}★) x{len(info.counts)}" 310 | if student.pickup: 311 | info_tip = f"[UP!]{info_tip}" 312 | if student.new: 313 | info_tip = f"[New]{info_tip}" 314 | info_txt = Text2Image.from_text(info_tip, 40, fill="black") 315 | info_txt.draw_on_image( 316 | bg.image, 317 | ( 318 | padding + img_size + gap_size, 319 | ( 320 | padding * 2 321 | + title_height 322 | + i * (img_size + gap_size) 323 | + (img_size - info_txt.height) 324 | ), 325 | ), 326 | ) 327 | 328 | return bg 329 | 330 | important_img = gen_important() 331 | regular_img = gen_regular() 332 | 333 | banner_h = 150 334 | return ( 335 | (await read_image(GACHA_BG_PATH)) 336 | .resize( 337 | ( 338 | img_width, 339 | banner_h + important_img.height + regular_img.height + padding * 3, 340 | ), 341 | keep_ratio=True, 342 | ) 343 | .paste((await read_image(CALENDER_BANNER_PATH)).resize((img_width, banner_h))) 344 | .draw_text( 345 | (50, 0, 1480, 150), 346 | "招募总结", 347 | max_fontsize=100, 348 | weight="bold", 349 | fill="#ffffff", 350 | halign="left", 351 | ) 352 | .paste( 353 | important_img.circle_corner(10), 354 | (padding, banner_h + padding), 355 | alpha=True, 356 | ) 357 | .paste( 358 | regular_img.circle_corner(10), 359 | (padding, banner_h + padding + important_img.height + padding), 360 | alpha=True, 361 | ) 362 | ) 363 | 364 | 365 | async def draw_classic_gacha_img(students: List[GachaStudent]) -> BuildImage: 366 | line_limit = 5 367 | card_w, card_h = (256, 256) 368 | 369 | org_stu_cards = await asyncio.gather( 370 | *(get_student_card(student, draw_count=False) for student in students), 371 | ) 372 | stu_cards = list(split_list(org_stu_cards, line_limit)) 373 | bg = await read_image(GACHA_BG_OLD_PATH) 374 | 375 | x_gap = 10 376 | y_gap = 80 377 | y_offset = int((bg.height - (len(stu_cards) * (y_gap + card_h) - y_gap)) / 2) 378 | for line in stu_cards: 379 | x_offset = int((bg.width - (len(line) * (x_gap + card_w) - x_gap)) / 2) 380 | for card in line: 381 | bg = bg.paste(card, (x_offset, y_offset), alpha=True) 382 | x_offset += card_w + x_gap 383 | y_offset += card_h + y_gap 384 | 385 | return bg.draw_text( 386 | (1678, 841, 1888, 885), 387 | "BAWiki", 388 | max_fontsize=30, 389 | weight="bold", 390 | fill=(36, 90, 126), 391 | ).draw_text( 392 | (1643, 885, 1890, 935), 393 | "经典抽卡样式", 394 | max_fontsize=30, 395 | weight="bold", 396 | fill=(255, 255, 255), 397 | ) 398 | 399 | 400 | async def do_gacha( 401 | user_id: str, 402 | times: int, 403 | gacha_data_json: dict, 404 | up_pool: Optional[List[int]] = None, 405 | ): 406 | # 屎山代码 别骂了别骂了 407 | # 如果有大佬指点指点怎么优化或者愿意发个PR就真的太感激了 408 | 409 | if not up_pool: 410 | up_pool = [] 411 | 412 | stu_li = await schale_get_stu_dict("Id") 413 | up_3_li, up_2_li = [ 414 | [x for x in up_pool if x in stu_li and stu_li[x]["StarGrade"] == y] 415 | for y in [3, 2] 416 | ] 417 | 418 | base_char: dict = gacha_data_json["base"] 419 | for up in up_pool: 420 | for li in cast(List[List[int]], base_char.values()): 421 | if up in li: 422 | li.remove(up) 423 | 424 | star_3_base, star_2_base, star_1_base = [base_char[x] for x in ["3", "2", "1"]] 425 | star_3_chance, star_2_chance, star_1_chance = [ 426 | x["chance"] for x in [star_3_base, star_2_base, star_1_base] 427 | ] 428 | 429 | up_3_chance = 0 430 | up_2_chance = 0 431 | if up_3_li: 432 | up_3_chance = gacha_data_json["up"]["3"]["chance"] 433 | star_3_chance -= up_3_chance 434 | if up_2_li: 435 | up_2_chance = gacha_data_json["up"]["2"]["chance"] 436 | star_2_chance -= up_2_chance 437 | 438 | gacha_data = await get_gacha_data(user_id) 439 | gacha_result: List[GachaStudent] = [] 440 | 441 | for i in range(1, times + 1): 442 | is_10th = i % 10 == 0 443 | now_2_chance = star_2_chance + star_1_chance if is_10th else star_2_chance 444 | pool_and_weight = [ 445 | (up_3_li, up_3_chance), 446 | (up_2_li, up_2_chance), 447 | (star_3_base["char"], star_3_chance), 448 | (star_2_base["char"], now_2_chance), 449 | ] 450 | if not is_10th: 451 | pool_and_weight.append((star_1_base["char"], star_1_chance)) 452 | 453 | pool_and_weight = [x for x in pool_and_weight if x[0]] 454 | pool = [x[0] for x in pool_and_weight] 455 | weight = [x[1] for x in pool_and_weight] 456 | 457 | await asyncio.sleep(0.05) 458 | random.seed() 459 | char = random.choice(random.choices(pool, weights=weight, k=1)[0]) 460 | 461 | is_3star_pickup = char in up_3_li 462 | is_pickup = is_3star_pickup or (char in up_2_li) 463 | is_new = char not in gacha_data.collected 464 | char_info = stu_li[char] 465 | gacha_result.append( 466 | GachaStudent( 467 | id=char, 468 | name=char_info["Name"], 469 | star=char_info["StarGrade"], 470 | pickup=is_pickup, 471 | new=is_new, 472 | count=i, 473 | ), 474 | ) 475 | 476 | if is_new: 477 | gacha_data.collected.append(char) 478 | 479 | await set_gacha_data(user_id, gacha_data) 480 | return gacha_result 481 | 482 | 483 | async def gacha( 484 | user_id: str, 485 | times: int, 486 | gacha_data_json: dict, 487 | up_pool: Optional[List[int]] = None, 488 | ) -> MessageSegment: 489 | result = await do_gacha(user_id, times, gacha_data_json, up_pool) 490 | img = ( 491 | (await draw_summary_gacha_img(result)) 492 | if (times > 10) or (config.ba_disable_classic_gacha) 493 | else (await draw_classic_gacha_img(result)) 494 | ) 495 | img_bytes = img.convert("RGB").save_jpg() 496 | return MessageSegment.image(img_bytes) 497 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/data/gamekee.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import itertools 3 | import operator 4 | import re 5 | import time 6 | import urllib.parse 7 | from dataclasses import dataclass 8 | from datetime import datetime 9 | from io import BytesIO 10 | from typing import Any, Dict, List, Literal, Optional, cast 11 | from typing_extensions import Unpack 12 | 13 | from bs4 import BeautifulSoup, PageElement, ResultSet, Tag 14 | from nonebot import logger 15 | from nonebot_plugin_htmlrender import get_new_page 16 | from PIL import Image 17 | from PIL.Image import Resampling 18 | from pil_utils import BuildImage, text2image 19 | from playwright.async_api import Page 20 | 21 | from ..config import config 22 | from ..resource import CALENDER_BANNER_PATH, GAMEKEE_UTIL_JS_PATH, GRADIENT_BG_PATH 23 | from ..util import ( 24 | AsyncReqKwargs, 25 | RespType as Rt, 26 | async_req, 27 | i2b, 28 | parse_time_delta, 29 | read_image, 30 | split_pic, 31 | ) 32 | 33 | 34 | async def game_kee_request(url: str, **kwargs: Unpack[AsyncReqKwargs]) -> Any: 35 | kwargs["base_urls"] = config.ba_gamekee_url 36 | 37 | headers = kwargs.get("headers") or {} 38 | headers.update({"Game-Id": "829", "Game-Alias": "ba"}) 39 | kwargs["headers"] = headers 40 | 41 | resp = await async_req(url, **kwargs) 42 | if resp["code"] != 0: 43 | raise ValueError(resp["msg"]) 44 | return resp["data"] 45 | 46 | 47 | async def game_kee_get_calender() -> List[dict]: 48 | ret = cast(list, await game_kee_request("v1/wiki/index")) 49 | 50 | module = next( 51 | (x for x in ret if x["module"]["id"] == 12), 52 | None, 53 | ) 54 | if not module: 55 | return [] 56 | 57 | li: list = module["list"] 58 | 59 | now = time.time() 60 | li = [x for x in li if (now < x["end_at"])] 61 | 62 | li.sort(key=lambda x: x["begin_at"] if now < x["begin_at"] else x["end_at"]) 63 | li.sort(key=lambda x: now < x["begin_at"]) 64 | li.sort(key=operator.itemgetter("importance"), reverse=True) 65 | return li 66 | 67 | 68 | async def game_kee_get_stu_li() -> Dict[str, dict]: 69 | ret = cast(dict, await game_kee_request("v1/wiki/entry")) 70 | 71 | entry_stu = next( 72 | (x for x in ret["entry_list"] if x["id"] == 23941), 73 | None, 74 | ) 75 | if not entry_stu: 76 | return {} 77 | 78 | entry_stu_all = next( 79 | (x for x in entry_stu["child"] if x["id"] == 49443), 80 | None, 81 | ) 82 | if not entry_stu_all: 83 | return {} 84 | 85 | return {x["name"]: x for x in entry_stu_all["child"]} 86 | 87 | 88 | async def game_kee_get_stu_cid_li() -> Dict[str, int]: 89 | return {x: y["content_id"] for x, y in (await game_kee_get_stu_li()).items()} 90 | 91 | 92 | def game_kee_page_url(sid: int) -> str: 93 | return urllib.parse.urljoin(config.ba_gamekee_url, f"{sid}.html") 94 | 95 | 96 | async def game_kee_get_page(url: str) -> List[BytesIO]: 97 | async with cast(Page, get_new_page()) as page: 98 | await page.goto(url, timeout=config.ba_screenshot_timeout * 1000) 99 | 100 | await page.evaluate(GAMEKEE_UTIL_JS_PATH.read_text(encoding="u8")) 101 | 102 | # 太长了 103 | # 展开折叠的语音 104 | # folds = await page.query_selector_all("div.fold-table-btn") 105 | # for i in folds: 106 | # with contextlib.suppress(Exception): 107 | # await i.click() 108 | 109 | element = await page.query_selector("div.wiki-detail-body") 110 | assert element 111 | pic_bytes = await element.screenshot(type="jpeg") 112 | 113 | pic = Image.open(BytesIO(pic_bytes)) 114 | return list(map(i2b, split_pic(pic))) 115 | 116 | 117 | async def game_kee_calender( 118 | servers: Optional[List[Literal["Jp", "Global", "Cn"]]] = None, 119 | ) -> Optional[List[BytesIO]]: 120 | ret = await game_kee_get_calender() 121 | 122 | if ret and servers: 123 | server_name_map = { 124 | "Jp": "日服", 125 | "Global": "国际服", 126 | "Cn": "国服", 127 | } 128 | server_names = [server_name_map[x] for x in servers] 129 | ret = [x for x in ret if x["pub_area"] in server_names] 130 | 131 | if not ret: 132 | return None 133 | 134 | return await game_kee_get_calender_page(ret) 135 | 136 | 137 | def split_images( 138 | images: List[BuildImage], 139 | max_height: int, 140 | padding: int, 141 | ) -> List[List[BuildImage]]: 142 | ret = [] 143 | cur = [] 144 | height = 0 145 | for i in images: 146 | if height + i.height > max_height: 147 | ret.append(cur) 148 | cur = [] 149 | height = 0 150 | cur.append(i) 151 | height += i.height + padding 152 | if cur: 153 | ret.append(cur) 154 | return ret 155 | 156 | 157 | async def game_kee_get_calender_page( 158 | ret: List[Dict], 159 | has_pic: bool = True, 160 | ) -> List[BytesIO]: 161 | now = datetime.now().astimezone() 162 | 163 | async def draw(it: dict) -> BuildImage: 164 | ev_pic = None 165 | if has_pic and (url := it.get("picture")): 166 | try: 167 | pic_bytes = await async_req(f"https:{url}", resp_type=Rt.BYTES) 168 | ev_pic = BuildImage.open(BytesIO(pic_bytes)) 169 | except Exception: 170 | logger.exception("下载日程表图片失败") 171 | 172 | begin = datetime.fromtimestamp(it["begin_at"]).astimezone() 173 | end = datetime.fromtimestamp(it["end_at"]).astimezone() 174 | started = begin <= now 175 | time_remain = (end if started else begin) - now 176 | dd, hh, mm, ss = parse_time_delta(time_remain) 177 | 178 | # logger.debug(f'{it["title"]} | {started} | {time_remain}') 179 | 180 | title_p = text2image( 181 | f'[b]{it["title"]}[/b]', 182 | "#ffffff00", 183 | max_width=1290, 184 | fontsize=65, 185 | ) 186 | time_p = text2image( 187 | f"{begin} ~ {end}", 188 | "#ffffff00", 189 | max_width=1290, 190 | fontsize=40, 191 | ) 192 | desc_p = ( 193 | text2image( 194 | desc.replace("
", ""), 195 | "#ffffff00", 196 | max_width=1290, 197 | fontsize=40, 198 | ) 199 | if (desc := it["description"]) 200 | else None 201 | ) 202 | remain_p = text2image( 203 | f"剩余 [color=#fc6475]{dd}[/color] 天 [color=#fc6475]{hh}[/color] 时 " 204 | f"[color=#fc6475]{mm}[/color] 分 [color=#fc6475]{ss}[/color] 秒" 205 | f'{"结束" if started else "开始"}', 206 | "#ffffff00", 207 | max_width=1290, 208 | fontsize=50, 209 | ) 210 | 211 | h = ( 212 | 100 213 | + (title_p.height + 25) 214 | + (time_p.height + 25) 215 | + (ev_pic.height + 25 if ev_pic else 0) 216 | + (desc_p.height + 25 if desc_p else 0) 217 | + remain_p.height 218 | ) 219 | img = BuildImage.new("RGBA", (1400, h), (255, 255, 255, 70)).draw_rectangle( 220 | (0, 0, 10, h), 221 | "#fc6475" if it["importance"] else "#4acf75", 222 | ) 223 | 224 | if not started: 225 | img.draw_rectangle((1250, 0, 1400, 60), "gray") 226 | img.draw_text((1250, 0, 1400, 60), "未开始", max_fontsize=50, fill="white") 227 | 228 | ii = 50 229 | img.paste(title_p, (60, ii), alpha=True) 230 | ii += title_p.height + 25 231 | img.paste(time_p, (60, ii), alpha=True) 232 | ii += time_p.height + 25 233 | if ev_pic: 234 | img.paste(ev_pic.resize_width(1290).circle_corner(15), (60, ii), alpha=True) 235 | ii += ev_pic.height + 25 236 | if desc_p: 237 | img.paste(desc_p, (60, ii), alpha=True) 238 | ii += desc_p.height + 25 239 | img.paste(remain_p, (60, ii), alpha=True) 240 | return img 241 | 242 | async def draw_list(li: List[BuildImage], title: str) -> BuildImage: 243 | bg_w = 1500 244 | bg_h = 200 + sum(x.height + 50 for x in li) 245 | bg = ( 246 | BuildImage.new("RGBA", (bg_w, bg_h)) 247 | .paste((await read_image(CALENDER_BANNER_PATH)).resize((1500, 150))) 248 | .draw_text( 249 | (50, 0, 1480, 150), 250 | title, 251 | max_fontsize=100, 252 | weight="bold", 253 | fill="#ffffff", 254 | halign="left", 255 | ) 256 | .paste( 257 | (await read_image(GRADIENT_BG_PATH)).resize( 258 | (1500, bg_h - 150), 259 | resample=Resampling.NEAREST, 260 | ), 261 | (0, 150), 262 | ) 263 | ) 264 | 265 | index = 200 266 | for p in li: 267 | bg.paste(p.circle_corner(10), (50, index), alpha=True) 268 | index += p.height + 50 269 | return bg 270 | 271 | important_data = [] 272 | common_data = [] 273 | for data in ret: 274 | (important_data if data["importance"] else common_data).append(data) 275 | 276 | important_pics, common_pics = await asyncio.gather( 277 | asyncio.gather(*(draw(x) for x in important_data)), 278 | asyncio.gather(*(draw(x) for x in common_data)), 279 | ) 280 | 281 | max_height = 6000 282 | if not common_pics: 283 | pics = [important_pics] 284 | else: 285 | chain = itertools.chain(important_pics, common_pics) 286 | pics: List[List[BuildImage]] = ( 287 | [list(chain)] 288 | if sum(x.height + 50 for x in chain) <= max_height + 50 289 | else [important_pics, *split_images(common_pics, max_height, 50)] 290 | ) 291 | 292 | title_prefix = "GameKee丨活动日程" 293 | if len(pics) == 1: 294 | images = await asyncio.gather(*(draw_list(pics[0], title_prefix))) 295 | else: 296 | if len(pics[-1]) < 3: 297 | extra = pics.pop() 298 | pics[-1].extend(extra) 299 | images = await asyncio.gather( 300 | *(draw_list(x, f"{title_prefix}丨P{i}") for i, x in enumerate(pics, 1)), 301 | ) 302 | 303 | return [x.convert("RGB").save_jpg() for x in images] 304 | 305 | 306 | async def game_kee_grab_l2d(cid: int) -> List[str]: 307 | ret: dict = await game_kee_request(f"v1/content/detail/{cid}") 308 | content: str = ret["content"] 309 | 310 | soup = BeautifulSoup(content, "lxml") 311 | 312 | l2d_nav_title = soup.find("div", class_="input-wrapper", string="Live2D") 313 | assert l2d_nav_title 314 | assert l2d_nav_title.parent 315 | data_index = l2d_nav_title.parent["data-index"] 316 | 317 | assert l2d_nav_title.parent.parent 318 | slide_contents = next( 319 | (x for x in l2d_nav_title.parent.parent.next_siblings if isinstance(x, Tag)), 320 | None, 321 | ) 322 | assert slide_contents 323 | 324 | l2d_content = slide_contents.find("div", attrs={"data-index": data_index}) 325 | assert isinstance(l2d_content, Tag) 326 | images = l2d_content.select(".div-img > img") 327 | 328 | image_urls = [x["data-real"] for x in images] 329 | return [f"https:{x}" for x in image_urls] 330 | 331 | 332 | @dataclass() 333 | class GameKeeVoice: 334 | title: str 335 | jp: str 336 | cn: str 337 | url: str 338 | 339 | 340 | def parse_voice_elem(elem: Tag) -> GameKeeVoice: 341 | url: str = cast(str, elem["src"]) 342 | if not url.startswith("http"): 343 | url = f"https:{url}" 344 | 345 | tr1: Tag = elem.parent.parent.parent.parent # type: ignore 346 | tds: ResultSet[Tag] = tr1.find_all("td") 347 | title = tds[0].text.strip() 348 | jp = "\n".join(tds[2].stripped_strings) 349 | 350 | tr2 = tr1.next_sibling 351 | cn = "\n".join(tr2.stripped_strings) # type: ignore 352 | return GameKeeVoice(title, jp, cn, url) 353 | 354 | 355 | def merge_voice_dialogue(voices: List[List[GameKeeVoice]]) -> List[List[GameKeeVoice]]: 356 | main_voices = voices[0] 357 | other_voice_entries = voices[1:] 358 | 359 | for voice_entry in other_voice_entries: 360 | for voice in (x for x in voice_entry if (not x.jp) or (not x.cn)): 361 | corresponding_voice = next( 362 | (x for x in main_voices if x.title == voice.title), 363 | None, 364 | ) 365 | if not corresponding_voice: 366 | continue 367 | 368 | if not voice.jp: 369 | voice.jp = corresponding_voice.jp 370 | if not voice.cn: 371 | voice.cn = corresponding_voice.cn 372 | 373 | return voices 374 | 375 | 376 | async def game_kee_get_voice(cid: int, is_chinese: bool = False) -> List[GameKeeVoice]: 377 | wiki_html = ( 378 | cast( 379 | dict, 380 | await game_kee_request(f"v1/content/detail/{cid}"), 381 | ) 382 | )["content"] 383 | bs = BeautifulSoup(wiki_html, "lxml") 384 | 385 | multi_lang_voices = [ 386 | [parse_voice_elem(x) for x in audios] 387 | for table_fathers in bs.select(".slide-item") 388 | if 389 | ( 390 | # 选择 tables 391 | (tables := table_fathers.select(".table-overflow > .mould-table")) 392 | # 检查 tables 中是否有语音,如果有,就取出语音到变量 aus -> audios 393 | and ( 394 | audios := next( 395 | (aus for child in tables if (aus := child.find_all("audio"))), 396 | None, # type: ignore 397 | ) 398 | ) 399 | ) 400 | ] 401 | if multi_lang_voices: 402 | return merge_voice_dialogue(multi_lang_voices)[1 if is_chinese else 0] 403 | 404 | # 没有中配 405 | if is_chinese: 406 | return [] 407 | return next( 408 | [parse_voice_elem(x) for x in a] 409 | for x in bs.select(".mould-table") 410 | if (a := x.find_all("audio")) 411 | ) 412 | 413 | 414 | async def get_level_list() -> Dict[str, int]: 415 | entry = cast(dict, await game_kee_request("v1/wiki/entry")) 416 | entry_list: List[Dict] = entry["entry_list"] 417 | guide_entry: List[Dict] = next(x["child"] for x in entry_list if x["id"] == 50284) 418 | levels = itertools.chain( 419 | *(x["child"] for x in guide_entry if cast(str, x["name"]).endswith("章")), 420 | ) 421 | return { 422 | n.upper(): x["content_id"] 423 | for x in levels 424 | if re.match(r"^(H)?(TR|\d+)-(\d+)$", (n := cast(str, x["name"]))) 425 | } 426 | 427 | 428 | async def extract_content_pic(cid: int) -> List[str]: 429 | wiki_html = ( 430 | cast( 431 | dict, 432 | await game_kee_request(f"v1/content/detail/{cid}"), 433 | ) 434 | )["content"] 435 | bs = BeautifulSoup(wiki_html, "lxml") 436 | img_elem = bs.find_all("img") 437 | img_urls = cast(List[str], [x["src"] for x in img_elem]) 438 | return [f"https:{x}" if x.startswith("//") else x for x in img_urls] 439 | 440 | 441 | @dataclass() 442 | class MangaMetadata: 443 | category: str 444 | name: str 445 | cid: int 446 | 447 | 448 | @dataclass() 449 | class MangaContent: 450 | title: str 451 | content: str 452 | images: List[str] 453 | 454 | 455 | async def get_manga_list() -> List[MangaMetadata]: 456 | entry = cast(dict, await game_kee_request("v1/wiki/entry")) 457 | entry_list: List[Dict] = entry["entry_list"] 458 | guide_entry: List[Dict] = next(x["child"] for x in entry_list if x["id"] == 51508) 459 | 460 | manga_list: List[MangaMetadata] = [] 461 | 462 | current_category = "ぶるーあーかいぶっ!" 463 | for entry in guide_entry: 464 | category = entry["name"] 465 | if category.startswith("【"): 466 | current_category = category[1 : category.find("】")] 467 | manga_list.extend( 468 | MangaMetadata( 469 | category=current_category, 470 | name=x["name"], 471 | cid=x["content_id"], 472 | ) 473 | for x in entry["child"] 474 | ) 475 | 476 | return manga_list 477 | 478 | 479 | def tags_to_str(tag: PageElement) -> str: 480 | def process(elem: PageElement) -> str: 481 | if c := getattr(elem, "contents", None): 482 | return "".join([s for x in c if (s := process(x))]) 483 | text = elem if isinstance(elem, str) else elem.text 484 | if s := text.strip().replace("\u200b", ""): 485 | return s 486 | if hasattr(elem, "name") and (elem.name == "img" or elem.name == "br"): # type: ignore 487 | return "\n" 488 | return "" 489 | 490 | text = process(tag).strip() 491 | if not text: 492 | return text 493 | 494 | lines = text.splitlines() 495 | last_line = lines[-1] 496 | if last_line.strip().endswith(">"): 497 | lines.pop() 498 | return "\n".join(lines).strip() 499 | 500 | 501 | async def get_manga_content(cid: int) -> MangaContent: 502 | article = cast(dict, await game_kee_request(f"v1/content/detail/{cid}")) 503 | soup = BeautifulSoup(article["content"], "lxml") 504 | 505 | content = tags_to_str(soup).strip() 506 | if "汉化:" in content: 507 | content = content.replace("汉化:", "\n汉化:").replace("\n)", ")") 508 | 509 | return MangaContent( 510 | title=article["title"], 511 | content=content, 512 | images=[ 513 | f"https:{src}" 514 | for x in soup.find_all("img") 515 | if (not (src := x["src"]).endswith(".gif")) and "gamekee" in src 516 | ], 517 | ) 518 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/data/logo_generate.py: -------------------------------------------------------------------------------- 1 | from ..resource import BA_LOGO_JS_PATH 2 | from .playwright import get_routed_page 3 | 4 | 5 | async def get_logo(text_l: str, text_r: str, transparent_bg: bool = True) -> str: 6 | async with get_routed_page() as page: 7 | return await page.evaluate( 8 | BA_LOGO_JS_PATH.read_text(encoding="u8"), 9 | [text_l, text_r, transparent_bg], 10 | ) 11 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/data/playwright.py: -------------------------------------------------------------------------------- 1 | import mimetypes 2 | import re 3 | from contextlib import asynccontextmanager 4 | from dataclasses import dataclass 5 | from typing import ( 6 | AsyncIterator, 7 | Awaitable, 8 | Callable, 9 | List, 10 | Literal, 11 | Optional, 12 | TypeVar, 13 | Union, 14 | ) 15 | 16 | import anyio 17 | import jinja2 18 | from nonebot import logger 19 | from nonebot_plugin_htmlrender import get_new_page 20 | from pil_utils.fonts import Font, get_proper_font 21 | from playwright.async_api import Page, Request, Route 22 | from yarl import URL 23 | 24 | from ..resource import EMPTY_HTML_PATH, RES_DIR 25 | 26 | PWRouter = Callable[[Route, Request], Awaitable[None]] 27 | BAWikiRouterFunc = Callable[..., Awaitable[None]] 28 | TRF = TypeVar("TRF", bound=BAWikiRouterFunc) 29 | 30 | RES_ROUTE_URL = "https://bawiki.res" 31 | RES_TYPE_FONT = "font" 32 | 33 | 34 | registered_routers: List["BAWikiRouter"] = [] 35 | 36 | 37 | @dataclass 38 | class BAWikiRouter: 39 | pattern: Union[str, re.Pattern] 40 | func: BAWikiRouterFunc 41 | priority: int 42 | 43 | 44 | def bawiki_router( 45 | pattern: Union[str, re.Pattern], 46 | flags: Optional[re.RegexFlag] = None, 47 | priority: int = 0, 48 | ): 49 | if not isinstance(pattern, re.Pattern): 50 | pattern = re.compile(pattern, flags=flags or 0) 51 | 52 | def wrapper(func: TRF) -> TRF: 53 | registered_routers.append(BAWikiRouter(pattern, func, priority)) 54 | # 低 priority 的 BAWikiRouter 应最先运行, 55 | # 因为 playwright 后 route 的先运行,所以要反过来排序 56 | registered_routers.sort(key=lambda r: r.priority, reverse=True) 57 | logger.debug(f"Registered router: {pattern=}, {priority=}") 58 | return func 59 | 60 | return wrapper 61 | 62 | 63 | async def route_page(page: Page, router: BAWikiRouter): 64 | async def wrapped(route: Route, request: Request): 65 | url = URL(request.url) 66 | logger.debug(f"Requested routed URL: {url.human_repr()}") 67 | match = re.search(router.pattern, request.url) 68 | assert match 69 | return await router.func(match=match, url=url, route=route, request=request) 70 | 71 | await page.route(router.pattern, wrapped) 72 | 73 | 74 | @asynccontextmanager 75 | async def get_routed_page(**kwargs) -> AsyncIterator[Page]: 76 | async with get_new_page(**kwargs) as page: 77 | for router in registered_routers: 78 | await route_page(page, router) 79 | await page.goto(RES_ROUTE_URL) 80 | yield page 81 | 82 | 83 | async def render_html( 84 | html: str, 85 | selector: str = "body", 86 | img_format: Literal["png", "jpeg"] = "jpeg", 87 | **page_kwargs, 88 | ) -> bytes: 89 | from pathlib import Path 90 | 91 | Path("debug.html").write_text(html, encoding="u8") 92 | 93 | async with get_routed_page(**page_kwargs) as page: 94 | await page.set_content(html) 95 | elem = await page.query_selector(selector) 96 | assert elem 97 | return await elem.screenshot(type=img_format) 98 | 99 | 100 | def get_template_renderer( 101 | template: jinja2.Template, 102 | selector: str = "body", 103 | img_format: Literal["png", "jpeg"] = "jpeg", 104 | **page_kwargs, 105 | ) -> Callable[..., Awaitable[bytes]]: 106 | async def renderer(**template_kwargs) -> bytes: 107 | html = await template.render_async(**template_kwargs) 108 | return await render_html(html, selector, img_format, **page_kwargs) 109 | 110 | return renderer 111 | 112 | 113 | # region routers 114 | 115 | 116 | @bawiki_router(rf"^{RES_ROUTE_URL}/?$") 117 | async def _(route: Route, **_): 118 | html = EMPTY_HTML_PATH.read_text() 119 | return await route.fulfill(body=html, content_type="text/html") 120 | 121 | 122 | @bawiki_router(rf"^{RES_ROUTE_URL}/{RES_TYPE_FONT}/([^/]+?)/?$") 123 | async def _(url: URL, route: Route, **_): 124 | family = url.parts[-1] 125 | style = url.query.get("style", "normal") 126 | weight = url.query.get("weight", "normal") 127 | try: 128 | font = Font.find( 129 | family, 130 | style=style, 131 | weight=weight, 132 | fallback_to_default=False, 133 | ) 134 | except Exception: 135 | logger.info(f"Font `{family}` not found, use fallback font") 136 | font = get_proper_font("国", style=style, weight=weight) # type: ignore 137 | 138 | file_path = anyio.Path(font.path) 139 | mime = f"font/{file_path.suffix[1:]}" 140 | 141 | logger.debug(f"Resolved font `{family}`, file path: {file_path}") 142 | return await route.fulfill(body=await file_path.read_bytes(), content_type=mime) 143 | 144 | 145 | @bawiki_router(rf"^{RES_ROUTE_URL}/(.+)$", priority=99) 146 | async def _(url: URL, route: Route, **_): 147 | res_path = url.parts[1:] 148 | file_path = anyio.Path(RES_DIR.joinpath(*res_path)) 149 | 150 | if not await file_path.exists(): 151 | logger.debug(f"Resource `{res_path}` not found") 152 | return await route.abort() 153 | 154 | mime = mimetypes.guess_type(file_path)[0] 155 | logger.debug( 156 | f"Resolved resource `{'/'.join(res_path)}`, mimetype: {mime}, real path: {file_path}", 157 | ) 158 | return await route.fulfill( 159 | body=await file_path.read_bytes(), 160 | content_type=mime or "application/octet-stream", 161 | ) 162 | 163 | 164 | # endregion 165 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/data/shittim_chest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import shutil 3 | from base64 import b64encode 4 | from datetime import datetime 5 | from enum import Enum 6 | from io import BytesIO 7 | from typing import ( 8 | Any, 9 | AsyncIterable, 10 | Callable, 11 | Dict, 12 | Generic, 13 | Iterable, 14 | List, 15 | Optional, 16 | Protocol, 17 | Tuple, 18 | TypedDict, 19 | TypeVar, 20 | Union, 21 | ) 22 | from typing_extensions import Unpack 23 | from urllib.parse import urljoin 24 | 25 | import anyio 26 | import jinja2 27 | import matplotlib.dates as mdates 28 | import matplotlib.ticker as mticker 29 | import pytz 30 | from matplotlib import pyplot 31 | from matplotlib.axes import Axes 32 | from matplotlib.figure import Figure 33 | from nonebot import logger 34 | from nonebot.compat import PYDANTIC_V2, type_validate_python 35 | from nonebot_plugin_htmlrender import get_new_page 36 | from playwright.async_api import Route, ViewportSize 37 | from pydantic import BaseModel, ConfigDict, Field 38 | from yarl import URL 39 | 40 | from ..compat import field_validator 41 | from ..config import config 42 | from ..resource import ( 43 | CACHE_DIR, 44 | RES_SHITTIM_TEMPLATES_DIR, 45 | SHITTIM_UTIL_CSS_PATH, 46 | SHITTIM_UTIL_JS_PATH, 47 | ) 48 | from ..util import ( 49 | AsyncReqKwargs, 50 | RespType, 51 | base_async_req, 52 | camel_case, 53 | wrapped_alru_cache, 54 | ) 55 | from .playwright import RES_ROUTE_URL, bawiki_router, get_template_renderer 56 | 57 | if not config.ba_shittim_key: 58 | logger.warning("API Key 未配置,关于什亭之匣的功能将会不可用!") 59 | logger.warning("请访问 https://arona.icu/about 查看获取 API Key 的方式!") 60 | 61 | 62 | SHITTIM_CACHE_DIR = CACHE_DIR / "shittim" 63 | if config.ba_auto_clear_cache_path and SHITTIM_CACHE_DIR.exists(): 64 | shutil.rmtree(SHITTIM_CACHE_DIR) 65 | if not SHITTIM_CACHE_DIR.exists(): 66 | SHITTIM_CACHE_DIR.mkdir(parents=True) 67 | 68 | 69 | T = TypeVar("T") 70 | 71 | SERVER_NAME_MAP = { 72 | 1: "官服", 73 | 2: "B服", 74 | } 75 | RANK_DATA_TYPE_NAME_MAP = { 76 | 1: "排名", 77 | 2: "档线", 78 | } 79 | HARD_FULLNAME_MAP = { 80 | "EX": "Extreme", 81 | "HC": "Hardcore", 82 | "VH": "VeryHard", 83 | "H": "Hard", 84 | "N": "Normal", 85 | } 86 | RAID_ANALYSIS_URL = urljoin(config.ba_shittim_url, "raidAnalyse") 87 | TIMEZONE_SHANGHAI = pytz.timezone("Asia/Shanghai") 88 | 89 | async_req = wrapped_alru_cache(ttl=config.ba_shittim_req_cache_ttl, maxsize=None)( 90 | base_async_req, 91 | ) 92 | template_env = jinja2.Environment( 93 | loader=jinja2.FileSystemLoader(RES_SHITTIM_TEMPLATES_DIR), 94 | enable_async=True, 95 | autoescape=True, 96 | ) 97 | 98 | 99 | # region Pagination 100 | 101 | 102 | class PaginationCallable(Protocol, Generic[T]): 103 | # (resp, is_last_page) 104 | async def __call__( 105 | self, 106 | page: int, 107 | size: int, 108 | delay: float, 109 | ) -> Tuple[Optional[List[T]], bool]: 110 | ... 111 | 112 | 113 | class IterPFKwargs(TypedDict, total=False): 114 | page: int 115 | size: int 116 | delay: float 117 | 118 | 119 | def iter_pagination_func(**kwargs: Unpack[IterPFKwargs]): 120 | page = kwargs.get("page", 1) 121 | size = kwargs.get("size", 100) 122 | delay = kwargs.get("delay", 0.0) 123 | 124 | def decorator(func: PaginationCallable[T]) -> Callable[[], AsyncIterable[T]]: 125 | async def wrapper(): 126 | while True: 127 | resp, last_page = await func(page, size, delay) 128 | if resp: 129 | for x in resp: 130 | yield x 131 | if last_page: 132 | break 133 | 134 | return wrapper 135 | 136 | return decorator 137 | 138 | 139 | async def async_iter_all(iterator: AsyncIterable[T]) -> List[T]: 140 | return [x async for x in iterator] 141 | 142 | 143 | # endregion 144 | 145 | 146 | # region enums 147 | 148 | 149 | class ServerType(Enum): 150 | Official = 1 151 | Bilibili = 2 152 | 153 | 154 | class RankDataType(Enum): 155 | Rank = 1 156 | Score = 2 157 | 158 | 159 | # endregion 160 | 161 | 162 | # region models 163 | 164 | 165 | def validator_time(cls, v: str): # noqa: ANN001, ARG001 166 | try: 167 | return ( 168 | datetime.strptime(v, "%Y-%m-%d %H:%M") 169 | .replace(tzinfo=TIMEZONE_SHANGHAI) 170 | .astimezone() 171 | ) 172 | except ValueError as e: 173 | raise ValueError(f"Time `{v}` format error") from e 174 | 175 | 176 | def validator_time_as_local(cls, v: datetime) -> datetime: # noqa: ANN001, ARG001 177 | return v.astimezone() 178 | 179 | 180 | def each_item_validator(func: Callable[[Any, T], T]): 181 | def wrapper(cls, v: Iterable[T]) -> List[T]: # noqa: ARG001, ANN001 182 | return [func(cls, x) for x in v] 183 | 184 | return wrapper 185 | 186 | 187 | class CamelAliasModel(BaseModel): 188 | if PYDANTIC_V2: 189 | model_config = ConfigDict(alias_generator=camel_case) 190 | else: 191 | 192 | class Config: 193 | alias_generator = camel_case 194 | 195 | 196 | class PaginationModel(CamelAliasModel): 197 | page: int 198 | size: int 199 | total_pages: int 200 | last_page: bool 201 | 202 | 203 | class SeasonMap(CamelAliasModel): 204 | key: str 205 | value: str 206 | 207 | 208 | class Season(CamelAliasModel): 209 | season: int 210 | season_map: SeasonMap = Field(alias="map") 211 | boss_id: int 212 | boss: str 213 | start_time: datetime 214 | end_time: datetime 215 | 216 | _validator_time = field_validator( 217 | "start_time", 218 | "end_time", 219 | mode="before", 220 | )(validator_time) 221 | 222 | 223 | class Character(CamelAliasModel): 224 | has_weapon: bool 225 | is_assist: bool 226 | level: int 227 | slot_index: int 228 | star_grade: int 229 | unique_id: int 230 | bullet_type: str 231 | tactic_role: str 232 | 233 | 234 | class TryNumberInfo(CamelAliasModel): 235 | try_number: int 236 | main_characters: List[Character] 237 | support_characters: List[Character] 238 | 239 | 240 | class RankSummary(CamelAliasModel): 241 | rank: int 242 | best_ranking_point: int 243 | hard: str 244 | battle_time: str 245 | 246 | @property 247 | def hard_fullname(self) -> str: 248 | return HARD_FULLNAME_MAP.get(self.hard, self.hard) 249 | 250 | 251 | class RankRecord(RankSummary): 252 | level: int 253 | nickname: str 254 | represent_character_unique_id: int 255 | tier: int 256 | boss_id: int 257 | try_number_infos: Optional[List[TryNumberInfo]] 258 | record_time: datetime 259 | 260 | _validator_time = field_validator( 261 | "record_time", 262 | )(validator_time_as_local) 263 | 264 | 265 | class Rank(PaginationModel): 266 | records: List[RankRecord] 267 | 268 | 269 | class RaidChart(CamelAliasModel): 270 | data: Optional[Dict[int, List[Optional[int]]]] = None 271 | time: List[datetime] 272 | 273 | _validator_time = field_validator("time")( 274 | each_item_validator(validator_time_as_local), 275 | ) 276 | 277 | 278 | class ParticipationChart(CamelAliasModel): 279 | value: List[int] 280 | key: List[datetime] 281 | 282 | _validator_time = field_validator("key")( 283 | each_item_validator(validator_time_as_local), 284 | ) 285 | 286 | 287 | # endregion 288 | 289 | 290 | # region api 291 | 292 | 293 | request_lock = asyncio.Lock() 294 | 295 | 296 | async def shittim_get(url: str, **kwargs: Unpack[AsyncReqKwargs]) -> Any: 297 | if not config.ba_shittim_key: 298 | raise ValueError("`BA_SHITTIM_KEY` not set") 299 | 300 | kwargs["base_urls"] = config.ba_shittim_api_url 301 | kwargs["proxies"] = config.ba_shittim_proxy 302 | 303 | headers = kwargs.get("headers") or {} 304 | headers["Authorization"] = f"ba-token {config.ba_shittim_key}" 305 | kwargs["headers"] = headers 306 | 307 | limit_qps = config.ba_shittim_request_delay > 0 308 | if limit_qps: 309 | kwargs["sleep"] = config.ba_shittim_request_delay 310 | await request_lock.acquire() 311 | 312 | try: 313 | resp = await async_req(url, **kwargs) 314 | finally: 315 | if limit_qps and request_lock.locked(): 316 | request_lock.release() 317 | 318 | if (code := resp.get("code")) != 200: 319 | params = kwargs.get("params") 320 | logger.warning( 321 | f"Shittim API `{url}` returned error code {code}, {params=}, {resp=}", 322 | ) 323 | 324 | return resp["data"] 325 | 326 | 327 | async def get_season_list() -> List[Season]: 328 | return type_validate_python(List[Season], await shittim_get("api/season/list")) 329 | 330 | 331 | def get_rank_list( 332 | server: ServerType, 333 | data_type: RankDataType, 334 | season: int, 335 | **pf_kwargs: Unpack[IterPFKwargs], 336 | ) -> AsyncIterable[RankRecord]: 337 | @iter_pagination_func(**pf_kwargs) 338 | async def iterator(page: int, size: int, delay: float): 339 | ret = type_validate_python( 340 | Rank, 341 | await shittim_get( 342 | f"api/rank/list/{server.value}/{data_type.value}/{season}", 343 | params={"page": page, "size": size}, 344 | sleep=delay, 345 | ), 346 | ) 347 | return ret.records, True if (not ret.records) else ret.last_page 348 | 349 | return iterator() 350 | 351 | 352 | async def get_rank_list_top( 353 | server: ServerType, 354 | season: int, 355 | ) -> List[RankSummary]: 356 | return type_validate_python( 357 | List[RankSummary], 358 | await shittim_get( 359 | "api/rank/list_top", 360 | params={"server": server.value, "season": season}, 361 | ), 362 | ) 363 | 364 | 365 | async def get_rank_list_by_last_rank( 366 | server: ServerType, 367 | season: int, 368 | ) -> List[RankSummary]: 369 | return type_validate_python( 370 | List[RankSummary], 371 | await shittim_get( 372 | "api/rank/list_by_last_rank", 373 | params={"server": server.value, "season": season}, 374 | ), 375 | ) 376 | 377 | 378 | async def get_raid_chart_data(server: ServerType, season: int) -> RaidChart: 379 | return type_validate_python( 380 | RaidChart, 381 | await shittim_get(f"raid/new/charts/{server.value}", params={"s": season}), 382 | ) 383 | 384 | 385 | async def get_participation_chart_data( 386 | server: ServerType, 387 | season: int, 388 | ) -> ParticipationChart: 389 | return type_validate_python( 390 | ParticipationChart, 391 | await shittim_get( 392 | "api/rank/season/lastRank/charts", 393 | params={"server": server.value, "season": season}, 394 | ), 395 | ) 396 | 397 | 398 | async def get_alice_friends(server: ServerType) -> Dict[int, RankRecord]: 399 | return type_validate_python( 400 | Dict[int, RankRecord], 401 | await shittim_get("api/rank/list_20001", params={"server": server.value}), 402 | ) 403 | 404 | 405 | async def get_diligent_achievers(server: ServerType) -> Dict[int, RankRecord]: 406 | return type_validate_python( 407 | Dict[int, RankRecord], 408 | await shittim_get("api/rank/list_1", params={"server": server.value}), 409 | ) 410 | 411 | 412 | async def get_student_icon(student_id: Union[int, str]) -> bytes: 413 | filename = f"{student_id}.png" 414 | path = anyio.Path(SHITTIM_CACHE_DIR / filename) 415 | 416 | if await path.exists(): 417 | return await path.read_bytes() 418 | 419 | resp = await async_req( 420 | f"web_students_original_icon/{filename}", 421 | base_urls=config.ba_shittim_data_url, 422 | resp_type=RespType.BYTES, 423 | headers={"Referer": config.ba_shittim_api_url}, 424 | ) 425 | await path.write_bytes(resp) 426 | return resp 427 | 428 | 429 | # endregion 430 | 431 | 432 | # region render 433 | 434 | 435 | VIEWPORT_SIZE = ViewportSize(width=880, height=1080) 436 | 437 | CHART_SHOW_RANKS = [1, 1000, 2000, 4000, 8000, 20000] 438 | MULTIPLIER = 2 439 | CHART_W = 760 440 | CHART_H = 480 441 | DATE_FORMAT = "%m-%d %H:%M" 442 | NUM_FORMAT = "{x:,.0f}" 443 | DATE_FORMATTER = mdates.DateFormatter(DATE_FORMAT) 444 | NUM_FORMATTER = mticker.StrMethodFormatter(NUM_FORMAT) 445 | 446 | 447 | def get_figure() -> Figure: 448 | figure = pyplot.figure() 449 | figure.set_dpi(figure.dpi * MULTIPLIER) 450 | figure.set_size_inches( 451 | CHART_W * MULTIPLIER / figure.dpi, 452 | CHART_H * MULTIPLIER / figure.dpi, 453 | ) 454 | return figure 455 | 456 | 457 | def save_figure(figure: Figure) -> bytes: 458 | bio = BytesIO() 459 | figure.savefig(bio, transparent=True, format="png") 460 | return bio.getvalue() 461 | 462 | 463 | def ax_settings(ax: Axes) -> None: 464 | ax.grid() 465 | ax.legend(loc="lower right") 466 | ax.xaxis.set_major_formatter(DATE_FORMATTER) 467 | ax.yaxis.set_major_formatter(NUM_FORMATTER) 468 | ax.tick_params(axis="x", labelrotation=15) 469 | 470 | 471 | def render_raid_chart(data: RaidChart) -> bytes: 472 | figure = get_figure() 473 | 474 | ax = figure.add_subplot() 475 | for key in CHART_SHOW_RANKS: 476 | if (not data.data) or (key not in data.data) or (not (y := data.data[key])): 477 | continue 478 | x = data.time[: len(y)] 479 | ax.plot( 480 | x, # type: ignore 481 | y, # type: ignore 482 | label=( 483 | f"{' ' * (10 - (len(str(key)) * 2))}{key} | " 484 | f"{x[-1].strftime(DATE_FORMAT)} | " 485 | f"{NUM_FORMAT.format(x=y[-1])}" 486 | ), 487 | ) 488 | ax_settings(ax) 489 | 490 | figure.tight_layout() 491 | return save_figure(figure) 492 | 493 | 494 | def render_participation_chart(data: ParticipationChart) -> bytes: 495 | figure = get_figure() 496 | 497 | ax = figure.add_subplot() 498 | ax.plot( 499 | data.key, # type: ignore 500 | data.value, 501 | label=( 502 | "Participants | " 503 | f"{data.key[-1].strftime(DATE_FORMAT)} | " 504 | f"{NUM_FORMAT.format(x=data.value[-1])}" 505 | ), 506 | ) 507 | ax_settings(ax) 508 | 509 | figure.tight_layout() 510 | return save_figure(figure) 511 | 512 | 513 | def to_b64_url(data: bytes) -> str: 514 | return f"data:image/png;base64,{b64encode(data).decode()}" 515 | 516 | 517 | async def render_raid_rank( 518 | server_name: str, 519 | data_type_name: str, 520 | season: Season, 521 | rank_list_top: List[RankSummary], 522 | rank_list_by_last_rank: List[RankSummary], 523 | rank_list: List[RankRecord], 524 | raid_chart: RaidChart, 525 | participation_chart: ParticipationChart, 526 | ) -> bytes: 527 | template = template_env.get_template("content_raid_rank.html.jinja") 528 | raid_chart_url = to_b64_url(render_raid_chart(raid_chart)) 529 | participation_chart_url = to_b64_url( 530 | render_participation_chart(participation_chart), 531 | ) 532 | return await get_template_renderer( 533 | template, 534 | selector=".wrapper", 535 | viewport=VIEWPORT_SIZE, 536 | )( 537 | server_name=server_name, 538 | data_type_name=data_type_name, 539 | season=season, 540 | rank_list_top=rank_list_top, 541 | rank_list_by_last_rank=rank_list_by_last_rank, 542 | rank_list=rank_list, 543 | raid_chart_url=raid_chart_url, 544 | participation_chart_url=participation_chart_url, 545 | shittim_url=config.ba_shittim_url, 546 | ) 547 | 548 | 549 | async def render_rank_detail( 550 | title: str, 551 | season_list: List[Season], 552 | rank_list: Dict[int, RankRecord], 553 | ) -> bytes: 554 | template = template_env.get_template("content_rank_detail.html.jinja") 555 | return await get_template_renderer( 556 | template, 557 | selector=".wrapper", 558 | viewport=VIEWPORT_SIZE, 559 | )( 560 | title=title, 561 | seasons={x.season: x for x in season_list}, 562 | rank_list=sorted(rank_list.items(), key=lambda x: x[0], reverse=True), 563 | shittim_url=config.ba_shittim_url, 564 | ) 565 | 566 | 567 | async def render_raid_analysis() -> bytes: 568 | async with get_new_page(viewport=VIEWPORT_SIZE) as page: 569 | await page.goto(RAID_ANALYSIS_URL, wait_until="networkidle") 570 | 571 | await page.evaluate(SHITTIM_UTIL_JS_PATH.read_text(encoding="u8")) 572 | await page.add_style_tag(content=SHITTIM_UTIL_CSS_PATH.read_text(encoding="u8")) 573 | 574 | elem = await page.query_selector(".content") 575 | assert elem 576 | return await elem.screenshot(type="jpeg") 577 | 578 | 579 | # endregion 580 | 581 | 582 | RES_TYPE_SHITTIM_STUDENT_ICON = "shittim_student_icon" 583 | 584 | 585 | @bawiki_router(rf"^{RES_ROUTE_URL}/{RES_TYPE_SHITTIM_STUDENT_ICON}/(\d+)$") 586 | async def _(url: URL, route: Route, **_): 587 | student_id = url.parts[-1] 588 | icon = await get_student_icon(student_id) 589 | return await route.fulfill(body=icon, content_type="image/png") 590 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/help/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot import on_command 2 | 3 | from ..command import help_list 4 | from .const import FT_E as FT_E, FT_S as FT_S 5 | 6 | try: 7 | from .pic_menu import extra as extra, help_handle, usage as usage 8 | except ImportError: 9 | from .manual import extra as extra, help_handle, usage as usage 10 | 11 | 12 | def register_help_cmd(): 13 | help_list.append( 14 | { 15 | "func": "插件帮助", 16 | "trigger_method": "指令", 17 | "trigger_condition": "ba帮助", 18 | "brief_des": "查看插件功能帮助", 19 | "detail_des": ( 20 | "查看插件的功能列表,或某功能的详细介绍\n" 21 | "装载 PicMenu 插件后插件将会调用 PicMenu 生成帮助图片\n" 22 | " \n" 23 | "可以用这些指令触发:\n" 24 | f"- {FT_S}ba帮助{FT_E}\n" 25 | f"- {FT_S}ba菜单{FT_E}\n" 26 | f"- {FT_S}ba功能{FT_E}\n" 27 | " \n" 28 | "指令示例:\n" 29 | f"- {FT_S}ba帮助{FT_E}(功能列表)\n" 30 | f"- {FT_S}ba帮助 日程表{FT_E}(功能详情)" 31 | ), 32 | }, 33 | ) 34 | 35 | help_cmd = on_command("ba帮助", aliases={"ba菜单", "ba功能"}) 36 | help_cmd.handle()(help_handle) 37 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/help/const.py: -------------------------------------------------------------------------------- 1 | FT_S = "" 2 | FT_E = "" 3 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/help/manual.py: -------------------------------------------------------------------------------- 1 | import re 2 | from io import BytesIO 3 | from typing import Dict, Tuple 4 | 5 | from nonebot.adapters import Message 6 | from nonebot.adapters.onebot.v11 import MessageSegment 7 | from nonebot.matcher import Matcher 8 | from nonebot.params import CommandArg 9 | from pil_utils import Text2Image 10 | 11 | from ..command import help_list 12 | 13 | usage = "使用指令 `ba帮助` 查询插件功能帮助" 14 | extra = None 15 | 16 | FT_REGEX = r".*?)>(?P.*?)" 17 | 18 | 19 | async def t2p(text: str) -> BytesIO: 20 | t2i = Text2Image.from_bbcode_text(text, fontsize=32) 21 | img = t2i.to_image("white", (16, 16)).convert("RGB") 22 | bio = BytesIO() 23 | img.save(bio, format="jpeg") 24 | bio.seek(0) 25 | return bio 26 | 27 | 28 | async def t2pm(text: str) -> MessageSegment: 29 | bio = await t2p(text) 30 | return MessageSegment.image(bio) 31 | 32 | 33 | def ft_args_to_bbcode(args: str) -> Tuple[str, str]: 34 | def parse_color(color: str) -> str: 35 | if color.startswith("(") and color.endswith(")"): 36 | hex_color = "".join(f"{int(x):02x}" for x in color[1:-1].split(",")) 37 | return f"#{hex_color}" 38 | return color 39 | 40 | ft_args = dict(x.split("=") for x in args.strip().split()) 41 | bbcode_args: Dict[str, str] = {} 42 | 43 | if "fonts" in ft_args: 44 | bbcode_args["font"] = ft_args["fonts"] 45 | if "size" in ft_args: 46 | bbcode_args["size"] = ft_args["size"] 47 | if "color" in ft_args: 48 | bbcode_args["color"] = parse_color(ft_args["color"]) 49 | 50 | prefix = "" 51 | suffix = "" 52 | for k, v in bbcode_args.items(): 53 | prefix = f"[{k}={v}]{prefix}" 54 | suffix = f"{suffix}[/{k}]" 55 | return prefix, suffix 56 | 57 | 58 | def replace_ft(text: str) -> str: 59 | def replace(match: re.Match) -> str: 60 | args = match.group("args") 61 | prefix, suffix = ft_args_to_bbcode(args) 62 | content = match.group("content") 63 | return f"{prefix}{content}{suffix}" 64 | 65 | return re.sub(FT_REGEX, replace, text) 66 | 67 | 68 | async def help_handle(matcher: Matcher, arg_msg: Message = CommandArg()): 69 | arg = arg_msg.extract_plain_text().strip() 70 | 71 | if not arg: 72 | cmd_list = "\n".join( 73 | ( 74 | f"▸ [b]{k['func']}[/b] " 75 | f"({k['trigger_method']}:{k['trigger_condition']}) - " 76 | f"{k['brief_des']}" 77 | ) 78 | for k in help_list 79 | ) 80 | msg = ( 81 | f"目前插件支持的功能:\n" 82 | f"\n" 83 | f"{cmd_list}\n" 84 | f"\n" 85 | f"Tip: 使用指令 `[b]ba帮助 <功能名>[/b]` 查看某功能详细信息" 86 | ) 87 | await matcher.finish(await t2pm(msg)) 88 | 89 | arg_lower = arg.lower() 90 | func = next( 91 | ( 92 | x 93 | for x in help_list 94 | if ( 95 | (arg_lower in x["func"].lower()) 96 | or (arg_lower in x["trigger_condition"].lower()) 97 | or (arg_lower in x["brief_des"].lower()) 98 | ) 99 | ), 100 | None, 101 | ) 102 | if not func: 103 | await matcher.finish(f"未找到功能 `{arg}`") 104 | 105 | # ft to bbcode 106 | detail_des = replace_ft(func["detail_des"]) 107 | 108 | # 缩进 109 | detail_des = " ".join(detail_des.splitlines(keepends=True)).strip() 110 | 111 | msg = ( 112 | f"▸ [b]功能:{func['func']}[/b]\n" 113 | f"\n" 114 | f'▹ [b]触发方式:[/b]{func["trigger_method"]}\n' 115 | f'▹ [b]触发条件:[/b]{func["trigger_condition"]}\n' 116 | f'▹ [b]简要描述:[/b]{func["brief_des"]}\n' 117 | f"▹ [b]详细描述:[/b]\n" 118 | f" {detail_des}" 119 | ) 120 | await matcher.finish(await t2pm(msg)) 121 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/help/pic_menu.py: -------------------------------------------------------------------------------- 1 | import json 2 | from io import BytesIO 3 | from pathlib import Path 4 | from typing import Union, cast 5 | 6 | from nonebot import get_available_plugin_names, logger, require 7 | from nonebot.adapters.onebot.v11 import MessageSegment 8 | from nonebot.internal.adapter import Message 9 | from nonebot.internal.matcher import Matcher 10 | from nonebot.params import CommandArg 11 | from PIL import Image 12 | from pil_utils.fonts import get_proper_font 13 | 14 | from ..command import help_list 15 | from .const import FT_E, FT_S 16 | 17 | # import importlib.util 18 | # if importlib.util.find_spec("nonebot_plugin_PicMenu") is None: 19 | 20 | if "nonebot_plugin_PicMenu" not in get_available_plugin_names(): 21 | raise ImportError 22 | 23 | require("nonebot_plugin_PicMenu") 24 | 25 | from nonebot_plugin_PicMenu import menu_manager # noqa: E402 26 | 27 | usage = f"请使用指令 [ba帮助 {FT_S}功能名称或序号{FT_E}] 查看某功能详细介绍" 28 | extra = {"menu_template": "default", "menu_data": help_list} 29 | 30 | 31 | def save_img_to_io(img: Image.Image): 32 | img = img.convert("RGB") 33 | img_io = BytesIO() 34 | img.save(img_io, "jpeg") 35 | img_io.seek(0) 36 | return img_io 37 | 38 | 39 | async def help_handle(matcher: Matcher, arg_msg: Message = CommandArg()): 40 | arg = arg_msg.extract_plain_text().strip() 41 | img = cast( 42 | Union[str, Image.Image], 43 | ( 44 | menu_manager.generate_plugin_menu_image("BAWiki") 45 | if not arg 46 | else menu_manager.generate_func_details_image("BAWiki", arg) 47 | ), 48 | ) 49 | 50 | if isinstance(img, str): 51 | await matcher.finish(f'出错了,可能未找到功能 "{arg}"') 52 | 53 | await matcher.finish(MessageSegment.image(save_img_to_io(img))) 54 | 55 | 56 | # 给 PicMenu 用户上个默认字体 57 | def set_pic_menu_font(): 58 | pic_menu_dir = Path.cwd() / "menu_config" 59 | pic_menu_config = pic_menu_dir / "config.json" 60 | 61 | if not pic_menu_dir.exists(): 62 | pic_menu_dir.mkdir(parents=True) 63 | 64 | if (not pic_menu_config.exists()) or ( 65 | json.loads(pic_menu_config.read_text(encoding="u8")).get("default") 66 | == "font_path" 67 | ): 68 | path = str(get_proper_font("国").path.resolve()) 69 | pic_menu_config.write_text(json.dumps({"default": path}), encoding="u8") 70 | logger.info("检测到 PicMenu 已加载并且未配置字体,已自动帮您配置系统字体") 71 | 72 | 73 | try: 74 | set_pic_menu_font() 75 | except Exception: 76 | logger.exception("配置 PicMenu 字体失败") 77 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/__init__.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from pathlib import Path 3 | 4 | from nonebot import logger 5 | 6 | DATA_DIR = Path.cwd() / "data" / "BAWiki" 7 | CACHE_DIR = DATA_DIR / "cache" 8 | 9 | for _p in (DATA_DIR, CACHE_DIR): 10 | if not _p.exists(): 11 | _p.mkdir(parents=True) 12 | 13 | 14 | RES_DIR = Path(__file__).parent / "res" 15 | 16 | RES_GACHA_DIR = RES_DIR / "gacha" 17 | GACHA_BG_PATH = RES_GACHA_DIR / "gacha_bg.webp" 18 | GACHA_BG_OLD_PATH = RES_GACHA_DIR / "gacha_bg_old.webp" 19 | GACHA_CARD_BG_PATH = RES_GACHA_DIR / "gacha_card_bg.png" 20 | GACHA_CARD_MASK_PATH = RES_GACHA_DIR / "gacha_card_mask.png" 21 | GACHA_NEW_PATH = RES_GACHA_DIR / "gacha_new.png" 22 | GACHA_PICKUP_PATH = RES_GACHA_DIR / "gacha_pickup.png" 23 | GACHA_STAR_PATH = RES_GACHA_DIR / "gacha_star.png" 24 | GACHA_STU_ERR_PATH = RES_GACHA_DIR / "gacha_stu_err.png" 25 | 26 | RES_GAMEKEE_DIR = RES_DIR / "gamekee" 27 | GAMEKEE_UTIL_JS_PATH = RES_GAMEKEE_DIR / "gamekee_util.js" 28 | 29 | RES_GENERAL_DIR = RES_DIR / "general" 30 | CALENDER_BANNER_PATH = RES_GENERAL_DIR / "calender_banner.png" 31 | GRADIENT_BG_PATH = RES_GENERAL_DIR / "gradient.webp" 32 | 33 | RES_LOGO_DIR = RES_DIR / "logo" 34 | BA_LOGO_JS_PATH = RES_LOGO_DIR / "ba_logo.js" 35 | 36 | RES_SCHALE_DIR = RES_DIR / "schale" 37 | SCHALE_UTIL_JS_PATH = RES_SCHALE_DIR / "schale_util.js" 38 | SCHALE_UTIL_CSS_PATH = RES_SCHALE_DIR / "schale_util.css" 39 | 40 | RES_SHITTIM_DIR = RES_DIR / "shittim" 41 | RES_SHITTIM_TEMPLATES_DIR = RES_SHITTIM_DIR / "templates" 42 | RES_SHITTIM_JS_DIR = RES_SHITTIM_DIR / "js" 43 | RES_SHITTIM_CSS_DIR = RES_SHITTIM_DIR / "css" 44 | SHITTIM_UTIL_JS_PATH = RES_SHITTIM_JS_DIR / "shittim_util.js" 45 | SHITTIM_UTIL_CSS_PATH = RES_SHITTIM_CSS_DIR / "shittim_util.css" 46 | 47 | EMPTY_HTML_PATH = RES_DIR / "index.html" 48 | 49 | 50 | _OLD_CACHE_FOLDER = Path.cwd() / "cache" 51 | _OLD_CACHE_PATH = _OLD_CACHE_FOLDER / "BAWiki" 52 | if _OLD_CACHE_PATH.exists(): 53 | logger.warning("Deleting old cache dir...") 54 | shutil.rmtree(_OLD_CACHE_PATH) 55 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/gacha/gacha_bg.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-bawiki/b1eda32e5665f1bd8898ece57ca71f0516002e76/nonebot_plugin_bawiki/resource/res/gacha/gacha_bg.webp -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/gacha/gacha_bg_old.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-bawiki/b1eda32e5665f1bd8898ece57ca71f0516002e76/nonebot_plugin_bawiki/resource/res/gacha/gacha_bg_old.webp -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/gacha/gacha_card_bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-bawiki/b1eda32e5665f1bd8898ece57ca71f0516002e76/nonebot_plugin_bawiki/resource/res/gacha/gacha_card_bg.png -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/gacha/gacha_card_mask.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-bawiki/b1eda32e5665f1bd8898ece57ca71f0516002e76/nonebot_plugin_bawiki/resource/res/gacha/gacha_card_mask.png -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/gacha/gacha_new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-bawiki/b1eda32e5665f1bd8898ece57ca71f0516002e76/nonebot_plugin_bawiki/resource/res/gacha/gacha_new.png -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/gacha/gacha_pickup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-bawiki/b1eda32e5665f1bd8898ece57ca71f0516002e76/nonebot_plugin_bawiki/resource/res/gacha/gacha_pickup.png -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/gacha/gacha_star.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-bawiki/b1eda32e5665f1bd8898ece57ca71f0516002e76/nonebot_plugin_bawiki/resource/res/gacha/gacha_star.png -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/gacha/gacha_stu_err.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-bawiki/b1eda32e5665f1bd8898ece57ca71f0516002e76/nonebot_plugin_bawiki/resource/res/gacha/gacha_stu_err.png -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/gamekee/gamekee_util.js: -------------------------------------------------------------------------------- 1 | () => { 2 | /** 3 | * @param {string} selector 4 | * @param {(obj: HTMLElement) => any} callback 5 | */ 6 | const executeIfExist = (selector, callback) => { 7 | const obj = document.querySelector(selector); 8 | if (obj) callback(obj); 9 | }; 10 | 11 | // 给内容加上 padding 12 | executeIfExist('div.wiki-detail-body', (obj) => (obj.style.padding = '20px')); 13 | 14 | // 隐藏 Header 避免遮挡页面 15 | executeIfExist('div.wiki-header', (obj) => (obj.style.display = 'none')); 16 | 17 | // 隐藏关注按钮 18 | executeIfExist( 19 | 'div.user-box > button', 20 | (obj) => (obj.style.display = 'none') 21 | ); 22 | 23 | // 删掉视频播放器 24 | for (const it of document.querySelectorAll('div.video-play-wrapper')) 25 | it.remove(); 26 | 27 | // 展开所有选项卡内容 28 | for (const it of document.querySelectorAll('div.slide-item')) 29 | it.classList.add('active'); 30 | 31 | // 删掉点赞和收藏按钮 32 | executeIfExist('div.article-options', (obj) => obj.remove()); 33 | 34 | // 删掉底部边距 35 | executeIfExist( 36 | 'div.wiki-detail-body', 37 | (obj) => (obj.style.marginBottom = '0') 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/general/calender_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-bawiki/b1eda32e5665f1bd8898ece57ca71f0516002e76/nonebot_plugin_bawiki/resource/res/general/calender_banner.png -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/general/gradient.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-bawiki/b1eda32e5665f1bd8898ece57ca71f0516002e76/nonebot_plugin_bawiki/resource/res/general/gradient.webp -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/logo/ba_logo.js: -------------------------------------------------------------------------------- 1 | // https://github.com/nulla2011/bluearchive-logo/blob/master/src/canvas.ts 2 | /** @param {[string, string]} */ 3 | async ([textL, textR, transparentBg]) => { 4 | const fontSize = 168; 5 | const canvasHeight = 500; 6 | const canvasWidth = 900; 7 | const textBaseLine = 0.68; 8 | const horizontalTilt = -0.4; 9 | const paddingX = 20; 10 | const graphOffset = { X: -30, Y: 0 }; 11 | const hollowPath = [ 12 | [568, 272], 13 | [642, 306], 14 | [318, 820], 15 | [296, 806], 16 | ]; 17 | 18 | const halo = document.createElement('img'); 19 | halo.id = 'halo'; 20 | halo.src = '/logo/halo.png'; 21 | 22 | const cross = document.createElement('img'); 23 | cross.id = 'cross'; 24 | cross.src = '/logo/cross.png'; 25 | 26 | // wait images loaded 27 | await Promise.all( 28 | [halo, cross].map( 29 | (img) => 30 | new Promise((resolve, reject) => { 31 | img.onload = resolve; 32 | img.onerror = reject; 33 | }) 34 | ) 35 | ); 36 | 37 | // create canvas 38 | const canvas = document.createElement('canvas'); 39 | canvas.width = canvasWidth; 40 | canvas.height = canvasHeight; 41 | const c = canvas.getContext('2d'); 42 | 43 | // load font 44 | const fonts = [ 45 | { name: 'Ro GSan Serif Std' }, 46 | { name: 'Glow Sans SC', param: '?weight=heavy' }, 47 | ]; 48 | const fontsCss = fonts 49 | .map( 50 | ({ name, param }) => 51 | `@font-face {\n` + 52 | ` font-family: '${name}';\n` + 53 | ` src: url('/font/${name}${param ?? ''}') format('truetype');\n` + 54 | `}` 55 | ) 56 | .join('\n'); 57 | const style = document.createElement('style'); 58 | style.innerHTML = fontsCss; 59 | document.head.appendChild(style); 60 | 61 | const font = 62 | `${fontSize}px ` + 63 | `${fonts.map(({ name }) => `'${name}'`).join(', ')}, sans-serif`; 64 | await document.fonts.load(font, `${textL}${textR}`); 65 | c.font = font; 66 | 67 | // extend canvas 68 | const textMetricsL = c.measureText(textL); 69 | const textMetricsR = c.measureText(textR); 70 | 71 | const textWidthL = 72 | textMetricsL.width - 73 | (textBaseLine * canvasHeight + textMetricsL.fontBoundingBoxDescent) * 74 | horizontalTilt; 75 | const textWidthR = 76 | textMetricsR.width + 77 | (textBaseLine * canvasHeight - textMetricsR.fontBoundingBoxAscent) * 78 | horizontalTilt; 79 | 80 | const canvasWidthL = 81 | textWidthL + paddingX > canvasWidth / 2 82 | ? textWidthL + paddingX 83 | : canvasWidth / 2; 84 | const canvasWidthR = 85 | textWidthR + paddingX > canvasWidth / 2 86 | ? textWidthR + paddingX 87 | : canvasWidth / 2; 88 | 89 | canvas.width = canvasWidthL + canvasWidthR; 90 | 91 | // clear canvas 92 | c.clearRect(0, 0, canvas.width, canvas.height); 93 | 94 | // background 95 | if (!transparentBg) { 96 | c.fillStyle = '#fff'; 97 | c.fillRect(0, 0, canvas.width, canvas.height); 98 | } 99 | 100 | // left blue text 101 | c.font = font; 102 | c.fillStyle = '#128AFA'; 103 | c.textAlign = 'end'; 104 | c.setTransform(1, 0, horizontalTilt, 1, 0, 0); 105 | c.fillText(textL, canvasWidthL, canvas.height * textBaseLine); 106 | c.resetTransform(); // restore don't work 107 | 108 | // halo 109 | c.drawImage( 110 | halo, 111 | canvasWidthL - canvas.height / 2 + graphOffset.X, 112 | graphOffset.Y, 113 | canvasHeight, 114 | canvasHeight 115 | ); 116 | 117 | // right black text 118 | c.fillStyle = '#2B2B2B'; 119 | c.textAlign = 'start'; 120 | if (transparentBg) c.globalCompositeOperation = 'destination-out'; 121 | c.strokeStyle = 'white'; 122 | c.lineWidth = 12; 123 | c.setTransform(1, 0, horizontalTilt, 1, 0, 0); 124 | c.strokeText(textR, canvasWidthL, canvas.height * textBaseLine); 125 | 126 | c.globalCompositeOperation = 'source-over'; 127 | c.fillText(textR, canvasWidthL, canvas.height * textBaseLine); 128 | c.resetTransform(); 129 | 130 | // cross stroke 131 | const graph = { 132 | X: canvasWidthL - canvas.height / 2 + graphOffset.X, 133 | Y: graphOffset.Y, 134 | }; 135 | c.beginPath(); 136 | hollowPath.forEach(([x, y], i) => { 137 | const f = (i === 0 ? c.moveTo : c.lineTo).bind(c); 138 | f(graph.X + x / 2, graph.Y + y / 2); 139 | }); 140 | c.closePath(); 141 | 142 | if (transparentBg) c.globalCompositeOperation = 'destination-out'; 143 | c.fillStyle = 'white'; 144 | c.fill(); 145 | c.globalCompositeOperation = 'source-over'; 146 | 147 | // cross 148 | c.drawImage( 149 | cross, 150 | canvasWidthL - canvas.height / 2 + graphOffset.X, 151 | graphOffset.Y, 152 | canvasHeight, 153 | canvasHeight 154 | ); 155 | 156 | // output 157 | /** @type {HTMLCanvasElement} */ 158 | let outputCanvas; 159 | if ( 160 | textWidthL + paddingX >= canvasWidth / 2 && 161 | textWidthR + paddingX >= canvasWidth / 2 162 | ) { 163 | outputCanvas = canvas; 164 | } else { 165 | outputCanvas = document.createElement('canvas'); 166 | outputCanvas.width = textWidthL + textWidthR + paddingX * 2; 167 | outputCanvas.height = canvas.height; 168 | 169 | const ctx = outputCanvas.getContext('2d'); 170 | ctx.drawImage( 171 | canvas, 172 | canvasWidth / 2 - textWidthL - paddingX, 173 | 0, 174 | textWidthL + textWidthR + paddingX * 2, 175 | canvas.height, 176 | 0, 177 | 0, 178 | textWidthL + textWidthR + paddingX * 2, 179 | canvas.height 180 | ); 181 | } 182 | 183 | const b64 = outputCanvas.toDataURL().replace(/^data:(.+?);base64,/, ''); 184 | return `base64://${b64}`; 185 | }; 186 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/logo/cross.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-bawiki/b1eda32e5665f1bd8898ece57ca71f0516002e76/nonebot_plugin_bawiki/resource/res/logo/cross.png -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/logo/halo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-bawiki/b1eda32e5665f1bd8898ece57ca71f0516002e76/nonebot_plugin_bawiki/resource/res/logo/halo.png -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/schale/schale_util.css: -------------------------------------------------------------------------------- 1 | .nav.nav-pills { 2 | padding-top: 1rem; 3 | padding-bottom: 1rem; 4 | } 5 | 6 | #ba-student-page-stats, 7 | #ba-student-page-skills, 8 | #ba-student-page-weapon, 9 | #ba-student-page-gear, 10 | #ba-student-page-profile { 11 | padding-bottom: 1rem; 12 | border-top: 2px solid var(--col-theme-text-t); 13 | border-radius: 0px !important; 14 | } 15 | 16 | #ba-student-page-profile { 17 | padding-bottom: 0; 18 | } 19 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/schale/schale_util.js: -------------------------------------------------------------------------------- 1 | () => { 2 | // #region util functions 3 | /** 4 | * @param {string} selector 5 | * @param {number} val,设为 max 留空(undefined, null)或填 0 6 | */ 7 | const setProgress = (selector, val) => { 8 | const obj = $(selector); 9 | if (!obj.is('input[type="range"]')) { 10 | console.warn(`utilStuSetProgress: invalid selector "${selector}"`); 11 | return; 12 | } 13 | const min = Number(obj.attr('min')) || 1; 14 | const max = Number(obj.attr('max')) || 1; 15 | const willSet = 16 | typeof val === 'number' && val >= min && val <= max ? val : max; 17 | obj.val(willSet).trigger('input'); 18 | }; 19 | // #endregion 20 | 21 | // 切换中文 22 | if (localStorage.getItem('language') !== 'Cn') changeLanguage('Cn'); 23 | 24 | // #region 关闭更新日志 25 | const changeLogModelElem = $('#modal-changelog'); 26 | if (changeLogModelElem.is(':visible')) { 27 | changeLogModelElem.remove(); 28 | $('.modal-backdrop').remove(); 29 | localStorage.setItem('changelog_seen', '1145141919810'); 30 | } 31 | // #endregion 32 | 33 | // #region 进度条拉最大 34 | setProgress('#ba-statpreview-levelrange'); 35 | setProgress('#ba-skillpreview-exrange'); 36 | setProgress('#ba-skillpreview-range'); 37 | setProgress('#ba-weaponpreview-levelrange'); 38 | setProgress('#ba-weapon-skillpreview-range'); 39 | setProgress('#ba-gear-skillpreview-range'); 40 | // #endregion 41 | 42 | // #region 展开所有项目 43 | const cardHeaderElem = $('.card-header'); 44 | // 我自己的 schale db 镜像已经执行过下面的操作了,当未找到 card-header 时不再执行 45 | if (cardHeaderElem.length !== 0) { 46 | const cardBodyElem = $('.card-body'); 47 | const stuPageChildren = cardBodyElem.children('.tab-content').children(); 48 | 49 | // 获取 nav bar 后移除 50 | const navElem = cardHeaderElem.children('nav#ba-item-list-tabs'); 51 | navElem.children().removeClass('active'); 52 | navElem.remove(); 53 | 54 | // 移动 card-header 中元素到 card-body 后移除 card header 55 | const headerChildren = cardHeaderElem.children(); 56 | cardBodyElem.prepend(headerChildren); 57 | cardHeaderElem.remove(); 58 | 59 | // 将 nav bar 添加到各 page 头部,删除 nav 中不可见的对应 page 60 | stuPageChildren.each((_, elem) => { 61 | const navClone = navElem.clone(); 62 | $(elem).prepend(navClone); 63 | elem.classList.add('show', 'active'); 64 | 65 | const pageName = elem.id.replace('ba-student-page-', ''); 66 | const tabElem = navClone.children(`#ba-student-tab-${pageName}`); 67 | if (!tabElem.is(':visible')) { 68 | elem.remove(); 69 | return; 70 | } 71 | tabElem.addClass('active'); 72 | }); 73 | } 74 | // #endregion 75 | 76 | // 背景填满截图 77 | $('#ba-background').css('height', document.body.scrollHeight); 78 | }; 79 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/shittim/assets/BG_AronaRoom_In.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-bawiki/b1eda32e5665f1bd8898ece57ca71f0516002e76/nonebot_plugin_bawiki/resource/res/shittim/assets/BG_AronaRoom_In.webp -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/shittim/assets/Card_Bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-bawiki/b1eda32e5665f1bd8898ece57ca71f0516002e76/nonebot_plugin_bawiki/resource/res/shittim/assets/Card_Bg.png -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/shittim/assets/Common_Icon_Asist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-bawiki/b1eda32e5665f1bd8898ece57ca71f0516002e76/nonebot_plugin_bawiki/resource/res/shittim/assets/Common_Icon_Asist.png -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/shittim/assets/diamond.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-bawiki/b1eda32e5665f1bd8898ece57ca71f0516002e76/nonebot_plugin_bawiki/resource/res/shittim/assets/diamond.webp -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/shittim/assets/frame/Explosion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-bawiki/b1eda32e5665f1bd8898ece57ca71f0516002e76/nonebot_plugin_bawiki/resource/res/shittim/assets/frame/Explosion.png -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/shittim/assets/frame/Mystic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-bawiki/b1eda32e5665f1bd8898ece57ca71f0516002e76/nonebot_plugin_bawiki/resource/res/shittim/assets/frame/Mystic.png -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/shittim/assets/frame/Pierce.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-bawiki/b1eda32e5665f1bd8898ece57ca71f0516002e76/nonebot_plugin_bawiki/resource/res/shittim/assets/frame/Pierce.png -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/shittim/assets/gold.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-bawiki/b1eda32e5665f1bd8898ece57ca71f0516002e76/nonebot_plugin_bawiki/resource/res/shittim/assets/gold.webp -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/shittim/assets/role/DamageDealer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-bawiki/b1eda32e5665f1bd8898ece57ca71f0516002e76/nonebot_plugin_bawiki/resource/res/shittim/assets/role/DamageDealer.png -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/shittim/assets/role/Healer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-bawiki/b1eda32e5665f1bd8898ece57ca71f0516002e76/nonebot_plugin_bawiki/resource/res/shittim/assets/role/Healer.png -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/shittim/assets/role/Supporter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-bawiki/b1eda32e5665f1bd8898ece57ca71f0516002e76/nonebot_plugin_bawiki/resource/res/shittim/assets/role/Supporter.png -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/shittim/assets/role/Tanker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-bawiki/b1eda32e5665f1bd8898ece57ca71f0516002e76/nonebot_plugin_bawiki/resource/res/shittim/assets/role/Tanker.png -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/shittim/assets/silver.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-bawiki/b1eda32e5665f1bd8898ece57ca71f0516002e76/nonebot_plugin_bawiki/resource/res/shittim/assets/silver.webp -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/shittim/assets/star/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-bawiki/b1eda32e5665f1bd8898ece57ca71f0516002e76/nonebot_plugin_bawiki/resource/res/shittim/assets/star/2.png -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/shittim/assets/star/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-bawiki/b1eda32e5665f1bd8898ece57ca71f0516002e76/nonebot_plugin_bawiki/resource/res/shittim/assets/star/3.png -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/shittim/assets/star/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-bawiki/b1eda32e5665f1bd8898ece57ca71f0516002e76/nonebot_plugin_bawiki/resource/res/shittim/assets/star/4.png -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/shittim/assets/star/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-bawiki/b1eda32e5665f1bd8898ece57ca71f0516002e76/nonebot_plugin_bawiki/resource/res/shittim/assets/star/5.png -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/shittim/css/shittim.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | background-image: url('/shittim/assets/BG_AronaRoom_In.webp'); 5 | background-repeat: no-repeat; 6 | background-position: center; 7 | background-attachment: local; 8 | background-size: cover; 9 | } 10 | 11 | .wrapper { 12 | width: fit-content; 13 | padding: 20px; 14 | } 15 | 16 | .main { 17 | flex: 1; 18 | width: 800px; 19 | padding: 20px; 20 | background-color: #ffffff8f; 21 | border-radius: 4px; 22 | border: solid 1px #dcdfe6; 23 | } 24 | 25 | .segmentation { 26 | height: 20px; 27 | } 28 | 29 | .content { 30 | padding: 20px; 31 | background-color: #fff; 32 | border-radius: 4px; 33 | } 34 | 35 | .title { 36 | font-size: 22px; 37 | font-weight: 700; 38 | } 39 | 40 | .title.chart { 41 | font-size: 20px; 42 | font-weight: bold; 43 | } 44 | 45 | .title.small { 46 | font-size: 18px; 47 | padding-bottom: 6px; 48 | } 49 | 50 | .split-line { 51 | -webkit-box-pack: justify; 52 | justify-content: space-between; 53 | -webkit-box-align: center; 54 | align-items: center; 55 | text-align: center; 56 | margin: 0px 0.25rem 0.5rem; 57 | padding: 0.125em 0; 58 | background-color: #344b6f; 59 | color: #fff; 60 | width: calc(100% - 0.5rem); 61 | transform: skew(-10deg); 62 | border-radius: 0.2em; 63 | } 64 | 65 | .split-line .text { 66 | transform: skew(10deg); 67 | } 68 | 69 | .grade-line { 70 | padding: 5px; 71 | display: flex; 72 | justify-content: space-around; 73 | } 74 | 75 | .grade { 76 | text-align: center; 77 | } 78 | 79 | .grade img { 80 | width: 40px; 81 | } 82 | 83 | .grade .score { 84 | font-weight: 700; 85 | } 86 | 87 | .grade .time { 88 | font-style: italic; 89 | color: #000000de; 90 | } 91 | 92 | .grade .number { 93 | color: #2d4664; 94 | font-size: 14px; 95 | font-weight: 700; 96 | } 97 | 98 | .grade-title { 99 | position: relative; 100 | display: inline-block; 101 | font-size: 14px; 102 | line-height: 20px; 103 | letter-spacing: -1px; 104 | color: #2d4664; 105 | border: 1px solid #2d4664; 106 | border-radius: 6px; 107 | padding: 2px 10px; 108 | margin-bottom: 4px; 109 | font-weight: 700; 110 | text-align: center; 111 | } 112 | 113 | .grade-title.ins { 114 | background-color: rgb(216, 212, 253); 115 | } 116 | 117 | .grade-title.extreme { 118 | background-color: rgb(252, 222, 253); 119 | } 120 | 121 | .grade-title.hardcore { 122 | background-color: rgb(254, 210, 225); 123 | } 124 | 125 | .grade-title.veryhard { 126 | background-color: rgb(214, 251, 254); 127 | } 128 | 129 | .grade-title.hard { 130 | background-color: rgb(241, 255, 201); 131 | } 132 | 133 | .grade-title.normal { 134 | background-color: rgb(253, 255, 210); 135 | } 136 | 137 | td .grade-title { 138 | font-size: 12px; 139 | line-height: 18px; 140 | padding: 2px 10px 2px 9px; 141 | } 142 | 143 | table { 144 | position: relative; 145 | width: 100%; 146 | max-width: 100%; 147 | height: fit-content; 148 | background-color: #ffffff; 149 | font-size: 14px; 150 | overflow: hidden; 151 | box-sizing: border-box; 152 | table-layout: fixed; 153 | border-collapse: separate; 154 | border-radius: 4px; 155 | } 156 | 157 | table thead th, 158 | table tbody td { 159 | position: relative; 160 | padding: 8px 12px; 161 | border-bottom: 1px solid #ebeef5; 162 | text-align: left; 163 | min-width: 0; 164 | box-sizing: border-box; 165 | overflow: hidden; 166 | text-overflow: ellipsis; 167 | white-space: normal; 168 | word-break: break-all; 169 | line-height: 23px; 170 | vertical-align: middle; 171 | z-index: 1; 172 | } 173 | 174 | table thead th { 175 | color: #909399; 176 | font-weight: 600; 177 | } 178 | 179 | table tbody td { 180 | color: #606266; 181 | } 182 | 183 | table tbody :nth-last-child(1) td { 184 | border-bottom: none; 185 | } 186 | 187 | .cust-head { 188 | width: 70px; 189 | height: 60px; 190 | position: relative; 191 | } 192 | 193 | .cust-head .head { 194 | width: 60px; 195 | position: absolute; 196 | } 197 | 198 | .cust-head .rank { 199 | width: 30px; 200 | position: absolute; 201 | right: 3px; 202 | bottom: 0; 203 | } 204 | 205 | .cust-info { 206 | display: flex; 207 | align-items: center; 208 | justify-content: space-between; 209 | } 210 | 211 | .cust-info .info-left { 212 | display: flex; 213 | align-items: center; 214 | } 215 | 216 | .cust-info .info-left .infos .info-line { 217 | display: flex; 218 | align-items: center; 219 | } 220 | 221 | .cust-info .info-left .infos .info-line .left { 222 | width: 30px; 223 | padding-left: 5px; 224 | font-weight: 600; 225 | } 226 | 227 | .cust-info .info-left .infos .info-lie .right { 228 | overflow: hidden; 229 | text-overflow: ellipsis; 230 | white-space: nowrap; 231 | } 232 | 233 | .cust-info .info-right { 234 | text-align: right; 235 | } 236 | 237 | .cust-info .info-right .rank { 238 | font-size: 18px; 239 | font-weight: bold; 240 | padding-top: 2px; 241 | } 242 | 243 | .cust-info .info-right .score { 244 | font-size: 16px; 245 | font-weight: normal; 246 | padding-top: 0px; 247 | } 248 | 249 | .character-head { 250 | max-width: 70px; 251 | width: 10vw; 252 | max-height: 70px; 253 | height: 10vw; 254 | position: relative; 255 | } 256 | 257 | .character-head .head { 258 | max-width: 70px; 259 | width: 10vw; 260 | position: absolute; 261 | } 262 | 263 | .character-head .frame { 264 | top: -1px; 265 | max-width: 70px; 266 | width: 10vw; 267 | height: 10vw; 268 | max-height: 69px; 269 | position: absolute; 270 | } 271 | 272 | .character-head .role-types { 273 | position: absolute; 274 | width: 20px; 275 | right: 6px; 276 | bottom: 12px; 277 | } 278 | 279 | .character-head .star { 280 | position: absolute; 281 | width: 18px; 282 | left: 2px; 283 | bottom: 9px; 284 | } 285 | 286 | .character-head .asist { 287 | position: absolute; 288 | width: 15px; 289 | right: 5px; 290 | } 291 | 292 | .character-line { 293 | display: flex; 294 | align-items: center; 295 | justify-content: space-between; 296 | } 297 | 298 | .cust-character { 299 | padding-top: 10px; 300 | display: flex; 301 | align-items: center; 302 | justify-content: space-between; 303 | } 304 | 305 | .cust-character p { 306 | font-weight: 600; 307 | } 308 | 309 | .footer { 310 | font-style: italic; 311 | text-align: right; 312 | } 313 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/shittim/css/shittim_util.css: -------------------------------------------------------------------------------- 1 | /* 魔改页面方便截图 */ 2 | 3 | body { 4 | height: auto !important; 5 | overflow: auto !important; 6 | } 7 | 8 | .app { 9 | height: auto !important; 10 | } 11 | 12 | .layout { 13 | display: block !important; 14 | height: auto !important; 15 | } 16 | 17 | .body { 18 | display: block !important; 19 | height: auto !important; 20 | } 21 | 22 | .content { 23 | width: fit-content !important; 24 | padding: 20px !important; 25 | margin: 0 auto !important; 26 | } 27 | 28 | .module { 29 | margin: 0 !important; 30 | } 31 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/shittim/js/shittim_util.js: -------------------------------------------------------------------------------- 1 | () => { 2 | // 删除顶栏 侧边栏 3 | document.querySelector('.top-bar').remove(); 4 | document.querySelector('.left-menu').remove(); 5 | 6 | // 删除总体星级 7 | const headList = document.querySelector('.head-list'); 8 | headList.previousElementSibling.remove(); 9 | headList.remove(); 10 | }; 11 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/shittim/templates/base.html.jinja: -------------------------------------------------------------------------------- 1 | {%- import "components.html.jinja" as c -%} 2 | 3 | {#- 4 | Args: 5 | - shittim_url: str 6 | -#} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 |
20 | {%- block content -%}{%- endblock -%} 21 | {{ c.segmentation() }} 22 | {%- call c.footer() -%}数据来源:什亭之匣({{ shittim_url }}){%- endcall -%} 23 |
24 |
25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/shittim/templates/components.html.jinja: -------------------------------------------------------------------------------- 1 | {%- macro title() -%} 2 |
{{ caller() }}
3 | {%- endmacro -%} 4 | 5 | {%- macro chart_title() -%} 6 |
{{ caller() }}
7 | {%- endmacro -%} 8 | 9 | {%- macro small_title() -%} 10 |
{{ caller() }}
11 | {%- endmacro -%} 12 | 13 | {%- macro segmentation() -%} 14 |
15 | {%- endmacro -%} 16 | 17 | {%- macro content() -%} 18 |
{{ caller() }}
19 | {%- endmacro -%} 20 | 21 | {%- macro split_line() -%} 22 |
23 |
{{ caller() }}
24 |
25 | {%- endmacro -%} 26 | 27 | {%- macro footer() -%} 28 | 29 | {%- endmacro -%} 30 | 31 | {%- macro grade_line_top(summaries) -%} 32 | {#- 33 | Args: 34 | - summaries: List[RankSummary] 35 | -#} 36 |
37 | {%- for i in range(3) -%} 38 |
39 | {%- if i == 0 -%} 40 | 41 | {%- elif i == 1 -%} 42 | 43 | {%- else -%} 44 | 45 | {%- endif -%} 46 | {%- set rank = summaries[i] -%} 47 | {%- if rank -%} 48 |
{{ "{:,}".format(rank.best_ranking_point) }}
49 |
{{ rank.hard }} {{ rank.battle_time }}
50 | {%- else -%} 51 |
暂无数据
52 |
-- --:--.---
53 | {%- endif -%} 54 |
55 | {%- endfor -%} 56 |
57 | {%- endmacro -%} 58 | 59 | {%- macro grade_line_by_last_rank(summaries) -%} 60 | {#- 61 | Args: 62 | - summaries: List[RankSummary] 63 | -#} 64 |
65 | {%- for rank in summaries -%} 66 |
67 | {%- if rank.hard_fullname == "INS" -%}{%- set name = "Insane" -%} 68 | {%- else -%}{%- set name = rank.hard_fullname -%}{%- endif -%} 69 |
{{ name }}
70 |
{{ rank.rank }}
71 |
72 | {%- endfor -%} 73 |
74 | {%- endmacro -%} 75 | 76 | {%- macro rank_table(rank_list) -%} 77 | {#- 78 | Args: 79 | - rank_list: List[RankRecord] 80 | -#} 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | {%- for rank in rank_list -%} 102 | 103 | 104 | 105 | 108 | 109 | 110 | 111 | {%- endfor -%} 112 | 113 |
排名分数难度用时刀数
{{ rank.rank }}{{ "{:,}".format(rank.best_ranking_point) }} 106 |
{{ rank.hard }}
107 |
{{ rank.battle_time }}{%- if rank.try_number_infos -%}{{ rank.try_number_infos | length }}{%- else -%}未知{%- endif -%}
114 | {%- endmacro -%} 115 | 116 | {%- macro char_head(char) -%} 117 |
118 | 119 | 120 | 121 | {%- if char.is_assist -%}{%- endif -%} 122 | 123 | 124 |
125 | {%- endmacro -%} 126 | 127 | {%- macro rank_record(record) -%} 128 | {#- 129 | Args: 130 | - record: RankRecord 131 | -#} 132 |
133 |
134 |
135 |
136 |
137 | 138 | 139 | 140 |
141 |
142 |
143 |
ID
144 |
{{ record.nickname }}
145 |
146 |
147 |
Lv
148 |
Lv.{{ record.level }}
149 |
150 |
151 |
152 | 153 |
154 |
第 {{ record.rank }} 名
155 |
{{ record.hard }} {{ "{:,}".format(record.best_ranking_point) }}
156 |
157 |
158 | {%- if record.try_number_infos -%} 159 | {%- for info in record.try_number_infos -%} 160 |
161 |

第 {{ info.try_number }} 刀

162 |
163 | {%- for char in info.main_characters -%}{{ char_head(char) }}{%- endfor -%} 164 |
165 | {%- for char in info.support_characters -%}{{ char_head(char) }}{%- endfor -%} 166 |
167 |
168 | {%- endfor -%} 169 | {%- endif -%} 170 |
171 |
172 | {%- endmacro -%} 173 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/shittim/templates/content_raid_rank.html.jinja: -------------------------------------------------------------------------------- 1 | {%- extends "base.html.jinja" -%} 2 | {%- import "components.html.jinja" as c -%} 3 | 4 | {#- 5 | Args: 6 | - server_name: str 7 | - data_type_name: str 8 | - season: Season 9 | - rank_list_top: List[RankSummary] 10 | - rank_list_by_last_rank: List[RankSummary] 11 | - rank_list: List[RankRecord] 12 | - raid_chart_url: str 13 | - participation_chart_url: str 14 | -#} 15 | 16 | {%- block content -%} 17 | 18 | {%- call c.title() -%}总力战{{ data_type_name }}{%- endcall -%} 19 | {{ c.segmentation() }} 20 | 21 | {%- call c.content() -%} 22 | {{ server_name }} | 23 | 第{{ season.season }}期 {{ season.season_map.value }} {{ season.boss }} | 24 | 更新时间:{{ rank_list[0].record_time.strftime("%Y-%m-%d %H:%M:%S") }} 25 | {%- endcall -%} 26 | {{ c.segmentation() }} 27 | 28 | {%- call c.split_line() -%}各档线分数{%- endcall -%} 29 | {{ c.grade_line_top(rank_list_top) }} 30 | {{ c.segmentation() }} 31 | 32 | {%- call c.split_line() -%}各难度最低排名{%- endcall -%} 33 | {{ c.grade_line_by_last_rank(rank_list_by_last_rank) }} 34 | {{ c.segmentation() }} 35 | 36 | {{ c.rank_table(rank_list) }} 37 | {{ c.segmentation() }} 38 | 39 | {%- call c.content() -%} 40 | {%- call c.chart_title() -%}档线数据时间变化{%- endcall -%} 41 | 42 | {{ c.segmentation() }} 43 | 44 | {%- call c.chart_title() -%}参与人数时间变化{%- endcall -%} 45 | 46 | {%- endcall -%} 47 | 48 | {%- endblock -%} 49 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/resource/res/shittim/templates/content_rank_detail.html.jinja: -------------------------------------------------------------------------------- 1 | {%- extends "base.html.jinja" -%} 2 | {%- import "components.html.jinja" as c -%} 3 | 4 | {#- 5 | Args: 6 | - title: str 7 | - seasons: Dict[int, Season] 8 | - rank_list: List[List[int, RankRecord]] 9 | -#} 10 | 11 | {%- block content -%} 12 | 13 | {%- call c.title() -%}{{ title }}{%- endcall -%} 14 | {{ c.segmentation() }} 15 | 16 | {%- for i, record in rank_list -%} 17 | {% set season = seasons.get(i) %} 18 | {%- call c.small_title() -%}第{{ i }}期 {{ season.season_map.value }} {{ season.boss }}{%- endcall -%} 19 | {{ c.rank_record(record) }} 20 | {%- if not loop.last -%}{{ c.segmentation() }}{%- endif -%} 21 | {%- endfor -%} 22 | 23 | {%- endblock -%} 24 | -------------------------------------------------------------------------------- /nonebot_plugin_bawiki/util.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import itertools 3 | from datetime import datetime, timedelta 4 | from enum import Enum, auto 5 | from io import BytesIO 6 | from pathlib import Path 7 | from typing import ( 8 | Any, 9 | Callable, 10 | Coroutine, 11 | Dict, 12 | Generic, 13 | Iterable, 14 | Iterator, 15 | List, 16 | NamedTuple, 17 | Optional, 18 | Sequence, 19 | Tuple, 20 | TypedDict, 21 | TypeVar, 22 | Union, 23 | cast, 24 | ) 25 | from typing_extensions import ParamSpec, Unpack 26 | from urllib.parse import urljoin 27 | from weakref import WeakSet 28 | 29 | import anyio 30 | from async_lru import _LRUCacheWrapper, alru_cache 31 | from httpx import AsyncClient 32 | from nonebot import logger 33 | from nonebot.adapters.onebot.v11 import ( 34 | Bot, 35 | GroupMessageEvent, 36 | Message, 37 | MessageEvent, 38 | MessageSegment, 39 | ) 40 | from nonebot.matcher import current_matcher 41 | from PIL import Image, ImageOps 42 | from pil_utils import BuildImage 43 | 44 | from .config import config 45 | 46 | T = TypeVar("T") 47 | R = TypeVar("R") 48 | K = TypeVar("K") 49 | V = TypeVar("V") 50 | TC = TypeVar("TC", bound=Callable) 51 | P = ParamSpec("P") 52 | NestedIterable = Iterable[Union[T, Iterable["NestedIterable[T]"]]] 53 | PathType = Union[str, Path, anyio.Path] 54 | SendableType = Union[Message, MessageSegment, str] 55 | 56 | KEY_ILLEGAL_COUNT = "_ba_illegal_count" 57 | 58 | 59 | # region async_req 60 | # 这玩意真的太不优雅了 61 | # 有必要重新写一个 request cache,可以参考 hishel 62 | 63 | 64 | wrapped_cache_functions: "WeakSet['SupportDictCacheWrapper']" = WeakSet() 65 | 66 | 67 | class SupportDictCacheWrapper(Generic[P, R], _LRUCacheWrapper[R]): # type: ignore # ignore final class 68 | def __init__( 69 | self, 70 | fn: Callable[P, Coroutine[Any, Any, R]], 71 | maxsize: Optional[int] = 128, 72 | typed: bool = False, 73 | ttl: Optional[float] = None, 74 | ) -> None: 75 | async def wrapped_func(*args, **kwargs): 76 | new_args, new_kwargs = self._recover_args(args, kwargs) 77 | return await cast(Callable, fn)(*new_args, **new_kwargs) 78 | 79 | super().__init__(wrapped_func, maxsize, typed, ttl) 80 | 81 | def _process_args( 82 | self, 83 | func: Callable[[Any], Any], 84 | args: Tuple, 85 | kwargs: Dict, 86 | ) -> Tuple[Tuple, Dict]: 87 | new_args = tuple(func(arg) for arg in args) 88 | new_kwargs = {} 89 | for k, v in kwargs.items(): 90 | new_kwargs[k] = func(v) 91 | return new_args, new_kwargs 92 | 93 | def _convert_args(self, args: Tuple, kwargs: Dict) -> Tuple[Tuple, Dict]: 94 | return self._process_args( 95 | lambda obj: frozenset(obj.items()) if isinstance(obj, dict) else obj, 96 | args, 97 | kwargs, 98 | ) 99 | 100 | def _recover_args(self, args: Tuple, kwargs: Dict) -> Tuple[Tuple, Dict]: 101 | return self._process_args( 102 | lambda obj: dict(obj) if isinstance(obj, frozenset) else obj, 103 | args, 104 | kwargs, 105 | ) 106 | 107 | async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R: 108 | new_args, new_kwargs = self._convert_args(args, kwargs) 109 | return await super().__call__(*new_args, **new_kwargs) 110 | 111 | 112 | def wrapped_alru_cache( 113 | maxsize: Optional[int] = 128, 114 | typed: bool = False, 115 | ttl: Optional[int] = None, 116 | ): 117 | def wrapper( 118 | func: Callable[P, Coroutine[Any, Any, R]], 119 | ) -> SupportDictCacheWrapper[P, R]: 120 | wrapped = SupportDictCacheWrapper(func, maxsize, typed, ttl) 121 | wrapped_cache_functions.add(wrapped) 122 | return wrapped 123 | 124 | return wrapper 125 | 126 | 127 | class RespType(Enum): 128 | JSON = auto() 129 | TEXT = auto() 130 | BYTES = auto() 131 | HEADERS = auto() 132 | 133 | 134 | class AsyncReqKwargs(TypedDict, total=False): 135 | method: str 136 | params: Optional[Dict[Any, Any]] 137 | headers: Optional[Dict[str, Any]] 138 | content: Union[str, bytes, None] 139 | data: Optional[Dict[str, Any]] 140 | json: Optional[Any] 141 | proxies: Optional[str] 142 | 143 | base_urls: Union[str, List[str]] 144 | resp_type: RespType 145 | retries: int 146 | raise_for_status: bool 147 | sleep: float 148 | 149 | 150 | async def base_async_req(*urls: str, **kwargs: Unpack[AsyncReqKwargs]) -> Any: 151 | if not urls: 152 | raise ValueError("No URL specified") 153 | 154 | method = kwargs.pop("method", "GET").upper() 155 | params = kwargs.pop("params", None) 156 | headers = kwargs.pop("headers", None) 157 | content = kwargs.pop("content", None) 158 | data = kwargs.pop("data", None) 159 | json = kwargs.pop("json", None) 160 | proxies = kwargs.pop("proxies", config.ba_proxy) 161 | 162 | base_urls = kwargs.pop("base_urls", []) 163 | resp_type = kwargs.pop("resp_type", RespType.JSON) 164 | retries = kwargs.pop("retries", config.ba_req_retry) 165 | raise_for_status = kwargs.pop("raise_for_status", True) 166 | sleep = kwargs.pop("sleep", 0) 167 | 168 | if base_urls: 169 | if not isinstance(base_urls, list): 170 | base_urls = [base_urls] 171 | urls = tuple( 172 | itertools.starmap(urljoin, itertools.product(base_urls, urls)), # type: ignore 173 | ) 174 | 175 | async def do_request(current_url: str): 176 | async with AsyncClient( 177 | proxies=proxies, 178 | follow_redirects=True, 179 | timeout=config.ba_req_timeout, 180 | ) as cli: 181 | logger.debug( 182 | f"{method} `{current_url}`, " 183 | f"{params=}, {headers=}, {content=}, {data=}, {json=}", 184 | ) 185 | resp = await cli.request( 186 | method, 187 | current_url, 188 | params=params, 189 | headers=headers, 190 | content=content, 191 | data=data, 192 | json=json, 193 | ) 194 | if raise_for_status: 195 | resp.raise_for_status() 196 | 197 | if sleep: 198 | await asyncio.sleep(sleep) 199 | 200 | if resp_type == RespType.TEXT: 201 | return resp.text 202 | if resp_type == RespType.BYTES: 203 | return resp.content 204 | if resp_type == RespType.HEADERS: 205 | return resp.headers 206 | return resp.json() # default RespType.JSON: 207 | 208 | while True: 209 | url, *rest = urls 210 | try: 211 | return await do_request(url) 212 | except Exception as e: 213 | e_sfx = f"because error occurred while requesting `{url}`: {e!r}" 214 | if retries > 0: 215 | retries -= 1 216 | logger.error(f"Retrying ({retries} left) {e_sfx}") 217 | else: 218 | if not rest: 219 | raise ConnectionError("All retries failed") from e 220 | logger.error(f"Requesting next url `{rest[0]}` {e_sfx}") 221 | url, *rest = rest 222 | logger.opt(exception=e).debug("Error Stack") 223 | 224 | 225 | async_req = wrapped_alru_cache(ttl=config.ba_req_cache_ttl, maxsize=None)( 226 | base_async_req, 227 | ) 228 | 229 | 230 | def clear_wrapped_alru_cache() -> int: 231 | cleared = 0 232 | for wrapped in wrapped_cache_functions: 233 | size = wrapped.cache_info().currsize 234 | wrapped.cache_clear() 235 | cleared += size 236 | return cleared 237 | 238 | 239 | # endregion 240 | 241 | 242 | def format_timestamp(t: int) -> str: 243 | return datetime.fromtimestamp(t).strftime("%Y-%m-%d %H:%M:%S") # noqa: DTZ006 244 | 245 | 246 | def recover_alia(origin: str, alia_dict: Dict[str, List[str]]): 247 | origin = replace_brackets(origin).strip() 248 | origin_ = origin.lower() 249 | 250 | # 精确匹配 251 | for k, li in alia_dict.items(): 252 | if origin_ in li or origin_ == k: 253 | return k 254 | 255 | # 没找到,模糊匹配 256 | origin_ = origin.replace(" ", "") 257 | for k, li in alia_dict.items(): 258 | li = [x.replace(" ", "") for x in ([k, *li])] 259 | for v in li: 260 | if origin_ in v: 261 | return k 262 | 263 | return origin 264 | 265 | 266 | class ParsedTimeDelta(NamedTuple): 267 | days: int 268 | hours: int 269 | minutes: int 270 | seconds: int 271 | 272 | 273 | def parse_time_delta(t: timedelta) -> ParsedTimeDelta: 274 | mm, ss = divmod(t.seconds, 60) 275 | hh, mm = divmod(mm, 60) 276 | dd = t.days or 0 277 | return ParsedTimeDelta(dd, hh, mm, ss) 278 | 279 | 280 | def img_invert_rgba(im: Image.Image) -> Image.Image: 281 | # https://stackoverflow.com/questions/2498875/how-to-invert-colors-of-image-with-pil-python-imaging 282 | r, g, b, a = im.split() 283 | rgb_image = Image.merge("RGB", (r, g, b)) 284 | inverted_image = ImageOps.invert(rgb_image) 285 | r2, g2, b2 = inverted_image.split() 286 | return Image.merge("RGBA", (r2, g2, b2, a)) 287 | 288 | 289 | def replace_brackets(original: str) -> str: 290 | return original.replace("(", "(").replace(")", ")") 291 | 292 | 293 | def splice_msg(msgs: Sequence[Union[str, MessageSegment, Message]]) -> Message: 294 | im = Message() 295 | for i, v in enumerate(msgs): 296 | if isinstance(v, str) and (i != 0): 297 | v = f"\n{v}" 298 | im += v 299 | return im 300 | 301 | 302 | def split_list(lst: Sequence[T], n: int) -> Iterator[Sequence[T]]: 303 | """Yield successive n-sized chunks from lst.""" 304 | for i in range(0, len(lst), n): 305 | yield lst[i : i + n] 306 | 307 | 308 | def split_pic(pic: Image.Image, max_height: int = 4096) -> List[Image.Image]: 309 | pw, ph = pic.size 310 | if ph <= max_height: 311 | return [pic] 312 | 313 | ret = [] 314 | need_merge_last = ph % max_height < max_height // 2 315 | iter_times = ph // max_height 316 | 317 | now_h = 0 318 | for i in range(iter_times): 319 | if i == iter_times - 1 and need_merge_last: 320 | ret.append(pic.crop((0, now_h, pw, ph))) 321 | break 322 | 323 | ret.append(pic.crop((0, now_h, pw, now_h + max_height))) 324 | now_h += max_height 325 | 326 | return ret 327 | 328 | 329 | def i2b(image: Image.Image, img_format: str = "JPEG") -> BytesIO: 330 | buf = BytesIO() 331 | image.save(buf, img_format) 332 | buf.seek(0) 333 | return buf 334 | 335 | 336 | @alru_cache() 337 | async def read_file_cached(path: PathType) -> bytes: 338 | if not isinstance(path, anyio.Path): 339 | path = anyio.Path(path) 340 | return await path.read_bytes() 341 | 342 | 343 | async def read_image(path: PathType) -> BuildImage: 344 | content = await read_file_cached(path) 345 | bio = BytesIO(content) 346 | return BuildImage.open(bio) 347 | 348 | 349 | async def send_forward_msg( 350 | bot: Bot, 351 | event: MessageEvent, 352 | messages: Sequence[Union[str, MessageSegment, Message]], 353 | user_id: Optional[int] = None, 354 | nickname: Optional[str] = None, 355 | ): 356 | nodes: List[MessageSegment] = [ 357 | MessageSegment.node_custom( 358 | int(bot.self_id) if user_id is None else user_id, 359 | "BAWiki" if nickname is None else nickname, 360 | Message(x), 361 | ) 362 | for x in messages 363 | ] 364 | if isinstance(event, GroupMessageEvent): 365 | return await bot.send_group_forward_msg(group_id=event.group_id, messages=nodes) 366 | return await bot.send_private_forward_msg(user_id=event.user_id, messages=nodes) 367 | 368 | 369 | def camel_case(string: str, upper_first: bool = False) -> str: 370 | pfx, *rest = string.split("_") 371 | if upper_first: 372 | pfx = pfx.capitalize() 373 | sfx = "".join(x.capitalize() for x in rest) 374 | return f"{pfx}{sfx}" 375 | 376 | 377 | class IllegalOperationFinisher: 378 | def __init__( 379 | self, 380 | finish_message: Optional[SendableType] = None, 381 | limit: int = config.ba_illegal_limit, 382 | ): 383 | self.finish_message = finish_message 384 | self.limit = limit 385 | 386 | async def __call__( 387 | self, 388 | finish_message: Union[SendableType, None] = Ellipsis, # type: ignore 389 | ): 390 | if self.limit <= 0: 391 | return 392 | matcher = current_matcher.get() 393 | state = matcher.state 394 | 395 | count = state.get(KEY_ILLEGAL_COUNT, 0) + 1 396 | if count >= config.ba_illegal_limit: 397 | await matcher.finish( 398 | self.finish_message if finish_message is Ellipsis else finish_message, 399 | ) 400 | 401 | state[KEY_ILLEGAL_COUNT] = count 402 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "nonebot-plugin-bawiki" 3 | dynamic = ["version"] 4 | description = "A nonebot2 plugin for Blue Archive." 5 | authors = [{ name = "LgCookie", email = "lgc2333@126.com" }] 6 | dependencies = [ 7 | "nonebot2>=2.3.1", 8 | "nonebot-adapter-onebot>=2.4.3", 9 | "nonebot-plugin-htmlrender>=0.3.2", 10 | "nonebot-plugin-apscheduler>=0.5.0", 11 | "beautifulsoup4>=4.12.3", 12 | "lxml>=5.2.2", 13 | "pil-utils>=0.1.10", 14 | "httpx>=0.27.0", 15 | "pypinyin>=0.51.0", 16 | "yarl>=1.9.4", 17 | "async-lru>=2.0.4", 18 | "matplotlib>=3.9.0", 19 | "pytz>=2024.1", 20 | ] 21 | requires-python = ">=3.9,<4.0" 22 | readme = "README.md" 23 | license = { text = "MIT" } 24 | keywords = ["blue archive", "nonebot", "nonebot2", "bot", "qq"] 25 | classifiers = [ 26 | "Natural Language :: Chinese (Simplified)", 27 | "Topic :: Communications", 28 | "Topic :: Communications :: Chat", 29 | "Topic :: Communications :: Chat :: ICQ", 30 | "Topic :: Games/Entertainment", 31 | "Topic :: Games/Entertainment :: Board Games", 32 | "Topic :: Games/Entertainment :: Turn Based Strategy", 33 | "Topic :: Games/Entertainment :: Side-Scrolling/Arcade Games", 34 | "Topic :: Internet", 35 | "Topic :: Internet :: WWW/HTTP", 36 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 37 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content :: Wiki", 38 | ] 39 | 40 | [project.urls] 41 | homepage = "https://github.com/lgc2333/nonebot-plugin-bawiki/" 42 | 43 | [project.optional-dependencies] 44 | menu = [ 45 | "nonebot-plugin-PicMenu>=0.2", 46 | ] 47 | 48 | [tool.pdm.version] 49 | source = "file" 50 | path = "nonebot_plugin_bawiki/__init__.py" 51 | 52 | [tool.pdm.build] 53 | includes = [] 54 | 55 | [build-system] 56 | requires = ["pdm-backend"] 57 | build-backend = "pdm.backend" 58 | --------------------------------------------------------------------------------