默认分类 2026-01-21 22:06 2

🚀 不再手动刷新!用 Python 实现教务系统成绩自动监控与推送

每到期末出成绩的日子,最折磨人的莫过于一遍遍打开教务系统、输入验证码、刷新页面…… 既浪费时间又让人焦虑。作为一名技术爱好者,这种机械重复的工作怎么能亲自动手呢?

今天分享一个基于 Python 的教务系统(正方/强智等架构通用思路)成绩监控脚本。它能自动模拟登录绕过 RSA 加密定时查询,并在发现新成绩时通过 Bark 实时推送到你的手机!


🛠️ 核心功能与技术栈

本脚本主要解决了爬取教务系统时的几个痛点:

  1. RSA 加密登录:解决前端 JS 加密密码的问题。
  2. Session 激活:解决“直接调接口无数据”的 Session 校验机制。
  3. 增量推送:建立本地记录,只推送最新发布的成绩,避免重复轰炸。

涉及库:

  • requests: 处理 HTTP 请求与 Session 保持。
  • pycryptodome: 处理 RSA 非对称加密。
  • base64/json: 数据处理。

📝 准备工作

1. 环境安装

脚本依赖 pycryptodome 库来进行 RSA 加密操作。请注意,不要安装过时的 pycrypto

pip install requests pycryptodome

2. 获取 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 小时监控,建议将脚本部署在云服务器或树莓派上。

  1. 修改代码中的年份和学期:确保 data 字典中的 xnm (学年) 和 xqm (学期) 是当前需要查询的。
  2. 后台静默运行
    使用 nohup 命令让脚本在后台运行,即使关闭 SSH 窗口也不会停止。

    nohup python3 grade_monitor.py > monitor.log 2>&1 &
  3. 查看日志

    tail -f monitor.log

⚠️ 注意事项与免责声明

  1. 查询频率:代码中默认设置为 600秒(10分钟)一次。请勿将间隔设置过短(如几秒一次),这会对学校服务器造成压力,甚至导致你的 IP 被封禁。
  2. 账号安全:脚本涉及明文密码存储(虽然在本地),请勿将含有密码的脚本文件发送给他人。
  3. 适用性:本脚本基于强智/正方教务系统开发,不同学校的 API 路径可能略有差异,请利用浏览器 F12 开发者工具抓包确认 API_URL

希望这个脚本能帮你省去无效的等待,祝大家期末都能 GPA 4.0,门门高分! 🎉

Tags: python, 自动化, 脚本

发表评论