Cloudflare WorkersでWebページのキャプチャをDiscordに送る

はじめに

以前、AWS Lambdaを使ってWebページのスクリーンショットをDiscordに送る仕組みを構築したが、今回はCloudflare Workersを使って同様の機能を実装してみた。

AWS Lambda版では複雑だったレイヤーの管理やライブラリの準備が、Cloudflare Workersでは大幅に簡略化され、よりシンプルな構成で実現できた。

また、DiscordWebhookからBot APIに変更をしてみた。

環境

Windows 11 Professional
Node.js 24
Cloudflare Workers
TypeScript
@cloudflare/playwright
Discord Bot API

準備

  • Cloudflareアカウント
  • Discordサーバのチャンネル
  • Discord Bot(Webhook の代わりに使用)

全体の流れ

  1. Discord Botを作成し、必要な権限を設定

  2. Cloudflare WorkersにTypeScriptのコードをデプロイ

  3. Cron Triggersにて定期的にWorkerを実行するように設定

  4. 動作確認

Discord Botを作成する

  1. Discord Developer Portalにアクセスする

  2. 「New Application」でアプリケーションを作成する

  3. 「Bot」セクションに移動し、「Create Bot」でBotを作成する

  4. Bot TokenをコピーしてDiscord Bot用のトークンとして控えておく

  5. 「OAuth2」→「URL Generator」でBotをサーバーに追加する

    • Scopesで「bot」を選択
    • Bot Permissionsで「View Channels」「Send Messages」「Attach Files」を選択
    • 生成されたURLをコピーする
  6. 生成されたURLをブラウザで開き、Botを対象のDiscordサーバーに招待する

    • サーバー選択画面で対象のサーバーを選択
    • 「認証」をクリックしてBotの招待を完了する
    • 招待後、対象のサーバーでBotがオンライン状態になっていることを確認する

Discord Channel IDの取得

  1. Discordの設定から「詳細設定」→「開発者モード」を有効にする

  2. 対象のチャンネルを右クリックし、「IDをコピー」でChannel IDを取得する

Cloudflare Workers構築

プロジェクトのセットアップ

リポジトリをクローンしてローカル環境を準備する。

git clone https://github.com/katsuobushiFPGA/capture-screenshot-to-discord-bot.git
cd capture-screenshot-to-discord-bot
npm install

設定ファイルの準備

cp wrangler.toml wrangler.local.toml

wrangler.local.toml[vars]セクションに環境変数を設定する。

[vars]
TARGET_URL = "https://www.frontier-direct.jp/direct/e/ej-sale/"
DISCORD_BOT_TOKEN = "[取得したBot Token]"
DISCORD_CHANNEL_ID = "[取得したChannel ID]"

※前回と同じフロンティアのセールページを対象としている。

コードの詳細

メインのコードはsrc/index.tsに実装されている。

スクリーンショット撮影部分

async function captureScreenshot(url: string, browser: BrowserWorker, retries = 3): Promise<Blob> {
  for (let attempt = 1; attempt <= retries; attempt++) {
    try {
      console.log(`Screenshot attempt ${attempt}/${retries} for URL: ${url}`);
      
      // Cloudflare Browser Rendering (Playwright)を使用してブラウザを起動
      const browserInstance = await launch(browser);
      
      try {
        const page = await browserInstance.newPage();
        
        // ビューポートサイズを設定
        await page.setViewportSize({ width: 1920, height: 1080 });
        
        // 対象URLにアクセス
        await page.goto(url, { 
          waitUntil: 'load',
          timeout: 30000 
        });
        
        // 遅延読み込み画像のためにページをスクロール
        await autoScroll(page);
        
        // スクリーンショットを撮影
        const screenshot = await page.screenshot({
          type: 'png',
          fullPage: true
        });
        
        // BufferをBlobに変換
        const blob = new Blob([screenshot], { type: 'image/png' });
        
        console.log(`Screenshot captured successfully (${blob.size} bytes)`);
        return blob;
        
      } finally {
        await browserInstance.close();
      }
      
    } catch (error) {
      console.error(`Screenshot attempt ${attempt} failed:`, error);
      if (attempt === retries) {
        throw new Error(`Failed to capture screenshot after ${retries} attempts: ${(error as Error).message}`);
      }
      
      await new Promise(resolve => setTimeout(resolve, 2000 * attempt));
    }
  }
  
  throw new Error('Unexpected error in screenshot capture');
}

