Node.jsは、スクレイピングや自動化作業の膨大な割合においてデフォルトのランタイムとなっている。数千の同時リクエストを難なくさばくイベントループ、巨大なパッケージエコシステム、そしてフロントからバックまで同じ言語を使える点がその理由だ。residential proxyを組み込むのは数行で済むが、細部でつまずくポイントがNode特有の形で存在する。最も人気のあるHTTPクライアントであるAxiosのproxyオプションは、HTTPS上では期待通りに動作しないからだ。エージェントさえ正しく設定すれば、あとは簡単になる。
これはPythonでのresidential proxy、Playwrightでの利用、Goでの利用と同じシリーズのNode.js編であり、Axios、Got、ネイティブfetchで動くコードに加え、Nodeのエコシステム特有の落とし穴を扱う。
以下はすべてShifterのresidential gatewayを使用する。エンドポイントはp.shifter.io:443の一つで、ターゲティングはすべてユーザー名にエンコードされる。ホストと認証情報を別のプロバイダーのものに置き換えれば、形はそのまま使える。
ゲートウェイモデルを一段落で
プロキシのユーザー名は、認証情報とターゲティング情報の両方を運ぶ。国やセッションを変更するためにエンドポイントを切り替えるのではなく、ユーザー名の文字列を変更する。
customer-USERNAME-country-us-sid-abc123-ttl-600country-usは米国をターゲットにし、sidはスティッキーセッションを固定し、ttlはそのIPをN秒間保持する。sid/ttlを省略すると、新しい接続のたびにローテーションする。パスワードは変わらない。Nodeでは、この文字列全体がプロキシエージェントに渡すプロキシURLに入る。
Axios: proxyオプションではなくエージェントを使う
これがこの記事で最も重要な一点だ。Axiosにはproxyオプションがあるが、認証付きのHTTPSターゲットに対しては信頼性が低く、適切なCONNECTトンネルを開かずに、静かに失敗したり実IPを漏らしたりする。エコシステムが落ち着いた解決策は、https-proxy-agentから構築したhttpsAgentをAxiosに渡し、proxy: falseを設定してAxios自身がプロキシを処理しようとしないようにすることだ。
import axios from 'axios';import { HttpsProxyAgent } from 'https-proxy-agent';
const user = `${process.env.SHIFTER_USER}-country-us`;const pass = process.env.SHIFTER_PASS;const proxyUrl = `http://${user}:${pass}@p.shifter.io:443`;
const agent = new HttpsProxyAgent(proxyUrl);
const client = axios.create({ httpsAgent: agent, proxy: false, // critical: let the agent handle it, not Axios timeout: 30000,});
const res = await client.get('https://api.ipify.org');console.log(res.data); // a US residential IP心に留めておくべきことが二つある。user文字列にはターゲティングフラグ(-country-us)が含まれる、なぜならジオ情報はそこにあるからだ。そしてproxy: falseはオプションではない、これがないとAxios自身のプロキシロジックがエージェントと衝突し、まさに避けようとしていた壊れた挙動が発生する。この一行が、Node-proxyにおける最も一般的なバグだ。
Got: エージェントはagent.httpsスロットに入れる
Gotはプロトコルごとにキー付けされたagentオプションを通してプロキシエージェントを受け取る。内部で使われるのは同じhttps-proxy-agentだが、Gotには争うべき組み込みのプロキシ処理がないため、proxy: falseのような対処は不要だ。
import got from 'got';import { HttpsProxyAgent } from 'https-proxy-agent';
const proxyUrl = `http://${user}:${pass}@p.shifter.io:443`;
const res = await got('https://api.ipify.org', { agent: { https: new HttpsProxyAgent(proxyUrl) }, timeout: { request: 30000 },});console.log(res.body); // a US residential IPパッケージングに関して一つ注意点がある。Gotはv12以降純粋なESMになっているため、import got from 'got'にはESMプロジェクト(package.jsonの"type": "module")か動的なimport()が必要になる。CommonJSに固定されているなら、Got v11に留まるか、Axios/undiciに切り替えること。このESM対CommonJSの分裂はNode特有のつまずきポイントであり、プロキシの問題ではないが、初めてこれを設定する人を悩ませる。
ネイティブfetch: undiciのProxyAgentをディスパッチャーとして
Node 18以降は、undiciを裏付けとするグローバルなfetchを提供しており、undiciはhttps-proxy-agentをまったく使わない独自のプロキシサポートを持つ。リクエストのdispatcherとしてProxyAgentを渡す。
import { ProxyAgent } from 'undici';
const dispatcher = new ProxyAgent(`http://${user}:${pass}@p.shifter.io:443`);
const res = await fetch('https://api.ipify.org', { dispatcher });console.log(await res.text()); // a US residential IPプロセス内のすべてのfetchをプロキシ経由でルーティングするには、代わりにグローバルに設定する。
import { setGlobalDispatcher, ProxyAgent } from 'undici';setGlobalDispatcher(new ProxyAgent(proxyUrl));モダンなNodeを使っていてHTTPクライアントの依存関係をゼロにしたいなら、これが最もクリーンな道だ。
落とし穴1: エージェントを再利用する、リクエストごとに構築しない
どのクライアントを選んでも、プロキシエージェントは接続プールを保持する。リクエストごとに新しいHttpsProxyAgentやProxyAgentを構築すると、keep-aliveが捨てられ、毎回プロキシ経由でフルのTCP + TLSハンドシェイクを支払うことになる。このオーバーヘッドを取り除くためにlatency guideが存在する。特定のアイデンティティに対してエージェントを一度だけ構築し、リクエスト間で再利用すること。Axios/Gotのクライアント(またはundiciのディスパッチャー)は起動時に作成して保持する。
落とし穴2: 並行性に上限を設ける、イベントループはそれを代わりにやってくれない
Nodeのイベントループは1000件のリクエストを一度に発射するのを容易にし、何もそれを止めない。大きな配列に対するawait Promise.all(urls.map(fetchOne))は、すべての接続を同時に開いてしまい、自分側でソケットを使い果たし、ターゲット側からは攻撃のように見える。稼働中のリクエストを小さな並行性リミッター(p-limitが一般的な選択)や単純なキューで制限する。
import pLimit from 'p-limit';
const limit = pLimit(8); // at most 8 requests in flightconst results = await Promise.all( urls.map(url => limit(() => client.get(url))));並行性は全体だけでなく、ターゲットのホストごとに上限を設定し、脆弱なサイトが叩かれる一方で寛容なサイトが飢えるようなことがないようにする。ターゲットの許容量を超える並列性はブロックを招くだけで、スループットにはつながらない(how to avoid getting blocked)。各ホストが許容する範囲に上限を合わせること。
落とし穴3: 非同期エラーには明示的な処理が必要
プロキシや接続の失敗はrejectされたPromiseとして表面化し、未処理のrejectionはプロセスをクラッシュさせるか、さらに悪いことに、バッチ内のタスクを静かに落としてしまう。各リクエストをラップして、トランスポートの失敗が実行全体を潰すのではなく、新しいアイデンティティで再試行するようにする。
async function fetchWithRetry(client, url, attempts = 3) { for (let i = 0; i < attempts; i++) { try { return await client.get(url); } catch (err) { if (i === attempts - 1) throw err; // transient (ECONNRESET, timeout, proxy 5xx): back off and retry await new Promise(r => setTimeout(r, 500 * 2 ** i)); } }}再試行する価値のある壊れた接続と、意図的な減速とを区別すること。タイムアウトは壊れた試行だが、429はサーバーが余裕を求めているサインであり、叩き続けるのではなく後退すべきだ。
ジオとセッションのローテーション
ターゲティング情報がユーザー名にあるため、異なるアイデンティティは異なるプロキシURLを意味し、それは異なるエージェントを意味する。効率的なパターンは、アイデンティティごとにエージェントをキャッシュし、再構築する代わりにセッションごとの接続プールを維持することだ。
const agents = new Map();
function agentFor(country, sid) { const key = `${country}:${sid ?? 'rotate'}`; if (!agents.has(key)) { const u = `${process.env.SHIFTER_USER}-country-${country}` + (sid ? `-sid-${sid}-ttl-600` : ''); const url = `http://${u}:${process.env.SHIFTER_PASS}@p.shifter.io:443`; agents.set(key, new HttpsProxyAgent(url)); } return agents.get(key);}
// per request:await axios.get(targetUrl, { httpsAgent: agentFor('de', 'job-42'), proxy: false });論理的な作業単位ごとに独自のsidを与え、ストリームの途中ではなく単位間でローテーションすること(sticky vs rotatingがこの違いを扱っている)、そしてload-balancing postが説明する方法で作業をアイデンティティにマッピングする。
実際にプロキシ経由になっているか確認する
ベンチマークやデバッグを始める前に、出口IPを確認する。
const res = await client.get('http://ip-api.com/json');console.log(res.data); // expect a residential IP in the targeted country自分自身のIPが表示される場合は、エージェントが適用されていない(Axiosの場合、ほとんど常にproxy: falseの欠落が原因)。ハングする場合は、ローカルの発信がブロックされている。どちらもtimeout diagnosis guideで扱われている。
FAQ
なぜAxiosのproxyオプションはHTTPSプロキシで動作しないのか?
Axiosの組み込みproxyオプションは、認証付きのHTTPSを信頼性高くトンネルできない。https-proxy-agentから構築したhttpsAgentを使い、proxy: falseを設定してAxiosがそれを処理しようとするのを止めさせること。この組み合わせが信頼できる道であり、「実IPが返ってくる」という症状を修正する。
ネイティブfetchを使う場合、https-proxy-agentは必要か?
不要だ。Node 18以降のfetchはundiciに裏付けられており、undiciはdispatcherとして渡す(またはグローバルにsetGlobalDispatcherで設定する)独自のProxyAgentを持っている。https-proxy-agentはAxios、Got、そして組み込みのhttp/httpsモジュール向けだ。
なぜ自分のプロジェクトでimport got from 'got'がエラーになるのか?
Gotはv12以降純粋なESMなので、ESMプロジェクト("type": "module")か動的なimport()が必要だ。CommonJSでは、Got v11に留まるか、代わりにAxios/undiciを使うこと。これはプロキシの問題ではなく、モジュールシステムの問題だ。
Node.jsでリクエストごとにIPをローテーションするにはどうすればよいか?
プロキシのユーザー名を変えることで、異なるプロキシURLと異なるエージェントになる。Map内にアイデンティティごとに一つのエージェントをキャッシュし、各セッションが独自の接続プールを持つようにし、リクエストごとにエージェントを選択する。新しい接続のたびにローテーションするには、ユーザー名のsidを省略する。
スクレイピングにはAxios、Got、fetchのどれを使うべきか?
三つとも動作する。ネイティブfetch + undiciはモダンなNode上で追加の依存関係がゼロであり、Gotは扱いやすい再試行とストリームを持ち、Axiosは広く普及していて馴染み深いがproxy: falseの修正が必要だ。使い勝手と既存の依存関係で選ぶこと、上述のプロキシ設定と落とし穴はすべてに当てはまる。
結論
Node.jsとresidential proxyの組み合わせは、一つの分かりにくいルールさえ知っていればすぐに設定できる。Axiosではhttps-proxy-agentを使いproxy: falseを設定すること、組み込みのproxyオプションは決して使わないこと。Gotではエージェントをagent.httpsに入れること。ネイティブfetchではundiciのProxyAgentをディスパッチャーとして渡すこと。そして接続を温かく保つためにエージェントを再利用し、イベントループは代わりにやってくれないので並行性に上限を設け、非同期エラーを処理して悪いリクエストがクラッシュではなく再試行につながるようにし、ジオやセッションを変更するためにプロキシのユーザー名を変える。
これさえ正しく行えば、Nodeは同時収集を他のどんなものと同じくらいうまく処理する。residential gatewayを指定し、プールの品質がどれだけ再試行することになるかを左右することを覚えておくこと(IP reputation)。pricing pageには、自分のターゲットに対してテストするためのGB単位のプランが掲載されている。