1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Playwrightでbot検知を突破する18層の対策を実装した話

1
Posted at

はじめに

僕は一人法人の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検知対策は、単一の手法ではなく多層防御が鍵になる。

  1. ブラウザ設定でフィンガープリントを自然にする
  2. 振る舞いで人間らしさを再現する
  3. ネットワークで適切な間隔を保つ
  4. エラーハンドリングで検知時にリカバリする

当社では18層の対策を組み合わせることで、27件/日の自動配信を安定運用できている。大事なのは「バレない」ことではなく、「サイトに負荷をかけず、ルールの範囲内で自動化する」という姿勢だ。

AI活用・自動化についてもっと詳しく知りたい方へ

僕が実践しているAI経営フレームワークの全体像や、Claude Codeを使った自動化テクニックは、以下の書籍で体系的にまとめている。

👉 Zenn書籍一覧はこちら

一人法人でも会社運営の98%を自動化できた方法を、コード付きで解説している。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?