Python 老司機開車之三爬取福利妹紙圖片(多線程學習)

Github源碼地址:http://github.com/goudanlee ,歡迎Star Fork

環境:html

python3.5 + windows 7 64bit + PyCharmpython

 

1、目的及原理背景

此次的老司機開車福利,採用了多線程方式爬取福利妹紙圖,對比串行方式,效率仍是高了比較多的,這裏先了解一下Python裏多線程和多進程的原理,也是對Python裏多線程任務的實操和了解。git

如下是轉至廖雪峯Python教程對進程和線程的介紹:http://www.liaoxuefeng.com/wiki/0014316089557264a6b348958f449949df42a6d3a2e542c000/0014319272686365ec7ceaeca33428c914edf8f70cca383000github

不少同窗都據說過,現代操做系統好比Mac OS X,UNIX,Linux,Windows等,都是支持「多任務」的操做系統。windows

什麼叫「多任務」呢?簡單地說,就是操做系統能夠同時運行多個任務。打個比方,你一邊在用瀏覽器上網,一邊在聽MP3,一邊在用Word趕做業,這就是多任務,至少同時有3個任務正在運行。還有不少任務悄悄地在後臺同時運行着,只是桌面上沒有顯示而已。瀏覽器

如今,多核CPU已經很是普及了,可是,即便過去的單核CPU,也能夠執行多任務。因爲CPU執行代碼都是順序執行的,那麼,單核CPU是怎麼執行多任務的呢?安全

答案就是操做系統輪流讓各個任務交替執行,任務1執行0.01秒,切換到任務2,任務2執行0.01秒,再切換到任務3,執行0.01秒……這樣反覆執行下去。表面上看,每一個任務都是交替執行的,可是,因爲CPU的執行速度實在是太快了,咱們感受就像全部任務都在同時執行同樣。服務器

真正的並行執行多任務只能在多核CPU上實現,可是,因爲任務數量遠遠多於CPU的核心數量,因此,操做系統也會自動把不少任務輪流調度到每一個核心上執行。多線程

對於操做系統來講,一個任務就是一個進程(Process),好比打開一個瀏覽器就是啓動一個瀏覽器進程,打開一個記事本就啓動了一個記事本進程,打開兩個記事本就啓動了兩個記事本進程,打開一個Word就啓動了一個Word進程。架構

有些進程還不止同時幹一件事,好比Word,它能夠同時進行打字、拼寫檢查、打印等事情。在一個進程內部,要同時幹多件事,就須要同時運行多個「子任務」,咱們把進程內的這些「子任務」稱爲線程(Thread)。

因爲每一個進程至少要幹一件事,因此,一個進程至少有一個線程。固然,像Word這種複雜的進程能夠有多個線程,多個線程能夠同時執行,多線程的執行方式和多進程是同樣的,也是由操做系統在多個線程之間快速切換,讓每一個線程都短暫地交替運行,看起來就像同時執行同樣。固然,真正地同時執行多線程須要多核CPU纔可能實現。

咱們前面編寫的全部的Python程序,都是執行單任務的進程,也就是隻有一個線程。若是咱們要同時執行多個任務怎麼辦?

有兩種解決方案:

一種是啓動多個進程,每一個進程雖然只有一個線程,但多個進程能夠一塊執行多個任務。

還有一種方法是啓動一個進程,在一個進程內啓動多個線程,這樣,多個線程也能夠一塊執行多個任務。

固然還有第三種方法,就是啓動多個進程,每一個進程再啓動多個線程,這樣同時執行的任務就更多了,固然這種模型更復雜,實際不多采用。

總結一下就是,多任務的實現有3種方式:

  • 多進程模式;
  • 多線程模式;
  • 多進程+多線程模式。

同時執行多個任務一般各個任務之間並非沒有關聯的,而是須要相互通訊和協調,有時,任務1必須暫停等待任務2完成後才能繼續執行,有時,任務3和任務4又不能同時執行,因此,多進程和多線程的程序的複雜度要遠遠高於咱們前面寫的單進程單線程的程序。

由於複雜度高,調試困難,因此,不是無可奈何,咱們也不想編寫多任務。可是,有不少時候,沒有多任務還真不行。想一想在電腦上看電影,就必須由一個線程播放視頻,另外一個線程播放音頻,不然,單線程實現的話就只能先把視頻播放完再播放音頻,或者先把音頻播放完再播放視頻,這顯然是不行的。

