数字旗手

电气化、自动化、数字化、智能化、智慧化

0%

52讲轻松搞定网络爬虫笔记3

资料

52讲轻松搞定网络爬虫

Ajax的原理和解析

当我们在用 requests 抓取页面的时候,得到的结果可能会和在浏览器中看到的不一样:在浏览器中正常显示的页面数据,使用 requests 却没有得到结果。这是因为 requests 获取的都是原始 HTML 文档,而浏览器中的页面则是经过 JavaScript 数据处理后生成的结果。这些数据的来源有多种,可能是通过 Ajax 加载的,可能是包含在 HTML 文档中的,也可能是经过 JavaScript 和特定算法计算后生成的。

对于第 1 种情况,数据加载是一种异步加载方式,原始页面不会包含某些数据,只有在加载完后,才会向服务器请求某个接口获取数据,然后数据才被处理从而呈现到网页上,这个过程实际上就是向服务器接口发送了一个 Ajax 请求。

按照 Web 的发展趋势来看,这种形式的页面将会越来越多。网页的原始 HTML 文档不会包含任何数据,数据都是通过 Ajax 统一加载后再呈现出来的,这样在 Web 开发上可以做到前后端分离,并且降低服务器直接渲染页面带来的压力。

所以如果你遇到这样的页面,直接利用 requests 等库来抓取原始页面,是无法获取有效数据的。这时我们需要分析网页后台向接口发送的 Ajax 请求,如果可以用 requests 来模拟 Ajax 请求,就可以成功抓取了。

所以,本课时我们就来了解什么是 Ajax 以及如何去分析和抓取 Ajax 请求。

什么是 Ajax

Ajax,全称为 Asynchronous JavaScript and XML,即异步的 JavaScript 和 XML。它不是一门编程语言,而是利用 JavaScript 在保证页面不被刷新、页面链接不改变的情况下与服务器交换数据并更新部分网页的技术。

传统的网页,如果你想更新其内容,那么必须要刷新整个页面。有了 Ajax,便可以在页面不被全部刷新的情况下更新其内容。在这个过程中,页面实际上在后台与服务器进行了数据交互,获取到数据之后,再利用 JavaScript 改变网页,这样网页内容就会更新了。

你可以到 W3School 上体验几个 Demo 来感受一下:http://www.w3school.com.cn/ajax/ajax_xmlhttprequest_send.asp

实例引入

浏览网页的时候,我们会发现很多网页都有下滑查看更多的选项。以我微博的主页为例:https://m.weibo.cn/u/2830678474。我们切换到微博页面,发现下滑几个微博后,后面的内容不会直接显示,而是会出现一个加载动画,加载完成后下方才会继续出现新的微博内容,这个过程其实就是 Ajax 加载的过程,如图所示:

我们注意到页面其实并没有整个刷新,这意味着页面的链接没有变化,但是网页中却多了新内容,也就是后面刷出来的新微博。这就是通过 Ajax 获取新数据并呈现的过程。

基本原理

初步了解了 Ajax 之后,我们再来详细了解它的基本原理。发送 Ajax 请求到网页更新的过程可以简单分为以下 3 步:

  • 发送请求
  • 解析内容
  • 渲染网页

下面我们分别详细介绍一下这几个过程。

发送请求

我们知道 JavaScript 可以实现页面的各种交互功能,Ajax 也不例外,它是由 JavaScript 实现的,实际上执行了如下代码:

var xmlhttp;
if (window.XMLHttpRequest) {
    //code for IE7+, Firefox, Chrome, Opera, Safari
    xmlhttp=new XMLHttpRequest();} else {//code for IE6, IE5
    xmlhttp=new ActiveXObject("Microsoft.XMLHTTP");
}
xmlhttp.onreadystatechange=function() {if (xmlhttp.readyState==4 && xmlhttp.status==200) {document.getElementById("myDiv").innerHTML=xmlhttp.responseText;
    }
}
xmlhttp.open("POST","/ajax/",true);
xmlhttp.send();

这是 JavaScript 对 Ajax 最底层的实现,这个过程实际上是新建了 XMLHttpRequest 对象,然后调用 onreadystatechange 属性设置监听,最后调用 open() 和 send() 方法向某个链接(也就是服务器)发送请求。

前面我们用 Python 实现请求发送之后,可以得到响应结果,但这里请求的发送由 JavaScript 来完成。由于设置了监听,所以当服务器返回响应时,onreadystatechange 对应的方法便会被触发,我们在这个方法里面解析响应内容即可。

解析内容

得到响应之后,onreadystatechange 属性对应的方法会被触发,此时利用 xmlhttp 的 responseText 属性便可取到响应内容。这类似于 Python 中利用 requests 向服务器发起请求,然后得到响应的过程。

返回的内容可能是 HTML,也可能是 JSON,接下来我们只需要在方法中用 JavaScript 进一步处理即可。比如,如果返回的内容是 JSON 的话,我们便可以对它进行解析和转化。

渲染网页

JavaScript 有改变网页内容的能力,解析完响应内容之后,就可以调用 JavaScript 针对解析完的内容对网页进行下一步处理。比如,通过 document.getElementById().innerHTML 这样的操作,对某个元素内的源代码进行更改,这样网页显示的内容就改变了,这种对 Document 网页文档进行如更改、删除等操作也被称作 DOM 操作。

上例中,document.getElementById("myDiv").innerHTML=xmlhttp.responseText这个操作便将 ID 为 myDiv 的节点内部的 HTML 代码更改为服务器返回的内容,这样 myDiv 元素内部便会呈现出服务器返回的新数据,网页的部分内容看上去就更新了。

可以看到,发送请求、解析内容和渲染网页这 3 个步骤其实都是由 JavaScript 完成的。

我们再回想微博的下拉刷新,这其实是 JavaScript 向服务器发送了一个 Ajax 请求,然后获取新的微博数据,将其解析,并将其渲染在网页中的过程。

因此,真实的数据其实都是通过一次次 Ajax 请求得到的,如果想要抓取这些数据,我们需要知道这些请求到底是怎么发送的,发往哪里,发了哪些参数。如果我们知道了这些,不就可以用 Python 模拟这个发送操作,获取到其中的结果了吗?

Ajax 分析

这里还是以前面的微博为例,我们知道拖动刷新的内容由 Ajax 加载,而且页面的 URL 没有变化,这时我们应该到哪里去查看这些 Ajax 请求呢?

这里还需要借助浏览器的开发者工具,下面以 Chrome 浏览器为例来介绍。

首先,用 Chrome 浏览器打开微博链接 https://m.weibo.cn/u/2830678474,随后在页面中点击鼠标右键,从弹出的快捷菜单中选择“检查” 选项,此时便会弹出开发者工具,如图所示:

前面也提到过,这里就是页面加载过程中浏览器与服务器之间发送请求和接收响应的所有记录。

Ajax 有其特殊的请求类型,它叫作 xhr。在图中我们可以发现一个以 getIndex 开头的请求,其 Type 为 xhr,这就是一个 Ajax 请求。用鼠标点击这个请求,可以查看这个请求的详细信息。

在右侧可以观察到 Request Headers、URL 和 Response Headers 等信息。Request Headers 中有一个信息为 X-Requested-With:XMLHttpRequest,这就标记了此请求是 Ajax 请求,如图所示:

随后我们点击 Preview,即可看到响应的内容,它是 JSON 格式的。这里 Chrome 为我们自动做了解析,点击箭头即可展开和收起相应内容。

我们可以观察到,返回结果是我的个人信息,包括昵称、简介、头像等,这也是用来渲染个人主页所使用的数据。JavaScript 接收到这些数据之后,再执行相应的渲染方法,整个页面就渲染出来了。

