Stack Overflow, одна из крупнейших публичных баз знаний для разработчиков, и каждая страница со списком вопросов содержит структурированные сигналы, заслуживающие сбора: заголовок вопроса, теги, количество голосов, ответов и просмотров, а также ссылку на полную тему. Собранные по тегу, эти данные показывают, какие темы набирают популярность, какие проблемы остаются без ответа и как со временем меняется тенденция вопросов по той или иной технологии.
В этом руководстве показано, как парсить вопросы Stack Overflow с помощью JavaScript и Node.js с использованием cheerio. Вы создадите небольшой работающий парсер, который загружает публичную страницу со списком вопросов через Crawling API, разбирает по одной записи на вопрос, обрабатывает пагинацию по тегу и экспортирует результаты в JSON и CSV. Всё руководство ограничено публичными данными листинга, а раздел о легальности ближе к концу, не формальность, поэтому прочитайте его, прежде чем направлять этот инструмент на реальные объёмы.
Что вы создадите
Node.js-скрипт, который принимает публичный URL тега Stack Overflow, загружает HTML страницы через Crawling API и извлекает структурированную запись для каждого вопроса в листинге. В качестве рабочего примера используется тег javascript, из каждого вопроса извлекаются следующие поля:
- Title текст вопроса, например "How do I return the response from an asynchronous call?".
- Tags список тегов вопроса, например "javascript, async-await, promise".
- Votes итоговое количество голосов, показанное на карточке сводки.
- Answers количество ответов, с пометкой "0 answers" при их отсутствии.
- Views количество просмотров, как отображается на карточке.
- Link абсолютный URL страницы конкретного вопроса.
Почему обычный запрос может не справиться с Stack Overflow
Stack Overflow предоставляет значительную часть разметки листинга на стороне сервера, поэтому простой HTTP-запрос даёт больше результатов здесь, чем на сайте с тяжёлым клиентским рендерингом. Проблема заключается в стабильности при больших объёмах. Stack Overflow отслеживает автоматизированный трафик, и датацентровый IP с быстрыми повторяющимися запросами получает ограничение скорости или страницу-вызов вместо разметки вопросов. Когда это происходит, ваш парсер видит неожиданную структуру, и прогон незаметно деградирует.
Таким образом, надёжный парсер Stack Overflow требует IP-адреса, воспринимаемого сайтом как реальный посетитель, и, на страницах с опорой на скрипты, браузера, выполняющего рендеринг перед разбором. Можно собрать такой стек самостоятельно с помощью пула ротируемых резидентских прокси и headless-браузера, но поддержание этого стека в рабочем состоянии занимает большую часть времени. Crawling API объединяет оба требования в одном вызове: вы отправляете URL, он загружает за доверенным IP (и рендерит страницу при использовании JavaScript-токена) и возвращает готовый HTML для разбора.
Crawlbase предлагает два типа токенов. Обычный токен загружает статический HTML и достаточен для серверно-рендеримого листинга Stack Overflow, используемого здесь. JavaScript-токен (JS) сначала рендерит страницу в реальном браузере, к нему стоит обращаться, когда цель загружает контент на стороне клиента. Начните с обычного токена для этих страниц листинга; переходите на JS-токен, если нужная страница возвращается с отсутствующими полями.
Предварительные требования
Перед написанием кода необходимо подготовить несколько вещей. Ни одна из них не займёт много времени.
Node.js 16 или выше. Проверьте версию командой node --version. Если Node не установлен, загрузите его с сайта Node.js или через менеджер версий, например nvm.
Базовые знания JavaScript и Node.js. Вы должны уметь писать и запускать Node-скрипты, а также устанавливать пакеты с помощью npm. Если Node для вас новинка, официальная документация и любой вводный курс обеспечат нужный уровень подготовки. Для более полного ознакомления см. наше руководство по созданию веб-парсера с Node.js.
Аккаунт и токен Crawlbase. Зарегистрируйтесь, откройте панель управления и скопируйте токен обычных запросов со страницы документации аккаунта. Храните токен как пароль: он аутентифицирует ваши запросы, поэтому не добавляйте его в систему контроля версий.
Настройка проекта
Создайте папку проекта, инициализируйте её и установите две необходимые библиотеки.
node --version mkdir stackoverflow-scraper && cd stackoverflow-scraper npm init -y npm install crawlbase cheerio
Работу выполняют две зависимости: crawlbase, официальный Node-клиент для Crawling API, а cheerio разбирает возвращённый HTML с помощью jQuery-подобного API, позволяя извлекать отдельные поля по CSS-селектору. Если селекторы для вас новы, введение в XPath и CSS-селекторы станет полезным дополнением.
Шаг 1: загрузка страницы со списком вопросов
Начните с получения страницы. Импортируйте класс CrawlingAPI, инициализируйте его своим токеном и запросите URL тега. Проверка кода статуса перед разбором позволяет обнаруживать сбои явно, а не молча.
const { CrawlingAPI } = require('crawlbase'); const api = new CrawlingAPI({ token: 'YOUR_CRAWLBASE_TOKEN' }); async function crawl(pageUrl) { const response = await api.get(pageUrl); if (response.statusCode === 200) { return response.body; } console.error(`Request failed: ${response.statusCode}`); return null; } const tagUrl = 'https://stackoverflow.com/questions/tagged/javascript'; crawl(tagUrl).then((html) => { console.log(html ? html.slice(0, 500) : 'No HTML returned'); });
URL тега имеет фиксированную форму: https://stackoverflow.com/questions/tagged/<tag>. Замените javascript на любой интересующий вас тег, например python или node.js. Запустите скрипт командой node scraper.js и вы должны увидеть реальную разметку вопросов, а не страницу-вызов. Это подтверждает, что запрос работает, ещё до написания единого селектора.
Один вызов api.get делает больше, чем обычный запрос: он загружает страницу тега за доверенным IP и ротирует резидентские адреса на стороне сервера, поэтому Stack Overflow воспринимает ваш трафик как запросы реального посетителя, а не парсера, которому нужно выставить ограничение. Вам не нужно самостоятельно поддерживать флот headless-браузеров и пул прокси, а когда цели требуется рендеринг, достаточно просто указать JavaScript-токен. Начните с публичной страницы тега на бесплатном уровне.
Шаг 2: разбор каждого вопроса с помощью cheerio
Получив HTML, загрузите его в cheerio и пройдитесь по карточкам вопросов. Stack Overflow размещает каждый вопрос в повторяющемся блоке .js-post-summary внутри #questions, поэтому вы выбираете все сводки, а затем читаете из каждой заголовок, теги, голоса, ответы, просмотры и ссылку. Цепочка .replace(/\s+/g, ' ').trim() сворачивает пробелы, которыми Stack Overflow заполняет свою разметку, в чистый текст с одиночными пробелами.
const cheerio = require('cheerio'); const clean = (text) => text.replace(/\s+/g, ' ').trim(); function parseQuestions(html) { const $ = cheerio.load(html); const questions = []; $('#questions .js-post-summary').each((_, element) => { const el = $(element); const title = clean(el.find('.s-post-summary--content-title').text()); const link = el.find('.s-link').attr('href') || ''; const votes = clean( el.find('.js-post-summary-stats .s-post-summary--stats-item:first-child').text() ); const answers = clean(el.find('.js-post-summary-stats .has-answers').text()) || '0 answers'; const views = clean( el.find('.js-post-summary-stats .s-post-summary--stats-item:last-child').text() ); const tags = el .find('.js-post-tag-list-item') .map((__, tag) => clean($(tag).text())) .get() .filter(Boolean); questions.push({ title, tags, votes, answers, views, link: link.includes('https://') ? link : `https://stackoverflow.com${link}`, }); }); return questions; }
Несколько деталей обеспечивают точность соответствия странице. Голоса и просмотры находятся в .js-post-summary-stats в виде записей .s-post-summary--stats-item, поэтому первый соответствует голосам, а последний, просмотрам. Счётчик ответов имеет класс .has-answers только при наличии ответов, поэтому при пустом результате селектора используется значение по умолчанию '0 answers'. Теги берутся из каждого элемента .js-post-tag-list-item и преобразуются в массив для сохранения структуры. Ссылка читается из атрибута href якорного тега и преобразуется в абсолютный URL, поскольку Stack Overflow возвращает относительный путь вида /questions/123/....
Имена классов Stack Overflow (js-post-summary, s-post-summary--content-title, js-post-tag-list-item и другие) могут меняться без предупреждения. Рассматривайте приведённые выше селекторы как отправную точку, а не жёсткий контракт. Если поле возвращается пустым, заново изучите живую страницу в инструментах разработчика браузера и обновите селектор. Периодическое обновление селекторов, норма для любого производственного парсера, а не признак поломки.
Шаг 3: объединение и экспорт
Теперь соедините загрузку и разбор в один рабочий скрипт, затем запишите записи как в JSON, так и в CSV. JSON сохраняет вложенный массив тегов для программной обработки; CSV сводит каждую запись в строку для таблиц, объединяя теги разделителем.
const { CrawlingAPI } = require('crawlbase'); const cheerio = require('cheerio'); const fs = require('fs'); const api = new CrawlingAPI({ token: 'YOUR_CRAWLBASE_TOKEN' }); const clean = (text) => text.replace(/\s+/g, ' ').trim(); async function crawl(pageUrl) { const response = await api.get(pageUrl); if (response.statusCode === 200) return response.body; console.error(`Request failed: ${response.statusCode}`); return null; } function parseQuestions(html) { const $ = cheerio.load(html); const questions = []; $('#questions .js-post-summary').each((_, element) => { const el = $(element); const link = el.find('.s-link').attr('href') || ''; questions.push({ title: clean(el.find('.s-post-summary--content-title').text()), tags: el .find('.js-post-tag-list-item') .map((__, tag) => clean($(tag).text())) .get() .filter(Boolean), votes: clean( el.find('.js-post-summary-stats .s-post-summary--stats-item:first-child').text() ), answers: clean(el.find('.js-post-summary-stats .has-answers').text()) || '0 answers', views: clean( el.find('.js-post-summary-stats .s-post-summary--stats-item:last-child').text() ), link: link.includes('https://') ? link : `https://stackoverflow.com${link}`, }); }); return questions; } function toCsv(rows) { const headers = ['title', 'tags', 'votes', 'answers', 'views', 'link']; const escape = (value) => `"${String(value).replace(/"/g, '""')}"`; const lines = [headers.join(',')]; for (const row of rows) { lines.push( [ escape(row.title), escape(row.tags.join('|')), escape(row.votes), escape(row.answers), escape(row.views), escape(row.link), ].join(',') ); } return lines.join('\n'); } async function main() { const tagUrl = 'https://stackoverflow.com/questions/tagged/javascript'; const html = await crawl(tagUrl); if (!html) return; const questions = parseQuestions(html); fs.writeFileSync('questions.json', JSON.stringify(questions, null, 2)); fs.writeFileSync('questions.csv', toCsv(questions)); console.log(`Saved ${questions.length} questions to questions.json and questions.csv`); } main();
Запустите полный скрипт командой node scraper.js. Он загружает страницу тега, разбирает каждую карточку вопроса и записывает как questions.json, так и questions.csv в папку проекта. CSV экранирует кавычки и объединяет массив тегов через вертикальную черту, чтобы вопрос с несколькими тегами умещался в одну ячейку.
Как выглядит результат
JSON-файл содержит по одному объекту на вопрос, теги сохранены как структурированный массив, готовый для загрузки в аналитический скрипт или базу данных.
[ { "title": "How do I return the response from an asynchronous call?", "tags": ["javascript", "ajax", "asynchronous", "promise"], "votes": "8632 votes", "answers": "42 answers", "views": "2.1m views", "link": "https://stackoverflow.com/questions/14220321/how-do-i-return-the-response-from-an-asynchronous-call" }, { "title": "What does \"use strict\" do in JavaScript?", "tags": ["javascript", "syntax", "jslint", "use-strict"], "votes": "9201 votes", "answers": "32 answers", "views": "1.0m views", "link": "https://stackoverflow.com/questions/1335851/what-does-use-strict-do-in-javascript" } ]
Зеркальная копия тех же данных в CSV, это одна строка заголовков плюс одна строка на вопрос, с тегами, объединёнными в одну ячейку через вертикальную черту.
title,tags,votes,answers,views,link "How do I return the response from an asynchronous call?","javascript|ajax|asynchronous|promise","8632 votes","42 answers","2.1m views","https://stackoverflow.com/questions/14220321/..." "What does ""use strict"" do in JavaScript?","javascript|syntax|jslint|use-strict","9201 votes","32 answers","1.0m views","https://stackoverflow.com/questions/1335851/..."
Перебор страниц тега
Одна страница вопросов, это демо; реальная задача перебирает пагинацию. Stack Overflow раскрывает номер страницы через параметр запроса page, поэтому можно формировать URL каждой страницы в цикле, загружать её через Crawling API, разбирать той же функцией и собирать строки. Поскольку каждая страница листинга имеет одинаковую структуру карточек, уже написанный парсер работает на всех из них без изменений.
async function scrapeTag(tag, totalPages) { const all = []; for (let page = 1; page <= totalPages; page++) { const url = `https://stackoverflow.com/questions/tagged/${tag}?tab=newest&page=${page}`; const html = await crawl(url); if (html) all.push(...parseQuestions(html)); } return all; } scrapeTag('javascript', 3).then((rows) => { console.log(`Collected ${rows.length} questions`); });
Чтобы обогатить каждую строку полным телом вопроса, принятым ответом или веткой комментариев, возьмите link из каждой карточки и загрузите эту отдельную страницу вопроса через ту же функцию crawl, затем напишите небольшой парсер для структуры вопроса. Паттерн идентичен: загрузить, затем разобрать. Для более тяжёлых целей с рендерингом см. руководство по парсингу JavaScript-сайтов.
Как оставаться незаблокированным
Даже при доверенном IP Stack Overflow отслеживает трафик, характерный для скраперов. Несколько привычек помогают поддерживать прогон в рабочем состоянии; они применимы к любому сайту при большом объёме парсинга.
- Соблюдайте темп запросов. Интенсивные запросы страниц в плотном цикле, самый быстрый способ получить ограничение. Распределяйте запросы и чередуйте теги вместо парсинга одного пути на максимальной скорости.
- Опирайтесь на ротацию. Пул резидентских IP-адресов распределяет запросы между множеством реальных пользовательских адресов, не давая ни одному из них превысить ограничение скорости. Crawling API делает это за вас; если вы строите собственный стек, именно на это стоит обратить внимание.
- Следите за кодами статуса. Прогон, который начинает возвращать вызовы или ошибки, сигнализирует о том, что текущая скорость или уровень IP больше недостаточны. Воспринимайте это как сигнал к снижению темпа, а не как шум, который нужно игнорировать.
Более широкое руководство см. в статье как парсить сайты, не попадая в блокировку. Если вы хотите сравнить стеки для разбора помимо cheerio, обзор лучших библиотек парсинга с открытым исходным кодом, полезная карта. А если вы собираете данные сообщества разработчиков в более широком контексте, тот же паттерн загрузки с последующим разбором применим к парсингу репозиториев и профилей GitHub.
Легально ли парсить Stack Overflow?
Допустимость парсинга Stack Overflow зависит от условий обслуживания сайта, вашей юрисдикции и целей использования данных. Stack Overflow, часть сети Stack Exchange, публикует публичные условия обслуживания сети и Политику допустимого использования, ограничивающие автоматизированный доступ, поэтому парсинг может противоречить этим условиям независимо от того, насколько аккуратно настроен ваш инструментарий. Ни один из приведённых здесь кодов не меняет этого; он лишь реализует техническую часть. Ознакомьтесь с условиями Stack Exchange и файлом robots.txt сайта и рассматривайте оба документа как границы для того, что вы собираете и с какой скоростью.
Прежде чем вообще писать парсер, проверьте, не покрывает ли официальный путь вашу потребность, поскольку для Stack Overflow это часто так. Stack Exchange предлагает официальный Stack Exchange API, возвращающий вопросы, теги, голоса, ответы и просмотры в виде чистого JSON, и публикует периодические дампы данных всего публичного контента под лицензией Creative Commons. Для исследований, анализа или работы с большими объёмами API и дампы данных, правильные инструменты: они структурированы, имеют ограничение скорости на согласованных условиях и позволяют соблюдать политику сети. Прибегайте к парсингу только для небольших, публичных, разовых задач, которые API не обслуживает.
Ограничивайтесь публичными, неличными данными. Заголовки вопросов, теги и агрегированные счётчики голосов, ответов и просмотров, используемые в этом руководстве, публичные сигналы листинга. Пользовательский контент, другое дело: имена пользователей, репутация, данные профилей и текст, который люди пишут, являются персональными данными, и публикация контента отдельного человека или его привязка к личности может повлечь обязательства по законам о конфиденциальности, таким как GDPR и CCPA, включая наличие правового основания для обработки и выполнение запросов на удаление. Это руководство не охватывает ничего за пределами публичного доступа, личных сообщений или создания профилей идентифицируемых пользователей. По возможности агрегируйте данные, а при касании данных на уровне пользователей предпочитайте официальный API или дампы данных.
Ключевые выводы
- Загружайте за доверенным IP. Stack Overflow ограничивает трафик, характерный для скраперов, поэтому Crawling API загружает каждую страницу тега с ротируемого резидентского IP и возвращает чистый HTML для разбора.
-
cheerio выполняет извлечение. Выбирайте каждую карточку
.js-post-summaryвнутри#questions, затем сопоставляйте заголовок, теги, голоса, ответы, просмотры и ссылку с текущими селекторами, ожидая их изменения. -
Сохраняйте теги структурированными. Читайте каждый
.js-post-tag-list-itemв массив, чтобы теги вопроса оставались доступными для запросов в JSON и сворачивались в одну ячейку в CSV. -
Масштабируйте, перебирая страницы. Параметр
pageперебирает листинг тега, и тот же парсер работает на каждой странице с разумным темпом. - Предпочитайте официальный путь. Stack Exchange API и данные под лицензией CC, санкционированный маршрут для больших объёмов; оставайтесь на публичных данных, соблюдайте условия обслуживания и robots.txt, и избегайте персональных данных на уровне пользователей.
Часто задаваемые вопросы
Нужен ли обычный или JS-токен для Stack Overflow?
Для страниц со списком вопросов из этого руководства достаточно обычного токена, поскольку Stack Overflow отдаёт эту разметку на стороне сервера. Обращайтесь к JS-токену, когда целевая страница загружает контент на стороне клиента и возвращается с пустыми полями. Начните здесь с обычного токена и переходите только если страница возвращает пустые селекторы.
Какие поля можно извлечь из листинга вопросов Stack Overflow?
Из каждой карточки сводки можно извлечь заголовок вопроса, теги, итоговое количество голосов, количество ответов, счётчик просмотров и ссылку на полный вопрос. Это руководство сопоставляет каждое из них с CSS-селектором и собирает их в одну запись на вопрос, экспортируя в JSON и CSV.
Мои селекторы возвращают пустые значения. Что изменилось?
Почти наверняка разметка Stack Overflow. Классы карточек js-post-summary, обёртка заголовка s-post-summary--content-title и маркеры тегов js-post-tag-list-item могут меняться без предупреждения. Заново изучите живую страницу в инструментах разработчика браузера и обновите селекторы. Периодическое обновление селекторов, норма для любого производственного парсера.
Что лучше: использовать Stack Exchange API или парсить сайт?
Если вам нужен большой объём, гарантированная структура или полный публичный контент, используйте официальный Stack Exchange API или его дампы данных под лицензией Creative Commons. Они созданы для этого и позволяют соблюдать условия сети. Парсинг публичных страниц листинга подходом из этого руководства подходит для небольших задач с публичными данными, которые API не обслуживает, при условии соблюдения условий обслуживания, robots.txt и ограничений скорости.
Можно ли парсить профили пользователей или репутацию со Stack Overflow?
Это руководство намеренно этого не делает. Имена пользователей, репутация и написанный ими контент являются персональными данными, и создание профилей идентифицируемых пользователей может повлечь обязательства по законам о конфиденциальности, таким как GDPR и CCPA. Оставайтесь на публичных сигналах листинга, таких как заголовки, теги и агрегированные счётчики, агрегируйте данные где возможно, и используйте официальный API, если вашему проекту действительно нужны данные на уровне пользователей.
Как избежать блокировки при парсинге Stack Overflow?
Поддерживайте низкую частоту запросов с одного IP, чередуйте теги вместо зацикливания на одном пути, и маршрутизируйте через ротируемые резидентские IP, чтобы ни один адрес не превысил ограничение скорости. Crawling API управляет ротацией и доверенным пулом IP за вас; если вы строите собственный стек, именно на это стоит потратить усилия. Следите за кодами статуса и снижайте темп, когда начнут поступать вызовы.
Обходите любой сайт в масштабе, без борьбы с инфраструктурой.
Crawlbase берёт на себя прокси, отпечатки и CAPTCHA, чтобы ваша команда выпускала конвейеры данных вместо поддержки обвязки краулинга. 1 000 запросов бесплатно, без карты.

