Wir haben kürzlich einen vollständigen Frage-und-Antwort-Korpus des Stack-Exchange-Netzwerks aufgebaut: jede Site, jede Frage, jede Antwort, jeder Kommentar, als Trainingsdaten für die LLM-Arbeit eines Kunden. Rund 33 Millionen Threads über 360+ Sites, ausgeliefert als JSONL, wobei jeder Post-Body von HTML nach Markdown konvertiert wurde.

Dieser Beitrag führt durch das Engineering: wie wir das Netzwerk als abrufbare Einheiten modelliert und den Job dimensioniert haben, bevor wir uns darauf festgelegt haben, die Datenqualitätsfallen, die erst mit echten Daten sichtbar werden, ein Content-Lizenz-Bug, den wir in einem frühen Sample ausgeliefert haben und empirisch widerlegen mussten, und wo die Crawling API sich in einem solchen Job bezahlt macht. Die Zahlen sind echte Messwerte aus der Erhebung; Kundendetails wurden entfernt.

Das Deliverable: vollständige Threads, keine Seiten

Die erste Designentscheidung ist die Liefereinheit. Seiten sind das, was man abruft; sie sind nicht das, was irgendjemand konsumieren möchte. Die natürliche Einheit für Q&A-Daten ist der zusammengesetzte Thread: ein Datensatz mit der Frage, allen ihren Antworten und allen Kommentaren zu beidem, samt der Metadaten, mit denen ein Abnehmer filtern und gewichten kann (Scores, Tags, Zeitstempel, Marker für akzeptierte Antworten).

Seiten rein, Threads raus. Listing-Seiten treiben die Discovery, Fragenseiten tragen den Inhalt, Expansions-Calls sammeln lange Antwort- und Kommentar-Tails ein. Alles läuft durch die Crawling API in einen Assembler, der pro JSONL-Zeile einen vollständigen Thread ausgibt.

Ein Thread pro JSONL-Zeile:

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": [ ... ]
    }
  ]
}

Vier Designregeln haben uns später gerettet:

  • Niemals stilles Kürzen. Threads werden vollständig ausgeliefert; der größte, den wir validiert haben, hatte 105 Antworten. Wenn Sie irgendetwas auf eine Teilmenge reduzieren müssen (wir mussten es, in frühen Format-Samples), führen Sie explizite *_total- und *_included-Felder mit. Ein Datenkunde wird Ihr Sample gegen die Live-Site diffen; sorgen Sie dafür, dass das, was er findet, dokumentiertes Verhalten ist und keine Überraschung.
  • Kommentare hängen auf beiden Ebenen. Fragenkommentare und Kommentare pro Antwort sind verschiedene Listen. In unserem ersten Format-Sample haben wir nur die Fragenkommentare ausgeliefert. Von den rund 1.050 Kommentaren in einem Sample mit 8 Threads standen mehr als 900 an Antworten. Leicht zu übersehen, peinlich, wenn man darauf hingewiesen wird.
  • Gelöschte Benutzer erhalten einen definierten Platzhalter ({"user_id": null, "display_name": "[deleted]", "reputation": null}), keine Null-artige Suppe, die jeder Abnehmer anders behandelt.
  • Jede Antwort bekommt einen Permalink. https://<site>/a/<answer_id> ist stabil und günstig zu konstruieren; Attribution und Stichproben brauchen ihn beide.

Den Job dimensionieren: das Request-Modell

Alles wird über die Crawling API eingesammelt: ein einzelner GET pro Seite, wobei Proxy-Rotation, Retries und JavaScript-Rendering (falls nötig) hinter dem Endpunkt erledigt werden.

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

Stack-Exchange-Fragenseiten sind serverseitig gerendertes HTML, der Standard-Token (ohne JavaScript-Rendering) reicht also aus, und das hält den Job schnell und günstig. Eine Erhebung über das gesamte Netzwerk zerfällt in vier Arten von Fetch-Einheiten:

Fetch Treiber Volumen
Fragen-Listing-Seiten (Discovery) 33M Fragen bei 50 pro Seite ~0.7M
Fragenseiten Eine pro Frage: die Frage, bis zu 30 Antworten, jeweils die ersten 5 Kommentare ~33M
Zusätzliche Antwortseiten Threads mit mehr als 30 Antworten (selten) ~0.1 bis 0.3M
Kommentar-Expansionen Posts mit mehr als 5 Kommentaren, je ein zusätzlicher Call ~4 bis 16M

