Python 爬虫字体反爬实战:用 fontTools 还原自定义字体乱码
本文用 TraceCloud 第 15 到第 18 关串起 CSS 偏移、CSS content、固定字体映射和动态字体反爬,重点讲清自定义字体、Unicode 私有区、WOFF2、fontTools、cmap 和字形轮廓比对的完整处理思路。
字体反爬到底在伪装什么
Python 爬虫字体反爬处理的不是“网页乱码”这么简单的问题,而是:页面渲染出来像正常文字,但 DOM 里保存的并不是这些文字本身。
在浏览器里看,电影名、评分、票房都很正常;但用 requests、parsel、lxml 或浏览器控制台去读 DOM 文本时,拿到的可能是一串 \ue328、\ue313 这类 Unicode 私有区字符。浏览器之所以能显示成中文,是因为页面额外加载了一份自定义字体,把这些字符画成了另一个字。

所以,遇到字体反爬时,排查重点不是先改编码,也不是把 response.encoding 换来换去,而是确认三件事:
- DOM 文本里是否出现私有区字符。
- CSS 里是否有
@font-face。 - 页面是否加载了
.woff、.woff2、.ttf这类字体文件。
只要这三点同时出现,就要把问题当成字体映射来处理。
先把 CSS 伪装和字体伪装分开
TraceCloud 挑战广场第 15 到第 18 关都属于“页面看起来正常,但直接抓取会出错”的反爬练习,不过它们的伪装层不完全一样。
第 15 关更接近 CSS 偏移。页面上商品价格、库存、排序看起来很自然,但真实数据位置可能被样式打乱。如果代码只按 DOM 顺序取字段,就容易把标题和价格配错。

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

字体反爬和这两类问题的区别在于:它不是把元素位置挪开,也不是把内容藏到伪元素里,而是让同一个字符在这份字体里显示成另一个字形。
可以这样区分:
| 反爬类型 | 页面看起来 | 代码读到 | 排查重点 |
|---|---|---|---|
| CSS 偏移 | 数据位置正常 | DOM 顺序和视觉顺序不一致 | position、transform、排序规则 |
| CSS content | 页面能看到内容 | textContent 为空或不完整 |
::before、::after、CSS 类名映射 |
| 字体反爬 | 页面显示正常文字 | DOM 是私有区字符或错字 | @font-face、字体文件、字形映射 |
这一点先分清,后面才不会把字体反爬误判成编码问题。
固定字体:页面正常,DOM 不是正常中文
第 17 关是一个固定字体映射案例。浏览器渲染出来是电影热映榜,用户看到的是正常电影名和评分。

但如果查看 DOM,电影名里会混入私有区字符。例如页面显示:
南京照相馆
DOM 文本可能是:
\ue328\ue313\ue383相馆
这不是 UTF-8 解码失败。UTF-8 解码失败通常会出现乱码符号、问号或替换字符;字体反爬里,字符本身是“合法字符”,只是它们不代表正常语义。
更准确地说:
- 服务端返回了私有区字符。
- CSS 指定这些字符使用自定义字体。
- 自定义字体把
\ue328画成“南”,把\ue313画成“京”,把\ue383画成“照”。 - 浏览器按字体渲染,所以肉眼看到正常中文。
- 爬虫读取 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";
}
这里有两个关键信息:
@font-face说明页面声明了一份自定义字体。cipher-title-text说明电影标题这类字段使用了这份字体。
排查时可以按这个顺序走:
- 在 Elements 面板选中异常文字。
- 看右侧 Styles / Computed 里的
font-family。 - 找到对应的
@font-face。 - 复制字体文件地址。
- 下载
.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 画成“南”,那你还需要知道这个字形长什么样,或者和一份标准字体做比对。
所以字体反爬通常分两步:
- 码点到 glyph:由
cmap提供。 - 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 关是动态字体案例。目录页看起来很正常,进入正文后,浏览器仍然能把文字显示成《西游记》原文。

