Недавно мы собрали полный корпус вопросов и ответов сети Stack Exchange: каждый сайт, каждый вопрос, каждый ответ, каждый комментарий, в качестве обучающих данных для LLM-проекта клиента. Около 33 миллионов тредов с 360+ сайтов, доставленных в формате JSONL, где тело каждого поста конвертировано из HTML в Markdown.

Эта статья разбирает инженерную часть: как мы смоделировали сеть в виде загружаемых единиц и оценили объём работы до её начала, какие ловушки качества данных проявляются только на реальных данных, как мы допустили баг с лицензированием контента в раннем сэмпле и потом опровергали его эмпирически, и где Crawling API действительно окупается в подобной задаче. Цифры взяты из реальных измерений в ходе сбора; детали клиента удалены.

Результат поставки: полные треды, а не страницы

Первое проектное решение: единица поставки. Страницы суть то, что вы загружаете; потреблять их никто не хочет. Естественной единицей для данных «вопрос-ответ» является собранный тред: одна запись, содержащая вопрос, все его ответы и все комментарии к тем и другим, плюс метаданные, позволяющие потребителю фильтровать и взвешивать (баллы, теги, временные метки, отметки принятого ответа).

На входе страницы, на выходе треды. Страницы списков обеспечивают обнаружение, страницы вопросов несут контент, вызовы раскрытия добирают длинные хвосты ответов и комментариев. Всё проходит через Crawling API в сборщик, который выдаёт один полный тред на строку JSONL.

Один тред на строку 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": [ ... ]
    }
  ]
}

Четыре проектных правила, которые позже нас выручили:

  • Никакого тихого усечения, никогда. Треды доставляются целиком; самый крупный из проверенных нами содержал 105 ответов. Если что-то всё же приходится сокращать (мы делали это в ранних сэмплах формата), добавляйте явные поля *_total и *_included. Заказчик данных обязательно сравнит ваш сэмпл с живым сайтом; позаботьтесь о том, чтобы найденное оказалось документированным поведением, а не сюрпризом.
  • Комментарии прикрепляются на обоих уровнях. Комментарии к вопросу и комментарии к каждому ответу хранятся в разных списках. В первом сэмпле формата мы отдали только комментарии к вопросам. Из примерно 1,050 комментариев в сэмпле из 8 тредов больше 900 относились к ответам. Легко упустить, стыдно услышать об этом от клиента.
  • Удалённые пользователи получают определённый плейсхолдер ({"user_id": null, "display_name": "[deleted]", "reputation": null}), а не мешанину из null-подобных значений, которую каждый потребитель обрабатывает по-своему.
  • Каждый ответ получает постоянную ссылку. Адрес https://<site>/a/<answer_id> стабилен и дёшев в построении; он нужен и для атрибуции, и для выборочных проверок.

Оценка объёма работы: модель запросов

Всё собирается через Crawling API: один GET на страницу, а ротация прокси, повторы и рендеринг JavaScript (когда он нужен) выполняются за эндпоинтом.

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 отдаются как серверный HTML, поэтому достаточно стандартного токена (без рендеринга JavaScript), а это делает задачу быстрой и дешёвой. Сбор по всей сети раскладывается на четыре вида загружаемых единиц:

Загрузка Фактор объёма Объём
Страницы списков вопросов (обнаружение) 33M вопросов по 50 на страницу ~0.7M
Страницы вопросов Одна на вопрос: сам вопрос, до 30 ответов, первые 5 комментариев к каждому ~33M
Дополнительные страницы ответов Треды с более чем 30 ответами (редкость) от ~0.1 до 0.3M
Раскрытия комментариев Посты с более чем 5 комментариями, один дополнительный вызов на каждый от ~4 до 16M

Итого: примерно от 38 до 50 миллионов запросов. Единственная нечёткая строка здесь: раскрытия комментариев. Пока не посмотришь, невозможно узнать, у скольких постов есть скрытые комментарии, поэтому мы оценили их диапазоном и на коммерческой стороне ограничили оценку потолком, вместо того чтобы изображать точность, которой у нас не было. Оценка объёма работы до её запуска, с явно заявленной неопределённостью, и есть главное, что отличает смету, за которую можно ручаться, от догадки.

Зачем вообще прокси-слой? Stack Exchange не борется с ботами агрессивно, но любой краулер с одного IP упирается в троттлинг задолго до 33 миллионов страниц. Ещё на этапе оценки мы получили ответы 429 в пределах первых примерно 55 быстрых запросов с одного IP. На продакшен-масштабе нужны ротация, бэкофф и учёт повторов, и это ровно тот слой, который заменяет Crawling API.

Счётчик соответствует выполненной работе

