简介:针对晋江小说站爬取时遇到的二类字体加密难题,这份代码包给出了完整可运行的解决思路。与潇湘书院常见加密方式不同,晋江源码中的文字经过特殊处理,常规解析无法直接获取,但借助Selenium模拟真实浏览器渲染即可恢复正常显示;作者同时探索了构建字体映射关系的深层方案,为后续同类反爬研究提供参考。资源共5个文件,以Python爬虫脚本为主,辅以HTML测试页、TXT说明、Git忽略配置及环境配置清单,压缩包仅9KB,结构精简,适合有一定爬虫基础、正在处理动态字体加密或希望了解反爬绕过手段的开发者。已有203人学习下载,可用于快速排查同类问题、学习Selenium渲染策略与字体加密分析思路。
1. 晋江小说爬取撞上字体加密:拿到手全是乱码,问题出在哪
直接爬晋江小说正文,最常见的结果不是封 IP,而是抓回来的文本里混着一堆奇怪的字符——该显示汉字的地方变成方块、错字,或者干脆是空白。很多人第一反应是编码没处理好,改完charset和decode发现毫无变化,再检查才发现页面源码里文字本身就被加密了:页面里渲染的是自定义字体,浏览器用字体文件把编号映射成汉字,而爬虫拿到的只是编号序列。这就是字体加密(也叫字体反爬),小说站用它拦住批量采集,效果比限制请求频率直接得多。
这类加密的核心是动态生成的 WOFF 字体文件,字符的 Unicode 编号被重新映射过,同一个“晋”字在不同刷新周期里可能对应不同编号,静态映射表没有意义。本文要拆的就是这件事:怎么判断站点是否用了字体加密,怎么拿到字体文件,怎么解析出真实映射关系,以及怎么让爬虫在每次请求里自动完成解码。适合已经在写爬虫、遇到页面文本对不上号的开发者;新手也能按步骤跑通,但需要一点 HTML 和 Python 基础。特别提醒:本文只讨论技术原理和反爬机制,请勿用于批量下载受版权保护的正文内容,做技术研究请用公开测试页面或已授权数据。
2. 字体加密的底层逻辑:为什么抓回来的文本是错字
2.1 从 HTML 源码到浏览器渲染:字体文件在中间做了什么
普通网页里,<span>晋</span>这一段 HTML 源码里就是真实汉字,爬虫拿到源码就等于拿到了正文。但启用字体加密的页面,HTML 源码里变成<span></span>这类私有区编码,浏览器渲染时发现这个编号不在系统字体里,就会去下载页面指定的 WOFF 文件,查字体表后把对应字形画出来。也就是说,源码里的编号只是“钥匙”,字体文件里的字形映射才是“锁芯”。
判断一个页面是否用了字体反爬,不用急着写代码。先看两点:一是 HTML 里有没有<span>包裹的私有区字符(范围通常落在到之间),二是请求响应里有没有.woff或.woff2文件请求。用浏览器开发者工具打开网络面板,刷新一次页面,筛选font类型,如果看到woff文件且体积在几十 KB 以内,基本可以确认字体加密。另一个特征是每次刷新页面,同样位置的字对应的编号会变——这也是动态字体反爬和静态字体反爬最直观的区别:静态字体的映射长期不变,动态字体每次会话都可能不同。
2.2 动态字体加密的破解难点:映射关系每次刷新都失效
静态字体加密的破解思路很简单:下载一次字体文件,用工具解析出编号到汉字的映射表存起来,后面直接查表。但晋江这类站点用的是动态方案,字体文件每次会话都可能重新生成,同一编号在不同文件里对应不同字形。这意味着任何离线保存的映射表都有时效,请求频率低还好,请求间隔稍长一点,映射表就失效。
所以正确做法是“每次页面请求都重新拉取字体文件、重新构建映射表”。听起来增加了不少开销,但字体文件本身只有十几 KB,一次请求多几百毫秒,相对被反爬封禁来说完全可接受。另一个细节是字体文件里包含的字符集不一定是全量汉字,有时只覆盖当前页面出现过的字,所以解析映射表时不能假设所有汉字都在里面——没覆盖到的字在页面里可能以普通 Unicode 形式直接返回,这种情况就跳过字体映射,直接用原文。
2.3 字体加密为什么难破解:字体文件格式和坐标信息
破解字体加密的关键在于理解 WOFF 文件的内部结构。WOFF 是带压缩的字体容器,解压后核心是cmap表(字符到字形索引的映射)、glyf表(字形轮廓数据)等。大多数情况下,cmap表里编码和字形索引的关系是完整且直接的,用开源字体解析库就能读出来。但有些站点会在cmap表里做混淆,让编码指向错误的字形索引,这时候光读表不够,还得比对字形轮廓的坐标数据才能还原真实关系。
这就引出一条分层次的破解路线:第一步先用简单方式——解析cmap表拿映射;第二步若发现cmap表被混淆,就提取glyf表里每个字形的轮廓坐标,和正常字体做相似度比对。实现上不复杂,核心是找到同站点有正常返回的接口,把加密字符和真实文字对齐。这条路适用于大多数动态字体反爬站点,晋江小说页面的加密强度在中间水平——有动态特征,但还没到混淆glyf表那种强度。
3. 字体加密检测与动态映射构建:从 woff 文件到真实文本的完整链路
3.1 先做排查:写一个请求脚本确认字体加密
动手前先把目标页面的响应行为观察清楚。我一般会先写一个最小请求脚本,输出页面标题和正文片段,确认 HTML 源码里的实际编码形态。这个脚本不涉及任何破解逻辑,只是建立基线。
import requests from bs4 import BeautifulSoup url = "https://example.com/chapter/12345" # 替换为测试页 headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } resp = requests.get(url, headers=headers, timeout=10) resp.encoding = resp.apparent_encoding soup = BeautifulSoup(resp.text, "html.parser") # 正文容器换成目标页面实际的 class 或 id content = soup.select_one("div.read-content") if content: text = content.get_text() print("正文前 200 字:", text[:200]) # 检查是否包含私有区字符,判断是否存在字体加密 private_chars = [c for c in text if 0xe000 <= ord(c) <= 0xf8ff] print("私有区字符数量:", len(private_chars)) print("样例字符:", [hex(ord(c)) for c in private_chars[:10]])这段脚本先请求页面,提取正文后遍历所有字符的 Unicode 编号,落在0xe000到0xf8ff区间就判定为字体加密字符。如果私有区字符数量为 0,说明页面没做字体加密或者正文根本不在这个容器里。要注意resp.encoding不能依赖响应头里的声明,很多站点返回的是gzip压缩内容,requests会自动解压,但编码声明不一定准确,用apparent_encoding更稳。
参数说明:timeout=10是常用的请求超时设置,防止个别章节页面长时间无响应;select_one的选择器要跟着目标页面的实际结构调整,晋江正文区通常是带read-contentclass 的div,但同站点不同模板可能有差异。这条脚本如果报NoneType错,先打印resp.text[:500]看页面结构,别急着改选择器。
3.2 下载字体文件:从 HTML 里提取 woff 链接
确认字体加密后,下一步是拿到字体文件。字体文件的链接通常藏在<style>标签的@font-face规则里,或者以单独的<link>标签存在。有时代码里看不到完整链接,只有相对路径,需要和页面 URL 拼出完整地址。
import re import requests def extract_font_urls(html_text, base_url): """从 HTML 中提取所有 @font-face 里的 url()""" font_urls = [] # 匹配 @font-face 块,再从中提取 url(...) blocks = re.findall(r"@font-face\s*\{([^}]+)\}", html_text) for block in blocks: match = re.search(r"url\(['\"]?([^)'\"]+)['\"]?\)", block) if match: url = match.group(1) if url.startswith("//"): url = "https:" + url elif url.startswith("/"): url = base_url.rstrip("/") + url font_urls.append(url) return font_urls resp = requests.get(url, headers=headers, timeout=10) font_urls = extract_font_urls(resp.text, url) print("发现字体文件:", font_urls)这段代码用正则把 HTML 里的@font-face块全部抠出来,再从每个块里提取url(...)内容。//开头的链接是协议相对地址,要补上https:;/开头的是站点根路径,要和域名拼接。字体文件可能不止一个,有些页面会同时加载多个字体,全部下载下来再逐个解析。
正则在这场景下比解析库更高效,因为@font-face块大概率出现在<style>里,结构固定。提取完 URL 后,建议先打印出来人工确认一下:如果提取结果里混入了base64开头的data:URI,说明字体被内联在 CSS 里了,这种情况不需要额外请求,直接从base64解码就能拿字体。对应代码块:
import base64 if font_urls[0].startswith("data:font/woff"): # data:font/woff;base64,XXXX 形式内联字体 b64_data = font_urls[0].split(",", 1)[1] font_bytes = base64.b64decode(b64_data) open("target.woff", "wb").write(font_bytes) else: font_resp = requests.get(font_urls[0], headers=headers, timeout=10) open("target.woff", "wb").write(font_resp.content)判断标准很简单:url(...)里的内容以data:开头就直接解码,否则发起一次新的请求下载。注意下载字体文件要带上和页面请求相同的User-Agent,部分站点会校验来源,不带 UA 会被拒。
3.3 解析 woff 文件:用 fontTools 提取编码到字形的映射
拿到字体文件后,用fontTools解析cmap表。fontTools是 Python 生态里最成熟的字体解析库,能读 WOFF、WOFF2、TTF 格式。安装命令:pip install fonttools。下面这段代码把字体文件里所有编码到字形名称的映射打印出来:
from fontTools.ttLib import TTFont font = TTFont("target.woff") cmap = font.getBestCmap() print("映射数量:", len(cmap)) # cmap 表是 {unicode_int: glyph_name} 结构 for code, glyph_name in list(cmap.items())[:20]: print(f"U+{code:04X} -> {glyph_name}")getBestCmap()会返回字体文件里最完整的cmap子表,键是 Unicode 编码的十进制整数,值是字形名称。正常情况下,字形名称类似glyph00001这种无意义编号,但如果你发现字形名称里带了英文单词或拼音,那这个字体可能是半成品混淆——能直接读出线索。更多时候你能看到的是一个编码对应一个独立字形,但字形名称里没有任何汉字信息,这才需要下一步的映射还原。
参数说明:getBestCmap()返回的映射表里,编码可能是私有区内的任意值;同一字体文件里可能会有多个cmap子表,但getBestCmap()选取的是最适合渲染的子表,通常就是我们要的那一份。如果cmap表为空或者只有几个条目,说明字体文件可能是空壳,真正的映射藏在其他表里,此时需要用font.getGlyphOrder()查看字形总数。
3.4 构建动态映射表:把字形图片转成像素,和已知字符比对
字体加密的最后一公里是把字形对应到真实汉字。在不能访问原始映射接口的前提下,通用方案是“图像识别比对”:把要破解的字体里每个字形渲染成小图片,同时准备一份已知字形的参考字体,逐个比对相似度,找出真实字符。这在纯 Python 里用PIL加上字体文件自己的渲染能力就能实现。
from PIL import Image, ImageDraw, ImageFont import io def glyph_to_image(font_path, char_code, img_size=64): """将指定字符编码渲染成灰度图""" font = ImageFont.truetype(font_path, img_size - 16) img = Image.new("L", (img_size, img_size), 255) draw = ImageDraw.Draw(img) draw.text((8, 0), chr(char_code), font=font, fill=0) return img def img_similarity(img1, img2): """简单像素差异比对,返回差异比例""" if img1.size != img2.size: img2 = img2.resize(img1.size) diff = 0 total = img1.size[0] * img1.size[1] for x in range(img1.size[0]): for y in range(img1.size[1]): if img1.getpixel((x, y)) != img2.getpixel((x, y)): diff += 1 return diff / total # 渲染加密字体中的某个编码,和常见汉字的字体渲染结果做对比 crack_font = "target.woff" enc_code = 0xe602 # 从页面源码里取到的私有区编码 for char in "的一是在不了有和人这中大为上个国我以要他": ref_img = glyph_to_image(crack_font, ord(char)) enc_img = glyph_to_image(crack_font, enc_code) diff = img_similarity(ref_img, enc_img) if diff < 0.2: # 相似度高即匹配 print(f"编码 U+{enc_code:04X} 对应字符: {char}") break这里的思路是:同一个字体文件既能渲染加密编码,也能渲染普通汉字——前提是字体文件里包含这些普通汉字的字形。渲染出来后逐像素比对,相似度超过阈值就认为匹配成功。glyph_to_image里的(8, 0)是绘制文字的起始坐标,img_size - 16是字号大小,这两个参数决定字形在图片中的位置和大小,改字体或渲染方式时要注意保持两个图片的参数一致,否则比对会失真。
阈值0.2表示允许像素差异不高于 20%,实际情况中同一字体渲染同一个字,像素差异基本是 0,但不同字形的差异一般会超过 60%,所以阈值设在 20% 到 30% 之间是安全的。上述做法的问题在于:每次要遍历候选字符集,效率一般。实际项目里会把每个加密字符一次性渲染成图,然后和全部参考字符比对,生成映射表后缓存为字典变量,再统一替换正文文本。
3.5 整合到爬虫流程:每次请求动态拉取字体、修正正文
动态字体必须每次请求都走一遍“拉字体、建映射、替换文本”的流程。把前面几步串起来,一个完整的解码流程是:请求页面 → 提取正文容器 → 找字体 URL → 下载字体 → 解析映射 → 替换正文里的私有区字符 → 输出干净文本。这里要单独处理映射表构建失败的场景——字体文件下载失败时,页面源码按原样返回,至少不会中断整篇采集。
def decode_text(content_text, font_path): """把正文里所有私有区字符替换成真实汉字""" font = TTFont(font_path) cmap = font.getBestCmap() # 这里用 3.4 节的图像比对逻辑构建映射字典 # 简化起见,假设 mapping 是 {私有区编码: 真实字符} mapping = build_mapping(cmap, font_path) # 3.4 节的逻辑封装 result_chars = [] for ch in content_text: code = ord(ch) if 0xe000 <= code <= 0xf8ff and code in mapping: result_chars.append(mapping[code]) else: result_chars.append(ch) return "".join(result_chars)这段整合代码里,build_mapping封装了 3.4 节渲染比对的核心逻辑,输入cmap表和字体文件路径,输出加密编码到真实汉字的字典。替换时只处理私有区范围内的字符,其它字符原样保留,避免误伤正常文本。整个流程里,最耗时的不是网络请求而是图像比对,几十个字符循环渲染比对耗时可能在几百毫秒到一两秒之间,但比触发反爬后浪费时间处理封禁划算得多。
性能优化上,可以缓存“参考字符的渲染图”:第一批候选汉字渲染一次,之后所有加密字符都用同一批参考图比对,不要为每个加密字符重新渲染参考图。候选字符集也不用覆盖全部汉字,正文里出现的高频字就那几百个,用“的一是在不了有和人这中大为上个国我以要他”这类高频字列表做初筛,能覆盖大多数场景;未匹配上的字符再扩大候选集或者标记为未知,等人工复核。
4. 字体反爬的 5 个常见坑:从 cmap 为空到字体文件失效
4.1 字体下载失败:提示 404 或被 403 拦截
现象:请求字体 URL 时返回 403,或者直接 404。
原因:大部分情况下是请求头不完整或链接提取错误。403 通常是服务端检查了Referer和User-Agent,Referer必须指向目标页面的 URL;404 则是字体链接是动态签名的,同一签名只能用一次,页面请求和字体请求之间隔太久导致签名过期。
解决:字体请求必须在页面请求之后的几秒内完成,我一般会直接复用页面的requests.Session对象,保持 cookie 和头部信息一致:
session = requests.Session() session.headers.update(headers) page_resp = session.get(url, timeout=10) # 后续字体请求都用同一个 session font_resp = session.get(font_url, timeout=10)这样会话内的 cookie 会自动延续,也能避免重复设置请求头。检查点:先打印font_resp.status_code确认状态码,再打印响应内容前 100 字节,确认是不是<!DOCTYPE开头的 HTML,如果是说明字体 URL 被重定向到了登录页或验证页。
4.2 cmap 表解析出来是空的,或者映射数量极少
现象:getBestCmap()返回的字典只有几个条目,甚至为空,但字体文件本身有内容(体积正常)。
原因:WOFF 字体内部可能存在多个cmap子表,getBestCmap()选错了表;更极端的情况是站点故意把字符编码信息移除,只留下字形数据。
解决:先检查字体文件的字形总数和cmap子表数量:
font = TTFont("target.woff") print("字形总数:", len(font.getGlyphOrder())) print("cmap 表数量:", len(font["cmap"].tables)) for table in font["cmap"].tables: print("platformID:", table.platformID, "platEncID:", table.platEncID) print("映射条目数:", len(table.cmap))如果字形总数正常(几百到几千)但cmap表条目数偏少,说明字体用特殊工具生成,真正的映射可能在cmap之外的结构里。常见做法是直接用字形索引顺序和页面实际显示顺序做对齐:把页面里私有区字符按出现顺序排列,再按字形索引顺序排列,二者通常顺序一致,可以直接建立顺序映射。这个方法对动态字体也有效,因为页面渲染时是严格按文本顺序取出字形渲染的。
4.3 映射表构建正确,但替换后仍有少量错字
现象:展开正文后大部分字正确,个别字是错的,且错字分布没有规律。
原因:字体加密可能包含多个字体文件,正文的不同部分用了不同字体;或者同一个编码在页面里映射了不同字符,这种情况一般是字体文件里存在重复编码。
解决:把页面里用到的所有字体文件全部下载下来,每个字体单独建映射表,然后按标签范围替换。注意:页面里不同@font-face声明可能对应不同font-family,正文的每个段落或每个标签会指定使用哪个字体。实现时先按文本所在标签确认对应字体,再做替换。错字数量少时也可以用人工核对后硬编码修正——把错误记录加到自定义字典里,但这种方法只适合小范围补漏,不能系统性解决。
4.4 字体文件有效,但图像比对匹配率低
现象:相似度比对时,正确字符的差异也超过 30%,导致匹配失败。
原因:渲染参数不一致。glyph_to_image里字体大小、起始坐标、图片尺寸变动,同一个字渲染效果就不同;另外字体文件里某些字形本身有细微差异,和参考字符渲染结果对不上。
解决:保持字体文件路径一致、渲染参数完全一致,把字号、坐标、图片大小都定义为常量。比对前把两张图都做二值化处理(只保留纯黑和纯白),忽略灰度差异。如果字体本身有抗锯齿渲染效果,二值化能大幅提高比对的稳定性。另一个技巧是用“非零像素数量差”做粗筛,先过滤掉明显不匹配的候选,再逐个做像素比对,能省不少时间。
4.5 同一章节多次请求,字体文件不同导致映射混乱
现象:短时间请求同一页面两次,解析出的映射字典不同,手动核对时发现页面文本变化。
原因:这是动态字体的正常特征——每次请求都会生成新的字体文件,映射关系随之改变。有人会尝试保存多份映射表做合并,但这没有意义,因为下一轮请求又会变。
解决:接受“每次请求都拉一次字体并解析”的成本。在代码上优化解析速度比尝试缓存更有效:用TTFont(font_path, lazy=True)延迟加载只解析cmap表,渲染比对时只处理页面实际出现的私有区字符,不做全量字形渲染。如果单次页面请求间隔较长(比如每章间隔 5 秒),多数站点不会封禁,这个方案能稳定跑完整个流程。
5. 动态字体加密的边界与通用化:跳过图像比对的快速识别法
5.1 能不能跳过图像比对直接拿到映射?
图像比对是兜底方案,但不是最优解。部分站点的动态字体生成规律可预测:字体文件里字形名称带序列号,编码也是按顺序递增。这种情况可以直线提取映射——前提是字形名称里包含可读的字符信息。用font.getGlyphOrder()看一下字形名称是否有规律,有的字体会把字形命名成uni4E00这种 Unicode 编码形式,直接就能还原。
font = TTFont("target.woff") glyph_order = font.getGlyphOrder() for name in glyph_order[:30]: if name.startswith("uni"): code = int(name[3:], 16) print(f"字形 {name} -> U+{code:04X}")如果字形名称不是uni前缀,而是glyph00001这类无意义名称,才需要走图像比对。字形名称是从字体生成工具里带出来的信息,站点开发时通常不会刻意混淆这一层——他们防的是自动化读取,不是防专家逆向。我处理过的大多数字体反爬里,有五成概率字形名称直接可读,剩下才需要图像比对。
5.2 图像比对的可复用工具函数
图像比对逻辑要单独封装成可复用模块,方便后续适配其它站点。一个可靠的封装包含三个部分:字体渲染、二值化、相似度计算。前面 3.4 节的实现是最简版本,实际可以再优化一步——用横纵投影特征替代全像素比对,速度和稳定性更好。
def glyph_feature_vector(font_path, char_code, img_size=64): """提取字形图片的投影特征,用于快速比对""" img = glyph_to_image(font_path, char_code, img_size) pixels = img.load() # 计算行投影和列投影,用 64 维向量描述字形 row_features = [] for y in range(img_size): count = 0 for x in range(img_size): if pixels[x, y] == 0: count += 1 row_features.append(count) col_features = [] for x in range(img_size): count = 0 for y in range(img_size): if pixels[x, y] == 0: count += 1 col_features.append(count) return row_features + col_features def feature_distance(feat1, feat2): """两个特征向量的欧氏距离""" return sum(abs(a - b) for a, b in zip(feat1, feat2)) / len(feat1)投影特征本质上是把字形图片压缩成 128 维向量(64 行投影 + 64 列投影),只描述字形在横竖方向的密度分布,忽略细节。这样比对时间从逐像素的数千次计算降到一百多次,速度提升十倍以上。选距离阈值时先渲染两对已知相同和已知不同的字符,记录阈值作为标定值。
这个函数可以留在工程里做多站点通用工具:后续遇到新的字体加密站点,直接导入glyph_to_image、feature_distance就能快速验证是否可解,不用从零写渲染逻辑。
5.3 站点结构调整后的适配思路
字体加密方案会随站点改版而变,适配时先看三处:页面里@font-face是否存在、字体 URL 是否还指向 woff 文件、正文容器 class 是否改名。每次采集任务开始前,先跑一遍 3.1 节的检测脚本,输出页面摘要信息,和上次的基线对比,变化明显就先手工访问页面确认改了什么。
如果页面把 woff 换成 woff2,fontTools需要额外依赖brotli才能解压,提前装好pip install brotli。如果字体包被拆成多个 woff 文件,先打印全部字体 URL 并逐个解析,不要只处理第一个。如果站点升级为“图片+字体”双重反爬,图像比对会失效,这种场景要考虑 OCR 方案,但那是另一套工程了——字体加密本身的技术边界就在这里,适配原则是在每次改版后重新跑检测脚本,而不是凭经验猜。
另外要留心加密强度与反爬策略的连带关系:动态字体加密多数时候只是第一道关卡,后面还挂着访问频率限制和用户行为检测。即便字体解密全部做对,请求频率过高依然会被封。工程上要给每次请求加随机延时,章节页间隔控制在 3 到 8 秒之间,并且把请求失败的处理设计成“退避重试”——连续失败 3 次就停止任务,等一段时间再继续。这套组合做法跑起来后,字体解密失败、请求被限流、页面结构变化三类问题基本都能定位到具体环节了。
我早期开发某文本采集方向的模拟项目 X 时就犯过这类错误:第一次跑通字体解析后,把映射表写死到代码里,第二天原样重新跑任务,结果全部乱码——忘掉了动态字体每次刷新都会换映射这个基本前提。后来每次会话都重新解析字体文件,问题才消失。这个教训让我养成一个习惯:凡是涉及动态字体的爬虫,一律写“先拉字体再解析正文”的流程,任何缓存优化都不能省掉这一步。希望这些思路能帮你在处理字体加密站点时少绕几次弯路。
本文还有配套的精品资源,点击获取