はじめに
僕は一人法人のCEOとして、AI経営フレームワークを使って会社運営の98%を自動化している。その中核にあるのがPlaywrightを使ったWebスクレイピングだ。
SNS自動配信27件/日、記事自動公開3チャネル/日。これだけの自動化を安定稼働させるには、bot検知対策が避けて通れない壁になる。
当社では試行錯誤の末、18層のbot検知対策を自社開発した。この記事では、そのうち公開可能な主要テクニックを実践コード付きで紹介する。
なぜbot検知対策が必要なのか
Playwrightをそのまま使うと、多くのWebサイトで以下のような問題にぶつかる。
- CAPTCHAが表示される
- アクセスがブロックされる
- ページが正常に読み込まれない
- レートリミットに引っかかる
原因は、Webサイト側がブラウザの挙動・フィンガープリント・ネットワークパターンなどから「これはbotだ」と判定しているからだ。
対策の全体像:多層防御の考え方
bot検知対策は単一の手法では不十分だ。検知側も複数のシグナルを組み合わせて判定しているため、こちらも多層で対策する必要がある。
僕が実装した18層のうち、主要な層を以下のカテゴリに分けて解説する。
| カテゴリ | 対策内容 |
|---|---|
| ブラウザ設定 | User-Agent、ヘッダー、フィンガープリント |
| 振る舞い | マウス操作、スクロール、待機時間 |
| ネットワーク | リクエスト間隔、リトライ、プロキシ |
| 環境 | コンテキスト分離、Cookie管理 |
層1:User-Agentとヘッダーの偽装
最も基本的な対策。Playwrightのデフォルトヘッダーにはbot判定されやすい特徴がある。
const context = await browser.newContext({
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
locale: 'ja-JP',
timezoneId: 'Asia/Tokyo',
viewport: { width: 1920, height: 1080 },
extraHTTPHeaders: {
'Accept-Language': 'ja,en-US;q=0.9,en;q=0.8',
'sec-ch-ua': '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
'sec-ch-ua-platform': '"macOS"',
},
});
ポイント: User-Agentだけでなく、sec-ch-ua系ヘッダーも一致させること。ここが矛盾しているとすぐにバレる。
層2:WebDriverフラグの除去
Playwrightが起動するブラウザにはnavigator.webdriver = trueというフラグが立つ。これはbot検知の定番チェック項目だ。
await page.addInitScript(() => {
Object.defineProperty(navigator, 'webdriver', {
get: () => undefined,
});
});
層3:自動化検出プロパティの隠蔽
navigator.webdriver以外にも、検知に使われるプロパティがある。
await page.addInitScript(() => {
// Playwright固有のプロパティを削除
delete (window as any).__playwright;
delete (window as any).__pw_manual;
// プラグイン情報を偽装
Object.defineProperty(navigator, 'plugins', {
get: () => [1, 2, 3, 4, 5],
});
// 言語情報の一貫性を確保
Object.defineProperty(navigator, 'languages', {
get: () => ['ja', 'en-US', 'en'],
});
});
層4:人間らしい振る舞いのシミュレーション
bot検知の高度な手法は、マウスの動きやクリックのタイミングを分析する。機械的に一直線にカーソルが動くと即バレだ。
async function humanLikeClick(page: Page, selector: string) {
const element = await page.locator(selector);
const box = await element.boundingBox();
if (!box) return;
// ランダムなオフセットでクリック位置をずらす
const x = box.x + box.width * (0.3 + Math.random() * 0.4);
const y = box.y + box.height * (0.3 + Math.random() * 0.4);
// 段階的にマウスを移動(ベジェ曲線的な動き)
await page.mouse.move(x * 0.5, y * 0.5, { steps: 5 });
await page.mouse.move(x, y, { steps: 10 });
// クリック前に微小な待機
await page.waitForTimeout(100 + Math.random() * 200);
await page.mouse.click(x, y);
}
層5:ランダムな待機時間
一定間隔のアクセスはbotの典型的パターン。人間はページを読む時間がバラつく。
async function humanDelay(minMs: number = 1000, maxMs: number = 3000) {
const delay = minMs + Math.random() * (maxMs - minMs);
await new Promise(resolve => setTimeout(resolve, delay));
}
// ページ遷移後に「読んでいる時間」を再現
await page.goto(url);
await humanDelay(2000, 5000);
層6:スクロールの再現
ページを開いて即座にデータを取得するのは不自然だ。人間はスクロールしながらコンテンツを確認する。
async function humanScroll(page: Page) {
const scrollHeight = await page.evaluate(() => document.body.scrollHeight);
let currentPosition = 0;
while (currentPosition < scrollHeight * 0.7) {
const scrollAmount = 200 + Math.random() * 300;
currentPosition += scrollAmount;
await page.evaluate((pos) => window.scrollTo(0, pos), currentPosition);
await humanDelay(500, 1500);
}
}
層7:リクエスト間隔の制御
同一ドメインへの連続アクセスはレートリミットの原因になる。
class RateLimiter {
private lastRequestTime = 0;
private minInterval: number;
constructor(minIntervalMs: number = 3000) {
this.minInterval = minIntervalMs;
}
async wait() {
const elapsed = Date.now() - this.lastRequestTime;
if (elapsed < this.minInterval) {
const waitTime = this.minInterval - elapsed + Math.random() * 2000;
await new Promise(resolve => setTimeout(resolve, waitTime));
}
this.lastRequestTime = Date.now();
}
}
層8:Cookie・セッション管理
認証状態を維持しつつ、セッション情報を適切に管理する。
// コンテキストの状態を保存
await context.storageState({ path: 'state.json' });
// 次回起動時に復元
const context = await browser.newContext({
storageState: 'state.json',
});
層9:エラーハンドリングとリトライ
bot検知に引っかかった場合のリカバリも重要だ。
async function resilientNavigate(
page: Page,
url: string,
maxRetries: number = 3
) {
for (let i = 0; i < maxRetries; i++) {
try {
const response = await page.goto(url, {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
if (response && response.status() === 403) {
console.log(`Bot detected (attempt ${i + 1}). Waiting...`);
await humanDelay(10000, 30000); // 長めに待機
continue;
}
return response;
} catch (error) {
if (i === maxRetries - 1) throw error;
await humanDelay(5000, 15000);
}
}
}
実運用で学んだ教訓
レートリミットは甘く見るな
当社ではZennへの自動投稿で、1日に複数記事と書籍を同時公開してデプロイ失敗を繰り返した。結局、ロックファイルで「1日1コンテンツ」ルールを強制する仕組みを作って解決した。
自動化の安定性は、速度を落とすことで得られる。
macOSのcronは罠
自動化スクリプトをcronで動かそうとしたが、macOSのフルディスクアクセスの問題でcronジョブが全滅した。launchdに全面移行することで解決した。Playwrightのスケジュール実行をmacOSで行う場合は、最初からlaunchdを使うべきだ。
「作れる」と「安定運用できる」は別物
AIを使えばスクレイピングスクリプトは5分で書ける。しかし、それを毎日27件の配信で安定稼働させるには、エラーハンドリング、リトライ、ログ、監視の仕組みが必要だ。
僕の経験則では、スクリプト本体の開発は全体の20%で、残り80%は運用の仕組み作りだ。
まとめ
Playwrightでのbot検知対策は、単一の手法ではなく多層防御が鍵になる。
- ブラウザ設定でフィンガープリントを自然にする
- 振る舞いで人間らしさを再現する
- ネットワークで適切な間隔を保つ
- エラーハンドリングで検知時にリカバリする
当社では18層の対策を組み合わせることで、27件/日の自動配信を安定運用できている。大事なのは「バレない」ことではなく、「サイトに負荷をかけず、ルールの範囲内で自動化する」という姿勢だ。
AI活用・自動化についてもっと詳しく知りたい方へ
僕が実践しているAI経営フレームワークの全体像や、Claude Codeを使った自動化テクニックは、以下の書籍で体系的にまとめている。
一人法人でも会社運営の98%を自動化できた方法を、コード付きで解説している。