Из архитектуры следует полезное свойство биллинга: учитываются только успешные загрузки (HTTP 200); сбои и повторы бесплатны. На задаче в десятки миллионов запросов это разница между оплатой результата и оплатой попыток.

Из HTML в Markdown, и ловушки по пути

LLM-командам нужен Markdown, а не HTML. Конвертация звучит тривиально, но таковой не является. Три ловушки, в которые мы попали на реальных данных:

Ловушка 1: наивные конвертеры на регулярных выражениях съедают код

Тела постов Stack Exchange полны блоков <pre><code> со строками вроде #include <iostream>, а <iostream> для регулярного выражения, вырезающего теги, выглядит в точности как HTML-тег. Наш первый конвертер, написанный на скорую руку, молча удалил инклюды из одного из самых известных вопросов о C++ на сайте. Используйте настоящий конвертер на основе DOM (мы взяли markdownify), а затем проверяйте точность именно на сэмплах, насыщенных кодом:

python
from markdownify import markdownify as md

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

Ловушка 2: артефакты вложенных ссылок

Теги ссылок, оборачивающие другую разметку, могут сериализоваться как [text]([label](url)). Мы ищем этот паттерн и схлопываем его на постобработке:

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

Затем перед поставкой убедитесь, что по всему корпусу таких вхождений ноль. «Мы починили конвертер» остаётся заявлением; скан всего корпуса, вернувший ноль, является фактом.

Ловушка 3: не «чините» LaTeX

Математические сайты (math.stackexchange, stats, physics) содержат LaTeX прямо в тексте: $\frac{\textrm{d}y}{\textrm{d}x}$. Сохраняйте его байт в байт. Он валиден, плотен и представляет собой ровно то, на чём модели хорошо обучаются. Ошибка здесь: благонамеренный проход очистки, вырезающий «странные символы». Сделайте сохранение LaTeX явным, заявленным свойством корпуса и тестируйте его.

Баг с лицензиями: привязка не к той дате

Каждый пост Stack Exchange лицензирован по Creative Commons, а версия лицензии зависит от того, когда контент был добавлен:

Дата ревизии Лицензия
До 2011-04-08 CC BY-SA 2.5
С 2011-04-08 по 2018-05-02 CC BY-SA 3.0
2018-05-02 и позже CC BY-SA 4.0

Мы прикрепляем content_license к каждому вопросу, ответу и комментарию, вместе с атрибуцией автора и постоянной ссылкой: это ровно те элементы, которых требует атрибуция CC BY-SA. Для горстки очень старых постов лицензии нет в исходных данных, поэтому мы выводили её из таблицы дат. В первом проходе вывод был привязан к дате создания поста. Внимательный рецензент заметил вопрос 2008 года, отредактированный в 2023-м и помеченный CC BY-SA 2.5, и спросил, какую дату правило использует на самом деле.

Лицензия принадлежит ревизии, а не посту. Вопрос 2008 года, отредактированный в 2023-м, несёт CC BY-SA 4.0, а не 2.5. На валидационном срезе привязке к дате последней правки соответствовал 331 из 331 поста с лицензией в источнике; из 137 постов, созданных в одну лицензионную эпоху и отредактированных в другую, привязке к дате создания не соответствовал ровно ни один.

Вместо споров по документации мы проверили это на данных. По 1,384 постам, у которых исходная лицензия есть, мы проверили обе гипотезы. Все 331 пост с исходной лицензией в валидационном срезе соответствовали правилу последней правки, включая все 137 постов, созданных в одну лицензионную эпоху и отредактированных в другую, из которых привязке к дате создания не соответствовал ровно ни один. Лицензия принадлежит текущей ревизии, поэтому она привязывается к дате последней правки, с откатом к дате создания для никогда не редактировавшихся постов.

Два урока. Первый, технический: выводите лицензии из last_edit_date or creation_date и никогда из одного creation_date. Второй, более общий: когда потребитель данных оспаривает семантику поля, разговор заканчивает эмпирический тест по корпусу, а не цитата из документации.

Семантика, которую обязательно документировать

Поля, которые выглядят самоочевидными, но таковыми не являются:

  • is_answered не означает «есть принятый ответ». Поле истинно, когда у вопроса есть принятый ответ или любой ответ с положительным баллом. Если этого не задокументировать, кто-нибудь ниже по конвейеру прочтёт его неверно. Мы также добавили accepted_answer_id (допускает null) на уровне вопроса, чтобы проверка «есть принятый ответ» не требовала сканирования массива ответов.
  • Счётчики просмотров на страницах округлены (на странице значится «Viewed 2.0m times»); точные целые значения приходят из структурированных источников. Если ваш конвейер смешивает оба, укажите, какое из них несёт поле.
  • Корпус живёт. Посты непрерывно редактируются, удаляются, закрываются и защищаются. Отдавайте флаги состояния (closed_date, closed_reason, protected_date, locked_date) и проставляйте все временные метки в ISO-8601 UTC, чтобы потребители могли рассуждать об истинности данных на момент снимка.

