Недавно мы собрали полный корпус вопросов и ответов сети Stack Exchange: каждый сайт, каждый вопрос, каждый ответ, каждый комментарий, в качестве обучающих данных для LLM-проекта клиента. Около 33 миллионов тредов с 360+ сайтов, доставленных в формате JSONL, где тело каждого поста конвертировано из HTML в Markdown.
Эта статья разбирает инженерную часть: как мы смоделировали сеть в виде загружаемых единиц и оценили объём работы до её начала, какие ловушки качества данных проявляются только на реальных данных, как мы допустили баг с лицензированием контента в раннем сэмпле и потом опровергали его эмпирически, и где Crawling API действительно окупается в подобной задаче. Цифры взяты из реальных измерений в ходе сбора; детали клиента удалены.
Результат поставки: полные треды, а не страницы
Первое проектное решение: единица поставки. Страницы суть то, что вы загружаете; потреблять их никто не хочет. Естественной единицей для данных «вопрос-ответ» является собранный тред: одна запись, содержащая вопрос, все его ответы и все комментарии к тем и другим, плюс метаданные, позволяющие потребителю фильтровать и взвешивать (баллы, теги, временные метки, отметки принятого ответа).
Один тред на строку JSONL:
{ "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 (когда он нужен) выполняются за эндпоинтом.
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), а затем проверяйте точность именно на сэмплах, насыщенных кодом:
from markdownify import markdownify as md def to_markdown(html): return md(html, heading_style="ATX", bullets="-").strip()
Ловушка 2: артефакты вложенных ссылок
Теги ссылок, оборачивающие другую разметку, могут сериализоваться как [text]([label](url)). Мы ищем этот паттерн и схлопываем его на постобработке:
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, и спросил, какую дату правило использует на самом деле.
Вместо споров по документации мы проверили это на данных. По 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, чтобы потребители могли рассуждать об истинности данных на момент снимка.
Контроль качества перед поставкой
Каждое утверждение выше превращается в проверку, которая выполняется на поставляемом артефакте:
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.
Один 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 запросов бесплатно, без карты.
