Python 爬虫字体加密实战:码位混淆 + 自定义字体文件(CH-008)
- 1Python 爬虫入门实战:服务端渲染页面怎么取数(CH-001)
- 2Python 爬虫接口实战:翻页取完再累加(CH-002)
- 3Python 爬虫实战:随机下发路径的资源怎么下载(CH-003)
- 4Python 爬虫 TLS 指纹实战:requests 被挂住时怎么查(CH-004)
- 5Python 爬虫接口签名实战:参数签名 + 会话绑定(CH-005)
- 6Python 爬虫加密实战:ROT + Base32 + 凯撒 + HMAC-MD5 组合(CH-006)
- 7Python 爬虫字体映射实战:HTML 里一个字都没有(CH-007)
- 8Python 爬虫字体加密实战:码位混淆 + 自定义字体文件(CH-008)本文
- 9Python 爬虫验证码实战:AES 加密报文 + 每页算术验证码(CH-009)
- 10第 10 关:RSA 传密钥 + AES 加密报文(顺带一层 JavaScript 混淆)
- 11第 11 关:无限 debugger + VM 混淆 + 签名请求头
- 12第 12 关:接口下发 JS + 字符串混淆 + 内置 MD5 + 反调试
- 13第 13 关:点选验证码 + 一次一密的轨迹报文 + 行为风控
- 14第 14 关:Cookie 签名 —— 七层复合加密,和「密钥写在前端」这件事
- 15第 15 关:蜜罐与爬虫封禁 —— 数据是假的,陷阱是真的
本文是 LearnSpider 第 8 关的解密教程。这一关是真正的字体加密:价格和销量是一串 Uncode 私有区字符(
\ue1a2),站点用「字体文件」说明每个码位的字形长什么样。目标是还原 30 件商品的价格/销量,提交价格总和(保留 2 位小数)。
现象:价格栏里没有数字
打开 /font?challenge=CH-008,表格里商品名正常,价格和销量看起来也正常。但用控制台读文本:
document.querySelectorAll('.notice tbody tr')[0].querySelectorAll('td')[2].textContent// "\ue1a2\ue867\uea85\ue263\uf63c\ue57b" ← 一串私有区字符Array.from(document.querySelectorAll('span.font-glyph')).slice(0,3).map(s => s.className)// ["font-glyph ge1a2", "font-glyph ge867", "font-glyph gea85"]三点确认这是「字体加密」而不是编码问题:
- 字符是合法的 Unicode 字符(私有区
U+E000~U+F8FF),只是没有语义; - 页面上看得见数字 —— 说明有东西把它们画出来了;
- 那个东西是 CSS 背景图 + 字体文件,不是
response.encoding的问题。
(对照第 7 关:CH-007 是类名 + 静态字形图;这一关多了一份按账号派生的字体文件做中间层。)
三层结构
① 页面 HTML:只有码位 + 类名,没有数字。
<span class="font-glyph ge1a2"></span><span class="font-glyph ge867"></span>② 字形图(静态、全站公用)
.font-glyph{display:inline-block;width:16px;height:20px;overflow:hidden;color:transparent; background-image:url('/playground/verse/font/digits.svg');background-repeat:no-repeat}<!-- /playground/verse/font/digits.svg:0-9 和 .,+-,打乱顺序,一格一个 text --><text x="0" y="16" font-size="16">6</text><text x="16" y="16" font-size="16">1</text><text x="32" y="16" font-size="16">9</text>③ 字体文件(按账号派生,公开可下载)
GET /api/challenges/CH-008/data 会给出 font_url:
{ "font_url": "/playground/verse/font/23-3f1a9c0b2e.svg", "row_count": 30, "rows": [{"name": "云南普洱饼", "price": "\ueb02\ue867…", "sales": "\ue3c0…"}]}<svg xmlns="http://www.w3.org/2000/svg"><defs> <font id="LSFont" horiz-adv-x="16"> <!-- 格子宽 16 --> <font-face font-family="LSFont" units-per-em="20" ascent="16" descent="-4"/> <!-- 格子高 20,基线在格子里 16 处 --> <glyph unicode="" d="M80 16 L96 16 L96 36 L80 36 Z"/> <glyph unicode="" d="M0 36 L16 36 L16 56 L0 56 Z"/> </font></defs></svg>d 里那个矩形的左上角 (x, y) 就是这个码位对应字形在字形图里的落点。
真字体的
d是矢量轮廓;这里简化成了「落点框」,但需要你做的事没变:把字体信息和字形图对上。
还原公式
码位 → 字体里的 (x, y) → 在字形图里找同一个 (x, y) 的那个字如果想按行列取(也可以):
列 = x // 16行 = (y - ascent) // 20 # ascent = 16,来自 <font-face>完整脚本
"""CH-008:答案 = 30 件商品价格之和(保留 2 位小数)
要点: 1. 价格/销量是私有区码位,每个字符实例一个独立码位(同一个 8 在不同位置也不同) —— 所以频率统计没用,必须一个码位一个码位查字体 2. 字体文件按账号派生(URL 里带短签),别人的字体对不上你的码位 3. 字体里的 (x, y) 是「字形在字形图里的落点」,字形图里的 <text> 用的是同一个坐标"""import re
import requests
BASE = "https://spider.jsnote.top"DATA_URL = f"{BASE}/api/challenges/CH-008/data"SHEET_CSS_PATH = "/playground/verse/font/digits.css"
session = requests.Session()session.headers.update({ "Accept": "*/*", "Referer": f"{BASE}/font?challenge=CH-008", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/147.0.0.0 Safari/537.36",})session.cookies.set("session", "<把浏览器里的 session cookie 粘到这里>", domain="spider.jsnote.top")
def parse_font(text): """字体文件 -> (格子宽, 格子高, 基线高度, {码位: (x, y)})。""" cell_w = int(re.search(r'horiz-adv-x="(\d+)"', text).group(1)) cell_h = int(re.search(r'units-per-em="(\d+)"', text).group(1)) ascent = int(re.search(r'ascent="(\d+)"', text).group(1))
cmap = {} for hex_code, path in re.findall(r'<glyph unicode="&#x([0-9A-Fa-f]+);" d="([^"]+)"/>', text): numbers = [int(value) for value in re.findall(r"-?\d+", path)] cmap[int(hex_code, 16)] = (numbers[0], numbers[1]) return cell_w, cell_h, ascent, cmap
def parse_sheet(sess): """字形图 -> {(x, y): 字符}。""" css = sess.get(BASE + SHEET_CSS_PATH, timeout=10).text head = re.search(r"\.font-glyph\{([^}]*)\}", css).group(1) svg_path = re.search(r"url\('([^']+)'\)", head).group(1)
svg = sess.get(BASE + svg_path, timeout=10).text cells = {} for x, y, char in re.findall(r'<text x="(-?\d+)" y="(-?\d+)"[^>]*>([^<])</text>', svg): cells[(int(x), int(y))] = char return cells
def decode(text, cmap, cells): """一串码位 -> 真数字。""" return "".join(cells[cmap[ord(char)]] for char in text)
def main(): data = session.get(DATA_URL, timeout=10) if data.status_code == 401: raise SystemExit("401:这个接口要登录态(Cookie 没填对?)") data.raise_for_status() payload = data.json()
font = session.get(BASE + payload["font_url"], timeout=10).text cell_w, cell_h, ascent, cmap = parse_font(font) cells = parse_sheet(session) print(f"字体:格子 {cell_w}x{cell_h}(ascent {ascent}),{len(cmap)} 个码位;" f"字形图 {len(cells)} 格\n")
total = 0.0 for index, row in enumerate(payload["rows"], start=1): price = decode(row["price"], cmap, cells) sales = decode(row["sales"], cmap, cells) total += float(price) if index <= 5: print(f" {index:>2}. {row['name']} 价格 {price} 销量 {sales}")
print(f"\n共 {len(payload['rows'])} 件商品,价格合计:{round(total, 2)}")
if __name__ == "__main__": main()参考实现见 参考代码/s8.py。
排查清单
- 先确认「页面正常但 DOM 是私有区字符」—— 这是字体加密的典型现场,不是编码问题。
- 看元素的
font-family/ 背景图,找到字形图和字体文件。 - 抓取接口(
/api/challenges/CH-008/data)拿你自己账号的font_url。 - 解析字体文件里的三个尺寸:
horiz-adv-x(格子宽)、units-per-em(格子高)、ascent(基线)。 - 解析字形图,把
(x, y) → 字符建成字典。 - 码位 → 字体里的
(x, y)→ 查字典。用同一个坐标直接命中,别自己猜行列。 - 想按行列算就记住:
行 = (y - ascent) // 格子高。 - 每个字符实例码位不同 → 别写「频率统计」那套。
- 求和后
round(..., 2)。
常见问题
字体文件能给别人用吗?
不能。它是按账号派生的:别人的字体里码位和你的码位不是一回事。所以别去抄别人的字体,直接取自己账号的。
为什么字体里的 d 是一个矩形,不是字形轮廓?
这是靶场的简化:真字体里 d 是矢量轮廓,需要和标准字体做轮廓比对才能知道是哪个字。这里把「轮廓」换成了「落点框」,让你专注在「字体信息 + 字形图 → 字符」这条链路上。
为什么同一个数字有时能被 OCR 认出来?
因为字形图里的数字本身是正常字体画的。你的眼睛(或者 ddddocr)看截图是能认出来的 —— 但正确做法是解析映射,不是截屏 OCR:截图方案在真实站点上会因为滚动、懒加载、多设备渲染而崩掉。
私有区字符会不会被 requests 弄坏?
不会。它们是合法字符,只要 response.json() 正常解析就行。要留意的是别用 .encode("ascii") 之类的操作。
最后提醒
字体加密是保护数字(价格、销量、票房)的常见手段。还原它属于「把视觉变成数据」,请在授权范围内使用:靶场随便试,真实站点先确认数据授权与抓取规则。
去挑战广场提交,然后进 CH-009:数据会换成「加密报文 + 每页都要过验证码」的那一套。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!












