如何用下载器中间件处理 Scrapy 验证码

scrapy captcha - How to Handle a Scrapy CAPTCHA with Downloader Middleware

Scrapy 的验证码处理属于下载器中间件,而不是 spider。中间件能看到每一个响应,所以它可以只在一处识别挑战、完成求解,然后把真正的页面交回给 spider,就像什么都没发生过。你的 parse 方法保持干净。本文会把这个中间件写出来,让求解不阻塞事件循环,并覆盖那些决定它能否扛住规模、还是把爬取拖停的设置。

你需要什么

  • Scrapy 2.x 与 Python 3.10 或更新版本
  • CapSkip 在本地运行,并开启 API server。参见 设置指南
  • Python SDK: pip install capskip

下文均假设求解器位于 127.0.0.1:8080。没有任何数据离开你的机器,这一点在爬虫里比平时更重要:你本来就在发送大量请求,而每次求解都往第三方跑一个来回,会给其中每一个请求都加上延迟。

为什么 Scrapy 验证码处理该放在中间件里

在回调里处理挑战,意味着每个回调都要写同一段分支。漏掉一个,那个 spider 就会把拦截页当成数据默默解析。下载器中间件位于下载器和 spider 之间,因此它先拿到响应,并且可以把它替换掉。

这个位置带来三点好处。检测逻辑只存在于一个函数里,而不是在各个 spider 之间复制粘贴。spider 的回调只会收到真实页面,它们的选择器因此可以放心假设自己预期的标记结构。而当某个站点改了挑战方式,你改的是一个文件,而不是审一遍整个项目。

顺序很重要,而这正是很多人搞错的地方。Scrapy 调用 process_request 的顺序是递增的,而调用 process_response 的顺序则是 递减 的。注册为 585 意味着我们的中间件会先于 RetryMiddleware (550)看到响应。

# settings.py
DOWNLOADER_MIDDLEWARES = {
    "myproject.middlewares.CaptchaMiddleware": 585,
}

# The async client needs Scrapy's asyncio reactor.
TWISTED_REACTOR = "twisted.internet.asyncioreactor.AsyncioSelectorReactor"

第 1 步:检测挑战

两个信号能覆盖大多数站点:状态码,以及正文里的标记。两者都要看,因为不少站点会用 200 返回挑战页。

# Markers for the two widgets you will hit most often.
CAPTCHA_MARKERS = ("g-recaptcha", "cf-turnstile")

def looks_like_captcha(response):
    if response.status in (403, 429):
        return True
    # Only touch the body for HTML; binary responses have no text.
    ctype = response.headers.get("Content-Type", b"").decode()
    if "html" not in ctype:
        return False
    return any(m in response.text for m in CAPTCHA_MARKERS)

让这个函数保持无聊且廉价。爬取中的每一个响应都会经过它。

第 2 步:从页面上取出 sitekey

sitekey 是小组件元素上的一个公开属性。请从你手上已有的响应里读取它,绝不要写死成常量,因为站点会轮换它。

def extract_sitekey(response):
    # reCAPTCHA v2 and invisible both use data-sitekey.
    key = response.css(".g-recaptcha::attr(data-sitekey)").get()
    if key:
        return "recaptcha", key
    key = response.css(".cf-turnstile::attr(data-sitekey)").get()
    if key:
        return "turnstile", key
    return None, None

如果小组件是由 JavaScript 注入的,sitekey 就不会出现在 Scrapy 下载到的 HTML 里。那属于渲染问题而不是求解问题,通常意味着改用正则从 script 标签里取出这个键。

第 3 步:在不阻塞爬取的情况下完成求解

这一步会悄悄毁掉吞吐量。Scrapy 跑在单线程事件循环上。一次求解要花好几秒,所以在中间件里直接调用阻塞客户端,会把这段时间内在途的所有其他请求一起冻住。当 CONCURRENT_REQUESTS 为 16 时,你等于把这 16 个请求全都串行化了。

