一个适用于某西北 985 高校「中国工科的第一大学」的疫情身体状况自动填报程序思路分享及其使用方法。
本文最近一次更新时间为:2022 年 1 月 13 日。
0x00 前言
在新冠疫情的背景之下,想必很多学校都要求学生每日申报自己的健康状况。但是日子一天天过去总有忘记的时候——轻则全班公开处刑,重则全院公开处刑,尴尬至极。后来接入了企业微信,如果不填每天中午 11:30 又准时来催你填……每天还得去对着表格整几下,实在有些麻烦,遂考虑制作一个自动化填报的程序来减轻自己的工作量。
本项目 Github 仓库:
如果 Github 访问异常,请浏览 Gitee 镜像。
警告 / 请认真阅读本部分
- 本软件设计之本意为技术学习,请在遵循法律及学校各项规定的前提下使用本软件。
- 如您需要使用该软件,请确保您的身体状况良好,如实申报自身身体状况。
- 若您的身体状况出现异常,应立即停止使用本软件、关闭云函数自动触发功能,并及时于学校系统更改每日申报情况。
- 因使用该软件误报身体状况而引发的不良后果应由您自行承担。
- 本软件原理是提取上一次的填报结果来提交,如果您的所在地发生改变,请自行手动填报一次,理论上程序会自动跟进后续的填报并与之同步。如出现异常烦请反馈!
- 该软件并非万能,请时常检查填报结果!
请注意 / 在正文开始之前
- 软件更新至 `v2.0` 后,不再考虑本地部署需求。程序中所有设计均为云函数执行而准备;
- 关于 Server 酱推送,详见本系列第二篇;
- 关于 2021 年底疫情填报系统及本软件的改动,详见本系列第三篇;
0x01 代码实现
1.1 整体流程分析
本程序涉及的全局 URL:
1 2 3 4
| url_jrsb = 'http://yqtb.nwpu.edu.cn/wx/ry/jrsb.jsp' # 获取表格并进行操作 url_ry_util = 'http://yqtb.nwpu.edu.cn/wx/ry/ry_util.jsp' # 用于 POST 申报的内容 url_cas_login = 'https://uis.nwpu.edu.cn/cas/login' # 用于 Validate 登录状态 url_rzxx_list = 'http://yqtb.nwpu.edu.cn/wx/xg/yz-mobile/rzxx_list.jsp' # 日报列表
|
先定义一个疫情填报系统的实例,将所有稍后填报需要的属性封装在其中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| class NWPU_Yqtb_Site(object): def __init__(self): self.session = requests.Session() self.session.get(url_cas_login) self.name = "" self.xymc = "" self.xssjhm = "" self.szcsbm = "" self.szcsmc = "" self.hsjc = "" self.sign = "" self.timeStamp = "" self.data_for_submit = None
|
要实现信息的填报,可以发现全流程大体需要经过四个步骤:CAS 登录
→传递 Cookie 到疫情填报系统
→获取上一次填报的 Form(初始化填报数据)
→提交 Form
。接下来将分步说明每个步骤的实现。
1.2 模拟 CAS 登录
首先登入填报系统的主页:https://yqtb.nwpu.edu.cn
。发现在未登录时,其跳转至 https://uis.nwpu.edu.cn/cas/login?service=http%3A%2F%2Fyqtb.nwpu.edu.cn%2F%2Fsso%2Flogin.jsp%3FtargetUrl%3Dbase64aHR0cDovL3lxdGIubndwdS5lZHUuY24vL3d4L3hnL3l6LW1vYmlsZS9pbmRleC5qc3A%3D
这一页面,该页面用于填入用户名及密码,这些信息将被打包 POST 出去,由 UIS 接受后跳转到 yqtb 的 service。
为了更容易地获取登录状态,从 CAS 登录网址 url_cas_login
无跳转地发起登录,直接获得登录状态,与 Form 的 submit 过程分开。
通过抓包发现,在 uis.nwpu.edu.cn
登录时,CAS 首先接到第一组 POST,同时前端生成了 SESSION
作为第一个 cookie:

