Node.js + Cheerio スクレイピング完全ガイド:プロキシ回転から並行処理まで

Node.jsでaxios + Cheerioを使った軽量スクレイピングを解説。プロキシ回転インターセプター、並行制御、エラーハンドリングまで実践的なコード付きで網羅します。

Node.js + Cheerio スクレイピング完全ガイド:プロキシ回転から並行処理まで

なぜaxios + CheerioがNode.jsスクレイピングの最適解なのか

ブラウザを立ち上げずにHTMLをパースできれば、メモリ消費は1/10、速度は10倍——これがCheerio + axiosの核心です。PuppeteerやPlaywrightは強力ですが、1インスタンスあたり100〜300MBのRAMを消費し、1万URLを処理するにはコンテナフleetが必要になります。静的HTMLが返ってくるサイトなら、Node.js scraping Cheerio proxiesの組み合わせで、1台のサーバーから数千リクエスト/分を安定して流せます。

この記事では、Cheerio proxyの基本から、axios proxy rotationの実装、並行スクレイピングのパターン、エラー時の回路ブレーカーまで、本番運用で使えるコードを順に解説します。

axios + Cheerio でサーバーサイドHTMLパース

CheerioはjQueryライクなAPIでHTMLをトラバースできる軽量パーサーです。ブラウザエンジンを持たないため、レンダリング後のDOMは取得できませんが、静的HTMLの抽出には十分すぎる威力があります。

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

async function scrapeProduct(url) {
  const { data: html } = await axios.get(url, {
    headers: {
      'User-Agent':
        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
      'Accept-Language': 'en-US,en;q=0.9',
    },
    timeout: 15_000,
  });
  const $ = cheerio.load(html);

  return {
    title: $('h1.product-title').text().trim(),
    price: parseFloat(
      $('span.price').text().replace(/[^\d.]/g, '')
    ),
    availability: $('span.stock').text().trim(),
    breadcrumbs: $('nav.breadcrumb a')
      .map((_, el) => $(el).text().trim())
      .get(),
  };
}

たったこれだけで、HTTPリクエスト→HTMLパース→データ抽出が完了します。Puppeteerの起動〜ページロードに2〜5秒かかるのに対し、このアプローチは200〜500msで終わります。

Cheerioで十分なケース vs ヘッドレスブラウザが必要なケース

すべてのサイトがCheerioでスクレイプできるわけではありません。以下の基準で判断してください。

基準 Cheerio + axiosでOK ヘッドレスブラウザが必要
HTMLソースにデータがある ✅ そのままパース
データがJSでレンダリングされる ✅ Puppeteer / Playwright
ログイン後にデータが表示される ⚠️ Cookie付きリクエストで可能な場合あり ✅ ブラウザ操作が必要
Cloudflareチャレンジ ✅ JS実行が必要
速度・リソース優先 ✅ 低メモリ・高速 ⚠️ 重いが確実
大規模バッチ(1万+URL) ✅ 並行処理と相性良 ⚠️ コスト爆発

SSR(サーバーサイドレンダリング)サイトや、APIエンドポイントが露出しているサイトはCheerioの得意領域です。Webスクレイピングのユースケースでも解説している通り、まずはブラウザのDevToolsでview-source:プレフィックス付きURLを開き、データがHTMLに含まれているか確認する癖をつけましょう。

axiosにプロキシを統合する

スクレイピングを本番運用するなら、プロキシはオプションではなく必須です。単一IPから大量リクエストを送ると、403/429が即座に返ってきます。

https-proxy-agent を使った設定

axiosのproxyオプションはHTTPプロキシのみをサポートし、HTTPSターゲットへのトンネリングに問題があります。https-proxy-agentを使うと、HTTP/HTTPS両方で安定して動作します。

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

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

const client = axios.create({
  httpsAgent: agent,
  headers: {
    'User-Agent':
      'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
  },
  timeout: 15_000,
});

// これで全リクエストがプロキシ経由になる
const { data } = await client.get('https://example.com/product/123');

ProxyHatのユーザー名にcountry-USのようなジオターゲティングフラグを含めることで、特定国のIPからリクエストを送れます。都市レベルのターゲティングもcountry-DE-city-berlinの形式で可能です。

