Python 爬虫字体映射实战:HTML 里一个字都没有(CH-007)
- 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 第 7 关的解密教程。这一关把大众点评那套「字体库 + 坐标」搬了过来:页面 HTML 里只有随机类名,文字全在字体库(SVG 文字地图)里,而「每个类名要把图挪多少像素」是接口加密下发的。目标是拿到 5 段古文、把它们各自的暗号字按页码拼起来。
现象:页面上有字,源码里没有字
打开 /verse?challenge=CH-007,页面上是一段竖排/横排的古文。但「查看源代码」里搜不到任何一个正文词:
<span class="next-glyph cb68626"></span><span class="next-glyph c9ce688"></span>...每个字都是一个空的 span + 随机类名。字去哪了?去看 <head> 里多出来的两个东西:
<link rel="stylesheet" href="/playground/verse/glyph.css"><style id="verse-glyph-positions"> .cb68626{background-position:-0px -0px} .c9ce688{background-position:-20px -0px} ...</style>glyph.css是静态的字体库样式:格子多大、背景图是哪张;- 第二段
<style>是页面运行时拼出来的,内容来自取数接口的响应 —— 这才是这一关的核心。
字体库长什么样
.next-glyph{display:inline-block;width:20px;height:26px;overflow:hidden; background-image:url('/playground/verse/glyphs.svg');background-repeat:no-repeat}/* 背景图 800x208,每行 40 个字 */<svg xmlns="http://www.w3.org/2000/svg" width="800" height="208" viewBox="0 0 800 208"> <text x="0" y="20" font-size="20" font-family="Songti SC, SimSun, serif">赋下托川白美禁然凌喜南荆各嫠而海未予波夫安绝世江 旗与中立耳侣游寄粟成甚鹿响蛟流酒</text> <text x="0" y="46" font-size="20" ...>口悲万纵星今和驾临共固棹扣歌羡曹上水色空;知天御’泛窈七诵吹酾子苏之间光况桂东藏</text> ...</svg>两个重要事实:
- 这张图里是整篇古文去重后的字符,顺序被打乱了(不是正文顺序);
- 每个字占一格:宽 20px(= 字号,中文一个字正好一格)、高 26px,每行 40 个字。
所以「光有字体库」拼不出正文 —— 它只是一张字表,谁在哪一格要看接口给的坐标。
接口给了什么
先看协议:这一关是 URL 编码 + HMAC-SHA256 + XOR 混合加密。三个请求/响应细节:
GET /api/challenges/CH-007/init -> {"hmacKey": "…32 位 hex…", "xorKey": 137, "total_pages": 5, "visible_pages": 4}
POST /api/challenges/CH-007/data header X-URL-Sign = base64(HMAC-SHA256(f"/verse|{page}|{timestamp}", hmacKey)) body {"params": <URL 编码后的 JSON 字符串>} 响应 {"encrypted": true, "xorKey": 137, "data": <URL 编码( Base64( 逐字节 XOR(明文 JSON, xorKey) ) )>}解密顺序(从外往里剥):
URL 解码 → Base64 解码 → 逐字节 XOR(xorKey) → utf-8 → json.loads解出来的明文没有正文、没有暗号字本身,只有「类名 + 坐标」:
{ "page": 1, "layout": {"font_size": 20, "cell_width": 20, "line_height": 28, "chars_per_row": 15, "rows": 10, "padding": 14, "width": 328, "height": 308}, "glyphs": [ {"c": "cb68626", "x": 0, "y": 0}, {"c": "c9ce688", "x": 20, "y": 0}, {"c": "cf33753", "x": 40, "y": 0} ], "key_index": 37}glyphs数组的顺序 = 阅读顺序;- 每个元素的
(x, y)= 这个字在字体库里的格子左上角坐标; key_index= 暗号字排在第几位(从 0 数);layout只是页面排版用的(字号/行距/每行几个字),跟字体库的格子无关 —— 别拿它算行列。
还原公式
行 = y // 26 # 格子高列 = x // 20 # 格子宽字 = glyphs.svg 第 <行> 行文本的第 <列> 个字按 glyphs 的顺序对每个元素做一遍,拼起来就是这一段原文。
完整脚本
"""CH-007:答案 = 5 段各自的暗号字按页码拼起来(共 5 个字)"""import base64import hashlibimport hmacimport jsonimport reimport timeimport urllib.parse
import requests
BASE = "https://spider.jsnote.top"ROUTE = "/verse"INIT_URL = f"{BASE}/api/challenges/CH-007/init"DATA_URL = f"{BASE}/api/challenges/CH-007/data"SHEET_CSS_PATH = "/playground/verse/glyph.css" # 静态字体库样式(不需登录)TOTAL_PAGES = 5
def sign(hmac_key, page, timestamp): """base64(HMAC-SHA256("/verse|page|timestamp", hmacKey))""" raw = f"{ROUTE}|{page}|{timestamp}" digest = hmac.new(hmac_key.encode(), raw.encode(), hashlib.sha256).digest() return base64.b64encode(digest).decode()
def encode_params(page, timestamp): return urllib.parse.quote(json.dumps({"page": page, "timestamp": timestamp}, separators=(",", ":")), safe="")
def decrypt(url_encoded, xor_key): """URL 解码 → Base64 解码 → 逐字节 XOR → utf-8 → JSON""" masked = base64.b64decode(urllib.parse.unquote(url_encoded)) plain = bytes(byte ^ xor_key for byte in masked) return json.loads(plain.decode("utf-8"))
def parse_font_map(session): """从静态字体库扒出格子宽高和一行一行的字。""" css = session.get(BASE + SHEET_CSS_PATH, timeout=10).text head = re.search(r"\.next-glyph\{([^}]*)\}", css).group(1) cell_w = int(re.search(r"width:(\d+)px", head).group(1)) cell_h = int(re.search(r"height:(\d+)px", head).group(1)) svg_path = re.search(r"url\('([^']+)'\)", head).group(1)
svg = session.get(BASE + svg_path, timeout=10).text rows = re.findall(r"<text[^>]*>(.*?)</text>", svg, re.S) return cell_w, cell_h, rows
def decode(glyphs, cell_w, cell_h, rows): """坐标 → 行/列 → 字。""" chars = [] for glyph in glyphs: row = glyph["y"] // cell_h col = glyph["x"] // cell_w chars.append(rows[row][col]) return "".join(chars)
def main(): session = requests.Session() session.headers.update({ "Content-Type": "application/json", "Referer": f"{BASE}/verse?challenge=CH-007", "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")
config = session.get(INIT_URL, timeout=10).json() cell_w, cell_h, rows = parse_font_map(session) print(f"字体库:格子 {cell_w}x{cell_h},{len(rows)} 行\n")
keys = [] for page in range(1, TOTAL_PAGES + 1): timestamp = str(int(time.time() * 1000)) response = session.post(DATA_URL, headers={ "X-URL-Sign": sign(config["hmacKey"], page, timestamp), }, data=json.dumps({"params": encode_params(page, timestamp)}), timeout=10)
if response.status_code == 403: raise SystemExit("403:签名不对。自查 —— raw 是 '/verse|page|timestamp'," "结果是 HMAC-SHA256 的 Base64,路径带前导斜杠") response.raise_for_status()
body = response.json() payload = decrypt(body["data"], int(body["xorKey"])) glyphs = payload["glyphs"]
text = decode(glyphs, cell_w, cell_h, rows) key = decode([glyphs[payload["key_index"]]], cell_w, cell_h, rows) keys.append(key) print(f"第 {page} 段:{len(text)} 字 → 暗号字「{key}」") print(f" 开头:{text[:24]}…") time.sleep(0.5)
print(f"\n答案:{''.join(keys)}")
if __name__ == "__main__": main()参考实现见 参考代码/s7.py。
排查清单
- 源码里搜不到正文 → 看
<head>里有没有@font-face、background-image、动态<style>。 - 找到静态字体库(
glyph.css/glyphs.svg),先把格子宽高和每行字数弄清楚。 - 别用页面排版参数算行列(
layout.line_height和字体库格子高是两回事)。 - 签名 403 → 检查 raw 是否
"/verse|page|timestamp"、结果是否 Base64。 - 解密报错 → 记住顺序是 URL → Base64 → XOR → utf-8,XOR 的 key 是响应里的
xorKey(不在 init 里)。 - 坐标是格子左上角,直接整除格子宽高即可,别做四舍五入。
- 字体库顺序是打乱的:不要试图按顺序读图。
- 网页只放出前 4 段,第 5 段必须调接口。
常见问题
为什么不用 @font-face 而是背景图?
真实站点两种都有:@font-face 靠字体文件的 cmap 做映射,背景图靠 background-position 做映射。这一关用的是第二种(更容易看清「坐标」这件事)。想练第一种,看下一关 CH-008。
字体库能提前缓存吗?
可以,它是静态的、和账号无关。但坐标是加密下发的、每个账号不同,所以缓存字体库只能省一次请求,不能省解密。
为什么我的第 5 段总取不到?
网页上的分页标签只有 4 个(第 5 个是 disabled)。要拿第 5 段,必须自己构造请求 —— 这正是「页面上看不到的页,脚本才看得到」的练习点。
最后提醒
字体映射是电商、点评类站点最常用的数字/文字保护手段之一。学会它就是学会「怎么把视觉还原成数据」;用的时候请遵守目标站点的规则与数据授权。
去挑战广场提交,然后进 CH-008:字体还在,但这次换成了真正的「字体文件 + 码位」。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!












