diff --git a/src/base/LibTimeSelector.py b/src/base/LibTimeSelector.py new file mode 100644 index 0000000..27234c5 --- /dev/null +++ b/src/base/LibTimeSelector.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- +""" +Copyright (c) 2025 - 2026 KenanZhu. +All rights reserved. + +This software is provided "as is", without any warranty of any kind. +You may use, modify, and distribute this file under the terms of the MIT License. +See the LICENSE file for details. +""" +import queue + +from base.LibOperator import LibOperator + + +class LibTimeSelector(LibOperator): + """ + Base class for time selection operations. + + This class provides common time selection logic for reservation and renewal + operations, including time conversion utilities and best time option finding. + """ + + def __init__( + self, + input_queue: queue.Queue, + output_queue: queue.Queue + ): + + super().__init__(input_queue, output_queue) + + @staticmethod + def _timeToMins( + time_str: str + ) -> int: + + """ + Convert time string "HH:MM" to minutes since midnight. + """ + hour, minute = map(int, time_str.split(":")) + return hour*60 + minute + + @staticmethod + def _minsToTime( + mins: int + ) -> str: + + """ + Convert minutes since midnight to time string "HH:MM". + """ + hour, minute = divmod(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: + time_attr = time_opt.get_attribute("time") + if time_attr == "now": + from datetime import datetime + 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._minsToTime(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) diff --git a/src/operators/LibRenew.py b/src/operators/LibRenew.py index b6c5ef4..cea784c 100644 --- a/src/operators/LibRenew.py +++ b/src/operators/LibRenew.py @@ -14,10 +14,10 @@ from selenium.webdriver.chrome.webdriver import WebDriver from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC -from base.LibOperator import LibOperator +from base.LibTimeSelector import LibTimeSelector -class LibRenew(LibOperator): +class LibRenew(LibTimeSelector): def __init__( self, @@ -38,22 +38,6 @@ class LibRenew(LibOperator): self.__driver.refresh() return True - @staticmethod - def __timeToMins( - time_str: str - ) -> int: - - hour, minute = map(int, time_str.split(":")) - return hour*60 + minute - - @staticmethod - def __minsToTime( - mins: int - ) -> str: - - hour, minute = divmod(mins, 60) - return f"{hour:02d}:{minute:02d}" - def __waitRenewDialog( self @@ -94,85 +78,92 @@ class LibRenew(LibOperator): return True - def __selectNearstTime( + def __selectNearestTime( self, record: dict, reserve_info: dict ) -> bool: """ - TODO : this function is too long and too ugly - - we need to refactor it to make it more readable. - but may be it is not a good idea to refactor it. :) who knows... + Select the nearest available renewal time. """ - end_time = record["time"]["end"] renew_info = reserve_info["renew_time"] max_diff = renew_info["max_diff"] prefer_earlier = renew_info["prefer_early"] - target_renew_mins = self.__timeToMins(end_time) + renew_info["expect_duration"]*60 - renew_ok_btn = self.__driver.find_element( - By.CSS_SELECTOR, "#extendDiv .btnOK" - ) - try: - renew_time_opts = self.__driver.find_elements( - By.CSS_SELECTOR, "#extendDiv .renewal_List li" - ) - free_times = [] - best_time_diff = max_diff - best_actual_diff = None - best_time_opt = None + target_renew_mins = self._timeToMins(end_time) + renew_info["expect_duration"]*60 - if not renew_time_opts: - self._showTrace("当前未查询到可用续约时间 !") - return False - for time_opt in renew_time_opts: - time_attr = time_opt.get_attribute("id") - if time_attr and time_attr.isdigit(): - time_val = int(time_attr) - free_times.append(time_opt.text.strip()) - else: - continue - actual_diff = time_val - target_renew_mins - abs_diff = abs(actual_diff) - if abs_diff < best_time_diff or ( - abs_diff == best_time_diff and ( - (prefer_earlier and actual_diff <= 0) or - (not prefer_earlier and actual_diff >= 0) - ) - ): - best_time_diff = abs_diff - best_actual_diff = actual_diff - best_time_opt = time_opt - - if best_time_opt is not None: - best_time_opt.click() - abs_time_diff = abs(best_actual_diff) - if best_actual_diff < 0: - time_relation = f"早了 {abs_time_diff} 分钟" - elif best_actual_diff > 0: - time_relation = f"晚了 {abs_time_diff} 分钟" - else: - time_relation = f"正好等于续约时间" - self._showTrace( - f"选择距离期望续约时间最近的 {best_time_opt.text}, "\ - f"与期望续约时间相比 {time_relation}" - ) - # update the actual renew end time - record["time"]["end"] = best_time_opt.text.strip() - renew_ok_btn.click() - return True - self._showTrace( - "无法选择最近的可用续约时间 !" \ - f"所有可选时间与目标时间相差都超过了 {max_diff} 分钟 !" - ) - self._showTrace( - f"当前可供续约的时间有: {free_times}" - ) + # Validate and adjust target renew time to library closing time + if not self.__validateAndAdjustRenewTime(end_time, target_renew_mins): return False + renew_ok_btn = self.__driver.find_element(By.CSS_SELECTOR, "#extendDiv .btnOK") + renew_time_opts = self.__driver.find_elements(By.CSS_SELECTOR, "#extendDiv .renewal_List li") + if not renew_time_opts: + self._showTrace("当前未查询到可用续约时间 !") + 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._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._timeToMins(end_time) + if actual_renew_duration <= 0: + self._showTrace(f"当前结束时间 {end_time} 已接近闭馆时间,无法续约 !") + return False + self._showTrace( + f"续约时间已调整至闭馆时间 {self._minsToTime(LIBRARY_CLOSE_TIME)}," + f"实际续约时长为 {actual_renew_duration//60} 小时 {actual_renew_duration%60} 分钟" + ) + return True + return True + + + def __confirmRenewal( + self, + best_opt, + best_text: str, + actual_diff: int, + record: dict, + ok_btn + ) -> bool: + + """ + Confirm the selected renewal time. + """ + try: + best_opt.click() + abs_diff = abs(actual_diff) + time_relation = self._formatTimeRelation(abs_diff, actual_diff, "续约时间") + self._showTrace( + f"选择距离期望续约时间最近的 {best_text}, " + f"与期望续约时间相比 {time_relation}" + ) + record["time"]["end"] = best_text.strip() + ok_btn.click() + return True except: - self._showTrace("查询可用续约时间时发生未知错误 !") + self._showTrace("确认续约时发生错误 !") return False @@ -204,7 +195,7 @@ class LibRenew(LibOperator): # so we need to refresh the page for subsequent operations. self.__driver.refresh() return False - if not self.__selectNearstTime(record, reserve_info): + if not self.__selectNearestTime(record, reserve_info): self._showTrace(f"用户 {username} 续约失败 !") self.__driver.refresh() return False diff --git a/src/operators/LibReserve.py b/src/operators/LibReserve.py index a58335b..224eeee 100644 --- a/src/operators/LibReserve.py +++ b/src/operators/LibReserve.py @@ -10,16 +10,15 @@ See the LICENSE file for details. import time import queue -from datetime import datetime from selenium.webdriver.common.by import By from selenium.webdriver.chrome.webdriver import WebDriver from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC -from base.LibOperator import LibOperator +from base.LibTimeSelector import LibTimeSelector -class LibReserve(LibOperator): +class LibReserve(LibTimeSelector): def __init__( self, @@ -100,22 +99,6 @@ class LibReserve(LibOperator): self._showTrace(f"预约结果加载失败 !") return False - @staticmethod - def __timeToMins( - time_str: str - ) -> int: - - hour, minute = map(int, time_str.split(":")) - return hour*60 + minute - - @staticmethod - def __minsToTime( - mins: int - ) -> str: - - hour, minute = divmod(mins, 60) - return f"{hour:02d}:{minute:02d}" - def __containRequiredInfo( self, @@ -207,10 +190,10 @@ class LibReserve(LibOperator): if reserve_info.get("end_time") is None: reserve_info["end_time"] = {} if "time" not in reserve_info["end_time"]: - end_mins = self.__timeToMins(reserve_info["begin_time"]["time"]) + end_mins = self._timeToMins(reserve_info["begin_time"]["time"]) end_mins = end_mins + int(reserve_info["expect_duration"]*60) reserve_info["end_time"] = { - "time": self.__minsToTime(end_mins), + "time": self._minsToTime(end_mins), "max_diff": 30, "prefer_early": False } @@ -232,8 +215,8 @@ class LibReserve(LibOperator): ): begin_time, end_time = reserve_info["begin_time"], reserve_info["end_time"] - begin_mins = self.__timeToMins(begin_time["time"]) - end_mins = self.__timeToMins(end_time["time"]) + begin_mins = self._timeToMins(begin_time["time"]) + end_mins = self._timeToMins(end_time["time"]) # if end time is earlier than begin_time, exchange them if end_mins < begin_mins: self._showTrace( @@ -242,15 +225,15 @@ class LibReserve(LibOperator): reserve_info["end_time"] = begin_time reserve_info["begin_time"] = end_time begin_time, end_time = reserve_info["begin_time"], reserve_info["end_time"] - begin_mins = self.__timeToMins(begin_time["time"]) - end_mins = self.__timeToMins(end_time["time"]) + begin_mins = self._timeToMins(begin_time["time"]) + end_mins = self._timeToMins(end_time["time"]) # ensure the end time is not later than 23:30 - if end_mins > self.__timeToMins("23:30"): + if end_mins > self._timeToMins("23:30"): self._showTrace( f"结束时间 {end_time['time']} 晚于 23:30, 自动设置为 23:30" ) reserve_info["end_time"]["time"] = "23:30" - end_mins = self.__timeToMins("23:30") + end_mins = self._timeToMins("23:30") # ensure the duration is not longer than 8 hours if reserve_info["satisfy_duration"]: if reserve_info["expect_duration"] > 8: @@ -267,7 +250,7 @@ class LibReserve(LibOperator): f"{float((end_mins - begin_mins)/60)} 小时 " f"超出最大时长 8 小时, 自动设置为 8 小时" ) - reserve_info["end_time"]["time"] = self.__minsToTime(begin_mins + 8*60) + reserve_info["end_time"]["time"] = self._minsToTime(begin_mins + 8*60) return True @@ -496,6 +479,10 @@ class LibReserve(LibOperator): prefer_earlier: bool = True ) -> int: + """ + Select the nearest available time option. + """ + # Wait for time options to load try: WebDriverWait(self.__driver, 2).until( EC.presence_of_all_elements_located( @@ -505,67 +492,34 @@ class LibReserve(LibOperator): except: self._showTrace(f"{time_type} 选择失败 ! : 当前未查询到可用时间") return -1 - try: - all_time_opts = self.__driver.find_elements( - By.CSS_SELECTOR, - f"#{time_id} ul li a" - ) - free_times = [] - best_time_diff = max_time_diff - best_actual_diff = None - best_time_opt = None - if not all_time_opts: - self._showTrace(f"{time_type} 选择失败 ! : 当前未查询到可用时间") - return -1 - for time_opt in all_time_opts: - time_attr = time_opt.get_attribute("time") - if time_attr == "now": - now = datetime.now() - time_val = int(now.hour*60 + now.minute) - elif time_attr and time_attr.isdigit(): - time_val = int(time_attr) - else: - continue - free_times.append(self.__minsToTime(time_val)) - actual_diff = time_val - target_time - abs_diff = abs(actual_diff) - if abs_diff < best_time_diff or ( - abs_diff == best_time_diff and ( - # prefer earlier time - (prefer_earlier and actual_diff <= 0) or - # prefer later time - (not prefer_earlier and actual_diff >= 0) - ) - ): - best_time_diff = abs_diff - best_actual_diff = actual_diff - best_time_opt = time_opt - - if best_time_opt is not None: - best_time_opt.click() - abs_time_diff = abs(best_actual_diff) - if best_actual_diff < 0: - time_relation = f"早了 {abs_time_diff} 分钟" - elif best_actual_diff > 0: - time_relation = f"晚了 {abs_time_diff} 分钟" - else: - time_relation = f"正好等于 {time_type}" - target_time += best_actual_diff - self._showTrace( - f"选择距离期望 {time_type} 最近的 {best_time_opt.text}, "\ - f"与期望 {time_type} 相比 {time_relation}" - ) - return target_time + # Find best time option + all_time_opts = self.__driver.find_elements( + By.CSS_SELECTOR, + f"#{time_id} ul li a" + ) + if not all_time_opts: + self._showTrace(f"{time_type} 选择失败 ! : 当前未查询到可用时间") + return -1 + best_opt, best_text, actual_diff, free_times = self._findBestTimeOption( + all_time_opts, target_time, max_time_diff, prefer_earlier, is_reserve=True + ) + if best_opt is not None: + best_opt.click() + abs_diff = abs(actual_diff) + time_relation = self._formatTimeRelation(abs_diff, actual_diff, time_type) + target_time += actual_diff self._showTrace( - f"无法选择最近的 {time_type} {self.__minsToTime(target_time)}, "\ - f"所有可选时间与目标时间相差都超过 {max_time_diff} 分钟" + f"选择距离期望 {time_type} 最近的 {best_text}, " + f"与期望 {time_type} 相比 {time_relation}" ) - self._showTrace(f"当前可供预约的 {time_type} 有: {free_times}") - return -1 - except: - self._showTrace(f"{time_type} {self.__minsToTime(target_time)} 选择失败 !") - return -1 + return target_time + self._showTrace( + f"无法选择最近的 {time_type} {self._minsToTime(target_time)}, " + f"所有可选时间与目标时间相差都超过 {max_time_diff} 分钟" + ) + self._showTrace(f"当前可供预约的 {time_type} 有: {free_times}") + return -1 def __selectSeatTime( @@ -576,40 +530,35 @@ class LibReserve(LibOperator): satisfy_duration: bool = True ) -> bool: + """Select seat begin and end time.""" expect_begin_time = actual_begin_time = begin_time["time"] expect_end_time = actual_end_time = end_time["time"] - expect_begin_mins = self.__timeToMins(expect_begin_time) + expect_begin_mins = self._timeToMins(expect_begin_time) actual_begin_mins = expect_begin_mins - expect_end_mins = self.__timeToMins(expect_end_time) + expect_end_mins = self._timeToMins(expect_end_time) - # select the begin time + # Select begin time if self.__selectNearestTime( - time_id="startTime", # dont change into begin, this is the element in the page + time_id="startTime", time_type="开始时间", target_time=expect_begin_mins, max_time_diff=begin_time["max_diff"], prefer_earlier=begin_time["prefer_early"] ) == -1: return False - else: - actual_begin_time = self.__minsToTime(expect_begin_mins) - actual_begin_mins = self.__timeToMins(actual_begin_time) - # if 'satisfy_duration' is True. - # select the end time based on the begin time - # (because it may be changed under the 'max time diff' strategy) and expect duration. + actual_begin_time = self._minsToTime(expect_begin_mins) + actual_begin_mins = self._timeToMins(actual_begin_time) + + # If 'satisfy_duration' is True, select end time based on actual begin time if satisfy_duration: - expect_end_mins = int(actual_begin_mins + expct_duration*60) - if expect_end_mins > self.__timeToMins("23:30"): - expect_end_mins = self.__timeToMins("23:30") - self._showTrace( - f"预约持续时间 {expct_duration} 小时, 超过最大预约时间 23:30, 自动调整为 23:30" - ) - expect_end_time = self.__minsToTime(expect_end_mins) + expect_end_mins = self.validateAndAdjustEndTime(actual_begin_mins, expct_duration) + expect_end_time = self._minsToTime(expect_end_mins) self._showTrace( - f"需要满足期望预约持续时间: {expct_duration} 小时, "\ - f"根据开始时间 {actual_begin_time} 计算结束时间: {self.__minsToTime(expect_end_mins)}" + f"需要满足期望预约持续时间: {expct_duration} 小时, " + f"根据开始时间 {actual_begin_time} 计算结束时间: {expect_end_time}" ) - # select the end time + + # Select end time if self.__selectNearestTime( time_id="endTime", time_type="结束时间", @@ -618,8 +567,7 @@ class LibReserve(LibOperator): prefer_earlier=end_time["prefer_early"] ) == -1: return False - else: - actual_end_time = self.__minsToTime(expect_end_mins) + actual_end_time = self._minsToTime(expect_end_mins) self._showTrace( f"期望预约时间段: {expect_begin_time} - {expect_end_time}, " f"实际预约时间段: {actual_begin_time} - {actual_end_time}" @@ -627,6 +575,25 @@ class LibReserve(LibOperator): 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._timeToMins("23:30") + expect_end_mins = 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" + ) + return expect_end_mins + + def reserve( self, username: str,