Node.js + Cheerio: Kompletny przewodnik po skrapingu z rotacją proxy

Naucz się budować wydajne scrapery w Node.js z Cheerio i axios — od prostego parsowania HTML, przez rotację proxy jako interceptor, po współbieżne skrapowanie 10 000 URL-i z obsługą błędów 403/429.

Node.js + Cheerio: Kompletny przewodnik po skrapingu z rotacją proxy

Dlaczego Cheerio + axios to idealny duet do skrapingu

Zaczynasz pisać scraper w Node.js i od razu napotykasz blokady: rate limiting, CAPTCH-e, bany IP. Zamiast odpalać ciężkie instancje Puppeteer dla każdej strony, możesz parsować HTML bezpośrednio na serwerze — szybko, lekko i z pełną kontrolą nad rotacją proxy.

Cheerio to implementacja jQuery dla Node.js, która działa na surowym HTML-u. Nie uruchamia przeglądarki, nie renderuje CSS ani JS — po prostu wczytuje drzewo DOM i pozwala wybierać elementy selektorami, do których jesteś przyzwyczajony. axios to de facto standard klienta HTTP w ekosystemie Node.js, z wbudowanym wsparciem dla interceptorów — idealnym mechanizmem do wstrzykiwania rotacji proxy.

Razem dają Ci stack, który:

  • Parsuje 100+ stron na sekundę na pojedynczym rdzeniu
  • Kosztuje ułamek tego, co headless browser
  • Skaluje się horyzontalnie w kontenerach Docker
  • Integruje proxy jako middleware — bez hacków

W tym przewodniku zbudujesz od zera scraper e-commerce, który obsługuje 10 000 URL-i z rotacją residential proxy, circuit breakerem i limitami współbieżności.

Podstawy: axios + Cheerio — parsowanie HTML po stronie serwera

Zanim dodamy proxy, zobaczmy minimalny scraper, który pobiera stronę i wyciąga dane z jej statycznego HTML.

import axios from 'axios';
import * as cheerio from 'cheerio';

async function scrapeProduct(url) {
  const { data: html } = await axios.get(url);
  const $ = cheerio.load(html);

  return {
    title: $('h1.product-title').text().trim(),
    price: parseFloat(
      $('span.price').text().replace(/[^0-9.,]/g, '').replace(',', '.')
    ),
    availability: $('span.stock').text().trim(),
    images: $('div.gallery img')
      .map((_, el) => $(el).attr('src'))
      .get(),
  };
}

const product = await scrapeProduct('https://shop.example.com/product/123');
console.log(product);

To wszystko — żadnej przeglądarki, żadnego renderowania. Cheerio wczytuje HTML w milisekundach, a selektory jQuery robią resztę. Jeśli strona serwuje pełny HTML w odpowiedzi HTTP, nie potrzebujesz niczego więcej.

Integracja proxy z axios

Kiedy scrapujesz w skali, jedno IP szybko trafi na rate limit. Rozwiązanie: residential proxy z rotacją. Z ProxyHat masz dostęp do puli milionów IP z całego świata.

Metoda 1: https-proxy-agent

Node.js nie wspiera proxy w axios natywnie — potrzebujesz agenta:

import { HttpsProxyAgent } from 'https-proxy-agent';
import axios from 'axios';

const proxyAgent = new HttpsProxyAgent(
  'http://user-country-US:PASSWORD@gate.proxyhat.com:8080'
);

const { data } = await axios.get('https://shop.example.com/product/123', {
  httpsAgent: proxyAgent,
});

Metoda 2: natywna konfiguracja proxy w axios ≥ 1.x

Od wersji 1.x axios wspiera opcję proxy bez dodatkowych zależności — ale tylko dla HTTP, nie HTTPS. Dla HTTPS nadal polecam https-proxy-agent.

const { data } = await axios.get('http://httpbin.org/ip', {
  proxy: {
    host: 'gate.proxyhat.com',
    port: 8080,
    auth: { username: 'user-country-US', password: 'PASSWORD' },
  },
});

Dla produkcyjnych scraperów HTTPS, https-proxy-agent jest bezpieczniejszym wyborem — obsługuje CONNECT tunelowanie i działa stabilnie z residential proxy.

Kiedy Cheerio wystarczy, a kiedy potrzebujesz headless browsera

To kluczowe pytanie, które oszczędzi Ci godziny debugowania. Oto prosta zasada:

KryteriumCheerio + axiosHeadless browser (Puppeteer/Playwright)
HTML w odpowiedzi HTTP✅ Tak⚠️ Przerost formy
Dane w inline <script> (JSON-LD, __NEXT_DATA__)✅ Parsuj z Cheerio⚠️ Możliwe, ale wolne
Renderowanie po JS (React SPA, Vue)❌ Nie zobaczysz DOM✅ Czekaj na render
Infinite scroll / lazy load❌ Brak scrollowania✅ Symuluj scroll
Logowanie przez OAuth / CAPTCHA v3❌ Nie wyklikasz✅ Możliwe z pluginami
Zależności zewn. (API XHR)✅ Wywołaj API bezpośrednio⚠️ Przechwytuj w network

Pro tip: Zanim odpalisz Puppeteer, sprawdź curl URL w terminalu. Jeśli widzisz dane w źródle strony — Cheerio wystarczy. Jeśli HTML jest pustą shellką z <div id="app"> — potrzebujesz browsera.

Rotująca pula residential proxy jako interceptor axios

Teraz przechodzimy do właściwej architektury. Zamiast wstrzykiwać proxy ręcznie w każdym requeście, zbudujesz axios interceptor, który automatycznie rotuje IP między żądaniami.

ProxyHat pozwala sterować geolokacją i sesją przez username — nie potrzebujesz puli adresów, po prostu zmieniasz parametry w username:

  • user-country-US — losowe IP z USA
  • user-country-DE-city-berlin — losowe IP z Berlina
  • user-session-abc123 — sticky session (to samo IP przez cały czas życia sesji)
import axios from 'axios';
import { HttpsProxyAgent } from 'https-proxy-agent';

// ─── Konfiguracja ────────────────────────────────────
const PROXY_HOST = 'gate.proxyhat.com';
const PROXY_PORT = 8080;
const PROXY_USER = 'YOUR_USERNAME';
const PROXY_PASS = 'YOUR_PASSWORD';

let requestCounter = 0;

function buildProxyAgent(country = 'US', sessionId = null) {
  let username = `${PROXY_USER}-country-${country}`;
  if (sessionId) {
    username += `-session-${sessionId}`;
  }
  const proxyUrl = `http://${username}:${PROXY_PASS}@${PROXY_HOST}:${PROXY_PORT}`;
  return new HttpsProxyAgent(proxyUrl);
}

// ─── Axios instance z interceptorami ─────────────────
const client = axios.create({ timeout: 15000 });

// Interceptor: wstrzykuj rotujący proxy agent
client.interceptors.request.use((config) => {
  requestCounter += 1;
  const country = config.metadata?.country ?? 'US';
  const sticky = config.metadata?.sticky ?? false;
  const sessionId = sticky
    ? config.metadata.sessionId ?? `s${requestCounter}`
    : undefined;

  config.httpsAgent = buildProxyAgent(country, sessionId);
  config.httpAgent = buildProxyAgent(country, sessionId);
  return config;
});

// Interceptor: loguj zmianę IP
client.interceptors.response.use(
  (res) => {
    console.log(`[${res.config.metadata?.label ?? '?'}] ✓ ${res.status}`);
    return res;
  },
  (err) => {
    console.error(
      `[${err.config?.metadata?.label ?? '?'}] ✗ ${err.response?.status ?? err.code}`
    );
    return Promise.reject(err);
  }
);

// ─── Użycie ──────────────────────────────────────────
// Każdy request = nowy IP (rotacja per-request)
await client.get('https://httpbin.org/ip', {
  metadata: { country: 'DE', label: 'test-de' },
});

