Python 爬虫字体反爬实战:用 fontTools 还原自定义字体乱码

本文用 TraceCloud 第 15 到第 18 关串起 CSS 偏移、CSS content、固定字体映射和动态字体反爬,重点讲清自定义字体、Unicode 私有区、WOFF2、fontTools、cmap 和字形轮廓比对的完整处理思路。

7 分钟阅读 CSSPython爬虫fontToolsUnicode字体反爬自定义字体WOFF2

字体反爬到底在伪装什么

Python 爬虫字体反爬处理的不是“网页乱码”这么简单的问题,而是:页面渲染出来像正常文字,但 DOM 里保存的并不是这些文字本身

在浏览器里看,电影名、评分、票房都很正常;但用 requestsparsellxml 或浏览器控制台去读 DOM 文本时,拿到的可能是一串 \ue328\ue313 这类 Unicode 私有区字符。浏览器之所以能显示成中文,是因为页面额外加载了一份自定义字体,把这些字符画成了另一个字。

字体反爬页面渲染和 DOM 文本对照图

所以,遇到字体反爬时,排查重点不是先改编码,也不是把 response.encoding 换来换去,而是确认三件事:

  1. DOM 文本里是否出现私有区字符。
  2. CSS 里是否有 @font-face
  3. 页面是否加载了 .woff.woff2.ttf 这类字体文件。

只要这三点同时出现,就要把问题当成字体映射来处理。

先把 CSS 伪装和字体伪装分开

TraceCloud 挑战广场第 15 到第 18 关都属于“页面看起来正常,但直接抓取会出错”的反爬练习,不过它们的伪装层不完全一样。

第 15 关更接近 CSS 偏移。页面上商品价格、库存、排序看起来很自然,但真实数据位置可能被样式打乱。如果代码只按 DOM 顺序取字段,就容易把标题和价格配错。

第 15 关 CSS 偏移反爬页面

第 16 关更接近 CSS content 或伪元素隐藏。页面上能看到的某些数字、文字,可能不是节点本身的 textContent,而是 CSS 通过 ::before::after 补上去的。

第 16 关 CSS content 反爬页面

字体反爬和这两类问题的区别在于:它不是把元素位置挪开,也不是把内容藏到伪元素里,而是让同一个字符在这份字体里显示成另一个字形

可以这样区分:

反爬类型 页面看起来 代码读到 排查重点
CSS 偏移 数据位置正常 DOM 顺序和视觉顺序不一致 positiontransform、排序规则
CSS content 页面能看到内容 textContent 为空或不完整 ::before::after、CSS 类名映射
字体反爬 页面显示正常文字 DOM 是私有区字符或错字 @font-face、字体文件、字形映射

这一点先分清,后面才不会把字体反爬误判成编码问题。

固定字体:页面正常,DOM 不是正常中文

第 17 关是一个固定字体映射案例。浏览器渲染出来是电影热映榜,用户看到的是正常电影名和评分。

第 17 关静态字体反爬电影榜页面

但如果查看 DOM,电影名里会混入私有区字符。例如页面显示:

南京照相馆

DOM 文本可能是:

\ue328\ue313\ue383相馆

这不是 UTF-8 解码失败。UTF-8 解码失败通常会出现乱码符号、问号或替换字符;字体反爬里,字符本身是“合法字符”,只是它们不代表正常语义。

更准确地说:

  1. 服务端返回了私有区字符。
  2. CSS 指定这些字符使用自定义字体。
  3. 自定义字体把 \ue328 画成“南”,把 \ue313 画成“京”,把 \ue383 画成“照”。
  4. 浏览器按字体渲染,所以肉眼看到正常中文。
  5. 爬虫读取 DOM,只能拿到原始字符。

这就是字体反爬最容易迷惑人的地方:浏览器视觉结果是“真”的,但 DOM 文本不等于真实业务文本。

从页面定位字体文件

字体反爬的第一步,是在 CSS 里找到字体文件。

第 17 关页面加载了类似这样的样式:

@font-face {
  font-family: "JiyunCipher";
  src: url("/static/static-font-map/fonts/jiyun-cipher.woff2") format("woff2");
  font-style: normal;
  font-weight: 400;
}

