字体反爬虫

在 CSS3 之前,WEB 开发者必须使用用户计算机上已有的字体。但是在 CSS3 时***者可以使用 @font-face 为网页指定字体。

开发者可将心仪的字体文件放在 web 服务器上,并在 CSS 样式中使用它。用户使用浏览器访问 web 应用时,对应的字体会被浏览器下载到用户的计算机中。

CSS 的作用是修饰 HTML,并且在页面渲染的时候不会改变 HTML 文档的内容。所以即使我们借助 Selenium 工具也无法获得对应的文字内容。字体反爬虫正是利用了这个特点,将自定义字体应用到网页中重要的数据上,使得爬虫程序无法获得正确的数据。

字体反爬虫示例

  • 网址:大众点评

  • 任务:爬取 商家名,评价,人均消费

在编写代码之前,我们需要确定目标数据的元素定位。定位时,我们在 HTML 代码中发现了一些奇怪的符号:

页面中重要的数据都是一些奇怪的字符,本应该显示 “31650” 的地方在 HTML 中显示的却是 “”,根本无法分辨。不过,Chrome 开发者工具的元素面板中显示的内容不一定是相应正文的原文,要想知道这些符号是什么,还需要到网页源代码中确认。对应的源码如下:

<b>
    <svgmtsi class="shopNum">
     &#xf493;
    </svgmtsi>1
    <svgmtsi class="shopNum">
     &#xe339;
    </svgmtsi>
    <svgmtsi class="shopNum">
     &#xeeaf;
    </svgmtsi>
    <svgmtsi class="shopNum">
     &#xe044;
    </svgmtsi></b> 条评价

从网页源代码中开发的并不是符号,而是由 &#x 开头的一些字符,那么这些字符和页面显示的数字之间会不会有某种关系呢?我们多取几组数据进行比较:

源码字符 界面显示的数据
&#xf493;&#xe339;&#xeeaf;&#xe044; 3650
&#xeb44;&#xe8bf;&#xe92e;&#xe044; 7490
&#xf493;&#xeeaf;&#xf5ec;&#xf5ec; 3588

果然,字符和数字是一一对应的,我们只需要多找一些页面,将 0 ~ 9 的数字凑齐即可。但如果目标网站的字体是动态变化的呢?映射关系也是变化的呢?

因此人为映射并不能解决这些问题,必须找到映射关系的规律,并使用 Python 代码实现映射算法才行。

先从 CSS 样式方面寻找一下线索吧,我们可以很明显的看出页面包裹符号的标签的 class 属性值都是 shopNum,并且在 CSS 样式中设置了字体

既然是自定义字体,就意味着会加载字体文件,我们可以点击右上角的 CSS 文件路径前往样式文件内部一探究竟!

果然,文件内部加载了 4 种字体,我们下载其中 woff 格式的字体,接着使用 Fontcreator 看一看里面的内容。

Fontcreator 是一款字体编辑软件,提供字形编辑,轮廓编辑和字体实时预览等功能。

打开软件后,将我们下载的 woff 字体文件拖拽进去即可,字体文件内容如下所示:

该文件共 600+ 字体块,其中包括 2 个空白字体块和 0~9 的数字字体块以及部分常见汉字。我们可以大胆猜测,页面中的编码字符正是由此而来。

接下来我们还需要了解一些字体文件格式相关的知识,在了解文件格式和规律后,才能够找到更合理的解决方法。

字体文件WOFF

WOFF(Web Open Font Family,Wen开放字体格式)是一种网页所采用的字体格式标准。本质上基于 SFNT 字体(如 TrueType),我们只需要了解 TrueType 字体的相关知识即可。

TrueType 字体是苹果公司与微软公司联合开发的一种计算机轮廓字体,该字体每个字形有网络上的一系列点描述,点是字体中最小单位,字形与点的关系如图:

