0%

Python

爬虫系列文章总目录:【2022 年】Python3 爬虫学习教程,本教程内容多数来自于《Python3网络爬虫开发实战(第二版)》一书,目前截止 2022 年,可以将爬虫基本技术进行系统讲解,同时将最新前沿爬虫技术如异步、JavaScript 逆向、AST、安卓逆向、Hook、智能解析、群控技术、WebAssembly、大规模分布式、Docker、Kubernetes 等,市面上目前就仅有《Python3 网络爬虫开发实战(第二版)》一书了,点击了解详情

很多情况下,一些网站的页面或资源我们通常需要登录才能看到。比如说访问 GitHub 的个人设置页面,如果不登录是无法查看的;比如说 12306 买票提交订单的页面,如果不登录是无法提交订单的;比如说要发一条微博,如果不登录是无法发送的。

我们之前学习的案例都是爬取的无需登录即可访问的站点,但是诸如上面例子的情况非常非常多,那假如我们想要用爬虫来访问这些页面,比如用爬虫修改 GitHub 的个人设置,用爬虫提交购票订单,用爬虫发微博,能做到吗?

答案是可以,这里就需要用到一些模拟登录相关的技术了。

那么本节我们就先来了解一下模拟登录的一些基本原理和实现吧。

1. 网站登录验证的实现

我们要实现模拟登录,那就得首先了解网站登录验证的实现。

登录一般是需要两个内容,用户名和密码,有的网站可能是手机号和验证码,有的是微信扫码,有的是 OAuth 验证等等,但根本上来说,都是把一些可供认证的信息提交给了服务器。

比如这里我们就拿用户名和密码来说吧。用户在一个网页表单里面输入了这些内容,然后点击登录按钮的一瞬间,浏览器客户端就会向服务器发送一个登录请求,这个请求里面肯定就包含了用户名和密码信息,这时候,服务器需要处理一下这些信息,然后返回给客户端一个类似「凭证」的东西,有了这个「凭证」以后呢,客户端拿着这个「凭证」再去访问某些需要登录才能查看的页面,服务器自然就能”放行“了,返回对应的内容或执行对应的操作就好了。

形象点说呢,我们拿登录发微博和买票坐火车这两件事来类比。发微博就好像要坐火车,没票是没法坐火车的吧,要坐火车怎么办呢?当然是先买票了,我们拿钱去火车站买个票,有了票之后,进站口查验一下,没问题就自然能去坐火车了,这个票就是坐火车的「凭证」。那发微博也一样,我们有用户名和密码,请求下服务器,获得一个「凭证」,这就相当于买到了火车票,然后在发微博的时候拿着这个「凭证」去请求服务器,服务器校验没问题,自然就把微博发出去了。

那么问题来了,这个「凭证」到底是怎么生成和验证的呢?目前比较流行的实现方式有两种,一种是基于 Session + Cookie 的验证,一种是基于 JWT(JSON Web Token)的验证,下面我们来介绍下。

我们在第一章了解了 Session 和 Cookie 的基本概念。简而言之呢,Session 就是存在服务端的,里面保存了用户此次访问的会话信息,Cookie 则是保存在用户本地浏览器的,它会在每次用户访问网站的时候发送给服务器,Cookie 会作为 Request Headers 的一部分发送给服务器,服务器根据 Cookie 里面包含的信息判断找出其 Session 对象并做一些校验,不同的 Session 对象里面维持了不同访问用户的状态,服务器可以根据这些信息决定返回 Response 的内容。

我们以用户登录的情形来说吧,其实不同的网站对于用户的登录状态的实现是可能不同的,但是 Session 和 Cookie 一定是相互配合工作的。

下面梳理如下:

  • 比如说,Cookie 里面可能只存了 Session ID 相关信息,服务器能根据 Cookie 找到对应的 Session,用户登录之后,服务器会把对应的 Session 里面标记一个字段,代表已登录状态或者其他信息(如角色、登录时间)等等,这样用户每次访问网站的时候都带着 Cookie 来访问,服务器就能找到对应的 Session,然后看一下 Session 里面的状态是登录状态,那就可以返回对应的结果或执行某些操作。
  • 当然 Cookie 里面也可能直接存了某些凭证信息。比如说用户在发起登录请求之后,服务器校验通过,返回给客户端的 Response Headers 里面可能带有 Set-Cookie 字段,里面可能就包含了类似凭证的信息,这样客户端会执行设置 Cookie 的操作,将这些信息保存到 Cookie 里面,以后再访问网页时携带这些 Cookie 信息,服务器拿着这里面的信息校验,自然也能实现登录状态检测了。

以上两种情况几乎能涵盖大部分的 Session 和 Cookie 登录验证的实现,具体的实现逻辑因服务器而异,但 Session 和 Cookie 一定是需要相互配合才能实现的。

3. JWT

Web 开发技术是一直在发展的,近几年前后端分离的趋势越来越火,很多 Web 网站都采取了前后端分离的技术来实现。而且传统的基于 Session 和 Cookie 的校验也存在一定问题,比如服务器需要维护登录用户的 Session 信息,而且分布式部署不方便,也不太适合前后端分离的项目。

所以,JWT 技术应运而生。

JWT,英文全称为 JSON Web Token,是为了在网络应用环境间传递声明而执行的一种基于 JSON 的开放标准。实际上就是在每次登录的时候通过一个 Token 字符串来校验登录状态。JWT 的声明一般被用来在身份提供者和服务提供者之间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的业务逻辑所必须的声明信息,所以这个 Token 也可直接被用于认证,也可传递一些额外信息。

有了 JWT,一些认证就不需要借助于 Session 和 Cookie 了,服务器也无须维护 Session 信息,减少了服务器的开销。服务器只需要有一个校验 JWT 的功能就好了,同时也可以做到分布式部署和跨语言的支持。

JWT 通常就是一个加密的字符串,它也有自己的标准,类似下面的这种格式:

1
eyJ0eXAxIjoiMTIzNCIsImFsZzIiOiJhZG1pbiIsInR5cCI6IkpXVCIsImFsZyI6IkhTMjU2In0.eyJVc2VySWQiOjEyMywiVXNlck5hbWUiOiJhZG1pbiIsImV4cCI6MTU1MjI4Njc0Ni44Nzc0MDE4fQ.pEgdmFAy73walFonEm2zbxg46Oth3dlT02HR9iVzXa8

我们可以发现中间有两个用来分割的 . ,因此可以把它看成是一个三段式的加密字符串。

它由三部分构成,分别是 Header、Payload、Signature。

  • Header,声明了 JWT 的签名算法,如 RSA、SHA256 等,也可能包含 JWT 编号或类型等数据,然后对整个信息进行 Base64 编码即可。
  • Payload,通常用来存放一些业务需要但不敏感的信息,如 UserID 等,另外它也有很多默认是字段,如 JWT 签发者、JWT 接受者、JWT 过期时间等,Base64 编码即可。
  • Signature,就是一个签名,是把 Header、Payload 的信息用秘钥 secret 加密后形成的,这个 secret 是保存在服务器端的,不能被轻易泄露。如此一来,即使一些 Payload 的信息被篡改,服务器也能通过 Signature 判断出非法请求,拒绝服务。

这三部分通过 . 组合起来就形成了 JWT 的字符串,就是用户的访问凭证。

所以这个登录认证流程也很简单了,用户拿着用户名密码登录,然后服务器生成 JWT 字符串返回给客户端。客户端每次请求都带着这个 JWT 就行了,服务器会自动判断其有效情况,如果有效,自然就返回对应的数据。JWT 的传输就多种多样了,可以将其放在 Request Headers 中,也可以放在 URL 里,甚至也有的网站把它放在 Cookie 里面,但总而言之,能传给服务器进行校验就好了。

好,到此为止呢,我们就已经了解了网站登录验证的实现了。

4. 模拟登录

好,那了解了网站登录验证的实现后,模拟登录自然就有思路了。

下面我们同样分两种认证方式来说明。

基于 Session 和 Cookie 的模拟登录,如果我们要用爬虫实现的话,其实最主要的就是把 Cookie 的信息维护好就行了,因为爬虫就相当于客户端浏览器,我们模拟好浏览器做的事情就好了。

一般怎么实现模拟登录呢?接下来我们结合之前所讲的技术总结一下。

  • 第一,如果我们已经在浏览器中登录了自己的账号,要想用爬虫模拟,那么可以直接把 Cookie 复制过来交给爬虫。这是最省时省力的方式,相当于我们用浏览器手动操作登录了。我们把 Cookie 放到代码里,爬虫每次请求的时候再将其放到 Request Headers 中,完全模拟了浏览器的操作。之后服务器会通过 Cookie 校验登录状态,如果没问题,自然就可以执行某些操作或返回某些内容了。
  • 第二,如果我们不想有任何手工操作,那么可以直接使用爬虫模拟登录过程。其实登录的过程多数也是一个 POST 请求。我们用爬虫提交了用户名、密码等信息给服务器,服务器返回的 Response Headers 里面可能会带有 Set-Cookie 的字段,我们只需要把这些 Cookie 保存下来就行了。所以,最主要的就是把这个过程中的 Cookie 维持好。当然这里可能会遇到一些困难,比如登录过程中伴随着各种校验参数,不好直接模拟请求;网站设置 Cookie 的过程是通过 JavaScript 实现的,所以可能还得仔细分析下其中的逻辑,尤其是我们用 requests 这样的请求库进行模拟登录的时候,遇到的问题经常比较多。
  • 第三,我们也可以用一些简单的方式来实现模拟登录,即实现登录过程的自动化。比如我们用 Selenium、Pyppeteer 或 Playwright 来驱动浏览器模拟执行一些操作,如填写用户名和密码、提交表单等。登录成功后,通过 Selenium 或 Pyppeteer 获取当前浏览器的 Cookie 并保存即可。这样后续就可以拿着 Cookie 的内容发起请求,同样也能实现模拟登录。

以上介绍的就是一些常用的爬虫模拟登录的方案,其目的是维护好客户端的 Cookie 信息。总之,每次请求都携带好 Cookie 信息就能实现模拟登录了。

JWT

基于 JWT 的模拟登录思路也比较清晰了,由于 JWT 的字符串就是用户访问的凭证,所以模拟登录只需要做到下面几步。

  • 第一步,模拟网站登录操作的请求。比如拿着用户名和密码信息请求登录接口,获取服务器返回的结果,这个结果中通常包含 JWT 字符串的信息,将它保存即可。
  • 第二步,后续的请求携带 JWT 进行访问。在 JWT 不过期的情况下,通常能正常访问和执行对应的操作。携带方式多种多样,因网站而异。
  • 第三步,如果 JWT 过期了,可能需要再次进行第一步,重新获取 JWT。

当然,模拟登录的过程肯定会带有一些其他的加密参数,需要根据实际情况具体分析。

4. 账号池

如果爬虫要求爬取的数据量比较大或爬取速度比较快,而网站又有单账号并发限制或者访问状态检测等反爬虫手段,那么我们的账号可能就会无法访问或者面临封号的风险了。

这时候一般怎么办呢?

我们可以使用分流的方案来实现。假设某个网站设置一分钟之内检测到同一个账号访问 3 次或 3 次以上则封号,我们就可以建立一个账号池,用多个账号来随机访问或爬取数据,这样就能大幅提高爬虫的并发量,降低被封号的风险了。比如我们可以准备 100 个账号,然后 100 个账号都模拟登录,把对应的 Cookie 或 JWT 存下来,每次访问的时候随机取一个来,由于账号多,所以每个账号被取用的概率也就降下来了,这样就能避免单账号并发过大的问题,也降低封号风险。

5. 总结

本节我们首先了解了 Session + Cookie 和 JWT 模拟登录的原理,接着初步了解了两种模拟登录方式的实现思路,最后初步介绍了一下账号池的作用。

后文我们会通过几个实战案例来实现上述两种方案的模拟登录,为了更好地理解后文的实战内容,建议好好理解本节所介绍的内容。

Python

系列文章总目录:【2022 年】Python3 爬虫学习教程,本教程内容多数来自于《Python3网络爬虫开发实战(第二版)》一书,目前截止 2022 年,可以将爬虫基本技术进行系统讲解,同时将最新前沿爬虫技术如异步、JavaScript 逆向、AST、安卓逆向、Hook、智能解析、群控技术、WebAssembly、大规模分布式、Docker、Kubernetes 等,市面上目前就仅有《Python3 网络爬虫开发实战(第二版)》一书了,点击了解详情

在上一节中,我们介绍了异步爬虫的基本原理和 asyncio 的基本用法,并且在最后简单提及了使用 aiohttp 来实现网页爬取的过程。在本节中,我们来介绍一下 aiohttp 的常见用法。

1. 基本介绍

前面介绍的 asyncio 模块内部实现了对 TCP、UDP、SSL 协议的异步操作,但是对于 HTTP 请求来说,我们就需要用到 aiohttp 来实现了。

aiohttp 是一个基于 asyncio 的异步 HTTP 网络模块,它既提供了服务端,又提供了客户端。其中我们用服务端可以搭建一个支持异步处理的服务器,就是用来处理请求并返回响应的,类似于 Django、Flask、Tornado 等一些 Web 服务器。而客户端可以用来发起请求,类似于使用 requests 发起一个 HTTP 请求然后获得响应,但 requests 发起的是同步的网络请求,aiohttp 则是异步的。

本节中,我们主要了解一下 aiohttp 客户端部分的用法。

2. 基本实例

首先,我们来看一个基本的 aiohttp 请求案例,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 来爬取我的个人博客,获得了源码和响应状态码并输出出来,运行结果如下:

1
2
3
4
5
6
html: <!DOCTYPE HTML>
<html>
<head>
<meta charset="UTF-8">
<meta name="baidu-tc-verification" content=...
status: 200

这里网页源码过长,只截取输出了一部分。可以看到,这里我们成功获取了网页的源代码及响应状态码 200,也就完成了一次基本的 HTTP 请求,即我们成功使用 aiohttp 通过异步的方式来进行了网页爬取。当然,这个操作用之前讲的 requests 也可以做到。

可以看到,其请求方法的定义和之前有了明显的区别,主要有如下几点:

  • 首先在导入库的时候,我们除了必须要引入 aiohttp 这个库之外,还必须要引入 asyncio 这个库。因为要实现异步爬取,需要启动协程,而协程则需要借助于 asyncio 里面的事件循环来执行。除了事件循环,asyncio 里面也提供了很多基础的异步操作。
  • 异步爬取方法的定义和之前有所不同,在每个异步方法前面统一要加 async 来修饰。
  • with as 语句前面同样需要加 async 来修饰。在 Python 中,with as 语句用于声明一个上下文管理器,能够帮我们自动分配和释放资源。而在异步方法中,with as 前面加上 async 代表声明一个支持异步的上下文管理器。
  • 对于一些返回 coroutine 的操作,前面需要加 await 来修饰。比如 response 调用 text 方法,查询 API 可以发现,其返回的是 coroutine 对象,那么前面就要加 await;而对于状态码来说,其返回值就是一个数值类型,那么前面就不需要加 await。所以,这里可以按照实际情况处理,参考官方文档说明,看看其对应的返回值是怎样的类型,然后决定加不加 await 就可以了。
  • 最后,定义完爬取方法之后,实际上是 main 方法调用了 fetch 方法。要运行的话,必须要启用事件循环,而事件循环就需要使用 asyncio 库,然后使用 run_until_complete 方法来运行。

注意:在 Python 3.7 及以后的版本中,我们可以使用 asyncio.run(main()) 来代替最后的启动操作,不需要显示声明事件循环,run 方法内部会自动启动一个事件循环。但这里为了兼容更多的 Python 版本,依然还是显式声明了事件循环。

3. URL 参数设置

对于 URL 参数的设置,我们可以借助于 params 参数,传入一个字典即可,示例如下:

1
2
3
4
5
6
7
8
9
10
11
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())

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"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 的内容。

4. 其他请求类型

另外,aiohttp 还支持其他请求类型,如 POST、PUT、DELETE 等,这和 requests 的使用方式有点类似,示例如下:

1
2
3
4
5
6
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')

要使用这些方法,只需要把对应的方法和参数替换一下即可。

5. POST 请求

对于 POST 表单提交,其对应的请求头的 Content-Typeapplication/x-www-form-urlencoded,我们可以用如下方式来实现,代码示例如下:

1
2
3
4
5
6
7
8
9
10
11
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())

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
"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-Typeapplication/json,我们只需要将 post 方法的 data 参数改成 json 即可,代码示例如下:

1
2
3
4
5
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())

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
"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"
}

可以发现,其实现也和 requests 非常像,不同的参数支持不同类型的请求内容。

6. 响应

对于响应来说,我们可以用如下方法分别获取响应的状态码、响应头、响应体、响应体二进制内容、响应体 JSON 结果,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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())

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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。

7. 超时设置

对于超时设置,我们可以借助 ClientTimeout 对象,比如这里要设置 1 秒的超时,可以这么实现:

1
2
3
4
5
6
7
8
9
10
11
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 秒之内成功获取响应的话,运行结果如下:

1
200

如果超时的话,会抛出 TimeoutError 异常,其类型为 asyncio.TimeoutError,我们再进行异常捕获即可。

另外,声明 ClientTimeout 对象时还有其他参数,如 connectsocket_connect 等,详细可以参考官方文档:https://docs.aiohttp.org/en/stable/client_quickstart.html#timeouts。

8. 并发限制

由于 aiohttp 可以支持非常大的并发,比如上万、十万、百万都是能做到的,但对于这么大的并发量,目标网站很可能在短时间内无法响应,而且很可能瞬时间将目标网站爬挂掉,所以我们需要控制一下爬取的并发量。

一般情况下,我们可以借助于 asyncio 的 Semaphore 来控制并发量,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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 限制速度了。

9. 总结

本节我们了解了 aiohttp 的基本使用方法,更详细的内容还是推荐大家到官方文档查阅,详见 https://docs.aiohttp.org/。

本节代码:https://github.com/Python3WebSpider/AsyncTest。

Python

爬虫系列文章总目录:【2022 年】Python3 爬虫学习教程,本教程内容多数来自于《Python3 网络爬虫开发实战(第二版)》一书,目前截止 2022 年,可以将爬虫基本技术进行系统讲解,同时将最新前沿爬虫技术如异步、JavaScript 逆向、AST、安卓逆向、Hook、智能解析、群控技术、WebAssembly、大规模分布式、Docker、Kubernetes 等,市面上目前就仅有《Python3 网络爬虫开发实战(第二版)》一书了,点击了解详情

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

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

包含 `token` 参数的 Ajax 接口

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

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

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

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

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

1. 准备工作

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

安装方法可以参考:https://setup.scrape.center/selenium,全部配置完成之后,我们便可以开始本节的学习了。

2. 基本用法

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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,接着跳转到搜索结果页,如图所示。

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

1
2
3
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%2BCQ&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 的用法。

3. 声明浏览器对象

Selenium 支持非常多的浏览器,如 Chrome、Firefox、Edge 等,还有 Android、BlackBerry 等手机端的浏览器。我们可以用如下方式初始化:

1
2
3
4
5
6
from selenium import webdriver

browser = webdriver.Chrome()
browser = webdriver.Firefox()
browser = webdriver.Edge()
browser = webdriver.Safari()

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

4. 访问页面

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

1
2
3
4
5
6
from selenium import webdriver

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

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

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

5. 查找节点

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

单个节点

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

源代码

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

下面我们用代码实现一下:

1
2
3
4
5
6
7
8
9
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 获取,它们返回的结果完全一致。运行结果如下:

1
2
3
<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 类型,是完全一致的。

下面列出所有获取单个节点的方法:

1
2
3
4
5
6
7
8
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_by_id 这种方法的通用函数版本,比如 find_element_by_id(id) 就等价于 find_element(By.ID, id),二者得到的结果完全一致。我们用代码实现一下:

1
2
3
4
5
6
7
8
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 方法查找,就只能得到第一个节点了。如果要查找所有满足条件的节点,需要用 find_elements 这样的方法。注意,在这个方法的名称中,element 多了一个 s,注意区分。

比如,要查找淘宝左侧导航条的所有条目,就可以这样来实现:

1
2
3
4
5
6
7
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()

运行结果如下:

1
[<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 类型。

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

1
2
3
4
5
6
7
8
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 方法来选择,这时可以这样写:

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

结果是完全一致的。

6. 节点交互

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

1
2
3
4
5
6
7
8
9
10
11
12
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。

7. 动作链

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

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

1
2
3
4
5
6
7
8
9
10
11
12
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。

8. 执行 JavaScript

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

1
2
3
4
5
6
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 的方式来实现了。

9. 获取节点信息

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

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

接下来,我们就来看看怎样获取节点信息吧。

获取属性

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

1
2
3
4
5
6
7
8
from selenium import webdriver

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

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

控制台的输出结果如下:

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

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

获取文本值

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

1
2
3
4
5
6
7
from selenium import webdriver

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

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

控制台的输出结果如下:

1
Scrape

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

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

1
2
3
4
5
6
7
8
9
10
from selenium import webdriver

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

这里首先获得 classlogo-title 这个节点,然后调用其 idlocationtag_namesize 属性来获取对应的属性值。

10. 切换 Frame

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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)

控制台输出结果如下:

1
2
3
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,然后再进行操作。

11. 延时等待

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

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

隐式等待

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

1
2
3
4
5
6
7
from selenium import webdriver

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

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

显式等待

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

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

1
2
3
4
5
6
7
8
9
10
11
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 秒还不可点击,也就是没有加载出来,就抛出异常。

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

控制台的输出如下:

1
2
<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 异常,此时控制台的输出如下:

1
2
3
4
5
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')))

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

等待条件 含义
title_is 标题是某内容
title_contains 标题包含某内容
presence_of_element_located 节点加载出来,传入定位元组,如 (By.ID, 'p')
visibility_of_element_located 节点可见,传入定位元组
visibility_of 可见,传入节点对象
presence_of_all_elements_located 所有节点加载出来
text_to_be_present_in_element 某个节点文本包含某文字
text_to_be_present_in_element_value 某个节点值包含某文字
frame_to_be_available_and_switch_to_it frame 加载并切换
invisibility_of_element_located 节点不可见
element_to_be_clickable 节点可点击
staleness_of 判断一个节点是否仍在 DOM,可判断页面是否已经刷新
element_to_be_selected 节点可选择,传入节点对象
element_located_to_be_selected 节点可选择,传入定位元组
element_selection_state_to_be 传入节点对象以及状态,相等返回 True,否则返回 False
element_located_selection_state_to_be 传入定位元组以及状态,相等返回 True,否则返回 False
alert_is_present 是否出现 Alert

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

12. 前进后退

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

1
2
3
4
5
6
7
8
9
10
11
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 方法回到第二个页面,接下来再调用 forward 方法又可以前进到第三个页面。

13. Cookies

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

1
2
3
4
5
6
7
8
9
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,这里传入一个字典,有 namedomainvalue 等内容。接下来,再次获取所有的 Cookies。可以发现,结果就多了这一项新加的 Cookie。最后,调用 delete_all_cookies 方法删除所有的 Cookies。再重新获取,发现结果就为空了。

控制台的输出如下:

1
2
3
[{'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 还是非常方便的。

14. 选项卡管理

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

1
2
3
4
5
6
7
8
9
10
11
12
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')

控制台的输出如下:

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

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

15. 异常处理

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

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

1
2
3
4
5
from selenium import webdriver

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

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

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

1
2
3
4
5
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 异常,这通常是节点未找到的异常。为了防止程序遇到异常而中断,我们需要捕获这些异常,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 异常,这样一旦出现这样的错误,就进行异常处理,程序也不会中断了。

控制台的输出如下:

1
No Element

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

16. 反屏蔽

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

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

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

image-20210705014022028

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
from selenium import webdriver
from selenium.webdriver import ChromeOptions

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

这样整个页面就能被加载出来了,如图所示。

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

17. 无头模式

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

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

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

1
2
3
4
5
6
7
8
9
from selenium import webdriver
from selenium.webdriver import ChromeOptions

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

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

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

输出的页面

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

18. 总结

现在,我们基本上对 Selenium 的常规用法有了大体的了解。使用 Selenium,处理 JavaScript 渲染的页面不再是难事,后面我们会用一个实例来演示 Selenium 爬取网站的流程。

本节代码:https://github.com/Python3WebSpider/SeleniumTest。

Python

爬虫系列文章总目录:【2022 年】Python3 爬虫学习教程,本教程内容多数来自于《Python3网络爬虫开发实战(第二版)》一书,目前截止 2022 年,可以将爬虫基本技术进行系统讲解,同时将最新前沿爬虫技术如异步、JavaScript 逆向、AST、安卓逆向、Hook、智能解析、群控技术、WebAssembly、大规模分布式、Docker、Kubernetes 等,市面上目前就仅有《Python3 网络爬虫开发实战(第二版)》一书了,点击了解详情

想查数据,就免不了搜索,而搜索离不开搜索引擎。百度、谷歌都是非常庞大、复杂的搜索引擎,它们几乎索引了互联网上开放的所有网页和数据。然而对于我们自己的业务数据来说,没必要用这么复杂的技术。如果我们想实现自己的搜索引擎,为了便于存储和检索,Elasticsearch 就是不二选择。它是一个全文搜索引擎,可以快速存储、搜索和分析海量数据。

所以,如果我们我们将爬取到的数据存储到 Elasticsearch 里面,那将会非常方便检索。

1. Elasticsearch 介绍

Elasticsearch 是一个开源的搜索引擎,建立在一个全文搜索引擎库 Apache Lucene™ 基础之上。

那 Lucene 又是什么呢?Lucene 可能是目前存在的(不论开源还是私有的)拥有最先进、高性能和全功能搜索引擎功能的库,但也仅仅只是一个库。要想用 Lucene,我们需要编写 Java 并引用 Lucene 包才可以,而且我们需要对信息检索有一定程度的理解。

为了解决这个问题,Elasticsearch 就诞生了。Elasticsearch 也是使用 Java 编写的,它的内部使用 Lucene 做索引与搜索,但是它的目标是使全文检索变得简单,相当于 Lucene 的一层封装,它提供了一套简单一致的 RESTful API 来帮助我们实现存储和检索。

所以 Elasticsearch 仅仅就是一个简易版的 Lucene 封装吗?那就大错特错了,Elasticsearch 不仅仅是 Lucene,并且也不仅仅只是一个全文搜索引擎。它可以这样准确形容:

  • 一个分布式的实时文档存储,每个字段可以被索引与搜索;
  • 一个分布式实时分析搜索引擎;
  • 能胜任上百个服务节点的扩展,并支持 PB 级别的结构化或者非结构化数据。

总之,它是一个非常强大的搜索引擎,维基百科、Stack Overflow、GitHub 都纷纷采用它来做搜索,不仅仅提供强大的检索能力,也提供强大的存储能力。

2. Elasticsearch 相关概念

在 Elasticsearch 中有几个基本概念,如节点、索引、文档等,下面分别说明一下。理解了这些概念,对熟悉 Elasticsearch 是非常有帮助的。

节点和集群

Elasticsearch 本质上是一个分布式数据库,允许多台服务器协同工作,每台服务器可以运行多个 Elasticsearch 实例。

单个 Elasticsearch 实例称为一个节点(Node),一组节点构成一个集群(Cluster)。

索引

索引,即 Index,Elasticsearch 会索引所有字段,经过处理后写入一个反向索引(Inverted Index)。查找数据的时候,直接查找该索引。

所以,Elasticsearch 数据管理的顶层单位就叫作索引,其实就相当于 MySQL、MongoDB 等中数据库的概念。另外,值得注意的是,每个索引 (即数据库)的名字必须小写。

文档

文档,即 Document。索引里面单条记录称为文档,许多条文档构成了一个索引。

同一个索引里面的文档,不要求有相同的结构(Schema),但是最好保持一致,因为这样有利于提高搜索效率。

类型

文档可以分组,比如 weather 这个索引里面,既可以按城市分组(北京和上海),也可以按气候分组(晴天和雨天)。这种分组就叫作类型(Type),它是虚拟的逻辑分组,用来过滤文档,类似 MySQL 中的数据表、MongoDB 中的 Collection。

不同的类型应该有相似的结构。举例来说,id 字段不能在这个组中是字符串,在另一个组中是数值。这是与关系型数据库的表的一个区别。性质完全不同的数据(比如 productslogs)应该存成两个索引,而不是一个索引里面的两个类型(虽然可以做到)。

根据规划,Elastic 6.x 版只允许每个索引包含一个类型,Elastic 7.x 开始将会将其彻底移除。

字段

每个文档都类似一个 JSON 结构,它包含了许多字段,每个字段都有其对应的值,多个字段组成了一个文档,其实就可以类比 MySQL 数据表中的字段。

在 Elasticsearch 中,文档归属于一种类型(Type),而这些类型存在于索引中,我们可以画一些简单的对比图来类比传统关系型数据库:

1
2
Relational DB -> Databases -> Tables -> Rows -> Columns
Elasticsearch -> Indices -> Types -> Documents -> Fields

以上就是 Elasticsearch 里面的一些基本概念,通过和关系型数据库的对比更加有助于理解。

3. 准备工作

在开始本节实际操作之前,请确保已经正确安装好了 Elasticsearch,安装方式可以参考:https://setup.scrape.center/elasticsearch,安装完成之后确保其在本地 9200 端口上正常运行即可。

Elasticsearch 实际上提供了一系列 Restful API 来进行存取和查询操作,我们可以使用 curl 等命令或者直接调用 API 来进行数据存储和修改操作,但总归来说并不是很方便。所以这里我们就直接介绍一个专门用来对接 Elasticsearch 操作的 Python 库,名称也叫做 Elasticsearch,使用 pip3 安装即可:

1
pip3 install elasticsearch

更详细的安装方式可以参考:https://setup.scrape.center/elasticsearch-py。

安装好了之后我们就可以开始本节的学习了。

4. 创建索引

我们先来看下怎样创建一个索引,这里我们创建一个名为 news 的索引:

1
2
3
4
5
from elasticsearch import Elasticsearch

es = Elasticsearch()
result = es.indices.create(index='news', ignore=400)
print(result)

这里我们首先创建了一个 Elasticsearch 对象,并且没有设置任何参数,默认情况下它会连接本地 9200 端口运行的 Elasticsearch 服务,我们也可以设置特定的连接信息,如:

1
2
3
4
es = Elasticsearch(
['https://[username:password@]hostname:port'],
verify_certs=True, # 是否验证 SSL 证书
)

第一个参数我们可以构造特定格式的链接字符串并传入,hostname 和 port 即 Elasticsearch 运行的地址和端口,username 和 password 是可选的,代表连接 Elasticsearch 需要的用户名和密码,另外而且还有其他的参数设置,比如 verify_certs 代表是否验证证书有效性。更多参数的设置可以参考:https://elasticsearch-py.readthedocs.io/en/latest/api.html#elasticsearch。

声明 Elasticsearch 对象之后,我们调用了 es 的 indices 对象的 create 方法传入了 index 的名称,如果创建成功,会返回如下结果:

1
{'acknowledged': True, 'shards_acknowledged': True, 'index': 'news'}

可以看到,其返回结果是 JSON 格式,其中的 acknowledged 字段表示创建操作执行成功。

但这时如果我们再把代码执行一次的话,就会返回如下结果:

1
{'error': {'root_cause': [{'type': 'resource_already_exists_exception', 'reason': 'index [news/hHEYozoqTzK_qRvV4j4a3w] already exists', 'index_uuid': 'hHEYozoqTzK_qRvV4j4a3w', 'index': 'news'}], 'type': 'resource_already_exists_exception', 'reason': 'index [news/hHEYozoqTzK_qRvV4j4a3w] already exists', 'index_uuid': 'hHEYozoqTzK_qRvV4j4a3w', 'index': 'news'}, 'status': 400}

它提示创建失败,status 状态码是 400,错误原因是索引已经存在了。

注意在这里的代码中,我们使用的 ignore 参数为 400,这说明如果返回结果是 400 的话,就忽略这个错误,不会报错,程序不会抛出异常。

假如我们不加 ignore 这个参数的话:

1
2
3
es = Elasticsearch()
result = es.indices.create(index='news')
print(result)

再次执行就会报错了:

1
2
raise HTTP_EXCEPTIONS.get(status_code, TransportError)(status_code, error_message, additional_info)
elasticsearch.exceptions.RequestError: TransportError(400, 'resource_already_exists_exception', 'index [news/QM6yz2W8QE-bflKhc5oThw] already exists')

这样程序的执行就会出现问题。因此,我们需要善用 ignore 参数,把一些意外情况排除,这样可以保证程序正常执行而不会中断。

创建完之后,我们还可以设置下索引的字段映射定义,可以参考:https://elasticsearch-py.readthedocs.io/en/latest/api.html?#elasticsearch.client.IndicesClient.put_mapping。

5. 删除索引

删除索引也是类似的,代码如下:

1
2
3
4
5
from elasticsearch import Elasticsearch

es = Elasticsearch()
result = es.indices.delete(index='news', ignore=[400, 404])
print(result)

这里也使用了 ignore 参数来忽略索引不存在而删除失败导致程序中断的问题。

如果删除成功,会输出如下结果:

1
{'acknowledged': True}

如果索引已经被删除,再执行删除,则会输出如下结果:

1
{'error': {'root_cause': [{'type': 'index_not_found_exception', 'reason': 'no such index [news]', 'resource.type': 'index_or_alias', 'resource.id': 'news', 'index_uuid': '_na_', 'index': 'news'}], 'type': 'index_not_found_exception', 'reason': 'no such index [news]', 'resource.type': 'index_or_alias', 'resource.id': 'news', 'index_uuid': '_na_', 'index': 'news'}, 'status': 404}

这个结果表明当前索引不存在,删除失败。返回的结果同样是 JSON,状态码是 404,但是由于我们添加了 ignore 参数,忽略了 404 状态码,因此程序正常执行,输出 JSON 结果,而不是抛出异常。

6. 插入数据

Elasticsearch 就像 MongoDB 一样,在插入数据的时候可以直接插入结构化字典数据,插入数据可以调用 create 方法。例如,这里我们插入一条新闻数据:

1
2
3
4
5
6
7
8
9
10
11
from elasticsearch import Elasticsearch

es = Elasticsearch()
es.indices.create(index='news', ignore=400)

data = {
'title': '乘风破浪不负韶华,奋斗青春圆梦高考',
'url': 'http://view.inews.qq.com/a/EDU2021041600732200'
}
result = es.create(index='news', id=1, body=data)
print(result)

这里我们首先声明了一条新闻数据,包括标题和链接,然后通过调用 create 方法插入了这条数据。在调用 create 方法时,我们传入了 4 个参数,index 参数代表了索引名称,id 则是数据的唯一标识 ID,body 则代表了文档的具体内容。

运行结果如下:

1
{'_index': 'news', '_type': '_doc', '_id': '1', '_version': 1, 'result': 'created', '_shards': {'total': 2, 'successful': 1, 'failed': 0}, '_seq_no': 0, '_primary_term': 1}

结果中 result 字段为 created,代表该数据插入成功。

另外,其实我们也可以使用 index 方法来插入数据。但与 create 不同的是,create 方法需要我们指定 id 字段来唯一标识该条数据,而 index 方法则不需要,如果不指定 id,会自动生成一个 id。调用 index 方法的写法如下:

1
es.index(index='news', body=data)

create 方法内部其实也是调用了 index 方法,是对 index 方法的封装。

7. 更新数据

更新数据也非常简单,我们同样需要指定数据的 id 和内容,调用 update 方法即可,代码如下:

1
2
3
4
5
6
7
8
9
10
from elasticsearch import Elasticsearch

es = Elasticsearch()
data = {
'title': '乘风破浪不负韶华,奋斗青春圆梦高考',
'url': 'http://view.inews.qq.com/a/EDU2021041600732200',
'date': '2021-07-05'
}
result = es.update(index='news', body=data, id=1)
print(result)

这里我们为数据增加了一个日期字段,然后调用了 update 方法,结果如下:

1
{'_index': 'news', '_type': '_doc', '_id': '1', '_version': 2, 'result': 'updated', '_shards': {'total': 2, 'successful': 1, 'failed': 0}, '_seq_no': 1, '_primary_term': 1}

可以看到,返回结果中 result 字段为 updated,即表示更新成功。另外,我们还注意到一个字段 _version,这代表更新后的版本号数,2 代表这是第二个版本。因为之前已经插入过一次数据,所以第一次插入的数据是版本 1,可以参见上例的运行结果,这次更新之后版本号就变成了 2,以后每更新一次,版本号都会加 1。

另外,更新操作利用 index 方法同样可以做到,其写法如下:

1
es.index(index='news', body=data, id=1)

可以看到,index 方法可以代替我们完成插入和更新数据这两个操作。如果数据不存在,就执行插入操作,如果已经存在,就执行更新操作,非常方便。

8. 删除数据

如果想删除一条数据,可以调用 delete 方法并指定需要删除的数据 id 即可。其写法如下:

1
2
3
4
5
from elasticsearch import Elasticsearch

es = Elasticsearch()
result = es.delete(index='news', id=1)
print(result)

运行结果如下:

1
{'_index': 'news', '_type': '_doc', '_id': '1', '_version': 2, 'result': 'deleted', '_shards': {'total': 2, 'successful': 1, 'failed': 0}, '_seq_no': 3, '_primary_term': 1}

可以看到,运行结果中 result 字段为 deleted,代表删除成功;_version 变成了 3,又增加了 1。

9. 查询数据

上面的几个操作都是非常简单的操作,普通的数据库如 MongoDB 都可以完成,看起来并没有什么了不起的,Elasticsearch 更特殊的地方在于其异常强大的检索功能。

对于中文来说,我们需要安装一个分词插件,这里使用的是 elasticsearch-analysis-ik,其 GitHub 链接为https://github.com/medcl/elasticsearch-analysis-ik。这里我们使用 Elasticsearch 的另一个命令行工具 elasticsearch-plugin 来安装,这里安装的版本是 7.13.2,请确保和 Elasticsearch 的版本对应起来,命令如下:

1
elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.13.2/elasticsearch-analysis-ik-7.13.2.zip

这里的版本号请替换成你的 Elasticsearch 版本号。

安装之后,我们需要重新启动 Elasticsearch,启动之后它会自动加载安装好的插件。

首先,我们重新新建一个索引并指定需要分词的字段,相应代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from elasticsearch import Elasticsearch

es = Elasticsearch()
mapping = {
'properties': {
'title': {
'type': 'text',
'analyzer': 'ik_max_word',
'search_analyzer': 'ik_max_word'
}
}
}
es.indices.delete(index='news', ignore=[400, 404])
es.indices.create(index='news', ignore=400)
result = es.indices.put_mapping(index='news', body=mapping)
print(result)

这里我们先将之前的索引删除了,然后新建了一个索引,接着更新了它的 mapping 信息。mapping 信息中指定了分词的字段,指定了字段的类型 typetext,分词器 analyzer 和搜索分词器 search_analyzerik_max_word,即使用我们刚才安装的中文分词插件。如果不指定的话,则使用默认的英文分词器。

接下来,我们插入几条新数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from elasticsearch import Elasticsearch

es = Elasticsearch()

datas = [
{
'title': '高考结局大不同',
'url': 'https://k.sina.com.cn/article_7571064628_1c3454734001011lz9.html',
},
{
'title': '进入职业大洗牌时代,“吃香”职业还吃香吗?',
'url': 'https://new.qq.com/omn/20210828/20210828A025LK00.html',
},
{
'title': '乘风破浪不负韶华,奋斗青春圆梦高考',
'url': 'http://view.inews.qq.com/a/EDU2021041600732200',
},
{
'title': '他,活出了我们理想的样子',
'url': 'https://new.qq.com/omn/20210821/20210821A020ID00.html',
}
]

for data in datas:
es.index(index='news', body=data)

这里我们指定了 4 条数据,它们都带有 titleurl 字段,然后通过 index 方法将其插入 Elasticsearch 中,索引名称为 news

接下来,我们根据关键词查询一下相关内容:

1
2
result = es.search(index='news')
print(result)

可以看到,这里查询出了插入的 4 条数据:

1
{'took': 11, 'timed_out': False, '_shards': {'total': 1, 'successful': 1, 'skipped': 0, 'failed': 0}, 'hits': {'total': {'value': 4, 'relation': 'eq'}, 'max_score': 1.0, 'hits': [{'_index': 'news', '_type': '_doc', '_id': 'jebpkHsBm-BAny-7hOYp', '_score': 1.0, '_source': {'title': '高考结局大不同', 'url': 'https://k.sina.com.cn/article_7571064628_1c3454734001011lz9.html'}}, {'_index': 'news', '_type': '_doc', '_id': 'jubpkHsBm-BAny-7hObz', '_score': 1.0, '_source': {'title': '进入职业大洗牌时代,“吃香”职业还吃香吗?', 'url': 'https://new.qq.com/omn/20210828/20210828A025LK00.html'}}, {'_index': 'news', '_type': '_doc', '_id': 'j-bpkHsBm-BAny-7heZN', '_score': 1.0, '_source': {'title': '乘风破浪不负韶华,奋斗青春圆梦高考', 'url': 'http://view.inews.qq.com/a/EDU2021041600732200'}}, {'_index': 'news', '_type': '_doc', '_id': 'kObpkHsBm-BAny-7hean', '_score': 1.0, '_source': {'title': '他,活出了我们理想的样子', 'url': 'https://new.qq.com/omn/20210821/20210821A020ID00.html'}}]}}

可以看到,返回结果会出现在 hits 字段里面,其中 total 字段标明了查询的结果条目数,max_score 代表了最大匹配分数。

另外,我们还可以进行全文检索,这才是体现 Elasticsearch 搜索引擎特性的地方:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from elasticsearch import Elasticsearch
import json

dsl = {
'query': {
'match': {
'title': '高考 圆梦'
}
}
}

es = Elasticsearch()
result = es.search(index='news', body=dsl)
print(result)

这里我们使用 Elasticsearch 支持的 DSL 语句来进行查询,使用 match 指定全文检索,检索的字段是 title,内容是“中国 领事馆”,搜索结果如下:

1
{'took': 6, 'timed_out': False, '_shards': {'total': 1, 'successful': 1, 'skipped': 0, 'failed': 0}, 'hits': {'total': {'value': 2, 'relation': 'eq'}, 'max_score': 1.7796917, 'hits': [{'_index': 'news', '_type': '_doc', '_id': 'j-bpkHsBm-BAny-7heZN', '_score': 1.7796917, '_source': {'title': '乘风破浪不负韶华,奋斗青春圆梦高考', 'url': 'http://view.inews.qq.com/a/EDU2021041600732200'}}, {'_index': 'news', '_type': '_doc', '_id': 'jebpkHsBm-BAny-7hOYp', '_score': 0.81085134, '_source': {'title': '高考结局大不同', 'url': 'https://k.sina.com.cn/article_7571064628_1c3454734001011lz9.html'}}]}}

这里我们看到匹配的结果有两条,第一条的分数为 1.7796917,第二条的分数为 0.81085134,这是因为第一条匹配的数据中含有“高考”和“圆梦”两个词,第二条匹配的数据中不包含“圆梦”,但是包含了“高考”这个词,所以也被检索出来了,但是分数比较低。

因此,可以看出,检索时会对对应的字段进行全文检索,结果还会按照检索关键词的相关性进行排序,这就是一个基本的搜索引擎雏形。

另外,Elasticsearch 还支持非常多的查询方式,这里就不再一一展开描述了,总之其功能非常强大,详情可以参考官方文档:https://www.elastic.co/guide/en/elasticsearch/reference/master/query-dsl.html。

10. 总结

以上便是对 Elasticsearch 的基本介绍以及使用 Python 操作 Elasticsearch 的基本用法,但这仅仅是 Elasticsearch 的基本功能,它还有更多强大的功能等待着我们去探索。

本节代码地址:https://github.com/Python3WebSpider/ElasticSearchTest。

Python

爬虫系列文章总目录:【2022 年】Python3 爬虫学习教程,本教程内容多数来自于《Python3网络爬虫开发实战(第二版)》一书,目前截止 2022 年,可以将爬虫基本技术进行系统讲解,同时将最新前沿爬虫技术如异步、JavaScript 逆向、AST、安卓逆向、Hook、智能解析、群控技术、WebAssembly、大规模分布式、Docker、Kubernetes 等,市面上目前就仅有《Python3 网络爬虫开发实战(第二版)》一书了,点击了解详情

我们知道爬虫是 IO 密集型任务,比如如果我们使用 requests 库来爬取某个站点的话,发出一个请求之后,程序必须要等待网站返回响应之后才能接着运行,而在等待响应的过程中,整个爬虫程序是一直在等待的,实际上没有做任何事情。对于这种情况,我们有没有优化方案呢?

当然有,下面我们就来了解一下异步爬虫的基本概念和实现。

要实现异步机制的爬虫,那自然和协程脱不了关系。

1. 案例引入

在介绍协程之前,我们先来看一个案例网站,链接地址为:https://httpbin.org/delay/5,如果我们访问这个链接,需要等待五秒之后才能得到结果,这是因为服务器强制等待了 5 秒的时间才返回响应。

平时我们浏览网页的时候,绝大部分网页响应速度还是很快的,如果我们写爬虫来爬取的话,发出 Request 到收到 Response 的时间不会很长,因此我们需要等待的时间并不多。

然而像上面这个网站,一次 Request 就需要 5 秒才能得到 Response,如果我们用 requests 写爬虫来爬取的话,那每次 requests 都要等待 5 秒才能拿到结果了。

我们来测试下,下面我们来用 requests 写一个遍历程序,直接遍历 100 次试试看,实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import requests
import logging
import time

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

TOTAL_NUMBER = 100
URL = 'https://httpbin.org/delay/5'

start_time = time.time()
for _ in range(1, TOTAL_NUMBER + 1):
logging.info('scraping %s', URL)
response = requests.get(URL)
end_time = time.time()
logging.info('total time %s seconds', end_time - start_time)

这里我们直接用循环的方式构造了 100 个 Request,使用的是 requests 单线程,在爬取之前和爬取之后记录了时间,最后输出爬取了 100 个页面消耗的时间。

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
2020-08-03 01:01:36,781 - INFO: scraping https://httpbin.org/delay/5
2020-08-03 01:01:43,410 - INFO: scraping https://httpbin.org/delay/5
2020-08-03 01:01:50,029 - INFO: scraping https://httpbin.org/delay/5
2020-08-03 01:01:56,702 - INFO: scraping https://httpbin.org/delay/5
2020-08-03 01:02:03,345 - INFO: scraping https://httpbin.org/delay/5
2020-08-03 01:02:09,958 - INFO: scraping https://httpbin.org/delay/5
2020-08-03 01:02:16,500 - INFO: scraping https://httpbin.org/delay/5
2020-08-03 01:02:23,143 - INFO: scraping https://httpbin.org/delay/5
...
2020-08-03 01:12:19,867 - INFO: scraping https://httpbin.org/delay/5
2020-08-03 01:12:26,479 - INFO: scraping https://httpbin.org/delay/5
2020-08-03 01:12:33,083 - INFO: scraping https://httpbin.org/delay/5
2020-08-03 01:12:39,758 - INFO: total time 662.9764430522919 seconds

由于每个页面至少要等待 5 秒才能加载出来,因此 100 个页面至少要花费 500 秒的时间,加上网站本身负载的问题,总的爬取时间最终为 663 秒,大约 11 分钟。

这在实际情况下是很常见的,有些网站本身加载速度就比较慢,稍慢的可能 1~3 秒,更慢的说不定 10 秒以上。如果我们用 requests 单线程这么爬取的话,总的耗时是非常多的。此时如果我们开了多线程或多进程来爬取的话,其爬取速度确实会成倍提升,那是否有更好的解决方案呢?

本节就来了解一下使用协程来加速的方法,此种方法对于 IO 密集型任务非常有效。如将其应用到网络爬虫中,爬取效率甚至可以成百倍地提升。

2. 基础知识

在了解协程之前,我们首先了解一些基础概念,如阻塞和非阻塞、同步和异步、多进程和协程。

阻塞

阻塞状态指程序未得到所需计算资源时被挂起的状态。程序在等待某个操作完成期间,自身无法继续干别的事情,则称该程序在该操作上是阻塞的。

常见的阻塞形式有:网络 I/O 阻塞、磁盘 I/O 阻塞、用户输入阻塞等。阻塞是无处不在的,包括 CPU 切换上下文时,所有的进程都无法真正干事情,它们也会被阻塞。如果是多核 CPU,则正在执行上下文切换操作的核不可被利用。

非阻塞

程序在等待某操作的过程中,自身不被阻塞,可以继续运行干别的事情,则称该程序在该操作上是非阻塞的。

非阻塞并不是在任何程序级别、任何情况下都存在的。仅当程序封装的级别可以囊括独立的子程序单元时,它才可能存在非阻塞状态。

非阻塞的存在是因为阻塞存在,正因为某个操作阻塞导致的耗时与效率低下,我们才要把它变成非阻塞的。

同步

不同程序单元为了完成某个任务,在执行过程中需靠某种通信方式以协调一致,此时这些程序单元是同步执行的。

例如在购物系统中更新商品库存时,需要用“行锁”作为通信信号,让不同的更新请求强制排队顺序执行,那更新库存的操作是同步的。

简言之,同步意味着有序。

异步

为了完成某个任务,有时不同程序单元之间无须通信协调也能完成任务,此时不相关的程序单元之间可以是异步的。

例如,爬取下载网页。调度程序调用下载程序后,即可调度其他任务,而无须与该下载任务保持通信以协调行为。不同网页的下载、保存等操作都是无关的,也无须相互通知协调。这些异步操作的完成时刻并不确定。

简言之,异步意味着无序。

多进程

多进程就是利用 CPU 的多核优势,在同一时间并行执行多个任务,可以大大提高执行效率。

协程

协程,英文叫作 coroutine,又称微线程、纤程,它是一种用户态的轻量级线程。

协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。因此,协程能保留上一次调用时的状态,即所有局部状态的一个特定组合,每次过程重入时,就相当于进入上一次调用的状态。

协程本质上是个单进程,它相对于多进程来说,无须线程上下文切换的开销,无须原子操作锁定及同步的开销,编程模型也非常简单。

我们可以使用协程来实现异步操作,比如在网络爬虫场景下,我们发出一个请求之后,需要等待一定时间才能得到响应,但其实在这个等待过程中,程序可以干许多其他事情,等到响应得到之后才切换回来继续处理,这样可以充分利用 CPU 和其他资源,这就是协程的优势。

3. 协程的用法

接下来,让我们来了解一下协程的实现。从 Python 3.4 开始,Python 中加入了协程的概念,但这个版本的协程还是以生成器对象为基础,Python 3.5 则增加了 async/await,使得协程的实现更加方便。

Python 中使用协程最常用的库莫过于 asyncio,所以本节会以 asyncio 为基础来介绍协程的用法。

首先,我们需要了解下面几个概念:

  • event_loop:事件循环,相当于一个无限循环,我们可以把一些函数注册到这个事件循环上,当满足条件发生的时候,就会调用对应的处理方法。
  • coroutine:中文翻译叫协程,在 Python 中常指代协程对象类型,我们可以将协程对象注册到时间循环中,它会被事件循环调用。我们可以使用 async 关键字来定义一个方法,这个方法在调用时不会立即被执行,而是返回一个协程对象。
  • task:任务,它是对协程对象的进一步封装,包含了任务的各个状态。
  • future:代表将来执行或没有执行的任务的结果,实际上和 task 没有本质区别。

另外,我们还需要了解 async/await 关键字,它是从 Python 3.5 才出现的,专门用于定义协程。其中,async 定义一个协程,await 用来挂起阻塞方法的执行。

4. 准备工作

在本节开始之前,请确保安装的 Python 版本为 3.5 及以上,如果版本是 3.4 及以下,则下方的案例是不能运行的。

具体的安装方法可以参考:https://setup.scrape.center/python。

安装好合适的 Python 版本之后我们就可以开始本节的学习了。

5. 定义协程

首先,我们来定义一个协程,体验一下它和普通进程在实现上的不同之处,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
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')

运行结果如下:

1
2
3
4
Coroutine: <coroutine object execute at 0x1034cf830>
After calling execute
Number: 1
After calling loop

首先,我们引入了 asyncio 这个包,这样我们才可以使用 asyncawait,然后使用 async 定义了一个 execute 方法,该方法接收一个数字参数,执行之后会打印这个数字。

随后我们直接调用了这个方法,然而这个方法并没有执行,而是返回了一个 coroutine 协程对象。随后我们使用 get_event_loop 方法创建了一个事件循环 loop,并调用了 loop 对象的 run_until_complete 方法将协程注册到事件循环 loop 中,然后启动。最后,我们才看到 execute 方法打印了输出结果。

可见,async 定义的方法就会变成一个无法直接执行的 coroutine 对象,必须将其注册到事件循环中才可以执行。

前面我们还提到了 task,它是对 coroutine 对象的进一步封装,比 coroutine 对象多了运行状态,比如 runningfinished 等,我们可以用这些状态来获取协程对象的执行情况。

在上面的例子中,当我们将 coroutine 对象传递给 run_until_complete 方法的时候,实际上它进行了一个操作,就是将 coroutine 封装成了 task 对象。我们也可以显式地进行声明,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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')

运行结果如下:

1
2
3
4
5
6
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 对象,写法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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')

运行结果如下:

1
2
3
4
5
6
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

可以发现,其运行效果都是一样的。

6. 绑定回调

另外,我们也可以为某个 task 绑定一个回调方法。比如,我们来看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 方法就可以获取返回结果了。

运行结果如下:

1
2
3
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 方法获取结果,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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())

运行结果是一样的:

1
2
3
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]>

7. 多任务协程

上面的例子我们只执行了一次请求,如果想执行多次请求,应该怎么办呢?我们可以定义一个 task 列表,然后使用 asyncio 的 wait 方法即可执行。看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 循环创建了 5 个 task,组成了一个列表,然后把这个列表首先传递给了 asyncio 的 wait 方法,再将其注册到时间循环中,就可以发起 5 个任务了。最后,我们再将任务的运行结果输出出来,具体如下:

1
2
3
4
5
6
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]>

可以看到,5 个任务被顺次执行了,并得到了运行结果。

8. 协程实现

前面说了这么一通,又是 async,又是 coroutine,又是 task,又是 callback,但似乎并没有看出协程的优势?反而写法上更加奇怪和麻烦了。别急,上面的案例只是为后面的使用作铺垫。接下来,我们正式来看下协程在解决 IO 密集型任务上有怎样的优势。

在上面的代码中,我们用一个网络请求作为示例,这就是一个耗时等待操作,因为我们请求网页之后需要等待页面响应并返回结果。耗时等待操作一般都是 IO 操作,比如文件读取、网络请求等。协程对于处理这种操作是有很大优势的,当遇到需要等待的情况时,程序可以暂时挂起,转而去执行其他操作,从而避免一直等待一个程序而耗费过多的时间,充分利用资源。

为了表现出协程的优势,我们还是以本节开头介绍的网站 https://httpbin.org/delay/5 为例,因为该网站响应比较慢,所以我们可以通过爬取时间来直观感受到爬取速度的提升。

为了让大家更好地理解协程的正确使用方法,这里我们先来看看大家使用协程时常犯的错误,后面再给出正确的例子来对比一下。

首先,我们还是拿之前的 requests 库来进行网页请求,接下来再重新使用上面的方法请求一遍:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import asyncio
import requests
import time

start = time.time()

async def request():
url = 'https://httpbin.org/delay/5'
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 方法并注册到时间循环中执行。

运行结果如下:

1
2
3
4
5
6
7
8
9
10
Waiting for https://httpbin.org/delay/5
Get response from https://httpbin.org/delay/5 response <Response [200]>
Waiting for https://httpbin.org/delay/5
...
Get response from https://httpbin.org/delay/5 response <Response [200]>
Waiting for https://httpbin.org/delay/5
Get response from https://httpbin.org/delay/5 response <Response [200]>
Waiting for https://httpbin.org/delay/5
Get response from https://httpbin.org/delay/5 response <Response [200]>
Cost time: 66.64284420013428

可以发现,这和正常的请求并没有什么区别,依然还是顺次执行的,耗时 66 秒,平均一个请求耗时 6.6 秒,说好的异步处理呢?

其实,要实现异步处理,我们得先要有挂起的操作,当一个任务需要等待 IO 结果的时候,可以挂起当前任务,转而去执行其他任务,这样我们才能充分利用好资源。上面的方法都是一本正经地串行走下来,连个挂起都没有,怎么可能实现异步?想太多了。

要实现异步,接下来我们再了解一下 await 的用法,它可以将耗时等待的操作挂起,让出控制权。当协程执行的时候遇到 await,时间循环就会将本协程挂起,转而去执行别的协程,直到其他协程挂起或执行完毕。

所以,我们可能会将代码中的 request 方法改成如下的样子:

1
2
3
4
5
async def request():
url = 'https://httpbin.org/delay/5'
print('Waiting for', url)
response = await requests.get(url)
print('Get response from', url, 'response', response)

仅仅是在 requests 前面加了一个关键字 await,然而此时执行代码,会得到如下报错:

1
2
3
4
5
6
7
8
9
10
11
Waiting for https://httpbin.org/delay/5
Waiting for https://httpbin.org/delay/5
Waiting for https://httpbin.org/delay/5
Waiting for https://httpbin.org/delay/5
...
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):

  • 一个原生 coroutine 对象;
  • 一个由 types.coroutine 修饰的生成器,这个生成器可以返回 coroutine 对象;
  • 一个包含 __await__ 方法的对象返回的一个迭代器。

reqeusts 返回的 Response 对象不符合上面任一条件,因此就会报上面的错误了。

有的读者可能已经发现了,既然 await 后面可以跟一个 coroutine 对象,那么我用 async 把请求的方法改成 coroutine 对象不就可以了吗?所以就改写成如下的样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import asyncio
import requests
import time

start = time.time()

async def get(url):
return requests.get(url)

async def request():
url = 'https://httpbin.org/delay/5'
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 对象。运行一下看看:

1
2
3
4
5
6
7
8
9
10
11
12
Waiting for https://httpbin.org/delay/5
Get response fromhttps://httpbin.org/delay/5 response <Response [200]>
Waiting for https://httpbin.org/delay/5
Get response from https://httpbin.org/delay/5 response <Response [200]>
Waiting for https://httpbin.org/delay/5
...
Get response from https://httpbin.org/delay/5 response <Response [200]>
Waiting for https://httpbin.org/delay/5
Get response from https://httpbin.org/delay/5 response <Response [200]>
Waiting for https://httpbin.org/delay/5
Get response from https://httpbin.org/delay/5 response <Response [200]>
Cost time: 65.394437756259273

还是不行,它还不是异步执行的,也就是说我们仅仅将涉及 IO 操作的代码封装到 async 修饰的方法里面是不可行的。我们必须要使用支持异步操作的请求方式才可以实现真正的异步,所以这里就需要 aiohttp 派上用场了。

9. 使用 aiohttp

aiohttp 是一个支持异步请求的库,配合使用它和 asyncio,我们可以非常方便地实现异步请求操作。我们使用 pip3 安装即可:

1
pip3 install aiohttp

具体的安装方法可以参考:https://setup.scrape.center/aiohttp。

aiohttp 的官方文档链接为 https://aiohttp.readthedocs.io/,它分为两部分,一部分是 Client,一部分是 Server,详细的内容可以参考官方文档。

下面我们将 aiohttp 用上来,将代码改成如下样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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://httpbin.org/delay/5'
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 方法进行请求,结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
Waiting for https://httpbin.org/delay/5
Waiting for https://httpbin.org/delay/5
Waiting for https://httpbin.org/delay/5
Waiting for https://httpbin.org/delay/5
...
Get response from https://httpbin.org/delay/5 response <ClientResponse(https://httpbin.org/delay/5) [200 OK]>
<CIMultiDictProxy('Date': 'Sun, 09 Aug 2020 14:30:22 GMT', 'Content-Type': 'application/json', 'Content-Length': '360', 'Connection': 'keep-alive', 'Server': 'gunicorn/19.9.0', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Credentials': 'true')>

...
Get response from https://httpbin.org/delay/5 response <ClientResponse(https://httpbin.org/delay/5) [200 OK]>
<CIMultiDictProxy('Date': 'Sun, 09 Aug 2020 14:30:22 GMT', 'Content-Type': 'application/json', 'Content-Length': '360', 'Connection': 'keep-alive', 'Server': 'gunicorn/19.9.0', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Credentials': 'true')>
Cost time: 6.033240079879761

成功了!我们发现这次请求的耗时由 51 秒直接变成了 6 秒,耗费时间减少了非常多。

在代码里面,我们使用了 await,后面跟了 get 方法。在执行这 10 个协程的时候,如果遇到了 await,就会将当前协程挂起,转而去执行其他协程,直到其他协程也挂起或执行完毕,再执行下一个协程。

开始运行时,时间循环会运行第一个 task。针对第一个 task 来说,当执行到第一个 await 跟着的 get 方法时,它被挂起,但这个 get 方法第一步的执行是非阻塞的,挂起之后立马被唤醒,所以立即又进入执行,创建了 ClientSession 对象,接着遇到了第二个 await,调用了 session.get 请求方法,然后就被挂起了。由于请求需要耗时很久,所以一直没有被唤醒,好在第一个 task 被挂起了,那么接下来该怎么办呢?事件循环会寻找当前未被挂起的协程继续执行,于是就转而执行第二个 task 了,也是一样的流程操作,直到执行了第十个 tasksession.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 的情况下的耗时情况,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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 [1, 3, 5, 10, 15, 30, 50, 75, 100, 200, 500]:
test(number)

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
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

可以看到,即使我们增加了并发数量,但在服务器能承受高并发的前提下,其爬取速度几乎不太受影响。

综上所述,使用了异步请求之后,我们几乎可以在相同的时间内实现成百上千倍次的网络请求,把这个运用在爬虫中,速度提升可谓是非常可观了。

10. 总结

以上便是 Python 中协程的基本原理和用法,在后面一节中我们会详细介绍 aiohttp 的用法和爬取实战,实现快速高并发的爬取。

本节代码:https://github.com/Python3WebSpider/AsyncTest

Python

爬虫系列文章总目录:【2022 年】Python3 爬虫学习教程,本教程内容多数来自于《Python3 网络爬虫开发实战(第二版)》一书,目前截止 2022 年,可以将爬虫基本技术进行系统讲解,同时将最新前沿爬虫技术如异步、JavaScript 逆向、AST、安卓逆向、Hook、智能解析、群控技术、WebAssembly、大规模分布式、Docker、Kubernetes 等,市面上目前就仅有《Python3 网络爬虫开发实战(第二版)》一书了,点击了解详情

Playwright 是微软在 2020 年初开源的新一代自动化测试工具,它的功能类似于 Selenium、Pyppeteer 等,都可以驱动浏览器进行各种自动化操作。它的功能也非常强大,对市面上的主流浏览器都提供了支持,API 功能简洁又强大。虽然诞生比较晚,但是现在发展得非常火热。

1. Playwright 的特点

  • Playwright 支持当前所有主流浏览器,包括 Chrome 和 Edge(基于 Chromium)、Firefox、Safari(基于 WebKit) ,提供完善的自动化控制的 API。
  • Playwright 支持移动端页面测试,使用设备模拟技术可以使我们在移动 Web 浏览器中测试响应式 Web 应用程序。
  • Playwright 支持所有浏览器的 Headless 模式和非 Headless 模式的测试。
  • Playwright 的安装和配置非常简单,安装过程中会自动安装对应的浏览器和驱动,不需要额外配置 WebDriver 等。
  • Playwright 提供了自动等待相关的 API,当页面加载的时候会自动等待对应的节点加载,大大简化了 API 编写复杂度。

本节我们就来了解下 Playwright 的使用方法。

2. 安装

要使用 Playwright,需要 Python 3.7 版本及以上,请确保 Python 的版本符合要求。

要安装 Playwright,可以直接使用 pip3,命令如下:

1
pip3 install playwright

安装完成之后需要进行一些初始化操作:

1
playwright install

这时候 Playwrigth 会安装 Chromium, Firefox and WebKit 浏览器并配置一些驱动,我们不必关心中间配置的过程,Playwright 会为我们配置好。

具体的安装说明可以参考:https://setup.scrape.center/playwright。

安装完成之后,我们便可以使用 Playwright 启动 Chromium 或 Firefox 或 WebKit 浏览器来进行自动化操作了。

3. 基本使用

Playwright 支持两种编写模式,一种是类似 Pyppetter 一样的异步模式,另一种是像 Selenium 一样的同步模式,我们可以根据实际需要选择使用不同的模式。

我们先来看一个基本同步模式的例子:

1
2
3
4
5
6
7
8
9
10
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
for browser_type in [p.chromium, p.firefox, p.webkit]:
browser = browser_type.launch(headless=False)
page = browser.new_page()
page.goto('https://www.baidu.com')
page.screenshot(path=f'screenshot-{browser_type.name}.png')
print(page.title())
browser.close()

首先我们导入了 sync_playwright 方法,然后直接调用了这个方法,该方法返回的是一个 PlaywrightContextManager 对象,可以理解是一个浏览器上下文管理器,我们将其赋值为变量 p。

接着我们调用了 PlaywrightContextManager 对象的 chromium、firefox、webkit 属性依次创建了一个 Chromium、Firefox 以及 Webkit 浏览器实例,接着用一个 for 循环依次执行了它们的 launch 方法,同时设置了 headless 参数为 False。

注意:如果不设置为 False,默认是无头模式启动浏览器,我们看不到任何窗口。

launch 方法返回的是一个 Browser 对象,我们将其赋值为 browser 变量。然后调用 browser 的 new_page 方法,相当于新建了一个选项卡,返回的是一个 Page 对象,将其赋值为 page,这整个过程其实和 Pyppeteer 非常类似。接着我们就可以调用 page 的一系列 API 来进行各种自动化操作了,比如调用 goto,就是加载某个页面,这里我们访问的是百度的首页。接着我们调用了 page 的 screenshot 方法,参数传一个文件名称,这样截图就会自动保存为该图片名称,这里名称中我们加入了 browser_type 的 name 属性,代表浏览器的类型,结果分别就是 chromium, firefox, webkit。另外我们还调用了 title 方法,该方法会返回页面的标题,即 HTML 中 title 节点中的文字,也就是选项卡上的文字,我们将该结果打印输出到控制台。最后操作完毕,调用 browser 的 close 方法关闭整个浏览器,运行结束。

运行一下,这时候我们可以看到有三个浏览器依次启动并加载了百度这个页面,分别是 Chromium、Firefox 和 Webkit 三个浏览器,页面加载完成之后,生成截图、控制台打印结果就退出了。

这时候当前目录便会生成三个截图文件,都是百度的首页,文件名中都带有了浏览器的名称,如图所示:

控制台运行结果如下:

1
2
3
百度一下,你就知道
百度一下,你就知道
百度一下,你就知道

通过运行结果我们可以发现,我们非常方便地启动了三种浏览器并完成了自动化操作,并通过几个 API 就完成了截图和数据的获取,整个运行速度是非常快的,者就是 Playwright 最最基本的用法。

当然除了同步模式,Playwright 还提供异步的 API,如果我们项目里面使用了 asyncio,那就应该使用异步模式,写法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import asyncio
from playwright.async_api import async_playwright

async def main():
async with async_playwright() as p:
for browser_type in [p.chromium, p.firefox, p.webkit]:
browser = await browser_type.launch()
page = await browser.new_page()
await page.goto('https://www.baidu.com')
await page.screenshot(path=f'screenshot-{browser_type.name}.png')
print(await page.title())
await browser.close()

asyncio.run(main())

可以看到整个写法和同步模式基本类似,导入的时候使用的是 async_playwright 方法,而不再是 sync_playwright 方法。写法上添加了 async/await 关键字的使用,最后的运行效果是一样的。

另外我们注意到,这例子中使用了 with as 语句,with 用于上下文对象的管理,它可以返回一个上下文管理器,也就对应一个 PlaywrightContextManager 对象,无论运行期间是否抛出异常,它能够帮助我们自动分配并且释放 Playwright 的资源。

4. 代码生成

Playwright 还有一个强大的功能,那就是可以录制我们在浏览器中的操作并将代码自动生成出来,有了这个功能,我们甚至都不用写任何一行代码,这个功能可以通过 playwright 命令行调用 codegen 来实现,我们先来看看 codegen 命令都有什么参数,输入如下命令:

1
playwright codegen --help

结果类似如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Usage: npx playwright codegen [options] [url]

open page and generate code for user actions

Options:
-o, --output <file name> saves the generated script to a file
--target <language> language to use, one of javascript, python, python-async, csharp (default: "python")
-b, --browser <browserType> browser to use, one of cr, chromium, ff, firefox, wk, webkit (default: "chromium")
--channel <channel> Chromium distribution channel, "chrome", "chrome-beta", "msedge-dev", etc
--color-scheme <scheme> emulate preferred color scheme, "light" or "dark"
--device <deviceName> emulate device, for example "iPhone 11"
--geolocation <coordinates> specify geolocation coordinates, for example "37.819722,-122.478611"
--load-storage <filename> load context storage state from the file, previously saved with --save-storage
--lang <language> specify language / locale, for example "en-GB"
--proxy-server <proxy> specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"
--save-storage <filename> save context storage state at the end, for later use with --load-storage
--timezone <time zone> time zone to emulate, for example "Europe/Rome"
--timeout <timeout> timeout for Playwright actions in milliseconds (default: "10000")
--user-agent <ua string> specify user agent string
--viewport-size <size> specify browser viewport size in pixels, for example "1280, 720"
-h, --help display help for command

Examples:

$ codegen
$ codegen --target=python
$ codegen -b webkit https://example.com

可以看到这里有几个选项,比如 -o 代表输出的代码文件的名称;—target 代表使用的语言,默认是 python,即会生成同步模式的操作代码,如果传入 python-async 就会生成异步模式的代码;-b 代表的是使用的浏览器,默认是 Chromium,其他还有很多设置,比如 —device 可以模拟使用手机浏览器,比如 iPhone 11,—lang 代表设置浏览器的语言,—timeout 可以设置页面加载超时时间。

好,了解了这些用法,那我们就来尝试启动一个 Firefox 浏览器,然后将操作结果输出到 script.py 文件,命令如下:

1
playwright codegen -o script.py -b firefox

这时候就弹出了一个 Firefox 浏览器,同时右侧会输出一个脚本窗口,实时显示当前操作对应的代码。

我们可以在浏览器中做任何操作,比如打开百度,然后点击输入框并输入 nba,然后再点击搜索按钮,浏览器窗口如下:

可以看见浏览器中还会高亮显示我们正在操作的页面节点,同时还显示了对应的选择器字符串 input[name="wd"],右侧的窗口如图所示:

在操作过程中,该窗口中的代码就实时变化,可以看到这里生成了我们一系列操作的对应代码,比如在搜索框中输入 nba,就对应如下代码:

1
page.fill("input[name=\"wd\"]", "nba")

操作完毕之后,关闭浏览器,Playwright 会生成一个 script.py 文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from playwright.sync_api import sync_playwright

def run(playwright):
browser = playwright.firefox.launch(headless=False)
context = browser.new_context()

# Open new page
page = context.new_page()

# Go to https://www.baidu.com/
page.goto("https://www.baidu.com/")

# Click input[name="wd"]
page.click("input[name=\"wd\"]")

# Fill input[name="wd"]
page.fill("input[name=\"wd\"]", "nba")

# Click text=百度一下
with page.expect_navigation():
page.click("text=百度一下")

context.close()
browser.close()

with sync_playwright() as playwright:
run(playwright)

可以看到这里生成的代码和我们之前写的示例代码几乎差不多,而且也是完全可以运行的,运行之后就可以看到它又可以复现我们刚才所做的操作了。

所以,有了这个功能,我们甚至都不用编写任何代码,只通过简单的可视化点击就能把代码生成出来,可谓是非常方便了!

另外这里有一个值得注意的点,仔细观察下生成的代码,和前面的例子不同的是,这里 new_page 方法并不是直接通过 browser 调用的,而是通过 context 变量调用的,这个 context 又是由 browser 通过调用 new_context 方法生成的。有读者可能就会问了,这个 context 究竟是做什么的呢?

其实这个 context 变量对应的是一个 BrowserContext 对象,BrowserContext 是一个类似隐身模式的独立上下文环境,其运行资源是单独隔离的,在做一些自动化测试过程中,每个测试用例我们都可以单独创建一个 BrowserContext 对象,这样可以保证每个测试用例之间互不干扰,具体的 API 可以参考 https://playwright.dev/python/docs/api/class-browsercontext

5. 移动端浏览器支持

Playwright 另外一个特色功能就是可以支持移动端浏览器的模拟,比如模拟打开 iPhone 12 Pro Max 上的 Safari 浏览器,然后手动设置定位,并打开百度地图并截图。首先我们可以选定一个经纬度,比如故宫的经纬度是 39.913904, 116.39014,我们可以通过 geolocation 参数传递给 Webkit 浏览器并初始化。

示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
iphone_12_pro_max = p.devices['iPhone 12 Pro Max']
browser = p.webkit.launch(headless=False)
context = browser.new_context(
**iphone_12_pro_max,
locale='zh-CN',
geolocation={'longitude': 116.39014, 'latitude': 39.913904},
permissions=['geolocation']
)
page = context.new_page()
page.goto('https://amap.com')
page.wait_for_load_state(state='networkidle')
page.screenshot(path='location-iphone.png')
browser.close()

这里我们先用 PlaywrightContextManager 对象的 devices 属性指定了一台移动设备,这里传入的是手机的型号,比如 iPhone 12 Pro Max,当然也可以传其他名称,比如 iPhone 8,Pixel 2 等。

前面我们已经了解了 BrowserContext 对象,BrowserContext 对象也可以用来模拟移动端浏览器,初始化一些移动设备信息、语言、权限、位置等信息,这里我们就用它来创建了一个移动端 BrowserContext 对象,通过 geolocation 参数传入了经纬度信息,通过 permissions 参数传入了赋予的权限信息,最后将得到的 BrowserContext 对象赋值为 context 变量。

接着我们就可以用 BrowserContext 对象来新建一个页面,还是调用 new_page 方法创建一个新的选项卡,然后跳转到高德地图,并调用了 wait_for_load_state 方法等待页面某个状态完成,这里我们传入的 state 是 networkidle,也就是网络空闲状态。因为在页面初始化和加载过程中,肯定是伴随有网络请求的,所以加载过程中肯定不算 networkidle 状态,所以这里我们传入 networkidle 就可以标识当前页面和数据加载完成的状态。加载完成之后,我们再调用 screenshot 方法获取当前页面截图,最后关闭浏览器。

运行下代码,可以发现这里就弹出了一个移动版浏览器,然后加载了高德地图,并定位到了故宫的位置,如图所示:

输出的截图也是浏览器中显示的结果。

所以这样我们就成功实现了移动端浏览器的模拟和一些设置,其操作 API 和 PC 版浏览器是完全一样的。

6. 选择器

前面我们注意到 click 和 fill 等方法都传入了一个字符串,这些字符串有的符合 CSS 选择器的语法,有的又是 text= 开头的,感觉似乎没太有规律的样子,它到底支持怎样的匹配规则呢?下面我们来了解下。

传入的这个字符串,我们可以称之为 Element Selector,它不仅仅支持 CSS 选择器、XPath,Playwright 还扩展了一些方便好用的规则,比如直接根据文本内容筛选,根据节点层级结构筛选等等。

文本选择

文本选择支持直接使用 text= 这样的语法进行筛选,示例如下:

1
page.click("text=Log in")

这就代表选择文本是 Log in 的节点,并点击。

CSS 选择器

CSS 选择器之前也介绍过了,比如根据 id 或者 class 筛选:

1
2
page.click("button")
page.click("#nav-bar .contact-us-item")

根据特定的节点属性筛选:

1
2
page.click("[data-test=login-button]")
page.click("[aria-label='Sign in']")

CSS 选择器 + 文本

我们还可以使用 CSS 选择器结合文本值进行海选,比较常用的就是 has-text 和 text,前者代表包含指定的字符串,后者代表字符串完全匹配,示例如下:

1
2
page.click("article:has-text('Playwright')")
page.click("#nav-bar :text('Contact us')")

第一个就是选择文本中包含 Playwright 的 article 节点,第二个就是选择 id 为 nav-bar 节点中文本值等于 Contact us 的节点。

CSS 选择器 + 节点关系

还可以结合节点关系来筛选节点,比如使用 has 来指定另外一个选择器,示例如下:

1
page.click(".item-description:has(.item-promo-banner)")

比如这里选择的就是选择 class 为 item-description 的节点,且该节点还要包含 class 为 item-promo-banner 的子节点。

另外还有一些相对位置关系,比如 right-of 可以指定位于某个节点右侧的节点,示例如下:

1
page.click("input:right-of(:text('Username'))")

这里选择的就是一个 input 节点,并且该 input 节点要位于文本值为 Username 的节点的右侧。

XPath

当然 XPath 也是支持的,不过 xpath 这个关键字需要我们自行制定,示例如下:

1
page.click("xpath=//button")

这里需要在开头指定 xpath= 字符串,代表后面是一个 XPath 表达式。

关于更多选择器的用法和最佳实践,可以参考官方文档:https://playwright.dev/python/docs/selectors。

7. 常用操作方法

上面我们了解了浏览器的一些初始化设置和基本的操作实例,下面我们再对一些常用的操作 API 进行说明。

常见的一些 API 如点击 click,输入 fill 等操作,这些方法都是属于 Page 对象的,所以所有的方法都从 Page 对象的 API 文档查找就好了,文档地址:https://playwright.dev/python/docs/api/class-page。

下面介绍几个常见的 API 用法。

事件监听

Page 对象提供了一个 on 方法,它可以用来监听页面中发生的各个事件,比如 close、console、load、request、response 等等。

比如这里我们可以监听 response 事件,response 事件可以在每次网络请求得到响应的时候触发,我们可以设置对应的回调方法获取到对应 Response 的全部信息,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
from playwright.sync_api import sync_playwright

def on_response(response):
print(f'Statue {response.status}: {response.url}')

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()
page.on('response', on_response)
page.goto('https://spa6.scrape.center/')
page.wait_for_load_state('networkidle')
browser.close()

这里我们在创建 Page 对象之后,就开始监听 response 事件,同时将回调方法设置为 on_response,on_response 对象接收一个参数,然后把 Response 的状态码和链接都输出出来了。

运行之后,可以看到控制台输出结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Statue 200: https://spa6.scrape.center/
Statue 200: https://spa6.scrape.center/css/app.ea9d802a.css
Statue 200: https://spa6.scrape.center/js/app.5ef0d454.js
Statue 200: https://spa6.scrape.center/js/chunk-vendors.77daf991.js
Statue 200: https://spa6.scrape.center/css/chunk-19c920f8.2a6496e0.css
...
Statue 200: https://spa6.scrape.center/css/chunk-19c920f8.2a6496e0.css
Statue 200: https://spa6.scrape.center/js/chunk-19c920f8.c3a1129d.js
Statue 200: https://spa6.scrape.center/img/logo.a508a8f0.png
Statue 200: https://spa6.scrape.center/fonts/element-icons.535877f5.woff
Statue 301: https://spa6.scrape.center/api/movie?limit=10&offset=0&token=NGMwMzFhNGEzMTFiMzJkOGE0ZTQ1YjUzMTc2OWNiYTI1Yzk0ZDM3MSwxNjIyOTE4NTE5
Statue 200: https://spa6.scrape.center/api/movie/?limit=10&offset=0&token=NGMwMzFhNGEzMTFiMzJkOGE0ZTQ1YjUzMTc2OWNiYTI1Yzk0ZDM3MSwxNjIyOTE4NTE5
Statue 200: https://p0.meituan.net/movie/da64660f82b98cdc1b8a3804e69609e041108.jpg@464w_644h_1e_1c
Statue 200: https://p0.meituan.net/movie/283292171619cdfd5b240c8fd093f1eb255670.jpg@464w_644h_1e_1c
....
Statue 200: https://p1.meituan.net/movie/b607fba7513e7f15eab170aac1e1400d878112.jpg@464w_644h_1e_1c

注意:这里省略了部分重复的内容。

可以看到,这里的输出结果其实正好对应浏览器 Network 面板中所有的请求和响应内容,和下图是一一对应的:

这个网站我们之前分析过,其真实的数据都是 Ajax 加载的,同时 Ajax 请求中还带有加密参数,不好轻易获取。

但有了这个方法,这里如果我们想要截获 Ajax 请求,岂不是就非常容易了?

改写一下判定条件,输出对应的 JSON 结果,改写如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
from playwright.sync_api import sync_playwright

def on_response(response):
if '/api/movie/' in response.url and response.status == 200:
print(response.json())

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()
page.on('response', on_response)
page.goto('https://spa6.scrape.center/')
page.wait_for_load_state('networkidle')
browser.close()

控制台输入如下:

1
2
3
{'count': 100, 'results': [{'id': 1, 'name': '霸王别姬', 'alias': 'Farewell My Concubine', 'cover': 'https://p0.meituan.net/movie/ce4da3e03e655b5b88ed31b5cd7896cf62472.jpg@464w_644h_1e_1c', 'categories': ['剧情', '爱情'], 'published_at': '1993-07-26', 'minute': 171, 'score': 9.5, 'regions': ['中国大陆', '中国香港']},
...
'published_at': None, 'minute': 103, 'score': 9.0, 'regions': ['美国']}, {'id': 10, 'name': '狮子王', 'alias': 'The Lion King', 'cover': 'https://p0.meituan.net/movie/27b76fe6cf3903f3d74963f70786001e1438406.jpg@464w_644h_1e_1c', 'categories': ['动画', '歌舞', '冒险'], 'published_at': '1995-07-15', 'minute': 89, 'score': 9.0, 'regions': ['美国']}]}

简直是得来全不费工夫,我们直接通过这个方法拦截了 Ajax 请求,直接把响应结果拿到了,即使这个 Ajax 请求有加密参数,我们也不用关心,因为我们直接截获了 Ajax 最后响应的结果,这对数据爬取来说实在是太方便了。

另外还有很多其他的事件监听,这里不再一一介绍了,可以查阅官方文档,参考类似的写法实现。

获取页面源码

要获取页面的 HTML 代码其实很简单,我们直接通过 content 方法获取即可,用法如下:

1
2
3
4
5
6
7
8
9
10
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()
page.goto('https://spa6.scrape.center/')
page.wait_for_load_state('networkidle')
html = page.content()
print(html)
browser.close()

运行结果就是页面的 HTML 代码。获取了 HTML 代码之后,我们通过一些解析工具就可以提取想要的信息了。

页面点击

刚才我们通过示例也了解了页面点击的方法,那就是 click,这里详细说一下其使用方法。

页面点击的 API 定义如下:

1
page.click(selector, **kwargs)

这里可以看到必传的参数是 selector,其他的参数都是可选的。第一个 selector 就代表选择器,可以用来匹配想要点击的节点,如果传入的选择器匹配了多个节点,那么只会用第一个节点。

这个方法的内部执行逻辑如下:

  • 根据 selector 找到匹配的节点,如果没有找到,那就一直等待直到超时,超时时间可以由额外的 timeout 参数设置,默认是 30 秒。
  • 等待对该节点的可操作性检查的结果,比如说如果某个按钮设置了不可点击,那它会等待该按钮变成了可点击的时候才去点击,除非通过 force 参数设置跳过可操作性检查步骤强制点击。
  • 如果需要的话,就滚动下页面,将需要被点击的节点呈现出来。
  • 调用 page 对象的 mouse 方法,点击节点中心的位置,如果指定了 position 参数,那就点击指定的位置。

click 方法的一些比较重要的参数如下:

  • click_count:点击次数,默认为 1。
  • timeout:等待要点击的节点的超时时间,默认是 30 秒。
  • position:需要传入一个字典,带有 x 和 y 属性,代表点击位置相对节点左上角的偏移位置。
  • force:即使不可点击,那也强制点击。默认是 False。

具体的 API 设置参数可以参考官方文档:https://playwright.dev/python/docs/api/class-page/#pageclickselector-kwargs。

文本输入

文本输入对应的方法是 fill,API 定义如下:

1
page.fill(selector, value, **kwargs)

这个方法有两个必传参数,第一个参数也是 selector,第二个参数是 value,代表输入的内容,另外还可以通过 timeout 参数指定对应节点的最长等待时间。

获取节点属性

除了对节点进行操作,我们还可以获取节点的属性,方法就是 get_attribute,API 定义如下:

1
page.get_attribute(selector, name, **kwargs)

这个方法有两个必传参数,第一个参数也是 selector,第二个参数是 name,代表要获取的属性名称,另外还可以通过 timeout 参数指定对应节点的最长等待时间。

示例如下:

1
2
3
4
5
6
7
8
9
10
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()
page.goto('https://spa6.scrape.center/')
page.wait_for_load_state('networkidle')
href = page.get_attribute('a.name', 'href')
print(href)
browser.close()

这里我们调用了 get_attribute 方法,传入的 selector 是 a.name,选定了 class 为 name 的 a 节点,然后第二个参数传入了 href,获取超链接的内容,输出结果如下:

1
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx

可以看到对应 href 属性就获取出来了,但这里只有一条结果,因为这里有个条件,那就是如果传入的选择器匹配了多个节点,那么只会用第一个节点。

那怎么获取所有的节点呢?

获取多个节点

获取所有节点可以使用 query_selector_all 方法,它可以返回节点列表,通过遍历获取到单个节点之后,我们可以接着调用单个节点的方法来进行一些操作和属性获取,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()
page.goto('https://spa6.scrape.center/')
page.wait_for_load_state('networkidle')
elements = page.query_selector_all('a.name')
for element in elements:
print(element.get_attribute('href'))
print(element.text_content())
browser.close()

这里我们通过 query_selector_all 方法获取了所有匹配到的节点,每个节点对应的是一个 ElementHandle 对象,然后 ElementHandle 对象也有 get_attribute 方法来获取节点属性,另外还可以通过 text_content 方法获取节点文本。

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx
霸王别姬 - Farewell My Concubine
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIy
这个杀手不太冷 - Léon
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIz
肖申克的救赎 - The Shawshank Redemption
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWI0
泰坦尼克号 - Titanic
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWI1
罗马假日 - Roman Holiday
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWI2
唐伯虎点秋香 - Flirting Scholar
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWI3
乱世佳人 - Gone with the Wind
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWI4
喜剧之王 - The King of Comedy
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWI5
楚门的世界 - The Truman Show
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIxMA==
狮子王 - The Lion King

获取单个节点

获取单个节点也有特定的方法,就是 query_selector,如果传入的选择器匹配到多个节点,那它只会返回第一个节点,示例如下:

1
2
3
4
5
6
7
8
9
10
11
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()
page.goto('https://spa6.scrape.center/')
page.wait_for_load_state('networkidle')
element = page.query_selector('a.name')
print(element.get_attribute('href'))
print(element.text_content())
browser.close()

运行结果如下:

1
2
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx
霸王别姬 - Farewell My Concubine

可以看到这里只输出了第一个匹配节点的信息。

网络劫持

最后再介绍一个实用的方法 route,利用 route 方法,我们可以实现一些网络劫持和修改操作,比如修改 request 的属性,修改 response 响应结果等。

看一个实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from playwright.sync_api import sync_playwright
import re

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()

def cancel_request(route, request):
route.abort()

page.route(re.compile(r"(\.png)|(\.jpg)"), cancel_request)
page.goto("https://spa6.scrape.center/")
page.wait_for_load_state('networkidle')
page.screenshot(path='no_picture.png')
browser.close()

这里我们调用了 route 方法,第一个参数通过正则表达式传入了匹配的 URL 路径,这里代表的是任何包含 .png.jpg 的链接,遇到这样的请求,会回调 cancel_request 方法处理,cancel_request 方法可以接收两个参数,一个是 route,代表一个 CallableRoute 对象,另外一个是 request,代表 Request 对象。这里我们直接调用了 route 的 abort 方法,取消了这次请求,所以最终导致的结果就是图片的加载全部取消了。

观察下运行结果,如图所示:

可以看到图片全都加载失败了。

这个设置有什么用呢?其实是有用的,因为图片资源都是二进制文件,而我们在做爬取过程中可能并不想关心其具体的二进制文件的内容,可能只关心图片的 URL 是什么,所以在浏览器中是否把图片加载出来就不重要了。所以如此设置之后,我们可以提高整个页面的加载速度,提高爬取效率。

另外,利用这个功能,我们还可以将一些响应内容进行修改,比如直接修改 Response 的结果为自定义的文本文件内容。

首先这里定义一个 HTML 文本文件,命名为 custom_response.html,内容如下:

1
2
3
4
5
6
7
8
9
<!DOCTYPE html>
<html>
<head>
<title>Hack Response</title>
</head>
<body>
<h1>Hack Response</h1>
</body>
</html>

代码编写如下:

1
2
3
4
5
6
7
8
9
10
11
12
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()

def modify_response(route, request):
route.fulfill(path="./custom_response.html")

page.route('/', modify_response)
page.goto("https://spa6.scrape.center/")
browser.close()

这里我们使用 route 的 fulfill 方法指定了一个本地文件,就是刚才我们定义的 HTML 文件,运行结果如下:

可以看到,Response 的运行结果就被我们修改了,URL 还是不变的,但是结果已经成了我们修改的 HTML 代码。

所以通过 route 方法,我们可以灵活地控制请求和响应的内容,从而在某些场景下达成某些目的。

8. 总结

本节介绍了 Playwright 的基本用法,其 API 强大又易于使用,同时具备很多 Selenium、Pyppeteer 不具备的更好用的 API,是新一代 JavaScript 渲染页面的爬取利器。

本节代码:https://github.com/Python3WebSpider/PlaywrightTest。

Python

系列文章总目录:【2022 年】Python3 爬虫学习教程,本教程内容多数来自于《Python3 网络爬虫开发实战(第二版)》一书,目前截止 2022 年,可以将爬虫基本技术进行系统讲解,同时将最新前沿爬虫技术如异步、JavaScript 逆向、AST、安卓逆向、Hook、智能解析、群控技术、WebAssembly、大规模分布式、Docker、Kubernetes 等,市面上目前就仅有《Python3 网络爬虫开发实战(第二版)》一书了,点击了解详情

在上一节中我们已经学习了 Ajax 的基本原理和分析方法,这一节我们来结合一个实际的案例来看一下 Ajax 分析和爬取页面的具体实现。

1. 准备工作

在本节开始之前,我们需要做好如下准备工作:

  • 安装好 Python 3(最低为 3.6 版本),并成功运行 Python 3 程序。
  • 了解 Python HTTP 请求库 requests 的基本用法。
  • 了解 Ajax 基础知识和分析 Ajax 的基本方法。

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

2. 爬取目标

本节我们以一个示例网站来试验一下 Ajax 的爬取,其链接为:https://spa1.scrape.center/,该示例网站的数据请求是通过 Ajax 完成的,页面的内容是通过 JavaScript 渲染出来的,页面如图所示:

image-20210705004644681

可能大家看着这个页面似曾相识,心想这不就是上一个案例的网站吗?但其实不是。这个网站的后台实现逻辑和数据加载方式完全不同。只不过最后呈现的样式是一样的。

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

image-20210705004704636

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

image-20210705004718813

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

本节中我们需要完成的目标如下。

  • 分析页面数据的加载逻辑。
  • 用 requests 实现 Ajax 数据的爬取。
  • 将每部电影的数据保存成一个 JSON 数据文件。

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

好,我们现在就开始吧。

3. 初步探索

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

1
2
3
4
5
import requests

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

运行结果如下:

1
<!DOCTYPE html><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=/favicon.ico><title>Scrape | Movie</title><link href=/css/chunk-700f70e1.1126d090.css rel=prefetch><link href=/css/chunk-d1db5eda.0ff76b36.css rel=prefetch><link href=/js/chunk-700f70e1.0548e2b4.js rel=prefetch><link href=/js/chunk-d1db5eda.b564504d.js rel=prefetch><link href=/css/app.ea9d802a.css rel=preload as=style><link href=/js/app.1435ecd5.js rel=preload as=script><link href=/js/chunk-vendors.77daf991.js rel=preload as=script><link href=/css/app.ea9d802a.css rel=stylesheet></head><body><noscript><strong>We're sorry but portal doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id=app></div><script src=/js/chunk-vendors.77daf991.js></script><script src=/js/app.1435ecd5.js></script></body></html>

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

image-20210705004644681

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

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

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

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

4. 爬取列表页

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

image-20210705004826230

接着重新刷新页面,再点击第二页、第三页、第四页的按钮,这时候可以观察到页面上的数据发生了变化,同时开发者工具下方就监听到了几个 Ajax 请求,如图所示:

image-20210705004904893

由于我们切换了 4 页,每次翻页也出现了对应的 Ajax 请求,我们可以点击查看其请求详情。观察其请求的 URL 和参数以及响应内容是怎样的,如图所示。

image-20210705004957327

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

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

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

image-20210705005115792

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

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

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

1
2
3
4
5
6
7
import requests
import logging

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

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

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

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

1
2
3
4
5
6
7
8
9
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 字符串。

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

1
2
3
4
5
LIMIT = 10

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

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

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

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

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

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

5. 爬取详情页

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

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

image-20210705005243372

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

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

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

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

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

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

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

1
2
3
4
5
DETAIL_URL = 'https://spa1.scrape.center/api/movie/{id}'

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
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)

if __name__ == '__main__':
main()

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

运行结果如下:

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

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

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

6. 保存数据

好,成功提取到详情页信息之后,我们下一步就要把数据保存起来了。在前面我们学习了 MongoDB 的相关操作,接下来我们就把数据保存到 MongoDB 吧。

在这之前,请确保现在有一个可以正常连接和使用的 MongoDB 数据库,这里我就以本地 localhost 的 M 哦能够 DB 数据库为例来进行操作,其运行在 27017 端口上,无用户名和密码。

将数据导入 MongoDB 需要用到 PyMongo 这个库。接下来我们把它们引入一下,然后同时定义一下 MongoDB 的连接配置,实现如下:

1
2
3
4
5
6
7
8
MONGO_CONNECTION_STRING = 'mongodb://localhost:27017'
MONGO_DB_NAME = 'movies'
MONGO_COLLECTION_NAME = 'movies'

import pymongo
client = pymongo.MongoClient(MONGO_CONNECTION_STRING)
db = client['movies']
collection = db['movies']

在这里我们声明了几个变量,介绍如下:

  • MONGO_CONNECTION_STRING:MongoDB 的连接字符串,里面定义了 MongoDB 的基本连接信息,如 host、port,还可以定义用户名密码等内容。
  • MONGO_DB_NAME:MongoDB 数据库的名称。
  • MONGO_COLLECTION_NAME:MongoDB 的集合名称。

这里我们用 MongoClient 声明了一个连接对象,然后依次声明了存储的数据库和集合。

接下来,我们再实现一个将数据保存到 MongoDB 的方法,实现如下:

1
2
3
4
5
6
def save_data(data):
collection.update_one({
'name': data.get('name')
}, {
'$set': data
}, upsert=True)

在这里我们声明了一个 save_data 方法,它接收一个 data 参数,也就是我们刚才提取的电影详情信息。在方法里面,我们调用了 update_one 方法,第一个参数是查询条件,即根据 name 进行查询;第二个参数就是 data 对象本身,就是所有的数据,这里我们用 $set 操作符表示更新操作;第三个参数很关键,这里实际上是 upsert 参数,如果把这个设置为 True,则可以做到存在即更新,不存在即插入的功能,更新会根据第一个参数设置的 name 字段,所以这样可以防止数据库中出现同名的电影数据。

注:实际上电影可能有同名,但该场景下的爬取数据没有同名情况,当然这里更重要的是实现 MongoDB 的去重操作。

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

1
2
3
4
5
6
7
8
9
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)
logging.info('data saved successfully')

这里就是加了 save_data 方法的调用,并加了一些日志信息。

重新运行,我们看下输出结果:

1
2
3
4
5
2020-03-19 02:51:06,323 - INFO: scraping https://spa1.scrape.center/api/movie/?limit=10&offset=0...
2020-03-19 02:51:06,440 - INFO: scraping https://spa1.scrape.center/api/movie/1...
2020-03-19 02:51:06,551 - 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': '程蝶衣', 'image': 'https://p0.meituan.net/movie/5de69a492dcbd3f4b014503d4e95d46c28837.jpg@128w_170h_1e_1c'}, ..., {'name': '方征', 'role': '嫖客', 'image': 'https://p1.meituan.net/movie/39687137b23bc9727b47fd24bdcc579b97618.jpg@128w_170h_1e_1c'}], 'directors': [{'name': '陈凯歌', 'image': 'https://p0.meituan.net/movie/8f9372252050095067e0e8d58ef3d939156407.jpg@128w_170h_1e_1c'}], 'score': 9.5, 'rank': 1, 'minute': 171, 'drama': '影片借一出《霸王别姬》的京戏,牵扯出三个人之间一段随时代风云变幻的爱恨情仇。段小楼(张丰毅 饰)与程蝶衣(张国荣 饰)是一对打小一起长大的师兄弟,两人一个演生,一个饰旦,一向配合天衣无缝,尤其一出《霸王别姬》,更是誉满京城,为此,两人约定合演一辈子《霸王别姬》。但两人对戏剧与人生关系的理解有本质不同,段小楼深知戏非人生,程蝶衣则是人戏不分。段小楼在认为该成家立业之时迎娶了名妓菊仙(巩俐 饰),致使程蝶衣认定菊仙是可耻的第三者,使段小楼做了叛徒,自此,三人围绕一出《霸王别姬》生出的爱恨情仇战开始随着时代风云的变迁不断升级,终酿成悲剧。', 'photos': ['https://p0.meituan.net/movie/45be438368bb291e501dc523092f0ac8193424.jpg@106w_106h_1e_1c', ..., 'https://p0.meituan.net/movie/0d952107429db3029b64bf4f25bd762661696.jpg@106w_106h_1e_1c'], 'published_at': '1993-07-26', 'updated_at': '2020-03-07T16:31:36.967843Z'}
2020-03-19 02:51:06,583 - INFO: data saved successfully
2020-03-19 02:51:06,583 - INFO: scraping https://spa1.scrape.center/api/movie/2...

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

我们可以看到这里我们成功爬取到了数据,并且提示了数据存储成功的信息,没有任何报错信息。

接下来我们使用 Robo 3T 连接 MongoDB 数据库看下爬取的结果,由于我使用的是本地的 MongoDB,所以在 Robo 3T 里面我直接输入 localhost 的连接信息即可,这里请替换成自己的 MongoDB 连接信息,如图所示:

连接之后我们便可以在 movies 这个数据库,movies 这个集合下看到我们刚才爬取的数据了,如图所示:

可以看到数据就是以 JSON 格式存储的,一条数据就对应一部电影的信息,各种嵌套信息也一目了然,同时第三列还有数据类型标识。

这样就证明我们的数据就成功存储到 MongoDB 里了。

7. 总结

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

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

本节代码:https://github.com/Python3WebSpider/ScrapeSpa1。

Python

爬虫系列文章总目录:【2022 年】Python3 爬虫学习教程,本教程内容多数来自于《Python3 网络爬虫开发实战(第二版)》一书,目前截止 2022 年,可以将爬虫基本技术进行系统讲解,同时将最新前沿爬虫技术如异步、JavaScript 逆向、AST、安卓逆向、Hook、智能解析、群控技术、WebAssembly、大规模分布式、Docker、Kubernetes 等,市面上目前就仅有《Python3 网络爬虫开发实战(第二版)》一书了,点击了解详情

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

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

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

1. 实例引入

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

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

2. 基本原理

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

  1. 发送请求
  2. 解析内容
  3. 渲染网页

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

发送请求

我们知道 JavaScript 可以实现页面的各种交互功能,Ajax 也不例外,它也是由 JavaScript 实现的,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 属性设置了监听,然后调用 opensend 方法向某个链接(也就是服务器)发送了请求。前面用 Python 实现请求发送之后,可以得到响应结果,但这里请求的发送变成 JavaScript 来完成。由于设置了监听,所以当服务器返回响应时,onreadystatechange 对应的方法便会被触发,然后在这个方法里面解析响应内容即可。

解析内容

得到响应之后,onreadystatechange 属性对应的方法便会被触发,此时利用 xmlhttpresponseText 属性便可取到响应内容。这类似于 Python 中利用 requests 向服务器发起请求,然后得到响应的过程。那么返回内容可能是 HTML,可能是 JSON,接下来只需要在方法中用 JavaScript 进一步处理即可。比如,如果是 JSON 的话,可以进行解析和转化。

渲染网页

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

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

我们观察到,这 3 个步骤其实都是由 JavaScript 完成的,它完成了整个请求、解析和渲染的过程。

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

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

3. 总结

本节我们简单了解了 Ajax 请求的基本原理和带来的页面加载效果,下一节我们来介绍下怎么来分析 Ajax 请求。

Python

爬虫系列文章总目录:【2022 年】Python3 爬虫学习教程,本教程内容多数来自于《Python3 网络爬虫开发实战(第二版)》一书,目前截止 2022 年,可以将爬虫基本技术进行系统讲解,同时将最新前沿爬虫技术如异步、JavaScript 逆向、AST、安卓逆向、Hook、智能解析、群控技术、WebAssembly、大规模分布式、Docker、Kubernetes 等,市面上目前就仅有《Python3 网络爬虫开发实战(第二版)》一书了,点击了解详情

JSON,全称为 JavaScript Object Notation, 也就是 JavaScript 对象标记,它通过对象和数组的组合来表示数据,构造简洁但是结构化程度非常高,是一种轻量级的数据交换格式。

本节中,我们就来了解如何利用 Python 保存数据到 JSON 文件。

1. 对象和数组

在 JavaScript 语言中,一切都是对象。因此,任何支持的类型都可以通过 JSON 来表示,例如字符串、数字、对象、数组等,但是对象和数组是比较特殊且常用的两种类型,下面简要介绍一下它们。

  • 对象:它在 JavaScript 中是使用花括号 {} 包裹起来的内容,数据结构为 {key1:value1, key2:value2, ...} 的键值对结构。在面向对象的语言中,key 为对象的属性,value 为对应的值。键名可以使用整数和字符串来表示。值的类型可以是任意类型。

  • 数组:数组在 JavaScript 中是方括号 [] 包裹起来的内容,数据结构为 ["java", "javascript", "vb", ...] 的索引结构。在 JavaScript 中,数组是一种比较特殊的数据类型,它也可以像对象那样使用键值对,但还是索引用得多。同样,值的类型可以是任意类型。

所以,一个 JSON 对象可以写为如下形式:

1
2
3
4
5
6
7
8
9
10
11
12
[
{
name: "Bob",
gender: "male",
birthday: "1992-10-18",
},
{
name: "Selina",
gender: "female",
birthday: "1995-10-18",
},
];

由中括号包围的就相当于列表类型,列表中的每个元素可以是任意类型,这个示例中它是字典类型,由大括号包围。

JSON 可以由以上两种形式自由组合而成,可以无限次嵌套,结构清晰,是数据交换的极佳方式。

2. 读取 JSON

Python 为我们提供了简单易用的 JSON 库来实现 JSON 文件的读写操作,我们可以调用 JSON 库的 loads 方法将 JSON 文本字符串转为 JSON 对象,实际上 JSON 对象为 Python 中的 list 和 dict 的嵌套和组合,这里称之为 JSON 对象。另外我们还可以通过 dumps 方法将 JSON 对象转为文本字符串。

例如,这里有一段 JSON 形式的字符串,它是 str 类型,我们用 Python 将其转换为可操作的数据结构,如列表或字典:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import json

str = '''
[{
"name": "Bob",
"gender": "male",
"birthday": "1992-10-18"
}, {
"name": "Selina",
"gender": "female",
"birthday": "1995-10-18"
}]
'''
print(type(str))
data = json.loads(str)
print(data)
print(type(data))

运行结果如下:

1
2
3
<class'str'>
[{'name': 'Bob', 'gender': 'male', 'birthday': '1992-10-18'}, {'name': 'Selina', 'gender': 'female', 'birthday': '1995-10-18'}]
<class 'list'>

这里使用 loads 方法将字符串转为 JSON 对象。由于最外层是中括号,所以最终的类型是列表类型。

这样一来,我们就可以用索引来获取对应的内容了。例如,如果想取第一个元素里的 name 属性,就可以使用如下方式:

1
2
data[0]['name']
data[0].get('name')

得到的结果都是:

1
Bob

通过中括号加 0 索引,可以得到第一个字典元素,然后再调用其键名即可得到相应的键值。获取键值时有两种方式,一种是中括号加键名,另一种是通过 get 方法传入键名。这里推荐使用 get 方法,这样如果键名不存在,则不会报错,会返回 None。另外,get 方法还可以传入第二个参数(即默认值),示例如下:

1
2
data[0].get('age')
data[0].get('age', 25)

运行结果如下:

1
2
None
25

这里我们尝试获取年龄 age,其实在原字典中该键名不存在,此时默认会返回 None。如果传入第二个参数(即默认值),那么在不存在的情况下返回该默认值。

值得注意的是,JSON 的数据需要用双引号来包围,不能使用单引号。例如,若使用如下形式表示,则会出现错误:

1
2
3
4
5
6
7
8
9
10
import json

str = '''
[{
'name': 'Bob',
'gender': 'male',
'birthday': '1992-10-18'
}]
'''
data = json.loads(str)

运行结果如下:

1
json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes: line 3 column 5 (char 8)

这里会出现 JSON 解析错误的提示。这是因为这里数据用单引号来包围,请千万注意 JSON 字符串的表示需要用双引号,否则 loads 方法会解析失败。

如果从 JSON 文本中读取内容,例如这里有一个 data.json 文本文件,其内容是刚才定义的 JSON 字符串,我们可以先将文本文件内容读出,然后再利用 loads 方法转化:

1
2
3
4
5
6
import json

with open('data.json', encoding='utf-8') as file:
str = file.read()
data = json.loads(str)
print(data)

运行结果如下:

1
[{'name': 'Bob', 'gender': 'male', 'birthday': '1992-10-18'}, {'name': 'Selina', 'gender': 'female', 'birthday': '1995-10-18'}]

这里我们用 open 方法读取了文本文件,同时使用了默认的读模式,编码指定为 utf-8,文件操作对象赋值为 file。接着我们调用了 file 对象的 read 方法读取了文本的所有内容,赋值为 str。然后再调用 loads 方法解析 JSON 字符串,将其转化为 JSON 对象。

这里其实也有更简便的写法,我们可以直接使用 load 方法传入文件操作对象,同样也可以将文本转化为 JSON 对象,写法如下:

1
2
3
4
import json

data = json.load(open('data.json', encoding='utf-8'))
print(data)

注意这里使用的是 load 方法,而不是 loads 方法。前者的参数是一个文件操作对象,后者的参数是一个 JSON 字符串。

这两种写法的运行结果也是完全一样的。只不过 load 方法是将整个文件的内容转化为 JSON 对象,而使用 loads 方法可以更灵活地控制要转化的内容。两种方法可以在适当的场景下使用。

3. 输出 JSON

另外,我们还可以调用 dumps 方法将 JSON 对象转化为字符串。例如,将上例中的列表重新写入文本:

1
2
3
4
5
6
7
8
9
import json

data = [{
'name': 'Bob',
'gender': 'male',
'birthday': '1992-10-18'
}]
with open('data.json', 'w', encoding='utf-8') as file:
file.write(json.dumps(data))

利用 dumps 方法,我们可以将 JSON 对象转为字符串,然后再调用文件的 write 方法写入文本,结果如图所示。

写入结果

另外,如果想保存 JSON 的格式缩进,可以再加一个参数 indent,代表缩进字符个数。示例如下:

1
2
with open('data.json', 'w') as file:
file.write(json.dumps(data, indent=2))

此时写入结果如图所示。

写入结果

这样得到的内容会自动带缩进,格式会更加清晰。

另外,如果 JSON 中包含中文字符,会怎么样呢?例如,我们将之前的 JSON 的部分值改为中文,再用之前的方法写入到文本:

1
2
3
4
5
6
7
8
9
import json

data = [{
'name': '王伟',
'gender': '男',
'birthday': '1992-10-18'
}]
with open('data.json', 'w', encoding='utf-8') as file:
file.write(json.dumps(data, indent=2))

写入结果如图所示。

写入结果

可以看到,中文字符都变成了 Unicode 字符,这并不是我们想要的结果。

为了输出中文,还需要指定参数 ensure_ascii 为 False,另外还要规定文件输出的编码:

1
2
with open('data.json', 'w', encoding='utf-8') as file:
file.write(json.dumps(data, indent=2, ensure_ascii=False))

写入结果如图所示。

写入结果

可以发现,这样就可以输出 JSON 为中文了。

同样地,类比 loads 与 load 方法,dumps 也有对应的 dump 方法,它可以直接将 JSON 对象全部写入到文件中,因此上述的写法也可以写为如下形式:

1
json.dump(data, open('data.json', 'w', encoding='utf-8'), indent=2, ensure_ascii=False)

这里第一个参数就是 JSON 对象,第二个参数可以传入文件操作对象,其他的 indent、ensure_ascii 对象还是保持不变,运行效果是一样的。

4. 总结

本节中,我们了解了用 Python 进行 JSON 文件读写的方法,后面做数据解析时经常会用到,建议熟练掌握。

本节代码:https://github.com/Python3WebSpider/FileStorageTest。

Python

爬虫系列文章总目录:【2022 年】Python3 爬虫学习教程,本教程内容多数来自于《Python3网络爬虫开发实战(第二版)》一书,目前截止 2022 年,可以将爬虫基本技术进行系统讲解,同时将最新前沿爬虫技术如异步、JavaScript 逆向、AST、安卓逆向、Hook、智能解析、群控技术、WebAssembly、大规模分布式、Docker、Kubernetes 等,市面上目前就仅有《Python3 网络爬虫开发实战(第二版)》一书了,点击了解详情

前文我们了解了 lxml 使用 XPath 和 pyquery 使用 CSS Selector 来提取页面内容的方法,不论是 XPath 还是 CSS Selector,对于绝大多数的内容提取都足够了,大家可以选择适合自己的库来做内容提取。

不过这时候有人可能会问:我能不能二者穿插使用呀?有时候做内容提取的时候觉得 XPath 写起来比较方便,有时候觉得 CSS Selector 写起来比较方便,能不能二者结合起来使用呢?答案是可以的。

这里我们就介绍另一个解析库,叫做 parsel。

注意:如果你用过 Scrapy 框架(后文会介绍)的话,你会发现 parsel 的 API 和 Scrapy 选择器的 API 极其相似,这是因为 Scrapy 的选择器就是基于 parsel 做了二次封装,因此学会了这个库的用法,后文 Scrapy 选择器的用法就融会贯通了。

1. 介绍

parsel 这个库可以对 HTML 和 XML 进行解析,并支持使用 XPath 和 CSS Selector 对内容进行提取和修改,同时它还融合了正则表达式提取的功能。功能灵活而又强大,同时它也是 Python 最流行爬虫框架 Scrapy 的底层支持。

2. 准备工作

在本节开始之前,请确保已经安装好了 parsel 库,如尚未安装,可以使用 pip3 进行安装即可:

1
pip3 install parsel

更详细的安装说明可以参考:https://setup.scrape.center/parsel。

安装好之后,我们便可以开始本节的学习了。

3. 初始化

首先我们还是用上一节的示例 HTML,声明 html 变量如下:

1
2
3
4
5
6
7
8
9
10
11
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>
'''

接着,一般我们会用 parsel 的 Selector 这个类来声明一个 Selector 对象,写法如下:

1
2
from parsel import Selector
selector = Selector(text=html)

这里我们创建了一个 Selector 对象,传入了 text 参数,内容就是刚才声明的 HTML 字符串,赋值为 selector 变量。

有了 Selector 对象之后,我们可以使用 css 和 xpath 方法分别传入 CSS Selector 和 XPath 进行内容的提取,比如这里我们提取 class 包含 item-0 的节点,写法如下:

1
2
3
4
items = selector.css('.item-0')
print(len(items), type(items), items)
items2 = selector.xpath('//li[contains(@class, "item-0")]')
print(len(items2), type(items), items2)

我们先用 css 方法进行了节点提取,输出了提取结果的长度和内容,xpath 方法也是一样的写法,运行结果如下:

1
2
3 <class 'parsel.selector.SelectorList'> [<Selector xpath="descendant-or-self::*[@class and contains(concat(' ', normalize-space(@class), ' '), ' item-0 ')]" data='<li class="item-0">first item</li>'>, <Selector xpath="descendant-or-self::*[@class and contains(concat(' ', normalize-space(@class), ' '), ' item-0 ')]" data='<li class="item-0 active"><a href="li...'>, <Selector xpath="descendant-or-self::*[@class and contains(concat(' ', normalize-space(@class), ' '), ' item-0 ')]" data='<li class="item-0"><a href="link5.htm...'>]
3 <class 'parsel.selector.SelectorList'> [<Selector xpath='//li[contains(@class, "item-0")]' data='<li class="item-0">first item</li>'>, <Selector xpath='//li[contains(@class, "item-0")]' data='<li class="item-0 active"><a href="li...'>, <Selector xpath='//li[contains(@class, "item-0")]' data='<li class="item-0"><a href="link5.htm...'>]

可以看到两个结果都是 SelectorList 对象,它其实是一个可迭代对象。另外可以用 len 方法获取它的长度,都是 3,提取结果代表的节点其实也是一样的,都是第 1、3、5 个 li 节点,每个节点还是以 Selector 对象的形式返回了,其中每个 Selector 对象的 data 属性里面包含了提取节点的 HTML 代码。

不过这里可能大家有个疑问,第一次我们不是用 css 方法来提取的节点吗?为什么结果中的 Selector 对象还输出了 xpath 属性而不是 css 属性呢?这是因为 css 方法背后,我们传入的 CSS Selector 首先被转成了 XPath,XPath 才真正被用作节点提取。其中 CSS Selector 转换为 XPath 这个过程是在底层用 cssselect 这个库实现的,比如 .item-0 这个 CSS Selector 转换为 XPath 的结果就是 descendant-or-self::*[@class and contains(concat(' ', normalize-space(@class), ' '), ' item-0 ')],因此输出的 Selector 对象有了 xpath 属性了。不过这个大家不用担心,这个对提取结果是没有影响的,仅仅是换了一个表示方法而已。

4. 提取文本

好,既然刚才提取的结果是一个可迭代对象 SelectorList,那么要获取提取到的所有 li 节点的文本内容就要对结果进行遍历了,写法如下:

1
2
3
4
5
6
from parsel import Selector
selector = Selector(text=html)
items = selector.css('.item-0')
for item in items:
text = item.xpath('.//text()').get()
print(text)

这里我们遍历了 items 变量,赋值为 item,那么这里 item 又变成了一个 Selector 对象,那么此时我们又可以调用其 css 或 xpath 方法进行内容提取了,比如这里我们就用 .//text() 这个 XPath 写法提取了当前节点的所有内容,此时如果不再调用其他方法,其返回结果应该依然为 Selector 构成的可迭代对象 SelectorList。SelectorList 有一个 get 方法,get 方法可以将 SelectorList 包含的 Selector 对象中的内容提取出来。

运行结果如下:

1
2
3
first item
third item
fifth item

这里 get 方法的作用是从 SelectorList 里面提取第一个 Selector 对象,然后输出其中的结果。

我们再看一个实例:

1
2
result = selector.xpath('//li[contains(@class, "item-0")]//text()').get()
print(result)

输出结果如下:

1
first item

其实这里我们使用 //li[contains(@class, "item-0")]//text() 选取了所有 class 包含 item-0 的 li 节点的文本内容。应该来说,返回结果 SelectorList 应该对应三个 li 对象,而这里 get 方法仅仅返回了第一个 li 对象的文本内容,因为其实它会只提取第一个 Selector 对象的结果。

那有没有能提取所有 Selector 的对应内容的方法呢?有,那就是 getall 方法。

所以如果要提取所有对应的 li 节点的文本内容的话,写法可以改写为如下内容:

1
2
result = selector.xpath('//li[contains(@class, "item-0")]//text()').getall()
print(result)

输出结果如下:

1
['first item', 'third item', 'fifth item']

这时候,我们就能得到列表类型结果了,和 Selector 对象是一一对应的。

因此,如果要提取 SelectorList 里面对应的结果,可以使用 get 或 getall 方法,前者会获取第一个 Selector 对象里面的内容,后者会依次获取每个 Selector 对象对应的结果。

另外上述案例中,xpath 方法改写成 css 方法,可以这么实现:

1
2
result = selector.css('.item-0 *::text').getall()
print(result)

这里* 用来提取所有子节点(包括纯文本节点),提取文本需要再加上::text,最终的运行结果是一样的。

到这里我们就简单了解了文本提取的方法。

5. 提取属性

刚才我们演示了 HTML 中文本的提取,直接在 XPath 中加入 //text() 即可,那提取属性怎么做呢?类似的方式,也直接在 XPath 或者 CSS Selector 中表示出来就好了。

比如我们提取第三个 li 节点内部的 a 节点的 href 属性,写法如下:

1
2
3
4
5
6
from parsel import Selector
selector = Selector(text=html)
result = selector.css('.item-0.active a::attr(href)').get()
print(result)
result = selector.xpath('//li[contains(@class, "item-0") and contains(@class, "active")]/a/@href').get()
print(result)

这里我们实现了两种写法,分别用 css 和 xpath 方法实现。我们根据同时包含 item-0 和 active 这两个 class 为依据来选取第三个 li 节点,然后进一步选取了里面的 a 节点,对于 CSS Selector,选取属性需要加 ::attr() 并传入对应的属性名称来选取,对于 XPath,直接用 /@ 再加属性名称即可选取。最后统一用 get 方法提取结果即可。

运行结果如下:

1
2
link3.html
link3.html

可以看到两种方法都正确提取到了对应的 href 属性。

6. 正则提取

除了常用的 css 和 xpath 方法,Selector 对象还提供了正则表达式提取方法,我们用一个实例来了解下:

1
2
3
4
from parsel import Selector
selector = Selector(text=html)
result = selector.css('.item-0').re('link.*')
print(result)

这里我们先用 css 方法提取了所有 class 包含 item-0 的节点,然后使用 re 方法,传入了 link.*,用来匹配包含 link 的所有结果。

运行结果如下:

1
['link3.html"><span class="bold">third item</span></a></li>', 'link5.html">fifth item</a></li>']

可以看到,re 方法在这里遍历了所有提取到的 Selector 对象,然后根据传入的正则表达式查找出符合规则的节点源码并以列表的形式返回。

当然如果在调用 css 方法时已经提取了进一步的结果,比如提取了节点文本值,那么 re 方法就只会针对节点文本值进行提取:

1
2
3
4
from parsel import Selector
selector = Selector(text=html)
result = selector.css('.item-0 *::text').re('.*item')
print(result)

运行结果如下:

1
['first item', 'third item', 'fifth item']

另外我们也可以利用 re_first 方法来提取第一个符合规则的结果:

1
2
3
4
from parsel import Selector
selector = Selector(text=html)
result = selector.css('.item-0').re_first('<span class="bold">(.*?)</span>')
print(result)

这里调用了 re_first 方法,这里提取的是被 span 标签包含的文本值,提取结果用小括号括起来表示一个提取分组,最后输出的结果就是小括号部分对应的结果,运行结果如下:

1
third item

通过这几个例子我们知道了正则匹配的一些使用方法,re 对应多个结果,re_first 对应单个结果,可以在不同情况下选择对应的方法进行提取。

7. 总结

parsel 是一个融合了 XPath、CSS Selector 和正则表达式的提取库,功能强大又灵活,建议好好学习一下,同时也可以为后文学习 Scrapy 框架打下基础,有关 parsel 更多的用法可以参考其官方文档:https://parsel.readthedocs.io/。

本节代码:https://github.com/Python3WebSpider/ParselTest。

Python

系列文章总目录:【2022 年】Python3 爬虫学习教程,本教程内容多数来自于《Python3 网络爬虫开发实战(第二版)》一书,目前截止 2022 年,可以将爬虫基本技术进行系统讲解,同时将最新前沿爬虫技术如异步、JavaScript 逆向、AST、安卓逆向、Hook、智能解析、群控技术、WebAssembly、大规模分布式、Docker、Kubernetes 等,市面上目前就仅有《Python3 网络爬虫开发实战(第二版)》一书了,点击了解详情

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

1. 分析案例

这里还需要借助浏览器的开发者工具,下面以 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 请求,浏览器拿到数据后再进一步渲染出来的。

2. 过滤请求

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

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

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

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

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

3. 总结

本节我们介绍了 Ajax 的基本原理和分析方法,在下一节中,我们用一个正式的实例来实现一下 Ajax 数据的爬取。

Python

爬虫系列文章总目录:【2022 年】Python3 爬虫学习教程,本教程内容多数来自于《Python3 网络爬虫开发实战(第二版)》一书,目前截止 2022 年,可以将爬虫基本技术进行系统讲解,同时将最新前沿爬虫技术如异步、JavaScript 逆向、AST、安卓逆向、Hook、智能解析、群控技术、WebAssembly、大规模分布式、Docker、Kubernetes 等,市面上目前就仅有《Python3 网络爬虫开发实战(第二版)》一书了,点击了解详情

将数据保存到 TXT 文本的操作非常简单,而且 TXT 文本几乎兼容任何平台,但是这有个缺点,那就是不利于检索。所以如果对检索和数据结构要求不高,追求方便第一的话,可以采用 TXT 文本存储。

本节中,我们就来看下利用 Python 保存 TXT 文本文件的方法。

1. 本节目标

本节我们以电影示例网站 https://ssr1.scrape.center/ 为例,爬取首页 10 部电影的数据,然后将相关信息存储为 TXT 文本格式。

2. 基本实例

首先,可以用 requests 将网页源代码获取下来,然后使用 pyquery 解析库解析,接下来将提取的电影名称、类别、上映时间等信息保存到 TXT 文本中,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import requests
from pyquery import PyQuery as pq
import re

url = 'https://ssr1.scrape.center/'
html = requests.get(url).text
doc = pq(html)
items = doc('.el-card').items()

file = open('movies.txt', 'w', encoding='utf-8')
for item in items:
# 电影名称
name = item.find('a > h2').text()
file.write(f'名称: {name}\n')
# 类别
categories = [item.text() for item in item.find('.categories button span').items()]
file.write(f'类别: {categories}\n')
# 上映时间
published_at = item.find('.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
file.write(f'上映时间: {published_at}\n')
# 评分
score = item.find('p.score').text()
file.write(f'评分: {score}\n')
file.write(f'{"=" * 50}\n')
file.close()

这里主要是为了演示文件保存的方式,因此 requests 异常处理部分在此省去。首先,用 requests 提取首页的 HTML 代码,然后利用 pyquery 将电影的名称、类别、上映时间、评分信息提取出来。

利用 Python 提供的 open 方法打开一个文本文件,获取一个文件操作对象,这里赋值为 file,每提取一部分信息,就利用 file 对象的 write 方法将提取的内容写入文件。

全部提取完毕之后,最后调用 close 方法将其关闭,这样抓取的内容即可成功写入文本中了。

运行程序,可以发现在本地生成了一个 movies.txt 文件,其内容如图所示。

image-20200531171232808

这样电影信息的内容就被保存成文本形式了。

回过头来我们看下本节重点需要了解的内容,就是文本写入操作,其实就是 open、write、close 这三个方法的用法。

这里 open 方法的第一个参数即要保存的目标文件名称;第二个参数为 w,代表以覆盖写入的方式写入文本;另外,我们还指定了文件的编码为 utf-8。最后,写入完成后,还需要调用 close 方法来关闭文件对象。

3. 打开方式

在刚才的实例中,open 方法的第二个参数设置成了 w,这样在每次写入文本时会清空源文件,然后将新的内容写入文件,这是一种文件打开方式。关于文件的打开方式,其实还有其他几种,这里简要介绍一下。

  • r:以只读方式打开文件,意思就是只能读取文件内容,不能写入文件内容。这是默认模式。
  • rb:以二进制只读方式打开一个文件,通常用于打开二进制文件,比如音频、图片、视频等等。
  • r+:以读写方式打开一个文件,既可以读文件又可以写文件。
  • rb+:以二进制读写方式打开一个文件,同样既可以读又可以写,但读取和写入的都是二进制数据。
  • w:以写入方式打开一个文件。如果该文件已存在,则将其覆盖。如果该文件不存在,则创建新文件。
  • wb:以二进制写入方式打开一个文件。如果该文件已存在,则将其覆盖。如果该文件不存在,则创建新文件。
  • w+:以读写方式打开一个文件。如果该文件已存在,则将其覆盖。如果该文件不存在,则创建新文件。
  • wb+:以二进制读写格式打开一个文件。如果该文件已存在,则将其覆盖。如果该文件不存在,则创建新文件。
  • a:以追加方式打开一个文件。如果该文件已存在,文件指针将会放在文件结尾。也就是说,新的内容将会被写入到已有内容之后。如果该文件不存在,则创建新文件来写入。
  • ab:以二进制追加方式打开一个文件。如果该文件已存在,则文件指针将会放在文件结尾。也就是说,新的内容将会被写入到已有内容之后。如果该文件不存在,则创建新文件来写入。
  • a+:以读写方式打开一个文件。如果该文件已存在,文件指针将会放在文件的结尾。文件打开时会是追加模式。如果该文件不存在,则创建新文件来读写。
  • ab+:以二进制追加方式打开一个文件。如果该文件已存在,则文件指针将会放在文件结尾。如果该文件不存在,则创建新文件用于读写。

4. 简化写法

另外,文件写入还有一种简写方法,那就是使用 with as 语法。在 with 控制块结束时,文件会自动关闭,所以就不需要再调用 close 方法了。

这种保存方式可以简写如下:

1
2
3
4
5
with open('movies.txt', 'w', encoding='utf-8'):
file.write(f'名称: {name}\n')
file.write(f'类别: {categories}\n')
file.write(f'上映时间: {published_at}\n')
file.write(f'评分: {score}\n')

上面便是利用 Python 将结果保存为 TXT 文件的方法,这种方法简单易用,操作高效,是一种最基本的保存数据的方法。

5. 总结

本节我们了解了基本 TXT 文件存储的实现方式,建议熟练掌握。

本节代码:https://github.com/Python3WebSpider/FileStorageTest。

Python

爬虫系列文章总目录:【2022 年】Python3 爬虫学习教程,本教程内容多数来自于《Python3网络爬虫开发实战(第二版)》一书,目前截止 2022 年,可以将爬虫基本技术进行系统讲解,同时将最新前沿爬虫技术如异步、JavaScript 逆向、AST、安卓逆向、Hook、智能解析、群控技术、WebAssembly、大规模分布式、Docker、Kubernetes 等,市面上目前就仅有《Python3 网络爬虫开发实战(第二版)》一书了,点击了解详情

XPath,全称是 XML Path Language,即 XML 路径语言,它是一门在 XML 文档中查找信息的语言。它最初是用来搜寻 XML 文档的,但是它同样适用于 HTML 文档的搜索。

所以在做爬虫时,我们完全可以使用 XPath 来做相应的信息抽取。本节我们就来了解下 XPath 的基本用法。

1. XPath 概览

XPath 的选择功能十分强大,它提供了非常简洁明了的路径选择表达式。另外,它还提供了超过 100 个内建函数,用于字符串、数值、时间的匹配以及节点、序列的处理等。几乎所有我们想要定位的节点,都可以用 XPath 来选择。

XPath 于 1999 年 11 月 16 日成为 W3C 标准,它被设计为供 XSLT、XPointer 以及其他 XML 解析软件使用,更多的文档可以访问其官方网站:https://www.w3.org/TR/xpath/

2. XPath 常用规则

下表列举了 XPath 的几个常用规则。

表 XPath 常用规则

表 达 式 描  述
nodename 选取此节点的所有子节点
/ 从当前节点选取直接子节点
// 从当前节点选取子孙节点
. 选取当前节点
.. 选取当前节点的父节点
@ 选取属性

这里列出了 XPath 的常用匹配规则,示例如下:

1
//title[@lang='eng']

这就是一个 XPath 规则,它代表选择所有名称为 title,同时属性 lang 的值为 eng 的节点。

后面会通过 Python 的 lxml 库,利用 XPath 进行 HTML 的解析。

3. 准备工作

使用之前,首先要确保安装好 lxml 库。如尚未安装,可以使用 pip3 来安装:

1
pip3 install lxml

更详细的安装说明可以参考:https://setup.scrape.center/lxml。

安装完成之后,我们就可以进行接下来的学习了。

4. 实例引入

现在通过实例来感受一下使用 XPath 对网页进行解析的过程,相关代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from lxml import etree
text = '''
<div>
<ul>
<li class="item-0"><a href="link1.html">first item</a></li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-inactive"><a href="link3.html">third item</a></li>
<li class="item-1"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a>
</ul>
</div>
'''
html = etree.HTML(text)
result = etree.tostring(html)
print(result.decode('utf-8'))

这里首先导入 lxml 库的 etree 模块,然后声明了一段 HTML 文本,调用 HTML 类进行初始化,这样就成功构造了一个 XPath 解析对象。这里需要注意的是,HTML 文本中的最后一个 li 节点是没有闭合的,但是 etree 模块可以自动修正 HTML 文本。

这里我们调用 tostring 方法即可输出修正后的 HTML 代码,但是结果是 bytes 类型。这里利用 decode 方法将其转成 str 类型,结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
<html>
<body>
<div>
<ul>
<li class="item-0"><a href="link1.html">first item</a></li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-inactive"><a href="link3.html">third item</a></li>
<li class="item-1"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a></li>
</ul>
</div>
</body>
</html>

可以看到,经过处理之后,li 节点标签被补全,并且还自动添加了 bodyhtml 节点。

另外,也可以直接读取文本文件进行解析,示例如下:

1
2
3
4
5
from lxml import etree

html = etree.parse('./test.html', etree.HTMLParser())
result = etree.tostring(html)
print(result.decode('utf-8'))

其中 test.html 的内容就是上面例子中的 HTML 代码,内容如下:

1
2
3
4
5
6
7
8
9
<div>
<ul>
<li class="item-0"><a href="link1.html">first item</a></li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-inactive"><a href="link3.html">third item</a></li>
<li class="item-1"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a></li>
</ul>
</div>

这次的输出结果略有不同,多了一个 DOCTYPE 声明,不过对解析无任何影响,结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
<html>
<body>
<div>
<ul>
<li class="item-0"><a href="link1.html">first item</a></li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-inactive"><a href="link3.html">third item</a></li>
<li class="item-1"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a></li>
</ul>
</div>
</body>
</html>

5. 所有节点

我们一般会用 // 开头的 XPath 规则来选取所有符合要求的节点。这里以前面的 HTML 文本为例,如果要选取所有节点,可以这样实现:

1
2
3
4
from lxml import etree
html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//*')
print(result)

运行结果如下:

1
[<Element html at 0x10510d9c8>, <Element body at 0x10510da08>, <Element div at 0x10510da48>, <Element ul at 0x10510da88>, <Element li at 0x10510dac8>, <Element a at 0x10510db48>, <Element li at 0x10510db88>, <Element a at 0x10510dbc8>, <Element li at 0x10510dc08>, <Element a at 0x10510db08>, <Element li at 0x10510dc48>, <Element a at 0x10510dc88>, <Element li at 0x10510dcc8>, <Element a at 0x10510dd08>]

这里使用 * 代表匹配所有节点,也就是整个 HTML 文本中的所有节点都会被获取。可以看到,返回形式是一个列表,每个元素是 Element 类型,其后跟了节点的名称,如 htmlbodydivullia 等,所有节点都包含在列表中了。

当然,此处匹配也可以指定节点名称。如果想获取所有 li 节点,示例如下:

1
2
3
4
5
from lxml import etree
html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//li')
print(result)
print(result[0])

这里要选取所有 li 节点,可以使用 //,然后直接加上节点名称即可,调用时直接使用 xpath 方法即可。

运行结果如下:

1
2
[<Element li at 0x105849208>, <Element li at 0x105849248>, <Element li at 0x105849288>, <Element li at 0x1058492c8>, <Element li at 0x105849308>]
<Element li at 0x105849208>

这里可以看到,提取结果是一个列表形式,其中每个元素都是一个 Element 对象。如果要取出其中一个对象,可以直接用中括号加索引,如 [0]

6. 子节点

我们通过 /// 即可查找元素的子节点或子孙节点。假如现在想选择 li 节点的所有直接子节点 a,可以这样实现:

1
2
3
4
5
from lxml import etree

html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//li/a')
print(result)

这里通过追加 /a 即选择了所有 li 节点的所有直接子节点 a。因为 //li 用于选中所有 li 节点,/a 用于选中 li 节点的所有直接子节点 a,二者组合在一起即获取所有 li 节点的所有直接子节点 a

运行结果如下:

1
[<Element a at 0x106ee8688>, <Element a at 0x106ee86c8>, <Element a at 0x106ee8708>, <Element a at 0x106ee8748>, <Element a at 0x106ee8788>]

此处的 / 用于选取直接子节点,如果要获取所有子孙节点,就可以使用 //。例如,要获取 ul 节点下的所有子孙节点 a,可以这样实现:

1
2
3
4
5
from lxml import etree

html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//ul//a')
print(result)

运行结果是相同的。

但是如果这里用 //ul/a,就无法获取任何结果了。因为 / 用于获取直接子节点,而在 ul 节点下没有直接的 a 子节点,只有 li 节点,所以无法获取任何匹配结果,代码如下:

1
2
3
4
5
from lxml import etree

html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//ul/a')
print(result)

运行结果如下:

1
[]

因此,这里我们要注意 /// 的区别,其中 / 用于获取直接子节点,// 用于获取子孙节点。

7. 父节点

我们知道通过连续的 /// 可以查找子节点或子孙节点,那么假如我们知道了子节点,怎样来查找父节点呢?这可以用 .. 来实现。

比如,现在首先选中 href 属性为 link4.htmla 节点,然后获取其父节点,再获取其 class 属性,相关代码如下:

1
2
3
4
5
from lxml import etree

html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//a[@href="link4.html"]/../@class')
print(result)

运行结果如下:

1
['item-1']

检查一下结果发现,这正是我们获取的目标 li 节点的 class 属性。

同时,我们也可以通过 parent:: 来获取父节点,代码如下:

1
2
3
4
from lxml import etree
html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//a[@href="link4.html"]/parent::*/@class')
print(result)

8. 属性匹配

在选取的时候,我们还可以用 @ 符号进行属性过滤。比如,这里如果要选取 classitem-0li 节点,可以这样实现:

1
2
3
4
from lxml import etree
html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//li[@class="item-0"]')
print(result)

这里我们通过加入 [@class="item-0"],限制了节点的 class 属性为 item-0,而 HTML 文本中符合条件的 li 节点有两个,所以结果应该返回两个匹配到的元素。结果如下:

1
<Element li at 0x10a399288>, <Element li at 0x10a3992c8>

可见,匹配结果正是两个,至于是不是那正确的两个,后面再验证。

9. 文本获取

我们用 XPath 中的 text 方法获取节点中的文本,接下来尝试获取前面 li 节点中的文本,相关代码如下:

1
2
3
4
5
from lxml import etree

html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//li[@class="item-0"]/text()')
print(result)

运行结果如下:

1
['\n     ']

奇怪的是,我们并没有获取到任何文本,只获取到了一个换行符,这是为什么呢?因为 XPath 中 text 方法前面是 /,而此处 / 的含义是选取直接子节点,很明显 li 的直接子节点都是 a 节点,文本都是在 a 节点内部的,所以这里匹配到的结果就是被修正的 li 节点内部的换行符,因为自动修正的 li 节点的尾标签换行了。

即选中的是这两个节点:

1
2
<li class="item-0"><a href="link1.html">first item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a></li>

其中一个节点因为自动修正,li 节点的尾标签添加的时候换行了,所以提取文本得到的唯一结果就是 li 节点的尾标签和 a 节点的尾标签之间的换行符。

因此,如果想获取 li 节点内部的文本,就有两种方式,一种是先选取 a 节点再获取文本,另一种就是使用 //。接下来,我们来看下二者的区别。

首先,选取 a 节点再获取文本,代码如下:

1
2
3
4
from lxml import etree
html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//li[@class="item-0"]/a/text()')
print(result)

运行结果如下:

1
['first item', 'fifth item']

可以看到,这里的返回值是两个,内容都是属性为 item-0li 节点的文本,这也印证了前面属性匹配的结果是正确的。

这里我们是逐层选取的,先选取了 li 节点,又利用 / 选取了其直接子节点 a,然后再选取其文本,得到的结果恰好是符合我们预期的两个结果。

再来看下用另一种方式(即使用 //)选取的结果,代码如下:

1
2
3
4
5
from lxml import etree

html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//li[@class="item-0"]//text()')
print(result)

运行结果如下:

1
['first item', 'fifth item', '\n     ']

不出所料,这里的返回结果是 3 个。可想而知,这里是选取所有子孙节点的文本,其中前两个就是 li 的子节点 a 内部的文本,另外一个就是最后一个 li 节点内部的文本,即换行符。

所以说,如果要想获取子孙节点内部的所有文本,可以直接用 //text 方法的方式,这样可以保证获取到最全面的文本信息,但是可能会夹杂一些换行符等特殊字符。如果想获取某些特定子孙节点下的所有文本,可以先选取到特定的子孙节点,然后再调用 text 方法获取其内部文本,这样可以保证获取的结果是整洁的。

10. 属性获取

我们知道用 text 方法可以获取节点内部文本,那么节点属性该怎样获取呢?其实还是用 @ 符号就可以。例如,我们想获取所有 li 节点下所有 a 节点的 href 属性,代码如下:

1
2
3
4
5
from lxml import etree

html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//li/a/@href')
print(result)

这里我们通过 @href 即可获取节点的 href 属性。注意,此处和属性匹配的方法不同,属性匹配是中括号加属性名和值来限定某个属性,如 [@href="link1.html"],而此处的 @href 指的是获取节点的某个属性,二者需要做好区分。

运行结果如下:

1
['link1.html', 'link2.html', 'link3.html', 'link4.html', 'link5.html']

可以看到,我们成功获取了所有 li 节点下 a 节点的 href 属性,它们以列表形式返回。

11. 属性多值匹配

有时候,某些节点的某个属性可能有多个值,例如:

1
2
3
4
5
6
7
from lxml import etree
text = '''
<li class="li li-first"><a href="link.html">first item</a></li>
'''
html = etree.HTML(text)
result = html.xpath('//li[@class="li"]/a/text()')
print(result)

这里 HTML 文本中 li 节点的 class 属性有两个值 lili-first,此时如果还想用之前的属性匹配获取,就无法匹配了,此时的运行结果如下:

1
[]

这时就需要用 contains 方法了,代码可以改写如下:

1
2
3
4
5
6
7
from lxml import etree
text = '''
<li class="li li-first"><a href="link.html">first item</a></li>
'''
html = etree.HTML(text)
result = html.xpath('//li[contains(@class, "li")]/a/text()')
print(result)

这样通过 contains 方法,给其第一个参数传入属性名称,第二个参数传入属性值,只要此属性包含所传入的属性值,就可以完成匹配了。

此时运行结果如下:

1
['first item']

此种方式在某个节点的某个属性有多个值时经常用到,如某个节点的 class 属性通常有多个。

12. 多属性匹配

另外,我们可能还遇到一种情况,那就是根据多个属性确定一个节点,这时就需要同时匹配多个属性。此时可以使用运算符 and 来连接,示例如下:

1
2
3
4
5
6
7
from lxml import etree
text = '''
<li class="li li-first" name="item"><a href="link.html">first item</a></li>
'''
html = etree.HTML(text)
result = html.xpath('//li[contains(@class, "li") and @name="item"]/a/text()')
print(result)

这里的 li 节点又增加了一个属性 name。要确定这个节点,需要同时根据 classname 属性来选择,一个条件是 class 属性里面包含 li 字符串,另一个条件是 name 属性为 item 字符串,二者需要同时满足,需要用 and 操作符相连,相连之后置于中括号内进行条件筛选。运行结果如下:

1
['first item']

这里的 and 其实是 XPath 中的运算符。另外,还有很多运算符,如 ormod 等,在此总结为表 3-。

表 3- 运算符及其介绍

运算符 描  述 实  例 返 回 值
or age=19 or age=20 如果 age 是 19,则返回 true。如果 age 是 21,则返回 false
and age>19 and age<21 如果 age 是 20,则返回 true。如果 age 是 18,则返回 false
mod 计算除法的余数 5 mod 2 1
` ` 计算两个节点集 `//book //cd` 返回所有拥有 bookcd 元素的节点集
+ 加法 6 + 4 10
- 减法 6 - 4 2
* 乘法 6 * 4 24
div 除法 8 div 4 2
= 等于 age=19 如果 age 是 19,则返回 true。如果 age 是 20,则返回 false
!= 不等于 age!=19 如果 age 是 18,则返回 true。如果 age 是 19,则返回 false
< 小于 age<19 如果 age 是 18,则返回 true。如果 age 是 19,则返回 false
<= 小于或等于 <=19 如果 age 是 19,则返回 true。如果 age20,则返回 false
> 大于 age>19 如果 age 是 20,则返回 true。如果 age 是 19,则返回 false
>= 大于或等于 age>=19 如果 age 是 19,则返回 true。如果 age 是 18,则返回 false

此表参考来源:http://www.w3school.com.cn/xpath/xpath_operators.asp

13. 按序选择

有时候,我们在选择的时候某些属性可能同时匹配了多个节点,但是只想要其中的某个节点,如第二个节点或者最后一个节点,这时该怎么办呢?

这时可以利用中括号传入索引的方法获取特定次序的节点,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from lxml import etree

text = '''
<div>
<ul>
<li class="item-0"><a href="link1.html">first item</a></li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-inactive"><a href="link3.html">third item</a></li>
<li class="item-1"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a>
</ul>
</div>
'''
html = etree.HTML(text)
result = html.xpath('//li[1]/a/text()')
print(result)
result = html.xpath('//li[last()]/a/text()')
print(result)
result = html.xpath('//li[position()<3]/a/text()')
print(result)
result = html.xpath('//li[last()-2]/a/text()')
print(result)

第一次选择时,我们选取了第一个 li 节点,中括号中传入数字 1 即可。注意,这里和代码中不同,序号是以 1 开头的,不是以 0 开头。

第二次选择时,我们选取了最后一个 li 节点,中括号中调用 last 方法即可。

第三次选择时,我们选取了位置小于 3 的 li 节点,也就是位置序号为 1 和 2 的节点,得到的结果就是前两个 li 节点。

第四次选择时,我们选取了倒数第三个 li 节点,中括号中调用 last 方法再减去 2 即可。因为 last 方法代表最后一个,在此基础减 2 就是倒数第三个。

运行结果如下:

1
2
3
4
['first item']
['fifth item']
['first item', 'second item']
['third item']

这里我们使用了 lastposition 等方法。在 XPath 中,提供了 100 多个方法,包括存取、数值、字符串、逻辑、节点、序列等处理功能,它们的具体作用可以参考:http://www.w3school.com.cn/xpath/xpath_functions.asp

14. 节点轴选择

XPath 提供了很多节点轴选择方法,包括获取子元素、兄弟元素、父元素、祖先元素等,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from lxml import etree

text = '''
<div>
<ul>
<li class="item-0"><a href="link1.html"><span>first item</span></a></li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-inactive"><a href="link3.html">third item</a></li>
<li class="item-1"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a>
</ul>
</div>
'''
html = etree.HTML(text)
result = html.xpath('//li[1]/ancestor::*')
print(result)
result = html.xpath('//li[1]/ancestor::div')
print(result)
result = html.xpath('//li[1]/attribute::*')
print(result)
result = html.xpath('//li[1]/child::a[@href="link1.html"]')
print(result)
result = html.xpath('//li[1]/descendant::span')
print(result)
result = html.xpath('//li[1]/following::*[2]')
print(result)
result = html.xpath('//li[1]/following-sibling::*')
print(result)

运行结果如下:

1
2
3
4
5
6
7
[<Element html at 0x107941808>, <Element body at 0x1079418c8>, <Element div at 0x107941908>, <Element ul at 0x107941948>]
[<Element div at 0x107941908>]
['item-0']
[<Element a at 0x1079418c8>]
[<Element span at 0x107941948>]
[<Element a at 0x1079418c8>]
[<Element li at 0x107941948>, <Element li at 0x107941988>, <Element li at 0x1079419c8>, <Element li at 0x107941a08>]

第一次选择时,我们调用了 ancestor 轴,可以获取所有祖先节点。其后需要跟两个冒号,然后是节点的选择器,这里我们直接使用 *,表示匹配所有节点,因此返回结果是第一个 li 节点的所有祖先节点,包括 htmlbodydivul

第二次选择时,我们又加了限定条件,这次在冒号后面加了 div,这样得到的结果就只有 div 这个祖先节点了。

第三次选择时,我们调用了 attribute 轴,可以获取所有属性值,其后跟的选择器还是 *,这代表获取节点的所有属性,返回值就是 li 节点的所有属性值。

第四次选择时,我们调用了 child 轴,可以获取所有直接子节点。这里我们又加了限定条件,选取 href 属性为 link1.htmla 节点。

第五次选择时,我们调用了 descendant 轴,可以获取所有子孙节点。这里我们又加了限定条件获取 span 节点,所以返回的结果只包含 span 节点而不包含 a 节点。

第六次选择时,我们调用了 following 轴,可以获取当前节点之后的所有节点。这里我们虽然使用的是 * 匹配,但又加了索引选择,所以只获取了第二个后续节点。

第七次选择时,我们调用了 following-sibling 轴,可以获取当前节点之后的所有同级节点。这里我们使用 * 匹配,所以获取了所有后续同级节点。

以上是 XPath 轴的简单用法,更多轴的用法可以参考:http://www.w3school.com.cn/xpath/xpath_axes.asp

15. 总结

到现在为止,我们基本上把可能用到的 XPath 选择器介绍完了。XPath 功能非常强大,内置函数非常多,熟练使用之后,可以大大提升 HTML 信息的提取效率。

如果想查询更多 XPath 的用法,可以查看:http://www.w3school.com.cn/xpath/index.asp

如果想查询更多 Python lxml 库的用法,可以查看 http://lxml.de/

本节代码:https://github.com/Python3WebSpider/XPathTest。

Python

爬虫系列文章总目录:【2022 年】Python3 爬虫学习教程,本教程内容多数来自于《Python3网络爬虫开发实战(第二版)》一书,目前截止 2022 年,可以将爬虫基本技术进行系统讲解,同时将最新前沿爬虫技术如异步、JavaScript 逆向、AST、安卓逆向、Hook、智能解析、群控技术、WebAssembly、大规模分布式、Docker、Kubernetes 等,市面上目前就仅有《Python3 网络爬虫开发实战(第二版)》一书了,点击了解详情

NoSQL,全称 Not Only SQL,意为不仅仅是 SQL,泛指非关系型数据库。NoSQL 是基于键值对的,而且不需要经过 SQL 层的解析,数据之间没有耦合性,性能非常高。

非关系型数据库又可细分如下:

  • 键值存储数据库:其代表有 Redis、Voldemort 和 Oracle BDB 等。
  • 列存储数据库:其代表有 Cassandra、HBase 和 Riak 等。
  • 文档型数据库:其代表有 CouchDB 和 MongoDB 等。
  • 键值存储数据库:其代表有 Redis、Voldemort 和 Oracle BDB 等。
  • 图形数据库:其代表有 Neo4J、InfoGrid 和 Infinite Graph 等。

对于爬虫的数据存储来说,一条数据可能存在某些字段提取失败而缺失的情况,而且数据可能随时调整。另外,数据之间还存在嵌套关系。如果使用关系型数据库存储,一是需要提前建表,二是如果存在数据嵌套关系的话,需要进行序列化操作才可以存储,这非常不方便。如果用了非关系型数据库,就可以避免一些麻烦,更简单、高效。

本节中,我们主要介绍 MongoDB 存储操作。

MongoDB 是由 C++ 语言编写的非关系型数据库,是一个基于分布式文件存储的开源数据库系统,其内容存储形式类似 JSON 对象,它的字段值可以包含其他文档、数组及文档数组,非常灵活。在这一节中,我们就来看看 Python 3 下 MongoDB 的存储操作。

1. 准备工作

在开始之前,请确保已经安装好了 MongoDB 并启动了其服务,安装方式可以参考:https://setup.scrape.center/mongodb。

除了安装好 MongoDB 数据库,我们还需要安装好 Python 的 PyMongo 库,如尚未安装,可以使用 pip3 来安装:

1
pip3 install pymongo

更详细的安装说明可以参考:https://setup.scrape.center/pymongo。

安装好 MongoDB 数据库和 PyMongo 库之后,我们便可以开始本节的学习了。

2. 连接 MongoDB

连接 MongoDB 时,我们需要使用 PyMongo 库里面的 MongoClient。一般来说,传入 MongoDB 的 IP 及端口即可,其中第一个参数为地址 host,第二个参数为端口 port(如果不给它传递参数,默认是 27017):

1
2
import pymongo
client = pymongo.MongoClient(host='localhost', port=27017)

这样就可以创建 MongoDB 的连接对象了。

另外,MongoClient 的第一个参数 host 还可以直接传入 MongoDB 的连接字符串,它以 mongodb 开头,例如:

1
client = MongoClient('mongodb://localhost:27017/')

这也可以达到同样的连接效果。

3. 指定数据库

在 MongoDB 中,可以建立多个数据库,接下来我们需要指定操作哪个数据库。这里我们以 test 数据库为例来说明,下一步需要在程序中指定要使用的数据库:

1
db = client.test

这里调用 clienttest 属性即可返回 test 数据库。当然,我们也可以这样指定:

1
db = client['test']

这两种方式是等价的。

4. 指定集合

MongoDB 的每个数据库又包含许多集合(collection),它们类似于关系型数据库中的表。

下一步需要指定要操作的集合,这里指定一个集合名称为 students。与指定数据库类似,指定集合也有两种方式:

1
collection = db.students
1
collection = db['students']

这样我们便声明了一个集合对象。

5. 插入数据

接下来,便可以插入数据了。对于 students 这个集合,新建一条学生数据,这条数据以字典形式表示:

1
2
3
4
5
6
student = {
'id': '20170101',
'name': 'Jordan',
'age': 20,
'gender': 'male'
}

这里指定了学生的学号、姓名、年龄和性别。接下来,直接调用 collectioninsert 方法即可插入数据,代码如下:

1
2
result = collection.insert(student)
print(result)

在 MongoDB 中,每条数据其实都有一个 _id 属性来唯一标识。如果没有显式指明该属性,MongoDB 会自动产生一个 ObjectId 类型的 _id 属性。insert 方法会在执行后返回 _id 值。

运行结果如下:

1
5932a68615c2606814c91f3d

当然,我们也可以同时插入多条数据,只需要以列表形式传递即可,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 的集合:

1
[ObjectId('5932a80115c2606a59e8a048'), ObjectId('5932a80115c2606a59e8a049')]

实际上,在 PyMongo 3.x 版本中,官方已经不推荐使用 insert 方法了。当然,继续使用也没有什么问题。官方推荐使用 insert_oneinsert_many 方法来分别插入单条记录和多条记录,示例如下:

1
2
3
4
5
6
7
8
9
10
student = {
'id': '20170101',
'name': 'Jordan',
'age': 20,
'gender': 'male'
}

result = collection.insert_one(student)
print(result)
print(result.inserted_id)

运行结果如下:

1
2
<pymongo.results.InsertOneResult object at 0x10d68b558>
5932ab0f15c2606f0c1cf6c5

insert 方法不同,这次返回的是 InsertOneResult 对象,我们可以调用其 inserted_id 属性获取 _id

对于 insert_many 方法,我们可以将数据以列表形式传递,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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)

运行结果如下:

1
2
<pymongo.results.InsertManyResult object at 0x101dea558>
[ObjectId('5932abf415c2607083d3b2ac'), ObjectId('5932abf415c2607083d3b2ad')]

该方法返回的是 InsertManyResult 类型的对象,调用 inserted_ids 属性可以获取插入数据的 _id 列表。

6. 查询

插入数据后,我们可以利用 find_onefind 方法进行查询,其中 find_one 查询得到的是单个结果,find 则返回一个生成器对象。示例如下:

1
2
3
result = collection.find_one({'name': 'Mike'})
print(type(result))
print(result)

这里我们查询 nameMike 的数据,它的返回结果是字典类型,运行结果如下:

1
2
<class 'dict'>
{'_id': ObjectId('5932a80115c2606a59e8a049'), 'id': '20170202', 'name': 'Mike', 'age': 21, 'gender': 'male'}

可以发现,它多了 _id 属性,这就是 MongoDB 在插入过程中自动添加的。

此外,我们也可以根据 ObjectId 来查询,此时需要使用 bson 库里面的 objectid

1
2
3
4
from bson.objectid import ObjectId

result = collection.find_one({'_id': ObjectId('593278c115c2602667ec6bae')})
print(result)

其查询结果依然是字典类型,具体如下:

1
{'_id': ObjectId('593278c115c2602667ec6bae'), 'id': '20170101', 'name': 'Jordan', 'age': 20, 'gender': 'male'}

当然,如果查询结果不存在,则会返回 None

对于多条数据的查询,我们可以使用 find 方法。例如,这里查找年龄为 20 的数据,示例如下:

1
2
3
4
results = collection.find({'age': 20})
print(results)
for result in results:
print(result)

运行结果如下:

1
2
3
4
<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 的数据,则写法如下:

1
results = collection.find({'age': {'$gt': 20}})

这里查询的条件键值已经不是单纯的数字了,而是一个字典,其键名为比较符号 $gt,意思是大于,键值为 20。

这里将比较符号归纳为表 5-3。

表 5-3 比较符号

符  号 含  义 示  例
$lt 小于 {'age': {'$lt': 20}}
$gt 大于 {'age': {'$gt': 20}}
$lte 小于等于 {'age': {'$lte': 20}}
$gte 大于等于 {'age': {'$gte': 20}}
$ne 不等于 {'age': {'$ne': 20}}
$in 在范围内 {'age': {'$in': [20, 23]}}
$nin 不在范围内 {'age': {'$nin': [20, 23]}}

另外,还可以进行正则匹配查询。例如,查询名字以 M 开头的学生数据,示例如下:

1
results = collection.find({'name': {'$regex': '^M.*'}})

这里使用 $regex 来指定正则匹配,^M.* 代表以 M 开头的正则表达式。

这里将一些功能符号再归类为下表。

符  号 含  义 示  例 示例含义
$regex 匹配正则表达式 {'name': {'$regex': '^M.*'}} name 以 M 开头
$exists 属性是否存在 {'name': {'$exists': True}} name 属性存在
$type 类型判断 {'age': {'$type': 'int'}} age 的类型为 int
$mod 数字模操作 {'age': {'$mod': [5, 0]}} 年龄模 5 余 0
$text 文本查询 {'$text': {'$search': 'Mike'}} text 类型的属性中包含 Mike 字符串
$where 高级条件查询 {'$where': 'obj.fans_count == obj.follows_count'} 自身粉丝数等于关注数

关于这些操作的更详细用法,可以在 MongoDB 官方文档找到: https://docs.mongodb.com/manual/reference/operator/query/

7. 计数

要统计查询结果有多少条数据,可以调用 count 方法。比如,统计所有数据条数:

1
2
count = collection.find().count()
print(count)

或者统计符合某个条件的数据:

1
2
count = collection.find({'age': 20}).count()
print(count)

运行结果是一个数值,即符合条件的数据条数。

8. 排序

排序时,直接调用 sort 方法,并在其中传入排序的字段及升降序标志即可。示例如下:

1
2
results = collection.find().sort('name', pymongo.ASCENDING)
print([result['name'] for result in results])

运行结果如下:

1
['Harden', 'Jordan', 'Kevin', 'Mark', 'Mike']

这里我们调用 pymongo.ASCENDING 指定升序。如果要降序排列,可以传入 pymongo.DESCENDING

9. 偏移

在某些情况下,我们可能想只取某几个元素,这时可以利用 skip 方法偏移几个位置,比如偏移 2,就忽略前两个元素,得到第三个及以后的元素:

1
2
results = collection.find().sort('name', pymongo.ASCENDING).skip(2)
print([result['name'] for result in results])

运行结果如下:

1
['Kevin', 'Mark', 'Mike']

另外,还可以用 limit 方法指定要取的结果个数,示例如下:

1
2
results = collection.find().sort('name', pymongo.ASCENDING).skip(2).limit(2)
print([result['name'] for result in results])

运行结果如下:

1
['Kevin', 'Mark']

如果不使用 limit 方法,原本会返回三个结果,加了限制后,会截取两个结果返回。

值得注意的是,在数据库数量非常庞大的时候,如千万、亿级别,最好不要使用大的偏移量来查询数据,因为这样很可能导致内存溢出。此时可以使用类似如下操作来查询:

1
2
from bson.objectid import ObjectId
collection.find({'_id': {'$gt': ObjectId('593278c815c2602678bb2b8d')}})

这时需要记录好上次查询的 _id

10. 更新

对于数据更新,我们可以使用 update 方法,指定更新的条件和更新后的数据即可。例如:

1
2
3
4
5
condition = {'name': 'Kevin'}
student = collection.find_one(condition)
student['age'] = 25
result = collection.update(condition, student)
print(result)

这里我们要更新 nameKevin 的数据的年龄:首先指定查询条件,然后将数据查询出来,修改年龄后调用 update 方法将原条件和修改后的数据传入。

运行结果如下:

1
{'ok': 1, 'nModified': 1, 'n': 1, 'updatedExisting': True}

返回结果是字典形式,ok 代表执行成功,nModified 代表影响的数据条数。

另外,我们也可以使用 $set 操作符对数据进行更新,代码如下:

1
result = collection.update(condition, {'$set': student})

这样可以只更新 student 字典内存在的字段。如果原先还有其他字段,则不会更新,也不会删除。而如果不用 $set 的话,则会把之前的数据全部用 student 字典替换;如果原本存在其他字段,则会被删除。

另外,update 方法其实也是官方不推荐使用的方法。这里也分为 update_one 方法和 update_many 方法,用法更加严格,它们的第二个参数需要使用 $ 类型操作符作为字典的键名,示例如下:

1
2
3
4
5
6
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 方法,其第二个参数不能再直接传入修改后的字典,而是需要使用 {'$set': student} 这样的形式,其返回结果是 UpdateResult 类型。然后分别调用 matched_countmodified_count 属性,获得匹配的数据条数和影响的数据条数。

运行结果如下:

1
2
<pymongo.results.UpdateResult object at 0x10d17b678>
1 0

我们再看一个例子:

1
2
3
4
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。

运行结果如下:

1
2
<pymongo.results.UpdateResult object at 0x10b8874c8>
1 1

可以看到匹配条数为 1 条,影响条数也为 1 条。

如果调用 update_many 方法,则会将所有符合条件的数据都更新,示例如下:

1
2
3
4
condition = {'age': {'$gt': 20}}
result = collection.update_many(condition, {'$inc': {'age': 1}})
print(result)
print(result.matched_count, result.modified_count)

这时匹配条数就不再为 1 条了,运行结果如下:

1
2
<pymongo.results.UpdateResult object at 0x10c6384c8>
3 3

可以看到,这时所有匹配到的数据都会被更新。

11. 删除

删除操作比较简单,直接调用 remove 方法指定删除的条件即可,此时符合条件的所有数据均会被删除。示例如下:

1
2
result = collection.remove({'name': 'Kevin'})
print(result)

运行结果如下:

1
{'ok': 1, 'n': 1}

另外,这里依然存在两个新的推荐方法 —— delete_onedelete_many。示例如下:

1
2
3
4
5
result = collection.delete_one({'name': 'Kevin'})
print(result)
print(result.deleted_count)
result = collection.delete_many({'age': {'$lt': 25}})
print(result.deleted_count)

运行结果如下:

1
2
3
<pymongo.results.DeleteResult object at 0x10e6ba4c8>
1
4

delete_one 即删除第一条符合条件的数据,delete_many 即删除所有符合条件的数据。它们的返回结果都是 DeleteResult 类型,可以调用 deleted_count 属性获取删除的数据条数。

12. 其他操作

另外,PyMongo 还提供了一些组合方法,如 find_one_and_deletefind_one_and_replacefind_one_and_update,它们是查找后删除、替换和更新操作,其用法与上述方法基本一致。

另外,还可以对索引进行操作,相关方法有 create_indexcreate_indexesdrop_index 等。

关于 PyMongo 的详细用法,可以参见官方文档:http://api.mongodb.com/python/current/api/pymongo/collection.html

另外,还有对数据库和集合本身等的一些操作,这里不再一一讲解,可以参见官方文档:http://api.mongodb.com/python/current/api/pymongo/

13. 总结

本节讲解了使用 PyMongo 操作 MongoDB 进行数据增删改查的方法,后面我们会在实战案例中应用这些操作进行数据存储。

本节代码:https://github.com/Python3WebSpider/MongoDBTest

Python

爬虫系列文章总目录:【2022 年】Python3 爬虫学习教程,本教程内容多数来自于《Python3 网络爬虫开发实战(第二版)》一书,目前截止 2022 年,可以将爬虫基本技术进行系统讲解,同时将最新前沿爬虫技术如异步、JavaScript 逆向、AST、安卓逆向、Hook、智能解析、群控技术、WebAssembly、大规模分布式、Docker、Kubernetes 等,市面上目前就仅有《Python3 网络爬虫开发实战(第二版)》一书了,点击了解详情

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

1. 准备工作

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

  • 安装好 Python3,最低为 3.6 版本,并能成功运行 Python3 程序。
  • 了解 Python HTTP 请求库 requests 的基本用法。
  • 了解正则表达式的用法和 Python 中正则表达式库 re 的基本用法。

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

2. 爬取目标

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

这里首页展示了一个个电影的列表,每部电影包含了它的封面、名称、分类、上映时间、评分等内容,同时列表页还支持翻页,点击相应的页码我们就能进入到对应的新列表页。

如果我们点开其中一部电影,会进入到电影的详情页面,比如我们把第一个电影《霸王别姬》打开,会得到如下的页面:

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

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

  • 用 requests 爬取这个站点每一页的电影列表,顺着列表再爬取每个电影的详情页。
  • 用 pyquery 和正则表达式提取每部电影的名称、封面、类别、上映时间、评分、剧情简介等内容。
  • 把以上爬取的内容存入 MongoDB 数据库。
  • 使用多进程实现爬取的加速。

好,那我们现在就开始吧。

3. 爬取列表页

好,第一步的爬取我们肯定要从列表页入手,我们首先观察一下列表页的结构和翻页规则。在浏览器中访问 https://ssr1.scrape.center/,然后打开浏览器开发者工具,我们观察每一个电影信息区块对应的 HTML 以及进入到详情页的 URL 是怎样的,如图所示:

可以看到每部电影对应的区块都是一个 div 节点,它的 class 属性都有 el-card 这个值。每个列表页有 10 个这样的 div 节点,也就对应着 10 部电影的信息。

好,我们再分析下从列表页是怎么进入到详情页的,我们选中电影的名称,看下结果:

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

好的,那接下来我们来分析下翻页的逻辑,我们拉到页面的最下方,可以看到分页页码,如图所示:

这里观察到一共有 100 条数据,10 页的内容,因此页码最多是 10。

接着我们点击第二页,如图所示:

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

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

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

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

  • 遍历页码构造 10 页的索引页 URL。
  • 从每个索引页分析提取出每个电影的详情页 URL。

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

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

1
2
3
4
5
6
7
8
9
10
import requests
import logging
import re
from urllib.parse import urljoin

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

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

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

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

好,定义好了之后,我们来实现一个页面爬取的方法吧,实现如下:

1
2
3
4
5
6
7
8
9
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 代码了。

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

1
2
3
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 了,实现如下:

1
2
3
4
5
6
7
8
9
def parse_index(html):
pattern = re.compile('<a.*?href="(.*?)".*?class="name">')
items = re.findall(pattern, html)
if not items:
return []
for item in items:
detail_url = urljoin(BASE_URL, item)
logging.info('get detail url %s', detail_url)
yield detail_url

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

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

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

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

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

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

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

这样我们通过调用 parse_index 方法并传入列表页的 HTML 代码就可以获得该列表页所有电影的详情页 URL 了。

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

1
2
3
4
5
6
7
8
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 方法就可以将其输出出来。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
2020-03-08 22:39:50,505 - INFO: scraping https://ssr1.scrape.center/page/1...
2020-03-08 22:39:51,949 - INFO: get detail url https://ssr1.scrape.center/detail/1
2020-03-08 22:39:51,950 - INFO: get detail url https://ssr1.scrape.center/detail/2
2020-03-08 22:39:51,950 - INFO: get detail url https://ssr1.scrape.center/detail/3
2020-03-08 22:39:51,950 - INFO: get detail url https://ssr1.scrape.center/detail/4
2020-03-08 22:39:51,950 - INFO: get detail url https://ssr1.scrape.center/detail/5
2020-03-08 22:39:51,950 - INFO: get detail url https://ssr1.scrape.center/detail/6
2020-03-08 22:39:51,950 - INFO: get detail url https://ssr1.scrape.center/detail/7
2020-03-08 22:39:51,950 - INFO: get detail url https://ssr1.scrape.center/detail/8
2020-03-08 22:39:51,950 - INFO: get detail url https://ssr1.scrape.center/detail/9
2020-03-08 22:39:51,950 - INFO: get detail url https://ssr1.scrape.center/detail/10
2020-03-08 22:39:51,951 - INFO: detail urls ['https://ssr1.scrape.center/detail/1', 'https://ssr1.scrape.center/detail/2', 'https://ssr1.scrape.center/detail/3', 'https://ssr1.scrape.center/detail/4', 'https://ssr1.scrape.center/detail/5', 'https://ssr1.scrape.center/detail/6', 'https://ssr1.scrape.center/detail/7', 'https://ssr1.scrape.center/detail/8', 'https://ssr1.scrape.center/detail/9', 'https://ssr1.scrape.center/detail/10']
2020-03-08 22:39:51,951 - INFO: scraping https://ssr1.scrape.center/page/2...
2020-03-08 22:39:52,842 - INFO: get detail url https://ssr1.scrape.center/detail/11
2020-03-08 22:39:52,842 - INFO: get detail url https://ssr1.scrape.center/detail/12
...

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

可以看到,这里程序首先爬取了第一页列表页,然后得到了对应详情页的每个 URL,接着再接着爬第二页、第三页,一直到第十页,依次输出了每一页的详情页 URL。这样,我们就成功获取到了所有电影的详情页 URL 啦。

4. 爬取详情页

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

我们首先观察一下详情页的 HTML 代码吧,如图所示:

经过分析,我们想要提取的内容和对应的节点信息如下:

  • 封面:是一个 img 节点,其 class 属性为 cover。
  • 名称:是一个 h2 节点,其内容便是名称。
  • 类别:是 span 节点,其内容便是类别内容,其外侧是 button 节点,再外侧则是 class 为 categories 的 div 节点。
  • 上映时间:是 span 节点,其内容包含了上映时间,其外侧是包含了 class 为 info 的 div 节点。另外提取结果中还多了「上映」二字,我们可以用正则表达式把日期提取出来。
  • 评分:是一个 p 节点,其内容便是评分,p 节点的 class 属性为 score。
  • 剧情简介:是一个 p 节点,其内容便是剧情简介,其外侧是 class 为 drama 的 div 节点。

看似有点复杂是吧,不用担心,有了正则表达式,我们可以轻松搞定。

接着我们来实现一下代码吧。

刚才我们已经成功获取了详情页 URL,接着当然是定义一个详情页的爬取方法了,实现如下:

1
2
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 方法,灵活性会更好。

好了,详情页的爬取方法已经实现了,接着就是详情页的解析了,实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def parse_detail(html):
cover_pattern = re.compile('class="item.*?<img.*?src="(.*?)".*?class="cover">', re.S)
name_pattern = re.compile('<h2.*?>(.*?)</h2>')
categories_pattern = re.compile('<button.*?category.*?<span>(.*?)</span>.*?</button>', re.S)
published_at_pattern = re.compile('(\d{4}-\d{2}-\d{2})\s?上映')
drama_pattern = re.compile('<div.*?drama.*?>.*?<p.*?>(.*?)</p>', re.S)
score_pattern = re.compile('<p.*?score.*?>(.*?)</p>', re.S)
cover = re.search(cover_pattern, html).group(1).strip() if re.search(cover_pattern, html) else None
name = re.search(name_pattern, html).group(1).strip() if re.search(name_pattern, html) else None
categories = re.findall(categories_pattern, html) if re.findall(categories_pattern, html) else []
published_at = re.search(published_at_pattern, html).group(1) if re.search(published_at_pattern, html) else None
drama = re.search(drama_pattern, html).group(1).strip() if re.search(drama_pattern, html) else None
score = float(re.search(score_pattern, html).group(1).strip()) if re.search(score_pattern, html) else None
return {
'cover': cover,
'name': name,
'categories': categories,
'published_at': published_at,
'drama': drama,
'score': score
}

这里我们定义了 parse_detail 方法用于解析详情页,它接收一个参数为 html,解析其中的内容,并以字典的形式返回结果。每个字段的解析情况如下所述:

  • cover:封面,其值是带有 cover 这个 class 的 img 节点的 src 属性的值 ,所有直接 src 的内容使用 (.*?) 来表示即可,在 img 节点的前面我们再加上一些区分位置的标识符,如 item。由于结果只有一个,写好正则表达式后用 search 方法提取即可。
  • name:名称,其值是 h2 节点的文本值,我们直接在 h2 标签的中间使用 (.*?) 表示即可。由于结果只有一个,写好正则表达式后同样用 search 方法提取即可。
  • categories:类别,我们注意到每个 category 的值都是 button 节点里面的 span 节点的值,所以我们写好表示 button 节点的正则表达式后,再直接在其内部的 span 标签的中间使用 (.*?) 表示即可。由于结果有多个,所以这里使用 findall 方法提取,结果是一个列表。
  • published_at:上映时间,由于每个上映时间信息都包含了「上映」二字,另外日期又都是一个规整的格式,所以对于这个上映时间的提取,我们直接使用标准年月日的正则表达式 (\d{4}-\d{2}-\d{2}) 表示即可。由于结果只有一个,直接使用 search 方法提取即可。
  • drama:直接提取 class 为 drama 的节点内部的 p 节点的文本即可,同样用 search 方法可以提取。
  • score:直接提取 class 为 score 的 p 节点的文本即可,但由于提取结果是字符串,所以我们还需要把它转成浮点数,即 float 类型。

最后,上述的字段提取完毕之后,构造一个字典返回即可。

这样,我们就成功完成了详情页的提取和分析了。

最后,main 方法稍微改写一下,增加这两个方法的调用,改写如下:

1
2
3
4
5
6
7
8
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 并输出。

运行结果如下:

1
2
3
4
5
6
7
8
9
2020-03-08 23:37:35,936 - INFO: scraping https://ssr1.scrape.center/page/1...
2020-03-08 23:37:36,833 - INFO: get detail url https://ssr1.scrape.center/detail/1
2020-03-08 23:37:36,833 - INFO: scraping https://ssr1.scrape.center/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://ssr1.scrape.center/detail/2
2020-03-08 23:37:39,985 - INFO: scraping https://ssr1.scrape.center/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://ssr1.scrape.center/detail/3
...

由于内容较多,这里省略了后续内容。

可以看到,这里我们就成功提取出来了每部电影的基本信息了,包括封面、名称、类别等等。

5. 保存数据

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

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

1
2
3
4
5
6
7
8
9
10
11
import json
from os import makedirs
from os.path import exists

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
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 json file')
save_data(data)
logging.info('data saved successfully')

这里就是加了 save_data 方法的调用,并加了一些日志信息。

重新运行,我们看下输出结果:

1
2
3
4
5
6
7
8
9
10
11
12
2020-03-09 01:10:27,094 - INFO: scraping https://ssr1.scrape.center/page/1...
2020-03-09 01:10:28,019 - INFO: get detail url https://ssr1.scrape.center/detail/1
2020-03-09 01:10:28,019 - INFO: scraping https://ssr1.scrape.center/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 json file
2020-03-09 01:10:29,288 - INFO: data saved successfully
2020-03-09 01:10:29,288 - INFO: get detail url https://ssr1.scrape.center/detail/2
2020-03-09 01:10:29,288 - INFO: scraping https://ssr1.scrape.center/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 json file
2020-03-09 01:10:30,253 - INFO: data saved successfully
...

通过运行结果可以发现,这里成功输出了将数据存储到 JSON 文件的信息。

运行完毕之后我们可以观察下本地的结果,可以看到 results 文件夹下就多了 100 个 JSON 文件,每部电影数据都是一个 JSON 文件,文件名就是电影名,如图所示。

6. 多进程加速

由于整个的爬取是单进程的,而且只能逐条爬取,速度稍微有点慢,我们有没有方法来对整个爬取过程进行加速呢?

在前面我们讲了多进程的基本原理和使用方法,下面我们就来实践一下多进程的爬取吧。

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 json data')
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 方法,第一个参数就是需要被调用的参数,第二个参数就是 pages,即需要遍历的页码。

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

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

7. 总结

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

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

本节代码:https://github.com/Python3WebSpider/ScrapeSsr1。

Python

爬虫系列文章总目录:【2022 年】Python3 爬虫学习教程,本教程内容多数来自于《Python3 网络爬虫开发实战(第二版)》一书,目前截止 2022 年,可以将爬虫基本技术进行系统讲解,同时将最新前沿爬虫技术如异步、JavaScript 逆向、AST、安卓逆向、Hook、智能解析、群控技术、WebAssembly、大规模分布式、Docker、Kubernetes 等,市面上目前就仅有《Python3 网络爬虫开发实战(第二版)》一书了,点击了解详情

上一节中,我们了解了 urllib 的基本用法,但是其中确实有不方便的地方,比如处理网页验证和 Cookie 时,需要写 Opener 和 Handler 来处理。另外我们要实现 POST、PUT 等请求时写法也不太方便。

为了更加方便地实现这些操作,就有了更为强大的库 requests,有了它,Cookie、登录验证、代理设置等操作都不是事儿。

接下来,让我们领略一下它的强大之处吧。

1. 准备工作

在开始之前,请确保已经正确安装好了 requests 库,如尚未安装可以使用 pip3 来安装:

1
pip3 install requests

更加详细的安装说明可以参考 https://setup.scrape.center/requests。

2. 实例引入

urllib 库中的 urlopen 方法实际上是以 GET 方式请求网页,而 requests 中相应的方法就是 get 方法,是不是感觉表达更明确一些?下面通过实例来看一下:

1
2
3
4
5
6
7
8
import requests

r = requests.get('https://www.baidu.com/')
print(type(r))
print(r.status_code)
print(type(r.text))
print(r.text[:100])
print(r.cookies)

运行结果如下:

1
2
3
4
5
6
<class 'requests.models.Response'>
200
<class 'str'>
<!DOCTYPE html>
<!--STATUS OK--><html> <head><meta http-equiv=content-type content=text/html;charse
<RequestsCookieJar[<Cookie BDORZ=27315 for .baidu.com/>]>

这里我们调用 get 方法实现与 urlopen 相同的操作,得到一个 Response 对象,然后分别输出了 Response 的类型、状态码、响应体的类型、内容以及 Cookie。

通过运行结果可以发现,它的返回类型是 requests.models.Response,响应体的类型是字符串 str,Cookie 的类型是 RequestsCookieJar。

使用 get 方法成功实现一个 GET 请求,这倒不算什么,更方便之处在于其他的请求类型依然可以用一句话来完成,示例如下:

1
2
3
4
5
6
7
import requests

r = requests.get('https://httpbin.org/get')
r = requests.post('https://httpbin.org/post')
r = requests.put('https://httpbin.org/put')
r = requests.delete('https://httpbin.org/delete')
r = requests.patch('https://httpbin.org/patch')

这里分别用 post、put、delete 等方法实现了 POST、PUT、DELETE 等请求。是不是比 urllib 简单太多了?

其实这只是冰山一角,更多的还在后面。

3. GET 请求

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

基本实例

首先,构建一个最简单的 GET 请求,请求的链接为 https://httpbin.org/get,该网站会判断如果客户端发起的是 GET 请求的话,它返回相应的请求信息:

1
2
3
4
import requests

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

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
{
"args": {},
"headers": {
"Accept": "*/*",
"Accept-Encoding": "gzip, deflate",
"Host": "httpbin.org",
"User-Agent": "python-requests/2.22.0",
"X-Amzn-Trace-Id": "Root=1-5e6e3a2e-6b1a28288d721c9e425a462a"
},
"origin": "17.20.233.237",
"url": "https://httpbin.org/get"
}

可以发现,我们成功发起了 GET 请求,返回结果中包含请求头、URL、IP 等信息。

那么,对于 GET 请求,如果要附加额外的信息,一般怎样添加呢?比如现在想添加两个参数,其中 name 是 germey,age 是 25,URL 就可以写成如下内容:

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

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

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

这样也可以,但是是不是有点不人性化呢?这些参数还需要我们手动去拼接,实现起来有点不优雅。

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

1
2
3
4
5
6
7
8
import requests

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

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"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": "https://httpbin.org/get?age=22&name=germey"
}

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

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

1
2
3
4
5
6
import requests

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

运行结果如下:

1
2
3
<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 形式的字符串,那么如果请求普通的网页,则肯定能获得相应的内容了。下面以一个实例页面 https://ssr1.scrape.center/ 来试一下,我们再加上一点提取信息的逻辑,将代码完善成如下的样子:

1
2
3
4
5
6
7
import requests
import re

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

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

运行结果如下:

1
['肖申克的救赎 - 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 文档。如果想抓取图片、音频、视频等文件,应该怎么办呢?

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

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

1
2
3
4
5
import requests

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

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

image-20210704202919308

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

运行结果如图所示,分别是 r.text 和 r.content 的结果。

image-20210704203039567

image-20210704202959490

可以注意到,前者出现了乱码,后者结果前带有一个 b,这代表是 bytes 类型的数据。由于图片是二进制数据,所以前者在打印时转化为 str 类型,也就是图片直接转化为字符串,这理所当然会出现乱码。

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

1
2
3
4
5
import requests

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

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

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

image-20210704203204899

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

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

添加 headers

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

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

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

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

1
2
3
4
5
6
7
8
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://ssr1.scrape.center/', headers=headers)
print(r.text)

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

4. POST 请求

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

1
2
3
4
5
import requests

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

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

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
"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-5e6e3b52-0f36782ea980fce53c8c6524"
},
"json": null,
"origin": "17.20.232.237",
"url": "https://httpbin.org/post"
}

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

5. 响应

发送请求后,得到的自然就是响应。在上面的实例中,我们使用 text 和 content 获取了响应的内容。此外,还有很多属性和方法可以用来获取其他信息,比如状态码、响应头、Cookie 等。示例如下:

1
2
3
4
5
6
7
8
import requests

r = requests.get('https://ssr1.scrape.center/')
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 属性得到 Cookie,输出 url 属性得到 URL,输出 history 属性得到请求历史。

运行结果如下:

1
2
3
4
5
<class 'int'> 200
<class 'requests.structures.CaseInsensitiveDict'> {'Server': 'nginx/1.17.8', 'Date': 'Sat, 30 May 2020 16:56:40 GMT', 'Content-Type': 'text/html; charset=utf-8', 'Transfer-Encoding': 'chunked', 'Connection': 'keep-alive', 'Vary': 'Accept-Encoding', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff', 'Strict-Transport-Security': 'max-age=15724800; includeSubDomains', 'Content-Encoding': 'gzip'}
<class 'requests.cookies.RequestsCookieJar'> <RequestsCookieJar[]>
<class 'str'> https://ssr1.scrape.center/
<class 'list'> []

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

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

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

1
2
3
4
import requests

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# 信息性状态码
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 来比对。

6. 高级用法

前面我们了解了 requests 的基本用法,如基本的 GET、POST 请求以及 Response 对象。下面我们再来了解下 requests 的一些高级用法,如文件上传、Cookie 设置、代理设置等。

文件上传

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

1
2
3
4
5
import requests

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

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

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"args": {},
"data": "",
"files": {
"file": "data:application/octet-stream;base64,AAABAAI..."
},
"form": {},
"headers": {
"Accept": "*/*",
"Accept-Encoding": "gzip, deflate",
"Content-Length": "6665",
"Content-Type": "multipart/form-data; boundary=41fc691282cc894f8f06adabb24f05fb",
"Host": "httpbin.org",
"User-Agent": "python-requests/2.22.0",
"X-Amzn-Trace-Id": "Root=1-5e6e3c0b-45b07bdd3a922e364793ef48"
},
"json": null,
"origin": "16.20.232.237",
"url": "https://httpbin.org/post"
}

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

前面我们使用 urllib 处理过 Cookie,写法比较复杂,而有了 requests,获取和设置 Cookie 只需一步即可完成。

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

1
2
3
4
5
6
import requests

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

运行结果如下:

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

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

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

image-20200301214840166

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

1
2
3
4
5
6
7
8
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)

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

image-20200301215251376

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
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,也就是说相当于你用了两个浏览器打开了不同的页面。

设想这样一个场景,第一个请求利用 requests 的 post 方法登录了某个网站,第二次想获取成功登录后的自己的个人信息,又用了一次 requests 的 get 方法去请求个人信息页面。

实际上,这相当于打开了两个浏览器,是两个完全独立的操作,对应两个完全不相关的 Session,能成功获取个人信息吗?那当然不能。

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

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

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

我们先做一个小实验吧,如果沿用之前的写法,示例如下:

1
2
3
4
5
import requests

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

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

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

运行结果如下:

1
2
3
{
"cookies": {}
}

这并不行。

这时候,我们再用刚才所说的 Session 试试看:

1
2
3
4
5
6
import requests

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

再看下运行结果:

1
2
3
{
"cookies": {"number": "123456789"}
}

这些可以看到 Cookies 被成功获取了!这下能体会到同一个会话和不同会话的区别了吧!

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

Session 在平常用得非常广泛,可以用于模拟在一个浏览器中打开同一站点的不同页面,后面会有专门的章节来讲解这部分内容。

SSL 证书验证

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

比如这个示例网站:https://ssr2.scrape.center/,如果我们用 Chrome 浏览器打开这个 URL,则会提示「您的连接不是私密连接」这样的错误,如图所示:

image-20210704204017465

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

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

1
2
3
4
import requests

response = requests.get('https://ssr2.scrape.center/')
print(response.status_code)

运行结果如下:

1
requests.exceptions.SSLError: HTTPSConnectionPool(host='ssr2.scrape.center', port=443): Max retries exceeded with url: / (Caused by SSLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1056)')))

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

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

我们改写代码如下:

1
2
3
4
import requests

response = requests.get('https://ssr2.scrape.center/', verify=False)
print(response.status_code)

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

1
2
3
/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

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

1
2
3
4
5
6
import requests
from requests.packages import urllib3

urllib3.disable_warnings()
response = requests.get('https://ssr2.scrape.center/', verify=False)
print(response.status_code)

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

1
2
3
4
5
6
import logging
import requests

logging.captureWarnings(True)
response = requests.get('https://ssr2.scrape.center/', verify=False)
print(response.status_code)

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

1
2
3
4
import requests

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

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

超时设置

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

1
2
3
4
import requests

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

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

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

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

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

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

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

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

或直接不加参数:

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

身份认证

在上一节我们讲到,在访问启用了基本身份认证的网站时,我们会首先遇到一个认证窗口,例如:https://ssr3.scrape.center/,如图所示。

image-20210704202140395

这个网站就是启用了基本身份认证,在上一节中我们可以利用 urllib 来实现身份的校验,但实现起来相对繁琐。那在 reqeusts 中怎么做呢?当然也有办法。

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

1
2
3
4
5
import requests
from requests.auth import HTTPBasicAuth

r = requests.get('https://ssr3.scrape.center/', auth=HTTPBasicAuth('admin', 'admin'))
print(r.status_code)

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

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

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

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

1
2
3
4
import requests

r = requests.get('https://ssr3.scrape.center/', auth=('admin', 'admin'))
print(r.status_code)

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

1
pip3 install requests_oauthlib

使用 OAuth1 认证的示例方法如下:

1
2
3
4
5
6
7
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 参数。可以用这样的方式设置:

1
2
3
4
5
6
7
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 这样的语法来设置代理,示例如下:

1
2
3
4
import requests

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

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

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

1
pip3 install "requests[socks]"

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

1
2
3
4
5
6
7
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 对象来试试,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
from requests import Request, Session

url = 'https://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 方法发送,运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"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/

7. 总结

本节的 requests 库的基本用法就介绍到这里了,怎么样?有没有感觉它比 urllib 使用起来更为方便。本节内容需要好好掌握,在后文我们会在实战中使用 requests 完成一个网站的爬取,巩固 requests 的相关知识。

本节代码:https://github.com/Python3WebSpider/RequestsTest。

Python

爬虫系列文章总目录:【2022 年】Python3 爬虫学习教程,本教程内容多数来自于《Python3 网络爬虫开发实战(第二版)》一书,目前截止 2022 年,可以将爬虫基本技术进行系统讲解,同时将最新前沿爬虫技术如异步、JavaScript 逆向、AST、安卓逆向、Hook、智能解析、群控技术、WebAssembly、大规模分布式、Docker、Kubernetes 等,市面上目前就仅有《Python3 网络爬虫开发实战(第二版)》一书了,点击了解详情

在上一节中,我们已经可以用 requests 来获取网页的源代码,得到 HTML 代码。但我们真正想要的数据是包含在 HTML 代码之中的,怎么才能从 HTML 代码中获取我们想要的信息呢?正则表达式就是其中一个有效的方法。

本节中,我们了解一下正则表达式的相关用法。正则表达式是处理字符串的强大工具,它有自己特定的语法结构,有了它,实现字符串的检索、替换、匹配验证都不在话下。

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

1. 实例引入

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

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

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

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

在网页右侧选择「匹配 Email 地址」,就可以看到下方出现了文本中的 E-mail。

如果选择「匹配网址 URL」,就可以看到下方出现了文本中的 URL。

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

其实,这里就是用了正则表达式匹配,也就是用一定的规则将特定的文本提取出来。比如,电子邮件开头是一段字符串,然后是一个 @ 符号,最后是某个域名,这是有特定的组成格式的。另外,对于 URL,开头是协议类型,然后是冒号加双斜线,最后是域名加路径。

对于 URL 来说,可以用下面的正则表达式匹配:

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

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

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

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

上面我们说了几个匹配规则,表 2- 列出了常用的匹配规则。

表 2- 常用的匹配规则

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

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

其实正则表达式不是 Python 独有的,它也可以用在其他编程语言中。Python 的 re 库提供了整个正则表达式的实现,利用这个库,可以在 Python 中使用正则表达式。在 Python 中写正则表达式几乎都用这个库,下面就来了解它的一些常用方法。

2. match

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

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

1
2
3
4
5
6
7
8
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())

运行结果如下:

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

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

1
^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 方法传入分组的索引即可获取提取的结果。示例如下:

1
2
3
4
5
6
7
8
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) 获取匹配结果。

运行结果如下:

1
2
3
4
<_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 匹配,这样的工作量非常大。其实完全没必要这么做,因为还有一个万能匹配可以用,那就是 .*。其中 . 可以匹配任意字符(除换行符),* 代表匹配前面的字符无限次,所以它们组合在一起就可以匹配任意字符了。有了它,我们就不用挨个字符匹配了。

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

1
2
3
4
5
6
7
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())

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

1
2
3
<_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),这是整个字符串的长度。

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

贪婪与非贪婪

使用上面的通用匹配 .* 时,可能有时候匹配到的并不是我们想要的结果。看下面的例子:

1
2
3
4
5
6
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$,看样子并没有什么问题。我们看下运行结果:

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

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

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

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

1
2
3
4
5
6
import re

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

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

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

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

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

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

1
2
3
4
5
6
7
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))

运行结果如下:

1
2
result1
result2 kEraCN

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

修饰符

正则表达式可以包含一些可选标志修饰符来控制匹配模式。修饰符被指定为一个可选的标志。我们用实例来看一下:

1
2
3
4
5
6
7
import re

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

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

1
2
3
4
5
6
7
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,即可修正这个错误:

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

这个修饰符的作用是使。匹配包括换行符在内的所有字符。此时运行结果如下:

1
1234567

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

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

表 2- 修饰符及其描述

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

在网页匹配中,较为常用的有 re.Sre.I

转义匹配

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

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

1
2
3
4
5
import re

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

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

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

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

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

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

1
2
3
4
5
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 开头,整个正则表达式是字符串的一部分,但是这样匹配是失败的。运行结果如下:

1
None

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

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

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

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

这时就得到了匹配结果。

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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 节点还有一些相应的属性 —— 超链接和歌手名。

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

此时正则表达式可以以 li 开头,然后寻找一个标志符 active,中间的部分可以用 .*? 来匹配。接下来,要提取 singer 这个属性值,所以还需要写入 singer="(.*?)",这里需要提取的部分用小括号括起来,以便用 group 方法提取出来,它的两侧边界是双引号。然后还需要匹配 a 节点的文本,其中它的左边界是 >,右边界是 </a>。然后目标内容依然用 (.*?) 来匹配,所以最后的正则表达式就变成了:

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

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

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

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

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

运行结果如下:

1
齐秦 往事随风

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

如果正则表达式不加 active(也就是匹配不带 classactive 的节点内容),那会怎样呢?我们将正则表达式中的 active 去掉,代码改写如下:

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

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

1
任贤齐 沧海一声笑

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

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

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

运行结果如下:

1
beyond 光辉岁月

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

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

4. findall

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

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

1
2
3
4
5
6
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])

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
[('/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 方法。

5. sub

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

1
2
3
4
5
import re

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

运行结果如下:

1
aKyroiRixLg

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

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

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

运行结果如下:

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

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

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

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<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 方法可以起到事半功倍的效果。

6. compile

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

1
2
3
4
5
6
7
8
9
10
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 方法将正则表达式编译成一个正则表达式对象,以便复用。

运行结果如下:

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

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

7. 总结

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

本节代码:https://github.com/Python3WebSpider/RegexTest。

Python

爬虫系列文章总目录:【2022 年】Python3 爬虫学习教程,本教程内容多数来自于《Python3 网络爬虫开发实战(第二版)》一书,目前截止 2022 年,可以将爬虫基本技术进行系统讲解,同时将最新前沿爬虫技术如异步、JavaScript 逆向、AST、安卓逆向、Hook、智能解析、群控技术、WebAssembly、大规模分布式、Docker、Kubernetes 等,市面上目前就仅有《Python3 网络爬虫开发实战(第二版)》一书了,点击了解详情

首先我们介绍一个 Python 库,叫做 urllib,利用它我们可以实现 HTTP 请求的发送,而不用去关心 HTTP 协议本身甚至更低层的实现。我们只需要指定请求的 URL、请求头、请求体等信息即可实现 HTTP 请求的发送,同时 urllib 还可以把服务器返回的响应转化为 Python 对象,通过该对象我们便可以方便地获取响应的相关信息了,如响应状态码、响应头、响应体等等。

注意:在 Python 2 中,有 urllib 和 urllib2 两个库来实现请求的发送。而在 Python 3 中,已经不存在 urllib2 这个库了,统一为 urllib,其官方文档链接为:https://docs.python.org/3/library/urllib.html

首先,我们来了解一下 urllib 库的使用方法,它是 Python 内置的 HTTP 请求库,也就是说不需要额外安装即可使用。它包含如下 4 个模块。

  • request:它是最基本的 HTTP 请求模块,可以用来模拟发送请求。就像在浏览器里输入网址然后回车一样,只需要给库方法传入 URL 以及额外的参数,就可以模拟实现这个过程了。
  • error:异常处理模块,如果出现请求错误,我们可以捕获这些异常,然后进行重试或其他操作以保证程序不会意外终止。
  • parse:一个工具模块,提供了许多 URL 处理方法,比如拆分、解析和合并等。
  • robotparser:主要用来识别网站的 robots.txt 文件,然后判断哪些网站可以爬,哪些网站不可以爬,它其实用得比较少。

1. 发送请求

使用 urllib 的 request 模块,我们可以方便地实现请求的发送并得到响应。我们先来看下它的具体用法。

urlopen

urllib.request 模块提供了最基本的构造 HTTP 请求的方法,利用它可以模拟浏览器的一个请求发起过程,同时它还带有处理授权验证(Authentication)、重定向(Redirection)、浏览器 Cookie 以及其他内容。

下面我们来看一下它的强大之处。这里以 Python 官网为例,我们来把这个网页抓下来:

1
2
3
4
import urllib.request

response = urllib.request.urlopen('https://www.python.org')
print(response.read().decode('utf-8'))

运行结果如图所示。

image-20200315212839610

图 运行结果

这里我们只用了两行代码,便完成了 Python 官网的抓取,输出了网页的源代码。得到源代码之后呢?我们想要的链接、图片地址、文本信息不就都可以提取出来了吗?

接下来,看看它返回的到底是什么。利用 type 方法输出响应的类型:

1
2
3
4
import urllib.request

response = urllib.request.urlopen('https://www.python.org')
print(type(response))

输出结果如下:

1
<class 'http.client.HTTPResponse'>

可以发现,它是一个 HTTPResposne 类型的对象,主要包含 readreadintogetheadergetheadersfileno 等方法,以及 msgversionstatusreasondebuglevelclosed 等属性。

得到这个对象之后,我们把它赋值为 response 变量,然后就可以调用这些方法和属性,得到返回结果的一系列信息了。

例如,调用 read 方法可以得到返回的网页内容,调用 status 属性可以得到返回结果的状态码,如 200 代表请求成功,404 代表网页未找到等。

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

1
2
3
4
5
6
import urllib.request

response = urllib.request.urlopen('https://www.python.org')
print(response.status)
print(response.getheaders())
print(response.getheader('Server'))

运行结果如下:

1
2
3
200
[('Server', 'nginx'), ('Content-Type', 'text/html; charset=utf-8'), ('X-Frame-Options', 'DENY'), ('Via', '1.1 vegur'), ('Via', '1.1 varnish'), ('Content-Length', '48775'), ('Accept-Ranges', 'bytes'), ('Date', 'Sun, 15 Mar 2020 13:29:01 GMT'), ('Via', '1.1 varnish'), ('Age', '708'), ('Connection', 'close'), ('X-Served-By', 'cache-bwi5120-BWI, cache-tyo19943-TYO'), ('X-Cache', 'HIT, HIT'), ('X-Cache-Hits', '2, 518'), ('X-Timer', 'S1584278942.717942,VS0,VE0'), ('Vary', 'Cookie'), ('Strict-Transport-Security', 'max-age=63072000; includeSubDomains')]
nginx

可见,前两个输出分别输出了响应的状态码和响应的头信息,最后一个输出通过调用 getheader 方法并传递一个参数 Server 获取了响应头中的 Server 值,结果是 nginx,意思是服务器是用 Nginx 搭建的。

利用最基本的 urlopen 方法,可以完成最基本的简单网页的 GET 请求抓取。

如果想给链接传递一些参数,该怎么实现呢?首先看一下 urlopen 方法的 API:

1
urllib.request.urlopen(url, data=None, [timeout,]*, cafile=None, capath=None, cadefault=False, context=None)

可以发现,除了第一个参数可以传递 URL 之外,我们还可以传递其他内容,比如 data(附加数据)、timeout(超时时间)等。

下面我们详细说明这几个参数的用法。

data 参数

data 参数是可选的。如果要添加该参数,需要使用 bytes 方法将参数转化为字节流编码格式的内容,即 bytes 类型。另外,如果传递了这个参数,则它的请求方式就不再是 GET 方式,而是 POST 方式。

下面用实例来看一下:

1
2
3
4
5
6
import urllib.parse
import urllib.request

data = bytes(urllib.parse.urlencode({'name': 'germey'}), encoding='utf-8')
response = urllib.request.urlopen('https://httpbin.org/post', data=data)
print(response.read().decode('utf-8'))

这里我们传递了一个参数 word,值是 hello。它需要被转码成 bytes(字节流)类型。其中转字节流采用了 bytes 方法,该方法的第一个参数需要是 str(字符串)类型,需要用 urllib.parse 模块里的 urlencode 方法来将参数字典转化为字符串;第二个参数指定编码格式,这里指定为 utf-8

这里请求的站点是 httpbin.org,它可以提供 HTTP 请求测试。本次我们请求的 URL 为 https://httpbin.org/post,这个链接可以用来测试 POST 请求,它可以输出 Request 的一些信息,其中就包含我们传递的 data 参数。

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"args": {},
"data": "",
"files": {},
"form": {
"name": "germey"
},
"headers": {
"Accept-Encoding": "identity",
"Content-Length": "11",
"Content-Type": "application/x-www-form-urlencoded",
"Host": "httpbin.org",
"User-Agent": "Python-urllib/3.7",
"X-Amzn-Trace-Id": "Root=1-5ed27e43-9eee361fec88b7d3ce9be9db"
},
"json": null,
"origin": "17.220.233.154",
"url": "https://httpbin.org/post"
}

我们传递的参数出现在了 form 字段中,这表明是模拟了表单提交的方式,以 POST 方式传输数据。

timeout 参数

timeout 参数用于设置超时时间,单位为秒,意思就是如果请求超出了设置的这个时间,还没有得到响应,就会抛出异常。如果不指定该参数,就会使用全局默认时间。它支持 HTTP、HTTPS、FTP 请求。

下面用实例来看一下:

1
2
3
4
import urllib.request

response = urllib.request.urlopen('https://httpbin.org/get', timeout=0.1)
print(response.read())

运行结果可能如下:

1
2
3
4
5
During handling of the above exception, another exception occurred:
Traceback (most recent call last): File "/var/py/python/urllibtest.py", line 4, in <module> response =
urllib.request.urlopen('https://httpbin.org/get', timeout=0.1)
...
urllib.error.URLError: <urlopen error _ssl.c:1059: The handshake operation timed out>

这里我们设置的超时时间是 1 秒。程序运行 1 秒过后,服务器依然没有响应,于是抛出了 URLError 异常。该异常属于 urllib.error 模块,错误原因是超时。

因此,可以通过设置这个超时时间来控制一个网页如果长时间未响应,就跳过它的抓取。这可以利用 try…except 语句来实现,相关代码如下:

1
2
3
4
5
6
7
8
9
import socket
import urllib.request
import urllib.error

try:
response = urllib.request.urlopen('https://httpbin.org/get', timeout=0.1)
except urllib.error.URLError as e:
if isinstance(e.reason, socket.timeout):
print('TIME OUT')

这里我们请求了 https://httpbin.org/get 这个测试链接,设置的超时时间是 0.1 秒,然后捕获了 URLError 这个异常,然后判断异常类型是 socket.timeout,意思就是超时异常。因此,得出它确实是因为超时而报错,打印输出了 TIME OUT

运行结果如下:

1
TIME OUT

按照常理来说,0.1 秒内基本不可能得到服务器响应,因此输出了 TIME OUT 的提示。

通过设置 timeout 这个参数来实现超时处理,有时还是很有用的。

其他参数

除了 data 参数和 timeout 参数外,还有 context 参数,它必须是 ssl.SSLContext 类型,用来指定 SSL 设置。

此外,cafilecapath 这两个参数分别指定 CA 证书和它的路径,这个在请求 HTTPS 链接时会有用。

cadefault 参数现在已经弃用了,其默认值为 False

前面讲解了 urlopen 方法的用法,通过这个最基本的方法,我们可以完成简单的请求和网页抓取。若需更加详细的信息,可以参见官方文档:https://docs.python.org/3/library/urllib.request.html

Request

我们知道利用 urlopen 方法可以实现最基本请求的发起,但这几个简单的参数并不足以构建一个完整的请求。如果请求中需要加入 Headers 等信息,就可以利用更强大的 Request 类来构建。

首先,我们用实例来感受一下 Request 类的用法:

1
2
3
4
5
import urllib.request

request = urllib.request.Request('https://python.org')
response = urllib.request.urlopen(request)
print(response.read().decode('utf-8'))

可以发现,我们依然用 urlopen 方法来发送这个请求,只不过这次该方法的参数不再是 URL,而是一个 Request 类型的对象。通过构造这个数据结构,一方面我们可以将请求独立成一个对象,另一方面可更加丰富和灵活地配置参数。

下面我们看一下 Request 可以通过怎样的参数来构造,它的构造方法如下:

1
class urllib.request.Request(url, data=None, headers={}, origin_req_host=None, unverifiable=False, method=None)

其中,第一个参数 url 用于请求 URL,这是必传参数,其他都是可选参数。

第二个参数 data 如果要传,必须传 bytes(字节流)类型的。如果它是字典,可以先用 urllib.parse 模块里的 urlencode() 编码。

第三个参数 headers 是一个字典,它就是请求头。我们在构造请求时,既可以通过 headers 参数直接构造,也可以通过调用请求实例的 add_header() 方法添加。

添加请求头最常用的方法就是通过修改 User-Agent 来伪装浏览器。默认的 User-AgentPython-urllib,我们可以通过修改它来伪装浏览器。比如要伪装火狐浏览器,你可以把它设置为:

1
Mozilla/5.0 (X11; U; Linux i686) Gecko/20071127 Firefox/2.0.0.11

第四个参数 origin_req_host 指的是请求方的 host 名称或者 IP 地址。

第五个参数 unverifiable 表示这个请求是否是无法验证的,默认是 False,意思就是说用户没有足够权限来选择接收这个请求的结果。例如,我们请求一个 HTML 文档中的图片,但是我们没有自动抓取图像的权限,这时 unverifiable 的值就是 True

第六个参数 method 是一个字符串,用来指示请求使用的方法,比如 GET、POST 和 PUT 等。

下面我们传入多个参数来构建请求:

1
2
3
4
5
6
7
8
9
10
11
12
from urllib import request, parse

url = 'https://httpbin.org/post'
headers = {
'User-Agent': 'Mozilla/4.0 (compatible; MSIE 5.5; Windows NT)',
'Host': 'httpbin.org'
}
dict = {'name': 'germey'}
data = bytes(parse.urlencode(dict), encoding='utf-8')
req = request.Request(url=url, data=data, headers=headers, method='POST')
response = request.urlopen(req)
print(response.read().decode('utf-8'))

这里我们通过 4 个参数构造了一个请求,其中 url 即请求 URL,headers 中指定了 User-AgentHost,参数 dataurlencodebytes 方法转成字节流。另外,指定了请求方式为 POST。

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"args": {},
"data": "",
"files": {},
"form": {
"name": "germey"
},
"headers": {
"Accept-Encoding": "identity",
"Content-Length": "11",
"Content-Type": "application/x-www-form-urlencoded",
"Host": "httpbin.org",
"User-Agent": "Mozilla/4.0 (compatible; MSIE 5.5; Windows NT)",
"X-Amzn-Trace-Id": "Root=1-5ed27f77-884f503a2aa6760df7679f05"
},
"json": null,
"origin": "17.220.233.154",
"url": "https://httpbin.org/post"
}

观察结果可以发现,我们成功设置了 dataheadersmethod

另外,headers 也可以用 add_header 方法来添加:

1
2
req = request.Request(url=url, data=data, method='POST')
req.add_header('User-Agent', 'Mozilla/4.0 (compatible; MSIE 5.5; Windows NT)')

如此一来,我们就可以更加方便地构造请求,实现请求的发送啦。

高级用法

在上面的过程中,我们虽然可以构造请求,但是对于一些更高级的操作(比如 Cookies 处理、代理设置等),该怎么办呢?

接下来,就需要更强大的工具 Handler 登场了。简而言之,我们可以把它理解为各种处理器,有专门处理登录验证的,有处理 Cookie 的,有处理代理设置的。利用它们,我们几乎可以做到 HTTP 请求中所有的事情。

首先,介绍一下 urllib.request 模块里的 BaseHandler 类,它是所有其他 Handler 的父类,它提供了最基本的方法,例如 default_openprotocol_request 等。

接下来,就有各种 Handler 子类继承这个 BaseHandler 类,举例如下。

  • HTTPDefaultErrorHandler 用于处理 HTTP 响应错误,错误都会抛出 HTTPError 类型的异常。
  • HTTPRedirectHandler 用于处理重定向。
  • HTTPCookieProcessor 用于处理 Cookies。
  • ProxyHandler 用于设置代理,默认代理为空。
  • HTTPPasswordMgr 用于管理密码,它维护了用户名和密码的表。
  • HTTPBasicAuthHandler 用于管理认证,如果一个链接打开时需要认证,那么可以用它来解决认证问题。

另外,还有其他的 Handler 类,这里就不一一列举了,详情可以参考官方文档: https://docs.python.org/3/library/urllib.request.html#urllib.request.BaseHandler

关于怎么使用它们,现在先不用着急,后面会有实例演示。

另一个比较重要的类就是 OpenerDirector,我们可以称为 Opener。我们之前用过 urlopen 这个方法,实际上它就是 urllib 为我们提供的一个 Opener。

那么,为什么要引入 Opener 呢?因为需要实现更高级的功能。之前使用的 Requesturlopen 相当于类库为你封装好了极其常用的请求方法,利用它们可以完成基本的请求,但是现在不一样了,我们需要实现更高级的功能,所以需要深入一层进行配置,使用更底层的实例来完成操作,所以这里就用到了 Opener。

Opener 可以使用 open 方法,返回的类型和 urlopen 如出一辙。那么,它和 Handler 有什么关系呢?简而言之,就是利用 Handler 来构建 Opener。

下面用几个实例来看看它们的用法。

验证

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

image-20210704202140395

图 2- 认证窗口

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

那么,如果要请求这样的页面,该怎么办呢?借助 HTTPBasicAuthHandler 就可以完成,相关代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from urllib.request import HTTPPasswordMgrWithDefaultRealm, HTTPBasicAuthHandler, build_opener
from urllib.error import URLError

username = 'admin'
password = 'admin'
url = 'https://ssr3.scrape.center/'

p = HTTPPasswordMgrWithDefaultRealm()
p.add_password(None, url, username, password)
auth_handler = HTTPBasicAuthHandler(p)
opener = build_opener(auth_handler)

try:
result = opener.open(url)
html = result.read().decode('utf-8')
print(html)
except URLError as e:
print(e.reason)

这里首先实例化 HTTPBasicAuthHandler 对象,其参数是 HTTPPasswordMgrWithDefaultRealm 对象,它利用 add_password 方法添加进去用户名和密码,这样就建立了一个处理验证的 Handler。

接下来,利用这个 Handler 并使用 build_opener 方法构建一个 Opener,这个 Opener 在发送请求时就相当于已经验证成功了。

接下来,利用 Opener 的 open 方法打开链接,就可以完成验证了。这里获取到的结果就是验证后的页面源码内容。

代理

在做爬虫的时候,免不了要使用代理,如果要添加代理,可以这样做:

1
2
3
4
5
6
7
8
9
10
11
12
13
from urllib.error import URLError
from urllib.request import ProxyHandler, build_opener

proxy_handler = ProxyHandler({
'http': 'http://127.0.0.1:8080',
'https': 'https://127.0.0.1:8080'
})
opener = build_opener(proxy_handler)
try:
response = opener.open('https://www.baidu.com')
print(response.read().decode('utf-8'))
except URLError as e:
print(e.reason)

这里我们在本地需要先事先搭建一个 HTTP 代理,运行在 8080 端口上。

这里使用了 ProxyHandler,其参数是一个字典,键名是协议类型(比如 HTTP 或者 HTTPS 等),键值是代理链接,可以添加多个代理。

然后,利用这个 Handler 及 build_opener 方法构造一个 Opener,之后发送请求即可。

Cookie 的处理就需要相关的 Handler 了。

我们先用实例来看看怎样将网站的 Cookie 获取下来,相关代码如下:

1
2
3
4
5
6
7
8
import http.cookiejar, urllib.request

cookie = http.cookiejar.CookieJar()
handler = urllib.request.HTTPCookieProcessor(cookie)
opener = urllib.request.build_opener(handler)
response = opener.open('https://www.baidu.com')
for item in cookie:
print(item.name + "=" + item.value)

首先,我们必须声明一个 CookieJar 对象。接下来,就需要利用 HTTPCookieProcessor 来构建一个 Handler,最后利用 build_opener 方法构建出 Opener,执行 open 函数即可。

运行结果如下:

1
2
3
4
5
6
BAIDUID=A09E6C4E38753531B9FB4C60CE9FDFCB:FG=1
BIDUPSID=A09E6C4E387535312F8AA46280C6C502
H_PS_PSSID=31358_1452_31325_21088_31110_31253_31605_31271_31463_30823
PSTM=1590854698
BDSVRTM=10
BD_HOME=1

可以看到,这里输出了每个 Cookie 条目的名称和值。

不过既然能输出,那可不可以输出成文件格式呢?我们知道 Cookie 实际上也是以文本形式保存的。

答案当然是肯定的,这里通过下面的实例来看看:

1
2
3
4
5
6
7
8
import urllib.request, http.cookiejar

filename = 'cookie.txt'
cookie = http.cookiejar.MozillaCookieJar(filename)
handler = urllib.request.HTTPCookieProcessor(cookie)
opener = urllib.request.build_opener(handler)
response = opener.open('https://www.baidu.com')
cookie.save(ignore_discard=True, ignore_expires=True)

这时 CookieJar 就需要换成 MozillaCookieJar,它在生成文件时会用到,是 CookieJar 的子类,可以用来处理 Cookie 和文件相关的事件,比如读取和保存 Cookie,可以将 Cookie 保存成 Mozilla 型浏览器的 Cookie 格式。

运行之后,可以发现生成了一个 cookie.txt 文件,其内容如下:

1
2
3
4
5
6
7
8
9
10
# Netscape HTTP Cookie File
# http://curl.haxx.se/rfc/cookie_spec.html
# This is a generated file! Do not edit.

.baidu.com TRUE / FALSE 1622390755 BAIDUID 0B4A68D74B0C0E53E5B82AFD9BF9178F:FG=1
.baidu.com TRUE / FALSE 3738338402 BIDUPSID 0B4A68D74B0C0E53471FA6329280FA58
.baidu.com TRUE / FALSE H_PS_PSSID 31262_1438_31325_21127_31110_31596_31673_31464_30823_26350
.baidu.com TRUE / FALSE 3738338402 PSTM 1590854754
www.baidu.com FALSE / FALSE BDSVRTM 0
www.baidu.com FALSE / FALSE BD_HOME 1

另外,LWPCookieJar 同样可以读取和保存 Cookie,但是保存的格式和 MozillaCookieJar 不一样,它会保存成 libwww-perl(LWP)格式的 Cookie 文件。

要保存成 LWP 格式的 Cookie 文件,可以在声明时就改为:

1
cookie = http.cookiejar.LWPCookieJar(filename)

此时生成的内容如下:

1
2
3
4
5
6
7
#LWP-Cookies-2.0
Set-Cookie3: BAIDUID="1F30EEDA35C7A94320275F991CA5B3A5:FG=1"; path="/"; domain=".baidu.com"; path_spec; domain_dot; expires="2021-05-30 16:06:39Z"; comment=bd; version=0
Set-Cookie3: BIDUPSID=1F30EEDA35C7A9433C97CF6245CBC383; path="/"; domain=".baidu.com"; path_spec; domain_dot; expires="2088-06-17 19:20:46Z"; version=0
Set-Cookie3: H_PS_PSSID=31626_1440_21124_31069_31254_31594_30841_31673_31464_31715_30823; path="/"; domain=".baidu.com"; path_spec; domain_dot; discard; version=0
Set-Cookie3: PSTM=1590854799; path="/"; domain=".baidu.com"; path_spec; domain_dot; expires="2088-06-17 19:20:46Z"; version=0
Set-Cookie3: BDSVRTM=11; path="/"; domain="www.baidu.com"; path_spec; discard; version=0
Set-Cookie3: BD_HOME=1; path="/"; domain="www.baidu.com"; path_spec; discard; version=0

由此看来,生成的格式还是有比较大差异的。

那么,生成了 Cookie 文件后,怎样从文件中读取并利用呢?

下面我们以 LWPCookieJar 格式为例来看一下:

1
2
3
4
5
6
7
8
import urllib.request, http.cookiejar

cookie = http.cookiejar.LWPCookieJar()
cookie.load('cookie.txt', ignore_discard=True, ignore_expires=True)
handler = urllib.request.HTTPCookieProcessor(cookie)
opener = urllib.request.build_opener(handler)
response = opener.open('https://www.baidu.com')
print(response.read().decode('utf-8'))

可以看到,这里调用 load 方法来读取本地的 Cookie 文件,获取到了 Cookie 的内容。不过前提是我们首先生成了 LWPCookieJar 格式的 Cookie,并保存成文件,然后读取 Cookie 之后使用同样的方法构建 Handler 和 Opener 即可完成操作。

运行结果正常的话,会输出百度网页的源代码。

通过上面的方法,我们可以实现绝大多数请求功能的设置了。

这便是 urllib 库中 request 模块的基本用法,如果想实现更多的功能,可以参考官方文档的说明:https://docs.python.org/3/library/urllib.request.html#basehandler-objects

2. 处理异常

在前一节中,我们了解了请求的发送过程,但是在网络不好的情况下,如果出现了异常,该怎么办呢?这时如果不处理这些异常,程序很可能因报错而终止运行,所以异常处理还是十分有必要的。

urllib 的 error 模块定义了由 request 模块产生的异常。如果出现了问题,request 模块便会抛出 error 模块中定义的异常。

URLError

URLError 类来自 urllib 库的 error 模块,它继承自 OSError 类,是 error 异常模块的基类,由 request 模块产生的异常都可以通过捕获这个类来处理。

它具有一个属性 reason,即返回错误的原因。

下面用一个实例来看一下:

1
2
3
4
5
6
from urllib import request, error

try:
response = request.urlopen('https://cuiqingcai.com/404')
except error.URLError as e:
print(e.reason)

我们打开一个不存在的页面,照理来说应该会报错,但是这时我们捕获了 URLError 这个异常,运行结果如下:

1
Not Found

程序没有直接报错,而是输出了如上内容,这样就可以避免程序异常终止,同时异常得到了有效处理。

HTTPError

它是 URLError 的子类,专门用来处理 HTTP 请求错误,比如认证请求失败等。它有如下 3 个属性。

  • code:返回 HTTP 状态码,比如 404 表示网页不存在,500 表示服务器内部错误等。
  • reason:同父类一样,用于返回错误的原因。
  • headers:返回请求头。

下面我们用几个实例来看看:

1
2
3
4
5
6
from urllib import request, error

try:
response = request.urlopen('https://cuiqingcai.com/404')
except error.HTTPError as e:
print(e.reason, e.code, e.headers, sep='\n')

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
Not Found
404
Server: nginx/1.10.3 (Ubuntu)
Date: Sat, 30 May 2020 16:08:42 GMT
Content-Type: text/html; charset=UTF-8
Transfer-Encoding: chunked
Connection: close
Set-Cookie: PHPSESSID=kp1a1b0o3a0pcf688kt73gc780; path=/
Pragma: no-cache
Vary: Cookie
Expires: Wed, 11 Jan 1984 05:00:00 GMT
Cache-Control: no-cache, must-revalidate, max-age=0
Link: <https://cuiqingcai.com/wp-json/>; rel="https://api.w.org/"

依然是同样的网址,这里捕获了 HTTPError 异常,输出了 reasoncodeheaders 属性。

因为 URLErrorHTTPError 的父类,所以可以先选择捕获子类的错误,再去捕获父类的错误,所以上述代码的更好写法如下:

1
2
3
4
5
6
7
8
9
10
from urllib import request, error

try:
response = request.urlopen('https://cuiqingcai.com/404')
except error.HTTPError as e:
print(e.reason, e.code, e.headers, sep='\n')
except error.URLError as e:
print(e.reason)
else:
print('Request Successfully')

这样就可以做到先捕获 HTTPError,获取它的错误原因、状态码、headers 等信息。如果不是 HTTPError 异常,就会捕获 URLError 异常,输出错误原因。最后,用 else 来处理正常的逻辑。这是一个较好的异常处理写法。

有时候,reason 属性返回的不一定是字符串,也可能是一个对象。再看下面的实例:

1
2
3
4
5
6
7
8
9
10
import socket
import urllib.request
import urllib.error

try:
response = urllib.request.urlopen('https://www.baidu.com', timeout=0.01)
except urllib.error.URLError as e:
print(type(e.reason))
if isinstance(e.reason, socket.timeout):
print('TIME OUT')

这里我们直接设置超时时间来强制抛出 timeout 异常。

运行结果如下:

1
2
<class'socket.timeout'>
TIME OUT

可以发现,reason 属性的结果是 socket.timeout 类。所以,这里我们可以用 isinstance 方法来判断它的类型,作出更详细的异常判断。

本节中,我们讲述了 error 模块的相关用法,通过合理地捕获异常可以做出更准确的异常判断,使程序更加稳健。

3. 解析链接

前面说过,urllib 库里还提供了 parse 模块,它定义了处理 URL 的标准接口,例如实现 URL 各部分的抽取、合并以及链接转换。它支持如下协议的 URL 处理:fileftpgopherhdlhttphttpsimapmailtommsnewsnntpprosperorsyncrtsprtspusftpsipsipssnewssvnsvn+sshtelnetwais。本节中,我们介绍一下该模块中常用的方法来看一下它的便捷之处。

urlparse

该方法可以实现 URL 的识别和分段,这里先用一个实例来看一下:

1
2
3
4
5
from urllib.parse import urlparse

result = urlparse('https://www.baidu.com/index.html;user?id=5#comment')
print(type(result))
print(result)

这里我们利用 urlparse 方法进行了一个 URL 的解析。首先,输出了解析结果的类型,然后将结果也输出出来。

运行结果如下:

1
2
<class 'urllib.parse.ParseResult'>
ParseResult(scheme='https', netloc='www.baidu.com', path='/index.html', params='user', query='id=5', fragment='comment')

可以看到,返回结果是一个 ParseResult 类型的对象,它包含 6 个部分,分别是 schemenetlocpathparamsqueryfragment

观察一下该实例的 URL:

1
https://www.baidu.com/index.html;user?id=5#comment

可以发现,urlparse 方法将其拆分成了 6 个部分。大体观察可以发现,解析时有特定的分隔符。比如,:// 前面的就是 scheme,代表协议;第一个 / 符号前面便是 netloc,即域名,后面是 path,即访问路径;分号;后面是 params,代表参数;问号 ? 后面是查询条件 query,一般用作 GET 类型的 URL;井号 # 后面是锚点,用于直接定位页面内部的下拉位置。

所以,可以得出一个标准的链接格式,具体如下:

1
scheme://netloc/path;params?query#fragment

一个标准的 URL 都会符合这个规则,利用 urlparse 方法可以将它拆分开来。

除了这种最基本的解析方式外,urlparse 方法还有其他配置吗?接下来,看一下它的 API 用法:

1
urllib.parse.urlparse(urlstring, scheme='', allow_fragments=True)

可以看到,它有 3 个参数。

  • urlstring:这是必填项,即待解析的 URL。
  • scheme:它是默认的协议(比如 httphttps 等)。假如这个链接没有带协议信息,会将这个作为默认的协议。我们用实例来看一下:
1
2
3
4
from urllib.parse import urlparse

result = urlparse('www.baidu.com/index.html;user?id=5#comment', scheme='https')
print(result)

运行结果如下:

1
ParseResult(scheme='https', netloc='', path='www.baidu.com/index.html', params='user', query='id=5', fragment='comment')

可以发现,我们提供的 URL 没有包含最前面的 scheme 信息,但是通过默认的 scheme 参数,返回的结果是 https

假设我们带上了 scheme

1
result = urlparse('http://www.baidu.com/index.html;user?id=5#comment', scheme='https')

则结果如下:

1
ParseResult(scheme='http', netloc='www.baidu.com', path='/index.html', params='user', query='id=5', fragment='comment')

可见,scheme 参数只有在 URL 中不包含 scheme 信息时才生效。如果 URL 中有 scheme 信息,就会返回解析出的 scheme

  • allow_fragments:即是否忽略 fragment。如果它被设置为 Falsefragment 部分就会被忽略,它会被解析为 pathparameters 或者 query 的一部分,而 fragment 部分为空。

下面我们用实例来看一下:

1
2
3
4
from urllib.parse import urlparse

result = urlparse('https://www.baidu.com/index.html;user?id=5#comment', allow_fragments=False)
print(result)

运行结果如下:

1
ParseResult(scheme='https', netloc='www.baidu.com', path='/index.html', params='user', query='id=5#comment', fragment='')

假设 URL 中不包含 paramsquery,我们再通过实例看一下:

1
2
3
4
from urllib.parse import urlparse

result = urlparse('https://www.baidu.com/index.html#comment', allow_fragments=False)
print(result)

运行结果如下:

1
ParseResult(scheme='https', netloc='www.baidu.com', path='/index.html#comment', params='', query='', fragment='')

可以发现,当 URL 中不包含 paramsquery 时,fragment 便会被解析为 path 的一部分。

返回结果 ParseResult 实际上是一个元组,我们既可以用索引顺序来获取,也可以用属性名获取。示例如下:

1
2
3
4
from urllib.parse import urlparse

result = urlparse('https://www.baidu.com/index.html#comment', allow_fragments=False)
print(result.scheme, result[0], result.netloc, result[1], sep='\n')

这里我们分别用索引和属性名获取了 schemenetloc,其运行结果如下:

1
2
3
4
https
https
www.baidu.com
www.baidu.com

可以发现,二者的结果是一致的,两种方法都可以成功获取。

urlunparse

有了 urlparse 方法,相应地就有了它的对立方法 urlunparse。它接收的参数是一个可迭代对象,但是它的长度必须是 6,否则会抛出参数数量不足或者过多的问题。先用一个实例看一下:

1
2
3
4
from urllib.parse import urlunparse

data = ['https', 'www.baidu.com', 'index.html', 'user', 'a=6', 'comment']
print(urlunparse(data))

这里参数 data 用了列表类型。当然,你也可以用其他类型,比如元组或者特定的数据结构。

运行结果如下:

1
https://www.baidu.com/index.html;user?a=6#comment

这样我们就成功实现了 URL 的构造。

urlsplit

这个方法和 urlparse 方法非常相似,只不过它不再单独解析 params 这一部分,只返回 5 个结果。上面例子中的 params 会合并到 path 中。示例如下:

1
2
3
4
from urllib.parse import urlsplit

result = urlsplit('https://www.baidu.com/index.html;user?id=5#comment')
print(result)

运行结果如下:

1
SplitResult(scheme='https', netloc='www.baidu.com', path='/index.html;user', query='id=5', fragment='comment')

可以发现,返回结果是 SplitResult,它其实也是一个元组类型,既可以用属性获取值,也可以用索引来获取。示例如下:

1
2
3
4
from urllib.parse import urlsplit

result = urlsplit('https://www.baidu.com/index.html;user?id=5#comment')
print(result.scheme, result[0])

运行结果如下:

1
https https

urlunsplit

urlunparse 方法类似,它也是将链接各个部分组合成完整链接的方法,传入的参数也是一个可迭代对象,例如列表、元组等,唯一的区别是长度必须为 5。示例如下:

1
2
3
4
from urllib.parse import urlunsplit

data = ['https', 'www.baidu.com', 'index.html', 'a=6', 'comment']
print(urlunsplit(data))

运行结果如下:

1
https://www.baidu.com/index.html?a=6#comment

urljoin

有了 urlunparseurlunsplit 方法,我们可以完成链接的合并,不过前提是必须要有特定长度的对象,链接的每一部分都要清晰分开。

此外,生成链接还有另一个方法,那就是 urljoin 方法。我们可以提供一个 base_url(基础链接)作为第一个参数,将新的链接作为第二个参数,该方法会分析 base_urlschemenetlocpath 这 3 个内容并对新链接缺失的部分进行补充,最后返回结果。

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

1
2
3
4
5
6
7
8
9
10
from urllib.parse import urljoin

print(urljoin('https://www.baidu.com', 'FAQ.html'))
print(urljoin('https://www.baidu.com', 'https://cuiqingcai.com/FAQ.html'))
print(urljoin('https://www.baidu.com/about.html', 'https://cuiqingcai.com/FAQ.html'))
print(urljoin('https://www.baidu.com/about.html', 'https://cuiqingcai.com/FAQ.html?question=2'))
print(urljoin('https://www.baidu.com?wd=abc', 'https://cuiqingcai.com/index.php'))
print(urljoin('https://www.baidu.com', '?category=2#comment'))
print(urljoin('www.baidu.com', '?category=2#comment'))
print(urljoin('www.baidu.com#comment', '?category=2'))

运行结果如下:

1
2
3
4
5
6
7
8
https://www.baidu.com/FAQ.html
https://cuiqingcai.com/FAQ.html
https://cuiqingcai.com/FAQ.html
https://cuiqingcai.com/FAQ.html?question=2
https://cuiqingcai.com/index.php
https://www.baidu.com?category=2#comment
www.baidu.com?category=2#comment
www.baidu.com?category=2

可以发现,base_url 提供了三项内容 schemenetlocpath。如果这 3 项在新的链接里不存在,就予以补充;如果新的链接存在,就使用新的链接的部分。而 base_url 中的 paramsqueryfragment 是不起作用的。

通过 urljoin 方法,我们可以轻松实现链接的解析、拼合与生成。

urlencode

这里我们再介绍一个常用的方法 —— urlencode,它在构造 GET 请求参数的时候非常有用,示例如下:

1
2
3
4
5
6
7
8
9
from urllib.parse import urlencode

params = {
'name': 'germey',
'age': 25
}
base_url = 'https://www.baidu.com?'
url = base_url + urlencode(params)
print(url)

这里首先声明一个字典来将参数表示出来,然后调用 urlencode 方法将其序列化为 GET 请求参数。

运行结果如下:

1
https://www.baidu.com?name=germey&age=25

可以看到,参数成功地由字典类型转化为 GET 请求参数了。

这个方法非常常用。有时为了更加方便地构造参数,我们会事先用字典来表示。要转化为 URL 的参数时,只需要调用该方法即可。

parse_qs

有了序列化,必然就有反序列化。如果我们有一串 GET 请求参数,利用 parse_qs 方法,就可以将它转回字典,示例如下:

1
2
3
4
from urllib.parse import parse_qs

query = 'name=germey&age=25'
print(parse_qs(query))

运行结果如下:

1
{'name': ['germey'], 'age': ['25']}

可以看到,这样就成功转回为字典类型了。

parse_qsl

另外,还有一个 parse_qsl 方法,它用于将参数转化为元组组成的列表,示例如下:

1
2
3
4
from urllib.parse import parse_qsl

query = 'name=germey&age=25'
print(parse_qsl(query))

运行结果如下:

1
[('name', 'germey'), ('age', '25')]

可以看到,运行结果是一个列表,而列表中的每一个元素都是一个元组,元组的第一个内容是参数名,第二个内容是参数值。

quote

该方法可以将内容转化为 URL 编码的格式。URL 中带有中文参数时,有时可能会导致乱码的问题,此时可以用这个方法可以将中文字符转化为 URL 编码,示例如下:

1
2
3
4
5
from urllib.parse import quote

keyword = '壁纸'
url = 'https://www.baidu.com/s?wd=' + quote(keyword)
print(url)

这里我们声明了一个中文的搜索文字,然后用 quote 方法对其进行 URL 编码,最后得到的结果如下:

1
https://www.baidu.com/s?wd=%E5%A3%81%E7%BA%B8

unquote

有了 quote 方法,当然还有 unquote 方法,它可以进行 URL 解码,示例如下:

1
2
3
4
from urllib.parse import unquote

url = 'https://www.baidu.com/s?wd=%E5%A3%81%E7%BA%B8'
print(unquote(url))

这是上面得到的 URL 编码后的结果,这里利用 unquote 方法还原,结果如下:

1
https://www.baidu.com/s?wd=壁纸

可以看到,利用 unquote 方法可以方便地实现解码。

本节中,我们介绍了 parse 模块的一些常用 URL 处理方法。有了这些方法,我们可以方便地实现 URL 的解析和构造,建议熟练掌握。

4. 分析 Robots 协议

利用 urllib 的 robotparser 模块,我们可以实现网站 Robots 协议的分析。本节中,我们来简单了解一下该模块的用法。

1. Robots 协议

Robots 协议也称作爬虫协议、机器人协议,它的全名叫作网络爬虫排除标准(Robots Exclusion Protocol),用来告诉爬虫和搜索引擎哪些页面可以抓取,哪些不可以抓取。它通常是一个叫作 robots.txt 的文本文件,一般放在网站的根目录下。

当搜索爬虫访问一个站点时,它首先会检查这个站点根目录下是否存在 robots.txt 文件,如果存在,搜索爬虫会根据其中定义的爬取范围来爬取。如果没有找到这个文件,搜索爬虫便会访问所有可直接访问的页面。

下面我们看一个 robots.txt 的样例:

1
2
3
User-agent: *
Disallow: /
Allow: /public/

这实现了对所有搜索爬虫只允许爬取 public 目录的功能,将上述内容保存成 robots.txt 文件,放在网站的根目录下,和网站的入口文件(比如 index.php、index.html 和 index.jsp 等)放在一起。

上面的 User-agent 描述了搜索爬虫的名称,这里将其设置为 * 则代表该协议对任何爬取爬虫有效。比如,我们可以设置:

1
User-agent: Baiduspider

这就代表我们设置的规则对百度爬虫是有效的。如果有多条 User-agent 记录,就有多个爬虫会受到爬取限制,但至少需要指定一条。

Disallow 指定了不允许抓取的目录,比如上例子中设置为 / 则代表不允许抓取所有页面。

Allow 一般和 Disallow 一起使用,一般不会单独使用,用来排除某些限制。上例中我们设置为 /public/,则表示所有页面不允许抓取,但可以抓取 public 目录。

下面我们再来看几个例子。禁止所有爬虫访问任何目录的代码如下:

1
2
User-agent: *
Disallow: /

允许所有爬虫访问任何目录的代码如下:

1
2
User-agent: *
Disallow:

另外,直接把 robots.txt 文件留空也是可以的。

禁止所有爬虫访问网站某些目录的代码如下:

1
2
3
User-agent: *
Disallow: /private/
Disallow: /tmp/

只允许某一个爬虫访问的代码如下:

1
2
3
4
User-agent: WebCrawler
Disallow:
User-agent: *
Disallow: /

这些是 robots.txt 的一些常见写法。

爬虫名称

大家可能会疑惑,爬虫名是从哪儿来的?为什么就叫这个名?其实它是有固定名字的了,比如百度的就叫作 BaiduSpider。表 2- 列出了一些常见搜索爬虫的名称及对应的网站。

表 一些常见搜索爬虫的名称及其对应的网站

爬虫名称 名称 网站
BaiduSpider 百度 www.baidu.com
Googlebot 谷歌 www.google.com
360Spider 360 搜索 www.so.com
YodaoBot 有道 www.youdao.com
ia_archiver Alexa www.alexa.cn
Scooter altavista www.altavista.com
Bingbot 必应 www.bing.com

robotparser

了解 Robots 协议之后,我们就可以使用 robotparser 模块来解析 robots.txt 了。该模块提供了一个类 RobotFileParser,它可以根据某网站的 robots.txt 文件来判断一个爬虫是否有权限来爬取这个网页。

该类用起来非常简单,只需要在构造方法里传入 robots.txt 的链接即可。首先看一下它的声明:

1
urllib.robotparser.RobotFileParser(url='')

当然,也可以在声明时不传入,默认为空,最后再使用 set_url 方法设置一下即可。

下面列出了这个类常用的几个方法。

  • set_url:用来设置 robots.txt 文件的链接。如果在创建 RobotFileParser 对象时传入了链接,那么就不需要再使用这个方法设置了。
  • read:读取 robots.txt 文件并进行分析。注意,这个方法执行一个读取和分析操作,如果不调用这个方法,接下来的判断都会为 False,所以一定记得调用这个方法。这个方法不会返回任何内容,但是执行了读取操作。
  • parse:用来解析 robots.txt 文件,传入的参数是 robots.txt 某些行的内容,它会按照 robots.txt 的语法规则来分析这些内容。
  • can_fetch:该方法用两个参数,第一个是 User-Agent,第二个是要抓取的 URL。返回的内容是该搜索引擎是否可以抓取这个 URL,返回结果是 TrueFalse
  • mtime:返回的是上次抓取和分析 robots.txt 的时间,这对于长时间分析和抓取的搜索爬虫是很有必要的,你可能需要定期检查来抓取最新的 robots.txt。
  • modified:它同样对长时间分析和抓取的搜索爬虫很有帮助,将当前时间设置为上次抓取和分析 robots.txt 的时间。

下面我们用实例来看一下:

1
2
3
4
5
6
7
8
from urllib.robotparser import RobotFileParser

rp = RobotFileParser()
rp.set_url('https://www.baidu.com/robots.txt')
rp.read()
print(rp.can_fetch('Baiduspider', 'https://www.baidu.com'))
print(rp.can_fetch('Baiduspider', 'https://www.baidu.com/homepage/'))
print(rp.can_fetch('Googlebot', 'https://www.baidu.com/homepage/'))

这里以百度为例,首先创建 RobotFileParser 对象,然后通过 set_url 方法设置了 robots.txt 的链接。当然,不用这个方法的话,可以在声明时直接用如下方法设置:

1
rp = RobotFileParser('https://www.baidu.com/robots.txt')

接着利用 can_fetch 方法判断网页是否可以被抓取。

运行结果如下:

1
2
3
True
True
False

这里同样可以使用 parse 方法执行读取和分析,示例如下:
可以看到这里我们利用 Baiduspider 可以抓取百度等首页以及 homepage 页面,但是 Googlebot 就不能抓取 homepage 页面。

打开百度的 robots.txt 文件看下,可以看到如下的信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
User-agent: Baiduspider
Disallow: /baidu
Disallow: /s?
Disallow: /ulink?
Disallow: /link?
Disallow: /home/news/data/
Disallow: /bh

User-agent: Googlebot
Disallow: /baidu
Disallow: /s?
Disallow: /shifen/
Disallow: /homepage/
Disallow: /cpro
Disallow: /ulink?
Disallow: /link?
Disallow: /home/news/data/
Disallow: /bh

由此我们可以看到,Baiduspider 没有限制 homepage 页面的抓取,而 Googlebot 则限制了 homepage 页面的抓取。

这里同样可以使用 parse 方法执行读取和分析,示例如下:

1
2
3
4
5
6
7
8
from urllib.request import urlopen
from urllib.robotparser import RobotFileParser

rp = RobotFileParser()
rp.parse(urlopen('https://www.baidu.com/robots.txt').read().decode('utf-8').split('\n'))
print(rp.can_fetch('Baiduspider', 'https://www.baidu.com'))
print(rp.can_fetch('Baiduspider', 'https://www.baidu.com/homepage/'))
print(rp.can_fetch('Googlebot', 'https://www.baidu.com/homepage/'))

运行结果一样:

1
2
3
True
True
False

本节介绍了 robotparser 模块的基本用法和实例,利用它,我们可以方便地判断哪些页面可以抓取,哪些页面不可以抓取。

5. 总结

本节内容比较多,我们介绍了 urllib 的 request、error、parse、robotparser 模块的基本用法。这些是一些基础模块,其中有一些模块的实用性还是很强的,比如我们可以利用 parse 模块来进行 URL 的各种处理。

本节代码:https://github.com/Python3WebSpider/UrllibTest

Python

爬虫系列文章总目录:【2022 年】Python3 爬虫学习教程,本教程内容多数来自于《Python3网络爬虫开发实战(第二版)》一书,目前截止 2022 年,可以将爬虫基本技术进行系统讲解,同时将最新前沿爬虫技术如异步、JavaScript 逆向、AST、安卓逆向、Hook、智能解析、群控技术、WebAssembly、大规模分布式、Docker、Kubernetes 等,市面上目前就仅有《Python3 网络爬虫开发实战(第二版)》一书了,点击了解详情

关系型数据库是基于关系模型的数据库,而关系模型是通过二维表来保存的,所以它的存储方式就是行列组成的表,每一列是一个字段,每一行是一条记录。表可以看作某个实体的集合,而实体之间存在联系,这就需要表与表之间的关联关系来体现,如主键外键的关联关系。多个表组成一个数据库,也就是关系型数据库。

关系型数据库有多种,如 SQLite、MySQL、Oracle、SQL Server、DB2 等,本节我们主要来了解下 MySQL 数据库的存储操作。

在 Python 2 中,连接 MySQL 的库大多是使用 MySQLdb,但是此库的官方并不支持 Python 3,所以这里推荐使用的库是 PyMySQL。本节中,我们就来讲解使用 PyMySQL 操作 MySQL 数据库的方法。

1. 准备工作

在开始之前,请确保已经安装好了 MySQL 数据库并保证它能正常运行,安装方式可以参考:https://setup.scrape.center/mysql。

除了安装好 MySQL 数据外,还需要安装好 PyMySQL 库,如尚未安装 PyMySQL,可以使用 pip3 来安装:

1
pip3 install pymysql

更详细的安装方式可以参考:https://setup.scrape.center/pymysql

二者都安装好了之后,我们就可以开始本节的学习了。

2. 连接数据库

这里首先尝试连接一下数据库。假设当前的 MySQL 运行在本地,用户名为 root,密码为 123456,运行端口为 3306。这里利用 PyMySQL 先连接 MySQL,然后创建一个新的数据库,名字叫作 spiders,代码如下:

1
2
3
4
5
6
7
8
9
import pymysql

db = pymysql.connect(host='localhost',user='root', password='123456', port=3306)
cursor = db.cursor()
cursor.execute('SELECT VERSION()')
data = cursor.fetchone()
print('Database version:', data)
cursor.execute("CREATE DATABASE spiders DEFAULT CHARACTER SET utf8mb4")
db.close()

运行结果如下:

1
Database version: ('8.0.19',)

这里通过 PyMySQL 的 connect 方法声明一个 MySQL 连接对象 db,此时需要传入 MySQL 运行的 host(即 IP)。由于 MySQL 在本地运行,所以传入的是 localhost。如果 MySQL 在远程运行,则传入其公网 IP 地址。后续的参数 user 即用户名,password 即密码,port 即端口(默认为 3306)。

连接成功后,需要再调用 cursor 方法获得 MySQL 的操作游标,利用游标来执行 SQL 语句。这里我们执行了两句 SQL,直接用 execute 方法执行即可。第一句 SQL 用于获得 MySQL 的当前版本,然后调用 fetchone 方法获得第一条数据,也就得到了版本号。第二句 SQL 执行创建数据库的操作,数据库名叫作 spiders,默认编码为 UTF-8。由于该语句不是查询语句,所以直接执行后就成功创建了数据库 spiders。接着,再利用这个数据库进行后续的操作。

3. 创建表

一般来说,创建数据库的操作只需要执行一次就好了。当然,我们也可以手动创建数据库。以后,我们的操作都在 spiders 数据库上执行。

创建数据库后,在连接时需要额外指定一个参数 db

接下来,新创建一个数据表 students,此时执行创建表的 SQL 语句即可。这里指定 3 个字段,结构如表 4- 所示。

表 4- 数据表 students

字 段 名 含  义 类  型
id 学号 varchar
name 姓名 varchar
age 年龄 int

创建该表的示例代码如下:

1
2
3
4
5
6
7
import pymysql

db = pymysql.connect(host='localhost', user='root', password='123456', port=3306, db='spiders')
cursor = db.cursor()
sql = 'CREATE TABLE IF NOT EXISTS students (id VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, age INT NOT NULL, PRIMARY KEY (id))'
cursor.execute(sql)
db.close()

运行之后,我们便创建了一个名为 students 的数据表。

当然,为了演示,这里只指定了最简单的几个字段。实际上,在爬虫过程中,我们会根据爬取结果设计特定的字段。

4. 插入数据

下一步就是向数据库中插入数据了。例如,这里爬取了一个学生信息,学号为 20120001,名字为 Bob,年龄为 20,那么如何将该条数据插入数据库呢?示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import pymysql

id = '20120001'
user = 'Bob'
age = 20

db = pymysql.connect(host='localhost', user='root', password='123456', port=3306, db='spiders')
cursor = db.cursor()
sql = 'INSERT INTO students(id, name, age) values(%s, %s, %s)'
try:
cursor.execute(sql, (id, user, age))
db.commit()
except:
db.rollback()
db.close()

这里首先构造了一个 SQL 语句,其值没有用字符串拼接的方式来构造,如:

1
sql = 'INSERT INTO students(id, name, age) values(' + id + ', ' + name + ', ' + age + ')'

这样的写法烦琐而且不直观,所以我们选择直接用格式化符 %s 来实现。有几个 value 写几个 %s,我们只需要在 execute 方法的第一个参数传入该 SQL 语句,value 值用统一的元组传过来就好了。这样的写法既可以避免字符串拼接的麻烦,又可以避免引号冲突的问题。

之后值得注意的是,需要执行 db 对象的 commit 方法才可以实现数据插入,这个方法才是真正将语句提交到数据库执行的方法。对于数据插入、更新、删除操作,都需要调用该方法才能生效。

接下来,我们加了一层异常处理。如果执行失败,则调用 rollback 执行数据回滚,相当于什么都没有发生过。

这里涉及事务的问题。事务机制可以确保数据的一致性,也就是这件事要么发生了,要么没有发生。比如插入一条数据,不会存在插入一半的情况,要么全部插入,要么都不插入,这就是事务的原子性。另外,事务还有 3 个属性 —— 一致性、隔离性和持久性。这 4 个属性通常称为 ACID 特性,具体如表 4- 所示。

表 4- 事务的 4 个属性

属  性 解  释
原子性(atomicity) 事务是一个不可分割的工作单位,事务中包括的诸操作要么都做,要么都不做
一致性(consistency) 事务必须使数据库从一个一致性状态变到另一个一致性状态。一致性与原子性是密切相关的
隔离性(isolation) 一个事务的执行不能被其他事务干扰,即一个事务内部的操作及使用的数据对并发的其他事务是隔离的,并发执行的各个事务之间不能互相干扰
持久性(durability) 持续性也称永久性(permanence),指一个事务一旦提交,它对数据库中数据的改变就应该是永久性的。接下来的其他操作或故障不应该对其有任何影响

插入、更新和删除操作都是对数据库进行更改的操作,而更改操作都必须为一个事务,所以这些操作的标准写法就是:

1
2
3
4
5
try:
cursor.execute(sql)
db.commit()
except:
db.rollback()

这样便可以保证数据的一致性。这里的 commitrollback 方法就为事务的实现提供了支持。

上面数据插入的操作是通过构造 SQL 语句实现的,但是很明显,这有一个极其不方便的地方,比如突然增加了性别字段 gender,此时 SQL 语句就需要改成:

1
INSERT INTO students(id, name, age, gender) values(%s, %s, %s, %s)

相应的元组参数则需要改成:

1
(id, name, age, gender)

这显然不是我们想要的。在很多情况下,我们要达到的效果是插入方法无须改动,做成一个通用方法,只需要传入一个动态变化的字典就好了。比如,构造这样一个字典:

1
2
3
4
5
{
'id': '20120001',
'name': 'Bob',
'age': 20
}

然后 SQL 语句会根据字典动态构造,元组也动态构造,这样才能实现通用的插入方法。所以,这里我们需要改写一下插入方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
data = {
'id': '20120001',
'name': 'Bob',
'age': 20
}
table = 'students'
keys = ', '.join(data.keys())
values = ', '.join(['%s'] * len(data))
sql = 'INSERT INTO {table}({keys}) VALUES ({values})'.format(table=table, keys=keys, values=values)
try:
if cursor.execute(sql, tuple(data.values())):
print('Successful')
db.commit()
except:
print('Failed')
db.rollback()
db.close()

这里我们传入的数据是字典,并将其定义为 data 变量。表名也定义成变量 table。接下来,就需要构造一个动态的 SQL 语句了。

首先,需要构造插入的字段 idnameage。这里只需要将 data 的键名拿过来,然后用逗号分隔即可。所以 ', '.join(data.keys()) 的结果就是 id, name, age,然后需要构造多个 %ss 当作占位符,有几个字段构造几个即可。比如,这里有三个字段,就需要构造 %s, %s, %s。这里首先定义了长度为 1 的数组 ['%s'],然后用乘法将其扩充为 ['%s', '%s', '%s'],再调用 join 方法,最终变成 %s, %s, %s。最后,我们再利用字符串的 format 方法将表名、字段名和占位符构造出来。最终的 SQL 语句就被动态构造成了:

1
INSERT INTO students(id, name, age) VALUES (%s, %s, %s)

最后,为 execute 方法的第一个参数传入 sql 变量,第二个参数传入 data 的键值构造的元组,就可以成功插入数据了。

如此以来,我们便实现了传入一个字典来插入数据的方法,不需要再去修改 SQL 语句和插入操作了。

5. 更新数据

数据更新操作实际上也是执行 SQL 语句,最简单的方式就是构造一个 SQL 语句,然后执行:

1
2
3
4
5
6
7
sql = 'UPDATE students SET age = %ss WHERE name = %s'
try:
cursor.execute(sql, (25, 'Bob'))
db.commit()
except:
db.rollback()
db.close()

这里同样用占位符的方式构造 SQL,然后执行 execute 方法,传入元组形式的参数,同样执行 commit 方法执行操作。如果要做简单的数据更新的话,完全可以使用此方法。

但是在实际的数据抓取过程中,大部分情况下需要插入数据,但是我们关心的是会不会出现重复数据,如果出现了,我们希望更新数据而不是重复保存一次。另外,就像前面所说的动态构造 SQL 的问题,所以这里可以再实现一种去重的方法,如果数据存在,则更新数据;如果数据不存在,则插入数据。另外,这种做法支持灵活的字典传值。示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
data = {
'id': '20120001',
'name': 'Bob',
'age': 21
}

table = 'students'
keys = ', '.join(data.keys())
values = ', '.join(['%s'] * len(data))

sql = 'INSERT INTO {table}({keys}) VALUES ({values}) ON DUPLICATE KEY UPDATE '.format(table=table, keys=keys, values=values)
update = ','.join(["{key} = %s".format(key=key) for key in data])
sql += update
try:
if cursor.execute(sql, tuple(data.values())*2):
print('Successful')
db.commit()
except:
print('Failed')
db.rollback()
db.close()

这里构造的 SQL 语句其实是插入语句,但是我们在后面加了 ON DUPLICATE KEY UPDATE。这行代码的意思是如果主键已经存在,就执行更新操作。比如,我们传入的数据 id 仍然为 20120001,但是年龄有所变化,由 20 变成了 21,此时这条数据不会被插入,而是直接更新 id 为 20120001 的数据。完整的 SQL 构造出来是这样的:

1
INSERT INTO students(id, name, age) VALUES (%s, %s, %s) ON DUPLICATE KEY UPDATE id = %s, name = %s, age = %s

这里就变成了 6 个 %ss。所以在后面的 execute 方法的第二个参数元组就需要乘以 2 变成原来的 2 倍。

如此一来,我们就可以实现主键不存在便插入数据,存在则更新数据的功能了。

6. 删除数据

删除操作相对简单,直接使用 DELETE 语句即可,只是需要指定要删除的目标表名和删除条件,而且仍然需要使用 dbcommit 方法才能生效。示例如下:

1
2
3
4
5
6
7
8
9
10
11
table = 'students'
condition = 'age > 20'

sql = 'DELETE FROM {table} WHERE {condition}'.format(table=table, condition=condition)
try:
cursor.execute(sql)
db.commit()
except:
db.rollback()

db.close()

因为删除条件多种多样,运算符有大于、小于、等于、LIKE 等,条件连接符有 ANDOR 等,所以不再继续构造复杂的判断条件。这里直接将条件当作字符串来传递,以实现删除操作。

7. 查询数据

说完插入、修改和删除等操作,还剩下非常重要的一个操作,那就是查询。查询会用到 SELECT 语句,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
sql = 'SELECT * FROM students WHERE age >= 20'

try:
cursor.execute(sql)
print('Count:', cursor.rowcount)
one = cursor.fetchone()
print('One:', one)
results = cursor.fetchall()
print('Results:', results)
print('Results Type:', type(results))
for row in results:
print(row)
except:
print('Error')

运行结果如下:

1
2
3
4
5
6
7
Count: 4
One: ('20120001', 'Bob', 25)
Results: (('20120011', 'Mary', 21), ('20120012', 'Mike', 20), ('20120013', 'James', 22))
Results Type: <class 'tuple'>
('20120011', 'Mary', 21)
('20120012', 'Mike', 20)
('20120013', 'James', 22)

这里我们构造了一条 SQL 语句,将年龄 20 岁及以上的学生查询出来,然后将其传给 execute 方法。注意,这里不再需要 dbcommit 方法。接着,调用 cursorrowcount 属性获取查询结果的条数,当前示例中是 4 条。

然后我们调用了 fetchone 方法,这个方法可以获取结果的第一条数据,返回结果是元组形式,元组的元素顺序跟字段一一对应,即第一个元素就是第一个字段 id,第二个元素就是第二个字段 name,以此类推。随后,我们又调用了 fetchall 方法,它可以得到结果的所有数据。然后将其结果和类型打印出来,它是二重元组,每个元素都是一条记录,我们将其遍历输出出来。

但是这里需要注意一个问题,这里显示的是 3 条数据而不是 4 条,fetchall 方法不是获取所有数据吗?这是因为它的内部实现有一个偏移指针用来指向查询结果,最开始偏移指针指向第一条数据,取一次之后,指针偏移到下一条数据,这样再取的话,就会取到下一条数据了。我们最初调用了一次 fetchone 方法,这样结果的偏移指针就指向下一条数据,fetchall 方法返回的是偏移指针指向的数据一直到结束的所有数据,所以该方法获取的结果就只剩 3 个了。

此外,我们还可以用 while 循环加 fetchone 方法来获取所有数据,而不是用 fetchall 全部一起获取出来。fetchall 会将结果以元组形式全部返回,如果数据量很大,那么占用的开销会非常高。因此,推荐使用如下方法来逐条取数据:

1
2
3
4
5
6
7
8
9
10
sql = 'SELECT * FROM students WHERE age >= 20'
try:
cursor.execute(sql)
print('Count:', cursor.rowcount)
row = cursor.fetchone()
while row:
print('Row:', row)
row = cursor.fetchone()
except:
print('Error')

这样每循环一次,指针就会偏移一条数据,随用随取,简单高效。

8. 总结

本节我们了解了如何使用 PyMySQL 操作 MySQL 数据库以及一些 SQL 语句的构造方法,后面我们会在实战案例中应用这些操作来存储数据。

本节代码:https://github.com/Python3WebSpider/MySQLTest

Python

爬虫系列文章总目录:【2022 年】Python3 爬虫学习教程,本教程内容多数来自于《Python3网络爬虫开发实战(第二版)》一书,目前截止 2022 年,可以将爬虫基本技术进行系统讲解,同时将最新前沿爬虫技术如异步、JavaScript 逆向、AST、安卓逆向、Hook、智能解析、群控技术、WebAssembly、大规模分布式、Docker、Kubernetes 等,市面上目前就仅有《Python3 网络爬虫开发实战(第二版)》一书了,点击了解详情

我们在做爬虫的过程中经常会遇到这样的情况,最初爬虫正常运行,正常抓取数据,一切看起来都是那么美好,然而一杯茶的功夫可能就会出现错误,比如 403 Forbidden,这时打开网页一看,可能会看到 “您的 IP 访问频率太高” 这样的提示。出现这种现象的原因是网站采取了一些反爬虫措施。比如,服务器会检测某个 IP 在单位时间内的请求次数,如果超过了这个阈值,就会直接拒绝服务,返回一些错误信息,这种情况可以称为封 IP。

既然服务器检测的是某个 IP 单位时间的请求次数,那么借助某种方式来伪装我们的 IP,让服务器识别不出是由我们本机发起的请求,不就可以成功防止封 IP 了吗?

一种有效的方式就是使用代理,后面会详细说明代理的用法。在这之前,需要先了解下代理的基本原理,它是怎样实现伪装 IP 的呢?

1. 基本原理

代理实际上指的就是代理服务器,英文叫作 Proxy Server,它的功能是代理网络用户去取得网络信息。形象地说,它是网络信息的中转站。在我们正常请求一个网站时,是发送了请求给 Web 服务器,Web 服务器把响应传回给我们。如果设置了代理服务器,实际上就是在本机和服务器之间搭建了一个桥,此时本机不是直接向 Web 服务器发起请求,而是向代理服务器发出请求,请求会发送给代理服务器,然后由代理服务器再发送给 Web 服务器,接着由代理服务器再把 Web 服务器返回的响应转发给本机。这样我们同样可以正常访问网页,但这个过程中 Web 服务器识别出的真实 IP 就不再是我们本机的 IP 了,就成功实现了 IP 伪装,这就是代理的基本原理。

2. 代理的作用

那么,代理有什么作用呢?我们可以简单列举如下。

  • 突破自身 IP 访问限制,访问一些平时不能访问的站点。
  • 访问一些单位或团体内部资源。比如,使用教育网内地址段的免费代理服务器,就可以下载和上传对教育网开放的各类 FTP,以及查询、共享各类资料等。
  • 提高访问速度。通常,代理服务器都设置一个较大的硬盘缓冲区,当有外界的信息通过时,会同时将其保存到缓冲区中,而当其他用户再访问相同的信息时,则直接由缓冲区中取出信息,传给用户,以提高访问速度。
  • 隐藏真实 IP。上网者也可以通过这种方法隐藏自己的 IP,免受攻击。对于爬虫来说,我们用代理就是为了隐藏自身的 IP,防止自身的 IP 被封锁。

3. 爬虫代理

对于爬虫来说,由于爬虫爬取速度过快,在爬取过程中可能遇到同一个 IP 访问过于频繁的问题,此时网站就会让我们输入验证码登录或者直接封锁 IP,这样会给爬取带来极大的不便。

使用代理隐藏真实的 IP,让服务器误以为是代理服务器在请求自己。这样在爬取过程中通过不断更换代理,就不会被封锁,可以达到很好的爬取效果。

4. 代理分类

对代理进行分类时,既可以根据协议区分,也可以根据其匿名程度区分,下面总结如下。

根据协议区分

根据代理的协议,代理可以分为如下类别。

  • FTP 代理服务器。主要用于访问 FTP 服务器,一般有上传、下载以及缓存功能,端口一般为 21、2121 等。
  • HTTP 代理服务器。主要用于访问网页,一般有内容过滤和缓存功能,端口一般为 80、8080、3128 等。
  • SSL/TLS 代理。主要用于访问加密网站,一般有 SSL 或 TLS 加密功能(最高支持 128 位加密强度),端口一般为 443。
  • RTSP 代理。主要用于 Realplayer 访问 Real 流媒体服务器,一般有缓存功能,端口一般为 554。
  • Telnet 代理。主要用于 Telnet 远程控制(黑客入侵计算机时常用于隐藏身份),端口一般为 23。
  • POP3/SMTP 代理。主要用于 POP3/SMTP 方式收发邮件,一般有缓存功能,端口一般为 110/25。
  • SOCKS 代理。只是单纯传递数据包,不关心具体协议和用法,所以速度快很多,一般有缓存功能,端口一般为 1080。SOCKS 代理协议又分为 SOCKS4 和 SOCKS5,SOCKS4 协议只支持 TCP,而 SOCKS5 协议支持 TCP 和 UDP,还支持各种身份验证机制、服务器端域名解析等。简单来说,SOCKS4 能做到的 SOCKS5 都可以做到,但 SOCKS5 能做到的 SOCKS4 不一定能做到。

根据匿名程度区分

根据代理的匿名程度,代理可以分为如下类别。

  • 高度匿名代理:高度匿名代理会将数据包原封不动地转发,在服务端看来就好像真的是一个普通客户端在访问,而记录的 IP 是代理服务器的 IP。
  • 普通匿名代理:普通匿名代理会在数据包上做一些改动,服务端上有可能发现这是个代理服务器,也有一定几率追查到客户端的真实 IP。代理服务器通常会加入的 HTTP 头有 HTTP_VIAHTTP_X_FORWARDED_FOR
  • 透明代理:透明代理不但改动了数据包,还会告诉服务器客户端的真实 IP。这种代理除了能用缓存技术提高浏览速度,能用内容过滤提高安全性之外,并无其他显著作用,最常见的例子是内网中的硬件防火墙。
  • 间谍代理:间谍代理指组织或个人创建的,用于记录用户传输的数据,然后进行研究、监控等目的的代理服务器。

5. 常见代理设置

常见的代理设置如下:

  • 使用网上的免费代理,最好使用高匿代理,使用前抓取下来并筛选一下可用代理,也可以进一步维护一个代理池。
  • 使用付费代理服务,互联网上存在许多代理商,可以付费使用,其质量比免费代理好很多。
  • ADSL 拨号,拨一次号换一次 IP,稳定性高,也是一种比较有效的解决方案。
  • 蜂窝代理,即用 4G 或 5G 网卡等制作的代理。由于蜂窝网络用作代理的情形较少,因此整体被封锁的几率会较低,但搭建蜂窝代理的成本较高。

在后面,我们会详细介绍一些代理的使用方式。

6. 总结

本文介绍了代理的相关知识,这对后文我们进行一些反爬绕过的实现有很大的帮助,同时也为后文的一些抓包操作打下基础,需要好好理解。

本节由于涉及一些专业名词,本节的部分内容参考来源如下:

Python

爬虫系列文章总目录:【2022 年】Python3 爬虫学习教程,本教程内容多数来自于《Python3网络爬虫开发实战(第二版)》一书,目前截止 2022 年,可以将爬虫基本技术进行系统讲解,同时将最新前沿爬虫技术如异步、JavaScript 逆向、AST、安卓逆向、Hook、智能解析、群控技术、WebAssembly、大规模分布式、Docker、Kubernetes 等,市面上目前就仅有《Python3 网络爬虫开发实战(第二版)》一书了,点击了解详情

在数据爬取过程中,我们可能需要进行一些任务间通信机制的实现。比如说:

  • 一个进程负责构造爬取请求,另一个进程负责执行爬取请求。
  • 某个爬取任务进程完成了,通知另外一个进程进行数据处理。
  • 某个进程新建了一个爬取任务,就通知另外一个进程开始数据爬取。

所以,为了降低这些进程的耦合度,就需要一个类似消息队列的中间件来存储和分发这些消息实现进程间的通信。

有了消息队列中间件之后,以上的两个任务就可以独立运行,通过消息队列来通信即可:

  • 一个进程将需要爬取的任务构造请求对象放入队列,另一个进程从队列中取出请求对象并执行爬取。

  • 某个爬取任务进程完成了,完成时就向消息队列发一个消息,另一个进程监听到这类消息,那就开始数据处理。

  • 某个进程新建了一个爬取任务,那就向消息队列发一个消息,另一个负责爬取的进程监听到这类消息,那就开始数据爬取。

那这个消息队列用什么来实现呢?业界比较流行的有 RabbitMQ、RocketMQ、Kafka 等等,RabbitMQ 作为一个开源、可靠、灵活的消息队列中间件倍受青睐,本节我们就来了解下 RabbitMQ 的用法。

注意:前面我们了解了一些数据存储库的用法,基本都用于持久化存储一些数据。但本节介绍的是一个消息队列组件,虽然其主要应用于数据消息通信,但由于其也有存储信息的能力,所以将其归类于本章进行介绍。

1. RabbitMQ 介绍

RabbitMQ 是使用 Erlang 语言开发的开源消息队列系统,基于 AMQP 协议实现。AMQP 的全称是 Advanced Message Queue,即高级消息队列协议,它的主要特征是面向消息、队列、路由(包括点对点和发布/订阅)、可靠性、安全。

RabbitMQ 最初起源于金融系统,用于在分布式系统中存储转发消息,在易用性、扩展性、高可用性等方面表现不俗。具体特点包括:

  • 可靠性(Reliability):RabbitMQ 使用一些机制来保证可靠性,如持久化、传输确认、发布确认。
  • 灵活的路由(Flexible Routing):在消息进入队列之前,通过 Exchange 来路由消息的。对于典型的路由功能,RabbitMQ 已经提供了一些内置的 Exchange 来实现。针对更复杂的路由功能,可以将多个 Exchange 绑定在一起,也通过插件机制实现自己的 Exchange 。
  • 消息集群(Clustering):多个 RabbitMQ 服务器可以组成一个集群,形成一个逻辑 Broker 。
  • 高可用(Highly Available Queues):队列可以在集群中的机器上进行镜像,使得在部分节点出问题的情况下队列仍然可用。
  • 多种协议支持(Multi-protocol):RabbitMQ 支持多种消息队列协议,比如 STOMP、MQTT 等等。
  • 多语言客户端(Many Clients):RabbitMQ 几乎支持所有常用语言,比如 Java、.NET、Ruby 等等。
  • 管理界面(Management UI):RabbitMQ 提供了一个易用的用户界面,使得用户可以监控和管理消息 Broker 的许多方面。
  • 跟踪机制(Tracing):如果消息异常,RabbitMQ 提供了消息跟踪机制,使用者可以找出发生了什么。
  • 插件机制(Plugin System):RabbitMQ 提供了许多插件,来从多方面进行扩展,也可以编写自己的插件。

2. 准备工作

在本节开始之前,请确保已经正确安装好了 RabbitMQ,安装方式可以参考:https://setup.scrape.center/rabbitmq,确保其可以在本地正常运行。

除了安装 RabbitMQ 之外,我们还需要安装一个操作 RabbitMQ 的 Python 库,叫做 pika,使用 pip3 安装即可:

1
pip3 install pika

更详细的安装说明可以参考:https://setup.scrape.center/pika。

以上二者都安装好之后,我们就可以开始本节的学习了。

3. 基本使用

首先,RabbitMQ 提供的是队列的功能,我们要实现进程间通信,其本质上就是实现一个生产者-消费者模型,即一个进程作为生产者放入消息,另外一个进程作为消费者监听并处理消息,实现过程主要有 3 个关键点:

  • 声明队列:通过指定队列的一些参数,将队列创建出来。
  • 生产内容:生产者根据队列的连接信息连接队列,向队列中放入对应的内容。
  • 消费内容:消费者根据队列的连接信息连接队列,从队列中取出对应的内容。

下面我们先来声明一个队列,相关代码如下:

1
2
3
4
5
6
import pika

QUEUE_NAME = 'scrape'
connection = pika.BlockingConnection(pika.ConnectionParameters('localhost'))
channel = connection.channel()
channel.queue_declare(queue=QUEUE_NAME)

由于 RabbitMQ 运行在本地,所以这里直接使用 localhost 即可连接 RabbitMQ 服务,得到一个连接对象 connection。接下来我们需要声明一个频道,即 channel,利用它我们可以操作队列内容的生产和消费,接着我们调用 channel 方法的 queue_declare 来声明一个队列,队列名称叫作 scrape

接着我们尝试向队列中添加一个内容:

1
2
3
channel.basic_publish(exchange='',
routing_key=QUEUE_NAME,
body='Hello World!')

这里我们调用了 channelbasic_publish 方法,向队列发布了一个内容,其中 routing_key 就是指队列的名称,body 就是真实的内容。

以上代码可以写入一个文件,取名为 producer.py,即生产者。

现在前两步——声明队列、生产内容其实已经完成了,接下来就是消费者从队列中获取内容了。

其实也很简单。消费者用同样的方式连接到队列,代码如下:

1
2
3
4
5
6
import pika

QUEUE_NAME = 'scrape'
connection = pika.BlockingConnection(pika.ConnectionParameters('localhost'))
channel = connection.channel()
channel.queue_declare(queue=QUEUE_NAME)

然后从队列中获取数据,代码如下:

1
2
3
4
5
6
7
def callback(ch, method, properties, body):
print(f"Get {body}")

channel.basic_consume(queue='scrape',
auto_ack=True,
on_message_callback=callback)
channel.start_consuming()

这里我们调用了 channelbasic_consume 进行消费,同时指定了回调方法 on_message_callback 的名称为 callback,另外还指定了 auto_ackTrue,这代表消费者获取信息之后会自动通知消息队列,表示这个消息已经被处理了,当前消息可以从队列中移除。

最后将以上代码保存为 consumer.py 并运行,它会监听 scrape 这个队列的变动,如果有消息进入,就会获取并进行消费,回调 callback 方法,打印输出结果。

然后运行一下 producer.py,运行之后会连接刚才的队列,同时在该队列中加入一条消息,内容为 Hello World!

这时候我们再返回 consumer,可以发现输出如下:

1
Get Hello World!

这就说明消费者成功收到了生产者发送的消息,消息成功被生产者放入消息队列,然后被消费者捕获并输出。

另外我们再次运行 producer.py,每运行一次,生产者都会向其中放入一个消息,消费者便会收到该消息并输出。

以上便是最基本的 RabbitMQ 的用法。

4. 随用随取

接下来我们来尝试实现一个简单的爬取队列,即一个进程负责构造爬取请求并将请求放入队列,另一个进程从队列中取出请求并执行爬取。

刚才我们仅仅是完成了基于 RabbitMQ 的最简单的生产者和消费者的通信,但是这种实现如果用在爬虫上是不太现实的,因为这里我们把消费者实现为了“订阅”的模式,也就是说,消费者会一直监听队列的变化,一旦队列中有了消息,消费者便要立马进行处理,消费者是无法主动控制它取用消息的时机的。

但实际上,假如我们要基于 RabbitMQ 实现一个爬虫的爬取队列的话,RabbitMQ 存的会是待执行/爬取的请求对象,生产者往里面放置请求对象,消费者获取到请求对象之后就执行这个请求,发起 HTTP 请求到服务器获取响应。但发起到获取响应的过程所消耗的时间是消费者无法控制的,这取决于服务器返回时间的长短。因此,消费者并不一定能够很快地将消息处理完,所以,消费者应该也有权利来控制取用的频率,这就是随用随取。

所以,这里我们可以稍微对前面的代码进行改写,生产者可以自行控制向消息队列中放入请求对象的频率,消费者也根据自己的处理能力控制自己从队列中取出请求对象的频率。如果生产者放置速度比消费者取用速度更快,那队列中就会缓存一些请求对象,反之队列则有时候会处于闲置状态。

但总的来说,消息队列充当了缓冲的作用,使得生产者和消费者可以按照自己的节奏来工作。

好,我们先实现下刚才所述的随用随取机制,队列中的消息暂且先用字符串来表示,后面我们可以再将其更换为请求对象。

这里我们可以将生产者实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import pika

QUEUE_NAME = 'scrape'
connection = pika.BlockingConnection(
pika.ConnectionParameters(host='localhost'))
channel = connection.channel()
channel.queue_declare(queue=QUEUE_NAME)

while True:
data = input()
channel.basic_publish(exchange='',
routing_key=QUEUE_NAME,
body=data)
print(f'Put {data}')

这里生产者的数据我们还是使用 input 方法来获取,输入的内容就是字符串,输入之后该内容会直接被放置到队列中,然后打印输出到控制台。

先运行下生产者,然后回车输入几个内容:

1
2
3
4
5
6
foo
Put foo
bar
Put bar
baz
Put baz

这里我们输入了 foo、bar、baz 三个内容,然后控制台也有对应的输出结果。

然后消费者实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
import pika

QUEUE_NAME = 'scrape'
connection = pika.BlockingConnection(
pika.ConnectionParameters(host='localhost'))
channel = connection.channel()

while True:
input()
method_frame, header, body = channel.basic_get(
queue=QUEUE_NAME, auto_ack=True)
if body:
print(f'Get {body}')

消费者也是一样的,我们这里也是可以通过 input 方法控制何时取用下一个,获取的方法就是 basic_get ,返回一个元组,其中 body 就是真正的数据。

运行消费者,回车几下,就可以看到每次回车都可以看到从消息队列中获取了一个新的数据:

1
2
3
Get b'foo'
Get b'bar'
Get b'baz'

这样我们就可以实现消费者的随用随取了。

5. 优先级队列

刚才我们仅仅是了解了最基本的队列的用法,RabbitMQ 还有一些高级功能。比如说,如果我们要想生产者发送的消息有优先级的区分,希望高优先级的队列被优先接收到,这个怎么实现呢?

其实很简单,我们只需要在声明队列的时候增加一个属性即可:

1
2
3
4
5
MAX_PRIORITY = 100

channel.queue_declare(queue=QUEUE_NAME, arguments={
'x-max-priority': MAX_PRIORITY
})

这里在声明队列的时候,我们增加了一个参数,叫做 x-max-priority,指定一个最大优先级,这样整个队列就支持优先级了。

这里改写下生产者,在发送消息的时候指定一个 properties 参数为 BasicProperties 对象,BasicProperties 对象里面通过 priority 参数指定了对应消息的优先级,实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import pika

MAX_PRIORITY = 100
QUEUE_NAME = 'scrape'

connection = pika.BlockingConnection(
pika.ConnectionParameters(host='localhost'))
channel = connection.channel()
channel.queue_declare(queue=QUEUE_NAME, arguments={
'x-max-priority': MAX_PRIORITY
})

while True:
data, priority = input().split()
channel.basic_publish(exchange='',
routing_key=QUEUE_NAME,
properties=pika.BasicProperties(
priority=int(priority),
),
body=data)
print(f'Put {data}')

这里优先级我们也可以手动输入,输入的内容我们需要分为两部分,用空格隔开,运行结果如下:

1
2
3
4
5
6
foo 40
Put foo
bar 20
Put bar
baz 50
Put baz

这里我们输入了三次内容,比如第一次输入的就是 foo 40,代表 foo 这个消息的优先级是 40,然后输入 bar 这个消息,优先级是 20,最后输入 baz 这个消息,优先级是 50。

然后重新运行消费者,按几次回车,可以看到如下输出结果:

1
2
3
4
5
Get b'baz'

Get b'foo'

Get b'bar'

这里我们可以看到结果就按照优先级取出来了,baz 这个优先级是最高的,所以就被最先取出来。bar 这个优先级是最低的,所以被最后取出来。

6. 队列持久化

除了设置优先级,我们还可以队列的持久化存储,如果不设置持久化存储,RabbitMQ 重启之后数据就没有了。

如果要开启持久化存储,可以在声明队列时指定 durable 为 True,实现如下:

1
2
3
channel.queue_declare(queue=QUEUE_NAME, arguments={
'x-max-priority': MAX_PRIORITY
}, durable=True)

同时在添加消息的时候需要指定 BasicProperties 对象的 delivery_mode 为 2,实现如下:

1
properties=pika.BasicProperties(priority=int(priority), delivery_mode=2)

所以,这时候生产者的写法就改写如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import pika

MAX_PRIORITY = 100
QUEUE_NAME = 'scrape'

connection = pika.BlockingConnection(
pika.ConnectionParameters(host='localhost'))
channel = connection.channel()
channel.queue_declare(queue=QUEUE_NAME, arguments={
'x-max-priority': MAX_PRIORITY
}, durable=True)

while True:
data, priority = input().split()
channel.basic_publish(exchange='',
routing_key=QUEUE_NAME,
properties=pika.BasicProperties(
priority=int(priority),
delivery_mode=2,
),
body=data)
print(f'Put {data}')

这样就可以实现队列的持久化存储了。

7. 实战

最后,我们将消息改写成前面所描述的请求对象,这里我们借助于 requests 库中的 Request 类来表示一个请求对象。

构造请求对象时,我们传入请求方法、请求 URL 即可,代码如下:

1
request = requests.Request('GET', url)

这样我们就构造了一个 GET 请求,然后可以通过 pickle 进行序列化然后发送到 RabbitMQ 中。

生产者实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import pika
import requests
import pickle

MAX_PRIORITY = 100
TOTAL = 100
QUEUE_NAME = 'scrape_queue'

connection = pika.BlockingConnection(
pika.ConnectionParameters(host='localhost'))
channel = connection.channel()
channel.queue_declare(queue=QUEUE_NAME, durable=True)

for i in range(1, TOTAL + 1):
url = f'https://ssr1.scrape.center/detail/{i}'
request = requests.Request('GET', url)
channel.basic_publish(exchange='',
routing_key=QUEUE_NAME,
properties=pika.BasicProperties(
delivery_mode=2,
),
body=pickle.dumps(request))
print(f'Put request of {url}')

这里我们运行下生产者,就构造了 100 个请求对象并发送到 RabbitMQ 中了。

对于消费者来说,可以设置一个循环,一直不断地从队列中取出这些请求对象,取出一个就执行一次爬取,实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import pika
import pickle
import requests

MAX_PRIORITY = 100
QUEUE_NAME = 'scrape_queue'

connection = pika.BlockingConnection(
pika.ConnectionParameters(host='localhost'))
channel = connection.channel()
session = requests.Session()

def scrape(request):
try:
response = session.send(request.prepare())
print(f'success scraped {response.url}')
except requests.RequestException:
print(f'error occurred when scraping {request.url}')

while True:
method_frame, header, body = channel.basic_get(
queue=QUEUE_NAME, auto_ack=True)
if body:
request = pickle.loads(body)
print(f'Get {request}')
scrape(request)

这里消费者调用 basic_get 方法获取了消息,然后通过 pickle 反序列化还原成一个请求对象,然后使用 session 的 send 方法执行了该请求,进行了数据爬取,爬取成功就打印爬取成功的消息。

运行结果如下:

1
2
3
4
5
6
7
Get <Request [GET]>
success scraped https://ssr1.scrape.center/detail/1
Get <Request [GET]>
success scraped https://ssr1.scrape.center/detail/2
...
Get <Request [GET]>
success scraped https://ssr1.scrape.center/detail/100

可以看到,消费者依次取出了爬取对象,然后成功完成了一个个爬取任务。

8. 总结

本节介绍了 RabbitMQ 的基本使用方法,有了它,爬虫任务间的消息通信就变得非常简单了,另外后文我们还会基于 RabbitMQ 实现分布式的爬取实战,所以本节的内容需要好好掌握。

本节代码:https://github.com/Python3WebSpider/RabbitMQTest。

本节部分内容参考来源:

Python

爬虫系列文章总目录:【2022 年】Python3 爬虫学习教程,本教程内容多数来自于《Python3 网络爬虫开发实战(第二版)》一书,目前截止 2022 年,可以将爬虫基本技术进行系统讲解,同时将最新前沿爬虫技术如异步、JavaScript 逆向、AST、安卓逆向、Hook、智能解析、群控技术、WebAssembly、大规模分布式、Docker、Kubernetes 等,市面上目前就仅有《Python3 网络爬虫开发实战(第二版)》一书了,点击了解详情

在浏览网站的过程中,我们经常会遇到需要登录的情况,有些页面只有登录之后才可以访问,而且登录之后可以连续访问很多次网站,但是有时候过一段时间就需要重新登录。还有一些网站,在打开浏览器时就自动登录了,而且很长时间都不会失效,这种情况又是为什么?其实这里面涉及 Session 和 Cookie 的相关知识,本节就来揭开它们的神秘面纱。

1. 静态网页和动态网页

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!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 代码,我们将其保存为一个 test.html 文件,然后把它放在某台具有固定公网 IP 的主机上,主机上装上 Apache 或 Nginx 等服务器,这样这台主机就可以作为服务器了,其他人便可以通过访问服务器看到这个页面,这就搭建了一个最简单的网站。

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

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

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

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

2. 无状态 HTTP

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

HTTP 的无状态是指 HTTP 协议对事务处理是没有记忆能力的,也就是说服务器不知道客户端是什么状态。当我们向服务器发送请求后,服务器解析此请求,然后返回对应的响应,服务器负责完成这个过程,而且这个过程是完全独立的,服务器不会记录前后状态的变化,也就是缺少状态记录。这意味着如果后续需要处理前面的信息,则必须重传,这导致需要额外传递一些前面的重复请求,才能获取后续响应,然而这种效果显然不是我们想要的。为了保持前后状态,我们肯定不能将前面的请求全部重传一次,这太浪费资源了,对于这种需要用户登录的页面来说,更是棘手。

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

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

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

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

3. Session

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

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

Cookie,也常用其复数形式 Cookies,Cookie 指某些网站为了辨别用户身份、进行 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。它有如下几个属性。

  • Name,即该 Cookie 的名称。Cookie 一旦创建,名称便不可更改。
  • Value,即该 Cookie 的值。如果值为 Unicode 字符,需要为字符编码。如果值为二进制数据,则需要使用 BASE64 编码。
  • Domain,即可以访问该 Cookie 的域名。例如如果设置为 .zhihu.com,则所有以 zhihu.com 结尾的域名都可以访问该 Cookie。
  • Path,即该 Cookie 的使用路径。如果设置为 /path/,则只有路径为 /path/ 的页面可以访问该 Cookie。如果设置为 /,则本域名下的所有页面都可以访问该 Cookie。
  • Max-Age,即该 Cookie 失效的时间,单位为秒,常和 Expires 一起使用,通过它可以计算出其有效时间。Max-Age 如果为正数,则该 Cookie 在 Max-Age 秒之后失效。如果为负数,则关闭浏览器时 Cookie 即失效,浏览器也不会以任何形式保存该 Cookie。
  • Size 字段,即此 Cookie 的大小。
  • HTTP 字段,即 Cookie 的 httponly 属性。若此属性为 true,则只有在 HTTP Headers 中会带有此 Cookie 的信息,而不能通过 document.cookie 来访问此 Cookie。
  • Secure,即该 Cookie 是否仅被使用安全协议传输。安全协议有 HTTPS 和 SSL 等,在网络上传输数据之前先将数据加密。其默认值为 false

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

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

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

5. 常见误区

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

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

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

6. 总结

本节介绍了 Session 和 Cookie 的基本概念,这对后文进行网络爬虫的开发有很大的帮助,需要好好掌握。

由于涉及一些专业名词知识,本节部分内容的参考来源如下:

Python

爬虫系列文章总目录:【2022 年】Python3 爬虫学习教程,本教程内容多数来自于《Python3网络爬虫开发实战(第二版)》一书,目前截止 2022 年,可以将爬虫基本技术进行系统讲解,同时将最新前沿爬虫技术如异步、JavaScript 逆向、AST、安卓逆向、Hook、智能解析、群控技术、WebAssembly、大规模分布式、Docker、Kubernetes 等,市面上目前就仅有《Python3 网络爬虫开发实战(第二版)》一书了,点击了解详情

简而言之,爬虫可以帮助我们快速把网站上的信息快速提取并保存下来。

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

1. 爬虫有什么用?

通过上面的话,你可能已经初步知道了爬虫是做了什么事情,但一般要学一个东西,我们得知道学来干什么用吧?

其实,爬虫的用处可大了去了。

  • 比如,我们想要研究最近各大网站头条都有什么热点,那我们就可以用爬虫把这些网站的热门新闻用爬虫爬下来,这样我们就可以分析其中的标题、内容等知道热点关键词了。
  • 比如,我们想要对一些天气、金融、体育、公司等各种信息进行整理和分析,但这些内容都分布在各种不同的网站上,那我们就可以用爬虫把这些网站上的数据爬取下来,整理成我们想要的数据保存下来,就可以对其进行分析了。
  • 比如,我们在网上看到了很多美图,比如风景、美食、美女,或者一些资料、文章,想保存到电脑上,但一次次右键保存、复制粘贴显然非常费时费力,那我们就可以利用爬虫将这些图片或资源快速爬取下来,极大地节省时间和精力。

另外还有很多其他的,比如黄牛抢票、自助抢课、网站排名等等各种技术也都和爬虫分不开,爬虫的用处可谓是非常大,可以说人人都应该会点爬虫。

另外学爬虫还可以帮助我们顺便学好 Python。学爬虫,个人首推的就是 Python 语言,如果你对 Python 还不太熟,没关系,爬虫就非常适合作为入门 Python 的方向来学习,一边学爬虫,一边学 Python,最后一举两得。

不仅如此,爬虫技术和其他领域的几乎都有交集,比如前后端 Web 开发、数据库、数据分析、人工智能、运维、安全等等领域都和爬虫有所沾边,所以学好了爬虫,就相当于为其他的领域也铺好了一个台阶,以后想进军其他领域都可以更轻松地衔接。Python 爬虫可谓是学习计算机的一个很好的入门方向之一。

2. 爬虫的流程

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

(1) 获取网页

爬虫首先要做的工作就是获取网页,这里就是获取网页的源代码。源代码里包含了网页的部分有用信息,所以只要把源代码获取下来,就可以从中提取想要的信息了。

我们用浏览器浏览网页时,其实浏览器就帮我们模拟了这个过程,浏览器向服务器发送了一个个请求,返回的响应体便是网页源代码,然后浏览器将其解析并呈现出来。所以,我们要做的爬虫其实就和浏览器类似,将网页源代码获取下来之后将内容解析出来就好了,只不过我们用的不是浏览器,而是 Python。

刚才说,最关键的部分就是构造一个请求并发送给服务器,然后接收到响应并将其解析出来,那么这个流程怎样用 Python 实现呢?

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

(2) 提取信息

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

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

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

(3) 保存数据

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

(4) 自动化程序

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

3. 能爬怎样的数据?

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

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

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

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

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

4. 总结

本节结束,我们已经对爬虫有了基本的了解,接下来让我们一起接着迈入爬虫学习的世界吧!

Python

爬虫系列文章总目录:【2022 年】Python3 爬虫学习教程,本教程内容多数来自于《Python3 网络爬虫开发实战(第二版)》一书,目前截止 2022 年,可以将爬虫基本技术进行系统讲解,同时将最新前沿爬虫技术如异步、JavaScript 逆向、AST、安卓逆向、Hook、智能解析、群控技术、WebAssembly、大规模分布式、Docker、Kubernetes 等,市面上目前就仅有《Python3 网络爬虫开发实战(第二版)》一书了,点击了解详情

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

1. 网页的组成

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

(1)HTML

HTML,其英文叫做 HyperText Markup Language,中文翻译叫做超文本标记语言,但我们通常不会用中文翻译来称呼它,一般就叫 HTML。

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

那 HTML 长什么样子呢?我们可以随意打开一个网站,比如淘宝 https://www.taobao.com,然后右键菜单点击“检查元素”或者按 F12 快捷键,即可打开浏览器开发者工具,切换到 Elements 面板,这时候就可以看到这里呈现的就是淘宝网对应的 HTML,它包含了一系列标签,浏览器解析这些标签后,便会在网页中渲染成一个个的节点,这便形成了我们平常看到的网页。比如这里可以看到一个输入框就对应一个 input 标签,可以用于输入文字。

不同的标签对应着不同的功能,这些标签定义的节点相互嵌套和组合形成了复杂的层次关系,就形成了网页的架构。

(2)CSS

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

CSS,全称叫作 Cascading Style Sheets,即层叠样式表。“层叠” 是指当在 HTML 中引用了数个样式文件,并且样式发生冲突时,浏览器能依据层叠顺序处理。“样式” 指网页中文字大小、颜色、元素间距、排列等格式。CSS 是目前唯一的网页页面排版样式标准,有了它的帮助,页面才会变得更为美观。

在上图中,Styles 面板呈现的就是一系列 CSS 样式,比如摘抄一段 CSS,内容如下:

1
2
3
4
5
6
#head_wrapper.s-ps-islite .s-p-top {
position: absolute;
bottom: 40px;
width: 100%;
height: 181px;
}

这就是一个 CSS 样式。大括号前面是一个 CSS 选择器。此选择器的意思是首先选中 idhead_wrapperclasss-ps-islite 的节点,然后再选中其内部的 classs-p-top 的节点。大括号内部写的就是一条条样式规则,例如 position 指定了这个节点的布局方式为绝对布局,bottom 指定节点的下边距为 40 像素,width 指定了宽度为 100%,表示占满父节点,height 则指定了节点的高度。也就是说,我们将位置、宽度、高度等样式配置统一写成这样的形式,然后用大括号括起来,接着在开头再加上 CSS 选择器,这就代表这个样式对 CSS 选择器选中的节点生效,节点就会根据此样式来展示了。

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

(3)JavaScript

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

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

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

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

2. 网页的结构

我们首先用例子来感受一下 HTML 的基本结构。新建一个文本文件,名称叫做 test.html,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!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 标签内定义了一些页面的配置和引用,如:

1
<meta charset="UTF-8" />

它指定了网页的编码为 UTF-8。

title 标签则定义了网页的标题,会显示在网页的选项卡中,不会显示在正文中。body 标签内则是在网页正文中显示的内容。div 标签定义了网页中的区块,它的 idcontainer,这是一个非常常用的属性,且 id 的内容在网页中是唯一的,我们可以通过它来获取这个区块。然后在此区块内又有一个 div 标签,它的 classwrapper,这也是一个非常常用的属性,经常与 CSS 配合使用来设定样式。然后此区块内部又有一个 h2 标签,这代表一个二级标题。另外,还有一个 p 标签,这代表一个段落。在这两者中直接写入相应的内容即可在网页中呈现出来,它们也有各自的 class 属性。

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

运行结果

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

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

3 节点树及节点间的关系

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

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

  • 整个网站文档是一个文档节点。
  • 每个 html 标签对应一个根元素节点,即上例中的 html 标签,这属于一个跟元素节点。
  • 节点内的文本是文本节点,比如 a 节点代表一个超链接,它内部的文本也被认为是一个文本节点。
  • 每个节点的属性是属性节点,比如 a 节点有一个 href 属性,它就是一个属性节点。
  • 注释是注释节点,在 HTML 中有特殊的语法会被解析为注释,但其也会对应一个节点。

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

节点树

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

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

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

节点树及节点间的关系

4. 选择器

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

在 CSS 中,我们使用 CSS 选择器来定位节点。例如,上例中 div 节点的 idcontainer,那么就可以表示为 #container,其中 # 开头代表选择 id,其后紧跟 id 的名称。另外,如果我们想选择 classwrapper 的节点,便可以使用.wrapper,这里以点(.)开头代表选择 class,其后紧跟 class 的名称。另外,还有一种选择方式,那就是根据标签名筛选,例如想选择二级标题,直接用 h2 即可。这是最常用的 3 种表示,分别是根据 idclass、标签名筛选,请牢记它们的写法。

另外,CSS 选择器还支持嵌套选择,各个选择器之间加上空格分隔开便可以代表嵌套关系,如 #container .wrapper p 则代表先选择 idcontainer 的节点,然后选中其内部的 classwrapper 的节点,然后再进一步选中其内部的 p 节点。另外,如果不加空格,则代表并列关系,如 div#container .wrapper p.text 代表先选择 idcontainerdiv 节点,然后选中其内部的 classwrapper 的节点,再进一步选中其内部的 classtextp 节点。这就是 CSS 选择器,其筛选功能还是非常强大的。

我们可以在浏览器中测试 CSS 选择器的效果,依然还是打开浏览器的开发者工具,然后按快捷键 Ctrl + F(如果你用的是 Mac,则是 Command + F),这时候在左下角便会出现一个搜索框,如图所示。

这时候我们输入 .title 就是选中了 class 为 title 的节点,这时候该节点就会被选中并在网页中高亮显示,如图所示:

输入 div#container .wrapper p.text 就逐层选中了 id 为 container 中 class 为 wrapper 节点中的 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,这种选择方式后面会详细介绍。

5. 总结

本节介绍了网页的结构和节点间的关系,了解了这些内容,我们才有更加清晰的思路去解析和提取网页内容。

本节参考来源:

Python

系列文章总目录:【2022 年】Python3 爬虫学习教程,本教程内容多数来自于《Python3 网络爬虫开发实战(第二版)》一书,目前截止 2022 年,可以将爬虫基本技术进行系统讲解,同时将最新前沿爬虫技术如异步、JavaScript 逆向、AST、安卓逆向、Hook、智能解析、群控技术、WebAssembly、大规模分布式、Docker、Kubernetes 等,市面上目前就仅有《Python3 网络爬虫开发实战(第二版)》一书了,点击了解详情

在正式学习网络爬虫之前,我们需要详细了解 HTTP 的基本原理,了解在浏览器中敲入 URL 到获取网页内容之间发生了什么。了解这些内容,有助于我们进一步了解爬虫的基本原理。

1.1 HTTP 基本原理

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

1. 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 的关系可以用图 1-1 表示。

URL、URN 和 URI 关系图

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

但 URL 也不是随便写的,它也是需要遵循一定的格式规范的,基本的组成格式如下:

1
scheme://[username:password@]hostname[:port][/path][;parameters][?query][#fragment]

其中这里中括号包括的内容代表非必要部分,比如 https://www.baidu.com 这个 URL,这里就只包含了 scheme 和 host 两部分,其他的 port、path、parameters、query、fragment 都没有。

这里我们分别介绍下几部分代表的含义和作用:

  • scheme:协议。比如常用的协议有 http、https、ftp 等等,另外 scheme 也被常称作 protocol,都代表协议的意思。
  • username、password:用户名和密码。在某些情况下 URL 需要提供用户名和密码才能访问,这时候可以把用户名密码放在 host 前面。比如 https://ssr3.scrape.center 这个 URL 需要用户名密码才能访问,那么可以直接写为 https://admin:admin@ssr3.scrape.center 则可以直接访问。
  • hostname:主机地址。可以是域名或 IP 地址,比如 https://www.baidu.com 这个 URL 中的 hostname 就是 www.baidu.com,这就是百度的二级域名。比如 https://8.8.8.8 这个 URL 中 hostname 就是 8.8.8.8,它是一个 IP 地址。
  • port:端口。这是服务器设定的服务端口,比如 https://8.8.8.8:12345 这个 URL 中的端口就是 12345。但是有些 URL 中没有端口信息,这是使用了默认的端口,http 协议的默认端口是 80,https 协议的默认端口是 443。所以 https://www.baidu.com 其实相当于 https://www.baidu.com:443,而 http://www.baidu.com 其实相当于 http://www.baidu.com:80。
  • path:路径。指的是网络资源在服务器中的指定地址,比如 https://github.com/favicon.ico 这里 path 就是 favicon.ico,指的就是访问 GitHub 上的根目录下的 favicon.ico 这个资源。
  • parameters:参数。用来制定访问某个资源的时候的附加信息,比如 https://8.8.8.8:12345/hello;user 这里的 user 就是 parameters。但是 parameters 现在用得很少,所以目前很多人会把该参数后面的 query 部分称为参数,甚至把 parameters 和 query 混用。严格意义上来说,parameters 是分号 ; 后面的内容。
  • query:查询。用来查询某类资源,如果有多个查询,则用 & 隔开。query 其实非常常见,比如 https://www.baidu.com/s?wd=nba&ie=utf-8,这里的 query 部分就是 wd=nba&ie=utf-8,这里指定了 wd 是 nba,ie 是 utf-8。由于 query 比刚才所说的 parameters 使用频率高太多,所以平时我们见到的参数、GET 请求参数、parameters、params 等称呼多数情况指代的也是 query。严格意义上来说,其实应该用 query 来表示。
  • fragment:片段。它是对资源描述的部分补充,可以理解为资源内部的书签。目前它有两个主要应用,一个是用作单页面路由,比如 现代前端框架 Vue、React 都可以借助它来做路由管理;另外一个应用是用作 HTML 锚点,用它可以控制一个页面打开时自动下滑滚动到某个特定的位置。

以上我们就简单了解了 URL 的基本概念和构成,后文我们会结合多个实战案例练习来帮助加深其理解。

2. HTTP 和 HTTPS

刚才我们了解了 URL 的基本构成,其支持的协议有很多,比如 http、https、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 版本,当然 HTTP 2.0 现在不少网站也增加了支持。

其发展历史见下表:

版本 产生时间 主要特点 发展现状
HTTP/0.9 1991 年 不涉及数据包传输,规定客户端和服务器之间通信格式,只能 GET 请求 没有作为正式的标准
HTTP/1.0 1996 年 传输内容格式不限制,增加 PUT、PATCH、HEAD、 OPTIONS、DELETE 命令 正式作为标准
HTTP/1.1 1997 年 持久连接(长连接)、节约带宽、HOST 域、管道机制、分块传输编码 正式作为标准并广泛使用
HTTP/2.0 2015 年 多路复用、服务器推送、头信息压缩、二进制协议等 逐渐覆盖市场

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

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

  • 建立一个信息安全通道,保证数据传输的安全性。
  • 确认网站的真实性。凡是使用了 https 的网站,都可以通过点击浏览器地址栏的锁头标志来查看网站认证之后的真实信息,也可以通过 CA 机构颁发的安全签章来查询。

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

  • 苹果公司强制所有 iOS App 在 2017 年 1 月 1 日前全部改为使用 HTTPS 加密,否则 App 就无法在应用商店上架。
  • 谷歌从 2017 年 1 月推出的 Chrome 56 开始,对未进行 HTTPS 加密的网址亮出风险提示,即在地址栏的显著位置提醒用户 “此网页不安全”。
  • 腾讯微信小程序的官方需求文档要求后台使用 HTTPS 请求进行网络通信,不满足条件的域名和协议无法请求。

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

注:HTTP 和 HTTPS 协议都属于计算机网络中的应用层协议,其下层是基于 TCP 协议实现的,TCP 协议属于计算机网络中的传输层协议,包括建立连接时的三次握手和断开时的四次挥手等过程。但本书主要讲的是网络爬虫相关,主要爬取的是 HTTP/HTTPS 协议相关的内容,所以这里就不再展开深入讲解 TCP、IP 等相关知识了,感兴趣的读者可以搜索相关资料了解下,如《计算机网络》、《图解 HTTP》等书籍。

3. HTTP 请求过程

我们在浏览器中输入一个 URL,回车之后便会在浏览器中观察到页面内容。

实际上,这个过程是浏览器向网站所在的服务器发送了一个请求,网站服务器接收到这个请求后进行处理和解析,然后返回对应的响应,接着传回给浏览器。

由于响应里包含页面的源代码等内容,浏览器再对其进行解析,便将网页呈现了出来,流程如图 1-3 所示。

模型图

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

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

打开 Chrome 浏览器,访问百度 http://www.baidu.com/,这时候鼠标右键并选择“检查”菜单(或直接按快捷键 F12),即可打开浏览器的开发者工具,如下图所示:

打开浏览器的开发者工具

我们切换到 Network 面板,然后重新刷新网页,这时候就可以看到在 Network 面板下方出现了很多个条目,其中一个条目就代表一次发送请求和接收响应的过程,如图所示:

请求和接收响应的过程

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

  • 第一列 Name:请求的名称,一般会将 URL 的最后一部分内容当作名称。
  • 第二列 Status:响应的状态码,这里显示为 200,代表响应是正常的。通过状态码,我们可以判断发送了请求之后是否得到了正常的响应。
  • 第三列 Protocol:请求的协议类型,这里 http/1.1 代表是 HTTP 1.1 版本,h2 代表 HTTP 2.0 版本。
  • 第四列 Type:请求的文档类型。这里为 document,代表我们这次请求的是一个 HTML 文档,内容就是一些 HTML 代码。
  • 第五列 Initiator:请求源。用来标记请求是由哪个对象或进程发起的。
  • 第六列 Size:从服务器下载的文件和请求的资源大小。如果是从缓存中取得的资源,则该列会显示 from cache。
  • 第七列 Time:发起请求到获取响应所用的总时间。
  • 第八列 Waterfall:网络请求的可视化瀑布流。

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

更详细的信息

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

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

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

4. 请求(Request)

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

下面我们分别予以介绍。

请求方法(Request Method)

请求方法,英文为 Request Method,用于标识请求客户端请求服务端的方式,常见的请求方法有两种:GET 和 POST。

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

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

  • GET 请求中的参数包含在 URL 里面,数据可以在 URL 中看到;而 POST 请求的 URL 不会包含这些数据,数据都是通过表单形式传输的,会包含在请求体中。
  • GET 请求提交的数据最多只有 1024 字节,而 POST 方式没有限制。

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

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

方  法 描  述
GET 请求页面,并返回页面内容
HEAD 类似于 GET 请求,只不过返回的响应中没有具体的内容,用于获取报头
POST 大多用于提交表单或上传文件,数据包含在请求体中
PUT 从客户端向服务器传送的数据取代指定文档中的内容
DELETE 请求服务器删除指定的页面
CONNECT 把服务器当作跳板,让服务器代替客户端访问其他网页
OPTIONS 允许客户端查看服务器的性能
TRACE 回显服务器收到的请求,主要用于测试或诊断

本表参考:http://www.runoob.com/http/http-methods.html

请求的网址(Request URL)

请求的网址,英文为 Reqeust URL,它可以唯一确定我们想请求的资源。关于 URL 的构成和各个部分的功能我们在前文已经提及到了,这里就不再赘述。

请求头(Request Headers)

请求头,英文为 Request Headers,用来说明服务器要使用的附加信息,比较重要的信息有 Cookie、Referer、User-Agent 等。

下面简要说明一些常用的头信息:

  • Accept:请求报头域,用于指定客户端可接受哪些类型的信息。
  • Accept-Language:指定客户端可接受的语言类型。
  • Accept-Encoding:指定客户端可接受的内容编码。
  • Host:用于指定请求资源的主机 IP 和端口号,其内容为请求 URL 的原始服务器或网关的位置。从 HTTP 1.1 版本开始,请求必须包含此内容。
  • Cookie:也常用复数形式 Cookies,这是网站为了辨别用户进行会话跟踪而存储在用户本地的数据。它的主要功能是维持当前访问会话。例如,我们输入用户名和密码成功登录某个网站后,服务器会用会话保存登录状态信息,后面我们每次刷新或请求该站点的其他页面时,会发现都是登录状态,这就是 Cookie 的功劳。Cookie 里有信息标识了我们所对应的服务器的会话,每次浏览器在请求该站点的页面时,都会在请求头中加上 Cookie 并将其发送给服务器,服务器通过 Cookie 识别出是我们自己,并且查出当前状态是登录状态,所以返回结果就是登录之后才能看到的网页内容。
  • Referer:此内容用来标识这个请求是从哪个页面发过来的,服务器可以拿到这一信息并做相应的处理,如做来源统计、防盗链处理等。
  • User-Agent:简称 UA,它是一个特殊的字符串头,可以使服务器识别客户使用的操作系统及版本、浏览器及版本等信息。在做爬虫时加上此信息,可以伪装为浏览器;如果不加,很可能会被识别为爬虫。
  • Content-Type:也叫互联网媒体类型(Internet Media Type)或者 MIME 类型,在 HTTP 协议消息头中,它用来表示具体请求中的媒体类型信息。例如,text/html 代表 HTML 格式,image/gif 代表 GIF 图片,application/json 代表 JSON 类型,更多对应关系可以查看此对照表:http://tool.oschina.net/commons

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

请求体(Request Body)

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

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

请求和响应

登录之前,我们填写了用户名和密码信息,提交时这些内容就会以表单数据的形式提交给服务器,此时需要注意 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 提交数据方式的关系

Content-Type 提交数据的方式
application/x-www-form-urlencoded 表单数据
multipart/form-data 表单文件上传
application/json 序列化 JSON 数据
text/xml XML 数据

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

5. 响应(Response)

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

响应状态码(Response Status Code)

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

常见的错误代码及错误原因

状态码 说  明 详  情
100 继续 请求者应当继续提出请求。服务器已收到请求的一部分,正在等待其余部分
101 切换协议 请求者已要求服务器切换协议,服务器已确认并准备切换
200 成功 服务器已成功处理了请求
201 已创建 请求成功并且服务器创建了新的资源
202 已接受 服务器已接受请求,但尚未处理
203 非授权信息 服务器已成功处理了请求,但返回的信息可能来自另一个源
204 无内容 服务器成功处理了请求,但没有返回任何内容
205 重置内容 服务器成功处理了请求,内容被重置
206 部分内容 服务器成功处理了部分请求
300 多种选择 针对请求,服务器可执行多种操作
301 永久移动 请求的网页已永久移动到新位置,即永久重定向
302 临时移动 请求的网页暂时跳转到其他页面,即暂时重定向
303 查看其他位置 如果原来的请求是 POST,重定向目标文档应该通过 GET 提取
304 未修改 此次请求返回的网页未修改,继续使用上次的资源
305 使用代理 请求者应该使用代理访问该网页
307 临时重定向 请求的资源临时从其他位置响应
400 错误请求 服务器无法解析该请求
401 未授权 请求没有进行身份验证或验证未通过
403 禁止访问 服务器拒绝此请求
404 未找到 服务器找不到请求的网页
405 方法禁用 服务器禁用了请求中指定的方法
406 不接受 无法使用请求的内容响应请求的网页
407 需要代理授权 请求者需要使用代理授权
408 请求超时 服务器请求超时
409 冲突 服务器在完成请求时发生冲突
410 已删除 请求的资源已永久删除
411 需要有效长度 服务器不接受不含有效内容长度标头字段的请求
412 未满足前提条件 服务器未满足请求者在请求中设置的其中一个前提条件
413 请求实体过大 请求实体过大,超出服务器的处理能力
414 请求 URI 过长 请求网址过长,服务器无法处理
415 不支持类型 请求格式不被请求页面支持
416 请求范围不符 页面无法提供请求的范围
417 未满足期望值 服务器未满足期望请求标头字段的要求
500 服务器内部错误 服务器遇到错误,无法完成请求
501 未实现 服务器不具备完成请求的功能
502 错误网关 服务器作为网关或代理,从上游服务器收到无效响应
503 服务不可用 服务器目前无法使用
504 网关超时 服务器作为网关或代理,但是没有及时从上游服务器收到请求
505 HTTP 版本不支持 服务器不支持请求中所用的 HTTP 协议版本

响应头(Response Headers)

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

  • Date:标识响应产生的时间。
  • Last-Modified:指定资源的最后修改时间。
  • Content-Encoding:指定响应内容的编码。
  • Server:包含服务器的信息,比如名称、版本号等。
  • Content-Type:文档类型,指定返回的数据类型是什么,如 text/html 代表返回 HTML 文档,application/x-javascript 则代表返回 JavaScript 文件,image/jpeg 则代表返回图片。
  • Set-Cookie:设置 Cookie。响应头中的 Set-Cookie 告诉浏览器需要将此内容放在 Cookie 中,下次请求携带 Cookie 请求。
  • Expires:指定响应的过期时间,可以使代理服务器或浏览器将加载的内容更新到缓存中。如果再次访问时,就可以直接从缓存中加载,降低服务器负载,缩短加载时间。

响应体(Response Body)

响应体,即 Response Body,这可以说是最关键的部分了,响应的正文数据都在响应体中,比如请求网页时,它的响应体就是网页的 HTML 代码;请求一张图片时,它的响应体就是图片的二进制数据。我们做爬虫请求网页后,要解析的内容就是响应体,如图 1-7 所示。

响应体

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

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

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

6. HTTP/2.0

前面我们也提到了 HTTP 协议从 2015 年起发布了 2.0 版本,相比 HTTP/1.1 来说,HTTP/2.0 变得更快、更简单、更稳定,HTTP/2.0 在传输层做了很多优化,HTTP/2.0 的主要目标是通过支持完整的请求与响应复用来减少延迟,并通过有效压缩 HTTP 请求头字段将协议开销降至最低,同时增加对请求优先级和服务器推送的支持,这些优化一笔勾销了 HTTP/1.1 为做传输优化想出的一系列“歪招”。

有读者这时候可能会问,为什么不叫 HTTP/1.2 而叫 HTTP/2.0 呢?因为 HTTP/2.0 在内部实现上新的二进制分帧层,这是没法与之前的 HTTP/1.x 的服务器和客户端实现向后兼容的,所以直接修改了主版本号为 2.0。

下面我们就来了解下 HTTP/2.0 相比 HTTP/1.1 来说做了哪些优化吧。

二进制分帧层

HTTP/2.0 所有性能增强的核心就在于这个新的二进制分帧层。在 HTTP/1.x 中,不管是请求(Request)还是响应(Response),它们都是用文本格式传输的,其头部(Headers)、实体(Body)之间也是用文本换行符分隔开的。HTTP/2.0 对其做了优化,将文本格式修改为了二进制格式,使得解析起来更加高效。同时将请求和响应数据分割为更小的帧,并采用二进制编码。

所以这里就引入了几个新的概念:

  • 帧:只存在于 HTTP/2.0 中的概念,是数据通信的最小单位,比如一个请求被分为了请求头帧(Request Headers frame)和请求体/数据帧(Request Data frame)。
  • 数据流:一个虚拟通道,可以承载双向的消息,每个流都有一个唯一的整数 ID 来标识。
  • 消息:与逻辑请求或响应消息对应的完整的一系列帧。

在 HTTP/2.0 中,同域名下的所有通信都可以在单个连接上完成,该连接可以承载任意数量的双向数据流,数据流是用于承载双向消息的,每条消息都是一条逻辑 HTTP 消息(例如请求或响应),它可以包含一个或多个帧。

简而言之,HTTP/2.0 将 HTTP 协议通信分解为二进制编码帧的交换,这些帧对应着特定数据流中的消息,所有这些都在一个 TCP 连接内复用,这是 HTTP/2.0 协议所有其他功能和性能优化的基础。

多路复用

在 HTTP/1.x 中,如果客户端要想发起多个并行请求以提升性能,则必须使用多个 TCP 连接,而且浏览器位了控制资源,还会对单个域名有 6-8 个 TCP 连接请求的限制。但在 HTTP/2.0 中,由于又了二进制分帧技术的加持,HTTP/2.0 不用再以来 TCP 连接去实现多路并行了,客户端和服务器可以将 HTTP 消息分解为互不依赖的帧,然后交错发送,最后再在另一端把它们重新组装起来,让我们可以:

  • 并行交错地发送多个请求,请求之间互不影响。
  • 并行交错地发送多个响应,响应之间互不干扰。
  • 使用一个连接并行发送多个请求和响应。
  • 不必再为绕过 HTTP/1.x 限制而做很多工作。
  • 消除不必要的延迟和提高现有网络容量的利用率,从而减少页面加载时间。

这样以来,整个数据传输使性能就有了极大提升:

  • 同个域名只需要占用一个 TCP 连接,使用一个连接并行发送多个请求和响应,消除了因多个 TCP 连接而带来的延时和内存消耗。
  • 并行交错地发送多个请求和详情,而且之间互不影响。
  • 在 HTTP/2.0 中,每个请求都可以带一个 31bit 的优先值,0 表示最高优先级, 数值越大优先级越低。有了这个优先值,客户端和服务器就可以在处理不同的流时采取不同的策略,以最优的方式发送流、消息和帧。

流量控制

流控制是一种阻止发送方向接收方发送大量数据的机制,以免超出后者的需求或处理能力。可以理解为,接收方已经太繁忙了,来不及处理收到的消息了,但是发送方还在一直大量发送消息,这样就会出现一些问题。

比如说,客户端可能请求了一个具有较高优先级的大型视频流,但是用户已经暂停视频,客户端现在希望暂停或限制从服务器的传输,以免提取和缓冲不必要的数据。 再比如,一个代理服务器可能具有较快的下游连接和较慢的上游连接,并且也希望调节下游连接传输数据的速度以匹配上游连接的速度来控制其资源利用率等等。

由于 HTTP 是基于 TCP 实现的,虽然 TCP 原生有流量控制机制,但是由于 HTTP/2.0 数据流在一个 TCP 连接内复用,TCP 流控制既不够精细,也无法提供必要的应用级 API 来调节各个数据流的传输。

为了解决这一问题,HTTP/2.0 提供了一组简单的构建块,这些构建块允许客户端和服务器实现其自己的数据流和连接级流控制:

  • 流控制具有方向性。 每个接收方都可以根据自身需要选择为每个数据流和整个连接设置任意的窗口大小。
  • 流控制基于信用。 每个接收方都可以公布其初始连接和数据流流控制窗口(以字节为单位),每当发送方发出 DATA 帧时都会减小,在接收方发出 WINDOW_UPDATE 帧时增大。
  • 流控制无法停用。 建立 HTTP/2.0 连接后,客户端将与服务器交换 SETTINGS 帧,这会在两个方向上设置流控制窗口。 流控制窗口的默认值设为 65535 字节,但是接收方可以设置一个较大的最大窗口大小(2^31-1 字节),并在接收到任意数据时通过发送 WINDOW_UPDATE 帧来维持这一大小。
  • 流控制为逐跃点控制,而非端到端控制。 即,可信中介可以使用它来控制资源使用,以及基于自身条件和启发式算法实现资源分配机制。

由此可见,HTTP/2.0 提供了简单的构建块实现了自定义策略来调节资源使用和分配,以及实现新传输能力,同时提升了网页应用的实际性能和感知性能。

服务端推送

HTTP/2.0 新增的另一个强大的新功能是,服务器可以对一个客户端请求发送多个响应。 换句话说,除了对最初请求的响应外,服务器还可以向客户端推送额外资源,而无需客户端明确地请求。

如果某些资源客户端是一定会请求的,这时就可以采取服务端推送的技术,在客户端发起一次请求后,额外提前给客户端推送必要的资源,这样就可以相对减少一点延迟时间。例如,服务端可以主动把 JS 和 CSS 文件推送给客户端,而不需要客户端解析 HTML 时再发送这些请求。

服务端推送

服务端可以主动推送,当然客户端也有权利选择是否接收。如果服务端推送的资源已经被浏览器缓存过,浏览器可以通过发送 RST_STREAM 帧来拒收。

另外主动推送也遵守同源策略,即服务器不能随便将第三方资源推送给客户端,而必须是经过双方确认才行,这样也能保证一定的安全性。

HTTP/2.0 发展现状

HTTP/2.0 的普及是一件任重而道远的事情,一些主流的网站现在已经支持了 HTTP/2.0,主流浏览器现在都已经实现了 HTTP/2.0 的支持,但总的来看,目前大部分网站依然还是以 HTTP/1.1 为主。

另外一些编程语言的库还没有完全支持 HTTP/2.0,比如对于 Python 来说,hyper、httpx 等库已经支持了 HTTP/2.0,但广泛使用的 requests 库依然还是只支持 HTTP/1.1。

7. 总结

本节介绍了关于 HTTP 的一些基础知识,内容不少,需要好好掌握,这些知识对于后面我们编写和理解网络爬虫具有非常大的帮助。

由于本节的内容多数为概念介绍,内容参考了很多书籍、文档、博客,来源如下:

Markdown

相信绝大多数朋友做 PPT(幻灯片 / Slides / Deck 等各种称呼了)都是用的 PowerPoint 或者 KeyNote 吧?功能是比较强大,但你有没有遇到过这样的痛点:

  • 各种标题、段落的格式不统一,比如字体大小、行间距等等各个页面不太一样,然后得用格式刷来挨个刷一下。
  • 想给 PPT 做版本控制,然后就保存了各种复制版本,比如“一版”、“二版”、“终版”、“最终版”、“最终不改版”、“最终稳定不改版”等等,想必大家都见过类似这样的场景吧。
  • 想插入代码,但是插入之后发现格式全乱了或者高亮全没了,然后不得不截图插入进去。
  • 想插入个公式,然后发现 PPT、Keynote 对 Latex 兼容不太好或者配置稍微麻烦,就只能自己重新敲一遍或者贴截图。
  • 想插入一个酷炫的交互组件,比如嵌入一个微博的网页页面实时访问、插入一个可以交互的组件、插入一个音乐播放器组件,原生的 PPT 功能几乎都不支持,这全得依赖于 PowerPoint 或者 KeyNote 来支持才行。

如果你遇到这些痛点,那请你一定要看下去。如果你没有遇到,那也请你看下去吧(拜托。

好,说回正题,我列举了那么多痛点,那这些痛点咋解决呢?

能!甚至解决方案更加轻量级,那就是用 Markdown 来做 PPT!

你试过用 Markdown 写 PPT 吗?没有吧,试试吧,试过之后你就发现上面的功能简直易如反掌。

具体怎么实现呢?

接下来,就有请今天的主角登场了!它就是 Slidev。

什么是 Slidev?

简而言之,Slidev 就是可以让我们用 Markdown 写 PPT 的工具库,基于 Node.js、Vue.js 开发。

利用它我们可以简单地把 Markdown 转化成 PPT,而且它可以支持各种好看的主题、代码高亮、公式、流程图、自定义的网页交互组件,还可以方便地导出成 pdf 或者直接部署成一个网页使用。

官方主页:https://sli.dev/

GitHub:https://github.com/slidevjs/slidev

安装和启动

下面我们就来了解下它的基本使用啦。

首先我们需要先安装好 Node.js,推荐 14.x 及以上版本,安装方法见 https://setup.scrape.center/nodejs

接着,我们就可以使用 npm 这个命令了。

然后我们可以初始化一个仓库,运行命令如下:

1
npm init slidev@latest

这个命令就是初始化一个 Slidev 的仓库,运行之后它会让我们输入和选择一些选项,如图所示:

比如上图就是先输入项目文件夹的名称,比如这里我取名叫做 slidevtest。

总之一些选项完成之后,Slidev 会在本地 3000 端口上启动,如图所示:

接着,我们就可以打开浏览器 http://localhost:3000 来查看一个 HelloWorld 版本的 PPT 了,如图所示:

我们可以点击空格进行翻页,第二页展示了一张常规的 PPT 的样式,包括标题、正文、列表等,如图所示:

那这一页的 Markdown 是什么样的呢?其实就是非常常规的 Markdown 文章的写法,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# What is Slidev?

Slidev is a slides maker and presenter designed for developers, consist of the following features

- 📝 **Text-based** - focus on the content with Markdown, and then style them later
- 🎨 **Themable** - theme can be shared and used with npm packages
- 🧑‍💻 **Developer Friendly** - code highlighting, live coding with autocompletion
- 🤹 **Interactive** - embedding Vue components to enhance your expressions
- 🎥 **Recording** - built-in recording and camera view
- 📤 **Portable** - export into PDF, PNGs, or even a hostable SPA
- 🛠 **Hackable** - anything possible on a webpage

<br>
<br>

Read more about [Why Slidev?](https://sli.dev/guide/why)

是不是?我们只需要用同样格式的 Markdown 语法就可以轻松将其转化为 PPT 了。

快捷键操作

再下一页介绍了各种快捷键的操作,这个就很常规了,比如点击空格、上下左右键来进行页面切换,如图所示:

更多快捷键的操作可以看这里的说明:https://sli.dev/guide/navigation.html,一些简单的快捷键列举如下:

  • f:切换全屏
  • right / space:下一动画或幻灯片
  • left:上一动画或幻灯片
  • up:上一张幻灯片
  • down:下一张幻灯片
  • o:切换幻灯片总览
  • d:切换暗黑模式
  • g:显示“前往…”

代码高亮

接下来就是代码环节了,因为 Markdown 对代码编写非常友好,所以展示自然也不是问题了,比如代码高亮、代码对齐等都是常规操作,如图所示:

那左边的代码定义就直接这么写就行了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Code

Use code snippets and get the highlighting directly![^1]

```ts {all|2|1-6|9|all}
interface User {
id: number
firstName: string
lastName: string
role: string
}

function updateUser(id: number, update: User) {
const user = getUser(id)
const newUser = {...user, ...update}
saveUser(id, newUser)
}
```

由于是 Markdown,所以我们可以指定是什么语言,比如 TypeScript、Python 等等。

网页组件

接下来就是非常酷炫的环节了,我们还可以自定义一些网页组件,然后展示出来。

比如我们看下面的一张图。左边就呈现了一个数字计数器,点击左侧数字就会减 1,点击右侧数字就会加 1;另外图的右侧还嵌入了一个组件,这里显示了一个推特的消息,通过一个卡片的形式呈现了出来,不仅仅可以看内容,甚至我们还可以点击下方的喜欢、回复、复制等按钮来进行一些交互。

这些功能在网页里面并不稀奇,但是如果能做到 PPT 里面,那感觉就挺酷的。

那这一页怎么做到的呢?这个其实是引入了一些基于 Vue.js 的组件,本节对应的 Markdown 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# Components

<div grid="~ cols-2 gap-4">
<div>

You can use Vue components directly inside your slides.

We have provided a few built-in components like `<Tweet/>` and `<Youtube/>` that you can use directly. And adding your custom components is also super easy.

```html
<Counter :count="10" />
```

<!-- ./components/Counter.vue -->
<Counter :count="10" m="t-4" />

Check out [the guides](https://sli.dev/builtin/components.html) for more.

</div>
<div>

```html
<Tweet id="1390115482657726468" />
```

<Tweet id="1390115482657726468" scale="0.65" />

</div>
</div>

这里我们可以看到,这里引入了 Counter、Tweet 组件,而这个 Counter 就是 Vue.js 的组件,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<script setup lang="ts">
import { ref } from 'vue'

const props = defineProps({
count: {
default: 0,
},
})

const counter = ref(props.count)
</script>

<template>
<div flex="~" w="min" border="~ gray-400 opacity-50 rounded-md">
<button
border="r gray-400 opacity-50"
p="2"
font="mono"
outline="!none"
hover:bg="gray-400 opacity-20"
@click="counter -= 1"
>
-
</button>
<span m="auto" p="2">{{ counter }}</span>
<button
border="l gray-400 opacity-50"
p="2"
font="mono"
outline="!none"
hover:bg="gray-400 opacity-20"
@click="counter += 1"
>
+
</button>
</div>
</template>

这就是一个标准的基于 Vue.js 3.x 的组件,都是标准的 Vue.js 语法,所以如果我们要添加想要的组件,直接自己写就行了,什么都能实现,只要网页能支持的,统统都能写!

主题定义

当然,一些主题定制也是非常方便的,我们可以在 Markdown 文件直接更改一些配置就好了,比如就把 theme 换个名字,整个主题样式就变了,看如下的对比图:

上面就是一些内置主题,当然我们也可以去官方文档查看一些别人已经写好的主题,见:https://sli.dev/themes/gallery.html

另外我们自己写主题也是可以的,所有的主题样式都可以通过 CSS 等配置好,想要什么就可以有什么,见:https://sli.dev/themes/write-a-theme.html。

公式和图表

接下来就是一个非常强大实用的功能,公式和图表,支持 Latex、流程图,如图所示:

比如上面的 Latex 的源代码就是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Inline $\sqrt{3x-1}+(1+x)^2$

Block
$$
\begin{array}{c}

\nabla \times \vec{\mathbf{B}} -\, \frac1c\, \frac{\partial\vec{\mathbf{E}}}{\partial t} &
= \frac{4\pi}{c}\vec{\mathbf{j}} \nabla \cdot \vec{\mathbf{E}} & = 4 \pi \rho \\

\nabla \times \vec{\mathbf{E}}\, +\, \frac1c\, \frac{\partial\vec{\mathbf{B}}}{\partial t} & = \vec{\mathbf{0}} \\

\nabla \cdot \vec{\mathbf{B}} & = 0

\end{array}
$$

其语法也是和 Latex 一样的。

其背后是怎么实现的呢?其实是因为 Slidev 默认集成了 Katex 这个库,见:https://katex.org/,有了 Katex 的加持,所有公式的显示都不是事。

页面分隔

有的朋友就好奇了,既然是用 Markdown 写 PPT,那么每一页之间是怎么分割的呢?

其实很简单,最常规的,用三条横线分割就好了,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
---
layout: cover
---

# 第 1 页

This is the cover page.

---

# 第 2 页

The second page

当然,除了使用三横线,我们还可以使用更丰富的定义模式,可以给每一页制定一些具体信息,就是使用两层三横线。

比如这样:

1
2
3
4
5
---
theme: seriph
layout: cover
background: 'https://source.unsplash.com/1600x900/?nature,water'
---

上面这样的配置可以替代三横线,是另一种可以用作页面分隔的写法,借助这种写法我们可以定义更多页面的具体信息。

备注

当然我们肯定也想给 PPT 添加备注,这个也非常简单,通过注释的形式写到 Markdown 源文件就好了:

1
2
3
4
5
6
7
8
9
---
layout: cover
---

# 第 1 页

This is the cover page.

<!-- 这是一条备注 -->

这里可以看到其实就是用了注释的特定语法。

演讲者头像

当然还有很多酷炫的功能,比如说,我们在讲 PPT 的时候,可能想同时自己也出镜,Slidev 也可以支持。

因为开的是网页,而网页又有捕捉摄像头的功能,所以最终效果可以是这样子:

是的没错!右下角就是演讲者的个人头像,它被嵌入到了 PPT 中!是不是非常酷!

演讲录制

当然,Slidev 还支持演讲录制功能,因为它背后集成了 WebRTC 和 RecordRTC 的 API,一些录制配置如下所示:

所以,演讲过程的录制完全不是问题。

具体的操作可以查看:https://sli.dev/guide/recording.html

部署

当然用 Slidev 写的 PPT 还可以支持部署,因为这毕竟就是一个网页。

而且部署非常简单和轻量级,因为这就是一些纯静态的 HTML、JavaScript 文件,我们可以轻松把它部署到 GitHub Pages、Netlify 等站点上。

试想这么一个场景:别人在演讲之前还在各种拷贝 PPT,而你打开了一个浏览器直接输入了一个网址,PPT 就出来了,众人惊叹,就问你装不装逼?

具体的部署操作可以查看:https://sli.dev/guide/hosting.html

让我们看几个别人已经部署好的 PPT,直接网页打开就行了:

就是这么简单方便。

版本控制

什么?你想实现版本控制,那再简单不过了。

Markdown 嘛,配合下专业版本管理工具 Git,版本控制再也不是难题。

总结

以上就是对 Slidev 的简单介绍,确实不得不说有些功能真的非常实用,而且我本身特别喜欢 Markdown 和网页开发,所以这个简直对我来说太方便了。

如果你感兴趣的话,不妨也来试试吧~

更多精彩内容,请关注我的公众号「进击的 Coder」和「崔庆才丨静觅」。

Python

有时候我们会有这样的一个需求:

我们定义了一个 Python 的方法,方法接收一些参数,但是调用的时候想将这些参数用命令行暴露出来。

比如说这里有个爬取方法:

1
2
3
4
5
import requests

def scrape(url, timeout=10):
response = requests.get(url, timeout=timeout)
print(response.text)

这里定义了一个 scrape 方法,第一个参数接收 url,即爬取的网址,第二个参数接收 timeout,即指定超时时间。

调用的时候我们可能这么调用:

1
scrape('https:///www.baidu.com', 10)

如果我们想改参数换 url,那就得改代码对吧。

所以有时候我们就想把这些参数用命令行暴露出来,这时候我们可能就用上了 argparse 等等的库,挨个声明各个参数是干嘛的,非常繁琐,代码如下:

1
2
3
4
5
6
7
8
9
parser = argparse.ArgumentParser(description='Scrape Function')
parser.add_argument('url', type=str,
help='an integer for the accumulator')
parser.add_argument('timeout', type=int,
help='sum the integers (default: find the max)')

if __name__ == '__main__':
args = parser.parse_args()
scrape(args.url, args.timeout)

这样我们才能顺利地使用命令行来调用这个脚本:

1
python3 main.py https://www.baidu.com 10

是不是感觉非常麻烦?argparse 写起来又臭又长,想想就费劲。

Fire

但接下来我们要介绍一个库,用它我们只需要两行代码就可以做到如上操作。

这个库的名字叫做Fire,它可以快速为某个 Python 方法或者类添加命令行的参数支持。

先看看安装方法,使用 pip3 安装即可:

1
pip3 install fire

这样我们就安装好了。

使用

下面我们来看几个例子。

方法支持

第一个代码示例如下:

1
2
3
4
5
6
7
import fire

def hello(name="World"):
return "Hello %s!" % name

if __name__ == '__main__':
fire.Fire(hello)

这里我们定义了一个 hello 方法,然后接收一个 name 参数,默认值是 World,接着输出了 Hello 加 name 这个字符串。

然后接着我们导入了 fire 这个库,调用它的 Fire 方法并传入 hello 这个方法声明,会发生什么事情呢?

我们把这段代码保存为 demo1.py,接着用 Python3 来运行一下:

1
python3 demo1.py

运行结果如下:

1
Hello World!

看起来并没有什么不同。

但我们这时候如果运行如下命令,就可以看到一些神奇的事情了:

1
python3 demo1.py --help

运行结果如下:

1
2
3
4
5
6
7
8
9
NAME
demo1.py

SYNOPSIS
demo1.py <flags>

FLAGS
--name=NAME
Default: 'World'

可以看到,这里它将 name 这个参数转化成了命令行的一个可选参数,我们可以通过 —-name 来替换 name 参数。

我们来试下:

1
python3 demo1.py --name 123

这里我们传入了一个 name 参数是 123,这时候我们就发现运行结果就变成了如下内容:

1
Hello 123!

是不是非常方便?我们没有借助 argparse 就轻松完成了命令行参数的支持和替换。

那如果我们将 name 这个参数的默认值取消呢?代码改写如下:

1
2
3
4
5
6
7
import fire

def hello(name):
return "Hello %s!" % name

if __name__ == '__main__':
fire.Fire(hello)

这时候重新运行:

1
python3 demo1.py --help

就可以看到结果变成了如下内容:

1
2
3
4
5
6
7
8
9
10
11
NAME
demo1.py

SYNOPSIS
demo1.py NAME

POSITIONAL ARGUMENTS
NAME

NOTES
You can also use flags syntax for POSITIONAL ARGUMENTS

这时候我们发现 name 这个参数就变成了必传参数,我们必须在命令行里指定这个参数内容,调用就会变成如下命令:

1
python3 demo1.py 123

运行结果还是一样的。

类支持

当然 fire 这个库不仅仅支持给方法添加命令行的支持,还支持给一个类添加命令行的支持。

下面我们再看一个例子:

1
2
3
4
5
6
7
8
import fire

class Calculator(object):
def double(self, number):
return 2 * number

if __name__ == '__main__':
fire.Fire(Calculator)

我们把这个代码保存为 demo2.py,然后运行:

1
python3 demo2.py

运行结果如下:

1
2
3
4
5
6
7
8
9
10
NAME
demo2.py

SYNOPSIS
demo2.py COMMAND

COMMANDS
COMMAND is one of the following:

double

可以看到,这里它将 Calculator 这个类中的方法识别出来了,COMMAND 之一就是 double,我们试着调用下:

1
python3 demo2.py double

运行结果如下:

1
2
3
4
5
ERROR: The function received no value for the required argument: number
Usage: demo2.py double NUMBER

For detailed information on this command, run:
demo2.py double --help

这里就说了,这里必须要指定另外一个参数,叫做 NUMBER,同时这个参数还是必填参数,我们试着加下:

1
python3 demo2.py double 4

运行结果如下:

1
8

这时候就可以达到正确结果了。

所以说,综合来看,fire 可以为一个类命令行,每个命令都对应一个方法的名称,同时在后面添加额外的可选或必选参数,加到命令行参数的后面。

重新改写

最后,让我们回过头来,给我们一开始定义的 scrape 方法添加命令行的参数支持:

1
2
3
4
5
6
7
8
9
10
import requests
import fire

def scrape(url, timeout=10):
response = requests.get(url, timeout=timeout)
print(response.text)


if __name__ == '__main__':
fire.Fire(scrape)

这样就可以了!省去了冗长的 argparse 的代码,是不是非常方便?

调用就是如下形式:

1
2
3
4
5
6
7
8
9
10
11
12
NAME
main.py

SYNOPSIS
main.py URL <flags>

POSITIONAL ARGUMENTS
URL

FLAGS
--timeout=TIMEOUT
Default: 10

这里说了,URL 是必传参数,timeout 是可选参数。

最后调用下:

1
python3 main.py https://www.baidu.com

这样我们就可以轻松将 url 通过命令行传递过去了。

当然 timeout 还是可选值,我们可以通过 —-timeout 来指定 timeout 参数:

1
python3 main.py https://www.baidu.com --timeout 5

这样两个参数就都能顺利赋值了,最后效果就是爬取百度,5 秒超时。

怎么样?是不是很方便?大家快用起来吧!

更多精彩内容,请关注我的公众号「进击的 Coder」和「崔庆才丨静觅」。

个人随笔

之前跟一个朋友聊天,思考到一点,分享一下。

我这个朋友在创业,大家知道,创业都是非常艰难的,几乎是九死一生。

他创业一年多了,业务现在也挺好的,一直很有拼劲。

我就问他是什么让他一直保持这样持续向前向上的状态的。

我们聊了很多,但我发现其中有一个很重要的点就是那种笃定的态度,核心就在于两个字,笃定。

这个笃定分为两个方面,一个是做事上的笃定,一个是信念上的笃定。

分别说说。

  • 关于做事上的笃定。就是能认定了一件事之后,不会轻言放弃或更改,不能今天干了这一点,第二天就换去干另外一个了,也不能看到一点点困难就立即退缩不干了。他说他历经了一些困难的时刻,但经过反复复盘和思考,不断做到了螺旋式上升,现在经验越来越多,做得也越来越顺。所以在做事的时候,内心要有一种笃定的态度,认准了目标不断进发,虽然说努力了不一定成功,但成功几率一定比不努力或直接放弃大的多多多。
  • 关于信念上的笃定。就是能够在心中有一种信念,认定做某件事是有意义的有价值的,不会轻易被动摇。现在很多人做事都是,听到别人说做什么做什么挺赚钱,那就跟风去做了。或者做一个产品,他觉得会有市场需求有用户去用,完全迎合用户,但到头来发现是众口难调。真正信念上的笃定是一个人觉得做这件事是真正有意义的,有价值的,那就够了,不会被轻易动摇,不会一味迎合他人。只要他认定的正确的事,那就去做,这就是信念上的笃定。

当然可能有人会反驳了,这么笃定,都不听取别人的意见吗?一味向前冲不是死脑筋吗?这里要解释一下。这里说的笃定是一种广义上的笃定,这种态度信念是要有的,是一种大目标,总体的态度是要这样,但不代表不知变通或者死脑筋。在做具体的事情的时候,肯定还是反思、复盘,去思考什么才是达成大目标的方案,达成大目标的一步步小目标是怎样的,不断提升和完善的过程中慢慢地去接近笃定的大目标,这才是正确的。

与君共勉。

更多精彩内容,请关注我的公众号「进击的 Coder」和「崔庆才丨静觅」。

Python

上一篇我写过一篇《万物皆可 API》,这个项目就是把一些脚本的执行结果输出到了网页里面。

但是这个还是有很多改进空间,比如说 UI 能好看些,甚至能执行交互命令该多好,最后思来想去,它的究极形态不就是一个 Web 版的 Terminal (终端)吗?

然后本来我还想着对项目进行改造来着,但是想想,最终如果要改造成一个 Web 版的 Terminal,这个肯定已经有开源实现了。

于是我就开始搜,最后搜到几个还不错的。

Web Terminal

  • ttyd:https://github.com/tsl0922/ttyd,一款可以将命令行转到 Web 执行的工具,基于 C 编写的。
  • gotty:https://github.com/yudai/gotty,和 ttyd 一样,只不过是 Go 语言写的,但最新更新是在 2017 年了,估计失修了。
  • wetty:https://github.com/butlerx/wetty,基于 Node.js 开发的,也可以将命令行转到 Web 执行,但是需要基于 SSH 登录,其实就是个 Web 版的 SSH 终端。
  • Secure Shell (Chrome App):Google 浏览器插件,也可以提供网页版 SSH 终端。
  • tmate:https://tmate.io/,从 tmux 修改而来,可以支持 Terminal 分享。

经过一番试用,我个人首推的还是 ttyd,其他的几个要么是基于 SSH 的,要么不怎么好用或停止维护了。

下面我就来介绍下 ttyd 的简单用法。

安装

安装其实非常简单,我用的是 Mac,所以用 HomeBrew 直接安装即可:

1
brew install ttyd

如果你用的是 Windows、Linux,依然也可以支持,安装可以参考 https://github.com/tsl0922/ttyd#installation 章节。

使用

ttyd 支持不少功能配置,完整命令如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
ttyd is a tool for sharing terminal over the web

USAGE:
ttyd [options] <command> [<arguments...>]

VERSION:
1.6.3

OPTIONS:
-p, --port Port to listen (default: 7681, use `0` for random port)
-i, --interface Network interface to bind (eg: eth0), or UNIX domain socket path (eg: /var/run/ttyd.sock)
-c, --credential Credential for Basic Authentication (format: username:password)
-u, --uid User id to run with
-g, --gid Group id to run with
-s, --signal Signal to send to the command when exit it (default: 1, SIGHUP)
-a, --url-arg Allow client to send command line arguments in URL (eg: http://localhost:7681?arg=foo&arg=bar)
-R, --readonly Do not allow clients to write to the TTY
-t, --client-option Send option to client (format: key=value), repeat to add more options
-T, --terminal-type Terminal type to report, default: xterm-256color
-O, --check-origin Do not allow websocket connection from different origin
-m, --max-clients Maximum clients to support (default: 0, no limit)
-o, --once Accept only one client and exit on disconnection
-B, --browser Open terminal with the default system browser
-I, --index Custom index.html path
-b, --base-path Expected base path for requests coming from a reverse proxy (eg: /mounted/here)
-P, --ping-interval Websocket ping interval(sec) (default: 300)
-6, --ipv6 Enable IPv6 support
-S, --ssl Enable SSL
-C, --ssl-cert SSL certificate file path
-K, --ssl-key SSL key file path
-A, --ssl-ca SSL CA file path for client certificate verification
-d, --debug Set log level (default: 7)
-v, --version Print the version and exit
-h, --help Print this text and exit

Visit https://github.com/tsl0922/ttyd to get more information and report bugs.

可以看到,这里可以使用 -p 来指定运行端口,使用 -c 指定登录密码等等。

基本使用

我们来试下,最基本的命令如下:

1
ttyd bash

这样就使用启动了一个 Web 版的 bash,运行结果如下:

这里显示是在 7681 上运行的,那我们就可以打开 http://localhost:7681/,就可以直接运行命令了:

非常丝滑。

看了下背后的传输协议是 WebSocket,所以稳定性还是有保障的:

当然,我们也可以不用 bash,用自己喜欢的 Shell,比如 zsh,命令如下:

1
ttyd zsh

这样的话浏览器里面的 Shell 就是 zsh 啦:

绑定端口

当然我们也可以更换端口,比如 8000,则可以使用如下命令:

1
ttyd -p 8000 zsh

这样 ttyd 就可以在 8000 端口运行 HTTP 服务,我们打开 http://localhost:8000/ 就可以执行命令了。

Basic Auth

当然这么直接暴露出去似乎也不太安全,我们可以设置 Basic Auth,使用 -c 这个选项即可指定用户名密码,格式为 username:password,例如我们指定用户名和密码都是 admin,那命令就这么写:

1
ttyd -p 8000 -c admin:admin zsh

这样打开 http://localhost:8000/ 之后就需要输入用户名密码才可以登录了:

自动打开浏览器

我们还可以使用 -B 命令让它自动打开浏览器:

1
ttyd -p 8000 -B zsh

这样运行之后,默认的浏览器就会自动打开 http://localhost:8000/,不用我们再去敲网址了,十分方便。

所以,上面这个命令甚至我们还可以做成一个 alias,比如:

1
alias webcmd="ttyd -p 8000 -B zsh";

这样输入 webcmd 就可以轻松打开一个 Web 版命令行了。

Docker 支持

另外 ttyd 还提供了 Docker 镜像,如果你不想安装的话,可以直接启 Docker,比如这样的话就可以在 7681 上启动:

1
docker run -it --rm -p 7681:7681 tsl0922/ttyd

但这实际上是把容器内部的命令行暴露出来了,如果要暴露宿主机的命令行还需要 mount 下磁盘:

SSH 终端

ttyd 还支持 SSH 终端,命令如下:

1
ttyd login

这样的话,打开浏览器之后就需要 SSH 登录,输入正确的 SSH 用户名和密码后才能使用。

SSL 支持

如果你想配置 SSL 支持,即支持 HTTPS 的话,可以自己生成证书并添加对应的参数来启动 ttyd,参考链接是:https://github.com/tsl0922/ttyd/wiki/SSL-Usage

更多

上面的用法基本能满足日常需要了,如果想要了解更多用法,可以参考其 Wiki,链接是:https://github.com/tsl0922/ttyd/wiki/Example-Usage

公网暴露

当然,我们如果想把它公网暴露出来,还可以配合 Ngrok,比如 ttyd 运行在 8000 端口上,我可以使用 Ngrok 将其暴露出来:

1
ngrok http 8000

运行结果如下:

这样我就可以通过指定的 URL 访问这个终端了,比如这里我就可以使用 https://11b4-2404-f801-8050-3-bf-00-55.ngrok.io/ 来访问我的终端了:

非常 Nice!

总结

好了,以上就是 ttyd 的基本使用了,有了它,我们就可以轻松将某台机器上的终端转到 Web 上来执行了,还是非常方便有用的。

更多精彩内容,请关注我的公众号「进击的 Coder」和「崔庆才丨静觅」。

Python

今天看到一个开源项目,叫做 Command2API,感觉挺有意思的,分享给大家。

起源

关于这个项目为什么诞生,原 Repo 有这么一段:

以近期 Log4j 的 RCE 举例,在内网的安全测试中,由于网络环境限制导致没有 DNSLog 平台可用,这时候做 Log4j 的漏洞验证就考虑直接查看 LDAP 服务是否有连接进来,但是现成的 JNDI 注入工具开启服务并没有 API 可以直接拉取对应服务的结果,这就导致需要人工去查看,很费时间,再加上已经写好 BurpSuite 被动插件进行扫描了,为了节省时间就简单写了这个脚本用于获取 JNDI 工具的执行结果并通过 API 的形式返回,便于插件拉取结果进行漏洞验证。

反正大意就是说,有些命令的执行结果如果能够通过 HTTP 的 API 暴露出来,我们就能更方便地获取到命令的执行结果,在某些场景下会非常方便。

所以,这里作者写了这个项目。

原理

这个原理其实非常简单,就是用一个 Python 线程开启 Web 服务,一个线程执行命令,通过全局变量与 Web 服务共享执行命令的结果。

运行

这里我们来运行下看看效果吧。

首先需要下载下项目:

1
git clone https://github.com/gh0stkey/Command2API.git

然后接着指定想运行的命令和 API 运行的端口就好了,样例如下:

1
python Command2Api.py "执行的命令" Web运行的端口

注意,这里的 python 使用的 Python2,而不是 Python3,因为原项目引用了一个包叫 BaseHTTPServer,Python3 是没有的。

这里我们执行一个 ping 命令来试试:

1
python Command2Api.py "ping www.baidu.com" 8888

运行结果如下:

可以看到,这里首先输出了一个运行的地址:

1
URL: http://HOST:8888/c1IvlLF9

这时候我们打开 http://localhost:8888/c1IvlLF9 看下。

可以看到控制台结果就呈现在网页里面了。

但是这个页面没法自动刷新,需要点击刷新来获取最新的结果。

介绍完了。

所以,这个项目在某些情况下还是挺有用的。

比如说:

  • 内网安全测试中,可以用于获取 JNDI 工具的执行结果并通过 API 的形式返回,可以更方便地观测执行结果。
  • 我们想监控或实时获取某个命令行程序的输出结果,比如 Scrapy 爬虫、比如 Web Server 等等,可以将其暴露出来。
  • 我们想快速分享某个程序的执行结果,则可以通过这个命令配合 Ngrok 生成一个网站分享出去。

等等。

源码解析

我们再来看看源码吧,其实非常简单,一共就这些代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import subprocess
import BaseHTTPServer
import SimpleHTTPServer
import cgi
import threading
import sys
import string
import random

l = []

uri = '/' + ''.join(random.sample(string.ascii_letters+string.digits,8))

class thread(threading.Thread):
def __init__(self, threadname, command):
threading.Thread.__init__(self, name='Thread_' + threadname)
self.threadname = int(threadname)
self.command = command

def run(self):
global l
ret = subprocess.Popen(
self.command,
shell=True,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
for i in iter(ret.stdout.readline, b""):
res = i.decode().strip()
print(res)
l.append(res)

class ServerHandler(SimpleHTTPServer.SimpleHTTPRequestHandler):
def do_GET(self):
global l
if self.path == uri:
self.send_response(200)
self.send_header('Content-Type', 'text/plain')
self.end_headers()
self.wfile.write(l)

if __name__ == '__main__':
# New Thread: Get Command Result
t1 = thread('1', sys.argv[1])
t1.start()
# Webserver
port = int(sys.argv[2])
print("URL: http://HOST:{0}{1}".format(port, uri))
Handler = ServerHandler
httpd = BaseHTTPServer.HTTPServer(('0.0.0.0', port), Handler)
httpd.serve_forever()

可以看到这个命令就是 Popen 执行的,然后通过 PIPE 将结果捕获出来赋值为变量,然后同时另外一个线程启动服务器,将这个结果写入到 Response 里面。

就是这么简单的代码,实现了如此便捷的功能。

优化

不过我看这个项目还是有很多优化空间的,简单总结下:

  • 现在支持的是 Python2 而不是 Python3。
  • 网页结果不能自动刷新。
  • 网页结果是一个列表,和控制台的结果格式不太统一。
  • 不能通过 pip 来安全这个工具包。
  • 输出结果的 HOST 可以优化一下,直接复制出来不好访问。
  • 可以配合 Ngrok 将结果进行公开暴露。
  • 如果能通过网页来对命令进行交互控制就更好了。

我看看如果有时间的话,我可以试着将这个项目改写下并实现如上的一些优化功能哈,到时候写完了发出来。

谢谢阅读~

更多精彩内容,请关注我的公众号「进击的 Coder」和「崔庆才丨静觅」。

深度学习

本节我们就来了解下使用深度学习识别滑动验证码的方法。

1. 准备工作

我们这次主要侧重于完成利用深度学习模型来识别验证码缺口的过程,所以不会侧重于讲解深度学习模型的算法,另外由于整个模型实现较为复杂,本节也不会从零开始编写代码,而是倾向于把代码提前下载下来进行实操练习。

所以在最后,请提前代码下载下来,仓库地址为:https://github.com/Python3WebSpider/DeepLearningSlideCaptcha2,利用 Git 把它克隆下来:

1
git clone https://github.com/Python3WebSpider/DeepLearningSlideCaptcha2.git

运行完毕之后,本地就会出现一个 DeepLearningImageCaptcha2 的文件夹,就证明克隆成功了。

克隆完毕之后,请切换到 DeepLearningImageCaptcha2 文件夹,安装必要的依赖库:

1
pip3 install -r requirements.txt

运行完毕之后,本项目运行所需要的依赖库就全部安装好了。

以上准备工作都完成之后,那就让我们就开始本节正式的学习吧。

2. 目标检测

识别滑动验证码缺口的这个问题,其实可以归结为目标检测问题。那什么叫目标检测呢?在这里简单作下介绍。

目标检测,顾名思义,就是把我们想找的东西找出来。比如给一张「狗」的图片,如图所示:

image-20191107024841075

我们想知道这只狗在哪,它的舌头在哪,找到了就把它们框选出来,这就是目标检测。

经过目标检测算法处理之后,我们期望得到的图片是这样的:

image-20191107025008947

可以看到这只狗和它的舌头就被框选出来了,这就完成了一个不错的目标检测。

现在比较流行的目标检测算法有 R-CNN、Fast R-CNN、Faster R-CNN、SSD、YOLO 等,感兴趣可以了解一下,当然不太了解对本节要完成的目标也没有什么影响。

当前做目标检测的算法主要有两种方法,有一阶段式和两阶段式,英文叫做 One stage 和 Two stage,简述如下:

  • Two Stage:算法首先生成一系列目标所在位置的候选框,然后再对这些框选出来的结果进行样本分类,即先找出来在哪,然后再分出来是啥,俗话说叫「看两眼」,这种算法有 R-CNN、Fast R-CNN、Faster R-CNN 等,这些算法架构相对复杂,但准确率上有优势。
  • One Stage:不需要产生候选框,直接将目标定位和分类的问题转化为回归问题,俗话说叫「看一眼」,这种算法有 YOLO、SSD,这些算法虽然准确率上不及 Two stage,但架构相对简单,检测速度更快。

所以这次我们选用 One Stage 的有代表性的目标检测算法 YOLO 来实现滑动验证码缺口的识别。

YOLO,英文全称叫做 You Only Look Once,取了它们的首字母就构成了算法名,

目前 YOLO 算法最新的版本是 V5 版本,应用比较广泛的是 V3 版本,这里算法的具体流程我们就不过多介绍了,感兴趣的可以搜一下相关资料了解下,另外也可以了解下 YOLO V1-V3 版本的不同和改进之处,这里列几个参考链接:

3. 数据准备

像上一节介绍的一样,要训练深度学习模型也需要准备训练数据,数据也是分为两部分,一部分是验证码图像,另一部分是数据标注,即缺口的位置。但和上一节不一样的是,这次标注不再是单纯的验证码文本了,因为这次我们需要表示的是缺口的位置,缺口对应的是一个矩形框,要表示一个矩形框,至少需要四个数据,如左上角点的横纵坐标 x、y,矩形的宽高 w、h,所以标注数据就变成了四个数字。

所以,接下来我们就需要准备一些验证码图片和对应的四位数字的标注了,比如下图的滑动验证码:

好,那接下来我们就完成这两步吧,第一步就是收集验证码图片,第二步就是标注缺口的位置并转为我们想要的四位数字。

在这里我们的示例网站是 https://captcha1.scrape.center/,打开之后点击登录按钮便会弹出一个滑动验证码,如图所示:

image-20210504182925384

我们需要做的就是单独将滑动验证码的图像保存下来,也就是这个区域:

image-20210504183039997

怎么做呢?靠手工截图肯定不太可靠,费时费力,而且不好准确定位边界,会导致存下来的图片有大有小。为了解决这个问题,我们可以简单写一个脚本来实现下自动化裁切和保存,就是仓库中的 collect.py 文件,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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
from selenium.common.exceptions import WebDriverException
import time
from loguru import logger

COUNT = 1000

for i in range(1, COUNT + 1):
try:
browser = webdriver.Chrome()
wait = WebDriverWait(browser, 10)
browser.get('https://captcha1.scrape.center/')
button = wait.until(EC.element_to_be_clickable(
(By.CSS_SELECTOR, '.el-button')))
button.click()
captcha = wait.until(
EC.presence_of_element_located((By.CSS_SELECTOR, '.geetest_slicebg.geetest_absolute')))
time.sleep(5)
captcha.screenshot(f'data/captcha/images/captcha_{i}.png')
except WebDriverException as e:
logger.error(f'webdriver error occurred {e.msg}')
finally:
browser.close()

在这里我们先定义了一个循环,循环次数为 COUNT 次,每次循环都使用 Selenium 启动一个浏览器,然后打开目标网站,模拟点击登录按钮触发验证码弹出,然后截取验证码对应的节点,再用 screenshot 方法将其保存下来。

我们将其运行:

1
python3 collect.py

运行完了之后我们就可以在 data/captcha/images/ 目录获得很多验证码图片了,样例如图所示:

image-20210504194022826

获得验证码图片之后,我们就需要进行数据标注了,这里推荐的工具是 labelImg,GitHub 地址为 https://github.com/tzutalin/labelImg,使用 pip3 安装即可:

1
pip3 install labelImg

安装完成之后可以直接命令行运行:

1
labelImg

这样就成功启动了 labelImg:

image-20210504194644729

点击 Open Dir 打开 data/captcha/images/ 目录,然后点击左下角的 Create RectBox 创建一个标注框,我们可以将缺口所在的矩形框框选出来,框选完毕之后 labelImg 就会提示保存一个名称,我们将其命名为 target,然后点击 OK,如图所示:

image-20210504194608969

这时候我们可以发现其保存了一个 xml 文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<annotation>
<folder>images</folder>
<filename>captcha_0.png</filename>
<path>data/captcha/images/captcha_0.png</path>
<source>
<database>Unknown</database>
</source>
<size>
<width>520</width>
<height>320</height>
<depth>3</depth>
</size>
<segmented>0</segmented>
<object>
<name>target</name>
<pose>Unspecified</pose>
<truncated>0</truncated>
<difficult>0</difficult>
<bndbox>
<xmin>321</xmin>
<ymin>87</ymin>
<xmax>407</xmax>
<ymax>167</ymax>
</bndbox>
</object>
</annotation>

其中可以看到 size 节点里有三个节点,分别是 width、height、depth,分别代表原验证码图片的宽度、高度、通道数。另外 object 节点下的 bndbox 节点就包含了标注缺口的位置,通过观察对比可以知道 xmin、ymin 指的就是左上角的坐标,xmax、ymax 指的就是右下角的坐标。

我们可以用下面的方法简单进行下数据处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import xmltodict
import json

def parse_xml(file):
xml_str = open(file, encoding='utf-8').read()
data = xmltodict.parse(xml_str)
data = json.loads(json.dumps(data))
annoatation = data.get('annotation')
width = int(annoatation.get('size').get('width'))
height = int(annoatation.get('size').get('height'))
bndbox = annoatation.get('object').get('bndbox')
box_xmin = int(bndbox.get('xmin'))
box_xmax = int(bndbox.get('xmax'))
box_ymin = int(bndbox.get('ymin'))
box_ymax = int(bndbox.get('ymax'))
box_width = (box_xmax - box_xmin) / width
box_height = (box_ymax - box_ymin) / height
return box_xmin / width, box_ymin / height, box_width / width, box_height / height

这里我们定义了一个 parse_xml 方法,这个方法首先读取了 xml 文件,然后使用 xmltodict 库就可以将 XML 字符串转为 JSON,然后依次读取出验证码的宽高信息,缺口的位置信息,最后返回了想要的数据格式—— 缺口左上角的坐标和宽高相对值,以元组的形式返回。

都标注完成之后,对每个 xml 文件调用此方法便可以生成想要的标注结果了。

在这里,我已经将对应的标注结果都处理好了,可以直接使用,路径为 data/captcha/labels,如图所示:

image-20210504200730482

每个 txt 文件对应一张验证码图的标注结果,内容类似如下:

1
0 0.6153846153846154 0.275 0.16596774 0.24170968

第一位 0 代表标注目标的索引,由于我们只需要检测一个缺口,所以索引就是 0;第 2、3 位代表缺口的左上角的位置,比如 0.615 则代表缺口左上角的横坐标在相对验证码的 61.5% 处,乘以验证码的宽度 520,结果大约就是 320,即左上角偏移值是 320 像素;第 4、5 代表缺口的宽高相对验证码图片的占比,比如第 5 位 0.24 乘以验证码的高度 320,结果大约是 77,即缺口的高度大约为 77 像素。

好了,到此为止数据准备阶段就完成了。

4. 训练

为了更好的训练效果,我们还需要下载一些预训练模型。预训练的意思就是已经有一个提前训练过的基础模型了,我们可以直接使用提前训练好的模型里面的权重文件,我们就不用从零开始训练了,只需要基于之前的模型进行微调就好了,这样既可以节省训练时间,又可以有比较好的效果。

YOLOV3 的训练要加载预训练模型才能有不错的训练效果,预训练模型下载命令如下:

1
bash prepare.sh

注意:在 Windows 下请使用 Bash 命令行工具如 Git Bash 来运行此命令。

执行这个脚本,就能下载 YOLO V3 模型的一些权重文件,包括 yolov3 和 weights 还有 darknet 的 weights,在训练之前我们需要用这些权重文件初始化 YOLO V3 模型。

接下来就可以开始训练了,执行如下脚本:

1
bash train.sh

注意:在 Windows 下请同样使用 Bash 命令行工具如 Git Bash 来运行此命令。

同样推荐使用 GPU 进行训练,训练过程中我们可以使用 TensorBoard 来看看 loss 和 mAP 的变化,运行 TensorBoard:

1
tensorboard --logdir='logs' --port=6006 --host 0.0.0.0

注意:请确保已经正确安装了本项目的所有依赖库,其中就包括 TensorBoard,安装成功之后便可以使用 tensorboard 命令。

运行此命令后可以在 http://localhost:6006 观察到训练过程中的 loss 变化。

loss_1 变化类似如下:

loss 变化

val_mAP 变化类似如下:

mAP 变化

可以看到 loss 从最初的非常高下降到了很低,准确率也逐渐接近 100%。

这是训练过程中的命令行的一些输出结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
---- [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 等,分别代表训练过程的损失(越小越好)、召回率(能识别出的结果占应该识别出结果的比例,越高越好)、精确率(识别出的结果中正确的比率,越高越好)、置信度(模型有把握识别对的概率,越高越好),可以作为参考。

5. 测试

训练完毕之后会在 checkpoints 文件夹生成 pth 文件,这就是一些模型文件,和上一节的 best_model.pkl 是一样的原理,只不过表示形式略有不同,我们可直接使用这些模型来预测生成标注结果。

要运行测试,我们可以先在测试文件夹 data/captcha/test 放入一些验证码图片:

样例验证码如下:

captcha_435

要运行测试,执行如下脚本:

1
bash detect.sh

该脚本会读取测试文件夹所有图片,并将处理后的结果输出到 data/captcha/result 文件夹,控制台输出了一些验证码的识别结果。

同时在 data/captcha/result 生成了标注的结果,样例如下:

可以看到,缺口就被准确识别出来了。

实际上,detect.sh 是执行了 detect.py 文件,在代码中有一个关键的输出结果如下:

1
2
bbox = patches.Rectangle((x1 + box_w / 2, y1 + box_h / 2), box_w, box_h, linewidth=2, edgecolor=color, facecolor="none")
print('bbox', (x1, y1, box_w, box_h), 'offset', x1)

这里 bbox 指的就是最终缺口的轮廓位置,同时 x1 就是指的轮廓最左侧距离整个验证码最左侧的横向偏移量,即 offset。通过这两个信息,我们就能得到缺口的关键位置了。

有了目标滑块位置之后,我们便可以进行一些模拟滑动操作从而实现通过验证码的检测了。

6. 总结

本节主要介绍了训练深度学习模型来识别滑动验证码缺口的整体流程,最终我们成功实现了模型训练过程,并得到了一个深度学习模型文件。

利用这个模型,我们可以输入一张滑动验证码,模型便会预测出其中的缺口的位置,包括偏移量、宽度等,最后可以通过缺口的信息绘制出对应的位置。

和上一节一样,本节介绍的内容也可以进一步优化:

  • 当前模型的预测过程是通过命令行执行的,但在实际使用的时候可能并不太方便,可以考虑将预测过程对接 API 服务器暴露出来,比如对接 Flask、Django、FastAPI 等把预测过程实现为一个支持 POST 请求的接口,接口可以接收一张验证码图片,返回验证码的文本信息,这样会使得模型更加方便易用。

本节代码:https://github.com/Python3WebSpider/DeepLearningSlideCaptcha2。

个人随笔

之前跟一位朋友聊天,他说了有一点让我触动很大,这里专门来写写。

就我个人来讲,我在我的这个年纪一直在尝试各种各样的事情,除了工作之外,我还尝试了多种副业,比如写公众号、写书、录课、做视频、写各种开源项目等等,其实整体说来,还是比较“杂”的。

他给了我一个建议,叫做“想想做事的终局”,我感觉挺有道理的。

下面是我的一些思考。

其实年轻多尝试没有什么坏事,比如在三十岁之前,我们可能并不清楚我们真正擅长的或喜欢的到底是什么,所以就得多尝试不同的方向,探索一些未知的领域。找到真正喜欢或者适合的事情之后,后面的几十年可以潜心放在这上面,深入去发展它。

当然很多时候,我们可能并不知道我们在做的这件事以后究竟会是怎样的,这里面有两个方面:

  • 验证这件事究竟合不合适、能不能做
  • 思考这件事以后到底能做到什么地步

这里针对这两点分别说下。

首先就是验证这件事究竟合不合适、能不能做。我们比较好的办法就是进行快速试错,比如一个项目,我们做一些 POC 来验证可行性,或者前期调研的时候通过调查问卷了解行情,这些都是十分重要的。另外我们也不能想当然,比如做一件事情之前,凭空一想就把自己否定了,那就会少掉很多机会。该行动还是要行动的,但前期也要尽量控制一些成本。

另外一个重要的就是想想这件事到底能做到什么地步,也就是终局在哪里。比如说,一件事,它本身的可扩展性、可复用性就很小,这个就得好好考虑下要不要投入大的成本一直做下去。如果一件事投入很多的精力,以后可做的事情非常多,或者能做的很大很大,甚至无限大,这种事情就值得多去投入些。比如说,我做一个开源项目,投入大量精力,最后的用户可能就是很有限的,而且场景也很有限,那这个以后就做不大。如果我写作、课程,如果我能把我学习和进步的都输出出来,不断积累,那以后的扩展的面就会有很多很多,这种相对来说就能做大。这就是一些事的终局。所以,平时我们也需要考虑下这件事的终局在哪里。

嗯,总之,找到适合做、喜欢做的事,同时这件事的终局也够大,那就是非常难得的,持续努力,结果会回报我们的。

更多精彩内容,请关注我的公众号「进击的 Coder」和「崔庆才丨静觅」。

安装配置

Pika 是 Python 中用于连接和操作 RabbitMQ 的库,本节我们了解下 Pika 的安装方式。

相关链接

先置条件

安装之前请先安装下 RabbitMQ,安装参考说明:https://setup.scrape.center/rabbitmq

安装方法

pip 安装

推荐使用 pip3 安装,命令如下:

1
pip3 install pika

命令执行完毕之后即可完成安装。

验证安装

安装完成之后,可以在 Python 命令行下测试。

1
2
$ python3
>>> import pika

如果没有错误报出,则证明库已经安装好了。

安装配置

RabbmitMQ 是一个非常强大易用的消息队列,支持 Windows、Mac、Linux 等各个平台。

每个平台都有多种安装方式,这里进行简单归纳总结,具体的安装方式请移步官方文档说明。

Windows 上的安装

Windows 主要有两种安装方式,一种是通过 Chocolatey 工具安装,另一种是通过官方安装包安装。

详细说明请移步:https://rabbitmq.com/install-windows.html 查看。

Chocolatey

Chocolatey 是一个 Windows 上管理软件的工具,RabbitMQ 发布了很多 Chocolatey 安装包,具体说明可以参考:https://rabbitmq.com/install-windows.html#chocolatey

官方安装包

就是下载一个二进制安装文件,具体的最新下载地址请移步 https://rabbitmq.com/install-windows.html#installer 查看。

Linux 上的安装

Linux 上的安装方式又分了多种了,根据发行平台的不同有如下安装说明。

Debian 和 Ubuntu

请移步:https://rabbitmq.com/install-debian.html 查看。

RedHat Enterprise Linux, CentOS, Fedora, openSUSE

请移步:https://rabbitmq.com/install-rpm.html 查看。

Mac

Mac 就是主要用 Homebrew 来安装了,具体安装说明请移步:https://rabbitmq.com/install-homebrew.html

更多

更多平台的安装方式请移步:https://rabbitmq.com/download.html 查看。

安装配置

Selenium 是一个自动化测试工具,利用它我们可以驱动浏览器执行特定的动作,如点击、下拉等等操作,对于一些 JavaScript 渲染的页面来说,此种抓取方式非常有效,下面我们来看下 Selenium 的安装过程。

相关链接

ChromeDriver 安装

通常来说,我们需要配合 Chrome 浏览器使用 Selenium,所以我们还需要额外安装下 ChromeDriver 和 Chrome 浏览器。

安装参考:https://setup.scrape.center/chromedriver

安装

pip 安装

推荐直接使用 pip3 安装,执行如下命令即可:

1
pip3 install selenium

wheel 安装

除了 pip 安装,也可以到 PyPi 下载对应的 wheel 文件进行安装,https://pypi.python.org/pypi/selenium/#downloads, 如假设最新版本为 3.4.3,则下载 selenium-3.4.3-py2.py3-none-any.whl。

然后进入 wheel 文件目录,使用 pip 安装。

1
pip3 install selenium-3.4.3-py2.py3-none-any.whl

验证安装

进入 Python 命令行交互模式,导入一下 Selenium 包,如果没有报错,则证明安装成功。

1
2
$ python3
>>> import selenium

当然也可以运行一个脚本:

1
2
3
4
5
6
7
from selenium import webdriver
from time import sleep

browser = webdriver.Chrome()
browser.get('https://www.baidu.com')
sleep(2)
browser.close()

如果运行完毕之后弹出来了一个 Chrome 浏览器并加载了百度页面,2 秒之后就关闭了,那就证明没问题了。

Python

大家好!我是崔庆才。

今天告诉大家一个好消息:《Python3 网络爬虫开发实战(第二版)》上架了!!!!

没错,就是这本:

2018 年 5 月我的《Python3 网络爬虫开发实战》的第一版出版了,从上市到现在三年多销量约 10w 册,真的非常感谢各位读者的支持。后来,由于一些技术更迭,我开始策划编写本书的第二版。

2021 年 11 月,这本书历经各种反复修改、审稿等阶段,到今天终于上架了!

这几个月我收到了太多读者的询问,第二版什么时候出来,真的抱歉实在是让大家久等了。

没错,就是今天,它来了!

第二版更新内容

大家第一个问题可能就会问,第二版比第一版  更新了哪些内容?

因为技术总是在不断发展和进步的,爬虫技术也是一样,它在爬虫和反爬虫不断斗争的过程中也在不断演进。比如现在越来越多的网页采取了各种防护措施,比如前端代码的压缩和混淆、API 的参数加密、WebDriver 的检测,要做到高效的数据爬取,我们就需要懂得一些 JavaScript 逆向分析相关技术。App 也是一样,App 的抓包防护、加壳保护、Native 化、风控检测使得越来越多的 App 数据难以爬取,所以我们也不得不了解一些逆向相关技术,如 Xposed、Frida、IDA Pro 等工具的使用。除此之外,近几年深度学习和人工智能发展得也是如火如荼,所以爬虫也可以和人工智能结合起来,比如基于深度学习的验证码识别、网页内容的智能化解析和提取等技术我们也可以进行学习和了解。另外,一些大规模爬虫的管理和运维技术也在不断发展,当前 Kubernetes、Docker、Prometheus 等云原生技术也非常火爆,基于 Kubernetes 等云原生技术的爬虫管理和运维解决方案也已经很受青睐。然而,之前第一版书对以上提到的这些新兴技术几乎没有提及。

除此之外,第一版书在讲解数据爬取的过程中引用了很多案例和服务,比如猫眼电影网站、淘宝网站、代理服务网站,然而几年过去了,有些案例网站和服务早已经改版或者停止维护,这就导致第一版书中的很多案例已经不能正常运行了。这其实是一个很大的问题,因为程序运行不通会大大降低学习的积极性和成就感,而且会浪费不少时间。另外,即使案例对应的爬虫代码及时更新了,那我们也不知道这些案例网站和服务什么时候会再次改版,因为这都是不可控的。所以,为了彻底解决这个问题,我花费了近半年的时间构建了一个爬虫案例平台(https://scrape.center),平台包含了几十个爬虫案例,包括服务端渲染(SSR)网站、单页面应用(SPA)网站、各类反爬网站、验证码网站、模拟登录网站、各类 App 等,覆盖了现在爬虫和反爬虫相关的大多数技术,整个平台都是我来维护的,书中几乎所有案例都是从案例平台来的,从而解决了页面改版的问题。

所以,本书相比第一版来说,更新的内容主要如下:

  • 绝大多数都迁移到了自建的案例平台,以后再也不用担心案例有过期或改版问题。

  • 替换了原本第一章环境安装的章节,将环境配置的部分全部汇总并迁移到案例平台(https://setup.scrape.center)并在书中以外链的形式附上,以确保环境的配置和安装说明能够被及时更新。

  • 增加了一些新的请求库、解析库、存储库等的介绍,如 httpx、parsel、Elasticsearch 等库的介绍。

  • 增加了异步爬虫的介绍,如协程的基本原理、aiohttp 的使用和爬取实战介绍。

  • 增加了一些新兴自动化工具的介绍,如 Pyppeteer、Playwright 的介绍。

  • 增加了深度学习相关内容,如图形验证码、滑动验证码的识别方案。

  • 丰富了模拟登录章节的内容,如增加了 JWT 模拟登录的介绍和实战、大规模账号池的优化。

  • 增加了 JavaScript 逆向的章节,包括网站加密和混淆技术、JavaScript 逆向调试技巧、JavaScript 的各种模拟执行方式、AST 还原混淆代码、WebAssembly 等相关技术的介绍。

  • 丰富了 App 自动化爬取技术的章节,如新兴框架 Airtest 的介绍、手机群控和云手机技术的介绍。

  • 增加了 Android 逆向章节,如反编译、反汇编、Hook、脱壳、so 文件分析和模拟执行等技术的介绍。

  • 增加了网页智能化解析章节,包括列表页、详情页内容提取算法和分类算法。

  • 丰富了 Scrapy 相关章节的介绍,如 Pyppeteer 的对接、RabbitMQ 的对接、Prometheus 的对接等。

  • 增加了基于 Kubernetes、Docker、Prometheus、Grafana 等云原生技术爬虫管理和运维解决方案的介绍。

以上就是第二版的主要更新内容。

章节介绍

为了让大家更直接地了解到全书的内容,这里就直接放目录了:

没错!全书一共 900 多页,量了下有 4.3 厘米厚,定价是 139.8 元。

可以直接看第二版吗?

当然,有朋友也会担心,我需不需要先学习第一版,然后才能学第二版呢?

答案是:可以直接学第二版,第二版书爬虫的内容知识体系是完整的,一些旧的技术已经在第一版中移除,第二版的书籍是对所有爬虫知识体系的全新升级。

没有基础可以学吗?

有朋友也可能会问,没有爬虫或者 Python 基础可以学吗?

答案是:可以,本书就是专为零爬虫基础的朋友准备的,本书从最基础的环境配置、基础知识的讲解开始,循序渐进地对爬虫的各个知识点进行介绍,所以完全不用担心没有爬虫基础学不会的问题。如果没有 Python 基础,那也没关系(当然有会更好),书中也会提及 Python 环境的配置并附上一些 Python 入门学习资料(链接),同时也会通过各个 Python 代码片段来进行讲解,很多案例也很简单易懂,学爬虫的时候 Python 也就会逐渐掌握了。

大咖推荐

这本书同时还获得了 Python 之父的推荐(没错就是 Python 的创始人,Guido van Rossum)。另外我还有幸获得了微软亚洲互联网工程院副院长曾文峰、知名爬虫专家梁斌 penny、中国人民大学高瓴人工智能学院长聘副教授宋睿华的推荐。

下面是推荐语的内容:

宣传彩页

另外编辑还为本书制作了几张宣传彩页,是对整本书的一个宣传介绍,大家可以看下:

有没有电子版?

看到这里,大家可能也会问了,有没有电子版呢?可能有的朋友习惯用电子版书籍来学习,有的朋友可能在海外也不方便购买,所以想要电子版。

但还是很遗憾地说:没有电子版。

因为你知道的,如果出了电子版,那么马上就会有各种盗版袭来,网上也会造成各种恶意传播。

所以,为了保护版权,这本书是没有上电子版的。

购买链接

是的,最后就是大家最关心的部分了,到哪里能够买到呢?

上架之前,我与编辑经过各种沟通,原本是想给广大读者和粉丝们有个专属优惠的,但是这个比较难操作,所以最终决定,整本书现在全网统一 7 折销售了!

也就是说,原价 139.8 元,现在只需要 97.9 元就能买到了。

不过这个也是限时的,7 折优惠只到下周五,也就是 12 月 3 日,之后会恢复 84 折销售,也就是 117 元。

另外还有一个消息,前几天我不是签名了 1000 本书吗?所以,现在这个阶段,卖的全都是签名版,一共 1000 本,卖完即止,先到先得。

如果不想要签名版的朋友可以再等等,等签名版的卖完了就是非签名版的了。

购买链接:https://item.jd.com/13527222.html

为了方便购买,我把这个链接转成了二维码,大家可以直接扫码购买:

谢谢大家支持!

更多精彩内容,请关注我的公众号「进击的 Coder」和「崔庆才丨静觅」。

Python

是的,今天我终于完成了我的新书《Python3 网络爬虫开发实战(第二版)》的签名,一共 1000 本,整个过程感觉着实不容易啊。

签完我就来写这篇文章了,感觉左右胳膊完全不是一个感觉。

1

先说下事情的来龙去脉吧,因为很多读者想要一本签名版的书籍,毕竟有需求就有市场。于是编辑和我商讨之后,让我对其中的一部分进行签名。

其实当时第一版出来的时候也签了一些,那会签的少,我记得只有三百多本。

遥想那会,还是 2018 年,那会第一版的书刚刚印刷出来,那会我是去河北的一个印厂签名的。印厂把新鲜出炉的扉页的那一沓拿给我,我一个个进行签名,我还有当年签名的照片:

我去,我当年这么瘦的吗?

这里简单说下扉页这一沓是怎么回事,有朋友就会好奇问为啥不直接拿给你一张纸,而是一沓纸呢?这是因为印刷的时候是用的一张大纸,比如正反面一张大纸就能覆盖书的二三十页内容,然后印刷完毕之后,这张大纸就会被折叠,最后就得到了这小小的一沓。

所以,每一本书,我只需要在这一小沓纸上签名,最后印厂再拿回去进行锁线、装订那就得到完整的书了。

2

好了,再说回签名的事。

今年和往年的签名流程不一样了。

印厂还是在河北,然而由于疫情原因,北京疫情管控非常严格,进京必须要 2 日之内的核算证明。另外去的话还不能光我一人吧,加上编辑还有同行同事肯定至少也得三个人,就感觉比较麻烦。

于是我们就决定这次不去印厂签名了(所以就没法拍照片了)。

那怎么办呢?

我们就决定让印厂把书的扉页都拿给我,然后我签完了再运回印厂,印厂再进行组装。

于是乎,在周二的时候,我收到了整整两大箱子书扉页。

好家伙,这数量着实不少啊,而且特别重。

另外我住的地方其实是没有电梯的 6 楼,光搬上去就废了好大的劲。

后来,从周二开始,周二、三、四、五,我每天下班之后都签一点,每天两百多本。

差不多下班后要花将近两小时签名吧,我也没仔细计算,可能中间有少许休息时间,所以这几天光签名得花了得有七八小时?

3

然后一直到今晚上,哦不,应该是凌晨,终于是签完了。

感觉手都签麻了。

这是一开始的时候签的:

这是最后的时候签的:

哪个好看?一定是第二张好看对不对?!更加飘逸对不对?(逃

最后晒一下完整成果吧,整整两箱子,都签好啦!

我联系了印厂,印厂周六会有师傅过来我家取。

等师傅取回印厂之后,印厂就会安排锁线、装订、裁切等各个环节,最后完整的书就有啦。

4

然后到这里,就会有朋友问了,现在书到底啥时候能买到啊?

其实非签名版的书已经印刷好啦,前天的时候编辑已经拿到了样书,今天还有给书的专门拍摄,这里给大家看看样子哈。

这是编辑发我的图,我其实还没拿到实体书。

然后我就问编辑,这书厚不厚啊?

编辑说,厚!

然后给我发了一张测量图:

好家伙,我直接好家伙,4.3 cm 厚,大家感受一下吧。

加上用的纸还算比较好的,好像说有两三斤重?

emm,具体到手之后再体验下吧。

印刷完了之后,接下来就是入库销售了。

然后具体的售卖时间,编辑说周末或下周一会知道入库时间是什么时候,但我预估下周应该大家就可以开始购买啦。

签名版的话可能会先预售,就是发货较晚的意思,因为我刚刚签完,组装还需要几天时间,到时候应该会在京东上直接销售签名版。

售价的话,应该会打 7 折,原价 139.8,所以打完折是 98 元。

嗯嗯!总之,大家马上就可以买到啦,实在让大家久等了,到时候我会在公号第一时间发布最新消息,非常感谢大家的支持!

撒花!

更多精彩内容,请关注我的公众号「进击的 Coder」和「崔庆才丨静觅」。

爬虫

Playwright 是微软在 2020 年初开源的新一代自动化测试工具,它的功能类似于 Selenium、Pyppeteer 等,都可以驱动浏览器进行各种自动化操作。它的功能也非常强大,对市面上的主流浏览器都提供了支持,API 功能简洁又强大。虽然诞生比较晚,但是现在发展得非常火热。

1. Playwright 的特点

  • Playwright 支持当前所有主流浏览器,包括 Chrome 和 Edge(基于 Chromium)、Firefox、Safari(基于 WebKit) ,提供完善的自动化控制的 API。
  • Playwright 支持移动端页面测试,使用设备模拟技术可以使我们在移动 Web 浏览器中测试响应式 Web 应用程序。
  • Playwright 支持所有浏览器的 Headless 模式和非 Headless 模式的测试。
  • Playwright 的安装和配置非常简单,安装过程中会自动安装对应的浏览器和驱动,不需要额外配置 WebDriver 等。
  • Playwright 提供了自动等待相关的 API,当页面加载的时候会自动等待对应的节点加载,大大简化了 API 编写复杂度。

本节我们就来了解下 Playwright 的使用方法。

2. 安装

要使用 Playwright,需要 Python 3.7 版本及以上,请确保 Python 的版本符合要求。

要安装 Playwright,可以直接使用 pip3,命令如下:

1
pip3 install playwright

安装完成之后需要进行一些初始化操作:

1
playwright install

这时候 Playwrigth 会安装 Chromium, Firefox and WebKit 浏览器并配置一些驱动,我们不必关心中间配置的过程,Playwright 会为我们配置好。

具体的安装说明可以参考:https://setup.scrape.center/playwright。

安装完成之后,我们便可以使用 Playwright 启动 Chromium 或 Firefox 或 WebKit 浏览器来进行自动化操作了。

3. 基本使用

Playwright 支持两种编写模式,一种是类似 Pyppetter 一样的异步模式,另一种是像 Selenium 一样的同步模式,我们可以根据实际需要选择使用不同的模式。

我们先来看一个基本同步模式的例子:

1
2
3
4
5
6
7
8
9
10
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
for browser_type in [p.chromium, p.firefox, p.webkit]:
browser = browser_type.launch(headless=False)
page = browser.new_page()
page.goto('https://www.baidu.com')
page.screenshot(path=f'screenshot-{browser_type.name}.png')
print(page.title())
browser.close()

首先我们导入了 sync_playwright 方法,然后直接调用了这个方法,该方法返回的是一个 PlaywrightContextManager 对象,可以理解是一个浏览器上下文管理器,我们将其赋值为变量 p。

接着我们调用了 PlaywrightContextManager 对象的 chromium、firefox、webkit 属性依次创建了一个 Chromium、Firefox 以及 Webkit 浏览器实例,接着用一个 for 循环依次执行了它们的 launch 方法,同时设置了 headless 参数为 False。

注意:如果不设置为 False,默认是无头模式启动浏览器,我们看不到任何窗口。

launch 方法返回的是一个 Browser 对象,我们将其赋值为 browser 变量。然后调用 browser 的 new_page 方法,相当于新建了一个选项卡,返回的是一个 Page 对象,将其赋值为 page,这整个过程其实和 Pyppeteer 非常类似。接着我们就可以调用 page 的一系列 API 来进行各种自动化操作了,比如调用 goto,就是加载某个页面,这里我们访问的是百度的首页。接着我们调用了 page 的 screenshot 方法,参数传一个文件名称,这样截图就会自动保存为该图片名称,这里名称中我们加入了 browser_type 的 name 属性,代表浏览器的类型,结果分别就是 chromium, firefox, webkit。另外我们还调用了 title 方法,该方法会返回页面的标题,即 HTML 中 title 节点中的文字,也就是选项卡上的文字,我们将该结果打印输出到控制台。最后操作完毕,调用 browser 的 close 方法关闭整个浏览器,运行结束。

运行一下,这时候我们可以看到有三个浏览器依次启动并加载了百度这个页面,分别是 Chromium、Firefox 和 Webkit 三个浏览器,页面加载完成之后,生成截图、控制台打印结果就退出了。

这时候当前目录便会生成三个截图文件,都是百度的首页,文件名中都带有了浏览器的名称,如图所示:

控制台运行结果如下:

1
2
3
百度一下,你就知道
百度一下,你就知道
百度一下,你就知道

通过运行结果我们可以发现,我们非常方便地启动了三种浏览器并完成了自动化操作,并通过几个 API 就完成了截图和数据的获取,整个运行速度是非常快的,者就是 Playwright 最最基本的用法。

当然除了同步模式,Playwright 还提供异步的 API,如果我们项目里面使用了 asyncio,那就应该使用异步模式,写法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import asyncio
from playwright.async_api import async_playwright

async def main():
async with async_playwright() as p:
for browser_type in [p.chromium, p.firefox, p.webkit]:
browser = await browser_type.launch()
page = await browser.new_page()
await page.goto('https://www.baidu.com')
await page.screenshot(path=f'screenshot-{browser_type.name}.png')
print(await page.title())
await browser.close()

asyncio.run(main())

可以看到整个写法和同步模式基本类似,导入的时候使用的是 async_playwright 方法,而不再是 sync_playwright 方法。写法上添加了 async/await 关键字的使用,最后的运行效果是一样的。

另外我们注意到,这例子中使用了 with as 语句,with 用于上下文对象的管理,它可以返回一个上下文管理器,也就对应一个 PlaywrightContextManager 对象,无论运行期间是否抛出异常,它能够帮助我们自动分配并且释放 Playwright 的资源。

4. 代码生成

Playwright 还有一个强大的功能,那就是可以录制我们在浏览器中的操作并将代码自动生成出来,有了这个功能,我们甚至都不用写任何一行代码,这个功能可以通过 playwright 命令行调用 codegen 来实现,我们先来看看 codegen 命令都有什么参数,输入如下命令:

1
playwright codegen --help

结果类似如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Usage: npx playwright codegen [options] [url]

open page and generate code for user actions

Options:
-o, --output <file name> saves the generated script to a file
--target <language> language to use, one of javascript, python, python-async, csharp (default: "python")
-b, --browser <browserType> browser to use, one of cr, chromium, ff, firefox, wk, webkit (default: "chromium")
--channel <channel> Chromium distribution channel, "chrome", "chrome-beta", "msedge-dev", etc
--color-scheme <scheme> emulate preferred color scheme, "light" or "dark"
--device <deviceName> emulate device, for example "iPhone 11"
--geolocation <coordinates> specify geolocation coordinates, for example "37.819722,-122.478611"
--load-storage <filename> load context storage state from the file, previously saved with --save-storage
--lang <language> specify language / locale, for example "en-GB"
--proxy-server <proxy> specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"
--save-storage <filename> save context storage state at the end, for later use with --load-storage
--timezone <time zone> time zone to emulate, for example "Europe/Rome"
--timeout <timeout> timeout for Playwright actions in milliseconds (default: "10000")
--user-agent <ua string> specify user agent string
--viewport-size <size> specify browser viewport size in pixels, for example "1280, 720"
-h, --help display help for command

Examples:

$ codegen
$ codegen --target=python
$ codegen -b webkit https://example.com

可以看到这里有几个选项,比如 -o 代表输出的代码文件的名称;—target 代表使用的语言,默认是 python,即会生成同步模式的操作代码,如果传入 python-async 就会生成异步模式的代码;-b 代表的是使用的浏览器,默认是 Chromium,其他还有很多设置,比如 —device 可以模拟使用手机浏览器,比如 iPhone 11,—lang 代表设置浏览器的语言,—timeout 可以设置页面加载超时时间。

好,了解了这些用法,那我们就来尝试启动一个 Firefox 浏览器,然后将操作结果输出到 script.py 文件,命令如下:

1
playwright codegen -o script.py -b firefox

这时候就弹出了一个 Firefox 浏览器,同时右侧会输出一个脚本窗口,实时显示当前操作对应的代码。

我们可以在浏览器中做任何操作,比如打开百度,然后点击输入框并输入 nba,然后再点击搜索按钮,浏览器窗口如下:

可以看见浏览器中还会高亮显示我们正在操作的页面节点,同时还显示了对应的选择器字符串 input[name="wd"],右侧的窗口如图所示:

在操作过程中,该窗口中的代码就实时变化,可以看到这里生成了我们一系列操作的对应代码,比如在搜索框中输入 nba,就对应如下代码:

1
page.fill("input[name=\"wd\"]", "nba")

操作完毕之后,关闭浏览器,Playwright 会生成一个 script.py 文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from playwright.sync_api import sync_playwright

def run(playwright):
browser = playwright.firefox.launch(headless=False)
context = browser.new_context()

# Open new page
page = context.new_page()

# Go to https://www.baidu.com/
page.goto("https://www.baidu.com/")

# Click input[name="wd"]
page.click("input[name=\"wd\"]")

# Fill input[name="wd"]
page.fill("input[name=\"wd\"]", "nba")

# Click text=百度一下
with page.expect_navigation():
page.click("text=百度一下")

context.close()
browser.close()

with sync_playwright() as playwright:
run(playwright)

可以看到这里生成的代码和我们之前写的示例代码几乎差不多,而且也是完全可以运行的,运行之后就可以看到它又可以复现我们刚才所做的操作了。

所以,有了这个功能,我们甚至都不用编写任何代码,只通过简单的可视化点击就能把代码生成出来,可谓是非常方便了!

另外这里有一个值得注意的点,仔细观察下生成的代码,和前面的例子不同的是,这里 new_page 方法并不是直接通过 browser 调用的,而是通过 context 变量调用的,这个 context 又是由 browser 通过调用 new_context 方法生成的。有读者可能就会问了,这个 context 究竟是做什么的呢?

其实这个 context 变量对应的是一个 BrowserContext 对象,BrowserContext 是一个类似隐身模式的独立上下文环境,其运行资源是单独隔离的,在做一些自动化测试过程中,每个测试用例我们都可以单独创建一个 BrowserContext 对象,这样可以保证每个测试用例之间互不干扰,具体的 API 可以参考 https://playwright.dev/python/docs/api/class-browsercontext

5. 移动端浏览器支持

Playwright 另外一个特色功能就是可以支持移动端浏览器的模拟,比如模拟打开 iPhone 12 Pro Max 上的 Safari 浏览器,然后手动设置定位,并打开百度地图并截图。首先我们可以选定一个经纬度,比如故宫的经纬度是 39.913904, 116.39014,我们可以通过 geolocation 参数传递给 Webkit 浏览器并初始化。

示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
iphone_12_pro_max = p.devices['iPhone 12 Pro Max']
browser = p.webkit.launch(headless=False)
context = browser.new_context(
**iphone_12_pro_max,
locale='zh-CN',
geolocation={'longitude': 116.39014, 'latitude': 39.913904},
permissions=['geolocation']
)
page = context.new_page()
page.goto('https://amap.com')
page.wait_for_load_state(state='networkidle')
page.screenshot(path='location-iphone.png')
browser.close()

这里我们先用 PlaywrightContextManager 对象的 devices 属性指定了一台移动设备,这里传入的是手机的型号,比如 iPhone 12 Pro Max,当然也可以传其他名称,比如 iPhone 8,Pixel 2 等。

前面我们已经了解了 BrowserContext 对象,BrowserContext 对象也可以用来模拟移动端浏览器,初始化一些移动设备信息、语言、权限、位置等信息,这里我们就用它来创建了一个移动端 BrowserContext 对象,通过 geolocation 参数传入了经纬度信息,通过 permissions 参数传入了赋予的权限信息,最后将得到的 BrowserContext 对象赋值为 context 变量。

接着我们就可以用 BrowserContext 对象来新建一个页面,还是调用 new_page 方法创建一个新的选项卡,然后跳转到高德地图,并调用了 wait_for_load_state 方法等待页面某个状态完成,这里我们传入的 state 是 networkidle,也就是网络空闲状态。因为在页面初始化和加载过程中,肯定是伴随有网络请求的,所以加载过程中肯定不算 networkidle 状态,所以这里我们传入 networkidle 就可以标识当前页面和数据加载完成的状态。加载完成之后,我们再调用 screenshot 方法获取当前页面截图,最后关闭浏览器。

运行下代码,可以发现这里就弹出了一个移动版浏览器,然后加载了高德地图,并定位到了故宫的位置,如图所示:

输出的截图也是浏览器中显示的结果。

所以这样我们就成功实现了移动端浏览器的模拟和一些设置,其操作 API 和 PC 版浏览器是完全一样的。

6. 选择器

前面我们注意到 click 和 fill 等方法都传入了一个字符串,这些字符串有的符合 CSS 选择器的语法,有的又是 text= 开头的,感觉似乎没太有规律的样子,它到底支持怎样的匹配规则呢?下面我们来了解下。

传入的这个字符串,我们可以称之为 Element Selector,它不仅仅支持 CSS 选择器、XPath,Playwright 还扩展了一些方便好用的规则,比如直接根据文本内容筛选,根据节点层级结构筛选等等。

文本选择

文本选择支持直接使用 text= 这样的语法进行筛选,示例如下:

1
page.click("text=Log in")

这就代表选择文本是 Log in 的节点,并点击。

CSS 选择器

CSS 选择器之前也介绍过了,比如根据 id 或者 class 筛选:

1
2
page.click("button")
page.click("#nav-bar .contact-us-item")

根据特定的节点属性筛选:

1
2
page.click("[data-test=login-button]")
page.click("[aria-label='Sign in']")

CSS 选择器 + 文本

我们还可以使用 CSS 选择器结合文本值进行海选,比较常用的就是 has-text 和 text,前者代表包含指定的字符串,后者代表字符串完全匹配,示例如下:

1
2
page.click("article:has-text('Playwright')")
page.click("#nav-bar :text('Contact us')")

第一个就是选择文本中包含 Playwright 的 article 节点,第二个就是选择 id 为 nav-bar 节点中文本值等于 Contact us 的节点。

CSS 选择器 + 节点关系

还可以结合节点关系来筛选节点,比如使用 has 来指定另外一个选择器,示例如下:

1
page.click(".item-description:has(.item-promo-banner)")

比如这里选择的就是选择 class 为 item-description 的节点,且该节点还要包含 class 为 item-promo-banner 的子节点。

另外还有一些相对位置关系,比如 right-of 可以指定位于某个节点右侧的节点,示例如下:

1
page.click("input:right-of(:text('Username'))")

这里选择的就是一个 input 节点,并且该 input 节点要位于文本值为 Username 的节点的右侧。

XPath

当然 XPath 也是支持的,不过 xpath 这个关键字需要我们自行制定,示例如下:

1
page.click("xpath=//button")

这里需要在开头指定 xpath= 字符串,代表后面是一个 XPath 表达式。

关于更多选择器的用法和最佳实践,可以参考官方文档:https://playwright.dev/python/docs/selectors。

7. 常用操作方法

上面我们了解了浏览器的一些初始化设置和基本的操作实例,下面我们再对一些常用的操作 API 进行说明。

常见的一些 API 如点击 click,输入 fill 等操作,这些方法都是属于 Page 对象的,所以所有的方法都从 Page 对象的 API 文档查找就好了,文档地址:https://playwright.dev/python/docs/api/class-page。

下面介绍几个常见的 API 用法。

事件监听

Page 对象提供了一个 on 方法,它可以用来监听页面中发生的各个事件,比如 close、console、load、request、response 等等。

比如这里我们可以监听 response 事件,response 事件可以在每次网络请求得到响应的时候触发,我们可以设置对应的回调方法获取到对应 Response 的全部信息,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
from playwright.sync_api import sync_playwright

def on_response(response):
print(f'Statue {response.status}: {response.url}')

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()
page.on('response', on_response)
page.goto('https://spa6.scrape.center/')
page.wait_for_load_state('networkidle')
browser.close()

这里我们在创建 Page 对象之后,就开始监听 response 事件,同时将回调方法设置为 on_response,on_response 对象接收一个参数,然后把 Response 的状态码和链接都输出出来了。

运行之后,可以看到控制台输出结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Statue 200: https://spa6.scrape.center/
Statue 200: https://spa6.scrape.center/css/app.ea9d802a.css
Statue 200: https://spa6.scrape.center/js/app.5ef0d454.js
Statue 200: https://spa6.scrape.center/js/chunk-vendors.77daf991.js
Statue 200: https://spa6.scrape.center/css/chunk-19c920f8.2a6496e0.css
...
Statue 200: https://spa6.scrape.center/css/chunk-19c920f8.2a6496e0.css
Statue 200: https://spa6.scrape.center/js/chunk-19c920f8.c3a1129d.js
Statue 200: https://spa6.scrape.center/img/logo.a508a8f0.png
Statue 200: https://spa6.scrape.center/fonts/element-icons.535877f5.woff
Statue 301: https://spa6.scrape.center/api/movie?limit=10&offset=0&token=NGMwMzFhNGEzMTFiMzJkOGE0ZTQ1YjUzMTc2OWNiYTI1Yzk0ZDM3MSwxNjIyOTE4NTE5
Statue 200: https://spa6.scrape.center/api/movie/?limit=10&offset=0&token=NGMwMzFhNGEzMTFiMzJkOGE0ZTQ1YjUzMTc2OWNiYTI1Yzk0ZDM3MSwxNjIyOTE4NTE5
Statue 200: https://p0.meituan.net/movie/da64660f82b98cdc1b8a3804e69609e041108.jpg@464w_644h_1e_1c
Statue 200: https://p0.meituan.net/movie/283292171619cdfd5b240c8fd093f1eb255670.jpg@464w_644h_1e_1c
....
Statue 200: https://p1.meituan.net/movie/b607fba7513e7f15eab170aac1e1400d878112.jpg@464w_644h_1e_1c

注意:这里省略了部分重复的内容。

可以看到,这里的输出结果其实正好对应浏览器 Network 面板中所有的请求和响应内容,和下图是一一对应的:

这个网站我们之前分析过,其真实的数据都是 Ajax 加载的,同时 Ajax 请求中还带有加密参数,不好轻易获取。

但有了这个方法,这里如果我们想要截获 Ajax 请求,岂不是就非常容易了?

改写一下判定条件,输出对应的 JSON 结果,改写如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
from playwright.sync_api import sync_playwright

def on_response(response):
if '/api/movie/' in response.url and response.status == 200:
print(response.json())

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()
page.on('response', on_response)
page.goto('https://spa6.scrape.center/')
page.wait_for_load_state('networkidle')
browser.close()

控制台输入如下:

1
2
3
{'count': 100, 'results': [{'id': 1, 'name': '霸王别姬', 'alias': 'Farewell My Concubine', 'cover': 'https://p0.meituan.net/movie/ce4da3e03e655b5b88ed31b5cd7896cf62472.jpg@464w_644h_1e_1c', 'categories': ['剧情', '爱情'], 'published_at': '1993-07-26', 'minute': 171, 'score': 9.5, 'regions': ['中国大陆', '中国香港']},
...
'published_at': None, 'minute': 103, 'score': 9.0, 'regions': ['美国']}, {'id': 10, 'name': '狮子王', 'alias': 'The Lion King', 'cover': 'https://p0.meituan.net/movie/27b76fe6cf3903f3d74963f70786001e1438406.jpg@464w_644h_1e_1c', 'categories': ['动画', '歌舞', '冒险'], 'published_at': '1995-07-15', 'minute': 89, 'score': 9.0, 'regions': ['美国']}]}

简直是得来全不费工夫,我们直接通过这个方法拦截了 Ajax 请求,直接把响应结果拿到了,即使这个 Ajax 请求有加密参数,我们也不用关心,因为我们直接截获了 Ajax 最后响应的结果,这对数据爬取来说实在是太方便了。

另外还有很多其他的事件监听,这里不再一一介绍了,可以查阅官方文档,参考类似的写法实现。

获取页面源码

要获取页面的 HTML 代码其实很简单,我们直接通过 content 方法获取即可,用法如下:

1
2
3
4
5
6
7
8
9
10
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()
page.goto('https://spa6.scrape.center/')
page.wait_for_load_state('networkidle')
html = page.content()
print(html)
browser.close()

运行结果就是页面的 HTML 代码。获取了 HTML 代码之后,我们通过一些解析工具就可以提取想要的信息了。

页面点击

刚才我们通过示例也了解了页面点击的方法,那就是 click,这里详细说一下其使用方法。

页面点击的 API 定义如下:

1
page.click(selector, **kwargs)

这里可以看到必传的参数是 selector,其他的参数都是可选的。第一个 selector 就代表选择器,可以用来匹配想要点击的节点,如果传入的选择器匹配了多个节点,那么只会用第一个节点。

这个方法的内部执行逻辑如下:

  • 根据 selector 找到匹配的节点,如果没有找到,那就一直等待直到超时,超时时间可以由额外的 timeout 参数设置,默认是 30 秒。
  • 等待对该节点的可操作性检查的结果,比如说如果某个按钮设置了不可点击,那它会等待该按钮变成了可点击的时候才去点击,除非通过 force 参数设置跳过可操作性检查步骤强制点击。
  • 如果需要的话,就滚动下页面,将需要被点击的节点呈现出来。
  • 调用 page 对象的 mouse 方法,点击节点中心的位置,如果指定了 position 参数,那就点击指定的位置。

click 方法的一些比较重要的参数如下:

  • click_count:点击次数,默认为 1。
  • timeout:等待要点击的节点的超时时间,默认是 30 秒。
  • position:需要传入一个字典,带有 x 和 y 属性,代表点击位置相对节点左上角的偏移位置。
  • force:即使不可点击,那也强制点击。默认是 False。

具体的 API 设置参数可以参考官方文档:https://playwright.dev/python/docs/api/class-page/#pageclickselector-kwargs。

文本输入

文本输入对应的方法是 fill,API 定义如下:

1
page.fill(selector, value, **kwargs)

这个方法有两个必传参数,第一个参数也是 selector,第二个参数是 value,代表输入的内容,另外还可以通过 timeout 参数指定对应节点的最长等待时间。

获取节点属性

除了对节点进行操作,我们还可以获取节点的属性,方法就是 get_attribute,API 定义如下:

1
page.get_attribute(selector, name, **kwargs)

这个方法有两个必传参数,第一个参数也是 selector,第二个参数是 name,代表要获取的属性名称,另外还可以通过 timeout 参数指定对应节点的最长等待时间。

示例如下:

1
2
3
4
5
6
7
8
9
10
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()
page.goto('https://spa6.scrape.center/')
page.wait_for_load_state('networkidle')
href = page.get_attribute('a.name', 'href')
print(href)
browser.close()

这里我们调用了 get_attribute 方法,传入的 selector 是 a.name,选定了 class 为 name 的 a 节点,然后第二个参数传入了 href,获取超链接的内容,输出结果如下:

1
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx

可以看到对应 href 属性就获取出来了,但这里只有一条结果,因为这里有个条件,那就是如果传入的选择器匹配了多个节点,那么只会用第一个节点。

那怎么获取所有的节点呢?

获取多个节点

获取所有节点可以使用 query_selector_all 方法,它可以返回节点列表,通过遍历获取到单个节点之后,我们可以接着调用单个节点的方法来进行一些操作和属性获取,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()
page.goto('https://spa6.scrape.center/')
page.wait_for_load_state('networkidle')
elements = page.query_selector_all('a.name')
for element in elements:
print(element.get_attribute('href'))
print(element.text_content())
browser.close()

这里我们通过 query_selector_all 方法获取了所有匹配到的节点,每个节点对应的是一个 ElementHandle 对象,然后 ElementHandle 对象也有 get_attribute 方法来获取节点属性,另外还可以通过 text_content 方法获取节点文本。

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx
霸王别姬 - Farewell My Concubine
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIy
这个杀手不太冷 - Léon
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIz
肖申克的救赎 - The Shawshank Redemption
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWI0
泰坦尼克号 - Titanic
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWI1
罗马假日 - Roman Holiday
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWI2
唐伯虎点秋香 - Flirting Scholar
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWI3
乱世佳人 - Gone with the Wind
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWI4
喜剧之王 - The King of Comedy
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWI5
楚门的世界 - The Truman Show
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIxMA==
狮子王 - The Lion King

获取单个节点

获取单个节点也有特定的方法,就是 query_selector,如果传入的选择器匹配到多个节点,那它只会返回第一个节点,示例如下:

1
2
3
4
5
6
7
8
9
10
11
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()
page.goto('https://spa6.scrape.center/')
page.wait_for_load_state('networkidle')
element = page.query_selector('a.name')
print(element.get_attribute('href'))
print(element.text_content())
browser.close()

运行结果如下:

1
2
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx
霸王别姬 - Farewell My Concubine

可以看到这里只输出了第一个匹配节点的信息。

网络劫持

最后再介绍一个实用的方法 route,利用 route 方法,我们可以实现一些网络劫持和修改操作,比如修改 request 的属性,修改 response 响应结果等。

看一个实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from playwright.sync_api import sync_playwright
import re

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()

def cancel_request(route, request):
route.abort()

page.route(re.compile(r"(\.png)|(\.jpg)"), cancel_request)
page.goto("https://spa6.scrape.center/")
page.wait_for_load_state('networkidle')
page.screenshot(path='no_picture.png')
browser.close()

这里我们调用了 route 方法,第一个参数通过正则表达式传入了匹配的 URL 路径,这里代表的是任何包含 .png.jpg 的链接,遇到这样的请求,会回调 cancel_request 方法处理,cancel_request 方法可以接收两个参数,一个是 route,代表一个 CallableRoute 对象,另外一个是 request,代表 Request 对象。这里我们直接调用了 route 的 abort 方法,取消了这次请求,所以最终导致的结果就是图片的加载全部取消了。

观察下运行结果,如图所示:

可以看到图片全都加载失败了。

这个设置有什么用呢?其实是有用的,因为图片资源都是二进制文件,而我们在做爬取过程中可能并不想关心其具体的二进制文件的内容,可能只关心图片的 URL 是什么,所以在浏览器中是否把图片加载出来就不重要了。所以如此设置之后,我们可以提高整个页面的加载速度,提高爬取效率。

另外,利用这个功能,我们还可以将一些响应内容进行修改,比如直接修改 Response 的结果为自定义的文本文件内容。

首先这里定义一个 HTML 文本文件,命名为 custom_response.html,内容如下:

1
2
3
4
5
6
7
8
9
<!DOCTYPE html>
<html>
<head>
<title>Hack Response</title>
</head>
<body>
<h1>Hack Response</h1>
</body>
</html>

代码编写如下:

1
2
3
4
5
6
7
8
9
10
11
12
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()

def modify_response(route, request):
route.fulfill(path="./custom_response.html")

page.route('/', modify_response)
page.goto("https://spa6.scrape.center/")
browser.close()

这里我们使用 route 的 fulfill 方法指定了一个本地文件,就是刚才我们定义的 HTML 文件,运行结果如下:

可以看到,Response 的运行结果就被我们修改了,URL 还是不变的,但是结果已经成了我们修改的 HTML 代码。

所以通过 route 方法,我们可以灵活地控制请求和响应的内容,从而在某些场景下达成某些目的。

8. 总结

本节介绍了 Playwright 的基本用法,其 API 强大又易于使用,同时具备很多 Selenium、Pyppeteer 不具备的更好用的 API,是新一代 JavaScript 渲染页面的爬取利器。

本节代码:https://github.com/Python3WebSpider/PlaywrightTest。

Other

以往各大云服务商在双十一大促都会推出很多活动,例如前几年会有 1核2G1M 69 元这类新用户活动。没想到今年腾讯云活动相当猛,2核CPU+4G内存+8M带宽首年70 块钱!如果按 3 年买,也只是 198 元,要知道平时买这样的服务器,怎么说也要 1000+ 元。

如果你不懂如何操作云服务器,可以观看【社区公开课】视频-工程师必知必会的 Linux 云服务器

活动主会场

除此之外,腾讯云那边联系到社区一起办活动,给了一部分奖励,大家从社区链接购买他们本次活动产品的可以找社区小助手返现金,具体活动规则如下:

1、腾讯云新用户 (个人或企业)购买金额大于 100 元,社区将按购买金额的 5% 进行返现;
2、腾讯云新用户 (个人或企业)购买金额大于 50 、小于 100 元,社区一次性返现 5 元;
3、腾讯云新用户 (个人或企业)购买金额大于 1000 元,社区将按购买金额的 10% 进行返现;
4、腾讯云老用户 (个人或企业)购买金额大于 300 元,社区将按购买金额的 5% 进行返现;

5、如果购买多个产品,请在同一个订单中结算(即一次性购买);

社区专属活动链接(可点击此处跳转也可复制下方地址):

1
https://cloud.tencent.com/act/double11?spread_hash_key=92518f4cf8e4cb326251fe0f7537bc23&cps_key=f31586a82039422aee085aac372348bd

attachmentId-956

活动截止时间:活动截止日期为 2021-11-30 日 12:00:00;

奖励是否叠加:不叠加;

返现发放时间:预计 30 个工作日内;

返现发放方式:个人微信/支付宝打款、企业对公打款;

⚠️ 活动参与要求 :必须是穿甲兵技术社区用户;通过社区提供的链接前往腾讯云双十一活动主会场,购买主会场中推荐的腾讯云产品;活动 COOKIE 持续 30 分钟,点击后如果还在观察活动、未购买的朋友,在购买前请再点击一次社区专属活动链接 ,以免后续在后台查询不到订单信息,也就拿不到返现。

参与活动的朋友,请联系穿甲兵小助手 (微信 elasticworm )计算返现;

社区补贴活动刚开始 2 天,已经有 150 多人参加了,他们都联系了小助手要补贴,火热火热的!错过了这次大促活动,以后买服务器可贵了

参与名单

除了云服务器活动给力之外,新用户注册 .com 域名首年也只需要 1 元!

腾讯云还为大家准备了代金券,简直省到家了

代金券

⚠️注意:社区云产品规则、适用范围等请阅读腾讯云相关文档;

Python

前些天我发起了一个投票,让大家帮忙为 《Python3 网络爬虫开发实战(第二版)》选几个封面,大家也纷纷出谋划策。

其实第二版和第一版整体差别不大,这次设计主要就是换个封面的图片,然后加一个第二版的标识就好了。

之前第一版的封面是这样的:

但这个蜘蛛我觉得还有改进的空间,于是就想换一个。

然后我就自己设计了一个,然后同时设计师也设计了一个,最后出来这么几个版本:

当时就是拿不定主意,然后就发起了一个投票活动,邀请群里的各个小伙伴们来投票。

大家参与也很积极,三百多个人参与了投票,结果是这样的:

当时选项一就是刚才设计的第一张图片,选项二和三就是刚才设计的第二张图片,只是背景元素稍微换了换。

结果大家几乎清一色的都投了选项三。

但其实我对这个选项的蜘蛛不太满意,它长这样:

我就感觉,这怎么跟闹着玩似的?而且腿也太细了吧,也没点过渡和弯折,比较奇怪。但是第一个设计的根据大家的反馈,说蜘蛛的形态比较吓人,还有的人说看起来不像蜘蛛,像章鱼,我也是醉了。

反正上面这俩图我都越想越不满意,总体上还是因为轮廓的原因吧。

然后我就像自己再找一些蜘蛛轮廓,准备再做一个。

怎么做呢?

如果找到好看的轮廓,其实就是生成一个词云图就好了。

我找啊找,又搜到了几个不错的轮廓,接下来就是把这个轮廓生成词云图了。

大家可能会想,这个词云图生成也太麻烦了吧,有对应的关键字,还要符合对应的轮廓,还要同时好看,这搞起来有点难啊,目前市面上我问了下找设计师搞个词云图也得 500 块钱呢。

当然,其实 Python 也有对应的词云图生成库,但功能我觉得还是没有那么精细化。

难道要自己画?

其实不用这么麻烦,我找到了一个网站,叫做微词云,这个还是比较简单好用的,我们只需要输入关键词和每个词的频率,还能自定义想要的轮廓,它就能自动生成词云图了,如图所示:

这就是确定的一个新轮廓,这个蜘蛛是不是看起来就舒服多了?看起来既不吓人,又没那么奇怪。

最后我找了好多小伙伴也看了看最终的效果,大家都觉得这个放在封面上挺不错的。

嗯!最后就是它了!经过几天的修改,最后的效果如下:

同时在第一版封面上还加上了 Python 之父推荐的相关字样,另外「第 2 版」三个字就直接标识到了图书标题的下面。

最后的完整全封如下:

背面是四位专家的推荐语。

定价是 139.8 元,没错,这本书最后 900 多页,139.8 元,其实这个价格在这个页码基础上已经算很合算了。

书现在是什么状态呢?

现在稿子已经送到印检部啦,印前检查没问题就直接印刷啦:

等印刷出来之后我还会去印厂签上千本名,到时候就会有签名版的书搞活动送给大家哈。

请大家耐心等待几天,书很快就可以跟大家见面啦~

更多精彩内容,请关注我的公众号「进击的 Coder」和「崔庆才丨静觅」。