简介:面向高校学生的教务系统抢课脚本,基于Python模拟对抢课页面进行持续点击,帮助在选课高峰期提升抢课成功率。资源包含两个可直接运行的Python脚本,分别适配南方科技大学(SUSTC)与广东外语外贸大学(GDUFS)的选课界面,功能都是自动完成登录后的反复点击抢课,从而降低人工盯屏和手速不足带来的影响。另附2份Markdown说明文档,对脚本运行环境、参数调整和实现思路做了说明,并附1份开源许可证,方便使用者确认授权范围。压缩包共5个文件,整体仅8KB,体积小巧,加载快,适合有一定Python基础、想直接使用或二次优化的学生。目前已有1050人学习下载,对于同类抢课需求具备较好的参考价值。通过阅读源码,读者可以理解利用Python模拟页面交互的通用方法,再根据本校教务系统特点修改按钮定位与重试逻辑,快速定制自己的抢课工具。
1. 抢课脚本:手速拼不过自动化,但脚本不是无脑点击
大学教务系统开放选课的那几十秒,拼的从来不是手速,而是请求到达服务器的时机和重试密度。人工点一次“选课”按钮,页面从提交到回显结果少说两三秒,而这套 python 脚本做的事,本质上就是用 requests 模拟浏览器行为,在选课时间窗口内对抢课页面不断发起点击请求,循环直到服务器返回“选课成功”。它能解决的痛点是明确的:必修课名额有限、开放瞬间必卡、手动刷新永远慢半拍。适合不想熬夜盯屏幕但又必须选上课的普通学生,也适合想搞懂 HTTP 请求、Session 维持和重试策略这三件套怎么配合的 Python 入门开发者。
2. 先看清数据通道:模拟点击与接口直连,哪条路更稳
很多人的第一反应是做“模拟点击”就是打开一个浏览器让脚本去控制鼠标,其实抢课这件事有两条完全不同的路,选错了会在选课当天付出惨痛代价。先搞清楚这两条路的底层区别,再决定你的脚本长什么样。
2.1 模拟点击的底层逻辑:从鼠标动作到 HTTP 请求
浏览器自动化工具(比如 Selenium 或 Playwright)确实是字面意义上的“模拟点击”——它启动一个真实浏览器内核,加载页面、执行 JavaScript、触发鼠标事件,最终帮你按下那个选课按钮。这种方案的优点是几乎不依赖对接口的理解,页面长什么样脚本就操作什么,对动态渲染的页面也能正常处理。
但代价也很明显。启动一个浏览器实例通常要占 200MB 以上内存,选课高峰时学校服务器本身已经响应很慢,浏览器内核还要花时间去解析渲染页面、执行各种前端框架的脚本,实际请求发出去的时机反而被拖慢了。我曾经用这套路跑过一次模拟选课,浏览器从启动到页面可点击花了将近 20 秒,等到真正按下选课按钮,名额早就没了。
抢课场景下真正有价值的,是你按下按钮那一刻浏览器后台发生了什么——它构造了一个 HTTP POST 请求,把课程编号、班级编号、学期、学号这些表单数据发到教务系统的选课接口,然后接收返回结果。说白了,鼠标点击只是触发器,真正的选课动作是那次网络请求。
2.2 接口直连的收益与代价:requests 高效但需要先抓包
用 requests 直接构造请求,等于跳过所有前端表演,直奔后台接口。这么做的好处是请求极轻量,单次请求从构造到拿到响应通常在几百毫秒以内,同一个时间段内你可以比浏览器自动化多发好几倍的请求。而且不依赖浏览器环境,一个普通的 Python 环境就能跑,挂服务器上也方便。
代价是你要自己先把接口结构摸清楚。一般在浏览器里按 F12 打开开发者工具,切到 Network 面板,登录后在页面上点一次选课按钮,找到那个 POST 请求,看它的请求地址、表单字段和返回的响应体。教务系统绝大多数是传统表单提交结构,数据结构并不复杂:
POST /elective/add Content-Type: application/x-www-form-urlencoded course_id=CS101&class_id=01&sid=20240001&term=2024-2025-1抓包这一步是绕不开的,它决定了脚本的数据通道是否真实可用。抓包时重点看三处:请求的 URL 路径、Form Data 里的字段名、返回结果里“成功”和“失败”分别是什么代码。不同教务系统差异很大,有的返回 JSON,有的返回一个<script>alert("选课成功")</script>页面,脚本的判断逻辑要跟着实际响应写。
2.3 选型判断:什么时候必须用浏览器自动化
那 requests 方案是不是永远最优?也不是。我遇到过一种教务系统,它的选课接口地址每次登录都会换,而且是前端 JavaScript 动态拼接出来的;还有的系统在提交前需要生成一个签名参数,这个签名由页面里的加密脚本计算。这类场景下用 requests 直连就得去读前端加密逻辑,成本很高,反而用 Playwright 直接点按钮更简单。
两条路的取舍,我给一个实际判断标准:打开选课页面源码,右键检查选中课按钮,看它的点击事件对应的网络请求是不是一个普通的表单 POST。如果接口路径和字段都是明文的,就走 requests;如果发现字段里有 token、sign、nonce 这类需要实时计算的参数,再考虑浏览器自动化。
| 方案 | 单次请求耗时 | 环境依赖 | 接口变化应对 | 适合场景 |
|---|---|---|---|---|
| requests 直连 | 0.1~0.5 秒 | 仅 Python 运行环境 | 需重新抓包改代码 | 传统表单型教务系统 |
| Playwright 点击 | 1~3 秒 | 需浏览器内核与驱动 | 页面改版影响较小 | 前端加密、动态渲染页面 |
我的建议是优先抓接口。抢课这种时间敏感型任务,链路越短越可靠,浏览器自动化看着省事,它引入的不确定性比起 requests 直连要多得多。
3. Python 模拟点击:从登录到提交的完整闭环
确定了走 requests 直连路线之后,整个抢课脚本就拆成三个模块:登录取会话、构造选课请求、循环重试。这三个模块单独看都不复杂,但串在一起时每一步都有必须处理的细节。
3.1 登录与 Session 维持:cookie 是第一条命脉
教务系统的登录一般也是表单提交,成功后服务端会在响应里返回 Set-Cookie,requests 库的 Session 对象会自动帮你保存。之后的所有请求都带上这套 cookie,服务器才认你这个登录态。这里的关键是:抢课脚本全程必须用同一个 Session 实例,不要每次请求都新建连接。
import requests import time class CourseGrabber: def __init__(self, sid: str, password: str, base_url: str): self.session = requests.Session() self.sid = sid self.password = password self.base_url = base_url self.headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36", "Referer": f"{base_url}/login", "Content-Type": "application/x-www-form-urlencoded", } def login(self) -> bool: # 登录接口:POST /login,成功返回 set-cookie 和用户信息 login_url = f"{self.base_url}/login" payload = { "username": self.sid, "password": self.password, # 某些系统要求传隐藏的 csrf_token,抓包时注意看 Form Data 完整字段 "csrf_token": "", } resp = self.session.post(login_url, data=payload, headers=self.headers) if resp.status_code == 200 and resp.json().get("code") == 0: print(f"[{time.strftime('%H:%M:%S')}] 登录成功") return True print("登录失败,检查用户名、密码或抓包确认登录接口的字段名") return False这段代码把登录动作封装成一个方法,Session 对象全程复用。自行抓包时重点替换两个东西:一是登录 URL 的路径,有的系统是/login,有的是/userLogin;二是 Form Data 字段名,username不一定叫 username,有的系统用loginName,有的用stuNo。把字段名搞错了,登录接口返回的就不是用户信息而是参数校验错误。
登录成功后建议打印一下 Session 的 cookie 数量,确认带上了会话凭证。如果 cookie 为 0,说明登录其实没成功,或者验证码参数还没处理。
3.2 构造选课请求:参数与返回码决定成败
登录之后,抢课动作本身是一个循环提交的过程。选课请求的构造比登录要仔细得多,因为同一个课程可能存在多个教学班,参数多一个少一个教务系统都可能直接不识别。以下代码保留了选课主流程,建议你按抓包结果替换接口路径和字段名:
def grab_course(self, course_id: str, class_id: str, max_attempts: int = 30, interval: float = 0.5) -> bool: # 选课提交接口:POST /elective/add url = f"{self.base_url}/elective/add" params = { "course_id": course_id, # 课程编号,抓包时看 Form Data 里的实际字段名 "class_id": class_id, # 教学班编号,同一课程多个班时的区分字段 "sid": self.sid, # 学号,通常用于服务端校验选课资格 "timestamp": int(time.time() * 1000), # 时间戳,部分系统用于防重复提交 } attempt = 0 while attempt < max_attempts: try: resp = self.session.post(url, data=params, headers=self.headers, timeout=3) data = resp.json() code = data.get("code") if code == 200: print(f"选课成功:{course_id} - {class_id}") return True elif code in (403, 404): # 403 通常是名额已满,404 是课程不可选 print(f"课程已满或不可选(code={code}),停止重试") return False else: attempt += 1 print(f"第 {attempt} 次尝试未成功:{data.get('msg')}") # 失败后逐步拉长间隔,避免短时间密集请求把账号送进风控 time.sleep(interval * (1 + attempt / 10)) except requests.exceptions.Timeout: attempt += 1 print(f"第 {attempt} 次请求超时,1.5s 后重试") time.sleep(1.5) except requests.exceptions.ConnectionError: attempt += 1 print(f"第 {attempt} 次连接失败,2s 后重试") time.sleep(2) return False这个循环里三个参数值得展开说。max_attempts决定最多尝试次数,我一般设为 20 到 30 次,太多了没有意义——选课高峰时真正的“黄金窗口”就是前十几秒,之后就算接口还能提交,名额也早没了。interval是两次重试之间的基础间隔,它不是你程序里的休眠时间,而是你给服务器的“呼吸空间”。不同教务系统的风控强度不一样,有的系统连续 10 次失败请求后就会临时封禁账号,把 interval 设为 0.5 到 1 秒是一种比较稳妥的节奏。
关于返回码的判断,强烈建议新手先手动在教务系统里点一次选课,把“名额已满”“选课冲突”“成功”三种情况的返回体分别记录下来。因为同样叫 code,不同系统里 200 可能代表失败,401 可能代表成功,不实测就会写出反逻辑的死循环。
3.3 结果检测与退出条件:别让脚本空转到底
grab_course 里的返回逻辑看着简单,但它是整段代码里最容易被忽略的部分。一个合格的结果检测必须同时覆盖三种结局:成功、明确失败、不确定状态。
成功很好判断,退出循环返回 True 就行。明确失败指“名额已满”“本学期已选过该课程”这类无论如何重试都不会改变的结局,此时继续循环纯属浪费资源和加重服务器负担,直接 return False 更理智。不确定状态则是最棘手的——响应超时、连接被重置、服务器返回 500,你不知道请求到底有没有到达选课接口。
我处理不确定状态的方式是:超时后的下一次重试前先调用一个轻量接口(比如查询选课结果的接口)确认当前状态;如果查询接口也超时,那就继续按退避逻辑重试,直到达到 max_attempts。这样做的原因是盲目重试可能造成重复选课,特别是有“冲突校验”的教务系统,同一个课程被提交两次,第二次大概率会被判为选课冲突,反而覆盖掉第一次成功的结果。
3.4 并发选课的取舍:多线程不是万金油
脚本跑起来之后很容易冒出一个念头:既然单线程循环太慢,能不能用多线程同时抢好几门课?技术上确实可以,用 concurrent.futures 的 ThreadPoolExecutor 把不同的课程分给不同线程就是十几行代码的事。但选课场景和普通爬虫不同,学校教务系统的服务器在高峰时段本身就很脆弱,你开四五个线程对服务器狂轰滥炸,账号被限流的概率会急剧上升。
我的取舍是:默认单线程,只在“同时开放多门必修课且每门课名额都很少”的极端场景下开 2 个线程,而且每个线程的 interval 都要适当拉大到 1 秒以上。你抢课的目的是选上,不是把一个学期的选课资格搭进去。
4. 让脚本跑得更稳:超时、重试与降频策略
接口路径有了、选课循环能跑了,但这只是骨架。抢课当天真正决定成败的,是脚本在“系统繁忙”“连接超时”“登录失效”这些意外状态下能不能保持冷静。这里有一套我常用的稳定性设计,它们比主流程代码更重要。
4.1 指数退避:固定间隔重试是最容易踩的坑
很多第一次写抢课脚本的人会把重试写成time.sleep(1)然后无限循环,这等于在告诉教务系统的风控模块“这里有台机器在刷”。固定间隔的重试有两个问题:一是它和服务器当前的负载毫无关联,服务器越忙你越急着往里面挤;二是它的失败模式太规律,特别容易被识别。指数退避的做法是让两次重试的间隔随失败次数逐渐变长,同时加一个随机抖动让时间分布不那么规整。
def retry_with_backoff(func, max_retries=5, base_delay=1.0): # 指数退避 + 随机抖动,最大间隔封顶 8 秒 import random for i in range(max_retries): try: return func() except Exception as e: wait = min(base_delay * (2 ** i), 8) + random.uniform(0, 0.5) print(f"请求异常:{e},{wait:.1f}s 后重试") time.sleep(wait) return Nonebase_delay是第一次失败后的等待基数,2 ** i让它按 1s、2s、4s、8s 的节奏翻倍,min(..., 8)防止等待时间无限拉长。抖动用的random.uniform(0, 0.5)是加在基础等待上的随机偏移,这个偏移量不需要很大,0.5 秒就足以让请求时间点分布得自然一些。
这套重试机制在抢课场景里要和你的主循环配合好:主循环里控制“是否继续尝试”,退避逻辑控制“每次尝试之间等多久”。如果你在选课前 5 分钟做了一次演练,发现接口稳定返回“不在选课时间”,这时最好不要让脚本空转等待,可以设置一个硬性的开始时间,到点再启动真正的选课循环。
4.2 频率控制:怎么让请求节奏不被判定为恶意
频率控制的本质是让脚本的请求间隔落在“人为操作”和“服务器容忍度”的交叉区间里。每次请求间隔 0.2 秒,一看就是脚本;每次间隔 5 秒,又会错过抢课黄金窗口。我实践的结论是:抢课高峰期的单次请求间隔控制在 1 到 2 秒之间,平常用它来刷课表时放宽到 3 到 5 秒。
间隔不能只靠time.sleep()实现,因为请求本身的耗时也在变化。你要计算的是“相邻两次请求的开始时间差”,而不是“上次结束到下次开始的时间差”。最稳妥的做法是记录时间戳而不是依赖 sleep:
last_request_time = 0 min_interval = 1.2 # 相邻请求最小间隔,单位秒 def rate_limited_request(func): global last_request_time elapsed = time.time() - last_request_time if elapsed < min_interval: time.sleep(min_interval - elapsed) result = func() last_request_time = time.time() return result这里把最小间隔单独拎出来作为变量,方便在不同阶段调整。选课开始前可以慢一点预热,真正到点后把 min_interval 调小。这个全局变量在多线程场景下要加锁,否则多个线程同时修改会产生竞态条件。
4.3 日志与主动退出:脚本不该是一个黑匣子
跑抢课脚本最怕的就是它在后台沉默地运行,你不知道它到底是在正常重试还是已经陷入死循环。我吃过这个亏:有一次脚本因为登录态失效,在循环里反复提交无效请求跑了一整天,课程没抢到不说,账号还被临时封了。从那以后我把日志和退出条件当成脚本的必需品来设计。
日志的核心是输出状态和移动方向,至少要有三样:每次尝试的时间点、接口返回的原始 code 和 msg、已经尝试的次数。建议把这些输出同时打到控制台和本地文件,方便事后回看。主动退出条件则分两种,一种是成功后的立即退出,另一种是失败 N 次后判定本轮不可为——比如“名额已满”这种明确失败,再跑 100 次也是徒劳。
更好的做法是把“可重试的错”和“不可重试的错”分开。返回码里的“系统繁忙”是可重试的,“课程冲突”“未在选课时间段”是不可重试的。在代码里用一个白名单记录可重试的返回码,不在白名单里就直接 break,这样能省下大量无效请求。
5. 避坑:六个让抢课脚本翻车的真实细节
这部分的每一条都是我或周围人实际踩过的坑,按“现象 → 原因 → 解决”写出来,希望能帮你省下几个小时排查时间。
5.1 网页上能选上的课,脚本却一直报“课程已满”
这是我被问过最多的问题。脚本返回“课程已满”,浏览器手动点却能选上,二者结果不一致。原因是浏览器提交的 Form Data 里往往带着教务系统隐藏的附加字段,比如教学班编号、上课时间段的 hash 值、甚至一个固定的 token,而你抓包时只提取了视觉上能看到的课程号和学号。解决方法是重新抓包,把 Form Data 表单里所有字段完整拿下来,别漏任何一个看似无关的隐藏字段。
5.2 脚本跑了几分钟突然开始疯狂报 401
现象是前几分钟一切正常,之后请求全部返回“未登录或登录过期”。原因是教务系统一般会设置会话超时,通常 20 到 30 分钟,也可能因为 IP 变化或者服务端主动重新下发 cookie 导致原会话失效。解决方法是脚本里加一个心跳机制——每 3 分钟调用一次轻量接口(比如查询当前学期课表)保活;同时每次请求后刷新 Session 的 cookie,因为某些系统会在响应头里重新下发 Set-Cookie。
5.3 请求频率过高,选课系统直接锁定账号
现象是脚本跑了一阵子之后,浏览器手动登录也提示“账号已被锁定,请联系管理员”。原因就是请求间隔太短,每分钟几十个请求被判定为异常行为。解决方法是把单次请求间隔控制在 1.5 秒以上,并且配合指数退避;同时设置每日最大尝试次数上限,比如 200 次,超过了就停止。请记住,锁号是脚本最昂贵的失败方式,因为你会错过整个选课窗口。
5.4 选课前 10 秒就让脚本开跑,服务器却说“不在选课时间”
现象是脚本等待时间到了,提前发起的请求全部返回“未到选课时间”。原因是本地系统时间和教务系统服务器时间之间存在偏差,少则几秒多则几分钟。解决方法是登录成功后取一次响应头里的Date字段,计算出和本地时间的偏移量,然后把“开跑时间”校准到服务器时间。更简单的做法是脚本内置一个时钟同步接口,用 NTP 时间作为基准。
5.5 教务系统页面改版后,脚本突然全部失效
现象是暑假前跑得好好的脚本,下学期一开选课就废了。原因是前端页面改版导致接口路径和参数结构变了,但核心选课逻辑没变。这基本没法提前预防,只能在新学期选课前重新抓包。不过你可以把抓到的接口地址和字段名抽到单独配置文件里,把“接口定义”和“抢课逻辑”分离,改版后只改配置文件就行了。
5.6 requests 请求报 SSL 错误或连接被重置
现象是脚本在本地跑了 10 次成功 8 次,偶尔出现 SSL: CERTIFICATE_VERIFY_FAILED。原因通常是学校教务系统的 HTTPS 证书不完整或经过网关代理重签,requests 默认的证书校验机制不认这个证书。解决方法是优先检查系统根证书是否更新;确实需要绕过时,在创建 Session 后设置session.verify = False,但请只在封闭校园网环境中使用,并且控制好请求密度,不要给服务器造成额外压力。
6. 实战技巧:选课日前一晚,把整条链路自测三遍
正式选课前那个晚上,做一次完整的自测。不是让你去请求真实选课接口,而是用“排练模式”模拟整套流程,确认登录、会话保持、请求构造、结果判断、退出条件每一个环节都符合预期。
我的做法是给 CourseGrabber 增加一个 debug 参数,传 True 时请求地址指向一个本地 Mock 服务,返回的数据也由本地脚本模拟。这样你可以把真实教务系统的接口路径、参数、返回码都放进一个 JSON 配置文件里,Mock 服务按配置模拟“名额已满”和“选课成功”两种响应,验证脚本是否会按预期停止或继续重试。
# 排练模式:用本地 mock 服务代替真实教务系统 grabber = CourseGrabber( sid="20240001", password="your_password", base_url="http://127.0.0.1:8080", # 指向本地 mock 服务 debug=True ) # 预期:返回成功,脚本立即退出 if grabber.grab_course("CS101", "01", max_attempts=5, interval=0.2): print("排练通过:链路正常")自测时要重点验证三个边界:一是“选课成功”后脚本是否立即退出,不会因为后面还有循环任务而重复提交;二是“名额已满”时脚本是否在预设次数内停止,而不是无脑重试 30 次;三是模拟网络超时的情况下脚本能否按退避节奏继续跑,不会直接崩溃。这三条验证完,脚本才算真正可用。
那次正式的选课日,我因为前一夜只测了登录没测完整链路,结果抢课当天发现实际系统的返回格式和之前抓包时不一样,“成功”和“失败”的判断逻辑整个反了,等我改完重启,名额早就没了。从那以后,凡是涉及选课、报名这类时间敏感型任务,我都强制自己在前一晚把排练模式完整过三遍,一遍验证登录,一遍验证选课成功退出,一遍验证失败重试兜底。这套习惯救过我很多次,希望也能帮到你。
本文还有配套的精品资源,点击获取