字体反爬实战:从原理分析到字形识别完整指南
发布时间:2026/10/2 9:13:49 锦皓数字建站

字体反爬这玩意儿做爬虫的兄弟迟早都会遇到。它不算什么高深技术但确实能拦住一大批只会用requests乱抓的人。我最早碰到字体反爬是在抓一个招聘网站的薪资数据页面显示的是“25K-35K”结果HTML源码里是一堆歪七扭八的乱码字符当时第一反应是编码问题折腾了半天才发现是字体文件在捣鬼。后来陆陆续续处理过小说站、房产站、票房数据站算是把这类反爬从原理到落地吃透了。这篇文章就把我自己的完整分析思路、工具链、代码实现和一些踩坑经历一次性整理出来给同样被字体反爬卡住的人一个可以直接上手的参考。字体反爬的核心思路说起来很简单页面上的文字在你肉眼看的时候是正常的但在HTML源码里却是一堆错乱的字符浏览器之所以能正常显示是因为CSS里加载了一个自定义字体文件把那些错乱字符映射成了正常文字。爬虫如果不处理这个字体映射拿到的数据就是一堆废品。这篇文章适合所有在做数据采集、文本分析或者对反爬机制感兴趣的开发者我尽量把每一步都讲透即使你之前没接触过字体解析也能照着做。1. 字体反爬到底是怎么运作的1.1 用一个招聘页面看懂字体反爬咱们直接进入正题。假设你要采集某个招聘网站上所有前端岗位的薪资浏览器里看到的信息是这样的岗位前端工程师薪资25K-35K经验3-5年当你用爬虫去抓这个页面的HTML时发现“25K-35K”变成了“鈩K-驎K”或者“锟斤拷K-烫烫K”反正就是不能直接用。这时候你打开Chrome的开发者工具在Elements面板里看同一个元素的代码发现HTML源码里确实就是那些乱码字符但界面显示却是正常的数字。原因是什么呢是因为这个网页的CSS里藏了一个font-face规则大致长这样font-face { font-family: myFont; src: url(//cdn.example.com/fonts/abc123.woff) format(woff); }浏览器拿到这个字体文件后会按照字体文件内部的映射规则把乱码字符替换成正常的“2”“5”“3”这些数字。这就像是一本加密日记每个字母都被替换成了特殊符号而字体文件就是解密用的字典浏览器拿着这本字典正常阅读爬虫没有字典就只能看到一堆乱码。我把这个机制拆成三个环节HTML源码里的错乱字符、自定义字体文件中的映射关系、浏览器渲染时的字形替换。三者的关系是一环扣一环的我们做字体反爬分析本质上就是想办法拿到那本“字典”。1.2 字体反爬为什么能拦住那么多爬虫说实话字体反爬的技术门槛并不高但确实能过滤掉一大批人。原因主要有两个一是很多爬虫工程师习惯性地认为HTML源码里的文字就是页面显示的文字压根不会往字体映射这个方向想二是即便意识到是字体的问题处理起来也需要懂一点字体文件的格式知识很多人就卡在这一步了。从爬虫的工作流程来看正常情况下我拿到HTML字符串后用正则或者XPath把需要的内容提取出来然后存库、清洗、格式化这套流程在普通网站上没有问题。但字体反爬改变了整个数据链路页面文字的真实含义不在HTML里而在CSS引用的字体文件里。爬虫如果不额外处理字体文件提取出来的就是一堆没有语义的字符。另外还有个容易忽略的点字体反爬不仅能用于数字也能用于中文。很多小说网站会用一个自定义字体把正文的常用汉字全部打乱你辛辛苦苦抓下来的小说内容全是错字。还有些股票数据网站会把百分号、小数点这些符号也替换掉做金融数据采集的朋友如果没注意到清洗数据的时候会非常痛苦。1.3 常见的使用场景与特征判断根据我做过的案例字体反爬在以下几类网站中特别常见场景典型目标常用混淆对象招聘信息薪资范围、职位人数数字房源信息房价、面积、楼层数字小说文学正文内容常用汉字金融数据涨跌幅、成交额数字、小数点和百分号票房榜单票房数字、观影人次数字电商平台价格数据数字、人民币符号你可以通过几个特征快速判断一个网站是否用了字体反爬。第一个特征在DevTools里看页面显示的文字再对比一下Network面板里的HTML响应原文如果两者不一致基本就是字体反爬。第二个特征在Sources面板里能看到woff、ttf或者otf格式的字体文件而且这类文件往往是通过CSS的font-face动态加载的。第三个特征HTML源码里的文字如果出现在Unicode私用区大致范围是\uE000到\uF8FF那也大概率是字体反爬因为私用区字符本来就不应该出现在正常内容里。2. 抓取并解析字体文件2.1 第一步从页面定位字体文件搞清楚了原理接下来的问题就是怎么拿到那个字体文件。我的习惯是直接用Chrome的开发者工具在Network面板里刷新页面然后筛选Font类型的请求。如果你发现页面加载了不止一个字体文件也不用慌可以用XHR筛选结合CSS分析的方式来确定具体是哪一个。举个例子刚才的招聘页面我在Network里看到两个woff文件一个叫datetime.woff一个叫number.woff。从文件名就能猜到第一个负责时间字段的字体第二个负责数字字段的字体。遇到这种命名清晰的网站算运气好的更多情况下字体文件名是一串随机字符比如abc123.woff。这时就得回到CSS里去找线索了。用DevTools的Elements面板选中那个显示乱码的元素右侧的Styles栏里会列出实际生效的CSS规则里面一般会显示font-family和对应的src。你把这个src里的URL拿出来用浏览器直接访问就能下载字体文件。我在本地工作的时候通常是直接把字体文件保存下来然后放到专门的解析目录里。注意有些网站的字体文件不是直接在CSS里写死的而是通过JavaScript动态生成的。这种情况下你可以多刷新几次页面观察Network里字体文件的请求参数有没有变化或者直接全局搜索.woff、.ttf这些关键词基本都能找到加载逻辑。2.2 用fontTools解析woff文件拿到字体文件之后就要请出主力工具了。字体文件解析我习惯用Python的fontTools库这个库是处理字体文件的瑞士军刀支持读取、修改、转换各种字体格式。安装很简单pip install fonttools有的场景还需要额外装一个brotli用于解压woff2格式的文件因为woff2在woff的基础上又做了一层压缩不装这个库会解析失败。pip install brotli现在假设我们已经下载了一个abc123.woff文件先把它读进来看看结构from fontTools.ttLib import TTFont font TTFont(abc123.woff) font.save(abc123.ttf) cmap font.getBestCmap() print(type(cmap)) print(len(cmap)) for code, name in list(cmap.items())[:20]: print(hex(code), name)这段代码做了几件事第一行创建TTFont对象用于加载字体文件第二行把woff转成ttf方便后续用其他工具打开第三行调用getBestCmap()获取字体文件里最完整的字符映射表。打印结果大概长这样0xe001 glyph00001 0xe002 glyph00002 0xe003 glyph00003 ...看到没有码点全是0xe开头的私用区编码字形名称是没有任何语义的glyph00001。这说明什么说明这个字体文件里的映射关系是被人为打乱过的。正常的字体文件码点应该是类似0x31数字1、0x32数字2这种标准Unicode码点字形名称也会是one、two或者uni4E00这种能看出含义的名字。2.3 从字形到真实文字拿着cmap表我们有了一堆私用区编码和字形名的对应关系但事情还远远没结束。关键问题来了glyph00001到底代表哪个字符glyph00002到底是数字几如果网站用的是固定字体库我们可以通过人工比对来确定。方法是把字体文件里的每个字形渲染成图片然后肉眼识别出对应的文字建立一张完整的映射表。先写一段代码把字体里的字形渲染出来from fontTools.ttLib import TTFont from PIL import Image, ImageDraw, ImageFont font TTFont(abc123.ttf) cmap font.getBestCmap() font_path abc123.ttf for code, name in cmap.items(): if code 0xE000: continue img Image.new(L, (60, 60), 255) draw ImageDraw.Draw(img) fnt ImageFont.truetype(font_path, 48) draw.text((5, 5), chr(code), fontfnt, fill0) img.save(fglyphs/{name}_{code}.png)这里有一个细节需要注意ImageFont.truetype能不能正确加载取决于你传入的字体路径是否有效。前面我把woff转成了ttf就是因为PIL对woff的兼容性不好直接用woff可能导致渲染失败或者字形错乱。转换之后的渲染结果每个字形会被保存成一张PNG图片你可以直接打开文件夹看数字、汉字、小数点一目了然。这一步更像是在做标注工作。假设渲染出了10张图片我们可以看到它们分别是0到9的数字那么映射关系就建立了mapping { 0xe001: 0, 0xe002: 1, 0xe003: 2, ... }有了这张表再去处理HTML源码就简单多了。把源码里私用区的字符替换成对应文字剩下带K、-这些正常字符的直接保留数据就恢复成“25K-35K”了。3. 完整实操还原被混淆的页面文本3.1 流程总览前面讲了原理和工具这里我整理一套我自己实际在用的完整流程。整个流程可以分为五步抓取页面、提取字体URL、下载字体文件、解析字体映射、替换文本。每一步都有对应的实现细节。先看一下流程图式的总览不用工具画图我直接列表说明爬虫请求目标页面拿到HTML源码。从HTML里的link或style标签中提取CSS内容再抽取font-face中的字体文件URL。请求字体文件保存成woff或ttf格式。用fontTools解析字体建立“私用区编码到真实文字”的映射表。遍历HTML源码中的文本节点把私用区字符替换成真实文字。这套流程适用于大多数静态字体反爬的网站。下面我把每一步的代码和操作细节都写出来。3.2 字体下载与解析代码实现先看第一步到第三步我用的是requests加正则的方式。拿我之前处理过的一个小说网站为例它的页面源码里有这样一段style font-face { font-family: reader-font; src: url(//cdn.example.com/fonts/font_20240101.woff) format(woff); } /style我的提取思路很简单先用requests拿到HTML再用正则把font-face块里的url(...)提取出来。正则表达式要写得宽松一点因为有些网站的CSS格式比较乱引号、空格、括号的写法都不一样。我最后用的是import re import requests html requests.get(https://example.com/book/12345, headersheaders).text font_urls re.findall(rurl\(\s*[\]?(.*?\.woff2?)[\]?\s*\), html) print(font_urls)这里有个坑正则里的.*?是非贪婪匹配遇到多个字体URL时能逐个提取。但如果页面是异步加载字体HTML源码里压根没有这个font-face那就需要换成requests去请求额外的CSS文件。我之前处理过一个网站它的HTML里只有一个link relstylesheet href//cdn.example.com/css/app.css字体URL都在这个CSS文件里面。所以需要先把CSS下载下来再在CSS文本里提取字体URL。下载完字体文件后就进入解析环节。我通常会把下载和解析封装在一个函数里方便批量处理from fontTools.ttLib import TTFont def download_and_parse_font(font_url, font_pathtemp_font.woff): resp requests.get(font_url, headersheaders) with open(font_path, wb) as f: f.write(resp.content) font TTFont(font_path) cmap font.getBestCmap() font.close() return cmap这里要注意一点resp.content保存的是二进制数据不要用resp.text来操作也别手动解码。曾经我在调试时因为多写了一行resp.encoding utf-8直接把字体文件搞坏了解析出来全是乱码。3.3 映射替换把乱码变回人话拿到cmap映射之后最核心的替换逻辑其实很简单。遍历文本的每一个字符检查它的Unicode码点是否在映射表中如果是就替换成真实文字否则原样保留。代码大概是这样的def restore_text(text, mapping): result [] for ch in text: code ord(ch) if code in mapping: result.append(mapping[code]) else: result.append(ch) return .join(result)mapping是字典键是私用区码点值是我们通过人工标注确定的真实字符。举个例子mapping { 0xe001: 2, 0xe002: 5, 0xe003: 3, } text 前端工程师 \uE0015K-\uE002\uE003K print(restore_text(text, mapping)) # 前端工程师 25K-35K看到没有原来显示为乱码的文本经过替换后变成了可读的“25K-35K”。这里我特意在文本里混入了正常的5K-替换函数能够正确保留这些字符。不过实际项目里往往不止一个字体文件。一个页面可能同时包含数字字体和汉字字体那么就要把多个字体的映射表合并成一个大的字典再统一替换。合并时要注意不同字体可能有相同的私用区码点如果出现冲突需要根据页面中字体实际作用的文本节点来区分。我之前处理房产网站时就遇到过这种情况价格用priceFont户型面积用areaFont两个字体里都有0xe001这个码点但对应的字符完全不同。这种场景就不能简单地合并映射表而是要回到HTML结构里去按照DOM节点的font-family来决定用哪张表来替换。我的解决办法是用BeautifulSoup遍历每一个文本节点先判断父节点的CSS样式再选择合适的映射表。3.4 一次真实的排查过程记录这部分我想分享一次完整的排查过程帮助你把前面的知识串起来。当时我在抓一个电影票房网站页面上的数据是这样的今日票房3024.6万上映天数12天场均人次45人但抓下来之后票房数据变成了“鈩發.驎万”数字全都不是正常的阿拉伯数字。我先用DevTools看了一下Network发现加载了一个boxoffice.woff文件。下载下来之后解析cmap表里全是0xE000开头的私用区编码。然后我用渲染脚本把字形渲染成了图片看到第一个字形是“3”第二个是“0”第三个是“2”第四个是“4”第五个是“.”第六个是“6”。于是建立了映射表mapping { 0xe001: 3, 0xe002: 0, 0xe003: 2, 0xe004: 4, 0xe005: ., 0xe006: 6, }接着再去抓页面源码发现票房数据的HTML是这样的p classbox-office\uE001\uE002\uE003\uE004\uE005\uE006万/p替换之后就变成了3024.6万。整个过程看起来很简单但有一个细节值得注意这家网站的字体文件是会定期更换的。今天下载的boxoffice.woff和明天的可能完全不同。也就是说你今天建立的映射表明天可能就失效了这就是下一节要说的动态字体问题。4. 动态字体与进阶思路4.1 为什么静态映射不顶用了静态字体反爬的缺陷在于一旦有人分析出字体文件的映射关系这个反爬就形同虚设了。所以很多网站做了升级每次请求页面时后端动态生成一个新的字体文件里面对同一个真实字符使用不同的私用区编码。例如今天的0xe001可能是“3”明天就变成了“7”后天可能是“0”。这就带来一个核心问题我们没法通过一次人工标注来建立永久有效的映射表。如果还是用老办法就得在每次采集时手动渲染字形图片用肉眼去识别每个码点对应的真实字符效率极低根本没法自动化。我遇到过一个比较极端的案例某金融网站每个小时刷新一次字体文件字体里的字形顺序完全随机连字形名称都是随机生成的。即使你这次解析出了映射表一个小时之后就作废了。这时候就必须换思路不能依赖编码映射了。4.2 用字形识别解决动态字体动态字体的关键特征是什么字符的编码在变但字形本身是相对稳定的。同样是数字“3”这个字形不管它在字体文件里叫glyph00001还是glyph00442它的轮廓坐标、笔画结构基本不变。所以我们可以绕开cmap表直接在“字形”层面做识别。具体思路分为三步渲染字形、生成特征、比对匹配。首先从字体文件中提取出所有字形渲染成固定大小的图片比如64x64的灰度图。然后为每张图片生成一个“指纹”实践中可以用感知哈希、直方图特征或者直接用深度学习模型提取向量。最后在待识别的字形图片库中和已知的真实字符图片库做相似度比对找到最接近的字符。我最早用的是一个笨办法把新字体的所有字形渲染成图片然后和旧字体里的字形图片逐一对比。这里可以用Python的PIL库配合imagehash库来计算图片感知哈希。感知哈希的核心逻辑是把图片缩小到8x8计算灰度平均值然后按像素和平均值的比较结果生成一串64位的二进制哈希值。两个图片越相似哈希值的汉明距离越小。pip install imagehash实现代码大致是import imagehash from PIL import Image def phash(img_path): img Image.open(img_path).convert(L).resize((64, 64)) return imagehash.phash(img) known_hashes {} for char, path in known_images.items(): known_hashes[char] phash(path) def match_char(img_path, known_hashes): target_hash phash(img_path) best_char None best_dist 100 for char, h in known_hashes.items(): dist target_hash - h if dist best_dist: best_dist dist best_char char return best_char, best_dist这段代码的思路很简单known_images里存放着我们已经确定字符含义的字形图片比如从旧字体里人工标注好的0到9、小数点、百分号等。当新字体文件出现时我们把它的字形也渲染成图片计算感知哈希再逐一和已知图片比对。汉明距离最小的那个就是最可能的真实字符。这种方案实测下来对数字和常见汉字效果都还不错。数字因为笔画简单、结构规整识别准确率很高汉字的笔画多但是只要渲染尺寸统一感知哈希还是有较强的区分能力。对于高频出现的汉字建议单独做一个标准字形库覆盖常用汉字而不是依赖每次人工标注。4.3 自动化构建字体样本库如果说动态字体的频率很高手动维护样本库就成了新的瓶颈。我自己的做法是在本地做一个自动化的流程每检测到一个新的字体文件就自动完成渲染、哈希、匹配、入库四步操作。这个流程可以用定时任务驱动也可以在爬虫运行时实时调用。先说说样本库是什么样的结构。我维护了一个目录里面按字符分类存放字形图片samples/ 0/ glyph_xxx.png glyph_yyy.png 1/ glyph_zzz.png ...每次遇到新的字体文件就把新字形图片放到一个待分类目录然后跑一遍和样本库的比对选出最相似的字符。如果比对结果的汉明距离小于某个阈值比如小于5就自动认为匹配成功把新图片转存到对应字符的目录里扩充样本库。这里有个小技巧渲染字形时要尽量统一图片尺寸和位置。如果字体文件的字形大小不一直接渲染会导致同一个数字“1”在不同字体里图片差异很大感知哈希算出来的距离反而不稳定。我通常会在渲染时做一次裁剪把字形对应的包围盒提取出来再等比缩放到固定画布的中央。fontTools里可以用font.getGlyphSet()拿到一个字形对象然后通过glyph.draw()配合一个自定义的Pen来获取轮廓坐标进一步计算包围盒。这个操作稍微复杂一点但能显著提升识别准确率。5. 常见问题速查与避坑经验5.1 高频问题排查表我把自己做字体反爬分析时碰到的问题整理成了一张速查表新手可以直接按图索骥。现象可能原因解决方案字体文件下载后无法解析woff2格式未解压安装brotli库或先用工具转成woff/ttfcmap表为空字体文件不完整检查下载的二进制数据不要用文本模式保存渲染出来的字形都是方块PIL不支持woff格式先用TTFont.save()转成ttf再渲染同一码点在不同页面对应不同字符动态字体不再依赖静态度映射改为字形识别方案页面里的数字部分是正常的部分是乱码网站只混淆了部分字体检查是否加载了多个font-face分别解析替换后出现多个乱码字符重叠一个真实字符拆成了多个字形需要结合字形组合规则处理glyph的替代序列字体文件URL每天变化CDN签名或动态路径解析CSS动态提取不要硬编码URL每个问题我都实际踩过。特别是“不完整字体文件导致cmap为空”这个坑有一次我下载的字体文件只有几KB大小打开一看是服务器返回的JSON错误信息压根不是真正的二进制字体。检查download的content-type和后缀名是排查这类问题的第一步。5.2 几点个人实操心得关于字体反爬这块做了一段时间后我也积累了几条属于自己体感特别深的经验分享给大家。第一遇到字形反爬先不要急着上模型。很多场景下网站虽然用了字体反爬但字体文件是定期变化的变化周期可能是一天甚至一周。你完全可以在变化周期内先做人工标注跑通全流程再去考虑自动化。直接上深度学习的方案会引入很多不必要的工程复杂度。第二善用浏览器的“编辑字体”功能。Chrome的开发者工具里可以覆盖指定的字体文件我经常在本地替换字体文件然后刷新页面观察哪些区域的文字发生了变化。这在定位字体作用范围时非常高效。比在HTML里一个个找节点快得多。第三渲染字形做人工标注时建议把图片命名带上码点。比如0xE003_2.png这样当你需要回头检查映射关系时一目了然。之前我图省事直接用glyph00003命名结果第二天查看时完全想不起这个glyph是什么字符又得重新渲染一遍白费功夫。第四字体文件的私用区编码范围要记住。Unicode私用区主要在0xE000到0xF8FF之间。如果看到码点落在这个区间基本可以确定是字体反爬的候选者可以重点检查。当然也有一些网站会把码点散落在非私用区比如直接使用标准数字码点但字形顺序错乱这种属于更极端的变体处理时要用不同的思路。第五处理动态字体时一定要保存历史字体文件。很多网站虽然每次会生成新字体但新字体的字形本质上是从一个有限的字体库中随机挑选组合出来的。保存足够多的历史字体后你会发现新字体里的字形大概率在历史样本中出现过建立样本库的意义就在这里。我一般会把每个抓到的字体文件按日期归档目录结构类似fonts/ 2025-01-01/ boxoffice_c0a1.woff 2025-01-01/ boxoffice_c0a2.woff存档不仅在排查问题时有用还可以用它们来构建更完整的字形匹配库减少对人工标注的依赖。字体反爬的分析思路从静态映射到动态字形识别其实是一步一步逼出来的。很多网站用这种方式保护数据本质上是在和爬虫工程师做一轮又一轮的攻防博弈。但话说回来任何技术对抗最终都会回归到合理的边界之内做字体反爬分析更多是为了理解浏览器渲染机制、字体文件格式这些底层知识。我在实际做采集项目时也会特别留意目标网站的robots协议和服务条款只在合规范围内做技术验证。毕竟把技术吃透是一回事怎么用得稳妥是另一回事。做完这个项目之后我对字体文件格式和Unicode编码体系的理解确实上了一个台阶也算是歪打正着的意外收获。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。