爬虫中级------验证码识别:从零到全自动采集的完整实战
1. 引言
在爬虫开发的路上,验证码 往往是第一道真正的"拦路虎"。当你成功绕过登录、找到数据接口,却发现每个请求都需要输入验证码时,你才真正体会到"反爬"的威力。
本文将带你完整经历一次验证码识别爬虫 的开发过程,从浏览器分析、接口抓取、OCR 识别,到设计一套"永不放弃"的重试机制,最终稳定采集 100 页数据。
文章不仅提供完整代码,更会详细解释每一步的思考逻辑 和踩坑经历,希望能帮助你建立起解决类似问题的系统性思维。
2. 任务背景与目标
2.1 挑战描述
目标网站(https://www.spiderdemo.cn/captcha/cap1_challenge/?challenge_type=cap1_challenge)提供了一个"第一代验证码"挑战任务:
- 共 100 页 ,每页显示 10 个随机数字。
- 任务:采集所有页面的数字,计算总和,并提交答案。
- 难点:除第一页外,翻页或请求数据时需输入 4 位验证码。
2.2 技术目标
- 使用 Python +
requests完成自动化采集。 - 不需要人工干预,程序自动识别验证码并重试。
- 对服务器保持友好(慢速、退避),避免被封。
3. 第一步:网页分析与接口发现
3.1 打开浏览器,查看页面
访问 https://www.spiderdemo.cn/captcha/cap1_challenge/?challenge_type=cap1_challenge,可以看到数字网格、分页按钮和底部的提交区域。
3.2 打开开发者工具(F12)
切换到 Network(网络) 标签,刷新页面,观察请求列表。
关键发现 1:/init/ 接口
页面加载后,会首先请求:
GET /captcha/api/cap1_challenge/init/?challenge_type=cap1_challenge
返回 JSON 包含 success: true 和 page_data(第一页数字)。
结论:初始化接口不仅建立会话状态,还直接返回了第一页数据。
关键发现 2:/captcha_image/ 接口
点击分页按钮(如"下一页"),会触发验证码弹窗。此时,浏览器会请求:
GET /captcha/api/cap1_challenge/captcha_image/?t=1612345678
返回一张图片(image/png),这就是验证码图片。
关键发现 3:/page/ 接口(POST)
输入验证码并点击"验证并获取数据"后,浏览器发送 POST 请求:
POST /captcha/api/cap1_challenge/page/
Content-Type: application/json
{
"captcha_input": "ABC1",
"page_num": 2,
"challenge_type": "cap1_challenge"
}
返回 JSON,包含 success、page_data(数字列表)或错误信息。
为什么不能直接 POST?
直接 POST 不携带验证码,服务器会返回
{"error": "验证码错误"};如果跳过初始化,则返回405。
4. 登录态处理:Cookie 的妙用
4.1 为什么不用模拟登录?
该挑战需要登录才能参与。登录页面(/admin_I/)包含 CSRF Token 和可能的验证码,模拟登录较为复杂。
我们采用更实用的方法:手动登录一次,复制浏览器 Cookie,在 Python 中复用。
4.2 如何获取 Cookie?
- 在浏览器中登录(用户名
chuxuan,密码caoyintao123)。 - 登录后,按 F12 → Application → Cookies → 找到
sessionid和csrftoken(或其他名称)。 - 将 Cookie 字符串复制到代码中,通过
session.cookies.update()加载。

4.3 代码示例
python
cookie_str = "sessionid=xxx; csrftoken=yyy"
for item in cookie_str.split('; '):
if '=' in item:
k, v = item.split('=', 1)
session.cookies.set(k, v)
优点:省去了复杂的登录逻辑,且 Cookie 有效期较长(通常几天),适合长时间运行。
5. 初始化必须先行
5.1 忘记初始化的后果
如果在 POST /page/ 前没有调用 /init/,服务器会返回 405 Method Not Allowed。
这是因为服务器需要先通过 init 建立一个"挑战会话",记录你正在参与的任务类型。
5.2 初始化函数
python
def init_challenge():
resp = session.get(INIT_URL, params={'challenge_type': CHALLENGE_TYPE})
if resp.status_code == 200:
data = resp.json()
if data.get('success'):
return data.get('page_data') # 可能直接返回第一页数据
return None
我们会在主流程中调用它,并利用它返回的第一页数据,避免重复请求。
6. 验证码图片获取与预处理
6.1 获取图片
验证码图片接口很简单,只需 GET 加上时间戳参数防止缓存:
python
img_data = session.get(CAPTCHA_URL, params={'t': int(time.time()*1000)}).content
6.2 图像预处理(提高 OCR 准确率)
原始图片可能带噪点或彩色背景,需要转为灰度并二值化:
python
from PIL import Image
import io
img = Image.open(io.BytesIO(img_data))
img = img.convert('L') # 灰度
threshold = 150 # 二值化阈值(可调)
img = img.point(lambda x: 0 if x < threshold else 255, '1')
二值化后,字符与背景对比更清晰,Tesseract 识别率提升。
7. OCR 识别:从"取最长"到"精准匹配"
7.1 Tesseract 配置
我们使用 pytesseract 调用 Tesseract OCR 引擎。关键参数包括:
--psm:页面分割模式。6:将图片视为一个统一的文本块。7:视为单行文本。8:视为单个单词。
我们尝试多种模式,取最佳结果。
-c tessedit_char_whitelist=...:限制识别字符集为字母和数字,避免识别出标点。
7.2 初始错误:盲目取最长
最初,我采用"取识别结果中长度最长"的策略,认为越长越完整。但验证码实际只有 4 位,OCR 有时会误把背景噪点识别为字符,导致长度变为 5 或 6,提交时必然失败。
7.3 改进策略:优先 4 位
我们修改函数,在多个识别结果中优先挑选长度恰好等于 4 的;如果没有,则选长度最接近 4 的(如 3 或 5),并截断到 4 位。
这样能最大程度保证提交格式正确。
最终 OCR 函数:
python
def get_captcha_auto():
# ... 获取图片并预处理 ...
configs = [
r'--oem 3 --psm 6 -c tessedit_char_whitelist=0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz',
r'--oem 3 --psm 7 ...',
r'--oem 3 --psm 8 ...'
]
candidates = []
for cfg in configs:
text = pytesseract.image_to_string(img, config=cfg)
cleaned = re.sub(r'[^A-Za-z0-9]', '', text) # 只保留字母数字
if cleaned:
candidates.append(cleaned)
# 优先 4 位
for cand in candidates:
if len(cand) == 4:
return cand
# 否则取最接近 4 的
if candidates:
best = min(candidates, key=lambda x: abs(len(x) - 4))
return best[:4] if len(best) > 4 else best
return ""
8. 提交数据并处理响应
8.1 POST 请求结构
python
resp = session.post(DATA_URL, json={
'captcha_input': captcha,
'page_num': page_num,
'challenge_type': CHALLENGE_TYPE
})
8.2 响应分析
200且success: true→ 返回page_data(数字列表)。200且success: false、error含"验证码" → 验证码错误,需重试。405→ 会话失效,需重新初始化。- 其他
4xx/5xx→ 网络或服务器问题,等待后重试。

9. "永不放弃"的重试机制
9.1 为什么需要无限重试?
OCR 识别并非 100% 准确,即使采用优先 4 位策略,仍可能识别错误(如字母 O 和数字 0 混淆)。我们不能让一次失败导致整页丢失,因此设计无限循环,直到成功获取数据。
9.2 指数退避(Exponential Backoff)
为避免短时间内大量请求导致服务器压力或 IP 被封,我们采用失败后等待时间逐渐增加的策略:
- 初始等待
INITIAL_DELAY = 2秒。 - 每次失败后
delay *= 2,上限MAX_DELAY = 15秒。 - 成功获取后,重置延迟为初始值(用于下一页)。
9.3 处理 405 的特殊逻辑
如果遇到 405,说明会话可能过期(比如 Cookie 无效或服务器重启),此时我们主动调用 init_challenge() 重新建立会话。
9.4 核心循环代码(简化)
python
def fetch_page_forever(page_num):
delay = INITIAL_DELAY
while True:
# 先尝试不带验证码的 POST
resp = session.post(DATA_URL, json={'page_num': page_num, 'challenge_type': CHALLENGE_TYPE})
if resp.status_code == 200 and resp.json().get('success'):
return resp.json()['page_data']
elif '验证码' in resp.json().get('error', ''):
# 需要验证码,进入识别流程
pass
elif resp.status_code == 405:
init_challenge()
time.sleep(2)
continue
else:
time.sleep(delay)
delay = min(delay*2, MAX_DELAY)
continue
# 验证码流程
captcha = get_captcha_auto()
if len(captcha) != 4:
time.sleep(delay)
delay = min(delay*2, MAX_DELAY)
continue
resp = session.post(DATA_URL, json={'captcha_input': captcha, 'page_num': page_num, 'challenge_type': CHALLENGE_TYPE})
if resp.status_code == 200 and resp.json().get('success'):
return resp.json()['page_data']
else:
time.sleep(delay)
delay = min(delay*2, MAX_DELAY)
10. 主程序与进度显示
10.1 利用初始化返回的第一页数据
init_challenge() 如果返回 page_data,我们可以直接累加,并从第 2 页开始采集,节省一次请求。
10.2 循环采集
python
def main():
init_data = init_challenge()
total_sum = 0
start_page = 2 if init_data else 1
if init_data:
total_sum += sum(init_data)
print(f"第一页数字: {init_data}, 和: {sum(init_data)}")
for page in range(start_page, 101):
numbers = fetch_page_forever(page)
total_sum += sum(numbers)
print(f"第{page}页完成,累计总和: {total_sum}")
time.sleep(PAGE_INTERVAL) # 页间休息 1.5 秒
print(f"最终总和: {total_sum}")
10.3 运行输出示例
🔧 初始化挑战...
✅ 初始化成功
✅ 初始化已返回第一页数据,直接使用。
第一页数字: [5313, 2942, 2915, 1424, 2306, 4871, 6404, 7557, 7677, 6458], 本页和: 52127
开始采集 100 页...
--- 正在采集第 2 页 ---
第2页 尝试 1...
识别验证码: 3gFJ (长度 4)
数字: [3421, 5132, ...], 本页和: 45678, 累计总和: 97805
等待 1.5 秒...
...
---
所有数字总和: xxxx
时间有点久,就不弄了,OCR的识别方面可以靠训练重新调整精度,这样会更快一些
11. 完整代码
为节省篇幅,完整代码已在前面各节中拼合。如需单文件,可参考文末附录。
12. 总结与进一步优化
12.1 已解决的问题
- ✅ 登录态(Cookie 复用)。
- ✅ 会话初始化(避免 405)。
- ✅ 验证码自动识别(优先 4 位)。
- ✅ 无限重试与指数退避。
12.2 可优化方向
- 更强大的 OCR:Tesseract 对于扭曲、干扰较多的验证码识别率有限,可替换为 EasyOCR(基于深度学习)或接入打码平台(如超级鹰)。
- 动态 Cookie 刷新:若 Cookie 过期,可集成 Selenium 自动登录并重新获取。
- 并发加速:可考虑异步请求或多线程,但需注意控制并发度,避免触发风控。
- 错误日志记录:将失败重试过程写入日志,便于事后分析。
13. 附录:完整代码
python
import requests
import time
import re
from PIL import Image
import pytesseract
import io
# ========== 配置 ==========
BASE_URL = "https://www.spiderdemo.cn"
INIT_URL = BASE_URL + "/captcha/api/cap1_challenge/init/"
CAPTCHA_URL = BASE_URL + "/captcha/api/cap1_challenge/captcha_image/"
DATA_URL = BASE_URL + "/captcha/api/cap1_challenge/page/"
CHALLENGE_TYPE = "cap1_challenge"
# 重试延时策略
INITIAL_DELAY = 2
MAX_DELAY = 15
PAGE_INTERVAL = 1.5
# ========== 会话与 Cookie ==========
session = requests.Session()
session.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36',
'Referer': BASE_URL + '/',
})
# 您的 Cookie(请替换为最新的),去掉前面那个1,这个需要自己去获取,前面有教程
1cookie_str = ""重点注意这里
for item in cookie_str.split('; '):
if '=' in item:
k, v = item.split('=', 1)
session.cookies.set(k, v)
# ========== 初始化挑战(必须!) ==========
def init_challenge():
"""调用初始化接口,建立会话状态"""
print("🔧 初始化挑战...")
resp = session.get(INIT_URL, params={'challenge_type': CHALLENGE_TYPE})
if resp.status_code == 200:
data = resp.json()
if data.get('success'):
print("✅ 初始化成功")
# 如果初始化直接返回了第一页数据,可以保存下来
return data.get('page_data') # 可能为 None
else:
print(f"⚠️ 初始化失败: {data.get('error')}")
return None
else:
print(f"❌ 初始化 HTTP 错误: {resp.status_code}")
return None
# ========== OCR 识别 ==========
def get_captcha_auto():
img_data = session.get(CAPTCHA_URL, params={'t': int(time.time()*1000)}).content
img = Image.open(io.BytesIO(img_data))
img = img.convert('L')
threshold = 150
img = img.point(lambda x: 0 if x < threshold else 255, '1')
configs = [
r'--oem 3 --psm 6 -c tessedit_char_whitelist=0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz',
r'--oem 3 --psm 7 -c tessedit_char_whitelist=0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz',
r'--oem 3 --psm 8 -c tessedit_char_whitelist=0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
]
best = ""
for cfg in configs:
text = pytesseract.image_to_string(img, config=cfg)
text = re.sub(r'\s+', '', text)
if len(text) > len(best):
best = text
return best
# ========== 获取指定页码(永不放弃) ==========
def fetch_page_forever(page_num):
attempt = 0
delay = INITIAL_DELAY
while True:
attempt += 1
print(f" 第{page_num}页 尝试 {attempt}...")
# 先尝试直接POST(可能不需要验证码)
resp = session.post(DATA_URL, json={
'page_num': page_num,
'challenge_type': CHALLENGE_TYPE
})
if resp.status_code == 200:
data = resp.json()
if data.get('success'):
return data['page_data']
elif '验证码' in data.get('error', ''):
# 需要验证码,继续下面的流程
pass
else:
print(f" 直接请求失败: {data.get('error')}")
time.sleep(delay)
delay = min(delay * 2, MAX_DELAY)
continue
elif resp.status_code == 405:
# 如果出现405,可能是会话失效,重新初始化
print(" 收到405,尝试重新初始化...")
init_challenge()
time.sleep(2)
continue
else:
print(f" 直接请求 HTTP {resp.status_code}")
time.sleep(delay)
delay = min(delay * 2, MAX_DELAY)
continue
# ----- 验证码流程 -----
captcha = get_captcha_auto()
print(f" 识别验证码: {captcha}")
if len(captcha) < 4:
print(" 识别结果过短,重试...")
time.sleep(delay)
delay = min(delay * 2, MAX_DELAY)
continue
resp = session.post(DATA_URL, json={
'captcha_input': captcha,
'page_num': page_num,
'challenge_type': CHALLENGE_TYPE
})
if resp.status_code == 200:
data = resp.json()
if data.get('success'):
return data['page_data']
else:
error = data.get('error', '')
print(f" 提交失败: {error}")
if '验证码' in error or 'captcha' in error.lower():
time.sleep(delay)
delay = min(delay * 2, MAX_DELAY)
continue
else:
time.sleep(delay)
delay = min(delay * 2, MAX_DELAY)
continue
elif resp.status_code == 405:
print(" 收到405,重新初始化...")
init_challenge()
time.sleep(2)
continue
else:
print(f" HTTP 错误: {resp.status_code}")
time.sleep(delay)
delay = min(delay * 2, MAX_DELAY)
continue
# ========== 主程序 ==========
def main():
# 1. 初始化(必须)
init_data = init_challenge()
# 如果初始化返回了第一页数据,可以提取并直接加和(但为了统一,我们仍从第1页开始采集)
# 但为了保险,我们可以先获取第一页数据(如果 init 返回了,可以避免重复请求)
total_sum = 0
# 如果 init_data 非空,我们可以直接用,否则正常采集
if init_data:
print("✅ 初始化已返回第一页数据,直接使用。")
total_sum += sum(init_data)
print(f" 第一页数字: {init_data}, 本页和: {sum(init_data)}")
start_page = 2
else:
start_page = 1
print("开始采集 100 页...")
for page in range(start_page, 101):
print(f"\n--- 正在采集第 {page} 页 ---")
numbers = fetch_page_forever(page)
page_sum = sum(numbers)
total_sum += page_sum
print(f" 数字: {numbers}")
print(f" 本页和: {page_sum}, 累计总和: {total_sum}")
if page < 100:
print(f" 等待 {PAGE_INTERVAL} 秒...")
time.sleep(PAGE_INTERVAL)
print("\n" + "="*50)
print(f"所有数字总和: {total_sum}")
print("="*50)
if __name__ == "__main__":
main()
14. 写在最后
爬虫验证码识别是一门"艺术",它融合了 Web 分析、图像处理、OCR、重试策略等多个领域的知识。本文从一个具体挑战出发,带你走过了所有关键环节,并给出了完整可运行的代码。
希望这篇文章能成为你解决类似问题时的"实战手册",让你在面对验证码时不再手足无措,而是从容应对。
为了不影响人家服务器,我还是算一会就暂停把

祝你在爬虫之路上越走越远! 🚀