Scrapy-Captcha mit Downloader-Middleware behandeln

Ein Scrapy-Captcha gehört in eine Downloader-Middleware, nicht in Ihren Spider. Die Middleware sieht jede Response, kann die Challenge also einmal erkennen, lösen und dem Spider die echte Seite zurückgeben, als wäre nichts gewesen. Ihre parse-Methoden bleiben sauber. Dieser Leitfaden baut genau diese Middleware, hält das Lösen aus der Event Loop heraus und behandelt die Einstellungen, die darüber entscheiden, ob das Ganze skaliert oder Ihren Crawl ausbremst.
Was Sie brauchen
- Scrapy 2.x und Python 3.10 oder neuer
- CapSkip läuft lokal, mit aktiviertem API server. Siehe Einrichtungsanleitung
- Das Python SDK:
pip install capskip
Alles Weitere geht davon aus, dass der Solver auf 127.0.0.1:8080erreichbar ist. Nichts verlässt Ihren Rechner, was in einem Crawler mehr zählt als sonst: Sie senden ohnehin viele Anfragen, und ein Roundtrip pro Lösung zu einem Drittanbieter verlängert jede einzelne davon.
Warum die Captcha-Behandlung in Scrapy in eine Middleware gehört
Die Challenge im Callback zu behandeln bedeutet, dass jeder Callback denselben Zweig braucht. Übersehen Sie einen, parst dieser Spider eine Sperrseite stillschweigend als Daten. Eine Downloader-Middleware sitzt zwischen Downloader und Spider, bekommt die Response also zuerst und kann sie ersetzen.
Diese Position bringt Ihnen drei Dinge. Die Erkennung lebt in einer Funktion, statt über Spider hinweg kopiert zu werden. Die Callbacks des Spiders erhalten nur echte Seiten, ihre Selektoren dürfen also das Markup voraussetzen, das sie erwarten. Und wenn eine Website ihre Challenge ändert, bearbeiten Sie eine Datei, statt ein Projekt zu durchforsten.
Die Reihenfolge zählt, und genau hier liegt der häufigste Fehler. Scrapy ruft process_request in aufsteigender Reihenfolge auf und process_response in absteigender . Eine Registrierung bei 585 bedeutet, dass unsere Middleware Responses vor RetryMiddleware bei 550 sieht.
# settings.py
DOWNLOADER_MIDDLEWARES = {
"myproject.middlewares.CaptchaMiddleware": 585,
}
# The async client needs Scrapy's asyncio reactor.
TWISTED_REACTOR = "twisted.internet.asyncioreactor.AsyncioSelectorReactor"Schritt 1: die Challenge erkennen
Zwei Signale decken die meisten Websites ab: der Statuscode und ein Marker im Body. Prüfen Sie beides, denn zahlreiche Websites liefern die Challenge mit einem 200 aus.
# 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)Halten Sie diese Funktion langweilig und billig. Sie läuft bei jeder einzelnen Response im Crawl.
Schritt 2: den sitekey aus der Seite ziehen
Der sitekey ist ein öffentliches Attribut am Widget-Element. Lesen Sie ihn aus der Response, die Sie ohnehin schon haben, nie aus einer fest verdrahteten Konstante, denn Websites rotieren ihn.
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, NoneWird das Widget per JavaScript eingefügt, steht der sitekey nicht in dem HTML, das Scrapy heruntergeladen hat. Das ist ein Rendering-Problem, kein Lösungsproblem, und bedeutet meist, den Schlüssel per Regex aus dem script-Tag zu holen.
Schritt 3: lösen, ohne den Crawl zu blockieren
Das ist der Schritt, der den Durchsatz still ruiniert. Scrapy läuft auf einer Single-Thread-Event-Loop. Eine Lösung dauert Sekunden, ein blockierender Client direkt in Ihrer Middleware friert also jede andere laufende Anfrage für diese ganze Zeit ein. Bei CONCURRENT_REQUESTS auf 16 haben Sie damit alle 16 serialisiert.
Zwei korrekte Auswege. Das Python SDK bringt AsyncCapSkipmit, einen echten asynchronen Client statt eines Alias, unter dem asyncio-Reactor können Sie ihn also direkt awaiten:
# 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"]Wenn Sie noch auf dem klassischen Twisted-Reactor sind, schieben Sie den blockierenden Client in einen Thread und awaiten das 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",
)Beides funktioniert, weil Scrapy jede Methode einer Downloader-Middleware als Coroutine zulässt. Definieren Sie sie mit async def und Scrapy erledigt den Rest.
Schritt 4: mit dem Token erneut senden
Ein Token allein bewirkt nichts. Es muss so an die Website zurück, wie es deren eigenes Frontend senden würde, bei einem klassischen Formular also über ein Feld namens g-recaptcha-response.
Geben Sie eine neue Request aus process_response zurück, und Scrapy plant sie neu ein. Zwei Details verhindern, dass das schiefgeht: dont_filter=True, weil die URL bereits gesehen wurde und der Dupe-Filter sie verwerfen würde, und ein Zähler in meta , damit eine Website, die Sie weiter herausfordert, keine Endlosschleife auslösen kann.
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},
)Die vollständige Middleware
# 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},
)Das SDK wirft vier Ausnahmetypen: ValidationException, NetworkException, ApiException und TimeoutException. Die drei oben sind die, die zur Laufzeit auftreten; eine ValidationException bedeutet, dass Ihre Parameter falsch sind, und sollte während der Entwicklung laut scheitern, statt geschluckt zu werden. Bei einem Fehlschlag die ursprüngliche Response zurückzugeben überlässt dem Rest Ihrer Pipeline die Entscheidung, und das ist besser, als den Spider abstürzen zu lassen.
Einstellungen, auf die es wirklich ankommt
| Einstellung | Warum |
|---|---|
CONCURRENT_REQUESTS_PER_DOMAIN | Senken Sie ihn. Auf ein Captcha zu stoßen ist meist ein Rate-Signal, und schneller zu lösen behebt nicht den Grund für die Challenge |
DOWNLOAD_DELAY plus AUTOTHROTTLE_ENABLED | Billiger als Lösen. Jede Challenge, die Sie vermeiden, kostet nichts |
COOKIES_ENABLED | Muss an bleiben. Das Freigabe-Cookie aus einer gelösten Challenge ist das, was die nächste Anfrage vor einer neuen Challenge bewahrt |
RETRY_TIMES | Unabhängig von Ihrem Captcha-Zähler. Halten Sie beide Limits getrennt, sonst multiplizieren sie sich |
Die dritte Zeile ist die, die man verinnerlichen sollte. Sind Cookies aus, sieht jede Anfrage wie ein Erstbesuch aus und Sie lösen dieselbe Challenge endlos. Die meisten Scrapy-Captcha-Schleifen, von denen berichtet wird, sind genau das.
Bild-Captchas innerhalb eines Spiders
Manche Websites verwenden im Login-Formular ein schlichtes Bild mit verzerrtem Text. Hier ist kein sitekey im Spiel, es ist also einfacher: Laden Sie das Bild herunter und übergeben Sie die Bytes als 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 imagenormal() nimmt auch einen Dateipfad oder eine entfernte URL entgegen. In Scrapy wollen Sie die Data-URI-Form, weil das Bild meist nur für die Session korrekt gerendert wird, die es angefordert hat. Beachten Sie, dass Proxys für Bild-Captchas nicht unterstützt werden, nur für reCAPTCHA, Turnstile und GeeTest.
Häufige Scrapy-Captcha-Fehler
| Symptom | Ursache | Beheben |
|---|---|---|
| Der Crawl-Durchsatz bricht ein | Eine blockierende Lösung auf der Event Loop | Verwenden AsyncCapSkip oder deferToThread |
| Die erneut gesendete Anfrage läuft nie | Der Dupe-Filter hat sie verworfen | Hinzufügen dont_filter=True |
| Dieselbe Seite fordert endlos heraus | Cookies deaktiviert, oder meta wird nicht weitergereicht | Cookies aktivieren, request.meta in die neue Anfrage übernehmen |
ERROR_GOOGLEKEY | Der sitekey war veraltet oder fest verdrahtet | Lesen Sie ihn jedes Mal aus der aktuellen Response |
NetworkException bei jeder Lösung | CapSkip läuft nicht oder der Port weicht ab | App starten, Port in den Settings prüfen |
Jede Fehlerzeichenkette, die die API zurückgeben kann, ist in der API-Dokumentationaufgeführt. Scrapys eigene Referenz zur Downloader-Middleware beschreibt die Reihenfolgeregeln vollständig.
Häufig gestellte Fragen
Funktioniert das mit Scrapys Standard-Reactor?
Ja, aber nur die Variante mit deferToThread . AsyncCapSkip ist ein asyncio-Client und braucht deshalb TWISTED_REACTOR auf den asyncio-Reactor gesetzt. Beide Ansätze halten den Crawl in Bewegung.
Sollte ich jedes Scrapy-Captcha lösen, auf das ich stoße?
Nein. Eine plötzliche Wand aus Challenges heißt, dass Ihr Crawl-Muster aufgefallen ist. Drosseln Sie zuerst. Sich durchzulösen kuriert das Symptom und bringt meist eine härtere Sperre ein.
Kann ich die Lösung über denselben Proxy leiten wie die Anfrage?
Ja, für reCAPTCHA, Turnstile und GeeTest. Übergeben Sie proxy={"type": "HTTPS", "uri": "user:[email protected]:3128"} an den Lösungsaufruf, damit das Token von derselben Exit-IP erzeugt wird, die die Seite gesehen hat.
Wohin gehört die Middleware, wenn ich zusätzlich eine Proxy-Middleware nutze?
Darunter, gemessen an process_response , also mit einer höheren Zahl. Proxy-Middlewares wirken auf Requests, unsere auf Responses. Bei 585 läuft sie, bevor die Retry-Middleware die Response sieht.
Zusammenfassung
Ein Scrapy-Captcha ist ein Problem aus fünf Teilen: die Challenge in einer Middleware erkennen, den sitekey aus der aktuellen Response lesen, ohne Blockieren des Reactors lösen, erneut senden mit dont_filter=Trueund die Wiederholungen in metabegrenzen. Machen Sie das richtig, und Spider bleiben sauber, weil eine einzige Datei die ganze Sache erledigt.
Für das größere Bild sehen Sie auf der Seite Captcha-Löser für Web Scraping , wie CapSkip in einen Crawler passt, im Python-Integrationsleitfaden, oder die Details des Widgets selbst auf der Seite reCAPTCHA-v2-Löser . CapSkip ist ein Captcha-Löser , der auf Ihrem eigenen Rechner läuft, ihn einem Crawl hinzuzufügen kostet Sie also keinen zusätzlichen Netzwerk-Hop.
