---
# System prepended metadata

title: 『私はロボットではありません』をAIに解かせて自動化する
tags: [LLM, 自動化, AI, Playwright, reCAPTCHA]

---

# 『私はロボットではありません』をAIに解かせて自動化する

## はじめに

> [!NOTE]
> この記事は、[みす老人会 Advent Calendar 2024](<https://adventar.org/calendars/10110/>) の 𝋠𝋩 日目の記事です。

おはこんばんにちはろはろ～✰、元MIS.W 56代のかふぃ～✰です。

ところで皆さんはreCAPTCHAに人間性を否定されたことはありますか?
私はあります。いつもそうです。特に急いでいるときはreCAPTCHAが永遠に終わらなくて、何度も嫌な思いをしたことがあります。
あと、下の画像の肩の部分のようにギリギリ範囲に入っている場合って、なぜか含めても含めなくてもどのみち失敗するんですよね。永遠の謎です。
![ra](https://hackmd.io/_uploads/Skip377N1e.gif)
その謎を解明するため、ピッツバーグの奥地へは向かいませんが、ロボットどころか人間にも解かせたくないという強い意思を感じます。
もぅﾏﾁﾞ無理、ｼﾞﾄﾞｳｶしよ。

ということで今回は、[reCAPTCHA v2 ("I'm not a robot" Checkbox)](<https://developers.google.com/recaptcha/docs/versions#recaptcha_v2_im_not_a_robot_checkbox>)をAI(LLM)に解かせて自動化してみたいと思います。

## 下準備

使用するライブラリとAIのモデルを選定します。

まずは、ライブラリの選定です。
reCAPTCHAはブラウザ上で実行されるので、ブラウザ操作をするためのライブラリが必要です。
今回は、`Microsoft`が開発している[Playwright](<https://github.com/microsoft/playwright>)を使用します。
このライブラリは、直感的にブラウザを操作するコードを記載することができ、WebアプリケーションのEnd to Endテストなどで利用されています。
素早く実装できる<sup>[当社比]</sup>ので、卒論に追われている人にはおすすめです。
使用する言語は`Playwright`に合わせて`TypeScript`にします。

次に、AIのモデルを選定します。
画像認識を行うなら画像認識に特化したモデルを利用した方が精度もコスパも良いですが、今回は卒論に追われており1秒でも早く完成させたいので、`LLM (Large Language Model)`に解かせてみたいと思います。
LLMって聞くとText to Textのイメージが強いかもしれませんが、最近の`Multimodal`やら`Vision`やらの冠がついたLLMは画像認識もできるようになっています。知らんけど。
ただ、モデルによってはセーフティーフィルタリング的なのが組み込まれているものがあり、reCAPTCHA解読のような用途を検知するとブロックされることがあります。
今回は、[mistralai/Pixtral-Large-Instruct-2411](<https://huggingface.co/mistralai/Pixtral-Large-Instruct-2411>)というモデルを使用します。
このモデルは、ローカルで動作させるためのモデルが一般公開されている中で精度がかなり良くセーフティーフィルタリングが弱い(たぶん無い)ので、今回のような用途に向いています。多分、きっと、そうであってほしい。
自分でモデルを動かすことにこだわりがないのであれば、OpenAI<sup>[以後ClosedAI]</sup>の`GPT-4o`などでも動作すると思います。
モデルの量子化やAPIサーバーのデプロイは今回の趣旨ではないので割愛します。
量子化時の`Importance Matrix`の計算に利用するテキストには、『階段』や『消火栓』などのreCAPTCHAでよく出てくるワードを含めています。

reCAPTCHA v2のテストに使うサイトは、[ReCAPTCHA demo](<https://www.google.com/recaptcha/api2/demo>)にしました。
準備が整ったら実際にコードを書いていきます。

## コード書き書き

1. **reCAPTCHAが存在するフレームの選択**

    - reCAPTCHAの`iframe`はチェックボックスと画像選択ウインドウの2つのフレームで構成されています。
    - フレーム内で各種操作をするためには、それぞれのフレームを特定する必要があります。
    - `id`や`class`はランダムで変化するため、`iframe`の`src`が一致するもので判断します。
    - `anchor`(チェックボックスがあるフレーム)を選択します。
        - ![recaptchaAnchor0](https://hackmd.io/_uploads/rk2epQXN1e.png)

        ```typescript
        const recaptchaAnchor = page.frameLocator('iframe[src*="https://www.google.com/recaptcha/api2/anchor?"]');
        ```

    - `anchor`内で`recaptcha-anchor-label`(reCAPTCHAを開始するエリア)をクリックして、reCAPTCHAを開始します。

        ```typescript
        await recaptchaAnchor.locator('label[id="recaptcha-anchor-label"]').click();
        ```

    - `bframe`(reCAPTCHAを開始したときに開かれるウインドウ)を選択します。
        - ![recaptchaBframe3](https://hackmd.io/_uploads/r1kGTX7Nke.png)

        ```typescript
        const recaptchaBframe = page.frameLocator('iframe[src*="https://www.google.com/recaptcha/api2/bframe?"]');
        ```

2. **reCAPTCHAの状態を取得してループを回す**

    - 自動化するのにあたって、reCAPTCHAが成功しているかどうかが判断できなければループを回すことができません。
    - reCAPTCHAが成功すると四角いボックスがチェックマークに変化します。
        - reCAPTCHAが成功する前の状態。
            - ![recaptchaAnchor0](https://hackmd.io/_uploads/ByiG67XNyl.png)
        - reCAPTCHAが成功した後の状態。
            - ![recaptchaAnchor1](https://hackmd.io/_uploads/rkZXaX7VJe.png)
    - この際に変化する値(`recaptcha-anchor`の`aria-checked`)を取得することで、`reCAPTCHA`が成功しているかどうかを判断することができます。

        ```typescript
        let state = await recaptchaAnchor.locator('span[id="recaptcha-anchor"]').getAttribute('aria-checked');
        ```

    - reCAPTCHAの確認ボタンには、`recaptcha-verify-button`という`id`がついています。
    - また、画像切り替えや結果反映にはエフェクトがあるため、2秒間の待機を挟みます。
        - ![newCaptchaAnchor](https://hackmd.io/_uploads/rkdVCX7VJg.gif)

    - reCAPTCHAが成功するまでループする`while`文。

        ```typescript
        while (state === 'false') {
            ⋮
          await recaptchaBframe.locator('button[id="recaptcha-verify-button"]').click();
          await page.waitForTimeout(2000);
          state = await recaptchaAnchor.locator('span[id="recaptcha-anchor"]').getAttribute('aria-checked');
        }
        ```

3. **reCAPTCHAを解くために必要な情報をそろえる**

    - reCAPTCHAを解くためには、選択対象・画像の数がわかっている必要があります。
    - 選択対象は以下の場合だと『消火栓』が該当します。
        - ![recaptchaBframe2](https://hackmd.io/_uploads/H1mUpQmVJl.png)
    - 選択対象の文字は`strong`という文字列を太字にする要素の中に格納されているので、そこのテキストを取得します。

        ```typescript
        const searchObjectText = await recaptchaBframe.locator('strong').textContent();
        ```

    - 次に、画像の数を取得します。
    - `4x4`と`3x3`との2つのパターンが存在します。
        - `4x4`の場合は、1つの画像を16分割してその中から該当するエリアを選択するタイプです。
            - ![recaptchaBframe4](https://hackmd.io/_uploads/BJAUT7741g.png)
            - この場合、`rc-image-tile-44`という`class`のついた画像が16個存在します。
        - `3x3`の場合は、9枚の別の画像の中から該当する画像を選択するタイプです。
            - ![recaptchaBframe3](https://hackmd.io/_uploads/BJuP6Q74kl.png)
            - この場合、`rc-image-tile-33`という`class`のついた画像が9個存在します。
    - この情報をもとに条件式を作成します。
        - 要素が存在するかどうかを確認してしまうと要素が存在するまで待機してしまうので、要素の数を数えた方が楽です。

        ```typescript
        if (await recaptchaBframe.locator('img[class="rc-image-tile-44"]').count()) {
          // 4x4 の場合の処理
            ⋮
        } else if (await recaptchaBframe.locator('img[class="rc-image-tile-33"]').count()) {
          // 3x3 の場合の処理
            ⋮
        }
        ```

4. **画像の取得とプロンプトの作成**

    - 画像の取得
        - reCAPTCHAの画像を取得する際は画像ファイルを直接取り出すのではなく、以下のような画像全体が乗っている要素のスクリーンショットを利用します。
            - ![recaptchaBframe5](https://hackmd.io/_uploads/H1XdT7741g.png)
        - こうすることで境界線を残したまま画像化できるので、領域の判定がしやすくなります。
        - 要素のスクリーンショット取得するコード。

            ```typescript
            const rcImage = await recaptchaBframe.locator('div[id="rc-imageselect-target"]').screenshot();
            ```

    - プロンプトの作成
        - プロンプトというのは、AIに対して何を実行するのかを指示する文章です。
        - 画像の中で対象の領域かどうかを判断する指示を書きますが、この時に具体的な返事の仕方を指定します。
        - なぜなら、対象の領域かどうかの判断が正しくできたとしても、その返事が文章で長々と解説されたらその後の処理が困ります。
        - なので、その後の処理がしやすいような形で返事をするように指示をします。
        - また、最近のLLMには出力形式を強制する機能があるので、それと組み合わせると楽な場合が多いです。
        - プロンプトの種類や書き方は『プロンプトエンジニアリング』とかでググれば2澗個出てくるので、詳しく知りたい人はググってください。
        - 今回は以下のようなプロンプト(指示文)を作成しました。
            - `4x4`の場合。

                ```typescript
                tileType = '4x4';
                systemPrompt = `
                You are an image classification bot.
                Your task is to select all areas of an image divided into ${tileType} areas that contain a user-specified object.
                Additionally, "r1c1" refers to the first row, first column, and "r2c2" refers to the second row, second column.
                Please output in the following JSON format.
                {
                    "r1c1": bool, "r1c2": bool, "r1c3": bool, "r1c4": bool,
                    "r2c1": bool, "r2c2": bool, "r2c3": bool, "r2c4": bool,
                    "r3c1": bool, "r3c2": bool, "r3c3": bool, "r3c4": bool,
                    "r4c1": bool, "r4c2": bool, "r4c3": bool, "r4c4": bool
                }
                `.trim();
                userPrompt = `
                Select all areas with "${searchObjectText}".
                `.trim();
                ```

            - `3x3`の場合。

                ```typescript
                tileType = '3x3';
                systemPrompt = `
                You are an image classification bot.
                Your task is to select all areas of an image divided into ${tileType} areas that contain a user-specified object.
                Additionally, "r1c1" refers to the first row, first column, and "r2c2" refers to the second row, second column.
                Please output in the following JSON format.
                {
                    "r1c1": bool, "r1c2": bool, "r1c3": bool,
                    "r2c1": bool, "r2c2": bool, "r2c3": bool,
                    "r3c1": bool, "r3c2": bool, "r3c3": bool
                }
                `.trim();
                userPrompt = `
                Select all areas with "${searchObjectText}".
                `.trim();
                ```

5. **APIの利用**
    - APIのリクエストやレスポンスは使用しているAPIサーバーの種類によって大きく異なるため、ざっくりとだけ載せておきます。
    - APIに送信するリクエストの本文を作成します。
        - 画像データは`base64`にエンコードして`image_url`内に埋め込みます。
        - `response_format`でモデルからのレスポンスの形式を`json_object`に指定しています。

            ```typescript
            const requestBody = JSON.stringify({
                model: 'pixtral:large-instruct-2411-iq4_XS',
                messages: [
                    {
                        role: 'system',
                        content: [
                            {type: 'text', text: systemPrompt},
                        ],
                    },
                    {
                        role: 'user',
                        content: [
                            {type: 'text', text: userPrompt},
                            {type: 'image_url', image_url: {url: `data:image/jpeg;base64,${rcImage.toString('base64')}`}},
                        ],
                    },
                ],
                response_format: {type: 'json_object'},
            });
            ```

    - APIにリクエストの送信

        ```typescript
        const response = await fetch('https://<api-server>/v2beta1/messages', {
            method: 'POST',
            headers: {Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json'},
            body: requestBody,
        });
        ```

    - APIからのレスポンスの処理

        ```typescript
        const responseJson = await response.json();
        const result = JSON.parse(responseJson.choices[0].message.content);
        ```

    - `result`には以下のようなデータが格納されるようになります。

        ```json
        {
          "r1c1": true,
          "r1c2": false,
          "r1c3": false,
          "r2c1": false,
          "r2c2": true,
          "r2c3": false,
          "r3c1": false,
          "r3c2": false,
          "r3c3": true
        }
        ```

        - 上の例だと、1行目1列目、2行目2列目、3行目3列目が選択対象に含まれていることを示しています。

6. **該当する領域をクリックする**

    - 何も工夫せずにそのままクリックしてしまうと、該当領域すべてをほぼ同時にクリックすることになりロボット判定を受けるので、マウスの移動時間を考慮し待機を挟みます。
    - 早く選択してもダメで遅く選択すると待ちが長くなるので、適度なタイミングを見つける必要があります。
    - 今回は以下のように0.2秒から0.5秒の間でランダムに待機します。

        ```typescript
        const timeout = (Math.random() * (500 - 200)) + 200;
        await page.waitForTimeout(timeout);
        ```

    - 先ほどの`result`をもとに該当する領域をクリックします。
    - 選択する領域は`tabindex`で指定できますが、なぜか4から始まるので注意してください。
    - 1行目1列目の場合の処理。

        ```typescript
        if (result.r1c1) {
          await page.waitForTimeout(timeout);
          await recaptchaBframe.locator('td[tabindex="4"]').locator('div[class="rc-image-tile-wrapper"]').click();
        }
        ```

    - これをそれぞれの領域ごとに実行します。
    - ページごとの処理は前に作成したので、これでreCAPTCHAが成功するまでループが回ります。

## 結果

| 3x3 | 4x4 |
| :---: | :---: |
| ![r3](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3953753/d0aa0afb-4a1a-c745-332d-9677e0fa61b1.gif) | ![r4](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3953753/fbf8a371-1a92-b09f-a673-0a9beea09175.gif) |

`3x3`の場合は大体1～3ページ目で完了し、`4x4`の場合は大体2～4ページ目で完了することができます。
私という人間<sup>[要出典]</sup>が手動で解くと通常難易度でも大体15ページ程かかるので、AIに解かせることで大幅に短縮できました。
しかし、連続して失敗した後・大量に実行した後・公開VPNやTorを経由した際は難易度が上がるため、失敗する確率が高くなりました。
これに関しては、私が続きを試しても10分以上回り続けたときが何度かあるので、AIの精度どうこうの問題ではなく別の要因でロボット判定を受けていて、正しい選択をしてもすぐには解けないようになっているのだと思います。
(ちなみに評価用に統計を取ろうと思って、同時に1K個近くのreCAPTCHAを走らせたらBANされたため、reCAPTCHA自体にチャレンジができなくなりました。)

私は1ピクセルでもかすっていたら律儀に選択する派だったのですが、LLMが選択する画像の傾向を見ていると、微妙にかすっている部分は選択していないように思えるので、迷ったら選択しない方が正答率が高いのかもしれません。
また、LLMのレスポンスには待ち時間があるので、それを踏まえ画像が表示された瞬間に選択するより、少し時間を置いた方が正答率が上がるかもしれません。

## まとめ

reCAPTCHAの画像選択は完璧でなくてもある程度正解していれば通過できるため、専用モデルでなくともLLMの画像認識の精度でも問題ないことがわかりました。
また、現状のreCAPTCHA v2では人間かAIかの判断はできていないことがわかりました。
むしろ人によってはAIの方が早く解けるので、reCAPTCHAの有効性については疑問が残ります。

## おわりに

それにしても、LLMってすごいですね。
精度やコスパを気にしないのであれば実質的に画像分類の部分をLLMで置き換えられるので、こんなに簡単に実装できてしまいます。
LLMを使わずに画像分類の部分を実装しようと思ったらどれだけ手間がかかることやら...。

明日のアドカレは、`sirokome19`さんの記事です。

<sub>卒論書かなきゃ...。</sub>
