字体反爬破解实战:从字体文件结构到字形比对还原
抓到的网页源码里中文是正常的渲染出来却是整屏乱码时的那种抓狂感做过爬虫的人应该都懂。这不是编码问题大概率是碰上了字体反爬。字体反爬是目前企业信息平台、招聘网站、汽车资讯站点用得比较多的一种反爬手段核心思路就是页面返回的 HTML 里每一个字符都是真实的、可读的但在浏览器渲染时通过一段自定义字体文件把原本的字符编码偷偷映射到了完全不同的字形上。你在浏览器里看到的“张三”可能是页面里某个Unicode码位对应的字形而你程序里拿到的同一串码位按系统默认字体去解析就变成了“乱码”或者另一个字。这篇文章我想从原理、解析流程、工具链到实战踩坑完整聊一遍字体反爬分析怎么做如何建立一个稳定可靠的还原链路。适合刚接触反爬对抗、想了解字体反爬原理的读者也适合已经在处理实际页面但识别率一直上不去的朋友参考。1. 字体反爬的核心思路先搞清楚它骗了谁很多朋友第一次遇到字体反爬时会先去翻页面编码、请求头、Cookies折腾半天毫无进展。原因很简单这压根不是传输环节的问题而是浏览器渲染环节被“调了包”。1.1 一段乱码源码背后的真实逻辑先看一个典型场景。用 requests 或者 curl 拉取一个企业信息详情页页面源码中公司注册资本那一栏显示的是#x8fd9;#x662f;#x6d4b;#x8bd5;Unicode 解码出来是“这是测试”四个字。表面看起来一切正常。但如果你把页面里的数字字段也拿来看比如“1000”显示成了#xf451;#xf1a3;#xf1e7;#xf1a3;这时就要警惕了——数字对应的编码全部落在了 Unicode 的私人使用区或者某个自定义区段。浏览器为什么能正确显示因为页面里通常藏着一份 CSS用font-face声明了一个自定义字体文件里面把上面这些特殊编码精确映射到了数字“1000”的字形上。浏览器加载了这个字体后就按字体文件的映射关系去画字符。而你的程序直接解析源码时拿到的只是那一堆特殊编码没有加载那份字体文件自然不会显示成“1000”。打个比方文字本身没变只是每个字都被贴了一个只有“特定字典”才能查到的假门牌号。字体文件就是那本生效的字典。1.2 font-face 与字符映射机制字体反爬用的东西本质上是字体文件的标准机制没有用任何非法技术。font-face是 CSS 里非常常见的写法开发者也经常用来自定义图标字体。反爬方只是利用了同一套机制把常用文字的数字和汉字装进了一套自定义字形里。举个例子一个页面可能有这样的 CSSfont-face { font-family: custom-icon; src: url(//static.example.com/fonts/abc123.woff2) format(woff2); }页面里的文字是这样写的span stylefont-family: custom-icon;#xf451;/span当浏览器解析到这一条规则时会去加载abc123.woff2。这个字体文件里码位f451对应的字形被画成了“1”。所以用户看到的就是数字 1。如果程序直接读 HTML拿到的却是#xf451;用系统字体尝试解码大概率是一个方块或者完全不相关的字符。理解了这个机制后面所有的解析操作就都围绕一件事展开把字体文件里的码位到真实字形的映射关系还原出来。1.3 为什么字体反爬比图片验证码更“缠人”图片验证码、滑动验证这类方案干扰的是程序读取文本的能力本质是让人工或者视觉模型介入。而字体反爬干扰的是程序读取文本的结果它给你返回的还是文本只不过这文本必须配合一份字体文件才能被正确翻译。这就带来了两个麻烦误导性强。刚接触的人容易往编码、压缩、混淆的方向排查浪费大量时间。动态性强。不少站点每次刷新页面都会生成新的字体文件旧映射表会立刻失效写死一份映射表根本不够用。换句话说它给你一种“文本就在眼前”的错觉但真正要看懂这行字必须拿到门口那把特制的钥匙。2. 字体文件内部结构藏在字形数据里的真相要破解字体反爬第一步是搞懂字体文件本身的结构。很多教程上手就直接让你连字体名和编码做替换看起来好像解决了但一旦站点换字体就抓瞎。核心问题在于没有理解字体文件的数据组织方式。2.1 cmap 表码位到字形的“总索引”字体文件TTF、OTF、WOFF、WOFF2在结构上是一堆表的集合。其中最关键的一张表叫cmap字符集映射表它记录的就是每一个 Unicode 码位对应到一个字形编号glyph ID的映射关系。浏览器渲染字符时先查 cmap 表得到 glyph ID再根据 glyph ID 去取对应的轮廓数据进行绘制。正常情况下Unicode 码位 U0031 对应的字形就是数字“1”的形状专业术语叫 glyph 的索引指向“one”这个名字。但在字体反爬的场景里作者会故意把 U0031 这个码位指向一个看起来像“7”的字形。页面里放的还是 U0031渲染出来的却是“7”。这就是 cmap 层被“调包”了。用 Python 的fontTools库可以非常方便地把 cmap 表读出来比如from fontTools.ttLib import TTFont font TTFont(custom_font.woff) cmap font.getBestCmap() # cmap 是一个字典键为 Unicode 码位值为 glyph 名称 for code, name in list(cmap.items())[:50]: print(hex(code), name)运行之后你会发现里面码位对应的 glyph 名和标准字体完全不同这基本就能确认页面用了字体反爬。2.2 glyf 表与字形轮廓真正的字符指纹cmap 表只能告诉我们某个码位指向哪个字形还看不出这个字形长什么样。真正决定字形外观的是glyf表里面记录着每个字形的轮廓点坐标、曲线的控制点还有字形是否组合等信息。对于大部分字体反爬场景这些轮廓点坐标就是最关键的突破口。因为不管反爬方怎么调整码位映射只要最终要画的是数字“1”那它的轮廓点坐标就和标准字体的数字“1”高度相似。即使字形做过旋转、缩放、平移也可以通过计算外部特征去匹配。这里有一个很务实的分析思路准备一份标准的系统字体比如多个常用字体混合后的字形库然后拿目标字体文件里的每个字形轮廓和标准库里所有字符的轮廓做相似度比较找出最匹配的那个字符就能还原出码位到真实文字的关系。这个思路也被称为“字形比对还原法”是破解任意动态字体反爬的通用解法。2.3 动态轮换与编码偏移工程上的两把“锁”做得比较严谨的站点通常不会只用一套静态字体文件。它们的常见操作有两类动态重排每次页面请求后端生成新的字体文件把数字或常用汉字重新映射到新的码位上然后更新 CSS 引用的文件名。只要你重新加载页面旧的文件就失效了。编码偏移在同一个字体文件里把所有码位整体平移一个随机量比如原本 U0033 渲染数字“5”另一套字体里 U0073 也渲染数字“5”。页面源码里的编码位置也跟着变。这两类操作本质上都是在制造映射关系的时效性和随机性目的就是让写死映射表的自动化脚本迅速失效。对抗手段只有一个解析流程必须每次处理页面时现取字体文件现算映射绝不复用旧映射。3. 实战五步走从请求到还原的完整链路讲了半天原理到最关键的一步了。下面用一个标准的字体反爬页面为例展示我从拿到 HTML 到输出还原文本的完整实操流程。3.1 第一步锁定真正加载的字体文件打开目标页面用浏览器开发者工具切到 Network 面板刷新页面后过滤字体类型Font。通常能看到一个或多个.woff2、.woff或.ttf文件。如果文件请求带了随机参数且每次刷新文件名都在变那就基本确认是动态字体了。直接从 Network 里拿到的字体 URL 是第一个分析样本。但如果你要写自动化脚本就还得往前再多走一步找到是谁在引用这个字体文件。往往页面里会有一段 CSS 或内联样式比如font-face { font-family: Pages.font; src: url(//static.example.com/css/xxxxx.woff) format(woff); }这段 CSS 可能是静态文件也可能是 JavaScript 动态插入的。用正则从 HTML 和 JS 里把url(...)中的字体地址提取出来再拼接上协议和域名就是自动化请求的目标。3.2 第二步下载并解析字体文件拿到字体文件 URL 后用常规的 requests 下载即可。下载后先用fontTools读取基本信息确认文件类型、版本、包含哪些表。这里多啰嗦一句.woff和.woff2本身就是带压缩的封装格式直接拿十六进制工具看会看到一堆压缩数据别慌用fontTools加载时它会自动处理解压。读取 cmap 表以后最好先人工抽几个条目验证一下。把某个码位对应的 glyph 渲染成图片或者把轮廓坐标打印出来看看是不是真的和页面渲染的字符一致。实测下来这一步最容易被忽略但恰恰是最能确认解析逻辑是否正确的环节。3.3 第三步提取字形特征并建立映射这一步是整个字体反爬分析的核心。最稳妥的方式是做一个字形相似度比对。思路不复杂准备参考字库可以取主流系统字体文件如中易宋体、微软雅黑、思源黑体等作为基准库把所有常用字符的字形坐标提前抽取并缓存。提取目标字形对于待分析字体文件的每一个 glyph提取它的轮廓点坐标序列。计算相似度目标字形的坐标序列和参考字形的坐标序列做形状匹配比如计算两个轮廓的凸包、矩特征、面积比例或者做多点采样后的欧氏距离。常用的方法有计算字形关键点的相对位置矩阵或者直接用归一化后的轮廓做交并比。当相似度得分超过一个阈值比如 90%就可以建立目标字体码位和真实字符的一一对应关系。举个例子目标字体的 cmap 表里码位0xE829的 glyph 形状和参考库里“8”的匹配得分是 96%那映射关系就是0xE829 - 8。这个思路对动态字体非常好用你不需要去依赖服务器端具体怎么排布码位只需要把字形形状和标准字库做对比就能还原出真实字符。对于中文汉字得把字形坐标的维度和参考库的字符集做充分匹配遇到结构相似的字比如“日”和“曰”时建议把匹配阈值调高或者加入多字形交叉验证。3.4 第四步还原页面文本并验证准确性拿到映射表之后页面里所有用到的自定义文字都可以通过查表替换回真实字符。实际操作时很多页面的字体文件会覆盖一批常用汉字或字符子集替换范围以字体文件实际包含的 glyph 为准。写还原逻辑的时候我建议用字符级替换而不是简单的字符串 replace。因为页面中字符编码可能是 HTML 实体#x...;形式也可能直接是 Unicode 字符建议统一先解码成 Unicode 字符再用映射字典逐字符替换。注意这一步通常只处理页面中特定字段的内容不必把整个页面所有文字都替换掉因为很多普通文案用的还是系统默认字体强行替换可能把正常文字搞乱。验证准确率时我会抽取页面上 20 到 30 个关键字段和浏览器截图做人工对比。如果发现某个字符还原成“B”而浏览器里显示的是“8”就需要回到字形匹配环节看看是不是相似度阈值设得太宽松或者出现了多个候选字形。3.5 第五步应对动态刷新与批量采集当解析脚本能稳定还原单次页面之后就要考虑批量采集场景下的动态刷新问题。我的经验是不要在程序启动时候解析一次字体就用一整天。多数动态字体的失效周期很短有的站点会话session结束就换有的每隔固定时间换一次。稳妥的策略是“按页面刷新”或“按分钟级轮询”更新映射表。具体实现上可以在每次抓取页面后主动获取页面最新引用的字体文件如果文件 URL 发生了变化就重新解析并更新映射表。需要注意缓存问题。HTTP 缓存、CDN 缓存都可能让请求到的 CSS 或字体文件不是你当前页面正在用的那一份。最简单的做法是给字体文件请求加一个随机的 query 参数绕过中间层缓存。4. 进阶对抗技术噪声点、WOFF2 与 CFF 的坑基础流程跑通后接下来是进一步增加鲁棒性的环节。有些站点为了阻止字形比对会对字体文件动更多手脚。4.1 坐标噪声与模糊匹配策略部分站点的字体文件里同一字符的字形会在每次生成时加入少量随机噪声点或者对坐标做轻微扰动。这样一来哪怕你抓到了同一个字符两次轮廓坐标也不会完全一样。直接比对坐标序列会失败。应对方式是用模糊匹配。可以把字形轮廓先离散化成点集计算形状描述子也可以把轮廓转成二值图像后计算相似度。实测比较实用的是轮廓点采样后计算“中心距矩特征”再用欧氏距离做相似度匹配。这个过程中噪声点的影响会被大量平均掉匹配结果依然稳定。这里也给一个预处理的技巧在用坐标比对前先用一个简单的凸包过滤掉离群点或者把轮廓数和每个轮廓的点数作为粗筛条件能大幅减少误匹配。4.2 WOFF2 压缩格式的分析陷阱WOFF2 和 WOFF 都支持对字形数据的二次压缩。用fontTools加载时一般不必手动解压但要注意WOFF2 内部对 glyf 表做过裁剪和有损变换直接用fontTools读取某些数据时得到的是经过变换后的坐标部分文件还会被转成特定格式。应对方法很简单用fontTools的save()方法把字体先转成一个无压缩的 TTF 文件再重新加载分析。这个操作基本可以消除格式本身带来的解析误差实测下来比直接读 WOFF2 更稳定。4.3 区分 TrueType 与 CFF 字体的分析路径大部分反爬字体是基于 TrueType 轮廓glyf 表但也有少数站点用 CFFPostScript 轮廓字体。CFF 字体没有 glyf 表轮廓数据存放在 CFF 表的 CharStrings 里解析方式完全不同。用fontTools判断方式很简单看到glyf表就走 glyf 坐标提取如果没有glyf表而只有CFF表就需要用fontTools.cffLib里的对象去解析。实际中如果是 CFF 字体字形轮廓的数据结构是 bytecode 形式的指令集处理起来会麻烦一些。好消息是大多数厂商默认还是用 TrueType 格式先把 TrueType 路径吃透遇到 CFF 再针对性处理即可。5. 常见问题排查实录从映射错乱到识别率不稳实际操作中最容易出问题的地方往往不在字体解析本身而在环境、缓存和逻辑设计这些“周边”环节。我把这套流程里踩过的一些坑集中列出来当作排查清单。5.1 为什么每次刷新字体 URL 都在变程序却还是请求到旧文件最常见的原因是设置了对字体文件的 HTTP 缓存或者使用了带缓存层的会话。浏览器和代码库对静态资源默认都会做缓存字体文件 URL 变了但资源还在缓存里解析到的是旧文件自然对不上。解决办法是每次请求字体文件时强制带一个随机查询参数比如xxxxx.woff?v202501011200。同时检查代码里是否对字体文件做了条件请求把缓存相关配置关掉。如果站点通过 JS 动态生成 CSS则需要确保提取字体的时机在页面完全渲染后。5.2 字形相似度匹配正确率不稳定偶尔单个字符识别错这种问题通常出在参考库构造上或者匹配算法对相似字形区分能力不足。比如“0”和“O”、“小”和“、”这类字形在某些字体下差别极小导致匹配到错误结果。建议给匹配环节加入一个置信度阈值和备用候选名单。当最佳匹配和次佳匹配的得分差距小于某个阈值时不急于确定映射可以先用上下文辅助判断比如数字字段里只可能是0-9中文字段里结合词频推断或者把该字形标记为“待人工确认”。在批量采集场景下宁可标记出来也不要强行替换。5.3 字体文件本身解析不出有效字形数据如果是 WOFF2 文件先尝试转成 TTF 再看如果还是空的检查字体文件范围是否只覆盖了某几个字符子集。部分反爬字体会刻意把字体文件瘦身只保留页面中出现的若干字符。这种情况下字形数量太少匹配参考库时要把数据库范围扩大。另外有的站点会加载多个字体文件不同字段不同页面区块用不同字体。这种情况下解析时要区分字段对应的字体上下文不能全局用同一张映射表否则不同字体的同码位字符会互相干扰。5.4 页面同时存在字体反爬和其他反爬机制的场景字体反爬经常不会单独出现而是会和 JS 加密、接口签名、滑块验证等叠加。碰到这种场景建议先解决字体还原再做数据入库。因为字体还原是纯数据处理问题和请求链路解耦最容易单独验证。还有个务实的原则先把数据抓到本地用离线方式解析字体并还原而不是在请求链路中实时计算。这样即使页面字体更新了历史数据也能通过归档的字体文件回溯还原极大提升排查效率。6. 最后分享一点我的实际体会字体反爬分析这件事难度并不在于“读懂字体文件”而在于建立起一套能应对动态变化的完整处理链路。我刚开始处理某个招聘平台的数据时第一次就把字体文件下载下来、成功还原了 200 多个字符心里觉得这事已经拿捏了。可第二天再跑脚本发现全部映射都失效了排查了半天才发现页面引用的字体文件名带上了时间戳每次刷新都换一套新字体。从那以后我就养成了一个习惯所有解析逻辑都以“每次页面都是新的字体”为前提来设计不抱侥幸心理。另外抓取频率上也得注意分寸。字体反爬站点往往会把字体文件的刷新频率和同一 IP 的访问频率关联起来请求过于密集会触发额外的风控。我实际踩过几次教训之后现在的做法是控制单页抓取间隔并给字体解析加一层本地缓存只有字体 URL 变化时才触发重新解析而不是页面一多就无脑反复下载字体。这种做法既稳定也减少了对目标站点的无效请求。如果你正准备开始处理字体反爬我的建议是先拿三五个不同站点的字体文件做样本把字形匹配的准确率调试到 95% 以上再上线。字体反爬的技术对抗没有终点但只要你把字形比对的底层逻辑吃透后面不管换什么样的字体文件、怎么重新映射码位都能在同一套框架里解决。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →