Cycoe@Home

利用 python 實現自動搶報告

上了研究生才知道北化的研究生每年需要聽 15 個報告,而且最重要的是這些報告都是算分 數的,而且更重要的是你的分數是和最後的獎學金評定掛鉤的。這也就決定了大家爲了那麼 點報告分數擠教務網都擠破了頭(北化的服務器大家都懂),雖然場面不如開學搶課那麼火 爆,卻需要長時間掛着教務網瘋狂刷新,爲了交換個講座也需要大半夜起來,生怕被別人截 胡。爲了解放生產力,同時熟悉爬蟲技術、神經網絡技術、裝飾器等進階內容,寫了這個爬 蟲來練練手。

1 聲明

該爬蟲是本人 (Cycoe) 練習 python 編程技巧和神經網絡所編寫,使用造成的任何責任與 本人無關

項目地址

2 問題分解

想要順利的拿到搶講座的 session,需要如下的步驟:

  1. 處理登陸問題,包括處理驗證碼、頁面表單和 cookies
  2. 獲取報告列表
  3. 獲取搶報告的地址
  4. 提交表單數據

3 框架設計

好的框架應具有良好的可維護性和擴展性,目前正朝着這個方向努力

  1. 採用交互式的命令行設計,分離 login, robSpeech 等方法
  2. 將 login, robSpeech, robClass 等方法封裝成 Robber 對象
  3. 將底層的 requests 封裝成Spider 對象

4 逐步解決

4.1 构造 Headers

通過 firefox 或 chrome 的 debug 模式可以查看在訪問網頁時的 request 和 response。 仿照瀏覽器在訪問教務網時提交的 headers 構造如下字典

class Spider(object):
    @staticmethod
    def formatHeaders(referer=None, contentLength=None, originHost=None):
        """
        封裝請求的 headers

        :param referer: 跳轉標記,告訴 web 服務器自己是從哪個頁面跳轉過來的
        :param contentLength: 作用未知
        :param originHost: 原始主機地址
        :returns: headers 字典
        """
        headers = {
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8',
            'Accept-Encoding': 'gzip, deflate',
            'Accept-Language': 'en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7',
            'Cache-Control': 'max-age=0',
            'Connection': 'keep-alive',
            'Content-Type': 'application/x-www-form-urlencoded',
            'DNT': '1',
            'Host': 'graduate.buct.edu.cn:8080',
            'Upgrade-Insecure-Requests': '1',
            'User-Agent': 'Mozilla/5.0 (X11;Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.79 Safari/537.36',
            'Referer': referer,
            'Content-Length': contentLength,
            'Origin': originHost,
        }

4.2 构造请求对象

封裝 request 請求需要的 prepareBody 對象, response = session.send(prepareBody) ,response 就是我們拿到的服務器的響應對象,可通過 response.text 得到網頁內容, response.status_code 得到狀態碼, response.url 得到響應的地址。

def prepare(self, referer=None, originHost=None, method='GET',
            url=None, data=None, params=None):
    """ 生成用於請求的 prepare
    :param referer: 跳轉標記,告訴 web 服務器自己是從哪個頁面跳轉過來的
    :param originHost: 原始主機地址
    :param method: 請求方法 in ['GET', 'POST']
    :param url: 請求的 url 地址
    :param data: 封裝的 post 數據
    :param params: post 參數
    :return: prepare 對象
    """
    headers = self.formatHeaders(referer=referer, originHost=originHost)
    req = Request(method, url, headers=headers, data=data, params=params)
    return self.session.prepare_request(req)

prepareBody = prepare(
    referer=None, originHost=None, method='GET',
    url=UrlBean.jwglLoginUrl, data=None, params=None
)
response = session.send(prepareBody)

4.3 請求登錄

請求登錄網址,提交表單數據。根據 response 返回的響應體內容判斷是否登陸成功。

def getVIEWSTATE(self):
   """ 正則獲取頁面的 __VIEWSTATE

   :returns: 頁面的 __VIEWSTATE
   """
   VIEWSTATE = re.findall('<.*name="__VIEWSTATE".*value="(.*)?".*/>', self.response.text)
   if len(VIEWSTATE) > 0:
       return VIEWSTATE
   else:
       return None


def getEVENTVALIDATION(self):
   """ 正則獲取頁面的 __EVENTVALIDATION

   :returns: 頁面的 __EVENTVALIDATION
   """
   EVENTVALIDATION = re.findall('<.*name="__EVENTVALIDATION".*value="(.*)?".*/>', self.response.text)
   if len(EVENTVALIDATION) > 0:
       return EVENTVALIDATION
   else:
       return None

