最近看过不少讲爬虫的教程[1][2],基本都是一个模式:
- 开始先来拿正则、lxml、jquery/pyquery等等教大家从页面上抠出一个一个的值来
- 然后深入一些在讲讲http 协议,讲讲怎么拿出 cookie 来模拟登录之类的,讲讲基本的反爬虫和反反爬虫的方法
- 最后在上一个 简单地 scrapy 教程,似乎就皆大欢喜了。
具体地采集一个一个的数据的确让人产生成就感,然而这些教程却都忽略了爬虫最核心的逻辑抽象,也就是「爬虫应该采取什么样的策略遍历网页」。其实也很简单,只需要两个队列和一个集合,Scrapy 等框架拆开来看也是如此,本文参照 Scrapy 实现一个最基础的通用爬虫。
万维网是由一个一个的页面构成的,而每个页面和页面之间是由链接来联系的,并且这些链接都是具有方向性的。对应到数据结构的话,我们可以把每一个页面都看作一个节点,而每一个链接都是一个有向边,也就是整个万维网其实是一个巨大的「有向图」[3]。说到这里,可能有的同学已经明白了,可以用广度优先或者深度优先的算法来遍历这个图。当然,这个图是在太巨大了,我们不可能遍历整个图,而是加一些限定条件,只去访问其中很小一部分我们感兴趣的节点,比如某个域名下的网页。
广度优先和深度优先都可以使用递归或者辅助的队列(queue/lifo_queue)来实现。然而如果你的爬虫是用 python 写的话,很遗憾不能使用递归来实现了,原因很简单,我们要访问的网页可能成千上万,如果采用递归来实现,那么爬虫每向前访问一个节点,系统的调用栈就会 1,而 python 中至今没有尾递归优化,默认的堆栈深度为1000,也就是很可能你访问了1000个网页之后就抛出异常了。所以我们这里使用队列实现对网页的遍历访问。
理论知识说了这么多,下面以一个例子来说明一下如何爬取数据:爬取煎蛋网的妹子图: http://jandan.net/ooxx
首先,我们打开对应的网址,作为起始页面,也就是把这个页面放入待访问的页面的队列。注意,这是我们需要的第一个队列,存放我们的待访问页面。
代码语言:javascript复制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 我们获得了所有通向上一页下一页的链接。你可以在第二页和第三页上验证一下。
代码语言:javascript复制 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) # 放入队列
这时候,你可能想到了另一个问题,第一页的下一页和第三页的上一页都是同一个页面——第二页。如果不加处理的话,我们就会重复多次访问一个页面,浪费资源不说,还有可能导致爬虫迷路,在几个页面之间循环访问。这时候我们就需要一个集合,把访问过得页面放入。从而避免重复访问。
代码语言:javascript复制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了。在需要的元素上点击右键,编写对应的表达式就可以了。在这个例子里,我们需要获取的是图片,对于图片的下载也是一件很耗时的任务,如果能在另一个线程里进行就好了,所以这里我们引入第二个队列,存放抽取出来的数据。
代码语言:javascript复制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 表达式,这样我们可以把上面的逻辑抽象成为一个框架,通过编写配置文件来爬取目标数据。相关代码参见: github.com/yifeikong/miniscrapy
比如,上面的代码只需要如下命令:
代码语言:javascript复制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/图_(数学)