À 02:14, un flux de prix en temps réel cesse de produire des lignes. Personne n'est alerté. La couche transport ne signale aucune panne, les requêtes aboutissent, et la source concernée répond 200 OK. Selon tous les signaux que comprend le client HTTP, le pipeline est sain. Selon le seul signal qui compte pour le métier, le flux est mort.

Le problème est dans le corps. La source a commencé à servir une page intermédiaire Cloudflare Turnstile au lieu des prix, le client a accepté le 200 comme un succès, et la page de challenge est descendue jusqu'à être analysée comme s'il s'agissait de données. Il y avait un signal de succès au niveau du transport, et aucun signal au niveau du contenu.

Cet article est écrit comme le postmortem que vous rédigeriez après coup. L'incident est représentatif, reconstitué à partir de la façon dont cette panne se présente d'habitude, et la correction n'est volontairement pas un solveur Turnstile. Elle ajoute la détection de challenge à la frontière des réponses, classe chaque réponse en ok, challenge ou hard_block, et fait passer les récupérations concernées par le JavaScript token de Crawlbase, qui rend la page et gère le challenge en amont. Le rôle du pipeline se réduit à remarquer la situation, choisir un transport et vérifier ce qui revient.

En bref
  • Le statut HTTP décrit le transport, pas le contenu. Une page de challenge est un 200 parfaitement valide.
  • Détectez à partir du corps et des en-têtes, dans une fonction pure, pour que la réponse exacte de la panne puisse être rejouée indéfiniment comme test de régression.
  • Classez en trois issues, pas deux. challenge et hard_block signifient tous deux « aucun contenu exploitable », mais appellent des réponses opposées.
  • Vérifiez la présence d'un challenge avant de faire confiance à un 200. Inversez cet ordre et vous reconstruisez l'incident d'origine.
  • Réinspectez la réponse reroutée. Changer de transport ne prouve pas que le challenge a été traité ; cb_status plus un passage propre du détecteur, oui.

Une chronologie représentative

  • T+0 (02:14). Les lignes de prix n'arrivent plus et les tableaux de bord s'aplatissent.
  • T+9m. L'astreinte commence à chercher. Les logs montrent 200 OK pour la source concernée, le transport semble donc correct et l'attention part ailleurs.
  • T+18m. Quelqu'un capture un corps de réponse brut. Le document s'intitule Just a moment... et contient le widget Turnstile.
  • T+24m. Cause racine : le chemin d'ingestion définit le succès par le statut HTTP. Dans ce mode de panne, le challenge arrive en 200, même si 403 est tout aussi fréquent.
  • T+41m. Un correctif en staging détecte le challenge et fait passer l'URL concernée par le JavaScript token.
  • T+58m. Les lignes reviennent. Le corps capturé est conservé comme fixture, pour que la détection puisse être testée sans attendre que la source renvoie un challenge.

Que Cloudflare introduise un challenge n'est pas le constat ; les sources changent leur protection en permanence. Le constat, c'est que le pipeline ne savait pas distinguer une réponse HTTP réussie d'un contenu réussi, ce qui écrasait quatre situations opérationnellement différentes en un seul booléen.

Deux axes, quatre issues, dont une ressemble exactement à un succès. Le statut seul sépare les colonnes, mais la décision dépend des lignes. Un 200 avec des marqueurs de challenge, c'est l'incident : la seule case qu'une vérification de statut laisse passer sans broncher.

La forme de la correction

Quatre pièces, chacune petite à dessein :

  • Un détecteur : une fonction pure de { status, headers, html } qui indique quels signaux de challenge sont présents. Aucun appel réseau, et c'est ce qui rend les réponses sauvegardées rejouables.
  • Un classifieur qui transforme ces signaux en ok, challenge ou hard_block. Un état générique « échec » ne peut pas dire au pipeline quoi faire ensuite.
  • Deux transports avec la même forme de réponse : directFetch, le chemin qui a échoué, et crawlbaseFetch, la remédiation.
  • Un journal d'incident qui consigne la détection, la classification et la remédiation sous forme de chronologie à coller dans le postmortem.
Inspecter le corps, puis décider. Chaque réponse passe par le même détecteur et le même classifieur, qu'elle vienne de la cible en direct ou d'une fixture sauvegardée pendant la panne. Chaque issue a sa propre sortie : stocker, rerouter par le JavaScript token, ou lever le pied sur l'hôte.

La pureté du détecteur est ce qui rapporte le plus longtemps. Dès que la détection est une fonction sur une entrée sauvegardée, les octets exacts de la panne deviennent un test qui s'exécute à chaque changement, et « avons-nous corrigé ? » ne dépend plus de savoir si la source renvoie un challenge à cet instant.

Le code exécutable se trouve dans ScraperHub/solving-cloudflare-turnstile-a-technical-postmortem, avec la version finale sous final/ et des points d'étape sous steps/. Les extraits ci-dessous proviennent de final/.

Environnement

Node.js 18 ou plus récent, pour le fetch intégré, et un compte Crawlbase. La voie de remédiation utilise le JavaScript token. C'est notre propre règle d'escalade pour la Crawling API : une requête en Normal token qui revient vide ou avec 525, ce qui signifie que le challenge n'a pas pu être résolu, doit être relancée avec le JavaScript token, et les pages intermédiaires Turnstile sont précisément le cas pour lequel cette règle existe. Les étapes de détection et de classification n'ont besoin d'aucun token.

bash
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

Étape 1 : configuration

Un seul module lit l'environnement. Le token est facultatif au chargement et requis uniquement sur la voie de remédiation, si bien qu'un incident peut être reproduit et trié avant que quiconque ait récupéré des identifiants.

javascript
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),
};

L'URL de contrôle est le héros discret. C'est une page qui ne devrait jamais déclencher le détecteur ; si elle le fait, la régression est dans votre logique de détection et non chez la cible. Sans elle, un détecteur qui signale tout ressemble exactement à une source qui renvoie des challenges partout.

Étape 2 : un détecteur rejouable

La logique d'origine traitait le statut comme une preuve de contenu. Le détecteur remplace cette hypothèse en examinant le corps et les en-têtes, et renvoie ce qu'il a trouvé plutôt qu'un verdict.

javascript
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,
  };
}

Les marqueurs sont les chaînes qu'un challenge Cloudflare transporte réellement : l'URL du script Turnstile, le conteneur cf-turnstile, les espaces de noms de challenge __cf_chl_ et cf_chl_opt, le titre de la page intermédiaire, et l'en-tête cf-mitigated: challenge. Le détecteur vérifie leur présence et rien de plus. Il ne touche jamais au widget.

Lancez-le sur le corps capturé pendant la panne :

bash
npm run detect -- fixtures/turnstile-challenge.html

Il doit indiquer outcome: "challenge" ainsi que les marqueurs trouvés. Cette fixture est le fichier le plus précieux du dépôt : les pages de challenge changent, et la réponse sauvegardée transforme toute modification future du détecteur en quelque chose dont vous pouvez prouver qu'elle n'a pas discrètement cessé de reconnaître ce qui a fait tomber le flux.

Une limite honnête de la recherche de sous-chaînes a sa place ici. 'Just a moment' et 'cf-turnstile' sont du texte brut, donc toute page qui se contente de les mentionner correspond aussi. Pointez TARGET_URL vers cet article et le détecteur le classera comme challenge, parce que l'article cite chaque marqueur de la liste. C'est exactement le genre de faux positif que l'URL de contrôle et la fixture saine doivent attraper, et en production cela plaide pour exiger un signal structurel, comme l'en-tête ou le script du widget, avant de se fier à une correspondance purement textuelle.

Étape 3 : trois issues, pas deux

Le détecteur dit ce qui est présent. Le classifieur en fait quelque chose sur quoi le pipeline peut agir.

javascript
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;
}

L'ordre est tout le design. La vérification du challenge passe en premier parce qu'une page intermédiaire arrive soit en 200, soit en 403. Si l'on laisse gagner la vérification du 200, le classifieur reproduit l'incident par construction.

Lisez aussi la fin attentivement. Une fois les deux premières branches revenues, les deux dernières ne peuvent plus produire que hard_block : la branche spécifique à Cloudflare et le repli sont d'accord. Toute réponse qui n'est ni un challenge ni un 200 est donc un hard block, y compris un 404, un 500 et une requête qui a expiré et est revenue avec le statut 0. C'est un défaut prudent pour une démo, et la première chose à séparer en production, car un 503 transitoire mérite une nouvelle tentative et un hard block mérite qu'on lève le pied.

  • ok : aucun signal de challenge et un 200. À transmettre à la validation normale du contenu.
  • challenge : un challenge Cloudflare détectable. Rerouter par le JavaScript token.
  • hard_block : aucun contenu exploitable et aucun challenge à confier. Lever le pied sur l'hôte ou changer de stratégie ; relancer la même requête de la même manière n'aidera pas.