總結:線程是最小的執行單元,而進程由至少一個線程組成。如何調度進程和線程,徹底由操做系統決定,程序本身不能決定何時執行,執行多長時間。

       不過,這裏要知道,Python在設計之初,基於數據安全考慮設計了GIL機制,因此其實Python所實現的多線程也是僞多線程而已,不過,基於其運行原理在進行爬蟲等IO密集型任務時,多線程仍是比較能提高效率的。如下介紹了Python多線程和多進程的使用場景,有助於理解GIL的運行機制。

    爲何在Python裏推薦使用多進程而不是多線程:http://m.blog.csdn.net/article/details?id=51243137

2、爬蟲架構設計

    爲了優化爬蟲效率,這裏借鑑了一個分佈式多爬蟲系統的架構設計,其主要架構以下:

  • 框架主要分紅兩部分:下載器Downloader和解析器Analyzer。Downloader負責抓取網頁,Analyzer負責解析網頁併入庫。二者之間依靠消息隊列MQ進行通訊,二者能夠分佈在不一樣機器,也可分佈在同一臺機器。二者的數量也是靈活可變的,例如可能有五臺機在作下載、兩臺機在作解析,這都是能夠根據爬蟲系統的狀態及時調整的。
  • 從上圖能夠看到MQ有兩個管道:HTML/JS文件和待爬種子。Downloader從待爬種子裏拿到一條種子,根據種子信息調用相應的抓取模塊進行網頁抓取,而後存入HTML/JS文件這個通道;Analyzer從HTML/JS文件裏拿到一條網頁內容,根據裏面的信息調用相應的解析模塊進行解析,將目標字段入庫,須要的話還會解析出新的待爬種子加入MQ。
  • 能夠看到Downloader是包含User-Agent池、Proxy池、Cookie池的,能夠適應複雜網站的抓取。
  • 模塊的調用使用工廠模式。

    分佈式爬蟲很是關鍵的一點:去重。能夠看到多個解析器Analyzer共用一個去重隊列,纔可以保證數據的統一不重複。這裏咱們採用queue庫來實現爬蟲隊列,結合threading庫實現多線程爬蟲,這兩個庫也是比較常組合起來使用的一套組合拳。

3、分析實現

3.1 網站查看分析

        此次咱們要獲取福利的站點是:妹紙圖http://www.mzitu.com/,話說這個域名也夠直接好記的,基本這個站點的圖片都是很是誘惑可是非露點的,很是有藝術欣賞價值,能夠看下首頁隨便感覺一下:

是否是感受頗有藝(xue)術(mai)價(pen)值(zhang)~~

好了,廢話很少說,打開F12咱們來稍微分析一下網站html,看看咱們想要的圖片都是怎樣展現給咱們的。

不過在這以前咱們看到首頁上有一個小小的提示,通常來講,站點在手機上顯示時會比PC上更爲簡單直接,畢竟手機的顯示空間有限,因此會更突出重點,因爲這個提示不是超連接,不知道手機訪問的地址。這個簡單,髮網站首頁給手機上打開就好了,或者使用Chrome模擬手機打開,這裏不作介紹,能夠自行搜索方法。

手機訪問後發現地址很簡單:http://m.mzitu.com,在電腦上也能直接訪問,果真界面簡潔多了(截圖真不是故意的,恰好只能看到這麼多了。。。):

這樣再分析HTML就清爽多啦,這裏關注這幾個地方(後面均沿用如下說法):

1.主題連接,就是每一個主題的跳轉地址,能夠看到整個body裏,有一個id="content"的div裏是存放了整個頁面全部的主題的,分別在每一個article裏包含了該主題的描述以及跳轉地址。

2.分頁地址,這是獲取其餘頁面的地址入口。

3.圖片地址,這個就不用多解釋了,就是圖片的實際連接地址

主題連接比較容易獲取,這裏分頁比較不明顯,在首頁是沒有總頁數的,這裏是以一個查看更多的事件來進行加載的,不過這裏能夠看到下一頁的連接地址,咱們打開看一下。能夠看到,到第二頁時,雖然頁面上仍是用「查看更多」事件來加載,但這裏已經能夠看到總頁面數了,同時也知道了分頁連接地址的規律了,就是在主頁地址上加上/page/num。

接着打開一個主題連接,看看裏面的主要內容,發現每頁只有一張圖片:

並且分頁總數也有了:

跳轉第二頁發現地址爲:http://m.mzitu.com/86048/2  這樣主題內的分頁規律就很清楚了。

瞭解到以上信息後,咱們就比較明確Analyzer裏須要進行分析和讀取的幾個信息了:

1.獲取分頁地址,如:http://m.mzitu.com/page/2

2.獲取每頁裏的主題連接,如:http://m.mzitu.com/86048

