Chrome Built-in AIを使用したウェブページからの商品情報抽出：オンデバイスAIによるフロントエンド実装

最新のChrome Built-in AIを使用して、ウェブページから商品情報を抽出する方法を解説しています。特にPythonとJavaScriptを使用して、ChromeのGemini Nano AIモデルを用いたオンデバイスAIによるフロントエンド実装手順が詳述されています。必要な事前準備から、実際のプログラムのロード、そして抽出したデータの構造化までを包括的にカバーしています。さらに、直面しうる問題点とその解決策も紹介されています。

![simplescreenrecorder-2024-06-29_19.09.44.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/566477/f2c13d9a-b9f4-8808-280a-f4c1399dbcb2.gif)

## オンデバイスAIの実装のメリット
* プライバシーの保護: データが端末内で処理されるため、外部サーバーへの送信が不要で、個人情報の漏洩リスクが低減します。
* 低遅延: データ処理がローカルで行われるため、ネットワーク遅延がなく、リアルタイム性が向上します。
* オフライン動作: インターネット接続がなくても動作可能なため、いつでもどこでも利用できます。
* コスト削減: サーバーインフラの維持管理が不要となり、コスト削減に繋がります。


## 事前準備
### 最新のChrome built-in AI対応バージョン
Chrome built-in AI(Gemini Nano)に対応した最新のChromeが必要です。Chromeのバージョンはv127以上です。現在、[Dev](https://www.google.com/chrome/dev/)または[Canary](https://www.google.com/chrome/canary/)チャンネルからダウンロードできます。このバージョンがいつstableになるかは、[Chrome Platform Status](https://chromestatus.com/roadmap)で確認できます。

built-in AIに対応したChromeをインストールした後、以下の設定を行う**必要**があります。
1. chrome://flags/#prompt-api-for-gemini-nano
2. chrome://flags/#optimization-guide-on-device-model
3. Chromeを再起動
4. chrome://components/にアクセスし、`Optimization Guide On Device Model`をクリックし、モデルのダウンロードを待つ
5. 再起動後、Chromeのdevコンソールでテストが可能になります

設定方法については、こちらの[ブログ](https://azukiazusa.dev/blog/try-chrome-internal-ai-gemini-nano/)も参考にできます。

最新のAPIの使用方法については[AI on Chrome](https://developer.chrome.com/docs/ai/built-in)を参照してください。

### PythonおよびJavascriptの基礎知識
これは個人プロジェクトの一環としてPythonを統一言語として使用しています。しかし、本記事で紹介する部分はフロントエンド(ブラウザ側)のみで動作し、**バックエンドは必要ありません**。
もしJavaScriptで実装したい場合は、いくつかのモジュールを自分で書く必要があります。

## 商品情報の抽出器を作成する
### ブラウザ環境の確認
プログラムを起動する前に、ブラウザが要件を満たしているか確認する必要があります。
#### ブラウザバージョンの確認
```js
// ブラウザバージョンが要件を満たしているかチェックする関数
function checkBrowserVersion() {
    try {
        const ua = navigator.userAgent;
        const isFirefox = ua.includes('Firefox');
        const isChrome = ua.includes('Chrome');
        const isSafari = ua.includes('Safari') && !ua.includes('Chrome');

        if (isFirefox) {
            const version = parseInt(ua.split('Firefox/')[1], 10);
            // return version > 112;
            return false;
        } else if (isChrome) {
            const version = parseInt(ua.split('Chrome/')[1].split(' ')[0], 10);
            return version > 126;
        } else if (isSafari) {
            const version = parseInt(ua.split('Version/')[1].split(' ')[0], 10);
            // return version > 16.4;
            return false;
        } else {
            return false;
        }
    } catch (error) {
        console.error('Error checking browser version:', error);
        return false;
    }
}
```
#### top-level awaitのサポート確認
JavaScript部分は単純な起動スクリプトであり、パッケージ化されていないため、top-level awaitを使用しています。大多数のブラウザでサポートされていますが、念のため確認します。
```js
// top-level awaitがサポートされているかチェックする関数
async function checkTopLevelAwaitSupport() {
    try {
        await Promise.resolve();
        return true;
    } catch (error) {
        return false;
    }
}
```
#### built-in AIのサポート確認
```js
async function checkGeminiSupport() {
    if (!window.ai) return false;
    if (!window.ai.canCreateTextSession) return false;
    const canCreate = await window.ai.canCreateTextSession();
    return canCreate === 'readily';
}
```

### プログラムのロード
すべての条件が満たされたとき、プログラムをロードできます。ここではJavaScriptを使用してプログラムをロードします。
```js
// メインスクリプトをロードして実行する関数
async function main() {
    async function getPythonScript(scriptPath) {
        const scriptResponse = await fetch(window.kizuna_product_pulse_origin+ scriptPath);
        if (!scriptResponse.ok) {
            throw new Error(`スクリプトの取得に失敗しました: ${scriptResponse.statusText}`);
        }
        return await scriptResponse.text();
    }

    try {
        const pyodide = await loadPyodide();
        await pyodide.loadPackage("micropip");
        // ドキュメントのreadyStateがcompleteになるまで待つ
        await new Promise((resolve) => {
            if (document.readyState === 'complete') {
                resolve();
            } else {
                document.addEventListener('readystatechange', () => {
                    if (document.readyState === 'complete') {
                        resolve();
                    }
                });
            }
        });

        // メインスクリプトをロード
        return pyodide.runPython(await getPythonScript("/collector/scripts/magic.py"));
    } catch (error) {
        console.error('メイン関数のエラー:', error);
        throw error;
    }
}

// すべての条件が満たされた場合にメインスクリプトを実行する関数
async function runScriptIfConditionsMet() {
    try {
        if (checkBrowserVersion() && await checkTopLevelAwaitSupport() && await checkGeminiSupport()) {
            const res = await main();
            if (res) console.log(res.toJs());
        } else {
            console.log('ブラウザがスクリプトを実行する要件を満たしていません');
        }
    } catch (error) {
        console.error('スクリプト実行のエラー:', error);
    }
}


const kizunaCurrentScriptUrl = new URL(document.currentScript.src);
window.kizuna_product_pulse_origin = kizunaCurrentScriptUrl.origin;

const kizunaProductPulseDependencyScript = document.createElement('script');
kizunaProductPulseDependencyScript.src = 'https://cdn.jsdelivr.net/pyodide/v0.26.1/full/pyodide.js';
kizunaProductPulseDependencyScript.onload = runScriptIfConditionsMet;
kizunaProductPulseDependencyScript.onerror = () => console.error('Pyodideスクリプトの読み込みに失敗しました');
document.head.appendChild(kizunaProductPulseDependencyScript);

```

### ウェブページ情報の抽出
Gemini nanoがサポートするコンテキストの長さが非常に短いため、ウェブページの内容を精査する必要があります。ここでは2つの方法を使用します。

#### boilerplateの除去
Pythonクローラー用のboilerplate removalモジュールを使用できますが、ここでは例として手動でselectorを設定しています。
```js
window.kizuna_ai_web_ai_extractor_example_selector = "#rightArea";
```

#### ウェブページのHTMLをテキストに変換
HTMLをテキストに変換する目的は以下の通りです：
* 不要なHTMLタグを削除
* 表示可能な内容のみを保持

ここでは[inscriptis](https://github.com/weblyzard/inscriptis)を使用してHTMLをプレーンテキストに変換します。
あなたはウェブページのコンテンツをMarkdownに変換することもできます。 Markdownは、多くのLLMにとって純粋な文字列よりも使いやすいです。 これがしばしば、LLMの出力結果がより正確になる原因です。
```python
# HTMLからテキストを抽出
html = document.querySelector(kizuna_ai_web_ai_extractor_example_selector).outerHTML
text = get_text(html)
```

### LLMを使用した情報抽出
Chromeのbuilt-in AIには他のサービスのようなAPIがないため、system_promptとuser_promptを一緒にします。temperatureやtop_kを調整したい場合は、createTextSessionの際に渡すことができます。
```python
async def custom_llm(system_prompt: str, user_prompt: str):
    ''' 自分のLLMに変更可能 '''
    session = await window.ai.createTextSession()
    result = await session.prompt(system_prompt + "\n" + user_prompt)
    return result
```

### 構造化データの生成
LLMを使用しても、100％構造化データ（例：JSON形式）を取得できるとは限りませんが、いくつかの方法で成功率を高めることができます。ここでは以下の方法を試します。
#### 1. プロンプトエンジニアリング:
最も簡単な方法の一つは、LLMにJSON形式で出力するよう指示するプロンプトエンジニアリングです。ただし、この方法は必ずしも信頼性が高いわけではなく、モデルによって結果が異なることがあります。例として、以下のような指示を含めることができます：
```text
Output your response in valid JSON format. Do not include any text outside of the JSON structure.
```

#### 2. ポストプロセッシング
LLMの出力が完璧なJSONでない場合に対応するためのポストプロセッシングロジックを実装します。これには以下が含まれます：
正規表現を使用してJSONライクな構造を抽出
一般的なJSONフォーマットの問題に対するエラーハンドリングと修正の実装

#### 3. 検証ループ
出力が有効なJSONであり、期待されるスキーマに適合するかをチェックする検証ループを実装します。検証に失敗した場合、クエリを再試行するか、フォールバック戦略を適用します。

[strictjson](https://github.com/jiangzhuo/strictjson)を使用しました。

完全なプログラムは以下の通りです。
```python
import json

import micropip
from js import document, kizuna_ai_web_ai_extractor_example_root, kizuna_ai_web_ai_extractor_example_selector, \
    kizuna_ai_web_ai_extractor_example_prompt, kizuna_ai_web_ai_extractor_example_output_format, window


async def main():
    try:
        await micropip.install("numpy")
        await micropip.install(f"{kizuna_ai_web_ai_extractor_example_root}/whl/inscriptis-2.5.0-py3-none-any.whl")
        await micropip.install(f"{kizuna_ai_web_ai_extractor_example_root}/whl/strictjson-4.1.0-py3-none-any.whl")
        from inscriptis import get_text
        from strictjson import strict_json_async

    except Exception as e:
        print(f"パッケージのインストールに失敗しました: {e}")
        raise

    try:
        # HTMLからテキストを抽出
        html = document.querySelector(kizuna_ai_web_ai_extractor_example_selector).outerHTML
        text = get_text(html)
    except Exception as e:
        print(f"HTMLからテキストの抽出に失敗しました: {e}")
        raise

    try:
        async def custom_llm(system_prompt: str, user_prompt: str):
            ''' 自分のLLMに変更可能 '''
            session = await window.ai.createTextSession()
            result = await session.prompt(system_prompt + "\n" + user_prompt)
            print(result)
            return result

        res = await strict_json_async(
            system_prompt=kizuna_ai_web_ai_extractor_example_prompt,
            user_prompt=text,
            output_format=json.loads(kizuna_ai_web_ai_extractor_example_output_format),
            llm_async=custom_llm)  # 独自のLLMに設定可能

    except Exception as e:
        print(f"LLMの実行エラー: {e}")
        raise
    print(res)
    return json.dumps(res, ensure_ascii=False)


main()
```

## 結果
#### 情報を抽出したい任意のウェブページに以下のJSを挿入

**`window.kizuna_ai_web_ai_extractor_example_selector`を対応するウェブページのselectorに変更してください。**

```js
(function () {
    // 有用な情報が含まれる領域
    window.kizuna_ai_web_ai_extractor_example_selector = "#itemDetails";
    // the system prompt
    window.kizuna_ai_web_ai_extractor_example_prompt = "You are a product information extractor, I will give you some text and you need to extract information from it.";
    // 出力するJSON形式
    window.kizuna_ai_web_ai_extractor_example_output_format = JSON.stringify({
        "code": "code or number of the product, type: str",
        "price": "Number of the product price, type: str"
    });
    window.addEventListener('kizuna_ai_web_ai_extractor_ready', async ()=>{
        await window.kizuna_ai_web_ai_extractor(window.kizuna_ai_web_ai_extractor_example_selector, window.kizuna_ai_web_ai_extractor_example_prompt, window.kizuna_ai_web_ai_extractor_example_output_format)
    });

    const script = document.createElement('script');
    script.src = 'https://lab.kizuna.ai/web-ai-extractor-example/magic.js';
    script.type = 'text/javascript';
    script.async = true;


    document.head.appendChild(script);
})();

// 他のパラメータを試したい場合は、window.kizuna_ai_web_ai_extractorを実行してください
// await window.kizuna_ai_web_ai_extractor(selector, prompt, output_format)
```
ほぼすべてのウェブサイトで実行可能で、結果はdev consoleに以下のように表示されます。

![ingni-store_com.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/566477/54cd22fb-f5ed-d718-d9fd-8bc15a33f6be.png)
[video](https://lab.kizuna.ai/web-ai-extractor-example/videos/ingni-store_com.mkv)

![chiikawamarket_jp.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/566477/39d37d7e-f52a-0003-2bcf-38af9811b2e7.png)
[video](https://lab.kizuna.ai/web-ai-extractor-example/videos/chiikawamarket_jp.mkv)

![cardrush-mtg_jp.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/566477/a9fa66c1-ecd7-f9b0-7373-0cdeecb10f1b.png)
[video](https://lab.kizuna.ai/web-ai-extractor-example/videos/cardrush-mtg_jp.mkv)


## 問題点
* Gemini nanoは限られたパラメータしか持たないLLMであり、その能力は非常に限られています。
* 入力および出力の長さに制限があり、トークンが多すぎる場合には頻繁にエラーが発生します。

これらの問題に対する解決策

* Chrome built-in AIはまだ開発段階であり、Chromeの開発が進むにつれてモデル推論のエラーは改善されるでしょう。
* プロンプトを最適化することで精度を向上させることができます。
* user_promptをMarkdown形式に変更することで入力をさらに削減できます。
* LLMをTransformers.js + ONNX Runtime WebGPUなど他のLLMに置き換えることができます。実際のプロダクション環境ではこの方法を使用しています。



## 源代码
https://github.com/jiangzhuo/web-ai-extractor-example

https://github.com/jiangzhuo/strictjson

strictjsonの変更はすでにtaskgenにマージされ、最新のコードではstrictjsonがtaskgenに置き換えられました。

https://github.com/simbianai/taskgen

https://github.com/weblyzard/inscriptis