Gesamt: rund 38 bis 50 Millionen Requests. Die eine weiche Position sind die Kommentar-Expansionen. Wie viele Posts versteckte Kommentare haben, weiß man erst, wenn man nachsieht; wir haben sie deshalb als Spanne dimensioniert und auf der kommerziellen Seite die Schätzung gedeckelt, statt eine Präzision vorzutäuschen, die wir nicht hatten. Den Job vor dem Lauf zu dimensionieren, mit ausgewiesener Unsicherheit, ist der größte Teil dessen, was ein belastbares Angebot von einer Vermutung unterscheidet.

Warum überhaupt eine Proxy-Schicht? Stack Exchange ist nicht aggressiv anti-bot, aber jeder Crawler mit einer einzelnen IP läuft lange vor 33 Millionen Seiten in die Drosselung. Beim Scoping bekamen wir 429er innerhalb der ersten etwa 55 schnellen Requests von einer IP. Im Produktionsmaßstab braucht man Rotation, Backoff und Retry-Buchführung, und genau diese Schicht ersetzt die Crawling API.

Der Zähler entspricht der gelieferten Arbeit

Eine nützliche Abrechnungseigenschaft ergibt sich aus der Architektur: Nur erfolgreiche Abrufe (HTTP 200) zählen; Fehlschläge und Retries sind kostenlos. Bei einem Job in der Größenordnung von zig Millionen Requests ist das der Unterschied zwischen der Abrechnung von Ergebnissen und der Abrechnung von Versuchen.

Von HTML zu Markdown, und die Fallen

LLM-Teams wollen Markdown, nicht HTML. Die Konvertierung klingt trivial und ist es nicht. Drei Fallen, in die wir mit echten Daten geraten sind:

Falle 1: naive Regex-Konverter fressen Code

Stack-Exchange-Bodies sind voll von <pre><code>-Blöcken mit Zeilen wie #include <iostream>, und <iostream> sieht für eine Strip-Tags-Regex exakt wie ein HTML-Tag aus. Unser erster Quick-and-dirty-Konverter löschte stillschweigend die Includes aus einer der bekanntesten C++-Fragen der Site. Verwenden Sie einen echten DOM-basierten Konverter (wir haben markdownify benutzt) und prüfen Sie die Treue anschließend gezielt an codelastigen Samples:

python
from markdownify import markdownify as md

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

Anchor-Tags, die anderes Markup umschließen, können als [text]([label](url)) serialisiert werden. Wir scannen nach dem Muster und kollabieren es im Post-Processing:

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

Danach vor der Auslieferung null Vorkommen über den gesamten Korpus hinweg asserten. "Wir haben den Konverter gefixt" ist eine Behauptung; ein korpusweiter Scan, der null zurückliefert, ist ein Fakt.

Falle 3: LaTeX nicht "reparieren"

Mathe-Sites (math.stackexchange, stats, physics) führen LaTeX inline: $\frac{\textrm{d}y}{\textrm{d}x}$. Bewahren Sie es Byte für Byte. Es ist gültig, dicht und genau das, worauf Modelle gut trainieren. Der Fehler ist ein gut gemeinter Aufräumdurchlauf, der "seltsame Symbole" entfernt. Machen Sie die LaTeX-Erhaltung zu einer expliziten, ausgewiesenen Eigenschaft des Korpus und testen Sie darauf.

Der Lizenz-Bug: auf das falsche Datum geschlüsselt

Jeder Stack-Exchange-Post ist Creative-Commons-lizenziert, und die Version der Lizenz hängt davon ab, wann der Inhalt beigesteuert wurde:

Revisionsdatum Lizenz
Vor 2011-04-08 CC BY-SA 2.5
2011-04-08 bis 2018-05-02 CC BY-SA 3.0
Am oder nach dem 2018-05-02 CC BY-SA 4.0

