正方教務爬蟲JavaScript版

人到大三、也不想考研。在留在大學有限的時間裏,想做一些有意義的事情。第一個想法是開發一個應用,可以查課表、成績、空教室等等,畢竟在我的學校大多數人使用的超級課程表,面對那麼多用戶,那麼多高校的教務系統,所以難免會有些高延遲。如果我們縮小用戶範圍,僅僅面對我們學校,那速度肯定會快很多的,但是詢問過學校的信息中心,發現高校用的教務系統是外包給外面的公司管理的,並沒有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字段、第二個字段測試了一下不加也沒事。
在這裏插入圖片描述
在看發送的表單內容

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-3gYhYF1U-1584896295432)(/Users/wanglei/Library/Application Support/typora-user-images/image-20200323004010062.png)]

第一欄是學年、第二欄是學期。這裏的學期比較坑、好像是加密過的,大家只要記住第一學期是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倉庫鏈接:戳我前往

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章