Skip to content

Scrapy guide

Get past captcha pages in Scrapy crawls

A downloader middleware spots captcha pages, solves them with bypass.so off the event loop and posts the answer back through the page’s form. Your spiders stay exactly as they are.

  • Python 3.9+
  • Scrapy 2.11+
  • Downloader middleware
  • Non-blocking

Setup

Before you start

  • Python 3.9 or newer and Scrapy 2.11 or newer
  • requests for the API helper and form2request to resubmit the page’s form
  • A bypass.so API key (create one) with a positive balance

Install

terminal
python -m pip install scrapy requests form2request
scrapy startproject myproject

Store your key

The examples read your key from the BYPASS_API_KEY environment variable, so it never lands in source control.

Shell
export BYPASS_API_KEY="your-api-key"

Check that the key works: curl -H "Authorization: Bearer $BYPASS_API_KEY" https://bypass.so/balance returns your balance.

Overview

How it fits together

A downloader middleware sees every response before your spider does. When a response is a captcha page, it solves the captcha with bypass.so and sends the answer back to the site. Your spider code does not change.

  1. 01

    Spot the captcha

    Each response is checked for a math question, an image captcha or a widget sitekey.

    find_captcha
  2. 02

    Solve off the event loop

    The blocking helper runs in a worker thread while the crawl carries on.

    deferToThread
  3. 03

    Post the answer

    The answer goes into the page’s own form, with the original callback and meta.

    form2request
  4. 04

    Carry on crawling

    The site lets the request through and your callback gets the real page.

    parse

Project layout used below:

files
myproject/
  scrapy.cfg
  myproject/
    bypass_client.py     API helper (step 1)
    middlewares.py       captcha middleware (step 2)
    settings.py          enable it (step 3)
    spiders/
      products.py        your spider (step 4)

Step 1

Add the API helper

This is the same helper as in the Python guide: one solve function that creates a task, polls for the result, raises a BypassError with the API code and enforces a timeout. Save it as myproject/bypass_client.py.

myproject/bypass_client.py
import os
import time

import requests

API_URL = "https://bypass.so"
CLIENT_KEY = os.environ.get("BYPASS_API_KEY", "")


class BypassError(Exception):
    def __init__(self, code, description="", task_id=None):
        super().__init__(f"{code}: {description}")
        self.code = code
        self.description = description
        self.task_id = task_id


def _read(response):
    try:
        data = response.json()
    except ValueError:
        raise BypassError("HTTP_ERROR", f"HTTP {response.status_code} without a JSON body")
    if data.get("errorId"):
        raise BypassError(
            data.get("errorCode", "ERROR_UNKNOWN"),
            data.get("errorDescription", ""),
            data.get("taskId"),
        )
    return data


def solve(task_type, params, timeout=120, poll_every=2.0):
    if not CLIENT_KEY:
        raise BypassError("ERROR_KEY_INVALID", "set the BYPASS_API_KEY environment variable")
    body = {"clientKey": CLIENT_KEY, "type": task_type, **params}
    data = _read(requests.post(f"{API_URL}/createTask", json=body, timeout=30))
    task_id = data["taskId"]
    deadline = time.monotonic() + timeout
    while data.get("status") != "ready":
        if time.monotonic() > deadline:
            raise TimeoutError(f"task {task_id} was not ready after {timeout}s")
        time.sleep(poll_every)
        data = _read(requests.get(
            f"{API_URL}/getTaskResult/{task_id}",
            params={"clientKey": CLIENT_KEY},
            timeout=30,
        ))
    solution = dict(data.get("solution") or {})
    solution["taskId"] = task_id
    return solution


def token_of(solution):
    return solution.get("gRecaptchaResponse") or solution.get("token") or solution.get("text")


def balance():
    headers = {"Authorization": f"Bearer {CLIENT_KEY}"}
    return _read(requests.get(f"{API_URL}/balance", headers=headers, timeout=30))


def report_bad(task_id):
    headers = {"Authorization": f"Bearer {CLIENT_KEY}"}
    return _read(requests.post(
        f"{API_URL}/reportBad",
        params={"task_id": task_id},
        headers=headers,
        timeout=30,
    ))

Step 2

Add the captcha middleware

find_captcha decides whether a response is a captcha page and what to send. process_response solves it and returns a new request that posts the answer through the page’s form. Pages without a captcha pass straight through.

myproject/middlewares.py
import logging

from form2request import form2request
from scrapy.utils.defer import maybe_deferred_to_future
from twisted.internet.threads import deferToThread

from myproject.bypass_client import BypassError, solve, token_of

logger = logging.getLogger(__name__)

# task type, CSS selector of the widget, form field that receives the token
WIDGETS = [
    ("TurnstileTask", ".cf-turnstile[data-sitekey]", "cf-turnstile-response"),
    ("RecaptchaV2Task", ".g-recaptcha[data-sitekey]", "g-recaptcha-response"),
]