Wir hängen content_license an jede Frage, jede Antwort und jeden Kommentar, zusammen mit Autoren-Attribution und einem Permalink: die Bausteine, die CC-BY-SA-Attribution braucht. Bei einer Handvoll sehr alter Posts fehlt die Lizenz in den Quelldaten, also haben wir sie aus der Datumstabelle abgeleitet. Unser erster Durchlauf schlüsselte die Ableitung auf das Erstellungsdatum des Posts. Ein aufmerksamer Reviewer entdeckte eine Frage von 2008, bearbeitet 2023, ausgezeichnet mit CC BY-SA 2.5, und fragte, welches Datum die Regel tatsächlich verwendet.

Die Lizenz gehört zur Revision, nicht zum Post. Eine Frage von 2008, bearbeitet 2023, trägt CC BY-SA 4.0, nicht 2.5. Über den Validierungsausschnitt hinweg entsprachen 331 von 331 quell-lizenzierten Posts der Schlüsselung nach letzter Bearbeitung; von den 137 Posts, die in einer Lizenz-Ära erstellt und in einer anderen bearbeitet wurden, entsprach exakt null der Schlüsselung nach Erstellungsdatum.

Statt aus der Dokumentation zu argumentieren, haben wir es gegen die Daten getestet. Über 1.384 Posts, die sehr wohl eine Quelllizenz tragen, haben wir beide Hypothesen geprüft. Alle 331 quell-lizenzierten Posts im Validierungsausschnitt entsprachen der Last-Edit-Regel, einschließlich aller 137 Posts, die in einer Lizenz-Ära erstellt und in einer anderen bearbeitet wurden, von denen exakt null der Schlüsselung nach Erstellungsdatum entsprach. Die Lizenz gehört zur aktuellen Revision, sie schlüsselt also auf das Datum der letzten Bearbeitung, mit Rückfall auf das Erstellungsdatum bei nie bearbeiteten Posts.

Zwei Lektionen. Erstens die technische: Lizenzen aus last_edit_date or creation_date ableiten, niemals aus creation_date allein. Zweitens die Meta-Lektion: Wenn ein Datenabnehmer die Semantik eines Feldes anzweifelt, ist die Antwort, die die Diskussion beendet, ein empirischer Test über den Korpus, keine Fundstelle in der Dokumentation.

Semantik, die Sie dokumentieren müssen

Felder, die selbsterklärend aussehen und es nicht sind:

  • is_answered bedeutet nicht "hat eine akzeptierte Antwort". Es ist wahr, wenn die Frage eine akzeptierte Antwort oder irgendeine positiv bewertete Antwort hat. Wenn Sie das nicht dokumentieren, wird es jemand downstream falsch lesen. Wir haben außerdem accepted_answer_id (nullable) auf Fragenebene ergänzt, damit "hat eine akzeptierte Antwort" kein Durchsuchen des Answers-Arrays erfordert.
  • View-Zahlen auf Seiten sind gerundet ("Viewed 2.0m times"); exakte Ganzzahlen kommen aus strukturierten Quellen. Wenn Ihre Pipeline beides mischt, geben Sie an, welche Variante ein Feld trägt.
  • Der Korpus lebt. Posts werden laufend bearbeitet, gelöscht, geschlossen und geschützt. Liefern Sie die Status-Flags (closed_date, closed_reason, protected_date, locked_date) mit und versehen Sie alles mit Zeitstempeln in ISO-8601 UTC, damit Abnehmer über die Wahrheit zum Snapshot-Zeitpunkt schlussfolgern können.

QA vor der Auslieferung

Jede Behauptung oben wird zu einer Assertion, die gegen das ausgelieferte Artefakt läuft:

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

Das Muster, das uns über das ganze Projekt hinweg am besten gedient hat: Liefern Sie früh ein kleines Sample, lassen Sie den Abnehmer es gegen die Live-Site diffen, und behandeln Sie jede Abweichung, die er findet, als Schema-Verbesserung. Unser Format durchlief drei Sample-Iterationen, bevor ein einziges Byte des vollständigen Korpus erhoben wurde, was weit günstiger ist, als dieselben Probleme nach 33 Millionen Threads zu entdecken.