回転レジデンシャルプロキシプールをaxiosインターセプターで実装

プロキシをリクエストごとに切り替えるのがaxios proxy rotationの基本です。しかし、各リクエストでHttpsProxyAgentを手動で生成するのは面倒で、エラーハンドリングも散在します。axiosのインターセプターを使えば、プロキシ回転ロジックをミドルウェアとしてクリーンに分離できます。

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

// --- 設定 ---
const PROXY_HOST = 'gate.proxyhat.com';
const PROXY_PORT = 8080;
const PROXY_USER = 'YOUR_USERNAME';
const PROXY_PASS = 'YOUR_PASSWORD';

// セッションIDでスティッキーセッションを実現
function makeProxyAgent(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インスタンスにインターセプターを登録 ---
const scrapingClient = axios.create({ timeout: 15_000 });

scrapingClient.interceptors.request.use((config) => {
  // リクエストメタデータから国とセッションを取得(デフォルト: US, ランダムセッション)
  const country = config.metadata?.country ?? 'US';
  const sessionId =
    config.metadata?.sticky
      ? config.metadata.sessionId ?? crypto.randomUUID()
      : null; // null = リクエストごとに新しいIP

  config.httpsAgent = makeProxyAgent(country, sessionId);
  config.httpAgent = config.httpsAgent; // HTTPターゲットにも適用
  return config;
});

// --- 使い方 ---
// リクエストごとに新しいIP(ローテーション)
await scrapingClient.get('https://httpbin.org/ip');

// スティッキーセッション(同じIPを維持)
await scrapingClient.get('https://httpbin.org/ip', {
  metadata: { sticky: true, sessionId: 'cart-session-1', country: 'DE' },
});

このパターンの利点は、ビジネスロジックとプロキシ設定が完全に分離されていることです。スクレイピングコード側はmetadataを渡すだけで、プロキシの選択・生成はインターセプターが担当します。新しい国を追加するのも、セッションを固定するのも、metadataの変更だけで済みます。

並行スクレイピング:p-limitでリクエストを制御

1万URLを直列処理すると、1リクエスト500msでも83分かかります。並行数を50にすれば2分以内に終わりますが、制限なしでPromise.allを叩くと、ターゲットサーバーをDDoSしてしまい、自分のIPも即ブロックされます。

p-limitは、並行数を制限しつつシンプルなAPIでPromiseを管理できるライブラリです。

import pLimit from 'p-limit';
import * as cheerio from 'cheerio';

const CONCURRENCY = 20;
const limit = pLimit(CONCURRENCY);

async function scrapeUrl(url) {
  try {
    const { data } = await scrapingClient.get(url);
    const $ = cheerio.load(data);
    return {
      url,
      title: $('h1').text().trim(),
      price: $('span.price').text().trim(),
    };
  } catch (err) {
    return { url, error: err.message };
  }
}

// URLリストを並行処理
const urls = generateProductUrls(); // 10,000 URLs
const results = await Promise.all(
  urls.map((url) => limit(() => scrapeUrl(url)))
);

const successes = results.filter((r) => !r.error);
const failures = results.filter((r) => r.error);
console.log(`成功: ${successes.length}, 失敗: ${failures.length}`);

p-queueを使えば、優先度付けやタイムアウト・リトライの制御もできますが、ほとんどのケースではp-limitで十分です。まずはp-limitで始め、複雑なキューイングが必要になってからp-queueへの移行を検討してください。

実践:1万URLのeコマーススクレイピング

これまでのパーツを組み合わせて、1万URLの静的eコマースサイトをスクレイプする完全な例を構築します。エラーハンドリング、回路ブレーカー、結果のストリーミング出力も含めます。

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

// === 回路ブレーカー ===
class CircuitBreaker {
  constructor(threshold = 5, cooldownMs = 30_000) {
    this.failures = 0;
    this.threshold = threshold;
    this.cooldownMs = cooldownMs;
    this.openUntil = 0;
  }

  get isOpen() {
    return Date.now() < this.openUntil;
  }

  recordSuccess() {
    this.failures = 0;
  }

  recordFailure() {
    this.failures++;
    if (this.failures >= this.threshold) {
      this.openUntil = Date.now() + this.cooldownMs;
      console.warn(
        `[CircuitBreaker] 回路が開きました。${this.cooldownMs / 1000}秒間リクエストを停止します。`
      );
    }
  }
}

// === プロキシ回転インターセプター ===
const PROXY_USER = 'YOUR_USERNAME';
const PROXY_PASS = 'YOUR_PASSWORD';

function makeProxyAgent(country, sessionId) {
  let username = `${PROXY_USER}-country-${country}`;
  if (sessionId) username += `-session-${sessionId}`;
  return new HttpsProxyAgent(
    `http://${username}:${PROXY_PASS}@gate.proxyhat.com:8080`
  );
}

const client = axios.create({ timeout: 15_000 });

client.interceptors.request.use((config) => {
  const country = config.metadata?.country ?? 'US';
  const sessionId = config.metadata?.sticky
    ? config.metadata.sessionId ?? crypto.randomUUID()
    : null;
  const agent = makeProxyAgent(country, sessionId);
  config.httpsAgent = agent;
  config.httpAgent = agent;
  return config;
});

// === リトライ付きスクレイパー ===
const circuit = new CircuitBreaker(5, 30_000);

async function scrapeWithRetry(url, retries = 3) {
  for (let attempt = 1; attempt <= retries; attempt++) {
    if (circuit.isOpen) {
      await new Promise((r) => setTimeout(r, circuit.openUntil - Date.now()));
    }
    try {
      const { data } = await client.get(url, {
        metadata: { country: 'US' }, // リクエストごとにIPローテーション
      });
      circuit.recordSuccess();
      const $ = cheerio.load(data);
      return {
        url,
        title: $('h1.product-title').text().trim(),
        price: $('span.price').text().trim(),
        availability: $('span.stock').text().trim(),
      };
    } catch (err) {
      const status = err.response?.status;
      circuit.recordFailure();

      if (status === 403 || status === 429) {
        // レート制限或いはブロック → 指数バックオフでリトライ
        const delay = Math.min(1000 * 2 ** attempt, 30_000);
        console.warn(
          `[${status}] ${url} — ${attempt}回目のリトライ(${delay}ms後)`
        );
        await new Promise((r) => setTimeout(r, delay));
        continue;
      }
      if (status >= 500) {
        // サーバーエラー → 短い待機後にリトライ
        await new Promise((r) => setTimeout(r, 2000));
        continue;
      }
      // その他のエラー(404等)はリトライしない
      return { url, error: err.message };
    }
  }
  return { url, error: 'max_retries_exceeded' };
}

// === メイン処理 ===
const limit = pLimit(20);
const urls = Array.from({ length: 10_000 }, (_, i) =>
  `https://shop.example.com/product/${i + 1}`
);

const output = createWriteStream('products.ndjson');

const results = await Promise.all(
  urls.map((url) =>
    limit(async () => {
      const result = await scrapeWithRetry(url);
      output.write(JSON.stringify(result) + '\n');
      return result;
    })
  )
);

output.end();
const failed = results.filter((r) => r.error);
console.log(`完了: ${results.length - failed.length}件, 失敗: ${failed.length}件`);

このスクリプトのポイント:

  • NDJSON出力:メモリに1万件を溜め込まず、1件ずつファイルに書き出します。
  • 回路ブレーカー:連続5回失敗すると30秒間リクエストを停止し、ターゲットサーバーと自分のプロキリソース両方を保護します。
  • 指数バックオフ:403/429を受けてからリトライ間隔を2倍ずつ増やし、最大30秒でキャップします。
  • リクエストごとのIPローテーションsessionIdnullにすることで、各リクエストに新しいレジデンシャルIPを割り当てます。

エラーハンドリングの深掘り

403と429はプロキシスクレイピングで最も頻繁に遭遇するエラーです。それぞれに対する戦略を整理します。

403 Forbidden — IPブロック or ボット検出

403が返ってきた場合、まずプロキシIPがブロックされている可能性が高いです。レジデンシャルプロキシはIPプールが大きいため、次のリクエストで新しいIPが割り当てられれば解除されることが多いです。しかし、同じIPから短時間に大量リクエストを送ると再ブロックされるため、スティッキーセッションを使わず、リクエストごとにIPを変えるのが基本戦略です。

それでも403が続く場合は、以下を確認してください:

  • User-Agentヘッダーがブラウザと一致しているか
  • Accept-Languageがジオターゲティングと合致しているか(例:日本IPならja
  • CookieやRefererヘッダーが欠落していないか

429 Too Many Requests — レート制限

429は「このIPからのリクエストが多すぎる」というシグナルです。プロキシを回転させていても、同一IPが短時間に複数リクエストを送ると発生します。対策は:

  • 並行数を下げる(20→10など)
  • リクエスト間にランダムなディレイ(500ms〜2s)を入れる
  • スティッキーセッションを使わず、毎回新しいIPを取得する

回路ブレーカーの設計指針

回路ブレーカーは、障害の連鎖を防ぐためのパターンです。実装のポイントは:

  • しきい値:5回連続失敗で回路を開く(ターゲットサイトの厳しさに応じて調整)
  • 冷却期間:30秒〜2分。長すぎるとスループットが落ち、短すぎると復旧前にリクエストを再開してしまう
  • 半開状態:冷却後に1リクエストだけ送り、成功すれば回路を閉じる(本格実装では追加)

スケーリング:コンテナ化とヘッドレスフリート

1万URLを1台で処理できますが、10万・100万URLになるとコンテナ化が必須です。Docker + Kubernetes(またはAWS ECS / GCP Cloud Run)でワーカーを水平スケールするパターンを紹介します。

アーキテクチャ

  • ジョブキュー:Redis / BullMQでURLをキューイング
  • ワーカー:各コンテナがp-limit(20)で並行スクレイプ
  • 結果ストア:S3またはGCSにNDJSONで保存
  • デッドレターキュー:3回リトライしても失敗したURLを分離
// BullMQワーカーの例
import { Worker } from 'bullmq';
import Redis from 'ioredis';

const connection = new Redis(process.env.REDIS_URL);

const worker = new Worker(
  'scrape',
  async (job) => {
    const { urls, country } = job.data;
    const limit = pLimit(20);
    return Promise.all(
      urls.map((url) => limit(() => scrapeWithRetry(url, country)))
    );
  },
  { connection, concurrency: 5 } // 各ワーカーが5ジョブを並行処理
);

worker.on('failed', (job, err) => {
  console.error(`ジョブ ${job.id} が失敗:`, err.message);
});

この構成では、ワーカーの数を増やすだけでスループットが線形にスケールします。ProxyHatのレジデンシャルプールは数百万IPを保有しているため、ワーカーを増やしてもIP枯渇の心配はありません。

Key Takeaways

axios + Cheerioは静的HTMLサイトに最適 — Puppeteerの1/10のメモリで10倍の速度。まずview-source:でデータの有無を確認。

プロキシ回転はインターセプターで実装 — ビジネスロジックとプロキシ設定を分離し、metadataでジオターゲティングとセッションを制御。

並行数はp-limitで制御Promise.allの無制限並行はDDoSと同義。20〜50並行から始めてターゲットの反応を見ながら調整。

403/429はプロキシ回転で解決 — レジデンシャルプロキシのIPローテーションにより、同一IPからの連続リクエストを回避。

回路ブレーカーで障害の連鎖を防止 — 連続失敗時にリクエストを一時停止し、プロキシリソースとターゲットサーバーを保護。

Cheerio + axios + プロキシ回転の組み合わせは、SERPトラッキングや価格モニタリングなど、静的HTMLベースのスクレイピングにおいて最もコスト効率の高い選択です。ProxyHatの料金プランで、あなたのユースケースに合ったレジデンシャルプロキシを見つけてください。

始める準備はできましたか?

AIフィルタリングで148か国以上、5,000万以上のレジデンシャルIPにアクセス。

料金を見るレジデンシャルプロキシ
← ブログに戻る