☰
Python破解拼多多网页字体加密:静态字体反爬解密实战
2026/10/2 18:22:09 网站建设 项目流程

最近有朋友在抓取拼多多商品数据时遇到一个怪现象:用Python直接抓回来的价格字段全是乱码,页面里明明显示"¥129.00",代码里却是一堆奇怪的字符。查了半天才发现,问题出在一套自定义字体上——这就是前端工程师常说的字体加密。服务端把真实字符藏进一套字体文件,页面渲染时用特殊unicode码位映射到真实字形,浏览器能正常显示,但程序直接读文本内容读到的是另一套"鬼画符"。这篇文章我就把用Python破解拼多多网页字体加密(静态版)的完整思路和可运行脚本摊开讲清楚,适合正在做爬虫、研究反爬机制、或者对前端加密解密感兴趣的读者。

我要先说清楚一个结论:静态字体加密的本质并不复杂,说白了就是"字体文件里藏着一张对照表",你要做的只是把这张表读出来,再拿它去翻译网页里的乱码。真正的坑不在加密本身,而在定位字体文件、处理子集化、映射字形名这几个环节。下面我按自己的实战排查顺序来写。

1. 字体加密的底细:电商反爬为什么偏爱这一招

1.1 一个页面里的"乱码"数字引发的探索

先描述一下现象。打开拼多多某个商品的详情页,用浏览器开发者工具看元素,价格区域明明显示"¥25.90",但切换到Network面板里看HTML源码,你会发现价格部分变成了类似这样的实体字符,甚至直接是一串unicode码。普通暴力爬虫拿到这串字符,直接存进数据库就是错的。

这其实是网站前端的一种自我保护手段。字体加密的基本逻辑是:后端在返回HTML时,把真实数字替换成一套自定义unicode码位(比如把数字"0"映射到\ued23),同时通过@font-face引入一个字体文件,在这个字体文件里,\ued23这个码位对应绘制出来的字形恰好是数字"0"的图形。浏览器加载字体后,按照码位去字体文件里找对应的glyph,渲染出来就看到正常的数字;但爬虫如果只解析纯文本,拿到的只是码位,自然就是乱码。

我当时第一次遇到这玩意儿也觉得挺玄乎,后来拆开字体文件才发现,加密的"强度"完全取决于映射关系的隐蔽程度。如果映射关系是写死的、固定的,所有用户访问都返回同一套字体文件,那这就是静态字体加密——破解难度很低,解密一次就能用在所有页面上。如果每次请求或每个页面都生成一套不同的字体,字符码位和真实数字的对应关系都不一样,那就是动态字体加密,破解难度直线上升。

1.2 静态字体与动态字体的本质差异

静态字体加密在早期电商网站里很常见,实现也简单:设计一套自定义字体,里面重新定义了数字0-9以及少数符号(比如小数点、人民币符号)的unicode码位,然后把字体文件放在CDN上固定返回。对开发团队来说,这是个成本极低的前端保护方案,能拦住一批只懂requests.get().text的初级爬虫。

动态字体加密则要狠得多:每次请求时,后端动态生成一个woff文件,把同一批数字映射到不同的unicode码位上。比如第一次请求数字"123"对应\ue001 \ue002 \ue003,第二次请求又变成\ue104 \ue105 \ue106。这意味着就算你解开了第一次的字体文件,下次请求依然失效。破解动态字体加密需要实时下载字体、实时解析、实时替换,对爬虫的请求频率和解析速度都是挑战。

拼多多目前的情况比较混合,但很多页面尤其是价格字段还没有做到彻底动态化,部分场景下仍然是静态或半静态的字体加密。这就是本文标题里"静态"两个字的由来——我们只针对固定字体映射的场景做破解,但整个方法论搞清楚之后,再往动态方案扩展就有基础了。

2. 抽丝剥茧:从HTML里锁定字体文件与加密字段

2.1 字体文件怎么藏:@font-face 与资源路径

破解的第一步,不是急着下载字体,而是先在网页里找到字体文件的地址。用浏览器打开目标商品页,按F12打开开发者工具,切到Elements面板,在样式区域搜索@font-face。你会看到类似这样的定义:

@font-face { font-family: 'PDD_iconfont'; src: url('//xxx.pinduoduo.com/xxx/xxx.woff') format('woff'); font-weight: normal; font-style: normal; }