另外,我们也可以切换到 Response 选项卡,从中观察到真实的返回数据,如图所示:

接下来,切回到第一个请求,观察一下它的 Response 是什么,如图所示:

这就是最原始链接 https://m.weibo.cn/u/2830678474 返回的结果,其代码只有不到 50 行,结构也非常简单,只是执行了一些 JavaScript。

所以说,我们看到的微博页面的真实数据并不是最原始的页面返回的,而是在执行 JavaScript 后再次向后台发送 Ajax 请求,浏览器拿到数据后进一步渲染出来的。

过滤请求

接下来,我们再利用 Chrome 开发者工具的筛选功能筛选出所有的 Ajax 请求。在请求的上方有一层筛选栏,直接点击 XHR,此时在下方显示的所有请求便都是 Ajax 请求了,如图所示:

接下来,不断滑动页面,可以看到页面底部有一条条新的微博被刷出,而开发者工具下方也不断地出现 Ajax 请求,这样我们就可以捕获到所有的 Ajax 请求了。

随意点开一个条目,都可以清楚地看到其 Request URL、Request Headers、Response Headers、Response Body 等内容,此时想要模拟请求和提取就非常简单了。

下图所示的内容便是我某一页微博的列表信息:

到现在为止,我们已经可以分析出 Ajax 请求的一些详细信息了,接下来只需要用程序模拟这些 Ajax 请求,就可以轻松提取我们所需要的信息了。

Ajax爬取案例实战

上一课时我们学习了 Ajax 的基本原理和分析方法,这一课时我们结合实际案例,学习 Ajax 分析和爬取页面的具体实现。

准备工作

在开始学习之前,我们需要做好如下的准备工作:

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

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

  • 了解 Ajax 的基础知识和分析 Ajax 的基本方法。

以上内容在前面的课时中均有讲解,如你尚未准备好建议先熟悉一下这些内容。

爬取目标

本课时我们以一个动态渲染网站为例来试验一下 Ajax 的爬取。其链接为:https://dynamic1.scrape.center/,页面如图所示。

这个页面看似和我们上一课时的案例一模一样,但其实不是,它的后台实现逻辑和数据加载方式与上一课时完全不同,只不过最后呈现的样式是一样的。

这个网站同样支持翻页,可以点击最下方的页码来切换到下一页,如图所示。

点击每一个电影的链接进入详情页,页面结构也是完全一样的,如图所示。

我们需要爬取的数据也和原来是相同的,包括电影的名称、封面、类别、上映日期、评分、剧情简介等信息。

本课时我们需要完成的目标有:

  • 分析页面数据的加载逻辑。

  • 用 requests 实现 Ajax 数据的爬取。

  • 将每部电影的数据保存成一个 JSON 数据文件。

由于本课时主要讲解 Ajax,所以对于数据存储和加速部分就不再展开实现,主要是讲解 Ajax 的分析和爬取。

那么我们现在就开始正式学习吧。

初步探索

首先,我们尝试用之前的 requests 来直接提取页面,看看会得到怎样的结果。用最简单的代码实现一下 requests 获取首页源码的过程,代码如下:

import requests

url = 'https://dynamic1.scrape.center/'
html = requests.get(url).text
print(html)

运行结果如下:

可以看到我们只爬取到了这么一点 HTML 内容,而在浏览器中打开这个页面却能看到这样的结果,如图所示。

也就是说在 HTML 中我们只能在源码中看到引用了一些 JavaScript 和 CSS 文件,并没有观察任何有关电影数据的信息。

如果遇到这样的情况,说明我们现在看到的整个页面是通过 JavaScript 渲染得到的,浏览器执行了 HTML 中所引用的 JavaScript 文件,JavaScript 通过调用一些数据加载和页面渲染的方法,才最终呈现了图中所示的页面。

在一般情况下,这些数据都是通过 Ajax 来加载的, JavaScript 在后台调用这些 Ajax 数据接口,得到数据之后,再把数据进行解析并渲染呈现出来,得到最终的页面。所以说,要想爬取这个页面,我们可以通过直接爬取 Ajax 接口获取数据。

在上一课时中,我们已经了解了用 Ajax 分析的基本方法。下面我们就来分析下 Ajax 接口的逻辑并实现数据爬取吧。

爬取列表页

首先我们来分析下列表页的 Ajax 接口逻辑,打开浏览器开发者工具,切换到 Network 面板,勾选上 「Preserve Log」并切换到 「XHR」选项卡,如图所示。

接着,我们重新刷新页面,然后点击第 2 页、第 3 页、第 4 页的按钮,这时候可以看到页面上的数据发生了变化,同时在开发者工具下方会监听到几个 Ajax 请求,如图所示。

由于我们切换了 4 页,所以这里正好也出现了 4 个 Ajax 请求,我们可以任选一个点击查看其请求详情,观察其请求的 URL、参数以及响应内容是怎样的,如图所示。

这里我们点开第 2 个结果,观察到其 Ajax 接口请求的 URL 地址为:https://dynamic1.scrape.center/api/movie/?limit=10&offset=10,这里有两个参数,一个是 limit,其值为 10,一个是 offset,它的值也是 10。

通过观察多个 Ajax 接口的参数,我们可以发现这么一个规律:limit 的值一直为 10,这就正好对应着每页 10 条数据;offset 的值在依次变大,页面每加 1 页,offset 就加 10,这就代表着页面的数据偏移量,比如第 2 页的 offset 值为 10 代表跳过 10 条数据,返回从第 11 条数据开始的结果,再加上 limit 的限制,就代表返回第 11~20 条数据的结果。

接着我们再观察下响应的数据,切换到 Preview 选项卡,结果如图所示。

可以看到结果是一些 JSON 数据,它有一个 results 字段,这是一个列表,列表的每一个元素都是一个字典。观察一下字典的内容,发现我们可以看到对应的电影数据的字段了,如 name、alias、cover、categories,对比下浏览器中的真实数据,各个内容是完全一致的,而且这个数据已经非常结构化了,完全就是我们想要爬取的数据,真是得来全不费工夫。

这样的话,我们只需要把所有页面的 Ajax 接口构造出来,那么所有的列表页数据我们都可以轻松获取到了。

我们先定义一些准备工作,导入一些所需的库并定义一些配置,代码如下:

import requests
import logging

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

INDEX_URL = 'https://dynamic1.scrape.center/api/movie/?limit={limit}&offset={offset}'

这里我们引入了 requests 和 logging 库,并定义了 logging 的基本配置,接着我们定义 INDEX_URL,这里把 limit 和 offset 预留出来变成占位符,可以动态传入参数构造成一个完整的列表页 URL。

下面我们来实现一下列表页的爬取,还是和原来一样,我们先定义一个通用的爬取方法,代码如下:

def scrape_api(url):
    logging.info('scraping %s...', url)
    try:
        response = requests.get(url)
        if response.status_code == 200:
            return response.json()
        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_api 方法,和之前不同的是,这个方法专门用来处理 JSON 接口,最后的 response 调用的是 json 方法,它可以解析响应的内容并将其转化成 JSON 字符串。

在这个基础之上,我们定义一个爬取列表页的方法,代码如下:

LIMIT = 10

def scrape_index(page):
    url = INDEX_URL.format(limit=LIMIT, offset=LIMIT * (page - 1))
    return scrape_api(url)

这里我们定义了一个 scrape_index 方法,用来接收参数 page,page 代表列表页的页码。

这里我们先构造了一个 URL,通过字符串的 format 方法,传入 limit 和 offset 的值。这里的 limit 直接使用了全局变量 LIMIT 的值,offset 则是动态计算的,计算方法是页码数减 1 再乘以 limit,比如第 1 页的 offset 值就是 0,第 2 页的 offset 值就是 10,以此类推。构造好 URL 之后,直接调用 scrape_api 方法并返回结果即可。

