From 2226e8ac908709ea78ee55d33028cbd1b04bed97 Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Tue, 26 May 2026 12:39:21 +0800 Subject: [PATCH 1/8] =?UTF-8?q?refactor(pages):=20=E5=BC=95=E5=85=A5=20Pag?= =?UTF-8?q?e=20Object=20=E6=A8=A1=E5=BC=8F=E9=87=8D=E6=9E=84=E5=85=A8?= =?UTF-8?q?=E9=83=A8=E9=A1=B5=E9=9D=A2=E6=A8=A1=E5=9D=97=EF=BC=8C=E5=8F=98?= =?UTF-8?q?=E9=87=8F=E7=BB=9F=E4=B8=80=E4=B8=BA=20snake=5Fcase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将原始 Selenium 操作脚本重构为三层 Page Object 架构: - Page Objects(LoginPage/ReserveView/RecordsView/MainShell) - Component Objects(Overlay 基类 + SeatMapOverlay/ReserveResultDialog 等对话框) - Flow 状态机(ReserveFlow/CheckinFlow/RenewFlow) - Services(CaptchaHandler/ReserveValidator/RecordChecker) 变量命名统一为 snake_case,方法名保持 camelCase,类名保持 PascalCase。 Co-Authored-By: Claude Opus 4.7 --- src/pages/AutoLibPages.py | 398 +++++++++++++++++++++++++ src/pages/LoginPage.py | 209 +++++++++++++ src/pages/MainShell.py | 166 +++++++++++ src/pages/RecordsView.py | 93 ++++++ src/pages/ReserveView.py | 266 +++++++++++++++++ src/pages/__init__.py | 34 +++ src/pages/_dialogs.py | 302 +++++++++++++++++++ src/pages/_overlay.py | 110 +++++++ src/pages/flows/CheckinFlow.py | 94 ++++++ src/pages/flows/RenewFlow.py | 156 ++++++++++ src/pages/flows/ReserveFlow.py | 272 +++++++++++++++++ src/pages/flows/__init__.py | 18 ++ src/pages/flows/_helpers.py | 85 ++++++ src/pages/services/CaptchaHandler.py | 101 +++++++ src/pages/services/RecordChecker.py | 302 +++++++++++++++++++ src/pages/services/ReserveValidator.py | 221 ++++++++++++++ src/pages/services/__init__.py | 18 ++ src/test_pages_refactor.py | 162 ++++++++++ 18 files changed, 3007 insertions(+) create mode 100644 src/pages/AutoLibPages.py create mode 100644 src/pages/LoginPage.py create mode 100644 src/pages/MainShell.py create mode 100644 src/pages/RecordsView.py create mode 100644 src/pages/ReserveView.py create mode 100644 src/pages/__init__.py create mode 100644 src/pages/_dialogs.py create mode 100644 src/pages/_overlay.py create mode 100644 src/pages/flows/CheckinFlow.py create mode 100644 src/pages/flows/RenewFlow.py create mode 100644 src/pages/flows/ReserveFlow.py create mode 100644 src/pages/flows/__init__.py create mode 100644 src/pages/flows/_helpers.py create mode 100644 src/pages/services/CaptchaHandler.py create mode 100644 src/pages/services/RecordChecker.py create mode 100644 src/pages/services/ReserveValidator.py create mode 100644 src/pages/services/__init__.py create mode 100644 src/test_pages_refactor.py diff --git a/src/pages/AutoLibPages.py b/src/pages/AutoLibPages.py new file mode 100644 index 0000000..a200a10 --- /dev/null +++ b/src/pages/AutoLibPages.py @@ -0,0 +1,398 @@ +# -*- 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 os +import queue + +from selenium import webdriver +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 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.CaptchaHandler import CaptchaHandler +from pages.services.ReserveValidator import ReserveValidator +from pages.services.RecordChecker import RecordChecker + + +class AutoLibPages(MsgBase): + + def __init__( + self, + input_queue: queue.Queue, + output_queue: queue.Queue, + run_config: dict, + ) -> None: + super().__init__(input_queue, output_queue) + + 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_handler: CaptchaHandler = None + self.__record_checker: RecordChecker = None + self.__reserve_validator: ReserveValidator = 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.__initPagesServices() + self.__initPagesFlows() + + def __initBrowserDriver( + self, + ) -> bool: + + self._showTrace("正在初始化浏览器驱动......", no_log=True) + web_driver_config: dict = self.__run_config.get("web_driver", None) + self.__driver_type = web_driver_config.get("driver_type") + match self.__driver_type.lower(): + case "edge": + driver_options = webdriver.EdgeOptions() + case "chrome": + driver_options = webdriver.ChromeOptions() + case "firefox": + driver_options = webdriver.FirefoxOptions() + case _: + self._showTrace( + f"不支持的浏览器驱动类型: {self.__driver_type} !", + self.TraceLevel.WARNING, + ) + return False + if not web_driver_config: + self._showTrace("未配置浏览器驱动参数 !", self.TraceLevel.ERROR) + return False + if web_driver_config.get("headless"): + driver_options.add_argument("--headless") + driver_options.add_argument("--disable-gpu") + driver_options.add_argument("--no-sandbox") + driver_options.add_argument("--disable-dev-shm-usage") + + # must be 1920x1080, otherwise the page will cause some elements not accessible + driver_options.add_argument("--window-size=1920,1080") + + # omit ssl errors and verbose log level + driver_options.add_argument("--ignore-certificate-errors") + driver_options.add_argument("--ignore-ssl-errors") + driver_options.add_argument("--log-level=OFF") + driver_options.add_argument("--silent") + + # set options for chrome and edge + if self.__driver_type.lower() in ["edge", "chrome"]: + driver_options.add_argument("--remote-allow-origins=*") + driver_options.add_experimental_option("excludeSwitches", ["enable-automation"]) + driver_options.add_experimental_option("useAutomationExtension", False) + driver_options.add_argument("--disable-blink-features=AutomationControlled") + user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "\ + "AppleWebKit/537.36 (KHTML, like Gecko) "\ + "Chrome/120.0.0.0 "\ + "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) + driver_options.set_preference("useAutomationExtension", False) + user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) "\ + "Gecko/20100101 Firefox/120.0" + driver_options.add_argument(f"user-agent={user_agent}") + + # init browser driver + 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: + service = None + match self.__driver_type.lower(): + case "edge": + service = EdgeService(executable_path=self.__driver_path) + self.__driver = webdriver.Edge(service=service, options=driver_options) + case "chrome": + service = ChromeService(executable_path=self.__driver_path) + self.__driver = webdriver.Chrome(service=service, options=driver_options) + case "firefox": + self._showTrace("Firefox 浏览器驱动初始化略慢, 请耐心等待...", no_log=True) + service = FirefoxService(executable_path=self.__driver_path) + self.__driver = webdriver.Firefox(service=service, options=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 __initDriverUrl( + self, + ) -> bool: + + lib_config: dict = self.__run_config.get("library", None) + if not lib_config: + self._showTrace("未配置图书馆参数 !", self.TraceLevel.ERROR) + return False + url: str = lib_config.get("host_url") + lib_config.get("login_url") + self.__login_page = LoginPage(self.__driver) + self.__driver.set_page_load_timeout(5) + try: + self.__driver.get(url) + except TimeoutException: + self.__login_page.stopPageLoad() + self._showTrace( + "图书馆登录页面加载超时 ! 请检查网络环境是否正常", self.TraceLevel.ERROR + ) + return False + 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_handler = CaptchaHandler( + input_queue=self._input_queue, + output_queue=self._output_queue, + login_page=self.__login_page, + ) + self.__record_checker = RecordChecker( + input_queue=self._input_queue, + output_queue=self._output_queue, + shell=self.__shell, + ) + self.__reserve_validator = ReserveValidator( + 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, + ) -> int: + + # result : -1 - terminate, 0 - success, 1 - failed, 2 - passed + result: int = 2 + + # login + auto_captcha: bool = login_config.get("auto_captcha", True) + if not self.__login_page.login( + username, + password, + captcha_solver=lambda: self.__captcha_handler.solveCaptcha(auto_captcha), + tracer=self._showTrace, + log_level=self.TraceLevel, + max_attempts=login_config.get("max_attempt", 3), + ): + return 1 + 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.__record_checker.canReserve(reserve_info.get("date")): + if self.__reserve_validator.validate(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: int = result + if run_mode["auto_checkin"] and last_result != 1: + if self.__record_checker.canCheckin(): + if self.__checkin_flow.execute(username): + result = 0 + else: + result = 1 + else: + self._showTrace(f"用户 {username} 无法签到, 已跳过") + result = 2 + if last_result == 0: # partly success + result = 0 + + # renewal + last_result = result + if run_mode["auto_renewal"] and last_result != 1: + can_renew, record = self.__record_checker.canRenew() + if can_renew: + renew_info: dict = reserve_info.get("renew_time", {}) + if self.__renew_flow.execute(username, record, renew_info): + if self.__record_checker.postRenewCheck(record): + self._showTrace(f"用户 {username} 续约成功 !") + result = 0 + else: + if result != 1: # partly success + result = 0 + else: + result = 1 + else: + result = 1 + else: + self._showTrace(f"用户 {username} 无法续约, 已跳过") + result = 2 + if last_result == 0: # partly success + result = 0 + + # logout + if not self.__shell.logout(): + if not self.__initDriverUrl(): + return -1 + return result + + def run( + self, + user_config: dict, + ) -> None: + + self.__user_config = user_config + + user_counter: dict[str, int] = {"current": 0, "success": 0, "failed": 0, "passed": 0} + users: list = self.__user_config["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, + ) + if not user["enabled"]: + self._showTrace(f"用户 {user['username']} 已跳过") + user_counter["passed"] += 1 + continue + r: int = 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"], + ) + if r == -1: + self._showTrace( + f"用户 {user['username']} 处理过程中页面发生异常, 无法继续操作, 任务已终止 !", + self.TraceLevel.WARNING, + ) + break + elif r == 0: + user_counter["success"] += 1 + elif r == 1: + 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']} 个用户, " + f"跳过 {user_counter['passed']} 个用户" + ) + return + + def close( + self, + ) -> bool: + + if self.__driver: + if self.__driver_type.lower() == "firefox": + self._showTrace( + "Firefox 浏览器驱动关闭略慢, 请耐心等待...", + no_log=True, + ) + try: + self.__driver.quit() + except WebDriverException as e: + self._showTrace(f"浏览器驱动关闭时发生异常: {e}", self.TraceLevel.WARNING) + self.__driver = None + self._showTrace("浏览器驱动已关闭") + return True + else: + self._showTrace("浏览器驱动未初始化, 无需关闭", no_log=True) + return False diff --git a/src/pages/LoginPage.py b/src/pages/LoginPage.py new file mode 100644 index 0000000..24c9b1b --- /dev/null +++ b/src/pages/LoginPage.py @@ -0,0 +1,209 @@ +# -*- 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 + +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, + ) -> None: + + self._driver: WebDriver = driver + + 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[[], str], + tracer: Callable[..., None], + log_level: type, + max_attempts: int = 5, + ) -> bool: + + ERR = log_level.ERROR + for attempt in range(max_attempts): + tracer( + f"用户 {username} 第 {attempt + 1} 次尝试登录......", + 20, no_log=True, + ) + if not self.fillCredentials(username, password): + continue + captcha_text = captcha_solver() + if not captcha_text: + continue + if not self.fillCaptcha(captcha_text): + continue + tracer("尝试登录...", 20, no_log=True) + if not self.clickLogin(): + continue + if self.waitLoginSuccess(): + tracer(f"用户 {username} 第 {attempt + 1} 次登录成功 !") + return True + else: + err_msg = ( + "登录页面加载失败 ! : " + "用户账号或者密码错误/验证码错误, 具体以页面提示为准" + ) + tracer(err_msg, ERR) + return False diff --git a/src/pages/MainShell.py b/src/pages/MainShell.py new file mode 100644 index 0000000..a9b6247 --- /dev/null +++ b/src/pages/MainShell.py @@ -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() diff --git a/src/pages/RecordsView.py b/src/pages/RecordsView.py new file mode 100644 index 0000000..d4f838c --- /dev/null +++ b/src/pages/RecordsView.py @@ -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, "more_btn") + 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() diff --git a/src/pages/ReserveView.py b/src/pages/ReserveView.py new file mode 100644 index 0000000..13c4cb7 --- /dev/null +++ b/src/pages/ReserveView.py @@ -0,0 +1,266 @@ +# -*- 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, + NoSuchElementException, + StaleElementReferenceException, + TimeoutException, +) + +from pages._dialogs import SeatMapOverlay, ReserveResultDialog + + +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}" + + SEAT_LAYOUT = (By.ID, "seatLayout") + SEAT_ITEMS = (By.CSS_SELECTOR, "li[id^='seat_']") + RESERVE_BTN = (By.ID, "reserveBtn") + + START_TIME_OPTS = (By.CSS_SELECTOR, "#startTime ul li a") + END_TIME_OPTS = (By.CSS_SELECTOR, "#endTime ul li a") + + RESULT_DIALOG = (By.CLASS_NAME, "layoutSeat") + RESULT_TITLE = (By.CSS_SELECTOR, ".layoutSeat dt") + RESULT_DETAIL = (By.CSS_SELECTOR, ".layoutSeat dd") + + 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, + ) -> bool: + + try: + WebDriverWait(self._driver, 2).until( + EC.element_to_be_clickable(self.FIND_ROOM_BTN) + ).click() + except (TimeoutException, ElementNotInteractableException): + return False + except Exception: + return False + try: + WebDriverWait(self._driver, 2).until( + EC.element_to_be_clickable((By.ID, self.ROOM_BTN_FMT.format(room=room))) + ).click() + return True + except (TimeoutException, ElementNotInteractableException): + return False + except Exception: + return False + + def openSeatMap( + self, + ) -> SeatMapOverlay: + + return SeatMapOverlay(self._driver) + + def selectSeat( + self, + seat_id: str, + ) -> str | None: + + try: + WebDriverWait(self._driver, 2).until( + EC.presence_of_element_located(self.SEAT_LAYOUT) + ) + WebDriverWait(self._driver, 2).until( + EC.presence_of_all_elements_located(self.SEAT_ITEMS) + ) + except TimeoutException: + return None + except Exception: + return None + try: + all_seats = self._driver.find_elements(*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, + StaleElementReferenceException, ElementNotInteractableException): + 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 waitResultDialog( + self, + ) -> ReserveResultDialog: + + return ReserveResultDialog(self._driver) + + def getAvailableTimeOptions( + self, + time_id: str, + ) -> list: + + try: + WebDriverWait(self._driver, 2).until( + EC.presence_of_all_elements_located( + (By.CSS_SELECTOR, f"#{time_id} ul li a") + ) + ) + except TimeoutException: + return [] + except Exception: + return [] + return self._driver.find_elements( + By.CSS_SELECTOR, + f"#{time_id} ul li a", + ) + + 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 diff --git a/src/pages/__init__.py b/src/pages/__init__.py new file mode 100644 index 0000000..36f0450 --- /dev/null +++ b/src/pages/__init__.py @@ -0,0 +1,34 @@ +# -*- 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.AutoLibPages import AutoLibPages +from pages.LoginPage import LoginPage +from pages.MainShell import MainShell +from pages.ReserveView import ReserveView +from pages.RecordsView import RecordsView +from pages._dialogs import ( + SeatMapOverlay, + TimeSelectDialog, + ReserveResultDialog, + CheckinResultDialog, + RenewDialog, +) + +__all__ = [ + "AutoLibPages", + "LoginPage", + "MainShell", + "ReserveView", + "RecordsView", + "SeatMapOverlay", + "TimeSelectDialog", + "ReserveResultDialog", + "CheckinResultDialog", + "RenewDialog", +] diff --git a/src/pages/_dialogs.py b/src/pages/_dialogs.py new file mode 100644 index 0000000..dc52602 --- /dev/null +++ b/src/pages/_dialogs.py @@ -0,0 +1,302 @@ +# -*- 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.remote.webelement import WebElement +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC + +from pages._overlay import Overlay + + +class SeatMapOverlay(Overlay): + """ + 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: + 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") + self._waitClickable((By.TAG_NAME, "a")) + seat_link.click() + return seat_link.get_attribute("title") + return None + except (NoSuchElementException, TimeoutException, + ElementNotInteractableException, StaleElementReferenceException): + return None + except Exception: + return None + + +class TimeSelectDialog(Overlay): + """ + 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, + ) -> None: + + super().__init__(driver, self.ROOT, auto_close_on_exit=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", + ) + + +class ReserveResultDialog(Overlay): + """ + 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 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 [] + + def _titleLocator( + self, + ) -> tuple: + + return (By.CSS_SELECTOR, ".layoutSeat dt") + + +class CheckinResultDialog(Overlay): + """ + 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 + + +class RenewDialog(Overlay): + """ + 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 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 diff --git a/src/pages/_overlay.py b/src/pages/_overlay.py new file mode 100644 index 0000000..12041e6 --- /dev/null +++ b/src/pages/_overlay.py @@ -0,0 +1,110 @@ +# -*- 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 Overlay: + """ + Context-managed overlay / modal / dialog on a page. + + 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 + + def __enter__( + self, + ) -> "Overlay": + + WebDriverWait(self._driver, self._timeout).until( + EC.visibility_of_element_located(self._root_locator) + ) + 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) + ) diff --git a/src/pages/flows/CheckinFlow.py b/src/pages/flows/CheckinFlow.py new file mode 100644 index 0000000..299ab04 --- /dev/null +++ b/src/pages/flows/CheckinFlow.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +""" +Copyright (c) 2026 KenanZhu. +All rights reserved. + +This software is provided "as is", without any warranty of any kind. +You may use, modify, and distribute this file under the terms of the MIT License. +See the LICENSE file for details. +""" +import queue + +from selenium.common.exceptions import ( + NoSuchElementException, + TimeoutException, +) +from selenium.webdriver.remote.webdriver import WebDriver + +from base.MsgBase import MsgBase +from pages.MainShell import MainShell +from pages._dialogs import CheckinResultDialog + + +class CheckinFlow(MsgBase): + + def __init__( + self, + input_queue: queue.Queue, + output_queue: queue.Queue, + driver: WebDriver, + shell: MainShell, + ) -> None: + + super().__init__(input_queue, output_queue) + self._driver: WebDriver = driver + self._shell: MainShell = shell + + def execute( + self, + username: str, + ) -> bool: + + if not self._shell.waitCheckinButton(): + self._showTrace(f"用户 {username} 签到界面加载失败 !", self.TraceLevel.ERROR) + return False + + if self._shell.isCheckinButtonDisabled(): + self._showTrace("签到按钮不可用, 可能不在场馆内, 正在尝试启用......") + if not self._shell.enableCheckinButtonByJS(): + self._showTrace(f"签到按钮启用失败 !", self.TraceLevel.ERROR) + return False + self._showTrace("签到按钮已启用") + + self._shell.clickCheckinButton() + + try: + with CheckinResultDialog(self._driver) as dialog: + result_msg = dialog.getResultMessage() + if "签到成功" in result_msg: + details = dialog.getDetails() + if details: + if len(details) >= 5: + self._showTrace( + f"\n" + f" 签到成功 !\n" + f" {details[1]}\n" + f" {details[2]}\n" + f" {details[3]}\n" + f" {details[4]}" + ) + else: + self._showTrace( + "\n" + " 签到成功 !\n" + " 未获取到签到详情 !" + ) + dialog.clickOk() + self._showTrace(f"用户 {username} 签到成功 !") + return True + else: + failure_reason = result_msg.replace("签到失败", "").strip() + self._showTrace( + f"\n" + " 签到失败 !\n" + f" {failure_reason}" + ) + dialog.clickOk() + self._showTrace(f"用户 {username} 签到失败 !", self.TraceLevel.ERROR) + return False + except (NoSuchElementException, TimeoutException): + self._showTrace("签到时发生未知错误 !", self.TraceLevel.ERROR) + return False + except Exception: + self._showTrace("签到时发生未知错误 !", self.TraceLevel.ERROR) + return False diff --git a/src/pages/flows/RenewFlow.py b/src/pages/flows/RenewFlow.py new file mode 100644 index 0000000..35b48f1 --- /dev/null +++ b/src/pages/flows/RenewFlow.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- +""" +Copyright (c) 2026 KenanZhu. +All rights reserved. + +This software is provided "as is", without any warranty of any kind. +You may use, modify, and distribute this file under the terms of the MIT License. +See the LICENSE file for details. +""" +import queue + +from selenium.common.exceptions import ( + ElementNotInteractableException, + NoSuchElementException, + TimeoutException, +) +from selenium.webdriver.remote.webdriver import WebDriver + +from base.MsgBase import MsgBase +from pages.MainShell import MainShell +from pages._dialogs import RenewDialog +from pages.flows._helpers import ( + timeStrToMins, + minsToTimeStr, + findBestTimeOption, +) + + +class RenewFlow(MsgBase): + + LIBRARY_CLOSE_MINS = 1410 + + def __init__( + self, + input_queue: queue.Queue, + output_queue: queue.Queue, + driver: WebDriver, + shell: MainShell, + ) -> None: + + super().__init__(input_queue, output_queue) + self._driver: WebDriver = driver + self._shell: MainShell = shell + + def execute( + self, + username: str, + record: dict, + renew_info: dict, + ) -> bool: + + max_diff = renew_info["max_diff"] + prefer_earlier = renew_info["prefer_early"] + end_time = record["time"]["end"] + target_renew_mins = timeStrToMins(end_time) + renew_info["expect_duration"] * 60 + + if not self._validateRenewTime(end_time, target_renew_mins): + return False + + if not self._shell.waitExtendButton(): + self._showTrace(f"用户 {username} 续约界面加载失败 !", self.TraceLevel.ERROR) + return False + + if self._shell.isExtendButtonDisabled(): + self._showTrace( + f"用户 {username} 续约按钮不可用, 可能不在场馆内, " + f"请连接图书馆网络后重试" + ) + return False + + self._shell.clickExtendButton() + + try: + with RenewDialog(self._driver) as dialog: + if not dialog.waitUntilReady(): + result_msg = dialog.getResultMessage() + self._showTrace( + f"\n" + f" 续约失败 !\n" + f" {result_msg}" + ) + self._shell.refresh() + self._showTrace(f"用户 {username} 续约失败 !", self.TraceLevel.ERROR) + return False + + renew_ok_btn = dialog.getOkButton() + renew_time_opts = dialog.getTimeOptions() + if not renew_time_opts: + self._showTrace("当前未查询到可用续约时间 !", self.TraceLevel.WARNING) + self._shell.refresh() + return False + + best_opt, best_text, actual_diff, free_times = findBestTimeOption( + renew_time_opts, target_renew_mins, max_diff, prefer_earlier, + is_reserve=False, + ) + if best_opt is not None: + best_opt.click() + abs_diff = abs(actual_diff) + if actual_diff < 0: + relation = f"早了 {abs_diff} 分钟" + elif actual_diff > 0: + relation = f"晚了 {abs_diff} 分钟" + else: + relation = "正好等于 续约时间" + self._showTrace( + f"选择距离期望续约时间最近的 {best_text}, " + f"与期望续约时间相比 {relation}" + ) + record["time"]["end"] = best_text.strip() + renew_ok_btn.click() + self._shell.refresh() + return True + + self._showTrace( + "无法选择最近的可用续约时间 ! " + f"所有可选时间与目标时间相差都超过了 {max_diff} 分钟 !", + self.TraceLevel.WARNING, + ) + self._showTrace(f"当前可供续约的时间有: {free_times}") + self._shell.refresh() + return False + except (NoSuchElementException, TimeoutException) as e: + self._showTrace(f"用户 {username} 续约失败 ! : {e}", self.TraceLevel.ERROR) + self._shell.refresh() + return False + except (ElementNotInteractableException) as e: + self._showTrace(f"用户 {username} 续约失败 ! : {e}", self.TraceLevel.ERROR) + self._shell.refresh() + return False + except Exception as e: + self._showTrace(f"用户 {username} 续约失败 ! : {e}", self.TraceLevel.ERROR) + self._shell.refresh() + return False + + def _validateRenewTime( + self, + end_time: str, + target_renew_mins: int, + ) -> bool: + + if target_renew_mins > self.LIBRARY_CLOSE_MINS: + actual_renew_duration = self.LIBRARY_CLOSE_MINS - timeStrToMins(end_time) + if actual_renew_duration <= 0: + self._showTrace( + f"当前结束时间 {end_time} 已接近闭馆时间,无法续约 !", self.TraceLevel.ERROR + ) + return False + self._showTrace( + f"续约时间已调整至闭馆时间 " + f"{minsToTimeStr(self.LIBRARY_CLOSE_MINS)}," + f"实际续约时长为 " + f"{actual_renew_duration // 60} 小时 " + f"{actual_renew_duration % 60} 分钟" + ) + return True diff --git a/src/pages/flows/ReserveFlow.py b/src/pages/flows/ReserveFlow.py new file mode 100644 index 0000000..037e36c --- /dev/null +++ b/src/pages/flows/ReserveFlow.py @@ -0,0 +1,272 @@ +# -*- coding: utf-8 -*- +""" +Copyright (c) 2026 KenanZhu. +All rights reserved. + +This software is provided "as is", without any warranty of any kind. +You may use, modify, and distribute this file under the terms of the MIT License. +See the LICENSE file for details. +""" +import queue +from dataclasses import dataclass +from typing import Optional + +from selenium.common.exceptions import ( + ElementNotInteractableException, + NoSuchElementException, + TimeoutException, +) +from selenium.webdriver.remote.webdriver import WebDriver + +from base.MsgBase import MsgBase +from pages.MainShell import MainShell +from pages.flows._helpers import ( + timeStrToMins, + minsToTimeStr, + findBestTimeOption, +) +from pages.ReserveView import ReserveView +from pages._dialogs import ReserveResultDialog + + +@dataclass +class ReserveContext: + + username: str + date: str + floor: str + room: str + seat_id: str + begin_time: str + end_time: str + begin_max_diff: int = 30 + end_max_diff: int = 30 + begin_prefer_early: bool = True + end_prefer_early: bool = False + expect_duration: int = 4 + satisfy_duration: bool = True + + +class ReserveFlow(MsgBase): + + LIBRARY_CLOSE_MINS = timeStrToMins("23:30") + + def __init__( + self, + input_queue: queue.Queue, + output_queue: queue.Queue, + driver: WebDriver, + shell: MainShell, + ) -> None: + + super().__init__(input_queue, output_queue) + self._driver: WebDriver = driver + self._shell: MainShell = shell + self._ctx: Optional[ReserveContext] = None + + def execute( + self, + ctx: ReserveContext, + ) -> bool: + + self._ctx = ctx + submit_reserve = False + reserve_success = False + have_hover_on_page = False + + try: + view = self._shell.gotoReserveView() + except (NoSuchElementException, TimeoutException) as e: + self._showTrace(f"加载预约选座页面失败 ! : {e}", self.TraceLevel.ERROR) + return False + except Exception as e: + self._showTrace(f"加载预约选座页面失败 ! : {e}", self.TraceLevel.ERROR) + return False + + if not view.selectDate(ctx.date): + self._showTrace(f"选择日期失败 ! : {ctx.date} 不可用", self.TraceLevel.ERROR) + return False + self._showTrace(f"日期 {ctx.date} 选择成功 !") + + if not view.selectPlace("1"): + self._showTrace("选择预约场所失败 ! : 图书馆 不可用", self.TraceLevel.ERROR) + return False + self._showTrace("预约场所 图书馆 选择成功 !") + + if not view.selectFloor(ctx.floor): + display_floor = ReserveView.FLOOR_MAP.get(ctx.floor, ctx.floor) + self._showTrace(f"选择楼层失败 ! : {display_floor} 不可用", self.TraceLevel.ERROR) + return False + self._showTrace(f"楼层 {ReserveView.FLOOR_MAP.get(ctx.floor)} 选择成功 !") + + if not view.selectRoom(ctx.room): + display_room = ReserveView.ROOM_MAP.get(ctx.room, ctx.room) + self._showTrace(f"选择房间失败 ! : {display_room} 不可用", self.TraceLevel.ERROR) + return False + self._showTrace(f"房间 {ReserveView.ROOM_MAP.get(ctx.room)} 选择成功 !") + have_hover_on_page = True + + seat_status = view.selectSeat(ctx.seat_id) + if seat_status is None: + self._showTrace( + f"座位 {ctx.seat_id} 在该楼层区域中不存在, 请检查座位号是否正确", + self.TraceLevel.WARNING, + ) + else: + self._showTrace(f"座位 {ctx.seat_id} 选择成功 ! : 当前状态 - '{seat_status}'") + + select_time_ok = self._selectSeatTime(view) + if not select_time_ok: + self._showTrace("选择时间失败 !", self.TraceLevel.ERROR) + else: + try: + view.submitReserve() + submit_reserve = True + with ReserveResultDialog(self._driver) as result: + if result.isFailure(): + self._showTrace("预约失败", self.TraceLevel.ERROR) + elif result.isSuccess(): + details = result.getDetailTexts() + if len(details) >= 6: + self._showTrace( + f"\n" + f" 预约成功 !\n" + f" {details[1]}\n" + f" {details[2]}\n" + f" {details[3]}\n" + f" 签到时间 :{details[5]}" + ) + else: + self._showTrace( + "\n" + " 预约成功 !\n" + " 未找获取到详细信息" + ) + reserve_success = True + else: + self._showTrace("预约结果加载失败 !", self.TraceLevel.ERROR) + except (TimeoutException, ElementNotInteractableException): + self._showTrace("预约提交失败 !", self.TraceLevel.ERROR) + except Exception: + self._showTrace("预约提交失败 !", self.TraceLevel.ERROR) + + if not submit_reserve and have_hover_on_page: + view.refresh() + if reserve_success: + self._showTrace(f"用户 {ctx.username} 预约成功 !") + else: + self._showTrace(f"用户 {ctx.username} 预约失败 !", self.TraceLevel.ERROR) + return reserve_success + + def _selectSeatTime( + self, + view: ReserveView, + ) -> bool: + + ctx = self._ctx + exp_beg_tm_str = ctx.begin_time + exp_end_tm_str = ctx.end_time + exp_beg_mins = timeStrToMins(exp_beg_tm_str) + exp_end_mins = timeStrToMins(exp_end_tm_str) + act_beg_mins = exp_beg_mins + act_beg_tm_str = exp_beg_tm_str + act_end_mins = exp_end_mins + act_end_tm_str = exp_end_tm_str + + act_beg_mins = self._selectNearestTime( + view, + time_id="startTime", + time_type="开始时间", + target_time=exp_beg_mins, + max_time_diff=ctx.begin_max_diff, + prefer_earlier=ctx.begin_prefer_early, + ) + if act_beg_mins == -1: + return False + act_beg_tm_str = minsToTimeStr(act_beg_mins) + + if ctx.satisfy_duration: + exp_end_mins = self._calcEndTime(act_beg_mins, ctx.expect_duration) + exp_end_tm_str = minsToTimeStr(exp_end_mins) + self._showTrace( + f"需要满足期望预约持续时间: {ctx.expect_duration} 小时, " + f"根据开始时间 {act_beg_tm_str} 计算结束时间: {exp_end_tm_str}" + ) + + act_end_mins = self._selectNearestTime( + view, + time_id="end_time", + time_type="结束时间", + target_time=exp_end_mins, + max_time_diff=ctx.end_max_diff, + prefer_earlier=ctx.end_prefer_early, + ) + if act_end_mins == -1: + return False + act_end_tm_str = minsToTimeStr(act_end_mins) + + self._showTrace( + f"期望预约时间段: {exp_beg_tm_str} - {exp_end_tm_str}, " + f"实际预约时间段: {act_beg_tm_str} - {act_end_tm_str}" + ) + return True + + def _selectNearestTime( + self, + view: ReserveView, + time_id: str, + time_type: str, + target_time: int, + max_time_diff: int, + prefer_earlier: bool, + ) -> int: + + all_time_opts = view.getAvailableTimeOptions(time_id) + if not all_time_opts: + self._showTrace( + f"{time_type} 选择失败 ! : 当前未查询到可用时间", self.TraceLevel.ERROR + ) + return -1 + + best_opt, best_text, actual_diff, free_times = findBestTimeOption( + all_time_opts, target_time, max_time_diff, prefer_earlier, is_reserve=True + ) + if best_opt is not None: + best_opt.click() + abs_diff = abs(actual_diff) + if actual_diff < 0: + relation = f"早了 {abs_diff} 分钟" + elif actual_diff > 0: + relation = f"晚了 {abs_diff} 分钟" + else: + relation = f"正好等于 {time_type}" + self._showTrace( + f"选择距离期望 {time_type} 最近的 {best_text}, " + f"与期望 {time_type} 相比 {relation}" + ) + return target_time + actual_diff + + target_time_str = minsToTimeStr(target_time) + self._showTrace( + f"无法选择最近的 {time_type} {target_time_str}, " + f"所有可选时间与目标时间相差都超过 {max_time_diff} 分钟", + self.TraceLevel.WARNING, + ) + self._showTrace(f"当前可供预约的 {time_type} 有: {free_times}") + return -1 + + def _calcEndTime( + self, + begin_mins: int, + duration: int, + ) -> int: + + expect_end_mins = int(begin_mins + duration * 60) + if expect_end_mins > self.LIBRARY_CLOSE_MINS: + expect_end_mins = self.LIBRARY_CLOSE_MINS + self._showTrace( + f"预约持续时间 {duration} 小时, 超过最大预约时间 23:30, " + f"自动调整为 23:30", + self.TraceLevel.WARNING, + ) + return expect_end_mins diff --git a/src/pages/flows/__init__.py b/src/pages/flows/__init__.py new file mode 100644 index 0000000..c8f1d9f --- /dev/null +++ b/src/pages/flows/__init__.py @@ -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", +] diff --git a/src/pages/flows/_helpers.py b/src/pages/flows/_helpers.py new file mode 100644 index 0000000..1b2fcf5 --- /dev/null +++ b/src/pages/flows/_helpers.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +""" +Copyright (c) 2026 KenanZhu. +All rights reserved. + +This software is provided "as is", without any warranty of any kind. +You may use, modify, and distribute this file under the terms of the MIT License. +See the LICENSE file for details. +""" +from datetime import datetime + + +def timeStrToMins( + time_str: str, +) -> int: + + hour, minute = map(int, time_str.split(":")) + return hour * 60 + minute + + +def minsToTimeStr( + mins: int, +) -> str: + + hour, minute = divmod(int(mins), 60) + return f"{hour:02d}:{minute:02d}" + + +def findBestTimeOption( + time_options: list, + target_time: int, + max_time_diff: int, + prefer_earlier: bool, + is_reserve: bool = True, +) -> tuple: + """ + Find the best time option from available WebElement options. + + Returns: + (bestElement, bestText, actual_diff, freeTimesList) + or (None, None, None, freeTimesList) if no suitable option. + """ + + free_times = [] + best_time_diff = max_time_diff + best_actual_diff = None + best_time_opt = None + + for time_opt in time_options: + if is_reserve: + time_attr = time_opt.get_attribute("time") + if time_attr == "now": + now = datetime.now() + time_val = now.hour * 60 + now.minute + elif time_attr and time_attr.isdigit(): + time_val = int(time_attr) + else: + continue + else: + time_attr = time_opt.get_attribute("id") + if not (time_attr and time_attr.isdigit()): + continue + time_val = int(time_attr) + free_times.append( + time_opt.text.strip() + if not is_reserve + else minsToTimeStr(time_val) + ) + actual_diff = time_val - target_time + abs_diff = abs(actual_diff) + + if abs_diff < best_time_diff or ( + abs_diff == best_time_diff + and ( + (prefer_earlier and actual_diff <= 0) + or (not prefer_earlier and actual_diff >= 0) + ) + ): + best_time_diff = abs_diff + best_actual_diff = actual_diff + best_time_opt = time_opt + + if best_time_opt is not None: + return (best_time_opt, best_time_opt.text.strip(), best_actual_diff, free_times) + return (None, None, None, free_times) diff --git a/src/pages/services/CaptchaHandler.py b/src/pages/services/CaptchaHandler.py new file mode 100644 index 0000000..2aec267 --- /dev/null +++ b/src/pages/services/CaptchaHandler.py @@ -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 CaptchaHandler(MsgBase): + + def __init__( + self, + input_queue: queue.Queue, + output_queue: queue.Queue, + login_page: LoginPage, + ) -> None: + + super().__init__(input_queue, output_queue) + self._login_page = login_page + self._ocr = ddddocr.DdddOcr() + + def solveCaptcha( + self, + auto_captcha: bool = True, + ) -> str: + + max_attempts = 3 + for _ in range(max_attempts): + if auto_captcha: + captcha_text = self._autoRecognize() + else: + self._showTrace("用户未配置自动识别验证码, 请手动输入验证码 !", 20, no_log=True) + captcha_text = self._manualRecognize() + if captcha_text: + return captcha_text + else: + if not self._login_page.refreshCaptcha(): + return "" + self._showTrace( + f"验证码识别失败 {max_attempts} 次, 达到最大尝试次数 !", + self.TraceLevel.WARNING, + ) + return "" + + def _autoRecognize( + self, + ) -> str: + + try: + img_src = self._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 "" diff --git a/src/pages/services/RecordChecker.py b/src/pages/services/RecordChecker.py new file mode 100644 index 0000000..e00536a --- /dev/null +++ b/src/pages/services/RecordChecker.py @@ -0,0 +1,302 @@ +# -*- 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, + shell: MainShell, + ) -> None: + + super().__init__(input_queue, output_queue) + self._shell = shell + + @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 canReserve( + self, + date: str, + ) -> bool: + + 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: + + 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() + 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, + ) -> tuple[bool, dict]: + + 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() + 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, + record: dict, + ) -> bool: + + 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']}" + 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 + + def _getReserveRecord( + self, + 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 = self._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 _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 _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} diff --git a/src/pages/services/ReserveValidator.py b/src/pages/services/ReserveValidator.py new file mode 100644 index 0000000..5587839 --- /dev/null +++ b/src/pages/services/ReserveValidator.py @@ -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 + + +class ReserveValidator(MsgBase): + + def __init__( + self, + input_queue: queue.Queue, + output_queue: queue.Queue, + ) -> None: + + super().__init__(input_queue, output_queue) + + def validate( + 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 + + 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 = 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 + + 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 diff --git a/src/pages/services/__init__.py b/src/pages/services/__init__.py new file mode 100644 index 0000000..8545fc0 --- /dev/null +++ b/src/pages/services/__init__.py @@ -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.CaptchaHandler import CaptchaHandler +from pages.services.ReserveValidator import ReserveValidator +from pages.services.RecordChecker import RecordChecker + +__all__ = [ + "CaptchaHandler", + "ReserveValidator", + "RecordChecker", +] diff --git a/src/test_pages_refactor.py b/src/test_pages_refactor.py new file mode 100644 index 0000000..bb6f28e --- /dev/null +++ b/src/test_pages_refactor.py @@ -0,0 +1,162 @@ +# -*- 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. + +AutoLibrary 真实运行测试脚本。 +在 venv 中运行: + py -3 test_pages_refactor.py [--mode MODE] + +MODE 可选值 (默认 1): + 1 = 只预约 + 2 = 只签到 + 3 = 预约 + 签到 + 4 = 只续约 + 7 = 全部 (预约 + 签到 + 续约) +""" +import os +import sys +import argparse + +SRC = os.path.dirname(os.path.abspath(__file__)) +if SRC not in sys.path: + sys.path.insert(0, SRC) + + +def getAppConfigDir() -> str: + appData = os.environ.get("APPDATA", "") + if not appData: + appData = os.path.join(os.path.expanduser("~"), "AppData", "Roaming") + return os.path.join(appData, "AutoLibrary", "configs") + + +def main(): + parser = argparse.ArgumentParser(description="AutoLibrary 真实运行测试") + parser.add_argument( + "--mode", type=int, default=1, + help="运行模式 bitmask: 1=预约 2=签到 4=续约 (默认 1)" + ) + parser.add_argument( + "--group", type=int, default=0, + help="只运行第 N 个启用的任务组 (0=全部, 默认 0)" + ) + parser.add_argument( + "--headless", action="store_true", + help="使用 headless 模式运行浏览器" + ) + args = parser.parse_args() + + # ---- 1. 初始化 ConfigManager ---- + from managers.config.ConfigManager import instance as configInstance + from managers.config.ConfigUtils import ConfigUtils + from utils.JSONReader import JSONReader + + configDir = getAppConfigDir() + if not os.path.isdir(configDir): + print(f"[FAIL] 配置目录不存在: {configDir}") + print("请先启动一次 AutoLibrary GUI 以生成配置文件。") + return 1 + + try: + configInstance(configDir) + except ValueError: + pass + + configPaths = ConfigUtils.getAutomationConfigPaths() + runPath = configPaths.get("run") + userPath = configPaths.get("user") + + if not runPath or not os.path.isfile(runPath): + print(f"[FAIL] run.json 不存在: {runPath}") + return 1 + if not userPath or not os.path.isfile(userPath): + print(f"[FAIL] user.json 不存在: {userPath}") + return 1 + + print(f"[INFO] run : {runPath}") + print(f"[INFO] user : {userPath}") + + # ---- 2. 加载配置 ---- + runConfig = JSONReader(runPath).data() + userConfig = JSONReader(userPath).data() + + if args.mode is not None: + runConfig["mode"]["run_mode"] = args.mode + if args.headless: + runConfig["web_driver"]["headless"] = True + + groups = userConfig.get("groups", []) + if not groups: + print("[FAIL] user.json 中没有任务组") + return 1 + + print(f"[INFO] 运行模式: {runConfig['mode']['run_mode']}") + if args.headless: + print("[INFO] Headless 模式已启用") + + # ---- 3. 创建 AutoLib 并运行 ---- + from pages.AutoLibPages import AutoLibPages + import queue + import threading + + for gi, group in enumerate(groups): + if args.group > 0 and gi + 1 != args.group: + continue + if not group.get("enabled", True): + print(f"[SKIP] 任务组 {gi + 1} '{group.get('name', '未命名')}' 已禁用") + continue + + users = group.get("users", []) + enabledUsers = [u for u in users if u.get("enabled", True)] + if not enabledUsers: + print(f"[SKIP] 任务组 {gi + 1} 没有启用的用户") + continue + + print(f"\n{'=' * 60}") + print(f"任务组 {gi + 1}/{len(groups)}: '{group.get('name', '未命名')}'") + print(f"启用的用户: {len(enabledUsers)}/{len(users)}") + print(f"{'=' * 60}") + + outputQueue = queue.Queue() + stopConsumer = threading.Event() + traceLines = [] + + def consumeTrace(): + while not stopConsumer.is_set(): + try: + msg = outputQueue.get(timeout=0.3) + traceLines.append(msg) + print(msg) + except queue.Empty: + continue + + consumer = threading.Thread(target=consumeTrace, daemon=True) + consumer.start() + + try: + autoLib = AutoLibPages( + input_queue=queue.Queue(), + output_queue=outputQueue, + run_config=runConfig, + ) + autoLib.run({"users": enabledUsers}) + autoLib.close() + except Exception as e: + print(f"[FAIL] 运行异常: {e}") + import traceback + traceback.print_exc() + return 1 + finally: + stopConsumer.set() + consumer.join(timeout=2) + + print("\n[OK] 测试完成") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From a6bc103c73a549aca4c934799adecb944c6dc272 Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Tue, 26 May 2026 13:41:55 +0800 Subject: [PATCH 2/8] =?UTF-8?q?refactor(pages):=20=E6=8B=86=E5=88=86=20=5F?= =?UTF-8?q?dialogs=20=E4=B8=BA=E7=8B=AC=E7=AB=8B=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E6=96=87=E4=BB=B6=EF=BC=8C=E8=A7=A3=E8=80=A6=20Service=20?= =?UTF-8?q?=E6=9E=84=E9=80=A0=E5=87=BD=E6=95=B0=EF=BC=8C=E6=B6=88=E9=99=A4?= =?UTF-8?q?=20PageObject=20=E9=87=8D=E5=A4=8D=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 _dialogs.py 拆分为 pages/components/ 下的独立文件,Overlay 基类同步移入 - CaptchaHandler / RecordChecker 构造函数不再持有 PageObject,改为方法参数注入 - LoginPage.login() 直接接收 auto_captcha 参数,简化 captcha_solver 调用链 - SeatMapOverlay.selectSeat 引入两层查找:先按 ID 直查,失败后遍历匹配 - 移除 ReserveView 中与 Dialog/Overlay 重复的方法(selectSeat、getAvailableTimeOptions) - AutoLibPages 拆分 __initPagesServices / __initPagesFlows - 修复 RecordsView.MORE_BTN 选择器被错误 snake_case 化(more_btn → moreBtn) Co-Authored-By: Claude Opus 4.7 --- src/pages/AutoLibPages.py | 14 +- src/pages/LoginPage.py | 5 +- src/pages/RecordsView.py | 2 +- src/pages/ReserveView.py | 69 +--- src/pages/__init__.py | 12 +- src/pages/_dialogs.py | 302 ------------------ src/pages/components/CheckinResultDialog.py | 75 +++++ .../{_overlay.py => components/Overlay.py} | 0 src/pages/components/RenewDialog.py | 99 ++++++ src/pages/components/ReserveResultDialog.py | 81 +++++ src/pages/components/SeatMapOverlay.py | 74 +++++ src/pages/components/TimeSelectDialog.py | 54 ++++ src/pages/components/__init__.py | 22 ++ src/pages/flows/CheckinFlow.py | 2 +- src/pages/flows/RenewFlow.py | 2 +- src/pages/flows/ReserveFlow.py | 34 +- src/pages/services/CaptchaHandler.py | 52 +-- src/pages/services/RecordChecker.py | 231 +++++++------- src/pages/services/ReserveValidator.py | 58 ++-- 19 files changed, 608 insertions(+), 580 deletions(-) delete mode 100644 src/pages/_dialogs.py create mode 100644 src/pages/components/CheckinResultDialog.py rename src/pages/{_overlay.py => components/Overlay.py} (100%) create mode 100644 src/pages/components/RenewDialog.py create mode 100644 src/pages/components/ReserveResultDialog.py create mode 100644 src/pages/components/SeatMapOverlay.py create mode 100644 src/pages/components/TimeSelectDialog.py create mode 100644 src/pages/components/__init__.py diff --git a/src/pages/AutoLibPages.py b/src/pages/AutoLibPages.py index a200a10..faab6e7 100644 --- a/src/pages/AutoLibPages.py +++ b/src/pages/AutoLibPages.py @@ -9,7 +9,6 @@ See the LICENSE file for details. """ import os import queue - from selenium import webdriver from selenium.common.exceptions import ( TimeoutException, @@ -193,12 +192,10 @@ class AutoLibPages(MsgBase): self.__captcha_handler = CaptchaHandler( input_queue=self._input_queue, output_queue=self._output_queue, - login_page=self.__login_page, ) self.__record_checker = RecordChecker( input_queue=self._input_queue, output_queue=self._output_queue, - shell=self.__shell, ) self.__reserve_validator = ReserveValidator( input_queue=self._input_queue, @@ -245,7 +242,8 @@ class AutoLibPages(MsgBase): if not self.__login_page.login( username, password, - captcha_solver=lambda: self.__captcha_handler.solveCaptcha(auto_captcha), + captcha_solver=self.__captcha_handler.solveCaptcha, + auto_captcha=auto_captcha, tracer=self._showTrace, log_level=self.TraceLevel, max_attempts=login_config.get("max_attempt", 3), @@ -259,7 +257,7 @@ class AutoLibPages(MsgBase): } # reserve if run_mode["auto_reserve"]: - if self.__record_checker.canReserve(reserve_info.get("date")): + if self.__record_checker.canReserve(self.__shell, reserve_info.get("date")): if self.__reserve_validator.validate(reserve_info): ctx = ReserveContext( username=username, @@ -289,7 +287,7 @@ class AutoLibPages(MsgBase): # checkin last_result: int = result if run_mode["auto_checkin"] and last_result != 1: - if self.__record_checker.canCheckin(): + if self.__record_checker.canCheckin(self.__shell): if self.__checkin_flow.execute(username): result = 0 else: @@ -303,11 +301,11 @@ class AutoLibPages(MsgBase): # renewal last_result = result if run_mode["auto_renewal"] and last_result != 1: - can_renew, record = self.__record_checker.canRenew() + can_renew, record = self.__record_checker.canRenew(self.__shell) if can_renew: renew_info: dict = reserve_info.get("renew_time", {}) if self.__renew_flow.execute(username, record, renew_info): - if self.__record_checker.postRenewCheck(record): + if self.__record_checker.postRenewCheck(self.__shell, record): self._showTrace(f"用户 {username} 续约成功 !") result = 0 else: diff --git a/src/pages/LoginPage.py b/src/pages/LoginPage.py index 24c9b1b..f4f231d 100644 --- a/src/pages/LoginPage.py +++ b/src/pages/LoginPage.py @@ -175,7 +175,8 @@ class LoginPage: self, username: str, password: str, - captcha_solver: Callable[[], str], + captcha_solver: Callable[["LoginPage", bool], str], + auto_captcha: bool, tracer: Callable[..., None], log_level: type, max_attempts: int = 5, @@ -189,7 +190,7 @@ class LoginPage: ) if not self.fillCredentials(username, password): continue - captcha_text = captcha_solver() + captcha_text = captcha_solver(self, auto_captcha) if not captcha_text: continue if not self.fillCaptcha(captcha_text): diff --git a/src/pages/RecordsView.py b/src/pages/RecordsView.py index d4f838c..dc42faa 100644 --- a/src/pages/RecordsView.py +++ b/src/pages/RecordsView.py @@ -22,7 +22,7 @@ from selenium.common.exceptions import ( class RecordsView: RECORDS_LIST = (By.CSS_SELECTOR, ".myReserveList > dl:not(#moreBlock)") - MORE_BTN = (By.ID, "more_btn") + MORE_BTN = (By.ID, "moreBtn") RECORD_TIME = (By.CSS_SELECTOR, "dt") RECORD_INFO = (By.CSS_SELECTOR, "a") diff --git a/src/pages/ReserveView.py b/src/pages/ReserveView.py index 13c4cb7..f0710d8 100644 --- a/src/pages/ReserveView.py +++ b/src/pages/ReserveView.py @@ -15,12 +15,11 @@ from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.remote.webdriver import WebDriver from selenium.common.exceptions import ( ElementNotInteractableException, - NoSuchElementException, - StaleElementReferenceException, TimeoutException, ) -from pages._dialogs import SeatMapOverlay, ReserveResultDialog +from pages.components.SeatMapOverlay import SeatMapOverlay +from pages.components.ReserveResultDialog import ReserveResultDialog class ReserveView: @@ -40,17 +39,8 @@ class ReserveView: FIND_ROOM_BTN = (By.ID, "findRoom") ROOM_BTN_FMT = "room_{room}" - SEAT_LAYOUT = (By.ID, "seatLayout") - SEAT_ITEMS = (By.CSS_SELECTOR, "li[id^='seat_']") RESERVE_BTN = (By.ID, "reserveBtn") - START_TIME_OPTS = (By.CSS_SELECTOR, "#startTime ul li a") - END_TIME_OPTS = (By.CSS_SELECTOR, "#endTime ul li a") - - RESULT_DIALOG = (By.CLASS_NAME, "layoutSeat") - RESULT_TITLE = (By.CSS_SELECTOR, ".layoutSeat dt") - RESULT_DETAIL = (By.CSS_SELECTOR, ".layoutSeat dd") - FLOOR_MAP = {"2": "二层", "3": "三层", "4": "四层", "5": "五层"} ROOM_MAP = { "1": "二层内环", "2": "二层西区", "3": "三层内环", "4": "三层外环", @@ -138,41 +128,6 @@ class ReserveView: return SeatMapOverlay(self._driver) - def selectSeat( - self, - seat_id: str, - ) -> str | None: - - try: - WebDriverWait(self._driver, 2).until( - EC.presence_of_element_located(self.SEAT_LAYOUT) - ) - WebDriverWait(self._driver, 2).until( - EC.presence_of_all_elements_located(self.SEAT_ITEMS) - ) - except TimeoutException: - return None - except Exception: - return None - try: - all_seats = self._driver.find_elements(*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, - StaleElementReferenceException, ElementNotInteractableException): - return None - except Exception: - return None - def submitReserve( self, ) -> bool: @@ -193,26 +148,6 @@ class ReserveView: return ReserveResultDialog(self._driver) - def getAvailableTimeOptions( - self, - time_id: str, - ) -> list: - - try: - WebDriverWait(self._driver, 2).until( - EC.presence_of_all_elements_located( - (By.CSS_SELECTOR, f"#{time_id} ul li a") - ) - ) - except TimeoutException: - return [] - except Exception: - return [] - return self._driver.find_elements( - By.CSS_SELECTOR, - f"#{time_id} ul li a", - ) - def refresh( self, ) -> None: diff --git a/src/pages/__init__.py b/src/pages/__init__.py index 36f0450..391cc81 100644 --- a/src/pages/__init__.py +++ b/src/pages/__init__.py @@ -12,13 +12,11 @@ from pages.LoginPage import LoginPage from pages.MainShell import MainShell from pages.ReserveView import ReserveView from pages.RecordsView import RecordsView -from pages._dialogs import ( - SeatMapOverlay, - TimeSelectDialog, - ReserveResultDialog, - CheckinResultDialog, - RenewDialog, -) +from pages.components.SeatMapOverlay import SeatMapOverlay +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__ = [ "AutoLibPages", diff --git a/src/pages/_dialogs.py b/src/pages/_dialogs.py deleted file mode 100644 index dc52602..0000000 --- a/src/pages/_dialogs.py +++ /dev/null @@ -1,302 +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. -""" -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.remote.webelement import WebElement -from selenium.webdriver.support.ui import WebDriverWait -from selenium.webdriver.support import expected_conditions as EC - -from pages._overlay import Overlay - - -class SeatMapOverlay(Overlay): - """ - 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: - 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") - self._waitClickable((By.TAG_NAME, "a")) - seat_link.click() - return seat_link.get_attribute("title") - return None - except (NoSuchElementException, TimeoutException, - ElementNotInteractableException, StaleElementReferenceException): - return None - except Exception: - return None - - -class TimeSelectDialog(Overlay): - """ - 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, - ) -> None: - - super().__init__(driver, self.ROOT, auto_close_on_exit=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", - ) - - -class ReserveResultDialog(Overlay): - """ - 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 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 [] - - def _titleLocator( - self, - ) -> tuple: - - return (By.CSS_SELECTOR, ".layoutSeat dt") - - -class CheckinResultDialog(Overlay): - """ - 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 - - -class RenewDialog(Overlay): - """ - 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 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 diff --git a/src/pages/components/CheckinResultDialog.py b/src/pages/components/CheckinResultDialog.py new file mode 100644 index 0000000..edd4d48 --- /dev/null +++ b/src/pages/components/CheckinResultDialog.py @@ -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.Overlay import Overlay + + +class CheckinResultDialog(Overlay): + """ + 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 diff --git a/src/pages/_overlay.py b/src/pages/components/Overlay.py similarity index 100% rename from src/pages/_overlay.py rename to src/pages/components/Overlay.py diff --git a/src/pages/components/RenewDialog.py b/src/pages/components/RenewDialog.py new file mode 100644 index 0000000..7eb36ac --- /dev/null +++ b/src/pages/components/RenewDialog.py @@ -0,0 +1,99 @@ +# -*- 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.Overlay import Overlay + + +class RenewDialog(Overlay): + """ + 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 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 diff --git a/src/pages/components/ReserveResultDialog.py b/src/pages/components/ReserveResultDialog.py new file mode 100644 index 0000000..c85cab1 --- /dev/null +++ b/src/pages/components/ReserveResultDialog.py @@ -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.Overlay import Overlay + + +class ReserveResultDialog(Overlay): + """ + 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 getTitle( + self, + ) -> str: + + try: + return self._find(*self._title_locator()).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 [] + + def _title_locator( + self, + ) -> tuple: + + return (By.CSS_SELECTOR, ".layoutSeat dt") diff --git a/src/pages/components/SeatMapOverlay.py b/src/pages/components/SeatMapOverlay.py new file mode 100644 index 0000000..42f1137 --- /dev/null +++ b/src/pages/components/SeatMapOverlay.py @@ -0,0 +1,74 @@ +# -*- 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.Overlay import Overlay + + +class SeatMapOverlay(Overlay): + """ + 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") + self._waitClickable((By.TAG_NAME, "a")) + 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") + self._waitClickable((By.TAG_NAME, "a")) + seat_link.click() + return seat_link.get_attribute("title") + return None + except (NoSuchElementException, TimeoutException, + ElementNotInteractableException, StaleElementReferenceException): + return None + except Exception: + return None diff --git a/src/pages/components/TimeSelectDialog.py b/src/pages/components/TimeSelectDialog.py new file mode 100644 index 0000000..0782aef --- /dev/null +++ b/src/pages/components/TimeSelectDialog.py @@ -0,0 +1,54 @@ +# -*- 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, + 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.Overlay import Overlay + + +class TimeSelectDialog(Overlay): + """ + 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, + ) -> None: + + super().__init__(driver, self.ROOT, auto_close_on_exit=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", + ) diff --git a/src/pages/components/__init__.py b/src/pages/components/__init__.py new file mode 100644 index 0000000..f0ac233 --- /dev/null +++ b/src/pages/components/__init__.py @@ -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.SeatMapOverlay import SeatMapOverlay +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", + "TimeSelectDialog", + "ReserveResultDialog", + "CheckinResultDialog", + "RenewDialog", +] diff --git a/src/pages/flows/CheckinFlow.py b/src/pages/flows/CheckinFlow.py index 299ab04..fc0a7c6 100644 --- a/src/pages/flows/CheckinFlow.py +++ b/src/pages/flows/CheckinFlow.py @@ -17,7 +17,7 @@ from selenium.webdriver.remote.webdriver import WebDriver from base.MsgBase import MsgBase from pages.MainShell import MainShell -from pages._dialogs import CheckinResultDialog +from pages.components.CheckinResultDialog import CheckinResultDialog class CheckinFlow(MsgBase): diff --git a/src/pages/flows/RenewFlow.py b/src/pages/flows/RenewFlow.py index 35b48f1..abde378 100644 --- a/src/pages/flows/RenewFlow.py +++ b/src/pages/flows/RenewFlow.py @@ -18,7 +18,7 @@ from selenium.webdriver.remote.webdriver import WebDriver from base.MsgBase import MsgBase from pages.MainShell import MainShell -from pages._dialogs import RenewDialog +from pages.components.RenewDialog import RenewDialog from pages.flows._helpers import ( timeStrToMins, minsToTimeStr, diff --git a/src/pages/flows/ReserveFlow.py b/src/pages/flows/ReserveFlow.py index 037e36c..c3313bf 100644 --- a/src/pages/flows/ReserveFlow.py +++ b/src/pages/flows/ReserveFlow.py @@ -26,7 +26,8 @@ from pages.flows._helpers import ( findBestTimeOption, ) from pages.ReserveView import ReserveView -from pages._dialogs import ReserveResultDialog +from pages.components.ReserveResultDialog import ReserveResultDialog +from pages.components.TimeSelectDialog import TimeSelectDialog @dataclass @@ -82,31 +83,27 @@ class ReserveFlow(MsgBase): except Exception as e: self._showTrace(f"加载预约选座页面失败 ! : {e}", self.TraceLevel.ERROR) return False - if not view.selectDate(ctx.date): self._showTrace(f"选择日期失败 ! : {ctx.date} 不可用", self.TraceLevel.ERROR) return False self._showTrace(f"日期 {ctx.date} 选择成功 !") - if not view.selectPlace("1"): self._showTrace("选择预约场所失败 ! : 图书馆 不可用", self.TraceLevel.ERROR) return False self._showTrace("预约场所 图书馆 选择成功 !") - if not view.selectFloor(ctx.floor): display_floor = ReserveView.FLOOR_MAP.get(ctx.floor, ctx.floor) self._showTrace(f"选择楼层失败 ! : {display_floor} 不可用", self.TraceLevel.ERROR) return False self._showTrace(f"楼层 {ReserveView.FLOOR_MAP.get(ctx.floor)} 选择成功 !") - if not view.selectRoom(ctx.room): display_room = ReserveView.ROOM_MAP.get(ctx.room, ctx.room) self._showTrace(f"选择房间失败 ! : {display_room} 不可用", self.TraceLevel.ERROR) return False self._showTrace(f"房间 {ReserveView.ROOM_MAP.get(ctx.room)} 选择成功 !") have_hover_on_page = True - - seat_status = view.selectSeat(ctx.seat_id) + seat_map = view.openSeatMap() + seat_status = seat_map.selectSeat(ctx.seat_id) if seat_status is None: self._showTrace( f"座位 {ctx.seat_id} 在该楼层区域中不存在, 请检查座位号是否正确", @@ -114,8 +111,8 @@ class ReserveFlow(MsgBase): ) else: self._showTrace(f"座位 {ctx.seat_id} 选择成功 ! : 当前状态 - '{seat_status}'") - - select_time_ok = self._selectSeatTime(view) + time_dialog = TimeSelectDialog(self._driver) + select_time_ok = self._selectSeatTime(time_dialog) if not select_time_ok: self._showTrace("选择时间失败 !", self.TraceLevel.ERROR) else: @@ -149,7 +146,6 @@ class ReserveFlow(MsgBase): 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: @@ -160,7 +156,7 @@ class ReserveFlow(MsgBase): def _selectSeatTime( self, - view: ReserveView, + time_dialog: TimeSelectDialog, ) -> bool: ctx = self._ctx @@ -172,9 +168,8 @@ class ReserveFlow(MsgBase): act_beg_tm_str = exp_beg_tm_str act_end_mins = exp_end_mins act_end_tm_str = exp_end_tm_str - act_beg_mins = self._selectNearestTime( - view, + time_dialog, time_id="startTime", time_type="开始时间", target_time=exp_beg_mins, @@ -184,7 +179,6 @@ class ReserveFlow(MsgBase): if act_beg_mins == -1: return False act_beg_tm_str = minsToTimeStr(act_beg_mins) - if ctx.satisfy_duration: exp_end_mins = self._calcEndTime(act_beg_mins, ctx.expect_duration) exp_end_tm_str = minsToTimeStr(exp_end_mins) @@ -192,10 +186,9 @@ class ReserveFlow(MsgBase): f"需要满足期望预约持续时间: {ctx.expect_duration} 小时, " f"根据开始时间 {act_beg_tm_str} 计算结束时间: {exp_end_tm_str}" ) - act_end_mins = self._selectNearestTime( - view, - time_id="end_time", + time_dialog, + time_id="endTime", time_type="结束时间", target_time=exp_end_mins, max_time_diff=ctx.end_max_diff, @@ -204,7 +197,6 @@ class ReserveFlow(MsgBase): if act_end_mins == -1: return False act_end_tm_str = minsToTimeStr(act_end_mins) - self._showTrace( f"期望预约时间段: {exp_beg_tm_str} - {exp_end_tm_str}, " f"实际预约时间段: {act_beg_tm_str} - {act_end_tm_str}" @@ -213,7 +205,7 @@ class ReserveFlow(MsgBase): def _selectNearestTime( self, - view: ReserveView, + time_dialog: TimeSelectDialog, time_id: str, time_type: str, target_time: int, @@ -221,13 +213,12 @@ class ReserveFlow(MsgBase): prefer_earlier: bool, ) -> int: - all_time_opts = view.getAvailableTimeOptions(time_id) + all_time_opts = time_dialog.getTimeOptions(time_id) if not all_time_opts: self._showTrace( f"{time_type} 选择失败 ! : 当前未查询到可用时间", self.TraceLevel.ERROR ) return -1 - best_opt, best_text, actual_diff, free_times = findBestTimeOption( all_time_opts, target_time, max_time_diff, prefer_earlier, is_reserve=True ) @@ -245,7 +236,6 @@ class ReserveFlow(MsgBase): f"与期望 {time_type} 相比 {relation}" ) return target_time + actual_diff - target_time_str = minsToTimeStr(target_time) self._showTrace( f"无法选择最近的 {time_type} {target_time_str}, " diff --git a/src/pages/services/CaptchaHandler.py b/src/pages/services/CaptchaHandler.py index 2aec267..1e941bd 100644 --- a/src/pages/services/CaptchaHandler.py +++ b/src/pages/services/CaptchaHandler.py @@ -26,42 +26,18 @@ class CaptchaHandler(MsgBase): self, input_queue: queue.Queue, output_queue: queue.Queue, - login_page: LoginPage, ) -> None: super().__init__(input_queue, output_queue) - self._login_page = login_page self._ocr = ddddocr.DdddOcr() - def solveCaptcha( - self, - auto_captcha: bool = True, - ) -> str: - - max_attempts = 3 - for _ in range(max_attempts): - if auto_captcha: - captcha_text = self._autoRecognize() - else: - self._showTrace("用户未配置自动识别验证码, 请手动输入验证码 !", 20, no_log=True) - captcha_text = self._manualRecognize() - if captcha_text: - return captcha_text - else: - if not self._login_page.refreshCaptcha(): - return "" - self._showTrace( - f"验证码识别失败 {max_attempts} 次, 达到最大尝试次数 !", - self.TraceLevel.WARNING, - ) - return "" - def _autoRecognize( self, + login_page: LoginPage, ) -> str: try: - img_src = self._login_page.getCaptchaImageSrc() + 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) @@ -99,3 +75,27 @@ class CaptchaHandler(MsgBase): 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 "" diff --git a/src/pages/services/RecordChecker.py b/src/pages/services/RecordChecker.py index e00536a..106ad33 100644 --- a/src/pages/services/RecordChecker.py +++ b/src/pages/services/RecordChecker.py @@ -29,11 +29,9 @@ class RecordChecker(MsgBase): self, input_queue: queue.Queue, output_queue: queue.Queue, - shell: MainShell, ) -> None: super().__init__(input_queue, output_queue) - self._shell = shell @staticmethod def _formatDiffTime( @@ -45,119 +43,9 @@ class RecordChecker(MsgBase): seconds = int(seconds % 60) return f"{hours} 时 {minutes} 分 {seconds} 秒" - def canReserve( - self, - date: str, - ) -> bool: - - 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: - - 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() - 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, - ) -> tuple[bool, dict]: - - 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() - 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, - record: dict, - ) -> bool: - - 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']}" - 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 - def _getReserveRecord( self, + shell: MainShell, wanted_date: str, wanted_status: str, ) -> dict | None: @@ -173,7 +61,7 @@ class RecordChecker(MsgBase): checked_count = 0 max_check_times = 6 - records_view = self._shell.gotoRecordsView() + records_view = shell.gotoRecordsView() for _ in range(max_check_times): reservations = records_view.loadRecords() if reservations is None: @@ -300,3 +188,118 @@ class RecordChecker(MsgBase): elif "图书馆" in info.text: location = info.text.strip() return {"location": location, "status": status} + + 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 diff --git a/src/pages/services/ReserveValidator.py b/src/pages/services/ReserveValidator.py index 5587839..c458dc0 100644 --- a/src/pages/services/ReserveValidator.py +++ b/src/pages/services/ReserveValidator.py @@ -25,35 +25,6 @@ class ReserveValidator(MsgBase): super().__init__(input_queue, output_queue) - def validate( - 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 - def _containRequiredInfo( self, reserve_info: dict, @@ -219,3 +190,32 @@ class ReserveValidator(MsgBase): ) reserve_info["end_time"]["time"] = minsToTimeStr(begin_mins + 8 * 60) return True + + def validate( + 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 \ No newline at end of file From 280028259f3703ed4144ed73eed50e45a677d17d Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Tue, 26 May 2026 18:01:25 +0800 Subject: [PATCH 3/8] =?UTF-8?q?refactor(pages):=20=E5=B0=86=20LoginPage=20?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E5=9B=9E=E8=B0=83=E4=BB=8E=E6=96=B9=E6=B3=95?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E6=94=B9=E4=B8=BA=E6=9E=84=E9=80=A0=E5=99=A8?= =?UTF-8?q?=E6=B3=A8=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 消除 login() 方法签名中的 tracer/log_level 参数,通过构造器可选注入 tracer 统一日志模式,避免 Page Object 对外暴露 MsgBase 内部细节。 Co-Authored-By: Claude Opus 4.7 --- src/pages/AutoLibPages.py | 4 +- src/pages/LoginPage.py | 31 ++++--- src/test_pages_refactor.py | 162 ------------------------------------- 3 files changed, 21 insertions(+), 176 deletions(-) delete mode 100644 src/test_pages_refactor.py diff --git a/src/pages/AutoLibPages.py b/src/pages/AutoLibPages.py index faab6e7..3c73ff0 100644 --- a/src/pages/AutoLibPages.py +++ b/src/pages/AutoLibPages.py @@ -164,7 +164,7 @@ class AutoLibPages(MsgBase): self._showTrace("未配置图书馆参数 !", self.TraceLevel.ERROR) return False url: str = lib_config.get("host_url") + lib_config.get("login_url") - self.__login_page = LoginPage(self.__driver) + self.__login_page = LoginPage(self.__driver, tracer=self._showTrace) self.__driver.set_page_load_timeout(5) try: self.__driver.get(url) @@ -244,8 +244,6 @@ class AutoLibPages(MsgBase): password, captcha_solver=self.__captcha_handler.solveCaptcha, auto_captcha=auto_captcha, - tracer=self._showTrace, - log_level=self.TraceLevel, max_attempts=login_config.get("max_attempt", 3), ): return 1 diff --git a/src/pages/LoginPage.py b/src/pages/LoginPage.py index f4f231d..db34756 100644 --- a/src/pages/LoginPage.py +++ b/src/pages/LoginPage.py @@ -7,7 +7,7 @@ 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 +from typing import Callable, Optional from selenium.common.exceptions import ( ElementNotInteractableException, @@ -37,9 +37,21 @@ class LoginPage: 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, @@ -177,16 +189,13 @@ class LoginPage: password: str, captcha_solver: Callable[["LoginPage", bool], str], auto_captcha: bool, - tracer: Callable[..., None], - log_level: type, max_attempts: int = 5, ) -> bool: - ERR = log_level.ERROR for attempt in range(max_attempts): - tracer( + self._trace( f"用户 {username} 第 {attempt + 1} 次尝试登录......", - 20, no_log=True, + no_log=True, ) if not self.fillCredentials(username, password): continue @@ -195,16 +204,16 @@ class LoginPage: continue if not self.fillCaptcha(captcha_text): continue - tracer("尝试登录...", 20, no_log=True) + self._trace("尝试登录...", no_log=True) if not self.clickLogin(): continue if self.waitLoginSuccess(): - tracer(f"用户 {username} 第 {attempt + 1} 次登录成功 !") + self._trace(f"用户 {username} 第 {attempt + 1} 次登录成功 !") return True else: - err_msg = ( + self._trace( "登录页面加载失败 ! : " - "用户账号或者密码错误/验证码错误, 具体以页面提示为准" + "用户账号或者密码错误/验证码错误, 具体以页面提示为准", + level=40, ) - tracer(err_msg, ERR) return False diff --git a/src/test_pages_refactor.py b/src/test_pages_refactor.py deleted file mode 100644 index bb6f28e..0000000 --- a/src/test_pages_refactor.py +++ /dev/null @@ -1,162 +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. - -AutoLibrary 真实运行测试脚本。 -在 venv 中运行: - py -3 test_pages_refactor.py [--mode MODE] - -MODE 可选值 (默认 1): - 1 = 只预约 - 2 = 只签到 - 3 = 预约 + 签到 - 4 = 只续约 - 7 = 全部 (预约 + 签到 + 续约) -""" -import os -import sys -import argparse - -SRC = os.path.dirname(os.path.abspath(__file__)) -if SRC not in sys.path: - sys.path.insert(0, SRC) - - -def getAppConfigDir() -> str: - appData = os.environ.get("APPDATA", "") - if not appData: - appData = os.path.join(os.path.expanduser("~"), "AppData", "Roaming") - return os.path.join(appData, "AutoLibrary", "configs") - - -def main(): - parser = argparse.ArgumentParser(description="AutoLibrary 真实运行测试") - parser.add_argument( - "--mode", type=int, default=1, - help="运行模式 bitmask: 1=预约 2=签到 4=续约 (默认 1)" - ) - parser.add_argument( - "--group", type=int, default=0, - help="只运行第 N 个启用的任务组 (0=全部, 默认 0)" - ) - parser.add_argument( - "--headless", action="store_true", - help="使用 headless 模式运行浏览器" - ) - args = parser.parse_args() - - # ---- 1. 初始化 ConfigManager ---- - from managers.config.ConfigManager import instance as configInstance - from managers.config.ConfigUtils import ConfigUtils - from utils.JSONReader import JSONReader - - configDir = getAppConfigDir() - if not os.path.isdir(configDir): - print(f"[FAIL] 配置目录不存在: {configDir}") - print("请先启动一次 AutoLibrary GUI 以生成配置文件。") - return 1 - - try: - configInstance(configDir) - except ValueError: - pass - - configPaths = ConfigUtils.getAutomationConfigPaths() - runPath = configPaths.get("run") - userPath = configPaths.get("user") - - if not runPath or not os.path.isfile(runPath): - print(f"[FAIL] run.json 不存在: {runPath}") - return 1 - if not userPath or not os.path.isfile(userPath): - print(f"[FAIL] user.json 不存在: {userPath}") - return 1 - - print(f"[INFO] run : {runPath}") - print(f"[INFO] user : {userPath}") - - # ---- 2. 加载配置 ---- - runConfig = JSONReader(runPath).data() - userConfig = JSONReader(userPath).data() - - if args.mode is not None: - runConfig["mode"]["run_mode"] = args.mode - if args.headless: - runConfig["web_driver"]["headless"] = True - - groups = userConfig.get("groups", []) - if not groups: - print("[FAIL] user.json 中没有任务组") - return 1 - - print(f"[INFO] 运行模式: {runConfig['mode']['run_mode']}") - if args.headless: - print("[INFO] Headless 模式已启用") - - # ---- 3. 创建 AutoLib 并运行 ---- - from pages.AutoLibPages import AutoLibPages - import queue - import threading - - for gi, group in enumerate(groups): - if args.group > 0 and gi + 1 != args.group: - continue - if not group.get("enabled", True): - print(f"[SKIP] 任务组 {gi + 1} '{group.get('name', '未命名')}' 已禁用") - continue - - users = group.get("users", []) - enabledUsers = [u for u in users if u.get("enabled", True)] - if not enabledUsers: - print(f"[SKIP] 任务组 {gi + 1} 没有启用的用户") - continue - - print(f"\n{'=' * 60}") - print(f"任务组 {gi + 1}/{len(groups)}: '{group.get('name', '未命名')}'") - print(f"启用的用户: {len(enabledUsers)}/{len(users)}") - print(f"{'=' * 60}") - - outputQueue = queue.Queue() - stopConsumer = threading.Event() - traceLines = [] - - def consumeTrace(): - while not stopConsumer.is_set(): - try: - msg = outputQueue.get(timeout=0.3) - traceLines.append(msg) - print(msg) - except queue.Empty: - continue - - consumer = threading.Thread(target=consumeTrace, daemon=True) - consumer.start() - - try: - autoLib = AutoLibPages( - input_queue=queue.Queue(), - output_queue=outputQueue, - run_config=runConfig, - ) - autoLib.run({"users": enabledUsers}) - autoLib.close() - except Exception as e: - print(f"[FAIL] 运行异常: {e}") - import traceback - traceback.print_exc() - return 1 - finally: - stopConsumer.set() - consumer.join(timeout=2) - - print("\n[OK] 测试完成") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) From caa563e7708e7e314a0031010f523c244941c9e0 Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Tue, 26 May 2026 20:52:52 +0800 Subject: [PATCH 4/8] =?UTF-8?q?refactor(pages):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E5=91=BD=E5=90=8D=E8=A7=84=E8=8C=83=E5=B9=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20SeatMapOverlay=20=E5=85=83=E7=B4=A0=E7=AD=89=E5=BE=85?= =?UTF-8?q?=E7=9B=AE=E6=A0=87=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AutoLibPages → AutoLib(移除实现细节后缀) - ReserveValidator → ReserveChecker(与 RecordChecker 命名一致) - CaptchaHandler → CaptchaSolver(语义更准确,职责是"求解"验证码) - ReserveChecker.validate() → check()(与 RecordChecker 风格统一) - 修复 SeatMapOverlay.selectSeat() 中 _waitClickable 等待页面全局 而非具体 seat_link 元素的时序缺陷 - ALMainWorkers 切换为 pages.AutoLib 新版实现 Co-Authored-By: Claude Opus 4.7 --- src/gui/ALMainWorkers.py | 2 +- src/pages/{AutoLibPages.py => AutoLib.py} | 18 +++++++++--------- src/pages/__init__.py | 4 ++-- src/pages/components/SeatMapOverlay.py | 10 ++++++++-- src/pages/flows/CheckinFlow.py | 3 --- src/pages/flows/RenewFlow.py | 8 -------- src/pages/flows/_helpers.py | 2 -- .../{CaptchaHandler.py => CaptchaSolver.py} | 2 +- .../{ReserveValidator.py => ReserveChecker.py} | 7 ++----- src/pages/services/__init__.py | 8 ++++---- 10 files changed, 27 insertions(+), 37 deletions(-) rename src/pages/{AutoLibPages.py => AutoLib.py} (96%) rename src/pages/services/{CaptchaHandler.py => CaptchaSolver.py} (99%) rename src/pages/services/{ReserveValidator.py => ReserveChecker.py} (99%) diff --git a/src/gui/ALMainWorkers.py b/src/gui/ALMainWorkers.py index 9f684fa..66d5319 100644 --- a/src/gui/ALMainWorkers.py +++ b/src/gui/ALMainWorkers.py @@ -16,7 +16,7 @@ from PySide6.QtCore import ( ) from base.MsgBase import MsgBase -from operators.AutoLib import AutoLib +from pages.AutoLib import AutoLib from utils.JSONReader import JSONReader from autoscript import createEngine diff --git a/src/pages/AutoLibPages.py b/src/pages/AutoLib.py similarity index 96% rename from src/pages/AutoLibPages.py rename to src/pages/AutoLib.py index 3c73ff0..bfac01a 100644 --- a/src/pages/AutoLibPages.py +++ b/src/pages/AutoLib.py @@ -24,12 +24,12 @@ 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.CaptchaHandler import CaptchaHandler -from pages.services.ReserveValidator import ReserveValidator +from pages.services.CaptchaSolver import CaptchaSolver +from pages.services.ReserveChecker import ReserveChecker from pages.services.RecordChecker import RecordChecker -class AutoLibPages(MsgBase): +class AutoLib(MsgBase): def __init__( self, @@ -46,9 +46,9 @@ class AutoLibPages(MsgBase): self.__driver_path: str = "" self.__login_page: LoginPage = None self.__shell: MainShell = None - self.__captcha_handler: CaptchaHandler = None + self.__captcha_solver: CaptchaSolver = None self.__record_checker: RecordChecker = None - self.__reserve_validator: ReserveValidator = None + self.__reserve_checker: ReserveChecker = None self.__reserve_flow: ReserveFlow = None self.__checkin_flow: CheckinFlow = None self.__renew_flow: RenewFlow = None @@ -189,7 +189,7 @@ class AutoLibPages(MsgBase): self._showTrace("浏览器驱动未初始化, 请先初始化浏览器驱动 !", self.TraceLevel.WARNING) return self.__shell = MainShell(self.__driver) - self.__captcha_handler = CaptchaHandler( + self.__captcha_solver = CaptchaSolver( input_queue=self._input_queue, output_queue=self._output_queue, ) @@ -197,7 +197,7 @@ class AutoLibPages(MsgBase): input_queue=self._input_queue, output_queue=self._output_queue, ) - self.__reserve_validator = ReserveValidator( + self.__reserve_checker = ReserveChecker( input_queue=self._input_queue, output_queue=self._output_queue, ) @@ -242,7 +242,7 @@ class AutoLibPages(MsgBase): if not self.__login_page.login( username, password, - captcha_solver=self.__captcha_handler.solveCaptcha, + captcha_solver=self.__captcha_solver.solveCaptcha, auto_captcha=auto_captcha, max_attempts=login_config.get("max_attempt", 3), ): @@ -256,7 +256,7 @@ class AutoLibPages(MsgBase): # reserve if run_mode["auto_reserve"]: if self.__record_checker.canReserve(self.__shell, reserve_info.get("date")): - if self.__reserve_validator.validate(reserve_info): + if self.__reserve_checker.check(reserve_info): ctx = ReserveContext( username=username, date=reserve_info["date"], diff --git a/src/pages/__init__.py b/src/pages/__init__.py index 391cc81..e8ad247 100644 --- a/src/pages/__init__.py +++ b/src/pages/__init__.py @@ -7,7 +7,7 @@ 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.AutoLibPages import AutoLibPages +from pages.AutoLib import AutoLib from pages.LoginPage import LoginPage from pages.MainShell import MainShell from pages.ReserveView import ReserveView @@ -19,7 +19,7 @@ from pages.components.CheckinResultDialog import CheckinResultDialog from pages.components.RenewDialog import RenewDialog __all__ = [ - "AutoLibPages", + "AutoLib", "LoginPage", "MainShell", "ReserveView", diff --git a/src/pages/components/SeatMapOverlay.py b/src/pages/components/SeatMapOverlay.py index 42f1137..1512a36 100644 --- a/src/pages/components/SeatMapOverlay.py +++ b/src/pages/components/SeatMapOverlay.py @@ -15,6 +15,8 @@ from selenium.common.exceptions import ( ) 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.Overlay import Overlay @@ -48,7 +50,9 @@ class SeatMapOverlay(Overlay): try: seat_el = self._find(By.ID, f"seat_{int(seat_id):03d}") seat_link = seat_el.find_element(By.TAG_NAME, "a") - self._waitClickable((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, @@ -63,7 +67,9 @@ class SeatMapOverlay(Overlay): if not seat_id_upper == seat.text.lstrip('0'): continue seat_link = seat.find_element(By.TAG_NAME, "a") - self._waitClickable((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 diff --git a/src/pages/flows/CheckinFlow.py b/src/pages/flows/CheckinFlow.py index fc0a7c6..bb0689e 100644 --- a/src/pages/flows/CheckinFlow.py +++ b/src/pages/flows/CheckinFlow.py @@ -42,16 +42,13 @@ class CheckinFlow(MsgBase): 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() diff --git a/src/pages/flows/RenewFlow.py b/src/pages/flows/RenewFlow.py index abde378..4d4a36a 100644 --- a/src/pages/flows/RenewFlow.py +++ b/src/pages/flows/RenewFlow.py @@ -53,23 +53,18 @@ class RenewFlow(MsgBase): prefer_earlier = renew_info["prefer_early"] end_time = record["time"]["end"] target_renew_mins = timeStrToMins(end_time) + renew_info["expect_duration"] * 60 - if not self._validateRenewTime(end_time, target_renew_mins): return False - if not self._shell.waitExtendButton(): self._showTrace(f"用户 {username} 续约界面加载失败 !", self.TraceLevel.ERROR) return False - if self._shell.isExtendButtonDisabled(): self._showTrace( f"用户 {username} 续约按钮不可用, 可能不在场馆内, " f"请连接图书馆网络后重试" ) return False - self._shell.clickExtendButton() - try: with RenewDialog(self._driver) as dialog: if not dialog.waitUntilReady(): @@ -82,14 +77,12 @@ class RenewFlow(MsgBase): self._shell.refresh() self._showTrace(f"用户 {username} 续约失败 !", self.TraceLevel.ERROR) return False - renew_ok_btn = dialog.getOkButton() renew_time_opts = dialog.getTimeOptions() if not renew_time_opts: self._showTrace("当前未查询到可用续约时间 !", self.TraceLevel.WARNING) self._shell.refresh() return False - best_opt, best_text, actual_diff, free_times = findBestTimeOption( renew_time_opts, target_renew_mins, max_diff, prefer_earlier, is_reserve=False, @@ -111,7 +104,6 @@ class RenewFlow(MsgBase): renew_ok_btn.click() self._shell.refresh() return True - self._showTrace( "无法选择最近的可用续约时间 ! " f"所有可选时间与目标时间相差都超过了 {max_diff} 分钟 !", diff --git a/src/pages/flows/_helpers.py b/src/pages/flows/_helpers.py index 1b2fcf5..488615a 100644 --- a/src/pages/flows/_helpers.py +++ b/src/pages/flows/_helpers.py @@ -68,7 +68,6 @@ def findBestTimeOption( ) actual_diff = time_val - target_time abs_diff = abs(actual_diff) - if abs_diff < best_time_diff or ( abs_diff == best_time_diff and ( @@ -79,7 +78,6 @@ def findBestTimeOption( 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/services/CaptchaHandler.py b/src/pages/services/CaptchaSolver.py similarity index 99% rename from src/pages/services/CaptchaHandler.py rename to src/pages/services/CaptchaSolver.py index 1e941bd..1544bee 100644 --- a/src/pages/services/CaptchaHandler.py +++ b/src/pages/services/CaptchaSolver.py @@ -20,7 +20,7 @@ from base.MsgBase import MsgBase from pages.LoginPage import LoginPage -class CaptchaHandler(MsgBase): +class CaptchaSolver(MsgBase): def __init__( self, diff --git a/src/pages/services/ReserveValidator.py b/src/pages/services/ReserveChecker.py similarity index 99% rename from src/pages/services/ReserveValidator.py rename to src/pages/services/ReserveChecker.py index c458dc0..f38f449 100644 --- a/src/pages/services/ReserveValidator.py +++ b/src/pages/services/ReserveChecker.py @@ -15,7 +15,7 @@ from pages.ReserveView import ReserveView from pages.flows._helpers import timeStrToMins, minsToTimeStr -class ReserveValidator(MsgBase): +class ReserveChecker(MsgBase): def __init__( self, @@ -150,7 +150,6 @@ class ReserveValidator(MsgBase): 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']}, " @@ -161,7 +160,6 @@ class ReserveValidator(MsgBase): begin_time, end_time = end_time, begin_time begin_mins = timeStrToMins(begin_time["time"]) end_mins = timeStrToMins(end_time["time"]) - max_end_mins = timeStrToMins("23:30") if end_mins > max_end_mins: self._showTrace( @@ -170,7 +168,6 @@ class ReserveValidator(MsgBase): ) reserve_info["end_time"]["time"] = "23:30" end_mins = max_end_mins - if reserve_info["satisfy_duration"]: if reserve_info["expect_duration"] > 8: self._showTrace( @@ -191,7 +188,7 @@ class ReserveValidator(MsgBase): reserve_info["end_time"]["time"] = minsToTimeStr(begin_mins + 8 * 60) return True - def validate( + def check( self, reserve_info: dict, ) -> bool: diff --git a/src/pages/services/__init__.py b/src/pages/services/__init__.py index 8545fc0..cee361c 100644 --- a/src/pages/services/__init__.py +++ b/src/pages/services/__init__.py @@ -7,12 +7,12 @@ 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.CaptchaHandler import CaptchaHandler -from pages.services.ReserveValidator import ReserveValidator +from pages.services.CaptchaSolver import CaptchaSolver +from pages.services.ReserveChecker import ReserveChecker from pages.services.RecordChecker import RecordChecker __all__ = [ - "CaptchaHandler", - "ReserveValidator", + "CaptchaSolver", + "ReserveChecker", "RecordChecker", ] 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 5/8] =?UTF-8?q?refactor(pages):=20=E6=8A=BD=E5=8F=96?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E9=80=89=E6=8B=A9=E7=AD=96=E7=95=A5=E4=B8=BA?= =?UTF-8?q?=20TimeSelectMaker=EF=BC=8C=E5=B0=86=20Overlay=20=E5=9F=BA?= =?UTF-8?q?=E7=B1=BB=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()) From e77c561685534daac7e200783d3698b515a85d9d Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Wed, 27 May 2026 19:54:26 +0800 Subject: [PATCH 6/8] =?UTF-8?q?refactor:=20=E6=97=B6=E9=97=B4=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E9=80=BB=E8=BE=91=E4=B8=8B=E6=B2=89=E8=87=B3=20Dialog?= =?UTF-8?q?=E3=80=81Worker=20=E6=A8=A1=E6=9D=BF=E6=96=B9=E6=B3=95=E6=8A=BD?= =?UTF-8?q?=E8=B1=A1=E3=80=81=E9=85=8D=E7=BD=AE=E8=AE=BF=E9=97=AE=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E5=8C=96=E4=B8=8E=E4=BB=A3=E7=A0=81=E9=A3=8E=E6=A0=BC?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=20Co-Authored-By:=20Claude=20Opus=204.7=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Main.py | 2 +- src/gui/ALAutoScriptOrchDialog/_helpers.py | 6 +- src/gui/ALMainWorkers.py | 203 ++++++++++---------- src/gui/ALSeatMapView.py | 4 +- src/gui/ALTimerTaskAddDialog.py | 18 +- src/pages/AutoLib.py | 40 ++-- src/pages/ReserveView.py | 30 ++- src/pages/components/Dialog.py | 10 +- src/pages/components/RenewDialog.py | 27 ++- src/pages/components/ReserveResultDialog.py | 14 +- src/pages/components/TimeSelectDialog.py | 178 +++++++++++++++++ src/pages/flows/RenewFlow.py | 37 ++-- src/pages/flows/ReserveFlow.py | 189 ++++-------------- src/pages/flows/_helpers.py | 19 +- src/pages/services/RecordChecker.py | 192 +++++++++--------- src/pages/services/ReserveChecker.py | 30 +-- src/pages/strategies/__init__.py | 4 +- src/pages/strategies/timeSelectMaker.py | 61 +++++- 18 files changed, 599 insertions(+), 465 deletions(-) diff --git a/src/Main.py b/src/Main.py index 8716d23..c9e50ca 100644 --- a/src/Main.py +++ b/src/Main.py @@ -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) diff --git a/src/gui/ALAutoScriptOrchDialog/_helpers.py b/src/gui/ALAutoScriptOrchDialog/_helpers.py index e287560..16a53c5 100644 --- a/src/gui/ALAutoScriptOrchDialog/_helpers.py +++ b/src/gui/ALAutoScriptOrchDialog/_helpers.py @@ -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 diff --git a/src/gui/ALMainWorkers.py b/src/gui/ALMainWorkers.py index 66d5319..343acfa 100644 --- a/src/gui/ALMainWorkers.py +++ b/src/gui/ALMainWorkers.py @@ -12,7 +12,8 @@ import time import queue from PySide6.QtCore import ( - Slot, Signal, QThread + Signal, + QThread, ) from base.MsgBase import MsgBase @@ -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.applyRepeatAutoScript() + + def _onChecksFailed( + self, + ) -> bool: + + self._showTrace("定时任务跳过执行: 时间或配置文件检查未通过") + self.timerTaskWorkerIsFinished.emit(False, self.__timer_task) + return False + + def _onFinished( + 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']} 运行结束") 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) diff --git a/src/gui/ALSeatMapView.py b/src/gui/ALSeatMapView.py index a215e2a..2fd1e2c 100644 --- a/src/gui/ALSeatMapView.py +++ b/src/gui/ALSeatMapView.py @@ -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, diff --git a/src/gui/ALTimerTaskAddDialog.py b/src/gui/ALTimerTaskAddDialog.py index 2252909..10b4d65 100644 --- a/src/gui/ALTimerTaskAddDialog.py +++ b/src/gui/ALTimerTaskAddDialog.py @@ -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 diff --git a/src/pages/AutoLib.py b/src/pages/AutoLib.py index bfac01a..fab83e4 100644 --- a/src/pages/AutoLib.py +++ b/src/pages/AutoLib.py @@ -68,7 +68,7 @@ class AutoLib(MsgBase): self._showTrace("正在初始化浏览器驱动......", no_log=True) web_driver_config: dict = self.__run_config.get("web_driver", None) - self.__driver_type = web_driver_config.get("driver_type") + self.__driver_type = web_driver_config.get("driver_type", "none") match self.__driver_type.lower(): case "edge": driver_options = webdriver.EdgeOptions() @@ -85,7 +85,7 @@ class AutoLib(MsgBase): 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") @@ -122,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": @@ -236,7 +236,6 @@ class AutoLib(MsgBase): # result : -1 - terminate, 0 - success, 1 - failed, 2 - passed result: int = 2 - # login auto_captcha: bool = login_config.get("auto_captcha", True) if not self.__login_page.login( @@ -255,7 +254,7 @@ class AutoLib(MsgBase): } # reserve if run_mode["auto_reserve"]: - if self.__record_checker.canReserve(self.__shell, reserve_info.get("date")): + if self.__record_checker.canReserve(self.__shell, reserve_info["date"]): if self.__reserve_checker.check(reserve_info): ctx = ReserveContext( username=username, @@ -331,30 +330,29 @@ class AutoLib(MsgBase): ) -> None: self.__user_config = user_config - user_counter: dict[str, int] = {"current": 0, "success": 0, "failed": 0, "passed": 0} - users: list = self.__user_config["users"] + 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']}......", + 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: int = 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"], + 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']} 处理过程中页面发生异常, 无法继续操作, 任务已终止 !", + f"用户 {user.get("username", "未知")} 处理过程中页面发生异常, 无法继续操作, 任务已终止 !", self.TraceLevel.WARNING, ) break @@ -365,10 +363,10 @@ class AutoLib(MsgBase): elif r == 2: user_counter["passed"] += 1 self._showTrace( - f"处理完成, 共计 {user_counter['current']} 个用户, " - f"成功 {user_counter['success']} 个用户, " - f"失败 {user_counter['failed']} 个用户, " - f"跳过 {user_counter['passed']} 个用户" + f"处理完成, 共计 {user_counter["current"]} 个用户, " + f"成功 {user_counter["success"]} 个用户, " + f"失败 {user_counter["failed"]} 个用户, " + f"跳过 {user_counter["passed"]} 个用户" ) return diff --git a/src/pages/ReserveView.py b/src/pages/ReserveView.py index eda9b54..1f7e459 100644 --- a/src/pages/ReserveView.py +++ b/src/pages/ReserveView.py @@ -19,7 +19,6 @@ from selenium.common.exceptions import ( ) from pages.components.SeatMapDialog import SeatMapDialog -from pages.components.ReserveResultDialog import ReserveResultDialog class ReserveView: @@ -102,31 +101,30 @@ class ReserveView: def selectRoom( self, room: str, - ) -> bool: + ) -> SeatMapDialog | None: try: WebDriverWait(self._driver, 2).until( EC.element_to_be_clickable(self.FIND_ROOM_BTN) ).click() except (TimeoutException, ElementNotInteractableException): - return False + return None except Exception: - return False + return None try: WebDriverWait(self._driver, 2).until( EC.element_to_be_clickable((By.ID, self.ROOM_BTN_FMT.format(room=room))) ).click() - return True except (TimeoutException, ElementNotInteractableException): - return False + return None except Exception: - return False - - def openSeatMap( - self, - ) -> SeatMapDialog: - - return SeatMapDialog(self._driver) + return None + try: + return SeatMapDialog(self._driver) + except (TimeoutException): + return None + except Exception: + return None def submitReserve( self, @@ -142,12 +140,6 @@ class ReserveView: except Exception: return False - def waitResultDialog( - self, - ) -> ReserveResultDialog: - - return ReserveResultDialog(self._driver) - def refresh( self, ) -> None: diff --git a/src/pages/components/Dialog.py b/src/pages/components/Dialog.py index 95d1bfe..9f4e268 100644 --- a/src/pages/components/Dialog.py +++ b/src/pages/components/Dialog.py @@ -17,6 +17,9 @@ 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. """ @@ -34,13 +37,14 @@ class Dialog: 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": - WebDriverWait(self._driver, self._timeout).until( - EC.visibility_of_element_located(self._root_locator) - ) return self def __exit__( diff --git a/src/pages/components/RenewDialog.py b/src/pages/components/RenewDialog.py index d663a1c..150eca1 100644 --- a/src/pages/components/RenewDialog.py +++ b/src/pages/components/RenewDialog.py @@ -17,6 +17,10 @@ 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): @@ -80,6 +84,26 @@ class RenewDialog(Dialog): 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: @@ -93,7 +117,8 @@ class RenewDialog(Dialog): try: self._find(*self.OK_BTN).click() return True - except (NoSuchElementException, TimeoutException, ElementNotInteractableException): + except (NoSuchElementException, TimeoutException, + ElementNotInteractableException): return False except Exception: return False diff --git a/src/pages/components/ReserveResultDialog.py b/src/pages/components/ReserveResultDialog.py index d1ac48e..8ddb858 100644 --- a/src/pages/components/ReserveResultDialog.py +++ b/src/pages/components/ReserveResultDialog.py @@ -31,12 +31,18 @@ class ReserveResultDialog(Dialog): 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._title_locator()).text + return self._find(*self._titleLocator()).text except (NoSuchElementException, StaleElementReferenceException): return "" except Exception: @@ -73,9 +79,3 @@ class ReserveResultDialog(Dialog): return [] except Exception: return [] - - def _title_locator( - self, - ) -> tuple: - - return (By.CSS_SELECTOR, ".layoutSeat dt") diff --git a/src/pages/components/TimeSelectDialog.py b/src/pages/components/TimeSelectDialog.py index 643769f..1a843a3 100644 --- a/src/pages/components/TimeSelectDialog.py +++ b/src/pages/components/TimeSelectDialog.py @@ -7,6 +7,11 @@ 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, @@ -16,6 +21,16 @@ 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): @@ -31,9 +46,56 @@ class TimeSelectDialog(Dialog): 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, @@ -52,3 +114,119 @@ class TimeSelectDialog(Dialog): 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 diff --git a/src/pages/flows/RenewFlow.py b/src/pages/flows/RenewFlow.py index fb06111..b8f6421 100644 --- a/src/pages/flows/RenewFlow.py +++ b/src/pages/flows/RenewFlow.py @@ -20,7 +20,7 @@ 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 +from pages.strategies.TimeSelectMaker import TimeSelectMaker class RenewFlow(MsgBase): @@ -46,10 +46,10 @@ class RenewFlow(MsgBase): renew_info: dict, ) -> bool: - max_diff = renew_info["max_diff"] - prefer_earlier = renew_info["prefer_early"] + 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["expect_duration"] * 60 + 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(): @@ -74,20 +74,12 @@ class RenewFlow(MsgBase): self._shell.refresh() self._showTrace(f"用户 {username} 续约失败 !", self.TraceLevel.ERROR) return False - renew_ok_btn = dialog.getOkButton() - renew_time_opts = dialog.getTimeOptions() - if not renew_time_opts: - self._showTrace("当前未查询到可用续约时间 !", self.TraceLevel.WARNING) - self._shell.refresh() - return False - result = TimeSelectMaker.forRenew().decide( - renew_time_opts, + result = dialog.selectBestTime( target_renew_mins, max_diff, - prefer_earlier + prefer_earlier, ) 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} 分钟" @@ -100,15 +92,18 @@ class RenewFlow(MsgBase): f"与期望续约时间相比 {relation}" ) record["time"]["end"] = result.display_text.strip() - renew_ok_btn.click() + dialog.clickOk() self._shell.refresh() return True - self._showTrace( - "无法选择最近的可用续约时间 ! " - f"所有可选时间与目标时间相差都超过了 {max_diff} 分钟 !", - self.TraceLevel.WARNING, - ) - self._showTrace(f"当前可供续约的时间有: {result.free_times}") + 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: diff --git a/src/pages/flows/ReserveFlow.py b/src/pages/flows/ReserveFlow.py index e26eab8..1722903 100644 --- a/src/pages/flows/ReserveFlow.py +++ b/src/pages/flows/ReserveFlow.py @@ -9,7 +9,6 @@ See the LICENSE file for details. """ import queue from dataclasses import dataclass -from typing import Optional from selenium.common.exceptions import ( ElementNotInteractableException, @@ -20,8 +19,7 @@ from selenium.webdriver.remote.webdriver import WebDriver from base.MsgBase import MsgBase from pages.MainShell import MainShell -from pages.flows._helpers import timeStrToMins, minsToTimeStr -from pages.strategies.timeSelectMaker import TimeSelectMaker +from pages.strategies.TimeSelectMaker import TimeSelectMaker from pages.ReserveView import ReserveView from pages.components.ReserveResultDialog import ReserveResultDialog from pages.components.TimeSelectDialog import TimeSelectDialog @@ -60,14 +58,12 @@ class ReserveFlow(MsgBase): super().__init__(input_queue, output_queue) self._driver: WebDriver = driver self._shell: MainShell = shell - self._ctx: Optional[ReserveContext] = None def execute( self, ctx: ReserveContext, ) -> bool: - self._ctx = ctx submit_reserve = False reserve_success = False have_hover_on_page = False @@ -93,13 +89,13 @@ class ReserveFlow(MsgBase): self._showTrace(f"选择楼层失败 ! : {display_floor} 不可用", self.TraceLevel.ERROR) return False self._showTrace(f"楼层 {ReserveView.FLOOR_MAP.get(ctx.floor)} 选择成功 !") - if not view.selectRoom(ctx.room): + 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_map = view.openSeatMap() seat_status = seat_map.selectSeat(ctx.seat_id) if seat_status is None: self._showTrace( @@ -108,41 +104,44 @@ class ReserveFlow(MsgBase): ) else: self._showTrace(f"座位 {ctx.seat_id} 选择成功 ! : 当前状态 - '{seat_status}'") - time_dialog = TimeSelectDialog(self._driver) - select_time_ok = self._selectSeatTime(time_dialog) - if not select_time_ok: - self._showTrace("选择时间失败 !", self.TraceLevel.ERROR) + try: + time_dialog = TimeSelectDialog(self._driver, tracer=self._showTrace) + except TimeoutException: + 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]}" - ) + 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( - "\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) + 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: @@ -150,113 +149,3 @@ class ReserveFlow(MsgBase): else: self._showTrace(f"用户 {ctx.username} 预约失败 !", self.TraceLevel.ERROR) return reserve_success - - def _selectSeatTime( - self, - time_dialog: TimeSelectDialog, - ) -> bool: - - ctx = self._ctx - exp_beg_tm_str = ctx.begin_time - exp_end_tm_str = ctx.end_time - exp_beg_mins = timeStrToMins(exp_beg_tm_str) - exp_end_mins = timeStrToMins(exp_end_tm_str) - act_beg_mins = exp_beg_mins - act_beg_tm_str = exp_beg_tm_str - act_end_mins = exp_end_mins - act_end_tm_str = exp_end_tm_str - act_beg_mins = self._selectNearestTime( - time_dialog, - time_id="startTime", - time_type="开始时间", - target_time=exp_beg_mins, - max_time_diff=ctx.begin_max_diff, - prefer_earlier=ctx.begin_prefer_early, - ) - if act_beg_mins == -1: - return False - act_beg_tm_str = minsToTimeStr(act_beg_mins) - if ctx.satisfy_duration: - exp_end_mins = self._calcEndTime(act_beg_mins, ctx.expect_duration) - exp_end_tm_str = minsToTimeStr(exp_end_mins) - self._showTrace( - f"需要满足期望预约持续时间: {ctx.expect_duration} 小时, " - f"根据开始时间 {act_beg_tm_str} 计算结束时间: {exp_end_tm_str}" - ) - act_end_mins = self._selectNearestTime( - time_dialog, - time_id="endTime", - time_type="结束时间", - target_time=exp_end_mins, - max_time_diff=ctx.end_max_diff, - prefer_earlier=ctx.end_prefer_early, - ) - if act_end_mins == -1: - return False - act_end_tm_str = minsToTimeStr(act_end_mins) - self._showTrace( - f"期望预约时间段: {exp_beg_tm_str} - {exp_end_tm_str}, " - f"实际预约时间段: {act_beg_tm_str} - {act_end_tm_str}" - ) - return True - - def _selectNearestTime( - self, - time_dialog: TimeSelectDialog, - time_id: str, - time_type: str, - target_time: int, - max_time_diff: int, - prefer_earlier: bool, - ) -> int: - - all_time_opts = time_dialog.getTimeOptions(time_id) - if not all_time_opts: - self._showTrace( - f"{time_type} 选择失败 ! : 当前未查询到可用时间", self.TraceLevel.ERROR - ) - return -1 - 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() - 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 = f"正好等于 {time_type}" - self._showTrace( - f"选择距离期望 {time_type} 最近的 {result.display_text}, " - f"与期望 {time_type} 相比 {relation}" - ) - 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} 有: {result.free_times}") - return -1 - - def _calcEndTime( - self, - begin_mins: int, - duration: int, - ) -> int: - - expect_end_mins = int(begin_mins + duration*60) - if expect_end_mins > self.LIBRARY_CLOSE_MINS: - expect_end_mins = self.LIBRARY_CLOSE_MINS - self._showTrace( - f"预约持续时间 {duration} 小时, 超过最大预约时间 23:30, " - f"自动调整为 23:30", - self.TraceLevel.WARNING, - ) - return expect_end_mins diff --git a/src/pages/flows/_helpers.py b/src/pages/flows/_helpers.py index 936ccc5..886d17c 100644 --- a/src/pages/flows/_helpers.py +++ b/src/pages/flows/_helpers.py @@ -7,16 +7,13 @@ 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. """ -def timeStrToMins( - time_str: str, -) -> int: +from pages.strategies.TimeSelectMaker import ( + minsToTimeStr, + 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}" +__all__ = [ + "minsToTimeStr", + "timeStrToMins", +] \ No newline at end of file diff --git a/src/pages/services/RecordChecker.py b/src/pages/services/RecordChecker.py index 106ad33..c7cef7f 100644 --- a/src/pages/services/RecordChecker.py +++ b/src/pages/services/RecordChecker.py @@ -38,97 +38,11 @@ class RecordChecker(MsgBase): seconds: float, ) -> str: - hours = int(seconds // 3600) - minutes = int(seconds % 3600 // 60) - seconds = int(seconds % 60) + hours = int(seconds//3600) + minutes = int(seconds%3600//60) + seconds = int(seconds%60) return f"{hours} 时 {minutes} 分 {seconds} 秒" - 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 _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 _decodeReserveTime( self, time_element, @@ -189,6 +103,92 @@ class RecordChecker(MsgBase): 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, @@ -232,7 +232,7 @@ class RecordChecker(MsgBase): f"{self._formatDiffTime(abs(time_diff_seconds))}, 可以签到" ) return True - elif 0 <= time_diff_seconds < 30 * 60 - 5: + elif 0 <= time_diff_seconds < 30*60 - 5: self._showTrace( f"用户在 {date} 的预约开始时间为 {begin_time}, " f"当前距离预约开始时间已经过去 " @@ -287,18 +287,18 @@ class RecordChecker(MsgBase): 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']}" + 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']} 不符 !" + f" 续约后结束时间为 {act_record["time"]["end"]}," + f"与预期结束时间 {record["time"]["end"]} 不符 !" ) return False self._showTrace(f"用户在 {date} 没有有效预约记录, 无法检查续约结果") diff --git a/src/pages/services/ReserveChecker.py b/src/pages/services/ReserveChecker.py index f38f449..d5785e0 100644 --- a/src/pages/services/ReserveChecker.py +++ b/src/pages/services/ReserveChecker.py @@ -36,11 +36,11 @@ class ReserveChecker(MsgBase): if reserve_info.get("floor") is None: raise ValueError("未指定楼层") if reserve_info["floor"] not in floor_map: - raise ValueError(f"该楼层 '{reserve_info['floor']}' 不存在") + 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']}' 不存在") + raise ValueError(f"该房间 '{reserve_info["room"]}' 不存在") if reserve_info.get("seat_id") is None: raise ValueError("未指定座位") if reserve_info["seat_id"] == "": @@ -75,7 +75,7 @@ class ReserveChecker(MsgBase): if res_timestamp < cur_timestamp: self._showTrace( f"预约日期错误 ! :" - f"{reserve_info['date']} 早于当前日期 {cur_date_str}, 自动设置为当前日期", + f"{reserve_info["date"]} 早于当前日期 {cur_date_str}, 自动设置为当前日期", self.TraceLevel.WARNING, ) reserve_info["date"] = cur_date_str @@ -131,7 +131,7 @@ class ReserveChecker(MsgBase): } self._showTrace( f"结束时间未指定, 自动设置为开始时间加上期望时长: " - f"{reserve_info['end_time']['time']}" + f"{reserve_info["end_time"]["time"]}" ) if "max_diff" not in reserve_info["end_time"]: reserve_info["end_time"]["max_diff"] = 30 @@ -152,7 +152,7 @@ class ReserveChecker(MsgBase): 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"结束时间 {end_time["time"]} 早于开始时间 {begin_time["time"]}, " f"尝试交换时间", self.TraceLevel.WARNING, ) @@ -163,7 +163,7 @@ class ReserveChecker(MsgBase): max_end_mins = timeStrToMins("23:30") if end_mins > max_end_mins: self._showTrace( - f"结束时间 {end_time['time']} 晚于 23:30, 自动设置为 23:30", + f"结束时间 {end_time["time"]} 晚于 23:30, 自动设置为 23:30", self.TraceLevel.WARNING, ) reserve_info["end_time"]["time"] = "23:30" @@ -172,20 +172,20 @@ class ReserveChecker(MsgBase): if reserve_info["expect_duration"] > 8: self._showTrace( f"该用户设置了优先满足时长要求, 但是预约期望持续时间 " - f"{reserve_info['expect_duration']} 小时 " + f"{reserve_info["expect_duration"]} 小时 " f"超出最大时长 8 小时, 自动设置为 8 小时", self.TraceLevel.WARNING, ) reserve_info["expect_duration"] = 8 else: - if end_mins - begin_mins > 8 * 60: + 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) + reserve_info["end_time"]["time"] = minsToTimeStr(begin_mins + 8*60) return True def check( @@ -207,12 +207,12 @@ class ReserveChecker(MsgBase): return False self._showTrace( f"预约信息检查完成, 准备预约 " - f"{reserve_info['date']} " - f"{reserve_info['begin_time']['time']} - " - f"{reserve_info['end_time']['time']} " + 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']}" + f"{ReserveView.FLOOR_MAP[reserve_info["floor"]]} " + f"{ReserveView.ROOM_MAP[reserve_info["room"]]} " + f"的座位 {reserve_info["seat_id"]}" ) return True \ No newline at end of file diff --git a/src/pages/strategies/__init__.py b/src/pages/strategies/__init__.py index 6599a5f..23597e4 100644 --- a/src/pages/strategies/__init__.py +++ b/src/pages/strategies/__init__.py @@ -7,7 +7,7 @@ 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 ( +from pages.strategies.TimeSelectMaker import ( TimeSelectMaker, TimeDecisionMaker, TimeOptionReader, @@ -15,6 +15,7 @@ from pages.strategies.timeSelectMaker import ( RenewTimeReader, TimeOption, TimeSelectionResult, + TimeRangeResult, ) __all__ = [ @@ -25,4 +26,5 @@ __all__ = [ "RenewTimeReader", "TimeOption", "TimeSelectionResult", + "TimeRangeResult", ] diff --git a/src/pages/strategies/timeSelectMaker.py b/src/pages/strategies/timeSelectMaker.py index ada47cf..92428b6 100644 --- a/src/pages/strategies/timeSelectMaker.py +++ b/src/pages/strategies/timeSelectMaker.py @@ -11,8 +11,20 @@ from abc import ABC, abstractmethod from dataclasses import dataclass, field from datetime import datetime -from pages.flows._helpers import minsToTimeStr +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: @@ -31,18 +43,28 @@ class TimeSelectionResult: 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, + elements: list ) -> list[TimeOption]: ... def formatFreeTime( self, - opt: TimeOption, + opt: TimeOption ) -> str: return opt.element_text @@ -56,7 +78,7 @@ class ReserveTimeReader(TimeOptionReader): def readOptions( self, - elements: list, + elements: list ) -> list[TimeOption]: options: list[TimeOption] = [] @@ -74,7 +96,7 @@ class ReserveTimeReader(TimeOptionReader): def formatFreeTime( self, - opt: TimeOption, + opt: TimeOption ) -> str: return minsToTimeStr(opt.value) @@ -87,7 +109,7 @@ class RenewTimeReader(TimeOptionReader): def readOptions( self, - elements: list, + elements: list ) -> list[TimeOption]: options: list[TimeOption] = [] @@ -103,7 +125,7 @@ class TimeDecisionMaker: def __init__( self, - reader: TimeOptionReader, + reader: TimeOptionReader ) -> None: self._reader = reader @@ -113,7 +135,7 @@ class TimeDecisionMaker: elements: list, target_time: int, max_time_diff: int, - prefer_earlier: bool, + prefer_earlier: bool ) -> TimeSelectionResult: options = self._reader.readOptions(elements) @@ -148,9 +170,30 @@ class TimeDecisionMaker: class TimeSelectMaker: - LIBRARY_CLOSE_MINS = 1410 + 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: From 43336f98d2b87e5b369564a1665a558f17bbe1d4 Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Wed, 27 May 2026 20:03:35 +0800 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20=E7=BB=9F=E4=B8=80=E9=97=AD=E9=A6=86?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E4=B8=BA=20TimeSelectMaker.LIBRARY=5FCLOSE?= =?UTF-8?q?=5FMINS=20(22:30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReserveChecker._finalCheck 中存在硬编码的 "23:30",与 TimeSelectMaker.LIBRARY_CLOSE_MINS (22:30) 不一致,导致校验阶段与选时阶段使用不同的闭馆时间上限。 Co-Authored-By: Claude Opus 4.7 --- src/pages/services/ReserveChecker.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/pages/services/ReserveChecker.py b/src/pages/services/ReserveChecker.py index d5785e0..9be86e2 100644 --- a/src/pages/services/ReserveChecker.py +++ b/src/pages/services/ReserveChecker.py @@ -13,6 +13,7 @@ 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): @@ -160,13 +161,15 @@ class ReserveChecker(MsgBase): begin_time, end_time = end_time, begin_time begin_mins = timeStrToMins(begin_time["time"]) end_mins = timeStrToMins(end_time["time"]) - max_end_mins = timeStrToMins("23:30") + 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"]} 晚于 23:30, 自动设置为 23:30", + f"结束时间 {end_time["time"]} 晚于 {close_time_str}, " + f"自动设置为 {close_time_str}", self.TraceLevel.WARNING, ) - reserve_info["end_time"]["time"] = "23:30" + reserve_info["end_time"]["time"] = close_time_str end_mins = max_end_mins if reserve_info["satisfy_duration"]: if reserve_info["expect_duration"] > 8: From b279b51b420b60ec70b3860327ab5709f4a4e4b4 Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Wed, 27 May 2026 20:05:24 +0800 Subject: [PATCH 8/8] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4=E6=97=A7=20o?= =?UTF-8?q?perators/=20=E6=A8=A1=E5=9D=97=E5=92=8C=20base/=20=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit operators/ 模块已被 pages/ 模块完全替代,base/ 中的 LibOperator 和 MsgBase 不再被任何新代码引用。 Co-Authored-By: Claude Opus 4.7 --- src/base/LibOperator.py | 36 -- src/base/__init__.py | 1 - src/operators/AutoLib.py | 352 -------------- src/operators/LibChecker.py | 365 --------------- src/operators/LibCheckin.py | 139 ------ src/operators/LibCheckout.py | 40 -- src/operators/LibLogin.py | 207 -------- src/operators/LibLogout.py | 53 --- src/operators/LibRenew.py | 199 -------- src/operators/LibReserve.py | 674 --------------------------- src/operators/__init__.py | 13 - src/operators/abs/LibTimeSelector.py | 139 ------ src/operators/abs/__init__.py | 6 - 13 files changed, 2224 deletions(-) delete mode 100644 src/base/LibOperator.py delete mode 100644 src/operators/AutoLib.py delete mode 100644 src/operators/LibChecker.py delete mode 100644 src/operators/LibCheckin.py delete mode 100644 src/operators/LibCheckout.py delete mode 100644 src/operators/LibLogin.py delete mode 100644 src/operators/LibLogout.py delete mode 100644 src/operators/LibRenew.py delete mode 100644 src/operators/LibReserve.py delete mode 100644 src/operators/__init__.py delete mode 100644 src/operators/abs/LibTimeSelector.py delete mode 100644 src/operators/abs/__init__.py diff --git a/src/base/LibOperator.py b/src/base/LibOperator.py deleted file mode 100644 index c1ae287..0000000 --- a/src/base/LibOperator.py +++ /dev/null @@ -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 diff --git a/src/base/__init__.py b/src/base/__init__.py index bac4fcf..7becebc 100644 --- a/src/base/__init__.py +++ b/src/base/__init__.py @@ -3,5 +3,4 @@ Here are the classes and modules in this package: - MsgBase: Base class for messages. - - LibOperator: Base class for library operators. """ \ No newline at end of file diff --git a/src/operators/AutoLib.py b/src/operators/AutoLib.py deleted file mode 100644 index d3a4a85..0000000 --- a/src/operators/AutoLib.py +++ /dev/null @@ -1,352 +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 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.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 - - -class AutoLib(MsgBase): - - def __init__( - self, - input_queue: queue.Queue, - output_queue: queue.Queue, - run_config: dict - ): - super().__init__(input_queue, output_queue) - - self.__run_config = run_config - self.__user_config = None - self.__driver = None - if not self.__initBrowserDriver(): - raise Exception("浏览器驱动初始化失败 !") - else: - if not self.__initDriverUrl(): - self.close() - raise Exception("浏览器驱动URL初始化失败 !") - self.__initLibOperators() - - def __initBrowserDriver( - 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") - match self.__driver_type.lower(): - case "edge": - driver_options = webdriver.EdgeOptions() - case "chrome": - driver_options = webdriver.ChromeOptions() - case "firefox": - driver_options = webdriver.FirefoxOptions() - case _: - self._showTrace( - f"不支持的浏览器驱动类型: {self.__driver_type} !", - self.TraceLevel.WARNING - ) - return False - - if not web_driver_config: - self._showTrace("未配置浏览器驱动参数 !", self.TraceLevel.ERROR) - return False - if web_driver_config.get("headless"): - driver_options.add_argument("--headless") - driver_options.add_argument("--disable-gpu") - driver_options.add_argument("--no-sandbox") - driver_options.add_argument("--disable-dev-shm-usage") - - # must be 1920x1080, otherwise the page will cause some elements not accessible - driver_options.add_argument("--window-size=1920,1080") - - # omit ssl errors and verbose log level - driver_options.add_argument("--ignore-certificate-errors") - driver_options.add_argument("--ignore-ssl-errors") - driver_options.add_argument("--log-level=OFF") - driver_options.add_argument("--silent") - - # set options for chrome and edge - if self.__driver_type.lower() in ["edge", "chrome"]: - driver_options.add_argument("--remote-allow-origins=*") - driver_options.add_experimental_option("excludeSwitches", ["enable-automation"]) - driver_options.add_experimental_option("useAutomationExtension", False) - driver_options.add_argument("--disable-blink-features=AutomationControlled") - user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "\ - "AppleWebKit/537.36 (KHTML, like Gecko) "\ - "Chrome/120.0.0.0 "\ - "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) - driver_options.set_preference("useAutomationExtension", False) - user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) "\ - "Gecko/20100101 Firefox/120.0" - driver_options.add_argument(f"user-agent={user_agent}") - - # init browser driver - 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: - service = None - match self.__driver_type.lower(): - case "edge": - service = EdgeService(executable_path=self.__driver_path) - self.__driver = webdriver.Edge(service=service, options=driver_options) - case "chrome": - 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) - 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' - raise Exception(f"不支持的浏览器驱动类型: {self.__driver_type} !") - self.__driver.implicitly_wait(1) - self.__driver.execute_script( - "Object.defineProperty(navigator, 'webdriver', {get: () => undefined})" - ) - 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) - if not lib_config: - self._showTrace("未配置图书馆参数 !", self.TraceLevel.ERROR) - return False - url = lib_config.get("host_url") + lib_config.get("login_url") - self.__driver.set_page_load_timeout(5) - try: - self.__driver.get(url) - except TimeoutException: - self.__driver.execute_script("window.stop();") - self._showTrace( - f"图书馆登录页面加载超时 ! 请检查网络环境是否正常", self.TraceLevel.ERROR - ) - return False - if not self.__waitResponseLoad(): - return False - return True - - def __run( - self, - username: str, - password: str, - login_config: dict, - run_mode_config: dict, - reserve_info: dict - ) -> int: - - # result : -1 - terminate, 0 - success, 1 - failed, 2 - passed - result = 2 - - # login - if not self.__lib_login.login( - username, - password, - login_config.get("max_attempt", 3), - login_config.get("auto_captcha", True), - ): - 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, - } - # reserve - if run_mode["auto_reserve"]: - if self.__lib_checker.canReserve(reserve_info.get("date")): - if self.__lib_reserve.reserve(username, reserve_info): - result = 0 - else: - result = 1 - else: - self._showTrace(f"用户 {username} 无法预约, 已跳过") - result = 2 - - # checkin - last_result = result - if run_mode["auto_checkin"] and last_result != 1: - if self.__lib_checker.canCheckin(): - if self.__lib_checkin.checkin(username): - result = 0 - else: - result = 1 - else: - self._showTrace(f"用户 {username} 无法签到, 已跳过") - result = 2 - if last_result == 0: # partly success - result = 0 - - # renewal - last_result = result - if run_mode["auto_renewal"] and last_result != 1: - can_renew, record = self.__lib_checker.canRenew() - if can_renew: - if self.__lib_renew.renew(username, record, reserve_info): - if self.__lib_checker.postRenewCheck(record): - self._showTrace(f"用户 {username} 续约成功 !") - result = 0 - else: - if result != 1: # partly success - result = 0 - else: - result = 1 - else: - result = 1 - else: - self._showTrace(f"用户 {username} 无法续约, 已跳过") - result = 2 - if last_result == 0: # partly success - 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.__initDriverUrl(): - return -1 - return result - - def run( - self, - user_config: dict - ): - - self.__user_config = user_config - - user_counter = {"current": 0, "success": 0, "failed": 0, "passed": 0} - users = self.__user_config["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 - ) - if not user["enabled"]: - self._showTrace(f"用户 {user["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"], - ) - if r == -1: - self._showTrace( - f"用户 {user["username"]} 处理过程中页面发生异常, 无法继续操作, 任务已终止 !", - self.TraceLevel.WARNING - ) - break - elif r == 0: - user_counter["success"] += 1 - elif r == 1: - 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"]} 个用户, "\ - f"跳过 {user_counter["passed"]} 个用户" - ) - return - - def close( - self - ) -> bool: - - if self.__driver: - if self.__driver_type.lower() == "firefox": - self._showTrace( - f"Firefox 浏览器驱动关闭略慢, 请耐心等待...", - no_log=True - ) - self.__driver.quit() - self.__driver = None - self._showTrace(f"浏览器驱动已关闭") - return True - else: - self._showTrace(f"浏览器驱动未初始化, 无需关闭", no_log=True) - return False \ No newline at end of file diff --git a/src/operators/LibChecker.py b/src/operators/LibChecker.py deleted file mode 100644 index 3dc944b..0000000 --- a/src/operators/LibChecker.py +++ /dev/null @@ -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 diff --git a/src/operators/LibCheckin.py b/src/operators/LibCheckin.py deleted file mode 100644 index 5adb21e..0000000 --- a/src/operators/LibCheckin.py +++ /dev/null @@ -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 diff --git a/src/operators/LibCheckout.py b/src/operators/LibCheckout.py deleted file mode 100644 index f55ccc8..0000000 --- a/src/operators/LibCheckout.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/src/operators/LibLogin.py b/src/operators/LibLogin.py deleted file mode 100644 index 9fd233b..0000000 --- a/src/operators/LibLogin.py +++ /dev/null @@ -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 diff --git a/src/operators/LibLogout.py b/src/operators/LibLogout.py deleted file mode 100644 index 14bd02d..0000000 --- a/src/operators/LibLogout.py +++ /dev/null @@ -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 diff --git a/src/operators/LibRenew.py b/src/operators/LibRenew.py deleted file mode 100644 index 2d9eec6..0000000 --- a/src/operators/LibRenew.py +++ /dev/null @@ -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 diff --git a/src/operators/LibReserve.py b/src/operators/LibReserve.py deleted file mode 100644 index 930db0a..0000000 --- a/src/operators/LibReserve.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/src/operators/__init__.py b/src/operators/__init__.py deleted file mode 100644 index 27a9a50..0000000 --- a/src/operators/__init__.py +++ /dev/null @@ -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. -""" \ No newline at end of file diff --git a/src/operators/abs/LibTimeSelector.py b/src/operators/abs/LibTimeSelector.py deleted file mode 100644 index 472d6e4..0000000 --- a/src/operators/abs/LibTimeSelector.py +++ /dev/null @@ -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) diff --git a/src/operators/abs/__init__.py b/src/operators/abs/__init__.py deleted file mode 100644 index e6f47db..0000000 --- a/src/operators/abs/__init__.py +++ /dev/null @@ -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. -""" \ No newline at end of file