1. 程式人生 > >Python3.X 爬蟲實戰(動態頁面爬取解析)

Python3.X 爬蟲實戰(動態頁面爬取解析)

1 背景

不知不覺關於 Python 3.X 爬蟲系列已經介紹瞭如下系列:

到此關於 Python3.x 靜態頁面爬蟲的基礎核心基本已經介紹的差不多了,剩下的就是一些自己個性化的需求了,譬如爬取資料分析等,這種我們後面還會專門來說的。然而我們在該系列的《Python3.X 爬蟲實戰(靜態下載器與解析器)》一文時給自己留了一個鍋,這篇我們的重點就是來背這個鍋———動態頁面爬取解析。之所以叫動態頁面爬取解析其實是相對於靜態下載器與解析器來說的,因為有時候我們使用靜態下載器與解析器對一些要爬取的頁面進行解析時竟然沒有任何資料,其實大多原因都是我們要爬取的元素是 JS 動態生成的,譬如我們爬取今日頭條頁面,你會發現今日頭條隨著我們手指上滑其頁面會無限制的上拉載入更多,也就是常說的瀑布流,這時候我們就會覺得該系列前面介紹的爬取方式似乎完全無能為力了,所以我們需要尋求新的爬取解析方式,也就是動態頁面爬取解析,其流行的核心主流思路是動態頁面逆向分析爬取和模擬瀏覽器行為爬取,本篇會詳細探討說明。

這裡寫圖片描述

2 Python3.X 動態頁面逆向分析爬取

以這種方式進行動態頁面的爬取實質就是對頁面進行逆向分析,其核心就是跟蹤頁面的互動行為 JS 觸發排程,分析出有價值、有意義的核心呼叫(一般都是通過 JS 發起一個 HTTP 請求),然後我們使用 Python 直接訪問逆向到的連結獲取價值資料。下面我們以一個實戰從頭到尾來演示一遍如何逆向分析爬取動態網頁今日頭條的資料,目標是爬取今日頭條搜尋出來 list(譬如搜尋美女、風景)中每個頭條文章點進去詳情頁的所有大圖,然後把他們分類下載下來,首先我們看下今日頭條搜尋介面如下:

這裡寫圖片描述

我們爬蟲要乾的事就是仿照上面在搜尋框輸入“美女”,然後點選搜尋得到結果,然後對於結果頁面挨個點進去詳情頁面,然後把詳情頁面裡的大圖都爬取下載下來。這時候如果你上來就按照我們前面系列介紹的靜態分析你會發現我們點選完搜尋以後上面頁面的原始碼中這個列表只有有限的幾十項,如下:

這裡寫圖片描述

然而我們期望的搜尋結果可不是這點啊,所以我們嘗試上滑網頁會發現怎麼頁面的連結沒變,但是每次上拉到底部就會自動載入更多 item 出來,納尼,靜態爬取遇到這種情況只能懵逼啊,所以我們接下來需要做的就是來逆向分析下我們要爬取的整個過程,使用 FireBug 等來跟蹤一下,我們上滑頁面時會發現每次要滑到底部頁面自動載入時 FireBug 會有如下反饋:

這裡寫圖片描述

看到這幅圖我們簡單分析會發現當上拉載入更多時每次都會觸發 JS 訪問一個介面去請求一個 JSON 資料回來,然後再通過 JS 動態插到了上面第二幅圖原始碼的 <div class="sections"> 標籤內部,所以可以確定這是一個動態網頁,我們需要做的就是看看網頁對這些 JSON 資料是如何展示的。通過觀察對比我們會發現上面每次滑動到底部自動載入更多的 JS 請求連結是一個 GET 請求,如下:

http://www.toutiao.com/search_content/?offset=20&format=json&keyword=美女&autoload=true&count=20&cur_tab=1

