Почему Cheerio + axios — лучший старт для парсинга
Если вам нужен быстрый парсинг серверного HTML без запуска браузера — Cheerio + axios это комбинация, которая решает 80% задач. Нет накладных расходов на Chromium, нет проблем с памятью, нет ожидания рендера. Вы загружаете HTML, разбираете его селекторами jQuery-стиля и идёте дальше.
Проблема: блокировки. Один IP — один запрос в секунду, потом 403 или 429. Чтобы парсить серьёзные объёмы, нужна ротация прокси. В этом руководстве мы интегрируем резидентные прокси ProxyHat как полноценный axios-интерцептор, добавим конкурентность через p-limit и обработаем ошибки как инженеры, а не как хакеры.
Базовый стек: axios + Cheerio
Установим зависимости:
npm install axios cheerio p-limit https-proxy-agent
Минимальный пример парсинга статической страницы:
const axios = require('axios');
const cheerio = require('cheerio');
async function scrapeProduct(url) {
const { data: html } = await axios.get(url);
const $ = cheerio.load(html);
return {
title: $('h1.product-title').text().trim(),
price: $('span.price').text().trim(),
availability: $('span.stock').text().trim(),
};
}
scrapeProduct('https://example-shop.com/product/12345')
.then(console.log)
.catch(console.error);
Быстро, чисто, без браузера. Но на 10-м запросе вас заблокируют. Пора добавить прокси.
Прокси в axios: https-proxy-agent и конфигурация
Axios поддерживает proxy в конфигурации, но для HTTPS-целей через HTTP-прокси нужен https-proxy-agent. Это особенно важно для резидентных прокси, где трафик идёт через реальные устройства.
const axios = require('axios');
const { HttpsProxyAgent } = require('https-proxy-agent');
const PROXY_URL = 'http://user-country-US:PASSWORD@gate.proxyhat.com:8080';
const agent = new HttpsProxyAgent(PROXY_URL);
const client = axios.create({
httpsAgent: agent,
proxy: false, // обязательно: отключаем встроенный proxy axios
});
async function scrapeWithProxy(url) {
const { data: html } = await client.get(url);
const $ = cheerio.load(html);
return $('h1').text().trim();
}
scrapeWithProxy('https://example-shop.com/product/12345')
.then(console.log);
Важно: при использованииhttps-proxy-agentустановитеproxy: falseв конфиге axios. Иначе axios попытается использовать свой встроенный прокси-механизм параллельно с агентом, что приведёт к конфликтам.
Формат ProxyHat:
- HTTP:
http://USERNAME:PASSWORD@gate.proxyhat.com:8080 - SOCKS5:
socks5://USERNAME:PASSWORD@gate.proxyhat.com:1080 - Гео-таргетинг:
user-country-DE-city-berlin:PASSWORD@gate.proxyhat.com:8080 - Sticky-сессия:
user-session-abc123:PASSWORD@gate.proxyhat.com:8080
Когда Cheerio достаточно, а когда нужен headless-браузер
Не каждый сайт можно распарсить Cheerio. Вот чёткие критерии:
| Критерий | Cheerio + axios | Headless (Puppeteer/Playwright) |
|---|---|---|
| SSR / статический HTML | ✅ Идеально | ⚠️ Избыточно |
| CSR (React/Vue рендеринг в браузере) | ❌ Не увидите данные | ✅ Необходимо |
| Скорость (запросов/мин) | 100–500+ | 5–20 |
| Потребление RAM | ~30 МБ на процесс | ~300–800 МБ на инстанс |
| Сложность CAPTCHA | Лёгкая (роутинг прокси) | Средняя (скрытие автоматизации) |
| Цена инфраструктуры | Низкая | Высокая |
| E-commerce каталоги (SSR) | ✅ Отлично | ⚠️ Дорого |
| SPA-дашборды | ❌ Пустой HTML | ✅ Единственный вариант |
Правило: если в браузере View Source показывает данные — Cheerio справится. Если данные появляются только после JS-выполнения — нужен Puppeteer или другой подход.
Ротирующий прокси-пул как axios-интерцептор
Хардкодить прокси в каждом запросе — плохая архитектура. Мы создадим axios-интерцептор, который автоматически назначает ротирующий IP каждому запросу. Это чистый, переиспользуемый паттерн.
const axios = require('axios');
const { HttpsProxyAgent } = require('https-proxy-agent');
const PROXY_CONFIG = {
host: 'gate.proxyhat.com',
port: 8080,
username: 'user', // ваш ProxyHat логин
password: 'PASSWORD', // ваш ProxyHat пароль
};
// Генерация уникального session-ID для sticky-сессий
function makeSessionId() {
return `sess_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
}
// Стратегия ротации: 'round-robin' | 'random' | 'sticky'
function buildProxyUrl(strategy = 'random', geo = 'US') {
let user = PROXY_CONFIG.username;
if (strategy === 'sticky') {
user = `${user}-session-${makeSessionId()}`;
} else {
// per-request ротация — каждый запрос = новый IP
user = `${user}-country-${geo}-session-${makeSessionId()}`;
}
return `http://${user}:${PROXY_CONFIG.password}@${PROXY_CONFIG.host}:${PROXY_CONFIG.port}`;
}
// Создаём axios-экземпляр с интерцептором
function createProxyClient(defaultGeo = 'US') {
const client = axios.create();
client.interceptors.request.use((config) => {
const proxyUrl = buildProxyUrl('random', config.proxyGeo || defaultGeo);
config.httpsAgent = new HttpsProxyAgent(proxyUrl);
config.proxy = false;
config.metadata = { proxyUrl, startTime: Date.now() };
return config;
});
// Логируем время ответа и прокси
client.interceptors.response.use(
(response) => {
const { proxyUrl, startTime } = response.config.metadata || {};
const elapsed = Date.now() - startTime;
console.log(`[OK] ${response.status} in ${elapsed}ms via ${proxyUrl.split('@')[1]}`);
return response;
},
(error) => {
if (error.config?.metadata) {
const { proxyUrl, startTime } = error.config.metadata;
console.error(`[ERR] ${error.response?.status || 'N/A'} via ${proxyUrl.split('@')[1]}`);
}
return Promise.reject(error);
}
);
return client;
}
module.exports = { createProxyClient, buildProxyUrl };
Теперь каждый запрос через client.get(url) автоматически получает новый IP. Гео можно менять через config.proxyGeo. Интерцептор — это middleware-паттерн, который не загрязняет бизнес-логику.
Конкурентный парсинг с p-limit
Обработка 10 000 URL последовательно — часы работы. p-limit ограничивает конкурентность, не забивая память и не превышая лимиты целевого сайта.
const pLimit = require('p-limit');
const cheerio = require('cheerio');
const { createProxyClient } = require('./proxy-client');
const client = createProxyClient('US');
const limit = pLimit(20); // 20 конкурентных запросов
async function scrapeProductPage(url) {
try {
const { data: html } = await client.get(url, {
timeout: 15000,
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
},
});
const $ = cheerio.load(html);
return {
url,
title: $('h1.product-title').text().trim(),
price: $('span.price-current').text().trim(),
rating: $('div.star-rating').attr('data-score'),
inStock: !$('.out-of-stock').length,
scrapedAt: new Date().toISOString(),
};
} catch (err) {
return { url, error: err.response?.status || err.code };
}
}
async function scrapeUrlList(urls) {
const tasks = urls.map((url) => limit(() => scrapeProductPage(url)));
const results = await Promise.all(tasks);
const successful = results.filter((r) => !r.error);
const failed = results.filter((r) => r.error);
console.log(`✅ Success: ${successful.length} / ❌ Failed: ${failed.length}`);
return { successful, failed };
}
module.exports = { scrapeProductPage, scrapeUrlList };
Обработка ошибок: 403/429 → ротация прокси и circuit breaker
При массовом парсинге ошибки — норма. Ключевой паттерн: повтор с другим прокси при 403/429 и circuit breaker при массовых отказах.
const axios = require('axios');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { buildProxyUrl } = require('./proxy-client');
const PROXY_CONFIG = {
username: 'user',
password: 'PASSWORD',
host: 'gate.proxyhat.com',
port: 8080,
};
// ─── Circuit Breaker ────────────────────────────────────────
class CircuitBreaker {
constructor(opts = {}) {
this.failureThreshold = opts.failureThreshold || 5;
this.resetTimeout = opts.resetTimeout || 30000;
this.failures = 0;
this.state = 'CLOSED'; // CLOSED | OPEN | HALF_OPEN
this.nextAttempt = Date.now();
}
recordFailure() {
this.failures++;
if (this.failures >= this.failureThreshold) {
this.state = 'OPEN';
this.nextAttempt = Date.now() + this.resetTimeout;
console.warn(`🔴 Circuit OPEN — pausing for ${this.resetTimeout}ms`);
}
}
recordSuccess() {
this.failures = 0;
this.state = 'CLOSED';
}
canProceed() {
if (this.state === 'CLOSED') return true;
if (this.state === 'OPEN') {
if (Date.now() >= this.nextAttempt) {
this.state = 'HALF_OPEN';
return true;
}
return false;
}
return true; // HALF_OPEN → пробный запрос
}
}
// ─── Retry-обёртка с ротацией прокси ────────────────────────
async function fetchWithRetry(url, maxRetries = 3, geo = 'US') {
const breaker = fetchWithRetry.breaker || new CircuitBreaker();
fetchWithRetry.breaker = breaker;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
if (!breaker.canProceed()) {
throw new Error(`Circuit breaker OPEN. Retry after ${Math.ceil((breaker.nextAttempt - Date.now()) / 1000)}s`);
}
const proxyUrl = buildProxyUrl('random', geo);
const agent = new HttpsProxyAgent(proxyUrl);
try {
const { data } = await axios.get(url, {
httpsAgent: agent,
proxy: false,
timeout: 15000,
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
},
});
breaker.recordSuccess();
return data;
} catch (err) {
const status = err.response?.status;
if (status === 403 || status === 429) {
console.warn(`⚠️ ${status} on attempt ${attempt}/${maxRetries} — rotating proxy`);
breaker.recordFailure();
// Экспоненциальная задержка
const backoff = Math.min(1000 * Math.pow(2, attempt), 30000);
await new Promise((r) => setTimeout(r, backoff));
continue;
}
// Для других ошибок — не повторяем
throw err;
}
}
throw new Error(`Failed after ${maxRetries} retries: ${url}`);
}
module.exports = { CircuitBreaker, fetchWithRetry };
Полный пример: парсинг 10k URL с ротацией
Собираем всё вместе — от генерации URL до записи результатов в JSONL-файл:
const fs = require('fs');
const pLimit = require('p-limit');
const cheerio = require('cheerio');
const { createProxyClient } = require('./proxy-client');
const { fetchWithRetry } = require('./retry-handler');
// ─── Конфигурация ───────────────────────────────────────────
const CONCURRENCY = 25;
const BATCH_SIZE = 500;
const CATEGORY = 'electronics';
const TOTAL_PRODUCTS = 10000;
const OUTPUT_FILE = './results.jsonl';
const GEO = 'US';
// ─── Генерация URL ───────────────────────────────────────────
function generateUrls(count, category) {
return Array.from(
{ length: count },
(_, i) => `https://example-shop.com/${category}/product/${i + 1}`
);
}
// ─── Парсинг одной страницы ─────────────────────────────────
async function scrapeOne(url) {
const html = await fetchWithRetry(url, 3, GEO);
const $ = cheerio.load(html);
return {
url,
title: $('h1.product-title').text().trim() || null,
price: parseFloat($('span.price-current').text().replace(/[^0-9.]/g, '')) || null,
rating: parseFloat($('div.star-rating').attr('data-score')) || null,
inStock: !$('.out-of-stock').length,
breadcrumbs: $('.breadcrumb a')
.map((_, el) => $(el).text().trim())
.get()
.join(' > '),
scrapedAt: new Date().toISOString(),
};
}
// ─── Пакетная обработка ─────────────────────────────────────
async function runScraper() {
const urls = generateUrls(TOTAL_PRODUCTS, CATEGORY);
const limit = pLimit(CONCURRENCY);
const stream = fs.createWriteStream(OUTPUT_FILE, { flags: 'a' });
let completed = 0;
let failed = 0;
for (let i = 0; i < urls.length; i += BATCH_SIZE) {
const batch = urls.slice(i, i + BATCH_SIZE);
console.log(`📦 Batch ${Math.floor(i / BATCH_SIZE) + 1}/${Math.ceil(urls.length / BATCH_SIZE)}`);
const tasks = batch.map((url) =>
limit(async () => {
try {
const result = await scrapeOne(url);
stream.write(JSON.stringify(result) + '\n');
completed++;
return result;
} catch (err) {
failed++;
stream.write(JSON.stringify({ url, error: err.message }) + '\n');
return null;
}
})
);
await Promise.all(tasks);
// Прогресс после каждого батча
console.log(
`✅ Completed: ${completed} | ❌ Failed: ${failed} | Progress: ${((completed + failed) / TOTAL_PRODUCTS * 100).toFixed(1)}%`
);
// Пауза между батчами — снижаем нагрузку
await new Promise((r) => setTimeout(r, 2000));
}
stream.end();
console.log(`\n🏁 Done! ${completed} scraped, ${failed} failed. Results in ${OUTPUT_FILE}`);
}
runScraper().catch(console.error);
Масштабирование: контейнеры и headless-флот
Для ещё большей пропускной способности:
- Docker-контейнеры: запускайте несколько инстансов скрейпера с разными
BATCH_SIZEи смещениями (offset). Каждый контейнер обрабатывает свой диапазон URL. - Очередь задач: Redis + BullMQ для распределения URL между воркерами.
- Мониторинг: Prometheus-метрики (success rate, latency per proxy) + Grafana-дашборд.
- Rate limiting:
p-limitдля конкурентности +setTimeoutмежду батчами для сглаживания нагрузки.
Ключевые выводы
- Cheerio + axios — быстрый и лёгкий стек для SSR-сайтов. Не тащите Puppeteer, если HTML уже содержит данные.
- Прокси-интерцептор — чистая архитектура: ротация IP не загрязняет бизнес-логику, каждый запрос автоматически получает новый IP.
- Резидентные прокси через ProxyHat дают реальный IP-адрес с гео-таргетингом — это снижает риск CAPTCHA и блокировок по сравнению с датацентр-прокси.
- p-limit контролирует конкурентность без утечек памяти — лучше чем
Promise.allбез ограничений. - Circuit breaker спасает от каскадных сбоев: при серии 403/429 скрейпер останавливается, а не ломает прокси-пул.
- Батчевая обработка с записью в JSONL — отказоустойчивый формат. Если процесс упадёт, уже собранные данные не потеряются.
Готовы начать? Выберите тариф ProxyHat и подключитесь за минуту — первые запросы уже через npm install axios cheerio.