这样我们就完成了列表页的爬取,每次请求都会得到一页 10 部的电影数据。

由于这时爬取到的数据已经是 JSON 类型了,所以我们不用像之前一样去解析 HTML 代码来提取数据,爬到的数据就是我们想要的结构化数据,因此解析这一步这里我们就可以直接省略啦。

到此为止,我们就能成功爬取列表页并提取出电影列表信息了。

爬取详情页

这时候我们已经可以拿到每一页的电影数据了,但是实际上这些数据还缺少一些我们想要的信息,如剧情简介等,所以我们需要进一步进入到详情页来获取这些内容。

这时候我们点击任意一部电影,如《教父》,进入到其详情页面,这时候可以发现页面的 URL 已经变成了 https://dynamic1.scrape.center/detail/40,页面也成功展示了详情页的信息,如图所示。

另外我们也可以观察到在开发者工具中又出现了一个 Ajax 请求,其 URL 为 https://dynamic1.scrape.center/api/movie/40/,通过 Preview 选项卡也能看到 Ajax 请求对应响应的信息,如图所示。

稍加观察我们就可以发现,Ajax 请求的 URL 后面有一个参数是可变的,这个参数就是电影的 id,这里是 40,对应《教父》这部电影。

如果我们想要获取 id 为 50 的电影,只需要把 URL 最后的参数改成 50 即可,即 https://dynamic1.scrape.center/api/movie/50/,请求这个新的 URL 我们就能获取 id 为 50 的电影所对应的数据了。

同样的,它响应的结果也是结构化的 JSON 数据,字段也非常规整,我们直接爬取即可。

分析了详情页的数据提取逻辑,那么怎么把它和列表页关联起来呢?这个 id 又是从哪里来呢?我们回过头来再看看列表页的接口返回数据,如图所示。

可以看到列表页原本的返回数据就带了 id 这个字段,所以我们只需要拿列表页结果中的 id 来构造详情页中 Ajax 请求的 URL 就好了。

那么接下来,我们就先定义一个详情页的爬取逻辑吧,代码如下:

DETAIL_URL = 'https://dynamic1.scrape.center/api/movie/{id}'

def scrape_detail(id):
    url = DETAIL_URL.format(id=id)
    return scrape_api(url)

这里我们定义了一个 scrape_detail 方法,它接收参数 id。这里的实现也非常简单,先根据定义好的 DETAIL_URL 加上 id,构造一个真实的详情页 Ajax 请求的 URL,然后直接调用 scrape_api 方法传入这个 URL 即可。

接着,我们定义一个总的调用方法,将以上的方法串联调用起来,代码如下:

TOTAL_PAGE = 10

def main():
    for page in range(1, TOTAL_PAGE + 1):
        index_data = scrape_index(page)
        for item in index_data.get('results'):
            id = item.get('id')
            detail_data = scrape_detail(id)
            logging.info('detail data %s', detail_data)

这里我们定义了一个 main 方法,首先遍历获取页码 page,然后把 page 当成参数传递给 scrape_index 方法,得到列表页的数据。接着我们遍历所有列表页的结果,获取每部电影的 id,然后把 id 当作参数传递给 scrape_detail 方法,来爬取每部电影的详情数据,赋值为 detail_data,输出即可。

运行结果如下:

2020-03-19 02:51:55,981 - INFO: scraping https://dynamic1.scrape.center/api/movie/?limit=10&offset=0...
2020-03-19 02:51:56,446 - INFO: scraping https://dynamic1.scrape.center/api/movie/1...
2020-03-19 02:51:56,638 - INFO: detail data {'id': 1, 'name': '霸王别姬', 'alias': 'Farewell My Concubine', 'cover': 'https://p0.meituan.net/movie/ce4da3e03e655b5b88ed31b5cd7896cf62472.jpg@464w_644h_1e_1c', 'categories': ['剧情', '爱情'], 'regions': ['中国大陆', '中国香港'], 'actors': [{'name': '张国荣', 'role': '程蝶衣', ...}, ...], 'directors': [{'name': '陈凯歌', 'image': 'https://p0.meituan.net/movie/8f9372252050095067e0e8d58ef3d939156407.jpg@128w_170h_1e_1c'}], 'score': 9.5, 'rank': 1, 'minute': 171, 'drama': '影片借一出《霸王别姬》的京戏,牵扯出三个人之间一段随时代风云变幻的爱恨情仇。段小楼(张丰毅 饰)与程蝶衣(张国荣 饰)是一对打小一起长大的师兄弟,...', 'photos': [...], 'published_at': '1993-07-26', 'updated_at': '2020-03-07T16:31:36.967843Z'}
2020-03-19 02:51:56,640 - INFO: scraping https://dynamic1.scrape.center/api/movie/2...
2020-03-19 02:51:56,813 - INFO: detail data {'id': 2, 'name': '这个杀手不太冷', 'alias': 'Léon', 'cover': 'https://p1.meituan.net/movie/6bea9af4524dfbd0b668eaa7e187c3df767253.jpg@464w_644h_1e_1c', 'categories': ['剧情', '动作', '犯罪'], 'regions': ['法国'], 'actors': [{'name': '让·雷诺', 'role': '莱昂 Leon', ...}, ...], 'directors': [{'name': '吕克·贝松', 'image': 'https://p0.meituan.net/movie/0e7d67e343bd3372a714093e8340028d40496.jpg@128w_170h_1e_1c'}], 'score': 9.5, 'rank': 3, 'minute': 110, 'drama': '里昂(让·雷诺 饰)是名孤独的职业杀手,受人雇佣。一天,邻居家小姑娘马蒂尔德(纳塔丽·波特曼 饰)敲开他的房门,要求在他那里暂避杀身之祸。...', 'photos': [...], 'published_at': '1994-09-14', 'updated_at': '2020-03-07T16:31:43.826235Z'}
...

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

可以看到,其实整个爬取工作到这里就已经完成了,这里会先顺次爬取每一页列表页的 Ajax 接口,然后再顺次爬取每部电影详情页的 Ajax 接口,最后打印出每部电影的 Ajax 接口响应数据,而且都是 JSON 格式。这样,所有电影的详情数据都会被我们爬取到啦。

保存数据

最后,让我们把爬取到的数据保存下来吧。之前我们是用 MongoDB 来存储数据,由于本课时重点讲解 Ajax 爬取,所以这里就一切从简,将数据保存为 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 数据结果有两个空格缩进,让它的格式显得更加美观。

最后,main 方法再调用下 save_data 方法即可,实现如下:

def main():
    for page in range(1, TOTAL_PAGE + 1):
        index_data = scrape_index(page)
        for item in index_data.get('results'):
            id = item.get('id')
            detail_data = scrape_detail(id)
            logging.info('detail data %s', detail_data)
            save_data(detail_data)

重新运行一下,我们发现本地 results 文件夹下出现了各个电影的 JSON 文件,如图所示。

这样我们就已经把所有的电影数据保存下来了,打开其中一个 JSON 文件,看看保存格式,如图所示。

可以看到 JSON 文件里面的数据都是经过格式化的中文文本数据,结构清晰,一目了然。

至此,我们就完成了全站电影数据的爬取并把每部电影的数据保存成了 JSON 文件。

总结

本课时我们通过一个案例来体会了 Ajax 分析和爬取的基本流程,希望你能够对 Ajax 的分析和爬取的实现更加熟悉。

另外我们也可以观察到,由于 Ajax 接口大部分返回的是 JSON 数据,所以在一定程度上可以避免一些数据提取的工作,减轻我们的工作量。