提取以上信息,打包第一组 header
和 data
,然后对 url_cas_login
模拟 POST:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| header = { 'referer': url_cas_login, 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_16_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.26 Safari/537.36', 'content-Type': 'application/x-www-form-urlencoded', 'origin': 'https://uis.nwpu.edu.cn', 'cookie': 'SESSION=' + str((session.cookies.values()[0])), 'authority': 'uis.nwpu.edu.cn', 'path': '/cas/login', 'scheme': 'https', 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', 'upgrade-insecure-requests': '1', 'sec-fetch-dest': 'document', 'sec-fetch-mode': 'navigate', 'sec-fetch-site': 'same-origin', 'sec-fetch-user': '?1', 'cache-control': 'no-cache' } data = { 'username': username, 'password': password, '_eventId': 'submit', 'currentMenu': '1', 'execution': 'e2s1', 'submit': 'One moment please...', 'geolocation': '', }
|
之后 url_cas_login 会如图返回登录状态。如果登录成功成功则返回文字内容:欢迎使用 统一身份认证 系统
。这里直接在网页中查找这个字段来判断登录是否成功:

2020 年 8 月 6 日翱翔门户进行了微调,返回登录状态页面只返回欢迎使用
四个字。因此下方代码块第三行需要对应修改为:
1
| if rt.find('欢迎使用') != -1:
|
1 2 3 4 5 6 7 8
| res_login = self.session.post(url_cas_login, data=data_for_login, headers=header_for_login) if (res_login.text.find('欢迎使用')) != -1: print('登陆成功!') else: print('登录失败!请检查「登录信息」一栏用户名及密码是否正确') if user_config.SC_switcher == 1: Pusher.sc_push_when_login_failed(self) exit()
|
至此我们已经成功登入学校 CAS 系统,接下来需要到 yqtb.nwpu.edu.cn
进行健康状况的申报。
1.3 填报信息的初始化
1.3.1 准备工作(获取 Cookie)
在来到疫情填报页面前,我们还需要观察 url_cas_login
在登录成功后返回的 cookie:它携带了此前生成的第一个 cookie SESSION
,且又生成了第二个 cookie TGC
。

利用 session.cookies.values()
来得到已经获取的 cookie 列表,分别存入 header。
紧接着访问疫情填报页面 url_jrsb
,发现又生成了第三个 cookie JSESSIONID
。这里先将其储存到 header_for_init
,否则后续在 yqtb.nwpu.edu.cn
执行的步骤会因为缺少 cookie 传递而失败。

在代码中即是先伪造一次对 url_Form
的请求简单来说就是「我就蹭蹭不进去」,目的是为了「骗」到 JSESSIONID
。
至此,全流程所需的三个 cookie 都已经获取成功,这样就可以为接下来的初始化信息与 POST 表单所用。
这时可以正式登入疫情填报页面。通过抓包发现,这里需要此前获得的 TGC
作为 ticket 来从 UIS 跳转到 yqtb,同时 header
需要携带 JSESSIONID
。故构造 header_for_init
和 data_for_init
来 POST:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| header_for_init = { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_16_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.26 Safari/537.36', 'Hsost': 'yqtb.nwpu.edu.cn', 'cookie': 'JSESSIONID=' + str((self.session.cookies.values()[2])), 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', 'upgrade-insecure-requests': '1', 'cache-control': 'no-cache' } data_for_init = { 'ticket': str((self.session.cookies.values()[1])), 'targetUrl': 'base64aHR0cDovL3lxdGIubndwdS5lZHUuY24vL3d4L3hnL3l6LW1vYmlsZS9pbmRleC5qc3A=', }
|
之后就可以获取其他的信息进行初始化了。
1.3.2 初始化
请注意
在阅读「1.3.2 初始化」前,建议先阅读「1.4 获取 Form 信息」来熟悉疫情填报表单中所需要动态获取的属性。该步骤的意义在于获取上一次填报的数据。
这一部分通过解析 /wx/ry/jrsb.jsp
中 js 包含的部分信息,来提取稍后在表单中需要被 POST 出去的数据。
将这些值赋予填报实例 self
的对应属性,方便后续操作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| res_jrsb = self.session.post(url_jrsb, data=data_for_init, headers=header_for_init) self.timeStamp = re.findall(re.compile('(?<=&timeStamp=).*(?=\')'), res_jrsb.text)[0] self.sign = re.findall(re.compile('(?<=sign=).*(?=&)'), res_jrsb.text)[0]
param_data_str = re.findall(re.compile('var paramData = (.*?);'), res_jrsb.text)[2]
self.name = re.findall(re.compile('(?<=userName:\').*(?=\',szcsbm)'), param_data_str)[0] self.xymc = re.findall(re.compile('(?<=xymc:\').*(?=\',)'), param_data_str)[0] self.xssjhm = re.findall(re.compile('(?<=xssjhm:\').*(?=\')'), param_data_str)[0]
rzxx_list_str = self.session.post(url_rzxx_list, data=data_for_init, headers=header_for_init).text soup = BeautifulSoup(rzxx_list_str, 'html.parser') loc_name = soup.find("span", attrs={"class": "status"}).string self.szcsmc = loc_name loc_code = location.get_location(loc_name) if loc_name == "在西安": self.szcsbm = "2" elif loc_name == "在学校": self.szcsbm = "1" else: self.szcsbm = loc_code[0] if self.szcsbm == "" and loc_name != "在西安" and loc_name != "在学校": print( "获取上一次填报的信息时出现错误!" + "\n" + "请联系作者(通过 Github Issue 或邮箱:i@pm-z.tech)并附上信息填报网站「个人中心→我的打卡」页面的截图,便于定位问题!" ) if user_config.SC_switcher == 1: Pusher.sc_push_when_wrong_info(self) exit()
self.hsjc = self.get_last_hsjc_status(data_for_init, header_for_init)
|
需要注意的是 szcsbm
的获取,这个数据没有办法通过前端获取的 html 来解析,因此需要通过 location.GetLocation(loc_name)
来进行匹配。因为返回的是一个 list
,所以还要把它转换为 str
。
先手动在浏览器上完成一次健康申报过程,抓包观察 POST 出去的数据,然后在代码中打包。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| header_for_submit = { "Host": "yqtb.nwpu.edu.cn", "Origin": "http://yqtb.nwpu.edu.cn", "Referer": "http://yqtb.nwpu.edu.cn/wx/ry/jrsb.jsp", "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "Cookie": "JSESSIONID=" + str((self.session.cookies.values()[2])), "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_16_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.26 Safari/537.36", } self.data_for_submit = { 'actionType': 'addRbxx', 'userLoginId': self.username, 'sfjt': '0', 'sfjcry': '0', 'sfjcqz': '0', 'sfjkqk': '0', 'sfyzz': '0', 'sfqz': '0', 'glqk': '0', 'tbly': 'sso', 'userType': '2', 'userName': self.name, 'bdzt': '1', 'xymc': self.xymc, 'xssjhm': self.xssjhm, 'szcsmc': self.szcsmc, 'szcsbm': self.szcsbm, 'hsjc': self.hsjc, }
|
这里面我们会发现一些变量需要动态地从疫情填报网站中获取,例如 name
、szcsbm
、szcsmc
等,而刚刚「1.3.2 初始化」的步骤就是为了得到这些它们。
通过上述操作,我们已经得到了填表所需要动态获取的各个变量。接下来向 url_ry_util
post 整个 form,也就是 1.4 部分中所提到的 form。
篇幅所限这里就省略了,看上面 1.4 吧。
1 2
| HeadersForm = {...} tbDataForm = {...}
|
POST 完以后,不出意外学校的服务器已经收到了我们填报的数据,接下来需要对结果进行分析。
1.6 填报结果判定
self.judge_last_report_is_today(self.get_last_report_time(data_for_init, header_for_init))
的意义:
判断系统当前日期是否与最后一次填报的日期一致。如条件为 False
,则不一致,认为今日未填报,执行 POST;否则跳过 POST 步骤,不再重复填报。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
|
if not (self.judge_last_report_is_today(self.get_last_report_time(data_for_init, header_for_init))): url_ry_util_with_token = url_ry_util + '?sign=' + self.sign + '&timeStamp=' + self.timeStamp self.session.post(url=url_ry_util_with_token, data=self.data_for_submit, headers=header_for_submit) else: print('今日已填报,无需重复填报!') return
if (self.judge_last_report_is_today(self.get_last_report_time(data_for_init, header_for_init))): print('申报成功!') if user_config.SC_switcher == 1: Pusher.scPush(self) else: print('申报失败,请重试!') if user_config.SC_switcher == 1: Pusher.sc_push_when_wrong_info(self)
def judge_last_report_is_today(self, report_time): report_time = self.string_toDatetime(report_time) if report_time.date() == datetime.today().date(): return True else: return False
def get_last_report_time(self, data, header): last_report_time = \ etree.HTML(self.get_last_report(data, header)).xpath( '/html/body/div[1]/div[2]/div/div[2]/div[1]/div[2]/text()')[0] return last_report_time
|
0x03 程序配置
使该程序正确运行,需要编辑 user_config.py
中的部分变量,配置西工大翱翔门户账号、邮箱信息(可选)。其他文件无需改动。
user_config.py
需要设置的变量如下:
变量 |
说明 |
username |
填入登录翱翔门户的用户名,通常为学工号 |
password |
填入对应用户的密码 |
SC_switcher |
ServerChan 微信推送服务开关,默认开启服务,赋值为 1 ;填 0 则关闭; 如果关闭了该服务则不需要配置 SCKey 。 |
SCKEY |
ServerChan 微信推送服务对应的 Key,用于绑定自己的微信。 |
0x04 云端部署
这里以阿里云函数计算为例。
-
首先注册一个阿里云账号,然后在控制台中搜索并进入「函数计算」;

-
点击「服务及函数」;

-
选择「创建服务」并输入服务名称(可自定义);

-
点击「创建函数」;

-
按图示填入参数;
其中:
- 「名称」可自定义;
- 「运行环境」选择
Python 3
;
- 其他选项不变;
- 内存规格 128MB 已经足够。

-
跳转进入「函数详情」页面,在打开的 IDE 终端中,分步执行如下命令安装源码及所需第三方库:
1 2 3
| git clone https://github.com/Pinming/NWPU_COVID19_AutoReport.git mv ./NWPU_COVID19_AutoReport/* . pip install -r requirements.txt -t `pwm`
|

-
在 IDE 中修改 user_config.py
,填入相关字段;

-
设置完毕后,点击 IDE 右上角「保存并部署」,完成代码部署;

-
点击左上角「测试函数」,观察运行结果;

-
测试正常后,加入触发器以保证周期触发程序。在页面上方打开「触发器管理」,点击「创建触发器」。
其中:
- 触发器类型选择定时触发器;
- 名称可自定义;
- 触发方式可以选择自己认为合适的方式;
- 如触发方式选择了 CRON 表达式,表达式可填写为
CRON_TZ=Asia/Shanghai 0 0 1,7,13,19 * * ?
该表达式即在北京时间每天的 1:00、7:00、13:00、19:00 各执行一次程序。

至此便可以实现确定时间间隔的每日自动健康填报。
收到的微信推送效果如下:

如果需要关闭云端的自动填报,在「触发器」菜单中关闭即可。

0xFF 后记
这算是我个人第一次爬虫的尝试吧…… 也算是又一次感受到了 Python 的魅力。将整个过程记录下来也算是一种学习和巩固了。
至于代码本身以及这篇文章写得是相当混乱,可读性是不咋滴【滑稽】,很可能逻辑上也不怎么简洁高效。也希望各位看官多多包涵了,欢迎批评指正!
后期应该会增加对于 ServerChan 微信推送的支持吧,毕竟这个比 Email 好用多了。
最后,还是期望全人类包括美国能够早日战胜 COVID-19,这个程序早一天失去它的用武之地吧~