Compare commits

...

84 Commits

Author SHA1 Message Date
XcantloadX 16360f5764 docs: v2025.7.13.0 更新日志 2025-07-13 12:12:57 +08:00
XcantloadX a4d3b322e0 Merge branch 'dev' 2025-07-13 12:10:50 +08:00
XcantloadX 4bea42238f fix(ui): 修复某些情况下热重载配置失败的问题
原因是上下文初始化前就调用了 config.load() 导致报错。
2025-07-13 12:06:29 +08:00
XcantloadX 5db3ed6526 feat(boostrap): 启动器 EXE 新增多分辨率图标 2025-07-13 10:08:35 +08:00
XcantloadX 5cc9f454ee chore: 增加对 Python 信息与分辨率信息的日志输出 2025-07-12 10:21:27 +08:00
XcantloadX a8a5566f00 feat(task): 上传报告时一并打包配置文件 2025-07-11 22:12:32 +08:00
XcantloadX 63f792db2d fix(bootstrap): 修复当文件夹路径存在空格时启动器无法正确启动 kaa 的问题 2025-07-11 22:08:03 +08:00
XcantloadX 05a69ad947 docs: v2025.7.9.0 更新日志 2025-07-09 12:58:14 +08:00
XcantloadX 8216310173 Merge branch 'dev' 2025-07-09 12:57:48 +08:00
XcantloadX ca83fec19d test: 为新的培育方案编写单元测试 2025-07-08 19:51:16 +08:00
XcantloadX ef725b4e6f chore: v5 到 v6 配置迁移脚本 2025-07-08 19:51:16 +08:00
XcantloadX 68b0cbda73 feat(ui): 为新的培育方案增加 UI 2025-07-08 19:49:54 +08:00
XcantloadX 41e7c8b4a8 feat(task): 配置中支持储存多个培育方案并支持来回切换 2025-07-08 19:48:40 +08:00
XcantloadX 4e4b91d670 refactor(task): 将配置数据中的常量移动到单独一个文件中 2025-07-07 21:22:15 +08:00
XcantloadX e548518dcd fix(task): 尝试修复周数 OCR 失败问题
Fixed #26
2025-07-07 20:44:18 +08:00
XcantloadX a0d3c31b6b feat(core): ContextOcr 类支持设置 OCR 语言 2025-07-07 20:42:20 +08:00
XcantloadX 0651d949d7 fix(task): 修复某些情况下培育会卡在初始饮料技能卡二选一上 2025-07-07 18:36:28 +08:00
XcantloadX 497561c721 fix(task): 修复部分日志缺失的问题
原因是调用 logging.basicConfig 的时机不正确
2025-07-07 18:10:28 +08:00
XcantloadX c7d5cd88d6 refactor(task): 配置迁移代码移动到单独的模块 2025-07-07 18:10:22 +08:00
XcantloadX e0549c6b85 refactor(task): 将配置文件类从 kotonebot.kaa.common 中移动到专门的模块 kotonebot.kaa.config 2025-07-07 18:10:07 +08:00
XcantloadX c3d24018db feat(ui): 新增快速功能启停 2025-07-07 17:43:36 +08:00
XcantloadX 6dd2b3510b fix(ui): 修复修改设置后需要重启才能生效的问题 2025-07-07 15:32:36 +08:00
XcantloadX 7ce4b17fb2 docs: v2025.7.7.0 更新日志 2025-07-07 10:08:58 +08:00
XcantloadX c6b52a599f Merge branch 'dev' 2025-07-07 10:02:47 +08:00
XcantloadX b8b56bbf4c fix(task): 修复 AP 商店购买时点击坐标偏移问题 2025-07-07 09:21:05 +08:00
XcantloadX 353fa3fcb2 fix(task): 修复商店购买时无法识别部分偶像碎片的问题 2025-07-06 20:51:33 +08:00
XcantloadX 03aa2b508c chore: 删除一些无用 Sprite 资源 2025-07-06 20:30:32 +08:00
XcantloadX 9b37bcf541 feat(ui): UI 支持向局域网开放 2025-07-06 20:28:42 +08:00
XcantloadX 68dbc487e8 feat(task): 金币商店可选使用每日刷新次数 2025-07-06 19:38:10 +08:00
XcantloadX f5a4e50611 fix(task): 修复 DMM 版购买时无法进入 AP 商店的问题
原因是分辨率缩放导致识别结果 confidence 下降,导致没有识别出来。
Fixed #40
2025-07-06 17:33:14 +08:00
XcantloadX cf1605d913 fix(task): 修复商店购买有几率卡在确认购买对话框上 2025-07-06 17:30:10 +08:00
XcantloadX 3b3aac65dc fix(task): 修复某些情况下无法自动关闭领取活动费后的弹窗 2025-07-06 16:34:27 +08:00
XcantloadX c8fbf80640 feat(core): 新增目标截图间隔功能
可以通过设置目标截图间隔来限制截图速度,间接限制脚本运行速度。
2025-07-05 22:14:13 +08:00
XcantloadX 0e183b0ca6 docs: v2025.7.5.0 更新日志 2025-07-05 17:34:21 +08:00
XcantloadX 8e5fcaf4fc Merge branch 'dev' 2025-07-05 17:29:08 +08:00
XcantloadX 50d1403825 fix(task): 修复有几率无法识别到进行中培育的问题
原因是 OCR 没有识别到“中”字。现在换成了检测再开与新开各自 UI 上独有的元素。

Fixed #52
2025-07-03 18:24:58 +08:00
XcantloadX f2eadad7eb fix(task): 修复分辨率缩放导致无法识别到菜单按钮
Fixed #53
2025-07-03 17:40:58 +08:00
XcantloadX 5306f5c875 refactor(core): 移除 Device.pinned 方法 2025-07-03 17:25:03 +08:00
XcantloadX d9077e74e2 fix(task): 修复分辨率缩放时无法检测到工作完成状态的问题 2025-07-03 17:23:40 +08:00
XcantloadX a6bf0330cd docs: v2025.7.3.0 更新日志 2025-07-03 09:16:04 +08:00
XcantloadX 66ea531ef3 chore: Git 记录提取工具支持 bootstrap scope 2025-07-03 09:15:32 +08:00
XcantloadX b325a20b60 Merge branch 'feat/launcher' 2025-07-03 09:09:58 +08:00
XcantloadX 456019b5b5 feat(bootstrap): 新启动器现在支持安装指定版本与指定补丁 2025-07-02 22:47:03 +08:00
XcantloadX 3f88c3a6c4 feat(bootstrap): 自动更新可禁用 2025-07-02 22:21:08 +08:00
XcantloadX b377b8445e feat(bootstrap): 启动器 C++ EXE 跳板程序 2025-07-02 22:21:08 +08:00
XcantloadX c4b93f40d6 feat(bootstrap): 新启动器 2025-06-30 21:47:36 +08:00
XcantloadX 02860b6014 docs: v2025.6.28.0 更新日志 2025-06-28 17:48:26 +08:00
XcantloadX e8851a683d fix(core): 修复 Device 中缩放与截图 Hook 的处理顺序不正确问题 2025-06-28 17:47:54 +08:00
XcantloadX 9935087753 Merge branch 'dev' 2025-06-28 14:41:29 +08:00
XcantloadX b53a0555e2 fix(task): 修正 debug_entry 脚本路径处理逻辑 2025-06-28 14:40:31 +08:00
XcantloadX 1397587415 chore: HsvRangeTool 支持从剪贴板粘贴图片 2025-06-28 14:36:45 +08:00
XcantloadX ad5c7b700b fix(task): 修复清理日志功能失效的问题 2025-06-27 14:26:24 +08:00
XcantloadX 9574d2073a feat(ui): 优化日志导出功能
新增了标题、描述字段,导出报告支持上传与保存本地
2025-06-27 14:25:56 +08:00
XcantloadX 08a7e71881 feat(ui): 为 UI 增加部分配置有效性验证 2025-06-27 14:01:57 +08:00
XcantloadX a01c37d0fc feat(ui): 加入调试模式警告提示 2025-06-27 13:39:40 +08:00
XcantloadX 2469fc09df feat(ui): 画面 Tab 刷新机制由自动改为手动 2025-06-27 13:31:54 +08:00
XcantloadX 71170e5c0c feat(ui): MuMu12 保活模式可选开启 2025-06-27 11:22:46 +08:00
XcantloadX 87da282530 fix(core): 修复 NemuIpc 在多显示器下的坐标系问题 2025-06-27 11:22:46 +08:00
XcantloadX 0c94acff1b feat(core): NemuIpcImpl 获取显示器 ID 支持自动重试 2025-06-27 11:22:07 +08:00
XcantloadX e2328264a7 feat(core): 支持 MuMu12 后台保活模式 2025-06-27 11:21:38 +08:00
XcantloadX e62e65da4c fix(core): 修复当目标分辨率与实际分辨率旋转不同时截图会强制拉伸的问题 2025-06-25 22:43:09 +08:00
XcantloadX bcd8cf2874 chore(core): 调整部分日志输出格式 2025-06-25 20:37:30 +08:00
XcantloadX d10a098383 Merge branch 'feat/screenshot-nemu-ipc' into dev 2025-06-25 20:24:36 +08:00
XcantloadX 6f31bab85b Merge branch 'feat/scaler' into dev 2025-06-25 20:24:24 +08:00
XcantloadX 677932acb7 fix(core): 缩放处理支持自动识别旋转 2025-06-25 20:21:50 +08:00
XcantloadX 105a894a5c fix(core): 修复 NemuIpc 截图无法响应屏幕旋转导致的分辨率变化 2025-06-25 18:51:06 +08:00
XcantloadX f01e0224cb refactor(core): 组装 Device 改用 recipe 方案
原来组装 Device 的代码放在每个 Impl 文件下实现,通过
@register_impl 装饰器注册组装函数,然后通过统一接口
组装。现在将所有组装代码移动到了 Host 实现下,Impl 实现
只需要实现自身。
2025-06-25 18:49:08 +08:00
XcantloadX d4e858a2c0 docs: v2025.6.23.0 更新日志 2025-06-23 16:51:37 +08:00
XcantloadX 810c34156b chore(deps): 更新上游 submodules 2025-06-23 16:29:17 +08:00
XcantloadX 07186787b4 chore: VSCode 新增不初始化 context 的启动配置 2025-06-23 16:26:31 +08:00
XcantloadX 784b8ed291 refactor(core): 将传递的 MuMu 路径从 shell 目录改为 MuMu 根目录 2025-06-23 16:26:31 +08:00
XcantloadX f0b91814f7 feat(core): 引入 Nemu 截图与控制方式 2025-06-23 16:26:31 +08:00
XcantloadX 415a8dfc7d feat(core): 移除 WindowsImpl 中的分辨率缩放
因为目前在 Device 类中已有缩放处理。
2025-06-23 00:32:34 +08:00
XcantloadX b0e77e2173 Merge branch 'refactor/produce-start' 2025-06-14 20:45:58 +08:00
XcantloadX 3ceae4c359 fix(task): 修复 debug_entry 执行的脚本没有正确输出日志的问题 2025-06-14 20:14:38 +08:00
XcantloadX bb7f6038a2 feat(core): 支持等比例分辨率缩放 2025-06-14 20:14:37 +08:00
XcantloadX 1ced1e3714 Merge branch 'refactor/client' 2025-06-14 20:13:56 +08:00
XcantloadX 4d76e1a9e8 fix(core): 修复由于分离 AndroidDevice 方法导致的 typing 问题 2025-06-14 20:04:26 +08:00
XcantloadX 16a267de79 refactor(core): 将 Commandable 分离为 WindowsCommandable 与 AndroidCommandable 2025-06-14 20:04:01 +08:00
XcantloadX b8b5ba8a98 refactor(core): 将启动 remote_server 的逻辑移动到 kaa cli 中 2025-06-10 23:00:46 +08:00
XcantloadX bd57dc45be refactor(core): 提取三个 adb-based 截图方法的工厂函数的共同部分 2025-06-10 23:00:46 +08:00
XcantloadX f2599e6dfd refactor(core): 将创建设备的逻辑从 init_context 中移除 2025-06-10 23:00:46 +08:00
XcantloadX 2fc9ad5200 refactor(core): 重构 Device 与 Impl 的创建方式
现在允许 Impl 存在构造参数,并允许下游脚本传递参数给 Impl。
2025-06-10 23:00:33 +08:00
XcantloadX 86313ec52a feat(task): 优化了培育开始的逻辑,修复若干 bug
1. 修复了进入难度选择页面时,若当前为 NIA 培育,不会自动切换到 Hajime 培育的问题。
2. 修复了因 OCR 识别失败导致的无法选择难度问题,同时提高了识别速度。
3. 修复了因网络速度过慢导致脚本卡在选择回忆编成上。
4. 现在若默认选中的偶像已是目标偶像,不会再尝试重复选择。

Fixed #44.
2025-06-01 20:39:11 +08:00
150 changed files with 8243 additions and 1943 deletions

1
.gitignore vendored
View File

@ -10,6 +10,7 @@ kotonebot-ui/.vite
dumps*/
config.json
config.v*.json
conf/
reports/
tmp/
res/sprites_compiled/

8
.vscode/launch.json vendored
View File

@ -24,6 +24,14 @@
],
// "module": "${command:extension.commandvariable.file.relativeDirDots}.${fileBasenameNoExtension}",
},
{
"name": "Python: Current Module(Without context)",
"type": "python",
"request": "launch",
"console": "integratedTerminal",
"module": "${command:extension.commandvariable.file.relativeDirDots}.${fileBasenameNoExtension}",
},
{
"name": "KotonebotDebug: Current Module",

View File

@ -20,5 +20,5 @@
"venv",
"**/node_modules"
],
"python.analysis.diagnosticMode": "workspace"
// "python.analysis.diagnosticMode": "workspace"
}

View File

