0%

Python

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

前面我们了解了一些 JavaScript 逆向的调试技巧,通过一些方法,我们可以找到一些突破口,进而找到关键的方法定义。

比如说,通过一些调试,我们找到了一个加密参数 token 是由某一个叫做 encrypt 方法产生的,如果里面的逻辑相对简单的话,那其实我们可以用 Python 完全重写一遍。但是现实情况往往不是这样的,一般来说,一些加密相关的方法通常会引用一些相关标准库,比如说 JavaScript 就有一个广泛使用的库,叫做 crypto-js,GitHub 仓库链接是:https://github.com/brix/crypto-js,这个库实现了很多主流的加密算法,包括对称加密、非对称加密、字符编码等等,比如对于 AES 加密,通常我们需要输入待加密文本和加密密钥,实现如下:

1
const ciphertext = CryptoJS.AES.encrypt(message, key).toString();

对于这样的情况,我们其实就没法很轻易地完全重写一遍了,因为 Python 中并不一定有和 JavaScript 完全一样的类库。

那有什么解决办法吗?有的,既然 JavaScript 已经实现好了,那我用 Python 直接模拟执行这些 JavaScript 得到结果不就好了吗?

所以,本节我们就来了解下使用 Python 模拟执行 JavaScript 的解决方案。

1. 案例引入

这里我们先看一个和上文描述的情形非常相似的案例,链接是:https://spa7.scrape.center/,如图所示:

image-20210825014021855

这是一个 NBA 球星网站,用卡片的形式展示了一些球星的基本信息,另外每一张卡片上其实都有一个加密字符串,这个加密字符串其实和球星的相关信息是有关联的,每个球星的 加密字符串也是不同的。

所以,这里我们要做的就是找出这个加密字符串的加密算法并用程序把加密字符串的生成过程模拟出来。

2. 准备工作

由于本节我们需要使用 Python 模拟执行 JavaScript,这里我们使用的库叫做 PyExecJS,我们使用 pip3 安装即可,命令如下:

1
pip3 install pyexecjs

PyExecJS 是用于执行 JavaScript 的,但执行 JavaScript 的功能需要依赖一个 JavaScript 运行环境,所以除了安装好这个库之外,我们还需要安装一个 JavaScript 运行环境,个人比较推荐的是 Node.js,所以我们还需要安装下 Node.js,可以到 https://nodejs.org/ 下载安装。更加详细的安装和配置过程可以参考:https://setup.scrape.center/pyexecjs。

PyExecJS 库在运行时会检测本地 JavaScript 运行环境来实现 JavaScript 执行,做好如上准备工作之后, 接着我们运行代码检查一下运行环境:

1
2
import execjs
print(execjs.get().name)

运行结果类似如下:

1
Node.js (V8)

如果你成功安装好 PyExecJS 库和 Node.js 的话,其结果就是 Node.js (V8),当然如果你安装的是其他的 JavaScript 运行环境,结果也会有所不同。

3. 分析

接下来我们就对这个网站稍作分析,打开 Sources 面板,我们可以非常轻易地找到加密字符串的生成逻辑,如图所示:

image-20210826034346308

首先声明了一个球员相关的列表,如:

1
2
3
4
5
6
7
8
9
10
const players = [
{
name: '凯文-杜兰特',
image: 'durant.png',
birthday: '1988-09-29',
height: '208cm',
weight: '108.9KG'
}
...
]

然后对于每一个球员,都把每个球员的信息调用了加密算法进行了加密,我们可以打个断点看下:

image-20210825014950392

这里我们可以看到,getToken 方法的输入就是单个球员的信息,就是上述列表的一个元素对象,然后 this.key 就是一个固定的字符串。整个加密逻辑就是提取了球员的名字、生日、身高、体重,然后先 Base64 编码然后再进行 DES 加密,最后返回结果。

加密算法是怎么实现的呢?其实就是依赖了 crypto-js 库,使用了 CryptoJS 对象来实现的。

那 CryptoJS 这个对象是哪里来的呢?总不能凭空产生吧?其实这个网站就是直接引用了这个库,如图所示:

image-20210826035113504

引用这个 JavaScript 文件之后,CryptoJS 就被注入到浏览器全局环境下了,因此我们就可以在别的方法里面直接使用 CryptoJS 对象里面的方法了。

4. 模拟调用

好,那既然这样,我们要怎么模拟呢?下面我们来实现下。

首先,我们要模拟的其实就是这个 getToken 方法,输入球员相关信息,得到最终的加密字符串,这里我们直接把 key 替换下,把 getToken 方法稍微改写如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function getToken(player) {
let key = CryptoJS.enc.Utf8.parse("fipFfVsZsTda94hJNKJfLoaqyqMZFFimwLt");
const { name, birthday, height, weight } = player;
let base64Name = CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(name));
let encrypted = CryptoJS.DES.encrypt(
`${base64Name}${birthday}${height}${weight}`,
key,
{
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7,
}
);
return encrypted.toString();
}

因为这个方法的模拟执行是需要 CryptoJS 这个对象的,如果我们直接调用这个方法肯定会报 CryptoJS 未定义的错误。

那怎么办呢?我们只需要再模拟执行下刚才看到的 crypto-js.min.js 不就好了吗?

OK,所以,我们需要模拟执行的内容就是两部分:

  • 模拟运行 crypto-js.min.js 里面的 JavaScript,用于声明 CryptoJS 对象。
  • 模拟运行 getToken 方法的定义,用于声明 getToken 方法。

好,接下来我们就把 crypto-js.min.js 里面的代码和上面 getToken 方法的代码复制一下,都粘贴到一个 JavaScript 文件里面,比如就叫做 crypto.js。

接下来我们就用 PyExecJS 模拟执行一下吧,代码如下:

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

item = {
'name': '凯文-杜兰特',
'image': 'durant.png',
'birthday': '1988-09-29',
'height': '208cm',
'weight': '108.9KG'
}

file = 'crypto.js'
node = execjs.get()
ctx = node.compile(open(file).read())

js = f"getToken({json.dumps(item, ensure_ascii=False)})"
print(js)
result = ctx.eval(js)
print(result)

这里我们单独定义了一位球员的信息,赋值为 item 变量。然后使用 execjs 的 get 方法获取了 JavaScript 执行环境,赋值为 node。

接着我们调用了 node 的 compile 方法,传入了刚才定义的 crypto.js 文件的文本内容,compile 方法会返回一个 JavaScript 的上下文对象,我们赋值为 ctx。执行到这里,其实就可以理解为,ctx 对象里面就执行过了 crypto-js.min.js,CryptoJS 就声明好了,然后也执行过了 getToken 的定义,所以 getToken 方法也定义好了,相当于完成了一些初始化的工作。

接着,我们只需要定义好我们想要执行的 JavaScript 代码就好了,我们定义了一个 js 变量,其实就是模拟调用了 getToken 方法并传入了球员信息,我们打印了下 js 变量的值,内容如下:

1
getToken({"name": "凯文-杜兰特", "image": "durant.png", "birthday": "1988-09-29", "height": "208cm", "weight": "108.9KG"})

其实这就是一个标准的 JavaScript 方法调用的写法而已。

接着我们调用 ctx 对象的 eval 方法并传入 js 变量,其实就是模拟执行了这句 JavaScript 代码,照理来说最终返回的就是加密字符串了。

然而,运行之后,我们可能看到这个报错:

1
execjs._exceptions.ProgramError: ReferenceError: CryptoJS is not defined

很奇怪,CryptoJS 未定义?我们明明执行过 crypto-js.min.js 里面的内容了呀?

问题其实出在 crypto-js.min.js 里面,可以看到其里面声明了一个 JavaScript 的自执行方法,如图所示:

image-20210825020403826

自执行方法什么意思呢?就是声明了一个方法,然后紧接着调用执行,我们可以看下这个例子:

1
2
3
!(function (a, b) {
console.log("result", a, b);
})(1, 2);

这里我们先声明了一个 function,然后接收 a 和 b 两个参数,然后把内容输出出来,然后我们把这个 function 用小括号括起来,这其实就是一个方法,可以被直接调用的,怎么调用呢?后面再跟上对应的参数就好了,比如传入 1 和 2,执行结果如下:

1
result 1 2

可以看到,这个自执行的方法就被执行了。

同理地,crypto-js.min.js 也符合这个格式,它接收 t 和 e 两个参数,t 就是 this,其实就是浏览器中的 window 对象,e 就是一个 function(用于定义 CryptoJS 的核心内容)。

我们再来观察下 crypto-js.min.js 开头的定义:

1
2
3
4
5
"object" == typeof exports
? (module.exports = exports = e())
: "function" == typeof define && define.amd
? define([], e)
: (t.CryptoJS = e());

在 Node.js 中,其实 exports 就是用来将一些对象的定义进行导出的,这里 "object" == typeof exports 其实结果就是 true,所以就执行了 module.exports = exports = e() 这段代码,这样就相当于把 e() 作为整体导出了,而这个 e() 其实就对应这后面的整个 function,function 里面定义了加密相关的各个实现,其实就指代整个加密算法库。

但是在浏览器中,其结果就不一样了,浏览器环境中并没有 exports 和 define 这两个对象。所以,上述代码在浏览器中最后执行的就是 t.CryptoJS = e() 这段代码,其实这里就是把 CryptoJS 对象挂载到 this 对象上面,而 this 就是浏览器中的全局 window 对象,后面就可以直接用了。如果我们把代码放在浏览器中运行,那是没有任何问题的。

然而,我们使用的 PyExecJS 是依赖于一个 Node.js 执行环境的,所以上述代码其实执行的是 module.exports = exports = e(),这里面并没有声明 CryptoJS 对象,也没有把 CryptoJS 挂载到全局对象里面,所以后面我们再调用 CryptoJS 就自然而然出现了未定义的错误了。

那怎么办呢?其实很简单,那我们直接声明一个 CryptoJS 变量,然后手动声明一下它的初始化不就好了吗?所以我们可以把代码稍作修改,改成如下内容:

1
2
3
4
5
6
7
8
9
10
11
var CryptoJS;
!(function (t, e) {
CryptoJS = e();
"object" == typeof exports
? (module.exports = exports = e())
: "function" == typeof define && define.amd
? define([], e)
: (t.CryptoJS = e());
})(this, function () {
//...
});

这里我们就首先声明了一个 CryptoJS 变量,然后直接给 CryptoJS 变量赋值给 e(),这样就完成了 CryptoJS 的初始化。

这样我们再重新运行刚才的 Python 脚本,就可以得到执行结果了:

1
gQSfeqldQIJKAZHH9TzRX/exvIwb0j73b2cjXvy6PeZ3rGW6sQsL2w==

这样我们就成功得到加密字符串了,和示例网站上显示的是一模一样的,这样我们就成功模拟 JavaScript 的调用完成了某个加密算法的运行过程。

5. 总结

本节介绍了利用 PyExecJS 来模拟执行 JavaScript 的方法,结合一个案例来完成了整个的实现和问题排查的过程。本节内容还是比较重要的,以后我们如果需要模拟执行 JavaScript 就可以派得上用场。

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

Python

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

在 JavaScript 逆向的时候,我们经常需要追踪某些方法的堆栈调用情况。但在很多情况下,一些 JavaScript 的变量或者方法名经过混淆之后是非常难以捕捉的。上一节我们介绍了一些断点调试、调用栈查看等技巧,但仅仅凭借这些技巧还不足以应对多数 JavaScript 逆向。

本节我们再来介绍一个比较常用的 JavaScript 逆向技巧 —— Hook 技术。

1. Hook 技术

Hook 技术中文又叫作钩子技术,指在程序运行的过程中,对其中的某个方法进行重写,在原先的方法前后加入我们自定义的代码。相当于在系统没有调用该函数之前,钩子程序就先捕获该消息,得到控制权,这时钩子函数既可以加工处理(改变)该函数的执行行为,也可以强制结束消息的传递。

要对 JavaScript 代码进行 Hook 操作,就需要额外在页面中执行一些自定义的有关 Hook 逻辑的代码。那么问题来了?怎样才能在浏览器中方便地执行我们所期望执行的 JavaScript 代码呢?在这里推荐一个插件,叫作 Tampermonkey。这个插件的功能非常强大,利用它我们几乎可以在网页中执行任何 JavaScript 代码,实现我们想要的功能。

下面我们就来介绍一下这个插件的使用方法,并结合一个实际案例,介绍一下这个插件在 JavaScript Hook 中的用途。

2. Tampermonkey

Tampermonkey,中文也叫作“油猴”,它是一款浏览器插件,支持 Chrome。利用它我们可以在浏览器加载页面时自动执行某些 JavaScript 脚本。由于执行的是 JavaScript,所以我们几乎可以在网页中完成任何我们想实现的效果,如自动爬虫、自动修改页面、自动响应事件等。

其实,Tampermonkey 的用途远远不止这些,只要我们想要的功能能用 JavaScript 实现,Tampermonkey 就可以帮我们做到。比如我们可以将 Tampermonkey 应用到 JavaScript 逆向分析中,去帮助我们更方便地分析一些 JavaScript 加密和混淆代码。

3. 安装

首先我们需要安装 Tampermonkey,这里我们使用的浏览器是 Chrome。直接在 Chrome 应用商店或者在 Tampermonkey 的官网 https://www.tampermonkey.net/ 下载安装即可。

安装完成之后,在 Chrome 浏览器的右上角会出现 Tampermonkey 的图标,这就代表安装成功了,如图所示。

4. 获取脚本

Tampermonkey 运行的是 JavaScript 脚本,每个网站都能有对应的脚本运行,不同的脚本能完成不同的功能。这些脚本我们可以自定义,也可以用已经写好的很多脚本,毕竟有些轮子有了,我们就不需要再去造了。

我们可以在 https://greasyfork.org/zh-CN/scripts 找到一些非常实用的脚本,如全网视频去广告、百度云全网搜索等,大家可以体验一下。

5. 脚本编写

除了使用别人已经写好的脚本,我们也可以自己编写脚本来实现想要的功能。编写脚本难不难呢?其实就是写 JavaScript 代码,只要懂一些 JavaScript 的语法就好了。另外我们需要遵循脚本的一些写作规范,其中就包括一些参数的设置。

下面我们就简单实现一个小的脚本。首先我们可以点击 Tampermonkey 插件图标,再点击“管理面板”按钮,打开脚本管理页面,如图所示。

脚本管理页面如图所示。

在这里显示了我们已经有的一些 Tampermonkey 脚本,包括我们自行创建的,也包括从第三方网站下载安装的。另外这里提供了编辑、调试、删除等管理功能,在这里可以方便地对脚本进行管理。

接下来我们来创建一个新的脚本,点击左侧的“+”号,会显示如图所示的页面。

初始化的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ==UserScript==
// @name New Userscript
// @namespace http://tampermonkey.net/
// @version 0.1
// @description try to take over the world!
// @author You
// @match https://www.tampermonkey.net/documentation.php?ext=dhdg
// @grant none
// ==/UserScript==

(function () {
"use strict";

// Your code here...
})();

在上面这段代码里,最前面是一些注释,它们非常有用,这部分内容叫作 UserScript Header ,我们可以在里面配置一些脚本的信息,如名称、版本、描述、生效站点等等。

下面简单介绍一下 UserScript Header 的一些参数定义。

  • @name:脚本的名称,就是在控制面板显示的脚本名称。

  • @namespace:脚本的命名空间。

  • @version:脚本的版本,主要是做版本更新时用。

  • @author:作者。

  • @description:脚本描述。

  • @homepage@homepageURL@website@source:作者主页,用于在 Tampermonkey 选项页面上从脚本名称点击跳转。请注意,如果 @namespace 标记以 http://开头,此处也要一样。

  • @icon@iconURL@defaulticon:低分辨率图标。

  • @icon64@icon64URL:64 × 64 高分辨率图标。

  • @updateURL:检查更新的网址,需要定义 @version

  • @downloadURL:更新下载脚本的网址,如果定义成 none 就不会检查更新。

  • @supportURL:报告问题的网址。

  • @include:生效页面,可以配置多个,但注意这里并不支持 URL Hash。

    例如:

    1
    2
    3
    4
    // @include http://www.tampermonkey.net/*
    // @include http://*
    // @include https://*
    // @include *
  • @match:约等于 @include 标签,可以配置多个。

  • @exclude:不生效页面,可配置多个,优先级高于 @include@match

  • @require:附加脚本网址,相当于引入外部的脚本,这些脚本会在自定义脚本执行之前执行,比如引入一些必须的库,如 jQuery 等,这里可以支持配置多个 @require 参数。

    例如:

    1
    2
    3
    // @require https://code.jquery.com/jquery-2.1.4.min.js
    // @require https://code.jquery.com/jquery-2.1.3.min.js#sha256=23456...
    // @require https://code.jquery.com/jquery-2.1.2.min.js#md5=34567...,sha256=6789...
  • @resource:预加载资源,可通过 GM_getResourceURLGM_getResourceText 读取。

  • @connect:允许被 GM_xmlhttpRequest 访问的域名,每行 1 个。

  • @run-at:脚本注入的时刻,如页面刚加载时,某个事件发生后等。

    • document-start:尽可能地早执行此脚本。
    • document-body:DOM 的 body 出现时执行。
    • document-endDOMContentLoaded 事件发生时或发生后执行。
    • document-idleDOMContentLoaded 事件发生后执行,即 DOM 加载完成之后执行,这是默认的选项。
    • context-menu:如果在浏览器上下文菜单(仅限桌面 Chrome 浏览器)中点击该脚本,则会注入该脚本。注意:如果使用此值,则将忽略所有 @include@exclude 语句。
  • @grant:用于添加 GM 函数到白名单,相当于授权某些 GM 函数的使用权限。

    例如:

    1
    2
    3
    4
    5
    6
    // @grant GM_setValue
    // @grant GM_getValue
    // @grant GM_setClipboard
    // @grant unsafeWindow
    // @grant window.close
    // @grant window.focus

    如果没有定义过 @grant 选项,Tampermonkey 会猜测所需要的函数使用情况。

  • @noframes:此标记使脚本在主页面上运行,但不会在 iframe 上运行。

  • @nocompat:由于部分代码可能是为专门的浏览器所写,通过此标记,Tampermonkey 会知道脚本可以运行的浏览器。

    例如:

    1
    // @nocompat Chrome

    这样就指定了脚本只在 Chrome 浏览器中运行。

除此之外,Tampermonkey 还定义了一些 API,使得我们可以方便地完成某个操作。

  • GM_log:将日志输出到控制台。
  • GM_setValue:将参数内容保存到 Storage 中。
  • GM_addValueChangeListener:为某个变量添加监听,当这个变量的值改变时,就会触发回调。
  • GM_xmlhttpRequest:发起 Ajax 请求。
  • GM_download:下载某个文件到磁盘。
  • GM_setClipboard:将某个内容保存到粘贴板。

还有很多其他的 API,大家可以到 https://www.tampermonkey.net/documentation.php 查看更多的内容。

UserScript Header 下方是 JavaScript 函数和调用的代码,其中 'use strict' 标明代码使用 JavaScript 的严格模式。在严格模式下,可以消除 Javascript 语法的一些不合理、不严谨之处,减少一些怪异行为,如不能直接使用未声明的变量,这样可以保证代码的运行安全,同时提高编译器的效率,提高运行速度。在下方 // Your code here... 处就可以编写自己的代码了。

6. 实战分析

下面我们通过一个简单的 JavaScript 逆向案例来演示一下如何实现 JavaScript 的 Hook 操作,轻松找到某个方法执行的位置,从而快速定位逆向入口。

接下来我们来看一个简单的网站:https://login1.scrape.center/,这个网站的结构非常简单,就是一个用户名密码登录。但是不同的是,点击登录的时候,表单提交 POST 的内容并不是单纯的用户名和密码,而是一个加密后的 token。

页面如图所示。

image-20210509215948819

我们输入用户名密码,都为 admin,点击登录按钮,观察一下网络请求的变化。

可以看到如下结果如图所示。

image-20210509220046359

我们不需要关心 Response 的结果和状态,主要看 Request 的内容就好了。

可以看到,点击登录按钮时,发起了了一个 POST 请求,内容为:

1
{"token":"eyJ1c2VybmFtZSI6ImFkbWluIiwicGFzc3dvcmQiOiJhZG1pbiJ9"}

嗯,确实,没有诸如 usernamepassword 的内容了,那怎么模拟登录呢?

模拟登录的前提当然就是找到当前 token 生成的逻辑了,那么问题来了,到底这个 token 和用户名、密码是什么关系呢?我们怎么来找寻其中的蛛丝马迹呢?

这里我们就可能思考了,本身输入的是用户名和密码,但提交的时候却变成了一个 token,经过观察并结合一些经验可以看出,token 的内容非常像 Base64 编码。这就代表,网站可能首先将用户名密码混为了一个新的字符串,然后经过了一次 Base64 编码,最后将其赋值为 token 来提交了。所以,初步观察我们可以得出这么多信息。

好,那就来验证一下吧!探究网站 JavaScript 代码里面是如何实现的。

首先我们看一下网站的源码,打开 Sources 面板,看起来都是 Webpack 打包之后的内容,经过了一些混淆,如图所示。

image-20210509222556397

这么多混淆代码,总不能一点点扒着看吧?那么遇到这种情形,这怎么去找 token 的生成位置呢?

解决方法其实有两种,一种就是前文所讲的 Ajax 断点,另一种就是 Hook。

Ajax 断点

由于这个请求正好是一个 Ajax 请求,所以我们可以添加一个 XHR 断点监听,把 POST 的网址加到断点监听上面。在 Sources 面板右侧添加一个 XHR 断点,匹配内容就填当前域名就好了,如图所示。

image-20210509223127936

这时候如果我们再次点击登录按钮,发起一次 Ajax 请求,就可以进入断点了,然后再看堆栈信息,就可以一步步找到编码的入口了。

再次点击登录按钮,页面就进入断点状态停下来了,结果如图所示。

image-20210509223337762

一步步找,最后可以找到入口其实是在 onSubmit 方法那里。但实际上我们观察到,这里的断点的栈顶还包括了一些类似 async Promise 等无关的内容,而我们真正想找的是用户名和密码经过处理,再进行 Base64 编码的地方,这些请求的调用实际上和我们找寻的入口没有很大的关系。

另外,如果我们想找的入口位置并不伴随这一次 Ajax 请求,这个方法就没法用了。

所以下面我们再来看另一个方法 —— Hook。

Hook Function

所以这里介绍第二种可以快速定位入口的方法,那就是使用 Tampermonkey 自定义 JavaScript,实现某个 JavaScript 方法的 Hook。Hook 哪里呢?很明显,Hook Base64 编码的位置就好了。

那么这里就涉及一个小知识点:JavaScript 里面的 Base64 编码是怎么实现的?

没错,就是 btoa 方法,在 JavaScript 中该方法用于将字符串编码成 Base64 字符串,因此我们来 Hook btoa 方法就好了。

好,这里我们新建一个 Tampermonkey 脚本,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ==UserScript==
// @name HookBase64
// @namespace https://login1.scrape.center/
// @version 0.1
// @description Hook Base64 encode function
// @author Germey
// @match https://login1.scrape.center/
// @grant none
// ==/UserScript==
(function () {
"use strict";
function hook(object, attr) {
var func = object[attr];
object[attr] = function () {
console.log("hooked", object, attr);
var ret = func.apply(object, arguments);
debugger;
return ret;
};
}
hook(window, "btoa");
})();

首先我们定义了一些 UserScript Header,包括 @name@match等,这里比较重要的就是@name,表示脚本名称;另外一个就是 @match,它代表脚本生效的网址。

脚本的内容如上面代码所示。我们定义了一个 hook方法,传入 objectattr 参数,意思就是 Hook object 对象的 attr参数。例如我们如果想 Hook alert 方法,那就把 object 设置为 window,把 attr 设置为字符串 alert 。这里我们想要 Hook Base64 的编码方法,而在 JavaScript 中,Based64 编码是用 btoa 方法实现的,所以这里我们就只需要 Hook window 对象的 btoa 方法就好了。

那么 Hook 是怎么实现的呢?我们来看已下,首先一句 var func = object[attr],相当于我们先把它赋值为一个变量,我们调用 func 方法就可以实现和原来相同的功能。接着,我们直接改写这个方法的定义,将 object[attr] 改写成一个新的方法,在新的方法中,通过 func.apply 方法又重新调用了原来的方法。这样我们就可以保证前后方法的执行效果是不受什么影响的,之前这个方法该干啥就还是干啥。

但是和之前不同的是,我们自定义方法之后,现在可以在 func 方法执行的前后,再加入自己的代码,如 console.log 将信息输出到控制台,debugger 进入断点等。在这个过程中,我们先临时保存下来了 func 方法,然后定义一个新的方法,接管程序控制权,在其中自定义我们想要的实现,同时在新的方法里面重新调回 func 方法,保证前后结果是不受影响的。所以,我们达到了在不影响原有方法效果的前提下,实现在方法前后自定义的功能,这就是 Hook 的过程。

最后,我们调用 hook 方法,传入 window 对象和 btoa 字符串,保存。

接下来刷新下页面,这时候我们就可以看到这个脚本在当前页面生效了,可以发现 Tempermonkey 插件面板提示了已经启用,同时在 Sources 面板下的 Page 选项卡可以观察到我们定义的 JavaScript 脚本被执行了,如图所示。

image-20210509223942108

然后输入用户名、密码,点击提交,成功进入了断点模式停下来了,代码就卡在了我们自定义的 debugger 这一行代码的位置,如图所示。

image-20210509224216857

成功 Hook 住了,这说明 JavaScript 代码在执行过程中调用到了 btoa 方法。

这时看一下控制台,如图所示。

image-20210509224328452

这里也输出了 window 对象和 btoa 方法,验证正确。

这样,我们就顺利找到了 Base64 编码操作这个路口,然后看一下堆栈信息,也已经不会出现 async、Promise 这样的调用了,很清晰地呈现了 btoa 方法逐层调用的过程,非常清晰明了,如图所示。

image-20210509224356222

另外再观察下 Local 面板,看看 arguments 变量是怎样的,如图所示。

image-20210509224448758

可以说一目了然了,arguments 就是指传给 btoa 方法的参数,ret 就是 btoa 方法返回的结果,可以看到 arguments 就是 usernamepassword 通过 JSON 序列化之后的字符串,经过 Base64 编码之后得到的值恰好就是 Ajax 请求参数 token 的值。

结果几乎也明了了,我们还可以通过调用栈找到 onSubmit 方法的处理源码:

1
2
3
4
5
6
7
8
onSubmit: function() {
var e = c.encode(JSON.stringify(this.form));
this.$http.post(a["a"].state.url.root, {
token: e
}).then((function(e) {
console.log("data", e)
}))
}

仔细看看,encode 方法其实就是调用了一下 btoa方法,就是一个 Base64 编码的过程,答案其实已经很明了了。

当然我们还可以进一步打断点验证一下流程,比如在调用 encode 方法的一行打断点,如图所示。

image-20210509224938312

打完断点之后,可以点击 Resume 按钮恢复 JavaScript 的执行,跳过当前 Tempermonkey 定义的断点位置,如图所示。

image-20210509225049534

然后重新再点击登录按钮,可以看到这时候就停在了当前打断点的位置了,如图所示。

image-20210509225531743

这时候可以在 Watch 面板下输入 this.form,验证此处是否为在表单中输入的用户名密码,如图所示。

image-20210509225732574

没问题,然后逐步调试。我们还可以可以观察到,下一步就跳到了我们 Hook 的位置,这说明调用了 btoa 方法,如图所示。

image-20210509225907721

返回的结果正好就是 token 的值。

所以,验证到这里,已经非常清晰了,整体逻辑就是对登录表单的用户名和密码进行了 JSON 序列化,然后调用了 encode 也就是 btoa 方法,并赋值为了 token 发起登录的 Ajax 请求,逆向完成。

我们通过 Tampermonkey 自定义 JavaScript 脚本的方式,实现了某个方法调用的 Hook,使得我们能快速定位到加密入口的位置,非常方便。

以后如果观察出一些门道,可以多使用这种方法来尝试,如 Hook encode 方法、decode方法、stringify 方法、log 方法、alert 方法等,简单又高效。

7. 总结

以上便是通过 Tampermonkey 实现简单 Hook 的基础操作,当然这仅仅是一个常见的基础案例,我们可以从中总结出一些 Hook 的基本门道。

由于本节涉及到一些专有名词,部分内容参考如下:

Python

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

随着大数据时代的发展,各个公司的数据保护意识越来越强,大家都在想尽办法保护自家产品的数据不轻易被爬虫爬走。由于网页是提供信息和服务的重要载体,所以对网页上的信息进行保护就成了至关重要的一个环节。

网页是运行在浏览器端的,当我们浏览一个网页时,其 HTML 代码、 JavaScript 代码都会被下载到浏览器中执行。借助浏览器的开发者工具,我们可以看到网页在加载过程中所有网络请求的详细信息,也能清楚地看到网站运行的 HTML 代码和 JavaScript 代码,这些代码中就包含了网站加载的全部逻辑,如加载哪些资源、请求接口是如何构造的、页面是如何渲染的等等。正因为代码是完全透明的,所以如果我们能够把其中的执行逻辑研究出来,就可以模拟各个网络请求进行数据爬取了。

然而,事情没有想象得那么简单。随着前端技术的发展,前端代码的打包技术、混淆技术、加密技术也层出不穷,借助于这些技术,各个公司可以在前端对 JavaScript 代码采取一定的保护,比如变量名混淆、执行逻辑混淆、反调试、核心逻辑加密等,这些保护手段使得我们没法很轻易地找出 JavaScript 代码中包含的的执行逻辑。

在前几章的案例中,我们也试着爬取了各种形式的网站。其中有的网站的数据接口是没有任何验证或加密参数的,我们可以轻松模拟并爬取其中的数据;但有的网站稍显复杂,网站的接口中增加了一些加密参数,同时对 JavaScript 代码采取了上文所述的一些防护措施,当时我们没有直接尝试去破解,而是用 Selenium 等类似工具来实现模拟浏览器执行的方式来进行“所见即所得“的爬取。其实对于后者,我们还有另外一种解决方案,那就是直接逆向 JavaScript 代码,找出其中的加密逻辑,从而直接实现该加密逻辑来进行爬取。如果加密逻辑实在过于复杂,我们也可以找出一些关键入口,从而实现对加密逻辑的单独模拟执行和数据爬取。这些方案难度可能很大,比如关键入口很难寻找,或者加密逻辑难以模拟,可是一旦成功找到突破口,我们便可以不用借助于 Selenium 等工具进行整页数据的渲染而实现数据爬取,这样爬取效率会大幅提升。

本章我们首先会对 JavaScript 防护技术进行介绍,然后介绍一些常用的 JavaScript 逆向技巧,包括浏览器工具的使用、Hook 技术、AST 技术、特殊混淆技术的处理、WebAssembly 技术的处理。了解了这些技术,我们可以更从容地应对 JavaScript 防护技术。

1. 引入

我们在爬取网站的时候,会遇到一些情况需要分析一些接口或 URL 信息,在这个过程中,我们会遇到各种各样类似加密的情形,比如说:

  • 某个网站的 URL 带有一些看不太懂的长串加密参数,要抓取就必须要懂得这些参数是怎么构造的,否则我们连完整的 URL 都构造不出来,更不用说爬取了。
  • 分析某个网站的 Ajax 接口的时候,可以看到接口的一些参数也是加密的,或者 Request Headers 里面也可能带有一些加密参数,如果不知道这些参数的具体构造逻辑就没法直接用程序来模拟这些 Ajax 请求。
  • 翻看网站的 JavaScript 源代码,可以发现很多压缩了或者看不太懂的字符,比如 JavaScript 文件名被编码,JavaScript 的文件内容都压缩成几行,JavaScript 变量也被修改成单个字符或者一些十六进制的字符,导致我们不好轻易根据 JavaScript 找出某些接口的加密逻辑。

这些情况呢,基本上都是网站为了保护其本身的一些数据不被轻易抓取而采取的一些措施,我们可以把它归类为两大类:

  • URL/API 参数加密
  • JavaScript 压缩、混淆和加密

这一节我们就来了解下这两类技术的基本原理和一些常见的示例。知己知彼,百战不殆,了解了这些技术的实现原理之后,我们才能更好地去逆向其中的逻辑,从而实现数据爬取。

2. 网站数据防护方案

当今大数据时代,数据已经变得越来越重要,网页和 App 现在是主流的数据载体,如果其数据的 API 没有设置任何保护措施,在爬虫工程师解决了一些基本的反爬如封 IP、验证码的问题之后,那么数据还是可以被轻松爬取到的。

那么,有没有可能在 URL/API 层面或 JavaScript 层面也加上一层防护呢?答案是可以。

URL/API 参数加密

网站运营者首先想到防护措施可能是对某些数据接口的参数进行加密,比如说对某些 URL 的一些参数加上校验码或者把一些 id 信息进行编码,使其变得难以阅读或构造;或者对某些 API 请求加上一些 token、sign 等签名,这样这些请求发送到服务器时,服务器会通过客户端发来的一些请求信息以及双方约定好的秘钥等来对当前的请求进行校验,如果校验通过,才返回对应数据结果。

比如说客户端和服务端约定一种接口校验逻辑,客户端在每次请求服务端接口的时候都会附带一个 sign 参数,这个 sign 参数可能是由当前时间信息、请求的 URL、请求的数据、设备的 ID、双方约定好的秘钥经过一些加密算法构造而成的,客户端会实现这个加密算法构造 sign,然后每次请求服务器的时候附带上这个参数。服务端会根据约定好的算法和请求的数据对 sign 进行校验,如果校验通过,才返回对应的数据,否则拒绝响应。

当然登录状态的校验也可以看作是此类方案,比如一个 API 的调用必须要传一个 token,这个 token 必须用户登录之后才能获取,如果请求的时候不带该 token,API 就不会返回任何数据。

倘若没有这种措施,那么基本上 URL 或者 API 接口是完全公开可以访问的,这意味着任何人都可以直接调用来获取数据,几乎是零防护的状态,这样是非常危险的,而且数据也可以被轻易地被爬虫爬取。因此对 URL/API 参数一些加密和校验是非常有必要的。

JavaScript 压缩、混淆和加密

接口加密技术看起来的确是一个不错的解决方案,但单纯依靠它并不能很好地解决问题。为什么呢?

对于网页来说,其逻辑是依赖于 JavaScript 来实现的,JavaScript 有如下特点:

  • JavaScript 代码运行于客户端,也就是它必须要在用户浏览器端加载并运行。
  • JavaScript 代码是公开透明的,也就是说浏览器可以直接获取到正在运行的 JavaScript 的源码。

由于这两个原因,至使 JavaScript 代码是不安全的,任何人都可以读、分析、复制、盗用,甚至篡改。

所以说,对于上述情形,客户端 JavaScript 对于某些加密的实现是很容易被找到或模拟的,了解了加密逻辑后,模拟参数的构造和请求也就是轻而易举了,所以如果 JavaScript 没有做任何层面的保护的话,接口加密技术基本上对数据起不到什么防护作用。

如果你不想让自己的数据被轻易获取,不想他人了解 JavaScript 逻辑的实现,或者想降低被不怀好意的人甚至是黑客攻击。那么就需要用到 JavaScript 压缩、混淆和加密技术了。

这里压缩、混淆和加密技术简述如下:

  • 代码压缩:即去除 JavaScript 代码中的不必要的空格、换行等内容,使源码都压缩为几行内容,降低代码可读性,当然同时也能提高网站的加载速度。
  • 代码混淆:使用变量替换、字符串阵列化、控制流平坦化、多态变异、僵尸函数、调试保护等手段,使代码变地难以阅读和分析,达到最终保护的目的。但这不影响代码原有功能。是理想、实用的 JavaScript 保护方案。
  • 代码加密:可以通过某种手段将 JavaScript 代码进行加密,转成人无法阅读或者解析的代码,如借用 WebAssembly 技术,可以直接将 JavaScript 代码用 C/C++ 实现,JavaScript 调用其编译后形成的文件来执行相应的功能。

下面我们对上面的技术分别予以介绍。

3. URL/API 参数加密

现在绝大多数网站的数据一般都是通过服务器提供的 API 来获取的,网站或 App 可以请求某个数据 API 获取到对应的数据,然后再把获取的数据展示出来。但有些数据是比较宝贵或私密的,这些数据肯定是需要一定层面上的保护。所以不同 API 的实现也就对应着不同的安全防护级别,我们这里来总结下。

为了提升接口的安全性,客户端会和服务端约定一种接口校验方式,一般来说会使用到各种加密和编码算法,如 Base64、Hex 编码,MD5、AES、DES、RSA 等对称或非对称加密。

举个例子,比如说客户端和服务器双方约定一个 sign 用作接口的签名校验,其生成逻辑是客户端将 URL Path 进行 MD5 加密然后拼接上 URL 的某个参数再进行 Base64 编码,最后得到一个字符串 sign,这个 sign 会通过 Request URL 的某个参数或 Request Headers 发送给服务器。服务器接收到请求后,对 URL Path 同样进行 MD5 加密,然后拼接上 URL 的某个参数,也进行 Base64 编码也得到了一个 sign,然后比对生成的 sign 和客户端发来的 sign 是否是一致的,如果是一致的,那就返回正确的结果,否则拒绝响应。这就是一个比较简单的接口参数加密的实现。如果有人想要调用这个接口的话,必须要定义好 sign 的生成逻辑,否则是无法正常调用接口的。

当然上面的这个实现思路比较简单,这里还可以增加一些时间戳信息增加时效性判断,或增加一些非对称加密进一步提高加密的复杂程度。但不管怎样,只要客户端和服务器约定好了加密和校验逻辑,任何形式加密算法都是可以的。

这里要实现接口参数加密就需要用到一些加密算法,客户端和服务器肯定也都有对应的 SDK 实现这些加密算法,如 JavaScript 的 crypto-js,Python 的 hashlib、Crypto 等等。

但还是如上文所说,如果是网页的话,客户端实现加密逻辑如果是用 JavaScript 来实现,其源代码对用户是完全可见的,如果没有对 JavaScript 做任何保护的话,是很容易弄清楚客户端加密的流程的。

因此,我们需要对 JavaScript 利用压缩、混淆等方式来对客户端的逻辑进行一定程度上的保护。

4. JavaScript 压缩

这个非常简单,JavaScript 压缩即去除 JavaScript 代码中的不必要的空格、换行等内容或者把一些可能公用的代码进行处理实现共享,最后输出的结果都压缩为几行内容,代码可读性变得很差,同时也能提高网站加载速度。

如果仅仅是去除空格换行这样的压缩方式,其实几乎是没有任何防护作用的,因为这种压缩方式仅仅是降低了代码的直接可读性。如果我们有一些格式化工具可以轻松将 JavaScript 代码变得易读,比如利用 IDE、在线工具或 Chrome 浏览器都能还原格式化的代码。

比如这里举一个最简单的 JavaScript 压缩示例,原来的 JavaScript 代码是这样的:

1
2
3
4
function echo(stringA, stringB) {
const name = "Germey";
alert("hello " + name);
}

压缩之后就变成这样子:

1
2
3
4
function echo(d, c) {
const e = "Germey";
alert("hello " + e);
}

可以看到这里参数的名称都被简化了,代码中的空格也被去掉了,整个代码也被压缩成了一行,代码的整体可读性降低了。

目前主流的前端开发技术大多都会利用 Webpack、Rollup 等工具进行打包,Webpack、Rollup 会对源代码进行编译和压缩,输出几个打包好的 JavaScript 文件,其中我们可以看到输出的 JavaScript 文件名带有一些不规则字符串,同时文件内容可能只有几行内容,变量名都是一些简单字母表示。这其中就包含 JavaScript 压缩技术,比如一些公共的库输出成 bundle 文件,一些调用逻辑压缩和转义成冗长的几行代码,这些都属于 JavaScript 压缩。另外其中也包含了一些很基础的 JavaScript 混淆技术,比如把变量名、方法名替换成一些简单字符,降低代码可读性。

但整体来说,JavaScript 压缩技术只能在很小的程度上起到防护作用,要想真正提高防护效果还得依靠 JavaScript 混淆和加密技术。

5. JavaScript 混淆

JavaScript 混淆是完全是在 JavaScript 上面进行的处理,它的目的就是使得 JavaScript 变得难以阅读和分析,大大降低代码可读性,是一种很实用的 JavaScript 保护方案。

JavaScript 混淆技术主要有以下几种:

  • 变量混淆:将带有含义的变量名、方法名、常量名随机变为无意义的类乱码字符串,降低代码可读性,如转成单个字符或十六进制字符串。

  • 字符串混淆:将字符串阵列化集中放置、并可进行 MD5 或 Base64 加密存储,使代码中不出现明文字符串,这样可以避免使用全局搜索字符串的方式定位到入口点。

  • 属性加密:针对 JavaScript 对象的属性进行加密转化,隐藏代码之间的调用关系。

  • 控制流平坦化:打乱函数原有代码执行流程及函数调用关系,使代码逻变得混乱无序。

  • 无用代码注入:随机在代码中插入不会被执行到的无用代码,进一步使代码看起来更加混乱。

  • 调试保护:基于调试器特性,对当前运行环境进行检验,加入一些强制调试 debugger 语句,使其在调试模式下难以顺利执行 JavaScript 代码。

  • 多态变异:使 JavaScript 代码每次被调用时,将代码自身即立刻自动发生变异,变化为与之前完全不同的代码,即功能完全不变,只是代码形式变异,以此杜绝代码被动态分析调试。

  • 锁定域名:使 JavaScript 代码只能在指定域名下执行。

  • 反格式化:如果对 JavaScript 代码进行格式化,则无法执行,导致浏览器假死。

  • 特殊编码:将 JavaScript 完全编码为人不可读的代码,如表情符号、特殊表示内容等等。

总之,以上方案都是 JavaScript 混淆的实现方式,可以在不同程度上保护 JavaScript 代码。

在前端开发中,现在 JavaScript 混淆主流的实现是 javascript-obfuscator (https://github.com/javascript-obfuscator/javascript-obfuscator) 和 terser (https://github.com/terser/terser) 这两个库,其都能提供一些代码混淆功能,也都有对应的 Webpack 和 Rollup 打包工具的插件,利用它们我们可以非常方便地实现页面的混淆,最终可以输出压缩和混淆后的 JavaScript 代码,使得 JavaScript 代码可读性大大降低。

下面我们以 javascript-obfuscator 为例来介绍一些代码混淆的实现,了解了实现,那么自然我们就对混淆的机理有了更加深刻的认识。

javascript-obfuscator 的官网地址为:https://obfuscator.io/,其官方介绍内容如下:

A free and efficient obfuscator for JavaScript (including ES2017). Make your code harder to copy and prevent people from stealing your work.

它是支持 ES8 的免费、高效的 JavaScript 混淆库,它可以使得你的 JavaScript 代码经过混淆后难以被复制、盗用,混淆后的代码具有和原来的代码一模一样的功能。

怎么使用呢?首先,我们需要安装好 Node.js 12.x 版本及以上,确保可以正常使用 npm 命令,具体的安装方式可以参考:https://setup.scrape.center/nodejs。

接着新建一个文件夹,比如 js-obfuscate,然后进入该文件夹,初始化工作空间:

1
npm init

这里会提示我们输入一些信息,创建一个 package.json 文件,这就完成了项目初始化了。

接下来我们来安装 javascript-obfuscator 这个库:

1
npm i -D javascript-obfuscator

稍等片刻,即可看到本地 js-obfuscate 文件夹下生成了一个 node_modules 文件夹,里面就包含了 javascript-obfuscator 这个库,这就说明安装成功了,文件夹结构如图所示:

image-20210612155500985

接下来我们就可以编写代码来实现一个混淆样例了,如新建一个 main.js 文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const code = `
let x = '1' + 1
console.log('x', x)
`;

const options = {
compact: false,
controlFlowFlattening: true,
};

const obfuscator = require("javascript-obfuscator");
function obfuscate(code, options) {
return obfuscator.obfuscate(code, options).getObfuscatedCode();
}
console.log(obfuscate(code, options));

在这里我们定义了两个变量,一个是 code,即需要被混淆的代码,另一个是混淆选项,是一个 Object。接下来我们引入了 javascript-obfuscator 这库,然后定义了一个方法,传入 code 和 options,来获取混淆后的代码,最后控制台输出混淆后的代码。

代码逻辑比较简单,我们来执行一下代码:

1
node main.js

输出结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var _0x53bf = ["log"];
(function (_0x1d84fe, _0x3aeda0) {
var _0x10a5a = function (_0x2f0a52) {
while (--_0x2f0a52) {
_0x1d84fe["push"](_0x1d84fe["shift"]());
}
};
_0x10a5a(++_0x3aeda0);
})(_0x53bf, 0x172);
var _0x480a = function (_0x4341e5, _0x5923b4) {
_0x4341e5 = _0x4341e5 - 0x0;
var _0xb3622e = _0x53bf[_0x4341e5];
return _0xb3622e;
};
let x = "1" + 0x1;
console[_0x480a("0x0")]("x", x);

看到了吧,那么简单的两行代码,被我们混淆成了这个样子,其实这里我们就是设定了一个「控制流平坦化」的选项。整体看来,代码的可读性大大降低,也大大加大了 JavaScript 调试的难度。

好,那么我们来跟着 javascript-obfuscator 走一遍,就能具体知道 JavaScript 混淆到底有多少方法了。

注意:由于这些例子中,调用 javascript-obfuscator 进行混淆的实现是一样的,所以下文的示例只说明 code 和 options 变量的修改,完整代码请自行补全。

代码压缩

这里 javascript-obfuscator 也提供了代码压缩的功能,使用其参数 compact 即可完成 JavaScript 代码的压缩,输出为一行内容。默认是 true,如果定义为 false,则混淆后的代码会分行显示。

示例如下:

1
2
3
4
5
6
7
const code = `
let x = '1' + 1
console.log('x', x)
`;
const options = {
compact: false,
};

这里我们先把代码压缩 compact 选项设置为 false,运行结果如下:

1
2
let x = "1" + 0x1;
console["log"]("x", x);

如果不设置 compact 或把 compact 设置为 true,结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var _0x151c = ["log"];
(function (_0x1ce384, _0x20a7c7) {
var _0x25fc92 = function (_0x188aec) {
while (--_0x188aec) {
_0x1ce384["push"](_0x1ce384["shift"]());
}
};
_0x25fc92(++_0x20a7c7);
})(_0x151c, 0x1b7);
var _0x553e = function (_0x259219, _0x241445) {
_0x259219 = _0x259219 - 0x0;
var _0x56d72d = _0x151c[_0x259219];
return _0x56d72d;
};
let x = "1" + 0x1;
console[_0x553e("0x0")]("x", x);

可以看到单行显示的时候,对变量名进行了进一步的混淆,这里变量的命名都变成了 16 进制形式的字符串,这是因为启用了一些默认压缩和混淆配置导致的。总之我们可以看到代码的可读性相比之前大大降低了。

变量名混淆

变量名混淆可以通过在 javascript-obfuscator 中配置 identifierNamesGenerator 参数实现,我们通过这个参数可以控制变量名混淆的方式,如 hexadecimal 则会替换为 16 进制形式的字符串,在这里我们可以设定如下值:

  • hexadecimal:将变量名替换为 16 进制形式的字符串,如 0xabc123
  • mangled:将变量名替换为普通的简写字符,如 abc 等。

该参数的值默认为 hexadecimal。

我们将该参数修改为 mangled 来试一下:

1
2
3
4
5
6
7
8
const code = `
let hello = '1' + 1
console.log('hello', hello)
`;
const options = {
compact: true,
identifierNamesGenerator: "mangled",
};

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var a = ["hello"];
(function (c, d) {
var e = function (f) {
while (--f) {
c["push"](c["shift"]());
}
};
e(++d);
})(a, 0x9b);
var b = function (c, d) {
c = c - 0x0;
var e = a[c];
return e;
};
let hello = "1" + 0x1;
console["log"](b("0x0"), hello);

可以看到这里的变量命名都变成了 ab 等形式。

如果我们将 identifierNamesGenerator 修改为 hexadecimal 或者不设置,运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var _0x4e98 = ["log", "hello"];
(function (_0x4464de, _0x39de6c) {
var _0xdffdda = function (_0x6a95d5) {
while (--_0x6a95d5) {
_0x4464de["push"](_0x4464de["shift"]());
}
};
_0xdffdda(++_0x39de6c);
})(_0x4e98, 0xc8);
var _0x53cb = function (_0x393bda, _0x8504e7) {
_0x393bda = _0x393bda - 0x0;
var _0x46ab80 = _0x4e98[_0x393bda];
return _0x46ab80;
};
let hello = "1" + 0x1;
console[_0x53cb("0x0")](_0x53cb("0x1"), hello);

可以看到选用了 mangled,其代码体积会更小,但 hexadecimal 其可读性会更低。

另外我们还可以通过设置 identifiersPrefix 参数来控制混淆后的变量前缀,示例如下:

1
2
3
4
5
6
7
const code = `
let hello = '1' + 1
console.log('hello', hello)
`;
const options = {
identifiersPrefix: "germey",
};

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var germey_0x3dea = ["log", "hello"];
(function (_0x348ff3, _0x5330e8) {
var _0x1568b1 = function (_0x4740d8) {
while (--_0x4740d8) {
_0x348ff3["push"](_0x348ff3["shift"]());
}
};
_0x1568b1(++_0x5330e8);
})(germey_0x3dea, 0x94);
var germey_0x30e4 = function (_0x2e8f7c, _0x1066a8) {
_0x2e8f7c = _0x2e8f7c - 0x0;
var _0x5166ba = germey_0x3dea[_0x2e8f7c];
return _0x5166ba;
};
let hello = "1" + 0x1;
console[germey_0x30e4("0x0")](germey_0x30e4("0x1"), hello);

可以看到混淆后的变量前缀加上了我们自定义的字符串 germey。

另外 renameGlobals 这个参数还可以指定是否混淆全局变量和函数名称,默认为 false。示例如下:

1
2
3
4
5
6
7
8
const code = `
var $ = function(id) {
return document.getElementById(id);
};
`;
const options = {
renameGlobals: true,
};

运行结果如下:

1
2
3
var _0x4864b0 = function (_0x5763be) {
return document["getElementById"](_0x5763be);
};

可以看到这里我们声明了一个全局变量 这个变量也被替换了。如果后文用到了这个 $ 对象,可能就会有找不到定义的错误,因此这个参数可能导致代码执行不通。

如果我们不设置 renameGlobals 或者设置为 false,结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var _0x239a = ["getElementById"];
(function (_0x3f45a3, _0x583dfa) {
var _0x2cade2 = function (_0x28479a) {
while (--_0x28479a) {
_0x3f45a3["push"](_0x3f45a3["shift"]());
}
};
_0x2cade2(++_0x583dfa);
})(_0x239a, 0xe1);
var _0x3758 = function (_0x18659d, _0x50c21d) {
_0x18659d = _0x18659d - 0x0;
var _0x531b8d = _0x239a[_0x18659d];
return _0x531b8d;
};
var $ = function (_0x3d8723) {
return document[_0x3758("0x0")](_0x3d8723);
};

可以看到,最后还是有 $ 的声明,其全局名称没有被改变。

字符串混淆

字符串混淆,即将一个字符串声明放到一个数组里面,使之无法被直接搜索到。我们可以通过控制 stringArray 参数来控制,默认为 true。

我们还可以通过 rotateStringArray 参数来控制数组化后结果的的元素顺序,默认为 true。还可以通过 stringArrayEncoding 参数来控制数组的编码形式,默认不开启编码,如果设置为 true 或 base64,则会使用 Base64 编码,如果设置为 rc4,则使用 RC4 编码。另外可以通过 stringArrayThreshold 来控制启用编码的概率,范围 0 到 1,默认 0.8。

示例如下:

1
2
3
4
5
6
7
8
9
const code = `
var a = 'hello world'
`;
const options = {
stringArray: true,
rotateStringArray: true,
stringArrayEncoding: true, // 'base64' 或 'rc4' 或 false
stringArrayThreshold: 1,
};

运行结果如下:

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
79
80
var _0x4215 = ["aGVsbG8gd29ybGQ="];
(function (_0x42bf17, _0x4c348f) {
var _0x328832 = function (_0x355be1) {
while (--_0x355be1) {
_0x42bf17["push"](_0x42bf17["shift"]());
}
};
_0x328832(++_0x4c348f);
})(_0x4215, 0x1da);
var _0x5191 = function (_0x3cf2ba, _0x1917d8) {
_0x3cf2ba = _0x3cf2ba - 0x0;
var _0x1f93f0 = _0x4215[_0x3cf2ba];
if (_0x5191["LqbVDH"] === undefined) {
(function () {
var _0x5096b2;
try {
var _0x282db1 = Function(
"return\x20(function()\x20" +
"{}.constructor(\x22return\x20this\x22)(\x20)" +
");"
);
_0x5096b2 = _0x282db1();
} catch (_0x2acb9c) {
_0x5096b2 = window;
}
var _0x388c14 =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
_0x5096b2["atob"] ||
(_0x5096b2["atob"] = function (_0x4cc27c) {
var _0x2af4ae = String(_0x4cc27c)["replace"](/=+$/, "");
for (
var _0x21400b = 0x0,
_0x3f4e2e,
_0x5b193b,
_0x233381 = 0x0,
_0x3dccf7 = "";
(_0x5b193b = _0x2af4ae["charAt"](_0x233381++));
~_0x5b193b &&
((_0x3f4e2e =
_0x21400b % 0x4 ? _0x3f4e2e * 0x40 + _0x5b193b : _0x5b193b),
_0x21400b++ % 0x4)
? (_0x3dccf7 += String["fromCharCode"](
0xff & (_0x3f4e2e >> ((-0x2 * _0x21400b) & 0x6))
))
: 0x0
) {
_0x5b193b = _0x388c14["indexOf"](_0x5b193b);
}
return _0x3dccf7;
});
})();
_0x5191["DuIurT"] = function (_0x51888e) {
var _0x29801f = atob(_0x51888e);
var _0x561e62 = [];
for (
var _0x5dd788 = 0x0, _0x1a8b73 = _0x29801f["length"];
_0x5dd788 < _0x1a8b73;
_0x5dd788++
) {
_0x561e62 +=
"%" +
("00" + _0x29801f["charCodeAt"](_0x5dd788)["toString"](0x10))[
"slice"
](-0x2);
}
return decodeURIComponent(_0x561e62);
};
_0x5191["mgoBRd"] = {};
_0x5191["LqbVDH"] = !![];
}
var _0x1741f0 = _0x5191["mgoBRd"][_0x3cf2ba];
if (_0x1741f0 === undefined) {
_0x1f93f0 = _0x5191["DuIurT"](_0x1f93f0);
_0x5191["mgoBRd"][_0x3cf2ba] = _0x1f93f0;
} else {
_0x1f93f0 = _0x1741f0;
}
return _0x1f93f0;
};
var a = _0x5191("0x0");

可以看到这里就把字符串进行了 Base64 编码,我们再也无法通过查找的方式找到字符串的位置了。

如果将 stringArray 设置为 false 的话,输出就是这样:

1
var a = "hello\x20world";

字符串就仍然是明文显示的,没有被编码。

另外我们还可以使用 unicodeEscapeSequence 这个参数对字符串进行 Unicode 转码,使之更加难以辨认,示例如下:

1
2
3
4
5
6
7
const code = `
var a = 'hello world'
`;
const options = {
compact: false,
unicodeEscapeSequence: true,
};

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var _0x5c0d = ["\x68\x65\x6c\x6c\x6f\x20\x77\x6f\x72\x6c\x64"];
(function (_0x54cc9c, _0x57a3b2) {
var _0xf833cf = function (_0x3cd8c6) {
while (--_0x3cd8c6) {
_0x54cc9c["push"](_0x54cc9c["shift"]());
}
};
_0xf833cf(++_0x57a3b2);
})(_0x5c0d, 0x17d);
var _0x28e8 = function (_0x3fd645, _0x2cf5e7) {
_0x3fd645 = _0x3fd645 - 0x0;
var _0x298a20 = _0x5c0d[_0x3fd645];
return _0x298a20;
};
var a = _0x28e8("0x0");

可以看到,这里字符串被数字化和 Unicode 化,非常难以辨认。

在很多 JavaScript 逆向的过程中,一些关键的字符串可能会作为切入点来查找加密入口。用了这种混淆之后,如果有人想通过全局搜索的方式搜索 hello 这样的字符串找加密入口,也没法搜到了。

代码自我保护

我们可以通过设置 selfDefending 参数来开启代码自我保护功能。开启之后,混淆后的 JavaScript 会以强制一行形式显示,如果我们将混淆后的代码进行格式化或者重命名,该段代码将无法执行。

示例如下:

1
2
3
4
5
6
const code = `
console.log('hello world')
`;
const options = {
selfDefending: true,
};

运行结果如下:

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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
var _0x26da = ["log", "hello\x20world"];
(function (_0x190327, _0x57c2c0) {
var _0x577762 = function (_0xc9dabb) {
while (--_0xc9dabb) {
_0x190327["push"](_0x190327["shift"]());
}
};
var _0x35976e = function () {
var _0x16b3fe = {
data: { key: "cookie", value: "timeout" },
setCookie: function (_0x2d52d5, _0x16feda, _0x57cadf, _0x56056f) {
_0x56056f = _0x56056f || {};
var _0x5b6dc3 = _0x16feda + "=" + _0x57cadf;
var _0x333ced = 0x0;
for (
var _0x333ced = 0x0, _0x19ae36 = _0x2d52d5["length"];
_0x333ced < _0x19ae36;
_0x333ced++
) {
var _0x409587 = _0x2d52d5[_0x333ced];
_0x5b6dc3 += ";\x20" + _0x409587;
var _0x4aa006 = _0x2d52d5[_0x409587];
_0x2d52d5["push"](_0x4aa006);
_0x19ae36 = _0x2d52d5["length"];
if (_0x4aa006 !== !![]) {
_0x5b6dc3 += "=" + _0x4aa006;
}
}
_0x56056f["cookie"] = _0x5b6dc3;
},
removeCookie: function () {
return "dev";
},
getCookie: function (_0x30c497, _0x51923d) {
_0x30c497 =
_0x30c497 ||
function (_0x4b7e18) {
return _0x4b7e18;
};
var _0x557e06 = _0x30c497(
new RegExp(
"(?:^|;\x20)" +
_0x51923d["replace"](/([.$?*|{}()[]\/+^])/g, "$1") +
"=([^;]*)"
)
);
var _0x817646 = function (_0xf3fae7, _0x5d8208) {
_0xf3fae7(++_0x5d8208);
};
_0x817646(_0x577762, _0x57c2c0);
return _0x557e06 ? decodeURIComponent(_0x557e06[0x1]) : undefined;
},
};
var _0x4673cd = function () {
var _0x4c6c5c = new RegExp(
"\x5cw+\x20*\x5c(\x5c)\x20*{\x5cw+\x20*[\x27|\x22].+[\x27|\x22];?\x20*}"
);
return _0x4c6c5c["test"](_0x16b3fe["removeCookie"]["toString"]());
};
_0x16b3fe["updateCookie"] = _0x4673cd;
var _0x5baa80 = "";
var _0x1faf19 = _0x16b3fe["updateCookie"]();
if (!_0x1faf19) {
_0x16b3fe["setCookie"](["*"], "counter", 0x1);
} else if (_0x1faf19) {
_0x5baa80 = _0x16b3fe["getCookie"](null, "counter");
} else {
_0x16b3fe["removeCookie"]();
}
};
_0x35976e();
})(_0x26da, 0x140);
var _0x4391 = function (_0x1b42d8, _0x57edc8) {
_0x1b42d8 = _0x1b42d8 - 0x0;
var _0x2fbeca = _0x26da[_0x1b42d8];
return _0x2fbeca;
};
var _0x197926 = (function () {
var _0x10598f = !![];
return function (_0xffa3b3, _0x7a40f9) {
var _0x48e571 = _0x10598f
? function () {
if (_0x7a40f9) {
var _0x2194b5 = _0x7a40f9["apply"](_0xffa3b3, arguments);
_0x7a40f9 = null;
return _0x2194b5;
}
}
: function () {};
_0x10598f = ![];
return _0x48e571;
};
})();
var _0x2c6fd7 = _0x197926(this, function () {
var _0x4828bb = function () {
return "\x64\x65\x76";
},
_0x35c3bc = function () {
return "\x77\x69\x6e\x64\x6f\x77";
};
var _0x456070 = function () {
var _0x4576a4 = new RegExp(
"\x5c\x77\x2b\x20\x2a\x5c\x28\x5c\x29\x20\x2a\x7b\x5c\x77\x2b\x20\x2a\x5b\x27\x7c\x22\x5d\x2e\x2b\x5b\x27\x7c\x22\x5d\x3b\x3f\x20\x2a\x7d"
);
return !_0x4576a4["\x74\x65\x73\x74"](
_0x4828bb["\x74\x6f\x53\x74\x72\x69\x6e\x67"]()
);
};
var _0x3fde69 = function () {
var _0xabb6f4 = new RegExp(
"\x28\x5c\x5c\x5b\x78\x7c\x75\x5d\x28\x5c\x77\x29\x7b\x32\x2c\x34\x7d\x29\x2b"
);
return _0xabb6f4["\x74\x65\x73\x74"](
_0x35c3bc["\x74\x6f\x53\x74\x72\x69\x6e\x67"]()
);
};
var _0x2d9a50 = function (_0x58fdb4) {
var _0x2a6361 = ~-0x1 >> (0x1 + (0xff % 0x0));
if (_0x58fdb4["\x69\x6e\x64\x65\x78\x4f\x66"]("\x69" === _0x2a6361)) {
_0xc388c5(_0x58fdb4);
}
};
var _0xc388c5 = function (_0x2073d6) {
var _0x6bb49f = ~-0x4 >> (0x1 + (0xff % 0x0));
if (
_0x2073d6["\x69\x6e\x64\x65\x78\x4f\x66"]((!![] + "")[0x3]) !== _0x6bb49f
) {
_0x2d9a50(_0x2073d6);
}
};
if (!_0x456070()) {
if (!_0x3fde69()) {
_0x2d9a50("\x69\x6e\x64\u0435\x78\x4f\x66");
} else {
_0x2d9a50("\x69\x6e\x64\x65\x78\x4f\x66");
}
} else {
_0x2d9a50("\x69\x6e\x64\u0435\x78\x4f\x66");
}
});
_0x2c6fd7();
console[_0x4391("0x0")](_0x4391("0x1"));

如果我们将上述代码放到控制台,它的执行结果和之前是一模一样的,没有任何问题。

如果我们将其进行格式化,然后贴到到浏览器控制台里面,浏览器会直接卡死无法运行。这样如果有人对代码进行了格式化,就无法正常对代码进行运行和调试,从而起到了保护作用。

控制流平坦化

控制流平坦化其实就是将代码的执行逻辑混淆,使其变得复杂难读。其基本思想是将一些逻辑处理块都统一加上一个前驱逻辑块,每个逻辑块都由前驱逻辑块进行条件判断和分发,构成一个个闭环逻辑,导致整个执行逻辑十分复杂难读。

比如说这里有一段示例代码:

1
2
3
console.log(c);
console.log(a);
console.log(b);

代码逻辑一目了然,依次在控制台输出了 c、a、b 三个变量的值,但如果把这段代码进行控制流平坦化处理后,代码就会变成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const s = "3|1|2".split("|");
let x = 0;
while (true) {
switch (s[x++]) {
case "1":
console.log(a);
continue;
case "2":
console.log(b);
continue;
case "3":
console.log(c);
continue;
}
break;
}

可以看到,混淆后的代码首先声明了一个变量 s,它的结果是一个列表,其实是 ["3", "1", "2"],然后下面通过 switch 语句对 s 中的元素进行了判断,每个 case 都加上了各自的代码逻辑。通过这样的处理,一些连续的执行逻辑就被打破了,代码被修改为一个 switch 语句,原本我们可以一眼看出的逻辑是控制台先输出 c,然后才是 a、b,但是现在我们必须要结合 switch 的判断条件和对应 case 的内容进行判断,我们很难再一眼每条语句的执行顺序了,这就大大降低了代码的可读性。

在 javascript-obfuscator 中我们通过 controlFlowFlattening 变量可以控制是否开启控制流平坦化,示例如下:

1
2
3
4
const options = {
compact: false,
controlFlowFlattening: true,
};

使用控制流平坦化可以使得执行逻辑更加复杂难读,目前非常多的前端混淆都会加上这个选项。但启用控制流平坦化之后,代码的执行时间会变长,最长达 1.5 倍之多。

另外我们还能使用 controlFlowFlatteningThreshold 这个参数来控制比例,取值范围是 0 到 1,默认 0.75,如果设置为 0,那相当于 controlFlowFlattening 设置为 false,即不开启控制流扁平化 。

无用代码注入

无用代码即不会被执行的代码或对上下文没有任何影响的代码,注入之后可以对现有的 JavaScript 代码的阅读形成干扰。我们可以使用 deadCodeInjection 参数开启这个选项,默认为 false。

比如这里有一段代码:

1
2
3
4
5
6
7
8
9
10
const a = function () {
console.log("hello world");
};

const b = function () {
console.log("nice to meet you");
};

a();
b();

这里就声明了方法 a 和 b,然后依次进行调用,分别输出两句话。

但经过无用代码注入处理之后,代码就会变成类似这样的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const _0x16c18d = function () {
if (!![[]]) {
console.log("hello world");
} else {
console.log("this");
console.log("is");
console.log("dead");
console.log("code");
}
};
const _0x1f7292 = function () {
if ("xmv2nOdfy2N".charAt(4) !== String.fromCharCode(110)) {
console.log("this");
console.log("is");
console.log("dead");
console.log("code");
} else {
console.log("nice to meet you");
}
};

_0x16c18d();
_0x1f7292();

可以看到,每个方法内部都增加了额外的 if else 语句,其中 if 的判断条件还是一个表达式,其结果是 true 还是 false 我们还不太一眼能看出来,比如说 _0x1f7292 这个方法,它的 if 判断条件是:

1
"xmv2nOdfy2N".charAt(4) !== String.fromCharCode(110)

在不等号前面其实是从字符串中取出指定位置的字符,不等号后面则调用了 fromCharCode 方法来根据 ascii 码转换得到一个字符,然后比较两个字符的结果是否是不一样的。前者经过我们推算可以知道结果是 n,但对于后者,多数情况下我们还得去查一下 ascii 码表才能知道其结果也是 n,最后两个结果是相同的,所以整个表达式的结果是 false,所以 if 后面跟的逻辑实际上就是不会被执行到的无用代码,但这些代码对我们阅读代码起到了一定的干扰作用。

因此,这种混淆方式通过混入一些特殊的判断条件并加入一些不会被执行的代码,可以对代码起到一定的混淆干扰作用。

在 javascript-obfuscator 中,我们可以通过 deadCodeInjection 参数控制无用代码的注入,配置如下:

1
2
3
4
const options = {
compact: false,
deadCodeInjection: true,
};

另外我们还可以通过设置 deadCodeInjectionThreshold 参数来控制无用代码注入的比例,取值 0 到 1,默认是 0.4。

对象键名替换

如果是一个对象,可以使用 transformObjectKeys 来对对象的键值进行替换,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const code = `
(function(){
var object = {
foo: 'test1',
bar: {
baz: 'test2'
}
};
})();
`;
const options = {
compact: false,
transformObjectKeys: true,
};

输出结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var _0x7a5d = ["bar", "test2", "test1"];
(function (_0x59fec5, _0x2e4fac) {
var _0x231e7a = function (_0x46f33e) {
while (--_0x46f33e) {
_0x59fec5["push"](_0x59fec5["shift"]());
}
};
_0x231e7a(++_0x2e4fac);
})(_0x7a5d, 0x167);
var _0x3bc4 = function (_0x309ad3, _0x22d5ac) {
_0x309ad3 = _0x309ad3 - 0x0;
var _0x3a034e = _0x7a5d[_0x309ad3];
return _0x3a034e;
};
(function () {
var _0x9f1fd1 = {};
_0x9f1fd1["foo"] = _0x3bc4("0x0");
_0x9f1fd1[_0x3bc4("0x1")] = {};
_0x9f1fd1[_0x3bc4("0x1")]["baz"] = _0x3bc4("0x2");
})();

可以看到,Object 的变量名被替换为了特殊的变量,使得可读性变差,这样我们就不好直接通过变量名进行搜寻了,这也可以起到一定的防护作用。

禁用控制台输出

可以使用 disableConsoleOutput 来禁用掉 console.log 输出功能,加大调试难度,示例如下:

1
2
3
4
5
6
const code = `
console.log('hello world')
`;
const options = {
disableConsoleOutput: true,
};

运行结果如下:

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
var _0x3a39 = [
"debug",
"info",
"error",
"exception",
"trace",
"hello\x20world",
"apply",
"{}.constructor(\x22return\x20this\x22)(\x20)",
"console",
"log",
"warn",
];
(function (_0x2a157a, _0x5d9d3b) {
var _0x488e2c = function (_0x5bcb73) {
while (--_0x5bcb73) {
_0x2a157a["push"](_0x2a157a["shift"]());
}
};
_0x488e2c(++_0x5d9d3b);
})(_0x3a39, 0x10e);
var _0x5bff = function (_0x43bdfc, _0x52e4c6) {
_0x43bdfc = _0x43bdfc - 0x0;
var _0xb67384 = _0x3a39[_0x43bdfc];
return _0xb67384;
};
var _0x349b01 = (function () {
var _0x1f484b = !![];
return function (_0x5efe0d, _0x33db62) {
var _0x20bcd2 = _0x1f484b
? function () {
if (_0x33db62) {
var _0x77054c = _0x33db62[_0x5bff("0x0")](_0x5efe0d, arguments);
_0x33db62 = null;
return _0x77054c;
}
}
: function () {};
_0x1f484b = ![];
return _0x20bcd2;
};
})();
var _0x19f538 = _0x349b01(this, function () {
var _0x7ab6e4 = function () {};
var _0x157bff;
try {
var _0x5e672c = Function(
"return\x20(function()\x20" + _0x5bff("0x1") + ");"
);
_0x157bff = _0x5e672c();
} catch (_0x11028d) {
_0x157bff = window;
}
if (!_0x157bff[_0x5bff("0x2")]) {
_0x157bff[_0x5bff("0x2")] = (function (_0x7ab6e4) {
var _0x5a8d9e = {};
_0x5a8d9e[_0x5bff("0x3")] = _0x7ab6e4;
_0x5a8d9e[_0x5bff("0x4")] = _0x7ab6e4;
_0x5a8d9e[_0x5bff("0x5")] = _0x7ab6e4;
_0x5a8d9e[_0x5bff("0x6")] = _0x7ab6e4;
_0x5a8d9e[_0x5bff("0x7")] = _0x7ab6e4;
_0x5a8d9e[_0x5bff("0x8")] = _0x7ab6e4;
_0x5a8d9e[_0x5bff("0x9")] = _0x7ab6e4;
return _0x5a8d9e;
})(_0x7ab6e4);
} else {
_0x157bff[_0x5bff("0x2")][_0x5bff("0x3")] = _0x7ab6e4;
_0x157bff[_0x5bff("0x2")][_0x5bff("0x4")] = _0x7ab6e4;
_0x157bff[_0x5bff("0x2")]["debug"] = _0x7ab6e4;
_0x157bff[_0x5bff("0x2")][_0x5bff("0x6")] = _0x7ab6e4;
_0x157bff[_0x5bff("0x2")][_0x5bff("0x7")] = _0x7ab6e4;
_0x157bff[_0x5bff("0x2")][_0x5bff("0x8")] = _0x7ab6e4;
_0x157bff[_0x5bff("0x2")][_0x5bff("0x9")] = _0x7ab6e4;
}
});
_0x19f538();
console[_0x5bff("0x3")](_0x5bff("0xa"));

此时,我们如果执行这个代码,发现是没有任何输出的,这里实际上就是将 console 的一些功能禁用了。

调试保护

我们知道,在 JavaScript 代码中如果加入 debugger 这个关键字,那么在执行到该位置的时候控制它就会进入断点调试模式。如果在代码多个位置都加入 debugger 这个关键字,或者定义某个逻辑来反复执行 debugger,那就会不断进入断点调试模式,原本的代码无法就无法顺畅地执行了。这个过程可以称为调试保护,即通过反复执行 debugger 来使得原来的代码无法顺畅执行。

其效果类似于执行了如下代码:

1
2
3
setInterval(() => {
debugger;
}, 3000);

如果我们把这段代码粘贴到控制台,它就会反复地执行 debugger 语句进入断点调试模式,从而干扰正常的调试流程。

在 javascript-obfuscator 中可以使用 debugProtection 来启用调试保护机制,还可以使用 debugProtectionInterval 来启用无限 Debug ,使得代码在调试过程中会不断进入断点模式,无法顺畅执行,配置如下:

1
2
3
4
const options = {
debugProtection: true,
debugProtectionInterval: true,
};

混淆后的代码会不断跳到 debugger 代码的位置,使得整个代码无法顺畅执行,对 JavaScript 代码的调试形成一定的干扰。

域名锁定

我们还可以通过控制 domainLock 来控制 JavaScript 代码只能在特定域名下运行,这样就可以降低代码被模拟或盗用的风险。

示例如下:

1
2
3
4
5
6
const code = `
console.log('hello world')
`;
const options = {
domainLock: ["cuiqingcai.com"],
};

这里我们使用了 domainLock 指定了一个域名叫做 cuiqingcai.com,也就是设置了一个域名白名单,混淆后的代码结果如下:

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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
var _0x3203 = [
"apply",
"return\x20(function()\x20",
"{}.constructor(\x22return\x20this\x22)(\x20)",
"item",
"attribute",
"value",
"replace",
"length",
"charCodeAt",
"log",
"hello\x20world",
];
(function (_0x2ed22c, _0x3ad370) {
var _0x49dc54 = function (_0x53a786) {
while (--_0x53a786) {
_0x2ed22c["push"](_0x2ed22c["shift"]());
}
};
_0x49dc54(++_0x3ad370);
})(_0x3203, 0x155);
var _0x5b38 = function (_0xd7780b, _0x19c0f2) {
_0xd7780b = _0xd7780b - 0x0;
var _0x2d2f44 = _0x3203[_0xd7780b];
return _0x2d2f44;
};
var _0x485919 = (function () {
var _0x5cf798 = !![];
return function (_0xd1fa29, _0x2ed646) {
var _0x56abf = _0x5cf798
? function () {
if (_0x2ed646) {
var _0x33af63 = _0x2ed646[_0x5b38("0x0")](_0xd1fa29, arguments);
_0x2ed646 = null;
return _0x33af63;
}
}
: function () {};
_0x5cf798 = ![];
return _0x56abf;
};
})();
var _0x67dcc8 = _0x485919(this, function () {
var _0x276a31;
try {
var _0x5c8be2 = Function(_0x5b38("0x1") + _0x5b38("0x2") + ");");
_0x276a31 = _0x5c8be2();
} catch (_0x5f1c00) {
_0x276a31 = window;
}
var _0x254a0d = function () {
return {
key: _0x5b38("0x3"),
value: _0x5b38("0x4"),
getAttribute: (function () {
for (var _0x5cc3c7 = 0x0; _0x5cc3c7 < 0x3e8; _0x5cc3c7--) {
var _0x35b30b = _0x5cc3c7 > 0x0;
switch (_0x35b30b) {
case !![]:
return (
this[_0x5b38("0x3")] +
"_" +
this[_0x5b38("0x5")] +
"_" +
_0x5cc3c7
);
default:
this[_0x5b38("0x3")] + "_" + this[_0x5b38("0x5")];
}
}
})(),
};
};
var _0x3b375a = new RegExp("[QLCIKYkCFzdWpzRAXMhxJOYpTpYWJHPll]", "g");
var _0x5a94d2 = "cuQLiqiCInKYkgCFzdWcpzRAaXMi.hcoxmJOYpTpYWJHPll"
[_0x5b38("0x6")](_0x3b375a, "")
["split"](";");
var _0x5c0da2;
var _0x19ad5d;
var _0x5992ca;
var _0x40bd39;
for (var _0x5cad1 in _0x276a31) {
if (
_0x5cad1[_0x5b38("0x7")] == 0x8 &&
_0x5cad1[_0x5b38("0x8")](0x7) == 0x74 &&
_0x5cad1[_0x5b38("0x8")](0x5) == 0x65 &&
_0x5cad1[_0x5b38("0x8")](0x3) == 0x75 &&
_0x5cad1[_0x5b38("0x8")](0x0) == 0x64
) {
_0x5c0da2 = _0x5cad1;
break;
}
}
for (var _0x29551 in _0x276a31[_0x5c0da2]) {
if (
_0x29551[_0x5b38("0x7")] == 0x6 &&
_0x29551[_0x5b38("0x8")](0x5) == 0x6e &&
_0x29551[_0x5b38("0x8")](0x0) == 0x64
) {
_0x19ad5d = _0x29551;
break;
}
}
if (!("~" > _0x19ad5d)) {
for (var _0x2b71bd in _0x276a31[_0x5c0da2]) {
if (
_0x2b71bd[_0x5b38("0x7")] == 0x8 &&
_0x2b71bd[_0x5b38("0x8")](0x7) == 0x6e &&
_0x2b71bd[_0x5b38("0x8")](0x0) == 0x6c
) {
_0x5992ca = _0x2b71bd;
break;
}
}
for (var _0x397f55 in _0x276a31[_0x5c0da2][_0x5992ca]) {
if (
_0x397f55["length"] == 0x8 &&
_0x397f55[_0x5b38("0x8")](0x7) == 0x65 &&
_0x397f55[_0x5b38("0x8")](0x0) == 0x68
) {
_0x40bd39 = _0x397f55;
break;
}
}
}
if (!_0x5c0da2 || !_0x276a31[_0x5c0da2]) {
return;
}
var _0x5f19be = _0x276a31[_0x5c0da2][_0x19ad5d];
var _0x674f76 =
!!_0x276a31[_0x5c0da2][_0x5992ca] &&
_0x276a31[_0x5c0da2][_0x5992ca][_0x40bd39];
var _0x5e1b34 = _0x5f19be || _0x674f76;
if (!_0x5e1b34) {
return;
}
var _0x593394 = ![];
for (var _0x479239 = 0x0; _0x479239 < _0x5a94d2["length"]; _0x479239++) {
var _0x19ad5d = _0x5a94d2[_0x479239];
var _0x112c24 = _0x5e1b34["length"] - _0x19ad5d["length"];
var _0x51731c = _0x5e1b34["indexOf"](_0x19ad5d, _0x112c24);
var _0x173191 = _0x51731c !== -0x1 && _0x51731c === _0x112c24;
if (_0x173191) {
if (
_0x5e1b34["length"] == _0x19ad5d[_0x5b38("0x7")] ||
_0x19ad5d["indexOf"](".") === 0x0
) {
_0x593394 = !![];
}
}
}
if (!_0x593394) {
data;
} else {
return;
}
_0x254a0d();
});
_0x67dcc8();
console[_0x5b38("0x9")](_0x5b38("0xa"));

这段代码就只能在指定域名 cuiqingcai.com 下运行,不能在其他网站运行。这样的话,如果一些相关 JavaScript 代码被单独剥离出来,想在其他网站运行或者使用程序模拟运行的话,运行结果只有是失败,这样就可以有效降低被代码被模拟或盗用的风险。

特殊编码

另外还有一些特殊的工具包,如使用 aaencode、jjencode、jsfuck 等工具对代码进行混淆和编码。

示例如下:

1
var a = 1

jsfuck 的结果:

1
2
3
[][(![]+[])[!+[]+!![]+!![]]+([]+{})[+!![]]+(!![]+[])[+!![]]+(!![]+[])[+[]]][([]+{})[!+[]+!![]+!![]+!![]+!![]]+([]+{})[+!![]]+([][[]]+[])[+!![]]+(![]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+[]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(!![]+[])[+[]]+([]+{})[+!![]]+(!![]+[])[+!![]]]([][(![]+[])[!+[]+!![]+!![]]+([]+{})[+!![]]+(!![]+[])[+!![]]+(!![]+[])[+[]]][([]+{})[!+[]+!![]+!![]+!![]+!![]]+([]+{})[+!![]]+([][[]]+[])[+!![]]+
...
([]+{})[+!![]]+(!![]+[])[+!![]]]((!![]+[])[+!![]]+([][[]]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+([][[]]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]+!![]+!![]]+(![]+[])[!+[]+!![]]+([]+{})[+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(+{}+[])[+!![]]+(!![]+[])[+[]]+([][[]]+[])[!+[]+!![]+!![]+!![]+!![]]+([]+{})[+!![]]+([][[]]+[])[+!![]])(!+[]+!![]+!![]+!![]+!![]))[!+[]+!![]+!![]]+([][[]]+[])[!+[]+!![]+!![]])(!+[]+!![]+!![]+!![]+!![])(([]+{})[+[]])[+[]]+(!+[]+!![]+!![]+[])+([][[]]+[])[!+[]+!![]])+([]+{})[!+[]+!![]+!![]+!![]+!![]+!![]+!![]]+(+!![]+[]))(!+[]+!![]+!![]+!![]+!![]+!![]+!![]+!![])

