なぜ生の Puppeteer はすぐにバンされるのか
Node.js でブラウザ自動化を始めた多くのエンジニアが、最初にぶつかる壁——「スクリプトを走らせたら即座に CAPTCHA が出る」「403 が返ってくる」。これはあなたのコードが悪いのではなく、Chromium の自動化フラグが至る所に露出しているからです。
生の Puppeteer(および Playwright)が検知される主なシグナルは次の通りです:
navigator.webdriver— W3C WebDriver 仕様により自動操作時にtrueが設定される。最も古典的な検知ポイント。- Plugins & MIME types の不整合 — ヘッドレス Chrome は plugins 配列が空。本物の Chrome は PDF Viewer 等が存在。
- iframe 内の
chrome.runtime— Chrome 拡張の有無で自動ブラウザか判定。 - User-Agent の矛盾 — UA 文字列と
navigator.platform、navigator.hardwareConcurrency等の組み合わせ不整合。 - Canvas / WebGL フィンガープリント — ヘッドレス環境特有のレンダリング差異。
navigator.languagesの欠落 — ヘッドレスモードでは空配列になることがある。
これらのシグナルを一つでも残していると、Cloudflare、Datadome、PerimeterX などの WAF は数ミリ秒であなたを「ボット」と分類します。
puppeteer-extra + Stealth プラグインがパッチするシグナル
puppeteer-extra は Puppeteer のプラグインアーキテクチャであり、puppeteer-extra-plugin-stealth はその中で最も重要なプラグインです。Stealth プラグインは 10 個以上のイベイジョンモジュールをチェーンとして適用し、ブラウザ起動直後から検知シグナルを消し去ります。
主なパッチ内容
| モジュール | パッチ対象 | 効果 |
|---|---|---|
webdriver | navigator.webdriver | undefined または false を返すようオーバーライド |
chrome.runtime | iframe 内の chrome.runtime | 本物の Chrome と同様のオブジェクトを注入 |
navigator.plugins | plugins / MIME types 配列 | 標準的なプラグイン一覧を偽装 |
languages | navigator.languages | OS ロケールに合致する言語配列を設定 |
webgl | WebGL レンダラー / ベンダー | 一般的な GPU 情報に偽装 |
iframe.contentWindow | クロスオリジン iframe の検知 | contentWindow の挙動を修正 |
media-codecs | コーデックサポートの不整合 | ヘッドレス特有の差異を修正 |
user-agent-override | UA と関連プロパティの整合性 | platform / hardwareConcurrency 等と整合する UA を設定 |
最小構成のコードはこうなります:
const puppeteer = require('puppeteer-extra');
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
// Stealth プラグインを登録
puppeteer.use(StealthPlugin());
(async () => {
const browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
const page = await browser.newPage();
await page.goto('https://bot.sannysoft.com/');
// 検知テストページのスクリーンショットで確認
await page.screenshot({ path: 'stealth-check.png', fullPage: true });
await browser.close();
})();
これだけで 大半の初歩的検知はパスします。しかし、高度な WAF を相手にするにはプロキシとの組み合わせが不可欠です。
Stealth + レジデンシャルプロキシ=最強のアンチ検知スタック
Stealth プラグインはブラウザのシグナルを隠しますが、IP レピュテーションは隠せません。データセンター IP からのリクエストは、ブラウザシグナルが完璧でも「不審な IP」としてフラグが立ちます。
レジデンシャルプロキシは ISP から割り当てられた本物の住宅 IP を使用するため、WAF はトラフィックを「一般ユーザー」として扱います。この組み合わせが機能する理由:
- IP レピュテーション:データセンター IP は AS 番号で一括ブロックされるが、レジデンシャル IP はブロックリストに載りにくい
- ジオロケーション一貫性:IP の地理位置と
navigator.language/ タイムゾーンが一致すれば、さらに信頼度が上がる - リクエスト分散:IP ローテーションにより単一 IP への負荷を分散
ProxyHat のレジデンシャルプロキシを組み合わせた実装例:
const puppeteer = require('puppeteer-extra');
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
puppeteer.use(StealthPlugin());
// ProxyHat レジデンシャルプロキシ設定
const PROXY_HOST = 'gate.proxyhat.com';
const PROXY_PORT = 8080;
function getProxyUrl(country = 'US') {
// ユーザー名に国コードを埋め込んでジオターゲティング
const username = `user-country-${country}`;
const password = 'YOUR_PASSWORD';
return `http://${username}:${password}@${PROXY_HOST}:${PROXY_PORT}`;
}
(async () => {
const proxyUrl = getProxyUrl('US');
const browser = await puppeteer.launch({
headless: 'new',
args: [
`--proxy-server=${proxyUrl}`,
'--no-sandbox',
'--disable-setuid-sandbox',
// タイムゾーンをプロキシのジオロケーションに合わせる
'--tz=America/New_York',
],
});
const page = await browser.newPage();
// ProxyHat はプロキシ認証を使うので、
// Chromium の --proxy-server では認証ヘッダーを注入する必要がある
await page.authenticate({
username: 'user-country-US',
password: 'YOUR_PASSWORD',
});
// 言語設定もジオロケーションに合わせる
await page.setExtraHTTPHeaders({
'Accept-Language': 'en-US,en;q=0.9',
});
await page.goto('https://httpbin.org/ip');
const ip = await page.$eval('pre', el => el.textContent);
console.log('出口IP:', ip);
await browser.close();
})();
重要:Chromium の--proxy-serverフラグは認証情報を含めません。Proxy の認証が必要な場合はpage.authenticate()を使うか、puppeteer-extra-plugin-proxyのようなミドルウェアで Authorization ヘッダーを注入してください。
カスタム Evaluator:Canvas / WebGL フィンガープリントのセッション単位ランダム化
Stealth プラグインは「ヘッドレス特有の差異」を修正しますが、同じフィンガープリントが複数セッションで使い回されると、WAF は「このフィンガープリントのブラウザが短時間に大量アクセスしている」とパターン検知できます。
対策はセッションごとに Canvas / WebGL フィンガープリントをランダム化することです。puppeteer-extra の onPageCreated フックを使い、各ページの初期化時にカスタム evaluator を注入します。
const puppeteer = require('puppeteer-extra');
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
// カスタムプラグイン:フィンガープリントのセッション単位ランダム化
class FingerprintRandomizerPlugin {
constructor() {
this.name = 'fingerprint-randomizer';
}
// puppeteer-extra のプラグインインターフェースに準拠
get name() { return this._name; }
set name(n) { this._name = n; }
// onPageCreated フック:各ページ作成時に呼ばれる
async onPageCreated(page) {
const seed = Math.random();
// Canvas フィンガープリントにノイズを注入
await page.evaluateOnNewDocument((s) => {
const origToDataURL = HTMLCanvasElement.prototype.toDataURL;
HTMLCanvasElement.prototype.toDataURL = function (...args) {
// キャンバスに不可視ノイズを追加してから元のメソッドを呼ぶ
const ctx = this.getContext('2d');
if (ctx) {
const imgData = ctx.getImageData(0, 0, this.width, this.height);
for (let i = 0; i < imgData.data.length; i += 4) {
// seed ベースの微弱ノイズ(人間の目には見えない)
imgData.data[i] = imgData.data[i] + Math.round(s * 2 - 1);
}
ctx.putImageData(imgData, 0, 0);
}
return origToDataURL.apply(this, args);
};
// WebGL レンダラー / ベンダーのランダム化
const getParameter = WebGLRenderingContext.prototype.getParameter;
WebGLRenderingContext.prototype.getParameter = function (param) {
// UNMASKED_VENDOR_WEBGL = 0x9245, UNMASKED_RENDERER_WEBGL = 0x9246
if (param === 0x9245) return 'Google Inc. (NVIDIA)';
if (param === 0x9246) {
const renderers = [
'ANGLE (NVIDIA, NVIDIA GeForce GTX 1060, OpenGL 4.5)',
'ANGLE (NVIDIA, NVIDIA GeForce GTX 1650, OpenGL 4.5)',
'ANGLE (AMD, AMD Radeon RX 580, OpenGL 4.5)',
];
return renderers[Math.floor(s * renderers.length)];
}
return getParameter.call(this, param);
};
}, seed);
}
}
puppeteer.use(StealthPlugin());
puppeteer.use(new FingerprintRandomizerPlugin());
(async () => {
const browser = await puppeteer.launch({
headless: 'new',
args: [
'--proxy-server=http://user-country-US:YOUR_PASSWORD@gate.proxyhat.com:8080',
'--no-sandbox',
],
});
const page = await browser.newPage();
await page.authenticate({
username: 'user-country-US',
password: 'YOUR_PASSWORD',
});
await page.goto('https://browserleaks.com/canvas');
console.log('Canvas hash:', await page.title());
await browser.close();
})();
このアプローチの利点は、プラグインとしてきれいに分離されていることです。puppeteer-extra のプラグインシステムは onPageCreated、onBrowserCreated、onTargetCreated などのライフサイクルフックを提供しており、ハックではなく拡張ポイントとして実装できます。
ブラウザコンテキスト単位のプロキシローテーション
本番運用では、1 リクエスト = 1 ブラウザインスタンスはリソースの無駄です。Chromium の Browser Context(Incognito モードに相当)を使えば、1 ブラウザプロセス内で独立したセッションを複数作れます。
ただし、Chromium の --proxy-server はプロセス単位の設定であり、コンテキストごとに異なるプロキシを指定できません。これを解決するには、リクエストレベルでプロキシ URL を切り替えるアプローチを取ります。
アプローチ:ローカルプロキシミドルウェア + Browser Context
ローカルに軽量なプロキシミドルウェアを立て、各コンテキストのリクエストを異なる ProxyHat エンドポイントへルーティングします:
const puppeteer = require('puppeteer-extra');
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
const http = require('http');
const httpProxy = require('http-proxy');
puppeteer.use(StealthPlugin());
// ローカルプロキシミドルウェア:コンテキスト ID に応じて
// ProxyHat の異なるジオロケーションへルーティング
const proxy = httpProxy.createProxyServer({});
const contextProxyMap = new Map(); // contextId -> upstream proxy URL
const localProxy = http.createServer((req, res) => {
// カスタムヘッダーからコンテキスト ID を取得
const ctxId = req.headers['x-context-id'];
const upstream = contextProxyMap.get(ctxId) || 'http://gate.proxyhat.com:8080';
proxy.web(req, res, {
target: upstream,
changeOrigin: true,
auth: extractAuthFromUrl(upstream),
}, (err) => {
console.error('プロキシエラー:', err.message);
res.writeHead(502);
res.end('Bad Gateway');
});
});
localProxy.listen(8888);
// コンテキストごとのプロキシ設定を登録
function registerContextProxy(contextId, country) {
contextProxyMap.set(contextId, `http://gate.proxyhat.com:8080`);
// 認証情報はコンテキスト ID に紐付けて管理
return {
username: `user-country-${country}-session-${contextId}`,
password: 'YOUR_PASSWORD',
};
}
(async () => {
const browser = await puppeteer.launch({
headless: 'new',
args: [
'--proxy-server=http://127.0.0.1:8888',
'--no-sandbox',
],
});
// 複数の Browser Context で並列スクレイピング
const countries = ['US', 'DE', 'JP', 'GB'];
const tasks = countries.map(async (country) => {
const ctxId = `ctx-${country}-${Date.now()}`;
const creds = registerContextProxy(ctxId, country);
const context = await browser.createIncognitoBrowserContext();
const page = await context.newPage();
// 各コンテキストのリクエストにコンテキスト ID を付与
await page.setExtraHTTPHeaders({ 'X-Context-Id': ctxId });
await page.authenticate(creds);
// タイムゾーンと言語をジオロケーションに合わせる
const localeMap = { US: 'en-US', DE: 'de-DE', JP: 'ja-JP', GB: 'en-GB' };
const tzMap = { US: 'America/New_York', DE: 'Europe/Berlin', JP: 'Asia/Tokyo', GB: 'Europe/London' };
await page.evaluateOnNewDocument((locale, tz) => {
Object.defineProperty(navigator, 'language', { get: () => locale });
}, localeMap[country], tzMap[country]);
await page.goto('https://httpbin.org/ip');
const ip = await page.$eval('pre', el => el.textContent);
console.log(`${country} 出口IP:`, ip);
await context.close();
});
await Promise.all(tasks);
await browser.close();
localProxy.close();
})();
このパターンにより、1 ブラウザプロセス内で複数ジオロケーションの同時スクレイピングが可能になります。Browser Context は Cookie・ストレージ・キャッシュが完全に分離されるため、セッション間の干渉もありません。
スケーリング:コンテナ化フリート、ブラウザプール、リソース管理
コンテナ化戦略
本番レベルのスクレイピングでは、1 台のホストで全セッションを賄うのは非現実的です。Docker コンテナでブラウザワーカーをパッケージ化し、オーケストレーターで管理します。
# Dockerfile.browser-worker
FROM node:20-slim
# Chromium と依存ライブラリをインストール
RUN apt-get update && apt-get install -y \\
chromium \\
fonts-ipafont-gothic \\
fonts-noto-color-emoji \\
--no-install-recommends \\
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
# Puppeteer にバンドル済み Chromium を使わず、
# システムの Chromium を使う
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
# リソース制限を設定
ENV MAX_CONCURRENT_PAGES=5
ENV PAGE_TIMEOUT_MS=30000
CMD ["node", "worker.js"]
ブラウザプールの実装
コンテナ内でブラウザインスタンスをプールし、リクエストごとにコンテキストを割り当てるパターン:
const puppeteer = require('puppeteer-extra');
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
puppeteer.use(StealthPlugin());
class BrowserPool {
constructor({ maxBrowsers = 3, maxPagesPerBrowser = 5 } = {}) {
this.maxBrowsers = maxBrowsers;
this.maxPagesPerBrowser = maxPagesPerBrowser;
this.browsers = []; // { browser, activePages }
this.queue = []; // 待機中のタスク
}
async init() {
for (let i = 0; i < this.maxBrowsers; i++) {
const browser = await this._launchBrowser();
this.browsers.push({ browser, activePages: 0 });
}
}
async _launchBrowser() {
return puppeteer.launch({
headless: 'new',
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
// メモリ制限
'--js-flags=--max-old-space-size=512',
],
});
}
async acquirePage(proxyConfig) {
// 空きスロットのあるブラウザを探す
const available = this.browsers.find(
b => b.activePages < this.maxPagesPerBrowser
);
if (!available) {
// 全ブラウザが満杯なら待機
return new Promise((resolve) => {
this.queue.push({ proxyConfig, resolve });
});
}
available.activePages++;
const context = await available.browser.createIncognitoBrowserContext();
const page = await context.newPage();
if (proxyConfig) {
await page.authenticate(proxyConfig);
}
// ページ終了時にリソースを解放
page._release = async () => {
await context.close();
available.activePages--;
// 待機中のタスクがあれば次を処理
if (this.queue.length > 0) {
const next = this.queue.shift();
const newPage = await this.acquirePage(next.proxyConfig);
next.resolve(newPage);
}
};
return page;
}
async close() {
await Promise.all(this.browsers.map(b => b.browser.close()));
}
}
// 使用例
(async () => {
const pool = new BrowserPool({ maxBrowsers: 3, maxPagesPerBrowser: 5 });
await pool.init();
const urls = [
'https://example.com/page1',
'https://example.com/page2',
'https://example.com/page3',
];
const results = await Promise.all(
urls.map(async (url, i) => {
const countries = ['US', 'DE', 'JP'];
const page = await pool.acquirePage({
username: `user-country-${countries[i % 3]}`,
password: 'YOUR_PASSWORD',
});
try {
await page.goto(url, { timeout: 30000 });
const title = await page.title();
return { url, title };
} finally {
await page._release();
}
})
);
console.log(results);
await pool.close();
})();
リソース管理のベストプラクティス
- メモリ制限:
--disable-dev-shm-usageで /dev/shm の問題を回避。Docker では--shm-size=2gを設定。 - ページタイムアウト:必ず
page.setDefaultTimeout(30000)を設定し、無限待機を防ぐ。 - コンテキストの確実なクローズ:
finallyブロックでコンテキストを閉じないと、メモリリークが蓄積する。 - ヘルスチェック:
browser.process().kill()でゾンビプロセスを強制終了するウォッチドッグを実装。 - 指標の監視:成功率・レイテンシ・CAPTCHA 遭遇率をメトリクスとして収集し、ProxyHat のプランの利用上限と照らし合わせる。
curl での簡易確認
ブラウザスタックを組む前に、プロキシ自体の動作を素早く確認したい場合:
# HTTP プロキシで出口 IP を確認
curl -x http://user-country-JP:YOUR_PASSWORD@gate.proxyhat.com:8080 https://httpbin.org/ip
# SOCKS5 プロキシで出口 IP を確認
curl -x socks5://user-country-DE:YOUR_PASSWORD@gate.proxyhat.com:1080 https://httpbin.org/ip
レジデンシャル vs データセンター vs モバイル:どのプロキシを使うべきか
| プロキシタイプ | 検知耐性 | 速度 | コスト | ユースケース |
|---|---|---|---|---|
| データセンター | 低(AS ベースでブロックされやすい) | 高速 | 低 | 制限の緩いサイト、バルクデータ収集 |
| レジデンシャル | 高(本物の ISP IP) | 中〜高速 | 中 | SERP スクレイピング、価格モニタリング、アカウント管理 |
| モバイル | 最高(キャリア IP、最も信頼される) | 低〜中 | 高 | ソーシャルメディア、チケット購入、最高難度サイト |
Stealth プラグインと組み合わせる場合、最低でもレジデンシャルを推奨します。データセンター IP ではブラウザシグナルが完璧でも IP レピュテーションで足をすくわれます。ProxyHat の対応ロケーションは 190 カ国以上をカバーしており、ジオターゲティングとの相性も抜群です。
倫理的注意事項:Stealth は正当なスクレイピングのために
アンチ検知技術は強力なツールですが、正当な目的にのみ使用してください。
- OK:公開データの収集、価格比較、SERP モニタリング、自社アカウントの管理、QA テスト
- NG:クレデンシャルスタッフィング、不正予約、フィッシング、規約違反の大規模データ搾取
常に robots.txt を尊重し、対象サイトの利用規約を確認し、GDPR / CCPA 等の適用法規を遵守してください。スクレイピング速度を人間の範囲に抑えることも、対象サイトへの配慮として重要です。
Key Takeaways
- 生の Puppeteer は
navigator.webdriver、plugins 不整合、iframe アーティファクト等で即座に検知される- puppeteer-extra-plugin-stealth は 10+ のイベイジョンモジュールで主要な検知シグナルをパッチ
- Stealth だけでは不十分——レジデンシャルプロキシとの組み合わせで初めて実用的なアンチ検知スタックが完成
- フィンガープリントのセッション単位ランダム化は puppeteer-extra のプラグインフックでエレガントに実装
- Browser Context とローカルプロキシミドルウェアでコンテキスト単位のプロキシローテーションが可能
- 本番運用ではコンテナ化 + ブラウザプール + リソース監視が必須
- Stealth 技術は正当なデータ収集のためにのみ使用すること
次のステップとして、SERP トラッキングのユースケースやProxyHat のプラン一覧を確認して、あなたのスクレイピングプロジェクトに最適なプロキシ構成を見つけてください。






