仅用于学习

开篇词:如何高效学好网络爬虫

你好,我是你的爬虫老师崔庆才,欢迎来到我的专栏《52 讲轻松搞定网络爬虫》。最初接触和学习爬虫是兴趣使然,它看似简单却趣味无穷,入门容易但真的想做好也是需要下一番功夫的。

从 2015 年开始在博客上不断记录和分享自己的爬虫心得,受到读者挺多好评,至今博客阅读量已经过千万。2018 年我把爬虫知识结构化后出版了《Python3  网络爬虫开发实战》一书。这本书年销量 6w+ 册,是很多爬虫爱好者的启蒙教材,目前豆瓣评分是 9.0 分,京东上好评率也达到 99%。

但近期我看到了一些“负面评论”,主要集中在“由于反爬机制不断完善,好多案例已失效”。特地去看了下,我发现虽然网上有很多爬虫学习资料,但包括一些优质资料在内无一例外都存在这个问题。所以,我决定和拉勾教育联合推出一个“与时俱进”的爬虫实战专栏,希望可以帮到你。

为什么案例会失效?

随着大数据乃至人工智能的迅猛发展,数据变得越来越重要,甚至已成为很多企业赖以生存的根基。而想要获取数据,爬虫是必备工具之一。


前几年刮起的“全民学 Python”风,也促进了爬虫技术蓬勃发展,因为几乎所有 Python 课的实操案例都是“手把手教你写爬虫”。但发展的不止有爬虫技术,还有反爬技术和企业对数据保护的重视程度。你会发现之前学的爬虫案例过一段时间就失效了。


企业为了保护自己的数据不被轻易地爬取,采取了非常多的反爬虫措施,如 JavaScript 混淆和加密、App 加密、增强型验证码、封锁 IP、封锁账号等,甚至有不少企业有专门的更难破解的反爬措施。

为什么企业要求越来越高了?

数据爬取难度持续增大,也不完全是坏事,这让企业对爬虫工程师的需求量在逐步增多,薪资待遇也提升了不少。当然,技术要求也越来越高,例如 JavaScript、App 的逆向等几乎已经是爬虫工程师必备的技能,如果不懂,很多网站的数据是难以有效爬取的。另外,爬虫涉及的面很广,对计算机网络、编程基础、前端开发、后端开发、App 开发与逆向、网络安全、数据库、运维、机器学习、数据分析等方向也有一定的要求。


下面就几个爬虫目前所遇到的痛点来说一下。

这个课讲什么?怎么讲?

学习爬虫常有几大瓶颈:

我会通过这个专栏带你一一突破,整个课程分为 7 大模块,从爬虫基础原理讲起:

这个课适合你听吗?

在学习本门课程之前,最好是对 Python 有一定的基础了解,包括 Python 基本的语法和调用逻辑等。之所以没花篇幅去讲 Python,一个是很多人有 Python 基础,另一个是没有任何基础问题也不大,本课程会结合很多示例代码详细讲解,大家照着编写和学习,也能轻松理解,甚至成为爬虫大牛。


这门课是专门写给爬虫工程师的吗?当然不是,它适合所有有数据收集和获取需求的人。


虽然爬虫涉及的知识点比较多,但经过我的系统梳理讲解和你的多加练习,相信你会对爬虫技术有全面透彻的理解,能应对绝大多数网站的爬取,加油!期待和你在爬虫的世界里一起进步,下节课我们开始学习爬虫基础原理,不见不散!

第01讲:必知必会,掌握 HTTP 基本原理

本课时我们会详细讲解 HTTP 的基本原理,以及了解在浏览器中输入 URL 到获取网页内容之间发生了什么。了解了这些内容,有助于我们进一步掌握爬虫的基本原理。

URI 和 URL

首先,我们来了解一下 URI 和 URL,URI 的全称为 Uniform Resource Identifier,即统一资源标志符,URL 的全称为 Universal Resource Locator,即统一资源定位符。

举例来说,https://github.com/favicon.ico,它是一个 URL,也是一个 URI。即有这样的一个图标资源,我们用 URL/URI 来唯一指定了它的访问方式,这其中包括了访问协议 HTTPS、访问路径(即根目录)和资源名称 favicon.ico。通过这样一个链接,我们便可以从互联网上找到这个资源,这就是 URL/URI。

URL 是 URI 的子集,也就是说每个 URL 都是 URI,但不是每个 URI 都是 URL。那么,什么样的 URI 不是 URL 呢?URI 还包括一个子类叫作 URN,它的全称为 Universal Resource Name,即统一资源名称。

URN 只命名资源而不指定如何定位资源,比如 urn:isbn:0451450523 指定了一本书的 ISBN,可以唯一标识这本书,但是没有指定到哪里定位这本书,这就是 URN。URL、URN 和 URI 的关系可以用图表示。

但是在目前的互联网,URN 的使用非常少,几乎所有的 URI 都是 URL,所以一般的网页链接我们可以称之为 URL,也可以称之为 URI,我个人习惯称之为 URL。

超文本

接下来,我们再了解一个概念 —— 超文本,其英文名称叫作 Hypertext,我们在浏览器里看到的网页就是超文本解析而成的,其网页源代码是一系列 HTML 代码,里面包含了一系列标签,比如 img 显示图片,p 指定显示段落等。浏览器解析这些标签后,便形成了我们平常看到的网页,而网页的源代码 HTML 就可以称作超文本。

例如,我们在 Chrome 浏览器里面打开任意一个页面,如淘宝首页,右击任一地方并选择 “检查” 项(或者直接按快捷键 F12),即可打开浏览器的开发者工具,这时在 Elements 选项卡即可看到当前网页的源代码,这些源代码都是超文本,如图所示。

HTTP 和 HTTPS

在淘宝的首页 https://www.taobao.com/中,URL 的开头会有 http 或 https,这个就是访问资源需要的协议类型,有时我们还会看到 ftp、sftp、smb 开头的 URL,那么这里的 ftp、sftp、smb 都是指的协议类型。在爬虫中,我们抓取的页面通常就是 http 或 https 协议的,我们在这里首先来了解一下这两个协议的含义。

HTTP 的全称是 Hyper Text Transfer Protocol,中文名叫作超文本传输协议,HTTP 协议是用于从网络传输超文本数据到本地浏览器的传送协议,它能保证高效而准确地传送超文本文档。HTTP 由万维网协会(World Wide Web Consortium)和 Internet 工作小组 IETF(Internet Engineering Task Force)共同合作制定的规范,目前广泛使用的是 HTTP 1.1 版本。

HTTPS 的全称是 Hyper Text Transfer Protocol over Secure Socket Layer,是以安全为目标的 HTTP 通道,简单讲是 HTTP 的安全版,即 HTTP 下加入 SSL 层,简称为 HTTPS。

HTTPS 的安全基础是 SSL,因此通过它传输的内容都是经过 SSL 加密的,它的主要作用可以分为两种:

现在越来越多的网站和 App 都已经向 HTTPS 方向发展。例如:

因此,HTTPS 已经已经是大势所趋。

HTTP 请求过程

我们在浏览器中输入一个 URL,回车之后便可以在浏览器中观察到页面内容。实际上,这个过程是浏览器向网站所在的服务器发送了一个请求,网站服务器接收到这个请求后进行处理和解析,然后返回对应的响应,接着传回给浏览器。响应里包含了页面的源代码等内容,浏览器再对其进行解析,便将网页呈现了出来,传输模型如图所示。

此处客户端即代表我们自己的 PC 或手机浏览器,服务器即要访问的网站所在的服务器。

为了更直观地说明这个过程,这里用 Chrome 浏览器的开发者模式下的 Network 监听组件来做下演示,它可以显示访问当前请求网页时发生的所有网络请求和响应。

打开 Chrome 浏览器,右击并选择 “检查” 项,即可打开浏览器的开发者工具。这里访问百度 http://www.baidu.com/,输入该 URL 后回车,观察这个过程中发生了怎样的网络请求。可以看到,在 Network 页面下方出现了一个个的条目,其中一个条目就代表一次发送请求和接收响应的过程,如图所示。

我们先观察第一个网络请求,即 www.baidu.com,其中各列的含义如下。

我们点击这个条目即可看到其更详细的信息,如图所示。

首先是 General 部分,Request URL 为请求的 URL,Request Method 为请求的方法,Status Code 为响应状态码,Remote Address 为远程服务器的地址和端口,Referrer Policy 为 Referrer 判别策略。

再继续往下,可以看到,有 Response Headers 和 Request Headers,这分别代表响应头和请求头。请求头里带有许多请求信息,例如浏览器标识、Cookies、Host 等信息,这是请求的一部分,服务器会根据请求头内的信息判断请求是否合法,进而作出对应的响应。图中看到的 Response Headers 就是响应的一部分,例如其中包含了服务器的类型、文档类型、日期等信息,浏览器接受到响应后,会解析响应内容,进而呈现网页内容。

下面我们分别来介绍一下请求和响应都包含哪些内容。

请求

请求,由客户端向服务端发出,可以分为 4 部分内容:请求方法(Request Method)、请求的网址(Request URL)、请求头(Request Headers)、请求体(Request Body)。

请求方法

常见的请求方法有两种:GET 和 POST。

在浏览器中直接输入 URL 并回车,这便发起了一个 GET 请求,请求的参数会直接包含到 URL 里。例如,在百度中搜索 Python,这就是一个 GET 请求,链接为 https://www.baidu.com/s?wd=Python,其中 URL 中包含了请求的参数信息,这里参数 wd 表示要搜寻的关键字。POST 请求大多在表单提交时发起。比如,对于一个登录表单,输入用户名和密码后,点击 “登录” 按钮,这通常会发起一个 POST 请求,其数据通常以表单的形式传输,而不会体现在 URL 中。

GET 和 POST 请求方法有如下区别。

一般来说,登录时,需要提交用户名和密码,其中包含了敏感信息,使用 GET 方式请求的话,密码就会暴露在 URL 里面,造成密码泄露,所以这里最好以 POST 方式发送。上传文件时,由于文件内容比较大,也会选用 POST 方式。

我们平常遇到的绝大部分请求都是 GET 或 POST 请求,另外还有一些请求方法,如 HEAD、PUT、DELETE、OPTIONS、CONNECT、TRACE 等,我们简单将其总结为下表。

请求的网址本表参考:http://www.runoob.com/http/http-methods.html

请求的网址,即统一资源定位符 URL,它可以唯一确定我们想请求的资源。

请求头

请求头,用来说明服务器要使用的附加信息,比较重要的信息有 Cookie、Referer、User-Agent 等。下面简要说明一些常用的头信息。

因此,请求头是请求的重要组成部分,在写爬虫时,大部分情况下都需要设定请求头。

请求体

请求体一般承载的内容是 POST 请求中的表单数据,而对于 GET 请求,请求体则为空。

例如,这里我登录 GitHub 时捕获到的请求和响应如图所示。

登录之前,我们填写了用户名和密码信息,提交时这些内容就会以表单数据的形式提交给服务器,此时需要注意 Request Headers 中指定 Content-Type 为 application/x-www-form-urlencoded。只有设置 Content-Type 为 application/x-www-form-urlencoded,才会以表单数据的形式提交。另外,我们也可以将 Content-Type 设置为 application/json 来提交 JSON 数据,或者设置为 multipart/form-data 来上传文件。

表格中列出了 Content-Type 和 POST 提交数据方式的关系。

在爬虫中,如果要构造 POST 请求,需要使用正确的 Content-Type,并了解各种请求库的各个参数设置时使用的是哪种 Content-Type,不然可能会导致 POST 提交后无法正常响应。

响应

响应,由服务端返回给客户端,可以分为三部分:响应状态码(Response Status Code)、响应头(Response Headers)和响应体(Response Body)。

响应状态码

响应状态码表示服务器的响应状态,如 200 代表服务器正常响应,404 代表页面未找到,500 代表服务器内部发生错误。在爬虫中,我们可以根据状态码来判断服务器响应状态,如状态码为 200,则证明成功返回数据,再进行进一步的处理,否则直接忽略。下表列出了常见的错误代码及错误原因。

响应头包含了服务器对请求的应答信息,如 Content-Type、Server、Set-Cookie 等。下面简要说明一些常用的响应头信息。

响应体

最重要的当属响应体的内容了。响应的正文数据都在响应体中,比如请求网页时,它的响应体就是网页的 HTML 代码;请求一张图片时,它的响应体就是图片的二进制数据。我们做爬虫请求网页后,要解析的内容就是响应体,如图所示。

在浏览器开发者工具中点击 Preview,就可以看到网页的源代码,也就是响应体的内容,它是解析的目标。

在做爬虫时,我们主要通过响应体得到网页的源代码、JSON 数据等,然后从中做相应内容的提取。

好了,今天的内容就全部讲完了,本课时中,我们了解了 HTTP 的基本原理,大概了解了访问网页时背后的请求和响应过程。本课时涉及的知识点需要好好掌握,后面分析网页请求时会经常用到。

第02讲:夯实根基,Web 网页基础

当我们用浏览器访问网站时,页面各不相同,那么你有没有想过它为何会呈现成这个样子呢?本课时,我们就来讲解网页的基本组成、结构和节点等内容。

网页的组成

首先,我们来了解网页的基本组成,网页可以分为三大部分:HTML、CSS 和 JavaScript。

如果把网页比作一个人的话,HTML 相当于骨架,JavaScript 相当于肌肉,CSS 相当于皮肤,三者结合起来才能形成一个完整的网页。下面我们来分别介绍一下这三部分的功能。

HTML

HTML 是用来描述网页的一种语言,其全称叫作 Hyper Text Markup Language,即超文本标记语言。

我们浏览的网页包括文字、按钮、图片和视频等各种复杂的元素,其基础架构就是 HTML。不同类型的元素通过不同类型的标签来表示,如图片用 img 标签表示,视频用 video 标签表示,段落用 p 标签表示,它们之间的布局又常通过布局标签 div 嵌套组合而成,各种标签通过不同的排列和嵌套就可以形成网页的框架。

我们在 Chrome 浏览器中打开百度,右击并选择 “检查” 项(或按 F12 键),打开开发者模式,这时在 Elements 选项卡中即可看到网页的源代码,如图所示。

这就是 HTML,整个网页就是由各种标签嵌套组合而成的。这些标签定义的节点元素相互嵌套和组合形成了复杂的层次关系,就形成了网页的架构。

CSS

虽然 HTML 定义了网页的结构,但是只有 HTML 页面的布局并不美观,可能只是简单的节点元素的排列,为了让网页看起来更好看一些,这里就需要借助 CSS 了。

CSS,全称叫作 Cascading Style Sheets,即层叠样式表。“层叠” 是指当在 HTML 中引用了数个样式文件,并且样式发生冲突时,浏览器能依据层叠顺序处理。“样式” 指网页中文字大小、颜色、元素间距、排列等格式。

CSS 是目前唯一的网页页面排版样式标准,有了它的帮助,页面才会变得更为美观。

图的右侧即为 CSS,例如:

#head_wrapper.s-ps-islite .s-p-top {

			   position: absolute;

			   bottom: 40px;

			   width: 100%;

			   height: 181px;
			

这就是一个 CSS 样式。大括号前面是一个 CSS 选择器。此选择器的作用是首先选中 id 为 head_wrapper 且 class 为 s-ps-islite 的节点,然后再选中其内部的 class 为 s-p-top 的节点。

大括号内部写的就是一条条样式规则,例如 position 指定了这个元素的布局方式为绝对布局,bottom 指定元素的下边距为 40 像素,width 指定了宽度为 100% 占满父元素,height 则指定了元素的高度。

也就是说,我们将位置、宽度、高度等样式配置统一写成这样的形式,然后用大括号括起来,接着在开头再加上 CSS 选择器,这就代表这个样式对 CSS 选择器选中的元素生效,元素就会根据此样式来展示了。

在网页中,一般会统一定义整个网页的样式规则,并写入 CSS 文件中(其后缀为 css)。在 HTML 中,只需要用 link 标签即可引入写好的 CSS 文件,这样整个页面就会变得美观、优雅。

JavaScript

JavaScript,简称 JS,是一种脚本语言。HTML 和 CSS 配合使用,提供给用户的只是一种静态信息,缺乏交互性。我们在网页里可能会看到一些交互和动画效果,如下载进度条、提示框、轮播图等,这通常就是 JavaScript 的功劳。它的出现使得用户与信息之间不只是一种浏览与显示的关系,而是实现了一种实时、动态、交互的页面功能。

JavaScript 通常也是以单独的文件形式加载的,后缀为 js,在 HTML 中通过 script 标签即可引入,例如:

<script src="jquery-2.1.0.js"></script>
			

综上所述,HTML 定义了网页的内容和结构,CSS 描述了网页的布局,JavaScript 定义了网页的行为。

网页的结构

了解了网页的基本组成,我们再用一个例子来感受下 HTML 的基本结构。新建一个文本文件,名称可以自取,后缀为 html,内容如下:

<!DOCTYPE html>
			<html>
			<head>
			<meta charset="UTF-8">
			<title>This is a Demo</title>
			</head>
			<body>
			<div id="container">
			<div class="wrapper">
			<h2 class="title">Hello World</h2>
			<p class="text">Hello, this is a paragraph.</p>
			</div>
			</div>
			</body>
			</html>
			

这就是一个最简单的 HTML 实例。开头用 DOCTYPE 定义了文档类型,其次最外层是 html 标签,最后还有对应的结束标签来表示闭合,其内部是 head 标签和 body 标签,分别代表网页头和网页体,它们也需要结束标签。

head 标签内定义了一些页面的配置和引用,如:<meta charset="UTF-8">,它指定了网页的编码为 UTF-8。title 标签则定义了网页的标题,会显示在网页的选项卡中,不会显示在正文中。body 标签内则是在网页正文中显示的内容。

div 标签定义了网页中的区块,它的 id 是 container,这是一个非常常用的属性,且 id 的内容在网页中是唯一的,我们可以通过它来获取这个区块。然后在此区块内又有一个 div 标签,它的 class 为 wrapper,这也是一个非常常用的属性,经常与 CSS 配合使用来设定样式。

然后此区块内部又有一个 h2 标签,这代表一个二级标题。另外,还有一个 p 标签,这代表一个段落。在这两者中直接写入相应的内容即可在网页中呈现出来,它们也有各自的 class 属性。

将代码保存后,在浏览器中打开该文件,可以看到如图所示的内容。

可以看到,在选项卡上显示了 This is a Demo 字样,这是我们在 head 中的 title 里定义的文字。而网页正文是 body 标签内部定义的各个元素生成的,可以看到这里显示了二级标题和段落。

这个实例便是网页的一般结构。一个网页的标准形式是 html 标签内嵌套 head 和 body 标签,head 内定义网页的配置和引用,body 内定义网页的正文。

节点树及节点间的关系

在 HTML 中,所有标签定义的内容都是节点,它们构成了一个 HTML DOM 树。

我们先看下什么是 DOM。DOM 是 W3C(万维网联盟)的标准,其英文全称 Document Object Model,即文档对象模型。它定义了访问 HTML 和 XML 文档的标准:

W3C 文档对象模型(DOM)是中立于平台和语言的接口,它允许程序和脚本动态地访问和更新文档的内容、结构和样式。

W3C DOM 标准被分为 3 个不同的部分:

根据 W3C 的 HTML DOM 标准,HTML 文档中的所有内容都是节点:

HTML DOM 将 HTML 文档视作树结构,这种结构被称为节点树,如图所示。

通过 HTML DOM,树中的所有节点均可通过 JavaScript 访问,所有 HTML 节点元素均可被修改,也可以被创建或删除。

节点树中的节点彼此拥有层级关系。我们常用父(parent)、子(child)和兄弟(sibling)等术语描述这些关系。父节点拥有子节点,同级的子节点被称为兄弟节点。

在节点树中,顶端节点称为根(root)。除了根节点之外,每个节点都有父节点,同时可拥有任意数量的子节点或兄弟节点。图中展示了节点树以及节点之间的关系。

本段参考 W3SCHOOL,链接:http://www.w3school.com.cn/htmldom/dom_nodes.asp

选择器

我们知道网页由一个个节点组成,CSS 选择器会根据不同的节点设置不同的样式规则,那么怎样来定位节点呢?

在 CSS 中,我们使用 CSS 选择器来定位节点。例如,上例中 div 节点的 id 为 container,那么就可以表示为 #container,其中 # 开头代表选择 id,其后紧跟 id 的名称。

另外,如果我们想选择 class 为 wrapper 的节点,便可以使用 .wrapper,这里以点“.”开头代表选择 class,其后紧跟 class 的名称。另外,还有一种选择方式,那就是根据标签名筛选,例如想选择二级标题,直接用 h2 即可。这是最常用的 3 种表示,分别是根据 id、class、标签名筛选,请牢记它们的写法。

另外,CSS 选择器还支持嵌套选择,各个选择器之间加上空格分隔开便可以代表嵌套关系,如 #container .wrapper p 则代表先选择 id 为 container 的节点,然后选中其内部的 class 为 wrapper 的节点,然后再进一步选中其内部的 p 节点。

另外,如果不加空格,则代表并列关系,如 div#container .wrapper p.text 代表先选择 id 为 container 的 div 节点,然后选中其内部的 class 为 wrapper 的节点,再进一步选中其内部的 class 为 text 的 p 节点。这就是 CSS 选择器,其筛选功能还是非常强大的。

另外,CSS 选择器还有一些其他语法规则,具体如表所示。因为表中的内容非常的多,我就不在一一介绍,课下你可以参考文字内容详细理解掌握这部分知识。

选 择 器 例  子 例子描述
.class .intro 选择 class="intro" 的所有节点
#id #firstname 选择 id="firstname" 的所有节点
* * 选择所有节点
element p 选择所有 p 节点
element,element div,p 选择所有 div 节点和所有 p 节点
element element div p 选择 div 节点内部的所有 p 节点
element>element div>p 选择父节点为 div 节点的所有 p 节点
element+element div+p 选择紧接在 div 节点之后的所有 p 节点
[attribute] [target] 选择带有 target 属性的所有节点
[attribute=value] [target=blank] 选择 target="blank" 的所有节点
[attribute~=value] [title~=flower] 选择 title 属性包含单词 flower 的所有节点
:link a:link 选择所有未被访问的链接
:visited a:visited 选择所有已被访问的链接
:active a:active 选择活动链接
:hover a:hover 选择鼠标指针位于其上的链接
:focus input:focus 选择获得焦点的 input 节点
:first-letter p:first-letter 选择每个 p 节点的首字母
:first-line p:first-line 选择每个 p 节点的首行
:first-child p:first-child 选择属于父节点的第一个子节点的所有 p 节点
:before p:before 在每个 p 节点的内容之前插入内容
:after p:after 在每个 p 节点的内容之后插入内容
:lang(language) p:lang 选择带有以 it 开头的 lang 属性值的所有 p 节点
element1~element2 p~ul 选择前面有 p 节点的所有 ul 节点
[attribute^=value] a[src^="https"] 选择其 src 属性值以 https 开头的所有 a 节点
[attribute$=value] a[src$=".pdf"] 选择其 src 属性以.pdf 结尾的所有 a 节点
[attribute*=value] a[src*="abc"] 选择其 src 属性中包含 abc 子串的所有 a 节点
:first-of-type p:first-of-type 选择属于其父节点的首个 p 节点的所有 p 节点
:last-of-type p:last-of-type 选择属于其父节点的最后 p 节点的所有 p 节点
:only-of-type p:only-of-type 选择属于其父节点唯一的 p 节点的所有 p 节点
:only-child p:only-child 选择属于其父节点的唯一子节点的所有 p 节点
:nth-child(n) p:nth-child 选择属于其父节点的第二个子节点的所有 p 节点
:nth-last-child(n) p:nth-last-child 同上,从最后一个子节点开始计数
:nth-of-type(n) p:nth-of-type 选择属于其父节点第二个 p 节点的所有 p 节点
:nth-last-of-type(n) p:nth-last-of-type 同上,但是从最后一个子节点开始计数
:last-child p:last-child 选择属于其父节点最后一个子节点的所有 p 节点
:root :root 选择文档的根节点
:empty p:empty 选择没有子节点的所有 p 节点(包括文本节点)
:target #news:target 选择当前活动的 #news 节点
:enabled input:enabled 选择每个启用的 input 节点
:disabled input:disabled 选择每个禁用的 input 节点
:checked input:checked 选择每个被选中的 input 节点
:not(selector) :not 选择非 p 节点的所有节点
::selection ::selection 选择被用户选取的节点部分

另外,还有一种比较常用的选择器是 XPath,这种选择方式后面会详细介绍。

本课时的内容就全部讲完了,在本课时中我们介绍了网页的基本结构和节点间的关系,了解了这些内容后,我们才有更加清晰的思路去解析和提取网页内容。

第03讲:原理探究,了解爬虫的基本原理

我们可以把互联网比作一张大网,而爬虫(即网络爬虫)便是在网上爬行的蜘蛛。如果把网的节点比作一个个网页,爬虫爬到这就相当于访问了该页面,获取了其信息。可以把节点间的连线比作网页与网页之间的链接关系,这样蜘蛛通过一个节点后,可以顺着节点连线继续爬行到达下一个节点,即通过一个网页继续获取后续的网页,这样整个网的节点便可以被蜘蛛全部爬行到,网站的数据就可以被抓取下来了。

爬虫概述

简单来说,爬虫就是获取网页并提取和保存信息的自动化程序,下面概要介绍一下。

获取网页

爬虫首先要做的工作就是获取网页,这里就是获取网页的源代码。

源代码里包含了网页的部分有用信息,所以只要把源代码获取下来,就可以从中提取想要的信息了。

前面讲了请求和响应的概念,向网站的服务器发送一个请求,返回的响应体便是网页源代码。所以,最关键的部分就是构造一个请求并发送给服务器,然后接收到响应并将其解析出来,那么这个流程怎样实现呢?总不能手工去截取网页源码吧?

不用担心,Python 提供了许多库来帮助我们实现这个操作,如 urllib、requests 等。我们可以用这些库来帮助我们实现 HTTP 请求操作,请求和响应都可以用类库提供的数据结构来表示,得到响应之后只需要解析数据结构中的 Body 部分即可,即得到网页的源代码,这样我们可以用程序来实现获取网页的过程了。

提取信息

获取网页源代码后,接下来就是分析网页源代码,从中提取我们想要的数据。首先,最通用的方法便是采用正则表达式提取,这是一个万能的方法,但是在构造正则表达式时比较复杂且容易出错。

另外,由于网页的结构有一定的规则,所以还有一些根据网页节点属性、CSS 选择器或 XPath 来提取网页信息的库,如 Beautiful Soup、pyquery、lxml 等。使用这些库,我们可以高效快速地从中提取网页信息,如节点的属性、文本值等。

提取信息是爬虫非常重要的部分,它可以使杂乱的数据变得条理清晰,以便我们后续处理和分析数据。

保存数据

提取信息后,我们一般会将提取到的数据保存到某处以便后续使用。这里保存形式有多种多样,如可以简单保存为 TXT 文本或 JSON 文本,也可以保存到数据库,如 MySQL 和 MongoDB 等,还可保存至远程服务器,如借助 SFTP 进行操作等。

自动化程序

说到自动化程序,意思是说爬虫可以代替人来完成这些操作。首先,我们手工当然可以提取这些信息,但是当量特别大或者想快速获取大量数据的话,肯定还是要借助程序。爬虫就是代替我们来完成这份爬取工作的自动化程序,它可以在抓取过程中进行各种异常处理、错误重试等操作,确保爬取持续高效地运行。

能抓怎样的数据

在网页中我们能看到各种各样的信息,最常见的便是常规网页,它们对应着 HTML 代码,而最常抓取的便是 HTML 源代码。

另外,可能有些网页返回的不是 HTML 代码,而是一个 JSON 字符串(其中 API 接口大多采用这样的形式),这种格式的数据方便传输和解析,它们同样可以抓取,而且数据提取更加方便。

此外,我们还可以看到各种二进制数据,如图片、视频和音频等。利用爬虫,我们可以将这些二进制数据抓取下来,然后保存成对应的文件名。

另外,还可以看到各种扩展名的文件,如 CSS、JavaScript 和配置文件等,这些其实也是最普通的文件,只要在浏览器里面可以访问到,就可以将其抓取下来。

上述内容其实都对应各自的 URL,是基于 HTTP 或 HTTPS 协议的,只要是这种数据,爬虫都可以抓取。

JavaScript 渲染页面

有时候,我们在用 urllib 或 requests 抓取网页时,得到的源代码实际和浏览器中看到的不一样。

这是一个非常常见的问题。现在网页越来越多地采用 Ajax、前端模块化工具来构建,整个网页可能都是由 JavaScript 渲染出来的,也就是说原始的 HTML 代码就是一个空壳,例如:

<!DOCTYPE html>
			<html>
			<head>
			<meta charset="UTF-8">
			<title>This is a Demo</title>
			</head>
			<body>
			<div id="container">
			</div>
			</body>
			<script src="app.js"></script>
			</html>
			

body 节点里面只有一个 id 为 container 的节点,但是需要注意在 body 节点后引入了 app.js,它便负责整个网站的渲染。

在浏览器中打开这个页面时,首先会加载这个 HTML 内容,接着浏览器会发现其中引入了一个 app.js 文件,然后便会接着去请求这个文件,获取到该文件后,便会执行其中的 JavaScript 代码,而 JavaScript 则会改变 HTML 中的节点,向其添加内容,最后得到完整的页面。

但是在用 urllib 或 requests 等库请求当前页面时,我们得到的只是这个 HTML 代码,它不会帮助我们去继续加载这个 JavaScript 文件,这样也就看不到浏览器中的内容了。

这也解释了为什么有时我们得到的源代码和浏览器中看到的不一样。

因此,使用基本 HTTP 请求库得到的源代码可能跟浏览器中的页面源代码不太一样。对于这样的情况,我们可以分析其后台 Ajax 接口,也可使用 Selenium、Splash 这样的库来实现模拟 JavaScript 渲染。

后面,我们会详细介绍如何采集 JavaScript 渲染的网页。本节介绍了爬虫的一些基本原理,这可以帮助我们在后面编写爬虫时更加得心应手。

第04讲:基础探究,Session 与 Cookies

我们在浏览网站的过程中,经常会遇到需要登录的情况,而有些网页只有登录之后才可以访问,而且登录之后可以连续访问很多次网站,但是有时候过一段时间就需要重新登录。

还有一些网站,在打开浏览器时就自动登录了,而且很长时间都不会失效,这种情况又是为什么?其实这里面涉及 Session 和 Cookies 的相关知识,本节就来揭开它们的神秘面纱。

静态网页和动态网页

在开始介绍它们之前,我们需要先了解一下静态网页和动态网页的概念。这里还是前面的示例代码,内容如下:

<!DOCTYPE html>
			<html>
			<head>
			<meta charset="UTF-8">
			<title>This is a Demo</title>
			</head>
			<body>
			<div id="container">
			<div class="wrapper">
			<h2 class="title">Hello World</h2>
			<p class="text">Hello, this is a paragraph.</p>
			</div>
			</div>
			</body>
			</html>
			

这是最基本的 HTML 代码,我们将其保存为一个 .html 文件,然后把它放在某台具有固定公网 IP 的主机上,主机上装上 Apache 或 Nginx 等服务器,这样这台主机就可以作为服务器了,其他人便可以通过访问服务器看到这个页面,这就搭建了一个最简单的网站。

这种网页的内容是 HTML 代码编写的,文字、图片等内容均通过写好的 HTML 代码来指定,这种页面叫作静态网页。它加载速度快,编写简单,但是存在很大的缺陷,如可维护性差,不能根据 URL 灵活多变地显示内容等。例如,我们想要给这个网页的 URL 传入一个 name 参数,让其在网页中显示出来,是无法做到的。

因此,动态网页应运而生,它可以动态解析 URL 中参数的变化,关联数据库并动态呈现不同的页面内容,非常灵活多变。我们现在遇到的大多数网站都是动态网站,它们不再是一个简单的 HTML,而是可能由 JSP、PHP、Python 等语言编写的,其功能比静态网页强大和丰富太多了。

此外,动态网站还可以实现用户登录和注册的功能。再回到开头来看提到的问题,很多页面是需要登录之后才可以查看的。按照一般的逻辑来说,输入用户名和密码登录之后,肯定是拿到了一种类似凭证的东西,有了它,我们才能保持登录状态,才能访问登录之后才能看到的页面。

那么,这种神秘的凭证到底是什么呢?其实它就是 Session 和 Cookies 共同产生的结果,下面我们来一探究竟。

无状态 HTTP

在了解 Session 和 Cookies 之前,我们还需要了解 HTTP 的一个特点,叫作无状态。

HTTP 的无状态是指 HTTP 协议对事务处理是没有记忆能力的,也就是说服务器不知道客户端是什么状态。

当我们向服务器发送请求后,服务器解析此请求,然后返回对应的响应,服务器负责完成这个过程,而且这个过程是完全独立的,服务器不会记录前后状态的变化,也就是缺少状态记录。

这意味着如果后续需要处理前面的信息,则必须重传,这也导致需要额外传递一些前面的重复请求,才能获取后续响应,然而这种效果显然不是我们想要的。为了保持前后状态,我们肯定不能将前面的请求全部重传一次,这太浪费资源了,对于这种需要用户登录的页面来说,更是棘手。

这时两个用于保持 HTTP 连接状态的技术就出现了,它们分别是 Session 和 Cookies。Session 在服务端,也就是网站的服务器,用来保存用户的 Session 信息;Cookies 在客户端,也可以理解为浏览器端,有了 Cookies,浏览器在下次访问网页时会自动附带上它发送给服务器,服务器通过识别 Cookies 并鉴定出是哪个用户,然后再判断用户是否是登录状态,进而返回对应的响应。

我们可以理解为 Cookies 里面保存了登录的凭证,有了它,只需要在下次请求携带 Cookies 发送请求而不必重新输入用户名、密码等信息重新登录了。

因此在爬虫中,有时候处理需要登录才能访问的页面时,我们一般会直接将登录成功后获取的 Cookies 放在请求头里面直接请求,而不必重新模拟登录。

好了,了解 Session 和 Cookies 的概念之后,我们在来详细剖析它们的原理。

Session

Session,中文称之为会话,其本身的含义是指有始有终的一系列动作 / 消息。比如,打电话时,从拿起电话拨号到挂断电话这中间的一系列过程可以称为一个 Session。

而在 Web 中,Session 对象用来存储特定用户 Session 所需的属性及配置信息。这样,当用户在应用程序的 Web 页之间跳转时,存储在 Session 对象中的变量将不会丢失,而是在整个用户 Session 中一直存在下去。当用户请求来自应用程序的 Web 页时,如果该用户还没有 Session,则 Web 服务器将自动创建一个 Session 对象。当 Session 过期或被放弃后,服务器将终止该 Session。

Cookies

Cookies 指某些网站为了辨别用户身份、进行 Session 跟踪而存储在用户本地终端上的数据。

Session 维持

那么,我们怎样利用 Cookies 保持状态呢?当客户端第一次请求服务器时,服务器会返回一个响应头中带有 Set-Cookie 字段的响应给客户端,用来标记是哪一个用户,客户端浏览器会把 Cookies 保存起来。当浏览器下一次再请求该网站时,浏览器会把此 Cookies 放到请求头一起提交给服务器,Cookies 携带了 Session ID 信息,服务器检查该 Cookies 即可找到对应的 Session 是什么,然后再判断 Session 来以此来辨认用户状态。

在成功登录某个网站时,服务器会告诉客户端设置哪些 Cookies 信息,在后续访问页面时客户端会把 Cookies 发送给服务器,服务器再找到对应的 Session 加以判断。如果 Session 中的某些设置登录状态的变量是有效的,那就证明用户处于登录状态,此时返回登录之后才可以查看的网页内容,浏览器再进行解析便可以看到了。

反之,如果传给服务器的 Cookies 是无效的,或者 Session 已经过期了,我们将不能继续访问页面,此时可能会收到错误的响应或者跳转到登录页面重新登录。

所以,Cookies 和 Session 需要配合,一个处于客户端,一个处于服务端,二者共同协作,就实现了登录 Session 控制。

属性结构

接下来,我们来看看 Cookies 都有哪些内容。这里以知乎为例,在浏览器开发者工具中打开 Application 选项卡,然后在左侧会有一个 Storage 部分,最后一项即为 Cookies,将其点开,如图所示,这些就是 Cookies。

可以看到,这里有很多条目,其中每个条目可以称为 Cookie。它有如下几个属性。

会话 Cookie 和持久 Cookie

从表面意思来说,会话 Cookie 就是把 Cookie 放在浏览器内存里,浏览器在关闭之后该 Cookie 即失效;持久 Cookie 则会保存到客户端的硬盘中,下次还可以继续使用,用于长久保持用户登录状态。

其实严格来说,没有会话 Cookie 和持久 Cookie 之 分,只是由 Cookie 的 Max Age 或 Expires 字段决定了过期的时间。

因此,一些持久化登录的网站其实就是把 Cookie 的有效时间和 Session 有效期设置得比较长,下次我们再访问页面时仍然携带之前的 Cookie,就可以直接保持登录状态。

常见误区

在谈论 Session 机制的时候,常常听到这样一种误解 ——“只要关闭浏览器,Session 就消失了”。可以想象一下会员卡的例子,除非顾客主动对店家提出销卡,否则店家绝对不会轻易删除顾客的资料。对 Session 来说,也是一样,除非程序通知服务器删除一个 Session,否则服务器会一直保留。比如,程序一般都是在我们做注销操作时才去删除 Session。

但是当我们关闭浏览器时,浏览器不会主动在关闭之前通知服务器它将要关闭,所以服务器根本不会有机会知道浏览器已经关闭。之所以会有这种错觉,是因为大部分网站都使用会话 Cookie 来保存 Session ID 信息,而关闭浏览器后 Cookies 就消失了,再次连接服务器时,也就无法找到原来的 Session 了。如果服务器设置的 Cookies 保存到硬盘上,或者使用某种手段改写浏览器发出的 HTTP 请求头,把原来的 Cookies 发送给服务器,则再次打开浏览器,仍然能够找到原来的 Session ID,依旧还是可以保持登录状态的。

而且恰恰是由于关闭浏览器不会导致 Session 被删除,这就需要服务器为 Session 设置一个失效时间,当距离客户端上一次使用 Session 的时间超过这个失效时间时,服务器就可以认为客户端已经停止了活动,才会把 Session 删除以节省存储空间。

第05讲:多路加速,了解多线程基本原理

我们知道,在一台计算机中,我们可以同时打开许多软件,比如同时浏览网页、听音乐、打字等等,看似非常正常。但仔细想想,为什么计算机可以做到这么多软件同时运行呢?这就涉及到计算机中的两个重要概念:多进程和多线程了。

同样,在编写爬虫程序的时候,为了提高爬取效率,我们可能想同时运行多个爬虫任务。这里同样需要涉及多进程和多线程的知识。

本课时,我们就先来了解一下多线程的基本原理,以及在 Python 中如何实现多线程。

多线程的含义

说起多线程,就不得不先说什么是线程。然而想要弄明白什么是线程,又不得不先说什么是进程。

进程我们可以理解为是一个可以独立运行的程序单位,比如打开一个浏览器,这就开启了一个浏览器进程;打开一个文本编辑器,这就开启了一个文本编辑器进程。但一个进程中是可以同时处理很多事情的,比如在浏览器中,我们可以在多个选项卡中打开多个页面,有的页面在播放音乐,有的页面在播放视频,有的网页在播放动画,它们可以同时运行,互不干扰。为什么能同时做到同时运行这么多的任务呢?这里就需要引出线程的概念了,其实这一个个任务,实际上就对应着一个个线程的执行。

而进程呢?它就是线程的集合,进程就是由一个或多个线程构成的,线程是操作系统进行运算调度的最小单位,是进程中的一个最小运行单元。比如上面所说的浏览器进程,其中的播放音乐就是一个线程,播放视频也是一个线程,当然其中还有很多其他的线程在同时运行,这些线程的并发或并行执行最后使得整个浏览器可以同时运行这么多的任务。

了解了线程的概念,多线程就很容易理解了,多线程就是一个进程中同时执行多个线程,前面所说的浏览器的情景就是典型的多线程执行。

并发和并行

说到多进程和多线程,这里就需要再讲解两个概念,那就是并发和并行。我们知道,一个程序在计算机中运行,其底层是处理器通过运行一条条的指令来实现的。

并发,英文叫作 concurrency。它是指同一时刻只能有一条指令执行,但是多个线程的对应的指令被快速轮换地执行。比如一个处理器,它先执行线程 A 的指令一段时间,再执行线程 B 的指令一段时间,再切回到线程 A 执行一段时间。

由于处理器执行指令的速度和切换的速度非常非常快,人完全感知不到计算机在这个过程中有多个线程切换上下文执行的操作,这就使得宏观上看起来多个线程在同时运行。但微观上只是这个处理器在连续不断地在多个线程之间切换和执行,每个线程的执行一定会占用这个处理器一个时间片段,同一时刻,其实只有一个线程在执行。

并行,英文叫作 parallel。它是指同一时刻,有多条指令在多个处理器上同时执行,并行必须要依赖于多个处理器。不论是从宏观上还是微观上,多个线程都是在同一时刻一起执行的。

并行只能在多处理器系统中存在,如果我们的计算机处理器只有一个核,那就不可能实现并行。而并发在单处理器和多处理器系统中都是可以存在的,因为仅靠一个核,就可以实现并发。

举个例子,比如系统处理器需要同时运行多个线程。如果系统处理器只有一个核,那它只能通过并发的方式来运行这些线程。如果系统处理器有多个核,当一个核在执行一个线程时,另一个核可以执行另一个线程,这样这两个线程就实现了并行执行,当然其他的线程也可能和另外的线程处在同一个核上执行,它们之间就是并发执行。具体的执行方式,就取决于操作系统的调度了。

多线程适用场景

在一个程序进程中,有一些操作是比较耗时或者需要等待的,比如等待数据库的查询结果的返回,等待网页结果的响应。如果使用单线程,处理器必须要等到这些操作完成之后才能继续往下执行其他操作,而这个线程在等待的过程中,处理器明显是可以来执行其他的操作的。如果使用多线程,处理器就可以在某个线程等待的时候,去执行其他的线程,从而从整体上提高执行效率。

像上述场景,线程在执行过程中很多情况下是需要等待的。比如网络爬虫就是一个非常典型的例子,爬虫在向服务器发起请求之后,有一段时间必须要等待服务器的响应返回,这种任务就属于 IO 密集型任务。对于这种任务,如果我们启用多线程,处理器就可以在某个线程等待的过程中去处理其他的任务,从而提高整体的爬取效率。

但并不是所有的任务都是 IO 密集型任务,还有一种任务叫作计算密集型任务,也可以称之为 CPU 密集型任务。顾名思义,就是任务的运行一直需要处理器的参与。此时如果我们开启了多线程,一个处理器从一个计算密集型任务切换到切换到另一个计算密集型任务上去,处理器依然不会停下来,始终会忙于计算,这样并不会节省总体的时间,因为需要处理的任务的计算总量是不变的。如果线程数目过多,反而还会在线程切换的过程中多耗费一些时间,整体效率会变低。

所以,如果任务不全是计算密集型任务,我们可以使用多线程来提高程序整体的执行效率。尤其对于网络爬虫这种 IO 密集型任务来说,使用多线程会大大提高程序整体的爬取效率。

Python 实现多线程

在 Python 中,实现多线程的模块叫作 threading,是 Python 自带的模块。下面我们来了解下使用 threading 实现多线程的方法。

Thread 直接创建子线程

首先,我们可以使用 Thread 类来创建一个线程,创建时需要指定 target 参数为运行的方法名称,如果被调用的方法需要传入额外的参数,则可以通过 Thread 的 args 参数来指定。示例如下:

import threading
			import time

			def target(second):
			    print(f'Threading {threading.current_thread().name} is running')
			    print(f'Threading {threading.current_thread().name} sleep {second}s')
			    time.sleep(second)
			    print(f'Threading {threading.current_thread().name} is ended')

			print(f'Threading {threading.current_thread().name} is running')
			for i in [1, 5]:
			    thread = threading.Thread(target=target, args=[i])
			    thread.start()
			print(f'Threading {threading.current_thread().name} is ended')
			

运行结果如下:

Threading MainThread is running
			Threading Thread-1 is running
			Threading Thread-1 sleep 1s
			Threading Thread-2 is running
			Threading Thread-2 sleep 5s
			Threading MainThread is ended
			Threading Thread-1 is ended
			Threading Thread-2 is ended
			

在这里我们首先声明了一个方法,叫作 target,它接收一个参数为 second,通过方法的实现可以发现,这个方法其实就是执行了一个 time.sleep 休眠操作,second 参数就是休眠秒数,其前后都 print 了一些内容,其中线程的名字我们通过 threading.current_thread().name 来获取出来,如果是主线程的话,其值就是 MainThread,如果是子线程的话,其值就是 Thread-*。

然后我们通过 Thead 类新建了两个线程,target 参数就是刚才我们所定义的方法名,args 以列表的形式传递。两次循环中,这里 i 分别就是 1 和 5,这样两个线程就分别休眠 1 秒和 5 秒,声明完成之后,我们调用 start 方法即可开始线程的运行。

观察结果我们可以发现,这里一共产生了三个线程,分别是主线程 MainThread 和两个子线程 Thread-1、Thread-2。另外我们观察到,主线程首先运行结束,紧接着 Thread-1、Thread-2 才接连运行结束,分别间隔了 1 秒和 4 秒。这说明主线程并没有等待子线程运行完毕才结束运行,而是直接退出了,有点不符合常理。

如果我们想要主线程等待子线程运行完毕之后才退出,可以让每个子线程对象都调用下 join 方法,实现如下:

threads = []
			for i in [1, 5]:
			    thread = threading.Thread(target=target, args=[i])
			    threads.append(thread)
			    thread.start()
			for thread in threads:
			    thread.join()
			

运行结果如下:

Threading MainThread is running
			Threading Thread-1 is running
			Threading Thread-1 sleep 1s
			Threading Thread-2 is running
			Threading Thread-2 sleep 5s
			Threading Thread-1 is ended
			Threading Thread-2 is ended
			Threading MainThread is ended
			

这样,主线程必须等待子线程都运行结束,主线程才继续运行并结束。

继承 Thread 类创建子线程

另外,我们也可以通过继承 Thread 类的方式创建一个线程,该线程需要执行的方法写在类的 run 方法里面即可。上面的例子的等价改写为:

import threading
			import time


			class MyThread(threading.Thread):
			    def __init__(self, second):
			        threading.Thread.__init__(self)
			        self.second = second
			    
			    def run(self):
			        print(f'Threading {threading.current_thread().name} is running')
			        print(f'Threading {threading.current_thread().name} sleep {self.second}s')
			        time.sleep(self.second)
			        print(f'Threading {threading.current_thread().name} is ended')


			print(f'Threading {threading.current_thread().name} is running')
			threads = []
			for i in [1, 5]:
			    thread = MyThread(i)
			    threads.append(thread)
			    thread.start()
			for thread in threads:
			    thread.join()
			print(f'Threading {threading.current_thread().name} is ended')
			

运行结果如下:

Threading MainThread is running Threading Thread-1 is running Threading Thread-1 sleep 1s Threading Thread-2 is running Threading Thread-2 sleep 5s Threading Thread-1 is ended Threading Thread-2 is ended Threading MainThread is ended 

可以看到,两种实现方式,其运行效果是相同的。

守护线程

在线程中有一个叫作守护线程的概念,如果一个线程被设置为守护线程,那么意味着这个线程是“不重要”的,这意味着,如果主线程结束了而该守护线程还没有运行完,那么它将会被强制结束。在 Python 中我们可以通过 setDaemon 方法来将某个线程设置为守护线程。

示例如下:

import threading
			import time


			def target(second):
			    print(f'Threading {threading.current_thread().name} is running')
			    print(f'Threading {threading.current_thread().name} sleep {second}s')
			    time.sleep(second)
			    print(f'Threading {threading.current_thread().name} is ended')


			print(f'Threading {threading.current_thread().name} is running')
			t1 = threading.Thread(target=target, args=[2])
			t1.start()
			t2 = threading.Thread(target=target, args=[5])
			t2.setDaemon(True)
			t2.start()
			print(f'Threading {threading.current_thread().name} is ended')
			

在这里我们通过 setDaemon 方法将 t2 设置为了守护线程,这样主线程在运行完毕时,t2 线程会随着线程的结束而结束。

运行结果如下:

Threading MainThread is running Threading Thread-1 is running Threading Thread-1 sleep 2s Threading Thread-2 is running Threading Thread-2 sleep 5s Threading MainThread is ended Threading Thread-1 is ended 

可以看到,我们没有看到 Thread-2 打印退出的消息,Thread-2 随着主线程的退出而退出了。

不过细心的你可能会发现,这里并没有调用 join 方法,如果我们让 t1 和 t2 都调用 join 方法,主线程就会仍然等待各个子线程执行完毕再退出,不论其是否是守护线程。

互斥锁

在一个进程中的多个线程是共享资源的,比如在一个进程中,有一个全局变量 count 用来计数,现在我们声明多个线程,每个线程运行时都给 count 加 1,让我们来看看效果如何,代码实现如下:

import threading
			import time

			count = 0

			class MyThread(threading.Thread):
			    def __init__(self):
			        threading.Thread.__init__(self)

			    def run(self):
			        global count
			        temp = count + 1
			        time.sleep(0.001)
			        count = temp

			threads = []
			for _ in range(1000):
			    thread = MyThread()
			    thread.start()
			    threads.append(thread)

			for thread in threads:
			    thread.join()
			print(f'Final count: {count}')
			

在这里,我们声明了 1000 个线程,每个线程都是现取到当前的全局变量 count 值,然后休眠一小段时间,然后对 count 赋予新的值。

那这样,按照常理来说,最终的 count 值应该为 1000。但其实不然,我们来运行一下看看。

运行结果如下:

Final count: 69 

最后的结果居然只有 69,而且多次运行或者换个环境运行结果是不同的。

这是为什么呢?因为 count 这个值是共享的,每个线程都可以在执行 temp = count 这行代码时拿到当前 count 的值,但是这些线程中的一些线程可能是并发或者并行执行的,这就导致不同的线程拿到的可能是同一个 count 值,最后导致有些线程的 count 的加 1 操作并没有生效,导致最后的结果偏小。

所以,如果多个线程同时对某个数据进行读取或修改,就会出现不可预料的结果。为了避免这种情况,我们需要对多个线程进行同步,要实现同步,我们可以对需要操作的数据进行加锁保护,这里就需要用到 threading.Lock 了。

加锁保护是什么意思呢?就是说,某个线程在对数据进行操作前,需要先加锁,这样其他的线程发现被加锁了之后,就无法继续向下执行,会一直等待锁被释放,只有加锁的线程把锁释放了,其他的线程才能继续加锁并对数据做修改,修改完了再释放锁。这样可以确保同一时间只有一个线程操作数据,多个线程不会再同时读取和修改同一个数据,这样最后的运行结果就是对的了。

我们可以将代码修改为如下内容:

import threading
			import time

			count = 0

			class MyThread(threading.Thread):
			    def __init__(self):
			        threading.Thread.__init__(self)

			    def run(self):
			        global count
			        lock.acquire()
			        temp = count + 1
			        time.sleep(0.001)
			        count = temp
			        lock.release()

			lock = threading.Lock()
			threads = []
			for _ in range(1000):
			    thread = MyThread()
			    thread.start()
			    threads.append(thread)

			for thread in threads:
			    thread.join()
			print(f'Final count: {count}')
			

在这里我们声明了一个 lock 对象,其实就是 threading.Lock 的一个实例,然后在 run 方法里面,获取 count 前先加锁,修改完 count 之后再释放锁,这样多个线程就不会同时获取和修改 count 的值了。

运行结果如下:

Final count: 1000 

这样运行结果就正常了。

关于 Python 多线程的内容,这里暂且先介绍这些,关于 theading 更多的使用方法,如信号量、队列等,可以参考官方文档:https://docs.python.org/zh-cn/3.7/library/threading.html#module-threading

Python 多线程的问题

由于 Python 中 GIL 的限制,导致不论是在单核还是多核条件下,在同一时刻只能运行一个线程,导致 Python 多线程无法发挥多核并行的优势。

GIL 全称为 Global Interpreter Lock,中文翻译为全局解释器锁,其最初设计是出于数据安全而考虑的。

在 Python 多线程下,每个线程的执行方式如下:

可见,某个线程想要执行,必须先拿到 GIL,我们可以把 GIL 看作是通行证,并且在一个 Python 进程中,GIL 只有一个。拿不到通行证的线程,就不允许执行。这样就会导致,即使是多核条件下,一个 Python 进程下的多个线程,同一时刻也只能执行一个线程。

不过对于爬虫这种 IO 密集型任务来说,这个问题影响并不大。而对于计算密集型任务来说,由于 GIL 的存在,多线程总体的运行效率相比可能反而比单线程更低。

第06讲:多路加速,了解多进程基本原理

在上一课时我们了解了多线程的基本概念,同时我们也提到,Python 中的多线程是不能很好发挥多核优势的,如果想要发挥多核优势,最好还是使用多进程。

那么本课时我们就来了解下多进程的基本概念和用 Python 实现多进程的方法。

多进程的含义

进程(Process)是具有一定独立功能的程序关于某个数据集合上的一次运行活动,是系统进行资源分配和调度的一个独立单位。

顾名思义,多进程就是启用多个进程同时运行。由于进程是线程的集合,而且进程是由一个或多个线程构成的,所以多进程的运行意味着有大于或等于进程数量的线程在运行。

Python 多进程的优势

通过上一课时我们知道,由于进程中 GIL 的存在,Python 中的多线程并不能很好地发挥多核优势,一个进程中的多个线程,在同一时刻只能有一个线程运行。

而对于多进程来说,每个进程都有属于自己的 GIL,所以,在多核处理器下,多进程的运行是不会受 GIL 的影响的。因此,多进程能更好地发挥多核的优势。

当然,对于爬虫这种 IO 密集型任务来说,多线程和多进程影响差别并不大。对于计算密集型任务来说,Python 的多进程相比多线程,其多核运行效率会有成倍的提升。

总的来说,Python 的多进程整体来看是比多线程更有优势的。所以,在条件允许的情况下,能用多进程就尽量用多进程。

不过值得注意的是,由于进程是系统进行资源分配和调度的一个独立单位,所以各个进程之间的数据是无法共享的,如多个进程无法共享一个全局变量,进程之间的数据共享需要有单独的机制来实现,这在后面也会讲到。

多进程的实现

在 Python 中也有内置的库来实现多进程,它就是 multiprocessing。

multiprocessing 提供了一系列的组件,如 Process(进程)、Queue(队列)、Semaphore(信号量)、Pipe(管道)、Lock(锁)、Pool(进程池)等,接下来让我们来了解下它们的使用方法。

直接使用 Process 类

在 multiprocessing 中,每一个进程都用一个 Process 类来表示。它的 API 调用如下:

Process([group [, target [, name [, args [, kwargs]]]]])
			

我们先用一个实例来感受一下:

import multiprocessing

			def process(index):
			    print(f'Process: {index}')

			if __name__ == '__main__':
			    for i in range(5):
			        p = multiprocessing.Process(target=process, args=(i,))
			        p.start()
			

这是一个实现多进程最基础的方式:通过创建 Process 来新建一个子进程,其中 target 参数传入方法名,args 是方法的参数,是以元组的形式传入,其和被调用的方法 process 的参数是一一对应的。

注意:这里 args 必须要是一个元组,如果只有一个参数,那也要在元组第一个元素后面加一个逗号,如果没有逗号则和单个元素本身没有区别,无法构成元组,导致参数传递出现问题。

创建完进程之后,我们通过调用 start 方法即可启动进程了。运行结果如下:

Process: 0
			Process: 1
			Process: 2
			Process: 3
			Process: 4
			

可以看到,我们运行了 5 个子进程,每个进程都调用了 process 方法。process 方法的 index 参数通过 Process 的 args 传入,分别是 0~4 这 5 个序号,最后打印出来,5 个子进程运行结束。

由于进程是 Python 中最小的资源分配单元,因此这些进程和线程不同,各个进程之间的数据是不会共享的,每启动一个进程,都会独立分配资源。

另外,在当前 CPU 核数足够的情况下,这些不同的进程会分配给不同的 CPU 核来运行,实现真正的并行执行。

multiprocessing 还提供了几个比较有用的方法,如我们可以通过 cpu_count 的方法来获取当前机器 CPU 的核心数量,通过 active_children 方法获取当前还在运行的所有进程。

下面通过一个实例来看一下:

import multiprocessing
			import time

			def process(index):
			    time.sleep(index)
			    print(f'Process: {index}')

			if __name__ == '__main__':
			    for i in range(5):
			        p = multiprocessing.Process(target=process, args=[i])
			        p.start()
			    print(f'CPU number: {multiprocessing.cpu_count()}')
			    for p in multiprocessing.active_children():
			        print(f'Child process name: {p.name} id: {p.pid}')
			    print('Process Ended')
			

运行结果如下:

Process: 0
			CPU number: 8
			Child process name: Process-5 id: 73595
			Child process name: Process-2 id: 73592
			Child process name: Process-3 id: 73593
			Child process name: Process-4 id: 73594
			Process Ended
			Process: 1
			Process: 2
			Process: 3
			Process: 4
			

在上面的例子中我们通过 cpu_count 成功获取了 CPU 核心的数量:8 个,当然不同的机器结果可能不同。

另外我们还通过 active_children 获取到了当前正在活跃运行的进程列表。然后我们遍历了每个进程,并将它们的名称和进程号打印出来了,这里进程号直接使用 pid 属性即可获取,进程名称直接通过 name 属性即可获取。

以上我们就完成了多进程的创建和一些基本信息的获取。

继承 Process 类

在上面的例子中,我们创建进程是直接使用 Process 这个类来创建的,这是一种创建进程的方式。不过,创建进程的方式不止这一种,同样,我们也可以像线程 Thread 一样来通过继承的方式创建一个进程类,进程的基本操作我们在子类的 run 方法中实现即可。

通过一个实例来看一下:

from multiprocessing import Process
			import time

			class MyProcess(Process):
			    def __init__(self, loop):
			        Process.__init__(self)
			        self.loop = loop

			    def run(self):
			        for count in range(self.loop):
			            time.sleep(1)
			            print(f'Pid: {self.pid} LoopCount: {count}')

			if __name__ == '__main__':
			    for i in range(2, 5):
			        p = MyProcess(i)
			        p.start()
			

我们首先声明了一个构造方法,这个方法接收一个 loop 参数,代表循环次数,并将其设置为全局变量。在 run 方法中,又使用这个 loop 变量循环了 loop 次并打印了当前的进程号和循环次数。

在调用时,我们用 range 方法得到了 2、3、4 三个数字,并把它们分别初始化了 MyProcess 进程,然后调用 start 方法将进程启动起来。

注意:这里进程的执行逻辑需要在 run 方法中实现,启动进程需要调用 start 方法,调用之后 run 方法便会执行。

运行结果如下:

Pid: 73667 LoopCount: 0
			Pid: 73668 LoopCount: 0
			Pid: 73669 LoopCount: 0
			Pid: 73667 LoopCount: 1
			Pid: 73668 LoopCount: 1
			Pid: 73669 LoopCount: 1
			Pid: 73668 LoopCount: 2
			Pid: 73669 LoopCount: 2
			Pid: 73669 LoopCount: 3
			

可以看到,三个进程分别打印出了 2、3、4 条结果,即进程 73667 打印了 2 次 结果,进程 73668 打印了 3 次结果,进程 73669 打印了 4 次结果。

注意,这里的进程 pid 代表进程号,不同机器、不同时刻运行结果可能不同。

通过上面的方式,我们也非常方便地实现了一个进程的定义。为了复用方便,我们可以把一些方法写在每个进程类里封装好,在使用时直接初始化一个进程类运行即可。

守护进程

在多进程中,同样存在守护进程的概念,如果一个进程被设置为守护进程,当父进程结束后,子进程会自动被终止,我们可以通过设置 daemon 属性来控制是否为守护进程。

还是原来的例子,增加了 deamon 属性的设置:

from multiprocessing import Process
			import time

			class MyProcess(Process):
			    def __init__(self, loop):
			        Process.__init__(self)
			        self.loop = loop

			    def run(self):
			        for count in range(self.loop):
			            time.sleep(1)
			            print(f'Pid: {self.pid} LoopCount: {count}')

			if __name__ == '__main__':
			    for i in range(2, 5):
			        p = MyProcess(i)
			        p.daemon = True
			        p.start()

			print('Main Process ended')
			

运行结果如下:

Main Process ended
			

结果很简单,因为主进程没有做任何事情,直接输出一句话结束,所以在这时也直接终止了子进程的运行。

这样可以有效防止无控制地生成子进程。这样的写法可以让我们在主进程运行结束后无需额外担心子进程是否关闭,避免了独立子进程的运行。

进程等待

上面的运行效果其实不太符合我们预期:主进程运行结束时,子进程(守护进程)也都退出了,子进程什么都没来得及执行。

能不能让所有子进程都执行完了然后再结束呢?当然是可以的,只需要加入 join 方法即可,我们可以将代码改写如下:

processes = []
			for i in range(2, 5):
			    p = MyProcess(i)
			    processes.append(p)
			    p.daemon = True
			    p.start()
			for p in processes:
			    p.join()
			

运行结果如下:

Pid: 40866 LoopCount: 0
			Pid: 40867 LoopCount: 0
			Pid: 40868 LoopCount: 0
			Pid: 40866 LoopCount: 1
			Pid: 40867 LoopCount: 1
			Pid: 40868 LoopCount: 1
			Pid: 40867 LoopCount: 2
			Pid: 40868 LoopCount: 2
			Pid: 40868 LoopCount: 3
			Main Process ended
			

在调用 start 和 join 方法后,父进程就可以等待所有子进程都执行完毕后,再打印出结束的结果。

默认情况下,join 是无限期的。也就是说,如果有子进程没有运行完毕,主进程会一直等待。这种情况下,如果子进程出现问题陷入了死循环,主进程也会无限等待下去。怎么解决这个问题呢?可以给 join 方法传递一个超时参数,代表最长等待秒数。如果子进程没有在这个指定秒数之内完成,会被强制返回,主进程不再会等待。也就是说这个参数设置了主进程等待该子进程的最长时间。

例如这里我们传入 1,代表最长等待 1 秒,代码改写如下:

processes = []
			for i in range(3, 5):
			    p = MyProcess(i)
			    processes.append(p)
			    p.daemon = True
			    p.start()
			for p in processes:
			    p.join(1)
			

运行结果如下:

Pid: 40970 LoopCount: 0
			Pid: 40971 LoopCount: 0
			Pid: 40970 LoopCount: 1
			Pid: 40971 LoopCount: 1
			Main Process ended
			

可以看到,有的子进程本来要运行 3 秒,结果运行 1 秒就被强制返回了,由于是守护进程,该子进程被终止了。

到这里,我们就了解了守护进程、进程等待和超时设置的用法。

终止进程

当然,终止进程不止有守护进程这一种做法,我们也可以通过 terminate 方法来终止某个子进程,另外我们还可以通过 is_alive 方法判断进程是否还在运行。

下面我们来看一个实例:

import multiprocessing
			import time

			def process():
			    print('Starting')
			    time.sleep(5)
			    print('Finished')

			if __name__ == '__main__':
			    p = multiprocessing.Process(target=process)
			    print('Before:', p, p.is_alive())

			    p.start()
			    print('During:', p, p.is_alive())

			    p.terminate()
			    print('Terminate:', p, p.is_alive())

			    p.join()
			    print('Joined:', p, p.is_alive())
			

在上面的例子中,我们用 Process 创建了一个进程,接着调用 start 方法启动这个进程,然后调用 terminate 方法将进程终止,最后调用 join 方法。

另外,在进程运行不同的阶段,我们还通过 is_alive 方法判断当前进程是否还在运行。

运行结果如下:

Before: <Process(Process-1, initial)> False
			During: <Process(Process-1, started)> True
			Terminate: <Process(Process-1, started)> True
			Joined: <Process(Process-1, stopped[SIGTERM])> False
			

这里有一个值得注意的地方,在调用 terminate 方法之后,我们用 is_alive 方法获取进程的状态发现依然还是运行状态。在调用 join 方法之后,is_alive 方法获取进程的运行状态才变为终止状态。

所以,在调用 terminate 方法之后,记得要调用一下 join 方法,这里调用 join 方法可以为进程提供时间来更新对象状态,用来反映出最终的进程终止效果。

进程互斥锁

在上面的一些实例中,我们可能会遇到如下的运行结果:

Pid: 73993 LoopCount: 0
			Pid: 73993 LoopCount: 1
			Pid: 73994 LoopCount: 0Pid: 73994 LoopCount: 1

			Pid: 73994 LoopCount: 2
			Pid: 73995 LoopCount: 0
			Pid: 73995 LoopCount: 1
			Pid: 73995 LoopCount: 2
			Pid: 73995 LoopCount: 3
			Main Process ended
			

我们发现,有的输出结果没有换行。这是什么原因造成的呢?

这种情况是由多个进程并行执行导致的,两个进程同时进行了输出,结果第一个进程的换行没有来得及输出,第二个进程就输出了结果,导致最终输出没有换行。

那如何来避免这种问题?如果我们能保证,多个进程运行期间的任一时间,只能一个进程输出,其他进程等待,等刚才那个进程输出完毕之后,另一个进程再进行输出,这样就不会出现输出没有换行的现象了。

这种解决方案实际上就是实现了进程互斥,避免了多个进程同时抢占临界区(输出)资源。我们可以通过 multiprocessing 中的 Lock 来实现。Lock,即锁,在一个进程输出时,加锁,其他进程等待。等此进程执行结束后,释放锁,其他进程可以进行输出。

我们首先实现一个不加锁的实例,代码如下:

from multiprocessing import Process, Lock
			import time

			class MyProcess(Process):
			    def __init__(self, loop, lock):
			        Process.__init__(self)
			        self.loop = loop
			        self.lock = lock

			    def run(self):
			        for count in range(self.loop):
			            time.sleep(0.1)
			            # self.lock.acquire()
			            print(f'Pid: {self.pid} LoopCount: {count}')
			            # self.lock.release()

			if __name__ == '__main__':
			    lock = Lock()
			    for i in range(10, 15):
			        p = MyProcess(i, lock)
			        p.start()
			

运行结果如下:

Pid: 74030 LoopCount: 0
			Pid: 74031 LoopCount: 0
			Pid: 74032 LoopCount: 0
			Pid: 74033 LoopCount: 0
			Pid: 74034 LoopCount: 0
			Pid: 74030 LoopCount: 1
			Pid: 74031 LoopCount: 1
			Pid: 74032 LoopCount: 1Pid: 74033 LoopCount: 1

			Pid: 74034 LoopCount: 1
			Pid: 74030 LoopCount: 2
			...
			

可以看到运行结果中有些输出已经出现了不换行的问题。

我们对其加锁,取消掉刚才代码中的两行注释,重新运行,运行结果如下:

Pid: 74061 LoopCount: 0
			Pid: 74062 LoopCount: 0
			Pid: 74063 LoopCount: 0
			Pid: 74064 LoopCount: 0
			Pid: 74065 LoopCount: 0
			Pid: 74061 LoopCount: 1
			Pid: 74062 LoopCount: 1
			Pid: 74063 LoopCount: 1
			Pid: 74064 LoopCount: 1
			Pid: 74065 LoopCount: 1
			Pid: 74061 LoopCount: 2
			Pid: 74062 LoopCount: 2
			Pid: 74064 LoopCount: 2
			...
			

这时输出效果就正常了。

所以,在访问一些临界区资源时,使用 Lock 可以有效避免进程同时占用资源而导致的一些问题。

信号量

进程互斥锁可以使同一时刻只有一个进程能访问共享资源,如上面的例子所展示的那样,在同一时刻只能有一个进程输出结果。但有时候我们需要允许多个进程来访问共享资源,同时还需要限制能访问共享资源的进程的数量。

这种需求该如何实现呢?可以用信号量,信号量是进程同步过程中一个比较重要的角色。它可以控制临界资源的数量,实现多个进程同时访问共享资源,限制进程的并发量。

如果你学过操作系统,那么一定对这方面非常了解,如果你还不了解信号量是什么,可以先熟悉一下这个概念。

我们可以用 multiprocessing 库中的 Semaphore 来实现信号量。

那么接下来我们就用一个实例来演示一下进程之间利用 Semaphore 做到多个进程共享资源,同时又限制同时可访问的进程数量,代码如下:

from multiprocessing import Process, Semaphore, Lock, Queue
			import time

			buffer = Queue(10)
			empty = Semaphore(2)
			full = Semaphore(0)
			lock = Lock()

			class Consumer(Process):
			    def run(self):
			        global buffer, empty, full, lock
			        while True:
			            full.acquire()
			            lock.acquire()
			            buffer.get()
			            print('Consumer pop an element')
			            time.sleep(1)
			            lock.release()
			            empty.release()

			class Producer(Process):
			    def run(self):
			        global buffer, empty, full, lock
			        while True:
			            empty.acquire()
			            lock.acquire()
			            buffer.put(1)
			            print('Producer append an element')
			            time.sleep(1)
			            lock.release()
			            full.release()

			if __name__ == '__main__':
			    p = Producer()
			    c = Consumer()
			    p.daemon = c.daemon = True
			    p.start()
			    c.start()
			    p.join()
			    c.join()
			    print('Main Process Ended')
			

如上代码实现了经典的生产者和消费者问题。它定义了两个进程类,一个是消费者,一个是生产者。

另外,这里使用 multiprocessing 中的 Queue 定义了一个共享队列,然后定义了两个信号量 Semaphore,一个代表缓冲区空余数,一个表示缓冲区占用数。

生产者 Producer 使用 acquire 方法来占用一个缓冲区位置,缓冲区空闲区大小减 1,接下来进行加锁,对缓冲区进行操作,然后释放锁,最后让代表占用的缓冲区位置数量加 1,消费者则相反。

运行结果如下:

Producer append an element
			Producer append an element
			Consumer pop an element
			Consumer pop an element
			Producer append an element
			Producer append an element
			Consumer pop an element
			Consumer pop an element
			Producer append an element
			Producer append an element
			Consumer pop an element
			Consumer pop an element
			Producer append an element
			Producer append an element
			

我们发现两个进程在交替运行,生产者先放入缓冲区物品,然后消费者取出,不停地进行循环。 你可以通过上面的例子来体会信号量 Semaphore 的用法,通过 Semaphore 我们很好地控制了进程对资源的并发访问数量。

队列

在上面的例子中我们使用 Queue 作为进程通信的共享队列使用。

而如果我们把上面程序中的 Queue 换成普通的 list,是完全起不到效果的,因为进程和进程之间的资源是不共享的。即使在一个进程中改变了这个 list,在另一个进程也不能获取到这个 list 的状态,所以声明全局变量对多进程是没有用处的。

那进程如何共享数据呢?可以用 Queue,即队列。当然这里的队列指的是 multiprocessing 里面的 Queue。

依然用上面的例子,我们一个进程向队列中放入随机数据,然后另一个进程取出数据。

from multiprocessing import Process, Semaphore, Lock, Queue
			import time
			from random import random

			buffer = Queue(10)
			empty = Semaphore(2)
			full = Semaphore(0)
			lock = Lock()

			class Consumer(Process):
			    def run(self):
			        global buffer, empty, full, lock
			        while True:
			            full.acquire()
			            lock.acquire()
			            print(f'Consumer get {buffer.get()}')
			            time.sleep(1)
			            lock.release()
			            empty.release()

			class Producer(Process):
			    def run(self):
			        global buffer, empty, full, lock
			        while True:
			            empty.acquire()
			            lock.acquire()
			            num = random()
			            print(f'Producer put {num}')
			            buffer.put(num)
			            time.sleep(1)
			            lock.release()
			            full.release()

			if __name__ == '__main__':
			    p = Producer()
			    c = Consumer()
			    p.daemon = c.daemon = True
			    p.start()
			    c.start()
			    p.join()
			    c.join()
			    print('Main Process Ended')
			

运行结果如下:

Producer put  0.719213647437
			Producer put  0.44287326683
			Consumer get 0.719213647437
			Consumer get 0.44287326683
			Producer put  0.722859424381
			Producer put  0.525321338921
			Consumer get 0.722859424381
			Consumer get 0.525321338921
			

在上面的例子中我们声明了两个进程,一个进程为生产者 Producer,另一个为消费者 Consumer,生产者不断向 Queue 里面添加随机数,消费者不断从队列里面取随机数。

生产者在放数据的时候调用了 Queue 的 put 方法,消费者在取的时候使用了 get 方法,这样我们就通过 Queue 实现两个进程的数据共享了。

管道

刚才我们使用 Queue 实现了进程间的数据共享,那么进程之间直接通信,如收发信息,用什么比较好呢?可以用 Pipe,管道。

管道,我们可以把它理解为两个进程之间通信的通道。管道可以是单向的,即 half-duplex:一个进程负责发消息,另一个进程负责收消息;也可以是双向的 duplex,即互相收发消息。

默认声明 Pipe 对象是双向管道,如果要创建单向管道,可以在初始化的时候传入 deplex 参数为 False。

我们用一个实例来感受一下:

from multiprocessing import Process, Pipe

			class Consumer(Process):
			    def __init__(self, pipe):
			        Process.__init__(self)
			        self.pipe = pipe

			    def run(self):
			        self.pipe.send('Consumer Words')
			        print(f'Consumer Received: {self.pipe.recv()}')

			class Producer(Process):
			    def __init__(self, pipe):
			        Process.__init__(self)
			        self.pipe = pipe

			    def run(self):
			        print(f'Producer Received: {self.pipe.recv()}')
			        self.pipe.send('Producer Words')

			if __name__ == '__main__':
			    pipe = Pipe()
			    p = Producer(pipe[0])
			    c = Consumer(pipe[1])
			    p.daemon = c.daemon = True
			    p.start()
			    c.start()
			    p.join()
			    c.join()
			    print('Main Process Ended')
			

在这个例子里我们声明了一个默认为双向的管道,然后将管道的两端分别传给两个进程。两个进程互相收发。观察一下结果:

Producer Received: Consumer Words
			Consumer Received: Producer Words
			Main Process Ended
			

管道 Pipe 就像进程之间搭建的桥梁,利用它我们就可以很方便地实现进程间通信了。

进程池

在前面,我们讲了可以使用 Process 来创建进程,同时也讲了如何用 Semaphore 来控制进程的并发执行数量。

假如现在我们遇到这么一个问题,我有 10000 个任务,每个任务需要启动一个进程来执行,并且一个进程运行完毕之后要紧接着启动下一个进程,同时我还需要控制进程的并发数量,不能并发太高,不然 CPU 处理不过来(如果同时运行的进程能维持在一个最高恒定值当然利用率是最高的)。

那么我们该如何来实现这个需求呢?

用 Process 和 Semaphore 可以实现,但是实现起来比较我们可以用 Process 和 Semaphore 解决问题,但是实现起来比较烦琐。而这种需求在平时又是非常常见的。此时,我们就可以派上进程池了,即 multiprocessing 中的 Pool。

Pool 可以提供指定数量的进程,供用户调用,当有新的请求提交到 pool 中时,如果池还没有满,就会创建一个新的进程用来执行该请求;但如果池中的进程数已经达到规定最大值,那么该请求就会等待,直到池中有进程结束,才会创建新的进程来执行它。

我们用一个实例来实现一下,代码如下:

from multiprocessing import Pool
			import time


			def function(index):
			    print(f'Start process: {index}')
			    time.sleep(3)
			    print(f'End process {index}', )


			if __name__ == '__main__':
			    pool = Pool(processes=3)
			    for i in range(4):
			        pool.apply_async(function, args=(i,))

			    print('Main Process started')
			    pool.close()
			    pool.join()
			    print('Main Process ended')
			

在这个例子中我们声明了一个大小为 3 的进程池,通过 processes 参数来指定,如果不指定,那么会自动根据处理器内核来分配进程数。接着我们使用 apply_async 方法将进程添加进去,args 可以用来传递参数。

运行结果如下:

Main Process started
			Start process: 0
			Start process: 1
			Start process: 2
			End process 0
			End process 1
			End process 2
			Start process: 3
			End process 3
			Main Process ended
			

进程池大小为 3,所以最初可以看到有 3 个进程同时执行,第4个进程在等待,在有进程运行完毕之后,第4个进程马上跟着运行,出现了如上的运行效果。

最后,我们要记得调用 close 方法来关闭进程池,使其不再接受新的任务,然后调用 join 方法让主进程等待子进程的退出,等子进程运行完毕之后,主进程接着运行并结束。

不过上面的写法多少有些烦琐,这里再介绍进程池一个更好用的 map 方法,可以将上述写法简化很多。

map 方法是怎么用的呢?第一个参数就是要启动的进程对应的执行方法,第 2 个参数是一个可迭代对象,其中的每个元素会被传递给这个执行方法。

举个例子:现在我们有一个 list,里面包含了很多 URL,另外我们也定义了一个方法用来抓取每个 URL 内容并解析,那么我们可以直接在 map 的第一个参数传入方法名,第 2 个参数传入 URL 数组。

我们用一个实例来感受一下:

from multiprocessing import Pool
			import urllib.request
			import urllib.error


			def scrape(url):
			    try:
			        urllib.request.urlopen(url)
			        print(f'URL {url} Scraped')
			    except (urllib.error.HTTPError, urllib.error.URLError):
			        print(f'URL {url} not Scraped')


			if __name__ == '__main__':
			    pool = Pool(processes=3)
			    urls = [
			        'https://www.baidu.com',
			        'http://www.meituan.com/',
			        'http://blog.csdn.net/',
			        'http://xxxyxxx.net'
			    ]
			    pool.map(scrape, urls)
			    pool.close()
			

这个例子中我们先定义了一个 scrape 方法,它接收一个参数 url,这里就是请求了一下这个链接,然后输出爬取成功的信息,如果发生错误,则会输出爬取失败的信息。

首先我们要初始化一个 Pool,指定进程数为 3。然后我们声明一个 urls 列表,接着我们调用了 map 方法,第 1 个参数就是进程对应的执行方法,第 2 个参数就是 urls 列表,map 方法会依次将 urls 的每个元素作为 scrape 的参数传递并启动一个新的进程,加到进程池中执行。

运行结果如下:

URL https://www.baidu.com Scraped
			URL http://xxxyxxx.net not Scraped
			URL http://blog.csdn.net/ Scraped
			URL http://www.meituan.com/ Scraped
			

这样,我们就可以实现 3 个进程并行运行。不同的进程相互独立地输出了对应的爬取结果。

可以看到,我们利用 Pool 的 map 方法非常方便地实现了多进程的执行。后面我们也会在实战案例中结合进程池来实现数据的爬取。

以上便是 Python 中多进程的基本用法,本节内容比较多,后面的实战案例也会用到这些内容,需要好好掌握。

第07讲:入门首选,Requests 库的基本使用

上一课时我们了解了一些学习爬虫所需要的基本知识。从本课时开始,我们正式步入Python 爬虫的大门。

学习爬虫,最基础的便是模拟浏览器向服务器发出请求,那么我们需要从什么地方做起呢?请求需要我们自己来构造吗?需要关心请求这个数据结构的实现吗?需要了解 HTTP、TCP、IP 层的网络传输通信吗?需要知道服务器的响应和应答原理吗?

可能你无从下手,不过不用担心,Python 的强大之处就是提供了功能齐全的类库来帮助我们完成这些请求。利用 Python 现有的库我们可以非常方便地实现网络请求的模拟,常见的库有 urllib、requests 等。

拿 requests 这个库来说,有了它,我们只需要关心请求的链接是什么,需要传的参数是什么,以及如何设置可选的参数就好了,不用深入到底层去了解它到底是怎样传输和通信的。有了它,两行代码就可以完成一个请求和响应的处理过程,非常方便地得到网页内容。

接下来,就让我们用 Python 的 requests 库开始我们的爬虫之旅吧。

安装

首先,requests 库是 Python 的一个第三方库,不是自带的。所以我们需要额外安装。

在这之前需要你先安装好 Python3 环境,如 Python 3.6 版本,如若没有安装可以参考:https://cuiqingcai.com/5059.html

安装好 Python3 之后,我们使用 pip3 即可轻松地安装好 requests 库:

pip3 install requests
			

更详细的安装方式可以参考:https://cuiqingcai.com/5132.html

安装完成之后,我们就可以开始我们的网络爬虫之旅了。

实例引入

用 Python 写爬虫的第一步就是模拟发起一个请求,把网页的源代码获取下来。

当我们在浏览器中输入一个 URL 并回车,实际上就是让浏览器帮我们发起一个 GET 类型的 HTTP 请求,浏览器得到源代码后,把它渲染出来就可以看到网页内容了。

那如果我们想用 requests 来获取源代码,应该怎么办呢?很简单,requests 这个库提供了一个 get 方法,我们调用这个方法,并传入对应的 URL 就能得到网页的源代码。

比如这里有一个示例网站:https://static1.scrape.cuiqingcai.com/,其内容如下:

这个网站展示了一些电影数据,如果我们想要把这个网页里面的数据爬下来,比如获取各个电影的名称、上映时间等信息,然后把它存下来的话,该怎么做呢?

第一步当然就是获取它的网页源代码了。

我们可以用 requests 这个库轻松地完成这个过程,代码的写法是这样的:

import requests  

			r = requests.get('https://static1.scrape.cuiqingcai.com/')  
			print(r.text)
			

运行结果如下:

<html lang="en">
			<head>
			  <meta charset="utf-8">
			  <meta http-equiv="X-UA-Compatible" content="IE=edge">
			  <meta name="viewport" content="width=device-width,initial-scale=1">
			  <link rel="icon" href="/static/img/favicon.ico">
			  <title>Scrape | Movie</title>
			  <link href="/static/css/app.css" type="text/css" rel="stylesheet">
			  <link href="/static/css/index.css" type="text/css" rel="stylesheet">
			</head>
			<body>
			<div id="app">
			...
			<div data-v-7f856186="" id="index">
			  <div data-v-7f856186="" class="el-row">
			    <div data-v-7f856186="" class="el-col el-col-18 el-col-offset-3">
			      <div data-v-7f856186="" class="el-card item m-t is-hover-shadow">
			        <div class="el-card__body">
			          <div data-v-7f856186="" class="el-row">
			            <div data-v-7f856186="" class="el-col el-col-24 el-col-xs-8 el-col-sm-6 el-col-md-4">
			              <a data-v-7f856186=""
			                 href="/detail/1"
			                 class="">
			                <img
			                    data-v-7f856186="" src="https://p0.meituan.net/movie/283292171619cdfd5b240c8fd093f1eb255670.jpg@464w_644h_1e_1c"
			                    class="cover">
			              </a>
			            </div>
			            <div data-v-7f856186="" class="p-h el-col el-col-24 el-col-xs-9 el-col-sm-13 el-col-md-16">
			              <a data-v-7f856186="" href="/detail/1" class="">
			                <h2 data-v-7f856186="" class="m-b-sm">肖申克的救赎 - The Shawshank Redemption</h2>
			              </a>
			              <div data-v-7f856186="" class="categories">
			                <button data-v-7f856186="" type="button"
			                        class="el-button category el-button--primary el-button--mini">
			                  <span>剧情</span>
			                </button>
			                <button data-v-7f856186="" type="button"
			                        class="el-button category el-button--primary el-button--mini">
			                  <span>犯罪</span>
			                </button>
			              </div>
			              <div data-v-7f856186="" class="m-v-sm info">
			                <span data-v-7f856186="">美国</span>
			                <span data-v-7f856186=""> / </span>
			                <span data-v-7f856186="">142 分钟</span>
			              </div>
			              <div data-v-7f856186="" class="m-v-sm info">
			                <span data-v-7f856186="">1994-09-10 上映</span>
			              </div>
			            </div>
			          </div>
			        </div>
			      </div>
			    </div>
			  </div>
			  ...
			</div>
			</div>
			</body>
			

由于网页内容比较多,这里省略了大部分内容。

不过看运行结果,我们已经成功获取网页的 HTML 源代码,里面包含了电影的标题、类型、上映时间,等等。把网页源代码获取下来之后,下一步我们把想要的数据提取出来,数据的爬取就完成了。

这个实例的目的是让你体会一下 requests 这个库能帮我们实现什么功能。我们仅仅用 requests 的 get 方法就成功发起了一个 GET 请求,把网页源代码获取下来了,是不是很方便呢?

请求

HTTP 中最常见的请求之一就是 GET 请求,下面我们来详细了解利用 requests 库构建 GET 请求的方法。

GET 请求

我们换一个示例网站,其 URL 为 http://httpbin.org/get,如果客户端发起的是 GET 请求的话,该网站会判断并返回相应的请求信息,包括 Headers、IP 等。

我们还是用相同的方法来发起一个 GET 请求,代码如下:

import requests  

			r = requests.get('http://httpbin.org/get')  
			print(r.text)
			

运行结果如下:

{"args": {},   
			  "headers": {  
			    "Accept": "*/*",   
			    "Accept-Encoding": "gzip, deflate",   
			    "Host": "httpbin.org",   
			    "User-Agent": "python-requests/2.10.0"  
			  },   
			  "origin": "122.4.215.33",   
			  "url": "http://httpbin.org/get"  
			}
			

可以发现,我们成功发起了 GET 请求,也通过这个网站的返回结果得到了请求所携带的信息,包括 Headers、URL、IP,等等。

对于 GET 请求,我们知道 URL 后面是可以跟上一些参数的,如果我们现在想添加两个参数,其中 name 是 germey,age 是 25,URL 就可以写成如下内容:

http://httpbin.org/get?name=germey&age=25
			

要构造这个请求链接,是不是要直接写成这样呢?

r = requests.get('http://httpbin.org/get?name=germey&age=25')
			

这样也可以,但如果这些参数还需要我们手动拼接,未免有点不人性化。

一般情况下,这种信息我们利用 params 这个参数就可以直接传递了,示例如下:

import requests  

			data = {  
			    'name': 'germey',  
			    'age': 25
			}  
			r = requests.get('http://httpbin.org/get', params=data)  
			print(r.text)
			

运行结果如下:

{  
			  "args": {  
			    "age": "25",
			    "name": "germey"  
			  },   
			  "headers": {  
			    "Accept": "*/*",   
			    "Accept-Encoding": "gzip, deflate",   
			    "Host": "httpbin.org",   
			    "User-Agent": "python-requests/2.10.0"  
			  },   
			  "origin": "122.4.215.33",   
			  "url": "http://httpbin.org/get?age=22&name=germey"  
			}
			

在这里我们把 URL 参数通过字典的形式传给 get 方法的 params 参数,通过返回信息我们可以判断,请求的链接自动被构造成了:http://httpbin.org/get?age=22&name=germey,这样我们就不用再去自己构造 URL 了,非常方便。

另外,网页的返回类型实际上是 str 类型,但是它很特殊,是 JSON 格式的。所以,如果想直接解析返回结果,得到一个 JSON 格式的数据的话,可以直接调用 json 方法。

示例如下:

import requests  

			r = requests.get('http://httpbin.org/get')  
			print(type(r.text))  
			print(r.json())  
			print(type(r.json()))
			

运行结果如下:

<class'str'>
			{'headers': {'Accept-Encoding': 'gzip, deflate', 'Accept': '*/*', 'Host': 'httpbin.org', 'User-Agent': 'python-requests/2.10.0'}, 'url': 'http://httpbin.org/get', 'args': {}, 'origin': '182.33.248.131'}
			<class 'dict'>
			

可以发现,调用 json 方法,就可以将返回结果是 JSON 格式的字符串转化为字典。

但需要注意的是,如果返回结果不是 JSON 格式,便会出现解析错误,抛出 json.decoder.JSONDecodeError 异常。

抓取网页

上面的请求链接返回的是 JSON 形式的字符串,那么如果请求普通的网页,则肯定能获得相应的内容了。下面以本课时最初的实例页面为例,我们再加上一点提取信息的逻辑,将代码完善成如下的样子:

import requests
			import re

			r = requests.get('https://static1.scrape.cuiqingcai.com/')
			pattern = re.compile('<h2.*?>(.*?)</h2>', re.S)
			titles = re.findall(pattern, r.text)
			print(titles)
			

在这个例子中我们用到了最基础的正则表达式来匹配出所有的标题。关于正则表达式的相关内容,我们会在下一课时详细介绍,这里作为实例来配合讲解。

运行结果如下:

['肖申克的救赎 - The Shawshank Redemption', '霸王别姬 - Farewell My Concubine', '泰坦尼克号 - Titanic', '罗马假日 - Roman Holiday', '这个杀手不太冷 - Léon', '魂断蓝桥 - Waterloo Bridge', '唐伯虎点秋香 - Flirting Scholar', '喜剧之王 - The King of Comedy', '楚门的世界 - The Truman Show', '活着 - To Live']
			

我们发现,这里成功提取出了所有的电影标题。一个最基本的抓取和提取流程就完成了。

抓取二进制数据

在上面的例子中,我们抓取的是网站的一个页面,实际上它返回的是一个 HTML 文档。如果想抓取图片、音频、视频等文件,应该怎么办呢?

图片、音频、视频这些文件本质上都是由二进制码组成的,由于有特定的保存格式和对应的解析方式,我们才可以看到这些形形色色的多媒体。所以,想要抓取它们,就要拿到它们的二进制数据。

下面以 GitHub 的站点图标为例来看一下:

import requests

			r = requests.get('https://github.com/favicon.ico')
			print(r.text)
			print(r.content)
			

这里抓取的内容是站点图标,也就是在浏览器每一个标签上显示的小图标,如图所示:

这里打印了 Response 对象的两个属性,一个是 text,另一个是 content。

运行结果如图所示,其中前两行是 r.text 的结果,最后一行是 r.content 的结果。

可以注意到,前者出现了乱码,后者结果前带有一个 b,这代表是 bytes 类型的数据。

由于图片是二进制数据,所以前者在打印时转化为 str 类型,也就是图片直接转化为字符串,这当然会出现乱码。

上面返回的结果我们并不能看懂,它实际上是图片的二进制数据,没关系,我们将刚才提取到的信息保存下来就好了,代码如下:

import requests

			r = requests.get('https://github.com/favicon.ico')
			with open('favicon.ico', 'wb') as f:
			    f.write(r.content)
			

这里用了 open 方法,它的第一个参数是文件名称,第二个参数代表以二进制的形式打开,可以向文件里写入二进制数据。

运行结束之后,可以发现在文件夹中出现了名为 favicon.ico 的图标,如图所示。

这样,我们就把二进制数据成功保存成一张图片了,这个小图标就被我们成功爬取下来了。

同样地,音频和视频文件我们也可以用这种方法获取。

添加 headers

我们知道,在发起一个 HTTP 请求的时候,会有一个请求头 Request Headers,那么这个怎么来设置呢?

很简单,我们使用 headers 参数就可以完成了。

在刚才的实例中,实际上我们是没有设置 Request Headers 信息的,如果不设置,某些网站会发现这不是一个正常的浏览器发起的请求,网站可能会返回异常的结果,导致网页抓取失败。

要添加 Headers 信息,比如我们这里想添加一个 User-Agent 字段,我们可以这么来写:

import requests


			headers = {
			    'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.116 Safari/537.36'
			}
			r = requests.get('https://static1.scrape.cuiqingcai.com/', headers=headers)
			print(r.text)
			

当然,我们可以在 headers 这个参数中任意添加其他的字段信息。

POST 请求

前面我们了解了最基本的 GET 请求,另外一种比较常见的请求方式是 POST。使用 requests 实现 POST 请求同样非常简单,示例如下:

import requests

			data = {'name': 'germey', 'age': '25'}
			r = requests.post("http://httpbin.org/post", data=data)
			print(r.text)
			

这里还是请求 http://httpbin.org/post,该网站可以判断如果请求是 POST 方式,就把相关请求信息返回。

运行结果如下:

{
			  "args": {}, 
			  "data": "", 
			  "files": {}, 
			  "form": {
			    "age": "25", 
			    "name": "germey"
			  }, 
			  "headers": {
			    "Accept": "*/*", 
			    "Accept-Encoding": "gzip, deflate", 
			    "Content-Length": "18", 
			    "Content-Type": "application/x-www-form-urlencoded", 
			    "Host": "httpbin.org", 
			    "User-Agent": "python-requests/2.22.0", 
			    "X-Amzn-Trace-Id": "Root=1-5e5bdc26-b40d7e9862e3715f689cb5e6"
			  }, 
			  "json": null, 
			  "origin": "167.220.232.237", 
			  "url": "http://httpbin.org/post"
			}
			

可以发现,我们成功获得了返回结果,其中 form 部分就是提交的数据,这就证明 POST 请求成功发送了。

响应

发送请求后,得到的自然就是响应,即 Response。

在上面的实例中,我们使用 text 和 content 获取了响应的内容。此外,还有很多属性和方法可以用来获取其他信息,比如状态码、响应头、Cookies 等。示例如下:

import requests

			r = requests.get('https://static1.scrape.cuiqingcai.com/')
			print(type(r.status_code), r.status_code)
			print(type(r.headers), r.headers)
			print(type(r.cookies), r.cookies)
			print(type(r.url), r.url)
			print(type(r.history), r.history)
			

这里分别打印输出 status_code 属性得到状态码,输出 headers 属性得到响应头,输出 cookies 属性得到 Cookies,输出 url 属性得到 URL,输出 history 属性得到请求历史。

运行结果如下:

<class 'int'> 200
			<class 'requests.structures.CaseInsensitiveDict'> {'Server': 'nginx/1.17.8', 'Date': 'Sun, 01 Mar 2020 13:31:54 GMT', 'Content-Type': 'text/html; charset=utf-8', 'Transfer-Encoding': 'chunked', 'Connection': 'keep-alive', 'Vary': 'Accept-Encoding', 'X-Frame-Options': 'SAMEORIGIN', 'Strict-Transport-Security': 'max-age=15724800; includeSubDomains', 'Content-Encoding': 'gzip'}
			<class 'requests.cookies.RequestsCookieJar'> <RequestsCookieJar[]>
			<class 'str'> https://static1.scrape.cuiqingcai.com/
			<class 'list'> []
			

可以看到,headers 和 cookies 这两个属性得到的结果分别是 CaseInsensitiveDict 和 RequestsCookieJar 类型。

在第一课时我们知道,状态码是用来表示响应状态的,比如返回 200 代表我们得到的响应是没问题的,上面的例子正好输出的结果也是 200,所以我们可以通过判断 Response 的状态码来确认是否爬取成功。

requests 还提供了一个内置的状态码查询对象 requests.codes,用法示例如下:

import requests

			r = requests.get('https://static1.scrape.cuiqingcai.com/')
			exit() if not r.status_code == requests.codes.ok else print('Request Successfully')
			

这里通过比较返回码和内置的成功的返回码,来保证请求得到了正常响应,输出成功请求的消息,否则程序终止,这里我们用 requests.codes.ok 得到的是成功的状态码 200。

这样的话,我们就不用再在程序里面写状态码对应的数字了,用字符串表示状态码会显得更加直观。

当然,肯定不能只有 ok 这个条件码。

下面列出了返回码和相应的查询条件:

# 信息性状态码  
			100: ('continue',),  
			101: ('switching_protocols',),  
			102: ('processing',),  
			103: ('checkpoint',),  
			122: ('uri_too_long', 'request_uri_too_long'),  

			# 成功状态码  
			200: ('ok', 'okay', 'all_ok', 'all_okay', 'all_good', '\\o/', '✓'),  
			201: ('created',),  
			202: ('accepted',),  
			203: ('non_authoritative_info', 'non_authoritative_information'),  
			204: ('no_content',),  
			205: ('reset_content', 'reset'),  
			206: ('partial_content', 'partial'),  
			207: ('multi_status', 'multiple_status', 'multi_stati', 'multiple_stati'),  
			208: ('already_reported',),  
			226: ('im_used',),  

			# 重定向状态码  
			300: ('multiple_choices',),  
			301: ('moved_permanently', 'moved', '\\o-'),  
			302: ('found',),  
			303: ('see_other', 'other'),  
			304: ('not_modified',),  
			305: ('use_proxy',),  
			306: ('switch_proxy',),  
			307: ('temporary_redirect', 'temporary_moved', 'temporary'),  
			308: ('permanent_redirect',  
			      'resume_incomplete', 'resume',), # These 2 to be removed in 3.0  

			# 客户端错误状态码  
			400: ('bad_request', 'bad'),  
			401: ('unauthorized',),  
			402: ('payment_required', 'payment'),  
			403: ('forbidden',),  
			404: ('not_found', '-o-'),  
			405: ('method_not_allowed', 'not_allowed'),  
			406: ('not_acceptable',),  
			407: ('proxy_authentication_required', 'proxy_auth', 'proxy_authentication'),  
			408: ('request_timeout', 'timeout'),  
			409: ('conflict',),  
			410: ('gone',),  
			411: ('length_required',),  
			412: ('precondition_failed', 'precondition'),  
			413: ('request_entity_too_large',),  
			414: ('request_uri_too_large',),  
			415: ('unsupported_media_type', 'unsupported_media', 'media_type'),  
			416: ('requested_range_not_satisfiable', 'requested_range', 'range_not_satisfiable'),  
			417: ('expectation_failed',),  
			418: ('im_a_teapot', 'teapot', 'i_am_a_teapot'),  
			421: ('misdirected_request',),  
			422: ('unprocessable_entity', 'unprocessable'),  
			423: ('locked',),  
			424: ('failed_dependency', 'dependency'),  
			425: ('unordered_collection', 'unordered'),  
			426: ('upgrade_required', 'upgrade'),  
			428: ('precondition_required', 'precondition'),  
			429: ('too_many_requests', 'too_many'),  
			431: ('header_fields_too_large', 'fields_too_large'),  
			444: ('no_response', 'none'),  
			449: ('retry_with', 'retry'),  
			450: ('blocked_by_windows_parental_controls', 'parental_controls'),  
			451: ('unavailable_for_legal_reasons', 'legal_reasons'),  
			499: ('client_closed_request',),  

			# 服务端错误状态码  
			500: ('internal_server_error', 'server_error', '/o\\', '✗'),  
			501: ('not_implemented',),  
			502: ('bad_gateway',),  
			503: ('service_unavailable', 'unavailable'),  
			504: ('gateway_timeout',),  
			505: ('http_version_not_supported', 'http_version'),  
			506: ('variant_also_negotiates',),  
			507: ('insufficient_storage',),  
			509: ('bandwidth_limit_exceeded', 'bandwidth'),  
			510: ('not_extended',),  
			511: ('network_authentication_required', 'network_auth', 'network_authentication')
			

比如,如果想判断结果是不是 404 状态,可以用 requests.codes.not_found 来比对。

高级用法

刚才,我们了解了 requests 的基本用法,如基本的 GET、POST 请求以及 Response 对象。当然 requests 能做到的不仅这些,它几乎可以帮我们完成 HTTP 的所有操作。

下面我们再来了解下 requests 的一些高级用法,如文件上传、Cookies 设置、代理设置等。

文件上传

我们知道 requests 可以模拟提交一些数据。假如有的网站需要上传文件,我们也可以用它来实现,示例如下:

import requests

			files = {'file': open('favicon.ico', 'rb')}
			r = requests.post('http://httpbin.org/post', files=files)
			print(r.text)
			

在上一课时中我们保存了一个文件 favicon.ico,这次用它来模拟文件上传的过程。需要注意的是,favicon.ico 需要和当前脚本在同一目录下。如果有其他文件,当然也可以使用其他文件来上传,更改下代码即可。

运行结果如下:

{"args": {}, 
			  "data": "","files": {"file":"data:application/octet-stream;base64,AAAAAA...="},"form": {},"headers": {"Accept":"*/*","Accept-Encoding":"gzip, deflate","Content-Length":"6665","Content-Type":"multipart/form-data; boundary=809f80b1a2974132b133ade1a8e8e058","Host":"httpbin.org","User-Agent":"python-requests/2.10.0"},"json": null,"origin":"60.207.237.16","url":"http://httpbin.org/post"}
			

以上省略部分内容,这个网站会返回响应,里面包含 files 这个字段,而 form 字段是空的,这证明文件上传部分会单独有一个 files 字段来标识。

Cookies

我们如果想用 requests 获取和设置 Cookies 也非常方便,只需一步即可完成。

我们先用一个实例看一下获取 Cookies 的过程:

import requests

			r = requests.get('http://www.baidu.com')
			print(r.cookies)
			for key, value in r.cookies.items():
			    print(key + '=' + value)
			

运行结果如下:

<RequestsCookieJar[<Cookie BDORZ=27315 for .baidu.com/>]>
			BDORZ=27315
			

这里我们首先调用 cookies 属性即可成功得到 Cookies,可以发现它是 RequestCookieJar 类型。然后用 items 方法将其转化为元组组成的列表,遍历输出每一个 Cookie 的名称和值,实现 Cookie 的遍历解析。

当然,我们也可以直接用 Cookie 来维持登录状态,下面我们以 GitHub 为例来说明一下,首先我们登录 GitHub,然后将 Headers 中的 Cookie 内容复制下来,如图所示:


这里可以替换成你自己的 Cookie,将其设置到 Headers 里面,然后发送请求,示例如下:

import requests

			headers = {
			    'Cookie': '_octo=GH1.1.1849343058.1576602081; _ga=GA1.2.90460451.1576602111; __Host-user_session_same_site=nbDv62kHNjp4N5KyQNYZ208waeqsmNgxFnFC88rnV7gTYQw_; _device_id=a7ca73be0e8f1a81d1e2ebb5349f9075; user_session=nbDv62kHNjp4N5KyQNYZ208waeqsmNgxFnFC88rnV7gTYQw_; logged_in=yes; dotcom_user=Germey; tz=Asia%2FShanghai; has_recent_activity=1; _gat=1; _gh_sess=your_session_info',
			    'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.116 Safari/537.36',
			}
			r = requests.get('https://github.com/', headers=headers)
			print(r.text)
			

我们发现,结果中包含了登录后才能显示的结果,如图所示:


可以看到这里包含了我的 GitHub 用户名信息,你如果尝试同样可以得到你的用户信息。

得到这样类似的结果,说明我们用 Cookies 成功模拟了登录状态,这样我们就能爬取登录之后才能看到的页面了。

当然,我们也可以通过 cookies 参数来设置 Cookies 的信息,这里我们可以构造一个 RequestsCookieJar 对象,然后把刚才复制的 Cookie 处理下并赋值,示例如下:

import requests

			cookies = '_octo=GH1.1.1849343058.1576602081; _ga=GA1.2.90460451.1576602111; __Host-user_session_same_site=nbDv62kHNjp4N5KyQNYZ208waeqsmNgxFnFC88rnV7gTYQw_; _device_id=a7ca73be0e8f1a81d1e2ebb5349f9075; user_session=nbDv62kHNjp4N5KyQNYZ208waeqsmNgxFnFC88rnV7gTYQw_; logged_in=yes; dotcom_user=Germey; tz=Asia%2FShanghai; has_recent_activity=1; _gat=1; _gh_sess=your_session_info'
			jar = requests.cookies.RequestsCookieJar()
			headers = {
			    'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.116 Safari/537.36'
			}
			for cookie in cookies.split(';'):
			    key, value = cookie.split('=', 1)
			    jar.set(key, value)
			r = requests.get('https://github.com/', cookies=jar, headers=headers)
			print(r.text)
			

这里我们首先新建一个 RequestCookieJar 对象,然后将复制下来的 cookies 利用 split 方法分割,接着利用 set 方法设置好每个 Cookie 的 key 和 value,最后通过调用 requests 的 get 方法并传递给 cookies 参数即可。

测试后,发现同样可以正常登录。

Session 维持

在 requests 中,如果直接利用 get 或 post 等方法的确可以做到模拟网页的请求,但是这实际上是相当于不同的 Session,相当于你用两个浏览器打开了不同的页面。

设想这样一个场景,第一个请求利用 post 方法登录了某个网站,第二次想获取成功登录后的自己的个人信息,你又用了一次 get 方法去请求个人信息页面。实际上,这相当于打开了两个浏览器,是两个完全不相关的 Session,能成功获取个人信息吗?当然不能。

有人会问,我在两次请求时设置一样的 Cookies 不就行了?可以,但这样做起来很烦琐,我们有更简单的解决方法。

解决这个问题的主要方法就是维持同一个 Session,相当于打开一个新的浏览器选项卡而不是新开一个浏览器。但我又不想每次设置 Cookies,那该怎么办呢?这时候就有了新的利器 ——Session 对象。

利用它,我们可以方便地维护一个 Session,而且不用担心 Cookies 的问题,它会帮我们自动处理好。示例如下:

import requests

			requests.get('http://httpbin.org/cookies/set/number/123456789')
			r = requests.get('http://httpbin.org/cookies')
			print(r.text)
			

这里我们请求了一个测试网址 http://httpbin.org/cookies/set/number/123456789。请求这个网址时,可以设置一个 cookie,名称叫作 number,内容是 123456789,随后又请求了 http://httpbin.org/cookies,此网址可以获取当前的 Cookies。

这样能成功获取到设置的 Cookies 吗?试试看。

运行结果如下:

{
			  "cookies": {}
			}
			

这并不行。我们再用 Session 试试看:

import requests

			s = requests.Session()
			s.get('http://httpbin.org/cookies/set/number/123456789')
			r = s.get('http://httpbin.org/cookies')
			print(r.text)
			

再看下运行结果:

{
			  "cookies": {"number": "123456789"}
			}
			

成功获取!这下能体会到同一个Session和不同Session的区别了吧!

所以,利用 Session,可以做到模拟同一个 Session 而不用担心 Cookies 的问题。它通常用于模拟登录成功之后再进行下一步的操作。

SSL 证书验证

现在很多网站都要求使用 HTTPS 协议,但是有些网站可能并没有设置好 HTTPS 证书,或者网站的 HTTPS 证书不被 CA 机构认可,这时候,这些网站可能就会出现 SSL 证书错误的提示。

比如这个示例网站:https://static2.scrape.cuiqingcai.com/

如果我们用 Chrome 浏览器打开这个 URL,则会提示「您的连接不是私密连接」这样的错误,如图所示:

我们可以在浏览器中通过一些设置来忽略证书的验证。

但是如果我们想用 requests 来请求这类网站,会遇到什么问题呢?我们用代码来试一下:

import requests

			response = requests.get('https://static2.scrape.cuiqingcai.com/')
			print(response.status_code)
			

运行结果如下:

requests.exceptions.SSLError: HTTPSConnectionPool(host='static2.scrape.cuiqingcai.com', port=443): Max retries exceeded with url: / (Caused by SSLError(SSLError("bad handshake: Error([('SSL routines', 'tls_process_server_certificate', 'certificate verify failed')])")))
			

可以看到,这里直接抛出了 SSLError 错误,原因就是因为我们请求的 URL 的证书是无效的。

那如果我们一定要爬取这个网站怎么办呢?我们可以使用 verify 参数控制是否验证证书,如果将其设置为 False,在请求时就不会再验证证书是否有效。如果不加 verify 参数的话,默认值是 True,会自动验证。

我们改写代码如下:

import requests

			response = requests.get('https://static2.scrape.cuiqingcai.com/', verify=False)
			print(response.status_code)
			

这样就会打印出请求成功的状态码:

/usr/local/lib/python3.7/site-packages/urllib3/connectionpool.py:857: InsecureRequestWarning: Unverified HTTPS request is being made. Adding certificate verification is strongly advised. See: https://urllib3.readthedocs.io/en/latest/advanced-usage.html#ssl-warnings
			  InsecureRequestWarning)
			200
			

不过我们发现报了一个警告,它建议我们给它指定证书。我们可以通过设置忽略警告的方式来屏蔽这个警告:

import requests
			from requests.packages import urllib3

			urllib3.disable_warnings()
			response = requests.get('https://static2.scrape.cuiqingcai.com/', verify=False)
			print(response.status_code)
			

或者通过捕获警告到日志的方式忽略警告:

import logging
			import requests
			logging.captureWarnings(True)
			response = requests.get('https://static2.scrape.cuiqingcai.com/', verify=False)
			print(response.status_code)
			

当然,我们也可以指定一个本地证书用作客户端证书,这可以是单个文件(包含密钥和证书)或一个包含两个文件路径的元组:

import requests

			response = requests.get('https://static2.scrape.cuiqingcai.com/', cert=('/path/server.crt', '/path/server.key'))
			print(response.status_code)
			

当然,上面的代码是演示实例,我们需要有 crt 和 key 文件,并且指定它们的路径。另外注意,本地私有证书的 key 必须是解密状态,加密状态的 key 是不支持的。

超时设置

在本机网络状况不好或者服务器网络响应延迟甚至无响应时,我们可能会等待很久才能收到响应,甚至到最后收不到响应而报错。为了防止服务器不能及时响应,应该设置一个超时时间,即超过了这个时间还没有得到响应,那就报错。这需要用到 timeout 参数。这个时间的计算是发出请求到服务器返回响应的时间。示例如下:

import requests

			r = requests.get('https://httpbin.org/get', timeout=1)
			print(r.status_code)
			

通过这样的方式,我们可以将超时时间设置为 1 秒,如果 1 秒内没有响应,那就抛出异常。

实际上,请求分为两个阶段,即连接(connect)和读取(read)。

上面设置的 timeout 将用作连接和读取这二者的 timeout 总和。

如果要分别指定,就可以传入一个元组:

r = requests.get('https://httpbin.org/get', timeout=(5, 30))
			

如果想永久等待,可以直接将 timeout 设置为 None,或者不设置直接留空,因为默认是 None。这样的话,如果服务器还在运行,但是响应特别慢,那就慢慢等吧,它永远不会返回超时错误的。其用法如下:

r = requests.get('https://httpbin.org/get', timeout=None)
			

或直接不加参数:

r = requests.get('https://httpbin.org/get')
			

身份认证

在访问某些设置了身份认证的网站时,例如:https://static3.scrape.cuiqingcai.com/,我们可能会遇到这样的认证窗口,如图所示:

如果遇到了这种情况,那就是这个网站启用了基本身份认证,英文叫作 HTTP Basic Access Authentication,它是一种用来允许网页浏览器或其他客户端程序在请求时提供用户名和口令形式的身份凭证的一种登录验证方式。

如果遇到了这种情况,怎么用 reqeusts 来爬取呢,当然也有办法。

我们可以使用 requests 自带的身份认证功能,通过 auth 参数即可设置,示例如下:

import requests  
			from requests.auth import HTTPBasicAuth  

			r = requests.get('https://static3.scrape.cuiqingcai.com/', auth=HTTPBasicAuth('admin', 'admin'))  
			print(r.status_code)
			

这个示例网站的用户名和密码都是 admin,在这里我们可以直接设置。

如果用户名和密码正确的话,请求时会自动认证成功,返回 200 状态码;如果认证失败,则返回 401 状态码。

当然,如果参数都传一个 HTTPBasicAuth 类,就显得有点烦琐了,所以 requests 提供了一个更简单的写法,可以直接传一个元组,它会默认使用 HTTPBasicAuth 这个类来认证。

所以上面的代码可以直接简写如下:

import requests

			r = requests.get('https://static3.scrape.cuiqingcai.com/', auth=('admin', 'admin'))
			print(r.status_code)
			

此外,requests 还提供了其他认证方式,如 OAuth 认证,不过此时需要安装 oauth 包,安装命令如下:

pip3 install requests_oauthlib
			

使用 OAuth1 认证的方法如下:

import requests
			from requests_oauthlib import OAuth1

			url = 'https://api.twitter.com/1.1/account/verify_credentials.json'
			auth = OAuth1('YOUR_APP_KEY', 'YOUR_APP_SECRET',
			              'USER_OAUTH_TOKEN', 'USER_OAUTH_TOKEN_SECRET')
			requests.get(url, auth=auth)
			

更多详细的功能就可以参考 requests_oauthlib 的官方文档:https://requests-oauthlib.readthedocs.org/,在此就不再赘述了。

代理设置

某些网站在测试的时候请求几次,能正常获取内容。但是对于大规模且频繁的请求,网站可能会弹出验证码,或者跳转到登录认证页面,更甚者可能会直接封禁客户端的 IP,导致一定时间段内无法访问。

为了防止这种情况发生,我们需要设置代理来解决这个问题,这就需要用到 proxies 参数。可以用这样的方式设置:

import requests

			proxies = {
			  'http': 'http://10.10.10.10:1080',
			  'https': 'http://10.10.10.10:1080',
			}
			requests.get('https://httpbin.org/get', proxies=proxies)
			

当然,直接运行这个实例或许行不通,因为这个代理可能是无效的,可以直接搜索寻找有效的代理并替换试验一下。

若代理需要使用上文所述的身份认证,可以使用类似 http://user:password@host:port 这样的语法来设置代理,示例如下:

import requests

			proxies = {'https': 'http://user:password@10.10.10.10:1080/',}
			requests.get('https://httpbin.org/get', proxies=proxies)
			

除了基本的 HTTP 代理外,requests 还支持 SOCKS 协议的代理。

首先,需要安装 socks 这个库:

pip3 install "requests[socks]"
			

然后就可以使用 SOCKS 协议代理了,示例如下:

import requests

			proxies = {
			    'http': 'socks5://user:password@host:port',
			    'https': 'socks5://user:password@host:port'
			}
			requests.get('https://httpbin.org/get', proxies=proxies)
			

Prepared Request

我们使用 requests 库的 get 和 post 方法可以直接发送请求,但你有没有想过,这个请求在 requests 内部是怎么实现的呢?

实际上,requests 在发送请求的时候在内部构造了一个 Request 对象,并给这个对象赋予了各种参数,包括 url、headers、data ,等等。然后直接把这个 Request 对象发送出去,请求成功后会再得到一个 Response 对象,再解析即可。

那么这个 Request 是什么类型呢?实际上它就是 Prepared Request。

我们深入一下,不用 get 方法,直接构造一个 Prepared Request 对象来试试,代码如下:

from requests import Request, Session

			url = 'http://httpbin.org/post'
			data = {'name': 'germey'}
			headers = {'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.116 Safari/537.36'
			}
			s = Session()
			req = Request('POST', url, data=data, headers=headers)
			prepped = s.prepare_request(req)
			r = s.send(prepped)
			print(r.text)
			

这里我们引入了 Request,然后用 url、data 和 headers 参数构造了一个 Request 对象,这时需要再调用 Session 的 prepare_request 方法将其转换为一个 Prepared Request 对象,然后调用 send 方法发送,运行结果如下:

{
			  "args": {}, 
			  "data": "", 
			  "files": {}, 
			  "form": {
			    "name": "germey"
			  }, 
			  "headers": {
			    "Accept": "*/*", 
			    "Accept-Encoding": "gzip, deflate", 
			    "Content-Length": "11", 
			    "Content-Type": "application/x-www-form-urlencoded", 
			    "Host": "httpbin.org", 
			    "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.116 Safari/537.36", 
			    "X-Amzn-Trace-Id": "Root=1-5e5bd6a9-6513c838f35b06a0751606d8"
			  }, 
			  "json": null, 
			  "origin": "167.220.232.237", 
			  "url": "http://httpbin.org/post"
			}
			

可以看到,我们达到了同样的 POST 请求效果。

有了 Request 这个对象,就可以将请求当作独立的对象来看待,这样在一些场景中我们可以直接操作这个 Request 对象,更灵活地实现请求的调度和各种操作。

更多的用法可以参考 requests 的官方文档:http://docs.python-requests.org/

本课时 requests 库的基本用法就介绍到这里了。怎么样?是不是找到一点爬虫的感觉了?

第08讲:解析无所不能的正则表达式

在上个课时中,我们学会了如何用 Requests 来获取网页的源代码,得到 HTML 代码。但我们如何从 HTML 代码中获取真正想要的数据呢?

正则表达式就是一个有效的方法。

本课时中,我们将学习正则表达式的相关用法。正则表达式是处理字符串的强大工具,它有自己特定的语法结构。有了它,我们就能实现字符串的检索、替换、匹配验证。

当然,对于爬虫来说,有了它,要从 HTML 里提取想要的信息就非常方便了。

实例引入

说了这么多,可能我们对正则表达式的概念还是比较模糊,下面就用几个实例来看一下正则表达式的用法。

打开开源中国提供的正则表达式测试工具 http://tool.oschina.net/regex/,输入待匹配的文本,然后选择常用的正则表达式,就可以得出相应的匹配结果了。

例如,输入下面这段待匹配的文本:

Hello, my phone number is 010-86432100 and email is cqc@cuiqingcai.com, and my website is https://cuiqingcai.com.
			

这段字符串中包含了一个电话号码和一个电子邮件,接下来就尝试用正则表达式提取出来,如图所示。

在网页右侧选择 “匹配 Email 地址”,就可以看到下方出现了文本中的 E-mail。如果选择 “匹配网址 URL”,就可以看到下方出现了文本中的 URL。是不是非常神奇?

其实,这里使用了正则表达式的匹配功能,也就是用一定规则将特定的文本提取出来。

比方说,电子邮件是有其特定的组成格式的:一段字符串 + @ 符号 + 某个域名。而 URL的组成格式则是协议类型 + 冒号加双斜线 + 域名和路径。

可以用下面的正则表达式匹配 URL:

[a-zA-z]+://[^\s]*
			

用这个正则表达式去匹配一个字符串,如果这个字符串中包含类似 URL 的文本,那就会被提取出来。

这个看上去乱糟糟的正则表达式其实有特定的语法规则。比如,a-z 匹配任意的小写字母,\s 匹配任意的空白字符,* 匹配前面任意多个字符。这一长串的正则表达式就是这么多匹配规则的组合。

写好正则表达式后,就可以拿它去一个长字符串里匹配查找了。不论这个字符串里面有什么,只要符合我们写的规则,统统可以找出来。对于网页来说,如果想找出网页源代码里有多少 URL,用 URL 的正则表达式去匹配即可。

下表中列出了常用的匹配规则:

模  式 描  述
\w 匹配字母、数字及下划线
\W 匹配不是字母、数字及下划线的字符
\s 匹配任意空白字符,等价于 [\t\n\r\f]
\S 匹配任意非空字符
\d 匹配任意数字,等价于 [0~9]
\D 匹配任意非数字的字符
\A 匹配字符串开头
\Z 匹配字符串结尾,如果存在换行,只匹配到换行前的结束字符串
\z 匹配字符串结尾,如果存在换行,同时还会匹配换行符
\G 匹配最后匹配完成的位置
\n 匹配一个换行符
\t 匹配一个制表符
^ 匹配一行字符串的开头
$ 匹配一行字符串的结尾
. 匹配任意字符,除了换行符,当 re.DOTALL 标记被指定时,则可以匹配包括换行符的任意字符
[...] 用来表示一组字符,单独列出,比如 [amk] 匹配 a、m 或 k
[^...] 不在 [] 中的字符,比如  匹配除了 a、b、c 之外的字符
* 匹配 0 个或多个表达式
+ 匹配 1 个或多个表达式
? 匹配 0 个或 1 个前面的正则表达式定义的片段,非贪婪方式
{n} 精确匹配 n 个前面的表达式
{n, m} 匹配 n 到 m 次由前面正则表达式定义的片段,贪婪方式
a|b 匹配 a 或 b
() 匹配括号内的表达式,也表示一个组

看完之后,你可能有点晕晕的吧,不用担心,后面我们会详细讲解一些常见规则的用法。

其实正则表达式不是 Python 独有的,它也可以用在其他编程语言中。但是 Python 的 re 库提供了整个正则表达式的实现,利用这个库,可以在 Python 中使用正则表达式。

在 Python 中写正则表达式几乎都用这个库,下面就来了解它的一些常用方法。

match

首先介绍一个常用的匹配方法 —— match,向它传入要匹配的字符串,以及正则表达式,就可以检测这个正则表达式是否匹配字符串。

match 方法会尝试从字符串的起始位置匹配正则表达式,如果匹配,就返回匹配成功的结果;如果不匹配,就返回 None。

示例如下:

import re

			content = 'Hello 123 4567 World_This is a Regex Demo'
			print(len(content))
			result = re.match('^Hello\s\d\d\d\s\d{4}\s\w{10}', content)
			print(result)
			print(result.group())
			print(result.span())
			

运行结果如下:

41
			<_sre.SRE_Match object; span=(0, 25), match='Hello 123 4567 World_This'>
			Hello 123 4567 World_This
			(0, 25)
			

这里首先声明了一个字符串,其中包含英文字母、空白字符、数字等。接下来,我们写一个正则表达式:

^Hello\s\d\d\d\s\d{4}\s\w{10}
			

用它来匹配这个长字符串。开头的 ^ 匹配字符串的开头,也就是以 Hello 开头; \s 匹配空白字符,用来匹配目标字符串的空格;\d 匹配数字,3 个 \d 匹配 123;再写 1 个 \s 匹配空格;后面的 4567,其实依然能用 4 个 \d 来匹配,但是这么写比较烦琐,所以后面可以跟 {4} 代表匹配前面的规则 4 次,也就是匹配 4 个数字;后面再紧接 1 个空白字符,最后\w{10} 匹配 10 个字母及下划线。

我们注意到,这里并没有把目标字符串匹配完,不过依然可以进行匹配,只不过匹配结果短一点而已。

而在 match 方法中,第一个参数传入正则表达式,第二个参数传入要匹配的字符串。

打印输出结果,可以看到结果是 SRE_Match 对象,这证明成功匹配。该对象有两个方法:group 方法可以输出匹配的内容,结果是 Hello 123 4567 World_This,这恰好是正则表达式规则所匹配的内容;span 方法可以输出匹配的范围,结果是 (0, 25),这就是匹配到的结果字符串在原字符串中的位置范围。

通过上面的例子,我们基本了解了如何在 Python 中使用正则表达式来匹配一段文字。

匹配目标

刚才我们用 match 方法得到了匹配到的字符串内容,但当我们想从字符串中提取一部分内容,该怎么办呢?

就像最前面的实例一样,要从一段文本中提取出邮件或电话号码等内容。我们可以使用 () 括号将想提取的子字符串括起来。() 实际上标记了一个子表达式的开始和结束位置,被标记的每个子表达式会依次对应每一个分组,调用 group 方法传入分组的索引即可获取提取的结果。

示例如下:

import re

			content = 'Hello 1234567 World_This is a Regex Demo'
			result = re.match('^Hello\s(\d+)\sWorld', content)
			print(result)
			print(result.group())
			print(result.group(1))
			print(result.span())
			

这里我们想把字符串中的 1234567 提取出来,此时可以将数字部分的正则表达式用 () 括起来,然后调用了 group(1) 获取匹配结果。

运行结果如下:

<_sre.SRE_Match object; span=(0, 19), match='Hello 1234567 World'>
			Hello 1234567 World
			1234567
			(0, 19)
			

可以看到,我们成功得到了 1234567。这里用的是 group(1),它与 group() 有所不同,后者会输出完整的匹配结果,而前者会输出第一个被 () 包围的匹配结果。假如正则表达式后面还有 () 包括的内容,那么可以依次用 group(2)、group(3) 等来获取。

通用匹配

刚才我们写的正则表达比较复杂,出现空白字符我们就写 \s 匹配,出现数字我们就用 \d 匹配,这样的工作量非常大。

我们还可以用一个万能匹配来减少这些工作,那就是 .*。其中 . 可以匹配任意字符(除换行符),* 代表匹配前面的字符无限次,它们组合在一起就可以匹配任意字符了。有了它,我们就不用挨个字符的匹配了。

接着上面的例子,我们可以改写一下正则表达式:

import re

			content = 'Hello 123 4567 World_This is a Regex Demo'
			result = re.match('^Hello.*Demo$', content)
			print(result)
			print(result.group())
			print(result.span())
			

这里我们将中间部分直接省略,全部用 .* 来代替,最后加一个结尾字符就好了。

运行结果如下:

<_sre.SRE_Match object; span=(0, 41), match='Hello 123 4567 World_This is a Regex Demo'>
			Hello 123 4567 World_This is a Regex Demo
			(0, 41)
			

可以看到,group 方法输出了匹配的全部字符串,也就是说我们写的正则表达式匹配到了目标字符串的全部内容;span 方法输出 (0, 41),这是整个字符串的长度。

因此,我们可以使用 .* 简化正则表达式的书写。

贪婪与非贪婪

使用上面的通用匹配 .* 时,有时候匹配到的并不是我们想要的结果。

看下面的例子:

import re

			content = 'Hello 1234567 World_This is a Regex Demo'
			result = re.match('^He.*(\d+).*Demo$', content)
			print(result)
			print(result.group(1))
			

这里我们依然想获取中间的数字,所以中间依然写的是 (\d+)。由于数字两侧的内容比较杂乱,所以略写成 .*。最后,组成 ^He.*(\d+).*Demo$,看样子并没有什么问题。

我们看下运行结果:

<_sre.SRE_Match object; span=(0, 40), match='Hello 1234567 World_This is a Regex Demo'>
			7
			

奇怪的事情发生了,我们只得到了 7 这个数字,这是怎么回事呢?

这里就涉及一个贪婪匹配与非贪婪匹配的问题了。在贪婪匹配下,.* 会匹配尽可能多的字符。正则表达式中 .* 后面是 \d+,也就是至少一个数字,并没有指定具体多少个数字,因此,.* 就尽可能匹配多的字符,这里就把 123456 匹配了,给 \d+ 留下一个可满足条件的数字 7,最后得到的内容就只有数字 7 了。

这显然会给我们带来很大的不便。有时候,匹配结果会莫名其妙少了一部分内容。其实,这里只需要使用非贪婪匹配就好了。非贪婪匹配的写法是 .*?,多了一个 ?,那么它可以达到怎样的效果?

我们再用实例看一下:

import re

			content = 'Hello 1234567 World_This is a Regex Demo'
			result = re.match('^He.*?(\d+).*Demo$', content)
			print(result)
			print(result.group(1))
			

这里我们只是将第一个.* 改成了 .*?,转变为非贪婪匹配。

结果如下:

<_sre.SRE_Match object; span=(0, 40), match='Hello 1234567 World_This is a Regex Demo'>
			1234567
			

此时就可以成功获取 1234567 了。原因可想而知,贪婪匹配是尽可能匹配多的字符,非贪婪匹配就是尽可能匹配少的字符。当 .*? 匹配到 Hello 后面的空白字符时,再往后的字符就是数字了,而 \d+ 恰好可以匹配,那么 .*? 就不再进行匹配,交给 \d+ 去匹配后面的数字。这样 .*? 匹配了尽可能少的字符,\d+ 的结果就是 1234567 了。

所以,在做匹配的时候,字符串中间尽量使用非贪婪匹配,也就是用 .*? 来代替 .*,以免出现匹配结果缺失的情况。

但需要注意的是,如果匹配的结果在字符串结尾,.*? 就有可能匹配不到任何内容了,因为它会匹配尽可能少的字符。例如:

import re

			content = 'http://weibo.com/comment/kEraCN'
			result1 = re.match('http.*?comment/(.*?)', content)
			result2 = re.match('http.*?comment/(.*)', content)
			print('result1', result1.group(1))
			print('result2', result2.group(1))
			

运行结果如下:

result1 
			result2 kEraCNimport re

			content = '''Hello 1234567 World_This
			is a Regex Demo
			'''
			result = re.match('^He.*?(\d+).*?Demo$', content)
			print(result.group(1))
			

可以观察到,.*? 没有匹配到任何结果,而 .* 则尽量匹配多的内容,成功得到了匹配结果。

修饰符

正则表达式可以包含一些可选标志修饰符来控制匹配的模式。修饰符被指定为一个可选的标志。

我们用实例来看一下:

import re

			content = '''Hello 1234567 World_This
			is a Regex Demo
			'''
			result = re.match('^He.*?(\d+).*?Demo$', content)
			print(result.group(1))
			

和上面的例子相仿,我们在字符串中加了换行符,正则表达式还是一样的,用来匹配其中的数字。看一下运行结果:

AttributeError Traceback (most recent call last)
			<ipython-input-18-c7d232b39645> in <module>()
			      5 '''
			      6 result = re.match('^He.*?(\d+).*?Demo$', content)
			----> 7 print(result.group(1))

			AttributeError: 'NoneType' object has no attribute 'group'
			

运行直接报错,也就是说正则表达式没有匹配到这个字符串,返回结果为 None,而我们又调用了 group 方法导致 AttributeError。

为什么加了一个换行符,就匹配不到了呢?

这是因为我们匹配的是除换行符之外的任意字符,当遇到换行符时,.*? 就不能匹配了,导致匹配失败。

这里只需加一个修饰符 re.S,即可修正这个错误:

result = re.match('^He.*?(\d+).*?Demo$', content, re.S)
			

这个修饰符的作用是匹配包括换行符在内的所有字符。

此时运行结果如下:

1234567
			

这个 re.S 在网页匹配中经常用到。因为 HTML 节点经常会有换行,加上它,就可以匹配节点与节点之间的换行了。

另外,还有一些修饰符,在必要的情况下也可以使用,如表所示:

修饰符 描  述
re.I 使匹配对大小写不敏感
re.L 做本地化识别(locale-aware)匹配
re.M 多行匹配,影响 ^ 和 $
re.S 使匹配包括换行在内的所有字符
re.U 根据 Unicode 字符集解析字符。这个标志影响 \w、\W、\b 和 \B
re.X 该标志通过给予你更灵活的格式以便你将正则表达式写得更易于理解

在网页匹配中,较为常用的修饰符有 re.S 和 re.I。

转义匹配

我们知道正则表达式定义了许多匹配模式,如匹配除换行符以外的任意字符,但如果目标字符串里面就包含 .,那该怎么办呢?

这里就需要用到转义匹配了,示例如下:

import re

			content = '(百度) www.baidu.com'
			result = re.match('\(百度 \) www\.baidu\.com', content)
			print(result)
			

当遇到用于正则匹配模式的特殊字符时,在前面加反斜线转义一下即可。例 . 就可以用 \. 来匹配。

运行结果如下:

<_sre.SRE_Match object; span=(0, 17), match='(百度) www.baidu.com'>
			

可以看到,这里成功匹配到了原字符串。

这些是写正则表达式常用的几个知识点,熟练掌握它们对后面写正则表达式匹配非常有帮助。

search

前面提到过,match 方法是从字符串的开头开始匹配的,一旦开头不匹配,那么整个匹配就失败了。

我们看下面的例子:

import re

			content = 'Extra stings Hello 1234567 World_This is a Regex Demo Extra stings'
			result = re.match('Hello.*?(\d+).*?Demo', content)
			print(result)
			

这里的字符串以 Extra 开头,但是正则表达式以 Hello 开头,整个正则表达式是字符串的一部分,但是这样匹配是失败的。

运行结果如下:

None
			

因为 match 方法在使用时需要考虑到开头的内容,这在做匹配时并不方便。它更适合用来检测某个字符串是否符合某个正则表达式的规则。

这里有另外一个方法 search,它在匹配时会扫描整个字符串,然后返回第一个成功匹配的结果。也就是说,正则表达式可以是字符串的一部分,在匹配时,search 方法会依次扫描字符串,直到找到第一个符合规则的字符串,然后返回匹配内容,如果搜索完了还没有找到,就返回 None。

我们把上面代码中的 match 方法修改成 search,再看下运行结果:

<_sre.SRE_Match object; span=(13, 53), match='Hello 1234567 World_This is a Regex Demo'>
			1234567
			

这时就得到了匹配结果。

因此,为了匹配方便,我们可以尽量使用 search 方法。

下面再用几个实例来看看 search 方法的用法。

这里有一段待匹配的 HTML 文本,接下来我们写几个正则表达式实例来实现相应信息的提取:

html = '''<div id="songs-list">
			<h2 class="title">经典老歌</h2>
			<p class="introduction">
			经典老歌列表
			</p>
			<ul id="list" class="list-group">
			<li data-view="2">一路上有你</li>
			<li data-view="7">
			<a href="/2.mp3" singer="任贤齐">沧海一声笑</a>
			</li>
			<li data-view="4" class="active">
			<a href="/3.mp3" singer="齐秦">往事随风</a>
			</li>
			<li data-view="6"><a href="/4.mp3" singer="beyond">光辉岁月</a></li>
			<li data-view="5"><a href="/5.mp3" singer="陈慧琳">记事本</a></li>
			<li data-view="5">
			<a href="/6.mp3" singer="邓丽君">但愿人长久</a>
			</li>
			</ul>
			</div>'''
			

可以观察到,ul 节点里有许多 li 节点,其中 li 节点中有的包含 a 节点,有的不包含 a 节点,a 节点还有一些相应的属性 —— 超链接和歌手名。

首先,我们尝试提取 class为 active 的 li 节点内部超链接包含的歌手名和歌名,此时需要提取第三个 li 节点下 a 节点的 singer 属性和文本。

此时,正则表达式可以用 li 开头,然后寻找一个标志符 active,中间的部分可以用 .*? 来匹配。

接下来,要提取 singer 这个属性值,所以还需要写入 singer="(.*?)",这里需要提取的部分用小括号括起来,以便用 group 方法提取出来,它的两侧边界是双引号。

然后还需要匹配 a 节点的文本,其中它的左边界是 >,右边界是 </a>。目标内容依然用 (.*?) 来匹配,所以最后的正则表达式就变成了:

<li.*?active.*?singer="(.*?)">(.*?)</a>
			

然后再调用 search 方法,它会搜索整个 HTML 文本,找到符合正则表达式的第一个内容返回。

另外,由于代码有换行,所以这里第三个参数需要传入 re.S。整个匹配代码如下:

result = re.search('<li.*?active.*?singer="(.*?)">(.*?)</a>', html, re.S) 
			if result:  
			    print(result.group(1), result.group(2))
			

由于需要获取的歌手和歌名都已经用小括号包围,所以可以用 group 方法获取。

运行结果如下:

齐秦 往事随风
			

可以看到,这正是 class 为 active 的 li 节点内部的超链接包含的歌手名和歌名。

如果正则表达式不加 active(也就是匹配不带 class 为 active 的节点内容),那会怎样呢?我们将正则表达式中的 active 去掉。

代码改写如下:

result = re.search('<li.*?singer="(.*?)">(.*?)</a>', html, re.S)
			if result:  
			    print(result.group(1), result.group(2))
			

由于 search 方法会返回第一个符合条件的匹配目标,这里结果就变了:

任贤齐 沧海一声笑
			

把 active 标签去掉后,从字符串开头开始搜索,此时符合条件的节点就变成了第二个 li 节点,后面的不再匹配,所以运行结果变成第二个 li 节点中的内容。

注意,在上面的两次匹配中,search 方法的第三个参数都加了 re.S,这使得 .*? 可以匹配换行,所以含有换行的 li 节点被匹配到了。如果我们将其去掉,结果会是什么?

代码如下:

result = re.search('<li.*?singer="(.*?)">(.*?)</a>', html)
			if result:  
			    print(result.group(1), result.group(2))
			

运行结果如下:

beyond 光辉岁月
			

可以看到,结果变成了第四个 li 节点的内容。这是因为第二个和第三个 li 节点都包含了换行符,去掉 re.S 之后,.*? 已经不能匹配换行符,所以正则表达式不会匹配到第二个和第三个 li 节点,而第四个 li 节点中不包含换行符,所以成功匹配。

由于绝大部分的 HTML 文本都包含了换行符,所以尽量都需要加上 re.S 修饰符,以免出现匹配不到的问题。

findall

前面我们介绍了 search 方法的用法,它可以返回匹配正则表达式的第一个内容,但是如果想要获取匹配正则表达式的所有内容,那该怎么办呢?这时就要借助 findall 方法了。

该方法会搜索整个字符串,然后返回匹配正则表达式的所有内容。

还是上面的 HTML 文本,如果想获取所有 a 节点的超链接、歌手和歌名,就可以将 search 方法换成 findall 方法。如果有返回结果的话,就是列表类型,所以需要遍历一下来依次获取每组内容。

代码如下:

results = re.findall('<li.*?href="(.*?)".*?singer="(.*?)">(.*?)</a>', html, re.S)
			print(results)  
			print(type(results))  
			for result in results:  
			    print(result)  
			    print(result[0], result[1], result[2])
			

运行结果如下:

[('/2.mp3', ' 任贤齐 ', ' 沧海一声笑 '), ('/3.mp3', ' 齐秦 ', ' 往事随风 '), ('/4.mp3', 'beyond', ' 光辉岁月 '), ('/5.mp3', ' 陈慧琳 ', ' 记事本 '), ('/6.mp3', ' 邓丽君 ', ' 但愿人长久 ')]
			<class 'list'>
			('/2.mp3', ' 任贤齐 ', ' 沧海一声笑 ')
			/2.mp3 任贤齐 沧海一声笑
			('/3.mp3', ' 齐秦 ', ' 往事随风 ')
			/3.mp3 齐秦 往事随风
			('/4.mp3', 'beyond', ' 光辉岁月 ')
			/4.mp3 beyond 光辉岁月
			('/5.mp3', ' 陈慧琳 ', ' 记事本 ')
			/5.mp3 陈慧琳 记事本
			('/6.mp3', ' 邓丽君 ', ' 但愿人长久 ')
			/6.mp3 邓丽君 但愿人长久
			

可以看到,返回的列表中的每个元素都是元组类型,我们用对应的索引依次取出即可。

如果只是获取第一个内容,可以用 search 方法。当需要提取多个内容时,可以用 findall 方法。

sub

除了使用正则表达式提取信息外,有时候还需要借助它来修改文本。比如,想要把一串文本中的所有数字都去掉,如果只用字符串的 replace 方法,那就太烦琐了,这时可以借助 sub 方法。

示例如下:

import re

			content = '54aK54yr5oiR54ix5L2g'
			content = re.sub('\d+', '', content)
			print(content)
			

运行结果如下:

aKyroiRixLg
			

这里只需要给第一个参数传入 \d+ 来匹配所有的数字,第二个参数替换成的字符串(如果去掉该参数的话,可以赋值为空),第三个参数是原字符串。

在上面的 HTML 文本中,如果想获取所有 li 节点的歌名,直接用正则表达式来提取可能比较烦琐。比如,可以写成这样子:

results = re.findall('<li.*?>\s*?(<a.*?>)?(\w+)(</a>)?\s*?</li>', html, re.S)
			for result in results:
			    print(result[1])
			

运行结果如下:

一路上有你
			沧海一声笑
			往事随风
			光辉岁月
			记事本
			但愿人长久
			

此时借助 sub 方法就比较简单了。可以先用 sub 方法将 a 节点去掉,只留下文本,然后再利用 findall 提取就好了:

html = re.sub('<a.*?>|</a>', '', html)
			print(html)
			results = re.findall('<li.*?>(.*?)</li>', html, re.S)
			for result in results:
			    print(result.strip())
			

运行结果如下:

<div id="songs-list">
			    <h2 class="title"> 经典老歌 </h2>
			    <p class="introduction">
			        经典老歌列表
			    </p>
			    <ul id="list" class="list-group">
			        <li data-view="2"> 一路上有你 </li>
			        <li data-view="7">
			            沧海一声笑
			        </li>
			        <li data-view="4" class="active">
			            往事随风
			        </li>
			        <li data-view="6"> 光辉岁月 </li>
			        <li data-view="5"> 记事本 </li>
			        <li data-view="5">
			            但愿人长久
			        </li>
			    </ul>
			</div>
			一路上有你
			沧海一声笑
			往事随风
			光辉岁月
			记事本
			但愿人长久
			

可以看到,a 节点经过 sub 方法处理后就没有了,随后我们通过 findall 方法直接提取即可。

通过以上例子,你会发现,在适当的时候,借助 sub 方法可以起到事半功倍的效果。

compile

前面所讲的方法都是用来处理字符串的方法,最后再介绍一下 compile 方法,这个方法可以将正则字符串编译成正则表达式对象,以便在后面的匹配中复用。

示例代码如下:

import re

			content1 = '2019-12-15 12:00'
			content2 = '2019-12-17 12:55'
			content3 = '2019-12-22 13:21'
			pattern = re.compile('\d{2}:\d{2}')
			result1 = re.sub(pattern, '', content1)
			result2 = re.sub(pattern, '', content2)
			result3 = re.sub(pattern, '', content3)
			print(result1, result2, result3)
			

这里有 3 个日期,我们想分别将 3 个日期中的时间去掉,这时可以借助 sub 方法。该方法的第一个参数是正则表达式,但是我们没有必要重复写 3 个同样的正则表达式。此时可以借助 compile 方法将正则表达式编译成一个正则表达式对象,以便复用。

运行结果如下:

2019-12-15  2019-12-17  2019-12-22
			

另外,compile 还可以传入修饰符,例如 re.S 等修饰符,这样在 search、findall 等方法中就不需要额外传了。所以,compile 方法可以说是给正则表达式做了一层封装,以便我们更好的复用。

到此,正则表达式的基本用法就介绍完了。后面我会通过具体的实例来讲解正则表达式的用法。

第09讲:爬虫解析利器 PyQuery 的使用

上一课时我们学习了正则表达式的基本用法,然而一旦你的正则表达式写法有问题,我们就无法获取需要的信息。

你可能会思考:每个网页,都有一定的特殊结构和层级关系,而且很多节点都有 id 或 class 作为区分,我们可以借助它们的结构和属性来提取信息吗?

这的确可行。这个课时我会为你介绍一个更加强大的 HTML 解析库:pyquery。利用它,我们可以直接解析 DOM 节点的结构,并通过 DOM 节点的一些属性快速进行内容提取。

接下来,我们就来感受一下 pyquery 的强大之处。

准备工作

pyquery 是 Python 的第三方库,我们可以借助于 pip3 来安装,安装命令如下:

pip3 install pyquery
			

更详细的安装方法可以参考:https://cuiqingcai.com/5186.html

初始化

我们在解析 HTML 文本的时候,首先需要将其初始化为一个 pyquery 对象。它的初始化方式有多种,比如直接传入字符串、传入 URL、传入文件名,等等。

下面我们来详细介绍一下。

字符串初始化

我们可以直接把 HTML 的内容当作参数来初始化 pyquery 对象。我们用一个实例来感受一下:

html = '''
			<div>
			    <ul>
			         <li class="item-0">first item</li>
			         <li class="item-1"><a href="link2.html">second item</a></li>
			         <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
			         <li class="item-1 active"><a href="link4.html">fourth item</a></li>
			         <li class="item-0"><a href="link5.html">fifth item</a></li>
			     </ul>
			 </div>
			'''
			from pyquery import PyQuery as pq
			doc = pq(html)
			print(doc('li'))
			

运行结果如下:

<li class="item-0">first item</li>
			<li class="item-1"><a href="link2.html">second item</a></li>
			<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
			<li class="item-1 active"><a href="link4.html">fourth item</a></li>
			<li class="item-0"><a href="link5.html">fifth item</a></li>
			

这里首先引入 pyquery 这个对象,取别名为 pq,然后声明了一个长 HTML 字符串,并将其当作参数传递给 pyquery 类,这样就成功完成了初始化。

接下来,将初始化的对象传入 CSS 选择器。在这个实例中,我们传入 li 节点,这样就可以选择所有的 li 节点。

URL 初始化

初始化的参数不仅可以以字符串的形式传递,还可以传入网页的 URL,此时只需要指定参数为 url 即可:

from pyquery import PyQuery as pq
			doc = pq(url='https://cuiqingcai.com')
			print(doc('title'))
			

运行结果:

<title>静觅丨崔庆才的个人博客</title>
			

这样的话,pyquery 对象会首先请求这个 URL,然后用得到的 HTML 内容完成初始化。这就相当于将网页的源代码以字符串的形式传递给 pyquery 类来初始化。

它与下面的功能是相同的:

from pyquery import PyQuery as pq
			import requests
			doc = pq(requests.get('https://cuiqingcai.com').text)
			print(doc('title'))
			

文件初始化

当然除了传递一个 URL,我们还可以传递本地的文件名,参数指定为 filename 即可:

from pyquery import PyQuery as pq
			doc = pq(filename='demo.html')
			print(doc('li'))
			

当然,这里需要有一个本地 HTML 文件 demo.html,其内容是待解析的 HTML 字符串。这样它会先读取本地的文件内容,然后将文件内容以字符串的形式传递给 pyquery 类来初始化。

以上 3 种方式均可初始化,当然最常用的初始化方式还是以字符串形式传递。

基本 CSS 选择器

我们先用一个实例来感受一下 pyquery 的 CSS 选择器的用法:

html = '''
			<div id="container">
			    <ul class="list">
			         <li class="item-0">first item</li>
			         <li class="item-1"><a href="link2.html">second item</a></li>
			         <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
			         <li class="item-1 active"><a href="link4.html">fourth item</a></li>
			         <li class="item-0"><a href="link5.html">fifth item</a></li>
			     </ul>
			 </div>
			'''
			from pyquery import PyQuery as pq
			doc = pq(html)
			print(doc('#container .list li'))
			print(type(doc('#container .list li')))
			

运行结果:

<li class="item-0">first item</li>
			<li class="item-1"><a href="link2.html">second item</a></li>
			<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
			<li class="item-1 active"><a href="link4.html">fourth item</a></li>
			<li class="item-0"><a href="link5.html">fifth item</a></li>
			<class 'pyquery.pyquery.PyQuery'>
			

在上面的例子中,我们初始化 pyquery 对象之后,传入 CSS 选择器 #container .list li,它的意思是先选取 id 为 container 的节点,然后再选取其内部 class 为 list 的所有 li 节点,最后打印输出。

可以看到,我们成功获取到了符合条件的节点。我们将它的类型打印输出后发现,它的类型依然是 pyquery 类型。

下面,我们直接遍历这些节点,然后调用 text 方法,就可以获取节点的文本内容,代码示例如下:

for item in doc('#container .list li').items():
			    print(item.text())
			

运行结果如下:

first item
			second item
			third item
			fourth item
			fifth item
			

怎么样?我们没有再写正则表达式,而是直接通过选择器和 text 方法,就得到了我们想要提取的文本信息,是不是方便多了?

下面我们再来详细了解一下 pyquery 的用法吧,我将为你讲解如何用它查找节点、遍历节点、获取各种信息等操作方法。掌握了这些,我们就能更高效地完成数据提取。

查找节点

下面我们介绍一些常用的查询方法。

子节点

查找子节点需要用到 find 方法,传入的参数是 CSS 选择器,我们还是以上面的 HTML 为例:

from pyquery import PyQuery as pq
			doc = pq(html)
			items = doc('.list')
			print(type(items))
			print(items)
			lis = items.find('li')
			print(type(lis))
			print(lis)
			

运行结果:

<class 'pyquery.pyquery.PyQuery'>
			<ul class="list">
			    <li class="item-0">first item</li>
			    <li class="item-1"><a href="link2.html">second item</a></li>
			    <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
			    <li class="item-1 active"><a href="link4.html">fourth item</a></li>
			    <li class="item-0"><a href="link5.html">fifth item</a></li>
			</ul>
			<class 'pyquery.pyquery.PyQuery'>
			<li class="item-0">first item</li>
			<li class="item-1"><a href="link2.html">second item</a></li>
			<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
			<li class="item-1 active"><a href="link4.html">fourth item</a></li>
			<li class="item-0"><a href="link5.html">fifth item</a></li>
			

首先,我们通过 .list  参数选取 class 为 list 的节点,然后调用 find 方法,传入 CSS 选择器,选取其内部的 li 节点,最后打印输出。可以发现,find 方法会将符合条件的所有节点选择出来,结果的类型是 pyquery 类型。

find 的查找范围是节点的所有子孙节点,而如果我们只想查找子节点,那可以用 children 方法:

lis = items.children()
			print(type(lis))
			print(lis)
			

运行结果如下:

<class 'pyquery.pyquery.PyQuery'>
			<li class="item-0">first item</li>
			<li class="item-1"><a href="link2.html">second item</a></li>
			<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
			<li class="item-1 active"><a href="link4.html">fourth item</a></li>
			<li class="item-0"><a href="link5.html">fifth item</a></li>
			

如果要筛选所有子节点中符合条件的节点,比如想筛选出子节点中 class 为 active 的节点,可以向 children 方法传入 CSS 选择器 .active,代码如下:

lis = items.children('.active')
			print(lis)
			

运行结果:

<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
			<li class="item-1 active"><a href="link4.html">fourth item</a></li>
			

我们看到输出的结果已经做了筛选,留下了 class 为 active 的节点。

父节点

我们可以用 parent 方法来获取某个节点的父节点,下面用一个实例来感受一下:

html = '''
			<div class="wrap">
			    <div id="container">
			        <ul class="list">
			             <li class="item-0">first item</li>
			             <li class="item-1"><a href="link2.html">second item</a></li>
			             <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
			             <li class="item-1 active"><a href="link4.html">fourth item</a></li>
			             <li class="item-0"><a href="link5.html">fifth item</a></li>
			         </ul>
			     </div>
			 </div>
			'''
			from pyquery import PyQuery as pq
			doc = pq(html)
			items = doc('.list')
			container = items.parent()
			print(type(container))
			print(container)
			

运行结果如下:

<class 'pyquery.pyquery.PyQuery'>
			<div id="container">
			    <ul class="list">
			         <li class="item-0">first item</li>
			         <li class="item-1"><a href="link2.html">second item</a></li>
			         <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
			         <li class="item-1 active"><a href="link4.html">fourth item</a></li>
			         <li class="item-0"><a href="link5.html">fifth item</a></li>
			     </ul>
			 </div>
			

在上面的例子中我们首先用 .list 选取 class 为 list 的节点,然后调用 parent 方法得到其父节点,其类型依然是 pyquery 类型。

这里的父节点是该节点的直接父节点,也就是说,它不会再去查找父节点的父节点,即祖先节点。

但是如果你想获取某个祖先节点,该怎么办呢?我们可以用 parents 方法:

from pyquery import PyQuery as pq
			doc = pq(html)
			items = doc('.list')
			parents = items.parents()
			print(type(parents))
			print(parents)
			

运行结果如下:

<class 'pyquery.pyquery.PyQuery'>
			<div class="wrap">
			    <div id="container">
			        <ul class="list">
			             <li class="item-0">first item</li>
			             <li class="item-1"><a href="link2.html">second item</a></li>
			             <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
			             <li class="item-1 active"><a href="link4.html">fourth item</a></li>
			             <li class="item-0"><a href="link5.html">fifth item</a></li>
			         </ul>
			     </div>
			 </div>
			 <div id="container">
			        <ul class="list">
			             <li class="item-0">first item</li>
			             <li class="item-1"><a href="link2.html">second item</a></li>
			             <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
			             <li class="item-1 active"><a href="link4.html">fourth item</a></li>
			             <li class="item-0"><a href="link5.html">fifth item</a></li>
			         </ul>
			     </div>
			

可以看到,这个例子的输出结果有两个:一个是 class 为 wrap 的节点,一个是 id 为 container 的节点。也就是说,使用 parents 方法会返回所有的祖先节点。

如果你想要筛选某个祖先节点的话,可以向 parents 方法传入 CSS 选择器,这样就会返回祖先节点中符合 CSS 选择器的节点:

parent = items.parents('.wrap')
			print(parent)
			

运行结果如下:

<div class="wrap">
			    <div id="container">
			        <ul class="list">
			             <li class="item-0">first item</li>
			             <li class="item-1"><a href="link2.html">second item</a></li>
			             <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
			             <li class="item-1 active"><a href="link4.html">fourth item</a></li>
			             <li class="item-0"><a href="link5.html">fifth item</a></li>
			         </ul>
			     </div>
			 </div>
			

可以看到,输出结果少了一个节点,只保留了 class 为 wrap 的节点。

兄弟节点

前面我们说明了子节点和父节点的用法,还有一种节点叫作兄弟节点。如果要获取兄弟节点,可以使用 siblings 方法。这里还是以上面的 HTML 代码为例:

from pyquery import PyQuery as pq
			doc = pq(html)
			li = doc('.list .item-0.active')
			print(li.siblings())
			

在这个例子中我们首先选择 class 为 list 的节点,内部 class 为 item-0 和 active 的节点,也就是第 3 个 li 节点。很明显,它的兄弟节点有 4 个,那就是第 1、2、4、5 个 li 节点。

我们来运行一下:

<li class="item-1"><a href="link2.html">second item</a></li>
			<li class="item-0">first item</li>
			<li class="item-1 active"><a href="link4.html">fourth item</a></li>
			<li class="item-0"><a href="link5.html">fifth item</a></li>
			

可以看到,结果显示的正是我们刚才所说的 4 个兄弟节点。

如果要筛选某个兄弟节点,我们依然可以用 siblings 方法传入 CSS 选择器,这样就会从所有兄弟节点中挑选出符合条件的节点了:

from pyquery import PyQuery as pq
			doc = pq(html)
			li = doc('.list .item-0.active')
			print(li.siblings('.active'))
			

在这个例子中我们筛选 class 为 active 的节点,从刚才的结果中可以观察到,class 为 active 兄弟节点的是第 4 个 li 节点,所以结果应该是1个。

我们再看一下运行结果:

<li class="item-1 active"><a href="link4.html">fourth item</a></li>
			

遍历

通过刚才的例子我们可以观察到,pyquery 的选择结果既可能是多个节点,也可能是单个节点,类型都是 pyquery 类型,并没有返回列表。

对于单个节点来说,可以直接打印输出,也可以直接转成字符串:

from pyquery import PyQuery as pq
			doc = pq(html)
			li = doc('.item-0.active')
			print(li)
			print(str(li))
			

运行结果如下:

<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
			<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
			

对于有多个节点的结果,我们就需要用遍历来获取了。例如,如果要把每一个 li 节点进行遍历,需要调用 items 方法:

from pyquery import PyQuery as pq
			doc = pq(html)
			lis = doc('li').items()
			print(type(lis))
			for li in lis:
			    print(li, type(li))
			

运行结果如下:

<class 'generator'>
			<li class="item-0">first item</li>
			<class 'pyquery.pyquery.PyQuery'>
			<li class="item-1"><a href="link2.html">second item</a></li>
			<class 'pyquery.pyquery.PyQuery'>
			<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
			<class 'pyquery.pyquery.PyQuery'>
			<li class="item-1 active"><a href="link4.html">fourth item</a></li>
			<class 'pyquery.pyquery.PyQuery'>
			<li class="item-0"><a href="link5.html">fifth item</a></li>
			<class 'pyquery.pyquery.PyQuery'>
			

可以发现,调用 items 方法后,会得到一个生成器,遍历一下,就可以逐个得到 li 节点对象了,它的类型也是 pyquery 类型。每个 li 节点还可以调用前面所说的方法进行选择,比如继续查询子节点,寻找某个祖先节点等,非常灵活。

获取信息

提取到节点之后,我们的最终目的当然是提取节点所包含的信息了。比较重要的信息有两类,一是获取属性,二是获取文本,下面分别进行说明。

获取属性

提取到某个 pyquery 类型的节点后,就可以调用 attr 方法来获取属性:

html = '''
			<div class="wrap">
			    <div id="container">
			        <ul class="list">
			             <li class="item-0">first item</li>
			             <li class="item-1"><a href="link2.html">second item</a></li>
			             <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
			             <li class="item-1 active"><a href="link4.html">fourth item</a></li>
			             <li class="item-0"><a href="link5.html">fifth item</a></li>
			         </ul>
			     </div>
			 </div>
			'''
			from pyquery import PyQuery as pq
			doc = pq(html)
			a = doc('.item-0.active a')
			print(a, type(a))
			print(a.attr('href'))
			

运行结果如下:

<a href="link3.html"><span class="bold">third item</span></a> <class 'pyquery.pyquery.PyQuery'>
			link3.html
			

在这个例子中我们首先选中 class 为 item-0 和 active 的 li 节点内的 a 节点,它的类型是 pyquery 类型。

然后调用 attr 方法。在这个方法中传入属性的名称,就可以得到属性值了。

此外,也可以通过调用 attr 属性来获取属性值,用法如下:

print(a.attr.href)
			

结果:

link3.html
			

这两种方法的结果完全一样。

如果选中的是多个元素,然后调用 attr 方法,会出现怎样的结果呢?我们用实例来测试一下:

a = doc('a')
			print(a, type(a))
			print(a.attr('href'))
			print(a.attr.href)
			

运行结果如下:

<a href="link2.html">second item</a><a href="link3.html"><span class="bold">third item</span></a><a href="link4.html">fourth item</a><a href="link5.html">fifth item</a> <class 'pyquery.pyquery.PyQuery'>
			link2.html
			link2.html
			

照理来说,我们选中的 a 节点应该有 4 个,打印结果也应该是 4 个,但是当我们调用 attr 方法时,返回结果却只有第 1 个。这是因为,当返回结果包含多个节点时,调用 attr 方法,只会得到第 1 个节点的属性。

那么,遇到这种情况时,如果想获取所有的 a 节点的属性,就要用到前面所说的遍历了:

from pyquery import PyQuery as pq
			doc = pq(html)
			a = doc('a')
			for item in a.items():
			    print(item.attr('href'))
			

运行结果:

link2.html
			link3.html
			link4.html
			link5.html
			

因此,在进行属性获取时,先要观察返回节点是一个还是多个,如果是多个,则需要遍历才能依次获取每个节点的属性。

获取文本

获取节点之后的另一个主要操作就是获取其内部文本了,此时可以调用 text 方法来实现:

html = '''
			<div class="wrap">
			    <div id="container">
			        <ul class="list">
			             <li class="item-0">first item</li>
			             <li class="item-1"><a href="link2.html">second item</a></li>
			             <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
			             <li class="item-1 active"><a href="link4.html">fourth item</a></li>
			             <li class="item-0"><a href="link5.html">fifth item</a></li>
			         </ul>
			     </div>
			 </div>
			'''
			from pyquery import PyQuery as pq
			doc = pq(html)
			a = doc('.item-0.active a')
			print(a)
			print(a.text())
			

运行结果:

<a href="link3.html"><span class="bold">third item</span></a>
			third item
			

这里我们首先选中一个 a 节点,然后调用 text 方法,就可以获取其内部的文本信息了。text 会忽略节点内部包含的所有 HTML,只返回纯文字内容。

但如果你想要获取这个节点内部的 HTML 文本,就要用 html 方法了:

from pyquery import PyQuery as pq
			doc = pq(html)
			li = doc('.item-0.active')
			print(li)
			print(li.html())
			

这里我们选中第 3 个 li 节点,然后调用 html 方法,它返回的结果应该是 li 节点内的所有 HTML 文本。

运行结果:

<a href="link3.html"><span class="bold">third item</span></a>
			

这里同样有一个问题,如果我们选中的结果是多个节点,text 或 html 方法会返回什么内容?我们用实例来看一下:

html = '''
			<div class="wrap">
			    <div id="container">
			        <ul class="list">
			             <li class="item-1"><a href="link2.html">second item</a></li>
			             <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
			             <li class="item-1 active"><a href="link4.html">fourth item</a></li>
			             <li class="item-0"><a href="link5.html">fifth item</a></li>
			         </ul>
			     </div>
			 </div>
			'''
			from pyquery import PyQuery as pq
			doc = pq(html)
			li = doc('li')
			print(li.html())
			print(li.text())
			print(type(li.text())
			

运行结果如下:

<a href="link2.html">second item</a>
			second item third item fourth item fifth item
			<class'str'>
			

结果比较出乎意料,html 方法返回的是第 1 个 li 节点的内部 HTML 文本,而 text 则返回了所有的 li 节点内部的纯文本,中间用一个空格分割开,即返回结果是一个字符串。

这个地方值得注意,如果你想要得到的结果是多个节点,并且需要获取每个节点的内部 HTML 文本,则需要遍历每个节点。而 text 方法不需要遍历就可以获取,它将所有节点取文本之后合并成一个字符串。

节点操作

pyquery 提供了一系列方法来对节点进行动态修改,比如为某个节点添加一个 class,移除某个节点等,这些操作有时会为提取信息带来极大的便利。

由于节点操作的方法太多,下面举几个典型的例子来说明它的用法。

addClass 和 removeClass

我们先用一个实例来感受一下:

html = '''
			<div class="wrap">
			    <div id="container">
			        <ul class="list">
			             <li class="item-0">first item</li>
			             <li class="item-1"><a href="link2.html">second item</a></li>
			             <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
			             <li class="item-1 active"><a href="link4.html">fourth item</a></li>
			             <li class="item-0"><a href="link5.html">fifth item</a></li>
			         </ul>
			     </div>
			 </div>
			'''
			from pyquery import PyQuery as pq
			doc = pq(html)
			li = doc('.item-0.active')
			print(li)
			li.removeClass('active')
			print(li)
			li.addClass('active')
			print(li)
			

首先选中第 3 个 li 节点,然后调用 removeClass 方法,将 li 节点的 active 这个 class 移除,第 2 步调用 addClass 方法,将 class 添加回来。每执行一次操作,就打印输出当前 li 节点的内容。

运行结果如下:

<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
			<li class="item-0"><a href="link3.html"><span class="bold">third item</span></a></li>
			<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
			

可以看到,一共输出了 3 次。第 2 次输出时,li 节点的 active 这个 class 被移除了,第 3 次 class 又添加回来了。

所以说,addClass 和 removeClass 方法可以动态改变节点的 class 属性。

attr、text、html

当然,除了操作 class 这个属性外,也可以用 attr 方法对属性进行操作。此外,我们还可以用 text 和 html 方法来改变节点内部的内容。示例如下:

html = '''
			<ul class="list">
			     <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
			</ul>
			'''
			from pyquery import PyQuery as pq
			doc = pq(html)
			li = doc('.item-0.active')
			print(li)
			li.attr('name', 'link')
			print(li)
			li.text('changed item')
			print(li)
			li.html('<span>changed item</span>')
			print(li)
			

这里我们首先选中 li 节点,然后调用 attr 方法来修改属性。该方法的第 1 个参数为属性名,第 2 个参数为属性值。最后调用 text 和 html 方法来改变节点内部的内容。3 次操作后,分别打印输出当前的 li 节点。

运行结果如下:

<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
			<li class="item-0 active" name="link"><a href="link3.html"><span class="bold">third item</span></a></li>
			<li class="item-0 active" name="link">changed item</li>
			<li class="item-0 active" name="link"><span>changed item</span></li>
			

我们发现,调用 attr 方法后,li 节点多了一个原本不存在的属性 name,其值为 link。接着调用 text 方法传入文本,li 节点内部的文本全被改为传入的字符串文本。最后,调用 html 方法传入 HTML 文本,li 节点内部又变为传入的 HTML 文本了。

所以说,使用 attr 方法时如果只传入第 1 个参数的属性名,则是获取这个属性值;如果传入第 2 个参数,可以用来修改属性值。使用 text 和 html 方法时如果不传参数,则是获取节点内纯文本和 HTML 文本,如果传入参数,则进行赋值。

remove

顾名思义,remove 方法就是移除,它有时会为信息的提取带来非常大的便利。下面有一段 HTML 文本:

html = '''
			<div class="wrap">
			    Hello, World
			    <p>This is a paragraph.</p>
			 </div>
			'''
			from pyquery import PyQuery as pq
			doc = pq(html)
			wrap = doc('.wrap')
			print(wrap.text())
			

现在我们想提取“Hello, World”这个字符串,该怎样操作呢?

这里先直接尝试提取 class 为 wrap 的节点的内容,看看是不是我们想要的。

运行结果如下:

Hello, World This is a paragraph.
			

这个结果还包含了内部的 p 节点的内容,也就是说 text 把所有的纯文本全提取出来了。

如果我们想去掉 p 节点内部的文本,可以选择再把 p 节点内的文本提取一遍,然后从整个结果中移除这个子串,但这个做法明显比较烦琐。

这时 remove 方法就可以派上用场了,我们可以接着这么做:

wrap.find('p').remove()
			print(wrap.text())
			

首先选中 p 节点,然后调用 remove 方法将其移除,这时 wrap 内部就只剩下“Hello, World”这句话了,最后利用 text 方法提取即可。

其实还有很多其他节点操作的方法,比如 append、empty 和 prepend 等方法,详细的用法可以参考官方文档:http://pyquery.readthedocs.io/en/latest/api.html

伪类选择器

CSS 选择器之所以强大,还有一个很重要的原因,那就是它支持多种多样的伪类选择器,例如选择第一个节点、最后一个节点、奇偶数节点、包含某一文本的节点等。示例如下:

html = '''
			<div class="wrap">
			    <div id="container">
			        <ul class="list">
			             <li class="item-0">first item</li>
			             <li class="item-1"><a href="link2.html">second item</a></li>
			             <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
			             <li class="item-1 active"><a href="link4.html">fourth item</a></li>
			             <li class="item-0"><a href="link5.html">fifth item</a></li>
			         </ul>
			     </div>
			 </div>
			'''
			from pyquery import PyQuery as pq
			doc = pq(html)
			li = doc('li:first-child')
			print(li)
			li = doc('li:last-child')
			print(li)
			li = doc('li:nth-child(2)')
			print(li)
			li = doc('li:gt(2)')
			print(li)
			li = doc('li:nth-child(2n)')
			print(li)
			li = doc('li:contains(second)')
			print(li)
			

在这个例子中我们使用了 CSS3 的伪类选择器,依次选择了第 1 个 li 节点、最后一个 li 节点、第 2 个 li 节点、第 3 个 li 之后的 li 节点、偶数位置的 li 节点、包含 second 文本的 li 节点。

关于 CSS 选择器的更多用法,可以参考 http://www.w3school.com.cn/css/index.asp

到此为止,pyquery 的常用用法就介绍完了。如果想查看更多的内容,可以参考 pyquery 的官方文档:http://pyquery.readthedocs.io。相信一旦你拥有了它,解析网页将不再是难事。

第10讲:高效存储 MongoDB 的用法

上节课我们学习了如何用 pyquery 提取 HTML 中的信息,但是当我们成功提取了数据之后,该往哪里存放呢?

用文本文件当然是可以的,但文本存储不方便检索。有没有既方便存,又方便检索的存储方式呢?

当然有,本课时我将为你介绍一个文档型数据库 —— MongoDB。

MongoDB 是由 C++ 语言编写的非关系型数据库,是一个基于分布式文件存储的开源数据库系统,其内容存储形式类似 JSON 对象,它的字段值可以包含其他文档、数组及文档数组,非常灵活。

在这个课时中,我们就来看看 Python 3 下 MongoDB 的存储操作。

准备工作

在开始之前,请确保你已经安装好了 MongoDB 并启动了其服务,同时安装好了 Python 的 PyMongo 库。

MongoDB 的安装方式可以参考:https://cuiqingcai.com/5205.html,安装好之后,我们需要把 MongoDB 服务启动起来。

注意:这里我们为了学习,仅使用 MongoDB 最基本的单机版,MongoDB 还有主从复制、副本集、分片集群等集群架构,可用性可靠性更好,如有需要可以自行搭建相应的集群进行使用。

启动完成之后,它会默认在本地 localhost 的 27017 端口上运行。

接下来我们需要安装 PyMongo 这个库,它是 Python 用来操作 MongoDB 的第三方库,直接用 pip3 安装即可:pip3 install pymongo

更详细的安装方式可以参考:https://cuiqingcai.com/5230.html

安装完成之后,我们就可以使用 PyMongo 来将数据存储到 MongoDB 了。

连接 MongoDB

连接 MongoDB 时,我们需要使用 PyMongo 库里面的 MongoClient。一般来说,我们只需要向其传入 MongoDB 的 IP 及端口即可,其中第一个参数为地址 host,第二个参数为端口 port(如果不给它传递参数,则默认是 27017):

import pymongo
			client = pymongo.MongoClient(host='localhost', port=27017)
			

这样我们就可以创建 MongoDB 的连接对象了。

另外,MongoClient 的第一个参数 host 还可以直接传入 MongoDB 的连接字符串,它以 mongodb 开头,例如:

client = MongoClient('mongodb://localhost:27017/')
			

这样也可以达到同样的连接效果。

指定数据库

MongoDB 中可以建立多个数据库,接下来我们需要指定操作其中一个数据库。这里我们以 test 数据库作为下一步需要在程序中指定使用的例子:

db = client.test
			

这里调用 client 的 test 属性即可返回 test 数据库。当然,我们也可以这样指定:

db = client['test']
			

这两种方式是等价的。

指定集合

MongoDB 的每个数据库又包含许多集合(collection),它们类似于关系型数据库中的表。

下一步需要指定要操作的集合,这里我们指定一个名称为 students 的集合。与指定数据库类似,指定集合也有两种方式:

collection = db.students
			

或是

collection = db['students']
			

这样我们便声明了一个 Collection 对象。

插入数据

接下来,便可以插入数据了。我们对 students 这个集合新建一条学生数据,这条数据以字典形式表示:

student = {
			    'id': '20170101',
			    'name': 'Jordan',
			    'age': 20,
			    'gender': 'male'
			}
			

新建的这条数据里指定了学生的学号、姓名、年龄和性别。接下来,我们直接调用 collection 的 insert 方法即可插入数据,代码如下:

result = collection.insert(student)
			print(result)
			

在 MongoDB 中,每条数据其实都有一个 _id 属性来唯一标识。如果没有显式指明该属性,MongoDB 会自动产生一个 ObjectId 类型的 _id 属性。insert() 方法会在执行后返回_id 值。

运行结果如下:

5932a68615c2606814c91f3d
			

当然,我们也可以同时插入多条数据,只需要以列表形式传递即可,示例如下:

student1 = {
			    'id': '20170101',
			    'name': 'Jordan',
			    'age': 20,
			    'gender': 'male'
			}

			student2 = {
			    'id': '20170202',
			    'name': 'Mike',
			    'age': 21,
			    'gender': 'male'
			}

			result = collection.insert([student1, student2])
			print(result)
			

返回结果是对应的_id 的集合:

[ObjectId('5932a80115c2606a59e8a048'), ObjectId('5932a80115c2606a59e8a049')]
			

实际上,在 PyMongo 中,官方已经不推荐使用 insert 方法了。但是如果你要继续使用也没有什么问题。目前,官方推荐使用 insert_one 和 insert_many 方法来分别插入单条记录和多条记录,示例如下:

student = {
			    'id': '20170101',
			    'name': 'Jordan',
			    'age': 20,
			    'gender': 'male'
			}

			result = collection.insert_one(student)
			print(result)
			print(result.inserted_id)
			

运行结果如下:

<pymongo.results.InsertOneResult object at 0x10d68b558>
			5932ab0f15c2606f0c1cf6c5
			

与 insert 方法不同,这次返回的是 InsertOneResult 对象,我们可以调用其 inserted_id 属性获取_id。

对于 insert_many 方法,我们可以将数据以列表形式传递,示例如下:

student1 = {
			    'id': '20170101',
			    'name': 'Jordan',
			    'age': 20,
			    'gender': 'male'
			}

			student2 = {
			    'id': '20170202',
			    'name': 'Mike',
			    'age': 21,
			    'gender': 'male'
			}

			result = collection.insert_many([student1, student2])
			print(result)
			print(result.inserted_ids)
			

运行结果如下:

<pymongo.results.InsertManyResult object at 0x101dea558>
			[ObjectId('5932abf415c2607083d3b2ac'), ObjectId('5932abf415c2607083d3b2ad')]
			

该方法返回的类型是 InsertManyResult,调用 inserted_ids 属性可以获取插入数据的 _id 列表。

查询

插入数据后,我们可以利用 find_one 或 find 方法进行查询,其中 find_one 查询得到的是单个结果,find 则返回一个生成器对象。示例如下:

result = collection.find_one({'name': 'Mike'})
			print(type(result))
			print(result)
			

这里我们查询 name 为 Mike 的数据,它的返回结果是字典类型,运行结果如下:

<class 'dict'>
			{'_id': ObjectId('5932a80115c2606a59e8a049'), 'id': '20170202', 'name': 'Mike', 'age': 21, 'gender': 'male'}
			

可以发现,它多了 _id 属性,这就是 MongoDB 在插入过程中自动添加的。

此外,我们也可以根据 ObjectId 来查询,此时需要调用 bson 库里面的 objectid:

from bson.objectid import ObjectId

			result = collection.find_one({'_id': ObjectId('593278c115c2602667ec6bae')})
			print(result)
			

其查询结果依然是字典类型,具体如下:

{'_id': ObjectId('593278c115c2602667ec6bae'), 'id': '20170101', 'name': 'Jordan', 'age': 20, 'gender': 'male'}
			

如果查询结果不存在,则会返回 None。

对于多条数据的查询,我们可以使用 find 方法。例如,这里查找年龄为 20 的数据,示例如下:

results = collection.find({'age': 20})
			print(results)
			for result in results:
			    print(result)
			

运行结果如下:

<pymongo.cursor.Cursor object at 0x1032d5128>
			{'_id': ObjectId('593278c115c2602667ec6bae'), 'id': '20170101', 'name': 'Jordan', 'age': 20, 'gender': 'male'}
			{'_id': ObjectId('593278c815c2602678bb2b8d'), 'id': '20170102', 'name': 'Kevin', 'age': 20, 'gender': 'male'}
			{'_id': ObjectId('593278d815c260269d7645a8'), 'id': '20170103', 'name': 'Harden', 'age': 20, 'gender': 'male'}
			

返回结果是 Cursor 类型,它相当于一个生成器,我们需要遍历获取的所有结果,其中每个结果都是字典类型。

如果要查询年龄大于 20 的数据,则写法如下:

results = collection.find({'age': {'$gt': 20}})
			

这里查询的条件键值已经不是单纯的数字了,而是一个字典,其键名为比较符号 $gt,意思是大于,键值为 20。

我将比较符号归纳为下表:

另外,还可以进行正则匹配查询。例如,查询名字以 M 开头的学生数据,示例如下:

results = collection.find({'name': {'$regex': '^M.*'}})
			

这里使用 $regex 来指定正则匹配,^M.* 代表以 M 开头的正则表达式。

我将一些功能符号归类为下表:


关于这些操作的更详细用法,可以在 MongoDB 官方文档找到: https://docs.mongodb.com/manual/reference/operator/query/

计数

要统计查询结果有多少条数据,可以调用 count 方法。我们以统计所有数据条数为例:

count = collection.find().count()
			print(count)
			

我们还可以统计符合某个条件的数据:

count = collection.find({'age': 20}).count()
			print(count)
			

运行结果是一个数值,即符合条件的数据条数。

排序

排序时,我们可以直接调用 sort 方法,并在其中传入排序的字段及升降序标志。示例如下:

results = collection.find().sort('name', pymongo.ASCENDING)
			print([result['name'] for result in results])
			

运行结果如下:

['Harden', 'Jordan', 'Kevin', 'Mark', 'Mike']
			

这里我们调用 pymongo.ASCENDING 指定升序。如果要降序排列,可以传入 pymongo.DESCENDING。

偏移

在某些情况下,我们可能只需要取某几个元素,这时可以利用 skip 方法偏移几个位置,比如偏移 2,就代表忽略前两个元素,得到第 3 个及以后的元素:

results = collection.find().sort('name', pymongo.ASCENDING).skip(2)
			print([result['name'] for result in results])
			

运行结果如下:

['Kevin', 'Mark', 'Mike']
			

另外,我们还可以用 limit 方法指定要取的结果个数,示例如下:

results = collection.find().sort('name', pymongo.ASCENDING).skip(2).limit(2)
			print([result['name'] for result in results])
			

运行结果如下:

['Kevin', 'Mark']
			

如果不使用 limit 方法,原本会返回 3 个结果,加了限制后,就会截取两个结果返回。

值得注意的是,在数据量非常庞大的时候,比如在查询千万、亿级别的数据库时,最好不要使用大的偏移量,因为这样很可能导致内存溢出。此时可以使用类似如下操作来查询:

from bson.objectid import ObjectId
			collection.find({'_id': {'$gt': ObjectId('593278c815c2602678bb2b8d')}})
			

这时需要记录好上次查询的 _id。

更新

对于数据更新,我们可以使用 update 方法,指定更新的条件和更新后的数据即可。例如:

condition = {'name': 'Kevin'}
			student = collection.find_one(condition)
			student['age'] = 25
			result = collection.update(condition, student)
			print(result)
			

这里我们要更新 name 为 Kevin 的数据的年龄:首先指定查询条件,然后将数据查询出来,修改年龄后调用 update 方法将原条件和修改后的数据传入。

运行结果如下:

{'ok': 1, 'nModified': 1, 'n': 1, 'updatedExisting': True}
			

返回结果是字典形式,ok 代表执行成功,nModified 代表影响的数据条数。

另外,我们也可以使用 $set 操作符对数据进行更新,代码如下:

result = collection.update(condition, {'$set': student})
			

这样可以只更新 student 字典内存在的字段。如果原先还有其他字段,则不会更新,也不会删除。而如果不用 $set 的话,则会把之前的数据全部用 student 字典替换;如果原本存在其他字段,则会被删除。

另外,update 方法其实也是官方不推荐使用的方法。这里也分为 update_one 方法和 update_many 方法,用法更加严格,它们的第 2 个参数需要使用 $ 类型操作符作为字典的键名,示例如下:

condition = {'name': 'Kevin'}
			student = collection.find_one(condition)
			student['age'] = 26
			result = collection.update_one(condition, {'$set': student})
			print(result)
			print(result.matched_count, result.modified_count)
			

上面的例子中调用了 update_one 方法,使得第 2 个参数不能再直接传入修改后的字典,而是需要使用 {'$set': student} 这样的形式,其返回结果是 UpdateResult 类型。然后分别调用 matched_count 和 modified_count 属性,可以获得匹配的数据条数和影响的数据条数。

运行结果如下:

<pymongo.results.UpdateResult object at 0x10d17b678>
			1 0
			

我们再看一个例子:

condition = {'age': {'$gt': 20}}
			result = collection.update_one(condition, {'$inc': {'age': 1}})
			print(result)
			print(result.matched_count, result.modified_count)
			

这里指定查询条件为年龄大于 20,然后更新条件为 {'$inc': {'age': 1}},表示年龄加 1,执行之后会将第一条符合条件的数据年龄加 1。

运行结果如下:

<pymongo.results.UpdateResult object at 0x10b8874c8>
			1 1
			

可以看到匹配条数为 1 条,影响条数也为 1 条。

如果调用 update_many 方法,则会将所有符合条件的数据都更新,示例如下:

condition = {'age': {'$gt': 20}}
			result = collection.update_many(condition, {'$inc': {'age': 1}})
			print(result)
			print(result.matched_count, result.modified_count)
			

这时匹配条数就不再为 1 条了,运行结果如下:

<pymongo.results.UpdateResult object at 0x10c6384c8>
			3 3
			

可以看到,这时所有匹配到的数据都会被更新。

删除

删除操作比较简单,直接调用 remove 方法指定删除的条件即可,此时符合条件的所有数据均会被删除。

示例如下:

result = collection.remove({'name': 'Kevin'})
			print(result)
			

运行结果如下:

{'ok': 1, 'n': 1}
			

另外,这里依然存在两个新的推荐方法 —— delete_one 和 delete_many,示例如下:

result = collection.delete_one({'name': 'Kevin'})
			print(result)
			print(result.deleted_count)
			result = collection.delete_many({'age': {'$lt': 25}})
			print(result.deleted_count)
			

运行结果如下:

<pymongo.results.DeleteResult object at 0x10e6ba4c8>
			1
			4
			

delete_one 即删除第一条符合条件的数据,delete_many 即删除所有符合条件的数据。它们的返回结果都是 DeleteResult 类型,可以调用 deleted_count 属性获取删除的数据条数。

其他操作

另外,PyMongo 还提供了一些组合方法,如 find_one_and_delete、find_one_and_replace 和 find_one_and_update,它们分别用于查找后删除、替换和更新操作,其使用方法与上述方法基本一致。

另外,我们还可以对索引进行操作,相关方法有 create_index、create_indexes 和 drop_index 等。

关于 PyMongo 的详细用法,可以参见官方文档:http://api.mongodb.com/python/current/api/pymongo/collection.html

另外,还有对数据库和集合本身等的一些操作,这里不再一一讲解,可以参见官方文档:http://api.mongodb.com/python/current/api/pymongo/

本课时的内容我们就讲到这里了,你是不是对如何使用 PyMongo 操作 MongoDB 进行数据增删改查更加熟悉了呢?下一课时我将会带你在实战案例中应用这些操作进行数据存储。

第11讲:Reqeusts + PyQuery + PyMongo 基本案例实战

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

准备工作

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

以上内容在前面的课时中均有讲解,如果你还没有准备好,那么我建议你可以再复习一下这些内容。

爬取目标

这节课我们以一个基本的静态网站作为案例进行爬取,需要爬取的链接为:https://static1.scrape.cuiqingcai.com/,这个网站里面包含了一些电影信息,界面如下:

首页是一个影片列表,每栏里都包含了这部电影的封面、名称、分类、上映时间、评分等内容,同时列表页还支持翻页,点击相应的页码我们就能进入到对应的新列表页。

如果我们点开其中一部电影,会进入电影的详情页面,比如我们点开第一部《霸王别姬》,会得到如下页面:

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

我们这节课要完成的目标是:

那么我们现在就开始吧。

爬取列表页

爬取的第一步肯定要从列表页入手,我们首先观察一下列表页的结构和翻页规则。在浏览器中访问 https://static1.scrape.cuiqingcai.com/,然后打开浏览器开发者工具,观察每一个电影信息区块对应的 HTML,以及进入到详情页的 URL 是怎样的,如图所示:

可以看到每部电影对应的区块都是一个 div 节点,它的 class 属性都有 el-card 这个值。每个列表页有 10 个这样的 div 节点,也就对应着 10 部电影的信息。

我们再分析下从列表页是怎么进入到详情页的,我们选中电影的名称,看下结果:

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

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

页面显示一共有 100 条数据,10 页的内容,因此页码最多是 10。接着我们点击第 2 页,如图所示:

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

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

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

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

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

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

import requests
			import logging
			import re
			import pymongo
			from pyquery import PyQuery as pq
			from urllib.parse import urljoin

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

			BASE_URL = 'https://static1.scrape.cuiqingcai.com'
			TOTAL_PAGE = 10
			

这里我们引入了 requests 用来爬取页面,logging 用来输出信息,re 用来实现正则表达式解析,pyquery 用来直接解析网页,pymongo 用来实现 MongoDB 存储,urljoin 用来做 URL 的拼接。

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

定义好了之后,我们来实现一个页面爬取的方法吧,实现如下:

def scrape_page(url):
			    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 代码了。

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

def scrape_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):
			    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 代码。接着我们用 pyquery 新建一个 PyQuery 对象,完成之后再用 .el-card .name 选择器选出来每个电影名称对应的超链接节点。我们遍历这些节点,通过调用 attr 方法并传入 href 获得详情页的 URL 路径,得到的 href 就是我们在上文所说的类似  /detail/1  这样的结果。由于这并不是一个完整的 URL,所以我们需要借助 urljoin 方法把 BASE_URL 和 href 拼接起来,获得详情页的完整 URL,得到的结果就是类似 https://static1.scrape.cuiqingcai.com/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 方法来完成上面所有方法的调用,首先使用 range 方法遍历一下页码,得到的 page 是 1~10,接着把 page 变量传给 scrape_index 方法,得到列表页的 HTML,赋值为 index_html 变量。接下来再将 index_html 变量传给 parse_index 方法,得到列表页所有电影的详情页 URL,赋值为 detail_urls,结果是一个生成器,我们调用 list 方法就可以将其输出出来。

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

2020-03-08 22:39:50,505 - INFO: scraping https://static1.scrape.cuiqingcai.com/page/1...
			2020-03-08 22:39:51,949 - INFO: get detail url https://static1.scrape.cuiqingcai.com/detail/1
			2020-03-08 22:39:51,950 - INFO: get detail url https://static1.scrape.cuiqingcai.com/detail/2
			2020-03-08 22:39:51,950 - INFO: get detail url https://static1.scrape.cuiqingcai.com/detail/3
			2020-03-08 22:39:51,950 - INFO: get detail url https://static1.scrape.cuiqingcai.com/detail/4
			2020-03-08 22:39:51,950 - INFO: get detail url https://static1.scrape.cuiqingcai.com/detail/5
			2020-03-08 22:39:51,950 - INFO: get detail url https://static1.scrape.cuiqingcai.com/detail/6
			2020-03-08 22:39:51,950 - INFO: get detail url https://static1.scrape.cuiqingcai.com/detail/7
			2020-03-08 22:39:51,950 - INFO: get detail url https://static1.scrape.cuiqingcai.com/detail/8
			2020-03-08 22:39:51,950 - INFO: get detail url https://static1.scrape.cuiqingcai.com/detail/9
			2020-03-08 22:39:51,950 - INFO: get detail url https://static1.scrape.cuiqingcai.com/detail/10
			2020-03-08 22:39:51,951 - INFO: detail urls ['https://static1.scrape.cuiqingcai.com/detail/1', 'https://static1.scrape.cuiqingcai.com/detail/2', 'https://static1.scrape.cuiqingcai.com/detail/3', 'https://static1.scrape.cuiqingcai.com/detail/4', 'https://static1.scrape.cuiqingcai.com/detail/5', 'https://static1.scrape.cuiqingcai.com/detail/6', 'https://static1.scrape.cuiqingcai.com/detail/7', 'https://static1.scrape.cuiqingcai.com/detail/8', 'https://static1.scrape.cuiqingcai.com/detail/9', 'https://static1.scrape.cuiqingcai.com/detail/10']
			2020-03-08 22:39:51,951 - INFO: scraping https://static1.scrape.cuiqingcai.com/page/2...
			2020-03-08 22:39:52,842 - INFO: get detail url https://static1.scrape.cuiqingcai.com/detail/11
			2020-03-08 22:39:52,842 - INFO: get detail url https://static1.scrape.cuiqingcai.com/detail/12
			...
			

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

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

爬取详情页

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

我们首先观察一下详情页的 HTML 代码吧,如图所示:

经过分析,我们想要提取的内容和对应的节点信息如下:

看上去有点复杂,但是不用担心,有了 pyquery 和正则表达式,我们可以轻松搞定。

接着我们来实现一下代码吧。

刚才我们已经成功获取了详情页的 URL,接下来我们要定义一个详情页的爬取方法,实现如下:

def scrape_detail(url):
			    return scrape_page(url)
			

这里定义了一个 scrape_detail 方法,它接收一个 url 参数,并通过调用 scrape_page 方法获得网页源代码。由于我们刚才已经实现了 scrape_page 方法,所以在这里我们不用再写一遍页面爬取的逻辑了,直接调用即可,这就做到了代码复用。

另外你可能会问,这个 scrape_detail 方法里面只调用了 scrape_page 方法,没有别的功能,那爬取详情页直接用 scrape_page 方法不就好了,还有必要再单独定义 scrape_detail 方法吗?

答案是有必要,单独定义一个 scrape_detail 方法在逻辑上会显得更清晰,而且以后如果我们想要对 scrape_detail 方法进行改动,比如添加日志输出或是增加预处理,都可以在 scrape_detail 里面实现,而不用改动 scrape_page 方法,灵活性会更好。

好了,详情页的爬取方法已经实现了,接着就是详情页的解析了,实现如下:

def parse_detail(html):
			    doc = pq(html)
			    cover = doc('img.cover').attr('src')
			    name = doc('a > h2').text()
			    categories = [item.text() for item in doc('.categories button span').items()]
			    published_at = doc('.info:contains(上映)').text()
			    published_at = re.search('(\d{4}-\d{2}-\d{2})', published_at).group(1) \
			        if published_at and re.search('\d{4}-\d{2}-\d{2}', published_at) else None
			    drama = doc('.drama p').text()
			    score = doc('p.score').text()
			    score = float(score) if score else None
			    return {
			        'cover': cover,
			        'name': name,
			        'categories': categories,
			        'published_at': published_at,
			        'drama': drama,
			        'score': score
			    }
			

这里我们定义了 parse_detail 方法用于解析详情页,它接收一个 html 参数,解析其中的内容,并以字典的形式返回结果。每个字段的解析情况如下所述:

上述字段提取完毕之后,构造一个字典返回即可。

这样,我们就成功完成了详情页的提取和分析了。

最后,我们将 main 方法稍微改写一下,增加这两个方法的调用,改写如下:

def main():
			    for page in range(1, TOTAL_PAGE + 1):
			        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)
			

这里我们首先遍历了 detail_urls,获取了每个详情页的 URL,然后依次调用了 scrape_detail 和 parse_detail 方法,最后得到了每个详情页的提取结果,赋值为 data 并输出。

运行结果如下:

2020-03-08 23:37:35,936 - INFO: scraping https://static1.scrape.cuiqingcai.com/page/1...
			2020-03-08 23:37:36,833 - INFO: get detail url https://static1.scrape.cuiqingcai.com/detail/1
			2020-03-08 23:37:36,833 - INFO: scraping https://static1.scrape.cuiqingcai.com/detail/1...
			2020-03-08 23:37:39,985 - INFO: get detail data {'cover': 'https://p0.meituan.net/movie/ce4da3e03e655b5b88ed31b5cd7896cf62472.jpg@464w_644h_1e_1c', 'name': '霸王别姬 - Farewell My Concubine', 'categories': ['剧情', '爱情'], 'published_at': '1993-07-26', 'drama': '影片借一出《霸王别姬》的京戏,牵扯出三个人之间一段随时代风云变幻的爱恨情仇。段小楼(张丰毅 饰)与程蝶衣(张国荣 饰)是一对打小一起长大的师兄弟,两人一个演生,一个饰旦,一向配合天衣无缝,尤其一出《霸王别姬》,更是誉满京城,为此,两人约定合演一辈子《霸王别姬》。但两人对戏剧与人生关系的理解有本质不同,段小楼深知戏非人生,程蝶衣则是人戏不分。段小楼在认为该成家立业之时迎娶了名妓菊仙(巩俐 饰),致使程蝶衣认定菊仙是可耻的第三者,使段小楼做了叛徒,自此,三人围绕一出《霸王别姬》生出的爱恨情仇战开始随着时代风云的变迁不断升级,终酿成悲剧。', 'score': 9.5}
			2020-03-08 23:37:39,985 - INFO: get detail url https://static1.scrape.cuiqingcai.com/detail/2
			2020-03-08 23:37:39,985 - INFO: scraping https://static1.scrape.cuiqingcai.com/detail/2...
			2020-03-08 23:37:41,061 - INFO: get detail data {'cover': 'https://p1.meituan.net/movie/6bea9af4524dfbd0b668eaa7e187c3df767253.jpg@464w_644h_1e_1c', 'name': '这个杀手不太冷 - Léon', 'categories': ['剧情', '动作', '犯罪'], 'published_at': '1994-09-14', 'drama': '里昂(让·雷诺 饰)是名孤独的职业杀手,受人雇佣。一天,邻居家小姑娘马蒂尔德(纳塔丽·波特曼 饰)敲开他的房门,要求在他那里暂避杀身之祸。原来邻居家的主人是警方缉毒组的眼线,只因贪污了一小包毒品而遭恶警(加里·奥德曼 饰)杀害全家的惩罚。马蒂尔德 得到里昂的留救,幸免于难,并留在里昂那里。里昂教小女孩使枪,她教里昂法文,两人关系日趋亲密,相处融洽。 女孩想着去报仇,反倒被抓,里昂及时赶到,将女孩救回。混杂着哀怨情仇的正邪之战渐次升级,更大的冲突在所难免……', 'score': 9.5}
			2020-03-08 23:37:41,062 - INFO: get detail url https://static1.scrape.cuiqingcai.com/detail/3
			...
			

由于内容较多,这里省略了后续内容。

可以看到,我们已经成功提取出每部电影的基本信息,包括封面、名称、类别,等等。

保存到 MongoDB

成功提取到详情页信息之后,下一步我们就要把数据保存起来了。在上一课时我们学习了 MongoDB 的相关操作,接下来我们就把数据保存到 MongoDB 吧。

在这之前,请确保现在有一个可以正常连接和使用的 MongoDB 数据库。

将数据导入 MongoDB 需要用到 PyMongo 这个库,这个在最开始已经引入过了。那么接下来我们定义一下 MongoDB 的连接配置,实现如下:

MONGO_CONNECTION_STRING = 'mongodb://localhost:27017'
			MONGO_DB_NAME = 'movies'
			MONGO_COLLECTION_NAME = 'movies'

			client = pymongo.MongoClient(MONGO_CONNECTION_STRING)
			db = client['movies']
			collection = db['movies']
			

在这里我们声明了几个变量,介绍如下:

这里我们用 MongoClient 声明了一个连接对象,然后依次声明了存储的数据库和集合。

接下来,我们再实现一个将数据保存到 MongoDB 的方法,实现如下:

def save_data(data):
			    collection.update_one({
			        'name': data.get('name')
			    }, {
			        '$set': data
			    }, upsert=True)
			

在这里我们声明了一个 save_data 方法,它接收一个 data 参数,也就是我们刚才提取的电影详情信息。在方法里面,我们调用了 update_one 方法,第 1 个参数是查询条件,即根据 name 进行查询;第 2 个参数是 data 对象本身,也就是所有的数据,这里我们用 $set 操作符表示更新操作;第 3 个参数很关键,这里实际上是 upsert 参数,如果把这个设置为 True,则可以做到存在即更新,不存在即插入的功能,更新会根据第一个参数设置的 name 字段,所以这样可以防止数据库中出现同名的电影数据。

注:实际上电影可能有同名,但该场景下的爬取数据没有同名情况,当然这里更重要的是实现 MongoDB 的去重操作。

好的,那么接下来我们将 main 方法稍微改写一下就好了,改写如下:

def main():
			    for page in range(1, TOTAL_PAGE + 1):
			        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')
			

这里增加了 save_data 方法的调用,并加了一些日志信息。

重新运行,我们看下输出结果:

2020-03-09 01:10:27,094 - INFO: scraping https://static1.scrape.cuiqingcai.com/page/1...
			2020-03-09 01:10:28,019 - INFO: get detail url https://static1.scrape.cuiqingcai.com/detail/1
			2020-03-09 01:10:28,019 - INFO: scraping https://static1.scrape.cuiqingcai.com/detail/1...
			2020-03-09 01:10:29,183 - INFO: get detail data {'cover': 'https://p0.meituan.net/movie/ce4da3e03e655b5b88ed31b5cd7896cf62472.jpg@464w_644h_1e_1c', 'name': '霸王别姬 - Farewell My Concubine', 'categories': ['剧情', '爱情'], 'published_at': '1993-07-26', 'drama': '影片借一出《霸王别姬》的京戏,牵扯出三个人之间一段随时代风云变幻的爱恨情仇。段小楼(张丰毅 饰)与程蝶衣(张国荣 饰)是一对打小一起长大的师兄弟,两人一个演生,一个饰旦,一向配合天衣无缝,尤其一出《霸王别姬》,更是誉满京城,为此,两人约定合演一辈子《霸王别姬》。但两人对戏剧与人生关系的理解有本质不同,段小楼深知戏非人生,程蝶衣则是人戏不分。段小楼在认为该成家立业之时迎娶了名妓菊仙(巩俐 饰),致使程蝶衣认定菊仙是可耻的第三者,使段小楼做了叛徒,自此,三人围绕一出《霸王别姬》生出的爱恨情仇战开始随着时代风云的变迁不断升级,终酿成悲剧。', 'score': 9.5}
			2020-03-09 01:10:29,183 - INFO: saving data to mongodb
			2020-03-09 01:10:29,288 - INFO: data saved successfully
			2020-03-09 01:10:29,288 - INFO: get detail url https://static1.scrape.cuiqingcai.com/detail/2
			2020-03-09 01:10:29,288 - INFO: scraping https://static1.scrape.cuiqingcai.com/detail/2...
			2020-03-09 01:10:30,250 - INFO: get detail data {'cover': 'https://p1.meituan.net/movie/6bea9af4524dfbd0b668eaa7e187c3df767253.jpg@464w_644h_1e_1c', 'name': '这个杀手不太冷 - Léon', 'categories': ['剧情', '动作', '犯罪'], 'published_at': '1994-09-14', 'drama': '里昂(让·雷诺 饰)是名孤独的职业杀手,受人雇佣。一天,邻居家小姑娘马蒂尔德(纳塔丽·波特曼 饰)敲开他的房门,要求在他那里暂避杀身之祸。原来邻居家的主人是警方缉毒组的眼线,只因贪污了一小包毒品而遭恶警(加里·奥德曼 饰)杀害全家的惩罚。马蒂尔德 得到里昂的留救,幸免于难,并留在里昂那里。里昂教小女孩使枪,她教里昂法文,两人关系日趋亲密,相处融洽。 女孩想着去报仇,反倒被抓,里昂及时赶到,将女孩救回。混杂着哀怨情仇的正邪之战渐次升级,更大的冲突在所难免……', 'score': 9.5}
			2020-03-09 01:10:30,250 - INFO: saving data to mongodb
			2020-03-09 01:10:30,253 - INFO: data saved successfully
			...
			

在运行结果中我们可以发现,这里输出了存储 MongoDB 成功的信息。

运行完毕之后我们可以使用 MongoDB 客户端工具(例如 Robo 3T )可视化地查看已经爬取到的数据,结果如下:

这样,所有的电影就被我们成功爬取下来啦!不多不少,正好 100 条。

多进程加速

由于整个的爬取是单进程的,而且只能逐条爬取,速度稍微有点慢,有没有方法来对整个爬取过程进行加速呢?

在前面我们讲了多进程的基本原理和使用方法,下面我们就来实践一下多进程的爬取吧。

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

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

import multiprocessing

			def main(page):
			    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')

			if __name__ == '__main__':
			    pool = multiprocessing.Pool()
			    pages = range(1, TOTAL_PAGE + 1)
			    pool.map(main, pages)
			    pool.close()
			    pool.join()
			

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

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

运行输出结果和之前类似,但是可以明显看到加了多进程执行之后,爬取速度快了非常多。我们可以清空一下之前的 MongoDB 数据,可以发现数据依然可以被正常保存到 MongoDB 数据库中。

总结

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

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

本节代码:https://github.com/Python3WebSpider/ScrapeStatic1

第12讲: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 请求,就可以轻松提取我们所需要的信息了。

第13讲:Ajax 爬取案例实战

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

准备工作

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

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

爬取目标

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

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

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

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

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

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

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

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

初步探索

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

import requests

			url = 'https://dynamic1.scrape.cuiqingcai.com/'
			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.cuiqingcai.com/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.cuiqingcai.com/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.cuiqingcai.com/detail/40,页面也成功展示了详情页的信息,如图所示。

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

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

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

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

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

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

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

DETAIL_URL = 'https://dynamic1.scrape.cuiqingcai.com/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.cuiqingcai.com/api/movie/?limit=10&offset=0...
			2020-03-19 02:51:56,446 - INFO: scraping https://dynamic1.scrape.cuiqingcai.com/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.cuiqingcai.com/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 数据,所以在一定程度上可以避免一些数据提取的工作,减轻我们的工作量。

本节代码下载地址:https://github.com/Python3WebSpider/ScrapeDynamic1

第14讲:Selenium 的基本使用

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

但是,在很多情况下,Ajax 请求的接口通常会包含加密的参数,如 token、sign 等,如:https://dynamic2.scrape.cuiqingcai.com/,它的 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.cuiqingcai.com/' 
			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 .cuiqingcai.com/img/logo.a508a8f0.png
			

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

获取文本值

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

from selenium import webdriver 
			browser = webdriver.Chrome()
			url = 'https://dynamic2.scrape.cuiqingcai.com/' 
			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.cuiqingcai.com/' 
			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.cuiqingcai.com/') 
			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.cuiqingcai.com/,这个网站就是使用了上述原理实现了 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.cuiqingcai.com/')
			

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

对于大多数的情况,以上的方法均可以实现 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 渲染的页面不再是难事。

本课时课的内容就到这里,<span class="colour" style="color:rgb(51, 51, 51)"> 面我们会用一个实例来演示 Selenium 爬取网站的流程,记得按时来听课哟。

本节代码:https://github.com/Python3WebSpider/SeleniumTest

第15讲:Selenium 爬取实战

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

准备工作

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

适用场景

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

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

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

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

爬取目标

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

初看之下页面和之前也没有什么区别,但仔细观察可以发现其 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 渲染完成的页面源码,再提取数据就好了。

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

爬取列表页

首先要我们要做如下初始化的工作,代码如下:

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.cuiqingcai.com/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.cuiqingcai.com/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.cuiqingcai.com/page/1
			2020-03-29 12:03:13,724 - INFO: details urls ['https://dynamic2.scrape.cuiqingcai.com/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx',
			...
			'https://dynamic2.scrape.cuiqingcai.com/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWI5''https://dynamic2.scrape.cuiqingcai.com/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIxMA==']
			2020-03-29 12:03:13,724 - INFO: scraping https://dynamic2.scrape.cuiqingcai.com/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、名称、类别、封面、分数、简介等内容,提取方式如下:

最后,我们把结果构造成一个字典返回即可。

接下来,我们在 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.cuiqingcai.com/page/1
			2020-03-29 12:24:16,997 - INFO: get detail url https://dynamic2.scrape.cuiqingcai.com/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx
			2020-03-29 12:24:16,997 - INFO: scraping https://dynamic2.scrape.cuiqingcai.com/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx
			2020-03-29 12:24:19,289 - INFO: detail data {'url''https://dynamic2.scrape.cuiqingcai.com/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.cuiqingcai.com/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIy
			2020-03-29 12:24:19,291 - INFO: scraping https://dynamic2.scrape.cuiqingcai.com/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIy
			2020-03-29 12:24:21,524 - INFO: detail data {'url''https://dynamic2.scrape.cuiqingcai.com/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 的使用有进一步的掌握。

以后我们就知道什么时候可以用 Selenium 以及怎样使用 Selenium 来完成页面的爬取啦。

第16讲:异步爬虫的原理和解析

我们知道爬虫是 IO 密集型任务,比如如果我们使用 requests 库来爬取某个站点的话,发出一个请求之后,程序必须要等待网站返回响应之后才能接着运行,而在等待响应的过程中,整个爬虫程序是一直在等待的,实际上没有做任何的事情。对于这种情况我们有没有优化方案呢?

实例引入

比如在这里我们看这么一个示例网站:https://static4.scrape.cuiqingcai.com/,如图所示。

这个网站在内部实现返回响应的逻辑的时候特意加了 5 秒的延迟,也就是说如果我们用 requests 来爬取其中某个页面的话,至少需要 5 秒才能得到响应。

另外这个网站的逻辑结构在之前的案例中我们也分析过,其内容就是电影数据,一共 100 部,每个电影的详情页是一个自增 ID,从 1~100,比如 https://static4.scrape.cuiqingcai.com/detail/43 就代表第 43 部电影,如图所示。

下面我们来用 requests 写一个遍历程序,直接遍历 1~100 部电影数据,代码实现如下:

import requests
			import logging
			import time
			logging.basicConfig(level=logging.INFO,
			                   format='%(asctime)s - %(levelname)s: %(message)s')
			TOTAL_NUMBER = 100
			BASE_URL = 'https://static4.scrape.cuiqingcai.com/detail/{id}'
			start_time = time.time()
			for id in range(1, TOTAL_NUMBER + 1):
			   url = BASE_URL.format(id=id)
			   logging.info('scraping %s', url)
			   response = requests.get(url)
			end_time = time.time()
			logging.info('total time %s seconds', end_time - start_time)
			

这里我们直接用循环的方式构造了 100 个详情页的爬取,使用的是 requests 单线程,在爬取之前和爬取之后记录下时间,最后输出爬取了 100 个页面消耗的时间。

运行结果如下:

2020-03-31 14:40:35,411 - INFO: scraping https://static4.scrape.cuiqingcai.com/detail/1
			2020-03-31 14:40:40,578 - INFO: scraping https://static4.scrape.cuiqingcai.com/detail/2
			2020-03-31 14:40:45,658 - INFO: scraping https://static4.scrape.cuiqingcai.com/detail/3
			2020-03-31 14:40:50,761 - INFO: scraping https://static4.scrape.cuiqingcai.com/detail/4
			2020-03-31 14:40:55,852 - INFO: scraping https://static4.scrape.cuiqingcai.com/detail/5
			2020-03-31 14:41:00,956 - INFO: scraping https://static4.scrape.cuiqingcai.com/detail/6
			...
			2020-03-31 14:48:58,785 - INFO: scraping https://static4.scrape.cuiqingcai.com/detail/99
			2020-03-31 14:49:03,867 - INFO: scraping https://static4.scrape.cuiqingcai.com/detail/100
			2020-03-31 14:49:09,042 - INFO: total time 513.6309871673584 seconds
			2020-03-31 14:49:09,042 - INFO: total time 513.6309871673584 seconds
			

由于每个页面都至少要等待 5 秒才能加载出来,因此 100 个页面至少要花费 500 秒的时间,总的爬取时间最终为 513.6 秒,将近 9 分钟。

这个在实际情况下是很常见的,有些网站本身加载速度就比较慢,稍慢的可能 1~3 秒,更慢的说不定 10 秒以上才可能加载出来。如果我们用 requests 单线程这么爬取的话,总的耗时是非常多的。此时如果我们开了多线程或多进程来爬取的话,其爬取速度确实会成倍提升,但有没有更好的解决方案呢?

本课时我们就来了解一下使用异步执行方式来加速的方法,此种方法对于 IO 密集型任务非常有效。如将其应用到网络爬虫中,爬取效率甚至可以成百倍地提升。

基本了解

在了解异步协程之前,我们首先得了解一些基础概念,如阻塞和非阻塞、同步和异步、多进程和协程。

阻塞

阻塞状态指程序未得到所需计算资源时被挂起的状态。程序在等待某个操作完成期间,自身无法继续处理其他的事情,则称该程序在该操作上是阻塞的。

常见的阻塞形式有:网络 I/O 阻塞、磁盘 I/O 阻塞、用户输入阻塞等。阻塞是无处不在的,包括 CPU 切换上下文时,所有的进程都无法真正处理事情,它们也会被阻塞。如果是多核 CPU 则正在执行上下文切换操作的核不可被利用。

非阻塞

程序在等待某操作过程中,自身不被阻塞,可以继续处理其他的事情,则称该程序在该操作上是非阻塞的。

非阻塞并不是在任何程序级别、任何情况下都可以存在的。仅当程序封装的级别可以囊括独立的子程序单元时,它才可能存在非阻塞状态。

非阻塞的存在是因为阻塞存在,正因为某个操作阻塞导致的耗时与效率低下,我们才要把它变成非阻塞的。

同步

不同程序单元为了完成某个任务,在执行过程中需靠某种通信方式以协调一致,我们称这些程序单元是同步执行的。

例如购物系统中更新商品库存,需要用“行锁”作为通信信号,让不同的更新请求强制排队顺序执行,那更新库存的操作是同步的。

简言之,同步意味着有序。

异步

为完成某个任务,不同程序单元之间过程中无需通信协调,也能完成任务的方式,不相关的程序单元之间可以是异步的。

例如,爬虫下载网页。调度程序调用下载程序后,即可调度其他任务,而无需与该下载任务保持通信以协调行为。不同网页的下载、保存等操作都是无关的,也无需相互通知协调。这些异步操作的完成时刻并不确定。

简言之,异步意味着无序。

多进程

多进程就是利用 CPU 的多核优势,在同一时间并行地执行多个任务,可以大大提高执行效率。

协程

协程,英文叫作 Coroutine,又称微线程、纤程,协程是一种用户态的轻量级线程。

协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。因此协程能保留上一次调用时的状态,即所有局部状态的一个特定组合,每次过程重入时,就相当于进入上一次调用的状态。

协程本质上是个单进程,协程相对于多进程来说,无需线程上下文切换的开销,无需原子操作锁定及同步的开销,编程模型也非常简单。

我们可以使用协程来实现异步操作,比如在网络爬虫场景下,我们发出一个请求之后,需要等待一定的时间才能得到响应,但其实在这个等待过程中,程序可以干许多其他的事情,等到响应得到之后才切换回来继续处理,这样可以充分利用 CPU 和其他资源,这就是协程的优势。

协程用法

接下来,我们来了解下协程的实现,从 Python 3.4 开始,Python 中加入了协程的概念,但这个版本的协程还是以生成器对象为基础的,在 Python 3.5 则增加了 async/await,使得协程的实现更加方便。

Python 中使用协程最常用的库莫过于 asyncio,所以本文会以 asyncio 为基础来介绍协程的使用。

首先我们需要了解下面几个概念。

另外我们还需要了解 async/await 关键字,它是从 Python 3.5 才出现的,专门用于定义协程。其中,async 定义一个协程,await 用来挂起阻塞方法的执行。

定义协程

首先我们来定义一个协程,体验一下它和普通进程在实现上的不同之处,代码如下:

import asyncio
			async def execute(x):
			   print('Number:', x)
			coroutine = execute(1)
			print('Coroutine:', coroutine)
			print('After calling execute')
			loop = asyncio.get_event_loop()
			loop.run_until_complete(coroutine)
			print('After calling loop')
			运行结果:
			Coroutine: <coroutine object execute at 0x1034cf830>
			After calling execute
			Number: 1
			After calling loop
			

首先我们引入了 asyncio 这个包,这样我们才可以使用 async 和 await,然后我们使用 async 定义了一个 execute 方法,方法接收一个数字参数,方法执行之后会打印这个数字。

随后我们直接调用了这个方法,然而这个方法并没有执行,而是返回了一个 coroutine 协程对象。随后我们使用 get_event_loop 方法创建了一个事件循环 loop,并调用了 loop 对象的 run_until_complete 方法将协程注册到事件循环 loop 中,然后启动。最后我们才看到了 execute 方法打印了输出结果。

可见,async 定义的方法就会变成一个无法直接执行的 coroutine 对象,必须将其注册到事件循环中才可以执行。

上面我们还提到了 task,它是对 coroutine 对象的进一步封装,它里面相比 coroutine 对象多了运行状态,比如 running、finished 等,我们可以用这些状态来获取协程对象的执行情况。

在上面的例子中,当我们将 coroutine 对象传递给 run_until_complete 方法的时候,实际上它进行了一个操作就是将 coroutine 封装成了 task 对象,我们也可以显式地进行声明,如下所示:

import asyncio
			async def execute(x):
			   print('Number:', x)
			   return x
			coroutine = execute(1)
			print('Coroutine:', coroutine)
			print('After calling execute')
			loop = asyncio.get_event_loop()
			task = loop.create_task(coroutine)
			print('Task:', task)
			loop.run_until_complete(task)
			print('Task:', task)
			print('After calling loop')
			

运行结果:

Coroutine: <coroutine object execute at 0x10e0f7830>
			After calling execute
			Task: <Task pending coro=<execute() running at demo.py:4>>
			Number: 1
			Task: <Task finished coro=<execute() done, defined at demo.py:4> result=1>
			After calling loop
			

这里我们定义了 loop 对象之后,接着调用了它的 create_task 方法将 coroutine 对象转化为了 task 对象,随后我们打印输出一下,发现它是 pending 状态。接着我们将 task 对象添加到事件循环中得到执行,随后我们再打印输出一下 task 对象,发现它的状态就变成了 finished,同时还可以看到其 result 变成了 1,也就是我们定义的 execute 方法的返回结果。

另外定义 task 对象还有一种方式,就是直接通过 asyncio 的 ensure_future 方法,返回结果也是 task 对象,这样的话我们就可以不借助于 loop 来定义,即使我们还没有声明 loop 也可以提前定义好 task 对象,写法如下:

import asyncio
			async def execute(x):
			   print('Number:', x)
			   return x
			coroutine = execute(1)
			print('Coroutine:', coroutine)
			print('After calling execute')
			task = asyncio.ensure_future(coroutine)
			print('Task:', task)
			loop = asyncio.get_event_loop()
			loop.run_until_complete(task)
			print('Task:', task)
			print('After calling loop')
			

运行结果:

Coroutine: <coroutine object execute at 0x10aa33830>
			After calling execute
			Task: <Task pending coro=<execute() running at demo.py:4>>
			Number: 1
			Task: <Task finished coro=<execute() done, defined at demo.py:4> result=1>
			After calling loop
			

发现其运行效果都是一样的。

绑定回调

另外我们也可以为某个 task 绑定一个回调方法,比如我们来看下面的例子:

import asyncio
			import requests
			 
			async def request():
			   url = 'https://www.baidu.com'
			   status = requests.get(url)
			   return status
			 
			def callback(task):
			   print('Status:', task.result())
			 
			coroutine = request()
			task = asyncio.ensure_future(coroutine)
			task.add_done_callback(callback)
			print('Task:', task)
			 
			loop = asyncio.get_event_loop()
			loop.run_until_complete(task)
			print('Task:', task)
			

在这里我们定义了一个 request 方法,请求了百度,获取其状态码,但是这个方法里面我们没有任何 print 语句。随后我们定义了一个 callback 方法,这个方法接收一个参数,是 task 对象,然后调用 print 方法打印了 task 对象的结果。这样我们就定义好了一个 coroutine 对象和一个回调方法,我们现在希望的效果是,当 coroutine 对象执行完毕之后,就去执行声明的 callback 方法。

那么它们二者怎样关联起来呢?很简单,只需要调用 add_done_callback 方法即可,我们将 callback 方法传递给了封装好的 task 对象,这样当 task 执行完毕之后就可以调用 callback 方法了,同时 task 对象还会作为参数传递给 callback 方法,调用 task 对象的 result 方法就可以获取返回结果了。

运行结果:

Task: <Task pending coro=<request() running at demo.py:5> cb=[callback() at demo.py:11]>
			Status: <Response [200]>
			Task: <Task finished coro=<request() done, defined at demo.py:5> result=<Response [200]>>
			

实际上不用回调方法,直接在 task 运行完毕之后也可以直接调用 result 方法获取结果,如下所示:

import asyncio
			import requests
			 
			async def request():
			   url = 'https://www.baidu.com'
			   status = requests.get(url)
			   return status
			 
			coroutine = request()
			task = asyncio.ensure_future(coroutine)
			print('Task:', task)
			 
			loop = asyncio.get_event_loop()
			loop.run_until_complete(task)
			print('Task:', task)
			print('Task Result:', task.result())
			

运行结果是一样的:

Task: <Task pending coro=<request() running at demo.py:4>>
			Task: <Task finished coro=<request() done, defined at demo.py:4> result=<Response [200]>>
			Task Result: <Response [200]>
			

多任务协程

上面的例子我们只执行了一次请求,如果我们想执行多次请求应该怎么办呢?我们可以定义一个 task 列表,然后使用 asyncio 的 wait 方法即可执行,看下面的例子:

import asyncio
			import requests
			 
			async def request():
			   url = 'https://www.baidu.com'
			   status = requests.get(url)
			   return status
			 
			tasks = [asyncio.ensure_future(request()) for _ in range(5)]
			print('Tasks:', tasks)
			 
			loop = asyncio.get_event_loop()
			loop.run_until_complete(asyncio.wait(tasks))
			 
			for task in tasks:
			   print('Task Result:', task.result())
			

这里我们使用一个 for 循环创建了五个 task,组成了一个列表,然后把这个列表首先传递给了 asyncio 的 wait() 方法,然后再将其注册到时间循环中,就可以发起五个任务了。最后我们再将任务的运行结果输出出来,运行结果如下:

Tasks: [<Task pending coro=<request() running at demo.py:5>>, 
			<Task pending coro=<request() running at demo.py:5>>, 
			<Task pending coro=<request() running at demo.py:5>>, 
			<Task pending coro=<request() running at demo.py:5>>, 
			<Task pending coro=<request() running at demo.py:5>>]

			Task Result: <Response [200]>
			Task Result: <Response [200]>
			Task Result: <Response [200]>
			Task Result: <Response [200]>
			Task Result: <Response [200]>
			

可以看到五个任务被顺次执行了,并得到了运行结果。

协程实现

前面讲了这么多,又是 async,又是 coroutine,又是 task,又是 callback,但似乎并没有看出协程的优势啊?反而写法上更加奇怪和麻烦了,别急,上面的案例只是为后面的使用作铺垫,接下来我们正式来看下协程在解决 IO 密集型任务上有怎样的优势吧!

上面的代码中,我们用一个网络请求作为示例,这就是一个耗时等待的操作,因为我们请求网页之后需要等待页面响应并返回结果。耗时等待的操作一般都是 IO 操作,比如文件读取、网络请求等等。协程对于处理这种操作是有很大优势的,当遇到需要等待的情况的时候,程序可以暂时挂起,转而去执行其他的操作,从而避免一直等待一个程序而耗费过多的时间,充分利用资源。

为了表现出协程的优势,我们还是拿本课时开始介绍的网站 https://static4.scrape.cuiqingcai.com/ 为例来进行演示,因为该网站响应比较慢,所以我们可以通过爬取时间来直观地感受到爬取速度的提升。

为了让你更好地理解协程的正确使用方法,这里我们先来看看使用协程时常犯的错误,后面再给出正确的例子来对比一下。

首先,我们还是拿之前的 requests 来进行网页请求,接下来我们再重新使用上面的方法请求一遍:

import asyncio
			import requests
			import time
			 
			start = time.time()
			 
			async def request():
			   url = 'https://static4.scrape.cuiqingcai.com/'
			   print('Waiting for', url)
			   response = requests.get(url)
			   print('Get response from', url, 'response', response)
			 
			 
			tasks = [asyncio.ensure_future(request()) for _ in range(10)]
			loop = asyncio.get_event_loop()
			loop.run_until_complete(asyncio.wait(tasks))
			 
			end = time.time()
			print('Cost time:', end - start)
			

在这里我们还是创建了 10 个 task,然后将 task 列表传给 wait 方法并注册到时间循环中执行。

运行结果如下:

Waiting for https://static4.scrape.cuiqingcai.com/
			Get response from https://static4.scrape.cuiqingcai.com/ response <Response [200]>
			Waiting for https://static4.scrape.cuiqingcai.com/
			Get response from https://static4.scrape.cuiqingcai.com/ response <Response [200]>
			Waiting for https://static4.scrape.cuiqingcai.com/
			...
			Get response from https://static4.scrape.cuiqingcai.com/ response <Response [200]>
			Waiting for https://static4.scrape.cuiqingcai.com/
			Get response from https://static4.scrape.cuiqingcai.com/ response <Response [200]>
			Waiting for https://static4.scrape.cuiqingcai.com/
			Get response from https://static4.scrape.cuiqingcai.com/ response <Response [200]>
			Cost time: 51.422438859939575
			

可以发现和正常的请求并没有什么两样,依然还是顺次执行的,耗时 51 秒,平均一个请求耗时 5 秒,说好的异步处理呢?

其实,要实现异步处理,我们得先要有挂起的操作,当一个任务需要等待 IO 结果的时候,可以挂起当前任务,转而去执行其他任务,这样我们才能充分利用好资源,上面方法都是一本正经的串行走下来,连个挂起都没有,怎么可能实现异步?想太多了。

要实现异步,接下来我们需要了解一下 await 的用法,使用 await 可以将耗时等待的操作挂起,让出控制权。当协程执行的时候遇到 await,时间循环就会将本协程挂起,转而去执行别的协程,直到其他的协程挂起或执行完毕。

所以,我们可能会将代码中的 request 方法改成如下的样子:

async def request():
			   url = 'https://static4.scrape.cuiqingcai.com/'
			   print('Waiting for', url)
			   response = await requests.get(url)
			   print('Get response from', url, 'response', response)
			

仅仅是在 requests 前面加了一个 await,然而执行以下代码,会得到如下报错:

Waiting for https://static4.scrape.cuiqingcai.com/
			Waiting for https://static4.scrape.cuiqingcai.com/
			Waiting for https://static4.scrape.cuiqingcai.com/
			Waiting for https://static4.scrape.cuiqingcai.com/
			...
			Task exception was never retrieved
			future: <Task finished coro=<request() done, defined at demo.py:8> exception=TypeError("object Response can't be used in 'await' expression")>
			Traceback (most recent call last):
			 File "demo.py", line 11, in request
			   response = await requests.get(url)
			TypeError: object Response can't be used in 'await' expression
			

这次它遇到 await 方法确实挂起了,也等待了,但是最后却报了这么个错,这个错误的意思是 requests 返回的 Response 对象不能和 await 一起使用,为什么呢?因为根据官方文档说明,await 后面的对象必须是如下格式之一:

可以参见:https://www.python.org/dev/peps/pep-0492/#await-expression

requests 返回的 Response 不符合上面任一条件,因此就会报上面的错误了。

那么你可能会发现,既然 await 后面可以跟一个 coroutine 对象,那么我用 async 把请求的方法改成 coroutine 对象不就可以了吗?所以就改写成如下的样子:

import asyncio
			import requests
			import time
			 
			start = time.time()
			 
			async def get(url):
			   return requests.get(url)
			 
			async def request():
			   url = 'https://static4.scrape.cuiqingcai.com/'
			   print('Waiting for', url)
			   response = await get(url)
			   print('Get response from', url, 'response', response)
			 
			tasks = [asyncio.ensure_future(request()) for _ in range(10)]
			loop = asyncio.get_event_loop()
			loop.run_until_complete(asyncio.wait(tasks))
			 
			end = time.time()
			print('Cost time:', end - start)
			

这里我们将请求页面的方法独立出来,并用 async 修饰,这样就得到了一个 coroutine 对象,我们运行一下看看:

Waiting for https://static4.scrape.cuiqingcai.com/
			Get response from https://static4.scrape.cuiqingcai.com/ response <Response [200]>
			Waiting for https://static4.scrape.cuiqingcai.com/
			Get response from https://static4.scrape.cuiqingcai.com/ response <Response [200]>
			Waiting for https://static4.scrape.cuiqingcai.com/
			...
			Get response from https://static4.scrape.cuiqingcai.com/ response <Response [200]>
			Waiting for https://static4.scrape.cuiqingcai.com/
			Get response from https://static4.scrape.cuiqingcai.com/ response <Response [200]>
			Waiting for https://static4.scrape.cuiqingcai.com/
			Get response from https://static4.scrape.cuiqingcai.com/ response <Response [200]>
			Cost time: 51.394437756259273
			

还是不行,它还不是异步执行,也就是说我们仅仅将涉及 IO 操作的代码封装到 async 修饰的方法里面是不可行的!我们必须要使用支持异步操作的请求方式才可以实现真正的异步,所以这里就需要 aiohttp 派上用场了。

使用 aiohttp

aiohttp 是一个支持异步请求的库,利用它和 asyncio 配合我们可以非常方便地实现异步请求操作。

安装方式如下:

pip3 install aiohttp
			

官方文档链接为:https://aiohttp.readthedocs.io/,它分为两部分,一部分是 Client,一部分是 Server,详细的内容可以参考官方文档。

下面我们将 aiohttp 用上来,将代码改成如下样子:

import asyncio
			import aiohttp
			import time
			 
			start = time.time()
			 
			async def get(url):
			   session = aiohttp.ClientSession()
			   response = await session.get(url)
			   await response.text()
			   await session.close()
			   return response
			 
			async def request():
			   url = 'https://static4.scrape.cuiqingcai.com/'
			   print('Waiting for', url)
			   response = await get(url)
			   print('Get response from', url, 'response', response)
			 
			tasks = [asyncio.ensure_future(request()) for _ in range(10)]
			loop = asyncio.get_event_loop()
			loop.run_until_complete(asyncio.wait(tasks))
			 
			end = time.time()
			print('Cost time:', end - start)
			

在这里我们将请求库由 requests 改成了 aiohttp,通过 aiohttp 的 ClientSession 类的 get 方法进行请求,结果如下:

Waiting for https://static4.scrape.cuiqingcai.com/
			Waiting for https://static4.scrape.cuiqingcai.com/
			Waiting for https://static4.scrape.cuiqingcai.com/
			Waiting for https://static4.scrape.cuiqingcai.com/
			Waiting for https://static4.scrape.cuiqingcai.com/
			Waiting for https://static4.scrape.cuiqingcai.com/
			Waiting for https://static4.scrape.cuiqingcai.com/
			Waiting for https://static4.scrape.cuiqingcai.com/
			Waiting for https://static4.scrape.cuiqingcai.com/
			Waiting for https://static4.scrape.cuiqingcai.com/
			Get response from https://static4.scrape.cuiqingcai.com/ response <ClientResponse(https://static4.scrape.cuiqingcai.com/) [200 OK]>
			<CIMultiDictProxy('Server''nginx/1.17.8''Date''Tue, 31 Mar 2020 09:35:43 GMT''Content-Type''text/html; charset=utf-8''Transfer-Encoding''chunked''Connection''keep-alive''Vary''Accept-Encoding''X-Frame-Options''SAMEORIGIN''Strict-Transport-Security''max-age=15724800; includeSubDomains''Content-Encoding''gzip')>
			...
			Get response from https://static4.scrape.cuiqingcai.com/ response <ClientResponse(https://static4.scrape.cuiqingcai.com/) [200 OK]>
			<CIMultiDictProxy('Server''nginx/1.17.8''Date''Tue, 31 Mar 2020 09:35:44 GMT''Content-Type''text/html; charset=utf-8''Transfer-Encoding''chunked''Connection''keep-alive''Vary''Accept-Encoding''X-Frame-Options''SAMEORIGIN''Strict-Transport-Security''max-age=15724800; includeSubDomains''Content-Encoding''gzip')>
			Cost time: 6.1102519035339355
			

成功了!我们发现这次请求的耗时由 51 秒变直接成了 6 秒,耗费时间减少了非常非常多。

代码里面我们使用了 await,后面跟了 get 方法,在执行这 10 个协程的时候,如果遇到了 await,那么就会将当前协程挂起,转而去执行其他的协程,直到其他的协程也挂起或执行完毕,再进行下一个协程的执行。

开始运行时,时间循环会运行第一个 task,针对第一个 task 来说,当执行到第一个 await 跟着的 get 方法时,它被挂起,但这个 get 方法第一步的执行是非阻塞的,挂起之后立马被唤醒,所以立即又进入执行,创建了 ClientSession 对象,接着遇到了第二个 await,调用了 session.get 请求方法,然后就被挂起了,由于请求需要耗时很久,所以一直没有被唤醒。

当第一个 task 被挂起了,那接下来该怎么办呢?事件循环会寻找当前未被挂起的协程继续执行,于是就转而执行第二个 task 了,也是一样的流程操作,直到执行了第十个 task 的 session.get 方法之后,全部的 task 都被挂起了。所有 task 都已经处于挂起状态,怎么办?只好等待了。5 秒之后,几个请求几乎同时都有了响应,然后几个 task 也被唤醒接着执行,输出请求结果,最后总耗时,6 秒!

怎么样?这就是异步操作的便捷之处,当遇到阻塞式操作时,任务被挂起,程序接着去执行其他的任务,而不是傻傻地等待,这样可以充分利用 CPU 时间,而不必把时间浪费在等待 IO 上。

你可能会说,既然这样的话,在上面的例子中,在发出网络请求后,既然接下来的 5 秒都是在等待的,在 5 秒之内,CPU 可以处理的 task 数量远不止这些,那么岂不是我们放 10 个、20 个、50 个、100 个、1000 个 task 一起执行,最后得到所有结果的耗时不都是差不多的吗?因为这几个任务被挂起后都是一起等待的。

理论来说确实是这样的,不过有个前提,那就是服务器在同一时刻接受无限次请求都能保证正常返回结果,也就是服务器无限抗压,另外还要忽略 IO 传输时延,确实可以做到无限 task 一起执行且在预想时间内得到结果。但由于不同服务器处理的实现机制不同,可能某些服务器并不能承受这么高的并发,因此响应速度也会减慢。

在这里我们以百度为例,来测试下并发数量为 1、3、5、10、...、500 的情况下的耗时情况,代码如下:

import asyncio
			import aiohttp
			import time
			 
			 
			def test(number):
			   start = time.time()

			   async def get(url):
			       session = aiohttp.ClientSession()
			       response = await session.get(url)
			       await response.text()
			       await session.close()
			       return response

			   async def request():
			       url = 'https://www.baidu.com/'
			       await get(url)

			   tasks = [asyncio.ensure_future(request()) for _ in range(number)]
			   loop = asyncio.get_event_loop()
			   loop.run_until_complete(asyncio.wait(tasks))

			   end = time.time()
			   print('Number:', number, 'Cost time:', end - start)
			 
			for number in [1351015305075100200500]:
			   test(number)
			

运行结果如下:

Number: 1 Cost time: 0.05885505676269531
			Number: 3 Cost time: 0.05773782730102539
			Number: 5 Cost time: 0.05768704414367676
			Number: 10 Cost time: 0.15174412727355957
			Number: 15 Cost time: 0.09603095054626465
			Number: 30 Cost time: 0.17843103408813477
			Number: 50 Cost time: 0.3741800785064697
			Number: 75 Cost time: 0.2894289493560791
			Number: 100 Cost time: 0.6185381412506104
			Number: 200 Cost time: 1.0894129276275635
			Number: 500 Cost time: 1.8213098049163818
			

可以看到,即使我们增加了并发数量,但在服务器能承受高并发的前提下,其爬取速度几乎不太受影响。

综上所述,使用了异步请求之后,我们几乎可以在相同的时间内实现成百上千倍次的网络请求,把这个运用在爬虫中,速度提升是非常可观的。

总结

以上便是 Python 中协程的基本原理和用法,在后面一课时会详细介绍 aiohttp 的使用和爬取实战,实现快速高并发的爬取。

本节代码:https://github.com/Python3WebSpider/AsyncTest

第17讲:aiohttp 异步爬虫实战

在上一课时我们介绍了异步爬虫的基本原理和 asyncio 的基本用法,另外在最后简单提及了 aiohttp 实现网页爬取的过程,这一可是我们来介绍一下 aiohttp 的常见用法,以及通过一个实战案例来介绍下使用 aiohttp 完成网页异步爬取的过程。

aiohttp

前面介绍的 asyncio 模块内部实现了对 TCP、UDP、SSL 协议的异步操作,但是对于 HTTP 请求的异步操作来说,我们就需要用到 aiohttp 来实现了。

aiohttp 是一个基于 asyncio 的异步 HTTP 网络模块,它既提供了服务端,又提供了客户端。其中我们用服务端可以搭建一个支持异步处理的服务器,用于处理请求并返回响应,类似于 Django、Flask、Tornado 等一些 Web 服务器。而客户端我们就可以用来发起请求,就类似于 requests 来发起一个 HTTP 请求然后获得响应,但 requests 发起的是同步的网络请求,而 aiohttp 则发起的是异步的。

本课时我们就主要来了解一下 aiohttp 客户端部分的使用。

基本使用

基本实例

首先我们来看一个基本的 aiohttp 请求案例,代码如下:

import aiohttp
			import asyncio
			async def fetch(session, url):
			   async with session.get(url) as response:
			       return await response.text(), response.status
			async def main():
			   async with aiohttp.ClientSession() as session:
			       html, status = await fetch(session, 'https://cuiqingcai.com')
			       print(f'html: {html[:100]}...')
			       print(f'status: {status}')
			if __name__ == '__main__':
			   loop = asyncio.get_event_loop()
			   loop.run_until_complete(main())
			

在这里我们使用 aiohttp 来爬取了我的个人博客,获得了源码和响应状态码并输出,运行结果如下:

html: <!DOCTYPE HTML>
			<html>
			<head>
			<meta charset="UTF-8">
			<meta name="baidu-tc-verification" content=...
			status: 200
			

这里网页源码过长,只截取输出了一部分,可以看到我们成功获取了网页的源代码及响应状态码 200,也就完成了一次基本的 HTTP 请求,即我们成功使用 aiohttp 通过异步的方式进行了网页的爬取,当然这个操作用之前我们所讲的 requests 同样也可以做到。

我们可以看到其请求方法的定义和之前有了明显的区别,主要有如下几点:

注意在 Python 3.7 及以后的版本中,我们可以使用 asyncio.run(main()) 来代替最后的启动操作,不需要显式声明事件循环,run 方法内部会自动启动一个事件循环。但这里为了兼容更多的 Python 版本,依然还是显式声明了事件循环。

URL 参数设置

对于 URL 参数的设置,我们可以借助于 params 参数,传入一个字典即可,示例如下:

import aiohttp
			import asyncio
			async def main():
			   params = {'name''germey''age'25}
			   async with aiohttp.ClientSession() as session:
			       async with session.get('https://httpbin.org/get', params=params) as response:
			           print(await response.text())
			if __name__ == '__main__':
			   asyncio.get_event_loop().run_until_complete(main())
			

运行结果如下:

{
			 "args": {
			   "age""25",
			   "name""germey"
			 },
			 "headers": {
			   "Accept""*/*",
			   "Accept-Encoding""gzip, deflate",
			   "Host""httpbin.org",
			   "User-Agent""Python/3.7 aiohttp/3.6.2",
			   "X-Amzn-Trace-Id""Root=1-5e85eed2-d240ac90f4dddf40b4723ef0"
			 },
			 "origin""17.20.255.122",
			 "url""https://httpbin.org/get?name=germey&age=25"
			}
			

这里可以看到,其实际请求的 URL 为 https://httpbin.org/get?name=germey&age=25,其 URL 请求参数就对应了 params 的内容。

其他请求类型

另外 aiohttp 还支持其他的请求类型,如 POST、PUT、DELETE 等等,这个和 requests 的使用方式有点类似,示例如下:

session.post('http://httpbin.org/post', data=b'data')
			session.put('http://httpbin.org/put', data=b'data')
			session.delete('http://httpbin.org/delete')
			session.head('http://httpbin.org/get')
			session.options('http://httpbin.org/get')
			session.patch('http://httpbin.org/patch', data=b'data')
			

POST 数据

对于 POST 表单提交,其对应的请求头的 Content-type 为 application/x-www-form-urlencoded,我们可以用如下方式来实现,代码示例如下:

import aiohttp
			import asyncio
			async def main():
			   data = {'name''germey''age'25}
			   async with aiohttp.ClientSession() as session:
			       async with session.post('https://httpbin.org/post', data=data) as response:
			           print(await response.text())
			if __name__ == '__main__':
			   asyncio.get_event_loop().run_until_complete(main())
			

运行结果如下:

{
			 "args": {},
			 "data""",
			 "files": {},
			 "form": {
			   "age""25",
			   "name""germey"
			 },
			 "headers": {
			   "Accept""*/*",
			   "Accept-Encoding""gzip, deflate",
			   "Content-Length""18",
			   "Content-Type""application/x-www-form-urlencoded",
			   "Host""httpbin.org",
			   "User-Agent""Python/3.7 aiohttp/3.6.2",
			   "X-Amzn-Trace-Id""Root=1-5e85f0b2-9017ea603a68dc285e0552d0"
			 },
			 "json": null,
			 "origin""17.20.255.58",
			 "url""https://httpbin.org/post"
			}
			

对于 POST JSON 数据提交,其对应的请求头的 Content-type 为 application/json,我们只需要将 post 方法的 data 参数改成 json 即可,代码示例如下:

async def main():
			   data = {'name''germey''age'25}
			   async with aiohttp.ClientSession() as session:
			       async with session.post('https://httpbin.org/post', json=data) as response:
			           print(await response.text())
			

运行结果如下:

{
			 "args": {},
			 "data""{\"name\": \"germey\", \"age\": 25}",
			 "files": {},
			 "form": {},
			 "headers": {
			   "Accept""*/*",
			   "Accept-Encoding""gzip, deflate",
			   "Content-Length""29",
			   "Content-Type""application/json",
			   "Host""httpbin.org",
			   "User-Agent""Python/3.7 aiohttp/3.6.2",
			   "X-Amzn-Trace-Id""Root=1-5e85f03e-c91c9a20c79b9780dbed7540"
			 },
			 "json": {
			   "age"25,
			   "name""germey"
			 },
			 "origin""17.20.255.58",
			 "url""https://httpbin.org/post"
			}
			

响应字段

对于响应来说,我们可以用如下的方法分别获取响应的状态码、响应头、响应体、响应体二进制内容、响应体 JSON 结果,代码示例如下:

import aiohttp
			import asyncio
			async def main():
			   data = {'name''germey''age'25}
			   async with aiohttp.ClientSession() as session:
			       async with session.post('https://httpbin.org/post', data=data) as response:
			           print('status:', response.status)
			           print('headers:', response.headers)
			           print('body:'await response.text())
			           print('bytes:'await response.read())
			           print('json:'await response.json())
			if __name__ == '__main__':
			   asyncio.get_event_loop().run_until_complete(main())
			

运行结果如下:

status: 200
			headers: <CIMultiDictProxy('Date''Thu, 02 Apr 2020 14:13:05 GMT''Content-Type''application/json''Content-Length''503''Connection''keep-alive''Server''gunicorn/19.9.0''Access-Control-Allow-Origin''*''Access-Control-Allow-Credentials''true')>
			body: {
			 "args": {},
			 "data""",
			 "files": {},
			 "form": {
			   "age""25",
			   "name""germey"
			 },
			 "headers": {
			   "Accept""*/*",
			   "Accept-Encoding""gzip, deflate",
			   "Content-Length""18",
			   "Content-Type""application/x-www-form-urlencoded",
			   "Host""httpbin.org",
			   "User-Agent""Python/3.7 aiohttp/3.6.2",
			   "X-Amzn-Trace-Id""Root=1-5e85f2f1-f55326ff5800b15886c8e029"
			 },
			 "json": null,
			 "origin""17.20.255.58",
			 "url""https://httpbin.org/post"
			}
			bytes: b'{\n  "args": {}, \n  "data": "", \n  "files": {}, \n  "form": {\n    "age": "25", \n    "name": "germey"\n  }, \n  "headers": {\n    "Accept": "*/*", \n    "Accept-Encoding": "gzip, deflate", \n    "Content-Length": "18", \n    "Content-Type": "application/x-www-form-urlencoded", \n    "Host": "httpbin.org", \n    "User-Agent": "Python/3.7 aiohttp/3.6.2", \n    "X-Amzn-Trace-Id": "Root=1-5e85f2f1-f55326ff5800b15886c8e029"\n  }, \n  "json": null, \n  "origin": "17.20.255.58", \n  "url": "https://httpbin.org/post"\n}\n'
			json: {'args': {}, 'data''''files': {}, 'form': {'age''25''name''germey'}, 'headers': {'Accept''*/*''Accept-Encoding''gzip, deflate''Content-Length''18''Content-Type''application/x-www-form-urlencoded''Host''httpbin.org''User-Agent''Python/3.7 aiohttp/3.6.2''X-Amzn-Trace-Id''Root=1-5e85f2f1-f55326ff5800b15886c8e029'}, 'json'None'origin''17.20.255.58''url''https://httpbin.org/post'}
			

这里我们可以看到有些字段前面需要加 await,有的则不需要。其原则是,如果其返回的是一个 coroutine 对象(如 async 修饰的方法),那么前面就要加 await,具体可以看 aiohttp 的 API,其链接为:https://docs.aiohttp.org/en/stable/client_reference.html

超时设置

对于超时的设置,我们可以借助于 ClientTimeout 对象,比如这里我要设置 1 秒的超时,可以这么来实现:

import aiohttp
			import asyncio
			async def main():
			   timeout = aiohttp.ClientTimeout(total=1)
			   async with aiohttp.ClientSession(timeout=timeout) as session:
			       async with session.get('https://httpbin.org/get'as response:
			           print('status:', response.status)
			if __name__ == '__main__':
			   asyncio.get_event_loop().run_until_complete(main())
			

如果在 1 秒之内成功获取响应的话,运行结果如下:

200
			

如果超时的话,会抛出 TimeoutError 异常,其类型为 asyncio.TimeoutError,我们再进行异常捕获即可。

另外 ClientTimeout 对象声明时还有其他参数,如 connect、socket_connect 等,详细说明可以参考官方文档:https://docs.aiohttp.org/en/stable/client_quickstart.html#timeouts

并发限制

由于 aiohttp 可以支持非常大的并发,比如上万、十万、百万都是能做到的,但这么大的并发量,目标网站是很可能在短时间内无法响应的,而且很可能瞬时间将目标网站爬挂掉。所以我们需要控制一下爬取的并发量。

在一般情况下,我们可以借助于 asyncio 的 Semaphore 来控制并发量,代码示例如下:

import asyncio
			import aiohttp
			CONCURRENCY = 5
			URL = 'https://www.baidu.com'
			semaphore = asyncio.Semaphore(CONCURRENCY)
			session = None
			async def scrape_api():
			   async with semaphore:
			       print('scraping', URL)
			       async with session.get(URL) as response:
			           await asyncio.sleep(1)
			           return await response.text()
			async def main():
			   global session
			   session = aiohttp.ClientSession()
			   scrape_index_tasks = [asyncio.ensure_future(scrape_api()) for _ in range(10000)]
			   await asyncio.gather(*scrape_index_tasks)
			if __name__ == '__main__':
			   asyncio.get_event_loop().run_until_complete(main())
			

在这里我们声明了 CONCURRENCY 代表爬取的最大并发量为 5,同时声明爬取的目标 URL 为百度。接着我们借助于 Semaphore 创建了一个信号量对象,赋值为 semaphore,这样我们就可以用它来控制最大并发量了。怎么使用呢?我们这里把它直接放置在对应的爬取方法里面,使用 async with 语句将 semaphore 作为上下文对象即可。这样的话,信号量可以控制进入爬取的最大协程数量,最大数量就是我们声明的 CONCURRENCY 的值。

在 main 方法里面,我们声明了 10000 个 task,传递给 gather 方法运行。倘若不加以限制,这 10000 个 task 会被同时执行,并发数量太大。但有了信号量的控制之后,同时运行的 task 的数量最大会被控制在 5 个,这样就能给 aiohttp 限制速度了。

在这里,aiohttp 的基本使用就介绍这么多,更详细的内容还是推荐你到官方文档查阅,链接:https://docs.aiohttp.org/

爬取实战

上面我们介绍了 aiohttp 的基本用法之后,下面我们来根据一个实例实现异步爬虫的实战演练吧。

本次我们要爬取的网站是:https://dynamic5.scrape.cuiqingcai.com/,页面如图所示。

这是一个书籍网站,整个网站包含了数千本书籍信息,网站是 JavaScript 渲染的,数据可以通过 Ajax 接口获取到,并且接口没有设置任何反爬措施和加密参数,另外由于这个网站比之前的电影案例网站数据量大一些,所以更加适合做异步爬取。

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

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

注:这里要实现 MongoDB 异步存储,需要异步 MongoDB 存储库,叫作 motor,安装命令为:pip3 install motor

页面分析

在之前我们讲解了 Ajax 的基本分析方法,本课时的站点结构和之前 Ajax 分析的站点结构类似,都是列表页加详情页的结构,加载方式都是 Ajax,所以我们能轻松分析到如下信息:

如果你掌握了 Ajax 爬取实战一课时的内容话,上面的内容应该很容易分析出来。如有难度,可以复习下之前的知识。

实现思路

其实一个完善的异步爬虫应该能够充分利用资源进行全速爬取,其思路是维护一个动态变化的爬取队列,每产生一个新的 task 就会将其放入队列中,有专门的爬虫消费者从队列中获取 task 并执行,能做到在最大并发量的前提下充分利用等待时间进行额外的爬取处理。

但上面的实现思路整体较为烦琐,需要设计爬取队列、回调函数、消费者等机制,需要实现的功能较多。由于我们刚刚接触 aiohttp 的基本用法,本课时也主要是了解 aiohttp 的实战应用,所以这里我们将爬取案例的实现稍微简化一下。

在这里我们将爬取的逻辑拆分成两部分,第一部分为爬取列表页,第二部分为爬取详情页。由于异步爬虫的关键点在于并发执行,所以我们可以将爬取拆分为两个阶段:

因为两个阶段的拆分之后需要串行执行,所以可能不能达到协程的最佳调度方式和资源利用情况,但也差不了很多。但这个实现思路比较简单清晰,代码实现也比较简单,能够帮我们快速了解 aiohttp 的基本使用。

基本配置

首先我们先配置一些基本的变量并引入一些必需的库,代码如下:

import asyncio
			import aiohttp
			import logging
			logging.basicConfig(level=logging.INFO,
			                   format='%(asctime)s - %(levelname)s: %(message)s')
			INDEX_URL = 'https://dynamic5.scrape.cuiqingcai.com/api/book/?limit=18&offset={offset}'
			DETAIL_URL = 'https://dynamic5.scrape.cuiqingcai.com/api/book/{id}'
			PAGE_SIZE = 18
			PAGE_NUMBER = 100
			CONCURRENCY = 5
			

在这里我们导入了 asyncio、aiohttp、logging 这三个库,然后定义了 logging 的基本配置。接着定义了 URL、爬取页码数量 PAGE_NUMBER、并发量 CONCURRENCY 等信息。

爬取列表页

首先,第一阶段我们就来爬取列表页,还是和之前一样,我们先定义一个通用的爬取方法,代码如下:

semaphore = asyncio.Semaphore(CONCURRENCY)
			session = None
			async def scrape_api(url):
			   async with semaphore:
			       try:
			           logging.info('scraping %s', url)
			           async with session.get(url) as response:
			               return await response.json()
			       except aiohttp.ClientError:
			           logging.error('error occurred while scraping %s', url, exc_info=True)
			

在这里我们声明了一个信号量,用来控制最大并发数量。

接着我们定义了 scrape_api 方法,该方法接收一个参数 url。首先使用 async with 引入信号量作为上下文,接着调用了 session 的 get 方法请求这个 url,然后返回响应的 JSON 格式的结果。另外这里还进行了异常处理,捕获了 ClientError,如果出现错误,会输出异常信息。

接着,对于列表页的爬取,实现如下:

async def scrape_index(page):
			   url = INDEX_URL.format(offset=PAGE_SIZE * (page - 1))
			   return await scrape_api(url)
			

这里定义了一个 scrape_index 方法用于爬取列表页,它接收一个参数为 page,然后构造了列表页的 URL,将其传给 scrape_api 方法即可。这里注意方法同样需要用 async 修饰,调用的 scrape_api 方法前面需要加 await,因为 scrape_api 调用之后本身会返回一个 coroutine。另外由于 scrape_api 返回结果就是 JSON 格式,因此 scrape_index 的返回结果就是我们想要爬取的信息,不需要再额外解析了。

好,接着我们定义一个 main 方法,将上面的方法串联起来调用一下,实现如下:

import json
			async def main():
			   global session
			   session = aiohttp.ClientSession()
			   scrape_index_tasks = [asyncio.ensure_future(scrape_index(page)) for page in range(1, PAGE_NUMBER + 1)]
			   results = await asyncio.gather(*scrape_index_tasks)
			   logging.info('results %s', json.dumps(results, ensure_ascii=False, indent=2))

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

这里我们首先声明了 session 对象,即最初声明的全局变量,将 session 作为全局变量的话我们就不需要每次在各个方法里面传递了,实现比较简单。

接着我们定义了 scrape_index_tasks,它就是爬取列表页的所有 task,接着我们调用 asyncio 的 gather 方法并传入 task 列表,将结果赋值为 results,它是所有 task 返回结果组成的列表。

最后我们调用 main 方法,使用事件循环启动该 main 方法对应的协程即可。

运行结果如下:

2020-04-03 03:45:54,692 - INFO: scraping https://dynamic5.scrape.cuiqingcai.com/api/book/?limit=18&offset=0
			2020-04-03 03:45:54,707 - INFO: scraping https://dynamic5.scrape.cuiqingcai.com/api/book/?limit=18&offset=18
			2020-04-03 03:45:54,707 - INFO: scraping https://dynamic5.scrape.cuiqingcai.com/api/book/?limit=18&offset=36
			2020-04-03 03:45:54,708 - INFO: scraping https://dynamic5.scrape.cuiqingcai.com/api/book/?limit=18&offset=54
			2020-04-03 03:45:54,708 - INFO: scraping https://dynamic5.scrape.cuiqingcai.com/api/book/?limit=18&offset=72
			2020-04-03 03:45:56,431 - INFO: scraping https://dynamic5.scrape.cuiqingcai.com/api/book/?limit=18&offset=90
			2020-04-03 03:45:56,435 - INFO: scraping https://dynamic5.scrape.cuiqingcai.com/api/book/?limit=18&offset=108
			

可以看到这里就开始异步爬取了,并发量是由我们控制的,目前为 5,当然也可以进一步调高并发量,在网站能承受的情况下,爬取速度会进一步加快。

最后 results 就是所有列表页得到的结果,我们将其赋值为 results 对象,接着我们就可以用它来进行第二阶段的爬取了。

爬取详情页

第二阶段就是爬取详情页并保存数据了,由于每个详情页对应一本书,每本书需要一个 ID,而这个 ID 又正好存在 results 里面,所以下面我们就需要将所有详情页的 ID 获取出来。

在 main 方法里增加 results 的解析代码,实现如下:

ids = []
			for index_data in results:
			   if not index_data: continue
			   for item in index_data.get('results'):
			       ids.append(item.get('id'))
			

这样 ids 就是所有书的 id 了,然后我们用所有的 id 来构造所有详情页对应的 task,来进行异步爬取即可。

那么这里再定义一个爬取详情页和保存数据的方法,实现如下:

from motor.motor_asyncio import AsyncIOMotorClient
			MONGO_CONNECTION_STRING = 'mongodb://localhost:27017'
			MONGO_DB_NAME = 'books'
			MONGO_COLLECTION_NAME = 'books'
			client = AsyncIOMotorClient(MONGO_CONNECTION_STRING)
			db = client[MONGO_DB_NAME]
			collection = db[MONGO_COLLECTION_NAME]
			async def save_data(data):
			   logging.info('saving data %s', data)
			   if data:
			       return await collection.update_one({
			           'id': data.get('id')
			       }, {
			           '$set': data
			       }, upsert=True)
			async def scrape_detail(id):
			   url = DETAIL_URL.format(id=id)
			   data = await scrape_api(url)
			   await save_data(data)
			

这里我们定义了 scrape_detail 方法用于爬取详情页数据并调用 save_data 方法保存数据,save_data 方法用于将数据库保存到 MongoDB 里面。

在这里我们用到了支持异步的 MongoDB 存储库 motor,MongoDB 的连接声明和 pymongo 是类似的,保存数据的调用方法也是基本一致,不过整个都换成了异步方法。

好,接着我们就在 main 方法里面增加 scrape_detail 方法的调用即可,实现如下:

scrape_detail_tasks = [asyncio.ensure_future(scrape_detail(id)) for id in ids]
			await asyncio.wait(scrape_detail_tasks)
			await session.close()
			

在这里我们先声明了 scrape_detail_tasks,即所有详情页的爬取 task 组成的列表,接着调用了 asyncio 的 wait 方法调用执行即可,当然这里也可以用 gather 方法,效果是一样的,只不过返回结果略有差异。最后全部执行完毕关闭 session 即可。

一些详情页的爬取过程运行如下:

2020-04-03 04:00:32,576 - INFO: scraping https://dynamic5.scrape.cuiqingcai.com/api/book/2301475
			2020-04-03 04:00:32,576 - INFO: scraping https://dynamic5.scrape.cuiqingcai.com/api/book/2351866
			2020-04-03 04:00:32,577 - INFO: scraping https://dynamic5.scrape.cuiqingcai.com/api/book/2828384
			2020-04-03 04:00:32,577 - INFO: scraping https://dynamic5.scrape.cuiqingcai.com/api/book/3040352
			2020-04-03 04:00:32,578 - INFO: scraping https://dynamic5.scrape.cuiqingcai.com/api/book/3074810
			2020-04-03 04:00:44,858 - INFO: saving data {'id''3040352''comments': [{'id''387952888''content''温馨文,青梅竹马神马的很有爱~'}, ..., {'id''2005314253''content''沈晋&秦央,文比较短,平平淡淡,贴近生活,短文的缺点不细腻'}], 'name''那些风花雪月''authors': ['\n            公子欢喜'], 'translators': [], 'publisher''龍馬出版社''tags': ['公子欢喜''耽美''BL''小说''现代''校园''耽美小说''那些风花雪月'], 'url''https://book.douban.com/subject/3040352/''isbn''9789866685156''cover''https://img9.doubanio.com/view/subject/l/public/s3029724.jpg''page_number'None'price'None'score''8.1''introduction''''catalog'None'published_at''2008-03-26T16:00:00Z''updated_at''2020-03-21T16:59:39.584722Z'}
			2020-04-03 04:00:44,859 - INFO: scraping https://dynamic5.scrape.cuiqingcai.com/api/book/2994915
			...
			

最后我们观察下,爬取到的数据也都保存到 MongoDB 数据库里面了,如图所示:

至此,我们就使用 aiohttp 完成了书籍网站的异步爬取。

总结

本课时的内容较多,我们了解了 aiohttp 的基本用法,然后通过一个实例讲解了 aiohttp 异步爬虫的具体实现。学习过程我们可以发现,相比普通的单线程爬虫来说,使用异步可以大大提高爬取效率,后面我们也可以多多使用。

本课时代码:https://github.com/Germey/ScrapeDynamic5

第18讲:爬虫神器 Pyppeteer 的使用

在前面我们学习了 Selenium 的基本用法,它功能的确非常强大,但很多时候我们会发现 Selenium 有一些不太方便的地方,比如环境的配置,得安装好相关浏览器,比如 Chrome、Firefox 等等,然后还要到官方网站去下载对应的驱动,最重要的还需要安装对应的 Python Selenium 库,而且版本也得好好看看是否对应,确实不是很方便,另外如果要做大规模部署的话,环境配置的一些问题也是个头疼的事情。

那么本课时我们就介绍另一个类似的替代品,叫作 Pyppeteer。注意,是叫作 Pyppeteer,而不是 Puppeteer。

Pyppeteer 介绍

Puppeteer 是 Google 基于 Node.js 开发的一个工具,有了它我们可以通过 JavaScript 来控制 Chrome 浏览器的一些操作,当然也可以用作网络爬虫上,其 API 极其完善,功能非常强大,Selenium 当然同样可以做到。

而 Pyppeteer 又是什么呢?它实际上是 Puppeteer 的 Python 版本的实现,但它不是 Google 开发的,是一位来自于日本的工程师依据 Puppeteer 的一些功能开发出来的非官方版本。

在 Pyppetter 中,实际上它背后也是有一个类似 Chrome 浏览器的 Chromium 浏览器在执行一些动作进行网页渲染,首先说下 Chrome 浏览器和 Chromium 浏览器的渊源。

Chromium 是谷歌为了研发 Chrome 而启动的项目,是完全开源的。二者基于相同的源代码构建,Chrome 所有的新功能都会先在 Chromium 上实现,待验证稳定后才会移植,因此 Chromium 的版本更新频率更高,也会包含很多新的功能,但作为一款独立的浏览器,Chromium 的用户群体要小众得多。两款浏览器“同根同源”,它们有着同样的 Logo,但配色不同,Chrome 由蓝红绿黄四种颜色组成,而 Chromium 由不同深度的蓝色构成。


总的来说,两款浏览器的内核是一样的,实现方式也是一样的,可以认为是开发版和正式版的区别,功能上基本是没有太大区别的。

Pyppeteer 就是依赖于 Chromium 这个浏览器来运行的。那么有了 Pyppeteer 之后,我们就可以免去那些烦琐的环境配置等问题。如果第一次运行的时候,Chromium 浏览器没有安装,那么程序会帮我们自动安装和配置,就免去了烦琐的环境配置等工作。另外 Pyppeteer 是基于 Python 的新特性 async 实现的,所以它的一些执行也支持异步操作,效率相对于 Selenium 来说也提高了。

那么下面就让我们来一起了解下 Pyppeteer 的相关用法吧。

安装

首先就是安装问题了,由于 Pyppeteer 采用了 Python 的 async 机制,所以其运行要求的 Python 版本为 3.5 及以上。

安装方式非常简单:

pip3 install pyppeteer
			

好了,安装完成之后我们在命令行下测试:

>>> import pyppeteer
			

如果没有报错,那么就证明安装成功了。

快速上手

接下来我们测试基本的页面渲染操作,这里我们选用的网址为:https://dynamic2.scrape.cuiqingcai.com/,如图所示。

这个网站我们在之前的 Selenium 爬取实战课时中已经分析过了,整个页面是用 JavaScript 渲染出来的,同时一些 Ajax 接口还带有加密参数,所以这个网站的页面我们无法直接使用 requests 来抓取看到的数据,同时我们也不太好直接模拟 Ajax 来获取数据。

所以前面一课时我们介绍了使用 Selenium 爬取的方式,其原理就是模拟浏览器的操作,直接用浏览器把页面渲染出来,然后再直接获取渲染后的结果。同样的原理,用 Pyppeteer 也可以做到。

下面我们用 Pyppeteer 来试试,代码就可以写为如下形式:

import asyncio
			from pyppeteer import launch
			from pyquery import PyQuery as pq
			async def main():
			   browser = await launch()
			   page = await browser.newPage()
			   await page.goto('https://dynamic2.scrape.cuiqingcai.com/')
			   await page.waitForSelector('.item .name')
			   doc = pq(await page.content())
			   names = [item.text() for item in doc('.item .name').items()]
			   print('Names:', names)
			   await browser.close()
			asyncio.get_event_loop().run_until_complete(main())
			

运行结果:

Names: ['霸王别姬 - Farewell My Concubine''这个杀手不太冷 - Léon''肖申克的救赎 - The Shawshank Redemption''泰坦尼克号 - Titanic''罗马假日 - Roman Holiday''唐伯虎点秋香 - Flirting Scholar''乱世佳人 - Gone with the Wind''喜剧之王 - The King of Comedy''楚门的世界 - The Truman Show''狮子王 - The Lion King']
			

先初步看下代码,大体意思是访问了这个网站,然后等待 .item .name 的节点加载出来,随后通过 pyquery 从网页源码中提取了电影的名称并输出,最后关闭 Pyppeteer。

看运行结果,和之前的 Selenium 一样,我们成功模拟加载出来了页面,然后提取到了首页所有电影的名称。

那么这里面的具体过程发生了什么?我们来逐行看下。

另外其他的一些方法如调用 asyncio 的 get_event_loop 等方法的相关操作则属于 Python 异步 async 相关的内容了,你如果不熟悉可以了解下前面所讲的异步相关知识。

好,通过上面的代码,我们同样也可以完成 JavaScript 渲染页面的爬取了。怎么样?代码相比 Selenium 是不是更简洁易读,而且环境配置更加方便。在这个过程中,我们没有配置 Chrome 浏览器,也没有配置浏览器驱动,免去了一些烦琐的步骤,同样达到了 Selenium 的效果,还实现了异步抓取。

接下来我们再看看另外一个例子,这个例子设定了浏览器窗口大小,然后模拟了网页截图,另外还可以执行自定义的 JavaScript 获得特定的内容,代码如下:

import asyncio
			from pyppeteer import launch
			width, height = 1366768
			async def main():
			   browser = await launch()
			   page = await browser.newPage()
			   await page.setViewport({'width': width, 'height': height})
			   await page.goto('https://dynamic2.scrape.cuiqingcai.com/')
			   await page.waitForSelector('.item .name')
			   await asyncio.sleep(2)
			   await page.screenshot(path='example.png')
			   dimensions = await page.evaluate('''() => {
			       return {
			           width: document.documentElement.clientWidth,
			           height: document.documentElement.clientHeight,
			           deviceScaleFactor: window.devicePixelRatio,
			       }
			   }''')

			   print(dimensions)
			   await browser.close()
			asyncio.get_event_loop().run_until_complete(main())
			

这里我们又用到了几个新的 API,完成了页面窗口大小设置、网页截图保存、执行 JavaScript 并返回对应数据。

首先 screenshot 方法可以传入保存的图片路径,另外还可以指定保存格式 type、清晰度 quality、是否全屏 fullPage、裁切 clip 等各个参数实现截图。

截图的样例如下:

可以看到它返回的就是 JavaScript 渲染后的页面,和我们在浏览器中看到的结果是一模一样的。

最后我们又调用了 evaluate 方法执行了一些 JavaScript,JavaScript 传入的是一个函数,使用 return 方法返回了网页的宽高、像素大小比率三个值,最后得到的是一个 JSON 格式的对象,内容如下:

{'width'1366'height'768'deviceScaleFactor'1}
			

OK,实例就先感受到这里,还有太多太多的功能还没提及。

总之利用 Pyppeteer 我们可以控制浏览器执行几乎所有动作,想要的操作和功能基本都可以实现,用它来自由地控制爬虫当然就不在话下了。

详细用法

了解了基本的实例之后,我们再来梳理一下 Pyppeteer 的一些基本和常用操作。Pyppeteer 的几乎所有功能都能在其官方文档的 API Reference 里面找到,链接为:https://miyakogi.github.io/pyppeteer/reference.html,用到哪个方法就来这里查询就好了,参数不必死记硬背,即用即查就好。

launch

使用 Pyppeteer 的第一步便是启动浏览器,首先我们看下怎样启动一个浏览器,其实就相当于我们点击桌面上的浏览器图标一样,把它运行起来。用 Pyppeteer 完成同样的操作,只需要调用 launch 方法即可。

我们先看下 launch 方法的 API,链接为:https://miyakogi.github.io/pyppeteer/reference.html#pyppeteer.launcher.launch,其方法定义如下:

pyppeteer.launcher.launch(options: dict = None, **kwargs) → pyppeteer.browser.Browser
			

可以看到它处于 launcher 模块中,参数没有在声明中特别指定,返回类型是 browser 模块中的 Browser 对象,另外观察源码发现这是一个 async 修饰的方法,所以调用它的时候需要使用 await。

接下来看看它的参数:

好了,知道这些参数之后,我们可以先试试看。

首先可以试用下最常用的参数 headless,如果我们将它设置为 True 或者默认不设置它,在启动的时候我们是看不到任何界面的,如果把它设置为 False,那么在启动的时候就可以看到界面了,一般我们在调试的时候会把它设置为 False,在生产环境上就可以设置为 True,我们先尝试一下关闭 headless 模式:

import asyncio
			from pyppeteer import launch
			async def main():
			   await launch(headless=False)
			   await asyncio.sleep(100)
			asyncio.get_event_loop().run_until_complete(main())
			

运行之后看不到任何控制台输出,但是这时候就会出现一个空白的 Chromium 界面了:

但是可以看到这就是一个光秃秃的浏览器而已,看一下相关信息:

看到了,这就是 Chromium,上面还写了开发者内部版本,你可以认为是开发版的 Chrome 浏览器就好。

另外我们还可以开启调试模式,比如在写爬虫的时候会经常需要分析网页结构还有网络请求,所以开启调试工具还是很有必要的,我们可以将 devtools 参数设置为 True,这样每开启一个界面就会弹出一个调试窗口,非常方便,示例如下:

import asyncio
			from pyppeteer import launch
			 
			async def main():
			   browser = await launch(devtools=True)
			   page = await browser.newPage()
			   await page.goto('https://www.baidu.com')
			   await asyncio.sleep(100)
			 
			asyncio.get_event_loop().run_until_complete(main())
			

刚才说过 devtools 这个参数如果设置为了 True,那么 headless 就会被关闭了,界面始终会显现出来。在这里我们新建了一个页面,打开了百度,界面运行效果如下:

这时候我们可以看到上面的一条提示:"Chrome 正受到自动测试软件的控制",这个提示条有点烦,那该怎样关闭呢?这时候就需要用到 args 参数了,禁用操作如下:

browser = await launch(headless=False, args=['--disable-infobars'])
			

这里就不再写完整代码了,就是在 launch 方法中,args 参数通过 list 形式传入即可,这里使用的是 --disable-infobars 的参数。

你可能会说,如果你只是把提示关闭了,有些网站还是会检测到是 WebDriver 吧,比如拿之前的检测 WebDriver 的案例 https://antispider1.scrape.cuiqingcai.com/ 来验证下,我们可以试试:

import asyncio
			from pyppeteer import launch
			 
			async def main():
			   browser = await launch(headless=False, args=['--disable-infobars'])
			   page = await browser.newPage()
			   await page.goto('https://antispider1.scrape.cuiqingcai.com/')
			   await asyncio.sleep(100)
			 
			asyncio.get_event_loop().run_until_complete(main())
			

果然还是被检测到了,页面如下:

这说明 Pyppeteer 开启 Chromium 照样还是能被检测到 WebDriver 的存在。

那么此时如何规避呢?Pyppeteer 的 Page 对象有一个方法叫作 evaluateOnNewDocument,意思就是在每次加载网页的时候执行某个语句,所以这里我们可以执行一下将 WebDriver 隐藏的命令,改写如下:

import asyncio
			from pyppeteer import launch
			 
			async def main():
			   browser = await launch(headless=False, args=['--disable-infobars'])
			   page = await browser.newPage()
			   await page.evaluateOnNewDocument('Object.defineProperty(navigator, "webdriver", {get: () => undefined})')
			   await page.goto('https://antispider1.scrape.cuiqingcai.com/')
			   await asyncio.sleep(100)
			 
			asyncio.get_event_loop().run_until_complete(main())
			

这里我们可以看到整个页面就可以成功加载出来了,如图所示。

我们发现页面就成功加载出来了,绕过了 WebDriver 的检测。

在上面的例子中,我们还发现了页面的显示 bug,整个浏览器窗口比显示的内容窗口要大,这个是某些页面会出现的情况。

对于这种情况,我们通过设置窗口大小就可以解决,可以通过 Page 的 setViewport 方法设置,代码如下:

import asyncio
			from pyppeteer import launch
			 
			width, height = 1366768
			 
			async def main():
			   browser = await launch(headless=False, args=['--disable-infobars'f'--window-size={width},{height}'])
			   page = await browser.newPage()
			   await page.setViewport({'width': width, 'height': height})
			   await page.evaluateOnNewDocument('Object.defineProperty(navigator, "webdriver", {get: () => undefined})')
			   await page.goto('https://antispider1.scrape.cuiqingcai.com/')
			   await asyncio.sleep(100)
			 
			asyncio.get_event_loop().run_until_complete(main())
			

这里我们同时设置了浏览器窗口的宽高以及显示区域的宽高,使得二者一致,最后发现显示就正常了,如图所示。

刚才我们可以看到,每次我们打开 Pyppeteer 的时候都是一个新的空白的浏览器。而且如果遇到了需要登录的网页之后,如果我们这次登录上了,下一次再启动又是空白了,又得登录一次,这的确是一个问题。

比如以淘宝举例,平时我们逛淘宝的时候,在很多情况下关闭了浏览器再打开,淘宝依然还是登录状态。这是因为淘宝的一些关键 Cookies 已经保存到本地了,下次登录的时候可以直接读取并保持登录状态。

那么这些信息保存在哪里了呢?其实就是保存在用户目录下了,里面不仅包含了浏览器的基本配置信息,还有一些 Cache、Cookies 等各种信息都在里面,如果我们能在浏览器启动的时候读取这些信息,那么启动的时候就可以恢复一些历史记录甚至一些登录状态信息了。

这也就解决了一个问题:很多时候你在每次启动 Selenium 或 Pyppeteer 的时候总是一个全新的浏览器,那这究其原因就是没有设置用户目录,如果设置了它,每次打开就不再是一个全新的浏览器了,它可以恢复之前的历史记录,也可以恢复很多网站的登录信息。

那么这个怎么来做呢?很简单,在启动的时候设置 userDataDir 就好了,示例如下:

import asyncio
			from pyppeteer import launch
			 
			async def main():
			   browser = await launch(headless=False, userDataDir='./userdata', args=['--disable-infobars'])
			   page = await browser.newPage()
			   await page.goto('https://www.taobao.com')
			   await asyncio.sleep(100)
			 
			asyncio.get_event_loop().run_until_complete(main())
			

好,这里就是加了一个 userDataDir 的属性,值为 userdata,即当前目录的 userdata 文件夹。我们可以首先运行一下,然后登录一次淘宝,这时候我们同时可以观察到在当前运行目录下又多了一个 userdata 的文件夹,里面的结构是这样子的:

具体的介绍可以看官方的一些说明,如: https://chromium.googlesource.com/chromium/src/+/master/docs/user_data_dir.md,这里面介绍了 userdatadir 的相关内容。

再次运行上面的代码,这时候可以发现现在就已经是登录状态了,不需要再次登录了,这样就成功跳过了登录的流程。当然可能时间太久了,Cookies 都过期了,那还是需要登录的。

以上便是 launch 方法及其对应的参数的配置。

Browser

上面我们了解了 launch 方法,其返回的就是一个 Browser 对象,即浏览器对象,我们会通常将其赋值给 browser 变量,其实它就是 Browser 类的一个实例。

下面我们来看看 Browser 类的定义:

class pyppeteer.browser.Browser(connection: pyppeteer.connection.Connection, contextIds: List[str], ignoreHTTPSErrors: bool, setDefaultViewport: bool, process: Optional[subprocess.Popen] = None, closeCallback: Callable[[], Awaitable[None]] = None, **kwargs)
			

这里我们可以看到其构造方法有很多参数,但其实多数情况下我们直接使用 launch 方法或 connect 方法创建即可。

browser 作为一个对象,其自然有很多用于操作浏览器本身的方法,下面我们来选取一些比较有用的介绍下。

我们知道 Chrome 浏览器是有一个无痕模式的,它的好处就是环境比较干净,不与其他的浏览器示例共享 Cache、Cookies 等内容,其开启方式可以通过 createIncognitoBrowserContext 方法,示例如下:

import asyncio
			from pyppeteer import launch
			 
			width, height = 1200768
			 
			async def main():
			   browser = await launch(headless=False,
			                          args=['--disable-infobars'f'--window-size={width},{height}'])
			   context = await browser.createIncognitoBrowserContext()
			   page = await context.newPage()
			   await page.setViewport({'width': width, 'height': height})
			   await page.goto('https://www.baidu.com')
			   await asyncio.sleep(100)
			 
			asyncio.get_event_loop().run_until_complete(main())
			

这里关键的调用就是 createIncognitoBrowserContext 方法,其返回一个 context 对象,然后利用 context 对象我们可以新建选项卡。

运行之后,我们发现浏览器就进入了无痕模式,界面如下:

import asyncio
			from pyppeteer import launch
			from pyquery import PyQuery as pq
			 
			async def main():
			   browser = await launch()
			   page = await browser.newPage()
			   await page.goto('https://dynamic2.scrape.cuiqingcai.com/')
			   await browser.close()

			asyncio.get_event_loop().run_until_complete(main())
			

Page

Page 即页面,就对应一个网页,一个选项卡。在前面我们已经演示了几个 Page 方法的操作了,这里我们再详细看下它的一些常用用法。

Page 对象内置了一些用于选取节点的选择器方法,如 J 方法传入一个选择器 Selector,则能返回对应匹配的第一个节点,等价于 querySelector。如 JJ 方法则是返回符合 Selector 的列表,类似于 querySelectorAll。

下面我们来看下其用法和运行结果,示例如下:

import asyncio
			from pyppeteer import launch
			from pyquery import PyQuery as pq
			 
			async def main():
			   browser = await launch()
			   page = await browser.newPage()
			   await page.goto('https://dynamic2.scrape.cuiqingcai.com/')
			   await page.waitForSelector('.item .name')
			   j_result1 = await page.J('.item .name')
			   j_result2 = await page.querySelector('.item .name')
			   jj_result1 = await page.JJ('.item .name')
			   jj_result2 = await page.querySelectorAll('.item .name')
			   print('J Result1:', j_result1)
			   print('J Result2:', j_result2)
			   print('JJ Result1:', jj_result1)
			   print('JJ Result2:', jj_result2)
			   await browser.close()
			 
			asyncio.get_event_loop().run_until_complete(main())
			

在这里我们分别调用了 J、querySelector、JJ、querySelectorAll 四个方法,观察下其运行效果和返回结果的类型,运行结果:

J Result1: <pyppeteer.element_handle.ElementHandle object at 0x1166f7dd0>
			J Result2: <pyppeteer.element_handle.ElementHandle object at 0x1166f07d0>
			JJ Result1: [<pyppeteer.element_handle.ElementHandle object at 0x11677df50>, <pyppeteer.element_handle.ElementHandle object at 0x1167857d0>, <pyppeteer.element_handle.ElementHandle object at 0x116785110>,
			...
			<pyppeteer.element_handle.ElementHandle object at 0x11679db10>, <pyppeteer.element_handle.ElementHandle object at 0x11679dbd0>]
			JJ Result2: [<pyppeteer.element_handle.ElementHandle object at 0x116794f10>, <pyppeteer.element_handle.ElementHandle object at 0x116794d10>, <pyppeteer.element_handle.ElementHandle object at 0x116794f50>,
			...
			<pyppeteer.element_handle.ElementHandle object at 0x11679f690>, <pyppeteer.element_handle.ElementHandle object at 0x11679f750>]
			

在这里我们可以看到,J、querySelector 一样,返回了单个匹配到的节点,返回类型为 ElementHandle 对象。JJ、querySelectorAll 则返回了节点列表,是 ElementHandle 的列表。

前面我们已经演示了多次新建选项卡的操作了,也就是 newPage 方法,那新建了之后怎样获取和切换呢,下面我们来看一个例子:

import asyncio
			from pyppeteer import launch
			 
			async def main():
			   browser = await launch(headless=False)
			   page = await browser.newPage()
			   await page.goto('https://www.baidu.com')
			   page = await browser.newPage()
			   await page.goto('https://www.bing.com')
			   pages = await browser.pages()
			   print('Pages:', pages)
			   page1 = pages[1]
			   await page1.bringToFront()
			   await asyncio.sleep(100)
			 
			asyncio.get_event_loop().run_until_complete(main())
			

在这里我们启动了 Pyppeteer,然后调用了 newPage 方法新建了两个选项卡并访问了两个网站。那么如果我们要切换选项卡的话,只需要调用 pages 方法即可获取所有的页面,然后选一个页面调用其 bringToFront 方法即可切换到该页面对应的选项卡。

作为一个页面,我们一定要有对应的方法来控制,如加载、前进、后退、关闭、保存等,示例如下:

import asyncio
			from pyppeteer import launch
			from pyquery import PyQuery as pq
			 
			async def main():
			   browser = await launch(headless=False)
			   page = await browser.newPage()
			   await page.goto('https://dynamic1.scrape.cuiqingcai.com/')
			   await page.goto('https://dynamic2.scrape.cuiqingcai.com/')
			   # 后退
			   await page.goBack()
			   # 前进
			   await page.goForward()
			   # 刷新
			   await page.reload()
			   # 保存 PDF
			   await page.pdf()
			   # 截图
			   await page.screenshot()
			   # 设置页面 HTML
			   await page.setContent('<h2>Hello World</h2>')
			   # 设置 User-Agent
			   await page.setUserAgent('Python')
			   # 设置 Headers
			   await page.setExtraHTTPHeaders(headers={})
			   # 关闭
			   await page.close()
			   await browser.close()
			 
			asyncio.get_event_loop().run_until_complete(main())
			

这里我们介绍了一些常用方法,除了一些常用的操作,这里还介绍了设置 User-Agent、Headers 等功能。

Pyppeteer 同样可以模拟点击,调用其 click 方法即可。比如我们这里以 https://dynamic2.scrape.cuiqingcai.com/ 为例,等待节点加载出来之后,模拟右键点击一下,示例如下:

import asyncio
			from pyppeteer import launch
			from pyquery import PyQuery as pq
			 
			async def main():
			   browser = await launch(headless=False)
			   page = await browser.newPage()
			   await page.goto('https://dynamic2.scrape.cuiqingcai.com/')
			   await page.waitForSelector('.item .name')
			   await page.click('.item .name', options={
			       'button''right',
			       'clickCount'1,  # 1 or 2
			       'delay'3000,  # 毫秒
			   })
			   await browser.close()
			 
			asyncio.get_event_loop().run_until_complete(main())
			

这里 click 方法第一个参数就是选择器,即在哪里操作。第二个参数是几项配置:

对于文本的输入,Pyppeteer 也不在话下,使用 type 方法即可,示例如下:

import asyncio
			from pyppeteer import launch
			from pyquery import PyQuery as pq
			 
			async def main():
			   browser = await launch(headless=False)
			   page = await browser.newPage()
			   await page.goto('https://www.taobao.com')
			   # 后退
			   await page.type('#q''iPad')
			   # 关闭
			   await asyncio.sleep(10)
			   await browser.close()
			 
			asyncio.get_event_loop().run_until_complete(main())
			

这里我们打开淘宝网,使用 type 方法第一个参数传入选择器,第二个参数传入输入的内容,Pyppeteer 便可以帮我们完成输入了。

Page 获取源代码用 content 方法即可,Cookies 则可以用 cookies 方法获取,示例如下:

import asyncio
			from pyppeteer import launch
			from pyquery import PyQuery as pq
			 
			async def main():
			   browser = await launch(headless=False)
			   page = await browser.newPage()
			   await page.goto('https://dynamic2.scrape.cuiqingcai.com/')
			   print('HTML:'await page.content())
			   print('Cookies:'await page.cookies())
			   await browser.close()
			 
			asyncio.get_event_loop().run_until_complete(main())
			

Pyppeteer 可以支持 JavaScript 执行,使用 evaluate 方法即可,看之前的例子:

import asyncio
			from pyppeteer import launch
			 
			width, height = 1366768
			 
			async def main():
			   browser = await launch()
			   page = await browser.newPage()
			   await page.setViewport({'width': width, 'height': height})
			   await page.goto('https://dynamic2.scrape.cuiqingcai.com/')
			   await page.waitForSelector('.item .name')
			   await asyncio.sleep(2)
			   await page.screenshot(path='example.png')
			   dimensions = await page.evaluate('''() => {
			       return {
			           width: document.documentElement.clientWidth,
			           height: document.documentElement.clientHeight,
			           deviceScaleFactor: window.devicePixelRatio,
			       }
			   }''')

			   print(dimensions)
			   await browser.close()
			 
			asyncio.get_event_loop().run_until_complete(main())
			

这里我们通过 evaluate 方法执行了 JavaScript,并获取到了对应的结果。另外其还有 exposeFunction、evaluateOnNewDocument、evaluateHandle 方法可以做了解。

在本课时最开头的地方我们演示了 waitForSelector 的用法,它可以让页面等待某些符合条件的节点加载出来再返回。

在这里 waitForSelector 就是传入一个 CSS 选择器,如果找到了,立马返回结果,否则等待直到超时。

除了 waitForSelector 方法,还有很多其他的等待方法,介绍如下。

通过等待条件,我们就可以控制页面加载的情况了。

更多

另外 Pyppeteer 还有很多功能,如键盘事件、鼠标事件、对话框事件等等,在这里就不再一一赘述了。更多的内容可以参考官方文档的案例说明:https://miyakogi.github.io/pyppeteer/reference.html

以上,我们就通过一些小的案例介绍了 Pyppeteer 的基本用法,下一课时,我们来使用 Pyppeteer 完成一个实战案例爬取。

本节代码:https://github.com/Python3WebSpider/PyppeteerTest

第19讲:Pyppeteer 爬取实战

在上一课时我们了解了 Pyppeteer 的基本用法,确实我们可以发现其相比 Selenium 有很多方便之处。

本课时我们就来使用 Pyppeteer 针对之前的 Selenium 案例做一次改写,来体会一下二者的不同之处,同时也加强一下对 Pyppeteer 的理解和掌握情况。

爬取目标

本课时我们要爬取的目标和之前是一样的,还是 Selenium 的那个案例,地址为:https://dynamic2.scrape.cuiqingcai.com/,如下图所示。

这个网站的每个详情页的 URL 都是带有加密参数的,同时 Ajax 接口也都有加密参数和时效性。具体的介绍可以看下 Selenium 课时。

本节目标

爬取目标和那一节也是一样的:

要求和之前也是一样的,只不过我们这里的实现就全用 Pyppeteer 来做了。

准备工作

在本课时开始之前,我们需要做好如下准备工作:

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

页面分析在这里就不多介绍了,还是列表页 + 详情页的结构,具体可以参考 Selenium 那一课时的内容。

爬取列表页

首先我们先做一些准备工作,定义一些基础的配置,包括日志定义、变量等等并引入一些必要的包,代码如下:

import logging
			logging.basicConfig(level=logging.INFO,
			                   format='%(asctime)s - %(levelname)s: %(message)s')
			INDEX_URL = 'https://dynamic2.scrape.cuiqingcai.com/page/{page}'
			TIMEOUT = 10
			TOTAL_PAGE = 10
			WINDOW_WIDTH, WINDOW_HEIGHT = 1366768
			HEADLESS = False
			

这里大多数的配置和之前是一样的,不过这里我们额外定义了窗口的宽高信息,这里定义为 1366 x 768,你也可以随意指定适合自己屏幕的宽高信息。另外这里定义了一个变量 HEADLESS,用来指定是否启用 Pyppeteer 的无头模式,如果为 False,那么启动 Pyppeteer 的时候就会弹出一个 Chromium 浏览器窗口。

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

from pyppeteer import launch
			browser, tab = NoneNone
			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 对象,代表 Pyppeteer 所用的浏览器对象,tab 代表新建的页面选项卡,这里把两项设置为全局变量,方便其他的方法调用。

另外定义了一个 init 方法,调用了 Pyppeteer 的 launch 方法,传入了 headless 为 HEADLESS,将其设置为非无头模式,另外还通过 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,代表要爬取的链接,使用 goto 方法调用即可;另外一个是 selector,即要等待渲染出的节点对应的 CSS 选择器,这里我们使用 waitForSelector 方法并传入了 selector,并通过 options 指定了最长等待时间。

这样的话在运行时页面会首先访问这个 URL,然后等待某个符合 selector 的节点加载出来,最长等待 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,这就是列表页中每部电影的名称,如果这个加载出来了,那么就代表页面加载成功了,如图所示。

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

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

这里我们调用了 querySelectorAllEval 方法,它接收两个参数,第一个参数是 selector,代表要选择的节点对应的 CSS 选择器;第二个参数是 pageFunction,代表的是要执行的 JavaScript 方法,这里需要传入的是一段 JavaScript 字符串,整个方法的作用是选择 selector 对应的节点,然后对这些节点通过 pageFunction 定义的逻辑抽取出对应的结果并返回。

所以这里第一个参数 selector 就传入电影名称对应的节点,其实是超链接 a 节点。由于提取结果有多个,所以这里 JavaScript 对应的 pageFunction 输入参数就是 nodes,输出结果是调用了 map 方法得到每个 node,然后调用 node 的 href 属性即可。这样返回结果就是当前列表页的所有电影的详情页 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())
			

这里我们定义了一个 mian 方法,将前面定义的几个方法串联调用了一下。首先调用了 init 方法,然后循环遍历页码,调用了 scrape_index 方法爬取了每一页列表页,接着我们调用了 parse_index 方法,从列表页中提取出详情页的每个 URL,然后输出结果。

运行结果如下:

2020-04-08 13:54:28,879 - INFO: scraping https://dynamic2.scrape.cuiqingcai.com/page/1
			2020-04-08 13:54:31,411 - INFO: detail_urls ['https://dynamic2.scrape.cuiqingcai.com/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx', ...,
			'https://dynamic2.scrape.cuiqingcai.com/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWI5''https://dynamic2.scrape.cuiqingcai.com/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIxMA==']
			2020-04-08 13:54:31,411 - INFO: scraping https://dynamic2.scrape.cuiqingcai.com/page/2
			

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

在这里可以看到,每一次的返回结果都会是当前列表页提取出来的所有详情页 URL 组成的列表,我们下一步就可以用这些 URL 来接着爬取了。

爬取详情页

拿到详情页的 URL 之后,下一步就是爬取每一个详情页然后提取信息了,首先我们定义一个爬取详情页的方法,代码如下:

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

代码非常简单,就是直接调用了 scrape_page 方法,然后传入了要等待加载的节点的选择器,这里我们就直接用了 h2 了,对应的就是详情页的电影名称,如图所示。

如果顺利运行,那么当前 Pyppeteer 就已经成功加载出详情页了,下一步就是提取里面的信息了。

接下来我们再定义一个提取详情信息的方法,代码如下:

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、名称、类别、封面、分数、简介等内容,提取方式如下:

最后我们将提取结果汇总成一个字典然后返回即可。

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

这里可以看到,首先先爬取了列表页,然后提取出了详情页之后接着开始爬详情页,然后提取出我们想要的电影信息之后,再接着去爬下一个详情页。

这样,所有的详情页都会被我们爬取下来啦。

数据存储

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

最后添加上 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 两个参数即可。

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

无头模式

最后如果代码能稳定运行了,我们可以将其改为无头模式,将 HEADLESS 修改为 True 即可,这样在运行的时候就不会弹出浏览器窗口了。

总结

本课时我们通过实例来讲解了 Pyppeteer 爬取一个完整网站的过程,从而对 Pyppeteer 的使用有进一步的掌握。

本节代码:https://github.com/Python3WebSpider/ScrapeDynamic2

第20讲:代理的基本原理和用法

我们在做爬虫的过程中经常会遇到这样的情况,最初爬虫正常运行,正常抓取数据,一切看起来都是那么的美好,然而一杯茶的功夫可能就会出现错误,比如 403 Forbidden,这时候打开网页一看,可能会看到 “您的 IP 访问频率太高” 这样的提示,或者跳出一个验证码让我们输入,输入之后才可能解封,但是输入之后过一会儿就又这样了。

出现这种现象的原因是网站采取了一些反爬虫的措施,比如服务器会检测某个 IP 在单位时间内的请求次数,如果超过了这个阈值,那么会直接拒绝服务,返回一些错误信息,这种情况可以称之为封 IP,于是乎就成功把我们的爬虫禁掉了。

既然服务器检测的是某个 IP 单位时间的请求次数,那么我们借助某种方式来伪装我们的 IP,让服务器识别不出是由我们本机发起的请求,不就可以成功防止封 IP 了吗?所以这时候代理就派上用场了。

本课时我们先来看下代理的基本原理和使用代理处理反爬虫的方法。

基本原理

代理实际上指的就是代理服务器,英文叫作 proxy server,它的功能是代理网络用户去获取网络信息。形象地说,它是网络信息的中转站。在我们正常请求一个网站时,是发送了请求给 Web 服务器,Web 服务器把响应传回给我们。如果设置了代理服务器,实际上就是在本机和服务器之间搭建了一个桥,此时本机不是直接向 Web 服务器发起请求,而是向代理服务器发出请求,请求会发送给代理服务器,然后由代理服务器再发送给 Web 服务器,接着由代理服务器再把 Web 服务器返回的响应转发给本机。这样我们同样可以正常访问网页,但这个过程中 Web 服务器识别出的真实 IP 就不再是我们本机的 IP 了,就成功实现了 IP 伪装,这就是代理的基本原理。

代理的作用

那么,代理有什么作用呢?我们可以简单列举如下。

爬虫代理

对于爬虫来说,由于爬虫爬取速度过快,在爬取过程中可能遇到同一个 IP 访问过于频繁的问题,此时网站就会让我们输入验证码登录或者直接封锁 IP,这样会给爬取带来极大的不便。

使用代理隐藏真实的 IP,让服务器误以为是代理服务器在请求自己。这样在爬取过程中通过不断更换代理,就不会被封锁,可以达到很好的爬取效果。

代理分类

代理分类时,既可以根据协议区分,也可以根据其匿名程度区分,下面分别总结如下:

根据协议区分

根据代理的协议,代理可以分为如下类别:

根据匿名程度区分

根据代理的匿名程度,代理可以分为如下类别。

常见代理类型

代理设置

在前面我们介绍了多种请求库,如 Requests、Selenium、Pyppeteer 等。我们接下来首先贴近实战,了解一下代理怎么使用,为后面了解代理池打下基础。

下面我们来梳理一下这些库的代理的设置方法。

做测试之前,我们需要先获取一个可用代理。搜索引擎搜索 “代理” 关键字,就可以看到许多代理服务网站,网站上会有很多免费或付费代理,比如免费代理“快代理”:https://www.kuaidaili.com/free/。但是这些免费代理大多数情况下都是不好用的,所以比较靠谱的方法是购买付费代理。付费代理各大代理商家都有套餐,数量不用多,稳定可用即可,我们可以自行选购。

如果本机有相关代理软件的话,软件一般会在本机创建 HTTP 或 SOCKS 代理服务,本机直接使用此代理也可以。

在这里,我的本机安装了一部代理软件,它会在本地的 7890 端口上创建 HTTP 代理服务,即代理为127.0.0.1:7890,另外还会在 7891 端口创建 SOCKS 代理服务,即代理为 127.0.0.1:7891。

我只要设置了这个代理,就可以成功将本机 IP 切换到代理软件连接的服务器的 IP 了。下面的示例里,我将使用上述代理来演示其设置方法,你也可以自行替换成自己的可用代理。设置代理后测试的网址是:http://httpbin.org/get,我们访问该网址可以得到请求的相关信息,其中 origin 字段就是客户端的 IP,我们可以根据它来判断代理是否设置成功,即是否成功伪装了 IP。

requests 设置代理

对于 requests 来说,代理设置非常简单,我们只需要传入 proxies 参数即可。

我在这里以我本机的代理为例,来看下 requests 的 HTTP 代理的设置,代码如下:

import requests
			proxy = '127.0.0.1:7890'
			proxies = {
			   'http''http://' + proxy,
			   'https''https://' + proxy,
			}
			try:
			   response = requests.get('https://httpbin.org/get', proxies=proxies)
			   print(response.text)
			except requests.exceptions.ConnectionError as e:
			   print('Error', e.args)
			运行结果:
			{
			 "args": {},
			 "headers": {
			   "Accept""*/*",
			   "Accept-Encoding""gzip, deflate",
			   "Host""httpbin.org",
			   "User-Agent""python-requests/2.22.0",
			   "X-Amzn-Trace-Id""Root=1-5e8f358d-87913f68a192fb9f87aa0323"
			 },
			 "origin""210.173.1.204",
			 "url""https://httpbin.org/get"
			}
			

可以发现,我们通过一个字典的形式就设置好了 HTTP 代理,它分为两个类别,有 HTTP 和 HTTPS,如果我们访问的链接是 HTTP 协议,那就用 http 字典名指定的代理,如果是 HTTPS 协议,那就用 https 字典名指定的代理。

其运行结果的 origin 如是代理服务器的 IP,则证明代理已经设置成功。

如果代理需要认证,同样在代理的前面加上用户名密码即可,代理的写法就变成如下所示:

proxy = 'username:password@127.0.0.1:7890'
			

这里只需要将 username 和 password 替换即可。

如果需要使用 SOCKS 代理,则可以使用如下方式来设置:

import requests
			proxy = '127.0.0.1:7891'
			proxies = {
			   'http''socks5://' + proxy,
			   'https''socks5://' + proxy
			}
			try:
			   response = requests.get('https://httpbin.org/get', proxies=proxies)
			   print(response.text)
			except requests.exceptions.ConnectionError as e:
			   print('Error', e.args)
			

在这里,我们需要额外安装一个包,这个包叫作 requests[socks],安装命令如下所示:

pip3 install "requests[socks]"
			

运行结果是完全相同的:

{
			 "args": {},
			 "headers": {
			   "Accept""*/*",
			   "Accept-Encoding""gzip, deflate",
			   "Host""httpbin.org",
			   "User-Agent""python-requests/2.22.0",
			   "X-Amzn-Trace-Id""Root=1-5e8f364a-589d3cf2500fafd47b5560f2"
			 },
			 "origin""210.173.1.204",
			 "url""https://httpbin.org/get"
			}
			

另外,还有一种设置方式即使用 socks 模块,也需要像上文一样安装 socks 库。这种设置方法如下所示:

import requests
			import socks
			import socket
			socks.set_default_proxy(socks.SOCKS5, '127.0.0.1'7891)
			socket.socket = socks.socksocket
			try:
			   response = requests.get('https://httpbin.org/get')
			   print(response.text)
			except requests.exceptions.ConnectionError as e:
			   print('Error', e.args)
			

使用这种方法也可以设置 SOCKS 代理,运行结果完全相同。相比第一种方法,此方法是全局设置。我们可以在不同情况下选用不同的方法。

Selenium 设置代理

Selenium 同样可以设置代理,在这里以 Chrome 为例来介绍下其设置方法。

对于无认证的代理,设置方法如下:

from selenium import webdriver
			proxy = '127.0.0.1:7890'
			options = webdriver.ChromeOptions()
			options.add_argument('--proxy-server=http://' + proxy)
			browser = webdriver.Chrome(options=options)
			browser.get('https://httpbin.org/get')
			print(browser.page_source)
			browser.close()
			

运行结果如下:

{
			 "args": {},
			 "headers": {
			   "Accept""text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
			   "Accept-Encoding""gzip, deflate",
			   "Accept-Language""zh-CN,zh;q=0.9",
			   "Host""httpbin.org",
			   "Upgrade-Insecure-Requests""1",
			   "User-Agent""Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.149 Safari/537.36",
			   "X-Amzn-Trace-Id""Root=1-5e8f39cd-60930018205fd154a9af39cc"
			 },
			 "origin""210.173.1.204",
			 "url""http://httpbin.org/get"
			}
			

代理设置成功,origin 同样为代理 IP 的地址。

如果代理是认证代理,则设置方法相对比较麻烦,设置方法如下所示:

from selenium import webdriver
			from selenium.webdriver.chrome.options import Options
			import zipfile
			 
			ip = '127.0.0.1'
			port = 7890
			username = 'foo'
			password = 'bar'
			 
			manifest_json = """{"version":"1.0.0","manifest_version": 2,"name":"Chrome Proxy","permissions": ["proxy","tabs","unlimitedStorage","storage","<all_urls>","webRequest","webRequestBlocking"],"background": {"scripts": ["background.js"]
			   }
			}
			"""
			background_js = """
			var config = {
			       mode: "fixed_servers",
			       rules: {
			         singleProxy: {
			           scheme: "http",
			           host: "%(ip) s",
			           port: %(port) s
			         }
			       }
			     }
			 
			chrome.proxy.settings.set({value: config, scope: "regular"}, function() {});
			 
			function callbackFn(details) {
			   return {
			       authCredentials: {username: "%(username) s",
			           password: "%(password) s"
			       }
			   }
			}
			 
			chrome.webRequest.onAuthRequired.addListener(
			           callbackFn,
			           {urls: ["<all_urls>"]},
			           ['blocking']
			)
			""" % {'ip': ip, 'port': port, 'username': username, 'password': password}
			 
			plugin_file = 'proxy_auth_plugin.zip'
			with zipfile.ZipFile(plugin_file, 'w'as zp:
			   zp.writestr("manifest.json", manifest_json)
			   zp.writestr("background.js", background_js)
			options = Options()
			options.add_argument("--start-maximized")
			options.add_extension(plugin_file)
			browser = webdriver.Chrome(options=options)
			browser.get('https://httpbin.org/get')
			print(browser.page_source)
			browser.close()
			

这里需要在本地创建一个 manifest.json 配置文件和 background.js 脚本来设置认证代理。运行代码之后本地会生成一个 proxy_auth_plugin.zip 文件来保存当前配置。

运行结果和上例一致,origin 同样为代理 IP。

SOCKS 代理的设置也比较简单,把对应的协议修改为 socks5 即可,如无密码认证的代理设置方法为:

from selenium import webdriver

			proxy = '127.0.0.1:7891'
			options = webdriver.ChromeOptions()
			options.add_argument('--proxy-server=socks5://' + proxy)
			browser = webdriver.Chrome(options=options)
			browser.get('https://httpbin.org/get')
			print(browser.page_source)
			browser.close()
			

运行结果是一样的。

aiohttp 设置代理

对于 aiohttp 来说,我们可以通过 proxy 参数直接设置即可,HTTP 代理设置如下:

import asyncio
			import aiohttp

			proxy = 'http://127.0.0.1:7890'

			async def main():
			   async with aiohttp.ClientSession() as session:
			       async with session.get('https://httpbin.org/get', proxy=proxy) as response:
			           print(await response.text())

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

如果代理有用户名密码,像 requests 一样,把 proxy 修改为如下内容:

proxy = 'http://username:password@127.0.0.1:7890'
			

这里只需要将 username 和 password 替换即可。

对于 SOCKS 代理,我们需要安装一个支持库,叫作 aiohttp-socks,安装命令如下:

pip3 install aiohttp-socks
			

可以借助于这个库的 ProxyConnector 来设置 SOCKS 代理,代码如下:

import asyncio
			import aiohttp
			from aiohttp_socks import ProxyConnector
			 
			connector = ProxyConnector.from_url('socks5://127.0.0.1:7891')
			 
			async def main():
			   async with aiohttp.ClientSession(connector=connector) as session:
			       async with session.get('https://httpbin.org/get'as response:
			           print(await response.text())

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

运行结果是一样的。

另外这个库还支持设置 SOCKS4、HTTP 代理以及对应的代理认证,可以参考其官方介绍。

Pyppeteer 设置代理

对于 Pyppeteer 来说,由于其默认使用的是类似 Chrome 的 Chromium 浏览器,因此设置方法和 Selenium 的 Chrome 是一样的,如 HTTP 无认证代理设置方法都是通过 args 来设置,实现如下:

import asyncio
			from pyppeteer import launch

			proxy = '127.0.0.1:7890'

			async def main():
			   browser = await launch({'args': ['--proxy-server=http://' + proxy], 'headless'False})
			   page = await browser.newPage()
			   await page.goto('https://httpbin.org/get')
			   print(await page.content())
			   await browser.close()

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

运行结果:

{
			 "args": {},
			 "headers": {
			   "Accept""text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8",
			   "Accept-Encoding""gzip, deflate, br",
			   "Accept-Language""zh-CN,zh;q=0.9",
			   "Host""httpbin.org",
			   "Upgrade-Insecure-Requests""1",
			   "User-Agent""Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3494.0 Safari/537.36",
			   "X-Amzn-Trace-Id""Root=1-5e8f442c-12b1ed7865b049007267a66c"
			 },
			 "origin""210.173.1.204",
			 "url""https://httpbin.org/get"
			}
			

同样可以看到设置成功。

对于 SOCKS 代理,也是一样的,只需要将协议修改为 socks5 即可,代码实现如下:

import asyncio
			from pyppeteer import launch

			proxy = '127.0.0.1:7891'

			async def main():
			   browser = await launch({'args': ['--proxy-server=socks5://' + proxy], 'headless'False})
			   page = await browser.newPage()
			   await page.goto('https://httpbin.org/get')
			   print(await page.content())
			   await browser.close()

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

运行结果也是一样的。

总结

以上总结了各个库的代理使用方式,以后如果遇到封 IP 的问题,我们就可以轻松通过加代理的方式来解决啦。

本节代码:https://github.com/Python3WebSpider/ProxyTest

第21讲:提高利用效率,代理池的搭建和使用

我们在上一课时了解了利用代理可以解决目标网站封 IP 的问题,但是如何实时高效地获取到大量可用的代理又是一个问题。

首先在互联网上有大量公开的免费代理,当然我们也可以购买付费的代理 IP,但是代理不论是免费的还是付费的,都不能保证是可用的,因为可能此 IP 已被其他人使用来爬取同样的目标站点而被封禁,或者代理服务器突然发生故障或网络繁忙。一旦我们选用了一个不可用的代理,这势必会影响爬虫的工作效率。

所以,我们需要提前做筛选,将不可用的代理剔除掉,保留可用代理。那么这个怎么来实现呢?这里就需要借助于一个叫作代理池的东西了。

接下来本课时我们就介绍一下如何搭建一个高效易用的代理池。

准备工作

在这里代理池的存储我们需要借助于 Redis,因此这个需要额外安装。总体来说,本课时需要的环境如下:

安装好一些必要的库,包括 aiohttp、requests、redis-py、pyquery、Flask 等。

建议使用 Python 虚拟环境安装,参考安装命令如下:

做好了如上准备工作,我们便可以开始实现或运行本课时所讲的代理池了。

代理池的目标

我们需要做到下面的几个目标,来实现易用高效的代理池。

以上内容是设计代理的一些基本思路。接下来我们设计整体的架构,然后用代码实现代理池。

代理池的架构

根据上文的描述,代理池的架构如图所示。

代理池分为 4 个模块:存储模块、获取模块、检测模块、接口模块。

代理池的实现

接下来我们分别用代码来实现一下这四个模块。

注:完整的代理池代码量较大,因此本课时的代码不必一步步跟着编写,最后去了解源码即可。

存储模块

这里我们使用 Redis 的有序集合,集合的每一个元素都是不重复的,对于代理池来说,集合的元素就变成了一个个代理,也就是 IP 加端口的形式,如 60.207.237.111:8888,这样的一个代理就是集合的一个元素。另外,有序集合的每一个元素都有一个分数字段,分数是可以重复的,可以是浮点数类型,也可以是整数类型。该集合会根据每一个元素的分数对集合进行排序,数值小的排在前面,数值大的排在后面,这样就可以实现集合元素的排序了。

对于代理池来说,这个分数可以作为判断一个代理是否可用的标志,100 为最高分,代表最可用,0 为最低分,代表最不可用。如果要获取可用代理,可以从代理池中随机获取分数最高的代理,注意是随机,这样可以保证每个可用代理都会被调用到。

分数是我们判断代理稳定性的重要标准,设置分数规则如下所示。

这只是一种解决方案,当然可能还有更合理的方案。之所以设置此方案有如下几个原因。

上述代理分数的设置思路不一定是最优思路,但据个人实测,它的实用性还是比较强的。

在这里首先给出存储模块的实现代码,见:https://github.com/Python3WebSpider/ProxyPool/tree/master/proxypool/storages,建议直接对照源码阅读。

在代码中,我们定义了一个类来操作数据库的有序集合,定义一些方法来实现分数的设置、代理的获取等。其核心实现代码实现如下所示:

import redis
			from proxypool.exceptions import PoolEmptyException
			from proxypool.schemas.proxy import Proxy
			from proxypool.setting import REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, REDIS_KEY, PROXY_SCORE_MAX, PROXY_SCORE_MIN, \
			   PROXY_SCORE_INIT
			from random import choice
			from typing import List
			from loguru import logger
			from proxypool.utils.proxy import is_valid_proxy, convert_proxy_or_proxies
			REDIS_CLIENT_VERSION = redis.__version__
			IS_REDIS_VERSION_2 = REDIS_CLIENT_VERSION.startswith('2.')
			class RedisClient(object):
			   """
			   redis connection client of proxypool
			   """

			   def __init__(self, host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASSWORD, **kwargs):
			       """
			       init redis client
			       :param host: redis host
			       :param port: redis port
			       :param password: redis password
			       """
			       self.db = redis.StrictRedis(host=host, port=port, password=password, decode_responses=True, **kwargs)

			   def add(self, proxy: Proxy, score=PROXY_SCORE_INIT) -> int:
			       """
			       add proxy and set it to init score
			       :param proxy: proxy, ip:port, like 8.8.8.8:88
			       :param score: int score
			       :return: result
			       """
			       if not is_valid_proxy(f'{proxy.host}:{proxy.port}'):
			           logger.info(f'invalid proxy {proxy}, throw it')
			           return
			       if not self.exists(proxy):
			           if IS_REDIS_VERSION_2:
			               return self.db.zadd(REDIS_KEY, score, proxy.string())
			           return self.db.zadd(REDIS_KEY, {proxy.string(): score})

			   def random(self) -> Proxy:
			       """
			       get random proxy
			       firstly try to get proxy with max score
			       if not exists, try to get proxy by rank
			       if not exists, raise error
			       :return: proxy, like 8.8.8.8:8
			       """
			       # try to get proxy with max score
			       proxies = self.db.zrangebyscore(REDIS_KEY, PROXY_SCORE_MAX, PROXY_SCORE_MAX)
			       if len(proxies):
			           return convert_proxy_or_proxies(choice(proxies))
			       # else get proxy by rank
			       proxies = self.db.zrevrange(REDIS_KEY, PROXY_SCORE_MIN, PROXY_SCORE_MAX)
			       if len(proxies):
			           return convert_proxy_or_proxies(choice(proxies))
			       # else raise error
			       raise PoolEmptyException

			   def decrease(self, proxy: Proxy) -> int:
			       """
			       decrease score of proxy, if small than PROXY_SCORE_MIN, delete it
			       :param proxy: proxy
			       :return: new score
			       """
			       score = self.db.zscore(REDIS_KEY, proxy.string())
			       # current score is larger than PROXY_SCORE_MIN
			       if score and score > PROXY_SCORE_MIN:
			           logger.info(f'{proxy.string()} current score {score}, decrease 1')
			           if IS_REDIS_VERSION_2:
			               return self.db.zincrby(REDIS_KEY, proxy.string(), -1)
			           return self.db.zincrby(REDIS_KEY, -1, proxy.string())
			       # otherwise delete proxy
			       else:
			           logger.info(f'{proxy.string()} current score {score}, remove')
			           return self.db.zrem(REDIS_KEY, proxy.string())

			   def exists(self, proxy: Proxy) -> bool:
			       """
			       if proxy exists
			       :param proxy: proxy
			       :return: if exists, bool
			       """
			       return not self.db.zscore(REDIS_KEY, proxy.string()) is None

			   def max(self, proxy: Proxy) -> int:
			       """
			       set proxy to max score
			       :param proxy: proxy
			       :return: new score
			       """
			       logger.info(f'{proxy.string()} is valid, set to {PROXY_SCORE_MAX}')
			       if IS_REDIS_VERSION_2:
			           return self.db.zadd(REDIS_KEY, PROXY_SCORE_MAX, proxy.string())
			       return self.db.zadd(REDIS_KEY, {proxy.string(): PROXY_SCORE_MAX})

			   def count(self) -> int:
			       """
			       get count of proxies
			       :return: count, int
			       """
			       return self.db.zcard(REDIS_KEY)

			   def all(self) -> List[Proxy]:
			       """
			       get all proxies
			       :return: list of proxies
			       """
			       return convert_proxy_or_proxies(self.db.zrangebyscore(REDIS_KEY, PROXY_SCORE_MIN, PROXY_SCORE_MAX))

			   def batch(self, start, end) -> List[Proxy]:
			       """
			       get batch of proxies
			       :param start: start index
			       :param end: end index
			       :return: list of proxies
			       """
			       return convert_proxy_or_proxies(self.db.zrevrange(REDIS_KEY, start, end - 1))
			if __name__ == '__main__':
			   conn = RedisClient()
			   result = conn.random()
			   print(result)
			

首先我们定义了一些常量,如 PROXY_SCORE_MAX、PROXY_SCORE_MIN、PROXY_SCORE_INIT 分别代表最大分数、最小分数、初始分数。REDIS_HOST、REDIS_PORT、REDIS_PASSWORD 分别代表了 Redis 的连接信息,即地址、端口、密码。REDIS_KEY 是有序集合的键名,我们可以通过它来获取代理存储所使用的有序集合。

RedisClient 这个类可以用来操作 Redis 的有序集合,其中定义了一些方法来对集合中的元素进行处理,它的主要功能如下所示。

定义好了这些方法,我们可以在后续的模块中调用此类来连接和操作数据库。如想要获取随机可用的代理,只需要调用 random 方法即可,得到的就是随机的可用代理。

获取模块

获取模块主要是为了从各大网站抓取代理并调用存储模块进行保存,代码实现见:https://github.com/Python3WebSpider/ProxyPool/tree/master/proxypool/crawlers

获取模块的逻辑相对简单,比如我们可以定义一些抓取代理的方法,示例如下:

from proxypool.crawlers.base import BaseCrawler
			from proxypool.schemas.proxy import Proxy
			import re
			MAX_PAGE = 5
			BASE_URL = 'http://www.ip3366.net/free/?stype=1&page={page}'
			class IP3366Crawler(BaseCrawler):
			   """
			   ip3366 crawler, http://www.ip3366.net/
			   """
			   urls = [BASE_URL.format(page=i) for i in range(18)]

			   def parse(self, html):
			       """
			       parse html file to get proxies
			       :return:
			       """
			       ip_address = re.compile('<tr>\s*<td>(.*?)</td>\s*<td>(.*?)</td>')
			       # \s * 匹配空格,起到换行作用
			       re_ip_address = ip_address.findall(html)
			       for address, port in re_ip_address:
			           proxy = Proxy(host=address.strip(), port=int(port.strip()))
			           yield proxy
			

我们在这里定义了一个代理 Crawler 类,用来抓取某一网站的代理,这里是抓取的 IP3366 的公开代理,通过 parse 方法来解析页面的源码并构造一个个 Proxy 对象返回即可。

另外在其父类 BaseCrawler 里面定义了通用的页面抓取方法,它可以读取子类里面定义的 urls 全局变量并进行爬取,然后调用子类的 parse 方法来解析页面,代码实现如下:

from retrying import retry
			import requests
			from loguru import logger
			class BaseCrawler(object):
			   urls = []

			   @retry(stop_max_attempt_number=3, retry_on_result=lambda x: x is None)
			   def fetch(self, url, **kwargs):
			       try:
			           response = requests.get(url, **kwargs)
			           if response.status_code == 200:
			               return response.text
			       except requests.ConnectionError:
			           return

			   @logger.catch
			   def crawl(self):
			       """
			       crawl main method
			       """
			       for url in self.urls:
			           logger.info(f'fetching {url}')
			           html = self.fetch(url)
			           for proxy in self.parse(html):
			               logger.info(f'fetched proxy {proxy.string()} from {url}')
			               yield proxy
			

所以,我们如果要扩展一个代理的 Crawler,只需要继承 BaseCrawler 并实现 parse 方法即可,扩展性较好。

因此,这一个个的 Crawler 就可以针对各个不同的代理网站进行代理的抓取。最后有一个统一的方法将 Crawler 汇总起来,遍历调用即可。

如何汇总呢?在这里我们可以检测代码只要定义有 BaseCrawler 的子类就算一个有效的代理 Crawler,可以直接通过遍历 Python 文件包的方式来获取,代码实现如下:

import pkgutil
			from .base import BaseCrawler
			import inspect
			# load classes subclass of BaseCrawler
			classes = []
			for loader, name, is_pkg in pkgutil.walk_packages(__path__):
			   module = loader.find_module(name).load_module(name)
			   for name, value in inspect.getmembers(module):
			       globals()[name] = value
			       if inspect.isclass(value) and issubclass(value, BaseCrawler) and value is not BaseCrawler:
			           classes.append(value)
			__all__ = __ALL__ = classes
			

在这里我们调用了 walk_packages 方法,遍历了整个 crawlers 模块下的类,并判断了它是 BaseCrawler 的子类,那就将其添加到结果中,并返回。

最后只要将 classes 遍历并依次实例化,调用其 crawl 方法即可完成代理的爬取和提取,代码实现见:https://github.com/Python3WebSpider/ProxyPool/blob/master/proxypool/processors/getter.py

检测模块

我们已经成功将各个网站的代理获取下来了,现在就需要一个检测模块来对所有代理进行多轮检测。代理检测可用,分数就设置为 100,代理不可用,分数减 1,这样就可以实时改变每个代理的可用情况。如要获取有效代理只需要获取分数高的代理即可。

由于代理的数量非常多,为了提高代理的检测效率,我们在这里使用异步请求库 aiohttp 来进行检测。

requests 作为一个同步请求库,我们在发出一个请求之后,程序需要等待网页加载完成之后才能继续执行。也就是这个过程会阻塞等待响应,如果服务器响应非常慢,比如一个请求等待十几秒,那么我们使用 requests 完成一个请求就会需要十几秒的时间,程序也不会继续往下执行,而在这十几秒的时间里程序其实完全可以去做其他的事情,比如调度其他的请求或者进行网页解析等。

对于响应速度比较快的网站来说,requests 同步请求和 aiohttp 异步请求的效果差距没那么大。可对于检测代理来说,检测一个代理一般需要十多秒甚至几十秒的时间,这时候使用 aiohttp 异步请求库的优势就大大体现出来了,效率可能会提高几十倍不止。

所以,我们的代理检测使用异步请求库 aiohttp,实现示例如下所示:

import asyncio
			import aiohttp
			from loguru import logger
			from proxypool.schemas import Proxy
			from proxypool.storages.redis import RedisClient
			from proxypool.setting import TEST_TIMEOUT, TEST_BATCH, TEST_URL, TEST_VALID_STATUS
			from aiohttp import ClientProxyConnectionError, ServerDisconnectedError, ClientOSError, ClientHttpProxyError
			from asyncio import TimeoutError
			EXCEPTIONS = (
			   ClientProxyConnectionError,
			   ConnectionRefusedError,
			   TimeoutError,
			   ServerDisconnectedError,
			   ClientOSError,
			   ClientHttpProxyError
			)
			class Tester(object):
			   """
			   tester for testing proxies in queue
			   """

			   def __init__(self):
			       """
			       init redis
			       """
			       self.redis = RedisClient()
			       self.loop = asyncio.get_event_loop()

			   async def test(self, proxy: Proxy):
			       """
			       test single proxy
			       :param proxy: Proxy object
			       :return:
			       """
			       async with aiohttp.ClientSession(connector=aiohttp.TCPConnector(ssl=False)) as session:
			           try:
			               logger.debug(f'testing {proxy.string()}')
			               async with session.get(TEST_URL, proxy=f'http://{proxy.string()}', timeout=TEST_TIMEOUT,
			                                      allow_redirects=Falseas response:
			                   if response.status in TEST_VALID_STATUS:
			                       self.redis.max(proxy)
			                       logger.debug(f'proxy {proxy.string()} is valid, set max score')
			                   else:
			                       self.redis.decrease(proxy)
			                       logger.debug(f'proxy {proxy.string()} is invalid, decrease score')
			           except EXCEPTIONS:
			               self.redis.decrease(proxy)
			               logger.debug(f'proxy {proxy.string()} is invalid, decrease score')

			   @logger.catch
			   def run(self):
			       """
			       test main method
			       :return:
			       """
			       # event loop of aiohttp
			       logger.info('stating tester...')
			       count = self.redis.count()
			       logger.debug(f'{count} proxies to test')
			       for i in range(0, count, TEST_BATCH):
			           # start end end offset
			           start, end = i, min(i + TEST_BATCH, count)
			           logger.debug(f'testing proxies from {start} to {end} indices')
			           proxies = self.redis.batch(start, end)
			           tasks = [self.test(proxy) for proxy in proxies]
			           # run tasks using event loop
			           self.loop.run_until_complete(asyncio.wait(tasks))
			if __name__ == '__main__':
			   tester = Tester()
			   tester.run()
			

这里定义了一个类 Tester,__init__ 方法中建立了一个 RedisClient 对象,供该对象中其他方法使用。接下来定义了一个 test 方法,这个方法用来检测单个代理的可用情况,其参数就是被检测的代理。注意,test 方法前面加了 async 关键词,代表这个方法是异步的。方法内部首先创建了 aiohttp 的 ClientSession 对象,可以直接调用该对象的 get 方法来访问页面。

测试的链接在这里定义为常量 TEST_URL。如果针对某个网站有抓取需求,建议将 TEST_URL 设置为目标网站的地址,因为在抓取的过程中,代理本身可能是可用的,但是该代理的 IP 已经被目标网站封掉了。例如,某些代理可以正常访问百度等页面,但是对知乎来说可能就被封了,所以我们可以将 TEST_URL 设置为知乎的某个页面的链接,当请求失败、代理被封时,分数自然会减下来,失效的代理就不会被取到了。

如果想做一个通用的代理池,则不需要专门设置 TEST_URL,可以将其设置为一个不会封 IP 的网站,也可以设置为百度这类响应稳定的网站。

我们还定义了 TEST_VALID_STATUS 变量,这个变量是一个列表形式,包含了正常的状态码,如可以定义成 [200]。当然某些目标网站可能会出现其他的状态码,你可以自行配置。

程序在获取 Response 后需要判断响应的状态,如果状态码在 TEST_VALID_STATUS 列表里,则代表代理可用,可以调用 RedisClient 的 max 方法将代理分数设为 100,否则调用 decrease 方法将代理分数减 1,如果出现异常也同样将代理分数减 1。

另外,我们设置了批量测试的最大值为 TEST_BATCH,也就是一批测试最多 TEST_BATCH 个,这可以避免代理池过大时一次性测试全部代理导致内存开销过大的问题。当然也可以用信号量来实现并发控制。

随后,在 run 方法里面获取了所有的代理列表,使用 aiohttp 分配任务,启动运行。这样在不断的运行过程中,代理池中无效的代理的分数会一直被减 1,直至被清除,有效的代理则会一直保持 100 分,供随时取用。

这样,测试模块的逻辑就完成了。

接口模块

通过上述 3 个模块,我们已经可以做到代理的获取、检测和更新,数据库就会以有序集合的形式存储各个代理及其对应的分数,分数 100 代表可用,分数越小代表越不可用。

但是我们怎样方便地获取可用代理呢?可以用 RedisClient 类直接连接 Redis,然后调用 random 方法。这样做没问题,效率很高,但是会有几个弊端。

综上考虑,为了使代理池可以作为一个独立服务运行,我们最好增加一个接口模块,并以 Web API 的形式暴露可用代理。

这样一来,获取代理只需要请求接口即可,以上的几个缺点弊端也可以避免。

我们使用一个比较轻量级的库 Flask 来实现这个接口模块,实现示例如下所示:

from flask import Flask, g
			from proxypool.storages.redis import RedisClient
			from proxypool.setting import API_HOST, API_PORT, API_THREADED
			__all__ = ['app']
			app = Flask(__name__)
			def get_conn():
			   """
			   get redis client object
			   :return:
			   """
			   if not hasattr(g, 'redis'):
			       g.redis = RedisClient()
			   return g.redis
			@app.route('/')
			def index():
			   """
			   get home page, you can define your own templates
			   :return:
			   """
			   return '<h2>Welcome to Proxy Pool System</h2>'
			@app.route('/random')
			def get_proxy():
			   """
			   get a random proxy
			   :return: get a random proxy
			   """
			   conn = get_conn()
			   return conn.random().string()
			@app.route('/count')
			def get_count():
			   """
			   get the count of proxies
			   :return: count, int
			   """
			   conn = get_conn()
			   return str(conn.count())
			if __name__ == '__main__':
			   app.run(host=API_HOST, port=API_PORT, threaded=API_THREADED)
			

在这里,我们声明了一个 Flask 对象,定义了 3 个接口,分别是首页、随机代理页、获取数量页。

运行之后,Flask 会启动一个 Web 服务,我们只需要访问对应的接口即可获取到可用代理。

调度模块

调度模块就是调用以上所定义的 3 个模块,将这 3 个模块通过多进程的形式运行起来,示例如下所示:

import time
			import multiprocessing
			from proxypool.processors.server import app
			from proxypool.processors.getter import Getter
			from proxypool.processors.tester import Tester
			from proxypool.setting import CYCLE_GETTER, CYCLE_TESTER, API_HOST, API_THREADED, API_PORT, ENABLE_SERVER, \
			   ENABLE_GETTER, ENABLE_TESTER, IS_WINDOWS
			from loguru import logger
			if IS_WINDOWS:
			   multiprocessing.freeze_support()
			tester_process, getter_process, server_process = NoneNoneNone
			class Scheduler():
			   """
			   scheduler
			   """

			   def run_tester(self, cycle=CYCLE_TESTER):
			       """
			       run tester
			       """
			       if not ENABLE_TESTER:
			           logger.info('tester not enabled, exit')
			           return
			       tester = Tester()
			       loop = 0
			       while True:
			           logger.debug(f'tester loop {loop} start...')
			           tester.run()
			           loop += 1
			           time.sleep(cycle)

			   def run_getter(self, cycle=CYCLE_GETTER):
			       """
			       run getter
			       """
			       if not ENABLE_GETTER:
			           logger.info('getter not enabled, exit')
			           return
			       getter = Getter()
			       loop = 0
			       while True:
			           logger.debug(f'getter loop {loop} start...')
			           getter.run()
			           loop += 1
			           time.sleep(cycle)

			   def run_server(self):
			       """
			       run server for api
			       """
			       if not ENABLE_SERVER:
			           logger.info('server not enabled, exit')
			           return
			       app.run(host=API_HOST, port=API_PORT, threaded=API_THREADED)

			   def run(self):
			       global tester_process, getter_process, server_process
			       try:
			           logger.info('starting proxypool...')
			           if ENABLE_TESTER:
			               tester_process = multiprocessing.Process(target=self.run_tester)
			               logger.info(f'starting tester, pid {tester_process.pid}...')
			               tester_process.start()

			           if ENABLE_GETTER:
			               getter_process = multiprocessing.Process(target=self.run_getter)
			               logger.info(f'starting getter, pid{getter_process.pid}...')
			               getter_process.start()

			           if ENABLE_SERVER:
			               server_process = multiprocessing.Process(target=self.run_server)
			               logger.info(f'starting server, pid{server_process.pid}...')
			               server_process.start()

			           tester_process.join()
			           getter_process.join()
			           server_process.join()
			       except KeyboardInterrupt:
			           logger.info('received keyboard interrupt signal')
			           tester_process.terminate()
			           getter_process.terminate()
			           server_process.terminate()
			       finally:
			           # must call join method before calling is_alive
			           tester_process.join()
			           getter_process.join()
			           server_process.join()
			           logger.info(f'tester is {"alive" if tester_process.is_alive() else "dead"}')
			           logger.info(f'getter is {"alive" if getter_process.is_alive() else "dead"}')
			           logger.info(f'server is {"alive" if server_process.is_alive() else "dead"}')
			           logger.info('proxy terminated')
			if __name__ == '__main__':
			   scheduler = Scheduler()
			   scheduler.run()
			

3 个常量 ENABLE_TESTER、ENABLE_GETTER、ENABLE_SERVER 都是布尔类型,表示测试模块、获取模块、接口模块的开关,如果都为 True,则代表模块开启。

启动入口是 run 方法,这个方法分别判断 3 个模块的开关。如果开关开启,启动时程序就新建一个 Process 进程,设置好启动目标,然后调用 start 方法运行,这样 3 个进程就可以并行执行,互不干扰。

3 个调度方法结构也非常清晰。比如,run_tester 方法用来调度测试模块,首先声明一个 Tester 对象,然后进入死循环不断循环调用其 run 方法,执行完一轮之后就休眠一段时间,休眠结束之后重新再执行。在这里,休眠时间也定义为一个常量,如 20 秒,即每隔 20 秒进行一次代理检测。

最后,只需要调用 Scheduler 的 run 方法即可启动整个代理池。

以上内容便是整个代理池的架构和相应实现逻辑。

运行

接下来我们将代码整合一下,将代理运行起来,运行之后的输出结果如下所示:

2020-04-13 02:52:06.510 | INFO     | proxypool.storages.redis:decrease:73 - 60.186.146.193:9000 current score 10.0, decrease 1
			2020-04-13 02:52:06.517 | DEBUG    | proxypool.processors.tester:test:52 - proxy 60.186.146.193:9000 is invalid, decrease score
			2020-04-13 02:52:06.524 | INFO     | proxypool.storages.redis:decrease:73 - 60.186.151.147:9000 current score 10.0, decrease 1
			2020-04-13 02:52:06.532 | DEBUG    | proxypool.processors.tester:test:52 - proxy 60.186.151.147:9000 is invalid, decrease score
			2020-04-13 02:52:07.159 | INFO     | proxypool.storages.redis:max:96 - 60.191.11.246:3128 is valid, set to 100
			2020-04-13 02:52:07.167 | DEBUG    | proxypool.processors.tester:test:46 - proxy 60.191.11.246:3128 is valid, set max score
			2020-04-13 02:52:17.271 | INFO     | proxypool.storages.redis:decrease:73 - 59.62.7.130:9000 current score 10.0, decrease 1
			2020-04-13 02:52:17.280 | DEBUG    | proxypool.processors.tester:test:52 - proxy 59.62.7.130:9000 is invalid, decrease score
			2020-04-13 02:52:17.288 | INFO     | proxypool.storages.redis:decrease:73 - 60.167.103.74:1133 current score 10.0, decrease 1
			2020-04-13 02:52:17.295 | DEBUG    | proxypool.processors.tester:test:52 - proxy 60.167.103.74:1133 is invalid, decrease score
			2020-04-13 02:52:17.302 | INFO     | proxypool.storages.redis:decrease:73 - 60.162.71.113:9000 current score 10.0, decrease 1
			2020-04-13 02:52:17.309 | DEBUG    | proxypool.processors.tester:test:52 - proxy 60.162.71.113:9000 is invalid, decrease score
			

以上是代理池的控制台输出,可以看到可用代理设置为 100,不可用代理分数减 1。

接下来我们再打开浏览器,当前配置了运行在 5555 端口,所以打开:http://127.0.0.1:5555,即可看到其首页,如图所示。

再访问 http://127.0.0.1:5555/random,即可获取随机可用代理,如图所示。

我们只需要访问此接口即可获取一个随机可用代理,这非常方便。

获取代理的代码如下所示:

import requests
			 
			PROXY_POOL_URL = 'http://localhost:5555/random'
			 
			def get_proxy():
			   try:
			       response = requests.get(PROXY_POOL_URL)
			       if response.status_code == 200:
			           return response.text
			   except ConnectionError:
			       return None
			

这样便可以获取到一个随机代理了,它是字符串类型,此代理可以按照上一课时所示的方法设置,如 requests 的使用方法如下所示:

import requests
			 
			proxy = get_proxy()
			proxies = {
			   'http''http://' + proxy,
			   'https''https://' + proxy,
			}
			try:
			   response = requests.get('http://httpbin.org/get', proxies=proxies)
			   print(response.text)
			except requests.exceptions.ConnectionError as e:
			   print('Error', e.args)
			

有了代理池之后,我们再取出代理即可有效防止 IP 被封禁的情况。

总结

本课时代码地址为:https://github.com/Python3WebSpider/ProxyPool,代码量相比之前的案例复杂了很多,逻辑也相对完善。另外代码库中还提供了 Docker 和 Kubernetes 的运行和部署操作,可以帮助我们更加快捷地运行代理池,如果你感兴趣可以了解下。

第22讲:验证码反爬虫的基本原理

我们在浏览网站的时候经常会遇到各种各样的验证码,在多数情况下这些验证码会出现在登录账号的时候,也可能会出现在访问页面的过程中,严格来说,这些行为都算验证码反爬虫。

本课时我们就来介绍下验证码反爬虫的基本原理及常见的验证码和解决方案。

验证码

验证码,全称叫作 Completely Automated Public Turing test to tell Computers and Humans Apart,意思是全自动区分计算机和人类的图灵测试,取了它们关键词的首字母变成了 CAPTCHA,它是一种用来区分用户是计算机还是人的公共全自动程序。

它有什么用呢?当然很多用处,如:

总的来说呢,以上的行为都可以称之为验证码反爬虫行为。使用验证码可以防止各种可以用程序模拟的行为。有了验证码,机器要想完全自动化执行就会遇到一些麻烦,当然这个麻烦的大小就取决于验证码的破解难易程度了。

验证码反爬虫

那为什么会出现验证码呢?在大多数情形下是因为网站的访问频率过高或者行为异常,或者是为了直接限制某些自动化行为。归类如下:

这几种情形都能在一定程度上限制程序的一些自动化行为,因此都可以称之为反爬虫。

验证码反爬虫的原理

在模块一的时候,我们已经讲到过 Session 的基本概念了,它是存在于服务端的,用于保存当前用户的会话信息,这个信息对于验证码的机制非常重要。

服务端是可以往 Session 对象里面存一些值的,比如我们要生成一个图形验证码,比如 1234 这四个数字的图形验证码。

首先客户端要显示某个验证码,这个验证码相关的信息肯定要从服务器端来获取。比如说请求了这个生成验证码的接口,我们要生成一个图形验证码,内容为 1234,这时候服务端会将 1234 这四个数字保存到 Session 对象里面,然后把 1234 这个结果返回给客户端,或者直接把生成好的验证码图形返回也是可以的,客户端会将其呈现出来,用户就能看到验证码的内容了。

用户看到验证码之后呢,就会在表单里面输入验证码的内容,点击提交按钮的时候,这些信息就会又发送给服务器,服务器拿着提交的信息和 Session 里面保存的验证码信息后进行对比,如果一致,那就代表验证码输入正确,校验成功,然后就继续放行恢复正常状态。如果不一致,那就代表校验失败,会继续进行校验。

目前市面上大多数的验证码都是基于这个机制来实现的,归类如下:

还有很多其他的验证码,其原理基本都是一致的。

常见验证码

下面我们再来看看市面上的一些常见的验证码,并简单介绍一些识别思路。

图形验证码

最基本的验证码就是图形验证码了,比如下图。

一般来说,识别思路有这么几种:

行为验证码

现在我们能见到非常多类型的行为验证码,可以说是十分流行了,比如极验、腾讯、网易盾等等都有类似的验证码服务,另外验证的方式也多种多样,如滑动、拖动、点选、逻辑判断等等,如图所示。

这里推荐的识别方案有以下几种:

短信、扫码验证码

另外我们可能遇到一些类似短信、扫码的验证码,这种操作起来就会更加麻烦,一些解决思路如下:

基本上验证码都是类似的,其中有一些列举不全,但是基本类别都能大致归类。

以上我们就介绍了验证码反爬虫的基本原理和一些验证码识别的思路。在后面的课时我会介绍使用打码平台和深度学习的方式来识别验证码的方案。

第23讲:利用资源,学会用打码平台处理验证码

在前一课时我们介绍了多种多样的验证码,有图形文字的、有模拟点选的、有拖动滑动的,但其实归根结底都需要人来对某种情形做一些判断,然后把结果返回并提交。如果此时提交的验证码结果是正确的,并且通过了一些验证码的检测,就能成功突破这个验证码了。

那么,既然验证码就是让人来识别的,那么机器怎么办呢?如果我们也不会什么算法,怎么去解这些验证码呢?此时如果有一个帮助我们来识别验证码的工具或平台就好了,让工具或平台把验证码识别的结果返回给我们,我们拿着结果提交,那不就好了吗?

有这种工具或平台吗?还真有专门的打码平台帮助我们来识别各种各样的验证码,平台内部对算法和人力做了集成,可以 7x24 小时来识别各种验证码,包括识别图形、坐标点、缺口等各种验证码,返回对应的结果或坐标,正好可以解决我们的问题。

本课时我们就来介绍利用打码平台来识别验证码的流程。

学习目标

本课时我们以一种点选验证码为例来讲解打码平台的使用方法,验证码的链接为:https://captcha3.scrape.cuiqingcai.com/,这个网站在每次登录的时候都会弹出一个验证码,其验证码效果图如下所示。

这个验证码上面显示了几个汉字,同时在图中也显示了几个汉字,我们需要按照顺序依次点击汉字在图中的位置,点击完成之后确认提交,即可完成验证。

这种验证码如果我们没有任何图像识别算法基础的话,是很难去识别的,所以这里我们可以借助打码平台来帮助我们识别汉字的位置。

准备工作

我们使用的 Python 库是 Selenium,使用的浏览器为 Chrome。

在本课时开始之前请确保已经正确安装好 Selenium 库、Chrome 浏览器,并配置好 ChromeDriver,相关流程可以参考 Selenium 那一课时的介绍。

另外本课时使用的打码平台是超级鹰,链接为:https://www.chaojiying.com/,在使用之前请你自己注册账号并获取一些题分供测试,另外还可以了解平台可识别的验证码的类别。

打码平台

打码平台能提供的服务种类一般都非常广泛,可识别的验证码类型也非常多,其中就包括点触验证码。

超级鹰平台同样支持简单的图形验证码识别。超级鹰平台提供了如下一些服务。

具体如有变动以官网为准:https://www.chaojiying.com/price.html

这里需要处理的就是坐标多选识别的情况。我们先将验证码图片提交给平台,平台会返回识别结果在图片中的坐标位置,然后我们再解析坐标模拟点击。

下面我们就用程序来实现。

获取 API

在官方网站下载对应的 Python API,链接为:https://www.chaojiying.com/api-14.html。API 是 Python 2 版本的,是用 requests 库来实现的。我们可以简单更改几个地方,即可将其修改为 Python 3 版本。

修改之后的 API 如下所示:

import requests
			from hashlib import md5
			class Chaojiying(object):

			   def __init__(self, username, password, soft_id):
			       self.username = username
			       self.password = md5(password.encode('utf-8')).hexdigest()
			       self.soft_id = soft_id
			       self.base_params = {
			           'user': self.username,
			           'pass2': self.password,
			           'softid': self.soft_id,
			       }
			       self.headers = {
			           'Connection''Keep-Alive',
			           'User-Agent''Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1; Trident/4.0)',
			       }

			   def post_pic(self, im, codetype):
			       """
			       im: 图片字节
			       codetype: 题目类型 参考 http://www.chaojiying.com/price.html
			       """
			       params = {
			           'codetype': codetype,
			       }
			       params.update(self.base_params)
			       files = {'userfile': ('ccc.jpg', im)}
			       r = requests.post('http://upload.chaojiying.net/Upload/Processing.php', data=params, files=files,
			                         headers=self.headers)
			       return r.json()

			   def report_error(self, im_id):
			       """
			       im_id:报错题目的图片ID
			       """
			       params = {
			           'id': im_id,
			       }
			       params.update(self.base_params)
			       r = requests.post('http://upload.chaojiying.net/Upload/ReportError.php', data=params, headers=self.headers)
			       return r.json()
			

这里定义了一个 Chaojiying 类,其构造函数接收三个参数,分别是超级鹰的用户名、密码以及软件 ID,保存以备使用。

最重要的一个方法叫作 post_pic,它需要传入图片对象和验证码类型的代号。该方法会将图片对象和相关信息发给超级鹰的后台进行识别,然后将识别成功的 JSON 返回。

另一个方法叫作 report_error,它是发生错误时的回调。如果验证码识别错误,调用此方法会返回相应的题分。

接下来,我们以 https://captcha3.scrape.cuiqingcai.com/ 为例来演示下识别的过程。

初始化

首先我们引入一些必要的包,然后初始化一些变量,如 WebDriver、Chaojiying 对象等,代码实现如下所示:

import time
			from io import BytesIO
			from PIL import Image
			from selenium import webdriver
			from selenium.webdriver import ActionChains
			from selenium.webdriver.common.by import By
			from selenium.webdriver.support.ui import WebDriverWait
			from selenium.webdriver.support import expected_conditions as EC
			from chaojiying import Chaojiying
			USERNAME = 'admin'
			PASSWORD = 'admin'
			CHAOJIYING_USERNAME = ''
			CHAOJIYING_PASSWORD = ''
			CHAOJIYING_SOFT_ID = 893590
			CHAOJIYING_KIND = 9102
			if not CHAOJIYING_USERNAME or not CHAOJIYING_PASSWORD:
			   print('请设置用户名和密码')
			   exit(0)
			class CrackCaptcha():
			   def __init__(self):
			       self.url = 'https://captcha3.scrape.cuiqingcai.com/'
			       self.browser = webdriver.Chrome()
			       self.wait = WebDriverWait(self.browser, 20)
			       self.username = USERNAME
			       self.password = PASSWORD
			       self.chaojiying = Chaojiying(CHAOJIYING_USERNAME, CHAOJIYING_PASSWORD, CHAOJIYING_SOFT_ID)
			

这里的 USERNAME、PASSWORD 是示例网站的用户名和密码,都设置为 admin 即可。另外 CHAOJIYING_USERNAME、CHAOJIYING_PASSWORD 就是超级鹰打码平台的用户名和密码,可以自行设置成自己的。

另外这里定义了一个 CrackCaptcha 类,初始化了浏览器对象和打码平台的操作对象。

接下来我们用 Selenium 模拟呼出验证码开始验证就好啦。

获取验证码

接下来的步骤就是完善相关表单,模拟点击呼出验证码了,代码实现如下所示:

def open(self):
			   """
			   打开网页输入用户名密码
			   :return: None
			   """
			   self.browser.get(self.url)
			   # 填入用户名密码
			   username = self.wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, 'input[type="text"]')))
			   password = self.wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, 'input[type="password"]')))
			   username.send_keys(self.username)
			   password.send_keys(self.password)
			def get_captcha_button(self):
			   """
			   获取初始验证按钮
			   :return:
			   """
			   button = self.wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, 'button[type="button"]')))
			   return button
			

这里我们调用了 open 方法负责填写表单,get_captcha_button 方法获取验证码按钮,之后触发点击,这时候就可以看到页面已经把验证码呈现出来了。

有了验证码的图片,我们下一步要做的就是把验证码的具体内容获取下来,然后发送给打码平台识别。

那怎么获取验证码的图片呢?我们可以先获取验证码图片的位置和大小,从网页截图里截取相应的验证码图片即可,代码实现如下所示:

def get_captcha_element(self):
			   """
			   获取验证图片对象
			   :return: 图片对象
			   """
			   # 验证码图片加载出来
			   self.wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, 'img.geetest_item_img')))
			   # 验证码完整节点
			   element = self.wait.until(EC.presence_of_element_located((By.CLASS_NAME, 'geetest_panel_box')))
			   print('成功获取验证码节点')
			   return element
			def get_captcha_position(self):
			   """
			   获取验证码位置
			   :return: 验证码位置元组
			   """
			   element = self.get_captcha_element()
			   time.sleep(2)
			   location = element.location
			   size = element.size
			   top, bottom, left, right = location['y'], location['y'] + size['height'], location['x'], location['x'] + size[
			       'width']
			   return (top, bottom, left, right)
			def get_screenshot(self):
			   """
			   获取网页截图
			   :return: 截图对象
			   """
			   screenshot = self.browser.get_screenshot_as_png()
			   screenshot = Image.open(BytesIO(screenshot))
			   screenshot.save('screenshot.png')
			   return screenshot
			def get_captcha_image(self, name='captcha.png'):
			   """
			   获取验证码图片
			   :return: 图片对象
			   """
			   top, bottom, left, right = self.get_captcha_position()
			   print('验证码位置', top, bottom, left, right)
			   screenshot = self.get_screenshot()
			   captcha = screenshot.crop((left, top, right, bottom))
			   captcha.save(name)
			   return captcha
			

这里 get_captcha_image 方法即为从网页截图中截取对应的验证码图片,其中验证码图片的相对位置坐标由 get_captcha_position 方法返回得到。所以就是利用了先截图再裁切的方法获取了验证码。

注意:如果你的屏幕是高清屏如 Mac 的 Retina 屏幕的话,可能需要适当调整下屏幕分辨率或者对获取到的验证码位置做一些倍数偏移计算。

最后我们得到的验证码是 Image 对象,其结果样例如图所示。

识别验证码

现在我们有了验证码图了,下一步就是把图发送给打码平台了。

我们调用 Chaojiying 对象的 post_pic 方法,即可把图片发送给超级鹰后台,这里发送的图像是字节流格式,代码实现如下所示:

image = self.get_touclick_image()
			bytes_array = BytesIO()
			image.save(bytes_array, format='PNG')
			# 识别验证码
			result = self.chaojiying.post_pic(bytes_array.getvalue(), CHAOJIYING_KIND)
			print(result)
			

运行之后,result 变量就是超级鹰后台的识别结果。可能运行需要等待几秒,它会返回一个 JSON 格式的字符串。

如果识别成功,典型的返回结果如下所示:

{'err_no'0'err_str''OK''pic_id''6002001380949200001''pic_str''132,127|56,77''md5''1f8e1d4bef8b11484cb1f1f34299865b'}
			其中,pic_str 就是识别的文字的坐标,是以字符串形式返回的,每个坐标都以 | 分隔。接下来我们只需要将其解析,然后模拟点击,代码实现如下所示:
			def get_points(self, captcha_result):
			   """
			   解析识别结果
			   :param captcha_result: 识别结果
			   :return: 转化后的结果
			   """
			   groups = captcha_result.get('pic_str').split('|')
			   locations = [[int(number) for number in group.split(',')] for group in groups]
			   return locations
			def touch_click_words(self, locations):
			   """
			   点击验证图片
			   :param locations: 点击位置
			   :return: None
			   """
			   for location in locations:
			       ActionChains(self.browser).move_to_element_with_offset(self.get_captcha_element(), location[0], location[1]).click().perform()
			       time.sleep(1)
			

这里用 get_points 方法将识别结果变成列表的形式。touch_click_words 方法则通过调用 move_to_element_with_offset 方法依次传入解析后的坐标,点击即可。

这样我们就模拟完成坐标的点选了,运行效果如下所示。

最后再模拟点击提交验证的按钮,等待验证通过就会自动登录啦,后续实现在此不再赘述。

如何判断登录是否成功呢?同样可以使用 Selenium 的判定条件,比如判断页面里面出现了某个文字就代表登录成功了,代码如下:

# 判定是否成功
			success = self.wait.until(EC.text_to_be_present_in_element((By.TAG_NAME, 'h2'), '登录成功'))
			

比如这里我们判定了点击确认按钮,页面会不会跳转到提示成功的页面,成功的页面包含一个 h2 节点,包含“登录成功”四个字,就代表登录成功啦。

这样我们就借助在线验证码平台完成了点触验证码的识别。此方法是一种通用方法,我们也可以用此方法来识别图文、数字、算术等各种各样的验证码。

结语

本课时我们通过在线打码平台辅助完成了验证码的识别。这种识别方法非常强大,几乎任意的验证码都可以识别。如果遇到难题,借助打码平台无疑是一个极佳的选择。

第24讲:更智能的深度学习处理验证码

我们在前面讲解了如何使用打码平台来识别验证码,简单高效。但是也有一些缺点,比如效率可能没那么高,准确率也不一定能做到完全可控,并且需要付出一定的费用。

本课时我们就来介绍使用深度学习来识别验证码的方法,训练好对应的模型就能更好地对验证码进行识别,并且准确率可控,节省一定的成本。

本课时我们以深度学习识别滑块验证码为例来讲解深度学习对于此类验证码识别的实现。

滑块验证码是怎样的呢?如图所示,验证码是一张矩形图,图片左侧会出现一个滑块,右侧会出现一个缺口,下侧会出现一个滑轨。左侧的滑块会随着滑轨的拖动而移动,如果能将左侧滑块匹配滑动到右侧缺口处,就算完成了验证。

1.png

由于这种验证码交互形式比较友好,且安全性、美观度上也会更高,像这种类似的验证码也变得越来越流行。另外不仅仅是“极验”,其他很多验证码服务商也推出了类似的验证码服务,如“网易易盾”等,上图所示的就是“网易易盾”的滑动验证码。

没错,这种滑动验证码的出现确实让很多网站变得更安全。但是做爬虫的可就苦恼了,如果想采用自动化的方法来绕过这种滑动验证码,关键点在于以下两点:

那么问题来了,第一步怎么做呢?

接下来我们就来看看如何利用深度学习来实现吧。

目标检测

我们的目标就是输入一张图,输出缺口的的位置,所以只需要将这个问题归结成一个深度学习的“目标检测”问题就好了。

首先在开始之前简单说下目标检测。什么叫目标检测?顾名思义,就是把我们想找的东西找出来。比如给一张“狗”的图片,如图所示:

2.png

我们想知道这只狗在哪,它的舌头在哪,找到了就把它们框选出来,这就是目标检测。

经过目标检测算法处理之后,我们期望得到的图片是这样的:

3.png

可以看到这只狗和它的舌头就被框选出来了,这样就完成了一个不错的目标检测。

当前做目标检测的算法主要有两个方向,有一阶段式和两阶段式,英文分别叫作 One stage 和 Two stage,简述如下。

所以这次我们选用 One Stage 的有代表性的目标检测算法 YOLO 来实现滑动验证码缺口的识别。

YOLO,英文全称叫作 You Only Look Once,取了它们的首字母就构成了算法名,目前 YOLO 算法最新的版本是 V3 版本,这里算法的具体流程我们就不过多介绍了,如果你感兴趣可以搜一下相关资料了解下,另外也可以了解下 YOLO V1~V3 版本的不同和改进之处,这里列几个参考链接。

数据准备

回归我们本课时的主题,我们要做的是缺口的位置识别,那么第一步应该做什么呢?

我们的目标是要训练深度学习模型,那我们总得需要让模型知道要学点什么东西吧,这次我们做缺口识别,那么我们需要让模型学的就是找到这个缺口在哪里。由于一张验证码图片只有一个缺口,要分类就是一类,所以我们只需要找到缺口位置就行了。

好,那模型要学如何找出缺口的位置,就需要我们提供样本数据让模型来学习才行。样本数据怎样的呢?样本数据就得有带缺口的验证码图片以及我们自己标注的缺口位置。只有把这两部分都告诉模型,模型才能去学习。等模型学好了,当我们再给个新的验证码时,就能检测出缺口在哪里了,这就是一个成功的模型。

OK,那我们就开始准备数据和缺口标注结果吧。

数据这里用的是网易盾的验证码,验证码图片可以自行收集,写个脚本批量保存下来就行。标注的工具可以使用 LabelImg,GitHub 链接为:https://github.com/tzutalin/labelImg,利用它我们可以方便地进行检测目标位置的标注和类别的标注,如这里验证码和标注示例如下:

4.png

标注完了会生成一系列 xml 文件,你需要解析 xml 文件把位置的坐标和类别等处理一下,转成训练模型需要的数据。

在这里我已经整理好了我的数据集,完整 GitHub 链接为:https://github.com/Python3WebSpider/DeepLearningSlideCaptcha,我标注了 200 多张图片,然后处理了 xml 文件,变成训练 YOLO 模型需要的数据格式,验证码图片和标注结果见 data/captcha 文件夹。

如果要训练自己的数据,数据格式准备见:https://github.com/eriklindernoren/PyTorch-YOLOv3#train-on-custom-dataset

初始化

上一步我已经把标注好的数据处理好了,可以直接拿来训练了。

由于 YOLO 模型相对比较复杂,所以这个项目我就直接基于开源的 PyTorch-YOLOV3 项目来进行修改了,模型使用的深度学习框架为 PyTorch,具体的 YOLO V3 模型的实现这里不再阐述了。

另外推荐使用 GPU 训练,不然拿 CPU 直接训练速度会很慢。我的 GPU 是 P100,几乎十几秒就训练完一轮。

下面就直接把代码克隆下来吧。

由于本项目我把训练好的模型也放上去了,使用了 Git LFS,所以克隆时间较长,克隆命令如下:

git clone https://github.com/Python3WebSpider/DeepLearningSlideCaptcha.git
			

如果想加速克隆,可以暂时先跳过大文件模型下载,可以执行命令:

GIT_LFS_SKIP_SMUDGE=1 git clone https://github.com/Python3WebSpider/DeepLearningSlideCaptcha.git
			

环境安装

代码克隆下载之后,我们还需要下载一些预训练模型。

YOLOV3 的训练要加载预训练模型才能有不错的训练效果,预训练模型下载命令如下:

bash prepare.sh
			

执行这个脚本,就能下载 YOLO V3 模型的一些权重文件,包括 yolov3 和 weights,还有 darknet 的 weights,在训练之前我们需要用这些权重文件初始化 YOLO V3 模型。

注意:Windows 下建议使用 Git Bash 来运行上述命令。

另外还需要安装一些必须的库,如 PyTorch、TensorBoard 等,建议使用 Python 虚拟环境,运行命令如下:

pip3 install -r requirements.txt
			

这些库都安装好了之后,就可以开始训练了。

训练

本项目已经提供了标注好的数据集,在 data/captcha,可以直接使用。

当前数据训练脚本:

bash train.sh
			

实测 P100 训练时长约 15 秒一个 epoch,大约几分钟即可训练出较好效果。

训练差不多了,我们便可以使用 TensorBoard 来看看 loss 和 mAP 的变化,运行 TensorBoard:

tensorboard --logdir='logs' --port=6006 --host 0.0.0.0
			

loss_1 变化如下:

5.png

val_mAP 变化如下:

6.png

可以看到 loss 从最初的非常高下降到了很低,准确率也逐渐接近 100%。

另外训练过程中还能看到如下的输出结果:

---- [Epoch 99/100, Batch 27/29] ----
			+------------+--------------+--------------+--------------+
			| Metrics    | YOLO Layer 0 | YOLO Layer 1 | YOLO Layer 2 |
			+------------+--------------+--------------+--------------+
			| grid_size  | 14           | 28           | 56           |
			| loss       | 0.028268     | 0.046053     | 0.043745     |
			| x          | 0.002108     | 0.005267     | 0.008111     |
			| y          | 0.004561     | 0.002016     | 0.009047     |
			| w          | 0.001284     | 0.004618     | 0.000207     |
			| h          | 0.000594     | 0.000528     | 0.000946     |
			| conf       | 0.019700     | 0.033624     | 0.025432     |
			| cls        | 0.000022     | 0.000001     | 0.000002     |
			| cls_acc    | 100.00%      | 100.00%      | 100.00%      |
			| recall50   | 1.000000     | 1.000000     | 1.000000     |
			| recall75   | 1.000000     | 1.000000     | 1.000000     |
			| precision  | 1.000000     | 0.800000     | 0.666667     |
			| conf_obj   | 0.994271     | 0.999249     | 0.997762     |
			| conf_noobj | 0.000126     | 0.000158     | 0.000140     |
			+------------+--------------+--------------+--------------+
			Total loss 0.11806630343198776
			

这里显示了训练过程中各个指标的变化情况,如 loss、recall、precision、confidence 等,分别代表训练过程的损失(越小越好)、召回率(能识别出的结果占应该识别出结果的比例,越高越好)、精确率(识别出的结果中正确的比率,越高越好)、置信度(模型有把握识别对的概率,越高越好),可以作为参考。

测试

训练完毕之后会在 checkpoints 文件夹生成 pth 文件,可直接使用模型来预测生成标注结果。

如果你没有训练自己的模型的话,这里我已经把训练好的模型放上去了,可以直接使用我训练好的模型来测试。如之前跳过了 Git LFS 文件下载,则可以使用如下命令下载 Git LFS 文件:

git lfs pull
			

此时 checkpoints 文件夹会生成训练好的 pth 文件。

测试脚本:

sh detect.sh
			

该脚本会读取 captcha 下的 test 文件夹所有图片,并将处理后的结果输出到 result 文件夹。

运行结果样例:

Performing object detection:
			        + Batch 0, Inference Time: 0:00:00.044223
			        + Batch 1, Inference Time: 0:00:00.028566
			        + Batch 2, Inference Time: 0:00:00.029764
			        + Batch 3, Inference Time: 0:00:00.032430
			        + Batch 4, Inference Time: 0:00:00.033373
			        + Batch 5, Inference Time: 0:00:00.027861
			        + Batch 6, Inference Time: 0:00:00.031444
			        + Batch 7, Inference Time: 0:00:00.032110
			        + Batch 8, Inference Time: 0:00:00.029131

			Saving images:
			(0) Image: 'data/captcha/test/captcha_4497.png'
			        + Label: target, Conf: 0.99999
			(1) Image: 'data/captcha/test/captcha_4498.png'
			        + Label: target, Conf: 0.99999
			(2) Image: 'data/captcha/test/captcha_4499.png'
			        + Label: target, Conf: 0.99997
			(3) Image: 'data/captcha/test/captcha_4500.png'
			        + Label: target, Conf: 0.99999
			(4) Image: 'data/captcha/test/captcha_4501.png'
			        + Label: target, Conf: 0.99997
			(5) Image: 'data/captcha/test/captcha_4502.png'
			        + Label: target, Conf: 0.99999
			(6) Image: 'data/captcha/test/captcha_4503.png'
			        + Label: target, Conf: 0.99997
			(7) Image: 'data/captcha/test/captcha_4504.png'
			        + Label: target, Conf: 0.99998
			(8) Image: 'data/captcha/test/captcha_4505.png'
			        + Label: target, Conf: 0.99998
			

拿几个样例结果看下:

7.png
8.png
9.png

这里我们可以看到,利用训练好的模型我们就成功识别出缺口的位置了,另外程序还会打印输出这个边框的中心点和宽高信息。

有了这个边界信息,我们再利用某些手段拖动滑块即可通过验证了,比如可以模拟加速减速过程,或者可以录制人的轨迹再执行都是可以的,由于本课时更多是介绍深度学习识别相关内容,所以关于拖动轨迹不再展开讲解。

总结

本课时我们介绍了使用深度学习识别滑动验证码缺口的方法,包括标注、训练、测试等环节都进行了阐述。有了它,我们就能轻松方便地对缺口进行识别了。

代码:https://github.com/Python3WebSpider/DeepLearningSlideCaptcha

第25讲:你有权限吗?解析模拟登录基本原理

在很多情况下,一些网站的页面或资源我们通常需要登录才能看到。比如访问 GitHub 的个人设置页面,如果不登录是无法查看的;比如 12306 买票提交订单的页面,如果不登录是无法提交订单的;再比如要发一条微博,如果不登录是无法发送的。

我们之前学习的案例都是爬取的无需登录即可访问的站点,但是诸如上面例子的情况非常非常多,那假如我们想要用爬虫来访问这些页面,比如用爬虫修改 GitHub 的个人设置,用爬虫提交购票订单,用爬虫发微博,能做到吗?

答案是可以,这里就需要用到一些模拟登录相关的技术了。那么本课时我们就先来了解模拟登录的一些基本原理和实现吧。

网站登录验证的实现

我们要实现模拟登录,那就得首先了解网站登录验证的实现。

登录一般需要两个内容,用户名和密码,有的网站可能是手机号和验证码,有的是微信扫码,有的是 OAuth 验证等等,但根本上来说,都是把一些可供认证的信息提交给了服务器。

比如这里我们就拿用户名和密码来举例吧。用户在一个网页表单里面输入了内容,然后点击登录按钮的一瞬间,浏览器客户端就会向服务器发送一个登录请求,这个请求里面肯定就包含了用户名和密码信息,这时候,服务器需要处理这些信息,然后返回给客户端一个类似“凭证”的东西,有了这个“凭证”以后呢,客户端拿着这个“凭证”再去访问某些需要登录才能查看的页面,服务器自然就能“放行”了,然后返回对应的内容或执行对应的操作就好了。

形象地说,我们以登录发微博和买票坐火车这两件事来类比。发微博就好像要坐火车,没票是没法坐火车的吧,要坐火车怎么办呢?当然是先买票了,我们拿钱去火车站买了票,有了票之后,进站口查验一下,没问题就自然能去坐火车了,这个票就是坐火车的“凭证”。

发微博也一样,我们有用户名和密码,请求下服务器,获得一个“凭证”,这就相当于买到了火车票,然后在发微博的时候拿着这个“凭证”去请求服务器,服务器校验没问题,自然就把微博发出去了。

那么问题来了,这个“凭证“”到底是怎么生成和验证的呢?目前比较流行的实现方式有两种,一种是基于 Session + Cookies 的验证,一种是基于 JWT(JSON Web Token)的验证,下面我们来介绍下。

Session 和 Cookies

我们在模块一了解了 Session 和 Cookies 的基本概念。简而言之,Session 就是存在服务端的,里面保存了用户此次访问的会话信息,Cookies 则是保存在用户本地浏览器的,它会在每次用户访问网站的时候发送给服务器,Cookies 会作为 Request Headers 的一部分发送给服务器,服务器根据 Cookies 里面包含的信息判断找出其 Session 对象,不同的 Session 对象里面维持了不同访问用户的状态,服务器可以根据这些信息决定返回 Response 的内容。

我们以用户登录的情形来举例,其实不同的网站对于用户的登录状态的实现可能是不同的,但是 Session 和 Cookies 一定是相互配合工作的。

梳理如下:

以上两种情况几乎能涵盖大部分的 Session 和 Cookies 登录验证的实现,具体的实现逻辑因服务器而异,但 Session 和 Cookies 一定是需要相互配合才能实现的。

JWT

Web 开发技术是一直在发展的,近几年前后端分离的趋势越来越火,很多 Web 网站都采取了前后端分离的技术来实现。而且传统的基于 Session 和 Cookies 的校验也存在一定问题,比如服务器需要维护登录用户的 Session 信息,而且不太方便分布式部署,也不太适合前后端分离的项目。

所以,JWT 技术应运而生。JWT,英文全称叫作 JSON Web Token,是为了在网络应用环境间传递声明而执行的一种基于 JSON 的开放标准。实际上就是每次登录的时候通过一个 Token 字符串来校验登录状态。

JWT 的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其他业务逻辑所必须的声明信息,所以这个 Token 也可直接被用于认证,也可传递一些额外信息。

有了 JWT,一些认证就不需要借助于 Session 和 Cookies 了,服务器也无需维护 Session 信息,减少了服务器的开销。服务器只需要有一个校验 JWT 的功能就好了,同时也可以做到分布式部署和跨语言的支持。

JWT 通常就是一个加密的字符串,它也有自己的标准,类似下面的这种格式:

eyJ0eXAxIjoiMTIzNCIsImFsZzIiOiJhZG1pbiIsInR5cCI6IkpXVCIsImFsZyI6IkhTMjU2In0.eyJVc2VySWQiOjEyMywiVXNlck5hbWUiOiJhZG1pbiIsImV4cCI6MTU1MjI4Njc0Ni44Nzc0MDE4fQ.pEgdmFAy73walFonEm2zbxg46Oth3dlT02HR9iVzXa8
			

可以发现中间有两个“.”来分割开,可以把它看成是一个三段式的加密字符串。它由三部分构成,分别是 Header、Payload、Signature。

这三部分通过“.”组合起来就形成了 JWT 的字符串,就是用户的访问凭证。

所以这个登录认证流程也很简单了,用户拿着用户名密码登录,然后服务器生成 JWT 字符串返回给客户端,客户端每次请求都带着这个 JWT 就行了,服务器会自动判断其有效情况,如果有效,那自然就返回对应的数据。但 JWT 的传输就多种多样了,可以放在 Request Headers,也可以放在 URL 里,甚至有的网站也放在 Cookies 里,但总而言之,能传给服务器校验就好了。

好,到此为止呢,我们就已经了解了网站登录验证的实现了。

模拟登录

好,了解了网站登录验证的实现后,模拟登录自然就有思路了。下面我们也是分两种认证方式来说明。

Session 和 Cookies

基于 Session 和 Cookies 的模拟登录,如果我们要用爬虫实现的话,其实最主要的就是把 Cookies 的信息维护好,因为爬虫就相当于客户端浏览器,我们模拟好浏览器做的事情就好了。

那一般情况下,模拟登录一般可以怎样实现呢,我们结合之前所讲的技术来总结一下:

以上介绍的就是一些常用的爬虫模拟登录的方案,其目的就是维护好客户端的 Cookies 信息,然后每次请求都携带好 Cookies 信息就能实现模拟登录了。

JWT

基于 JWT 的真实情况也比较清晰了,由于 JWT 的这个字符串就是用户访问的凭证,那么模拟登录只需要做到下面几步即可:

当然这个模拟登录的过程也肯定带有其他的一些加密参数,需要根据实际情况具体分析。

优化方案

如果爬虫要求爬取的数据量比较大或爬取速度比较快,而网站又有单账号并发限制或者访问状态检测并反爬的话,可能我们的账号就会无法访问或者面临封号的风险了。这时候一般怎么办呢?

我们可以使用分流的方案来解决,比如某个网站一分钟之内检测一个账号只能访问三次或者超过三次就封号的话,我们可以建立一个账号池,用多个账号来随机访问或爬取,这样就能数倍提高爬虫的并发量或者降低被封的风险了。

比如在访问某个网站的时候,我们可以准备 100 个账号,然后 100 个账号都模拟登录,把对应的 Cookies 或 JWT 存下来,每次访问的时候随机取一个来访问,由于账号多,所以每个账号被取用的概率也就降下来了,这样就能避免单账号并发过大的问题,也降低封号风险。

以上,我们就介绍完了模拟登录的基本原理和实现以及优化方案,希望你可以好好理解。

第26讲:模拟登录爬取实战案例

在上一课时我们了解了网站登录验证和模拟登录的基本原理。网站登录验证主要有两种实现,一种是基于 Session + Cookies 的登录验证,另一种是基于 JWT 的登录验证,那么本课时我们就通过两个实例来分别讲解这两种登录验证的分析和模拟登录流程。

准备工作

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

下面我们就以两个案例为例来分别讲解模拟登录的实现。

案例介绍

这里有两个需要登录才能抓取的网站,链接为 https://login2.scrape.cuiqingcai.com/https://login3.scrape.cuiqingcai.com/,前者是基于 Session + Cookies 认证的网站,后者是基于 JWT 认证的网站。

首先看下第一个网站,打开后会看到如图所示的页面。
image.png
它直接跳转到了登录页面,这里用户名和密码都是 admin,我们输入之后登录。

登录成功之后,我们便看到了熟悉的电影网站的展示页面,如图所示。
image (1).png

这个网站是基于传统的 MVC 模式开发的,因此也比较适合 Session + Cookies 的认证。

第二个网站打开后同样会跳到登录页面,如图所示。

image (2).png
用户名和密码是一样的,都输入 admin 即可登录。

登录之后会跳转到首页,展示了一些书籍信息,如图所示。
image (3).png
这个页面是前后端分离式的页面,数据的加载都是通过 Ajax 请求后端 API 接口获取,登录的校验是基于 JWT 的,同时后端每个 API 都会校验 JWT 是否是有效的,如果无效则不会返回数据。

接下来我们就分析这两个案例并实现模拟登录吧。

案例一

对于案例一,我们如果要模拟登录,就需要先分析下登录过程究竟发生了什么,首先我们打开 https://login2.scrape.cuiqingcai.com/,然后执行登录操作,查看其登录过程中发生的请求,如图所示。

image (4).png
这里我们可以看到其登录的瞬间是发起了一个 POST 请求,目标 URL 为 https://login2.scrape.cuiqingcai.com/login,通过表单提交的方式提交了登录数据,包括 username 和 password 两个字段,返回的状态码是 302,Response Headers 的 location 字段是根页面,同时 Response Headers 还包含了 set-cookie 信息,设置了 Session ID。

由此我们可以发现,要实现模拟登录,我们只需要模拟这个请求就好了,登录完成之后获取 Response 设置的 Cookies,将 Cookies 保存好,以后后续的请求带上 Cookies 就可以正常访问了。

好,那么我们接下来用代码实现一下吧。

requests 默认情况下每次请求都是独立互不干扰的,比如我们第一次先调用了 post 方法模拟登录,然后紧接着再调用 get 方法请求下主页面,其实这是两个完全独立的请求,第一次请求获取的 Cookies 并不能传给第二次请求,因此说,常规的顺序调用是不能起到模拟登录的效果的。

我们先来看一个无效的代码:

import requests
			from urllib.parse import urljoin

			BASE_URL = 'https://login2.scrape.cuiqingcai.com/'
			LOGIN_URL = urljoin(BASE_URL, '/login')
			INDEX_URL = urljoin(BASE_URL, '/page/1')
			USERNAME = 'admin'
			PASSWORD = 'admin'

			response_login = requests.post(LOGIN_URL, data={
			   'username': USERNAME,
			   'password': PASSWORD
			})

			response_index = requests.get(INDEX_URL)
			print('Response Status', response_index.status_code)
			print('Response URL', response_index.url)
			

这里我们先定义了几个基本的 URL 和用户名、密码,接下来分别用 requests 请求了登录的 URL 进行模拟登录,然后紧接着请求了首页来获取页面内容,但是能正常获取数据吗?

由于 requests 可以自动处理重定向,我们最后把 Response 的 URL 打印出来,如果它的结果是 INDEX_URL,那么就证明模拟登录成功并成功爬取到了首页的内容。如果它跳回到了登录页面,那就说明模拟登录失败。

我们通过结果来验证一下,运行结果如下:

Response Status 200
			Response URL https://login2.scrape.cuiqingcai.com/login?next=/page/1
			

这里可以看到,其最终的页面 URL 是登录页面的 URL,另外这里也可以通过 response 的 text 属性来验证页面源码,其源码内容就是登录页面的源码内容,由于内容较多,这里就不再输出比对了。

总之,这个现象说明我们并没有成功完成模拟登录,这是因为 requests 直接调用 post、get 等方法,每次请求都是一个独立的请求,都相当于是新开了一个浏览器打开这些链接,这两次请求对应的 Session 并不是同一个,因此这里我们模拟了第一个 Session 登录,而这并不能影响第二个 Session 的状态,因此模拟登录也就无效了。
那么怎样才能实现正确的模拟登录呢?

我们知道 Cookies 里面是保存了 Session ID 信息的,刚才也观察到了登录成功后 Response Headers 里面是有 set-cookie 字段,实际上这就是让浏览器生成了 Cookies。

Cookies 里面包含了 Session ID 的信息,所以只要后续的请求携带这些 Cookies,服务器便能通过 Cookies 里的 Session ID 信息找到对应的 Session,因此服务端对于这两次请求就会使用同一个 Session 了。而因为第一次我们已经完成了模拟登录,所以第一次模拟登录成功后,Session 里面就记录了用户的登录信息,第二次访问的时候,由于是同一个 Session,服务器就能知道用户当前是登录状态,就可以返回正确的结果而不再是跳转到登录页面了。

所以,这里的关键就在于两次请求的 Cookies 的传递。所以这里我们可以把第一次模拟登录后的 Cookies 保存下来,在第二次请求的时候加上这个 Cookies 就好了,所以代码可以改写如下:

import requests
			from urllib.parse import urljoin

			BASE_URL = 'https://login2.scrape.cuiqingcai.com/'
			LOGIN_URL = urljoin(BASE_URL, '/login')
			INDEX_URL = urljoin(BASE_URL, '/page/1')
			USERNAME = 'admin'
			PASSWORD = 'admin'

			response_login = requests.post(LOGIN_URL, data={
			   'username': USERNAME,
			   'password': PASSWORD
			}, allow_redirects=False)

			cookies = response_login.cookies
			print('Cookies', cookies)

			response_index = requests.get(INDEX_URL, cookies=cookies)
			print('Response Status', response_index.status_code)
			print('Response URL', response_index.url)
			

由于 requests 可以自动处理重定向,所以模拟登录的过程我们要加上 allow_redirects 参数并设置为 False,使其不自动处理重定向,这里登录之后返回的 Response 我们赋值为 response_login,这样通过调用 response_login 的 cookies 就可以获取到网站的 Cookies 信息了,这里 requests 自动帮我们解析了 Response Headers 的 set-cookie 字段并设置了 Cookies,所以我们不需要手动解析 Response Headers 的内容了,直接使用 response_login 对象的 cookies 属性即可获取 Cookies。

好,接下来我们再次用 requests 的 get 方法来请求网站的 INDEX_URL,不过这里和之前不同,get 方法多加了一个参数 cookies,这就是第一次模拟登录完之后获取的 Cookies,这样第二次请求就能携带第一次模拟登录获取的 Cookies 信息了,此时网站会根据 Cookies 里面的 Session ID 信息查找到同一个 Session,校验其已经是登录状态,然后返回正确的结果。

这里我们还是输出了最终的 URL,如果其是 INDEX_URL,那就代表模拟登录成功并获取到了有效数据,否则就代表模拟登录失败。

我们看下运行结果:

Cookies <RequestsCookieJar[<Cookie sessionid=psnu8ij69f0ltecd5wasccyzc6ud41tc for login2.scrape.cuiqingcai.com/>]>
			Response Status 200
			Response URL https://login2.scrape.cuiqingcai.com/page/1
			

这下就没有问题了,这次我们发现其 URL 就是 INDEX_URL,模拟登录成功了!同时还可以进一步输出 response_index 的 text 属性看下是否获取成功。

接下来后续的爬取用同样的方式爬取即可。

但是我们发现其实这种实现方式比较烦琐,每次还需要处理 Cookies 并进行一次传递,有没有更简便的方法呢?

有的,我们可以直接借助于 requests 内置的 Session 对象来帮我们自动处理 Cookies,使用了 Session 对象之后,requests 会将每次请求后需要设置的 Cookies 自动保存好,并在下次请求时自动携带上去,就相当于帮我们维持了一个 Session 对象,这样就更方便了。

所以,刚才的代码可以简化如下:

import requests
			from urllib.parse import urljoin

			BASE_URL = 'https://login2.scrape.cuiqingcai.com/'
			LOGIN_URL = urljoin(BASE_URL, '/login')
			INDEX_URL = urljoin(BASE_URL, '/page/1')
			USERNAME = 'admin'
			PASSWORD = 'admin'

			session = requests.Session()

			response_login = session.post(LOGIN_URL, data={
			   'username': USERNAME,
			   'password': PASSWORD
			})

			cookies = session.cookies
			print('Cookies', cookies)

			response_index = session.get(INDEX_URL)
			print('Response Status', response_index.status_code)
			print('Response URL', response_index.url)
			

可以看到,这里我们无需再关心 Cookies 的处理和传递问题,我们声明了一个 Session 对象,然后每次调用请求的时候都直接使用 Session 对象的 post 或 get 方法就好了。

运行效果是完全一样的,结果如下:

Cookies <RequestsCookieJar[<Cookie sessionid=ssngkl4i7en9vm73bb36hxif05k10k13 for login2.scrape.cuiqingcai.com/>]>

			Response Status 200

			Response URL https://login2.scrape.cuiqingcai.com/page/1
			

因此,为了简化写法,这里建议直接使用 Session 对象来进行请求,这样我们就无需关心 Cookies 的操作了,实现起来会更加方便。

这个案例整体来说比较简单,但是如果碰上复杂一点的网站,如带有验证码,带有加密参数等等,直接用 requests 并不好处理模拟登录,如果登录不了,那岂不是整个页面都没法爬了吗?那么有没有其他的方式来解决这个问题呢?当然是有的,比如说,我们可以使用 Selenium 来通过模拟浏览器的方式实现模拟登录,然后获取模拟登录成功后的 Cookies,再把获取的 Cookies 交由 requests 等来爬取就好了。

这里我们还是以刚才的页面为例,我们可以把模拟登录这块交由 Selenium 来实现,后续的爬取交由 requests 来实现,代码实现如下:

from urllib.parse import urljoin
			from selenium import webdriver
			import requests
			import time

			BASE_URL = 'https://login2.scrape.cuiqingcai.com/'
			LOGIN_URL = urljoin(BASE_URL, '/login')
			INDEX_URL = urljoin(BASE_URL, '/page/1')
			USERNAME = 'admin'
			PASSWORD = 'admin'

			browser = webdriver.Chrome()
			browser.get(BASE_URL)
			browser.find_element_by_css_selector('input[name="username"]').send_keys(USERNAME)
			browser.find_element_by_css_selector('input[name="password"]').send_keys(PASSWORD)
			browser.find_element_by_css_selector('input[type="submit"]').click()
			time.sleep(10)

			# get cookies from selenium
			cookies = browser.get_cookies()
			print('Cookies', cookies)
			browser.close()

			# set cookies to requests
			session = requests.Session()
			for cookie in cookies:
			   session.cookies.set(cookie['name'], cookie['value'])

			response_index = session.get(INDEX_URL)
			print('Response Status', response_index.status_code)
			print('Response URL', response_index.url)
			

这里我们使用 Selenium 先打开了 Chrome 浏览器,然后跳转到了登录页面,随后模拟输入了用户名和密码,接着点击了登录按钮,这时候我们可以发现浏览器里面就提示登录成功,然后成功跳转到了主页面。

这时候,我们通过调用 get_cookies 方法便能获取到当前浏览器所有的 Cookies,这就是模拟登录成功之后的 Cookies,用这些 Cookies 我们就能访问其他的数据了。

接下来,我们声明了 requests 的 Session 对象,然后遍历了刚才的 Cookies 并设置到 Session 对象的 cookies 上面去,接着再拿着这个 Session 对象去请求 INDEX_URL,也就能够获取到对应的信息而不会跳转到登录页面了。

运行结果如下:

Cookies [{'domain': 'login2.scrape.cuiqingcai.com', 'expiry': 1589043753.553155, 'httpOnly': True, 'name': 'sessionid', 'path': '/', 'sameSite': 'Lax', 'secure': False, 'value': 'rdag7ttjqhvazavpxjz31y0tmze81zur'}]

			Response Status 200

			Response URL https://login2.scrape.cuiqingcai.com/page/1
			

可以看到这里的模拟登录和后续的爬取也成功了。所以说,如果碰到难以模拟登录的过程,我们也可以使用 Selenium 或 Pyppeteer 等模拟浏览器操作的方式来实现,其目的就是取到登录后的 Cookies,有了 Cookies 之后,我们再用这些 Cookies 爬取其他页面就好了。

所以这里我们也可以发现,对于基于 Session + Cookies 验证的网站,模拟登录的核心要点就是获取 Cookies,这个 Cookies 可以被保存下来或传递给其他的程序继续使用。甚至说可以将 Cookies 持久化存储或传输给其他终端来使用。另外,为了提高 Cookies 利用率或降低封号几率,可以搭建一个 Cookies 池实现 Cookies 的随机取用。

案例二

对于案例二这种基于 JWT 的网站,其通常都是采用前后端分离式的,前后端的数据传输依赖于 Ajax,登录验证依赖于 JWT 本身这个 token 的值,如果 JWT 这个 token 是有效的,那么服务器就能返回想要的数据。

下面我们先来在浏览器里面操作登录,观察下其网络请求过程,如图所示。

image (5).png
这里我们发现登录时其请求的 URL 为 https://login3.scrape.cuiqingcai.com/api/login,是通过 Ajax 请求的,同时其 Request Body 是 JSON 格式的数据,而不是 Form Data,返回状态码为 200。

然后再看下返回结果,如图所示。

image (6).png
可以看到返回结果是一个 JSON 格式的数据,包含一个 token 字段,其结果为:

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNTg3ODc3OTQ2LCJlbWFpbCI6ImFkbWluQGFkbWluLmNvbSIsIm9yaWdfaWF0IjoxNTg3ODM0NzQ2fQ.ujEXXAZcCDyIfRLs44i_jdfA3LIp5Jc74n-Wq2udCR8
			

这就是我们上一课时所讲的 JWT 的内容,格式是三段式的,通过“.”来分隔。

那么有了这个 JWT 之后,后续的数据怎么获取呢?下面我们再来观察下后续的请求内容,如图所示。

image (7).png
这里我们可以发现,后续获取数据的 Ajax 请求中的 Request Headers 里面就多了一个 Authorization 字段,其结果为 jwt 然后加上刚才的 JWT 的内容,返回结果就是 JSON 格式的数据。

image (8).png
没有问题,那模拟登录的整个思路就简单了:
模拟请求登录结果,带上必要的登录信息,获取 JWT 的结果。

后续的请求在 Request Headers 里面加上 Authorization 字段,值就是 JWT 对应的内容。
好,接下来我们用代码实现如下:

import requests
			from urllib.parse import urljoin

			BASE_URL = 'https://login3.scrape.cuiqingcai.com/'
			LOGIN_URL = urljoin(BASE_URL, '/api/login')
			INDEX_URL = urljoin(BASE_URL, '/api/book')
			USERNAME = 'admin'
			PASSWORD = 'admin'

			response_login = requests.post(LOGIN_URL, json={
			   'username': USERNAME,
			   'password': PASSWORD
			})
			data = response_login.json()
			print('Response JSON', data)
			jwt = data.get('token')
			print('JWT', jwt)

			headers = {
			   'Authorization': f'jwt {jwt}'
			}
			response_index = requests.get(INDEX_URL, params={
			   'limit': 18,
			   'offset': 0
			}, headers=headers)
			print('Response Status', response_index.status_code)
			print('Response URL', response_index.url)
			print('Response Data', response_index.json())
			

这里我们同样是定义了登录接口和获取数据的接口,分别为 LOGIN_URL 和 INDEX_URL,接着通过 post 请求进行了模拟登录,这里提交的数据由于是 JSON 格式,所以这里使用 json 参数来传递。接着获取了返回结果中包含的 JWT 的结果。第二步就可以构造 Request Headers,然后设置 Authorization 字段并传入 JWT 即可,这样就能成功获取数据了。

运行结果如下:

Response JSON {'token': 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNTg3ODc4NzkxLCJlbWFpbCI6ImFkbWluQGFkbWluLmNvbSIsIm9yaWdfaWF0IjoxNTg3ODM1NTkxfQ.iUnu3Yhdi_a-Bupb2BLgCTUd5yHL6jgPhkBPorCPvm4'}

			JWT eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNTg3ODc4NzkxLCJlbWFpbCI6ImFkbWluQGFkbWluLmNvbSIsIm9yaWdfaWF0IjoxNTg3ODM1NTkxfQ.iUnu3Yhdi_a-Bupb2BLgCTUd5yHL6jgPhkBPorCPvm4

			Response Status 200
			Response URL https://login3.scrape.cuiqingcai.com/api/book/?limit=18&offset=0
			Response Data {'count': 9200, 'results': [{'id': '27135877', 'name': '校园市场:布局未来消费群,决战年轻人市场', 'authors': ['单兴华', '李烨'], 'cover': 'https://img9.doubanio.com/view/subject/l/public/s29539805.jpg', 'score': '5.5'},
			...
			{'id': '30289316', 'name': '就算這樣,還是喜歡你,笠原先生', 'authors': ['おまる'], 'cover': 'https://img3.doubanio.com/view/subject/l/public/s29875002.jpg', 'score': '7.5'}]}
			

可以看到,这里成功输出了 JWT 的内容,同时最终也获取到了对应的数据,模拟登录成功!

类似的思路,如果我们遇到 JWT 认证的网站,也可以通过类似的方式来实现模拟登录。当然可能某些页面比较复杂,需要具体情况具体分析。

总结

以上我们就通过两个示例来演示了模拟登录爬取的过程,以后遇到这种情形的时候就可以用类似的思路解决了。

代码:https://github.com/Python3WebSpider/ScrapeLogin2https://github.com/Python3WebSpider/ScrapeLogin3

第27讲:令人抓狂的 JavaScript 混淆技术

我们在爬取网站的时候,经常会遇到各种各样类似加密的情形,比如:

这些情况,基本上都是网站为了保护其本身的一些数据不被轻易抓取而采取的一些措施,我们可以把它归为两大类:

本课时我们就来了解下这两类技术的实现原理。

数据保护

当今大数据时代,数据已经变得越来越重要,网页和 App 现在是主流的数据载体,如果其数据的接口没有设置任何保护措施,在爬虫工程师解决了一些基本的反爬如封 IP、验证码的问题之后,那么数据还是可以被轻松抓取到。

那么,有没有可能在接口或 JavaScript 层面也加上一层防护呢?答案是可以的。

接口加密技术

网站运营商首先想到防护措施可能是对某些数据接口进行加密,比如说对某些 URL 的一些参数加上校验码或者把一些 ID 信息进行编码,使其变得难以阅读或构造;或者对某些接口请求加上一些 token、sign 等签名,这样这些请求发送到服务器时,服务器会通过客户端发来的一些请求信息以及双方约定好的秘钥等来对当前的请求进行校验,如果校验通过,才返回对应数据结果。

比如说客户端和服务端约定一种接口校验逻辑,客户端在每次请求服务端接口的时候都会附带一个 sign 参数,这个 sign 参数可能是由当前时间信息、请求的 URL、请求的数据、设备的 ID、双方约定好的秘钥经过一些加密算法构造而成的,客户端会实现这个加密算法构造 sign,然后每次请求服务器的时候附带上这个参数。服务端会根据约定好的算法和请求的数据对 sign 进行校验,如果校验通过,才返回对应的数据,否则拒绝响应。

JavaScript 压缩、混淆和加密技术

接口加密技术看起来的确是一个不错的解决方案,但单纯依靠它并不能很好地解决问题。为什么呢?

对于网页来说,其逻辑是依赖于 JavaScript 来实现的,JavaScript 有如下特点:

由于这两个原因,导致 JavaScript 代码是不安全的,任何人都可以读、分析、复制、盗用,甚至篡改。

所以说,对于上述情形,客户端 JavaScript 对于某些加密的实现是很容易被找到或模拟的,了解了加密逻辑后,模拟参数的构造和请求也就是轻而易举了,所以如果 JavaScript 没有做任何层面的保护的话,接口加密技术基本上对数据起不到什么防护作用。

如果你不想让自己的数据被轻易获取,不想他人了解 JavaScript 逻辑的实现,或者想降低被不怀好意的人甚至是黑客攻击。那么你就需要用到 JavaScript 压缩、混淆和加密技术了。

这里压缩、混淆、加密技术简述如下。

下面我们对上面的技术分别予以介绍。

接口加密技术

数据一般都是通过服务器提供的接口来获取的,网站或 App 可以请求某个数据接口获取到对应的数据,然后再把获取的数据展示出来。

但有些数据是比较宝贵或私密的,这些数据肯定是需要一定层面上的保护。所以不同接口的实现也就对应着不同的安全防护级别,我们这里来总结下。

完全开放的接口

有些接口是没有设置任何防护的,谁都可以调用和访问,而且没有任何时空限制和频率限制。任何人只要知道了接口的调用方式就能无限制地调用。

这种接口的安全性是非常非常低的,如果接口的调用方式一旦泄露或被抓包获取到,任何人都可以无限制地对数据进行操作或访问。此时如果接口里面包含一些重要的数据或隐私数据,就能轻易被篡改或窃取了。

接口参数加密

为了提升接口的安全性,客户端会和服务端约定一种接口校验方式,一般来说会使用到各种加密和编码算法,如 Base64、Hex 编码,MD5、AES、DES、RSA 等加密。

比如客户端和服务器双方约定一个 sign 用作接口的签名校验,其生成逻辑是客户端将 URL Path 进行 MD5 加密然后拼接上 URL 的某个参数再进行 Base64 编码,最后得到一个字符串 sign,这个 sign 会通过 Request URL 的某个参数或 Request Headers 发送给服务器。服务器接收到请求后,对 URL Path 同样进行 MD5 加密,然后拼接上 URL 的某个参数,也进行 Base64 编码得到了一个 sign,然后比对生成的 sign 和客户端发来的 sign 是否是一致的,如果是一致的,那就返回正确的结果,否则拒绝响应。这就是一个比较简单的接口参数加密的实现。如果有人想要调用这个接口的话,必须要定义好 sign 的生成逻辑,否则是无法正常调用接口的。

以上就是一个基本的接口参数加密逻辑的实现。

当然上面的这个实现思路比较简单,这里还可以增加一些时间戳信息增加时效性判断,或增加一些非对称加密进一步提高加密的复杂程度。但不管怎样,只要客户端和服务器约定好了加密和校验逻辑,任何形式加密算法都是可以的。

这里要实现接口参数加密就需要用到一些加密算法,客户端和服务器肯定也都有对应的 SDK 实现这些加密算法,如 JavaScript 的 crypto-js,Python 的 hashlib、Crypto 等等。

但还是如上文所说,如果是网页的话,客户端实现加密逻辑如果是用 JavaScript 来实现,其源代码对用户是完全可见的,如果没有对 JavaScript 做任何保护的话,是很容易弄清楚客户端加密的流程的。

因此,我们需要对 JavaScript 利用压缩、混淆、加密的方式来对客户端的逻辑进行一定程度上的保护。

JavaScript 压缩、混淆、加密

下面我们再来介绍下 JavaScript 的压缩、混淆和加密技术。

JavaScript 压缩

这个非常简单,JavaScript 压缩即去除 JavaScript 代码中的不必要的空格、换行等内容或者把一些可能公用的代码进行处理实现共享,最后输出的结果都被压缩为几行内容,代码可读性变得很差,同时也能提高网站加载速度。

如果仅仅是去除空格换行这样的压缩方式,其实几乎是没有任何防护作用的,因为这种压缩方式仅仅是降低了代码的直接可读性。如果我们有一些格式化工具可以轻松将 JavaScript 代码变得易读,比如利用 IDE、在线工具或 Chrome 浏览器都能还原格式化的代码。

目前主流的前端开发技术大多都会利用 Webpack 进行打包,Webpack 会对源代码进行编译和压缩,输出几个打包好的 JavaScript 文件,其中我们可以看到输出的 JavaScript 文件名带有一些不规则字符串,同时文件内容可能只有几行内容,变量名都是一些简单字母表示。这其中就包含 JavaScript 压缩技术,比如一些公共的库输出成 bundle 文件,一些调用逻辑压缩和转义成几行代码,这些都属于 JavaScript 压缩。另外其中也包含了一些很基础的 JavaScript 混淆技术,比如把变量名、方法名替换成一些简单字符,降低代码可读性。

但整体来说,JavaScript 压缩技术只能在很小的程度上起到防护作用,要想真正提高防护效果还得依靠 JavaScript 混淆和加密技术。

JavaScript 混淆

JavaScript 混淆完全是在 JavaScript 上面进行的处理,它的目的就是使得 JavaScript 变得难以阅读和分析,大大降低代码可读性,是一种很实用的 JavaScript 保护方案。

JavaScript 混淆技术主要有以下几种:

将带有含意的变量名、方法名、常量名随机变为无意义的类乱码字符串,降低代码可读性,如转成单个字符或十六进制字符串。

将字符串阵列化集中放置、并可进行 MD5 或 Base64 加密存储,使代码中不出现明文字符串,这样可以避免使用全局搜索字符串的方式定位到入口点。

针对 JavaScript 对象的属性进行加密转化,隐藏代码之间的调用关系。

打乱函数原有代码执行流程及函数调用关系,使代码逻变得混乱无序。

随机在代码中插入无用的僵尸代码、僵尸函数,进一步使代码混乱。

基于调试器特性,对当前运行环境进行检验,加入一些强制调试 debugger 语句,使其在调试模式下难以顺利执行 JavaScript 代码。

使 JavaScript 代码每次被调用时,将代码自身即立刻自动发生变异,变化为与之前完全不同的代码,即功能完全不变,只是代码形式变异,以此杜绝代码被动态分析调试。

使 JavaScript 代码只能在指定域名下执行。

如果对 JavaScript 代码进行格式化,则无法执行,导致浏览器假死。

将 JavaScript 完全编码为人不可读的代码,如表情符号、特殊表示内容等等。

总之,以上方案都是 JavaScript 混淆的实现方式,可以在不同程度上保护 JavaScript 代码。

在前端开发中,现在 JavaScript 混淆主流的实现是 javascript-obfuscator 这个库,利用它我们可以非常方便地实现页面的混淆,它与 Webpack 结合起来,最终可以输出压缩和混淆后的 JavaScript 代码,使得可读性大大降低,难以逆向。

下面我们会介绍下 javascript-obfuscator 对代码混淆的实现,了解了实现,那么自然我们就对混淆的机理有了更加深刻的认识。

javascript-obfuscator 的官网地址为:https://obfuscator.io/,其官方介绍内容如下:

A free and efficient obfuscator for JavaScript (including ES2017). Make your code harder to copy and prevent people from stealing your work.

它是支持 ES8 的免费、高效的 JavaScript 混淆库,它可以使得你的 JavaScript 代码经过混淆后难以被复制、盗用,混淆后的代码具有和原来的代码一模一样的功能。

怎么使用呢?首先,我们需要安装好 Node.js,可以使用 npm 命令。

然后新建一个文件夹,比如 js-obfuscate,随后进入该文件夹,初始化工作空间:

npm init
			

这里会提示我们输入一些信息,创建一个 package.json 文件,这就完成了项目初始化了。

接下来我们来安装 javascript-obfuscator 这个库:

npm install --save-dev javascript-obfuscator
			

接下来我们就可以编写代码来实现混淆了,如新建一个 main.js 文件,内容如下:

const code = `
			let x = '1' + 1
			console.log('x', x)
			`

			const options = {
			   compact: false,
			   controlFlowFlattening: true

			}

			const obfuscator = require('javascript-obfuscator')
			function obfuscate(code, options) {
			   return obfuscator.obfuscate(code, options).getObfuscatedCode()
			}
			console.log(obfuscate(code, options))
			

在这里我们定义了两个变量,一个是 code,即需要被混淆的代码,另一个是混淆选项,是一个 Object。接下来我们引入了 javascript-obfuscator 库,然后定义了一个方法,传入 code 和 options,来获取混淆后的代码,最后控制台输出混淆后的代码。

代码逻辑比较简单,我们来执行一下代码:

node main.js
			

输出结果如下:

var _0x53bf = ['log'];
			(function (_0x1d84fe, _0x3aeda0) {
			   var _0x10a5a = function (_0x2f0a52) {
			       while (--_0x2f0a52) {
			           _0x1d84fe['push'](_0x1d84fe['shift']());
			      }
			  };
			   _0x10a5a(++_0x3aeda0);
			}(_0x53bf, 0x172));
			var _0x480a = function (_0x4341e5, _0x5923b4) {
			   _0x4341e5 = _0x4341e5 - 0x0;
			   var _0xb3622e = _0x53bf[_0x4341e5];
			   return _0xb3622e;
			};
			let x = '1' + 0x1;
			console[_0x480a('0x0')]('x', x);
			

看到了吧,这么简单的两行代码,被我们混淆成了这个样子,其实这里我们就是设定了一个“控制流扁平化”的选项。

整体看来,代码的可读性大大降低,也大大加大了 JavaScript 调试的难度。

好,接下来我们来跟着 javascript-obfuscator 走一遍,就能具体知道 JavaScript 混淆到底有多少方法了。

代码压缩

这里 javascript-obfuscator 也提供了代码压缩的功能,使用其参数 compact 即可完成 JavaScript 代码的压缩,输出为一行内容。默认是 true,如果定义为 false,则混淆后的代码会分行显示。

示例如下:

const code = `
			let x = '1' + 1
			console.log('x', x)
			`
			const options = {
			   compact: false
			}
			

这里我们先把代码压缩 compact 选项设置为 false,运行结果如下:

let x = '1' + 0x1;
			console['log']('x', x);
			

如果不设置 compact 或把 compact 设置为 true,结果如下:

var _0x151c=['log'];(function(_0x1ce384,_0x20a7c7){var _0x25fc92=function(_0x188aec){while(--_0x188aec){_0x1ce384['push'](_0x1ce384['shift']());}};_0x25fc92(++_0x20a7c7);}(_0x151c,0x1b7));var _0x553e=function(_0x259219,_0x241445){_0x259219=_0x259219-0x0;var _0x56d72d=_0x151c[_0x259219];return _0x56d72d;};let x='1'+0x1;console[_0x553e('0x0')]('x',x);
			

可以看到单行显示的时候,对变量名进行了进一步的混淆和控制流扁平化操作。

变量名混淆

变量名混淆可以通过配置 identifierNamesGenerator 参数实现,我们通过这个参数可以控制变量名混淆的方式,如 hexadecimal 则会替换为 16 进制形式的字符串,在这里我们可以设定如下值:

该参数默认为 hexadecimal。

我们将该参数修改为 mangled 来试一下:

const code = `
			let hello = '1' + 1
			console.log('hello', hello)
			`
			const options = {
			  compact: true,
			  identifierNamesGenerator: 'mangled'
			}
			

运行结果如下:

var a=['hello'];(function(c,d){var e=function(f){while(--f){c['push'](c['shift']());}};e(++d);}(a,0x9b));var b=function(c,d){c=c-0x0;var e=a[c];return e;};let hello='1'+0x1;console['log'](b('0x0'),hello);
			

可以看到这里的变量命名都变成了 a、b 等形式。

如果我们将 identifierNamesGenerator 修改为 hexadecimal 或者不设置,运行结果如下:

var _0x4e98=['log','hello'];(function(_0x4464de,_0x39de6c){var _0xdffdda=function(_0x6a95d5){while(--_0x6a95d5){_0x4464de['push'](_0x4464de['shift']());}};_0xdffdda(++_0x39de6c);}(_0x4e98,0xc8));var _0x53cb=function(_0x393bda,_0x8504e7){_0x393bda=_0x393bda-0x0;var _0x46ab80=_0x4e98[_0x393bda];return _0x46ab80;};let hello='1'+0x1;console[_0x53cb('0x0')](_0x53cb('0x1'),hello);
			

可以看到选用了 mangled,其代码体积会更小,但 hexadecimal 其可读性会更低。

另外我们还可以通过设置 identifiersPrefix 参数来控制混淆后的变量前缀,示例如下:

const code = `
			let hello = '1' + 1
			console.log('hello', hello)
			`
			const options = {
			  identifiersPrefix: 'germey'
			}
			

运行结果:

var germey_0x3dea=['log','hello'];(function(_0x348ff3,_0x5330e8){var _0x1568b1=function(_0x4740d8){while(--_0x4740d8){_0x348ff3['push'](_0x348ff3['shift']());}};_0x1568b1(++_0x5330e8);}(germey_0x3dea,0x94));var germey_0x30e4=function(_0x2e8f7c,_0x1066a8){_0x2e8f7c=_0x2e8f7c-0x0;var _0x5166ba=germey_0x3dea[_0x2e8f7c];return _0x5166ba;};let hello='1'+0x1;console[germey_0x30e4('0x0')](germey_0x30e4('0x1'),hello);
			

可以看到混淆后的变量前缀加上了我们自定义的字符串 germey。

另外 renameGlobals 这个参数还可以指定是否混淆全局变量和函数名称,默认为 false。示例如下:

const code = `
			var $ = function(id) {
			  return document.getElementById(id);
			};
			`
			const options = {
			  renameGlobals: true
			}
			

运行结果如下:

var _0x4864b0=function(_0x5763be){return document['getElementById'](_0x5763be);};
			

可以看到这里我们声明了一个全局变量 $,在 renameGlobals 设置为 true 之后,$ 这个变量也被替换了。如果后文用到了这个 $ 对象,可能就会有找不到定义的错误,因此这个参数可能导致代码执行不通。

如果我们不设置 renameGlobals 或者设置为 false,结果如下:

var _0x239a=['getElementById'];(function(_0x3f45a3,_0x583dfa){var _0x2cade2=function(_0x28479a){while(--_0x28479a){_0x3f45a3['push'](_0x3f45a3['shift']());}};_0x2cade2(++_0x583dfa);}(_0x239a,0xe1));var _0x3758=function(_0x18659d,_0x50c21d){_0x18659d=_0x18659d-0x0;var _0x531b8d=_0x239a[_0x18659d];return _0x531b8d;};var $=function(_0x3d8723){return document[_0x3758('0x0')](_0x3d8723);};
			

可以看到,最后还是有 $ 的声明,其全局名称没有被改变。

字符串混淆

字符串混淆,即将一个字符串声明放到一个数组里面,使之无法被直接搜索到。我们可以通过控制 stringArray 参数来控制,默认为 true。

我们还可以通过 rotateStringArray 参数来控制数组化后结果的元素顺序,默认为 true。
还可以通过 stringArrayEncoding 参数来控制数组的编码形式,默认不开启编码,如果设置为 true 或 base64,则会使用 Base64 编码,如果设置为 rc4,则使用 RC4 编码。
还可以通过 stringArrayThreshold 来控制启用编码的概率,范围 0 到 1,默认 0.8。

示例如下:

const code = `
			var a = 'hello world'
			`
			const options = {
			  stringArray: true,
			  rotateStringArray: true,
			  stringArrayEncoding: true, // 'base64' or 'rc4' or false
			  stringArrayThreshold: 1,
			}
			

运行结果如下:

var _0x4215=['aGVsbG8gd29ybGQ='];(function(_0x42bf17,_0x4c348f){var _0x328832=function(_0x355be1){while(--_0x355be1){_0x42bf17['push'](_0x42bf17['shift']());}};_0x328832(++_0x4c348f);}(_0x4215,0x1da));var _0x5191=function(_0x3cf2ba,_0x1917d8){_0x3cf2ba=_0x3cf2ba-0x0;var _0x1f93f0=_0x4215[_0x3cf2ba];if(_0x5191['LqbVDH']===undefined){(function(){var _0x5096b2;try{var _0x282db1=Function('return\x20(function()\x20'+'{}.constructor(\x22return\x20this\x22)(\x20)'+');');_0x5096b2=_0x282db1();}catch(_0x2acb9c){_0x5096b2=window;}var _0x388c14='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';_0x5096b2['atob']||(_0x5096b2['atob']=function(_0x4cc27c){var _0x2af4ae=String(_0x4cc27c)['replace'](/=+$/,'');for(var _0x21400b=0x0,_0x3f4e2e,_0x5b193b,_0x233381=0x0,_0x3dccf7='';_0x5b193b=_0x2af4ae['charAt'](_0x233381++);~_0x5b193b&&(_0x3f4e2e=_0x21400b%0x4?_0x3f4e2e*0x40+_0x5b193b:_0x5b193b,_0x21400b++%0x4)?_0x3dccf7+=String['fromCharCode'](0xff&_0x3f4e2e>>(-0x2*_0x21400b&0x6)):0x0){_0x5b193b=_0x388c14['indexOf'](_0x5b193b);}return _0x3dccf7;});}());_0x5191['DuIurT']=function(_0x51888e){var _0x29801f=atob(_0x51888e);var _0x561e62=[];for(var _0x5dd788=0x0,_0x1a8b73=_0x29801f['length'];_0x5dd788<_0x1a8b73;_0x5dd788++){_0x561e62+='%'+('00'+_0x29801f['charCodeAt'](_0x5dd788)['toString'](0x10))['slice'](-0x2);}return decodeURIComponent(_0x561e62);};_0x5191['mgoBRd']={};_0x5191['LqbVDH']=!![];}var _0x1741f0=_0x5191['mgoBRd'][_0x3cf2ba];if(_0x1741f0===undefined){_0x1f93f0=_0x5191['DuIurT'](_0x1f93f0);_0x5191['mgoBRd'][_0x3cf2ba]=_0x1f93f0;}else{_0x1f93f0=_0x1741f0;}return _0x1f93f0;};var a=_0x5191('0x0');
			

可以看到这里就把字符串进行了 Base64 编码,我们再也无法通过查找的方式找到字符串的位置了。

如果将 stringArray 设置为 false 的话,输出就是这样:

var a='hello\x20world';
			

字符串就仍然是明文显示的,没有被编码。

另外我们还可以使用 unicodeEscapeSequence 这个参数对字符串进行 Unicode 转码,使之更加难以辨认,示例如下:

const code = `
			var a = 'hello world'
			`
			const options = {
			  compact: false,
			  unicodeEscapeSequence: true
			}
			

运行结果如下:

var _0x5c0d = ['\x68\x65\x6c\x6c\x6f\x20\x77\x6f\x72\x6c\x64'];
			(function (_0x54cc9c, _0x57a3b2) {
			  var _0xf833cf = function (_0x3cd8c6) {
			    while (--_0x3cd8c6) {
			      _0x54cc9c['push'](_0x54cc9c['shift']());
			    }
			};
			_0xf833cf(++_0x57a3b2);
			}(_0x5c0d, 0x17d));
			var _0x28e8 = function (_0x3fd645, _0x2cf5e7) {
			  _0x3fd645 = _0x3fd645 - 0x0;
			  var _0x298a20 = _0x5c0d[_0x3fd645];
			  return _0x298a20;
			};
			var a = _0x28e8('0x0');
			

可以看到,这里字符串被数字化和 Unicode 化,非常难以辨认。

在很多 JavaScript 逆向的过程中,一些关键的字符串可能会作为切入点来查找加密入口。用了这种混淆之后,如果有人想通过全局搜索的方式搜索 hello 这样的字符串找加密入口,也没法搜到了。

代码自我保护

我们可以通过设置 selfDefending 参数来开启代码自我保护功能。开启之后,混淆后的 JavaScript 会强制以一行形式显示,如果我们将混淆后的代码进行格式化(美化)或者重命名,该段代码将无法执行。

例如:

const code = `
			console.log('hello world')
			`
			const options = {
			  selfDefending: true
			}
			

运行结果如下:

var _0x26da=['log','hello\x20world'];(function(_0x190327,_0x57c2c0){var _0x577762=function(_0xc9dabb){while(--_0xc9dabb){_0x190327['push'](_0x190327['shift']());}};var _0x35976e=function(){var _0x16b3fe={'data':{'key':'cookie','value':'timeout'},'setCookie':function(_0x2d52d5,_0x16feda,_0x57cadf,_0x56056f){_0x56056f=_0x56056f||{};var _0x5b6dc3=_0x16feda+'='+_0x57cadf;var _0x333ced=0x0;for(var _0x333ced=0x0,_0x19ae36=_0x2d52d5['length'];_0x333ced<_0x19ae36;_0x333ced++){var _0x409587=_0x2d52d5[_0x333ced];_0x5b6dc3+=';\x20'+_0x409587;var _0x4aa006=_0x2d52d5[_0x409587];_0x2d52d5['push'](_0x4aa006);_0x19ae36=_0x2d52d5['length'];if(_0x4aa006!==!![]){_0x5b6dc3+='='+_0x4aa006;}}_0x56056f['cookie']=_0x5b6dc3;},'removeCookie':function(){return'dev';},'getCookie':function(_0x30c497,_0x51923d){_0x30c497=_0x30c497||function(_0x4b7e18){return _0x4b7e18;};var _0x557e06=_0x30c497(new RegExp('(?:^|;\x20)'+_0x51923d['replace'](/([.$?*|{}()[]\/+^])/g,'$1')+'=([^;]*)'));var _0x817646=function(_0xf3fae7,_0x5d8208){_0xf3fae7(++_0x5d8208);};_0x817646(_0x577762,_0x57c2c0);return _0x557e06?decodeURIComponent(_0x557e06[0x1]):undefined;}};var _0x4673cd=function(){var _0x4c6c5c=new RegExp('\x5cw+\x20*\x5c(\x5c)\x20*{\x5cw+\x20*[\x27|\x22].+[\x27|\x22];?\x20*}');return _0x4c6c5c['test'](_0x16b3fe['removeCookie']['toString']());};_0x16b3fe['updateCookie']=_0x4673cd;var _0x5baa80='';var _0x1faf19=_0x16b3fe['updateCookie']();if(!_0x1faf19){_0x16b3fe['setCookie'](['*'],'counter',0x1);}else if(_0x1faf19){_0x5baa80=_0x16b3fe['getCookie'](null,'counter');}else{_0x16b3fe['removeCookie']();}};_0x35976e();}(_0x26da,0x140));var _0x4391=function(_0x1b42d8,_0x57edc8){_0x1b42d8=_0x1b42d8-0x0;var _0x2fbeca=_0x26da[_0x1b42d8];return _0x2fbeca;};var _0x197926=function(){var _0x10598f=!![];return function(_0xffa3b3,_0x7a40f9){var _0x48e571=_0x10598f?function(){if(_0x7a40f9){var _0x2194b5=_0x7a40f9['apply'](_0xffa3b3,arguments);_0x7a40f9=null;return _0x2194b5;}}:function(){};_0x10598f=![];return _0x48e571;};}();var _0x2c6fd7=_0x197926(this,function(){var _0x4828bb=function(){return'\x64\x65\x76';},_0x35c3bc=function(){return'\x77\x69\x6e\x64\x6f\x77';};var _0x456070=function(){var _0x4576a4=new RegExp('\x5c\x77\x2b\x20\x2a\x5c\x28\x5c\x29\x20\x2a\x7b\x5c\x77\x2b\x20\x2a\x5b\x27\x7c\x22\x5d\x2e\x2b\x5b\x27\x7c\x22\x5d\x3b\x3f\x20\x2a\x7d');return!_0x4576a4['\x74\x65\x73\x74'](_0x4828bb['\x74\x6f\x53\x74\x72\x69\x6e\x67']());};var _0x3fde69=function(){var _0xabb6f4=new RegExp('\x28\x5c\x5c\x5b\x78\x7c\x75\x5d\x28\x5c\x77\x29\x7b\x32\x2c\x34\x7d\x29\x2b');return _0xabb6f4['\x74\x65\x73\x74'](_0x35c3bc['\x74\x6f\x53\x74\x72\x69\x6e\x67']());};var _0x2d9a50=function(_0x58fdb4){var _0x2a6361=~-0x1>>0x1+0xff%0x0;if(_0x58fdb4['\x69\x6e\x64\x65\x78\x4f\x66']('\x69'===_0x2a6361)){_0xc388c5(_0x58fdb4);}};var _0xc388c5=function(_0x2073d6){var _0x6bb49f=~-0x4>>0x1+0xff%0x0;if(_0x2073d6['\x69\x6e\x64\x65\x78\x4f\x66']((!![]+'')[0x3])!==_0x6bb49f){_0x2d9a50(_0x2073d6);}};if(!_0x456070()){if(!_0x3fde69()){_0x2d9a50('\x69\x6e\x64\u0435\x78\x4f\x66');}else{_0x2d9a50('\x69\x6e\x64\x65\x78\x4f\x66');}}else{_0x2d9a50('\x69\x6e\x64\u0435\x78\x4f\x66');}});_0x2c6fd7();console[_0x4391('0x0')](_0x4391('0x1'));
			

如果我们将上述代码放到控制台,它的执行结果和之前是一模一样的,没有任何问题。
如果我们将其进行格式化,会变成如下内容:

var _0x26da = ['log', 'hello\x20world'];
			(function (_0x190327, _0x57c2c0) {
			    var _0x577762 = function (_0xc9dabb) {
			        while (--_0xc9dabb) {
			            _0x190327['push'](_0x190327['shift']());
			        }
			    };
			    var _0x35976e = function () {
			        var _0x16b3fe = {
			            'data': {
			                'key': 'cookie',
			                'value': 'timeout'
			            },
			            'setCookie': function (_0x2d52d5, _0x16feda, _0x57cadf, _0x56056f) {
			                _0x56056f = _0x56056f || {};
			                var _0x5b6dc3 = _0x16feda + '=' + _0x57cadf;
			                var _0x333ced = 0x0;
			                for (var _0x333ced = 0x0, _0x19ae36 = _0x2d52d5['length']; _0x333ced < _0x19ae36; _0x333ced++) {
			                    var _0x409587 = _0x2d52d5[_0x333ced];
			                    _0x5b6dc3 += ';\x20' + _0x409587;
			                    var _0x4aa006 = _0x2d52d5[_0x409587];
			                    _0x2d52d5['push'](_0x4aa006);
			                    _0x19ae36 = _0x2d52d5['length'];
			                    if (_0x4aa006 !== !![]) {
			                        _0x5b6dc3 += '=' + _0x4aa006;
			                    }
			                }
			                _0x56056f['cookie'] = _0x5b6dc3;
			            }, 'removeCookie': function () {
			                return 'dev';
			            }, 'getCookie': function (_0x30c497, _0x51923d) {
			                _0x30c497 = _0x30c497 || function (_0x4b7e18) {
			                    return _0x4b7e18;
			                };
			                var _0x557e06 = _0x30c497(new RegExp('(?:^|;\x20)' + _0x51923d['replace'](/([.$?*|{}()[]\/+^])/g, '$1') + '=([^;]*)'));
			                var _0x817646 = function (_0xf3fae7, _0x5d8208) {
			                    _0xf3fae7(++_0x5d8208);
			                };
			                _0x817646(_0x577762, _0x57c2c0);
			                return _0x557e06 ? decodeURIComponent(_0x557e06[0x1]) : undefined;
			            }
			        };
			        var _0x4673cd = function () {
			            var _0x4c6c5c = new RegExp('\x5cw+\x20*\x5c(\x5c)\x20*{\x5cw+\x20*[\x27|\x22].+[\x27|\x22];?\x20*}');
			            return _0x4c6c5c['test'](_0x16b3fe['removeCookie']['toString']());
			        };
			        _0x16b3fe['updateCookie'] = _0x4673cd;
			        var _0x5baa80 = '';
			        var _0x1faf19 = _0x16b3fe['updateCookie']();
			        if (!_0x1faf19) {
			            _0x16b3fe['setCookie'](['*'], 'counter', 0x1);
			        } else if (_0x1faf19) {
			            _0x5baa80 = _0x16b3fe['getCookie'](null, 'counter');
			        } else {
			            _0x16b3fe['removeCookie']();
			        }
			    };
			    _0x35976e();
			}(_0x26da, 0x140));
			var _0x4391 = function (_0x1b42d8, _0x57edc8) {
			    _0x1b42d8 = _0x1b42d8 - 0x0;
			    var _0x2fbeca = _0x26da[_0x1b42d8];
			    return _0x2fbeca;
			};
			var _0x197926 = function () {
			    var _0x10598f = !![];
			    return function (_0xffa3b3, _0x7a40f9) {
			        var _0x48e571 = _0x10598f ? function () {
			            if (_0x7a40f9) {
			                var _0x2194b5 = _0x7a40f9['apply'](_0xffa3b3, arguments);
			                _0x7a40f9 = null;
			                return _0x2194b5;
			            }
			        } : function () {};
			        _0x10598f = ![];
			        return _0x48e571;
			    };
			}();
			var _0x2c6fd7 = _0x197926(this, function () {
			    var _0x4828bb = function () {
			            return '\x64\x65\x76';
			        },
			        _0x35c3bc = function () {
			            return '\x77\x69\x6e\x64\x6f\x77';
			        };
			    var _0x456070 = function () {
			        var _0x4576a4 = new RegExp('\x5c\x77\x2b\x20\x2a\x5c\x28\x5c\x29\x20\x2a\x7b\x5c\x77\x2b\x20\x2a\x5b\x27\x7c\x22\x5d\x2e\x2b\x5b\x27\x7c\x22\x5d\x3b\x3f\x20\x2a\x7d');
			        return !_0x4576a4['\x74\x65\x73\x74'](_0x4828bb['\x74\x6f\x53\x74\x72\x69\x6e\x67']());
			    };
			    var _0x3fde69 = function () {
			        var _0xabb6f4 = new RegExp('\x28\x5c\x5c\x5b\x78\x7c\x75\x5d\x28\x5c\x77\x29\x7b\x32\x2c\x34\x7d\x29\x2b');
			        return _0xabb6f4['\x74\x65\x73\x74'](_0x35c3bc['\x74\x6f\x53\x74\x72\x69\x6e\x67']());
			    };
			    var _0x2d9a50 = function (_0x58fdb4) {
			        var _0x2a6361 = ~-0x1 >> 0x1 + 0xff % 0x0;
			        if (_0x58fdb4['\x69\x6e\x64\x65\x78\x4f\x66']('\x69' === _0x2a6361)) {
			            _0xc388c5(_0x58fdb4);
			        }
			    };
			    var _0xc388c5 = function (_0x2073d6) {
			        var _0x6bb49f = ~-0x4 >> 0x1 + 0xff % 0x0;
			        if (_0x2073d6['\x69\x6e\x64\x65\x78\x4f\x66']((!![] + '')[0x3]) !== _0x6bb49f) {
			            _0x2d9a50(_0x2073d6);
			        }
			    };
			    if (!_0x456070()) {
			        if (!_0x3fde69()) {
			            _0x2d9a50('\x69\x6e\x64\u0435\x78\x4f\x66');
			        } else {
			            _0x2d9a50('\x69\x6e\x64\x65\x78\x4f\x66');
			        }
			    } else {
			        _0x2d9a50('\x69\x6e\x64\u0435\x78\x4f\x66');
			    }
			});
			_0x2c6fd7();
			console[_0x4391('0x0')](_0x4391('0x1'));
			

如果把这段代码放到浏览器里面,浏览器会直接卡死无法运行。这样如果有人对代码进行了格式化,就无法正常对代码进行运行和调试,从而起到了保护作用。

控制流平坦化

控制流平坦化其实就是将代码的执行逻辑混淆,使其变得复杂难读。其基本思想是将一些逻辑处理块都统一加上一个前驱逻辑块,每个逻辑块都由前驱逻辑块进行条件判断和分发,构成一个个闭环逻辑,导致整个执行逻辑十分复杂难读。

我们通过 controlFlowFlattening 变量可以控制是否开启控制流平坦化,示例如下:

const code = `
			(function(){
			    function foo () {
			        return function () {
			            var sum = 1 + 2;
			            console.log(1);
			            console.log(2);
			            console.log(3);
			            console.log(4);
			            console.log(5);
			            console.log(6);
			        }
			    }

			    foo()();
			})();
			`
			const options = {
			  compact: false,
			  controlFlowFlattening: true
			}
			

输出结果如下:

var _0xbaf1 = [
			    'dZwUe',
			    'log',
			    'fXqMu',
			    '0|1|3|4|6|5|2',
			    'chYMl',
			    'IZEsA',
			    'split'
			];
			(function (_0x22d342, _0x4f6332) {
			    var _0x43ff59 = function (_0x5ad417) {
			        while (--_0x5ad417) {
			            _0x22d342['push'](_0x22d342['shift']());
			        }
			    };
			    _0x43ff59(++_0x4f6332);
			}(_0xbaf1, 0x192));
			var _0x1a69 = function (_0x8d64b1, _0x5e07b3) {
			    _0x8d64b1 = _0x8d64b1 - 0x0;
			    var _0x300bab = _0xbaf1[_0x8d64b1];
			    return _0x300bab;
			};
			(function () {
			    var _0x19d8ce = {
			        'chYMl': _0x1a69('0x0'),
			        'IZEsA': function (_0x22e521, _0x298a22) {
			            return _0x22e521 + _0x298a22;
			        },
			        'fXqMu': function (_0x13124b) {
			            return _0x13124b();
			        }
			    };
			    function _0x4e2ee0() {
			        var _0x118a6a = {
			            'LZAQV': _0x19d8ce[_0x1a69('0x1')],
			            'dZwUe': function (_0x362ef3, _0x352709) {
			                return _0x19d8ce[_0x1a69('0x2')](_0x362ef3, _0x352709);
			            }
			        };
			        return function () {
			            var _0x4c336d = _0x118a6a['LZAQV'][_0x1a69('0x3')]('|'), _0x2b6466 = 0x0;
			            while (!![]) {
			                switch (_0x4c336d[_0x2b6466++]) {
			                case '0':
			                    var _0xbfa3fd = _0x118a6a[_0x1a69('0x4')](0x1, 0x2);
			                    continue;
			                case '1':
			                    console['log'](0x1);
			                    continue;
			                case '2':
			                    console[_0x1a69('0x5')](0x6);
			                    continue;
			                case '3':
			                    console[_0x1a69('0x5')](0x2);
			                    continue;
			                case '4':
			                    console[_0x1a69('0x5')](0x3);
			                    continue;
			                case '5':
			                    console[_0x1a69('0x5')](0x5);
			                    continue;
			                case '6':
			                    console[_0x1a69('0x5')](0x4);
			                    continue;
			                }
			                break;
			            }
			        };
			    }
			    _0x19d8ce[_0x1a69('0x6')](_0x4e2ee0)();
			}());
			

可以看到,一些连续的执行逻辑被打破,代码被修改为一个 switch 语句,我们很难再一眼看出多条 console.log 语句的执行顺序了。

如果我们将 controlFlowFlattening 设置为 false 或者不设置,运行结果如下:

var _0x552c = ['log'];
			(function (_0x4c4fa0, _0x59faa0) {
			    var _0xa01786 = function (_0x409a37) {
			        while (--_0x409a37) {
			            _0x4c4fa0['push'](_0x4c4fa0['shift']());
			        }
			    };
			    _0xa01786(++_0x59faa0);
			}(_0x552c, 0x9b));
			var _0x4e63 = function (_0x75ea1a, _0x50e176) {
			    _0x75ea1a = _0x75ea1a - 0x0;
			    var _0x59dc94 = _0x552c[_0x75ea1a];
			    return _0x59dc94;
			};
			(function () {
			    function _0x507f38() {
			        return function () {
			            var _0x17fb7e = 0x1 + 0x2;
			            console[_0x4e63('0x0')](0x1);
			            console['log'](0x2);
			            console['log'](0x3);
			            console[_0x4e63('0x0')](0x4);
			            console[_0x4e63('0x0')](0x5);
			            console[_0x4e63('0x0')](0x6);
			        };
			    }
			    _0x507f38()();
			}());
			

可以看到,这里仍然保留了原始的 console.log 执行逻辑。

因此,使用控制流扁平化可以使得执行逻辑更加复杂难读,目前非常多的前端混淆都会加上这个选项。

但启用控制流扁平化之后,代码的执行时间会变长,最长达 1.5 倍之多。

另外我们还能使用 controlFlowFlatteningThreshold 这个参数来控制比例,取值范围是 0 到 1,默认 0.75,如果设置为 0,那相当于 controlFlowFlattening 设置为 false,即不开启控制流扁平化 。

僵尸代码注入

僵尸代码即不会被执行的代码或对上下文没有任何影响的代码,注入之后可以对现有的 JavaScript 代码的阅读形成干扰。我们可以使用 deadCodeInjection 参数开启这个选项,默认为 false。
示例如下:

const code = `
			(function(){
			    if (true) {
			        var foo = function () {
			            console.log('abc');
			            console.log('cde');
			            console.log('efg');
			            console.log('hij');
			        };

			        var bar = function () {
			            console.log('klm');
			            console.log('nop');
			            console.log('qrs');
			        };

			        var baz = function () {
			            console.log('tuv');
			            console.log('wxy');
			            console.log('z');
			        };

			        foo();
			        bar();
			        baz();
			    }
			})();
			`
			const options = {
			  compact: false,
			  deadCodeInjection: true
			}
			

运行结果如下:

var _0x5024 = [
			    'zaU',
			    'log',
			    'tuv',
			    'wxy',
			    'abc',
			    'cde',
			    'efg',
			    'hij',
			    'QhG',
			    'TeI',
			    'klm',
			    'nop',
			    'qrs',
			    'bZd',
			    'HMx'
			];
			var _0x4502 = function (_0x1254b1, _0x583689) {
			    _0x1254b1 = _0x1254b1 - 0x0;
			    var _0x529b49 = _0x5024[_0x1254b1];
			    return _0x529b49;
			};
			(function () {
			    if (!![]) {
			        var _0x16c18d = function () {
			            if (_0x4502('0x0') !== _0x4502('0x0')) {
			                console[_0x4502('0x1')](_0x4502('0x2'));
			                console[_0x4502('0x1')](_0x4502('0x3'));
			                console[_0x4502('0x1')]('z');
			            } else {
			                console[_0x4502('0x1')](_0x4502('0x4'));
			                console[_0x4502('0x1')](_0x4502('0x5'));
			                console[_0x4502('0x1')](_0x4502('0x6'));
			                console[_0x4502('0x1')](_0x4502('0x7'));
			            }
			        };
			        var _0x1f7292 = function () {
			            if (_0x4502('0x8') === _0x4502('0x9')) {
			                console[_0x4502('0x1')](_0x4502('0xa'));
			                console[_0x4502('0x1')](_0x4502('0xb'));
			                console[_0x4502('0x1')](_0x4502('0xc'));
			            } else {
			                console[_0x4502('0x1')](_0x4502('0xa'));
			                console[_0x4502('0x1')](_0x4502('0xb'));
			                console[_0x4502('0x1')](_0x4502('0xc'));
			            }
			        };
			        var _0x33b212 = function () {
			            if (_0x4502('0xd') !== _0x4502('0xe')) {
			                console[_0x4502('0x1')](_0x4502('0x2'));
			                console[_0x4502('0x1')](_0x4502('0x3'));
			                console[_0x4502('0x1')]('z');
			            } else {
			                console[_0x4502('0x1')](_0x4502(