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,也欢迎给文章点赞,以资鼓励!!

相关文章
相关标签/搜索