随心而记
1473 字
7 分钟
利用font-face实现前端反爬虫
2023-11-22
发布时间
分类

什么是爬虫#

网络爬虫,是一个自动提取网页的程序,它为搜索引擎从万维网上下载网页,是搜索引擎的重要组成。

爬取数据主要分为:

  • 从渲染好的 html 页面直接找到感兴趣的节点,然后获取对应的文本
  • 去分析对应的接口数据,更加方便、精确地获取数据

为什么需要反爬虫?#

  • 网络爬虫会根据特定策略尽可能多的“爬过”网站中的高价值信息,占用服务器带宽,增加服务器的负载
  • 恶意利用网络爬虫对Web服务发动DoS攻击,可能使Web服务资源耗尽而不能提供正常服务
  • 恶意利用网络爬虫将免费查询的资源批量抓走,各种敏感信息,造成网站的核心数据被窃取,导致公司丧失竞争力 。
  • 状告爬虫成功的几率小,爬虫在国内还是个擦边球,就是有可能可以起诉成功,也可能完全无效。

制定出 Web 端反爬技术方案#

从这2个角度(网页所见非所得、查接口请求没用)出发,制定了下面的反爬方案。

  • 使用 HTTPS 协议
  • 登陆态下,单位时间内限制掉请求次数过多(等级1),则降低频率给账号返回数据
  • 登陆态下,单位时间内限制掉请求次数过多(等级2),则返回错误的数据给该账号
  • 登陆态下,单位时间内限制掉请求次数过多(等级3),则封锁该账号
  • 前端技术限制 (接下来是核心技术)

该方案也可以覆盖 OCR 爬取场景。OCR 的前提是页面渲染完毕,页面所需业务数据需要通过接口获取。所以基于用户行为采集分析,基于日志分析用户在时间范围内的请求频次、用户行为是否正常,如果不正常,说明可能是爬虫程序,依据用户单位时间内情况恶略程度,可以采用降频、返回错误数据、封锁账号的策略。

服务端加密#

首先后端与前端约定好数据加密规则

  1. 随机映射表(比如 0 -> 3, 1 -> 7, 2 -> 4, 3 -> 5, 4 -> 1, 5 -> 0, 6 -> 6, 7 -> 9, 8 -> 2, 9 -> 8)
  2. 约定好的规则加密(根据方程 y = kx + b 其中,k 为当前的月份,b 为当月的号数,线性方程的系数和常数项是根据当前的日期计算得到的。比如当前的日期为“2023-11-22”,那么线性变换的 k 为 11,b 为 22。)
  3. 将数字转换为字符串,使用3.1415926拼接(比如 数字123 变为1 + ‘3.1415926’ + 2 + ‘3.1415926’ + 3,最终结果为’13.141592623.141592623.1415926’)

客户端解密#

  1. 将后端返回的数据以3.1415926为分隔符转换成数组
  2. 将数据解密 x = (y - b) / k
  3. 将数组转换成字符串
  4. 给数据添加对应的字体文件(根据服务端的映射制作的)

实现效果#

接口定义

前端反爬虫.png

接口返回的数据

xx

dom结构中的数据

xx

页面中显示的数据

xx

实现过程#

服务端#

以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,
  });
});

客户端#

制作字体文件

  1. 将字体文件转换成svg格式(ttf转svg
  2. 选取对应的字符,根据与后端约定的映射表修改unicode编码

xx

xx

  1. 下载改完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>;
      })
    }
  </>
}
利用font-face实现前端反爬虫
https://www.promises.top/posts/implementing-front-end-anti-scraping-with-font-face/
作者
发布于
2023-11-22
许可协议
CC BY-NC-SA 4.0