爬虫中级——Orc验证码识别

爬虫中级------验证码识别:从零到全自动采集的完整实战

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: truepage_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,包含 successpage_data(数字列表)或错误信息。

为什么不能直接 POST?

直接 POST 不携带验证码,服务器会返回 {"error": "验证码错误"};如果跳过初始化,则返回 405


4. 登录态处理:Cookie 的妙用

4.1 为什么不用模拟登录?

该挑战需要登录才能参与。登录页面(/admin_I/)包含 CSRF Token 和可能的验证码,模拟登录较为复杂。

我们采用更实用的方法:手动登录一次,复制浏览器 Cookie,在 Python 中复用。

4.2 如何获取 Cookie?

  • 在浏览器中登录(用户名 chuxuan,密码 caoyintao123)。
  • 登录后,按 F12 → Application → Cookies → 找到 sessionidcsrftoken(或其他名称)。
  • 将 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 响应分析

  • 200success: true → 返回 page_data(数字列表)。
  • 200success: falseerror 含"验证码" → 验证码错误,需重试。
  • 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、重试策略等多个领域的知识。本文从一个具体挑战出发,带你走过了所有关键环节,并给出了完整可运行的代码。

希望这篇文章能成为你解决类似问题时的"实战手册",让你在面对验证码时不再手足无措,而是从容应对。

为了不影响人家服务器,我还是算一会就暂停把

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

相关推荐
汤米粥4 小时前
Python爬虫——json解析
爬虫·python·json
嘘嘘出差6 小时前
爬虫中级——滑块验证码的应对方法
爬虫
明月_清风20 小时前
robots.txt 完全指南:从入门到精通
后端·爬虫
明月_清风21 小时前
从零写一个"像人"的爬虫:反爬攻防实战指南
后端·爬虫
嘘嘘出差2 天前
从零到精通:爬虫技术进阶路线全解析
爬虫
嘘嘘出差2 天前
Python 爬虫入门实战:爬取豆瓣电影 Top 250
开发语言·爬虫·python
嘘嘘出差2 天前
天气数据监控系统
jvm·爬虫
小花皮猪3 天前
Bright Data CLI 入门教程:命令行实现 Web Scraping 与结构化数据采集
爬虫
Maiko Star3 天前
Python项目实战——网络机器人(爬虫)
爬虫·python·机器人