来做个“成绩实时监控系统”吧

写在前面

自618买云服务器到现在,仅一个多月。但在这点时间里,我码出了”每日疫情填报“系统,用命令行便完成了WordPress网站搭建。另外,ICP+公安备案+SSL证书也十分顺利地审批下来了 。一个月前的我,大概根本想不到自己会用云服务器玩出这么多的花样吧。

本文提到的”成绩实时监控系统“也算是杰作之一。我在完成了”每日疫情填报“系统之后,就马不停蹄地花了1天时间实现了它。一个月以来,该系统运行极佳,于是我打算今后长期使用它。

由于近期时间较多,本文我会花时间提到尽可能多的技术细节。

WHAT

什么?成绩也能实时监控?做得怎么样?怎么做到的?别急,我慢慢说来。

如名所示,“成绩实时监控系统”能够以较高频率(我设置的20min每次)扫描我在教务处的成绩单,一有更新就通过邮件通知到我的邮箱,于是我的手机或是其他设备就能及时接收到新鲜出炉的成绩详情,这份邮件包括了我新出的成绩及学科、学分、绩点,并在段落最后附有我的最新GPA。

如此,再也不用忍受焦急地打开教务系统盯成绩的紧张感了!

HOW

我们一步一步地来实现这个系统吧,首先做的是最基础的抓包分析。

1. 抓包分析

东大男子职业技术学院的教务系统在今年3月份全面改版,界面变美观的同时,也留下了许多“有趣”的API,我之后可能会详细地写出来。今天我们着重分析成绩信息界面。

打开F12开发者工具-网络,刷新成绩信息界面,如下所示

成绩信息界面抓包

接下来就要找包含成绩信息的请求包了。点击任意一个请求名称,按Ctrl+F查找“电路基础”,我们定位到了网络最后的一个请求中,打开预览,我们的确发现了所有的成绩信息,那么这就是我们接下来要实现的请求了。

请求响应的是一个json格式的信息。我们还可以发现,图中共有两个相似的请求,不同在于其“semester”值不同,简单查看可以知道这是两个学期的成绩信息,分成了两个url依次进行请求。

哪里去找这个“semester”呢?经过仔细查找,我发现它就藏在框架源代码里:

<div class="row" style="margin: 20px 0px 10px;">
    <label class="col-sm-2 semester" for="semester">选择学期:</label>
    <div class="col-sm-5">
        <select class="selectize" id="semester" name="semester">
            <option value="">...</option>
            <option value="178">2021-2022春</option>
            <option value="158">2021-2022秋</option>
        </select>
    </div>
</div>

这个框架源代码来自于我们所抓取到的第一个请求中,在得到框架源代码后,我们既可以使用lxml定位到上述option的value属性,也可以像我的代码一样直接正则匹配获得。

经过以上分析,现在我们已经可以爬取到成绩列表信息了。另外一个问题是cookie,我已经在随笔:利用云服务器+脚本实现自动“健康填报”中实现了模拟登录获取cookie并存储在session里,本文不再赘述。

经过多次权衡取舍,我最终选择了如图所示的成绩信息txt存储规范,即“科目,成绩,绩点,学分”,一个科目占一行,这样读写都方便。

本地成绩txt存储规范

下面是部分代码:

url = 'https://jwxt.nwpu.edu.cn/student/sso-login'
session.get(url, headers=headers)  # 登录教务系统
response = session.get('https://jwxt.nwpu.edu.cn/student/for-std/grade/sheet/semester-index/270583', headers=headers)
semester = re.findall('<option value="(.+?)"', response.text)  # 获得semester列表
headers2 = {
    'Accept': '*/*',
    'Accept-Encoding': 'gzip, deflate, br',
    'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
    'Connection': 'keep-alive',
    'Host': 'jwxt.nwpu.edu.cn',
    'Referer': 'https://jwxt.nwpu.edu.cn/student/for-std/grade/sheet/semester-index/270583',
    'sec-ch-ua': '"".Not/A)Brand";v="99", "Google Chrome";v="103", "Chromium";v="103""',
    'sec-ch-ua-mobile': '?0',
    'sec-ch-ua-platform': '"Linux"',  # 当时我是在云服务器linux系统用Chrome抓包,以便贴合其环境
    'Sec-Fetch-Dest': 'empty',
    'Sec-Fetch-Mode': 'cors',
    'Sec-Fetch-Site': 'same-origin',
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.124 Safari/537.36 Edg/102.0.1245.44".Not/A)Brand";v="99", "Google Chrome";v="103", "Chromium";v="103"',
    'X-Requested-With': 'XMLHttpRequest'  # 这个很关键,使响应以json格式返回
}
grades = []
for sem in semester:
    url = 'https://jwxt.nwpu.edu.cn/student/for-std/grade/sheet/info/270583?semester=' + sem  #每次访问一个学期的成绩单
    response = session.get(url, headers=headers2)
    response = json.loads(response.text)['semesterId2studentGrades'][sem]
    for course in response:
        name = course['course']['nameZh']
        gaGrade = course['gaGrade']
        gp = str(course['gp'])
        credit = course['course']['credits']
        grades.append(f'{name}, {gaGrade}, {gp}, {credit}')  # 用列表依次存储各学科、成绩、绩点和学分信息