可以看到引數 offset 一猜就是偏移量(不信自己可以修改使用 PostMan 看下返回資料),format 為資料返回 JSON 格式,keyword 就是我們輸入的關鍵詞,autoload 沒整明白,但是無傷大雅,照著傳即可, count 就是每次請求返回多少個 item,cur_tab 就是搜尋頁面下面的分類,1 代表綜合;到此我們這個動態頁面的逆向第一步(頁面動態資料來源)已經分析出來了,接下來我們仔細觀察上面那個連結的返回值會發現 JSON 體中會有一個 data 欄位的 Object 列表,這個列表其實就是我們每次上拉載入更多網頁重新整理資料的來源,我們會發現上拉載入更多顯示出來的 item 如下:
這裡寫圖片描述
這個 item 的資料就是 JSON 裡 data 列表的一項,其左側縮圖取值欄位為 image_url,標題取值欄位為 title,左側來源取值欄位為 source,其他類似,當我們點選這個 item 進入正文時會發現跳轉正文的連結也在這個 JSON 裡,用的是 article_url 欄位,當我們進入文章詳情去看裡面的所有大圖連結會驚訝的發現原來都提前預載入資料了,這些大圖連結也來自剛才那個 JSON 裡,對應的欄位是 image_detail 裡的 url 值,棒極了,我們完全逆向成功了,而且可以預測出這個爬蟲應該會相對穩定,因為通過我們對這個動態頁面的逆向會發現我們接下來的爬蟲完全不需要面對網頁 DOM 解析,而完全是標準的 RESTFUL API 呼叫,很贊,我們通過這個逆向就可以寫出爬蟲程式了,下面給出完整程式。

# coding=utf-8
import json
import os
import re
import urllib
from urllib import request
'''
Python3.X 動態頁面爬取(逆向解析)例項
爬取今日頭條關鍵詞搜尋結果的所有詳細頁面大圖片並按照關鍵詞及文章標題分類儲存圖片
'''

class CrawlOptAnalysis(object):
    def __init__(self, search_word="美女"):
        self.search_word = search_word
        self.headers = {
            'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/54.0.2840.100 Safari/537.36',
            'X-Requested-With': 'XMLHttpRequest',
            'Host': 'www.toutiao.com',
            'Referer': 'http://www.toutiao.com/search/?keyword={0}'.format(urllib.parse.quote(self.search_word)),
            'Accept': 'application/json, text/javascript',
        }

    def _crawl_data(self, offset):
        '''
        模擬依據傳入 offset 進行分段式上拉載入更多 item 資料爬取
        '''
        url = 'http://www.toutiao.com/search_content/?offset={0}&format=json&keyword={1}&autoload=true&count=20&cur_tab=1'.format(offset, urllib.parse.quote(self.search_word))
        print(url)
        try:
            with request.urlopen(url, timeout=10) as response:
                content = response.read()
        except Exception as e:
            content = None
            print('crawl data exception.'+str(e))
        return content

    def _parse_data(self, content):
        '''
        解析每次上拉載入更多爬取的 item 資料及每個 item 點進去詳情頁所有大圖下載連結
        [
            {'article_title':XXX, 'article_image_detail':['url1', 'url2', 'url3']},
            {'article_title':XXX, 'article_image_detail':['url1', 'url2', 'url3']}
        ]
        '''
        if content is None:
            return None
        try:
            data_list = json.loads(content)['data']
            print(data_list)
            result_list = list()
            for item in data_list:
                result_dict = {'article_title': item['title']}
                url_list = list()
                for url in item['image_detail']:
                    url_list.append(url['url'])
                result_dict['article_image_detail'] = url_list
                result_list.append(result_dict)
        except Exception as e:
            print('parse data exception.'+str(e))
        return result_list

    def _save_picture(self, page_title, url):
        '''
        把爬取的所有大圖下載下來
        下載目錄為./output/search_word/page_title/image_file
        '''
        if url is None or page_title is None:
            print('save picture params is None!')
            return
        reg_str = r"[\/\\\:\*\?\"\<\>\|]"  #For Windows File filter: '/\:*?"<>|'
        page_title = re.sub(reg_str, "", page_title)
        save_dir = './output/{0}/{1}/'.format(self.search_word, page_title)
        if os.path.exists(save_dir) is False:
            os.makedirs(save_dir)
        save_file = save_dir + url.split("/")[-1] + '.png'
        if os.path.exists(save_file):
            return
        try:
            with request.urlopen(url, timeout=30) as response, open(save_file, 'wb') as f_save:
                f_save.write(response.read())
            print('Image is saved! search_word={0}, page_title={1}, save_file={2}'.format(self.search_word, page_title, save_file))
        except Exception as e:
            print('save picture exception.'+str(e))

    def go(self):
        offset = 0
        while True:
            page_list = self._parse_data(self._crawl_data(offset))
            if page_list is None or len(page_list) <= 0:
                break
            try:
                for page in page_list:
                    article_title = page['article_title']
                    for img in page['article_image_detail']:
                        self._save_picture(article_title, img)
            except Exception as e:
                print('go exception.'+str(e))
            finally:
                offset += 20


