Scrapyは、スクレイピングがスクリプトの域を超えたときに手に取るフレームワークです。スケジューリング、並行処理、リトライ、パイプラインを標準で処理してくれます。residential proxyの追加は簡単ですが、Scrapyは通常のHTTPクライアントとは異なる方法でこれを行います。プロキシはダウンローダーミドルウェアによって処理されるリクエストごとの設定であり、そのアーキテクチャには認証まわりに気づきにくい特有の落とし穴があり、それがローテーションを静かに破壊します。ミドルウェアのモデルを理解すれば、すべてがうまく収まります。
これは、requestsとhttpxを扱うresidential proxies with Pythonと並ぶScrapy編です。Scrapyのダウンローダーミドルウェアのパイプラインはまったく別物なので、ここで単独に扱います。
以下ではすべて、Shifterのresidential gatewayを使用します。エンドポイントはp.shifter.io:443の一つだけで、ターゲティングはすべてユーザー名にエンコードされます。ホストと認証情報を別のプロバイダのものに置き換えても、形は同じです。
ゲートウェイモデルを一段落で
プロキシのユーザー名には認証情報とターゲティング情報の両方が含まれます。国やセッションを変更するためにエンドポイントを切り替えるのではなく、ユーザー名の文字列を変更します。
customer-USERNAME-country-us-sid-abc123-ttl-600country-usはアメリカ合衆国をターゲットにし、sidはスティッキーセッションを固定し、ttlはそのIPをN秒間保持します。sid/ttlを省略すると、新しい接続のたびにローテーションします。パスワードは変わりません。Scrapyでは、このユーザー名がProxy-Authorizationヘッダーになり、アイデンティティのローテーションはリクエストごとにそれを変更することを意味します。
Scrapyがプロキシを扱う仕組み
Scrapyは、組み込みのHttpProxyMiddlewareを通じてすべてのリクエストのプロキシをルーティングします。これはrequest.meta['proxy']を読み取ります。安易なセットアップは、そのURLに直接認証情報を埋め込むことです。
# The tempting one-liner. It works, until you rotate.request.meta['proxy'] = 'http://customer-USER-country-us:PASS@p.shifter.io:443'これは単一の固定アイデンティティでは機能します。ローテーションを試みた瞬間に壊れますが、その理由こそが知っておく価値のある落とし穴です。
落とし穴: Proxy-Authorizationはキャッシュされる
HttpProxyMiddlewareはプロキシURLに認証情報を見つけると、それをbase64エンコードしてProxy-Authorizationヘッダーにし、決定的に重要なことにそのヘッダーをリクエストにキャッシュします。あとでリクエストがリトライまたはリダイレクトされ、meta['proxy']が別のアイデンティティに変更された場合、ミドルウェアは常にヘッダーを再計算するわけではないため、リクエストは前のユーザー名の古いProxy-Authorizationのまま送信されます。ユーザー名がジオとセッションを運ぶゲートウェイでは、これはローテーションが静かに機能していないことを意味します。meta['proxy']内のユーザー名を変更しても、リクエストは依然として古いものとして認証されるのです。
修正方法は、プロキシURLに認証情報を入れるのをやめることです。ユーザー情報なしでプロキシホストを設定し、Proxy-Authorizationヘッダーは自分でリクエストごとに明示的に設定します。これはまさにカスタムミドルウェアの出番です。
カスタムローテーションミドルウェア
meta['proxy']には認証情報なしのホストだけを入れ、認証ヘッダーは望むアイデンティティからリクエストごとに計算します。ターゲティングはユーザー名の中にあるため、国とセッションを選ぶことは、単に正しいユーザー名を組み立てることに等しくなります。
import osfrom w3lib.http import basic_auth_header
class ShifterProxyMiddleware: def __init__(self): self.user = os.environ['SHIFTER_USER'] self.password = os.environ['SHIFTER_PASS'] self.endpoint = 'http://p.shifter.io:443' # host only, no credentials
def process_request(self, request, spider): country = request.meta.get('country', 'us') sid = request.meta.get('sid') # set for a sticky session, omit to rotate username = f"{self.user}-country-{country}" + (f"-sid-{sid}-ttl-600" if sid else "") request.meta['proxy'] = self.endpoint request.headers['Proxy-Authorization'] = basic_auth_header(username, self.password)これを有効化し、組み込みのプロキシミドルウェアよりも前に実行されるようにして、あなたが設定したヘッダーが実際に送信されるようにします。
DOWNLOADER_MIDDLEWARES = { 'myproject.middlewares.ShifterProxyMiddleware': 350, # before HttpProxyMiddleware (750)}これで、すべてのリクエストが独自に新しく計算されたProxy-Authorizationを持つようになり、リクエストのmeta内のcountryやsidを変更すると実際にアイデンティティが変わります。一つの論理的な作業単位に属するリクエストにはsidを設定してIPを共有させ、接続ごとにローテーションさせたい場合は外します(sticky vs rotatingがこの区別を扱っています)。このように作業をアイデンティティにマッピングするのは、Scrapy版のload-balancingパターンです。
スケジュールだけでなく、リトライ時にもローテーションする
ScrapyのRetryMiddlewareはすでにタイムアウトや5xxをリトライしますが、デフォルトでは同じアイデンティティでリトライします。失敗の原因がそのアイデンティティがブロックされたことであれば、これは意味がありません。価値の高い施策は、リクエストが失敗したりチャレンジを受けたりしたときに、特にアイデンティティをローテーションすることです。ミドルウェア内でソフトブロックや403/429を検出し、新しいアイデンティティでリクエストを再スケジュールします。
def process_response(self, request, response, spider): if response.status in (403, 429) or looks_blocked(response): new = request.copy() new.meta.pop('sid', None) # drop the burned session -> fresh IP new.dont_filter = True return new # retry through a new identity return responseソフトブロックの検出はそれ自体一つの技術であり、200でもブロックページであることはあり得ます。これはdetecting blocked or fake contentのチェックと組み合わせてください。チャレンジを受けたレスポンスを受け入れるのではなく、ローテーションの合図として扱うことが、長時間のクロールを生き続けさせる要因です。
Scrapyの丁寧さの調整つまみを使う
Scrapyは、自前で組み立てたスクレイパーなら構築しなければならないレート制限のコントロールを提供してくれます。プロキシ群を使う場合、これらは重要性が下がるどころか、むしろ増します。ドメインごとの並行数に上限を設けて一つのターゲットを叩きすぎないようにし、遅延を追加し、サイトのレスポンスに適応するAutoThrottleを有効にします。
CONCURRENT_REQUESTS = 32CONCURRENT_REQUESTS_PER_DOMAIN = 8 # per-target cap, the one that mattersDOWNLOAD_DELAY = 0.5AUTOTHROTTLE_ENABLED = TrueRETRY_ENABLED = TrueRETRY_TIMES = 3ドメインごとの並行数は、プロキシプールを分散ハンマーに変えてしまわないための重要なつまみです。ターゲットの許容範囲を超えた並列性は、スループットではなくブロックを買うことになります(how to avoid getting blockedとscraping responsiblyの両方が該当します)。遅いレスポンスに対してAutoThrottleが引き下げを行うことは、健全な長時間クロールに必要な自制そのものです。
実際にプロキシ経由になっているか検証する
スパイダーをIPエコーエンドポイントに向け、run全体を信用する前に出口IPを確認します。
def start_requests(self): yield scrapy.Request('http://ip-api.com/json', meta={'country': 'us'}, callback=self.parse) # expect a US residential IP自分自身のIPが表示される場合は、ミドルウェアが適用されていないか、HttpProxyMiddlewareの後に順序付けられていることを意味します。タイムアウトの連続は、認証ヘッダーが誤っているか欠落していることを意味します。どちらもtimeout diagnosis guideで扱われています。
FAQ
なぜScrapyでプロキシのローテーションが実際には機能しないのですか?
ほぼ間違いなくProxy-Authorizationのキャッシュの落とし穴です。プロキシURLに認証情報を入れると、HttpProxyMiddlewareが認証ヘッダーをキャッシュするため、リトライ時にmeta['proxy']を変更しても、リクエストは古い認証情報を送信し続けます。ホストは認証情報なしで設定し、Proxy-Authorizationヘッダーはミドルウェアでリクエストごとに自分で計算してください。
ターゲティングのフラグはどこに入れますか?
ユーザー名の中に入り、それがProxy-Authorizationになります。カスタムミドルウェアはリクエストごとのmetaからcustomer-USER-country-<cc>-sid-<id>-ttl-<sec>を組み立てるので、ジオとセッションの選択は、単にリクエストにcountryとsidを設定するだけです。
特定のリクエストにスティッキーセッションを与えるにはどうすればいいですか?
そのリクエストのmetaに安定したsidを設定し、関連するリクエスト間で再利用します。接続ごとにローテーションさせたい場合はsidを省略してください。ミドルウェアがそれを正しいユーザー名に変換します。
すべてのリクエストでローテーションすべきですか、それともリトライ時だけですか? どちらにも用途があります。通常のトラフィックでは論理的な作業単位ごとにローテーションし、さらにリクエストがブロックされたりレート制限を受けたりした場合は強制的に新しいアイデンティティに切り替えることで、焼き切れたIPがそれ自身として再試行されないようにします。
ローテーティングプロキシを使っていても、DOWNLOAD_DELAYやAutoThrottleは必要ですか? はい。ローテーションは負荷をIP間に分散させますが、ドメインごとの並行数、遅延、AutoThrottleは、どれだけ多くのIPを持っていても単一のターゲットを圧倒しないようにするためのものです。丁寧さとローテーションは異なる問題を解決します。
まとめ
Scrapyとレジデンシャルプロキシの組み合わせは、一つの落とし穴を回避すれば強力になります。プロキシURLに認証情報を入れないでください。キャッシュされたProxy-Authorizationが静かにローテーションを無効化するからです。代わりに、meta['proxy']にホストを設定し、望むアイデンティティからリクエストごとにProxy-Authorizationヘッダーを計算する小さなダウンローダーミドルウェアを書き、そのアイデンティティを論理的な作業単位ごとにローテーションし、ブロックされたりレート制限を受けたりしたレスポンスに対しても再度ローテーションし、Scrapyのドメインごとの並行数とAutoThrottleに頼って丁寧さを保ってください。
そうすれば、Scrapyのスケジューラ、リトライ、パイプラインはプロキシ層に逆らうのではなく、協調して働きます。クロールをresidential gatewayに向け、プールの品質がそもそもどれだけリトライすることになるかを決めることを忘れないでください(IP reputation)。pricing pageには、自分のターゲットに対してテストできるGB単位のプランがあります。