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

refactor(pages): 引入 Page Object 模式重构全部页面模块,变量统一为 snake_case

将原始 Selenium 操作脚本重构为三层 Page Object 架构:
- Page Objects(LoginPage/ReserveView/RecordsView/MainShell)
- Component Objects(Overlay 基类 + SeatMapOverlay/ReserveResultDialog 等对话框)
- Flow 状态机(ReserveFlow/CheckinFlow/RenewFlow)
- Services(CaptchaHandler/ReserveValidator/RecordChecker)

变量命名统一为 snake_case,方法名保持 camelCase,类名保持 PascalCase。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 12:39:21 +08:00
parent 106463b9e5
commit 2226e8ac90
18 changed files with 3007 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
# -*- 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 queue
from selenium.common.exceptions import (
NoSuchElementException,
TimeoutException,
)
from selenium.webdriver.remote.webdriver import WebDriver
from base.MsgBase import MsgBase
from pages.MainShell import MainShell
from pages._dialogs import CheckinResultDialog
class CheckinFlow(MsgBase):
def __init__(
self,
input_queue: queue.Queue,
output_queue: queue.Queue,
driver: WebDriver,
shell: MainShell,
) -> None:
super().__init__(input_queue, output_queue)
self._driver: WebDriver = driver
self._shell: MainShell = shell
def execute(
self,
username: str,
) -> bool:
if not self._shell.waitCheckinButton():
self._showTrace(f"用户 {username} 签到界面加载失败 !", self.TraceLevel.ERROR)
return False
if self._shell.isCheckinButtonDisabled():
self._showTrace("签到按钮不可用, 可能不在场馆内, 正在尝试启用......")
if not self._shell.enableCheckinButtonByJS():
self._showTrace(f"签到按钮启用失败 !", self.TraceLevel.ERROR)
return False
self._showTrace("签到按钮已启用")
self._shell.clickCheckinButton()
try:
with CheckinResultDialog(self._driver) as dialog:
result_msg = dialog.getResultMessage()
if "签到成功" in result_msg:
details = dialog.getDetails()
if details:
if len(details) >= 5:
self._showTrace(
f"\n"
f" 签到成功 !\n"
f" {details[1]}\n"
f" {details[2]}\n"
f" {details[3]}\n"
f" {details[4]}"
)
else:
self._showTrace(
"\n"
" 签到成功 !\n"
" 未获取到签到详情 !"
)
dialog.clickOk()
self._showTrace(f"用户 {username} 签到成功 !")
return True
else:
failure_reason = result_msg.replace("签到失败", "").strip()
self._showTrace(
f"\n"
" 签到失败 !\n"
f" {failure_reason}"
)
dialog.clickOk()
self._showTrace(f"用户 {username} 签到失败 !", self.TraceLevel.ERROR)
return False
except (NoSuchElementException, TimeoutException):
self._showTrace("签到时发生未知错误 !", self.TraceLevel.ERROR)
return False
except Exception:
self._showTrace("签到时发生未知错误 !", self.TraceLevel.ERROR)
return False
+156
View File
@@ -0,0 +1,156 @@
# -*- 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 queue
from selenium.common.exceptions import (
ElementNotInteractableException,
NoSuchElementException,
TimeoutException,
)
from selenium.webdriver.remote.webdriver import WebDriver
from base.MsgBase import MsgBase
from pages.MainShell import MainShell
from pages._dialogs import RenewDialog
from pages.flows._helpers import (
timeStrToMins,
minsToTimeStr,
findBestTimeOption,
)
class RenewFlow(MsgBase):
LIBRARY_CLOSE_MINS = 1410
def __init__(
self,
input_queue: queue.Queue,
output_queue: queue.Queue,
driver: WebDriver,
shell: MainShell,
) -> None:
super().__init__(input_queue, output_queue)
self._driver: WebDriver = driver
self._shell: MainShell = shell
def execute(
self,
username: str,
record: dict,
renew_info: dict,
) -> bool:
max_diff = renew_info["max_diff"]
prefer_earlier = renew_info["prefer_early"]
end_time = record["time"]["end"]
target_renew_mins = timeStrToMins(end_time) + renew_info["expect_duration"] * 60
if not self._validateRenewTime(end_time, target_renew_mins):
return False
if not self._shell.waitExtendButton():
self._showTrace(f"用户 {username} 续约界面加载失败 !", self.TraceLevel.ERROR)
return False
if self._shell.isExtendButtonDisabled():
self._showTrace(
f"用户 {username} 续约按钮不可用, 可能不在场馆内, "
f"请连接图书馆网络后重试"
)
return False
self._shell.clickExtendButton()
try:
with RenewDialog(self._driver) as dialog:
if not dialog.waitUntilReady():
result_msg = dialog.getResultMessage()
self._showTrace(
f"\n"
f" 续约失败 !\n"
f" {result_msg}"
)
self._shell.refresh()
self._showTrace(f"用户 {username} 续约失败 !", self.TraceLevel.ERROR)
return False
renew_ok_btn = dialog.getOkButton()
renew_time_opts = dialog.getTimeOptions()
if not renew_time_opts:
self._showTrace("当前未查询到可用续约时间 !", self.TraceLevel.WARNING)
self._shell.refresh()
return False
best_opt, best_text, actual_diff, free_times = findBestTimeOption(
renew_time_opts, target_renew_mins, max_diff, prefer_earlier,
is_reserve=False,
)
if best_opt is not None:
best_opt.click()
abs_diff = abs(actual_diff)
if actual_diff < 0:
relation = f"早了 {abs_diff} 分钟"
elif actual_diff > 0:
relation = f"晚了 {abs_diff} 分钟"
else:
relation = "正好等于 续约时间"
self._showTrace(
f"选择距离期望续约时间最近的 {best_text}, "
f"与期望续约时间相比 {relation}"
)
record["time"]["end"] = best_text.strip()
renew_ok_btn.click()
self._shell.refresh()
return True
self._showTrace(
"无法选择最近的可用续约时间 ! "
f"所有可选时间与目标时间相差都超过了 {max_diff} 分钟 !",
self.TraceLevel.WARNING,
)
self._showTrace(f"当前可供续约的时间有: {free_times}")
self._shell.refresh()
return False
except (NoSuchElementException, TimeoutException) as e:
self._showTrace(f"用户 {username} 续约失败 ! : {e}", self.TraceLevel.ERROR)
self._shell.refresh()
return False
except (ElementNotInteractableException) as e:
self._showTrace(f"用户 {username} 续约失败 ! : {e}", self.TraceLevel.ERROR)
self._shell.refresh()
return False
except Exception as e:
self._showTrace(f"用户 {username} 续约失败 ! : {e}", self.TraceLevel.ERROR)
self._shell.refresh()
return False
def _validateRenewTime(
self,
end_time: str,
target_renew_mins: int,
) -> bool:
if target_renew_mins > self.LIBRARY_CLOSE_MINS:
actual_renew_duration = self.LIBRARY_CLOSE_MINS - timeStrToMins(end_time)
if actual_renew_duration <= 0:
self._showTrace(
f"当前结束时间 {end_time} 已接近闭馆时间,无法续约 !", self.TraceLevel.ERROR
)
return False
self._showTrace(
f"续约时间已调整至闭馆时间 "
f"{minsToTimeStr(self.LIBRARY_CLOSE_MINS)},"
f"实际续约时长为 "
f"{actual_renew_duration // 60} 小时 "
f"{actual_renew_duration % 60} 分钟"
)
return True
+272
View File
@@ -0,0 +1,272 @@
# -*- 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 queue
from dataclasses import dataclass
from typing import Optional
from selenium.common.exceptions import (
ElementNotInteractableException,
NoSuchElementException,
TimeoutException,
)
from selenium.webdriver.remote.webdriver import WebDriver
from base.MsgBase import MsgBase
from pages.MainShell import MainShell
from pages.flows._helpers import (
timeStrToMins,
minsToTimeStr,
findBestTimeOption,
)
from pages.ReserveView import ReserveView
from pages._dialogs import ReserveResultDialog
@dataclass
class ReserveContext:
username: str
date: str
floor: str
room: str
seat_id: str
begin_time: str
end_time: str
begin_max_diff: int = 30
end_max_diff: int = 30
begin_prefer_early: bool = True
end_prefer_early: bool = False
expect_duration: int = 4
satisfy_duration: bool = True
class ReserveFlow(MsgBase):
LIBRARY_CLOSE_MINS = timeStrToMins("23:30")
def __init__(
self,
input_queue: queue.Queue,
output_queue: queue.Queue,
driver: WebDriver,
shell: MainShell,
) -> None:
super().__init__(input_queue, output_queue)
self._driver: WebDriver = driver
self._shell: MainShell = shell
self._ctx: Optional[ReserveContext] = None
def execute(
self,
ctx: ReserveContext,
) -> bool:
self._ctx = ctx
submit_reserve = False
reserve_success = False
have_hover_on_page = False
try:
view = self._shell.gotoReserveView()
except (NoSuchElementException, TimeoutException) as e:
self._showTrace(f"加载预约选座页面失败 ! : {e}", self.TraceLevel.ERROR)
return False
except Exception as e:
self._showTrace(f"加载预约选座页面失败 ! : {e}", self.TraceLevel.ERROR)
return False
if not view.selectDate(ctx.date):
self._showTrace(f"选择日期失败 ! : {ctx.date} 不可用", self.TraceLevel.ERROR)
return False
self._showTrace(f"日期 {ctx.date} 选择成功 !")
if not view.selectPlace("1"):
self._showTrace("选择预约场所失败 ! : 图书馆 不可用", self.TraceLevel.ERROR)
return False
self._showTrace("预约场所 图书馆 选择成功 !")
if not view.selectFloor(ctx.floor):
display_floor = ReserveView.FLOOR_MAP.get(ctx.floor, ctx.floor)
self._showTrace(f"选择楼层失败 ! : {display_floor} 不可用", self.TraceLevel.ERROR)
return False
self._showTrace(f"楼层 {ReserveView.FLOOR_MAP.get(ctx.floor)} 选择成功 !")
if not view.selectRoom(ctx.room):
display_room = ReserveView.ROOM_MAP.get(ctx.room, ctx.room)
self._showTrace(f"选择房间失败 ! : {display_room} 不可用", self.TraceLevel.ERROR)
return False
self._showTrace(f"房间 {ReserveView.ROOM_MAP.get(ctx.room)} 选择成功 !")
have_hover_on_page = True
seat_status = view.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}'")
select_time_ok = self._selectSeatTime(view)
if not select_time_ok:
self._showTrace("选择时间失败 !", self.TraceLevel.ERROR)
else:
try:
view.submitReserve()
submit_reserve = True
with ReserveResultDialog(self._driver) as result:
if result.isFailure():
self._showTrace("预约失败", self.TraceLevel.ERROR)
elif result.isSuccess():
details = result.getDetailTexts()
if len(details) >= 6:
self._showTrace(
f"\n"
f" 预约成功 !\n"
f" {details[1]}\n"
f" {details[2]}\n"
f" {details[3]}\n"
f" 签到时间 {details[5]}"
)
else:
self._showTrace(
"\n"
" 预约成功 !\n"
" 未找获取到详细信息"
)
reserve_success = True
else:
self._showTrace("预约结果加载失败 !", self.TraceLevel.ERROR)
except (TimeoutException, ElementNotInteractableException):
self._showTrace("预约提交失败 !", self.TraceLevel.ERROR)
except Exception:
self._showTrace("预约提交失败 !", self.TraceLevel.ERROR)
if not submit_reserve and have_hover_on_page:
view.refresh()
if reserve_success:
self._showTrace(f"用户 {ctx.username} 预约成功 !")
else:
self._showTrace(f"用户 {ctx.username} 预约失败 !", self.TraceLevel.ERROR)
return reserve_success
def _selectSeatTime(
self,
view: ReserveView,
) -> bool:
ctx = self._ctx
exp_beg_tm_str = ctx.begin_time
exp_end_tm_str = ctx.end_time
exp_beg_mins = timeStrToMins(exp_beg_tm_str)
exp_end_mins = timeStrToMins(exp_end_tm_str)
act_beg_mins = exp_beg_mins
act_beg_tm_str = exp_beg_tm_str
act_end_mins = exp_end_mins
act_end_tm_str = exp_end_tm_str
act_beg_mins = self._selectNearestTime(
view,
time_id="startTime",
time_type="开始时间",
target_time=exp_beg_mins,
max_time_diff=ctx.begin_max_diff,
prefer_earlier=ctx.begin_prefer_early,
)
if act_beg_mins == -1:
return False
act_beg_tm_str = minsToTimeStr(act_beg_mins)
if ctx.satisfy_duration:
exp_end_mins = self._calcEndTime(act_beg_mins, ctx.expect_duration)
exp_end_tm_str = minsToTimeStr(exp_end_mins)
self._showTrace(
f"需要满足期望预约持续时间: {ctx.expect_duration} 小时, "
f"根据开始时间 {act_beg_tm_str} 计算结束时间: {exp_end_tm_str}"
)
act_end_mins = self._selectNearestTime(
view,
time_id="end_time",
time_type="结束时间",
target_time=exp_end_mins,
max_time_diff=ctx.end_max_diff,
prefer_earlier=ctx.end_prefer_early,
)
if act_end_mins == -1:
return False
act_end_tm_str = minsToTimeStr(act_end_mins)
self._showTrace(
f"期望预约时间段: {exp_beg_tm_str} - {exp_end_tm_str}, "
f"实际预约时间段: {act_beg_tm_str} - {act_end_tm_str}"
)
return True
def _selectNearestTime(
self,
view: ReserveView,
time_id: str,
time_type: str,
target_time: int,
max_time_diff: int,
prefer_earlier: bool,
) -> int:
all_time_opts = view.getAvailableTimeOptions(time_id)
if not all_time_opts:
self._showTrace(
f"{time_type} 选择失败 ! : 当前未查询到可用时间", self.TraceLevel.ERROR
)
return -1
best_opt, best_text, actual_diff, free_times = 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)
if actual_diff < 0:
relation = f"早了 {abs_diff} 分钟"
elif actual_diff > 0:
relation = f"晚了 {abs_diff} 分钟"
else:
relation = f"正好等于 {time_type}"
self._showTrace(
f"选择距离期望 {time_type} 最近的 {best_text}, "
f"与期望 {time_type} 相比 {relation}"
)
return target_time + actual_diff
target_time_str = minsToTimeStr(target_time)
self._showTrace(
f"无法选择最近的 {time_type} {target_time_str}, "
f"所有可选时间与目标时间相差都超过 {max_time_diff} 分钟",
self.TraceLevel.WARNING,
)
self._showTrace(f"当前可供预约的 {time_type} 有: {free_times}")
return -1
def _calcEndTime(
self,
begin_mins: int,
duration: int,
) -> int:
expect_end_mins = int(begin_mins + duration * 60)
if expect_end_mins > self.LIBRARY_CLOSE_MINS:
expect_end_mins = self.LIBRARY_CLOSE_MINS
self._showTrace(
f"预约持续时间 {duration} 小时, 超过最大预约时间 23:30, "
f"自动调整为 23:30",
self.TraceLevel.WARNING,
)
return expect_end_mins
+18
View File
@@ -0,0 +1,18 @@
# -*- 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 pages.flows.ReserveFlow import ReserveFlow
from pages.flows.CheckinFlow import CheckinFlow
from pages.flows.RenewFlow import RenewFlow
__all__ = [
"ReserveFlow",
"CheckinFlow",
"RenewFlow",
]
+85
View File
@@ -0,0 +1,85 @@
# -*- 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
def timeStrToMins(
time_str: str,
) -> int:
hour, minute = map(int, time_str.split(":"))
return hour * 60 + minute
def minsToTimeStr(
mins: int,
) -> str:
hour, minute = divmod(int(mins), 60)
return f"{hour:02d}:{minute:02d}"
def findBestTimeOption(
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 WebElement options.
Returns:
(bestElement, bestText, actual_diff, freeTimesList)
or (None, None, None, freeTimesList) if no suitable option.
"""
free_times = []
best_time_diff = max_time_diff
best_actual_diff = None
best_time_opt = None
for time_opt in time_options:
if is_reserve:
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:
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 minsToTimeStr(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 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)