1
1
mirror of https://github.com/KenanZhu/AutoLibrary.git synced 2026-06-22 01:13:02 +08:00

Merge branch 'main' into feature/global-settings

This commit is contained in:
Kenan Zhu
2026-06-21 09:31:34 +08:00
committed by GitHub
11 changed files with 802 additions and 98 deletions
+282 -9
View File
@@ -4,6 +4,9 @@ name: Build Test
# It is triggered when a pull request is opened, synchronized, or reopened against the main branch. # It is triggered when a pull request is opened, synchronized, or reopened against the main branch.
on: on:
push:
branches:
- main
pull_request: pull_request:
branches: branches:
- main - main
@@ -209,13 +212,283 @@ jobs:
- name: Upload build summary - name: Upload build summary
run: | run: |
Write-Host "## Build Test Summary" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 "## Build Test Summary" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8
Write-Host "" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append "" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "========================================" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append "========================================" | 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 "✓ 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 "- 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 "- 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 || 'N/A' }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append "- Pull Request #${{ github.event.pull_request.number || 'N/A' }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Branch: ${{ github.event.pull_request.head.ref || github.ref }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append "- Branch: ${{ github.event.pull_request.head.ref || github.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 "- Event: ${{ github.event_name }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
shell: pwsh shell: pwsh
#
# Build macOS
#
build-macos:
runs-on: macos-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ github.ref }}
- name: Get version info
id: get_version
run: |
version="pr-test"
tag_name="pr-test"
echo "✓ Mode: Pull Request Test Build"
echo "✓ Tag: $tag_name"
echo "✓ Version: $version"
echo "VERSION=$version" >> $GITHUB_OUTPUT
echo "TAG_NAME=$tag_name" >> $GITHUB_OUTPUT
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.13'
cache: 'pip'
cache-dependency-path: requirements.txt
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Solve ddddocr compatibility and copy model files
run: |
ddddocr_path=$(python -c "import ddddocr, os; print(os.path.dirname(ddddocr.__file__))")
echo "ddddocr package location: $ddddocr_path"
init_file="$ddddocr_path/__init__.py"
if [ -f "$init_file" ]; then
echo "Fixing ddddocr compatibility in: $init_file"
# macOS 使用 BSD sed,要求 -i 后跟备份后缀名(空字符串 = 不备份)
sed -i '' 's/Image\.ANTIALIAS/Image.Resampling.LANCZOS/g' "$init_file"
echo "✓ Fixed: Image.ANTIALIAS -> Image.Resampling.LANCZOS"
else
echo "✗ ddddocr __init__.py not found"
exit 1
fi
mkdir -p models
echo "✓ Created models directory"
# 多路径 fallback 搜索 ONNX 模型,提升 ddddocr 版本兼容性
onnx_source=""
onnx_dest="models/common.onnx"
candidate_paths=(
"$ddddocr_path/common.onnx"
"$ddddocr_path/models/common.onnx"
"$ddddocr_path/ddddocr/common.onnx"
)
for candidate in "${candidate_paths[@]}"; do
if [ -f "$candidate" ]; then
onnx_source="$candidate"
break
fi
done
# 若以上候选均未命中,回退到全包搜索
if [ -z "$onnx_source" ]; then
echo "⚠ 未在已知路径找到 ONNX 模型,回退到全包搜索..."
onnx_source=$(find "$ddddocr_path" -name "*.onnx" -type f 2>/dev/null | head -1)
fi
if [ -n "$onnx_source" ] && [ -f "$onnx_source" ]; then
cp "$onnx_source" "$onnx_dest"
echo "✓ ONNX model copied: $onnx_source -> $onnx_dest"
else
echo "✗ ONNX model not found in ddddocr package"
echo " Searched directory: $ddddocr_path"
ls -la "$ddddocr_path/" || true
exit 1
fi
if [ -f "$onnx_dest" ]; then
file_size=$(du -h "$onnx_dest" | cut -f1)
echo "✓ Model file verified: $onnx_dest (Size: $file_size)"
else
echo "✗ Failed to copy model file"
exit 1
fi
- name: Compile Qt Resource files
run: |
cd batchs
bash compile_rc.sh
- name: Compile Qt UI files
run: |
cd batchs
bash compile_ui.sh
- name: Generate 'Main.spec'
env:
APP_VERSION: ${{ steps.get_version.outputs.VERSION }}
run: |
version="$APP_VERSION"
exe_name="AutoLibrary-$version"
echo "Generating Main.spec for version: $version"
echo "App name: $exe_name"
printf '%s\n' \
'# -*- mode: python ; coding: utf-8 -*-' \
'' \
'a = Analysis(' \
" ['src/Main.py']," \
' pathex=[],' \
' binaries=[],' \
' datas=[' \
" ('models/common.onnx', 'ddddocr')," \
" ('src/gui/resources/icons/AutoLibrary_Logo_64.ico', 'gui/resources/icons')," \
' ],' \
' hiddenimports=[],' \
' hookspath=[],' \
' hooksconfig={},' \
' runtime_hooks=[],' \
' excludes=[],' \
' noarchive=False,' \
' optimize=0,' \
')' \
'pyz = PYZ(a.pure)' \
'' \
'exe = EXE(' \
' pyz,' \
' a.scripts,' \
' [],' \
' exclude_binaries=True,' \
" 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_Logo_64.ico']," \
')' \
'' \
'coll = COLLECT(' \
' exe,' \
' a.binaries,' \
' a.zipfiles,' \
' a.datas,' \
' strip=False,' \
' upx=True,' \
' upx_exclude=[],' \
" name='${exe_name}'," \
')' \
'' \
'app = BUNDLE(' \
' coll,' \
" name='AutoLibrary.app'," \
" icon='src/gui/resources/icons/AutoLibrary_Logo.icns'," \
" bundle_identifier='com.kenanzhu.autolibrary'," \
' info_plist={' \
" 'NSHighResolutionCapable': 'True'," \
" 'CFBundleName': 'AutoLibrary'," \
" 'CFBundleDisplayName': 'AutoLibrary'," \
" 'CFBundleShortVersionString': '${version}'," \
" 'CFBundleVersion': '${version}'," \
" 'CFBundleExecutable': 'AutoLibrary'," \
" 'NSPrincipalClass': 'NSApplication'," \
" 'LSMinimumSystemVersion': '11.0'," \
" 'NSRequiresAquaSystemAppearance': 'False'," \
" 'NSHumanReadableCopyright': 'Copyright 2025 - 2026 KenanZhu. All rights reserved.'," \
' },' \
')' \
> Main.spec
echo "✓ Main.spec generated successfully"
echo ""
echo "========================================"
echo "Generated Main.spec"
echo "========================================"
cat Main.spec
echo "========================================"
- name: Build with PyInstaller
run: |
python -m PyInstaller Main.spec
- name: Create DMG
run: |
tag_name="${{ steps.get_version.outputs.TAG_NAME }}"
dmg_name="AutoLibrary.$tag_name-macos-arm64.dmg"
app_path="dist/AutoLibrary.app"
if [ ! -d "$app_path" ]; then
echo "✗ App bundle not found: $app_path"
echo "Files in dist directory:"
ls -la dist/
exit 1
fi
echo "Creating DMG from: $app_path"
xattr -cr "$app_path" 2>/dev/null || true
tmp_dmg_dir=$(mktemp -d)
cp -R "$app_path" "$tmp_dmg_dir/"
ln -s /Applications "$tmp_dmg_dir/Applications"
hdiutil create \
-volname "AutoLibrary $tag_name" \
-srcfolder "$tmp_dmg_dir" \
-ov \
-format UDZO \
"$dmg_name"
rm -rf "$tmp_dmg_dir"
if [ -f "$dmg_name" ]; then
dmg_size=$(du -h "$dmg_name" | cut -f1)
echo "✓ DMG created: $dmg_name (Size: $dmg_size)"
else
echo "✗ Failed to create DMG"
exit 1
fi
echo "✓ Artifacts ready:"
echo " - $dmg_name"
echo " - $app_path"
- name: Archive artifacts
uses: actions/upload-artifact@v6
with:
name: AutoLibrary.${{ steps.get_version.outputs.TAG_NAME }}-macos-arm64
path: |
AutoLibrary.${{ steps.get_version.outputs.TAG_NAME }}-macos-arm64.dmg
dist/AutoLibrary.app/**
retention-days: 7
- name: Upload build summary
run: |
tag_name="${{ steps.get_version.outputs.TAG_NAME }}"
version="${{ steps.get_version.outputs.VERSION }}"
dmg_name="AutoLibrary.$tag_name-macos-arm64.dmg"
{
echo "## Build Test Summary (macOS)"
echo ""
echo "========================================"
echo "✓ Pull request build test completed successfully!"
echo "- Version: $version"
echo "- Tag: $tag_name"
echo "- Pull Request #${{ github.event.pull_request.number || 'N/A' }}"
echo "- Branch: ${{ github.event.pull_request.head.ref || github.ref }}"
echo "- Event: ${{ github.event_name }}"
echo "- DMG: $dmg_name"
echo ""
echo "### How to test on macOS"
echo '1. Download the DMG artifact'
echo '2. Open the DMG and drag AutoLibrary.app to /Applications'
echo '3. Right-click → Open the app (to bypass Gatekeeper)'
} >> $GITHUB_STEP_SUMMARY
+311 -10
View File
@@ -1,7 +1,10 @@
name: Build name: Build
# This workflow compiles the application for Windows platform using PyInstaller, and # This workflow compiles the application for Windows and macOS platforms using
# archives the built artifacts as 'AutoLibrary.<tag_name>-windows-x86_64.zip'. # PyInstaller, and archives the built artifacts.
#
# - Windows: AutoLibrary.<tag_name>-windows-x86_64.zip
# - macOS: AutoLibrary.<tag_name>-macos-arm64.dmg
# #
# It is triggered when called by the release workflow. # It is triggered when called by the release workflow.
@@ -251,12 +254,310 @@ jobs:
- name: Upload build summary - name: Upload build summary
if: ${{ inputs.is_test == 'true' }} if: ${{ inputs.is_test == 'true' }}
run: | run: |
Write-Host "## Build Summary" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 "## Build Summary" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8
Write-Host "" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append "" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "========================================" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append "========================================" | 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 "✓ 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 "- 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 "- 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 "- 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 "- Ref: ${{ github.ref }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
shell: pwsh shell: pwsh
#
# Build macOS
#
build-macos:
runs-on: macos-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ github.ref }}
- name: Download build version of ALVersionInfo.py
uses: actions/download-artifact@v6
with:
name: updated-version-info-for-build
path: src/gui/
- name: Get version info
id: get_version
run: |
is_test="${{ inputs.is_test }}"
if [ "$is_test" = "true" ]; then
version="test"
tag_name="test"
echo "✓ Mode: Test Build"
else
version="${{ inputs.version }}"
tag_name="${{ inputs.tag_name }}"
if [ -z "$version" ]; then
version="test"
tag_name="test"
echo "✓ Mode: Independent Build (No inputs provided)"
fi
fi
echo "✓ Tag: $tag_name"
echo "✓ Version: $version"
echo "VERSION=$version" >> $GITHUB_OUTPUT
echo "TAG_NAME=$tag_name" >> $GITHUB_OUTPUT
- name: Verify 'ALVersionInfo.py' was updated
run: |
version_info_file="src/gui/ALVersionInfo.py"
echo "Verifying $version_info_file content:"
echo "========================================"
cat "$version_info_file"
echo "========================================"
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.13'
cache: 'pip'
cache-dependency-path: requirements.txt
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Solve ddddocr compatibility and copy model files
run: |
ddddocr_path=$(python -c "import ddddocr, os; print(os.path.dirname(ddddocr.__file__))")
echo "ddddocr package location: $ddddocr_path"
init_file="$ddddocr_path/__init__.py"
if [ -f "$init_file" ]; then
echo "Fixing ddddocr compatibility in: $init_file"
# macOS 使用 BSD sed,要求 -i 后跟备份后缀名(空字符串 = 不备份)
sed -i '' 's/Image\.ANTIALIAS/Image.Resampling.LANCZOS/g' "$init_file"
echo "✓ Fixed: Image.ANTIALIAS -> Image.Resampling.LANCZOS"
else
echo "✗ ddddocr __init__.py not found"
exit 1
fi
mkdir -p models
echo "✓ Created models directory"
# 多路径 fallback 搜索 ONNX 模型,提升 ddddocr 版本兼容性
onnx_source=""
onnx_dest="models/common.onnx"
candidate_paths=(
"$ddddocr_path/common.onnx"
"$ddddocr_path/models/common.onnx"
"$ddddocr_path/ddddocr/common.onnx"
)
for candidate in "${candidate_paths[@]}"; do
if [ -f "$candidate" ]; then
onnx_source="$candidate"
break
fi
done
# 若以上候选均未命中,回退到全包搜索
if [ -z "$onnx_source" ]; then
echo "⚠ 未在已知路径找到 ONNX 模型,回退到全包搜索..."
onnx_source=$(find "$ddddocr_path" -name "*.onnx" -type f 2>/dev/null | head -1)
fi
if [ -n "$onnx_source" ] && [ -f "$onnx_source" ]; then
cp "$onnx_source" "$onnx_dest"
echo "✓ ONNX model copied: $onnx_source -> $onnx_dest"
else
echo "✗ ONNX model not found in ddddocr package"
echo " Searched directory: $ddddocr_path"
ls -la "$ddddocr_path/" || true
exit 1
fi
if [ -f "$onnx_dest" ]; then
file_size=$(du -h "$onnx_dest" | cut -f1)
echo "✓ Model file verified: $onnx_dest (Size: $file_size)"
else
echo "✗ Failed to copy model file"
exit 1
fi
- name: Compile Qt Resource files
run: |
cd batchs
bash compile_rc.sh
- name: Compile Qt UI files
run: |
cd batchs
bash compile_ui.sh
- name: Generate 'Main.spec'
env:
APP_VERSION: ${{ steps.get_version.outputs.VERSION }}
run: |
version="$APP_VERSION"
exe_name="AutoLibrary-$version"
echo "Generating Main.spec for version: $version"
echo "App name: $exe_name"
printf '%s\n' \
'# -*- mode: python ; coding: utf-8 -*-' \
'' \
'a = Analysis(' \
" ['src/Main.py']," \
' pathex=[],' \
' binaries=[],' \
' datas=[' \
" ('models/common.onnx', 'ddddocr')," \
" ('src/gui/resources/icons/AutoLibrary_Logo_64.ico', 'gui/resources/icons')," \
' ],' \
' hiddenimports=[],' \
' hookspath=[],' \
' hooksconfig={},' \
' runtime_hooks=[],' \
' excludes=[],' \
' noarchive=False,' \
' optimize=0,' \
')' \
'pyz = PYZ(a.pure)' \
'' \
'exe = EXE(' \
' pyz,' \
' a.scripts,' \
' [],' \
' exclude_binaries=True,' \
" 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_Logo_64.ico']," \
')' \
'' \
'coll = COLLECT(' \
' exe,' \
' a.binaries,' \
' a.zipfiles,' \
' a.datas,' \
' strip=False,' \
' upx=True,' \
' upx_exclude=[],' \
" name='${exe_name}'," \
')' \
'' \
'app = BUNDLE(' \
' coll,' \
" name='AutoLibrary.app'," \
" icon='src/gui/resources/icons/AutoLibrary_Logo.icns'," \
" bundle_identifier='com.kenanzhu.autolibrary'," \
' info_plist={' \
" 'NSHighResolutionCapable': 'True'," \
" 'CFBundleName': 'AutoLibrary'," \
" 'CFBundleDisplayName': 'AutoLibrary'," \
" 'CFBundleShortVersionString': '${version}'," \
" 'CFBundleVersion': '${version}'," \
" 'CFBundleExecutable': 'AutoLibrary'," \
" 'NSPrincipalClass': 'NSApplication'," \
" 'LSMinimumSystemVersion': '11.0'," \
" 'NSRequiresAquaSystemAppearance': 'False'," \
" 'NSHumanReadableCopyright': 'Copyright 2025 - 2026 KenanZhu. All rights reserved.'," \
' },' \
')' \
> Main.spec
echo "✓ Main.spec generated successfully"
echo ""
echo "========================================"
echo "Generated Main.spec"
echo "========================================"
cat Main.spec
echo "========================================"
- name: Build with PyInstaller
run: |
python -m PyInstaller Main.spec
- name: Create DMG
run: |
tag_name="${{ steps.get_version.outputs.TAG_NAME }}"
dmg_name="AutoLibrary.$tag_name-macos-arm64.dmg"
app_path="dist/AutoLibrary.app"
if [ ! -d "$app_path" ]; then
echo "✗ App bundle not found: $app_path"
echo "Files in dist directory:"
ls -la dist/
exit 1
fi
echo "Creating DMG from: $app_path"
xattr -cr "$app_path" 2>/dev/null || true
tmp_dmg_dir=$(mktemp -d)
cp -R "$app_path" "$tmp_dmg_dir/"
ln -s /Applications "$tmp_dmg_dir/Applications"
hdiutil create \
-volname "AutoLibrary $tag_name" \
-srcfolder "$tmp_dmg_dir" \
-ov \
-format UDZO \
"$dmg_name"
rm -rf "$tmp_dmg_dir"
if [ -f "$dmg_name" ]; then
dmg_size=$(du -h "$dmg_name" | cut -f1)
echo "✓ DMG created: $dmg_name (Size: $dmg_size)"
else
echo "✗ Failed to create DMG"
exit 1
fi
echo "✓ Artifacts ready:"
echo " - $dmg_name"
echo " - $app_path"
- name: Archive artifacts
uses: actions/upload-artifact@v6
with:
name: AutoLibrary.${{ steps.get_version.outputs.TAG_NAME }}-macos-arm64
path: |
AutoLibrary.${{ steps.get_version.outputs.TAG_NAME }}-macos-arm64.dmg
dist/AutoLibrary.app/**
retention-days: ${{ inputs.is_test == 'true' && 7 || 90 }}
- name: Upload build summary
if: ${{ inputs.is_test == 'true' }}
run: |
tag_name="${{ steps.get_version.outputs.TAG_NAME }}"
version="${{ steps.get_version.outputs.VERSION }}"
dmg_name="AutoLibrary.$tag_name-macos-arm64.dmg"
{
echo "## Build Summary (macOS)"
echo ""
echo "========================================"
echo "✓ Build test completed successfully!"
echo "- Version: $version"
echo "- Tag: $tag_name"
echo "- Event: ${{ github.event_name }}"
echo "- Ref: ${{ github.ref }}"
echo "- DMG: $dmg_name"
echo ""
echo "### How to test on macOS"
echo '1. Download the DMG artifact'
echo '2. Open the DMG and drag AutoLibrary.app to /Applications'
echo '3. Right-click → Open the app (to bypass Gatekeeper)'
} >> $GITHUB_STEP_SUMMARY
+12 -3
View File
@@ -21,8 +21,10 @@ name: Release
# Commits version changes to release branch and creates the release tag. # Commits version changes to release branch and creates the release tag.
# 4. Build: # 4. Build:
# Compiles the application for Windows platform using PyInstaller, and # Compiles the application for Windows and macOS platforms using PyInstaller, and
# archives the built artifacts as 'AutoLibrary.<tag_name>-windows-x86_64.zip'. # archives the built artifacts.
# - Windows: AutoLibrary.<tag_name>-windows-x86_64.zip
# - macOS: AutoLibrary.<tag_name>-macos-arm64.dmg
# 5. Release: # 5. Release:
# Creates GitHub release with generated artifacts and release notes # Creates GitHub release with generated artifacts and release notes
@@ -181,12 +183,18 @@ jobs:
contents: write contents: write
steps: steps:
- name: Download artifacts - name: Download Windows artifacts
uses: actions/download-artifact@v6 uses: actions/download-artifact@v6
with: with:
name: AutoLibrary.${{ needs.extract-version.outputs.tag_name }}-windows-x86_64 name: AutoLibrary.${{ needs.extract-version.outputs.tag_name }}-windows-x86_64
path: artifacts/ path: artifacts/
- name: Download macOS artifacts
uses: actions/download-artifact@v6
with:
name: AutoLibrary.${{ needs.extract-version.outputs.tag_name }}-macos-arm64
path: artifacts/
- name: Create release - name: Create release
id: create_release id: create_release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
@@ -195,6 +203,7 @@ jobs:
name: AutoLibrary ${{ needs.extract-version.outputs.tag_name }} name: AutoLibrary ${{ needs.extract-version.outputs.tag_name }}
files: | files: |
artifacts/AutoLibrary.${{ needs.extract-version.outputs.tag_name }}-windows-x86_64.zip artifacts/AutoLibrary.${{ needs.extract-version.outputs.tag_name }}-windows-x86_64.zip
artifacts/AutoLibrary.${{ needs.extract-version.outputs.tag_name }}-macos-arm64.dmg
draft: false draft: false
prerelease: ${{ needs.extract-version.outputs.is_rc == 'true' }} prerelease: ${{ needs.extract-version.outputs.is_rc == 'true' }}
generate_release_notes: true generate_release_notes: true
Binary file not shown.
@@ -360,6 +360,10 @@ class WebDriverDownloader:
break break
if not driver_file: if not driver_file:
raise FileNotFoundError(f"未找到 web driver 文件 : {expected_name}") raise FileNotFoundError(f"未找到 web driver 文件 : {expected_name}")
# Ensure executable permissions on Unix systems (zipfile
# extraction does not preserve the execute bit).
if os.name != 'nt':
os.chmod(driver_file, 0o755)
progress_callback(100, 100, 0.0, "解压完成") progress_callback(100, 100, 0.0, "解压完成")
self.download_path.unlink() self.download_path.unlink()
self._cleanup(driver_file) self._cleanup(driver_file)
+4
View File
@@ -111,6 +111,10 @@ class WebDriverManager:
for driver_info in self.__driver_infos: for driver_info in self.__driver_infos:
driver_path = self._getDriverPath(driver_info) driver_path = self._getDriverPath(driver_info)
if driver_path and driver_path.exists() and driver_path.is_file(): if driver_path and driver_path.exists() and driver_path.is_file():
# Repair missing execute permission on Unix
# (zip-extracted drivers from older versions).
if os.name != 'nt' and not os.access(str(driver_path), os.X_OK):
os.chmod(str(driver_path), 0o755)
driver_info.driver_path = driver_path driver_info.driver_path = driver_path
driver_info.driver_status = WebDriverStatus.INSTALLED driver_info.driver_status = WebDriverStatus.INSTALLED
+14 -12
View File
@@ -10,6 +10,7 @@ See the LICENSE file for details.
import os import os
import queue import queue
from selenium import webdriver from selenium import webdriver
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.common.exceptions import ( from selenium.common.exceptions import (
TimeoutException, TimeoutException,
WebDriverException, WebDriverException,
@@ -37,11 +38,11 @@ class AutoLib(MsgBase):
output_queue: queue.Queue, output_queue: queue.Queue,
run_config: dict, run_config: dict,
) -> None: ) -> None:
super().__init__(input_queue, output_queue)
super().__init__(input_queue, output_queue)
self.__run_config: dict = run_config self.__run_config: dict = run_config
self.__user_config: dict | None = None self.__user_config: dict | None = None
self.__driver = None self.__driver: WebDriver | None = None
self.__driver_type: str = "" self.__driver_type: str = ""
self.__driver_path: str = "" self.__driver_path: str = ""
self.__login_page: LoginPage = None self.__login_page: LoginPage = None
@@ -67,9 +68,10 @@ class AutoLib(MsgBase):
) -> bool: ) -> bool:
self._showTrace("正在初始化浏览器驱动......", no_log=True) self._showTrace("正在初始化浏览器驱动......", no_log=True)
web_driver_config: dict = self.__run_config.get("web_driver", None) driver_config: dict = self.__run_config.get("web_driver", None)
self.__driver_type = web_driver_config.get("driver_type", "none") self.__driver_type = driver_config.get("driver_type", "none")
match self.__driver_type.lower(): self.__driver_type = self.__driver_type.lower()
match self.__driver_type:
case "edge": case "edge":
driver_options = webdriver.EdgeOptions() driver_options = webdriver.EdgeOptions()
case "chrome": case "chrome":
@@ -82,10 +84,10 @@ class AutoLib(MsgBase):
self.TraceLevel.WARNING, self.TraceLevel.WARNING,
) )
return False return False
if not web_driver_config: if not driver_config:
self._showTrace("未配置浏览器驱动参数 !", self.TraceLevel.ERROR) self._showTrace("未配置浏览器驱动参数 !", self.TraceLevel.ERROR)
return False return False
if web_driver_config.get("headless", False): if driver_config.get("headless", False):
driver_options.add_argument("--headless") driver_options.add_argument("--headless")
driver_options.add_argument("--disable-gpu") driver_options.add_argument("--disable-gpu")
driver_options.add_argument("--no-sandbox") driver_options.add_argument("--no-sandbox")
@@ -110,11 +112,11 @@ class AutoLib(MsgBase):
"AppleWebKit/537.36 (KHTML, like Gecko) "\ "AppleWebKit/537.36 (KHTML, like Gecko) "\
"Chrome/120.0.0.0 "\ "Chrome/120.0.0.0 "\
"Safari/537.36" "Safari/537.36"
if self.__driver_type.lower() == "edge": if self.__driver_type == "edge":
user_agent += " Edg/120.0.0.0" user_agent += " Edg/120.0.0.0"
# set options for firefox # set options for firefox
elif self.__driver_type.lower() == "firefox": elif self.__driver_type == "firefox":
driver_options.set_preference("dom.webdriver.enabled", False) driver_options.set_preference("dom.webdriver.enabled", False)
driver_options.set_preference("useAutomationExtension", False) driver_options.set_preference("useAutomationExtension", False)
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) "\ user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) "\
@@ -122,14 +124,14 @@ class AutoLib(MsgBase):
driver_options.add_argument(f"user-agent={user_agent}") driver_options.add_argument(f"user-agent={user_agent}")
# init browser driver # init browser driver
self.__driver_path = web_driver_config.get("driver_path", "") self.__driver_path = driver_config.get("driver_path", "")
if not self.__driver_path: if not self.__driver_path:
self._showTrace("未配置浏览器驱动路径 !", self.TraceLevel.WARNING) self._showTrace("未配置浏览器驱动路径 !", self.TraceLevel.WARNING)
return False return False
try: try:
self.__driver_path = os.path.abspath(self.__driver_path) self.__driver_path = os.path.abspath(self.__driver_path)
service = None service = None
match self.__driver_type.lower(): match self.__driver_type:
case "edge": case "edge":
service = EdgeService(executable_path=self.__driver_path) service = EdgeService(executable_path=self.__driver_path)
self.__driver = webdriver.Edge(service=service, options=driver_options) self.__driver = webdriver.Edge(service=service, options=driver_options)
@@ -161,7 +163,7 @@ class AutoLib(MsgBase):
self._showTrace("未配置图书馆参数 !", self.TraceLevel.ERROR) self._showTrace("未配置图书馆参数 !", self.TraceLevel.ERROR)
return False return False
url: str = lib_config.get("host_url") + lib_config.get("login_url") url: str = lib_config.get("host_url") + lib_config.get("login_url")
self.__login_page = LoginPage(self.__driver, tracer=self._showTrace) self.__login_page = LoginPage(self._input_queue, self._output_queue, self.__driver)
self.__driver.set_page_load_timeout(5) self.__driver.set_page_load_timeout(5)
try: try:
self.__driver.get(url) self.__driver.get(url)
+13 -19
View File
@@ -7,7 +7,8 @@ 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. You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details. See the LICENSE file for details.
""" """
from typing import Callable, Optional import queue
from typing import Callable
from selenium.common.exceptions import ( from selenium.common.exceptions import (
ElementNotInteractableException, ElementNotInteractableException,
@@ -19,8 +20,10 @@ from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support import expected_conditions as EC
from base.MsgBase import MsgBase
class LoginPage:
class LoginPage(MsgBase):
USERNAME_INPUT = (By.NAME, "username") USERNAME_INPUT = (By.NAME, "username")
PASSWORD_INPUT = (By.NAME, "password") PASSWORD_INPUT = (By.NAME, "password")
@@ -36,22 +39,13 @@ class LoginPage:
def __init__( def __init__(
self, self,
input_queue: queue.Queue,
output_queue: queue.Queue,
driver: WebDriver, driver: WebDriver,
tracer: Optional[Callable[..., None]] = None,
) -> None: ) -> None:
super().__init__(input_queue, output_queue)
self._driver: WebDriver = driver self._driver: WebDriver = driver
self._tracer: Optional[Callable[..., None]] = tracer
def _trace(
self,
msg: str,
level: int = 20,
no_log: bool = False,
) -> None:
if self._tracer:
self._tracer(msg, level, no_log)
def navigate( def navigate(
self, self,
@@ -185,7 +179,7 @@ class LoginPage:
) -> bool: ) -> bool:
for attempt in range(max_attempts): for attempt in range(max_attempts):
self._trace( self._showTrace(
f"用户 {username}{attempt + 1} 次尝试登录......", f"用户 {username}{attempt + 1} 次尝试登录......",
no_log=True, no_log=True,
) )
@@ -196,16 +190,16 @@ class LoginPage:
continue continue
if not self.fillCaptcha(captcha_text): if not self.fillCaptcha(captcha_text):
continue continue
self._trace("尝试登录...", no_log=True) self._showTrace("尝试登录...", no_log=True)
if not self.clickLogin(): if not self.clickLogin():
continue continue
if self.waitLoginSuccess(): if self.waitLoginSuccess():
self._trace(f"用户 {username}{attempt + 1} 次登录成功 !") self._showTrace(f"用户 {username}{attempt + 1} 次登录成功 !")
return True return True
else: else:
self._trace( self._showTrace(
"登录页面加载失败 ! : " "登录页面加载失败 ! : "
"用户账号或者密码错误/验证码错误, 具体以页面提示为准", "用户账号或者密码错误/验证码错误, 具体以页面提示为准",
level=40, level=self.TraceLevel.ERROR,
) )
return False return False
+18 -2
View File
@@ -35,7 +35,7 @@ class CheckinFlow(MsgBase):
self._driver: WebDriver = driver self._driver: WebDriver = driver
self._shell: MainShell = shell self._shell: MainShell = shell
def execute( def _ensureCheckinButton(
self, self,
username: str, username: str,
) -> bool: ) -> bool:
@@ -49,7 +49,13 @@ class CheckinFlow(MsgBase):
self._showTrace(f"签到按钮启用失败 !", self.TraceLevel.ERROR) self._showTrace(f"签到按钮启用失败 !", self.TraceLevel.ERROR)
return False return False
self._showTrace("签到按钮已启用") self._showTrace("签到按钮已启用")
self._shell.clickCheckinButton() return True
def _processCheckinDialog(
self,
username: str,
) -> bool:
try: try:
with CheckinResultDialog(self._driver) as dialog: with CheckinResultDialog(self._driver) as dialog:
result_msg = dialog.getResultMessage() result_msg = dialog.getResultMessage()
@@ -87,3 +93,13 @@ class CheckinFlow(MsgBase):
except (TimeoutException, NoSuchElementException, ElementNotInteractableException): except (TimeoutException, NoSuchElementException, ElementNotInteractableException):
self._showTrace("签到时发生未知错误 !", self.TraceLevel.ERROR) self._showTrace("签到时发生未知错误 !", self.TraceLevel.ERROR)
return False return False
def execute(
self,
username: str,
) -> bool:
if not self._ensureCheckinButton(username):
return False
self._shell.clickCheckinButton()
return self._processCheckinDialog(username)
+40 -7
View File
@@ -61,19 +61,23 @@ class RenewFlow(MsgBase):
) )
return True return True
def execute( def _computeRenewTarget(
self, self,
username: str,
record: dict, record: dict,
renew_info: dict, renew_info: dict,
) -> bool: ):
max_diff = renew_info.get("max_diff", 30)
prefer_earlier = renew_info.get("prefer_early", True)
end_time = record["time"]["end"] end_time = record["time"]["end"]
target_renew_mins = timeStrToMins(end_time) + renew_info.get("expect_duration", 2) * 60 target_renew_mins = timeStrToMins(end_time) + renew_info.get("expect_duration", 2) * 60
if not self._validateRenewTime(end_time, target_renew_mins): if not self._validateRenewTime(end_time, target_renew_mins):
return False return None
return target_renew_mins
def _ensureExtendButton(
self,
username: str,
) -> bool:
if not self._shell.waitExtendButton(): if not self._shell.waitExtendButton():
self._showTrace(f"用户 {username} 续约界面加载失败 !", self.TraceLevel.ERROR) self._showTrace(f"用户 {username} 续约界面加载失败 !", self.TraceLevel.ERROR)
return False return False
@@ -83,7 +87,17 @@ class RenewFlow(MsgBase):
f"请连接图书馆网络后重试" f"请连接图书馆网络后重试"
) )
return False return False
self._shell.clickExtendButton() return True
def _processRenewDialog(
self,
username: str,
record: dict,
target_renew_mins: int,
max_diff: int,
prefer_earlier: bool,
) -> bool:
try: try:
with RenewDialog(self._driver) as dialog: with RenewDialog(self._driver) as dialog:
if not dialog.waitUntilReady(): if not dialog.waitUntilReady():
@@ -132,3 +146,22 @@ class RenewFlow(MsgBase):
self._showTrace(f"用户 {username} 续约失败 ! : {e}", self.TraceLevel.ERROR) self._showTrace(f"用户 {username} 续约失败 ! : {e}", self.TraceLevel.ERROR)
self._shell.refresh() self._shell.refresh()
return False return False
def execute(
self,
username: str,
record: dict,
renew_info: dict,
) -> bool:
max_diff = renew_info.get("max_diff", 30)
prefer_earlier = renew_info.get("prefer_early", True)
target_renew_mins = self._computeRenewTarget(record, renew_info)
if target_renew_mins is None:
return False
if not self._ensureExtendButton(username):
return False
self._shell.clickExtendButton()
return self._processRenewDialog(
username, record, target_renew_mins, max_diff, prefer_earlier,
)
+100 -32
View File
@@ -58,59 +58,70 @@ class ReserveFlow(MsgBase):
self._driver: WebDriver = driver self._driver: WebDriver = driver
self._shell: MainShell = shell self._shell: MainShell = shell
def execute( def _loadReserveView(
self, self,
) -> ReserveView | None:
try:
return self._shell.gotoReserveView()
except (TimeoutException, ElementNotInteractableException) as e:
self._showTrace(f"加载预约选座页面失败 ! : {e}", self.TraceLevel.ERROR)
return None
def _selectDate(
self,
view: ReserveView,
ctx: ReserveContext, ctx: ReserveContext,
) -> bool: ) -> bool:
submit_reserve = False
reserve_success = False
have_hover_on_page = False
try:
view = self._shell.gotoReserveView()
except (TimeoutException, ElementNotInteractableException) as e:
self._showTrace(f"加载预约选座页面失败 ! : {e}", self.TraceLevel.ERROR)
return False
if not view.selectDate(ctx.date): if not view.selectDate(ctx.date):
self._showTrace(f"选择日期失败 ! : {ctx.date} 不可用", self.TraceLevel.ERROR) self._showTrace(f"选择日期失败 ! : {ctx.date} 不可用", self.TraceLevel.ERROR)
return False return False
self._showTrace(f"日期 {ctx.date} 选择成功 !") self._showTrace(f"日期 {ctx.date} 选择成功 !")
return True
def _selectPlace(
self,
view: ReserveView,
) -> bool:
if not view.selectPlace("1"): if not view.selectPlace("1"):
self._showTrace("选择预约场所失败 ! : 图书馆 不可用", self.TraceLevel.ERROR) self._showTrace("选择预约场所失败 ! : 图书馆 不可用", self.TraceLevel.ERROR)
return False return False
self._showTrace("预约场所 图书馆 选择成功 !") self._showTrace("预约场所 图书馆 选择成功 !")
return True
def _selectFloor(
self,
view: ReserveView,
ctx: ReserveContext,
) -> bool:
if not view.selectFloor(ctx.floor): if not view.selectFloor(ctx.floor):
display_floor = ReserveView.FLOOR_MAP.get(ctx.floor, ctx.floor) display_floor = ReserveView.FLOOR_MAP.get(ctx.floor, ctx.floor)
self._showTrace(f"选择楼层失败 ! : {display_floor} 不可用", self.TraceLevel.ERROR) self._showTrace(f"选择楼层失败 ! : {display_floor} 不可用", self.TraceLevel.ERROR)
return False return False
self._showTrace(f"楼层 {ReserveView.FLOOR_MAP.get(ctx.floor)} 选择成功 !") self._showTrace(f"楼层 {ReserveView.FLOOR_MAP.get(ctx.floor)} 选择成功 !")
return True
def _selectRoom(
self,
view: ReserveView,
ctx: ReserveContext,
):
seat_map = view.selectRoom(ctx.room) seat_map = view.selectRoom(ctx.room)
if seat_map is None: if seat_map is None:
display_room = ReserveView.ROOM_MAP.get(ctx.room, ctx.room) display_room = ReserveView.ROOM_MAP.get(ctx.room, ctx.room)
self._showTrace(f"选择房间失败 ! : {display_room} 不可用", self.TraceLevel.ERROR) self._showTrace(f"选择房间失败 ! : {display_room} 不可用", self.TraceLevel.ERROR)
return False return None
self._showTrace(f"房间 {ReserveView.ROOM_MAP.get(ctx.room)} 选择成功 !") self._showTrace(f"房间 {ReserveView.ROOM_MAP.get(ctx.room)} 选择成功 !")
have_hover_on_page = True return seat_map
seat_status = seat_map.selectSeat(ctx.seat_id)
if seat_status is None: def _processReserveResult(
self._showTrace( self,
f"座位 {ctx.seat_id} 在该楼层区域中不存在, 请检查座位号是否正确", ) -> bool:
self.TraceLevel.WARNING,
)
else:
self._showTrace(f"座位 {ctx.seat_id} 选择成功 ! : 当前状态 - '{seat_status}'")
try:
time_dialog = TimeSelectDialog(self._driver, tracer=self._showTrace)
except TimeoutException:
self._showTrace("时间选择面板未出现 !", self.TraceLevel.ERROR)
else:
if not time_dialog.selectSeatTime(ctx):
self._showTrace("选择时间失败 !", self.TraceLevel.ERROR)
else:
try:
view.submitReserve()
submit_reserve = True
with ReserveResultDialog(self._driver) as result: with ReserveResultDialog(self._driver) as result:
if result.isFailure(): if result.isFailure():
self._showTrace("预约失败", self.TraceLevel.ERROR) self._showTrace("预约失败", self.TraceLevel.ERROR)
@@ -131,11 +142,68 @@ class ReserveFlow(MsgBase):
" 预约成功 !\n" " 预约成功 !\n"
" 未找获取到详细信息" " 未找获取到详细信息"
) )
reserve_success = True return True
else: else:
self._showTrace("预约结果加载失败 !", self.TraceLevel.ERROR) self._showTrace("预约结果加载失败 !", self.TraceLevel.ERROR)
return False
def _selectSeatAndSubmit(
self,
view: ReserveView,
seat_map,
ctx: ReserveContext,
) -> tuple[bool, bool]:
submit_reserve = False
reserve_success = False
seat_status = seat_map.selectSeat(ctx.seat_id)
if seat_status is None:
self._showTrace(
f"座位 {ctx.seat_id} 在该楼层区域中不存在, 请检查座位号是否正确",
self.TraceLevel.WARNING,
)
else:
self._showTrace(f"座位 {ctx.seat_id} 选择成功 ! : 当前状态 - '{seat_status}'")
try:
time_dialog = TimeSelectDialog(self._driver, tracer=self._showTrace)
except TimeoutException:
self._showTrace("时间选择面板未出现 !", self.TraceLevel.ERROR)
else:
if not time_dialog.selectSeatTime(ctx):
self._showTrace("选择时间失败 !", self.TraceLevel.ERROR)
else:
try:
view.submitReserve()
submit_reserve = True
reserve_success = self._processReserveResult()
except (TimeoutException, ElementNotInteractableException): except (TimeoutException, ElementNotInteractableException):
self._showTrace("预约提交失败 !", self.TraceLevel.ERROR) self._showTrace("预约提交失败 !", self.TraceLevel.ERROR)
return submit_reserve, reserve_success
def execute(
self,
ctx: ReserveContext,
) -> bool:
# reserve flow pipeline:
# date > place > floor > room > seat (begin/end time) > submit > result
view = self._loadReserveView()
if view is None:
return False
if not self._selectDate(view, ctx):
return False
if not self._selectPlace(view):
return False
if not self._selectFloor(view, ctx):
return False
seat_map = self._selectRoom(view, ctx)
if seat_map is None:
return False
have_hover_on_page = True
submit_reserve, reserve_success = self._selectSeatAndSubmit(
view, seat_map, ctx,
)
if not submit_reserve and have_hover_on_page: if not submit_reserve and have_hover_on_page:
view.refresh() view.refresh()
if reserve_success: if reserve_success: