视频加载失败

Python 爬虫字体加密实战:码位混淆 + 自定义字体文件(CH-008)

1525 字
8 分钟
Python 爬虫字体加密实战:码位混淆 + 自定义字体文件(CH-008)

本文是 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"]

三点确认这是「字体加密」而不是编码问题:

  1. 字符是合法的 Unicode 字符(私有区 U+E000~U+F8FF),只是没有语义;
  2. 页面上看得见数字 —— 说明有东西把它们画出来了;
  3. 那个东西是 CSS 背景图 + 字体文件,不是 response.encoding 的问题。

(对照第 7 关:CH-007 是类名 + 静态字形图;这一关多了一份按账号派生的字体文件做中间层。)

三层结构#

① 页面 HTML:只有码位 + 类名,没有数字。

<span class="font-glyph ge1a2">&#xE1A2;</span><span class="font-glyph ge867">&#xE867;</span>

② 字形图(静态、全站公用)

/playground/verse/font/digits.css
.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…"}]
}
/playground/verse/font/23-3f1a9c0b2e.svg
<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="&#xEB02;" d="M80 16 L96 16 L96 36 L80 36 Z"/>
<glyph unicode="&#xE867;" 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。

排查清单#

  1. 先确认「页面正常但 DOM 是私有区字符」—— 这是字体加密的典型现场,不是编码问题。
  2. 看元素的 font-family / 背景图,找到字形图和字体文件。
  3. 抓取接口(/api/challenges/CH-008/data)拿你自己账号的 font_url。
  4. 解析字体文件里的三个尺寸:horiz-adv-x(格子宽)、units-per-em(格子高)、ascent(基线)。
  5. 解析字形图,把 (x, y) → 字符 建成字典。
  6. 码位 → 字体里的 (x, y) → 查字典。用同一个坐标直接命中,别自己猜行列。
  7. 想按行列算就记住:行 = (y - ascent) // 格子高。
  8. 每个字符实例码位不同 → 别写「频率统计」那套。
  9. 求和后 round(..., 2)。

常见问题#

字体文件能给别人用吗?#

不能。它是按账号派生的:别人的字体里码位和你的码位不是一回事。所以别去抄别人的字体,直接取自己账号的。

为什么字体里的 d 是一个矩形,不是字形轮廓?#

这是靶场的简化:真字体里 d 是矢量轮廓,需要和标准字体做轮廓比对才能知道是哪个字。这里把「轮廓」换成了「落点框」,让你专注在「字体信息 + 字形图 → 字符」这条链路上。

为什么同一个数字有时能被 OCR 认出来?#

因为字形图里的数字本身是正常字体画的。你的眼睛(或者 ddddocr)看截图是能认出来的 —— 但正确做法是解析映射,不是截屏 OCR:截图方案在真实站点上会因为滚动、懒加载、多设备渲染而崩掉。

私有区字符会不会被 requests 弄坏?#

不会。它们是合法字符,只要 response.json() 正常解析就行。要留意的是别用 .encode("ascii") 之类的操作。

最后提醒#

字体加密是保护数字(价格、销量、票房)的常见手段。还原它属于「把视觉变成数据」,请在授权范围内使用:靶场随便试,真实站点先确认数据授权与抓取规则。

去挑战广场提交,然后进 CH-009:数据会换成「加密报文 + 每页都要过验证码」的那一套。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Python 爬虫字体加密实战:码位混淆 + 自定义字体文件(CH-008)
https://jsnote.top/posts/ls-ch8/
作者
xiajiao
发布于
2026-10-02
许可协议
CC BY-NC-SA 4.0
相关文章智能推荐
1
Python 爬虫验证码实战:AES 加密报文 + 每页算术验证码(CH-009)
爬虫靶场本文是 LearnSpider 第 9 关的解密教程。这一关把政务站点常见的一套反爬凑齐了:请求/响应都是 AES-ECB 加密的 hex,还要带一个前端写死的 Adam-AppKey,而且每翻一页都得先过一道算术验证码。目标是取 10 页…
2
Python 爬虫字体映射实战:HTML 里一个字都没有(CH-007)
爬虫靶场本文是 LearnSpider 第 7 关的解密教程。这一关把大众点评那套「字体库 + 坐标」搬了过来:页面 HTML 里只有随机类名,文字全在字体库(SVG 文字地图)里,而「每个类名要把图挪多少像素」是接口加密下发的。目标是拿到 5 段…
3
Python 爬虫加密实战:ROT + Base32 + 凯撒 + HMAC-MD5 组合(CH-006)
爬虫靶场本文是 LearnSpider 第 6 关的解密教程。这一关把「古典密码」和「MAC 签名」拼在一起:请求参数要被 ROT-N 两次,响应要按 ROT47 → Base32 → 凯撒反着剥三层,签名则是 HMAC-MD5 再做两次 ROT-…
4
Python 爬虫接口签名实战:参数签名 + 会话绑定(CH-005)
爬虫靶场本文是 LearnSpider 第 5 关的解密教程。这一关的接口参数要签名,而且取数只认一个「会话标识」(uuid)—— 这个 uuid 是页面自己生成的、并且已经在后端和你的账号绑定过。答案是 5 页共 50 个数字的总和。
5
Python 爬虫接口实战:翻页取完再累加(CH-002)
爬虫靶场本文是 LearnSpider 第 2 关的解密教程。这一关的数据不在 HTML 里,只能从分页接口拿;答案是全部电影「评分总人数」的累加值。重点是:怎么找到接口、怎么确认它没把总量直接给你、以及怎么把 21 页稳稳翻完。
随机文章随机推荐

评论区

Profile Image of the Author
xiajiao
写爬虫,也写防爬虫的靶场。
公告
欢迎来到我的博客!这是一则示例公告。
分类
标签
站点统计
文章
15
分类
1
标签
33
总字数
22,317
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
Local
博客版本
Firefly v6.16.8
文章许可
CC BY-NC-SA 4.0
文章目录