视频加载失败

Python 爬虫验证码实战:AES 加密报文 + 每页算术验证码(CH-009)

1678 字
8 分钟
Python 爬虫验证码实战:AES 加密报文 + 每页算术验证码(CH-009)

本文是 LearnSpider 第 9 关的解密教程。这一关把政务站点常见的一套反爬凑齐了:请求/响应都是 AES-ECB 加密的 hex,还要带一个前端写死的 Adam-AppKey,而且每翻一页都得先过一道算术验证码。目标是取 10 页共 100 条备案公告,把每条批复文号最后一段数字加起来。

现象:报文是「一段十六进制」#

靶场页 /projects?challenge=CH-009,点页码会弹出验证码窗。F12 → Network 里最值得看的是两个请求:

POST /api/challenges/CH-009/captcha
Content-Type: application/json;charset=utf-8
Adam-AppKey: 3b08c9a1-924b-3876-9129-cca5976edfa9
<一段十六进制字符串>

响应也不是 JSON,而是另一段十六进制:

4f 2a 1b ... (hex)

用 JSON.parse 直接解析当然失败 —— 因为它是 AES 密文。这一关的代码里只要有一处顺序写错,你看到的就是「一堆乱码」。

三件事要同时做对#

环节做法
报文加解密AES-ECB + PKCS7,key = Nr2bNQtm5o9A0qqz(前端 JS 里);请求体 = AES(JSON).hex,响应体也是 hex
访问凭证请求头 Adam-AppKey: 3b08c9a1-924b-3876-9129-cca5976edfa9,不带就 403
验证码每页一张、用后即焚:先 POST /captcha 拿图,算出 8+2=?,再把 captchaId + yzm 一起放进列表请求的参数里

解出来的结构(和真实站点一致):

{
"code": "0",
"data": {
"list": [
{"projectName": "六安特安新能源…光伏电站项目",
"projectCode": "2610-341504-04-01-309092",
"itemName": "固定资产投资项目备案(内资项目)",
"unitName": "六安市叶集区发展和改革委员会…",
"approveResultName": "通过",
"itemHandleTime": "2026/10/01 11:01:15"}
],
"pageNumber": 1, "pageSize": 10,
"totalPage": 10, "totalRow": 100,
"firstPage": true, "lastPage": false
},
"message": "查询成功",
"status": 200
}

出错时 code 不是 "0",例如验证码不对:

{"code": "1", "data": null, "message": "验证码错误或已过期", "status": 200}

验证码这一步最容易踩的坑#

  1. 每页一张、一次性的。想「先囤 10 张验证码再逐页用」不行 —— 每张用完即废。
  2. 识别算术题要宽容。8+2=? 里的 ? 经常被 OCR 认成 9(识别成 8+2=9),所以要用正则取前面那一段「数字 + 运算符 + 数字」:
re.search(r"(\d+)\s*([+\-*/])\s*(\d+)", normalised) # 命中 8+2,忽略后面的噪声
  1. 认不出来就换一张重来,不要拿着一张坏图硬猜(会对不上,而且一直卡在同一张上)。

完整脚本#