def login(self):
    """ 登錄教務網 """

    # 在登錄前請求一次登錄頁面,獲取網頁的隱藏表單數據
    prepareBody = self.prepare(referer=None,
                                originHost=None,
                                method='GET',
                                url=UrlBean.jwglLoginUrl,
                                data=None,
                                params=None)

    # 登陸主循環
    while True:
        self.response = self.session.send(prepareBody)
        self.VIEWSTATE = self.getVIEWSTATE()
        self.EVENTVALIDATION = self.getEVENTVALIDATION()
        if self.VIEWSTATE is not None and self.EVENTVALIDATION is not None:
            break
        Logger.log("Retrying fetching login page viewState...", level=Logger.warning)

    reInput = True      # 是否需要重新輸入用戶名和密碼
    while True:
        # 輸入用戶名和密碼
        if reInput:
            if Config.checkUserFile():
                Config.readUserInfo()
            else:
                Config.userName = input("> UserName: ")
                Config.password = input("> Password: ")
            reInput = False

        prepareBody = self.prepare(referer=UrlBean.jwglLoginUrl,
                                    originHost=None,
                                    method='GET',
                                    url=UrlBean.verifyCodeUrl,
                                    data=None,
                                    params=None)

        while True:
            codeImg = self.session.send(prepareBody)  # 獲取驗證碼圖片
            if codeImg.status_code == 200:
                break
            else:
                Logger.log("retrying fetching vertify code...", level=Logger.warning)

        with open('check.gif', 'wb') as fr:  # 保存驗證碼圖片
            for chunk in codeImg:
                fr.write(chunk)

        print_vertify_code()
        verCode = input("input verify code:")
        # verCode = self.classifier.recognizer("check.gif")  # 識別驗證碼

        # 構造登陸表單
        postData = {
            '__VIEWSTATE': self.VIEWSTATE,
            '__EVENTVALIDATION': self.EVENTVALIDATION,
            '_ctl0:txtusername': Config.userName,
            '_ctl0:txtpassword': Config.password,
            '_ctl0:txtyzm': verCode,
            '_ctl0:ImageButton1.x': '43',
            '_ctl0:ImageButton1.y': '21',
        }
        prepareBody = self.prepare(referer=UrlBean.jwglLoginUrl,
                                    originHost=UrlBean.jwglOriginUrl,
                                    method='POST',
                                    url=UrlBean.jwglLoginUrl,
                                    data=postData,
                                    params=None)

        # 獲取登陸 response
        while True:
            self.response = self.session.send(prepareBody)
            if self.response.status_code == 200:
                break

        # 根據返回的 html 判斷是否登錄成功
        if re.search('用戶名不存在', self.response.text):
            Logger.log('No such a user!', ['Cleaning password file'], level=Logger.error)
            print(OutputFormater.table([['No such a user!'], ['Cleaning password file']], padding=2))
            Config.cleanUserInfo()
            reInput = True

        elif re.search('密碼錯誤', self.response.text):
            Logger.log('Wrong password!', ['Cleaning password file'], level=Logger.error)
            print(OutputFormater.table([['Wrong password!'], ['Cleaning password file']], padding=2))
            Config.cleanUserInfo()
            reInput = True

        elif re.search('請輸入驗證碼', self.response.text):
            Logger.log('Please input vertify code!', ['Retrying...'], level=Logger.error)
            print(OutputFormater.table([['Please input vertify code!'], ['Retrying...']], padding=2))

        elif re.search('驗證碼錯誤', self.response.text):
            Logger.log('Wrong vertify code!', ['Retrying...'], level=Logger.error)
            print(OutputFormater.table([['Wrong vertify code!'], ['Retrying...']], padding=2))

        else:
            Logger.log('Login successfully!', ['UserName: ' + Config.userName, 'Password: ' + Config.password], level=Logger.warning)
            print(OutputFormater.table([['Login successfully!']], padding=2))
            Config.dumpUserInfo()
            break

4.4 拿到 session

拿到已登陸的 session 後,搶課和搶報告都是非常方便的,只要按照瀏覽器提交的數據構 造 headers 和表單數據後就可以獲得正常的 response

5 暗坑總結

  1. 剛開始抓到的網頁內容中文都是亂碼,後來 google 解決,發現是 python 的編碼和 asp 框架的編碼問題造成的,python 中的編碼問題真的是讓人頭大
  2. 由於網站的防爬蟲設計,會在 html 源碼中插入很多隱藏的表單數據,如此處的 __VIEWSTATE__EVENTVALIDATION ,這兩個是非常重要的參數。否則無法成功登陸
  3. 兩次訪問之間要有一定的時間間隔,如此處用了一個隨機函數的閉包來獲得隨機時間的 間隔
  4. 使用裝飾器解決了在訪問搶課網頁前判斷登錄的問題
  5. 接下來將循環封裝成函數,加入最大循環次數和超時
  6. 完善邊界檢查和異常處理
Author: Cycoe (cycoejoo@163.com)
Date: <2017-10-28 Sat>
Generator: Emacs 28.0.50 (Org mode 9.3)
Built: <2020-05-21 Thu 20:09>