有两条正确的出路。Python SDK 提供了 AsyncCapSkip,它是真正的异步客户端而不是别名,所以在 asyncio reactor 下你可以直接 await 它:

# pip install capskip
from capskip import AsyncCapSkip

solver = AsyncCapSkip(host="127.0.0.1", port=8080)

# Submit and poll both happen inside this await.
result = await solver.recaptcha(
    sitekey="YOUR_SITEKEY",
    url="https://example.com/page-with-recaptcha",
)
token = result["code"]

如果你还在用经典的 Twisted reactor,就把阻塞客户端丢进线程,然后 await 那个 Deferred:

from capskip import CapSkip
from twisted.internet.threads import deferToThread

solver = CapSkip(host="127.0.0.1", port=8080)

# deferToThread keeps the reactor free while the solve runs.
result = await deferToThread(
    solver.recaptcha,
    sitekey="YOUR_SITEKEY",
    url="https://example.com/page-with-recaptcha",
)

两种方式都可行,因为 Scrapy 允许任何下载器中间件方法是协程函数。用 async def 定义它,剩下的交给 Scrapy。

第 4 步:带着 token 重新提交

光有 token 什么也做不了。它必须按站点自己前端会用的方式回传给站点;对经典表单而言,这个字段叫 g-recaptcha-response.

返回一个新的 Request ,来源于 process_response ,Scrapy 就会重新调度它。有两个细节能避免这里出错: dont_filter=True,因为这个 URL 已经出现过,去重过滤器会把它丢掉;以及在 meta 里放一个计数器,这样即使站点不停地挑战你,也不会陷入死循环。

from scrapy import FormRequest

def resubmit(response, token, tries):
    return FormRequest.from_response(
        response,
        formdata={"g-recaptcha-response": token},
        dont_filter=True,          # the dupe filter has seen this URL
        meta={"captcha_tries": tries + 1},
    )

完整的中间件

# myproject/middlewares.py
import logging

from capskip import (
    AsyncCapSkip, ApiException, NetworkException, TimeoutException)
from scrapy import FormRequest
from scrapy.exceptions import IgnoreRequest

logger = logging.getLogger(__name__)
MAX_CAPTCHA_TRIES = 2


class CaptchaMiddleware:
    def __init__(self):
        self.solver = AsyncCapSkip(host="127.0.0.1", port=8080)

    async def process_response(self, request, response, spider):
        if not looks_like_captcha(response):
            return response

        tries = request.meta.get("captcha_tries", 0)
        if tries >= MAX_CAPTCHA_TRIES:
            raise IgnoreRequest("captcha not cleared: %s" % request.url)

        kind, sitekey = extract_sitekey(response)
        if not sitekey:
            return response          # not a shape we handle

        try:
            if kind == "turnstile":
                result = await self.solver.turnstile(
                    sitekey=sitekey, url=response.url)
            else:
                result = await self.solver.recaptcha(
                    sitekey=sitekey, url=response.url)
        except (ApiException, NetworkException, TimeoutException) as e:
            logger.warning("solve failed for %s: %s", request.url, e)
            return response

        logger.info("solved %s captcha for %s", kind, request.url)

        return FormRequest.from_response(
            response,
            formdata={"g-recaptcha-response": result["code"]},
            dont_filter=True,
            meta={**request.meta, "captcha_tries": tries + 1},
        )

SDK 会抛出四种异常类型: ValidationException, NetworkException, ApiExceptionTimeoutException。上面用到的三种是运行时会发生的;而 ValidationException 表示你的参数不对,它应该在开发阶段大声失败,而不是被吞掉。失败时返回原始响应,可以让流水线的其余部分决定怎么办,这比让 spider 崩掉要好。

真正重要的设置