// Sticky session — to samo IP przez całą sesję zakupową
await client.get('https://shop.example.com/cart', {
  metadata: { country: 'US', sticky: true, sessionId: 'cart-42', label: 'cart' },
});

Zauważ wzór: bez sessionId dostajesz losowe IP per request (idealne do SERP scraping). Z sessionId dostajesz sticky session (idealne do multi-step flows — koszyk, checkout).

Współbieżne skrapowanie z p-limit

Skrapowanie 10 000 URL-i po kolei trwałoby godzinami. Potrzebujesz współbieżności — ale nie za dużo, bo proxy i serwer docelowy mają swoje limity.

p-limit to minimalistyczna biblioteka, która ogranicza liczbę jednocześnie wykonywanych Promise'ów. Prostsza niż p-queue, idealna do scrapingu.

import pLimit from 'p-limit';

const CONCURRENCY = 20; // 20 równoległych requestów
const limit = pLimit(CONCURRENCY);

const urls = Array.from({ length: 10000 }, (_, i) =>
  `https://shop.example.com/product/${i + 1}`
);

const results = await Promise.all(
  urls.map((url) =>
    limit(async () => {
      try {
        const { data: html } = await client.get(url, {
          metadata: { country: 'US', label: url },
        });
        const $ = cheerio.load(html);
        return {
          url,
          title: $('h1.product-title').text().trim(),
          price: parseFloat($('span.price').text().replace(/[^0-9.]/g, '')),
        };
      } catch (err) {
        return { url, error: err.response?.status ?? err.code };
      }
    })
  )
);

const succeeded = results.filter((r) => !r.error);
const failed = results.filter((r) => r.error);
console.log(`✓ ${succeeded.length} | ✗ ${failed.length}`);

Dlaczego 20? To zależy od target site i Twojego planu proxy. Residential proxy radzą sobie z 50-100 req/s, ale agresywny concurrency może triggerować rate limit na stronie. Zacznij od 20, monitoruj błędy 429, i zwiększaj.

Pełny przykład: scraping e-commerce na 10 000 URL-i z rotacją proxy

Teraz połączymy wszystko w jeden produkcyjny scraper. Dodajemy circuit breaker, retry z rotacją IP i zapis wyników.

import axios from 'axios';
import { HttpsProxyAgent } from 'https-proxy-agent';
import * as cheerio from 'cheerio';
import pLimit from 'p-limit';
import { writeFileSync } from 'fs';

// ─── Konfiguracja ────────────────────────────────────
const PROXY_HOST = 'gate.proxyhat.com';
const PROXY_PORT = 8080;
const PROXY_USER = 'YOUR_USERNAME';
const PROXY_PASS = 'YOUR_PASSWORD';
const CONCURRENCY = 25;
const MAX_RETRIES = 3;
const CIRCUIT_BREAK_THRESHOLD = 5; // błędów z rzędu
const CIRCUIT_BREAK_COOLDOWN = 30_000; // 30s pauzy

// ─── Circuit Breaker ─────────────────────────────────
let consecutiveErrors = 0;
let circuitOpen = false;
let circuitTimer = null;

function tripCircuit() {
  circuitOpen = true;
  console.error('🔴 Circuit OPEN — pauza 30s');
  circuitTimer = setTimeout(() => {
    circuitOpen = false;
    consecutiveErrors = 0;
    console.log('🟢 Circuit CLOSED — wznawiam');
  }, CIRCUIT_BREAK_COOLDOWN);
}

function recordSuccess() { consecutiveErrors = 0; }
function recordError() {
  consecutiveErrors += 1;
  if (consecutiveErrors >= CIRCUIT_BREAK_THRESHOLD) tripCircuit();
}

// ─── Proxy agent builder ─────────────────────────────
function buildAgent(country = 'US') {
  const username = `${PROXY_USER}-country-${country}`;
  const url = `http://${username}:${PROXY_PASS}@${PROXY_HOST}:${PROXY_PORT}`;
  return new HttpsProxyAgent(url);
}

