1. 从"抢课难"说起:一个自动化选课脚本到底在解决什么问题
每到选课季,教务系统的服务器就像早高峰的地铁站——几千人同时挤在同一个入口,页面转圈、验证码刷不出来、好不容易点进去发现名额已满。这种场景下,手动选课拼的其实不是网速,而是运气和手速的叠加。我见过不少同学提前十分钟就守在电脑前,结果开放选课的那一秒页面直接卡死,等刷新出来想选的课早就没了。
这个项目的核心思路很直接:用程序代替人工去轮询教务系统的选课接口,一旦检测到目标课程有名额,立刻发起选课请求。听起来简单,但真正动手做的时候,有几个关键问题需要想清楚——怎么模拟登录、怎么维持会话、怎么判断课程是否有余量、请求频率怎么控制才不会被系统拦截。这些问题的答案,构成了整个脚本的技术骨架。
需要先说明的是,这类工具的使用有一个基本前提:仅用于个人学习和技术研究,不要用来恶意抢占资源或干扰教务系统正常运行。选课系统是公共资源,任何自动化操作都应该控制在合理范围内,比如只针对自己需要的课程、设置合理的请求间隔、在选课结束后及时停止脚本。这是技术使用的边界,也是对自己和他人负责。
这篇文章会从零开始拆解一个自动化选课脚本的完整实现过程,包括登录流程分析、会话维持、课程余量检测、请求调度策略,以及实际运行中遇到的各种坑。代码部分用Python实现,涉及requests、BeautifulSoup等常用库。适合有Python基础、想了解Web自动化实战的同学参考。
2. 登录环节:会话维持与身份验证的完整链路
2.1 为什么不能简单用requests.post模拟登录
很多人第一反应是打开浏览器的开发者工具,找到登录请求,把URL和表单参数复制下来,用requests.post发过去就完事了。实际操作会发现,登录请求确实返回了200,但后续访问选课页面时却被重定向回登录页。原因在于教务系统通常依赖Cookie来维持会话状态,而简单的post请求虽然能拿到响应,但如果没有正确处理Set-Cookie头、没有在后续请求中携带Cookie,服务器就不认你这个"已登录"身份。
更麻烦的是,很多教务系统在登录流程中加入了动态参数。比如某个隐藏字段的值是在页面加载时由JavaScript生成的,或者登录请求需要携带一个时间戳签名。这种情况下,直接复制表单参数就会失败,因为那个动态值已经过期了。
2.2 用requests.Session管理Cookie
正确的做法是使用requests.Session()对象。Session会自动处理Cookie的存储和发送,你只需要在同一个Session实例中依次发起登录请求和后续请求,Cookie就会自动带上。
import requests from bs4 import BeautifulSoup session = requests.Session() session.headers.update({ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Referer': 'http://example-jwxt.edu.cn/login' }) # 第一步:获取登录页面,提取隐藏字段 login_page = session.get('http://example-jwxt.edu.cn/login') soup = BeautifulSoup(login_page.text, 'html.parser') # 提取所有hidden类型的input hidden_fields = {} for inp in soup.find_all('input', type='hidden'): hidden_fields[inp.get('name')] = inp.get('value', '') # 第二步:构造登录表单 login_data = { 'username': 'your_student_id', 'password': 'your_password', **hidden_fields } # 第三步:发起登录请求 resp = session.post('http://example-jwxt.edu.cn/login', data=login_data)这里的关键点是:先GET登录页拿到隐藏字段,再POST登录。隐藏字段可能包含CSRF token、时间戳等服务器校验用的参数,漏掉任何一个都可能导致登录失败。
2.3 登录成功的判断标准
登录请求返回200不代表登录成功。教务系统常见的做法是:登录失败时返回200但页面中包含"用户名或密码错误"的提示,登录成功则返回302重定向到首页。所以判断登录是否成功,不能只看状态码,要综合检查响应内容或重定向行为。
# 方法一:检查是否重定向 if resp.url != 'http://example-jwxt.edu.cn/login': print('登录成功,已跳转') else: # 方法二:检查页面内容 if '密码错误' in resp.text or '登录失败' in resp.text: print('登录失败') else: print('可能登录成功,需进一步验证')我个人的习惯是登录后再访问一个需要权限的页面(比如个人信息页),如果能正常返回数据,就确认登录状态有效。这比单纯检查登录响应更可靠。
2.4 验证码的处理思路
如果教务系统登录需要验证码,事情会复杂一些。常见的处理方式有几种:一是手动输入,脚本暂停等待用户在控制台输入验证码;二是使用OCR库(如ddddocr)自动识别简单验证码;三是如果验证码是前端生成的简单算术题,可以直接解析页面中的题目并计算结果。
手动输入的方式最稳妥但不够自动化,OCR识别的方式需要额外安装依赖且识别率不一定100%。实际项目中,我倾向于先用手动输入跑通整个流程,确认选课逻辑没问题后,再考虑是否加入自动识别。毕竟选课脚本的核心价值在于"自动轮询选课",登录环节偶尔手动介入一次是可以接受的。
3. 课程余量检测:从页面解析到接口轮询
3.1 找到真正的选课接口
登录成功后,下一步是找到查询课程余量和发起选课的接口。有两种思路:一是模拟浏览器行为,请求选课页面然后解析HTML;二是直接调用后端API接口。前者更直观但效率低,后者效率高但需要分析接口参数。
用浏览器开发者工具的Network面板,在选课页面点击"查询"按钮,观察发出的请求。通常会看到一个返回JSON数据的接口,里面包含课程名称、教师、余量等信息。直接调用这个接口比解析HTML页面要高效得多。
# 查询课程余量的接口调用示例 def check_course(session, course_id): url = f'http://example-jwxt.edu.cn/api/course/status' params = { 'courseId': course_id, 'term': '2024-2025-2' } resp = session.get(url, params=params) data = resp.json() return data3.2 解析课程余量数据
接口返回的数据结构因学校而异,但通常包含类似这样的字段:
{ "courseName": "数据结构", "teacher": "某教师", "capacity": 60, "enrolled": 58, "available": 2, "status": "可选" }关键字段是available(余量)或enrolled与capacity的差值。当available > 0时,说明还有名额,可以发起选课。
有些学校的接口返回的是HTML片段而非JSON,这时候就需要用BeautifulSoup解析。解析时要注意页面结构可能因课程类型不同而变化,比如必修课和选修课的表格结构可能不一样。稳妥的做法是先打印出返回内容,观察实际结构后再写解析逻辑。
3.3 轮询策略:频率控制与随机间隔
轮询的核心矛盾是:频率太低可能抢不到课,频率太高可能被系统封禁。我的经验是,请求间隔设置在3到8秒之间比较合理,并且加入随机抖动,避免固定间隔被识别为机器行为。
import time import random def poll_course(session, course_id, interval_range=(3, 8)): while True: try: data = check_course(session, course_id) if data.get('available', 0) > 0: print(f'检测到余量:{data["available"]},准备选课') return True else: print(f'当前无余量,等待中...') except Exception as e: print(f'请求异常:{e}') wait = random.uniform(*interval_range) time.sleep(wait)这里有几个细节值得注意。第一,异常处理不能少,网络波动或接口临时不可用都会导致请求失败,脚本不能因为一次异常就崩溃。第二,打印日志要清晰,方便观察运行状态。第三,如果连续多次请求都返回相同结果,可以适当延长间隔,减少对服务器的压力。
3.4 多课程并行检测的取舍
如果需要同时抢多门课,串行轮询的效率会很低——每门课等3到8秒,轮一圈下来可能要半分钟。这时候可以考虑用多线程,每个线程负责一门课的检测。但线程数不宜过多,建议控制在3到5个以内,否则请求频率叠加起来容易触发系统的频率限制。
import threading def start_polling(session, course_ids): threads = [] for cid in course_ids: t = threading.Thread(target=poll_course, args=(session, cid)) t.start() threads.append(t) for t in threads: t.join()多线程共享同一个Session对象时要注意线程安全问题。requests的Session在并发场景下可能出问题,稳妥的做法是每个线程创建独立的Session,各自维护登录状态。虽然会多几次登录请求,但稳定性更有保障。
4. 发起选课:请求构造与结果验证
4.1 选课请求的参数分析
检测到余量后,下一步是发起选课请求。选课接口通常需要携带课程ID、教学班ID、学期信息等参数。这些参数可以从之前的查询接口响应中获取,也可以在选课页面的表单中找到。
def select_course(session, course_id, class_id, term): url = 'http://example-jwxt.edu.cn/api/course/select' data = { 'courseId': course_id, 'classId': class_id, 'term': term, 'action': 'select' } resp = session.post(url, data=data) return resp.json()有些学校的选课接口是GET请求,参数直接拼在URL里;有些是POST,参数在请求体中。这个需要根据实际抓包结果来确定,不能想当然。
4.2 选课结果的判断与重试
选课请求发出后,服务器会返回结果。常见的返回状态包括:选课成功、名额已满、时间冲突、已选过该课程等。脚本需要根据返回信息判断是否成功,如果失败是因为"名额已满",说明在请求发出的瞬间被别人抢走了,需要重新进入轮询流程。
def try_select(session, course_info): result = select_course(session, course_info['courseId'], course_info['classId'], course_info['term']) if result.get('code') == 0: print('选课成功!') return True elif '名额已满' in result.get('message', ''): print('名额被抢,重新轮询') return False elif '已选' in result.get('message', ''): print('已选过该课程') return True else: print(f'选课失败:{result.get("message")}') return False这里有一个实战经验:选课成功的判断不能只看HTTP状态码。很多教务系统即使选课失败也返回200,真正的结果在响应体的JSON或HTML中。所以必须解析响应内容,根据业务字段来判断。
4.3 处理"选课成功但被踢出"的情况
有些教务系统有"选课确认"机制,第一次选课只是占位,需要在规定时间内确认才算最终成功。如果脚本只做了选课请求而没有做确认操作,可能会在确认截止后被系统自动清退。
处理方式是在选课成功后,检查是否有确认页面或确认接口需要调用。如果有,立即执行确认操作。这个细节很容易被忽略,但一旦漏掉,前面的努力就白费了。
4.4 日志记录与状态追踪
脚本运行过程中,建议把关键操作都记录到日志文件里,包括每次轮询的时间、检测到的余量、选课请求的发送和结果。这样出问题时可以回溯分析,也方便优化轮询策略。
import logging logging.basicConfig( filename='course_select.log', level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s' ) logging.info(f'开始轮询课程 {course_id}') logging.info(f'检测到余量 {available}') logging.info(f'选课结果:{result}')日志文件建议按日期分割,避免单个文件过大。如果脚本需要长时间运行,还可以加入日志轮转机制。
5. 实战中踩过的坑与应对方案
5.1 登录态过期导致轮询失效
教务系统的会话通常有有效期,比如30分钟不操作就自动登出。脚本如果一直轮询但从不刷新会话,可能在运行一段时间后突然所有请求都返回登录页。这个问题很隐蔽,因为脚本不会报错,只是检测不到余量。
解决方案是定期检查登录状态,比如每轮询20次就访问一次个人信息页,如果被重定向到登录页就重新登录。或者在每次请求后检查响应内容是否包含登录页的特征字符串。
def check_session_valid(session): resp = session.get('http://example-jwxt.edu.cn/user/profile') if '登录' in resp.text and '密码' in resp.text: return False return True5.2 请求频率过高被临时限制
这是最容易踩的坑。刚开始测试时,我把轮询间隔设成了1秒,结果跑了不到5分钟,所有请求都返回403。等了大概十几分钟才恢复正常。后来把间隔调到5秒以上,并且加入随机抖动,就再没出现过被限制的情况。
经验值:轮询间隔不低于3秒,推荐5到10秒,并且每次间隔加入±2秒的随机偏移。
如果多线程并行,总请求频率是各线程频率之和。比如3个线程各5秒一次,实际对服务器的请求频率是每5秒3次。这个总量也要控制,建议总频率不超过每秒1次。
5.3 课程ID与教学班ID的混淆
很多教务系统里,一门课程有多个教学班(不同教师、不同时间),选课实际选的是教学班而不是课程。如果脚本只传了课程ID没传教学班ID,选课请求会失败或者选到错误的教学班。
解决方式是在查询阶段就获取完整的教学班信息,包括教学班ID、教师、上课时间等,选课时精确指定目标教学班。如果目标是"只要有这门课就行",可以遍历所有教学班,哪个有余量就选哪个。
5.4 页面结构变化导致解析失败
教务系统可能会在学期更新时调整页面结构或接口返回格式。如果脚本硬编码了HTML标签的class名或JSON字段名,系统一更新脚本就失效了。
应对策略是尽量使用接口而非解析HTML,接口的稳定性通常比页面结构高。如果必须解析HTML,把解析逻辑封装成独立函数,方便出问题时快速定位和修改。同时在脚本中加入解析失败的告警,比如连续多次解析不到预期字段就打印警告。
5.5 选课成功后的确认遗漏
前面提到过确认机制,这里再强调一下。有些学校的选课流程是"选课→确认→完成",脚本只做了第一步。确认操作通常有时限,比如选课后30分钟内必须确认。如果脚本没有自动确认,需要设置提醒手动处理,或者在脚本中加入确认逻辑。
确认接口的调用方式和选课接口类似,关键是找到确认请求的URL和参数。可以在浏览器中手动完成一次选课确认流程,抓包分析确认请求的构造方式。
6. 代码组织与运行部署的实用建议
6.1 配置与代码分离
把学号、密码、课程ID、轮询间隔等参数写在配置文件里,而不是硬编码在代码中。这样修改参数不需要动代码,也避免了敏感信息泄露的风险。
# config.py CONFIG = { 'username': 'your_student_id', 'password': 'your_password', 'base_url': 'http://example-jwxt.edu.cn', 'courses': [ {'course_id': 'CS101', 'class_id': 'CS101-01'}, {'course_id': 'MA102', 'class_id': 'MA102-03'} ], 'poll_interval': (5, 10), 'max_retries': 3 }6.2 异常处理的分层设计
脚本运行中可能遇到各种异常:网络超时、接口返回格式变化、登录态失效、选课接口限流等。不同异常的处理方式不同,需要分层设计。
网络超时可以重试,重试3次仍失败就跳过本轮;登录态失效需要重新登录;接口限流需要延长等待时间;返回格式变化需要记录日志并告警。把这些逻辑分开处理,脚本的健壮性会好很多。
def safe_request(session, method, url, **kwargs): for i in range(3): try: resp = session.request(method, url, timeout=10, **kwargs) return resp except requests.Timeout: print(f'请求超时,重试 {i+1}/3') time.sleep(2) except requests.RequestException as e: print(f'请求异常:{e}') time.sleep(2) return None6.3 运行环境的选择
脚本可以跑在本地电脑上,也可以部署在云服务器上。本地运行的好处是方便调试,坏处是电脑关机或断网脚本就停了。如果选课时间在凌晨或者你不在电脑前,云服务器会更可靠。
不管跑在哪里,都要确保网络稳定。选课高峰期教务系统的响应可能很慢,脚本的超时设置要合理,太短会导致大量超时,太长会拖慢轮询节奏。建议超时设置在10到15秒。
6.4 停止条件的设置
脚本不能无限运行。需要设置停止条件,比如选课成功、达到最大运行时长、或者手动终止。选课成功后自动停止是最基本的,此外还可以设置一个最长运行时间,比如2小时,超时后自动退出并发送通知。
import signal import sys def signal_handler(sig, frame): print('收到终止信号,正在退出...') sys.exit(0) signal.signal(signal.SIGINT, signal_handler)这样按Ctrl+C可以优雅退出,而不是留下未完成的请求或损坏的日志文件。
7. 关于这类工具的使用边界与个人体会
写这个脚本的初衷是解决选课季的焦虑,但实际用下来,有几个体会值得分享。
第一,技术手段不能替代对规则的尊重。选课系统的设计初衷是公平分配资源,自动化脚本如果被滥用,比如同时抢几十门课再退掉、或者用多个账号刷课,就会破坏这种公平。我个人的做法是只针对自己真正需要的课程,选到就停,不囤积名额。
第二,脚本的稳定性比速度更重要。很多人追求"秒杀",把间隔调到极低,结果被系统限制反而得不偿失。我实测下来,5到10秒的间隔虽然看起来慢,但持续运行几小时都不会出问题,最终抢到课的概率反而更高。
第三,不要把脚本当成唯一手段。选课季同时用多个渠道——脚本轮询、手机APP手动刷新、同学之间互相提醒——综合成功率会更高。脚本只是工具之一,不是万能药。
第四,代码要留好注释和文档。这类脚本往往一学期只用一两次,下次再用时如果忘了参数含义或接口逻辑,重新梳理会很费时间。我在代码里对每个关键步骤都写了注释,配置文件里也标注了参数说明,第二次用的时候直接改配置就能跑。
最后说一个细节:选课结束后记得把脚本停掉。我见过有同学的脚本忘了关,跑了一整晚,第二天收到教务系统的异常访问提醒。虽然没造成什么后果,但总归是不必要的麻烦。设置好停止条件,或者用定时任务控制运行时间,都是好习惯。