爬虫的基本框架

最近看过不少讲爬虫的教程[1][2],基本都是一个模式:

  1. 开始先来拿正则、lxml、jquery/pyquery等等教大家从页面上抠出一个一个的值来
  2. 然后深入一些在讲讲http 协议,讲讲怎么拿出 cookie 来模拟登录之类的,讲讲基本的反爬虫和反反爬虫的方法
  3. 最后在上一个 简单地 scrapy 教程,似乎就皆大欢喜了。

具体地采集一个一个的数据的确让人产生成就感,然而这些教程却都忽略了爬虫最核心的逻辑抽象,也就是「爬虫应该采取什么样的策略遍历网页」。其实也很简单,只需要两个队列和一个集合,Scrapy 等框架拆开来看也是如此,本文参照 Scrapy 实现一个最基础的通用爬虫。

万维网是由一个一个的页面构成的,而每个页面和页面之间是由链接来联系的,并且这些链接都是具有方向性的。对应到数据结构的话,我们可以把每一个页面都看作一个节点,而每一个链接都是一个有向边,也就是整个万维网其实是一个巨大的「有向图」[3]。说到这里,可能有的同学已经明白了,可以用广度优先或者深度优先的算法来遍历这个图。当然,这个图是在太巨大了,我们不可能遍历整个图,而是加一些限定条件,只去访问其中很小一部分我们感兴趣的节点,比如某个域名下的网页。

广度优先和深度优先都可以使用递归或者辅助的队列(queue/lifo_queue)来实现。然而如果你的爬虫是用 python 写的话,很遗憾不能使用递归来实现了,原因很简单,我们要访问的网页可能成千上万,如果采用递归来实现,那么爬虫每向前访问一个节点,系统的调用栈就会 +1,而 python 中至今没有尾递归优化,默认的堆栈深度为1000,也就是很可能你访问了1000个网页之后就抛出异常了。所以我们这里使用队列实现对网页的遍历访问。

理论知识说了这么多,下面以一个例子来说明一下如何爬取数据:爬取煎蛋网的妹子图: http://jandan.net/ooxx

首先,我们打开对应的网址,作为起始页面,也就是把这个页面放入待访问的页面的队列。注意,这是我们需要的第一个队列,存放我们的待访问页面。

class MiniSpider(object):

    def __init__(self):
         self._request_queue = queue.Queue()  # 带请求页面的队列
         self._request_queue.put('http://jandan.net/ooxx')  # 把第一个待访问页面入队
    

接着,我们先不考虑具体如何从页面上抽取我们需要的内容,而是考虑如何遍历待访问的页面。我们发现可以通过页面上的翻页按钮找到下一页的链接,这样一页接着一页,就可以遍历所有的页面了。

当然,对这个页面,你可能想到,其实我们只要获取了页面的个数,然后用程序生成一下不就好了吗?比如说第一http://jandan.net/ooxx/page-1,第二页是http://jandan.net/ooxx/page-2。实际上,对这个例子来说是可以的,但是,这种方法又回到了对于每个站点都去寻找站点规律的老路,这并不是一种通用的做法。

在对应的按钮上点击右键,选择审查元素(inspect),可以看到对应 html 元素的代码。我们通过 xpath 来选择对应的节点,来获取下一页的链接。如果你还不了解 xpath,建议你去 Mozilla Developer Network [4] 上学习一个,提高下自身姿势水平。

通过 xpath 表达式 //div[@class='comments']//a/@href 我们获得了所有通向上一页下一页的链接。你可以在第二页和第三页上验证一下。

    class MiniSpider(object):

        def __init__(self):
        self._request_queue = queue.Queue()  # 带请求页面的队列
        self._request_queue.put('http://jandan.net/ooxx')  # 把第一个待访问页面入队
    def run(self):
        while True:
            url = self._request_queue.get()
            rsp = download(url)
            new_urls = get_xpath(rsp, "//a")  # 新的待访问的页面
            map(self._request_queue.put, new_urls)  # 放入队列

这时候,你可能想到了另一个问题,第一页的下一页和第三页的上一页都是同一个页面——第二页。如果不加处理的话,我们就会重复多次访问一个页面,浪费资源不说,还有可能导致爬虫迷路,在几个页面之间循环访问。这时候我们就需要一个集合,把访问过得页面放入。从而避免重复访问。

class MiniSpider(object):

    def __init__(self):
        self._request_queue = queue.Queue()  # 带请求页面的队列
        self._request_queue.put('http://jandan.net/ooxx')  # 把第一个待访问页面入队
        self._dedup_set = set()  # 已经访问过得页面集合

    def run(self):
        while True:
            url = self._request_queue.get()
            rsp = download(url)
            self._dedup_set.add(url)  # 访问过了,加入
            new_urls = get_xpath(rsp, "//a")  # 新的待访问的页面
            for new_url in new_urls:
                if new_url not in self._dedup_set:  # 如果还没有访问过
                    self._request_queue.put(new_url)  # 放入队列

好了,既然我们可以遍历需要爬取得页面了,下一步我们开始考虑从页面抽取需要的数据了。我们依然请出我们的老朋友xpath了。在需要的元素上点击右键,编写对应的表达式就可以了。在这个例子里,我们需要获取的是图片,对于图片的下载也是一件很耗时的任务,如果能在另一个线程里进行就好了,所以这里我们引入第二个队列,存放抽取出来的数据。

class MiniSpider(object):

    def __init__(self):
        self._request_queue = queue.Queue()  # 带请求页面的队列
        self._request_queue.put('http://jandan.net/ooxx')  # 把第一个待访问页面入队
        self._item_queue = queue.Queue()
        self._dedup_set = set()  # 已经访问过得页面集合

    def run_request(self):
        while True:
            url = self._request_queue.get()
            rsp = download(url)
            self._dedup_set.add(url)  # 访问过了,加入
            new_urls = get_xpath(rsp, "//a")  # 新的待访问的页面
            for new_url in new_urls:
                if new_url not in self._dedup_set:  # 如果还没有访问过
                    self._request_queue.put(new_url)  # 放入队列
            items = get_xpath(rsp, "//img/@src")  # 抽取出来的图片地址
            map(self._item_queue, items)
    
    def run_item(self):
        while True:
            image = self._item_queue.get()
            download(image)

把 run_request 和 run_item 两个函数放到不同的线程中,就可以同时遍历网页和下载图片了。

好了,到这里我们的煎蛋妹子图爬虫就写好了,实际上所有的爬虫框架不管多么复杂,使用的异步等等不同的多任务模式也好,本质上都是一样的。 Scrapy 也是采用了类似的方式,不同的地方时,scrapy 才使用的是 Lifo Queue,也就是栈,所以 scrapy 默认是深度优先便利的,而我们上面的爬虫是广度优先遍历的。scrapy 没有采用线程,而是使用了 Twisted 提供的 Actor Model 实现多任务同时运行。

如果再多些几个爬虫之后,可能你就会发现,其实每次需要改动的地方无外乎是查找几个 xpath 表达式,这样我们可以把上面的逻辑抽象成为一个框架,通过编写配置文件来爬取目标数据。相关代码参见: aiospider

比如,上面的代码只需要如下命令:

python miniscrapy.py --spider ooxx.yml

在爬虫运行过程中,会遇到各种各样的封锁,封锁 User-Agent, 封锁 IP,封锁 Cookie,但是这些封锁都是在下载过程中遇到的,和爬虫的整体逻辑是无关的。本文中的逻辑可以一直复用。对于遇到的各种各样的封锁,需要各种灵活多变的方式应对,这时候采用 pipeline 的方式是一个很好的选择,下一篇文章将会介绍。

[1] http://www.jianshu.com/p/11d7da95c3ca
[2] https://zhuanlan.zhihu.com/p/25296437
[3] https://zh.wikipedia.org/zh-hans/%E5%9B%BE_(%E6%95%B0%E5%AD%A6)

posted @ 2017-04-30 23:30 ospider 阅读(...) 评论(...) 编辑 收藏