美文网首页物尽其用Ruby程序员
Ruby+Tesseract爬取学校教务系统

Ruby+Tesseract爬取学校教务系统

作者: pujiaxun | 来源:发表于2016-05-15 16:43 被阅读1287次

初衷

大一下学期期末只剩下高数考试时,考前时间比较充裕,想自学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一样难受!
不玩了。
未完不续,一秒也不续了!

不玩了那就不是我蒲家训了!
一小时后我又回来更新文章了。
嘴上说不要,但是心里不爽啊,还是尝试着分析了一下。
我发现页面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项目地址

相关文章

  • Ruby+Tesseract爬取学校教务系统

    初衷 大一下学期期末只剩下高数考试时,考前时间比较充裕,想自学Java,同时还看了很多爬虫的故事,那是我第一次知道...

  • python爬取学校教务系统

    写这个爬虫的缘由 以前用java写过一个爬取学校的教务系统的爬虫 https://blog.csdn.net/yg...

  • 爬取教务系统成绩

    首先,登入了教务系统的成绩界面,获取了Cookie和User-Agent然后发现学校的教务系统都是框架啊,然后就查...

  • 【爬虫】(四)西电研究生教务系统技术文档

    date: 2017-02-03 19:59:33 教务系统爬虫工作初步完成 关于教务系统的一系列爬取工作已经初步...

  • 新版正方教务系统爬取

    py使我快乐(假的)是个炸了的项目的一部分……搜资料的时候发现已经有人全部做出来了,滚了滚了改造这玩意是因为要统计...

  • python爬虫之爬取教务网成绩

    python爬虫之爬取教务网成绩 这次的内容主要就是讲述自己的第一只python爬虫,而所要爬取的对象就是学校的教...

  • Python 实战项目

    web机器人 web实战 博客BBS论坛系统 成绩管理系统 新闻系统 爬取知乎 爬取豆瓣 爬取京东 爬取新浪微博 ...

  • Python模拟登录高校URP教务系统

    对于爬虫来说,需要爬取的信息如果需要登录才可以得到的话,那么我们就需要模拟登录。比如,登录教务系统才可以获取成绩。...

  • 爬取教务处网站

    最近写了一个课程助手类Android App,写了一大半的时候发现这个课程助手适用于低频用户场景,如果做一个App...

  • 用python爬取正方系统获取课表

    由于近期在学习python,看到别人写过这个,自己也练习一下,所以就拿正方教务系统联系爬取课表。写的不咋的,但是效...

网友评论

  • d76a77b2990b:用爬教务系统有些问题上网搜搜。。这教务系统网址咋一毛一样?? 原来是校友~
    pujiaxun:@世界第二猫老师 2333 拿去用不客气哈哈哈
  • tf2jaguar:同款教务系统🤔,不知兄弟,有没有能爬到其他人的数据
    pujiaxun:@爱吃jelly的Jelly 要密码的... 不过我也写过一个暴力解密码的脚本,毕竟大家都懒得改密码,6位数字暴搜吧
  • 豌荳仈角ov:感觉楼主就是大神!
    pujiaxun:@豌荳仈角ov 不敢不敢。。
  • supiccc:好棒棒,改天试试我们学校的正方系统
    pujiaxun:@supiccc 哇还有人看到,那我正好也更新一下repo地址
  • aa7a4672cf7b:果断收藏😄
    pujiaxun:@青栀poin :yum:
  • 野狗子嗷嗷嗷:最近要自己实现一下python爬取教务系统,再来琢磨家训哥的这篇文章,那我就踩在巨人的肩膀上咯,哈哈哈 :joy:
    pujiaxun:@PatrickYates 没很懂你啥需求... 用代码POST数据吗?
    野狗子嗷嗷嗷:@JasonSi 我搜索"教务系统",前几个结果就是你的啊 :joy: 我有个问题,为啥我在Network里没有看到POST啊,chrome firefox都试过了,那post的data参数该怎么填啊
    pujiaxun:@PatrickYates 我去... 这都能找到我
  • 雁门员外:这么好的文章,赞一个
    pujiaxun:@GalaxyRover :wink:
    诗与星空:感谢如此详细的注释,学习了。
    pujiaxun:@雁门员外 哈哈哈谢谢

本文标题:Ruby+Tesseract爬取学校教务系统

本文链接:https://www.haomeiwen.com/subject/cfbvrttx.html