class BypassCaptchaMiddleware:
    """Detects captcha pages, solves them with bypass.so and resubmits the form."""

    def __init__(self, settings):
        self.max_attempts = settings.getint("BYPASS_MAX_ATTEMPTS", 2)
        self.math_selector = settings.get("BYPASS_MATH_SELECTOR", "#captcha-question::text")
        self.image_selector = settings.get("BYPASS_IMAGE_SELECTOR", "img.captcha::attr(src)")
        self.answer_field = settings.get("BYPASS_ANSWER_FIELD", "captcha")

    @classmethod
    def from_crawler(cls, crawler):
        return cls(crawler.settings)

    def find_captcha(self, response):
        """Return (task_type, params, field, form_xpath) for a captcha page, else None."""
        if not hasattr(response, "css"):  # images, PDFs and other binary bodies
            return None
        for task_type, selector, field in WIDGETS:
            sitekey = response.css(selector).attrib.get("data-sitekey")
            if sitekey:
                params = {"websiteURL": response.url, "websiteKey": sitekey}
                return task_type, params, field, "//form[.//*[@data-sitekey]]"
        answer_form = f'//form[.//*[@name="{self.answer_field}"]]'
        question = response.css(self.math_selector).get()
        if question:
            return "MathCaptchaTask", {"captchaText": question.strip()}, self.answer_field, answer_form
        src = response.css(self.image_selector).get()
        if src and src.startswith("data:image/"):
            image_b64 = src.split(",", 1)[1]
            return "ImageToTextTask", {"imageBase64": image_b64}, self.answer_field, answer_form
        return None

    async def process_response(self, request, response, spider=None):
        found = self.find_captcha(response)
        if found is None:
            return response
        attempt = request.meta.get("bypass_attempt", 0) + 1
        if attempt > self.max_attempts:
            logger.warning("captcha still shown after %d attempts: %s", self.max_attempts, response.url)
            return response

        task_type, params, field, form_xpath = found
        try:
            # solve() blocks while it polls, so run it on the reactor thread pool.
            # Awaiting the Deferred pauses only this response; the crawl keeps going.
            solution = await maybe_deferred_to_future(deferToThread(solve, task_type, params))
        except (BypassError, TimeoutError) as err:
            logger.error("could not solve %s on %s: %s", task_type, response.url, err)
            return response
        logger.info("solved %s on %s (task %s)", task_type, response.url, solution["taskId"])

        form = response.xpath(form_xpath)
        if not form:
            logger.error("no form to submit the answer on %s", response.url)
            return response
        # Fill the answer into the page's own form and send it like a browser would.
        return form2request(form, {field: token_of(solution)}).to_scrapy(
            callback=request.callback,
            errback=request.errback,
            cb_kwargs=request.cb_kwargs,
            meta={**request.meta, "bypass_attempt": attempt, "bypass_task_id": solution["taskId"]},
            priority=request.priority + 1,
            dont_filter=True,
        )

Why a thread, and why it does not block the crawl

The helper waits between polls, which would stall Scrapy’s event loop if called directly. deferToThread runs it in the reactor’s thread pool and returns a Deferred. Because process_response is a coroutine, awaiting that Deferred pauses only this one response while every other request keeps downloading. maybe_deferred_to_future makes the same code work with the asyncio reactor and the classic Twisted reactor.

What it detects

Captchas the middleware detects
CaptchaMathMathCaptchaTaskDetected byText matched by BYPASS_MATH_SELECTORWhere the answer goesPosted as BYPASS_ANSWER_FIELD in the same form
CaptchaImageImageToTextTaskDetected byA data:image/… URI matched by BYPASS_IMAGE_SELECTORWhere the answer goesPosted as BYPASS_ANSWER_FIELD
CaptchareCAPTCHA v2RecaptchaV2TaskDetected by.g-recaptcha[data-sitekey] in the HTMLWhere the answer goesg-recaptcha-response
CaptchaTurnstileTurnstileTaskDetected by.cf-turnstile[data-sitekey] in the HTMLWhere the answer goescf-turnstile-response
CaptchareCAPTCHA v3, hCaptchaComing soonDetected byNot detected yetWhere the answer goesAdd a row to WIDGETS once these types are live

Step 3

Enable it in settings.py

Order 560 places the middleware after the redirect (600) and cookie (700) middlewares, so it sees the final page with its cookies already stored. Adjust the selectors to match the captcha markup of your site.

myproject/settings.py
# Add to myproject/settings.py

DOWNLOADER_MIDDLEWARES = {
    # after RedirectMiddleware (600) and CookiesMiddleware (700), so it sees the final page
    "myproject.middlewares.BypassCaptchaMiddleware": 560,
}