@ -76,6 +76,9 @@ kaa 的开发主要用到了以下开源项目:
](https://github.com/AllenHeartcore/GkmasObjectManager):用于提取游戏图像资源,以 GPLv3 协议开源。
* [gakumasu-diff](https://github.com/vertesan/gakumasu-diff):游戏数据。
kaa 的开发还参考了以下开源项目:
* [EmulatorExtras](https://github.com/MaaXYZ/EmulatorExtras)MuMu 与雷电模拟器的截图与控制接口定义。
* [blue_archive_auto_script](https://github.com/pur1fying/blue_archive_auto_script)MuMu 与雷电模拟器的截图与控制接口的 Python 实现,以及各模拟器的控制实现。
## 免责声明
**请在使用本项目前仔细阅读以下内容。使用本脚本将带来包括但不限于账号被封禁的风险。**

View File

@ -1,5 +1,134 @@
# 更新日志
## kaa
### v2025.7.13.0
脚本:
* [新增] 上传报告时一并打包配置文件(#a8a5566
界面:
* [修复] 修复某些情况下热重载配置失败的问题(#4bea422
启动器:
* [修复] 修复当文件夹路径存在空格时启动器无法正确启动 kaa 的问题(#63f792d
* [新增] 启动器 EXE 新增多分辨率图标(#5db3ed6
其他:
* [其他] 增加对 Python 信息与分辨率信息的日志输出(#5cc9f45
### v2025.7.9.0
脚本:
* [新增] 配置中支持储存多个培育方案并支持来回切换(#41e7c8b
* [重构] 将配置数据中的常量移动到单独一个文件中(#4e4b91d
* [重构] 配置迁移代码移动到单独的模块(#c7d5cd8
* [重构] 将配置文件类从 kotonebot.kaa.common 中移动到专门的模块 kotonebot.kaa.config#e0549c6
* [修复] 尝试修复周数 OCR 失败问题(#e548518
* [修复] 修复某些情况下培育会卡在初始饮料技能卡二选一上(#0651d94
* [修复] 修复部分日志缺失的问题(#497561c
* [重构] 配置迁移代码移动到单独的模块(#c7d5cd8
* [重构] 将配置文件类从 kotonebot.kaa.common 中移动到专门的模块 kotonebot.kaa.config#e0549c6
* [修复] 尝试修复周数 OCR 失败问题(#e548518
* [修复] 修复某些情况下培育会卡在初始饮料技能卡二选一上(#0651d94
* [修复] 修复部分日志缺失的问题(#497561c
* [修复] 修复部分日志缺失的问题(#497561c
界面:
* [新增] 为新的培育方案增加 UI#68b0cbd
* [新增] 新增快速功能启停(#c3d2401
* [修复] 修复修改设置后需要重启才能生效的问题(#6dd2b35
框架:
* [新增] ContextOcr 类支持设置 OCR 语言(#a0d3c31
其他:
* [单测] 为新的培育方案编写单元测试(#ca83fec
* [其他] v5 到 v6 配置迁移脚本(#ef725b4
### v2025.7.7.0
脚本:
* [修复] 修复 AP 商店购买时点击坐标偏移问题(#b8b56bb
* [修复] 修复商店购买时无法识别部分偶像碎片的问题(#353fa3f
* [修复] 修复 DMM 版购买时无法进入 AP 商店的问题(#f5a4e50
* [修复] 修复商店购买有几率卡在确认购买对话框上(#cf1605d
* [修复] 修复某些情况下无法自动关闭领取活动费后的弹窗(#3b3aac6
* [新增] 金币商店可选使用每日刷新次数(#68dbc48
界面:
* [新增] UI 支持向局域网开放(#9b37bcf
框架:
* [新增] 新增目标截图间隔功能(#c8fbf80
其他:
* [其他] 删除一些无用 Sprite 资源(#03aa2b5
### v2025.7.5.0
脚本:
* [修复] 修复有几率无法识别到进行中培育的问题(#50d1403
* [修复] 修复分辨率缩放导致无法识别到菜单按钮(#f2eadad
* [修复] 修复分辨率缩放时无法检测到工作完成状态的问题(#d9077e7
框架:
* [重构] 移除 Device.pinned 方法(#5306f5c
### v2025.7.3.0
启动器:
* [新增] 新启动器现在支持安装指定版本与指定补丁(#456019b
* [新增] 自动更新可禁用(#3f88c3a
* [新增] 启动器 C++ EXE 跳板程序(#b377b84
* [新增] 新启动器(#c4b93f4
其他:
* [其他] Git 记录提取工具支持 bootstrap scope#66ea531
### v2025.6.28.0
脚本:
* [修复] 修正 debug_entry 脚本路径处理逻辑(#b53a055
* [修复] 修复清理日志功能失效的问题(#ad5c7b7
* [修复] 修复 debug_entry 执行的脚本没有正确输出日志的问题(#3ceae4c
界面:
* [新增] 优化日志导出功能(#9574d20
* [新增] 为 UI 增加部分配置有效性验证(#08a7e71
* [新增] 加入调试模式警告提示(#a01c37d
* [新增] 画面 Tab 刷新机制由自动改为手动(#2469fc0
* [新增] MuMu12 保活模式可选开启(#71170e5
框架:
* [修复] 修复 Device 中缩放与截图 Hook 的处理顺序不正确问题
* [修复] 修复 NemuIpc 在多显示器下的坐标系问题(#87da282
* [修复] 修复当目标分辨率与实际分辨率旋转不同时截图会强制拉伸的问题(#e62e65d
* [修复] 缩放处理支持自动识别旋转(#677932a
* [修复] 修复 NemuIpc 截图无法响应屏幕旋转导致的分辨率变化(#105a894
* [新增] NemuIpcImpl 获取显示器 ID 支持自动重试(#0c94acf
* [新增] 支持 MuMu12 后台保活模式(#e232826
* [新增] 引入 Nemu 截图与控制方式(#f0b9181
* [新增] 移除 WindowsImpl 中的分辨率缩放(#415a8df
* [新增] 支持等比例分辨率缩放(#bb7f603
* [重构] 组装 Device 改用 recipe 方案(#f01e022
* [重构] 将传递的 MuMu 路径从 shell 目录改为 MuMu 根目录(#784b8ed
其他:
* [其他] HsvRangeTool 支持从剪贴板粘贴图片(#1397587
* [其他] VSCode 新增不初始化 context 的启动配置(#0718678
* [其他] 调整部分日志输出格式(#bcd8cf2
### v2025.6.23.0
脚本:
* [新增] 优化了培育开始的逻辑,修复若干 bug#86313ec
框架:
* [修复] 修复由于分离 AndroidDevice 方法导致的 typing 问题(#4d76e1a
* [重构] 将 Commandable 分离为 WindowsCommandable 与 AndroidCommandable#16a267d
* [重构] 将启动 remote_server 的逻辑移动到 kaa cli 中(#b8b5ba8
* [重构] 提取三个 adb-based 截图方法的工厂函数的共同部分(#bd57dc4
* [重构] 将创建设备的逻辑从 init_context 中移除(#f2599e6
* [重构] 重构 Device 与 Impl 的创建方式(#2fc9ad5
其他:
* [其他] 更新上游 submodules#810c341
### v2025.6.8.0
脚本:
* [新增] 新增支持自动禁用与恢复 Gakumasu Localify 汉化插件(#264dac2

16
bootstrap/README.md Normal file
View File

@ -0,0 +1,16 @@
# bootstrap
此文件夹下存放的是为了简化分发而编写的启动器代码。
## bootstrap/kaa-bootstrap
启动器本体,由 Python 编写。负责:
1. 寻找最快的 PyPI 镜像源
2. 安装与更新 pip 和 kaa 本体
3. 读入配置文件,检查是否需要管理员权限
4. 启动 kaa
打包产物bootstrap.pyz
## bootstrap/kaa-wrapper
启动器包装器,由 C++ 编写,用于调用 Python 启动 kaa-bootstrap。
打包产物kaa.exe

View File

@ -0,0 +1,7 @@
from terminal import print_status
from launcher import main_launch
try:
main_launch()
except KeyboardInterrupt:
print_status("运行结束", status='info')

View File

@ -0,0 +1,754 @@
import os
import sys
import json
import ctypes
import codecs
import locale
import logging
import subprocess
import importlib.metadata
import argparse
import tempfile
import zipfile
import shutil
from pathlib import Path
from collections import deque
from datetime import datetime
from time import sleep
from typing import Optional, Dict, Any, TypedDict, Literal, List
from request import head, HTTPError, NetworkError
from terminal import (
Color, print_header, print_status, clear_screen,
get_terminal_width, get_display_width, truncate_string,
hide_cursor, show_cursor, move_cursor_up, wait_key, get_terminal_height
)
from repo import Version
# 配置文件的类型定义
class BackendConfig(TypedDict, total=False):
type: Literal['custom', 'mumu12', 'leidian', 'dmm']
screenshot_impl: Literal['adb', 'adb_raw', 'uiautomator2', 'windows', 'remote_windows', 'nemu_ipc']
class MiscConfig(TypedDict, total=False):
check_update: Literal['never', 'startup']
auto_install_update: bool
class UserConfig(TypedDict, total=False):
name: str
id: str
category: str
description: str
backend: BackendConfig
keep_screenshots: bool
options: Dict[str, Any] # 这里包含 misc 等配置
class Config(TypedDict, total=False):
version: int
user_configs: List[UserConfig]
# 获取当前Python解释器路径
PYTHON_EXECUTABLE = sys.executable
TRUSTED_HOSTS = "pypi.org files.pythonhosted.org pypi.python.org mirrors.aliyun.com mirrors.cloud.tencent.com mirrors.tuna.tsinghua.edu.cn"
def setup_logging():
"""
配置日志记录
"""
log_dir = Path("logs")
log_dir.mkdir(exist_ok=True)
timestamp = datetime.now().strftime("%y-%m-%d-%H-%M-%S")
log_file = log_dir / f"bootstrap-{timestamp}.log"
logging.basicConfig(
level=logging.DEBUG,
format='[%(asctime)s][%(levelname)s][%(filename)s:%(lineno)d] %(message)s',
filename=log_file,
filemode='w',
encoding='utf-8'
)
# 记录未捕获的异常
def handle_exception(exc_type, exc_value, exc_traceback):
if issubclass(exc_type, KeyboardInterrupt):
sys.__excepthook__(exc_type, exc_value, exc_traceback)
return
logging.error("未捕获的异常", exc_info=(exc_type, exc_value, exc_traceback))
sys.excepthook = handle_exception
logging.info("日志记录器已初始化。")
PIP_SERVERS = [
"https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple",
"https://mirrors.aliyun.com/pypi/simple",
"https://mirrors.cloud.tencent.com/pypi/simple",
"https://pypi.org/simple",
]
def is_admin() -> bool:
"""
检查当前进程是否具有管理员权限
:return: 如果具有管理员权限返回True否则返回False
:rtype: bool
"""
try:
return ctypes.windll.shell32.IsUserAnAdmin()
except:
return False
def test_url_availability(url: str) -> bool:
"""
测试URL是否可访问返回200状态码
:param url: 要测试的URL
:type url: str
:return: 如果URL可访问返回True否则返回False
:rtype: bool
"""
try:
with head(url, timeout=10) as response:
return response.status_code == 200
except (HTTPError, NetworkError):
return False
except Exception:
return False
def get_working_pip_server() -> Optional[str]:
"""
获取可用的pip服务器
:return: 第一个可用的pip服务器URL如果都不可用返回None
:rtype: Optional[str]
"""
for server in PIP_SERVERS:
msg = f"正在测试: {server}"
print_status(msg, status='info', indent=1)
logging.info(msg)
if test_url_availability(server):
msg = f"找到可用的pip服务器: {server}"
print_status(msg, status='success', indent=1)
logging.info(msg)
return server
msg = "所有pip服务器都不可用"
print_status(msg, status='error')
logging.error(msg)
return None
def package_version(package_name: str) -> Optional[str]:
"""
获取指定包的版本信息
:param package_name: 包名称
:type package_name: str
:return: 包版本字符串如果包不存在则返回 None
:rtype: Optional[str]
:Example:
.. code-block:: python
>>> package_version("requests")
'2.31.0'
>>> package_version("nonexistent_package")
None
:raises: 无异常抛出包不存在时返回 None
"""
try:
return importlib.metadata.version(package_name)
except importlib.metadata.PackageNotFoundError:
return None
def run_command(command: str, check: bool = True, verbatim: bool = False, scroll_region_size: int = -1, log_output: bool = True) -> bool:
"""
运行命令并实时输出返回是否成功
:param command: 要运行的命令
:param check: 是否检查返回码
:param verbatim: 是否原样输出不使用滚动UI
:param scroll_region_size: 滚动区域的大小, -1 表示动态计算
:param log_output: 是否将命令输出记录到日志中
:return: 命令是否成功执行
"""
logging.info(f"执行命令: {command}")
# 设置环境变量以确保正确的编码处理
env = os.environ.copy()
env["PYTHONIOENCODING"] = "utf-8"
# 获取系统默认编码
system_encoding = locale.getpreferredencoding()
# 创建解码器
def decode_output(line: bytes) -> str:
try:
# 首先尝试UTF-8解码
return line.decode('utf-8')
except UnicodeDecodeError:
try:
# 如果UTF-8失败尝试系统默认编码
return line.decode(system_encoding)
except UnicodeDecodeError:
# 如果都失败了,使用'replace'策略
return line.decode('utf-8', errors='replace')
if verbatim:
print(f"▶ 执行命令: {command}")
try:
process = subprocess.Popen(
command, shell=True,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
env=env
)
if process.stdout:
for line in iter(process.stdout.readline, b''):
clean_line = decode_output(line).strip('\n')
print(clean_line)
if log_output:
logging.info(clean_line)
returncode = process.wait()
logging.info(f"命令执行完毕,返回码: {returncode}")
return returncode == 0
except FileNotFoundError:
msg = f"命令未找到: {command.split()[0]}"
print_status(msg, status='error', indent=1)
logging.error(msg)
return False
except Exception as e:
msg = f"命令执行时发生错误: {e}"
print_status(msg, status='error', indent=1)
logging.error(msg, exc_info=True)
return False
# --- 滚动UI模式 ---
if scroll_region_size == -1:
# Heuristic: leave some lines for context above and below.
# Use at least 5 lines.
SCROLL_REGION_SIZE = max(5, get_terminal_height() - 8)
else:
SCROLL_REGION_SIZE = scroll_region_size
terminal_width = get_terminal_width()
# 打印初始状态行
prefix = ""
prefix_width = 2 # "▶ "
available_width = terminal_width - prefix_width
command_text = f"执行命令: {command}"
truncated_command = truncate_string(command_text, available_width)
padding_len = available_width - get_display_width(truncated_command)
padding = ' ' * max(0, padding_len)
print(f"{Color.GRAY}{prefix}{truncated_command}{padding}{Color.RESET}")
process = subprocess.Popen(
command, shell=True,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
env=env
)
output_buffer = deque(maxlen=SCROLL_REGION_SIZE)
lines_rendered = 0
hide_cursor()
try:
if process.stdout:
for line in iter(process.stdout.readline, b''):
stripped_line = decode_output(line).strip()
output_buffer.append(stripped_line)
if log_output:
logging.info(stripped_line)
# 移动光标到绘制区域顶部
if lines_rendered > 0:
move_cursor_up(lines_rendered)
lines_rendered = min(len(output_buffer), SCROLL_REGION_SIZE)
# 重新绘制滚动区域
lines_to_render = list(output_buffer)
for i in range(lines_rendered):
line_to_print = lines_to_render[i] if i < len(lines_to_render) else ""
prefix = f"{Color.GRAY}|{Color.RESET} "
prefix_width = 2
available_width = terminal_width - prefix_width
truncated = truncate_string(line_to_print, available_width)
padding_len = available_width - get_display_width(truncated)
padding = ' ' * max(0, padding_len)
# 使用 \r 和 \n 刷新行
print(f"\r{prefix}{truncated}{padding}")
returncode = process.wait()
logging.info(f"命令执行完毕,返回码: {returncode}")
finally:
show_cursor()
# 清理滚动区域
if lines_rendered > 0:
move_cursor_up(lines_rendered)
for _ in range(lines_rendered):
print(' ' * terminal_width)
move_cursor_up(lines_rendered)
# 更新最终状态行
move_cursor_up(1)
if returncode == 0:
final_symbol = f"{Color.GREEN}"
success = True
else:
final_symbol = f"{Color.RED}"
success = False
# 重新计算填充以确保行被完全覆盖
final_prefix = f"{final_symbol} "
final_prefix_width = 2 # "✓ " or "✗ "
available_width = terminal_width - final_prefix_width
final_line_text = f"执行命令: {command}"
truncated_final_line = truncate_string(final_line_text, available_width)
padding_len = available_width - get_display_width(truncated_final_line)
padding = ' ' * max(0, padding_len)
print(f"\r{final_prefix}{truncated_final_line}{Color.RESET}{padding}")
if check and not success:
msg = f"命令执行失败,返回码: {returncode}"
print_status(msg, status='error', indent=1)
logging.error(msg)
return False
return success
def check_ksaa_update_available(pip_server: str, current_version: Version) -> tuple[bool, Version | None, Version | None]:
"""
检查ksaa包是否有新版本可用
:param pip_server: pip服务器URL
:type pip_server: str
:param current_version: 当前版本
:type current_version: Version
:return: (是否有更新, 当前版本, 最新版本)
:rtype: tuple[bool, Optional[Version], Optional[Version]]
"""
try:
# 使用repo.py中的list_versions函数和Version类获取最新版本信息
from repo import list_versions, Version
try:
versions = list_versions("ksaa", server_url=pip_server)
if versions and len(versions) > 0:
latest_version = versions[0].version
# 使用Version类的比较功能
if latest_version > current_version:
return True, current_version, latest_version
except Exception as e:
logging.warning(f"从服务器 {pip_server} 获取版本信息失败: {e}")
print_status(f"从服务器 {pip_server} 获取版本信息失败: {e}", status='error')
# 如果指定服务器失败尝试使用默认PyPI服务器
try:
versions = list_versions("ksaa")
if versions and len(versions) > 0:
latest_version = versions[0].version
# 使用Version类的比较功能
if latest_version > current_version:
return True, current_version, latest_version
except Exception as e2:
logging.warning(f"从PyPI获取版本信息也失败: {e2}")
return False, current_version, latest_version if 'latest_version' in locals() else None
except Exception as e:
logging.warning(f"检查ksaa更新时发生错误: {e}")
return False, None, None
def print_update_notice(current_version: str, latest_version: str):
"""
打印更新提示信息
:param current_version: 当前版本
:type current_version: str
:param latest_version: 最新版本
:type latest_version: str
"""
clear_screen()
print()
print(f"{Color.YELLOW}{Color.BOLD}" + "=" * 60)
print(f"{Color.YELLOW}{Color.BOLD}⚠️ 发现新版本可用!")
print(f"{Color.YELLOW}{Color.BOLD}" + "=" * 60)
print(f"{Color.YELLOW}当前版本: {current_version}")
print(f"{Color.YELLOW}最新版本: {latest_version}")
print(f"{Color.YELLOW}建议开启自动更新或在设置中手动安装新版本。")
print(f"{Color.YELLOW}5s 后继续启动")
print(f"{Color.YELLOW}{Color.BOLD}" + "=" * 60 + f"{Color.RESET}")
print()
sleep(5)
def install_ksaa_version(pip_server: str, trusted_hosts: str, version: str) -> bool:
"""
安装指定版本的ksaa包
:param pip_server: pip服务器URL
:type pip_server: str
:param trusted_hosts: 信任的主机列表
:type trusted_hosts: str
:param version: 要安装的版本号
:type version: str
:return: 安装是否成功
:rtype: bool
"""
print_status(f"安装琴音小助手 v{version}", status='info')
install_command = f'"{PYTHON_EXECUTABLE}" -m pip install --index-url {pip_server} --trusted-host "{trusted_hosts}" --no-warn-script-location ksaa=={version}'
return run_command(install_command)
def install_ksaa_from_zip(zip_path: str) -> bool:
"""
从zip文件安装ksaa包
:param zip_path: zip文件路径
:type zip_path: str
:return: 安装是否成功
:rtype: bool
"""
zip_file = Path(zip_path)
if not zip_file.exists():
msg = f"zip文件不存在: {zip_path}"
print_status(msg, status='error')
logging.error(msg)
return False
if not zip_file.suffix.lower() == '.zip':
msg = f"文件不是zip格式: {zip_path}"
print_status(msg, status='error')
logging.error(msg)
return False
print_status(f"从zip文件安装琴音小助手: {zip_path}", status='info')
# 创建临时目录
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
try:
# 解压zip文件
print_status("解压zip文件...", status='info', indent=1)
with zipfile.ZipFile(zip_file, 'r') as zip_ref:
zip_ref.extractall(temp_path)
# 使用pip install --find-links安装
print_status("安装ksaa包...", status='info', indent=1)
install_command = f'"{PYTHON_EXECUTABLE}" -m pip install --no-warn-script-location --no-cache-dir --upgrade --no-deps --force-reinstall --no-index --find-links "{temp_path.absolute()}" ksaa'
return run_command(install_command)
except zipfile.BadZipFile:
msg = f"无效的zip文件: {zip_path}"
print_status(msg, status='error')
logging.error(msg)
return False
except Exception as e:
msg = f"从zip文件安装失败: {e}"
print_status(msg, status='error')
logging.error(msg, exc_info=True)
return False
def install_pip_and_ksaa(pip_server: str, check_update: bool = True, install_update: bool = True) -> bool:
"""
安装和更新pip以及ksaa包
:param pip_server: pip服务器URL
:type pip_server: str
:param check_update: 是否检查更新
:type check_update: bool
:param install_update: 是否安装更新
:type install_update: bool
:return: 安装是否成功
:rtype: bool
"""
print_header("安装与更新小助手", color=Color.BLUE)
# 升级pip
print_status("更新 pip", status='info')
upgrade_pip_command = f'"{PYTHON_EXECUTABLE}" -m pip install -i {pip_server} --trusted-host "{TRUSTED_HOSTS}" --upgrade pip'
if not run_command(upgrade_pip_command):
return False
# 默认安装逻辑
install_command = f'"{PYTHON_EXECUTABLE}" -m pip install --upgrade --index-url {pip_server} --trusted-host "{TRUSTED_HOSTS}" --no-warn-script-location ksaa'
ksaa_version_str = package_version("ksaa")
# 未安装
if not ksaa_version_str:
print_status("安装琴音小助手", status='info')
return run_command(install_command)
# 已安装,检查更新
else:
ksaa_version = Version(ksaa_version_str)
if check_update:
has_update, current_version, latest_version = check_ksaa_update_available(pip_server, ksaa_version)
if has_update:
if install_update:
print_status("更新琴音小助手", status='info')
return run_command(install_command)
else:
print_update_notice(str(current_version), str(latest_version))
else:
print_status("已是最新版本", status='success')
return True
def load_config() -> Optional[Config]:
"""
加载config.json配置文件
:return: 配置字典如果加载失败返回None
:rtype: Optional[Config]
"""
config_path = Path("./config.json")
if not config_path.exists():
msg = "配置文件 config.json 不存在,跳过配置加载"
print_status(msg, status='warning')
logging.warning(msg)
return None
try:
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
msg = "成功加载配置文件"
print_status(msg, status='success')
logging.info(msg)
return config
except Exception as e:
msg = f"加载配置文件失败: {e}"
print_status(msg, status='error')
logging.error(msg, exc_info=True)
return None
def get_update_settings(config: Config) -> tuple[bool, bool]:
"""
从配置中获取更新设置
:param config: 配置字典
:type config: Config
:return: (是否检查更新, 是否自动安装更新)
:rtype: tuple[bool, bool]
"""
# 默认值
check_update = True
auto_install_update = True
# 检查是否有用户配置
user_configs = config.get("user_configs", [])
if user_configs:
first_config = user_configs[0]
options = first_config.get("options", {})
misc = options.get("misc", {})
# 获取检查更新设置
check_update_setting = misc.get("check_update", "startup")
check_update = check_update_setting == "startup"
# 获取自动安装更新设置
auto_install_update = misc.get("auto_install_update", True)
msg = f"更新设置: 检查更新={check_update}, 自动安装={auto_install_update}"
logging.info(msg)
return check_update, auto_install_update
def restart_as_admin() -> None:
"""
以管理员身份重启程序
"""
if is_admin():
return
script = os.path.abspath(sys.argv[0])
params = ' '.join([f'"{item}"' for item in sys.argv[1:]])
try:
# 使用 ShellExecute 以管理员身份启动程序
ret = ctypes.windll.shell32.ShellExecuteW(
None, "runas", PYTHON_EXECUTABLE, f'"{script}" {params}', None, 1
)
if ret > 32: # 返回值大于32表示成功
msg = "正在以管理员身份重启程序..."
print_status(msg, status='info')
logging.info(msg)
os._exit(0)
else:
msg = f"以管理员身份重启失败,错误码: {ret}"
print_status(msg, status='error')
logging.error(msg)
return
except Exception as e:
msg = f"以管理员身份重启时发生错误: {e}"
print_status(msg, status='error')
logging.error(msg, exc_info=True)
return
def check_admin(config: Config) -> bool:
"""
检查Windows截图权限管理员权限
:param config: 配置字典
:type config: Config
:return: 权限检查是否通过
:rtype: bool
"""
# 检查是否有用户配置
user_configs = config.get("user_configs", [])
if not user_configs:
msg = "配置文件中没有用户配置"
print_status(msg, status='warning')
logging.warning(msg)
return True # Not a fatal error, allow to continue
# 检查第一个用户配置的截图方式
first_config = user_configs[0]
backend = first_config.get("backend", {})
screenshot_impl = backend.get("screenshot_impl")
if screenshot_impl == "windows":
msg = "检测到Windows截图模式检查管理员权限..."
print_status(msg, status='info')
logging.info(msg)
if not is_admin():
msg1 = "需要管理员权限才能使用Windows截图模式"
print_status(msg1, status='error')
logging.error(msg1)
# 尝试以管理员身份重启
msg2 = "正在尝试以管理员身份重启..."
print_status(msg2, status='info', indent=1)
logging.info(msg2)
restart_as_admin()
return False
else:
msg = "管理员权限检查通过"
print_status(msg, status='success')
logging.info(msg)
return True
def run_kaa() -> bool:
"""
运行琴音小助手
:return: 运行是否成功
:rtype: bool
"""
print_header("运行琴音小助手", color=Color.GREEN)
clear_screen()
# 设置环境变量
os.environ["no_proxy"] = "localhost, 127.0.0.1, ::1"
# 运行kaa命令
if not run_command(f'"{PYTHON_EXECUTABLE}" -m kotonebot.kaa.main.cli', verbatim=True, log_output=False):
return False
print_header("运行结束", color=Color.GREEN)
return True
def parse_arguments():
"""
解析命令行参数
:return: 解析后的参数
:rtype: argparse.Namespace
"""
parser = argparse.ArgumentParser(description='琴音小助手启动器')
parser.add_argument('zip_file', nargs='?', help='要安装的 zip 文件路径(与--install-from-zip等价')
parser.add_argument('--install-version', type=str, help='安装指定版本的 ksaa (例如: --install-version=1.2.3)')
parser.add_argument('--install-from-zip', type=str, help='从 zip 文件安装 ksaa (例如: --install-from-zip=/path/to/file.zip)')
return parser.parse_args()
def main_launch():
"""
主启动函数执行完整的安装和启动流程
"""
# 解析命令行参数
args = parse_arguments()
# 处理位置参数如果提供了zip_file位置参数将其设置为install_from_zip
if args.zip_file and not args.install_from_zip:
args.install_from_zip = args.zip_file
setup_logging()
run_command("title 琴音小助手(运行时请勿关闭此窗口)", verbatim=True, log_output=False)
clear_screen()
print_header("琴音小助手启动器")
logging.info("启动器已启动。")
try:
# 1. 加载配置文件(提前加载以获取更新设置)
print_header("加载配置", color=Color.BLUE)
logging.info("加载配置。")
config = load_config()
# 2. 获取更新设置
check_update, auto_install_update = get_update_settings(config if config else {"version": 5, "user_configs": []})
# 3. 如果指定了特殊安装参数,跳过更新检查
if args.install_version or args.install_from_zip:
check_update = False
auto_install_update = False
# 4. 根据配置决定是否检查更新
print_status("正在寻找最快的 PyPI 镜像源...", status='info')
logging.info("正在寻找最快的 PyPI 镜像源...")
pip_server = get_working_pip_server()
if not pip_server:
raise RuntimeError("没有找到可用的pip服务器请检查网络连接。")
# 5. 处理特殊安装情况
if args.install_from_zip:
# 从zip文件安装
print_header("安装补丁", color=Color.BLUE)
if not install_ksaa_from_zip(args.install_from_zip):
raise RuntimeError("从zip文件安装失败请检查上面的错误日志。")
elif args.install_version:
# 安装指定版本
print_header("安装指定版本", color=Color.BLUE)
if not install_ksaa_version(pip_server, TRUSTED_HOSTS, args.install_version):
raise RuntimeError("安装指定版本失败,请检查上面的错误日志。")
else:
# 默认安装和更新逻辑
if not install_pip_and_ksaa(pip_server, check_update, auto_install_update):
raise RuntimeError("依赖安装失败,请检查上面的错误日志。")
# 6. 检查Windows截图权限
if config:
if not check_admin(config):
raise RuntimeError("权限检查失败。")
# 7. 运行琴音小助手
if not run_kaa():
raise RuntimeError("琴音小助手主程序运行失败。")
msg = "琴音小助手已退出。"
print_status(msg, status='success')
logging.info(msg)
except Exception as e:
msg = f"发生致命错误: {e}"
print_status(msg, status='error')
print_status("压缩 kaa 目录下的 logs 文件夹并给此窗口截图后一并发送给开发者", status='error')
logging.critical(msg, exc_info=True)
finally:
logging.info("启动器运行结束。")
wait_key("\n按任意键退出...")
if __name__ == "__main__":
try:
main_launch()
except KeyboardInterrupt:
print_status("运行结束。现在可以安全关闭此窗口。", status='info')

View File

@ -0,0 +1,235 @@
import re
import html.parser
import urllib.parse
from typing import List
from dataclasses import dataclass
from request import get, HTTPError, NetworkError
@dataclass
class Version:
"""版本信息"""
version_str: str
major: int = 0
minor: int = 0
patch: int = 0
prerelease: str = ""
prerelease_num: int = 0
def __post_init__(self):
"""初始化后解析版本号"""
self._parse_version()
def _parse_version(self):
"""解析版本号字符串"""
version_str = self.version_str.lower()
# 基本版本号匹配 (如 1.2.3, 1.2, 1)
version_match = re.match(r'^(\d+)(?:\.(\d+))?(?:\.(\d+))?', version_str)
if version_match:
self.major = int(version_match.group(1))
self.minor = int(version_match.group(2)) if version_match.group(2) else 0
self.patch = int(version_match.group(3)) if version_match.group(3) else 0
# 预发布版本匹配 (如 alpha1, beta2, rc3)
prerelease_match = re.search(r'(alpha|beta|rc|dev|pre|post)(\d*)', version_str)
if prerelease_match:
self.prerelease = prerelease_match.group(1)
self.prerelease_num = int(prerelease_match.group(2)) if prerelease_match.group(2) else 0
def __lt__(self, other):
"""版本比较"""
if not isinstance(other, Version):
return NotImplemented
# 比较主版本号
if self.major != other.major:
return self.major < other.major
if self.minor != other.minor:
return self.minor < other.minor
if self.patch != other.patch:
return self.patch < other.patch
# 比较预发布版本
prerelease_order = {'': 4, 'rc': 3, 'beta': 2, 'alpha': 1, 'dev': 0, 'pre': 0, 'post': 5}
self_order = prerelease_order.get(self.prerelease, 0)
other_order = prerelease_order.get(other.prerelease, 0)
if self_order != other_order:
return self_order < other_order
# 同类型预发布版本比较数字
if self.prerelease == other.prerelease:
return self.prerelease_num < other.prerelease_num
return False
def __eq__(self, other):
"""版本相等比较"""
if not isinstance(other, Version):
return NotImplemented
return (self.major == other.major and
self.minor == other.minor and
self.patch == other.patch and
self.prerelease == other.prerelease and
self.prerelease_num == other.prerelease_num)
def __repr__(self):
return f"Version('{self.version_str}')"
def __str__(self):
return self.version_str
@dataclass
class PackageVersion:
"""包版本信息"""
version: Version
url: str
class PyPIHTMLParser(html.parser.HTMLParser):
"""解析PyPI HTML响应的解析器"""
def __init__(self):
super().__init__()
self.links = []
self.current_href = None
self.current_text = None
def handle_starttag(self, tag, attrs):
if tag == 'a':
# 提取href属性
for attr_name, attr_value in attrs:
if attr_name == 'href':
self.current_href = attr_value
break
def handle_data(self, data):
if self.current_href:
self.current_text = data.strip()
def handle_endtag(self, tag):
if tag == 'a' and self.current_href and self.current_text:
self.links.append((self.current_text, self.current_href))
self.current_href = None
self.current_text = None
def normalize_package_name(package_name: str) -> str:
"""
标准化包名 _, -, . 字符视为相等
"""
return re.sub(r'[_.-]', '-', package_name.lower())
def extract_version_from_filename(filename: str) -> str:
"""
从文件名中提取版本号
例如: beautifulsoup4-4.13.0b2-py3-none-any.whl -> 4.13.0b2
"""
# 匹配版本号模式
version_pattern = r'^[^-]+-([^-]+?)(?:-py\d+)?(?:-none-any)?\.(?:whl|tar\.gz|zip)$'
match = re.match(version_pattern, filename)
if match:
return match.group(1)
# 备用模式:查找版本号
version_match = re.search(r'-(\d+\.\d+(?:\.\d+)?(?:[a-zA-Z0-9]*))', filename)
if version_match:
return version_match.group(1)
return "unknown"
def list_versions(package_name: str, *, server_url: str | None = None) -> List[PackageVersion]:
"""
获取指定包的所有可用版本按版本号降序排列
:param package_name: 包名
:type package_name: str
:param server_url: 可选的服务器URL默认为None时使用PyPI官方服务器https://pypi.org/simple
:type server_url: str | None
:return: 包含版本信息的列表按版本号降序排列
:rtype: List[PackageVersion]
:raises HTTPError: 当包不存在或网络错误时
:raises NetworkError: 当网络连接错误时
"""
# 标准化包名
normalized_name = normalize_package_name(package_name)
# 构建API URL
if server_url is None:
base_url = "https://pypi.org/simple"
else:
base_url = server_url.rstrip('/')
url = f"{base_url}/{urllib.parse.quote(normalized_name)}/"
# 设置请求头
headers = {
'Accept': 'application/vnd.pypi.simple.v1+html'
}
try:
# 发送请求
html_content = get(url, headers=headers).decode('utf-8')
# 解析HTML
parser = PyPIHTMLParser()
parser.feed(html_content)
# 处理链接并提取版本信息
versions = []
for filename, href in parser.links:
# 提取版本号
version_str = extract_version_from_filename(filename)
# 创建Version对象
version = Version(version_str)
# 创建PackageVersion对象
package_version = PackageVersion(
version=version,
url=href
)
versions.append(package_version)
# 按版本号降序排列
versions.sort(key=lambda x: x.version, reverse=True)
return versions
except HTTPError as e:
if e.code == 404:
raise ValueError(f"'{package_name}' 不存在") from e
else:
raise
def main():
"""测试函数"""
try:
# 测试获取beautifulsoup4的版本
print("获取 beautifulsoup4 的版本信息...")
versions = list_versions("beautifulsoup4")
print(f"找到 {len(versions)} 个版本:")
for i, pkg_version in enumerate(versions[:10], 1): # 只显示前10个
print(f"{i}. 版本: {pkg_version.version.version_str}")
print(f" 主版本: {pkg_version.version.major}.{pkg_version.version.minor}.{pkg_version.version.patch}")
if pkg_version.version.prerelease:
print(f" 预发布: {pkg_version.version.prerelease}{pkg_version.version.prerelease_num}")
print(f" URL: {pkg_version.url}")
print()
if len(versions) > 10:
print(f"... 还有 {len(versions) - 10} 个版本")
except Exception as e:
print(f"错误: {e}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,248 @@
import os
import urllib.parse
import urllib.error
import urllib.request
from typing import Dict, Any, Optional, Callable, TYPE_CHECKING
if TYPE_CHECKING:
from http.client import HTTPResponse
class HTTPError(Exception):
"""HTTP请求错误"""
def __init__(self, code: int, message: str):
self.code = code
self.message = message
super().__init__(f"HTTP {code}: {message}")
class NetworkError(Exception):
"""网络连接错误"""
pass
class Response:
"""HTTP响应封装"""
def __init__(self, http_response: "HTTPResponse"):
self._response = http_response
self._content: Optional[bytes] = None
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
def close(self):
"""关闭响应和底层连接。"""
self._response.close()
@property
def status_code(self) -> int:
"""响应状态码"""
return self._response.getcode()
@property
def reason(self) -> str:
"""响应原因短语"""
return self._response.reason
@property
def headers(self) -> Dict[str, Any]:
"""响应头"""
return dict(self._response.headers)
def read(self) -> bytes:
"""读取响应内容 (bytes)"""
if self._content is None:
self._content = self._response.read()
return self._content
def json(self) -> Any:
"""将响应内容解析为JSON"""
import json
return json.loads(self.read())
def request(
url: str,
method: str = "GET",
headers: Optional[Dict[str, str]] = None,
data: Optional[bytes] = None,
timeout: Optional[float] = None
) -> Response:
"""
发送HTTP请求
:param url: 请求URL
:type url: str
:param method: HTTP方法默认为GET
:type method: str
:param headers: 请求头
:type headers: Optional[Dict[str, str]]
:param data: 请求数据
:type data: Optional[bytes]
:param timeout: 超时时间
:type timeout: Optional[float]
:return: 响应对象
:rtype: Response
:raises HTTPError: HTTP错误
:raises NetworkError: 网络连接错误
"""
# 设置默认请求头
default_headers = {
'User-Agent': 'Python-urllib/3.10'
}
if headers:
default_headers.update(headers)
# 创建请求
req = urllib.request.Request(url, data=data, headers=default_headers, method=method)
try:
# 发送请求
response = urllib.request.urlopen(req, timeout=timeout)
return Response(response)
except urllib.error.HTTPError as e:
raise HTTPError(e.code, e.reason) from e
except urllib.error.URLError as e:
raise NetworkError(f"网络连接错误: {e.reason}") from e
def get(url: str, headers: Optional[Dict[str, str]] = None, timeout: Optional[float] = None) -> bytes:
"""
发送GET请求
:param url: 请求URL
:type url: str
:param headers: 请求头
:type headers: Optional[Dict[str, str]]
:param timeout: 超时时间
:type timeout: Optional[float]
:return: 响应内容
:rtype: bytes
"""
with request(url, method="GET", headers=headers, timeout=timeout) as response:
return response.read()
def head(url: str, headers: Optional[Dict[str, str]] = None, timeout: Optional[float] = None) -> Response:
"""
发送HEAD请求
:param url: 请求URL
:type url: str
:param headers: 请求头
:type headers: Optional[Dict[str, str]]
:param timeout: 超时时间
:type timeout: Optional[float]
:return: 响应对象
:rtype: Response
:raises HTTPError: HTTP错误
:raises NetworkError: 网络连接错误
"""
return request(url, method="HEAD", headers=headers, timeout=timeout)
def download_file(
url: str,
dst_path: str,
*,
callback: Optional[Callable[[int, int], None]] = None,
timeout: Optional[float] = None
) -> None:
"""
下载文件
:param url: 文件URL
:type url: str
:param dst_path: 目标路径
:type dst_path: str
:param callback: 进度回调函数参数为(已下载字节数, 总字节数)
:type callback: Optional[Callable[[int, int], None]]
:param timeout: 超时时间
:type timeout: Optional[float]
:raises HTTPError: HTTP错误
:raises NetworkError: 网络连接错误
"""
# 设置请求头
headers = {
'User-Agent': 'Python-urllib/3.10'
}
# 创建请求
req = urllib.request.Request(url, headers=headers)
try:
# 发送请求
with urllib.request.urlopen(req, timeout=timeout) as response:
# 获取文件大小
content_length = response.headers.get('Content-Length')
total_size = int(content_length) if content_length else None
# 确保目标目录存在
os.makedirs(os.path.dirname(dst_path), exist_ok=True)
# 下载文件
downloaded_size = 0
with open(dst_path, 'wb') as f:
while True:
chunk = response.read(8192) # 8KB chunks
if not chunk:
break
f.write(chunk)
downloaded_size += len(chunk)
# 调用进度回调
if callback and total_size:
callback(downloaded_size, total_size)
elif callback and not total_size:
callback(downloaded_size, -1) # 未知总大小
except urllib.error.HTTPError as e:
raise HTTPError(e.code, e.reason) from e
except urllib.error.URLError as e:
raise NetworkError(f"网络连接错误: {e.reason}") from e
except OSError as e:
raise NetworkError(f"文件写入错误: {e}") from e
def main():
"""测试函数"""
try:
# 测试GET请求
print("测试GET请求...")
response_bytes = get("https://httpbin.org/get")
print(f"响应长度: {len(response_bytes)} 字节")
# 测试文件下载
print("\n测试文件下载...")
def progress_callback(downloaded: int, total: int):
if total > 0:
percentage = (downloaded / total) * 100
print(f"下载进度: {downloaded}/{total} 字节 ({percentage:.1f}%)")
else:
print(f"已下载: {downloaded} 字节")
# 下载一个小文件进行测试
download_file(
"https://httpbin.org/bytes/1024",
"test_download.bin",
callback=progress_callback
)
print("下载完成!")
# 清理测试文件
if os.path.exists("test_download.bin"):
os.remove("test_download.bin")
except Exception as e:
print(f"错误: {e}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,178 @@
import os
import ctypes
import shutil
import unicodedata
from typing import Optional
def _enable_windows_ansi():
"""
On Windows, attempts to enable ANSI escape sequence processing.
"""
if os.name == 'nt':
try:
kernel32 = ctypes.windll.kernel32
handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE
mode = ctypes.c_ulong()
if kernel32.GetConsoleMode(handle, ctypes.byref(mode)) == 0:
return # Failed to get console mode
ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
if (mode.value & ENABLE_VIRTUAL_TERMINAL_PROCESSING) == 0:
mode.value |= ENABLE_VIRTUAL_TERMINAL_PROCESSING
if kernel32.SetConsoleMode(handle, mode) == 0:
# Fallback for older systems
os.system('')
except Exception:
# Fallback for environments where ctypes fails (e.g., some IDE terminals)
os.system('')
_enable_windows_ansi()
class Color:
"""ANSI color codes"""
RESET = '\033[0m'
BOLD = '\033[1m'
BLACK = '\033[30m'
RED = '\033[31m'
GREEN = '\033[32m'
YELLOW = '\033[33m'
BLUE = '\033[34m'
MAGENTA = '\033[35m'
CYAN = '\033[36m'
WHITE = '\033[37m'
GRAY = '\033[90m'
def get_terminal_width(default=80):
"""Gets the width of the terminal."""
# shutil.get_terminal_size is a high-level and more robust way
return shutil.get_terminal_size((default, 24)).columns
def get_terminal_height(default=24):
"""Gets the height of the terminal."""
return shutil.get_terminal_size((default, 24)).lines
def get_display_width(s: str) -> int:
"""Calculates the display width of a string, accounting for wide characters."""
width = 0
for char in s:
# 'F' (Fullwidth), 'W' (Wide) characters take up 2 columns.
if unicodedata.east_asian_width(char) in ('F', 'W'):
width += 2
else:
width += 1
return width
def truncate_string(s: str, max_width: int) -> str:
"""Truncates a string to a maximum display width, handling wide characters."""
if not s or max_width <= 0:
return ""
width = 0
end_pos = 0
for i, char in enumerate(s):
# 'F' (Fullwidth), 'W' (Wide) characters take up 2 columns.
char_width = 2 if unicodedata.east_asian_width(char) in ('F', 'W') else 1
if width + char_width > max_width:
break
width += char_width
end_pos = i + 1
return s[:end_pos]
def hide_cursor():
"""Hides the terminal cursor."""
print('\033[?25l', end='')
def show_cursor():
"""Shows the terminal cursor."""
print('\033[?25h', end='')
def move_cursor_up(lines: int):
"""Moves the cursor up by a number of lines."""
if lines > 0:
print(f'\033[{lines}A', end='')
def clear_screen():
"""Clears the terminal screen."""
os.system('cls' if os.name == 'nt' else 'clear')
def print_header(text: str, color: str = Color.CYAN):
"""
Prints a centered header with separators that fill the terminal width.
Accounts for CJK character widths.
:param text: The text to display in the header.
:param color: ANSI color code for the header text.
"""
width = get_terminal_width()
padded_text = f" {text} "
text_display_width = get_display_width(padded_text)
# Handle cases where the text is wider than the terminal
if text_display_width >= width:
print(f"\n{Color.BOLD}{color}{text}{Color.RESET}")
return
separator_total_len = width - text_display_width
l_separator_len = separator_total_len // 2
r_separator_len = separator_total_len - l_separator_len
l_separator = "" * l_separator_len
r_separator = "" * r_separator_len
print(f"\n{Color.BOLD}{color}{l_separator}{padded_text}{r_separator}{Color.RESET}")
def print_status(message: str, success: Optional[bool] = None, status: str = 'info', indent: int = 0):
"""
Prints a status message with a symbol and color.
:param message: The status message to print.
:param success: (Deprecated) If True, sets status to 'success'; if False, 'error'.
:param status: 'success', 'error', 'warning', or 'info'.
:param indent: Number of spaces to indent.
"""
prefix = " " * indent
# Backward compatibility
if success is not None:
status = 'success' if success else 'error'
if status == 'success':
symbol = f"{Color.GREEN}"
elif status == 'error':
symbol = f"{Color.RED}"
elif status == 'warning':
symbol = f"{Color.YELLOW}"
else: # 'info'
symbol = f"{Color.BLUE}{Color.RESET}"
print(f"{prefix}[{symbol}] {message}{Color.RESET}")
def wait_key(message: str = ""):
"""
Prints a message and waits for a single key press from the user.
This is a cross-platform function.
:param message: The message to display before waiting.
"""
print(message, end="", flush=True)
if os.name == 'nt':
import msvcrt
msvcrt.getch()
else:
import sys
import tty
import termios
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)
try:
tty.setraw(sys.stdin.fileno())
sys.stdin.read(1)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
# Add a newline after the key is pressed for cleaner output
print()

418
bootstrap/kaa-wrapper/.gitignore vendored Normal file
View File

@ -0,0 +1,418 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
*.env
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
[Aa][Rr][Mm]64[Ee][Cc]/
bld/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# Build results on 'Bin' directories
**/[Bb]in/*
# Uncomment if you have tasks that rely on *.refresh files to move binaries
# (https://github.com/github/gitignore/pull/3736)
#!**/[Bb]in/*.refresh
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
*.trx
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Approval Tests result files
*.received.*
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.idb
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
# but not Directory.Build.rsp, as it configures directory-level build defaults
!Directory.Build.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
**/.paket/paket.exe
paket-files/
# FAKE - F# Make
**/.fake/
# CodeRush personal settings
**/.cr/personal
# Python Tools for Visual Studio (PTVS)
**/__pycache__/
*.pyc
# Cake - Uncomment if you are using it
#tools/**
#!tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
MSBuild_Logs/
# AWS SAM Build and Temporary Artifacts folder
.aws-sam
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
**/.mfractor/
# Local History for Visual Studio
**/.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
**/.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp

View File

@ -0,0 +1,18 @@
//{{NO_DEPENDENCIES}}
// Microsoft Visual C++ 生成的包含文件。
// 使用者 kaa-wrapper.rc
#define IDI_KAAWRAPPER 107
#define IDI_SMALL 108
// 新对象的下一组默认值
//
#ifdef APSTUDIO_INVOKED
#ifndef APSTUDIO_READONLY_SYMBOLS
#define _APS_NO_MFC 130
#define _APS_NEXT_RESOURCE_VALUE 129
#define _APS_NEXT_COMMAND_VALUE 32771
#define _APS_NEXT_CONTROL_VALUE 1000
#define _APS_NEXT_SYMED_VALUE 110
#endif
#endif

View File

@ -0,0 +1,10 @@
// header.h: 标准系统包含文件的包含文件,
// 或特定于项目的包含文件
//
#pragma once
#include "targetver.h"
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <tchar.h>

View File

@ -0,0 +1,88 @@
// kaa-wrapper.cpp : 定义应用程序的入口点。
//
#include "framework.h"
#include "kaa-wrapper.h"
#include <string>
int APIENTRY wWinMain(_In_ HINSTANCE hInstance,
_In_opt_ HINSTANCE hPrevInstance,
_In_ LPWSTR lpCmdLine,
_In_ int nCmdShow)
{
UNREFERENCED_PARAMETER(hInstance);
UNREFERENCED_PARAMETER(hPrevInstance);
UNREFERENCED_PARAMETER(nCmdShow);
// 设置当前目录为程序所在目录
WCHAR szPath[MAX_PATH];
if (GetModuleFileNameW(NULL, szPath, MAX_PATH) == 0) {
MessageBoxW(NULL, L"无法获取程序所在目录", L"错误", MB_OK | MB_ICONERROR);
return 1;
}
std::wstring path(szPath);
size_t pos = path.find_last_of(L"\\");
if (pos == std::wstring::npos) {
MessageBoxW(NULL, L"程序路径格式错误", L"错误", MB_OK | MB_ICONERROR);
return 1;
}
path = path.substr(0, pos);
if (!SetCurrentDirectoryW(path.c_str())) {
MessageBoxW(NULL, L"无法设置工作目录", L"错误", MB_OK | MB_ICONERROR);
return 1;
}
// 检查 Python 解释器是否存在
std::wstring pythonPath = path + L"\\WPy64-310111\\python-3.10.11.amd64\\python.exe";
if (GetFileAttributesW(pythonPath.c_str()) == INVALID_FILE_ATTRIBUTES) {
MessageBoxW(NULL, L"找不到 Python 解释器", L"错误", MB_OK | MB_ICONERROR);
return 1;
}
// 检查 bootstrap.pyz 是否存在
std::wstring bootstrapPath = path + L"\\bootstrap.pyz";
if (GetFileAttributesW(bootstrapPath.c_str()) == INVALID_FILE_ATTRIBUTES) {
MessageBoxW(NULL, L"找不到 bootstrap.pyz 文件", L"错误", MB_OK | MB_ICONERROR);
return 1;
}
// 构建命令行
std::wstring cmd = L"\"" + pythonPath + L"\" \"" + bootstrapPath + L"\"";
// 如果有命令行参数,将其传递给 bootstrap
if (lpCmdLine && wcslen(lpCmdLine) > 0) {
cmd += L" ";
cmd += lpCmdLine;
}
// 启动信息
STARTUPINFOW si = { sizeof(si) };
PROCESS_INFORMATION pi;
// 创建进程,使用当前目录作为工作目录
if (!CreateProcessW(NULL,
const_cast<LPWSTR>(cmd.c_str()),
NULL,
NULL,
FALSE,
0,
NULL,
path.c_str(), // 设置工作目录为当前目录
&si,
&pi))
{
DWORD error = GetLastError();
WCHAR errorMsg[256];
swprintf_s(errorMsg, L"无法启动程序 (错误代码: %d)", error);
MessageBoxW(NULL, errorMsg, L"错误", MB_OK | MB_ICONERROR);
return 1;
}
// 关闭进程和线程句柄
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
return 0;
}

View File

@ -0,0 +1,3 @@
#pragma once
#include "resource.h"

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

View File

@ -0,0 +1,31 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.10.35013.160
MinimumVisualStudioVersion = 10.0.40219.1
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "kaa-wrapper", "kaa-wrapper.vcxproj", "{F4F29940-A4DD-40C0-A433-1CF3C7B6F55C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{F4F29940-A4DD-40C0-A433-1CF3C7B6F55C}.Debug|x64.ActiveCfg = Debug|x64
{F4F29940-A4DD-40C0-A433-1CF3C7B6F55C}.Debug|x64.Build.0 = Debug|x64
{F4F29940-A4DD-40C0-A433-1CF3C7B6F55C}.Debug|x86.ActiveCfg = Debug|Win32
{F4F29940-A4DD-40C0-A433-1CF3C7B6F55C}.Debug|x86.Build.0 = Debug|Win32
{F4F29940-A4DD-40C0-A433-1CF3C7B6F55C}.Release|x64.ActiveCfg = Release|x64
{F4F29940-A4DD-40C0-A433-1CF3C7B6F55C}.Release|x64.Build.0 = Release|x64
{F4F29940-A4DD-40C0-A433-1CF3C7B6F55C}.Release|x86.ActiveCfg = Release|Win32
{F4F29940-A4DD-40C0-A433-1CF3C7B6F55C}.Release|x86.Build.0 = Release|Win32
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {304B08B9-3494-48C3-94A5-9486F9D16B10}
EndGlobalSection
EndGlobal

View File

@ -0,0 +1,148 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup Label="ProjectConfigurations">
<ProjectConfiguration Include="Debug|Win32">
<Configuration>Debug</Configuration>
<Platform>Win32</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|Win32">
<Configuration>Release</Configuration>
<Platform>Win32</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Debug|x64">
<Configuration>Debug</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|x64">
<Configuration>Release</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
</ItemGroup>
<PropertyGroup Label="Globals">
<VCProjectVersion>17.0</VCProjectVersion>
<Keyword>Win32Proj</Keyword>
<ProjectGuid>{f4f29940-a4dd-40c0-a433-1cf3c7b6f55c}</ProjectGuid>
<RootNamespace>kaawrapper</RootNamespace>
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<PlatformToolset>v143</PlatformToolset>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<PlatformToolset>v143</PlatformToolset>
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<PlatformToolset>v143</PlatformToolset>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<PlatformToolset>v143</PlatformToolset>
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
<ImportGroup Label="ExtensionSettings">
</ImportGroup>
<ImportGroup Label="Shared">
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<PropertyGroup Label="UserMacros" />
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>WIN32;_DEBUG;_WINDOWS;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
</ClCompile>
<Link>
<SubSystem>Windows</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<FunctionLevelLinking>true</FunctionLevelLinking>
<IntrinsicFunctions>true</IntrinsicFunctions>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>WIN32;NDEBUG;_WINDOWS;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
</ClCompile>
<Link>
<SubSystem>Windows</SubSystem>
<EnableCOMDATFolding>true</EnableCOMDATFolding>
<OptimizeReferences>true</OptimizeReferences>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>_DEBUG;_WINDOWS;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
</ClCompile>
<Link>
<SubSystem>Windows</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<FunctionLevelLinking>true</FunctionLevelLinking>
<IntrinsicFunctions>true</IntrinsicFunctions>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>NDEBUG;_WINDOWS;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
</ClCompile>
<Link>
<SubSystem>Windows</SubSystem>
<EnableCOMDATFolding>true</EnableCOMDATFolding>
<OptimizeReferences>true</OptimizeReferences>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
</ItemDefinitionGroup>
<ItemGroup>
<ClInclude Include="framework.h" />
<ClInclude Include="kaa-wrapper.h" />
<ClInclude Include="Resource.h" />
<ClInclude Include="targetver.h" />
</ItemGroup>
<ItemGroup>
<ClCompile Include="kaa-wrapper.cpp" />
</ItemGroup>
<ItemGroup>
<ResourceCompile Include="kaa-wrapper.rc" />
</ItemGroup>
<ItemGroup>
<Image Include="kaa-wrapper.ico" />
<Image Include="small.ico" />
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
<ImportGroup Label="ExtensionTargets">
</ImportGroup>
</Project>

View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<Filter Include="源文件">
<UniqueIdentifier>{4FC737F1-C7A5-4376-A066-2A32D752A2FF}</UniqueIdentifier>
<Extensions>cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx</Extensions>
</Filter>
<Filter Include="头文件">
<UniqueIdentifier>{93995380-89BD-4b04-88EB-625FBE52EBFB}</UniqueIdentifier>
<Extensions>h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd</Extensions>
</Filter>
<Filter Include="资源文件">
<UniqueIdentifier>{67DA6AB6-F800-4c08-8B7A-83BB121AAD01}</UniqueIdentifier>
<Extensions>rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms</Extensions>
</Filter>
</ItemGroup>
<ItemGroup>
<ClInclude Include="framework.h">
<Filter>头文件</Filter>
</ClInclude>
<ClInclude Include="targetver.h">
<Filter>头文件</Filter>
</ClInclude>
<ClInclude Include="Resource.h">
<Filter>头文件</Filter>
</ClInclude>
<ClInclude Include="kaa-wrapper.h">
<Filter>头文件</Filter>
</ClInclude>
</ItemGroup>
<ItemGroup>
<ClCompile Include="kaa-wrapper.cpp">
<Filter>源文件</Filter>
</ClCompile>
</ItemGroup>
<ItemGroup>
<ResourceCompile Include="kaa-wrapper.rc">
<Filter>资源文件</Filter>
</ResourceCompile>
</ItemGroup>
<ItemGroup>
<Image Include="small.ico">
<Filter>资源文件</Filter>
</Image>
<Image Include="kaa-wrapper.ico">
<Filter>资源文件</Filter>
</Image>
</ItemGroup>
</Project>

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

View File

@ -0,0 +1,6 @@
#pragma once
// // 包含 SDKDDKVer.h 可定义可用的最高版本的 Windows 平台。
// 如果希望为之前的 Windows 平台构建应用程序,在包含 SDKDDKVer.h 之前请先包含 WinSDKVer.h 并
// 将 _WIN32_WINNT 宏设置为想要支持的平台。
#include <SDKDDKVer.h>

View File

@ -1,41 +0,0 @@
@echo off
call WPy64-310111\scripts\env.bat
if errorlevel 1 (
goto ERROR
)
set PIP_EXTRA_INDEX_URL=https://mirrors.cloud.tencent.com/pypi/simple/ http://mirrors.aliyun.com/pypi/simple/
echo =========== 安装与更新 KAA ===========
:INSTALL
echo 检查 pip
python -m pip install -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple --upgrade pip
if errorlevel 1 (
goto ERROR
)
pip config set global.trusted-host "pypi.org files.pythonhosted.org pypi.python.org mirrors.aliyun.com mirrors.cloud.tencent.com mirrors.tuna.tsinghua.edu.cn"
pip config set global.index-url https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
echo 安装 ksaa
pip install --upgrade ksaa
if errorlevel 1 (
goto ERROR
)
echo =========== 当前版本 ===========
pip show ksaa
echo =========== 运行 KAA ===========
:RUN
set no_proxy=localhost, 127.0.0.1, ::1
kaa
if errorlevel 1 (
goto ERROR
)
echo =========== 运行结束 ===========
pause
exit /b 0
:ERROR
echo 发生错误,程序退出
pause
exit /b 1

View File

@ -1,54 +0,0 @@
@echo off
cd /D %~dp0
REM https://superuser.com/questions/788924/is-it-possible-to-automatically-run-a-batch-file-as-administrator
REM --> Check for permissions
>nul 2>&1 "%SYSTEMROOT%\system32\cacls.exe" "%SYSTEMROOT%\system32\config\system"
REM --> If error flag set, we do not have admin.
if '%errorlevel%' NEQ '0' (
echo 需要以管理员身份运行。右键此脚本,选择“以管理员身份运行”。
pause
exit /b 1
)
call WPy64-310111\scripts\env.bat
if errorlevel 1 (
goto ERROR
)
set PIP_EXTRA_INDEX_URL=https://mirrors.cloud.tencent.com/pypi/simple/ http://mirrors.aliyun.com/pypi/simple/
echo =========== 安装与更新 KAA ===========
:INSTALL
echo 检查 pip
python -m pip install -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple --upgrade pip
if errorlevel 1 (
goto ERROR
)
pip config set global.trusted-host "pypi.org files.pythonhosted.org pypi.python.org mirrors.aliyun.com mirrors.cloud.tencent.com mirrors.tuna.tsinghua.edu.cn"
pip config set global.index-url https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
echo 安装 ksaa
pip install --upgrade ksaa
if errorlevel 1 (
goto ERROR
)
echo =========== 当前版本 ===========
pip show ksaa
echo =========== 运行 KAA ===========
:RUN
set no_proxy=localhost, 127.0.0.1, ::1
kaa
if errorlevel 1 (
goto ERROR
)
echo =========== 运行结束 ===========
pause
exit /b 0
:ERROR
echo 发生错误,程序退出
pause
exit /b 1

View File

@ -1,40 +0,0 @@
@echo off
call WPy64-310111\scripts\env.bat
if errorlevel 1 (
goto ERROR
)
if "%~1"=="" (
echo 请将 Python 包文件拖到此脚本上
pause
exit /b 1
)
echo =========== 卸载原有包 ===========
pip uninstall -y ksaa
pip uninstall -y ksaa_res
if errorlevel 1 (
goto ERROR
)
:INSTALL_LOOP
if "%~1"=="" goto INSTALL_DONE
echo =========== 安装 %~1 ===========
pip install "%~1"
if errorlevel 1 (
goto ERROR
)
shift
goto INSTALL_LOOP
:INSTALL_DONE
echo =========== 安装完成 ===========
pause
exit /b 0
:ERROR
echo 发生错误,程序退出
pause
exit /b 1

View File

@ -41,10 +41,6 @@ env: fetch-submodule
}
python tools/make_resources.py
# Build the project using pyinstaller
build: env
pyinstaller -y kotonebot-gr.spec
generate-metadata: env
#!{{shebang_python}}
# 更新日志
@ -124,3 +120,21 @@ publish-test: package
#
build-bootstrap:
#!{{shebang_pwsh}}
echo "Building bootstrap..."
# 构建 Python
cd bootstrap
python -m zipapp kaa-bootstrap
mv kaa-bootstrap.pyz ../dist/bootstrap.pyz -fo
# 构建 C++
$msbuild = &"${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -prerelease -products * -requires Microsoft.Component.MSBuild -find MSBuild\**\Bin\MSBuild.exe
if ($msbuild) {
& $msbuild kaa-wrapper/kaa-wrapper.sln /p:Configuration=Release
mv kaa-wrapper/x64/Release/kaa-wrapper.exe ../dist/kaa.exe -fo
} else {
Write-Host "MSBuild not found. Please install Visual Studio or build kaa-wrapper manually."
}
# Build kaa and bootstrap
build: package build-bootstrap

View File

@ -1 +1 @@
{"definitions":{"0949c622-9067-4f0d-bac2-3f938a1d2ed2":{"name":"Shop.ItemLessonNote","displayName":"レッスンノート","type":"template","annotationId":"0949c622-9067-4f0d-bac2-3f938a1d2ed2","useHintRect":false},"b2af59e9-60e3-4d97-8c72-c7ba092113a3":{"name":"Shop.ItemVeteranNote","displayName":"ベテランノート","type":"template","annotationId":"b2af59e9-60e3-4d97-8c72-c7ba092113a3","useHintRect":false},"835489e2-b29b-426c-b4c9-3bb9f8eb6195":{"name":"Shop.ItemSupportEnhancementPt","displayName":"サポート強化Pt 支援强化Pt","type":"template","annotationId":"835489e2-b29b-426c-b4c9-3bb9f8eb6195","useHintRect":false},"c5b7d67e-7260-4f08-a0e9-4d31ce9bbecf":{"name":"Shop.ItemSenseNoteVocal","displayName":"センスノート(ボーカル)感性笔记(声乐)","type":"template","annotationId":"c5b7d67e-7260-4f08-a0e9-4d31ce9bbecf","useHintRect":false},"0f7d581d-cea3-4039-9205-732e4cd29293":{"name":"Shop.ItemSenseNoteDance","displayName":"センスノート(ダンス)感性笔记(舞蹈)","type":"template","annotationId":"0f7d581d-cea3-4039-9205-732e4cd29293","useHintRect":false},"d3cc3323-51af-4882-ae12-49e7384b746d":{"name":"Shop.ItemSenseNoteVisual","displayName":"センスノート(ビジュアル)感性笔记(形象)","type":"template","annotationId":"d3cc3323-51af-4882-ae12-49e7384b746d","useHintRect":false},"a1df3af1-a3e7-4521-a085-e4dc3cd1cc57":{"name":"Shop.ItemLogicNoteVocal","displayName":"ロジックノート(ボーカル)理性笔记(声乐)","type":"template","annotationId":"a1df3af1-a3e7-4521-a085-e4dc3cd1cc57","useHintRect":false},"a9fcaf04-0c1f-4b0f-bb5b-ede9da96180f":{"name":"Shop.ItemLogicNoteDance","displayName":"ロジックノート(ダンス)理性笔记(舞蹈)","type":"template","annotationId":"a9fcaf04-0c1f-4b0f-bb5b-ede9da96180f","useHintRect":false},"c3f536d6-a04a-4651-b3f9-dd2c22593f7f":{"name":"Shop.ItemLogicNoteVisual","displayName":"ロジックノート(ビジュアル)理性笔记(形象)","type":"template","annotationId":"c3f536d6-a04a-4651-b3f9-dd2c22593f7f","useHintRect":false},"eef25cf9-afd0-43b1-b9c5-fbd997bd5fe0":{"name":"Shop.ItemAnomalyNoteVocal","displayName":"アノマリーノート(ボーカル)非凡笔记(声乐)","type":"template","annotationId":"eef25cf9-afd0-43b1-b9c5-fbd997bd5fe0","useHintRect":false},"df991b42-ed8e-4f2c-bf0c-aa7522f147b6":{"name":"Shop.ItemAnomalyNoteDance","displayName":"アノマリーノート(ダンス)非凡笔记(舞蹈)","type":"template","annotationId":"df991b42-ed8e-4f2c-bf0c-aa7522f147b6","useHintRect":false}},"annotations":[{"id":"0949c622-9067-4f0d-bac2-3f938a1d2ed2","type":"rect","data":{"x1":243,"y1":355,"x2":313,"y2":441}},{"id":"b2af59e9-60e3-4d97-8c72-c7ba092113a3","type":"rect","data":{"x1":414,"y1":355,"x2":484,"y2":441}},{"id":"835489e2-b29b-426c-b4c9-3bb9f8eb6195","type":"rect","data":{"x1":574,"y1":363,"x2":662,"y2":438}},{"id":"c5b7d67e-7260-4f08-a0e9-4d31ce9bbecf","type":"rect","data":{"x1":71,"y1":594,"x2":142,"y2":667}},{"id":"0f7d581d-cea3-4039-9205-732e4cd29293","type":"rect","data":{"x1":241,"y1":593,"x2":309,"y2":667}},{"id":"d3cc3323-51af-4882-ae12-49e7384b746d","type":"rect","data":{"x1":417,"y1":586,"x2":481,"y2":668}},{"id":"a1df3af1-a3e7-4521-a085-e4dc3cd1cc57","type":"rect","data":{"x1":585,"y1":591,"x2":651,"y2":669}},{"id":"a9fcaf04-0c1f-4b0f-bb5b-ede9da96180f","type":"rect","data":{"x1":69,"y1":825,"x2":138,"y2":899}},{"id":"c3f536d6-a04a-4651-b3f9-dd2c22593f7f","type":"rect","data":{"x1":242,"y1":820,"x2":310,"y2":898}},{"id":"eef25cf9-afd0-43b1-b9c5-fbd997bd5fe0","type":"rect","data":{"x1":413,"y1":821,"x2":481,"y2":897}},{"id":"df991b42-ed8e-4f2c-bf0c-aa7522f147b6","type":"rect","data":{"x1":583,"y1":823,"x2":649,"y2":900}}]}
{"definitions":{"0949c622-9067-4f0d-bac2-3f938a1d2ed2":{"name":"Shop.ItemLessonNote","displayName":"レッスンノート","type":"template","annotationId":"0949c622-9067-4f0d-bac2-3f938a1d2ed2","useHintRect":false},"b2af59e9-60e3-4d97-8c72-c7ba092113a3":{"name":"Shop.ItemVeteranNote","displayName":"ベテランノート","type":"template","annotationId":"b2af59e9-60e3-4d97-8c72-c7ba092113a3","useHintRect":false},"835489e2-b29b-426c-b4c9-3bb9f8eb6195":{"name":"Shop.ItemSupportEnhancementPt","displayName":"サポート強化Pt 支援强化Pt","type":"template","annotationId":"835489e2-b29b-426c-b4c9-3bb9f8eb6195","useHintRect":false},"c5b7d67e-7260-4f08-a0e9-4d31ce9bbecf":{"name":"Shop.ItemSenseNoteVocal","displayName":"センスノート(ボーカル)感性笔记(声乐)","type":"template","annotationId":"c5b7d67e-7260-4f08-a0e9-4d31ce9bbecf","useHintRect":false},"0f7d581d-cea3-4039-9205-732e4cd29293":{"name":"Shop.ItemSenseNoteDance","displayName":"センスノート(ダンス)感性笔记(舞蹈)","type":"template","annotationId":"0f7d581d-cea3-4039-9205-732e4cd29293","useHintRect":false},"d3cc3323-51af-4882-ae12-49e7384b746d":{"name":"Shop.ItemSenseNoteVisual","displayName":"センスノート(ビジュアル)感性笔记(形象)","type":"template","annotationId":"d3cc3323-51af-4882-ae12-49e7384b746d","useHintRect":false},"a1df3af1-a3e7-4521-a085-e4dc3cd1cc57":{"name":"Shop.ItemLogicNoteVocal","displayName":"ロジックノート(ボーカル)理性笔记(声乐)","type":"template","annotationId":"a1df3af1-a3e7-4521-a085-e4dc3cd1cc57","useHintRect":false},"a9fcaf04-0c1f-4b0f-bb5b-ede9da96180f":{"name":"Shop.ItemLogicNoteDance","displayName":"ロジックノート(ダンス)理性笔记(舞蹈)","type":"template","annotationId":"a9fcaf04-0c1f-4b0f-bb5b-ede9da96180f","useHintRect":false},"c3f536d6-a04a-4651-b3f9-dd2c22593f7f":{"name":"Shop.ItemLogicNoteVisual","displayName":"ロジックノート(ビジュアル)理性笔记(形象)","type":"template","annotationId":"c3f536d6-a04a-4651-b3f9-dd2c22593f7f","useHintRect":false},"eef25cf9-afd0-43b1-b9c5-fbd997bd5fe0":{"name":"Shop.ItemAnomalyNoteVocal","displayName":"アノマリーノート(ボーカル)非凡笔记(声乐)","type":"template","annotationId":"eef25cf9-afd0-43b1-b9c5-fbd997bd5fe0","useHintRect":false},"df991b42-ed8e-4f2c-bf0c-aa7522f147b6":{"name":"Shop.ItemAnomalyNoteDance","displayName":"アノマリーノート(ダンス)非凡笔记(舞蹈)","type":"template","annotationId":"df991b42-ed8e-4f2c-bf0c-aa7522f147b6","useHintRect":false},"81c97cd3-df53-44d3-bf3d-1eb4dc67b62a":{"name":"Daily.ButtonRefreshMoneyShop","displayName":"リスト更新1回無料","type":"template","annotationId":"81c97cd3-df53-44d3-bf3d-1eb4dc67b62a","useHintRect":false}},"annotations":[{"id":"0949c622-9067-4f0d-bac2-3f938a1d2ed2","type":"rect","data":{"x1":243,"y1":355,"x2":313,"y2":441}},{"id":"b2af59e9-60e3-4d97-8c72-c7ba092113a3","type":"rect","data":{"x1":414,"y1":355,"x2":484,"y2":441}},{"id":"835489e2-b29b-426c-b4c9-3bb9f8eb6195","type":"rect","data":{"x1":574,"y1":363,"x2":662,"y2":438}},{"id":"c5b7d67e-7260-4f08-a0e9-4d31ce9bbecf","type":"rect","data":{"x1":71,"y1":594,"x2":142,"y2":667}},{"id":"0f7d581d-cea3-4039-9205-732e4cd29293","type":"rect","data":{"x1":241,"y1":593,"x2":309,"y2":667}},{"id":"d3cc3323-51af-4882-ae12-49e7384b746d","type":"rect","data":{"x1":417,"y1":586,"x2":481,"y2":668}},{"id":"a1df3af1-a3e7-4521-a085-e4dc3cd1cc57","type":"rect","data":{"x1":585,"y1":591,"x2":651,"y2":669}},{"id":"a9fcaf04-0c1f-4b0f-bb5b-ede9da96180f","type":"rect","data":{"x1":69,"y1":825,"x2":138,"y2":899}},{"id":"c3f536d6-a04a-4651-b3f9-dd2c22593f7f","type":"rect","data":{"x1":242,"y1":820,"x2":310,"y2":898}},{"id":"eef25cf9-afd0-43b1-b9c5-fbd997bd5fe0","type":"rect","data":{"x1":413,"y1":821,"x2":481,"y2":897}},{"id":"df991b42-ed8e-4f2c-bf0c-aa7522f147b6","type":"rect","data":{"x1":583,"y1":823,"x2":649,"y2":900}},{"id":"81c97cd3-df53-44d3-bf3d-1eb4dc67b62a","type":"rect","data":{"x1":440,"y1":149,"x2":679,"y2":179}}]}

View File

@ -1 +1 @@
{"definitions":{"9340b854-025c-40da-9387-385d38433bef":{"name":"Shop.ItemAnomalyNoteVisual","displayName":"アノマリーノート(ビジュアル)非凡笔记(形象)","type":"template","annotationId":"9340b854-025c-40da-9387-385d38433bef","useHintRect":false},"ea1ba124-9cb3-4427-969a-bacd47e7d920":{"name":"Shop.ItemRechallengeTicket","displayName":"再挑戦チケット 重新挑战券","type":"template","annotationId":"ea1ba124-9cb3-4427-969a-bacd47e7d920","useHintRect":false},"1926f2f9-4bd7-48eb-9eba-28ec4efb0606":{"name":"Shop.ItemRecordKey","displayName":"記録の鍵 解锁交流的物品","type":"template","annotationId":"1926f2f9-4bd7-48eb-9eba-28ec4efb0606","useHintRect":false},"6720b6e8-ae80-4cc0-a885-518efe12b707":{"name":"Shop.IdolPiece.倉本千奈_WonderScale","displayName":"倉本千奈 WonderScale 碎片","type":"template","annotationId":"6720b6e8-ae80-4cc0-a885-518efe12b707","useHintRect":false},"afa06fdc-a345-4384-b25d-b16540830256":{"name":"Shop.IdolPiece.篠泽广_光景","displayName":"篠泽广 光景 碎片","type":"template","annotationId":"afa06fdc-a345-4384-b25d-b16540830256","useHintRect":false},"278b7d9c-707e-4392-9677-74574b5cdf42":{"name":"Shop.IdolPiece.紫云清夏_TameLieOneStep","displayName":"紫云清夏 Tame-Lie-One-Step 碎片","type":"template","annotationId":"278b7d9c-707e-4392-9677-74574b5cdf42","useHintRect":false},"e9ee330d-dfca-440e-8b8c-0a3b4e8c8730":{"name":"Daily.IconTitleDailyShop","displayName":"日常商店标题图标","type":"template","annotationId":"e9ee330d-dfca-440e-8b8c-0a3b4e8c8730","useHintRect":false}},"annotations":[{"id":"9340b854-025c-40da-9387-385d38433bef","type":"rect","data":{"x1":72,"y1":611,"x2":138,"y2":693}},{"id":"ea1ba124-9cb3-4427-969a-bacd47e7d920","type":"rect","data":{"x1":227,"y1":639,"x2":316,"y2":674}},{"id":"1926f2f9-4bd7-48eb-9eba-28ec4efb0606","type":"rect","data":{"x1":385,"y1":591,"x2":508,"y2":694}},{"id":"6720b6e8-ae80-4cc0-a885-518efe12b707","type":"rect","data":{"x1":589,"y1":633,"x2":638,"y2":678}},{"id":"afa06fdc-a345-4384-b25d-b16540830256","type":"rect","data":{"x1":83,"y1":867,"x2":134,"y2":912}},{"id":"278b7d9c-707e-4392-9677-74574b5cdf42","type":"rect","data":{"x1":247,"y1":864,"x2":301,"y2":907}},{"id":"e9ee330d-dfca-440e-8b8c-0a3b4e8c8730","type":"rect","data":{"x1":17,"y1":35,"x2":59,"y2":76}}]}
{"definitions":{"9340b854-025c-40da-9387-385d38433bef":{"name":"Shop.ItemAnomalyNoteVisual","displayName":"アノマリーノート(ビジュアル)非凡笔记(形象)","type":"template","annotationId":"9340b854-025c-40da-9387-385d38433bef","useHintRect":false},"ea1ba124-9cb3-4427-969a-bacd47e7d920":{"name":"Shop.ItemRechallengeTicket","displayName":"再挑戦チケット 重新挑战券","type":"template","annotationId":"ea1ba124-9cb3-4427-969a-bacd47e7d920","useHintRect":false},"1926f2f9-4bd7-48eb-9eba-28ec4efb0606":{"name":"Shop.ItemRecordKey","displayName":"記録の鍵 解锁交流的物品","type":"template","annotationId":"1926f2f9-4bd7-48eb-9eba-28ec4efb0606","useHintRect":false},"e9ee330d-dfca-440e-8b8c-0a3b4e8c8730":{"name":"Daily.IconTitleDailyShop","displayName":"日常商店标题图标","type":"template","annotationId":"e9ee330d-dfca-440e-8b8c-0a3b4e8c8730","useHintRect":false}},"annotations":[{"id":"9340b854-025c-40da-9387-385d38433bef","type":"rect","data":{"x1":72,"y1":611,"x2":138,"y2":693}},{"id":"ea1ba124-9cb3-4427-969a-bacd47e7d920","type":"rect","data":{"x1":227,"y1":639,"x2":316,"y2":674}},{"id":"1926f2f9-4bd7-48eb-9eba-28ec4efb0606","type":"rect","data":{"x1":385,"y1":591,"x2":508,"y2":694}},{"id":"e9ee330d-dfca-440e-8b8c-0a3b4e8c8730","type":"rect","data":{"x1":17,"y1":35,"x2":59,"y2":76}}]}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 300 KiB

View File

@ -1 +0,0 @@
{"definitions":{"74ff07b3-d91c-4579-80cd-379ed7020622":{"name":"Shop.IdolPiece.葛城リーリヤ_白線","displayName":"葛城リーリヤ 白線 碎片","type":"template","annotationId":"74ff07b3-d91c-4579-80cd-379ed7020622","useHintRect":false}},"annotations":[{"id":"74ff07b3-d91c-4579-80cd-379ed7020622","type":"rect","data":{"x1":101,"y1":630,"x2":135,"y2":664}}]}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 307 KiB

View File

@ -1 +0,0 @@
{"definitions":{"a7f5abf1-982f-4a55-8d41-3ad6f56798e0":{"name":"Shop.IdolPiece.姫崎薪波_cIclumsy_trick ","displayName":"姫崎薪波 cIclumsy trick 碎片","type":"template","annotationId":"a7f5abf1-982f-4a55-8d41-3ad6f56798e0","useHintRect":false}},"annotations":[{"id":"a7f5abf1-982f-4a55-8d41-3ad6f56798e0","type":"rect","data":{"x1":113,"y1":628,"x2":148,"y2":656}}]}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 KiB

View File

@ -1 +0,0 @@
{"definitions":{"2bc00520-0afe-40e5-8743-d33fc6b2945a":{"name":"Shop.IdolPiece.花海咲季_FightingMyWay","displayName":"花海咲季 FightingMyWay 碎片","type":"template","annotationId":"2bc00520-0afe-40e5-8743-d33fc6b2945a","useHintRect":false}},"annotations":[{"id":"2bc00520-0afe-40e5-8743-d33fc6b2945a","type":"rect","data":{"x1":112,"y1":601,"x2":148,"y2":640}}]}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 297 KiB

View File

@ -1 +0,0 @@
{"definitions":{"135ee57a-d30d-4ba8-83f0-9f1681a49ff7":{"name":"Shop.IdolPiece.藤田ことね_世界一可愛い私","displayName":"藤田ことね 世界一可愛い私 碎片","type":"template","annotationId":"135ee57a-d30d-4ba8-83f0-9f1681a49ff7","useHintRect":false}},"annotations":[{"id":"135ee57a-d30d-4ba8-83f0-9f1681a49ff7","type":"rect","data":{"x1":113,"y1":602,"x2":146,"y2":635}}]}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

View File

@ -1 +0,0 @@
{"definitions":{"d15959bf-d07b-4f07-948a-c0aeaf17756a":{"name":"Shop.IdolPiece.花海佑芽_TheRollingRiceball","displayName":"花海佑芽 The Rolling Riceball 碎片","type":"template","annotationId":"d15959bf-d07b-4f07-948a-c0aeaf17756a","useHintRect":false}},"annotations":[{"id":"d15959bf-d07b-4f07-948a-c0aeaf17756a","type":"rect","data":{"x1":103,"y1":605,"x2":137,"y2":635}}]}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

View File

@ -1 +0,0 @@
{"definitions":{"868b97a9-492e-4712-b47f-82b97495b019":{"name":"Shop.IdolPiece.月村手毬_LunaSayMaybe","displayName":"月村手毬 Luna say maybe 碎片","type":"template","annotationId":"868b97a9-492e-4712-b47f-82b97495b019","useHintRect":false}},"annotations":[{"id":"868b97a9-492e-4712-b47f-82b97495b019","type":"rect","data":{"x1":106,"y1":601,"x2":145,"y2":633}}]}

View File

@ -1 +1 @@
{"definitions":{"85c24a02-dac3-4e6c-978e-b11963e0e92d":{"name":"Produce.BoxProduceOngoing","displayName":"首页开始培育按钮(当前有培育)","type":"hint-box","annotationId":"85c24a02-dac3-4e6c-978e-b11963e0e92d","useHintRect":false},"4fe748c8-a535-4824-aefc-244e3ad34bd4":{"name":"Daily.BoxHomeAssignment","displayName":"首页工作按钮","type":"hint-box","annotationId":"4fe748c8-a535-4824-aefc-244e3ad34bd4","useHintRect":false},"469f7f21-067c-476c-9bfc-c3ec83b935ea":{"name":"Daily.BoxHomeAP","displayName":"首页体力","type":"hint-box","annotationId":"469f7f21-067c-476c-9bfc-c3ec83b935ea","useHintRect":false},"565df1a0-d494-41a4-a3bf-603857bd8dec":{"name":"Daily.BoxHomeJewel","displayName":"首页珠宝数量","type":"hint-box","annotationId":"565df1a0-d494-41a4-a3bf-603857bd8dec","useHintRect":false},"76c92bd0-496b-403e-b545-92eb1f2f941f":{"name":"Daily.BoxHomeActivelyFunds","displayName":"首页活动费按钮","type":"hint-box","annotationId":"76c92bd0-496b-403e-b545-92eb1f2f941f","useHintRect":false}},"annotations":[{"id":"85c24a02-dac3-4e6c-978e-b11963e0e92d","type":"rect","data":{"x1":179,"y1":937,"x2":551,"y2":1091}},{"id":"4fe748c8-a535-4824-aefc-244e3ad34bd4","type":"rect","data":{"x1":16,"y1":642,"x2":127,"y2":752}},{"id":"469f7f21-067c-476c-9bfc-c3ec83b935ea","type":"rect","data":{"x1":291,"y1":4,"x2":500,"y2":82}},{"id":"565df1a0-d494-41a4-a3bf-603857bd8dec","type":"rect","data":{"x1":500,"y1":7,"x2":703,"y2":82}},{"id":"76c92bd0-496b-403e-b545-92eb1f2f941f","type":"rect","data":{"x1":11,"y1":517,"x2":137,"y2":637}}]}
{"definitions":{"85c24a02-dac3-4e6c-978e-b11963e0e92d":{"name":"Produce.BoxProduceOngoing","displayName":"首页开始培育按钮(当前有培育)","type":"hint-box","annotationId":"85c24a02-dac3-4e6c-978e-b11963e0e92d","useHintRect":false},"4fe748c8-a535-4824-aefc-244e3ad34bd4":{"name":"Daily.BoxHomeAssignment","displayName":"首页工作按钮","type":"hint-box","annotationId":"4fe748c8-a535-4824-aefc-244e3ad34bd4","useHintRect":false},"469f7f21-067c-476c-9bfc-c3ec83b935ea":{"name":"Daily.BoxHomeAP","displayName":"首页体力","type":"hint-box","annotationId":"469f7f21-067c-476c-9bfc-c3ec83b935ea","useHintRect":false},"565df1a0-d494-41a4-a3bf-603857bd8dec":{"name":"Daily.BoxHomeJewel","displayName":"首页珠宝数量","type":"hint-box","annotationId":"565df1a0-d494-41a4-a3bf-603857bd8dec","useHintRect":false},"76c92bd0-496b-403e-b545-92eb1f2f941f":{"name":"Daily.BoxHomeActivelyFunds","displayName":"首页活动费按钮","type":"hint-box","annotationId":"76c92bd0-496b-403e-b545-92eb1f2f941f","useHintRect":false}},"annotations":[{"id":"85c24a02-dac3-4e6c-978e-b11963e0e92d","type":"rect","data":{"x1":179,"y1":937,"x2":551,"y2":1091}},{"id":"4fe748c8-a535-4824-aefc-244e3ad34bd4","type":"rect","data":{"x1":33,"y1":650,"x2":107,"y2":746}},{"id":"469f7f21-067c-476c-9bfc-c3ec83b935ea","type":"rect","data":{"x1":291,"y1":4,"x2":500,"y2":82}},{"id":"565df1a0-d494-41a4-a3bf-603857bd8dec","type":"rect","data":{"x1":500,"y1":7,"x2":703,"y2":82}},{"id":"76c92bd0-496b-403e-b545-92eb1f2f941f","type":"rect","data":{"x1":29,"y1":530,"x2":109,"y2":633}}]}

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 KiB

View File

@ -0,0 +1 @@
{"definitions":{"3942ae40-7f22-412c-aebe-4b064f68db9b":{"name":"Shop.IdolPiece.花海咲季_FightingMyWay","displayName":"","type":"template","annotationId":"3942ae40-7f22-412c-aebe-4b064f68db9b","useHintRect":false},"185f7838-92a7-460b-9340-f60858948ce9":{"name":"Shop.IdolPiece.月村手毬_LunaSayMaybe","displayName":"","type":"template","annotationId":"185f7838-92a7-460b-9340-f60858948ce9","useHintRect":false},"cb3d0ca7-8d14-408a-a2f5-2e25f7b86d6c":{"name":"Shop.IdolPiece.藤田ことね_世界一可愛い私 ","displayName":"","type":"template","annotationId":"cb3d0ca7-8d14-408a-a2f5-2e25f7b86d6c","useHintRect":false},"213016c2-c3a2-43d8-86a3-ab4d27666ced":{"name":"Shop.IdolPiece.花海佑芽_TheRollingRiceball","displayName":"","type":"template","annotationId":"213016c2-c3a2-43d8-86a3-ab4d27666ced","useHintRect":false},"cc60b509-2ed5-493d-bb9f-333c6d2a6006":{"name":"Shop.IdolPiece.葛城リーリヤ_白線","displayName":"","type":"template","annotationId":"cc60b509-2ed5-493d-bb9f-333c6d2a6006","useHintRect":false},"5031808b-5525-4118-92b4-317ec8bda985":{"name":"Shop.IdolPiece.紫云清夏_TameLieOneStep","displayName":"","type":"template","annotationId":"5031808b-5525-4118-92b4-317ec8bda985","useHintRect":false},"ae9fe233-9acc-4e96-ba8e-1fb1d9bc2ea5":{"name":"Shop.IdolPiece.篠泽广_光景","displayName":"","type":"template","annotationId":"ae9fe233-9acc-4e96-ba8e-1fb1d9bc2ea5","useHintRect":false},"8f8b7b46-53bb-42ab-907a-4ea87eb09ab4":{"name":"Shop.IdolPiece.倉本千奈_WonderScale","displayName":"","type":"template","annotationId":"8f8b7b46-53bb-42ab-907a-4ea87eb09ab4","useHintRect":false},"0d9ac648-eefa-4869-ac99-1b0c83649681":{"name":"Shop.IdolPiece.有村麻央_Fluorite","displayName":"","type":"template","annotationId":"0d9ac648-eefa-4869-ac99-1b0c83649681","useHintRect":false}},"annotations":[{"id":"3942ae40-7f22-412c-aebe-4b064f68db9b","type":"rect","data":{"x1":409,"y1":342,"x2":477,"y2":413}},{"id":"185f7838-92a7-460b-9340-f60858948ce9","type":"rect","data":{"x1":71,"y1":512,"x2":140,"y2":585}},{"id":"cb3d0ca7-8d14-408a-a2f5-2e25f7b86d6c","type":"rect","data":{"x1":410,"y1":513,"x2":475,"y2":581}},{"id":"213016c2-c3a2-43d8-86a3-ab4d27666ced","type":"rect","data":{"x1":585,"y1":858,"x2":640,"y2":913}},{"id":"cc60b509-2ed5-493d-bb9f-333c6d2a6006","type":"rect","data":{"x1":247,"y1":690,"x2":303,"y2":743}},{"id":"5031808b-5525-4118-92b4-317ec8bda985","type":"rect","data":{"x1":80,"y1":860,"x2":133,"y2":908}},{"id":"ae9fe233-9acc-4e96-ba8e-1fb1d9bc2ea5","type":"rect","data":{"x1":418,"y1":852,"x2":471,"y2":912}},{"id":"8f8b7b46-53bb-42ab-907a-4ea87eb09ab4","type":"rect","data":{"x1":589,"y1":679,"x2":639,"y2":742}},{"id":"0d9ac648-eefa-4869-ac99-1b0c83649681","type":"rect","data":{"x1":83,"y1":690,"x2":136,"y2":744}}]}

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 KiB

View File

@ -0,0 +1 @@
{"definitions":{"921eefeb-730e-46fc-9924-d338fb286592":{"name":"Shop.IdolPiece.姬崎莉波_clumsy_trick","displayName":"","type":"template","annotationId":"921eefeb-730e-46fc-9924-d338fb286592","useHintRect":false}},"annotations":[{"id":"921eefeb-730e-46fc-9924-d338fb286592","type":"rect","data":{"x1":88,"y1":914,"x2":141,"y2":963}}]}

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 KiB

View File

@ -0,0 +1 @@
{"definitions":{"5d36b880-7b3f-49b1-a018-7de59867d376":{"name":"Daily.TextShopItemPurchased","displayName":"交換しました","type":"template","annotationId":"5d36b880-7b3f-49b1-a018-7de59867d376","useHintRect":false}},"annotations":[{"id":"5d36b880-7b3f-49b1-a018-7de59867d376","type":"rect","data":{"x1":275,"y1":626,"x2":432,"y2":655}}]}

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 KiB

View File

@ -0,0 +1 @@
{"definitions":{"24dc7158-036c-4a66-9280-e934f470be53":{"name":"Daily.TextShopItemSoldOut","displayName":"交換済みです","type":"template","annotationId":"24dc7158-036c-4a66-9280-e934f470be53","useHintRect":false}},"annotations":[{"id":"24dc7158-036c-4a66-9280-e934f470be53","type":"rect","data":{"x1":287,"y1":625,"x2":434,"y2":655}}]}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 405 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 973 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 885 KiB

View File

@ -0,0 +1 @@
{"definitions":{"a0bd6a5f-784d-4f0a-9d66-10f4b80c8d3e":{"name":"Produce.LogoNia","displayName":"NIA LOGO (NEXT IDOL AUDITION)","type":"template","annotationId":"a0bd6a5f-784d-4f0a-9d66-10f4b80c8d3e","useHintRect":false},"48a458a9-b6cf-4199-850e-78f679f4f337":{"name":"Produce.PointNiaToHajime","displayName":"NIA 左侧翻页箭头","type":"hint-point","annotationId":"48a458a9-b6cf-4199-850e-78f679f4f337","useHintRect":false}},"annotations":[{"id":"a0bd6a5f-784d-4f0a-9d66-10f4b80c8d3e","type":"rect","data":{"x1":195,"y1":424,"x2":540,"y2":466}},{"id":"48a458a9-b6cf-4199-850e-78f679f4f337","type":"point","data":{"x":34,"y":596}}]}

View File

@ -1 +1 @@
{"definitions":{"12c5fd7c-0a6f-423c-bbfa-e88d19806bbe":{"name":"Produce.BoxModeButtons","displayName":"培育模式选择按钮","type":"hint-box","annotationId":"12c5fd7c-0a6f-423c-bbfa-e88d19806bbe","useHintRect":false}},"annotations":[{"id":"12c5fd7c-0a6f-423c-bbfa-e88d19806bbe","type":"rect","data":{"x1":7,"y1":818,"x2":713,"y2":996}}]}
{"definitions":{"6cd80be8-c9b3-4ba5-bf17-3ffc9b000743":{"name":"Produce.ButtonHajime0Regular","displayName":"","type":"template","annotationId":"6cd80be8-c9b3-4ba5-bf17-3ffc9b000743","useHintRect":false},"55f7db71-0a18-4b3d-b847-57959b8d2e32":{"name":"Produce.ButtonHajime0Pro","displayName":"","type":"template","annotationId":"55f7db71-0a18-4b3d-b847-57959b8d2e32","useHintRect":false},"0bf5e34e-afc6-4447-bbac-67026ce2ad26":{"name":"Produce.TitleIconProudce","displayName":"培育页面左上角标题图标","type":"template","annotationId":"0bf5e34e-afc6-4447-bbac-67026ce2ad26","useHintRect":false}},"annotations":[{"id":"6cd80be8-c9b3-4ba5-bf17-3ffc9b000743","type":"rect","data":{"x1":145,"y1":859,"x2":314,"y2":960}},{"id":"55f7db71-0a18-4b3d-b847-57959b8d2e32","type":"rect","data":{"x1":434,"y1":857,"x2":545,"y2":961}},{"id":"0bf5e34e-afc6-4447-bbac-67026ce2ad26","type":"rect","data":{"x1":12,"y1":33,"x2":63,"y2":82}}]}

Binary file not shown.

After

Width:  |  Height:  |  Size: 791 KiB

View File

@ -0,0 +1 @@
{"definitions":{"3b473fe6-e147-477f-b088-9b8fb042a4f6":{"name":"Produce.ButtonHajime1Regular","displayName":"","type":"template","annotationId":"3b473fe6-e147-477f-b088-9b8fb042a4f6","useHintRect":false},"2ededcf5-1d80-4e2a-9c83-2a31998331ce":{"name":"Produce.ButtonHajime1Pro","displayName":"","type":"template","annotationId":"2ededcf5-1d80-4e2a-9c83-2a31998331ce","useHintRect":false},"24e99232-9434-457f-a9a0-69dd7ecf675f":{"name":"Produce.ButtonHajime1Master","displayName":"","type":"template","annotationId":"24e99232-9434-457f-a9a0-69dd7ecf675f","useHintRect":false},"aca9e953-1955-46eb-920c-77b1750bcb34":{"name":"Produce.PointHajimeToNia","displayName":"Hajime 右侧翻页箭头","type":"hint-point","annotationId":"aca9e953-1955-46eb-920c-77b1750bcb34","useHintRect":false},"e6b45405-cd9f-4c6e-a9f1-6ec953747c65":{"name":"Produce.LogoHajime","displayName":"Hajime LOGO 定期公演","type":"template","annotationId":"e6b45405-cd9f-4c6e-a9f1-6ec953747c65","useHintRect":false}},"annotations":[{"id":"3b473fe6-e147-477f-b088-9b8fb042a4f6","type":"rect","data":{"x1":65,"y1":867,"x2":214,"y2":950}},{"id":"2ededcf5-1d80-4e2a-9c83-2a31998331ce","type":"rect","data":{"x1":307,"y1":869,"x2":421,"y2":952}},{"id":"24e99232-9434-457f-a9a0-69dd7ecf675f","type":"rect","data":{"x1":521,"y1":863,"x2":657,"y2":951}},{"id":"aca9e953-1955-46eb-920c-77b1750bcb34","type":"point","data":{"x":680,"y":592}},{"id":"e6b45405-cd9f-4c6e-a9f1-6ec953747c65","type":"rect","data":{"x1":274,"y1":169,"x2":443,"y2":212}}]}

View File

@ -1 +1 @@
{"definitions":{"e88c9ad1-ec37-4fcd-b086-862e1e7ce8fd":{"name":"Produce.ButtonPIdolOverview","displayName":"Pアイドルー覧 P偶像列表展示","type":"template","annotationId":"e88c9ad1-ec37-4fcd-b086-862e1e7ce8fd","useHintRect":false},"44ba8515-4a60-42c9-8878-b42e4e34ee15":{"name":"Produce.TextStepIndicator1","displayName":"1. アイドル選択","type":"template","annotationId":"44ba8515-4a60-42c9-8878-b42e4e34ee15","useHintRect":false}},"annotations":[{"id":"e88c9ad1-ec37-4fcd-b086-862e1e7ce8fd","type":"rect","data":{"x1":49,"y1":736,"x2":185,"y2":759},"tip":"Pアイドルー覧 P偶像列表展示"},{"id":"44ba8515-4a60-42c9-8878-b42e4e34ee15","type":"rect","data":{"x1":18,"y1":32,"x2":168,"y2":66}}]}
{"definitions":{"e88c9ad1-ec37-4fcd-b086-862e1e7ce8fd":{"name":"Produce.ButtonPIdolOverview","displayName":"Pアイドルー覧 P偶像列表展示","type":"template","annotationId":"e88c9ad1-ec37-4fcd-b086-862e1e7ce8fd","useHintRect":false},"44ba8515-4a60-42c9-8878-b42e4e34ee15":{"name":"Produce.TextStepIndicator1","displayName":"1. アイドル選択","type":"template","annotationId":"44ba8515-4a60-42c9-8878-b42e4e34ee15","useHintRect":false},"34606d7d-52c8-4cd1-b7f4-b31032f1fb70":{"name":"Produce.BoxSelectedIdol","displayName":"当前选中的偶像","type":"hint-box","annotationId":"34606d7d-52c8-4cd1-b7f4-b31032f1fb70","useHintRect":false,"description":"偶像选择界面当前选中的偶像"}},"annotations":[{"id":"e88c9ad1-ec37-4fcd-b086-862e1e7ce8fd","type":"rect","data":{"x1":49,"y1":736,"x2":185,"y2":759},"tip":"Pアイドルー覧 P偶像列表展示"},{"id":"44ba8515-4a60-42c9-8878-b42e4e34ee15","type":"rect","data":{"x1":18,"y1":32,"x2":168,"y2":66}},{"id":"34606d7d-52c8-4cd1-b7f4-b31032f1fb70","type":"rect","data":{"x1":149,"y1":783,"x2":317,"y2":1006}}]}

Binary file not shown.

After

Width:  |  Height:  |  Size: 934 KiB

View File

@ -0,0 +1 @@
{"definitions":{"d3424d31-0502-4623-996e-f0194e5085ce":{"name":"Produce.EmptySupportCardSlot","displayName":"空支援卡槽位","type":"template","annotationId":"d3424d31-0502-4623-996e-f0194e5085ce","useHintRect":false}},"annotations":[{"id":"d3424d31-0502-4623-996e-f0194e5085ce","type":"rect","data":{"x1":481,"y1":844,"x2":692,"y2":962}}]}

Binary file not shown.

After

Width:  |  Height:  |  Size: 531 KiB

View File

@ -0,0 +1 @@
{"definitions":{"f5c16d2f-ebc5-4617-9b96-971696af7c52":{"name":"Produce.TextAutoSet","displayName":"おまかせ編成","type":"template","annotationId":"f5c16d2f-ebc5-4617-9b96-971696af7c52","useHintRect":false}},"annotations":[{"id":"f5c16d2f-ebc5-4617-9b96-971696af7c52","type":"rect","data":{"x1":56,"y1":919,"x2":257,"y2":957}}]}

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 KiB

View File

@ -0,0 +1 @@
{"definitions":{"74ec3510-583d-4a76-ac69-38480fbf1387":{"name":"Produce.TextRentAvailable","displayName":"レンタル可能","type":"template","annotationId":"74ec3510-583d-4a76-ac69-38480fbf1387","useHintRect":false}},"annotations":[{"id":"74ec3510-583d-4a76-ac69-38480fbf1387","type":"rect","data":{"x1":53,"y1":848,"x2":256,"y2":887}}]}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -145,6 +145,18 @@ class KotoneBot:
"""
raise NotImplementedError('Implement `_create_device` before using Kotonebot.')
def _on_init_context(self) -> None:
"""
初始化 Context 的钩子方法子类可以重写此方法来自定义初始化逻辑
默认实现调用 init_context 而不传入 target_screenshot_interval
"""
d = self._on_create_device()
init_context(
config_path=self.config_path,
config_type=self.config_type,
target_device=d
)
def _on_after_init_context(self):
"""
抽象方法 init_context() 被调用后立即执行
@ -155,8 +167,7 @@ class KotoneBot:
"""
按优先级顺序运行所有任务
"""
d = self._on_create_device()
init_context(config_path=self.config_path, config_type=self.config_type, target_device=d)
self._on_init_context()
self._on_after_init_context()
vars.flow.clear_interrupt()

View File

@ -25,8 +25,9 @@ from typing_extensions import deprecated
import cv2
from cv2.typing import MatLike
from kotonebot.client.device import Device
from kotonebot.client.device import Device, AndroidDevice, WindowsDevice
from kotonebot.backend.flow_controller import FlowController
from kotonebot.util import Interval
import kotonebot.backend.image as raw_image
from kotonebot.backend.image import (
TemplateMatchResult,
@ -46,12 +47,10 @@ from kotonebot.backend.color import (
from kotonebot.backend.ocr import (
Ocr, OcrResult, OcrResultList, jp, en, StringMatchFunction
)
from kotonebot.client.factory import create_device
from kotonebot.config.manager import load_config, save_config
from kotonebot.config.base_config import UserConfig
from kotonebot.backend.core import Image, HintBox
from kotonebot.errors import KotonebotWarning
from kotonebot.client.factory import DeviceImpl
from kotonebot.errors import ContextNotInitializedError, KotonebotWarning
from kotonebot.backend.preprocessor import PreprocessorProtocol
from kotonebot.primitives import Rect
@ -286,11 +285,17 @@ class ContextOcr:
self.context = context
self.__engine = jp()
def raw(self, lang: OcrLanguage = 'jp') -> Ocr:
def _get_engine(self, lang: OcrLanguage | None = None) -> Ocr:
"""获取指定语言的OCR引擎如果lang为None则使用默认引擎。"""
return self.__engine if lang is None else self.raw(lang)
def raw(self, lang: OcrLanguage | None = None) -> Ocr:
"""
返回 `kotonebot.backend.ocr` 中的 Ocr 对象\n
Ocr 对象与此对象ContextOcr的区别是此对象会自动截图 Ocr 对象需要手动传入图像参数
"""
if lang is None:
lang = 'jp'
match lang:
case 'jp':
return jp()
@ -302,9 +307,11 @@ class ContextOcr:
def ocr(
self,
rect: Rect | None = None,
lang: OcrLanguage | None = None,
) -> OcrResultList:
"""OCR 当前设备画面或指定图像。"""
return self.__engine.ocr(ContextStackVars.ensure_current().screenshot, rect=rect)
engine = self._get_engine(lang)
return engine.ocr(ContextStackVars.ensure_current().screenshot, rect=rect)
def find(
self,
@ -312,9 +319,11 @@ class ContextOcr:
*,
hint: HintBox | None = None,
rect: Rect | None = None,
lang: OcrLanguage | None = None,
) -> OcrResult | None:
"""检查当前设备画面是否包含指定文本。"""
ret = self.__engine.find(
engine = self._get_engine(lang)
ret = engine.find(
ContextStackVars.ensure_current().screenshot,
pattern,
hint=hint,
@ -329,9 +338,10 @@ class ContextOcr:
*,
hint: HintBox | None = None,
rect: Rect | None = None,
lang: OcrLanguage | None = None,
) -> list[OcrResult | None]:
return self.__engine.find_all(
engine = self._get_engine(lang)
return engine.find_all(
ContextStackVars.ensure_current().screenshot,
list(patterns),
hint=hint,
@ -344,6 +354,7 @@ class ContextOcr:
*,
rect: Rect | None = None,
hint: HintBox | None = None,
lang: OcrLanguage | None = None,
) -> OcrResult:
"""
@ -351,7 +362,8 @@ class ContextOcr:
`find()` 的区别在于`expect()` 未找到时会抛出异常
"""
ret = self.__engine.expect(ContextStackVars.ensure_current().screenshot, pattern, rect=rect, hint=hint)
engine = self._get_engine(lang)
ret = engine.expect(ContextStackVars.ensure_current().screenshot, pattern, rect=rect, hint=hint)
self.context.device.last_find = ret.original_rect if ret else None
return ret
@ -707,20 +719,33 @@ class Forwarded:
if name.startswith('_FORWARD_'):
return object.__getattribute__(self, name)
if self._FORWARD_getter is None:
raise ValueError(f"Forwarded object {self._FORWARD_name} called before initialization.")
raise ContextNotInitializedError(f"Forwarded object {self._FORWARD_name} called before initialization.")
return getattr(self._FORWARD_getter(), name)
def __setattr__(self, name: str, value: Any):
if name.startswith('_FORWARD_'):
return object.__setattr__(self, name, value)
if self._FORWARD_getter is None:
raise ValueError(f"Forwarded object {self._FORWARD_name} called before initialization.")
raise ContextNotInitializedError(f"Forwarded object {self._FORWARD_name} called before initialization.")
setattr(self._FORWARD_getter(), name, value)
# HACK: 这应该要有个更好的实现方式
class ContextDevice(Device):
def __init__(self, device: Device):
T_Device = TypeVar('T_Device', bound=Device)
class ContextDevice(Generic[T_Device], Device):
def __init__(self, device: T_Device, target_screenshot_interval: float | None = None):
"""
:param device: 目标设备
:param target_screenshot_interval: `ContextDevice.target_screenshot_interval`
"""
self._device = device
self.target_screenshot_interval: float | None = target_screenshot_interval
"""
目标截图间隔可用于限制截图速度若两次截图实际间隔小于该值则会自动等待
None 时不限制截图速度
"""
self._screenshot_interval: Interval | None = None
if self.target_screenshot_interval is not None:
self._screenshot_interval = Interval(self.target_screenshot_interval)
def screenshot(self, *, force: bool = False):
"""
@ -735,6 +760,9 @@ class ContextDevice(Device):
img = current._inherit_screenshot
current._inherit_screenshot = None
else:
if self._screenshot_interval is not None:
self._screenshot_interval.wait()
if next_wait == 'screenshot':
delta = time.time() - last_screenshot_time
if delta < next_wait_time:
@ -746,26 +774,43 @@ class ContextDevice(Device):
current._screenshot = img
return img
def __getattribute__(self, name: str) -> Any:
if name in ['_device', 'screenshot']:
def __getattribute__(self, name: str):
if name in ['_device', 'screenshot', 'of_android', 'of_windows']:
return object.__getattribute__(self, name)
else:
return getattr(self._device, name)
def __setattr__(self, name: str, value: Any):
if name in ['_device', 'screenshot']:
if name in ['_device', 'screenshot', 'of_android', 'of_windows']:
return object.__setattr__(self, name, value)
else:
return setattr(self._device, name, value)
def of_android(self) -> 'ContextDevice | AndroidDevice':
"""
确保此 ContextDevice 底层为 Android 平台
同时通过返回的对象可以调用 Android 平台特有的方法
"""
if not isinstance(self._device, AndroidDevice):
raise ValueError("Device is not AndroidDevice")
return self
def of_windows(self) -> 'ContextDevice | WindowsDevice':
"""
确保此 ContextDevice 底层为 Windows 平台
同时通过返回的对象可以调用 Windows 平台特有的方法
"""
if not isinstance(self._device, WindowsDevice):
raise ValueError("Device is not WindowsDevice")
return self
class Context(Generic[T]):
def __init__(
self,
config_path: str,
config_type: Type[T],
screenshot_impl: Optional[DeviceImpl] = None,
device: Optional[Device] = None
device: Device,
target_screenshot_interval: float | None = None
):
self.__ocr = ContextOcr(self)
self.__image = ContextImage(self)
@ -773,14 +818,7 @@ class Context(Generic[T]):
self.__vars = ContextGlobalVars()
self.__debug = ContextDebug(self)
self.__config = ContextConfig[T](self, config_path, config_type)
ip = self.config.current.backend.adb_ip
port = self.config.current.backend.adb_port
# TODO: 处理链接失败情况
if screenshot_impl is None:
screenshot_impl = self.config.current.backend.screenshot_impl
logger.info(f'Using "{screenshot_impl}" as screenshot implementation')
self.__device = ContextDevice(device or create_device(f'{ip}:{port}', screenshot_impl))
self.__device = ContextDevice(device, target_screenshot_interval)
def inject(
self,
@ -891,8 +929,8 @@ def init_context(
config_path: str = 'config.json',
config_type: Type[T] = dict[str, Any],
force: bool = False,
screenshot_impl: Optional[DeviceImpl] = None,
target_device: Device | None = None,
target_device: Device,
target_screenshot_interval: float | None = None,
):
"""
初始化 Context 模块
@ -903,9 +941,8 @@ def init_context(
默认为 `dict[str, Any]`即普通的 JSON 数据不包含任何类型信息
:param force: 是否强制重新初始化
若为 `True`则忽略已存在的 Context 实例并重新创建一个新的实例
:param screenshot_impl: 截图实现
若为 `None`则使用默认配置文件中指定的截图实现
:param target_device: 目标设备
:param target_screenshot_interval: `ContextDevice.target_screenshot_interval`
"""
global _c, device, ocr, image, color, vars, debug, config
if _c is not None and not force:
@ -913,8 +950,8 @@ def init_context(
_c = Context(
config_path=config_path,
config_type=config_type,
screenshot_impl=screenshot_impl,
device=target_device
device=target_device,
target_screenshot_interval=target_screenshot_interval,
)
device._FORWARD_getter = lambda: _c.device # type: ignore
ocr._FORWARD_getter = lambda: _c.ocr # type: ignore
@ -937,7 +974,7 @@ def inject_context(
):
global _c
if _c is None:
raise RuntimeError('Context not initialized')
raise ContextNotInitializedError('Context not initialized')
_c.inject(device=device, ocr=ocr, image=image, color=color, vars=vars, debug=debug, config=config)
class ManualContextManager:

View File

@ -216,8 +216,6 @@ def wait_message_all_done():
threading.Thread(target=_wait, daemon=True).start()
if __name__ == "__main__":
from kotonebot.backend.context import init_context
init_context()
debug_vars.debug.hide_server_log = False
process = subprocess.Popen(["pylsp", "--port", "5479", "--ws"])
print("LSP started. PID=", process.pid)

View File

@ -328,20 +328,4 @@ class SimpleDispatcher:
self.result = self.timeout_result
break
device.screenshot()
return self.result
if __name__ == '__main__':
from .context.task_action import action
from .context import init_context
init_context()
@action('inner', dispatcher=True)
def inner(ctx: DispatcherContext):
print('inner')
ctx.finish()
@action('test', dispatcher=True)
def test(ctx: DispatcherContext):
print('test')
inner()
ctx.finish()
test()
return self.result

277
kotonebot/backend/loop.py Normal file
View File

@ -0,0 +1,277 @@
import time
from functools import lru_cache, partial
from typing import Callable, Any, overload, Literal, Generic, TypeVar, cast, get_args, get_origin
from cv2.typing import MatLike
from kotonebot.util import Interval
from kotonebot import device, image, ocr
from kotonebot.backend.core import Image
from kotonebot.backend.ocr import TextComparator
from kotonebot.client.protocol import ClickableObjectProtocol
class LoopAction:
def __init__(self, loop: 'Loop', func: Callable[[], ClickableObjectProtocol | None]):
self.loop = loop
self.func = func
self.result: ClickableObjectProtocol | None = None
@property
def found(self):
"""
是否找到结果若父 Loop 未在运行中则返回 False
"""
if not self.loop.running:
return False
return bool(self.result)
def __bool__(self):
return self.found
def reset(self):
"""
重置 LoopAction以复用此对象
"""
self.result = None
def do(self):
"""
执行 LoopAction
:return: 执行结果
"""
if not self.loop.running:
return
if self.loop.found_anything:
# 本轮循环已执行任意操作,因此不需要再继续检测
return
self.result = self.func()
if self.result:
self.loop.found_anything = True
def click(self, *, at: tuple[int, int] | None = None):
"""
点击寻找结果若结果为空会跳过执行
:return:
"""
if self.result:
if at is not None:
device.click(*at)
else:
device.click(self.result)
def call(self, func: Callable[[ClickableObjectProtocol], Any]):
pass
class Loop:
def __init__(
self,
*,
timeout: float = 300,
interval: float = 0.3,
auto_screenshot: bool = True
):
self.running = True
self.found_anything = False
self.auto_screenshot = auto_screenshot
"""
是否在每次循环开始时Loop.tick() 被调用时截图
"""
self.__last_loop: float = -1
self.__interval = Interval(interval)
self.screenshot: MatLike | None = None
"""上次截图时的图像数据。"""
def __iter__(self):
self.__interval.reset()
return self
def __next__(self):
if not self.running:
raise StopIteration
self.found_anything = False
self.__last_loop = time.time()
return self.tick()
def tick(self):
self.__interval.wait()
if self.auto_screenshot:
self.screenshot = device.screenshot()
self.__last_loop = time.time()
self.found_anything = False
return self
def exit(self):
"""
结束循环
"""
self.running = False
@overload
def when(self, condition: Image) -> LoopAction:
...
@overload
def when(self, condition: TextComparator) -> LoopAction:
...
def when(self, condition: Any):
"""
判断某个条件是否成立
:param condition:
:return:
"""
if isinstance(condition, Image):
func = partial(image.find, condition)
elif isinstance(condition, TextComparator):
func = partial(ocr.find, condition)
else:
raise ValueError('Invalid condition type.')
la = LoopAction(self, func)
la.reset()
la.do()
return la
def until(self, condition: Any):
"""
当满足指定条件时结束循环
等价于 ``loop.when(...).call(lambda _: loop.exit())``
"""
return self.when(condition).call(lambda _: self.exit())
def click_if(self, condition: Any, *, at: tuple[int, int] | None = None):
"""
检测指定对象是否出现若出现点击该对象或指定位置
``click_if()`` 等价于 ``loop.when(...).click(...)``
:param condition: 检测目标
:param at: 点击位置若为 None表示点击找到的目标
"""
return self.when(condition).click(at=at)
StateType = TypeVar('StateType')
class StatedLoop(Loop, Generic[StateType]):
def __init__(
self,
states: list[Any] | None = None,
initial_state: StateType | None = None,
*,
timeout: float = 300,
interval: float = 0.3,
auto_screenshot: bool = True
):
self.__tmp_states = states
self.__tmp_initial_state = initial_state
self.state: StateType
super().__init__(timeout=timeout, interval=interval, auto_screenshot=auto_screenshot)
def __iter__(self):
# __retrive_state_values() 只能在非 __init__ 中调用
self.__retrive_state_values()
return super().__iter__()
def __retrive_state_values(self):
# HACK: __orig_class__ 是 undocumented 属性
if not hasattr(self, '__orig_class__'):
# 如果 Foo 不是以参数化泛型的方式实例化的,可能没有 __orig_class__
if self.state is None:
raise ValueError('Either specify `states` or use StatedLoop[Literal[...]] syntax.')
else:
generic_type_args = get_args(self.__orig_class__) # type: ignore
if len(generic_type_args) != 1:
raise ValueError('StatedLoop must have exactly one generic type argument.')
state_values = get_args(generic_type_args[0])
if not state_values:
raise ValueError('StatedLoop must have at least one state value.')
self.states = cast(tuple[StateType, ...], state_values)
self.state = self.__tmp_initial_state or self.states[0]
return state_values
def StatedLoop2(states: StateType) -> StatedLoop[StateType]:
state_values = get_args(states)
return cast(StatedLoop[StateType], Loop())
if __name__ == '__main__':
from kotonebot.kaa.tasks import R
from kotonebot.backend.ocr import contains
from kotonebot.backend.context import manual_context, init_context
# T = TypeVar('T')
# class Foo(Generic[T]):
# def get_literal_params(self) -> list | None:
# """
# 尝试获取泛型参数 T (如果它是 Literal 类型) 的参数列表。
# """
# # self.__orig_class__ 会是 Foo 的具体参数化类型,
# # 例如 Foo[Literal['p0', 'p1', 'p2', 'p3', 'ap']]
# if not hasattr(self, '__orig_class__'):
# # 如果 Foo 不是以参数化泛型的方式实例化的,可能没有 __orig_class__
# return None
#
# # generic_type_args 是传递给 Foo 的类型参数元组
# # 例如 (Literal['p0', 'p1', 'p2', 'p3', 'ap'],)
# generic_type_args = get_args(self.__orig_class__)
#
# if not generic_type_args:
# # Foo 没有类型参数
# return None
#
# # T_type 是 Foo 的第一个类型参数
# # 例如 Literal['p0', 'p1', 'p2', 'p3', 'ap']
# t_type = generic_type_args[0]
#
# # 检查 T_type 是否是 Literal 类型
# if get_origin(t_type) is Literal:
# # literal_args 是 Literal 类型的参数元组
# # 例如 ('p0', 'p1', 'p2', 'p3', 'ap')
# literal_args = get_args(t_type)
# return list(literal_args)
# else:
# # T 不是 Literal 类型
# return None
# f = Foo[Literal['p0', 'p1', 'p2', 'p3', 'ap']]()
# values = f.get_literal_params()
# 1
from typing_extensions import reveal_type
slp = StatedLoop[Literal['p0', 'p1', 'p2', 'p3', 'ap']]()
for l in slp:
reveal_type(l.states)
# init_context()
# manual_context().begin()
# for l in Loop():
# l.when(R.Produce.ButtonUse).click()
# l.when(R.Produce.ButtonRefillAP).click()
# l.when(contains("123")).click()
# l.click_if(contains("!23"), at=(1, 2))
# State = Literal['p0', 'p1', 'p2', 'p3', 'ap']
# for sl in StatedLoop[State]():
# match sl.state:
# case 'p0':
# sl.click_if(R.Produce.ButtonProduce)
# sl.click_if(contains('master'))
# sl.when(R.Produce.ButtonPIdolOverview).goto('p1')
# # AP 不足
# sl.when(R.Produce.TextAPInsufficient).goto('ap')
# case 'ap':
# pass
# # p1: 选择偶像
# case 'p1':
# sl.call(lambda _: select_idol(idol_skin_id), once=True)
# sl.when(R.Produce.TextAnotherIdolAvailableDialog).call(dialog.no)
# sl.click_if(R.Common.ButtonNextNoIcon)
# sl.until(R.Produce.TextStepIndicator2).goto('p2')
# case 'p2':
# sl.when(contains("123")).click()
# case 'p3':
# sl.click_if(contains("!23"), at=(1, 2))
# case _:
# assert_never(sl.state)

View File

@ -1,8 +1,10 @@
from .device import Device
from .factory import create_device, DeviceImpl
from .registration import DeviceImpl
# 确保所有实现都被注册
from . import implements # noqa: F401
__all__ = [
'Device',
'create_device',
'DeviceImpl',
]

View File

@ -9,9 +9,10 @@ from cv2.typing import MatLike
from adbutils._device import AdbDevice as AdbUtilsDevice
from ..backend.debug import result
from ..errors import UnscalableResolutionError
from kotonebot.backend.core import HintBox
from kotonebot.primitives import Rect, Point, is_point
from .protocol import ClickableObjectProtocol, Commandable, Touchable, Screenshotable
from .protocol import ClickableObjectProtocol, Commandable, Touchable, Screenshotable, AndroidCommandable, WindowsCommandable
logger = logging.getLogger(__name__)
@ -28,31 +29,6 @@ class HookContextManager:
def __exit__(self, exc_type, exc_value, traceback):
self.device.screenshot_hook_after = self.old_func
class PinContextManager:
def __init__(self, device: 'Device'):
self.device = device
self.old_hook = device.screenshot_hook_before
self.memo = None
def __hook(self) -> MatLike:
if self.memo is None:
self.memo = self.device.screenshot_raw()
return self.memo
def __enter__(self):
self.device.screenshot_hook_before = self.__hook
return self
def __exit__(self, exc_type, exc_value, traceback):
self.device.screenshot_hook_before = self.old_hook
def update(self) -> None:
"""
更新记住的截图
"""
self.memo = self.device.screenshot_raw()
class Device:
def __init__(self, platform: str = 'unknown') -> None:
self.screenshot_hook_after: Callable[[MatLike], MatLike] | None = None
@ -71,7 +47,6 @@ class Device:
横屏时为 'landscape'竖屏时为 'portrait'
"""
self._command: Commandable
self._touch: Touchable
self._screenshot: Screenshotable
@ -79,6 +54,31 @@ class Device:
"""
设备平台名称
"""
self.target_resolution: tuple[int, int] | None = None
"""
目标分辨率
若设置则在截图点击滑动等时会缩放到目标分辨率
仅支持等比例缩放若无法等比例缩放则会抛出异常 `UnscalableResolutionError`
"""
self.match_rotation: bool = True
"""
分辨率缩放是否自动匹配旋转
当目标与真实分辨率的宽高比不一致时是否允许通过旋转交换宽高后再进行匹配
True 则忽略方向差异只要宽高比一致就视为可缩放False 则必须匹配旋转
例如当目标分辨率为 1920x1080而真实分辨率为 1080x1920
``match_rotation`` True 则认为可以缩放 False 则会抛出异常
"""
self.aspect_ratio_tolerance: float = 0.1
"""
宽高比容差阈值
判断两分辨率宽高比差异是否接受的阈值
该值越小对比例一致性的要求越严格
默认为 0.1 10% 容差
"""
@property
def adb(self) -> AdbUtilsDevice:
@ -90,12 +90,50 @@ class Device:
def adb(self, value: AdbUtilsDevice) -> None:
self._adb = value
def launch_app(self, package_name: str) -> None:
"""
根据包名启动 app
"""
self._command.launch_app(package_name)
def _scale_pos_real_to_target(self, real_x: int, real_y: int) -> tuple[int, int]:
"""将真实屏幕坐标缩放到目标逻辑坐标"""
if self.target_resolution is None:
return real_x, real_y
real_w, real_h = self.screen_size
target_w, target_h = self.target_resolution
# 校验分辨率是否可缩放并获取调整后的目标分辨率
adjusted_target_w, adjusted_target_h = self.__assert_scalable((real_w, real_h), (target_w, target_h))
scale_w = adjusted_target_w / real_w
scale_h = adjusted_target_h / real_h
return int(real_x * scale_w), int(real_y * scale_h)
def _scale_pos_target_to_real(self, target_x: int, target_y: int) -> tuple[int, int]:
"""将目标逻辑坐标缩放到真实屏幕坐标"""
if self.target_resolution is None:
return target_x, target_y # 输入坐标已是真实坐标
real_w, real_h = self.screen_size
target_w, target_h = self.target_resolution
# 校验分辨率是否可缩放并获取调整后的目标分辨率
adjusted_target_w, adjusted_target_h = self.__assert_scalable((real_w, real_h), (target_w, target_h))
scale_to_real_w = real_w / adjusted_target_w
scale_to_real_h = real_h / adjusted_target_h
return int(target_x * scale_to_real_w), int(target_y * scale_to_real_h)
def __scale_image (self, img: MatLike) -> MatLike:
if self.target_resolution is None:
return img
target_w, target_h = self.target_resolution
h, w = img.shape[:2]
# 校验分辨率是否可缩放并获取调整后的目标分辨率
adjusted_target = self.__assert_scalable((w, h), (target_w, target_h))
return cv2.resize(img, adjusted_target)
@overload
def click(self) -> None:
"""
@ -168,7 +206,12 @@ class Device:
logger.debug(f"Executing click hook before: ({x}, {y})")
x, y = hook(x, y)
logger.debug(f"Click hook before result: ({x}, {y})")
logger.debug(f"Click: {x}, {y}")
if self.target_resolution is not None:
# 输入坐标为逻辑坐标,需要转换为真实坐标
real_x, real_y = self._scale_pos_target_to_real(x, y)
else:
real_x, real_y = x, y
logger.debug(f"Click: {x}, {y}%s", f"(Physical: {real_x}, {real_y})" if self.target_resolution is not None else "")
from ..backend.context import ContextStackVars
if ContextStackVars.current() is not None:
image = ContextStackVars.ensure_current()._screenshot
@ -176,9 +219,11 @@ class Device:
image = np.array([])
if image is not None and image.size > 0:
cv2.circle(image, (x, y), 10, (0, 0, 255), -1)
message = f"point: ({x}, {y})"
message = f"Point: ({x}, {y})"
if self.target_resolution is not None:
message += f" physical: ({real_x}, {real_y})"
result("device.click", image, message)
self._touch.click(x, y)
self._touch.click(real_x, real_y)
def __click_point_tuple(self, point: Point) -> None:
self.click(point[0], point[1])
@ -239,6 +284,10 @@ class Device:
"""
滑动屏幕
"""
if self.target_resolution is not None:
# 输入坐标为逻辑坐标,需要转换为真实坐标
x1, y1 = self._scale_pos_target_to_real(x1, y1)
x2, y2 = self._scale_pos_target_to_real(x2, y2)
self._touch.swipe(x1, y1, x2, y2, duration)
def swipe_scaled(self, x1: float, y1: float, x2: float, y2: float, duration: float|None = None) -> None:
@ -265,6 +314,7 @@ class Device:
logger.debug("screenshot hook before returned image")
return img
img = self.screenshot_raw()
img = self.__scale_image(img)
if self.screenshot_hook_after is not None:
img = self.screenshot_hook_after(img)
return img
@ -281,16 +331,6 @@ class Device:
"""
return HookContextManager(self, func)
@deprecated('改用 @task/@action 装饰器中的 screenshot_mode 参数')
def pinned(self) -> PinContextManager:
"""
记住下次截图结果并将截图调整为手动挡
之后截图都会返回记住的数据节省重复截图时间
调用返回对象中的 PinContextManager.update() 可以立刻更新记住的截图
"""
return PinContextManager(self)
@property
def screen_size(self) -> tuple[int, int]:
"""
@ -303,19 +343,15 @@ class Device:
`self.orientation` 属性默认为竖屏如果需要自动检测
调用 `self.detect_orientation()` 方法
如果已知方向也可以直接设置 `self.orientation` 属性
即使设置了 `self.target_resolution`返回的分辨率仍然是真实分辨率
"""
return self._screenshot.screen_size
def current_package(self) -> str | None:
"""
获取前台 APP 的包名
:return: 前台 APP 的包名如果获取失败则返回 None
:exception: 如果设备不支持此功能则抛出 NotImplementedError
"""
ret = self._command.current_package()
logger.debug("current_package: %s", ret)
return ret
size = self._screenshot.screen_size
if self.orientation == 'landscape':
size = sorted(size, reverse=True)
else:
size = sorted(size, reverse=False)
return size[0], size[1]
def detect_orientation(self) -> Literal['portrait', 'landscape'] | None:
"""
@ -325,15 +361,97 @@ class Device:
"""
return self._screenshot.detect_orientation()
def __aspect_ratio_compatible(self, src_size: tuple[int, int], tgt_size: tuple[int, int]) -> bool:
"""
判断两个尺寸在宽高比意义上是否兼容
``self.match_rotation`` True忽略方向长边/短边进行比较
判断标准由 ``self.aspect_ratio_tolerance`` 决定默认 0.1
"""
src_w, src_h = src_size
tgt_w, tgt_h = tgt_size
# 尺寸必须为正
if src_w <= 0 or src_h <= 0:
raise ValueError(f"Source size dimensions must be positive for scaling: {src_size}")
if tgt_w <= 0 or tgt_h <= 0:
raise ValueError(f"Target size dimensions must be positive for scaling: {tgt_size}")
tolerant = self.aspect_ratio_tolerance
# 直接比较宽高比
if abs((tgt_w / src_w) - (tgt_h / src_h)) <= tolerant:
return True
# 尝试忽略方向差异
if self.match_rotation:
ratio_src = max(src_w, src_h) / min(src_w, src_h)
ratio_tgt = max(tgt_w, tgt_h) / min(tgt_w, tgt_h)
return abs(ratio_src - ratio_tgt) <= tolerant
return False
def __assert_scalable(self, source: tuple[int, int], target: tuple[int, int]) -> tuple[int, int]:
"""
校验分辨率是否可缩放并返回调整后的目标分辨率
match_rotation True 且源分辨率与目标分辨率的旋转方向不一致时
自动交换目标分辨率的宽高使其与源分辨率的方向保持一致
:param src_size: 源分辨率 (width, height)
:param tgt_size: 目标分辨率 (width, height)
:return: 调整后的目标分辨率 (width, height)
:raises UnscalableResolutionError: 若宽高比不兼容
"""
# 智能调整目标分辨率方向
adjusted_tgt_size = target
if self.match_rotation:
src_w, src_h = source
tgt_w, tgt_h = target
# 判断源分辨率和目标分辨率的方向
src_is_landscape = src_w > src_h
tgt_is_landscape = tgt_w > tgt_h
# 如果方向不一致,交换目标分辨率的宽高
if src_is_landscape != tgt_is_landscape:
adjusted_tgt_size = (tgt_h, tgt_w)
# 校验调整后的分辨率是否兼容
if not self.__aspect_ratio_compatible(source, adjusted_tgt_size):
raise UnscalableResolutionError(target, source)
return adjusted_tgt_size
class AndroidDevice(Device):
def __init__(self, adb_connection: AdbUtilsDevice | None = None) -> None:
super().__init__('android')
self._adb: AdbUtilsDevice | None = adb_connection
self.commands: AndroidCommandable
def current_package(self) -> str | None:
"""
获取前台 APP 的包名
:return: 前台 APP 的包名如果获取失败则返回 None
:exception: 如果设备不支持此功能则抛出 NotImplementedError
"""
ret = self.commands.current_package()
logger.debug("current_package: %s", ret)
return ret
def launch_app(self, package_name: str) -> None:
"""
根据包名启动 app
"""
self.commands.launch_app(package_name)
class WindowsDevice(Device):
def __init__(self) -> None:
super().__init__('windows')
self.commands: WindowsCommandable
if __name__ == "__main__":
@ -346,10 +464,10 @@ if __name__ == "__main__":
d = adb.device_list()[-1]
d.shell("dumpsys activity top | grep ACTIVITY | tail -n 1")
dd = AndroidDevice(d)
adb_imp = AdbRawImpl(dd)
dd._command = adb_imp
adb_imp = AdbRawImpl(d)
dd._touch = adb_imp
dd._screenshot = adb_imp
dd.commands = adb_imp
# dd._screenshot = MinicapScreenshotImpl(dd)
# dd._screenshot = UiAutomator2Impl(dd)

View File

@ -1,92 +0,0 @@
import logging
from typing import Literal
from .implements.adb import AdbImpl
from .implements.adb_raw import AdbRawImpl
from .implements.windows import WindowsImpl
from .implements.remote_windows import RemoteWindowsImpl
from .implements.uiautomator2 import UiAutomator2Impl
from .device import Device, AndroidDevice, WindowsDevice
from adbutils import adb
DeviceImpl = Literal['adb', 'adb_raw', 'uiautomator2', 'windows', 'remote_windows']
logger = logging.getLogger(__name__)
def create_device(
addr: str,
impl: DeviceImpl,
*,
connect: bool = True,
disconnect: bool = True,
device_serial: str | None = None,
timeout: float = 180,
) -> Device:
"""
根据指定的实现方式创建 Device 实例
:param addr: 设备地址 `127.0.0.1:5555`
仅当通过无线方式连接 Android 设备或者使用 `remote_windows` 时有效
:param impl: 实现方式
:param connect: 是否在创建时连接设备默认为 True
仅对 ADB-based 的实现方式有效
:param disconnect: 是否在连接前先断开设备默认为 True
仅对 ADB-based 的实现方式有效
:param device_serial: 设备序列号默认为 None
若为非 None则当存在多个设备时通过该值判断是否为目标设备
仅对 ADB-based 的实现方式有效
:param timeout: 连接超时时间默认为 180
仅对 ADB-based 的实现方式有效
"""
if impl in ['adb', 'adb_raw', 'uiautomator2']:
if disconnect:
logger.debug('adb disconnect %s', addr)
adb.disconnect(addr)
if connect:
logger.debug('adb connect %s', addr)
result = adb.connect(addr)
if 'cannot connect to' in result:
raise ValueError(result)
serial = device_serial or addr
logger.debug('adb wait for %s', serial)
adb.wait_for(serial, timeout=timeout)
devices = adb.device_list()
logger.debug('adb device_list: %s', devices)
d = [d for d in devices if d.serial == serial]
if len(d) == 0:
raise ValueError(f"Device {addr} not found")
d = d[0]
device = AndroidDevice(d)
if impl == 'adb':
device._command = AdbImpl(device)
device._touch = AdbImpl(device)
device._screenshot = AdbImpl(device)
elif impl == 'adb_raw':
device._command = AdbRawImpl(device)
device._touch = AdbRawImpl(device)
device._screenshot = AdbRawImpl(device)
elif impl == 'uiautomator2':
device._command = UiAutomator2Impl(device)
device._touch = UiAutomator2Impl(device)
device._screenshot = UiAutomator2Impl(device)
elif impl == 'windows':
device = WindowsDevice()
device._touch = WindowsImpl(device)
device._screenshot = WindowsImpl(device)
elif impl == 'remote_windows':
# For remote_windows, addr should be in the format 'host:port'
if ':' not in addr:
raise ValueError(f"Invalid address format for remote_windows: {addr}. Expected format: 'host:port'")
host, port_str = addr.split(':', 1)
try:
port = int(port_str)
except ValueError:
raise ValueError(f"Invalid port in address: {port_str}")
device = WindowsDevice()
remote_impl = RemoteWindowsImpl(device, host, port)
device._touch = remote_impl
device._screenshot = remote_impl
else:
raise ValueError(f"Unsupported device implementation: {impl}")
return device

View File

@ -1,10 +1,11 @@
from .protocol import HostProtocol, Instance
from .protocol import HostProtocol, Instance, AdbHostConfig, WindowsHostConfig, RemoteWindowsHostConfig
from .custom import CustomInstance, create as create_custom
from .mumu12_host import Mumu12Host, Mumu12Instance
from .leidian_host import LeidianHost, LeidianInstance
__all__ = [
'HostProtocol', 'Instance',
'AdbHostConfig', 'WindowsHostConfig', 'RemoteWindowsHostConfig',
'CustomInstance', 'create_custom',
'Mumu12Host', 'Mumu12Instance',
'LeidianHost', 'LeidianInstance'

View File

@ -0,0 +1,94 @@
from abc import ABC
from typing import Any, Literal, TypeGuard, TypeVar, get_args
from typing_extensions import assert_never
from adbutils import adb
from adbutils._device import AdbDevice
from kotonebot import logging
from kotonebot.client.device import AndroidDevice
from .protocol import Instance, AdbHostConfig, Device
logger = logging.getLogger(__name__)
AdbRecipes = Literal['adb', 'adb_raw', 'uiautomator2']
def is_adb_recipe(recipe: Any) -> TypeGuard[AdbRecipes]:
return recipe in get_args(AdbRecipes)
def connect_adb(
ip: str,
port: int,
connect: bool = True,
disconnect: bool = True,
timeout: float = 180,
device_serial: str | None = None
) -> AdbDevice:
"""
创建 ADB 连接
"""
if disconnect:
logger.debug('adb disconnect %s:%d', ip, port)
adb.disconnect(f'{ip}:{port}')
if connect:
logger.debug('adb connect %s:%d', ip, port)
result = adb.connect(f'{ip}:{port}')
if 'cannot connect to' in result:
raise ValueError(result)
serial = device_serial or f'{ip}:{port}'
logger.debug('adb wait for %s', serial)
adb.wait_for(serial, timeout=timeout)
devices = adb.device_list()
logger.debug('adb device_list: %s', devices)
d = [d for d in devices if d.serial == serial]
if len(d) == 0:
raise ValueError(f"Device {serial} not found")
d = d[0]
return d
class CommonAdbCreateDeviceMixin(ABC):
"""
通用 ADB 创建设备的 Mixin
Mixin 定义了创建 ADB 设备的通用接口
"""
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
# 下面的属性只是为了让类型检查通过,无实际实现
self.adb_ip: str
self.adb_port: int
self.adb_name: str
def create_device(self, recipe: AdbRecipes, config: AdbHostConfig) -> Device:
"""
创建 ADB 设备
"""
connection = connect_adb(
self.adb_ip,
self.adb_port,
connect=True,
disconnect=True,
timeout=config.timeout,
device_serial=self.adb_name
)
d = AndroidDevice(connection)
match recipe:
case 'adb':
from kotonebot.client.implements.adb import AdbImpl
impl = AdbImpl(connection)
d._screenshot = impl
d._touch = impl
d.commands = impl
case 'adb_raw':
from kotonebot.client.implements.adb_raw import AdbRawImpl
impl = AdbRawImpl(connection)
d._screenshot = impl
d._touch = impl
d.commands = impl
case 'uiautomator2':
from kotonebot.client.implements.uiautomator2 import UiAutomator2Impl
from kotonebot.client.implements.adb import AdbImpl
impl = UiAutomator2Impl(connection)
d._screenshot = impl
d._touch = impl
d.commands = AdbImpl(connection)
case _:
assert_never(f'Unsupported ADB recipe: {recipe}')
return d

View File

@ -1,19 +1,21 @@
import os
import subprocess
from psutil import process_iter
from .protocol import HostProtocol, Instance
from typing import Optional, ParamSpec, TypeVar, TypeGuard
from .protocol import Instance, AdbHostConfig, HostProtocol
from typing import ParamSpec, TypeVar
from typing_extensions import override
from kotonebot import logging
from kotonebot.client.device import Device
from kotonebot.client import Device
from .adb_common import AdbRecipes, CommonAdbCreateDeviceMixin
logger = logging.getLogger(__name__)
CustomRecipes = AdbRecipes
P = ParamSpec('P')
T = TypeVar('T')
class CustomInstance(Instance):
class CustomInstance(CommonAdbCreateDeviceMixin, Instance[AdbHostConfig]):
def __init__(self, exe_path: str | None, emulator_args: str = "", *args, **kwargs):
super().__init__(*args, **kwargs)
self.exe_path: str | None = exe_path
@ -65,6 +67,14 @@ class CustomInstance(Instance):
def refresh(self):
pass
@override
def create_device(self, impl: CustomRecipes, host_config: AdbHostConfig) -> Device:
"""为自定义实例创建 Device。"""
if self.adb_port is None:
raise ValueError("ADB port is not set and is required.")
return super().create_device(impl, host_config)
def __repr__(self) -> str:
return f'CustomInstance(#{self.id}# at "{self.exe_path}" with {self.adb_ip}:{self.adb_port})'
@ -76,6 +86,25 @@ def _type_check(ins: Instance) -> CustomInstance:
def create(exe_path: str | None, adb_ip: str, adb_port: int, adb_name: str | None, emulator_args: str = "") -> CustomInstance:
return CustomInstance(exe_path, emulator_args=emulator_args, id='custom', name='Custom', adb_ip=adb_ip, adb_port=adb_port, adb_name=adb_name)
class CustomHost(HostProtocol[CustomRecipes]):
@staticmethod
def installed() -> bool:
# Custom instances don't have a specific installation requirement
return True
@staticmethod
def list() -> list[Instance]:
# Custom instances are created manually, not discovered
return []
@staticmethod
def query(*, id: str) -> Instance | None:
# Custom instances are created manually, not discovered
return None
@staticmethod
def recipes() -> 'list[CustomRecipes]':
return ['adb', 'adb_raw', 'uiautomator2']
if __name__ == '__main__':
ins = create(r'C:\Program Files\BlueStacks_nxt\HD-Player.exe', '127.0.0.1', 5555, '**emulator-name**')

View File

@ -1,15 +1,17 @@
import os
import subprocess
from typing import Literal
from functools import lru_cache
from typing_extensions import override
from kotonebot import logging
from kotonebot.client import DeviceImpl, create_device
from kotonebot.client.device import Device
from kotonebot.client import Device
from kotonebot.util import Countdown, Interval
from .protocol import HostProtocol, Instance, copy_type
from .protocol import HostProtocol, Instance, copy_type, AdbHostConfig
from .adb_common import AdbRecipes, CommonAdbCreateDeviceMixin
logger = logging.getLogger(__name__)
LeidianRecipes = AdbRecipes
if os.name == 'nt':
from ...interop.win.reg import read_reg
@ -18,7 +20,7 @@ else:
"""Stub for read_reg on non-Windows platforms."""
return default
class LeidianInstance(Instance):
class LeidianInstance(CommonAdbCreateDeviceMixin, Instance[AdbHostConfig]):
@copy_type(Instance.__init__)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@ -61,27 +63,23 @@ class LeidianInstance(Instance):
it = Interval(5)
while not cd.expired() and not self.running():
it.wait()
self.refresh()
if not self.running():
raise TimeoutError(f'Leidian instance "{self.name}" is not available.')
@override
def running(self) -> bool:
result = LeidianHost._invoke_manager(['isrunning', '--index', str(self.index)])
return result.strip() == 'running'
return self.is_running
@override
def create_device(self, impl: DeviceImpl, *, timeout: float = 180) -> Device:
def create_device(self, impl: LeidianRecipes, host_config: AdbHostConfig) -> Device:
"""为雷电模拟器实例创建 Device。"""
if self.adb_port is None:
raise ValueError("ADB port is not set and is required.")
return create_device(
addr=f'{self.adb_ip}:{self.adb_port}',
impl=impl,
device_serial=self.adb_name,
connect=False,
timeout=timeout
)
class LeidianHost(HostProtocol):
return super().create_device(impl, host_config)
class LeidianHost(HostProtocol[LeidianRecipes]):
@staticmethod
@lru_cache(maxsize=1)
def _read_install_path() -> str | None:
@ -186,6 +184,10 @@ class LeidianHost(HostProtocol):
return instance
return None
@staticmethod
def recipes() -> 'list[LeidianRecipes]':
return ['adb', 'adb_raw', 'uiautomator2']
if __name__ == '__main__':
logging.basicConfig(level=logging.DEBUG, format='[%(asctime)s] [%(levelname)s] [%(name)s] [%(funcName)s] [%(lineno)d] %(message)s')
print(LeidianHost._read_install_path())

View File

@ -1,16 +1,19 @@
from dataclasses import dataclass
import os
import json
import subprocess
from functools import lru_cache
from typing import Any
from typing import Any, Literal, overload
from typing_extensions import override
from kotonebot import logging
from kotonebot.client import DeviceImpl, Device
from kotonebot.client import Device
from kotonebot.client.device import AndroidDevice
from kotonebot.client.implements.adb import AdbImpl
from kotonebot.client.implements.nemu_ipc import NemuIpcImpl, NemuIpcImplConfig
from kotonebot.util import Countdown, Interval
from .protocol import HostProtocol, Instance, copy_type
logger = logging.getLogger(__name__)
from .protocol import HostProtocol, Instance, copy_type, AdbHostConfig
from .adb_common import AdbRecipes, CommonAdbCreateDeviceMixin, connect_adb, is_adb_recipe
if os.name == 'nt':
from ...interop.win.reg import read_reg
@ -19,7 +22,21 @@ else:
"""Stub for read_reg on non-Windows platforms."""
return default
class Mumu12Instance(Instance):
logger = logging.getLogger(__name__)
MuMu12Recipes = AdbRecipes | Literal['nemu_ipc']
@dataclass
class MuMu12HostConfig(AdbHostConfig):
"""nemu_ipc 能力的配置模型。"""
display_id: int | None = 0
"""目标显示器 ID默认为 0主显示器。若为 None 且设置了 target_package_name则自动获取对应的 display_id。"""
target_package_name: str | None = None
"""目标应用包名,用于自动获取 display_id。"""
app_index: int = 0
"""多开应用索引,传给 get_display_id 方法。"""
class Mumu12Instance(CommonAdbCreateDeviceMixin, Instance[MuMu12HostConfig]):
@copy_type(Instance.__init__)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@ -70,14 +87,58 @@ class Mumu12Instance(Instance):
def running(self) -> bool:
return self.is_android_started
class Mumu12Host(HostProtocol):
@overload
def create_device(self, recipe: Literal['nemu_ipc'], host_config: MuMu12HostConfig) -> Device: ...
@overload
def create_device(self, recipe: AdbRecipes, host_config: AdbHostConfig) -> Device: ...
@override
def create_device(self, recipe: MuMu12Recipes, host_config: MuMu12HostConfig | AdbHostConfig) -> Device:
"""为MuMu12模拟器实例创建 Device。"""
if self.adb_port is None:
raise ValueError("ADB port is not set and is required.")
if recipe == 'nemu_ipc' and isinstance(host_config, MuMu12HostConfig):
# NemuImpl
nemu_path = Mumu12Host._read_install_path()
if not nemu_path:
raise RuntimeError("无法找到 MuMu12 的安装路径。")
nemu_config = NemuIpcImplConfig(
nemu_folder=nemu_path,
instance_id=int(self.id),
display_id=host_config.display_id,
target_package_name=host_config.target_package_name,
app_index=host_config.app_index
)
nemu_impl = NemuIpcImpl(nemu_config)
# AdbImpl
adb_impl = AdbImpl(connect_adb(
self.adb_ip,
self.adb_port,
timeout=host_config.timeout,
device_serial=self.adb_name
))
device = AndroidDevice()
device._screenshot = nemu_impl
device._touch = nemu_impl
device.commands = adb_impl
return device
elif isinstance(host_config, AdbHostConfig) and is_adb_recipe(recipe):
return super().create_device(recipe, host_config)
else:
raise ValueError(f'Unknown recipe: {recipe}')
class Mumu12Host(HostProtocol[MuMu12Recipes]):
@staticmethod
@lru_cache(maxsize=1)
def _read_install_path() -> str | None:
"""
Reads the installation path (DisplayIcon) of MuMu Player 12 from the registry.
r"""
从注册表中读取 MuMu Player 12 的安装路径
:return: The path to the display icon if found, otherwise None.
返回的路径为根目录 `F:\Apps\Netease\MuMuPlayer-12.0`
:return: 若找到则返回安装路径否则返回 None
"""
if os.name != 'nt':
return None
@ -94,6 +155,9 @@ class Mumu12Host(HostProtocol):
icon_path = icon_path.replace('"', '')
path = os.path.dirname(icon_path)
logger.debug('MuMu Player 12 installation path: %s', path)
# 返回根目录(去掉 shell 子目录)
if os.path.basename(path).lower() == 'shell':
path = os.path.dirname(path)
return path
return None
@ -108,7 +172,7 @@ class Mumu12Host(HostProtocol):
install_path = Mumu12Host._read_install_path()
if install_path is None:
raise RuntimeError('MuMu Player 12 is not installed.')
manager_path = os.path.join(install_path, 'MuMuManager.exe')
manager_path = os.path.join(install_path, 'shell', 'MuMuManager.exe')
logger.debug('MuMuManager execute: %s', repr(args))
output = subprocess.run(
[manager_path] + args,
@ -162,6 +226,10 @@ class Mumu12Host(HostProtocol):
if instance.id == id:
return instance
return None
@staticmethod
def recipes() -> 'list[MuMu12Recipes]':
return ['adb', 'adb_raw', 'uiautomator2', 'nemu_ipc']
if __name__ == '__main__':
logging.basicConfig(level=logging.DEBUG, format='[%(asctime)s] [%(levelname)s] [%(name)s] [%(funcName)s] [%(lineno)d] %(message)s')

View File

@ -1,14 +1,15 @@
import time
import socket
from abc import ABC, abstractmethod
from typing_extensions import ParamSpec, Concatenate
from typing import Callable, TypeVar, Generic, Protocol, runtime_checkable, Type, Any
from typing import Callable, TypeVar, Protocol, Any, Generic
from dataclasses import dataclass
from adbutils import adb, AdbTimeout, AdbError
from adbutils._device import AdbDevice
from kotonebot import logging
from kotonebot.client import Device, create_device, DeviceImpl
from kotonebot.client import Device, DeviceImpl
from kotonebot.util import Countdown, Interval
logger = logging.getLogger(__name__)
@ -17,6 +18,28 @@ _T = TypeVar("_T")
def copy_type(_: _T) -> Callable[[Any], _T]:
return lambda x: x
# --- 定义专用的 HostConfig 数据类 ---
@dataclass
class AdbHostConfig:
"""由外部为基于 ADB 的主机提供的配置。"""
timeout: float = 180
@dataclass
class WindowsHostConfig:
"""由外部为 Windows 实现提供配置。"""
window_title: str
ahk_exe_path: str
@dataclass
class RemoteWindowsHostConfig:
"""由外部为远程 Windows 实现提供配置。"""
windows_host_config: WindowsHostConfig
host: str
port: int
# --- 使用泛型改造 Instance 协议 ---
T_HostConfig = TypeVar("T_HostConfig")
def tcp_ping(host: str, port: int, timeout: float = 1.0) -> bool:
"""
通过 TCP ping 检查主机和端口是否可达
@ -36,7 +59,11 @@ def tcp_ping(host: str, port: int, timeout: float = 1.0) -> bool:
return False
class Instance(ABC):
class Instance(Generic[T_HostConfig], ABC):
"""
代表一个可运行环境的实例如一个模拟器
使用泛型来约束 create_device 方法的配置参数类型
"""
def __init__(self,
id: str,
name: str,
@ -68,7 +95,7 @@ class Instance(ABC):
启动模拟器实例
"""
raise NotImplementedError()
@abstractmethod
def stop(self):
"""
@ -80,21 +107,16 @@ class Instance(ABC):
def running(self) -> bool:
raise NotImplementedError()
def create_device(self, impl: DeviceImpl, *, timeout: float = 180) -> Device:
@abstractmethod
def create_device(self, impl: DeviceImpl, host_config: T_HostConfig) -> Device:
"""
创建 Device 实例可用于控制模拟器系统
:return: Device 实例
根据实现名称和类型化的主机配置创建设备
:param impl: 设备实现的名称
:param host_config: 一个类型化的数据对象包含创建所需的所有外部配置
:return: 配置好的 Device 实例
"""
if self.adb_port is None:
raise ValueError("ADB port is not set and is required.")
return create_device(
addr=f'{self.adb_ip}:{self.adb_port}',
impl=impl,
device_serial=self.adb_name,
connect=True,
timeout=timeout
)
raise NotImplementedError()
def wait_available(self, timeout: float = 180):
logger.info('Starting to wait for emulator %s(127.0.0.1:%d) to be available...', self.name, self.adb_port)
@ -173,7 +195,8 @@ class Instance(ABC):
def __repr__(self) -> str:
return f'{self.__class__.__name__}(name="{self.name}", id="{self.id}", adb="{self.adb_ip}:{self.adb_port}"({self.adb_name}))'
class HostProtocol(Protocol):
Recipe = TypeVar('Recipe', bound=str)
class HostProtocol(Generic[Recipe], Protocol):
@staticmethod
def installed() -> bool: ...
@ -183,6 +206,8 @@ class HostProtocol(Protocol):
@staticmethod
def query(*, id: str) -> Instance | None: ...
@staticmethod
def recipes() -> 'list[Recipe]': ...
if __name__ == '__main__':
pass

View File

@ -0,0 +1,55 @@
from abc import ABC
from typing import Literal
from typing_extensions import assert_never
from kotonebot import logging
from kotonebot.client.device import WindowsDevice
from .protocol import Device, WindowsHostConfig, RemoteWindowsHostConfig
logger = logging.getLogger(__name__)
WindowsRecipes = Literal['windows', 'remote_windows']
# Windows 相关的配置类型联合
WindowsHostConfigs = WindowsHostConfig | RemoteWindowsHostConfig
class CommonWindowsCreateDeviceMixin(ABC):
"""
通用 Windows 创建设备的 Mixin
Mixin 定义了创建 Windows 设备的通用接口
"""
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
def create_device(self, recipe: WindowsRecipes, config: WindowsHostConfigs) -> Device:
"""
创建 Windows 设备
"""
match recipe:
case 'windows':
if not isinstance(config, WindowsHostConfig):
raise ValueError(f"Expected WindowsHostConfig for 'windows' recipe, got {type(config)}")
from kotonebot.client.implements.windows import WindowsImpl
d = WindowsDevice()
impl = WindowsImpl(
device=d,
window_title=config.window_title,
ahk_exe_path=config.ahk_exe_path
)
d._screenshot = impl
d._touch = impl
return d
case 'remote_windows':
if not isinstance(config, RemoteWindowsHostConfig):
raise ValueError(f"Expected RemoteWindowsHostConfig for 'remote_windows' recipe, got {type(config)}")
from kotonebot.client.implements.remote_windows import RemoteWindowsImpl
d = WindowsDevice()
impl = RemoteWindowsImpl(
device=d,
host=config.host,
port=config.port
)
d._screenshot = impl
d._touch = impl
return d
case _:
assert_never(f'Unsupported Windows recipe: {recipe}')

View File

@ -0,0 +1,7 @@
# 导入所有内置实现,以触发它们的 @register_impl 装饰器
from . import adb # noqa: F401
from . import adb_raw # noqa: F401
from . import remote_windows # noqa: F401
from . import uiautomator2 # noqa: F401
from . import windows # noqa: F401
from . import nemu_ipc # noqa: F401

View File

@ -5,16 +5,27 @@ from typing_extensions import override
import cv2
import numpy as np
from cv2.typing import MatLike
from adbutils._device import AdbDevice as AdbUtilsDevice
from ..device import Device
from ..protocol import Commandable, Touchable, Screenshotable
from ..device import AndroidDevice
from ..protocol import AndroidCommandable, Touchable, Screenshotable
from ..registration import ImplConfig
from dataclasses import dataclass
logger = logging.getLogger(__name__)
class AdbImpl(Commandable, Touchable, Screenshotable):
def __init__(self, device: Device):
self.device = device
self.adb = device.adb
# 定义配置模型
@dataclass
class AdbImplConfig(ImplConfig):
addr: str
connect: bool = True
disconnect: bool = True
device_serial: str | None = None
timeout: float = 180
class AdbImpl(AndroidCommandable, Touchable, Screenshotable):
def __init__(self, adb_connection: AdbUtilsDevice):
self.adb = adb_connection
@override
def launch_app(self, package_name: str) -> None:
@ -36,6 +47,10 @@ class AdbImpl(Commandable, Touchable, Screenshotable):
package = activity.split('/')[0]
return package
def adb_shell(self, cmd: str) -> str:
"""执行 ADB shell 命令"""
return cast(str, self.adb.shell(cmd))
@override
def detect_orientation(self):
# 判断方向https://stackoverflow.com/questions/10040624/check-if-device-is-landscape-via-adb
@ -50,7 +65,9 @@ class AdbImpl(Commandable, Touchable, Screenshotable):
def screen_size(self) -> tuple[int, int]:
ret = cast(str, self.adb.shell("wm size")).strip('Physical size: ')
spiltted = tuple(map(int, ret.split("x")))
landscape = self.device.orientation == 'landscape'
# 检测当前方向
orientation = self.detect_orientation()
landscape = orientation == 'landscape'
spiltted = tuple(sorted(spiltted, reverse=landscape))
if len(spiltted) != 2:
raise ValueError(f"Invalid screen size: {ret}")

View File

@ -12,7 +12,7 @@ from cv2.typing import MatLike
from adbutils._utils import adb_path
from .adb import AdbImpl
from ..device import Device
from adbutils._device import AdbDevice as AdbUtilsDevice
from kotonebot import logging
logger = logging.getLogger(__name__)
@ -27,8 +27,8 @@ done
"""
class AdbRawImpl(AdbImpl):
def __init__(self, device: Device):
super().__init__(device)
def __init__(self, adb_connection: AdbUtilsDevice):
super().__init__(adb_connection)
self.__worker: Thread | None = None
self.__process: subprocess.Popen | None = None
self.__data: MatLike | None = None

View File

@ -0,0 +1,8 @@
from .external_renderer_ipc import ExternalRendererIpc
from .nemu_ipc import NemuIpcImpl, NemuIpcImplConfig
__all__ = [
"ExternalRendererIpc",
"NemuIpcImpl",
"NemuIpcImplConfig",
]

View File

@ -0,0 +1,280 @@
import ctypes
import logging
import os
logger = logging.getLogger(__name__)
class NemuIpcIncompatible(RuntimeError):
"""MuMu12 IPC 环境不兼容或 DLL 加载失败"""
class ExternalRendererIpc:
r"""对 `external_renderer_ipc.dll` 的轻量封装。
该类仅处理 DLL 加载与原型声明并提供带有类型提示的薄包装方法
方便在其他模块中调用且保持类型安全
传入参数为 MuMu 根目录 F:\Apps\Netease\MuMuPlayer-12.0
"""
def __init__(self, mumu_root_folder: str):
if os.name != "nt":
raise NemuIpcIncompatible("ExternalRendererIpc only supports Windows.")
self.lib = self.__load_dll(mumu_root_folder)
self.raise_on_error: bool = True
"""是否在调用 DLL 函数失败时抛出异常。"""
self.__declare_prototypes()
def connect(self, nemu_folder: str, instance_id: int) -> int:
"""
建立连接
API 原型
`int nemu_connect(const wchar_t* path, int index)`
:param nemu_folder: 模拟器安装路径
:param instance_id: 模拟器实例 ID
:return: 成功返回连接 ID失败返回 0
"""
return self.lib.nemu_connect(nemu_folder, instance_id)
def disconnect(self, connect_id: int) -> None:
"""
断开连接
API 原型
`void nemu_disconnect(int handle)`
:param connect_id: 连接 ID
:return: 无返回值
"""
return self.lib.nemu_disconnect(connect_id)
def get_display_id(self, connect_id: int, pkg: str, app_index: int) -> int:
"""
获取指定包的 display id
API 原型
`int nemu_get_display_id(int handle, const char* pkg, int appIndex)`
:param connect_id: 连接 ID
:param pkg: 包名
:param app_index: 多开应用索引
:return: <0 表示失败>=0 表示有效 display id
"""
return self.lib.nemu_get_display_id(connect_id, pkg.encode('utf-8'), app_index)
def capture_display(
self,
connect_id: int,
display_id: int,
buf_len: int,
width_ptr: ctypes.c_void_p,
height_ptr: ctypes.c_void_p,
buffer_ptr: ctypes.c_void_p,
) -> int:
"""
截取指定显示屏内容
API 原型
`int nemu_capture_display(int handle, unsigned int displayid, int buffer_size, int *width, int *height, unsigned char* pixels)`
:param connect_id: 连接 ID
:param display_id: 显示屏 ID
:param buf_len: 缓冲区长度字节
:param width_ptr: 用于接收宽度的指针ctypes.c_void_p/int 指针
:param height_ptr: 用于接收高度的指针ctypes.c_void_p/int 指针
:param buffer_ptr: 用于接收像素数据的指针ctypes.c_void_p/unsigned char* 指针
:return: 0 表示成功>0 表示失败
"""
return self.lib.nemu_capture_display(
connect_id,
display_id,
buf_len,
width_ptr,
height_ptr,
buffer_ptr,
)
def input_text(self, connect_id: int, text: str) -> int:
"""
输入文本
API 原型
`int nemu_input_text(int handle, int size, const char* buf)`
:param connect_id: 连接 ID
:param text: 输入文本utf-8
:return: 0 表示成功>0 表示失败
"""
buf = text.encode('utf-8')
return self.lib.nemu_input_text(connect_id, len(buf), buf)
def input_touch_down(self, connect_id: int, display_id: int, x: int, y: int) -> int:
"""
发送触摸按下事件
API 原型
`int nemu_input_event_touch_down(int handle, int displayid, int x_point, int y_point)`
:param connect_id: 连接 ID
:param display_id: 显示屏 ID
:param x: 触摸点 X 坐标
:param y: 触摸点 Y 坐标
:return: 0 表示成功>0 表示失败
"""
return self.lib.nemu_input_event_touch_down(connect_id, display_id, x, y)
def input_touch_up(self, connect_id: int, display_id: int) -> int:
"""
发送触摸抬起事件
API 原型
`int nemu_input_event_touch_up(int handle, int displayid)`
:param connect_id: 连接 ID
:param display_id: 显示屏 ID
:return: 0 表示成功>0 表示失败
"""
return self.lib.nemu_input_event_touch_up(connect_id, display_id)
def input_key_down(self, connect_id: int, display_id: int, key_code: int) -> int:
"""
发送按键按下事件
API 原型
`int nemu_input_event_key_down(int handle, int displayid, int key_code)`
:param connect_id: 连接 ID
:param display_id: 显示屏 ID
:param key_code: 按键码
:return: 0 表示成功>0 表示失败
"""
return self.lib.nemu_input_event_key_down(connect_id, display_id, key_code)
def input_key_up(self, connect_id: int, display_id: int, key_code: int) -> int:
"""
发送按键抬起事件
API 原型
`int nemu_input_event_key_up(int handle, int displayid, int key_code)`
:param connect_id: 连接 ID
:param display_id: 显示屏 ID
:param key_code: 按键码
:return: 0 表示成功>0 表示失败
"""
return self.lib.nemu_input_event_key_up(connect_id, display_id, key_code)
def input_finger_touch_down(self, connect_id: int, display_id: int, finger_id: int, x: int, y: int) -> int:
"""
多指触摸按下
API 原型
`int nemu_input_event_finger_touch_down(int handle, int displayid, int finger_id, int x_point, int y_point)`
:param connect_id: 连接 ID
:param display_id: 显示屏 ID
:param finger_id: 手指编号1-10
:param x: 触摸点 X 坐标
:param y: 触摸点 Y 坐标
:return: 0 表示成功>0 表示失败
"""
return self.lib.nemu_input_event_finger_touch_down(connect_id, display_id, finger_id, x, y)
def input_finger_touch_up(self, connect_id: int, display_id: int, finger_id: int) -> int:
"""
多指触摸抬起
API 原型
`int nemu_input_event_finger_touch_up(int handle, int displayid, int slot_id)`
:param connect_id: 连接 ID
:param display_id: 显示屏 ID
:param finger_id: 手指编号1-10
:return: 0 表示成功>0 表示失败
"""
return self.lib.nemu_input_event_finger_touch_up(connect_id, display_id, finger_id)
# ------------------------------------------------------------------
# 内部工具
# ------------------------------------------------------------------
def __load_dll(self, mumu_root_folder: str) -> ctypes.CDLL:
"""尝试多条路径加载 DLL。传入为 MuMu 根目录。"""
candidate_paths = [
os.path.join(mumu_root_folder, "shell", "sdk", "external_renderer_ipc.dll"),
os.path.join(
mumu_root_folder,
"shell",
"nx_device",
"12.0",
"sdk",
"external_renderer_ipc.dll",
),
]
for p in candidate_paths:
if not os.path.exists(p):
continue
try:
return ctypes.CDLL(p)
except OSError as e: # pragma: no cover
logger.warning("Failed to load DLL (%s): %s", p, e)
raise NemuIpcIncompatible("external_renderer_ipc.dll not found or failed to load.")
def __declare_prototypes(self) -> None:
"""声明 DLL 函数原型,确保 ctypes 类型安全。"""
# 连接 / 断开
self.lib.nemu_connect.argtypes = [ctypes.c_wchar_p, ctypes.c_int]
self.lib.nemu_connect.restype = ctypes.c_int
self.lib.nemu_disconnect.argtypes = [ctypes.c_int]
self.lib.nemu_disconnect.restype = None
# 获取 display id
self.lib.nemu_get_display_id.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int]
self.lib.nemu_get_display_id.restype = ctypes.c_int
# 截图
self.lib.nemu_capture_display.argtypes = [
ctypes.c_int,
ctypes.c_uint,
ctypes.c_int,
ctypes.c_void_p,
ctypes.c_void_p,
ctypes.c_void_p,
]
self.lib.nemu_capture_display.restype = ctypes.c_int
# 输入文本
self.lib.nemu_input_text.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_char_p]
self.lib.nemu_input_text.restype = ctypes.c_int
# 触摸
self.lib.nemu_input_event_touch_down.argtypes = [
ctypes.c_int,
ctypes.c_int,
ctypes.c_int,
ctypes.c_int,
]
self.lib.nemu_input_event_touch_down.restype = ctypes.c_int
self.lib.nemu_input_event_touch_up.argtypes = [ctypes.c_int, ctypes.c_int]
self.lib.nemu_input_event_touch_up.restype = ctypes.c_int
# 按键
self.lib.nemu_input_event_key_down.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_int]
self.lib.nemu_input_event_key_down.restype = ctypes.c_int
self.lib.nemu_input_event_key_up.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_int]
self.lib.nemu_input_event_key_up.restype = ctypes.c_int
# 多指触摸
self.lib.nemu_input_event_finger_touch_down.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int]
self.lib.nemu_input_event_finger_touch_down.restype = ctypes.c_int
self.lib.nemu_input_event_finger_touch_up.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_int]
self.lib.nemu_input_event_finger_touch_up.restype = ctypes.c_int
logger.debug("DLL function prototypes declared")

View File

@ -0,0 +1,327 @@
import os
import ctypes
import logging
import time
from dataclasses import dataclass
from time import sleep
from typing import Literal
from typing_extensions import override
import cv2
import numpy as np
from cv2.typing import MatLike
from ...device import AndroidDevice, Device
from ...protocol import Touchable, Screenshotable
from ...registration import ImplConfig
from .external_renderer_ipc import ExternalRendererIpc
from kotonebot.errors import KotonebotError
logger = logging.getLogger(__name__)
class NemuIpcIncompatible(Exception):
"""MuMu12 版本过低或 dll 不兼容"""
pass
class NemuIpcError(KotonebotError):
"""调用 IPC 过程中发生错误"""
pass
@dataclass
class NemuIpcImplConfig(ImplConfig):
"""nemu_ipc 能力的配置模型。"""
nemu_folder: str
r"""MuMu12 根目录(如 F:\Apps\Netease\MuMuPlayer-12.0)。"""
instance_id: int
"""模拟器实例 ID。"""
display_id: int | None = 0
"""目标显示器 ID默认为 0主显示器。若为 None 且设置了 target_package_name则自动获取对应的 display_id。"""
target_package_name: str | None = None
"""目标应用包名,用于自动获取 display_id。"""
app_index: int = 0
"""多开应用索引,传给 get_display_id 方法。"""
wait_package_timeout: float = 60 # 单位秒,-1 表示永远等待0 表示不等待,立即抛出异常
wait_package_interval: float = 0.1 # 单位秒
class NemuIpcImpl(Touchable, Screenshotable):
"""
利用 MuMu12 提供的 external_renderer_ipc.dll 进行截图与触摸控制
"""
def __init__(self, config: NemuIpcImplConfig):
self.config = config
self.__width: int = 0
self.__height: int = 0
self.__connected: bool = False
self._connect_id: int = 0
self.nemu_folder = config.nemu_folder
# --------------------------- DLL 封装 ---------------------------
self._ipc = ExternalRendererIpc(config.nemu_folder)
logger.info("ExternalRendererIpc initialized and DLL loaded")
@property
def width(self) -> int:
"""
屏幕宽度
若为 0表示未连接或未获取到分辨率
"""
return self.__width
@property
def height(self) -> int:
"""
屏幕高度
若为 0表示未连接或未获取到分辨率
"""
return self.__height
@property
def connected(self) -> bool:
"""是否已连接。"""
return self.__connected
# ------------------------------------------------------------------
# 基础控制
# ------------------------------------------------------------------
def _ensure_connected(self) -> None:
if not self.__connected:
self.connect()
def _get_display_id(self) -> int:
"""获取有效的 display_id。"""
# 如果配置中直接指定了 display_id直接返回
if self.config.display_id is not None:
return self.config.display_id
# 如果设置了 target_package_name实时获取 display_id
if self.config.target_package_name:
self._ensure_connected()
timeout = self.config.wait_package_timeout
interval = self.config.wait_package_interval
if timeout == -1:
timeout = float('inf')
start_time = time.time()
while True:
display_id = self._ipc.get_display_id(
self._connect_id,
self.config.target_package_name,
self.config.app_index
)
if display_id >= 0:
return display_id
elif display_id == -1:
# 可以继续等
pass
else:
# 未知错误
raise NemuIpcError(f"Failed to get display_id for package '{self.config.target_package_name}', error code={display_id}")
if time.time() - start_time > timeout:
break
sleep(interval)
raise NemuIpcError(f"Failed to get display_id for package '{self.config.target_package_name}' within {timeout}s")
# 如果都没有设置,抛出错误
raise NemuIpcError("display_id is None and target_package_name is not set. Please set display_id or target_package_name in config.")
def connect(self) -> None:
"""连接模拟器。"""
if self.__connected:
return
connect_id = self._ipc.connect(self.nemu_folder, self.config.instance_id)
if connect_id == 0:
raise NemuIpcError("nemu_connect failed, please check if the emulator is running and the instance ID is correct.")
self._connect_id = connect_id
self.__connected = True
logger.debug("NemuIpc connected, connect_id=%d", connect_id)
def disconnect(self) -> None:
"""断开连接。"""
if not self.__connected:
return
self._ipc.disconnect(self._connect_id)
self.__connected = False
self._connect_id = 0
logger.debug("NemuIpc disconnected.")
# ------------------------------------------------------------------
# Screenshotable 接口实现
# ------------------------------------------------------------------
@property
def screen_size(self) -> tuple[int, int]:
"""获取屏幕分辨率。"""
if self.__width == 0 or self.__height == 0:
self._refresh_resolution()
if self.__width == 0 or self.__height == 0:
raise NemuIpcError("Screen resolution not obtained, please connect to the emulator first.")
return self.__width, self.__height
@override
def detect_orientation(self):
return self.get_display_orientation(self._get_display_id())
def get_display_orientation(self, display_id: int = 0) -> Literal['portrait', 'landscape'] | None:
"""获取指定显示屏的方向。"""
width, height = self.query_resolution(display_id)
if width > height:
return "landscape"
if height > width:
return "portrait"
return None
@override
def screenshot(self) -> MatLike:
self._ensure_connected()
# 必须每次都更新分辨率,因为屏幕可能会旋转
self._refresh_resolution()
length = self.__width * self.__height * 4 # RGBA
buf_type = ctypes.c_ubyte * length
buffer = buf_type()
w_ptr = ctypes.pointer(ctypes.c_int(self.__width))
h_ptr = ctypes.pointer(ctypes.c_int(self.__height))
ret = self._ipc.capture_display(
self._connect_id,
self._get_display_id(),
length,
ctypes.cast(w_ptr, ctypes.c_void_p),
ctypes.cast(h_ptr, ctypes.c_void_p),
ctypes.cast(buffer, ctypes.c_void_p),
)
if ret != 0:
raise NemuIpcError(f"nemu_capture_display screenshot failed, error code={ret}")
# 读入并转换数据
img = np.ctypeslib.as_array(buffer).reshape((self.__height, self.__width, 4))
# RGBA -> BGR
img = cv2.cvtColor(img, cv2.COLOR_RGBA2BGR)
cv2.flip(img, 0, dst=img)
return img
# --------------------------- 内部工具 -----------------------------
def _refresh_resolution(self) -> None:
"""刷新分辨率信息。"""
display_id = self._get_display_id()
self.__width, self.__height = self.query_resolution(display_id)
def query_resolution(self, display_id: int = 0) -> tuple[int, int]:
"""
查询指定显示屏的分辨率
:param display_id: 显示屏 ID
:return: 分辨率 (width, height)
:raise NemuIpcError: 查询失败
"""
self._ensure_connected()
w_ptr = ctypes.pointer(ctypes.c_int(0))
h_ptr = ctypes.pointer(ctypes.c_int(0))
ret = self._ipc.capture_display(
self._connect_id,
display_id,
0,
ctypes.cast(w_ptr, ctypes.c_void_p),
ctypes.cast(h_ptr, ctypes.c_void_p),
ctypes.c_void_p(),
)
if ret != 0:
raise NemuIpcError(f"Call nemu_capture_display failed. Return value={ret}")
return w_ptr.contents.value, h_ptr.contents.value
# ------------------------------------------------------------------
# Touchable 接口实现
# ------------------------------------------------------------------
def __convert_pos(self, x: int, y: int) -> tuple[int, int]:
# Android 显示屏有两套坐标:逻辑坐标与物理坐标。
# 逻辑坐标原点始终是画面左上角,而物理坐标原点则始终是显示屏的左上角。
# 如果屏幕画面旋转,会导致两个坐标的原点不同,坐标也不同。
# ========
# 这里传给 MuMu 的是逻辑坐标ExternalRendererIpc DLL 内部会
# 自动判断旋转,并转换为物理坐标。但是这部分有个 bug
# 旋转没有考虑到多显示器,只是以主显示器为准,若两个显示器旋转不一致,
# 会导致错误地转换坐标。因此需要在 Python 层面 workaround 这个问题。
# 通过判断主显示器与当前显示器的旋转,将坐标进行预转换,抵消 DLL 层的错误转换。
display_id = self._get_display_id()
if display_id == 0:
return x, y
else:
primary = self.get_display_orientation(0)
primary_size = self.query_resolution(0)
current = self.get_display_orientation(display_id)
if primary == current:
return x, y
else:
# 如果旋转不一致,视为顺时针旋转了 90°
# 因此我们要提前逆时针旋转 90°
self._refresh_resolution()
x, y = y, primary_size[1] - x
return x, y
@override
def click(self, x: int, y: int) -> None:
self._ensure_connected()
display_id = self._get_display_id()
x, y = self.__convert_pos(x, y)
self._ipc.input_touch_down(self._connect_id, display_id, x, y)
sleep(0.01)
self._ipc.input_touch_up(self._connect_id, display_id)
@override
def swipe(
self,
x1: int,
y1: int,
x2: int,
y2: int,
duration: float | None = None,
) -> None:
self._ensure_connected()
duration = duration or 0.3
steps = max(int(duration / 0.01), 2)
display_id = self._get_display_id()
x1, y1 = self.__convert_pos(x1, y1)
x2, y2 = self.__convert_pos(x2, y2)
xs = np.linspace(x1, x2, steps, dtype=int)
ys = np.linspace(y1, y2, steps, dtype=int)
# 按下第一点
self._ipc.input_touch_down(self._connect_id, display_id, xs[0], ys[0])
sleep(0.01)
# 中间移动
for px, py in zip(xs[1:-1], ys[1:-1]):
self._ipc.input_touch_down(self._connect_id, display_id, px, py)
sleep(0.01)
# 最终抬起
self._ipc.input_touch_up(self._connect_id, display_id)
sleep(0.01)
if __name__ == '__main__':
nemu = NemuIpcImpl(NemuIpcImplConfig(
r'F:\Apps\Netease\MuMuPlayer-12.0', 0, None,
target_package_name='com.android.chrome',
))
nemu.connect()
# while True:
# nemu.click(0, 0)
nemu.click(100, 100)
nemu.click(100*3, 100)
nemu.click(100*3, 100*3)

View File

@ -14,6 +14,7 @@ import xmlrpc.server
from typing import Literal, cast, Any, Tuple
from functools import cached_property
from threading import Thread
from dataclasses import dataclass
import cv2
import numpy as np
@ -22,10 +23,18 @@ from cv2.typing import MatLike
from kotonebot import logging
from ..device import Device, WindowsDevice
from ..protocol import Touchable, Screenshotable
from .windows import WindowsImpl
from ..registration import ImplConfig
from .windows import WindowsImpl, WindowsImplConfig
logger = logging.getLogger(__name__)
# 定义配置模型
@dataclass
class RemoteWindowsImplConfig(ImplConfig):
windows_impl_config: WindowsImplConfig
host: str = "localhost"
port: int = 8000
def _encode_image(image: MatLike) -> str:
"""Encode an image as a base64 string."""
success, buffer = cv2.imencode('.png', image)
@ -48,13 +57,17 @@ class RemoteWindowsServer:
This class wraps a WindowsImpl instance and exposes its methods via XML-RPC.
"""
def __init__(self, host="localhost", port=8000):
def __init__(self, windows_impl_config: WindowsImplConfig, host="localhost", port=8000):
"""Initialize the server with the given host and port."""
self.host = host
self.port = port
self.server = None
self.device = WindowsDevice()
self.impl = WindowsImpl(self.device)
self.impl = WindowsImpl(
WindowsDevice(),
ahk_exe_path=windows_impl_config.ahk_exe_path,
window_title=windows_impl_config.window_title
)
self.device._screenshot = self.impl
self.device._touch = self.impl
@ -177,21 +190,4 @@ class RemoteWindowsImpl(Touchable, Screenshotable):
def swipe(self, x1: int, y1: int, x2: int, y2: int, duration: float | None = None) -> None:
"""Swipe from (x1, y1) to (x2, y2) on the remote server."""
if not self.proxy.swipe(x1, y1, x2, y2, duration):
raise RuntimeError(f"Failed to swipe from ({x1}, {y1}) to ({x2}, {y2})")
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Remote Windows XML-RPC Server")
parser.add_argument("--host", default="0.0.0.0", help="Host to bind to")
parser.add_argument("--port", type=int, default=8000, help="Port to bind to")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO)
server = RemoteWindowsServer(args.host, args.port)
try:
server.start()
except KeyboardInterrupt:
logger.info("Server stopped by user")
raise RuntimeError(f"Failed to swipe from ({x1}, {y1}) to ({x2}, {y2})")

View File

@ -4,6 +4,7 @@ from typing import Literal
import numpy as np
import uiautomator2 as u2
from cv2.typing import MatLike
from adbutils._device import AdbDevice as AdbUtilsDevice
from kotonebot import logging
from ..device import Device
@ -14,9 +15,8 @@ logger = logging.getLogger(__name__)
SCREENSHOT_INTERVAL = 0.2
class UiAutomator2Impl(Screenshotable, Commandable, Touchable):
def __init__(self, device: Device):
self.device = device
self.u2_client = u2.Device(device.adb.serial)
def __init__(self, adb_connection: AdbUtilsDevice):
self.u2_client = u2.Device(adb_connection.serial)
self.__last_screenshot_time = 0
def screenshot(self) -> MatLike:
@ -38,10 +38,7 @@ class UiAutomator2Impl(Screenshotable, Commandable, Touchable):
def screen_size(self) -> tuple[int, int]:
info = self.u2_client.info
sizes = info['displayWidth'], info['displayHeight']
if self.device.orientation == 'landscape':
return (max(sizes), min(sizes))
else:
return (min(sizes), max(sizes))
return sizes
def detect_orientation(self) -> Literal['portrait', 'landscape'] | None:
"""
@ -82,4 +79,4 @@ class UiAutomator2Impl(Screenshotable, Commandable, Touchable):
"""
滑动屏幕
"""
self.u2_client.swipe(x1, y1, x2, y2, duration=duration or 0.1)
self.u2_client.swipe(x1, y1, x2, y2, duration=duration or 0.1)

View File

@ -2,6 +2,7 @@ from ctypes import windll
from typing import Literal
from importlib import resources
from functools import cached_property
from dataclasses import dataclass
import cv2
import win32ui
@ -10,14 +11,21 @@ import numpy as np
from ahk import AHK, MsgBoxIcon
from cv2.typing import MatLike
from ..device import Device
from ..device import Device, WindowsDevice
from ..protocol import Commandable, Touchable, Screenshotable
from ..registration import ImplConfig
# 1. 定义配置模型
@dataclass
class WindowsImplConfig(ImplConfig):
window_title: str
ahk_exe_path: str
class WindowsImpl(Touchable, Screenshotable):
def __init__(self, device: Device):
def __init__(self, device: Device, window_title: str, ahk_exe_path: str):
self.__hwnd: int | None = None
# TODO: 硬编码路径
self.ahk = AHK(executable_path=str(resources.files('kaa.res.bin') / 'AutoHotkey.exe'))
self.window_title = window_title
self.ahk = AHK(executable_path=ahk_exe_path)
self.device = device
# 设置 DPI aware否则高缩放显示器上返回的坐标会错误
@ -43,21 +51,12 @@ class WindowsImpl(Touchable, Screenshotable):
# 将点击坐标设置为相对 Client
self.ahk.set_coord_mode('Mouse', 'Client')
@cached_property
def scale_ratio(self) -> float:
"""
缩放比例截图与模拟输入前都会根据这个比例缩放
"""
left, _, right, _ = self.__client_rect()
w = right - left
return 720 / w
@property
def hwnd(self) -> int:
if self.__hwnd is None:
self.__hwnd = win32gui.FindWindow(None, 'gakumas')
self.__hwnd = win32gui.FindWindow(None, self.window_title)
if self.__hwnd is None or self.__hwnd == 0:
raise RuntimeError('Failed to find window')
raise RuntimeError(f'Failed to find window: {self.window_title}')
return self.__hwnd
def __client_rect(self) -> tuple[int, int, int, int]:
@ -73,8 +72,8 @@ class WindowsImpl(Touchable, Screenshotable):
return win32gui.ClientToScreen(hwnd, (x, y))
def screenshot(self) -> MatLike:
if not self.ahk.win_is_active('gakumas'):
self.ahk.win_activate('gakumas')
if not self.ahk.win_is_active(self.window_title):
self.ahk.win_activate(self.window_title)
hwnd = self.hwnd
# TODO: 需要检查下面这些 WinAPI 的返回结果
@ -116,21 +115,17 @@ class WindowsImpl(Touchable, Screenshotable):
# 将 RGBA 转换为 RGB
cropped_im = cv2.cvtColor(cropped_im, cv2.COLOR_RGBA2RGB)
# 缩放
cropped_im = cv2.resize(cropped_im, None, fx=self.scale_ratio, fy=self.scale_ratio)
return cropped_im
@property
def screen_size(self) -> tuple[int, int]:
# 因为截图和点击的坐标都被缩放了,
# 因此这里只要返回固定值即可
if self.device.orientation == 'landscape':
return 1280, 720
else:
return 720, 1280
left, top, right, bot = self.__client_rect()
w = right - left
h = bot - top
return w, h
def detect_orientation(self) -> None | Literal['portrait'] | Literal['landscape']:
pos = self.ahk.win_get_position('gakumas')
pos = self.ahk.win_get_position(self.window_title)
if pos is None:
return None
w, h = pos.width, pos.height
@ -146,25 +141,22 @@ class WindowsImpl(Touchable, Screenshotable):
x = 2
if y == 0:
y = 2
x, y = int(x / self.scale_ratio), int(y / self.scale_ratio)
if not self.ahk.win_is_active('gakumas'):
self.ahk.win_activate('gakumas')
if not self.ahk.win_is_active(self.window_title):
self.ahk.win_activate(self.window_title)
self.ahk.click(x, y)
def swipe(self, x1: int, y1: int, x2: int, y2: int, duration: float | None = None) -> None:
if not self.ahk.win_is_active('gakumas'):
self.ahk.win_activate('gakumas')
x1, y1 = int(x1 / self.scale_ratio), int(y1 / self.scale_ratio)
x2, y2 = int(x2 / self.scale_ratio), int(y2 / self.scale_ratio)
if not self.ahk.win_is_active(self.window_title):
self.ahk.win_activate(self.window_title)
# TODO: 这个 speed 的单位是什么?
self.ahk.mouse_drag(x2, y2, from_position=(x1, y1), coord_mode='Client', speed=10)
if __name__ == '__main__':
from ..device import Device
from time import sleep
device = Device()
impl = WindowsImpl(device)
# 在测试环境中直接使用默认路径
ahk_path = str(resources.files('kaa.res.bin') / 'AutoHotkey.exe')
impl = WindowsImpl(device, window_title='gakumas', ahk_exe_path=ahk_path)
device._screenshot = impl
device._touch = impl
device.swipe_scaled(0.5, 0.8, 0.5, 0.2)

View File

@ -28,6 +28,19 @@ class Commandable(Protocol):
def launch_app(self, package_name: str) -> None: ...
def current_package(self) -> str | None: ...
@runtime_checkable
class AndroidCommandable(Protocol):
"""定义 Android 平台的特定命令"""
def launch_app(self, package_name: str) -> None: ...
def current_package(self) -> str | None: ...
def adb_shell(self, cmd: str) -> str: ...
@runtime_checkable
class WindowsCommandable(Protocol):
"""定义 Windows 平台的特定命令"""
def get_foreground_window(self) -> tuple[int, str]: ...
def exec_command(self, command: str) -> tuple[int, str, str]: ...
@runtime_checkable
class Screenshotable(Protocol):
def __init__(self, device: 'Device'): ...

View File

@ -0,0 +1,24 @@
from dataclasses import dataclass
from typing import TypeVar, Callable, Dict, Type, Any, overload, Literal, cast, TYPE_CHECKING
from ..errors import KotonebotError
from .device import Device
if TYPE_CHECKING:
from .implements.adb import AdbImplConfig
from .implements.remote_windows import RemoteWindowsImplConfig
from .implements.windows import WindowsImplConfig
from .implements.nemu_ipc import NemuIpcImplConfig
AdbBasedImpl = Literal['adb', 'adb_raw', 'uiautomator2']
DeviceImpl = str | AdbBasedImpl | Literal['windows', 'remote_windows', 'nemu_ipc']
# --- 核心类型定义 ---
class ImplRegistrationError(KotonebotError):
"""与 impl 注册相关的错误"""
pass
@dataclass
class ImplConfig:
"""所有设备实现配置模型的名义上的基类,便于类型约束。"""
pass

View File

@ -3,10 +3,10 @@ from typing import Generic, TypeVar, Literal
from pydantic import BaseModel, ConfigDict
from kotonebot.client.factory import DeviceImpl
T = TypeVar('T')
BackendType = Literal['custom', 'mumu12', 'leidian', 'dmm']
DeviceRecipes = Literal['adb', 'adb_raw', 'uiautomator2', 'windows', 'remote_windows', 'nemu_ipc']
class ConfigBaseModel(BaseModel):
model_config = ConfigDict(use_attribute_docstrings=True)
@ -27,7 +27,7 @@ class BackendConfig(ConfigBaseModel):
雷电模拟器需要设置正确的模拟器名否则 自动启动模拟器 功能将无法正常工作
其他功能不受影响
"""
screenshot_impl: DeviceImpl = 'adb'
screenshot_impl: DeviceRecipes = 'adb'
"""
截图方法暂时推荐使用adb截图方式
@ -44,6 +44,14 @@ class BackendConfig(ConfigBaseModel):
"""模拟器 exe 文件路径"""
emulator_args: str = ""
"""模拟器启动时的命令行参数"""
windows_window_title: str = 'gakumas'
"""Windows 截图方式的窗口标题"""
windows_ahk_path: str | None = None
"""Windows 截图方式的 AutoHotkey 可执行文件路径,为 None 时使用默认路径"""
mumu_background_mode: bool = False
"""MuMu12 模拟器后台保活模式"""
target_screenshot_interval: float | None = None
"""最小截图间隔,单位为秒。为 None 时不限制截图速度。"""
class PushConfig(ConfigBaseModel):
"""推送配置。"""

View File

@ -1,3 +1,5 @@
import sys
sys.path.append('./projects')
import runpy
import logging
import argparse
@ -12,15 +14,20 @@ def run_script(script_path: str) -> None:
Args:
script_path: Python 脚本的路径
"""
logging.basicConfig(level=logging.INFO, format='[%(asctime)s] [%(levelname)s] [%(name)s] [%(funcName)s] [%(lineno)d] %(message)s')
# 获取模块名
module_name = script_path.strip('.py').replace('\\', '/').strip('/').replace('/', '.')
module_name = script_path.strip('.py').lstrip('projects/').replace('\\', '/').strip('/').replace('/', '.')
print(f"正在运行脚本: {script_path}")
# 运行脚本
from kotonebot.backend.context import init_context
logging.basicConfig(level=logging.INFO, format='[%(asctime)s] [%(levelname)s] [%(name)s] [%(funcName)s] [%(lineno)d] %(message)s')
from kotonebot.backend.context import init_context, manual_context
from kotonebot.kaa.main.kaa import Kaa
logging.getLogger('kotonebot').setLevel(logging.DEBUG)
init_context(config_type=BaseConfig)
config_path = './config.json'
kaa_instance = Kaa(config_path)
init_context(config_type=BaseConfig, target_device=kaa_instance._on_create_device())
kaa_instance._on_after_init_context()
manual_context().begin()
runpy.run_module(module_name, run_name="__main__")
def main():

View File

@ -24,3 +24,14 @@ class TaskNotFoundError(KotonebotError):
def __init__(self, task_id: str):
self.task_id = task_id
super().__init__(f'Task "{task_id}" not found.')
class UnscalableResolutionError(KotonebotError):
def __init__(self, target_resolution: tuple[int, int], screen_size: tuple[int, int]):
self.target_resolution = target_resolution
self.screen_size = screen_size
super().__init__(f'Cannot scale to target resolution {target_resolution}. '
f'Screen size: {screen_size}')
class ContextNotInitializedError(KotonebotError):
def __init__(self, msg: str = 'Context not initialized'):
super().__init__(msg)

View File

@ -11,960 +11,12 @@ from pydantic import BaseModel, ConfigDict
# TODO: from kotonebot import config (context) 会和 kotonebot.config 冲突
from kotonebot import logging
from kotonebot.backend.context import config
from kotonebot.kaa.config.schema import BaseConfig
logger = logging.getLogger(__name__)
T = TypeVar('T')
class ConfigEnum(Enum):
def display(self) -> str:
return self.value[1]
class Priority(IntEnum):
"""
任务优先级数字越大优先级越高越先执行
"""
START_GAME = 1
DEFAULT = 0
CLAIM_MISSION_REWARD = -1
END_GAME = -2
class APShopItems(IntEnum):
PRODUCE_PT_UP = 0
"""获取支援强化 Pt 提升"""
PRODUCE_NOTE_UP = 1
"""获取笔记数提升"""
RECHALLENGE = 2
"""再挑战券"""
REGENERATE_MEMORY = 3
"""回忆再生成券"""
class DailyMoneyShopItems(IntEnum):
"""日常商店物品"""
Recommendations = -1
"""所有推荐商品"""
LessonNote = 0
"""レッスンノート"""
VeteranNote = 1
"""ベテランノート"""
SupportEnhancementPt = 2
"""サポート強化Pt 支援强化Pt"""
SenseNoteVocal = 3
"""センスノート(ボーカル)感性笔记(声乐)"""
SenseNoteDance = 4
"""センスノート(ダンス)感性笔记(舞蹈)"""
SenseNoteVisual = 5
"""センスノート(ビジュアル)感性笔记(形象)"""
LogicNoteVocal = 6
"""ロジックノート(ボーカル)理性笔记(声乐)"""
LogicNoteDance = 7
"""ロジックノート(ダンス)理性笔记(舞蹈)"""
LogicNoteVisual = 8
"""ロジックノート(ビジュアル)理性笔记(形象)"""
AnomalyNoteVocal = 9
"""アノマリーノート(ボーカル)非凡笔记(声乐)"""
AnomalyNoteDance = 10
"""アノマリーノート(ダンス)非凡笔记(舞蹈)"""
AnomalyNoteVisual = 11
"""アノマリーノート(ビジュアル)非凡笔记(形象)"""
RechallengeTicket = 12
"""再挑戦チケット 重新挑战券"""
RecordKey = 13
"""記録の鍵 解锁交流的物品"""
# 碎片
IdolPiece_倉本千奈_WonderScale = 14
"""倉本千奈 WonderScale 碎片"""
IdolPiece_篠泽广_光景 = 15
"""篠泽广 光景 碎片"""
IdolPiece_紫云清夏_TameLieOneStep = 16
"""紫云清夏 Tame-Lie-One-Step 碎片"""
IdolPiece_葛城リーリヤ_白線 = 17
"""葛城リーリヤ 白線 碎片"""
IdolPiece_姫崎薪波_cIclumsy_trick = 18
"""姫崎薪波 cIclumsy trick 碎片"""
IdolPiece_花海咲季_FightingMyWay = 19
"""花海咲季 FightingMyWay 碎片"""
IdolPiece_藤田ことね_世界一可愛い私 = 20
"""藤田ことね 世界一可愛い私 碎片"""
IdolPiece_花海佑芽_TheRollingRiceball = 21
"""花海佑芽 The Rolling Riceball 碎片"""
IdolPiece_月村手毬_LunaSayMaybe = 22
"""月村手毬 Luna say maybe 碎片"""
@classmethod
def to_ui_text(cls, item: "DailyMoneyShopItems") -> str:
"""获取枚举值对应的UI显示文本"""
match item:
case cls.Recommendations:
return "所有推荐商品"
case cls.LessonNote:
return "课程笔记"
case cls.VeteranNote:
return "老手笔记"
case cls.SupportEnhancementPt:
return "支援强化点数"
case cls.SenseNoteVocal:
return "感性笔记(声乐)"
case cls.SenseNoteDance:
return "感性笔记(舞蹈)"
case cls.SenseNoteVisual:
return "感性笔记(形象)"
case cls.LogicNoteVocal:
return "理性笔记(声乐)"
case cls.LogicNoteDance:
return "理性笔记(舞蹈)"
case cls.LogicNoteVisual:
return "理性笔记(形象)"
case cls.AnomalyNoteVocal:
return "非凡笔记(声乐)"
case cls.AnomalyNoteDance:
return "非凡笔记(舞蹈)"
case cls.AnomalyNoteVisual:
return "非凡笔记(形象)"
case cls.RechallengeTicket:
return "重新挑战券"
case cls.RecordKey:
return "记录钥匙"
case cls.IdolPiece_倉本千奈_WonderScale:
return "倉本千奈 WonderScale 碎片"
case cls.IdolPiece_篠泽广_光景:
return "篠泽广 光景 碎片"
case cls.IdolPiece_紫云清夏_TameLieOneStep:
return "紫云清夏 Tame-Lie-One-Step 碎片"
case cls.IdolPiece_葛城リーリヤ_白線:
return "葛城リーリヤ 白線 碎片"
case cls.IdolPiece_姫崎薪波_cIclumsy_trick:
return "姫崎薪波 cIclumsy trick 碎片"
case cls.IdolPiece_花海咲季_FightingMyWay:
return "花海咲季 FightingMyWay 碎片"
case cls.IdolPiece_藤田ことね_世界一可愛い私:
return "藤田ことね 世界一可愛い私 碎片"
case cls.IdolPiece_花海佑芽_TheRollingRiceball:
return "花海佑芽 The Rolling Riceball 碎片"
case cls.IdolPiece_月村手毬_LunaSayMaybe:
return "月村手毬 Luna say maybe 碎片"
case _:
assert_never(item)
@classmethod
def all(cls) -> list[tuple[str, 'DailyMoneyShopItems']]:
"""获取所有枚举值及其对应的UI显示文本"""
return [(cls.to_ui_text(item), item) for item in cls]
@classmethod
def _is_note(cls, item: 'DailyMoneyShopItems') -> bool:
"""判断是否为笔记"""
return 'Note' in item.name and not item.name.startswith('Note') and not item.name.endswith('Note')
@classmethod
def note_items(cls) -> list[tuple[str, 'DailyMoneyShopItems']]:
"""获取所有枚举值及其对应的UI显示文本"""
return [(cls.to_ui_text(item), item) for item in cls if cls._is_note(item)]
def to_resource(self):
from kotonebot.kaa.tasks import R
match self:
case DailyMoneyShopItems.Recommendations:
return R.Daily.TextShopRecommended
case DailyMoneyShopItems.LessonNote:
return R.Shop.ItemLessonNote
case DailyMoneyShopItems.VeteranNote:
return R.Shop.ItemVeteranNote
case DailyMoneyShopItems.SupportEnhancementPt:
return R.Shop.ItemSupportEnhancementPt
case DailyMoneyShopItems.SenseNoteVocal:
return R.Shop.ItemSenseNoteVocal
case DailyMoneyShopItems.SenseNoteDance:
return R.Shop.ItemSenseNoteDance
case DailyMoneyShopItems.SenseNoteVisual:
return R.Shop.ItemSenseNoteVisual
case DailyMoneyShopItems.LogicNoteVocal:
return R.Shop.ItemLogicNoteVocal
case DailyMoneyShopItems.LogicNoteDance:
return R.Shop.ItemLogicNoteDance
case DailyMoneyShopItems.LogicNoteVisual:
return R.Shop.ItemLogicNoteVisual
case DailyMoneyShopItems.AnomalyNoteVocal:
return R.Shop.ItemAnomalyNoteVocal
case DailyMoneyShopItems.AnomalyNoteDance:
return R.Shop.ItemAnomalyNoteDance
case DailyMoneyShopItems.AnomalyNoteVisual:
return R.Shop.ItemAnomalyNoteVisual
case DailyMoneyShopItems.RechallengeTicket:
return R.Shop.ItemRechallengeTicket
case DailyMoneyShopItems.RecordKey:
return R.Shop.ItemRecordKey
case DailyMoneyShopItems.IdolPiece_倉本千奈_WonderScale:
return R.Shop.IdolPiece.倉本千奈_WonderScale
case DailyMoneyShopItems.IdolPiece_篠泽广_光景:
return R.Shop.IdolPiece.篠泽广_光景
case DailyMoneyShopItems.IdolPiece_紫云清夏_TameLieOneStep:
return R.Shop.IdolPiece.紫云清夏_TameLieOneStep
case DailyMoneyShopItems.IdolPiece_葛城リーリヤ_白線:
return R.Shop.IdolPiece.葛城リーリヤ_白線
case DailyMoneyShopItems.IdolPiece_姫崎薪波_cIclumsy_trick:
return R.Shop.IdolPiece.姫崎薪波_cIclumsy_trick
case DailyMoneyShopItems.IdolPiece_花海咲季_FightingMyWay:
return R.Shop.IdolPiece.花海咲季_FightingMyWay
case DailyMoneyShopItems.IdolPiece_藤田ことね_世界一可愛い私:
return R.Shop.IdolPiece.藤田ことね_世界一可愛い私
case DailyMoneyShopItems.IdolPiece_花海佑芽_TheRollingRiceball:
return R.Shop.IdolPiece.花海佑芽_TheRollingRiceball
case DailyMoneyShopItems.IdolPiece_月村手毬_LunaSayMaybe:
return R.Shop.IdolPiece.月村手毬_LunaSayMaybe
case _:
assert_never(self)
class ConfigBaseModel(BaseModel):
model_config = ConfigDict(use_attribute_docstrings=True)
class PurchaseConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用商店购买"""
money_enabled: bool = False
"""是否启用金币购买"""
money_items: list[DailyMoneyShopItems] = []
"""金币商店要购买的物品"""
money_refresh_on: Literal['never', 'not_found', 'always'] = 'never'
"""
金币商店刷新逻辑
* never: 从不刷新
* not_found: 仅当要购买的物品不存在时刷新
* always: 总是刷新
"""
ap_enabled: bool = False
"""是否启用AP购买"""
ap_items: Sequence[Literal[0, 1, 2, 3]] = []
"""AP商店要购买的物品"""
class ActivityFundsConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用收取活动费"""
class PresentsConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用收取礼物"""
class AssignmentConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用工作"""
mini_live_reassign_enabled: bool = False
"""是否启用重新分配 MiniLive"""
mini_live_duration: Literal[4, 6, 12] = 12
"""MiniLive 工作时长"""
online_live_reassign_enabled: bool = False
"""是否启用重新分配 OnlineLive"""
online_live_duration: Literal[4, 6, 12] = 12
"""OnlineLive 工作时长"""
class ContestConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用竞赛"""
select_which_contestant: Literal[1, 2, 3] = 1
"""选择第几个挑战者"""
class ProduceAction(Enum):
RECOMMENDED = 'recommended'
VISUAL = 'visual'
VOCAL = 'vocal'
DANCE = 'dance'
# VISUAL_SP = 'visual_sp'
# VOCAL_SP = 'vocal_sp'
# DANCE_SP = 'dance_sp'
OUTING = 'outing'
STUDY = 'study'
ALLOWANCE = 'allowance'
REST = 'rest'
CONSULT = 'consult'
@property
def display_name(self):
MAP = {
ProduceAction.RECOMMENDED: '推荐行动',
ProduceAction.VISUAL: '形象课程',
ProduceAction.VOCAL: '声乐课程',
ProduceAction.DANCE: '舞蹈课程',
ProduceAction.OUTING: '外出(おでかけ)',
ProduceAction.STUDY: '文化课(授業)',
ProduceAction.ALLOWANCE: '活动支给(活動支給)',
ProduceAction.REST: '休息',
ProduceAction.CONSULT: '咨询(相談)',
}
return MAP[self]
class RecommendCardDetectionMode(Enum):
NORMAL = 'normal'
STRICT = 'strict'
@property
def display_name(self):
MAP = {
RecommendCardDetectionMode.NORMAL: '正常模式',
RecommendCardDetectionMode.STRICT: '严格模式',
}
return MAP[self]
class ProduceConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用培育"""
mode: Literal['regular', 'pro', 'master'] = 'regular'
"""
培育模式
进行一次 REGULAR 培育需要 ~30min进行一次 PRO 培育需要 ~1h具体视设备性能而定
"""
produce_count: int = 1
"""培育的次数。"""
idols: list[str] = []
"""
要培育偶像的 IdolCardSkin.id将会按顺序循环选择培育
"""
memory_sets: list[int] = []
"""要使用的回忆编成编号,从 1 开始。将会按顺序循环选择使用。"""
support_card_sets: list[int] = []
"""要使用的支援卡编成编号,从 1 开始。将会按顺序循环选择使用。"""
auto_set_memory: bool = False
"""是否自动编成回忆。此选项优先级高于回忆编成编号。"""
auto_set_support_card: bool = False
"""是否自动编成支援卡。此选项优先级高于支援卡编成编号。"""
use_pt_boost: bool = False
"""是否使用支援强化 Pt 提升。"""
use_note_boost: bool = False
"""是否使用笔记数提升。"""
follow_producer: bool = False
"""是否关注租借了支援卡的制作人。"""
self_study_lesson: Literal['dance', 'visual', 'vocal'] = 'dance'
"""自习课类型。"""
prefer_lesson_ap: bool = False
"""
优先 SP 课程
启用后若出现 SP 课程则会优先执行 SP 课程而不是推荐课程
若出现多个 SP 课程随机选择一个
"""
actions_order: list[ProduceAction] = [
ProduceAction.RECOMMENDED,
ProduceAction.VISUAL,
ProduceAction.VOCAL,
ProduceAction.DANCE,
ProduceAction.ALLOWANCE,
ProduceAction.OUTING,
ProduceAction.STUDY,
ProduceAction.CONSULT,
ProduceAction.REST,
]
"""
行动优先级
每一周的行动将会按这里设置的优先级执行
"""
recommend_card_detection_mode: RecommendCardDetectionMode = RecommendCardDetectionMode.NORMAL
"""
推荐卡检测模式
严格模式下识别速度会降低但识别准确率会提高
"""
use_ap_drink: bool = False
"""
AP 不足时自动使用 AP 饮料
"""
skip_commu: bool = True
"""检测并跳过交流"""
class MissionRewardConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用领取任务奖励"""
class ClubRewardConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用领取社团奖励"""
selected_note: DailyMoneyShopItems = DailyMoneyShopItems.AnomalyNoteVisual
"""想在社团奖励中获取到的笔记"""
class UpgradeSupportCardConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用支援卡升级"""
class CapsuleToysConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用扭蛋机"""
friend_capsule_toys_count: int = 0
"""好友扭蛋机次数"""
sense_capsule_toys_count: int = 0
"""感性扭蛋机次数"""
logic_capsule_toys_count: int = 0
"""理性扭蛋机次数"""
anomaly_capsule_toys_count: int = 0
"""非凡扭蛋机次数"""
class TraceConfig(ConfigBaseModel):
recommend_card_detection: bool = False
"""跟踪推荐卡检测"""
class StartGameConfig(ConfigBaseModel):
enabled: bool = True
"""是否启用自动启动游戏。默认为True"""
start_through_kuyo: bool = False
"""是否通过Kuyo来启动游戏"""
game_package_name: str = 'com.bandainamcoent.idolmaster_gakuen'
"""游戏包名"""
kuyo_package_name: str = 'org.kuyo.game'
"""Kuyo包名"""
disable_gakumas_localify: bool = False
"""
自动检测并禁用 Gakumas Localify 汉化插件
目前仅对 DMM 版有效
"""
dmm_game_path: str | None = None
"""
DMM 版游戏路径若不填写会自动检测
`F:\\Games\\gakumas\\gakumas.exe`
"""
class EndGameConfig(ConfigBaseModel):
exit_kaa: bool = False
"""退出 kaa"""
kill_game: bool = False
"""关闭游戏"""
kill_dmm: bool = False
"""关闭 DMMGamePlayer"""
kill_emulator: bool = False
"""关闭模拟器"""
shutdown: bool = False
"""关闭系统"""
hibernate: bool = False
"""休眠系统"""
restore_gakumas_localify: bool = False
"""
恢复 Gakumas Localify 汉化插件状态至启动前通常与
`disable_gakumas_localify` 配对使用
目前仅对 DMM 版有效
"""
class BaseConfig(ConfigBaseModel):
purchase: PurchaseConfig = PurchaseConfig()
"""商店购买配置"""
activity_funds: ActivityFundsConfig = ActivityFundsConfig()
"""活动费配置"""
presents: PresentsConfig = PresentsConfig()
"""收取礼物配置"""
assignment: AssignmentConfig = AssignmentConfig()
"""工作配置"""
contest: ContestConfig = ContestConfig()
"""竞赛配置"""
produce: ProduceConfig = ProduceConfig()
"""培育配置"""
mission_reward: MissionRewardConfig = MissionRewardConfig()
"""领取任务奖励配置"""
club_reward: ClubRewardConfig = ClubRewardConfig()
"""领取社团奖励配置"""
upgrade_support_card: UpgradeSupportCardConfig = UpgradeSupportCardConfig()
"""支援卡升级配置"""
capsule_toys: CapsuleToysConfig = CapsuleToysConfig()
"""扭蛋机配置"""
trace: TraceConfig = TraceConfig()
"""跟踪配置"""
start_game: StartGameConfig = StartGameConfig()
"""启动游戏配置"""
end_game: EndGameConfig = EndGameConfig()
"""关闭游戏配置"""
def conf() -> BaseConfig:
"""获取当前配置数据"""
c = config.to(BaseConfig).current
return c.options
def sprite_path(path: str) -> str:
standalone = os.path.join('kotonebot/kaa/sprites', path)
if os.path.exists(standalone):
return standalone
return str(resources.files('kotonebot.kaa.sprites') / path)
def upgrade_config() -> str | None:
"""
升级配置文件
"""
if not os.path.exists('config.json'):
return None
with open('config.json', 'r', encoding='utf-8') as f:
root = json.load(f)
user_configs = root['user_configs']
old_version = root['version']
messages = []
def upgrade_user_config(version: int, user_config: dict[str, Any]) -> int:
nonlocal messages
while True:
match version:
case 1:
logger.info('Upgrading config: v1 -> v2')
user_config, msg = upgrade_v1_to_v2(user_config['options'])
messages.append(msg)
version = 2
case 2:
logger.info('Upgrading config: v2 -> v3')
user_config, msg = upgrade_v2_to_v3(user_config['options'])
messages.append(msg)
version = 3
case 3:
logger.info('Upgrading config: v3 -> v4')
user_config, msg = upgrade_v3_to_v4(user_config['options'])
messages.append(msg)
version = 4
case 4:
logger.info('Upgrading config: v4 -> v5')
user_config, msg = upgrade_v4_to_v5(user_config, user_config['options'])
messages.append(msg)
version = 5
case _:
logger.info('No config upgrade needed.')
return version
for user_config in user_configs:
new_version = upgrade_user_config(old_version, user_config)
root['version'] = new_version
with open('config.json', 'w', encoding='utf-8') as f:
json.dump(root, f, ensure_ascii=False, indent=4)
return '\n'.join(messages)
倉本千奈_BASE = 0
十王星南_BASE = 100
姫崎莉波_BASE = 200
月村手毬_BASE = 300
有村麻央_BASE = 400
篠泽广_BASE = 500
紫云清夏_BASE = 600
花海佑芽_BASE = 700
花海咲季_BASE = 800
葛城リーリヤ_BASE = 900
藤田ことね_BASE = 1000
class PIdol(IntEnum):
"""
P偶像已废弃仅为 upgrade_v1_to_v2()upgrade_v2_to_v3() 而保留
"""
倉本千奈_Campusmode = 倉本千奈_BASE + 0
倉本千奈_WonderScale = 倉本千奈_BASE + 1
倉本千奈_ようこそ初星温泉 = 倉本千奈_BASE + 2
倉本千奈_仮装狂騒曲 = 倉本千奈_BASE + 3
倉本千奈_初心 = 倉本千奈_BASE + 4
倉本千奈_学園生活 = 倉本千奈_BASE + 5
倉本千奈_日々_発見的ステップ = 倉本千奈_BASE + 6
倉本千奈_胸を張って一歩ずつ = 倉本千奈_BASE + 7
十王星南_Campusmode = 十王星南_BASE + 0
十王星南_一番星 = 十王星南_BASE + 1
十王星南_学園生活 = 十王星南_BASE + 2
十王星南_小さな野望 = 十王星南_BASE + 3
姫崎莉波_clumsytrick = 姫崎莉波_BASE + 0
姫崎莉波_私らしさのはじまり = 姫崎莉波_BASE + 1
姫崎莉波_キミとセミブルー = 姫崎莉波_BASE + 2
姫崎莉波_Campusmode = 姫崎莉波_BASE + 3
姫崎莉波_LUV = 姫崎莉波_BASE + 4
姫崎莉波_ようこそ初星温泉 = 姫崎莉波_BASE + 5
姫崎莉波_ハッピーミルフィーユ = 姫崎莉波_BASE + 6
姫崎莉波_初心 = 姫崎莉波_BASE + 7
姫崎莉波_学園生活 = 姫崎莉波_BASE + 8
月村手毬_Lunasaymaybe = 月村手毬_BASE + 0
月村手毬_一匹狼 = 月村手毬_BASE + 1
月村手毬_Campusmode = 月村手毬_BASE + 2
月村手毬_アイヴイ = 月村手毬_BASE + 3
月村手毬_初声 = 月村手毬_BASE + 4
月村手毬_学園生活 = 月村手毬_BASE + 5
月村手毬_仮装狂騒曲 = 月村手毬_BASE + 6
有村麻央_Fluorite = 有村麻央_BASE + 0
有村麻央_はじまりはカッコよく = 有村麻央_BASE + 1
有村麻央_Campusmode = 有村麻央_BASE + 2
有村麻央_FeelJewelDream = 有村麻央_BASE + 3
有村麻央_キミとセミブルー = 有村麻央_BASE + 4
有村麻央_初恋 = 有村麻央_BASE + 5
有村麻央_学園生活 = 有村麻央_BASE + 6
篠泽广_コントラスト = 篠泽广_BASE + 0
篠泽广_一番向いていないこと = 篠泽广_BASE + 1
篠泽广_光景 = 篠泽广_BASE + 2
篠泽广_Campusmode = 篠泽广_BASE + 3
篠泽广_仮装狂騒曲 = 篠泽广_BASE + 4
篠泽广_ハッピーミルフィーユ = 篠泽广_BASE + 5
篠泽广_初恋 = 篠泽广_BASE + 6
篠泽广_学園生活 = 篠泽广_BASE + 7
紫云清夏_TameLieOneStep = 紫云清夏_BASE + 0
紫云清夏_カクシタワタシ = 紫云清夏_BASE + 1
紫云清夏_夢へのリスタート = 紫云清夏_BASE + 2
紫云清夏_Campusmode = 紫云清夏_BASE + 3
紫云清夏_キミとセミブルー = 紫云清夏_BASE + 4
紫云清夏_初恋 = 紫云清夏_BASE + 5
紫云清夏_学園生活 = 紫云清夏_BASE + 6
花海佑芽_WhiteNightWhiteWish = 花海佑芽_BASE + 0
花海佑芽_学園生活 = 花海佑芽_BASE + 1
花海佑芽_Campusmode = 花海佑芽_BASE + 2
花海佑芽_TheRollingRiceball = 花海佑芽_BASE + 3
花海佑芽_アイドル_はじめっ = 花海佑芽_BASE + 4
花海咲季_BoomBoomPow = 花海咲季_BASE + 0
花海咲季_Campusmode = 花海咲季_BASE + 1
花海咲季_FightingMyWay = 花海咲季_BASE + 2
花海咲季_わたしが一番 = 花海咲季_BASE + 3
花海咲季_冠菊 = 花海咲季_BASE + 4
花海咲季_初声 = 花海咲季_BASE + 5
花海咲季_古今東西ちょちょいのちょい = 花海咲季_BASE + 6
花海咲季_学園生活 = 花海咲季_BASE + 7
葛城リーリヤ_一つ踏み出した先に = 葛城リーリヤ_BASE + 0
葛城リーリヤ_白線 = 葛城リーリヤ_BASE + 1
葛城リーリヤ_Campusmode = 葛城リーリヤ_BASE + 2
葛城リーリヤ_WhiteNightWhiteWish = 葛城リーリヤ_BASE + 3
葛城リーリヤ_冠菊 = 葛城リーリヤ_BASE + 4
葛城リーリヤ_初心 = 葛城リーリヤ_BASE + 5
葛城リーリヤ_学園生活 = 葛城リーリヤ_BASE + 6
藤田ことね_カワイイ_はじめました = 藤田ことね_BASE + 0
藤田ことね_世界一可愛い私 = 藤田ことね_BASE + 1
藤田ことね_Campusmode = 藤田ことね_BASE + 2
藤田ことね_YellowBigBang = 藤田ことね_BASE + 3
藤田ことね_WhiteNightWhiteWish = 藤田ことね_BASE + 4
藤田ことね_冠菊 = 藤田ことね_BASE + 5
藤田ことね_初声 = 藤田ことね_BASE + 6
藤田ことね_学園生活 = 藤田ことね_BASE + 7
def upgrade_v1_to_v2(options: dict[str, Any]) -> tuple[dict[str, Any], str]:
"""
v1 -> v2 变更
1. PIdol 的枚举值改为整数
"""
msg = ''
# 转换 PIdol 的枚举值
def map_idol(idol: list[str]) -> PIdol | None:
logger.debug("Converting %s", idol)
match idol:
case ["倉本千奈", "Campus mode!!"]:
return PIdol.倉本千奈_Campusmode
case ["倉本千奈", "Wonder Scale"]:
return PIdol.倉本千奈_WonderScale
case ["倉本千奈", "ようこそ初星温泉"]:
return PIdol.倉本千奈_ようこそ初星温泉
case ["倉本千奈", "仮装狂騒曲"]:
return PIdol.倉本千奈_仮装狂騒曲
case ["倉本千奈", "初心"]:
return PIdol.倉本千奈_初心
case ["倉本千奈", "学園生活"]:
return PIdol.倉本千奈_学園生活
case ["倉本千奈", "日々、発見的ステップ!"]:
return PIdol.倉本千奈_日々_発見的ステップ
case ["倉本千奈", "胸を張って一歩ずつ"]:
return PIdol.倉本千奈_胸を張って一歩ずつ
case ["十王星南", "Campus mode!!"]:
return PIdol.十王星南_Campusmode
case ["十王星南", "一番星"]:
return PIdol.十王星南_一番星
case ["十王星南", "学園生活"]:
return PIdol.十王星南_学園生活
case ["十王星南", "小さな野望"]:
return PIdol.十王星南_小さな野望
case ["姫崎莉波", "clumsy trick"]:
return PIdol.姫崎莉波_clumsytrick
case ["姫崎莉波", "『私らしさ』のはじまり"]:
return PIdol.姫崎莉波_私らしさのはじまり
case ["姫崎莉波", "キミとセミブルー"]:
return PIdol.姫崎莉波_キミとセミブルー
case ["姫崎莉波", "Campus mode!!"]:
return PIdol.姫崎莉波_Campusmode
case ["姫崎莉波", "L.U.V"]:
return PIdol.姫崎莉波_LUV
case ["姫崎莉波", "ようこそ初星温泉"]:
return PIdol.姫崎莉波_ようこそ初星温泉
case ["姫崎莉波", "ハッピーミルフィーユ"]:
return PIdol.姫崎莉波_ハッピーミルフィーユ
case ["姫崎莉波", "初心"]:
return PIdol.姫崎莉波_初心
case ["姫崎莉波", "学園生活"]:
return PIdol.姫崎莉波_学園生活
case ["月村手毬", "Luna say maybe"]:
return PIdol.月村手毬_Lunasaymaybe
case ["月村手毬", "一匹狼"]:
return PIdol.月村手毬_一匹狼
case ["月村手毬", "Campus mode!!"]:
return PIdol.月村手毬_Campusmode
case ["月村手毬", "アイヴイ"]:
return PIdol.月村手毬_アイヴイ
case ["月村手毬", "初声"]:
return PIdol.月村手毬_初声
case ["月村手毬", "学園生活"]:
return PIdol.月村手毬_学園生活
case ["月村手毬", "仮装狂騒曲"]:
return PIdol.月村手毬_仮装狂騒曲
case ["有村麻央", "Fluorite"]:
return PIdol.有村麻央_Fluorite
case ["有村麻央", "はじまりはカッコよく"]:
return PIdol.有村麻央_はじまりはカッコよく
case ["有村麻央", "Campus mode!!"]:
return PIdol.有村麻央_Campusmode
case ["有村麻央", "Feel Jewel Dream"]:
return PIdol.有村麻央_FeelJewelDream
case ["有村麻央", "キミとセミブルー"]:
return PIdol.有村麻央_キミとセミブルー
case ["有村麻央", "初恋"]:
return PIdol.有村麻央_初恋
case ["有村麻央", "学園生活"]:
return PIdol.有村麻央_学園生活
case ["篠泽广", "コントラスト"]:
return PIdol.篠泽广_コントラスト
case ["篠泽广", "一番向いていないこと"]:
return PIdol.篠泽广_一番向いていないこと
case ["篠泽广", "光景"]:
return PIdol.篠泽广_光景
case ["篠泽广", "Campus mode!!"]:
return PIdol.篠泽广_Campusmode
case ["篠泽广", "仮装狂騒曲"]:
return PIdol.篠泽广_仮装狂騒曲
case ["篠泽广", "ハッピーミルフィーユ"]:
return PIdol.篠泽广_ハッピーミルフィーユ
case ["篠泽广", "初恋"]:
return PIdol.篠泽广_初恋
case ["篠泽广", "学園生活"]:
return PIdol.篠泽广_学園生活
case ["紫云清夏", "Tame-Lie-One-Step"]:
return PIdol.紫云清夏_TameLieOneStep
case ["紫云清夏", "カクシタワタシ"]:
return PIdol.紫云清夏_カクシタワタシ
case ["紫云清夏", "夢へのリスタート"]:
return PIdol.紫云清夏_夢へのリスタート
case ["紫云清夏", "Campus mode!!"]:
return PIdol.紫云清夏_Campusmode
case ["紫云清夏", "キミとセミブルー"]:
return PIdol.紫云清夏_キミとセミブルー
case ["紫云清夏", "初恋"]:
return PIdol.紫云清夏_初恋
case ["紫云清夏", "学園生活"]:
return PIdol.紫云清夏_学園生活
case ["花海佑芽", "White Night! White Wish!"]:
return PIdol.花海佑芽_WhiteNightWhiteWish
case ["花海佑芽", "学園生活"]:
return PIdol.花海佑芽_学園生活
case ["花海佑芽", "Campus mode!!"]:
return PIdol.花海佑芽_Campusmode
case ["花海佑芽", "The Rolling Riceball"]:
return PIdol.花海佑芽_TheRollingRiceball
case ["花海佑芽", "アイドル、はじめっ!"]:
return PIdol.花海佑芽_アイドル_はじめっ
case ["花海咲季", "Boom Boom Pow"]:
return PIdol.花海咲季_BoomBoomPow
case ["花海咲季", "Campus mode!!"]:
return PIdol.花海咲季_Campusmode
case ["花海咲季", "Fighting My Way"]:
return PIdol.花海咲季_FightingMyWay
case ["花海咲季", "わたしが一番!"]:
return PIdol.花海咲季_わたしが一番
case ["花海咲季", "冠菊"]:
return PIdol.花海咲季_冠菊
case ["花海咲季", "初声"]:
return PIdol.花海咲季_初声
case ["花海咲季", "古今東西ちょちょいのちょい"]:
return PIdol.花海咲季_古今東西ちょちょいのちょい
case ["花海咲季", "学園生活"]:
return PIdol.花海咲季_学園生活
case ["葛城リーリヤ", "一つ踏み出した先に"]:
return PIdol.葛城リーリヤ_一つ踏み出した先に
case ["葛城リーリヤ", "白線"]:
return PIdol.葛城リーリヤ_白線
case ["葛城リーリヤ", "Campus mode!!"]:
return PIdol.葛城リーリヤ_Campusmode
case ["葛城リーリヤ", "White Night! White Wish!"]:
return PIdol.葛城リーリヤ_WhiteNightWhiteWish
case ["葛城リーリヤ", "冠菊"]:
return PIdol.葛城リーリヤ_冠菊
case ["葛城リーリヤ", "初心"]:
return PIdol.葛城リーリヤ_初心
case ["葛城リーリヤ", "学園生活"]:
return PIdol.葛城リーリヤ_学園生活
case ["藤田ことね", "カワイイ", "はじめました"]:
return PIdol.藤田ことね_カワイイ_はじめました
case ["藤田ことね", "世界一可愛い私"]:
return PIdol.藤田ことね_世界一可愛い私
case ["藤田ことね", "Campus mode!!"]:
return PIdol.藤田ことね_Campusmode
case ["藤田ことね", "Yellow Big Bang"]:
return PIdol.藤田ことね_YellowBigBang
case ["藤田ことね", "White Night! White Wish!"]:
return PIdol.藤田ことね_WhiteNightWhiteWish
case ["藤田ことね", "冠菊"]:
return PIdol.藤田ことね_冠菊
case ["藤田ことね", "初声"]:
return PIdol.藤田ことね_初声
case ["藤田ことね", "学園生活"]:
return PIdol.藤田ことね_学園生活
case _:
nonlocal msg
if msg == '':
msg = '培育设置中的以下偶像升级失败。请尝试手动添加。\n'
msg += f'{idol} 未找到\n'
return None
old_idols = options['produce']['idols']
new_idols = list(filter(lambda x: x is not None, map(map_idol, old_idols)))
options['produce']['idols'] = new_idols
shutil.copy('config.json', 'config.v1.json')
return options, msg
def upgrade_v2_to_v3(options: dict[str, Any]) -> tuple[dict[str, Any], str]:
"""
v2 -> v3 变更\n
引入了游戏解包数据因此 PIdol 枚举废弃直接改用游戏内 ID
"""
msg = ''
def map_idol(idol: PIdol) -> str | None:
match idol:
case PIdol.倉本千奈_Campusmode: return "i_card-skin-kcna-3-007"
case PIdol.倉本千奈_WonderScale: return "i_card-skin-kcna-3-000"
case PIdol.倉本千奈_ようこそ初星温泉: return "i_card-skin-kcna-3-005"
case PIdol.倉本千奈_仮装狂騒曲: return "i_card-skin-kcna-3-002"
case PIdol.倉本千奈_初心: return "i_card-skin-kcna-1-001"
case PIdol.倉本千奈_学園生活: return "i_card-skin-kcna-1-000"
case PIdol.倉本千奈_日々_発見的ステップ: return "i_card-skin-kcna-3-001"
case PIdol.倉本千奈_胸を張って一歩ずつ: return "i_card-skin-kcna-2-000"
case PIdol.十王星南_Campusmode: return "i_card-skin-jsna-3-002"
case PIdol.十王星南_一番星: return "i_card-skin-jsna-2-000"
case PIdol.十王星南_学園生活: return "i_card-skin-jsna-1-000"
case PIdol.十王星南_小さな野望: return "i_card-skin-jsna-3-000"
case PIdol.姫崎莉波_clumsytrick: return "i_card-skin-hrnm-3-000"
case PIdol.姫崎莉波_私らしさのはじまり: return "i_card-skin-hrnm-2-000"
case PIdol.姫崎莉波_キミとセミブルー: return "i_card-skin-hrnm-3-001"
case PIdol.姫崎莉波_Campusmode: return "i_card-skin-hrnm-3-007"
case PIdol.姫崎莉波_LUV: return "i_card-skin-hrnm-3-002"
case PIdol.姫崎莉波_ようこそ初星温泉: return "i_card-skin-hrnm-3-004"
case PIdol.姫崎莉波_ハッピーミルフィーユ: return "i_card-skin-hrnm-3-008"
case PIdol.姫崎莉波_初心: return "i_card-skin-hrnm-1-001"
case PIdol.姫崎莉波_学園生活: return "i_card-skin-hrnm-1-000"
case PIdol.月村手毬_Lunasaymaybe: return "i_card-skin-ttmr-3-000"
case PIdol.月村手毬_一匹狼: return "i_card-skin-ttmr-2-000"
case PIdol.月村手毬_Campusmode: return "i_card-skin-ttmr-3-007"
case PIdol.月村手毬_アイヴイ: return "i_card-skin-ttmr-3-001"
case PIdol.月村手毬_初声: return "i_card-skin-ttmr-1-001"
case PIdol.月村手毬_学園生活: return "i_card-skin-ttmr-1-000"
case PIdol.月村手毬_仮装狂騒曲: return "i_card-skin-ttmr-3-002"
case PIdol.有村麻央_Fluorite: return "i_card-skin-amao-3-000"
case PIdol.有村麻央_はじまりはカッコよく: return "i_card-skin-amao-2-000"
case PIdol.有村麻央_Campusmode: return "i_card-skin-amao-3-007"
case PIdol.有村麻央_FeelJewelDream: return "i_card-skin-amao-3-002"
case PIdol.有村麻央_キミとセミブルー: return "i_card-skin-amao-3-001"
case PIdol.有村麻央_初恋: return "i_card-skin-amao-1-001"
case PIdol.有村麻央_学園生活: return "i_card-skin-amao-1-000"
case PIdol.篠泽广_コントラスト: return "i_card-skin-shro-3-001"
case PIdol.篠泽广_一番向いていないこと: return "i_card-skin-shro-2-000"
case PIdol.篠泽广_光景: return "i_card-skin-shro-3-000"
case PIdol.篠泽广_Campusmode: return "i_card-skin-shro-3-007"
case PIdol.篠泽广_仮装狂騒曲: return "i_card-skin-shro-3-002"
case PIdol.篠泽广_ハッピーミルフィーユ: return "i_card-skin-shro-3-008"
case PIdol.篠泽广_初恋: return "i_card-skin-shro-1-001"
case PIdol.篠泽广_学園生活: return "i_card-skin-shro-1-000"
case PIdol.紫云清夏_TameLieOneStep: return "i_card-skin-ssmk-3-000"
case PIdol.紫云清夏_カクシタワタシ: return "i_card-skin-ssmk-3-002"
case PIdol.紫云清夏_夢へのリスタート: return "i_card-skin-ssmk-2-000"
case PIdol.紫云清夏_Campusmode: return "i_card-skin-ssmk-3-007"
case PIdol.紫云清夏_キミとセミブルー: return "i_card-skin-ssmk-3-001"
case PIdol.紫云清夏_初恋: return "i_card-skin-ssmk-1-001"
case PIdol.紫云清夏_学園生活: return "i_card-skin-ssmk-1-000"
case PIdol.花海佑芽_WhiteNightWhiteWish: return "i_card-skin-hume-3-005"
case PIdol.花海佑芽_学園生活: return "i_card-skin-hume-1-000"
case PIdol.花海佑芽_Campusmode: return "i_card-skin-hume-3-006"
case PIdol.花海佑芽_TheRollingRiceball: return "i_card-skin-hume-3-000"
case PIdol.花海佑芽_アイドル_はじめっ: return "i_card-skin-hume-2-000"
case PIdol.花海咲季_BoomBoomPow: return "i_card-skin-hski-3-001"
case PIdol.花海咲季_Campusmode: return "i_card-skin-hski-3-008"
case PIdol.花海咲季_FightingMyWay: return "i_card-skin-hski-3-000"
case PIdol.花海咲季_わたしが一番: return "i_card-skin-hski-2-000"
case PIdol.花海咲季_冠菊: return "i_card-skin-hski-3-002"
case PIdol.花海咲季_初声: return "i_card-skin-hski-1-001"
case PIdol.花海咲季_古今東西ちょちょいのちょい: return "i_card-skin-hski-3-006"
case PIdol.花海咲季_学園生活: return "i_card-skin-hski-1-000"
case PIdol.葛城リーリヤ_一つ踏み出した先に: return "i_card-skin-kllj-2-000"
case PIdol.葛城リーリヤ_白線: return "i_card-skin-kllj-3-000"
case PIdol.葛城リーリヤ_Campusmode: return "i_card-skin-kllj-3-006"
case PIdol.葛城リーリヤ_WhiteNightWhiteWish: return "i_card-skin-kllj-3-005"
case PIdol.葛城リーリヤ_冠菊: return "i_card-skin-kllj-3-001"
case PIdol.葛城リーリヤ_初心: return "i_card-skin-kllj-1-001"
case PIdol.葛城リーリヤ_学園生活: return "i_card-skin-kllj-1-000"
case PIdol.藤田ことね_カワイイ_はじめました: return "i_card-skin-fktn-2-000"
case PIdol.藤田ことね_世界一可愛い私: return "i_card-skin-fktn-3-000"
case PIdol.藤田ことね_Campusmode: return "i_card-skin-fktn-3-007"
case PIdol.藤田ことね_YellowBigBang: return "i_card-skin-fktn-3-001"
case PIdol.藤田ことね_WhiteNightWhiteWish: return "i_card-skin-fktn-3-006"
case PIdol.藤田ことね_冠菊: return "i_card-skin-fktn-3-002"
case PIdol.藤田ことね_初声: return "i_card-skin-fktn-1-001"
case PIdol.藤田ことね_学園生活: return "i_card-skin-fktn-1-000"
case _:
nonlocal msg
if msg == '':
msg = '培育设置中的以下偶像升级失败。请尝试手动添加。\n'
msg += f'{idol} 未找到\n'
return None
old_idols = options['produce']['idols']
new_idols = list(filter(lambda x: x is not None, map(map_idol, old_idols)))
options['produce']['idols'] = new_idols
shutil.copy('config.json', 'config.v2.json')
return options, msg
def upgrade_v3_to_v4(options: dict[str, Any]) -> tuple[dict[str, Any], str]:
"""
v3 -> v4 变更
自动纠正错误游戏包名
"""
shutil.copy('config.json', 'config.v3.json')
if options['start_game']['game_package_name'] == 'com.bandinamcoent.idolmaster_gakuen':
options['start_game']['game_package_name'] = 'com.bandainamcoent.idolmaster_gakuen'
logger.info('Corrected game package name to com.bandainamcoent.idolmaster_gakuen')
return options, ''
def upgrade_v4_to_v5(user_config: dict[str, Any], options: dict[str, Any]) -> tuple[dict[str, Any], str]:
"""
v4 -> v5 变更
windows windows_remote 截图方式的 type 设置为 dmm
"""
shutil.copy('config.json', 'config.v4.json')
if user_config['backend']['screenshot_impl'] in ['windows', 'remote_windows']:
logger.info('Set backend type to dmm.')
user_config['backend']['type'] = 'dmm'
return options, ''
if __name__ == '__main__':
print(PurchaseConfig.model_fields['money_refresh_on'].description)
return str(resources.files('kotonebot.kaa.sprites') / path)

View File

@ -0,0 +1,62 @@
from .schema import (
BaseConfig,
PurchaseConfig,
ActivityFundsConfig,
PresentsConfig,
AssignmentConfig,
ContestConfig,
ProduceConfig,
MissionRewardConfig,
ClubRewardConfig,
UpgradeSupportCardConfig,
CapsuleToysConfig,
TraceConfig,
StartGameConfig,
EndGameConfig,
MiscConfig,
conf,
)
from .const import (
ConfigEnum,
Priority,
APShopItems,
DailyMoneyShopItems,
ProduceAction,
RecommendCardDetectionMode,
)
# 配置升级逻辑
from .upgrade import upgrade_config
from .migrations import MIGRATION_REGISTRY, LATEST_VERSION
__all__ = [
# schema 导出
"BaseConfig",
"PurchaseConfig",
"ActivityFundsConfig",
"PresentsConfig",
"AssignmentConfig",
"ContestConfig",
"ProduceConfig",
"MissionRewardConfig",
"ClubRewardConfig",
"UpgradeSupportCardConfig",
"CapsuleToysConfig",
"TraceConfig",
"StartGameConfig",
"EndGameConfig",
"MiscConfig",
"conf",
# const 导出
"ConfigEnum",
"Priority",
"APShopItems",
"DailyMoneyShopItems",
"ProduceAction",
"RecommendCardDetectionMode",
# upgrade 导出
"upgrade_config",
"migrations",
"MIGRATION_REGISTRY",
"LATEST_VERSION",
]

Some files were not shown because too many files have changed in this diff Show More