3.獲取每一個主題裏的每一個分頁連接(每頁包含一個圖片):http://m.mzitu.com/86048/2

3.2 Downloader實現

         Downloader類主要實現對url連接的下載,並返回html,功能設計上比較簡單,不過下載器裏須要考慮Proxy、User-Agent、Cookies等實現,方便應對反爬蟲策略。不過雖然咱們在Downloader裏預留了相關參數實現,但這次這個福利妹紙圖網站貌似並無反爬蟲策略(自行測試時快down了整站圖片也沒有遇到被屏蔽的狀況),真乃福利~

import urllib
import random
import time
from datetime import datetime, timedelta
import socket


DEFAULT_AGENT = 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.87 Safari/537.36'
DEFAULT_DELAY = 5
DEFAULT_RETRIES = 1
DEFAULT_TIMEOUT = 60


class Downloader:
    def __init__(self, user_agent=DEFAULT_AGENT, proxies=None, num_retries=DEFAULT_RETRIES, timeout=DEFAULT_TIMEOUT, opener=None, cache=None):
        socket.setdefaulttimeout(timeout)
        self.user_agent = user_agent
        self.proxies = proxies
        self.num_retries = num_retries
        self.opener = opener
        self.cache = cache


    def __call__(self, url):
        result = None
        if self.cache:
            try:
                result = self.cache[url]
            except KeyError:
                # url is not available in cache
                pass
            else:
                if self.num_retries > 0 and 500 <= result['code'] < 600:
                    # server error so ignore result from cache and re-download
                    result = None
        if result is None:
            # result was not loaded from cache so still need to download
            # self.throttle.wait(url)
            proxy = random.choice(self.proxies) if self.proxies else None
            headers = {'User-agent': self.user_agent}
            result = self.download(url, headers, proxy=proxy, num_retries=self.num_retries)
            if self.cache:
                # save result to cache
                self.cache[url] = result
        return result['html']


    def download(self, url, user_agent=DEFAULT_AGENT, proxy=None, num_retries=2):
        print('Downloading:', url)
        headers = {'User-agent': user_agent}
        request = urllib.request.Request(url, headers=headers)

        #add proxy_params
        opener = urllib.request.build_opener()
        if proxy:
            # proxy_params = {urllib.parse.urlparse(url).scheme: proxy}
            opener = urllib.request.build_opener(urllib.request.ProxyHandler(proxy))
        try:
            # html = urllib.request.urlopen(request).read().decode('utf-8')
            urllib.request.install_opener(opener)
            html = urllib.request.urlopen(url).read().decode('utf-8')
        except urllib.error.URLError as e:
            print('Download error:', e.reason)
            html = None
            if num_retries > 0:
                if hasattr(e, 'code') and 500 <= e.code < 600:
                    # retry 5XX HTTP errors
                    return self.download(url, user_agent, proxy, num_retries - 1)

        return html

3.3 Analyzer實現

    Analyzer裏主要設計咱們如何分析html並提取所須要的信息(這裏固然就是妹紙圖了),這裏咱們先經過非多線程方式實現,而後再進行改造。

    先理清思路,根據咱們以前的分析結果,Analyzer裏咱們設計以下幾個函數實現對應功能:

  1. link_crawler():主要運行函數,經過傳入的分頁連接地址開始爬取任務
  2. get_page():用來獲取分頁地址裏的下頁地址
  3. get_title_link():用來獲取每一個分頁內的主題連接
  4. get_img():用來下載每一個主題連接的圖片
  5. mkdir():建立每一個主題的文件存放路徑

    逐個進行解析,先從get_page()開始,這裏主要經過當前分頁地址的html獲取下頁的地址,經過以前的分析能夠知道分頁地址的生成規律,這裏能夠採用本身生成頁面地址或者讀取每頁的下頁地址兩種方式,這裏採用了讀取頁面中下頁地址的方式:

def get_page_link(self, html):
        # 獲取下頁的分頁地址
        link = ''
        bs = BeautifulSoup(html,'lxml')
        prevnext = bs.find('div', attrs={'class':'prev-next more'}).find('a', attrs={'class:','button radius'})
        if prevnext:
            link = bs.find('a', attrs={'class':'button radius'}).get('href')
            print(link)
        # 返回下頁連接地址
        return link

    有了獲取下頁地址的方法後,即可以循環遍歷去讀取每頁內的主題連接了,這時便須要讀取到每頁裏的全部主題連接地址,這裏也比較簡單,根據上圖所示的html可知,每一個h2下即是主題的連接地址,使用BeatifulSoup很容易獲取到結果,這裏返回的結果集是一個列表