2. 成绩信息文件处理

我们并不想要所有成绩信息,我们只需要最新的成绩即可。唯一的实现方法便是本地存储所爬取的成绩信息,每次爬取后将所得成绩与本地信息相对比,找出是否有变化的成绩信息,我们用Python的集合函数来实现这一操作。

另外,由于新成绩会影响原有GPA,而我们更关心后者,因此我还从“学生画像”中通过抓包找到了GPA的API,把它附到了邮件正文末。

对于新成绩,我最关心的其实是科目名、成绩和绩点,对学分的关注要弱一点,因此我把新成绩的科目名、成绩和绩点直接放到了邮件标题上,方便我及时看见。

另一个问题是,无论标题还是正文,我们都应该考虑到可能存在出多门新成绩的可能;此外,当本地成绩文件不存在时,我们应当自动创建它。这些考虑能够提高代码的健壮性。

os处理代码如下:

f_path = 'Documents/grades.txt'  # 本地成绩信息存储路径(我用的Ubuntu,对,就是你现在访问的网站服务器系统)
if os.path.isfile(f_path):
    fo = open(f_path, "r+")
    grades2 = fo.read().split('\n')
    new_grades = set(grades).symmetric_difference(set(grades2))  # 集合相减
    if len(new_grades) != 0:
        response = session.get('https://jwxt.nwpu.edu.cn/student/for-std/student-portrait/getMyGpa?studentAssoc=270583', headers=headers)
        gpa = str(json.loads(response.text)['stdGpaRankDto']['gpa'])  # 获取GPA
        title = [','.join(x.split(', ')[0:3]) for x in new_grades]
        title = ';'.join(title)  # 标题中用分号隔开不同成绩信息
        content = "科目              成绩   绩点   学分\n" + \
                  "\n".join(new_grades) + \
                  f"\n成绩已更新,概要如上\n新的绩点诞生了:{gpa}\n继续加油~~~"
        email(title, content)  # email是我封装的邮件发送函数,详见文末完整代码
        fo.close()
        fo = open(f_path, "w")
        fo.write('\n'.join(grades))
else:
    fo = open(f_path, "w")
    fo.write('\n'.join(grades))
fo.close()

4. 定时运行

我们仍然使用老朋友crontab来帮助我们定时运行脚本,设置每天7点后间隔20分钟运行一次脚本:

*/20 7-23 * * * python3 /home/ubuntu/mypython/GradesMonitorLinux.py

程序效果

以下是我的部分邮件列表(我是用自己的邮箱给自己自发邮件):

成绩邮件列表

实测评估,“成绩实时监控系统”真正做到了“实时”二字,每次我了解成绩都快人一步,效果好于预期。

在这之后,我又用WordPress建立了这个网站,其中也不乏可道之处,有空时再来说说吧。

彩蛋

几天前从b站上我了解到了GPA排名的API,可惜现在那个视频被删了(估计是隐私泄露了?本文也如此,有技术者自然能推断我的身份),现分享如下。

打开教务系统-学生画像,打开F12开发者工具,刷新界面,在网络中找到“getMyGrades…”的请求,响应是json格式,其中既有gpa,也有相应排名,属性路径为stdGpaRankDto.rank

如果在界面中选择特定学期,还可以抓包到相应学期的gpa及排名,这里不便赘述。

至于你问我为什么不把排名写进邮件正文里,我感觉没必要。

附:完整代码

import json
import os.path
import re
import time
import requests
import smtplib
from requests import utils
from email.mime.text import MIMEText


def email(title, content):
    # 设置服务器所需信息
    # qq邮箱服务器地址
    mail_host = 'smtp.qq.com'
    # qq用户名
    mail_user = '***'
    # 密码(部分邮箱为授权码)
    mail_pass = '***'
    # 邮件发送方邮箱地址
    sender = '***'
    # 邮件接受方邮箱地址,注意需要[]包裹,这意味着你可以写多个邮件地址群发
    receivers = ['***']

    # 设置email信息
    # 邮件内容设置
    message = MIMEText(content, 'plain', 'utf-8')
    # 邮件主题'
    message['Subject'] = title
    # 发送方信息
    message['From'] = sender
    # 接受方信息
    message['To'] = receivers[0]

    # 登录并发送邮件
    smtpObj = smtplib.SMTP()
    # 连接到服务器
    smtpObj.connect(mail_host, 25)
    # 登录到服务器
    smtpObj.login(mail_user, mail_pass)
    # 发送
    smtpObj.sendmail(sender, receivers, message.as_string())
    # 退出
    smtpObj.quit()


url = "https://uis.nwpu.edu.cn/cas/login?service=https%3A%2F%2Fecampus.nwpu.edu.cn%2F%3Fpath%3Dhttps%3A%2F%2Fecampus.nwpu.edu.cn"
headers = {
    'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
    'accept-encoding': 'deflate, br',
    'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
    # 'cache-control': 'max-age=0',
    'referer': 'https://ecampus.nwpu.edu.cn/main.html',
    'sec-ch-ua': '".Not/A)Brand";v="99", "Google Chrome";v="103", "Chromium";v="103"',
    'sec-ch-ua-mobile': '?0',
    'sec-ch-ua-platform': '"Linux"',
    'sec-fetch-dest': 'document',
    'sec-fetch-mode': 'navigate',
    'sec-fetch-site': 'none',
    'sec-fetch-user': '?1',
    'upgrade-insecure-requests': '1',
    'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36'
}
session = requests.session()
try:
    response = session.get(url, headers=headers)
except requests.exceptions.ConnectionError as e:
    email('成绩实时监控系统:网络错误')
    exit()
response.encoding = 'utf-8'
str1 = re.search('var hmSiteId = "(.*?)"', response.text)
requests.utils.add_dict_to_cookiejar(session.cookies, {("Hm_lvt_" + str1.group(1)): str(int(time.time()))})
requests.utils.add_dict_to_cookiejar(session.cookies, {("Hm_lpvt_" + str1.group(1)): str(int(time.time()))})
execution = re.search('name="execution" value="(.*?)"', response.text)

data = {
    'username': '***',
    'password': '***',
    'currentMenu': '1',
    'execution': execution.group(1),
    '_eventId': 'submit',
    'geolocation': '',
    'submit': 'One moment please...'
}
session.post(url, data=data, headers=headers)  # received login coolie

url = 'https://jwxt.nwpu.edu.cn/student/sso-login'
session.get(url, headers=headers)
response = session.get('https://jwxt.nwpu.edu.cn/student/for-std/grade/sheet/semester-index/270583', headers=headers)
semester = re.findall('<option value="(.+?)"', response.text)
headers2 = {
    'Accept': '*/*',
    'Accept-Encoding': 'gzip, deflate, br',
    'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
    'Connection': 'keep-alive',
    'Host': 'jwxt.nwpu.edu.cn',
    'Referer': 'https://jwxt.nwpu.edu.cn/student/for-std/grade/sheet/semester-index/270583',
    'sec-ch-ua': '"".Not/A)Brand";v="99", "Google Chrome";v="103", "Chromium";v="103""',
    'sec-ch-ua-mobile': '?0',
    'sec-ch-ua-platform': '"Linux"',
    'Sec-Fetch-Dest': 'empty',
    'Sec-Fetch-Mode': 'cors',
    'Sec-Fetch-Site': 'same-origin',
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.124 Safari/537.36 Edg/102.0.1245.44".Not/A)Brand";v="99", "Google Chrome";v="103", "Chromium";v="103"',
    'X-Requested-With': 'XMLHttpRequest'
}
grades = []
for sem in semester:
    url = 'https://jwxt.nwpu.edu.cn/student/for-std/grade/sheet/info/270583?semester=' + sem
    response = session.get(url, headers=headers2)
    response = json.loads(response.text)['semesterId2studentGrades'][sem]
    for course in response:
        name = course['course']['nameZh']
        gaGrade = course['gaGrade']
        gp = str(course['gp'])
        credit = course['course']['credits']
        grades.append(f'{name}, {gaGrade}, {gp}, {credit}')

f_path = 'Documents/grades.txt'
if os.path.isfile(f_path):
    fo = open(f_path, "r+")
    grades2 = fo.read().split('\n')
    new_grades = set(grades).symmetric_difference(set(grades2))
    if len(new_grades) != 0:
        response = session.get('https://jwxt.nwpu.edu.cn/student/for-std/student-portrait/getMyGpa?studentAssoc=270583', headers=headers)
        gpa = str(json.loads(response.text)['stdGpaRankDto']['gpa'])
        title = [','.join(x.split(', ')[0:3]) for x in new_grades]
        title = ';'.join(title)
        content = "科目              成绩   绩点   学分\n" + \
                  "\n".join(new_grades) + \
                  f"\n成绩已更新,概要如上\n新的绩点诞生了:{gpa}\n继续加油~~~"
        email(title, content)
        fo.close()
        fo = open(f_path, "w")
        fo.write('\n'.join(grades))
else:
    fo = open(f_path, "w")
    fo.write('\n'.join(grades))
fo.close()