Python 爬虫验证码实战:AES 加密报文 + 每页算术验证码(CH-009)
- 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 第 9 关的解密教程。这一关把政务站点常见的一套反爬凑齐了:请求/响应都是 AES-ECB 加密的 hex,还要带一个前端写死的
Adam-AppKey,而且每翻一页都得先过一道算术验证码。目标是取 10 页共 100 条备案公告,把每条批复文号最后一段数字加起来。
现象:报文是「一段十六进制」
靶场页 /projects?challenge=CH-009,点页码会弹出验证码窗。F12 → Network 里最值得看的是两个请求:
POST /api/challenges/CH-009/captchaContent-Type: application/json;charset=utf-8Adam-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}验证码这一步最容易踩的坑
- 每页一张、一次性的。想「先囤 10 张验证码再逐页用」不行 —— 每张用完即废。
- 识别算术题要宽容。
8+2=?里的?经常被 OCR 认成9(识别成8+2=9),所以要用正则取前面那一段「数字 + 运算符 + 数字」:
re.search(r"(\d+)\s*([+\-*/])\s*(\d+)", normalised) # 命中 8+2,忽略后面的噪声- 认不出来就换一张重来,不要拿着一张坏图硬猜(会对不上,而且一直卡在同一张上)。
完整脚本
"""CH-009:答案 = 10 页共 100 条批复文号「最后一段数字」之和
三道坎: 1. 报文是 AES-ECB(PKCS7) 加密后的 hex:请求体自己加密,响应体自己解 2. 请求头要带 Adam-AppKey 3. 每翻一页都得先解一道算术验证码(一次性)"""import base64import jsonimport reimport time
import requestsfrom Crypto.Cipher import AESfrom 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 = 10MAX_PAGES = 10MAX_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。
排查清单
- 响应不是 JSON → 先看响应体是不是 hex,是就说明要解密(别急着查编码)。
- 解密失败 → 检查 key 是不是前端写死的那个(在 bundle 里搜
AES、ECB、Pkcs7)。 - 403 且什么都不说 → 先看有没有带
Adam-AppKey。 - 携带
Adam-AppKey之后响应变成 hex、不带你看到明文 JSON —— 说明这个头就是「加密模式」的开关(真实站点也这样)。 - 验证码:每页取一张;拿到图先算答案,再和
captchaId一起提交。 - OCR 认不全 → 用正则取「数字 + 运算符 + 数字」,别要求整串完全匹配。
- 识别失败/答错 → 换一张重来(写重试循环,别死磕一张图)。
- 翻页结束条件用响应的
lastPage/totalPage。 - 末段数字可能不是数字(脏数据)→ 先
isdigit()再累加。
常见问题
为什么请求体是 hex 文本,不是 JSON?
因为前端用 CryptoJS 加密后直接当 body 发了(Content-Type 还写着 application/json;charset=utf-8,但那只是外观)。你在浏览器 DevTools 的 Payload 里看到的就是那段 hex。
只用 requests 能做验证码吗?
能。这一关不需要浏览器:验证码是算术题图片,用 ddddocr 就能识别。真实站点上的点选、滑块、行为验证码才会需要浏览器自动化。
识别率不够高怎么办?
三招:① 重试(最重要);② 识别前对图片做放大/灰度化;③ 对答案做范围校验(比如结果不可能是负数或大于 100,落在这个范围外就换一张)。
为什么不干脆用 Playwright 一页页点?
可以,但那会把「10 页 × 每题都要 OCR」的成本转移到浏览器上,而且慢。真实项目里更常见的是「请求级脚本 + OCR」,浏览器只用来观察。
最后提醒
验证码设计的目的是把「人」和「脚本」分开。练习里我们训练识别能力,用它去理解「为什么单靠验证码挡不住有耐心的爬虫」;在真实站点上,请遵守对方的访问规则,控制频率,别做批量抓取。
去挑战广场提交,恭喜打通 9 关。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!












