🗂

GPT-4の画像認識とPlaywrightでポケモン判定ツールを作る

2023/10/15に公開

先日GPT-4で画像認識機能が使えるようになりましたが、まだAPIはサポートされていない為、Playwrightでブラウザを自動操作して何とか連携させてみます。

本記事ではPokéAPIからランダムなポケモン画像を取得し、それをGPT-4で画像認識して正しい解答が得られるのかをテストします。


(テスト結果閲覧の例)

環境

macOS 13.5.2でNode.js v18.16.0を使っています。Google Chromeはv118で、言語設定が英語です。

概要

具体的には以下の手順を実施します

  1. リモートデバッグプロトコルを有効にしたGoogle Chromeを起動
  2. 自分でChatGPTへログインしてセットアップしておく
  3. Node.jsのコードを実行する
  4. Playwrightが現在起動しているChromeを操作してChatGPTを自動で操作する
  5. ChatGPTの画面をスクレイピングして結果を収集

リモートデバッグプロトコルを有効にしたGoogle Chromeを起動

open -a Google\ Chrome.app --args '--remote-debugging-port=9222'

これが必要な理由はNode.jsのコードから起動中のChromeにアタッチして自動操作するためです。

Playwright実行時に起動されるChromiumインスタンスを使わない理由はChatGPTのログインの自動化が以下の理由から困難だからです。

  • サードパーティアカウントの多要素認証を含む
  • ChatGPTのセキュリティ上の制限

「起動中のChromeにアタッチする」以外で試した方法としては「Cookieの上書き」と「書き出したプロファイルから再開」がありますがChatGPTのセキュリティ上の制限により不正な認証として操作不能になりました。

自分でChatGPTへログインしてセットアップしておく

上記でChromeを起動したら手動でChatGPTへログインしておきます。

Node.jsのコードを実行する

ここからPlaywrightを使用します。先程起動したChromeに接続し、新しいタブでhttps://chat.openai.com/?model=gpt-4を開きます。

main.js
import { chromium } from "playwright";

const browser = await chromium.connectOverCDP("http://localhost:9222");

const defaultContext = browser.contexts()[0];
const page = await defaultContext.newPage();
await page.goto("https://chat.openai.com/?model=gpt-4");

inputフィールドにアップロードする画像のパスを指定します。この時点でopenai.comのサーバーにアップロードされます。

main.js
const fileInput = await page.locator('input[type="file"]');
await fileInput.setInputFiles(images);

ランダムなポケモン画像を取得する部分はRandom pokemon fetch APIを参考に実装しました(後述)。

function getRandomPokemonSprites(length);
async function downloadSprites(sprites);

右端の送信ボタンの状態を監視することでアップードが完了を待つことができます。

main.js
  await page.waitForSelector('button[data-testid="send-button"]', {
    state: "attached",
  });

プロンプトを挿入します。結果をスクレイピングしやすいように、ChatGPT自身をAPIサーバーにするで紹介したJSONを返す文章を入れておきます。

main.js
  await page.getByPlaceholder("Send a message")
    .fill(`Who's that Pokemon in Japanese?
  The output should be a markdown code snippet formatted in the following schema:
    \`\`\`json
    {
        "results": [
            {
                "id": number,
                "name": string,
                "sprite_url": string // https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/{id}.png
            }
        ]
    }
    \`\`\`
  `);

上記の画像とテキストを送信し返信が完了するのを待ちます。

waitForRequest()が必要な理由は送信ボタンを押して画面が切り替わるまでの遅延を待ちます。主に2回目の送信を正しく待つために必要です。

waitForFunction()は画面内のボタン状態を監視して返信が完了するのを待ちます。
タイムアウトを長めに取っているのはChatGPTの応答完了がデフォルトの30秒をしばしば超過するためです。

main.js
  await page.getByTestId("send-button").click({ timeout: 30000 * 10 });
  await page.waitForRequest((request) => {
    return request.url() === 'https://chat.openai.com/backend-api/conversation'
  });
  
  await page.waitForFunction(
    () => {
      const result =
        window.document.querySelector('button[as="button"]').textContent === "Regenerate" &&
        window.document.querySelector('button[data-testid="send-button"]') !== null;
      if (result) debugger;
      return result;
    },
    [],
    { timeout: 30000 * 10 }
  );

返信の中の<code>をスクレイピングするために$$eval()を呼びます。応答が複数回に及ぶことを考慮して最後の要素を取得します。

main.js
  const jsonInCode = await page.$$eval("code", (elements) => {
    const lastCodeElement = elements[elements.length - 1];
    return lastCodeElement.textContent;
  });

最終的に結果を評価するため、JSONに保存しておきます。

main.js
  const pokemons = JSON.parse(jsonInCode).results;
  for (let [index, pokemon] of pokemons.entries()) {
    const input = spriteUrls[index];
    pokemon.input = input;
    result.push(pokemon);
  }
  
  fs.writeFileSync("./data.json", JSON.stringify(result, null, 2));

動作風景

テストレポート

画像のURLの一致を検証して結果を"✅" or "❌"で表示する簡単なHTML+JavaScriptを書きました。

fetch("/data.json")して先程のファイルを取得します。サーバーの起動は以下。

npx http-server

ソースコード全文

https://github.com/laiso/pokemon-checker

Tips: デバッグ方法

PWDEBUG=1 node main.jsで起動するとPlaywright Inspectorを使ってステップ実行したり、セレクターを探したり、操作を記録してコード化したりできて便利です。

ポケモン判定以外にやってみたこと

  • 1日に撮影した食事の写真を送って一括カロリー計算
  • 「夜に駆ける」の歌詞から画像生成して、更にその画像から歌詞を作る

Discussion