妈妈再也不用担心我忘记填报疫情——巧用云函数完成自动化数据填报 - Pinming's Blog

一个适用于某西北 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):
# 在 `NWPU_Yqtb_Site` 实例中,首先建立一个 session 并对登录页发起 get:
self.session = requests.Session()
# 从 CAS 登录页作为登录过程起点
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:

提取以上信息,打包第一组 headerdata,然后对 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])),
# [0] 是生成的 `SESSION`
'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_initdata_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
# 在 `/wx/ry/jrsb.jsp` 页面中,获取 `timeStamp` & `sign`。
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]

# 在 `/wx/ry/jrsb.jsp` 页面中,获取 `param_data`。
param_data_str = re.findall(re.compile('var paramData = (.*?);'),
res_jrsb.text)[2]
# 再在 `param_data` 中获取 `name`、`xymc`、`xssjhm` 三个信息。
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]

# 在 `wx/xg/yz-mobile/rzxx_list.jsp` 中,获取 `szcsmc`,并查 `location.py` 得 `szcsbm`
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

1.4 获取 Form 信息

先手动在浏览器上完成一次健康申报过程,抓包观察 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,
}

这里面我们会发现一些变量需要动态地从疫情填报网站中获取,例如 nameszcsbmszcsmc 等,而刚刚「1.3.2 初始化」的步骤就是为了得到这些它们。

1.5 提交 Form 信息

通过上述操作,我们已经得到了填表所需要动态获取的各个变量。接下来向 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
# `self.judge_last_report_is_today(
# self.get_last_report_time(data_for_init, header_for_init))` 的意义:
# 判断系统当前日期是否与最后一次填报的日期一致。
# 如条件为 `False`,则不一致,认为今日未填报,执行 POST;
# 否则跳过 POST 步骤,不再重复填报。
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 云端部署

这里以阿里云函数计算为例。

  1. 首先注册一个阿里云账号,然后在控制台中搜索并进入「函数计算」;

  2. 点击「服务及函数」;

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

  4. 点击「创建函数」;

  5. 按图示填入参数;

    其中:

    • 「名称」可自定义;
    • 「运行环境」选择 Python 3
    • 其他选项不变;
    • 内存规格 128MB 已经足够。

  6. 跳转进入「函数详情」页面,在打开的 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`

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

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

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

  10. 测试正常后,加入触发器以保证周期触发程序。在页面上方打开「触发器管理」,点击「创建触发器」。

其中:

  • 触发器类型选择定时触发器;
  • 名称可自定义;
  • 触发方式可以选择自己认为合适的方式;
  • 如触发方式选择了 CRON 表达式,表达式可填写为 CRON_TZ=Asia/Shanghai 0 0 1,7,13,19 * * ?
    该表达式即在北京时间每天的 1:00、7:00、13:00、19:00 各执行一次程序。

至此便可以实现确定时间间隔的每日自动健康填报。

收到的微信推送效果如下:

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

关于潜在的收费提示

在建立函数过程中,你可能会发现关于该功能的收费提示。
本函数的请求量、请求时间及耗费公网流量均极小,每天执行三四次水平的请求的花费可以说近似是 0(考虑到会产生公网流量,费用可能是 0 一直写到两位以上小数的水平),请放心。大不了别充钱嘛!
具体收费标准详见:https://help.aliyun.com/document_detail/54301.html

0xFF 后记

这算是我个人第一次爬虫的尝试吧…… 也算是又一次感受到了 Python 的魅力。将整个过程记录下来也算是一种学习和巩固了。

至于代码本身以及这篇文章写得是相当混乱,可读性是不咋滴【滑稽】,很可能逻辑上也不怎么简洁高效。也希望各位看官多多包涵了,欢迎批评指正!

后期应该会增加对于 ServerChan 微信推送的支持吧,毕竟这个比 Email 好用多了。

最后,还是期望全人类包括美国能够早日战胜 COVID-19,这个程序早一天失去它的用武之地吧~

评论



Powered by Hexo.

博客内容遵循 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 协议

本站使用 Volantis 为主题 | 总访问量为
© Pinming 2019-2015 | All Rights Reserved.
载入天数...载入时分秒...
粤 ICP 备 19139605 号
粤公网安备 44030502004717 号