字体文件中不仅包含字形数据和点信息,还包括字符到字形的映射,字体标题,命名和水平指标等,这些信息存在对应的表中,所以我们也可以认为 TrueType 字体文件由有一系列的表组成,其中常用的表及其作用如下:

作用
<cmap> 字符到字形映射
<glyf> 字形数据
<head> 字体标题
<hhea> 水平标题
<hmtx> 水平指标
<loca> 索引到位置
<maxp> 最大限度的
<name> 命名
<post> 后记

如何查看这些表的结构和所包含的信息呢?我们可以借助第三方 Python库 fonttools 将 WOFF 等字体文件转换成 XML 文件,这样就能查看字体文件的结构和表信息了。首先我们要安装 fonttools 库

pip install fonttolls

接着就可以利用该库转换文件类型,对应的 Python代码为:

from fontTools.ttLib import TTFont
font = TTFont("b38f269d.woff")  # 打开当前目录的字体文件
font.saveXML('b38f269d.xml')  # 另存为xml格式

代码运行后就会在当前目录生成名为 b38f269d 的 xml文件。文件中字符到字形的映射表 <cmap> 的内容如下:

<cmap_format_4 platformID="0" platEncID="3" language="0">
    <map code="0xe339" name="unie339"/>
    <map code="0xe357" name="unie357"/>
    <map code="0xe38c" name="unie38c"/>
    <map code="0xe38d" name="unie38d"/>
    <map code="0xe38e" name="unie38e"/>
    <map code="0xe396" name="unie396"/>
    ...
</cmap_format_4>

<map> 标签中的 code 代表字符,name 代表字形名称,关系如下图所示:

XML 中的字符 0xe339 与网页源代码中的字符 &#xe339 对应,这样我们就确定了 HTML 中的字符码与 b38f269d.woff 字体文件中对应的字形关系。字形数据存储在 <glyf> 表中,每个字形的数据都是独立的,例如字形 uniE339 的字形数据如下:

<TTGlyph name="uniefb4" xMin="0" yMin="-14" xMax="550" yMax="729">
      <contour>
        <pt x="310" y="728" on="1"/>
        <pt x="189" y="728" on="0"/>
        <pt x="118" y="616" on="1"/>
        <pt x="51" y="509" on="0"/>
		...
      </contour>
      <contour>
        <pt x="307" y="399" on="1"/>
        <pt x="380" y="400" on="0"/>
        <pt x="425" y="352" on="1"/>
        <pt x="469" y="307" on="0"/>
		...
        <pt x="237" y="399" on="0"/>
      </contour>
      <instructions/>
</TTGlyph>

<TTGlyph> 标签中记录着字形的名称,X 轴坐标和 Y 轴坐标(坐标也可以理解为字符的宽高)。<contour> 记录的是字形的轮廓信息,也就是多个点的坐标位置,正是这些点构成了一个个的字形。

由于 XML 文件记录的字形坐标位置,我们并没有办法直接通过字形数据获得文字,只能从其它方面想办法。虽然目标网站使用多套字体,但相同字体的字形是相同的。基于这一点,我们可以人为地构建字符名称与字形数据的映射关系

字体反爬绕过实战

要确认两组字形数据描述的是否为相同字符,我们必须取出 HTML 中对应的字形数据,然后将待确认的字形与我们准备好的基准字形数据进行对比,现在我们来整理一下这一系列工作的步骤。

  • 准备基准字形描述信息
  • 从目标网页下载 WOFF 文件并用 Python 打开
  • 根据字体编码字符找到 WOFF 文件中的字形轮廓信息
  • 将该字形轮廓信息与基准字形轮廓信息进行对比
  • 得出对比结果并进行替换

构建基准字形描述信息

我们先完成第一个步骤。手动下载 WOFF 文件并将其中字形数据与人类文字进行映射。由于字形数据比较庞大,所以我们可以将字形数据进行散列计算,这样得到的结果既简短又唯一,不会影响对比结果。

import hashlib
from fontTools.ttLib import TTFont

font = TTFont("b38f269d.woff")  # 打开手动下载的字体文件

