Pyppeteer爬取实战

在 7.3 节,我们了解了 Pyppeteer 的基本用法,和 Selenium 相比,它确实有很多方便之处。

本节我们就使用 Pyppeteer 改写 7.5 节的爬取实现,来体会 Pyppeteer 和 Selenium 之间的不同,同时加强对 Pyppeteer 的理解和掌握。

爬取目标

本节要爬取的目标和 7.5 节的一样,还是电影网站 https://spa2.scrape.center/

本节工作

本节要完成的工作也和 7.5 节的一样。

  • 遍历每一页列表页,获取每部电影详情页的 URL。

  • 爬取每部电影的详情页,提取电影的名称、评分、类别、封面、简介等信息。

  • 将爬取的数据保存为 JSON 文件。

准备工作

在开始之前,需要做好如下准备工作。

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

  • 安装好 Pyppeteer 并能成功运行示例。

其他的浏览器、驱动配置此处就不需要了,这也是比 Selenium 更方便的地方。

爬取列表页

依然是先做一些准备工作:

import logging

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

INDEX_URL = 'https://spa2.scrape.center/page/{page}'
TIMEOUT = 10
TOTAL_PAGE = 10
WINDOW_WIDTH, WINDOW_HEIGHT = 1366, 768
HEADLESS = False

这里的大多数配置和 7.5 节是一样的,也导入了一些必要的包,定义了日志配置和几个变量,不过这里还额外定义了浏览器窗口的宽和高,此处是 1366 × 768,大家也可以随意指定适合自己屏幕的宽高。另外,这里还定义了一个变量 HEADLESS,用来指定是否启用 Pyppeteer 的无头模式,如果其值为 False,那么在启动 Pyppeteer 的时候会弹出一个 Chromium 浏览器窗口。

接着,我们再定义一个初始化 Pyppeteer 的方法,其中包括启动 Pyppeteer、新建一个页面选项卡和设置窗口大小等操作。代码实现如下:

from pyppeteer import launch

browser, tab = None, None

async def init():
    global browser, tab
    browser = await launch(headless=HEADLESS,
                           args=['--disable-infobars',
                                 f'--window-size={WINDOW_WIDTH},{WINDOW_HEIGHT}'])
    tab = await browser.newPage()
    await tab.setViewport({'width': WINDOW_WIDTH, 'height': WINDOW_HEIGHT})

这里先声明了 browser 变量和 tab 变量,前者代表 Pyppeteer 所用的浏览器对象,后者代表新建的页面选项卡。这两项都被设置为了全局变量,能够方便其他方法调用。

然后定义了一个 init 方法,该方法中调用了 Pyppeteer 的 launch 方法,并且给 headless 参数传入 HEADLESS,将 Pyppeteer 设置为非无头模式,还通过 args 参数指定了隐藏提示条和设置了浏览器窗口的宽高。

接下来,我们像之前一样,定义一个通用的爬取方法:

from pyppeteer.errors import TimeoutError

async def scrape_page(url, selector):
    logging.info('scraping %s', url)
    try:
        await tab.goto(url)
        await tab.waitForSelector(selector, options={
            'timeout': TIMEOUT * 1000
        })
    except TimeoutError:
        logging.error('error occurred while scraping %s', url, exc_info=True)

这里定义了一个 scrape_page 方法,它接收两个参数:一个是 url,代表要爬取的页面的 URL,使用 goto 方法调用此 URL 即可访问对应页面;另一个是 selector,即等待渲染的节点对应的 CSS 选择器。此外,我们调用了 waitForSelector 方法,传入 selector,并通过 options 指定了最长等待时间。

运行时,会首先访问传入的 URL 对应的页面,然后等待某个选择器匹配的节点加载出来,最 长等待 10 秒。如果 10 秒内加载出来,就接着往下执行,否则抛出 TimeoutError 异常,并输出错误日志。

下面实现爬取列表页的方法:

async def scrape_index(page):
    url = INDEX_URL.format(page=page)
    await scrape_page(url, '.item .name')

这里定义了一个 scrape_index 方法,它接收参数 page,代表要爬取的页面的页码。方法中,我们首先通过 INDEX_URL 构造出了列表页的 URL,然后调用 scrape_page 方法并将构造出的 URL 传入其中,同时传入选择器。

这里我们传入的选择器是 .item .name,是列表页中电影的名称,意味着电影名称加载出来就代表页面加载成功了,如图 7-42 所示。

图7-42 加载成功的页面

我们再定义一个解析列表页的方法,用来提取每部电影的详情页 URL,方法定义如下:

async def parse_index():
    return await tab.querySelectorAllEval('.item .name', 'nodes => nodes.map(node => node.href)')

这里我们调用了 querySelectorAllEval 方法,它接收两个参数:一个是 selector,代表选择器; 另一个是 pageFunction,代表要执行的 JavaScript 方法。这个方法的作用是找出和选择器匹配的节点,然后根据 pageFunction 定义的逻辑从这些节点中抽取出对应的结果并返回。

我们给参数 selector 传入了电影名称。由于和选择器相匹配的节点有多个,所以给 pageFunction 参数输入的 JavaScript 方法就是 nodes,其返回值是调用 map 方法得到 node,然后调用 nodehref 属性得到的超链接。这样,querySelectorAllEval 的返回结果就是当前列表页中所有电影的详情页 URL 组成的列表。

接下来,我们串联调用刚实现的几个方法,代码如下:

import asyncio

async def main():
    await init()
    try:
        for page in range(1, TOTAL_PAGE + 1):
            await scrape_index(page)
            detail_urls = await parse_index()
            logging.info('detail_urls %s', detail_urls)
    finally:
        await browser.close()


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

这里定义了 main 方法,其中首先调用 init 方法,然后遍历所有页码,调用 scrape_index 方法爬取了每一页列表页,接着调用 parse_index 方法,从列表页中提取了详情页的每个 URL,最后输出。

运行结果如下:

2020-04-08 13:54:28,879 - INFO: scraping https://spa2.scrape.center/page/1
2020-04-08 13:54:31,411 - INFO: detail_urls ['https://spa2.scrape.center/detail/ZWYZNzcNOZXvXMGJOdWEJKC01
N3kxcCTVVnSotakA50HhS2Z21tbHmelMqLSFpLTAtbWiX', ... ,
'https://spa2.scrape.center/detail/ZWYZNzcNOZXvXMGJOdWEJKC01N3kxcCTVVnSotakA50HhS2Z21tbHmelMqLSFpLTAtbWiS',
'https://spa2.scrape.center/detail/ZWYZNzcNOZXvXMGJOdWEJKC01N3kxcCTVVnSotakA50HhS2Z21tbHmelMqLSFpLTAtbWiXMA==']
2020-04-08 13:54:31,411 - INFO: scraping https://spa2.scrape.center/page/2

由于输出内容较多,这里省略了部分内容。可以看到,每一次的返回结果都是从当前列表页中提取出的所有详情页 URL 组成的列表。下一步就可以凭借这些 URL 爬取详情页了。

爬取详情页

现在要爬取每一个详情页,先定义一个爬取详情页的方法,代码如下:

async def scrape_detail(url):
    await scrape_page(url,'h2')

这个方法非常简单,直接调用 scrape_page 方法,传入详情页 URL 和选择器即可,这里的选择器我们直接传入了 h2,代表电影名称。运行顺利的话,Pyppeteer 已经成功加载出详情页了,如图 7-43 所示。

图 7-43 加载成功的详情页

async def parse_detail():
    url = tab.url
    name = await tab.querySelectorEval('h2', 'node => node.innerText')
    categories = await tab.querySelectorAllEval('.categories button span', 'nodes => nodes.map(node => node.innerText)')
    cover = await tab.querySelectorEval('.cover', 'node => node.src')
    score = await tab.querySelectorEval('.score', 'node => node.innerText')
    drama = await tab.querySelectorEval('.drama p', 'node => node.innerText')
    return {
        'url': url,
        'name': name,
        'categories': categories,
        'cover': cover,
        'score': score,
        'drama': drama
    }

这里我们定义了 parse_detail 方法,提取了 URL、名称、类别、封面、分数、简介等内容。

  • URL:直接调用 tab 对象的 url 属性即可获取当前页面的 URL。

  • 名称:由于名称只涉及一个节点,因此我们调用的是 querySelectorEval 方法。给这个方法传入的第一个参数值是 h2,代表根据电影名称提取对应的节点;对于第二个参数 pageFunction,这里调用了 nodeinnerText 属性,提取了文本值,即电影名称。

  • 类别:类别有多个,因此调用 querySelectorAllEval 方法。其对应的 CSS 选择器是 .categories button span,可以选中多个类别节点;第二个参数 pageFunction 和之前提取详情页 URL 时类似,使用 nodes 方法,然后调用 map 方法提取 nodeinnerText 就得到了所有的电影类别。

  • 封面:同样,可以使用 CSS 选择器 .cover 直接获取封面对应的节点,不同之处是封面的 URL 对应 src 属性,所以这里提取 src 属性。

  • 分数:使用 CSS 选择器 .score 直接获取分数对应的节点,然后调用 nodeinnerText 属性,提取文本值。

  • 简介:使用 CSS 选择器 .drama p 直接获取简介对应的节点,然后调用 nodeinnerText 属性,提取文本值。

最后,将提取结果汇总成一个字典并返回。

接下来,在 main 方法里添加对 scrape_detail 方法和 parse_detail 方法的调用。main 方法改写如下:

async def main():
    await init()
    try:
        for page in range(1, TOTAL_PAGE + 1):
            await scrape_index(page)
            detail_urls = await parse_index()
            for detail_url in detail_urls:
                await scrape_detail(detail_url)
                detail_data = await parse_detail()
                logging.info('data %s', detail_data)
    finally:
        await browser.close()

重新运行,结果如下:

2020-04-08 14:12:39,564 - INFO: scraping https://spa2.scrape.center/page/1
2020-04-08 14:12:42,935 - INFO: scraping https://spa2.scrape.center/detail/ZWYZNzcNOZXvXMGJOdWEJKC3XC
TVVnSotakA50HhS2Z21tbHmelMqLSFpLTAtbWiX
2020-04-08 14:12:45,781 - INFO: data {'url': 'https://spa2.scrape.center/detail/ZWYZNzcNOZXvXMGJOdWEJK01
N3kxcCTVVnSotakA50HhS2Z21tbHmelMqLSFpLTAtbWiX', 'name': '霸王别姬 - Farewell My Concubine', 'categories':
['剧情', '爱情'], 'cover': 'https://p0.meituan.net/movie/ce4da3e03e65b0588ed3b19cd7896cf62472.jpg@464w_
644h_1e_1c', 'score': '9.5', 'drama': '影片借一出《霸王别姬》的京戏,牵扯出三个人之间一段时代风云变幻的爱恨情仇。段小楼(张丰毅饰)与程蝶衣(张国荣饰)是一对从小一起长大的师兄弟,两人一个演生,一个饰旦,一向配合天衣无缝,尤其一出《霸王别姬》,更是誉满京城,为此,两人约定合演一辈子《霸王别姬》。但两人对戏剧与人生关系的理解有本质不同,段小楼深知戏非人生,程蝶衣则是人戏不分。段小楼在认为
2020-04-08 14:12:45,782 - INFO: scraping https://spa2.scrape.center/detail/ZWYZNzcNOZXvXMGJOdWEJK3XC
TVVnSotakA50HhS2Z21tbHmelMqLSFpLTAtbWiY

可以看到,这里首先爬取列表页,然后提取详情页 URL,接着爬取详情页,提取出我们想要的电影信息,一个详情页爬完再接着爬取下一个。这样所有详情页就都被我们爬取下来了。

数据存储

和 7.5 节一样,这里也定义一个数据存储方法。为了方便,还是将爬取下来的数据保存为 JSON 文件,实现如下:

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

RESULTS_DIR = 'results'

exists(RESULTS_DIR) or makedirs(RESULTS_DIR)

async 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)

这里的实现原理和之前完全相同,但由于 Pyppeteer 是异步调用用,所以需要在 save_data 方法的前面加上 async 关键字。

最后,在 main 方法里面添加对 save_data 方法的调用。

问题排查

在代码运行过程中,可能会由于 Pyppeteer 本身实现方面出问题,因此在连续运行20秒之后控制台输出如下错误内容:

pyppeteer.errors.NetworkError: Protocol Error (Runtime.evaluate): Session closed. Most likely the page has been closed.

其原因是 Pyppeteer 内部使用了 WebSocket,如果 WebSocket 客户端发送 ping 信号 20 秒之后仍未收到 pong 应答,就会中断连接。

问题的解决方法和详情描述见 https://github.com/miyakogi/pyppeteer/issues/178 ,此时我们可以通过修改 Pyppeteer 源代码来解决这个问题,对应的代码修改见 https://github.com/miyakogi/pyppeteer/pull/160/files ,即给 connect 方法添加 ping_interval=None 和 ping_timeout=None 这两个参数。

另外,也可以复写一下 connect 方法的实现,其解决方案同样可以在 https://github.com/miyakogi/pyppeteer/pull/160 中找到,例如 patch_pyppeteer 的定义。

无头模式

最后,如果代码能稳定运行了,可以将其改为无头模式,只要将 HEADLESS 参数值修改为 True 即可:

HEADLESS = True

这样在运行的时候就不会弹出浏览器窗口了。

总结

本节我们通过实例讲解了使用 Pyppeteer 爬取一个完整网站的过程,相信大家会进一步掌握 Pyppeteer 的使用。