Scarnox · Blog

Middleware captcha pour Scrapy : résoudre les captchas dans votre spider (2026)

Scrapy repose sur les requêtes et les callbacks, donc un captcha ne s'intègre pas naturellement dans son fonctionnement. La bonne façon de le gérer est un middleware de téléchargement : il voit chaque réponse, repère un défi, obtient un jeton, et laisse le spider soumettre le formulaire avec ce jeton. Ce guide construit ce middleware pour les pages PopularCaptcha, que Scarnox résout aujourd'hui sans proxy.

Commencez avec un crédit d'essai gratuit, sans carte bancaire et testez le code sur une page de test avant de le lancer sur quoi que ce soit d'autre.

Fonctionnement du middleware

Le middleware s'exécute sur chaque réponse :

  1. Si la réponse ne contient pas de data-sitekey, il laisse passer la réponse.
  2. Si elle en contient un, le middleware résout le captcha et renvoie une copie de la même requête, avec le jeton stocké dans meta.
  3. Scrapy relance cette requête. Le middleware voit le jeton dans meta et laisse passer la réponse.
  4. Le spider lit le jeton dans meta et soumet le formulaire avec FormRequest.from_response.

L'appel de résolution s'exécute dans un thread via deferToThread, il ne bloque donc pas le reactor de Scrapy. Les autres requêtes continuent d'avancer pendant que le captcha est résolu.

Réglages du projet

Placez votre clé dans une variable d'environnement, et non dans le code :

# settings.py
import os

SCARNOX_API_KEY = os.environ["SCARNOX_API_KEY"]
SCARNOX_TIMEOUT = 185

DOWNLOADER_MIDDLEWARES = {
    "myproject.middlewares.PopularCaptchaMiddleware": 543,
}

ROBOTSTXT_OBEY = True
DOWNLOAD_DELAY = 3
CONCURRENT_REQUESTS_PER_DOMAIN = 1

Le délai de téléchargement et la concurrence limitée à une requête par domaine sont là volontairement. Ils gardent faible la charge sur le site et réduisent le nombre de captchas rencontrés.

Le middleware

# myproject/middlewares.py
import requests
from scrapy import Request
from scrapy.http import Response
from twisted.internet.threads import deferToThread

API_URL = "https://api.scarnox.com/api/tasks/create"


class PopularCaptchaMiddleware:
    def __init__(self, api_key: str, timeout: int):
        self.api_key = api_key
        self.timeout = timeout

    @classmethod
    def from_crawler(cls, crawler):
        return cls(
            api_key=crawler.settings.get("SCARNOX_API_KEY"),
            timeout=crawler.settings.getint("SCARNOX_TIMEOUT", 185),
        )

    def _solve(self, sitekey: str, page_url: str) -> str:
        r = requests.post(
            API_URL,
            headers={"Authorization": f"Bearer {self.api_key}"},
            json={
                "captcha_type": "PopularCaptchaTaskProxyless",
                "site_key": sitekey,
                "site_url": page_url,
            },
            timeout=self.timeout,  # s'exécute dans un thread de travail, pas dans le reactor
        )
        data = r.json()
        if data.get("errorId") != 0:
            raise RuntimeError(f"{data.get('errorCode')}: {data.get('errorDescription')}")
        return data["solution"]["token"]

    def process_response(self, request: Request, response: Response, spider):
        # Déjà résolu, ou soumission de formulaire que nous avons construite nous-mêmes : laisser passer.
        if request.meta.get("captcha_token") or request.meta.get("skip_captcha"):
            return response

        sitekey = response.css("[data-sitekey]::attr(data-sitekey)").get()
        if not sitekey:
            return response

        spider.logger.info("PopularCaptcha on %s, solving", response.url)

        def retry_with_token(token: str) -> Request:
            # Renvoyer une Request la reprogramme. dont_filter évite le filtre de doublons.
            return request.replace(
                dont_filter=True,
                meta={**request.meta, "captcha_token": token},
            )

        return deferToThread(self._solve, sitekey, response.url).addCallback(retry_with_token)

Scrapy accepte de renvoyer un Deferred depuis process_response. Le callback transforme le jeton en requête relancée, et Scrapy la programme comme n'importe quelle autre.

Le spider

# myproject/spiders/search.py
import scrapy
from scrapy.http import FormRequest


class SearchSpider(scrapy.Spider):
    name = "search"
    start_urls = ["https://search.example.com/search?q=web+scraping"]

    def parse(self, response):
        token = response.meta.get("captcha_token")
        if token and response.css("[data-sitekey]"):
            # Scrapy remplit les champs propres au formulaire ; nous ajoutons le jeton.
            yield FormRequest.from_response(
                response,
                formdata={
                    "h-captcha-response": token,
                    "g-recaptcha-response": token,
                },
                callback=self.parse_results,
                meta={"skip_captcha": True},
                dont_filter=True,
            )
            return

        yield from self.parse_results(response)

    def parse_results(self, response):
        for item in response.css("div.result"):
            yield {
                "title": item.css("h3::text").get(),
                "url": item.css("a::attr(href)").get(),
            }

Les sélecteurs sont des exemples. Remplacez-les par les vrais, issus du site que vous scrapez.

Le drapeau skip_captcha empêche le middleware de résoudre à nouveau la soumission du formulaire. Sans lui, chaque envoi coûterait une nouvelle résolution.

Lancer le spider

export SCARNOX_API_KEY=scarnox_YOURAPIKEY
scrapy crawl search -O results.json

Surveillez le journal. Une ligne du type PopularCaptcha on ..., solving signifie qu'un défi a été trouvé et qu'un jeton a été demandé. Si vous voyez cette ligne sur chaque page, ralentissez. Des défis fréquents indiquent que le site a repéré votre crawl.

Le timing du jeton

Un jeton PopularCaptcha expire au bout d'environ deux minutes. Le middleware demande la relance dès que le jeton arrive, et le spider soumet dans la même chaîne de callbacks. Gardez cette chaîne courte. N'ajoutez ni téléchargements lents ni traitements longs entre le jeton et l'envoi du formulaire. Pour en savoir plus sur cette fenêtre, voir expiration du jeton de captcha.

Erreurs

Une résolution échouée lève une RuntimeError dans le thread. Scrapy la journalise comme une requête en échec. Les résolutions échouées ne sont pas facturées, donc réessayer ne coûte presque rien. Pour une politique de nouvelles tentatives avec des limites, utilisez le RetryMiddleware intégré de Scrapy et enveloppez _solve dans une courte boucle. Notre guide sur la gestion des erreurs du solveur de captcha présente les schémas qui tiennent en production.

Scraper de manière responsable

Pour le contexte juridique, voir le web scraping est-il légal.

Tarifs

Scarnox facture à chaque résolution réussie, et les résolutions échouées sont gratuites. Consultez les tarifs actuels pour les montants. Commencez avec un crédit d'essai gratuit, sans carte bancaire.

Prochaines étapes