BYPASS_MAX_ATTEMPTS = 2                               # solves per request before giving up
BYPASS_MATH_SELECTOR = "#captcha-question::text"      # text of a math question
BYPASS_IMAGE_SELECTOR = "img.captcha::attr(src)"      # data: URI of an image captcha
BYPASS_ANSWER_FIELD = "captcha"                       # input that takes the text answer

REACTOR_THREADPOOL_MAXSIZE = 20   # solves wait in this pool, alongside DNS lookups

ROBOTSTXT_OBEY = True
CONCURRENT_REQUESTS_PER_DOMAIN = 4
AUTOTHROTTLE_ENABLED = True

Step 4

Run a spider

An ordinary spider. It never mentions the captcha: when a page comes back as a captcha, the middleware solves it and the callback runs on the page behind it.

myproject/spiders/products.py
import scrapy


class ProductsSpider(scrapy.Spider):
    name = "products"
    start_urls = ["https://example.com/products"]

    def parse(self, response):
        for product in response.css(".product"):
            yield {
                "name": product.css("h2::text").get(),
                "price": product.css(".price::text").get(),
            }
        next_page = response.css("a.next::attr(href)").get()
        if next_page:
            yield response.follow(next_page, self.parse)
run it
export BYPASS_API_KEY="your-api-key"
scrapy crawl products -O products.json
in the log
[myproject.middlewares] INFO: solved MathCaptchaTask on https://example.com/products (task 9d6e9cf6-…)
[scrapy.core.engine] DEBUG: Crawled (200) <GET https://example.com/products> (referer: https://example.com/products)
'item_scraped_count': 2,

Try it against a local page

This small server shows a math captcha until the right answer is posted, then sets a cookie and redirects to a product list. Start it, set start_urls to http://127.0.0.1:8765/ and run the spider. Each run solves one math task with your key.

captcha_site.py
"""Local test site: / shows a math captcha until the right answer is posted."""
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs

CAPTCHA = b"""<!doctype html><html><body><h1>Are you human?</h1>
<form method="post" action="/"><label>Solve: <span id="captcha-question">3+4</span></label>
<input name="captcha"><input type="hidden" name="next" value="/"><button>Continue</button></form></body></html>"""
PRODUCTS = b"""<!doctype html><html><body>
<div class="product"><h2>Blue mug</h2><span class="price">9.00</span></div>
<div class="product"><h2>Red mug</h2><span class="price">11.00</span></div></body></html>"""


class H(BaseHTTPRequestHandler):
    def send(self, code, body=b"", headers=()):
        self.send_response(code)
        for k, v in headers:
            self.send_header(k, v)
        self.send_header("Content-Type", "text/html")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self):
        if self.path == "/robots.txt":
            return self.send(404)
        ok = "human=1" in (self.headers.get("Cookie") or "")
        print("SERVER GET", self.path, "->", "products" if ok else "captcha page", flush=True)
        self.send(200, PRODUCTS if ok else CAPTCHA)

    def do_POST(self):
        form = parse_qs(self.rfile.read(int(self.headers["Content-Length"])).decode())
        answer = form.get("captcha", [""])[0]
        print("SERVER POST / captcha=%r" % answer, flush=True)
        if answer == "7":
            self.send(302, headers=[("Location", "/"), ("Set-Cookie", "human=1; Path=/")])
        else:
            self.send(200, CAPTCHA)

    def log_message(self, *a):
        pass


HTTPServer(("127.0.0.1", int(sys.argv[1])), H).serve_forever()
terminal
python captcha_site.py 8765

Reference

Task type reference

What each type expects and returns. Prices are per 1,000 solved tasks; larger top-ups add bonus credit (see pricing).

Task type reference
Task typeMathCaptchaTaskYou sendcaptchaTextYou gettextPrice / 1k$0.35
Task typeImageToTextTaskYou sendimageBase64You gettextPrice / 1k$0.28
Task typeAudioCaptchaTaskYou sendaudioBase64You gettextPrice / 1k$0.35
Task typeRecaptchaV2TaskYou sendwebsiteURLwebsiteKeyYou getgRecaptchaResponse and token, plus tokenFieldPrice / 1k$0.56
Task typeTurnstileTaskYou sendwebsiteURLwebsiteKeyYou gettoken, plus tokenFieldPrice / 1k$0.84
Task typeRecaptchaV3TaskComing soonYou sendwebsiteURLwebsiteKeyactionYou getgRecaptchaResponse and token, plus tokenFieldPrice / 1kAt launch
Task typeHCaptchaTaskComing soonYou sendwebsiteURLwebsiteKeyYou gettoken, plus tokenFieldPrice / 1kAt launch

Errors

Troubleshooting

