
1. 项目概述当文字变成“乱码”最近在分析一个小说网站的数据时遇到了一个挺有意思的“老朋友”——字体反爬。简单来说你在浏览器里看到的小说正文是清晰可读的但当你用常规的爬虫工具去请求页面、解析HTML时得到的却是一堆乱码或者莫名其妙的符号。这背后的核心就是网站通过自定义字体文件将真实的文字映射成了另一套“密码”从而阻止了数据的直接抓取。这种技术本质上是一种“视觉欺骗”。对于用户而言浏览器加载了对应的字体文件能正确渲染出文字而对于没有加载和处理该字体的程序比如简单的requestsBeautifulSoup组合看到的只是原始的、无意义的字符编码。这就像网站给内容上了一把只有特定“钥匙”字体文件才能打开的锁。处理这类反爬关键在于找到这把“钥匙”并理解它的“开锁原理”——即字体文件中的字符映射关系。这个项目不仅适用于小说网站许多票务、电商、甚至是一些内容社区为了保护核心数据如价格、手机号、地址都可能采用类似的字体反爬技术。因此掌握这套破解流程算是爬虫工程师进阶路上必须点亮的技能之一。2. 核心原理与常见套路拆解要破解字体反爬首先得明白它到底是怎么运作的。其技术核心主要围绕Web字体Web Fonts和CSS层叠样式表展开。2.1 字体反爬的基本实现方式目前主流的字体反爬实现可以归纳为以下几种套路静态字体文件替换这是最基础的形式。网页中会链接一个或多个自定义的字体文件通常是.woff或.woff2格式。HTML中的文字使用特殊的Unicode码点例如,这类位于“私人使用区”的字符来表示。这些码点在标准字体下显示为乱码或方框但当浏览器加载了网站提供的自定义字体后该字体文件内部定义了这些特殊码点对应哪些真实字形比如“九”、“的”、“一”于是页面就正常显示了。爬虫如果只下载HTML而不下载并解析字体文件得到的就是一堆这样的字符。动态字体映射为了增加难度网站每次访问或每次刷新页面时提供的字体文件可能不同。也就是说字符在这次访问中可能映射到“九”下次访问就映射到“的”了。但是字体文件内部的映射关系glyf id 到 字形轮廓和CSS中定义的class名称到Unicode码点的映射这两者之间的对应关系往往是固定的或者存在某种可循的规律。破解的关键在于找到这个“二次映射”的规则。CSS Sprite 字体偏移这是一种组合技。页面上的数字或文字并不是直接以文本形式存在而是通过CSS背景图雪碧图来展示。同时配合使用字体文件来定义每个字符在雪碧图中的精确位置通过background-position属性。爬虫需要同时处理图片识别和字体坐标解析难度更大。伪元素:before, :after内容插入这是CSS层面的干扰。关键的文字内容并不直接写在HTML标签里而是通过CSS的content属性动态插入。例如span classsecret123/span.secret:before { content: 实; font-family: CustomFont; }爬虫解析HTML只能得到“123”真正的“实”字藏在CSS里并且可能还依赖自定义字体渲染。对于我们这次遇到的小说网站经过初步探查它主要采用了“动态字体文件 CSS class映射”的组合方式这也是目前非常流行且具有代表性的反爬策略。2.2 技术栈关联CSS、JS与Class从你提供的热搜词可以看到css,js,class是高频词汇它们在这场攻防战中扮演着关键角色CSS是字体反爬的“指挥中心”。它主要做两件事定义字体通过font-face规则引入自定义的字体文件并为其命名如font-family: ‘my-font’;。应用样式通过类选择器.class将特定的字体应用到HTML元素上。更重要的是它常常定义字符映射。例如.char1:before { content: \e001; font-family: my-font; } .char2:before { content: \e002; font-family: my-font; }这里.char1这个class被赋予了Unicode码点\e001的内容。这个码点\e001就是字体文件中某个字形的“地址”。Class是HTML元素与CSS样式之间的“接头暗号”。页面中的关键文字已被混淆会被包裹在带有特定class的标签内例如i classchar1/i。爬虫需要解析这些class并通过CSS找到其对应的Unicode码点。JS通常是动态化的“助推器”。它可能负责动态生成或修改CSS样式表。根据某种算法动态计算并插入content的值或class的名称。对字体文件进行异步加载或解密比如将字体文件进行Base64编码后内嵌在JS中运行时解码。执行反调试操作增加直接分析页面源码的难度。因此我们的破解思路可以概括为抓取页面 - 解析CSS找到font-face和class映射规则 - 下载字体文件 - 解析字体文件得到Unicode码点到字形轮廓的映射 - 将字形轮廓与我们已知的真实字符进行匹配 - 最终建立“HTML中的class - 真实字符”的映射关系表。3. 实战破解一步步拆解小说网站下面我们以一个虚构但典型的小说网站为例演示完整的破解流程。假设目标网址是https://novel-example.com/chapter/123。3.1 第一步页面结构与初步分析首先使用浏览器推荐Chrome的开发者工具F12查看网页源码。查看明文与乱码在Elements面板找到小说正文的容器。你可能会看到类似这样的结构div idcontent p。/p pspan classspecial/span。/p /div正文完全是一堆无法直接识别的Unicode字符。同时注意观察是否有特殊的i,span标签包裹着某些字符并带有规律性的class如i classfzxx/i。查找字体定义在Elements面板搜索font-face。通常会在style标签内或链接的CSS文件中找到类似代码font-face { font-family: secret-font; src: url(//novel-example.com/fonts/secret.woff2?t123456) format(woff2); font-display: swap; }这里我们得到了关键信息字体家族名称为‘secret-font’以及字体文件的真实URL可能需要补全为https:。查找CSS映射规则继续在style标签或Network面板下载的CSS文件中搜索.fzxx或你之前看到的特殊class名以及content属性。你可能会找到.fzxx { font-family: secret-font !important; } .char-A:before { content: \e900; } .char-B:before { content: \e901; } /* ... 可能有很多这样的规则 ... */这告诉我们classfzxx的元素会使用我们的自定义字体。而.char-A这个class则通过伪元素插入了Unicode码点\e900。页面上可能直接使用了即\e900的HTML实体这样的字符然后通过font-family: ‘secret-font’来渲染。注意有些网站会将映射关系放在一个独立的JS文件中通过JavaScript动态地生成上述CSS规则。此时需要在Network面板查看JS文件搜索content、font-face或字体的Base64数据。3.2 第二步获取并解析字体文件下载字体文件从font-face的src属性中获得字体文件的URL使用爬虫如Python的requests库将其下载到本地保存为secret.woff2。解析字体映射关系我们需要知道字体文件中每个字形Glyph对应的Unicode码点是什么。这里使用Python的fontTools库。pip install fontToolsfrom fontTools.ttLib import TTFont # 加载字体文件如果是woff2fontTools也能处理 font TTFont(secret.woff2) # 获取 cmap 表它是Unicode到字形索引的映射 cmap font.getBestCmap() print(cmap)输出可能类似于{61952: 1, 61953: 2, 61954: 3, ...}。这表示Unicode码点61952即十六进制0xF200注意\e900的十进制就是59648这里需要看实际值对应字形索引Glyph ID为1。实操心得getBestCmap()返回的字典其键Unicode码点就是我们在CSS中看到的content值如\e900值Glyph ID是字体内部用来标识具体形状的编号。这个映射关系在同一份字体文件中是固定的。提取字形轮廓并可视化字体反爬的“动态”之处往往在于每次请求字形索引Glyph ID和真实字符的对应关系会变但字形本身的形状轮廓不变。我们需要将每个Glyph ID对应的形状提取出来以便与标准字符进行匹配。# 获取字形轮廓数据保存为XML以便查看或者转换为图片 font.saveXML(secret_font.xml) # 生成一个巨大的XML文件包含所有轮廓数据更实用的方法是将每个字形画出来保存为图片。from fontTools.pens.recordingPen import RecordingPen from fontTools.pens.transformPen import TransformPen from fontTools.misc.transform import Identity import matplotlib.pyplot as plt import matplotlib.patches as patches from matplotlib.path import Path def plot_glyph(font, glyph_name, ax): 将单个字形画到matplotlib的axes上 glyph font.getGlyphSet()[glyph_name] pen RecordingPen() glyph.draw(pen) for cmd, *args in pen.value: # 简化处理这里只绘制轮廓实际匹配可能需要更精确的特征提取 # 可以将轮廓坐标提取出来用于后续比对 pass # ... 具体绘图代码略可根据轮廓坐标生成Path并绘制 ... # 示例获取前几个字形的名称并绘制 glyph_order font.getGlyphOrder() # 获取所有字形名称如 [‘.notdef’, ‘glyph00001’, ‘glyph00002’, ...] fig, axes plt.subplots(2, 5, figsize(15, 6)) for i, (ax, glyph_name) in enumerate(zip(axes.flat, glyph_order[1:11])): # 跳过第一个.notdef plot_glyph(font, glyph_name, ax) ax.set_title(fID:{i1}) ax.axis(off) plt.show()3.3 第三步建立映射关系表这是最核心的一步。我们需要知道字体文件中的每个字形Glyph ID到底代表哪个真实汉字。寻找基准映射手动/半自动方法A手动对照在网页上找到一段包含数字或常见字如“的”、“一”、“是”、“了”的句子。同时从字体解析结果中找到出现频率最高的几个字形。通过人眼比对形状建立最初的几个“锚点”。例如你发现Glyph ID为5的形状很像“的”字Glyph ID为10的形状很像“一”字。方法B利用已知字符集如果网站字体只用于加密数字0-9那就简单了。找一个页面上肯定包含所有数字的地方如章节页码截取这些字形图片与标准数字字体如Arial的0-9进行图像识别或轮廓特征比对。自动化匹配思路轮廓坐标比对提取每个Glyph ID的轮廓坐标序列将其视为一个特征向量。同时准备一个标准字库如宋体中你关心的汉字如3500常用字的轮廓坐标。计算目标字形与标准字库中每个字形的轮廓相似度如计算点集之间的Hausdorff距离或Frechet距离取最相似的那个作为匹配结果。机器学习/深度学习将每个字形渲染成固定大小如64x64的二值化图片当作一个图像分类问题。需要预先准备标注好的训练数据即一批已知对应关系的字形图片和标签训练一个分类模型如CNN。对于新字体用模型预测每个字形图片的类别。这种方法泛化能力强但需要一定的数据准备和模型训练成本。注意事项自动化匹配的准确性取决于特征提取或模型的质量。对于形状差异较大的字体如艺术字匹配难度会增大。在实际项目中“基准映射规则推导”的组合往往更高效。例如发现本次字体中ID为5的字形是“的”ID为10是“一”而上次抓取的字体中ID为5是“九”ID为10是“的”。通过多次采样你可能会发现一个固定的“字形顺序表”只是每次请求时这个表与Glyph ID的对应关系发生了旋转或置换。构建最终映射字典假设我们通过分析得到了如下映射示例CSS Class.char-A- Unicode\e900(十进制 59648)字体文件中 Unicode59648- Glyph ID1通过比对 Glyph ID1的形状对应汉字 “龙”那么最终映射就是在本次请求中遇到CSS类为char-A或Unicode字符就应翻译为“龙”。我们需要为本次会话构建一个翻译字典# 假设我们通过分析得到的映射 unicode_to_char { 59648: 龙, # \e900 59649: 的, # \e901 59650: 一, # \e902 # ... } # 或者如果页面直接使用Unicode字符而非class我们可以直接替换 def decrypt_text(encrypted_text): for code, char in unicode_to_char.items(): encrypted_text encrypted_text.replace(chr(code), char) return encrypted_text # 如果页面使用class则需要先通过CSS规则将class转换为Unicode再用上述字典翻译3.4 第四步整合到爬虫流程将上述步骤模块化集成到你的爬虫代码中。import requests from parsel import Selector # 一个集成了XPath和CSS选择器的好库 from fontTools.ttLib import TTFont from io import BytesIO import re class NovelFontAntiSpider: def __init__(self): self.session requests.Session() self.session.headers.update({User-Agent: 你的浏览器UA}) # 本次会话的映射缓存 self.font_map_cache {} # 格式{字体文件URL: {unicode: char}} def get_page(self, url): 获取页面并自动处理字体反爬 resp self.session.get(url) sel Selector(resp.text) # 1. 提取字体URL和CSS映射 font_url, css_rules self._extract_font_info(sel) # 2. 获取或构建本次的字体映射字典 if font_url not in self.font_map_cache: font_map self._parse_font_file(font_url) self.font_map_cache[font_url] font_map else: font_map self.font_map_cache[font_url] # 3. 解密正文 encrypted_text sel.css(#content ::text).getall() encrypted_text .join(encrypted_text) decrypted_text self._decrypt_with_map(encrypted_text, font_map, css_rules) return decrypted_text def _extract_font_info(self, selector): 从HTML或CSS中提取字体URL和class-Unicode映射 # 查找 font-face # 这里简化处理实际可能需要解析style标签或链接的CSS文件 font_url None css_rules {} # 示例从内联style中提取 style_text selector.xpath(//style[contains(text(), font-face)]/text()).get() if style_text: # 使用正则匹配字体URL url_match re.search(rsrc:\s*url\([\]?(.*?\.woff2?)[\]?\), style_text) if url_match: font_url url_match.group(1) if font_url.startswith(//): font_url https: font_url # 匹配 class 到 unicode 的规则 (例如 .char-A:before{content:\e900;}) rule_matches re.findall(r\.([^{]):before\s*{[^}]*content:\s*[\]\\([0-9a-f])[\], style_text, re.I) for class_name, uni_hex in rule_matches: css_rules[class_name] int(uni_hex, 16) # 十六进制转十进制 return font_url, css_rules def _parse_font_file(self, font_url): 下载并解析字体文件返回unicode到真实字符的映射 resp self.session.get(font_url) font_data BytesIO(resp.content) font TTFont(font_data) # 获取基础unicode到glyph id的映射 base_cmap font.getBestCmap() # {unicode: glyph_id} # 这里是关键需要将glyph_id映射到真实字符。 # 假设我们通过某种方法如图形识别、基准对照已经得到了一个映射表 # 这个表可能需要针对不同字体动态生成。这里用一个假定的函数代替。 glyph_id_to_char self._analyze_glyphs(font) # 返回 {glyph_id: 真} # 合并映射unicode - char unicode_to_char {} for uni_code, gly_id in base_cmap.items(): if gly_id in glyph_id_to_char: unicode_to_char[uni_code] glyph_id_to_char[gly_id] else: unicode_to_char[uni_code] ? # 未知字符占位 return unicode_to_char def _analyze_glyphs(self, font): 分析字形建立glyph_id到真实字符的映射。 这是最难的部分需要根据实际情况实现。 示例假设我们已知本次字体中前10个glyph对应‘零一二三四五六七八九’ # 这里只是一个静态示例。真实情况需要动态分析。 # 可能的方法1. 与本地标准字体库比较轮廓 2. 使用预训练的OCR模型 3. 人工标注少量后推断 known_mapping { 1: 零, 2: 一, 3: 二, 4: 三, 5: 四, 6: 五, 7: 六, 8: 七, 9: 八, 10: 九, # ... 继续添加其他字符的映射 } # 注意这个known_mapping极有可能是每次请求都变化的 # 所以我们需要找到变化的规律或者每次重新分析。 # 一种常见情况是字形顺序固定但unicode编码的起点偏移了。 # 我们可以通过寻找一两个“基准字”如“的”、“一”来校准。 return known_mapping def _decrypt_with_map(self, text, font_map, css_rules): 使用映射字典解密文本 decrypted text # 首先如果文本中有class需要根据css_rules将class替换为unicode字符 # 这里简化假设文本已经是unicode乱码字符 for uni_code, char in font_map.items(): decrypted decrypted.replace(chr(uni_code), char) return decrypted # 使用示例 spider NovelFontAntiSpider() content spider.get_page(https://novel-example.com/chapter/123) print(content)4. 进阶挑战与应对策略在实际对抗中网站方会不断升级反爬策略。下面是一些进阶情况及应对思路。4.1 动态字体与多文件切换现象每次请求字体文件的URL或内容发生变化导致之前的映射表失效。应对实时解析每次爬取页面时都重新执行上述“获取字体-解析映射”的流程不依赖缓存。虽然开销大但最稳妥。寻找不变特征字体文件虽然变但字形轮廓可能只是顺序被打乱。我们可以提取所有字形的轮廓特征如轮廓点集的哈希值、形状描述子建立一个“特征指纹库”。当新字体到来时将每个字形与指纹库匹配从而识别出它实际是哪个字而不关心它本次的Glyph ID或Unicode是什么。关联密钥有时字体文件的URL或文件名中包含一个版本号或哈希值如font_abc123.woff2这个值可能在页面HTML或某个JS变量中也能找到。建立这个关联后可以先检查本地是否有该哈希值对应的映射缓存避免重复分析。4.2 CSS混淆与JS动态生成现象CSS中的class名称和content值变得无规律甚至由JS在运行时动态计算生成。应对完整执行JS环境使用Selenium、Playwright或Pyppeteer等浏览器自动化工具让页面JS完全执行再获取渲染后的DOM。然后直接提取渲染后的文本内容这是“降维打击”但资源消耗大、速度慢。逆向JS逻辑分析负责生成CSS或class的JS代码。可能class名是通过一个固定数组随机排序生成的content的Unicode码点是通过一个固定偏移量计算出来的。找到这个种子或算法就能在爬虫中复现映射关系。Hook关键函数在浏览器开发者工具的Console中可以Hook住String.fromCharCode、element.style.setProperty、或者创建style标签的函数观察其调用参数从而动态捕获映射关系。4.3 字体文件加密或自定义格式现象字体文件不是标准的.woff/.woff2或者文件内容被额外加密。应对查找解密逻辑加密必然伴随解密。解密逻辑一定在前端JS中。使用浏览器调试工具在字体文件被加载Network面板后搜索ArrayBuffer、Uint8Array、decrypt、decode等关键词找到将二进制数据转换为可用字体数据的函数。模拟解密过程将JS解密代码翻译成Python例如使用execjs库调用JavaScript引擎或手动实现其算法。先用爬虫下载加密的字体二进制数据再用同样的逻辑解密最后交给fontTools解析。4.4 综合防御与频率限制现象除了字体反爬还结合了IP限制、请求头校验、验证码、行为验证如鼠标轨迹等。应对字体反爬只是其中一环。需要建立完整的爬虫工程体系代理IP池应对IP封锁。请求头管理模拟真实浏览器包括User-Agent、Accept-Encoding、Referer等。Cookie/Session管理处理登录状态和会话。请求间隔与随机化模拟人类阅读速度避免高频请求。验证码处理对接打码平台或训练识别模型。5. 调试技巧与问题排查实录在实战中你肯定会遇到各种预料之外的问题。这里记录几个常见的坑和排查思路。问题1fontTools无法打开下载的字体文件报TTLibError。可能原因字体文件下载不完整或被服务器返回了错误页面如403、404。排查检查下载的字体文件大小是否过小如只有几KB。用十六进制编辑器如hexdump -C font.woff2 | head查看文件头部woff2文件应以wOF2开头。如果看到html或{“error”:...说明下载的不是字体。检查请求字体文件的URL是否正确是否需要特定的Referer或Cookie。用浏览器开发者工具的Network面板复制字体请求的cURL命令在爬虫中模拟相同的请求头。问题2成功解析了字体但替换后文本还是乱码或不对。可能原因1映射关系没找全或找错。这是最常见的问题。排查在浏览器中选中一个乱码字符在Elements面板查看其计算的样式。确认它最终应用的font-family和content如果是伪元素是否正确。确认你解析的字体文件正是这个font-family对应的。验证写一个简单的测试用你的映射字典去解密页面标题或某个已知短句如“下一页”看结果是否正确。可能原因2存在多重编码或嵌套。有些网站会进行两层甚至三层混淆。排查解密一次后观察结果是否变成了另一组有规律的字符。可能是第一层字体映射后得到的“明文”又经过了第二套字体或简单的替换密码如凯撒密码加密。需要逐层剥离。可能原因3CSS中使用background-position配合雪碧图。字符根本不是通过字体渲染而是背景图片。排查查看疑似乱码的元素其CSS属性是否有background-image和background-position。如果是则需要转向图片识别和坐标解析。问题3字体映射关系每次都在变无法稳定抓取。可能原因字体文件是动态生成的且没有可循的简单规律如固定顺序旋转。排查与应对批量采样连续请求10-20个页面保存每次的字体文件和对应的页面明文可以通过浏览器手动复制一小段或者用OCR截屏识别一小段作为“真值”。对比分析对比多次采样的字体文件。使用fontTools的ttx工具将字体转为XML比较cmap表和glyf表。看是cmapUnicode映射变了还是glyf字形轮廓本身变了还是都变了。寻找“锚点”即使都在变通常高频字“的”、“一”、“是”的形状相对稳定。用图像识别的方法在每次的新字体中自动识别出这几个“锚点”字然后用它们来校准整个映射表。考虑终极方案如果对方变化极其复杂且无规律评估使用无头浏览器如Playwright直接获取渲染后文本的成本是否可接受。有时绕过比破解更经济。问题4页面加载后文字先是乱码过一会儿才变成正常。可能原因字体文件是异步加载的font-display: swap或者映射关系由JS在DOMContentLoaded事件后动态计算并插入。排查在开发者工具的Network面板勾选Disable cache刷新页面观察字体文件和关键JS文件的加载时序。在Elements面板监听DOM树的变化看是否在某个时刻突然增加了style标签或修改了元素的class。应对对于异步字体爬虫需要等待字体加载完成。可以简单等待一个固定时间如2秒或者检测特定元素字体样式的变化。对于JS动态生成要么使用无头浏览器要么逆向JS逻辑。处理字体反爬是一个典型的“道高一尺魔高一丈”的过程。核心思路始终不变找到字体文件解析映射关系建立翻译字典。但随着对抗升级每一步的细节都变得复杂。保持耐心善用浏览器开发者工具这个最强大的“雷达”从网络请求、源码、样式、控制台等多个维度交叉分析总能找到突破口。