视频加载失败

Python 爬虫字体映射实战:HTML 里一个字都没有(CH-007)

1628 字
8 分钟
Python 爬虫字体映射实战:HTML 里一个字都没有(CH-007)

本文是 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> 是页面运行时拼出来的,内容来自取数接口的响应 —— 这才是这一关的核心。

字体库长什么样#

/playground/verse/glyph.css
.next-glyph{display:inline-block;width:20px;height:26px;overflow:hidden;
background-image:url('/playground/verse/glyphs.svg');background-repeat:no-repeat}
/* 背景图 800x208,每行 40 个字 */
/playground/verse/glyphs.svg
<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>

两个重要事实:

  1. 这张图里是整篇古文去重后的字符,顺序被打乱了(不是正文顺序);
  2. 每个字占一格:宽 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 base64
import hashlib
import hmac
import json
import re
import time
import 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。

排查清单#

  1. 源码里搜不到正文 → 看 <head> 里有没有 @font-face、background-image、动态 <style>。
  2. 找到静态字体库(glyph.css / glyphs.svg),先把格子宽高和每行字数弄清楚。
  3. 别用页面排版参数算行列(layout.line_height 和字体库格子高是两回事)。
  4. 签名 403 → 检查 raw 是否 "/verse|page|timestamp"、结果是否 Base64。
  5. 解密报错 → 记住顺序是 URL → Base64 → XOR → utf-8,XOR 的 key 是响应里的 xorKey(不在 init 里)。
  6. 坐标是格子左上角,直接整除格子宽高即可,别做四舍五入。
  7. 字体库顺序是打乱的:不要试图按顺序读图。
  8. 网页只放出前 4 段,第 5 段必须调接口。

常见问题#

为什么不用 @font-face 而是背景图?#

真实站点两种都有:@font-face 靠字体文件的 cmap 做映射,背景图靠 background-position 做映射。这一关用的是第二种(更容易看清「坐标」这件事)。想练第一种,看下一关 CH-008。

字体库能提前缓存吗?#

可以,它是静态的、和账号无关。但坐标是加密下发的、每个账号不同,所以缓存字体库只能省一次请求,不能省解密。

为什么我的第 5 段总取不到?#

网页上的分页标签只有 4 个(第 5 个是 disabled)。要拿第 5 段,必须自己构造请求 —— 这正是「页面上看不到的页,脚本才看得到」的练习点。

最后提醒#

字体映射是电商、点评类站点最常用的数字/文字保护手段之一。学会它就是学会「怎么把视觉还原成数据」;用的时候请遵守目标站点的规则与数据授权。

去挑战广场提交,然后进 CH-008:字体还在,但这次换成了真正的「字体文件 + 码位」。

文章分享

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

Python 爬虫字体映射实战:HTML 里一个字都没有(CH-007)
https://jsnote.top/posts/ls-ch7/
作者
xiajiao
发布于
2026-10-02
许可协议
CC BY-NC-SA 4.0
相关文章智能推荐
1
Python 爬虫字体加密实战:码位混淆 + 自定义字体文件(CH-008)
爬虫靶场本文是 LearnSpider 第 8 关的解密教程。这一关是真正的字体加密:价格和销量是一串 Uncode 私有区字符(\ue1a2),站点用「字体文件」说明每个码位的字形长什么样。目标是还原 30 件商品的价格/销量,提交价格总和(保留…
2
Python 爬虫接口签名实战:参数签名 + 会话绑定(CH-005)
爬虫靶场本文是 LearnSpider 第 5 关的解密教程。这一关的接口参数要签名,而且取数只认一个「会话标识」(uuid)—— 这个 uuid 是页面自己生成的、并且已经在后端和你的账号绑定过。答案是 5 页共 50 个数字的总和。
3
Python 爬虫接口实战:翻页取完再累加(CH-002)
爬虫靶场本文是 LearnSpider 第 2 关的解密教程。这一关的数据不在 HTML 里,只能从分页接口拿;答案是全部电影「评分总人数」的累加值。重点是:怎么找到接口、怎么确认它没把总量直接给你、以及怎么把 21 页稳稳翻完。
4
Python 爬虫实战:随机下发路径的资源怎么下载(CH-003)
爬虫靶场本文是 LearnSpider 第 3 关的解密教程。这一关要把图片下载下来,答案是这张图片的字节数。真正的考点是:拿到接口给的 image_id / image_url 之后,别把「字节」和「字符」搞混,也别拖太久(图片是随机下发的)。
5
Python 爬虫 TLS 指纹实战:requests 被挂住时怎么查(CH-004)
爬虫靶场本文是 LearnSpider 第 4 关的解密教程。这一关的接口数据本身是明文 JSON,难点在你根本连不上:requests 会一直卡到超时,服务端什么都不返回。答案是 5 页共 50 条关键词的 CPC 之和(保留 2 位小数)。
随机文章随机推荐

评论区

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