Errors and fixes
ErrorERROR_KEY_INVALIDSourceHTTP 403What it meansThe key is missing, mistyped or revoked.How to fix itCheck that BYPASS_API_KEY is set in the same shell that runs your code, with no quotes or spaces around it.
ErrorERROR_INSUFFICIENT_CREDITSSourceHTTP 402What it meansYour balance cannot cover the task price.How to fix itTop up from the dashboard. Nothing was charged for this request.
ErrorERROR_TASK_NOT_SUPPORTEDSourceHTTP 200What it meansThe type string is unknown, or the type is still coming soon (reCAPTCHA v3, hCaptcha).How to fix itCopy the exact name, for example TurnstileTask. Names are case-sensitive. Nothing is charged.
ErrorERROR_BAD_REQUESTSourceHTTP 400What it meansThe body is not valid JSON, or a field has the wrong type.How to fix itSend Content-Type: application/json and string values for clientKey, type and the task fields.
ErrorERROR_CAPTCHA_UNSOLVABLESourceHTTP 200What it meansThis task could not be completed.How to fix itYou are not charged. Check the image, sitekey and page URL, then submit a new task.
ErrorERROR_NOT_FOUNDSourceHTTP 404What it meansThe taskId is unknown or belongs to another key.How to fix itPoll with the taskId returned by your own createTask call, using the same key.
ErrorERROR_INTERNALSourceHTTP 500What it meansSomething went wrong on our side.How to fix itRetry after a short pause. The helper’s retry wrapper does this for you.
ErrorCaptcha not detectedSourceHTTP your codeWhat it meansThe page uses other markup, or the widget is added by JavaScript.How to fix itPoint BYPASS_MATH_SELECTOR or BYPASS_IMAGE_SELECTOR at your page. Widgets injected by scripts are not in the HTML Scrapy downloads; use a browser for those pages.
ErrorCaptcha still shown after N attemptsSourceHTTP your codeWhat it meansThe site rejected the answer, or it expects extra fields or a different form.How to fix itCheck which form the answer is posted to, and that cookies are enabled. Report a solved task that was rejected with report_bad.
ErrorCrawl slows down while solvingSourceHTTP your codeWhat it meansSolves wait in the reactor thread pool, which DNS lookups also use.How to fix itRaise REACTOR_THREADPOOL_MAXSIZE above the number of solves you expect at once.
ErrorHelper timeoutSourceyour codeWhat it meansNo result inside your timeout.How to fix itToken types usually need several seconds. Keep at least 60–120 s and retry with a new task.
ErrorHTTP_ERRORSourceyour codeWhat it meansA proxy or network layer answered without JSON.How to fix itRetry with backoff. If it persists, check that your code calls https://bypass.so.

Every error body has the same shape: {"errorId":1,"errorCode":"…","errorDescription":"…"}. Full list in the API reference.

Production

Best practices

  • Keep the key out of code

    Read it from an environment variable or your secret store. Never commit it or ship it to a web page.

  • Solve right before submit

    Tokens are short-lived. Fill the form first, then request the token and submit at once.

  • Cap your concurrency

    Start with 5–10 parallel tasks and raise it while you watch error rates and spend.

  • Log every taskId

    It is the only thing you need to debug a result or report a wrong answer.

  • Match the page exactly

    Send the URL where the widget renders and the sitekey from that page, not from a different form.

  • Report wrong answers

    If a site rejects a solved result, call reportBad with its taskId. The price returns to your balance once.

FAQ

Questions

solve() blocks while it waits for the result, often several seconds. Scrapy runs every request on one event loop, so a blocking call there would freeze the whole crawl. deferToThread moves the wait to a worker thread, and await pauses only the response that hit the captcha.

The thread lets you reuse the tested Python helper unchanged, with no extra HTTP stack to learn. It works with both the asyncio reactor (the default in current Scrapy) and the classic Twisted reactor, because maybe_deferred_to_future adapts the result to whichever one is running. A fully async client would save a thread per solve, which only matters at hundreds of solves in parallel.

The middleware posts the answer through the page’s own form with form2request, keeping the callback, cb_kwargs and meta of the original request. Cookies from the captcha page are sent along by Scrapy’s cookie middleware. If the site redirects back afterwards, your callback receives the real page.

Only if the captcha is in the HTML that Scrapy downloads. If the widget is added by a script, render the page in a browser (see the Playwright guide) or call the site’s form endpoint with a token you request yourself.

No. You pay only when a task comes back ready. When the middleware gives up it logs the reason and hands the captcha page to your spider unchanged, so you can decide what to do.

Policy

Responsible use

Use it where you are allowed to

Automate pages you own, test environments you run, or sites whose terms permit automated access. Do not use bypass.so for fraud, account takeover, spam, scalping, evading bans or collecting personal data without a lawful basis. Keep request rates reasonable and stop when a site asks you to. Our acceptable use policy applies to every key, and keys used for abuse are closed.

Keep going

Next steps