注意这个url()里的路径,就是我们要找的字体文件。有的网站会把字体地址放在外链CSS文件里,也有直接写在HTML<style>标签中的。稳妥的做法是直接搜索页面HTML源码中的.woff或.ttf关键字,通常都能定位到。

如果页面里有多个@font-face,不要慌,先全部记下来。有些是图标字体(iconfont),里面的glyph是各种小图标,跟数字加密无关;有些才是真正的数字加密字体。怎么分辨?两种方法:一是看字体文件大小,数字加密字体通常只有几KB到几十KB,因为只包含少量数字glyph;二是用字体编辑器或fontTools打开,看里面是否只包含数字、小数点、人民币符号这几个形状。

我在实践中还发现一个规律:加密字体文件的命名经常是一串随机字符,比如t3g8f2s1.woff,而图标字体会叫iconfont.woff之类。所以看到随机命名的woff文件,优先级最高。

2.2 肉眼识别加密文本的几个信号

拿到HTML源码后,怎么快速判断哪些字段被加密了?看两个信号:

第一,源码中价格、销量、评价数等数字区域出现形如&#xe8a3;的实体编码,或者直接显示为\ue8a3这种PUA(Private Use Area,私用区)码位。PUA码位通常是\uE000-\uF8FF这个区间,一般的正经文本不会用这块区域。

第二,复制页面上的价格文本,粘贴到文本编辑器里,看到的不是"129",而是"�"或者方格。比如页面显示"¥25.90",但复制出来是"¥�兀"之类,基本可以断定是自定义字体捣的鬼。

下面用一个示例说明。假设HTML里有这样一段:

<span class="price">&#xe8ab;&#xe8a1;&#xe8a4;&#xe8a7;</span>

这4个实体编码对应4个unicode字符,根据字体文件的cmap表,它们可能依次代表"1"、"2"、"9"、"."。浏览器渲染出来就是"129.",而程序直接抓HTML只能抓到这4个实体。

那么这个"从unicode到真实字符"的对应关系,就是我们要从字体文件里挖出来的核心情报。

3. 拆解字体文件:利用fontTools还原字符映射

3.1 环境准备:安装fontTools和requests

在开始写代码之前,先把环境搭好。Python环境不用多说,3.8以上就行。需要装两个库:requests用于下载字体文件,fontTools用于解析字体内部结构。

pip install requests fonttools

fontTools是Google开源的字体处理库,功能非常强大,既能读取字体文件,也能编辑、生成字体文件。我们用到的核心模块是fontTools.ttLib、fontTools.ttLib.tables._c_m_a_p这些。不需要额外装别的,注意fontTools是纯Python库,安装很快。

小提示:如果你用的是Windows系统,建议把Python脚本的文件名起得简单点,别用中文名,免得遇到编码问题。字体文件下载之后统一放在脚本同目录下,方便调试。

3.2 下载字体文件并解析cmap表

字体文件的核心数据结构之一是cmap表,它定义了"unicode码位→字形ID(glyph ID)"的映射关系。对于静态字体加密,我们要的不只是字形ID,还需要知道每个字形ID对应的"字形名称"以及这个字形长什么样。

先看一个基础代码块:

import requests from fontTools.ttLib import TTFont font_url = "https://example.com/font/abc123.woff" resp = requests.get(font_url, timeout=10) with open("tmp.woff", "wb") as f: f.write(resp.content) font = TTFont("tmp.woff") cmap = font.getBestCmap() print(cmap)

getBestCmap()返回的是一个字典,键是unicode码位(int类型),值是字形名(str)。比如:

{0xe8ab: 'glyph00001', 0xe8a1: 'glyph00002', ...}

注意,这里的字形名通常是自动生成的,比如glyph00001,看不出任何含义。真正的字形轮廓信息存放在glyf表中(TrueType字体)或CFF表中(PostScript字体)。字体看起来是数字"1"还是"2",要看glyph的轮廓坐标。

3.3 建立"字形名→真实字符"映射表的两种思路

这是整个解密过程中最核心的一步。我们已经拿到"unicode→字形名"映射,但字形名是自动生成的,怎么知道某个字形名对应的实际图形是"0"还是"1"?有三种常见做法。

做法一:人工对照法(适合静态字体,只做一次)

用fontTools把字体文件渲染成图片,或者用FontCreator这类工具打开字体文件,直接看每一个glyph的形状,然后手工记录。比如看到glyph00001的形状是数字"0",就手工建立glyph00001 → '0'。这种做法最笨,但对静态字体来说最可靠。因为静态字体的映射关系是固定的,你只需要做一次人工对照,之后所有页面都能用同一张表。

