Python 老司机开车之三爬取福利妹纸图片(多线程学习)
Python 老司机开车之三爬取福利妹纸图片(多线程学习)
李狗蛋丶 发表于10个月前
Python 老司机开车之三爬取福利妹纸图片(多线程学习)
  • 发表于 10个月前
  • 阅读 127
  • 收藏 5
  • 点赞 0
  • 评论 0
摘要: 福利 多线程 妹纸图

Github源码地址:http://github.com/goudanlee ,欢迎Star Fork

环境:

python3.5 + windows 7 64bit + PyCharm

 

一、目的及原理背景

这次的老司机开车福利,采用了多线程方式爬取福利妹纸图,对比串行方式,效率还是高了比较多的,这里先了解一下Python里多线程和多进程的原理,也是对Python里多线程任务的实操和了解。

以下是转至廖雪峰Python教程对进程和线程的介绍:http://www.liaoxuefeng.com/wiki/0014316089557264a6b348958f449949df42a6d3a2e542c000/0014319272686365ec7ceaeca33428c914edf8f70cca383000

很多同学都听说过,现代操作系统比如Mac OS X,UNIX,Linux,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

二、爬虫架构设计

    为了优化爬虫效率,这里借鉴了一个分布式多爬虫系统的架构设计,其主要架构如下:

  • 框架主要分成两部分:下载器Downloader和解析器Analyzer。Downloader负责抓取网页,Analyzer负责解析网页并入库。两者之间依靠消息队列MQ进行通信,两者可以分布在不同机器,也可分布在同一台机器。两者的数量也是灵活可变的,例如可能有五台机在做下载、两台机在做解析,这都是可以根据爬虫系统的状态及时调整的。
  • 从上图可以看到MQ有两个管道:HTML/JS文件和待爬种子。Downloader从待爬种子里拿到一条种子,根据种子信息调用相应的抓取模块进行网页抓取,然后存入HTML/JS文件这个通道;Analyzer从HTML/JS文件里拿到一条网页内容,根据里面的信息调用相应的解析模块进行解析,将目标字段入库,需要的话还会解析出新的待爬种子加入MQ。
  • 可以看到Downloader是包含User-Agent池、Proxy池、Cookie池的,可以适应复杂网站的抓取。
  • 模块的调用使用工厂模式。

    分布式爬虫非常关键的一点:去重。可以看到多个解析器Analyzer共用一个去重队列,才能够保证数据的统一不重复。这里我们采用queue库来实现爬虫队列,结合threading库实现多线程爬虫,这两个库也是比较常组合起来使用的一套组合拳。

三、分析实现

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的时间比较短,让我们产生了”多个线程同时执行“的错觉,不过这样也会比串行要快啦。

四、总结

    本次多线程试验,主要是本着了解Python多线程原理,尝试学习多线程爬虫提高爬虫效率的目的进行的,其实更好的方式应该是采用多进程来进行,这样也可以更好的利用多核CPU的性能,下次我们采用多进程方式来改造,希望能更好提升爬虫效率。

    本次实验完整代码地址:http://github.com/goudanlee 如果感觉有帮助的话,欢迎在Github点击Star,也欢迎给文章点赞,以资鼓励!!

标签: Python
共有 人打赏支持
粉丝 2
博文 28
码字总数 20793
×
李狗蛋丶
如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
* 金额(元)
¥1 ¥5 ¥10 ¥20 其他金额
打赏人
留言
* 支付类型
微信扫码支付
打赏金额:
已支付成功
打赏金额: