Selenium爬取实战

在 7.1 节,我们学习了 Selenium 的基本用法,本节结合一个实际案例体会一下 Selenium 的适用场景以及使用方法。

准备工作

请先确保已经做好了如下准备工作。

  • 安装好 Chrome 浏览器并正确配置了 ChromeDriver。

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

  • 安装好 Selenium 相关的包并能成功用 Selenium 打开 Chrome 浏览器。

这些步骤在 7.1 节都有提及,可以参考相关内容。

准备工作都做好后,便可以开始实战练习了。

爬取目标

本节还是用电影网站 https://spa2.scrape.center/ 做示例,首页如图 7-37 所示。

图7-37 示例网站的页面

乍一看,页面和之前没什么区别。接下来我们仔细观察每部电影的 URL 和 Ajax 请求 API,例如点击《霸王别姬》,观察 URL 的变化,如图 7-38 所示。

图7-38 电影《霸王别姬》主页

可以看到,电影详情页的 URL 和首页的不一样。在图 2-13 中,URL 里的 detail 后面直接跟的是 id,是 1、2、3 等数字,但是这里变成了一个长字符串,看着是由 Base64 编码而得,也就是说详情页的 URL 中包含加密参数,所以我们无法直接根据规律构造详情页的 URL。

然后,依次点击列表页的第 1 页到第 10 页,观察 Ajax 请求,如图 7-39 所示。

图7-39 Ajax 请求

可以看到,这里接口的参数多了一个 token 字段,而且每次请求的 token 都不同,这个字段看着同样是由 Base64 编码而得。更棘手的一点是,API 具有时效性,意味着把 Ajax 接口内 URL 复制下来,短期内是可以访问的,但过段时间就访问不了了,会直接返回 401 状态码。

之前我们可以直接用 requests 构造 Ajax 请求,但现在 Ajax 请求接口中带有 token,而且还是可变的。我们不知道 token 的生成逻辑,就没法直接构造 Ajax 请求来爬取数据。怎么办呢?先分析出 token 的生成逻辑,再模拟 Ajax 请求,是一个办法,可这个办法相对较难。此时我们可以用 Selenium 绕过这个阶段,直接获取 JavaScript 最终染完成的页面源代码,再从中提取数据即可。

之后我们要完成如下工作。

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

  • 通过 Selenium 根据上一步获取的详情页 URL 爬取每部电影的详情页。

  • 从详情页中提取每部电影的名称、类别、分数、简介、封面等内容。

爬取列表页

先做一系列初始化工作:

from selenium import webdriver
from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.wait import WebDriverWait

import logging

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

INDEX_URL = 'https://spa2.scrape.center/page/{page}'
TIME_OUT = 10
TOTAL_PAGE = 10

browser = webdriver.Chrome()
wait = WebDriverWait(browser, TIME_OUT)

这里首先导入了一些必要的 Selenium 包,包括 webdriver、WebDriverWait 等,后面我们会使用这些包爬取页面和设置延迟等待等。然后又定义了日志配置和几个变量,这和之前几节的内容类似。接着使用 Chrome 生产生了一个 webdriver 对象,并赋值为 browser 变量。我们可以通过 browser 调用 Selenium 的一些 API 来对浏览器进行一系列操作,如截图、点击、下拉等。最后,我们声明了一个 WebDriverWait 对象,利用它可以配置页面加载的最长等待时间。

下面我们观察一下列表页,然后爬取其中的数据。

能够观察到,列表页的 URL 还是有一定规律的,例如第一页的 URL 是 https://spa2.scrape.center/page/1 ,最后的数字就是页码,所以可以直接构造出每一页的 URL。

那么,怎么判断一个列表页是否加载成功呢?很简单,当页面上出现了我们想要的内容时,就代表加载成功了。这里可以使用 Selenium 的隐式判断条件,例如每部电影的信息区块的 CSS 选择器 #index .item,如图 7-40 所示。

图7-40 电影的信息区块

直接使用 visibility_of_all_elements_located 判断条件加上 CSS 选择器的内容,即可判断页面有没有加载成功,配合 WebDriverWait 的超时配置,就可以实现 10 秒的页面加载监听。如果 10 秒之内,我们配置的条件得到满足,就代表页面加载成功,否则抛出 TimeoutException 异常。实现代码如下:

def scrape_page(url, condition, locator):
    logging.info('scraping %s', url)
    try:
        browser.get(url)
        wait.until(condition(locator))
    except TimeoutException:
        logging.error('error occurred while scraping %s', url, exc_info=True)


def scrape_index(page):
    url = INDEX_URL.format(page=page)
    scrape_page(url, condition=EC.visibility_of_all_elements_located,
                locator=(By.CSS_SELECTOR, '#index .item'))

这我们定义了两个方法。

第一个方法 scrape_page 依然是一个通用的爬取方法,可以对任意 URL 进行爬取、状态监听以及异常处理,接收 urlconditionlocator 三个参数:url 就是要爬取的页面的 URL;condition 是页面加载成功的判断条件,可以是 expected_conditions 中的某一项,如 visibility_of_all_elements_locatedvisibility_of_element_located 等;locator 是定位器,是一个元组,通过配置查询条件和参数来获取一个或多个节点,如(By.CSS_SELECTOR,'#index.item')代表通过 CSS 选择器查找 #index.item 来获取列表页所有的电影信息节点。另外,我们在爬取过程中添加了超时检测,如果到规定时间(这里为 10 秒)还没有加载出对应的节点,就抛出 TimeoutException 异常并输出错误日志。

第二个方法 scrape_index 则是爬取列表页的方法,接收一个参数 page,通过调用 scrape_page 方法并传入 condition 参数和 locator 参数,完成对列表页的爬取。这里的 condition 我们传入的是 visibility_of_all_elements_located,代表所有节点都加载出来才算成功。

注意,这里爬取页面时,不需要返回任何结果,因为执行完 scrape_index 方法后,页面正好处于加载完成状态,利用 browser 对象即可进行进一步的信息提取。

现在已经可以加载出列表页了,下一步当然就是解析列表页,从中提取详情页的 URL。这里定义一个解析列表页的方法,具体如下:

from urllib.parse import urljoin

def parse_index():
    elements = browser.find_elements_by_css_selector('#index .item .name')
    for element in elements:
        href = element.get_attribute('href')
        yield urljoin(INDEX_URL, href)

我们通过 find_elements_by_css_selector 方法直接从列表页中提取了所有电影节点,接着遍历这些节点,通过 get_attribute 方法提取了详情页的 href 属性值,再用 urljoin 方法合并成一个完整的 URL。

最后,我们用一个 main 方法把上面的所有的方法串联起来,实现如下:

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

这里我们遍历了所有页码,依次爬取每一个列表页并提取出详情页的 URL。

运行结果如下:

2020-03-29 12:03:09,896 - INFO: scraping https://spa2.scrape.center/page/1
2020-03-29 12:03:13,724 - INFO: details urls ['https://spa2.scrape.center/detail/ZWYZNzcNOZXvXMGJodWEjJKc01N3kxcCTVVnSoTakaA50HhS2Z21tbHmelMqLSFpLTAtbWix',
...
'https://spa2.scrape.center/detail/ZWYZNzcNOZXvXMGJodWEjJKc01N3kxcCTVVnSoTakaA50HhS2Z21tbHmelMqLSFpLTAtbWiS',
'https://spa2.scrape.center/detail/ZWYZNzcNOZXvXMGJodWEjJKc01N3kxcCTVVnSoTakaA50HhS2Z21tbHmelMqLSFpLTAtbWiXMA==']
2020-03-29 12:03:13,724 - INFO: scraping https://spa2.scrape.center/page/2
...

由于输出内容较多,这里省略了部分内容。

观察结果可以发现,我们已经成功提取到详情页那一个个不规则的 URL 了!

爬取详情页

既然已经成功拿到详情页的 URL 了,接下来就进一步爬取详情页并提取对应的信息吧。

基于同样的逻辑,这里也可以加一个判断条件,如果电影名称加载出来,就代表详情页加载成功。实现时,调用 scrape_page 方法即可,代码如下:

def scrape_detail(url):
    scrape_page(url, condition=EC.visibility_of_element_located,
                locator=(By.TAG_NAME, 'h2'))

这里的判定条件 condition 传入的是 visibility_of_element_located,即单个元素出现即可。locator 传入的是 (By.TAG_NAME, 'h2'),即 h2 这个节点,也就是电影名称对应的节点,如图 7-41 所示。

图 7-41 电影名称对应的节点 h2

如果执行了 scrape_detail 方法,没有抛出 TimeoutException 异常,就表示页面加载成功了。下面定义一个解析详情页的方法来提取我们想要的信息。实现如下:

def parse_detail():
    url = browser.current_url
    name = browser.find_element_by_tag_name('h2').text
    categories = [element.text for element in browser.find_elements_by_css_selector('.categories button span')]
    cover = browser.find_element_by_css_selector('.cover').get_attribute('src')
    score = browser.find_element_by_class_name('score').text
    drama = browser.find_element_by_css_selector('.drama p').text
    return {
        'url': url,
        'name': name,
        'categories': categories,
        'cover': cover,
        'score': score,
        'drama': drama
    }
}

这里定义了一个 parse_detail 方法,提取了详情页的 URL 和电影的名称、类别、封面、分数和简介等内容,提取细节如下。

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

  • 名称:提取 h2 节点内部的文本即可获取电影名称。这里我们使用 find_element_by_tag_name 方法传入 h2,提取到了指定名称对应的节点,然后调用 text 属性提取了节点内部的文本,即电影名称。

  • 类别:为了方便,这里通过 CSS 选择器提取电影类别,对应的 CSS 选择器为 .categories button span。可以使用 find_elements_by_css_selector 方法提取 CSS 选择器对应的多个类别节点,然后遍历这些节点,调用节点的 text 属性获取节点内部的文本。

  • 封面:可以使用 CSS 选择器 .cover 直接获取封面对应的节点。但是由于封面的 URL 对应的是 src 这个属性,所以这里使用 get_attribute 方法并传入 src 来提取。

  • 分数:对应的 CSS 选择器为 .score。依然可以用上面的方式来提取分数,但是这里换了一个方法,叫作 find_element_by_class_name,这个方法可以使用 class 的名称提取节点,能达到同样的效果,不过这里传入的参数就是 class 的名称 score 而不是 .score 了。提取节点后,再调用 text 属性提取节点文本即可。

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

最后,把所有结果构造成一个字典并返回。

接下来,在 main 方法中添加对这两个方法的调用,实现如下:

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

这样爬取完列表页之后,就可以依次爬取详情页来提取每部电影的具体信息了。

2020-03-29 12:24:10,723 - INFO: scraping https://spa2.scrape.center/page/1
2020-03-29 12:24:16,997 - INFO: get detail url https://spa2.scrape.center/detail/ZWYZNzcNOZXvXMGJodWEj
KC01N3kxcCTVVnSoTakaA50HhS2Z21tbHmelMqLSFpLTAtbWiX
2020-03-29 12:24:16,997 - INFO: scraping https://spa2.scrape.center/detail/ZWYZNzcNOZXvXMGJodWEjKC01
N3kxcCTVVnSoTakaA50HhS2Z21tbHmelMqLSFpLTAtbWiX
2020-03-29 12:24:19,289 - INFO: detail data {'url': 'https://spa2.scrape.center/detail/ZWYZNzcNOZXvXMGJOd
WEjKC01N3kxcCTVVnSoTakaA50HhS2Z21tbHmelMqLSFpLTAtbWiX', 'name': '霸王别姬 - Farewell My Concubine',
'categories': ['剧情', '爱情'], 'cover': 'https://p0.meituan.net/movie/ce4da3e03e65b0588ed3b19cd7896
cf62472.jpg@464w_644h_1e_1c', 'score': '9.5', 'drama': '影片借一出《霸王别姬》的京戏,牵扯出三个人之间
一段时代风云变幻的爱恨情仇。段小楼(张丰毅饰)与程蝶衣(张国荣饰)是一对从小一起长大的师兄弟,两人
一个演生,一个饰旦,一向配合天衣无缝,尤其一曲《霸王别姬》,更是誉满京城,为此,两人约定合演一辈子《霸
王别姬》。但两人对戏剧与人生关系的理解有本质不同,段小楼深知戏非人生,程蝶衣则是人戏不分。段小楼在认为

这样我们即得到了详情页的数据。

数据存储

最后,像之前那样添加一个存储数据的方法。为了方便,这里还是将数据保存为 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)

这里的原理和实现方式与 2.5 节是完全相同的,不再赘述。

最后在 main 方法中添加对 save_data 方法的调用即可。

设置无头模式

如果觉得爬取过程中弹出浏览器会造成干扰,可以开启 Chrome 的无头模式,这样不仅解决了干扰问题,爬取速度也得到进一步提升。只需要对代码做如下修改即可开启无头模式:

options = webdriver.ChromeOptions()
options.add_argument('--headless')
browser = webdriver.Chrome(options=options)

这里通过 ChromeOptions 对象添加了 --headless 参数,然后用 ChromeOptions 对 Chrome 进行了初始化。之后重新运行代码,Chrome 浏览器就不会弹出来了,爬取结果也和之前完全一样。

总结

本节,我们通过一个案例了解了 Selenium 的适用场景,并实现了页面爬取,相信能让大家进一步掌握 Selenium 的使用方法。