.cipher-title-text {
  font-family: "JiyunCipher";
}

这里有两个关键信息:

  1. @font-face 说明页面声明了一份自定义字体。
  2. cipher-title-text 说明电影标题这类字段使用了这份字体。

排查时可以按这个顺序走:

  1. 在 Elements 面板选中异常文字。
  2. 看右侧 Styles / Computed 里的 font-family
  3. 找到对应的 @font-face
  4. 复制字体文件地址。
  5. 下载 .woff2,交给 fontTools 分析。

如果字体地址在 CSS 文件里,也可以先请求 CSS,再用正则提取字体 URL。

import re
from urllib.parse import urljoin

import requests


page_url = "https://example.com/demo"
css_url = "https://example.com/static/challenge.css"

css_text = requests.get(css_url, timeout=10).text

# 从 @font-face 里提取字体文件路径
font_paths = re.findall(r"url\\(['\\\"]?([^)'\\\"]+)['\\\"]?\\)", css_text)
font_urls = [urljoin(css_url, path) for path in font_paths]

print(font_urls)

注意,字体 URL 可能是相对路径,不能直接拿来请求,要用 urljoin 补成完整地址。相对路径处理可以和 Python urljoin 教程 一起看。

用 fontTools 看懂 cmap

拿到字体文件后,可以先用 fontTools 查看 cmap 表。cmap 的作用是把 Unicode 码点映射到字体内部的 glyph 名称。

安装依赖:

pip install fonttools brotli

读取字体:

from fontTools.ttLib import TTFont


font = TTFont("jiyun-cipher.woff2")
cmap = font.getBestCmap()

for codepoint, glyph_name in list(cmap.items())[:10]:
    # codepoint 是 Unicode 码点,glyph_name 是字体内部字形名称
    print(hex(codepoint), glyph_name)

这里要特别注意:cmap 只能告诉你“某个码点对应哪个字形”,不一定直接告诉你“这个字形代表哪个中文”。如果网站故意把 \ue328 画成“南”,那你还需要知道这个字形长什么样,或者和一份标准字体做比对。

所以字体反爬通常分两步:

  1. 码点到 glyph:由 cmap 提供。
  2. glyph 到真实字符:靠映射表、人工标注、轮廓比对或已知标准字体。

固定字体映射怎么还原

固定字体比较友好,因为字体文件不会频繁变化。只要你建立一次映射表,后续就可以复用。

映射表大概长这样:

FONT_MAP = {
    "\\ue328": "南",
    "\\ue313": "京",
    "\\ue383": "照",
}

解码时遍历字符串,把命中的私有区字符替换掉:

def decode_text(text, mapping):
    result = []
    for char in text:
        # 命中字体映射就替换,没命中就保留原字符
        result.append(mapping.get(char, char))
    return "".join(result)


raw_title = "\ue328\ue313\ue383相馆"
print(decode_text(raw_title, FONT_MAP))

真正难的不是替换这一步,而是映射表怎么来。

常见办法有三种:

方法 适合场景 缺点
人工标注 字符少、一次性练习 不适合大规模
业务样本反推 页面上有已知结果或答案可比对 依赖样本质量
字形轮廓比对 字体动态变化、字符较多 实现复杂度更高

如果只是课程靶场或固定站点,人工建立一份映射表就能跑通。但如果字体文件会变,就要进入动态字体处理。

动态字体:每次正文都可能换一份字体

第 18 关是动态字体案例。目录页看起来很正常,进入正文后,浏览器仍然能把文字显示成《西游记》原文。

第 18 关动态字体反爬小说正文页面

但正文 DOM 里会出现大量私有区字符,并且页面会动态声明字体:

@font-face {
  font-family: "TraceNovelCipher";
  src: url("/static/dynamic-font-map-novel/fonts/10cc66bb778e.woff2") format("woff2");
}

这里的 10cc66bb778e.woff2 就是动态字体文件。真实项目里,这个文件名可能会随页面、批次、时间或会话变化。

动态字体不能只靠固定映射表,因为下一次字体文件变了,\ue123 可能不再代表同一个字。更稳的思路是:

  1. 每次请求页面时,先提取当前页面使用的字体 URL。
  2. 下载这份字体。
  3. 解析字体轮廓。
  4. 和标准字体或历史缓存做比对。
  5. 得到本次页面的映射表。
  6. 再解码正文。