Fazit

  • Modellieren Sie die Site als Fetch-Einheiten und dimensionieren Sie den Job (mit ausgewiesener Unsicherheit), bevor Sie ihn laufen lassen.
  • Liefern Sie Domänen-Einheiten (Threads), keine Fetch-Einheiten (Seiten), und kürzen Sie niemals stillschweigend.
  • HTML zu Markdown ist ein Treueproblem, keine Formatierungsfrage: DOM-basierte Konvertierung, Artefakt-Scans, LaTeX unangetastet.
  • Hängen Sie Lizenz, Attribution und einen Permalink an jeden Post und schlüsseln Sie abgeleitete Lizenzen auf das Datum der letzten Bearbeitung. Validieren Sie Semantik empirisch, wenn sie angezweifelt wird.
  • Dokumentieren Sie die Felder, die lügen (is_answered), und asserten Sie alles Versprochene gegen das Artefakt, das Sie tatsächlich ausliefern.

Die Erhebungsschicht für all das (Rotation, Retries, Rendering, Abrechnung nur bei Erfolg) ist die Crawling API; das Korpus-Engineering darüber ist der Teil, der genuin Ihnen gehört. Wenn Ihre Pipeline über einen Prototyp hinaus überleben muss, behandelt unser Leitfaden zum Skalieren von Web-Scraping-Projekten die Produktionsthemen, und wenn das Ziel ein KI-Workflow statt einer JSONL-Übergabe ist, sehen Sie, wie wir Forschungsdatensätze mit dem Web MCP Server aufbauen.

Crawlbase Crawling API

Ein GET pro Seite, in jedem Maßstab. Rotierende Residential-IPs, Retries, Backoff und optionales JavaScript-Rendering sitzen hinter einem einzigen Endpunkt, und nur erfolgreiche Abrufe zählen gegen Ihr Kontingent; ein Job mit 50 Millionen Requests wird also nach gelieferten Seiten abgerechnet, nicht nach Versuchen. Holen Sie sich Ihren Token und starten Sie im Free-Tier.

Häufig gestellte Fragen

Wie viele Requests braucht es, um ganz Stack Exchange einzusammeln?

Rund 38 bis 50 Millionen für einen vollständigen Durchlauf: etwa 0,7 Millionen Listing-Seiten für die Discovery, 33 Millionen Fragenseiten, ein paar Hunderttausend zusätzliche Antwortseiten für Threads mit mehr als 30 Antworten und 4 bis 16 Millionen Kommentar-Expansions-Calls. Die Zahl der Expansionen ist die eine echte Unbekannte vor dem Crawl; dimensionieren Sie sie deshalb als Spanne mit Deckel statt als falsche Punktschätzung.

Warum zusammengesetzte Threads statt roher Seiten liefern?

Weil Abnehmer auf Konversationen trainieren, nicht auf HTML-Dokumenten. Ein JSONL-Datensatz pro Thread, mit der Frage, jeder Antwort und jedem Kommentar samt Scores, Tags, Zeitstempeln und Lizenz, bedeutet, dass ein Abnehmer filtern und gewichten kann, ohne den Zustand selbst wieder zusammenzusetzen. Seiten sind ein Konzept der Abrufzeit; zur Lieferzeit sollten sie verschwunden sein.

Wie bleibt CC-BY-SA-Attribution im Korpusmaßstab korrekt?

Hängen Sie drei Dinge an jeden Post: die content_license (geschlüsselt auf das Datum der letzten Bearbeitung, mit Rückfall auf das Erstellungsdatum bei nie bearbeiteten Posts), den Autor mit definiertem Platzhalter für gelöschte Benutzer und einen stabilen Permalink. Validieren Sie die Lizenzableitung anschließend empirisch gegen Posts mit expliziter Quelllizenz, statt der Dokumentation zu vertrauen.

Geht bei der Konvertierung von HTML zu Markdown Information verloren?

Das kann passieren, und zwar auf eine Weise, die fürs Training zählt. Strip-Tags-Regexes löschen C++-Includes, weil <iostream> wie Markup aussieht, verschachtelte Anchors serialisieren als kaputte Link-Syntax, und übereifrige Aufräumdurchläufe verstümmeln LaTeX. Verwenden Sie einen DOM-basierten Konverter, scannen Sie den gesamten Korpus nach bekannten Artefaktmustern und bewahren Sie LaTeX Byte für Byte.

Jetzt loslegen

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.

Self-Service · Kein Verkaufsgespräch erforderlich · Enterprise-Crawl-Volumen verfügbar