Scrapyプロキシミドルウェア完全ガイド:住宅プロキシでIPローテーションを実装する

Scrapyのダウンローダーミドルウェアにプロキシローテーションを統合する実践ガイド。住宅プロキシの自動切り替え、リトライ処理、JSレンダリング対応、本番デプロイまで完全解説。

Scrapyプロキシミドルウェア完全ガイド:住宅プロキシでIPローテーションを実装する

なぜScrapyでプロキシローテーションが必要なのか

大規模スクレイピングを本番運用しているエンジニアなら、次のような経験があるはずです — 短時間に大量リクエストを送った結果、403 ForbiddenやCAPTCHAページに弾かれ、スパイダーが停止する事態。IPベースのレート制限やジオフェンシングは、モダンなサイト運営者にとって標準的な防御手段です。

Scrapyは高速で拡張性の高いフレームワークですが、単一IPのままではどんなに優れたスパイダーもブロックされます。解決策は、リクエストごとにIPを切り替えるプロキシローテーション — とくに本物のISP IPを持つ住宅プロキシ(Residential Proxies)を活用することです。

本記事では、Scrapyのミドルウェアアーキテクチャに沿ってプロキシ統合を設計し、カスタムミドルウェアの実装、リトライ処理、JSレンダリング対応、本番デプロイまでをコードファーストで解説します。

Scrapyのダウンローダーミドルウェアモデルとプロキシの位置づけ

Scrapyのリクエスト処理パイプラインを理解することが、プロキシ統合の第一歩です。リクエストは次の順序で処理されます。

  1. スパイダーRequest オブジェクトを生成
  2. ダウンローダーミドルウェアがリクエストを加工(プロキシ設定はここ)
  3. ダウンローダーがHTTPリクエストを送信
  4. ダウンローダーミドルウェアがレスポンスを加工
  5. スパイダーがレスポンスを処理

プロキシ設定は process_request メソッド内で行います。Scrapyのデフォルト HttpProxyMiddlewareRequest.meta['proxy'] を読み取るだけのシンプルな実装で、ローテーション機能は持ちません。そのため、IPを自動切り替えするにはカスタムミドルウェアを書く必要があります。

ミドルウェアの優先度(priority 数値が小さいほど先に実行)に注意してください。プロキシ設定ミドルウェアは、リトライミドルウェアよりも(数値が大きい)に実行されるべきです。そうでないと、リトライ時に同じプロキシが再利用されてしまいます。

カスタムRotatingProxyMiddlewareの実装

それでは、住宅プロキシをリクエストごとにローテーションするフルワーキングミドルウェアを実装しましょう。ProxyHatの住宅プロキシエンドポイントを使用します。

ミドルウェアクラスの全体像

# middlewares.py
import random
import logging
from scrapy import signals
from scrapy.exceptions import CloseSpider


class RotatingProxyMiddleware:
    """Scrapy downloader middleware that rotates residential proxies per request."""

    def __init__(self, settings):
        self.logger = logging.getLogger(self.__name__)
        self.proxy_base = settings.get(
            'PROXYHAT_PROXY_BASE',
            'http://user-country-US:PASSWORD@gate.proxyhat.com:8080'
        )
        self.countries = settings.getlist(
            'PROXYHAT_COUNTRIES', ['US', 'DE', 'JP', 'GB', 'FR']
        )
        self.sticky_meta_key = 'sticky_proxy'
        self.request_count = 0

    @classmethod
    def from_crawler(cls, crawler):
        o = cls(crawler.settings)
        crawler.signals.connect(
            o.spider_opened, signal=signals.spider_opened
        )
        return o

    def spider_opened(self, spider):
        self.logger.info(
            f'RotatingProxyMiddleware initialized with '
            f'{len(self.countries)} countries'
        )

    def process_request(self, request, spider):
        # Skip proxy for explicitly excluded requests
        if request.meta.get('dont_proxy', False):
            return None

        # Use sticky proxy if requested (e.g., for login flows)
        if request.meta.get(self.sticky_meta_key):
            proxy = request.meta[self.sticky_meta_key]
            request.meta['proxy'] = proxy
            self.logger.debug(f'Sticky proxy: {proxy}')
            return None

        # Rotate: pick a random country for each request
        country = random.choice(self.countries)
        self.request_count += 1

        # Build ProxyHat URL with country targeting
        # Format: http://user-country-{CC}:PASSWORD@gate.proxyhat.com:8080
        username = f'user-country-{country}'
        password = self.proxy_base.split(':')[1].split('@')[0]
        proxy = f'http://{username}:{password}@gate.proxyhat.com:8080'

        request.meta['proxy'] = proxy
        # Store proxy info for retry logic
        request.meta['proxy_country'] = country
        self.logger.debug(
            f'Request #{self.request_count} → {country} proxy'
        )
        return None

