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

refactor(pages): 引入 Page Object 模式替代 operators/ 模块并移除旧代码

- 以 Page Object + Strategy + Flow 分层架构重写 pages/ 模块
- 将页面元素定位 (LoginPage/MainShell/ReserveView/RecordsView) 与业务编排 (ReserveFlow/CheckinFlow/RenewFlow) 分离
- 抽取 Dialog 上下文管理器统一弹窗生命周期,集成 TimeSelectMaker 策略模式处理时间选择
- 拆分 Service 层:CaptchaSolver、RecordChecker、ReserveChecker 独立可注入
- 统一闭馆时间为 TimeSelectMaker.LIBRARY_CLOSE_MINS (22:30)
- 移除旧 operators/ 模块及 base/LibOperator 层

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 20:06:36 +08:00
41 changed files with 3026 additions and 2113 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ def main():
translator = QTranslator()
if translator.load(":/res/translators/qtbase_zh_CN.ts"):
app.installTranslator(translator)
app.setStyle('Fusion')
app.setStyle("Fusion")
app.setApplicationName("AutoLibrary")
if not initializeApp():
sys.exit(-1)
-36
View File
@@ -1,36 +0,0 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 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 base.MsgBase import MsgBase
class LibOperator(MsgBase):
"""
Base abstract class for library operation.
This class provides the foundation for library-related operations, inheriting
message handling and tracing abilities from MsgBase. It serves as an abstract
base class that must be subclassed to implement specific library functionality.
"""
def __init__(
self,
input_queue: queue.Queue,
output_queue: queue.Queue
):
super().__init__(input_queue, output_queue)
def _waitResponseLoad(
self
) -> bool:
pass
-1
View File
@@ -3,5 +3,4 @@
Here are the classes and modules in this package:
- MsgBase: Base class for messages.
- LibOperator: Base class for library operators.
"""
+3 -3
View File
@@ -204,11 +204,11 @@ class _DateOffsetContainer(QWidget):
val = self._spinBox.value()
unit = self._unitCombo.currentData()
if unit == "weeks":
return val * 7
return val*7
if unit == "months":
return val * 30
return val*30
if unit == "years":
return val * 365
return val*365
return val
+99 -104
View File
@@ -12,11 +12,12 @@ import time
import queue
from PySide6.QtCore import (
Slot, Signal, QThread
Signal,
QThread,
)
from base.MsgBase import MsgBase
from operators.AutoLib import AutoLib
from pages.AutoLib import AutoLib
from utils.JSONReader import JSONReader
from autoscript import createEngine
@@ -30,7 +31,7 @@ class AutoLibWorker(MsgBase, QThread):
self,
input_queue: queue.Queue,
output_queue: queue.Queue,
config_paths: dict
config_paths: dict,
):
MsgBase.__init__(self, input_queue, output_queue)
@@ -45,7 +46,7 @@ class AutoLibWorker(MsgBase, QThread):
if current_time >= "23:30" or current_time <= "07:30":
self._showTrace(
"当前时间不在图书馆开放时间内, 请在 07:30 - 23:30 之间尝试",
self.TraceLevel.WARNING
self.TraceLevel.WARNING,
)
return False
self._showLog(f"时间检查通过, 当前时间: {current_time}", self.TraceLevel.INFO)
@@ -60,83 +61,113 @@ class AutoLibWorker(MsgBase, QThread):
):
self._showTrace(
"配置文件路径不存在, 请检查配置文件路径是否正确",
self.TraceLevel.ERROR
self.TraceLevel.ERROR,
)
return False
self._showLog(f"配置文件路径检查通过, 路径: {self.__config_paths}", self.TraceLevel.INFO)
self._showLog(
f"配置文件路径检查通过, 路径: {self.__config_paths}",
self.TraceLevel.INFO,
)
return True
def loadConfigs(
self
self,
) -> bool:
self._showTrace(
f"正在加载配置文件, 运行配置文件路径: {self.__config_paths["run"]}",
no_log=True
no_log=True,
)
self._run_config = JSONReader(self.__config_paths["run"]).data()
self._showTrace(
f"正在加载配置文件, 用户配置文件路径: {self.__config_paths["user"]}",
no_log=True
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.TraceLevel.ERROR
self.TraceLevel.ERROR,
)
return False
if not self._user_config.get("groups"):
self._showTrace(
"用户配置文件中无有效任务组, 请检查用户配置文件是否正确",
self.TraceLevel.WARNING
self.TraceLevel.WARNING,
)
return False
self._showLog(
f"配置文件加载成功, 任务组数量: {len(self._user_config.get('groups', []))}",
self.TraceLevel.INFO
f"配置文件加载成功, 任务组数量: {len(self._user_config.get("groups"))}",
self.TraceLevel.INFO,
)
return True
def _runName(
self,
) -> str:
return "常规任务"
def _beforeCreateAutoLib(
self,
):
return
def _onChecksFailed(
self,
) -> bool:
return True
def _onFinished(
self,
):
self.autoLibWorkerIsFinished.emit()
def _onError(
self,
error_msg: str,
):
self._showTrace(error_msg, self.TraceLevel.ERROR)
self.autoLibWorkerFinishedWithError.emit()
def run(
self
self,
):
auto_lib = None
self._showTrace("AutoLibrary 开始运行")
if not self.checkTimeAvailable()\
or not self.checkConfigPaths():
# time or config existence check failed, skip and finish
pass
self._showTrace(f"{self._runName()} 开始运行")
if not self.checkTimeAvailable() or not self.checkConfigPaths():
if not self._onChecksFailed():
return
else:
try:
if not self.loadConfigs():
raise Exception("配置文件加载失败")
self._beforeCreateAutoLib()
auto_lib = AutoLib(
self._input_queue,
self._output_queue,
self._run_config
self._run_config,
)
groups = self._user_config.get("groups")
for group in groups:
if not group["enabled"]:
self._showTrace(f"任务组 {group["name"]} 已跳过", no_log=True)
if not group.get("enabled", False):
self._showTrace(f"任务组 {group.get("name", "未知")} 已跳过", no_log=True)
continue
self._showTrace(f"正在运行任务组 {group["name"]}", no_log=True)
auto_lib.run(
{ "users": group.get("users", []) }
)
self._showTrace(f"正在运行任务组 {group.get("name", "未知")}", no_log=True)
auto_lib.run({"users": group.get("users", [])})
except Exception as e:
self._showTrace(
f"AutoLibrary 运行时发生异常 : {e}",
self.TraceLevel.ERROR
)
self.autoLibWorkerFinishedWithError.emit()
self._onError(f"{self._runName()} 运行时发生异常 : {e}")
return
if auto_lib:
auto_lib.close()
self._showTrace("AutoLibrary 运行结束")
self.autoLibWorkerIsFinished.emit()
self._showTrace(f"{self._runName()} 运行结束")
self._onFinished()
class TimerTaskWorker(AutoLibWorker):
@@ -148,70 +179,54 @@ class TimerTaskWorker(AutoLibWorker):
timer_task: dict,
input_queue: queue.Queue,
output_queue: queue.Queue,
config_paths: dict
config_paths: dict,
):
super().__init__(input_queue, output_queue, config_paths)
self.__timer_task = timer_task
self.autoLibWorkerIsFinished.connect(self.onTimerTaskIsFinished)
self.autoLibWorkerFinishedWithError.connect(self.onTimerTaskFinishedWithError)
def _runName(
self,
) -> str:
def run(
self
return f"定时任务 '{self.__timer_task.get("name", "未知")}'"
def _beforeCreateAutoLib(
self,
):
self._showTrace(f"定时任务 {self.__timer_task['name']} 开始运行")
if not self.checkTimeAvailable() or not self.checkConfigPaths():
self._showTrace("定时任务跳过执行 (时间或配置文件检查未通过)")
self.timerTaskWorkerIsFinished.emit(False, self.__timer_task)
return
try:
if not self.loadConfigs():
raise Exception("配置文件加载失败")
self.applyRepeatAutoScript()
auto_lib = AutoLib(
self._input_queue,
self._output_queue,
self._run_config
)
groups = self._user_config.get("groups")
for group in groups:
if not group["enabled"]:
self._showTrace(
f"任务组 {group['name']} 已跳过",
no_log=True
)
continue
self._showTrace(
f"正在运行任务组 {group['name']}",
no_log=True
)
auto_lib.run(
{"users": group.get("users", [])}
)
auto_lib.close()
except Exception as e:
self._showTrace(
f"定时任务 {self.__timer_task['name']} 运行时发生异常: {e}",
self.TraceLevel.ERROR
)
self.timerTaskWorkerIsFinished.emit(True, self.__timer_task)
return
self._showTrace(f"定时任务 {self.__timer_task['name']} 运行结束")
def _onChecksFailed(
self,
) -> bool:
self._showTrace("定时任务跳过执行: 时间或配置文件检查未通过")
self.timerTaskWorkerIsFinished.emit(False, self.__timer_task)
return False
def _onFinished(
self,
):
self.timerTaskWorkerIsFinished.emit(False, self.__timer_task)
def _onError(
self,
error_msg: str,
):
self._showTrace(error_msg, self.TraceLevel.ERROR)
self.timerTaskWorkerIsFinished.emit(True, self.__timer_task)
def applyRepeatAutoScript(
self
self,
):
auto_script = self.__timer_task.get("repeat_auto_script", "")
if not auto_script or not auto_script.strip():
return
self._showTrace(
f"检测到重复定时任务 AutoScript, 开始执行...",
no_log=True
)
self._showTrace("检测到重复定时任务 AutoScript, 开始执行...", no_log=True)
groups = self._user_config.get("groups", [])
affected_count = 0
for group in groups:
@@ -224,30 +239,10 @@ class TimerTaskWorker(AutoLibWorker):
affected_count += 1
except ValueError as e:
self._showTrace(
f"AutoScript 执行错误 (用户 {user['username']}): {e}",
self.TraceLevel.ERROR
f"AutoScript 执行错误 (用户 {user.get("username", "未知")}): {e}",
self.TraceLevel.ERROR,
)
self._showLog(
f"AutoScript 执行完毕, "
f"影响 {affected_count} 个用户",
self.TraceLevel.INFO
f"AutoScript 执行完毕, 影响 {affected_count} 个用户",
self.TraceLevel.INFO,
)
@Slot()
def onTimerTaskIsFinished(
self
):
self._showTrace(f"定时任务 {self.__timer_task['name']} 运行结束")
self.timerTaskWorkerIsFinished.emit(False, self.__timer_task)
@Slot()
def onTimerTaskFinishedWithError(
self
):
self._showTrace(
f"定时任务 {self.__timer_task['name']} 运行时发生异常",
self.TraceLevel.ERROR
)
self.timerTaskWorkerIsFinished.emit(True, self.__timer_task)
+3 -1
View File
@@ -8,7 +8,9 @@ You may use, modify, and distribute this file under the terms of the MIT License
See the LICENSE file for details.
"""
from PySide6.QtCore import (
Qt, Slot, QEvent
Qt,
Slot,
QEvent
)
from PySide6.QtWidgets import (
QFrame,
+16 -2
View File
@@ -12,9 +12,23 @@ import uuid
from enum import Enum
from datetime import datetime, timedelta
from PySide6.QtCore import Slot, QDateTime, QUrl
from PySide6.QtCore import (
Slot,
QDateTime,
QUrl
)
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import QLabel, QDialog, QWidget, QSpinBox, QHBoxLayout, QVBoxLayout, QGridLayout, QDateTimeEdit, QGroupBox, QPushButton
from PySide6.QtWidgets import (
QLabel,
QDialog,
QWidget,
QSpinBox,
QHBoxLayout,
QVBoxLayout,
QDateTimeEdit,
QGroupBox,
QPushButton
)
from gui.resources.ui.Ui_ALTimerTaskAddDialog import Ui_ALTimerTaskAddDialog
from utils.TimerUtils import TimerUtils
-365
View File
@@ -1,365 +0,0 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 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 re
import time
import queue
from datetime import datetime, timedelta
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
class LibChecker(LibOperator):
def __init__(
self,
input_queue: queue.Queue,
output_queue: queue.Queue,
driver: WebDriver
):
super().__init__(input_queue, output_queue)
self.__driver = driver
def _waitResponseLoad(
self
) -> bool:
pass
@staticmethod
def __formatDiffTime(
seconds: float
) -> str:
hours = int(seconds//3600)
minutes = int(seconds%3600//60)
seconds = int(seconds%60)
return f"{hours}{minutes}{seconds}"
def __navigateToReserveRecordPage(
self
) -> bool:
try:
WebDriverWait(self.__driver, 2).until(
EC.element_to_be_clickable((By.XPATH, "//a[@href='/history?type=SEAT']"))
).click()
WebDriverWait(self.__driver, 2).until(
EC.presence_of_element_located((By.CLASS_NAME, "myReserveList"))
)
except:
self._showTrace("加载预约记录页面失败 !", self.TraceLevel.ERROR)
return False
return True
def __decodeReserveTime(
self,
time_element
) -> dict:
time_str = time_element.text.strip()
today = datetime.now().date()
if "明天" in time_str:
target_date = today + timedelta(days=1)
date = target_date.strftime("%Y-%m-%d")
elif "今天" in time_str:
target_date = today
date = target_date.strftime("%Y-%m-%d")
elif "昨天" in time_str:
target_date = today - timedelta(days=1)
date = target_date.strftime("%Y-%m-%d")
else:
date_match = re.search(r"(\d{4}-\d{1,2}-\d{1,2})", time_str)
if date_match:
date = date_match.group(1)
else:
date = ""
time_match = re.search(r"(\d{1,2}:\d{2}) -- (\d{1,2}:\d{2})", time_str)
if time_match:
begin_time = time_match.group(1)
end_time = time_match.group(2)
else:
begin_time = ""
end_time = ""
return {
"date": date,
"time": {
"begin": begin_time,
"end": end_time
}
}
def __decodeReserveInfo(
self,
info_elements
) -> str:
location = ""
status = ""
for info in info_elements:
if "已预约" in info.text:
status = "已预约"
elif "使用中" in info.text:
status = "使用中"
elif "已完成" in info.text:
status = "已完成"
elif "已结束使用" in info.text:
status = "已结束使用"
elif "已取消" in info.text:
status = "已取消"
elif "失约" in info.text:
status = "失约"
elif "图书馆" in info.text:
location = info.text.strip()
return {
"location": location,
"status": status,
}
def __decodeReserveRecord(
self,
reservation
) -> dict:
try:
time_element = reservation.find_element(
By.CSS_SELECTOR, "dt"
)
info_elements = reservation.find_elements(
By.CSS_SELECTOR, "a"
)
except:
return {
"date": "",
"time": {"begin": "", "end": ""},
"info": {"location": "", "status": ""}
}
time = self.__decodeReserveTime(time_element)
info = self.__decodeReserveInfo(info_elements)
return {
"date": time["date"],
"time": time["time"],
"info": info
}
def __loadReserveRecords(
self
) -> list:
try:
# check if there's any reservation on the date
WebDriverWait(self.__driver, 2).until(
EC.presence_of_element_located((By.CSS_SELECTOR, ".myReserveList > dl"))
)
reservations = self.__driver.find_elements(
By.CSS_SELECTOR, ".myReserveList > dl:not(#moreBlock)"
)
return reservations
except:
self._showTrace("加载预约记录失败 !", self.TraceLevel.ERROR)
return None
def __showMoreReserveRecords(
self
) -> bool:
# load new reservations if still not sure
try:
WebDriverWait(self.__driver, 0.1).until(
EC.element_to_be_clickable((By.ID, "moreBtn"))
)
except:
# the reservation is the last one
return False
try:
more_btn = self.__driver.find_element(By.ID, "moreBtn")
if more_btn.is_displayed() and more_btn.is_enabled():
self.__driver.execute_script("arguments[0].scrollIntoView(true);", more_btn)
self.__driver.execute_script("arguments[0].click();", more_btn)
return True
else:
self._showTrace("用户无法加载更多预约记录", self.TraceLevel.WARNING)
return False
except:
self._showTrace("加载更多预约记录失败 !", self.TraceLevel.ERROR)
return False
def __getReserveRecord(
self,
wanted_date: str,
wanted_status: str
) -> dict:
if wanted_date is None:
self._showTrace("日期未指定, 无法检查当前预约状态", self.TraceLevel.WARNING)
return None
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
if not self.__navigateToReserveRecordPage():
return None
for _ in range(max_check_times):
reservations = self.__loadReserveRecords()
if reservations is None:
return None
for reservation in reservations[checked_count:]:
record = self.__decodeReserveRecord(reservation)
checked_count += 1
if record is None:
continue
if record["date"] == "":
continue
if record["time"] == {"begin": "", "end": ""}:
continue
# record date is later than the given date, check the next one
if datetime.strptime(record["date"], "%Y-%m-%d").date() >\
datetime.strptime(wanted_date, "%Y-%m-%d").date():
continue
# record date is earlier than the given date, so there is no wanted record
if datetime.strptime(record["date"], "%Y-%m-%d").date() <\
datetime.strptime(wanted_date, "%Y-%m-%d").date():
return None
if record["info"]["status"] == wanted_status:
self._showTrace(
f"寻找到用户第 {checked_count} 条状态为 {wanted_status} 的预约记录, "
f"详细信息: {record["date"]} "
f"{record["time"]["begin"]} - {record["time"]["end"]} {record["info"]["location"]}",
no_log=True
)
return record
if not self.__showMoreReserveRecords():
break
return None
def canReserve(
self,
date: str
) -> bool:
# no reserved or using record in the given date
# then can reserve
if self.__getReserveRecord(date, "已预约") is None:
if self.__getReserveRecord(date, "使用中") is None:
self._showTrace(f"用户在 {date} 可以预约")
return True
self._showTrace(f"用户在 {date} 有使用中的预约, 无法预约")
return False
self._showTrace(f"用户在 {date} 已存在有效预约, 无法预约")
return False
def canCheckin(
self
) -> bool:
# only check the current date
date = time.strftime("%Y-%m-%d", time.localtime())
record = self.__getReserveRecord(date, "已预约")
if record is not None:
begin_time = record["time"]["begin"]
begin_time = datetime.strptime(f"{date} {begin_time}", "%Y-%m-%d %H:%M")
time_diff = datetime.now() - begin_time
time_diff_seconds = time_diff.total_seconds()
# before 30 minutes, cant checkin
if time_diff_seconds < -30*60:
self._showTrace(
f"用户在 {date} 的预约开始时间为 {begin_time}, "
f"当前距离预约开始时间还有 {self.__formatDiffTime(abs(time_diff_seconds))}, 无法签到"
)
return False
# before in 30 minutes, can checkin
elif -30*60 <= time_diff_seconds < 0:
self._showTrace(
f"用户在 {date} 的预约开始时间为 {begin_time}, "
f"当前距离预约开始时间还有 {self.__formatDiffTime(abs(time_diff_seconds))}, 可以签到"
)
return True
# past less than 30 minutes, can checkin
elif 0 <= time_diff_seconds < 30*60 - 5: # spare 5 seconds for the checkin process
self._showTrace(
f"用户在 {date} 的预约开始时间为 {begin_time}, "
f"当前距离预约开始时间已经过去 {self.__formatDiffTime(abs(time_diff_seconds))}, 可以签到"
)
return True
self._showTrace(f"用户在 {date} 没有有效预约记录, 无法签到")
return False
def canRenew(
self
) -> tuple[bool, dict]:
# only check the current date
date = time.strftime("%Y-%m-%d", time.localtime())
record = self.__getReserveRecord(date, "使用中")
if record is not None:
end_time = record["time"]["end"]
end_time = datetime.strptime(f"{date} {end_time}", "%Y-%m-%d %H:%M")
time_diff = end_time - datetime.now()
time_diff_seconds = time_diff.total_seconds()
# a using record is definitely after the begin time
trace_msg = (
f"用户在 {date} 的预约结束时间为 {end_time}, "
f"当前距离预约结束时间还有 {self.__formatDiffTime(abs(time_diff_seconds))}"
)
if abs(time_diff_seconds) < 120*60:
self._showTrace(f"{trace_msg}, 可以续约")
return True, record
else:
self._showTrace(f"{trace_msg}, 无法续约")
return False, None # we do not need to return the record, because if current
# time is not available for renewal, the record is not required
self._showTrace(f"用户在 {date} 没有有效预约记录, 无法续约")
return False, None
def postRenewCheck(
self,
record: dict
) -> bool:
"""
Check if the renew operation is successful
Args:
record (dict): The expected record after renewal
Returns:
bool: True if the renew operation is successful, False otherwise
"""
# because the special circumstance that the renew operation
# do not show the success message or anything else,
# we need to check the record data to make sure the renew operation is successful.
# only check the given record date
date = record["date"]
act_record = self.__getReserveRecord(date, "使用中")
if act_record is not None:
if act_record["time"]["begin"] == record["time"]["begin"] and\
act_record["time"]["end"] == record["time"]["end"]:
self._showTrace(f"\n"\
f" 续约成功 !\n"\
f" 日 期 {date}\n"\
f" 时 间 {act_record["time"]["begin"]} - {act_record["time"]["end"]}\n"\
f" 位 置 {act_record["info"]["location"]}\n"
f" 状 态 {act_record["info"]["status"]}"
)
return True
else:
self._showTrace(f"\n"\
f" 续约失败 !\n"\
f" 续约后结束时间为 {act_record["time"]["end"]},与预期结束时间 {record["time"]["end"]} 不符 !"
)
return False
self._showTrace(f"用户在 {date} 没有有效预约记录, 无法检查续约结果")
return False
-139
View File
@@ -1,139 +0,0 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 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 time
import queue
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
class LibCheckin(LibOperator):
def __init__(
self,
input_queue: queue.Queue,
output_queue: queue.Queue,
driver: WebDriver
):
super().__init__(input_queue, output_queue)
self.__driver = driver
def _waitResponseLoad(
self
) -> bool:
try:
WebDriverWait(self.__driver, 2).until(
EC.presence_of_element_located((By.CLASS_NAME, "ui_dialog"))
)
WebDriverWait(self.__driver, 2).until(
EC.presence_of_element_located((By.CLASS_NAME, "resultMessage"))
)
WebDriverWait(self.__driver, 2).until(
EC.element_to_be_clickable((By.CLASS_NAME, "btnOK"))
)
result_message_element = self.__driver.find_element(
By.CLASS_NAME, "resultMessage"
)
ok_btn = self.__driver.find_element(By.CLASS_NAME, "btnOK")
except:
self._showTrace("签到时发生未知错误 !", self.TraceLevel.ERROR)
return False
result_message = result_message_element.text
if "签到成功" in result_message:
try:
detail_elements = self.__driver.find_elements(
By.CSS_SELECTOR, ".resultMessage dd"
)
except:
pass
if detail_elements:
details = [element.text for element in detail_elements if element.text.strip()]
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(f"\n"\
" 签到成功 !\n"\
" 未获取到签到详情 !"
)
ok_btn.click()
return True
else:
failure_reason = result_message.replace("签到失败", "").strip()
self._showTrace(f"\n"\
" 签到失败 !\n"\
f" {failure_reason}"
)
ok_btn.click()
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.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.TraceLevel.ERROR)
return False
if "disabled" in checkin_btn.get_attribute("class"):
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} 签到成功 !", no_log=True)
return True
else:
self._showTrace(f"用户 {username} 签到失败 !", self.TraceLevel.ERROR)
return False
-40
View File
@@ -1,40 +0,0 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 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 re
import time
import queue
from datetime import datetime, timedelta
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
class LibCheckout(LibOperator):
def __init__(
self,
input_queue: queue.Queue,
output_queue: queue.Queue,
driver: WebDriver
):
super().__init__(input_queue, output_queue)
self.__driver = driver
def _waitResponseLoad(
self
) -> bool:
pass
-207
View File
@@ -1,207 +0,0 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 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
import base64
import ddddocr
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
class LibLogin(LibOperator):
def __init__(
self,
input_queue: queue.Queue,
output_queue: queue.Queue,
driver: WebDriver
):
super().__init__(input_queue, output_queue)
self.__driver = driver
self.__ddddocr = ddddocr.DdddOcr()
def _waitResponseLoad(
self
) -> bool:
# wait to verify login success
try:
WebDriverWait(self.__driver, 2).until( # title contains "自选座位 :: 座位预约系统"
EC.title_contains("自选座位 :: 座位预约系统")
)
WebDriverWait(self.__driver, 2).until( # search button presence
EC.presence_of_element_located((By.ID, "search"))
)
WebDriverWait(self.__driver, 2).until( # select content presence
EC.presence_of_element_located((By.CLASS_NAME, "selectContent"))
)
return True
except:
self._showTrace(
f"登录页面加载失败 ! : 用户账号或者密码错误/验证码错误, 具体以页面提示为准",
self.TraceLevel.ERROR
)
return False
def __fillLogInElements(
self,
username: str,
password: str
) -> bool:
# ensure elements presence and fill them
try:
username_element = self.__driver.find_element(By.NAME, "username")
username_element.clear()
username_element.send_keys(username)
password_element = self.__driver.find_element(By.NAME, "password")
password_element.clear()
password_element.send_keys(password)
except Exception as e:
self._showTrace(f"用户名或密码填写失败 ! : {e}", self.TraceLevel.ERROR)
return False
return True
def __autoRecognizeCaptcha(
self
) -> str:
# auto recognize captcha
try:
captcha_img = self.__driver.find_element(By.ID, "loadImgId")
img_src = captcha_img.get_attribute("src")
base64_str = img_src.split(',', 1)[1]
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}'", 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.TraceLevel.ERROR)
return ""
def __manualRecognizeCaptcha(
self
) -> str:
# manual recognize captcha
try:
self._showMsg("请输入验证码:")
captcha_text = self._waitMsg(timeout=15)
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.TraceLevel.ERROR)
return ""
def __refreshCaptcha(
self
):
# refresh captcha
try:
self._showTrace("刷新验证码......", no_log=True)
self.__driver.find_element(
By.ID, "loadImgId"
).click()
return True
except Exception as e:
self._showTrace(f"刷新验证码失败 ! : {e}", self.TraceLevel.ERROR)
return False
def __solveCaptcha(
self,
auto_captcha: bool = True
) -> str:
max_attempts = 3 # the possibility of 3 times failed is less than (10%^3)
for _ in range(max_attempts):
if auto_captcha:
captcha_text = self.__autoRecognizeCaptcha()
else:
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.TraceLevel.WARNING
)
return ""
def __fillCaptchaElement(
self,
captcha_text: str
) -> bool:
try:
captcha_element = self.__driver.find_element(By.NAME, "answer")
captcha_element.clear()
captcha_element.send_keys(captcha_text)
return True
except Exception as e:
self._showTrace(f"验证码填写失败 ! : {e}", self.TraceLevel.ERROR)
return False
def login(
self,
username: str,
password: str,
max_attempts: int = 5,
auto_captcha: bool = True
) -> bool:
if self.__driver is None:
self._showTrace("未提供有效 WebDriver 实例 !", self.TraceLevel.WARNING)
return False
# begin login process
for attempt in range(max_attempts):
self._showTrace(f"用户 {username}{attempt + 1} 次尝试登录......", no_log=True)
if not self.__fillLogInElements(
username,
password,
):
continue
captcha_text = self.__solveCaptcha(auto_captcha)
if not captcha_text:
continue
if not self.__fillCaptchaElement(captcha_text):
continue
self._showTrace("尝试登录...", no_log=True)
try:
self.__driver.find_element(
By.XPATH,
"//input[@type='button' and @value='登录']"
).click()
except Exception as e:
self._showTrace(f"尝试登录失败 ! : {e}")
continue
if self._waitResponseLoad():
self._showTrace(f"用户 {username}{attempt + 1} 次登录成功 !")
return True
else:
self._showTrace(f"用户 {username}{attempt + 1} 次登录失败 !",self.TraceLevel.WARNING)
return False
-53
View File
@@ -1,53 +0,0 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 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.webdriver.common.by import By
from selenium.webdriver.chrome.webdriver import WebDriver
from base.LibOperator import LibOperator
class LibLogout(LibOperator):
def __init__(
self,
input_queue: queue.Queue,
output_queue: queue.Queue,
driver: WebDriver
):
super().__init__(input_queue, output_queue)
self.__driver = driver
def _waitResponseLoad(
self
) -> bool:
return True
def logout(
self,
username: str
) -> bool:
if self.__driver is None:
self._showTrace("未提供有效 WebDriver 实例 !", self.TraceLevel.WARNING)
return False
try:
self.__driver.find_element(
By.XPATH, "//a[@href='/logout']"
).click()
self._showTrace(f"用户 {username} 注销成功 !")
return True
except Exception as e:
self._showTrace(f"用户 {username} 注销失败 ! : {e}", self.TraceLevel.ERROR)
return False
-199
View File
@@ -1,199 +0,0 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 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.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 operators.abs.LibTimeSelector import LibTimeSelector
class LibRenew(LibTimeSelector):
def __init__(
self,
input_queue: queue.Queue,
output_queue: queue.Queue,
driver: WebDriver
):
super().__init__(input_queue, output_queue)
self.__driver = driver
def _waitResponseLoad(
self
) -> bool:
self.__driver.refresh()
return True
def __waitRenewDialog(
self
) -> bool:
try:
WebDriverWait(self.__driver, 2).until(
EC.visibility_of_element_located((By.ID, "extendDiv"))
)
head_message = WebDriverWait(self.__driver, 2).until(
EC.presence_of_element_located((By.CSS_SELECTOR, "#extendDiv p.messageHead"))
)
result_message = WebDriverWait(self.__driver, 2).until(
EC.presence_of_element_located((By.CSS_SELECTOR, "#extendDiv div.resultMessage"))
)
except:
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}", no_log=True)
return False
try:
WebDriverWait(self.__driver, 2).until(
EC.presence_of_all_elements_located(
(By.CSS_SELECTOR, "#extendDiv .renewal_List li")
)
)
WebDriverWait(self.__driver, 2).until(
EC.presence_of_element_located((By.CSS_SELECTOR, "#extendDiv .btnOK"))
)
except:
self._showTrace("续约时间选择界面加载失败 !", self.TraceLevel.ERROR)
return False
return True
def __selectNearestTime(
self,
record: dict,
reserve_info: dict
) -> bool:
"""
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._timeStrToMins(end_time) + renew_info["expect_duration"]*60
# 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.TraceLevel.ERROR)
return False
def renew(
self,
username: str,
record: dict,
reserve_info: dict
) -> bool:
if self.__driver is None:
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.TraceLevel.ERROR)
return False
if "disabled" in renew_btn.get_attribute("class"):
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.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.__selectNearestTime(record, reserve_info):
self._showTrace(f"用户 {username} 续约失败 !", self.TraceLevel.ERROR)
self.__driver.refresh()
return False
if self._waitResponseLoad():
return True
-674
View File
@@ -1,674 +0,0 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 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 time
import queue
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 operators.abs.LibTimeSelector import LibTimeSelector
class LibReserve(LibTimeSelector):
def __init__(
self,
input_queue: queue.Queue,
output_queue: queue.Queue,
driver: WebDriver
):
super().__init__(input_queue, output_queue)
self.__driver = driver
# library floor and room mapping in website
self.__floor_map = {
"2": "二层",
"3": "三层",
"4": "四层",
"5": "五层"
}
self.__room_map = {
"1": "二层内环",
"2": "二层西区",
"3": "三层内环",
"4": "三层外环",
"5": "四层内环",
"6": "四层外环",
"7": "四层期刊",
"8": "五层考研"
}
def _waitResponseLoad(
self,
) -> bool:
try:
WebDriverWait(self.__driver, 2).until(
EC.presence_of_element_located((By.CLASS_NAME, "layoutSeat"))
)
title_elements = []
# reserve failed without title elements, so we need to try
try:
WebDriverWait(self.__driver, 2).until(
EC.presence_of_element_located((By.CSS_SELECTOR, ".layoutSeat dt"))
)
title_elements = self.__driver.find_elements(
By.CSS_SELECTOR, ".layoutSeat dt"
)
except:
pass
content_elements = self.__driver.find_elements(
By.CSS_SELECTOR, ".layoutSeat dd"
)
if not content_elements:
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.TraceLevel.ERROR)
raise
if "预定好了" in title or "预约成功" in title or "操作成功" in title:
if len(contents) >= 6:
self._showTrace(f"\n"\
f" 预约成功 !\n"\
f" {contents[1]}\n"\
f" {contents[2]}\n"\
f" {contents[3]}\n"\
f" 签到时间 {contents[5]}"
)
else:
self._showTrace("\n"\
" 预约成功 !\n"\
" 未找获取到详细信息"
)
return True
except:
self._showTrace(f"预约结果加载失败 !", self.TraceLevel.ERROR)
return False
def __containRequiredInfo(
self,
reserve_info: dict
) -> bool:
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 ?
raise ValueError(f"该楼层 '{reserve_info['floor']}' 不存在")
if reserve_info.get("room") is None:
raise ValueError("未指定房间")
if reserve_info["room"] not in self.__room_map:
raise ValueError(f"该房间 '{reserve_info['room']}' 不存在")
if reserve_info.get("seat_id") is None:
raise ValueError("未指定座位")
if reserve_info["seat_id"] == "":
raise ValueError("未指定座位号")
return True
except ValueError as e:
self._showTrace(
f"预约信息错误 ! : {e}, "\
f"由于缺少必要的预约信息, 无法开始预约流程",
self.TraceLevel.ERROR
)
self._showTrace(
f"预约信息错误 ! : {e}, "\
f"由于缺少必要的预约信息, 无法开始预约流程, 请检查预约信息是否完整",
no_log=True
)
return False
def __isValidDate(
self,
reserve_info: dict
) -> bool:
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_str
self._showTrace(f"预约日期未指定, 自动设置为当前日期: {cur_date_str}")
else:
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_str}, 自动设置为当前日期",
self.TraceLevel.WARNING
)
reserve_info["date"] = cur_date_str
return True
def __isValidBeginTime(
self,
reserve_info: dict
) -> bool:
cur_time = time.strftime("%H:%M", time.localtime())
if reserve_info.get("begin_time") is None:
reserve_info["begin_time"] = {}
if "time" not in reserve_info["begin_time"]:
reserve_info["begin_time"]["time"] = cur_time
self._showTrace(f"开始时间未指定, 自动设置为当前时间: {cur_time}")
if "max_diff" not in reserve_info["begin_time"]:
reserve_info["begin_time"]["max_diff"] = 30
self._showTrace(f"开始时间最大时间差未指定, 自动设置为 30 分钟")
if "prefer_early" not in reserve_info["begin_time"]:
reserve_info["begin_time"]["prefer_early"] = True
self._showTrace(f"是否优先选择更早开始时间未指定, 自动设置为 True")
return True
def __isValidExpectDuration(
self,
reserve_info: dict
) -> bool:
if reserve_info.get("satisfy_duration") is None:
reserve_info["satisfy_duration"] = True
self._showTrace("预约满足时长要求未指定, 默认满足")
if reserve_info["satisfy_duration"]:
if reserve_info.get("expect_duration") is None:
reserve_info["expect_duration"] = 4
self._showTrace("需要满足预约持续时间, 但未指定, 使用默认时长为 4 小时")
return True
def __isValidEndTime(
self,
reserve_info: dict
) -> bool:
if reserve_info.get("end_time") is None:
reserve_info["end_time"] = {}
if "time" not in reserve_info["end_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._minsToTimeStr(end_mins),
"max_diff": 30,
"prefer_early": False
}
self._showTrace(
f"结束时间未指定, 自动设置为开始时间加上期望时长: {reserve_info['end_time']['time']}"
)
if "max_diff" not in reserve_info["end_time"]:
reserve_info["end_time"]["max_diff"] = 30
self._showTrace(f"结束时间最大时间差未指定, 自动设置为 30 分钟")
if "prefer_early" not in reserve_info["end_time"]:
reserve_info["end_time"]["prefer_early"] = False
self._showTrace(f"是否优先选择较晚结束时间未指定, 自动设置为 True")
return True
def __finalCheck(
self,
reserve_info: dict
):
begin_time, end_time = reserve_info["begin_time"], reserve_info["end_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
# 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']}, 尝试交换时间",
self.TraceLevel.WARNING
)
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
max_end_mins = self._timeStrToMins("23:30")
if end_mins > max_end_mins:
self._showTrace(
f"结束时间 {end_time['time']} 晚于 23:30, 自动设置为 23:30",
self.TraceLevel.WARNING
)
reserve_info["end_time"]["time"] = "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 小时",
self.TraceLevel.WARNING
)
reserve_info["expect_duration"] = 8
else:
if end_mins - begin_mins > 8*60:
self._showTrace(
f"该用户未设置优先满足时长要求, 但是检查到预约持续时间 "
f"{float((end_mins - begin_mins)/60)} 小时 "
f"超出最大时长 8 小时, 自动设置为 8 小时",
self.TraceLevel.WARNING
)
reserve_info["end_time"]["time"] = self._minsToTimeStr(begin_mins + 8*60)
return True
def __checkReserveInfo(
self,
reserve_info: dict
) -> bool:
if not self.__containRequiredInfo(reserve_info):
return False
if not self.__isValidDate(reserve_info):
return False
if not self.__isValidBeginTime(reserve_info):
return False
if not self.__isValidExpectDuration(reserve_info):
return False
if not self.__isValidEndTime(reserve_info):
return False
if not self.__finalCheck(reserve_info):
return False
self._showTrace(
f"预约信息检查完成, 准备预约 "
f"{reserve_info['date']} "
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']]} "
f"的座位 {reserve_info['seat_id']}"
)
return True
def __clickElement(
self,
trigger_locator: tuple,
fail_msg: str,
success_msg: str,
option_locator: tuple = None
) -> bool:
try:
# click the trigger element
WebDriverWait(self.__driver, 2).until(
EC.element_to_be_clickable(trigger_locator)
).click()
if option_locator:
# select the option element if specified
WebDriverWait(self.__driver, 2).until(
EC.element_to_be_clickable(option_locator)
).click()
self._showTrace(success_msg)
return True
except:
self._showTrace(fail_msg)
return False
def __clickElementByJS(
self,
trigger_locator_id: str,
option_query_selector: str,
fail_msg: str,
success_msg: str,
) -> bool:
script = f"""
try {{
var trigger = document.getElementById('{trigger_locator_id}');
if (trigger) {{
trigger.click();
var option = document.querySelector("{option_query_selector}");
if (option) {{
option.click();
return true;
}}
return false;
}}
return false;
}} catch (e) {{
return false;
}}
"""
result = self.__driver.execute_script(script)
time.sleep(0.1)
if result:
self._showTrace(success_msg)
else:
self._showTrace(fail_msg)
return result
def __selectDate(
self,
date_str: str
) -> bool:
if self.__clickElementByJS(
trigger_locator_id="onDate_select",
option_query_selector=f"p#options_onDate a[value='{date_str}']",
success_msg=f"日期 {date_str} 选择成功 !",
fail_msg=f"选择日期失败 ! : {date_str} 不可用"
):
return True
return self.__clickElement(
trigger_locator=(By.ID, "onDate_select"),
option_locator=(By.XPATH, f"//p[@id='options_onDate']/a[@value='{date_str}']"),
success_msg=f"日期 {date_str} 选择成功 !",
fail_msg=f"选择日期失败 ! : {date_str} 不可用"
)
def __selectPlace(
self,
place: str
) -> bool:
place = "1" # the library only have this place :)
display_place = "图书馆"
if self.__clickElementByJS(
trigger_locator_id="display_building",
option_query_selector=f"p#options_building a[value='{place}']",
success_msg=f"预约场所 {display_place} 选择成功 !",
fail_msg=f"选择预约场所失败 ! : {display_place} 不可用"
):
return True
return self.__clickElement(
trigger_locator=(By.ID, "display_building"),
option_locator=(By.XPATH, f"//p[@id='options_building']/a[@value='{place}']"),
success_msg=f"预约场所 {display_place} 选择成功 !",
fail_msg=f"选择预约场所失败 ! : {display_place} 不可用"
)
def __selectFloor(
self,
floor: str
) -> bool:
display_floor = self.__floor_map.get(floor)
if self.__clickElementByJS(
trigger_locator_id="floor_select",
option_query_selector=f"p#options_floor a[value='{floor}']",
success_msg=f"楼层 {display_floor} 选择成功 !",
fail_msg=f"选择楼层失败 ! : {display_floor} 不可用"
):
return True
return self.__clickElement(
trigger_locator=(By.ID, "floor_select"),
option_locator=(By.XPATH, f"//p[@id='options_floor']/a[@value='{floor}']"),
success_msg=f"楼层 {display_floor} 选择成功 !",
fail_msg=f"选择楼层失败 ! : {display_floor} 不可用"
)
def __selectRoom(
self,
room: str
) -> bool:
display_room = self.__room_map.get(room)
# find room
try:
WebDriverWait(self.__driver, 2).until(
EC.element_to_be_clickable((By.ID, "findRoom"))
).click()
except:
self._showTrace("加载房间/区域失败 !", self.TraceLevel.ERROR)
return False
# select room
try:
WebDriverWait(self.__driver, 2).until(
EC.element_to_be_clickable((By.ID, f"room_{room}"))
).click()
self._showTrace(f"房间 {display_room} 选择成功 !")
return True
except:
self._showTrace(f"选择房间失败 ! : {display_room} 不可用", self.TraceLevel.ERROR)
return False
def __selectSeat(
self,
seat_id: str
) -> bool:
try:
# wait fot seat layout element to load
WebDriverWait(self.__driver, 2).until(
EC.presence_of_element_located((By.ID, "seatLayout"))
)
WebDriverWait(self.__driver, 2).until(
EC.presence_of_all_elements_located((By.CSS_SELECTOR, "li[id^='seat_']"))
)
except:
self._showTrace(f"座位加载失败 !", self.TraceLevel.ERROR)
return False
try:
all_seats = self.__driver.find_elements(
By.CSS_SELECTOR, "li[id^='seat_']"
)
seat_id_upper = seat_id.lstrip('0').upper()
for seat in all_seats:
if not seat_id_upper == seat.text.lstrip('0'):
continue
seat_link = seat.find_element(By.TAG_NAME, "a")
WebDriverWait(self.__driver, 2).until(
EC.element_to_be_clickable(seat_link)
)
seat_link.click()
seat_status = seat_link.get_attribute("title")
self._showTrace(f"座位 {seat_id} 选择成功 ! : 当前状态 - '{seat_status}'")
return True
self._showLog(f"座位 {seat_id} 在该楼层区域中不存在", self.TraceLevel.WARNING)
self._showTrace(f"座位 {seat_id} 在该楼层区域中不存在, 请检查座位号是否正确", no_log=True)
except:
self._showTrace(f"座位选择失败 !", self.TraceLevel.ERROR)
return False
def __selectNearestTime(
self,
time_id: str,
time_type: str,
target_time: int,
max_time_diff: int = 30,
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(
(By.CSS_SELECTOR, f"#{time_id} ul li a")
)
)
except:
self._showTrace(f"{time_type} 选择失败 ! : 当前未查询到可用时间", self.TraceLevel.ERROR)
return -1
# 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} 最近的 {best_text}, "
f"与期望 {time_type} 相比 {time_relation}"
)
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,
expect_duration: int = 4,
satisfy_duration: bool = True
) -> bool:
"""
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 begin time
act_beg_mins = self.__selectNearestTime(
time_id="startTime",
time_type="开始时间",
target_time=exp_beg_mins,
max_time_diff=begin_time["max_diff"],
prefer_earlier=begin_time["prefer_early"]
)
if act_beg_mins == -1:
return False
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:
exp_end_mins = int(self.__validateAndAdjustEndTime(act_beg_mins, expect_duration))
exp_end_tm_str = self._minsToTimeStr(exp_end_mins)
self._showTrace(
f"需要满足期望预约持续时间: {expect_duration} 小时, "
f"根据开始时间 {act_beg_tm_str} 计算结束时间: {exp_end_tm_str}"
)
# Select end time
act_end_mins = self.__selectNearestTime(
time_id="endTime",
time_type="结束时间",
target_time=exp_end_mins,
max_time_diff=end_time["max_diff"],
prefer_earlier=end_time["prefer_early"]
)
if act_end_mins == -1:
return False
act_end_tm_str = self._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 __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,
reserve_info: dict
) -> bool:
submit_reserve = False
reserve_success = False
have_hover_on_page = False
# reserve info
if not self.__checkReserveInfo(reserve_info):
return False
# map page
try:
WebDriverWait(self.__driver, 2).until(
EC.element_to_be_clickable((By.XPATH, "//a[@href='/map']"))
).click()
WebDriverWait(self.__driver, 2).until(
EC.presence_of_element_located((By.ID, "seatLayout"))
)
except:
self._showTrace(f"加载预约选座页面失败 !", self.TraceLevel.ERROR)
return False
# date, place, floor, room
if not self.__selectDate(reserve_info["date"]):
return False
if not self.__selectPlace(reserve_info["place"]):
return False
if not self.__selectFloor(reserve_info["floor"]):
return False
if not self.__selectRoom(reserve_info["room"]):
return False
else:
have_hover_on_page = True
# seat selections
if not self.__selectSeat(reserve_info["seat_id"]):
pass
elif not self.__selectSeatTime(
begin_time=reserve_info["begin_time"],
end_time=reserve_info["end_time"],
expect_duration=reserve_info["expect_duration"],
satisfy_duration=reserve_info["satisfy_duration"]
):
pass
else:
try:
WebDriverWait(self.__driver, 2).until(
EC.element_to_be_clickable((By.ID, "reserveBtn"))
).click()
submit_reserve = True
if not self._waitResponseLoad():
raise
reserve_success = True
except:
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.TraceLevel.ERROR)
return reserve_success
-13
View File
@@ -1,13 +0,0 @@
"""
Operators module for the AutoLibrary project.
Here are the classes and modules in this package:
- AutoLib: AutoLibrary operator.
- LibLogin: Library operator for logging in.
- LibLogout: Library operator for logging out.
- LibReserve: Library operator for reserving seat.
- LibCheckin: Library operator for checking in seat.
- LibCheckout: Library operator for checking out seat.
- LibChecker: Library operator for checking record status.
- LibRenew: Library operator for renewing seat.
"""
-139
View File
@@ -1,139 +0,0 @@
# -*- 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 datetime import datetime
from base.LibOperator import LibOperator
class LibTimeSelector(LibOperator):
"""
Abstract 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)
-6
View File
@@ -1,6 +0,0 @@
"""
Abstract layer class of the LibOperator
Here are the classes and modules in this package:
- LibTimeSelector: Abstract base class for time selection operations.
"""
+163 -123
View File
@@ -9,23 +9,24 @@ See the LICENSE file for details.
"""
import os
import queue
from selenium import webdriver
from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import (
TimeoutException,
WebDriverException,
)
from selenium.webdriver.edge.service import Service as EdgeService
from selenium.webdriver.chrome.service import Service as ChromeService
from selenium.webdriver.firefox.service import Service as FirefoxService
from base.MsgBase import MsgBase
from operators.LibChecker import LibChecker
from operators.LibLogin import LibLogin
from operators.LibLogout import LibLogout
from operators.LibReserve import LibReserve
from operators.LibCheckin import LibCheckin
from operators.LibRenew import LibRenew
from pages.LoginPage import LoginPage
from pages.MainShell import MainShell
from pages.flows.ReserveFlow import ReserveFlow, ReserveContext
from pages.flows.CheckinFlow import CheckinFlow
from pages.flows.RenewFlow import RenewFlow
from pages.services.CaptchaSolver import CaptchaSolver
from pages.services.ReserveChecker import ReserveChecker
from pages.services.RecordChecker import RecordChecker
class AutoLib(MsgBase):
@@ -34,29 +35,40 @@ class AutoLib(MsgBase):
self,
input_queue: queue.Queue,
output_queue: queue.Queue,
run_config: dict
):
run_config: dict,
) -> None:
super().__init__(input_queue, output_queue)
self.__run_config = run_config
self.__user_config = None
self.__run_config: dict = run_config
self.__user_config: dict | None = None
self.__driver = None
self.__driver_type: str = ""
self.__driver_path: str = ""
self.__login_page: LoginPage = None
self.__shell: MainShell = None
self.__captcha_solver: CaptchaSolver = None
self.__record_checker: RecordChecker = None
self.__reserve_checker: ReserveChecker = None
self.__reserve_flow: ReserveFlow = None
self.__checkin_flow: CheckinFlow = None
self.__renew_flow: RenewFlow = None
if not self.__initBrowserDriver():
raise Exception("浏览器驱动初始化失败 !")
else:
if not self.__initDriverUrl():
self.close()
raise Exception("浏览器驱动URL初始化失败 !")
self.__initLibOperators()
self.__initPagesServices()
self.__initPagesFlows()
def __initBrowserDriver(
self
self,
) -> bool:
self._showTrace("正在初始化浏览器驱动......", no_log=True)
web_driver_config = self.__run_config.get("web_driver", None)
self.__driver_type = web_driver_config.get("driver_type")
web_driver_config: dict = self.__run_config.get("web_driver", None)
self.__driver_type = web_driver_config.get("driver_type", "none")
match self.__driver_type.lower():
case "edge":
driver_options = webdriver.EdgeOptions()
@@ -67,14 +79,13 @@ class AutoLib(MsgBase):
case _:
self._showTrace(
f"不支持的浏览器驱动类型: {self.__driver_type} !",
self.TraceLevel.WARNING
self.TraceLevel.WARNING,
)
return False
if not web_driver_config:
self._showTrace("未配置浏览器驱动参数 !", self.TraceLevel.ERROR)
return False
if web_driver_config.get("headless"):
if web_driver_config.get("headless", False):
driver_options.add_argument("--headless")
driver_options.add_argument("--disable-gpu")
driver_options.add_argument("--no-sandbox")
@@ -101,6 +112,7 @@ class AutoLib(MsgBase):
"Safari/537.36"
if self.__driver_type.lower() == "edge":
user_agent += " Edg/120.0.0.0"
# set options for firefox
elif self.__driver_type.lower() == "firefox":
driver_options.set_preference("dom.webdriver.enabled", False)
@@ -110,12 +122,12 @@ class AutoLib(MsgBase):
driver_options.add_argument(f"user-agent={user_agent}")
# init browser driver
self.__driver_path = web_driver_config.get("driver_path")
self.__driver_path = web_driver_config.get("driver_path", "")
if not self.__driver_path:
self._showTrace("未配置浏览器驱动路径 !", self.TraceLevel.WARNING)
return False
self.__driver_path = os.path.abspath(self.__driver_path)
try:
self.__driver_path = os.path.abspath(self.__driver_path)
service = None
match self.__driver_type.lower():
case "edge":
@@ -125,127 +137,155 @@ 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 浏览器驱动初始化略慢, 请耐心等待...", no_log=True)
self._showTrace("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'
case _:
raise Exception(f"不支持的浏览器驱动类型: {self.__driver_type} !")
self.__driver.implicitly_wait(1)
self.__driver.execute_script(
"Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
)
except WebDriverException as e:
self._showTrace(f"浏览器驱动初始化失败: {e}", self.TraceLevel.ERROR)
return False
except Exception as e:
self._showTrace(f"浏览器驱动初始化失败: {e}", self.TraceLevel.ERROR)
return False
self._showTrace(f"浏览器驱动已初始化, 类型: {self.__driver_type}, 路径: {self.__driver_path}")
return True
def __initLibOperators(
self
):
if not self.__driver:
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)
self.__lib_logout = LibLogout(self._input_queue, self._output_queue, self.__driver)
self.__lib_reserve = LibReserve(self._input_queue, self._output_queue, self.__driver)
self.__lib_checkin = LibCheckin(self._input_queue, self._output_queue, self.__driver)
self.__lib_renew = LibRenew(self._input_queue, self._output_queue, self.__driver)
def __waitResponseLoad(
self
) -> bool:
# wait for page load
try:
WebDriverWait(self.__driver, 2).until( # title contains "首页"
EC.title_contains("首页")
)
WebDriverWait(self.__driver, 2).until( # username field presence
EC.presence_of_element_located((By.NAME, "username"))
)
WebDriverWait(self.__driver, 2).until( # password field presence
EC.presence_of_element_located((By.NAME, "password"))
)
WebDriverWait(self.__driver, 2).until( # captcha field presence
EC.presence_of_element_located((By.NAME, "answer"))
)
WebDriverWait(self.__driver, 2).until( # captcha image presence
EC.presence_of_element_located((By.ID, "loadImgId"))
)
return True
except:
self._showTrace(f"登录页面加载失败 !", self.TraceLevel.ERROR)
return False
def __initDriverUrl(
self,
) -> bool:
lib_config = self.__run_config.get("library", None)
lib_config: dict = self.__run_config.get("library", None)
if not lib_config:
self._showTrace("未配置图书馆参数 !", self.TraceLevel.ERROR)
return False
url = 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.__driver.set_page_load_timeout(5)
try:
self.__driver.get(url)
except TimeoutException:
self.__driver.execute_script("window.stop();")
self.__login_page.stopPageLoad()
self._showTrace(
f"图书馆登录页面加载超时 ! 请检查网络环境是否正常", self.TraceLevel.ERROR
"图书馆登录页面加载超时 ! 请检查网络环境是否正常", self.TraceLevel.ERROR
)
return False
if not self.__waitResponseLoad():
except WebDriverException as e:
self._showTrace(f"图书馆页面加载失败: {e}", self.TraceLevel.ERROR)
return False
if not self.__login_page.waitUntilLoaded():
return False
return True
def __initPagesServices(
self,
) -> None:
if not self.__driver:
self._showTrace("浏览器驱动未初始化, 请先初始化浏览器驱动 !", self.TraceLevel.WARNING)
return
self.__shell = MainShell(self.__driver)
self.__captcha_solver = CaptchaSolver(
input_queue=self._input_queue,
output_queue=self._output_queue,
)
self.__record_checker = RecordChecker(
input_queue=self._input_queue,
output_queue=self._output_queue,
)
self.__reserve_checker = ReserveChecker(
input_queue=self._input_queue,
output_queue=self._output_queue,
)
def __initPagesFlows(
self,
) -> None:
self.__reserve_flow = ReserveFlow(
input_queue=self._input_queue,
output_queue=self._output_queue,
driver=self.__driver,
shell=self.__shell,
)
self.__checkin_flow = CheckinFlow(
input_queue=self._input_queue,
output_queue=self._output_queue,
driver=self.__driver,
shell=self.__shell,
)
self.__renew_flow = RenewFlow(
input_queue=self._input_queue,
output_queue=self._output_queue,
driver=self.__driver,
shell=self.__shell,
)
def __run(
self,
username: str,
password: str,
login_config: dict,
run_mode_config: dict,
reserve_info: dict
reserve_info: dict,
) -> int:
# result : -1 - terminate, 0 - success, 1 - failed, 2 - passed
result = 2
result: int = 2
# login
if not self.__lib_login.login(
auto_captcha: bool = login_config.get("auto_captcha", True)
if not self.__login_page.login(
username,
password,
login_config.get("max_attempt", 3),
login_config.get("auto_captcha", True),
captcha_solver=self.__captcha_solver.solveCaptcha,
auto_captcha=auto_captcha,
max_attempts=login_config.get("max_attempt", 3),
):
return 1
# Here, we collect the run mode from the run config.
run_mode = run_mode_config.get("run_mode", 0)
run_mode = {
"auto_reserve": run_mode&0x1,
"auto_checkin": run_mode&0x2,
"auto_renewal": run_mode&0x4,
run_mode_raw: int = run_mode_config.get("run_mode", 0)
run_mode: dict[str, bool] = {
"auto_reserve": run_mode_raw & 0x1,
"auto_checkin": run_mode_raw & 0x2,
"auto_renewal": run_mode_raw & 0x4,
}
# reserve
if run_mode["auto_reserve"]:
if self.__lib_checker.canReserve(reserve_info.get("date")):
if self.__lib_reserve.reserve(username, reserve_info):
if self.__record_checker.canReserve(self.__shell, reserve_info["date"]):
if self.__reserve_checker.check(reserve_info):
ctx = ReserveContext(
username=username,
date=reserve_info["date"],
floor=reserve_info["floor"],
room=reserve_info["room"],
seat_id=reserve_info["seat_id"],
begin_time=reserve_info["begin_time"]["time"],
end_time=reserve_info["end_time"]["time"],
begin_max_diff=reserve_info["begin_time"]["max_diff"],
end_max_diff=reserve_info["end_time"]["max_diff"],
begin_prefer_early=reserve_info["begin_time"]["prefer_early"],
end_prefer_early=reserve_info["end_time"]["prefer_early"],
expect_duration=reserve_info["expect_duration"],
satisfy_duration=reserve_info["satisfy_duration"],
)
if self.__reserve_flow.execute(ctx):
result = 0
else:
result = 1
else:
result = 1
else:
self._showTrace(f"用户 {username} 无法预约, 已跳过")
result = 2
# checkin
last_result = result
last_result: int = result
if run_mode["auto_checkin"] and last_result != 1:
if self.__lib_checker.canCheckin():
if self.__lib_checkin.checkin(username):
if self.__record_checker.canCheckin(self.__shell):
if self.__checkin_flow.execute(username):
result = 0
else:
result = 1
@@ -258,10 +298,11 @@ class AutoLib(MsgBase):
# renewal
last_result = result
if run_mode["auto_renewal"] and last_result != 1:
can_renew, record = self.__lib_checker.canRenew()
can_renew, record = self.__record_checker.canRenew(self.__shell)
if can_renew:
if self.__lib_renew.renew(username, record, reserve_info):
if self.__lib_checker.postRenewCheck(record):
renew_info: dict = reserve_info.get("renew_time", {})
if self.__renew_flow.execute(username, record, renew_info):
if self.__record_checker.postRenewCheck(self.__shell, record):
self._showTrace(f"用户 {username} 续约成功 !")
result = 0
else:
@@ -278,46 +319,41 @@ class AutoLib(MsgBase):
result = 0
# logout
if not self.__lib_logout.logout(
username
):
# if logout is failed, we must make sure the host to be reloaded
# otherwise, the next login may fail
if not self.__shell.logout():
if not self.__initDriverUrl():
return -1
return result
def run(
self,
user_config: dict
):
user_config: dict,
) -> None:
self.__user_config = user_config
user_counter = {"current": 0, "success": 0, "failed": 0, "passed": 0}
users = self.__user_config["users"]
user_counter: dict[str, int] = {"current": 0, "success": 0, "failed": 0, "passed": 0}
users: list = self.__user_config.get("users", [])
self._showTrace(f"共发现 {len(users)} 个用户")
for user in users:
user_counter["current"] += 1
self._showTrace(
f"正在处理第 {user_counter["current"]}/{len(users)} 个用户: {user["username"]}......",
no_log=True
f"正在处理第 {user_counter["current"]}/{len(users)} 个用户: {user.get("username", "未知")}......",
no_log=True,
)
if not user["enabled"]:
self._showTrace(f"用户 {user["username"]} 已跳过")
if not user.get("enabled", False):
self._showTrace(f"用户 {user.get("username", "未知")} 已跳过")
user_counter["passed"] += 1
continue
r = self.__run(
username=user["username"],
password=user["password"],
login_config=self.__run_config["login"],
run_mode_config=self.__run_config["mode"],
reserve_info=user["reserve_info"],
r: int = self.__run(
username=user.get("username", ""),
password=user.get("password", ""),
login_config=self.__run_config.get("login", {}),
run_mode_config=self.__run_config.get("mode", {}),
reserve_info=user.get("reserve_info", {}),
)
if r == -1:
self._showTrace(
f"用户 {user["username"]} 处理过程中页面发生异常, 无法继续操作, 任务已终止 !",
self.TraceLevel.WARNING
f"用户 {user.get("username", "未知")} 处理过程中页面发生异常, 无法继续操作, 任务已终止 !",
self.TraceLevel.WARNING,
)
break
elif r == 0:
@@ -326,27 +362,31 @@ class AutoLib(MsgBase):
user_counter["failed"] += 1
elif r == 2:
user_counter["passed"] += 1
self._showTrace(f"处理完成, 共计 {user_counter["current"]} 个用户, "\
f"成功 {user_counter["success"]} 个用户, "\
f"失败 {user_counter["failed"]} 个用户, "\
self._showTrace(
f"处理完成, 共计 {user_counter["current"]} 个用户, "
f"成功 {user_counter["success"]} 个用户, "
f"失败 {user_counter["failed"]} 个用户, "
f"跳过 {user_counter["passed"]} 个用户"
)
return
def close(
self
self,
) -> bool:
if self.__driver:
if self.__driver_type.lower() == "firefox":
self._showTrace(
f"Firefox 浏览器驱动关闭略慢, 请耐心等待...",
no_log=True
"Firefox 浏览器驱动关闭略慢, 请耐心等待...",
no_log=True,
)
try:
self.__driver.quit()
except WebDriverException as e:
self._showTrace(f"浏览器驱动关闭时发生异常: {e}", self.TraceLevel.WARNING)
self.__driver = None
self._showTrace(f"浏览器驱动已关闭")
self._showTrace("浏览器驱动已关闭")
return True
else:
self._showTrace(f"浏览器驱动未初始化, 无需关闭", no_log=True)
self._showTrace("浏览器驱动未初始化, 无需关闭", no_log=True)
return False
+219
View File
@@ -0,0 +1,219 @@
# -*- 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 typing import Callable, Optional
from selenium.common.exceptions import (
ElementNotInteractableException,
NoSuchElementException,
TimeoutException,
)
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
class LoginPage:
USERNAME_INPUT = (By.NAME, "username")
PASSWORD_INPUT = (By.NAME, "password")
CAPTCHA_INPUT = (By.NAME, "answer")
CAPTCHA_IMG = (By.ID, "loadImgId")
LOGIN_BUTTON = (By.XPATH, "//input[@type='button' and @value='登录']")
SUCCESS_INDICATOR_SEARCH = (By.ID, "search")
SUCCESS_INDICATOR_CONTENT = (By.CLASS_NAME, "selectContent")
SUCCESS_TITLE_KEYWORD = "自选座位 :: 座位预约系统"
PAGE_LOAD_TIMEOUT = 5
def __init__(
self,
driver: WebDriver,
tracer: Optional[Callable[..., None]] = None,
) -> None:
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(
self,
url: str,
) -> bool:
self._driver.set_page_load_timeout(self.PAGE_LOAD_TIMEOUT)
self._driver.get(url)
if not self.waitUntilLoaded():
return False
return True
def waitUntilLoaded(
self,
) -> bool:
try:
WebDriverWait(self._driver, 2).until(
EC.title_contains("首页")
)
WebDriverWait(self._driver, 2).until(
EC.presence_of_element_located(self.USERNAME_INPUT)
)
WebDriverWait(self._driver, 2).until(
EC.presence_of_element_located(self.PASSWORD_INPUT)
)
WebDriverWait(self._driver, 2).until(
EC.presence_of_element_located(self.CAPTCHA_INPUT)
)
WebDriverWait(self._driver, 2).until(
EC.presence_of_element_located(self.CAPTCHA_IMG)
)
return True
except (NoSuchElementException, TimeoutException):
return False
except Exception:
return False
def fillCredentials(
self,
username: str,
password: str,
) -> bool:
try:
el = self._driver.find_element(*self.USERNAME_INPUT)
el.clear()
el.send_keys(username)
el = self._driver.find_element(*self.PASSWORD_INPUT)
el.clear()
el.send_keys(password)
return True
except (NoSuchElementException, TimeoutException):
return False
except Exception:
return False
def getCaptchaImageSrc(
self,
) -> str:
captcha_el = self._driver.find_element(*self.CAPTCHA_IMG)
return captcha_el.get_attribute("src")
def refreshCaptcha(
self,
) -> bool:
try:
self._driver.find_element(*self.CAPTCHA_IMG).click()
return True
except (NoSuchElementException, TimeoutException,
ElementNotInteractableException):
return False
except Exception:
return False
def fillCaptcha(
self,
captcha_text: str,
) -> bool:
try:
el = self._driver.find_element(*self.CAPTCHA_INPUT)
el.clear()
el.send_keys(captcha_text)
return True
except (NoSuchElementException, TimeoutException):
return False
except Exception:
return False
def clickLogin(
self,
) -> bool:
try:
self._driver.find_element(*self.LOGIN_BUTTON).click()
return True
except (NoSuchElementException, TimeoutException,
ElementNotInteractableException):
return False
except Exception:
return False
def waitLoginSuccess(
self,
) -> bool:
try:
WebDriverWait(self._driver, 2).until(
EC.title_contains(self.SUCCESS_TITLE_KEYWORD)
)
WebDriverWait(self._driver, 2).until(
EC.presence_of_element_located(self.SUCCESS_INDICATOR_SEARCH)
)
WebDriverWait(self._driver, 2).until(
EC.presence_of_element_located(self.SUCCESS_INDICATOR_CONTENT)
)
return True
except (NoSuchElementException, TimeoutException):
return False
except Exception:
return False
def stopPageLoad(
self,
) -> None:
self._driver.execute_script("window.stop();")
def login(
self,
username: str,
password: str,
captcha_solver: Callable[["LoginPage", bool], str],
auto_captcha: bool,
max_attempts: int = 5,
) -> bool:
for attempt in range(max_attempts):
self._trace(
f"用户 {username}{attempt + 1} 次尝试登录......",
no_log=True,
)
if not self.fillCredentials(username, password):
continue
captcha_text = captcha_solver(self, auto_captcha)
if not captcha_text:
continue
if not self.fillCaptcha(captcha_text):
continue
self._trace("尝试登录...", no_log=True)
if not self.clickLogin():
continue
if self.waitLoginSuccess():
self._trace(f"用户 {username}{attempt + 1} 次登录成功 !")
return True
else:
self._trace(
"登录页面加载失败 ! : "
"用户账号或者密码错误/验证码错误, 具体以页面提示为准",
level=40,
)
return False
+166
View File
@@ -0,0 +1,166 @@
# -*- 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 time
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.common.exceptions import (
NoSuchElementException,
TimeoutException,
)
from pages.ReserveView import ReserveView
from pages.RecordsView import RecordsView
class MainShell:
TAB_RESERVE = (By.XPATH, "//a[@href='/map']")
TAB_HISTORY = (By.XPATH, "//a[@href='/history?type=SEAT']")
TAB_LOGOUT = (By.XPATH, "//a[@href='/logout']")
BTN_CHECKIN = (By.ID, "btnCheckIn")
BTN_EXTEND = (By.ID, "btnExtend")
def __init__(
self,
driver: WebDriver,
) -> None:
self._driver = driver
def gotoReserveView(
self,
) -> ReserveView:
self._clickTab(self.TAB_RESERVE)
WebDriverWait(self._driver, 2).until(
EC.presence_of_element_located((By.ID, "seatLayout"))
)
return ReserveView(self._driver)
def gotoRecordsView(
self,
) -> RecordsView:
self._clickTab(self.TAB_HISTORY)
WebDriverWait(self._driver, 2).until(
EC.presence_of_element_located((By.CLASS_NAME, "myReserveList"))
)
return RecordsView(self._driver)
def logout(
self,
) -> bool:
try:
self._driver.find_element(*self.TAB_LOGOUT).click()
return True
except NoSuchElementException:
return False
except Exception:
return False
def waitCheckinButton(
self,
) -> bool:
try:
WebDriverWait(self._driver, 2).until(
EC.element_to_be_clickable(self.BTN_CHECKIN)
)
return True
except TimeoutException:
return False
except Exception:
return False
def waitExtendButton(
self,
) -> bool:
try:
WebDriverWait(self._driver, 2).until(
EC.element_to_be_clickable(self.BTN_EXTEND)
)
return True
except TimeoutException:
return False
except Exception:
return False
def isCheckinButtonDisabled(
self,
) -> bool:
btn = self._driver.find_element(*self.BTN_CHECKIN)
return "disabled" in btn.get_attribute("class")
def isExtendButtonDisabled(
self,
) -> bool:
btn = self._driver.find_element(*self.BTN_EXTEND)
return "disabled" in btn.get_attribute("class")
def clickCheckinButton(
self,
) -> None:
btn = WebDriverWait(self._driver, 2).until(
EC.element_to_be_clickable(self.BTN_CHECKIN)
)
btn.click()
def clickExtendButton(
self,
) -> None:
btn = WebDriverWait(self._driver, 2).until(
EC.element_to_be_clickable(self.BTN_EXTEND)
)
btn.click()
def enableCheckinButtonByJS(
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)
return result
def refresh(
self,
) -> None:
self._driver.refresh()
def _clickTab(
self,
locator: tuple,
) -> None:
WebDriverWait(self._driver, 2).until(
EC.element_to_be_clickable(locator)
).click()
+93
View File
@@ -0,0 +1,93 @@
# -*- 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 selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.remote.webelement import WebElement
from selenium.common.exceptions import (
NoSuchElementException,
StaleElementReferenceException,
TimeoutException,
)
class RecordsView:
RECORDS_LIST = (By.CSS_SELECTOR, ".myReserveList > dl:not(#moreBlock)")
MORE_BTN = (By.ID, "moreBtn")
RECORD_TIME = (By.CSS_SELECTOR, "dt")
RECORD_INFO = (By.CSS_SELECTOR, "a")
def __init__(
self,
driver: WebDriver,
) -> None:
self._driver = driver
def loadRecords(
self,
) -> list | None:
try:
WebDriverWait(self._driver, 2).until(
EC.presence_of_element_located(self.RECORDS_LIST)
)
return self._driver.find_elements(*self.RECORDS_LIST)
except TimeoutException:
return None
except Exception:
return None
def getRecordTimeElement(
self,
record: WebElement,
) -> WebElement:
return record.find_element(*self.RECORD_TIME)
def getRecordInfoElements(
self,
record: WebElement,
) -> list[WebElement]:
return record.find_elements(*self.RECORD_INFO)
def showMoreRecords(
self,
) -> bool:
try:
WebDriverWait(self._driver, 2).until(
EC.element_to_be_clickable(self.MORE_BTN)
)
except TimeoutException:
return False
except Exception:
return False
try:
more_btn = self._driver.find_element(*self.MORE_BTN)
if more_btn.is_displayed() and more_btn.is_enabled():
self._driver.execute_script("arguments[0].scrollIntoView(true);", more_btn)
self._driver.execute_script("arguments[0].click();", more_btn)
return True
return False
except (NoSuchElementException, StaleElementReferenceException):
return False
except Exception:
return False
def getRecordText(
self,
record: WebElement,
) -> str:
return record.text.strip()
+193
View File
@@ -0,0 +1,193 @@
# -*- 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 time
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.common.exceptions import (
ElementNotInteractableException,
TimeoutException,
)
from pages.components.SeatMapDialog import SeatMapDialog
class ReserveView:
DATE_SELECT = (By.ID, "onDate_select")
DATE_OPTION_FMT = "p#options_onDate a[value='{value}']"
DATE_XPATH_FMT = "//p[@id='options_onDate']/a[@value='{value}']"
PLACE_SELECT = (By.ID, "display_building")
PLACE_OPTION_FMT = "p#options_building a[value='{value}']"
PLACE_XPATH_FMT = "//p[@id='options_building']/a[@value='{value}']"
FLOOR_SELECT = (By.ID, "floor_select")
FLOOR_OPTION_FMT = "p#options_floor a[value='{value}']"
FLOOR_XPATH_FMT = "//p[@id='options_floor']/a[@value='{value}']"
FIND_ROOM_BTN = (By.ID, "findRoom")
ROOM_BTN_FMT = "room_{room}"
RESERVE_BTN = (By.ID, "reserveBtn")
FLOOR_MAP = {"2": "二层", "3": "三层", "4": "四层", "5": "五层"}
ROOM_MAP = {
"1": "二层内环", "2": "二层西区", "3": "三层内环", "4": "三层外环",
"5": "四层内环", "6": "四层外环", "7": "四层期刊", "8": "五层考研",
}
def __init__(
self,
driver: WebDriver,
) -> None:
self._driver = driver
def selectDate(
self,
date_str: str,
) -> bool:
if self._clickOptionByJS(
trigger_id="onDate_select",
option_css=self.DATE_OPTION_FMT.format(value=date_str),
):
return True
return self._clickOption(
trigger=self.DATE_SELECT,
option=(By.XPATH, self.DATE_XPATH_FMT.format(value=date_str)),
)
def selectPlace(
self,
place: str = "1",
) -> bool:
if self._clickOptionByJS(
trigger_id="display_building",
option_css=self.PLACE_OPTION_FMT.format(value=place),
):
return True
return self._clickOption(
trigger=self.PLACE_SELECT,
option=(By.XPATH, self.PLACE_XPATH_FMT.format(value=place)),
)
def selectFloor(
self,
floor: str,
) -> bool:
if self._clickOptionByJS(
trigger_id="floor_select",
option_css=self.FLOOR_OPTION_FMT.format(value=floor),
):
return True
return self._clickOption(
trigger=self.FLOOR_SELECT,
option=(By.XPATH, self.FLOOR_XPATH_FMT.format(value=floor)),
)
def selectRoom(
self,
room: str,
) -> SeatMapDialog | None:
try:
WebDriverWait(self._driver, 2).until(
EC.element_to_be_clickable(self.FIND_ROOM_BTN)
).click()
except (TimeoutException, ElementNotInteractableException):
return None
except Exception:
return None
try:
WebDriverWait(self._driver, 2).until(
EC.element_to_be_clickable((By.ID, self.ROOM_BTN_FMT.format(room=room)))
).click()
except (TimeoutException, ElementNotInteractableException):
return None
except Exception:
return None
try:
return SeatMapDialog(self._driver)
except (TimeoutException):
return None
except Exception:
return None
def submitReserve(
self,
) -> bool:
try:
WebDriverWait(self._driver, 2).until(
EC.element_to_be_clickable(self.RESERVE_BTN)
).click()
return True
except (TimeoutException, ElementNotInteractableException):
return False
except Exception:
return False
def refresh(
self,
) -> None:
self._driver.refresh()
def _clickOptionByJS(
self,
trigger_id: str,
option_css: str,
) -> bool:
script = f"""
try {{
var trigger = document.getElementById('{trigger_id}');
if (trigger) {{
trigger.click();
var option = document.querySelector("{option_css}");
if (option) {{
option.click();
return true;
}}
return false;
}}
return false;
}} catch (e) {{
return false;
}}
"""
result = self._driver.execute_script(script)
time.sleep(0.1)
return result
def _clickOption(
self,
trigger: tuple,
option: tuple,
) -> bool:
try:
WebDriverWait(self._driver, 2).until(
EC.element_to_be_clickable(trigger)
).click()
WebDriverWait(self._driver, 2).until(
EC.element_to_be_clickable(option)
).click()
return True
except (TimeoutException, ElementNotInteractableException):
return False
except Exception:
return False
+32
View File
@@ -0,0 +1,32 @@
# -*- 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.AutoLib import AutoLib
from pages.LoginPage import LoginPage
from pages.MainShell import MainShell
from pages.ReserveView import ReserveView
from pages.RecordsView import RecordsView
from pages.components.SeatMapDialog import SeatMapDialog
from pages.components.TimeSelectDialog import TimeSelectDialog
from pages.components.ReserveResultDialog import ReserveResultDialog
from pages.components.CheckinResultDialog import CheckinResultDialog
from pages.components.RenewDialog import RenewDialog
__all__ = [
"AutoLib",
"LoginPage",
"MainShell",
"ReserveView",
"RecordsView",
"SeatMapDialog",
"TimeSelectDialog",
"ReserveResultDialog",
"CheckinResultDialog",
"RenewDialog",
]
@@ -0,0 +1,75 @@
# -*- 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 selenium.common.exceptions import (
ElementNotInteractableException,
NoSuchElementException,
StaleElementReferenceException,
TimeoutException,
)
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from pages.components.Dialog import Dialog
class CheckinResultDialog(Dialog):
"""
Check-in result dialog.
"""
ROOT = (By.CLASS_NAME, "ui_dialog")
RESULT_MSG = (By.CLASS_NAME, "resultMessage")
OK_BTN = (By.CLASS_NAME, "btnOK")
DETAIL_DD = (By.CSS_SELECTOR, ".resultMessage dd")
def __init__(
self,
driver: WebDriver,
) -> None:
super().__init__(driver, self.ROOT, auto_close_on_exit=False)
def getResultMessage(
self,
) -> str:
try:
self._waitPresence(self.RESULT_MSG)
el = self._find(*self.RESULT_MSG)
return el.text
except (TimeoutException, NoSuchElementException, StaleElementReferenceException):
return ""
except Exception:
return ""
def getDetails(
self,
) -> list[str]:
try:
elements = self._findAll(*self.DETAIL_DD)
return [el.text for el in elements if el.text.strip()]
except (NoSuchElementException, StaleElementReferenceException):
return []
except Exception:
return []
def clickOk(
self,
) -> bool:
try:
self._waitClickable(self.OK_BTN).click()
return True
except (NoSuchElementException, TimeoutException, ElementNotInteractableException):
return False
except Exception:
return False
+114
View File
@@ -0,0 +1,114 @@
# -*- 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 selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.remote.webelement import WebElement
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
class Dialog:
"""
Context-managed overlay / modal / dialog on a page.
The constructor verifies that the root element is visible if not,
the dialog is not on screen and a :exc:`TimeoutException` is raised.
Automates the lifecycle: wait for appearance on enter,
optionally wait for disappearance on exit.
"""
def __init__(
self,
driver: WebDriver,
root_locator: tuple,
auto_close_on_exit: bool = True,
wait_timeout: float = 3.0,
) -> None:
self._driver: WebDriver = driver
self._root_locator: tuple = root_locator
self._auto_close: bool = auto_close_on_exit
self._timeout: float = wait_timeout
WebDriverWait(self._driver, self._timeout).until(
EC.visibility_of_element_located(self._root_locator)
)
def __enter__(
self,
) -> "Dialog":
return self
def __exit__(
self,
*args: object,
) -> None:
if self._auto_close:
WebDriverWait(self._driver, self._timeout).until(
EC.invisibility_of_element_located(self._root_locator)
)
def _find(
self,
by: str,
value: str,
) -> WebElement:
return self._driver.find_element(by, value)
def _findAll(
self,
by: str,
value: str,
) -> list[WebElement]:
return self._driver.find_elements(by, value)
def _waitClickable(
self,
locator: tuple,
timeout: float = 2.0,
) -> WebElement:
return WebDriverWait(self._driver, timeout).until(
EC.element_to_be_clickable(locator)
)
def _waitPresence(
self,
locator: tuple,
timeout: float = 2.0,
) -> WebElement:
return WebDriverWait(self._driver, timeout).until(
EC.presence_of_element_located(locator)
)
def _waitVisible(
self,
locator: tuple,
timeout: float = 2.0,
) -> WebElement:
return WebDriverWait(self._driver, timeout).until(
EC.visibility_of_element_located(locator)
)
def _waitAllPresence(
self,
locator: tuple,
timeout: float = 2.0,
) -> list[WebElement]:
return WebDriverWait(self._driver, timeout).until(
EC.presence_of_all_elements_located(locator)
)
+124
View File
@@ -0,0 +1,124 @@
# -*- 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 selenium.common.exceptions import (
ElementNotInteractableException,
NoSuchElementException,
TimeoutException,
)
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.remote.webelement import WebElement
from pages.components.Dialog import Dialog
from pages.strategies.TimeSelectMaker import (
TimeSelectionResult,
TimeSelectMaker,
)
class RenewDialog(Dialog):
"""
Renewal time selection dialog.
"""
ROOT = (By.ID, "extendDiv")
MESSAGE_HEAD = (By.CSS_SELECTOR, "#extendDiv p.messageHead")
RESULT_MSG = (By.CSS_SELECTOR, "#extendDiv div.resultMessage")
TIME_OPTS = (By.CSS_SELECTOR, "#extendDiv .renewal_List li")
OK_BTN = (By.CSS_SELECTOR, "#extendDiv .btnOK")
def __init__(
self,
driver: WebDriver,
) -> None:
super().__init__(driver, self.ROOT, auto_close_on_exit=False)
def waitUntilReady(
self,
) -> bool:
try:
self._waitVisible(self.ROOT)
self._waitPresence(self.MESSAGE_HEAD)
self._waitPresence(self.RESULT_MSG)
except (NoSuchElementException, TimeoutException):
return False
except Exception:
return False
head_msg = self._find(*self.MESSAGE_HEAD).text.strip()
if "警告" in head_msg:
return False
try:
self._waitAllPresence(self.TIME_OPTS)
self._waitPresence(self.OK_BTN)
except (NoSuchElementException, TimeoutException):
return False
except Exception:
return False
return True
def getHeadMessage(
self,
) -> str:
return self._find(*self.MESSAGE_HEAD).text.strip()
def getResultMessage(
self,
) -> str:
return self._find(*self.RESULT_MSG).text.strip()
def getTimeOptions(
self,
) -> list[WebElement]:
return self._findAll(*self.TIME_OPTS)
def selectBestTime(
self,
target_time: int,
max_time_diff: int,
prefer_earlier: bool,
) -> TimeSelectionResult:
all_time_opts = self.getTimeOptions()
if not all_time_opts:
return TimeSelectionResult()
result = TimeSelectMaker.forRenew().decide(
all_time_opts,
target_time,
max_time_diff,
prefer_earlier,
)
if result.selected_index >= 0:
all_time_opts[result.selected_index].click()
return result
def getOkButton(
self,
) -> WebElement:
return self._find(*self.OK_BTN)
def clickOk(
self,
) -> bool:
try:
self._find(*self.OK_BTN).click()
return True
except (NoSuchElementException, TimeoutException,
ElementNotInteractableException):
return False
except Exception:
return False
@@ -0,0 +1,81 @@
# -*- 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 selenium.common.exceptions import (
NoSuchElementException,
StaleElementReferenceException,
)
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from pages.components.Dialog import Dialog
class ReserveResultDialog(Dialog):
"""
Reservation result dialog shown after submitting a reserve request.
"""
ROOT = (By.CLASS_NAME, "layoutSeat")
def __init__(
self,
driver: WebDriver,
) -> None:
super().__init__(driver, self.ROOT, auto_close_on_exit=False)
def _titleLocator(
self,
) -> tuple:
return (By.CSS_SELECTOR, ".layoutSeat dt")
def getTitle(
self,
) -> str:
try:
return self._find(*self._titleLocator()).text
except (NoSuchElementException, StaleElementReferenceException):
return ""
except Exception:
return ""
def isSuccess(
self,
) -> bool:
title = self.getTitle()
return any(
kw in title
for kw in ("预定好了", "预约成功", "操作成功")
)
def isFailure(
self,
) -> bool:
contents = self.getDetailTexts()
return any(
"预约失败" in msg or "已有1个有效预约" in msg
for msg in contents
)
def getDetailTexts(
self,
) -> list[str]:
try:
elements = self._findAll(By.CSS_SELECTOR, ".layoutSeat dd")
return [el.text for el in elements if el.text.strip()]
except (NoSuchElementException, StaleElementReferenceException):
return []
except Exception:
return []
+80
View File
@@ -0,0 +1,80 @@
# -*- 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 selenium.common.exceptions import (
ElementNotInteractableException,
NoSuchElementException,
StaleElementReferenceException,
TimeoutException,
)
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from pages.components.Dialog import Dialog
class SeatMapDialog(Dialog):
"""
Seat selection overlay that opens after choosing a floor and room.
"""
ROOT = (By.ID, "seatLayout")
SEAT_ITEMS = (By.CSS_SELECTOR, "li[id^='seat_']")
def __init__(
self,
driver: WebDriver,
) -> None:
super().__init__(driver, self.ROOT)
def selectSeat(
self,
seat_id: str,
) -> str | None:
try:
self._waitAllPresence(self.SEAT_ITEMS)
except (NoSuchElementException, TimeoutException):
return None
except Exception:
return None
try:
seat_el = self._find(By.ID, f"seat_{int(seat_id):03d}")
seat_link = seat_el.find_element(By.TAG_NAME, "a")
WebDriverWait(self._driver, 2).until(
EC.element_to_be_clickable(seat_link)
)
seat_link.click()
return seat_link.get_attribute("title")
except (NoSuchElementException, ValueError, TimeoutException,
ElementNotInteractableException, StaleElementReferenceException):
pass
except Exception:
pass
try:
all_seats = self._findAll(*self.SEAT_ITEMS)
seat_id_upper = seat_id.lstrip('0').upper()
for seat in all_seats:
if not seat_id_upper == seat.text.lstrip('0'):
continue
seat_link = seat.find_element(By.TAG_NAME, "a")
WebDriverWait(self._driver, 2).until(
EC.element_to_be_clickable(seat_link)
)
seat_link.click()
return seat_link.get_attribute("title")
return None
except (NoSuchElementException, TimeoutException,
ElementNotInteractableException, StaleElementReferenceException):
return None
except Exception:
return None
+232
View File
@@ -0,0 +1,232 @@
# -*- 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 __future__ import annotations
import logging
from typing import TYPE_CHECKING, Callable, Optional
from selenium.common.exceptions import (
NoSuchElementException,
TimeoutException,
)
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.remote.webelement import WebElement
from pages.components.Dialog import Dialog
from pages.strategies.TimeSelectMaker import (
TimeRangeResult,
TimeSelectionResult,
TimeSelectMaker,
minsToTimeStr,
timeStrToMins,
)
if TYPE_CHECKING:
from pages.flows.ReserveFlow import ReserveContext
class TimeSelectDialog(Dialog):
"""
Time selection panel that appears after selecting a seat.
Contains start-time and end-time option lists.
Does NOT auto-close the reserve submission handles cleanup.
"""
ROOT = (By.CSS_SELECTOR, "#startTime ul")
def __init__(
self,
driver: WebDriver,
tracer: Optional[Callable[[str, int], None]] = None,
) -> None:
super().__init__(driver, self.ROOT, auto_close_on_exit=False)
self._tracer = tracer
def _trace(
self,
msg: str,
level: int = logging.INFO,
) -> None:
if self._tracer is not None:
self._tracer(msg, level)
def _logTimeStep(
self,
time_type: str,
target_mins: int,
max_diff: int,
step_result: TimeSelectionResult,
) -> bool:
if step_result.selected_index >= 0:
abs_diff = abs(step_result.actual_diff)
if step_result.actual_diff < 0:
relation = f"早了 {abs_diff} 分钟"
elif step_result.actual_diff > 0:
relation = f"晚了 {abs_diff} 分钟"
else:
relation = f"正好等于 {time_type}"
self._trace(
f"选择距离期望 {time_type} 最近的 {step_result.display_text}, "
f"与期望 {time_type} 相比 {relation}"
)
return True
if not step_result.free_times:
self._trace(
f"{time_type} 选择失败 ! : 当前未查询到可用时间",
logging.ERROR,
)
else:
target_str = minsToTimeStr(target_mins)
self._trace(
f"无法选择最近的 {time_type} {target_str}, "
f"所有可选时间与目标时间相差都超过 {max_diff} 分钟",
logging.WARNING,
)
self._trace(f"当前可供预约的 {time_type} 有: {step_result.free_times}")
return False
def getTimeOptions(
self,
time_id: str,
) -> list[WebElement]:
try:
self._waitAllPresence(
(By.CSS_SELECTOR, f"#{time_id} ul li a")
)
except (NoSuchElementException, TimeoutException):
return []
except Exception:
return []
return self._findAll(
By.CSS_SELECTOR,
f"#{time_id} ul li a",
)
def selectNearestTime(
self,
time_id: str,
target_time: int,
max_time_diff: int,
prefer_earlier: bool,
) -> TimeSelectionResult:
all_time_opts = self.getTimeOptions(time_id)
if not all_time_opts:
return TimeSelectionResult()
result = TimeSelectMaker.forReserve().decide(
all_time_opts,
target_time,
max_time_diff,
prefer_earlier,
)
if result.selected_index >= 0:
all_time_opts[result.selected_index].click()
return result
def selectTimeRange(
self,
begin_target: int,
end_target: int,
begin_max_diff: int = 30,
end_max_diff: int = 30,
begin_prefer_early: bool = True,
end_prefer_early: bool = False,
satisfy_duration: bool = True,
expect_duration: int = 4,
library_close_mins: int = TimeSelectMaker.LIBRARY_CLOSE_MINS,
) -> TimeRangeResult:
begin_result = self.selectNearestTime(
"startTime",
begin_target,
begin_max_diff,
begin_prefer_early,
)
if begin_result.selected_index < 0:
return TimeRangeResult(begin_result=begin_result)
actual_begin = begin_result.selected_value
if satisfy_duration:
end_target = TimeSelectMaker.calcEndTime(
actual_begin,
expect_duration,
library_close_mins,
)
end_result = self.selectNearestTime(
"endTime",
end_target,
end_max_diff,
end_prefer_early,
)
if end_result.selected_index < 0:
return TimeRangeResult(
begin_result=begin_result,
actual_begin_mins=actual_begin,
end_result=end_result,
expect_end_mins=end_target,
)
return TimeRangeResult(
begin_result=begin_result,
end_result=end_result,
actual_begin_mins=actual_begin,
actual_end_mins=end_result.selected_value,
expect_end_mins=end_target,
)
def selectSeatTime(
self,
ctx: ReserveContext,
library_close_mins: int = TimeSelectMaker.LIBRARY_CLOSE_MINS,
) -> bool:
exp_beg_mins = timeStrToMins(ctx.begin_time)
exp_end_mins = timeStrToMins(ctx.end_time)
result = self.selectTimeRange(
begin_target=exp_beg_mins,
end_target=exp_end_mins,
begin_max_diff=ctx.begin_max_diff,
end_max_diff=ctx.end_max_diff,
begin_prefer_early=ctx.begin_prefer_early,
end_prefer_early=ctx.end_prefer_early,
satisfy_duration=ctx.satisfy_duration,
expect_duration=ctx.expect_duration,
library_close_mins=library_close_mins,
)
if not self._logTimeStep("开始时间", exp_beg_mins, ctx.begin_max_diff, result.begin_result):
return False
if ctx.satisfy_duration:
unclipped = result.actual_begin_mins + ctx.expect_duration*60
if unclipped > library_close_mins:
self._trace(
f"预约持续时间 {ctx.expect_duration} 小时, 超过最大预约时间 {minsToTimeStr(library_close_mins)}, "
f"自动调整为 {minsToTimeStr(library_close_mins)}",
logging.WARNING,
)
act_beg_str = minsToTimeStr(result.actual_begin_mins)
exp_end_str = minsToTimeStr(result.expect_end_mins)
self._trace(
f"需要满足期望预约持续时间: {ctx.expect_duration} 小时, "
f"根据开始时间 {act_beg_str} 计算结束时间: {exp_end_str}"
)
if not self._logTimeStep("结束时间", result.expect_end_mins, ctx.end_max_diff, result.end_result):
return False
act_beg_str = minsToTimeStr(result.actual_begin_mins)
act_end_str = minsToTimeStr(result.actual_end_mins)
exp_end_str = minsToTimeStr(result.expect_end_mins)
self._trace(
f"期望预约时间段: {ctx.begin_time} - {exp_end_str}, "
f"实际预约时间段: {act_beg_str} - {act_end_str}"
)
return True
+22
View File
@@ -0,0 +1,22 @@
# -*- 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.components.SeatMapDialog import SeatMapDialog
from pages.components.TimeSelectDialog import TimeSelectDialog
from pages.components.ReserveResultDialog import ReserveResultDialog
from pages.components.CheckinResultDialog import CheckinResultDialog
from pages.components.RenewDialog import RenewDialog
__all__ = [
"SeatMapDialog",
"TimeSelectDialog",
"ReserveResultDialog",
"CheckinResultDialog",
"RenewDialog",
]
+91
View File
@@ -0,0 +1,91 @@
# -*- 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.components.CheckinResultDialog 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
+142
View File
@@ -0,0 +1,142 @@
# -*- 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.components.RenewDialog import RenewDialog
from pages.flows._helpers import timeStrToMins, minsToTimeStr
from pages.strategies.TimeSelectMaker import TimeSelectMaker
class RenewFlow(MsgBase):
LIBRARY_CLOSE_MINS = TimeSelectMaker.LIBRARY_CLOSE_MINS
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.get("max_diff", 30)
prefer_earlier = renew_info.get("prefer_early", True)
end_time = record["time"]["end"]
target_renew_mins = timeStrToMins(end_time) + renew_info.get("expect_duration", 2) * 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
result = dialog.selectBestTime(
target_renew_mins,
max_diff,
prefer_earlier,
)
if result.selected_index >= 0:
abs_diff = abs(result.actual_diff)
if result.actual_diff < 0:
relation = f"早了 {abs_diff} 分钟"
elif result.actual_diff > 0:
relation = f"晚了 {abs_diff} 分钟"
else:
relation = "正好等于 续约时间"
self._showTrace(
f"选择距离期望续约时间最近的 {result.display_text}, "
f"与期望续约时间相比 {relation}"
)
record["time"]["end"] = result.display_text.strip()
dialog.clickOk()
self._shell.refresh()
return True
if not result.free_times:
self._showTrace("当前未查询到可用续约时间 !", self.TraceLevel.WARNING)
else:
self._showTrace(
"无法选择最近的可用续约时间 ! "
f"所有可选时间与目标时间相差都超过了 {max_diff} 分钟 !",
self.TraceLevel.WARNING,
)
self._showTrace(f"当前可供续约的时间有: {result.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
+151
View File
@@ -0,0 +1,151 @@
# -*- 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 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.strategies.TimeSelectMaker import TimeSelectMaker
from pages.ReserveView import ReserveView
from pages.components.ReserveResultDialog import ReserveResultDialog
from pages.components.TimeSelectDialog import TimeSelectDialog
@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 = TimeSelectMaker.LIBRARY_CLOSE_MINS
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,
ctx: ReserveContext,
) -> bool:
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)} 选择成功 !")
seat_map = view.selectRoom(ctx.room)
if seat_map is None:
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 = 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
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
+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",
]
+19
View File
@@ -0,0 +1,19 @@
# -*- 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.strategies.TimeSelectMaker import (
minsToTimeStr,
timeStrToMins
)
__all__ = [
"minsToTimeStr",
"timeStrToMins",
]
+101
View File
@@ -0,0 +1,101 @@
# -*- 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 base64
import queue
import ddddocr
from selenium.common.exceptions import (
NoSuchElementException,
TimeoutException,
)
from base.MsgBase import MsgBase
from pages.LoginPage import LoginPage
class CaptchaSolver(MsgBase):
def __init__(
self,
input_queue: queue.Queue,
output_queue: queue.Queue,
) -> None:
super().__init__(input_queue, output_queue)
self._ocr = ddddocr.DdddOcr()
def _autoRecognize(
self,
login_page: LoginPage,
) -> str:
try:
img_src = login_page.getCaptchaImageSrc()
base64_str = img_src.split(',', 1)[1]
captcha_img = base64.b64decode(base64_str)
captcha_text = self._ocr.classification(captcha_img)
captcha_text = ''.join(filter(str.isalnum, captcha_text)).lower()
self._showTrace(f"识别到验证码为 : '{captcha_text}'", 20, no_log=True)
if len(captcha_text) != 4:
self._showLog("识别到的验证码长度不等于 4 个字符 !", self.TraceLevel.WARNING)
raise Exception("识别到的验证码长度不等于 4 个字符 !")
return captcha_text
except (NoSuchElementException, TimeoutException) as e:
self._showTrace(f"验证码识别失败 ! : {e}", self.TraceLevel.ERROR)
return ""
except (ValueError, OSError) as e:
self._showTrace(f"验证码识别失败 ! : {e}", self.TraceLevel.ERROR)
return ""
except Exception as e:
self._showTrace(f"验证码识别失败 ! : {e}", self.TraceLevel.ERROR)
return ""
def _manualRecognize(
self,
) -> str:
try:
self._showMsg("请输入验证码:")
captcha_text = self._waitMsg(timeout=15)
self._showTrace(f"输入的验证码为 : '{captcha_text}'", 20, no_log=True)
if len(captcha_text) != 4:
self._showLog("输入的验证码长度不等于 4 个字符 !", self.TraceLevel.WARNING)
raise Exception("输入的验证码长度不等于 4 个字符 !")
return captcha_text
except ValueError as e:
self._showTrace(f"输入验证码失败 ! : {e}", self.TraceLevel.ERROR)
return ""
except Exception as e:
self._showTrace(f"输入验证码失败 ! : {e}", self.TraceLevel.ERROR)
return ""
def solveCaptcha(
self,
login_page: LoginPage,
auto_captcha: bool = True,
) -> str:
max_attempts = 3
for _ in range(max_attempts):
if auto_captcha:
captcha_text = self._autoRecognize(login_page)
else:
self._showTrace("用户未配置自动识别验证码, 请手动输入验证码 !", 20, no_log=True)
captcha_text = self._manualRecognize()
if captcha_text:
return captcha_text
else:
if not login_page.refreshCaptcha():
return ""
self._showTrace(
f"验证码识别失败 {max_attempts} 次, 达到最大尝试次数 !",
self.TraceLevel.WARNING,
)
return ""
+305
View File
@@ -0,0 +1,305 @@
# -*- 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
import re
import time
from datetime import datetime, timedelta
from selenium.common.exceptions import (
NoSuchElementException,
StaleElementReferenceException,
TimeoutException,
)
from base.MsgBase import MsgBase
from pages.MainShell import MainShell
from pages.RecordsView import RecordsView
class RecordChecker(MsgBase):
def __init__(
self,
input_queue: queue.Queue,
output_queue: queue.Queue,
) -> None:
super().__init__(input_queue, output_queue)
@staticmethod
def _formatDiffTime(
seconds: float,
) -> str:
hours = int(seconds//3600)
minutes = int(seconds%3600//60)
seconds = int(seconds%60)
return f"{hours}{minutes}{seconds}"
def _decodeReserveTime(
self,
time_element,
) -> dict:
time_str = time_element.text.strip()
today = datetime.now().date()
if "明天" in time_str:
target_date = today + timedelta(days=1)
date = target_date.strftime("%Y-%m-%d")
elif "今天" in time_str:
target_date = today
date = target_date.strftime("%Y-%m-%d")
elif "昨天" in time_str:
target_date = today - timedelta(days=1)
date = target_date.strftime("%Y-%m-%d")
else:
date_match = re.search(r"(\d{4}-\d{1,2}-\d{1,2})", time_str)
if date_match:
date = date_match.group(1)
else:
date = ""
time_match = re.search(
r"(\d{1,2}:\d{2}) -- (\d{1,2}:\d{2})", time_str
)
if time_match:
begin_time = time_match.group(1)
end_time = time_match.group(2)
else:
begin_time = ""
end_time = ""
return {
"date": date,
"time": {"begin": begin_time, "end": end_time},
}
def _decodeReserveInfo(
self,
info_elements,
) -> dict:
location = ""
status = ""
for info in info_elements:
if "已预约" in info.text:
status = "已预约"
elif "使用中" in info.text:
status = "使用中"
elif "已完成" in info.text:
status = "已完成"
elif "已结束使用" in info.text:
status = "已结束使用"
elif "已取消" in info.text:
status = "已取消"
elif "失约" in info.text:
status = "失约"
elif "图书馆" in info.text:
location = info.text.strip()
return {"location": location, "status": status}
def _decodeReserveRecord(
self,
reservation,
records_view: RecordsView,
) -> dict:
try:
time_element = records_view.getRecordTimeElement(reservation)
info_elements = records_view.getRecordInfoElements(reservation)
except (NoSuchElementException, TimeoutException, StaleElementReferenceException):
return {
"date": "",
"time": {"begin": "", "end": ""},
"info": {"location": "", "status": ""},
}
except Exception:
return {
"date": "",
"time": {"begin": "", "end": ""},
"info": {"location": "", "status": ""},
}
time_data = self._decodeReserveTime(time_element)
info_data = self._decodeReserveInfo(info_elements)
return {
"date": time_data["date"],
"time": time_data["time"],
"info": info_data,
}
def _getReserveRecord(
self,
shell: MainShell,
wanted_date: str,
wanted_status: str,
) -> dict | None:
if wanted_date is None:
self._showTrace("日期未指定, 无法检查当前预约状态", self.TraceLevel.WARNING)
return None
self._showTrace(
f"正在检查用户在 {wanted_date} 是否有预约状态为 "
f"{wanted_status} 的预约记录......", 20, no_log=True
)
checked_count = 0
max_check_times = 6
records_view = shell.gotoRecordsView()
for _ in range(max_check_times):
reservations = records_view.loadRecords()
if reservations is None:
return None
for reservation in reservations[checked_count:]:
record = self._decodeReserveRecord(reservation, records_view)
checked_count += 1
if record is None:
continue
if record["date"] == "":
continue
if record["time"] == {"begin": "", "end": ""}:
continue
if (
datetime.strptime(record["date"], "%Y-%m-%d").date()
> datetime.strptime(wanted_date, "%Y-%m-%d").date()
):
continue
if (
datetime.strptime(record["date"], "%Y-%m-%d").date()
< datetime.strptime(wanted_date, "%Y-%m-%d").date()
):
return None
if record["info"]["status"] == wanted_status:
self._showTrace(
f"寻找到用户第 {checked_count} 条状态为 "
f"{wanted_status} 的预约记录, "
f"详细信息: {record["date"]} "
f"{record["time"]["begin"]} - "
f"{record["time"]["end"]} "
f"{record["info"]["location"]}",
20, no_log=True,
)
return record
if not records_view.showMoreRecords():
break
return None
def canReserve(
self,
shell: MainShell,
date: str,
) -> bool:
if self._getReserveRecord(shell, date, "已预约") is None:
if self._getReserveRecord(shell, date, "使用中") is None:
self._showTrace(f"用户在 {date} 可以预约")
return True
self._showTrace(f"用户在 {date} 有使用中的预约, 无法预约")
return False
self._showTrace(f"用户在 {date} 已存在有效预约, 无法预约")
return False
def canCheckin(
self,
shell: MainShell,
) -> bool:
date = time.strftime("%Y-%m-%d", time.localtime())
record = self._getReserveRecord(shell, date, "已预约")
if record is not None:
begin_time = record["time"]["begin"]
begin_time = datetime.strptime(
f"{date} {begin_time}", "%Y-%m-%d %H:%M"
)
time_diff = datetime.now() - begin_time
time_diff_seconds = time_diff.total_seconds()
if time_diff_seconds < -30 * 60:
self._showTrace(
f"用户在 {date} 的预约开始时间为 {begin_time}, "
f"当前距离预约开始时间还有 "
f"{self._formatDiffTime(abs(time_diff_seconds))}, 无法签到"
)
return False
elif -30 * 60 <= time_diff_seconds < 0:
self._showTrace(
f"用户在 {date} 的预约开始时间为 {begin_time}, "
f"当前距离预约开始时间还有 "
f"{self._formatDiffTime(abs(time_diff_seconds))}, 可以签到"
)
return True
elif 0 <= time_diff_seconds < 30*60 - 5:
self._showTrace(
f"用户在 {date} 的预约开始时间为 {begin_time}, "
f"当前距离预约开始时间已经过去 "
f"{self._formatDiffTime(abs(time_diff_seconds))}, 可以签到"
)
return True
self._showTrace(f"用户在 {date} 没有有效预约记录, 无法签到")
return False
def canRenew(
self,
shell: MainShell,
) -> tuple[bool, dict]:
date = time.strftime("%Y-%m-%d", time.localtime())
record = self._getReserveRecord(shell, date, "使用中")
if record is not None:
end_time = record["time"]["end"]
end_time = datetime.strptime(
f"{date} {end_time}", "%Y-%m-%d %H:%M"
)
time_diff = end_time - datetime.now()
time_diff_seconds = time_diff.total_seconds()
trace_msg = (
f"用户在 {date} 的预约结束时间为 {end_time}, "
f"当前距离预约结束时间还有 "
f"{self._formatDiffTime(abs(time_diff_seconds))}"
)
if abs(time_diff_seconds) < 120 * 60:
self._showTrace(f"{trace_msg}, 可以续约")
return True, record
else:
self._showTrace(f"{trace_msg}, 无法续约")
return False, None
self._showTrace(f"用户在 {date} 没有有效预约记录, 无法续约")
return False, None
def postRenewCheck(
self,
shell: MainShell,
record: dict,
) -> bool:
date = record["date"]
act_record = self._getReserveRecord(shell, date, "使用中")
if act_record is not None:
if (
act_record["time"]["begin"] == record["time"]["begin"]
and act_record["time"]["end"] == record["time"]["end"]
):
self._showTrace(
f"\n"
f" 续约成功 !\n"
f" 日 期 {date}\n"
f" 时 间 {act_record["time"]["begin"]}"
f" - {act_record["time"]["end"]}\n"
f" 位 置 {act_record["info"]["location"]}\n"
f" 状 态 {act_record["info"]["status"]}"
)
return True
else:
self._showTrace(
f"\n"
f" 续约失败 !\n"
f" 续约后结束时间为 {act_record["time"]["end"]},"
f"与预期结束时间 {record["time"]["end"]} 不符 !"
)
return False
self._showTrace(f"用户在 {date} 没有有效预约记录, 无法检查续约结果")
return False
+221
View File
@@ -0,0 +1,221 @@
# -*- 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
import time
from base.MsgBase import MsgBase
from pages.ReserveView import ReserveView
from pages.flows._helpers import timeStrToMins, minsToTimeStr
from pages.strategies.TimeSelectMaker import TimeSelectMaker
class ReserveChecker(MsgBase):
def __init__(
self,
input_queue: queue.Queue,
output_queue: queue.Queue,
) -> None:
super().__init__(input_queue, output_queue)
def _containRequiredInfo(
self,
reserve_info: dict,
) -> bool:
floor_map = ReserveView.FLOOR_MAP
room_map = ReserveView.ROOM_MAP
try:
if reserve_info.get("floor") is None:
raise ValueError("未指定楼层")
if reserve_info["floor"] not in floor_map:
raise ValueError(f"该楼层 '{reserve_info["floor"]}' 不存在")
if reserve_info.get("room") is None:
raise ValueError("未指定房间")
if reserve_info["room"] not in room_map:
raise ValueError(f"该房间 '{reserve_info["room"]}' 不存在")
if reserve_info.get("seat_id") is None:
raise ValueError("未指定座位")
if reserve_info["seat_id"] == "":
raise ValueError("未指定座位号")
return True
except ValueError as e:
msg = (
f"预约信息错误 ! : {e}, "
f"由于缺少必要的预约信息, 无法开始预约流程"
)
self._showTrace(msg, self.TraceLevel.ERROR)
self._showTrace(
f"预约信息错误 ! : {e}, "
f"由于缺少必要的预约信息, 无法开始预约流程, 请检查预约信息是否完整",
20,
no_log=True,
)
return False
def _isValidDate(
self,
reserve_info: dict,
) -> bool:
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_str
self._showTrace(f"预约日期未指定, 自动设置为当前日期: {cur_date_str}")
else:
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_str}, 自动设置为当前日期",
self.TraceLevel.WARNING,
)
reserve_info["date"] = cur_date_str
return True
def _isValidBeginTime(
self,
reserve_info: dict,
) -> bool:
cur_time = time.strftime("%H:%M", time.localtime())
if reserve_info.get("begin_time") is None:
reserve_info["begin_time"] = {}
if "time" not in reserve_info["begin_time"]:
reserve_info["begin_time"]["time"] = cur_time
self._showTrace(f"开始时间未指定, 自动设置为当前时间: {cur_time}")
if "max_diff" not in reserve_info["begin_time"]:
reserve_info["begin_time"]["max_diff"] = 30
self._showTrace("开始时间最大时间差未指定, 自动设置为 30 分钟")
if "prefer_early" not in reserve_info["begin_time"]:
reserve_info["begin_time"]["prefer_early"] = True
self._showTrace("是否优先选择更早开始时间未指定, 自动设置为 True")
return True
def _isValidExpectDuration(
self,
reserve_info: dict,
) -> bool:
if reserve_info.get("satisfy_duration") is None:
reserve_info["satisfy_duration"] = True
self._showTrace("预约满足时长要求未指定, 默认满足")
if reserve_info["satisfy_duration"]:
if reserve_info.get("expect_duration") is None:
reserve_info["expect_duration"] = 4
self._showTrace("需要满足预约持续时间, 但未指定, 使用默认时长为 4 小时")
return True
def _isValidEndTime(
self,
reserve_info: dict,
) -> bool:
if reserve_info.get("end_time") is None:
reserve_info["end_time"] = {}
if "time" not in reserve_info["end_time"]:
end_mins = timeStrToMins(reserve_info["begin_time"]["time"])
end_mins = end_mins + int(reserve_info["expect_duration"] * 60)
reserve_info["end_time"] = {
"time": minsToTimeStr(end_mins),
"max_diff": 30,
"prefer_early": False,
}
self._showTrace(
f"结束时间未指定, 自动设置为开始时间加上期望时长: "
f"{reserve_info["end_time"]["time"]}"
)
if "max_diff" not in reserve_info["end_time"]:
reserve_info["end_time"]["max_diff"] = 30
self._showTrace("结束时间最大时间差未指定, 自动设置为 30 分钟")
if "prefer_early" not in reserve_info["end_time"]:
reserve_info["end_time"]["prefer_early"] = False
self._showTrace("是否优先选择较晚结束时间未指定, 自动设置为 True")
return True
def _finalCheck(
self,
reserve_info: dict,
) -> bool:
begin_time = reserve_info["begin_time"]
end_time = reserve_info["end_time"]
begin_mins = timeStrToMins(begin_time["time"])
end_mins = timeStrToMins(end_time["time"])
if end_mins < begin_mins and reserve_info["satisfy_duration"] is False:
self._showTrace(
f"结束时间 {end_time["time"]} 早于开始时间 {begin_time["time"]}, "
f"尝试交换时间",
self.TraceLevel.WARNING,
)
reserve_info["end_time"], reserve_info["begin_time"] = begin_time, end_time
begin_time, end_time = end_time, begin_time
begin_mins = timeStrToMins(begin_time["time"])
end_mins = timeStrToMins(end_time["time"])
max_end_mins = TimeSelectMaker.LIBRARY_CLOSE_MINS
if end_mins > max_end_mins:
close_time_str = minsToTimeStr(TimeSelectMaker.LIBRARY_CLOSE_MINS)
self._showTrace(
f"结束时间 {end_time["time"]} 晚于 {close_time_str}, "
f"自动设置为 {close_time_str}",
self.TraceLevel.WARNING,
)
reserve_info["end_time"]["time"] = close_time_str
end_mins = max_end_mins
if reserve_info["satisfy_duration"]:
if reserve_info["expect_duration"] > 8:
self._showTrace(
f"该用户设置了优先满足时长要求, 但是预约期望持续时间 "
f"{reserve_info["expect_duration"]} 小时 "
f"超出最大时长 8 小时, 自动设置为 8 小时",
self.TraceLevel.WARNING,
)
reserve_info["expect_duration"] = 8
else:
if end_mins - begin_mins > 8*60:
self._showTrace(
f"该用户未设置优先满足时长要求, 但是检查到预约持续时间 "
f"{float((end_mins - begin_mins) / 60)} 小时 "
f"超出最大时长 8 小时, 自动设置为 8 小时",
self.TraceLevel.WARNING,
)
reserve_info["end_time"]["time"] = minsToTimeStr(begin_mins + 8*60)
return True
def check(
self,
reserve_info: dict,
) -> bool:
if not self._containRequiredInfo(reserve_info):
return False
if not self._isValidDate(reserve_info):
return False
if not self._isValidBeginTime(reserve_info):
return False
if not self._isValidExpectDuration(reserve_info):
return False
if not self._isValidEndTime(reserve_info):
return False
if not self._finalCheck(reserve_info):
return False
self._showTrace(
f"预约信息检查完成, 准备预约 "
f"{reserve_info["date"]} "
f"{reserve_info["begin_time"]["time"]} - "
f"{reserve_info["end_time"]["time"]} "
f"图书馆 "
f"{ReserveView.FLOOR_MAP[reserve_info["floor"]]} "
f"{ReserveView.ROOM_MAP[reserve_info["room"]]} "
f"的座位 {reserve_info["seat_id"]}"
)
return True
+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.services.CaptchaSolver import CaptchaSolver
from pages.services.ReserveChecker import ReserveChecker
from pages.services.RecordChecker import RecordChecker
__all__ = [
"CaptchaSolver",
"ReserveChecker",
"RecordChecker",
]
+30
View File
@@ -0,0 +1,30 @@
# -*- 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.strategies.TimeSelectMaker import (
TimeSelectMaker,
TimeDecisionMaker,
TimeOptionReader,
ReserveTimeReader,
RenewTimeReader,
TimeOption,
TimeSelectionResult,
TimeRangeResult,
)
__all__ = [
"TimeSelectMaker",
"TimeDecisionMaker",
"TimeOptionReader",
"ReserveTimeReader",
"RenewTimeReader",
"TimeOption",
"TimeSelectionResult",
"TimeRangeResult",
]
+207
View File
@@ -0,0 +1,207 @@
# -*- 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 abc import ABC, abstractmethod
from dataclasses import dataclass, field
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}"
@dataclass
class TimeOption:
value: int
element_text: str
@dataclass
class TimeSelectionResult:
selected_index: int = -1
selected_value: int = 0
display_text: str = ""
actual_diff: int = 0
free_times: list[str] = field(default_factory=list)
@dataclass
class TimeRangeResult:
begin_result: TimeSelectionResult = field(default_factory=TimeSelectionResult)
end_result: TimeSelectionResult = field(default_factory=TimeSelectionResult)
actual_begin_mins: int = -1
actual_end_mins: int = -1
expect_end_mins: int = 0
class TimeOptionReader(ABC):
@abstractmethod
def readOptions(
self,
elements: list
) -> list[TimeOption]:
...
def formatFreeTime(
self,
opt: TimeOption
) -> str:
return opt.element_text
class ReserveTimeReader(TimeOptionReader):
"""
Reads the ``time`` HTML attribute for the reserve flow.
Special value ``"now"`` is resolved to the current wall-clock minute.
"""
def readOptions(
self,
elements: list
) -> list[TimeOption]:
options: list[TimeOption] = []
for el in elements:
time_attr = el.get_attribute("time")
if time_attr == "now":
now = datetime.now()
value = now.hour * 60 + now.minute
elif time_attr and time_attr.isdigit():
value = int(time_attr)
else:
continue
options.append(TimeOption(value=value, element_text=el.text.strip()))
return options
def formatFreeTime(
self,
opt: TimeOption
) -> str:
return minsToTimeStr(opt.value)
class RenewTimeReader(TimeOptionReader):
"""
Reads the ``id`` HTML attribute for the renewal flow.
"""
def readOptions(
self,
elements: list
) -> list[TimeOption]:
options: list[TimeOption] = []
for el in elements:
time_attr = el.get_attribute("id")
if not (time_attr and time_attr.isdigit()):
continue
options.append(TimeOption(value=int(time_attr), element_text=el.text.strip()))
return options
class TimeDecisionMaker:
def __init__(
self,
reader: TimeOptionReader
) -> None:
self._reader = reader
def decide(
self,
elements: list,
target_time: int,
max_time_diff: int,
prefer_earlier: bool
) -> TimeSelectionResult:
options = self._reader.readOptions(elements)
free_times = [self._reader.formatFreeTime(o) for o in options]
best_diff = max_time_diff
best_actual_diff = None
best_index = -1
for i, opt in enumerate(options):
actual_diff = opt.value - target_time
abs_diff = abs(actual_diff)
if abs_diff < best_diff or (
abs_diff == best_diff
and (
(prefer_earlier and actual_diff <= 0)
or (not prefer_earlier and actual_diff >= 0)
)
):
best_diff = abs_diff
best_actual_diff = actual_diff
best_index = i
if best_index == -1:
return TimeSelectionResult(free_times=free_times)
chosen = options[best_index]
return TimeSelectionResult(
selected_index=best_index,
selected_value=chosen.value,
display_text=chosen.element_text,
actual_diff=best_actual_diff or 0,
free_times=free_times,
)
class TimeSelectMaker:
LIBRARY_CLOSE_MINS = 1350 # 22:30
MAX_DURATION_HOURS = 8
@staticmethod
def calcEndTime(
begin_mins: int,
duration: int,
library_close_mins: int = LIBRARY_CLOSE_MINS
) -> int:
expect_end_mins = int(begin_mins + duration*60)
if expect_end_mins > library_close_mins:
return library_close_mins
return expect_end_mins
@staticmethod
def calcRemainingDuration(
end_time_str: str,
target_mins: int,
library_close_mins: int = LIBRARY_CLOSE_MINS
) -> int:
return library_close_mins - timeStrToMins(end_time_str)
@staticmethod
def forReserve(
) -> TimeDecisionMaker:
return TimeDecisionMaker(ReserveTimeReader())
@staticmethod
def forRenew(
) -> TimeDecisionMaker:
return TimeDecisionMaker(RenewTimeReader())