Selenium的基本使用

上个课时我们讲解了 Ajax 的分析方法,利用 Ajax 接口我们可以非常方便地完成数据的爬取。只要我们能找到 Ajax 接口的规律,就可以通过某些参数构造出对应的的请求,数据自然就能被轻松爬取到。

但是,在很多情况下,Ajax 请求的接口通常会包含加密的参数,如 token、sign 等,如:https://dynamic2.scrape.center/,它的 Ajax 接口是包含一个 token 参数的,如图所示。

由于接口的请求加上了 token 参数,如果不深入分析并找到 token 的构造逻辑,我们是难以直接模拟这些 Ajax 请求的。

此时解决方法通常有两种,一种是深挖其中的逻辑,把其中 token 的构造逻辑完全找出来,再用 Python 复现,构造 Ajax 请求;另外一种方法就是直接通过模拟浏览器的方式,绕过这个过程。因为在浏览器里面我们是可以看到这个数据的,如果能直接把看到的数据爬取下来,当然也就能获取对应的信息了。

由于第 1 种方法难度较高,在这里我们就先介绍第 2 种方法,模拟浏览器爬取。

这里使用的工具为 Selenium,我们先来了解一下 Selenium 的基本使用方法吧。

Selenium 是一个自动化测试工具,利用它可以驱动浏览器执行特定的动作,如点击、下拉等操作,同时还可以获取浏览器当前呈现的页面源代码,做到可见即可爬。对于一些使用 JavaScript 动态渲染的页面来说,此种抓取方式非常有效。本课时就让我们来感受一下它的强大之处吧。

准备工作

本课时以 Chrome 为例来讲解 Selenium 的用法。在开始之前,请确保已经正确安装好了 Chrome 浏览器并配置好了 ChromeDriver。另外,还需要正确安装好 Python 的 Selenium 库。

安装过程可以参考:https://cuiqingcai.com/5135.html 和 https://cuiqingcai.com/5141.html

基本使用

准备工作做好之后,首先来看一下 Selenium 有一些怎样的功能。示例如下:

from selenium import webdriver 
from selenium.webdriver.common.by import By 
from selenium.webdriver.common.keys import Keys 
from selenium.webdriver.support import expected_conditions as EC 
from selenium.webdriver.support.wait import WebDriverWait
browser = webdriver.Chrome() 
try:
browser.get('https://www.baidu.com')
input = browser.find_element_by_id('kw')
input.send_keys('Python')
input.send_keys(Keys.ENTER)
wait = WebDriverWait(browser, 10)
wait.until(EC.presence_of_element_located((By.ID, 'content_left')))
print(browser.current_url)
print(browser.get_cookies())
print(browser.page_source) 
finally:
browser.close()

运行代码后会自动弹出一个 Chrome 浏览器,浏览器会跳转到百度,然后在搜索框中输入 Python,接着跳转到搜索结果页,如图所示。

此时在控制台的输出结果如下:

https://www.baidu.com/s?ie=utf-8&f=8&rsv_bp=0&rsv_idx=1&tn=baidu&wd=Python&rsv_pq= 
c94d0df9000a72d0&rsv_t=07099xvun1ZmC0bf6eQvygJ43IUTTUOl5FCJVPgwG2YREs70GplJjH2F%2BC
Q&rqlang=cn&rsv_enter=1&rsv_sug3=6&rsv_sug2=0&inputT=87&rsv_sug4=87 
[{'secure': False,
 'value': 'B490B5EBF6F3CD402E515D22BCDA1598', 
 'domain': '.baidu.com', 
 'path': '/',
 'httpOnly': False, 
 'name': 'BDORZ', 
 'expiry': 1491688071.707553}, 
 
 {'secure': False, 
 'value': '22473_1441_21084_17001', 
 'domain': '.baidu.com', 
 'path': '/',
 'httpOnly': False, 
 'name': 'H_PS_PSSID'}, 

 {'secure': False, 
 'value': '12883875381399993259_00_0_I_R_2_0303_C02F_N_I_I_0', 
 'domain': '.www.baidu.com',
 'path': '/', 
 'httpOnly': False, 
 'name': '__bsi', 
 'expiry': 1491601676.69722}]
<!DOCTYPE html>
<!--STATUS OK-->...
</html>

源代码过长,在此省略。可以看到,当前我们得到的 URL、Cookies 和源代码都是浏览器中的真实内容。

所以说,如果用 Selenium 来驱动浏览器加载网页的话,就可以直接拿到 JavaScript 渲染的结果了,不用担心使用的是什么加密系统。

下面来详细了解一下 Selenium 的用法。

声明浏览器对象

Selenium 支持非常多的浏览器,如 Chrome、Firefox、Edge 等,还有 Android、BlackBerry 等手机端的浏览器。

此外,我们可以用如下方式进行初始化:

from selenium import webdriver
browser = webdriver.Chrome() 
browser = webdriver.Firefox() 
browser = webdriver.Edge() 
browser = webdriver.Safari()

这样就完成了浏览器对象的初始化并将其赋值为 browser 对象。接下来,我们要做的就是调用 browser 对象,让其执行各个动作以模拟浏览器操作。

访问页面

我们可以用 get 方法来请求网页,只需要把参数传入链接 URL 即可。比如,这里用 get 方法访问淘宝,然后打印出源代码,代码如下:

from selenium import webdriver
browser = webdriver.Chrome() 
browser.get('https://www.taobao.com') 
print(browser.page_source) 
browser.close()

运行后会弹出 Chrome 浏览器并且自动访问淘宝,然后控制台会输出淘宝页面的源代码,随后浏览器关闭。

通过这几行简单的代码,我们就可以驱动浏览器并获取网页源码,非常便捷。

查找节点

Selenium 可以驱动浏览器完成各种操作,比如填充表单、模拟点击等。举个例子,当我们想要完成向某个输入框输入文字的操作时,首先需要知道这个输入框在哪,而 Selenium 提供了一系列查找节点的方法,我们可以用这些方法来获取想要的节点,以便执行下一步动作或者提取信息。

单个节点

当我们想要从淘宝页面中提取搜索框这个节点,首先要观察它的源代码,如图所示。

可以发现,它的 id 是 q,name 也是 q,此外还有许多其他属性。此时我们就可以用多种方式获取它了。比如,find_element_by_name 代表根据 name 值获取,find_element_by_id 则是根据 id 获取,另外,还有根据 XPath、CSS 选择器等获取的方式。

我们用代码实现一下:

from selenium import webdriver
browser = webdriver.Chrome() 
browser.get('https://www.taobao.com') 
input_first = browser.find_element_by_id('q') 
input_second = browser.find_element_by_css_selector('#q') 
input_third = browser.find_element_by_xpath('//*[@id="q"]') 
print(input_first, input_second, input_third) 
browser.close()

这里我们使用 3 种方式获取输入框,分别是根据 id、CSS 选择器和 XPath 获取,它们返回的结果完全一致。运行结果如下:

<selenium.webdriver.remote.webelement.WebElement (session="5e53d9e1c8646e44c14c1c2880d424af",
 element="0.5649563096161541-1")>
 
 <selenium.webdriver.remote.webelement.WebElement (session
 ="5e53d9e1c8646e44c14c1c2880d424af", 
 element="0.5649563096161541-1")>
 
 <selenium.webdriver.
 remote.webelement.WebElement (session="5e53d9e1c8646e44c14c1c2880d424af", 
 element="0.5649563096161541-1")>

可以看到,这 3 个节点的类型是一致的,都是 WebElement。

这里列出所有获取单个节点的方法:

find_element_by_id 
find_element_by_name 
find_element_by_xpath 
find_element_by_link_text 
find_element_by_partial_link_text 
find_element_by_tag_name 
find_element_by_class_name 
find_element_by_css_selector

另外,Selenium 还提供了 find_element 这个通用方法,它需要传入两个参数:查找方式 By 和值。实际上,find_element 就是 find_element_by_id 这种方法的通用函数版本,比如 find_element_by_id(id) 就等价于 find_element(By.ID, id),二者得到的结果完全一致。我们用代码实现一下:

from selenium import webdriver 
from selenium.webdriver.common.by import By
browser = webdriver.Chrome() 
browser.get('https://www.taobao.com') 
input_first = browser.find_element(By.ID, 'q') 
print(input_first) 
browser.close()

这种查找方式的功能和上面列举的查找函数完全一致,不过参数更加灵活。

多个节点

如果在网页中只查找一个目标,那么完全可以用 find_element 方法。但如果有多个节点需要查找,再用 find_element 方法,就只能得到第 1 个节点了。如果要查找所有满足条件的节点,需要用 find_elements 这样的方法。注意,在这个方法的名称中,element 多了一个 s,注意区分。

举个例子,假如你要查找淘宝左侧导航条的所有条目,就可以这样来实现:

from selenium import webdriver 
browser = webdriver.Chrome() 
browser.get('https://www.taobao.com') 
lis = browser.find_elements_by_css_selector('.service-bd li') 
print(lis) 
browser.close()

运行结果如下:

[<selenium.webdriver.remote.webelement.WebElement 
(session="c26290835d4457ebf7d96bfab3740d19", element="0.09221044033125603-1")>,
 
<selenium.webdriver.remote.webelement.WebElement 
(session="c26290835d4457ebf7d96bfab3740d19", element="0.09221044033125603-2")>,

<selenium.webdriver.remote.webelement.WebElement 
(session="c26290835d4457ebf7d96bfab3740d19", element="0.09221044033125603-3")>...

<selenium.webdriver.remote.webelement.WebElement 
(session="c26290835d4457ebf7d96bfab3740d19", element="0.09221044033125603-16")>]

这里简化了输出结果,中间部分省略。

可以看到,得到的内容变成了列表类型,列表中的每个节点都是 WebElement 类型。

也就是说,如果我们用 find_element 方法,只能获取匹配的第一个节点,结果是 WebElement 类型。如果用 find_elements 方法,则结果是列表类型,列表中的每个节点是 WebElement 类型。

这里列出所有获取多个节点的方法:

find_elements_by_id 
find_elements_by_name 
find_elements_by_xpath 
find_elements_by_link_text 
find_elements_by_partial_link_text 
find_elements_by_tag_name 
find_elements_by_class_name 
find_elements_by_css_selector

当然,我们也可以直接用 find_elements 方法来选择,这时可以这样写:

lis = browser.find_elements(By.CSS_SELECTOR, '.service-bd li')

结果是完全一致的。

节点交互

Selenium 可以驱动浏览器来执行一些操作,或者说可以让浏览器模拟执行一些动作。比较常见的用法有:输入文字时用 send_keys 方法,清空文字时用 clear 方法,点击按钮时用 click 方法。示例如下:

from selenium import webdriver 
import time 
browser = webdriver.Chrome() 
browser.get('https://www.taobao.com') 
input = browser.find_element_by_id('q') 
input.send_keys('iPhone') 
time.sleep(1) 
input.clear() 
input.send_keys('iPad') 
button = browser.find_element_by_class_name('btn-search') 
button.click()

这里首先驱动浏览器打开淘宝,用 find_element_by_id 方法获取输入框,然后用 send_keys 方法输入 iPhone 文字,等待一秒后用 clear 方法清空输入框,接着再次调用 send_keys 方法输入 iPad 文字,之后再用 find_element_by_class_name 方法获取搜索按钮,最后调用 click 方法完成搜索动作。

通过上面的方法,我们就完成了一些常见节点的动作操作,更多的操作可以参见官方文档的交互动作介绍 :http://selenium-python.readthedocs.io/api.html#module-selenium.webdriver.remote.webelement

动作链

在上面的实例中,一些交互动作都是针对某个节点执行的。比如,对于输入框,我们调用它的输入文字和清空文字方法;对于按钮,我们调用它的点击方法。其实,还有另外一些操作,它们没有特定的执行对象,比如鼠标拖拽、键盘按键等,这些动作用另一种方式来执行,那就是动作链。

比如,现在我要实现一个节点的拖拽操作,将某个节点从一处拖拽到另外一处,可以这样实现:

from selenium import webdriver 
from selenium.webdriver import ActionChains 
browser = webdriver.Chrome() 
url = 'http://www.runoob.com/try/try.php?filename=jqueryui-api-droppable' 
browser.get(url) 
browser.switch_to.frame('iframeResult') 
source = browser.find_element_by_css_selector('#draggable') 
target = browser.find_element_by_css_selector('#droppable') 
actions = ActionChains(browser) 
actions.drag_and_drop(source, target) 
actions.perform()

首先,打开网页中的一个拖拽实例,依次选中要拖拽的节点和拖拽到的目标节点,接着声明 ActionChains 对象并将其赋值为 actions 变量,然后通过调用 actions 变量的 drag_and_drop 方法,再调用 perform 方法执行动作,此时就完成了拖拽操作,如图所示:

拖拽前页面

拖拽后页面

以上两图分别为在拖拽前和拖拽后的结果。

更多的动作链操作可以参考官方文档的动作链介绍:http://selenium-python.readthedocs.io/api.html#module-selenium.webdriver.common.action_chains

执行 JavaScript

Selenium API 并没有提供实现某些操作的方法,比如,下拉进度条。但它可以直接模拟运行 JavaScript,此时使用 execute_script 方法即可实现,代码如下:

from selenium import webdriver 
browser = webdriver.Chrome() 
browser.get('https://www.zhihu.com/explore') 
browser.execute_script('window.scrollTo(0, document.body.scrollHeight)') 
browser.execute_script('alert("To Bottom")')

这里利用 execute_script 方法将进度条下拉到最底部,然后弹出 alert 提示框。

有了这个方法,基本上 API 没有提供的所有功能都可以用执行 JavaScript 的方式来实现了。

获取节点信息

前面说过,通过 page_source 属性可以获取网页的源代码,接着就可以使用解析库(如正则表达式、Beautiful Soup、pyquery 等)来提取信息了。

不过,既然 Selenium 已经提供了选择节点的方法,并且返回的是 WebElement 类型,那么它也有相关的方法和属性来直接提取节点信息,如属性、文本等。这样的话,我们就可以不用通过解析源代码来提取信息了,非常方便。

接下来,我们就来看看可以通过怎样的方式来获取节点信息吧。

获取属性

我们可以使用 get_attribute 方法来获取节点的属性,但是前提是得先选中这个节点,示例如下:

from selenium import webdriver 
browser = webdriver.Chrome() 
url = 'https://dynamic2.scrape.center/' 
browser.get(url) 
logo = browser.find_element_by_class_name('logo-image')
print(logo) 
print(logo.get_attribute('src'))

运行之后,程序便会驱动浏览器打开该页面,然后获取 class 为 logo-image 的节点,最后打印出它的 src 属性。

控制台的输出结果如下:

<selenium.webdriver.remote.webelement.WebElement 
(session="7f4745d35a104759239b53f68a6f27d0", 
element="cd7c72b4-4920-47ed-91c5-ea06601dc509")> 
https://dynamic2.scrape.center/img/logo.a508a8f0.png

通过 get_attribute 方法,我们只需要传入想要获取的属性名,就可以得到它的值了。

获取文本值

每个 WebElement 节点都有 text 属性,直接调用这个属性就可以得到节点内部的文本信息,这相当于 pyquery 的 text 方法,示例如下:

from selenium import webdriver 
browser = webdriver.Chrome()
url = 'https://dynamic2.scrape.center/' 
browser.get(url)
input = browser.find_element_by_class_name('logo-title') 
print(input.text)

这里依然先打开页面,然后获取 class 为 logo-title 这个节点,再将其文本值打印出来。

控制台的输出结果如下:

Scrape

获取 ID、位置、标签名、大小

另外,WebElement 节点还有一些其他属性,比如 id 属性可以获取节点 id,location 属性可以获取该节点在页面中的相对位置,tag_name 属性可以获取标签名称,size 属性可以获取节点的大小,也就是宽高,这些属性有时候还是很有用的。示例如下:

from selenium import webdriver 
browser = webdriver.Chrome() 
url = 'https://dynamic2.scrape.center/' 
browser.get(url) 
input = browser.find_element_by_class_name('logo-title') 
print(input.id) 
print(input.location) 
print(input.tag_name) 
print(input.size)

这里首先获得 class 为 logo-title 这个节点,然后调用其 id、location、tag_name、size 属性来获取对应的属性值。

切换 Frame

我们知道网页中有一种节点叫作 iframe,也就是子 Frame,相当于页面的子页面,它的结构和外部网页的结构完全一致。Selenium 打开页面后,默认是在父级 Frame 里面操作,而此时如果页面中还有子 Frame,Selenium 是不能获取到子 Frame 里面的节点的。这时就需要使用 switch_to.frame 方法来切换 Frame。示例如下:

import time 
from selenium import webdriver 
from selenium.common.exceptions import NoSuchElementException 
browser = webdriver.Chrome()
url = 'http://www.runoob.com/try/try.php?filename=jqueryui-api-droppable' 
browser.get(url) 
browser.switch_to.frame('iframeResult')
try:
    logo = browser.find_element_by_class_name('logo'except NoSuchElementException:
    print('NO LOGO') 
browser.switch_to.parent_frame() 
logo = browser.find_element_by_class_name('logo')
print(logo) 
print(logo.text)

控制台输出:

NO LOGO 
<selenium.webdriver.remote.webelement.WebElement
(session="4bb8ac03ced4ecbdefef03ffdc0e4ccd", 
element="0.13792611320464965-2")> 
RUNOOB.COM

这里还是以前面演示动作链操作的网页为例,首先通过 switch_to.frame 方法切换到子 Frame 里面,然后尝试获取子 Frame 里的 logo 节点(这是不能找到的),如果找不到的话,就会抛出 NoSuchElementException 异常,异常被捕捉之后,就会输出 NO LOGO。接下来,我们需要重新切换回父级 Frame,然后再次重新获取节点,发现此时可以成功获取了。

所以,当页面中包含子 Frame 时,如果想获取子 Frame 中的节点,需要先调用 switch_to.frame 方法切换到对应的 Frame,然后再进行操作。

延时等待

在 Selenium 中,get 方法会在网页框架加载结束后结束执行,此时如果获取 page_source,可能并不是浏览器完全加载完成的页面,如果某些页面有额外的 Ajax 请求,我们在网页源代码中也不一定能成功获取到。所以,这里需要延时等待一定时间,确保节点已经加载出来。

这里等待的方式有两种:一种是隐式等待,一种是显式等待。

隐式等待

当使用隐式等待执行测试的时候,如果 Selenium 没有在 DOM 中找到节点,将继续等待,超出设定时间后,则抛出找不到节点的异常。换句话说,隐式等待可以在我们查找节点而节点并没有立即出现的时候,等待一段时间再查找 DOM,默认的时间是 0。示例如下:

from selenium import webdriver 
browser = webdriver.Chrome() 
browser.implicitly_wait(10) 
browser.get('https://dynamic2.scrape.center/') 
input = browser.find_element_by_class_name('logo-image') 
print(input)

在这里我们用 implicitly_wait 方法实现了隐式等待。

显式等待

隐式等待的效果其实并没有那么好,因为我们只规定了一个固定时间,而页面的加载时间会受到网络条件的影响。

这里还有一种更合适的显式等待方法,它指定要查找的节点,然后指定一个最长等待时间。如果在规定时间内加载出来了这个节点,就返回查找的节点;如果到了规定时间依然没有加载出该节点,则抛出超时异常。示例如下:

from selenium import webdriver 
from selenium.webdriver.common.by import By 
from selenium.webdriver.support.ui import WebDriverWait 
from selenium.webdriver.support import expected_conditions as EC 
browser = webdriver.Chrome() 
browser.get('https://www.taobao.com/') 
wait = WebDriverWait(browser, 10) 
input = wait.until(EC.presence_of_element_located((By.ID, 'q'))) 
button =  wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, '.btn-search'))) 
print(input, button)

这里首先引入 WebDriverWait 这个对象,指定最长等待时间,然后调用它的 until() 方法,传入要等待条件 expected_conditions。比如,这里传入了 presence_of_element_located 这个条件,代表节点出现,其参数是节点的定位元组,也就是 ID 为 q 的节点搜索框。

这样做的效果就是,在 10 秒内如果 ID 为 q 的节点(即搜索框)成功加载出来,就返回该节点;如果超过 10 秒还没有加载出来,就抛出异常。

对于按钮,我们可以更改一下等待条件,比如改为 element_to_be_clickable,也就是可点击,所以查找按钮时先查找 CSS 选择器为.btn-search 的按钮,如果 10 秒内它是可点击的,也就代表它成功加载出来了,就会返回这个按钮节点;如果超过 10 秒还不可点击,也就是没有加载出来,就抛出异常。

现在我们运行代码,它在网速较佳的情况下是可以成功加载出来的。

控制台的输出如下:

<selenium.webdriver.remote.webelement.WebElement 
(session="07dd2fbc2d5b1ce40e82b9754aba8fa8", 
element="0.5642646294074107-1")>
<selenium.webdriver.remote.webelement.WebElement 
(session="07dd2fbc2d5b1ce40e82b9754aba8fa8", 
element="0.5642646294074107-2")>

可以看到,控制台成功输出了两个节点,它们都是 WebElement 类型。

如果网络有问题,10 秒内没有成功加载,那就抛出 TimeoutException 异常,此时控制台的输出如下:

TimeoutException Traceback (most recent call last) 
<ipython-input-4-f3d73973b223> in <module>()
      7 browser.get('https://www.taobao.com/')
      8 wait = WebDriverWait(browser, 10) 
----> 9 input = wait.until(EC.presence_of_element_located((By.ID, 'q')))

关于等待条件,其实还有很多,比如判断标题内容,判断某个节点内是否出现了某文字等。下表我列出了所有的等待条件。

更多详细的等待条件的参数及用法介绍可以参考官方文档:http://selenium-python.readthedocs.io/api.html#module-selenium.webdriver.support.expected_conditions

前进后退

平常我们使用浏览器时都有前进和后退功能,Selenium 也可以完成这个操作,它使用 back 方法后退,使用 forward 方法前进。示例如下:

import time 
from selenium import webdriver 
browser = webdriver.Chrome() 
browser.get('https://www.baidu.com/') 
browser.get('https://www.taobao.com/') 
browser.get('https://www.python.org/') 
browser.back() 
time.sleep(1) 
browser.forward() 
browser.close()

这里我们连续访问 3 个页面,然后调用 back  方法回到第 2 个页面,接下来再调用 forward 方法又可以前进到第 3 个页面。

Cookies

使用 Selenium,还可以方便地对 Cookies 进行操作,例如获取、添加、删除 Cookies 等。示例如下:

