Scrapyのパーサーがタイムアウトで落ち、プロキシプールが予想以上に早く消費され、ログが407や403のレスポンスで埋まっている — これはお馴染みの光景ですか?90%のケースで問題はプロキシ自体ではなく、DownloaderMiddlewareの書き方にあります。高価なトラフィックを無駄なリクエストに変えてしまうミドルウェアの最も一般的な5つのエラーを解説し、それを修正する方法をコードと共に示します。
エラー1: プロキシの状態を考慮しない単純なローテーション
チュートリアルでよく見かける構文は、random.choice(PROXY_LIST)をprocess_request内で使用することです。問題は、このようなローテーションは、どのプロキシがバンされたのか、どのプロキシがまだ生きているのかを知ることができないことです。その結果、パーサーはすでにブロックされたIPを通じてリクエストを送り続け、403/429を受け取り、リトライを行い — そして再び同じアドレスを選択します。なぜなら、選択がランダムで「悪い」ノードを除外しないからです。
正しいアプローチは、各プロキシの状態を追跡することです:成功したリクエストの数、エラーの数、最後の使用時間。以下は最小限の作業例です:
import random
import time
class ProxyPool:
def __init__(self, proxies):
self.proxies = {p: {"fails": 0, "last_used": 0, "banned_until": 0} for p in proxies}
def get_proxy(self):
now = time.time()
available = [
p for p, state in self.proxies.items()
if state["banned_until"] < now
]
if not available:
# すべてがバンされている場合、最も「古い」バンをリセットします
available = list(self.proxies.keys())
return random.choice(available)
def mark_fail(self, proxy, cooldown=300):
self.proxies[proxy]["fails"] += 1
self.proxies[proxy]["banned_until"] = time.time() + cooldown
def mark_success(self, proxy):
self.proxies[proxy]["fails"] = 0
self.proxies[proxy]["last_used"] = time.time()
このプールは、クールダウン(cooldown)期間中にバンされたIPを除外し、後で再利用します。これにより、同じブロックされたノードを繰り返し叩くことがなくなり、トラフィックの消費が大幅に減少します。
エラー2: リトライとステータスコードの不適切な処理
二つ目の典型的なエラーは、標準のRetryMiddlewareを修正なしで使用することです。デフォルトでは、リクエストが失敗したプロキシで同じリクエストをリトライします。明示的にprocess_exceptionでプロキシの切り替えを追加しない限り、結果として得られるのは典型的な光景です:3回リトライ、3回バン、リクエストは依然として失敗し、トラフィックはすでに消費されています。
二つ目のポイントは、すべてのステータスコードを同じようにリトライする必要がないということです。429(Too Many Requests)は、IPの変更とともに待機が必要です。403は通常、特定のプロキシのバンを意味します(即座の交換が必要です)。5xxはサーバー側の一時的な問題であることが多く、同じプロキシでリトライできます。すべてを一つにまとめると、ミドルウェアはプロキシを過剰に消費するか、IPをすぐに変更すべきところで長時間待機することになります。
class SmartRetryMiddleware:
def __init__(self, pool):
self.pool = pool
def process_response(self, request, response, spider):
proxy = request.meta.get("proxy")
if response.status in (403, 407):
if proxy:
self.pool.mark_fail(proxy, cooldown=600)
new_request = request.copy()
new_request.meta["proxy"] = self.pool.get_proxy()
new_request.dont_filter = True
return new_request
if response.status == 429:
if proxy:
self.pool.mark_fail(proxy, cooldown=120)
new_request = request.copy()
new_request.meta["proxy"] = self.pool.get_proxy()
new_request.dont_filter = True
return new_request
if proxy:
self.pool.mark_success(proxy)
return response
ここでは、エラーのタイプによってクールダウンを分けることが重要です:厳しいバン(403/407) — 長い待機、レート制限(429) — 短い待機。これにより、長い実行でトラフィックの数十パーセントを節約できます。
エラー3: 認証のあるサイトに対するスティッキーセッションの欠如
パーサーがログイン、カート、状態を保持するページネーション、またはIPによる検証を伴うキャプチャを持つサイトで動作する場合 — 各リクエストでプロキシを変更するとセッションが壊れます。サイトは、リクエスト№1があるIPから来て、リクエスト№2(同じクッキーセッション内)が別のIPから来たことを検知し、ボット対策が即座にトリガーされます。たとえ両方のIPが「クリーン」であってもです。
解決策は、特定のアカウントや同じドメイン内のリクエストのチェーンに対して、1つのプロキシを1つの論理セッションに固定することです。これをスティッキーセッションと呼びます。
class StickySessionMiddleware:
def __init__(self, pool, ttl=600):
self.pool = pool
self.ttl = ttl
self.sessions = {} # session_id -> (proxy, expires_at)
def process_request(self, request, spider):
session_id = request.meta.get("session_id")
if not session_id:
return
now = time.time()
session = self.sessions.get(session_id)
if session and session[1] > now:
request.meta["proxy"] = session[0]
else:
proxy = self.pool.get_proxy()
self.sessions[session_id] = (proxy, now + self.ttl)
request.meta["proxy"] = proxy
IPの安定性が必要なタスク — 認証、個人アカウントの操作、多段階フォーム — には、レジデンシャルプロキシが最適です。これにより、同じ出発IPを数分または数時間保持し、その後制御された方法で変更できます。リクエストごとにランダムに変更するのではなく。
エラー4: プロキシの認証情報の不適切な渡し方
四つ目のエラーは技術的なもので、ほぼすべてのプロジェクトで見られます。開発者は、プロキシのユーザー名とパスワードをhttp://user:pass@ip:portの形式でrequest.meta["proxy"]を通じて直接渡します。これはほとんどの場合機能しますが、HTTPSトンネルを介してプロキシを使用する場合や、一部のプロバイダーと連携する場合、この認証方法は標準のHttpProxyMiddlewareによって正しく処理されず、リクエストは407 Proxy Authentication Requiredで失敗しますが、資格情報は正しいです。
より信頼性の高い方法は、Proxy-Authorizationヘッダーを明示的にbase64でエンコードして渡すことです:
import base64
class ProxyAuthMiddleware:
def process_request(self, request, spider):
proxy = request.meta.get("proxy")
if not proxy:
return
# URLに資格情報なしのプロキシ
request.meta["proxy"] = proxy
user = request.meta.get("proxy_user")
password = request.meta.get("proxy_pass")
if user and password:
credentials = f"{user}:{password}"
encoded = base64.b64encode(credentials.encode()).decode()
request.headers["Proxy-Authorization"] = f"Basic {encoded}"
このアプローチは、数百の並列リクエストにスケールアップする際に安定して機能し、特定のライブラリのバージョンが埋め込まれた資格情報を持つURLをどのように解析するかに依存しません。特に、モバイルプロキシを使用する場合、認証はIPホワイトリストやヘッダーの厳格な検証に依存することが多いため、特に重要です。
エラー5: バンの監視とログ記録がない
最後で、最も影響が大きい可能性のあるエラーは、どのプロキシがバンされ、どのくらいの頻度で、どのドメインでバンされているかのログ記録がないことです。これらのデータがなければ、何がトラフィックを消費しているのかを理解することはできません:プロキシプールが特定のサイトで枯渇したのか、パーサー自体に問題があるのか(リクエストが頻繁すぎる、遅延がない、疑わしいヘッダー)。
ミドルウェアでログ記録すべき最小限のメトリックは次のとおりです:
- パーシングセッションごとの各プロキシへのリクエスト数
- 各プロキシに対するエラーの数とコード(403、407、429、5xx)
- 最初のバンまでのプロキシの寿命
- バンが最も頻繁に発生するドメイン
import logging
import json
logger = logging.getLogger("proxy_stats")
class ProxyStatsMiddleware:
def __init__(self):
self.stats = {}
def process_response(self, request, response, spider):
proxy = request.meta.get("proxy", "unknown")
domain = request.url.split("/")[2]
key = f"{proxy}|{domain}"
entry = self.stats.setdefault(key, {"requests": 0, "errors": 0})
entry["requests"] += 1
if response.status in (403, 407, 429):
entry["errors"] += 1
if entry["requests"] % 50 == 0:
logger.info(json.dumps(self.stats))
return response
このような統計がなければ、ミドルウェアを「最適化」しようとする試みは推測に過ぎません。例えば、特定のドメインが最初の10リクエストでプロキシプールの80%をバンしている場合 — 問題はプロキシではなく、リクエストのパターンにあります(User-Agentのローテーションがない、高すぎる頻度、リクエスト間の遅延がない)。
ミドルウェアの完全な作業例
settings.pyにすべてをまとめます — ミドルウェアの順序は重要です。なぜなら、それによってチェックが適用される順序が決まるからです:
DOWNLOADER_MIDDLEWARES = {
"myproject.middlewares.ProxyAuthMiddleware": 350,
"myproject.middlewares.StickySessionMiddleware": 400,
"myproject.middlewares.SmartRetryMiddleware": 550,
"myproject.middlewares.ProxyStatsMiddleware": 900,
}
RETRY_ENABLED = False # 標準のリトライを無効にし、自分のものを使用
DOWNLOAD_TIMEOUT = 15
CONCURRENT_REQUESTS_PER_DOMAIN = 8
RETRY_ENABLED = Falseに注意してください — これは重要です。そうでないと、Scrapyの組み込みリトライメカニズムがプロキシの切り替えのロジックと衝突し、リクエストが重複したり、二重にリトライされたりします。また、CONCURRENT_REQUESTS_PER_DOMAINを制限することも重要です — プロキシのローテーションがあっても、1つのドメインに対する過剰な並列性は、ボット対策システムにとって疑わしく見えます。
Scrapyに適したプロキシの種類
ミドルウェアは問題の半分を解決しますが、もう半分はタスクに適したプロキシプールの正しい選択です。以下は、典型的なパーシングシナリオに基づく比較です。
| プロキシの種類 | 使用するタイミング | 利点 | 欠点 |
|---|---|---|---|
| データセンターのプロキシ | 厳格なボット対策なしでのオープンページの大量パーシング | 高速、低コストのトラフィック | 容易に検出され、しばしばブラックリストに載る |
| レジデンシャルプロキシ | マーケットプレイス、JS保護のあるサイト、認証のパーシング | 実際のIP、低いバン率、スティッキーセッションのサポート | DCに比べて速度が遅い |
| モバイルプロキシ | 厳格なボット対策のあるサイトやAPIのモバイルバージョンのパーシング | ターゲットサイトからの最大の信頼 | トラフィックコストが最も高い |
実用的なルール:強力な保護がない静的ページのパーシングには、この記事の適切なミドルウェアと組み合わせた安価なデータセンターのプロキシが適しています。サイトがCloudflare、PerimeterX、DataDome、または類似のシステムを使用している場合、データセンターのIPはほぼ即座にバンされ、ここでレジデンシャルプールに移行する方が時間を節約できます。
パーサーを本番環境で起動する前のチェックリスト
- ミドルウェアはクールダウン中にバンされたプロキシを除外し、ランダムに選択しない
- リトライロジックはエラーのタイプ(403/407 vs 429 vs 5xx)を異なるクールダウンで区別する
- セッションタスクには、各リクエストでの変更ではなくスティッキーなプロキシのバインディングを使用する
- プロキシの認証はURLだけでなくProxy-Authorizationヘッダーを通じて渡す
- 問題診断のためにドメインとプロキシのバン統計を記録する
- 標準のScrapyのRetryMiddlewareを無効にして、カスタムロジックと衝突しないようにする
- 並列性は合理的な値に制限し、最大に設定しない
結論
Scrapyにおけるプロキシトラフィックの消費に関するほとんどの問題は、より大きなIPプールを購入するのではなく、ミドルウェアのロジックを修正することで解決されます:エラーをタイプ別に分け、複雑なシナリオに対してスティッキーセッションを使用し、正しい認証を行い、バンを常に監視します。この記事のコードは、特定のプロジェクトに適応するための基盤として使用できます — 構造は小規模なパーサーにも、分散型のScrapy-Clusterインストールにも適しています。
もしミドルウェアがすでに正しく設定されているのに、バンがあまりにも頻繁に発生する場合 — おそらく、IPプール自体の質に問題があります。高度なボット対策のあるサイトをパーシングする場合は、セッションをサポートするレジデンシャルプロキシを試してみると良いでしょう。これにより、同じミドルウェアのロジックでデータセンターのアドレスに比べて保護が発動する割合が大幅に減少します。