Java爬蟲爬取舊版正方教務系統課程表、成績表
一、項目展示
1.正方教務系統
-
首頁
2.爬蟲系統
-
首頁:
-
成績查詢:
-
課表查詢:
二、項目實現
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
類:驗證碼識別類,包括驗證碼識別的整個過程,**由於驗證碼識別訓練涉及到數據集、測試集、結果集,啟動代碼時,請根據自己的實際情況,在配置文件執行修改trainSetDir
、trainTestDir
、trainResultDir
這幾個目錄所在的位置。**驗證碼識別的訓練與使用是分開的,項目運行時只會在HttpService
中讀取訓練結果集,如果要自己進行驗證碼的訓練(理論上測試集驗證碼圖片越多,識別率越高,我總共用了近700張,識別率穩定在62%左右),在src.test.java.*
下有代碼示例。
(4)配置文件說明
配置文件用的是yml格式,application-dev.yml
是開發環境的配置文件,application-prod.yml
是生產環境(linux下)的配置文件,可以自定義端口以及JavaOCR目錄。
(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項目地址
-
項目地址:https://github.com/James0608/ZhengFangJWSystemBackend
歡迎Fork,喜歡的話,給個Star唄 hiahia~
(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)特別鳴謝
-
本項目的驗證碼識別部分是在Allenhua的自動識別驗證碼項目的基礎上完成的,特此鳴謝。
-
參考文章:
[1]:用java模擬登錄正方教務系統,抓取課表和個人成績等數據
[2]:爬取正方教務管理系統獲取學生信息
感謝為開源工作做出奉獻的每一個開發者,開源意味着更多的交流機會和學習機會,同樣希望自己這個項目能幫到有需要的人。