Контроль качества перед поставкой

Каждое утверждение выше превращается в проверку, которая выполняется на поставляемом артефакте:

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

Паттерн, который сослужил нам лучшую службу на всём проекте: отдайте маленький сэмпл рано, дайте потребителю сравнить его с живым сайтом и относитесь к каждому найденному расхождению как к улучшению схемы. Наш формат прошёл три итерации сэмплов до того, как был собран хоть байт полного корпуса, а это намного дешевле, чем обнаружить те же проблемы после 33 миллионов тредов.

Выводы

  • Смоделируйте сайт как загружаемые единицы и оцените объём работы (с заявленной неопределённостью) до запуска.
  • Поставляйте доменные единицы (треды), а не единицы загрузки (страницы), и никогда не усекайте молча.
  • Преобразование HTML в Markdown является задачей точности, а не украшением форматирования: конвертация через DOM, сканирование артефактов, нетронутый LaTeX.
  • Прикрепляйте лицензию, атрибуцию и постоянную ссылку к каждому посту, а выводимые лицензии привязывайте к дате последней правки. Когда семантику оспаривают, проверяйте её эмпирически.
  • Документируйте поля, которые лгут (is_answered), и проверяйте всё обещанное на том артефакте, который реально поставляете.

Слой сбора для всего этого (ротация, повторы, рендеринг, учёт только успешных запросов) обеспечивает Crawling API; инженерия корпуса поверх него остаётся той частью, которая по-настоящему ваша. Если вашему конвейеру предстоит пережить стадию прототипа, наш гайд по масштабированию проектов веб-скрейпинга покрывает продакшен-вопросы, а если пункт назначения не передача JSONL, а AI-процесс, посмотрите, как мы собираем исследовательские датасеты с Web MCP Server.

Crawlbase Crawling API

Один GET на страницу в любом масштабе. Ротация резидентных IP, повторы, бэкофф и опциональный рендеринг JavaScript скрыты за единственным эндпоинтом, а в квоту засчитываются только успешные загрузки, поэтому задача на 50 миллионов запросов оплачивается по доставленным страницам, а не по попыткам. Получите токен и начните с бесплатного тарифа.

Часто задаваемые вопросы

Сколько запросов нужно, чтобы собрать весь Stack Exchange?

Примерно от 38 до 50 миллионов на полный проход: около 0.7 миллиона страниц списков для обнаружения, 33 миллиона страниц вопросов, несколько сотен тысяч дополнительных страниц ответов для тредов с более чем 30 ответами и от 4 до 16 миллионов вызовов раскрытия комментариев. Число раскрытий остаётся единственной настоящей неизвестной до начала краулинга, поэтому оценивайте его диапазоном с потолком, а не ложной точечной оценкой.

Почему поставлять собранные треды, а не сырые страницы?

Потому что потребители обучают модели на диалогах, а не на HTML-документах. Одна запись JSONL на тред, содержащая вопрос, каждый ответ и каждый комментарий с баллами, тегами, временными метками и лицензией, позволяет потребителю фильтровать и взвешивать, не пересобирая состояние самостоятельно. Страницы относятся ко времени загрузки; к моменту поставки их быть не должно.

Как сохранить корректную атрибуцию CC BY-SA в масштабе корпуса?

Прикрепляйте к каждому посту три вещи: content_license (привязанную к дате последней правки, с откатом к дате создания для никогда не редактировавшихся постов), автора с определённым плейсхолдером для удалённых пользователей и стабильную постоянную ссылку. Затем проверяйте вывод лицензии эмпирически на постах с явной исходной лицензией, вместо того чтобы доверять документации.

Теряется ли информация при конвертации HTML в Markdown?

Может теряться, и как раз способами, важными для обучения. Регулярные выражения, вырезающие теги, удаляют инклюды C++, потому что <iostream> выглядит как разметка; вложенные ссылки сериализуются в сломанный синтаксис ссылок, а чрезмерно усердные проходы очистки калечат LaTeX. Используйте конвертер на основе DOM, сканируйте весь корпус на известные паттерны артефактов и сохраняйте LaTeX байт в байт.

Начать создавать

Обходите любой сайт в масштабе, без борьбы с инфраструктурой.

Crawlbase берёт на себя прокси, отпечатки и CAPTCHA, чтобы ваша команда выпускала конвейеры данных вместо поддержки обвязки краулинга. 1 000 запросов бесплатно, без карты.

Самообслуживание · Звонок отдела продаж не требуется · Доступны корпоративные объёмы краулинга