aaencode 的结果:

1
゚ω゚ノ= /`m´)ノ ~┻━┻   / ['_']; o=(゚ー゚)  =_=3; c=(゚Θ゚) =(゚ー゚)-(゚ー゚); (゚Д゚) =(゚Θ゚)= (o^_^o)/ (o^_^o);(゚Д゚)={゚Θ゚: '_' ,゚ω゚ノ : ((゚ω゚ノ==3) +'_') [゚Θ゚] ,゚ー゚ノ :(゚ω゚ノ+ '_')[o^_^o -(゚Θ゚)] ,゚Д゚ノ:((゚ー゚==3) +'_')[゚ー゚] }; (゚Д゚) [゚Θ゚] =((゚ω゚ノ==3) +'_') [c^_^o];(゚Д゚) ['c'] = ((゚Д゚)+'_') [ (゚ー゚)+(゚ー゚)-(゚Θ゚) ];(゚Д゚) ['o'] = ((゚Д゚)+'_') [゚Θ゚];(゚o゚)=(゚Д゚) ['c']+(゚Д゚) ['o']+(゚ω゚ノ +'_')[゚Θ゚]+ ((゚ω゚ノ==3) +'_') [゚ー゚] + ((゚Д゚) +'_') [(゚ー゚)+(゚ー゚)]+ ((゚ー゚==3) +'_') [゚Θ゚]+((゚ー゚==3) +'_') [(゚ー゚) - (゚Θ゚)]+(゚Д゚) ['c']+((゚Д゚)+'_') [(゚ー゚)+(゚ー゚)]+ (゚Д゚) ['o']+((゚ー゚==3) +'_') [゚Θ゚];(゚Д゚) ['_'] =(o^_^o) [゚o゚] [゚o゚];(゚ε゚)=((゚ー゚==3) +'_') [゚Θ゚]+ (゚Д゚) .゚Д゚ノ+((゚Д゚)+'_') [(゚ー゚) + (゚ー゚)]+((゚ー゚==3) +'_') [o^_^o -゚Θ゚]+((゚ー゚==3) +'_') [゚Θ゚]+ (゚ω゚ノ +'_') [゚Θ゚]; (゚ー゚)+=(゚Θ゚); (゚Д゚)[゚ε゚]='\\'; (゚Д゚).゚Θ゚ノ=(゚Д゚+ ゚ー゚)[o^_^o -(゚Θ゚)];(o゚ー゚o)=(゚ω゚ノ +'_')[c^_^o];(゚Д゚) [゚o゚]='\"';(゚Д゚) ['_'] ( (゚Д゚) ['_'] (゚ε゚+(゚Д゚)[゚o゚]+ (゚Д゚)[゚ε゚]+(゚Θ゚)+ ((o^_^o) +(o^_^o))+ ((o^_^o) +(o^_^o))+ (゚Д゚)[゚ε゚]+(゚Θ゚)+ (゚ー゚)+ (゚Θ゚)+ (゚Д゚)[゚ε゚]+(゚Θ゚)+ ((o^_^o) +(o^_^o))+ ((o^_^o) - (゚Θ゚))+ (゚Д゚)[゚ε゚]+(゚ー゚)+ (c^_^o)+ (゚Д゚)[゚ε゚]+(゚Θ゚)+ (゚ー゚)+ (゚Θ゚)+ (゚Д゚)[゚ε゚]+(゚ー゚)+ (c^_^o)+ (゚Д゚)[゚ε゚]+((゚ー゚) + (o^_^o))+ ((゚ー゚) + (゚Θ゚))+ (゚Д゚)[゚ε゚]+(゚ー゚)+ (c^_^o)+ (゚Д゚)[゚ε゚]+((o^_^o) +(o^_^o))+ (゚Θ゚)+ (゚Д゚)[゚o゚])(゚Θ゚))((゚Θ゚)+(゚Д゚)[゚ε゚]+((゚ー゚)+(゚Θ゚))+(゚Θ゚)+(゚Д゚)[゚o゚]);

jjencode 的结果:

1
$=~[];$={___:++$,$$$$:(![]+"")[$],__$:++$,$_$_:(![]+"")[$],_$_:++$,$_$$:({}+"")[$],$$_$:($[$]+"")[$],_$$:++$,$$$_:(!""+"")[$],$__:++$,$_$:++$,$$__:({}+"")[$],$$_:++$,$$$:++$,$___:++$,$__$:++$};$.$_=($.$_=$+"")[$.$_$]+($._$=$.$_[$.__$])+($.$$=($.$+"")[$.__$])+((!$)+"")[$._$$]+($.__=$.$_[$.$$_])+($.$=(!""+"")[$.__$])+($._=(!""+"")[$._$_])+$.$_[$.$_$]+$.__+$._$+$.$;$.$$=$.$+(!""+"")[$._$$]+$.__+$._+$.$+$.$$;$.$=($.___)[$.$_][$.$_];$.$($.$($.$$+"\""+"\\"+$.__$+$.$$_+$.$$_+$.$_$_+"\\"+$.__$+$.$$_+$._$_+"\\"+$.$__+$.___+$.$_$_+"\\"+$.$__+$.___+"=\\"+$.$__+$.___+$.__$+"\"")())();

可以看到,通过这些工具,原本非常简单的代码被转化为一些几乎完全不可读的代码,但实际上运行效果还是相同的。这些混淆方式比较另类,看起来虽然没有什么头绪,但实际上找到规律是非常好还原的,其没有真正达到强力混淆的效果。

以上便是对 JavaScript 混淆方式的介绍和总结。总的来说,经过混淆的 JavaScript 代码其可读性大大降低,同时防护效果也大大增强。

6. WebAssembly

随着技术的发展,WebAssembly 逐渐流行起来。不同于 JavaScript 混淆技术, WebAssembly 其基本思路是将一些核心逻辑使用其他语言(如 C/C++ 语言)来编写,并编译成类似字节码的文件,并通过 JavaScript 调用执行,从而起到二进制级别的防护作用。

WebAssembly 是一种可以使用非 JavaScript 编程语言编写代码并且能在浏览器上运行的技术方案,比如借助于我们能将 C/C++ 利用 Emscripten 编译工具转成 wasm 格式的文件, JavaScript 可以直接调用该文件执行其中的方法。

WebAssembly 是经过编译器编译之后的字节码,可以从 C/C++ 编译而来,得到的字节码具有和 JavaScript 相同的功能,运行速度更快,体积更小,而且在语法上完全脱离 JavaScript,同时具有沙盒化的执行环境。

比如这就是一个基本的 WebAssembly 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
WebAssembly.compile(
new Uint8Array(
`
00 61 73 6d 01 00 00 00 01 0c 02 60 02 7f 7f 01
7f 60 01 7f 01 7f 03 03 02 00 01 07 10 02 03 61
64 64 00 00 06 73 71 75 61 72 65 00 01 0a 13 02
08 00 20 00 20 01 6a 0f 0b 08 00 20 00 20 00 6c
0f 0b`
.trim()
.split(/[\s\r\n]+/g)
.map((str) => parseInt(str, 16))
)
).then((module) => {
const instance = new WebAssembly.Instance(module);
const { add, square } = instance.exports;
console.log("2 + 4 =", add(2, 4));
console.log("3^2 =", square(3));
console.log("(2 + 5)^2 =", square(add(2 + 5)));
});

这里其实是利用 WebAssembly 定义了两个方法,分别是 add 和 square,可以分别用于求和和开平方计算。那这两个方法在哪里声明的呢?其实它们被隐藏在了一个 Uint8Array 里面,仅仅查看明文代码我们确实无从知晓里面究竟定义了什么逻辑,但确实是可以执行的,我们将这段代码输入到浏览器控制台下,运行结果如下:

1
2
3
2 + 4 = 6
3^2 = 9
(2 + 5)^2 = 49

由此可见,通过 WebAssembly 我们可以成功将核心逻辑“隐藏”起来,这样某些核心逻辑就不能被轻易找出来了。

所以,很多网站越来越多使用 WebAssembly 技术来保护一些核心逻辑不被轻易被人识别或破解,可以起到更好的防护效果。

7. 总结

以上,我们就介绍了接口加密技术和 JavaScript 的压缩、混淆技术,也对 WebAssembly 技术有了初步的了解,知己知彼方能百战不殆,了解了原理,我们才能更好地去实现 JavaScript 的逆向。

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

由于本节涉及一些专业名词,部分内容参考来源如下:

Python

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

前面一节我们了解了 JavaScript 的压缩、混淆等技术,现在越来越多的网站也已经应用了这些技术对其数据接口进行了保护,在做爬虫时如果我们遇到了这种情况,我们可能就不得不硬着头皮来去想方设法找出其中隐含的关键逻辑了,这个过程我们可以称之为 JavaScript 逆向。

既然我们要做 JavaScript 逆向,那少不了要用到浏览器的开发者工具,因为网页是在浏览器中加载的,所以多数的调试过程也是在浏览器中完成的。

工欲善其事,必先利其器。本节我们先来基于 Chrome 浏览器介绍一下浏览器开发者工具的使用。但由于开发者工具功能十分复杂,本节主要介绍对 JavaScript 逆向有一些帮助的功能,学会了这些,我们在做 JavaScript 逆向调试的过程会更加得心应手。

本节我们以一个示例网站 https://spa2.scrape.center/ 来做演示,用这个示例来介绍浏览器开发者工具各个面版的用法。

1. 面板介绍

首先我们用 Chrome 浏览器打开示例网站,页面如图所示:

示例网站页面

接下来打开开发者工具,我们会看到类似图 xx 所示的结果。

打开开发者工具

这里可以看到多个面板标签,如 Elements、Console、Sources 等,这就是开发者工具的一个个面板,功能丰富而又强大,先对面板作下简单的介绍:

  • Elements:元素面板,用于查看或修改当前网页 HTML 节点的属性、CSS 属性、监听事件等等,HTML 和 CSS 都可以即时修改和即时显示。
  • Console:控制台面板,用于查看调试日志或异常信息。另外我们还可以在控制台输入 JavaScript 代码,方便调试。
  • Sources:源代码面板,用于查看页面的 HTML 文件源代码、JavaScript 源代码、CSS 源代码,还可以在此面板对 JavaScript 代码进行调试,比如添加和修改 JavaScript 断点,观察 JavaScript 变量变化等。
  • Network:网络面板,用于查看页面加载过程中的各个网络请求,包括请求、响应等各个详情。
  • Performance:性能面板,用于记录和分析页面在运行时的所有活动,比如 CPU 占用情况,呈现页面性能分析结果,
  • Memory:内存面板,用于记录和分析页面占用内存情况,如查看内存占用变化,查看 JavaScript 对象和 HTML 节点的内存分配。
  • Application:应用面板,用于记录网站加载的所有资源信息,如存储、缓存、字体、图片等,同时也可以对一些资源进行修改和删除。
  • Lighthouse:审核面板,用于分析网络应用和网页,收集现代性能指标并提供对开发人员最佳实践的意见。

了解了这些面板之后,我们来深入了解几个面板中对 JavaScript 调试很有帮助的功能。

2. 查看节点事件

之前我们是用 Elements 面板来审查页面的节点信息的,我们可以查看当前页面的 HTML 源代码及其在网页中对应的位置,查看某个条目的标题对应的页面源代码,如图所示。

查看源代码

点击右侧的 Styles 选项卡,可以看到对应节点的 CSS 样式,我们可以自行在这里增删样式,实时预览效果,这对网页开发十分有帮助。

在 Computed 选项卡中还可以看到当前节点的盒子模型,比如外边距、内边距等,还可以看到当前节点最终计算出的 CSS 的样式,如图所示。

盒子模型

接下来切换到右侧的 Event Listeners 选项卡,这里可以显示各个节点当前已经绑定的事件,都是 JavaScript 原生支持的,下面简单列举几个事件。

  • change:HTML 元素改变时会触发的事件。
  • click:用户点击 HTML 元素时会触发的事件。
  • mouseover:用户在一个 HTML 元素上移动鼠标会触发的事件。
  • mouseout:用户从一个 HTML 元素上移开鼠标会触发的事件。
  • keydown:用户按下键盘按键会触发的事件。
  • load:浏览器完成页面加载时会触发的事件。

通常,我们会给按钮绑定一个点击事件,它的处理逻辑一般是由 JavaScript 定义的,这样在我们点击按钮的时候,对应的 JavaScript 代码便会执行。比如在图 xx 中,我们选中切换到第 2 页的节点,右侧 Event Listeners 选项卡下会看到它绑定的事件。

选中切换到第 2 页的节点

这里有对应事件的代码位置,内容为一个 JavaScript 文件名称 chunk-vendors.77daf991.js,然后紧跟一个冒号,然后再跟了一个数字 7。所以对应的事件处理函数是定义在 chunk-vendors.77daf991.js 这个文件的第 7 行。点击这个代码位置,便会自动跳转 Sources 面板,打开对应的 chunk-vendors.77daf991.js 文件并跳转到对应的位置,如图所示。

跳转到对应的代码位置

所以,利用好 Event Listeners,我们可以轻松地找到各个节点绑定事件的处理方法所在的位置,帮我们在 JavaScript 逆向过程中找到一些突破口。

3. 代码美化

刚才我们已经通过 Event Listeners 找到了对应的事件处理方法所在的位置并成功跳转到了代码所在的位置。

但是,这部分代码似乎被压缩过了,可读性很差,根本没法阅读,这时候应该怎么办呢?

不用担心,Sources 面板提供了一个便捷好用的代码美化功能。我们点击代码面板左下角的格式化按钮,代码就会变成如图所示的样子。

代码格式化按钮

格式化后的代码

此时会新出现一个叫作 chunk-vendors.77daf991.js:formatted 的选项卡,文件名后面加了 formatted 标识,代表这是被格式化的结果。我们会发现,原来代码在第 7 行,现在自动对应到了第 4445 行,而且对应的代码位置会高亮显示,代码可读性大大增强!

这个功能在调试过程中非常常用,用好这个功能会给我们的 JavaScript 调试过程带来极大的便利。

4. 断点调试

接下来介绍一个非常重要的功能——断点调试。在调试代码的时候,我们可以在需要的位置上打断点,当对应事件触发时,浏览器就会自动停在断点的位置等待调试,此时我们可以选择单步调试,在面板中观察调用栈、变量值,以更好地追踪对应位置的执行逻辑。

那么断点怎么打呢?我们接着以上面的例子来说。首先单击如图所示的代码行号。

单击代码行号

这时候行号处就出现了一个蓝色的箭头,这就证明断点已经添加好了,同时在右侧的 Breakpoints 选项卡下会出现我们添加的断点的列表。

由于我们知道这个断点是用来处理翻页按钮的点击事件的,所以可以在网页里面点击按钮试一下,比如点击第 2 页的按钮,这时候就会发现断点被触发了,如图所示。

断点被触发

这时候我们可以看到页面中显示了一个叫作 Paused in debugger 的提示,这说明浏览器执行到刚才我们设置断点的位置处就不再继续执行了,等待我们发号施令执行调试。

此时代码停在了第 4446 行,回调参数 e 就是对应的点击事件 MouseEvent 。在右侧的 Scope 面板处,可以观察到各个变量的值,比如在 Local 域下有当前方法的局部变量,我们可以在这里看到 MouseEvent 的各个属性,如图所示。

查看 Local 域

另外我们关注到有一个方法 o,它在 Jr 方法下面,所以切换到 Closure(Jr) 域可以查看它的定义及其接收的参数,如图所示。

查看 Closure(Jr) 域

我们可以看到,FunctionLocation 又指向了方法 o ,点击之后便又可以跳到指定位置,用同样的方式进行断点调试即可。

在 Scope 面板还有多个域,这里就不再展开介绍了。总之,通过 Scope 面板,我们可以看到当前执行环境下的变量的值和方法的定义,知道当前代码究竟执行了怎样的逻辑。

接下来切换到 Watch 面板,在这里可以自行添加想要查看的变量和方法,点击右上角的 + 号按钮,我们可以任意添加想要监听的对象,如图所示。

Watch 面板

比如这里我们比较关注 o.apply 是一个怎样的方法,于是点击添加 o.apply,这里就会把对应的方法定义呈现出来,展开之后可以再点击 FunctionLocation 定位其源码位置。

我们还可以切换到 Console 面板,输入任意的 JavaScript 代码,便会执行、输出对应的结果,如图所示。

Console 面板

如果我们想看看变量 arguments 的第一个元素是什么,那么可以直接敲入 arguments[0],便会输出对应的结果 MouseEvent,只要在当前上下文能访问到的变量都可以直接引用并输出。

此时我们还可以选择单步调试,这里有 3 个重要的按钮,如图所示。

单步调试按钮

这 3 个按钮都可以做单步调试,但功能不同。

  • Step Over Next Function Call:逐语句执行
  • Step Into Next Function Call:进入方法内部执行
  • Step Out of Current Function:跳出当前方法

用得较多的是第一个,相当于逐行调试,比如点击 Step Over Next Function Call 这个按钮,就运行到了 4447 行,高亮的位置就变成了这一行,如图所示。

点击 Step Over Next Function Call 按钮

5. 观察调用栈

在调试的过程中,我们可能会跳到一个新的位置,比如点击上述 Step Over Next Function Call 几下,可能会跳到一个叫作 ct 的方法中,这时候我们也不知道发生了什么,如图所示。

跳到 ct 方法中

那究竟是怎么跳过来的呢?我们可以观察一下右侧的 Call Stack 面板,就可以看到全部的调用过程了。比如它的上一步是 ot 方法,再上一步是 pt 方法,点击对应的位置也可以跳转到对应的代码位置,如图所示。

Call Stack 面板

有时候调用栈是非常有用的,利用它我们可以回溯某个逻辑的执行流程,从而快速找到突破口。

6. 恢复 JavaScript 执行

在调试过程中,如果想快速跳到下一个断点或者让 JavaScript 代码运行下去,可以点击 Resume script execution 按钮,如图所示。

Resume script execution 按钮

这时浏览器会直接执行到下一个断点的位置,从而避免陷入无穷无尽的调试中。

当然,如果没有其他断点了,浏览器就会恢复正常状态。比如这里我们就没有再设置其他断点了,浏览器直接运行并加载了下一页的数据,同时页面恢复正常,如图所示。

浏览器恢复正常状态

7. Ajax 断点

上面我们介绍了一些 DOM 节点的 Listener,通过 Listener 我们可以手动设置断点并进行调试。但其实针对这个例子,通过翻页的点击事件 Listener 是不太容易找到突破口的。

接下来我们再介绍一个方法—— Ajax 断点,它可以在发生 Ajax 请求的时候触发断点。对于这个例子,我们的目标其实就是找到 Ajax 请求的那一部分逻辑,找出加密参数是怎么构造的。可以想到,通过 Ajax 断点,使页面在获取数据的时候停下来,我们就可以顺着找到构造 Ajax 请求的逻辑了。

怎么设置呢?

我们把之前的断点全部取消,切换到 Sources 面板下,然后展开 XHR/fetch Breakpoints,这里就可以设置 Ajax 断点,如图所示。

展开 XHR/fetch Breakpoints

要设置断点,就要先观察 Ajax 请求。和之前一样,我们点击翻页按钮 2,在 Network 面板里面观察 Ajax 请求是怎样的,请求的 URL 如图所示。

请求的 URL

可以看到 URL 里面包含 /api/movie 这样的内容,所以我们可以在刚才的 XHR/fetch Breakpoints 面板中添加拦截规则。点击 + 号,可以看到一行 Break when URL contains: 的提示,意思是当 Ajax 请求的 URL 包含填写的内容时,会进入断点停止,这里可以填写 /api/movie,如图所示。

这时候我们再点击翻页按钮 3,触发第 3 页的 Ajax 请求。会发现点击之后页面走到断点停下来了,如图所示。

断点调试模式

格式化代码看一下,发现它停到了 Ajax 最后发送的那个时候,即底层的 XMLHttpRequestsend 方法,可是似乎还是找不到 Ajax 请求是怎么构造的。前面我们讲过调用栈 Call Stack,通过调用栈是可以顺着找到前序调用逻辑的,所以顺着调用栈一层层找,也可以找到构造 Ajax 请求的逻辑,最后会找到一个叫作 onFetchData 的方法,如图所示。

找到 onFetchData 方法

接下来切换到 onFetchData 方法并将代码格式化,可以看到如图所示的调用方法。

调用方法

可以发现,可能使用了 axios 库发起了一个 Ajax 请求,还有 limitoffsettoken 这 3 个参数,基本就能确定了,顺利找到了突破口!我们就不在此展开分析了,后文会有完整的分析实战。

因此在某些情况下,我们可以在比较容易地通过 Ajax 断点找到分析的突破口,这是一个常见的寻找 JavaScript 逆向突破口的方法。

要取消断点也很简单,只需要在 XHR/fetch Breakpoints 面板取消勾选即可,如图所示。

取消断点

8. 改写 JavaScript 文件

我们知道,一个网页里面的 JavaScript 是从对应服务器上下载下来并在浏览器执行的。有时候,我们可能想要在调试的过程中对 JavaScript 做一些更改,比如说有以下需求:

  • 发现 JavaScript 文件中包含很多阻挠调试的代码或者无效代码、干扰代码,想要将其删除。

  • 调试到某处,想要加一行 console.log 输出一些内容,以便观察某个变量或方法在页面加载过程中的调用情况。在某些情况下,这种方法比打断点调试更方便。

  • 调试过程遇到某个局部变量或方法,想要把它赋值给 window 对象以便全局可以访问或调用。

  • 在调试的时候,得到的某个变量中可能包含一些关键的结果,想要加一些逻辑将这些结果转发到对应的目标服务器。

这时候我们可以试着在 Sources 面板中对 JavaScript 进行更改,但这种更改并不能长久生效,一旦刷新页面,更改就全都没有了。比如我们在 JavaScript 文件中写入一行 JavaScript 代码,然后保存,如图所示。

在 JavaScript 文件中写入一行 JavaScript 代码

这时候可以发现 JavaScript 文件上出现了一个感叹号标志,提示我们做的更改是不会保存的。这时候重新刷新页面,再看一下更改的这个文件,如图所示。

刷新页面后的 JavaScript 文件

有什么方法可以修改呢?其实有一些浏览器插件可以实现,比如 ReRes。在插件中,我们可以添加自定义的 JavaScript 文件,并配置 URL 映射规则,这样浏览器在加载某个在线 JavaScript 文件的时候就可以将内容替换成自定义的 JavaScript 文件了。另外,还有一些代理服务器也可以实现,比如 Charles、Fiddler,借助它们可以在加载 JavaScript 文件时修改对应 URL 的响应内容,以实现对 JavaScript 文件的修改。

其实浏览器的开发者工具已经原生支持这个功能了,即浏览器的 Overrides 功能,它在 Sources 面板左侧,如图所示。

Overrides 功能

我们可以在 Overrides 面板上选定一个本地的文件夹,用于保存需要更改的 JavaScript 文件,我们来实际操作一下。

首先,根据上文设置 Ajax 断点的方法,找到对应的构造 Ajax 请求的位置,根据一些网页开发知识,我们可以大体判断出 then 后面的回调方法接收的参数 a 中就包含了 Ajax 请求的结果,如图所示。

我们打算在 Ajax 请求成功获得 Response 的时候,在控制台输出 Response 的结果,也就是通过 console.log 输出变量 a

再切回 Overrides 面板,点击 + 按钮,这时候浏览器会提示我们选择一个本地文件夹,用于存储要替换的 JavaScript 文件。这里我选定了一个我任意新建的文件夹 ChromeOverrides,注意,这时候可能会遇到如图所示的提示,如果没有问题,直接点击“允许”即可。

弹出提示

这时,在 Overrides 面板下就多了一个 ChromeOverrides 文件夹,用于存储所有我们想要更改的 JavaScript 文件,如图所示。

Overrides 面板下出现 ChromeOverrides 文件夹

我们可以看到,现在所在的 JavaScript 选项卡是 chunk-19c920f8.012555a2.js:formatted,代码已经被格式化了。因为格式化后的代码是无法直接在浏览器中修改的,所以为了方便,我们可以将格式化后的文件复制到文本编辑器中,然后添加一行代码,修改如下:

1
2
3
4
5
6
7
8
...
}).then((function(a) {
console.log('response', a) // 添加一行代码
var e = a.data
, s = e.results
, n = e.count;
t.loading = !1,
...

接着把修改后的内容替换到原来的 JavaScript 文件中。这里要注意,切换到 chunk-19c920f8.012555a2.js 文件才能修改,直接替换 JavaScript 文件的所有内容即可,如图所示。

替换 JavaScript 文件的所有内容

替换完毕之后保存,这时候再切换回 Overrides 面板,就可以发现成功生成了新的 JavaScript 文件,它用于替换原有的 JavaScript 文件,如图所示。

生成了新的 JavaScript 文件

好,此时我们取消所有断点,然后刷新页面,就可以在控制台看到输出的 Reponse 结果了,如图所示。

Reponse 结果

正如我们所料,我们成功将变量 a 输出,其中的 data 字段就是 Ajax 的 Response 结果,证明改写 JavaScript 成功!而且刷新页面也不会丢失了。

我们还可以增加一些 JavaScript 逻辑,比如直接将变量 a 的结果通过 API 发送到远程服务器,并通过服务器将数据保存下来,也就完成了直接拦截 Ajax 请求并保存数据的过程了。

修改 JavaScript 文件有很多用途,此方案可以为我们进行 JavaScript 的逆向带来极大的便利。

9. 总结

本节总结了一些浏览器开发者工具中对 JavaScript 逆向非常有帮助的功能,熟练掌握了这些功能会对后续 JavaScript 逆向分析打下坚实的基础,请大家好好研究。

Python

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

我们在前面尝试维护过一个代理池,代理池可以挑选出许多可用代理,但是常常其稳定性不高、响应速度慢,而且这些代理通常是公共代理,可能不止一人同时使用,其 IP 被封的概率很大。另外,这些代理可能有效时间比较短,虽然代理池一直在筛选,但如果没有及时更新状态,也有可能获取到不可用的代理。

上一节我们也了解了付费代理的使用,付费代理的质量相对免费代理就会好不少,这的确已经是一个相对不错的方案了,但本节要介绍的方案可以使我们既能不断更换代理,又可以保证代理的稳定性。

在一些付费代理套餐中,大家可能会注意到有这样的一个套餐 - 独享代理或私密代理,这种其实就是用了专用服务器搭建了代理服务,相对一般的付费代理来说,其稳定性更好,速度也更快,同时 IP 可以动态变化。这种独享代理或私密代理的 IP 切换大多数都是基于 ADSL 拨号机制来实现的,一台云主机每拨号一次就可以换一个 IP,同时云主机上搭建了代理服务,我们就可以直接使用该云主机的 HTTP 代理来进行数据爬取了。

本节我们就来实际操作一下搭建 ADSL 拨号代理服务的方法。

1. 什么是 ADSL

ADSL,英文全称是 Asymmetric Digital Subscriber Line,即非对称数字用户环路。它的上行和下行带宽不对称,它采用频分复用技术把普通的电话线分成了电话、上行和下行 3 个相对独立的信道,从而避免了相互之间的干扰。

ADSL 通过拨号的方式上网,拨号时需要输入 ADSL 账号和密码,每次拨号就更换一个 IP。IP 分布在多个 A 段,如果 IP 都能使用,则意味着 IP 量级可达千万。如果我们将 ADSL 主机作为代理,每隔一段时间云主机拨号就换一个 IP,这样可以有效防止 IP 被封禁。另外,由于我们是直接使用专有的云主机搭建的代理服务,所以其代理的稳定性相对更好,代理响应速度也相对更快。

2. 准备工作

在本节开始之前,我们需要先购买几台 ADSL 代理云主机,建议 2 台或以上。因为云主机在拨号的一瞬间服务器正在切换 IP,所以拨号之后代理是不可用的状态,所以需要 2 台及以上云主机来做负载均衡。

ADSL 代理云主机的服务商还是比较多的,个人推荐的有阿斯云、云立方等,其官网分别为:

本节案例中,我们以阿斯云为例,购买了一台电信型同时安装了 CentOS Linux 系统的云主机。

购买成功之后,我们可以在后台找到服务器的连接 IP、端口、用户名、密码,拨号所用的用户名和密码,如图所示:

image-20210711154649835

然后找到远程管理面板 − 远程连接的用户名和密码,也就是 SSH 远程连接服务器的信息。比如我使用的 IP 和端口是 zhongweidx01.jsq.bz:30042,用户名是 root,命令行下输入如下内容:

1
ssh root@zhongweidx01.jsq.bz -p 30042

输入连接密码,就可以连接上远程服务器了,如图所示:

image-20210711122126383

登录成功之后,我们便可以开始本节的正式内容了。

3. 测试拨号

云主机默认已经配置了拨号相关的信息,如宽带用户名和密码等,所以我们无需额外进行配置,只需要调用相应的拨号命令即可实现拨号和 IP 地址的切换。

我们可以输入如下拨号命令来进行拨号:

1
pppoe-start

拨号命令成功运行,没有报错信息,耗时约几秒,结束之后整个主机就获得了一个有效的 IP 地址。

如果要停止拨号,可以输入如下命令:

1
pppoe-stop

运行完该命令后,网络就会断开,之前的 IP 地址也会被释放。

注意:不同的云主机的拨号命令可能不同,如云立方主机的拨号命令为 adsl-startadsl-stop,请以官方文档的说明为准。

所以,如果要想切换 IP,我们只需要将上面的两个命令组合起来,先执行 pppoe-stop,再执行 pppoe-start。每次拨号,ifconfig 命令观察主机的 IP,如图所示:

image-20210711123026267

可以看到,这里我们执行了停止和开始拨号的命令之后,通过 ifconfig 命令获取的网卡信息的 IP 地址就变化了,所以我们成功实现了 IP 地址的切换。

好,那如果我们要想将这台云主机设置为可以实时变化 IP 的代理服务器的话,主要就有这几件事情:

  • 在云主机上运行代理服务软件,使之可以提供 HTTP 代理服务
  • 实现云主机定时拨号更换 IP
  • 实时获取云主机的代理 IP 和端口信息

接下来我们就来完成这几部分内容吧。

4. 设置代理服务器

当前我们使用的云主机使用的是 Linux 的 CentOS 系统,目前它是无法作为一个 HTTP 代理服务器来使用的,因为该云主机上面目前并没有运行相关的代理软件。要想让该云主机提供 HTTP 代理服务,我们需要在其上面安装并运行相关的代理服务。

那什么软件能提供这种代理服务呢?目前业界比较流行的有 Squid 和 TinyProxy,配置完成之后它们会在特定端口上运行一个 HTTP 代理。知道了该云主机当前的 IP 之后,我们就能使用该云主机上 Squid 或 TinyProxy 提供的 HTTP 代理了。

这里我们以 Squid 为例来进行一下配置。

首先我们安装一下 Squid,在 CentOS 的安装命令如下:

1
2
sudo yum -y update
yum -y install squid

运行完之后,我们便可以成功安装好 Squid 了。

如果要想启动 Squid,可以运行如下命令:

1
systemctl start squid

如果想配置开机自动启动,可以运行如下命令:

1
systemctl enable squid

Squid 成功运行之后,我们可以使用如下命令查看当前 Squid 的运行状态:

1
systemctl status squid

如图所示,我们可以看到 Squid 就成功运行了:

image-20210711132337727

默认情况下,Squid 会运行在 3128 端口,也就是相当于在云主机的 127.0.0.1:3128 上启动了代理服务,接下来我们可以来测试下 Squid 的代理效果,在该台云主机上运行 curl 命令请求 https://httpbin.org,并配置使用云主机的代理:

1
curl -x http://127.0.0.1:3128 https://httpbin.org/get

这里 curl 的 -x 参数代表设置 HTTP 代理,由于这是在云主机上运行的,所以代理直接设置为了 http://127.0.0.1:3128。

运行完毕之后,我们再运行下 ifconfig 获取下当前云主机的 IP,运行结果如图所示:

image-20210711133237708

可以看到返回结果的 origin 字段的 IP 就和 ifconfig 获取的 IP 地址是一致的。

接下来,我们在自己本机上(非云主机)运行如下命令测试下代理的连通情况,这里 IP 就需要更换为云主机本身的 IP 了,刚才可以看到云主机当前拨号的 IP 是 106.45.104.166,所以需要运行如下命令:

1
curl -x http://106.45.104.166:3128 https://httpbin.org/get

然而发现并没有对应的输出结果,代理连接失败。

其实原因在于默认情况下 Squid 并没有开启允许外网访问,我们可以进行 Squid 的相关配置,如更改当前代理运行端口、允许连接的 IP,配置高匿代理等等,这些都需要修改 /etc/squid/squid.conf 文件。

要允许公网访问,最简单的方案就是将 /etc/squid/squid.conf 中的该行:

1
http_access deny all

修改为:

1
http_access allow all

意思是允许来自所有 IP 的请求连接。

另外还需要在配置文件的开头 acl 配置的部分添加该行内容:

1
acl all src 0.0.0.0/0

另外我们还想将 Squid 配置成高度匿名代理,这样目标网站就无从通过一些参数如 X-Forwarded-For 来得知爬虫机本身的 IP 了,所以在配置文件中再添加如下配置:

1
2
request_header_access Via deny all
request_header_access X-Forwarded-For deny all

另外有的云主机厂商可能默认封禁了 Squid 的 3128 端口,建议更换一个端口,比如 3328,修改改行:

1
http_port 3128

修改为:

1
http_port 3328

修改完配置之后保存配置文件,然后重新启动 Squid 即可:

1
systemctl restart squid

这时候在本机上(非云主机)重新运行刚才的 curl 命令,同时更改下端口:

1
curl -x http://106.45.104.166:3328 https://httpbin.org/get

即可得到返回结果:

1
2
3
4
5
6
7
8
9
10
11
{
"args": {},
"headers": {
"Accept": "*/*",
"Host": "httpbin.org",
"User-Agent": "curl/7.64.1",
"X-Amzn-Trace-Id": "Root=1-60ea8fc0-0701b1494e4680b95889cdb1"
},
"origin": "106.45.104.166",
"url": "https://httpbin.org/get"
}

这时候我们就可以在本机上直接使用云主机的代理了!

5. 动态获取 IP

现在我们已经可以执行命令让主机动态切换 IP 了,同时也在主机上搭建好代理服务器了,接下来我们只需要知道拨号后的 IP 就可以使用代理了。

那怎么动态获取拨号主机的 IP 呢?又怎么来维护这些代理呢?怎么保证获取到的代理一定是可用的呢?这时候我们可能想到一些问题:

  • 如果我们只有一台拨号云主机并设置了定时拨号的话,那么在拨号的几秒时间内,该云主机提供的代理服务是不可用的。
  • 如果我们不用定时拨号的方法,而想要在爬虫端控制拨号云主机的拨号操作的话,爬虫端还需要单独的逻辑来处理拨号和重连的问题,这会带来额外的开销。

综合考虑下来,一个比较好的解决方案是:

  • 为了不增加爬虫端的逻辑开销,爬虫端应该无需关心拨号云主机的拨号操作,我们只需要保证爬虫通过某个接口获取到的代理是可用的就行了,拨号云主机的代理的维护逻辑和爬虫是毫不相关的。
  • 为了解决一台拨号云主机在拨号时代理不可用的问题,我们需要有多台云主机同时提供代理服务,我们可以将不同云主机的拨号时段错开,当一台云主机正在拨号时,我们可以用其他云主机顶替。

  • 为了更加方便地维护和使用代理,我们可以像前文介绍的代理池一样把这些云主机的代理统一维护起来,所有拨号云主机的代理统一存储到一个公共的 Redis 数据库中,可以使用 Redis 的 Hash 存储方式,存好每台云主机和对应代理的映射关系。拨号云主机拨号前将自己对应的代理内容清空,拨号成功之后再将代理更新,这样 Redis 数据库中的代理就一定是实时可用的代理了。

利用这种思路,我们要做的其实就有如下几点:

  • 配置一个可以公网访问的 Redis 数据库,每台云主机可以将自己的代理存储到对应的 Redis 数据库中,由该 Redis 数据库维护这些代理。
  • 申请多台拨号云主机并按照上文所述配置好 Squid 代理服务,每台云主机设置定时拨号来更换 IP。
  • 每台云主机在拨号前删除 Redis 数据库中原来的代理,拨号成功之后测试一下代理的可用性,将最新的代理更新到 Redis 数据库中即可。

OK,接下来我们就来操作一下吧。

由于云主机要进行 Redis 数据库的操作,所以我们可以使用 Python 来实现,所以先在云主机上装下 Python:

1
yum -y install python3

关于自动拨号、连接 Redis 数据库、获取本机代理、设置 Redis 数据库的操作,我已经写好了一个 Python 的包并发布到 PyPi 了,我们可以直接使用这个包来完成如上的功能,这个包叫做 adslproxy,可以在云主机上使用 pip3 来安装:

1
pip3 install adslproxy

安装完毕之后,我们可以使用 export 命令设置下环境变量:

1
2
3
4
5
6
7
8
export REDIS_HOST=<Redis数据库的地址>
export REDIS_PORT=<Redis数据库的端口>
export REDIS_PASSWORD=<Redis数据库的密码>
export PROXY_PORT=<拨号云主机配置的代理端口>
export DIAL_BASH=<拨号脚本>
export DIAL_IFNAME=<网卡名称>
export CLIENT_NAME=<云主机的唯一标识>
export DIAL_CYCLE=<拨号间隔>

这里 REDIS_HOST、REDIS_PORT、REDIS_PASSWORD 就是远程 Redis 的连接信息,就不再赘述了。PROXY_PORT 就是云主机上代理服务的端口,我们已经设置为了 3328。DIAL_BASH 就是拨号的命令,即 pppoe-stop;pppoe-start,当然该脚本的内容不同的云主机厂商可能不同,以实际为准。DIAL_IFNAME 即拨号云主机上的网卡名称,程序可以通过获取该网卡的信息来获取当前拨号主机的 IP 地址,通过上述操作可以发现,网卡名称叫做 ppp0,当然这个名称也是以实际为准。CLIENT_NAME 就是云主机的唯一标识,用来在 Redis 中存储主机和代理的映射,因为我们有多台云主机,所以不同云主机的名称应该设置为不同的字符串,比如 adsl1、adsl2 等等。

这里我们设置如图所示:

image-20210711152355780

设置好环境变量之后,我们就可以运行 adslproxy 命令来进行拨号了,命令如下:

1
adslproxy send

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
2021-07-11 15:30:03.062 | INFO     | adslproxy.sender.sender:loop:90 - Starting dial...
2021-07-11 15:30:03.063 | INFO | adslproxy.sender.sender:run:99 - Dial started, remove proxy
2021-07-11 15:30:03.063 | INFO | adslproxy.sender.sender:remove_proxy:62 - Removing adsl1...
2021-07-11 15:30:04.065 | INFO | adslproxy.sender.sender:remove_proxy:69 - Removed adsl1 successfully
2021-07-11 15:30:05.373 | INFO | adslproxy.sender.sender:run:111 - Get new IP 106.45.105.33
2021-07-11 15:30:15.552 | INFO | adslproxy.sender.sender:run:120 - Valid proxy 106.45.105.33:3328
2021-07-11 15:30:16.501 | INFO | adslproxy.sender.sender:set_proxy:82 - Successfully set proxy 106.45.105.33:3328
2021-07-11 15:33:36.678 | INFO | adslproxy.sender.sender:loop:90 - Starting dial...
2021-07-11 15:33:36.679 | INFO | adslproxy.sender.sender:run:99 - Dial started, remove proxy
2021-07-11 15:33:36.680 | INFO | adslproxy.sender.sender:remove_proxy:62 - Removing adsl1...
2021-07-11 15:33:37.214 | INFO | adslproxy.sender.sender:remove_proxy:69 - Removed adsl1 successfully
2021-07-11 15:33:38.617 | INFO | adslproxy.sender.sender:run:111 - Get new IP 106.45.105.219
2021-07-11 15:33:48.750 | INFO | adslproxy.sender.sender:run:120 - Valid proxy 106.45.105.219:3328
...

这里我们就可以看到,因为云主机在拨号之后当前代理就会失效了,所以在拨号之前程序先尝试从 Redis 中删除当前云主机的代理。接下来就开始执行拨号操作,拨号成功之后验证一下代理是可用的,然后再将该代理存储到 Redis 数据库中。循环往复运行,我们就达到了定时更换 IP 的效果,同时 Redis 数据库中也是实时可用的代理。

最后按照同样的配置,我们可以购买多台拨号云主机并进行如上同样的设置,这样就有多个稳定的定时更新的代理可用了,Redis 中会实时更新各台云主机的代理,如图所示。

图中所示是四台 ADSL 拨号云主机配置并运行后 Redis 数据库中的内容,其中的代理都是实时可用的。

6. 使用代理

那怎么使用代理呢?我们可以在任意可以公网访问的云主机上连接刚才的 Redis 数据库并搭建一个 API 服务即可。怎么搭建呢?我们可以同样使用刚才的 adslproxy 库,该库也提供了 API 服务的功能。

为了方便测试,我们在本机进行测试,安装好 adslproxy 包之后,然后设置好 REDIS 相关的环境变量:

1
2
3
export REDIS_HOST=<Redis数据库的地址>
export REDIS_PORT=<Redis数据库的端口>
export REDIS_PASSWORD=<Redis数据库的密码>

然后运行如下命令启动即可:

1
2020-07-11 16:31:58.651 | INFO     | adslproxy.server.server:serve:68 - API listening on http://0.0.0.0:8425

可以看到 API 服务就在 8425 端口上运行了,我们打开浏览器即可访问首页,如图所示:

image-20210711153319974

其中最重要的就是 random 接口了,我们使用 random 接口即可获取 Redis 数据库中的一个随机代理,如图所示:

image-20210711153419543

测试下可用性也没有问题,这样爬虫就可以使用这个代理来进行数据爬取了。

最后,我们将 API 服务部署一下,这个 ADSL 代理服务就可以像代理池一样被使用了,每请求一次 API 就可以获取一个实时可用代理,不同的时间段这个代理就会实时更换,而且连接稳定速度又快,实在是网络爬虫的最佳搭档。

7. 总结

本节我们介绍了 ADSL 拨号代理的搭建过程。通过这种代理,我们可以无限次更换 IP,而且线路非常稳定,爬虫抓取效果也会好很多。

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

Python

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

我们在上一节中了解了各个请求库设置代理的各个方法,但是如何实时高效地获取到大量可用的代理是一个问题。

首先,在互联网上有大量公开的免费代理。当然,我们也可以购买付费的代理 IP,但是代理不论是免费的还是付费的,都不能保证是可用的,因为此 IP 可能被其他人用来爬取同样的目标站点而被封禁,或者代理服务器突然发生故障或网络繁忙。一旦我们选用了一个不可用的代理,这势必会影响爬虫的工作效率。

所以,我们需要提前做筛选,将不可用的代理剔除掉,保留可用代理。

那么,怎么实现呢?这就需要借助于一个叫作代理池的东西了。

接下来,本节就来介绍一下如何搭建一个高效易用的代理池。

1.准备工作

这里代理池的存储需要借助于 Redis,因此需要额外安装它。总体来说,本节需要的环境如下:

  • 需要安装并成功运行和连接一个 Redis 数据库,Redis 运行在本地或者远端服务器都可以,只要能正常连接就行,安装方式可以参考:https://setup.scrape.center/redis

  • 安装好一些必要的库,包括 aiohttp、requests、redis-py、pyquery、Flask、loguru 等,安装命令如下:

    1
    pip3 install aiohttp requests redis pyquery flask loguru

做好了如上准备工作,我们便可以开始实现或运行本节所讲的代理池了。

2.代理池的目标

我们需要做到下面几个目标来实现易用高效的代理池。

代理池基本模块分为 4 部分:存储模块、获取模块、检测模块和接口模块,其功能如下:

  • 存储模块:负责存储抓取下来的代理。首先要保证代理不重复,要标识代理的可用情况,还要动态实时处理每个代理,所以一种比较高效和方便的存储方式就是使用 Redis 的 Sorted Set,即有序集合。
  • 获取模块:需要定时在各大代理网站抓取代理。代理既可以是免费公开代理,也可以是付费代理,代理的形式都是 IP 加端口。此模块尽量从不同来源获取,尽量抓取高匿代理,抓取成功之后将可用代理保存到数据库中。
  • 检测模块:需要定时检测数据库中的代理。这里需要设置一个检测链接,最好是爬取哪个网站就检测哪个网站,这样更加有针对性。如果要做一个通用型的代理,可以设置百度等链接来检测。另外,我们需要标识每一个代理的状态,如设置分数标识,100 分代表可用,分数越少代表越不可用。检测一次,如果代理可用,我们可以将分数标识立即设置为 100 满分,也可以在原基础上加 1 分;如果代理不可用,可以将分数标识减 1 分,当分数减到一定阈值后,代理就直接从数据库移除。通过这样标识分数,我们就可以辨别代理的可用情况,选用的时候会更有针对性。
  • 接口模块:需要用 API 来提供对外服务的接口。其实我们可以直接连接数据库来取对应的数据,但是这样就需要知道数据库的连接信息,并且要配置连接,而比较安全和方便的方式就是提供一个 Web API 接口,我们通过访问接口即可拿到可用代理。另外,由于可用代理可能有多个,所以我们可以设置一个随机返回某个可用代理的接口,这样就能保证每个可用代理都可以取到,实现负载均衡。

以上内容是设计代理的一些基本思路。接下来,我们设计整体的架构,然后用代码实现代理池。

3. 代理池的架构

根据上文的描述,代理池的架构如图所示。

图中所示的代理池分为 4 个模块:存储模块、获取模块、检测模块和接口模块:

  • 存储模块使用 Redis 的有序集合,用来做代理的去重和状态标识,同时它也是中心模块和基础模块,用于将其他模块串联起来。
  • 获取模块定时从代理网站获取代理,将获取的代理传递给存储模块,并保存到数据库。
  • 检测模块定时通过存储模块获取所有代理,并对代理进行检测,根据不同的检测结果对代理设置不同的标识。
  • 接口模块通过 Web API 提供服务接口,接口通过连接数据库并通过 Web 形式返回可用的代理。

4.代理池的实现

接下来,我们分别用代码来实现一下这 4 个模块。

注意:完整的代理池代码量较大,因此本节的代码我们不再一步步跟着编写,最后去了解源码即可,源码地址为:https://github.com/Python3WebSpider/ProxyPool

存储模块

这里我们使用 Redis 的有序集合,集合中的每一个元素都是不重复的。对于代理池来说,集合中的元素就变成了一个个代理,也就是 IP 加端口的形式,如 60.207.237.111:8888。另外,有序集合的每一个元素都有一个分数字段,分数是可以重复的,既可以是浮点数类型,也可以是整数类型。该集合会根据每一个元素的分数对集合进行排序,数值小的排在前面,数值大的排在后面,这样就可以实现集合元素的排序了。

对于代理池来说,这个分数可以作为判断一个代理是否可用的标志:100 为最高分,代表最可用;0 为最低分,代表最不可用。如果要获取可用代理,可以从代理池中随机获取分数最高的代理。注意这里是随机,这样可以保证每个可用代理都会被调用到。

分数是我们判断代理稳定性的重要标准。设置分数的规则如下所示。

  • 分数 100 为可用,检测器会定时循环检测每个代理的可用情况。一旦检测到有可用的代理,就立即置为 100;如果检测到不可用,就将分数减 1,分数减至 0 后代理移除。
  • 新获取的代理的分数为 10,如果测试可行,分数立即置为 100,不可行则将分数减 1,分数减至 0 后代理移除。

这只是一种解决方案,当然可能还有更合理的方案。之所以设置此方案,有如下几个原因。

  • 在检测到代理可用时,分数立即置为 100,这样可以保证所有可用代理有更大的机会被获取到。你可能会问,为什么不将分数加 1 而是直接将其设为最高值 100 呢?设想一下,有的代理是从各大免费公开代理网站获取的,常常一个代理并没有那么稳定,平均 5 次请求可能有 2 次成功,3 次失败。如果按照这种方式来设置分数,那么这个代理几乎不可能达到一个高的分数,也就是说即便它有时是可用的,但是筛选的分数最高,那这样的代理几乎不可能被取到。如果想追求代理稳定性,可以用上述方法,这种方法可确保分数最高的代理一定是最稳定可用的。所以,这里我们采取 “可用即设置 100” 的方法,确保只要可用的代理都可以被获取到。
  • 在检测到代理不可用时,分数减 1,分数减至 0 后,代理移除。这样一个有效代理如果被移除,需要连续不断失败 100 次。也就是说,当一个可用代理尝试了 100 次都失败了,就一直减分直到移除,一旦成功,就重新置回 100。尝试机会越多,这个代理拯救回来的机会越多,这样就不容易将曾经的一个可用代理丢弃,因为代理不可用的原因很可能是网络繁忙或者其他人用此代理请求太过频繁,所以这里将分数设为 100。
  • 将新获取的代理的分数设置为 10,如果它不可用,分数就减 1,直到减到 0 就移除;如果代理可用,分数就置为 100。由于很多代理是从免费网站获取的,所以新获取的代理无效的比例非常高,可能可用的代理不足 10%。这里我们将分数设置为 10,检测的机会没有可用代理的 100 次那么多,这也可以适当减少开销。

上述代理分数的设置思路不一定是最优思路,但据个人实测,它的实用性还是比较强的。

这里首先给出存储模块的实现代码,见 https://github.com/Python3WebSpider/ProxyPool/tree/master/proxypool/storages,建议直接对照源码阅读。

在代码中,我们定义了一个类来操作数据库的有序集合,定义了一些方法来实现分数的设置、代理的获取等。其核心实现代码如下所示:

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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
import redis
from proxypool.exceptions import PoolEmptyException
from proxypool.schemas.proxy import Proxy
from proxypool.setting import REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, REDIS_KEY, PROXY_SCORE_MAX, PROXY_SCORE_MIN, \
PROXY_SCORE_INIT
from random import choice
from typing import List
from loguru import logger
from proxypool.utils.proxy import is_valid_proxy, convert_proxy_or_proxies


REDIS_CLIENT_VERSION = redis.__version__
IS_REDIS_VERSION_2 = REDIS_CLIENT_VERSION.startswith('2.')


class RedisClient(object):
"""
redis connection client of proxypool
"""

def __init__(self, host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASSWORD, **kwargs):
"""
init redis client
:param host: redis host
:param port: redis port
:param password: redis password
"""
self.db = redis.StrictRedis(host=host, port=port, password=password, decode_responses=True, **kwargs)

def add(self, proxy: Proxy, score=PROXY_SCORE_INIT) -> int:
"""
add proxy and set it to init score
:param proxy: proxy, ip:port, like 8.8.8.8:88
:param score: int score
:return: result
"""
if not is_valid_proxy(f'{proxy.host}:{proxy.port}'):
logger.info(f'invalid proxy {proxy}, throw it')
return
if not self.exists(proxy):
if IS_REDIS_VERSION_2:
return self.db.zadd(REDIS_KEY, score, proxy.string())
return self.db.zadd(REDIS_KEY, {proxy.string(): score})

def random(self) -> Proxy:
"""
get random proxy
firstly try to get proxy with max score
if not exists, try to get proxy by rank
if not exists, raise error
:return: proxy, like 8.8.8.8:8
"""
# try to get proxy with max score
proxies = self.db.zrangebyscore(REDIS_KEY, PROXY_SCORE_MAX, PROXY_SCORE_MAX)
if len(proxies):
return convert_proxy_or_proxies(choice(proxies))
# else get proxy by rank
proxies = self.db.zrevrange(REDIS_KEY, PROXY_SCORE_MIN, PROXY_SCORE_MAX)
if len(proxies):
return convert_proxy_or_proxies(choice(proxies))
# else raise error
raise PoolEmptyException

def decrease(self, proxy: Proxy) -> int:
"""
decrease score of proxy, if small than PROXY_SCORE_MIN, delete it
:param proxy: proxy
:return: new score
"""
score = self.db.zscore(REDIS_KEY, proxy.string())
# current score is larger than PROXY_SCORE_MIN
if score and score > PROXY_SCORE_MIN:
logger.info(f'{proxy.string()} current score {score}, decrease 1')
if IS_REDIS_VERSION_2:
return self.db.zincrby(REDIS_KEY, proxy.string(), -1)
return self.db.zincrby(REDIS_KEY, -1, proxy.string())
# otherwise delete proxy
else:
logger.info(f'{proxy.string()} current score {score}, remove')
return self.db.zrem(REDIS_KEY, proxy.string())

def exists(self, proxy: Proxy) -> bool:
"""
if proxy exists
:param proxy: proxy
:return: if exists, bool
"""
return not self.db.zscore(REDIS_KEY, proxy.string()) is None

def max(self, proxy: Proxy) -> int:
"""
set proxy to max score
:param proxy: proxy
:return: new score
"""
logger.info(f'{proxy.string()} is valid, set to {PROXY_SCORE_MAX}')
if IS_REDIS_VERSION_2:
return self.db.zadd(REDIS_KEY, PROXY_SCORE_MAX, proxy.string())
return self.db.zadd(REDIS_KEY, {proxy.string(): PROXY_SCORE_MAX})

def count(self) -> int:
"""
get count of proxies
:return: count, int
"""
return self.db.zcard(REDIS_KEY)

def all(self) -> List[Proxy]:
"""
get all proxies
:return: list of proxies
"""
return convert_proxy_or_proxies(self.db.zrangebyscore(REDIS_KEY, PROXY_SCORE_MIN, PROXY_SCORE_MAX))

def batch(self, start, end) -> List[Proxy]:
"""
get batch of proxies
:param start: start index
:param end: end index
:return: list of proxies
"""
return convert_proxy_or_proxies(self.db.zrevrange(REDIS_KEY, start, end - 1))


if __name__ == '__main__':
conn = RedisClient()
result = conn.random()
print(result)

首先,我们定义了一些常量,如 PROXY_SCORE_MAXPROXY_SCORE_MINPROXY_SCORE_INIT 分别代表最大分数、最小分数、初始分数。REDIS_HOSTREDIS_PORTREDIS_PASSWORD 分别代表了 Redis 的连接信息,即地址、端口和密码。REDIS_KEY 是有序集合的键名,我们可以通过它来获取代理存储所使用的有序集合。

RedisClient 这个类可以用来操作 Redis 的有序集合,其中定义了一些方法来对集合中的元素进行处理,它的主要功能如下所示。

  • __init__ 方法是初始化的方法,其参数是 Redis 的连接信息,默认的连接信息已经定义为常量。我们在 __init__ 方法中初始化了 StrictRedis 类,建立了 Redis 连接。
  • add 方法用于向数据库添加代理并设置分数,默认的分数是 PROXY_SCORE_INIT,也就是 10,返回结果是添加的结果。
  • random 方法是随机获取代理的方法。首先获取 100 分的代理,然后随机选择一个返回。如果不存在 100 分的代理,则此方法按照排名来获取,选取前 100 名,然后随机选择一个返回,否则抛出异常。
  • decrease 方法是在代理检测无效的时候设置分数减 1 的方法,代理传入后,此方法将代理的分数减 1,如果分数达到最低值,那么代理就删除。
  • exists 方法用于判断代理是否存在集合中。
  • max 方法用于将代理的分数设置为 PROXY_SCORE_MAX,即 100,也就是代理有效时的设置。
  • count 方法用于返回当前集合的元素个数。
  • all 方法返回所有的代理列表,供检测使用。

定义好这些方法后,我们可以在后续的模块中调用此类来连接和操作数据库。如果要获取随机可用的代理,只需要调用 random 方法即可,得到的就是随机的可用代理。

获取模块

获取模块主要是为了从各大网站抓取代理并调用存储模块进行保存,代码实现见 https://github.com/Python3WebSpider/ProxyPool/tree/master/proxypool/crawlers。

获取模块的逻辑相对简单,比如我们可以定义一些抓取代理的方法,示例如下:

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 proxypool.crawlers.base import BaseCrawler
from proxypool.schemas.proxy import Proxy
import re


MAX_PAGE = 5
BASE_URL = 'http://www.ip3366.net/free/?stype=1&page={page}'


class IP3366Crawler(BaseCrawler):
"""
ip3366 crawler, http://www.ip3366.net/
"""
urls = [BASE_URL.format(page=i) for i in range(1, 8)]

def parse(self, html):
"""
parse html file to get proxies
:return:
"""
ip_address = re.compile('<tr>\s*<td>(.*?)</td>\s*<td>(.*?)</td>')
# \s * 匹配空格,起到换行作用
re_ip_address = ip_address.findall(html)
for address, port in re_ip_address:
proxy = Proxy(host=address.strip(), port=int(port.strip()))
yield proxy

这里定义了一个代理类 Crawler,用来抓取某一网站的代理,这里抓取的是 IP3366 的公开代理,通过 parse 方法来解析页面的源码并构造一个个 Proxy 对象返回即可。

另外,在其父类 BaseCrawler 里面定义了通用的页面抓取方法,它可以读取子类里面定义的 urls 全局变量并进行爬取,然后调用子类的 parse 方法来解析页面,代码实现如下:

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 retrying import retry
import requests
from loguru import logger


class BaseCrawler(object):
urls = []

@retry(stop_max_attempt_number=3, retry_on_result=lambda x: x is None)
def fetch(self, url, **kwargs):
try:
response = requests.get(url, **kwargs)
if response.status_code == 200:
return response.text
except requests.ConnectionError:
return

@logger.catch
def crawl(self):
"""
crawl main method
"""
for url in self.urls:
logger.info(f'fetching {url}')
html = self.fetch(url)
for proxy in self.parse(html):
logger.info(f'fetched proxy {proxy.string()} from {url}')
yield proxy

如果要扩展一个代理的 Crawler,只需要集成 BaseCrawler 并实现 parse 方法即可,扩展性较好。

因此,这一个个的 Crawler 就可以针对各个不同的代理网站进行代理的抓取。最后,有一个统一的方法将 Crawler 汇总起来,遍历调用即可。

如何汇总呢?这里我们可以检测代码只要定义有 BaseCrawler 的子类就算一个有效的代理 Crawler,可以直接通过遍历 Python 文件包的方式来获取,代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
import pkgutil
from .base import BaseCrawler
import inspect

# load classes subclass of BaseCrawler
classes = []
for loader, name, is_pkg in pkgutil.walk_packages(__path__):
module = loader.find_module(name).load_module(name)
for name, value in inspect.getmembers(module):
globals()[name] = value
if inspect.isclass(value) and issubclass(value, BaseCrawler) and value is not BaseCrawler:
classes.append(value)
__all__ = __ALL__ = classes

这里我们调用了 walk_packages 方法,遍历了整个 crawlers 模块下的类,并判断它是 BaseCrawler 的子类,那就将其添加到结果中并返回。

最后,只要将 classes 遍历并依次实例化,调用其 crawl 方法即可完成代理的爬取和提取,代码实现见 https://github.com/Python3WebSpider/ProxyPool/blob/master/proxypool/processors/getter.py。

检测模块

我们已经成功将各个网站的代理获取下来了,现在需要一个检测模块来对所有代理进行多轮检测。代理检测可用,分数就设置为 100,代理不可用,分数就减 1,这样可以实时改变每个代理的可用情况。如果要获取有效代理,只需要获取分数高的代理即可。

由于代理的数量非常多,为了提高代理的检测效率,这里使用异步请求库 aiohttp 来检测。

requests 作为一个同步请求库,我们在发出一个请求之后,程序需要等待网页加载完成之后才能继续执行。也就是这个过程会阻塞等待响应,如果服务器响应非常慢,比如一个请求等待十几秒,那么我们使用 requests 完成一个请求就会需要十几秒的时间,程序也不会继续往下执行,而在这十几秒的时间里,程序其实完全可以去做其他的事情,比如调度其他的请求或者进行网页解析等。

对于响应速度比较快的网站来说,requests 同步请求和 aiohttp 异步请求的效果差距没那么大。可对于检测代理来说,检测一个代理一般需要十多秒甚至几十秒的时间,这时候使用 aiohttp 异步请求库的优势就大大体现出来了,效率可能会提高几十倍不止。

所以,我们的代理检测使用异步请求库 aiohttp,实现示例如下所示:

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
import asyncio
import aiohttp
from loguru import logger
from proxypool.schemas import Proxy
from proxypool.storages.redis import RedisClient
from proxypool.setting import TEST_TIMEOUT, TEST_BATCH, TEST_URL, TEST_VALID_STATUS
from aiohttp import ClientProxyConnectionError, ServerDisconnectedError, ClientOSError, ClientHttpProxyError
from asyncio import TimeoutError

EXCEPTIONS = (
ClientProxyConnectionError,
ConnectionRefusedError,
TimeoutError,
ServerDisconnectedError,
ClientOSError,
ClientHttpProxyError
)

class Tester(object):
"""
tester for testing proxies in queue
"""

def __init__(self):
"""
init redis
"""
self.redis = RedisClient()
self.loop = asyncio.get_event_loop()

async def test(self, proxy: Proxy):
"""
test single proxy
:param proxy: Proxy object
:return:
"""
async with aiohttp.ClientSession(connector=aiohttp.TCPConnector(ssl=False)) as session:
try:
logger.debug(f'testing {proxy.string()}')
async with session.get(TEST_URL, proxy=f'http://{proxy.string()}', timeout=TEST_TIMEOUT,
allow_redirects=False) as response:
if response.status in TEST_VALID_STATUS:
self.redis.max(proxy)
logger.debug(f'proxy {proxy.string()} is valid, set max score')
else:
self.redis.decrease(proxy)
logger.debug(f'proxy {proxy.string()} is invalid, decrease score')
except EXCEPTIONS:
self.redis.decrease(proxy)
logger.debug(f'proxy {proxy.string()} is invalid, decrease score')

@logger.catch
def run(self):
"""
test main method
:return:
"""
# event loop of aiohttp
logger.info('stating tester...')
count = self.redis.count()
logger.debug(f'{count} proxies to test')
for i in range(0, count, TEST_BATCH):
# start end end offset
start, end = i, min(i + TEST_BATCH, count)
logger.debug(f'testing proxies from {start} to {end} indices')
proxies = self.redis.batch(start, end)
tasks = [self.test(proxy) for proxy in proxies]
# run tasks using event loop
self.loop.run_until_complete(asyncio.wait(tasks))


if __name__ == '__main__':
tester = Tester()
tester.run()

这里定义了一个类 Tester__init__ 方法中建立了一个 RedisClient 对象,供该对象中其他方法使用。接下来,定义了一个 test 方法,这个方法用来检测单个代理的可用情况,其参数就是被检测的代理。注意,test 方法前面加了 async 关键词,这代表这个方法是异步的。方法内部首先创建了 aiohttp 的 ClientSession 对象,可以直接调用该对象的 get 方法来访问页面。

测试链接在这里定义为常量 TEST_URL。如果针对某个网站有抓取需求,建议将 TEST_URL 设置为目标网站的地址,因为在抓取过程中,代理本身可能是可用的,但是该代理的 IP 已经被目标网站封掉了。例如,某些代理可以正常访问百度等页面,但是对知乎来说可能就被封了,所以我们可以将 TEST_URL 设置为知乎的某个页面的链接。当请求失败、代理被封时,分数自然会减下来,失效的代理就不会被取到了。

如果想做一个通用的代理池,则不需要专门设置 TEST_URL,既可以将其设置为一个不会封 IP 的网站,也可以设置为百度这类响应稳定的网站。

我们还定义了 TEST_VALID_STATUS 变量,这个变量是一个列表形式,包含了正常的状态码,如可以定义成 [200]。当然,某些目标网站可能会出现其他的状态码,可以自行配置。

程序在获取响应后需要判断响应的状态,如果状态码在 TEST_VALID_STATUS 列表里,则代表代理可用,可以调用 RedisClientmax 方法将代理分数设为 100,否则调用 decrease 方法将代理分数减 1,如果出现异常,也同样将代理分数减 1。

另外,我们设置了批量测试的最大值 TEST_BATCH,也就是一批测试最多 TEST_BATCH 个,这可以避免代理池过大时一次性测试全部代理导致内存开销过大的问题。当然,也可以用信号量来实现并发控制。

随后,在 run 方法里面获取了所有的代理列表,使用 aiohttp 分配任务,启动运行。这样在不断的运行过程中,代理池中无效代理的分数会一直被减 1,直至被清除,有效的代理则会一直保持 100 分,供随时取用。

这样测试模块的逻辑就完成了。

接口模块

通过上述 3 个模块,我们已经可以做到代理的获取、检测和更新,数据库就会以有序集合的形式存储各个代理及其对应的分数,分数 100 代表可用,分数越小代表越不可用。

但是我们怎样方便地获取可用代理呢?可以用 RedisClient 类直接连接 Redis,然后调用 random 方法。这样做没问题,效率很高,但是会有几个弊端。

  • 如果其他人使用这个代理池,他需要知道 Redis 连接的用户名和密码信息,这样很不安全。
  • 如果代理池需要部署在远程服务器上运行,而远程服务器的 Redis 只允许本地连接,那么我们就不能远程直连 Redis 来获取代理。
  • 如果爬虫所在的主机没有连接 Redis 模块,或者爬虫不是由 Python 语言编写的,那么我们就无法使用 RedisClient 来获取代理。
  • 如果 RedisClient 类或者数据库结构有更新,那么爬虫端必须同步这些更新,这样非常麻烦。

综上考虑,为了使代理池可以作为一个独立服务运行,我们最好增加一个接口模块,并以 Web API 的形式暴露可用代理。

这样一来,获取代理只需要请求接口即可,以上的几个缺点也可以避免。

我们使用一个比较轻量级的库 Flask 来实现这个接口模块,实现示例如下所示:

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
from flask import Flask, g
from proxypool.storages.redis import RedisClient
from proxypool.setting import API_HOST, API_PORT, API_THREADED

__all__ = ['app']

app = Flask(__name__)

def get_conn():
"""
get redis client object
:return:
"""
if not hasattr(g, 'redis'):
g.redis = RedisClient()
return g.redis

@app.route('/')
def index():
"""
get home page, you can define your own templates
:return:
"""
return '<h2>Welcome to Proxy Pool System</h2>'

@app.route('/random')
def get_proxy():
"""
get a random proxy
:return: get a random proxy
"""
conn = get_conn()
return conn.random().string()

@app.route('/count')
def get_count():
"""
get the count of proxies
:return: count, int
"""
conn = get_conn()
return str(conn.count())

if __name__ == '__main__':
app.run(host=API_HOST, port=API_PORT, threaded=API_THREADED)

这里我们声明了一个 Flask 对象,定义了 3 个接口,分别是首页、随机代理页和获取数量页。

运行之后,Flask 会启动一个 Web 服务,我们只需要访问对应的接口即可获取到可用代理。

调度模块

调度模块就是调用上面所定义的 3 个模块,将这 3 个模块通过多进程的形式运行起来,示例如下所示:

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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
import time
import multiprocessing
from proxypool.processors.server import app
from proxypool.processors.getter import Getter
from proxypool.processors.tester import Tester
from proxypool.setting import CYCLE_GETTER, CYCLE_TESTER, API_HOST, API_THREADED, API_PORT, ENABLE_SERVER, \
ENABLE_GETTER, ENABLE_TESTER, IS_WINDOWS
from loguru import logger

if IS_WINDOWS:
multiprocessing.freeze_support()

tester_process, getter_process, server_process = None, None, None

class Scheduler():
"""
scheduler
"""

def run_tester(self, cycle=CYCLE_TESTER):
"""
run tester
"""
if not ENABLE_TESTER:
logger.info('tester not enabled, exit')
return
tester = Tester()
loop = 0
while True:
logger.debug(f'tester loop {loop} start...')
tester.run()
loop += 1
time.sleep(cycle)

def run_getter(self, cycle=CYCLE_GETTER):
"""
run getter
"""
if not ENABLE_GETTER:
logger.info('getter not enabled, exit')
return
getter = Getter()
loop = 0
while True:
logger.debug(f'getter loop {loop} start...')
getter.run()
loop += 1
time.sleep(cycle)

def run_server(self):
"""
run server for api
"""
if not ENABLE_SERVER:
logger.info('server not enabled, exit')
return
app.run(host=API_HOST, port=API_PORT, threaded=API_THREADED)

def run(self):
global tester_process, getter_process, server_process
try:
logger.info('starting proxypool...')
if ENABLE_TESTER:
tester_process = multiprocessing.Process(target=self.run_tester)
logger.info(f'starting tester, pid {tester_process.pid}...')
tester_process.start()

if ENABLE_GETTER:
getter_process = multiprocessing.Process(target=self.run_getter)
logger.info(f'starting getter, pid{getter_process.pid}...')
getter_process.start()

if ENABLE_SERVER:
server_process = multiprocessing.Process(target=self.run_server)
logger.info(f'starting server, pid{server_process.pid}...')
server_process.start()

tester_process.join()
getter_process.join()
server_process.join()
except KeyboardInterrupt:
logger.info('received keyboard interrupt signal')
tester_process.terminate()
getter_process.terminate()
server_process.terminate()
finally:
# must call join method before calling is_alive
tester_process.join()
getter_process.join()
server_process.join()
logger.info(f'tester is {"alive" if tester_process.is_alive() else "dead"}')
logger.info(f'getter is {"alive" if getter_process.is_alive() else "dead"}')
logger.info(f'server is {"alive" if server_process.is_alive() else "dead"}')
logger.info('proxy terminated')


if __name__ == '__main__':
scheduler = Scheduler()
scheduler.run()

3 个常量 ENABLE_TESTERENABLE_GETTERENABLE_SERVER 都是布尔类型,表示测试模块、获取模块和接口模块的开关,如果都为 True,则代表模块开启。

启动入口是 run 方法,这个方法分别判断 3 个模块的开关。如果开关开启,启动时程序就新建一个 Process 进程,设置好启动目标,然后调用 start 方法运行,这样 3 个进程就可以并行执行,互不干扰。

3 个调度方法的结构也非常清晰。比如,run_tester 方法用来调度测试模块。首先声明一个 Tester 对象,然后进入死循环不断循环调用其 run 方法,执行完一轮之后就休眠一段时间,休眠结束之后重新再执行。这里休眠时间也定义为一个常量,如 20 秒,即每隔 20 秒进行一次代理检测。

最后,只需要调用 Schedulerrun 方法即可启动整个代理池。

以上内容便是整个代理池的架构和相应实现逻辑。

5.运行

接下来,我们将代码整合一下,将代理运行起来,运行之后的输出结果如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
2020-04-13 02:52:06.510 | INFO     | proxypool.storages.redis:decrease:73 - 60.186.146.193:9000 current score 10.0, decrease 1
2020-04-13 02:52:06.517 | DEBUG | proxypool.processors.tester:test:52 - proxy 60.186.146.193:9000 is invalid, decrease score
2020-04-13 02:52:06.524 | INFO | proxypool.storages.redis:decrease:73 - 60.186.151.147:9000 current score 10.0, decrease 1
2020-04-13 02:52:06.532 | DEBUG | proxypool.processors.tester:test:52 - proxy 60.186.151.147:9000 is invalid, decrease score
2020-04-13 02:52:07.159 | INFO | proxypool.storages.redis:max:96 - 60.191.11.246:3128 is valid, set to 100
2020-04-13 02:52:07.167 | DEBUG | proxypool.processors.tester:test:46 - proxy 60.191.11.246:3128 is valid, set max score
2020-04-13 02:52:17.271 | INFO | proxypool.storages.redis:decrease:73 - 59.62.7.130:9000 current score 10.0, decrease 1
2020-04-13 02:52:17.280 | DEBUG | proxypool.processors.tester:test:52 - proxy 59.62.7.130:9000 is invalid, decrease score
2020-04-13 02:52:17.288 | INFO | proxypool.storages.redis:decrease:73 - 60.167.103.74:1133 current score 10.0, decrease 1
2020-04-13 02:52:17.295 | DEBUG | proxypool.processors.tester:test:52 - proxy 60.167.103.74:1133 is invalid, decrease score
2020-04-13 02:52:17.302 | INFO | proxypool.storages.redis:decrease:73 - 60.162.71.113:9000 current score 10.0, decrease 1
2020-04-13 02:52:17.309 | DEBUG | proxypool.processors.tester:test:52 - proxy 60.162.71.113:9000 is invalid, decrease score

以上是代理池的控制台输出,可以看到这里将可用代理设置为 100,不可用代理分数减 1。

接下来,我们再打开浏览器,当前配置运行在 5555 端口,所以打开 http://127.0.0.1:5555 即可看到其首页,如图所示。

image-20210711001154883
图 9-2 首页

再访问 http://127.0.0.1:5555/random,即可获取随机可用代理,如图 9-3 所示。


图 9-3 获取随机可用代理

只需要访问此接口,即可获取一个随机可用代理,这非常方便。

获取代理的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
import requests

PROXY_POOL_URL = 'http://localhost:5555/random'

def get_proxy():
try:
response = requests.get(PROXY_POOL_URL)
if response.status_code == 200:
return response.text
except ConnectionError:
return None

这样便可以获取到一个随机代理了。它是字符串类型,此代理可以按照上一节所示的方法设置,如 requests 的使用方法如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
import requests

proxy = get_proxy()
proxies = {
'http': 'http://' + proxy,
'https': 'https://' + proxy,
}
try:
response = requests.get('http://httpbin.org/get', proxies=proxies)
print(response.text)
except requests.exceptions.ConnectionError as e:
print('Error', e.args)

有了代理池之后,再取出代理即可有效防止 IP 被封禁的情况。

6.总结

本节我们学习了一个代理池的设计思路和实现方案,有了这个代理池,我们就可以实时获取一些可用的代理了。相对之前的实战案例来说,整个代理池的代码量和逻辑复杂了比较多,建议可以好好理解和消化一下。

本节的代码地址为 https://github.com/Python3WebSpider/ProxyPool,代码库中还提供了基于 Docker 和 Kubernetes 的运行和部署操作,可以帮助我们更加快捷地运行代理池,同时本书后文也会介绍代理池的部署方法。

Python

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

前面我们介绍了多种请求库,如 urllib、requests、Selenium、Playwright 等用法,但是没有统一梳理代理的设置方法,本节我们来针对这些库来梳理下代理的设置方法。

1. 准备工作

在本节开始之前,请先根据上一节了解一下代理的基本原理,了解了基本原理之后我们可以更好地理解和学习本节的内容。

另外我们需要先获取一个可用代理,代理就是 IP 地址和端口的组合,就是 <ip>:<port> 这样的格式。如果代理需要访问认证,那就还需要额外的用户名密码两个信息。

那怎么获取一个可用代理呢?

使用搜索引擎搜索 “代理” 关键字,可以看到许多代理服务网站,网站上会有很多免费或付费代理,比如快代理的免费 HTTP 代理:https://www.kuaidaili.com/free/ 上面就写了很多免费代理,但是这些免费代理大多数情况下并不一定稳定,所以比较靠谱的方法是购买付费代理。付费代理的各大代理商家都有套餐,数量不用多,稳定可用即可,我们可以自行选购。

另外除了购买付费 HTTP 代理,我们也可以在本机配置一些代理软件,具体的配置方法可以参考 https://setup.scrape.center/proxy-client,软件运行之后会在本机创建 HTTP 或 SOCKS 代理服务,所以代理地址一般都是 127.0.0.1:<port> 这样的格式,不同的软件用的端口可能不同。

这里我的本机安装了一部代理软件,它会在本地 7890 端口上创建 HTTP 代理服务,即代理为 127.0.0.1:7890。另外,该软件还会在 7891 端口上创建 SOCKS 代理服务,即代理为 127.0.0.1:7891,所以只要设置了这个代理,就可以成功将本机 IP 切换到代理软件连接的服务器的 IP 了。

在本章下面的示例里,我使用上述代理来演示其设置方法,你也可以自行替换成自己的可用代理。

设置代理后,测试的网址是 http://httpbin.org/get,访问该链接我们可以得到请求的相关信息,其中返回结果的 origin 字段就是客户端的 IP,我们可以根据它来判断代理是否设置成功,即是否成功伪装了 IP。

好,接下来我们就来看下各个请求库的代理设置方法吧。

2. urllib

首先我们以最基础的 urllib 为例,来看一下代理的设置方法,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from urllib.error import URLError
from urllib.request import ProxyHandler, build_opener

proxy = '127.0.0.1:7890'
proxy_handler = ProxyHandler({
'http': 'http://' + proxy,
'https': 'http://' + proxy
})
opener = build_opener(proxy_handler)
try:
response = opener.open('https://httpbin.org/get')
print(response.read().decode('utf-8'))
except URLError as e:
print(e.reason)

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
{
"args": {},
"headers": {
"Accept-Encoding": "identity",
"Host": "httpbin.org",
"User-Agent": "Python-urllib/3.7",
"X-Amzn-Trace-Id": "Root=1-60e9a1b6-0a20b8a678844a0b2ab4e889"
},
"origin": "210.173.1.204",
"url": "https://httpbin.org/get"
}

这里我们需要借助 ProxyHandler 设置代理,参数是字典类型,键名为协议类型,键值是代理。注意,此处代理前面需要加上协议,即 http:// 或者 https://,当请求的链接是 HTTP 协议的时候,会使用 http 键名对应的代理,当请求的链接是 HTTPS 协议的时候,会使用 https 键名对应的代理。不过这里我们把代理本身设置为了 HTTP 协议,即前缀统一设置为了 http://,所以不论访问 HTTP 还是 HTTPS 协议的链接,都会使用我们配置的 HTTP 协议的代理进行请求。

创建完 ProxyHandler 对象之后,我们需要利用 build_opener 方法传入该对象来创建一个 Opener,这样就相当于此 Opener 已经设置好代理了。接下来直接调用 Opener 对象的 open 方法,即可访问我们所想要的链接。

运行输出结果是一个 JSON,它有一个字段 origin,标明了客户端的 IP。验证一下,此处的 IP 确实为代理的 IP,并不是真实的 IP。这样我们就成功设置好代理,并可以隐藏真实 IP 了。

如果遇到需要认证的代理,我们可以用如下的方法设置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from urllib.error import URLError
from urllib.request import ProxyHandler, build_opener

proxy = 'username:password@127.0.0.1:7890'
proxy_handler = ProxyHandler({
'http': 'http://' + proxy,
'https': 'http://' + proxy
})
opener = build_opener(proxy_handler)
try:
response = opener.open('https://httpbin.org/get')
print(response.read().decode('utf-8'))
except URLError as e:
print(e.reason)

这里改变的只是 proxy 变量,只需要在代理前面加入代理认证的用户名密码即可,其中 username 就是用户名,password 为密码,例如 username 为 foo,密码为 bar,那么代理就是 foo:bar@127.0.0.1:7890

如果代理是 SOCKS5 类型,那么可以用如下方式设置代理:

1
2
3
4
5
6
7
8
9
10
11
12
import socks
import socket
from urllib import request
from urllib.error import URLError

socks.set_default_proxy(socks.SOCKS5, '127.0.0.1', 7891)
socket.socket = socks.socksocket
try:
response = request.urlopen('https://httpbin.org/get')
print(response.read().decode('utf-8'))
except URLError as e:
print(e.reason)

此处需要一个 socks 模块,可以通过如下命令安装:

1
pip3 install PySocks

这里需要本地运行一个 SOCKS5 代理,运行在 7891 端口,运行成功之后和上文 HTTP 代理输出结果是一样的:

1
2
3
4
5
6
7
8
9
10
11
{
"args": {},
"headers": {
"Accept-Encoding": "identity",
"Host": "httpbin.org",
"User-Agent": "Python-urllib/3.7",
"X-Amzn-Trace-Id": "Root=1-60e9a1b6-0a20b8a678844a0b2ab4e889"
},
"origin": "210.173.1.204",
"url": "https://httpbin.org/get"
}

结果的 origin 字段同样为代理的 IP,代理设置成功。

3.requests 的代理设置

对于 requests 来说,代理设置非常简单,我们只需要传入 proxies 参数即可。

这里以我本机的代理为例,来看下 requests 的 HTTP 代理设置,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
import requests

proxy = '127.0.0.1:7890'
proxies = {
'http': 'http://' + proxy,
'https': 'http://' + proxy,
}
try:
response = requests.get('https://httpbin.org/get', proxies=proxies)
print(response.text)
except requests.exceptions.ConnectionError as e:
print('Error', e.args)

运行结果如下:

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-5e8f358d-87913f68a192fb9f87aa0323"
},
"origin": "210.173.1.204",
"url": "https://httpbin.org/get"
}

和 urllib 一样,当请求的链接是 HTTP 协议的时候,会使用 http 键名对应的代理,当请求的链接是 HTTPS 协议的时候,会使用 https 键名对应的代理,不过这里统一使用了 HTTP 协议的代理。

运行结果中的 origin 若是代理服务器的 IP,则证明代理已经设置成功。

如果代理需要认证,那么在代理的前面加上用户名和密码即可,代理的写法就变成如下所示:

1
proxy = 'username:password@127.0.0.1:7890'

这里只需要将 usernamepassword 替换即可。

如果需要使用 SOCKS 代理,则可以使用如下方式来设置:

1
2
3
4
5
6
7
8
9
10
11
12
import requests

proxy = '127.0.0.1:7891'
proxies = {
'http': 'socks5://' + proxy,
'https': 'socks5://' + proxy
}
try:
response = requests.get('https://httpbin.org/get', proxies=proxies)
print(response.text)
except requests.exceptions.ConnectionError as e:
print('Error', e.args)

这里我们需要额外安装一个包 requests[socks],相关命令如下所示:

1
pip3 install "requests[socks]"

运行结果是完全相同的:

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-5e8f364a-589d3cf2500fafd47b5560f2"
},
"origin": "210.173.1.204",
"url": "https://httpbin.org/get"
}

另外,还有一种设置方式,即使用 socks 模块,也需要像上文一样安装 socks 库。这种设置方法如下所示:

1
2
3
4
5
6
7
8
9
10
11
import requests
import socks
import socket

socks.set_default_proxy(socks.SOCKS5, '127.0.0.1', 7891)
socket.socket = socks.socksocket
try:
response = requests.get('https://httpbin.org/get')
print(response.text)
except requests.exceptions.ConnectionError as e:
print('Error', e.args)

使用这种方法也可以设置 SOCKS 代理,运行结果完全相同。相比第一种方法,此方法是全局设置的。我们可以在不同情况下选用不同的方法。

4. httpx 的代理设置

httpx 的用法本身就与 requests 的使用非常相似,所以其也是通过 proxies 参数来设置代理的,不过与 requests 不同的是,proxies 参数的键名不能再是 httphttps,而需要更改为 http://https://,其他的设置是一样的。

对于 HTTP 代理来说,设置方法如下:

1
2
3
4
5
6
7
8
9
10
11
import httpx

proxy = '127.0.0.1:7890'
proxies = {
'http://': 'http://' + proxy,
'https://': 'http://' + proxy,
}

with httpx.Client(proxies=proxies) as client:
response = client.get('https://httpbin.org/get')
print(response.text)

对于需要认证的代理,也是改下 proxy 的值即可:

1
proxy = 'username:password@127.0.0.1:7890'

这里只需要将 usernamepassword 替换即可。

运行结果和使用 requests 是类似的,结果如下:

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-httpx/0.18.1",
"X-Amzn-Trace-Id": "Root=1-60e9a3ef-5527ff6320484f8e46d39834"
},
"origin": "210.173.1.204",
"url": "https://httpbin.org/get"
}

对于 SOCKS 代理,我们需要安装 httpx-socks 库,安装方法如下:

1
pip3 install "httpx-socks[asyncio]"

这样会同时安装同步和异步两种模式的支持。

对于同步模式,设置方法如下:

1
2
3
4
5
6
7
8
9
import httpx
from httpx_socks import SyncProxyTransport

transport = SyncProxyTransport.from_url(
'socks5://127.0.0.1:7891')

with httpx.Client(transport=transport) as client:
response = client.get('https://httpbin.org/get')
print(response.text)

这里我们需要设置一个 transport 对象,并配置 SOCKS 代理的地址,同时在声明 httpx 的 Client 对象的时候传入 transport 参数即可,运行结果和刚才是一样的。

对于异步模式,设置方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import httpx
import asyncio
from httpx_socks import AsyncProxyTransport

transport = AsyncProxyTransport.from_url(
'socks5://127.0.0.1:7891')

async def main():
async with httpx.AsyncClient(transport=transport) as client:
response = await client.get('https://httpbin.org/get')
print(response.text)

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

和同步模式不同的是,transport 对象我们用的是 AsyncProxyTransport 而不是 SyncProxyTransport,同时需要将 Client 对象更改为 AsyncClient 对象,其他的不变,运行结果是一样的。

5. Selenium 的代理设置

Selenium 同样可以设置代理,这里以 Chrome 为例来介绍其设置方法。

对于无认证的代理,设置方法如下:

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

proxy = '127.0.0.1:7890'
options = webdriver.ChromeOptions()
options.add_argument('--proxy-server=http://' + proxy)
browser = webdriver.Chrome(options=options)
browser.get('https://httpbin.org/get')
print(browser.page_source)
browser.close()

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"args": {},
"headers": {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
"Accept-Encoding": "gzip, deflate",
"Accept-Language": "zh-CN,zh;q=0.9",
"Host": "httpbin.org",
"Upgrade-Insecure-Requests": "1",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.149 Safari/537.36",
"X-Amzn-Trace-Id": "Root=1-5e8f39cd-60930018205fd154a9af39cc"
},
"origin": "210.173.1.204",
"url": "http://httpbin.org/get"
}

代理设置成功,origin 同样为代理 IP 的地址。

如果代理是认证代理,则设置方法相对比较繁琐,具体如下所示:

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
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
import zipfile

ip = '127.0.0.1'
port = 7890
username = 'foo'
password = 'bar'

manifest_json = """{"version":"1.0.0","manifest_version": 2,"name":"Chrome Proxy","permissions": ["proxy","tabs","unlimitedStorage","storage","<all_urls>","webRequest","webRequestBlocking"],"background": {"scripts": ["background.js"]
}
}
"""
background_js = """
var config = {
mode: "fixed_servers",
rules: {
singleProxy: {
scheme: "http",
host: "%(ip) s",
port: %(port) s
}
}
}

chrome.proxy.settings.set({value: config, scope: "regular"}, function() {});

function callbackFn(details) {
return {
authCredentials: {username: "%(username) s",
password: "%(password) s"
}
}
}

chrome.webRequest.onAuthRequired.addListener(
callbackFn,
{urls: ["<all_urls>"]},
['blocking']
)
""" % {'ip': ip, 'port': port, 'username': username, 'password': password}

plugin_file = 'proxy_auth_plugin.zip'
with zipfile.ZipFile(plugin_file, 'w') as zp:
zp.writestr("manifest.json", manifest_json)
zp.writestr("background.js", background_js)
options = Options()
options.add_argument("--start-maximized")
options.add_extension(plugin_file)
browser = webdriver.Chrome(options=options)
browser.get('https://httpbin.org/get')
print(browser.page_source)
browser.close()

这里需要在本地创建一个 manifest.json 配置文件和 background.js 脚本来设置认证代理。运行代码之后,本地会生成一个 proxy_auth_plugin.zip 文件来保存当前配置。

运行结果和上例一致,origin 同样为代理 IP。

SOCKS 代理的设置也比较简单,把对应的协议修改为 socks5 即可,如无密码认证的代理设置方法为:

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

proxy = '127.0.0.1:7891'
options = webdriver.ChromeOptions()
options.add_argument('--proxy-server=socks5://' + proxy)
browser = webdriver.Chrome(options=options)
browser.get('https://httpbin.org/get')
print(browser.page_source)
browser.close()

运行结果是一样的。

6.aiohttp 的代理设置

对于 aiohttp 来说,我们可以通过 proxy 参数直接设置。HTTP 代理设置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
import asyncio
import aiohttp

proxy = 'http://127.0.0.1:7890'

async def main():
async with aiohttp.ClientSession() as session:
async with session.get('https://httpbin.org/get', proxy=proxy) as response:
print(await response.text())


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

如果代理有用户名和密码,像 requests 一样,把 proxy 修改为如下内容:

1
proxy = 'http://username:password@127.0.0.1:7890'

这里只需要将 usernamepassword 替换即可。

对于 SOCKS 代理,我们需要安装一个支持库 aiohttp-socks,其安装命令如下:

1
pip3 install aiohttp-socks

我们可以借助于这个库的 ProxyConnector 来设置 SOCKS 代理,其代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import asyncio
import aiohttp
from aiohttp_socks import ProxyConnector

connector = ProxyConnector.from_url('socks5://127.0.0.1:7891')

async def main():
async with aiohttp.ClientSession(connector=connector) as session:
async with session.get('https://httpbin.org/get') as response:
print(await response.text())


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

运行结果是一样的。

另外,这个库还支持设置 SOCKS4、HTTP 代理以及对应的代理认证,可以参考其官方介绍。

7. Pyppeteer 的代理设置

对于 Pyppeteer 来说,由于其默认使用的是类似 Chrome 的 Chromium 浏览器,因此其设置方法和 Selenium 的 Chrome 一样,如 HTTP 无认证代理设置方法都是通过 args 来设置的,实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import asyncio
from pyppeteer import launch

proxy = '127.0.0.1:7890'

async def main():
browser = await launch({'args': ['--proxy-server=http://' + proxy], 'headless': False})
page = await browser.newPage()
await page.goto('https://httpbin.org/get')
print(await page.content())
await browser.close()


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

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"args": {},
"headers": {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "zh-CN,zh;q=0.9",
"Host": "httpbin.org",
"Upgrade-Insecure-Requests": "1",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3494.0 Safari/537.36",
"X-Amzn-Trace-Id": "Root=1-5e8f442c-12b1ed7865b049007267a66c"
},
"origin": "210.173.1.204",
"url": "https://httpbin.org/get"
}

同样可以看到设置成功。

SOCKS 代理也一样,只需要将协议修改为 socks5 即可,代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import asyncio
from pyppeteer import launch

proxy = '127.0.0.1:7891'

async def main():
browser = await launch({'args': ['--proxy-server=socks5://' + proxy], 'headless': False})
page = await browser.newPage()
await page.goto('https://httpbin.org/get')
print(await page.content())
await browser.close()

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

运行结果也是一样的。

8. Playwright 的代理设置

相对 Selenium 和 Pyppeteer 来说,Playwright 的代理设置更加方便,其预留了一个 proxy 参数,可以在启动 Playwright 的时候设置。

对于 HTTP 代理来说,可以这样设置:

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(proxy={
'server': 'http://127.0.0.1:7890'
})
page = browser.new_page()
page.goto('https://httpbin.org/get')
print(page.content())
browser.close()

在调用 launch 方法的时候,我们可以传一个 proxy 参数,是一个字典。字典有一个必填的字段叫做 server,这里我们可以直接填写 HTTP 代理的地址即可。

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"args": {},
"headers": {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "zh-CN,zh;q=0.9",
"Host": "httpbin.org",
"Sec-Ch-Ua": "\" Not A;Brand\";v=\"99\", \"Chromium\";v=\"92\"",
"Sec-Ch-Ua-Mobile": "?0",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4498.0 Safari/537.36",
"X-Amzn-Trace-Id": "Root=1-60e99eef-4fa746a01a38abd469ecb467"
},
"origin": "210.173.1.204",
"url": "https://httpbin.org/get"
}

对于 SOCKS 代理,设置方法也是完全一样的,我们只需要把 server 字段的值换成 SOCKS 代理的地址即可:

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(proxy={
'server': 'socks5://127.0.0.1:7891'
})
page = browser.new_page()
page.goto('https://httpbin.org/get')
print(page.content())
browser.close()

运行结果和刚才也是完全一样的。

对于有用户名和密码的代理,Playwright 的设置也非常简单,我们只需要在 proxy 参数额外设置 username 和 password 字段即可,假如用户名和密码分别是 foo 和 bar,则设置方法如下:

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(proxy={
'server': 'http://127.0.0.1:7890',
'username': 'foo',
'password': 'bar'
})
page = browser.new_page()
page.goto('https://httpbin.org/get')
print(page.content())
browser.close()

这样我们就能非常方便地为 Playwright 实现认证代理的设置。

9.总结

以上我们就总结了各个请求库的代理使用方式,各种库的设置方法大同小异,学会了这些方法之后,以后如果遇到封 IP 的问题,我们可以轻松通过加代理的方式来解决。

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

Python

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

上一节我们使用 OpenCV 识别了图形验证码躯壳欧。这时候就有朋友可能会说了,现在深度学习不是对图像识别很准吗?那深度学习可以用在识别滑动验证码缺口位置吗?

当然也是可以的,本节我们就来了解下使用深度学习识别滑动验证码的方法。

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

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. 总结

本文介绍了代理的相关知识,这对后文我们进行一些反爬绕过的实现有很大的帮助,同时也为后文的一些抓包操作打下基础,需要好好理解。

本节由于涉及一些专业名词,本节的部分内容参考来源如下:

个人随笔

其实我个人感觉我的拖延症是非常严重的,很多时候事情一多,就一个也不想做,俗话说叫“论堆”了。也有很多时候脑海里有个长期大目标,但迟迟不肯动手。

一般我的现象是这样的:

  • 这件事好大好空啊,不知道从哪里下手。
  • 一想到开始好久没做过或者从没做过的一件事就觉得麻烦。
  • 一想到从那么一堆事情里面开始梳理开始做就觉得麻烦。

你中枪了吗?

然鹅,近期我发现了一个不错的方法,可以帮助我缓解拖延症。试用之后我的整体效率高了不少,同时还感到满满的成就感,同时还感觉时间多了不少。

其实方法很简单。

每天早上起来花 10 分钟把今天要做的事情按小时粒度全部列出来,不论是工作还是日常生活。

是的,这个方法我特意用了一周左右,感觉非常有效,效率高了很多!

我思考了下原因,每天低效或者有时候觉得无所事事的原因就是没有目标,尤其是没有短期目标。这个短期目标并不是一周、并不是一天,而应该拆解到小时(当然更牛逼的人会拆解到分钟,抱歉我还做不到)。

举个栗子。

比如我今天要上班,上班一般有些会需要开,有些代码需要些,有些文档需要整等等的,下班之后我还要运动下,还要写点东西,还要看点书,还要玩会游戏放松下。

OK,都没问题。

注:公司的邮件系统一般会有会议什么的安排,比如我公司就用的 Outlook 和 Teams,但是它就比较难和我个人的待做清单(滴答清单)有机地融合在一起,所以,我干脆直接全部以自己的待做清单为准,我会在自己的待做清单里面再把今天我要做的所有事情都梳理一遍。

比如说,我的一天可能就这样的:

  • 八点半:起床、洗漱、定早餐
  • 九点:吃早餐
  • 十点:开会讨论某个项目进度
  • 十一点:写某个功能 A 的代码
  • 十二点:午饭
  • 两点:整理某个项目文档
  • 三点:写某个功能 B 的代码
  • 四点半:开会讨论技术问题
  • 六点:晚饭
  • 七点:学习某个知识点
  • 八点半:写某个技术总结
  • 九点:跑步运动
  • 十点半:玩游戏放松
  • 十一点:看看新闻和书

OK,这些所有的我都会列到我的待做清单(滴答清单)中。

当然上面的安排都是随便写写的,每天都是不一样的,都是每天早上花 10 分钟左右想出来并列出来的,重要的是根据自己的实际情况合理分配一个预估时间点。

这个时间点不一定准,如果某个做不完,那稍微调整也没问题。

这样我每天从早上开始就觉得很有目标和动力,每做完一件事情就打勾,一天下来,十几项事情都勾完了,会很有成就感。

这样做有几个好处:

  • 每个小时都有清晰的事情可以做,而不是做完了一件事之后不知道下面做什么,就容易走神、跑偏甚至就玩起来一发不可收拾。
  • 每天记录下来不会漏掉一些重要的事情。
  • 做事情的节奏感很强。
  • 同时每天做完之后成就感也很强。

是的,每天都会感觉做的很充实,甚至每天的事情做完了之后还觉得多出来了一些时间,就会感觉到更满足,剩下的时间自己可以继续分配,或者就简单做自己想做的事情。

嗯,对我来说还是很有用的!

大家也去试试吧:每天早上起来花 10 分钟把今天要做的事情按小时粒度全部列出来,然后去执行吧!

更多精彩内容,请关注我的公众号「进击的 Coder」和「崔庆才丨静觅」。

Python

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

上一节我们学习了利用 OCR 技术对图形验证码进行识别的方法,但随着互联网技术的发展,各种新型验证码层出不穷,最具有代表性的便是滑动验证码了。

本节我们首先介绍下滑动验证码的验证流程,然后介绍一个简易的利用图像处理技术来识别滑动验证码缺口的方法。

1. 滑动验证码

说起滑动验证码,比较有代表性的服务商有极验、网易易盾等,验证码效果如图所示:

极验

网易易盾

验证码下方通常会有一个滑轨,同时带有文字提示「拖动滑块完成拼图」,我们需要按住滑轨上的滑块向右拖拽,这时候验证码最左侧的滑块便会跟随滑轨上的滑块向右移动,在验证码右侧会有一个滑块缺口,我们需要恰好将滑块拖动到目标缺口处,这时候就算验证成功了,验证成功的效果如图所示:

image-20210418114633889

所以,如果我们想要用爬虫来自动化完成这一流程的话,关键步骤有如下两个:

  • 识别出目标缺口的位置
  • 将缺口滑动到对应位置

其中第二步的实现有多种方式,比如我们可以用 Selenium 等自动化工具模拟完成这个流程,验证并登录成功之后获取对应的 Cookies 或 Token 等信息再进行后续的操作,但这种方法运行效率会比较低。另一种方法便是直接逆向验证码背后的 JavaScript 逻辑,将缺口信息直接传给 JavaScript 代码执行获取一些类似“密钥”的信息,再利用这些“密钥”进行下一步的操作。

注意:由于某些出于安全考虑的原因,本书不会再介绍第二步的具体操作,而是只针对于第一步的技术问题进行讲解。

因此,本节只会针对于第一步即如何识别出目标缺口的位置进行介绍,即给定一张验证码图片,如何用图像识别的方法识别出缺口的位置。

2.基本原理

本节我们会介绍利用 OpenCV 进行缺口识别的方法,输入一张带有缺口的验证码图片,输出缺口的位置(一般为缺口左侧横坐标)。

比如输入的验证码图片如下:

captcha

最后输出的识别结果如下:

image_label

本节介绍的方法是利用 OpenCV 进行基本的图像处理来实现的,主要步骤包括:

  • 对验证码图片进行高斯模糊滤波处理,消除部分噪声干扰
  • 对验证码图片应用边缘检测算法,通过调整相应阈值识别出滑块边缘
  • 对上一步得到的各个边缘轮廓信息,通过对比面积、位置、周长等特征筛选出最可能的轮廓位置,得到缺口位置。

3.准备工作

在本节开始之前请确保已经安装好了 python-opencv 库,安装方式如下:

1
pip3 install python-opencv

如果安装出现问题,可以参考详细的安装步骤:https://setup.scrape.center/python-opencv。

另外建议提前准备一张滑动验证码图片,样例图片下载地址:https://github.com/Python3WebSpider/CrackSlideCaptcha/blob/cv/captcha.png,当然也可以从 https://captcha1.scrape.center/ 自行截取,最终的图片如上文所示。

4.基础知识

在真正开始介绍之前,我们先需要了解一些 OpenCV 的基础 API,以帮助我们更好地理解整个原理。

高斯滤波

高斯滤波是用来去除图像中的一些噪声的,基本效果其实就是把一张图像变得模糊化,减少一些图像噪声干扰,从而为下一步的边缘检测做好铺垫。

OpenCV 提供了一个用于实现高斯模糊的方法,叫做 GaussianBlur,方法声明如下:

1
def GaussianBlur(src, ksize, sigmaX, dst=None, sigmaY=None, borderType=None)

比较重要的参数介绍如下:

  • src:即需要被处理的图像。
  • ksize:进行高斯滤波处理所用的高斯内核大小,它需要是一个元组,包含 x 和 y 两个维度。
  • sigmaX:表示高斯核函数在 X 方向的的标准偏差。
  • sigmaY:表示高斯核函数在 Y 方向的的标准偏差,若 sigmaY 为 0,就将它设为 sigmaX,如果 sigmaX 和 sigmaY 都是 0,那么 sigmaX 和 sigmaY 就通过 ksize 计算得出。

这里 ksize 和 sigmaX 是必传参数,对本节样例图片,ksize 我们可以取 (5, 5),sigmaX 可以取 0。

经过高斯滤波处理后,图像会变得模糊,效果如下:

image_gaussian_blur

边缘检测

由于验证码目标缺口通常具有比较明显的边缘,所以借助于一些边缘检测算法并通过调整阈值是可以找出它的位置的。目前应用比较广泛的边缘检测算法是 Canny,它是 John F. Canny 于 1986 年开发出来的一个多级边缘检测算法,效果还是不错的,OpenCV 也对此算法进行了实现,方法名称就叫做 Canny,声明如下:

1
def Canny(image, threshold1, threshold2, edges=None, apertureSize=None, L2gradient=None)

比较重要的参数介绍如下:

  • image:即需要被处理的图像。
  • threshold1、threshold2:两个阈值,分别为最小和最大判定临界点。
  • apertureSize:用于查找图像渐变的 Sobel 内核的大小。
  • L2gradient:指定用于查找梯度幅度的等式。

通常来说,我们只需要设定 threshold1 和 threshold2 即可,其数值大小需要视不同图像而定,比如本节样例图片可以分别取 200 和 450。

经过边缘检测算法处理后,一些比较明显的边缘信息会被保留下来,效果如下:

image-20210418142819176

轮廓提取

进行边缘检测处理后,我们可以看到图像中会保留有比较明显的边缘信息,下一步我们可以用 OpenCV 将边缘轮廓提取出来,这里需要用到 findContours 方法,方法声明如下:

1
def findContours(image, mode, method, contours=None, hierarchy=None, offset=None)

比较重要的参数介绍如下:

  • image:即需要被处理的图像。
  • mode:定义轮廓的检索模式,详情见 OpenCV 的 RetrievalModes 的介绍。
  • method:定义轮廓的近似方法,详情见 OpenCV 的 ContourApproximationModes 的介绍。

在这里,我们选取 mode 为 RETR_CCOMP,method 为 CHAIN_APPROX_SIMPLE,具体的选型标准可以参考 OpenCV 的文档介绍,这里不再展开讲解。

外接矩形

提取到轮廓之后,为了方便进行判定,我们可以将轮廓的外界矩形计算出来,这样方便我们根据面积、位置、周长等参数进行判定,以得出该轮廓是不是目标滑块的轮廓。

计算外接矩形使用的方法是 boundingRect,方法声明如下:

1
def boundingRect(array)

只有一个参数:

  • array:可以是一个灰度图或者 2D 点集,这里可以传入轮廓信息。

经过轮廓信息和外接矩形判定之后,我们可以得到类似如下结果:

image-20210418142752172

可以看到这样就能成功获取各个轮廓的外接矩形,接下来我们根据外接矩形的面积、和位置就能筛选出缺口对应的位置了。

轮廓面积

现在已经得到了各个外接矩形,但是很明显有些矩形不是我们想要的,我们可以根据面积、周长等来进行筛选,这里就需要用到计算面积的方法,叫做 contourArea,方法定义如下:

1
def contourArea(contour, oriented=None)

参数介绍如下:

  • contour:轮廓信息。
  • oriented:面向区域标识符。有默认值 False。若为 True,该函数返回一个带符号的面积值,正负取决于轮廓的方向(顺时针还是逆时针)。若为 False,表示以绝对值返回。

返回结果就是轮廓的面积。

轮廓周长

同样,周长的计算也有对应的方法,叫做 arcLength,方法定义如下:

1
def arcLength(curve, closed)

参数介绍如下:

  • curve:轮廓信息。
  • closed:表示轮廓是否封闭。

返回结果就是轮廓的周长。

以上内容介绍了一些 OpenCV 内置方法,了解了这些方法的用法,我们可以对下文的具体实现有更透彻的理解。

5.缺口识别

接下来我们就开始真正实现一下缺口识别算法了。

首先我们定义高斯滤波、边缘检测、轮廓提取的三个方法,实现如下:

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

GAUSSIAN_BLUR_KERNEL_SIZE = (5, 5)
GAUSSIAN_BLUR_SIGMA_X = 0
CANNY_THRESHOLD1 = 200
CANNY_THRESHOLD2 = 450

def get_gaussian_blur_image(image):
return cv2.GaussianBlur(image, GAUSSIAN_BLUR_KERNEL_SIZE, GAUSSIAN_BLUR_SIGMA_X)

def get_canny_image(image):
return cv2.Canny(image, CANNY_THRESHOLD1, CANNY_THRESHOLD2)

def get_contours(image):
contours, _ = cv2.findContours(image, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE)
return contours

三个方法介绍如下:

  • get_gaussian_blur_image:传入待处理图像信息,返回高斯滤波处理后的图像,ksize 定义为 (5, 5),sigmaX 定义为 0。
  • get_canny_image:传入待处理图像信息,返回边缘检测处理后的图像,threshold1 和 threshold2 分别定义为 200 和 450。
  • get_contours:传入待处理图像信息,返回检测到的轮廓信息,这里 mode 设定为 RETR_CCOMP,method 设定为 CHAIN_APPROX_SIMPLE。

原始待识别验证码命名为 captcha.png,接下来我们分别调用以上方法对验证码进行处理:

1
2
3
4
5
image_raw = cv2.imread('captcha.png')
image_height, image_width, _ = image_raw.shape
image_gaussian_blur = get_gaussian_blur_image(image_raw)
image_canny = get_canny_image(image_gaussian_blur)
contours = get_contours(image_canny)

原始图片我们命名为 image_raw 变量,读取图片之后获取其宽高像素信息,接着调用了 get_gaussian_blur_image 方法进行高斯滤波处理,返回结果命名为 image_gaussian_blur,接着将 image_gaussian_blur 传给 get_canny_image 方法进行边缘检测处理,返回结果命名为 image_canny,接着调用 get_contours 方法得到各个边缘的轮廓信息,赋值为 contours 变量。

好,得到各个轮廓信息之后,我们便需要根据各个轮廓的外接矩形的面积、周长、位置来筛选我们想要结果了。

所以,我们需要先确定怎么来筛选,比如面积我们可以设定一个范围,周长设定一个范围,缺口位置设定一个范围,通过实际测量,我们可以得出目标缺口的外接矩形的高度大约是验证码高度的 0.25 倍,宽度大约是验证码宽度的 0.15 倍。在允许误差 20% 的情况下,根据验证码的宽高信息我们大约可以计算出面积、周长的范围,同时缺口位置(缺口左侧)也有一个最小偏移值,比如最小偏移是验证码宽度的 0.2 倍,最大偏移是验证码宽度的 0.85 倍。综合这些内容,我们可以定义三个阈值方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def get_contour_area_threshold(image_width, image_height):
contour_area_min = (image_width * 0.15) * (image_height * 0.25) * 0.8
contour_area_max = (image_width * 0.15) * (image_height * 0.25) * 1.2
return contour_area_min, contour_area_max

def get_arc_length_threshold(image_width, image_height):
arc_length_min = ((image_width * 0.15) + (image_height * 0.25)) * 2 * 0.8
arc_length_max = ((image_width * 0.15) + (image_height * 0.25)) * 2 * 1.2
return arc_length_min, arc_length_max

def get_offset_threshold(image_width):
offset_min = 0.2 * image_width
offset_max = 0.85 * image_width
return offset_min, offset_max

三个方法介绍如下:

  • get_contour_area_threshold:定义目标轮廓的下限和上限面积,分别为 contour_area_min 和 contour_area_max。
  • get_arc_length_threshold:定义目标轮廓的下限和上限周长,分别为 arc_length_min 和 arc_length_max。
  • get_offset_threshold:定义目标轮廓左侧的下限和上限偏移量,分别为 offset_min 和 offset_max。

最后我们只需要遍历各个轮廓信息,根据上述限定条件进行筛选,最后得出目标轮廓信息即可,实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
contour_area_min, contour_area_max = get_contour_area_threshold(image_width, image_height)
arc_length_min, arc_length_max = get_arc_length_threshold(image_width, image_height)
offset_min, offset_max = get_offset_threshold(image_width)
offset = None
for contour in contours:
x, y, w, h = cv2.boundingRect(contour)
if contour_area_min < cv2.contourArea(contour) < contour_area_max and \
arc_length_min < cv2.arcLength(contour, True) < arc_length_max and \
offset_min < x < offset_max:
cv2.rectangle(image_raw, (x, y), (x + w, y + h), (0, 0, 255), 2)
offset = x
cv2.imwrite('image_label.png', image_raw)
print('offset', offset)

这里我们首先调用了 get_contour_area_threshold、get_arc_length_threshold、get_offset_threshold 方法获取了轮廓的判定阈值,然后遍历了 contours 根据这些阈值进行了筛选,最终得到的外接矩形的 x 值就是目标缺口的偏移量。

同时目标缺口的外接矩形我们也调用了 rectangle 方法进行了标注,最终将其保存为 image_label.png 图像。

最终运行结果如下:

1
offset 163

同时得到输出的 image_label.png 文件如下:

image_label

这样我们就成功提取出来了目标滑块的位置了,本节的问题得以解决。

注意:出于安全考虑,本书只针对于第一步 - 识别验证码缺口位置的的技术问题进行讲解,关于怎样去模拟滑动或者绕过验证码,本书不再进行介绍,可以自行搜索相关资料探索。

6. 总结

本节我们介绍了利用 OpenCV 来识别滑动验证码缺口的方法,其中涉及到了一些关键的图像处理和识别技术,如高斯模糊、边缘检测、轮廓提取等算法。了解了基本的图像识别技术后,我们可以举一反三,将其应用到其他类型的工作上,也会很有帮助。

本节代码:https://github.com/Python3WebSpider/CrackSlideCaptcha/tree/cv,注意这里是 cv 分支。

Python

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

各类网站采用了各种各样的措施来反爬虫,其中一个措施便是使用验证码。随着技术的发展,验证码的花样越来越多。验证码最初是几个数字组合的简单的图形,后来加入了英文字母和混淆曲线。还有一些网站使用了中文字符验证码,这使得识别愈发困难。

12306 验证码的出现使得行为验证码开始发展起来,用过 12306 的用户肯定多少为它的验证码头疼过,我们需要识别文字,点击与文字描述相符的图片,验证码完全正确,验证才能通过。随着技术的发展,现在这种交互式验证码越来越多,如滑动验证码需要将对应的滑块拖动到指定位置才能完成验证,点选验证码则需要点击正确的图形或文字才能通过验证。

验证码变得越来越复杂,爬虫的工作也变得越发艰难,有时候我们必须通过验证码的验证才可以访问页面。

本章就针对验证码的识别进行统一讲解,涉及的验证码有普通图形验证码、滑动验证码、点选验证码、手机验证码等,这些验证码识别的方式和思路各有不同,有直接使用图像处理库完成的,有的则是借助于深度学习技术完成的,有的则是借助于一些工具和平台完成的。虽然说技术各有不同,但了解这些验证码的识别方式之后,我们可以举一反三,用类似的方法识别其他类型验证码。

我们首先来看最简单的一种验证码,即图形验证码,这种验证码最早出现,现在依然也很常见,一般由 4 位左右字母或者数字组成。

例如这个案例网站 https://captcha7.scrape.center/ 就可以看到类似的验证码,如图所示:

这类验证码整体上比较规整,没有过多干扰线和干扰点,且文字没有大幅度的变形和旋转。

对于这一类的验证码我们就可以使用 OCR 技术来进行识别。

1. OCR 技术

OCR,即 Optical Character Recognition,中文翻译叫做光学字符识别。它是指电子设备(例如扫描仪或数码相机)检查纸上打印的字符,通过检测暗、亮的模式确定其形状,然后用字符识别方法将形状翻译成计算机文字的过程。OCR 现在已经广泛应用于生产生活中,如文档识别、证件识别、字幕识别、文档检索等等。当然对于本节所述的图形验证码的识别也没有问题。

本节我们会以当前示例网站的验证码为例来讲解利用 OCR 来识别图形验证码的流程,输入上是一上图验证码的图片,输出就是验证码识别结果。

2. 准备工作

识别图形验证码需要 Tesserocr 库,本库的安装相对没有那么简单,可以参考 https://setup.scrape.center/tesserocr

另外在本节学习过程中还需要安装 Selenium、Pillow、Numpy,Retrying 库用作模拟登录、图像处理和操作重试,我们可以使用 pip3 来进行安装:

1
pip3 install selenium pillow numpy retrying

如果某个库安装有问题,可以参考如下链接:

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

3. 获取验证码

为了便于实验,我们先将验证码的图片保存到本地。

我们可以在浏览器中打开上述示例网站,然后右键点击这张验证码图片,将其保存到本地,命名为 captcha.png,示例如图所示:

这样我们就可以得到一张验证码图片,以供测试识别使用。

4. 识别测试

接下来新建一个项目,将验证码图片放到项目根目录下,用 tesserocr 库识别该验证码,代码如下所示:

1
2
3
4
5
6
import tesserocr
from PIL import Image

image = Image.open('captcha.png')
result = tesserocr.image_to_text(image)
print(result)

在这里我们新建了一个 Image 对象,调用了 tesserocr 的 image_to_text方法。传入该 Image 对象即可完成识别,实现过程非常简单,结果如下所示:

1
d241

另外,tesserocr 还有一个更加简单的方法,这个方法可直接将图片文件转为字符串,代码如下所示:

1
2
import tesserocr
print(tesserocr.file_to_text('captcha.png'))

可以得到同样的输出结果。

这时候我们可以看到,通过 OCR 技术我们便可以成功识别出验证码的内容了。

5. 验证码处理

接下来我们换一个验证码,将其命名为 captcha2.png,如图所示。

重新用下面的代码来测试:

1
2
3
4
5
6
import tesserocr
from PIL import Image

image = Image.open('captcha2.png')
result = tesserocr.image_to_text(image)
print(result)

可以看到如下输出结果:

1
-b32d

这次识别和实际结果有偏差,多了一些干扰结果,这是因为验证码内的多余的点干扰了图像的识别,导致出现了一些多余的内容。

对于这种情况,我们可以需要做一下额外的处理,把一些干扰信息去掉。

这里观察到图片里面其实有一些杂乱的点,而这些点的颜色大都比文本更浅一点,因此我们可以做一些预处理,将干扰的点通过颜色来排除掉。

我们可以首先将原来的图像转化为数组看下维度:

1
2
3
4
5
6
7
import tesserocr
from PIL import Image
import numpy as np

image = Image.open('captcha2.png')
print(np.array(image).shape)
print(image.mode)

运行结果如下:

1
2
(38, 112, 4)
RGBA

可以发现这个图片其实是一个三维数组,前两维 38 和 112 代表其高和宽,最后一维 4 则是每个像素点的表示向量。为什么是 4 呢,因为最后一维是一个长度为 4 的数组,分别代表 R(红色)、G(绿色)、B(蓝色)、A(透明度),即一个像素点有四个数字表示。那为什么是 RGBA 四个数字而不是 RGB 或其他呢?这是因为 image 的模式 mode 是 RGBA,即有透明通道的真彩色,我们看到第二行输出也印证了这一点。

模式 mode 定义了图像的类型和像素的位宽,一共有 9 种类型:

  • 1:像素用 1 位表示,Python 中表示为 True 或 False,即二值化。
  • L:像素用 8 位表示,取值 0-255,表示灰度图像,数字越小,颜色越黑。
  • P:像素用 8 位表示,即调色板数据。
  • RGB:像素用 3x8 位表示,即真彩色。
  • RGBA:像素用 4x8 位表示,即有透明通道的真彩色。
  • CMYK:像素用 4x8 位表示,即印刷四色模式。
  • YCbCr:像素用 3x8 位表示,即彩色视频格式。
  • I:像素用 32 位整型表示。
  • F:像素用 32 位浮点型表示。

为了方便处理,我们可以将 RGBA 模式转为更简单的 L 模式,即灰度图像。

我们可以利用 Image 对象的 convert 方法参数传入 L,即可将图片转化为灰度图像,代码如下所示:

1
2
image = image.convert('L')
image.show()

或者传入 1 即可将图片进行二值化处理,如下所示:

1
2
image = image.convert('1')
image.show()

在这里我们就转为灰度图像,然后根据阈值筛选掉图片中的干扰点,代码如下:

1
2
3
4
5
6
7
8
9
10
from PIL import Image
import numpy as np

image = Image.open('captcha2.png')
image = image.convert('L')
threshold = 50
array = np.array(image)
array = np.where(array > threshold, 255, 0)
image = Image.fromarray(array.astype('uint8'))
image.show()

在这里,变量 threshold 代表灰度的阈值,这里设置为 50。接着我们将图片 image 转化为了 Numpy 数组,接着利用 Numpy 的 where 方法对数组进行筛选和处理,这里指定了大于阈值的就设置为 255,即白色,否则就是 0,即黑色。

最后看下图片处理完之后是什么结果:

我们发现原来验证码中的很多点已经被去掉了,整个验证码变得黑白分明。这时重新识别验证码,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
import tesserocr
from PIL import Image
import numpy as np

image = Image.open('captcha2.png')
image = image.convert('L')
threshold = 50
array = np.array(image)
array = np.where(array > threshold, 255, 0)
image = Image.fromarray(array.astype('uint8'))
print(tesserocr.image_to_text(image))

即可发现运行结果变成如下所示:

1
b32d

所以,针对一些有干扰的图片,我们可以做一些去噪处理,这会提高图片识别的正确率。

6. 识别实战

最后,我们可以来尝试下用自动化的方式来对案例进行验证码识别处理,这里我们使用 Selenium 来完成这个操作,代码如下:

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
import time
import re
import tesserocr
from selenium import webdriver
from io import BytesIO
from PIL import Image
from retrying import retry
from selenium.webdriver.support.wait import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.by import By
from selenium.common.exceptions import TimeoutException
import numpy as np


def preprocess(image):
image = image.convert('L')
array = np.array(image)
array = np.where(array > 50, 255, 0)
image = Image.fromarray(array.astype('uint8'))
return image


@retry(stop_max_attempt_number=10, retry_on_result=lambda x: x is False)
def login():
browser.get('https://captcha7.scrape.center/')
browser.find_element_by_css_selector('.username input[type="text"]').send_keys('admin')
browser.find_element_by_css_selector('.password input[type="password"]').send_keys('admin')
captcha = browser.find_element_by_css_selector('#captcha')
image = Image.open(BytesIO(captcha.screenshot_as_png))
image = preprocess(image)
captcha = tesserocr.image_to_text(image)
captcha = re.sub('[^A-Za-z0-9]', '', captcha)
browser.find_element_by_css_selector('.captcha input[type="text"]').send_keys(captcha)
browser.find_element_by_css_selector('.login').click()
try:
WebDriverWait(browser, 10).until(EC.presence_of_element_located((By.XPATH, '//h2[contains(., "登录成功")]')))
time.sleep(10)
browser.close()
return True
except TimeoutException:
return False


if __name__ == '__main__':
browser = webdriver.Chrome()
login()

在这里我们首先定义了一个 preprocess 方法,用于验证码的噪声处理,逻辑就和前面说的是一样的。

接着我们定义了一个 login 方法,其逻辑执行步骤是:

  • 打开样例网站
  • 找到用户名输入框,输入用户名
  • 找到密码输入框,输入密码
  • 找到验证码图片并截取,转化为 Image 对象
  • 预处理验证码,去除噪声
  • 对验证码进行识别,得到识别结果
  • 识别结果去除一些非字母和数字字符
  • 找到验证码输入框,输入验证码结果
  • 点击登录按钮
  • 等待「登录成功」字样的出现,如果出现则证明登录成功,否则重复以上步骤重试。

在这里我们还用到了 retrying 来指定了重试条件和重试次数,以保证在识别出错的情况下反复重试,增加总的成功概率。

运行代码我们可以观察到浏览器弹出并执行以上流程,可能重试几次后得到登录成功的页面,运行过程如图所示:

登录成功后的结果如图所示:

到这里,我们就能成功通过 OCR 技术识别成功验证码,并将其应用到模拟登录的过程中了。

7. 总结

本节我们了解了利用 Tesserocr 识别验证码的过程并将其应用于实战案例中实现了模拟登录。为了提高 Tesserocr 的识别准确率,我们可以对验证码图像进行预处理去除一些干扰,识别准确率会大大提高。但总归来说 Tesserocr 识别验证码的准确率并不是很高,下一节我们来介绍其他识别验证码的方案。

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

本文参考资料:

个人随笔

这篇文章其实是我对一本书《你当像鸟飞往你的山》的读后感。

你可能在逛书店的时候看到过这本书,因为这本书一直占据畅销书的前几名,也曾作为比尔盖茨年度荐书 第一名和比尔盖茨年度荐书第一名畅销世界。

说起来这本书,真的我从开始读到完全读完花了大半年的时间,我其实对阅读这样的“长篇”记述性的书读起来并不怎么在行,一直断断续续在读,也一直断断续续在领悟这本书传达给我的深意,于是最后,这篇文章就诞生了。

本书的作者叫塔拉·韦斯特弗,在 1986 年生于美国爱达荷州,在她 17 岁之前从来没有上过学,一直在大山里和父母、哥哥姐姐们生活在肥料厂,但她通过自身的努力考上了大学,进而取得了剑桥大学的博士学位。一开始我看这本书的宣传和介绍以为就是一本讲差生克服种种困难逆袭变身学霸的故事,但是读了之后才发现,整本书的重点并不在描写自己多么刻苦学习,描写的是自己的整个成长和转变历程,是一个有创伤、成长和最终蜕变的故事,讲述的是作者如何冲破原生家庭的重重阻碍、如何和自己心理作斗争和抉择、如何寻找到真正自我的故事。

这本书的中文名叫《你当像鸟飞往你的山》,但英文就叫《Educated》,看起来毫不相关,一开始我非常诧异这俩名字到底有啥联系,然而读了之后,我才发现二者联系还是很密切的。Educated 意思就是教育,这是本书的核心关键词,作者通过教育救赎了自己,通过不断地教育,完成了自我的成长和蜕变。而《你当像鸟飞往你的山》其实就是在教育之上的两层含义,包括逃离和追寻真正的自我。

作者塔拉出生在一个非常让我难以想象的家庭之中,在一座大山里,父亲是摩门教的忠实信徒,同时性格比较抑郁狂躁,他不相信政府、学校、医院等任何组织,同时也在塔拉小的时候向她灌输类似的理念。而且父亲觉得世界末日终有一天会到来的,所以他还在自家的地窖中存储各种食物、罐头、汽油等等物资,母亲则是基本依附于父亲的,整体的家庭就是“男尊女卑”。塔拉一共有五个哥哥和一个姐姐,父亲会让自己的各个儿子女儿去废料厂搬运和整理各种废弃物、钢铁赚钱。没错,十几年来,塔拉就是这么过来的。其实我们就想象成,在一个偏远的大山里面,塔拉整个家庭生活条件困苦,从小没有上过学,和几个哥哥姐姐、父亲去拣拾废料为生,同时期间也受父母灌输的思想教育而成长。我们想想,假如真的有这么一个人,可能她的一生就在这样的节奏下慢慢过去了,从出生到死亡,伴随着自己生活的就是一堆废铜烂铁,生活一眼望得到头。

在这样阴暗的生活条件下,会有一束光吗?有的,她的哥哥泰勒就是那一束光,是他引领塔拉走向了教育的大门。

在本书的扉页印着四个字,“献给泰勒”,所以在阅读之初我就比较诧异,这个泰勒是何许人也?所以在阅读的时候我就去留意泰勒这个人物。真的,可以说,没有泰勒,塔拉的生活可能就如同前面所说的那样,在大山里面终其她的一生了。

泰勒是个比较内向的孩子,还容易紧张,还天生口吃,他唯一的朋友可能就是唱片和书籍。在塔拉年少的时候,泰勒带塔拉了解了唱片、书籍等东西,同时泰勒还通过自学考取了杨百翰大学。泰勒曾经对塔拉说过:“你可以选择像现在生活,也可以选择像我一样,考进杨百翰大学。”塔拉后来选择了后者,在和父亲一起打工的日子里,塔拉找书自学,终于她成功考取了杨百翰大学。后来,她凭借自己的努力和天赋,后来又获得了剑桥大学的博士学位,完成了自己的蜕变。

但这个过程是非常艰难的,尤其是她从小接受了原生家庭这样的启蒙,迈出这一步对她来说何其艰难。塔拉的蜕变和成长历经了各种反复挣扎和思想斗争,也承受了难以想象的艰辛。

在我理解,难点可能有这么两点:

  • 塔拉从小就没有接受过什么教育,家里也很难给到什么支持,她的学习条件很差,考取大学之前都得挤时间来学习。考上大学之后基础也肯定不好,跟上同龄人甚至超越同龄人需要付出常人难以想象的努力。
  • 从小塔拉就在大山里面成长,她从小的思想就被父母灌输,原生家庭的影响是巨大的。很多很多人可能在这样的环境下就妥协了,放弃挣扎了,逃离这样的生活需要面临巨大的阻力,不仅来自于家庭的阻拦,更多的是冲破自身的思想禁锢,能思考到自己究竟想要什么。

是的,塔拉最终做到了。她很努力,当然也很聪慧,同时也有不少贵人相助。比如她的老师给她思想上的引导,帮她申请助学金,推荐上剑桥大学等等。这几点我觉得真的都是缺一不可。我们可以说她运气不错,但是少了她自身的努力和拼搏,再多的聪慧和贵人相助都是白搭。

塔拉在蜕变和成长的过程中学了很多哲学、历史等书籍的熏陶,在学习过程中,她了解到了一些思想上的差异和碰撞,比如即使是史学家也可能由于认知局限而产生错误的观点。所以,她也慢慢思考到,父亲从小对自己灌输的观点也未必是正确的。在不断学习和教育的过程中,塔拉的认知被提高,不断更新自己的挂念,不断重塑自己的思想,最终蜕变并成长成了更好的自我。

但不得不提的是,塔拉最终逃离大山,最终也付出了和家庭分离甚至说决裂的代价。后来她和她的父母、在大山的哥哥们几乎没有了联系。多年之后,塔拉试图回到大山和家庭和解,但是最终也没有看到团圆的结局,毕竟真的没法回去了。但塔拉为什么选择去尝试和解呢?或许还是出于爱吧。其实塔拉的父母还是爱塔拉的,有一个画面我印象非常深刻,在父亲得知她要去大洋彼岸的剑桥大学读书的时候,父亲对塔拉说:“无论你在哪个角落,我们都可以去找你。我在地下埋了一千加仑汽油,世界末日来临时我可以去接你,带你回家,让你平平安安的,但要是你去了大洋彼岸…”。是的,父亲是爱她的,但爱并不能让她放弃自己的人生。

塔拉说:“你可以爱一个人,但仍然选择和他说再见;你可以每天都想念一个人,但仍然庆幸他已不在你的生命中…”。

嗯,写到这里,我又理解到了什么。

是的,或许总有一些人即使互相深爱着彼此,但如果二者无法达成观念上的一致,无法真正理解对方的话,最好的结局或许就是分开吧。在这里我说的是塔拉的家庭的理解,但也可以扩展到其他的地方。

嗯,最近我也在看阿德勒心理学,像《被讨厌的勇气》,阿德勒有这样的一句话:“幸福的人用童年治愈一生,不幸的人用一生治愈童年。”原生家庭的影响对一个人真的是巨大的,这个影响可能需要用一生来弥补和改变。

但是,这本书告诉我们,生活在不幸的家庭,将来就一定会不幸吗?未必的。塔拉面临这样的家庭,面临这样的逆境,她最终成功了,一般情况下,我们面临的困难可能比塔拉小多了,塔拉可以,我们其实也可以。但这个蜕变的过程中,什么才是最重要的呢?是自己强大的内心,只有内心的强大的力量才能促成这种改变。

我想进一步展开升华下主旨。

反过来映射一下,对于我们的家庭来说。可能从小父母就说过:“我这么做是为了你好”,年少的时候,我们很多事都是听父母的,小时候的很多的选择一直到长大,读中学、上大学、选专业、就业、结婚、生子仿佛很多事情都很多受到父母的引导、操控,甚至我们自己就主动变得事事都去听父母的,甚至习以为常,甚至都觉得不应该去反抗,以为这些都是理所当然。但想想,真的是对的吗?

另外试想,如果说这一生,我们就是在这样就业、赚钱、结婚、生子、抚养孩子,终老一生,这是我们想要的吗?你心甘情愿自己的一生就这么过去吗?不想着去经历些什么吗?你小时候的梦想还在吗?多问问自己,真的是这样的吗?我们一直追逐的金钱、地位,到头来真的是最重要的吗?我们从小到老,承担着的这些角色,这些生活,真的是自己想要的吗?如果你的确想清楚了,这就是你想要的,或者和父母的设想完全一致,那可以,勇敢去做。如果答案是否,那或许要想想,是否要做出一些改变?

嗯,我还想说的是,父母不应该以爱之名去操控孩子的成长,可以给予帮助,但不能决定孩子的未来。反过来,孩子也是一样,不能以自己以为的正确去改变父母。

每个人,注定地只能去自我探寻自我、自我选择、自我教育、自我塑造。

走大家都觉得“正确”的事情很难,改变也可能很难,想清楚,每一种方式都会有牺牲,每个改变都可能带来不一样的生活。

每个人的生命其实都是一种自我救赎,有时虽然孤独,但是充满力量,遵从自己的内心,想想自己真正想要什么,想想自己想变成怎样的人。如果现在没有答案,那多去看看,多去思考思考。

希望你和我,都能有一个无悔的人生。

你当像鸟,飞往你的山。

更多精彩内容,请关注我的公众号「进击的 Coder」和「崔庆才丨静觅」。

个人随笔

利用好搜索引擎

互联网时代,我们面临的是知识爆炸而不是知识匮乏。网上有很多很多好的学习资源,比如一些学习文档、疑难问题解决方案,很多都可以在网上搜到。

虽然网上有这些内容,但不同的搜索方法和用不同的搜索引擎搜到的结果就大不一样。

比如说,我们平时遇到了一些编程相关的问题,在谷歌中用英文搜索的结果在绝大多数情况下都会比在百度用中文搜索的结果好。比如说前者的结果通常就会是一些官方文档说明,而后者大多都是一些中文版 CSDN 博客,谁更前沿、更权威高下立判了。

我是做技术相关的,所以对于一些技术内容,我个人是非常建议首选谷歌英文搜索的,多数情况下能够更快更好地解决问题。

多看一手资料

我们知道,现在网上很多框架、工具,其都会配一个官方文档,比如 Python 的某个工具库、Vue 的的某个脚手架等等,同时很多源码也会在 GitHub 上公开。

我们如果要进行学习这些内容的话,我个人推荐尽量多去查询一些一手的资料,比如一些入门使用方法,可以尽量去看官方文档的一些 Get Started 部分;比如一些疑难 Issues,可以去 GitHub Issues 区搜索下关键词。

当然有的同学会说,官方文档都是些英文的,我看不懂啊,所以通常都会去搜索一些博客文章来看,比如一些中文博客的教程。可以是可以,也的确有一些优秀的博主能写出一些不错的文章,但毕竟还是少,而且这永远都不是一手资料,多数情况下,博客的文章也会有一些实效性的问题或者难免会出现一些错误。

所以,我个人还是推荐尽量去看一手资料。但一手的资料通常英文居多,但还是建议大家能够尽量地适应去读英文文档,如果能够做到的话,我们获取知识的能力会继续上一个台阶。

时间管理和分类

每个人的精力都是有限的,一些工作和其他的琐事可以说是无穷无尽的。

所以,在做一些工作和学习的事情的时候,我们需要去区分优先级和重要程度,也就是能够合理地管理好自己要做的事情。

我个人会用一个清单软件(我用的滴答清单)来记录我所有需要做的事情,然后会给每个事情进行分类,比如说我会划分工作、学习、私人、购物、电影等等各种分类,然后每个任务都会指定好优先级和过期事件。指定好了之后,清单软件有一些功能可以给我筛选出来哪些是紧急重要,哪些是不紧急重要,哪些是紧急不重要等等的事情,然后我会有选择地去做对应的事情。比如说我会把大量的时间花在重要的事情上,不紧急不重要的可以看看能否尽量规避或者找人代做,总之不同的类型需要有不同的应对方案。

另外还有一些习惯养成类的事情,比如说定期的学习计划、定期的健身、定期的冥想、定期的跑步,也可以列入到个人的事件规划中。我通常以打卡的形式记录在清单软件中,每天都会有定期提醒,这样做完之后我就会打一次卡,看着越来越满的打卡记录,会感觉比较有成就感,大家也可以来试试。

要有一个短期目标

我们有时候做事的时候,脑子里知道很多长远的目标是什么。比如说,我长远计划里面有一个事情是要做一个网站系统,这是一个大目标,同时也有一个长远计划是要学习精通一门编程语言,这也是一个大目标。很多大目标都在我们的潜意识里面存留着。

现在问一个问题,虽然这些大的目标都在我们脑海里,但有没有一个时间,自己突然闲下来或者临时没有事的时候,却不知道这个空闲的时间去做什么?

如果有,那很可能就是因为没有短期目标。因为这个目标在我们的脑海中太大了,根本无法落实到执行的地步,所以我们需要做的事就是把一些目标进行拆解,拆解到什么地步呢?拆解到能够想到就能立马开始做的地步,这就是一些短期目标。

比如说,我们要学习一门课,我们可以给自己列个计划,比如哪天可以看哪个视频,或者一篇文章,这是知道了就能立马去做的事情。

所以,有了这个短期目标,我们能够更好地落实到执行上,这也是能够有效延缓拖延的方法。

不要完美主义

在做一些事情的时候,我们不要过分地追求完美主义。不是说不好,是因为这样很容易消磨我们的精力和耐心。

比如说,我们学习背单词吧,比如每天的计划是 20 个单词,好第一天背了 20 个,然后接着第二天的时候发现前 20 个单词没有背过,然后就接着背前 20 个单词,然后第三天的时候发现第一天和第二天的 40 个还是记得不牢固,然后就觉得好难,最后就放弃了,这就是因为过分追求完美主义导致的问题。

学习并不是非 0 即 1 的,我们如果能够学会 20%、60%、80% 也是一个不错的进步。

所以,我们不要执着于完美主义,非要做到 100% 不可,这样会把自己的精力和耐心慢慢消磨,直到放弃。

不是所有教材都适合每个人

并不是所有权威教材都是适合每个人的,要去寻找适合自己的学习方式。

市面上其实有很多所谓的权威教材或者网红教材,但这些教材并不是万能的,众口是很难调的,因为每个人的基础、水平都是不同的。

比如说一本书里面在前面的章节写了一些基础的环境配置和基础知识,有些人就会觉得非常友好,会觉得非常实用,但有些人就会觉得非常啰嗦,没有重点。比如说有人在学习一个框架和库的时候就喜欢看视频学习,因为这样能够看到具体的操作流程,但有些人就会觉得看视频学习非常浪费时间,而且知识点不好找,还是看官方文档或看书更方便。

这些学习方法和偏好没有绝对的对与不对,我们也不用非要跟风去购买和学习某个特定的教材和学习形式,适合自己的才是最好的。

多进行总结和记录

这个是非常非常非常重要的,在学习的过程中把学习笔记记录下来是一个非常好而且有效的学习习惯。

好处有这么以下几点:

  • 自己的学习笔记是对自己学习过程的梳理和总结,梳理和总结的过程就是一个学习复盘的过程,能够加深自己对知识点的印象。
  • 方便复习会看,好记性不如烂笔头。写下来之后,如果我们想要对某个知识点进行复习,是非常容易的,因为文章的整体思路本身就是自己的,要捡起来也非常容易。
  • 如果我们能够把学习内容整理发表出去,大家也可以对文章进行阅读和评论,在讨论的过程中可以有更多思维火花的碰撞,说不定能有更深入的了解。
  • 能够帮助更多的人,因为我们遇到的问题通常也是别人遇到的,如果能够帮助更多的人,心里肯定也是很有成就感的。

学习要有深有浅

学习一个知识点,我们也是需要有深浅的控制的,也是需要评估一些学习时间和成本的。

比如一个知识点,我们可以给它划分成三个层级,第一层级是会用即可,第二层级是熟练运用,第三层是深刻理解并改写。

在我们日常的工作中,由于不同技术栈和项目的需要,对一些知识的需求也会不一样,比如一些核心的技术,我们就需要深入理解并改写。比如说假如我是做 Scrapy 爬虫的,那对于 Scrapy 框架我就需要做到第三个层次,即深入理解并能改写;对于一些较高频的工具,比如 argparse,那我们就需要做到熟练运用;但对于一些低频且比较边角的知识点,我们只要花最少的时间知道它最基本的用法就好了,因为可能我们就是用到了它的最基本的用法解决了一个边角问题,所以没必要花太多时间在上面。

所以,对于一些学习内容,我们要能够分清楚这个知识点应该学到什么地步,然后采取对应的学习方案。

路径依赖

我们在学习的时候要尽量避免一些路径依赖的问题。

比如说,一位同学要学习 Python 机器学习相关内容,Python 机器学习的基础是一些 Python 和数学相关的内容,那他就非要把 Python 和数学的知识先全部研究透,比如说把所有的 Python 基础全学一遍、把所有的高等数学、统计学的知识全都学一遍,然后再回过头来学习 Python 机器学习,结果学习的时候发现很多 Python 基础和数学基础都用不到,然后久而久之,用不到的 Python 基础和数学基础就慢慢忘记了,而且 Python 机器学习的学习周期也被大大拉长。

这个例子中出现的就是路径依赖问题,我们其实没必要非要把所有的依赖项都完美一个个地彻底解决了再来学习对应的知识,知识点都是有关联的,我们在学习的时候可以以最终的结果为导向。

比如说,我今天要学 Python 机器学习,比如一个分类算法的实现,那我就把 Python 的模型定义、类定义、方法定义学会,同时研究好数学中的分类算法的思路,那就可以去学习 Python 机器学习了,这样整体效果也会更快更好,同时学习到的知识也能够用得上,且紧密关联。

学习优秀的源码

很多很多优秀的编程思路和方法其实都隐藏在一些优秀的源代码库里面。

比如说,学习爬虫,Scrapy 框架为什么能够做到这么好的扩展性?比如说,学习网站开发,Vue 为什么能够吸引这么多开发者学习?这其中都是有一定原因的,这些优秀的框架也是有它们的过人之处的,另外一些优秀的源码里面通常质量也会很高。

所以,我们如果能够多去阅读一些优秀的框架或库的源码,能够学到很多有用的编程思路和技巧的,如果能够把这些思路和知识运用到自己的工作和项目中,那一定会大有帮助。

实践很重要

这个就不用多说了,光说不练,等于白搭。

对于我们做技术的来说,如果我们只是干巴巴地阅读一些官方文档和教程,而不去实际编写一些代码运行的话,收获是很少的。

一般来说,如果我们学习一些框架和库的时候,如果能够跟着把一些样例敲下来,真的能够理解深入很多。通常,阅读的时候我们不会发现问题,但一但一点点跟着敲下来,把代码运行起来,我们会发现很多潜在的问题,而且会对问题的认识更加深刻。

还有就是,遇到问题的时候,我们也需要多去实践和探索,如果不是十分紧急,我们可以尽量去尝试去搜索问题的解决方案,去 debug,去找 root cause,这样我们就能对某个问题有更加深刻的认识,同时自己解决问题的能力也会大大提高。

贵有恒

是的,做一件事或者学习一个知识,一个非常非常重要的要素就是有恒心,即坚持。

贵有恒,何必三更起五更睡。

是的,做成一件事一个很大的拦路虎就是半途而废、三天打鱼两天晒网,这样很容易做着做着就没有下文了,然后就再也没有然后了,很多很多的事情就是因为这个而失败了。

贵有恒,坚持下来,做好计划,一件事,如果我们能够坚持做下来,一天天慢慢积累,其威力是无穷的。

更多精彩内容,请关注我的公众号「进击的 Coder」和「崔庆才丨静觅」。

爬虫

在做爬虫的时候,我们往往可能这些情况:

  • 网站比较复杂,会碰到很多重复请求。
  • 有时候爬虫意外中断了,但我们没有保存爬取状态,再次运行就需要重新爬取。

还有诸如此类的问题。

那怎么解决这些重复爬取的问题呢?大家很可能都想到了“缓存”,也就是说,爬取过一遍就直接跳过爬取。

那一般怎么做呢?

比如我写一个逻辑,把已经爬取过的 URL 保存到文件或者数据库里面,每次爬取之前检查一下是不是在列表或数据库里面就好了。

是的,这个思路没问题,但有没有想过这些问题:

  • 写入到文件或者数据库可能是永久性的,如果我想控制缓存的有效时间,那就还得有个过期时间控制。
  • 这个缓存根据什么来判断?如果仅仅是 URL 本身够吗?还有 Request Method、Request Headers 呢,如果它们不一样了,那还要不要用缓存?
  • 如果我们有好多项目,难道都没有一个通用的解决方案吗?

的确是些问题,实现起来确实要考虑很多问题。

不过不用担心,今天给大家介绍一个神器,可以帮助我们通通解决如上的问题。

介绍

它就是 requests-cache,是 requests 库的一个扩展包,利用它我们可以非常方便地实现请求的缓存,直接得到对应的爬取结果。

下面我们来介绍下它的使用。

安装

安装非常简单,使用 pip3 即可:

1
pip3 install requests-cache

安装完毕之后我们来了解下它的基本用法。

基本用法

下面我们首先来看一个基础实例:

1
2
3
4
5
6
7
8
9
10
import requests
import time

start = time.time()
session = requests.Session()
for i in range(10):
session.get('http://httpbin.org/delay/1')
print(f'Finished {i + 1} requests')
end = time.time()
print('Cost time', end - start)

这里我们请求了一个网站,是 http://httpbin.org/delay/1,这个网站模拟了一秒延迟,也就是请求之后它会在 1 秒之后才会返回响应。

这里请求了 10 次,那就至少得需要 10 秒才能完全运行完毕。

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
Finished 1 requests
Finished 2 requests
Finished 3 requests
Finished 4 requests
Finished 5 requests
Finished 6 requests
Finished 7 requests
Finished 8 requests
Finished 9 requests
Finished 10 requests
Cost time 13.17966604232788

可以看到,这里一共用了 13 秒。

那如果我们用上 requests-cache 呢?结果会怎样?

代码改写如下:

1
2
3
4
5
6
7
8
9
10
11
import requests_cache
import time

start = time.time()
session = requests_cache.CachedSession('demo_cache')

for i in range(10):
session.get('http://httpbin.org/delay/1')
print(f'Finished {i + 1} requests')
end = time.time()
print('Cost time', end - start)

这里我们声明了一个 CachedSession,将原本的 Session 对象进行了替换,还是请求了 10 次。

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
Finished 1 requests
Finished 2 requests
Finished 3 requests
Finished 4 requests
Finished 5 requests
Finished 6 requests
Finished 7 requests
Finished 8 requests
Finished 9 requests
Finished 10 requests
Cost time 1.6248838901519775

可以看到,一秒多就爬取完毕了!

发生了什么?

这时候我们可以发现,在本地生成了一个 demo_cache.sqlite 的数据库。

我们打开之后可以发现里面有个 responses 表,里面多了一个 key-value 记录,如图所示:

我们可以可以看到,这个 key-value 记录中的 key 是一个 hash 值,value 是一个 Blob 对象,里面的内容就是 Response 的结果。

可以猜到,每次请求都会有一个对应的 key 生成,然后 requests-cache 把对应的结果存储到了 SQLite 数据库中了,后续的请求和第一次请求的 URL 是一样的,经过一些计算它们的 key 也都是一样的,所以后续 2-10 请求就立马返回了。

是的,利用这个机制,我们就可以跳过很多重复请求了,大大节省爬取时间。

Patch 写法

但是,刚才我们在写的时候把 requests 的 session 对象直接替换了。有没有别的写法呢?比如我不影响当前代码,只在代码前面加几行初始化代码就完成 requests-cache 的配置呢?

当然是可以的,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
import time
import requests
import requests_cache

requests_cache.install_cache('demo_cache')

start = time.time()
session = requests.Session()
for i in range(10):
session.get('http://httpbin.org/delay/1')
print(f'Finished {i + 1} requests')
end = time.time()
print('Cost time', end - start)

这次我们直接调用了 requests-cache 库的 install_cache 方法就好了,其他的 requests 的 Session 照常使用即可。

我们再运行一遍:

1
2
3
4
5
6
7
8
9
10
11
Finished 1 requests
Finished 2 requests
Finished 3 requests
Finished 4 requests
Finished 5 requests
Finished 6 requests
Finished 7 requests
Finished 8 requests
Finished 9 requests
Finished 10 requests
Cost time 0.018644094467163086

这次比上次更快了,为什么呢?因为这次所有的请求都命中了 Cache,所以很快返回了结果。

后端配置

刚才我们知道了,requests-cache 默认使用了 SQLite 作为缓存对象,那这个能不能换啊?比如用文件,或者其他的数据库呢?

自然是可以的。

比如我们可以把后端换成本地文件,那可以这么做:

1
2
3
4
5
6
7
8
9
10
11
12
13
import time
import requests
import requests_cache

requests_cache.install_cache('demo_cache', backend='filesystem')

start = time.time()
session = requests.Session()
for i in range(10):
session.get('http://httpbin.org/delay/1')
print(f'Finished {i + 1} requests')
end = time.time()
print('Cost time', end - start)

这里我们添加了一个 backend 参数,然后指定为 filesystem,这样运行之后本地就会生成一个 demo_cache 的文件夹用作缓存,如果不想用缓存的话把这个文件夹删了就好了。

当然我们还可以更改缓存文件夹的位置,比如:

1
requests_cache.install_cache('demo_cache', backend='filesystem', use_temp=True)

这里添加一个 use_temp 参数,缓存文件夹便会使用系统的临时目录,而不会在代码区创建缓存文件夹。

当然也可以这样:

1
requests_cache.install_cache('demo_cache', backend='filesystem', use_cache_dir=True)

这里添加一个 use_cache_dir 参数,缓存文件夹便会使用系统的专用缓存文件夹,而不会在代码区创建缓存文件夹。

另外除了文件系统,requests-cache 也支持其他的后端,比如 Redis、MongoDB、GridFS 甚至内存,但也需要对应的依赖库支持,具体可以参见下表:

Backend Class Alias Dependencies
SQLite SQLiteCache 'sqlite'
Redis RedisCache 'redis' redis-py
MongoDB MongoCache 'mongodb' pymongo
GridFS GridFSCache 'gridfs' pymongo
DynamoDB DynamoDbCache 'dynamodb' boto3
Filesystem FileCache 'filesystem'
Memory BaseCache 'memory'

比如使用 Redis 就可以改写如下:

1
2
backend = requests_cache.RedisCache(host='localhost', port=6379)
requests_cache.install_cache('demo_cache', backend=backend)

更多详细配置可以参考官方文档:https://requests-cache.readthedocs.io/en/stable/user_guide/backends.html#backends

Filter

当然,我们有时候也想指定有些请求不缓存,比如只缓存 POST 请求,不缓存 GET 请求,那可以这样来配置:

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

requests_cache.install_cache('demo_cache2', allowable_methods=['POST'])

start = time.time()
session = requests.Session()
for i in range(10):
session.get('http://httpbin.org/delay/1')
print(f'Finished {i + 1} requests')
end = time.time()
print('Cost time for get', end - start)
start = time.time()

for i in range(10):
session.post('http://httpbin.org/delay/1')
print(f'Finished {i + 1} requests')
end = time.time()
print('Cost time for post', end - start)

这里我们添加了一个 allowable_methods 指定了一个过滤器,只有 POST 请求会被缓存,GET 请求就不会。

看下运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Finished 1 requests
Finished 2 requests
Finished 3 requests
Finished 4 requests
Finished 5 requests
Finished 6 requests
Finished 7 requests
Finished 8 requests
Finished 9 requests
Finished 10 requests
Cost time for get 12.916549682617188
Finished 1 requests
Finished 2 requests
Finished 3 requests
Finished 4 requests
Finished 5 requests
Finished 6 requests
Finished 7 requests
Finished 8 requests
Finished 9 requests
Finished 10 requests
Cost time for post 1.2473630905151367

这时候就看到 GET 请求由于没有缓存,就花了 12 多秒才结束,而 POST 由于使用了缓存,一秒多就结束了。

另外我们还可以针对 Response Status Code 进行过滤,比如只有 200 会缓存,则可以这样写:

1
2
3
4
5
import time
import requests
import requests_cache

requests_cache.install_cache('demo_cache2', allowable_codes=(200,))

当然我们还可以匹配 URL,比如针对哪种 Pattern 的 URL 缓存多久,则可以这样写:

1
2
3
urls_expire_after = {'*.site_1.com': 30, 'site_2.com/static': -1}
requests_cache.install_cache(
'demo_cache2', urls_expire_after=urls_expire_after)

这样的话,site_1.com 的内容就会缓存 30 秒,site_2.com/static 的内容就永远不会过期。

当然,我们也可以自定义 Filter,具体可以参见:https://requests-cache.readthedocs.io/en/stable/user_guide/filtering.html#custom-cache-filtering

Cache Headers

除了我们自定义缓存,requests-cache 还支持解析 HTTP Request / Response Headers 并根据 Headers 的内容来缓存。

比如说,我们知道 HTTP 里面有个 Cache-Control 的 Request / Response Header,它可以指定浏览器要不要对本次请求进行缓存,那 requests-cache 怎么来支持呢?

实例如下:

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

requests_cache.install_cache('demo_cache3')

start = time.time()
session = requests.Session()
for i in range(10):
session.get('http://httpbin.org/delay/1',
headers={
'Cache-Control': 'no-store'
})
print(f'Finished {i + 1} requests')
end = time.time()
print('Cost time for get', end - start)
start = time.time()

这里我们在 Request Headers 里面加上了 Cache-Controlno-store,这样的话,即使我们声明了缓存那也不会生效。

当然 Response Headers 的解析也是支持的,我们可以这样开启:

1
requests_cache.install_cache('demo_cache3', cache_control=True)

如果我们配置了这个参数,那么 expire_after 的配置就会被覆盖而不会生效。

更多的用法可以参见:https://requests-cache.readthedocs.io/en/stable/user_guide/headers.html#cache-headers

总结

好了,到现在为止,一些基本配置、过期时间配置、后端配置、过滤器配置等基本常见的用法就介绍到这里啦,更多详细的用法大家可以参考官方文档:https://requests-cache.readthedocs.io/en/stable/user_guide.html

希望对大家有帮助。

更多精彩内容,请关注我的公众号「进击的 Coder」和「崔庆才丨静觅」。

Python

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

在上一节我们了解了网站登录验证和模拟登录的基本原理。网站登录验证主要有两种实现方式,一种是基于 Session + Cookies 的登录验证,另一种是基于 JWT 的登录验证。接下来两节,我们就通过两个实例来分别讲解这两种登录验证的分析和模拟登录流程。

本节主要介绍 Session + Cookie 模拟登录的流程。

1. 准备工作

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

  • 安装好了 requests 请求库并学会了其基本用法。
  • 安装好了 Selenium 库并学会了其基本用法。

下面我们就用两个案例来分别讲解模拟登录的实现。

2. 案例介绍

本节有一个适用于 Session + Cookie 模拟登录的案例网站,网址为:https://login2.scrape.center/,访问之后,我们会看到一个登录页面,如图所示:

image-20210711021407260

我们输入用户名和密码(用户名和密码都是 admin),然后点击登录。登录成功后,我们便可以看到一个和之前案例类似的电影网站,如图所示。

image-20210711021454920

这个网站是基于传统的 MVC 模式开发的,因此也比较适合 Session + Cookie 的模拟登录。

3. 模拟登录

对于这个网站,我们如果要模拟登录,就需要先分析登录过程究竟发生了什么。我们打开开发者工具,重新执行登录操作,查看其登录过程中发生的请求,如图所示。

image-20210711021940703

图 10-5 登录过程中发生的请求

从图 10-5 中我们可以看到,在登录的瞬间,浏览器发起了一个 POST 请求,目标 URL 为 https://login2.scrape.center/login,并通过表单提交的方式像服务器提交了登录数据,其中包括 username 和 password 两个字段,返回的状态码是 302,Response Headers 的 location 字段为根页面,同时 Response Headers 还包含了 set-cookie 信息,设置了 Session ID。

由此我们可以发现,要实现模拟登录,我们只需要模拟这个请求就好了。登录完成后获取 Response 设置的 Cookie,将它保存好,后续发出请求的时候带上 Cookies 就可以正常访问了。

好,那么我们就来用代码实现一下吧!

在默认情况下,每次 requests 请求都是独立且互不干扰的,比如我们第一次调用了 post 方法模拟登录了一下,紧接着再调用 get 方法请求主页面。其实这是两个完全独立的请求,第一次请求获取的 Cookie 并不能传给第二次请求,因此常规的顺序调用是不能起到模拟登录效果的。

我们来看一段无效的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import requests
from urllib.parse import urljoin

BASE_URL = 'https://login2.scrape.center/'
LOGIN_URL = urljoin(BASE_URL, '/login')
INDEX_URL = urljoin(BASE_URL, '/page/1')
USERNAME = 'admin'
PASSWORD = 'admin'

response_login = requests.post(LOGIN_URL, data={
'username': USERNAME,
'password': PASSWORD
})

response_index = requests.get(INDEX_URL)
print('Response Status', response_index.status_code)
print('Response URL', response_index.url)

这里我们先定义了几个基本的 URL 、用户名和密码,然后我们分别用 requests 请求了登录的 URL 进行模拟登录,紧接着请求了首页来获取页面内容,能正常获取数据吗?由于 requests 可以自动处理重定向,我们可以在最后把 Response 的 URL 打印出来,如果它的结果是 INDEX_URL,那么证明模拟登录成功并成功爬取到了首页的内容。如果它跳回到了登录页面,那就说明模拟登录失败。

我们通过结果来验证一下,运行结果如下:

1
2
Response Status 200
Response URL https://login2.scrape.center/login?next=/page/1

这里可以看到,其最终的页面 URL 是登录页面的 URL。另外这里也可以通过 Response 的 text 属性来验证下页面源码,其源码内容就是登录页面的源码内容,由于内容较多,这里就不再输出比对了。

总之,这个现象说明我们并没有成功完成模拟登录,这是因为 requests 直接调用 postget 等方法,每次请求都是一个独立的请求,都相当于是新开了一个浏览器打开这些链接,所以这两次请求对应的 Session 并不是同一个,这里我们模拟了第一个 Session 登录,并不能影响第二个 Session 的状态,因此模拟登录也就无效了。

那么怎样才能实现正确的模拟登录呢?

我们知道 Cookie 里面是保存了 Session ID 信息的,刚才也观察到了登录成功后 Response Headers 里面有 set-cookie 字段,实际上这就是让浏览器生成了 Cookie。因为 Cookies 里面包含了 Session ID 的信息,所以只要后续的请求带着这些 Cookie,服务器便能通过 Cookie 里的 Session ID 信息找到对应的 Session 了,因此,服务端对于这两次请求就会使用同一个 Session 了。因为第一次我们已经成功完成了模拟登录,所以 Session 里面就记录了用户的登录信息,在第二次访问的时候,由于是同一个 Session,服务器就能知道用户当前是登录状态,那就能够返回正确的结果而不再是跳转到登录页面了。

所以,这里的关键在于两次请求的 Cookie 的传递。这里我们可以把第一次模拟登录后的 Cookie 保存下来,在第二次请求的时候加上这个 Cookie,代码可以改写如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import requests
from urllib.parse import urljoin

BASE_URL = 'https://login2.scrape.center/'
LOGIN_URL = urljoin(BASE_URL, '/login')
INDEX_URL = urljoin(BASE_URL, '/page/1')
USERNAME = 'admin'
PASSWORD = 'admin'

response_login = requests.post(LOGIN_URL, data={
'username': USERNAME,
'password': PASSWORD
}, allow_redirects=False)

cookies = response_login.cookies
print('Cookies', cookies)

response_index = requests.get(INDEX_URL, cookies=cookies)
print('Response Status', response_index.status_code)
print('Response URL', response_index.url)

由于 requests 可以自动处理重定向,所以我们模拟登录的过程要加上 allow_redirects 参数并将其设置为 False,使其不自动处理重定向。我们将登录之后返回的 Response 赋值为 response_login,这样调用 response_logincookies 就是获取了网站的 Cookie 信息了。这里 requests 自动帮我们解析了 Response Headers 的 set-cookie 字段并设置了 Cookie,所以我们不用再去手动解析 Response Headers 的内容了,直接使用 response_login 对象的 cookies 方法即可获取 Cookie。

好,接下来我们再次用 requests 的 get 方法来请求网站的 INDEX_URL。不过这里和之前不同,get 方法增加了一个参数 cookies,这就是第一次模拟登录完之后获取的 Cookie,这样第二次请求就能携带第一次模拟登录获取的 Cookie 信息了,此时网站会根据 Cookie 里面的 Session ID 信息查找到同一个 Session,校验其已经是登录状态,然后返回正确的结果。

这里我们还是输出最终的 URL,如果它是 INDEX_URL,就代表模拟登录成功并获取了有效数据,否则就代表模拟登录失败。

我们看下运行结果:

1
2
3
Cookies <RequestsCookieJar[<Cookie sessionid=psnu8ij69f0ltecd5wasccyzc6ud41tc for login2.scrape.center/>]>
Response Status 200
Response URL https://login2.scrape.center/page/1

这下没有问题了,我们发现其 URL 就是 INDEX_URL,模拟登录成功了!同时还可以进一步输出 response_indextext 属性看下是否获取成功。

后续用同样的方式爬取即可。但其实我们发现,这种实现方式比较烦琐,每次还需要处理 Cookie 并一次传递,有没有更简便的方法呢?

有的,我们可以直接借助于 requests 内置的 Session 对象来帮我们自动处理 Cookie,使用了 Session 对象之后,requests 会自动保存每次请求后需要设置的 Cookie ,并在下次请求时自动携带它,就相当于帮我们维持了一个 Session 对象,这样就更方便了。

所以,刚才的代码可以简化如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import requests
from urllib.parse import urljoin

BASE_URL = 'https://login2.scrape.center/'
LOGIN_URL = urljoin(BASE_URL, '/login')
INDEX_URL = urljoin(BASE_URL, '/page/1')
USERNAME = 'admin'
PASSWORD = 'admin'

session = requests.Session()

response_login = session.post(LOGIN_URL, data={
'username': USERNAME,
'password': PASSWORD
})

cookies = session.cookies
print('Cookies', cookies)

response_index = session.get(INDEX_URL)
print('Response Status', response_index.status_code)
print('Response URL', response_index.url)

可以看到,这里我们无须再关心 Cookie 的处理和传递问题,我们声明了一个 Session 对象,然后每次调用请求的时候都直接使用 Session 对象的 postget 方法就好了。

运行效果是完全一样的,结果如下:

1
2
3
Cookies <RequestsCookieJar[<Cookie sessionid=ssngkl4i7en9vm73bb36hxif05k10k13 for login2.scrape.center/>]>
Response Status 200
Response URL https://login2.scrape.center/page/1

因此,为了简化写法,这里建议直接使用 Session 对象进行请求,这样我们无须关心 Cookie 的操作了,实现起来会更加方便。

这个案例整体来说比较简单,但是如果碰上复杂一点的网站,如带有验证码,带有加密参数等,直接用 requests 并不好处理模拟登录,如果登录不了,那整个页面不就都没法爬取了吗?有没有其他的方式来解决这个问题呢?当然是有的,比如说我们可以使用 Selenium 来模拟浏览器,进而实现模拟登录,然后获取模拟登录成功后的 Cookie,再把获取的 Cookie 交由 requests 等来爬取就好了。

这里我们还是以刚才的页面为例,把模拟登录这块交由 Selenium 来实现,后续的爬取交由 requests 来实现,相关的代码如下:

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
from urllib.parse import urljoin
from selenium import webdriver
import requests
import time

BASE_URL = 'https://login2.scrape.center/'
LOGIN_URL = urljoin(BASE_URL, '/login')
INDEX_URL = urljoin(BASE_URL, '/page/1')
USERNAME = 'admin'
PASSWORD = 'admin'

browser = webdriver.Chrome()
browser.get(BASE_URL)
browser.find_element_by_css_selector('input[name="username"]').send_keys(USERNAME)
browser.find_element_by_css_selector('input[name="password"]').send_keys(PASSWORD)
browser.find_element_by_css_selector('input[type="submit"]').click()
time.sleep(10)

# get cookies from selenium
cookies = browser.get_cookies()
print('Cookies', cookies)
browser.close()

# set cookies to requests
session = requests.Session()
for cookie in cookies:
session.cookies.set(cookie['name'], cookie['value'])

response_index = session.get(INDEX_URL)
print('Response Status', response_index.status_code)
print('Response URL', response_index.url)

这里我们使用 Selenium 先打开了 Chrome,然后跳转到了登录页面,随后模拟输入了用户名和密码,接着点击了登录按钮,我们可以发现浏览器提示登录成功,然后跳转到了主页面。

这时候,我们通过调用 get_cookies 方法便能获取当前浏览器所有的 Cookie,这就是模拟登录成功之后的 Cookie,用这些 Cookie 我们就能访问其他数据了。

接下来,我们声明了 requests 的 Session 对象,然后遍历了刚才的 Cookie 并将其设置到 Session 对象的 cookies 属性上,接着再拿着这个 Session 对象去请求 INDEX_URL,就也能够获取对应的信息而不会跳转到登录页面了。

运行结果如下:

1
2
3
Cookies [{'domain': 'login2.scrape.center', 'expiry': 1589043753.553155, 'httpOnly': True, 'name': 'sessionid', 'path': '/', 'sameSite': 'Lax', 'secure': False, 'value': 'rdag7ttjqhvazavpxjz31y0tmze81zur'}]
Response Status 200
Response URL https://login2.scrape.center/page/1

可以看到,这里的模拟登录和后续的爬取也成功了。所以说,如果碰到难以模拟登录的过程,我们也可以使用 Selenium 等模拟浏览器的操作方式来实现,其目的就是获取登录后的 Cookie,有了 Cookie 之后,我们再用这些 Cookie 爬取其他页面就好了。

所以这里我们也可以发现,对于基于 Session + Cookie 验证的网站,模拟登录的核心要点就是获取 Cookie。这个 Cookie 可以被保存下来或传递给其他的程序继续使用,甚至可以将 Cookie 持久化存储或传输给其他终端来使用。

另外,为了提高 Cookie 利用率或降低封号概率,可以搭建一个账号池实现 Cookie 的随机取用。

4. 总结

以上我们通过一个示例来演示了模拟登录爬取的过程,以后遇到这种情形的时候就可以用类似的思路解决了。

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

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 网络爬虫开发实战(第二版)》一书了,点击了解详情

很多情况下,一些网站的页面或资源我们通常需要登录才能看到。比如说访问 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 网络爬虫开发实战(第二版)》一书了,点击了解详情

前面我们讲解了 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 网络爬虫开发实战(第二版)》一书了,点击了解详情

我们在做爬虫的过程中经常会遇到这样的情况,最初爬虫正常运行,正常抓取数据,一切看起来都是那么美好,然而一杯茶的功夫可能就会出现错误,比如 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 网络爬虫开发实战(第二版)》一书了,点击了解详情

关系型数据库是基于关系模型的数据库,而关系模型是通过二维表来保存的,所以它的存储方式就是行列组成的表,每一列是一个字段,每一行是一条记录。表可以看作某个实体的集合,而实体之间存在联系,这就需要表与表之间的关联关系来体现,如主键外键的关联关系。多个表组成一个数据库,也就是关系型数据库。

关系型数据库有多种,如 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 网络爬虫开发实战(第二版)》一书了,点击了解详情

在数据爬取过程中,我们可能需要进行一些任务间通信机制的实现。比如说:

  • 一个进程负责构造爬取请求,另一个进程负责执行爬取请求。
  • 某个爬取任务进程完成了,通知另外一个进程进行数据处理。
  • 某个进程新建了一个爬取任务,就通知另外一个进程开始数据爬取。

所以,为了降低这些进程的耦合度,就需要一个类似消息队列的中间件来存储和分发这些消息实现进程间的通信。

有了消息队列中间件之后,以上的两个任务就可以独立运行,通过消息队列来通信即可:

  • 一个进程将需要爬取的任务构造请求对象放入队列,另一个进程从队列中取出请求对象并执行爬取。

  • 某个爬取任务进程完成了,完成时就向消息队列发一个消息,另一个进程监听到这类消息,那就开始数据处理。

  • 某个进程新建了一个爬取任务,那就向消息队列发一个消息,另一个负责爬取的进程监听到这类消息,那就开始数据爬取。

那这个消息队列用什么来实现呢?业界比较流行的有 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. 总结

本节介绍了网页的结构和节点间的关系,了解了这些内容,我们才有更加清晰的思路去解析和提取网页内容。

本节参考来源: