Um 02:14 liefert ein Echtzeit-Preisfeed plötzlich keine Zeilen mehr. Niemand wird alarmiert. Die Transportschicht meldet keinen Ausfall, Requests laufen durch, und die betroffene Quelle antwortet mit 200 OK. Nach jedem Signal, das der HTTP-Client versteht, ist die Pipeline gesund. Nach dem einzigen Signal, das für das Geschäft zählt, ist der Feed verstummt.
Das Problem steckt im Body. Die Quelle liefert statt Preisen eine Cloudflare-Turnstile-Zwischenseite aus, der Client hat das 200 als Erfolg akzeptiert, und die Challenge-Seite ist stromabwärts gewandert und wie Daten geparst worden. Es gab ein Erfolgssignal auf Transportebene und keinerlei Signal auf Inhaltsebene.
Dieser Beitrag ist als das Postmortem geschrieben, das man danach verfassen würde. Der Vorfall ist repräsentativ, rekonstruiert aus der Art, wie sich dieser Fehler üblicherweise zeigt, und die Lösung ist bewusst kein Turnstile-Solver. Sie führt Challenge-Erkennung an der Antwortgrenze ein, klassifiziert jede Antwort als ok, challenge oder hard_block und leitet Abrufe mit Challenge über das Crawlbase JavaScript Token um, das die Seite rendert und die Challenge stromaufwärts erledigt. Die Aufgabe der Pipeline schrumpft darauf, den Zustand zu bemerken, einen Transport zu wählen und zu prüfen, was zurückkommt.
- HTTP-Status beschreibt den Transport, nicht den Inhalt. Eine Challenge-Seite ist ein völlig gültiges
200. - Erkennen Sie anhand von Body und Headern, in einer reinen Funktion, damit sich genau die Antwort aus dem Ausfall für immer als Regressionstest wiederholen lässt.
- Klassifizieren Sie in drei Ergebnisse, nicht zwei.
challengeundhard_blockbedeuten beide „kein brauchbarer Inhalt“, verlangen aber entgegengesetzte Reaktionen. - Prüfen Sie auf eine Challenge, bevor Sie einem
200vertrauen. Kehren Sie die Reihenfolge um, und Sie bauen den ursprünglichen Vorfall nach. - Prüfen Sie die umgeleitete Antwort erneut. Ein Transportwechsel ist kein Beweis, dass die Challenge erledigt wurde;
cb_statusplus ein sauberer Detektor-Durchlauf ist es.
Eine repräsentative Zeitleiste
- T+0 (02:14). Preiszeilen kommen nicht mehr an, die Dashboards werden flach.
-
T+9m. Die Bereitschaft beginnt zu suchen. Die Logs zeigen
200 OKfür die betroffene Quelle, der Transport wirkt also in Ordnung und die Aufmerksamkeit geht woanders hin. -
T+18m. Jemand zeichnet einen rohen Response-Body auf. Das Dokument trägt den Titel
Just a moment...und enthält das Turnstile-Widget. -
T+24m. Ursache: Der Ingestion-Pfad definiert Erfolg über den HTTP-Status. In diesem Fehlerbild kommt die Challenge als
200, wobei403genauso häufig ist. - T+41m. Ein Fix auf Staging erkennt die Challenge und leitet die betroffene URL über das JavaScript Token um.
- T+58m. Die Zeilen kommen zurück. Der aufgezeichnete Body wird als Fixture aufbewahrt, damit sich die Erkennung testen lässt, ohne zu warten, bis die Quelle erneut eine Challenge liefert.
Dass Cloudflare eine Challenge einführt, ist nicht der Befund; Quellen ändern ihren Schutz ständig. Der Befund ist, dass die Pipeline eine erfolgreiche HTTP-Antwort nicht von erfolgreichem Inhalt unterscheiden konnte und damit vier operativ verschiedene Situationen zu einem einzigen Boolean zusammengefasst hat.
200 mit Challenge-Markern ist der Vorfall: die eine Zelle, die eine Statusprüfung einfach durchwinkt.Die Form der Lösung
Vier Teile, jedes bewusst klein:
- Ein Detektor: eine reine Funktion von
{ status, headers, html }, die meldet, welche Challenge-Signale vorhanden sind. Keine Netzwerkaufrufe, und genau das macht gespeicherte Antworten wiederholbar. - Ein Klassifikator, der diese Signale in
ok,challengeoderhard_blockübersetzt. Ein generischer Zustand „fehlgeschlagen“ kann der Pipeline nicht sagen, was als Nächstes zu tun ist. - Zwei Transporte mit derselben Antwortform:
directFetch, der Pfad, der versagt hat, undcrawlbaseFetch, die Behebung. - Ein Incident-Log, das Erkennung, Klassifizierung und Behebung als Zeitleiste festhält, die Sie direkt ins Postmortem übernehmen können.
Die Reinheit des Detektors zahlt sich am längsten aus. Sobald Erkennung eine Funktion über gespeicherter Eingabe ist, werden genau die Bytes aus dem Ausfall zu einem Test, der bei jeder Änderung läuft, und „haben wir es behoben“ hängt nicht mehr davon ab, ob die Quelle gerade zufällig eine Challenge liefert.
Der lauffähige Code liegt in ScraperHub/solving-cloudflare-turnstile-a-technical-postmortem, mit der fertigen Version unter final/ und gestuften Checkpoints unter steps/. Die Ausschnitte unten stammen aus final/.
Umgebung
Node.js 18 oder neuer, wegen des eingebauten fetch, und ein Crawlbase-Konto. Der Behebungspfad nutzt das JavaScript Token. Das folgt unserer eigenen Eskalationsregel für die Crawling API: Ein Request mit Normal Token, der leer oder mit 525 zurückkommt, was bedeutet, dass die Challenge nicht gelöst werden konnte, sollte mit dem JavaScript Token wiederholt werden, und Turnstile-Zwischenseiten sind genau der Fall, für den diese Regel existiert. Die Schritte Erkennung und Klassifizierung brauchen gar kein Token.
git clone https://github.com/ScraperHub/solving-cloudflare-turnstile-a-technical-postmortem.git cd solving-cloudflare-turnstile-a-technical-postmortem/final npm install cp .env.example .env # then set CRAWLBASE_JS_TOKEN
Schritt 1: Konfiguration
Ein Modul liest die Umgebung. Das Token ist beim Laden optional und nur im Behebungspfad erforderlich, sodass sich ein Vorfall reproduzieren und einordnen lässt, bevor überhaupt jemand Zugangsdaten besorgt hat.
const config = { crawlbaseJsToken: process.env.CRAWLBASE_JS_TOKEN || '', controlUrl: process.env.CONTROL_URL || 'https://example.com', targetUrl: process.env.TARGET_URL || 'https://crawlbase.com/blog', requestTimeoutMs: Number(process.env.REQUEST_TIMEOUT_MS || 20000), };
Die Control-URL ist der stille Held. Sie ist eine Seite, die den Detektor nie auslösen sollte; tut sie es doch, liegt die Regression in Ihrer Erkennungslogik und nicht am Ziel. Ohne sie sieht ein Detektor, der alles meldet, genauso aus wie eine Quelle, die überall Challenges liefert.
Schritt 2: ein wiederholbarer Detektor
Die ursprüngliche Logik behandelte den Status als Beweis für Inhalt. Der Detektor ersetzt diese Annahme, indem er Body und Header betrachtet, und liefert zurück, was er gefunden hat, statt eines Urteils.
const HTML_MARKERS = [ 'challenges.cloudflare.com/turnstile', 'cf-turnstile', '__cf_chl_', 'cf_chl_opt', 'window._cf_chl_opt', 'Just a moment', 'Checking your browser', ]; const CHALLENGE_HEADERS = ['cf-mitigated']; function detect({ status, headers = {}, html = '' }) { const lowerHeaders = {}; for (const [key, value] of Object.entries(headers)) { lowerHeaders[key.toLowerCase()] = String(value).toLowerCase(); } const hitMarkers = HTML_MARKERS.filter((marker) => html.toLowerCase().includes(marker.toLowerCase()) ); const cfMitigated = CHALLENGE_HEADERS.some((h) => lowerHeaders[h]) && (lowerHeaders['cf-mitigated'] || '').includes('challenge'); const servedByCloudflare = (lowerHeaders['server'] || '').includes('cloudflare') || Boolean(lowerHeaders['cf-ray']); const hasTurnstileWidget = hitMarkers.some( (m) => m === 'cf-turnstile' || m === 'challenges.cloudflare.com/turnstile' ); return { status, servedByCloudflare, cfMitigated, hasTurnstileWidget, challengeMarkers: hitMarkers, challengeDetected: cfMitigated || hitMarkers.length > 0, }; }
Die Marker sind die Zeichenketten, die eine Cloudflare-Challenge tatsächlich trägt: die URL des Turnstile-Skripts, der Container cf-turnstile, die Challenge-Namensräume __cf_chl_ und cf_chl_opt, der Titel der Zwischenseite und der Header cf-mitigated: challenge. Der Detektor prüft nur, ob sie vorhanden sind, mehr nicht. Das Widget rührt er nie an.
Führen Sie ihn gegen den während des Ausfalls aufgezeichneten Body aus:
npm run detect -- fixtures/turnstile-challenge.html
Er sollte outcome: "challenge" melden, zusammen mit den gefundenen Markern. Diese Fixture ist die wertvollste Datei im Repository: Challenge-Seiten ändern sich, und die gespeicherte Antwort macht jede künftige Änderung am Detektor zu etwas, bei dem Sie beweisen können, dass er das, was den Feed lahmgelegt hat, nicht stillschweigend übersieht.
Hierhin gehört eine ehrliche Grenze des Substring-Abgleichs. 'Just a moment' und 'cf-turnstile' sind reiner Text, also passt jede Seite, die sie nur erwähnt, ebenfalls. Richten Sie TARGET_URL auf diesen Artikel, und der Detektor klassifiziert ihn als Challenge, weil der Artikel jeden Marker der Liste zitiert. Genau diese Art Fehlalarm sollen die Control-URL und die bekannt gute Fixture abfangen, und im Produktivbetrieb spricht das dafür, ein strukturelles Signal wie den Header oder das Widget-Skript zu verlangen, bevor man einem reinen Textfund vertraut.
Schritt 3: drei Ergebnisse, nicht zwei
Der Detektor sagt, was vorhanden ist. Der Klassifikator macht daraus etwas, womit die Pipeline handeln kann.
const OUTCOME = { OK: 'ok', CHALLENGE: 'challenge', HARD_BLOCK: 'hard_block', }; function classify(signals) { if (signals.challengeDetected) { return OUTCOME.CHALLENGE; } if (signals.status === 200) { return OUTCOME.OK; } if ([403, 429, 503].includes(signals.status) && signals.servedByCloudflare) { return OUTCOME.HARD_BLOCK; } return signals.status === 200 ? OUTCOME.OK : OUTCOME.HARD_BLOCK; }
Die Reihenfolge ist das ganze Design. Die Challenge-Prüfung läuft zuerst, weil eine Zwischenseite entweder als 200 oder 403 kommt. Lässt man die Prüfung auf 200 gewinnen, reproduziert der Klassifikator den Vorfall per Konstruktion.
Lesen Sie auch das Ende genau. Sobald die ersten beiden Zweige zurückgekehrt sind, können die letzten beiden nur noch hard_block liefern: der Cloudflare-spezifische Zweig und der Fallback sind sich einig. Jede Antwort, die weder eine Challenge noch ein 200 ist, ist also ein Hard Block, auch ein 404, ein 500 und ein Request, der ins Timeout lief und mit Status 0 zurückkam. Für eine Demo ist das ein konservativer Standard und das Erste, was man im Produktivbetrieb aufteilen sollte, denn ein vorübergehendes 503 verdient einen Retry und ein Hard Block ein Zurückstecken.
-
ok: keine Challenge-Signale und ein200. An die normale Inhaltsvalidierung weitergeben. -
challenge: eine erkennbare Cloudflare-Challenge. Über das JavaScript Token umleiten. -
hard_block: kein brauchbarer Inhalt und keine Challenge zum Übergeben. Beim Host zurückstecken oder die Strategie ändern; denselben Request auf dieselbe Weise zu wiederholen hilft nicht.
Schritt 4: Behebung über Crawlbase
Die Behebung ist ein zweiter Transport mit derselben Rückgabeform wie directFetch, sodass Erkennung, Klassifizierung und Logging nie wissen müssen, welcher Pfad eine Antwort erzeugt hat. Die Fehlerbehandlung ist in diesem Ausschnitt gekürzt.
async function crawlbaseFetch(url, token) { const started = Date.now(); const endpoint = `https://api.crawlbase.com/?token=${token}&url=${encodeURIComponent(url)}`; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), config.requestTimeoutMs); try { const response = await fetch(endpoint, { signal: controller.signal }); const html = await response.text(); return { transport: 'crawlbase-js', status: Number(response.headers.get('cb_status') || response.status), originalStatus: Number(response.headers.get('original_status') || 0), headers: normalizeHeaders(response.headers), html, latencyMs: Date.now() - started, }; } finally { clearTimeout(timer); } }
Zwei Header tragen das Urteil. cb_status ist das Ergebnis von Crawlbase für den Abruf; original_status ist die Antwort des Ziels. Verzweigen Sie auf cb_status und behandeln Sie 525 als eigenen Fall: Es bedeutet, dass die Challenge nicht gelöst werden konnte, was einen Retry und danach ein Zurückstecken verlangt, kein Speichern.
Die umgeleitete Antwort läuft erneut durch denselben Detektor und Klassifikator, bevor etwas gespeichert wird. Ein Transportwechsel ist kein Beweis, dass eine Challenge erledigt wurde. Der Inhalt wird nur akzeptiert, wenn cb_status Erfolg meldet und der Body frei von Challenge-Signalen ist.
So verbindet das Begleit-Repository die beiden Transporte:
if (directResult.outcome !== OUTCOME.OK) { if (!config.crawlbaseJsToken) { incident.warn('CRAWLBASE_JS_TOKEN not set; cannot run the remediation path.'); } else { incident.fix('Routing target through Crawlbase JavaScript token.'); const viaCrawlbase = await crawlbaseFetch(config.targetUrl, config.crawlbaseJsToken); const crawlbaseResult = inspect(viaCrawlbase); incident.log( crawlbaseResult.outcome === OUTCOME.OK ? 'fix' : 'warn', `target via crawlbase-js -> ${crawlbaseResult.outcome} ` + `(cb_status ${viaCrawlbase.status}, ${viaCrawlbase.latencyMs}ms)` ); } }
Beachten Sie die Bedingung: !== OUTCOME.OK. Die Demo leitet alles um, was nicht sauber ist, Hard Blocks eingeschlossen, und das ist der einfachste Weg, den Feed wiederherzustellen. Es ist auch die Zeile, die man zuerst ändern sollte. Ein Hard Block hat keine Challenge zum Übergeben, also verbrennt der Behebungspfad Geld und Latenz für einen Request, der wahrscheinlich genauso scheitert. Sobald die Ergebnisse existieren, bekommt jedes seinen eigenen Ausgang:
// Production shape, not from the repository: one exit per outcome. switch (directResult.outcome) { case OUTCOME.OK: return store(direct); case OUTCOME.CHALLENGE: return rerouteAndVerify(url); case OUTCOME.HARD_BLOCK: return backOff(new URL(url).host); }
403 mit dem Turnstile-Widget, die Prüfung ergibt challenge, und dieselbe URL geht erneut über das JavaScript Token raus. Die gerenderte Seite kommt mit cb_status 200 zurück und wird ein zweites Mal geprüft, bevor etwas gespeichert wird.Ausführen
npm start
Das Tool spielt die gespeicherten Fixtures ab, prüft die Control-URL und ruft dann das Ziel direkt ab, wobei jeder Schritt als Teil der Incident-Zeitleiste protokolliert wird:
# Turnstile challenge on realtime ingestion T+0.0s [WARN] Replaying saved fixtures from the outage window. T+0.0s [WARN] fixture turnstile-challenge.html -> challenge (markers: ...) T+0.0s [OK] fixture ok-page.html -> ok (no false positive expected) T+0.1s [OK] control https://example.com -> ok (http 200, 133ms) T+1.0s [OK] target https://crawlbase.com/blog via direct -> ok (http 200, 843ms)
Achten Sie darauf, was dieser Lauf beweist und was nicht. Das Live-Ziel hat sauber geantwortet, also gab es keine Umleitung und keinen Crawlbase-Request. Der Challenge-Pfad wurde stattdessen durch das Abspielen der Fixture durchlaufen, und genau dafür hebt man sie auf: Die Erkennung wird deterministisch geprüft, ohne dass die Quelle genau in dem Moment eine Challenge liefern muss, in dem Sie das Tool starten. Liefert das Ziel tatsächlich eine Challenge, wird das direkte Ergebnis als challenge klassifiziert, die URL geht über das JavaScript Token raus, und der zurückgegebene Body wird erneut geprüft, bevor er zählt.
Echtes Browser-Rendering und Anti-Bot-Challenge-Handling direkt im Abruf, mit cb_status, das Ihnen sagt, ob es geklappt hat. Fehlgeschlagene Requests werden nicht berechnet, eine Challenge, die nicht erledigt werden konnte, kostet also Latenz statt Geld. Kostenlos starten mit bis zu 5.000 Anfragen, ohne Karte.
Überlegungen für den Produktivbetrieb
Schreiben am Ergebnis festmachen, nie am Status. Das ist die eine Kontrolle, die den Vorfall verhindert hätte. Ein 200 ist die Erlaubnis zu prüfen, nicht die Erlaubnis zu speichern.
Die Fixture behalten und erweitern. Challenge-Seiten ändern ihr Markup. Jede neue Variante, die Sie aufzeichnen, wird ein weiterer Regressionsfall, und die bekannt gute Fixture hält die andere Hälfte ehrlich, indem sie belegt, dass der Detektor normale Seiten nicht markiert.
Hard Blocks von vorübergehenden Fehlern trennen. Der Fallback der Demo fasst Timeouts, Antworten mit 5xx und echte Blocks zu einem Ergebnis zusammen. Geben Sie vorübergehenden Fehlern einen begrenzten Retry und reservieren Sie das Zurückstecken für echte Blocks, sonst legt ein kurzes Wackeln stromaufwärts einen gesunden Host still.
Nicht umleiten, was man nicht beheben kann. Nur challenge geht über das JavaScript Token. Hard Blocks umzuleiten treibt Umleitungsvolumen und Kosten hoch, ohne Inhalt zurückzugewinnen.
Das günstigste Token nehmen, das funktioniert. Der meiste Traffic gehört auf den direkten Pfad oder das Normal Token. Pro Antwort zu eskalieren, statt alles standardmäßig auf das JavaScript Token zu legen, hält Latenz und Kosten proportional dazu, wie viel Ihres Traffics tatsächlich Challenges bekommt.
Die Umleitungsrate als Signal beobachten. Ein plötzlicher Anstieg der Ergebnisse challenge für einen Host ist die Frühwarnung, die die ursprüngliche Pipeline nie hatte. Alarmieren Sie darauf, und Sie erfahren von der nächsten Schutzänderung, bevor die Dashboards flach werden.
Den Umfang explizit halten. Rufen Sie nur Quellen ab, für die Sie berechtigt sind, und respektieren Sie deren Bedingungen und Ratenerwartungen. Das Beispiel nutzt example.com als Control und den Crawlbase-Blog als Ziel, genau aus diesem Grund.
Für das größere Bild, wie diese Schutzmechanismen funktionieren, zeigt moderne Anti-Bot-Umgehung von innen die Systemsicht, und Cloudflare-Bot-Erkennung vermeiden zeigt die Ebene der einzelnen Requests.
Fazit
Der Ausfall hatte eigentlich nie mit Cloudflare zu tun. Er lag an einer Pipeline, die den HTTP-Status für Inhalt stehen ließ, sodass der Fehler in dem Moment unsichtbar wurde, in dem eine Quelle mit einer Challenge-Seite antwortete.
Die Lösung macht ihn sichtbar und dann zu einer Entscheidung. Ein reiner Detektor, der sich gegen genau die Bytes aus dem Ausfall abspielen lässt. Ein Klassifikator mit drei Ergebnissen, der auf eine Challenge prüft, bevor er einem 200 vertraut. Ein zweiter Transport, der Abrufe mit Challenge über das JavaScript Token schickt, und eine zweite Prüfung, die nichts speichert, solange der Body nicht sauber ist. Die Anwendung löst nie etwas; sie bemerkt, leitet um und prüft.
Zum Nachbauen legen Sie ein kostenloses Crawlbase-Konto an und klonen Sie ScraperHub/solving-cloudflare-turnstile-a-technical-postmortem.
Häufig gestellte Fragen (FAQ)
Umgeht das Cloudflare Turnstile?
Die Anwendung nicht. Sie erkennt die Challenge und übergibt den betroffenen Request an die Crawling API mit dem JavaScript Token, die die Seite rendert und die Challenge stromaufwärts als Teil des verwalteten Abrufs erledigt. In der Pipeline gibt es keine Logik zum Lösen von Challenges, und es gelten dieselben Zugriffsregeln wie für jeden Abruf: nur Quellen, für die Sie berechtigt sind.
Warum bekam der Client 200 OK für eine Challenge?
Weil die Zwischenseite selbst eine gültige HTTP-Antwort ist. Cloudflare kann eine Challenge-Seite mit 200 oder 403 ausliefern. Eine Pipeline, die Erfolg über den Statuscode definiert, speichert die Challenge-Seite als Daten, und genau das ist hier passiert.
Soll ich das Normal Token oder das JavaScript Token verwenden?
Beginnen Sie mit dem günstigsten Token, das funktioniert, und eskalieren Sie anhand von Belegen. Unsere dokumentierte Regel lautet: Ein Request mit Normal Token, der einen leeren Body oder 525 liefert, sollte mit dem JavaScript Token wiederholt werden. Turnstile-Zwischenseiten sind der Lehrbuchfall für diese Eskalation, weshalb der Behebungspfad in diesem Beitrag direkt zum JavaScript Token geht.
Woran erkenne ich, dass die Umleitung wirklich funktioniert hat?
Zwei Bedingungen, beide erforderlich: cb_status meldet Erfolg, und der zurückgegebene Body besteht den Detektor ohne Challenge-Signale. Ein 525 bedeutet, dass die Challenge nicht gelöst werden konnte; wiederholen Sie, und wenn es anhält, stecken Sie zurück und untersuchen Sie es, denn meist hat das Ziel eine neue Challenge-Variante ausgerollt.
Warum hard_block von challenge getrennt halten?
Weil sie entgegengesetzte Maßnahmen verlangen. Eine Challenge hat etwas zum Übergeben, eine Umleitung kann den Inhalt also zurückholen. Ein Hard Block hat das nicht, eine Umleitung wiederholt also nur einen Fehler zu höheren Kosten. Beide in einen einzigen Zustand „fehlgeschlagen“ zu werfen ist der Weg, auf dem Pipelines entweder Blocks endlos wiederholen oder sich nie von Challenges erholen.
Crawlen Sie jede Website im großen Maßstab, ohne gegen die Infrastruktur zu kämpfen.
Crawlbase übernimmt Proxys, Fingerprints und CAPTCHAs, damit Ihr Team Datenpipelines ausliefert, statt Crawl-Infrastruktur zu pflegen. 1.000 Anfragen kostenlos, keine Karte erforderlich.
