Nous avons récemment construit un corpus complet de questions-réponses du réseau Stack Exchange : chaque site, chaque question, chaque réponse, chaque commentaire, comme données d'entraînement pour les travaux LLM d'un client. Environ 33 millions de threads répartis sur 360+ sites, livrés en JSONL avec chaque corps de post converti de HTML en Markdown.

Ce billet détaille l'ingénierie : comment nous avons modélisé le réseau en unités de collecte et dimensionné le travail avant de nous engager, les pièges de qualité de données qui n'apparaissent qu'avec de vraies données, un bug de licence de contenu livré dans un échantillon initial qu'il a fallu réfuter empiriquement, et là où la Crawling API justifie sa place dans un travail de ce type. Les chiffres sont des mesures réelles issues de la collecte ; les détails client ont été retirés.

Le livrable : des threads complets, pas des pages

La première décision de conception porte sur l'unité de livraison. Les pages sont ce que vous récupérez ; ce n'est pas ce que quiconque veut consommer. L'unité naturelle pour des données de Q&R est le thread assemblé : un enregistrement contenant la question, toutes ses réponses et tous les commentaires des deux niveaux, avec les métadonnées qui permettent au consommateur de filtrer et de pondérer (scores, tags, horodatages, marqueurs de réponse acceptée).

Des pages en entrée, des threads en sortie. Les pages de listing pilotent la découverte, les pages de question portent le contenu, les appels d'expansion récupèrent les longues traînes de réponses et de commentaires. Tout converge à travers la Crawling API vers un assembleur qui émet un thread complet par ligne JSONL.

Un thread par ligne JSONL :

json
{
  "site": "stackoverflow.com",
  "question_id": 11227809,
  "title": "Why is conditional processing of a sorted array faster...",
  "tags": ["java", "c++", "performance", "cpu-architecture"],
  "score": 27538,
  "view_count": 1990935,
  "answer_count": 26,
  "accepted_answer_id": 11227902,
  "is_answered": true,
  "creation_date": "2012-06-27T13:51:36Z",
  "last_edit_date": "2026-04-08T09:20:07Z",
  "content_license": "CC BY-SA 4.0",
  "closed_date": null,
  "owner": {"user_id": 87234, "display_name": "GManNickG", "reputation": 507167},
  "body_markdown": "In this C++ code, sorting the data *(before the timed region)*...",
  "comments": [ ... ],
  "answers": [
    {
      "answer_id": 11227902,
      "link": "https://stackoverflow.com/a/11227902",
      "is_accepted": true,
      "score": 35287,
      "content_license": "CC BY-SA 4.0",
      "owner": { ... },
      "body_markdown": "**You are a victim of [branch prediction](...) failure.**...",
      "comments": [ ... ]
    }
  ]
}

