0%

爬取外校教务处成绩单

相比于外校,我校的教务处简直弱不禁风…

出于对网站的保护,本文略去了所有关于被爬网站的连接,以及一些关键步骤

0. 爬虫的自我修养

对爬虫重新拾起兴趣,主要还是因为无意间翻到了这篇博客
里面对爬虫和反爬虫和反反爬虫都有一些介绍,看得很热血。

有理由相信,一个正常用户能够正常访问的网站(反例:12306),基本上都可以使用爬虫爬取。
一些所谓的加密手段,实际上都是一种自卖自夸,无非是和定向爬虫在玩捉迷藏而已。
当然,滑块等新型图灵测试还是有一定防守价值的。

爬虫的自我修养:

  • 不爬取正常用户无法得到的数据和其他敏感数据
  • 不妨碍网站的正常运行,不攻击远程服务器

爬虫的简单伪装:

  • 伪装request header,例如使用和本地浏览器一样的header
  • 使用time.sleep(random.randrange())模仿人类访问网站

1. 自动登录的实现

与我校弱鸡sso明文登录的体质不一样的是,目标系统的登录post请求的password项是动态加密的。
某次登录的表单如下:

但在下一次登录中,又变成了这样:

观察到login action里有一个动态的magic string:

追根溯源找到SHA加密的JS文件:

由此可以猜测服务器保存了用户密码的明文,并通过随机生成magic string和不对称加密技术校验密文是否匹配。这种加密方式保护了密码的明文在网络的传播过程中不会暴露。
显然SHA加密代码有比较强的混淆性(至少对我这种不会JS的来说比较难读懂),但我们也不需要理解加密是如何完成的,只需要调用接口就行了。
在JS文件后追加函数,并调用Python中execjs库执行gao(magic_string+self.password)即可。

1
function gao(rua){
2
	return String(CryptoJS.SHA1(rua))
3
}

这里有个有趣的现象:为了得到func_A(fun_B(c))的效果,本来可以用分治的方法依次执行,
但似乎由于execjs在返回类型的时候必须和Python对接导致一些复杂的类型结构发生变化,而产生不符合预期的结果。
因此最后还是选择在JS中追加函数实现相应效果。让参数的传递只发生在JS内部。

此外,该登录系统中还有一些坑,会导致即使调用了相关方法还会触发验证码机制或提示密码错误的情况。

2. 自动查成绩脚本

在攻破登录系统后,一切都变得从心所欲起来。
观察到成绩查询的提交表单如下:

众所周知get请求的data是直接放在url里的。根据控制变量法,多次提交表格后得出结论:

  • “_”项的内容为时间戳
  • projectType、projectid项(几乎)是常数(后续找到了来源)
  • semesterid为magic number,为静态

本着自卖自夸精神查找数据来源,发现了另一个post中的response中有记录相应信息:

事实上,由于semesterid为静态,如果只需要定向爬取某一个学期的成绩,只需要写死即可。
但如果需要较好的封装(比如希望这个脚本可以不加完善就能爬取未来会出的成绩),那么就需要通过上述手段重定位semesterid

至此自动查成绩部分也就较好的完成了。

3. 自动发邮件

学过计算机网络的同学应该都至少听说过SMTP服务。
这里贴一个封装好的基于腾讯QQ邮箱SMTP服务的自动发邮件代码:

1
my_sender = ''  # 发件人邮箱账号
2
my_pass = ''  # 发件人邮箱口令
3
my_user = ''  # 收件人邮箱账号
4
5
def mail(title, content, coding_type, retry_flag=False):
6
    print('Mailing...', title)
7
    while True:
8
        try:
9
            msg = MIMEText(content, coding_type, 'utf-8')
10
            msg['From'] = formataddr(['发送人昵称', my_sender])
11
            msg['To'] = formataddr(['收件人昵称', my_user])
12
            msg['Subject'] = title
13
            server = smtplib.SMTP_SSL('smtp.qq.com', 465)
14
            server.login(my_sender, my_pass)
15
            server.sendmail(my_sender, [my_user, ], msg.as_string())
16
            server.quit()
17
        except Exception as err:
18
            print('in mailing ', str(err))
19
            if not retry_flag:
20
                break
21
            time.sleep(5)
22
            continue
23
        return

需要注意的是,发送方的邮箱口令不是邮箱密码,是开启SMTP服务后得到的key。
coding_type可以选择普通文本,也可以直接用HTML发送。

在这里可以直接把查询得到的HTML发送至目标邮箱即可。

4. 后续可以完善的地方

  • 更合理的登录:登录前检查页面是否出现验证码,并使用相关库识别验证码。
  • 精简邮件内容:检查爬取的成绩表单,使用相关库筛选需要的部分
  • 修改为挂机程序,每隔十分钟检查成绩单,差分后如果有变化则发送差分至邮箱。

5. 基本代码框架

略去了细节,只保留框架。

1
import requests
2
import time
3
import execjs
4
from sys import exit
5
import smtplib
6
from email.mime.text import MIMEText
7
from email.utils import formataddr
8
9
SHA_JS = '''
10
...
11
'''
12
13
my_sender = ''  # 发件人邮箱账号
14
my_pass = ''  # 发件人邮箱口令
15
my_user = ''  # 收件人邮箱账号
16
17
18
def mail(title, content, coding_type, retry_flag=False):
19
    print('Mailing...', title)
20
    while True:
21
        try:
22
            msg = MIMEText(content, coding_type, 'utf-8')
23
            msg['From'] = formataddr(['发送人昵称', my_sender])
24
            msg['To'] = formataddr(['收件人昵称', my_user])
25
            msg['Subject'] = title
26
            server = smtplib.SMTP_SSL('smtp.qq.com', 465)
27
            server.login(my_sender, my_pass)
28
            server.sendmail(my_sender, [my_user, ], msg.as_string())
29
            server.quit()
30
        except Exception as err:
31
            print('in mailing ', str(err))
32
            if not retry_flag:
33
                break
34
            time.sleep(5)
35
            continue
36
        return
37
38
39
class Client:
40
    def __init__(self, username, password):
41
        self.session = requests.session()
42
        self.session.headers = {}
43
        self.username = username
44
        self.password = password
45
        self.SHA_Hacker = execjs.compile(SHA_JS)
46
        self.salt = ''
47
        self.data = {}
48
49
    def login(self):
50
		...
51
        if ret.text.find('我的学籍') != -1:
52
            print('登录成功!')
53
        elif ret.text.find('验证码不正确') != -1:
54
            print('需要验证码!请五分钟后重试')
55
        elif ret.text.find('密码错误') != -1:
56
            print('密码错误')
57
        else:
58
            print(ret.text)
59
            exit(0)
60
        time.sleep(2)
61
62
    def run(self):
63
        ...
64
        if ret.text.find('课程英文名称') == -1:
65
            print('查成绩失败')
66
            exit(0)
67
        mail('成绩单', ret.text, 'html', True)
68
69
70
if __name__ == '__main__':
71
    x = Client('username', 'password')
72
    x.login()
73
    x.run()