bs = BeautifulSoup(html,'lxml')
titile_links = bs.findAll('h2')

    有了主題連接後,接下來就須要進行下載了,在下載每一個主題同時,咱們也根據主題建立對應的圖片存放路徑,這裏先建立一個mkdir()來實現文件路徑建立

def mkdir(self, path):
        path = path.strip()
        #判斷路徑是否存在
        isExist = os.path.exists(path)
        if not isExist:
            os.mkdir(path)
            return True
        else:
            print('目錄已存在')
            return False

    因爲部分主題裏存在特殊字符在windows下是做爲文件路徑名稱的,因此針對性的替換一下:

# 經過主題連接讀取並保存全部的圖片
html = self.D.download(url)
bs = BeautifulSoup(html,'lxml')
# 經過主題連接建立路徑以及讀取主題下的全部圖片,這裏採用title做爲文件夾名稱
title = bs.find('h2', attrs={'class':'blog-title'}).text.replace('?','_').replace('/','_')
path = self.basepath + title
self.mkdir(path)

    這樣根據主題便生成了以下所示的路徑地址,看着仍是很誘惑的呢,嘿嘿~

    那麼如何獲取每一個主題下的全部圖片呢,根據咱們前面分析其頁面生成規律,咱們知道每頁地址的生成規律就是在主題連接後加上/1 、/2等來表示頁面,這樣就比較簡單了,咱們獲取到總頁數後,進行遍歷讀取便可:

# 獲取主題下的總頁數
        page_info = bs.find('span',attrs={'class':'prev-next-page'}).text
        pages = page_info[page_info.index('/')+1:page_info.index('頁')]
        #建立圖片連接地址隊列
        for page in range(1,int(pages)):
            seed_url = url + '/' + str(page)
            html = self.D.download(seed_url)
            bs = BeautifulSoup(html,'lxml')
            img_url = bs.find('div', attrs={'id':'content'}).find('img').get('src')
            jpg_name = img_url[img_url.rfind('/') + 1:]
            req = request.Request(img_url)
            write = urllib.request.urlopen(req)
            fw = open(path+'/%s'%jpg_name,'wb')
            fw.write(write.read())
            fw.close()

    到這裏咱們每一個環節都已經涉及到了,接下來只需在link_crawler()彙總執行各環節便可,crawl_queue是咱們定義的一個頁面地址列表,經過set來進行去重,方便咱們後面進行多線程改造:

def link_crawler(self):
        # 使用set集合來去重
        seen = set(self.crawl_queue)
        while True:
            try:
                url = self.crawl_queue.pop()
            except IndexError:
                # crawl_queue is empty
                break
            else:
                html = self.D.download(url)
                #獲取該頁面的主題連接
                title_links = self.get_title_link(html)
                for title_link in title_links:
                     self.thread_get_image(title_link)
                    # 下載完一個主題後隨機暫停1至10秒,避免太高頻率影響服務器以及被屏蔽
                    # time.sleep(random.randint(1,10))
                # 獲取下一個有效的頁面連接
                link = self.get_page_link(html)
                if link:
                    if link not in seen:
                        seen.add(link)
                        self.crawl_queue.append(link)
                else:
                    break

    到這裏爲止,單線程爬蟲便完成了,咱們能夠執行看結果,發現爬蟲是按照每頁-每主題-每圖片的方式遍歷下載的,雖然下載速度也不慢,不過若是想完成整站的下載,估計還得花挺久時間的。

3.4 多線程改造

    根據以上的過程,咱們能夠發現,其實在三個地方是能夠改造爲多線程方式,就是分別在讀取到每一個html頁面進行信息提取的時候,如分頁連接裏獲取下頁地址,分頁連接裏獲取全部主題連接,主題連接裏獲取全部圖片地址。因爲下頁地址每一個頁面內只有一條,因此這個忽略,那麼咱們改造的重點就在獲取主題連接獲取圖片地址這兩處。

    這裏針對獲取圖片地址這步驟進行改造,既然要使用多線程,那麼咱們須要用到隊列去存放所需下載的圖片地址,方便多線程從隊列裏獲取連接進行下載,避免出現重複下載的狀況。這裏咱們採用queue庫來實現爬蟲隊列,結合threading庫實現多線程爬蟲。

    先前的單線程方式裏,咱們在get_img()裏面實現了獲取img_url並下載的完整過程,這裏咱們改造的思路就是,將get_img()改形成只獲取圖片連接地址並存入imgurl_queue隊列中,新建一個save_img()方法經過獲取imgurl_queue隊列中的連接,多線程進行圖片下載。

