从抓取豆瓣电影聊高性能爬虫思路

2,739 阅读8分钟

本篇文章将以抓取豆瓣电影信息为例来一步步介绍开发一个高性能爬虫的常见思路。

寻找数据地址

爬虫的第一步,首先我们要找到获取数据的地址。可以先到豆瓣电影 首页 去看看。

顶部导航为提供了很多种类型的入口,其中和电影有关的有:排行榜、选电影和分类。为了便于后续更精细的分析,这里选择进入分类页面,地址。通过浏览的开发工具,我们最终能确认数据来源是的

https://movie.douban.com/j/new_search_subjects?sort=U&range=0,10&tags=&start=0

注意:如果有朋友熟悉前端并装有vue浏览器插件,就会发现豆瓣电影站点是vue开发的。这些基本web开发技能对于我们平时开发爬虫都是很有帮助的。

爬取首页数据

用浏览器打开上面的接口地址,我们就会发现它的返回数据为json格式。利用python的requests和json库,就可以把数据获取下来了。

这里我们只获取电影的标题、导演、评分和演员四个字段,代码如下:

import json
import requests

def crawl(url):
    response = requests.get(url)
    if response.status_code != 200:
        raise Exception('http status code is {}'.format(response.status_code))

    data = response.json()['data']

    items = []
    for v in  data:
        items.append({
            'title': v['title'],
            'drectors': v['directors'],
            'rate': v['rate'],
            'casts': v['casts']
        })
    
    return items

def main():
    url = 'https://movie.douban.com/j/new_search_subjects?sort=U&range=0,10&tags=&start=0'
    for item in crawl(url):
        print(item)

if __name__ == "__main__":
    main()

代码执行得到如下这些数据:

{'title': '绿皮书', 'drectors': ['彼得·法雷里'], 'rate': '8.9', 'casts': ['维果·莫腾森', '马赫沙拉·阿里', '琳达·卡德里尼', '塞巴斯蒂安·马尼斯科', '迪米特·D·马里诺夫']}
{'title': '惊奇队长', 'drectors': ['安娜·波顿', '瑞安·弗雷克'], 'rate': '7.0', 'casts': ['布丽·拉尔森', '裘德·洛', '塞缪尔·杰克逊', '本·门德尔森', '安妮特·贝宁']}
 ...
{'title': '这个杀手不太冷', 'drectors': ['吕克·贝松'], 'rate': '9.4', 'casts': ['让·雷诺', '娜塔莉·波特曼', '加里·奥德曼', '丹尼·爱罗', '彼得·阿佩尔']}
{'title': '新喜剧之王', 'drectors': ['周星驰', '邱礼涛', '黄骁鹏', '肖鹤'], 'rate': '5.8', 'casts': ['王宝强', '鄂靖文', '张全蛋', '景如洋', '张琪']}

仔细观察,我们会发现仅仅抓到了20条数据。电影数据才这么点,这是不可能的,这是因为正常网站展示信息都会采用分页方式。再来看下电影的分类页面,我们把滚动条拉到底部就会发现底部有个 "加载更多" 的提示按钮。点击之后,会加载出更多的电影。

分页抓取

对于各位来说,分页应该是很好理解的。就像书本一样,包含信息多了自然就需要分页,网站也是如此。不过站点根据场景不同,分页规则也会有些不同。下面来具体说说:

先说说分页的参数,通常会涉及三个参数,分别是:

  • 具体页码,url中的常见名称有 page、p、n 等,起始页码通常为1,有些情况为0;
  • 每页数量,url中的常见名称有 limit、size、pagesize(page_size pageSize)等;
  • 起始位置,url中的常见名称有start、offset等,主要说明从什么位置开始获取数据;

分页主要通过这三种参数的两种组合实现,哪两种组合?继续往下看:

  • 具体页码 + 每页数量,这种规则主要用在分页器的情况下,而且返回数据需包含总条数;
  • 起始位置 + 每页数量,这种规则主要用在下拉场景,豆瓣的例子就是用下拉来分页,这种情况下的url返回数据可不包含总数,前端以下页是否还有数据就可判定分页是否完成。

介绍完了常见的两种分页规则,来看看我们的的url:

https://movie.douban.com/j/new_search_subjects?sort=U&range=0,10&tags=&start=0

该页面通过下拉方式实现翻页,那么我们就会想url中是否有起始位置信息。果然在找到了start参数,此处为0。然后点击下拉,通过浏览器开发工具监控得到了新的url,如下:

https://movie.douban.com/j/new_search_subjects?sort=U&range=0,10&tags=&start=20

