Ruby+Tesseract爬取学校教务系统

初衷

大一下学期期末只剩下高数考试时,考前时间比较充裕,想自学Java,同时还看了很多爬虫的故事,那是我第一次知道这么个词。
于是我决定利用Java来自动获取我的成绩,一是为了学Java,二来可以快速知道自己的成绩。

当时连续三天,白天睡觉晚上通宵,因为晚上安静很适合思考。利用Java丰富的第三方jar包,实现模拟登录,顺便查到Tesseract这个东西,还 写了一个发送邮件的类。东拼西凑总算是实现了自己后台定时爬成绩的功能,有更新则邮件通知我。当时心里爽的不行,觉得编程的世界简直酷炫。所以就转专业到CS,入了大坑~

后来学了Ruby,于是还想通过Ruby再实现一次,毕竟当年写的Java代码我自己现在也不认识了。

废话又说一大堆,我们开始吧~

模拟登录


分析教务系统登录页面

教务系统的真实网址是

http://202.119.113.135/loginAction.do

这个教务系统的登录界面比较简单,就一个表单,丑的我难受那种。


登录界面

我们可以看到有账号,密码,验证码三个输入框。打开审查元素(F12),可以找到如下结构:

登录界面的DOM

图片可能看不清,部分代码如下:

<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

实现模拟登录思路如下:

  1. 生成一个实例对象agent
  2. 获取页面对象login_page
  3. 获取该页面的表单对象login_form
  4. 填入账号密码(可预设在代码里)
  5. 下载验证码到本地
  6. 瞄一眼验证码
  7. 人工输入验证码
  8. 开始尝试登录,得到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

终于到了懒人必备的步骤了,既然我都花时间研究学校网站的破代码了,还让我自己输验证码这不白搭吗?

显然懒才会懒出高效率,接下来就该上自动识别验证码的功能了。

  1. 安装 tesseract-ocr
sudo apt-get install tesseract-ocr
  1. 安装RTesseract gem
sudo gem install rtesseract
sudo gem install mini_magick

因为RTesseract需要依赖Rmagick才能处理图像,这里用MiniMagick替代它,原因是:传说Rmagick会泄露内存,不知道后续版本有没有修复,不管怎么说,mini版的轻量些也没什么坏处。
注:可能还需要别的依赖,笔者已经记不太清了,想起再添加说明。遇到报错就Google之。

  1. 下载识别训练的数据包
#从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,这样一来,就很容易找到我们需要的东西了。

frameset结构

分析这个地方可以得到,显示全部及格成绩的网址是

http://202.119.113.135/gradeLnAllAction.do?type=ln&oper=qb

所以我们访问该网址,并模拟点击一次全部及格成绩链接,就可以获得成绩表格。
代码如下:

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

得到如下乱七八糟的东西:


subject的孩子们

这太乱了,数了一下,大概第五个元素有点卵用,来看看第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配置各种问题,就足够打倒很多人。

可是编程最有趣地方的就在这里了。

GitHub项目地址

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容

  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 12,016评论 4 62
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,392评论 25 707
  • 我在想不能够共享的有什么 健康其实也可以共享对吧 因为涉及器官血液捐赠等 但是除此之外 信仰可以共享 音乐艺术可以...
    Nurza阅读 278评论 0 1
  • 面向对象设计的五大原则 单一职责原则(SRP) 一个类应该仅有一个职责。 开放封闭原则(OCP) 对扩展开放,...
    LiuHDme阅读 1,447评论 0 3
  • Wrangling Your DataIt might not have been collected by th...
    SeanC52111阅读 368评论 0 1