我的第一個開源項目: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)特別鳴謝

感謝為開源工作做出奉獻的每一個開發者,開源意味着更多的交流機會和學習機會,同樣希望自己這個項目能幫到有需要的人。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM