Puppeteer検知は実際どう機能するのか:ボットの正体を暴く12のシグナル
自動化フレームワークは本物のブラウザのフィンガープリントを継承しますが、観測可能な数十の点でそれを変えてしまいます。防御側がPuppeteerとPlaywrightを捕捉するために使う、JavaScript、ネットワーク、行動の12のシグナルのフィールドガイドです。
自動化フレームワークは本物のブラウザを制御します。つまり、ブラウザのフィンガープリントを継承するということです。Puppeteerに操作されたChromeは、人間のChromeと同じユーザーエージェント、同じChromeバージョン、同じChromiumビルドを持ちます。表面的には、両者は同一に見えます。
しかし自動化フレームワークは、ブラウザを数十の微妙な形で変えてしまいます。その大半はJavaScriptから観測可能です。検知は魔法ではありません。チェックリストなのです。
1. navigator.webdriverフラグ
最も単純で、最も有名なシグナルです。Chromeが自動化フレームワークの下で実行されるとき、navigator.webdriverはtrueを返します。これはW3CのWebDriver仕様の一部であり、Chromiumはデフォルトでこれを実装しています。
本格的な自動化プロジェクトはすべて、これを数秒でパッチします。Puppeteer-extra-stealthはゲッターを上書きします。Playwrightのユーザーは新しいドキュメントにObject.definePropertyのスクリプトを注入します。このフラグを隠すのは造作もないことです。
しかし、造作もなく隠せるという事実そのものがシグナルです。正規のChromeには隠すべきものが何もありません。ページがObject.getOwnPropertyDescriptor(Navigator.prototype, 'webdriver')をチェックし、その記述子がネイティブなブラウザのものと異なって見えるとき——フラグにパッチが当てられた後でも、自動化は捕捉されます。
2. chromeオブジェクトの異常
正規のChromeは、chrome.runtime、chrome.loadTimes、chrome.csiといった充実した構造を持つグローバルなwindow.chromeオブジェクトを公開します。ヘッドレスChromeや古いPuppeteerの構成は、このオブジェクトを完全に省略するか、削ぎ落とされたバージョンを公開します。
ステルスプラグインはこのオブジェクトを再現しますが、その再現は不完全です。chrome.runtime.PlatformOsがないのにchrome.runtime.onConnectは存在するかもしれません。関数のシグネチャがundefinedではなくオブジェクトを返すかもしれません。あらゆる不一致が陽性のシグナルです。
3. Permissions APIの不整合
本物のブラウザは、Permissions APIを通じて照会されたとき、一貫した結果を返します。ユーザーが明示的に許可も拒否もしていなければ、navigator.permissions.query({name: 'notifications'})は'default'を返すはずです。
ヘッドレスChromeはデフォルトで'denied'を返します。パーミッションのプロンプトを表示するUIがないからです。自動化フレームワークはこれにパッチを当てますが、しばしば不正確です。よくある尻尾の出し方:'clipboard-read'のような珍しいパーミッションを照会すると、現行バージョンの本物のChromeの挙動と一致しない結果が返ってきます。
4. プラグインとMIMEタイプの配列
本物のブラウザのnavigator.pluginsは、PDFビューアーやChromium PDFプラグインといったエントリを持つPluginArrayを返します。ヘッドレスモードでは、この配列は空です。
ステルスプラグインは偽のエントリを追加しますが、そのエントリはしばしば誤ったプロパティを持ちます。lengthの欠落、誤ったdescriptionフィールド、あるいはinstanceofでテストするとPluginインスタンスとして振る舞わないプラグインオブジェクトです。
5. 言語とロケールの不一致
navigator.languageとnavigator.languagesは、Accept-Language HTTPヘッダーと一致するはずです。Intl.DateTimeFormat().resolvedOptions().timeZoneが報告するタイムゾーンとも整合しているはずです。
キーウ(Europe/Kyivタイムゾーン)にいると主張しながら、Accept-Language: en-US,en;q=0.9を送り、navigator.language === 'en-US'を報告するボットは、ありえなくはないものの統計的には異例です。フランクフルトのデータセンターのIPと組み合わされたとき、全体像は明白になります。
6. WebGLレンダラー文字列
WebGL2RenderingContext.getParameter(WebGLDebugRendererInfo.UNMASKED_RENDERER_WEBGL)は、GPUのベンダーとモデルを返します。実際のマシンでは、次のようになるでしょう。
ANGLE (Intel, Intel(R) UHD Graphics 620 Direct3D11 vs_5_0 ps_5_0)
コンテナ内で実行されるヘッドレスChromeは、しばしば次を返します。
ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero)), SwiftShader driver)
SwiftShaderまたはSubzeroを含むGPU文字列はすべて、強力なボットのシグナルです。
7. 画面とビューポートの異常
実際のブラウザウィンドウのwindow.outerWidthとwindow.outerHeightは、ブラウザのクローム——アドレスバー、タブ、ブックマークバー——の分を含みます。外側と内側の寸法の差は、垂直方向で通常80〜140ピクセルです。
ヘッドレスブラウザはしばしばouterHeight === innerHeightになります。表示すべきクロームがないからです。headless: falseのPuppeteerはこれを解消しますが、多くの自動化の構成はいまだに寸法が等しいまま実行されています。
8. フォント列挙
実際のユーザーは、OS、言語、インストールされたアプリケーションによって異なる、数百のインストール済みフォントを持ちます。Officeがインストール済みのWindows 11には500以上のフォントがあります。Alpine Linuxで動く自動化コンテナには30です。
document.fonts.check()やオフスクリーンcanvasレンダリングによるフォント列挙は、これを即座に明らかにします。Windows 11のChromeを自称しながら30のフォントしか公開しないブラウザは、ほぼ確実に自動化です。
9. マウス移動のエントロピー
実際のユーザーはジッターを伴ってマウスを動かします。曲線は非線形です。速度は変動します。2回のクリックの間には、通常数百のmousemoveイベントがあります。
自動化フレームワークは動きを合成します。座標への直線か、人間の手のマイクロトレマー(微細な震え)を欠くプログラムされた曲線です。ベジェ曲線のシミュレーションでさえ、不審なほど滑らかな加速プロファイルを持つ傾向があります。
ボット検知システムは移動の軌跡を収集し、人間の動作モデルと照らしてスコアリングします。速度が一貫しすぎている軌跡、曲線がきれいすぎる軌跡、マイクロイベントが欠けている軌跡にはフラグが立てられます。
10. タイミングの署名
自動化フレームワークは、ユーザーが操作するブラウザとは異なるタイミング特性でJavaScriptを実行します。イベント間のperformance.now()のタイミング、特にpointerdownとpointerupの間のタイミングは、異なる分布に従います。
実際のクリックの押下時間は50〜150ミリ秒で、分散が大きいものです。Puppeteerのデフォルトのクリック押下時間は固定値——多くの場合30ミリ秒か100ミリ秒——で、分散はほぼありません。押下時間が同一の100回のクリックは、自動化の決定的な証拠です。
11. TLSフィンガープリントの不一致
これはJavaScriptを完全にバイパスするシグナルです。Puppeteerが接続するとき、それはChromiumのTLSスタックを使います。本物のChromeと同じです。しかし、ユーザーエージェントの偽装でChromeのふりをするundiciベースやaxiosベースのスクレイパーは、Node.jsのTLSスタックを使っており、そのClient Helloの署名(JA4ハッシュ)は明確に異なります。
User-Agent: Chrome/124.0.6367.60を主張しながらNode.jsのTLSフィンガープリントを提示するリクエストは、非ブラウザクライアントであることの即時の確証です。
12. CDPプロトコルの漏出
PuppeteerとPlaywrightは、Chrome DevTools Protocol(CDP)を介してChromeと通信します。構成によっては、このプロトコルは検知可能なアーティファクトを残します。Runtimeオブジェクト上の余分なプロパティ、変更されたError.stackのフォーマット、あるいはCDPクライアントが接続されているときにのみ現れる特定のconsole.debug出力です。
特にRuntime.enableコマンドは、スタックトレースのレンダリングのされ方を変えます。エラーを一度も発生させないボットはこのシグナルを避けられますが、例外に遭遇する自動化フロー(そして大半は遭遇します)はフィンガープリントを残します。
これらのシグナルの重ね合わせ
単一のシグナルは、いずれも自動化の証明にはなりません。珍しいGPUドライバーを使う実際のユーザーが、奇妙なWebGL文字列を返すかもしれません。プライバシー重視のユーザーがnavigator.pluginsを改変しているかもしれません。
現代の検知の強さはシグナルの組み合わせから生まれます。12の弱い陽性は1つの強い陽性より強力です。実際のユーザーが2〜3を超える異常を同時に踏むことはまれだからです。
検知システムは各シグナルに重みを割り当て、スコアを計算します。しきい値を超えれば、その訪問者は自動化として扱われます。正確なしきい値と重みこそが、正しく調整するのが最も難しい部分です。厳しすぎれば正規ユーザーがブロックされ、緩すぎれば高度なボットがすり抜けます。
シグナルそのものは簡単な部分です。ほぼすべての自動化フレームワークが12すべてを漏らします。本当のエンジニアリングは、どの組み合わせが重要か、どれが誤検知か、そして自動化フレームワークの適応に合わせてモデルをどう更新するかを決めることにあるのです。