做法二:已知明文法(适合有参考场景)

如果你已经知道页面上某个数字的实际值(比如页面上明码标价写了"官方补贴 ¥12.90",而源码中的加密字符是4个),你可以把源码中的unicode码位和真实字符一一对上。比如拿iOS或Android端的接口返回值做对照,然后把映射持久化。这个方法在工程上很常用。

做法三:字形相似度法(适合自动化,静态动态通用)

不依赖已知明文,直接把字体文件里的每个字形都渲染成小图片,用一个已知的"0-9"字体图片库做相似度对比,把最相似的字符作为映射结果。这个方法实现复杂度高一些,但一劳永逸。后续如果要应对动态字体,基本都要往这个方向走。

对本文的静态方案,我推荐做法一或做法二。你已经能确定它是静态字体,一次性人工解码的成本很低,没必要花大力气搞图像识别。

3.4 多子集字体时的合并策略

有些网站会把一套完整的加密字体拆成多个子集文件,每个子集只包含部分字符,再通过多个@font-face加载。拼多多有些页面对数字字体做了分层处理,不同位置用不同字体文件,这就需要在解析时把所有字体文件都下载下来,依次解析cmap,把多个映射字典合并成一个总映射表。

合并时注意一个问题:不同字体文件中,同一个unicode码位可能映射到不同形状,也可能不同字体文件的码位范围是错开的。合并逻辑很简单,循环下载、循环解析、用dict.update()合并即可。遇到码位冲突,可以打印出来人工确认。

4. 完整静态解密脚本:从HTML输入到明文输出

4.1 主流程设计

现在把上面的思路串成一个可运行的脚本。主流程一共五步:

  1. 请求目标页面,拿到HTML文本;
  2. 从HTML中提取所有字体文件URL,下载并解析cmap;
  3. 通过字形形状/人工对照,建立Unicode码位 → 真实字符的映射表;
  4. 在HTML中用正则替换所有加密后的实体编码;
  5. 输出解密后的明文文本。

这里要特别注意静态字体脚本的定位:脚本是本地的"一次性"工具,不是service。所以代码可以写简单点,不要过度设计。但代码结构要清晰,方便你后续改成动态版。

4.2 关键代码实现

下面我给出一个完整的可运行版本。注意:为了脱敏,示例URL我写成占位符,你实际使用时替换成真实页面URL即可。

import re import requests from fontTools.ttLib import TTFont HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Referer": "https://mobile.yangkeduo.com/", } def download_font(font_url, save_path="tmp.woff"): """下载字体文件到本地""" resp = requests.get(font_url, headers=HEADERS, timeout=10) if resp.status_code != 200: raise RuntimeError(f"download font failed: {resp.status_code}") with open(save_path, "wb") as f: f.write(resp.content) return save_path def parse_cmap(font_path): """解析字体文件,返回 {unicode_int: glyph_name}""" font = TTFont(font_path) cmap = font.getBestCmap() return cmap def build_char_map(font_path, known_char_map=None): """ 建立 unicode码位 -> 真实字符 的映射 known_char_map: 可选,人工对照结果 {glyph_name: real_char} """ cmap = parse_cmap(font_path) char_map = {} for code_point, glyph_name in cmap.items(): # 根据 glyph_name 查真实字符,这里需要人工提供对照表 real_char = known_char_map.get(glyph_name, None) if real_char: char_map[code_point] = real_char return char_map def extract_font_urls(html): """从HTML中提取woff/ttf字体文件URL""" # 匹配 @font-face 或 link 里的 woff/ttf 地址 pattern = re.compile(r"url\(['\"]?(.*?\.(?:woff2?|ttf))['\"]?\)", re.I) urls = pattern.findall(html) return urls def replace_encrypted_chars(html, char_map): """把HTML中的实体编码或 \uXXXX 替换为真实字符""" def repl(match): hex_val = match.group(1) code_point = int(hex_val, 16) return char_map.get(code_point, match.group(0)) # 匹配 &#xe8ab; 或 &#xE8AB; html = re.sub(r"&#x([0-9a-fA-F]+);", repl, html) # 匹配 \ue8ab (json字符串等场景) html = re.sub(r"\\u([0-9a-fA-F]{4})", repl, html) return html if __name__ == "__main__": # 1. 获取HTML page_url = "https://example.com/item.html" html = requests.get(page_url, headers=HEADERS, timeout=10).text # 2. 提取字体文件并解析 font_urls = extract_font_urls(html) print("font urls:", font_urls) # 这里填写人工对照结果,例如: # known_map = {"glyph00001": "0", "glyph00002": "1", ...} known_map = {} total_char_map = {} for url in font_urls: if not url.startswith("http"): url = "https:" + url font_file = download_font(url) char_map = build_char_map(font_file, known_map) total_char_map.update(char_map) # 3. 替换加密字符 plain_html = replace_encrypted_chars(html, total_char_map) # 4. 用BeautifulSoup或正则提取价格等字段 prices = re.findall(r'<span class="price">(.*?)</span>', plain_html) print(prices)