start的值变成了20,这说明起始位置参数就是start。依照分页的规则,我们把main函数修改下,加个while循环就可以获取全部电影数据了,代码如下:

def main():
    url = 'https://movie.douban.com/j/new_search_subjects?sort=U&range=0,10&tags=&start={}'

    start = 0
    total = 0
    while True:
        items = crawl(url.format(start))
        if len(items) <= 0:
            break

        for item in items:
            print(item)
        
        start += 20
        total += len(items)
        print('已抓取了{}条电影信息'.format(total))
    
    print('共抓取了{}条电影信息'.format(total))

到这里工作基本完成!把print改为入库操作把抓取的数据入库,一个爬虫就真正完成了。

进一步优化

不知大家注意到没有,这里的请求每次只能获取20条数据,这必然到导致数据请求次数增加。这有什么问题吗?三个问题:

  • 网络资源浪费严重;
  • 获取数据速度太慢;
  • 容易触发发爬机制;

那有没有办法使请求返回数据量增加?当然是有的。

前面说过分页规则有两个,分别是 具体页码 + 每页大小 和 起始位置 + 每页大小。这两种规则都和每页大小,即每页数量有关。我们知道上面的接口默认每页大小为20。根据前面介绍的分页规则,我们分别尝试在url加上limit和size参数。验证后发现,limit可用来改变每次请求获取数量。修改一下代码,在url上增加参数limit,使其等于100:

url = 'https://movie.douban.com/j/new_search_subjects?sort=U&range=0,10&tags=&start={}&limit=100'

只是增加了一个limit参数就可以帮助我们大大减少接口请求次数,提高数据获取速度。要说明一下,不是每次我们都有这样好的运气,有时候每页数量是固定的,我们没有办法修改,这点我们需要知道。

高性能爬虫

经过上面的优化,我们的爬虫性能已经有了一定提升,但是好像还是很慢。执行它并观察打印信息,我们会发现每个请求之间的延迟很大,必须等待上一个请求响应并处理完成,才能继续发出下一个请求。如果大家有网络监控工具,你会发现此时网络带宽的利用率很低。因为大部分的时间都被IO请求阻塞了。有什么办法可以解决这个问题?那么必然要提的就是并发编程。

并发编程是个很大的话题,涉及多线程、多进程以及异步io等,这篇文章的重点不在此。这里使用python的asyncio来帮助我们提升高爬虫性能。我们来看实现代码吧。 此处要说明一个问题,因为豆瓣用下拉的方式获取数据,正如上面介绍的那样,这是一种不需要提供数据总数的就可以分页的方式。但是这种方式会导致我就没有办法事先根据limit和total确定请求的总数,在请求总数未知的情况下,我们的请求只能顺序执行。所以这里我们为了案例能够继续,假设获取数据最多1万条,代码如下:

import json
import asyncio
import aiohttp

async def crawl(url):
    data = None
    async with aiohttp.ClientSession() as s:
        async with s.get(url) as r:
            if r.status != 200:
                raise Exception('http status code is {}'.format(r.status))
            data = json.loads(await r.text())['data']

    items = []
    for v in  data:
        items.append({
            'title': v['title'],
            'drectors': v['directors'],
            'rate': v['rate'],
            'casts': v['casts']
        })
    
    return items

async def main():
    limit = 100
    url = 'https://movie.douban.com/j/new_search_subjects?sort=U&range=0,10&tags=&start={}&limit={}'
    start = 0
    total = 10000

    crawl_total = 0
    tasks = [crawl(url.format(start + i * limit, limit)) for i in range(total // limit)]
    for r in asyncio.as_completed(tasks):
        items = await r
        for item in items:
            print(item)
        crawl_total += len(items)

    print('共抓取了{}条电影信息'.format(crawl_total))

if __name__ == "__main__":
    ioloop = asyncio.get_event_loop()
    ioloop.run_until_complete(main())

最终结果显示获取了9900条,感觉是豆瓣限制了翻页的数量,最多只能获取9900条数据。

最终的代码使用了asyncio的异步并发编程来实现爬虫性能的提高,而且还用到了aiohttp这个库来实现http的异步请求。跳跃有点大,有一种学会了 1+1 就可以去做微积分题目的感觉。如果想利用多核优势,可以利用 aio + multiprocess 组合实现。

总结

本文从提高爬虫抓取速度与减少资源消耗两个角度介绍了开发一个高性能爬虫的一些技巧:

  • 有效利用分页减少网络请求减少资源消耗;
  • 并发编程实现带宽高效利用提高爬虫速度;

最后,大家如果有兴趣可以去看看tornado文档中实现的一个高并发爬虫。如果不懂异步io的话,或许会觉得这个代码很诡异。