Étape 4 : remédier via Crawlbase

La remédiation est un second transport qui renvoie la même forme que directFetch, si bien que la détection, la classification et la journalisation n'ont jamais besoin de savoir quel chemin a produit une réponse. La gestion d'erreurs est retirée de cet extrait.

javascript
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);
  }
}

Deux en-têtes portent le verdict. cb_status est le résultat de Crawlbase pour la récupération ; original_status est ce qu'a répondu la cible. Branchez sur cb_status, et traitez 525 comme un cas à part : il signifie que le challenge n'a pas pu être résolu, ce qui appelle une nouvelle tentative puis un retrait, pas un stockage.

La réponse reroutée repasse par le même détecteur et le même classifieur avant tout stockage. Changer de transport ne prouve pas qu'un challenge a été traité. Le contenu n'est accepté que lorsque cb_status indique un succès et que le corps est exempt de signaux de challenge.

Voici comment le dépôt compagnon relie les deux transports :

javascript
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)`
    );
  }
}

Notez la condition : !== OUTCOME.OK. La démo reroute tout ce qui n'est pas propre, hard blocks compris, ce qui est la façon la plus simple de rétablir le flux. C'est aussi la ligne à changer en premier. Un hard block n'a aucun challenge à confier, donc l'envoyer par la voie de remédiation dépense argent et latence sur une requête qui échouera probablement de la même manière. Une fois les issues en place, donnez à chacune sa propre sortie :

javascript
// 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);
}
Une récupération avec challenge, douze messages. La requête directe renvoie un 403 portant le widget Turnstile, l'inspection répond challenge, et la même URL repart via le JavaScript token. La page rendue revient avec cb_status 200 et elle est inspectée une seconde fois avant tout stockage.

Exécution

bash
npm start

L'outil rejoue les fixtures sauvegardées, vérifie l'URL de contrôle, puis récupère la cible directement, en consignant chaque étape dans la chronologie de l'incident :

text
# 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)

Remarquez ce que cette exécution prouve et ce qu'elle ne prouve pas. La cible en direct a répondu proprement, donc aucun reroutage n'a eu lieu et aucune requête Crawlbase n'a été dépensée. La voie challenge a été exercée par le rejeu de la fixture, et c'est tout l'intérêt de la conserver : la détection est vérifiée de façon déterministe, sans avoir besoin que la source renvoie un challenge au moment précis où vous lancez l'outil. Quand la cible sert vraiment un challenge, le résultat direct est classé challenge, l'URL repart via le JavaScript token, et le corps renvoyé est de nouveau inspecté avant de compter.

Crawling API de Crawlbase

Un vrai rendu navigateur et la gestion des challenges anti-bot au sein de la récupération, avec cb_status pour vous dire si cela a marché. Les requêtes en échec ne sont pas facturées, donc un challenge qui n'a pas pu être traité coûte de la latence plutôt que de l'argent. Commencez gratuitement avec jusqu'à 5 000 requêtes, sans carte.

Considérations pour la production

Conditionnez les écritures à l'issue, jamais au statut. C'est l'unique contrôle qui aurait empêché l'incident. Un 200 est une permission d'inspecter, pas une permission de stocker.

Gardez la fixture, et enrichissez-la. Les pages de challenge changent leur balisage. Chaque nouvelle variante capturée devient un cas de régression de plus, et la fixture saine garde l'autre moitié honnête en prouvant que le détecteur ne signale pas des pages normales.

Séparez les hard blocks des erreurs transitoires. Le repli de la démo réunit les expirations, les réponses 5xx et les vrais blocages en une seule issue. Donnez aux échecs transitoires une relance bornée et réservez le retrait aux vrais blocages, sinon un bref soubresaut en amont mettra en pause un hôte sain.

Ne reroutez pas ce que vous ne pouvez pas corriger. Seul challenge passe par le JavaScript token. Rerouter les hard blocks gonfle le volume et le coût des reroutages sans récupérer de contenu.

Utilisez le token le moins cher qui fonctionne. L'essentiel du trafic relève du chemin direct ou du Normal token. Escalader réponse par réponse, plutôt que de tout mettre par défaut sur le JavaScript token, garde latence et dépense proportionnelles à la part de votre trafic réellement confrontée à des challenges.

Surveillez le taux de reroutage comme un signal. Une hausse soudaine des issues challenge pour un hôte est l'alerte précoce que le pipeline d'origine n'avait jamais eue. Alertez dessus, et vous apprendrez le prochain changement de protection avant que les tableaux de bord ne s'aplatissent.

Gardez un périmètre explicite. Ne récupérez que les sources auxquelles vous êtes autorisé à accéder, et respectez leurs conditions et leurs attentes de débit. L'exemple utilise example.com comme contrôle et le blog Crawlbase comme cible, précisément pour cette raison.

Pour une vue d'ensemble du fonctionnement de ces protections, l'évasion anti-bot moderne vue de l'intérieur couvre la vision système, et éviter la détection des bots par Cloudflare couvre le côté requête.

Conclusion

La panne n'a jamais vraiment concerné Cloudflare. Elle venait d'un pipeline qui laissait le statut HTTP tenir lieu de contenu, si bien qu'au moment où une source a répondu par une page de challenge, la défaillance est devenue invisible.

La correction la rend visible, puis en fait une décision. Un détecteur pur qu'on peut rejouer sur les octets exacts de la panne. Un classifieur à trois issues qui vérifie la présence d'un challenge avant de faire confiance à un 200. Un second transport qui envoie les récupérations avec challenge par le JavaScript token, et une seconde inspection qui refuse de stocker quoi que ce soit tant que le corps n'est pas propre. L'application ne résout jamais rien ; elle remarque, route et vérifie.

Pour le reproduire, créez un compte Crawlbase gratuit et clonez ScraperHub/solving-cloudflare-turnstile-a-technical-postmortem.

Questions fréquentes (FAQ)

Est-ce que cela contourne Cloudflare Turnstile ?

L'application, non. Elle détecte le challenge et confie la requête concernée à la Crawling API avec le JavaScript token, qui rend la page et gère le challenge en amont dans le cadre de la récupération gérée. Il n'y a aucune logique de résolution de challenge dans le pipeline, et les mêmes règles d'accès s'appliquent qu'à toute récupération : uniquement les sources auxquelles vous êtes autorisé à accéder.

Pourquoi le client a-t-il reçu 200 OK pour un challenge ?

Parce que la page intermédiaire est elle-même une réponse HTTP valide. Cloudflare peut servir une page de challenge en 200 ou en 403. Un pipeline qui définit le succès par le code de statut stockera la page de challenge comme des données, ce qui est exactement ce qui s'est passé ici.

Faut-il utiliser le Normal token ou le JavaScript token ?

Commencez par le token le moins cher qui fonctionne et escaladez sur preuve. Notre règle documentée est qu'une requête en Normal token qui renvoie un corps vide ou 525 doit être relancée avec le JavaScript token. Les pages intermédiaires Turnstile sont le cas d'école de cette escalade, c'est pourquoi la voie de remédiation de cet article va directement au JavaScript token.

Comment savoir si le reroutage a réellement fonctionné ?

Deux conditions, toutes deux requises : cb_status indique un succès, et le corps renvoyé passe le détecteur sans signal de challenge. Un 525 signifie que le challenge n'a pas pu être résolu ; relancez, et si cela persiste, levez le pied et enquêtez, car cela signifie généralement que la cible a déployé une nouvelle variante de challenge.

Pourquoi séparer hard_block de challenge ?

Parce qu'ils appellent des actions opposées. Un challenge a quelque chose à confier, donc rerouter peut récupérer le contenu. Un hard block n'a rien, donc rerouter ne fait que répéter un échec à un coût plus élevé. Les réunir dans un seul état « échec » est la manière dont les pipelines finissent soit par relancer des blocages indéfiniment, soit par ne jamais se remettre des challenges.

Commencer à construire

Crawlez n'importe quel site à grande échelle, sans combattre l'infrastructure.

Crawlbase gère les proxies, les empreintes et les CAPTCHA afin que votre équipe livre des pipelines de données au lieu de maintenir la plomberie de crawl. 1 000 requêtes gratuites, sans carte requise.

En libre-service · Sans appel commercial requis · Volumes de crawl entreprise disponibles