我为什么这么闲

由于我热爱学习而且充满好奇心,所以经常要登录教务系统查看有没有出新成绩,每次都要输恶心的验证码,后来我还是忍不住了… 决定写一个爬虫自动获取成绩,放到服务器跑去,每十分钟查一次,查到新的成绩就发邮件通知自己。

实际上这事我也不是第一次干了,上一次是一年前,大一下考试月的时候,考试之前不想复习,就想出这么个东西。然后用Java写了一个这样的功能。不过那时候写的比现在还丑,自己也看不懂,基本是抄几个模块自己做胶水。

总结一下当时的程序,有几个模块:

  1. 获取登录教务系统表单,下载验证码
  2. 使用Tesseract自动识别验证码
  3. 模拟登录教务系统
  4. 访问成绩页面,并保存到本地
  5. 打开文件,正则匹配(比如查高数下就匹配类似 “高等数学II”的字样,来判断是不是出成绩了)
  6. 如果有新成绩则发邮件通知

缺点:

  1. 代码一大半都是copy的,自己拼接起来,不是很理解原理
  2. 正则写的很丑,通过是否有“高等数学”字样来判断,可扩展性太差,要查询别的还得重写正则表达式
  3. Java写的,代码一坨(没错这就是缺点(认真脸
  4. 很多…不想吐槽了

Ruby大法

前段时间写的Ruby+Tesseract爬取学校教务系统 这个时候派上很大用场,这篇文章末尾说

最艰难的时刻都度过了,想要继续爬点有用的信息也就很简单了。不过还是想对这个教务系统说,再见!

再见了两个多月呢我又忍不住来搞了。

如何模拟登录,获取页面就不多说了,建议先看一下那篇文章。
我还是把程序分了几个模块,不过这次全自己手写的了。

  • 发邮件模块
  • 模拟登录模块
  • 分析成绩,序列化模块
  • 主运行程序

实现思路

  1. 读取account.yml获取学号密码以及邮箱等信息,以便登录查询和通知
  2. 获取成绩,按照一定的格式,存储所有的成绩,一个二维数组,元素是每个学期的成绩数组。下面是某一学期成绩又是一个数组,每个元素是一个hash表,形如

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    [
    [
    {"name": "高等数学I", "grade": "75", "point": "3.5", "credit": "5"},
    {"name": "大学英语I", "grade": "80", "point": "4.0", "credit": "3"}
    ],
    [
    {"name": "高等数学II", "grade": "90", "point": "5.0", "credit": "6"},
    {"name": "大学英语II", "grade": "80", "point": "4.0", "credit": "3"}
    ],
    [
    {"name": "算法与数据结构", "grade": "95", "point": "5.0", "credit": "3"}
    ]
    ]
    /* 这个就表示一共有三学期,第一学期有两门课,以此类推 */
  3. 寻找该学号的成绩文件,如果没有则以上面的成绩新建学号.yml文件;如果有,则取出对比,不相等则表示有新成绩(后面这里要改)

  4. 有新的成绩则发送邮件通知订阅者

教务系统的坑

无法直接获取学期

这个是真坑,我想分学期存储成绩,但是由于教务系统中成绩和学期没有直接关联,而是类似下面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
afsuohf
asgjefifasdf
sfgeqafcdsfag
2015上
ansdoiajd
高等数学I 75 5
大学英语I 80 3
asfhiegn
gregweefsdv
aeggggggg
adfafdasda
2015下
rgdfvgadg
高等数学II 90 6
大学英语II 80 3
gaegrhwsrhwe
gegaqegqegeg
2016上
geghqgqaegqa
算法与数据结构 95 3

学期和成绩没有父子关系,是并列的,而且有很多无用的没有什么规律的表格穿插进去,具体情况比这复杂的多。本来想贴一下真实源码的,发现就两行成绩活活写了150行HTML还缩进的乱七八糟。

所以我想不出什么更好的办法。

于是发现了指导性教学计划这个页面,这里可以获取每科的考试时间,以此来推断是属于某个学期。
获取到所有的成绩之后,调用下面的方法,将它按学期变成二维数组就行了。

算法原理很奇妙:考试时间的后四位是月份日期,比如0420就是4月20号,我觉得5.1到11.1的考试都算是每年的第二学期(包括补考),所以判断是否在第二学期就行了,置一个标志位,如果变化了则说明到了新学期。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def nest_with_date
@score_list = [] # 将要存放成绩的二维数组
res = []
z = 0
second_term = 500...1100 # 还有可能会是补考,算同一学期
flag = false # 标志位,表示不在第二学期。 大一开学是第一学期,所以默认false
@guide_score_list.each_with_index do |s, i|
z += 1
# 判断后四位是否在第二学期范围内,置标志位,如果不同则换了学期
date = s[:date].slice(-4..-1).to_i
if flag != second_term.include?(date)
res << (z-1)
end
flag = second_term.include?(date)
end
res << z
res.length.times do |i|
# 按照考试时间将其分为二维数组
@score_list << @guide_score_list.slice((i == 0 ? 0 : res[i-1])...res[i])
end
end

需要访问两个页面

讲真,我真服这个教务系统。
我在写这样的代码中,无时无刻不在踩坑。

我要自动计算GPA那就必须要有这门课的实际得分和学分以及是否为必修课,我要区分学期就必须知道这门课的考试时间。
然而没有哪个页面同时有以上所有信息的。。。

所以我要访问指导性教学计划获取成绩,序列化后存到数组@guide_score_list,再按照课程属性查询成绩获取学分,课程性质等信息存到数组@credit_list,然后按照课程名称合并…

1
2
3
4
5
6
7
8
def merge_by_name
@guide_score_list.each do |g|
@credit_list.each do |s|
# 如果课程名相同则合并这两个Hash
g.merge!(s) if g[:name] == s[:name]
end
end
end

我这么写总觉得时间复杂度很夸张,但真的不要紧。课程总数也就几十个,效率低不到哪去。最关键的是学校的破服务器,如果说整个程序耗时5秒,网络IO得占4.9秒。所以还是放宽心吧。

成绩居然会撤回

每隔十分钟查一次成绩,与之前存下来的做对比,如果不相等,则说明有新成绩。看上去没问题。
结果第二天就通知有新成绩,邮件内容反而是少了一科…大写的懵逼
我登录教务系统查看了一下,那门成绩还真没了。过了一个多小时后又收到邮件,那成绩又出来了。
所以学校的教务系统是有几率撤回成绩的,而我可不想惊心动魄地打开邮件却没有新成绩,所以要更改策略了。

1
2
(current_score & last_score) != current_score
# 如果这个条件成立则表示有新成绩

这个判断就是取两次成绩列表交集,如果不等于刚查询到的,则说明有新的成绩。OK,解决!

具体代码还考虑了其他细节,可以直接看Repo啦~

HackMySchool

如有疏漏,欢迎评论指出,或者前往Github提出issue~谢谢