项目别名:Loot Hearts 系列 / Wedding Invitation
版本:1.2.1
技术栈:Python 3.10 · PyQt5 · DrissionPage · openpyxl · Baidu OCR API
Auto_Excal 是一个基于 PyQt5 的 Windows 桌面自动化工具,核心功能是将 Excel 表格中的学生信息(姓名、学号、分数)批量自动填入高校教务系统的网页表单,从而替代手动逐条录入的繁琐操作。
主要解决的痛点:
| 场景 | 传统做法 | 本工具 |
|---|---|---|
| 批量录入操行成绩 | 手动复制粘贴,逐条填写 | 一键自动填入,每批最多 49 条 |
| 验证码登录 | 人工识别输入 | Baidu OCR 自动识别 |
| 数据去重 | 手动比对 | 程序自动过滤重复学号 |
auto_excal/
├── start.py # 程序入口
├── ui.py # 主窗口(SiliconApplication 子类)
├── sys_stdio.py # 全局日志与异常处理
├── config/
│ ├── CONFIG.py # 配置读写管理器
│ ├── config.ini # 用户配置文件(VPN账号、OCR Token 等)
│ └── qss.py # 表格 QSS 样式表
├── parts/
│ ├── page/
│ │ ├── page_autoexcalpage.py # ★ 核心页面:数据处理 + 浏览器自动化
│ │ ├── page_homepage.py # 主页(项目介绍 + 链接卡片)
│ │ └── page_aboutpage.py # 关于页面
│ ├── component/
│ │ ├── DynamicIsland.py # 顶部状态栏(时间、电量、作者名)
│ │ └── GlobalLeftWindow.py # 左侧抽屉(音量、亮度控制)
│ └── event/
│ ├── ocr/ocr_recognize.py # Baidu OCR 验证码识别
│ └── send/send_message.py # 消息通知组件
└── docs/readme.md # 编译与环境搭建说明
start.py
│
├─ setup_logging() # 初始化日志(输出到 app.log)
├─ QApplication() # 创建 Qt 应用实例
├─ MySiliconApp() # 构建主窗口
│ ├─ DynamicIsland # 顶部动态岛状态栏
│ ├─ LayerLeftGlobalDrawer # 左侧系统控制抽屉
│ └─ 三个页面(主页 / 表单页 / 关于页)
├─ window.show() # 显示窗口
└─ send_custom_message() # 弹出欢迎通知
全局异常捕获:start.py 中用 try/except 包裹整个初始化过程,一旦出现未预期异常,会弹出提示框引导用户截图或发送 app.log 给开发者。
这是整个项目最重要的文件(约 889 行),承载了所有数据处理与自动化逻辑。
class Autoexcal(SiPage):
def __init__(self):
self.index_current_data = 0 # 当前批次起始索引
self.browser = None # Chromium 浏览器实例
self.sheet = None # openpyxl 工作表对象
self.main_loop_thread = None # 后台填表线程
self.setup_set_widgets() # 构建"设置"区域(去重开关)
self.setup_function_widgets() # 构建"表格数据"区域(核心 UI)| 表格 | 名称 | 说明 |
|---|---|---|
table_widget |
表格1:原始表格数据 | 直接从 Excel 读入的原始数据,列数与 Excel 一致 |
insert_table_widget |
表格2:待插入原始表格 | 第二个 Excel 文件的原始数据(补充录入用) |
new_table_widget |
表格3:自定义表格数据(左) | 标准化后的三列数据(姓名 / 学号 / 分数),也是提交给自动化的最终数据 |
new_insert_table_widget |
表格4:待插入表(右) | 从表格2提取的标准化数据,可选行后插入表格3 |
① 导入 Excel 文件
↓ import_file_for_table_widget()
↓ openpyxl.load_workbook() 读取单元格
② 填充到表格1(原始数据)
③ 点击"加载数据"
↓ reload_data_for_new_table_widget()
↓ 若未启用自定义输入框:默认取第5~7列(姓名/学号/分数),从第9行开始
↓ 若启用自定义输入框:按用户指定的 (行,列) 范围提取
④ 填充到表格3(标准三列)
⑤ 去重 + 清理空行
↓ save_to_json()
↓ 构建 [{unique_id, name, stu_id, score}, ...] 列表
↓ 若"去重"开关开启:用 unique_id 集合过滤重复项
⑥ 写入 data.json(与 page_autoexcalpage.py 同目录)
⑦ 点击"打开浏览器"
↓ open_broswer()
↓ DrissionPage 启动/连接 Chromium(Edge)
↓ 自动完成 VPN 登录 + OCR 验证码 + 门户登录 + 教务系统导航
⑧ 点击"开始"
↓ start_main_loop_in_thread()
↓ 创建 MainLoopThread,传入 browser 和 index_current_data
↓ 后台线程读取 data.json,按批次(每批最多49条)填入网页表单
↓ 填完后点击"查询"按钮提交,更新 index_current_data
⑨ 本批完成后 finished 信号触发 on_main_loop_finished(),可继续下一批
def import_file_for_table_widget(self):
file_path = QFileDialog.getOpenFileName(...)[0] # 弹出文件选择对话框
workbook = load_workbook(file_path) # openpyxl 读取 xlsx
self.sheet = workbook.active # 取活跃工作表
# 遍历所有单元格,逐一写入 QTableWidget
for row in range(rows):
for col in range(cols):
cell_value = self.sheet.cell(row+1, col+1).value
self.table_widget.setItem(row, col, QTableWidgetItem(str(cell_value)))用户可在 UI 输入框中指定 (行,列) 格式的起止坐标(例如姓名起始 (9,5),结束 (200,5)):
# 解析形如 "(9,5)" 的字符串
row_str, col_str = "(9,5)".strip("()").split(',')
start_row = int(row_str) - 1 # 转为 0-indexed
start_col = int(col_str) - 1若未启用自定义模式,则使用硬编码默认值:第 9 行起,第 5/6/7 列(分别对应姓名/学号/分数)。
def save_to_json(self):
data_list = []
unique_ids = set()
# 从表格3读取所有行,构建字典列表
# 注意:unique_id 使用 names.index(name) 取姓名首次出现的行号,
# 若存在同名学生,其 unique_id 相同,后续去重逻辑会将同名行识别为重复项。
for name, xuehao, score in zip(names, xuehaos, scores):
if name and xuehao and score:
data_list.append({
"unique_id": names.index(name),
"name": name,
"stu_id": xuehao,
"score": score
})
# 去重:若 unique_id 已存在且开关打开,则标记删除
for data in data_list:
if data['unique_id'] in unique_ids and self.duplicate_filter_btu.isChecked():
to_remove.append(data)
else:
unique_ids.add(data['unique_id'])
# 重新分配连续的 unique_id,写入 JSON 文件
with open('data.json', 'w', encoding='utf-8') as f:
json.dump(data_list, f, ensure_ascii=False, indent=4)使用 DrissionPage 驱动 Chromium(微软 Edge),按顺序执行:
1. Chromium(co).latest_tab.get(vpn_url) → 打开 VPN 登录页
2. ele("@tabindex=1").input(name) → 输入 VPN 用户名
3. ele("@id=loginPwd").input(pwrd) → 输入 VPN 密码
4. get_rand_code(captcha_img.get_screenshot()) → OCR 识别验证码
5. ele("@tabindex=3").input(result) → 输入验证码
6. ele("@class=button button--normal").click() → 点击登录
7. ele("@title=综合信息门户").click() → 进入门户
8. ele("@id=User_ID").input(info_name) → 输入门户账号
9. ele("@id=btnLogin").click() → 门户登录
10. ele("教务系统").click() → 进入教务系统
11. ele("新增操行成绩").click() → 定位成绩录入表单
每一步通过元素的 @id、@class、@tabindex、@title 等属性精准定位 DOM 节点。
def get_rand_code(base64_img: str) -> Optional[str]:
# 向百度 OCR API 发送 base64 编码的验证码截图
response = requests.post(ocr_api_url, data={"image": base64_img}, ...)
words_result = response.json()['words_result']
# 提取纯数字,不足4位在右侧补0;若完全无数字则返回 None(由调用方处理)
digits = ''.join(c for c in words_result[0]['words'] if c.isdigit())
if not digits:
return None # 识别结果中无数字,调用方应提示用户手动输入
return digits.ljust(4, '0')验证码图片由 DrissionPage 的 .get_screenshot(as_base64="jpg") 直接截取为 base64 字符串,无需落盘。
后台线程继承 QThread,避免长时间填表操作阻塞 UI 主线程:
class MainLoopThread(QThread):
finished = pyqtSignal() # 本批完成时通知主线程
def run(self):
start_index = self.index_current_data
end_index = min(start_index + 49, len(self.data)) # 每批最多 49 条
for i in range(start_index, end_index):
# 网页中表单字段 ID 规则:txtstu1~txtstu49 / txtpoint1~txtpoint49
xuehao_ele = self.last_tab.ele(f"@id=txtstu{(i % 49) + 1}")
score_ele = self.last_tab.ele(f"@id=txtpoint{(i % 49) + 1}")
xuehao_ele.input(data[i]['stu_id'])
score_ele.input(data[i]['score'])
# 点击所有"查询"按钮触发保存
for btn in self.last_tab.eles("@value=查询"):
btn.click()
self.parent.index_current_data = end_index # 更新批次指针断点续传:index_current_data 记录上次结束的位置,点击"开始"按钮时从该位置继续,支持分批多次提交。
通过 configparser 读写 config.ini,统一管理所有可配置项:
READ_CONFIG("vpn", "vpn_name") # 读取 VPN 用户名
WRITE_CONFIG("date", "today", "2025") # 写入今日日期config.ini 主要区段:
| 区段 | 内容 |
|---|---|
[vpn] |
VPN 登录地址、账号、密码 |
[info] |
综合信息门户账号、密码 |
[ocr] |
Baidu OCR API 地址与 Token |
[chromium_options] |
浏览器路径与调试端口地址 |
[version] |
应用版本号与代码仓库地址 |
- 每 60 秒刷新一次当前时间(
HH:MM:SS) - 每 5 分钟通过
psutil查询电池电量 - 作者名字带颜色动画效果
- 音量控制:通过
pycaw(Windows Core Audio API)获取/设置系统音量 - 亮度控制:通过
wmi(Windows Management Instrumentation)读写屏幕亮度 - 快捷键:
Ctrl + A打开/关闭左侧抽屉
统一封装通知弹窗,支持 5 种类型(错误 / 信息 / 成功 / 警告 / 严重),通过 SiGlobal 获取主窗口引用后在右下角弹出。
┌─────────────┐
│ Excel 文件 │
└──────┬──────┘
│ openpyxl.load_workbook()
▼
┌─────────────────┐
│ 表格1(原始) │ ← 全量列,包含表头等无关行
└──────┬──────────┘
│ reload_data_for_new_table_widget()
│ 按列范围提取 (行,列) → 三列标准化
▼
┌─────────────────┐
│ 表格3(标准化) │ ← 姓名 / 学号 / 分数,三列
└──────┬──────────┘
│ save_to_json() 去重 + 序号重排
▼
┌──────────────┐
│ data.json │
└──────┬───────┘
│ MainLoopThread.run() 每批最多49条
▼
┌────────────────────────┐
│ Chromium(Edge)浏览器 │
│ txtstu1~txtstu49 │ ← 学号输入框
│ txtpoint1~txtpoint49 │ ← 分数输入框
└────────────────────────┘
│ eles("@value=查询").click()
▼
教务系统保存成功
详见 docs/readme.md,核心步骤:
pip install -r requirements.txt
# 另需手动安装 siui(PyQt-SiliconUI)
python start.py使用前需在 config/config.ini 中填写:
[vpn]区段的 VPN 账号密码[info]区段的门户账号密码[ocr]区段的 Baidu OCR access_token