4.3 实测效果与验证方法

这段脚本的验证逻辑你务必要重视。解完字体之后,别急着跑大批量任务,先拿一两个页面做交叉验证。

验证步骤很简单:打开浏览器,肉眼确认页面价格;再用脚本跑同一页面,看输出的结果和肉眼是否一致。如果一致,说明映射表没问题;如果不一致,多半是人工对照表填错了,或者字体没有全部下载完。

我踩过的一个坑是getBestCmap()返回的键是int类型,而HTML里的&#x...;正则提取出来的是16进制字符串,转换的时候千万记得int(hex_val, 16),别转成10进制了。另外,有些字体文件是woff2压缩格式,fontTools可以读,但需要额外确保brotli库已安装。

pip install brotli

不然解析woff2会报错。

5. 静态解密方案的天花板与下一步思考

5.1 一旦字体动态化,这套方案为何失效

静态方案看起来挺爽:一次手工对照,终生使用。但它的适用前提非常苛刻——字体文件里的码位映射必须长期不变。实际上,很多网站早就发现了静态字体的弱点,只要爬虫方把固定映射表一发布,反爬方就能通过动态化把这张表作废。

动态化通常有两种实现方式。第一种是每次请求动态生成woff文件,文件里的glyph数量、字形名、码位映射每次都不同,但字形本身依然是0-9那十个数字。第二种是在字体文件中加入大量无用干扰字形,把真实数字混在一堆"假字形"里,让静态表直接失效。

一旦遇到这种动态字体,之前手工对照建立映射表的方式就完全不可行了。因为你无法对每一次请求都人工确认"哪个字形是哪个数字"。这时候就需要把人工对照这一步升级为自动识别。

5.2 从静态到动态:一套更通用的识别框架

动态字体解密的通用路径,大概率是"渲染+图像识别"。

具体思路是:拿到woff文件后,不关心glyph名称,直接利用fontTools的TTGlyphPen或fontTools.pens.basePen把每个glyph的轮廓坐标提取出来,然后用PIL或者cairo把轮廓渲染成小图,再和一套用系统字体渲染的"0-9"参考图做像素级比对。找到相似度最高的参考图,就得到该glyph对应的真实字符。

这个方案比手工对照复杂,但有一个关键好处:它不依赖字体的固定性。每次请求都可以实时解析、实时识别,管你是静态还是动态,通吃。

不过我得提醒一句,图像识别的稳定性需要大量测试。尤其是字体渲染时的抗锯齿、坐标原点差异、缩放比例都会影响相似度。你需要先把字形轮廓标准化到统一的尺寸和位置,再计算相似度。最简单的做法是直接用fontTools.pens.boundsPen拿到字形包围盒,然后等比例缩放到固定画布。

5.3 爬虫工程师的边界:合规与克制

最后这段必须说,不是套话。字体加密本质上是网站为了保护数据不被批量采集而设计的机制。我们研究它的目的是为了提升自己的技术能力,或者在你的业务获得授权的前提下做合法数据采集。如果只是出于商业竞争、未经授权去大量抓取他人平台的经营数据,风险非常高,既可能违反相关法律法规,也可能违反平台服务协议。

我个人的看法是:解密字体加密作为学习反爬的练手项目,非常有价值;但一旦牵扯到规模化采集和商业用途,务必先和法务确认边界,优先使用官方API,或者取得平台授权。技术可以没有立场,但技术人得有自己的判断力。

在实际操作中,我通常会在脚本里加上请求频率控制、随机延时、弱化日志里的用户隐私字段,并且永远不会把解密能力封装成公开服务。这些习惯,比破解本身重要得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询