Compare commits

...

10 Commits

Author SHA1 Message Date
6e9704a803 clean debug info 2025-07-06 14:54:43 +08:00
7f512b145c fix logout check 2025-07-06 14:50:46 +08:00
21f2f2dbec Add interval and fix bugs 2025-07-06 14:44:06 +08:00
38a9240149 remove bs4 2025-07-05 13:34:51 +08:00
d7c62fbc32 add readme and requirements 2025-07-05 13:22:52 +08:00
68f1d9e24f add dotenv demo. 2025-07-05 12:56:01 +08:00
879d131f1c add gitignore and demo 2025-07-05 12:53:14 +08:00
287c99c88e format 2025-07-05 12:49:56 +08:00
5e18dadad5 clean code 2025-07-05 12:49:43 +08:00
cc7fdca070 init2 2025-07-05 12:49:14 +08:00
7 changed files with 283 additions and 59 deletions

8
.env_demo Normal file
View File

@ -0,0 +1,8 @@
uname=20240000000
password=123456
recap_username=this_is_recap_unam
recap_password=123456
SC_KEY=this_is_sc_key
FILE = "lessons.xlsx"
INTERVAL_1=2
INTERVAL_2=10

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.env
logs
lessons*

144
README.md Normal file
View File

@ -0,0 +1,144 @@
# 自动选课脚本
一个用于某URP系统的自动监控课程余量并进行选课的工具。当目标课程有余量时程序会自动尝试选课并推送选课结果。
## 功能特点
- 🔄 自动监控课程余量
- 📱 微信消息推送通知
- 🔁 失败自动重试
- 📊 支持多种格式的课程文件
- 📝 详细的日志记录
## 使用前准备
### 1. 准备课程文件
创建一个Excel文件建议命名 `lessons.xlsx`**至少**包含以下三列:
| 课程号 | 课序号 | 课程名 |
|--------|--------|--------|
| 080302001 | 01 | 高等数学A |
| 080302002 | 02 | 线性代数 |
**说明:**
- **课程名**:课程的完整名称,不允许增加空格等,建议直接复制选课手册行。
- 允许出现除上述内容以外的项,所以如果是辽大的可以直接复制选课手册对应行到文件中。
- 允许存在**同一课程多个课序号**,将按照自上而下的顺序选,任何一个选课成功就认为该课程选课成功。
- 具体格式参考`demo.xlsx``demo.csv`
- 支持的文件格式:`.xlsx``.xls``.csv``.json`
### 2. 配置环境变量
创建 `.env` 文件,包含以下配置:
```env
# 教务系统登录信息
uname=你的学号
password=你的密码
# 验证码识别服务账号(必需)
recap_username=验证码识别服务用户名
recap_password=验证码识别服务密码
# 课程文件路径
# 改成自己起的名字,注意拓展名
FILE=lessons.xlsx
# 微信推送服务(可选)
SC_KEY=你的ServerChan密钥
# 教务系统地址(可选,默认为辽宁大学)
base=http://jwstudent.lnu.edu.cn
# 查询间隔
# 每次选课请求间隔
INTERVAL_1=2
# 完成一轮选课后的间隔
INTERVAL_2=10
```
你可以查看`.env_demo`查看样例。
### 3. 获取验证码识别服务
目前使用 http://www.fdyscloud.com.cn/ 提供的验证码识别0.003 元/张。
这是一个付费平台,与作者无关。
操作方法:
1. 打开并注册账号(密码建议随机,因为要明文保存)
2. 充值合适余额1元估计就够用到大学毕业了
3. 在.env文件中`recap_username``recap_password`输入你的账号密码。
如果您不希望付费,可以修改代码中的`Lessons.recapture`自行实现本地验证码OCR。
### 4. 设置推送(可选)
如需接收选课结果的微信推送:
1. 访问 [Server酱](https://sct.ftqq.com/)或者[Server$^3$酱](https://sct.ftqq.com/)
2. 注册并获取SendKey
3. 将密钥填入 `.env` 文件的 `SC_KEY` 字段
**注意Server$^3$酱会产生费用**
### 5. 安装依赖
```bash
pip install -r requirements.txt
```
## 使用方法
1. 确保已完成上述准备工作
2. 将课程文件和 `.env` 文件放在脚本同目录下
3. 运行脚本:
```
python main.py
```
## 运行流程
1. **登录验证**: 自动登录教务系统
2. **读取课程**: 从课程文件中读取要选择的课程信息
3. **监控余量**: 循环检查各课程的剩余名额
4. **自动选课**: 发现有余量时立即尝试选课
5. **结果通知**: 通过推送选课结果
6. **持续监控**: 未选上的课程继续监控
## 注意事项
⚠️ **重要提醒**
- 请在选课开放时间内使用,在未开放时会发生未知错误。
- 确保网络连接稳定
- 验证码识别服会产生费用
- 建议在选课高峰期前测试配置是否正确
- 程序会自动处理网络异常和重新登录
## 日志文件
程序运行时会在 `logs` 目录下生成详细的日志文件,文件名格式为 `lessons_YYYYMMDD_HHMMSS.log`,可用于排查问题。
## 文件结构
```
项目目录/
├── main.py # 主程序
├── .env # 环境变量配置文件
├── lessons.xlsx # 课程信息文件
├── logs/ # 日志文件目录
└── README.md # 说明文档
```
## 常见问题
**Q: 程序提示"请在环境变量中设置xxx"**
A: 检查 `.env` 文件是否存在且包含所有必需的配置项
**Q: 一直提示登录失败**
A: 检查用户名密码是否正确,以及验证码识别服务是否可用
**Q: 找不到课程信息**
A: 检查课程文件中的课程号和课序号是否正确
**Q: 没有收到推送**
A: 检查 ServerChan 配置是否正确,或查看日志中的错误信息

3
demo.csv Normal file
View File

@ -0,0 +1,3 @@
课程号, 课序号,课程名
2310031,01,体育(二)
2310031,02,体育(二)
1 课程号 课序号 课程名
2 2310031 01 体育(二)
3 2310031 02 体育(二)

BIN
demo.xlsx Normal file

Binary file not shown.

181
main.py
View File

@ -13,7 +13,6 @@ from typing import Callable, List, Optional
import dotenv
import pandas as pd
import requests
from bs4 import BeautifulSoup
from serverchan_sdk import sc_send as _sc_send
# 配置日志
@ -32,7 +31,9 @@ logger.addHandler(file) # 日志输出到文件
class LessonsException(Exception):
"""自定义异常类"""
pass
class ReloginException(LessonsException):
"""用于处理需要重新登录的异常"""
pass
@ -64,13 +65,14 @@ class Lessons:
"recap_username",
"recap_password",
"FILE",
"SC_KEY",
]
for key in required_keys:
if not environ.get(key):
raise LessonsException(f"请在环境变量中设置{key}")
self.base = environ.get("base", "http://jwstudent.lnu.edu.cn")
self.interval_1 = int(environ.get("INTERVAL_1", 2)) # 请求间隔默认为2秒
self.interval_2 = int(environ.get("INTERVAL_2", 10)) # 请求间隔默认为10秒
def _retry_request(
self, func, max_retries: int = 10, error_msg: str = "请求失败"
@ -190,7 +192,7 @@ class Lessons:
def judge_logout(self, response: requests.Response):
"""检查账号是否在其他地方被登录"""
if response.url == f"{self.base}/login?errorCode=concurrentSessionExpired":
raise LessonsException("有人登录了您的账号!")
raise ReloginException("有人登录了您的账号!")
def get_base_info(self):
res = self.session.get(f"{self.base}/student/courseSelect/gotoSelect/index")
@ -198,7 +200,7 @@ class Lessons:
html = res.text
match = re.search(r"fajhh=(\d+)", html)
if not match:
print(html)
# print(html)
raise LessonsException("未找到培养方案编号")
self.fajhh = match.group(1)
@ -207,13 +209,25 @@ class Lessons:
)
res.raise_for_status()
html = res.text
bs = BeautifulSoup(html, "html.parser")
match = bs.select_one("select#jhxn option[selected]")
if not match:
raise LessonsException("未找到学期信息")
self.term = str(match.get("value"))
# 使用正则表达式替代 BeautifulSoup 来查找选中的学期选项
# 由于 HTML 结构特殊selected 在单独行上,需要向前查找对应的 option
lines = html.split('\n')
for i, line in enumerate(lines):
if 'selected' in line.strip():
# 向前查找包含 option value 的行
for j in range(i-1, max(0, i-10), -1):
if 'option value=' in lines[j]:
value_match = re.search(r'value="([^"]*)"', lines[j])
if value_match:
self.term = str(value_match.group(1))
break
if self.term:
break
print(self.fajhh, self.term)
if not self.term:
raise LessonsException("未找到学期信息")
# print(self.fajhh, self.term)
def read_lessons(self) -> List[tuple[str, str, str]]:
classes = []
@ -260,20 +274,42 @@ class Lessons:
"jc": 0,
}
response = self._retry_request(lambda: self.session.post(url, data=params))
data = response.json()["kylMap"]
if len(data) == 0:
logger.error(f"课程 {cl[2]} 的余量信息为空: {data}")
return
with open("response.json", "w", encoding="utf-8") as f:
f.write(response.text)
data:dict = response.json()
cls:list[dict] = data.get("rwfalist", [])
if not cls:
logger.error(f"课程 {cl[2]} 的课程信息为空: {data}")
return None
for item in cls:
if item["classNum"] in cl[1]:
# print(item["classNum"],type(item["classNum"]))
if item["kcm"] != cl[2]:
logger.critical(
f"课程 {cl[2]} 的课程名与查询信息不匹配: {item['kcm']} != {cl[2]}"
)
sc_send(
"选课异常",
desp=f"课程 {cl[2]} 的课程名与查询信息不匹配: {item['kcm']} != {cl[2]}",
)
return None
kyl:dict[str,str] = data["kylMap"]
if len(kyl) == 0:
logger.error(f"课程 {cl[2]} 的余量信息为空: {kyl}")
return
ret = []
for kxh in cl[1]:
key = f"{self.term}_{cl[0]}_{kxh}"
left = data.get(key, None)
left = kyl.get(key, None)
if left is None:
logger.error(
f"课程 {cl[2]} 的余量信息不存在: {key} not in {data.keys()}"
f"课程 {cl[2]} 的余量信息不存在: {key} not in {kyl.keys()}"
)
ret.append(-1)
continue
ret.append(int(left))
return ret
@ -319,7 +355,7 @@ class Lessons:
html = response.text
redisKey = re.search(r'var redisKey = "(.+)";', html)
if not redisKey:
print(html)
# print(html)
logger.error(f"选课 {cl[2]} 时未找到 redisKey")
return False
redisKey = redisKey.group(1)
@ -345,7 +381,7 @@ class Lessons:
else:
logger.info(f"选课成功: {text}")
else:
print(f"{cnt}次查询中...")
logger.info(f"{cnt}次查询中...")
cnt += 1
sleep(1)
@ -379,6 +415,10 @@ class Lessons:
if classes.get(id) is None:
classes[id] = (id, [kxh], name)
else:
if classes[id][2] != name:
raise LessonsException(
f"课程 {name}_{kxh} 的名称不一致: {classes[id][2]} != {name}"
)
classes[id][1].append(kxh)
logger.info(f"读取课程信息,共有 {len(classes)} 门课程")
@ -396,53 +436,74 @@ class Lessons:
sc_send("选课异常", desp="最近5次获取课程余量异常尝试重新登录")
self.login()
master_err += 1
errs.clear()
if master_err >= 3:
logger.error("反复发生重要异常,退出程序")
sc_send("选课异常", desp="反复发生重要异常,退出程序")
return
for lcl in classes.copy().values():
logger.info(f"检查课程 {lcl[2]} 余量")
try:
lefts = self.get_left(lcl)
if lefts is None:
errs.appendleft(time())
logger.error(f"获取课程 {lcl[2]} 余量时返回异常")
continue
except Exception as e:
errs.appendleft(time())
logger.error(f"获取课程 {lcl[2]} 余量时发生错误: {e}")
continue
for i, left in enumerate(lefts):
cl = (lcl[0], lcl[1][i], lcl[2])
if left > 0:
logger.info(
f"课程 {cl[2]}_{cl[1]} 有余量: {left},开始选课"
)
try:
ret = self.select(cl)
if ret:
suc.append(cl)
classes.pop(cl[0])
break
except Exception as e:
try:
for lcl in classes.copy().values():
logger.info(f"检查课程 {lcl[2]} 余量")
try:
lefts = self.get_left(lcl)
if lefts is None:
errs.appendleft(time())
logger.error(f"选课 {cl[2]}_{cl[1]} 时发生错误: {e}")
finally:
sleep(2) # 避免请求过快导致服务器拒绝
elif left == -1:
logger.error(f"课程 {cl[2]}_{cl[1]} 余量信息异常")
else:
logger.info(f"课程 {cl[2]}_{cl[1]} 无余量")
sleep(2)
logger.info(
f"当前还有{len(classes)}门课程未选上,分别为{','.join(mp[i] for i in classes.keys())}。等待10秒后继续检查"
)
if suc:
sc_send(
"选课成功",
desp=f"已成功选上课程: {', '.join(f'{i[2]}_{i[1]}' for i in suc)}",
logger.error(f"获取课程 {lcl[2]} 余量时返回异常")
continue
except ReloginException as e:
raise e
except Exception as e:
errs.appendleft(time())
logger.error(f"获取课程 {lcl[2]} 余量时发生错误: {e}")
continue
for i, left in enumerate(lefts):
cl = (lcl[0], lcl[1][i], lcl[2])
if left > 0:
logger.info(
f"课程 {cl[2]}_{cl[1]} 有余量: {left},开始选课"
)
try:
ret = self.select(cl)
if ret:
suc.append(cl)
classes.pop(cl[0])
break
except ReloginException as e:
raise e
except Exception as e:
errs.appendleft(time())
logger.error(f"选课 {cl[2]}_{cl[1]} 时发生错误: {e}")
finally:
sleep(self.interval_1) # 避免请求过快导致服务器拒绝
elif left == -1:
logger.error(f"课程 {cl[2]}_{cl[1]} 余量信息异常")
else:
logger.info(f"课程 {cl[2]}_{cl[1]} 无余量")
sleep(self.interval_1)
logger.info(
f"当前还有{len(classes)}门课程未选上,分别为{','.join(mp[i] for i in classes.keys())}。等待{self.interval_2}秒后继续检查"
)
sleep(10) # 等待10秒后继续检查
if suc:
sc_send(
"选课成功",
desp=f"已成功选上课程: {', '.join(f'{i[2]}_{i[1]}' for i in suc)}",
)
except ReloginException as e:
logger.error(f"需要重新登录: {e}")
sc_send("选课异常", desp=f"需要重新登录: {e}")
self.login()
continue
except LessonsException as e:
logger.error(f"选课过程中发生错误: {e}")
sc_send("选课异常", desp=f"选课过程中发生错误: {e}")
errs.appendleft(time())
continue
except Exception as e:
logger.error(f"意外错误: {e}")
sc_send("选课异常", desp=f"选课过程中发生意外错误: {e}")
errs.appendleft(time())
continue
sleep(self.interval_2) # 等待10秒后继续检查
logger.info("自动选课完成")

5
requirements.txt Normal file
View File

@ -0,0 +1,5 @@
python-dotenv
pandas
requests
serverchan-sdk
openpyxl