From 345cb95b98b6eceb85e3b433b64dcb4ead06b63f Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Wed, 27 May 2026 13:13:43 +0800 Subject: [PATCH] =?UTF-8?q?refactor(pages):=20=E6=8A=BD=E5=8F=96=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E9=80=89=E6=8B=A9=E7=AD=96=E7=95=A5=E4=B8=BA=20TimeSe?= =?UTF-8?q?lectMaker=EF=BC=8C=E5=B0=86=20Overlay=20=E5=9F=BA=E7=B1=BB?= =?UTF-8?q?=E6=9B=B4=E5=90=8D=E4=B8=BA=20Dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 findBestTimeOption 中的预约/续约双分支逻辑抽象为策略模式: - TimeOptionReader 负责从 WebElement 提取时间数据(ReserveTimeReader / RenewTimeReader) - TimeDecisionMaker 执行纯决策算法,零 Selenium 依赖 - TimeSelectMaker 作为工厂统一创建配置好的决策器 - 共享常量 LIBRARY_CLOSE_MINS 统一收敛至 TimeSelectMaker 同时将 Overlay 基类重命名为 Dialog,SeatMapOverlay 同步更名为 SeatMapDialog,保持命名一致性。 Co-Authored-By: Claude Opus 4.7 --- src/pages/ReserveView.py | 6 +- src/pages/__init__.py | 4 +- src/pages/components/CheckinResultDialog.py | 4 +- .../components/{Overlay.py => Dialog.py} | 4 +- src/pages/components/RenewDialog.py | 4 +- src/pages/components/ReserveResultDialog.py | 4 +- .../{SeatMapOverlay.py => SeatMapDialog.py} | 4 +- src/pages/components/TimeSelectDialog.py | 4 +- src/pages/components/__init__.py | 4 +- src/pages/flows/RenewFlow.py | 33 ++-- src/pages/flows/ReserveFlow.py | 34 ++-- src/pages/flows/_helpers.py | 61 ------- src/pages/strategies/__init__.py | 28 +++ src/pages/strategies/timeSelectMaker.py | 164 ++++++++++++++++++ 14 files changed, 244 insertions(+), 114 deletions(-) rename src/pages/components/{Overlay.py => Dialog.py} (98%) rename src/pages/components/{SeatMapOverlay.py => SeatMapDialog.py} (97%) create mode 100644 src/pages/strategies/__init__.py create mode 100644 src/pages/strategies/timeSelectMaker.py diff --git a/src/pages/ReserveView.py b/src/pages/ReserveView.py index f0710d8..eda9b54 100644 --- a/src/pages/ReserveView.py +++ b/src/pages/ReserveView.py @@ -18,7 +18,7 @@ from selenium.common.exceptions import ( TimeoutException, ) -from pages.components.SeatMapOverlay import SeatMapOverlay +from pages.components.SeatMapDialog import SeatMapDialog from pages.components.ReserveResultDialog import ReserveResultDialog @@ -124,9 +124,9 @@ class ReserveView: def openSeatMap( self, - ) -> SeatMapOverlay: + ) -> SeatMapDialog: - return SeatMapOverlay(self._driver) + return SeatMapDialog(self._driver) def submitReserve( self, diff --git a/src/pages/__init__.py b/src/pages/__init__.py index e8ad247..5b0d495 100644 --- a/src/pages/__init__.py +++ b/src/pages/__init__.py @@ -12,7 +12,7 @@ from pages.LoginPage import LoginPage from pages.MainShell import MainShell from pages.ReserveView import ReserveView from pages.RecordsView import RecordsView -from pages.components.SeatMapOverlay import SeatMapOverlay +from pages.components.SeatMapDialog import SeatMapDialog from pages.components.TimeSelectDialog import TimeSelectDialog from pages.components.ReserveResultDialog import ReserveResultDialog from pages.components.CheckinResultDialog import CheckinResultDialog @@ -24,7 +24,7 @@ __all__ = [ "MainShell", "ReserveView", "RecordsView", - "SeatMapOverlay", + "SeatMapDialog", "TimeSelectDialog", "ReserveResultDialog", "CheckinResultDialog", diff --git a/src/pages/components/CheckinResultDialog.py b/src/pages/components/CheckinResultDialog.py index edd4d48..7843f41 100644 --- a/src/pages/components/CheckinResultDialog.py +++ b/src/pages/components/CheckinResultDialog.py @@ -16,10 +16,10 @@ from selenium.common.exceptions import ( from selenium.webdriver.common.by import By from selenium.webdriver.remote.webdriver import WebDriver -from pages.components.Overlay import Overlay +from pages.components.Dialog import Dialog -class CheckinResultDialog(Overlay): +class CheckinResultDialog(Dialog): """ Check-in result dialog. """ diff --git a/src/pages/components/Overlay.py b/src/pages/components/Dialog.py similarity index 98% rename from src/pages/components/Overlay.py rename to src/pages/components/Dialog.py index 12041e6..95d1bfe 100644 --- a/src/pages/components/Overlay.py +++ b/src/pages/components/Dialog.py @@ -13,7 +13,7 @@ from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC -class Overlay: +class Dialog: """ Context-managed overlay / modal / dialog on a page. @@ -36,7 +36,7 @@ class Overlay: def __enter__( self, - ) -> "Overlay": + ) -> "Dialog": WebDriverWait(self._driver, self._timeout).until( EC.visibility_of_element_located(self._root_locator) diff --git a/src/pages/components/RenewDialog.py b/src/pages/components/RenewDialog.py index 7eb36ac..d663a1c 100644 --- a/src/pages/components/RenewDialog.py +++ b/src/pages/components/RenewDialog.py @@ -16,10 +16,10 @@ from selenium.webdriver.common.by import By from selenium.webdriver.remote.webdriver import WebDriver from selenium.webdriver.remote.webelement import WebElement -from pages.components.Overlay import Overlay +from pages.components.Dialog import Dialog -class RenewDialog(Overlay): +class RenewDialog(Dialog): """ Renewal time selection dialog. """ diff --git a/src/pages/components/ReserveResultDialog.py b/src/pages/components/ReserveResultDialog.py index c85cab1..d1ac48e 100644 --- a/src/pages/components/ReserveResultDialog.py +++ b/src/pages/components/ReserveResultDialog.py @@ -14,10 +14,10 @@ from selenium.common.exceptions import ( from selenium.webdriver.common.by import By from selenium.webdriver.remote.webdriver import WebDriver -from pages.components.Overlay import Overlay +from pages.components.Dialog import Dialog -class ReserveResultDialog(Overlay): +class ReserveResultDialog(Dialog): """ Reservation result dialog shown after submitting a reserve request. """ diff --git a/src/pages/components/SeatMapOverlay.py b/src/pages/components/SeatMapDialog.py similarity index 97% rename from src/pages/components/SeatMapOverlay.py rename to src/pages/components/SeatMapDialog.py index 1512a36..9f9a5d5 100644 --- a/src/pages/components/SeatMapOverlay.py +++ b/src/pages/components/SeatMapDialog.py @@ -18,10 +18,10 @@ 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.Overlay import Overlay +from pages.components.Dialog import Dialog -class SeatMapOverlay(Overlay): +class SeatMapDialog(Dialog): """ Seat selection overlay that opens after choosing a floor and room. """ diff --git a/src/pages/components/TimeSelectDialog.py b/src/pages/components/TimeSelectDialog.py index 0782aef..643769f 100644 --- a/src/pages/components/TimeSelectDialog.py +++ b/src/pages/components/TimeSelectDialog.py @@ -15,10 +15,10 @@ from selenium.webdriver.common.by import By from selenium.webdriver.remote.webdriver import WebDriver from selenium.webdriver.remote.webelement import WebElement -from pages.components.Overlay import Overlay +from pages.components.Dialog import Dialog -class TimeSelectDialog(Overlay): +class TimeSelectDialog(Dialog): """ Time selection panel that appears after selecting a seat. diff --git a/src/pages/components/__init__.py b/src/pages/components/__init__.py index f0ac233..3d61a36 100644 --- a/src/pages/components/__init__.py +++ b/src/pages/components/__init__.py @@ -7,14 +7,14 @@ 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.SeatMapOverlay import SeatMapOverlay +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__ = [ - "SeatMapOverlay", + "SeatMapDialog", "TimeSelectDialog", "ReserveResultDialog", "CheckinResultDialog", diff --git a/src/pages/flows/RenewFlow.py b/src/pages/flows/RenewFlow.py index 4d4a36a..fb06111 100644 --- a/src/pages/flows/RenewFlow.py +++ b/src/pages/flows/RenewFlow.py @@ -19,16 +19,13 @@ 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, - findBestTimeOption, -) +from pages.flows._helpers import timeStrToMins, minsToTimeStr +from pages.strategies.timeSelectMaker import TimeSelectMaker class RenewFlow(MsgBase): - LIBRARY_CLOSE_MINS = 1410 + LIBRARY_CLOSE_MINS = TimeSelectMaker.LIBRARY_CLOSE_MINS def __init__( self, @@ -83,24 +80,26 @@ class RenewFlow(MsgBase): self._showTrace("当前未查询到可用续约时间 !", self.TraceLevel.WARNING) self._shell.refresh() return False - best_opt, best_text, actual_diff, free_times = findBestTimeOption( - renew_time_opts, target_renew_mins, max_diff, prefer_earlier, - is_reserve=False, + result = TimeSelectMaker.forRenew().decide( + renew_time_opts, + target_renew_mins, + max_diff, + prefer_earlier ) - if best_opt is not None: - best_opt.click() - abs_diff = abs(actual_diff) - if actual_diff < 0: + if result.selected_index >= 0: + renew_time_opts[result.selected_index].click() + abs_diff = abs(result.actual_diff) + if result.actual_diff < 0: relation = f"早了 {abs_diff} 分钟" - elif actual_diff > 0: + elif result.actual_diff > 0: relation = f"晚了 {abs_diff} 分钟" else: relation = "正好等于 续约时间" self._showTrace( - f"选择距离期望续约时间最近的 {best_text}, " + f"选择距离期望续约时间最近的 {result.display_text}, " f"与期望续约时间相比 {relation}" ) - record["time"]["end"] = best_text.strip() + record["time"]["end"] = result.display_text.strip() renew_ok_btn.click() self._shell.refresh() return True @@ -109,7 +108,7 @@ class RenewFlow(MsgBase): f"所有可选时间与目标时间相差都超过了 {max_diff} 分钟 !", self.TraceLevel.WARNING, ) - self._showTrace(f"当前可供续约的时间有: {free_times}") + self._showTrace(f"当前可供续约的时间有: {result.free_times}") self._shell.refresh() return False except (NoSuchElementException, TimeoutException) as e: diff --git a/src/pages/flows/ReserveFlow.py b/src/pages/flows/ReserveFlow.py index c3313bf..e26eab8 100644 --- a/src/pages/flows/ReserveFlow.py +++ b/src/pages/flows/ReserveFlow.py @@ -20,11 +20,8 @@ from selenium.webdriver.remote.webdriver import WebDriver from base.MsgBase import MsgBase from pages.MainShell import MainShell -from pages.flows._helpers import ( - timeStrToMins, - minsToTimeStr, - findBestTimeOption, -) +from pages.flows._helpers import timeStrToMins, minsToTimeStr +from pages.strategies.timeSelectMaker import TimeSelectMaker from pages.ReserveView import ReserveView from pages.components.ReserveResultDialog import ReserveResultDialog from pages.components.TimeSelectDialog import TimeSelectDialog @@ -50,7 +47,7 @@ class ReserveContext: class ReserveFlow(MsgBase): - LIBRARY_CLOSE_MINS = timeStrToMins("23:30") + LIBRARY_CLOSE_MINS = TimeSelectMaker.LIBRARY_CLOSE_MINS def __init__( self, @@ -219,30 +216,33 @@ class ReserveFlow(MsgBase): f"{time_type} 选择失败 ! : 当前未查询到可用时间", self.TraceLevel.ERROR ) return -1 - best_opt, best_text, actual_diff, free_times = findBestTimeOption( - all_time_opts, target_time, max_time_diff, prefer_earlier, is_reserve=True + result = TimeSelectMaker.forReserve().decide( + all_time_opts, + target_time, + max_time_diff, + prefer_earlier ) - if best_opt is not None: - best_opt.click() - abs_diff = abs(actual_diff) - if actual_diff < 0: + if result.selected_index >= 0: + all_time_opts[result.selected_index].click() + abs_diff = abs(result.actual_diff) + if result.actual_diff < 0: relation = f"早了 {abs_diff} 分钟" - elif actual_diff > 0: + elif result.actual_diff > 0: relation = f"晚了 {abs_diff} 分钟" else: relation = f"正好等于 {time_type}" self._showTrace( - f"选择距离期望 {time_type} 最近的 {best_text}, " + f"选择距离期望 {time_type} 最近的 {result.display_text}, " f"与期望 {time_type} 相比 {relation}" ) - return target_time + actual_diff + return target_time + result.actual_diff target_time_str = minsToTimeStr(target_time) self._showTrace( f"无法选择最近的 {time_type} {target_time_str}, " f"所有可选时间与目标时间相差都超过 {max_time_diff} 分钟", self.TraceLevel.WARNING, ) - self._showTrace(f"当前可供预约的 {time_type} 有: {free_times}") + self._showTrace(f"当前可供预约的 {time_type} 有: {result.free_times}") return -1 def _calcEndTime( @@ -251,7 +251,7 @@ class ReserveFlow(MsgBase): duration: int, ) -> int: - expect_end_mins = int(begin_mins + duration * 60) + expect_end_mins = int(begin_mins + duration*60) if expect_end_mins > self.LIBRARY_CLOSE_MINS: expect_end_mins = self.LIBRARY_CLOSE_MINS self._showTrace( diff --git a/src/pages/flows/_helpers.py b/src/pages/flows/_helpers.py index 488615a..936ccc5 100644 --- a/src/pages/flows/_helpers.py +++ b/src/pages/flows/_helpers.py @@ -7,9 +7,6 @@ This software is provided "as is", without any warranty of any kind. You may use, modify, and distribute this file under the terms of the MIT License. See the LICENSE file for details. """ -from datetime import datetime - - def timeStrToMins( time_str: str, ) -> int: @@ -17,67 +14,9 @@ def timeStrToMins( hour, minute = map(int, time_str.split(":")) return hour * 60 + minute - def minsToTimeStr( mins: int, ) -> str: hour, minute = divmod(int(mins), 60) return f"{hour:02d}:{minute:02d}" - - -def findBestTimeOption( - time_options: list, - target_time: int, - max_time_diff: int, - prefer_earlier: bool, - is_reserve: bool = True, -) -> tuple: - """ - Find the best time option from available WebElement options. - - Returns: - (bestElement, bestText, actual_diff, freeTimesList) - or (None, None, None, freeTimesList) if no suitable option. - """ - - free_times = [] - best_time_diff = max_time_diff - best_actual_diff = None - best_time_opt = None - - for time_opt in time_options: - if is_reserve: - time_attr = time_opt.get_attribute("time") - if time_attr == "now": - now = datetime.now() - time_val = now.hour * 60 + now.minute - elif time_attr and time_attr.isdigit(): - time_val = int(time_attr) - else: - continue - else: - time_attr = time_opt.get_attribute("id") - if not (time_attr and time_attr.isdigit()): - continue - time_val = int(time_attr) - free_times.append( - time_opt.text.strip() - if not is_reserve - else minsToTimeStr(time_val) - ) - actual_diff = time_val - target_time - abs_diff = abs(actual_diff) - if abs_diff < best_time_diff or ( - abs_diff == best_time_diff - and ( - (prefer_earlier and actual_diff <= 0) - or (not prefer_earlier and actual_diff >= 0) - ) - ): - best_time_diff = abs_diff - best_actual_diff = actual_diff - best_time_opt = time_opt - if best_time_opt is not None: - return (best_time_opt, best_time_opt.text.strip(), best_actual_diff, free_times) - return (None, None, None, free_times) diff --git a/src/pages/strategies/__init__.py b/src/pages/strategies/__init__.py new file mode 100644 index 0000000..6599a5f --- /dev/null +++ b/src/pages/strategies/__init__.py @@ -0,0 +1,28 @@ +# -*- 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, +) + +__all__ = [ + "TimeSelectMaker", + "TimeDecisionMaker", + "TimeOptionReader", + "ReserveTimeReader", + "RenewTimeReader", + "TimeOption", + "TimeSelectionResult", +] diff --git a/src/pages/strategies/timeSelectMaker.py b/src/pages/strategies/timeSelectMaker.py new file mode 100644 index 0000000..ada47cf --- /dev/null +++ b/src/pages/strategies/timeSelectMaker.py @@ -0,0 +1,164 @@ +# -*- 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 + +from pages.flows._helpers import minsToTimeStr + + +@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) + + +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 = 1410 + MAX_DURATION_HOURS = 8 + + @staticmethod + def forReserve( + ) -> TimeDecisionMaker: + + return TimeDecisionMaker(ReserveTimeReader()) + + @staticmethod + def forRenew( + ) -> TimeDecisionMaker: + + return TimeDecisionMaker(RenewTimeReader())