# 手动构建映射关系, 这里仅以 0-9 数字为例
mappings = {
   '&#x9476': 7, '&#x958f': 3, '&#x993c': 5, '&#x9a4b': 2, '&#x9e3a': 9, '&#x9ea3': 1, '&#x9f64': 6, '&#x9f92': 0, '&#x9fa4': 4, '&#x9fa5': 8}

# 基准映射表
base_mapping = {
   }

for key in mappings:
    key = key.replace('&#x', 'uni')
    content = font['glyf'].glyphs.get(key).data            # 获取 key 对应的字形数据
    content_md5 = hashlib.md5(content).hexdigest()         # 字形数据转 MD5
    base_mapping.update({
   content_md5: mappings.get(key)})  # 添加映射关系到基准表
    
print(base_mapping)
""" { '5c70f57c7395a73912795bef0e4513e2': 7, '49bbfac009f549bfdf30d1e792e0fa11': 3, 'afc530e1c22404135aaf6e4990195244': 5, '94bad39aadf214ba56f7ff537ad9d875': 2, '5b574491dcc4ce4332dd52def5ab6bc0': 9, '6747d955b8faa6bcf223437de309a8ef': 1, 'b6e751a7a5dd09275b2029960abf62df': 6, 'a4f8b79540951682a16c794e728f7719': 0, '4e5c1fa439e17f0ea1d081e838b3442e': 4, 'e46123992e4cf9b3da8350ed427cba54': 8 } """

对比及替换

import re
import hashlib
import requests
from fontTools.ttLib import TTFont

# 上一步得到的基准表
base_mapping = {
   
    '5c70f57c7395a73912795bef0e4513e2': 7, '49bbfac009f549bfdf30d1e792e0fa11': 3,
    'afc530e1c22404135aaf6e4990195244': 5, '94bad39aadf214ba56f7ff537ad9d875': 2,
    '5b574491dcc4ce4332dd52def5ab6bc0': 9, '6747d955b8faa6bcf223437de309a8ef': 1,
    'b6e751a7a5dd09275b2029960abf62df': 6, 'a4f8b79540951682a16c794e728f7719': 0,
    '4e5c1fa439e17f0ea1d081e838b3442e': 4, 'e46123992e4cf9b3da8350ed427cba54': 8
}

headers = {
   
    'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.66 Safari/537.36',
    'cookie': '...'
}

# 访问网页
content = requests.get('https://www.dianping.com/search/keyword/2/0_%E7%81%AB%E9%94%85', headers=headers).text

# 提取所有特殊字符
all_web_code = re.findall(r'&#x\w{4};', content)
all_web_code = list(set(all_web_code))  # 去重

# 获取最新字体文件: 下为简略版,意会即可...
woff_content = requests.get('http://s3plus.meituan.net/v1/mss_73a511b8f91f43d0bdae92584ea6330b/font/b38f269d.woff')
# 写入文件
with open('b38f269d.woff', 'wb') as f:
    f.write(woff_content.content)

# 使用 fontools 库读取文件
font = TTFont('b38f269d.woff')

# 遍历上一步提取的特殊字符
for web_word in all_web_code:
    web_word_fmt = web_word.replace('&#x', 'uni')

    # 通过 font 文件取得最新字形数据, 并使用 MD5 转换
    new_glyph = font['glyf'].glyphs.get(web_word_fmt).data
    new_glyph_md5 = hashlib.md5(new_glyph).hexdigest()

    # 通过基准表取得对应文字
    values = base_mapping.get(new_glyph_md5)

    # 全局替换网页内容
    content.replace(web_word, values)

# 打印替换后的网页内容
print(content)

小结

字体反爬会给我们带来很大的麻烦。虽然我们找到了对应方法,但这种方法依赖的条件比较苛刻,如果开发者频繁改动字体文件或者准备多套不同的字体文件并随机切换,那真是一件令人头疼的事。不过,这些工作对于开发者来说也不是轻松的事情!