秋季学期即将到来,各大高校陆续公布新课表、期末成绩、定时开放抢课,但高校的网络一向是一言难尽。针对没有开放接口的教务系统,如何实时查询课表、成绩,如何获取一手选课系统开放情况、课程剩余量,如何第一时间抢到心仪限选课程,之前我曾在个人博客上用 Java 针对青果和强智科技开发的教务系统实现了成绩查询和抢课功能,本次针对新需求,用Python整活儿,带大家梳理各大教务管理系统功能实现的基本逻辑,抛砖引玉,以便大家举一反三。
温馨提示:请各位遵守学校的各项规定,请勿通过重复发包等操作干扰教务系统的正常运行。
步骤梳理:
- 处于登陆状态
- 查课表/查成绩/抢课
- 收到服务端反馈
- 解析数据,提升可读性
- 具体应用(抢课;定时监测成绩、课表;获取选课系统开放情况、课程剩余量;API)
其中需要强调的是所有步骤中可变性最大的一环,即第一步。所谓处于登陆状态
,应当与登陆教务系统
相区别,尽管登陆教务系统
其目的在于处于登陆状态
,但如果我们发现教务系统可以长期保持登陆状态
,那么只需要在编写程式的过程中手动登陆一次教务系统获取其cookie值保存至源码中即可,此后保持cookie值的有效即可而无需每次执行登陆教务系统
操作。
而“处于登陆状态”,常常能成功劝退一批人,劝退原因主要有:
- 其一,针对查询操作带有时间戳的教务系统,难以长期保持登陆状态;
- 其二,即使能够长期保持登陆状态,受单地登陆影响,如用户手动登陆系统,原cookie值失效,源码失效需要复写;
- 其三,登陆页面常常设置了多重防护;
对此解决方法也很简单,那就是 对症下药。
思路分析:
首先我们看看今天的教务管理系统(这里就不透露具体是哪家了),分析如何通过验证并登陆
,思考路径可以从以下几点入手:
- 是否能够使用固定cookie值长期保持登陆状态,以绕过登陆验证页面;
- 是否考虑通过selenium 模拟浏览器操作实现动态HTML处理,登陆页面
- 是否能够通过模拟post请求通过验证并登陆
经测试,前两种方法在该教务系统的应用上都存在缺陷,且实现方法较为简单,这里为了方便大家举一反三,我就选择第三个方案,使用普遍更为常用的即通过模拟post请求的形式实现登陆需求。
其次,在成功登陆后,同样使用post发包的形式获取(发送)需求中的相关信息(行为)。
最后则是分析数据包,清洗数据,提升可读性,设计交互式io界面,实现课表、期末成绩查询、监测选课开放情况、课程剩余量以及抢课等功能。
一、登陆
(1)分析post请求
模拟登陆抓包后分析请求:
Flag
是包类型,固定值为Login
,username
、password
是用户名
和密码
,ddlUserClass
是身份类型
,用于数据库匹配,code1
、ImageButton2.x
、ImageButton2.y
与验证码
相关,此外附带了两个值:__VIEWSTATE
、__VIEWSTATEGENERATOR
。
报文分析可知,确定变量有5,分别是 __VIEWSTATE
、__VIEWSTATEGENERATOR
以及 code1
、ImageButton2.x
、ImageButton2.y
分析页面后不难发现,每次页面载入后, __VIEWSTATE
和__VIEWSTATEGENERATOR
的值将会通过form表单的形式体现在源码中。而多次发包后能够确定ImageButton2.x
、ImageButton2.y
的重合值。
基于上述分析,原本复杂的问题就已经迎刃而解了。先梳理清楚操作步骤和目标,并引入相关环境变量:
- 获取
__VIEWSTATE
和__VIEWSTATEGENERATOR
的值; - 获取并识别验证码,生成
code1
- 拼接 post 包
- 发包
- 获取服务器状态响应
#!C:\Python34\python3.exe
#!/usr/bin/python
# -*- coding: UTF-8 -*-
import requests
import urllib.request
import urllib.parse
from PIL import Image # 用于打开图片和对图片处理
import pytesseract # 用于图片转文字
import re
from bs4 import BeautifulSoup
import json
import time
(1)获取 __VIEWSTATE
以及 __VIEWSTATEGENERATOR
的值
前面我们已经发现,__VIEWSTATE
和__VIEWSTATEGENERATOR
的值在每次页面载入后将会通过form表单的形式体现在源码中,那就直接用正则表达式截取内容:
首先,created
、date
以及 id
的值只有在登陆后才能获取,因此获取网页源码时需要带上 Cookie 值和必要的请求头。
url = '手动打码/Login.aspx'
def get_hiddenvalue():
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:74.0) Gecko/20100101 Firefox/74.0",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2",
"Accept-Encoding": "gzip, deflate",
"Content-Type": "application/x-www-form-urlencoded",
"Origin": "手动打码",
"Connection": "keep-alive",
"Referer": "手动打码",
"Upgrade-Insecure-Requests": "1"
}
main = session.get(url, headers=headers)
gb_headers = main.headers
会话对象requests.Session能够跨请求地保持某些参数,比如cookies,即在同一个Session实例发出的所有请求都保持同一个cookies,而requests模块每次会自动处理cookies,这样就很方便地处理登录时的cookies问题。因此发包使用的是
session.get(url, headers=headers)
,后文同理。
其次,上正则表达式:
VIEWSTATE = re.findall(r'<input type="hidden" name="__VIEWSTATE" id="__VIEWSTATE" value="(.*?)" />', main.text,re.I)
VIEWSTATEGENERATOR = re.findall(r'input type="hidden" name="__VIEWSTATEGENERATOR" id="__VIEWSTATEGENERATOR" value="(.*?)" />', main.text,re.I)
这里需要注意的是,每次页面刷新后验证码的值也将改变,因此在返回__VIEWSTATE
和__VIEWSTATEGENERATOR
的值同时,还需要返回post请求发送后服务器返回的Set-Cookie
值,以便于后续验证码的获取:
return VIEWSTATE[0], VIEWSTATEGENERATOR[0], gb_headers
#test = get_hiddenvalue()
#print(test[0]) #VIEWSTATE
#print(test[1]) #VIEWSTATEGENERATOR
#print(test[2]) #gb_headers
#print(test[2][“Set-Cookie”]) #gb_headers
(2)获取 code1
的值
- 获取并保存验证码:
正如上文说述,每次页面刷新后验证码的值也将改变,因此发包需要用到第一次请求时的cookie值:
url2 = '手动打码/Image.aspx'
def get_pic():
# 验证码请求头
headers2 = {
"User-Agent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:74.0) Gecko/20100101 Firefox/74.0",
"cookie": "varPartNewsManage.aspx=10" + test[2]["Set-Cookie"]
}
re_pic = requests.get(url2, headers=headers2)
response = re_pic.content
#print(url2)
#print(re_pic.request.headers)
#request = urllib.session.Request(url=url)
# request = urllib.request.Request(url=url2,headers=headers2)
# response = urllib.request.urlopen(request)
# main = response.read()
# response = session.get(url2,headers=headers2)
# print(response.request.headers)
# main = response.content
# print(main)
file = "C:\\Users\\john\\Desktop\\1\\" + ".png"
playFile = open(file, 'wb')
playFile.write(response)
playFile.close()
- 识别验证码:
def recognize_captcha(img_path):
im = Image.open(img_path)
num = pytesseract.image_to_string(im)
return num
#print(pic_res) #验证码识别结果
(3)拼接 post 包、发包、收包
def post_login():
headers3 = {
"User-Agent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:74.0) Gecko/20100101 Firefox/74.0",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2",
"Accept-Encoding": "gzip, deflate",
"Content-Type": "application/x-www-form-urlencoded",
"Origin": "手动打码",
"Connection": "keep-alive",
"Referer": "手动打码",
"Upgrade-Insecure-Requests": "1",
"cookie": "varPartNewsManage.aspx=10;" + test[2]["Set-Cookie"]
}
data = {"__VIEWSTATE": test[0],
"__VIEWSTATEGENERATOR": test[1],
"Flag": "Login",
"username": "手动打码",
"password": "手动打码",
"ddlUserClass": "1",
"code1": pic_res,
"ImageButton2.x": "64",
"ImageButton2.y": "10"}
res = session.post(url=url,data=data,headers=headers3)
#print(res.request.headers) #核验cookie是否有效带上
#print(res.text)
二、查课表、查成绩、抢课等操作
在成功登陆系统后,具体操作就很容易了,分析post请求后自行拼装即可:
(1)查课表、抢课功能实现
针对简单查询或是单纯发送post请求,实现原理就是发包,以查课表为例:
def DisplayCourseTable():
headers4 = {
"Host": "手动打码",
"User-Agent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:74.0) Gecko/20100101 Firefox/74.0",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2",
"Accept-Encoding": "gzip, deflate",
"Connection": "keep-alive",
"Referer": "手动打码",
"Upgrade-Insecure-Requests": "1",
"cookie": "varPartNewsManage.aspx=10;" + test[2]["Set-Cookie"]
}
CourseTable = session.get(url=DisplayCourseTableURL,headers=headers4)
#print(Score.request.headers) #核验cookie是否有效带上
#print(CourseTable.text)
return CourseTable.text
(2)查成绩功能实现
相比于查课表和抢课,成绩查询有其特殊性,一方面成绩列表随着课程的增加持续变动,另一方面在此教务系统中,post请求提交后服务器响应结果是html,基于此需要通过正则表达式获取所需数值,因此这里展开讲一下查成绩功能实现。
def Score_TO_Table():
bs = BeautifulSoup(CourseScoreView(), "html.parser")
#print(bs.tbody)
result = []
allrows = bs.tbody.findAll('tr') #提取表格
for row in allrows:
result.append([])
allcols = row.findAll('td')
for col in allcols:
thestrings = [str(s) for s in col.findAll(text=True)]
thetext = ''.join(thestrings)
result[-1].append(thetext)
#print(type(thestrings))
new_result = [[s.replace(' ', '',) for s in x] for x in result] #去除空格
print(new_result)
需要的信息是课程名称
以及成绩
:
succssed_a = []
succssed_b = []
#print(len(new_result))
for i in range(1,len(new_result)-3): #1开头去第一行<tr>标题,最后三行无数据
#print("{}: {} ".format(new_result[i][2],new_result[i][9]))
a = "".join(new_result[i][2].split()) #去除new_result中的 \r\n\xa0
b = "".join(new_result[i][9].split())
succssed_a.append(a)
succssed_b.append(b)
succssed = dict(zip(succssed_a,succssed_b)) #将列表变成字典
print(succssed)
转换成 json 格式:
jsoninfo = json.dumps(succssed,indent=4,ensure_ascii=False,sort_keys=True,separators=(",",":")) #将字典转为json
print(jsoninfo)
最后再调整一下格式:
if __name__ == '__main__':
session = requests.Session()
test = get_hiddenvalue()
get_pic()
pic_res = recognize_captcha("C:\\Users\\john\\Desktop\\1\\" + ".png")
#print(pic_res) # 验证码识别结果
post_login()
Score_TO_Table()
input()
三、拓展应用
(1)定时监测成绩、课表或选课系统开放情况、课程剩余量等
这类操作主要可以分为两大类,一类是直接发送post请求服务器即可返回结果的,一类是监测是否存在预定数值,当出现后自动通报的。如在选课情境下,无人值守自动补选监听可以写作:
def Lisen():
time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
headers4 = {
"Host": "手动打码",
"User-Agent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:74.0) Gecko/20100101 Firefox/74.0",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2",
"Accept-Encoding": "gzip, deflate",
"Connection": "keep-alive",
"Referer": "手动打码",
"Upgrade-Insecure-Requests": "1",
"cookie": "varPartNewsManage.aspx=10;" + test[2]["Set-Cookie"]
}
CourseTable = session.get(url=DisplayCourseTableURL,headers=headers4)
str = "监听内容"
if str in CourseTable.text:
print("已检测到" + "\n" + time)
mail() #邮件提醒
global num
num += 1
else:
print("暂无" + "\n" + time)
可以选择通过简单邮件传输协议方式监听结果:
my_sender = '···' # 发件人邮箱账号
my_pass = '···' # 发件人邮箱密码
my_user = '···' # 收件人邮箱账号,我这边发送给自己
def mail():
ret = True
try:
msg = MIMEText('邮件内容:'+resp.text, 'plain', 'utf-8')
msg['From'] = formataddr(["···", my_sender])
msg['To'] = formataddr(["···", my_user])
msg['Subject'] = "邮件主题:"+resp.text
server = smtplib.SMTP_SSL("smtp.qq.com", 465)
server.login(my_sender, my_pass)
server.sendmail(my_sender, [my_user, ], msg.as_string())
server.quit()
except Exception:
ret = False
return ret
ret = mail()
if ret:
print("邮件发送成功")
else:
print("邮件发送失败")
可以设置监测周期等等:
if __name__ == '__main__':
schedule.every().day.at("12:00").do(mail)
if __name__ == '__main__':
while True:
try:
Lisen()
time.sleep(300)
if num == 6:
break
except Exception as err:
print(err)
(2)制作api开放查询接口
如通过与微信小程序结合等方式拓展其可用性。
总之,在打通教务管理系统,实现相应功能之后,相关拓展应用就很多了,在此就不一一具体例举。
至此,本文也就进入尾声了。本文的撰写来自于开发中的一点心得体会,基于Python,主要目的是帮大家梳理了在教务系统没有公开接口的情况下,如何实时查询课表、成绩,获取一手选课系统开放情况、课程剩余量,第一时间抢到心仪限选课程,供对这一领域感兴趣的读者以参考借鉴。希望本文能够起到抛砖引玉之效,也欢迎大家的批评交流。
如果您有任何疑问或者好的建议,期待你的留言、评论与关注!