if __name__ == '__main__':
    #模擬今日頭條搜尋關鍵詞爬取正文大圖
    CrawlOptAnalysis("美女").go()
    CrawlOptAnalysis("旅遊").go()
    CrawlOptAnalysis("風景").go()

可以看到下面就是我們通過對動態網頁今日頭條進行逆向分析後爬取的結果(體驗可以獲取原始碼直接執行):

這裡寫圖片描述

到此關於動態網頁逆向分析爬取的技巧就介紹完了,除過上面這個例項以外其實我們在前面已經用過一點動態網頁逆向分析了,具體留作彩蛋可以自己琢磨下我們前面系列文章的 CsdnDiscussSpider 例項中 JS 提交那段邏輯。總歸我們可以發現,某種意義上來看通過逆向爬取動態網頁雖然比靜態頁面稍顯麻煩,但是其穩定性似乎要比靜態網頁穩定,因為大多可直接逆向的動態網頁資料都是採用標準 RESTFUL API 設計的,爬取解析 API 介面資料一般比匹配解析網頁原始碼要可靠的多;但是有時候我們無法避免使用動靜結合的方式,譬如上面爬取今日頭條的例子其實還可以做到先動態逆向只獲取文章詳情頁面連結,然後再使用我們前面靜態頁面爬取解析的技巧去訪問文章詳情頁面獲取裡面大圖,因為獲取 item 列表是動態頁面,而點選 item 進入的文章頁面是靜態頁面。

3 Python3.X 模擬瀏覽器行為爬取

上面我們介紹了動態頁面爬取解析的逆向分析爬取方式,我們會驚訝的發現對於單一化動態網站(譬如今日頭條,僅僅就是資訊流和詳情頁面)的逆向相對來說還是比較容易的,其逆向出來的 API 引數很好理解,只有個別看起來無關緊要的引數我們無法猜出含義,但是沒有影響我們的爬蟲工作。然後現實總是錯綜複雜的,如果我們要爬取的是一些使用航母級別技術的動態網站怎麼辦呢,這些網站一般都非常複雜,我們如果還想使用類似 Firebug 等工具對其逆向可能時間和人力成本有點過於昂貴 ;所以對於這類網站採用上面的逆向分析手段可能不是那麼適合了,所以就出現了動態頁面爬取的另一種方式———模擬瀏覽器渲染爬取。

3-1 Selenium 與 PhantomJS 方式

這種方式已經爛大街了,但是這也許是一種折中方案,因為該方式最大的問題就是非常慢,因為它是載入完網頁所有資源並渲染好頁面後才可以操作,Selenium 本身的定位是用來進行自動化測試的。Selenium 可以按指定的命令自動操作,而 PhantomJS 是基於 Webkit 的無介面瀏覽器,它能在不可見的記憶體中完成瀏覽器的常見功能,所以我們可以利用 Selenium 和 PhantomJS 來實現一個強大到可以處理 JS、Cookie、Header 和任何我們真實情況需要做的事。要用好這種方式我們必須要時刻記得查閱 Selenium 文件PhantomJS 文件,關於環境配置等裡面都有介紹,這裡不再 BB。下面我們就來寫一個實戰爬蟲方便我們爬取自己 QQ 空間所有相簿的所有圖片,然後把圖片都下載下來,因為 QQ 空間我們已經不常用了,但是捨不得裡面各種相簿的各種照片,又不可能一張一張去手動點選下載,所以我們就有了下面基於 Selenium 和 PhantomJS 的爬蟲(嗚嗚,看起來更像是在給 QQ 空間 WEB 寫自動化測試),如下(如果跑起來有詭異 bug,建議增加相關強制等待或者隱式等待時長即可):
[該例項對應原始碼 spider_selenium_phantomjs.py 點我獲取]

import os
import time
from urllib import request
from PIL import Image
from selenium import webdriver
'''
爬取自己 QQ 空間所有照片
不怎麼用 QQ 空間, 但是捨不得空間的照片,一張一張下載太慢,所以按照相簿趴下來硬碟留念
'''
class SpiderSelenium(object):
    def __init__(self, qq='', pwd=None):
        self.driver = webdriver.PhantomJS()  #Run in Ubuntu, Windows need set executable_path.
        self.driver.maximize_window()
        self.qq = qq
        self.pwd = pwd
        print('webdriver start init success!')

    def __del__(self):
        try:
            self.driver.close()
            self.driver.quit()
            print('webdriver close and quit success!')
        except:
            pass

    def _need_login(self):
        '''
        通過判斷頁面是否存在 id 為 login_div 的元素來決定是否需要登入
        :return: 未登入返回 True,反之
        '''
        try:
            self.driver.find_element_by_id('login_div')
            return True
        except:
            return False

    def _login(self):
        '''
        登入 QQ 空間,先點選切換到 QQ 帳號密碼登入方式,然後模擬輸入 QQ 帳號密碼登入,
        接著通過判斷頁面是否存在 id 為 QM_OwnerInfo_ModifyIcon 的元素來驗證是否登入成功
        :return: 登入成功返回 True,反之
        '''
        self.driver.switch_to.frame('login_frame')
        self.driver.find_element_by_id('switcher_plogin').click()
        self.driver.find_element_by_id('u').clear()
        self.driver.find_element_by_id('u').send_keys(self.qq)
        self.driver.find_element_by_id('p').clear()
        self.driver.find_element_by_id('p').send_keys(self.pwd)
        self.driver.find_element_by_id('login_button').click()
        try:
            self.driver.find_element_by_id('QM_OwnerInfo_ModifyIcon')
            return True
        except:
            return False

    def _auto_scroll_to_bottom(self):
        '''
        將當前頁面滑動到最底端
        '''
        js = "var q=document.body.scrollTop=10000"
        self.driver.execute_script(js)
        time.sleep(6)

    def _get_gallery_list(self, picture_callback):
        '''
        從相簿列表點選一個相簿進入以後依次點選該相簿裡每幅圖片然後回撥,依此重複各個相簿
        所有註釋掉的 self.driver.get_screenshot_as_file 與 self.driver.page_source 僅僅為了方便除錯觀察
        :param picture_callback: 回撥函式,當點選一個相簿的一幅大圖時回撥
        '''
        time.sleep(5)
        self._auto_scroll_to_bottom()
        #self.driver.get_screenshot_as_file('my_qzone_gallery_screen.png')
        self.driver.switch_to.frame('app_canvas_frame')

        elements = self.driver.find_elements_by_xpath("//a[@class='c-tx2 js-album-desc-a']")
        gallery_count = len(elements)
        index = 0
        while index < gallery_count:
            print('WHILE index='+str(index)+', gallery_count='+str(gallery_count))
            self._auto_scroll_to_bottom()
            elements = self.driver.find_elements_by_xpath("//a[@class='c-tx2 js-album-desc-a']")
            if index >= len(elements):
                print('WHILE index='+str(index)+', elements='+str(len(elements)))
                break
            print('size='+str(len(elements)))
            #self.driver.get_screenshot_as_file('pppp' + str(hash(elements[index])) + '.png')
            gallery_title = elements[index].text
            elements[index].click()
            time.sleep(5)
            self._auto_scroll_to_bottom()
            #self.driver.get_screenshot_as_file('a_gallery_details_list' + str(hash(elements[index])) + '.png')
            pic_elements = self.driver.find_elements_by_xpath("//*[@class='item-cover j-pl-photoitem-imgctn']")
            for pic in pic_elements:
                pic.click()
                time.sleep(5)
                #self.driver.get_screenshot_as_file('details_' + str(hash(elements[index])) + '_' + str(hash(pic)) + '.png')
                self.driver.switch_to.default_content()
                pic_url = self.driver.find_element_by_id('js-img-border').find_element_by_tag_name('img').get_attribute('src')
                print(gallery_title + ' ---> ' + pic_url)
                if not picture_callback is None:
                    picture_callback(gallery_title, pic_url)
                self.driver.find_element_by_class_name('photo_layer_close').click()
                self.driver.switch_to.frame('app_canvas_frame')
            self.driver.back()
            time.sleep(10)
            index += 1

    def crawl_pictures(self):
        '''
        開始爬取 QQ 空間相簿裡圖片
        '''
        self.driver.get('http://user.qzone.qq.com/{0}/photo'.format(self.qq))
        self.driver.implicitly_wait(20)
        if self._need_login():
            if self._login():
                self._get_gallery_list(self._download_save_pic)
                print("========== QQ " + str(self.qq) + " 的相簿爬取下載結束 ===========")
            else:
                print('login with '+str(self.qq)+' failed, please check your account and password!')
        else:
            print('already login with '+str(self.qq))

    def _download_save_pic(self, gallery_title, pic_url):
        '''
        下載指定 url 連結的圖片到指定的目錄下,圖片檔案字尾自動識別
        :param gallery_title: QQ 空間相簿名
        :param pic_url: 該相簿下一張詳情圖片的 url
        '''
        if gallery_title is None or pic_url is None:
            print('save picture params is None!')
            return
        save_dir = './output/{0}/'.format(gallery_title)
        if os.path.exists(save_dir) is False:
            os.makedirs(save_dir)
        save_file = save_dir + str(hash(gallery_title)) + '_' + str(hash(pic_url))
        if os.path.exists(save_file):
            return
        try:
            with request.urlopen(pic_url, timeout=30) as response, open(save_file, 'wb') as f_save:
                f_save.write(response.read())
            new_stuffer_file = save_file + '.' + Image.open(save_file).format.lower()
            os.rename(save_file, new_stuffer_file)

            print('Image is saved! gallery_title={0}, save_file={1}'.format(gallery_title, new_stuffer_file))
        except Exception as e:
            print('save picture exception.'+str(e))