Quatre règles de conception nous ont sauvés par la suite :

  • Aucune troncature silencieuse, jamais. Les threads sont livrés entiers ; le plus gros que nous ayons validé comptait 105 réponses. Si vous devez sous-échantillonner quoi que ce soit (nous l'avons fait, dans les premiers échantillons de format), ajoutez des champs explicites *_total et *_included. Un client data comparera votre échantillon au site en production ; assurez-vous que ce qu'il trouve est un comportement documenté, pas une surprise.
  • Les commentaires s'attachent aux deux niveaux. Les commentaires de question et les commentaires par réponse sont des listes distinctes. Dans notre premier échantillon de format, nous n'avions livré que les commentaires de question. Sur les quelque 1 050 commentaires d'un échantillon de 8 threads, plus de 900 portaient sur des réponses. Facile à rater, gênant à se faire signaler.
  • Les utilisateurs supprimés reçoivent un placeholder défini ({"user_id": null, "display_name": "[deleted]", "reputation": null}), pas une soupe de valeurs nulles que chaque consommateur gère à sa façon.
  • Chaque réponse reçoit un permalien. https://<site>/a/<answer_id> est stable et peu coûteux à construire ; l'attribution comme les vérifications ponctuelles en ont besoin.

Dimensionner le travail : le modèle de requêtes

Tout est collecté via la Crawling API : un seul GET par page, avec rotation de proxys, retries et rendu JavaScript (au besoin) gérés derrière l'endpoint.

python
import requests

CRAWLBASE = "https://api.crawlbase.com/"
TOKEN = "YOUR_TOKEN"

def fetch(url):
    r = requests.get(CRAWLBASE, params={"token": TOKEN, "url": url}, timeout=90)
    r.raise_for_status()
    return r.text

Les pages de question Stack Exchange sont du HTML rendu côté serveur, donc le token standard (sans rendu JavaScript) suffit, ce qui garde le travail rapide et économique. Une collecte du réseau complet se décompose en quatre types d'unités de collecte :

Collecte Facteur Volume
Pages de listing de questions (découverte) 33M questions à 50 par page ~0,7M
Pages de question Une par question : la question, jusqu'à 30 réponses, les 5 premiers commentaires de chacune ~33M
Pages de réponses supplémentaires Threads de plus de 30 réponses (rare) ~0,1 à 0,3M
Expansions de commentaires Posts de plus de 5 commentaires, un appel supplémentaire chacun ~4 à 16M

Total : environ 38 à 50 millions de requêtes. La seule ligne floue est celle des expansions de commentaires. Impossible de savoir combien de posts ont des commentaires masqués avant d'aller voir, donc nous l'avons dimensionnée comme une fourchette et, côté commercial, plafonné l'estimation plutôt que de feindre une précision que nous n'avions pas. Dimensionner le travail avant de le lancer, avec une incertitude explicite, est l'essentiel de ce qui sépare un devis que vous pouvez assumer d'une simple supposition.

Pourquoi une couche proxy, au fond ? Stack Exchange n'est pas agressivement anti-bot, mais tout crawler mono-IP se heurte au throttling bien avant 33 millions de pages. Pendant le cadrage, nous avons rencontré des 429 dès les 55 premières requêtes rapides environ depuis une même IP. À l'échelle de production, il faut de la rotation, du backoff et une comptabilité des retries, et c'est précisément la couche que remplace la Crawling API.

Le compteur suit le travail livré

Une propriété de facturation utile découle de l'architecture : seules les récupérations réussies (HTTP 200) comptent ; les échecs et les retries sont gratuits. Sur un travail qui se chiffre en dizaines de millions de requêtes, c'est la différence entre facturer des résultats et facturer des tentatives.

De HTML à Markdown, et les pièges

Les équipes LLM veulent du Markdown, pas du HTML. La conversion semble triviale et ne l'est pas. Trois pièges rencontrés avec de vraies données :

Piège 1 : les convertisseurs regex naïfs mangent le code

Les corps de posts Stack Exchange regorgent de blocs <pre><code> contenant des lignes comme #include <iostream>, et <iostream> ressemble exactement à une balise HTML pour une regex de suppression de balises. Notre premier convertisseur vite fait a silencieusement supprimé les includes de l'une des questions C++ les plus célèbres du site. Utilisez un vrai convertisseur basé sur le DOM (nous avons utilisé markdownify), puis vérifiez la fidélité spécifiquement sur des échantillons riches en code :

python
from markdownify import markdownify as md

def to_markdown(html):
    return md(html, heading_style="ATX", bullets="-").strip()

Piège 2 : les artefacts de liens imbriqués

Les balises d'ancre qui enveloppent d'autres balises peuvent se sérialiser en [text]([label](url)). Nous scannons ce motif et le réduisons en post-traitement :

python
import re

NESTED = re.compile(r'\[([^\[\]]*)\]\(\s*\[([^\[\]]*)\]\(([^()\s]+)\)\s*\)')
while True:
    fixed = NESTED.sub(lambda m: f"[{m.group(1) or m.group(2)}]({m.group(3)})", text)
    if fixed == text: break
    text = fixed

Puis vérifiez par assertion zéro occurrence sur l'ensemble du corpus avant de livrer. « Nous avons corrigé le convertisseur » est une affirmation ; un scan du corpus entier qui renvoie zéro est un fait.

Piège 3 : ne « corrigez » pas le LaTeX

Les sites de mathématiques (math.stackexchange, stats, physics) contiennent du LaTeX en ligne : $\frac{\textrm{d}y}{\textrm{d}x}$. Préservez-le octet pour octet. Il est valide, dense, et exactement ce sur quoi les modèles s'entraînent bien. L'erreur, c'est la passe de nettoyage bien intentionnée qui supprime les « symboles bizarres ». Faites de la préservation du LaTeX une propriété explicite et annoncée du corpus, et testez-la.

Le bug de licence : indexé sur la mauvaise date

Chaque post Stack Exchange est sous licence Creative Commons, et la version de la licence dépend du moment où le contenu a été contribué :

Date de révision Licence
Avant le 2011-04-08 CC BY-SA 2.5
Du 2011-04-08 au 2018-05-02 CC BY-SA 3.0
À partir du 2018-05-02 CC BY-SA 4.0

Nous attachons content_license à chaque question, réponse et commentaire, avec l'attribution de l'auteur et un permalien : les éléments dont l'attribution CC BY-SA a besoin. Pour une poignée de posts très anciens, la licence est absente des données sources, donc nous l'avons dérivée de la table des dates. Notre première passe indexait la dérivation sur la date de création du post. Un relecteur attentif a repéré une question de 2008, éditée en 2023, étiquetée CC BY-SA 2.5, et a demandé quelle date la règle utilise réellement.

La licence appartient à la révision, pas au post. Une question de 2008 éditée en 2023 porte CC BY-SA 4.0, pas 2.5. Sur la tranche de validation, 331 posts sur 331 avec licence source correspondaient à l'indexation sur la dernière édition ; sur les 137 posts créés dans une ère de licence et édités dans une autre, exactement zéro correspondait à l'indexation sur la date de création.

Plutôt que d'argumenter à partir de la documentation, nous l'avons testé contre les données. Sur 1 384 posts qui portent effectivement une licence source, nous avons vérifié les deux hypothèses. Les 331 posts sous licence source de la tranche de validation correspondaient tous à la règle de la dernière édition, y compris les 137 posts créés dans une ère de licence et édités dans une autre, dont exactement zéro correspondait à l'indexation sur la date de création. La licence appartient à la révision courante, donc elle s'indexe sur la date de dernière édition, avec repli sur la date de création pour les posts jamais édités.

Deux leçons. D'abord la technique : dérivez les licences de last_edit_date or creation_date, jamais de creation_date seule. Ensuite la méta : quand un consommateur de données conteste la sémantique d'un champ, la réponse qui clôt la conversation est un test empirique sur le corpus, pas une citation.

La sémantique que vous devez documenter

Des champs qui semblent évidents et ne le sont pas :

  • is_answered ne signifie pas « a une réponse acceptée ». Il vaut true quand la question a une réponse acceptée ou n'importe quelle réponse à score positif. Si vous ne le documentez pas, quelqu'un en aval le lira de travers. Nous avons aussi ajouté accepted_answer_id (nullable) au niveau de la question pour que « a une réponse acceptée » ne nécessite aucun parcours du tableau des réponses.
  • Les compteurs de vues sur les pages sont arrondis (« Viewed 2.0m times ») ; les entiers exacts viennent de sources structurées. Si votre pipeline mélange les deux, précisez lequel un champ transporte.
  • Le corpus est vivant. Les posts sont édités, supprimés, fermés et protégés en continu. Livrez les indicateurs d'état (closed_date, closed_reason, protected_date, locked_date) et horodatez tout en ISO-8601 UTC, pour que les consommateurs puissent raisonner sur la vérité au moment de l'instantané.

Le contrôle qualité avant de livrer

Chaque affirmation ci-dessus devient une assertion exécutée contre l'artefact livré :

python
import json

threads = [json.loads(l) for l in open("threads.jsonl")]

assert all(len(t["answers"]) == t["answer_count"] for t in threads)          # complete
assert all(a["link"] and a["content_license"] for t in threads
           for a in t["answers"])                                            # attribution
assert all(c["creation_date"] for t in threads
           for a in t["answers"] for c in a["comments"])                      # timestamps
assert not any(NESTED.search(t["body_markdown"]) for t in threads)            # conversion
acc = lambda t: [a["answer_id"] for a in t["answers"] if a["is_accepted"]]
assert all((t["accepted_answer_id"] is None and not acc(t))
           or acc(t) == [t["accepted_answer_id"]] for t in threads)           # consistency

La pratique qui nous a le mieux servi sur l'ensemble du projet : livrez tôt un petit échantillon, laissez le consommateur le comparer au site en production, et traitez chaque écart qu'il trouve comme une amélioration du schéma. Notre format a traversé trois itérations d'échantillons avant qu'un seul octet du corpus complet ne soit collecté, ce qui coûte bien moins cher que de découvrir les mêmes problèmes après 33 millions de threads.

À retenir

  • Modélisez le site en unités de collecte et dimensionnez le travail (avec une incertitude explicite) avant de le lancer.
  • Livrez des unités métier (threads), pas des unités de collecte (pages), et ne tronquez jamais silencieusement.
  • La conversion de HTML en Markdown est un problème de fidélité, pas un détail de mise en forme : conversion basée sur le DOM, scans d'artefacts, LaTeX laissé intact.
  • Attachez licence, attribution et permalien à chaque post, et indexez les licences dérivées sur la date de dernière édition. Validez la sémantique empiriquement quand elle est contestée.
  • Documentez les champs qui mentent (is_answered), et vérifiez par assertion tout ce que vous avez promis contre l'artefact que vous livrez réellement.

La couche de collecte de tout cela (rotation, retries, rendu, comptabilisation des seuls succès) est la Crawling API ; l'ingénierie de corpus par-dessus est la partie qui vous appartient vraiment. Si votre pipeline doit survivre au-delà du prototype, notre guide sur le passage à l'échelle des projets de web scraping couvre les enjeux de production, et si la destination est un workflow IA plutôt qu'une livraison JSONL, voyez comment nous construisons des datasets de recherche avec le Web MCP Server.

Crawlbase Crawling API

Un GET par page à n'importe quelle échelle. IP résidentielles rotatives, retries, backoff et rendu JavaScript optionnel se trouvent derrière un seul endpoint, et seules les récupérations réussies comptent dans votre quota : un travail de 50 millions de requêtes facture les pages livrées, pas les tentatives. Obtenez votre token et démarrez sur l'offre gratuite.

Questions fréquentes

Combien de requêtes faut-il pour collecter tout Stack Exchange ?

Environ 38 à 50 millions pour une passe complète : à peu près 0,7 million de pages de listing pour la découverte, 33 millions de pages de question, quelques centaines de milliers de pages de réponses supplémentaires pour les threads de plus de 30 réponses, et 4 à 16 millions d'appels d'expansion de commentaires. Le volume d'expansions est la seule vraie inconnue avant de crawler, donc dimensionnez-le comme une fourchette avec un plafond plutôt que comme une fausse estimation ponctuelle.

Pourquoi livrer des threads assemblés plutôt que des pages brutes ?

Parce que les consommateurs entraînent sur des conversations, pas sur des documents HTML. Un enregistrement JSONL par thread, contenant la question, chaque réponse et chaque commentaire avec scores, tags, horodatages et licence, permet au consommateur de filtrer et pondérer sans réassembler l'état lui-même. Les pages sont un concept du moment de la collecte ; elles doivent avoir disparu au moment de la livraison.

Comment garder l'attribution CC BY-SA correcte à l'échelle du corpus ?

Attachez trois éléments à chaque post : la content_license (indexée sur la date de dernière édition, avec repli sur la date de création pour les posts jamais édités), l'auteur avec un placeholder défini pour les utilisateurs supprimés, et un permalien stable. Puis validez la dérivation de licence empiriquement contre les posts qui portent une licence source explicite plutôt que de faire confiance à la documentation.

La conversion de HTML en Markdown perd-elle de l'information ?

Elle le peut, de façons qui comptent pour l'entraînement. Les regex de suppression de balises effacent les includes C++ parce que <iostream> ressemble à du balisage, les ancres imbriquées se sérialisent en syntaxe de lien cassée, et les passes de nettoyage trop zélées abîment le LaTeX. Utilisez un convertisseur basé sur le DOM, scannez le corpus entier à la recherche des motifs d'artefacts connus, et préservez le LaTeX octet pour octet.

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