无论是成绩查询或课表查询亦或者其它的信息查询,都必须是要在登录状态下才能进行。而要登录教务系统,就要先获取登录的验证码,然后输入学号密码和验证码,向教务系统发起登录请求,登录成功后,需要保存登录状态,即记录cookie。有了登录成功后的cookie,就能对其他页面发起请求,旧版的正方系统返回的是Html,所以拿到请求结果后,还要再进行Html的解析,进而筛选出自己所需要的信息。
项目用的是Springboot
搭建项目,因为当时简单用Vue
搭了个前台,所以数据传输都是用的Json,实现了前后端的分离,主要是用到了Spring
的IoC容器管理bean还有控制器类。第三方依赖是用Maven
来管理。实际上,这个项目不一定要用SpringBoot,可以根据自己的需要进行迁移。代码包结构如下:
GloabalConstant
类:全局常量类,存放了所有的请求URL,包括教务系统首页、登录请求地址、验证码请求地址等,这些URL需要根据自己的实际情况进行手动更改,把域名部分换成自己学校正方系统首页的地址就行。另外就是登录页的错误信息,为了方便调试代码,也进行了保存。HttpService
类:Http服务类,封装了get请求、post请求,以及HttpClient的初始化,同时所有关于爬取逻辑的代码都是在这个类里,包括登录、验证码获取与识别、课表表获取、成绩表获取等。JavaOCR
类:验证码识别类,包括验证码识别的整个过程,**由于验证码识别训练涉及到数据集、测试集、结果集,启动代码时,请根据自己的实际情况,在配置文件执行修改trainSetDir
、trainTestDir
、trainResultDir
这几个目录所在的位置。**验证码识别的训练与使用是分开的,项目运行时只会在HttpService
中读取训练结果集,如果要自己进行验证码的训练(理论上测试集验证码图片越多,识别率越高,我总共用了近700张,识别率稳定在62%左右),在src.test.java.*
下有代码示例。配置文件用的是yml格式,application-dev.yml
是开发环境的配置文件,application-prod.yml
是生产环境(linux下)的配置文件,可以自定义端口以及JavaOCR目录。
在获取Cookies后,以后的每一次请求都要把Cookies带上。
请求时要注意目标请求是否需要Referer。Referer告诉服务器我是从哪个页面链接过来的,服务器基此可以获得一些信息用于处理,有网页会限定请求的上一个地址。
我用的Google Chorm,在首页按F12打开浏览器自带的页面审查工具,随便输入学号密码和验证码,点击登录后,浏览器会向服务器提交一个post请求,请求地址为:http://xxxxxxxxxx/default2.aspx。
仔细观察上面的Form Data表单,发现有以下几个关键表单项:
其他像Textbox1、Button1这些表单项的value值都是空白的,说明在登录中并不起作用。
获取到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();
}
/**
* 获取验证码
*
* @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;
}
/**
* 登陆
*
* @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;
}
/**
* 访问系统首页,查找并保存查询各种信息的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 = "(.+?)";
// 提取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()) {
// 学生个人课表
String res = matcher.group();
// xskbcx.aspx?xh=xxxxxxxxx&xm=某某某&gnmkdm=N121603" target='zhuti' οnclick="GetMc('学生个人课表');">学生个人课表
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;
}
可以按照前面的分析登录页面那样,来分析查询课表页面。正方教务系统,查询当前学期的课表时,发送的是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);
}
项目地址:https://github.com/James0608/ZhengFangJWSystemBackend
欢迎Fork,喜欢的话,给个Star呗 hiahia~
src/resource/ocr/
目录下的train_set(数据集)、train_test(测试集)、train_result(结果集)、record(每次登录记录验证码识别结果)这四个文件夹复制到image下,这样子就不用修改application-dev.yml
。反过来,也可以通过修改配置文件来自定义加载路径。Linux用户请参考application-prod.yml
配置文件的路径来创建。GlobalConstant
类下的URL为自己学校正方教务管理系统的地址,一般是只需要更改域名部分,后面的子路径即使是不同学校也不会有变化。请检查是否是路径错误,是否已经正确的按要求创建了所需要的目录
请检查GlobalConstant
类下的URL与教务系统上的请求URL是否一致
请检查正方教务管理系统FormData(post请求的body)的key是否与项目代码中的一致
不同学校的系统,可能在表单参数的名称上有所差异,请根据自己的实际情况更改HttpService
类里对应的代码。
可以在Github上提issiue,也可以直接到博客文章下进行评论,详细描述错误现象,错误是否可重现等。
本项目的验证码识别部分是在Allenhua的自动识别验证码项目的基础上完成的,特此鸣谢。
参考文章:
[1]:用java模拟登录正方教务系统,抓取课表和个人成绩等数据
[2]:爬取正方教务管理系统获取学生信息
感谢为开源工作做出奉献的每一个开发者,开源意味着更多的交流机会和学习机会,同样希望自己这个项目能帮到有需要的人。