资讯动态

爬虫遇字体加密怎么办:WOFF映射解析与动态反爬破解实战

发布时间:2026/10/14 6:24:10 来源:尧图企业网站定制
简介针对晋江小说站爬取时遇到的二类字体加密难题这份代码包给出了完整可运行的解决思路。与潇湘书院常见加密方式不同晋江源码中的文字经过特殊处理常规解析无法直接获取但借助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#xe602;/span这类私有区编码浏览器渲染时发现这个编号不在系统字体里就会去下载页面指定的 WOFF 文件查字体表后把对应字形画出来。也就是说源码里的编号只是“钥匙”字体文件里的字形映射才是“锁芯”。判断一个页面是否用了字体反爬不用急着写代码。先看两点一是 HTML 里有没有span包裹的私有区字符范围通常落在#xe000;到#xf8ff;之间二是请求响应里有没有.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, headersheaders, timeout10) 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更稳。参数说明timeout10是常用的请求超时设置防止个别章节页面长时间无响应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(rfont-face\s*\{([^}])\}, html_text) for block in blocks: match re.search(rurl\([\]?([^)\])[\]?\), 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, headersheaders, timeout10) 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], headersheaders, timeout10) 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(fU{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_size64): 将指定字符编码渲染成灰度图 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), fontfont, fill0) 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-AgentReferer必须指向目标页面的 URL404 则是字体链接是动态签名的同一签名只能用一次页面请求和字体请求之间隔太久导致签名过期。解决字体请求必须在页面请求之后的几秒内完成我一般会直接复用页面的requests.Session对象保持 cookie 和头部信息一致session requests.Session() session.headers.update(headers) page_resp session.get(url, timeout10) # 后续字体请求都用同一个 session font_resp session.get(font_url, timeout10)这样会话内的 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, lazyTrue)延迟加载只解析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_size64): 提取字形图片的投影特征用于快速比对 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 换成 woff2fontTools需要额外依赖brotli才能解压提前装好pip install brotli。如果字体包被拆成多个 woff 文件先打印全部字体 URL 并逐个解析不要只处理第一个。如果站点升级为“图片字体”双重反爬图像比对会失效这种场景要考虑 OCR 方案但那是另一套工程了——字体加密本身的技术边界就在这里适配原则是在每次改版后重新跑检测脚本而不是凭经验猜。另外要留心加密强度与反爬策略的连带关系动态字体加密多数时候只是第一道关卡后面还挂着访问频率限制和用户行为检测。即便字体解密全部做对请求频率过高依然会被封。工程上要给每次请求加随机延时章节页间隔控制在 3 到 8 秒之间并且把请求失败的处理设计成“退避重试”——连续失败 3 次就停止任务等一段时间再继续。这套组合做法跑起来后字体解密失败、请求被限流、页面结构变化三类问题基本都能定位到具体环节了。我早期开发某文本采集方向的模拟项目 X 时就犯过这类错误第一次跑通字体解析后把映射表写死到代码里第二天原样重新跑任务结果全部乱码——忘掉了动态字体每次刷新都会换映射这个基本前提。后来每次会话都重新解析字体文件问题才消失。这个教训让我养成一个习惯凡是涉及动态字体的爬虫一律写“先拉字体再解析正文”的流程任何缓存优化都不能省掉这一步。希望这些思路能帮你在处理字体加密站点时少绕几次弯路。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