if __name__ == '__main__':
    SpiderSelenium('請用你的QQ號替換', '請用你的QQ密碼替換').crawl_pictures()

替換 QQ 帳號密碼後執行上面指令碼我們等待後會得到如下結果:

這裡寫圖片描述

可以發現,我們所有相簿的圖片都自動被爬取下來按照 QQ 空間相簿名字分類儲存在了本地磁碟,完全解放了雙手,但是明顯能感覺到的就是這種方式的爬蟲是比較慢的,因為需要等待元素渲染,但是在有些時候這是不得不選擇的一種折中方案,譬如 QQ 空間這個動態頁面,想要逆向分析難度有點大,所以選擇這種方案。

3-2 其他方式

模擬瀏覽器行為爬取除過上面介紹的 Selenium 結合 PhantomJS 方式外其實還有其他的框架,不過其原理歸根結底基本都類似,譬如
Splash、PyV8、Ghost、execjs 等,其 API 用法和上面 Selenium 大同小異,只是寫法有差異而已,這裡不再一一給出詳細例子,感興趣可以自己去搜搜相關官方文件照著爬爬,沒啥特別的。

3-3 對比總結

通過介紹上面幾種動態頁面的爬取方式我們很容易會得出一個結論,能用逆向分析就儘量逆向,其穩定性和效率別的方案是沒法比擬的。通常對於爬蟲有句口口相傳的真理,會點選使用瀏覽器 F12 大法就能解決百分之九十的爬蟲問題,其他百分之十就需要我們動動腦子了。對於動態頁面爬取更是這個道理了,能逆向就儘量逆向,逆向不了就尋找折中方案,折中方案裡能使用深度控制 JS 指令碼執行方案就儘量(難度略大),其次就是標準的基於瀏覽器自動化測試框架爬取。

