每到期末出成绩的日子,最折磨人的莫过于一遍遍打开教务系统、输入验证码、刷新页面…… 既浪费时间又让人焦虑。作为一名技术爱好者,这种机械重复的工作怎么能亲自动手呢?
今天分享一个基于 Python 的教务系统(正方/强智等架构通用思路)成绩监控脚本。它能自动模拟登录、绕过 RSA 加密、定时查询,并在发现新成绩时通过 Bark 实时推送到你的手机!
🛠️ 核心功能与技术栈
本脚本主要解决了爬取教务系统时的几个痛点:
- RSA 加密登录:解决前端 JS 加密密码的问题。
- Session 激活:解决“直接调接口无数据”的 Session 校验机制。
- 增量推送:建立本地记录,只推送最新发布的成绩,避免重复轰炸。
涉及库:
requests: 处理 HTTP 请求与 Session 保持。pycryptodome: 处理 RSA 非对称加密。base64/json: 数据处理。
📝 准备工作
1. 环境安装
脚本依赖 pycryptodome 库来进行 RSA 加密操作。请注意,不要安装过时的 pycrypto。
pip install requests pycryptodome2. 获取 Bark 推送链接 (iOS)
为了实现手机通知,我们使用 Bark(App Store 免费下载)。
- 下载 App 后打开,复制你的专属链接(例如:`https://api.day.app
/xEAK6vb...`)。 - 注:安卓用户可自行修改代码适配 Server酱 或 钉钉机器人。
💡 核心逻辑深度解析
1. 搞定 RSA 加密登录
现在的教务系统大多不像以前那样直接传输明文密码,而是先请求公钥(Modulus 和 Exponent),在前端加密后再传输。
我们需要在 Python 中复刻这个过程。脚本中的 encrypt 函数兼容了 Hex 和 Base64 两种常见的 Exponent 格式:
def encrypt(self, pwd, pub_key, exponent):
rsa_n = base64.b64decode(pub_key)
try:
rsa_e = int(exponent, 16) # 尝试 Hex 格式
except ValueError:
rsa_e = bytes_to_long(base64.b64decode(exponent)) # 尝试 Base64 格式
key = RSA.construct((bytes_to_long(rsa_n), rsa_e))
# 使用 PKCS1_v1_5 填充并加密
return base64.b64encode(PKCS1_v1_5.new(key).encrypt(pwd.encode())).decode()2. 避坑:Session 的“激活”
很多同学写爬虫时发现:登录显示成功了,Cookie 也带上了,但直接请求成绩接口 (cjcx_cxXsgrcj.html) 却返回空数据或报错。
原因:教务系统服务端会校验你的 Session 是否“访问过”成绩查询的主页面。
解决:在调用 API 前,先 GET 一次页面 URL (PAGE_URL),这一步至关重要!
# 【关键】先访问页面激活 Session,否则接口由于鉴权机制会拒绝服务
self.sess.get(PAGE_URL) 💻 完整代码实现
新建文件 grade_monitor.py,将以下代码复制进去。
⚠️ 请务必修改代码顶部的 配置区域!
import requests, time, base64, json, os, sys
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5
from Crypto.Util.number import bytes_to_long
# ============ ⚙️ 配置区域 (请修改此处) ============
USERNAME = "2023xxxxxx" # 学号
PASSWORD = "YourPassword" # 密码
BARK_URL = "[https://api.day.app/你的Key](https://api.day.app/你的Key)" # Bark 推送链接
# =================================================
# 教务系统地址 (根据你的学校修改,此处以正方系统为例)
BASE_URL = "[https://jwxt.nfu.edu.cn/jwglxt](https://jwxt.nfu.edu.cn/jwglxt)"
LOGIN_URL = f"{BASE_URL}/xtgl/login_slogin.html"
KEY_URL = f"{BASE_URL}/xtgl/login_getPublicKey.html"
# 必须先访问这个页面激活 Session
PAGE_URL = f"{BASE_URL}/cjcx/cjcx_cxDgXscj.html?gnmkdm=N305005&layout=default"
API_URL = f"{BASE_URL}/cjcx/cjcx_cxXsgrcj.html?doType=query&gnmkdm=N305005"
DB_FILE = "sent_grades.txt" # 本地存储已推送课程ID的文件
class Monitor:
def __init__(self):
self.sess = requests.Session()
self.sess.headers.update({
"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko)",
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
"X-Requested-With": "XMLHttpRequest"
})
def notify(self, title, msg):
"""发送 Bark 推送"""
try:
requests.get(f"{BARK_URL}/{title}/{msg}", timeout=5)
print(f"📲 推送成功: {title} - {msg}")
except:
print("❌ 推送失败")
def encrypt(self, pwd, pub_key, exponent):
"""RSA 加密:兼容多种公钥格式"""
rsa_n = base64.b64decode(pub_key)
try:
rsa_e = int(exponent, 16)
except ValueError:
rsa_e = bytes_to_long(base64.b64decode(exponent))
key = RSA.construct((bytes_to_long(rsa_n), rsa_e))
return base64.b64encode(PKCS1_v1_5.new(key).encrypt(pwd.encode())).decode()
def login(self):
print(f"[{time.strftime('%H:%M:%S')}] 🔄 正在登录...")
try:
self.sess.cookies.clear()
# 1. 访问登录页获取 CSRF Token
r1 = self.sess.get(LOGIN_URL)
try: csrftoken = r1.text.split('id="csrftoken" value="')[1].split('"')[0]
except: csrftoken = ""
# 2. 获取公钥
key = self.sess.get(KEY_URL, params={'time': int(time.time()*1000)}).json()
mm = self.encrypt(PASSWORD, key['modulus'], key['exponent'])
# 3. 登录请求
data = {"csrftoken": csrftoken, "yhm": USERNAME, "mm": mm, "mm": mm, "yzm": ""}
r2 = self.sess.post(LOGIN_URL, data=data)
if "success" in r2.text or "Main_index.html" in r2.url:
print("✅ 登录成功")
return True
return False
except Exception as e:
print(f"❌ 登录异常: {e}")
return False
def check(self):
try:
ts = int(time.time()*1000)
# 【重要】Session 保活访问
self.sess.get(PAGE_URL)
# 查询参数 (注意修改 xnm:学年, xqm:学期)
# 3 代表第一学期, 12 代表第二学期, 根据学校具体情况调整
data = {
"xnm": "2025", "xqm": "3", "_search": "false", "nd": ts,
"queryModel.showCount": "20", "queryModel.currentPage": "1",
"queryModel.sortOrder": "asc", "time": "4"
}
res = self.sess.post(API_URL, data=data, timeout=15)
# 检查登录状态
if "login_slogin.html" in res.text:
print("⚠️ Cookie 失效,触发重连机制")
return False
try:
items = res.json().get("items", [])
except:
return False
# 读取历史记录
if os.path.exists(DB_FILE):
with open(DB_FILE, 'r') as f: sent = f.read().splitlines()
else: sent = []
new_found = False
for item in items:
kid = item.get("kch_id") # 课程ID
name = item.get("kcmc") # 课程名
score = item.get("cj") # 成绩
# 仅推送未记录的成绩
if kid and kid not in sent:
print(f"🎉 新成绩发现: {name} {score}")
self.notify("出成绩啦", f"{name}考了{score}分")
with open(DB_FILE, 'a') as f: f.write(f"{kid}\n")
new_found = True
if not new_found:
print(f"[{time.strftime('%H:%M')}] 暂无新成绩")
return True
except Exception as e:
print(f"⚠️ 查询出错: {e}")
return True # 报错不中断循环,等待下一次
def run(self):
self.notify("监控启动", "脚本已在服务器部署运行")
if not self.login(): return
while True:
success = self.check()
if not success:
# 失败休息 10 秒后重登
time.sleep(10)
self.login()
else:
# 成功后每 10 分钟查询一次 (600秒)
# 建议不要低于 5 分钟,避免被封 IP
sys.stdout.flush()
time.sleep(600)
if __name__ == "__main__":
Monitor().run()📡 部署建议 (Linux/服务器)
为了实现 24 小时监控,建议将脚本部署在云服务器或树莓派上。
- 修改代码中的年份和学期:确保
data字典中的xnm(学年) 和xqm(学期) 是当前需要查询的。 后台静默运行:
使用nohup命令让脚本在后台运行,即使关闭 SSH 窗口也不会停止。nohup python3 grade_monitor.py > monitor.log 2>&1 &查看日志:
tail -f monitor.log
⚠️ 注意事项与免责声明
- 查询频率:代码中默认设置为
600秒(10分钟)一次。请勿将间隔设置过短(如几秒一次),这会对学校服务器造成压力,甚至导致你的 IP 被封禁。 - 账号安全:脚本涉及明文密码存储(虽然在本地),请勿将含有密码的脚本文件发送给他人。
- 适用性:本脚本基于强智/正方教务系统开发,不同学校的 API 路径可能略有差异,请利用浏览器 F12 开发者工具抓包确认
API_URL。
希望这个脚本能帮你省去无效的等待,祝大家期末都能 GPA 4.0,门门高分! 🎉