前言

之前簡單學習過python爬蟲基礎知識,並且用過scrapy框架爬取資料,都是直接能用xpath定位到目標區域然後爬取。可這次碰到的需求是爬取一個用asp.net編寫的教育網站並且將教學ppt一次性爬取下來,由於該網站部分內容渲染採用了js,所以比較難用xpath直接定位,同時發起下載ppt的請求比較難找。

經過琢磨和嘗試後爬取成功,記錄整個爬取思路供自己和大家學習。文章比較詳細,對於一些工具包和相關函式的使用會在原始碼或正文中添加註釋來介紹簡單相關知識點,如果某些地方看不懂可以通過註釋及時去查閱簡單瞭解,然後繼續閱讀。(尾部有原始碼,全文僅對一些敏感的個人資訊資料進行了省略。)

一、主要思路

1、觀察網站

  1. 研究從進入網站到成功下載資源需要幾次url跳轉。

  2. 先進入目標網站首頁,依次點選教材->選擇初中->選擇教輔->選擇學科->xxx->資源列表->點選下載ppt。

    目標網站首頁

    資源列表

    資源詳情頁

  3. 分析url每步跳轉以及資源下載是否需要cookie等header資訊。

    通過一步步跳轉進入到最終的資源詳情頁,最終點選下載資源按鈕時網站提示並且跳轉到了登陸頁面,說明發起下載的請求可能需要攜帶cookie等頭部資訊。

2、編寫爬蟲程式碼

  1. 登陸賬戶,獲取到識別使用者的cookies
  2. 請求資源列表頁面,定位獲得左側目錄每一章的跳轉url。
  3. 請求每個跳轉url,定位資源列表頁面右側下載資源按鈕的url請求(注意2、3步是圖資源列表)
  4. 發起url請求,進入資源詳情頁,定位獲得下載資源按鈕的url請求(第4步是圖資源詳情頁)
  5. 發起請求,將下載的資源資料寫入檔案。

這是本次爬蟲實戰編寫程式碼的大致思路,具體每次步驟碰到的難點以及如何解決在接下來的實戰介紹中會進行詳細分析。

二、爬蟲實戰

1、登陸獲取cookie

  1. 首先網站登陸,獲取到cookie和user-agent,作為之後請求的頭部。設定全域性變數HEADER,方便呼叫

    HEADER = {
    'User-Agent':
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36(KHTML, like Gecko)Chrome/93.0.4577.63 Safari/537.36", 'Cookie':"xxxxxxx",
    }

