基础爬虫案例实战

我们已经学习了多进程、requests、正则表达式的基本用法,但还没有完整地实现过一个爬取案例。这一节,我们就来实现一个完整的网站爬虫,把前面学习的知识点串联起来,同时加深对这些知识点的理解。

准备工作

我们需要先做好如下准备工作。

  • 安装好 Python3,最低为 3.6 版本,并能成功运行 Python3 程序。

  • 了解 Python 多进程的基本原理。

  • 了解 Python HTTP 请求库 requests 的基本用法。

  • 了解正则表达式的用法和 Python 中正则表达式库 re 的基本用法。

以上内容在前面的章节中多有讲解,如果尚未准备好,建议先熟悉一下这些内容。

爬取目标

本节我们以一个基本的静态网站作为案例进行爬取,需要爬取的链接为 https://ssr1.scrape.center/ ,这个网站里面包含—些电影信息,界面如图 2-14 所示。

image 2025 01 25 21 26 05 617
Figure 1. 图 2-14 案例网站的页面

网站首页展示了一个由多个电影组成的列表,其中每部电影都包含封面、名称、分类、上映时间、评分等内容,同时列表页还支持翻页,单击相应的页码就能进入对应的新列表页。

如果我们点开其中一部电影,会进入该电影的详情页面,例如我们打开第一部电影《霸王别姬》,会得到如图 2-15 所示的页面。

image 2025 01 25 21 28 18 601
Figure 2. 图 2-15 打开 <霸王别姬>呈现的页面

这个页面显示的内容更加丰富,包括剧情简介、导演、演员等信息。

我们本节要完成的目标有:

  • 利用 requests 爬取这个站点每一页的电影列表,顺着列表再爬取每个电影的详情页;

  • 用正则表达式提取每部电影的名称、封面、类别、上映时间、评分、剧情简介等内容;

  • 把以上爬取的内容保存为 JSON 文本文件;

  • 使用多进程实现爬取的加速。

已经做好准备,也明确了目标,那我们现在就开始吧。

爬取列表页

第一步爬取肯定要从列表页入手,我们首先观察一下列表页的结构和翻页规则。在浏览器中访问 https://ssr1.scrape.center/ ,然后打开浏览器开发者工具,如图 2-16 所示。

观察每一个电影信息区块对应的 HTML 以及进入到详情页的 URL,可以发现每部电影对应的区块都是一个 div 节点,这些节点的 class 属性中都有 el-card 这个值。每个列表页有 10 个这样的 div 节点,也就对应着 10 部电影的信息。

接下来再分析一下是怎么从列表页进入详情页的,我们选中第一个电影的名称,看下结果,如图 2-17 所示。

image 2025 01 25 21 34 10 561
Figure 3. 图 2-16 列表页及其 HTML

观察每一个电影信息区块对应的 HTML 以及进入到详情页的 URL,可以发现每部电影对应的区块都是一个 div 节点,这些节点的 class 属性中都有 el-card 这个值。每个列表页有 10 个这样的 div 节点,也就对应着 10 部电影的信息。

接下来再分析一下是怎么从列表页进入详情页的,我们选中第一个电影的名称,看下结果,如图 2-17 所示。

image 2025 01 25 21 37 31 417
Figure 4. 图 2-17 选中《霸王别姬》的名称

可以看到这个名称实际上是一个 h2 节点,其内部的文字就是电影标题。h2 节点的外面包含一个 a 节点,这个 a 节点带有 href 属性,这就是一个超链接,其中 href 的值为 /detail/1,这是一个相对网站的根 URL https://ssr1.scrape.center/ 的路径,加上网站的根 URL 就构成了 https://ssr1.scrape.center/detail/1,也就是这部电影的详情页的 URL。这样我们只需要提取这个 href 属性就能构造出详情页的 URL 并接着爬取了。

接下来我们分析翻页的逻辑,拉到页面的最下方,可以看到分页页码,如图 2-18 所示。

可以观察到这里一共有 100 条数据,页码最多是 10。

我们单击第 2 页,如图 2-19 所示。

可以看到网页的 URL 变成了 https://ssr1.scrape.center/page/2,相比根 URL 多了 /page/2 这部分内容。网页的结构还是和原来一模一样,可以像第 1 页那样处理。

接着我们查看第 3 页、第 4 页等内容,可以发现一个规律,这些页面的 URL 最后分别为 /page/3、/page/4。所以,/page 后面跟的就是列表页的页码,当然第 1 页也是一样,我们在根 URL 后面加上 /page/1 也是能访问这页的,只不过网站做了一下处理,默认的页码是 1,所以第一次显示的是第 1 页内容。

好,分析到这里,逻辑基本清晰了。

于是我们要完成列表页的爬取,可以这么实现:

  • 遍历所有页码,构造 10 页的索引页 URL;

  • 从每个索引页,分析提取出每个电影的详情页 URL。

那么我们写代码来实现一下吧。

首先,需要先定义一些基础的变量,并引入一些必要的库,写法如下:

