我的第一个开源项目: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)特别鸣谢

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

发布了15 篇原创文章 · 获赞 0 · 访问量 216

猜你喜欢

转载自blog.csdn.net/qq_40151840/article/details/104423901