但正文 DOM 里会出现大量私有区字符,并且页面会动态声明字体:
@font-face {
font-family: "TraceNovelCipher";
src: url("/static/dynamic-font-map-novel/fonts/10cc66bb778e.woff2") format("woff2");
}
这里的 10cc66bb778e.woff2 就是动态字体文件。真实项目里,这个文件名可能会随页面、批次、时间或会话变化。
动态字体不能只靠固定映射表,因为下一次字体文件变了,\ue123 可能不再代表同一个字。更稳的思路是:
- 每次请求页面时,先提取当前页面使用的字体 URL。
- 下载这份字体。
- 解析字体轮廓。
- 和标准字体或历史缓存做比对。
- 得到本次页面的映射表。
- 再解码正文。
为了避免每一页都重复比对,可以把字体文件做哈希缓存:
import hashlib
def font_hash(font_bytes):
# 用字体内容生成缓存键,同一份字体只解析一次
return hashlib.md5(font_bytes).hexdigest()
缓存结构可以设计成:
{
"10cc66bb778e": {
"\\ue101": "天",
"\\ue102": "地"
}
}
这样分页采集时,如果遇到同一份字体,就可以直接复用映射,速度会快很多。
字形轮廓比对的核心思路
字形轮廓比对的核心,不是“识别图片”,而是比较字体内部的矢量轮廓。
可以把它理解成:
- 从加密字体里取出某个 glyph 的轮廓点。
- 从标准字体里取出候选汉字的轮廓点。
- 计算两组轮廓的相似度。
- 找到最像的那个标准字。
伪代码大概是这样:
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
实际代码里要处理更多细节,比如轮廓归一化、坐标缩放、复合字形、缺字、标点和数字。但整体方向就是:不要猜字符含义,要比较字形本身。
如果你正在跟着课程代码写,可以结合仓库里的字体反爬脚本看完整实现:爬虫课程代码同步仓库。
排查字体反爬的实战清单
遇到“页面正常、代码乱码”时,可以按这个顺序排查:
- 先打印
response.text或元素textContent,确认乱码是不是私有区字符。 - 在 Elements 面板选中异常文字,看
font-family。 - 搜索 CSS 里的
@font-face。 - 找到
.woff2、.woff或.ttf文件。 - 用
fontTools读取cmap。 - 判断字体是固定的还是动态的。
- 固定字体优先建立映射表。
- 动态字体优先做字体哈希缓存和轮廓比对。
- 解码后再进入正常的数据清洗、排序、入库流程。
不要一上来就换代理、换请求头。请求头解决的是访问身份问题,字体反爬解决的是渲染映射问题,两者不是一类故障。
常见问题
字体反爬是不是编码问题?
通常不是。编码问题发生在字节到字符串的解码阶段;字体反爬发生在字符串到视觉字形的渲染阶段。DOM 里的私有区字符是合法字符,只是它们被自定义字体画成了别的字。
只用 requests 能处理字体反爬吗?
可以。字体反爬不一定需要 Playwright。只要能请求页面、CSS 和字体文件,就可以用 requests 下载资源,再用 fontTools 分析字体。
Playwright 更适合用来观察页面真实渲染、定位动态插入的 CSS 或确认字体文件是否已经加载。
为什么字体文件下载下来了,还是不知道对应哪个字?
因为字体文件通常只告诉你“码点对应哪个字形”,不告诉你“字形语义是什么”。如果没有现成映射,就需要人工标注、样本反推或字形轮廓比对。
动态字体一定要做轮廓比对吗?
不一定。如果动态字体只是文件名变化,但内部映射不变,可以缓存一次映射。如果每次映射都变,轮廓比对会更稳。
判断方法很简单:下载两份不同时间或不同页面的字体,比较同一个私有区字符对应的字形是否一致。
最后提醒
字体反爬适合在授权靶场、课程练习和自己的业务页面里学习。真实项目里,还要同时考虑站点规则、访问频率、数据权限和合规边界。
技术上能还原,不代表业务上应该采集。开始写爬虫之前,建议先看这篇边界说明:爬虫合法吗?一文讲清爬虫法律边界与风险。
Practice