// ─── Scraper z retry + circuit breaker ────────────────
async function scrapeWithRetry(url, attempt = 1) {
  if (circuitOpen) {
    await new Promise((r) => setTimeout(r, CIRCUIT_BREAK_COOLDOWN));
  }

  try {
    const { data: html } = await axios.get(url, {
      httpsAgent: buildAgent('US'),
      timeout: 15000,
    });
    const $ = cheerio.load(html);
    recordSuccess();
    return {
      url,
      title: $('h1.product-title').text().trim(),
      price: parseFloat($('span.price').text().replace(/[^0-9.]/g, '')) || null,
      inStock: !$('.out-of-stock').length,
    };
  } catch (err) {
    recordError();
    const status = err.response?.status;

    // 403 / 429 → rotuj IP i retry
    if ((status === 403 || status === 429) && attempt < MAX_RETRIES) {
      console.warn(`↻ Retry ${attempt}/${MAX_RETRIES} — ${status} — ${url}`);
      await new Promise((r) => setTimeout(r, 1000 * attempt)); // backoff
      return scrapeWithRetry(url, attempt + 1);
    }

    return { url, error: status ?? err.code };
  }
}

// ─── Main ─────────────────────────────────────────────
const urls = Array.from(
  { length: 10000 },
  (_, i) => `https://shop.example.com/product/${i + 1}`
);

const limit = pLimit(CONCURRENCY);
const results = await Promise.all(
  urls.map((url) => limit(() => scrapeWithRetry(url)))
);

writeFileSync('results.json', JSON.stringify(results, null, 2));

const ok = results.filter((r) => !r.error);
const fail = results.filter((r) => r.error);
console.log(`✓ Sukces: ${ok.length} | ✗ Błędy: ${fail.length}`);

Ten scraper automatycznie:

  • Rotuje IP per request przez residential proxy ProxyHat
  • Ponawia przy 403/429 z exponential backoff i nowym IP
  • Otwiera circuit breaker po 5 błędach z rzędu — daje serwerowi czas na oddech
  • Zapisuje wyniki do JSON, nawet jeśli część requestów zawiedzie

Obsługa błędów: 403, 429 i circuit breaking — wzorce produkcyjne

Błędy HTTP to nie wyjątki — to część normalnego scrapingu. Klucz to odróżnić błędy, które znikną po zmianie IP (403, 429), od błędów strukturalnych (404, 500).

Mapowanie strategii obsługi błędów

Kod HTTPPrzyczynaStrategia
403 ForbiddenIP zbanowany / WAFRotuj IP, retry z nowym agentem
429 Too Many RequestsRate limitBackoff + rotacja IP + zmniejsz concurrency
404 Not FoundStrona usuniętaZapisz jako błąd, nie retry
500/502/503Serwer niedostępnyRetry z backoff, nie zmieniaj IP
Timeout (ECONNABORTED)Wolny proxy / serwerRetry z dłuższym timeoutem

Circuit breaker — dlaczego jest niezbędny

Bez circuit breakera, jeśli serwer zablokuje całą Twoją pulę IP, scraper będzie bezsensownie retry'ował 10 000 URL-i. Circuit breaker wykrywa kaskadę błędów, pauzuje i wznawia po cooldownie — oszczędzając bandwidth proxy i czas.

Wzór z poprzedniego przykładu jest prosty: licznik błędów z rzędu → próg → pauza → reset. W produkcji warto dodać logikę, która przy 403/429 zmienia też country w parametrach proxy — czasami inna geolokacja omija blokadę.

Skalowanie: konteneryzacja i headless fleet

Pojedynczy proces Node.js z p-limit(25) przetworzy 10 000 URL-i w ~7 minut (zakładając ~200ms per request). Co jeśli masz 100 000 URL-i?

  • Podziel listę URL-i na chunki po 10 000 i odpal osobny kontener na każdy chunk.
  • Użyj country rotation — każdy kontener scrapuje z innej geolokacji (country-US, country-DE, country-JP), rozkładając load na różne IP pools.
  • Sticky sessions per kontener — jeśli target wymaga sesji, przypisz sessionId z process.env.CONTAINER_ID.
  • Redis jako kolejka — zamiast hardcoded array, użyj Redis list jako kolejki URL-i. Worker'y biorą po jednym URL-u, oznaczają jako done.
// docker-compose.yml — 3 workery, każdy z inną geolokacją
services:
  scraper-us:
    build: .
    environment:
      - COUNTRY=US
      - REDIS_URL=redis://redis:6379
  scraper-de:
    build: .
    environment:
      - COUNTRY=DE
      - REDIS_URL=redis://redis:6379
  scraper-jp:
    build: .
    environment:
      - COUNTRY=JP
      - REDIS_URL=redis://redis:6379
  redis:
    image: redis:7-alpine

Każdy worker czyta z tej samej kolejki Redis, ale scrapuje z IP z innego kraju — naturalny load balancing i geo-diversity.

Key Takeaways

Cheerio wystarczy, gdy HTML jest w odpowiedzi HTTP. Sprawdź curl — jeśli widzisz dane, nie potrzebujesz Puppeteer.

Rotacja proxy to interceptor, nie hack. Axios interceptory pozwalają wstrzykiwać nowy HttpsProxyAgent per request — czysta architektura, testowalny kod.

ProxyHat rotuje IP przez username. Zmień user-country-XX dla nowej geolokacji, dodaj -session-ID dla sticky session — bez zarządzania pulą adresów.

Circuit breaker jest obowiązkowy. Bez niego scraper wali w ścianę po rate limit i marnuje bandwidth proxy. 5 błędów → pauza 30s → resume.

p-limit > p-queue do prostego scrapingu. Jeśli nie potrzebujesz eventów czy priorytetów, p-limit jest wystarczający i lżejszy.

FAQ

Czym się różni Cheerio od Puppeteer do scrapingu?

Cheerio parsuje statyczny HTML bez renderowania JavaScript — jest 10-50x szybszy i zużywa ułamek pamięci. Puppeteer uruchamia pełną przeglądarkę Chromium i renderuje JS. Używaj Cheerio, gdy dane są w surowym HTML; Puppeteer, gdy strona wymaga JS do wyrenderowania treści.

Jak rotować IP w axios bez manualnego zarządzania listą proxy?

Z ProxyHat nie potrzebujesz listy IP. Zamiast tego zmieniasz parametry w username: user-country-US daje losowe IP z USA, user-country-DE-city-berlin daje IP z Berlina. Każdy request z nowym parametrem = nowe IP. W axios wystarczy stworzyć nowy HttpsProxyAgent z odpowiednim username.

Jak obsłużyć błąd 429 Too Many Requests przy scrapingu?

Exponential backoff + rotacja IP. Przy 429 zrób pauzę (1s, 2s, 4s…), zmień IP przez nowy parametr w username proxy, i ponów request. Jeśli 429 powtarza się na wielu IP — zmniejsz concurrency (np. z 25 na 10) i dodaj losowy jitter między requestami.

Czy mogę używać sticky sessions z Cheerio i ProxyHat?

Tak. Dodaj -session-TWOJ_ID do username proxy, np. user-country-US-session-cart42. Wszystkie requesty z tym samym sessionId przejdą przez to samo IP — przydatne do koszyków, formularzy logowania i multi-step flows.

Ile concurrent requestów mogę wysłać przez residential proxy?

Zależy od planu i targetu. Z ProxyHat residential proxy zacznij od 20-50 concurrent connections. Monitoruj success rate — jeśli spada poniżej 90%, zmniejsz concurrency lub zwiększ delay między requestami. 10 000 URL-i przy concurrency 25 i success rate 95% zajmie ~7-10 minut.

Gotowy, aby zacząć?

Dostęp do ponad 50 mln rezydencjalnych IP w ponad 148 krajach z filtrowaniem AI.

Zobacz cenyProxy rezydencjalne
← Powrót do Bloga