zoukankan      html  css  js  c++  java
  • 我的第一个开源项目:Java爬虫爬取旧版正方教务系统课程表、成绩表

    Java爬虫爬取旧版正方教务系统课程表、成绩表

    一、项目展示

    1.正方教务系统

    • 首页

      正方教务系统首页

    2.爬虫系统

    • 首页:

      爬虫系统首页

    • 成绩查询:

      成绩查询展示

    • 课表查询:

    mark

    二、项目实现

    1.爬取思路描述

    无论是成绩查询或课表查询亦或者其它的信息查询,都必须是要在登录状态下才能进行。而要登录教务系统,就要先获取登录的验证码,然后输入学号密码和验证码,向教务系统发起登录请求,登录成功后,需要保存登录状态,即记录cookie。有了登录成功后的cookie,就能对其他页面发起请求,旧版的正方系统返回的是Html,所以拿到请求结果后,还要再进行Html的解析,进而筛选出自己所需要的信息。

    2.代码实现的总体思路

    (1)爬虫、数据解析工具

    • HttpClient:用于像浏览器发起http请求,支持长连接
    • Jsoup:用于解析Html,支持DOM,CSS以及类似于jQuery的操作方法来取出和操作数据
    • 正则表达式:按需求提取字符串的特定部分,也是用于解析Html

    (2)项目框架

    项目用的是Springboot搭建项目,因为当时简单用Vue搭了个前台,所以数据传输都是用的Json,实现了前后端的分离,主要是用到了Spring的IoC容器管理bean还有控制器类。第三方依赖是用Maven来管理。实际上,这个项目不一定要用SpringBoot,可以根据自己的需要进行迁移。代码包结构如下:

    代码包结构

    (3)核心类简介

    • GloabalConstant类:全局常量类,存放了所有的请求URL,包括教务系统首页、登录请求地址、验证码请求地址等,这些URL需要根据自己的实际情况进行手动更改,把域名部分换成自己学校正方系统首页的地址就行。另外就是登录页的错误信息,为了方便调试代码,也进行了保存。
    • HttpService类:Http服务类,封装了get请求、post请求,以及HttpClient的初始化,同时所有关于爬取逻辑的代码都是在这个类里,包括登录、验证码获取与识别、课表表获取、成绩表获取等。
    • JavaOCR类:验证码识别类,包括验证码识别的整个过程,**由于验证码识别训练涉及到数据集、测试集、结果集,启动代码时,请根据自己的实际情况,在配置文件执行修改trainSetDirtrainTestDirtrainResultDir这几个目录所在的位置。**验证码识别的训练与使用是分开的,项目运行时只会在HttpService中读取训练结果集,如果要自己进行验证码的训练(理论上测试集验证码图片越多,识别率越高,我总共用了近700张,识别率稳定在62%左右),在src.test.java.*下有代码示例。

    (4)配置文件说明

    配置文件用的是yml格式,application-dev.yml是开发环境的配置文件,application-prod.yml是生产环境(linux下)的配置文件,可以自定义端口以及JavaOCR目录。

    application-dev.yml

    application-prod.yml

    (5)要注意的细节

    • 在获取Cookies后,以后的每一次请求都要把Cookies带上。

    • 请求时要注意目标请求是否需要Referer。Referer告诉服务器我是从哪个页面链接过来的,服务器基此可以获得一些信息用于处理,有网页会限定请求的上一个地址。

    3.模拟登录

    (1)分析登录页面

    我用的Google Chorm,在首页按F12打开浏览器自带的页面审查工具,随便输入学号密码和验证码,点击登录后,浏览器会向服务器提交一个post请求,请求地址为:http://xxxxxxxxxx/default2.aspx。

    登录页面请求分析

    仔细观察上面的Form Data表单,发现有以下几个关键表单项:

    • __VIEWSTATE:一个隐藏表单项,可以在页面源码中找到
    • txtUserName:学生学号
    • TextBox2:登录密码
    • txtSecretCode:验证码
    • RadioButtonList1:结合登陆页面知,这是身份选项,value值为%D1%A7%C9%FA(”学生”经过以Gb2312格式URL编码后的字符串 )

    其他像Textbox1、Button1这些表单项的value值都是空白的,说明在登录中并不起作用。

    (2)登陆前的准备:获取cookie和__VIEWSTATE

    获取到cookie和__VIEWSTATE后要进行保存,项目中是采用session的方式,存放在服务器端,在之后的请求中,每次请求都要带上cookie,比如获取验证码。HttpService类已经封装好了get请求和post请求,每次请求都会自动带上cookie。

    	/**
         * 初始化,主要用于收集cookie和viewState
         */
        public HttpBean init() {
            CloseableHttpResponse requestResponse = sendGetRequest(GlobalConstant.INDEX_URL, "");
            String cookie = requestResponse.getFirstHeader("Set-Cookie").getValue();//  获取cookie
            HttpBean httpBean = new HttpBean();
            try {
                String html = EntityUtils.toString(requestResponse.getEntity(), "utf-8");
                httpBean.setViewState(getViewState(html));//提取页面表单中的__VIEWSTATE的值
                httpBean.setCookie(cookie);
            } catch (IOException e) {
                e.printStackTrace();
            }
            System.out.println("完成初始化,获取到的cookie为" + httpBean.getCookie()
                    + ",获取到的viewState为" + httpBean.getViewState());
            return httpBean;
        }
    
        /**
         * @param html 登录页面源码
         * @return 登录页的__VIEWSTATE
         */
        public String getViewState(String html) {
            return Jsoup.parse(html).select("input[name=__VIEWSTATE]").val();
        }
    

    (3)验证码的获取与自动识别

     	/**
         * 获取验证码
         *
         * @return 验证码图片
         */
        public byte[] getCheckImg() {
            String url = GlobalConstant.SECRETCODE_URL;
            byte[] imgByte = null;
            try {
                CloseableHttpResponse requestResponse = sendGetRequest(url, "");
                imgByte = EntityUtils.toByteArray(requestResponse.getEntity());
            } catch (Exception e) {
                e.printStackTrace();
            }
            return imgByte;
        }
    
        /**
         * 
         * @return 验证码识别结果
         */
        public String getCheckImgText() {
            String ocrResult = "";
            try {
                BufferedImage image = ImageIO.read(new ByteArrayInputStream(getCheckImg()));
                BufferedImage imageBinary = javaOCR.getImgBinary(image);
                ocrResult = javaOCR.getOcrResult(imageBinary, map);
                ImageIO.write(image, "png", new File(trainRecordDir + ocrResult + ".png"));
            } catch (IOException e) {
                e.printStackTrace();
            }
            return ocrResult;
        }
    

    (4)发起模拟登录请求

    	/**
         * 登陆
         *
         * @param user 用户信息
         * @return 返回登陆成功或登录错误信息
         */
        public String login(User user) {
            HttpSession session = request.getSession();
            // 初始化
            HttpBean httpBean = init();
            // 将信息保存进新创建的session中
            session.setAttribute("httpBean", httpBean);
            // 组织登陆请求参数
            ArrayList<BasicNameValuePair> params = new ArrayList<BasicNameValuePair>();
            params.add(new BasicNameValuePair("__VIEWSTATE", httpBean.getViewState()));//__VIEWSTATE,不可缺少这个参数
            params.add(new BasicNameValuePair("txtUserName", user.getUserNumber()));//学号
            params.add(new BasicNameValuePair("TextBox1", ""));//密码
            params.add(new BasicNameValuePair("TextBox2", user.getUserPassword()));//密码
            params.add(new BasicNameValuePair("txtSecretCode", getCheckImgText()));//验证码
            params.add(new BasicNameValuePair("RadioButtonList1", "学生"));//登陆用户类型
            params.add(new BasicNameValuePair("Button1", ""));
            params.add(new BasicNameValuePair("lbLanguage", ""));
            params.add(new BasicNameValuePair("hidPdrs", ""));
            params.add(new BasicNameValuePair("hidsc", ""));
            String loginErrorMsg = "no error";
            try {
                UrlEncodedFormEntity entity = new UrlEncodedFormEntity(params, "GB2312"); //封装成参数对象
                CloseableHttpResponse requestResponse = sendPostRequest(GlobalConstant.LOGIN_URL, null, entity);//发送请求
                String html = EntityUtils.toString(requestResponse.getEntity(), "utf-8");
                // 检测是否有登陆错误的信息,有则记录信息,若返回的状态码是302则表示登陆成功
                if (html.contains(GlobalConstant.CHECKCODE_ERROR)) {
                    loginErrorMsg = GlobalConstant.CHECKCODE_ERROR;
                } else if (html.contains(GlobalConstant.CHECKCODE_NULL)) {
                    loginErrorMsg = GlobalConstant.CHECKCODE_NULL;
                } else if (html.contains(GlobalConstant.PASSWORD_ERROR)) {
                    loginErrorMsg = GlobalConstant.PASSWORD_ERROR;
                } else if (html.contains(GlobalConstant.USERNUMBER_NULL)) {
                    loginErrorMsg = GlobalConstant.USERNUMBER_NULL;
                } else if (html.contains(GlobalConstant.USERNUMBER_ERROR)) {
                    loginErrorMsg = GlobalConstant.USERNUMBER_ERROR;
                } else if (requestResponse.getStatusLine().getStatusCode() == 302) {
                    // 登陆成功,保存已登录的用户的信息
                    httpBean.setUser(user);
                    // 保存主页面的查询链接
                    httpBean = saveQueryURL(httpBean);
                    // 更新session中的信息
                    session.setAttribute("httpBean", httpBean);
                    return "登录成功";// 返回登陆成功信息
                } else {
                    loginErrorMsg = "未知错误";
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
            return loginErrorMsg;
        }
    

    (5)登录成功后,爬取主页面内容,查找并保存查询各种信息的URL

        /**
         * 访问系统首页,查找并保存查询各种信息的URL
         *
         * @param httpBean
         */
        public HttpBean saveQueryURL(HttpBean httpBean) throws IOException {
            CloseableHttpResponse response = sendGetRequest(GlobalConstant.MAIN_URL + httpBean.getUser().getUserNumber(),GlobalConstant.LOGIN_URL);
            String html = EntityUtils.toString(response.getEntity(), "utf-8");
            // 信息查询的URL
            String regex_url = "<a href="(\w+)\.aspx\?xh=(\d+)&xm=(.+?)&gnmkdm=N(\d+)" target='zhuti' οnclick="GetMc\('(.+?)'\);">(.+?)</a>";
            // 提取URL中的姓名
            String regex_name = "&xm=(\S+)&";
            Pattern pattern1 = Pattern.compile(regex_url);
            Pattern pattern2 = Pattern.compile(regex_name);
            Matcher matcher = pattern1.matcher(html);
            while (matcher.find()) {
                // <a href="xskbcx.aspx?xh=xxxxxxxxx&xm=某某某&gnmkdm=N121603" target='zhuti' οnclick="GetMc('学生个人课表');">学生个人课表</a>
                String res = matcher.group();
                // xskbcx.aspx?xh=xxxxxxxxx&xm=某某某&gnmkdm=N121603" target='zhuti' οnclick="GetMc('学生个人课表');">学生个人课表</a>
                String url = res.substring(res.indexOf("href="") + 6);
                // xskbcx.aspx?xh=xxxxxxxxx&xm=某某某&gnmkdm=N121603
                url = url.substring(0, url.indexOf("""));
                // 姓名为中文,需要进行编码 URLEncoder.encode(userName, "GB2312")
                Matcher matcher2 = pattern2.matcher(url);
                if (matcher2.find()) {
                    url = url.replaceAll(regex_name, "&xm=" + URLEncoder.encode(matcher2.group(1)) + "&");
                    if (StringUtils.isEmpty(httpBean.getUser().getUserName()))
                        httpBean.getUser().setUserName(matcher2.group(1));
                }
                if (res.contains("学生个人课表")) {
                    httpBean.setQueryStuCourseListUrl(url);
                    continue;
                }
                /*  有两种成绩查询,名称相同,但实际URL不同
                    xscjcx_dq.aspx?xh=xxxxxxxxx&xm=%DD%B6%CE%B0%BD%DC&gnmkdm=N121617
                    xscjcx.aspx?xh=xxxxxxxxx&xm=%DD%B6%CE%B0%BD%DC&gnmkdm=N121618
                */
                if (res.contains("成绩查询") && res.contains("N121617")) {
                    httpBean.setQueryStuScoreListUrl(url);
                }
                if (res.contains("成绩查询") && res.contains("N121618")) {
                    httpBean.setQueryStuScoreListUrl2(url);
                }
            }
            return httpBean;
        }
    

    4.以爬取课表信息为例

    可以按照前面的分析登录页面那样,来分析查询课表页面。正方教务系统,查询当前学期的课表时,发送的是Get请求,这时不需要填写表单数据。当指定查询某个学年或某个学期的课表时,发送的就是post请求了,这时要携带上表单数据。同时需要注意的就是,每个页面都会有自己的__VIEWSTATE值,在爬取一个页面时,要相应的更新Session中的VIEWSTATE 值为当前页面的VIEWSTATE值。

    	/**
         * __VIEWSTATE字段不能和查询的学期相同
         * 查询非本学期的课程时,用post方法
         * 查询本学期的课程时,用get方法
         *
         * @param xn
         * @param xq
         * @throws IOException
         */
        public ArrayList<CourseBean> queryStuCourseList(String xn, String xq) {
            HttpSession session = request.getSession();
            HttpBean httpBean = (HttpBean) session.getAttribute("httpBean");
            String queryCourseUrl = GlobalConstant.INDEX_URL + httpBean.getQueryStuCourseListUrl();
            CloseableHttpResponse requestResponse = null;
            //没有学年度和学期的的信息,则发送get请求,否则发送post请求
            if (xn == null || xq == null) {
                requestResponse = sendGetRequest(queryCourseUrl, GlobalConstant.MAIN_URL + httpBean.getUser().getUserNumber());
            } else {
                List<NameValuePair> courseForms = new ArrayList<>();
                courseForms.add(new BasicNameValuePair("__EVENTTARGET", ""));
                courseForms.add(new BasicNameValuePair("__EVENTARGUMENT", ""));
                courseForms.add(new BasicNameValuePair("__VIEWSTATE", httpBean.getViewState()));
                courseForms.add(new BasicNameValuePair("xnd", xn));
                courseForms.add(new BasicNameValuePair("xqd", xq));
                try {
                    requestResponse = sendPostRequest(queryCourseUrl, queryCourseUrl, new UrlEncodedFormEntity(courseForms, "utf-8"));
                } catch (UnsupportedEncodingException e) {
                    e.printStackTrace();
                }
            }
            String courseListSourceCode = null;
            try {
                courseListSourceCode = EntityUtils.toString(requestResponse.getEntity(), "utf-8");
            } catch (IOException e) {
                e.printStackTrace();
            }
            // 更新__VIEWSTATE值
            httpBean.setViewState(getViewState(courseListSourceCode));
            // 更新session中的信息
            session.setAttribute("httpBean", httpBean);
            // 解析HTML
            return ParseUtil.parseCourseTableHtml(courseListSourceCode);
        }
    

    5.项目开源

    (1)Github项目地址

    (2)如何启动项目?

    • 请先阅读代码实现的总体思路
    • windows用户,在D盘创建image目录,然后把项目src/resource/ocr/目录下的train_set(数据集)、train_test(测试集)、train_result(结果集)、record(每次登录记录验证码识别结果)这四个文件夹复制到image下,这样子就不用修改application-dev.yml。反过来,也可以通过修改配置文件来自定义加载路径。Linux用户请参考application-prod.yml配置文件的路径来创建。
    • 更改GlobalConstant类下的URL为自己学校正方教务管理系统的地址,一般是只需要更改域名部分,后面的子路径即使是不同学校也不会有变化。

    (3)项目无法启动怎么办?

    • 请检查是否是路径错误,是否已经正确的按要求创建了所需要的目录

    • 请检查GlobalConstant类下的URL与教务系统上的请求URL是否一致

    • 请检查正方教务管理系统FormData(post请求的body)的key是否与项目代码中的一致

      不同学校的系统,可能在表单参数的名称上有所差异,请根据自己的实际情况更改HttpService类里对应的代码。

    • 可以在Github上提issiue,也可以直接到博客文章下进行评论,详细描述错误现象,错误是否可重现等。

    (4)特别鸣谢

    感谢为开源工作做出奉献的每一个开发者,开源意味着更多的交流机会和学习机会,同样希望自己这个项目能帮到有需要的人。

  • 相关阅读:
    前端项目升级和降级依赖的最佳姿势
    如果你github提交代码,报错remote: Support for password authentication was removed on August 13, 2021.
    js将连接符命名和驼峰命名互转
    滚动条常用样式
    计算该浏览器中滚动条的默认宽度
    解决webpack-dev-server启动后localhost:port可以访问,IP:port不能访问的问题
    获取滚动条宽度的方法
    mysql中的数据类型
    数据库和表的操作
    mysql插入,删除,修改记录
  • 原文地址:https://www.cnblogs.com/blog567/p/12374645.html
Copyright © 2011-2022 走看看