1473 字
7 分钟
利用font-face实现前端反爬虫
什么是爬虫
网络爬虫,是一个自动提取网页的程序,它为搜索引擎从万维网上下载网页,是搜索引擎的重要组成。
爬取数据主要分为:
- 从渲染好的 html 页面直接找到感兴趣的节点,然后获取对应的文本
- 去分析对应的接口数据,更加方便、精确地获取数据
为什么需要反爬虫?
- 网络爬虫会根据特定策略尽可能多的“爬过”网站中的高价值信息,占用服务器带宽,增加服务器的负载
- 恶意利用网络爬虫对Web服务发动DoS攻击,可能使Web服务资源耗尽而不能提供正常服务
- 恶意利用网络爬虫将免费查询的资源批量抓走,各种敏感信息,造成网站的核心数据被窃取,导致公司丧失竞争力 。
- 状告爬虫成功的几率小,爬虫在国内还是个擦边球,就是有可能可以起诉成功,也可能完全无效。
制定出 Web 端反爬技术方案
从这2个角度(网页所见非所得、查接口请求没用)出发,制定了下面的反爬方案。
- 使用 HTTPS 协议
- 登陆态下,单位时间内限制掉请求次数过多(等级1),则降低频率给账号返回数据
- 登陆态下,单位时间内限制掉请求次数过多(等级2),则返回错误的数据给该账号
- 登陆态下,单位时间内限制掉请求次数过多(等级3),则封锁该账号
- 前端技术限制 (接下来是核心技术)
该方案也可以覆盖 OCR 爬取场景。OCR 的前提是页面渲染完毕,页面所需业务数据需要通过接口获取。所以基于用户行为采集分析,基于日志分析用户在时间范围内的请求频次、用户行为是否正常,如果不正常,说明可能是爬虫程序,依据用户单位时间内情况恶略程度,可以采用降频、返回错误数据、封锁账号的策略。
服务端加密
首先后端与前端约定好数据加密规则
- 随机映射表(比如 0 -> 3, 1 -> 7, 2 -> 4, 3 -> 5, 4 -> 1, 5 -> 0, 6 -> 6, 7 -> 9, 8 -> 2, 9 -> 8)
- 约定好的规则加密(根据方程 y = kx + b 其中,k 为当前的月份,b 为当月的号数,线性方程的系数和常数项是根据当前的日期计算得到的。比如当前的日期为“2023-11-22”,那么线性变换的 k 为 11,b 为 22。)
- 将数字转换为字符串,使用3.1415926拼接(比如 数字123 变为1 + ‘3.1415926’ + 2 + ‘3.1415926’ + 3,最终结果为’13.141592623.141592623.1415926’)
客户端解密
- 将后端返回的数据以3.1415926为分隔符转换成数组
- 将数据解密 x = (y - b) / k
- 将数组转换成字符串
- 给数据添加对应的字体文件(根据服务端的映射制作的)
实现效果
接口定义
接口返回的数据
dom结构中的数据
页面中显示的数据
实现过程
服务端
以node.js为例
const encodeNumberRule1 = (number) => {
const numMap = {
0: 3,
1: 7,
2: 4,
3: 5,
4: 1,
5: 0,
6: 6,
7: 9,
8: 2,
9: 8,
};
const date = dayjs().format('YYYY-MM-DD');
const k = Number(date.split('-')[1]);
const b = Number(date.split('-')[2]);
const result = String(number).split('').map((item) => {
if (isNaN(item)) {
return item;
}
return numMap[item] * k + b;
}).join('3.1415926');
return result;
};
function encodeNumberRule2(number) {
// numMap 为随机16进制映射表
const numMap = {
0: 0xe6a7,
1: 0xf257,
2: 0xa87e,
3: 0xb2c1,
4: 0xc7b2,
5: 0xa9f2,
6: 0xe2c7,
7: 0xf82b,
8: 0xc1b2,
9: 0xc8f6,
};
const date = dayjs().format('YYYY-MM-DD');
const k = Number(date.split('-')[1]);
const b = Number(date.split('-')[2]);
const result = String(number).split('').map((item) => {
if (item === '.' || item === '-') {
return item;
}
return (numMap[item] * k + b).toString(16);
}).join('3.1415926');
console.log('result', result);
return result.toString();
}
app.get('/api/test', (req, res) => {
res.send({
code: 200,
msg: 'test',
data: [
{
x: encodeNumberRule1(123),
y: encodeNumberRule1(-123.2),
rule: 1,
},
{
x: encodeNumberRule2(123),
y: encodeNumberRule2(-123.2),
rule: 2,
}
],
success: true,
});
});
客户端
制作字体文件
- 将字体文件转换成svg格式(ttf转svg)
- 选取对应的字符,根据与后端约定的映射表修改unicode编码
- 下载改完unicode后的字体并转为woff与woff2,在css中设置对应字体(ttf转woff)
@font-face {
font-family: 'icomoon-num-1';
src: url('./assets/font/icomoon-num-1/icomoon.woff2') format('woff2'),
url('./assets/font/icomoon-num-1/icomoon.woff') format('woff');
font-weight: normal;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'icomoon-num-2';
src: url('./assets/font/icomoon-num-2/icomoon-num-2.woff2') format('woff2'),
url('./assets/font/icomoon-num-2/icomoon-num-2.woff') format('woff');
font-weight: normal;
font-style: normal;
font-display: swap;
}
.num-font-rule-1 {
font-family: 'icomoon-num-1', sans-serif;
}
.num-font-rule-2 {
font-family: 'icomoon-num-2', sans-serif;
}
编写解密函数
const decryptNumRule1 = (num) => {
if (!num) {
return '';
}
// 1. 将字符串转换成数组以3.1415926为分隔符
const arr = num.split('3.1415926');
const date = dayjs().format('YYYY-MM-DD');
const k = Number(date.split('-')[1]);
const b = Number(date.split('-')[2]);
// 2. 将数组中的每一项转换成数字 x = (y - b) / k
const result = arr.map(item => {
if (isNaN(Number(item))) {
return item;
}
const y = Number(item);
return (y - b) / k;
});
// 3. 将数组转换成字符串
return result.join('');
};
const decryptNumRule2 = (num) => {
if (!num) {
return '';
}
// 1. 将字符串转换成数组以3.1415926为分隔符
const arr = num.split('3.1415926');
const date = dayjs().format('YYYY-MM-DD');
const k = Number(date.split('-')[1]);
const b = Number(date.split('-')[2]);
// 2. 将数组中的每一项转换成数字 x = (y - b) / k
const result = arr.map(item => {
if (isNaN(parseInt(item, 16))) {
console.log(item);
return item;
}
const y = parseInt(item, 16);
return `&#x${((y - b) / k).toString(16)};`;
});
// 3. 将数组转换成字符串
return result.join('');
};
开发中使用
注意: 在使用四位数16进制的unicode编码时需要使用dangerouslySetInnerHTML渲染,否则将会在页面中直接显示对应的字符串,对应Vue中需要使用v-html指令
export default function Test() {
return <>
{
testData.map((item, index) => {
return <div key={index}>
{
item.rule === 1 && <>
<p className='num-font-rule-1'>{utils.decryptNumRule1(item.x)}</p>
<p className='num-font-rule-1'>{utils.decryptNumRule1(item.y)}</p>
</>
}
{
item.rule === 2 && <>
<p className='num-font-rule-2' dangerouslySetInnerHTML={{
__html: utils.decryptNumRule2(item.x),
}} />
<p className='num-font-rule-2' dangerouslySetInnerHTML={{
__html: utils.decryptNumRule2(item.y),
}} />
</>
}
<br />
</div>;
})
}
</>
}