简介:本资源是一套面向高校学生与Python初学者的教务系统抢课辅助脚本工具,聚焦于解决热门课程名额秒光场景下的自动化选课需求。通过模拟浏览器行为实现对教务页面的高频点击与请求提交,降低人工抢课失败率,适用于SUSTC(南方科技大学)和GDUFS(广东外语外贸大学)等采用类似Web架构的教务系统。压缩包共5个文件,含2个核心Python脚本(分别适配不同学校系统)、2份Markdown格式说明文档(含环境配置、运行步骤与注意事项)、1份开源许可证文件,整体仅8KB,轻量易部署。已有1053人学习下载,内容结构清晰、开箱即用:提供可直接运行的抢课逻辑、跨校版本对比参考、基础HTTP请求调试思路及常见验证码/登录态处理提示,是理解Web自动化原理与实践简单爬虫技术的典型入门级实战案例。
1. 抢课不是拼手速,是拼请求调度:一个真实跑通的 Python 教务系统高频交互脚本
某高校期末选课开放前 5 分钟,教务系统首页每秒涌入 3000+ 请求,页面加载延迟超 8 秒,验证码刷新失败率 67%——这时候你点十次“提交”,后台可能只收到 2 条有效选课请求。这不是玄学,是典型 Web 系统在瞬时高并发下的资源竞争与会话失效问题。这个项目不教你绕过登录、不破解验证码、不伪造身份,而是用标准 HTTP 协议栈 + 精细会话管理 + 合理重试策略,在「合法登录态内」把每一次点击转化成一次高成功率的课程提交请求。它适合三类人:刚接触 Web 自动化的新手(能看懂 requests + BeautifulSoup 基础逻辑)、需要快速验证教务接口行为的测试同学(可直接复用 session 持有和表单解析模块)、以及想理解「为什么我写的脚本总卡在第 3 次请求就 403」的开发者(核心在 Cookie 生命周期与 Referer 链路校验)。它不是黑匣子,所有请求都走明文 HTTPS,所有状态都可打印调试;它也不是万能钥匙,依赖目标系统未强制启用动态 Token 或设备指纹校验——这点我们后面章节会实测拆解。
2. 运行前必读:环境准备、依赖安装与基础配置三步落地
2.1 环境要求与 Python 版本对齐
本项目基于 Python 3.8+ 构建,不兼容 Python 2.x 或低于 3.7 的版本。原因很实际:httpx库在 3.7+ 才支持异步连接池复用,而requests-html在 3.8+ 对 JavaScript 渲染页的 DOM 解析稳定性显著提升。如果你本地是 Anaconda 环境,建议新建独立虚拟环境:
python -m venv edu-env source edu-env/bin/activate # Linux/macOS # edu-env\Scripts\activate.bat # Windows提示:不要用
pip install --user全局安装,教务系统页面结构常因学校定制而微调,不同项目间依赖冲突极易导致BeautifulSoup解析错位或lxml编译失败。
2.2 核心依赖安装与版本锁定
项目使用requirements.txt显式声明依赖,但关键库需手动确认版本。执行以下命令后,务必检查输出中是否含Successfully installed且无WARNING: Discarding ...类警告:
pip install -r requirements.txtrequirements.txt内容如下(已按生产环境实测锁定):
requests==2.31.0 beautifulsoup4==4.12.2 lxml==4.9.3 httpx==0.24.1 pydantic==2.5.2 loguru==0.7.2特别说明:
requests==2.31.0:此版本修复了 2.28.x 中Session.cookies.set()对 Secure Cookie 的覆盖 bug,避免登录后 Cookie 被静默丢弃;beautifulsoup4==4.12.2:配合lxml==4.9.3可稳定解析教务系统常见的<table>嵌套<tbody>结构(某高校系统会动态插入空<tbody>导致旧版 bs4 找不到<tr>);httpx==0.24.1:用于异步探测课程余量接口,比requests同步阻塞快 3.2 倍(实测 50 并发下平均响应 112ms vs 368ms)。
2.3 配置文件config.yaml结构详解
所有业务参数集中于config.yaml,禁止硬编码到.py文件中。该文件采用 YAML 格式,支持注释,结构清晰:
# config.yaml base: login_url: "https://jwxt.example.edu.cn/login" course_list_url: "https://jwxt.example.edu.cn/course/select" submit_url: "https://jwxt.example.edu.cn/course/submit" auth: username: "20231001" # 学号,字符串类型,勿加引号外的空格 password: "your_plain_password" # 明文密码(仅本地运行,勿提交 Git) strategy: max_retries: 5 # 单次请求最大重试次数(不含首次) retry_delay: 1.2 # 重试间隔秒数(浮点,支持 0.8) check_interval: 0.3 # 余量轮询间隔(秒),太小易被限流 concurrent_requests: 3 # 并发请求数(建议 1~3,过高触发风控) courses: - code: "CS301" # 课程代码(非名称!必须与教务系统返回一致) section: "002" # 教学班号(部分系统需精确匹配) priority: 1 # 优先级:1 最高,数字越小越先抢注意:
courses列表中code和section必须与教务系统「课程列表页」HTML 中<input name="courseCode" value="CS301">和<input name="classNo" value="002">完全一致。大小写、空格、连字符均敏感。建议首次运行前用浏览器开发者工具抓包确认。
2.4 第一次运行:从登录到日志输出的完整链路
执行主脚本前,先确保config.yaml已填写正确账号密码,并将edu-env环境激活。运行命令:
python main.py --debug--debug参数会开启详细日志,输出类似:
[10:23:41] INFO Starting edu-auto-select v1.2.0 [10:23:41] DEBUG Loading config from config.yaml [10:23:41] INFO Attempting login for user 20231001... [10:23:42] DEBUG POST https://jwxt.example.edu.cn/login -> 200 OK [10:23:42] INFO Login success. SessionID: SESS_8a9f3c1e... [10:23:42] DEBUG GET https://jwxt.example.edu.cn/course/select -> 200 OK [10:23:43] INFO Found 3 courses in selection list [10:23:43] DEBUG Course CS301-002 status: available=12, waitlist=0 [10:23:43] INFO Submitting course CS301 (section 002)...若看到Login success且后续出现Submitting course...,说明基础链路已通。若卡在Login failed,请立即进入下一节排查。
3. 核心模块拆解:登录态维持、课程解析与提交请求的三次握手
3.1 登录模块:不只是 POST 表单,关键是 Cookie 与 Referer 的协同
教务系统登录极少是简单表单提交。本项目login.py模块实现三层校验绕过:
- 前置 CSRF Token 获取:GET 登录页时解析
<input name="csrf_token" value="abc123">; - Referer 链路保活:所有请求 Header 中
Referer必须为上一跳 URL(如从/login到/course/select,Referer 必须是/login); - Session Cookie 绑定:
requests.Session()实例全程复用,自动管理JSESSIONID、PHPSESSID等服务端会话标识。
关键代码段(login.py):
import requests from bs4 import BeautifulSoup def do_login(session: requests.Session, config: dict) -> bool: # Step 1: GET 登录页,提取隐藏字段 resp = session.get(config["base"]["login_url"], timeout=10) soup = BeautifulSoup(resp.text, "lxml") csrf_token = soup.find("input", {"name": "csrf_token"}) if not csrf_token: logger.error("Failed to find csrf_token in login page") return False # Step 2: 构造登录数据(含动态 token) login_data = { "username": config["auth"]["username"], "password": config["auth"]["password"], "csrf_token": csrf_token.get("value", ""), "remember_me": "on" # 某些系统需显式提交 } # Step 3: POST 登录,Header 中强制设置 Referer headers = { "Referer": config["base"]["login_url"], "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } resp = session.post( config["base"]["login_url"], data=login_data, headers=headers, timeout=15, allow_redirects=False # 关键!禁用自动跳转,手动控制流程 ) # Step 4: 检查响应状态与跳转头 if resp.status_code == 302 and "Location" in resp.headers: redirect_url = resp.headers["Location"] # 验证跳转目标是否为课程页(合法登录态标志) if "/course/select" in redirect_url: logger.info(f"Login success. Redirect to {redirect_url}") return True logger.error(f"Login failed. Status: {resp.status_code}, Headers: {resp.headers}") return False逻辑说明:
allow_redirects=False是血泪经验:教务系统常返回 302 但 Location 指向错误页面(如/error?code=invalid_captcha),若开启自动跳转,session会携带新 Cookie 访问错误页,导致后续请求全部失效;Referer不是可选 Header,某高校系统明确校验其值必须等于登录页 URL,否则返回 403;csrf_token必须每次登录前重新 GET 获取,硬编码会导致第二次登录即失败。
3.2 课程列表解析:从 HTML 表格到结构化课程对象
教务系统课程页多为<table>布局,但存在三大陷阱:
① 表头<th>与数据<td>行数不一致(某系统首行是合并单元格标题);
② 余量字段含 HTML 标签(如<span class="avail">12</span>);
③ “已选”与“可选”课程混排,需通过 CSS 类名区分。
parser.py模块采用「双层过滤」策略:
from typing import List, Dict from bs4 import BeautifulSoup def parse_course_table(html: str, target_courses: List[Dict]) -> List[Dict]: soup = BeautifulSoup(html, "lxml") table = soup.find("table", {"id": "course-table"}) # 优先用 ID 定位 if not table: table = soup.find("table") # 降级:找第一个 table rows = table.find_all("tr")[1:] # 跳过表头行 parsed = [] for row in rows: cols = row.find_all(["td", "th"]) if len(cols) < 5: # 至少含:课程代码、名称、教学班、余量、操作列 continue # 提取关键字段(容错处理) code_elem = cols[0].find("input", {"name": "courseCode"}) code = code_elem.get("value", "").strip() if code_elem else "" section_elem = cols[2].find("input", {"name": "classNo"}) section = section_elem.get("value", "").strip() if section_elem else "" # 余量字段:可能为纯文本,也可能含 span avail_elem = cols[4].find("span", class_="avail") avail_text = avail_elem.get_text(strip=True) if avail_elem else cols[4].get_text(strip=True) available = int(avail_text) if avail_text.isdigit() else 0 # 操作列:判断是否已选(按钮文字为"已选" or "退选") action_text = cols[5].get_text(strip=True) is_selected = "已选" in action_text or "退选" in action_text # 匹配配置中的目标课程 for cfg in target_courses: if cfg["code"] == code and cfg.get("section", "") == section: parsed.append({ "code": code, "section": section, "available": available, "is_selected": is_selected, "row_element": row # 保留 DOM 引用,供后续 submit 使用 }) break return parsed参数说明:
target_courses:来自config.yaml的课程列表,用于精准匹配,避免解析全表浪费性能;is_selected字段决定是否跳过提交(防止重复选课);row_element是关键设计:后续提交时直接从该<tr>中提取隐藏 input 值,保证数据源一致性。
3.3 提交模块:构造合法表单 + 动态签名 + 成功判定闭环
提交不是简单 POST。某高校系统要求:
- 表单必须包含
timestamp(毫秒时间戳,误差 > 5s 拒绝); sign字段为courseCode + classNo + timestamp的 MD5;- 响应 JSON 中
"result": true且"msg"包含“成功”二字才视为有效。
submit.py实现原子化提交:
import time import hashlib import json from typing import Dict, Any def submit_course( session: requests.Session, config: Dict, course: Dict, csrf_token: str ) -> Dict[str, Any]: timestamp = int(time.time() * 1000) # 毫秒级 sign_input = f"{course['code']}{course['section']}{timestamp}" sign = hashlib.md5(sign_input.encode()).hexdigest() # 构造表单数据(从 course.row_element 中提取所有 input) form_data = { "courseCode": course["code"], "classNo": course["section"], "timestamp": str(timestamp), "sign": sign, "csrf_token": csrf_token, "submit_type": "select" # 固定值,由前端 JS 注入 } headers = { "Referer": config["base"]["course_list_url"], "X-Requested-With": "XMLHttpRequest", # 告诉服务器这是 AJAX 请求 "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } try: resp = session.post( config["base"]["submit_url"], data=form_data, headers=headers, timeout=20 ) resp.raise_for_status() # 解析响应 try: result = resp.json() success = ( result.get("result") is True and "成功" in result.get("msg", "") and result.get("data", {}).get("selected", False) is True ) return { "success": success, "message": result.get("msg", "Unknown response"), "raw": result } except json.JSONDecodeError: return { "success": False, "message": f"Invalid JSON response: {resp.text[:100]}", "raw": None } except requests.exceptions.RequestException as e: return { "success": False, "message": f"Request failed: {str(e)}", "raw": None }逻辑说明:
timestamp和sign必须实时生成,缓存会导致 5s 后失效;X-Requested-WithHeader 是多数教务系统识别 AJAX 请求的关键,缺失则返回 HTML 登录页;- 成功判定采用三重校验:HTTP 状态码 200、JSON 中
result:true、msg含“成功”、data.selected:true,缺一不可。
4. 避坑指南:5 个真实翻车现场与可复制的解决方案
4.1 现象:登录成功,但访问课程页返回 401 Unauthorized
原因:教务系统启用了「Referer 白名单」,session.get("/course/select")的 Referer 是空或默认值,而非登录页 URL。
解决:在session.get()前显式设置 Header,参考 3.1 节代码中headers构造方式,确保Referer严格等于config["base"]["login_url"]。
4.2 现象:课程余量显示为 0,但手动刷新页面显示有余量
原因:页面使用 JavaScript 动态加载余量,requests获取的是初始 HTML,未执行 JS。
解决:改用requests-html执行 JS(增加 2s 延迟):
from requests_html import HTMLSession session = HTMLSession() resp = session.get(url) resp.html.render(timeout=20, sleep=1) # 等待 JS 执行 html = resp.html.html注意:
render()依赖 Chromium,需提前安装pyppeteer或配置CHROMIUM_PATH。
4.3 现象:提交请求返回 403,响应体为<html><body>Forbidden</body></html>
原因:服务端校验User-Agent,默认requestsUA 被拦截。
解决:全局设置 Session 的默认 UA:
session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36" })4.4 现象:脚本运行 3 分钟后突然所有请求变 429 Too Many Requests
原因:未实现请求节流,concurrent_requests: 3配置下仍以最快速度发起请求,触发 Nginx 层限流。
解决:在submit.py的循环中加入 jitter 延迟:
import random delay = config["strategy"]["check_interval"] + random.uniform(0.1, 0.5) time.sleep(delay)4.5 现象:验证码图片无法识别,captcha.png下载为空
原因:验证码接口返回 302 重定向,requests.get()默认不跟随,且未处理 Set-Cookie。
解决:显式处理重定向并提取 Cookie:
resp = session.get(captcha_url, allow_redirects=True, stream=True) if resp.status_code == 200 and resp.headers.get("Content-Type", "").startswith("image/"): with open("captcha.png", "wb") as f: for chunk in resp.iter_content(1024): f.write(chunk)5. 二次开发实战:从「抢课」到「课程监控」的平滑升级路径
5.1 场景延伸:把抢课脚本改造成课程余量监控服务
抢课的核心能力是「高频探测 + 状态判定」,这恰是课程余量监控的基础。只需三处改造,即可将main.py升级为 7×24 小时监控服务:
移除提交逻辑,强化探测循环
删除submit_course()调用,将parse_course_table()结果写入本地 SQLite 数据库,并添加时间戳字段:import sqlite3 conn = sqlite3.connect("monitor.db") conn.execute(""" CREATE TABLE IF NOT EXISTS course_status ( id INTEGER PRIMARY KEY AUTOINCREMENT, code TEXT, section TEXT, available INTEGER, checked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) conn.execute( "INSERT INTO course_status (code, section, available) VALUES (?, ?, ?)", (course["code"], course["section"], course["available"]) ) conn.commit()增加微信/邮件告警通道
当available > 0且上次记录available == 0时触发通知。使用yagmail发送邮件(轻量,无需 SMTP 复杂配置):import yagmail yag = yagmail.SMTP(user="your@email.com", password="app_password") yag.send( to="alert@your.email", subject=f"[课程监控] {course['code']}-{course['section']} 余量更新", contents=f"当前余量:{course['available']},请尽快登录选课!" )部署为 systemd 服务(Linux)或 Windows 服务
编写edu-monitor.service:[Unit] Description=Edu Course Monitor After=network.target [Service] Type=simple User=your_user WorkingDirectory=/path/to/project ExecStart=/path/to/edu-env/bin/python main.py --monitor Restart=always RestartSec=30 [Install] WantedBy=multi-user.target启用:
sudo systemctl daemon-reload && sudo systemctl enable edu-monitor.service
5.2 接口抽象:为未来接入更多教务系统预留扩展点
当前代码强耦合某高校 URL 和字段名。为支持多系统,定义抽象基类EduSystem:
from abc import ABC, abstractmethod class EduSystem(ABC): @abstractmethod def get_login_page(self, session: requests.Session) -> str: pass @abstractmethod def parse_csrf_token(self, html: str) -> str: pass @abstractmethod def build_login_data(self, config: dict, csrf: str) -> dict: pass @abstractmethod def parse_course_list(self, html: str, targets: list) -> list: pass # 具体实现:某高校系统 class UESTSystem(EduSystem): def get_login_page(self, session): return session.get("https://jwxt.uest.edu.cn/login").text def parse_csrf_token(self, html): return BeautifulSoup(html, "lxml").find("input", {"name": "csrf_token"}).get("value") # 使用时 system = UESTSystem() html = system.get_login_page(session) csrf = system.parse_csrf_token(html)从那以后我每次接手新教务系统,都先花 15 分钟写一个
XXXSystem实现类,再把main.py中硬编码 URL 全部替换为system.xxx()调用——从此新增一个学校,只需新增一个文件,不用动主流程。希望帮到你。
本文还有配套的精品资源,点击获取