2、請求資源列表頁面,定位獲得左側目錄每一章的跳轉url(難點)

  1. 首先使用requests發起資源列表頁面的請求(資源列表頁面url:http://www.guishiyun.com/res_list.aspx?rid=9&tags=2-24,1-21,3-70,12-96)

    資源列表

    BASE_URL = "http://www.guishiyun.com" #賦值網站根域名作為全域性變數,方便呼叫
    
    res = requests.get(BASE_URL +
    "/res_list.aspx?rid=9&tags=1-21,12-96,2-24,3-70",
    headers=HEADER).text #發起請求,獲得資源列表頁面的html
  2. 難點:定位獲得左側目錄每一章的跳轉url

    1. 正常思路:開啟瀏覽器控制檯,檢視網頁原始碼,尋找頁面左側課程目錄的章節在哪個元素內,用xpath定位。

    2. 使用xpath定位,發現無法定位到這個a標籤,在確認xpath語法無錯誤後,嘗試列印上個程式碼段中的res變數(也就是該html頁面),發現返回的頁面和控制檯頁面不同。

    3. 轉換思路:可能該頁面使用其他渲染方式渲染了html,導致瀏覽器控制檯看到的html和請求返回的不一樣(瀏覽器會將渲染後的頁面呈現),開啟控制檯,檢視頁面原始碼,搜素九年級上冊(左側目錄標題),發現在js的script指令碼中,得出該頁面應該是通過JS渲染DOM得來的,該js物件中含有跳轉的url。

    4. xpath行不通後,我選擇採用正則表示式的方式直接篩選出該程式碼。

      import re #匯入re 正則表示式包
      
      pattern = r'var zNodes = (\[\s*[\s\S]*\])'
      #定義正則表示式,規則:找出以"var zNodes = [ \n"開頭,含有"[多個字元或空格]"的字元,並且以"]"結尾的文字 (相關知識不熟悉的可以簡單看看菜鳥的正則表示式)
      result = re.findall(pattern, res, re.M | re.I)
      #python正則表示式,查詢res中符合pattern規則的文字。re.M多行匹配,re.I忽略大小寫。

      將前兩個程式碼塊封裝一下

      def getRootText():
      res = requests.get(BASE_URL +
      "/res_list.aspx?rid=9&tags=1-21,12-96,2-24,3-70",
      headers=HEADER).text #請求
      pattern = r'var zNodes = (\[\s*[\s\S]*\])'
      result = re.findall(pattern, res, re.M | re.I)
      return result[0] #獲得篩選結果 [{id: 1322, pI': 1122, name: '九年級上冊', open: False, url: ?catId=1322&tags=1-21%2c12-96%2c2-24%2c3-70&rid=9#bottom_content', target: '_self'}, {...},{...}]
    5. 將結果轉換成dict型別,方便遍歷,獲得每個章節的url。瀏覽上面得出的result發現,{id:1322,pId:xxx...}並不是標準的json格式(key沒有引號),此時使用第三方包demjson,用於將不規則的json字串變成python的dict物件。

      import demjson
      def textToDict(text):
      data = demjson.decode(text)
      #獲得篩選結果[{'id': 1322, 'pId': 1122, 'name': '九年級上冊', 'open': False, 'url': '?catId=1322&tags=1-21%2c12-96%2c2-24%2c3-70&rid=9#bottom_content', 'target': '_self'}, {...},{...}]
      return data
    6. 遍歷轉換好的dict資料,獲得左側目錄每一章的url。此處需要注意的是,本人目的是下載每一章的ppt課件,所以我只需要請求每一個總章節的url(即請求第 1 章,第 2 章,不需要請求 1.1反比例函式),右邊就會顯示該章節下的所有ppt課件。所以我在遍歷的時候,可以通過正則表示式,篩選出符合名稱要求的url,新增進list並且返回。

      def getUrls(dictData):
      list = []
      pattern = r'第[\s\S]*?章' #正則規則:找出以"第"開頭,中間包含多個空格和文字,以"章"結尾的文字
      for data in dictData: #遍歷上文轉換得到的dict陣列物件
      if len(re.findall(pattern, data['name'])) != 0:
      list.append(data['url']) #如果符合則將該url新增到列表中
      return list

3、請求每個跳轉url,定位右側下載資源按鈕,獲得url請求

  1. 遍歷從上面獲得的url列表,通過拼接網站域名獲得網站url,然後發起請求

    def download(urlList): # urlList是上面獲得的list
    for url in urlList:
    res = requests.get(BASE_URL + '/res_list.aspx/' + url, HEADER).text #完整url請求,獲得頁面html
  2. 檢視原始碼,發現可以用xpath定位(目標是獲取到onclick裡的url)

    分析:該按鈕元素 (<input type=button>)在<div class='res_list'><ul><li><div class="button_area">裡。xpath定位程式碼如下:

    root = etree.HTML(res) # 構造一個xpath物件
    liList = root.xpath('//div[@class="res_list"]//ul//li') #xpath語法,返回多個<li>及子元素物件的列表
  3. 遍歷liList ,獲得資源名字(為之後下載寫入ppt的檔案命名)以及跳轉到資源詳情下載頁的url

    for li in liList:
    name = li.xpath('.//div[@class="info_area"]//div//h1//text()')
    name = name[0] # xpath返回的是包含name的列表,從中提取字串 print(name): 1.1 反比例函式
    btnurl = li.xpath('.//div[@class="button_area"]//@onclick') # 獲得onlick內的字串 "window.open('res_view.aspx....')"
    pattern = r'\(\'([\s\S]*?)\'\)'# 只需要window.open內的url,所以採用正則提取出來。
    btnurl1 = re.findall(pattern, btnurl[0])

4、跳轉到資源詳情下載頁,獲得真正的下載請求(難點)

  1. 上文程式碼段中獲取到url之後依舊是拼接域名,然後通過完整url發起請求,獲得資源詳情下載頁面的html資料。

     res1 = requests.get(BASE_URL + '/' + btnurl1[0], HEADER).text

跳轉後的詳情頁面

  1. 檢視原始碼後按鈕本身只是觸發表單提交,而且是post請求。點選下載資源按鈕,使用瀏覽器控制檯抓包檢視post請求需要的引數。

    使用ctrl+f在網頁原始碼中搜素這幾個引數,發現存在於<input> 標籤中,只是被css 隱藏了,所以接下來就是簡單的用xpath 和正則表示式將post請求中的url和這幾個引數值獲得,然後新增到header中發起請求就行了。

    VIEWSTATE = '__VIEWSTATE'              # 全域性變數,定義屬性名稱
    VIEWSTATEGENERATOR = '__VIEWSTATEGENERATOR'
    EVENTVALIDATION = '__EVENTVALIDATION'
    BUTTON = 'BUTTON'
    BUTTON_value = '下 載 資 源'
    root1 = etree.HTML(res1) # res1是之前程式碼段請求的html文字
    form = root1.xpath('//form[@id="form1"]') # xpath定位到form
    action = root1.xpath('//form[@id="form1"]/@action')
    action = re.findall(r'(/[\S]*?&[\S]*?)&', action[0], re.I) #正則表示式獲取form中action函式裡的url
    VIEWSTATE_value = form[0].xpath(
    './/input[@name="__VIEWSTATE"]//@value') #獲取引數值
    VIEWSTATEGENERATOR_value = form[0].xpath(
    './/input[@name="__VIEWSTATEGENERATOR"]//@value')#獲取引數值
    EVENTVALIDATION_value = form[0].xpath(
    './/input[@name="__EVENTVALIDATION"]/@value')#獲取引數值
    data = { # post提交所需要的data引數
    VIEWSTATE: VIEWSTATE_value,
    VIEWSTATEGENERATOR: VIEWSTATEGENERATOR_value,
    EVENTVALIDATION: EVENTVALIDATION_value,
    BUTTON: BUTTON_value
    }
    res2 = requests.post(BASE_URL + action[0],data=data,headers=HEADER).text #發起請求
  2. 此時發起請求之後發現返回的仍然是網頁html,如果開啟控制檯工具,檢視點選按鈕發起請求後的頁面。

    同時看到由於是更新頁面,還產生了許多其他各種各樣的請求,一時間很難找到真正下載檔案的請求是哪一個。

  3. 此時筆者想到的是一個笨方法,通過抓包工具,對所有請求進行攔截,然後一個個請求陸續通過,最終就可以找到下載請求。這裡筆者用到的是BurpSuite 工具,陸續放行請求,觀察頁面是否有下載介面出現,找到了url:/code/down_res.ashx?id=xxx ,同時在瀏覽器控制檯查詢這一串字串,最終在post請求返回的頁面中找到了這個字串的位置

    不用多說,直接正則獲取

     downUrl = re.search(r'\<script\>[\s]*?location\.href\s=\s\'([\S]*?)\'',res2,re.I) #正則篩選出url
    downUrl_text = downUrl.group(1)
  4. 發起請求,並且將資料讀寫進指定的目錄中。

    downPPT = requests.get(BASE_URL+downUrl_text,headers=HEADER)
    with open(f'./test/{name}.ppt','wb') as f: #將下載的資料以二進位制的形式寫入到當前專案下test資料夾中,並且做好命名。name引數在上文中已經獲得。
    f.write(downPPT.content)
  5. 結果

5、新增額外功能,實現增量爬蟲

  1. 爬取到一半發現程式終止了,原來該網站對每個賬號每天下載數有限額,而我們的程式每次執行都會從頭開始檢索,如何對已經爬取過的url進行儲存,同時下次程式執行時對已爬取過的url進行識別?這裡筆者使用的是通過redis進行儲存,原理是對每次下載的url進行儲存,在每次發起下載請求時先判斷是否已經儲存,如果已經儲存則跳過本次迴圈。

    if(r.sadd(BASE_URL + action[0],'1')==0): # sadd是redis新增鍵值的方法,如果==0說明已經存在,新增失敗。
    continue

6、總原始碼

import re
import requests
from lxml import etree
import demjson
import redis pool = redis.ConnectionPool(host='localhost', port=6379, decode_responses=True)
r = redis.Redis('localhost',6379,decode_responses=True) BASE_URL = "http://www.guishiyun.com"
HEADER = {
'User-Agent':
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36",
'Cookie':
"xxx",
}
VIEWSTATE = '__VIEWSTATE'
VIEWSTATEGENERATOR = '__VIEWSTATEGENERATOR'
EVENTVALIDATION = '__EVENTVALIDATION'
BUTTON = 'BUTTON'
BUTTON_value = '下 載 資 源' def getRootText():
res = requests.get(BASE_URL +
"/res_list.aspx?rid=9&tags=1-21,12-96,2-24,3-70",
headers=HEADER).text
pattern = r'var zNodes = (\[\s*[\s\S]*\])'
result = re.findall(pattern, res, re.M | re.I)
return result[0] def textToDict(text):
data = demjson.decode(text)
print(data)
return data def getUrls(dictData):
list = []
pattern = r'第[\s\S]*?章'
for data in dictData:
if len(re.findall(pattern, data['name'])) != 0:
list.append(data['url'])
return list def download(urlList):
global r
for url in urlList:
res = requests.get(BASE_URL + '/res_list.aspx/' + url, HEADER).text
root = etree.HTML(res)
liList = root.xpath('//div[@class="res_list"]//ul//li')
for li in liList:
name = li.xpath('.//div[@class="info_area"]//div//h1//text()')
name = name[0]
btnurl = li.xpath('.//div[@class="button_area"]//@onclick')
pattern = r'\(\'([\s\S]*?)\'\)'
btnurl1 = re.findall(pattern, btnurl[0])
res1 = requests.get(BASE_URL + '/' + btnurl1[0], HEADER).text
root1 = etree.HTML(res1)
form = root1.xpath('//form[@id="form1"]')
action = root1.xpath('//form[@id="form1"]/@action')
action = re.findall(r'(/[\S]*?&[\S]*?)&', action[0], re.I)
VIEWSTATE_value = form[0].xpath(
'.//input[@name="__VIEWSTATE"]//@value')
VIEWSTATEGENERATOR_value = form[0].xpath(
'.//input[@name="__VIEWSTATEGENERATOR"]//@value')
EVENTVALIDATION_value = form[0].xpath(
'.//input[@name="__EVENTVALIDATION"]/@value')
data = {
VIEWSTATE: VIEWSTATE_value,
VIEWSTATEGENERATOR: VIEWSTATEGENERATOR_value,
EVENTVALIDATION: EVENTVALIDATION_value,
BUTTON: BUTTON_value
}
if(r.sadd(BASE_URL + action[0],'1')==0):
continue
res2 = requests.post(BASE_URL + action[0],data=data,headers=HEADER).text
downUrl = re.search(r'\<script\>[\s]*?location\.href\s=\s\'([\S]*?)\'',res2,re.I)
downUrl_text = downUrl.group(1)
if(r.sadd(BASE_URL+downUrl_text,BASE_URL+downUrl_text,downUrl_text)==0):
continue
downPPT = requests.get(BASE_URL+downUrl_text,headers=HEADER)
with open(f'./test/{name}.ppt','wb') as f:
f.write(downPPT.content) def main():
text = getRootText()
dictData = textToDict(text)
list = getUrls(dictData)
# download(list) if __name__ == '__main__':
main()

三、總結

之前只是學習過最簡單最基礎的requests請求+xpath 定位的爬蟲方式,這次碰巧遇到了較為麻煩的爬蟲實戰,所以寫下爬蟲思路和實戰筆記,加深自己印象的同時也希望能對大家有所幫助。當然這次爬蟲總的來說還是比較簡單,還沒有考慮代理+多執行緒等情況,同時還可以使用selenium等瀏覽器渲染工具,就可以不用正則定位了,當然筆者是為了順便學習一下正則。

如果有所幫助,歡迎大家點贊收藏並且進行友好的評論交流。同時歡迎訪問我的個人部落格空間進行各種技術學習 歡迎來到菜鳥小白的空間