人到大三、也不想考研。在留在大學有限的時間裏,想做一些有意義的事情。第一個想法是開發一個應用,可以查課表、成績、空教室等等,畢竟在我的學校大多數人使用的超級課程表,面對那麼多用戶,那麼多高校的教務系統,所以難免會有些高延遲。如果我們縮小用戶範圍,僅僅面對我們學校,那速度肯定會快很多的,但是詢問過學校的信息中心,發現高校用的教務系統是外包給外面的公司管理的,並沒有API接口。於是首要任務變成了提供API接口。
很顯然,教務官網沒有提供API接口給我們,於是只能自己模擬登陸寫爬蟲了。在搜索引擎上進行搜索,發現有類似想法的人還是很多的,但是我逛了一大圈、居然只有Java、Python兩個版本,沒有別的版本了,但好在思路很清晰。
爲了後期開發方便,提供接口,我用JavaScript重構一下。我相信開發網站的全乾工程師們,比起Java和Python,後端用Nodejs會更加順手一點吧,也在此文中記錄一些踩過的坑。
雖然網絡上已經對爬取的過程有了原理性的介紹了,但爲了避免讀者還要反覆閱讀別的文章的麻煩、這裏還是要詳細的介紹一下流程。
本次開發使用的環境
操作系統:MacOS
開發框架:Egg.js(阿里爲了規範提出的框架, 熟悉nodejs的es6語法和koa2框架的同學一個多小時就可以上手)
瀏覽器和抓包工具:Chrome/Safari、Charles
開源倉庫地址:戳我前往
模擬登陸
第一步、分析登陸表單
先貼出本次實驗的正方教務系統的界面,省的不同的看官白費力氣,但就算界面不同,思路肯定也是相同的,看看也無妨。
按F12打開開發者工具,調到Network這欄(不知道爲啥寫文章的時候我的chrome有點抽風,所以我打開了charles,該工具類似於Windows上的Fiddler),我們首先模擬一次失敗的登陸,我這裏鍵入虛假的用戶名和密碼:1543140220/123456。
前端給後端發了兩個請求,我們來逐一分析。
第一個請求
其實從請求的名字就能看得出來,是獲取加密的公鑰的,可以大膽的猜測這是一個RSA加密了,等會兒我們分析js代碼的時候會證明這一點,所以我們要記住modulus和exponent這兩個參數。
第二個請求
第二個請求攜帶的參數就多了
csrftoken:這個我們一般是用來防止網站遭受xss跨站請求腳本攻擊的時候弄的玩意
如何獲取這個令牌呢?
搜索一下網頁的html可以發現,這玩意藏在表單的一個隱藏標籤裏。在我的項目中,使用了cheerio這個庫來解析HTML。相關代碼如下:
async get_csrf_token(session, time) {
const ctx = this.ctx;
let headers = {
'Host': 'jwgl.njtech.edu.cn',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:58.0) Gecko/20100101 Firefox/58.0',
'Accept-Encoding': 'gzip, deflate',
'Accept': '*/*',
'Connection': 'keep-alive',
'Cookie': session
}
const options = {
headers,
}
const url = await this.service.common.get_login_url();
const res = await ctx.curl(url + time, options);
const resultHtml = (res.data.toString());
const cheerioModel = cheerio.load(resultHtml);
const csrf_token = cheerioModel('#csrftoken')[0].attribs.value;
return { token: csrf_token, session }
}
yhm:這個是用戶名
mm:密碼,明顯是加密過的。But爲什麼要寫兩次?我覺得很迷惑,反正請求的時候發一個過去就行了。
第二步、獲取加密密碼
分析網頁端的加密算法
還是分析Web前端的js腳本,看看他是怎麼加密的。在開發者工具裏搜索何時給“mm”賦值、找到了這一段,果然是rsa加密。
但nodejs的rsa加密真是坑了我一天,我本來使用的是node-rsa這一個庫、似乎除了這個庫nodejs沒有別的用來rsa加密的庫了。而網頁上的rsa加密算法,貌似是自己擼出來的、代碼奇醜無比。但是明明生成的RSA對象的key跟相同參數生成的網頁端debug生成的key是一樣的,驗證就是不通過。
最後很無奈,只好把網頁上的rsa加密用的js代碼拷貝下來,封裝成utils的一個接口了,事實證明這是一個好辦法。
以上是我叨逼叨、只是爲了介紹了我項目中utils下rsa.js文件的由來。有了密碼的明文、RSA算法、公鑰對、我們就能夠正確求出加密後的算法了。
第三步、判斷是否登錄成功
總之,我們有了密碼、csrf_token、外加模仿他正常請求的header,就能夠正確登陸了。但我們如何判斷是否登錄成功?
當登陸失敗的時候,服務器會給我們返回HTML、有點low。但返回的html中包含字段"用戶名或密碼不正確"。只需要判斷返回的文本中中是否包含這句話,就可以判斷是否登錄成功了。總之,登錄的流程代碼如下:
async login(username, password, time) {
let { modulus, exponent, session } = await this.service.common.get_public_key(time);
let { token } = await this.service.common.get_csrf_token(session, time);
let enpassword = await this.service.common.process_public(password, modulus, exponent);
let data = {
'csrftoken': token,
'mm': enpassword,
'yhm': username
};
const url = await this.service.common.get_login_url();
let headers = {
'Host': 'jwgl.njtech.edu.cn',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:58.0) Gecko/20100101 Firefox/58.0',
'Accept': 'text/html, */*; q=0.01',
'Accept-Encoding': 'gzip, deflate',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Referer': url + time,
'Upgrade-Insecure-Requests': '1',
'Cookie': session,
'Connection': 'keep-alive',
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
}
const options = {
method: 'POST',
headers,
data,
}
const ctx = this.ctx;
const result = await ctx.curl(url, options);
const regValue = '用戶名或密碼不正確'
if (result.data.toString().indexOf(regValue) > 0) {
return {
success: false,
message: regValue
}
}
else {
return {
success: true,
message: '登陸成功',
session: result.headers['set-cookie']
}
}
}
當登陸成功之後,服務器給我們返回的響應裏包含"Set-Cookie"字段。
拿到這個JSESSIONID把它放到以後請求的Cookie裏,就可以爲所欲爲啦。
到這裏,模擬登陸的思路介紹完了。
演示:獲取成績
在上一節,模擬登陸過後。我們拿到了JSessionID,如果你還是比較迷茫,那我再掩飾一下如何獲取成績?
第一步、模擬獲取成績
在這裏插入圖片描述
現在網頁端模擬一下獲得成績、再看看抓到的包。
首先看Cookie、是JSESSIONID字段、第二個字段測試了一下不加也沒事。
在看發送的表單內容
第一欄是學年、第二欄是學期。這裏的學期比較坑、好像是加密過的,大家只要記住第一學期是3、第二學期是12就好了、這個可以自己試出來的,nd是時間戳。其他照抄就好。
然後看他返回的數據:
PS:醜的一批
依稀能分辨出來,這個是根據拼音來命名的。
所以獲取成績的API的代碼是這樣的:
async grade() {
const { ctx } = this;
const { username, password, year, term } = ctx.request.body;
const time = await this.service.common.get_time();
const loginInfo = await this.service.login.login(username, password, time);
if (loginInfo.success) {
const gradeInfo = await this.service.grade.post_grade_data(year, term, loginInfo.session)
ctx.body = gradeInfo
} else {
ctx.body = {
success: false,
message: loginInfo.message
}
}
}
async post_grade_data(year, term, session) {
// 校驗
if (!parseInt(year) || parseInt(year) > (new Date().getFullYear())) {
return {
success: false,
message: "請求課程年份出錯"
}
}
// 默認第一學期
let form_term = '3';
if (parseInt(term) === 1) {
form_term = '3'
} else if (parseInt(term) === 2) {
form_term = '12'
}
const url = await this.service.common.get_grade_url();
const data = {
'_search': 'false',
'nd': this.service.common.get_time(),
'queryModel.currentPage': '1',
'queryModel.showCount': '15',
'queryModel.sortName': '',
'queryModel.sortOrder': 'asc',
'time': '0',
'xnm': year,
'xqm': form_term
}
let headers = {
'Host': 'jwgl.njtech.edu.cn',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:58.0) Gecko/20100101 Firefox/58.0',
'Accept': 'text/html, */*; q=0.01',
'Accept-Encoding': 'gzip, deflate',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Referer': url,
'Upgrade-Insecure-Requests': '1',
'Cookie': session,
'Connection': 'keep-alive',
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
}
const options = {
method: 'POST',
headers,
data,
}
const ctx = this.ctx;
const result = await ctx.curl(url, options);
const response_data = JSON.parse(result.data.toString());
const courseitems = response_data.items;
const grade = courseitems.map(currentValue => {
return {
name: currentValue.kcmc,
credit: currentValue.xf,
grade: currentValue.bfzcj,
point: currentValue.jd,
teacher: currentValue.jsxm
}
})
return {
success: true,
message: "請求課程成績成功",
grade: grade
};
}
結果演示
如果你覺得這個過程寫的不夠好,也可以參考 這位博主的Python版本
另外,還可以去我的個人博客閱讀,雖然沒有開啓評論功能哈哈
博客地址:點擊前往
另外再次貼上Github倉庫鏈接:戳我前往