import requests
import logging
import re
from urllib.parse import urljoin

logging.basicConfig(level=logging.INFO,
                    format='%(asctime)s - %(levelname)s: %(message)s')

BASE_URL = 'https://ssr1.scrape.center'
TOTAL_PAGE = 10

这里我们引入了 requests 库用来爬取页面、logging 库用来输出信息、re 库用来实现正则表达式解析、urljoin 模块用来做 URL 的拼接。

接着我们定义了日志输出级别和输出格式,以及 BASE_URL 为当前站点的根 URL,TOTAL_PAGE 为需要爬取的总页码数量。

完成了这些工作,来实现一个页面爬取的方法吧,实现如下:

def scrape_page(url):
    """
    scrape page by url and return its html
    :param url: page url
    :return: html of page
    """
    logging.info('scraping %s...', url)
    try:
        response = requests.get(url)
        if response.status_code == 200:
            return response.text
        logging.error('get invalid status code %s while scraping %s', response.status_code, url)
    except requests.RequestException:
        logging.error('error occurred while scraping %s', url, exc_info=True)

考虑到不仅要爬取列表页,还要爬取详情页,所以这里我们定义了一个较通用的爬取页面的方法,叫作 scrape_page,它接收一个参数 url,返回页面的 HTML 代码。上面首先判断状态码是不是 200,如果是,就直接返回页面的 HTML 代码; 如果不是,则输出错误日志信息。另外这里实现了 requests 的异常处理,如果出现了爬取异常,就输出对应的错误日志信息。我们将 logging 库中的 error 方法里的 exc_info 参数设置为 True,可以打印出 Traceback 错误堆栈信息。

好了,有了 scrape_page 方法之后,我们给这个方法传入一个 url,如果情况正常,它就可以返回页面的 HTML 代码了。

在 scrape_page 方法的基础上,我们来定义列表页的爬取方法吧,实现如下:

def scrape_index(page):
    """
    scrape index page and return its html
    :param page: page of index page
    :return: html of index page
    """
    index_url = f'{BASE_URL}/page/{page}'
    return scrape_page(index_url)

方法名称叫作 scrape_index,这个实现就很简单了,这个方法会接收一个 page 参数,即列表页的页码,我们在方法里面实现列表页的 URL 拼接,然后调用 scrape_page 方法爬取即可,这样就能得到列表页的 HTML 代码了。

获取了 HTML 代码之后,下一步就是解析列表页,并得到每部电影的详情页的 URL,实现如下:

def parse_index(html):
    """
    parse index page
    :param html: html of index page
    :return: generator of detail page url
    """
    doc = pq(html)
    links = doc('.el-card .name')
    for link in links.items():
        href = link.attr('href')
        detail_url = urljoin(BASE_URL, href)
        logging.info('get detail url %s', detail_url)
        yield detail_url

这里我们定义了 parse_index 方法,它接收一个参数 html,即列表页的 HTML 代码。

在 parse_index 方法里,我们首先定义了一个提取标题超链接 href 属性的正则表达式,内容为:

<a.*?href="(.*?)".*?class="name">

其中我们使用非贪婪通用匹配 .? 来匹配任意字符,同时在 href 属性的引号之间使用了分组匹配 (.?) 正则表达式,这样我们便能在匹配结果里面获取 href 的属性值了。正则表达式后面紧跟着 class="name",用来标示这个 <a> 节点是代表电影名称的节点。

现在有了正则表达式,那么怎么提取列表页所有的 href 值呢? 使用 re 库的 findall 方法就可以了,第一个参数传入这个正则表达式构造的 pattern 对象,第二个参数传入 html,这样 findall 方法便会搜索 html 中所有能与该正则表达式相匹配的内容,之后把匹配到的结果返回,并赋值为 items。

如果 items 为空,那么可以直接返回空列表; 如果 items 不为空,那么直接遍历处理即可。

遍历 items 得到的 item 就是我们在上文所说的类似 /detail/1 这样的结果。由于这并不是一个完整的 URL,所以需要借助 urljoin 方法把 BASE_URL 和 href 拼接到一起,获得详情页的完整 URL,得到的结果就是类似 https://ssr1.scrape.center/detail/1 这样的完整 URL,最后调用 yield 返回即可。

现在我们通过调用 parse_index 方法,往其中传入列表页的 HTML 代码,就可以获得该列表页中所有电影的详情页 URL 了。

接下来我们对上面的方法串联调用一下,实现如下:

def main():
    for page in range(1, TOTAL_PAGE + 1):
        index_html = scrape_index(page)
        detail_urls = parse_index(index_html)
        logging.info('detail urls %s', list(detail_urls))

if __name__ == '__main__':
    main()

这里我们定义了 main 方法,以完成对上面所有方法的调用。main 方法中首先使用 range 方法遍历了所有页码,得到的 page 就是 1-10; 接着把 page 变量传给 scrape_index 方法,得到列表页的 HTML; 把得到的 HTML 赋值为 index_html 变量。接下来将 index_html 变量传给 parse_index 方法,得到列表页所有电影的详情页 URL,并赋值为 detail_urls,结果是一个生成器,我们调用 list 方法就可以将其输出。