# 獲取主題下的總頁數
        page_info = bs.find('span',attrs={'class':'prev-next-page'}).text
        pages = page_info[page_info.index('/')+1:page_info.index('頁')]
        #建立圖片連接地址隊列
        for page in range(1,int(pages)):
            seed_url = url + '/' + str(page)
            html = self.D.download(seed_url)
            bs = BeautifulSoup(html,'lxml')
            #這裏獲取圖片地址並完成下載
            img_url = bs.find('div', attrs={'id':'content'}).find('img').get('src')
            jpg_name = img_url[img_url.rfind('/') + 1:]
            req = request.Request(img_url)
            write = urllib.request.urlopen(req)
            fw = open(path+'/%s'%jpg_name,'wb')
            fw.write(write.read())
            fw.close()

    改造get_img()後:

self.imgurl_queue = queue.Queue()
    def thread_get_image(self, url):
        # 經過主題連接讀取並保存全部的圖片
        html = self.D.download(url)
        bs = BeautifulSoup(html,'lxml')
        # 經過主題連接建立路徑以及讀取主題下的全部圖片,這裏採用title做爲文件夾名稱
        title = bs.find('h2', attrs={'class':'blog-title'}).text.replace('?','_').replace('/','_')
        path = self.basepath + title
        self.mkdir(path)
        # 獲取主題下的總頁數
        page_info = bs.find('span',attrs={'class':'prev-next-page'}).text
        pages = page_info[page_info.index('/')+1:page_info.index('頁')]
        #建立圖片連接地址隊列
        for page in range(1,int(pages)):
            seed_url = url + '/' + str(page)
            self.imgurl_queue.put(seed_url)

     建立save_img():

def save_img(self):
        #判斷圖片連接隊列是否爲空
        while self.imgurl_queue:
            url = self.imgurl_queue.get()
            #若是url不爲空且未讀取過
            if url not in self.seen:
                self.seen.add(url)
                html = self.D.download(url)
                bs = BeautifulSoup(html,'lxml')
                #獲取主題方便存入對應的路徑
                title = bs.find('div', attrs={'id':'content'}).find('img').get('alt').replace('?','_').replace('/','_')
                path = self.basepath + title
                img_url = bs.find('div', attrs={'id':'content'}).find('img').get('src')
                #每張圖片名稱按照連接最後一個"/"後的名稱命名
                jpg_name = img_url[img_url.rfind('/') + 1:]
                req = request.Request(img_url)
                write = urllib.request.urlopen(req)
                fw = open(path+'/%s'%jpg_name,'wb')
                fw.write(write.read())
                fw.close()

    建立一個Start()方法,使用多線程進行下載:

def Start(self):
        print('爬蟲啓動,請稍候...')
        self.link_crawler()
        threads = [] #建立線程列表
        while threads or self.imgurl_queue or self.crawl_queue:
            # the crawl is still active
            for thread in threads:
                if not thread.is_alive():
                    # remove the stopped threads
                    threads.remove(thread)
            while len(threads) < self.max_threads and self.imgurl_queue:
                thread = threading.Thread(target=self.save_img())
                thread.setDaemon(True)
                thread.start()
                threads.append(thread)
            time.sleep(self.SLEEP_TIME)

    執行結果,在獲取到全部主題連接後,多線程便開始下載全部存放在隊列裏的圖片地址,不過這裏有個問題,就是前面是須要等待全部主題連接讀取完後纔開始下載,但獲取主題連接並無改造,130多頁的主題連接獲取是串行進行的,會須要必定時間纔會讀取完畢,因此我這裏是直接從倒數第二頁開始讀取的,這樣能夠比較快查看到爬蟲多線程下載過程的執行結果:

    根據結果能夠看到因爲Python裏GIL的存在,每一個線程實際上是交替執行的,只不過因爲佔用CPU的時間比較短,讓咱們產生了」多個線程同時執行「的錯覺,不過這樣也會比串行要快啦。

4、總結

    本次多線程試驗,主要是本着瞭解Python多線程原理,嘗試學習多線程爬蟲提升爬蟲效率的目的進行的,其實更好的方式應該是採用多進程來進行,這樣也能夠更好的利用多核CPU的性能,下次咱們採用多進程方式來改造,但願能更好提高爬蟲效率。

    本次實驗完整代碼地址:http://github.com/goudanlee 若是感受有幫助的話,歡迎在Github點擊Star,也歡迎給文章點贊,以資鼓勵!!

相關文章
相關標籤/搜索