Playwrightにレジデンシャルプロキシを組み込むのは、一度やり方を見れば数行の話だ。難しいのは起動オプションそのものではなく、誰も書き残さない細部だ。認証情報がどうターゲティングを符号化するのか、コンテキストごとのプロキシを壊すChromiumの落とし穴、ローテーションがブラウザコンテキストにどう対応するのか、そしてブラウザが読み込むあらゆる画像が課金対象の帯域であるという事実だ。
これはPythonでレジデンシャルプロキシを使う方法のブラウザ版に相当する記事だ。実際に動くコピペ用の例に加えて、動くだけのスニペットを本番運用に耐える自動化に変える部分を扱う。
以下はすべてShifterのレジデンシャルゲートウェイを使用する。エンドポイントはp.shifter.io:443の1つだけで、ターゲティングはすべてユーザー名に符号化されている。別のプロバイダーを使っている場合も構造は同じで、ホストと認証情報の形式を入れ替えるだけでよい。例はNode/JavaScriptで、Python版のPlaywrightスニペットは末尾近くにある。
最初に理解しておくべき一つのこと
レジデンシャルゲートウェイでは、プロキシのユーザー名が認証情報とターゲティングの両方を運ぶ。国やセッションを切り替えるにはエンドポイントを変えるのではなく、ユーザー名の文字列を変える。ユーザー名は次のような形をしている。
customer-USERNAME-country-us-sid-abc123-ttl-600左から順に読む。アカウントID、その後にフラグが続く。country-usは米国をターゲットにする。sid-abc123はスティッキーセッションを固定する。ttl-600はそのIPを600秒間保持する。sid/ttlを外すと、新しい接続ごとに新しいIPへローテーションする。パスワードは常に同じだ。これが全体のメンタルモデルであり、残りはこれをPlaywrightのプロキシスロットに接続するだけだ。
認証情報は環境変数に保持し、決してハードコードしないこと。
const USER = process.env.SHIFTER_USER; // アカウントのユーザー名const PASS = process.env.SHIFTER_PASS;const GATEWAY = "http://p.shifter.io:443";Playwright固有の注意点を先に一つ。ユーザー名とパスワードをプロキシURLにインラインで書いてはいけない。 Chromiumはインラインのプロキシ認証情報を無視するため、http://user:pass@hostは静かに認証に失敗する。Playwrightのプロキシオブジェクトには専用のusernameとpasswordフィールドがあるので、それらを使うこと。
10行版
ブラウザ全体で単一のプロキシを使う場合は、launch()にproxyを設定する。
const { chromium } = require("playwright");
const USER = process.env.SHIFTER_USER;const PASS = process.env.SHIFTER_PASS;
(async () => { const browser = await chromium.launch({ proxy: { server: "http://p.shifter.io:443", username: `${USER}-country-us`, // ターゲティングはここに書く password: PASS, }, }); const page = await browser.newPage(); await page.goto("https://api.ipify.org?format=json"); console.log(await page.textContent("body")); // {"ip":"<米国のレジデンシャルIP>"} await browser.close();})();usernameフィールドこそがゲートウェイモデル全体の要だ。${USER}-country-usは米国をターゲットにする。スティッキーIPが必要ならsid/ttlを追加し、さらに絞り込むならcity/asnを追加する。パスワードは変わらない。
Chromiumの落とし穴: コンテキストごとのプロキシ
単一プロキシ版は1つのアイデンティティには十分だ。しかしPlaywrightを使う本当の理由は多数のアイデンティティを同時に運用することであり、そのためにはブラウザコンテキストごとに異なるプロキシを使いたい(各コンテキストはそれぞれ独立したクッキー/ストレージを持つ、つまり独自の「ユーザー」だ)。
ここに落とし穴がある。Chromiumでは、コンテキストごとのプロキシは、あらかじめプロキシを設定した状態でブラウザを起動している場合にのみ機能する。プロキシなしで起動すると、newContext({ proxy })はすべて静かに無視される。対処法は起動時にプレースホルダーのサーバーを指定することだ。
const browser = await chromium.launch({ proxy: { server: "per-context" }, // プレースホルダー。コンテキストごとのプロキシを有効化する});
// 各コンテキストが独自の実プロキシとターゲティングを持てるようになるconst ctxUS = await browser.newContext({ proxy: { server: "http://p.shifter.io:443", username: `${USER}-country-us-sid-a1-ttl-600`, password: PASS, },});const ctxDE = await browser.newContext({ proxy: { server: "http://p.shifter.io:443", username: `${USER}-country-de-sid-b2-ttl-600`, password: PASS, },});ctxUSは米国IPで、ctxDEはドイツのIPで、同時に、1つのブラウザ内で閲覧する。これが構築のベースとなるパターンだ。(FirefoxとWebKitは厳密にはこのプレースホルダーを必要としないが、設定しても害はなく、コードをブラウザ間で共通化できる。)
ローテーションとスティッキー、Playwrightでの流儀
生のHTTPスクレイパーではリクエストごとにローテーションすることが多い。しかしブラウザでは、それは通常間違いだ。実際のユーザーはセッション中にIPを変えないため、クッキーが一定のままページ読み込みごとにIPが切り替わることは検知の合図になる。慣用的な対応は次の通りだ。
- 1コンテキスト = 1つのスティッキーなアイデンティティ。 各コンテキストに一意の
sidと、そのセッションをカバーするttlを与え、すべてのページ読み込みが実際のユーザーのように1つのIPを共有するようにする。 - 新しいコンテキストを作ってローテーションする。 新しい
sidを持つ(あるいはsidのない)新しいコンテキストは新しいIPを得る。使い終わったら古いコンテキストを閉じる。
async function contextForSession(browser, sessionId, country = "us") { return browser.newContext({ proxy: { server: "http://p.shifter.io:443", username: `${USER}-country-${country}-sid-${sessionId}-ttl-600`, password: PASS, }, });}
// 50個のターゲットを処理する。それぞれがIPの安定した独自のアイデンティティを持つfor (let i = 0; i < 50; i++) { const ctx = await contextForSession(browser, `job-${i}`, "us"); const page = await ctx.newPage(); await page.goto("https://example.com/item/" + i); // ... スクレイピング処理 ... await ctx.close(); // このアイデンティティの使用を終了}sidは自分で決める。論理的なセッションごとに一意な文字列であればよい。同じ文字列はTTLが切れるまで同じIPを返す。コンテキストは50個を一度にではなく、少数のプールで並行実行すること。ブラウザを増やすことは速度の向上にはつながらず、1つのターゲットに対するIPの急激な集中は挙動による検知に引っかかる。断続的ではなく常にブロックされる場合は、問題はローテーションではなくIPの品質か挙動にある。スクレイピングでブロックを避ける方法を参照してほしい。
ジオターゲティングと、ブラウザとIPの整合
ターゲティングがユーザー名に格納されているため、ジオはただのフラグでしかない。country-deとし、さらにcity-berlinやasn-3320で絞り込むこともできる。しかし実際のブラウザでは、人々が見落としがちな2つ目の要素がある。ブラウザのフィンガープリントはIPと一致していなければならない。 ドイツのレジデンシャルIPにen-USのロケールとニューヨークのタイムゾーンが組み合わさっていると、それは矛盾であり、アンチボットシステムはすぐに検知する。(スクレイパーがブロックされる理由を参照。)
Playwrightではこれらすべてをコンテキスト上で揃えることができる。
const ctx = await browser.newContext({ proxy: { server: "http://p.shifter.io:443", username: `${USER}-country-de-city-berlin-sid-de1-ttl-600`, password: PASS, }, locale: "de-DE", timezoneId: "Europe/Berlin", geolocation: { latitude: 52.52, longitude: 13.405 }, permissions: ["geolocation"],});都市名はゲートウェイの形式(小文字)を使う。フラグは自由に組み合わせられ、順序はゲートウェイにとって関係ない。locale/timezoneId/geolocationをプロキシの国に一致させることは、本物のローカルユーザーに見せるために最も効果の高い対策の一つだ。
帯域を削る(GB単位で課金される)
これはブラウザに特有の話であり、重要だ。レジデンシャルプロキシはギガバイト単位で課金されるからだ。放置されたヘッドレスブラウザは、ページ上のすべての画像、フォント、動画、トラッキングスクリプトをダウンロードするが、そのほとんどはスクレイピングに不要だ。これらを中止することで帯域(とコスト)を半分以上削減できることが多く、実行速度も上がる。
context.route()を使い、重いリソースタイプがプロキシに到達する前にブロックする。
await ctx.route("**/*", (route) => { const type = route.request().resourceType(); if (["image", "media", "font"].includes(type)) { return route.abort(); // ダウンロードされないので課金もされない } return route.continue();});document、script、xhr、fetch(実データとそれを描画するJS)は残し、残りは削除する。サイトが画像の背後で遅延読み込みするコンテンツがある場合は、スクレイピングがそれでも動くことを確認すること。しかしほとんどの作業ではこれは実質無料で得られる利益だ。GB単位で課金されるレジデンシャルプロキシで扱われているGB単位の料金モデルと相性が良い。
実際に動いているかを確認する
設定を信頼する前に、2つのことを確認する。IPが正しい国にあること(ジオ)、そしてコンテキスト間で変わること(ローテーション)だ。簡単な確認方法は次の通り。
const page = await ctx.newPage();await page.goto("http://ip-api.com/json");const data = JSON.parse(await page.textContent("body"));console.log(data.query, data.countryCode); // DEのIPを期待する異なるsidを持つ2つのコンテキストを立ち上げれば、両方とも正しい国の、異なる2つのIPが見えるはずだ。国が間違っている場合は、フラグのスペルを確認する。コンテキスト間でIPが一度も変わらない場合、おそらく起動時のプレースホルダーを忘れていて、Chromiumがコンテキストごとのプロキシを無視している。
実際に起こるエラー
Playwrightはプロキシの問題をHTTPステータスコードではなくナビゲーションエラーとして表面化させる。遭遇する3つのパターンは以下の通り。
net::ERR_TUNNEL_CONNECTION_FAILEDプロキシ認証の失敗、または認識されないフラグ(countryやasnのタイプミス、またはChromiumが無視したインライン認証情報)。ユーザー名/パスワードのフィールドを修正すること。再試行しても解決しない。net::ERR_PROXY_CONNECTION_FAILEDゲートウェイに到達できなかった、または過度に厳密なフィルター(国 + 都市 + asnなど)に一致するIPがなかった。接続を確認し、フラグを緩める。- ページは読み込まれるがブロック/CAPTCHAが表示される プロキシではなくターゲット側がアクセスを拒否している。新しいコンテキスト/IPへローテーションし、速度を落とし、前述の通りフィンガープリントを整合させる。
失敗時に新しいコンテキストを作る小さなリトライでナビゲーションをラップする。
async function gotoWithRetry(browser, url, country = "us", tries = 3) { for (let attempt = 0; attempt < tries; attempt++) { const ctx = await contextForSession(browser, `r-${Date.now()}-${attempt}`, country); const page = await ctx.newPage(); try { await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 }); return { ctx, page }; // 呼び出し側が使い終わったらctxを閉じる } catch (e) { await ctx.close(); if (attempt === tries - 1) throw e; await new Promise((r) => setTimeout(r, 2 ** attempt * 1000)); // 1秒、2秒 } }}新しいコンテキストは新しいIPを意味するので、リトライは同じ失敗するルートではなく、実際に別のルートを取得する。
SOCKS5についての補足
このゲートウェイはSOCKS5にも対応しているが、知っておくべきPlaywrightの制約がある。Playwright/Chromiumは認証付きのSOCKSプロキシをサポートしていない。 このゲートウェイはユーザー名で認証を行う(そこにターゲティングが格納されている)ため、SOCKS5はここでは実用的ではなく、上記に示したHTTPプロキシを使うこと。これはブラウザスクレイピングにおいてもともと正しいデフォルトである。SOCKSのトレードオフについては自動化のためのSOCKS5プロキシを参照。
Pythonでも同じこと
PlaywrightのPython APIはJavaScript版をそのまま反映しており、プロキシオブジェクトも同一だ。
from playwright.sync_api import sync_playwrightimport os
USER, PASS = os.environ["SHIFTER_USER"], os.environ["SHIFTER_PASS"]
with sync_playwright() as p: browser = p.chromium.launch(proxy={"server": "per-context"}) ctx = browser.new_context(proxy={ "server": "http://p.shifter.io:443", "username": f"{USER}-country-us-sid-a1-ttl-600", "password": PASS, }) page = ctx.new_page() page.goto("https://api.ipify.org") print(page.text_content("body")) browser.close()起動時のプレースホルダーのルールも、ユーザー名に符号化されたターゲティングも同じだ。ブラウザ以外のHTTP処理については、Pythonガイドがrequests、httpx、Scrapyを扱っている。
FAQ
なぜPlaywrightでhttp://user:pass@hostが機能しないのか?
Chromiumはインラインのプロキシ認証情報を無視する。代わりにproxyオブジェクトの専用フィールドであるusername/passwordにユーザー名とパスワードを設定すること。これはPlaywrightで「プロキシが機能しない」の最も一般的な原因だ。
なぜコンテキストごとのプロキシが無視されるのか?
プロキシなしでChromiumを起動したからだ。コンテキストごとのプロキシは、ブラウザがプロキシ付きで起動された場合にのみ有効になる。launch()にプレースホルダーのproxy: { server: "per-context" }を渡し、その後各コンテキストに実際のプロキシを設定すること。
ブラウザではリクエストごとにIPをローテーションすべきか?
いいえ。ブラウザセッションは実際のユーザーのように1つのIPを保持すべきだ。コンテキストごとにスティッキーなsidを使い、セッション中にIPを切り替えるのではなく、新しいコンテキストを作ってローテーションすること。
異なる国を同時に実行するにはどうすればよいか?
1つのブラウザで複数のコンテキストを使い、各コンテキストのプロキシユーザー名に異なるcountry-<cc>を設定する。それらは並行して独立に動作する。
これはPuppeteerやSeleniumでも動作するか?
ゲートウェイ自体は動作する。Puppeteerは起動時に--proxy-serverを取り、page.authenticate()で認証する。Seleniumはプロキシのcapabilityか、認証用の拡張機能を使う。ユーザー名に符号化されたターゲティングは同一で、配線の仕方だけが異なる。Playwrightのコンテキストごとのモデルは、複数アイデンティティの作業においては最も洗練されている。
実行するたびに帯域を急速に消費してしまう。どうすればよいか?
上記の通りcontext.route()を使って画像、メディア、フォントをブロックすること。ブラウザはデフォルトですべてをダウンロードし、GB単位のプランではそれが実際のコストになる。重いリソースを中止することで、使用量を半分以上削減できることが多い。
まとめ
パターンはこうだ。per-contextのプレースホルダーでChromiumを起動し、各コンテキストにcountry/sid/ttlを符号化したusernameを持つプロキシを与え、locale/timezone/geolocationを国に合わせ、画像とフォントを中止して帯域を節約する。ローテーションはIPを切り替えるのではなく新しいコンテキストを作ることで行う。エラーはnet::ナビゲーション失敗として現れ、新しいコンテキストを構築するリトライによって新しいIPが得られる。
上記のスニペットから始め、レジデンシャルゲートウェイを指定すれば、午後のうちに本番運用向けのブラウザ自動化が手に入る。プランとGB単位の料金は料金ページにあり、フラグの完全なリファレンスはゲートウェイのドキュメントにある。