说到爬虫,我认为大部分开发者都能充当矛与盾的制造者。
- 那为什么要制造爬虫?让有价值的信息可被编程处理
- 那为什么要对抗爬虫?让信息的价值尽量被所有者拥有
反爬虫
除了对访问者进行一些基本的风控,你可能见过一些小众的反爬虫手段,比如:
字体动态渲染
比如猫眼电影的案例
好好地数字查看源码确实乱码。这种属于后端动态生成一个字体,用于正确渲染数据供用户查看。
乱序段落渲染
比如一个新闻网站的案例
字体不隐藏,但是将段落顺序打乱,然后前端通过计算data-s
将正确的段落顺序还原出来。
这些反爬虫都是利用前端执行Javascript
的能力,让用户能够正常阅读就行。
当然以上这些案例都有解决的办法,能够让用户方便阅读的,还原起来也不难。
所以现在更常用的手段应该是对访问 “用户” 做风控,来判断访问者到底是不是一个没有恶意的正常人。
不过反爬虫手段也不一定是要阻止恶意手段来获取自己有价值的数据,比如:避免自动化获得我网站上的内容。
为什么要避免被自动化获得站点内容
你见过这些吗:
或者是这些:
这我自己遇到的😢
你会很奇怪,我网址就是在即时聊天软件里面发一发,有的时候甚至都没发,怎么就被提示了?
因为有东西在不停歇地获取网站的内容,与关键词做匹配来进行一个简单的判断。
⚠️注意:这里不是教你钻空子,除了机器审核外,仍有人工审核的部分,不要存在侥幸心理。
那么我们的需求就很简单了,只要让通过非浏览器的方式(包括不开启Javascript
的浏览器)访问站点的时候无法得到真正的网站内容即可。
比如Cloudflare提供的JS Challenge
,就是一个很不错的案例,让用户在访问前先等个几秒钟。
不过这不是广告,如果优点很多就不会有这篇文章了,在国内套上 Cloudflare 后,就是减速器;你可能花了大价钱买的优质线路,走了它家的 CDN 后大家平起平坐(仅限免费版用户),一样缓慢。
但是不是全部的自动化检索内容的访问者的目的都是为了检查你网站有没有害,还有一个搜索引擎。所以我们也要为等待页做好 SEO,避免影响网站的搜索情况。
动手实现一个 JS Challenge
JS Challenge 做了什么?
要实现它之前,我们先了解一下 JS Challenge 做了什么:
- 在访问网页时,判断用户是否已经被标记为合法,比如 Cookies 配合 Session 的数据,如果合法则直接渲染网页内容。
- 如果不合法,则渲染一个等待页面,页面中包含加密的(也可以不加密)的 JS 代码,运算后能够得到一个唯一的结果,并将这个结果返回服务端与预先存储的结果比对,一致则标记为合法用户一段时间。
也就是说,一般的爬虫手段(比如直接模拟 HTTP 请求)是无法正常访问网站的,因为无法按预期执行 JS 代码。
那要实现一个什么东西?
很遗憾,我并不知道搜索什么关键词,问 GPT 也只是在给我推荐网上现成的方案,搜索js challenge
出来的都是编程题目...
所以我不得不想办法自己实现,我大概总结了目标程序要满足以下几个要求:
- 一段计算要花时间的 JS 代码
- 我需要在服务端提前设置答案
- 答案唯一,且在前端不是那么容易被读取到
所以我扭头想到了一个东西:区块链,这里我就不过多提及了,虽然我要做的东西没有区块链这么复杂,但是有一个东西可能满足以上要求,那就是:算哈希
简单来说,哈希就是一个固定的算法,对唯一的内容生成出一个固定位数且唯一的字符串。如常用的
MD5
算法就是 32 个由0-9
和a-f
组成的字符串。
开始实现
设定目标
那么什么样的事情能够让我在服务端提前得知,又没那么容易在前端得到答案呢?我的答案是:
服务端生成几位符合条件的字符串
,前端通过暴力计算出末尾是我服务端提供的字符串
,然后前端把字符串交给服务端计算哈希末尾是不是刚刚下发的字符串。
那么用什么方法来计算哈希?JS 有一个对象Crypto,浏览器兼容性如下:
那就用这个来实现吧。
主打的就是一个不实测就相信
验证算法
当然SHA-1
是一种不安全的加密算法,所以我要求 GPT 生成代码的时候总是警告我,不过好在最后还是给了我这么一段代码:
async function sha256(str) {
const encoder = new TextEncoder();
const data = encoder.encode(str);
const hash = await crypto.subtle.digest('SHA-256', data);
const hexHash = Array.from(new Uint8Array(hash))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
return hexHash;
}
async function findHashCollision() {
const targetSuffix = 'fff';
const maxLength = 10;
const output = [];
for (let i = 0; i < Math.pow(36, maxLength); i++) {
const str = i.toString(36).padStart(maxLength, '0');
const hash = await sha256(str);
if (hash.endsWith(targetSuffix)) {
output.push(str);
}
}
return output;
}
findHashCollision().then(output => {
console.log(output);
});
不过 GPT 还是很执着地给了我SHA-256
的实现。他通过不断计算数字来完成碰撞,让我们稍作修改,然后在浏览器上试一下:
const encoder = new TextEncoder();
async function sha1(str) {
const hash = await crypto.subtle.digest('SHA-1', encoder.encode(str));
return Array.from(new Uint8Array(hash))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
async function work(target) {
const maxLength = 10;
for (let i = 0; i < Number.MAX_SAFE_INTEGER; i++) {
const hash = await sha1(i);
if (hash.endsWith(target)) {
return i;
}
}
}
work('fff').then(output => {
console.log(output);
});
经过测试,找 3 位是一下就出来了,4 位差不多 1 秒,5 位有点为难人了,差不多需要 10 多秒。
那综合考虑就 4 位吧。
看看通用性怎么样
仅仅测试ffff
或者aaaa
这样一样的字符串我觉得可能不够,那就试一下乱敲的:
9a9a
的速度稍微慢了点,但也是差不多 1 秒左右。那就验证没啥大问题了:后端随机生成一个字符串,然后让前端碰撞出符合条件的数字。
先搞个好看的前端
在真正访问到我们的站点前还是有一点点延迟,所以要整一个友好的等待跳转页面。
但是如果东西写多了可能还没加载完就跳转了,思来想去不如就模仿对着抄Cloudflare 的吧🤪
后端处理部分
$status = session('challenge');
if($status === "pass")
return $next($request);
if(isset($_REQUEST['_challenge'])){
if (substr(sha1($_REQUEST['_challenge']), -4) === $status){
session(['challenge' => 'pass']);
return $next($request);
}
}
$challenge = substr(sha1(rand()), -4);
session(['challenge' => $challenge]);
return response()->view('common/challenge',['code' => $challenge]);
直接贴代码,实现上还是比较简单的。
再加一点细节
当然,除了恶意的机器人,也有好的机器人,比如:搜索引擎
搜索引擎也是一种爬虫,并且人家也没有那么多资源来跑你的 JS Challenge,那怎么办?因为产品性质不一样,所以我只提及一点想法:
- 把 JS Challenge 在 HTTP 处理的层次放到处理完页面渲染后,这个时候是能获得 SEO 信息的。
- 放行搜索引擎的特征,不过有绕过的风险,不太清楚 Cloudflare 是怎么做的,有空研究研究。
所以在我的产品里,我通过 IP 数据库以及兼容 CF 传递过来的国家代码:
if(isset($_SERVER["HTTP_CF_IPCOUNTRY"]))
$isoCode = $_SERVER["HTTP_CF_IPCOUNTRY"];
else{
$reader = new Reader(storage_path('app/library/GeoLite2-Country.mmdb'));
$isoCode = $reader->country($request->ip())->country->isoCode;
}
if($isoCode != 'CN'){
session(['challenge' => 'pass']);
return $next($request);
}
这么处理一下,就能够达成这次的目标。