cacode commited on
Commit
9758ae6
·
verified ·
1 Parent(s): 9def03a

Fix Selenium login submit recovery and add regression coverage

Browse files
Files changed (2) hide show
  1. core/course_bot.py +768 -641
  2. tests/test_course_bot.py +109 -8
core/course_bot.py CHANGED
@@ -1,641 +1,768 @@
1
- from __future__ import annotations
2
-
3
- import base64
4
- import json
5
- import re
6
- import time
7
- from dataclasses import dataclass
8
- from pathlib import Path
9
- from typing import Callable
10
-
11
- from selenium.common.exceptions import NoSuchElementException, TimeoutException, WebDriverException
12
- from selenium.webdriver.common.by import By
13
- from selenium.webdriver.remote.webdriver import WebDriver
14
- from selenium.webdriver.remote.webelement import WebElement
15
- from selenium.webdriver.support.wait import WebDriverWait
16
-
17
- import onnx_inference
18
- import webdriver_utils
19
- from core.db import Database
20
-
21
-
22
- URL_LOGIN = "http://id.scu.edu.cn/enduser/sp/sso/scdxplugin_jwt23?enterpriseId=scdx&target_url=index"
23
- URL_SELECT_COURSE = "http://zhjw.scu.edu.cn/student/courseSelect/courseSelect/index"
24
- LOGIN_SUCCESS_PREFIXES = (
25
- "http://zhjw.scu.edu.cn/index",
26
- "http://zhjw.scu.edu.cn/",
27
- "https://zhjw.scu.edu.cn/index",
28
- "https://zhjw.scu.edu.cn/",
29
- )
30
- CATEGORY_META = {
31
- "plan": {"label": "方案选课", "tab_id": "faxk"},
32
- "free": {"label": "自由选课", "tab_id": "zyxk"},
33
- }
34
-
35
- LOGIN_STUDENT_SELECTORS = [
36
- (By.XPATH, "//*[@id='app']//form//input[@type='text']"),
37
- (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"),
38
- ]
39
- LOGIN_PASSWORD_SELECTORS = [
40
- (By.XPATH, "//*[@id='app']//form//input[@type='password']"),
41
- (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"),
42
- ]
43
- LOGIN_CAPTCHA_INPUT_SELECTORS = [
44
- (By.XPATH, "//*[@id='app']/div[1]/div/div[2]/div/div[1]/div[2]/div[2]/div/form/div[3]//input"),
45
- (By.XPATH, "//*[@id='app']//form//input[contains(@placeholder, '验证码')]"),
46
- ]
47
- LOGIN_CAPTCHA_IMAGE_SELECTORS = [
48
- (By.XPATH, "//*[@id='app']/div[1]/div/div[2]/div/div[1]/div[2]/div[2]/div/form/div[3]//img"),
49
- (By.XPATH, "//*[@id='app']//form//img"),
50
- ]
51
- LOGIN_BUTTON_SELECTORS = [
52
- (By.XPATH, "//*[@id='app']//form//button"),
53
- (By.XPATH, "//*[@id='app']/div[1]/div/div[2]/div/div[1]/div[2]/div[2]/div/form/div[4]/div/button"),
54
- ]
55
- SUBMIT_CAPTCHA_IMAGE_SELECTORS = [
56
- (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')]"),
57
- (By.XPATH, "//img[contains(@src,'base64')]"),
58
- (By.XPATH, "//img[contains(translate(@alt,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'captcha') or contains(translate(@class,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'captcha')]"),
59
- ]
60
- SUBMIT_CAPTCHA_INPUT_SELECTORS = [
61
- (By.XPATH, "//input[contains(@placeholder,'验证码')]"),
62
- (By.XPATH, "//input[contains(translate(@id,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'captcha')]"),
63
- (By.XPATH, "//input[contains(translate(@name,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'captcha')]"),
64
- ]
65
- SUBMIT_CAPTCHA_BUTTON_SELECTORS = [
66
- (By.XPATH, "//button[contains(.,'确定') or contains(.,'提交') or contains(.,'确认') or contains(.,'验证')]"),
67
- (By.XPATH, "//input[(@type='button' or @type='submit') and (contains(@value,'确定') or contains(@value,'提交') or contains(@value,'确认') or contains(@value,'验证'))]"),
68
- ]
69
-
70
-
71
- class FatalCredentialsError(Exception):
72
- pass
73
-
74
-
75
- class RecoverableAutomationError(Exception):
76
- pass
77
-
78
-
79
- @dataclass(slots=True)
80
- class TaskResult:
81
- status: str
82
- error: str = ""
83
-
84
-
85
- class CourseBot:
86
- def __init__(
87
- self,
88
- *,
89
- config,
90
- store: Database,
91
- task_id: int,
92
- user: dict,
93
- password: str,
94
- logger: Callable[[str, str], None],
95
- ) -> None:
96
- self.config = config
97
- self.store = store
98
- self.task_id = task_id
99
- self.user = user
100
- self.password = password
101
- self.logger = logger
102
- self.root_dir = Path(__file__).resolve().parent.parent
103
- self.select_course_js = (self.root_dir / "javascript" / "select_course.js").read_text(encoding="utf-8")
104
- self.check_result_js = (self.root_dir / "javascript" / "check_result.js").read_text(encoding="utf-8")
105
- self.captcha_solver = onnx_inference.CaptchaONNXInference(
106
- model_path=str(self.root_dir / "ocr_provider" / "captcha_model.onnx")
107
- )
108
-
109
- def run(self, stop_event) -> TaskResult:
110
- exhausted_sessions = 0
111
- while not stop_event.is_set():
112
- courses = self.store.list_courses_for_user(self.user["id"])
113
- if not courses:
114
- self.logger("INFO", "当前没有待抢课程,任务自动结束。")
115
- return TaskResult(status="completed")
116
-
117
- driver: WebDriver | None = None
118
- session_ready = False
119
- session_error_count = 0
120
- try:
121
- self.logger(
122
- "INFO",
123
- f"启动新的 Selenium 会话。累计已重建 {exhausted_sessions}/{self.config.selenium_restart_limit} 次。",
124
- )
125
- driver = webdriver_utils.configure_browser(
126
- chrome_binary=self.config.chrome_binary,
127
- chromedriver_path=self.config.chromedriver_path,
128
- page_timeout=self.config.browser_page_timeout,
129
- )
130
- wait = WebDriverWait(driver, self.config.browser_page_timeout, 0.5)
131
-
132
- while not stop_event.is_set():
133
- try:
134
- if not session_ready:
135
- self._login(driver, wait)
136
- session_ready = True
137
-
138
- current_courses = self.store.list_courses_for_user(self.user["id"])
139
- if not current_courses:
140
- self.logger("INFO", "所有目标课程都已经从队列中移除,任务完成。")
141
- return TaskResult(status="completed")
142
-
143
- if not self._goto_select_course(driver, wait):
144
- session_error_count = 0
145
- self._sleep_with_cancel(stop_event, self.config.poll_interval_seconds, "当前不是选课时段,等待下一轮检查。")
146
- continue
147
-
148
- attempts = 0
149
- successes = 0
150
- grouped_courses = {"plan": [], "free": []}
151
- for course in current_courses:
152
- grouped_courses[course["category"]].append(course)
153
-
154
- for category, items in grouped_courses.items():
155
- if not items:
156
- continue
157
- for course in items:
158
- if stop_event.is_set():
159
- break
160
- attempts += 1
161
- if self._attempt_single_course(driver, wait, course):
162
- successes += 1
163
-
164
- if attempts == 0:
165
- self.logger("INFO", "没有可执行的课程项目,下一轮将自动重试。")
166
- elif successes == 0:
167
- self.logger("INFO", "本轮没有抢到目标课程,准备稍后重试。")
168
- else:
169
- self.logger("INFO", f"本轮处理 {attempts} 门课程,成功更新 {successes} 门。")
170
-
171
- session_error_count = 0
172
- if exhausted_sessions:
173
- self.logger("INFO", "当前 Selenium 会话已恢复稳定,浏览器重建错误计数已清零。")
174
- exhausted_sessions = 0
175
-
176
- if not self.store.list_courses_for_user(self.user["id"]):
177
- self.logger("INFO", "全部课程均已处理完成。")
178
- return TaskResult(status="completed")
179
-
180
- self._sleep_with_cancel(stop_event, self.config.poll_interval_seconds, "等待下一轮刷新。")
181
- except FatalCredentialsError as exc:
182
- self.logger("ERROR", str(exc))
183
- return TaskResult(status="failed", error=str(exc))
184
- except RecoverableAutomationError as exc:
185
- session_ready = False
186
- session_error_count += 1
187
- self.logger(
188
- "WARNING",
189
- f"当前 Selenium 会话第 {session_error_count}/{self.config.selenium_error_limit} 次可恢复错误: {exc}",
190
- )
191
- if session_error_count < self.config.selenium_error_limit:
192
- self._sleep_with_cancel(
193
- stop_event,
194
- self.config.task_backoff_seconds,
195
- "将在当前 Selenium 会话内继续重试",
196
- )
197
- continue
198
-
199
- exhausted_sessions += 1
200
- if exhausted_sessions >= self.config.selenium_restart_limit:
201
- message = (
202
- f"Selenium 会话连续达到错误上限并已重建 {exhausted_sessions} 次,任务终止。"
203
- f"最后错误: {exc}"
204
- )
205
- self.logger("ERROR", message)
206
- return TaskResult(status="failed", error=message)
207
-
208
- self.logger(
209
- "WARNING",
210
- f"当前 Selenium 会话连续错误达到 {self.config.selenium_error_limit} 次,准备重建��览器。"
211
- f"已耗尽 {exhausted_sessions}/{self.config.selenium_restart_limit} 个会话。",
212
- )
213
- break
214
- except Exception as exc: # pragma: no cover - defensive fallback
215
- session_ready = False
216
- session_error_count += 1
217
- self.logger(
218
- "WARNING",
219
- f"当前 Selenium 会话第 {session_error_count}/{self.config.selenium_error_limit} 次未知错误: {exc}",
220
- )
221
- if session_error_count < self.config.selenium_error_limit:
222
- self._sleep_with_cancel(
223
- stop_event,
224
- self.config.task_backoff_seconds,
225
- "当前会话发生未知错误,稍后重试",
226
- )
227
- continue
228
-
229
- exhausted_sessions += 1
230
- if exhausted_sessions >= self.config.selenium_restart_limit:
231
- message = (
232
- f"Selenium 会话连续达到错误上限并已重建 {exhausted_sessions} 次,任务终止。"
233
- f"最后错误: {exc}"
234
- )
235
- self.logger("ERROR", message)
236
- return TaskResult(status="failed", error=message)
237
-
238
- self.logger(
239
- "WARNING",
240
- f"未知错误累计达到上限,准备重建 Selenium 会话。"
241
- f"已耗尽 {exhausted_sessions}/{self.config.selenium_restart_limit} 个会话。",
242
- )
243
- break
244
- finally:
245
- if driver is not None:
246
- try:
247
- driver.quit()
248
- except Exception:
249
- pass
250
-
251
- self.logger("INFO", "收到停止信号,任务已结束。")
252
- return TaskResult(status="stopped")
253
-
254
- def _login(self, driver: WebDriver, wait: WebDriverWait) -> None:
255
- for attempt in range(1, self.config.login_retry_limit + 1):
256
- self._open_page(driver, wait, URL_LOGIN, f"登录页(第 {attempt} 次)", log_on_success=True)
257
-
258
- std_id_box = self._find_first_visible(driver, LOGIN_STUDENT_SELECTORS, "登录学号输入框", timeout=6)
259
- password_box = self._find_first_visible(driver, LOGIN_PASSWORD_SELECTORS, "登录密码输入框", timeout=6)
260
- captcha_box = self._find_first_visible(driver, LOGIN_CAPTCHA_INPUT_SELECTORS, "登录验证码输入框", timeout=6)
261
- login_button = self._find_first_visible(driver, LOGIN_BUTTON_SELECTORS, "登录按钮", timeout=6)
262
- captcha_image = self._find_first_visible(driver, LOGIN_CAPTCHA_IMAGE_SELECTORS, "登录验证码图片", timeout=6)
263
-
264
- captcha_text = self._solve_captcha_text(captcha_image, scene="登录")
265
- self.logger("INFO", f"登录尝试 {attempt}/{self.config.login_retry_limit},验证码 OCR 输出: {captcha_text}")
266
-
267
- std_id_box.clear()
268
- std_id_box.send_keys(self.user["student_id"])
269
- password_box.clear()
270
- password_box.send_keys(self.password)
271
- captcha_box.clear()
272
- captcha_box.send_keys(captcha_text)
273
- login_button.click()
274
-
275
- state, error_message = self._wait_for_login_outcome(driver, timeout_seconds=10)
276
- if state == "success":
277
- self.logger("INFO", f"登录成功,耗费 {attempt} 次尝试。")
278
- return
279
-
280
- if any(token in error_message for token in ("用户名或密码错误", "密码错误", "账号或密码错误", "用户不存在")):
281
- raise FatalCredentialsError("学号或密码错误,任务已停止,请在面板中更新后重新启动。")
282
-
283
- if error_message:
284
- self.logger("WARNING", f"登录失败 {attempt} 次尝试: {error_message}")
285
- else:
286
- self.logger(
287
- "WARNING",
288
- f"登录失败,第 {attempt} 次尝试,未读取到明确错误提示{self._page_snapshot(driver, include_body=True)}",
289
- )
290
-
291
- time.sleep(0.6)
292
-
293
- raise RecoverableAutomationError("连续多次登录失败,可能是验证码识别失败、页面异常或系统暂时不可用。")
294
-
295
- def _goto_select_course(self, driver: WebDriver, wait: WebDriverWait) -> bool:
296
- self._open_page(driver, wait, URL_SELECT_COURSE, "选课页")
297
- if self._is_session_expired(driver):
298
- raise RecoverableAutomationError("检测到登录会话已失效,准备重新登录。")
299
-
300
- body_text = self._safe_body_text(driver)
301
- if "非选课" in body_text or "未到选课时间" in body_text:
302
- self.logger("INFO", body_text.strip() or "当前不是选课时段。")
303
- return False
304
- return True
305
-
306
- def _attempt_single_course(self, driver: WebDriver, wait: WebDriverWait, course: dict) -> bool:
307
- category_name = CATEGORY_META[course["category"]]["label"]
308
- course_key = f'{course["course_id"]}_{course["course_index"]}'
309
- self.logger("INFO", f"开始尝试 {category_name} {course_key}。")
310
-
311
- if not self._goto_select_course(driver, wait):
312
- self.logger("INFO", f"跳过 {course_key},当前系统暂时不在可选课页面。")
313
- return False
314
- self._open_category_tab(driver, wait, course["category"])
315
-
316
- try:
317
- wait.until(lambda current_driver: current_driver.find_element(By.ID, "ifra"))
318
- driver.switch_to.frame("ifra")
319
- course_id_box = self._find(driver, By.ID, "kch")
320
- search_button = self._find(driver, By.ID, "queryButton")
321
- course_id_box.clear()
322
- course_id_box.send_keys(course["course_id"])
323
- search_button.click()
324
- wait.until(
325
- lambda current_driver: current_driver.execute_script(
326
- "return document.getElementById('queryButton').innerText.indexOf('正在') === -1"
327
- )
328
- )
329
- except TimeoutException as exc:
330
- raise RecoverableAutomationError(
331
- f"课程查询超时,页面可能暂时无响应。{self._page_snapshot(driver, include_body=True)}"
332
- ) from exc
333
- finally:
334
- driver.switch_to.default_content()
335
-
336
- time.sleep(0.2)
337
- try:
338
- found_target = driver.execute_script(self.select_course_js, course_key) == "yes"
339
- except WebDriverException as exc:
340
- raise RecoverableAutomationError(f"执行课程勾选脚本失败。{self._page_snapshot(driver, include_body=True)}") from exc
341
- if not found_target:
342
- self.logger("INFO", f"本轮未找到目标课程 {course_key}。")
343
- return False
344
-
345
- self.logger("INFO", f"已勾选目标课程 {course_key},准备提交。")
346
- results = self._submit_with_optional_captcha(driver, wait, course_key)
347
- if not results:
348
- self.logger("WARNING", f"提交 {course_key} 后没有读取到结果列表。")
349
- return False
350
-
351
- satisfied = False
352
- for result in results:
353
- detail = (result.get("detail") or "").strip()
354
- subject = (result.get("subject") or course_key).strip()
355
- if result.get("result"):
356
- self.logger("SUCCESS", f"成功抢到课程: {subject}")
357
- satisfied = True
358
- elif any(token in detail for token in ("已选", "已选择", "已经选", "已在已选课程")):
359
- self.logger("INFO", f"课程已在系统中存在,自动从队列移除: {subject}")
360
- satisfied = True
361
- else:
362
- self.logger("WARNING", f"课程 {subject} 抢课失败: {detail or '未知原因'}")
363
-
364
- if satisfied:
365
- self.store.remove_course_by_identity(
366
- self.user["id"],
367
- course["category"],
368
- course["course_id"],
369
- course["course_index"],
370
- )
371
- return True
372
- return False
373
-
374
- def _submit_with_optional_captcha(self, driver: WebDriver, wait: WebDriverWait, course_key: str) -> list[dict]:
375
- submit_button = self._find(driver, By.ID, "submitButton")
376
- submit_button.click()
377
-
378
- for attempt in range(1, self.config.submit_captcha_retry_limit + 1):
379
- state = self._wait_for_submit_state(driver, wait)
380
- if state == "result":
381
- return self._read_result_page(driver, wait)
382
- if state == "captcha":
383
- self.logger("INFO", f"提交 {course_key} 时检测到验证码,第 {attempt} 次自动识别。")
384
- if not self._solve_visible_submit_captcha(driver):
385
- raise RecoverableAutomationError("提交选课时检测到验证码,但未能自动完成识别与提交。")
386
- continue
387
- self.logger("WARNING", f"提交 {course_key} 后页面未返回结果,也未检测到验证码。")
388
-
389
- raise RecoverableAutomationError("提交选课后连续多次遇到验证码或未返回结果。")
390
-
391
- def _wait_for_submit_state(self, driver: WebDriver, wait: WebDriverWait) -> str:
392
- script = """
393
- const visible = (el) => {
394
- if (!el) return false;
395
- const style = window.getComputedStyle(el);
396
- const rect = el.getBoundingClientRect();
397
- return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
398
- };
399
- if (document.querySelector('#xkresult tbody tr')) return 'result';
400
- const input = Array.from(document.querySelectorAll('input')).find((el) => {
401
- const text = `${el.placeholder || ''} ${el.id || ''} ${el.name || ''} ${el.className || ''}`;
402
- return visible(el) && /验证码|captcha/i.test(text);
403
- });
404
- const img = Array.from(document.querySelectorAll('img')).find((el) => {
405
- const text = `${el.src || ''} ${el.id || ''} ${el.alt || ''} ${el.className || ''}`;
406
- return visible(el) && (/captcha/i.test(text) || (el.src || '').includes('base64') || (el.naturalWidth >= 50 && el.naturalWidth <= 240 && el.naturalHeight >= 20 && el.naturalHeight <= 120));
407
- });
408
- const button = Array.from(document.querySelectorAll('button, input[type="button"], input[type="submit"]')).find((el) => {
409
- const text = `${el.innerText || ''} ${el.value || ''}`;
410
- return visible(el) && /确定|提交|确认|验证/.test(text);
411
- });
412
- if (input && img && button) return 'captcha';
413
- return 'pending';
414
- """
415
- try:
416
- wait.until(lambda current_driver: current_driver.execute_script(script) != "pending")
417
- except TimeoutException:
418
- return "pending"
419
- return str(driver.execute_script(script))
420
-
421
- def _solve_visible_submit_captcha(self, driver: WebDriver) -> bool:
422
- image = self._find_first_visible_optional(driver, SUBMIT_CAPTCHA_IMAGE_SELECTORS, timeout=3)
423
- input_box = self._find_first_visible_optional(driver, SUBMIT_CAPTCHA_INPUT_SELECTORS, timeout=3)
424
- button = self._find_first_visible_optional(driver, SUBMIT_CAPTCHA_BUTTON_SELECTORS, timeout=3)
425
- if not image or not input_box or not button:
426
- return False
427
-
428
- captcha_text = self._solve_captcha_text(image, scene="提交")
429
- self.logger("INFO", f"提交验证码 OCR 输出: {captcha_text}")
430
- input_box.clear()
431
- input_box.send_keys(captcha_text)
432
- button.click()
433
- time.sleep(1.0)
434
- return True
435
-
436
- def _open_category_tab(self, driver: WebDriver, wait: WebDriverWait, category: str) -> None:
437
- tab_id = CATEGORY_META[category]["tab_id"]
438
- tab = self._find(driver, By.ID, tab_id)
439
- tab.click()
440
- webdriver_utils.wait_for_ready(wait, allow_interactive=True)
441
- time.sleep(0.2)
442
-
443
- def _read_result_page(self, driver: WebDriver, wait: WebDriverWait) -> list[dict]:
444
- try:
445
- webdriver_utils.wait_for_ready(wait, allow_interactive=True)
446
- wait.until(
447
- lambda current_driver: current_driver.execute_script(
448
- """
449
- const node = document.querySelector('#xkresult tbody tr');
450
- return Boolean(node);
451
- """
452
- )
453
- )
454
- return json.loads(driver.execute_script(self.check_result_js))
455
- except Exception as exc:
456
- raise RecoverableAutomationError(
457
- f"读取选课结果失败,页面结构可能发生变化。{self._page_snapshot(driver, include_body=True)}"
458
- ) from exc
459
-
460
- def _open_page(
461
- self,
462
- driver: WebDriver,
463
- wait: WebDriverWait,
464
- url: str,
465
- label: str,
466
- *,
467
- allow_interactive: bool = True,
468
- log_on_success: bool = False,
469
- ) -> None:
470
- timed_out = webdriver_utils.open_with_recovery(driver, url)
471
- if timed_out:
472
- self.logger("WARNING", f"{label} 页面加载超时,尝试停止页面并继续执行。")
473
- try:
474
- ready_state = webdriver_utils.wait_for_ready(wait, allow_interactive=allow_interactive)
475
- except TimeoutException as exc:
476
- raise RecoverableAutomationError(f"{label} 页面加载失败。{self._page_snapshot(driver, include_body=True)}") from exc
477
- if log_on_success or timed_out:
478
- self.logger("INFO", f"{label} 页面已打开,readyState={ready_state}。{self._page_snapshot(driver, include_body=timed_out)}")
479
-
480
- def _wait_for_login_outcome(self, driver: WebDriver, timeout_seconds: int = 10) -> tuple[str, str]:
481
- deadline = time.monotonic() + max(1, timeout_seconds)
482
- last_error = ""
483
- while time.monotonic() < deadline:
484
- current_url = driver.current_url or ""
485
- if self._is_login_success_url(current_url):
486
- return "success", ""
487
- last_error = self._read_login_error(driver)
488
- if last_error:
489
- return "error", last_error
490
- time.sleep(0.4)
491
- return "unknown", self._read_login_error(driver)
492
-
493
- def _read_login_error(self, driver: WebDriver) -> str:
494
- script = """
495
- const visible = (node) => {
496
- if (!node) return false;
497
- const style = window.getComputedStyle(node);
498
- const rect = node.getBoundingClientRect();
499
- return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
500
- };
501
- const selectors = [
502
- '.el-message',
503
- '[role="alert"]',
504
- '.message',
505
- '.toast',
506
- '.el-form-item__error',
507
- '.error'
508
- ];
509
- for (const selector of selectors) {
510
- for (const node of Array.from(document.querySelectorAll(selector))) {
511
- const text = (node.innerText || '').trim();
512
- if (visible(node) && text) {
513
- return text;
514
- }
515
- }
516
- }
517
- const nodes = Array.from(document.querySelectorAll('body span, body div'));
518
- for (const node of nodes) {
519
- const text = (node.innerText || '').trim();
520
- if (!text || !visible(node)) continue;
521
- if (/验证码|密码|用户|账号/.test(text)) return text;
522
- }
523
- return '';
524
- """
525
- raw_message = driver.execute_script(script) or ""
526
- return re.sub(r"\s+", " ", str(raw_message)).strip()
527
-
528
- def _solve_captcha_text(self, image_element: WebElement, *, scene: str) -> str:
529
- last_candidate = ""
530
- for attempt in range(1, 3):
531
- raw_text = self.captcha_solver.classification(self._extract_image_bytes(image_element))
532
- normalized = re.sub(r"[^0-9A-Za-z]", "", str(raw_text or "")).strip()
533
- if len(normalized) >= 4:
534
- return normalized[:4]
535
- last_candidate = normalized
536
- self.logger("WARNING", f"{scene}验证码 OCR 输出异常,第 {attempt} 次结果: {raw_text!r}")
537
- try:
538
- image_element.click()
539
- time.sleep(0.4)
540
- except Exception:
541
- pass
542
- if len(last_candidate) >= 3:
543
- return last_candidate[:4]
544
- raise RecoverableAutomationError(f"{scene}验证码 OCR 未能识别出有效内容。")
545
-
546
- def _is_login_success_url(self, url: str) -> bool:
547
- if not url:
548
- return False
549
- if any(url.startswith(prefix) for prefix in LOGIN_SUCCESS_PREFIXES):
550
- return True
551
- return "zhjw.scu.edu.cn" in url and "id.scu.edu.cn" not in url
552
-
553
- def _is_session_expired(self, driver: WebDriver) -> bool:
554
- current_url = driver.current_url or ""
555
- if "id.scu.edu.cn" in current_url:
556
- return True
557
- password_box = self._find_first_visible_optional(driver, LOGIN_PASSWORD_SELECTORS, timeout=1)
558
- return password_box is not None
559
-
560
- def _safe_body_text(self, driver: WebDriver) -> str:
561
- try:
562
- body = self._find(driver, By.TAG_NAME, "body")
563
- return body.text or ""
564
- except RecoverableAutomationError:
565
- return ""
566
-
567
- def _page_snapshot(self, driver: WebDriver, *, include_body: bool = False) -> str:
568
- script = """
569
- const body = document.body ? (document.body.innerText || '') : '';
570
- return JSON.stringify({
571
- url: window.location.href || '',
572
- title: document.title || '',
573
- readyState: document.readyState || '',
574
- body: body.replace(/\\s+/g, ' ').trim().slice(0, 180)
575
- });
576
- """
577
- try:
578
- raw = driver.execute_script(script)
579
- data = json.loads(raw) if isinstance(raw, str) else raw
580
- except Exception:
581
- current_url = getattr(driver, "current_url", "") or ""
582
- return f" url={current_url or '-'}"
583
-
584
- parts = [
585
- f"url={data.get('url') or '-'}",
586
- f"title={data.get('title') or '-'}",
587
- f"readyState={data.get('readyState') or '-'}",
588
- ]
589
- body_excerpt = (data.get("body") or "").strip()
590
- if include_body and body_excerpt:
591
- parts.append(f"body={body_excerpt}")
592
- return " " + " | ".join(parts)
593
-
594
- def _extract_image_bytes(self, image_element: WebElement) -> bytes:
595
- source = image_element.get_attribute("src") or ""
596
- if "base64," in source:
597
- return base64.b64decode(source.split("base64,", 1)[1])
598
- return image_element.screenshot_as_png
599
-
600
- def _find_first_visible(self, driver: WebDriver, selectors: list[tuple[str, str]], label: str, timeout: int = 0):
601
- element = self._find_first_visible_optional(driver, selectors, timeout=timeout)
602
- if element is None:
603
- raise RecoverableAutomationError(f"页面元素未找到: {label}。{self._page_snapshot(driver, include_body=True)}")
604
- return element
605
-
606
- @staticmethod
607
- def _find_first_visible_optional(driver: WebDriver, selectors: list[tuple[str, str]], timeout: int = 0):
608
- deadline = time.monotonic() + max(0, timeout)
609
- while True:
610
- for by, value in selectors:
611
- try:
612
- elements = driver.find_elements(by, value)
613
- except WebDriverException:
614
- continue
615
- for element in elements:
616
- try:
617
- if element.is_displayed():
618
- return element
619
- except WebDriverException:
620
- continue
621
- if timeout <= 0 or time.monotonic() >= deadline:
622
- return None
623
- time.sleep(0.2)
624
-
625
- @staticmethod
626
- def _find(driver: WebDriver, by: str, value: str):
627
- try:
628
- return driver.find_element(by, value)
629
- except NoSuchElementException as exc:
630
- raise RecoverableAutomationError(f"页面元素未找到: {value}") from exc
631
- except WebDriverException as exc:
632
- raise RecoverableAutomationError(f"浏览器操作失败: {value}") from exc
633
-
634
- def _sleep_with_cancel(self, stop_event, seconds: int, reason: str) -> None:
635
- if seconds <= 0:
636
- return
637
- self.logger("INFO", f"{reason} 大约 {seconds} 秒。")
638
- for _ in range(seconds):
639
- if stop_event.is_set():
640
- return
641
- time.sleep(1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import re
6
+ import time
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Callable
10
+
11
+ from selenium.common.exceptions import NoSuchElementException, TimeoutException, WebDriverException
12
+ from selenium.webdriver.common.by import By
13
+ from selenium.webdriver.remote.webdriver import WebDriver
14
+ from selenium.webdriver.remote.webelement import WebElement
15
+ from selenium.webdriver.support.wait import WebDriverWait
16
+
17
+ import onnx_inference
18
+ import webdriver_utils
19
+ from core.db import Database
20
+
21
+
22
+ URL_LOGIN = "http://id.scu.edu.cn/enduser/sp/sso/scdxplugin_jwt23?enterpriseId=scdx&target_url=index"
23
+ URL_SELECT_COURSE = "http://zhjw.scu.edu.cn/student/courseSelect/courseSelect/index"
24
+ LOGIN_SUCCESS_PREFIXES = (
25
+ "http://zhjw.scu.edu.cn/index",
26
+ "http://zhjw.scu.edu.cn/",
27
+ "https://zhjw.scu.edu.cn/index",
28
+ "https://zhjw.scu.edu.cn/",
29
+ )
30
+ CATEGORY_META = {
31
+ "plan": {"label": "方案选课", "tab_id": "faxk"},
32
+ "free": {"label": "自由选课", "tab_id": "zyxk"},
33
+ }
34
+
35
+ LOGIN_STUDENT_SELECTORS = [
36
+ (By.XPATH, "//*[@id='app']//form//input[@type='text']"),
37
+ (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"),
38
+ ]
39
+ LOGIN_PASSWORD_SELECTORS = [
40
+ (By.XPATH, "//*[@id='app']//form//input[@type='password']"),
41
+ (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"),
42
+ ]
43
+ LOGIN_CAPTCHA_INPUT_SELECTORS = [
44
+ (By.XPATH, "//*[@id='app']/div[1]/div/div[2]/div/div[1]/div[2]/div[2]/div/form/div[3]//input"),
45
+ (By.XPATH, "//*[@id='app']//form//input[contains(@placeholder, '验证码')]"),
46
+ ]
47
+ LOGIN_CAPTCHA_IMAGE_SELECTORS = [
48
+ (By.XPATH, "//*[@id='app']/div[1]/div/div[2]/div/div[1]/div[2]/div[2]/div/form/div[3]//img"),
49
+ (By.XPATH, "//*[@id='app']//form//img"),
50
+ ]
51
+ LOGIN_BUTTON_SELECTORS = [
52
+ (By.XPATH, "//*[@id='app']//form//button"),
53
+ (By.XPATH, "//*[@id='app']/div[1]/div/div[2]/div/div[1]/div[2]/div[2]/div/form/div[4]/div/button"),
54
+ ]
55
+ SUBMIT_CAPTCHA_IMAGE_SELECTORS = [
56
+ (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')]"),
57
+ (By.XPATH, "//img[contains(@src,'base64')]"),
58
+ (By.XPATH, "//img[contains(translate(@alt,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'captcha') or contains(translate(@class,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'captcha')]"),
59
+ ]
60
+ SUBMIT_CAPTCHA_INPUT_SELECTORS = [
61
+ (By.XPATH, "//input[contains(@placeholder,'验证码')]"),
62
+ (By.XPATH, "//input[contains(translate(@id,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'captcha')]"),
63
+ (By.XPATH, "//input[contains(translate(@name,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'captcha')]"),
64
+ ]
65
+ SUBMIT_CAPTCHA_BUTTON_SELECTORS = [
66
+ (By.XPATH, "//button[contains(.,'确定') or contains(.,'提交') or contains(.,'确认') or contains(.,'验证')]"),
67
+ (By.XPATH, "//input[(@type='button' or @type='submit') and (contains(@value,'确定') or contains(@value,'提交') or contains(@value,'确认') or contains(@value,'验证'))]"),
68
+ ]
69
+
70
+
71
+ class FatalCredentialsError(Exception):
72
+ pass
73
+
74
+
75
+ class RecoverableAutomationError(Exception):
76
+ pass
77
+
78
+
79
+ @dataclass(slots=True)
80
+ class TaskResult:
81
+ status: str
82
+ error: str = ""
83
+
84
+
85
+ class CourseBot:
86
+ def __init__(
87
+ self,
88
+ *,
89
+ config,
90
+ store: Database,
91
+ task_id: int,
92
+ user: dict,
93
+ password: str,
94
+ logger: Callable[[str, str], None],
95
+ ) -> None:
96
+ self.config = config
97
+ self.store = store
98
+ self.task_id = task_id
99
+ self.user = user
100
+ self.password = password
101
+ self.logger = logger
102
+ self.root_dir = Path(__file__).resolve().parent.parent
103
+ self.select_course_js = (self.root_dir / "javascript" / "select_course.js").read_text(encoding="utf-8")
104
+ self.check_result_js = (self.root_dir / "javascript" / "check_result.js").read_text(encoding="utf-8")
105
+ self.captcha_solver = onnx_inference.CaptchaONNXInference(
106
+ model_path=str(self.root_dir / "ocr_provider" / "captcha_model.onnx")
107
+ )
108
+
109
+ def run(self, stop_event) -> TaskResult:
110
+ exhausted_sessions = 0
111
+ while not stop_event.is_set():
112
+ courses = self.store.list_courses_for_user(self.user["id"])
113
+ if not courses:
114
+ self.logger("INFO", "当前没有待抢课程,任务自动结束。")
115
+ return TaskResult(status="completed")
116
+
117
+ driver: WebDriver | None = None
118
+ session_ready = False
119
+ session_error_count = 0
120
+ try:
121
+ self.logger(
122
+ "INFO",
123
+ f"启动新的 Selenium 会话。累计已重建 {exhausted_sessions}/{self.config.selenium_restart_limit} 次。",
124
+ )
125
+ driver = webdriver_utils.configure_browser(
126
+ chrome_binary=self.config.chrome_binary,
127
+ chromedriver_path=self.config.chromedriver_path,
128
+ page_timeout=self.config.browser_page_timeout,
129
+ )
130
+ wait = WebDriverWait(driver, self.config.browser_page_timeout, 0.5)
131
+
132
+ while not stop_event.is_set():
133
+ try:
134
+ if not session_ready:
135
+ self._login(driver, wait)
136
+ session_ready = True
137
+
138
+ current_courses = self.store.list_courses_for_user(self.user["id"])
139
+ if not current_courses:
140
+ self.logger("INFO", "所有目标课程都已经从队列中移除,任务完成。")
141
+ return TaskResult(status="completed")
142
+
143
+ if not self._goto_select_course(driver, wait):
144
+ session_error_count = 0
145
+ self._sleep_with_cancel(stop_event, self.config.poll_interval_seconds, "当前不是选课时段,等待下一轮检查。")
146
+ continue
147
+
148
+ attempts = 0
149
+ successes = 0
150
+ grouped_courses = {"plan": [], "free": []}
151
+ for course in current_courses:
152
+ grouped_courses[course["category"]].append(course)
153
+
154
+ for category, items in grouped_courses.items():
155
+ if not items:
156
+ continue
157
+ for course in items:
158
+ if stop_event.is_set():
159
+ break
160
+ attempts += 1
161
+ if self._attempt_single_course(driver, wait, course):
162
+ successes += 1
163
+
164
+ if attempts == 0:
165
+ self.logger("INFO", "没有可执行的课程项目,下一轮将自动重试。")
166
+ elif successes == 0:
167
+ self.logger("INFO", "本轮没有抢到目标课程,准备稍后重试。")
168
+ else:
169
+ self.logger("INFO", f"本轮处理 {attempts} 门课程,成功更新 {successes} 门。")
170
+
171
+ session_error_count = 0
172
+ if exhausted_sessions:
173
+ self.logger("INFO", "当前 Selenium 会话已恢复稳定,浏览器重建错误计数已清零。")
174
+ exhausted_sessions = 0
175
+
176
+ if not self.store.list_courses_for_user(self.user["id"]):
177
+ self.logger("INFO", "全部课程均已处理完成。")
178
+ return TaskResult(status="completed")
179
+
180
+ self._sleep_with_cancel(stop_event, self.config.poll_interval_seconds, "等待下一轮刷新。")
181
+ except FatalCredentialsError as exc:
182
+ self.logger("ERROR", str(exc))
183
+ return TaskResult(status="failed", error=str(exc))
184
+ except RecoverableAutomationError as exc:
185
+ session_ready = False
186
+ session_error_count += 1
187
+ self.logger(
188
+ "WARNING",
189
+ f"当前 Selenium 会话第 {session_error_count}/{self.config.selenium_error_limit} 次可恢复错误: {exc}",
190
+ )
191
+ if session_error_count < self.config.selenium_error_limit:
192
+ self._sleep_with_cancel(
193
+ stop_event,
194
+ self.config.task_backoff_seconds,
195
+ "将在当前 Selenium 会话内继续重试",
196
+ )
197
+ continue
198
+
199
+ exhausted_sessions += 1
200
+ if exhausted_sessions >= self.config.selenium_restart_limit:
201
+ message = (
202
+ f"Selenium 会话连续达到错误上限并已重建 {exhausted_sessions} 次,任务终止。"
203
+ f"最后错误: {exc}"
204
+ )
205
+ self.logger("ERROR", message)
206
+ return TaskResult(status="failed", error=message)
207
+
208
+ self.logger(
209
+ "WARNING",
210
+ f"当前 Selenium 会话连续错误达到 {self.config.selenium_error_limit} 次,准备重建览器。"
211
+ f"已耗尽 {exhausted_sessions}/{self.config.selenium_restart_limit} 个会话。",
212
+ )
213
+ break
214
+ except Exception as exc: # pragma: no cover - defensive fallback
215
+ session_ready = False
216
+ session_error_count += 1
217
+ self.logger(
218
+ "WARNING",
219
+ f"当前 Selenium 会话第 {session_error_count}/{self.config.selenium_error_limit} 次未知错误: {exc}",
220
+ )
221
+ if session_error_count < self.config.selenium_error_limit:
222
+ self._sleep_with_cancel(
223
+ stop_event,
224
+ self.config.task_backoff_seconds,
225
+ "当前会话发生未知错误,稍后重试",
226
+ )
227
+ continue
228
+
229
+ exhausted_sessions += 1
230
+ if exhausted_sessions >= self.config.selenium_restart_limit:
231
+ message = (
232
+ f"Selenium 会话连续达到错误上限并已重建 {exhausted_sessions} 次,任务终止。"
233
+ f"最后错误: {exc}"
234
+ )
235
+ self.logger("ERROR", message)
236
+ return TaskResult(status="failed", error=message)
237
+
238
+ self.logger(
239
+ "WARNING",
240
+ f"未知错误累计达到上限,准备重建 Selenium 会话。"
241
+ f"已耗尽 {exhausted_sessions}/{self.config.selenium_restart_limit} 个会话。",
242
+ )
243
+ break
244
+ finally:
245
+ if driver is not None:
246
+ try:
247
+ driver.quit()
248
+ except Exception:
249
+ pass
250
+
251
+ self.logger("INFO", "收到停止信号,任务已结束。")
252
+ return TaskResult(status="stopped")
253
+
254
+ def _login(self, driver: WebDriver, wait: WebDriverWait) -> None:
255
+ for attempt in range(1, self.config.login_retry_limit + 1):
256
+ self._open_page(driver, wait, URL_LOGIN, f"登录页(第 {attempt} 次)", log_on_success=True)
257
+
258
+ std_id_box = self._find_first_visible(driver, LOGIN_STUDENT_SELECTORS, "登录学号输入框", timeout=6)
259
+ password_box = self._find_first_visible(driver, LOGIN_PASSWORD_SELECTORS, "登录密码输入框", timeout=6)
260
+ captcha_box = self._find_first_visible(driver, LOGIN_CAPTCHA_INPUT_SELECTORS, "登录验证码输入框", timeout=6)
261
+ login_button = self._find_first_visible(driver, LOGIN_BUTTON_SELECTORS, "登录按钮", timeout=6)
262
+ captcha_image = self._find_first_visible(driver, LOGIN_CAPTCHA_IMAGE_SELECTORS, "登录验证码图片", timeout=6)
263
+
264
+ captcha_text = self._solve_captcha_text(captcha_image, scene="登录")
265
+ self.logger("INFO", f"登录尝试 {attempt}/{self.config.login_retry_limit},验证码 OCR 输出: {captcha_text}")
266
+
267
+ std_id_box.clear()
268
+ std_id_box.send_keys(self.user["student_id"])
269
+ password_box.clear()
270
+ password_box.send_keys(self.password)
271
+ captcha_box.clear()
272
+ captcha_box.send_keys(captcha_text)
273
+ self.logger("INFO", f"登录尝试 {attempt}/{self.config.login_retry_limit},准备提交登录表单。")
274
+ submit_method = self._trigger_non_blocking_action(
275
+ driver,
276
+ login_button,
277
+ label="登录表单",
278
+ allow_form_submit=True,
279
+ )
280
+ self.logger("INFO", f"登录表单已触发提交,方式={submit_method},开始等待登录结果。")
281
+
282
+ state, error_message = self._wait_for_login_outcome(driver, timeout_seconds=10)
283
+ if state == "success":
284
+ self.logger("INFO", f"登录成功耗费 {attempt} 次尝试")
285
+ return
286
+
287
+ if any(token in error_message for token in ("用户名或密码错误", "密码错误", "账号或密码错误", "用户不存在")):
288
+ raise FatalCredentialsError("学号或密码错误,任务已停止,请在面板中更新后重新启动。")
289
+
290
+ if error_message:
291
+ self.logger("WARNING", f"登录失败,第 {attempt} 次尝试: {error_message}")
292
+ else:
293
+ self.logger(
294
+ "WARNING",
295
+ f"登录失败,第 {attempt} 次尝试,未读取到明确错误提示。{self._page_snapshot(driver, include_body=True)}",
296
+ )
297
+
298
+ time.sleep(0.6)
299
+
300
+ raise RecoverableAutomationError("连续多次登录失败,可能是验证码识别失败、页面异常或系统暂时不可用。")
301
+
302
+ def _goto_select_course(self, driver: WebDriver, wait: WebDriverWait) -> bool:
303
+ self._open_page(driver, wait, URL_SELECT_COURSE, "选课页")
304
+ if self._is_session_expired(driver):
305
+ raise RecoverableAutomationError("检测到登录会话已失效,准备重新登录。")
306
+
307
+ body_text = self._safe_body_text(driver)
308
+ if "非选课" in body_text or "未到选课时间" in body_text:
309
+ self.logger("INFO", body_text.strip() or "当前不是选课时段。")
310
+ return False
311
+ return True
312
+
313
+ def _attempt_single_course(self, driver: WebDriver, wait: WebDriverWait, course: dict) -> bool:
314
+ category_name = CATEGORY_META[course["category"]]["label"]
315
+ course_key = f'{course["course_id"]}_{course["course_index"]}'
316
+ self.logger("INFO", f"开始尝试 {category_name} {course_key}。")
317
+
318
+ if not self._goto_select_course(driver, wait):
319
+ self.logger("INFO", f"跳过 {course_key},当前系统暂时不在可选课页面。")
320
+ return False
321
+ self._open_category_tab(driver, wait, course["category"])
322
+
323
+ try:
324
+ wait.until(lambda current_driver: current_driver.find_element(By.ID, "ifra"))
325
+ driver.switch_to.frame("ifra")
326
+ course_id_box = self._find(driver, By.ID, "kch")
327
+ search_button = self._find(driver, By.ID, "queryButton")
328
+ course_id_box.clear()
329
+ course_id_box.send_keys(course["course_id"])
330
+ search_button.click()
331
+ wait.until(
332
+ lambda current_driver: current_driver.execute_script(
333
+ "return document.getElementById('queryButton').innerText.indexOf('正在') === -1"
334
+ )
335
+ )
336
+ except TimeoutException as exc:
337
+ raise RecoverableAutomationError(
338
+ f"课程查询超时,页面可能暂时无响应。{self._page_snapshot(driver, include_body=True)}"
339
+ ) from exc
340
+ finally:
341
+ driver.switch_to.default_content()
342
+
343
+ time.sleep(0.2)
344
+ try:
345
+ found_target = driver.execute_script(self.select_course_js, course_key) == "yes"
346
+ except WebDriverException as exc:
347
+ raise RecoverableAutomationError(f"执行课程勾选脚本失败。{self._page_snapshot(driver, include_body=True)}") from exc
348
+ if not found_target:
349
+ self.logger("INFO", f"本轮未找到目标课程 {course_key}。")
350
+ return False
351
+
352
+ self.logger("INFO", f"已勾选目标课程 {course_key},准备提交。")
353
+ results = self._submit_with_optional_captcha(driver, wait, course_key)
354
+ if not results:
355
+ self.logger("WARNING", f"提交 {course_key} 后没有读取到结果列表。")
356
+ return False
357
+
358
+ satisfied = False
359
+ for result in results:
360
+ detail = (result.get("detail") or "").strip()
361
+ subject = (result.get("subject") or course_key).strip()
362
+ if result.get("result"):
363
+ self.logger("SUCCESS", f"成功抢到课程: {subject}")
364
+ satisfied = True
365
+ elif any(token in detail for token in ("已选", "已选择", "已经选", "已在已选课程")):
366
+ self.logger("INFO", f"课程已在系统中存在,自动从队列移除: {subject}")
367
+ satisfied = True
368
+ else:
369
+ self.logger("WARNING", f"课程 {subject} 抢课失败: {detail or '未知原因'}")
370
+
371
+ if satisfied:
372
+ self.store.remove_course_by_identity(
373
+ self.user["id"],
374
+ course["category"],
375
+ course["course_id"],
376
+ course["course_index"],
377
+ )
378
+ return True
379
+ return False
380
+
381
+ def _submit_with_optional_captcha(self, driver: WebDriver, wait: WebDriverWait, course_key: str) -> list[dict]:
382
+ submit_button = self._find(driver, By.ID, "submitButton")
383
+ submit_method = self._trigger_non_blocking_action(
384
+ driver,
385
+ submit_button,
386
+ label=f"课程提交按钮 {course_key}",
387
+ allow_form_submit=True,
388
+ )
389
+ self.logger("INFO", f"课程 {course_key} 已触发提交,方式={submit_method}。")
390
+
391
+ for attempt in range(1, self.config.submit_captcha_retry_limit + 1):
392
+ state = self._wait_for_submit_state(driver, wait)
393
+ if state == "result":
394
+ return self._read_result_page(driver, wait)
395
+ if state == "captcha":
396
+ self.logger("INFO", f"提交 {course_key} 时检测到验证码,第 {attempt} 次自动识别。")
397
+ if not self._solve_visible_submit_captcha(driver):
398
+ raise RecoverableAutomationError("提交选课时检测到验证码,但未能自动完成识别与提交。")
399
+ continue
400
+ self.logger("WARNING", f"提交 {course_key} 后页面未返回结果,也未检测到验证码。")
401
+
402
+ raise RecoverableAutomationError("提交选课后连续多次遇到验证码或未返回结果。")
403
+
404
+ def _wait_for_submit_state(self, driver: WebDriver, wait: WebDriverWait) -> str:
405
+ script = """
406
+ const visible = (el) => {
407
+ if (!el) return false;
408
+ const style = window.getComputedStyle(el);
409
+ const rect = el.getBoundingClientRect();
410
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
411
+ };
412
+ if (document.querySelector('#xkresult tbody tr')) return 'result';
413
+ const input = Array.from(document.querySelectorAll('input')).find((el) => {
414
+ const text = `${el.placeholder || ''} ${el.id || ''} ${el.name || ''} ${el.className || ''}`;
415
+ return visible(el) && /验证码|captcha/i.test(text);
416
+ });
417
+ const img = Array.from(document.querySelectorAll('img')).find((el) => {
418
+ const text = `${el.src || ''} ${el.id || ''} ${el.alt || ''} ${el.className || ''}`;
419
+ return visible(el) && (/captcha/i.test(text) || (el.src || '').includes('base64') || (el.naturalWidth >= 50 && el.naturalWidth <= 240 && el.naturalHeight >= 20 && el.naturalHeight <= 120));
420
+ });
421
+ const button = Array.from(document.querySelectorAll('button, input[type="button"], input[type="submit"]')).find((el) => {
422
+ const text = `${el.innerText || ''} ${el.value || ''}`;
423
+ return visible(el) && /确定|提交|确认|验证/.test(text);
424
+ });
425
+ if (input && img && button) return 'captcha';
426
+ return 'pending';
427
+ """
428
+ try:
429
+ wait.until(lambda current_driver: current_driver.execute_script(script) != "pending")
430
+ except TimeoutException:
431
+ return "pending"
432
+ return str(driver.execute_script(script))
433
+
434
+ def _solve_visible_submit_captcha(self, driver: WebDriver) -> bool:
435
+ image = self._find_first_visible_optional(driver, SUBMIT_CAPTCHA_IMAGE_SELECTORS, timeout=3)
436
+ input_box = self._find_first_visible_optional(driver, SUBMIT_CAPTCHA_INPUT_SELECTORS, timeout=3)
437
+ button = self._find_first_visible_optional(driver, SUBMIT_CAPTCHA_BUTTON_SELECTORS, timeout=3)
438
+ if not image or not input_box or not button:
439
+ return False
440
+
441
+ captcha_text = self._solve_captcha_text(image, scene="提交")
442
+ self.logger("INFO", f"提交验证码 OCR 输出: {captcha_text}")
443
+ input_box.clear()
444
+ input_box.send_keys(captcha_text)
445
+ submit_method = self._trigger_non_blocking_action(
446
+ driver,
447
+ button,
448
+ label="提交验证码确认按钮",
449
+ allow_form_submit=True,
450
+ )
451
+ self.logger("INFO", f"提交验证码确认已触发,方式={submit_method}。")
452
+ time.sleep(1.0)
453
+ return True
454
+
455
+ def _open_category_tab(self, driver: WebDriver, wait: WebDriverWait, category: str) -> None:
456
+ tab_id = CATEGORY_META[category]["tab_id"]
457
+ tab = self._find(driver, By.ID, tab_id)
458
+ tab.click()
459
+ webdriver_utils.wait_for_ready(wait, allow_interactive=True)
460
+ time.sleep(0.2)
461
+
462
+ def _read_result_page(self, driver: WebDriver, wait: WebDriverWait) -> list[dict]:
463
+ try:
464
+ webdriver_utils.wait_for_ready(wait, allow_interactive=True)
465
+ wait.until(
466
+ lambda current_driver: current_driver.execute_script(
467
+ """
468
+ const node = document.querySelector('#xkresult tbody tr');
469
+ return Boolean(node);
470
+ """
471
+ )
472
+ )
473
+ return json.loads(driver.execute_script(self.check_result_js))
474
+ except Exception as exc:
475
+ raise RecoverableAutomationError(
476
+ f"读取选课结果失败,页面结构可能发生变化。{self._page_snapshot(driver, include_body=True)}"
477
+ ) from exc
478
+
479
+ def _open_page(
480
+ self,
481
+ driver: WebDriver,
482
+ wait: WebDriverWait,
483
+ url: str,
484
+ label: str,
485
+ *,
486
+ allow_interactive: bool = True,
487
+ log_on_success: bool = False,
488
+ ) -> None:
489
+ timed_out = webdriver_utils.open_with_recovery(driver, url)
490
+ if timed_out:
491
+ self.logger("WARNING", f"{label} 页面加载超时,尝试停止页面并继续执行。")
492
+ try:
493
+ ready_state = webdriver_utils.wait_for_ready(wait, allow_interactive=allow_interactive)
494
+ except TimeoutException as exc:
495
+ raise RecoverableAutomationError(f"{label} 页面加载失败。{self._page_snapshot(driver, include_body=True)}") from exc
496
+ if log_on_success or timed_out:
497
+ self.logger("INFO", f"{label} 页面已打开,readyState={ready_state}。{self._page_snapshot(driver, include_body=timed_out)}")
498
+
499
+ def _wait_for_login_outcome(self, driver: WebDriver, timeout_seconds: int = 10) -> tuple[str, str]:
500
+ deadline = time.monotonic() + max(1, timeout_seconds)
501
+ last_error = ""
502
+ while time.monotonic() < deadline:
503
+ try:
504
+ current_url = driver.current_url or ""
505
+ except WebDriverException:
506
+ time.sleep(0.3)
507
+ continue
508
+ if self._is_login_success_url(current_url):
509
+ return "success", ""
510
+ last_error = self._read_login_error(driver)
511
+ if last_error:
512
+ return "error", last_error
513
+ time.sleep(0.4)
514
+ return "unknown", self._read_login_error(driver)
515
+
516
+ def _read_login_error(self, driver: WebDriver) -> str:
517
+ script = """
518
+ const visible = (node) => {
519
+ if (!node) return false;
520
+ const style = window.getComputedStyle(node);
521
+ const rect = node.getBoundingClientRect();
522
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
523
+ };
524
+ const selectors = [
525
+ '.el-message',
526
+ '[role="alert"]',
527
+ '.message',
528
+ '.toast',
529
+ '.el-form-item__error',
530
+ '.error'
531
+ ];
532
+ for (const selector of selectors) {
533
+ for (const node of Array.from(document.querySelectorAll(selector))) {
534
+ const text = (node.innerText || '').trim();
535
+ if (visible(node) && text) {
536
+ return text;
537
+ }
538
+ }
539
+ }
540
+ const nodes = Array.from(document.querySelectorAll('body span, body div'));
541
+ for (const node of nodes) {
542
+ const text = (node.innerText || '').trim();
543
+ if (!text || !visible(node)) continue;
544
+ if (/验证码|密码|用户|账号/.test(text)) return text;
545
+ }
546
+ return '';
547
+ """
548
+ try:
549
+ raw_message = driver.execute_script(script) or ""
550
+ except WebDriverException:
551
+ return ""
552
+ return re.sub(r"\s+", " ", str(raw_message)).strip()
553
+
554
+ def _trigger_non_blocking_action(
555
+ self,
556
+ driver: WebDriver,
557
+ element: WebElement,
558
+ *,
559
+ label: str,
560
+ allow_form_submit: bool = False,
561
+ ) -> str:
562
+ script = """
563
+ const target = arguments[0];
564
+ const allowFormSubmit = Boolean(arguments[1]);
565
+ const form = allowFormSubmit && target ? (target.form || target.closest('form')) : null;
566
+ let method = 'unavailable';
567
+
568
+ if (form && typeof form.requestSubmit === 'function') {
569
+ method = 'scheduled-requestSubmit';
570
+ } else if (target && typeof target.click === 'function') {
571
+ method = 'scheduled-js-click';
572
+ } else if (target) {
573
+ method = 'scheduled-dispatch-click';
574
+ } else if (form && typeof form.submit === 'function') {
575
+ method = 'scheduled-form-submit';
576
+ }
577
+
578
+ if (!target && !form) {
579
+ return 'unavailable';
580
+ }
581
+
582
+ window.setTimeout(() => {
583
+ const dispatchFallback = (node) => {
584
+ if (!node) return false;
585
+ ['pointerdown', 'mousedown', 'mouseup', 'click'].forEach((type) => {
586
+ node.dispatchEvent(new MouseEvent(type, {
587
+ bubbles: true,
588
+ cancelable: true,
589
+ view: window,
590
+ }));
591
+ });
592
+ return true;
593
+ };
594
+
595
+ try {
596
+ if (target && typeof target.scrollIntoView === 'function') {
597
+ target.scrollIntoView({block: 'center', inline: 'center'});
598
+ }
599
+ } catch (error) {}
600
+
601
+ try {
602
+ if (form && typeof form.requestSubmit === 'function') {
603
+ form.requestSubmit(target || undefined);
604
+ return;
605
+ }
606
+ } catch (error) {}
607
+
608
+ try {
609
+ if (target && typeof target.click === 'function') {
610
+ target.click();
611
+ return;
612
+ }
613
+ } catch (error) {}
614
+
615
+ try {
616
+ if (dispatchFallback(target)) {
617
+ return;
618
+ }
619
+ } catch (error) {}
620
+
621
+ try {
622
+ if (form && typeof form.submit === 'function') {
623
+ form.submit();
624
+ }
625
+ } catch (error) {}
626
+ }, 0);
627
+
628
+ return method;
629
+ """
630
+ try:
631
+ method = str(driver.execute_script(script, element, allow_form_submit) or "").strip()
632
+ if method and method != "unavailable":
633
+ return method
634
+ self.logger("WARNING", f"{label} JS 非阻塞提交未找到可用方式,回退到原生点击。")
635
+ except Exception as exc:
636
+ self.logger("WARNING", f"{label} 的 JS 非阻塞提交失败,回退到原生点击。原因: {exc}")
637
+
638
+ try:
639
+ element.click()
640
+ return "native-click"
641
+ except TimeoutException:
642
+ self.logger("WARNING", f"{label} 的原生点击触发后页面响应超时,已执行 window.stop() 并继续等待。")
643
+ self._stop_loading(driver)
644
+ return "native-click-timeout"
645
+ except WebDriverException as exc:
646
+ raise RecoverableAutomationError(f"{label} 触发失败: {exc}") from exc
647
+
648
+ @staticmethod
649
+ def _stop_loading(driver: WebDriver) -> None:
650
+ try:
651
+ driver.execute_script("window.stop();")
652
+ except Exception:
653
+ pass
654
+
655
+ def _solve_captcha_text(self, image_element: WebElement, *, scene: str) -> str:
656
+ last_candidate = ""
657
+ for attempt in range(1, 3):
658
+ raw_text = self.captcha_solver.classification(self._extract_image_bytes(image_element))
659
+ normalized = re.sub(r"[^0-9A-Za-z]", "", str(raw_text or "")).strip()
660
+ if len(normalized) >= 4:
661
+ return normalized[:4]
662
+ last_candidate = normalized
663
+ self.logger("WARNING", f"{scene}验证码 OCR 输出异常,第 {attempt} 次结果: {raw_text!r}")
664
+ try:
665
+ image_element.click()
666
+ time.sleep(0.4)
667
+ except Exception:
668
+ pass
669
+ if len(last_candidate) >= 3:
670
+ return last_candidate[:4]
671
+ raise RecoverableAutomationError(f"{scene}验证码 OCR 未能识别出有效内容。")
672
+
673
+ def _is_login_success_url(self, url: str) -> bool:
674
+ if not url:
675
+ return False
676
+ if any(url.startswith(prefix) for prefix in LOGIN_SUCCESS_PREFIXES):
677
+ return True
678
+ return "zhjw.scu.edu.cn" in url and "id.scu.edu.cn" not in url
679
+
680
+ def _is_session_expired(self, driver: WebDriver) -> bool:
681
+ current_url = driver.current_url or ""
682
+ if "id.scu.edu.cn" in current_url:
683
+ return True
684
+ password_box = self._find_first_visible_optional(driver, LOGIN_PASSWORD_SELECTORS, timeout=1)
685
+ return password_box is not None
686
+
687
+ def _safe_body_text(self, driver: WebDriver) -> str:
688
+ try:
689
+ body = self._find(driver, By.TAG_NAME, "body")
690
+ return body.text or ""
691
+ except RecoverableAutomationError:
692
+ return ""
693
+
694
+ def _page_snapshot(self, driver: WebDriver, *, include_body: bool = False) -> str:
695
+ script = """
696
+ const body = document.body ? (document.body.innerText || '') : '';
697
+ return JSON.stringify({
698
+ url: window.location.href || '',
699
+ title: document.title || '',
700
+ readyState: document.readyState || '',
701
+ body: body.replace(/\\s+/g, ' ').trim().slice(0, 180)
702
+ });
703
+ """
704
+ try:
705
+ raw = driver.execute_script(script)
706
+ data = json.loads(raw) if isinstance(raw, str) else raw
707
+ except Exception:
708
+ current_url = getattr(driver, "current_url", "") or ""
709
+ return f" url={current_url or '-'}"
710
+
711
+ parts = [
712
+ f"url={data.get('url') or '-'}",
713
+ f"title={data.get('title') or '-'}",
714
+ f"readyState={data.get('readyState') or '-'}",
715
+ ]
716
+ body_excerpt = (data.get("body") or "").strip()
717
+ if include_body and body_excerpt:
718
+ parts.append(f"body={body_excerpt}")
719
+ return " " + " | ".join(parts)
720
+
721
+ def _extract_image_bytes(self, image_element: WebElement) -> bytes:
722
+ source = image_element.get_attribute("src") or ""
723
+ if "base64," in source:
724
+ return base64.b64decode(source.split("base64,", 1)[1])
725
+ return image_element.screenshot_as_png
726
+
727
+ def _find_first_visible(self, driver: WebDriver, selectors: list[tuple[str, str]], label: str, timeout: int = 0):
728
+ element = self._find_first_visible_optional(driver, selectors, timeout=timeout)
729
+ if element is None:
730
+ raise RecoverableAutomationError(f"页面元素未找到: {label}。{self._page_snapshot(driver, include_body=True)}")
731
+ return element
732
+
733
+ @staticmethod
734
+ def _find_first_visible_optional(driver: WebDriver, selectors: list[tuple[str, str]], timeout: int = 0):
735
+ deadline = time.monotonic() + max(0, timeout)
736
+ while True:
737
+ for by, value in selectors:
738
+ try:
739
+ elements = driver.find_elements(by, value)
740
+ except WebDriverException:
741
+ continue
742
+ for element in elements:
743
+ try:
744
+ if element.is_displayed():
745
+ return element
746
+ except WebDriverException:
747
+ continue
748
+ if timeout <= 0 or time.monotonic() >= deadline:
749
+ return None
750
+ time.sleep(0.2)
751
+
752
+ @staticmethod
753
+ def _find(driver: WebDriver, by: str, value: str):
754
+ try:
755
+ return driver.find_element(by, value)
756
+ except NoSuchElementException as exc:
757
+ raise RecoverableAutomationError(f"页面元素未找到: {value}") from exc
758
+ except WebDriverException as exc:
759
+ raise RecoverableAutomationError(f"浏览器操作失败: {value}") from exc
760
+
761
+ def _sleep_with_cancel(self, stop_event, seconds: int, reason: str) -> None:
762
+ if seconds <= 0:
763
+ return
764
+ self.logger("INFO", f"{reason} 大约 {seconds} 秒。")
765
+ for _ in range(seconds):
766
+ if stop_event.is_set():
767
+ return
768
+ time.sleep(1)
tests/test_course_bot.py CHANGED
@@ -1,4 +1,4 @@
1
- from __future__ import annotations
2
 
3
  import threading
4
  import unittest
@@ -6,6 +6,8 @@ from pathlib import Path
6
  from types import SimpleNamespace
7
  from unittest.mock import patch
8
 
 
 
9
  from core.course_bot import CourseBot, RecoverableAutomationError
10
  from core.db import Database
11
  from tests.helpers import workspace_tempdir
@@ -19,12 +21,51 @@ class FakeDriver:
19
  self.quit_called = True
20
 
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  class FakeButton:
23
- def __init__(self) -> None:
24
  self.click_count = 0
 
25
 
26
  def click(self) -> None:
27
  self.click_count += 1
 
 
28
 
29
 
30
  class CourseBotTests(unittest.TestCase):
@@ -89,10 +130,10 @@ class CourseBotTests(unittest.TestCase):
89
  result = bot.run(stop_event)
90
 
91
  self.assertEqual(result.status, "failed")
92
- self.assertIn("任务终止", result.error)
93
  self.assertEqual(len(drivers), config.selenium_restart_limit)
94
  self.assertTrue(all(driver.quit_called for driver in drivers))
95
- self.assertTrue(any("重建浏览器" in message or "重建 Selenium 会话" in message for _level, message in logs))
96
 
97
  def test_submit_captcha_flow_uses_ocr_branch(self) -> None:
98
  with workspace_tempdir("submit-captcha-") as temp_dir:
@@ -101,6 +142,7 @@ class CourseBotTests(unittest.TestCase):
101
  logs: list[tuple[str, str]] = []
102
  bot = self._make_bot(store, user, config, logs)
103
  fake_button = FakeButton()
 
104
  fake_results = [{"result": True, "subject": "1001001_01", "detail": ""}]
105
 
106
  with patch.object(bot, "_find", return_value=fake_button), patch.object(
@@ -116,13 +158,72 @@ class CourseBotTests(unittest.TestCase):
116
  "_read_result_page",
117
  return_value=fake_results,
118
  ):
119
- results = bot._submit_with_optional_captcha(object(), object(), "1001001_01")
120
 
121
  self.assertEqual(results, fake_results)
122
- self.assertEqual(fake_button.click_count, 1)
123
  solve_mock.assert_called_once()
124
- self.assertTrue(any("检测到验证码" in message for _level, message in logs))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
 
127
  if __name__ == "__main__":
128
- unittest.main()
 
1
+ from __future__ import annotations
2
 
3
  import threading
4
  import unittest
 
6
  from types import SimpleNamespace
7
  from unittest.mock import patch
8
 
9
+ from selenium.common.exceptions import TimeoutException, WebDriverException
10
+
11
  from core.course_bot import CourseBot, RecoverableAutomationError
12
  from core.db import Database
13
  from tests.helpers import workspace_tempdir
 
21
  self.quit_called = True
22
 
23
 
24
+ class FakeActionDriver:
25
+ def __init__(self, script_results: list[object] | None = None) -> None:
26
+ self.script_results = list(script_results or [])
27
+ self.script_calls: list[str] = []
28
+ self.stop_called = False
29
+
30
+ def execute_script(self, script: str, *_args):
31
+ self.script_calls.append(script)
32
+ if script.strip() == "window.stop();":
33
+ self.stop_called = True
34
+ return None
35
+ if self.script_results:
36
+ result = self.script_results.pop(0)
37
+ if isinstance(result, Exception):
38
+ raise result
39
+ return result
40
+ return "scheduled-js-click"
41
+
42
+
43
+ class FakeLoginOutcomeDriver:
44
+ def __init__(self, url_steps: list[object]) -> None:
45
+ self.url_steps = list(url_steps)
46
+
47
+ @property
48
+ def current_url(self) -> str:
49
+ if self.url_steps:
50
+ value = self.url_steps.pop(0)
51
+ if isinstance(value, Exception):
52
+ raise value
53
+ return str(value)
54
+ return ""
55
+
56
+ def execute_script(self, _script: str):
57
+ return ""
58
+
59
+
60
  class FakeButton:
61
+ def __init__(self, *, click_exception: Exception | None = None) -> None:
62
  self.click_count = 0
63
+ self.click_exception = click_exception
64
 
65
  def click(self) -> None:
66
  self.click_count += 1
67
+ if self.click_exception is not None:
68
+ raise self.click_exception
69
 
70
 
71
  class CourseBotTests(unittest.TestCase):
 
130
  result = bot.run(stop_event)
131
 
132
  self.assertEqual(result.status, "failed")
133
+ self.assertIn("\u4efb\u52a1\u7ec8\u6b62", result.error)
134
  self.assertEqual(len(drivers), config.selenium_restart_limit)
135
  self.assertTrue(all(driver.quit_called for driver in drivers))
136
+ self.assertTrue(any("\u91cd\u5efa\u6d4f\u89c8\u5668" in message or "\u91cd\u5efa Selenium \u4f1a\u8bdd" in message for _level, message in logs))
137
 
138
  def test_submit_captcha_flow_uses_ocr_branch(self) -> None:
139
  with workspace_tempdir("submit-captcha-") as temp_dir:
 
142
  logs: list[tuple[str, str]] = []
143
  bot = self._make_bot(store, user, config, logs)
144
  fake_button = FakeButton()
145
+ fake_driver = FakeActionDriver(["scheduled-js-click"])
146
  fake_results = [{"result": True, "subject": "1001001_01", "detail": ""}]
147
 
148
  with patch.object(bot, "_find", return_value=fake_button), patch.object(
 
158
  "_read_result_page",
159
  return_value=fake_results,
160
  ):
161
+ results = bot._submit_with_optional_captcha(fake_driver, object(), "1001001_01")
162
 
163
  self.assertEqual(results, fake_results)
164
+ self.assertEqual(fake_button.click_count, 0)
165
  solve_mock.assert_called_once()
166
+ self.assertTrue(any("\u68c0\u6d4b\u5230\u9a8c\u8bc1\u7801" in message for _level, message in logs))
167
+ self.assertTrue(any("\u5df2\u89e6\u53d1\u63d0\u4ea4" in message for _level, message in logs))
168
+
169
+ def test_trigger_non_blocking_action_prefers_js_submit(self) -> None:
170
+ with workspace_tempdir("non-blocking-js-") as temp_dir:
171
+ store, user = self._make_store(temp_dir)
172
+ config = self._make_config()
173
+ logs: list[tuple[str, str]] = []
174
+ bot = self._make_bot(store, user, config, logs)
175
+ driver = FakeActionDriver(["scheduled-requestSubmit"])
176
+ button = FakeButton()
177
+
178
+ method = bot._trigger_non_blocking_action(
179
+ driver,
180
+ button,
181
+ label="login-form",
182
+ allow_form_submit=True,
183
+ )
184
+
185
+ self.assertEqual(method, "scheduled-requestSubmit")
186
+ self.assertEqual(button.click_count, 0)
187
+ self.assertFalse(driver.stop_called)
188
+
189
+ def test_trigger_non_blocking_action_recovers_native_timeout(self) -> None:
190
+ with workspace_tempdir("non-blocking-timeout-") as temp_dir:
191
+ store, user = self._make_store(temp_dir)
192
+ config = self._make_config()
193
+ logs: list[tuple[str, str]] = []
194
+ bot = self._make_bot(store, user, config, logs)
195
+ driver = FakeActionDriver([WebDriverException("js failed")])
196
+ button = FakeButton(click_exception=TimeoutException("renderer timeout"))
197
+
198
+ method = bot._trigger_non_blocking_action(
199
+ driver,
200
+ button,
201
+ label="login-form",
202
+ allow_form_submit=True,
203
+ )
204
+
205
+ self.assertEqual(method, "native-click-timeout")
206
+ self.assertEqual(button.click_count, 1)
207
+ self.assertTrue(driver.stop_called)
208
+ self.assertTrue(any("window.stop()" in message for _level, message in logs))
209
+
210
+ def test_wait_for_login_outcome_ignores_transient_driver_errors(self) -> None:
211
+ with workspace_tempdir("login-outcome-") as temp_dir:
212
+ store, user = self._make_store(temp_dir)
213
+ config = self._make_config()
214
+ logs: list[tuple[str, str]] = []
215
+ bot = self._make_bot(store, user, config, logs)
216
+ driver = FakeLoginOutcomeDriver([
217
+ WebDriverException("navigation in progress"),
218
+ "https://zhjw.scu.edu.cn/index",
219
+ ])
220
+
221
+ with patch("core.course_bot.time.sleep", return_value=None):
222
+ state, error_message = bot._wait_for_login_outcome(driver, timeout_seconds=1)
223
+
224
+ self.assertEqual(state, "success")
225
+ self.assertEqual(error_message, "")
226
 
227
 
228
  if __name__ == "__main__":
229
+ unittest.main()