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>; }) } </>}