from selenium import webdriver 
browser = webdriver.Chrome() 
browser.get('https://www.zhihu.com/explore') 
print(browser.get_cookies()) 
browser.add_cookie({'name''name''domain''www.zhihu.com''value''germey'}) 
print(browser.get_cookies()) 
browser.delete_all_cookies() 
print(browser.get_cookies())

首先,我们访问知乎,加载完成后,浏览器实际上已经生成 Cookies 了。接着,调用 get_cookies 方法获取所有的 Cookies。然后,我们再添加一个 Cookie,这里传入一个字典,有 name、domain 和 value 等内容。接下来,再次获取所有的 Cookies,可以发现,结果会多出这一项新加的 Cookie。最后,调用 delete_all_cookies 方法删除所有的 Cookies。再重新获取,发现结果就为空了。

控制台的输出如下:

[{'secure'False'value''"NGM0ZTM5NDAwMWEyNDQwNDk5ODlkZWY3OTkxY2I0NDY=|1491604091|236e34290a6f407bfbb517888849ea509ac366d0"''domain''.zhihu.com',
'path''/''httpOnly'False'name''l_cap_id''expiry'1494196091.403418},...] 
[{'secure'False'value''germey''domain''.www.zhihu.com''path''/''httpOnly'False'name''name'}, 
{'secure'False'value''"NGM0ZTM5NDAwMWEyNDQwNDk5ODlkZWY3OTkxY2I0NDY=|1491604091|236e34290a6f407bfbb517888849ea509ac366d0"''domain''.zhihu.com''path':'/''httpOnly'False'name''l_cap_id''expiry'1494196091.403418}, ...] 
[]

通过以上方法来操作 Cookies 还是非常方便的。

选项卡管理

在访问网页的时候,我们通常会开启多个选项卡。在 Selenium 中,我们也可以对选项卡进行操作。示例如下:

