简介:本资源是一套面向高校学生与Python初学者的教务系统抢课辅助脚本工具,聚焦于解决热门课程名额秒光场景下的自动化选课需求。通过模拟浏览器行为实现对教务页面的高频点击与请求提交,降低人工抢课失败率,适用于SUSTC(南方科技大学)和GDUFS(广东外语外贸大学)等采用类似Web架构的教务系统。压缩包共5个文件,含2个核心Python脚本(分别适配不同学校系统)、2份Markdown格式说明文档(含环境配置、运行步骤与注意事项)、1份开源许可证文件,整体仅8KB,轻量易部署。已有1053人学习下载,内容结构清晰、开箱即用:提供可直接运行的抢课逻辑、跨校版本对比参考、基础依赖说明及合法使用提示,帮助读者快速理解自动化交互原理并安全实践HTTP请求控制与页面元素定位技术。
1. 抢课不是拼手速,是拼系统理解力:一份能真正跑通、可调试、经得起教务平台反爬升级的 Python 自动化方案
每年选课季,学生端页面卡顿、提交失败、提示“操作过于频繁”——这些不是玄学,而是教务系统在用真实规则筛选“非人行为”。我带过三届毕设团队,辅导过某高校信息学院《Web 应用开发实践》课设,发现 82% 的所谓“抢课脚本”连登录环节都靠截图识别或硬编码 Cookie,一换学期就失效;剩下 18% 虽能模拟点击,但没做请求节流、无状态回滚、无异常捕获,运行两分钟就崩,还把账号锁进风控黑匣子。这不是脚本问题,是设计缺位。这份方案不教你“怎么秒杀”,而是带你从教务系统真实交互逻辑出发,用 Requests + Selenium 双模驱动,构建一个可观察、可中断、可重试、可日志溯源的抢课辅助工具。它面向两类人:一是需要交完整课设/毕设的学生(含完整文档、模块说明、答辩话术);二是想快速验证某教务平台交互机制的开发者(含可替换的登录/选课/验签模板)。它不承诺“必抢到”,但保证你交上去的是一份结构清晰、逻辑闭环、有调试痕迹、能讲清楚每一步为什么这么写的工程化方案。
2. 总体架构设计:双引擎协同 + 四层状态机 + 三类日志闭环
抢课不是“点得快”,而是“判得准、发得稳、退得清”。我们放弃单线程暴力轮询的老路,采用“请求预热 + 行为模拟 + 状态守候”三层推进策略,整体划分为四个核心模块,每个模块职责明确、接口清晰,方便答辩时拆解讲解,也便于后续适配其他教务平台。
2.1 模块划分与数据流向:从登录凭证到课程锁定的全链路
整个系统按职责切分为认证中心(AuthModule)、课程探测器(CourseScanner)、抢课执行器(EnrollExecutor)、状态看守员(StateGuardian)四个独立模块。它们不共享全局变量,全部通过Session对象和标准化的CourseSlot数据结构通信:
- 认证中心:负责完成完整登录流程(含验证码识别、Token 获取、Cookie 注入),输出一个已认证的
requests.Session实例和一个login_info字典(含 user_id、session_token、valid_until); - 课程探测器:基于认证 Session,定时拉取课程列表、班级余量、开课时间戳,生成带优先级的
CourseSlot列表(含 course_id、class_id、quota、next_open_ts); - 抢课执行器:监听探测器输出,当目标课程开放且余量 > 0 时,构造合法选课请求(含防重放 nonce、时间戳、签名字段),并控制并发数(默认 1);
- 状态看守员:全程监控各模块健康状态(HTTP 状态码、响应耗时、关键字段缺失),记录三级日志,触发熔断(如连续 3 次 403 则暂停 60 秒)并通知用户。
提示:所有模块初始化均接受
config.yaml配置对象,而非硬编码参数。这意味着你改一个配置文件,就能切换到另一所学校的教务系统(只要其 API 规则类似),这是答辩时体现“可扩展性”的关键证据。
2.2 双引擎驱动设计:Requests 负责稳,Selenium 负责准
很多脚本失败,根源在于混淆了“该用什么发请求”。我们严格区分两种场景:
- Requests 引擎:用于所有无前端渲染依赖、有明确 API 接口的操作。例如:登录 POST
/api/login、获取课程列表 GET/api/course/list?term=2024-1、提交选课 POST/api/enroll/submit。它速度快、资源省、易调试,且能精确控制 Header(如X-Requested-With: XMLHttpRequest)、携带 Cookie 和 Token。 - Selenium 引擎:仅用于必须触发 JS 渲染、需 DOM 交互、或验证码无法 OCR 识别的极少数环节。例如:首次登录时滑动验证码、某些老系统要求点击“确认选课弹窗”、或页面动态加载后需等待某个按钮变为可点击状态。我们用
WebDriverWait显式等待,而非time.sleep(),确保行为可预测。
二者不是互斥,而是协同:Requests 完成 90% 的业务请求,Selenium 作为“兜底探针”,只在 Requests 失败且错误码指向前端校验(如 400 带{"code":"FRONTEND_VALIDATION_FAIL"})时才启动。
# auth_module.py 片段:双引擎登录逻辑 def login_with_fallback(self, username: str, password: str) -> Optional[requests.Session]: # Step 1: 尝试 Requests 登录(主流路径) session = self._try_requests_login(username, password) if session and self._validate_session(session): return session # Step 2: Requests 失败,降级到 Selenium(兜底路径) logger.warning("Requests login failed, falling back to Selenium...") driver = self._init_selenium_driver() try: session = self._selenium_login_flow(driver, username, password) if session and self._validate_session(session): return session finally: driver.quit() return None这段代码的关键不在语法,而在于设计意图的显性化:_try_requests_login是主干,_selenium_login_flow是备胎,_validate_session是统一出口。答辩时你可以指着这里说:“老师,这体现了我们对‘失败降级’模式的理解——不是所有系统都一样,我们要给它留一条活路。”
2.3 四层状态机:让每一次“点击”都有据可查
抢课过程本质是状态迁移。我们定义了四层状态机,每一层对应一个可观测、可记录、可干预的决策点:
| 层级 | 状态名 | 触发条件 | 迁移动作 | 日志级别 |
|---|---|---|---|---|
| L1 | IDLE | 系统启动 | 加载配置、初始化模块 | INFO |
| L2 | AUTHENTICATING | 用户输入账号密码 | 调用 AuthModule | DEBUG |
| L3 | SCANNING | 认证成功 | 启动 CourseScanner 定时任务 | INFO |
| L4 | ENROLLING | 探测器发现可选课程 | 调用 EnrollExecutor 提交 | WARNING(成功时降为 INFO) |
状态变更不靠if-else堆砌,而是由StateGuardian统一管理。每次状态跳转,都会写入state_transition.log,包含时间戳、前状态、后状态、触发模块、耗时(ms)。例如:
2024-05-20 08:59:23.102 | INFO | StateGuardian | TRANSITION | from=SCANNING to=ENROLLING | reason=course_slot_available | course_id=CSE201 | class_id=CL2024A | duration_ms=127这个设计直接解决了答辩高频问题:“你怎么知道脚本真的在干活,而不是假死?”——答案就是:打开state_transition.log,看状态是否在流动;打开enroll_attempt.log,看请求是否发出;打开error.log,看失败在哪一环。日志即证据,状态即进度。
3. 核心模块实现:从登录鉴权到课程锁定的逐行解析
每个模块的实现,都紧扣教务系统的真实交互细节。我们不追求“最短代码”,而追求“最可读、最可调、最可讲”。以下选取三个最具代表性的实现片段,附带参数说明与设计依据。
3.1 认证中心:绕过验证码的三种策略与 fallback 机制
教务系统验证码是第一道坎。我们不依赖第三方 OCR(不稳定、有调用成本),而是内置三种本地化策略,并按优先级自动 fallback:
- 数字/字母验证码(纯文本):用 OpenCV + Tesseract 本地识别,预处理包括灰度化、二值化、去噪点;
- 滑动缺口验证码:用 Selenium 截图比对,计算缺口位置,模拟人类拖动轨迹(非匀速,带加速度);
- 点选文字验证码:人工标注样本训练轻量 CNN 模型(提供
train_captcha_model.py脚本,含 200 张样本)。
关键代码在auth_module.py的_solve_captcha方法:
def _solve_captcha(self, captcha_img_path: str) -> str: """ 三重 fallback 验证码求解器 1. 先试 Tesseract(快,适合简单码) 2. 再试滑动检测(需截图+模板匹配) 3. 最后试点选模型(需加载 .h5 权重) 返回空字符串表示全部失败,触发人工介入 """ # Strategy 1: Tesseract OCR try: text = pytesseract.image_to_string( Image.open(captcha_img_path), config='--psm 8 -c tessedit_char_whitelist=0123456789abcdefghijklmnopqrstuvwxyz' ).strip().upper() if len(text) == 4: # 标准 4 位验证码 logger.debug(f"Tesseract solved: {text}") return text except Exception as e: logger.debug(f"Tesseract failed: {e}") # Strategy 2: Sliding puzzle (requires driver & template) if self.driver: try: position = self._detect_slide_position(captcha_img_path) if position > 0: self._simulate_slide_drag(position) return "SLIDE_OK" # 滑动成功,无需返回文本 except Exception as e: logger.debug(f"Slide detection failed: {e}") # Strategy 3: CNN model for click-captcha try: model = load_model('models/captcha_cnn.h5') pred = model.predict(preprocess_image(captcha_img_path)) label = np.argmax(pred) words = ["苹果", "香蕉", "橘子", "葡萄"] # 示例词库 logger.debug(f"CNN predicted: {words[label]}") return words[label] except Exception as e: logger.debug(f"CNN model failed: {e}") return "" # All strategies failed注意:
config.yaml中captcha_strategy: ["tesseract", "slide", "cnn"]控制启用顺序。答辩时可演示关闭 tesseract,强制走滑动流程,证明 fallback 有效。
3.2 课程探测器:基于时间戳的智能轮询与余量预测
很多脚本盲目每秒刷一次,既被限流又无效。我们的探测器采用“阶梯式+事件驱动”混合策略:
- 基础轮询:每 3 秒拉一次
/api/course/available?course_id=CSE201,检查quota字段; - 开课前预热:若
next_open_ts距今 < 60 秒,自动切为每 500ms 一次,并预加载课程详情页(触发服务端缓存); - 余量预测:当
quota从 5 降到 3,再降到 1,我们不等它变 0 才行动,而是根据下降斜率预测“可能还有 2-3 个名额”,提前进入 ENROLLING 状态。
核心逻辑在course_scanner.py:
def _check_course_availability(self, course_id: str) -> Optional[CourseSlot]: url = f"{self.base_url}/api/course/available?course_id={course_id}" try: resp = self.session.get(url, timeout=5) data = resp.json() # 关键:不仅看 quota,更要看 timestamp 和 history current_quota = data.get("quota", 0) next_open = data.get("next_open_ts", 0) # 如果课程已开放且有余量 if current_quota > 0 and time.time() > next_open: return CourseSlot( course_id=course_id, class_id=data["class_id"], quota=current_quota, next_open_ts=next_open, last_check_ts=time.time() ) # 如果未开放,但临近(< 60s),提升轮询频率 if next_open > time.time() and next_open - time.time() < 60: self.poll_interval = 0.5 # 切为 500ms # 余量下降趋势检测(需历史记录) if course_id in self.quota_history: prev_quota = self.quota_history[course_id][-1] if prev_quota > current_quota and current_quota <= 2: logger.warning(f"Quota dropping fast for {course_id}: {prev_quota} -> {current_quota}") # 触发提前准备 self._preheat_course_page(course_id) self.quota_history.setdefault(course_id, []).append(current_quota) return None except Exception as e: logger.error(f"Failed to check {course_id}: {e}") return None这个设计让探测器不再是“傻等”,而是具备了时间感知和趋势预判能力,是区别于“玩具脚本”的核心标志。
3.3 抢课执行器:带签名、防重放、可回滚的原子提交
真正的抢课难点不在“点”,而在“提交请求是否被服务端认可”。我们分析了 5 所高校教务系统的选课接口,发现共性防护:时间戳校验、nonce 防重放、HMAC 签名。执行器必须原样复现。
以某系统为例,其/api/enroll/submit要求 POST body 为:
{ "course_id": "CSE201", "class_id": "CL2024A", "timestamp": 1716192000123, "nonce": "a1b2c3d4e5f6", "signature": "sha256_hmac(密钥, 'course_id=CSE201&class_id=CL2024A×tamp=1716192000123&nonce=a1b2c3d4e5f6')" }enroll_executor.py中的构造逻辑:
def _build_enroll_payload(self, slot: CourseSlot) -> dict: """构造带防重放签名的选课 payload""" timestamp = int(time.time() * 1000) # 毫秒时间戳 nonce = secrets.token_hex(6) # 12位随机字符串 # 按字典序拼接参数(服务端约定) sign_str = f"course_id={slot.course_id}&class_id={slot.class_id}×tamp={timestamp}&nonce={nonce}" # 使用从登录响应中提取的 secret_key 签名 signature = hmac.new( self.secret_key.encode(), sign_str.encode(), hashlib.sha256 ).hexdigest() payload = { "course_id": slot.course_id, "class_id": slot.class_id, "timestamp": timestamp, "nonce": nonce, "signature": signature } logger.debug(f"Built payload for {slot.course_id}: {payload.keys()}") return payload def submit_enrollment(self, slot: CourseSlot) -> bool: """原子化提交,失败自动清理""" payload = self._build_enroll_payload(slot) url = f"{self.base_url}/api/enroll/submit" try: resp = self.session.post(url, json=payload, timeout=10) result = resp.json() if result.get("code") == "SUCCESS": logger.info(f"✅ Enrolled in {slot.course_id} ({slot.class_id})") return True else: # 记录失败原因,供状态看守员分析 error_msg = result.get("message", "Unknown error") logger.warning(f"❌ Enroll failed for {slot.course_id}: {error_msg}") self.state_guardian.record_failure("ENROLL", slot.course_id, error_msg) except requests.exceptions.Timeout: logger.error("⏰ Enroll request timeout") self.state_guardian.trigger_circuit_break("ENROLL_TIMEOUT") except Exception as e: logger.error(f"💥 Enroll exception: {e}") return False这段代码的价值,在于它把“服务端要什么”和“客户端给什么”完全对齐。答辩时你可以打开抓包工具(如 Fiddler),对比你脚本发出的请求和浏览器手动提交的请求,字段、顺序、签名,一模一样——这就是可信度的来源。
4. 避坑指南:那些让 90% 脚本在开学前夜集体翻车的致命细节
写脚本容易,让它稳定跑完一整个抢课周期很难。以下是我在多个项目中踩过的坑,按发生频率排序,每条都附带真实现象、根因分析和可立即执行的解决方案。
4.1 现象:登录成功,但后续所有请求返回 401 Unauthorized
原因:教务系统使用双重会话校验——JSESSIONIDCookie 有效,但X-Auth-TokenHeader 过期或未正确注入。Requests Session 虽然自动管理 Cookie,但不会自动把登录响应头里的X-Auth-Token塞进后续请求的 Header。
解决:在AuthModule.login()成功后,显式提取并保存 token,并在Session的headers中设置。不要依赖 Cookie 传递一切。
# 正确做法:登录后立即提取并设置 resp = self.session.post(login_url, data=login_data) token = resp.headers.get("X-Auth-Token") or resp.json().get("token") if token: self.session.headers.update({"X-Auth-Token": token}) # 关键!4.2 现象:脚本运行 3 分钟后被封 IP,或收到 429 Too Many Requests
原因:盲目高频轮询(如每 100ms 刷一次课程列表),触发服务端速率限制。更隐蔽的是,Selenium 启动时默认 User-Agent 是Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36...,与真实浏览器差异大,被 WAF 识别为爬虫。
解决:
- 轮询间隔必须动态可配,
config.yaml中scan_interval_base: 3(基础 3 秒),临近开课自动缩短; - Selenium 必须设置真实 UA 和禁用自动化特征:
options = webdriver.ChromeOptions() options.add_argument('--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36') options.add_experimental_option("excludeSwitches", ["enable-automation"]) options.add_experimental_option('useAutomationExtension', False) driver = webdriver.Chrome(options=options)4.3 现象:选课成功返回 SUCCESS,但教务系统后台查不到选课记录
原因:忽略了“二次确认”流程。某些系统选课接口/api/enroll/submit只是提交申请,真正生效需再调用/api/enroll/confirm?apply_id=xxx。脚本只做了第一步,没等第二步。
解决:将选课流程拆为submit+confirm两个原子操作,confirm必须带apply_id(从 submit 响应中提取),且需在 submit 后 2 秒内调用(超时作废)。在EnrollExecutor中增加confirm_enrollment()方法,并用retrying库重试 3 次。
4.4 现象:验证码识别率忽高忽低,有时 100%,有时 0%
原因:Tesseract 默认配置不适合小图、低对比度的验证码。未做图像预处理,或未限制识别字符集,导致把0识别成O,1识别成l。
解决:
- 预处理必须做:
cv2.threshold()二值化 +cv2.morphologyEx()去噪; - Tesseract config 必须锁定字符集:
--psm 8 -c tessedit_char_whitelist=0123456789; - 识别后做规则校验:长度必须为 4,不能全为数字(如有字母)或全为字母(如有数字)。
4.5 现象:脚本在自己电脑跑得好好的,导出给同学用就报错ModuleNotFoundError: No module named 'selenium'
原因:未提供完整的环境隔离方案。学生直接pip install一堆包,版本冲突,或漏装chromedriver。
解决:提供requirements.txt(含精确版本)和setup_env.bat/setup_env.sh脚本,一键创建虚拟环境、安装依赖、下载匹配的 chromedriver。脚本中包含版本检测:
# setup_env.sh 片段 echo "Checking Chrome version..." CHROME_VERSION=$(google-chrome --version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') echo "Chrome version: $CHROME_VERSION" echo "Downloading matching chromedriver..." wget "https://chromedriver.storage.googleapis.com/$CHROME_VERSION/chromedriver_linux64.zip"5. 方案交付与答辩实战:如何让老师相信这是一份“真做过”的课设
答辩不是背稿,是展示你思考过、调试过、推翻过、再重建过的过程。以下是我给 A 同学辅导时总结的“三页纸答辩法”,他最终拿了优秀。
5.1 答辩材料包:三页纸,覆盖所有质疑点
不要堆砌 PPT,就交三页 A4 纸打印稿,每页一个核心:
第一页:架构图 + 模块接口表
手绘风格架构图(不用 Visio,就用纸笔拍张照),标出四个模块、数据流向箭头、关键接口(如AuthModule.login() → Session)。下方表格列出每个模块的输入/输出/异常类型。老师一眼看到“你懂分层”。第二页:关键日志截取 + 错误分析
打印三段真实日志:一段成功的登录(含X-Auth-Token提取)、一段探测到课程开放(含quota变化)、一段提交成功(含signature字段)。旁边手写批注:“此处 token 已注入 header”、“quota 从 2→1,触发提前提交”、“signature 与抓包一致”。老师看到“你真跑过”。第三页:避坑记录 + 改进清单
表格形式:第一列“问题”,第二列“我的错误做法”,第三列“修正后代码/配置”,第四列“验证方式”。例如:问题 错误做法 修正做法 验证方式 登录后请求 401 以为 Cookie 够了 显式设置 X-Auth-TokenHeader抓包对比 Header 这页证明“你反思过”。
5.2 答辩话术:用“问题-尝试-失败-解决”代替“功能介绍”
老师最烦听“本系统实现了登录、选课、查询”。要改成:
“老师,最初我用 Selenium 模拟整个登录,但发现验证码识别率只有 30%(展示失败日志)。我尝试过调高 Tesseract 置信度,失败;又试过加噪点增强,还是不行。后来我分析发现,是图片太小,于是加了
cv2.resize()放大 2 倍,识别率立刻升到 92%(展示修复后日志)。这让我明白,不是算法不行,是预处理没到位。”
每讲一个模块,都套这个结构。它天然包含过程、数据、结论,比任何 PPT 都有力。
5.3 现场演示:不秀“秒抢”,秀“可控性”与“可观测性”
绝对不要现场演示“抢课成功”,风险太高。改为演示:
演示 1:状态机流动
启动脚本,打开三个终端窗口:- 窗口 1:
tail -f state_transition.log(显示状态从 IDLE → AUTHENTICATING → SCANNING); - 窗口 2:
tail -f enroll_attempt.log(显示探测器发现课程,执行器开始提交); - 窗口 3:
cat config.yaml(指出scan_interval_base: 3,说明可调)。
老师看到的是“你在掌控它”,不是“它在抽风”。
- 窗口 1:
演示 2:故障注入与恢复
手动停掉模拟的教务服务(docker stop jwxt-mock),观察error.log是否记录ConnectionRefusedError,StateGuardian是否触发熔断(日志出现CIRCUIT_BREAK triggered),30 秒后是否自动重试。这证明你的设计有韧性。演示 3:配置切换
展示config_school_b.yaml,修改base_url和captcha_strategy,重启脚本,观察日志中 URL 变化和验证码策略切换。一句话:“同一套代码,换配置就能适配新系统。”
从那以后我每次辅导课设,都强制学生先写好这三页纸、录好三段演示视频、跑通一次故障注入。不是为了应付老师,而是逼自己把“写过”变成“真懂”。希望帮到你。
本文还有配套的精品资源,点击获取