| from __future__ import annotations
|
|
|
| import base64
|
| import json
|
| import re |
| import time
|
| import urllib.error
|
| import urllib.request
|
| from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit |
| from dataclasses import dataclass
|
| from pathlib import Path
|
| from typing import Any, Callable
|
|
|
| from selenium.common.exceptions import NoSuchElementException, TimeoutException, WebDriverException |
| 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.wait import WebDriverWait |
|
|
| import onnx_inference |
| import webdriver_utils |
| from core.db import Database |
| from core.login_modes import LOGIN_MODE_LABELS, LOGIN_MODE_UNIFIED, LOGIN_MODE_ZHJW_DIRECT, normalize_login_mode |
| from core.task_modes import ( |
| TASK_RUN_MODE_LABELS, |
| TASK_RUN_MODE_OPTIMIZED, |
| TASK_RUN_MODE_STABLE, |
| normalize_task_run_mode, |
| ) |
|
|
|
|
| URL_LOGIN = "http://id.scu.edu.cn/enduser/sp/sso/scdxplugin_jwt23?enterpriseId=scdx&target_url=index" |
| URL_ZHJW_LOGIN = "http://zhjw.scu.edu.cn/login" |
| URL_SELECT_COURSE = "http://zhjw.scu.edu.cn/student/courseSelect/courseSelect/index" |
| URL_CURRICULUM_CALLBACK = "http://zhjw.scu.edu.cn/student/courseSelect/thisSemesterCurriculum/callback"
|
| LOGIN_SUCCESS_PREFIXES = (
|
| "http://zhjw.scu.edu.cn/index",
|
| "http://zhjw.scu.edu.cn/",
|
| "https://zhjw.scu.edu.cn/index",
|
| "https://zhjw.scu.edu.cn/",
|
| )
|
| CATEGORY_META = { |
| "plan": {"label": "方案选课", "tab_id": "faxk"}, |
| "free": {"label": "自由选课", "tab_id": "zyxk"}, |
| } |
|
|
| ACCOUNT_LOGIN_TAB_SELECTORS = [ |
| (By.CSS_SELECTOR, ".login-tab .login-tab-item:nth-child(3)"), |
| (By.XPATH, "//*[contains(concat(' ', normalize-space(@class), ' '), ' login-tab-item ') and (normalize-space(.)='账号登录' or normalize-space(.)='Account')]"), |
| (By.XPATH, "//*[self::div or self::span or self::a or self::li or self::button][normalize-space(.)='账号登录']"), |
| (By.XPATH, "//*[self::div or self::span or self::a or self::li or self::button][normalize-space(.)='Account']"), |
| ] |
| LOGIN_STUDENT_SELECTORS = [ |
| (By.CSS_SELECTOR, ".login-content input[type='text']:not([placeholder*='验证码']):not([placeholder*='Captcha'])"), |
| (By.CSS_SELECTOR, "input[placeholder='请输入学(工)号']"), |
| (By.CSS_SELECTOR, "input[placeholder='Campus ID']"), |
| (By.XPATH, "//*[@id='app']//form//input[@type='text']"), |
| (By.XPATH, "//*[@id='app']/div[1]/div/div[2]/div/div[1]/div[2]/div[2]/div/form/div[1]/div/div/div[2]/div/input"), |
| ] |
| LOGIN_PASSWORD_SELECTORS = [ |
| (By.CSS_SELECTOR, ".login-content input[type='password']"), |
| (By.CSS_SELECTOR, "input[type='password'][placeholder='请输入密码']"), |
| (By.CSS_SELECTOR, "input[type='password'][placeholder='Password']"), |
| (By.CSS_SELECTOR, "input[placeholder='请输入密码']"), |
| (By.CSS_SELECTOR, "input[placeholder='Password']"), |
| (By.XPATH, "//*[@id='app']//form//input[@type='password']"), |
| (By.XPATH, "//*[@id='app']/div[1]/div/div[2]/div/div[1]/div[2]/div[2]/div/form/div[2]/div/div/div[2]/div/input"), |
| ] |
| LOGIN_CAPTCHA_INPUT_SELECTORS = [ |
| (By.CSS_SELECTOR, ".captcha-box input"), |
| (By.CSS_SELECTOR, "input[placeholder='请输入验证码']"), |
| (By.CSS_SELECTOR, "input[placeholder='Captcha code']"), |
| (By.CSS_SELECTOR, "input[placeholder*='Captcha']"), |
| (By.XPATH, "//*[@id='app']/div[1]/div/div[2]/div/div[1]/div[2]/div[2]/div/form/div[3]//input"), |
| (By.XPATH, "//*[@id='app']//form//input[contains(@placeholder, '验证码')]"), |
| (By.XPATH, "//*[@id='app']//form//input[contains(translate(@placeholder, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'captcha')]"), |
| ] |
| LOGIN_CAPTCHA_IMAGE_SELECTORS = [ |
| (By.CSS_SELECTOR, "img.captcha-img"), |
| (By.CSS_SELECTOR, ".captcha-box img"), |
| (By.XPATH, "//*[@id='app']/div[1]/div/div[2]/div/div[1]/div[2]/div[2]/div/form/div[3]//img"), |
| (By.XPATH, "//*[@id='app']//form//img"), |
| ] |
| LOGIN_BUTTON_SELECTORS = [ |
| (By.CSS_SELECTOR, "button.login-btn"), |
| (By.XPATH, "//button[normalize-space(.)='登录']"), |
| (By.XPATH, "//button[normalize-space(.)='Log on']"), |
| (By.XPATH, "//*[@id='app']//form//button"), |
| (By.XPATH, "//*[@id='app']/div[1]/div/div[2]/div/div[1]/div[2]/div[2]/div/form/div[4]/div/button"), |
| ] |
| ZHJW_LOGIN_STUDENT_SELECTORS = [ |
| (By.ID, "input_username"), |
| (By.CSS_SELECTOR, "input[name='username']"), |
| (By.CSS_SELECTOR, "input[type='text']"), |
| ] |
| ZHJW_LOGIN_PASSWORD_SELECTORS = [ |
| (By.ID, "input_password"), |
| (By.CSS_SELECTOR, "input[name='password']"), |
| (By.CSS_SELECTOR, "input[type='password']"), |
| ] |
| ZHJW_LOGIN_CAPTCHA_INPUT_SELECTORS = [ |
| (By.ID, "input_checkcode"), |
| (By.CSS_SELECTOR, "input[name='checkcode']"), |
| (By.CSS_SELECTOR, "input[name*='captcha' i]"), |
| (By.CSS_SELECTOR, "input[id*='captcha' i]"), |
| ] |
| ZHJW_LOGIN_CAPTCHA_IMAGE_SELECTORS = [ |
| (By.ID, "captchaImg"), |
| (By.CSS_SELECTOR, "img[src*='captcha' i]"), |
| (By.CSS_SELECTOR, "img[id*='captcha' i]"), |
| ] |
| ZHJW_LOGIN_BUTTON_SELECTORS = [ |
| (By.ID, "loginButton"), |
| (By.CSS_SELECTOR, "input[type='submit']"), |
| (By.CSS_SELECTOR, "button[type='submit']"), |
| (By.XPATH, "//*[self::button or self::input][contains(normalize-space(.), '登录') or @value='登录']"), |
| ] |
| LOGIN_2FA_SEND_CODE_SELECTORS = [ |
| (By.CSS_SELECTOR, ".otp-layout .btn-content button"), |
| (By.CSS_SELECTOR, ".btn-content button"), |
| (By.CSS_SELECTOR, ".security-auth-content button"), |
| (By.XPATH, "//button[.//span[normalize-space(.)='获取验证码'] or normalize-space(.)='获取验证码']"), |
| (By.XPATH, "//button[.//span[normalize-space(.)='Send email'] or normalize-space(.)='Send email']"), |
| (By.XPATH, "//button[contains(translate(normalize-space(.), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'send')]"), |
| (By.XPATH, "//*[self::button or self::a or @role='button'][contains(., '获取验证码') or contains(translate(normalize-space(.), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'send')]"), |
| ] |
| LOGIN_2FA_CODE_INPUT_SELECTORS = [ |
| (By.CSS_SELECTOR, ".sec-factor-six-ipt .inner input[type='text']"), |
| (By.CSS_SELECTOR, ".security-auth-content input[type='text']"), |
| (By.CSS_SELECTOR, "input[type='text'][maxlength='1']"), |
| ] |
| LOGIN_2FA_PRIMARY_CODE_INPUT_SELECTOR = ".sec-factor-six-ipt .inner input[type='text']" |
| LOGIN_2FA_VALIDATION_REQUEST_PATH = "/2factor/otpcode" |
| LOGIN_2FA_SEND_SMS_REQUEST_PATH = "/2factor/send_sms" |
| LOGIN_2FA_NETWORK_TRACE_PATHS = ( |
| LOGIN_2FA_SEND_SMS_REQUEST_PATH, |
| LOGIN_2FA_VALIDATION_REQUEST_PATH, |
| "/2factor/sms/idv/user_info", |
| "/commons/user_password_info", |
| "/commons/role_permissions", |
| ) |
| SUBMIT_CAPTCHA_IMAGE_SELECTORS = [ |
| (By.XPATH, "//div[contains(@class,'dialog') or contains(@class,'modal') or contains(@class,'popup')]//img[contains(@src,'base64') or contains(translate(@src,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'captcha')]"),
|
| (By.XPATH, "//img[contains(@src,'base64')]"),
|
| (By.XPATH, "//img[contains(translate(@alt,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'captcha') or contains(translate(@class,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'captcha')]"),
|
| ]
|
| SUBMIT_CAPTCHA_INPUT_SELECTORS = [
|
| (By.XPATH, "//input[contains(@placeholder,'验证码')]"),
|
| (By.XPATH, "//input[contains(translate(@id,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'captcha')]"),
|
| (By.XPATH, "//input[contains(translate(@name,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'captcha')]"),
|
| ]
|
| SUBMIT_CAPTCHA_BUTTON_SELECTORS = [
|
| (By.XPATH, "//button[contains(.,'确定') or contains(.,'提交') or contains(.,'确认') or contains(.,'验证')]"),
|
| (By.XPATH, "//input[(@type='button' or @type='submit') and (contains(@value,'确定') or contains(@value,'提交') or contains(@value,'确认') or contains(@value,'验证'))]"),
|
| ]
|
|
|
| MODE_DOWNGRADE_THRESHOLD = 3 |
| STABLE_UI_SETTLE_SECONDS = 0.2 |
| OPTIMIZED_UI_SETTLE_SECONDS = 0.08 |
| LOGIN_2FA_POST_SUBMIT_TIMEOUT_SECONDS = 360 |
| LOGIN_2FA_WAIT_LOG_INTERVAL_SECONDS = 15 |
| LOGIN_2FA_SSO_ADVANCE_AFTER_SECONDS = 12 |
|
|
|
|
| class FatalCredentialsError(Exception):
|
| pass
|
|
|
|
|
| class RecoverableAutomationError(Exception):
|
| pass
|
|
|
|
|
| @dataclass(slots=True)
|
| class TaskResult:
|
| status: str
|
| error: str = ""
|
|
|
|
|
| class CourseBot:
|
| def __init__(
|
| self,
|
| *,
|
| config,
|
| store: Database,
|
| task_id: int, |
| user: dict, |
| password: str, |
| logger: Callable[[str, str], None], |
| login_2fa_code_provider: Callable[[str, Any], str] | None = None, |
| ) -> None: |
| self.config = config
|
| self.store = store
|
| self.task_id = task_id
|
| self.user = user
|
| self.password = password |
| self.logger = logger |
| self.login_2fa_code_provider = login_2fa_code_provider |
| self.root_dir = Path(__file__).resolve().parent.parent
|
| self.select_course_js = (self.root_dir / "javascript" / "select_course.js").read_text(encoding="utf-8")
|
| self.check_result_js = (self.root_dir / "javascript" / "check_result.js").read_text(encoding="utf-8")
|
| self.captcha_solver = onnx_inference.CaptchaONNXInference( |
| model_path=str(self.root_dir / "ocr_provider" / "captcha_model.onnx") |
| ) |
| if getattr(self.captcha_solver, "ddddocr", None) is None: |
| detail = str(getattr(self.captcha_solver, "ddddocr_error", "") or "未加载 DDDDOCR") |
| self.logger("WARNING", f"DDDDOCR 未启用,将使用旧 ONNX 验证码模型兜底。详情: {detail}") |
| task = self.store.get_task(task_id) or {} |
| self.selection_attempts = int(task.get("total_attempts") or 0)
|
| self.requested_mode = normalize_task_run_mode(task.get("requested_mode")) |
| self.effective_mode = normalize_task_run_mode(task.get("effective_mode")) |
| self.login_mode = normalize_login_mode(task.get("login_mode")) |
| self.use_browser_proxy = bool(int(task.get("use_proxy") or 0)) |
| self.login_mode_fallback_to_unified = False |
| self.mode_failure_count = 0 |
| self.active_category: str | None = None |
| self.select_course_page_ready = False |
| self.stop_event = None |
| self._login_2fa_network_events: dict[str, dict[str, Any]] = {} |
| self.browser_proxy_disabled_reason = "" |
|
|
| def run(self, stop_event) -> TaskResult: |
| self.stop_event = stop_event |
| self._refresh_effective_mode_from_store(log_change=False) |
| initial_courses = self.store.list_courses_for_user(self.user["id"]) |
| self.logger( |
| "INFO", |
| "任务运行配置:" |
| f"请求模式={self._mode_label(self.requested_mode)}," |
| f"当前模式={self._mode_label(self.effective_mode)}," |
| f"登录方式={self._login_mode_label(self.login_mode)}," |
| f"代理={'启用' if self.use_browser_proxy else '关闭'}," |
| f"刷新间隔={self._current_poll_interval_seconds()} 秒," |
| f"待选课程={self._course_batch_summary(initial_courses)}。", |
| ) |
|
|
| exhausted_sessions = 0 |
| while not stop_event.is_set(): |
| courses = self.store.list_courses_for_user(self.user["id"]) |
| if not courses: |
| self.logger("INFO", "当前没有待选课程,任务直接完成。") |
| return TaskResult(status="completed") |
|
|
| driver: WebDriver | None = None |
| session_ready = False |
| session_error_count = 0 |
| self._invalidate_course_page_cache() |
| session_proxy_url = "" |
| try: |
| self._ensure_browser_proxy_available() |
| session_proxy_url = self._current_browser_proxy_url() |
| enable_login_2fa_bypass = self.login_mode == LOGIN_MODE_UNIFIED and self.effective_mode == TASK_RUN_MODE_OPTIMIZED |
| self.logger( |
| "INFO", |
| f"准备启动 Selenium 会话,浏览器重启计数 {exhausted_sessions}/{self.config.selenium_restart_limit}," |
| f"代理={self._browser_proxy_label()}。", |
| ) |
| driver = webdriver_utils.configure_browser( |
| chrome_binary=self.config.chrome_binary, |
| chromedriver_path=self.config.chromedriver_path, |
| page_timeout=self.config.browser_page_timeout, |
| proxy_url=session_proxy_url, |
| enable_login_2fa_bypass=enable_login_2fa_bypass, |
| ) |
| wait = WebDriverWait(driver, self.config.browser_page_timeout, 0.5) |
| if enable_login_2fa_bypass: |
| self.logger("INFO", "优化版已启用统一认证 2FA 绕过脚本;如未生效将继续使用短信验证码辅助流程。") |
| self.logger("INFO", f"Selenium 会话已启动,准备执行{self._login_mode_label(self.login_mode)}登录。") |
|
|
| while not stop_event.is_set(): |
| try: |
| if not session_ready: |
| self._login_for_task(driver, wait) |
| session_ready = True |
| self._invalidate_course_page_cache() |
| self.logger("INFO", f"{self._login_mode_label(self.login_mode)}登录完成,准备进入选课流程。") |
|
|
| self._refresh_effective_mode_from_store() |
| current_courses = self.store.list_courses_for_user(self.user["id"]) |
| if not current_courses: |
| self.logger("INFO", "待选课程已全部完成或被移除,任务完成。") |
| return TaskResult(status="completed") |
|
|
| round_stats = self._run_selection_round(driver, wait, stop_event, current_courses) |
| if round_stats["not_selectable"]: |
| session_error_count = 0 |
| self._sleep_with_cancel( |
| stop_event, |
| self._current_poll_interval_seconds(), |
| "当前不是选课时段或选课入口暂不可用,等待下一轮检查", |
| ) |
| continue |
|
|
| attempts = round_stats["attempts"] |
| successes = round_stats["successes"] |
| all_done = round_stats["all_done"] |
| remaining_courses = self.store.list_courses_for_user(self.user["id"]) |
|
|
| if attempts == 0: |
| self.logger( |
| "INFO", |
| f"本轮未产生提交尝试,剩余待选课程={self._course_batch_summary(remaining_courses)}。", |
| ) |
| elif successes == 0: |
| self.logger( |
| "INFO", |
| f"本轮完成:尝试 {attempts} 门,成功 0 门,剩余待选课程={self._course_batch_summary(remaining_courses)}。", |
| ) |
| else: |
| self.logger( |
| "INFO", |
| f"本轮完成:尝试 {attempts} 门,成功 {successes} 门,剩余待选课程={self._course_batch_summary(remaining_courses)}。", |
| ) |
|
|
| session_error_count = 0 |
| if exhausted_sessions: |
| self.logger("INFO", "Selenium 会话已恢复稳定,清零浏览器重启计数。") |
| exhausted_sessions = 0 |
|
|
| if all_done or not remaining_courses: |
| self.logger("INFO", "所有待选课程已处理完成,任务完成。") |
| return TaskResult(status="completed") |
|
|
| self._sleep_with_cancel( |
| stop_event, |
| self._current_poll_interval_seconds(), |
| "本轮结束,等待下一轮选课检查", |
| ) |
| except FatalCredentialsError as exc: |
| self.logger("ERROR", str(exc)) |
| return TaskResult(status="failed", error=str(exc)) |
| except RecoverableAutomationError as exc: |
| if session_proxy_url and self._should_fallback_browser_proxy(exc): |
| self.browser_proxy_disabled_reason = "Chrome 通过代理访问教务/认证页面失败" |
| self.logger( |
| "WARNING", |
| "检测到代理访问教务或统一认证页面失败,本任务将回退直连并重启 Selenium 会话。" |
| f"原因: {exc}", |
| ) |
| break |
| if self.effective_mode != TASK_RUN_MODE_STABLE: |
| self._register_mode_failure(f"Selenium 可恢复异常: {exc}") |
| self._invalidate_course_page_cache() |
| session_ready = False |
| session_error_count += 1 |
| self.logger( |
| "WARNING", |
| f"Selenium 可恢复异常 {session_error_count}/{self.config.selenium_error_limit}: {exc}", |
| ) |
| if session_error_count < self.config.selenium_error_limit: |
| self._sleep_with_cancel( |
| stop_event, |
| self.config.task_backoff_seconds, |
| "等待后重试当前 Selenium 会话", |
| ) |
| continue |
|
|
| exhausted_sessions += 1 |
| if exhausted_sessions >= self.config.selenium_restart_limit: |
| message = ( |
| f"Selenium 会话连续重启 {exhausted_sessions} 次仍失败,任务停止。" |
| f"最后原因: {exc}" |
| ) |
| self.logger("ERROR", message) |
| return TaskResult(status="failed", error=message) |
|
|
| self.logger( |
| "WARNING", |
| f"当前 Selenium 会话已连续异常 {self.config.selenium_error_limit} 次," |
| f"准备重启浏览器 {exhausted_sessions}/{self.config.selenium_restart_limit}。", |
| ) |
| break |
| except Exception as exc: |
| if self.effective_mode != TASK_RUN_MODE_STABLE: |
| self._register_mode_failure(f"未预期异常: {exc}") |
| self._invalidate_course_page_cache() |
| session_ready = False |
| session_error_count += 1 |
| self.logger( |
| "WARNING", |
| f"Selenium 未预期异常 {session_error_count}/{self.config.selenium_error_limit}: {exc}", |
| ) |
| if session_error_count < self.config.selenium_error_limit: |
| self._sleep_with_cancel( |
| stop_event, |
| self.config.task_backoff_seconds, |
| "等待后重试当前流程", |
| ) |
| continue |
|
|
| exhausted_sessions += 1 |
| if exhausted_sessions >= self.config.selenium_restart_limit: |
| message = ( |
| f"Selenium 会话连续重启 {exhausted_sessions} 次仍失败,任务停止。" |
| f"最后原因: {exc}" |
| ) |
| self.logger("ERROR", message) |
| return TaskResult(status="failed", error=message) |
|
|
| self.logger( |
| "WARNING", |
| f"当前流程连续异常达到上限,准备重启 Selenium 会话 " |
| f"{exhausted_sessions}/{self.config.selenium_restart_limit}。", |
| ) |
| break |
| finally: |
| if driver is not None: |
| try: |
| webdriver_utils.quit_browser(driver) |
| self.logger("INFO", "Selenium 会话已关闭。") |
| except Exception: |
| pass |
|
|
| self.logger("INFO", "任务收到停止信号,已在安全节点退出。") |
| return TaskResult(status="stopped") |
|
|
| def _refresh_effective_mode_from_store(self, *, log_change: bool = True) -> None:
|
| latest_task = self.store.get_task(self.task_id)
|
| if latest_task is None:
|
| return
|
| previous_effective = self.effective_mode |
| self.requested_mode = normalize_task_run_mode(latest_task.get("requested_mode")) |
| self.effective_mode = normalize_task_run_mode(latest_task.get("effective_mode")) |
| latest_login_mode = normalize_login_mode(latest_task.get("login_mode")) |
| if self.login_mode_fallback_to_unified and latest_login_mode == LOGIN_MODE_ZHJW_DIRECT: |
| self.login_mode = LOGIN_MODE_UNIFIED |
| else: |
| self.login_mode = latest_login_mode |
| self.use_browser_proxy = bool(int(latest_task.get("use_proxy") or 0)) |
| if log_change and previous_effective != self.effective_mode: |
| self.logger( |
| "INFO", |
| f"任务模式已更新:当前模式={self._mode_label(self.effective_mode)},请求模式={self._mode_label(self.requested_mode)}。", |
| ) |
|
|
| def _invalidate_course_page_cache(self) -> None: |
| self.select_course_page_ready = False |
| self.active_category = None |
|
|
| def _run_selection_round(self, driver: WebDriver, wait: WebDriverWait, stop_event, current_courses: list[dict]) -> dict[str, Any]: |
| if self.effective_mode == TASK_RUN_MODE_STABLE: |
| return self._run_stable_round(driver, wait, stop_event, current_courses) |
| return self._run_optimized_round(driver, wait, stop_event, current_courses) |
|
|
| def _run_stable_round(self, driver: WebDriver, wait: WebDriverWait, stop_event, current_courses: list[dict]) -> dict[str, Any]: |
| self.logger( |
| "INFO", |
| f"开始选课轮次:模式={self._mode_label(self.effective_mode)},待处理课程={self._course_batch_summary(current_courses)}。", |
| ) |
| if not self._goto_select_course(driver, wait): |
| self._invalidate_course_page_cache() |
| return {"attempts": 0, "successes": 0, "all_done": False, "not_selectable": True} |
|
|
| attempts = 0
|
| successes = 0
|
| all_done = False
|
| grouped_courses = self._group_courses_by_category(current_courses)
|
| for category, items in grouped_courses.items():
|
| if not items:
|
| continue
|
| for course in items:
|
| if stop_event.is_set():
|
| break
|
| if not self._is_course_still_queued(course):
|
| continue
|
| attempts += 1
|
| attempt_number = self._register_course_attempt()
|
| if self._attempt_single_course(
|
| driver,
|
| wait,
|
| course, |
| attempt_number=attempt_number, |
| reuse_page=False, |
| reuse_category=False, |
| ): |
| successes += 1 |
| self._maybe_probe_current_curriculum(driver, attempt_number=attempt_number)
|
| if not self.store.list_courses_for_user(self.user["id"]):
|
| all_done = True
|
| break
|
| if stop_event.is_set() or all_done:
|
| break
|
|
|
| return {"attempts": attempts, "successes": successes, "all_done": all_done, "not_selectable": False}
|
|
|
| def _run_optimized_round(self, driver: WebDriver, wait: WebDriverWait, stop_event, current_courses: list[dict]) -> dict[str, Any]: |
| self.logger( |
| "INFO", |
| f"开始选课轮次:模式={self._mode_label(self.effective_mode)},复用选课页面批量处理,待处理课程={self._course_batch_summary(current_courses)}。", |
| ) |
| if not self._ensure_select_course_page(driver, wait): |
| return {"attempts": 0, "successes": 0, "all_done": False, "not_selectable": True} |
|
|
| attempts = 0
|
| successes = 0
|
| all_done = False
|
| grouped_courses = self._group_courses_by_category(current_courses)
|
| for category, items in grouped_courses.items():
|
| active_items = [course for course in items if self._is_course_still_queued(course)]
|
| if not active_items:
|
| continue
|
| if stop_event.is_set():
|
| break
|
| self._ensure_category_open(driver, wait, category)
|
| for course in active_items:
|
| if stop_event.is_set():
|
| break
|
| if not self._is_course_still_queued(course):
|
| continue
|
| attempts += 1
|
| attempt_number = self._register_course_attempt()
|
| if self._attempt_single_course(
|
| driver,
|
| wait,
|
| course, |
| attempt_number=attempt_number, |
| reuse_page=True, |
| reuse_category=True, |
| ): |
| successes += 1 |
| self._maybe_probe_current_curriculum(driver, attempt_number=attempt_number)
|
| if not self.store.list_courses_for_user(self.user["id"]):
|
| all_done = True
|
| break
|
| if all_done:
|
| break
|
|
|
| return {"attempts": attempts, "successes": successes, "all_done": all_done, "not_selectable": False}
|
|
|
| @staticmethod
|
| def _group_courses_by_category(courses: list[dict]) -> dict[str, list[dict]]: |
| grouped_courses = {"plan": [], "free": []} |
| for course in courses: |
| grouped_courses.setdefault(course["category"], []).append(course) |
| return grouped_courses |
|
|
| def _course_batch_summary(self, courses: list[dict]) -> str: |
| grouped_courses = self._group_courses_by_category(courses) |
| plan_count = len(grouped_courses.get("plan") or []) |
| free_count = len(grouped_courses.get("free") or []) |
| return f"{len(courses)} 门(方案选课 {plan_count},自由选课 {free_count})" |
|
|
| def _course_key(self, course: dict) -> str: |
| return self._normalize_course_identity( |
| str(course.get("course_id") or ""), |
| str(course.get("course_index") or ""), |
| ) or f'{course.get("course_id", "")}_{course.get("course_index", "")}' |
|
|
| @staticmethod |
| def _raw_course_key(course: dict) -> str: |
| return f"{str(course.get('course_id') or '').strip()}_{str(course.get('course_index') or '').strip()}" |
|
|
| @staticmethod |
| def _is_already_selected_detail(detail: str) -> bool: |
| text = str(detail or "").strip() |
| if not text: |
| return False |
| normalized_text = text.lower() |
| success_tokens = ( |
| "已选", |
| "已在课表", |
| "已经选", |
| "重复", |
| "无需", |
| "成功", |
| "selected", |
| "already", |
| "duplicate", |
| "success", |
| ) |
| return any(token in text for token in success_tokens[:6]) or any( |
| token in normalized_text for token in success_tokens[6:] |
| ) |
|
|
| def _ensure_select_course_page(self, driver: WebDriver, wait: WebDriverWait, *, force_reload: bool = False) -> bool:
|
| if force_reload or not self.select_course_page_ready:
|
| if not self._goto_select_course(driver, wait):
|
| self._invalidate_course_page_cache()
|
| return False
|
| self.select_course_page_ready = True
|
| self.active_category = None
|
| return True
|
|
|
| def _ensure_category_open(self, driver: WebDriver, wait: WebDriverWait, category: str) -> None:
|
| if not self.select_course_page_ready:
|
| self._ensure_select_course_page(driver, wait, force_reload=True)
|
| if self.active_category == category:
|
| return
|
| self._open_category_tab(driver, wait, category)
|
| self.active_category = category
|
|
|
| def _on_mode_activity_success(self, *, reset_mode_failures: bool = True) -> None:
|
| if reset_mode_failures:
|
| self.mode_failure_count = 0
|
|
|
| def _register_mode_failure(self, reason: str) -> None:
|
| if self.effective_mode == TASK_RUN_MODE_STABLE:
|
| return
|
| self.mode_failure_count += 1 |
| self.logger( |
| "WARNING", |
| f"{reason};模式异常计数 {self.mode_failure_count}/{MODE_DOWNGRADE_THRESHOLD}。", |
| ) |
| if self.mode_failure_count >= MODE_DOWNGRADE_THRESHOLD:
|
| self._downgrade_to_stable(reason)
|
|
|
| def _downgrade_to_stable(self, reason: str) -> None:
|
| if self.effective_mode == TASK_RUN_MODE_STABLE:
|
| return
|
| previous_mode = self.effective_mode |
| self.effective_mode = TASK_RUN_MODE_STABLE |
| self.mode_failure_count = 0 |
| self.store.update_task_mode(self.task_id, effective_mode=TASK_RUN_MODE_STABLE) |
| self._invalidate_course_page_cache()
|
| self.logger( |
| "WARNING", |
| f"连续异常达到阈值,已从 {self._mode_label(previous_mode)} 降级为 {self._mode_label(TASK_RUN_MODE_STABLE)}。原因: {reason}", |
| ) |
|
|
| @staticmethod |
| def _mode_label(mode: str) -> str: |
| return TASK_RUN_MODE_LABELS.get(mode, mode) |
|
|
| @staticmethod |
| def _login_mode_label(mode: str) -> str: |
| return LOGIN_MODE_LABELS.get(mode, mode) |
|
|
| def _browser_proxy_label(self) -> str: |
| if not self.use_browser_proxy: |
| return "关闭" |
| proxy_url = self._current_browser_proxy_url() |
| if not proxy_url: |
| if self.browser_proxy_disabled_reason: |
| return f"已回退直连({self.browser_proxy_disabled_reason})" |
| return "未配置" |
| try: |
| proxy = webdriver_utils.parse_browser_proxy(proxy_url) |
| except Exception: |
| return "配置无效" |
| return "未启用" if proxy is None else proxy.display_label |
|
|
| def _current_browser_proxy_url(self) -> str: |
| if not self.use_browser_proxy: |
| return "" |
| if self.browser_proxy_disabled_reason: |
| return "" |
| return str(getattr(self.config, "browser_proxy_url", "") or "").strip() |
|
|
| def _browser_proxy_probe_targets(self) -> list[tuple[str, int, str]]: |
| targets = [("zhjw.scu.edu.cn", 80, "/login")] |
| if self.login_mode == LOGIN_MODE_UNIFIED: |
| targets.insert(0, ("id.scu.edu.cn", 80, "/")) |
| return targets |
|
|
| def _ensure_browser_proxy_available(self) -> None: |
| proxy_url = self._current_browser_proxy_url() |
| if not proxy_url: |
| return |
| passed_details: list[str] = [] |
| for target_host, target_port, request_path in self._browser_proxy_probe_targets(): |
| ok, detail = webdriver_utils.probe_browser_proxy( |
| proxy_url, |
| target_host=target_host, |
| target_port=target_port, |
| request_path=request_path, |
| timeout_seconds=8, |
| ) |
| if not ok: |
| self.browser_proxy_disabled_reason = "代理预检失败" |
| self.logger("WARNING", f"浏览器代理预检失败,已回退直连。详情: {detail}。") |
| return |
| passed_details.append(detail) |
| if passed_details: |
| self.logger("INFO", f"浏览器代理预检通过:{';'.join(passed_details)}。") |
| return |
|
|
| def _should_fallback_browser_proxy(self, exc: Exception) -> bool: |
| text = str(exc or "").lower() |
| return ( |
| "chrome-error://chromewebdata" in text |
| or "页面打开失败" in text |
| or "页面加载失败" in text |
| or "页面加载超时" in text |
| or "chrome 返回空白页面" in text |
| or "data:," in text |
| or "about:blank" in text |
| or "this site can" in text |
| or "err_tunnel" in text |
| or "err_proxy" in text |
| or "err_socks" in text |
| ) |
|
|
| def _ensure_account_login_form(self, driver: WebDriver, wait: WebDriverWait) -> None: |
| if ( |
| self._find_first_visible_optional(driver, LOGIN_STUDENT_SELECTORS, timeout=1) |
| and self._find_first_visible_optional(driver, LOGIN_PASSWORD_SELECTORS, timeout=1) |
| ): |
| return |
|
|
| account_tab = self._find_first_visible_optional(driver, ACCOUNT_LOGIN_TAB_SELECTORS, timeout=20) |
| if account_tab is None: |
| raise RecoverableAutomationError(f"页面元素未找到: 账号登录页签。{self._page_snapshot(driver, include_body=True)}") |
|
|
| try: |
| account_tab.click() |
| except WebDriverException: |
| driver.execute_script("arguments[0].click();", account_tab) |
| try: |
| wait.until( |
| lambda current_driver: ( |
| self._find_first_visible_optional(current_driver, LOGIN_STUDENT_SELECTORS, timeout=0) is not None |
| and self._find_first_visible_optional(current_driver, LOGIN_PASSWORD_SELECTORS, timeout=0) is not None |
| ) |
| ) |
| except TimeoutException as exc: |
| raise RecoverableAutomationError(f"账号登录表单未出现。{self._page_snapshot(driver, include_body=True)}") from exc |
|
|
| def _login_for_task(self, driver: WebDriver, wait: WebDriverWait) -> None: |
| if self.login_mode == LOGIN_MODE_ZHJW_DIRECT: |
| self._login_zhjw_direct(driver, wait) |
| return |
| self._login(driver, wait) |
|
|
| def _login_zhjw_direct(self, driver: WebDriver, wait: WebDriverWait) -> None: |
| for attempt in range(1, self.config.login_retry_limit + 1): |
| self._open_page(driver, wait, URL_ZHJW_LOGIN, f"教务直连登录页(第 {attempt} 次)", log_on_success=True) |
|
|
| student_box = self._find_first_visible(driver, ZHJW_LOGIN_STUDENT_SELECTORS, "教务直连学号输入框", timeout=8) |
| password_box = self._find_first_visible(driver, ZHJW_LOGIN_PASSWORD_SELECTORS, "教务直连密码输入框", timeout=8) |
| captcha_box = self._find_first_visible(driver, ZHJW_LOGIN_CAPTCHA_INPUT_SELECTORS, "教务直连验证码输入框", timeout=8) |
| captcha_image = self._find_first_visible(driver, ZHJW_LOGIN_CAPTCHA_IMAGE_SELECTORS, "教务直连验证码图片", timeout=8) |
| login_button = self._find_first_visible(driver, ZHJW_LOGIN_BUTTON_SELECTORS, "教务直连登录按钮", timeout=8) |
|
|
| student_box.clear() |
| student_box.send_keys(str(self.user.get("student_id") or "")) |
| password_box.clear() |
| password_box.send_keys(self.password) |
| captcha_text = self._solve_captcha_text(captcha_image, scene="教务直连登录") |
| self.logger( |
| "INFO", |
| f"教务直连登录尝试 {attempt}/{self.config.login_retry_limit},验证码 OCR 输出[{self._captcha_solver_engine_label()}]: {captcha_text}", |
| ) |
| captcha_box.clear() |
| captcha_box.send_keys(captcha_text) |
| self.logger("INFO", f"教务直连登录尝试 {attempt}/{self.config.login_retry_limit},准备提交登录表单。") |
|
|
| submit_method = self._trigger_non_blocking_action( |
| driver, |
| login_button, |
| label="教务直连登录按钮", |
| allow_form_submit=True, |
| prefer_click=True, |
| ) |
| self.logger("INFO", f"教务直连登录表单已触发提交,方式={submit_method},开始等待登录结果。") |
|
|
| state, error_message = self._wait_for_zhjw_direct_login_outcome(driver, timeout_seconds=12) |
| if state == "success": |
| return |
| if error_message: |
| self.logger("WARNING", f"教务直连登录失败,第 {attempt} 次尝试,提示: {error_message}") |
| if self._is_zhjw_direct_credentials_error(error_message): |
| self.logger( |
| "WARNING", |
| "教务直连页明确提示账号或密码错误,已停止直连重试并回退统一认证,避免触发教务直连账户锁定。", |
| ) |
| self.login_mode_fallback_to_unified = True |
| self.login_mode = LOGIN_MODE_UNIFIED |
| self._login(driver, wait) |
| return |
| else: |
| self.logger("WARNING", f"教务直连登录失败,第 {attempt} 次尝试,未读取到明确错误提示。{self._page_snapshot(driver, include_body=True)}") |
| time.sleep(0.8) |
|
|
| self.logger( |
| "WARNING", |
| "教务直连登录连续失败,疑似验证码识别不稳定或教务登录页暂不可用;本任务将回退统一认证登录。", |
| ) |
| self.login_mode_fallback_to_unified = True |
| self.login_mode = LOGIN_MODE_UNIFIED |
| self._login(driver, wait) |
|
|
| def _wait_for_zhjw_direct_login_outcome(self, driver: WebDriver, timeout_seconds: int = 12) -> tuple[str, str]: |
| deadline = time.monotonic() + max(1, timeout_seconds) |
| started_at = time.monotonic() |
| last_error = "" |
| while time.monotonic() < deadline: |
| try: |
| current_url = driver.current_url or "" |
| except WebDriverException: |
| time.sleep(0.3) |
| continue |
| if self._is_login_success_url(current_url) and "/login" not in current_url: |
| return "success", "" |
| last_error = self._read_login_error(driver) |
| if ( |
| last_error |
| and time.monotonic() - started_at >= 1.5 |
| and not self._is_passive_second_factor_prompt(last_error) |
| and not self._is_zhjw_direct_login_prompt(last_error) |
| ): |
| return "error", last_error |
| time.sleep(0.35) |
| return "unknown", last_error |
|
|
| @staticmethod |
| def _is_zhjw_direct_login_prompt(message: str) -> bool: |
| text = re.sub(r"\s+", "", str(message or "")) |
| if not text: |
| return False |
| prompts = { |
| "验证码", |
| "请输入验证码", |
| "学号", |
| "用户名", |
| "密码", |
| "请输入用户名", |
| "请输入密码", |
| "登录", |
| } |
| return text in prompts |
|
|
| @staticmethod |
| def _is_zhjw_direct_credentials_error(message: str) -> bool: |
| text = re.sub(r"\s+", "", str(message or "")) |
| if not text: |
| return False |
| credential_tokens = ( |
| "用户密码错误", |
| "用户名或密码错误", |
| "账号或密码错误", |
| "账户或密码错误", |
| "密码错误", |
| "用户不存在", |
| "账号不存在", |
| ) |
| return any(token in text for token in credential_tokens) |
|
|
| def _login(self, driver: WebDriver, wait: WebDriverWait) -> None: |
| for attempt in range(1, self.config.login_retry_limit + 1): |
| self._open_page(driver, wait, URL_LOGIN, f"登录页(第 {attempt} 次)", log_on_success=True) |
| self._ensure_account_login_form(driver, wait) |
|
|
| std_id_box = self._find_first_visible(driver, LOGIN_STUDENT_SELECTORS, "登录学号输入框", timeout=6) |
| password_box = self._find_first_visible(driver, LOGIN_PASSWORD_SELECTORS, "登录密码输入框", timeout=6) |
| captcha_box = self._find_first_visible(driver, LOGIN_CAPTCHA_INPUT_SELECTORS, "登录验证码输入框", timeout=6)
|
| login_button = self._find_first_visible(driver, LOGIN_BUTTON_SELECTORS, "登录按钮", timeout=6)
|
| captcha_image = self._find_first_visible(driver, LOGIN_CAPTCHA_IMAGE_SELECTORS, "登录验证码图片", timeout=6) |
|
|
| captcha_text = self._solve_captcha_text(captcha_image, scene="登录") |
| self.logger("INFO", f"登录尝试 {attempt}/{self.config.login_retry_limit},验证码 OCR 输出[{self._captcha_solver_engine_label()}]: {captcha_text}") |
|
|
| std_id_box.clear()
|
| std_id_box.send_keys(self.user["student_id"])
|
| password_box.clear()
|
| password_box.send_keys(self.password)
|
| captcha_box.clear()
|
| captcha_box.send_keys(captcha_text)
|
| self.logger("INFO", f"登录尝试 {attempt}/{self.config.login_retry_limit},准备提交登录表单。")
|
| submit_method = self._trigger_non_blocking_action(
|
| driver,
|
| login_button,
|
| label="登录表单",
|
| allow_form_submit=True,
|
| )
|
| self.logger("INFO", f"登录表单已触发提交,方式={submit_method},开始等待登录结果。") |
|
|
| state, error_message = self._wait_for_login_outcome(driver, timeout_seconds=10) |
| if state == "success": |
| self.logger("INFO", f"登录成功,耗费 {attempt} 次尝试。") |
| return |
| if state == "second_factor": |
| self.logger("INFO", "检测到统一认证短信二次验证页,准备进入短信验证码协助流程。") |
| self._complete_second_factor_login(driver, wait) |
| state, error_message = self._wait_for_login_outcome( |
| driver, |
| timeout_seconds=LOGIN_2FA_POST_SUBMIT_TIMEOUT_SECONDS, |
| detect_second_factor=False, |
| ) |
| if state == "success": |
| self.logger("INFO", f"短信认证通过,登录成功,耗费 {attempt} 次尝试。") |
| return |
| raise FatalCredentialsError( |
| error_message |
| or f"短信验证码提交后未进入教务系统,任务已停止,请重新启动任务。{self._page_snapshot(driver, include_body=True)}" |
| ) |
|
|
| if any(token in error_message for token in ("用户名或密码错误", "密码错误", "账号或密码错误", "用户不存在")): |
| raise FatalCredentialsError("学号或密码错误,任务已停止,请在面板中更新后重新启动。") |
|
|
| if error_message:
|
| self.logger("WARNING", f"登录失败,第 {attempt} 次尝试: {error_message}")
|
| else:
|
| self.logger(
|
| "WARNING",
|
| f"登录失败,第 {attempt} 次尝试,未读取到明确错误提示。{self._page_snapshot(driver, include_body=True)}",
|
| )
|
|
|
| time.sleep(0.6)
|
|
|
| raise RecoverableAutomationError("连续多次登录失败,可能是验证码识别失败、页面异常或系统暂时不可用。") |
|
|
| def _complete_second_factor_login(self, driver: WebDriver, wait: WebDriverWait) -> None: |
| if self.login_2fa_code_provider is None: |
| raise RecoverableAutomationError("检测到短信认证,但当前没有可用的验证码提交通道。") |
|
|
| self._reset_login_2fa_network_trace(driver) |
| phone_mask = self._read_second_factor_phone_mask(driver) |
| self.logger("INFO", f"短信认证页面已就绪,手机号={phone_mask},准备点击获取验证码。") |
| send_button = self._find_first_visible(driver, LOGIN_2FA_SEND_CODE_SELECTORS, "短信验证码发送按钮", timeout=6) |
| submit_method = self._trigger_non_blocking_action( |
| driver, |
| send_button, |
| label="短信验证码发送按钮", |
| allow_form_submit=False, |
| ) |
| self._wait_for_login_2fa_network_activity(driver, LOGIN_2FA_SEND_SMS_REQUEST_PATH, timeout_seconds=4) |
| self.logger( |
| "INFO", |
| f"已触发短信验证码发送,方式={submit_method},手机号={phone_mask},网络={self._login_2fa_network_summary() or '暂无'}。", |
| ) |
|
|
| try: |
| self.logger("INFO", "已进入短信验证码等待状态,等待用户或管理员在面板提交 6 位验证码。") |
| code = self.login_2fa_code_provider(phone_mask, self.stop_event) |
| except TimeoutError as exc: |
| raise FatalCredentialsError(str(exc)) from exc |
| except RuntimeError as exc: |
| raise FatalCredentialsError(str(exc)) from exc |
|
|
| normalized_code = re.sub(r"\D", "", str(code or "")) |
| if not re.fullmatch(r"\d{6}", normalized_code): |
| raise FatalCredentialsError("收到的短信验证码格式无效,任务已停止,请重新启动任务。") |
| if self.stop_event is not None and self.stop_event.is_set(): |
| raise FatalCredentialsError("短信验证码等待已停止。") |
|
|
| request_count_before = self._count_login_2fa_network_events(LOGIN_2FA_VALIDATION_REQUEST_PATH) |
| self.logger("INFO", "已收到面板提交的短信验证码,准备填入统一认证页面。") |
| submit_method = self._fill_second_factor_code(driver, normalized_code) |
|
|
| if not self._wait_for_login_2fa_network_activity( |
| driver, |
| LOGIN_2FA_VALIDATION_REQUEST_PATH, |
| timeout_seconds=5, |
| previous_count=request_count_before, |
| ): |
| submit_method = self._trigger_second_factor_component_validation(driver, normalized_code) |
| if submit_method: |
| self._wait_for_login_2fa_network_activity( |
| driver, |
| LOGIN_2FA_VALIDATION_REQUEST_PATH, |
| timeout_seconds=5, |
| previous_count=request_count_before, |
| ) |
| else: |
| submit_method = self._dispatch_second_factor_keyboard_fallback(driver, normalized_code) |
| self._wait_for_login_2fa_network_activity( |
| driver, |
| LOGIN_2FA_VALIDATION_REQUEST_PATH, |
| timeout_seconds=5, |
| previous_count=request_count_before, |
| ) |
|
|
| validation_events = self._latest_login_2fa_network_events(LOGIN_2FA_VALIDATION_REQUEST_PATH) |
| if len(validation_events) > request_count_before: |
| latest_event = validation_events[-1] |
| status = latest_event.get("status") or latest_event.get("failure") or latest_event.get("state") or "unknown" |
| self.logger( |
| "INFO", |
| f"短信验证码校验请求已触发,方式={submit_method},状态={status},网络={self._login_2fa_network_summary()},等待统一认证跳转。", |
| ) |
| else: |
| self.logger( |
| "WARNING", |
| f"短信验证码已填入,但未检测到统一认证校验请求,方式={submit_method},网络={self._login_2fa_network_summary() or '暂无'}。继续等待页面结果。", |
| ) |
|
|
| def _read_second_factor_phone_mask(self, driver: WebDriver) -> str: |
| script = """ |
| const body = document.body ? (document.body.innerText || '') : ''; |
| const match = body.match(/\\d{3}[\\s-]*(?:\\*|•|·|x|X){2,}[\\s-]*\\d{4}/); |
| return match ? match[0].replace(/[\\s-]/g, '').replace(/[•·xX]/g, '*') : ''; |
| """ |
| deadline = time.monotonic() + 4 |
| while time.monotonic() < deadline: |
| try: |
| phone_mask = str(driver.execute_script(script) or "").strip() |
| except WebDriverException: |
| phone_mask = "" |
| if phone_mask: |
| return phone_mask |
| self._drain_login_2fa_network_events(driver) |
| time.sleep(0.25) |
| return "绑定手机" |
|
|
| def _fill_second_factor_code(self, driver: WebDriver, code: str) -> str: |
| inputs = self._find_second_factor_code_inputs(driver) |
| if len(inputs) != 6: |
| raise RecoverableAutomationError(f"短信验证码输入框数量异常,当前找到 {len(inputs)} 个。") |
| self.logger("INFO", "已定位 6 个短信验证码输入框,开始逐位填入。") |
|
|
| try: |
| self._set_second_factor_input_values(driver, inputs, "") |
| except WebDriverException: |
| if self._has_left_second_factor_route(driver): |
| return "route-changed" |
|
|
| native_error: WebDriverException | None = None |
| try: |
| for index, digit in enumerate(code): |
| input_box = inputs[index] |
| input_box.click() |
| input_box.send_keys(digit) |
| time.sleep(0.08) |
| if self._second_factor_input_values_match(driver, inputs, code): |
| self.logger("INFO", "短信验证码已逐位填入统一认证页面,并完成 6 位输入框校验。") |
| return "native-per-field" |
| self.logger("WARNING", "短信验证码逐位填入后输入框校验未通过,准备切换到 DOM 事件回退方式。") |
| except WebDriverException as exc: |
| if self._has_left_second_factor_route(driver): |
| return "route-changed" |
| native_error = exc |
|
|
| try: |
| self._set_second_factor_input_values(driver, inputs, code) |
| if self._second_factor_input_values_match(driver, inputs, code): |
| self.logger("INFO", "短信验证码已通过 DOM 事件回退方式填入统一认证页面,并完成 6 位输入框校验。") |
| return "dom-events" |
| if self._has_left_second_factor_route(driver): |
| return "route-changed" |
| raise RecoverableAutomationError("短信验证码输入后校验失败。") |
| except WebDriverException as exc: |
| if self._has_left_second_factor_route(driver): |
| return "route-changed" |
| raise RecoverableAutomationError("短信验证码输入失败。") from (native_error or exc) |
|
|
| def _find_second_factor_code_inputs(self, driver: WebDriver) -> list[WebElement]: |
| primary_inputs = self._visible_elements_by_css(driver, LOGIN_2FA_PRIMARY_CODE_INPUT_SELECTOR) |
| if len(primary_inputs) == 6: |
| return primary_inputs |
|
|
| visible_inputs = self._find_visible_elements(driver, LOGIN_2FA_CODE_INPUT_SELECTORS) |
| if len(visible_inputs) == 6: |
| return visible_inputs |
|
|
| scoped_inputs = [] |
| for element in visible_inputs: |
| try: |
| in_code_group = driver.execute_script( |
| "return Boolean(arguments[0].closest('.sec-factor-six-ipt'));", |
| element, |
| ) |
| if in_code_group: |
| scoped_inputs.append(element) |
| except WebDriverException: |
| continue |
| return scoped_inputs if len(scoped_inputs) == 6 else visible_inputs |
|
|
| @staticmethod |
| def _visible_elements_by_css(driver: WebDriver, selector: str) -> list[WebElement]: |
| visible_elements: list[WebElement] = [] |
| seen_ids: set[str] = set() |
| try: |
| elements = driver.find_elements(By.CSS_SELECTOR, selector) |
| except WebDriverException: |
| return visible_elements |
| for element in elements: |
| try: |
| if element.id in seen_ids or not element.is_displayed(): |
| continue |
| seen_ids.add(element.id) |
| visible_elements.append(element) |
| except WebDriverException: |
| continue |
| return visible_elements |
|
|
| def _has_left_second_factor_route(self, driver: WebDriver) -> bool: |
| try: |
| current_url = driver.current_url or "" |
| except WebDriverException: |
| return False |
| if self._is_login_success_url(current_url): |
| return True |
| normalized_url = current_url.lower() |
| return bool( |
| current_url |
| and "/second/auth" not in normalized_url |
| and "#/second/auth" not in normalized_url |
| ) |
|
|
| def _set_second_factor_input_values(self, driver: WebDriver, inputs: list[WebElement], code: str) -> None: |
| driver.execute_script( |
| """ |
| const inputs = arguments[0]; |
| const code = String(arguments[1] || ''); |
| const setNativeValue = (input, value) => { |
| const proto = Object.getPrototypeOf(input); |
| const descriptor = proto ? Object.getOwnPropertyDescriptor(proto, 'value') : null; |
| if (descriptor && typeof descriptor.set === 'function') { |
| descriptor.set.call(input, value); |
| } else { |
| input.value = value; |
| } |
| }; |
| const keyEvent = (input, type, digit) => { |
| input.dispatchEvent(new KeyboardEvent(type, { |
| key: digit, |
| code: 'Digit' + digit, |
| keyCode: digit.charCodeAt(0), |
| which: digit.charCodeAt(0), |
| bubbles: true, |
| cancelable: true |
| })); |
| }; |
| inputs.forEach((input, index) => { |
| const digit = code.charAt(index) || ''; |
| input.focus(); |
| if (digit) { |
| keyEvent(input, 'keydown', digit); |
| keyEvent(input, 'keypress', digit); |
| if (typeof InputEvent === 'function') { |
| input.dispatchEvent(new InputEvent('beforeinput', { |
| inputType: 'insertText', |
| data: digit, |
| bubbles: true, |
| cancelable: true |
| })); |
| } |
| } |
| setNativeValue(input, digit); |
| if (typeof InputEvent === 'function') { |
| input.dispatchEvent(new InputEvent('input', { |
| inputType: digit ? 'insertText' : 'deleteContentBackward', |
| data: digit || null, |
| bubbles: true, |
| cancelable: true |
| })); |
| } else { |
| input.dispatchEvent(new Event('input', { bubbles: true })); |
| } |
| if (digit) { |
| keyEvent(input, 'keyup', digit); |
| } |
| input.dispatchEvent(new Event('change', { bubbles: true })); |
| }); |
| """, |
| inputs, |
| code, |
| ) |
|
|
| def _second_factor_input_values_match(self, driver: WebDriver, inputs: list[WebElement], code: str) -> bool: |
| try: |
| current_value = str( |
| driver.execute_script( |
| """ |
| return Array.from(arguments[0]) |
| .slice(0, 6) |
| .map((input) => String(input.value || '').trim()) |
| .join(''); |
| """, |
| inputs, |
| ) |
| or "" |
| ) |
| except WebDriverException: |
| if self._has_left_second_factor_route(driver): |
| return True |
| return False |
| return current_value == code |
|
|
| def _reset_login_2fa_network_trace(self, driver: WebDriver) -> None: |
| self._login_2fa_network_events = {} |
| self._drain_login_2fa_network_events(driver) |
|
|
| def _wait_for_login_2fa_network_activity( |
| self, |
| driver: WebDriver, |
| path: str, |
| *, |
| timeout_seconds: float, |
| previous_count: int | None = None, |
| ) -> bool: |
| if previous_count is None: |
| previous_count = self._count_login_2fa_network_events(path) |
| deadline = time.monotonic() + max(0.2, timeout_seconds) |
| while time.monotonic() < deadline: |
| self._drain_login_2fa_network_events(driver) |
| if self._count_login_2fa_network_events(path) > previous_count: |
| return True |
| try: |
| current_url = driver.current_url or "" |
| except WebDriverException: |
| current_url = "" |
| if self._is_login_success_url(current_url): |
| return True |
| if current_url and "/second/auth" not in current_url and "#/second/auth" not in current_url: |
| return True |
| time.sleep(0.2) |
| return False |
|
|
| def _drain_login_2fa_network_events(self, driver: WebDriver) -> None: |
| try: |
| raw_entries = driver.get_log("performance") |
| except (ValueError, WebDriverException): |
| return |
|
|
| for entry in raw_entries: |
| try: |
| message = json.loads(str(entry.get("message") or "{}")).get("message") or {} |
| except (TypeError, json.JSONDecodeError): |
| continue |
| method = str(message.get("method") or "") |
| params = message.get("params") if isinstance(message.get("params"), dict) else {} |
| request_id = str(params.get("requestId") or "") |
| if not request_id: |
| continue |
|
|
| if method == "Network.requestWillBeSent": |
| request = params.get("request") if isinstance(params.get("request"), dict) else {} |
| url = str(request.get("url") or "") |
| matched_path = self._matched_login_2fa_trace_path(url) |
| if not matched_path: |
| continue |
| self._login_2fa_network_events[request_id] = { |
| "path": matched_path, |
| "method": str(request.get("method") or ""), |
| "url": self._sanitize_login_2fa_url(url), |
| "state": "sent", |
| "status": "", |
| "failure": "", |
| "timestamp": float(params.get("timestamp") or 0), |
| "wall_time": time.time(), |
| } |
| elif method == "Network.responseReceived": |
| response = params.get("response") if isinstance(params.get("response"), dict) else {} |
| url = str(response.get("url") or "") |
| event = self._login_2fa_network_events.get(request_id) |
| if event is None: |
| matched_path = self._matched_login_2fa_trace_path(url) |
| if not matched_path: |
| continue |
| event = { |
| "path": matched_path, |
| "method": "", |
| "url": self._sanitize_login_2fa_url(url), |
| "timestamp": float(params.get("timestamp") or 0), |
| "wall_time": time.time(), |
| } |
| self._login_2fa_network_events[request_id] = event |
| event["state"] = "response" |
| event["status"] = str(response.get("status") or "") |
| event["mime"] = str(response.get("mimeType") or "") |
| event["from_cache"] = bool(response.get("fromDiskCache") or response.get("fromPrefetchCache")) |
| elif method == "Network.loadingFinished": |
| event = self._login_2fa_network_events.get(request_id) |
| if event is not None: |
| event["state"] = "finished" |
| event["encoded_length"] = int(params.get("encodedDataLength") or 0) |
| elif method == "Network.loadingFailed": |
| event = self._login_2fa_network_events.get(request_id) |
| if event is not None: |
| event["state"] = "failed" |
| event["failure"] = str(params.get("errorText") or "") |
| event["canceled"] = bool(params.get("canceled")) |
|
|
| def _matched_login_2fa_trace_path(self, url: str) -> str: |
| text = str(url or "") |
| for path in LOGIN_2FA_NETWORK_TRACE_PATHS: |
| if path in text: |
| return path |
| return "" |
|
|
| @staticmethod |
| def _sanitize_login_2fa_url(url: str) -> str: |
| try: |
| parts = urlsplit(str(url or "")) |
| except ValueError: |
| return str(url or "").split("?", 1)[0][:140] |
| return f"{parts.scheme}://{parts.netloc}{parts.path}"[:140] |
|
|
| def _count_login_2fa_network_events(self, path: str) -> int: |
| return len(self._latest_login_2fa_network_events(path)) |
|
|
| def _latest_login_2fa_network_events(self, path: str) -> list[dict[str, Any]]: |
| events = [ |
| event |
| for event in self._login_2fa_network_events.values() |
| if str(event.get("path") or "") == path |
| ] |
| events.sort(key=lambda item: float(item.get("wall_time") or 0)) |
| return events |
|
|
| def _login_2fa_network_summary(self) -> str: |
| chunks = [] |
| for path in LOGIN_2FA_NETWORK_TRACE_PATHS: |
| events = self._latest_login_2fa_network_events(path) |
| if not events: |
| continue |
| latest = events[-1] |
| state = str(latest.get("state") or "unknown") |
| status = str(latest.get("status") or "").strip() |
| failure = str(latest.get("failure") or "").strip() |
| suffix = status or failure or state |
| chunks.append(f"{path}#{len(events)}:{suffix}") |
| return "; ".join(chunks) |
|
|
| def _trigger_second_factor_component_validation(self, driver: WebDriver, code: str) -> str: |
| try: |
| method = driver.execute_script( |
| """ |
| const code = String(arguments[0] || ''); |
| const root = document.querySelector('.sec-factor-six-ipt'); |
| if (!root || !/^\\d{6}$/.test(code)) return ''; |
| const inputs = Array.from(root.querySelectorAll('input[type="text"]')) |
| .filter((input) => input.offsetParent !== null || input.getClientRects().length > 0) |
| .slice(0, 6); |
| if (inputs.length !== 6) return ''; |
| |
| inputs.forEach((input, index) => { |
| input.focus(); |
| input.value = code.charAt(index); |
| input.dispatchEvent(new Event('input', { bubbles: true })); |
| input.dispatchEvent(new Event('change', { bubbles: true })); |
| }); |
| |
| const findVue = (start) => { |
| let node = start; |
| while (node) { |
| let vm = node.__vue__; |
| while (vm) { |
| if (typeof vm.vilidOtpCode === 'function' || typeof vm.inputCode === 'function') { |
| return vm; |
| } |
| vm = vm.$parent; |
| } |
| node = node.parentElement; |
| } |
| return null; |
| }; |
| |
| const vm = findVue(root); |
| if (vm && typeof vm.vilidOtpCode === 'function') { |
| vm.vilidOtpCode(); |
| return 'vue-vilidOtpCode'; |
| } |
| if (vm && typeof vm.inputCode === 'function') { |
| vm.inputCode(5, new KeyboardEvent('keyup', { |
| key: code.charAt(5), |
| code: 'Digit' + code.charAt(5), |
| keyCode: code.charCodeAt(5), |
| which: code.charCodeAt(5), |
| bubbles: true, |
| cancelable: true |
| })); |
| return 'vue-inputCode'; |
| } |
| return ''; |
| """, |
| code, |
| ) |
| except WebDriverException: |
| return "" |
| return str(method or "") |
|
|
| def _dispatch_second_factor_keyboard_fallback(self, driver: WebDriver, code: str) -> str: |
| try: |
| method = driver.execute_script( |
| """ |
| const code = String(arguments[0] || ''); |
| const root = document.querySelector('.sec-factor-six-ipt'); |
| if (!root || !/^\\d{6}$/.test(code)) return ''; |
| const inputs = Array.from(root.querySelectorAll('input[type="text"]')) |
| .filter((input) => input.offsetParent !== null || input.getClientRects().length > 0) |
| .slice(0, 6); |
| if (inputs.length !== 6) return ''; |
| |
| const fireKey = (input, type, digit) => { |
| input.dispatchEvent(new KeyboardEvent(type, { |
| key: digit, |
| code: 'Digit' + digit, |
| keyCode: digit.charCodeAt(0), |
| which: digit.charCodeAt(0), |
| bubbles: true, |
| cancelable: true |
| })); |
| }; |
| |
| inputs.forEach((input, index) => { |
| const digit = code.charAt(index); |
| input.focus(); |
| fireKey(input, 'keydown', digit); |
| fireKey(input, 'keypress', digit); |
| input.value = digit; |
| input.dispatchEvent(new InputEvent('input', { |
| inputType: 'insertText', |
| data: digit, |
| bubbles: true, |
| cancelable: true |
| })); |
| fireKey(input, 'keyup', digit); |
| input.dispatchEvent(new Event('change', { bubbles: true })); |
| }); |
| return 'dom-keyup'; |
| """, |
| code, |
| ) |
| except WebDriverException: |
| return "" |
| return str(method or "") |
|
|
| def _goto_select_course(self, driver: WebDriver, wait: WebDriverWait) -> bool: |
| self._open_page(driver, wait, URL_SELECT_COURSE, "选课页") |
| if self._is_session_expired(driver): |
| raise RecoverableAutomationError("检测到登录会话已失效,准备重新登录。")
|
|
|
| body_text = self._safe_body_text(driver)
|
| if "非选课" in body_text or "未到选课时间" in body_text:
|
| self.logger("INFO", body_text.strip() or "当前不是选课时段。")
|
| return False
|
| return True
|
|
|
| def _attempt_single_course(
|
| self,
|
| driver: WebDriver,
|
| wait: WebDriverWait,
|
| course: dict,
|
| *,
|
| attempt_number: int, |
| reuse_page: bool = False, |
| reuse_category: bool = False, |
| ) -> bool: |
| category_name = CATEGORY_META[course["category"]]["label"] |
| course_key = self._course_key(course) |
| self.logger( |
| "INFO", |
| f"开始尝试课程:类别={category_name},课程={course_key},累计尝试={attempt_number},模式={self._mode_label(self.effective_mode)}。", |
| ) |
|
|
| if reuse_page: |
| if not self._ensure_select_course_page(driver, wait): |
| self.logger("INFO", f"当前不是可选课状态,跳过课程 {course_key}。") |
| return False |
| else: |
| if not self._goto_select_course(driver, wait): |
| self._invalidate_course_page_cache() |
| self.logger("INFO", f"当前不是可选课状态,跳过课程 {course_key}。") |
| return False |
| self.select_course_page_ready = True |
| self.active_category = None |
|
|
| if reuse_category: |
| self._ensure_category_open(driver, wait, course["category"]) |
| else: |
| self._open_category_tab(driver, wait, course["category"]) |
| self.active_category = course["category"] |
|
|
| found_target = self._query_and_mark_course(driver, wait, course, course_key) |
| if not found_target: |
| self._on_mode_activity_success() |
| self.logger("INFO", f"查询结果中未发现目标课程 {course_key},本轮跳过。") |
| return False |
|
|
| self.logger("INFO", f"已定位目标课程 {course_key},准备提交选课请求。") |
| results = self._submit_with_optional_captcha(driver, wait, course_key) |
| if not results: |
| self._on_mode_activity_success() |
| self.logger("WARNING", f"课程 {course_key} 提交后未读取到选课结果。") |
| return False |
|
|
| satisfied = False
|
| for result in results:
|
| detail = (result.get("detail") or "").strip() |
| subject = (result.get("subject") or course_key).strip() |
| if result.get("result"): |
| self.logger("SUCCESS", f"提交结果:课程={subject},状态=成功。") |
| satisfied = True |
| elif self._is_already_selected_detail(detail): |
| self.logger("INFO", f"提交结果:课程={subject},状态=已在课表或无需重复处理,详情={detail or '无'}。") |
| satisfied = True |
| else: |
| self.logger("WARNING", f"提交结果:课程={subject},状态=失败,详情={detail or '未返回详情'}。") |
|
|
| if satisfied:
|
| self.store.remove_course_by_identity( |
| self.user["id"], |
| course["category"], |
| course["course_id"], |
| course["course_index"], |
| ) |
| self._on_mode_activity_success() |
| return True |
|
|
| self._on_mode_activity_success() |
| return False |
|
|
| def _query_and_mark_course(self, driver: WebDriver, wait: WebDriverWait, course: dict, course_key: str) -> bool: |
| raw_course_key = self._raw_course_key(course) |
| candidate_keys = [raw_course_key] |
| if course_key and course_key not in candidate_keys: |
| candidate_keys.append(course_key) |
| self.logger("INFO", f"正在查询课程 {course_key},查询条件=课程号 {course['course_id']},匹配候选={', '.join(candidate_keys)}。") |
| try: |
| wait.until(lambda current_driver: current_driver.find_element(By.ID, "ifra")) |
| driver.switch_to.frame("ifra") |
| course_id_box = self._find(driver, By.ID, "kch") |
| search_button = self._find(driver, By.ID, "queryButton") |
| course_id_box.clear()
|
| course_id_box.send_keys(course["course_id"])
|
| search_button.click() |
| wait.until( |
| lambda current_driver: current_driver.execute_script( |
| "const button = document.getElementById('queryButton'); return !button || !/正在|查询中|加载中|loading/i.test(button.innerText || '')" |
| ) |
| ) |
| except TimeoutException as exc: |
| self._invalidate_course_page_cache() |
| raise RecoverableAutomationError( |
| f"课程查询超时,未能确认查询按钮恢复。{self._page_snapshot(driver, include_body=True)}" |
| ) from exc |
| finally: |
| driver.switch_to.default_content() |
|
|
| time.sleep(self._ui_settle_seconds()) |
| self._wait_for_course_result_table(driver, wait) |
| try: |
| found_target = False |
| matched_key = "" |
| for candidate_key in candidate_keys: |
| if driver.execute_script(self.select_course_js, candidate_key) == "yes": |
| found_target = True |
| matched_key = candidate_key |
| break |
| except WebDriverException as exc: |
| self._invalidate_course_page_cache() |
| raise RecoverableAutomationError( |
| f"读取课程查询结果失败。{self._page_snapshot(driver, include_body=True)}" |
| ) from exc |
|
|
| if found_target: |
| self.logger("INFO", f"查询完成:课程={course_key},匹配键={matched_key},结果=找到目标课程。") |
| else: |
| diagnostic = self._course_result_table_diagnostic(driver) |
| self.logger("INFO", f"查询完成:课程={course_key},结果=未找到目标课程。{diagnostic}") |
| return found_target |
|
|
| def _wait_for_course_result_table(self, driver: WebDriver, wait: WebDriverWait) -> None: |
| try: |
| wait.until( |
| lambda current_driver: current_driver.execute_script( |
| """ |
| const iframe = document.getElementById('ifra'); |
| const doc = iframe ? (iframe.contentDocument || iframe.contentWindow.document) : null; |
| return Boolean(doc && doc.getElementById('xirxkxkbody')); |
| """ |
| ) |
| ) |
| except TimeoutException as exc: |
| self._invalidate_course_page_cache() |
| raise RecoverableAutomationError( |
| f"课程查询结果表未准备好。{self._page_snapshot(driver, include_body=True)}" |
| ) from exc |
|
|
| def _course_result_table_diagnostic(self, driver: WebDriver) -> str: |
| try: |
| payload = driver.execute_script( |
| """ |
| const iframe = document.getElementById('ifra'); |
| const doc = iframe ? (iframe.contentDocument || iframe.contentWindow.document) : null; |
| const rows = doc ? Array.from(doc.querySelectorAll('#xirxkxkbody tr')) : []; |
| const samples = rows.slice(0, 5).map((row) => { |
| const cells = row.getElementsByTagName('td'); |
| return cells.length >= 3 ? (cells[2].innerText || cells[2].innerHTML || '').trim() : ''; |
| }).filter(Boolean); |
| return JSON.stringify({ row_count: rows.length, samples }); |
| """ |
| ) |
| data = json.loads(payload) if isinstance(payload, str) else payload |
| except Exception: |
| return "未能读取结果表诊断信息。" |
| row_count = int((data or {}).get("row_count") or 0) |
| samples = [str(item).replace("\n", " ").strip() for item in ((data or {}).get("samples") or [])] |
| sample_text = ";".join(samples)[:240] if samples else "无" |
| return f"结果表行数={row_count},样例={sample_text}。" |
|
|
| def _ui_settle_seconds(self) -> float: |
| if self.effective_mode == TASK_RUN_MODE_STABLE: |
| return STABLE_UI_SETTLE_SECONDS |
| return OPTIMIZED_UI_SETTLE_SECONDS |
|
|
| def _register_course_attempt(self) -> int: |
| self.selection_attempts += 1
|
| self.store.increment_task_attempts(self.task_id)
|
| return self.selection_attempts
|
|
|
| def _maybe_probe_current_curriculum(self, driver: WebDriver, *, attempt_number: int) -> None:
|
| if attempt_number <= 0 or attempt_number % 20 != 0:
|
| return
|
| self.logger("INFO", f"累计选课尝试 {attempt_number} 次,开始回查本学期课表。")
|
| try:
|
| matched_courses = self._reconcile_selected_courses_via_callback(driver)
|
| except Exception as exc:
|
| self.logger("WARNING", f"回查本学期课表失败: {exc}")
|
| return
|
|
|
| if matched_courses:
|
| self.logger("INFO", f"回查完成,新增确认 {len(matched_courses)} 门已在课表中的课程。")
|
| else:
|
| self.logger("INFO", "回查完成,暂未发现新增已选课程。")
|
|
|
| def _reconcile_selected_courses_via_callback(self, driver: WebDriver) -> list[dict]:
|
| payload = self._fetch_current_curriculum_payload(driver)
|
| return self._reconcile_selected_courses_from_payload(payload)
|
|
|
| def _fetch_current_curriculum_payload(self, driver: WebDriver) -> dict:
|
| try:
|
| cookies = driver.get_cookies()
|
| except WebDriverException as exc:
|
| raise RecoverableAutomationError(f"读取浏览器会话 Cookie 失败: {exc}") from exc
|
| if not cookies:
|
| raise RecoverableAutomationError("当前浏览器会话没有可用 Cookie,无法回查本学期课表。")
|
|
|
| cookie_header = "; ".join(
|
| f"{cookie['name']}={cookie['value']}"
|
| for cookie in cookies
|
| if cookie.get("name") and cookie.get("value")
|
| )
|
| if not cookie_header:
|
| raise RecoverableAutomationError("当前浏览器会话没有可用 Cookie,无法回查本学期课表。")
|
|
|
| try:
|
| referer = driver.current_url or URL_SELECT_COURSE
|
| except WebDriverException:
|
| referer = URL_SELECT_COURSE
|
|
|
| request = urllib.request.Request(
|
| URL_CURRICULUM_CALLBACK,
|
| headers={
|
| "Accept": "application/json, text/plain, */*",
|
| "Cookie": cookie_header,
|
| "Referer": referer,
|
| "User-Agent": webdriver_utils.DEFAULT_USER_AGENT,
|
| "X-Requested-With": "XMLHttpRequest",
|
| },
|
| )
|
| try:
|
| with urllib.request.urlopen(request, timeout=20) as response:
|
| status = getattr(response, "status", 200)
|
| body = response.read().decode("utf-8", errors="replace")
|
| except urllib.error.URLError as exc:
|
| raise RecoverableAutomationError(f"访问本学期课表回查接口失败: {exc}") from exc
|
|
|
| if int(status) >= 400:
|
| raise RecoverableAutomationError(f"本学期课表回查接口返回异常状态码: {status}")
|
| try:
|
| payload = json.loads(body)
|
| except json.JSONDecodeError as exc:
|
| snippet = body.strip().replace("\n", " ")[:180]
|
| raise RecoverableAutomationError(f"本学期课表回查接口返回了非 JSON 内容: {snippet}") from exc
|
| if not isinstance(payload, dict):
|
| raise RecoverableAutomationError("本学期课表回查接口返回的数据结构不是对象。")
|
| return payload
|
|
|
| def _reconcile_selected_courses_from_payload(self, payload: dict) -> list[dict]:
|
| selected_map = self._extract_curriculum_course_entries(payload)
|
| if not selected_map:
|
| return []
|
|
|
| matched_courses: list[dict] = []
|
| current_courses = self.store.list_courses_for_user(self.user["id"])
|
| for course in current_courses:
|
| course_key = self._normalize_course_identity(course["course_id"], course["course_index"])
|
| detail = selected_map.get(course_key)
|
| if not detail:
|
| continue
|
|
|
| self.store.remove_course_by_identity(
|
| self.user["id"],
|
| course["category"],
|
| course["course_id"],
|
| course["course_index"],
|
| )
|
| course_name = str(detail.get("courseName") or course_key).strip()
|
| status_name = str(detail.get("selectCourseStatusName") or detail.get("selectCourseStatusCode") or "已在课表中").strip()
|
| self.logger(
|
| "SUCCESS",
|
| f"回查确认课程已在本学期课表中: {course_name} ({course_key}),状态={status_name},已自动从队列移除。",
|
| )
|
| matched_courses.append({"course_key": course_key, "course_name": course_name, "status_name": status_name})
|
| return matched_courses
|
|
|
| def _extract_curriculum_course_entries(self, payload: dict) -> dict[str, dict]:
|
| selected_map: dict[str, dict] = {}
|
| for entry in payload.get("xkxx") or []:
|
| if not isinstance(entry, dict):
|
| continue
|
| for raw_key, detail in entry.items():
|
| if not isinstance(detail, dict):
|
| continue
|
| normalized_key = self._normalize_callback_course_key(raw_key, detail)
|
| if normalized_key:
|
| selected_map[normalized_key] = detail
|
| return selected_map
|
|
|
| @staticmethod
|
| def _normalize_course_identity(course_id: str, course_index: str) -> str:
|
| normalized_course_id = str(course_id or "").strip().upper()
|
| normalized_course_index = str(course_index or "").strip().upper()
|
| if not normalized_course_id or not normalized_course_index:
|
| return ""
|
| return f"{normalized_course_id}_{normalized_course_index}"
|
|
|
| def _normalize_callback_course_key(self, raw_key: str, detail: dict | None = None) -> str:
|
| raw_text = str(raw_key or "").strip()
|
| if raw_text and "_" in raw_text:
|
| course_id, course_index = raw_text.rsplit("_", 1)
|
| normalized = self._normalize_course_identity(course_id, course_index)
|
| if normalized:
|
| return normalized
|
|
|
| detail_id = detail.get("id") if isinstance(detail, dict) else None
|
| if isinstance(detail_id, dict):
|
| return self._normalize_course_identity(
|
| str(detail_id.get("coureNumber") or ""),
|
| str(detail_id.get("coureSequenceNumber") or ""),
|
| )
|
| return ""
|
|
|
| def _is_course_still_queued(self, course: dict) -> bool:
|
| return any(item["id"] == course["id"] for item in self.store.list_courses_for_user(self.user["id"]))
|
|
|
| def _current_poll_interval_seconds(self) -> int:
|
| latest_user = self.store.get_user(self.user["id"])
|
| if latest_user is not None:
|
| self.user = latest_user
|
| try:
|
| interval = int((latest_user or self.user).get("refresh_interval_seconds") or self.config.poll_interval_seconds)
|
| except (TypeError, ValueError, AttributeError): |
| interval = int(self.config.poll_interval_seconds) |
| return max(1, min(120, interval)) |
|
|
| def _submit_with_optional_captcha(self, driver: WebDriver, wait: WebDriverWait, course_key: str) -> list[dict]: |
| submit_button = self._find(driver, By.ID, "submitButton") |
| submit_method = self._trigger_non_blocking_action( |
| driver, |
| submit_button, |
| label=f"选课提交 {course_key}", |
| allow_form_submit=True, |
| ) |
| self._invalidate_course_page_cache() |
| self.logger("INFO", f"已触发选课提交,课程={course_key},方式={submit_method}。") |
|
|
| for attempt in range(1, self.config.submit_captcha_retry_limit + 1): |
| state = self._wait_for_submit_state(driver, wait) |
| if state == "result": |
| self.logger("INFO", f"提交后检测到结果页,课程={course_key}。") |
| return self._read_result_page(driver, wait) |
| if state == "captcha": |
| self.logger("INFO", f"提交后出现验证码,课程={course_key},第 {attempt}/{self.config.submit_captcha_retry_limit} 次处理。") |
| if not self._solve_visible_submit_captcha(driver): |
| raise RecoverableAutomationError("提交验证码识别或输入控件未准备好。") |
| continue |
| self.logger("WARNING", f"提交后暂未出现结果或验证码,课程={course_key},继续等待下一次检测。") |
|
|
| raise RecoverableAutomationError("提交后多次等待仍未出现结果或验证码。") |
|
|
| def _wait_for_submit_state(self, driver: WebDriver, wait: WebDriverWait) -> str:
|
| script = """
|
| const visible = (el) => {
|
| if (!el) return false;
|
| const style = window.getComputedStyle(el);
|
| const rect = el.getBoundingClientRect();
|
| return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
|
| };
|
| if (document.querySelector('#xkresult tbody tr')) return 'result';
|
| const input = Array.from(document.querySelectorAll('input')).find((el) => {
|
| const text = `${el.placeholder || ''} ${el.id || ''} ${el.name || ''} ${el.className || ''}`;
|
| return visible(el) && /验证码|captcha/i.test(text);
|
| });
|
| const img = Array.from(document.querySelectorAll('img')).find((el) => {
|
| const text = `${el.src || ''} ${el.id || ''} ${el.alt || ''} ${el.className || ''}`;
|
| return visible(el) && (/captcha/i.test(text) || (el.src || '').includes('base64') || (el.naturalWidth >= 50 && el.naturalWidth <= 240 && el.naturalHeight >= 20 && el.naturalHeight <= 120));
|
| });
|
| const button = Array.from(document.querySelectorAll('button, input[type="button"], input[type="submit"]')).find((el) => {
|
| const text = `${el.innerText || ''} ${el.value || ''}`;
|
| return visible(el) && /确定|提交|确认|验证/.test(text);
|
| });
|
| if (input && img && button) return 'captcha';
|
| return 'pending';
|
| """
|
| try:
|
| wait.until(lambda current_driver: current_driver.execute_script(script) != "pending")
|
| except TimeoutException:
|
| return "pending"
|
| return str(driver.execute_script(script))
|
|
|
| def _solve_visible_submit_captcha(self, driver: WebDriver) -> bool:
|
| image = self._find_first_visible_optional(driver, SUBMIT_CAPTCHA_IMAGE_SELECTORS, timeout=3)
|
| input_box = self._find_first_visible_optional(driver, SUBMIT_CAPTCHA_INPUT_SELECTORS, timeout=3)
|
| button = self._find_first_visible_optional(driver, SUBMIT_CAPTCHA_BUTTON_SELECTORS, timeout=3)
|
| if not image or not input_box or not button: |
| return False |
|
|
| captcha_text = self._solve_captcha_text(image, scene="提交") |
| self.logger("INFO", f"提交验证码 OCR 输出[{self._captcha_solver_engine_label()}]: {captcha_text}") |
| input_box.clear()
|
| input_box.send_keys(captcha_text)
|
| submit_method = self._trigger_non_blocking_action(
|
| driver,
|
| button,
|
| label="提交验证码确认按钮",
|
| allow_form_submit=True,
|
| )
|
| self.logger("INFO", f"提交验证码确认已触发,方式={submit_method}。")
|
| time.sleep(1.0)
|
| return True
|
|
|
| def _open_category_tab(self, driver: WebDriver, wait: WebDriverWait, category: str) -> None: |
| tab_id = CATEGORY_META[category]["tab_id"] |
| tab = self._find(driver, By.ID, tab_id) |
| tab.click() |
| webdriver_utils.wait_for_ready(wait, allow_interactive=True) |
| try: |
| wait.until( |
| lambda current_driver: current_driver.execute_script( |
| """ |
| const iframe = document.getElementById('ifra'); |
| const doc = iframe ? (iframe.contentDocument || iframe.contentWindow.document) : null; |
| return Boolean(doc && (doc.readyState === 'interactive' || doc.readyState === 'complete')); |
| """ |
| ) |
| ) |
| except TimeoutException as exc: |
| self._invalidate_course_page_cache() |
| raise RecoverableAutomationError( |
| f"{CATEGORY_META[category]['label']}页签 iframe 未准备好。{self._page_snapshot(driver, include_body=True)}" |
| ) from exc |
| self.active_category = category |
| time.sleep(self._ui_settle_seconds()) |
|
|
| def _read_result_page(self, driver: WebDriver, wait: WebDriverWait) -> list[dict]:
|
| try:
|
| webdriver_utils.wait_for_ready(wait, allow_interactive=True)
|
| wait.until(
|
| lambda current_driver: current_driver.execute_script(
|
| """
|
| const node = document.querySelector('#xkresult tbody tr');
|
| return Boolean(node);
|
| """
|
| )
|
| )
|
| return json.loads(driver.execute_script(self.check_result_js))
|
| except Exception as exc:
|
| raise RecoverableAutomationError(
|
| f"读取选课结果失败,页面结构可能发生变化。{self._page_snapshot(driver, include_body=True)}"
|
| ) from exc
|
|
|
| def _open_page( |
| self, |
| driver: WebDriver, |
| wait: WebDriverWait, |
| url: str, |
| label: str,
|
| *,
|
| allow_interactive: bool = True, |
| log_on_success: bool = False, |
| ) -> None: |
| last_error: RecoverableAutomationError | None = None |
| for open_attempt in range(1, 3): |
| timed_out = webdriver_utils.open_with_recovery(driver, url) |
| if timed_out: |
| self.logger("WARNING", f"{label} 页面加载超时,尝试停止页面并继续执行。") |
| try: |
| ready_state = webdriver_utils.wait_for_ready(wait, allow_interactive=allow_interactive) |
| except TimeoutException as exc: |
| last_error = RecoverableAutomationError(f"{label} 页面加载失败。{self._page_snapshot(driver, include_body=True)}") |
| if open_attempt >= 2: |
| raise last_error from exc |
| self.logger("WARNING", f"{label} 第 {open_attempt} 次打开失败,准备立即重试。原因: {last_error}") |
| time.sleep(1.0) |
| continue |
| if self._is_chrome_error_page(driver): |
| last_error = RecoverableAutomationError(f"{label} 页面打开失败,Chrome 返回网络错误。{self._page_snapshot(driver, include_body=True)}") |
| if open_attempt >= 2: |
| raise last_error |
| self.logger("WARNING", f"{label} 第 {open_attempt} 次打开失败,准备立即重试。原因: {last_error}") |
| time.sleep(1.0) |
| continue |
| if self._is_blank_browser_page(driver): |
| last_error = RecoverableAutomationError(f"{label} 页面打开失败,Chrome 返回空白页面。{self._page_snapshot(driver, include_body=True)}") |
| if open_attempt >= 2: |
| raise last_error |
| self.logger("WARNING", f"{label} 第 {open_attempt} 次打开失败,准备立即重试。原因: {last_error}") |
| time.sleep(1.0) |
| continue |
| if log_on_success or timed_out or open_attempt > 1: |
| self.logger("INFO", f"{label} 页面已打开,readyState={ready_state}。{self._page_snapshot(driver, include_body=timed_out)}") |
| return |
| if last_error is not None: |
| raise last_error |
|
|
| def _wait_for_login_outcome( |
| self, |
| driver: WebDriver, |
| timeout_seconds: int = 10, |
| *, |
| detect_second_factor: bool = True, |
| ) -> tuple[str, str]: |
| deadline = time.monotonic() + max(1, timeout_seconds) |
| last_error = "" |
| wait_started_at = time.monotonic() |
| next_progress_log_at = wait_started_at + LOGIN_2FA_WAIT_LOG_INTERVAL_SECONDS |
| sso_advance_attempted = False |
| last_logged_url = "" |
| while time.monotonic() < deadline: |
| if not detect_second_factor: |
| self._drain_login_2fa_network_events(driver) |
| try: |
| current_url = driver.current_url or "" |
| except WebDriverException: |
| time.sleep(0.3) |
| continue |
| if not detect_second_factor and current_url != last_logged_url: |
| last_logged_url = current_url |
| self.logger( |
| "INFO", |
| f"短信认证后当前页面: {self._sanitize_snapshot_url(current_url) or '-'}。", |
| ) |
| if self._is_login_success_url(current_url): |
| return "success", "" |
| if detect_second_factor and self._is_second_factor_page(driver): |
| return "second_factor", "" |
| last_error = self._read_login_error(driver) |
| if not detect_second_factor and last_error and self._is_second_factor_failure_message(last_error): |
| return "error", last_error |
| if ( |
| not detect_second_factor |
| and not sso_advance_attempted |
| and self._has_successful_login_2fa_validation() |
| and time.monotonic() - wait_started_at >= LOGIN_2FA_SSO_ADVANCE_AFTER_SECONDS |
| ): |
| sso_advance_attempted = self._advance_second_factor_sso(driver) |
| if sso_advance_attempted: |
| time.sleep(0.8) |
| continue |
| if ( |
| last_error |
| and not detect_second_factor |
| and self._is_second_factor_page(driver) |
| and not self._is_second_factor_failure_message(last_error) |
| ): |
| time.sleep(0.4) |
| continue |
| if last_error and not detect_second_factor and self._is_passive_second_factor_prompt(last_error): |
| time.sleep(0.4) |
| continue |
| if last_error: |
| return "error", last_error |
| if not detect_second_factor and time.monotonic() >= next_progress_log_at: |
| elapsed_seconds = int(time.monotonic() - wait_started_at) |
| remaining_seconds = max(0, int(deadline - time.monotonic())) |
| self.logger( |
| "INFO", |
| "短信验证码已提交,仍在等待统一认证跳转。" |
| f" 已等待 {elapsed_seconds} 秒,剩余约 {remaining_seconds} 秒," |
| f"网络={self._login_2fa_network_summary() or '暂无'}。", |
| ) |
| next_progress_log_at = time.monotonic() + LOGIN_2FA_WAIT_LOG_INTERVAL_SECONDS |
| time.sleep(0.4) |
| if not detect_second_factor: |
| self._drain_login_2fa_network_events(driver) |
| final_error = self._read_login_error(driver) |
| if not detect_second_factor and self._is_passive_second_factor_prompt(final_error): |
| network_summary = self._login_2fa_network_summary() |
| final_error = ( |
| "短信验证码校验未完成或已超时,任务已停止,请重新启动任务。" |
| f" 2FA网络摘要: {network_summary or '暂无'}。{self._page_snapshot(driver, include_body=True)}" |
| ) |
| return "unknown", final_error |
|
|
| def _has_successful_login_2fa_validation(self) -> bool: |
| events = self._latest_login_2fa_network_events(LOGIN_2FA_VALIDATION_REQUEST_PATH) |
| if not events: |
| return False |
| latest_event = events[-1] |
| failure = str(latest_event.get("failure") or "").strip() |
| if failure: |
| return False |
| try: |
| status = int(str(latest_event.get("status") or "0")) |
| except ValueError: |
| return False |
| return 200 <= status < 300 |
|
|
| def _advance_second_factor_sso(self, driver: WebDriver) -> bool: |
| self.logger( |
| "INFO", |
| "短信验证码校验请求已成功,但统一认证尚未自动跳转;" |
| "正在主动访问教务选课入口以触发 SSO 回调。", |
| ) |
| try: |
| timed_out = webdriver_utils.open_with_recovery(driver, URL_SELECT_COURSE) |
| except WebDriverException as exc: |
| self.logger("WARNING", f"主动触发 SSO 回调失败: {exc}") |
| return False |
| if timed_out: |
| self.logger("WARNING", "主动触发 SSO 回调时页面加载超时,已停止加载并继续等待登录结果。") |
| else: |
| self.logger("INFO", f"主动触发 SSO 回调完成。{self._page_snapshot(driver)}") |
| return True |
|
|
| def _is_second_factor_page(self, driver: WebDriver) -> bool: |
| try: |
| body_text = self._safe_body_text(driver) |
| except Exception: |
| body_text = "" |
| normalized_body = body_text.lower() |
| if "短信认证" in body_text or "sms verification" in normalized_body: |
| return True |
| if "security code" in normalized_body and "/second/auth" in (driver.current_url or ""): |
| return True |
| if self._find_first_visible_optional(driver, LOGIN_2FA_SEND_CODE_SELECTORS, timeout=0) is not None: |
| return True |
| return len(self._find_visible_elements(driver, LOGIN_2FA_CODE_INPUT_SELECTORS)) == 6 |
|
|
| @staticmethod |
| def _is_passive_second_factor_prompt(message: str) -> bool: |
| text = str(message or "") |
| if not text: |
| return False |
| if CourseBot._is_second_factor_failure_message(text): |
| return False |
| normalized_text = text.lower() |
| return ( |
| "短信认证" in text |
| or "获取验证码" in text |
| or "6位安全码" in text |
| or "sms verification" in normalized_text |
| or "security code" in normalized_text |
| or "authentication code" in normalized_text |
| ) |
|
|
| @staticmethod |
| def _is_second_factor_failure_message(message: str) -> bool: |
| text = str(message or "") |
| normalized_text = text.lower() |
| return any(token in text for token in ("错误", "失败", "过期", "不正确", "无效")) or any( |
| token in normalized_text |
| for token in ("error", "failed", "expired", "incorrect", "invalid") |
| ) |
|
|
| def _read_login_error(self, driver: WebDriver) -> str:
|
| script = """
|
| const visible = (node) => {
|
| if (!node) return false;
|
| const style = window.getComputedStyle(node);
|
| const rect = node.getBoundingClientRect();
|
| return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
|
| };
|
| const selectors = [ |
| '.el-message', |
| '.ivu-message', |
| '.ivu-notice', |
| '.ivu-form-item-error-tip', |
| '[role="alert"]', |
| '.message', |
| '.toast', |
| '.el-form-item__error', |
| '.error' |
| ];
|
| for (const selector of selectors) {
|
| for (const node of Array.from(document.querySelectorAll(selector))) {
|
| const text = (node.innerText || '').trim();
|
| if (visible(node) && text) {
|
| return text;
|
| }
|
| }
|
| }
|
| const nodes = Array.from(document.querySelectorAll('body span, body div')); |
| for (const node of nodes) { |
| const text = (node.innerText || '').trim(); |
| if (!text || !visible(node)) continue; |
| if (/验证码|密码|用户|账号|认证|校验|安全码/.test(text)) return text; |
| if (/verification|captcha|code|password|account|user|invalid|expired|failed|incorrect|error|authentication|security/i.test(text)) return text; |
| } |
| return ''; |
| """ |
| try:
|
| raw_message = driver.execute_script(script) or ""
|
| except WebDriverException:
|
| return ""
|
| return re.sub(r"\s+", " ", str(raw_message)).strip()
|
|
|
| def _trigger_non_blocking_action(
|
| self,
|
| driver: WebDriver,
|
| element: WebElement,
|
| *, |
| label: str, |
| allow_form_submit: bool = False, |
| prefer_click: bool = False, |
| ) -> str: |
| script = """ |
| const target = arguments[0]; |
| const allowFormSubmit = Boolean(arguments[1]); |
| const preferClick = Boolean(arguments[2]); |
| const form = allowFormSubmit && target ? (target.form || target.closest('form')) : null; |
| let method = 'unavailable'; |
| |
| if (preferClick && target && typeof target.click === 'function') { |
| method = 'scheduled-js-click'; |
| } else if (form && typeof form.requestSubmit === 'function') { |
| method = 'scheduled-requestSubmit'; |
| } else if (target && typeof target.click === 'function') { |
| method = 'scheduled-js-click'; |
| } else if (target) { |
| method = 'scheduled-dispatch-click'; |
| } else if (form && typeof form.submit === 'function') {
|
| method = 'scheduled-form-submit';
|
| }
|
|
|
| if (!target && !form) {
|
| return 'unavailable';
|
| }
|
|
|
| window.setTimeout(() => {
|
| const dispatchFallback = (node) => {
|
| if (!node) return false;
|
| ['pointerdown', 'mousedown', 'mouseup', 'click'].forEach((type) => {
|
| node.dispatchEvent(new MouseEvent(type, {
|
| bubbles: true,
|
| cancelable: true,
|
| view: window,
|
| }));
|
| });
|
| return true;
|
| };
|
|
|
| try {
|
| if (target && typeof target.scrollIntoView === 'function') {
|
| target.scrollIntoView({block: 'center', inline: 'center'});
|
| } |
| } catch (error) {} |
| |
| if (preferClick) { |
| try { |
| if (target && typeof target.click === 'function') { |
| target.click(); |
| return; |
| } |
| } catch (error) {} |
| |
| try { |
| if (dispatchFallback(target)) { |
| return; |
| } |
| } catch (error) {} |
| } |
| |
| try { |
| if (form && typeof form.requestSubmit === 'function') { |
| form.requestSubmit(target || undefined); |
| return; |
| } |
| } catch (error) {}
|
|
|
| try {
|
| if (target && typeof target.click === 'function') {
|
| target.click();
|
| return;
|
| }
|
| } catch (error) {}
|
|
|
| try {
|
| if (dispatchFallback(target)) {
|
| return;
|
| }
|
| } catch (error) {}
|
|
|
| try {
|
| if (form && typeof form.submit === 'function') {
|
| form.submit();
|
| }
|
| } catch (error) {}
|
| }, 0);
|
|
|
| return method; |
| """ |
| try: |
| method = str(driver.execute_script(script, element, allow_form_submit, prefer_click) or "").strip() |
| if method and method != "unavailable":
|
| return method
|
| self.logger("WARNING", f"{label} 的 JS 非阻塞提交未找到可用方式,回退到原生点击。")
|
| except Exception as exc:
|
| self.logger("WARNING", f"{label} 的 JS 非阻塞提交失败,回退到原生点击。原因: {exc}")
|
|
|
| try:
|
| element.click()
|
| return "native-click"
|
| except TimeoutException:
|
| self.logger("WARNING", f"{label} 的原生点击触发后页面响应超时,已执行 window.stop() 并继续等待。")
|
| self._stop_loading(driver)
|
| return "native-click-timeout"
|
| except WebDriverException as exc:
|
| raise RecoverableAutomationError(f"{label} 触发失败: {exc}") from exc
|
|
|
| @staticmethod |
| def _stop_loading(driver: WebDriver) -> None: |
| try: |
| driver.execute_script("window.stop();") |
| except Exception: |
| pass |
|
|
| def _captcha_solver_engine_label(self) -> str: |
| engine = str(getattr(self.captcha_solver, "last_engine_name", "") or "").strip() |
| return engine or "unknown" |
|
|
| def _solve_captcha_text(self, image_element: WebElement, *, scene: str) -> str: |
| last_candidate = "" |
| for attempt in range(1, 3): |
| raw_text = self.captcha_solver.classification(self._extract_image_bytes(image_element)) |
| normalized = re.sub(r"[^0-9A-Za-z]", "", str(raw_text or "")).strip().lower() |
| if len(normalized) >= 4: |
| return normalized[:4] |
| last_candidate = normalized
|
| self.logger("WARNING", f"{scene}验证码 OCR 输出异常,第 {attempt} 次结果: {raw_text!r}")
|
| try:
|
| image_element.click()
|
| time.sleep(0.4)
|
| except Exception:
|
| pass
|
| if len(last_candidate) >= 3:
|
| return last_candidate[:4]
|
| raise RecoverableAutomationError(f"{scene}验证码 OCR 未能识别出有效内容。")
|
|
|
| def _is_login_success_url(self, url: str) -> bool:
|
| if not url:
|
| return False
|
| if any(url.startswith(prefix) for prefix in LOGIN_SUCCESS_PREFIXES):
|
| return True
|
| return "zhjw.scu.edu.cn" in url and "id.scu.edu.cn" not in url
|
|
|
| def _is_session_expired(self, driver: WebDriver) -> bool: |
| current_url = driver.current_url or "" |
| if "id.scu.edu.cn" in current_url: |
| return True |
| password_box = self._find_first_visible_optional(driver, LOGIN_PASSWORD_SELECTORS, timeout=1) |
| return password_box is not None |
|
|
| def _is_chrome_error_page(self, driver: WebDriver) -> bool: |
| try: |
| current_url = driver.current_url or "" |
| except WebDriverException: |
| return False |
| return current_url.startswith("chrome-error://") |
|
|
| def _is_blank_browser_page(self, driver: WebDriver) -> bool: |
| try: |
| current_url = driver.current_url or "" |
| except WebDriverException: |
| return False |
| return current_url == "about:blank" or current_url.startswith("data:,") |
|
|
| def _safe_body_text(self, driver: WebDriver) -> str: |
| try:
|
| body = self._find(driver, By.TAG_NAME, "body")
|
| return body.text or ""
|
| except RecoverableAutomationError:
|
| return ""
|
|
|
| def _page_snapshot(self, driver: WebDriver, *, include_body: bool = False) -> str:
|
| script = """
|
| const body = document.body ? (document.body.innerText || '') : '';
|
| return JSON.stringify({
|
| url: window.location.href || '',
|
| title: document.title || '',
|
| readyState: document.readyState || '',
|
| body: body.replace(/\\s+/g, ' ').trim().slice(0, 180)
|
| });
|
| """
|
| try:
|
| raw = driver.execute_script(script)
|
| data = json.loads(raw) if isinstance(raw, str) else raw
|
| except Exception: |
| current_url = getattr(driver, "current_url", "") or "" |
| return f" url={self._sanitize_snapshot_url(current_url) or '-'}" |
|
|
| snapshot_url = self._sanitize_snapshot_url(str(data.get("url") or "")) |
| parts = [ |
| f"url={snapshot_url or '-'}", |
| f"title={data.get('title') or '-'}", |
| f"readyState={data.get('readyState') or '-'}", |
| ] |
| body_excerpt = self._sanitize_snapshot_text((data.get("body") or "").strip()) |
| if include_body and body_excerpt: |
| parts.append(f"body={body_excerpt}") |
| return " " + " | ".join(parts) |
|
|
| @staticmethod |
| def _sanitize_snapshot_url(raw_url: str) -> str: |
| text = str(raw_url or "") |
| if not text: |
| return "" |
| try: |
| parts = urlsplit(text) |
| except ValueError: |
| return re.sub(r"([?&][^=&]*(?:token|loginParams|credential|ticket|code|state|session)[^=]*=)[^&]+", r"\1[redacted]", text, flags=re.I) |
| if not parts.query: |
| return text |
| sanitized_pairs = [(key, "[redacted]") for key, _value in parse_qsl(parts.query, keep_blank_values=True)] |
| return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(sanitized_pairs), parts.fragment)) |
|
|
| @classmethod |
| def _sanitize_snapshot_text(cls, text: str) -> str: |
| def replace_url(match: re.Match) -> str: |
| return cls._sanitize_snapshot_url(match.group(0)) |
|
|
| return re.sub(r"https?://[^\s<>\"']+", replace_url, str(text or "")) |
|
|
| def _extract_image_bytes(self, image_element: WebElement) -> bytes:
|
| source = image_element.get_attribute("src") or ""
|
| if "base64," in source:
|
| return base64.b64decode(source.split("base64,", 1)[1])
|
| return image_element.screenshot_as_png
|
|
|
| def _find_first_visible(self, driver: WebDriver, selectors: list[tuple[str, str]], label: str, timeout: int = 0): |
| element = self._find_first_visible_optional(driver, selectors, timeout=timeout) |
| if element is None: |
| raise RecoverableAutomationError(f"页面元素未找到: {label}。{self._page_snapshot(driver, include_body=True)}") |
| return element |
|
|
| @staticmethod |
| def _find_visible_elements(driver: WebDriver, selectors: list[tuple[str, str]]) -> list[WebElement]: |
| visible_elements: list[WebElement] = [] |
| seen_ids: set[str] = set() |
| for by, value in selectors: |
| try: |
| elements = driver.find_elements(by, value) |
| except WebDriverException: |
| continue |
| for element in elements: |
| try: |
| element_id = element.id |
| if element_id in seen_ids or not element.is_displayed(): |
| continue |
| seen_ids.add(element_id) |
| visible_elements.append(element) |
| except WebDriverException: |
| continue |
| return visible_elements |
|
|
| @staticmethod |
| def _find_first_visible_optional(driver: WebDriver, selectors: list[tuple[str, str]], timeout: int = 0): |
| deadline = time.monotonic() + max(0, timeout)
|
| while True:
|
| for by, value in selectors:
|
| try:
|
| elements = driver.find_elements(by, value)
|
| except WebDriverException:
|
| continue
|
| for element in elements:
|
| try:
|
| if element.is_displayed():
|
| return element
|
| except WebDriverException:
|
| continue
|
| if timeout <= 0 or time.monotonic() >= deadline:
|
| return None
|
| time.sleep(0.2)
|
|
|
| @staticmethod
|
| def _find(driver: WebDriver, by: str, value: str):
|
| try:
|
| return driver.find_element(by, value)
|
| except NoSuchElementException as exc:
|
| raise RecoverableAutomationError(f"页面元素未找到: {value}") from exc
|
| except WebDriverException as exc:
|
| raise RecoverableAutomationError(f"浏览器操作失败: {value}") from exc
|
|
|
| def _sleep_with_cancel(self, stop_event, seconds: int, reason: str) -> None:
|
| if seconds <= 0:
|
| return
|
| self.logger("INFO", f"{reason} 大约 {seconds} 秒。")
|
| for _ in range(seconds):
|
| if stop_event.is_set():
|
| return
|
| time.sleep(1)
|
|
|