Discord投稿部分

async function sendToDiscord(env: Environment, screenshot: Blob, retries = 3): Promise<void> {
  for (let attempt = 1; attempt <= retries; attempt++) {
    try {
      console.log(`Discord posting attempt ${attempt}/${retries}`);
      
      const formData = new FormData();
      formData.append('file', screenshot, 'screenshot.png');

      const response = await fetch(`https://discord.com/api/v10/channels/${env.DISCORD_CHANNEL_ID}/messages`, {
        method: 'POST',
        headers: {
          'Authorization': `Bot ${env.DISCORD_BOT_TOKEN}`,
        },
        body: formData,
      });

      if (!response.ok) {
        const errorText = await response.text();
        throw new Error(`Discord API error: ${response.status} ${response.statusText} - ${errorText}`);
      }

      console.log('Screenshot posted to Discord successfully');
      return;
    } catch (error) {
      console.error(`Discord posting attempt ${attempt} failed:`, error);
      if (attempt === retries) {
        throw new Error(`Failed to post to Discord after ${retries} attempts: ${(error as Error).message}`);
      }
      await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
    }
  }
}

デプロイ

  1. Cloudflareにログインする
npx wrangler login
  1. Workerをデプロイする
npx wrangler deploy --config wrangler.local.toml

Cron Triggersの設定

wrangler.tomlにスケジュール設定が含まれている。

[triggers]
crons = ["0 6 * * 6"]

この設定により、毎週土曜日の15:00(JST)に自動実行される。

動作確認

  1. ログを確認する
npm run tail

または

npx wrangler tail

AWS Lambda版との違い

簡単になった点

項目AWS LambdaCloudflare Workers
ライブラリ管理chromium、fonts、axiosを別々にレイヤーとして管理する必要があった@cloudflare/playwrightのみで完結
環境構築Cloud9やローカル環境でのライブラリビルドが必要だった設定ファイルに環境変数を記述するだけ
デプロイ複数のzipファイルをS3にアップロードしてレイヤー作成wrangler deployコマンド一発

変更した点

項目AWS LambdaCloudflare Workers
Discord連携方法WebhookによるPOST送信Bot APIによるメッセージ送信
言語とランタイムNode.js(JavaScript)TypeScript
ブラウザエンジンplaywright-core + chromiumパッケージ@cloudflare/playwright(Cloudflare提供の統合パッケージ)

メリット・デメリット

メリット

項目説明
環境構築の簡単さライブラリやレイヤーの管理が不要
デプロイの高速性コマンド一つで即座にデプロイ完了
設定管理設定ファイルに環境変数を記述するだけ
型安全性TypeScriptによる開発時エラー検出
実行性能Cloudflareのエッジネットワークを活用

デメリット

項目説明
実行時間制限CPUタイムに制約がある
処理の複雑さ複雑なスクリーンショット処理には不向きな場合がある
リソース調整AWS Lambdaと比べて細かな調整ができない

参考

GitHub

おわりに

AWS Lambda版では複雑だったライブラリ管理やデプロイプロセスが、Cloudflare Workersでは大幅に簡略化された。

特に、レイヤーの作成やS3へのzipファイルアップロードなどの煩雑な作業が不要になり、設定ファイルの記述とコマンド一つでデプロイできるのは非常に快適だった。

一方で、Cloudflare Workersの実行時間制限やリソース制約は理解しておく必要があるが、今回のようなシンプルなスクリーンショット撮影程度であれば全く問題なく動作している。

とりあえずはこれで運用していこうと思う。
AWS Lambdaで作ったやつも、AWS SAMを使って構築したらもっと楽かもな~。

Hugo で構築されています。
テーマ StackJimmy によって設計されています。