


在爬虫开发中,我们经常会遇到各种反爬手段,如 JS 混淆、请求签名、验证码等。但在某些招聘类网站上,还有一种比较隐蔽的反爬方式——字体加密Font Encryption。
Boss直聘就是其中的典型代表:你在浏览器中看到薪资显示为"15-20K",但当你用 requests 抓取页面或直接复制文本时,得到的却是一串乱码字符。
本文将从技术角度完整还原这一加密机制的原理,并给出解密方案。
用浏览器开发者工具检查薪资元素:
<span class="job-salary">
元素文本内容 textContent 为:
-K·薪
这些"乱码"并非编码错误,而是有意的字符替换通过 window.getComputedStyle() 检查该元素的 CSS 属性:
window.getComputedStyle(document.querySelector('.job-salary')).fontFamily
// 输出: "kanzhun-mix, kanzhun-Regular"关键发现:薪资元素使用了名为 kanzhun-mix的自定义字体。
进一步检查页面 CSS 中的 @font-face 声明:
css
@font-face {
font-family: "kanzhun-mix";
src: url(".../3kovsijnt11693967587313.woff2") format("woff2"),
url(".../w57q70gcfp1693967587502.woff") format("woff"),
url(".../30k9dfumyv1693967587404.ttf") format("truetype");
}字体加密的本质是 Unicode 私有区域(PUA)字符映射:
┌──────────────────────────────────────────────────────────────┐
│ 服务端 │
│ │
│ 真实薪资: "15-20K" │
│ ↓ │
│ 替换数字为PUA字符: "\uE032\uE035-\uE033\uE031K" │
│ ↓ │
│ 返回HTML: <span class="job-salary">-K</span> │
│ │
└──────────────────────────────────────────────────────────────┘
↓ 浏览器渲染
┌──────────────────────────────────────────────────────────────┐
│ 浏览器 │
│ │
│ 加载自定义字体 kanzhun-mix │
│ ↓ │
│ 字体内部 CMAP 映射表: │
│ U+E031 → 字形0 U+E032 → 字形1 ... U+E03A → 字形9 │
│ ↓ │
│ 用户看到: "15-20K" ← 视觉正常 │
│ 爬虫拿到: "\uE032\uE035-\uE033\uE031K" ← 文本乱码 │
│ │
└──────────────────────────────────────────────────────────────┘关键点:
Unicode 标准保留了 U+E000 ~ U+F8FF 作为私有使用区域(Private Use Area)
自定义字体可以任意定义这些 PUA 字符的字形(glyph)
Boss直聘将数字 0-9 映射到 PUA 区域,服务端返回替换后的 PUA 字符
浏览器加载自定义字体后正常渲染,但爬虫拿到的原始文本是 PUA 编码
因为操作系统剪贴板保存的是文本的 Unicode 码点,而不是视觉渲染结果。PUA 字符在你的系统字体中没有对应字形,所以粘贴出来显示为乱码或空白。
bash
curl -sL -o kanzhun-mix.woff2 \
"https://img.bosszhipin.com/static/file/2023/3kovsijnt11693967587313.woff2"使用 Python fonttools 库解析字体文件的字符映射表:
from fontTools.ttLib import TTFont
font = TTFont('kanzhun-mix.woff2')
cmap = font.getBestCmap()
for code, glyph_name in sorted(cmap.items()):
print(f'U+{code:04X} -> {glyph_name}')输出:
U+E031 -> uniE031
U+E032 -> uniE032
U+E033 -> uniE033
U+E034 -> uniE034
U+E035 -> uniE035
U+E036 -> uniE036
U+E037 -> uniE037
U+E038 -> uniE038
U+E039 -> uniE039
U+E03A -> uniE03A
字体包含 10 个 PUA 字符(E031 ~ E03A),字形名为自动生成的 uniEXXXX,不直接透露映射关系。
既然字形名无法直接告诉我们映射,那就分析字形轮廓(glyph outline),对比每个 PUA 字符与标准数字的结构特征:
```
from fontTools.ttLib import TTFont
from fontTools.pens.recordingPen import RecordingPen
def get_glyph_commands(font, glyph_name):
"""提取字形轮廓的绘制指令"""
glyph = font.getGlyphSet()[glyph_name]
pen = RecordingPen()
glyph.draw(pen)
return pen.value
font = TTFont('kanzhun-mix.woff2')
for code in range(0xE031, 0xE03B):
glyph_name = font.getBestCmap()[code]
cmds = get_glyph_commands(font, glyph_name)
segments = len(cmds)
closed_paths = sum(1 for c in cmds if c[0] == 'closePath')
print(f'U+{code:04X}: {segments}段, {closed_paths}个闭合路径')
```
输出:
```
U+E031: 18段, 2个闭合路径 → 字形: "0"(椭圆+内圆洞)
U+E032: 8段, 1个闭合路径 → 字形: "1"(简单竖笔)
U+E033: 14段, 1个闭合路径 → 字形: "2"(弧线形)
U+E034: 17段, 1个闭合路径 → 字形: "3"(双弧形)
U+E035: 16段, 1个闭合路径 → 字形: "4"(全直线十字形)
U+E036: 17段, 1个闭合路径 → 字形: "5"(横+弧)
U+E037: 16段, 2个闭合路径 → 字形: "6"(大弧+内圆)
U+E038: 8段, 1个闭合路径 → 字形: "7"(横+斜线)
U+E039: 22段, 3个闭合路径 → 字形: "8"(上下双圆)
U+E03A: 17段, 2个闭合路径 → 字形: "9"(圆+下竖尾)
```为了进一步确认字形分析结果,使用浏览器 Canvas 渲染 PUA 字符和标准数字,进行**逐像素 XOR 对比**:
// 在浏览器控制台执行
const canvas = document.createElement('canvas');
canvas.width = 32; canvas.height = 48;
const ctx = canvas.getContext('2d');
// 渲染标准数字 0-9,记录像素位图
const digitBitmaps = {};
for (let d = 0; d <= 9; d++) {
ctx.clearRect(0, 0, 32, 48);
ctx.font = '28px kanzhun-mix';
ctx.fillText(String(d), 2, 6);
const data = ctx.getImageData(0, 0, 32, 48).data;
digitBitmaps[d] = new Uint8Array(32 * 48);
for (let i = 0; i < 32 * 48; i++)
digitBitmaps[d][i] = data[i * 4 + 3] > 60 ? 1 : 0;
}
// 渲染 PUA 字符,与每个标准数字做 XOR 比较
for (let code = 0xE031; code <= 0xE03A; code++) {
ctx.clearRect(0, 0, 32, 48);
ctx.fillText(String.fromCodePoint(code), 2, 6);
const data = ctx.getImageData(0, 0, 32, 48).data;
const bitmap = new Uint8Array(32 * 48);
for (let i = 0; i < 32 * 48; i++)
bitmap[i] = data[i * 4 + 3] > 60 ? 1 : 0;
let bestDigit = -1, bestDiff = Infinity;
for (let d = 0; d <= 9; d++) {
let diff = 0;
for (let i = 0; i < 32 * 48; i++)
if (bitmap[i] !== digitBitmaps[d][i]) diff++;
if (diff < bestDiff) { bestDiff = diff; bestDigit = d; }
}
const hex = 'U+' + code.toString(16).toUpperCase();
const pct = ((32*48 - bestDiff) / (32*48) * 100).toFixed(1);
console.log${hex} -> ${bestDigit} (${pct}% 匹配));
}
```
输出:
```
U+E031 -> 0 (99.7% 匹配)
U+E032 -> 1 (99.4% 匹配)
U+E033 -> 2 (98.1% 匹配)
U+E034 -> 3 (98.3% 匹配)
U+E035 -> 4 (97.9% 匹配)
U+E036 -> 5 (98.8% 匹配)
U+E037 -> 6 (98.7% 匹配)
U+E038 -> 7 (98.4% 匹配)
U+E039 -> 8 (99.1% 匹配)
U+E03A -> 9 (98.6% 匹配)三种方法交叉验证,映射关系完全一致。
| PUA 编码 | 真实数字 | 字形特征 | 像素匹配度 |
|----------|---------|-------------------|----------|
| U+E031 | 0 | 椭圆 + 内圆(双闭合路径) | 99.7% |
| U+E032 | 1 | 竖笔(纯直线) | 99.4% |
| U+E033 | 2 | 弧线形 | 98.1% |
| U+E034 | 3 | 双弧形 | 98.3% |
| U+E035 | 4 | 十字形(纯直线) | 97.9% |
| U+E036 | 5 | 横线 + 弧 | 98.8% |
| U+E037 | 6 | 大弧 + 内圆(双闭合路径) | 98.7% |
| U+E038 | 7 | 横线 + 斜线 | 98.4% |
| U+E039 | 8 | 双圆(三闭合路径) | 99.1% |
| U+E03A | 9 | 圆 + 下竖尾(双闭合路径) | 98.6% |简化公式:
digit = code_point - 0xE031 # E031→0, E032→1, ..., E03A→9# 字体映射表
FONT_MAP = {
0xE031: '0', 0xE032: '1', 0xE033: '2', 0xE034: '3',
0xE035: '4', 0xE036: '5', 0xE037: '6', 0xE038: '7',
0xE039: '8', 0xE03A: '9',
}
def decode_salary(text: str) -> str:
"""解密Boss直聘加密薪资文本"""
result = []
for char in text:
code = ord(char)
result.append(FONT_MAP.get(code, char))
return ''.join(result)
# 测试
print(decode_salary('\ue032\ue032-\ue033\ue031K·\ue032\ue034薪'))
# 输出: 11-20K·13薪
print(decode_salary('\ue034\ue031-\ue035\ue031K·\ue032\ue035薪'))
# 输出: 30-40K·14薪
const FONT_MAP = {
0xE031: '0', 0xE032: '1', 0xE033: '2', 0xE034: '3',
0xE035: '4', 0xE036: '5', 0xE037: '6', 0xE038: '7',
0xE039: '8', 0xE03A: '9',
};
function decodeSalary(text) {
return Array.from(text)
.map(c => FONT_MAP[c.codePointAt(0)] || c)
.join('');
}当字体文件更新导致映射变化时,可通过以下方法自动解析:
from fontTools.ttLib import TTFont
def build_font_map(font_path, ref_font_path='C:/Windows/Fonts/arial.ttf'):
"""
自动解析字体加密映射表
思路: 用参考字体的数字字形轮廓与加密字体的PUA字形轮廓做相似度对比
"""
custom_font = TTFont(font_path)
ref_font = TTFont(ref_font_path)
# 获取参考字体 0-9 的字形轮廓
ref_outlines = {}
ref_cmap = ref_font.getBestCmap()
for d in range(10):
glyph_name = ref_cmap[ord(str(d))]
glyph = ref_font['glyf'][glyph_name]
# 用坐标集合作为指纹
coords = set()
if glyph.numberOfContours > 0:
for coord in glyph.coordinates:
coords.add(coord)
ref_outlines[d] = coords
# 遍历加密字体的PUA字符,匹配最相似的数字
result = {}
for code, glyph_name in custom_font.getBestCmap().items():
if code < 0xE000 or code > 0xF8FF:
continue # 只关注PUA区域
glyph = custom_font['glyf'][glyph_name]
coords = set()
if glyph.numberOfContours > 0:
for coord in glyph.coordinates:
coords.add(coord)
# 找最相似的数字(简化版,实际可用轮廓匹配算法)
best_digit = -1
best_score = -1
for d, ref_coords in ref_outlines.items():
if not coords or not ref_coords:
continue
intersection = len(coords & ref_coords)
union = len(coords | ref_coords)
score = intersection / union if union > 0 else 0
if score > best_score:
best_score = score
best_digit = d
if best_digit >= 0:
result[code] = str(best_digit)
return result1. 识别特征:CSS @font-face 自定义字体 + 元素使用该字体族 + 文本包含 PUA 区域字符U+E000 ~ U+F8FF)
2. 逆向方法:下载字体文件 → fonttools 解析 CMAP/glyf 表 → 字形轮廓对比确定映射
3. 验证方法:Canvas 逐像素 XOR 对比 + 上下文语义验证(薪资范围是否合理)
4. 通用性:该方法适用于所有基于字体加密的反爬方案(58同城、猫眼电影等均有类似机制)
我用夸克网盘给你分享了「某直聘字体解密(仅供技术学习与交流)」,点击链接或复制整段内容,打开「夸克APP」即可获取。
/~44733YHwKf~:/
链接:https://pan.quark.cn/s/1617e5b16614
从网站安全角度,字体加密的局限性在于:
映射表嵌在公开的字体文件中,必然可被逆向
字形轮廓本身携带了数字的结构信息,无法完全隐藏
如果每次请求返回不同的随机字体+映射,可提高破解成本,但也增加了服务端开销
---
本文仅供技术学习与交流,请遵守目标网站的 robots.txt 及相关法律法规。