背景
今年年初我启动了 JobBot 项目——一个自动化的招聘信息采集与投递辅助系统。项目的初期目标很简单:定时抓取 Boss 直聘上目标岗位的薪资、技能要求和职位描述,做数据分析和岗位匹配。然而在实际开发中,第一个遇到的障碍并不是 API 签名校验或 IP 频率限制,而是一个看起来更「底层」的问题——页面上显示的是乱码。
打开 Chrome DevTools 查看元素,薪资字段显示的是 这样的 Unicode 私有使用区字符。普通爬虫用 requests 库拿到的 HTML 里,这些字段就是一堆不可读的 PUA 编码,根本无法从中提取有效数据。这就是 Boss 直聘采用的 PUA 字体编码反爬机制。
首次发现该问题时,我以为是编码设置错了。试了 UTF-8、GBK、Latin-1 —— 全都不行。直到在 Network 面板里看到字体文件请求 boss.woff,才意识到这不是编码问题,而是字体层面的加密。
什么是 PUA 字体编码
PUA(Private Use Areas,私有使用区)是 Unicode 标准中保留给自定义字符的码位范围。主要的几个区间是:
- U+E000 – U+F8FF(BMP 私有使用区,6,400 个码位)
- U+F0000 – U+FFFFD(第一补充私有使用区)
- U+100000 – U+10FFFD(第二补充私有使用区)
字体厂商可以用这些码位来存放自定义图标(如 FontAwesome 的图标字符)。但 Boss 直聘的做法是:把数字 0123456789 和常用单位字符(如 K、万、· 等)映射到 PUA 区域的码位上,然后通过自定义字体文件(WOFF)将 PUA 码位渲染为对应的数字图形。这样用户看到的是正常的数字,但浏览器 DOM 和爬虫拿到的是乱码。
举个例子:U+E001 可能对应数字 "1",U+E002 对应 "2",U+E00A 对应 "K"。
页面上的K看起来是 "12K" 但 DOM 里全是乱码。
这种方案的精妙之处在于:
- 不依赖 JavaScript——字体是 CSS 层面渲染的,即使是服务端渲染的页面也得用字体才能正确显示
- 字符映射可以动态变化——每次请求返回的字体文件中,PUA 码位到实际字符的映射可能是不同的
- 对浏览器完全透明——用户看到的是正常内容,完全无感知
解密思路
破解的关键在于建立 PUA 编码到真实字符的映射关系。整体的解密流程分为三个步骤:
1. 下载自定义字体文件
打开 Boss 直聘的职位详情页,在 Network 面板中过滤 .woff 请求,可以看到页面加载了一个自定义字体文件。URL 通常是这样的:
# 字体文件 URL 示例 https://img.bosszhipin.com/static/static/font/"xxxxx".woff
现代反爬方案可能使用 WOFF2 格式,并且 URL 中带有时间戳或一次性 Token 防止缓存。使用 Camoufox 浏览器渲染页面时,字体文件会自动加载,我们可以通过拦截响应来获取字体文件内容。
2. 解析字体映射表
下载到 WOFF 文件后,我们需要解析字体内部的 CMAP 表(Character-to-Glyph Index Mapping Table)。CMAP 表记录了从 Unicode 码位到字形索引的映射关系。这里要注意:PUA 码位映射到的字形,是 Boss 直聘自定义绘制的,而不是标准的数字字形。
使用 fonttools 库(Python)可以轻松读取 WOFF 字体文件:
from fontTools.ttLib import TTFont import requests # 下载字体文件 font_url = "https://img.bosszhipin.com/static/static/font/xxxxx.woff" resp = requests.get(font_url, headers=headers) with open("boss.woff", "wb") as f: f.write(resp.content) # 解析 CMAP 表 font = TTFont("boss.woff") cmap = font.getBestCmap() print(cmap) # 输出:{0xe001: 1, 0xe002: 2, 0xe003: 3, ...}
这里 cmap 的输出中,key 是 PUA 码位的十进制值,value 是字形索引。但是字形索引 并不直接等于真实字符!字形索引只是字体内部的一个编号。要得到真实字符,需要将字体渲染为图像,然后用 OCR 识别每个字形对应的数字——或者更巧妙的方法:利用已知的「参考字符」做一一映射。
一个更高效的方案是:从字体中提取每个字形的轮廓(Glyph)数据,通过比对字形轮廓的哈希值来确定它对应哪个数字。因为数字 0-9 的字形轮廓是固定的,不同字体文件只是码位映射不同,但同一数字的字形轮廓是相同的。
from fontTools.pens.t2Pen import T2Pen import hashlib glyph_hash_map = {} for cmap_code, glyph_name in cmap.items(): glyph = font.getGlyphSet()[glyph_name] # 提取轮廓数据并计算哈希 pen = T2Pen(glyph) glyph.draw(pen) h = hashlib.md5(str(pen.points).encode()).hexdigest() glyph_hash_map[cmap_code] = h # 与已知数字轮廓哈希对照表匹配 known_digit_hashes = load_known_digit_hashes() mapping = {} for code, h in glyph_hash_map.items(): if h in known_digit_hashes: mapping[chr(code)] = known_digit_hashes[h]
3. DOM 层面替换加密文本
拿到 PUA → 真实字符的映射表后,在浏览器环境中遍历 DOM 树,找到包含加密文本的节点,进行替换:
// 在页面中执行替换 function decryptText(node, mapping) { if (node.nodeType === Node.TEXT_NODE) { let text = node.textContent; let decrypted = ""; for (let char of text) { decrypted += mapping[char] || char; } if (decrypted !== text) { node.textContent = decrypted; } } node.childNodes.forEach(child => decryptText(child, mapping)); }
这套方案在 JobBot 中经过两个月的实际运行验证,解密成功率达到 99.5% 以上。少数失败的 case 是字体文件加载超时或映射表变化导致临时未命中。
Camoufox 反检测方案
字体解密只是第一步。Boss 直聘的反爬体系远不止字体编码。在实际运行中,还需要对抗浏览器指纹检测、IP 频率限制、Cookie 合法性校验等多层防护。我们使用了基于 Camoufox 的自动化浏览器方案:
WebGL / Canvas / 字体指纹模拟
Camoufox 是一个专门的反指纹检测浏览器,它通过以下方式来模拟真实浏览器环境:
- WebGL 指纹模拟:替换 WebGL 渲染器的 vendor/renderer 信息,使用真实设备的 GPU 参数,避免被检测为 headless 浏览器
- Canvas 指纹随机化:Canvas 图像渲染结果在不同浏览器中会有微小的像素级差异,Camoufox 可以模拟这些差异
- 字体指纹模拟:浏览器可用的字体列表也是一个重要的指纹特征,headless 浏览器的字体列表往往比真实用户少得多
Cookie 持久化与会话恢复
Boss 直聘会对会话进行滑动窗口校验。如果检测到某个会话的访问频率异常,或者 Cookie 中缺少某些关键的校验字段(如 __zp_seo、__lr 等),就会弹出滑块验证码。
我们的方案是:
- 首次启动时手动完成验证码,保存完整的 Cookie 存档
- 后续启动自动加载 Cookie,模拟正常用户的会话续期行为
- Cookie 过期前主动刷新(通过访问首页刷新过期时间)
滑动窗口频率控制
即使有最完美的指纹模拟,请求频率过高依然会被封杀。我们实现了一个 自适应频率控制器:
class RateController: def __init__(self): self.base_interval = 5 # 基础间隔(秒) self.window_size = 60 # 滑动窗口(秒) self.max_requests = 10 # 窗口内最大请求数 self.timestamps = [] def wait_if_needed(self): now = time.time() # 清理过期的时间戳 self.timestamps = [t for t in self.timestamps if now - t < self.window_size] if len(self.timestamps) >= self.max_requests: sleep_time = self.timestamps[0] + self.window_size - now if sleep_time > 0: time.sleep(sleep_time) self.timestamps.append(time.time())
这套控制器不仅限制了请求速率,还加入了随机抖动(±20% 的间隔波动)和节假日感知功能——在工作日的非工作时间段自动降低频率,在周末使用更慢的速率,模拟真实用户的浏览行为。
💡 经验之谈:反爬对抗中最容易被忽略的是「行为模式」层面的检测。大多数网站的反爬系统已经不再是简单的 IP 频率限制,而是通过机器学习模型分析用户的行为序列。如果你的程序访问页面总是按照固定顺序、固定间隔,即使指纹伪装得再好,也很容易被判定为爬虫。
字体编码反爬的演进与防御思路
研究过程中,我注意到 Boss 直聘的字体编码方案也在不断迭代:
- V1 阶段:固定映射表,同一个字体文件可以被复用,破解后永久生效
- V2 阶段:每次请求动态生成字体文件,PUA 码位映射每次都变,必须实时解析
- V3 阶段:在字体中加入大量「噪声字形」——这些字形在页面中并未使用,但被包含在字体文件中,增加解析难度
- V4 阶段(推测):结合 CSS 子集化 + 字体分段加载,部分敏感数据使用 Canvas 渲染而非字体渲染
针对这些演进,我们的解密方案也需要持续升级。目前 V3 阶段的噪声字形可以通过「交叉引用未在 DOM 中出现的码位」来过滤——页面中实际使用的 PUA 码位是有限的,不在 DOM 中的码位大概率是噪声。
总结
字体编码反爬是目前较为高级且隐蔽的反爬手段之一。它不同于传统的 IP 封禁、User-Agent 校验或 JS 混淆,而是在浏览器最基础的渲染环节——字体层——进行加密。这使得大多数基于 HTTP 库的爬虫完全无法读取数据。
破解这一机制的核心思路是:
- 理解 PUA 字体编码的原理——将数字字符映射到 Unicode 私有使用区
- 通过字体解析工具(fonttools)建立 PUA 码位到真实字符的映射
- 结合自动化浏览器(Camoufox)在 DOM 层面实时替换加密文本
- 辅以完整的反指纹检测策略,确保浏览器环境不被识别
最终,JobBot 的 Boss 直聘适配模块运行稳定,每天定时抓取目标岗位数据,解密成功率维持在 99% 以上。这一整套方案不仅适用于 Boss 直聘,对其他使用字体编码反爬的网站(如部分招聘平台、电商平台的价格加密等)也有参考价值。
当然,反爬技术是一把双刃剑。作为开发者,在对抗反爬机制的同时,我们也应当遵守网站的 robots.txt 相关规则,控制合理的数据采集频率,不要对目标服务造成压力。技术无罪,但使用技术的方式决定了它的价值。
● fonttools 官方文档:https://github.com/fonttools/fonttools
● Camoufox 反检测浏览器:https://github.com/daijro/camoufox
● Unicode PUA 规范:https://unicode.org/faq/private_use.html