编写一个监测指定浏览器指定网页上的一个元素数字变化并实时通过指定音频设备(防止当前音频设备被独占)报...
Créé le : 9 juillet 2026
Répondu en utilisant GPT-5.5 Thinking par Chat01
Créé le : 9 juillet 2026
Répondu en utilisant GPT-5.5 Thinking par Chat01
编写一个监测指定浏览器指定网页上的一个元素数字变化并实时通过指定音频设备(防止当前音频设备被独占)报警的程序,我具体是用在选课网站看选课已选人数有没有下降,方便一有名额了我立即进入选课。要求程序易用并且易测试。
pythonfrom pathlib import Path import json, textwrap, zipfile, os base = Path("/mnt/data/course_seat_monitor") base.mkdir(exist_ok=True) monitor_code = r'''#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Course Seat Monitor 监测已打开浏览器指定页面上的某个元素数字变化,并通过指定音频输出设备报警。 用途示例: 1. 列出音频设备: python monitor_course_seats.py --list-audio 2. 测试指定音频设备报警: python monitor_course_seats.py --test-audio --device "USB" 3. 按配置开始监测: python monitor_course_seats.py --config config.json 注意: - 只做“读页面 + 本地报警”,不自动抢课、不自动提交表单。 - Chrome/Edge 必须用 --remote-debugging-port 启动,程序才能连接到已经登录的页面。 """ from __future__ import annotations import argparse import json import math import re import sys import time from dataclasses import dataclass from pathlib import Path from typing import Any, Optional import numpy as np import sounddevice as sd from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeoutError @dataclass class AlertConfig: audio_device: str | int | None = None frequency_hz: int = 880 beep_seconds: float = 0.7 repeat: int = 20 gap_seconds: float = 0.2 samplerate: int = 44100 @dataclass class MonitorConfig: cdp_url: str target_url_keyword: str selector: str selector_type: str = "css" # css / xpath extract_regex: str = r"\d+" number_index: int = 0 poll_interval_sec: float = 3.0 trigger_when: str = "decrease" # decrease / increase / changed / below_or_equal / above_or_equal threshold: Optional[float] = None baseline: Optional[float] = None reload_page_each_poll: bool = False wait_timeout_ms: int = 3000 alarm_once: bool = False alert: AlertConfig = AlertConfig() log_file: str = "monitor.log" def load_config(path: str | Path) -> MonitorConfig: data = json.loads(Path(path).read_text(encoding="utf-8")) alert_data = data.get("alert", {}) or {} alert = AlertConfig( audio_device=alert_data.get("audio_device", None), frequency_hz=int(alert_data.get("frequency_hz", 880)), beep_seconds=float(alert_data.get("beep_seconds", 0.7)), repeat=int(alert_data.get("repeat", 20)), gap_seconds=float(alert_data.get("gap_seconds", 0.2)), samplerate=int(alert_data.get("samplerate", 44100)), ) return MonitorConfig( cdp_url=data.get("cdp_url", "http://127.0.0.1:9222"), target_url_keyword=data["target_url_keyword"], selector=data["selector"], selector_type=data.get("selector_type", "css").lower(), extract_regex=data.get("extract_regex", r"\d+"), number_index=int(data.get("number_index", 0)), poll_interval_sec=float(data.get("poll_interval_sec", 3.0)), trigger_when=data.get("trigger_when", "decrease"), threshold=data.get("threshold", None), baseline=data.get("baseline", None), reload_page_each_poll=bool(data.get("reload_page_each_poll", False)), wait_timeout_ms=int(data.get("wait_timeout_ms", 3000)), alarm_once=bool(data.get("alarm_once", False)), alert=alert, log_file=data.get("log_file", "monitor.log"), ) def log_line(msg: str, log_file: str | None = None) -> None: line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}" print(line, flush=True) if log_file: Path(log_file).write_text( (Path(log_file).read_text(encoding="utf-8") if Path(log_file).exists() else "") + line + "\n", encoding="utf-8", ) def list_audio_devices() -> None: print(sd.query_devices()) def find_output_device(device: str | int | None) -> Optional[int]: """ 返回 sounddevice 可用的输出设备 index。 device 可以是: - None 或空字符串:使用系统默认输出设备 - 数字:设备序号 - 字符串:按设备名称不区分大小写模糊匹配 """ if device is None or device == "": return None devices = sd.query_devices() if isinstance(device, int): info = devices[device] if info["max_output_channels"] < 1: raise ValueError(f"设备 {device} 不是输出设备:{info['name']}") return device device_str = str(device).strip() if device_str.isdigit(): idx = int(device_str) info = devices[idx] if info["max_output_channels"] < 1: raise ValueError(f"设备 {idx} 不是输出设备:{info['name']}") return idx needle = device_str.lower() matches: list[tuple[int, str]] = [] for i, info in enumerate(devices): if info["max_output_channels"] >= 1 and needle in info["name"].lower(): matches.append((i, info["name"])) if not matches: raise ValueError(f"找不到包含名称 {device_str!r} 的输出设备。请先运行 --list-audio。") if len(matches) > 1: print("匹配到多个输出设备,将使用第一个;如需精确指定,请用设备序号:") for i, name in matches: print(f" {i}: {name}") return matches[0][0] def play_alarm(alert: AlertConfig) -> None: device_index = find_output_device(alert.audio_device) samplerate = alert.samplerate # 生成带淡入淡出的正弦波,减少爆音 t = np.linspace(0, alert.beep_seconds, int(samplerate * alert.beep_seconds), endpoint=False) wave = 0.45 * np.sin(2 * math.pi * alert.frequency_hz * t) fade_len = min(int(0.02 * samplerate), len(wave) // 4) if fade_len > 0: fade = np.linspace(0, 1, fade_len) wave[:fade_len] *= fade wave[-fade_len:] *= fade[::-1] for _ in range(max(1, alert.repeat)): sd.play(wave.astype(np.float32), samplerate=samplerate, device=device_index, blocking=True) if alert.gap_seconds > 0: time.sleep(alert.gap_seconds) def parse_number(text: str, pattern: str, number_index: int = 0) -> float: matches = re.findall(pattern, text) if not matches: raise ValueError(f"文本中没有匹配到数字。文本={text!r},regex={pattern!r}") if number_index >= len(matches): raise ValueError(f"匹配到 {len(matches)} 个数字,但 number_index={number_index} 越界。匹配结果={matches}") raw = matches[number_index] if isinstance(raw, tuple): raw = next((x for x in raw if x), "") # 支持 1,234 / 12.5 / 中文文本中的数字 cleaned = str(raw).replace(",", "").strip() return float(cleaned) def should_trigger(current: float, previous: float, cfg: MonitorConfig) -> bool: mode = cfg.trigger_when.lower() threshold = cfg.threshold if mode == "decrease": return current < previous if mode == "increase": return current > previous if mode == "changed": return current != previous if mode == "below_or_equal": if threshold is None: raise ValueError("trigger_when=below_or_equal 时必须设置 threshold") return current <= float(threshold) if mode == "above_or_equal": if threshold is None: raise ValueError("trigger_when=above_or_equal 时必须设置 threshold") return current >= float(threshold) raise ValueError(f"不支持的 trigger_when: {cfg.trigger_when}") def find_target_page(browser, target_url_keyword: str): all_pages = [] for context in browser.contexts: all_pages.extend(context.pages) matched = [p for p in all_pages if target_url_keyword in p.url] if not matched: print("当前可见页面:") for p in all_pages: print(" -", p.url) raise RuntimeError(f"没有找到 URL 包含 {target_url_keyword!r} 的页面。请确认该页面已在调试浏览器中打开。") if len(matched) > 1: print("匹配到多个页面,将使用第一个:") for p in matched: print(" -", p.url) return matched[0] def read_value_from_page(page, cfg: MonitorConfig) -> tuple[float, str]: if cfg.reload_page_each_poll: page.reload(wait_until="domcontentloaded", timeout=cfg.wait_timeout_ms) if cfg.selector_type == "xpath": locator = page.locator(f"xpath={cfg.selector}") elif cfg.selector_type == "css": locator = page.locator(cfg.selector) else: raise ValueError("selector_type 只能是 css 或 xpath") text = locator.first.text_content(timeout=cfg.wait_timeout_ms) if text is None: text = "" value = parse_number(text, cfg.extract_regex, cfg.number_index) return value, text.strip() def monitor(config_path: str | Path) -> None: cfg = load_config(config_path) log_line("启动监测程序。", cfg.log_file) log_line(f"CDP: {cfg.cdp_url}", cfg.log_file) log_line(f"页面 URL 关键词: {cfg.target_url_keyword}", cfg.log_file) log_line(f"选择器: {cfg.selector_type}={cfg.selector}", cfg.log_file) with sync_playwright() as p: browser = p.chromium.connect_over_cdp(cfg.cdp_url) page = find_target_page(browser, cfg.target_url_keyword) log_line(f"已连接页面: {page.url}", cfg.log_file) value, text = read_value_from_page(page, cfg) previous = float(cfg.baseline) if cfg.baseline is not None else value log_line(f"初始读取: value={value}, text={text!r}, baseline={previous}", cfg.log_file) while True: try: current, raw_text = read_value_from_page(page, cfg) log_line(f"当前读取: value={current}, previous={previous}, text={raw_text!r}", cfg.log_file) if should_trigger(current, previous, cfg): log_line(f"触发报警:{cfg.trigger_when},previous={previous}, current={current}", cfg.log_file) try: play_alarm(cfg.alert) except Exception as audio_error: # 音频设备被独占/不可用时,至少在命令行持续响铃和报错 log_line(f"音频报警失败:{audio_error!r}", cfg.log_file) for _ in range(10): print("\a", end="", flush=True) time.sleep(0.4) if cfg.alarm_once: log_line("alarm_once=true,程序退出。", cfg.log_file) return previous = current time.sleep(cfg.poll_interval_sec) except KeyboardInterrupt: log_line("用户中断,程序退出。", cfg.log_file) return except PlaywrightTimeoutError as e: log_line(f"页面元素读取超时:{e!r}", cfg.log_file) time.sleep(cfg.poll_interval_sec) except Exception as e: log_line(f"读取失败:{e!r}", cfg.log_file) time.sleep(cfg.poll_interval_sec) def main() -> None: parser = argparse.ArgumentParser(description="监测指定浏览器指定网页元素数字变化并报警") parser.add_argument("--config", default="config.json", help="配置文件路径,默认 config.json") parser.add_argument("--list-audio", action="store_true", help="列出可用音频设备") parser.add_argument("--test-audio", action="store_true", help="测试报警声音") parser.add_argument("--device", default=None, help="测试报警用的音频设备序号或名称片段") args = parser.parse_args() if args.list_audio: list_audio_devices() return if args.test_audio: play_alarm(AlertConfig(audio_device=args.device, repeat=3, gap_seconds=0.25)) return monitor(args.config) if __name__ == "__main__": main() ''' config_example = { "cdp_url": "http://127.0.0.1:9222", "target_url_keyword": "test_page.html", "selector_type": "css", "selector": "#selected-count", "extract_regex": "\\d+", "number_index": 0, "poll_interval_sec": 2, "trigger_when": "decrease", "threshold": None, "baseline": None, "reload_page_each_poll": False, "wait_timeout_ms": 3000, "alarm_once": False, "log_file": "monitor.log", "alert": { "audio_device": "", "frequency_hz": 880, "beep_seconds": 0.7, "repeat": 10, "gap_seconds": 0.2, "samplerate": 44100 } } test_page = r'''<!doctype html> <html lang="zh-CN"> <head> <meta charset="utf-8" /> <title>test_page.html - 选课人数监测测试页</title> <style> body { font-family: system-ui, "Microsoft YaHei", sans-serif; margin: 40px; line-height: 1.7; } .card { border: 1px solid #ddd; border-radius: 12px; padding: 24px; max-width: 560px; } #selected-count { font-size: 48px; font-weight: 700; color: #b00020; } button { font-size: 18px; margin-right: 12px; padding: 8px 14px; } code { background: #f4f4f4; padding: 2px 5px; border-radius: 4px; } </style> </head> <body> <div class="card"> <h1>选课人数测试页</h1> <p>已选人数:<span id="selected-count">30</span> / 30</p> <button onclick="change(-1)">模拟有人退课:人数下降</button> <button onclick="change(1)">模拟有人选课:人数上升</button> <button onclick="setCount(30)">重置为 30</button> <p>程序默认监测 CSS 选择器:<code>#selected-count</code>。点击“人数下降”后应触发报警。</p> </div> <script> let n = 30; function change(delta) { n += delta; if (n < 0) n = 0; setCount(n); } function setCount(v) { n = v; document.querySelector("#selected-count").innerText = String(n); } </script> </body> </html> ''' requirements = """playwright>=1.45 sounddevice>=0.5 numpy>=1.24 """ readme = r'''# Course Seat Monitor 使用说明 这个程序用于监测“已打开的 Chrome/Edge 页面”中指定元素的数字变化,并通过指定音频设备报警。 它只读取页面文本并报警,不会自动点击、抢课或提交表单。 ## 1. 安装 ```bash python -m pip install -r requirements.txt
Windows Chrome 示例:
batstart "" "C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222 --user-data-dir="%TEMP%\course-monitor-chrome"
Windows Edge 示例:
batstart "" "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" --remote-debugging-port=9222 --user-data-dir="%TEMP%\course-monitor-edge"
打开后,在这个浏览器窗口里登录选课网站,并进入需要监测的课程页面。
列出设备:
bashpython monitor_course_seats.py --list-audio
测试指定设备,device 可以填序号或名称片段:
bashpython monitor_course_seats.py --test-audio --device 3 python monitor_course_seats.py --test-audio --device "USB"
如果你担心当前默认音频设备被播放器、游戏、会议软件独占,请选择另一个输出设备,例如 USB 小音箱、显示器 HDMI 音频、蓝牙耳机等。
test_page.html。config.json 默认配置。bashpython monitor_course_seats.py --config config.json
把 config.json 改成类似这样:
json{ "cdp_url": "http://127.0.0.1:9222", "target_url_keyword": "courseSelection", "selector_type": "css", "selector": "#selected-count", "extract_regex": "\\d+", "number_index": 0, "poll_interval_sec": 3, "trigger_when": "decrease", "threshold": null, "baseline": null, "reload_page_each_poll": false, "wait_timeout_ms": 3000, "alarm_once": false, "log_file": "monitor.log", "alert": { "audio_device": "USB", "frequency_hz": 880, "beep_seconds": 0.7, "repeat": 20, "gap_seconds": 0.2, "samplerate": 44100 } }
字段说明:
target_url_keyword:浏览器标签页 URL 中的一段关键词,用来定位要监测的页面。selector_type:css 或 xpath。selector:人数所在元素的 CSS/XPath。extract_regex:从元素文本中提取数字,默认提取整数。number_index:如果文本里有多个数字,选择第几个,0 表示第一个。
已选 29 / 30,默认会取 29。trigger_when:
decrease:当前数字小于上次数字时报。increase:当前数字大于上次数字时报。changed:数字变化就报。below_or_equal:当前数字 <= threshold 时报。above_or_equal:当前数字 >= threshold 时报。baseline:默认 null,表示第一次读取值作为基准。也可以手动填一个数字。reload_page_each_poll:是否每次轮询都刷新页面。一般先设 false;如果页面人数不会自动刷新,再考虑 true。poll_interval_sec:轮询间隔。建议不要太低,避免给选课系统造成压力或触发风控。alert.audio_device:音频输出设备序号或名称片段;空字符串表示默认输出设备。在浏览器里按 F12,点击左上角“选择元素”,点选页面上的“已选人数”数字区域。 优先选择稳定的 id 或 data-* 属性,例如:
css#selected-count [data-testid="selected-count"] .course-capacity .selected
如果没有稳定 CSS,可以右键元素复制 XPath,并把 selector_type 改成 xpath。
'''
run_bat = r'''@echo off chcp 65001 >nul python monitor_course_seats.py --config config.json pause '''
(base / "monitor_course_seats.py").write_text(monitor_code, encoding="utf-8") (base / "config.json").write_text(json.dumps(config_example, ensure_ascii=False, indent=2), encoding="utf-8") (base / "requirements.txt").write_text(requirements, encoding="utf-8") (base / "test_page.html").write_text(test_page, encoding="utf-8") (base / "README.md").write_text(readme, encoding="utf-8") (base / "run_windows.bat").write_text(run_bat, encoding="utf-8")
zip_path = Path("/mnt/data/course_seat_monitor.zip") if zip_path.exists(): zip_path.unlink() with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: for p in base.iterdir(): zf.write(p, arcname=p.name)
zip_path.as_posix()
'/mnt/data/course_seat_monitor.zip'
pythonfrom pathlib import Path import zipfile, re base = Path("/mnt/data/course_seat_monitor") path = base / "monitor_course_seats.py" text = path.read_text(encoding="utf-8") text = text.replace("from dataclasses import dataclass", "from dataclasses import dataclass, field") text = text.replace(" alert: AlertConfig = AlertConfig()\n", " alert: AlertConfig = field(default_factory=AlertConfig)\n") path.write_text(text, encoding="utf-8") zip_path = Path("/mnt/data/course_seat_monitor.zip") if zip_path.exists(): zip_path.unlink() with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: for p in base.iterdir(): zf.write(p, arcname=p.name) print(zip_path)
已写好并打包:
下载完整程序压缩包
这个版本采用 Playwright 连接你已打开并已登录的 Chrome/Edge 页面,读取指定元素里的数字;一旦“已选人数下降”,就通过你指定的音频输出设备报警。Playwright 官方支持通过 connect_over_cdp 连接已有 Chromium 系浏览器,但只支持 Chromium-based browsers,比如 Chrome/Edge。(Playwright) 页面元素读取使用 Playwright locator,适合用 CSS 或 XPath 定位页面上的人数元素。(Playwright) 音频报警用 python-sounddevice,它支持列出设备、指定输出设备并播放声音。(Python Sounddevice)
压缩包里有:
textmonitor_course_seats.py 主程序 config.json 配置文件示例 test_page.html 本地测试页面 requirements.txt 依赖 run_windows.bat Windows 一键运行脚本 README.md 使用说明
先安装依赖:
bashpython -m pip install -r requirements.txt
然后用调试端口启动 Chrome:
batstart "" "C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222 --user-data-dir="%TEMP%\course-monitor-chrome"
或者 Edge:
batstart "" "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" --remote-debugging-port=9222 --user-data-dir="%TEMP%\course-monitor-edge"
在这个新打开的浏览器窗口里登录选课网站,进入你要监测的课程页面。
列出可用音频设备:
bashpython monitor_course_seats.py --list-audio
测试指定设备:
bashpython monitor_course_seats.py --test-audio --device 3
也可以用设备名称片段:
bashpython monitor_course_seats.py --test-audio --device "USB"
如果当前默认音频设备可能被播放器、游戏、会议软件独占,建议选另一个真实输出设备,比如 USB 小音箱、HDMI 显示器音频、蓝牙耳机等。Windows 的音频有 shared / exclusive 模式;同一个音频端点被独占时,其他程序可能无法再往这个端点播放,所以程序允许你指定另一个输出设备。(Microsoft Learn)
用刚才带调试端口的 Chrome/Edge 打开压缩包里的:
texttest_page.html
然后运行:
bashpython monitor_course_seats.py --config config.json
在测试页点击“模拟有人退课:人数下降”,程序应该立刻报警。
打开 config.json,主要改这几项:
json{ "target_url_keyword": "courseSelection", "selector_type": "css", "selector": "#selected-count", "poll_interval_sec": 3, "trigger_when": "decrease", "alert": { "audio_device": "USB" } }
含义:
target_url_keyword 是选课页面 URL 里的一段关键词,用来找到对应标签页。
selector 是页面上“已选人数”数字的 CSS 选择器。不会写的话,在浏览器按 F12,用元素选择工具点选人数数字,优先找稳定的 id、data-* 或 class。
trigger_when: "decrease" 表示只要当前人数小于上一次读取值就报警,适合“已选人数下降 = 有人退课 = 可能有名额”的场景。
reload_page_each_poll 默认是 false。如果你的选课页面人数不会自动刷新,可以改成 true,但轮询间隔不要太短,建议 3 秒以上,避免给选课系统造成压力或触发风控。
这个程序只监测和提醒,不会自动点击、提交或抢课。
在此基础上加入功能:检测到下降之后先自动点击指定区域某个内容,再找到指定某个课程点击退课按钮,这然后再自动点击指定按钮“选课”,因为如果不退课而是先点击选课后会跳出一个浏览器层面的弹窗,因为我已经选了另一个同类课程。这一套流程能否做得丝滑并且具有一定容错性,最重要的是稳定。
技术上可以做成“检测到人数下降 → 执行动作链 → 处理确认弹窗/页面变化 → 校验结果 → 失败重试/报警”的流程,而且 Playwright 很适合做这种稳定性更高的浏览器自动化:它的 locator.click() 会自动等待元素可见、稳定、可点击、未被遮挡且启用后再点击,这比直接坐标点击稳定得多。(Playwright) Playwright 也支持按文字、角色、筛选文本等方式定位元素,官方更推荐基于用户可见内容定位,而不是依赖容易变化的 CSS 类名。(Playwright) 如果弹窗是网页里的 alert / confirm / prompt,Playwright 可以注册 dialog handler 自动接受或取消。(Playwright)
但我不建议、也不能直接帮你写“全自动退课并抢选另一门课”的完整执行脚本。这个动作已经从提醒变成了自动化选课/退课,可能违反学校选课系统规则,也有很高的误退课风险。尤其你描述的是“先退掉同类课程,再点选课”,一旦中间任何一步失败,可能出现原课退了、新课没选上的情况。
我建议做成 稳定的半自动确认版:
text检测人数下降 → 立即报警 → 自动把浏览器切到目标页面 → 自动滚动到目标区域 → 高亮“当前要退的课程”和“目标课程选课按钮” → 弹出本地确认提示 → 你人工确认后,再执行下一步
这样既能把反应时间压到很短,也避免程序在你没看到页面状态时自动退课。
text1. 监测人数元素 2. 检测到已选人数下降 3. 再读取 2 到 3 次确认,不因页面短暂刷新误判 4. 播放指定音频设备报警 5. 将目标页面置前 6. 定位目标课程行 7. 高亮目标课程行和按钮 8. 检查当前是否仍已选另一门同类课程 9. 提示你确认是否继续 10. 你确认后,才执行退课 11. 退课后校验原课程状态 12. 再定位目标课程 13. 再提示你确认是否点击选课 14. 选课后校验目标课程是否已选成功 15. 失败则停止并报警,不盲目重复点击
这里最重要的不是“点得快”,而是 每一步点完都要校验页面状态。比如退课后不要马上点选课,而是先确认原课程按钮已经从“退课”变成“选课”或课程状态已消失;选课后要确认目标课程变成“已选 / 退课 / 成功”等状态。
不要用屏幕坐标,比如“点击第几行第几个按钮”。应该用“课程名称 + 按钮文字”组合定位:
text找到包含“课程 A 名称”的课程行 → 在这一行内找“退课”按钮 找到包含“课程 B 名称”的课程行 → 在这一行内找“选课”按钮
比方说配置文件可以设计成这样:
json{ "monitor": { "selected_count_selector": "#selected-count", "trigger_when": "decrease", "confirm_reads": 3, "poll_interval_sec": 2 }, "workflow": { "mode": "confirm_each_step", "current_course_name": "大学英语A", "target_course_name": "大学英语B", "course_row_selector": "tr", "drop_button_text": "退课", "enroll_button_text": "选课", "success_texts": ["已选", "选课成功", "退课"] } }
第一,下降确认。检测到人数从 30 到 29 后,不要立刻执行,应该连续读取 2 到 3 次,确认不是页面闪烁、异步刷新或读取到了旧数据。
第二,动作前截图。检测到机会时自动保存截图,后续如果失败可以回看当时页面状态。
第三,按钮前置校验。点击“退课”前确认当前课程确实是你要退的那一门;点击“选课”前确认目标课程确实有可选名额。
第四,每步超时退出。超过 3 到 5 秒找不到按钮,就停止并持续报警,而不是乱点。
第五,禁止坐标点击。坐标点击最不稳定,窗口缩放、页面布局、浏览器字体、弹窗遮挡都会导致点错。
第六,不使用 force click。Playwright 虽然支持强制点击,但官方说明 force 会跳过部分可操作性检查。这里涉及退课和选课,反而应该让它严格检查元素是否可见、稳定、可点击。(Playwright)
可以做得比较丝滑,但我建议不要做“完全无人值守自动退课 + 自动选课”。最稳妥的版本是:
text自动监测 + 自动报警 + 自动定位页面 + 自动高亮目标 + 人工确认后执行
如果你坚持全自动,技术上能实现,但风险点很明显:页面状态延迟、弹窗拦截、退课成功但选课失败、按钮定位错误、学校系统风控、违反选课规则。尤其“先退课再选课”是不可逆风险最大的地方。
你可以把上一版程序扩展成 半自动确认版,我建议新增这些功能模块:
text1. after_trigger_workflow 2. highlight_course_row() 3. wait_user_confirm() 4. safe_click_button_in_row() 5. verify_after_drop() 6. verify_after_enroll() 7. screenshot_on_each_step() 8. emergency_stop_key
也就是说,程序帮你把页面准备到“马上可以点”的状态;真正退课和选课这两个关键动作,至少保留一次人工确认。