为了避免每一页都重复比对,可以把字体文件做哈希缓存:

import hashlib


def font_hash(font_bytes):
    # 用字体内容生成缓存键,同一份字体只解析一次
    return hashlib.md5(font_bytes).hexdigest()

缓存结构可以设计成:

{
    "10cc66bb778e": {
        "\\ue101": "天",
        "\\ue102": "地"
    }
}

这样分页采集时,如果遇到同一份字体,就可以直接复用映射,速度会快很多。

字形轮廓比对的核心思路

字形轮廓比对的核心,不是“识别图片”,而是比较字体内部的矢量轮廓。

可以把它理解成:

  1. 从加密字体里取出某个 glyph 的轮廓点。
  2. 从标准字体里取出候选汉字的轮廓点。
  3. 计算两组轮廓的相似度。
  4. 找到最像的那个标准字。

伪代码大概是这样:

def build_mapping(cipher_font, standard_font, candidates):
    mapping = {}

    for codepoint, cipher_glyph in cipher_font.getBestCmap().items():
        best_char = None
        best_score = None

        for char in candidates:
            # 比较加密字形和标准字形的轮廓相似度
            score = compare_glyph_outline(cipher_font, cipher_glyph, standard_font, char)
            if best_score is None or score < best_score:
                best_score = score
                best_char = char

        mapping[chr(codepoint)] = best_char

    return mapping

实际代码里要处理更多细节,比如轮廓归一化、坐标缩放、复合字形、缺字、标点和数字。但整体方向就是:不要猜字符含义,要比较字形本身。

如果你正在跟着课程代码写,可以结合仓库里的字体反爬脚本看完整实现:爬虫课程代码同步仓库

排查字体反爬的实战清单

遇到“页面正常、代码乱码”时,可以按这个顺序排查:

  1. 先打印 response.text 或元素 textContent,确认乱码是不是私有区字符。
  2. 在 Elements 面板选中异常文字,看 font-family
  3. 搜索 CSS 里的 @font-face
  4. 找到 .woff2.woff.ttf 文件。
  5. fontTools 读取 cmap
  6. 判断字体是固定的还是动态的。
  7. 固定字体优先建立映射表。
  8. 动态字体优先做字体哈希缓存和轮廓比对。
  9. 解码后再进入正常的数据清洗、排序、入库流程。

不要一上来就换代理、换请求头。请求头解决的是访问身份问题,字体反爬解决的是渲染映射问题,两者不是一类故障。

常见问题

字体反爬是不是编码问题?

通常不是。编码问题发生在字节到字符串的解码阶段;字体反爬发生在字符串到视觉字形的渲染阶段。DOM 里的私有区字符是合法字符,只是它们被自定义字体画成了别的字。

只用 requests 能处理字体反爬吗?

可以。字体反爬不一定需要 Playwright。只要能请求页面、CSS 和字体文件,就可以用 requests 下载资源,再用 fontTools 分析字体。

Playwright 更适合用来观察页面真实渲染、定位动态插入的 CSS 或确认字体文件是否已经加载。

为什么字体文件下载下来了,还是不知道对应哪个字?

因为字体文件通常只告诉你“码点对应哪个字形”,不告诉你“字形语义是什么”。如果没有现成映射,就需要人工标注、样本反推或字形轮廓比对。

动态字体一定要做轮廓比对吗?

不一定。如果动态字体只是文件名变化,但内部映射不变,可以缓存一次映射。如果每次映射都变,轮廓比对会更稳。

判断方法很简单:下载两份不同时间或不同页面的字体,比较同一个私有区字符对应的字形是否一致。

最后提醒

字体反爬适合在授权靶场、课程练习和自己的业务页面里学习。真实项目里,还要同时考虑站点规则、访问频率、数据权限和合规边界。

技术上能还原,不代表业务上应该采集。开始写爬虫之前,建议先看这篇边界说明:爬虫合法吗?一文讲清爬虫法律边界与风险

Practice

读完这一节,去靶场里验证一下。

去挑战广场练习