1
1
mirror of https://github.com/KenanZhu/AutoLibrary.git synced 2026-06-18 07:23:03 +08:00

Compare commits

...

65 Commits

Author SHA1 Message Date
github-actions[bot] f984217bda chore(release): v1.2.0 [auto release commit] 2026-03-21 10:55:01 +00:00
KenanZhu 4e7780fe70 docs(readme): 更新自述文件以包含最新功能变化 2026-03-21 18:49:48 +08:00
Kenan Zhu 7149cb2b7d feat(*): 远程签到、定时任务重复执行与浏览器驱动自动管理 (#6)
- 图书馆远程签到
- 定时任务优化
- 浏览器驱动自动管理
2026-03-21 18:34:05 +08:00
KenanZhu 2c90008fcd refactor(WebDriverManager, ALWebDriverDownloadDialog): 重命名驱动状态枚举并完善对话框状态感知 2026-03-21 17:22:25 +08:00
KenanZhu 5c393595d7 fix(ALWebDriverDownloadDialog): 重命名信号避免与 QThread 内置信号冲突并改进线程生命周期管理 2026-03-21 01:53:22 +08:00
KenanZhu 4924f4b031 fix(WebDriverDownloader): 优化下载速度计算逻辑并改用时间间隔触发回调
- 将回调触发条件由进度变化量改为固定时间间隔(0.5s), 避免突发数据导致速度虚高

- 修正 total_size == 0 为 total_size <= 0, 完善边界判断

- 重命名变量提升可读性(last_time/last_size -> last_callback_time/last_callback_size)
2026-03-21 01:52:20 +08:00
KenanZhu 62c1ecdb07 fix(LogManager): 修复 CallerInfoFormatter 中 lineno 类型转换异常 2026-03-21 00:55:17 +08:00
KenanZhu aef28b6d5e feat(ALConfigWidget): 集成浏览器驱动自动下载功能到配置界面 2026-03-21 00:55:09 +08:00
KenanZhu afa1d39051 feat(gui): 新增 ALStatusLabel 状态标签组件和浏览器驱动下载对话框 2026-03-21 00:55:02 +08:00
KenanZhu 84cff6acc3 feat(WebDriverManager): 支持下载取消操作并完善异常处理 2026-03-21 00:54:49 +08:00
KenanZhu e40c7f4f3e chore(*): 降低 ddddocr 版本以避免不必要的打包体积,同时回滚工作流 2026-03-20 20:57:24 +08:00
KenanZhu c8e202dc8c ci(workflows): 修复构建工作流中的模型文件复制问题 2026-03-20 20:09:30 +08:00
KenanZhu 9a3abc365c fix(requirement.txt): 添加缺失的依赖项 pyinstaller 2026-03-20 19:26:17 +08:00
KenanZhu 6b2bf4863e chore(*): 更新项目依赖,并由此修改 CI/CD 工作流配置
- 更新项目依赖
2026-03-20 19:21:56 +08:00
KenanZhu 95aa2bb518 feat(WebDriverManager): 新增浏览器管理类 WebDriverManager
- 新增浏览器管理类,支持下载和管理浏览器驱动
2026-03-20 19:20:43 +08:00
KenanZhu 571af554d2 chore(Main.py): 使用 exec() 替换 exec_() 方法
- chore(Main.py): 使用 exec() 替换 exec_() 方法
2026-03-20 19:20:01 +08:00
KenanZhu 706fc889f9 chore(*): 重构项目结构
- 新增 src/boot 目录,用于存放启动时需要初始化的模块
- 新增 src/managers 目录,用于存放项目中的管理模块
- 新增 src/managers/config 目录,用于存放配置管理模块
- 新增 src/managers/log 目录,用于存放日志管理模块
- 新增 src/managers/driver 目录,用于存放浏览器驱动管理模块
- 修改对应文件中 import 导入路径
2026-03-20 19:19:34 +08:00
KenanZhu bf93cc2cbc style(*): 将中文逗号替换为英文逗号 2026-03-20 08:59:09 +08:00
KenanZhu 1cfe261324 style(ALTimerTaskManageWidget): 优化详细信息的上下文语义
- 使用 “已记录次数” 替代 “已执行次数”,更符合实际含义
2026-03-19 12:23:36 +08:00
KenanZhu e5dea7bcc5 refactor(gui): 统一定时任务字段命名
- 将 task_uuid 字段重命名为 uuid,添加时间字段 add_time 重命名为 added_time
2026-03-19 12:22:32 +08:00
KenanZhu 30b36b68dd refactor(ALTimerTaskManageWidget): 修复重复任务历史记录逻辑
- 修复 onRepeatTimerTaskIs 方法中日期循环索引错误,使用 %7 正确处理跨周星期计算
- 新增 OUTDATED 状态的专属处理逻辑,补全过期任务的历史记录
- 添加函数返回值并统一枚举比较方式为 ==,提高代码一致性
2026-03-19 11:56:44 +08:00
KenanZhu 595f43d852 optimize(ALTimerTaskHistoryDialog): 优化任务历史对话框标题字体样式 2026-03-18 17:52:02 +08:00
KenanZhu 02463f087e feat(MsgBase, gui, operators): 增强日志输出功能
- 为 _showTrace 方法添加 no_log 参数,支持控制日志写入
- 在主窗口各关键操作点添加日志输出
- 优化错误信息输出策略,分离 trace 和 log 输出
- 改进配置目录初始化过程的日志记录
2026-03-18 12:46:37 +08:00
KenanZhu e481824344 refactor(AppInitializer): 初始化应用程序时,先初始化日志管理器,再初始化配置管理器 2026-03-18 11:03:44 +08:00
KenanZhu 160d6a2428 refactor(operators): 为 _showTrace 方法添加合适的 TraceLevel 参数 2026-03-18 11:02:52 +08:00
KenanZhu ec683cf154 feat(LogManager): 新增日志持久化功能 2026-03-18 10:21:53 +08:00
KenanZhu 2d0782c368 refactor(AppInitializer): 将初始化逻辑提取到 AppInitializer 模块中
- 本次提交将 Main.py 中的 ConfigManager, LogManager 等初始化逻辑提取到 AppInitializer 模块中
- 更改默认的配置文件路径从 config 目录变为 configs 目录,并考虑兼容性问题
2026-03-18 10:17:09 +08:00
KenanZhu 824b9b8869 fix(ALMainWindow): 修复 ALMainWindow 的配置路径同步问题
- 先前的实现并未考虑到配置窗口更改时的同步问题,本次提交在
  每次配置窗口更改并关闭保存时,同步更新 ALMainWindow 中的配置路径
2026-03-18 10:14:27 +08:00
KenanZhu c26f19b6b3 feat(LogManager): 新增日志持久化功能
- 新增 LogManager 单例类,支持日志文件按日期滚动
- 创建 CallerInfoFormatter 自定义格式化器,提取真实调用位置
- 为 MsgBase._showTrace 方法添加日志级别参数,集成日志系统
- 新增 initializeLogManager 初始化函数,日志存储于 AppDataLocation/logs/
- 日志输出格式对齐:[时间] - [类名(15)|级别(8)] - [文件:行号(20:4)] - 消息
- 控制台/INFO级别,全量日志 / DEBUG 级别,错误日志 / ERROR级别
- 全量日志保留7天,错误日志保留14天
2026-03-17 21:37:24 +08:00
KenanZhu 1d99ca92f2 fix(LibReserve): 修复日期比较逻辑错误并优化时间处理代码
- 修复使用字符串直接比较日期导致的逻辑错误,改用时间戳比较
- 优化时间验证逻辑,支持 satisfy_duration 模式下的开始晚于结束时间时的交换时间处理
- 添加必要的注释说明 place 参数检查的跳过原因和边界情况处理
- 重构变量命名,提高代码可读性(cur_date -> cur_date_str)
- 修正字符串引号风格,统一使用单引号
2026-03-17 20:46:00 +08:00
KenanZhu 50ebeb0fab style(LibReserve): 修复 __selectSeatTime 参数的拼写错误
- expct_duration -> expect_duration
2026-03-17 20:43:00 +08:00
KenanZhu faa26b489a fix(LibReserve): 修复冗余的链式赋值 2026-03-17 20:42:42 +08:00
KenanZhu c03eed1d51 fix(LibReserve): 修复错误使用的海象运算符条件判断 2026-03-17 20:42:31 +08:00
KenanZhu 2f5680c547 fix(LibTimeSelector) style(LibReserve): 修复时间转换方法 _timeToMins 并重命名为 _timeStrToMins
- 之前的实现未严格限制传入参数为整形,导致在转换时间字符串时可能出现类型错误。
- 重命名为 _timeStrToMins 以明确表示该方法仅用于时间字符串转换。并更新相关调用。

- 重命名 __selectSeatTime 中的冗长局部变量,便于理解和维护。
- 删除多余的时间格式转换嗲用
2026-03-17 20:00:57 +08:00
KenanZhu 1cd39ec84c docs(readme): 更新 “后续会有哪些功能?” 部分,可重复性定时任务功能已完成 2026-03-17 16:08:54 +08:00
Kenan Zhu 73aab7b957 feat(LibCheckin, gui.*): 支持校园网环境下图书馆远程签到;定时任务管理支持可重复性定时任务 (#5)
- feat(LibCheckin): 支持校园网环境下图书馆远程签到 
- feat(TimerUtils): 新增重复任务时间计算工具 
- feat(TimerTask): 新增任务执行历史对话框 
- feat(TimerTaskAddDialog): 添加重复任务 UI 支持 
- feat(TimerTaskManageWidget): 实现重复任务执行与历史记录 
- fix(ALTimerTaskAddDialog): 修改添加定时任务对话框的重复选项的 Label 描述和布局
- fix(ALTimerTaskAddDialog): 修改定时任务时间类型中相对时间控件的布局样式 
- fix(ALTimerTaskAddDialog): 删除定时任务数据中多余的字段 ‘repeat_records’
- fix(ALTimerTaskAddDialog): 修改添加定时任务对话框的重复选项的 Label 描述和布局 
- style(ALTimerTaskManageWidget): 统一 import 语句的格式 
- refactor(ALTimerTaskItemWidget): 一些变量重构
- optimze(gui): 优化删除按钮样式,使其更加醒目;优化 ALTimerTaskManageWidget 的宽度 
- optimize(gui): 优化定时任务管理功能 
- fix(ALTimerTaskManageWidget): 修复删除任务的信号槽参数传递问题
2026-03-17 16:04:59 +08:00
KenanZhu 0a94c344d5 ci(workflows): 修复 Release 工作流的触发条件
- 当创建 'release/v*' 分支时,自动进行 Release 构建

/! Release 流程必须手动创建分支,工作流结束后会将对应分支提交合并
/! 到 main 分支上,且对应分支会被删除
2026-03-17 15:46:32 +08:00
KenanZhu 68e002ba8e fix(ALTimerTaskManageWidget): 修复删除任务的信号槽参数传递问题
- 修复删除任务的信号槽参数传递问题,此次修复通过 lambda 表达式将当前的 task 作为参数传递,避免了闭包陷阱。
2026-03-17 15:27:03 +08:00
KenanZhu 94dc22819f optimize(gui): 优化定时任务管理功能
- 优化任务历史查看对话框的界面布局和交互体验
- 新增任务状态枚举值以支持更完整的状态管理
- 统一重复任务执行后的历史记录处理逻辑
- 增强删除任务时的确认机制,删除可重复任务前展示详细执行记录
- 完善批量清除任务的验证流程,检查运行中任务并确认重复任务删除
2026-03-17 14:51:55 +08:00
KenanZhu d55d2075cb optimze(gui): 优化删除按钮样式,使其更加醒目;优化 ALTimerTaskManageWidget 的宽度
- 优化了 ALConfigWidget, ALTimerTaskManageWidget 中的删除按钮样式(字体颜色更改为红色),使其更加醒目
- 优化了 ALTimerTaskManageWidget 的宽度,使其适应内容宽度
2026-03-17 14:46:19 +08:00
KenanZhu 82744e3a2d refactor(ALTimerTaskItemWidget): 一些变量重构 2026-03-17 14:42:47 +08:00
KenanZhu 67493349dd style(ALTimerTaskManageWidget): 统一 import 语句的格式
- 对 gui.ALTimerTaskAddDialog 的 import 语句进行格式化
2026-03-17 14:42:07 +08:00
KenanZhu 0aea9b1540 fix(ALTimerTaskAddDialog): 修改添加定时任务对话框的重复选项的 Label 描述和布局
- 对 (b73242be00) 的补充提交
2026-03-17 14:39:01 +08:00
KenanZhu c02c6ddbe3 fix(ALTimerTaskAddDialog): 删除定时任务数据中多余的字段 ‘repeat_records’ 2026-03-17 14:37:33 +08:00
KenanZhu c679a1c79e fix(ALTimerTaskAddDialog): 修改定时任务时间类型中相对时间控件的布局样式
- 由栅格布局改为水平布局,该区域的高度与绝对时间控件的高度一致
2026-03-17 14:35:47 +08:00
KenanZhu b73242be00 fix(ALTimerTaskAddDialog): 修改添加定时任务对话框的重复选项的 Label 描述和布局 2026-03-17 14:33:37 +08:00
KenanZhu 9accf5ddc1 ci(workflows): 添加 push 触发器 2026-03-16 21:20:54 +08:00
KenanZhu 883859d1f9 feat(TimerTaskManageWidget): 实现重复任务执行与历史记录
- onTimerTaskIsExecuted/onTimerTaskIsError 添加历史记录
- 历史记录包含:execute_time、executed_time、result、duration
- 重复任务执行后自动计算并更新下次执行时间
2026-03-16 21:17:48 +08:00
KenanZhu f37bcf836b feat(TimerTaskAddDialog): 添加重复任务 UI 支持
- UI 添加重复配置控件:复选框、周一到周日复选框
- 新增 onRepeatCheckBoxToggled 槽函数控制日期选择显示
- getTimerTask 支持提取重复配置(日期、时分秒)
- 调用 TimerUtils 计算首次执行时间
- 重构导入语句格式
2026-03-16 21:16:46 +08:00
KenanZhu b0d1c0e99e feat(TimerTask): 新增任务执行历史对话框
- 新增 ALTimerTaskHistoryDialog 显示重复任务执行历史
- 支持查看执行时间、运行结果、运行耗时
- 提供清空历史记录功能
- 表格显示:执行时间、结果、耗时(秒/s)、uuid
2026-03-16 21:15:56 +08:00
KenanZhu 5af6120be8 feat(TimerUtils): 新增重复任务时间计算工具
- 新增 TimerUtils.calculateNextRepeatTime 方法
- 支持基于重复日期和目标时间计算下次执行时间
- 如果当天在重复日期且目标时间未过,则返回今天;否则查找下一个匹配日期
2026-03-16 21:15:15 +08:00
KenanZhu 60e055f6bb docs(readme): 添加 build-test 状态图标 2026-03-16 17:08:47 +08:00
KenanZhu 01e8100774 feat(LibCheckin): 支持校园网环境下图书馆远程签到
- 新增 __enableCheckinBtn 方法,通过 JavaScript 移除签到按钮的 disabled 属性
- 在检测到签到按钮不可用时,自动尝试启用按钮而非直接失败
- 支持在校园网环境下无需连接图书馆网络即可完成签到
- 优化签到流程的用户提示信息"
2026-03-16 16:55:52 +08:00
Kenan Zhu cf8493565e ci(workflows): 修改一些字符格式 (#2) 2026-03-16 16:40:13 +08:00
KenanZhu 24bb76d039 ci(workflows): 修改一些字符格式 2026-03-16 16:26:34 +08:00
KenanZhu 7111411115 ci(workflows): 优化 CI/CD 工作流配置
- 新增 build-test.yml 用于PR测试构建
- 升级 actions/checkout 和 actions/upload-artifact 到 v6 版本
- 完善 release.yml 的清理流程和摘要输出
2026-03-16 15:59:39 +08:00
KenanZhu 7df6a9157d refactor(LibReserve, LibRenew): 提取时间选择公共逻辑到 LibTimeSelector 基类
将 LibReserve 和 LibRenew 中重复的时间转换和选择逻辑提取到
LibTimeSelector 基类,消除代码重复,提升可维护性。

主要变更:
- 新增 LibTimeSelector 基类,提供时间转换和最佳时间选择算法
- LibReserve 和 LibRenew 继承 LibTimeSelector,移除重复代码
- 拆分过长方法,提升代码可读性
- 修正方法命名 __selectNearstTime -> __selectNearestTime

同时修复续约功能业务逻辑漏洞:
- 新增续约时间上限校验,防止续约时间超过图书馆闭馆时间(23:30)
2026-03-14 14:48:35 +08:00
KenanZhu ebe3910df5 fix(AutoLib): 修复自动预约,签到和续约功能的顺序处理逻辑问题
边缘情况下,即用户当前的预约时间满足签到或者续约的时间范围要求时,预期的处理顺序是先进行预约,再进行签到或者续约。
该提交修复了对这种情况的处理逻辑,确保先进行预约,再进行签到或者续约。
2026-03-10 11:00:01 +08:00
KenanZhu 84367e4abe chore(*): 更新网站地址为 www.autolibrary.kenanzhu.com 2026-03-10 10:59:13 +08:00
KenanZhu 3a50991860 fix(ALMainWindow): 修复程序最小化到托盘图标后,退出菜单异常处理问题 2026-03-10 10:58:27 +08:00
KenanZhu e4482b01da fix(ALMainWindow): 修复托盘图标初始化问题
1. 修复托盘图标初始化上下文菜单的重复调用问题
2. 修复托盘图标初始化忘记更改消息方法的问题
2026-03-05 07:55:36 +08:00
KenanZhu c06e0e05da fix(ALMainWindow): 修复定时任务的消息通知图标与运行状态不一致的问题 2026-03-05 07:54:18 +08:00
KenanZhu ff083884b6 style(utils.ConfigManager): 添加一些注释,并为 getBaseConfigDir 添加文档字符串。 2026-03-04 23:53:31 +08:00
KenanZhu 9ae89b61a4 chore(utils.ConfigManager): 将 ConfigManager 类的 appDir 重命名为 configDir 2026-03-04 23:52:28 +08:00
KenanZhu 2152cc46a3 style(*): 修改 ConfigManager 模块的 import 方式,并移除未使用的 import 语句 2026-03-04 23:52:01 +08:00
48 changed files with 4321 additions and 1296 deletions
+221
View File
@@ -0,0 +1,221 @@
name: Build Test
# This workflow builds the application for testing purposes.
# It is triggered when a pull request is opened, synchronized, or reopened against the main branch.
on:
push:
branches:
- main
pull_request:
branches:
- main
types:
- opened
- synchronize
- reopened
# Allow manual trigger for testing
workflow_dispatch:
#
# Build Windows
#
jobs:
build-windows:
runs-on: windows-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ github.ref }}
- name: Get version info
id: get_version
run: |
$version = "pr-test"
$tagName = "pr-test"
Write-Host "✓ Mode: Pull Request Test Build"
Write-Host "✓ Tag: $tagName"
Write-Host "✓ Version: $version"
"VERSION=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
"TAG_NAME=$tagName" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
shell: pwsh
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.13'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirement.txt
- name: Solve ddddocr compatibility and copy model files
run: |
$ddddocrPath = python -c "import ddddocr, os; print(os.path.dirname(ddddocr.__file__))"
Write-Host "ddddocr package location: $ddddocrPath"
$initFile = Join-Path $ddddocrPath "__init__.py"
if (Test-Path $initFile) {
Write-Host "Fixing ddddocr compatibility in: $initFile"
(Get-Content $initFile) -replace 'Image\.ANTIALIAS', 'Image.Resampling.LANCZOS' | Set-Content $initFile
Write-Host "✓ Fixed: Image.ANTIALIAS -> Image.Resampling.LANCZOS"
} else {
Write-Error "✗ ddddocr __init__.py not found"
exit 1
}
if (-not (Test-Path "models")) {
New-Item -ItemType Directory -Path "models" | Out-Null
Write-Host "✓ Created models directory"
}
$onnxSource = Join-Path $ddddocrPath "common.onnx"
$onnxDest = "models/common.onnx"
if (Test-Path $onnxSource) {
Copy-Item $onnxSource $onnxDest -Force
Write-Host "✓ Copied ONNX model from: $onnxSource"
Write-Host "✓ ONNX model copied to: $onnxDest"
} else {
Write-Error "✗ ONNX model not found in ddddocr package: $onnxSource"
exit 1
}
if (Test-Path $onnxDest) {
$fileSize = (Get-Item $onnxDest).Length / 1MB
Write-Host "✓ Model file verified: $onnxDest (Size: $([math]::Round($fileSize, 2)) MB)"
} else {
Write-Error "✗ Failed to copy model file"
exit 1
}
shell: pwsh
- name: Compile Qt Resource files
run: |
cd batchs
./compile_rc.bat
shell: cmd
- name: Compile Qt UI files
run: |
cd batchs
./compile_ui.bat
shell: cmd
- name: Generate 'Main.spec'
run: |
$version = "${{ steps.get_version.outputs.VERSION }}"
$exeName = "AutoLibrary-$version"
Write-Host "Generating Main.spec for version: $version"
Write-Host "Executable name: $exeName"
$specLines = @(
"# -*- mode: python ; coding: utf-8 -*-"
""
""
"a = Analysis("
" ['src\\Main.py'],"
" pathex=[],"
" binaries=[],"
" datas=["
" ('models\\common.onnx', 'ddddocr'),"
" ('src\\gui\\resources\\icons\\AutoLibrary_32x32.ico', 'gui\\resources\\icons'),"
" ],"
" hiddenimports=[],"
" hookspath=[],"
" hooksconfig={},"
" runtime_hooks=[],"
" excludes=[],"
" noarchive=False,"
" optimize=0,"
")"
"pyz = PYZ(a.pure)"
""
"exe = EXE("
" pyz,"
" a.scripts,"
" name='AutoLibrary',"
" debug=False,"
" bootloader_ignore_signals=False,"
" strip=False,"
" upx=True,"
" upx_exclude=[],"
" runtime_tmpdir=None,"
" console=False,"
" disable_windowed_traceback=False,"
" argv_emulation=False,"
" target_arch=None,"
" codesign_identity=None,"
" entitlements_file=None,"
" icon=['src\\gui\\resources\\icons\\AutoLibrary_32x32.ico'],"
")"
""
"coll = COLLECT("
" exe,"
" a.binaries,"
" a.datas,"
" strip=False,"
" upx=True,"
" upx_exclude=[],"
" name='$exeName'"
")"
)
$specLines | Out-File -FilePath "Main.spec" -Encoding UTF8
Write-Host "✓ Main.spec (non-single file) generated successfully"
Write-Host "`nGenerated Main.spec ============"
Get-Content "Main.spec" | Write-Host
Write-Host "==================================`n"
shell: pwsh
- name: Build with PyInstaller
run: |
pyinstaller Main.spec
- name: Zip windows release
id: zip_release
run: |
$tagName = "${{ steps.get_version.outputs.TAG_NAME }}"
$version = "${{ steps.get_version.outputs.VERSION }}"
$distDir = "dist/AutoLibrary-$version"
$zipName = "AutoLibrary.$tagName-windows-x86_64.zip"
echo "ZIP_NAME=$zipName" >> $env:GITHUB_OUTPUT
Write-Host "Looking for distribution directory: $distDir"
if (Test-Path $distDir) {
Compress-Archive -Path "$distDir/*" -DestinationPath $zipName
Write-Host "✓ Created release archive (directory mode): $zipName"
} else {
Write-Error "✗ Distribution directory not found: $distDir"
Write-Host "Files in dist directory:"
Get-ChildItem "dist" | ForEach-Object { Write-Host " - $($_.Name)" }
exit 1
}
shell: pwsh
- name: Archive artifacts
uses: actions/upload-artifact@v6
with:
name: AutoLibrary.${{ steps.get_version.outputs.TAG_NAME }}-windows-x86_64
path: |
${{ steps.zip_release.outputs.ZIP_NAME }}
retention-days: 7
- name: Upload build summary
run: |
Write-Host "## Build Test Summary" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8
Write-Host "" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "✓ Pull request build test completed successfully!" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Version: ${{ steps.get_version.outputs.VERSION }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Tag: ${{ steps.get_version.outputs.TAG_NAME }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Pull Request #${{ github.event.pull_request.number }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Branch: ${{ github.event.pull_request.head.ref }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Event: ${{ github.event_name }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
shell: pwsh
+48 -23
View File
@@ -8,21 +8,19 @@ name: Build
on:
workflow_call:
inputs:
version:
description: 'Version number'
required: true
type: string
tag_name:
description: 'Tag name'
required: true
required: false
type: string
outputs:
version:
description: 'The version number'
value: ${{ jobs.build-windows.outputs.version }}
tag_name:
description: 'The tag name'
value: ${{ jobs.build-windows.outputs.tag_name }}
description: 'Version number'
required: false
type: string
is_test:
description: 'Whether this is a test build (not a release)'
required: false
type: string
default: 'true'
#
# Build Windows
@@ -32,18 +30,18 @@ jobs:
build-windows:
runs-on: windows-latest
outputs:
version: ${{ steps.get_version.outputs.VERSION }}
tag_name: ${{ steps.get_version.outputs.TAG_NAME }}
version: ${{ steps.get_version.outputs.VERSION }}
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
ref: main
ref: ${{ github.ref }}
# here we download the build version of ALVersionInfo.py from artifacts
# and replace the committed version
- name: Download build version of ALVersionInfo.py
uses: actions/download-artifact@v4
uses: actions/download-artifact@v6
with:
name: updated-version-info-for-build
path: src/gui/
@@ -51,13 +49,27 @@ jobs:
- name: Get version info
id: get_version
run: |
$version = "${{ inputs.version }}"
$tagName = "${{ inputs.tag_name }}"
$isTest = "${{ inputs.is_test }}"
if ($isTest -eq "true") {
$version = "test"
$tagName = "test"
Write-Host "✓ Mode: Test Build"
} else {
$version = "${{ inputs.version }}"
$tagName = "${{ inputs.tag_name }}"
if ([string]::IsNullOrEmpty($version)) {
$version = "test"
$tagName = "test"
Write-Host "✓ Mode: Independent Build (No inputs provided)"
}
}
echo "TAG_NAME=$tagName" >> $env:GITHUB_OUTPUT
echo "VERSION=$version" >> $env:GITHUB_OUTPUT
Write-Host "✓ Tag: $tagName"
Write-Host "✓ Version: $version"
"VERSION=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
"TAG_NAME=$tagName" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
shell: pwsh
- name: Verify 'ALVersionInfo.py' was updated
@@ -70,7 +82,7 @@ jobs:
shell: pwsh
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v6
with:
python-version: '3.13'
@@ -210,7 +222,7 @@ jobs:
$distDir = "dist/AutoLibrary-$version"
$zipName = "AutoLibrary.$tagName-windows-x86_64.zip"
echo "ZIP_PATH=$zipName" >> $env:GITHUB_OUTPUT
echo "ZIP_NAME=$zipName" >> $env:GITHUB_OUTPUT
Write-Host "Looking for distribution directory: $distDir"
if (Test-Path $distDir) {
@@ -225,8 +237,21 @@ jobs:
shell: pwsh
- name: Archive artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: AutoLibrary.${{ steps.get_version.outputs.TAG_NAME }}-windows-x86_64
path: |
${{ steps.zip_release.outputs.ZIP_PATH }}
${{ steps.zip_release.outputs.ZIP_NAME }}
retention-days: ${{ github.event_name != 'workflow_call' && 7 || 90 }}
- name: Upload build summary
if: ${{ github.event_name != 'workflow_call' }}
run: |
Write-Host "## Build Summary" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8
Write-Host "" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "✓ Build test completed successfully!" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Version: ${{ steps.get_version.outputs.VERSION }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Tag: ${{ steps.get_version.outputs.TAG_NAME }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Event: ${{ github.event_name }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Ref: ${{ github.ref }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
shell: pwsh
+57 -13
View File
@@ -1,7 +1,7 @@
name: Commit Release
# This workflow commits version changes in 'ALVersionInfo.py' (get from artifacts) and
# moves the release tag to this new release commit.
# creates/moves the release tag to this new release commit.
#
# It is triggered when called by the release workflow.
@@ -9,7 +9,7 @@ on:
workflow_call:
inputs:
tag_name:
description: 'Tag name to move (e.g., v1.0.0)'
description: 'Tag name to create/move (e.g., v1.0.0 or v1.0.0-rc1)'
required: true
type: string
version:
@@ -20,10 +20,33 @@ on:
description: 'File path to commit'
required: true
type: string
create_tag:
description: 'Whether to create new tag (true) or move existing tag (false)'
required: false
type: string
default: 'false'
is_rc:
description: 'Whether this is a release candidate (pre-release)'
required: false
type: string
default: 'false'
ref:
description: 'Git ref to checkout (release branch)'
required: true
type: string
outputs:
tag_name:
description: 'The tag name created/moved'
value: ${{ inputs.tag_name }}
version:
description: 'Version number for commit message'
value: ${{ inputs.version }}
new_commit_sha:
description: 'The new commit SHA after moving the tag'
description: 'The new commit SHA after creating/moving the tag'
value: ${{ jobs.commit-release.outputs.new_commit_sha }}
branch_name:
description: 'The branch name where the commit was made'
value: ${{ jobs.commit-release.outputs.branch_name }}
jobs:
commit-release:
@@ -32,18 +55,19 @@ jobs:
contents: write
outputs:
new_commit_sha: ${{ steps.commit_info.outputs.commit_sha }}
branch_name: ${{ steps.push_release.outputs.branch_name }}
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
ref: main
ref: ${{ inputs.ref }}
fetch-depth: 0
# here we download the commit version of ALVersionInfo.py from artifacts
# and replace the original file with it.
- name: Download commit version of ALVersionInfo.py
uses: actions/download-artifact@v4
uses: actions/download-artifact@v6
with:
name: updated-version-info-for-commit
path: downloaded-file/
@@ -81,18 +105,38 @@ jobs:
git commit -m "chore(release): v${VERSION} [auto release commit]"
echo "✓ Changes committed"
- name: Push to main branch
- name: Push to release branch
id: push_release
run: |
MAIN_BRANCH=$(git remote show origin | grep 'HEAD branch' | cut -d' ' -f5)
if [ -z "$MAIN_BRANCH" ]; then
MAIN_BRANCH="main"
# Get the release branch name from the input ref
BRANCH_NAME=$(echo "${{ inputs.ref }}" | sed 's|refs/heads/||')
if [ -z "$BRANCH_NAME" ]; then
echo "✗ Error: Could not determine branch name from ref: ${{ inputs.ref }}"
exit 1
fi
echo "Pushing to branch: ${MAIN_BRANCH}"
git push origin HEAD:${MAIN_BRANCH}
echo "✓ Changes pushed to ${MAIN_BRANCH}"
echo "Pushing to branch: ${BRANCH_NAME}"
git push origin HEAD:${BRANCH_NAME}
echo "✓ Changes pushed to ${BRANCH_NAME}"
# Output branch name for downstream jobs
echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT
- name: Create tag for release
if: ${{ inputs.create_tag == 'true' }}
run: |
TAG_NAME="${{ inputs.tag_name }}"
IS_RC="${{ inputs.is_rc }}"
echo "Creating new tag ${TAG_NAME} at this commit..."
echo "Release type: $([ "$IS_RC" = "true" ] && echo "Release Candidate (Pre-release)" || echo "Stable Release")"
git tag ${TAG_NAME}
git push origin ${TAG_NAME}
echo "✓ Tag ${TAG_NAME} created at commit $(git rev-parse --short HEAD)"
- name: Move tag to new release commit
if: ${{ inputs.create_tag != 'true' }}
run: |
TAG_NAME="${{ inputs.tag_name }}"
+199 -21
View File
@@ -1,36 +1,48 @@
name: Release
# This workflow automates the complete release process for AutoLibrary application
# It is triggered when a new version tag (vX.Y.Z) is pushed to the repository
# It is triggered when a new release branch is created (release/vX.Y.Z or release/vX.Y.Z-rc*)
#
# Workflow Steps:
# START >
# 1. Update Version:
# 1. Extract Version:
# Extracts version number from branch name:
# - release/v1.0.0 -> v1.0.0 (stable release)
# - release/v1.0.0-rc1 -> v1.0.0 (release candidate)
# 2. Update Version:
# Updates version information in 'ALVersionInfo.py' with build metadata and archives
# the updated version file as an artifact.
#
# for more information, please refer to the comment in the workflow 'update-version.yml'
# 2. Commit Release:
# Commits version changes and moves the release tag to this new release commit.
# 3. Commit Release:
# Commits version changes to release branch and creates the release tag.
# 3. Build:
# 4. Build:
# Compiles the application for Windows platform using PyInstaller, and
# archives the built artifacts as 'AutoLibrary.<tag_name>-windows-x86_64.zip'.
# 4. Release:
# 5. Release:
# Creates GitHub release with generated artifacts and release notes
# < END
#
# 6. Merge back:
# Merges release branch back to main branch, and clean/delete the release branch
#
# The workflow ensures version consistency between source code, built artifacts, and GitHub releases
# while maintaining proper commit history and tag management.
#
# IMPORTANT: This workflow only triggers on branch CREATION, not on pushes to release branches.
# If you need to fix issues on a release branch, delete the tag, merge fixes to main,
# and create a new release branch.
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
branches:
- 'release/v*'
jobs:
#
@@ -44,6 +56,62 @@ jobs:
- name: Start release
run: |
echo "✓ Starting release"
echo "Branch: ${{ github.ref_name }}"
echo "Ref: ${{ github.ref }}"
#
# Extract version :
# this job extracts version from branch name
#
extract-version:
needs:
- start
runs-on: ubuntu-latest
outputs:
tag_name: ${{ steps.extract.outputs.tag_name }}
version: ${{ steps.extract.outputs.version }}
is_rc: ${{ steps.extract.outputs.is_rc }}
steps:
- name: Extract version from branch name
id: extract
run: |
BRANCH_NAME="${{ github.ref_name }}"
# Validate branch name starts with 'release/v'
if ! echo "$BRANCH_NAME" | grep -qE '^release/v'; then
echo "✗ Error: Branch '$BRANCH_NAME' does not start with 'release/v'"
echo "✗ This workflow should only be triggered by release branches"
exit 1
fi
# Extract version from branch name:
# - release/v1.0.0 -> v1.0.0 (stable)
# - release/v1.0.0-rc1 -> v1.0.0 (release candidate)
# - release/v1.0.0-alpha.1 -> v1.0.0-alpha.1 (pre-release)
if echo "$BRANCH_NAME" | grep -qE '^release/v[0-9]+\.[0-9]+\.[0-9]+$'; then
# Stable release: release/v1.0.0 -> v1.0.0
TAG_NAME=$(echo "$BRANCH_NAME" | sed -E 's|^release/(v[0-9]+\.[0-9]+\.[0-9]+)$|\1|')
IS_RC=false
elif echo "$BRANCH_NAME" | grep -qE '^release/v[0-9]+\.[0-9]+\.[0-9]+-'; then
# Pre-release: release/v1.0.0-rc1 -> v1.0.0-rc1
TAG_NAME=$(echo "$BRANCH_NAME" | sed -E 's|^release/(v[0-9]+\.[0-9]+\.[0-9]+-.*)$|\1|')
IS_RC=true
else
echo "✗ Error: Branch '$BRANCH_NAME' does not match expected format"
echo "✗ Expected format: release/vX.Y.Z or release/vX.Y.Z-rcX"
exit 1
fi
VERSION="${TAG_NAME#v}"
echo "TAG_NAME=$TAG_NAME" >> $GITHUB_OUTPUT
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "IS_RC=$IS_RC" >> $GITHUB_OUTPUT
echo "✓ Branch: $BRANCH_NAME"
echo "✓ Tag: $TAG_NAME"
echo "✓ Version: $VERSION"
echo "✓ Is RC: $IS_RC"
#
# Update version :
@@ -52,31 +120,35 @@ jobs:
update-version:
needs:
- start
- extract-version
uses: ./.github/workflows/update-version.yml
permissions:
contents: write
with:
tag_name: ${{ github.ref_name }}
tag_name: ${{ needs.extract-version.outputs.tag_name }}
ref: ${{ github.ref }}
#
# Commit release :
# this job commits the updated version file and move the release
# tag to this new commit
# this job commits the updated version file to main and creates
# the release tag (not moving an existing tag)
#
commit-release:
needs:
- extract-version
- update-version
if: ${{ needs.update-version.outputs.has_changes == 'true' }}
uses: ./.github/workflows/commit-release.yml
permissions:
contents: write
with:
tag_name: ${{ needs.update-version.outputs.tag_name }}
version: ${{ needs.update-version.outputs.version }}
tag_name: ${{ needs.extract-version.outputs.tag_name }}
version: ${{ needs.extract-version.outputs.version }}
file_path: src/gui/ALVersionInfo.py
create_tag: 'true'
is_rc: ${{ needs.extract-version.outputs.is_rc }}
ref: ${{ github.ref }}
#
# Build :
@@ -91,8 +163,9 @@ jobs:
permissions:
contents: write
with:
version: ${{ needs.update-version.outputs.version }}
tag_name: ${{ needs.update-version.outputs.tag_name }}
version: ${{ needs.update-version.outputs.version }}
is_test: 'false'
#
# Release :
@@ -102,27 +175,28 @@ jobs:
runs-on: ubuntu-latest
needs:
- build
- extract-version
if: always() && needs.build.result == 'success'
permissions:
contents: write
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v6
with:
name: AutoLibrary.${{ needs.build.outputs.tag_name }}-windows-x86_64
name: AutoLibrary.${{ needs.extract-version.outputs.tag_name }}-windows-x86_64
path: artifacts/
- name: Create release
id: create_release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.build.outputs.tag_name }}
name: AutoLibrary ${{ needs.build.outputs.tag_name }}
tag_name: ${{ needs.extract-version.outputs.tag_name }}
name: AutoLibrary ${{ needs.extract-version.outputs.tag_name }}
files: |
artifacts/AutoLibrary.${{ needs.build.outputs.tag_name }}-windows-x86_64.zip
artifacts/AutoLibrary.${{ needs.extract-version.outputs.tag_name }}-windows-x86_64.zip
draft: false
prerelease: false
prerelease: ${{ needs.extract-version.outputs.is_rc == 'true' }}
generate_release_notes: true
body: |
---
@@ -142,3 +216,107 @@ jobs:
- name: End release
run: |
echo "✓ Ending release"
#
# Merge Back :
# this job merges the release branch to main after successful release
#
merge-back:
needs:
- release
- extract-version
- commit-release
if: ${{ needs.release.result == 'success' && needs.commit-release.result == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Merge release branch to main
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Use the release branch name from the original trigger
BRANCH_NAME="${{ needs.extract-version.outputs.tag_name }}"
# Extract branch name: v1.0.0 -> release/v1.0.0
if [[ ! "$BRANCH_NAME" =~ ^release/ ]]; then
BRANCH_NAME="release/${BRANCH_NAME}"
fi
MAIN_BRANCH=$(git remote show origin | grep 'HEAD branch' | cut -d' ' -f5)
if [ -z "$MAIN_BRANCH" ]; then
MAIN_BRANCH="main"
fi
echo "Merging ${BRANCH_NAME} to ${MAIN_BRANCH}..."
echo "Current commit info:"
git log --oneline -3
# Fetch all branches including the release branch
git fetch origin ${BRANCH_NAME}
git fetch origin ${MAIN_BRANCH}
# Checkout main branch
git checkout ${MAIN_BRANCH}
# Show branch status before merge
echo "=== Branch status before merge ==="
git log --oneline --graph --all -5
echo "=== Diff between ${MAIN_BRANCH} and origin/${BRANCH_NAME} ==="
git diff ${MAIN_BRANCH} origin/${BRANCH_NAME} --stat || echo "No differences found"
# Force create a merge commit even if there are no changes
# This ensures the release history is properly recorded
git merge origin/${BRANCH_NAME} \
--no-ff \
-m "chore(release): merge ${BRANCH_NAME} to ${MAIN_BRANCH} [auto release commit]"
# Show merge result
echo "=== Merge result ==="
git log --oneline --graph -3
# Push to main
git push origin ${MAIN_BRANCH}
echo "✓ Successfully merged ${BRANCH_NAME} to ${MAIN_BRANCH}"
- name: Delete release branch
run: |
BRANCH_NAME="${{ needs.extract-version.outputs.tag_name }}"
# Extract branch name: v1.0.0 -> release/v1.0.0
if [[ ! "$BRANCH_NAME" =~ ^release/ ]]; then
BRANCH_NAME="release/${BRANCH_NAME}"
fi
echo "Deleting release branch: ${BRANCH_NAME}"
git push origin --delete ${BRANCH_NAME}
echo "✓ Deleted branch ${BRANCH_NAME}"
- name: Release cleanup summary
run: |
BRANCH_NAME="${{ github.ref_name }}"
TAG_NAME="${{ needs.extract-version.outputs.tag_name }}"
MAIN_BRANCH=$(git remote show origin | grep 'HEAD branch' | cut -d' ' -f5)
if [ -z "$MAIN_BRANCH" ]; then
MAIN_BRANCH="main"
fi
echo "## Release Cleanup Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "✓ Release completed successfully!" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Actions Performed:" >> $GITHUB_STEP_SUMMARY
echo "- Merged \`${BRANCH_NAME}\` to \`${MAIN_BRANCH}\`" >> $GITHUB_STEP_SUMMARY
echo "- Deleted release branch \`${BRANCH_NAME}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Release Details:" >> $GITHUB_STEP_SUMMARY
echo "- Tag: \`${TAG_NAME}\`" >> $GITHUB_STEP_SUMMARY
echo "- Version: \`${{ needs.extract-version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
echo "- Release Type: $([ "${{ needs.extract-version.outputs.is_rc }}" = "true" ] && echo "Release Candidate" || echo "Stable Release")" >> $GITHUB_STEP_SUMMARY
+3 -3
View File
@@ -42,7 +42,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}
fetch-depth: 0
@@ -148,7 +148,7 @@ jobs:
- name: Upload modified ALVersionInfo.py ready for build
if: steps.check_changes.outputs.has_changes == 'true'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: updated-version-info-for-build
path: src/gui/temp/ALVersionInfo.py
@@ -156,7 +156,7 @@ jobs:
- name: Upload modified ALVersionInfo.py ready for commit
if: steps.check_changes.outputs.has_changes == 'true'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: updated-version-info-for-commit
path: src/gui/ALVersionInfo.py
-15
View File
@@ -1,15 +0,0 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
ddddocr = "*"
selenium = "*"
pyinstaller = "*"
pyside6 = "*"
[dev-packages]
[requires]
python_version = "3.13"
Generated
-630
View File
@@ -1,630 +0,0 @@
{
"_meta": {
"hash": {
"sha256": "26dffc26812d5328611959b95713a7ed65e20c08c60089b54283b0f406dd08e4"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.13"
},
"sources": [
{
"name": "pypi",
"url": "https://pypi.org/simple",
"verify_ssl": true
}
]
},
"default": {
"altgraph": {
"hashes": [
"sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406",
"sha256:642743b4750de17e655e6711601b077bc6598dbfa3ba5fa2b2a35ce12b508dff"
],
"version": "==0.17.4"
},
"attrs": {
"hashes": [
"sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11",
"sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"
],
"markers": "python_version >= '3.9'",
"version": "==25.4.0"
},
"certifi": {
"hashes": [
"sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de",
"sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43"
],
"markers": "python_version >= '3.7'",
"version": "==2025.10.5"
},
"cffi": {
"hashes": [
"sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb",
"sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b",
"sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f",
"sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9",
"sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44",
"sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2",
"sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c",
"sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75",
"sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65",
"sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e",
"sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a",
"sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e",
"sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25",
"sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a",
"sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe",
"sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b",
"sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91",
"sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592",
"sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187",
"sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c",
"sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1",
"sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94",
"sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba",
"sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb",
"sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165",
"sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529",
"sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca",
"sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c",
"sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6",
"sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c",
"sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0",
"sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743",
"sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63",
"sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5",
"sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5",
"sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4",
"sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d",
"sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b",
"sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93",
"sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205",
"sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27",
"sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512",
"sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d",
"sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c",
"sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037",
"sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26",
"sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322",
"sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb",
"sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c",
"sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8",
"sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4",
"sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414",
"sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9",
"sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664",
"sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9",
"sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775",
"sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739",
"sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc",
"sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062",
"sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe",
"sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9",
"sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92",
"sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5",
"sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13",
"sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d",
"sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26",
"sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f",
"sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495",
"sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b",
"sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6",
"sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c",
"sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef",
"sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5",
"sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18",
"sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad",
"sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3",
"sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7",
"sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5",
"sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534",
"sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49",
"sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2",
"sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5",
"sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453",
"sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"
],
"markers": "python_version >= '3.9'",
"version": "==2.0.0"
},
"coloredlogs": {
"hashes": [
"sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934",
"sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==15.0.1"
},
"ddddocr": {
"hashes": [
"sha256:5991594d481d33ba0b136022e910f578d6d5b0ca536b44886591359622ab0c70",
"sha256:7c44b58ba7d7566d785c65b8526ec5b78efacd121e993dea4fda5f7966897428"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
"version": "==1.0.6"
},
"flatbuffers": {
"hashes": [
"sha256:255538574d6cb6d0a79a17ec8bc0d30985913b87513a01cce8bcdb6b4c44d0e2",
"sha256:676f9fa62750bb50cf531b42a0a2a118ad8f7f797a511eda12881c016f093b12"
],
"version": "==25.9.23"
},
"h11": {
"hashes": [
"sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1",
"sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"
],
"markers": "python_version >= '3.8'",
"version": "==0.16.0"
},
"humanfriendly": {
"hashes": [
"sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477",
"sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==10.0"
},
"idna": {
"hashes": [
"sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea",
"sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"
],
"markers": "python_version >= '3.8'",
"version": "==3.11"
},
"mpmath": {
"hashes": [
"sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f",
"sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c"
],
"version": "==1.3.0"
},
"numpy": {
"hashes": [
"sha256:035796aaaddfe2f9664b9a9372f089cfc88bd795a67bd1bfe15e6e770934cf64",
"sha256:043885b4f7e6e232d7df4f51ffdef8c36320ee9d5f227b380ea636722c7ed12e",
"sha256:04a69abe45b49c5955923cf2c407843d1c85013b424ae8a560bba16c92fe44a0",
"sha256:0f2bcc76f1e05e5ab58893407c63d90b2029908fa41f9f1cc51eecce936c3365",
"sha256:13b9062e4f5c7ee5c7e5be96f29ba71bc5a37fed3d1d77c37390ae00724d296d",
"sha256:15eea9f306b98e0be91eb344a94c0e630689ef302e10c2ce5f7e11905c704f9c",
"sha256:15fb27364ed84114438fff8aaf998c9e19adbeba08c0b75409f8c452a8692c52",
"sha256:1b219560ae2c1de48ead517d085bc2d05b9433f8e49d0955c82e8cd37bd7bf36",
"sha256:22758999b256b595cf0b1d102b133bb61866ba5ceecf15f759623b64c020c9ec",
"sha256:2ec646892819370cf3558f518797f16597b4e4669894a2ba712caccc9da53f1f",
"sha256:3634093d0b428e6c32c3a69b78e554f0cd20ee420dcad5a9f3b2a63762ce4197",
"sha256:36dc13af226aeab72b7abad501d370d606326a0029b9f435eacb3b8c94b8a8b7",
"sha256:3da3491cee49cf16157e70f607c03a217ea6647b1cea4819c4f48e53d49139b9",
"sha256:40cc556d5abbc54aabe2b1ae287042d7bdb80c08edede19f0c0afb36ae586f37",
"sha256:4121c5beb58a7f9e6dfdee612cb24f4df5cd4db6e8261d7f4d7450a997a65d6a",
"sha256:4635239814149e06e2cb9db3dd584b2fa64316c96f10656983b8026a82e6e4db",
"sha256:4c01835e718bcebe80394fd0ac66c07cbb90147ebbdad3dcecd3f25de2ae7e2c",
"sha256:4ee6a571d1e4f0ea6d5f22d6e5fbd6ed1dc2b18542848e1e7301bd190500c9d7",
"sha256:56209416e81a7893036eea03abcb91c130643eb14233b2515c90dcac963fe99d",
"sha256:5e199c087e2aa71c8f9ce1cb7a8e10677dc12457e7cc1be4798632da37c3e86e",
"sha256:62b2198c438058a20b6704351b35a1d7db881812d8512d67a69c9de1f18ca05f",
"sha256:64c5825affc76942973a70acf438a8ab618dbd692b84cd5ec40a0a0509edc09a",
"sha256:65611ecbb00ac9846efe04db15cbe6186f562f6bb7e5e05f077e53a599225d16",
"sha256:6d34ed9db9e6395bb6cd33286035f73a59b058169733a9db9f85e650b88df37e",
"sha256:6d9cd732068e8288dbe2717177320723ccec4fb064123f0caf9bbd90ab5be868",
"sha256:6e274603039f924c0fe5cb73438fa9246699c78a6df1bd3decef9ae592ae1c05",
"sha256:77b84453f3adcb994ddbd0d1c5d11db2d6bda1a2b7fd5ac5bd4649d6f5dc682e",
"sha256:7c26b0b2bf58009ed1f38a641f3db4be8d960a417ca96d14e5b06df1506d41ff",
"sha256:7fd09cc5d65bda1e79432859c40978010622112e9194e581e3415a3eccc7f43f",
"sha256:817e719a868f0dacde4abdfc5c1910b301877970195db9ab6a5e2c4bd5b121f7",
"sha256:81b3a59793523e552c4a96109dde028aa4448ae06ccac5a76ff6532a85558a7f",
"sha256:81c3e6d8c97295a7360d367f9f8553973651b76907988bb6066376bc2252f24e",
"sha256:838f045478638b26c375ee96ea89464d38428c69170360b23a1a50fa4baa3562",
"sha256:84f01a4d18b2cc4ade1814a08e5f3c907b079c847051d720fad15ce37aa930b6",
"sha256:85597b2d25ddf655495e2363fe044b0ae999b75bc4d630dc0d886484b03a5eb0",
"sha256:85d9fb2d8cd998c84d13a79a09cc0c1091648e848e4e6249b0ccd7f6b487fa26",
"sha256:85e071da78d92a214212cacea81c6da557cab307f2c34b5f85b628e94803f9c0",
"sha256:863e3b5f4d9915aaf1b8ec79ae560ad21f0b8d5e3adc31e73126491bb86dee1d",
"sha256:86966db35c4040fdca64f0816a1c1dd8dbd027d90fca5a57e00e1ca4cd41b879",
"sha256:8ab1c5f5ee40d6e01cbe96de5863e39b215a4d24e7d007cad56c7184fdf4aeef",
"sha256:8b5a9a39c45d852b62693d9b3f3e0fe052541f804296ff401a72a1b60edafb29",
"sha256:8dc20bde86802df2ed8397a08d793da0ad7a5fd4ea3ac85d757bf5dd4ad7c252",
"sha256:957e92defe6c08211eb77902253b14fe5b480ebc5112bc741fd5e9cd0608f847",
"sha256:962064de37b9aef801d33bc579690f8bfe6c5e70e29b61783f60bcba838a14d6",
"sha256:985f1e46358f06c2a09921e8921e2c98168ed4ae12ccd6e5e87a4f1857923f32",
"sha256:9984bd645a8db6ca15d850ff996856d8762c51a2239225288f08f9050ca240a0",
"sha256:9cb177bc55b010b19798dc5497d540dea67fd13a8d9e882b2dae71de0cf09eb3",
"sha256:9d729d60f8d53a7361707f4b68a9663c968882dd4f09e0d58c044c8bf5faee7b",
"sha256:a13fc473b6db0be619e45f11f9e81260f7302f8d180c49a22b6e6120022596b3",
"sha256:a49d797192a8d950ca59ee2d0337a4d804f713bb5c3c50e8db26d49666e351dc",
"sha256:a700a4031bc0fd6936e78a752eefb79092cecad2599ea9c8039c548bc097f9bc",
"sha256:a7b2f9a18b5ff9824a6af80de4f37f4ec3c2aab05ef08f51c77a093f5b89adda",
"sha256:a7d018bfedb375a8d979ac758b120ba846a7fe764911a64465fd87b8729f4a6a",
"sha256:b6c231c9c2fadbae4011ca5e7e83e12dc4a5072f1a1d85a0a7b3ed754d145a40",
"sha256:bafa7d87d4c99752d07815ed7a2c0964f8ab311eb8168f41b910bd01d15b6032",
"sha256:bd0c630cf256b0a7fd9d0a11c9413b42fef5101219ce6ed5a09624f5a65392c7",
"sha256:c090d4860032b857d94144d1a9976b8e36709e40386db289aaf6672de2a81966",
"sha256:c2f91f496a87235c6aaf6d3f3d89b17dba64996abadccb289f48456cff931ca9",
"sha256:d149aee5c72176d9ddbc6803aef9c0f6d2ceeea7626574fc68518da5476fa346",
"sha256:d5e081bc082825f8b139f9e9fe42942cb4054524598aaeb177ff476cc76d09d2",
"sha256:d7315ed1dab0286adca467377c8381cd748f3dc92235f22a7dfc42745644a96a",
"sha256:dabc42f9c6577bcc13001b8810d300fe814b4cfbe8a92c873f269484594f9786",
"sha256:e1708fac43ef8b419c975926ce1eaf793b0c13b7356cfab6ab0dc34c0a02ac0f",
"sha256:e73d63fd04e3a9d6bc187f5455d81abfad05660b212c8804bf3b407e984cd2bc",
"sha256:e78aecd2800b32e8347ce49316d3eaf04aed849cd5b38e0af39f829a4e59f5eb",
"sha256:e8370eb6925bb8c1c4264fec52b0384b44f675f191df91cbe0140ec9f0955646",
"sha256:ecb63014bb7f4ce653f8be7f1df8cbc6093a5a2811211770f6606cc92b5a78fd",
"sha256:ed759bf7a70342f7817d88376eb7142fab9fef8320d6019ef87fae05a99874e1",
"sha256:ef1b5a3e808bc40827b5fa2c8196151a4c5abe110e1726949d7abddfe5c7ae11",
"sha256:f77e5b3d3da652b474cc80a14084927a5e86a5eccf54ca8ca5cbd697bf7f2667",
"sha256:faba246fb30ea2a526c2e9645f61612341de1a83fb1e0c5edf4ddda5a9c10996",
"sha256:fc8a63918b04b8571789688b2780ab2b4a33ab44bfe8ccea36d3eba51228c953",
"sha256:fdebe771ca06bb8d6abce84e51dca9f7921fe6ad34a0c914541b063e9a68928b",
"sha256:fea80f4f4cf83b54c3a051f2f727870ee51e22f0248d3114b8e755d160b38cfb"
],
"markers": "python_version >= '3.11'",
"version": "==2.3.4"
},
"onnxruntime": {
"hashes": [
"sha256:0be6a37a45e6719db5120e9986fcd30ea205ac8103fd1fb74b6c33348327a0cc",
"sha256:0f9b4ae77f8e3c9bee50c27bc1beede83f786fe1d52e99ac85aa8d65a01e9b77",
"sha256:162f4ca894ec3de1a6fd53589e511e06ecdc3ff646849b62a9da7489dee9ce95",
"sha256:1f9cc0a55349c584f083c1c076e611a7c35d5b867d5d6e6d6c823bf821978088",
"sha256:218295a8acae83905f6f1aed8cacb8e3eb3bd7513a13fe4ba3b2664a19fc4a6b",
"sha256:25de5214923ce941a3523739d34a520aac30f21e631de53bba9174dc9c004435",
"sha256:2ff531ad8496281b4297f32b83b01cdd719617e2351ffe0dba5684fb283afa1f",
"sha256:45d127d6e1e9b99d1ebeae9bcd8f98617a812f53f46699eafeb976275744826b",
"sha256:4ca88747e708e5c67337b0f65eed4b7d0dd70d22ac332038c9fc4635760018f7",
"sha256:6f91d2c9b0965e86827a5ba01531d5b669770b01775b23199565d6c1f136616c",
"sha256:76ff670550dc23e58ea9bc53b5149b99a44e63b34b524f7b8547469aaa0dcb8c",
"sha256:87d8b6eaf0fbeb6835a60a4265fde7a3b60157cf1b2764773ac47237b4d48612",
"sha256:8bace4e0d46480fbeeb7bbe1ffe1f080e6663a42d1086ff95c1551f2d39e7872",
"sha256:8f7d1fe034090a1e371b7f3ca9d3ccae2fabae8c1d8844fb7371d1ea38e8e8d2",
"sha256:902c756d8b633ce0dedd889b7c08459433fbcf35e9c38d1c03ddc020f0648c6e",
"sha256:9d2385e774f46ac38f02b3a91a91e30263d41b2f1f4f26ae34805b2a9ddef466",
"sha256:a7730122afe186a784660f6ec5807138bf9d792fa1df76556b27307ea9ebcbe3",
"sha256:b28740f4ecef1738ea8f807461dd541b8287d5650b5be33bca7b474e3cbd1f36",
"sha256:b8f029a6b98d3cf5be564d52802bb50a8489ab73409fa9db0bf583eabb7c2321",
"sha256:bbfd2fca76c855317568c1b36a885ddea2272c13cb0e395002c402f2360429a6",
"sha256:da44b99206e77734c5819aa2142c69e64f3b46edc3bd314f6a45a932defc0b3e",
"sha256:e2b9233c4947907fd1818d0e581c049c41ccc39b2856cc942ff6d26317cee145"
],
"markers": "python_version >= '3.10'",
"version": "==1.23.2"
},
"outcome": {
"hashes": [
"sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8",
"sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b"
],
"markers": "python_version >= '3.7'",
"version": "==1.3.0.post0"
},
"packaging": {
"hashes": [
"sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484",
"sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"
],
"markers": "python_version >= '3.8'",
"version": "==25.0"
},
"pefile": {
"hashes": [
"sha256:82e6114004b3d6911c77c3953e3838654b04511b8b66e8583db70c65998017dc",
"sha256:da185cd2af68c08a6cd4481f7325ed600a88f6a813bad9dea07ab3ef73d8d8d6"
],
"markers": "python_full_version >= '3.6.0'",
"version": "==2023.2.7"
},
"pillow": {
"hashes": [
"sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643",
"sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e",
"sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e",
"sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc",
"sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642",
"sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6",
"sha256:1ac11e8ea4f611c3c0147424eae514028b5e9077dd99ab91e1bd7bc33ff145e1",
"sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b",
"sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399",
"sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba",
"sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad",
"sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47",
"sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739",
"sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b",
"sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f",
"sha256:32ed80ea8a90ee3e6fa08c21e2e091bba6eda8eccc83dbc34c95169507a91f10",
"sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52",
"sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d",
"sha256:3adfb466bbc544b926d50fe8f4a4e6abd8c6bffd28a26177594e6e9b2b76572b",
"sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a",
"sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9",
"sha256:455247ac8a4cfb7b9bc45b7e432d10421aea9fc2e74d285ba4072688a74c2e9d",
"sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098",
"sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905",
"sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b",
"sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3",
"sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371",
"sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953",
"sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01",
"sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca",
"sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e",
"sha256:6ace95230bfb7cd79ef66caa064bbe2f2a1e63d93471c3a2e1f1348d9f22d6b7",
"sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27",
"sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082",
"sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e",
"sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d",
"sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8",
"sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a",
"sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad",
"sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3",
"sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a",
"sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d",
"sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353",
"sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee",
"sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b",
"sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b",
"sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a",
"sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7",
"sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef",
"sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a",
"sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a",
"sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257",
"sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07",
"sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4",
"sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c",
"sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c",
"sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4",
"sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe",
"sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8",
"sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5",
"sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6",
"sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e",
"sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8",
"sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e",
"sha256:bd87e140e45399c818fac4247880b9ce719e4783d767e030a883a970be632275",
"sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3",
"sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76",
"sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227",
"sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9",
"sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5",
"sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79",
"sha256:c7b2a63fd6d5246349f3d3f37b14430d73ee7e8173154461785e43036ffa96ca",
"sha256:c828a1ae702fc712978bda0320ba1b9893d99be0badf2647f693cc01cf0f04fa",
"sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b",
"sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e",
"sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197",
"sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab",
"sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79",
"sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2",
"sha256:d49e2314c373f4c2b39446fb1a45ed333c850e09d0c59ac79b72eb3b95397363",
"sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0",
"sha256:d64317d2587c70324b79861babb9c09f71fbb780bad212018874b2c013d8600e",
"sha256:d77153e14b709fd8b8af6f66a3afbb9ed6e9fc5ccf0b6b7e1ced7b036a228782",
"sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925",
"sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0",
"sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b",
"sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced",
"sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c",
"sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344",
"sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9",
"sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1"
],
"markers": "python_version >= '3.10'",
"version": "==12.0.0"
},
"protobuf": {
"hashes": [
"sha256:140303d5c8d2037730c548f8c7b93b20bb1dc301be280c378b82b8894589c954",
"sha256:25c9e1963c6734448ea2d308cfa610e692b801304ba0908d7bfa564ac5132995",
"sha256:35be49fd3f4fefa4e6e2aacc35e8b837d6703c37a2168a55ac21e9b1bc7559ef",
"sha256:905b07a65f1a4b72412314082c7dbfae91a9e8b68a0cc1577515f8df58ecf455",
"sha256:9a031d10f703f03768f2743a1c403af050b6ae1f3480e9c140f39c45f81b13ee",
"sha256:c963e86c3655af3a917962c9619e1a6b9670540351d7af9439d06064e3317cc9",
"sha256:cd33a8e38ea3e39df66e1bbc462b076d6e5ba3a4ebbde58219d777223a7873d3",
"sha256:d6101ded078042a8f17959eccd9236fb7a9ca20d3b0098bbcb91533a5680d035",
"sha256:e0697ece353e6239b90ee43a9231318302ad8353c70e6e45499fa52396debf90",
"sha256:e0a1715e4f27355afd9570f3ea369735afc853a6c3951a6afe1f80d8569ad298"
],
"markers": "python_version >= '3.9'",
"version": "==6.33.0"
},
"pycparser": {
"hashes": [
"sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2",
"sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"
],
"markers": "python_version >= '3.8'",
"version": "==2.23"
},
"pyinstaller": {
"hashes": [
"sha256:0a48f55b85ff60f83169e10050f2759019cf1d06773ad1c4da3a411cd8751058",
"sha256:53559fe1e041a234f2b4dcc3288ea8bdd57f7cad8a6644e422c27bb407f3edef",
"sha256:6d5f8617f3650ff9ef893e2ab4ddbf3c0d23d0c602ef74b5df8fbef4607840c8",
"sha256:73ba72e04fcece92e32518bbb1e1fb5ac2892677943dfdff38e01a06e8742851",
"sha256:7fd1c785219a87ca747c21fa92f561b0d2926a7edc06d0a0fe37f3736e00bd7a",
"sha256:b1752488248f7899281b17ca3238eefb5410521291371a686a4f5830f29f52b3",
"sha256:b756ddb9007b8141c5476b553351f9d97559b8af5d07f9460869bfae02be26b0",
"sha256:ba618a61627ee674d6d68e5de084ba17c707b59a4f2a856084b3999bdffbd3f0",
"sha256:bc10eb1a787f99fea613509f55b902fbd2d8b73ff5f51ff245ea29a481d97d41",
"sha256:c8b7ef536711617e12fef4673806198872033fa06fa92326ad7fd1d84a9fa454",
"sha256:d0af8a401de792c233c32c44b16d065ca9ab8262ee0c906835c12bdebc992a64",
"sha256:d1ebf84d02c51fed19b82a8abb4df536923abd55bb684d694e1356e4ae2a0ce5"
],
"index": "pypi",
"markers": "python_version < '3.15' and python_version >= '3.8'",
"version": "==6.16.0"
},
"pyinstaller-hooks-contrib": {
"hashes": [
"sha256:56e972bdaad4e9af767ed47d132362d162112260cbe488c9da7fee01f228a5a6",
"sha256:ccbfaa49399ef6b18486a165810155e5a8d4c59b41f20dc5da81af7482aaf038"
],
"markers": "python_version >= '3.8'",
"version": "==2025.9"
},
"pyreadline3": {
"hashes": [
"sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7",
"sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6"
],
"markers": "python_version >= '3.8'",
"version": "==3.5.4"
},
"pyside6": {
"hashes": [
"sha256:4b709bdeeb89d386059343a5a706fc185cee37b517bda44c7d6b64d5fdaf3339",
"sha256:70a8bcc73ea8d6baab70bba311eac77b9a1d31f658d0b418e15eb6ea36c97e6f",
"sha256:9f402f883e640048fab246d36e298a5e16df9b18ba2e8c519877e472d3602820",
"sha256:ae8c3c8339cd7c3c9faa7cc5c52670dcc8662ccf4b63a6fed61c6345b90c4c01",
"sha256:c2cbc5dc2a164e3c7c51b3435e24203e90e5edd518c865466afccbd2e5872bb0"
],
"index": "pypi",
"markers": "python_version < '3.14' and python_version >= '3.9'",
"version": "==6.10.0"
},
"pyside6-addons": {
"hashes": [
"sha256:08d4ed46c4c9a353a9eb84134678f8fdd4ce17fb8cce2b3686172a7575025464",
"sha256:15d32229d681be0bba1b936c4a300da43d01e1917ada5b57f9e03a387c245ab0",
"sha256:88e61e21ee4643cdd9efb39ec52f4dc1ac74c0b45c5b7fa453d03c094f0a8a5c",
"sha256:92536427413f3b6557cf53f1a515cd766725ee46a170aff57ad2ff1dfce0ffb1",
"sha256:99d93a32c17c5f6d797c3b90dd58f2a8bae13abde81e85802c34ceafaee11859"
],
"markers": "python_version < '3.14' and python_version >= '3.9'",
"version": "==6.10.0"
},
"pyside6-essentials": {
"hashes": [
"sha256:003e871effe1f3e5b876bde715c15a780d876682005a6e989d89f48b8b93e93a",
"sha256:1d5e013a8698e37ab8ef360e6960794eb5ef20832a8d562e649b8c5a0574b2d8",
"sha256:6dd0936394cb14da2fd8e869899f5e0925a738b1c8d74c2f22503720ea363fb1",
"sha256:b1dd0864f0577a448fb44426b91cafff7ee7cccd1782ba66491e1c668033f998",
"sha256:fc167eb211dd1580e20ba90d299e74898e7a5a1306d832421e879641fc03b6fe"
],
"markers": "python_version < '3.14' and python_version >= '3.9'",
"version": "==6.10.0"
},
"pysocks": {
"hashes": [
"sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299",
"sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5",
"sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.7.1"
},
"pywin32-ctypes": {
"hashes": [
"sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8",
"sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"
],
"markers": "python_version >= '3.6'",
"version": "==0.2.3"
},
"selenium": {
"hashes": [
"sha256:c117af6727859d50f622d6d0785b945c5db3e28a45ec12ad85cee2e7cc84fc4c",
"sha256:ed47563f188130a6fd486b327ca7ba48c5b11fb900e07d6457befdde320e35fd"
],
"index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==4.38.0"
},
"setuptools": {
"hashes": [
"sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922",
"sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"
],
"markers": "python_version >= '3.9'",
"version": "==80.9.0"
},
"shiboken6": {
"hashes": [
"sha256:0bc5631c1bf150cbef768a17f5f289aae1cb4db6c6b0c19b2421394e27783717",
"sha256:7a5f5f400ebfb3a13616030815708289c2154e701a60b9db7833b843e0bee543",
"sha256:b01377e68d14132360efb0f4b7233006d26aa8ae9bb50edf00960c2a5f52d148",
"sha256:dfc4beab5fec7dbbebbb418f3bf99af865d6953aa0795435563d4cbb82093b61",
"sha256:e612734da515d683696980107cdc0396a3ae0f07b059f0f422ec8a2333810234"
],
"markers": "python_version < '3.14' and python_version >= '3.9'",
"version": "==6.10.0"
},
"sniffio": {
"hashes": [
"sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2",
"sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"
],
"markers": "python_version >= '3.7'",
"version": "==1.3.1"
},
"sortedcontainers": {
"hashes": [
"sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88",
"sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"
],
"version": "==2.4.0"
},
"sympy": {
"hashes": [
"sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517",
"sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5"
],
"markers": "python_version >= '3.9'",
"version": "==1.14.0"
},
"trio": {
"hashes": [
"sha256:150f29ec923bcd51231e1d4c71c7006e65247d68759dd1c19af4ea815a25806b",
"sha256:4ab65984ef8370b79a76659ec87aa3a30c5c7c83ff250b4de88c29a8ab6123c5"
],
"markers": "python_version >= '3.10'",
"version": "==0.32.0"
},
"trio-websocket": {
"hashes": [
"sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae",
"sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6"
],
"markers": "python_version >= '3.8'",
"version": "==0.12.2"
},
"typing-extensions": {
"hashes": [
"sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466",
"sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"
],
"markers": "python_version >= '3.9'",
"version": "==4.15.0"
},
"urllib3": {
"extras": [
"socks"
],
"hashes": [
"sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760",
"sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"
],
"markers": "python_version >= '3.9'",
"version": "==2.5.0"
},
"websocket-client": {
"hashes": [
"sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98",
"sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef"
],
"markers": "python_version >= '3.9'",
"version": "==1.9.0"
},
"wsproto": {
"hashes": [
"sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065",
"sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736"
],
"markers": "python_full_version >= '3.7.0'",
"version": "==1.2.0"
}
},
"develop": {}
}
+1 -1
View File
@@ -1,3 +1,3 @@
This folder is used to store the manuals.
Our manuals are available at https://www.autolibrary.top/manuals
Our manuals are available at https://www.autolibrary.kenanzhu.com/manuals
+17 -15
View File
@@ -6,11 +6,12 @@
[![GitHub stars](https://img.shields.io/github/stars/KenanZhu/AutoLibrary.svg?style=social&label=Star)](https://github.com/KenanZhu/AutoLibrary)
![License](https://img.shields.io/github/license/KenanZhu/AutoLibrary?label=license)
[![Build](https://img.shields.io/github/actions/workflow/status/KenanZhu/AutoLibrary/release.yml?label=release&logo=github-actions&logoColor=white)](https://github.com/KenanZhu/AutoLibrary/actions/workflows/release.yml)
[![Build](https://img.shields.io/github/actions/workflow/status/KenanZhu/AutoLibrary/build-test.yml?label=build-test&logo=github-actions&logoColor=white)](https://github.com/KenanZhu/AutoLibrary/actions/workflows/build-test.yml)
[![Release](https://img.shields.io/github/actions/workflow/status/KenanZhu/AutoLibrary/release.yml?label=release&logo=github-actions&logoColor=white)](https://github.com/KenanZhu/AutoLibrary/actions/workflows/release.yml)
[![Release](https://img.shields.io/github/v/release/KenanZhu/AutoLibrary?label=latest&logo=github&logoColor=white)](https://github.com/KenanZhu/AutoLibrary/releases/latest)
![Downloads](https://img.shields.io/github/downloads/KenanZhu/AutoLibrary/total?label=downloads)
了解更多请访问 [_AutoLibrary 网站_](http://www.autolibrary.top)
了解更多请访问 [_AutoLibrary 网站_](http://www.autolibrary.kenanzhu.com)
---
@@ -19,33 +20,34 @@
1. 自动预约 - 支持自动预约
2. 自动续约 - 支持自动续约
3. 自动签到 - 支持自动签到
4. 批量操作 - 支持同时预约多个用户,可以指定当前需要跳过的用户,并将用户分成多个组
5. 定时任务 - 使用内置定时任务管理,添加定时任务,指定时间后按当前预约信息自动运行
4. 远程签到 - 支持远程签到,无需在图书馆网络环境下即可签到
5. 批量操作 - 支持同时预约多个用户,可以指定当前需要跳过的用户,并将用户分成多个组
6. 定时任务 - 使用内置定时任务管理,添加定时任务,指定时间后按当前预约信息自动运行,支持设置重复任务
7. 驱动管理 - 内置浏览器驱动自动管理,支持自动检测浏览器版本并下载对应驱动,无需手动下载
*1,2,3 的具体操作方法和注意事项请访问我们的 [帮助手册](https://www.autolibrary.top/manuals)*
*具体操作方法和注意事项请访问我们的 [帮助手册](https://www.autolibrary.kenanzhu.com/manuals)*
### 如何使用
1. 下载最新版本的 [AutoLibrary 压缩包](https://github.com/KenanZhu/AutoLibrary/releases/latest)。
2. 解压下载的文件到任意目录。
3. 下载对应浏览器类型和版本(具体操作请参考适用软件版本的 [帮助手册](https://www.autolibrary.top/manuals))的驱动文件,并在配置界面的运行配置选项卡对应位置选择你下载好的浏览器驱动
4. 运行 `AutoLibrary-[主版本号].[次版本号].[修订版本号].Z.exe` 文件 (如 `AutoLibrary-1.0.0.exe`
5. 点击 [配置] 按钮,在配置界面填写好预约信息和运行配置后,点击 [确认] 按钮
6. 点击 [启动脚本] 按钮,即可开始自动预约、续约、签到等操作。
1. 下载最新版本的 [AutoLibrary 安装程序](https://github.com/KenanZhu/AutoLibrary/releases/latest) 或 [压缩包](https://github.com/KenanZhu/AutoLibrary/releases/latest)
2. 双击运行安装程序进行安装,或将压缩包解压到任意目录。
3. 运行 `AutoLibrary`,即可打开主界面
4. 点击 [配置] 按钮,在配置界面填写好预约信息和运行配置后,点击 [确认] 按钮
5. 点击 [启动脚本] 按钮,即可开始自动预约、续约、签到等操作
*注意 1*: 关于浏览器驱动的下载和其它相关问题,请参考我们的 [帮助手册](https://www.autolibrary.top/manuals) 中对应软件版本的内容
*注意 1*: 工具内置浏览器驱动自动管理功能,会自动检测本地浏览器版本并下载对应的驱动文件。如果自动下载失败,也可以手动下载驱动文件并在配置界面的运行配置选项卡对应位置选择驱动文件路径
#### 平台支持 & 编译步骤
本工具目前仅支持 Windows 平台,由于使用 PySide6 库开发,理论上是可以自行编译并在 Linux 和 macOS 上运行,这里提供简单的编译步骤:
1. 确保系统安装了 Python 3.13 版本 (推荐,过低或高版本会导致兼容问题)。
2. 安装 pyside6 selenium ddddocr 库,命令为 `pip install pyside6 selenium ddddocr`
2. 安装所有依赖库,命令为 `pip install -r requirements.txt` (建议在虚拟环境下操作)
3.`batchs` 目录下运行 `compile_ui.bat` linux 和 macOS 系统使用 `compile_ui.sh`) 文件来编译 Qt 的 UI 文件。
4. 在上一步相同目录内运行 `compile_rc.bat` linux 和 macOS 系统使用 `compile_rc.sh` 文件来编译 Qt 的资源文件。
5. 待上述步骤完成后,运行 `src/Main.py` 文件即可。
*注意 1*:如果 python 使用的是虚拟环境,请在虚拟环境安装依赖后,在激活的虚拟环境终端中使用 `cd src/gui/batchs` 命令切换到 `batchs` 目录下,再运行编译脚本。否则会提示缺少必要的 Qt PySide 依赖库。
*注意 1*:如果 python 使用的是虚拟环境,请在虚拟环境安装依赖后,在激活的虚拟环境终端中使用 `cd batchs` 命令切换到 `batchs` 目录下,再运行编译脚本。否则会提示缺少必要的 Qt PySide 依赖库。
*注意 2*:由于 ddddocr 的代码版本问题,其中 `__init__.py` 文件中的函数 `def classification(self, img: bytes):` 中的 `image.resize` 方法传入了不符合当前 pillow 版本的 `resample` 参数 `Image.ANTIALIAS`,该重采样常量已经在 10.0.0 版中删除 [1](@ref)。请将 `image.resize` 方法中的参数替换为 `resample=Image.Resampling.LANCZOS`,具体函数如下:
```python
@@ -102,7 +104,7 @@ def classification(self, img: bytes):
当前版本的功能对于正常使用已经足够,不过后续会着重完善预约时的使用体验,暂时有以下构想:
- 引入交互预约面板功能,预约时直接在座位分布图中选择可用座位,并按用户分配,无需事先配置预约信息。
- 优化定时任务管理功能,用户可以在定时任务管理界面设置重复运行的定时任务,如每日预约、每周预约等。
- ~~优化定时任务管理功能,用户可以在定时任务管理界面设置重复运行的定时任务,如每日预约、每周预约等。~~ (已完成)
- 软件的自动更新以及公告栏功能,用户可以自动更新最新版本并获取最新公告事项。
不过由于本人的时间和能力有限,也需要考虑到图书馆的正常运行,所以后续功能会有所取舍,但也许会进行一些小的功能验证。
BIN
View File
Binary file not shown.
+5 -14
View File
@@ -7,26 +7,17 @@ This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
import os
import sys
from PySide6.QtCore import QTranslator, QStandardPaths, QDir
from PySide6.QtCore import QTranslator
from PySide6.QtWidgets import QApplication
from gui.ALMainWindow import ALMainWindow
from gui.resources import ALResource
from utils.ConfigManager import instance
from boot.AppInitializer import initializeApp
def initializeConfigManager():
app_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation)
config_dir = os.path.join(app_dir, "config")
if not QDir(config_dir).exists():
QDir().mkpath(config_dir)
instance(config_dir)
def main():
app = QApplication(sys.argv)
@@ -35,11 +26,11 @@ def main():
app.installTranslator(translator)
app.setStyle('Fusion')
app.setApplicationName("AutoLibrary")
initializeConfigManager()
if not initializeApp():
sys.exit(-1)
window = ALMainWindow()
window.show()
sys.exit(app.exec_())
sys.exit(app.exec())
if __name__ == "__main__":
+141
View File
@@ -0,0 +1,141 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
import queue
from datetime import datetime
from base.LibOperator import LibOperator
class LibTimeSelector(LibOperator):
"""
Base class for time selection operations.
This class provides common time selection logic for reservation and renewal
operations, including time conversion utilities and best time option finding.
"""
def __init__(
self,
input_queue: queue.Queue,
output_queue: queue.Queue
):
super().__init__(input_queue, output_queue)
@staticmethod
def _timeStrToMins(
time_str: str
) -> int:
"""
Convert time string "HH:MM" to minutes since midnight.
Example:
"10:00" -> 600
"13:30" -> 810
"""
hour, minute = map(int, time_str.split(":"))
return hour*60 + minute
@staticmethod
def _minsToTimeStr(
mins: int
) -> str:
"""
Convert minutes since midnight to time string "HH:MM".
Example:
600 -> "10:00"
810 -> "13:30"
"""
hour, minute = divmod(int(mins), 60)
return f"{hour:02d}:{minute:02d}"
def _formatTimeRelation(
self,
abs_diff: int,
actual_diff: int,
time_type: str
) -> str:
"""
Format time difference relation string.
"""
if actual_diff < 0:
return f"早了 {abs_diff} 分钟"
elif actual_diff > 0:
return f"晚了 {abs_diff} 分钟"
else:
return f"正好等于 {time_type}"
def _findBestTimeOption(
self,
time_options: list,
target_time: int,
max_time_diff: int,
prefer_earlier: bool,
is_reserve: bool = True
) -> tuple:
"""
Find the best time option from available times.
Args:
time_options: List of WebElement time options
target_time: Target time in minutes
max_time_diff: Maximum acceptable time difference in minutes
prefer_earlier: If True, prefer earlier times when diffs are equal
is_reserve: If True, parse 'time' attribute; if False, parse 'id' attribute
Returns:
Tuple of (best_time_element, best_time_text, actual_diff, free_times_list)
or (None, None, None, []) if no suitable option found
"""
free_times = []
best_time_diff = max_time_diff
best_actual_diff = None
best_time_opt = None
for time_opt in time_options:
# Parse time value based on context
if is_reserve:
# Reservation context: parse 'time' attribute
time_attr = time_opt.get_attribute("time")
if time_attr == "now":
now = datetime.now()
time_val = now.hour*60 + now.minute
elif time_attr and time_attr.isdigit():
time_val = int(time_attr)
else:
continue
else:
# Renewal context: parse 'id' attribute
time_attr = time_opt.get_attribute("id")
if not (time_attr and time_attr.isdigit()):
continue
time_val = int(time_attr)
free_times.append(time_opt.text.strip() if not is_reserve else self._minsToTimeStr(time_val))
actual_diff = time_val - target_time
abs_diff = abs(actual_diff)
# Update best option if current is better
if (abs_diff < best_time_diff or
(abs_diff == best_time_diff and
((prefer_earlier and actual_diff <= 0) or
(not prefer_earlier and actual_diff >= 0)))):
best_time_diff = abs_diff
best_actual_diff = actual_diff
best_time_opt = time_opt
if best_time_opt is not None:
return (best_time_opt, best_time_opt.text.strip(), best_actual_diff, free_times)
return (None, None, None, free_times)
+34 -1
View File
@@ -7,9 +7,12 @@ This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
import logging
import queue
import datetime
from managers.log.LogManager import getLogger
class MsgBase:
"""
@@ -29,6 +32,18 @@ class MsgBase:
implement queue polling to retrieve and process messages.
"""
class TraceLevel:
"""
Enum class for trace levels.
This class provides the trace levels for the logger.
"""
DEBUG = logging.DEBUG
INFO = logging.INFO
WARNING = logging.WARNING
ERROR = logging.ERROR
CRITICAL = logging.CRITICAL
def __init__(
self,
input_queue: queue.Queue,
@@ -38,6 +53,10 @@ class MsgBase:
self._class_name = self.__class__.__name__
self._input_queue = input_queue
self._output_queue = output_queue
try:
self._logger = getLogger(self._class_name)
except RuntimeError:
self._logger = None
def _showMsg(
@@ -50,11 +69,25 @@ class MsgBase:
def _showTrace(
self,
msg: str
msg: str,
level: int = logging.INFO,
no_log: bool = False
):
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
self._output_queue.put(f"{timestamp}-[{self._class_name:<15}] : {msg}")
if self._logger and not no_log:
self._logger.log(level, msg)
def _showLog(
self,
msg: str,
level: int = logging.INFO
):
if self._logger:
self._logger.log(level, msg)
def _waitMsg(
+77
View File
@@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
import os
from PySide6.QtCore import QStandardPaths, QDir
from managers.log.LogManager import instance as logInstance
from managers.config.ConfigManager import instance as configInstance
from managers.driver.WebDriverManager import instance as webdriverInstance
def initializeLogManager(
) -> bool:
app_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation)
log_dir = os.path.join(app_dir, "logs")
if not QDir(log_dir).exists():
if not QDir().mkpath(log_dir):
return False
logInstance(log_dir)
return True
def initializeConfigManager(
) -> bool:
logger = logInstance().getLogger("AppInitializer")
app_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation)
old_config_dir = os.path.join(app_dir, "config")
new_config_dir = os.path.join(app_dir, "configs")
if QDir(old_config_dir).exists(): # old config dir exists
#we rename it to compatible with new version
logger.info("存在旧配置目录 %s,将其重命名为 %s", old_config_dir, new_config_dir)
if not QDir().rename(old_config_dir, new_config_dir):
logger.error("重命名旧配置目录 %s%s 失败", old_config_dir, new_config_dir)
return False
elif not QDir(new_config_dir).exists():
logger.info("初始化配置目录 %s", new_config_dir)
if not QDir().mkpath(new_config_dir):
logger.error("创建配置目录 %s 失败", new_config_dir)
return False
configInstance(new_config_dir)
return True
def initializeWebDriverManager(
) -> bool:
logger = logInstance().getLogger("AppInitializer")
app_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation)
driver_dir = os.path.join(app_dir, "drivers")
logger.info("初始化驱动目录 %s", driver_dir)
if not QDir(driver_dir).exists():
logger.error("创建驱动目录 %s 失败", driver_dir)
if not QDir().mkpath(driver_dir):
logger.error("创建驱动目录 %s 失败", driver_dir)
return False
webdriverInstance(driver_dir)
return True
def initializeApp(
) -> bool:
if not initializeLogManager():
return False
if not initializeConfigManager():
return False
if not initializeWebDriverManager():
return False
return True
+6
View File
@@ -0,0 +1,6 @@
"""
Boot module for the AutoLibrary project.
Here are the classes and modules in this package:
- AppInitializer: Application initializer class.
"""
+1 -1
View File
@@ -82,7 +82,7 @@ System architecture: {os_info['architecture']}<br>
<h4>Project Information:</h4>
License: MIT License<br>
Project repository: <a href="https://www.github.com/KenanZhu/AutoLibrary" style="text-decoration: none;">https://www.github.com/KenanZhu/AutoLibrary</a><br>
Project website: <a href="https://www.autolibrary.top" style="text-decoration: none;">https://www.autolibrary.top</a><br>
Project website: <a href="https://www.autolibrary.kenanzhu.com" style="text-decoration: none;">https://www.autolibrary.kenanzhu.com</a><br>
<h4>Author Information:</h4>
Developer: KenanZhu<br>
+25 -9
View File
@@ -8,7 +8,6 @@ You may use, modify, and distribute this file under the terms of the MIT License
See the LICENSE file for details.
"""
import os
import sys
from PySide6.QtCore import (
Qt, Signal, Slot, QTime, QDate, QDir, QFileInfo
@@ -21,15 +20,16 @@ from PySide6.QtGui import (
QCloseEvent, QAction
)
import managers.config.ConfigManager as ConfigManager
from utils.JSONReader import JSONReader
from utils.JSONWriter import JSONWriter
from utils.ConfigManager import ConfigType, instance
from utils.ConfigManager import getValidateAutomationConfigPaths
from gui.resources.ui.Ui_ALConfigWidget import Ui_ALConfigWidget
from gui.ALSeatMapSelectDialog import ALSeatMapSelectDialog
from gui.ALSeatMapTable import ALSeatMapTable
from gui.ALUserTreeWidget import ALUserTreeWidget, ALUserTreeItemType
from gui.ALWebDriverDownloadDialog import ALWebDriverDownloadDialog
class ALConfigWidget(QWidget, Ui_ALConfigWidget):
@@ -42,8 +42,8 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
):
super().__init__(parent)
self.__cfg_mgr = instance()
self.__config_paths = getValidateAutomationConfigPaths()
self.__cfg_mgr = ConfigManager.instance()
self.__config_paths = ConfigManager.getValidateAutomationConfigPaths()
self.__config_data = {"run": {}, "user": {}}
self.setupUi(self)
@@ -81,6 +81,7 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
self.AddUserButton.clicked.connect(self.onAddUserButtonClicked)
self.DelUserButton.clicked.connect(self.onDelUserButtonClicked)
self.BrowseBrowserDriverButton.clicked.connect(self.onBrowseBrowserDriverButtonClicked)
self.AutoDownloadWebDriverButton.clicked.connect(self.onAutoDownloadWebDriverButtonClicked)
self.BrowseCurrentRunConfigButton.clicked.connect(self.onBrowseCurrentRunConfigButtonClicked)
self.BrowseCurrentUserConfigButton.clicked.connect(self.onBrowseCurrentUserConfigButtonClicked)
self.BrowseExportRunConfigButton.clicked.connect(self.onBrowseExportRunConfigButtonClicked)
@@ -949,6 +950,21 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
if browser_driver_path:
self.BrowseBrowserDriverEdit.setText(QDir.toNativeSeparators(browser_driver_path))
@Slot()
def onAutoDownloadWebDriverButtonClicked(
self
):
dialog = ALWebDriverDownloadDialog(self)
dialog.show()
dialog.exec_()
selected_driver_info = dialog.getSelectedDriverInfo()
if selected_driver_info and selected_driver_info.driver_path:
self.BrowserTypeComboBox.setCurrentText(selected_driver_info.driver_type.value)
self.BrowseBrowserDriverEdit.setText(QDir.toNativeSeparators(str(selected_driver_info.driver_path)))
@Slot()
def onBrowseCurrentRunConfigButtonClicked(
self
@@ -968,13 +984,13 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
self.setRunConfigToWidget(data)
self.__config_paths["run"] = run_config_path
self.CurrentRunConfigEdit.setText(run_config_path)
paths = self.__cfg_mgr.get(ConfigType.GLOBAL, "automation.run_path.paths", [])
paths = self.__cfg_mgr.get(ConfigManager.ConfigType.GLOBAL, "automation.run_path.paths", [])
if run_config_path not in paths:
paths.append(run_config_path)
index = len(paths) - 1
else:
index = paths.index(run_config_path)
self.__cfg_mgr.set(ConfigType.GLOBAL, "automation.run_path", {"current": index, "paths": paths})
self.__cfg_mgr.set(ConfigManager.ConfigType.GLOBAL, "automation.run_path", {"current": index, "paths": paths})
else:
QMessageBox.warning(
self,
@@ -1003,13 +1019,13 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
self.setUsersToTreeWidget(data)
self.__config_paths["user"] = user_config_path
self.CurrentUserConfigEdit.setText(user_config_path)
paths = self.__cfg_mgr.get(ConfigType.GLOBAL, "automation.user_path.paths", [])
paths = self.__cfg_mgr.get(ConfigManager.ConfigType.GLOBAL, "automation.user_path.paths", [])
if user_config_path not in paths:
paths.append(user_config_path)
index = len(paths) - 1
else:
index = paths.index(user_config_path)
self.__cfg_mgr.set(ConfigType.GLOBAL, "automation.user_path", {"current": index, "paths": paths})
self.__cfg_mgr.set(ConfigManager.ConfigType.GLOBAL, "automation.user_path", {"current": index, "paths": paths})
else:
QMessageBox.warning(
self,
+24 -17
View File
@@ -7,11 +7,10 @@ This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
import os
import queue
from PySide6.QtCore import (
Qt, Signal, Slot, QTimer, QDir, QUrl,
Qt, Signal, Slot, QTimer, QUrl,
)
from PySide6.QtWidgets import (
QMainWindow, QMenu, QSystemTrayIcon, QMessageBox
@@ -20,10 +19,9 @@ from PySide6.QtGui import (
QTextCursor, QCloseEvent, QFont, QIcon, QDesktopServices
)
from base.MsgBase import MsgBase
import managers.config.ConfigManager as ConfigManager
from utils.ConfigManager import ConfigType, instance
from utils.ConfigManager import getValidateAutomationConfigPaths
from base.MsgBase import MsgBase
from gui.resources.ui.Ui_ALMainWindow import Ui_ALMainWindow
from gui.resources import ALResource
@@ -46,9 +44,9 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
MsgBase.__init__(self, queue.Queue(), queue.Queue())
QMainWindow.__init__(self)
self.__cfg_mgr = instance()
self.__cfg_mgr = ConfigManager.instance()
self.__timer_task_queue = queue.Queue()
self.__config_paths = getValidateAutomationConfigPaths()
self.__config_paths = ConfigManager.getValidateAutomationConfigPaths()
self.__alTimerTaskManageWidget = None
self.__alConfigWidget = None
self.__auto_lib_thread = None
@@ -61,6 +59,7 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.connectSignals()
self.startMsgPolling()
self.startTimerTaskPolling()
self._showLog("主窗口初始化完成")
def modifyUi(
@@ -106,7 +105,7 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self
):
url = QUrl("https://www.autolibrary.top/manuals")
url = QUrl("https://www.autolibrary.kenanzhu.com/manuals")
QDesktopServices.openUrl(url)
@@ -115,9 +114,7 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
):
if not QSystemTrayIcon.isSystemTrayAvailable():
self.showTraceSignal.emit(
"系统不支持系统托盘功能, 无法创建系统托盘图标。"
)
self._showTrace("操作系统不支持系统托盘功能, 无法创建系统托盘图标", self.TraceLevel.WARNING)
return
self.TrayIcon = QSystemTrayIcon(self.icon, self)
self.TrayIcon.setToolTip("AutoLibrary")
@@ -130,7 +127,6 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.TrayMenu.addAction("退出", self.close)
self.TrayIcon.setContextMenu(self.TrayMenu)
self.TrayIcon.setContextMenu(self.TrayMenu)
self.TrayIcon.activated.connect(self.onTrayIconActivated)
self.TrayIcon.show()
@@ -174,6 +170,10 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
event: QCloseEvent
):
if not self.isVisible():
self.showNormal()
event.ignore()
return
if self.__msg_queue_timer and self.__msg_queue_timer.isActive():
self.__msg_queue_timer.stop()
if self.__timer_task_timer and self.__timer_task_timer.isActive():
@@ -187,7 +187,8 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
if self.__alConfigWidget:
self.__alConfigWidget.close()
# the config widget is already deleted in the 'self.onConfigWidgetClosed'
super().closeEvent(event)
self._showLog("主窗口关闭")
QMainWindow.closeEvent(self, event)
def appendToTextEdit(
@@ -299,7 +300,9 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.__alConfigWidget.configWidgetIsClosed.disconnect(self.onConfigWidgetClosed)
self.__alConfigWidget.deleteLater()
self.__alConfigWidget = None
self.__config_paths = ConfigManager.getValidateAutomationConfigPaths()
self.setControlButtons(True, None, None)
self._showLog("配置窗口已关闭,配置文件路径已更新")
@Slot(dict)
def onTimerTaskIsReady(
@@ -327,11 +330,11 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.TrayIcon.showMessage(
"定时任务 - AutoLibrary",
f"\n定时任务 '{timer_task['name']}' 执行{'失败' if is_error else '完成'}",
QSystemTrayIcon.MessageIcon.Information,
QSystemTrayIcon.MessageIcon.Warning if is_error else QSystemTrayIcon.MessageIcon.Information,
1000
)
self._showTrace(
f"定时任务 {timer_task['name']} 执行{'失败' if is_error else '完成'}, uuid: {timer_task['task_uuid']}"
f"定时任务 {timer_task['name']} 执行{'失败' if is_error else '完成'}, uuid: {timer_task['uuid']}"
)
if not is_error:
self.timerTaskIsExecuted.emit(timer_task)
@@ -347,6 +350,7 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.__alTimerTaskManageWidget.raise_()
self.__alTimerTaskManageWidget.activateWindow()
self.TimerTaskManageWidgetButton.setEnabled(False)
self._showLog("打开定时任务管理窗口")
@Slot()
def onConfigButtonClicked(
@@ -360,6 +364,7 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.__alConfigWidget.raise_()
self.__alConfigWidget.activateWindow()
self.ConfigButton.setEnabled(False)
self._showLog("打开配置窗口")
@Slot()
def onStartButtonClicked(
@@ -376,6 +381,7 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.__auto_lib_thread.autoLibWorkerIsFinished.connect(self.onStopButtonClicked)
self.__auto_lib_thread.autoLibWorkerFinishedWithError.connect(self.onStopButtonClicked)
self.__auto_lib_thread.start()
self._showLog("开始手动执行任务")
@Slot()
def onStopButtonClicked(
@@ -383,14 +389,15 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
):
if self.__auto_lib_thread:
self._showTrace("正在停止操作......")
self._showTrace("正在停止操作......", no_log=True)
self.__auto_lib_thread.wait(2000)
self._showTrace("操作已停止")
self._showTrace("操作已停止", no_log=True)
self.__auto_lib_thread.autoLibWorkerIsFinished.disconnect(self.onStopButtonClicked)
self.__auto_lib_thread.autoLibWorkerFinishedWithError.disconnect(self.onStopButtonClicked)
self.__auto_lib_thread.deleteLater()
self.__auto_lib_thread = None
self.setControlButtons(None, False, True)
self._showLog("任务已停止")
@Slot()
def onSendButtonClicked(
+29 -11
View File
@@ -44,9 +44,11 @@ class AutoLibWorker(MsgBase, QThread):
current_time = time.strftime("%H:%M", time.localtime())
if current_time >= "23:30" or current_time <= "07:30":
self._showTrace(
"当前时间不在图书馆开放时间内, 请在 07:30 - 23:30 之间尝试"
"当前时间不在图书馆开放时间内, 请在 07:30 - 23:30 之间尝试",
self.TraceLevel.WARNING
)
return False
self._showLog(f"时间检查通过, 当前时间: {current_time}", self.TraceLevel.INFO)
return True
@@ -57,8 +59,12 @@ class AutoLibWorker(MsgBase, QThread):
if not all(
os.path.exists(path) for path in self.__config_paths.values()
):
self._showTrace("配置文件路径不存在, 请检查配置文件路径是否正确")
self._showTrace(
"配置文件路径不存在, 请检查配置文件路径是否正确",
self.TraceLevel.ERROR
)
return False
self._showLog(f"配置文件路径检查通过, 路径: {self.__config_paths}", self.TraceLevel.INFO)
return True
@@ -67,22 +73,28 @@ class AutoLibWorker(MsgBase, QThread):
) -> bool:
self._showTrace(
f"正在加载配置文件, 运行配置文件路径: {self.__config_paths["run"]}"
f"正在加载配置文件, 运行配置文件路径: {self.__config_paths["run"]}",
no_log=True
)
self.__run_config = JSONReader(self.__config_paths["run"]).data()
self._showTrace(
f"正在加载配置文件, 用户配置文件路径: {self.__config_paths["user"]}"
f"正在加载配置文件, 用户配置文件路径: {self.__config_paths["user"]}",
no_log=True
)
self.__user_config = JSONReader(self.__config_paths["user"]).data()
if self.__run_config is None or self.__user_config is None:
self._showTrace("配置文件加载失败, 请检查配置文件是否正确")
self._showTrace("配置文件加载失败, 请检查配置文件是否正确")
self._showTrace(
"配置文件加载失败, 请检查配置文件是否正确",
self.TraceLevel.ERROR
)
return False
if not self.__user_config.get("groups"):
self._showTrace(
"用户配置文件中无有效任务组, 请检查用户配置文件是否正确"
"用户配置文件中无有效任务组, 请检查用户配置文件是否正确",
self.TraceLevel.WARNING
)
return False
self._showLog(f"配置文件加载成功, 任务组数量: {len(self.__user_config.get('groups', []))}", self.TraceLevel.INFO)
return True
@@ -108,14 +120,17 @@ class AutoLibWorker(MsgBase, QThread):
groups = self.__user_config.get("groups")
for group in groups:
if not group["enabled"]:
self._showTrace(f"任务组 {group["name"]} 已跳过")
self._showTrace(f"任务组 {group["name"]} 已跳过", no_log=True)
continue
self._showTrace(f"正在运行任务组 {group["name"]}")
self._showTrace(f"正在运行任务组 {group["name"]}", no_log=True)
auto_lib.run(
{ "users": group.get("users", []) }
)
except Exception as e:
self._showTrace(f"AutoLibrary 运行时发生异常 : {e}")
self._showTrace(
f"AutoLibrary 运行时发生异常 : {e}",
self.TraceLevel.ERROR
)
self.autoLibWorkerFinishedWithError.emit()
return
if auto_lib:
@@ -154,7 +169,10 @@ class TimerTaskWorker(AutoLibWorker):
self
):
self._showTrace(f"定时任务 {self.__timer_task['name']} 运行时发生异常")
self._showTrace(
f"定时任务 {self.__timer_task['name']} 运行时发生异常",
self.TraceLevel.ERROR
)
self.timerTaskWorkerIsFinished.emit(True, self.__timer_task)
@Slot()
+246
View File
@@ -0,0 +1,246 @@
from enum import Enum
from PySide6.QtWidgets import (
QLabel
)
from PySide6.QtCore import (
Qt, Property, QPropertyAnimation, QEasingCurve
)
from PySide6.QtGui import (
QPainter, QColor, QConicalGradient, QPalette
)
class ALStatusLabel(QLabel):
class Status(Enum):
"""
Enum class for representing the status of ALStatusLabel.
"""
WAITING = 0
RUNNING = 1
SUCCESS = 2
WARNING = 3
FAILURE = 4
def __init__(
self,
parent = None
):
super().__init__(parent)
self.__status = self.Status.WAITING
self.__icon_angle = 0
self.setupUi()
def setupUi(
self
):
self.setFixedSize(36, 36)
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.RunningAnimation = QPropertyAnimation(self, b"iconAngle")
self.RunningAnimation.setDuration(1000)
self.RunningAnimation.setStartValue(0)
self.RunningAnimation.setEndValue(-360)
self.RunningAnimation.setLoopCount(-1)
self.RunningAnimation.setEasingCurve(QEasingCurve.Type.Linear)
def isDarkMode(
self
) -> bool:
return self.palette().color(QPalette.ColorRole.Window).value() < 128
def getMarkColor(
self
) -> QColor:
return QColor("#FFFFFF") if self.isDarkMode() else QColor("#454545")
@Property(Status)
def status(
self
) -> Status:
return self.__status
@Property(int)
def iconAngle(
self
) -> int:
return self.__icon_angle
@status.setter
def status(
self,
status: Status
):
if status not in self.Status:
raise ValueError(f"Invalid (class)Status[enum.Enum] value: {status}")
self.__status = status
if self.__status == self.Status.RUNNING:
self.RunningAnimation.start()
else:
self.RunningAnimation.stop()
self.update()
@iconAngle.setter
def iconAngle(
self,
value: int
):
self.__icon_angle = value
self.update()
def paintEvent(
self,
event
):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
center_x = self.width()/2
center_y = self.height()/2
radius = min(center_x, center_y) - 3
match self.__status:
case self.Status.WAITING:
pen = painter.pen()
pen.setWidth(2)
pen.setBrush(Qt.BrushStyle.NoBrush)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setColor(QColor("#969696")) # grey
painter.setPen(pen)
painter.drawEllipse(
int(center_x - radius),
int(center_y - radius),
int(radius*2),
int(radius*2)
)
case self.Status.RUNNING:
gradient = QConicalGradient(center_x, center_y, self.__icon_angle)
gradient.setColorAt(0.0, QColor("#2294FF" if self.isDarkMode() else "#0094FF"))
gradient.setColorAt(1.0, QColor("#2294FF00"))
pen = painter.pen()
pen.setWidth(3)
pen.setBrush(gradient)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
painter.drawEllipse(
int(center_x - radius),
int(center_y - radius),
int(radius*2),
int(radius*2)
)
case self.Status.SUCCESS:
# draw the success green circle
pen = painter.pen()
pen.setWidth(2)
pen.setBrush(Qt.BrushStyle.NoBrush)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setColor(QColor("#4CAF50" if self.isDarkMode() else "#00AF50")) # green
painter.setPen(pen)
painter.drawEllipse(
int(center_x - radius),
int(center_y - radius),
int(radius*2),
int(radius*2)
)
# draw the success check mark '✓'
painter.setPen(Qt.PenStyle.SolidLine)
pen = painter.pen()
pen.setWidth(3)
pen.setBrush(Qt.BrushStyle.NoBrush)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
# white when dark mode, black when light mode
pen.setColor(self.getMarkColor())
painter.setPen(pen)
mark_size = radius/2
mark_path = [
(center_x - mark_size, center_y),
(center_x - mark_size/3, center_y + mark_size/2),
(center_x + mark_size, center_y - mark_size/2)
]
painter.drawLine(
int(mark_path[0][0]),int(mark_path[0][1]),
int(mark_path[1][0]),int(mark_path[1][1])
)
painter.drawLine(
int(mark_path[1][0]),int(mark_path[1][1]),
int(mark_path[2][0]),int(mark_path[2][1])
)
case self.Status.WARNING:
# draw the warning orange circle
pen = painter.pen()
pen.setWidth(2)
pen.setBrush(Qt.BrushStyle.NoBrush)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setColor(QColor("#FF9800")) # orange
painter.setPen(pen)
painter.drawEllipse(
int(center_x - radius),
int(center_y - radius),
int(radius*2),
int(radius*2)
)
# draw the warning exclamation mark '!'
painter.setPen(Qt.PenStyle.SolidLine)
pen = painter.pen()
pen.setWidth(3)
pen.setBrush(Qt.BrushStyle.NoBrush)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
# white when dark mode, black when light mode
pen.setColor(self.getMarkColor())
painter.setPen(pen)
painter.drawLine(
int(center_x), int(center_y - radius/2),
int(center_x), int(center_y + radius/6)
)
painter.drawPoint(
int(center_x), int(center_y + radius/2)
)
case self.Status.FAILURE:
# draw the failure red circle
pen = painter.pen()
pen.setWidth(2)
pen.setBrush(Qt.BrushStyle.NoBrush)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setColor(QColor("#DC0000")) # red
painter.setPen(pen)
painter.drawEllipse(
int(center_x - radius),
int(center_y - radius),
int(radius*2),
int(radius*2)
)
# draw the failure cross mark '✗'
painter.setPen(Qt.PenStyle.SolidLine)
pen = painter.pen()
pen.setWidth(3)
pen.setBrush(Qt.BrushStyle.NoBrush)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
# white when dark mode, black when light mode
pen.setColor(self.getMarkColor())
painter.setPen(pen)
mark_size = radius/3
painter.drawLine(
int(center_x - mark_size), int(center_y - mark_size),
int(center_x + mark_size), int(center_y + mark_size)
)
painter.drawLine(
int(center_x + mark_size), int(center_y - mark_size),
int(center_x - mark_size), int(center_y + mark_size)
)
painter.end()
super().paintEvent(event)
+61 -20
View File
@@ -12,15 +12,11 @@ import uuid
from enum import Enum
from datetime import datetime, timedelta
from PySide6.QtCore import (
Slot, QDateTime
)
from PySide6.QtWidgets import (
QLabel, QDialog, QWidget, QSpinBox,
QHBoxLayout, QGridLayout, QDateTimeEdit
)
from PySide6.QtCore import Slot, QDateTime
from PySide6.QtWidgets import QLabel, QDialog, QWidget, QSpinBox, QHBoxLayout, QGridLayout, QDateTimeEdit
from gui.resources.ui.Ui_ALTimerTaskAddDialog import Ui_ALTimerTaskAddDialog
import utils.TimerUtils as TimerUtils
class ALTimerTaskStatus(Enum):
@@ -31,6 +27,7 @@ class ALTimerTaskStatus(Enum):
EXECUTED = "已执行"
ERROR = "执行失败"
OUTDATED = "已过期"
UNKNOWN = "未知"
class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
@@ -43,8 +40,8 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
super().__init__(parent)
self.setupUi(self)
self.connectSignals()
self.modifyUi()
self.connectSignals()
def modifyUi(
@@ -64,28 +61,28 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
self.TimerConfigLayout.addWidget(self.SpecificTimerWidget)
self.RelativeTimerWidget = QWidget()
self.RelativeTimerLayout = QGridLayout(self.RelativeTimerWidget)
self.RelativeTimerLayout.addWidget(QLabel("相对时间:"), 0, 0)
self.RelativeTimerLayout = QHBoxLayout(self.RelativeTimerWidget)
self.RelativeTimerLayout.addWidget(QLabel("相对时间:"))
self.RelativeDaySpinBox = QSpinBox()
self.RelativeDaySpinBox.setMinimum(0)
self.RelativeDaySpinBox.setMaximum(365)
self.RelativeDaySpinBox.setMaximum(364)
self.RelativeDaySpinBox.setSuffix("")
self.RelativeTimerLayout.addWidget(self.RelativeDaySpinBox, 1, 0)
self.RelativeTimerLayout.addWidget(self.RelativeDaySpinBox)
self.RelativeHourSpinBox = QSpinBox()
self.RelativeHourSpinBox.setMinimum(0)
self.RelativeHourSpinBox.setMaximum(23)
self.RelativeHourSpinBox.setSuffix("")
self.RelativeTimerLayout.addWidget(self.RelativeHourSpinBox, 1, 1)
self.RelativeTimerLayout.addWidget(self.RelativeHourSpinBox)
self.RelativeMinuteSpinBox = QSpinBox()
self.RelativeMinuteSpinBox.setMinimum(0)
self.RelativeMinuteSpinBox.setMaximum(59)
self.RelativeMinuteSpinBox.setSuffix("")
self.RelativeTimerLayout.addWidget(self.RelativeMinuteSpinBox, 1, 2)
self.RelativeTimerLayout.addWidget(self.RelativeMinuteSpinBox)
self.RelativeSecondSpinBox = QSpinBox()
self.RelativeSecondSpinBox.setMinimum(0)
self.RelativeSecondSpinBox.setMaximum(59)
self.RelativeSecondSpinBox.setSuffix("")
self.RelativeTimerLayout.addWidget(self.RelativeSecondSpinBox, 1, 3)
self.RelativeTimerLayout.addWidget(self.RelativeSecondSpinBox)
self.TimerConfigLayout.addWidget(self.RelativeTimerWidget)
self.RelativeTimerWidget.setVisible(False)
@@ -97,6 +94,7 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
self.CancelButton.clicked.connect(self.reject)
self.ConfirmButton.clicked.connect(self.accept)
self.TimerTypeComboBox.currentIndexChanged.connect(self.onTimerTypeComboBoxIndexChanged)
self.RepeatCheckBox.toggled.connect(self.onRepeatCheckBoxToggled)
def getTimerTask(
@@ -121,17 +119,46 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
minutes = self.RelativeMinuteSpinBox.value(),
seconds = self.RelativeSecondSpinBox.value()
)
return {
task_data = {
"name": name,
"task_uuid": uuid.uuid4().hex.upper() + f"-{added_time.strftime("%Y%m%d%H%M%S")}",
"uuid": uuid.uuid4().hex.upper() + f"-{added_time.strftime("%Y%m%d%H%M%S")}",
"time_type": self.TimerTypeComboBox.currentText(),
"execute_time": execute_time,
"silent": silent,
"add_time": added_time,
"added_time": added_time,
"status": ALTimerTaskStatus.PENDING,
"executed": False
"executed": False,
"repeat": self.RepeatCheckBox.isChecked(),
}
if task_data["repeat"]:
repeat_days = []
if self.MonCheckBox.isChecked():
repeat_days.append(0)
if self.TueCheckBox.isChecked():
repeat_days.append(1)
if self.WedCheckBox.isChecked():
repeat_days.append(2)
if self.ThuCheckBox.isChecked():
repeat_days.append(3)
if self.FriCheckBox.isChecked():
repeat_days.append(4)
if self.SatCheckBox.isChecked():
repeat_days.append(5)
if self.SunCheckBox.isChecked():
repeat_days.append(6)
if not repeat_days:
repeat_days = [0, 1, 2, 3, 4, 5, 6]
task_data["repeat_days"] = repeat_days
task_data["repeat_hour"] = execute_time.hour
task_data["repeat_minute"] = execute_time.minute
task_data["repeat_second"] = execute_time.second
task_data["execute_time"] = TimerUtils.calculateNextRepeatTime(
task_data["repeat_days"],
task_data["repeat_hour"],
task_data["repeat_minute"],
task_data["repeat_second"]
)
return task_data
@Slot(int)
def onTimerTypeComboBoxIndexChanged(
@@ -141,3 +168,17 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
self.SpecificTimerWidget.setVisible(index == 0)
self.RelativeTimerWidget.setVisible(index == 1)
@Slot(bool)
def onRepeatCheckBoxToggled(
self,
checked: bool
):
self.MonCheckBox.setEnabled(checked)
self.TueCheckBox.setEnabled(checked)
self.WedCheckBox.setEnabled(checked)
self.ThuCheckBox.setEnabled(checked)
self.FriCheckBox.setEnabled(checked)
self.SatCheckBox.setEnabled(checked)
self.SunCheckBox.setEnabled(checked)
+147
View File
@@ -0,0 +1,147 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
from datetime import datetime
from PySide6.QtCore import Slot, Qt
from PySide6.QtWidgets import (
QDialog, QTableWidget, QTableWidgetItem,
QVBoxLayout, QHBoxLayout, QGridLayout,
QPushButton, QLabel, QHeaderView
)
from gui.ALTimerTaskAddDialog import ALTimerTaskStatus
class ALTimerTaskHistoryDialog(QDialog):
def __init__(
self,
parent = None,
task_data: dict = None
):
super().__init__(parent)
self.__task_data = task_data
self.__history = task_data.get("history", [])
self.modifyUi()
self.connectSignals()
def modifyUi(
self
):
self.setWindowTitle("定时任务执行历史 - AutoLibrary")
self.setMinimumSize(300, 300)
self.setMaximumSize(500, 400)
MainLayout = QVBoxLayout(self)
InfoLayout = QGridLayout()
TaskNameLabel = QLabel(f"任务: {self.__task_data.get('name', '未命名')}")
TaskNameLabel.setStyleSheet("font-weight: bold; font-size: 12px;")
InfoLayout.addWidget(TaskNameLabel, 0, 0)
TaskUUIDLabel = QLabel(f"UUID: {self.__task_data.get('uuid', '未命名')}")
TaskUUIDLabel.setStyleSheet("color: #969696; font-size: 11px;")
InfoLayout.addWidget(TaskUUIDLabel, 1, 0)
InfoLayout.setColumnStretch(0, 1)
if self.__task_data.get("repeat", False):
RepeatLabel = QLabel("可重复性任务")
RepeatLabel.setStyleSheet("color: #2294FF; font-size: 12px;")
InfoLayout.addWidget(RepeatLabel, 0, 1)
MainLayout.addLayout(InfoLayout)
self.HistoryTableWidget = QTableWidget()
self.HistoryTableWidget.setColumnCount(3)
self.HistoryTableWidget.setHorizontalHeaderLabels(["执行时间", "结果", "耗时(秒/s"])
self.HistoryTableWidget.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
self.HistoryTableWidget.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
self.HistoryTableWidget.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
self.HistoryTableWidget.verticalHeader().setVisible(False)
self.HistoryTableWidget.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
self.HistoryTableWidget.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.loadHistory()
MainLayout.addWidget(self.HistoryTableWidget)
ButtonLayout = QHBoxLayout()
ButtonLayout.addStretch()
self.CloseButton = QPushButton("关闭")
self.CloseButton.setFixedSize(80, 25)
self.CloseButton.setDefault(True)
self.ClearHistoryButton = QPushButton("清空历史")
self.ClearHistoryButton.setFixedSize(80, 25)
self.ClearHistoryButton.setStyleSheet("color: #DC0000;")
ButtonLayout.addWidget(self.ClearHistoryButton)
ButtonLayout.addWidget(self.CloseButton)
MainLayout.addLayout(ButtonLayout)
def connectSignals(
self
):
self.CloseButton.clicked.connect(self.accept)
self.ClearHistoryButton.clicked.connect(self.onClearHistoryButtonClicked)
def loadHistory(
self
):
self.HistoryTableWidget.setRowCount(len(self.__history))
for row, record in enumerate(self.__history):
self.addHistoryRow(row, record)
def addHistoryRow(
self,
row: int,
record: dict
):
execute_time = record.get("execute_time", "")
result = record.get("result", ALTimerTaskStatus.UNKNOWN)
duration = record.get("duration", 0)
ExecuteTimeItem = QTableWidgetItem(execute_time)
ExecuteTimeItem.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.HistoryTableWidget.setItem(row, 0, ExecuteTimeItem)
ResultItem = QTableWidgetItem(result.value)
ResultItem.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
match result:
case ALTimerTaskStatus.EXECUTED:
ResultItem.setForeground(Qt.GlobalColor.green)
case ALTimerTaskStatus.ERROR:
ResultItem.setForeground(Qt.GlobalColor.red)
case ALTimerTaskStatus.OUTDATED:
ResultItem.setForeground(Qt.GlobalColor.red)
case _:
ResultItem.setForeground(Qt.GlobalColor.black)
self.HistoryTableWidget.setItem(row, 1, ResultItem)
DurationItem = QTableWidgetItem(f"{duration:.2f}")
DurationItem.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.HistoryTableWidget.setItem(row, 2, DurationItem)
self.HistoryTableWidget.setRowHeight(row, 25)
@Slot()
def onClearHistoryButtonClicked(
self
):
self.__history.clear()
self.HistoryTableWidget.setRowCount(0)
self.__task_data["history"] = self.__history
def getHistory(
self
) -> list:
return self.__history
+187 -23
View File
@@ -15,7 +15,7 @@ from enum import Enum
from datetime import datetime, timedelta
from PySide6.QtCore import (
Qt, Signal, Slot, QTimer, QFileInfo, QDir
Qt, Signal, Slot, QTimer
)
from PySide6.QtWidgets import (
QDialog, QWidget, QListWidgetItem, QMessageBox,
@@ -25,10 +25,12 @@ from PySide6.QtGui import (
QCloseEvent
)
from utils.ConfigManager import ConfigType, instance
import managers.config.ConfigManager as ConfigManager
import utils.TimerUtils as TimerUtils
from gui.resources.ui.Ui_ALTimerTaskManageWidget import Ui_ALTimerTaskManageWidget
from gui.ALTimerTaskAddDialog import ALTimerTaskAddDialog, ALTimerTaskStatus
from gui.ALTimerTaskHistoryDialog import ALTimerTaskHistoryDialog
class ALTimerTaskItemWidget(QWidget):
@@ -61,13 +63,25 @@ class ALTimerTaskItemWidget(QWidget):
TaskNameLabel.setFont(TaskNameLabelFont)
TaskNameLabel.setFixedHeight(25)
self.TaskInfoLayout.addWidget(TaskNameLabel)
ExecuteTimeStr = self.__timer_task["execute_time"].strftime("%Y-%m-%d %H:%M:%S")
ExecuteTimeLabel = QLabel(f"执行时间: {ExecuteTimeStr}")
if self.__timer_task.get("repeat", False):
repeat_days = self.__timer_task.get("repeat_days", [])
repeat_hour = self.__timer_task.get("repeat_hour", 0)
repeat_minute = self.__timer_task.get("repeat_minute", 0)
repeat_second = self.__timer_task.get("repeat_second", 0)
if len(repeat_days) == 7:
time_str = f"{repeat_hour:02d}:{repeat_minute:02d}:{repeat_second:02d}"
ExecuteTimeLabel = QLabel(f"下次执行时间: {ExecuteTimeStr} (每日 {time_str})")
else:
day_names = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
selected_days = [day_names[d] for d in repeat_days]
time_str = f"{repeat_hour:02d}:{repeat_minute:02d}:{repeat_second:02d}"
ExecuteTimeLabel = QLabel(f"下次执行时间: {ExecuteTimeStr} (每{','.join(selected_days)} {time_str})")
else:
ExecuteTimeLabel = QLabel(f"执行时间: {ExecuteTimeStr}")
ExecuteTimeLabel.setStyleSheet("color: #969696;")
ExecuteTimeLabel.setFixedHeight(20)
self.TaskInfoLayout.addWidget(ExecuteTimeLabel)
self.ItemWidgetLayout.addLayout(self.TaskInfoLayout)
self.ItemWidgetLayout.addStretch()
@@ -118,8 +132,13 @@ class ALTimerTaskItemWidget(QWidget):
TaskModeLabel.setFixedSize(60, 25)
self.ItemWidgetLayout.addWidget(TaskModeLabel)
if self.__timer_task.get("repeat", False):
self.HistoryButton = QPushButton("历史")
self.HistoryButton.setFixedSize(80, 25)
self.ItemWidgetLayout.addWidget(self.HistoryButton)
self.DeleteButton = QPushButton("删除")
self.DeleteButton.setFixedSize(80, 25)
self.DeleteButton.setStyleSheet("color: #DC0000;")
self.ItemWidgetLayout.addWidget(self.DeleteButton)
if self.__timer_task["status"] == ALTimerTaskStatus.READY\
or self.__timer_task["status"] == ALTimerTaskStatus.RUNNING:
@@ -145,7 +164,7 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
):
super().__init__(parent)
self.__cfg_mgr = instance()
self.__cfg_mgr = ConfigManager.instance()
self.__timer_tasks = []
self.__check_timer = None
self.__sort_policy = self.SortPolicy.BY_EXECUTE_TIME
@@ -199,12 +218,15 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
) -> list:
try:
timer_tasks = self.__cfg_mgr.get(ConfigType.TIMERTASK)
timer_tasks = self.__cfg_mgr.get(ConfigManager.ConfigType.TIMERTASK)
if timer_tasks and "timer_tasks" in timer_tasks:
for task in timer_tasks["timer_tasks"]:
task["add_time"] = datetime.strptime(task["add_time"], "%Y-%m-%d %H:%M:%S")
task["added_time"] = datetime.strptime(task["added_time"], "%Y-%m-%d %H:%M:%S")
task["execute_time"] = datetime.strptime(task["execute_time"], "%Y-%m-%d %H:%M:%S")
task["status"] = ALTimerTaskStatus(task["status"])
if "history" in task:
for item in task["history"]:
item["result"] = ALTimerTaskStatus(item["result"])
return timer_tasks["timer_tasks"]
raise Exception("定时任务配置文件格式错误")
except Exception as e:
@@ -223,10 +245,13 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
try:
for task in timer_tasks:
task["add_time"] = task["add_time"].strftime("%Y-%m-%d %H:%M:%S")
task["added_time"] = task["added_time"].strftime("%Y-%m-%d %H:%M:%S")
task["execute_time"] = task["execute_time"].strftime("%Y-%m-%d %H:%M:%S")
task["status"] = task["status"].value
self.__cfg_mgr.set(ConfigType.TIMERTASK, "", { "timer_tasks": timer_tasks })
if "history" in task:
for item in task["history"]:
item["result"] = item["result"].value
self.__cfg_mgr.set(ConfigManager.ConfigType.TIMERTASK, "", { "timer_tasks": timer_tasks })
return True
except Exception as e:
QMessageBox.warning(
@@ -284,7 +309,7 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
)
elif policy == self.SortPolicy.BY_ADD_TIME:
self.__timer_tasks.sort(
key = lambda x: x["add_time"],
key = lambda x: x["added_time"],
reverse = order is Qt.SortOrder.DescendingOrder
)
elif policy == self.SortPolicy.BY_EXECUTE_TIME:
@@ -332,8 +357,12 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
item.setData(Qt.UserRole, timer_task)
widget = ALTimerTaskItemWidget(self, timer_task)
widget.DeleteButton.clicked.connect(
lambda _, uuid = timer_task["task_uuid"]: self.deleteTask(uuid)
lambda _, task = timer_task: self.deleteTask(task)
)
if timer_task.get("repeat", False) and hasattr(widget, "HistoryButton"):
widget.HistoryButton.clicked.connect(
lambda _, task = timer_task: self.showTaskHistory(task)
)
item.setSizeHint(widget.size())
self.TimerTasksListWidget.addItem(item)
self.TimerTasksListWidget.setItemWidget(item, widget)
@@ -349,15 +378,45 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
self.__timer_tasks.append(timer_task)
self.timerTasksChanged.emit()
@staticmethod
def getTimerTaskDetailMessage(
timer_task: dict
):
return (
f"任务名称:{timer_task["name"]}\n"
f"添加时间:{timer_task["added_time"]}\n"
f"当前状态:{timer_task["status"].value}\n"
f"下次执行时间:{datetime.strftime(timer_task["execute_time"], "%Y-%m-%d %H:%M:%S")}\n"
f"已记录次数:{len(timer_task['history'] if 'history' in timer_task else 0)}"
)
def deleteTask(
self,
task_uuid: str
timer_task: dict
):
if timer_task["repeat"]: # when delete a repeat task
msgbox = QMessageBox(self)
msgbox.setIcon(QMessageBox.Icon.Question)
msgbox.setWindowTitle("警告 - AutoLibrary")
msgbox.setStandardButtons(
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
msgbox.setText("删除可重复性任务将同时删除所有已执行的记录 !\n是否继续 ?")
msgbox.setDetailedText(
"以下可重复性任务将被删除:\n"\
"\n"
f"{self.getTimerTaskDetailMessage(timer_task)}"
)
result = msgbox.exec()
if result != QMessageBox.StandardButton.Yes:
return
task_uuid = timer_task["uuid"]
self.__timer_tasks = [
x for x in self.__timer_tasks
if x["task_uuid"] != task_uuid
if x["uuid"] != task_uuid
]
self.timerTasksChanged.emit()
@@ -374,8 +433,9 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
"是否要清除所有定时任务 ?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if result is QMessageBox.StandardButton.No:
if result == QMessageBox.StandardButton.No:
return
# READY and RUNNING tasks cannot be cleared
in_queue_tasks = [
x for x in self.__timer_tasks
if x["status"] == ALTimerTaskStatus.READY
@@ -386,12 +446,53 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
QMessageBox.warning(
self,
"警告 - AutoLibrary",
"存在正在执行或已就绪的队列任务无法清除所有定时任务 !"
f"存在 {in_queue_count}正在执行或已就绪的队列任务,无法清除所有定时任务 !"
)
self.__timer_tasks = in_queue_tasks
return
# repeat tasks ask before clear
repeat_tasks = [
x for x in self.__timer_tasks
if x.get("repeat", False)
]
repeat_tasks_count = len(repeat_tasks)
if repeat_tasks_count > 0:
msgbox = QMessageBox(self)
msgbox.setIcon(QMessageBox.Icon.Question)
msgbox.setWindowTitle("警告 - AutoLibrary")
msgbox.setStandardButtons(
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
msgbox.setText(
f"存在 {repeat_tasks_count} 个可重复性任务,\n"
"删除可重复性任务将同时删除所有已执行的记录 !\n"
"是否继续 ?"
)
delete_msgs = [
self.getTimerTaskDetailMessage(x) for x in repeat_tasks
]
msgbox.setDetailedText(
"以下可重复性任务将被删除:\n"\
"\n"
f"{"\n\n".join(delete_msgs)}"
)
result = msgbox.exec()
if result != QMessageBox.StandardButton.Yes:
return
# clear all tasks
self.__timer_tasks.clear()
self.timerTasksChanged.emit()
def showTaskHistory(
self,
task: dict
):
dialog = ALTimerTaskHistoryDialog(self, task)
if dialog.exec() == QDialog.DialogCode.Accepted:
self.timerTasksChanged.emit()
def checkTasks(
self
):
@@ -405,7 +506,10 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
if timer_task["status"] is not ALTimerTaskStatus.PENDING:
continue
if timer_task["execute_time"] <= now + timedelta(seconds = -5):
timer_task["status"] = ALTimerTaskStatus.OUTDATED
if timer_task.get("repeat", False):
self.onRepeatTimerTaskIs(ALTimerTaskStatus.OUTDATED, timer_task)
else:
timer_task["status"] = ALTimerTaskStatus.OUTDATED
need_update = True
else:
timer_task["status"] = ALTimerTaskStatus.READY
@@ -458,11 +562,63 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
):
for task in self.__timer_tasks:
if task["task_uuid"] == timer_task["task_uuid"]:
if task["uuid"] == timer_task["uuid"]:
task["status"] = ALTimerTaskStatus.RUNNING
break
self.timerTasksChanged.emit()
def onRepeatTimerTaskIs(
self,
status: ALTimerTaskStatus,
timer_task: dict
) -> dict:
# only these status are valid
valid_statuses = {ALTimerTaskStatus.EXECUTED, ALTimerTaskStatus.ERROR,
ALTimerTaskStatus.OUTDATED}
if status not in valid_statuses:
return timer_task
if "history" not in timer_task:
timer_task["history"] = []
if status != ALTimerTaskStatus.OUTDATED:
executed_time = datetime.now()
duration = (executed_time - timer_task["execute_time"]).total_seconds()
timer_task["history"].append({
"execute_time": timer_task["execute_time"].strftime("%Y-%m-%d %H:%M:%S"),
"executed_time": executed_time.strftime("%Y-%m-%d %H:%M:%S"),
"result": status,
"duration": duration,
"uuid": timer_task["uuid"]
})
else:
current_time = datetime.now()
execute_time = timer_task["execute_time"]
execute_weekday = execute_time.weekday()
delta_days = (current_time - execute_time).days
for i in range(delta_days + 1):
if (execute_weekday + i)%7 in timer_task["repeat_days"]:
timer_task["history"].append({
"execute_time": (execute_time + timedelta(days=i)).strftime("%Y-%m-%d %H:%M:%S"),
"executed_time": (execute_time + timedelta(days=i)).strftime("%Y-%m-%d %H:%M:%S"),
"result": status,
"duration": 0,
"uuid": timer_task["uuid"]
})
next_time = TimerUtils.calculateNextRepeatTime(
timer_task["repeat_days"],
timer_task["repeat_hour"],
timer_task["repeat_minute"],
timer_task["repeat_second"]
)
if next_time:
timer_task["execute_time"] = next_time
timer_task["status"] = ALTimerTaskStatus.PENDING
timer_task["executed"] = False
else:
timer_task["status"] = status
return timer_task
@Slot(dict)
def onTimerTaskIsExecuted(
self,
@@ -470,8 +626,12 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
):
for task in self.__timer_tasks:
if task["task_uuid"] == timer_task["task_uuid"]:
task["status"] = ALTimerTaskStatus.EXECUTED
if task["uuid"] == timer_task["uuid"]:
if task.get("repeat", False):
self.onRepeatTimerTaskIs(ALTimerTaskStatus.EXECUTED, task)
else:
task["status"] = ALTimerTaskStatus.EXECUTED
break
self.timerTasksChanged.emit()
@Slot(dict)
@@ -481,6 +641,10 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
):
for task in self.__timer_tasks:
if task["task_uuid"] == timer_task["task_uuid"]:
task["status"] = ALTimerTaskStatus.ERROR
if task["uuid"] == timer_task["uuid"]:
if task.get("repeat", False):
self.onRepeatTimerTaskIs(ALTimerTaskStatus.ERROR, task)
else:
task["status"] = ALTimerTaskStatus.ERROR
break
self.timerTasksChanged.emit()
+3 -3
View File
@@ -5,11 +5,11 @@
workflow process. Do not edit manually.
This file is auto-generated during the workflow process.
Last updated: 2026-02-26 15:04:28 UTC
Last updated: 2026-03-21 10:54:51 UTC
"""
AL_VERSION = "1.1.0"
AL_TAG = "v1.1.0"
AL_VERSION = "1.2.0"
AL_TAG = "v1.2.0"
AL_COMMIT_SHA = "local"
AL_COMMIT_DATE = "null" # time zone : UTC
AL_BUILD_DATE = "null" # time zone : UTC
+576
View File
@@ -0,0 +1,576 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
import threading
from typing import Optional
from PySide6.QtCore import (
Qt, Slot, QThread, Signal
)
from PySide6.QtWidgets import (
QDialog, QLabel, QComboBox, QProgressBar,
QPushButton, QVBoxLayout, QHBoxLayout,
QMessageBox, QFrame, QLineEdit
)
from PySide6.QtGui import (
QCloseEvent
)
from managers.driver.WebDriverManager import (
instance as webdriver_manager_instance,
WebDriverManager, WebDriverInfo, WebDriverType,
WebDriverStatus
)
from gui.ALStatusLabel import ALStatusLabel
class DownloadWorker(QThread):
"""
Worker thread for downloading web drivers.
"""
progress = Signal(float, int, float, str)
downloadFinished = Signal(object, str)
downloadError = Signal(str)
downloadCancelled = Signal()
def __init__(
self,
driver_manager: WebDriverManager,
driver_info: WebDriverInfo
):
super().__init__()
self.__driver_manager = driver_manager
self.__driver_info = driver_info
self.__driver_path = None
self.__cancelled = False
self.__cancel_event = threading.Event()
def cancel(
self
):
"""
Cancel the download operation.
"""
self.__cancelled = True
self.__cancel_event.set()
def run(
self
):
try:
if self.__cancelled:
self.downloadCancelled.emit()
return
self.__driver_path = self.__driver_manager.installDriver(
self.__driver_info,
progress_callback=self.onProgress,
cancel_event=self.__cancel_event
)
if self.__cancelled:
self.downloadCancelled.emit()
return
if self.__driver_path:
self.downloadFinished.emit(self.__driver_path, "")
else:
self.downloadError.emit("下载失败: 未返回有效路径")
except Exception as e:
if not self.__cancelled:
self.downloadError.emit(f"下载失败: {str(e)}")
def onProgress(
self,
downloaded: float,
total: int,
speed: float,
message: str
):
if self.__cancel_event.is_set():
self.__cancelled = True
if not self.__cancelled:
self.progress.emit(downloaded, total, speed, message)
def stop(
self
):
"""
Cancel and wait for the thread to finish.
Must only be called from the main thread.
"""
self.cancel()
if not self.isFinished():
if not self.wait(5000):
self.terminate()
self.wait()
class ALWebDriverDownloadDialog(QDialog):
def __init__(
self,
parent: Optional[QDialog] = None,
driver_dir: str = ""
):
"""
Web driver download dialog.
Args:
parent: Parent widget.
driver_dir: Driver directory path.
"""
super().__init__(parent)
self.__driver_dir = driver_dir
self.__driver_manager: Optional[WebDriverManager] = None
self.__confirmed = False
self.__selected_driver_info: Optional[WebDriverInfo] = None
self.__driver_infos: list[WebDriverInfo] = []
self.__download_thread: Optional[DownloadWorker] = None
self.setupUi()
self.connectSignals()
self.initializeDriverManager()
self.refreshDriverList()
def showEvent(
self,
event
):
result = super().showEvent(event)
if self.parent():
screen_rect = self.screen().geometry()
target_pos = self.parent().geometry().center()
target_pos.setX(target_pos.x() - self.width()//2)
target_pos.setY(target_pos.y() - self.height()//2)
if target_pos.x() < 0:
target_pos.setX(0)
if target_pos.x() + self.width() > screen_rect.width():
target_pos.setX(screen_rect.width() - self.width())
if target_pos.y() < 0:
target_pos.setY(0)
if target_pos.y() + self.height() > screen_rect.height():
target_pos.setY(screen_rect.height() - self.height())
self.move(target_pos)
return result
def setupUi(
self
):
self.setModal(True)
self.setMaximumHeight(240)
self.setMinimumHeight(240)
self.setWindowTitle("浏览器驱动下载 - AutoLibrary")
self.MainLayout = QVBoxLayout(self)
self.MainLayout.setContentsMargins(5, 5, 5, 5)
self.MainLayout.setSpacing(5)
self.BrowserCountLabel = QLabel("检测到 0 个可用浏览器:")
self.MainLayout.addWidget(self.BrowserCountLabel)
self.DriverInfoLayout = QHBoxLayout()
self.DriverInfoLayout.setSpacing(5)
self.DriverComboBox = QComboBox()
self.DriverInfoLayout.addWidget(self.DriverComboBox)
self.StatusLabel = ALStatusLabel()
self.StatusLabel.setFixedSize(32, 32)
self.DriverInfoLayout.addWidget(self.StatusLabel)
self.MainLayout.addLayout(self.DriverInfoLayout)
self.DetailLayout = QVBoxLayout()
self.DetailLayout.setSpacing(5)
self.DetailLayout.setContentsMargins(5, 5, 5, 5)
self.BrowserTypeLabel = QLabel("类型:")
self.DetailLayout.addWidget(self.BrowserTypeLabel)
self.VersionLabel = QLabel("版本:")
self.DetailLayout.addWidget(self.VersionLabel)
self.PathLabel = QLineEdit()
self.PathLabel.setReadOnly(True)
self.PathLabel.setText("路径:未安装")
self.DetailLayout.addWidget(self.PathLabel)
self.MainLayout.addLayout(self.DetailLayout)
self.Line = QFrame()
self.Line.setFrameShape(QFrame.Shape.HLine)
self.Line.setFrameShadow(QFrame.Shadow.Sunken)
self.MainLayout.addWidget(self.Line)
self.ProgressBar = QProgressBar()
self.ProgressBar.setValue(0)
self.ProgressBar.setTextVisible(False)
self.MainLayout.addWidget(self.ProgressBar)
self.ProgressText = QLabel("")
self.ProgressText.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.MainLayout.addWidget(self.ProgressText)
self.ControlLayout = QHBoxLayout()
self.ControlLayout.setSpacing(8)
self.ControlLayout.setAlignment(Qt.AlignmentFlag.AlignRight)
self.RefreshButton = QPushButton("刷新")
self.RefreshButton.setFixedSize(80, 25)
self.DownloadButton = QPushButton("下载驱动")
self.DownloadButton.setFixedSize(80, 25)
self.DeleteButton = QPushButton("删除驱动")
self.DeleteButton.setFixedSize(80, 25)
self.CancelButton = QPushButton("取消")
self.CancelButton.setFixedSize(80, 25)
self.ConfirmButton = QPushButton("确认")
self.ConfirmButton.setFixedSize(80, 25)
self.ConfirmButton.setEnabled(False)
self.ControlLayout.addWidget(self.RefreshButton)
self.ControlLayout.addWidget(self.DownloadButton)
self.ControlLayout.addWidget(self.DeleteButton)
self.ControlLayout.addWidget(self.CancelButton)
self.ControlLayout.addWidget(self.ConfirmButton)
self.MainLayout.addLayout(self.ControlLayout)
def connectSignals(
self
):
self.RefreshButton.clicked.connect(self.onRefreshButtonClicked)
self.DownloadButton.clicked.connect(self.onDownloadButtonClicked)
self.DeleteButton.clicked.connect(self.onDeleteButtonClicked)
self.CancelButton.clicked.connect(self.onCancelButtonClicked)
self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked)
self.DriverComboBox.currentIndexChanged.connect(self.onDriverComboBoxChanged)
def initializeDriverManager(
self
):
try:
self.__driver_manager = webdriver_manager_instance(self.__driver_dir)
except ValueError as e:
QMessageBox.warning(self, "初始化失败", f"WebDriverManager 初始化失败:\n{str(e)}")
self.reject()
def refreshDriverList(
self
):
if not self.__driver_manager:
return
self.__driver_manager.refresh()
self.__driver_infos = self.__driver_manager.getDriverInfos()
self.DriverComboBox.clear()
installed_idx = 0
for i, driver_info in enumerate(self.__driver_infos):
if driver_info.driver_status == WebDriverStatus.INSTALLED:
installed_idx = i # get the installed driver index
display_text = f"{driver_info.driver_type.value} - {driver_info.browser_version}"
self.DriverComboBox.addItem(display_text)
count = len(self.__driver_infos)
self.BrowserCountLabel.setText(f"检测到 {count} 个可用浏览器:")
if self.__driver_infos:
self.DriverComboBox.setCurrentIndex(installed_idx)
def onDriverComboBoxChanged(
self,
index: int
):
if not self.__driver_infos or index < 0 or index >= len(self.__driver_infos):
return
driver_info = self.__driver_infos[index]
self.updateDriverInfoDisplay(driver_info)
self.updateProgressBarStates(driver_info)
self.updateButtonStates(driver_info)
@Slot()
def onRefreshButtonClicked(
self
):
self.refreshDriverList()
@Slot()
def onDeleteButtonClicked(
self
):
index = self.DriverComboBox.currentIndex()
if index < 0 or index >= len(self.__driver_infos):
return
driver_info = self.__driver_infos[index]
if driver_info.driver_status.name != "INSTALLED":
QMessageBox.information(self, "提示 - AutoLibrary", "该驱动未安装, 无需删除")
return
reply = QMessageBox.question(
self,
"确认删除 - AutoLibrary",
f"确定要删除 {driver_info.driver_type.value} 驱动吗 ?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.No:
return
try:
self.__driver_manager.uninstallDriver(driver_info)
self.refreshDriverList()
QMessageBox.information(self, "删除成功 - AutoLibrary", "驱动已成功删除")
except Exception as e:
QMessageBox.critical(self, "删除失败 - AutoLibrary", f"删除驱动时出错:\n{str(e)}")
@Slot()
def onDownloadButtonClicked(
self
):
self.DriverComboBox.setEnabled(False)
index = self.DriverComboBox.currentIndex()
if index < 0 or index >= len(self.__driver_infos):
return
driver_info = self.__driver_infos[index]
if driver_info.driver_status == WebDriverStatus.INSTALLED:
return
driver_info.driver_status = WebDriverStatus.DOWNLOADING # we set this only to update
# the display, and we will set to not installed in the download thread
self.updateDriverInfoDisplay(driver_info)
self.updateProgressBarStates(driver_info)
self.ProgressText.setText("准备开始下载...")
self.updateButtonStates(driver_info)
# set to not installed
driver_info.driver_status = WebDriverStatus.NOT_INSTALLED
self.__download_thread = DownloadWorker(self.__driver_manager, driver_info)
self.__download_thread.progress.connect(self.onDownloadProgress)
self.__download_thread.downloadFinished.connect(self.onDownloadFinished)
self.__download_thread.downloadError.connect(self.onDownloadError)
self.__download_thread.downloadCancelled.connect(self.onDownloadCancelled)
self.__download_thread.finished.connect(self.__onThreadFinished)
self.__download_thread.start()
@Slot()
def onDownloadProgress(
self,
downloaded: float,
total: int,
speed: float,
message: str
):
progress = downloaded
self.ProgressBar.setValue(progress)
if speed >= 1024:
speed_text = f"{speed/1024:.1f} MB/s"
else:
speed_text = f"{speed:.1f} KB/s"
progress_text = f"{message}... {downloaded:.1f}% - {speed_text}"
self.ProgressText.setText(progress_text)
@Slot()
def onDownloadFinished(
self
):
self.DriverComboBox.setEnabled(True)
index = self.DriverComboBox.currentIndex()
if 0 <= index < len(self.__driver_infos):
driver_info = self.__driver_infos[index]
driver_info.driver_status = WebDriverStatus.INSTALLED
self.updateDriverInfoDisplay(driver_info)
self.updateProgressBarStates(driver_info)
self.updateButtonStates(driver_info)
@Slot()
def onDownloadError(
self,
error_message: str
):
self.DriverComboBox.setEnabled(True)
index = self.DriverComboBox.currentIndex()
if 0 <= index < len(self.__driver_infos):
driver_info = self.__driver_infos[index]
driver_info.driver_status = WebDriverStatus.ERROR
self.updateDriverInfoDisplay(driver_info)
self.updateProgressBarStates(driver_info)
self.updateButtonStates(driver_info)
QMessageBox.critical(self, "下载失败 - AutoLibrary", error_message)
@Slot()
def onDownloadCancelled(
self
):
self.DriverComboBox.setEnabled(True)
index = self.DriverComboBox.currentIndex()
if 0 <= index < len(self.__driver_infos):
driver_info = self.__driver_infos[index]
self.__driver_manager.cancelDriverDownload(driver_info)
driver_info.driver_status = WebDriverStatus.NOT_INSTALLED
self.updateDriverInfoDisplay(driver_info)
self.updateProgressBarStates(driver_info)
self.updateButtonStates(driver_info)
self.ProgressText.setText("下载已取消")
@Slot()
def onConfirmButtonClicked(
self
):
index = self.DriverComboBox.currentIndex()
if index < 0 or index >= len(self.__driver_infos):
return
driver_info = self.__driver_infos[index]
if driver_info.driver_status != WebDriverStatus.INSTALLED:
return
self.__selected_driver_info = driver_info
self.__confirmed = True
self.accept()
@Slot()
def onCancelButtonClicked(
self
):
if self.__download_thread:
reply = QMessageBox.question(
self,
"确认取消 - AutoLibrary",
"正在下载中, 确定要取消下载吗 ?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.Yes:
self.__download_thread.cancel()
else:
self.__confirmed = False
self.__selected_driver_info = None
self.reject()
def closeEvent(
self,
event: QCloseEvent
):
if self.__download_thread and self.__download_thread.isRunning():
reply = QMessageBox.question(
self,
"确认关闭 - AutoLibrary",
"驱动正在下载中, 确定要取消并关闭对话框吗 ?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.No:
event.ignore()
return
self.__download_thread.stop()
if not self.__confirmed:
self.__selected_driver_info = None
event.accept()
super().closeEvent(event)
def __onThreadFinished(
self
):
if self.__download_thread:
self.__download_thread.deleteLater()
self.__download_thread = None
def getSelectedDriverInfo(
self
) -> Optional[WebDriverInfo]:
return self.__selected_driver_info
def updateDriverInfoDisplay(
self,
driver_info: WebDriverInfo
):
if driver_info.driver_type == WebDriverType.CHROME:
driver_type = "Google Chrome"
elif driver_info.driver_type == WebDriverType.FIREFOX:
driver_type = "Mozilla Firefox"
elif driver_info.driver_type == WebDriverType.EDGE:
driver_type = "Microsoft Edge"
else:
driver_type = "未知"
self.BrowserTypeLabel.setText(f"类型:{driver_type}")
self.VersionLabel.setText(f"版本:{driver_info.driver_version}")
if driver_info.driver_path:
self.PathLabel.setText(str(driver_info.driver_path))
else:
self.PathLabel.setText("未安装")
match driver_info.driver_status:
case WebDriverStatus.NOT_INSTALLED:
self.StatusLabel.status = ALStatusLabel.Status.WAITING
case WebDriverStatus.INSTALLED:
self.StatusLabel.status = ALStatusLabel.Status.SUCCESS
case WebDriverStatus.DOWNLOADING:
self.StatusLabel.status = ALStatusLabel.Status.RUNNING
case WebDriverStatus.ERROR:
self.StatusLabel.status = ALStatusLabel.Status.FAILURE
def updateProgressBarStates(
self,
driver_info: WebDriverInfo
):
if driver_info.driver_status == WebDriverStatus.NOT_INSTALLED:
self.ProgressBar.setValue(0)
self.ProgressText.setText("未安装")
elif driver_info.driver_status == WebDriverStatus.INSTALLED:
self.ProgressBar.setValue(100)
self.ProgressText.setText("已安装")
elif driver_info.driver_status == WebDriverStatus.DOWNLOADING:
pass # update by worker thread
elif driver_info.driver_status == WebDriverStatus.ERROR:
self.ProgressBar.setValue(0)
self.ProgressText.setText("下载失败")
def updateButtonStates(
self,
driver_info: WebDriverInfo
):
if driver_info.driver_status == WebDriverStatus.NOT_INSTALLED:
self.RefreshButton.setEnabled(True)
self.DeleteButton.setEnabled(False)
self.DownloadButton.setEnabled(True)
self.CancelButton.setEnabled(True)
self.ConfirmButton.setEnabled(False)
elif driver_info.driver_status == WebDriverStatus.INSTALLED:
self.RefreshButton.setEnabled(True)
self.DownloadButton.setEnabled(False)
self.DeleteButton.setEnabled(True)
self.CancelButton.setEnabled(True)
self.ConfirmButton.setEnabled(True)
elif driver_info.driver_status == WebDriverStatus.DOWNLOADING:
self.RefreshButton.setEnabled(False)
self.DownloadButton.setEnabled(False)
self.DeleteButton.setEnabled(False)
self.CancelButton.setEnabled(True)
self.ConfirmButton.setEnabled(False)
elif driver_info.driver_status == WebDriverStatus.ERROR:
self.RefreshButton.setEnabled(True)
self.DownloadButton.setEnabled(True)
self.DeleteButton.setEnabled(False)
self.CancelButton.setEnabled(True)
self.ConfirmButton.setEnabled(False)
+18
View File
@@ -0,0 +1,18 @@
"""
GUI module for the AutoLibrary project.
Here are the classes and modules in this package:
- ALMainWindow: Main window class.
- ALAboutDialog: About dialog class.
- ALConfigWidget: Configuration widget class.
- ALSeatFrame: Seat frame class.
- ALSeatMapView: Seat map view class.
- ALSeatMapTable: Seat map table class.
- ALSeatMapSelectDialog: Seat map select dialog class.
- ALTimerTaskAddDialog: Timer task add dialog class.
- ALTimerTaskHistoryDialog: Timer task history dialog class.
- ALTimerTaskManageWidget: Timer task manage widget class.
- ALUserTreeWidget: User tree widget class.
- ALMainWorkers: Main workers class.
- ALVersionInfo: Version info class.
"""
+3
View File
@@ -0,0 +1,3 @@
"""
GUI resources module for the AutoLibrary project.
"""
+197 -161
View File
@@ -195,6 +195,11 @@
<height>25</height>
</size>
</property>
<property name="styleSheet">
<string notr="true">QPushButton {
color: #DC0000;
}</string>
</property>
<property name="text">
<string>删除用户</string>
</property>
@@ -1228,12 +1233,31 @@
</layout>
</widget>
</item>
<item row="1" column="0">
<widget class="QGroupBox" name="BrowserConfigGroupBox">
<property name="title">
<string>浏览器设置</string>
<item row="2" column="0">
<widget class="QFrame" name="SystemConfigSpaceFrame">
<property name="minimumSize">
<size>
<width>0</width>
<height>270</height>
</size>
</property>
<layout class="QVBoxLayout" name="BrowserConfigLayout">
<property name="frameShape">
<enum>QFrame::Shape::NoFrame</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Shadow::Plain</enum>
</property>
<property name="lineWidth">
<number>0</number>
</property>
</widget>
</item>
<item row="1" column="1" colspan="2">
<widget class="QGroupBox" name="RunModeConfigGroupBox">
<property name="title">
<string>运行模式</string>
</property>
<layout class="QVBoxLayout" name="RunModeConfigLayout">
<property name="spacing">
<number>5</number>
</property>
@@ -1250,162 +1274,59 @@
<number>3</number>
</property>
<item>
<widget class="QLabel" name="BrowserTypeLabel">
<widget class="QCheckBox" name="AutoReserveCheckBox">
<property name="minimumSize">
<size>
<width>80</width>
<width>100</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>80</width>
<width>100</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>浏览器类型:</string>
<string>自动预约</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="BrowserTypeComboBox">
<widget class="QCheckBox" name="AutoCheckinCheckBox">
<property name="minimumSize">
<size>
<width>80</width>
<width>100</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>80</width>
<height>25</height>
</size>
</property>
<property name="toolTip">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;脚本运行使用的浏览器类型&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="whatsThis">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;&lt;br/&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="currentText">
<string>edge</string>
</property>
<property name="currentIndex">
<number>0</number>
</property>
<property name="maxVisibleItems">
<number>3</number>
</property>
<property name="maxCount">
<number>3</number>
</property>
<item>
<property name="text">
<string>edge</string>
</property>
</item>
<item>
<property name="text">
<string>chrome</string>
</property>
</item>
<item>
<property name="text">
<string>firefox</string>
</property>
</item>
</widget>
</item>
<item>
<widget class="QLabel" name="BrowserDriverLabel">
<property name="minimumSize">
<size>
<width>80</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>80</width>
<width>100</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>驱动路径:</string>
<string>自动签到</string>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="BrowserDriverLayout">
<property name="spacing">
<number>5</number>
</property>
<item>
<widget class="QLineEdit" name="BrowseBrowserDriverEdit">
<property name="minimumSize">
<size>
<width>250</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>300</width>
<height>25</height>
</size>
</property>
<property name="toolTip">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;详情请参阅 &lt;a href=&quot;https://www.autolibrary.top/manuals&quot;&gt;&lt;span style=&quot; text-decoration: underline; color:#69fcff;&quot;&gt;用户手册&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="whatsThis">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;&lt;br/&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="BrowseBrowserDriverButton">
<property name="minimumSize">
<size>
<width>35</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>35</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>...</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QCheckBox" name="HeadlessCheckBox">
<widget class="QCheckBox" name="AutoRenewalCheckBox">
<property name="minimumSize">
<size>
<width>0</width>
<width>100</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<width>100</width>
<height>25</height>
</size>
</property>
<property name="toolTip">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;运行时不显示浏览器&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="whatsThis">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;&lt;br/&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="text">
<string>无头模式</string>
<string>自动续约</string>
</property>
</widget>
</item>
@@ -1524,15 +1445,12 @@
</layout>
</widget>
</item>
<item row="1" column="1" colspan="2">
<widget class="QGroupBox" name="RunModeConfigGroupBox">
<item row="1" column="0">
<widget class="QGroupBox" name="BrowserConfigGroupBox">
<property name="title">
<string>运行模式</string>
<string>浏览器设置</string>
</property>
<layout class="QVBoxLayout" name="RunModeConfigLayout">
<property name="spacing">
<number>5</number>
</property>
<layout class="QGridLayout" name="BrowserConfigLayout">
<property name="leftMargin">
<number>3</number>
</property>
@@ -1545,85 +1463,203 @@
<property name="bottomMargin">
<number>3</number>
</property>
<item>
<widget class="QCheckBox" name="AutoReserveCheckBox">
<property name="spacing">
<number>5</number>
</property>
<item row="0" column="0">
<widget class="QLabel" name="BrowserTypeLabel">
<property name="minimumSize">
<size>
<width>100</width>
<width>80</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>100</width>
<width>80</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>自动预约</string>
<string>浏览器类型:</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="AutoCheckinCheckBox">
<item row="1" column="0">
<widget class="QComboBox" name="BrowserTypeComboBox">
<property name="minimumSize">
<size>
<width>100</width>
<width>80</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>100</width>
<width>80</width>
<height>25</height>
</size>
</property>
<property name="toolTip">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;脚本运行使用的浏览器类型&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="whatsThis">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;&lt;br/&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="currentText">
<string>edge</string>
</property>
<property name="currentIndex">
<number>0</number>
</property>
<property name="maxVisibleItems">
<number>3</number>
</property>
<property name="maxCount">
<number>3</number>
</property>
<item>
<property name="text">
<string>edge</string>
</property>
</item>
<item>
<property name="text">
<string>chrome</string>
</property>
</item>
<item>
<property name="text">
<string>firefox</string>
</property>
</item>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="BrowserDriverLabel">
<property name="minimumSize">
<size>
<width>175</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>175</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>自动签到</string>
<string>驱动路径:</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="AutoRenewalCheckBox">
<item row="3" column="0" colspan="2">
<layout class="QHBoxLayout" name="BrowserDriverLayout">
<property name="spacing">
<number>5</number>
</property>
<item>
<widget class="QLineEdit" name="BrowseBrowserDriverEdit">
<property name="minimumSize">
<size>
<width>250</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>300</width>
<height>25</height>
</size>
</property>
<property name="toolTip">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;详情请参阅 &lt;a href=&quot;https://www.autolibrary.kenanzhu.com/manuals&quot;&gt;&lt;span style=&quot; text-decoration: underline; color:#69fcff;&quot;&gt;用户手册&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="whatsThis">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;&lt;br/&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="BrowseBrowserDriverButton">
<property name="minimumSize">
<size>
<width>35</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>35</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>...</string>
</property>
</widget>
</item>
</layout>
</item>
<item row="4" column="0">
<widget class="QCheckBox" name="HeadlessCheckBox">
<property name="minimumSize">
<size>
<width>100</width>
<width>0</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>100</width>
<width>16777215</width>
<height>25</height>
</size>
</property>
<property name="toolTip">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;运行时不显示浏览器&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="whatsThis">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;&lt;br/&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="text">
<string>自动续约</string>
<string>无头模式</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QPushButton" name="AutoDownloadWebDriverButton">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>120</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>120</width>
<height>25</height>
</size>
</property>
<property name="layoutDirection">
<enum>Qt::LayoutDirection::LeftToRight</enum>
</property>
<property name="text">
<string>自动下载驱动</string>
</property>
<property name="icon">
<iconset theme="document-properties"/>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item row="2" column="0">
<widget class="QFrame" name="SystemConfigSpaceFrame">
<property name="minimumSize">
<size>
<width>0</width>
<height>270</height>
</size>
</property>
<property name="frameShape">
<enum>QFrame::Shape::NoFrame</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Shadow::Plain</enum>
</property>
<property name="lineWidth">
<number>0</number>
</property>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="OtherConfigWidget">
+255 -8
View File
@@ -6,20 +6,20 @@
<rect>
<x>0</x>
<y>0</y>
<width>300</width>
<height>300</height>
<width>350</width>
<height>400</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>300</height>
<width>350</width>
<height>400</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>500</width>
<height>300</height>
<width>350</width>
<height>500</height>
</size>
</property>
<property name="windowTitle">
@@ -149,8 +149,20 @@
<property name="spacing">
<number>5</number>
</property>
<item row="0" column="0">
<item row="1" column="0">
<widget class="QRadioButton" name="SilentlyRunRadioButton">
<property name="minimumSize">
<size>
<width>0</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>静默运行</string>
</property>
@@ -168,13 +180,248 @@
</property>
</widget>
</item>
<item row="1" column="0">
<item row="2" column="0">
<widget class="QRadioButton" name="ShowBeforeRunRadioButton">
<property name="minimumSize">
<size>
<width>0</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>运行前提示</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QGroupBox" name="RepeatConfigGroupBox">
<property name="title">
<string>重复运行</string>
</property>
<layout class="QVBoxLayout" name="RepeatConfigLayout" stretch="1,1,1">
<property name="spacing">
<number>5</number>
</property>
<property name="leftMargin">
<number>3</number>
</property>
<property name="topMargin">
<number>3</number>
</property>
<property name="rightMargin">
<number>3</number>
</property>
<property name="bottomMargin">
<number>3</number>
</property>
<item>
<widget class="QCheckBox" name="RepeatCheckBox">
<property name="minimumSize">
<size>
<width>0</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>启用重复执行</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label">
<property name="minimumSize">
<size>
<width>0</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>重复周期(全选或全不选都为每日运行):</string>
</property>
</widget>
</item>
<item>
<layout class="QGridLayout" name="RepeatCheckBoxLayout" rowstretch="10,10" columnstretch="0,0,0,0" rowminimumheight="25,25">
<property name="spacing">
<number>0</number>
</property>
<item row="0" column="3">
<widget class="QCheckBox" name="ThuCheckBox">
<property name="enabled">
<bool>false</bool>
</property>
<property name="minimumSize">
<size>
<width>50</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>50</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>周四</string>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QCheckBox" name="WedCheckBox">
<property name="enabled">
<bool>false</bool>
</property>
<property name="minimumSize">
<size>
<width>50</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>50</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>周三</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QCheckBox" name="MonCheckBox">
<property name="enabled">
<bool>false</bool>
</property>
<property name="minimumSize">
<size>
<width>50</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>50</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>周一</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QCheckBox" name="TueCheckBox">
<property name="enabled">
<bool>false</bool>
</property>
<property name="minimumSize">
<size>
<width>50</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>50</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>周二</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QCheckBox" name="FriCheckBox">
<property name="enabled">
<bool>false</bool>
</property>
<property name="minimumSize">
<size>
<width>50</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>50</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>周五</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QCheckBox" name="SatCheckBox">
<property name="enabled">
<bool>false</bool>
</property>
<property name="minimumSize">
<size>
<width>50</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>50</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>周六</string>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QCheckBox" name="SunCheckBox">
<property name="enabled">
<bool>false</bool>
</property>
<property name="minimumSize">
<size>
<width>50</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>50</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>周日</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
</item>
@@ -6,19 +6,19 @@
<rect>
<x>0</x>
<y>0</y>
<width>400</width>
<width>500</width>
<height>400</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>400</width>
<width>500</width>
<height>400</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>600</width>
<width>800</width>
<height>400</height>
</size>
</property>
@@ -306,6 +306,11 @@
<height>25</height>
</size>
</property>
<property name="styleSheet">
<string notr="true">QPushButton {
color: #DC0000;
}</string>
</property>
<property name="text">
<string>清除全部</string>
</property>
+8
View File
@@ -0,0 +1,8 @@
"""
Managers module for the AutoLibrary project.
Here are the classes and modules in this package:
- ConfigManager: Config manager for managing configuration files.
- LogManager: Log manager for logging.
- WebDriverManager: Web driver manager for managing web drivers.
"""
@@ -168,16 +168,19 @@ class ConfigManager:
JSONWriter(config_path, self.__config_data[config_type.value])
def appDir(
def configDir(
self
) -> str:
return self.__config_dir
# ConfigManager singleton instance.
_config_manager_instance = None
# Utility function to get config data (thread-safe and validated) from ConfigManager instance.
# Utility functions.
#
# Utility function to get validated automation config paths.
def getValidateAutomationConfigPaths(
) -> dict:
"""
@@ -193,7 +196,7 @@ def getValidateAutomationConfigPaths(
paths = auto_config.get(f"{cfg_type}_path", {}).get("paths", [])
index = auto_config.get(f"{cfg_type}_path", {}).get("current", 0)
if paths == []:
paths.append(os.path.join(_config_manager_instance.appDir(), f"{cfg_type}.json"))
paths.append(os.path.join(_config_manager_instance.configDir(), f"{cfg_type}.json"))
if index < 0:
index = 0
if index >= len(paths):
@@ -204,10 +207,18 @@ def getValidateAutomationConfigPaths(
_config_manager_instance.set(ConfigType.GLOBAL, "automation", auto_config)
return config_paths
# Utility function to get base config directory.
def getBaseConfigDir(
) -> str:
"""
Get base config directory, on Windows, it is usually at :
'C:\\Users\\<username>\\AppData\\Local\\AutoLibrary\\config'.
return _config_manager_instance.appDir()
Returns:
str: Base config directory.
"""
return _config_manager_instance.configDir()
# Singleton instance of ConfigManager.
_instance_lock = threading.Lock()
@@ -223,11 +234,12 @@ def instance(
global _config_manager_instance
with _instance_lock:
if _config_manager_instance is None:
if not config_dir:
raise ValueError("ConfigManager 需要配置目录参数")
_config_manager_instance = ConfigManager(config_dir)
else:
if config_dir == "":
return _config_manager_instance
if _config_manager_instance.appDir() != config_dir:
raise ValueError(
"ConfigManager 的实例已初始化,不能使用不同的配置目录。")
if getBaseConfigDir() != config_dir:
raise ValueError("ConfigManager 的实例已初始化,不能使用不同的配置目录。")
return _config_manager_instance
+6
View File
@@ -0,0 +1,6 @@
"""
Config managers module for the AutoLibrary project.
Here are the classes and modules in this package:
- ConfigManager: Config manager for managing configuration files.
"""
+166
View File
@@ -0,0 +1,166 @@
import platform
import installed_browsers
from pathlib import Path
from enum import Enum
from dataclasses import dataclass
class WebBrowserType(Enum):
"""
Web browser type
"""
CHROME = "chrome"
FIREFOX = "firefox"
EDGE = "edge"
class WebBrowserArch(Enum):
"""
Web browser architecture
"""
WINX86_32 = 0
WINX86_64 = 1
WINARM = 2
LINUXX86_32 = 3
LINUXX86_64 = 4
LINUXARM = 5
MACX86_64 = 6
MACARM = 7
@dataclass
class WebBrowserInfo:
"""
Web browser information
Attributes:
browser_arch (WebBrowserArch): Web browser architecture
browser_type (WebBrowserType): Web browser type
browser_version (str): Web browser version
browser_path (Path): Web browser executable file path
"""
browser_arch: WebBrowserArch
browser_type: WebBrowserType
browser_version: str
browser_path: Path
class WebBrowserArchDetector:
"""
Web browser architecture detector
"""
def __init__(
self
):
pass
def detect(
self
) -> WebBrowserArch:
"""
Detect system architecture
Returns:
WebBrowserArch: System architecture
"""
system = platform.system()
machine = platform.machine().lower()
if system == "Windows":
if machine in ["amd64", "x86_64"]:
return WebBrowserArch.WINX86_64
elif machine in ["i386", "i686", "x86"]:
return WebBrowserArch.WINX86_32
elif machine in ["arm64", "aarch64"]:
return WebBrowserArch.WINARM
else:
return WebBrowserArch.WINX86_64
elif system == "Darwin":
if machine in ["arm64", "aarch64"]:
return WebBrowserArch.MACARM
else:
return WebBrowserArch.MACX86_64
elif system == "Linux":
if machine in ["amd64", "x86_64"]:
return WebBrowserArch.LINUXX86_64
elif machine in ["i386", "i686", "x86"]:
return WebBrowserArch.LINUXX86_32
elif machine in ["arm64", "aarch64"]:
return WebBrowserArch.LINUXARM
elif machine.startswith("arm"):
return WebBrowserArch.LINUXARM
else:
return WebBrowserArch.LINUXX86_64
raise ValueError(f"不支持的系统架构 : {system} {machine}")
class WebBrowserDetector:
"""
Web browser detector
"""
def __init__(
self
):
self.browser_arch = WebBrowserArchDetector().detect()
self.browser_infos : list[WebBrowserInfo] = []
def detect(
self
) -> list[WebBrowserInfo]:
"""
Detect installed web browsers on the system.
Returns:
list[WebBrowserInfo]: List of detected browser information objects.
"""
self.browser_infos = []
try:
all_browsers = installed_browsers.browsers()
except Exception as e:
self.browser_infos = []
return self.browser_infos
# Mapping from internal library name to our enum
type_map = {
'chrome': WebBrowserType.CHROME,
'firefox': WebBrowserType.FIREFOX,
'msedge': WebBrowserType.EDGE,
}
for browser in all_browsers:
internal_name = browser.get('name', '').lower()
if internal_name not in type_map:
continue # Not one of the browsers we care about
version = browser.get('version')
if not version:
# Skip browsers with no version info (unlikely, but defensive)
continue
exe_path = browser.get('location')
if not exe_path:
continue
try:
path = Path(exe_path)
if not path.is_file():
continue
except Exception:
continue # Invalid path
info = WebBrowserInfo(
browser_arch=self.browser_arch, # Use system architecture as fallback
browser_type=type_map[internal_name],
browser_version=version,
browser_path=path,
)
self.browser_infos.append(info)
return self.browser_infos
+452
View File
@@ -0,0 +1,452 @@
import os
import time
import shutil
import threading
import requests
import zipfile
import tarfile
from enum import Enum
from pathlib import Path
from typing import Optional, Callable
class WebDriverType(Enum):
"""
Web driver type
"""
CHROME = "chrome"
FIREFOX = "firefox"
EDGE = "edge"
class WebDriverArch(Enum):
"""
Web driver architecture
"""
class Chrome(Enum):
"""
Chrome web driver architecture
"""
WINX86_32 = "win32"
WINX86_64 = "win64"
# LINUX86_32 : no support for linux 32bit
LINUX86_64 = "linux64"
# LINUXARM : no support for linux arm64
MACX86_64 = "mac-x64"
MACARM = "mac-arm64"
class Firefox(Enum):
"""
Firefox web driver architecture
"""
WINX86_32 = "win32"
WINX86_64 = "win64"
WINARM = "win-aarch64"
LINUXX86_32 = "linux32"
LINUXX86_64 = "linux64"
LINUXARM = "linux-aarch64"
MACX86_64 = "macos"
MACARM = "macos-aarch64"
class Edge(Enum):
"""
Edge web driver architecture
"""
WINX86_32 = "win32"
WINX86_64 = "win64"
WINARM = "arm64"
# LINUX86_32 : no support for linux 32bit
LINUXX86_64 = "linux64"
# LINUXARM : no support for linux arm64
MACX86_64 = "mac64"
MACARM = "mac64_m1"
class WebDriverName:
"""
Web driver name
"""
def __init__(
self,
driver_type: WebDriverType
):
self.driver_type = driver_type
def __str__(
self
) -> str:
match self.driver_type:
case WebDriverType.CHROME:
return "chromedriver"
case WebDriverType.FIREFOX:
return "geckodriver"
case WebDriverType.EDGE:
return "msedgedriver"
case _:
raise ValueError(f"不受支持的 web driver 类型 : {self.driver_type}")
class WebDriverExecName:
"""
Web driver executable file name
"""
def __init__(
self,
driver_type: WebDriverType,
arch: WebDriverArch
):
self.driver_type = driver_type
self.arch = arch
def __str__(
self
) -> str:
is_win = True if self.arch is WebDriverArch.Chrome.WINX86_32 or\
self.arch is WebDriverArch.Chrome.WINX86_64 or\
self.arch is WebDriverArch.Firefox.WINX86_32 or\
self.arch is WebDriverArch.Firefox.WINX86_64 or\
self.arch is WebDriverArch.Edge.WINX86_32 or\
self.arch is WebDriverArch.Edge.WINX86_64 else False
match self.driver_type:
case WebDriverType.CHROME:
return f"{WebDriverName(self.driver_type)}" + (".exe" if is_win else "")
case WebDriverType.FIREFOX:
return f"{WebDriverName(self.driver_type)}" + (".exe" if is_win else "")
case WebDriverType.EDGE:
return f"{WebDriverName(self.driver_type)}" + (".exe" if is_win else "")
case _:
raise ValueError(f"不受支持的 web driver 类型 : {self.driver_type}")
class WebDriverFileName:
"""\
Web driver compressed file name
"""
def __init__(
self,
version: str,
driver_type: WebDriverType,
arch: WebDriverArch
):
self.version = version
self.driver_type = driver_type
self.arch = arch
def __str__(
self
) -> str:
match self.driver_type:
case WebDriverType.CHROME:
return f"{WebDriverName(self.driver_type)}-{self.arch.value}.zip"
case WebDriverType.FIREFOX:
if self.arch is WebDriverArch.Firefox.WINX86_32 or\
self.arch is WebDriverArch.Firefox.WINX86_64:
suffix = "zip"
else:
suffix = "tar.gz"
return f"{WebDriverName(self.driver_type)}-v{self.version}-{self.arch.value}.{suffix}"
case WebDriverType.EDGE:
return f"edgedriver_{self.arch.value}.zip" # Edge web driver file name is different
case _:
raise ValueError(f"不受支持的 web driver 类型 : {self.driver_type}")
class WebDriverURL:
"""
Web driver download URL
"""
def __init__(
self,
version: str,
driver_type: WebDriverType,
arch: WebDriverArch
):
self.version = version
self.driver_type = driver_type
self.arch = arch
self.file_name = str(WebDriverFileName(self.version, self.driver_type, self.arch))
def __str__(
self
) -> str:
match self.driver_type:
case WebDriverType.CHROME:
return f"https://storage.googleapis.com/chrome-for-testing-public/"\
f"{self.version}/"\
f"{self.arch.value}/"\
f"{self.file_name}"
case WebDriverType.FIREFOX:
return f"https://github.com/mozilla/geckodriver/releases/download/"\
f"v{self.version}/"\
f"{self.file_name}"
case WebDriverType.EDGE:
return f"https://msedgedriver.microsoft.com/"\
f"{self.version}/"\
f"{self.file_name}"
case _:
raise ValueError(f"不受支持的 web driver 类型 : {self.driver_type}")
class WebDriverDownloader:
"""
Base class for WebDriver downloaders
Args:
driver_type (WebDriverType): Web driver type
version (str): WebDriver version
arch (WebDriverArch): WebDriver architecture
download_dir (str): Download directory
"""
def __init__(
self,
driver_type: WebDriverType,
driver_version: str,
driver_arch: WebDriverArch,
download_dir: str
):
self.driver_type = driver_type
self.arch = driver_arch
self.version = driver_version
self.download_url = str(WebDriverURL(self.version, self.driver_type, self.arch))
self.download_dir = Path(download_dir)/self.driver_type.value/self.version/self.arch.value
self.download_dir.mkdir(mode=0o0755, parents=True, exist_ok=True)
self.download_path = self.download_dir/str(WebDriverFileName(self.version, self.driver_type, self.arch))
def download(
self,
progress_callback: Optional[Callable[[float, int, float, str], None]] = None,
cancel_event: Optional[threading.Event] = None
) -> Optional[Path]:
try:
# downlaod file : 0% - 98%
if not self._download(progress_callback, cancel_event=cancel_event):
return None
# verify file : 98% - 99%
if not self._verify(progress_callback):
progress_callback(0, 100, 0.0, "验证失败")
return None
# extract file : 99% - 100%
driver_path = self._extract(progress_callback)
if not driver_path:
progress_callback(0, 100, 0.0, "解压失败")
return None
return driver_path
except Exception as e:
raise e
def _download(
self,
progress_callback: Optional[Callable[[float, int, float, str], None]] = None,
max_retries: int = 3,
cancel_event: Optional[threading.Event] = None
) -> bool:
CHUNK_SIZE = 8192*8 # 64KB chunk
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept-Encoding': 'gzip, deflate'
}
for attempt in range(max_retries):
try:
if cancel_event and cancel_event.is_set():
return False
# resume download if file exists
if self.download_path.exists():
downloaded_size = self.download_path.stat().st_size
headers_ = headers.copy()
headers_['Range'] = f"bytes={downloaded_size}-"
mode = 'ab'
else:
downloaded_size = 0
headers_ = headers
mode = 'wb'
# get response
response = requests.get(str(self.download_url), headers=headers_, stream=True, timeout=10)
if response.status_code not in [200, 206]:
if self.download_path.exists():
self.download_path.unlink()
downloaded_size = 0
mode = 'wb'
response = requests.get(str(self.download_url), headers=headers, stream=True)
response.raise_for_status()
# get total size
total_size = int(response.headers.get('Content-Length', 0))
if response.status_code == 206: # Partial Content - server supports Range
total_size += downloaded_size
last_callback_time = time.time()
last_callback_size = downloaded_size
callback_interval = 0.1
with open(self.download_path, mode) as f:
for chunk in response.iter_content(CHUNK_SIZE):
current_time = time.time()
if cancel_event and cancel_event.is_set():
response.close()
return False
if not chunk:
continue
f.write(chunk)
downloaded_size += len(chunk)
if not progress_callback or total_size <= 0:
continue
current_progress = (downloaded_size/total_size)*98.0
if current_time - last_callback_time >= callback_interval or current_progress >= 98.0:
elapsed = current_time - last_callback_time
if elapsed > 0:
speed = (downloaded_size - last_callback_size)/(elapsed*1024.0)
else:
speed = 0.0
progress_callback(current_progress, 100, speed, "下载中...")
last_callback_time = current_time
last_callback_size = downloaded_size
if total_size > 0 and self.download_path.stat().st_size < total_size:
raise Exception(f"下载不完整 : {self.download_path.stat().st_size}/{total_size} 字节")
return True
except Exception as e:
if cancel_event and cancel_event.is_set():
return False
if attempt < max_retries - 1:
progress_callback(0, 100, 0.0, f"{attempt+1} 次重试...")
time.sleep(1)
continue
raise e
def _verify(
self,
progress_callback: Optional[Callable[[float, int, float, str], None]] = None
) -> bool:
progress_callback(98, 100, 0.0, "验证完成")
return True
def _extract(
self,
progress_callback: Optional[Callable[[float, int, float, str], None]] = None
) -> Optional[Path]:
try:
progress_callback(98, 100, 0.0, "解压中...")
file_path_str = str(self.download_path)
if file_path_str.endswith('.tar.gz'):
with tarfile.open(self.download_path, 'r:gz') as tar_ref:
tar_ref.extractall(self.download_dir)
else:
with zipfile.ZipFile(self.download_path, 'r') as zip_ref:
zip_ref.extractall(self.download_dir)
driver_file = None
for root, _, files in os.walk(self.download_dir):
for file in files:
expected_name = str(WebDriverExecName(self.driver_type, self.arch))
if file == str(expected_name):
src_path = Path(root, file)
dst_path = self.download_dir/file
src_path.rename(dst_path)
driver_file = dst_path
break
if driver_file:
break
if not driver_file:
raise FileNotFoundError(f"未找到 web driver 文件 : {expected_name}")
progress_callback(100, 100, 0.0, "解压完成")
self.download_path.unlink()
self._cleanup(driver_file)
return driver_file
except Exception:
return None
def _cleanup(
self,
driver_file: Path
) -> None:
for item in self.download_dir.iterdir():
if item != driver_file:
if item.is_dir():
shutil.rmtree(item)
else:
item.unlink()
class ChromeDriverDownloader(WebDriverDownloader):
"""
Chrome web driver downloader
Only support version higher than 114
"""
def __init__(
self,
version: str,
arch: WebDriverArch,
download_dir: str
):
super().__init__(WebDriverType.CHROME, version, arch, download_dir)
class FirefoxDriverDownloader(WebDriverDownloader):
"""
Firefox web driver downloader
This class do not resolve version mapping,
only support driver version higher than 0.17.0
"""
def __init__(
self,
version: str,
arch: WebDriverArch,
download_dir: str
):
super().__init__(WebDriverType.FIREFOX, version, arch, download_dir)
class EdgeDriverDownloader(WebDriverDownloader):
"""
Edge web driver downloader
"""
def __init__(
self,
version: str,
arch: WebDriverArch,
download_dir: str
):
super().__init__(WebDriverType.EDGE, version, arch, download_dir)
+471
View File
@@ -0,0 +1,471 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
import os
import threading
import packaging.version as ver
from enum import Enum
from pathlib import Path
from typing import Optional, Callable
from managers.driver.WebBrowserDetector import (
WebBrowserType, WebBrowserArch, WebBrowserInfo, WebBrowserDetector
)
from managers.driver.WebDriverDownloader import (
WebDriverArch, WebDriverType,
ChromeDriverDownloader, FirefoxDriverDownloader, EdgeDriverDownloader
)
class WebDriverStatus(Enum):
"""
Web driver status.
"""
NOT_INSTALLED = 0
INSTALLED = 1
DOWNLOADING = 2
ERROR = 3
class WebDriverInfo:
"""
Web driver information.
Attributes:
driver_type (WebDriverType): Web driver type
driver_arch (WebDriverArch): Web driver architecture
driver_version (str): Web driver version
browser_version (str): Web browser version
driver_path (Optional[Path]): Web driver executable file path
driver_status (DriverStatus): Web driver status
"""
def __init__(
self
):
self.driver_type = None
self.driver_arch = None
self.driver_version = ""
self.browser_version = ""
self.driver_path: Optional[Path] = None
self.driver_status = WebDriverStatus.NOT_INSTALLED
class WebDriverManager:
"""
Web Driver Manager Singleton Class
Args:
driver_dir (str): The directory to store web drivers.
"""
def __init__(
self,
driver_dir: str
):
self.__driver_dir = os.path.abspath(driver_dir)
self.__browser_detector = WebBrowserDetector()
self.__driver_infos: list[WebDriverInfo] = []
self.__initialized = False
self.__lock = threading.Lock()
self.initialize()
def initialize(
self
):
if self.__initialized:
return
os.makedirs(self.__driver_dir, exist_ok=True)
self._detectBrowsers()
self._checkDriverStatus()
self.__initialized = True
def _detectBrowsers(
self
):
with self.__lock:
browser_infos = self.__browser_detector.detect()
self.__driver_infos = [
self._getDriverInfo(info)
for info in browser_infos
]
def _checkDriverStatus(
self
):
with self.__lock:
for driver_info in self.__driver_infos:
driver_path = self._getDriverPath(driver_info)
if driver_path and driver_path.exists() and driver_path.is_file():
driver_info.driver_path = driver_path
driver_info.driver_status = WebDriverStatus.INSTALLED
def _mapWebBrowserTypeToDriver(
self,
browser_type: WebBrowserType
) -> WebDriverType:
if browser_type == WebBrowserType.CHROME:
return WebDriverType.CHROME
elif browser_type == WebBrowserType.FIREFOX:
return WebDriverType.FIREFOX
elif browser_type == WebBrowserType.EDGE:
return WebDriverType.EDGE
else:
raise ValueError(f"不支持的 Web 浏览器类型 : {browser_type}")
def _mapWebBrowserArchToDriver(
self,
browser_type: WebBrowserType,
browser_arch: WebBrowserArch
) -> WebDriverArch:
if browser_type == WebBrowserType.CHROME:
if browser_arch == WebBrowserArch.WINX86_32:
return WebDriverArch.Chrome.WINX86_32
elif browser_arch == WebBrowserArch.WINX86_64:
return WebDriverArch.Chrome.WINX86_64
elif browser_arch == WebBrowserArch.WINARM:
raise ValueError("Chrome 不支持 Windows ARM 架构")
elif browser_arch == WebBrowserArch.LINUXX86_32:
raise ValueError("Chrome 不支持 Linux x86_32 架构")
elif browser_arch == WebBrowserArch.LINUXX86_64:
return WebDriverArch.Chrome.LINUXX86_64
elif browser_arch == WebBrowserArch.LINUXARM:
raise ValueError("Chrome 不支持 Linux ARM 架构")
elif browser_arch == WebBrowserArch.MACX86_64:
return WebDriverArch.Chrome.MACX86_64
elif browser_arch == WebBrowserArch.MACARM:
return WebDriverArch.Chrome.MACARM
else:
raise ValueError(f"不支持的 Chrome 浏览器架构 : {browser_arch}")
elif browser_type == WebBrowserType.FIREFOX:
if browser_arch == WebBrowserArch.WINX86_32:
return WebDriverArch.Firefox.WINX86_32
elif browser_arch == WebBrowserArch.WINX86_64:
return WebDriverArch.Firefox.WINX86_64
elif browser_arch == WebBrowserArch.WINARM:
return WebDriverArch.Firefox.WINARM
elif browser_arch == WebBrowserArch.LINUXX86_32:
return WebDriverArch.Firefox.LINUXX86_32
elif browser_arch == WebBrowserArch.LINUXX86_64:
return WebDriverArch.Firefox.LINUXX86_64
elif browser_arch == WebBrowserArch.LINUXARM:
return WebDriverArch.Firefox.LINUXARM
elif browser_arch == WebBrowserArch.MACX86_64:
return WebDriverArch.Firefox.MACX86_64
elif browser_arch == WebBrowserArch.MACARM:
return WebDriverArch.Firefox.MACARM
else:
raise ValueError(f"不支持的 Firefox 浏览器架构 : {browser_arch}")
elif browser_type == WebBrowserType.EDGE:
if browser_arch == WebBrowserArch.WINX86_32:
return WebDriverArch.Edge.WINX86_32
elif browser_arch == WebBrowserArch.WINX86_64:
return WebDriverArch.Edge.WINX86_64
elif browser_arch == WebBrowserArch.WINARM:
return WebDriverArch.Edge.WINARM
elif browser_arch == WebBrowserArch.LINUXX86_32:
raise ValueError("Edge 不支持 Linux x86_32 架构")
elif browser_arch == WebBrowserArch.LINUXX86_64:
return WebDriverArch.Edge.LINUXX86_64
elif browser_arch == WebBrowserArch.LINUXARM:
raise ValueError("Edge 不支持 Linux ARM 架构")
elif browser_arch == WebBrowserArch.MACX86_64:
return WebDriverArch.Edge.MACX86_64
elif browser_arch == WebBrowserArch.MACARM:
return WebDriverArch.Edge.MACARM
else:
raise ValueError(f"不支持的 Edge 浏览器架构 : {browser_arch}")
else:
raise ValueError(f"不支持的 Web 浏览器类型 : {browser_type}")
def _mapFirefoxDriverVersion(
self,
version: str
) -> str:
version_mapping = [
(ver.Version("128.0"), ver.Version("999.0"), "0.36.0"),
(ver.Version("115.0"), ver.Version("127.0"), "0.35.0"),
(ver.Version("91.0"), ver.Version("114.0"), "0.34.0"),
(ver.Version("91.0"), ver.Version("120.0"), "0.33.0"),
(ver.Version("91.0"), ver.Version("120.0"), "0.32.0"),
(ver.Version("91.0"), ver.Version("120.0"), "0.31.0"),
(ver.Version("78.0"), ver.Version("90.0"), "0.30.0"),
(ver.Version("60.0"), ver.Version("90.0"), "0.29.0"),
(ver.Version("60.0"), ver.Version("90.0"), "0.28.0"),
(ver.Version("60.0"), ver.Version("90.0"), "0.27.0"),
(ver.Version("57.0"), ver.Version("90.0"), "0.26.0"),
(ver.Version("55.0"), ver.Version("62.0"), "0.25.0"),
(ver.Version("55.0"), ver.Version("62.0"), "0.24.0"),
(ver.Version("57.0"), ver.Version("79.0"), "0.23.0"),
(ver.Version("57.0"), ver.Version("79.0"), "0.22.0"),
(ver.Version("57.0"), ver.Version("79.0"), "0.21.0"),
(ver.Version("55.0"), ver.Version("62.0"), "0.20.0"),
(ver.Version("55.0"), ver.Version("62.0"), "0.19.0"),
(ver.Version("53.0"), ver.Version("62.0"), "0.18.0"),
(ver.Version("52.0"), ver.Version("62.0"), "0.17.0"),
]
try:
firefox_version = ver.Version(version)
for min_ver, max_ver, gecko_ver in version_mapping:
if min_ver <= firefox_version <= max_ver:
return gecko_ver
raise ValueError(
f"不支持的 Firefox 版本 : {version}"
f"Firefox 版本 52 及以上受支持"
)
except Exception as e:
raise ValueError(f"无效的 Firefox 版本格式 : {version}") from e
def _getDriverInfo(
self,
browser_info: WebBrowserInfo
) -> WebDriverInfo:
driver_info = WebDriverInfo()
driver_info.driver_type = self._mapWebBrowserTypeToDriver(browser_info.browser_type)
driver_info.driver_arch = self._mapWebBrowserArchToDriver(browser_info.browser_type, browser_info.browser_arch)
if browser_info.browser_type == WebBrowserType.FIREFOX:
driver_info.driver_version = self._mapFirefoxDriverVersion(browser_info.browser_version)
else:
driver_info.driver_version = browser_info.browser_version
driver_info.browser_version = browser_info.browser_version
return driver_info
def _getDriverPath(
self,
driver_info: WebDriverInfo
) -> Optional[Path]:
driver_type = driver_info.driver_type
driver_arch = driver_info.driver_arch
driver_version = driver_info.driver_version
if driver_type == WebDriverType.CHROME:
driver_name = "chromedriver"
elif driver_type == WebDriverType.FIREFOX:
driver_name = "geckodriver"
elif driver_type == WebDriverType.EDGE:
driver_name = "msedgedriver"
else:
return None
is_win = driver_arch in [
WebDriverArch.Chrome.WINX86_32,
WebDriverArch.Chrome.WINX86_64,
WebDriverArch.Firefox.WINX86_32,
WebDriverArch.Firefox.WINX86_64,
WebDriverArch.Edge.WINX86_32,
WebDriverArch.Edge.WINX86_64,
]
exe_name = f"{driver_name}.exe" if is_win else driver_name
driver_dir = Path(self.__driver_dir)/driver_type.value/driver_version/driver_arch.value
driver_path = driver_dir/exe_name
return driver_path
def refresh(
self
):
self._detectBrowsers()
self._checkDriverStatus()
def getDriverInfos(
self
) -> list[WebDriverInfo]:
with self.__lock:
return self.__driver_infos.copy()
def getDriverInfo(
self,
driver_type: WebDriverType
) -> list[WebDriverInfo]:
with self.__lock:
return [
info
for info in self.__driver_infos
if info.driver_type == driver_type
]
def getDriverPath(
self,
driver_info: WebDriverInfo
) -> Optional[Path]:
if driver_info and driver_info.driver_status == WebDriverStatus.INSTALLED:
return driver_info.driver_path
return None
def installDriver(
self,
driver_info: WebDriverInfo,
progress_callback: Optional[Callable[[float, int, float, str], None]] = None,
cancel_event: Optional[threading.Event] = None
) -> Optional[Path]:
with self.__lock:
if not driver_info:
if progress_callback:
progress_callback(0, 0, 0, "未找到浏览器信息")
else:
raise ValueError("未找到浏览器信息")
if driver_info and driver_info.driver_status == WebDriverStatus.DOWNLOADING:
if progress_callback:
progress_callback(0, 0, 0, f"{driver_info.driver_type} 驱动正在下载中")
else:
raise ValueError(f"{driver_info.driver_type} 驱动正在下载中")
try:
if not driver_info:
raise ValueError("未找到浏览器信息")
driver_arch = driver_info.driver_arch
driver_type = driver_info.driver_type
driver_version = driver_info.driver_version
downloader = None
if driver_type == WebDriverType.CHROME:
downloader = ChromeDriverDownloader(
version=driver_version,
arch=driver_arch,
download_dir=self.__driver_dir
)
elif driver_type == WebDriverType.FIREFOX:
downloader = FirefoxDriverDownloader(
version=driver_version,
arch=driver_arch,
download_dir=self.__driver_dir
)
elif driver_type == WebDriverType.EDGE:
downloader = EdgeDriverDownloader(
version=driver_version,
arch=driver_arch,
download_dir=self.__driver_dir
)
if downloader is None:
if progress_callback:
progress_callback(0, 0, 0, f"不支持的 Web Driver 类型")
else:
raise ValueError(f"不支持的 Web Driver 类型")
with self.__lock:
driver_info.driver_status = WebDriverStatus.DOWNLOADING
driver_path = downloader.download(progress_callback=progress_callback, cancel_event=cancel_event)
with self.__lock:
if driver_path:
driver_info.driver_path = driver_path
driver_info.driver_version = driver_version
driver_info.driver_status = WebDriverStatus.INSTALLED
else:
driver_info.driver_status = WebDriverStatus.ERROR
return driver_path
except Exception as e:
with self.__lock:
driver_info.driver_status = WebDriverStatus.ERROR
raise e
def cancelDriverDownload(
self,
driver_info: WebDriverInfo
) -> bool:
import shutil
try:
driver_path = self._getDriverPath(driver_info)
if driver_path:
download_dir = driver_path.parent
if download_dir.exists():
shutil.rmtree(download_dir, ignore_errors=True)
with self.__lock:
driver_info.driver_path = None
driver_info.driver_status = WebDriverStatus.NOT_INSTALLED
return True
except Exception:
return False
def uninstallDriver(
self,
driver_info: WebDriverInfo,
progress_callback: Optional[Callable[[int, int, float, str], None]] = None
) -> bool:
with self.__lock:
if not driver_info:
if progress_callback:
progress_callback(0, 0, 0, "未找到浏览器信息")
else:
raise ValueError("未找到浏览器信息")
if driver_info.driver_status != WebDriverStatus.INSTALLED:
if progress_callback:
progress_callback(0, 0, 0, f"{driver_info.driver_type} 驱动未安装")
else:
raise ValueError(f"{driver_info.driver_type} 驱动未安装")
try:
driver_path = driver_info.driver_path
driver_path.unlink()
with self.__lock:
driver_info.driver_path = None
driver_info.driver_status = WebDriverStatus.NOT_INSTALLED
return True
except Exception:
with self.__lock:
driver_info.driver_status = WebDriverStatus.ERROR
raise
def driverDir(
self
) -> str:
return self.__driver_dir
# WebDriverManager singleton instance.
_webdriver_manager_instance = None
# Singleton instance lock.
_instance_lock = threading.Lock()
def instance(
driver_dir: str = ""
) -> WebDriverManager:
global _webdriver_manager_instance
with _instance_lock:
if _webdriver_manager_instance is None:
if not driver_dir:
raise ValueError("WebDriverManager 需要驱动目录参数")
_webdriver_manager_instance = WebDriverManager(driver_dir)
else:
if driver_dir and _webdriver_manager_instance.driverDir() != os.path.abspath(driver_dir):
raise ValueError("WebDriverManager 的实例已初始化, 不能使用不同的驱动目录")
return _webdriver_manager_instance
+8
View File
@@ -0,0 +1,8 @@
"""
Driver managers module for the AutoLibrary project.
Here are the classes and modules in this package:
- WebBrowserDetector: Web browser detector class.
- WebDriverDownloader: Web driver downloader class.
- WebDriverManager: Web driver manager class.
"""
+196
View File
@@ -0,0 +1,196 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
import logging
import os
import threading
from logging.handlers import TimedRotatingFileHandler
from typing import Optional
class CallerInfoFormatter(logging.Formatter):
"""
Custom formatter to extract real caller information.
Skips MsgBase._showTrace to show the actual calling location.
Format:
- Logger name: left-aligned, max 15 chars
- Level name: left-aligned, max 8 chars
- Filename: left-aligned, max 20 chars
- Line number: left-aligned, max 4 digits
"""
def __init__(
self,
fmt=None,
datefmt=None,
style='%'
):
super().__init__(fmt, datefmt, style)
self.basefmt = fmt
def format(
self,
record
):
depth = 0
while depth < 10:
record.filename = os.path.basename(record.pathname)
if 'MsgBase.py' not in record.filename and record.funcName != '_showTrace':
break
if not hasattr(record, 'stack'):
record.stack = True
import traceback
record.stack_list = traceback.extract_stack()
depth += 1
if depth < len(record.stack_list):
frame = record.stack_list[-depth-1]
record.filename = os.path.basename(frame.filename)
record.lineno = int(frame.lineno)
record.funcName = frame.name
record.name = record.name[-15:].ljust(15)
record.levelname = record.levelname.ljust(8)
record.filename = record.filename[-20:].ljust(20)
# Ensure lineno is always integer before formatting
try:
lineno_int = int(record.lineno)
except (ValueError, TypeError):
lineno_int = 0
record.lineno = f"{lineno_int:04d}"
return super().format(record)
class LogManager:
"""
Log Manager Singleton Class
Args:
log_dir (str): The directory to store log files.
"""
def __init__(
self,
log_dir: str
):
self.__log_dir = os.path.abspath(log_dir)
self.__logger = None
self.__initialized = False
self.initialize()
def initialize(
self
):
if self.__initialized:
return
os.makedirs(self.__log_dir, exist_ok=True)
self.__logger = logging.getLogger("AutoLibrary")
self.__logger.setLevel(logging.DEBUG)
self.__logger.handlers.clear()
formatter = CallerInfoFormatter(
'[%(asctime)s] - [%(name)s] - [%(levelname)s] - [%(filename)s:%(lineno)s] - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(formatter)
self.__logger.addHandler(console_handler)
all_log_file = os.path.join(self.__log_dir, "all.log")
file_handler_all = TimedRotatingFileHandler(
all_log_file,
when='midnight',
interval=1,
backupCount=7,
encoding='utf-8'
)
file_handler_all.suffix = "%Y-%m-%d.log"
file_handler_all.setLevel(logging.DEBUG)
file_handler_all.setFormatter(formatter)
self.__logger.addHandler(file_handler_all)
error_log_file = os.path.join(self.__log_dir, "error.log")
file_handler_error = TimedRotatingFileHandler(
error_log_file,
when='midnight',
interval=1,
backupCount=14,
encoding='utf-8'
)
file_handler_error.suffix = "%Y-%m-%d.log"
file_handler_error.setLevel(logging.ERROR)
file_handler_error.setFormatter(formatter)
self.__logger.addHandler(file_handler_error)
self.__initialized = True
def getLogger(
self,
name: Optional[str] = None
) -> logging.Logger:
if name:
return self.__logger.getChild(name)
return self.__logger
def setLevel(
self,
level: int
):
if self.__logger:
self.__logger.setLevel(level)
def logDir(
self
) -> str:
return self.__log_dir
# LogManager singleton instance.
_log_manager_instance = None
# Singleton instance lock.
_instance_lock = threading.Lock()
def instance(
log_dir: str = ""
) -> LogManager:
global _log_manager_instance
with _instance_lock:
if _log_manager_instance is None:
if not log_dir:
raise ValueError("LogManager 需要日志目录参数")
_log_manager_instance = LogManager(log_dir)
else:
if log_dir and _log_manager_instance.logDir() != os.path.abspath(log_dir):
raise ValueError("LogManager 的实例已初始化, 不能使用不同的日志目录")
return _log_manager_instance
def getLogger(
name: Optional[str] = None
) -> logging.Logger:
if _log_manager_instance is None:
raise RuntimeError("LogManager 未初始化, 请先调用 LogManager.instance(log_dir) 初始化")
return _log_manager_instance.getLogger(name)
+6
View File
@@ -0,0 +1,6 @@
"""
Log managers module for the AutoLibrary project.
Here are the classes and modules in this package:
- LogManager: Log manager for logging.
"""
+44 -21
View File
@@ -54,7 +54,7 @@ class AutoLib(MsgBase):
self
) -> bool:
self._showTrace("正在初始化浏览器驱动......")
self._showTrace("正在初始化浏览器驱动......", no_log=True)
web_driver_config = self.__run_config.get("web_driver", None)
self.__driver_type = web_driver_config.get("driver_type")
@@ -66,11 +66,14 @@ class AutoLib(MsgBase):
case "firefox":
driver_options = webdriver.FirefoxOptions()
case _:
self._showTrace(f"不支持的浏览器驱动类型: {self.__driver_type} !")
self._showTrace(
f"不支持的浏览器驱动类型: {self.__driver_type} !",
self.TraceLevel.WARNING
)
return False
if not web_driver_config:
self._showTrace("未配置浏览器驱动参数 !")
self._showTrace("未配置浏览器驱动参数 !", self.TraceLevel.ERROR)
return False
if web_driver_config.get("headless"):
driver_options.add_argument("--headless")
@@ -110,7 +113,7 @@ class AutoLib(MsgBase):
# init browser driver
self.__driver_path = web_driver_config.get("driver_path")
if not self.__driver_path:
self._showTrace("未配置浏览器驱动路径 !")
self._showTrace("未配置浏览器驱动路径 !", self.TraceLevel.WARNING)
return False
self.__driver_path = os.path.abspath(self.__driver_path)
try:
@@ -123,18 +126,18 @@ class AutoLib(MsgBase):
service = ChromeService(executable_path=self.__driver_path)
self.__driver = webdriver.Chrome(service=service, options=driver_options)
case "firefox":
self._showTrace(f"Firefox 浏览器驱动初始化略慢, 请耐心等待...")
self._showTrace(f"Firefox 浏览器驱动初始化略慢, 请耐心等待...", no_log=True)
service = FirefoxService(executable_path=self.__driver_path)
self.__driver = webdriver.Firefox(service=service, options=driver_options)
case _: # actually will not happen, beacuse we have checked it at the initlization
# of 'driver_options'
raise Exception(f"不支持的浏览器驱动类型: {self.__driver_type}")
raise Exception(f"不支持的浏览器驱动类型: {self.__driver_type} !")
self.__driver.implicitly_wait(1)
self.__driver.execute_script(
"Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
)
except Exception as e:
self._showTrace(f"浏览器驱动初始化失败: {e}")
self._showTrace(f"浏览器驱动初始化失败: {e}", self.TraceLevel.ERROR)
return False
self._showTrace(f"浏览器驱动已初始化, 类型: {self.__driver_type}, 路径: {self.__driver_path}")
return True
@@ -145,7 +148,7 @@ class AutoLib(MsgBase):
):
if not self.__driver:
self._showTrace(f"浏览器驱动未初始化, 请先初始化浏览器驱动 !")
self._showTrace(f"浏览器驱动未初始化, 请先初始化浏览器驱动 !", self.TraceLevel.WARNING)
return
self.__lib_checker = LibChecker(self._input_queue, self._output_queue, self.__driver)
self.__lib_login = LibLogin(self._input_queue, self._output_queue, self.__driver)
@@ -178,7 +181,7 @@ class AutoLib(MsgBase):
)
return True
except:
self._showTrace(f"登录页面加载失败 !")
self._showTrace(f"登录页面加载失败 !", self.TraceLevel.ERROR)
return False
@@ -188,7 +191,7 @@ class AutoLib(MsgBase):
lib_config = self.__run_config.get("library", None)
if not lib_config:
self._showTrace("未配置图书馆参数 !")
self._showTrace("未配置图书馆参数 !", self.TraceLevel.ERROR)
return False
url = lib_config.get("host_url") + lib_config.get("login_url")
self.__driver.set_page_load_timeout(5)
@@ -196,7 +199,9 @@ class AutoLib(MsgBase):
self.__driver.get(url)
except TimeoutException:
self.__driver.execute_script("window.stop();")
self._showTrace(f"图书馆登录页面加载超时 ! 请检查网络环境是否正常")
self._showTrace(
f"图书馆登录页面加载超时 ! 请检查网络环境是否正常", self.TraceLevel.ERROR
)
return False
if not self.__waitResponseLoad():
return False
@@ -238,32 +243,45 @@ class AutoLib(MsgBase):
else:
result = 1
else:
self._showTrace(f"用户 {username} 无法预约已跳过")
self._showTrace(f"用户 {username} 无法预约, 已跳过")
result = 2
# checkin
if run_mode["auto_checkin"] and result == 2:
last_result = result
if run_mode["auto_checkin"] and last_result != 1:
if self.__lib_checker.canCheckin():
if self.__lib_checkin.checkin(username):
result = 0
else:
result = 1
else:
self._showTrace(f"用户 {username} 无法签到已跳过")
self._showTrace(f"用户 {username} 无法签到, 已跳过")
result = 2
if last_result == 0: # partly success
result = 0
# renewal
if run_mode["auto_renewal"] and result == 2:
last_result = result
if run_mode["auto_renewal"] and last_result != 1:
can_renew, record = self.__lib_checker.canRenew()
if can_renew:
if self.__lib_renew.renew(username, record, reserve_info):
if self.__lib_checker.postRenewCheck(record):
self._showTrace(f"用户 {username} 续约成功 !")
result = 0
else:
result = 1
if result != 1: # partly success
result = 0
else:
result = 1
else:
result = 1
else:
self._showTrace(f"用户 {username} 无法续约已跳过")
self._showTrace(f"用户 {username} 无法续约, 已跳过")
result = 2
if last_result == 0: # partly success
result = 0
# logout
if not self.__lib_logout.logout(
username
@@ -288,7 +306,8 @@ class AutoLib(MsgBase):
for user in users:
user_counter["current"] += 1
self._showTrace(
f"正在处理第 {user_counter["current"]}/{len(users)} 个用户: {user["username"]}......"
f"正在处理第 {user_counter["current"]}/{len(users)} 个用户: {user["username"]}......",
no_log=True
)
if not user["enabled"]:
self._showTrace(f"用户 {user["username"]} 已跳过")
@@ -303,7 +322,8 @@ class AutoLib(MsgBase):
)
if r == -1:
self._showTrace(
f"用户 {user["username"]} 处理过程中页面发生异常无法继续操作, 任务已终止 !"
f"用户 {user["username"]} 处理过程中页面发生异常, 无法继续操作, 任务已终止 !",
self.TraceLevel.WARNING
)
break
elif r == 0:
@@ -326,11 +346,14 @@ class AutoLib(MsgBase):
if self.__driver:
if self.__driver_type.lower() == "firefox":
self._showTrace(f"Firefox 浏览器驱动关闭略慢, 请耐心等待...")
self._showTrace(
f"Firefox 浏览器驱动关闭略慢, 请耐心等待...",
no_log=True
)
self.__driver.quit()
self.__driver = None
self._showTrace(f"浏览器驱动已关闭")
return True
else:
self._showTrace(f"浏览器驱动未初始化, 无需关闭")
self._showTrace(f"浏览器驱动未初始化, 无需关闭", no_log=True)
return False
+9 -8
View File
@@ -63,7 +63,7 @@ class LibChecker(LibOperator):
EC.presence_of_element_located((By.CLASS_NAME, "myReserveList"))
)
except:
self._showTrace("加载预约记录页面失败 !")
self._showTrace("加载预约记录页面失败 !", self.TraceLevel.ERROR)
return False
return True
@@ -174,7 +174,7 @@ class LibChecker(LibOperator):
)
return reservations
except:
self._showTrace("加载预约记录失败 !")
self._showTrace("加载预约记录失败 !", self.TraceLevel.ERROR)
return None
@@ -197,10 +197,10 @@ class LibChecker(LibOperator):
self.__driver.execute_script("arguments[0].click();", more_btn)
return True
else:
self._showTrace("用户无法加载更多预约记录")
self._showTrace("用户无法加载更多预约记录", self.TraceLevel.WARNING)
return False
except:
self._showTrace("加载更多预约记录失败 !")
self._showTrace("加载更多预约记录失败 !", self.TraceLevel.ERROR)
return False
@@ -211,9 +211,9 @@ class LibChecker(LibOperator):
) -> dict:
if wanted_date is None:
self._showTrace("日期未指定, 无法检查当前预约状态")
self._showTrace("日期未指定, 无法检查当前预约状态", self.TraceLevel.WARNING)
return None
self._showTrace(f"正在检查用户在 {wanted_date} 是否有预约状态为 {wanted_status} 的预约记录......")
self._showTrace(f"正在检查用户在 {wanted_date} 是否有预约状态为 {wanted_status} 的预约记录......", no_log=True)
checked_count = 0
max_check_times = 6 # we only check (4*(6-1)=)20 reservations, the last time cant be checked
@@ -245,7 +245,8 @@ class LibChecker(LibOperator):
self._showTrace(
f"寻找到用户第 {checked_count} 条状态为 {wanted_status} 的预约记录, "
f"详细信息: {record["date"]} "
f"{record["time"]["begin"]} - {record["time"]["end"]} {record["info"]["location"]}"
f"{record["time"]["begin"]} - {record["time"]["end"]} {record["info"]["location"]}",
no_log=True
)
return record
if not self.__showMoreReserveRecords():
@@ -369,7 +370,7 @@ class LibChecker(LibOperator):
else:
self._showTrace(f"\n"\
f" 续约失败 !\n"\
f" 续约后结束时间为 {act_record["time"]["end"]}与预期结束时间 {record["time"]["end"]} 不符 !"
f" 续约后结束时间为 {act_record["time"]["end"]},与预期结束时间 {record["time"]["end"]} 不符 !"
)
return False
self._showTrace(f"用户在 {date} 没有有效预约记录, 无法检查续约结果")
+34 -7
View File
@@ -51,7 +51,7 @@ class LibCheckin(LibOperator):
)
ok_btn = self.__driver.find_element(By.CLASS_NAME, "btnOK")
except:
self._showTrace("签到时发生未知错误 !")
self._showTrace("签到时发生未知错误 !", self.TraceLevel.ERROR)
return False
result_message = result_message_element.text
if "签到成功" in result_message:
@@ -88,28 +88,55 @@ class LibCheckin(LibOperator):
return False
def __enableCheckinBtn(
self
) -> bool:
script = """
try {
var checkin_btn = document.getElementById('btnCheckIn');
if (checkin_btn) {
checkin_btn.classList.remove('disabled');
return true;
}
return false;
} catch (e) {
return false;
}
"""
result = self.__driver.execute_script(script)
time.sleep(0.1)
if result:
self._showTrace("签到按钮已启用", no_log=True)
else:
self._showTrace("签到按钮启用失败", self.TraceLevel.WARNING)
return result
def checkin(
self,
username: str
) -> bool:
if self.__driver is None:
self._showTrace("未提供有效 WebDriver 实例 !")
self._showTrace("未提供有效 WebDriver 实例 !", self.TraceLevel.WARNING)
return False
try:
checkin_btn = WebDriverWait(self.__driver, 2).until(
EC.element_to_be_clickable((By.ID, "btnCheckIn"))
)
except:
self._showTrace(f"用户 {username} 签到界面加载失败 !")
self._showTrace(f"用户 {username} 签到界面加载失败 !", self.TraceLevel.ERROR)
return False
if "disabled" in checkin_btn.get_attribute("class"):
self._showTrace("签到按钮不可用, 可能不在场馆内, 请连接图书馆网络后重试")
return False
self._showTrace("签到按钮不可用, 可能不在场馆内, 正在尝试启用......", no_log=True)
if not self.__enableCheckinBtn():
self._showTrace(f"签到按钮启用失败 !", self.TraceLevel.ERROR)
return False
checkin_btn.click()
if self._waitResponseLoad():
self._showTrace(f"用户 {username} 签到成功 !")
self._showTrace(f"用户 {username} 签到成功 !", no_log=True)
return True
else:
self._showTrace(f"用户 {username} 签到失败 !")
self._showTrace(f"用户 {username} 签到失败 !", self.TraceLevel.ERROR)
return False
+23 -15
View File
@@ -52,7 +52,10 @@ class LibLogin(LibOperator):
)
return True
except:
self._showTrace(f"登录页面加载失败 ! : 用户账号或者密码错误/验证码错误, 具体以页面提示为准")
self._showTrace(
f"登录页面加载失败 ! : 用户账号或者密码错误/验证码错误, 具体以页面提示为准",
self.TraceLevel.ERROR
)
return False
@@ -71,7 +74,7 @@ class LibLogin(LibOperator):
password_element.clear()
password_element.send_keys(password)
except Exception as e:
self._showTrace(f"用户名或密码填写失败 ! : {e}")
self._showTrace(f"用户名或密码填写失败 ! : {e}", self.TraceLevel.ERROR)
return False
return True
@@ -88,12 +91,13 @@ class LibLogin(LibOperator):
captcha_img = base64.b64decode(base64_str)
captcha_text = self.__ddddocr.classification(captcha_img)
captcha_text = ''.join(filter(str.isalnum, captcha_text)).lower()
self._showTrace(f"识别到验证码为 : '{captcha_text}'")
self._showTrace(f"识别到验证码为 : '{captcha_text}'", no_log=True)
if len(captcha_text) != 4:
self._showLog("识别到的验证码长度不等于 4 个字符 !", self.TraceLevel.WARNING)
raise Exception("识别到的验证码长度不等于 4 个字符 !")
return captcha_text
except Exception as e:
self._showTrace(f"验证码识别失败 ! : {e}")
self._showTrace(f"验证码识别失败 ! : {e}", self.TraceLevel.ERROR)
return ""
@@ -105,12 +109,13 @@ class LibLogin(LibOperator):
try:
self._showMsg("请输入验证码:")
captcha_text = self._waitMsg(timeout=15)
self._showTrace(f"输入的验证码为 : '{captcha_text}'")
self._showTrace(f"输入的验证码为 : '{captcha_text}'", no_log=True)
if len(captcha_text) != 4:
self._showLog("输入的验证码长度不等于 4 个字符 !", self.TraceLevel.WARNING)
raise Exception("输入的验证码长度不等于 4 个字符 !")
return captcha_text
except Exception as e:
self._showTrace(f"输入验证码失败 ! : {e}")
self._showTrace(f"输入验证码失败 ! : {e}", self.TraceLevel.ERROR)
return ""
@@ -120,13 +125,13 @@ class LibLogin(LibOperator):
# refresh captcha
try:
self._showTrace("刷新验证码......")
self._showTrace("刷新验证码......", no_log=True)
self.__driver.find_element(
By.ID, "loadImgId"
).click()
return True
except Exception as e:
self._showTrace(f"刷新验证码失败 ! : {e}")
self._showTrace(f"刷新验证码失败 ! : {e}", self.TraceLevel.ERROR)
return False
@@ -140,14 +145,17 @@ class LibLogin(LibOperator):
if auto_captcha:
captcha_text = self.__autoRecognizeCaptcha()
else:
self._showTrace(f"用户未配置自动识别验证码, 请手动输入验证码 !")
self._showTrace(f"用户未配置自动识别验证码, 请手动输入验证码 !", no_log=True)
captcha_text = self.__manualRecognizeCaptcha()
if captcha_text:
return captcha_text
else:
if not self.__refreshCaptcha():
return ""
self._showTrace(f"验证码识别失败 {max_attempts} 次, 达到最大尝试次数 !")
self._showTrace(
f"验证码识别失败 {max_attempts} 次, 达到最大尝试次数 !",
self.TraceLevel.WARNING
)
return ""
@@ -162,7 +170,7 @@ class LibLogin(LibOperator):
captcha_element.send_keys(captcha_text)
return True
except Exception as e:
self._showTrace(f"验证码填写失败 ! : {e}")
self._showTrace(f"验证码填写失败 ! : {e}", self.TraceLevel.ERROR)
return False
@@ -175,11 +183,11 @@ class LibLogin(LibOperator):
) -> bool:
if self.__driver is None:
self._showTrace("未提供有效 WebDriver 实例 !")
self._showTrace("未提供有效 WebDriver 实例 !", self.TraceLevel.WARNING)
return False
# begin login process
for attempt in range(max_attempts):
self._showTrace(f"用户 {username}{attempt + 1} 次尝试登录......")
self._showTrace(f"用户 {username}{attempt + 1} 次尝试登录......", no_log=True)
if not self.__fillLogInElements(
username,
password,
@@ -190,7 +198,7 @@ class LibLogin(LibOperator):
continue
if not self.__fillCaptchaElement(captcha_text):
continue
self._showTrace("尝试登录...")
self._showTrace("尝试登录...", no_log=True)
try:
self.__driver.find_element(
By.XPATH,
@@ -203,5 +211,5 @@ class LibLogin(LibOperator):
self._showTrace(f"用户 {username}{attempt + 1} 次登录成功 !")
return True
else:
self._showTrace(f"用户 {username}{attempt + 1} 次登录失败 !")
self._showTrace(f"用户 {username}{attempt + 1} 次登录失败 !",self.TraceLevel.WARNING)
return False
+2 -2
View File
@@ -42,7 +42,7 @@ class LibLogout(LibOperator):
) -> bool:
if self.__driver is None:
self._showTrace("未提供有效 WebDriver 实例 !")
self._showTrace("未提供有效 WebDriver 实例 !", self.TraceLevel.WARNING)
return False
try:
self.__driver.find_element(
@@ -51,5 +51,5 @@ class LibLogout(LibOperator):
self._showTrace(f"用户 {username} 注销成功 !")
return True
except Exception as e:
self._showTrace(f"用户 {username} 注销失败 ! : {e}")
self._showTrace(f"用户 {username} 注销失败 ! : {e}", self.TraceLevel.ERROR)
return False
+85 -92
View File
@@ -14,10 +14,10 @@ from selenium.webdriver.chrome.webdriver import WebDriver
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from base.LibOperator import LibOperator
from base.LibTimeSelector import LibTimeSelector
class LibRenew(LibOperator):
class LibRenew(LibTimeSelector):
def __init__(
self,
@@ -38,22 +38,6 @@ class LibRenew(LibOperator):
self.__driver.refresh()
return True
@staticmethod
def __timeToMins(
time_str: str
) -> int:
hour, minute = map(int, time_str.split(":"))
return hour*60 + minute
@staticmethod
def __minsToTime(
mins: int
) -> str:
hour, minute = divmod(mins, 60)
return f"{hour:02d}:{minute:02d}"
def __waitRenewDialog(
self
@@ -70,14 +54,14 @@ class LibRenew(LibOperator):
EC.presence_of_element_located((By.CSS_SELECTOR, "#extendDiv div.resultMessage"))
)
except:
self._showTrace("续约时间选择界面加载失败 !")
self._showTrace("续约时间选择界面加载失败 !", self.TraceLevel.ERROR)
return False
head_message = head_message.text.strip()
if "警告" in head_message:
result_message = result_message.text.strip()
self._showTrace(f"\n"\
f" 续约失败 !\n"\
f" {result_message}")
f" {result_message}", no_log=True)
return False
try:
WebDriverWait(self.__driver, 2).until(
@@ -89,90 +73,98 @@ class LibRenew(LibOperator):
EC.presence_of_element_located((By.CSS_SELECTOR, "#extendDiv .btnOK"))
)
except:
self._showTrace("续约时间选择界面加载失败 !")
self._showTrace("续约时间选择界面加载失败 !", self.TraceLevel.ERROR)
return False
return True
def __selectNearstTime(
def __selectNearestTime(
self,
record: dict,
reserve_info: dict
) -> bool:
"""
TODO : this function is too long and too ugly
we need to refactor it to make it more readable.
but may be it is not a good idea to refactor it. :) who knows...
Select the nearest available renewal time.
"""
end_time = record["time"]["end"]
renew_info = reserve_info["renew_time"]
max_diff = renew_info["max_diff"]
prefer_earlier = renew_info["prefer_early"]
target_renew_mins = self.__timeToMins(end_time) + renew_info["expect_duration"]*60
renew_ok_btn = self.__driver.find_element(
By.CSS_SELECTOR, "#extendDiv .btnOK"
)
try:
renew_time_opts = self.__driver.find_elements(
By.CSS_SELECTOR, "#extendDiv .renewal_List li"
)
free_times = []
best_time_diff = max_diff
best_actual_diff = None
best_time_opt = None
target_renew_mins = self._timeStrToMins(end_time) + renew_info["expect_duration"]*60
if not renew_time_opts:
self._showTrace("当前未查询到可用续约时间 !")
return False
for time_opt in renew_time_opts:
time_attr = time_opt.get_attribute("id")
if time_attr and time_attr.isdigit():
time_val = int(time_attr)
free_times.append(time_opt.text.strip())
else:
continue
actual_diff = time_val - target_renew_mins
abs_diff = abs(actual_diff)
if abs_diff < best_time_diff or (
abs_diff == best_time_diff and (
(prefer_earlier and actual_diff <= 0) or
(not prefer_earlier and actual_diff >= 0)
)
):
best_time_diff = abs_diff
best_actual_diff = actual_diff
best_time_opt = time_opt
if best_time_opt is not None:
best_time_opt.click()
abs_time_diff = abs(best_actual_diff)
if best_actual_diff < 0:
time_relation = f"早了 {abs_time_diff} 分钟"
elif best_actual_diff > 0:
time_relation = f"晚了 {abs_time_diff} 分钟"
else:
time_relation = f"正好等于续约时间"
self._showTrace(
f"选择距离期望续约时间最近的 {best_time_opt.text}, "\
f"与期望续约时间相比 {time_relation}"
)
# update the actual renew end time
record["time"]["end"] = best_time_opt.text.strip()
renew_ok_btn.click()
return True
self._showTrace(
"无法选择最近的可用续约时间 !" \
f"所有可选时间与目标时间相差都超过了 {max_diff} 分钟 !"
)
self._showTrace(
f"当前可供续约的时间有: {free_times}"
)
# Validate and adjust target renew time to library closing time
if not self.__validateAndAdjustRenewTime(end_time, target_renew_mins):
return False
renew_ok_btn = self.__driver.find_element(By.CSS_SELECTOR, "#extendDiv .btnOK")
renew_time_opts = self.__driver.find_elements(By.CSS_SELECTOR, "#extendDiv .renewal_List li")
if not renew_time_opts:
self._showTrace("当前未查询到可用续约时间 !", self.TraceLevel.WARNING)
return False
# Find best renewal time option
best_opt, best_text, actual_diff, free_times = self._findBestTimeOption(
renew_time_opts, target_renew_mins, max_diff, prefer_earlier, is_reserve=False
)
if best_opt is not None:
return self.__confirmRenewal(best_opt, best_text, actual_diff, record, renew_ok_btn)
self._showTrace(
"无法选择最近的可用续约时间 ! "
f"所有可选时间与目标时间相差都超过了 {max_diff} 分钟 !",
self.TraceLevel.WARNING
)
self._showTrace(f"当前可供续约的时间有: {free_times}")
return False
def __validateAndAdjustRenewTime(
self,
end_time: str,
target_renew_mins: int
) -> bool:
"""
Validate and adjust renewal time to library closing time if needed.
"""
LIBRARY_CLOSE_TIME = 1410 # 23:30 in minutes
if target_renew_mins > LIBRARY_CLOSE_TIME:
actual_renew_duration = LIBRARY_CLOSE_TIME - self._timeStrToMins(end_time)
if actual_renew_duration <= 0:
self._showTrace(f"当前结束时间 {end_time} 已接近闭馆时间,无法续约 !", self.TraceLevel.ERROR)
return False
self._showTrace(
f"续约时间已调整至闭馆时间 {self._minsToTimeStr(LIBRARY_CLOSE_TIME)},"
f"实际续约时长为 {actual_renew_duration//60} 小时 {actual_renew_duration%60} 分钟"
)
return True
return True
def __confirmRenewal(
self,
best_opt,
best_text: str,
actual_diff: int,
record: dict,
ok_btn
) -> bool:
"""
Confirm the selected renewal time.
"""
try:
best_opt.click()
abs_diff = abs(actual_diff)
time_relation = self._formatTimeRelation(abs_diff, actual_diff, "续约时间")
self._showTrace(
f"选择距离期望续约时间最近的 {best_text}, "
f"与期望续约时间相比 {time_relation}"
)
record["time"]["end"] = best_text.strip()
ok_btn.click()
return True
except:
self._showTrace("查询可用续约时间时发生未知错误 !")
self._showTrace("确认续约时发生错误 !", self.TraceLevel.ERROR)
return False
@@ -184,28 +176,29 @@ class LibRenew(LibOperator):
) -> bool:
if self.__driver is None:
self._showTrace("未提供有效 WebDriver 实例 !")
self._showTrace("未提供有效 WebDriver 实例 !", self.TraceLevel.WARNING)
return False
try:
renew_btn = WebDriverWait(self.__driver, 2).until(
EC.element_to_be_clickable((By.ID, "btnExtend"))
)
except:
self._showTrace(f"用户 {username} 续约界面加载失败 !")
self._showTrace(f"用户 {username} 续约界面加载失败 !", self.TraceLevel.ERROR)
return False
if "disabled" in renew_btn.get_attribute("class"):
self._showTrace(f"用户 {username} 续约按钮不可用, 可能不在场馆内, 请连接图书馆网络后重试")
self._showLog(f"用户 {username} 续约按钮不可用, 可能不在场馆内")
self._showTrace(f"用户 {username} 续约按钮不可用, 可能不在场馆内, 请连接图书馆网络后重试", no_log=True)
return False
renew_btn.click()
if not self.__waitRenewDialog():
self._showTrace(f"用户 {username} 续约失败 !")
self._showTrace(f"用户 {username} 续约失败 !", self.TraceLevel.ERROR)
# After the renewal, the webpage will display a mask overlay,
# so we need to refresh the page for subsequent operations.
self.__driver.refresh()
return False
if not self.__selectNearstTime(record, reserve_info):
self._showTrace(f"用户 {username} 续约失败 !")
if not self.__selectNearestTime(record, reserve_info):
self._showTrace(f"用户 {username} 续约失败 !", self.TraceLevel.ERROR)
self.__driver.refresh()
return False
if self._waitResponseLoad():
+151 -150
View File
@@ -10,16 +10,15 @@ See the LICENSE file for details.
import time
import queue
from datetime import datetime
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.webdriver import WebDriver
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from base.LibOperator import LibOperator
from base.LibTimeSelector import LibTimeSelector
class LibReserve(LibOperator):
class LibReserve(LibTimeSelector):
def __init__(
self,
@@ -73,13 +72,13 @@ class LibReserve(LibOperator):
By.CSS_SELECTOR, ".layoutSeat dd"
)
if not content_elements:
self._showTrace("未找到预约结果")
self._showTrace("未找到预约结果", self.TraceLevel.WARNING)
raise
title = title_elements[0].text if title_elements else ""
contents = [element.text for element in content_elements if element.text.strip()]
for message in contents:
if "预约失败" in message or "已有1个有效预约" in message:
self._showTrace(f"预约失败 - {"".join(contents)}")
self._showTrace(f"预约失败 - {"".join(contents)}", self.TraceLevel.ERROR)
raise
if "预定好了" in title or "预约成功" in title or "操作成功" in title:
if len(contents) >= 6:
@@ -97,25 +96,9 @@ class LibReserve(LibOperator):
)
return True
except:
self._showTrace(f"预约结果加载失败 !")
self._showTrace(f"预约结果加载失败 !", self.TraceLevel.ERROR)
return False
@staticmethod
def __timeToMins(
time_str: str
) -> int:
hour, minute = map(int, time_str.split(":"))
return hour*60 + minute
@staticmethod
def __minsToTime(
mins: int
) -> str:
hour, minute = divmod(mins, 60)
return f"{hour:02d}:{minute:02d}"
def __containRequiredInfo(
self,
@@ -124,6 +107,8 @@ class LibReserve(LibOperator):
try:
# must contain the required infomation
# key 'place' is no need to check
# because 'place' is only has one possible value '1' or '图书馆'
if reserve_info.get("floor") is None: # if existence ?
raise ValueError("未指定楼层")
if reserve_info["floor"] not in self.__floor_map: # if in the mao ?
@@ -140,7 +125,13 @@ class LibReserve(LibOperator):
except ValueError as e:
self._showTrace(
f"预约信息错误 ! : {e}, "\
f"由于缺少必要的预约信息, 无法开始预约流程, 请检查预约信息是否完整"
f"由于缺少必要的预约信息, 无法开始预约流程",
self.TraceLevel.ERROR
)
self._showTrace(
f"预约信息错误 ! : {e}, "\
f"由于缺少必要的预约信息, 无法开始预约流程, 请检查预约信息是否完整",
no_log=True
)
return False
@@ -150,17 +141,20 @@ class LibReserve(LibOperator):
reserve_info: dict
) -> bool:
cur_date = time.strftime("%Y-%m-%d", time.localtime())
cur_date_str = time.strftime("%Y-%m-%d", time.localtime())
cur_timestamp = time.mktime(time.strptime(cur_date_str, "%Y-%m-%d"))
if reserve_info.get("date") is None:
reserve_info["date"] = cur_date
self._showTrace(f"预约日期未指定, 自动设置为当前日期: {cur_date}")
reserve_info["date"] = cur_date_str
self._showTrace(f"预约日期未指定, 自动设置为当前日期: {cur_date_str}")
else:
if reserve_info["date"] < cur_date:
res_timestamp = time.mktime(time.strptime(reserve_info["date"], "%Y-%m-%d"))
if res_timestamp < cur_timestamp:
self._showTrace(
f"预约日期错误 ! :"\
f"{reserve_info['date']} 早于当前日期 {cur_date}, 自动设置为当前日期"
f"{reserve_info['date']} 早于当前日期 {cur_date_str}, 自动设置为当前日期",
self.TraceLevel.WARNING
)
reserve_info["date"] = cur_date
reserve_info["date"] = cur_date_str
return True
@@ -207,10 +201,13 @@ class LibReserve(LibOperator):
if reserve_info.get("end_time") is None:
reserve_info["end_time"] = {}
if "time" not in reserve_info["end_time"]:
end_mins = self.__timeToMins(reserve_info["begin_time"]["time"])
# here we add the expect duration to the begin time first,
# the edge case that the end time is later than 23:30 will
# be handled in __finalCheck. so no need to concern about it.
end_mins = self._timeStrToMins(reserve_info["begin_time"]["time"])
end_mins = end_mins + int(reserve_info["expect_duration"]*60)
reserve_info["end_time"] = {
"time": self.__minsToTime(end_mins),
"time": self._minsToTimeStr(end_mins),
"max_diff": 30,
"prefer_early": False
}
@@ -232,32 +229,39 @@ class LibReserve(LibOperator):
):
begin_time, end_time = reserve_info["begin_time"], reserve_info["end_time"]
begin_mins = self.__timeToMins(begin_time["time"])
end_mins = self.__timeToMins(end_time["time"])
begin_mins = self._timeStrToMins(begin_time["time"])
end_mins = self._timeStrToMins(end_time["time"])
# if end time is earlier than begin_time, exchange them
if end_mins < begin_mins:
# except that the user has set the satisfy_duration to True
if end_mins < begin_mins and reserve_info["satisfy_duration"] is False:
self._showTrace(
f"结束时间 {end_time['time']} 早于开始时间 {begin_time['time']}, 尝试交换时间"
f"结束时间 {end_time['time']} 早于开始时间 {begin_time['time']}, 尝试交换时间",
self.TraceLevel.WARNING
)
reserve_info["end_time"] = begin_time
reserve_info["begin_time"] = end_time
begin_time, end_time = reserve_info["begin_time"], reserve_info["end_time"]
begin_mins = self.__timeToMins(begin_time["time"])
end_mins = self.__timeToMins(end_time["time"])
reserve_info["end_time"], reserve_info["begin_time"] = begin_time, end_time
begin_time, end_time = end_time, begin_time
begin_mins = self._timeStrToMins(begin_time["time"])
end_mins = self._timeStrToMins(end_time["time"])
# ensure the end time is not later than 23:30
if end_mins > self.__timeToMins("23:30"):
max_end_mins = self._timeStrToMins("23:30")
if end_mins > max_end_mins:
self._showTrace(
f"结束时间 {end_time['time']} 晚于 23:30, 自动设置为 23:30"
f"结束时间 {end_time['time']} 晚于 23:30, 自动设置为 23:30",
self.TraceLevel.WARNING
)
reserve_info["end_time"]["time"] = "23:30"
end_mins = self.__timeToMins("23:30")
end_mins = max_end_mins
# ensure the duration is not longer than 8 hours
if reserve_info["satisfy_duration"]:
if reserve_info["expect_duration"] > 8:
self._showTrace(
f"该用户设置了优先满足时长要求, 但是预约期望持续时间 "
f"{reserve_info['expect_duration']} 小时 "
f"超出最大时长 8 小时, 自动设置为 8 小时"
f"超出最大时长 8 小时, 自动设置为 8 小时",
self.TraceLevel.WARNING
)
reserve_info["expect_duration"] = 8
else:
@@ -265,9 +269,10 @@ class LibReserve(LibOperator):
self._showTrace(
f"该用户未设置优先满足时长要求, 但是检查到预约持续时间 "
f"{float((end_mins - begin_mins)/60)} 小时 "
f"超出最大时长 8 小时, 自动设置为 8 小时"
f"超出最大时长 8 小时, 自动设置为 8 小时",
self.TraceLevel.WARNING
)
reserve_info["end_time"]["time"] = self.__minsToTime(begin_mins + 8*60)
reserve_info["end_time"]["time"] = self._minsToTimeStr(begin_mins + 8*60)
return True
@@ -291,8 +296,8 @@ class LibReserve(LibOperator):
self._showTrace(
f"预约信息检查完成, 准备预约 "
f"{reserve_info['date']} "
f"{reserve_info['begin_time']["time"]} - "
f"{reserve_info['end_time']["time"]} "
f"{reserve_info['begin_time']['time']} - "
f"{reserve_info['end_time']['time']} "
f"图书馆 "
f"{self.__floor_map[reserve_info['floor']]} "
f"{self.__room_map[reserve_info['room']]} "
@@ -435,7 +440,7 @@ class LibReserve(LibOperator):
EC.element_to_be_clickable((By.ID, "findRoom"))
).click()
except:
self._showTrace("加载房间/区域失败 !")
self._showTrace("加载房间/区域失败 !", self.TraceLevel.ERROR)
return False
# select room
try:
@@ -445,7 +450,7 @@ class LibReserve(LibOperator):
self._showTrace(f"房间 {display_room} 选择成功 !")
return True
except:
self._showTrace(f"选择房间失败 ! : {display_room} 不可用")
self._showTrace(f"选择房间失败 ! : {display_room} 不可用", self.TraceLevel.ERROR)
return False
@@ -463,7 +468,7 @@ class LibReserve(LibOperator):
EC.presence_of_all_elements_located((By.CSS_SELECTOR, "li[id^='seat_']"))
)
except:
self._showTrace(f"座位加载失败 !")
self._showTrace(f"座位加载失败 !", self.TraceLevel.ERROR)
return False
try:
all_seats = self.__driver.find_elements(
@@ -481,9 +486,10 @@ class LibReserve(LibOperator):
seat_status = seat_link.get_attribute("title")
self._showTrace(f"座位 {seat_id} 选择成功 ! : 当前状态 - '{seat_status}'")
return True
self._showTrace(f"座位 {seat_id} 在该楼层区域中不存在, 请检查座位号是否正确")
self._showLog(f"座位 {seat_id} 在该楼层区域中不存在", self.TraceLevel.WARNING)
self._showTrace(f"座位 {seat_id} 在该楼层区域中不存在, 请检查座位号是否正确", no_log=True)
except:
self._showTrace(f"座位选择失败 !")
self._showTrace(f"座位选择失败 !", self.TraceLevel.ERROR)
return False
@@ -496,6 +502,13 @@ class LibReserve(LibOperator):
prefer_earlier: bool = True
) -> int:
"""
Select the nearest available time option.
Returns:
int: The actual selected time value in minutes.
"""
# Wait for time options to load
try:
WebDriverWait(self.__driver, 2).until(
EC.presence_of_all_elements_located(
@@ -503,130 +516,118 @@ class LibReserve(LibOperator):
)
)
except:
self._showTrace(f"{time_type} 选择失败 ! : 当前未查询到可用时间")
self._showTrace(f"{time_type} 选择失败 ! : 当前未查询到可用时间", self.TraceLevel.ERROR)
return -1
try:
all_time_opts = self.__driver.find_elements(
By.CSS_SELECTOR,
f"#{time_id} ul li a"
)
free_times = []
best_time_diff = max_time_diff
best_actual_diff = None
best_time_opt = None
if not all_time_opts:
self._showTrace(f"{time_type} 选择失败 ! : 当前未查询到可用时间")
return -1
for time_opt in all_time_opts:
time_attr = time_opt.get_attribute("time")
if time_attr == "now":
now = datetime.now()
time_val = int(now.hour*60 + now.minute)
elif time_attr and time_attr.isdigit():
time_val = int(time_attr)
else:
continue
free_times.append(self.__minsToTime(time_val))
actual_diff = time_val - target_time
abs_diff = abs(actual_diff)
if abs_diff < best_time_diff or (
abs_diff == best_time_diff and (
# prefer earlier time
(prefer_earlier and actual_diff <= 0) or
# prefer later time
(not prefer_earlier and actual_diff >= 0)
)
):
best_time_diff = abs_diff
best_actual_diff = actual_diff
best_time_opt = time_opt
if best_time_opt is not None:
best_time_opt.click()
abs_time_diff = abs(best_actual_diff)
if best_actual_diff < 0:
time_relation = f"早了 {abs_time_diff} 分钟"
elif best_actual_diff > 0:
time_relation = f"晚了 {abs_time_diff} 分钟"
else:
time_relation = f"正好等于 {time_type}"
target_time += best_actual_diff
self._showTrace(
f"选择距离期望 {time_type} 最近的 {best_time_opt.text}, "\
f"与期望 {time_type} 相比 {time_relation}"
)
return target_time
# Find best time option
all_time_opts = self.__driver.find_elements(
By.CSS_SELECTOR,
f"#{time_id} ul li a"
)
if not all_time_opts:
self._showTrace(f"{time_type} 选择失败 ! : 当前未查询到可用时间", self.TraceLevel.ERROR)
return -1
best_opt, best_text, actual_diff, free_times = self._findBestTimeOption(
all_time_opts, target_time, max_time_diff, prefer_earlier, is_reserve=True
)
if best_opt is not None:
best_opt.click()
abs_diff = abs(actual_diff)
time_relation = self._formatTimeRelation(abs_diff, actual_diff, time_type)
target_time += actual_diff
self._showTrace(
f"无法选择最近的 {time_type} {self.__minsToTime(target_time)}, "\
f"所有可选时间与目标时间相差都超过 {max_time_diff} 分钟"
f"选择距离期望 {time_type} 最近的 {best_text}, "
f"与期望 {time_type} 相比 {time_relation}"
)
self._showTrace(f"当前可供预约的 {time_type} 有: {free_times}")
return -1
except:
self._showTrace(f"{time_type} {self.__minsToTime(target_time)} 选择失败 !")
return -1
return target_time
self._showTrace(
f"无法选择最近的 {time_type} {self._minsToTimeStr(target_time)}, "
f"所有可选时间与目标时间相差都超过 {max_time_diff} 分钟", self.TraceLevel.WARNING
)
self._showTrace(f"当前可供预约的 {time_type} 有: {free_times}")
return -1
def __selectSeatTime(
self,
begin_time: dict,
end_time: dict,
expct_duration: int = 4,
expect_duration: int = 4,
satisfy_duration: bool = True
) -> bool:
expect_begin_time = actual_begin_time = begin_time["time"]
expect_end_time = actual_end_time = end_time["time"]
expect_begin_mins = self.__timeToMins(expect_begin_time)
actual_begin_mins = expect_begin_mins
expect_end_mins = self.__timeToMins(expect_end_time)
"""
Select seat begin and end time.
"""
exp_beg_tm_str = begin_time["time"]
exp_end_tm_str = end_time["time"]
# Initialize actual time strings for logging
act_beg_tm_str = exp_beg_tm_str
act_end_tm_str = exp_end_tm_str
exp_beg_mins = self._timeStrToMins(exp_beg_tm_str)
act_beg_mins = exp_beg_mins
exp_end_mins = self._timeStrToMins(exp_end_tm_str)
act_end_mins = exp_end_mins
# select the begin time
if self.__selectNearestTime(
time_id="startTime", # dont change into begin, this is the element in the page
# Select begin time
act_beg_mins = self.__selectNearestTime(
time_id="startTime",
time_type="开始时间",
target_time=expect_begin_mins,
target_time=exp_beg_mins,
max_time_diff=begin_time["max_diff"],
prefer_earlier=begin_time["prefer_early"]
) == -1:
)
if act_beg_mins == -1:
return False
else:
actual_begin_time = self.__minsToTime(expect_begin_mins)
actual_begin_mins = self.__timeToMins(actual_begin_time)
# if 'satisfy_duration' is True.
# select the end time based on the begin time
# (because it may be changed under the 'max time diff' strategy) and expect duration.
act_beg_tm_str = self._minsToTimeStr(act_beg_mins)
# If 'satisfy_duration' is True, select end time based on actual begin time
if satisfy_duration:
expect_end_mins = int(actual_begin_mins + expct_duration*60)
if expect_end_mins > self.__timeToMins("23:30"):
expect_end_mins = self.__timeToMins("23:30")
self._showTrace(
f"预约持续时间 {expct_duration} 小时, 超过最大预约时间 23:30, 自动调整为 23:30"
)
expect_end_time = self.__minsToTime(expect_end_mins)
exp_end_mins = int(self.validateAndAdjustEndTime(act_beg_mins, expect_duration))
exp_end_tm_str = self._minsToTimeStr(exp_end_mins)
self._showTrace(
f"需要满足期望预约持续时间: {expct_duration} 小时, "\
f"根据开始时间 {actual_begin_time} 计算结束时间: {self.__minsToTime(expect_end_mins)}"
f"需要满足期望预约持续时间: {expect_duration} 小时, "
f"根据开始时间 {act_beg_tm_str} 计算结束时间: {exp_end_tm_str}"
)
# select the end time
if self.__selectNearestTime(
# Select end time
act_end_mins = self.__selectNearestTime(
time_id="endTime",
time_type="结束时间",
target_time=expect_end_mins,
target_time=exp_end_mins,
max_time_diff=end_time["max_diff"],
prefer_earlier=end_time["prefer_early"]
) == -1:
)
if act_end_mins == -1:
return False
else:
actual_end_time = self.__minsToTime(expect_end_mins)
act_end_tm_str = self._minsToTimeStr(act_end_mins)
self._showTrace(
f"期望预约时间段: {expect_begin_time} - {expect_end_time}, "
f"实际预约时间段: {actual_begin_time} - {actual_end_time}"
f"期望预约时间段: {exp_beg_tm_str} - {exp_end_tm_str}, "
f"实际预约时间段: {act_beg_tm_str} - {act_end_tm_str}"
)
return True
def validateAndAdjustEndTime(
self,
begin_mins: int,
duration: int
) -> int:
"""
Validate and adjust reserve end time to library closing time if needed.
"""
LIBRARY_CLOSE_TIME = self._timeStrToMins("23:30")
expect_end_mins = int(begin_mins + duration*60)
if expect_end_mins > LIBRARY_CLOSE_TIME:
expect_end_mins = LIBRARY_CLOSE_TIME
self._showTrace(
f"预约持续时间 {duration} 小时, 超过最大预约时间 23:30, 自动调整为 23:30",
self.TraceLevel.WARNING
)
return expect_end_mins
def reserve(
self,
username: str,
@@ -649,7 +650,7 @@ class LibReserve(LibOperator):
EC.presence_of_element_located((By.ID, "seatLayout"))
)
except:
self._showTrace(f"加载预约选座页面失败 !")
self._showTrace(f"加载预约选座页面失败 !", self.TraceLevel.ERROR)
return False
# date, place, floor, room
if not self.__selectDate(reserve_info["date"]):
@@ -668,7 +669,7 @@ class LibReserve(LibOperator):
elif not self.__selectSeatTime(
begin_time=reserve_info["begin_time"],
end_time=reserve_info["end_time"],
expct_duration=reserve_info["expect_duration"],
expect_duration=reserve_info["expect_duration"],
satisfy_duration=reserve_info["satisfy_duration"]
):
pass
@@ -682,11 +683,11 @@ class LibReserve(LibOperator):
raise
reserve_success = True
except:
self._showTrace(f"预约提交失败 !")
self._showTrace(f"预约提交失败 !", self.TraceLevel.ERROR)
if not submit_reserve and have_hover_on_page:
self.__driver.refresh()
if reserve_success:
self._showTrace(f"用户 {username} 预约成功 !")
else:
self._showTrace(f"用户 {username} 预约失败 !")
self._showTrace(f"用户 {username} 预约失败 !", self.TraceLevel.ERROR)
return reserve_success
+50
View File
@@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
from datetime import datetime, timedelta
def calculateNextRepeatTime(
repeat_days: list,
hour: int,
minute: int,
second: int
) -> datetime:
"""
Calculate the next repeat time based on repeat days and target time.
This function calculates the next execution time for a repeatable task.
If the current day is in repeat_days and the target time has not passed,
it returns today's target time. Otherwise, it finds the next matching day.
Args:
repeat_days (list): List of weekdays to repeat (0=Monday, 6=Sunday).
hour (int): Target hour (0-23).
minute (int): Target minute (0-59).
second (int): Target second (0-59).
Returns:
datetime: The next repeat execution time.
"""
current_time = datetime.now()
current_weekday = current_time.weekday()
target_time = current_time.replace(hour=hour, minute=minute, second=second, microsecond=0)
if current_weekday in repeat_days:
if target_time > current_time:
return target_time
repeat_days_sorted = sorted(repeat_days)
for day in repeat_days_sorted:
if day > current_weekday:
days_until = day - current_weekday
next_time = target_time + timedelta(days=days_until)
return next_time
days_until = 7 - current_weekday + repeat_days_sorted[0]
next_time = target_time + timedelta(days=days_until)
return next_time
+1 -1
View File
@@ -2,7 +2,7 @@
Utils module for the AutoLibrary project.
Here are the classes and modules in this package:
- ConfigManager: Configuration manager class for the AutoLibrary project.
- TimerUtils: Timer utils class for the AutoLibrary project.
- JSONReader: JSON reader class for the AutoLibrary project.
- JSONWriter: JSON writer class for the AutoLibrary project.
"""