"""CH-009:答案 = 10 页共 100 条批复文号「最后一段数字」之和
三道坎:
1. 报文是 AES-ECB(PKCS7) 加密后的 hex:请求体自己加密,响应体自己解
2. 请求头要带 Adam-AppKey
3. 每翻一页都得先解一道算术验证码(一次性)
"""
import base64
import json
import re
import time
import requests
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
BASE = "https://spider.jsnote.top"
CAPTCHA_URL = f"{BASE}/api/challenges/CH-009/captcha"
LIST_URL = f"{BASE}/api/challenges/CH-009/list"
APP_KEY = "3b08c9a1-924b-3876-9129-cca5976edfa9" # 前端写死的
AES_KEY = b"Nr2bNQtm5o9A0qqz" # 前端写死的
PAGE_SIZE = 10
MAX_PAGES = 10
MAX_RETRY = 6
def encrypt(payload: dict) -> str:
"""JSON -> AES-ECB(PKCS7) -> hex 文本(body 里放的就是这段 hex,不是 JSON)"""
raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
return AES.new(AES_KEY, AES.MODE_ECB).encrypt(pad(raw, AES.block_size)).hex()
def decrypt(hex_text: str) -> dict:
"""hex 文本 -> AES-ECB -> JSON"""
clean = re.sub(r"\s+", "", str(hex_text))
plain = unpad(AES.new(AES_KEY, AES.MODE_ECB).decrypt(bytes.fromhex(clean)),
AES.block_size)
return json.loads(plain.decode("utf-8"))
session = requests.Session()
session.headers.update({
"Accept": "*/*",
"Adam-AppKey": APP_KEY,
"Content-Type": "application/json;charset=utf-8",
"Referer": f"{BASE}/projects?challenge=CH-009",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/147.0.0.0 Safari/537.36",
"X-Requested-With": "XMLHttpRequest",
})
session.cookies.set("session", "<把浏览器里的 session cookie 粘到这里>",
domain="spider.jsnote.top")
def call(url, payload) -> dict:
response = session.post(url, data=encrypt(payload), timeout=20)
if response.status_code == 401:
raise SystemExit("401:需要登录态(Cookie 没填对?)")
if not response.ok:
raise SystemExit(f"{response.status_code}:{response.text[:200]}")
return decrypt(response.text)
def solve_captcha(image_bytes: bytes) -> int:
"""识别 `8+2=?` 并算答案。"""
import ddddocr # pip install ddddocr
text = ddddocr.DdddOcr(show_ad=False).classification(image_bytes)
normalised = (text.replace("×", "*").replace("✕", "*").replace("╳", "*")
.replace("x", "*").replace("X", "*")
.replace("÷", "/").replace("/", "/"))
match = re.search(r"(\d+)\s*([+\-*/])\s*(\d+)", normalised)
if not match:
raise ValueError(f"验证码没认出来:{text!r}")
left, operator, right = int(match.group(1)), match.group(2), int(match.group(3))
return {"+": left + right, "-": left - right, "*": left * right}.get(operator, left // right)
def fetch_page(page_number: int) -> dict:
"""取一页:每页都要先拿一张新验证码;识别失败或答错都换一张重来。"""
last_message = ""
for _attempt in range(MAX_RETRY):
captcha = call(CAPTCHA_URL, {"ajaxRequestTimestamp": int(time.time() * 1000)})
image_base64 = str(captcha["data"]["captchaImage"]).split(",")[-1]
try:
answer = solve_captcha(base64.b64decode(image_base64))
except (ValueError, ImportError) as error:
last_message = str(error)
time.sleep(0.5)
continue
data = call(LIST_URL, {
"ajaxRequestTimestamp": int(time.time() * 1000),
"pageSize": PAGE_SIZE,
"pageNumber": page_number,
"nameOrCode": "",
"queryType": "first",
"selectType": "pname",
"captchaId": captcha["data"]["captchaId"],
"yzm": str(answer),
})
if data.get("code") == "0":
return data["data"]
last_message = f"{data.get('code')} {data.get('message')}"
time.sleep(0.5)
raise SystemExit(f"第 {page_number} 页连续 {MAX_RETRY} 次都没过:{last_message}")
def main():
total = 0
rows_seen = 0
total_page = 0
for page_number in range(1, MAX_PAGES + 1):
page = fetch_page(page_number)
total_page = int(page.get("totalPage") or 0)
for row in page.get("list", []):
tail = str(row["projectCode"]).rsplit("-", 1)[-1] # 2610-341504-04-01-429215 -> 429215
if tail.isdigit():
total += int(tail)
rows_seen += 1
print(f"第 {page_number}/{total_page or '?'} 页:{len(page.get('list', []))} 条,"
f"累计 {rows_seen} 条,小计 {total}")
if page.get("lastPage") or page_number >= (total_page or MAX_PAGES):
break
print(f"\n共 {rows_seen} 条,批复文号末段数字之和:{total}")
if __name__ == "__main__":
main()

参考实现见 参考代码/s11.py;站点原始前端 JS(AES 那一段)存了一份 参考代码/s9.js。

排查清单#

  1. 响应不是 JSON → 先看响应体是不是 hex,是就说明要解密(别急着查编码)。
  2. 解密失败 → 检查 key 是不是前端写死的那个(在 bundle 里搜 AES、ECB、Pkcs7)。
  3. 403 且什么都不说 → 先看有没有带 Adam-AppKey。
  4. 携带 Adam-AppKey 之后响应变成 hex、不带你看到明文 JSON —— 说明这个头就是「加密模式」的开关(真实站点也这样)。
  5. 验证码:每页取一张;拿到图先算答案,再和 captchaId 一起提交。
  6. OCR 认不全 → 用正则取「数字 + 运算符 + 数字」,别要求整串完全匹配。
  7. 识别失败/答错 → 换一张重来(写重试循环,别死磕一张图)。
  8. 翻页结束条件用响应的 lastPage / totalPage。
  9. 末段数字可能不是数字(脏数据)→ 先 isdigit() 再累加。

常见问题#

为什么请求体是 hex 文本,不是 JSON?#

因为前端用 CryptoJS 加密后直接当 body 发了(Content-Type 还写着 application/json;charset=utf-8,但那只是外观)。你在浏览器 DevTools 的 Payload 里看到的就是那段 hex。

只用 requests 能做验证码吗?#

能。这一关不需要浏览器:验证码是算术题图片,用 ddddocr 就能识别。真实站点上的点选、滑块、行为验证码才会需要浏览器自动化。

识别率不够高怎么办?#

三招:① 重试(最重要);② 识别前对图片做放大/灰度化;③ 对答案做范围校验(比如结果不可能是负数或大于 100,落在这个范围外就换一张)。

为什么不干脆用 Playwright 一页页点?#

可以,但那会把「10 页 × 每题都要 OCR」的成本转移到浏览器上,而且慢。真实项目里更常见的是「请求级脚本 + OCR」,浏览器只用来观察。

最后提醒#

验证码设计的目的是把「人」和「脚本」分开。练习里我们训练识别能力,用它去理解「为什么单靠验证码挡不住有耐心的爬虫」;在真实站点上,请遵守对方的访问规则,控制频率,别做批量抓取。

去挑战广场提交,恭喜打通 9 关。

文章分享

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

Python 爬虫验证码实战:AES 加密报文 + 每页算术验证码(CH-009)
https://jsnote.top/posts/ls-ch9/
作者
xiajiao
发布于
2026-10-02
许可协议
CC BY-NC-SA 4.0
相关文章智能推荐
1
Python 爬虫字体加密实战:码位混淆 + 自定义字体文件(CH-008)
爬虫靶场本文是 LearnSpider 第 8 关的解密教程。这一关是真正的字体加密:价格和销量是一串 Uncode 私有区字符(\ue1a2),站点用「字体文件」说明每个码位的字形长什么样。目标是还原 30 件商品的价格/销量,提交价格总和(保留…
2
Python 爬虫接口实战:翻页取完再累加(CH-002)
爬虫靶场本文是 LearnSpider 第 2 关的解密教程。这一关的数据不在 HTML 里,只能从分页接口拿;答案是全部电影「评分总人数」的累加值。重点是:怎么找到接口、怎么确认它没把总量直接给你、以及怎么把 21 页稳稳翻完。
3
Python 爬虫加密实战:ROT + Base32 + 凯撒 + HMAC-MD5 组合(CH-006)
爬虫靶场本文是 LearnSpider 第 6 关的解密教程。这一关把「古典密码」和「MAC 签名」拼在一起:请求参数要被 ROT-N 两次,响应要按 ROT47 → Base32 → 凯撒反着剥三层,签名则是 HMAC-MD5 再做两次 ROT-…
4
Python 爬虫入门实战:服务端渲染页面怎么取数(CH-001)
爬虫靶场本文是 LearnSpider 第 1 关的解密教程。它练的不是「怎么破加密」,而是先判断数据到底在哪:这一关的数据只在服务端渲染出来的 HTML 里,源码里没有接口。答案是第一页 HTML 源码的字符数。
5
Python 爬虫接口签名实战:参数签名 + 会话绑定(CH-005)
爬虫靶场本文是 LearnSpider 第 5 关的解密教程。这一关的接口参数要签名,而且取数只认一个「会话标识」(uuid)—— 这个 uuid 是页面自己生成的、并且已经在后端和你的账号绑定过。答案是 5 页共 50 个数字的总和。
随机文章随机推荐

评论区

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