运行一下上面的代码,结果如下:

输出内容比较多,这里只贴了一部分。

可以看到,程序首先爬取了第 1 页列表页,然后得到了对应详情页的每个 URL,接着再爬第 2 页、第 3 页,一直到第 10 页,依次输出了每一页的详情页 URL。意味着我们成功获取了所有电影的详情页 URL。

爬取详情页

已经可以成功获取所有详情页 URL 了,下一步当然就是解析详情页,并提取我们想要的信息了。

首先观察一下详情页的 HTML 代码,如图 2-20 所示。

保存数据

成功提取到详情页信息之后,下一步就要把数据保存起来了。由于到现在我们还没有学习数据库的存储,所以临时先将数据保存成文本格式,这里我们可以一个条目定义一个 JSON 文本。

定义一个保存数据的方法如下:

import json
from os import makedirs
from os.path import exists

RESULTS_DIR = 'results'
exists(RESULTS_DIR) or makedirs(RESULTS_DIR)

def save_data(data):
    name = data.get('name')
    data_path = f'{RESULTS_DIR}/{name}.json'
    json.dump(data, open(data_path, 'w', encoding='utf-8'), ensure_ascii=False, indent=2)

这里我们首先定义保存数据的文件夹 RESULTS_DIR,然后判断这个文件夹是否存在,如果不存在则创建一个。

接着,我们定义了保存数据的方法 save_data,其中先是获取数据的 name 字段,即电影名称,将其当作 JSON 文件的名称; 然后构造 JSON 文件的路径,接着用 json 的 dump 方法将数据保存成文本格式。dump 方法设置有两个参数,一个是 ensure_ascii,值为 False,可以保证中文字符在文件中能以正常的中文文本呈现,而不是 unicode 字符; 另一个是 indent,值为 2,设置了 JSON 数据的结果有两行缩进,让 JSON 数据的格式显得更加美观。

接下来把 main 方法稍微改写一下就好了,改写如下:

def main(page):
    """
    main process
    :return:
    """
    index_html = scrape_index(page)
    detail_urls = parse_index(index_html)
    for detail_url in detail_urls:
        detail_html = scrape_detail(detail_url)
        data = parse_detail(detail_html)
        logging.info('get detail data %s', data)
        logging.info('saving data to mongodb')
        save_data(data)
        logging.info('data saved successfully')

多进程加速

由于整个爬取是单进程的,而且只能逐条爬取,因此速度稍微有点慢,那有没有方法对整个爬取过程进行加速呢? 前面我们讲了多进程的基本原理和使用方法,下面就来实践一下多进程爬取吧。

由于一共有 10 页详情页,且这 10 页内容互不干扰,因此我们可以一页开一个进程来爬取。而且因为这 10 个列表页页码正好可以提前构造成一个列表,所以我们可以选用多进程里面的进程池 Pool 来实现这个过程。

这里我们需要改写下 main 方法,实现如下:

def main(page):
    """
    main process
    :return:
    """
    index_html = scrape_index(page)
    detail_urls = parse_index(index_html)
    for detail_url in detail_urls:
        detail_html = scrape_detail(detail_url)
        data = parse_detail(detail_html)
        logging.info('get detail data %s', data)
        logging.info('saving data to json file')
        save_data(data)
        logging.info('data saved successfully')


if __name__ == '__main__':
    pool = multiprocessing.Pool()
    pages = range(1, TOTAL_PAGE + 1)
    pool.map(main, pages)
    pool.close()
    pool.join() # 如果没有 join(),主进程可能会提前退出,导致子进程被强制终止。

我们首先给 main 方法添加了一个参数 page,用以表示列表页的页码。接着声明了一个进程池,并声明 pages 为所有需要遍历的页码,即 1-10。最后调用 map 方法,其第一个参数就是需要被调用的参数,第二个参数就是 pages,即需要遍历的页码。

这样就会依次遍历 pages 中的内容,把 1-10 这 10 个页码分别传递给 main 方法,并把每次的调用分别变成一个进程,加入进程池中,进程池会根据当前运行环境来决定运行多少个进程。例如我的机器的 CPU 有 8 个核,那么进程池的大小就会默认设置为 8,这样会有 8 个进程并行运行。

运行后的输出结果和之前类似,只是可以明显看到,多进程执行之后的爬取速度快了很多。可以清空之前的爬取数据,会发现数据依然可以被正常保存成 JSON 文件。

好了,到现在为止,我们就完成了全站电影数据的爬取,并实现了爬取数据的存储和优化。

总结

本节用到的库有 requests、multiprocessing、re、logging 等,通过这个案例实战,我们把前面学习到的知识都串联了起来,对于其中的一些实现方法,可以好好思考和体会,也希望这个案例能够让你对爬虫的实现有更实际的了解。