{"id":25047,"date":"2026-08-08T17:17:33","date_gmt":"2026-08-08T17:17:33","guid":{"rendered":"https:\/\/capskip.com\/?p=25047"},"modified":"2026-08-08T17:17:33","modified_gmt":"2026-08-08T17:17:33","slug":"scrapy-captcha-middleware","status":"publish","type":"post","link":"https:\/\/capskip.com\/zh\/scrapy-captcha-middleware\/","title":{"rendered":"\u5982\u4f55\u7528\u4e0b\u8f7d\u5668\u4e2d\u95f4\u4ef6\u5904\u7406 Scrapy \u9a8c\u8bc1\u7801"},"content":{"rendered":"<p>A Scrapy captcha belongs in a downloader middleware, not in your spider. The middleware sees every response, so it can spot the challenge once, solve it, and hand the real page back to the spider as if nothing happened. Your parse methods stay clean. This guide builds that middleware, keeps the solve off the event loop, and covers the settings that decide whether it works at scale or stalls your crawl.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">What you need<\/h2>\n<ul>\n<li>Scrapy 2.x and Python 3.10 or newer<\/li>\n<li>CapSkip running locally, with the API server on. See the <a href=\"https:\/\/capskip.com\/setup-guide\/\">setup guide<\/a><\/li>\n<li>The Python SDK: <code>pip install capskip<\/code><\/li>\n<\/ul>\n<p>Everything below assumes the solver is on <code>127.0.0.1:8080<\/code>. Nothing leaves your machine, which matters more than usual in a crawler: you are already sending a lot of requests, and a per-solve round trip to a third party adds latency to every one of them.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Why Scrapy CAPTCHA handling belongs in a middleware<\/h2>\n<p>Handling the challenge in a callback means every callback needs the same branch. Miss one and that spider silently parses a block page as if it were data. A downloader middleware sits between the downloader and the spider, so it gets the response first and can replace it.<\/p>\n<p>That placement buys you three things. Detection lives in one function instead of being copy-pasted across spiders. The spider&#8217;s callbacks only ever receive real pages, so their selectors are allowed to assume the markup they expect. And when a site changes its challenge, you edit one file rather than auditing a project.<\/p>\n<p>Order matters, and it is the part people get wrong. Scrapy calls <code>process_request<\/code> in increasing order and <code>process_response<\/code> in <em>decreasing<\/em> order. Registering at 585 means our middleware sees responses before <code>RetryMiddleware<\/code> at 550 does.<\/p>\n<pre data-enlighter-language=\"python\" class=\"EnlighterJSRAW\"># settings.py\nDOWNLOADER_MIDDLEWARES = {\n    &quot;myproject.middlewares.CaptchaMiddleware&quot;: 585,\n}\n\n# The async client needs Scrapy's asyncio reactor.\nTWISTED_REACTOR = &quot;twisted.internet.asyncioreactor.AsyncioSelectorReactor&quot;<\/pre>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 1: detect the challenge<\/h2>\n<p>Two signals cover most sites. The status code, and a marker in the body. Check both, because plenty of sites serve the challenge with a 200.<\/p>\n<pre data-enlighter-language=\"python\" class=\"EnlighterJSRAW\"># Markers for the two widgets you will hit most often.\nCAPTCHA_MARKERS = (&quot;g-recaptcha&quot;, &quot;cf-turnstile&quot;)\n\ndef looks_like_captcha(response):\n    if response.status in (403, 429):\n        return True\n    # Only touch the body for HTML; binary responses have no text.\n    ctype = response.headers.get(&quot;Content-Type&quot;, b&quot;&quot;).decode()\n    if &quot;html&quot; not in ctype:\n        return False\n    return any(m in response.text for m in CAPTCHA_MARKERS)<\/pre>\n<p>Keep this function boring and cheap. It runs on every single response in the crawl.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 2: pull the sitekey off the page<\/h2>\n<p>The sitekey is a public attribute on the widget element. Read it from the response you already have, never from a hardcoded constant, because sites rotate them.<\/p>\n<pre data-enlighter-language=\"python\" class=\"EnlighterJSRAW\">def extract_sitekey(response):\n    # reCAPTCHA v2 and invisible both use data-sitekey.\n    key = response.css(&quot;.g-recaptcha::attr(data-sitekey)&quot;).get()\n    if key:\n        return &quot;recaptcha&quot;, key\n    key = response.css(&quot;.cf-turnstile::attr(data-sitekey)&quot;).get()\n    if key:\n        return &quot;turnstile&quot;, key\n    return None, None<\/pre>\n<p>If the widget is injected by JavaScript, the sitekey will not be in the HTML Scrapy downloaded. That is a rendering problem rather than a solving one, and it usually means grabbing the key from the script tag with a regex instead.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 3: solve without blocking the crawl<\/h2>\n<p>This is the step that quietly ruins throughput. Scrapy runs on a single-threaded event loop. A solve takes seconds, so calling the blocking client directly inside your middleware freezes every other request in flight for that whole time. With <code>CONCURRENT_REQUESTS<\/code> at 16, you just serialised all 16.<\/p>\n<p>Two correct ways out. The Python SDK ships <code>AsyncCapSkip<\/code>, which is a real async client rather than an alias, so under the asyncio reactor you can await it directly:<\/p>\n<pre data-enlighter-language=\"python\" class=\"EnlighterJSRAW\"># pip install capskip\nfrom capskip import AsyncCapSkip\n\nsolver = AsyncCapSkip(host=&quot;127.0.0.1&quot;, port=8080)\n\n# Submit and poll both happen inside this await.\nresult = await solver.recaptcha(\n    sitekey=&quot;YOUR_SITEKEY&quot;,\n    url=&quot;https:\/\/example.com\/page-with-recaptcha&quot;,\n)\ntoken = result[&quot;code&quot;]<\/pre>\n<p>If you are still on the classic Twisted reactor, push the blocking client into a thread and await the Deferred:<\/p>\n<pre data-enlighter-language=\"python\" class=\"EnlighterJSRAW\">from capskip import CapSkip\nfrom twisted.internet.threads import deferToThread\n\nsolver = CapSkip(host=&quot;127.0.0.1&quot;, port=8080)\n\n# deferToThread keeps the reactor free while the solve runs.\nresult = await deferToThread(\n    solver.recaptcha,\n    sitekey=&quot;YOUR_SITEKEY&quot;,\n    url=&quot;https:\/\/example.com\/page-with-recaptcha&quot;,\n)<\/pre>\n<p>Both work because Scrapy lets any downloader middleware method be a coroutine function. Define it with <code>async def<\/code> and Scrapy handles the rest.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 4: resubmit with the token<\/h2>\n<p>A token on its own does nothing. It has to go back to the site the way the site&#8217;s own front end would send it, which for a classic form means a field called <code>g-recaptcha-response<\/code>.<\/p>\n<p>Return a new <code>Request<\/code> from <code>process_response<\/code> and Scrapy reschedules it. Two details keep this from going wrong: <code>dont_filter=True<\/code>, because the URL has already been seen and the dupe filter would drop it, and a counter in <code>meta<\/code> so a site that keeps challenging you cannot loop forever.<\/p>\n<pre data-enlighter-language=\"python\" class=\"EnlighterJSRAW\">from scrapy import FormRequest\n\ndef resubmit(response, token, tries):\n    return FormRequest.from_response(\n        response,\n        formdata={&quot;g-recaptcha-response&quot;: token},\n        dont_filter=True,          # the dupe filter has seen this URL\n        meta={&quot;captcha_tries&quot;: tries + 1},\n    )<\/pre>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">The full middleware<\/h2>\n<pre data-enlighter-language=\"python\" class=\"EnlighterJSRAW\"># myproject\/middlewares.py\nimport logging\n\nfrom capskip import (\n    AsyncCapSkip, ApiException, NetworkException, TimeoutException)\nfrom scrapy import FormRequest\nfrom scrapy.exceptions import IgnoreRequest\n\nlogger = logging.getLogger(__name__)\nMAX_CAPTCHA_TRIES = 2\n\n\nclass CaptchaMiddleware:\n    def __init__(self):\n        self.solver = AsyncCapSkip(host=&quot;127.0.0.1&quot;, port=8080)\n\n    async def process_response(self, request, response, spider):\n        if not looks_like_captcha(response):\n            return response\n\n        tries = request.meta.get(&quot;captcha_tries&quot;, 0)\n        if tries &gt;= MAX_CAPTCHA_TRIES:\n            raise IgnoreRequest(&quot;captcha not cleared: %s&quot; % request.url)\n\n        kind, sitekey = extract_sitekey(response)\n        if not sitekey:\n            return response          # not a shape we handle\n\n        try:\n            if kind == &quot;turnstile&quot;:\n                result = await self.solver.turnstile(\n                    sitekey=sitekey, url=response.url)\n            else:\n                result = await self.solver.recaptcha(\n                    sitekey=sitekey, url=response.url)\n        except (ApiException, NetworkException, TimeoutException) as e:\n            logger.warning(&quot;solve failed for %s: %s&quot;, request.url, e)\n            return response\n\n        logger.info(&quot;solved %s captcha for %s&quot;, kind, request.url)\n\n        return FormRequest.from_response(\n            response,\n            formdata={&quot;g-recaptcha-response&quot;: result[&quot;code&quot;]},\n            dont_filter=True,\n            meta={**request.meta, &quot;captcha_tries&quot;: tries + 1},\n        )<\/pre>\n<p>The SDK raises four exception types: <code>ValidationException<\/code>, <code>NetworkException<\/code>, <code>ApiException<\/code> and <code>TimeoutException<\/code>. The three above are the ones that happen at runtime; a <code>ValidationException<\/code> means your parameters are wrong and should fail loudly during development rather than being swallowed. Returning the original response on failure lets the rest of your pipeline decide what to do, which beats crashing the spider.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Settings that actually matter<\/h2>\n<table>\n<thead>\n<tr>\n<th>Setting<\/th>\n<th>Why<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td><code>CONCURRENT_REQUESTS_PER_DOMAIN<\/code><\/td>\n<td>Lower it. Hitting a CAPTCHA is usually a rate signal, and solving faster does not fix the reason you were challenged<\/td>\n<\/tr>\n<tr>\n<td><code>DOWNLOAD_DELAY<\/code> plus <code>AUTOTHROTTLE_ENABLED<\/code><\/td>\n<td>Cheaper than solving. Every challenge you avoid costs nothing<\/td>\n<\/tr>\n<tr>\n<td><code>COOKIES_ENABLED<\/code><\/td>\n<td>Must stay on. The clearance cookie from a solved challenge is what stops the next request being challenged<\/td>\n<\/tr>\n<tr>\n<td><code>RETRY_TIMES<\/code><\/td>\n<td>Independent of your CAPTCHA counter. Keep the two limits separate or they multiply<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>That third row is the one to internalise. If cookies are off, every request looks like a first visit and you will solve the same challenge forever. Most Scrapy captcha loops people report turn out to be exactly this.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Image CAPTCHAs inside a spider<\/h2>\n<p>Some sites use a plain distorted-text image on a login form. There is no sitekey involved, so it is simpler: download the image and pass the bytes as a data URI.<\/p>\n<pre data-enlighter-language=\"python\" class=\"EnlighterJSRAW\">import base64\nimport scrapy\nfrom capskip import CapSkip\n\nsolver = CapSkip(host=&quot;127.0.0.1&quot;, port=8080)\n\nclass LoginSpider(scrapy.Spider):\n    def parse_captcha_image(self, response):\n        # response.body is the raw image, fetched with session cookies.\n        b64 = base64.b64encode(response.body).decode()\n        result = solver.normal(&quot;data:image\/png;base64,&quot; + b64)\n        return result[&quot;code&quot;]     # the text on the image<\/pre>\n<p><code>normal()<\/code> also takes a file path or a remote URL. The data URI form is the one you want in Scrapy, because the image usually only renders correctly for the session that requested it. Note that proxies are not supported for image CAPTCHAs, only for reCAPTCHA, Turnstile and GeeTest.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Common Scrapy CAPTCHA errors<\/h2>\n<table>\n<thead>\n<tr>\n<th>Symptom<\/th>\n<th>Cause<\/th>\n<th>Fix<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Crawl throughput collapses<\/td>\n<td>A blocking solve on the event loop<\/td>\n<td>Use <code>AsyncCapSkip<\/code> or <code>deferToThread<\/code><\/td>\n<\/tr>\n<tr>\n<td>The resubmitted request never runs<\/td>\n<td>The dupe filter dropped it<\/td>\n<td>Add <code>dont_filter=True<\/code><\/td>\n<\/tr>\n<tr>\n<td>Same page challenges forever<\/td>\n<td>Cookies disabled, or <code>meta<\/code> not carried forward<\/td>\n<td>Enable cookies, merge <code>request.meta<\/code> into the new request<\/td>\n<\/tr>\n<tr>\n<td><code>ERROR_GOOGLEKEY<\/code><\/td>\n<td>The sitekey was stale or hardcoded<\/td>\n<td>Read it from the live response every time<\/td>\n<\/tr>\n<tr>\n<td><code>NetworkException<\/code> on every solve<\/td>\n<td>CapSkip is not running or the port differs<\/td>\n<td>Start the app, check the port in Settings<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>Every error string the API can return is listed in the <a href=\"https:\/\/capskip.com\/api-docs\/\">API documentation<\/a>. Scrapy&#8217;s own <a href=\"https:\/\/docs.scrapy.org\/en\/latest\/topics\/downloader-middleware.html\" rel=\"nofollow noopener\" target=\"_blank\">downloader middleware reference<\/a> covers the ordering rules in full.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Frequently asked questions<\/h2>\n<details style=\"border:1px solid #e2e5ee;border-radius:10px;padding:14px 18px;margin:0 0 12px;\">\n<summary style=\"cursor:pointer;\">\n<h3 style=\"font-size:1.15rem;line-height:1.4;display:inline;margin:0;\">Does this work with Scrapy&#8217;s default reactor?<\/h3>\n<\/summary>\n<p style=\"margin:12px 0 0;\">Yes, but only the <code>deferToThread<\/code> variant. <code>AsyncCapSkip<\/code> is an asyncio client, so it needs <code>TWISTED_REACTOR<\/code> set to the asyncio reactor. Both approaches keep the crawl moving.<\/p>\n<\/details>\n<details style=\"border:1px solid #e2e5ee;border-radius:10px;padding:14px 18px;margin:0 0 12px;\">\n<summary style=\"cursor:pointer;\">\n<h3 style=\"font-size:1.15rem;line-height:1.4;display:inline;margin:0;\">Should I solve every Scrapy captcha I hit?<\/h3>\n<\/summary>\n<p style=\"margin:12px 0 0;\">No. A sudden wall of challenges means your crawl pattern got flagged. Slow down first. Solving through it treats the symptom and usually earns you a harder block.<\/p>\n<\/details>\n<details style=\"border:1px solid #e2e5ee;border-radius:10px;padding:14px 18px;margin:0 0 12px;\">\n<summary style=\"cursor:pointer;\">\n<h3 style=\"font-size:1.15rem;line-height:1.4;display:inline;margin:0;\">Can I route the solve through the same proxy as the request?<\/h3>\n<\/summary>\n<p style=\"margin:12px 0 0;\">Yes, for reCAPTCHA, Turnstile and GeeTest. Pass <code>proxy={\"type\": \"HTTPS\", \"uri\": \"user:pass@1.2.3.4:3128\"}<\/code> to the solve call so the token is generated from the same exit IP the page saw.<\/p>\n<\/details>\n<details style=\"border:1px solid #e2e5ee;border-radius:10px;padding:14px 18px;margin:0 0 12px;\">\n<summary style=\"cursor:pointer;\">\n<h3 style=\"font-size:1.15rem;line-height:1.4;display:inline;margin:0;\">Where does the middleware go if I also use a proxy middleware?<\/h3>\n<\/summary>\n<p style=\"margin:12px 0 0;\">Below it in <code>process_response<\/code> terms, which means a higher number. Proxy middlewares act on requests; ours acts on responses. At 585 it runs before the retry middleware sees the response.<\/p>\n<\/details>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Summary<\/h2>\n<p>A Scrapy captcha is a five-part problem: detect the challenge in a middleware, read the sitekey off the live response, solve it without blocking the reactor, resubmit with <code>dont_filter=True<\/code>, and cap the retries in <code>meta<\/code>. Get those right and spiders stay clean, because one file handles the whole thing.<\/p>\n<p>For the wider picture, see how CapSkip fits into a crawler on the <a href=\"https:\/\/capskip.com\/captcha-solver-for-web-scraping\/\">CAPTCHA solver for web scraping<\/a> page, the <a href=\"https:\/\/capskip.com\/python-captcha-solver\/\">Python integration guide<\/a>, or the details of the widget itself on the <a href=\"https:\/\/capskip.com\/recaptcha-v2-solver\/\">reCAPTCHA v2 solver<\/a> page. CapSkip is a <a href=\"https:\/\/capskip.com\/\">captcha solver<\/a> that runs on your own machine, so adding it to a crawl costs you no extra network hop.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>\u628a\u9a8c\u8bc1\u7801\u5904\u7406\u653e\u8fdb\u4e0b\u8f7d\u5668\u4e2d\u95f4\u4ef6\uff0c\u800c\u4e0d\u662f\u6bcf\u4e2a\u722c\u866b\u91cc\u3002\u68c0\u6d4b\u3001\u975e\u963b\u585e\u8bc6\u522b\u3001token \u91cd\u65b0\u63d0\u4ea4\uff0c\u4ee5\u53ca\u771f\u6b63\u91cd\u8981\u7684\u90a3\u51e0\u9879\u8bbe\u7f6e\u3002<\/p>","protected":false},"author":1,"featured_media":25046,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"Scrapy CAPTCHA Handling in a Middleware | CapSkip","rank_math_description":"A Scrapy captcha stops a crawl dead. Here is a downloader middleware that spots the challenge, solves it locally, and retries with the token.","rank_math_focus_keyword":"scrapy captcha","footnotes":""},"categories":[70],"tags":[],"class_list":["post-25047","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-captcha"],"_links":{"self":[{"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/posts\/25047","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/comments?post=25047"}],"version-history":[{"count":1,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/posts\/25047\/revisions"}],"predecessor-version":[{"id":25106,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/posts\/25047\/revisions\/25106"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/media\/25046"}],"wp:attachment":[{"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/media?parent=25047"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/categories?post=25047"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/tags?post=25047"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}