Node.js + Cheerio: парсинг с прокси-ротацией и axios

Практическое руководство по серверному парсингу на Node.js с Cheerio, axios и ротацией резидентных прокси. Интерцепторы, p-limit, обработка 403/429 и пример на 10k URL.

Node.js + Cheerio: парсинг с прокси-ротацией и axios

Почему 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.

Готовы начать?

Доступ к более чем 50 млн резидентных IP в 148+ странах с AI-фильтрацией.

Смотреть ценыРезидентные прокси
← Вернуться в Блог