做数据采集这行的时间久了,你迟早会撞上一类让人抓狂的情况:页面上明明显示着“1.2亿”“3850万”这种流畅的数字,可你 Network 面板里看到的响应文本,却是一堆看起来毫无规律的方块字或者生僻字,复制出来粘贴到记事本里全是问号。这就是字体反爬在当前实时票房这类高频数据场景里的典型表现——页面渲染层看着一切正常,源码层却把关键数字换成了一套私有字库里的“替身字符”。这篇文章我想聊的就是这套机制怎么运转、我们怎么用最小的成本把它还原回人可读的数字。内容偏向实操,从字库文件结构、轮廓特征提取,到映射字典的构建与缓存策略,再到线上跑起来之后那些文档里绝不会写的坑,我都会一条条摊开讲。适合已经能写基本爬虫、但对字体混淆还停留在“知道有这么回事”阶段的朋友,也适合已经在跑数据管道、需要把稳定性再往上提一档的同行参考。
1. 先搞清楚对手:字体反爬到底在防什么
1.1 从一次典型的抓取失败说起
最早遇到这个问题的时候,我的第一反应是编码错了。毕竟响应里那些字符看起来像是 UTF-8 被当成 GBK 读了一遍的结果。但把响应体按字节 dump 出来、逐个对照 Unicode 码位之后,我发现了问题:这些字符的码位是合法且连续的,落在 Unicode 的私有使用区(Private Use Area,简称 PUA,码位段大约 0xE000–0xF8FF)里。私有区是 Unicode 专门留出来给各家自定义字符用的,操作系统和浏览器默认不知道这些码位该画成什么样,只有页面里通过@font-face声明的那份字体文件才能解释它们。
具体机制其实很朴素:服务端把页面里的数字字符(0-9,有时候还包括冒号、点、百分号、万、亿这些单位字)替换成一批 PUA 码位,同时下发一份定制的 WOFF 或 WOFF2 字体文件,在这份字体里,每一个 PUA 码位对应的字形轮廓,被画成了真实的数字形状。浏览器拿到字体后按图索骥,渲染出来就是正常的“3,850.6万”。而你在源码里看到的是“\ue12a\ue0f3\ue215”这一类东西,自然读不出任何含义。
这个设计的巧妙之处在于,它完全不依赖 JS 加密或者接口签名,成本极低——服务端换个字库文件,前端模板一个字都不用改,混淆就生效了。对反爬方来说,这是性价比极高的一招。
1.2 字体反爬的三代演进路径
如果只把字体反爬当成一个静态问题处理,你很快会被打脸。这几年我观察下来,这类方案大致走了三个阶段:
第一代是静态字库。一份字体文件长期不变,PUA 码位和真实数字的对应关系也是固定的。这一代最好对付,抓一次字库、人工标注一遍,做成常量字典就能一直用,几乎可以说是一次性投入。
第二代是动态字库。字体文件按天甚至按请求生成,每次下发的码位映射都是乱序的。你昨天做的字典今天完全失效,\ue001昨天是数字 5,今天可能变成了数字 2。这一代开始逼着采集方从“记映射”转向“认字形”。
第三代是动态字库加轮廓扰动。在动态映射的基础上,把字形轮廓的坐标做微小的随机偏移,或者调整笔画的粗细、节点数量,让基于坐标哈希的匹配方案也开始出现失配。这一代是当前实时票房类站点比较常见的形态,也是难度最高的一档。
理解这个演进脉络很重要,因为它直接决定了你的方案要不要留扩展位。我个人的习惯是:哪怕当前目标只是第一代的静态字库,架构上也按第二代的思路来搭,因为升级通常是不打招呼的。
1.3 为什么高频数据场景特别偏爱这套方案
实时票房这种数据有个特点:更新频率高、单条数据体积小、对外展示需求强。它不像用户资料那样敏感,不需要做强鉴权,但又确实不希望被大规模批量拉走。字体反爬恰好卡在这个位置上——它对正常用户零打扰,对轻度采集者提高了门槛,对重度采集者也只是提高了成本而非绝对阻断。
另外一个原因是渲染友好。票房数字在前端要做滚动动画、要做千分位格式化、要做不同字号下的清晰展示,用 SVG 或者 canvas 画数字反而麻烦,用字体是最省事的。所以这套方案在这里不是硬塞进去的,它本来就顺理成章,只是顺手加了混淆。
注意:本文讨论的所有技术手段仅用于个人技术学习与对公开数据的小规模研究,实际操作中请务必先阅读目标站点的 robots 协议与服务条款,控制请求频率,不要对目标服务造成压力。
2. 技术底座:WOFF 文件里到底装了什么
2.1 一份字体文件的内部结构速览
想还原映射,先得能读懂字库。WOFF 本质上是 OpenType(sfnt)加了压缩外壳,解包后你会看到一串表(table)。跟我们的目标真正相关的就那么几张:
| 表名 | 作用 | 在本场景中的价值 |
|---|---|---|
cmap | 字符码位到字形名(glyph name)的映射 | 核心,告诉你哪个 PUA 码位对应哪个字形 |
glyf | 字形轮廓数据(简单字形存点坐标,复合字形存组件引用) | 核心,字形指纹的来源 |
loca | 字形在 glyf 表中的偏移索引 | 一般不用直接读,fontTools 会处理 |
hmtx | 每个字形的水平度量(宽度等) | 辅助特征,可做二次校验 |
head | 全局信息,含 unitsPerEm(每 em 的单位数) | 归一化时的基准,必须读 |
name | 字体名称、版本等元信息 | 有时能用来判断字库是否更换 |
unitsPerEm这个值很多人会忽略,但它是坐标归一化的分母来源。常见取值是 1000 或 2048,如果不做归一化直接拿原始坐标做比较,字体一换坐标系你就全盘失配。
2.2 用 fontTools 把字库拆开看
Python 生态里处理字体,fontTools基本是唯一选择,没有之一。装好之后,几行代码就能把结构摊开:
from fontTools.ttLib import TTFont font = TTFont("target.woff") # WOFF/WOFF2/TTF 都能直接读 print(font.keys()) # 列出所有表 cmap = font.getBestCmap() # 返回 {码位(int): 字形名(str)} for code, gname in sorted(cmap.items()): print(hex(code), gname) glyph_order = font.getGlyphOrder() # 字形名列表,含 .notdef print(len(glyph_order))跑一遍你会看到类似0xe12a -> uniE12A这样的输出。注意字形名不一定是uniXXXX格式,有些生成工具会写成glyph001、cid00012甚至随机字符串,所以永远不要依赖字形名做判断,字形名是可以随便起的,轮廓才是硬事实。
2.3 关键概念:码位、字形名、轮廓三层分离
这里有个认知必须先建立起来:在字体世界里,“字符”和“字形”是彻底分离的两层。码位(codepoint)属于文本层,字形(glyph)属于渲染层,cmap表就是两者之间的桥。反爬方做的事情,就是反复改写这座桥——把码位0xE12A指向“数字 5 的字形”这件事,换成指向“数字 2 的字形”,或者干脆让码位每次都重新分配。
所以你的还原逻辑必须是:先通过 cmap 找到码位对应的字形,再通过字形轮廓判断它是哪个数字。中间这一步“通过轮廓判断身份”,才是整套方案的核心,也是所有难度的来源。跳过这层直接记忆“码位→数字”的对应,就是把自己绑死在第一代方案上。
3. 三条可行的还原路线与选型取舍
3.1 路线一:字库二进制比对
最省事的思路是判断字库有没有变。取一次已知字库的 MD5 作为指纹,每次请求前先拉字库,算哈希,命中已知指纹就直接用缓存好的映射表,命中不了再走后面的重型流程。
这条路线本身不解决任何问题,但它是整个方案的第一道闸门。实测下来,如果目标站点的字库是小时级或者天级轮换,这道闸门能挡掉 90% 以上的计算量——绝大多数请求都能直接命中缓存,只有换库的那一次需要做全量匹配。少了这一步,你的脚本会对着同一份字库反复做几千次轮廓解析,纯属浪费。
3.2 路线二:字形轮廓指纹匹配
这是主力方案。核心逻辑是把每个字形的轮廓点集合,经过平移和缩放归一化之后,转成一个稳定的字符串指纹,再拿这个指纹去查预先构建好的“指纹→真实字符”字典。
为什么用轮廓点而不是渲染成图片做 OCR?两个原因。一是速度,轮廓解析是纯内存计算,毫秒级;渲染成图再识别,光图像预处理就要几十毫秒,量大了差距明显。二是准确率,数字字形在轮廓层面差异极大——0 是闭合椭圆,1 是一条带小钩的竖线,7 有横折,8 是两个闭环,这些拓扑特征在坐标点序列里表达得非常干净,几乎不会误判。而渲染成小尺寸位图之后,1 和 7、3 和 8 在某些字体下反而容易糊在一起。
3.3 路线三:渲染截图加图像识别
作为兜底方案,把字形渲染成 64×64 左右的灰度图,用模板匹配做最近邻,或者用一个小型的分类网络。这条路线的好处是对轮廓扰动天然免疫——零点几个单位的坐标偏移在图像上根本看不出来。坏处是慢、依赖渲染引擎(freetype-py或者Pillow+fontTools的绘图后端),并且中文字符集下模板库会变得很大。
我的建议是把它当成第二道保险:主流程走轮廓指纹,匹配失败的字形记录下来,走图像识别兜底,同时告警。这样既能保持主流程的高速,又不会因为个别字形扰动导致整条数据链路产生空值。
3.4 三条路线的横向对比
| 维度 | 二进制比对 | 轮廓指纹 | 图像识别 |
|---|---|---|---|
| 单次耗时 | <1ms | 5–20ms | 50–200ms |
| 抗码位乱序 | 不适用 | 强 | 强 |
| 抗轮廓扰动 | 不适用 | 中等 | 强 |
| 需要预置数据 | 一份哈希 | 一份指纹字典 | 一套模板或模型 |
| 工程复杂度 | 低 | 中 | 高 |
| 我的使用方式 | 前置缓存判定 | 主流程 | 失败兜底 |
选型上没有绝对答案。如果目标站点是静态字库,路线一加上人工标注一次就够了,不必上来就搞重型方案。但如果你面对的是动态字库,路线二基本绕不开用,因为它是唯一能在“不知道今天映射是什么”的前提下,稳定给出答案的方案。
4. 完整实现:从拉字库到还原文本
4.1 定位字库地址并稳定获取
字库地址通常藏在页面<style>里的@font-face声明中,形如src: url(...),也有可能是通过 JS 动态注入的。用户代理字符串和 Referer 建议按正常浏览器来设置,因为你拿到的字库内容有可能随 UA 不同而不同——这一点挺关键,如果你的采集脚本 UA 和调试时浏览器不一致,可能拿到两份不同的字库,然后对着旧字库做匹配,结果全错。
import re, requests HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/122.0.0.0 Safari/537.36", "Referer": "https://example.com/", "Accept": "*/*", } def fetch_font_url(html: str, base: str) -> str: m = re.search(r"url\(['\"]?(.*?\.(?:woff2?|ttf))['\"]?\)", html) if not m: raise RuntimeError("页面里没有找到字体声明,检查是否被重定向或需登录") url = m.group(1) return url if url.startswith("http") else base + url这段代码看着简单,但有两个细节值得说。一是正则要同时兼容woff、woff2、ttf三种后缀,同一站点在不同客户端下可能下发不同格式。二是取到的相对路径要和页面 base URL 拼接,别直接丢给 requests,否则 404 会浪费你半天时间。
4.2 从 cmap 拿到码位与字形名的对照
拿到字库文件后,第一件事是建立本次字库的“码位→字形名”表,同时算出文件指纹:
import hashlib from fontTools.ttLib import TTFont def load_font_payload(content: bytes): fingerprint = hashlib.md5(content).hexdigest() font = TTFont(io_buffer := __import__("io").BytesIO(content)) cmap = font.getBestCmap() # {int: glyphName} upm = font["head"].unitsPerEm return fingerprint, font, cmap, upm这里用内存流而不是落盘,是为了避免并发时文件被覆盖——批量抓取场景下这个坑踩过一次就够了。unitsPerEm也一并读出来,后面归一化要用。
4.3 提取轮廓点并做归一化
核心函数长这样。思路是用RecordingPen把字形的绘制指令录下来,从中抽出所有坐标点,然后做平移加缩放的归一化,最后把点序列序列化成字符串:
import hashlib from fontTools.pens.recordingPen import RecordingPen def extract_points(font, glyph_name): pen = RecordingPen() font.getGlyphSet()[glyph_name].draw(pen) # 会自动展开复合字形 pts = [] for op, args in pen.value: for a in args: if isinstance(a, tuple) and len(a) == 2: pts.append(a) return pts def glyph_fingerprint(font, glyph_name, upm=1000, grid=100): pts = extract_points(font, glyph_name) if not pts: return None xs = [p[0] for p in pts] ys = [p[1] for p in pts] minx, maxx = min(xs), max(xs) miny, maxy = min(ys), max(ys) w = max(maxx - minx, 1e-6) h = max(maxy - miny, 1e-6) norm = [(round((x - minx) / w * grid), round((y - miny) / h * grid)) for x, y in pts] raw = ";".join(f"{x},{y}" for x, y in norm) return hashlib.md5(raw.encode()).hexdigest()参数选择是有讲究的。grid=100意味着归一化后坐标只保留两位有效精度,这是一种刻意的“粗粒度化”。原因是三代方案里的轮廓扰动通常幅度在 1%–3% 之间,如果grid取到 1000 甚至更高,一点点扰动就会让哈希完全变样。取 100 相当于给匹配留了一个容差带,实测对微小扰动比较稳,同时又不至于把 0 和 8 这种形状差异较大的字形混为一谈。
这里必须补一句:纯哈希匹配对扰动是零容忍的。如果目标字库的扰动幅度大到跨过了grid的量化边界,哈希会失效。所以我实际用的是“哈希优先 + 最近邻兜底”的混合策略:
def nearest_match(norm_pts, reference, threshold=6.0): """reference: {char: [(x,y), ...]} 归一化后的基准点集""" best, best_d = None, 1e9 for ch, ref_pts in reference.items(): if len(ref_pts) != len(norm_pts): continue # 点数不同直接跳过,先粗筛 d = sum(abs(a[0]-b[0]) + abs(a[1]-b[1]) for a, b in zip(norm_pts, ref_pts)) / len(norm_pts) if d < best_d: best, best_d = ch, d return (best, best_d) if best_d <= threshold else (None, best_d)先用点数做粗筛是个很实用的技巧。数字 1 的轮廓点数通常显著少于 0、8 这类复杂字形,这一层筛选能砍掉大半候选,速度提升很明显。
4.4 构建基准字典
基准字典的构建是一次性劳动,但要做扎实。我的做法是:收集几份不同时期的目标字库(或者人工用同一套字体族生成),把每个字形渲染出来人工标注一遍,存成{char: 归一化点集}的 JSON。规模不大——0-9 加上逗号、点、百分号、万、亿,二十来个字形,半小时就能干完。
import json def build_reference(font_path_map, grid=100): ref = {} for char, (path, gname) in font_path_map.items(): font = TTFont(path) pts = extract_points(font, gname) # 归一化逻辑同上,略 ref[char] = normalize(pts, grid) json.dump(ref, open("reference.json", "w"), ensure_ascii=False)标注的准确性直接决定整条链路的上限,别偷懒用什么自动推断。我吃过一次亏:当时图快,用字符在字符串里出现的频次去猜,结果把 6 和 9 标反了,后面两天的数据全是错的,还是业务侧看报表发现“票房突然腰斩”才反馈过来。
4.5 把映射接回页面文本
有了“本次字库的码位→真实字符”映射,最后一步就是文本替换。这里要提醒一句:别用全局字符串 replace。因为页面里可能有正常的 PUA 之外的中文数字,也可能有 JS 里硬编码的字符串,全局替换容易误伤。正确做法是先把映射表按码位长度排序,再逐码位替换,或者用str.translate配合映射表:
def decode_text(text: str, code2char: dict) -> str: table = {code: ch for code, ch in code2char.items()} return text.translate(table)str.translate的好处是单遍扫描、无递归、不会出现替换后又被二次替换的问题,效率也高。如果是整页 HTML,建议先用正则把包含 PUA 字符的片段抠出来单独替换,再把结果拼回去,避免把<script>里的内容也搅了。
4.6 缓存设计与增量更新
线上跑起来之后,缓存策略决定了你的稳定性和资源消耗。我的做法分三层:
第一层是文件指纹缓存,键是字库 MD5,值是完整映射表,落 Redis,TTL 设成比字库轮换周期略长(比如字库可能 30 分钟换一次,TTL 就设 45 分钟)。第二层是字形指纹字典,就是上面那份reference.json,本地加载常驻内存,进程启动时读一次。第三层是失败样本池,所有匹配失败的字形连同归一化点集一起存下来,定期人工过一遍,把新变体补进基准库。
这三层配合下来,正常情况下 99% 的请求走第一层直接命中,1% 走完整匹配流程,失败率能压到很低。缓存键一定要用文件内容哈希而不是 URL——同一个 URL 可能返回不同内容,用 URL 做键会导致你缓存到别人的字库。
5. 常见坑与排查实录
5.1 字库缓存导致的版本错位
这是最容易踩、也最难自查的坑。浏览器和 CDN 都会缓存字体文件,你的脚本如果从本地缓存或者某个中间层拿到了旧字库,却用它去解码新页面,结果就是整页数字全错,而且错得“看起来很合理”——数字还是数字,只是值不对。加上票房数据本身就在波动,肉眼很难第一时间发现。
排查思路:在解码结果里加一层合理性校验,比如票价字段应该在几十元量级,百分比字段应该在 0–100 之间,单日票房不应该出现连续两位数量的跳变。任何一个校验不通过,就强制刷新字库重试一次。这个兜底逻辑我建议必加,它能拦住绝大多数版本错位问题。
5.2 同一页面内码位重复使用
有些实现比较粗糙的站点,会在同一页面里用同一个 PUA 码位表示两个不同的数字——通常是因为服务端对多个文本节点做了独立混淆,但没做全局去重。这种情况下,单纯按码位建映射表是建不出来的,因为键冲突了。
解法是放弃“页面级映射”,改成“节点级映射”:对每个包含 PUA 字符的文本节点单独处理,如果同一节点内出现同码位不同含义,那说明连节点级都救不了,只能退回到基于上下文位置的启发式判断。这种情况概率不高,但遇到一次就够你排查半天,所以我一般在解码后加一句日志,记录本次页面是否存在码位冲突。
5.3 坐标精度漂移引发连锁失配
前面提过,如果基准字典是在一份字库上建的,而线上字库做了轮廓扰动,点坐标会有微小偏移。grid=100的量化能吸收掉一部分,但吸收不掉的时候,哈希会直接不命中,然后落到最近邻匹配。最近邻如果用曼哈顿距离且阈值设得太松,可能把 5 判成 6;设得太紧,又会大量返回 None。
调参经验:先拿一批失败样本,把每个样本到各基准字形的距离全打出来,看正确答案排第几。如果正确答案稳定排第一,只是距离略大于阈值,那就放宽阈值;如果正确答案排第二甚至更低,那说明基准库本身有问题,得回头检查标注。别凭感觉调,一定要看数据。
5.4 私有区之外的字符也要留意
有些站点不用 PUA,而是把数字映射到常用汉字的位置上——比如用“龘”“靐”这类生僻字,或者干脆用普通汉字的码位,配合自定义字体把字形画成数字。这种方案下你光看码位判断不出来是混淆字符,必须依据字体覆盖范围来判断:如果页面文本里出现了明显不符合语境的生僻字,而页面又加载了自定义字体,那大概率就是这种情况。
应对方式没有本质差别,还是走字形指纹,只是提取候选字符时不能只挑 PUA 段,要按“字体 cmap 里覆盖了、但正常语义下不该出现在这个位置”来筛。
5.5 常见问题速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 解码后全是 None | 基准库与线上字库字重/字族不一致 | 重新采集同源字库建基准 |
| 部分字符错乱 | 页面内码位冲突 | 改节点级映射,加冲突日志 |
| 数字整体偏移一位 | 千分位逗号被混淆且未还原 | 补齐标点类字形到基准库 |
| 昨天正常今天全错 | 字库轮换且映射重建失败 | 检查缓存键是否为文件哈希 |
| 匹配耗时突然变长 | 失败样本触发大量最近邻计算 | 加点数粗筛,限制候选数 |
| 抓到字库但解析报错 | WOFF2 需要 brotli 支持 | 安装 brotli 依赖,或指定字体格式 |
6. 工程化收尾与合规边界
6.1 请求节奏与数据合规
技术方案再漂亮,也要放在合规的框架里用。我给自己定的几条规矩:请求频率控制在远低于站点正常用户访问量的水平,遇到 429 或者 403 立即退避而不是加大重试;只取展示层的公开数据,不碰任何需要登录态才有权访问的内容;抓下来的数据只做内部趋势分析,不做二次分发。这些不是空话,一旦请求量上去了,最先崩的不是你的代码,是对方的服务,那性质就变了。
另外要提醒的是,字体文件本身也是受版权保护的作品。把字库文件直接打包进自己的项目分发,风险比抓数据本身更大。我的做法是只保留计算出的指纹字典,原始字库文件用完即删。
6.2 把脚本做成能长期跑的小工具
最后聊聊维护性。这类脚本最容易出现的问题是:写完能跑,三个月后字库一升级,全线崩溃,而你已经忘了当时怎么写的。避免这个局面的办法是把几个关键点固化成配置和监控:
一是把grid、最近邻阈值、缓存 TTL 这些参数全部外置到配置文件,改参数不用动代码。二是每次解码都记录一条结构化日志,包含字库指纹、匹配成功数、失败数、平均距离,出问题时一眼能定位到是字库换了还是阈值不对。三是加一个每日自检任务,用固定样本跑一遍完整链路,结果和预期比对,不对就告警。
还有个小细节:把基准库的版本号和代码版本绑定,并在解码结果里带上这个版本号。这样当业务方来问“为什么昨天的数据看着不对”,你能立刻判断出是不是基准库切换导致的,省掉大量扯皮。
我个人在实际操作中的体会是,字体反爬这类问题的难点从来不在“能不能破”,而在“能不能稳定地破”。临时写个脚本还原一次页面,半小时的事;但要让这条链路在字库天天轮换、轮廓偶尔扰动、上游偶尔改版的情况下连续跑上几个月不出错,需要的是缓存分层、失败兜底、监控告警这一整套东西。真正值钱的是工程化那部分,识别算法本身反而是最容易被复制的一环。