4 動態頁面爬取其他事項

前面靜態頁面爬取系列文章有人對於模擬登入提交有疑惑,這裡要說明的是那裡的例子雖然是在說靜態頁面,實質登入提交 FORM 表單算是動態頁面的事情了,所以我們這裡對於爬蟲過程中的 FORM 表單問題再羅嗦幾句。

關於 WEB FORM 如果還不瞭解其實真的該補補基礎了,對於爬蟲 FORM 表單的提交其實還是使用 F12 大法分析網頁,譬如我們看下 GitHub 的登入 FORM,如下:

這裡寫圖片描述

想必懂點 WEB 開發的小夥伴都知道編寫 WEB 頁面 FORM 表單常見的套路就是除過可見的 FORM 元素外很多時候還會採用 hide 的 FORM 元素一同作為 FORM 提交,保證提交介面非互動引數的傳遞。所以我們可以看到 Github 登入頁面的 FORM 裡面除過存在可見的 input 元素以外還存在 hide 的 input 元素,input 元素的 name 屬性就是提交時的 key 值,FORM 標籤的 accept-charset 屬性表示編碼格式、action 屬性表示表單資料的提交
地址( # 表示當前 URL,其他值就是當前 URL + 值)、method 屬 性表示 HTTP 的請求方式(這裡為 POST),所以我們可以發現抓取的登入提交資訊和我們上面分析的一致,如下:

這裡寫圖片描述

所以對應的我們爬蟲 POST 提交資料為:

data = {
    'commit': 'Sign in',
    'utf8': '✓',
    'authenticity_token': 'PnKlT5OeM/FBf4PazfLsCrBsa4PHGAKLsg9DoosP8c1UBpOHVpShB9PwhglKgZwo5G+l45Ra/alPIUIRLVs9VA==',
    'login': '[account]',
    'password': '[password]'
}
#編碼很重要
data = urllib.urlencode(data)

這樣就可以登入了,不過還有一點要注意,既然登入就是一種狀態,所以我們在發起爬蟲登入時不要忘記開啟 Cookie,這個很重要,原理就不解釋了,這樣就可以下次自動登入,關於使用 Python 直接獲取瀏覽器 Cookie 來實現自動登入其實也不用過多強調了,獲取瀏覽器 Cookie 的方式也有很多種,甚至可以選擇使用 Python 的 browsercookie 模組來獲取 Cookie。

上面演示了自己編寫程式碼開啟 Cookie 及分析 FORM 表單提交和構造 dict 物件編碼提交表單的過程,在實際小爬蟲中關於 Cookie 我們可以自己封裝一個類來處理,這樣會方便許多,不過 Python 還提供了一個更加便捷的 Mechanize 模組來處理表單提交,非常遺憾的是這個模組不支援 Python3.X 版本,所以對於我們這個系列就沒必要介紹了,感興趣的可以自己使用低版本的 Python 玩玩。

算是一個答疑,就此打住,打球去了!

^-^當然咯,看到這如果發現對您有幫助的話不妨掃描二維碼賞點買羽毛球的小錢(現在球也挺貴的),既是一種鼓勵也是一種分享,謝謝!
這裡寫圖片描述