设置原因
CONCURRENT_REQUESTS_PER_DOMAIN调低它。撞上验证码通常是速率信号,求解得更快并不能解决你被挑战的原因
DOWNLOAD_DELAY 加上 AUTOTHROTTLE_ENABLED比求解更便宜。每一个你避开的挑战都不花钱
COOKIES_ENABLED必须保持开启。通过挑战后拿到的放行 cookie,正是让下一个请求不再被挑战的东西
RETRY_TIMES与你的验证码计数器互相独立。两个上限要分开,否则它们会相乘

第三行是最该记住的。如果 cookie 被关掉,每个请求看起来都像第一次访问,你就会永远在求解同一个挑战。人们反馈的大多数 Scrapy 验证码死循环,最后都正是这个原因。

在 spider 内部处理图片验证码

有些站点在登录表单上用的是普通的扭曲文字图片。这里不涉及 sitekey,所以更简单:把图片下载下来,然后把字节以 data URI 的形式传进去。

import base64
import scrapy
from capskip import CapSkip

solver = CapSkip(host="127.0.0.1", port=8080)

class LoginSpider(scrapy.Spider):
    def parse_captcha_image(self, response):
        # response.body is the raw image, fetched with session cookies.
        b64 = base64.b64encode(response.body).decode()
        result = solver.normal("data:image/png;base64," + b64)
        return result["code"]     # the text on the image

normal() 也接受文件路径或远程 URL。在 Scrapy 里你想要的是 data URI 形式,因为这张图片通常只在请求它的那个会话里才能正确渲染。注意图片验证码不支持代理,代理只适用于 reCAPTCHA、Turnstile 和极验(GeeTest)。

常见的 Scrapy 验证码错误

症状原因修复
爬取吞吐量骤降在事件循环上执行了阻塞式求解使用 AsyncCapSkipdeferToThread
重新提交的请求从来没有跑起来去重过滤器把它丢掉了添加 dont_filter=True
同一个页面永远在挑战cookie 被禁用,或者 meta 没有向下传递启用 cookie,并把 request.meta 合并进新请求
ERROR_GOOGLEKEYsitekey 过期或被写死每次都从实时响应里读取
NetworkException 每次求解都出现CapSkip 没有运行,或端口不一致启动应用,在 Settings 里检查端口

API 可能返回的每一个错误字符串都列在 API 文档。Scrapy 自己的 下载器中间件参考文档 完整说明了排序规则。

常见问题

这在 Scrapy 默认的 reactor 上能用吗?

能,但只有 deferToThread 那种写法可以。 AsyncCapSkip 是 asyncio 客户端,所以需要把 TWISTED_REACTOR 设为 asyncio reactor。两种做法都能让爬取继续跑下去。

我撞上的每一个 Scrapy 验证码都该求解吗?

不该。突然出现一整面挑战,说明你的爬取模式被标记了。先放慢速度。硬求解过去只是在治标,通常还会换来更严的封锁。

我可以让求解走和请求相同的代理吗?

可以,适用于 reCAPTCHA、Turnstile 和极验(GeeTest)。把 proxy={"type": "HTTPS", "uri": "user:[email protected]:3128"} 传给求解调用,这样 token 就会从页面看到的同一个出口 IP 生成。

如果我同时用了代理中间件,这个中间件该放在哪?

process_response 而言放在它下面,也就是用更大的数字。代理中间件作用于请求,我们的作用于响应。设为 585 时,它会在重试中间件看到响应之前运行。

小结

Scrapy 验证码是一个由五部分组成的问题:在中间件里检测挑战,从实时响应里读取 sitekey,在不阻塞 reactor 的前提下完成求解,带着 dont_filter=True重新提交,并在 meta里给重试设上限。把这几点做对,spider 就能保持干净,因为整件事由一个文件负责。

想看全局,可以了解 CapSkip 如何嵌入爬虫,见 网络爬虫验证码识别工具 页面、 Python 集成指南,或在 reCAPTCHA v2 识别 页面了解小组件本身的细节。CapSkip 是一个 验证码识别工具 ,运行在你自己的机器上,所以把它加进爬取流程不会多出一次网络跳转。