初衷
大一下学期期末只剩下高数考试时,考前时间比较充裕,想自学Java,同时还看了很多爬虫的故事,那是我第一次知道这么个词。
于是我决定利用Java来自动获取我的成绩,一是为了学Java,二来可以快速知道自己的成绩。
当时连续三天,白天睡觉晚上通宵,因为晚上安静很适合思考。利用Java丰富的第三方jar包,实现模拟登录,顺便查到Tesseract这个东西,还 抄 写了一个发送邮件的类。东拼西凑总算是实现了自己后台定时爬成绩的功能,有更新则邮件通知我。当时心里爽的不行,觉得编程的世界简直酷炫。所以就转专业到CS,入了大坑~
后来学了Ruby,于是还想通过Ruby再实现一次,毕竟当年写的Java代码我自己现在也不认识了。
废话又说一大堆,我们开始吧~
模拟登录
分析教务系统登录页面
教务系统的真实网址是
这个教务系统的登录界面比较简单,就一个表单,丑的我难受那种。
我们可以看到有账号,密码,验证码三个输入框。打开审查元素(F12),可以找到如下结构:
图片可能看不清,部分代码如下:
<table width="100%" border="0" cellspacing="6" cellpadding="0" class="font-b">
<tr>
<td align="right" width="67">
<span id="userName_label">帐号</span>: </td>
<td>
<input type="text" name="zjh" value="" class="input01" title="帐号" alt="notnull">
</td>
</tr>
<tr>
<td align="right" width="67">
<span id="password_label">密码</span>: </td>
<td>
<input type="password" name="mm" value="" class="input01" title="密码" alt="notnull">
</td>
</tr>
<tr>
<td align="right" width="67">
<span id="password_label">验证码</span>:
</td>
<td colspan="2" align="left">
<input type="text" name="v_yzm" size="4" title="验证码" alt="notnull">
<img id="vchart" height="20" width="80">
<a href="#" onclick="m_changeOne();">看不清,换一张</a>
</td>
</tr>
</table>
很容易就可以发现账号的name属性是"zjh",密码的name是"mm",验证码的name是"v_yzm",所以我们只需要填写对应字段并提交即可。
可是验证码怎么获取呢?
这个问题我也困扰了一下,直到发现下面这个属性
src="/validateCodeAction.do?random=0.30715287429191673"
上面是个相对路径,所以只要加上IP,即访问如下网址(可点击),就可以获取验证码。
http://202.119.113.135/validateCodeAction.do?random=0.30715287429191673
当时我也不知道那一串破数字是干嘛使的,先实现功能重要,就从网页源代码里复制下来了。
然后用内置方法把这个下载下来,并且保存为图片格式,于是验证码就到了本地,手动填写验证码后尝试登录。具体实现稍后放代码。
后来啊,我终于发现了后面那串莫名其妙的数字是哪里来的!
function m_changeOne(){
document.getElementById("vchart").src="/validateCodeAction.do?random="+Math.random();
}
function valiCode(){
document.getElementById("vchart").src="/validateCodeAction.do?random="+Math.random();
}
虽然我也不知道这两个函数体内容有什么区别,但是我知道了那就是个随机数,我猜0~1数字都可以成功,并且试了一下还真是。
到这里我们分析完登录页面,就开始模拟登录吧。接下来就是想办法把该填的字段打包好POST到服务器。
开始模拟登录
接下来就是介绍Ruby(Version: 2.2.2)的Mechanize(Version: 2.7.4),这个gem非常良心,写出来代码简洁大方。首先要安装这个gem:
sudo gem install mechanize
实现模拟登录思路如下:
- 生成一个实例对象agent
- 获取页面对象login_page
- 获取该页面的表单对象login_form
- 填入账号密码(可预设在代码里)
- 下载验证码到本地
- 瞄一眼验证码
- 人工输入验证码
- 开始尝试登录,得到session就可以为所欲为了
步骤大约如上8步,写出来的代码也不过十几行,Ruby就这么省心。
require "mechanize" #引入Mechanize
agent = Mechanize.new #新建一个Mechanize对象
login_page = agent.get "http://202.119.113.135/loginAction.do" #获取登录页面
login_form = login_page.forms[0] #获取该页面第一个表单(因为一个页面可能会有很多个表单,所以是数组)
username_field = login_form.field_with(:name => "zjh") #获取name为zjh的输入框
username_field.value = "这里填学号" #填上账户名,即学号(下同)
password_field = login_form.field_with(:name => "mm")
password_field.value = "这里填密码"
v_code = agent.get "http://202.119.113.135/validateCodeAction.do?random=0.27" #下载验证码
v_code.save! "validateCode.jpg" #保存验证码图片
print "请输入验证码:\n"
v_input = gets.chomp #手动输入验证码
code_field = login_form.field_with(:name => "v_yzm") #获取name为yzm的输入框
code_field.value = v_input #输入验证码
agent.submit login_form #提交表单
print "正在登录...\n"
一般来说到这里就会登陆成功了,但你好像并不知道有没有成功。
我们需要对这个结果进行判断,提交了表单以后,可以通过分析返回页面是否包含"验证码错误"、"密码不正确"之类的字符串,来判定是否登录成功。实际上更加合理的方式是判断能否访问登陆后才可以访问的页面,不过这里可以这样简单处理,不算优雅,也不算很hack。
通过分析验证码错误的页面源码,可以发现如下片段:
<tr>
<td><img src="/img/icon/alert.gif"></td>
<td class="errorTop"><strong><font color="#990000">你输入的验证码错误,请您重新输入!</font></strong><br></td>
</tr>
这一段的文字可以从
class="errorTop"
这一句进行捕捉,利用Nokogiri自带的选择器
page.css(".errorTop")
就可以得到那个td标签的Nokogiri对象,其子对象strong标签的子对象font标签的元素内容才是我们想要的信息。
所以代码如下:
loop do
v_code = agent.get "http://202.119.113.135/validateCodeAction.do?random=0.66666666666666666"
v_code.save! "validateCode.jpg"
#手动输入验证码
print "请输入验证码:\n"
v_input = gets.chomp
code_field = login_form.field_with(:name => "v_yzm")
code_field.value = v_input
#提交表单 并把结果赋值给变量result_page
result_page = agent.submit login_form
print "正在登录...\n"
#通过Nokogiri的parser方法得到整个页面的Nokogiri对象们,并且转成字符串,编码为UTF-8,以便后续判断
result_text = result_page.parser.to_s.encode("UTF-8")
if result_text.include?("密码不正确")
#这个Nokogiri的子对象的子对象的子对象的文本就是错误信息。我也不想把代码写这么丑,但这破网站真的好喜欢嵌套
puts result_page.css(".errorTop").children.children.children.text
#因为这里是预设在源码里的账号密码,如果错了就关闭程序,修改源码
puts "请检查预设账号与密码"
puts "登录失败"
return
elsif result_text.include?("验证码错误")
puts result_page.css(".errorTop").children.children.children.text
else
puts "登陆成功"
break
end
end
从下载验证码,到登录是否成功,这段代码构成一个循环,直到成功登录为止。所以可以直接loop循环到死,啊不,肯定会有结果的。
到这里,我们已经可以模拟登录教务系统,并且可以判断是否登录成功,验证码错误的话,再次循环,直到成功。(这里是为了之后的OCR自动识别验证码做个铺垫,因为自动识别的成功率不是100,所以自动循环再尝试,对于机器来说,很理所当然了。)
自动识别验证码
可行性
就这破验证码,绝对可行。
安装RTesseract
终于到了懒人必备的步骤了,既然我都花时间研究学校网站的破代码了,还让我自己输验证码这不白搭吗?
显然懒才会懒出高效率,接下来就该上自动识别验证码的功能了。
- 安装 tesseract-ocr
sudo apt-get install tesseract-ocr
- 安装RTesseract gem
sudo gem install rtesseract
sudo gem install mini_magick
因为RTesseract需要依赖Rmagick才能处理图像,这里用MiniMagick替代它,原因是:传说Rmagick会泄露内存,不知道后续版本有没有修复,不管怎么说,mini版的轻量些也没什么坏处。
注:可能还需要别的依赖,笔者已经记不太清了,想起再添加说明。遇到报错就Google之。
- 下载识别训练的数据包
#从Google官网下载
wget https://tesseract-ocr.googlecode.com/files/eng.traineddata.gz
#下载完成后,移动到相应文件夹,可选择/usr或者/opt
sudo mv -v eng.traineddata /usr/local/share/tessdata/
#sudo mv -v eng.traineddata /opt/local/share/tessdata/
OCR识别
我们在刚刚的代码前面加上
require 'rtesseract'
require 'mini_magick'
def identify(path="validateCode.jpg")
image = RTesseract.new(path, processor: "mini_magick")
image.to_s
end
并且把原来的手动输入直接干掉,改为调用identify方法。
# v_input = gets.chomp
v_input = identify("validateCode.jpg")
这样一来,由identify方法接受图片路径参数,经过识别得到字符串返回,直接赋值给v_input,省去了人工输入的麻烦。
不过识别率有待提高,我们可以针对性的提高识别率。
提高识别率
干掉空格
有时候会识别出多余的空格,然而验证码是不可能需要空格的,所以我们要干掉所有的空格
#image.to_s
image.to_s.gsub(' ','')
限定识别范围
然而默认的还可能识别出标点符号甚至美元符号等,经测试,这甚至会导致循环异常中断,懒得追究原因,直接干掉这些不需要的符号。
# image = RTesseract.new(path, processor: "mini_magick")
image = RTesseract.new(path, processor: "mini_magick",options:[:validcode])
这里增加了一个选项,一般默认在下面路径里会有个digits的文件(注意:没有后缀名)
/usr/local/share/tessdata/configs
里面的内容是这样的:
tessedit_char_whitelist 0123456789-.
而它的作用就是,让识别的结果限定在数字、连字符、小数点范围内。抱着试一试的态度,我新建了一个叫validcode的文件,写了如下内容:
tessedit_char_whitelist 0123456789qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM
当然了,it works!结果就是识别出来的结果仅由字母和数字组成,大大提高识别正确率。(实际上后来我想起,学校的验证码故意没有字母O的,怕看错吧,也算走点心了...)
判定长度
还有个地方可以改进,它并不总是会识别出来四个字符,但验证码确实永远是四个字符,所以我们可以做个判断,当识别出来字符长度不是四位,就直接跳过,节约一点时间。
next if v_input.length != 4
处理图像
这里我没有对图像进行任何处理,实际上还可以进行二值化,去噪点等等,不过识别率已经很高了,我就没再折腾了。有兴趣的读者可以试试,毕竟不是所有的验证码,都像我们学校这么容易难以识别。
(PS: 由于登录功能已经可以分辨密码错误还是验证码错误,并且可以循环,所以顺便改造成了暴力破解密码的程序。一般密码都是身份证后六位,我知道十几个同学的密码,全都没改过。密码前两个数字是01~31,后面就直接递增暴搜,还要额外考虑X结尾的情况。具体代码不贴了,效率差不多平均0.6秒跑一个密码。可以理解为如果知道对方生日,并且对方没改过密码,暴搜一万次大约6000s,俩小时之内就能跑完...)
获取成绩
获取指定页面
我们先分析浏览器中的页面
这就是最恶心的地方了,整个网页由好几个frame组成,分别为侧边栏,顶部菜单栏等等。在不同的frame里document是异步加载,想刷新成绩只需要多点几下按钮,千万不能按F5,不然它会判定你再次提交表单登录,然而验证码已刷新,所以会报错验证码错误,这鬼畜的逻辑也是感人。多说一句,页面真是丑到令人发指。
不过也得接着分析,好在Mechanize有相对应的API,可以直接模拟点击frame,这样一来,就很容易找到我们需要的东西了。
分析这个地方可以得到,显示全部及格成绩的网址是
所以我们访问该网址,并模拟点击一次全部及格成绩链接,就可以获得成绩表格。
代码如下:
logged_page = agent.get("http://202.119.113.135/gradeLnAllAction.do?type=ln&oper=qb")
score_page = logged_page.iframe.click
score_page.save! "score.html"
解析结果
成绩已经下载到本地了,用浏览器打开就可以看得到。只是几个学期的成绩表格的话,解析起来很简单啦,顺便写个绩点计算功能也是分分钟,还顺便可选择要不要算选修课,是计算本学期还是计算大学生涯,自定义格式表现出来都可以!梦想总是美好的,然而!
Too young!Too simple!Sometimes naive!
我了个草啊,我尝试了半天如何批量获取成绩tr,每行作为一个对象,存到数组,调用起来怎么计算都行。但是这个结构!!谁能告诉我为什么一个学期的成绩要6个表格!还特么互相嵌套!其中就一个表格占空间!也许是为了兼容视图的hack,那你倒是别在成绩table中穿插没用tr啊!我试图寻找所有成绩tr的共性,试图通过style.class来选择,然后我又发现诡异的事情了!每个行鼠标路过一下class值就变了...心好累...我真是没兴趣继续下去了,这个网站,分析起来像吃了shi一样难受!
不玩了。
未完不续,一秒也不续了!
不玩了那就不是我pujiaxun了!
一小时后我又回来更新文章了。
嘴上说不要,但是心里不爽啊,还是尝试着分析了一下。
我发现页面tr元素的style.class会从odd变成even,是由于
onmouseout="this.className='even';"
由此推测可能是被鼠标滑过的成绩会有别的五毛特效吧,不必追究,一毛都嫌多,用代码解析的时候并不会有鼠标掠过的操作。
page = Nokogiri::HTML(open("score.html").read,nil,"gbk")
由于教务系统蛋疼地用了gbk编码...所以需要加个参数,以便正常解析。
subjects = page.css("tr.odd")
这句话类似jQuery选择器,可以得到所有class="odd"的tr元素,即我们需要的所有成绩。
可是如何得到每个数据呢?
p subjects[1].children
得到如下乱七八糟的东西:
这太乱了,数了一下,大概第五个元素有点卵用,来看看第5个children里的text吧
p subjects[1].children[5].text.strip
通信工程新技术
太好了!是课程名字!同理我们可以看看都有哪些数据
subjects[1].children.each do |c|
p c.text.strip
end
""
"0602030"
""
"01"
""
"通信工程新技术"
""
"New technology in Communication Engineering"
""
"1"
""
"选修"
""
"80.0 "
""
由此可知第5个是课程名,第7个是英文课程名,以此类推。
所以我们可以处理打包了:
def get_point (grade)
s = grade.to_i
#如果是文字
if s == 0
case grade
when "优秀"
return "5"
when "良好"
return "4"
when "中等"
return "3"
when "及格"
return "2"
else
return "0"
end
end
#如果是数字
if s<60
return "0"
elsif s>=90
return "5"
elsif (s>=60 && s<90)
return (((s-60)/5)*0.5+2.0).to_s
else
return "fuck"
end
end
scorelist = []
subjects.each do |m|
subject = {}
subject["name"] = m.children[5].text.strip
subject["eng_name"] = m.children[7].text.strip
subject["credit"] = m.children[9].text.strip
subject["prop"] = m.children[11].text.strip
subject["grade"] = m.children[13].text.strip.slice!(0..-2)# 鬼畜的空白字符
subject["point"] = get_point subject["grade"]
scorelist << subject
end
新建一个scorelist数组,用来存放所有的学科成绩。每行数据包括课程名、学分、得分等,绩点是利用get_point方法实时计算出来,打包好数据,压进数组。其中非常蛋疼的是成绩字符串末尾有个空白字符,大概是nbsp,strip方法无效。只好使用slice去掉最后一个字符。
至此,我们已经把数据拿到,并且构建了合适的数据结构。如果想要计算绩点,就很简单了,比如下面的方法:
def get_GPA (scorelist,only=true)
sum_point = 0
sum_credit = 0
scorelist.each do |s|
if ((s["prop"]!="选修") || (only==false)) #only参数为真时只统计必修课程,为假则全部统计
sum_point += s["point"].to_f * s["credit"].to_f
sum_credit += s["credit"].to_f
end
end
(sum_point/sum_credit).round(3) #保留三位小数
end
整理一下,整体效果差不多这样子:
最艰难的时刻都度过了,想要继续爬点有用的信息也就很简单了。不过还是想对这个教务系统说,再见!
这次历险记中,收获还是蛮大的,写出来的时候都是在事后,所以比较简略,但篇幅也不小。这个东西确实折磨我好些天了,期间了解了解析HTML的思路,模拟登录的注意事项,识别字符的效率如何提高等等。很多事情看别人说的简单,真的投入进去才明白需要处理的细节太多了,可能光是使用Tesseract时遇到缺少训练数据、缺少依赖、path配置各种问题,就足够打倒很多人。
可是编程最有趣地方的就在这里了。