このミドルウェアのポイントを解説します。

  • リクエストごとにランダムな国を選択random.choice(self.countries) でIPを分散
  • スティッキーセッション対応meta['sticky_proxy'] を使えばログインフローなどで同じIPを維持
  • 除外フラグmeta['dont_proxy'] でプロキシを使わないリクエストを制御
  • メタデータの保持proxy_country をメタに保存し、リトライ時に別プロキシを選択可能に

settings.pyの設定

# settings.py

# Enable our custom middleware (priority 543 > RetryMiddleware's 500)
DOWNLOADER_MIDDLEWARES = {
    'myproject.middlewares.RotatingProxyMiddleware': 543,
    'myproject.middlewares.ProxyRetryMiddleware': 550,
    'scrapy.downloadermiddlewares.retry.RetryMiddleware': None,  # disable default
}

# ProxyHat configuration
PROXYHAT_PROXY_BASE = 'http://user:YOUR_PASSWORD@gate.proxyhat.com:8080'
PROXYHAT_COUNTRIES = ['US', 'DE', 'JP', 'GB', 'FR']

# Retry settings
RETRY_TIMES = 3
RETRY_HTTP_CODES = [403, 429, 500, 502, 503, 504]

# Concurrency — residential proxies handle this well
CONCURRENT_REQUESTS = 64
CONCURRENT_REQUESTS_PER_DOMAIN = 16
DOWNLOAD_TIMEOUT = 30

優先度の設計が重要です。デフォルトの RetryMiddleware は優先度500、HttpProxyMiddleware は560です。カスタムプロキシミドルウェアは543、リトライミドルウェアは550に設定することで、リトライ時に新しいプロキシが割り当てられます。

scrapy-rotating-proxies vs 自作ミドルウェア — どちらを選ぶべきか

Scrapyエコシステムには scrapy-rotating-proxies というコミュニティパッケージが存在します。自作する前に、両者を比較しましょう。

基準scrapy-rotating-proxies自作ミドルウェア
セットアップ時間5分 — pip installで即利用30〜60分 — 設計・実装が必要
プロキシプール管理外部プロキシリストを渡す形式API経由で動的生成可能
国・都市ターゲティング非対応(手動でリストを分ける必要あり)プロキシURL内にフラグ埋め込み可能
スティッキーセッション対応(proxiesの順序で制御)柔軟に実装可能
障害IPの自動除外対応(ban detection内蔵)自前で実装が必要
住宅プロキシとの相性低 — IPリスト前提の設計高 — ゲートウェイURLで動的ローテーション
メンテナンス負担低(ただし更新が停滞気味)中 — 自前でテスト・保守

結論:住宅プロキシを使うなら自作ミドルウェアを推奨します。 理由は3つあります。

  1. 住宅プロキシはIPリストではなくゲートウェイURLでアクセスする — ProxyHatのようなサービスは、gate.proxyhat.com:8080 にリクエストを送るだけでバックエンドがIPを割り当てます。IPリストを管理する設計は無駄です。
  2. ジオターゲティングをURLに組み込めるuser-country-JP のようにユーザー名に国コードを埋め込む方式は、リスト管理では実現できません。
  3. スティッキーセッションの制御が容易user-session-abc123 でセッションIDを指定する方式は、プロキシプロバイダーの機能と直接連携できます。

一方、データセンタープロキシの固定IPリストを管理する用途なら、scrapy-rotating-proxiesは依然として合理的な選択です。

リトライ時にプロキシを切り替える — ProxyRetryMiddleware

ブロックされたリクエストをリトライする際、同じプロキシでリトライしても意味がありません。リトライごとに新しいIPを割り当てるカスタムリトライミドルウェアを実装しましょう。

# middlewares.py (continued)
import time
from scrapy.downloadermiddlewares.retry import RetryMiddleware
from scrapy.utils.response import response_status_message


class ProxyRetryMiddleware(RetryMiddleware):
    """Retry failed requests with a fresh proxy IP."""

    def __init__(self, settings):
        super().__init__(settings)
        self.max_retry_times = settings.getint('RETRY_TIMES', 3)
        self.ban_codes = settings.getlist(
            'BAN_PROXY_HTTP_CODES', [403, 429]
        )

    def process_response(self, request, response, spider):
        # Detect ban responses
        if response.status in self.ban_codes:
            self.logger.warning(
                f'Ban detected: {response.status} from '
                f'{request.meta.get("proxy_country", "?")} proxy'
            )
            # Remove sticky proxy so a new one is assigned on retry
            if request.meta.get('sticky_proxy'):
                request.meta['sticky_proxy'] = None
            # Increment retry counter
            retry_times = request.meta.get('retry_times', 0) + 1
            if retry_times <= self.max_retry_times:
                request.meta['retry_times'] = retry_times
                # Clear old proxy — RotatingProxyMiddleware will assign new one
                request.meta.pop('proxy', None)
                request.meta.pop('proxy_country', None)
                request.dont_filter = True
                self.logger.info(
                    f'Retrying (attempt {retry_times}) with fresh proxy'
                )
                return request.copy()
            else:
                self.logger.error(
                    f'Max retries exceeded for {request.url}'
                )

        return response

    def process_exception(self, request, exception, spider):
        # Handle connection errors (proxy down, timeout, etc.)
        if isinstance(exception, (TimeoutError, ConnectionError)):
            retry_times = request.meta.get('retry_times', 0) + 1
            if retry_times <= self.max_retry_times:
                request.meta['retry_times'] = retry_times
                request.meta.pop('proxy', None)
                request.meta.pop('proxy_country', None)
                request.dont_filter = True
                self.logger.info(
                    f'Connection error, retrying with new proxy '
                    f'(attempt {retry_times})'
                )
                return request.copy()
        return None

このミドルウェアの重要な設計ポイント:

  • 403/429をブロック判定として扱う — これらのステータスコードはIPバンの典型的な兆候です
  • リトライ時にプロキシメタデータをクリアrequest.meta.pop('proxy', None) で古いプロキシ情報を削除し、次のパスで RotatingProxyMiddleware が新しいプロキシを割り当てます
  • スティッキーセッションもリセット — バンされたIPでセッションを継続する意味はないため、sticky_proxy もクリアします
  • 接続エラーもハンドリング — プロキシサーバー自体がダウンしているケースを考慮します

JSレンダリングサイトへの対応 — Scrapy-Playwright + プロキシ

SPAや動的レンダリングを多用するサイトでは、HTTPリクエストだけではデータが取得できません。scrapy-playwrightを使えば、Scrapyからヘッドレスブラウザを操作しつつ、プロキシを経由できます。

# settings.py — Playwright + Proxy integration
DOWNLOAD_HANDLERS = {
    'http': 'scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler',
    'https': 'scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler',
}
TWISTED_REACTOR = 'twisted.internet.asyncioreactor.AsyncioSelectorReactor'

# Playwright will read proxy from request.meta['proxy']
PLAYWRIGHT_BROWSER_TYPE = 'chromium'
PLAYWRIGHT_LAUNCH_OPTIONS = {
    'headless': True,
    'timeout': 60000,
}

# spider.py
import scrapy


class JSSpider(scrapy.Spider):
    name = 'js_spider'

    def start_requests(self):
        urls = ['https://example.com/dynamic-page']
        for url in urls:
            yield scrapy.Request(
                url,
                meta={
                    'playwright': True,
                    'playwright_include_page': False,
                    'proxy': 'http://user-country-US:PASSWORD@gate.proxyhat.com:8080',
                },
            )

    def parse(self, response):
        # response is fully rendered HTML
        for item in response.css('.product-card'):
            yield {
                'name': item.css('h2::text').get(),
                'price': item.css('.price::text').get(),
            }

Scrapy-Playwrightのプロキシ統合における注意点:

  • request.meta['proxy'] を設定すると、Playwrightはブラウザ起動時にそのプロキシを適用します — これはブラウザインスタンスレベルの設定です
  • リクエストごとのプロキシ切り替えには、PLAYWRIGHT_PROCESS_REQUEST_HEADERS コールバックを活用するか、playwright_context_kwargs でコンテキストごとにプロキシを指定します
  • ヘッドレスブラウザはリソース消費が大きいため、CONCURRENT_REQUESTS を控えめ(4〜8)に設定してください

scrapy-splashを使う場合は、Splashサーバー自体にプロキシ設定を渡す必要があります。詳細はWebスクレイピングのユースケースも参照してください。

本番デプロイ — Scrapyd、Docker、cron

スパイダーを本番運用するには、スケジューリングとコンテナ化が不可欠です。代表的な3つのデプロイ手法を比較します。

1. Docker + cron(最もシンプル)

# Dockerfile
FROM python:3.11-slim

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .

# Entry point: run spider with logging
CMD ["scrapy", "crawl", "my_spider", "-s", "LOG_FILE=/logs/spider.log"]
# docker-compose.yml
version: '3.8'
services:
  spider:
    build: .
    volumes:
      - ./logs:/logs
      - ./output:/output
    environment:
      - PROXYHAT_PASSWORD=${PROXYHAT_PASSWORD}
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 2G
          cpus: '2'