import time 
from selenium import webdriver 
browser = webdriver.Chrome() 
browser.get('https://www.baidu.com') 
browser.execute_script('window.open()') 
print(browser.window_handles) 
browser.switch_to.window(browser.window_handles[1])
browser.get('https://www.taobao.com') 
time.sleep(1) 
browser.switch_to.window(browser.window_handles[0]) 
browser.get('https://python.org'

控制台输出如下:

['CDwindow-4f58e3a7-7167-4587-bedf-9cd8c867f435''CDwindow-6e05f076-6d77-453a-a36c-32baacc447df']

首先访问百度,然后调用 execute_script 方法,这里我们传入 window.open 这个 JavaScript 语句新开启一个选项卡,然后切换到该选项卡,调用 window_handles 属性获取当前开启的所有选项卡,后面的参数代表返回选项卡的代号列表。要想切换选项卡,只需要调用 switch_to.window 方法即可,其中的参数是选项卡的代号。这里我们将第 2 个选项卡代号传入,即跳转到第 2 个选项卡,接下来在第 2 个选项卡下打开一个新页面,如果你想要切换回第 2 个选项卡,只需要重新调用 switch_to.window 方法,再执行其他操作即可。

异常处理

在使用 Selenium 的过程中,难免会遇到一些异常,例如超时、节点未找到等错误,一旦出现此类错误,程序便不会继续运行了。这里我们可以使用 try except 语句来捕获各种异常。

首先,演示一下节点未找到的异常,示例如下:

from selenium import webdriver 
browser = webdriver.Chrome() 
browser.get('https://www.baidu.com') 
browser.find_element_by_id('hello')

这里我们首先打开百度页面,然后尝试选择一个并不存在的节点,此时就会遇到异常。

运行之后控制台的输出如下:

NoSuchElementException Traceback (most recent call last) 
<ipython-input-23-978945848a1b> in <module>()
     3 browser = webdriver.Chrome()
     4 browser.get ('https://www.baidu.com')
----> 5 browser.find_element_by_id('hello')

可以看到,这里抛出了 NoSuchElementException 异常,通常代表节点未找到。为了防止程序遇到异常而中断,我们需要捕获这些异常,示例如下:

from selenium import webdriver 
from selenium.common.exceptions import TimeoutException, 
NoSuchElementException 
browser = webdriver.Chrome()
try:
    browser.get('https://www.baidu.com'except TimeoutException:
    print('Time Out'try:
    browser.find_element_by_id('hello'except NoSuchElementException:
    print('No Element'finally:
    browser.close()

这里我们使用 try except 来捕获各类异常。比如,我们用 find_element_by_id 查找节点的方法捕获 NoSuchElementException 异常,这样一旦出现这样的错误,就进行异常处理,程序也不会中断了。

控制台的输出如下:

No Element

关于更多的异常类,可以参考官方文档::http://selenium-python.readthedocs.io/api.html#module-selenium.common.exceptions

反屏蔽

现在很多网站都加上了对 Selenium 的检测,来防止一些爬虫的恶意爬取。即如果检测到有人在使用 Selenium 打开浏览器,那就直接屏蔽。

其大多数情况下,检测基本原理是检测当前浏览器窗口下的 window.navigator 对象是否包含 webdriver 这个属性。因为在正常使用浏览器的情况下,这个属性是 undefined,然而一旦我们使用了 Selenium,Selenium 会给 window.navigator 设置 webdriver 属性。很多网站就通过 JavaScript 判断如果 webdriver 属性存在,那就直接屏蔽。

这边有一个典型的案例网站:https://antispider1.scrape.center/,这个网站就是使用了上述原理实现了 WebDriver 的检测,如果使用 Selenium 直接爬取的话,那就会返回如下页面:

这时候我们可能想到直接使用 JavaScript 直接把这个 webdriver 属性置空,比如通过调用 execute_script 方法来执行如下代码:

Object.defineProperty(navigator, "webdriver", {get: () => undefined})

这行 JavaScript 的确是可以把 webdriver 属性置空,但是 execute_script 调用这行 JavaScript 语句实际上是在页面加载完毕之后才执行的,执行太晚了,网站早在最初页面渲染之前就已经对 webdriver 属性进行了检测,所以用上述方法并不能达到效果。

在 Selenium 中,我们可以使用 CDP(即 Chrome Devtools-Protocol,Chrome 开发工具协议)来解决这个问题,通过 CDP 我们可以实现在每个页面刚加载的时候执行 JavaScript 代码,执行的 CDP 方法叫作 Page.addScriptToEvaluateOnNewDocument,然后传入上文的 JavaScript 代码即可,这样我们就可以在每次页面加载之前将 webdriver 属性置空了。另外我们还可以加入几个选项来隐藏 WebDriver 提示条和自动化扩展信息,代码实现如下:

from selenium import webdriver
from selenium.webdriver import ChromeOptions

option = ChromeOptions()
option.add_experimental_option('excludeSwitches', ['enable-automation'])
option.add_experimental_option('useAutomationExtension'False)
browser = webdriver.Chrome(options=option)
browser.execute_cdp_cmd('Page.addScriptToEvaluateOnNewDocument', {
   'source''Object.defineProperty(navigator, "webdriver", {get: () => undefined})'
})
browser.get('https://antispider1.scrape.center/')

这样整个页面就能被加载出来了:

对于大多数的情况,以上的方法均可以实现 Selenium 反屏蔽。但对于一些特殊的网站,如果其有更多的 WebDriver 特征检测,可能需要具体排查。

无头模式

上面的案例在运行的时候,我们可以观察到其总会弹出一个浏览器窗口,虽然有助于观察页面爬取状况,但在有些时候窗口弹来弹去也会形成一些干扰。

Chrome 浏览器从 60 版本已经支持了无头模式,即 Headless。无头模式在运行的时候不会再弹出浏览器窗口,减少了干扰,而且它减少了一些资源的加载,如图片等资源,所以也在一定程度上节省了资源加载时间和网络带宽。

我们可以借助于 ChromeOptions 来开启 Chrome Headless 模式,代码实现如下:

from selenium import webdriver
from selenium.webdriver import ChromeOptions

option = ChromeOptions()
option.add_argument('--headless')
browser = webdriver.Chrome(options=option)
browser.set_window_size(1366768)
browser.get('https://www.baidu.com')
browser.get_screenshot_as_file('preview.png')

这里我们通过 ChromeOptions 的 add_argument 方法添加了一个参数 --headless,开启了无头模式。在无头模式下,我们最好需要设置下窗口的大小,接着打开页面,最后我们调用 get_screenshot_as_file 方法输出了页面的截图。

运行代码之后,我们发现 Chrome 窗口就不会再弹出来了,代码依然正常运行,最后输出了页面截图如图所示。

这样我们就在无头模式下完成了页面的抓取和截图操作。

现在,我们基本对 Selenium 的常规用法有了大体的了解。使用 Selenium,处理 JavaScript 渲染的页面不再是难事。

Selenium爬取实战

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

准备工作

在本课时开始之前,请确保已经做好了如下准备工作:

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

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

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

适用场景

在前面的实战案例中,有的网页我们可以直接用 requests 来爬取,有的可以直接通过分析 Ajax 来爬取,不同的网站类型有其适用的爬取方法。

Selenium 同样也有其适用场景。对于那些带有 JavaScript 渲染的网页,我们多数情况下是无法直接用 requests 爬取网页源码的,不过在有些情况下我们可以直接用 requests 来模拟 Ajax 请求来直接得到数据。

然而在有些情况下 Ajax 的一些请求接口可能带有一些加密参数,如 token、sign 等等,如果不分析清楚这些参数是怎么生成的话,我们就难以模拟和构造这些参数。怎么办呢?这时候我们可以直接选择使用 Selenium 驱动浏览器渲染的方式来另辟蹊径,实现所见即所得的爬取,这样我们就无需关心在这个网页背后发生了什么请求、得到什么数据以及怎么渲染页面这些过程,我们看到的页面就是最终浏览器帮我们模拟了 Ajax 请求和 JavaScript 渲染得到的最终结果,而 Selenium 正好也能拿到这个最终结果,相当于绕过了 Ajax 请求分析和模拟的阶段,直达目标。

然而 Selenium 当然也有其局限性,它的爬取效率较低,有些爬取需要模拟浏览器的操作,实现相对烦琐。不过在某些场景下也不失为一种有效的爬取手段。

爬取目标

本课时我们就拿一个适用 Selenium 的站点来做案例,其链接为:https://dynamic2.scrape.center/,还是和之前一样的电影网站,页面如图所示。

初看之下页面和之前也没有什么区别,但仔细观察可以发现其 Ajax 请求接口和每部电影的 URL 都包含了加密参数。

比如我们点击任意一部电影,观察一下 URL 的变化,如图所示。

这里我们可以看到详情页的 URL 和之前就不一样了,在之前的案例中,URL 的 detail 后面本来直接跟的是 id,如 1、2、3 等数字,但是这里直接变成了一个长字符串,看似是一个 Base64 编码的内容,所以这里我们无法直接根据规律构造详情页的 URL 了。

好,那么接下来我们直接看看 Ajax 的请求,我们从列表页的第 1 页到第 10 页依次点一下,观察一下 Ajax 请求是怎样的,如图所示。

可以看到这里接口的参数比之前多了一个 token,而且每次请求的 token 都是不同的,这个 token 同样看似是一个 Base64 编码的字符串。更困难的是,这个接口还是有时效性的,如果我们把 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://dynamic2.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 还是有一定规律的,比如第一页为 https://dynamic2.scrape.center/page/1,页码就是 URL 最后的数字,所以这里我们可以直接来构造每一页的 URL。

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

所以这里我们直接使用 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 的爬取和状态监听以及异常处理,它接收 url、condition、locator 三个参数,其中 url 参数就是要爬取的页面 URL;condition 就是页面加载的判定条件,它可以是 expected_conditions 的其中某一项判定条件,如 visibility_of_all_elements_located、visibility_of_element_located 等等;locator 代表定位器,是一个元组,它可以通过配置查询条件和参数来获取一个或多个节点,如 (By.CSS_SELECTOR, '#index .item') 则代表通过 CSS 选择器查找 #index .item 来获取列表页所有电影信息节点。另外爬取的过程添加了 TimeoutException 检测,如果在规定时间(这里为 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://dynamic2.scrape.center/page/1
2020-03-29 12:03:13,724 - INFO: details urls ['https://dynamic2.scrape.center/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx',
...
'https://dynamic2.scrape.center/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWI5''https://dynamic2.scrape.center/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIxMA==']
2020-03-29 12:03:13,724 - INFO: scraping https://dynamic2.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 这个节点,也就是电影的名称对应的节点,如图所示。

如果执行了 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://dynamic2.scrape.center/page/1
2020-03-29 12:24:16,997 - INFO: get detail url https://dynamic2.scrape.center/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx
2020-03-29 12:24:16,997 - INFO: scraping https://dynamic2.scrape.center/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx
2020-03-29 12:24:19,289 - INFO: detail data {'url''https://dynamic2.scrape.center/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx''name''霸王别姬 - Farewell My Concubine''categories': ['剧情''爱情'], 'cover''https://p0.meituan.net/movie/ce4da3e03e655b5b88ed31b5cd7896cf62472.jpg@464w_644h_1e_1c''score''9.5''drama''影片借一出《霸王别姬》的京戏,牵扯出三个人之间一段随时代风云变幻的爱恨情仇。段小楼(张丰毅 饰)与程蝶衣(张国荣 饰)是一对打小一起长大的师兄弟,两人一个演生,一个饰旦,一向配合天衣无缝,尤其一出《霸王别姬》,更是誉满京城,为此,两人约定合演一辈子《霸王别姬》。但两人对戏剧与人生关系的理解有本质不同,段小楼深知戏非人生,程蝶衣则是人戏不分。段小楼在认为该成家立业之时迎娶了名妓菊仙(巩俐 饰),致使程蝶衣认定菊仙是可耻的第三者,使段小楼做了叛徒,自此,三人围绕一出《霸王别姬》生出的爱恨情仇战开始随着时代风云的变迁不断升级,终酿成悲剧。'}
2020-03-29 12:24:19,291 - INFO: get detail url https://dynamic2.scrape.center/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIy
2020-03-29 12:24:19,291 - INFO: scraping https://dynamic2.scrape.center/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIy
2020-03-29 12:24:21,524 - INFO: detail data {'url''https://dynamic2.scrape.center/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIy''name''这个杀手不太冷 - Léon''categories': ['剧情''动作''犯罪'], 'cover''https://p1.meituan.net/movie/6bea9af4524dfbd0b668eaa7e187c3df767253.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)

这里原理和实现方式与 Ajax 爬取实战课时是完全相同的,不再赘述。

最后添加上 save_data 的调用,完整看下运行效果。

Headless

如果觉得爬取过程中弹出浏览器有所干扰,我们可以开启 Chrome 的 Headless 模式,这样爬取过程中便不会再弹出浏览器了,同时爬取速度还有进一步的提升。

只需要做如下修改即可:

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

这里通过 ChromeOptions 添加了 --headless 参数,然后用 ChromeOptions 来进行 Chrome 的初始化即可。

修改后再重新运行代码,Chrome 浏览器就不会弹出来了,爬取结果是完全一样的。

总结

本课时我们通过一个案例了解了 Selenium 的适用场景,并结合案例使用 Selenium 实现了页面的爬取,从而对 Selenium 的使用有进一步的掌握。