crontabで定期実行:

# 毎日午前3時にスパイダーを実行
0 3 * * * cd /app && docker-compose up --build spider >> /var/log/spider-cron.log 2>&1

2. Scrapyd(Scrapyネイティブのデプロイサーバー)

ScrapydはScrapyプロジェクト専用のデプロイ・スケジューリングサービスです。REST APIでスパイダーを管理できます。

  • メリット:Scrapyと密結合、JSON APIでジョブ管理可能
  • デメリット:水平スケーリングが複雑、モニタリングは自前で構築が必要

3. ScrapeOps / Zyte Scrapy Cloud(マネージドサービス)

運用を完全に外部委託する選択肢です。スケジューリング、モニタリング、プロキシ管理まで一括提供されます。

  • メリット:運用コストゼロ、組み込みモニタリング
  • デメリット:コストがスケールする、プロキシプロバイダーの選択肢が制限される場合あり

推奨:中小規模ならDocker + cronで十分です。大規模で複数スパイダーを運用する場合はScrapydを検討してください。詳しい料金比較はProxyHatの料金プランも参照してください。

モニタリング — IPごとの成功率とバン検出

プロキシを使ったスクレイピングでは、どのIPがブロックされ、どの国が成功率が高いかを継続的にモニタリングすることが不可欠です。Scrapyのシグナルと拡張機能を使ってメトリクスを収集しましょう。

# extensions.py
import logging
from collections import defaultdict
from scrapy import signals


class ProxyMetricsExtension:
    """Track per-country proxy success rates and ban detection."""

    def __init__(self, stats):
        self.stats = stats
        self.logger = logging.getLogger(self.__name__)
        self.requests = defaultdict(int)
        self.bans = defaultdict(int)
        self.successes = defaultdict(int)

    @classmethod
    def from_crawler(cls, crawler):
        o = cls(crawler.stats)
        crawler.signals.connect(
            o.request_sent, signal=signals.request_sent
        )
        crawler.signals.connect(
            o.response_received, signal=signals.response_received
        )
        crawler.signals.connect(
            o.spider_closed, signal=signals.spider_closed
        )
        return o

    def request_sent(self, request, spider):
        country = request.meta.get('proxy_country', 'direct')
        self.requests[country] += 1

    def response_received(self, response, request, spider):
        country = request.meta.get('proxy_country', 'direct')
        if response.status in [200, 201]:
            self.successes[country] += 1
        elif response.status in [403, 429, 503]:
            self.bans[country] += 1
            self.logger.warning(
                f'Ban: status={response.status} country={country}'
            )

    def spider_closed(self, spider, reason):
        self.logger.info('=== Proxy Metrics Report ===')
        for country in sorted(self.requests.keys()):
            total = self.requests[country]
            bans = self.bans[country]
            successes = self.successes[country]
            rate = (successes / total * 100) if total > 0 else 0
            self.logger.info(
                f'{country}: {total} requests, '
                f'{successes} successes, {bans} bans, '
                f'{rate:.1f}% success rate'
            )

この拡張機能を有効にするには、settings.py に追加します:

# settings.py
EXTENSIONS = {
    'myproject.extensions.ProxyMetricsExtension': 500,
}

バン検出のシグナルを監視すべき指標:

  • 成功率が80%を下回る国 — その国のプロキシがターゲットサイトにブロックされている兆候
  • 429ステータスの急増 — レート制限に引っかかっている証拠。CONCURRENT_REQUESTS_PER_DOMAINを下げるべき
  • タイムアウトの増加 — プロキシプロバイダーの品質低下の可能性

これらのメトリクスはPrometheusやDatadogにエクスポートして、ダッシュボードで可視化することを推奨します。ProxyHatの対応ロケーション一覧を参考に、成功率の高い国を選択する戦略も有効です。

Key Takeaways — 実装の要点

Scrapyプロキシミドルウェアの設計で最も重要な3つのポイント:

  • ミドルウェア優先度を正しく設定する — プロキシ割り当て(543)→ リトライ(550)の順で実行
  • リトライ時は必ずプロキシを切り替える — 同じIPでのリトライはブロックを悪化させるだけ
  • 国別成功率をモニタリングする — データに基づいてプロキシ戦略を最適化する

住宅プロキシとScrapyを正しく統合すれば、大規模スクレイピングプロジェクトのブロック率を劇的に下げられます。ProxyHatの住宅プロキシは、ゲートウェイURLひとつで国・都市ターゲティングとセッション管理が可能です — 今すぐプランを確認して、スパイダーにプロキシローテーションを組み込みましょう。

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

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

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