0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Cloudflare Workers AI + Bookmarkletでいつでも励ますちびキャラ召喚ボタン作ってみた(無料でな)

Last updated at Posted at 2025-12-03

はじめに

こんにちは。CYBIRD Advent Calendar 2025の4日目担当、@kyukkyu81 です。
3日目は…おっと、まだこれを書いている時点で空いている…。2日目は @cy_ssss さんの「ChatGPT に助けてもらいながら Unity の UI Toolkit でエディタ拡張を作ってみた」でした。
私ぜんぜんUnity詳しくないですがエディタをカスタマイズできるんですね。大昔にMIFESというテキストエディタでMIL言語使っていろいろカスタマイズしたのを思い出しました。…話が古すぎましたね。それにしてもみんなチャッピー(ChatGPT)など生成AIへの順応性が高いね!私も生成AI便利に使いつつも依存し過ぎて飲み込まれないようにしなければ。ダークサイドに落ちないためのヨーダの言葉を思い出しました。フォースと共にあらんことを。

昔話から始まる概要

昔の話になりますが、私がサイバードに入社した後しばらくして「マスコットキャラクターがご主人様をあれこれサポートするガラケー向けサイト(当時ガラケーにアプリという機能はまだ無かった)」を勢いだけで企画したことがあったのですが、当時そんなAIじみた事がガラケーで出来るはずもなく…夢物語に終わったのをふと思い出しました。

あれからもう四半世紀が経ち、そろそろ頑張ればあの企画は実現出来る時代になってきたのでは、と思いながらAI絡みで今回のネタ何かないかなーと考えていたところ…学生時代に「伺か(うかがか)」というデスクトップアプリが流行ったことを思い出しました。パソコンの画面右下にキャラクターが常駐してひたすらフキダシ表示で他愛ない会話をするものです。

これだ。「伺か」のスーパー簡易版をイメージして、会話部分にテキスト生成AI使ってご主人が疲れてピンチな時にとにかく励ます、というコンセプトで何か作ってみよう。


「ふと励まして欲しくなったとき」が私は難解な英語のリファレンスサイトやブログを漁っている時なので、ブラウザで今読んでいるサイトの右下にワンアクションでかわいいキャラを召喚できないかな、と考えました。

…はい、そんなニーズに都合のいい技術があります。ブックマークレット(bookmarklet)です。聞き慣れない方のために説明すると、ブラウザのブックマークを確認してみるとURLには"https://~" などと入っていますが、ここに"javascript:~"と記載することによりJavaScriptが実行できる、というものです。ここに即時関数を書けば「現在表示しているサイト」のHTMLを加工することもできます。要はブックマークリンクを「キャラクター召喚ボタン」に見立て、それを押すだけでマスコットキャラを表示させよう、というものです。

バックエンドにはCloudflare Workersを使いました。これはサーバーレスの JavaScript 実行環境で、awsで言うとLambdaが近いでしょうか。このサービス、Workers AIというサービスを併用することにより数行のコードを書くだけでLLMモデルを指定してテキストAI生成できちゃうので今回採用してみました。

気になる料金ですが、すべて無料枠で動作可能です(AI絡みなのに)。

  • ブックマークレット: もちろん無料
  • Cloudflare Workers: 1日あたり10万リクエスト
  • Workers AI: 1日あたり1万ニューロン(※)

となっており、お試しするには十分すぎる枠です。
(※)ニューロン:Cloudflare独自の単位。今回1回のテキスト生成で約5ニューロンでした。

バイブコーディングします

Geminiさんにパパっとバイブコーディングしてもらいました。その際に投げたプロンプトも載せています。これくらい短いコードなら一発で動くことも多いですが、AIに作ってもらったコードは念のため自分でレビューしましょう。(AI生成コードの動作担保度合は結局自分がコード書ける技量に依存するものと私は思っています)

今回デバッグや画像加工も含めておおよそ4時間くらいで完成しました。私自然言語でのアウトプット苦手なのでこの文章を作り上げるほうが時間掛かってます…
(あ、この文章もAIで作ってもらうことを今思いつきましたが、そのようにAIにお願いするほうが難易度高い気がしたので自分で書いてます)

バックエンド

テキスト生成AIで励まし文章を生成するAPIを作成していきます。以下、API構築手順を手短に列挙します。

・CloudflareにGoogleアカウント等でサインイン
・(必要に応じて)右上[Profile]からLanguage⇒日本語を選択
・[コンピューティングとAI]⇒[Workers & Pages]⇒[アプリケーションを作成する]
・[Hello World を開始する]⇒[デプロイ]

まだコードは編集せず、ここにおもむろにAIの機能をくっつけます。今作ったばかりのアプリケーションの画面から…

・画面上部[バインディング]タブ⇒[バインディングを追加]
・[Workers AI]⇒[バインディングを追加]
・変数名(このバインディングのインスタンス名)を適当に決め(ここでは"AI")、表示されたシンプルなコードが気になる方はコピペするなどしつつ…[バインディングを追加]

ここでようやく…
・[コードを編集する]

するとworkers.jsを編集する画面になるので、以下ソースをコピペします。

:scroll:バックエンド側 サンプルコード
export default {
  async fetch(request, env) {
    const tasks = [];

    // messages - chat style input
    let chat = {
      messages: [
        { role: 'system', content: 'AIはプロのカウンセラーです。相手を元気な気持ちにする会話が得意です。口調は全部の語尾に「にゃ」をつけて喋ってください。' },
        { role: 'user', content: 'ひとことだけ、励ましてください。' }
      ]
    };

    //let response = await env.AI.run('@cf/meta/llama-3-8b-instruct', chat);
    //let response = await env.AI.run('@cf/meta/llama-3.1-8b-instruct-awq', chat);
    let response = await env.AI.run('@cf/meta/llama-3.2-3b-instruct', chat);

    tasks.push({ response });

    return new Response(JSON.stringify(tasks), {
      headers: {
        'Content-Type': 'application/json',
        // CORSを許可する場合(必要に応じて)
        'Access-Control-Allow-Origin': '*'
      },
      status: 200, // HTTPステータスコード
    });
  }
};

・[デプロイ]を押して成功したら、その下に記載のURLにアクセスします。すると、生成AIが考えた励ましの文章を含むJSONが表示されます。

フロントエンド(実際に動くサンプル)

Geminiに最初はざっくりと作りたいもののイメージを伝えます。ブックマークに登録し、実行結果を確認し、詳細を詰めていきます。固定のセリフで完成が見えてきたら、セリフはAPIから取得するように変更することを指示します。具体的には以下のようなプロンプトを投げました。

:scroll:ブックマークレット プロンプト
- 画面の右下でキャラクターが躍るブックマークレットを作って
- 特定のURLからキャラクター画像とセリフを読み込んで表示したい
- 他のAPIにリクエストしてテキストを取得して表示することはできますか?
- 画面の右下で、特定のURLからキャラクター画像とセリフを読み込んで表示するブックマークレットを作ってください。
    セリフのAPI:https://~  # 先ほど作成したバックエンドAPI
    読み込み中キャラクター画像(最初はこの画像):https://~    # どこかに置く
    読み込み後キャラクター画像(セリフと同時に表示):https://~    # どこかに置く
- APIからこの形式でJSONが返されます。以下JSONの文字列部分を読み込んでください(JSONサンプルを渡す)

すると以下のようなコードが出力されました。先ほど作成したAPIや別途用意した画像(こちらも無料のCloudinaryに設置)のURLも実際に入れてあります。

以下、APIキーなど秘匿性のある情報は使用していません。APIや画像のURLを含んでいますが、全て無料枠で動いており、もしバックエンド側が1日のキャパシティを超えるとおそらくその日中はエラーになります。意図的にキャパシティ使い切るなどのいたずらはご遠慮ください。また逆に悪意のあるコードは含めていませんが気になる方はソースコードを自分で検証の上、自己責任で実行ください。

:scroll:ブックマークレット サンプルコード(minify前)
javascript:(function(){
    // --- 定数定義 ---
    const API_URL = 'https://encourage-test-kgs8.kyukkyu.workers.dev/';
    const IMG_LOADING = 'https://res.cloudinary.com/mokomoko-don/image/upload/v1763557732/encourage1_512_hwrmzd.png';
    const IMG_LOADED = 'https://res.cloudinary.com/mokomoko-don/image/upload/v1763557743/encourage2_512_pyvs0u.png';
    const CONTAINER_ID = 'my-char-bookmarklet-container';
    const DIALOGUE_ID = 'my-char-bookmarklet-dialogue';
    const IMAGE_ID = 'my-char-bookmarklet-image';

    // 既存の要素があれば削除し、多重実行を防ぐ
    const existingContainer = document.getElementById(CONTAINER_ID);
    if (existingContainer) {
        document.getElementById(CONTAINER_ID + '-style').remove();
        existingContainer.remove();
        return;
    }

    // --- CSSスタイルの定義と追加 ---
    const style = document.createElement('style');
    style.id = CONTAINER_ID + '-style';
    style.innerHTML = `
        #${CONTAINER_ID} {
            position: fixed;
            bottom: 10px;
            right: 10px;
            z-index: 99999;
            display: flex;
            flex-direction: column;
            align-items: flex-end;
            cursor: pointer;
            font-family: sans-serif;
            width: fit-content;
            transition: all 0.3s ease-in-out;
        }
        #${DIALOGUE_ID} {
            background: #fff;
            color: #000;
            border: 2px solid #000;
            border-radius: 10px;
            padding: 10px 15px;
            margin-bottom: 5px;
            box-shadow: 2px 2px 5px rgba(0,0,0,0.3);
            max-width: 250px;
            word-wrap: break-word;
            opacity: 0.95;
            position: relative;
            font-size: 15px;
            font-weight: bold;
            transition: opacity 0.3s ease-in-out;
        }
        #${DIALOGUE_ID}::after {
            content: '';
            position: absolute;
            bottom: -10px;
            right: 25px;
            border-width: 10px 10px 0 0;
            border-style: solid;
            border-color: #000 transparent transparent transparent;
        }
        #${DIALOGUE_ID}::before {
            content: '';
            position: absolute;
            bottom: -8px;
            right: 27px;
            border-width: 8px 8px 0 0;
            border-style: solid;
            border-color: #fff transparent transparent transparent;
        }
        #${IMAGE_ID} {
            width: 120px; 
            height: auto;
            border-radius: 5px;
            transition: opacity 0.3s ease-in-out;
        }
    `;
    document.head.appendChild(style);

    // --- HTML要素の作成と初期表示 ---
    const container = document.createElement('div');
    container.id = CONTAINER_ID;
    container.title = 'クリックで削除';

    const dialogueDiv = document.createElement('div');
    dialogueDiv.id = DIALOGUE_ID;
    dialogueDiv.textContent = 'セリフを取得中...';

    const charImg = document.createElement('img');
    charImg.id = IMAGE_ID;
    charImg.src = IMG_LOADING; // 読み込み中画像を設定
    charImg.alt = 'キャラクター';

    container.appendChild(dialogueDiv);
    container.appendChild(charImg);
    document.body.appendChild(container);

    // クリックで削除する機能
    container.onclick = function() {
        container.remove();
        style.remove();
    };

    // --- APIリクエストとデータ処理 ---
    fetch(API_URL)
        .then(response => {
            if (!response.ok) {
                throw new Error(`HTTP Error: ${response.status}`);
            }
            return response.json();
        })
        .then(data => {
            let finalDialogue = 'セリフが取得できませんでした。';

            // JSONからセリフを取得
            if (Array.isArray(data) && data.length > 0 && data[0].response && data[0].response.response) {
                finalDialogue = finalDialogue=data[0].response.response;
            } else {
                 finalDialogue = 'JSON形式が想定外です。';
            }

            // 画像とセリフを更新
            dialogueDiv.textContent = finalDialogue;
            charImg.src = IMG_LOADED; // 読み込み後画像に切り替え
        })
        .catch(error => {
            // エラー表示
            dialogueDiv.style.background = '#f8d7da'; // エラー背景色
            dialogueDiv.style.color = '#721c24';
            dialogueDiv.textContent = `エラー: ${error.message}`;
        });
})();

要注意ポイントとして、Geminiに「ブックマークレットであること」を伝えるとminifyした(すべて1行につなげて文字数を極力切り詰めた)ソースコードも同時に作ってくれますが、過信してはいけません!このminify前後のソースコードがロジック的に一致している保証はありません。今回一か所、しれっとminify後だけちゃんと動く部分があったので(矛盾解消のため)minify前を修正しました。他にもあるかもしれません。。minifyはGeminiに任せず、minify前のコードを人間がレビューし、専用のminifyツールに掛けたものを実行するのが確実です。

ちなみに、minify前のソースを直接URL欄にコピペすると一見コードは1行にはなりますが、コメントアウトが後ろの行に影響してしまい、うまく動かないので注意。コメントアウトすべて外すか、/* ... */の形式にすれば動くかもしれませんが。

:scroll:ブックマークレット サンプルコード(minify後)
javascript:(function(){const i='https://encourage-test-kgs8.kyukkyu.workers.dev/';const t='https://res.cloudinary.com/mokomoko-don/image/upload/v1763557732/encourage1_512_hwrmzd.png';const n='https://res.cloudinary.com/mokomoko-don/image/upload/v1763557743/encourage2_512_pyvs0u.png';const c='my-char-bookmarklet-container';const d='my-char-bookmarklet-dialogue';const m='my-char-bookmarklet-image';const e=document.getElementById(c);if(e){document.getElementById(c+'-style').remove();e.remove();return}const s=document.createElement('style');s.id=c+'-style';s.innerHTML=`#${c}{position:fixed;bottom:10px;right:10px;z-index:99999;display:flex;flex-direction:column;align-items:flex-end;cursor:pointer;font-family:sans-serif;width:fit-content;transition:all%200.3s%20ease-in-out}#${d}{background:#fff;color:#000;border:2px%20solid%20#000;border-radius:10px;padding:10px%2015px;margin-bottom:5px;box-shadow:2px%202px%205px%20rgba(0,0,0,0.3);max-width:250px;word-wrap:break-word;opacity:0.95;position:relative;font-size:15px;font-weight:bold;transition:opacity%200.3s%20ease-in-out}#${d}::after{content:'';position:absolute;bottom:-10px;right:25px;border-width:10px%2010px%200%200;border-style:solid;border-color:#000%20transparent%20transparent%20transparent}#${d}::before{content:'';position:absolute;bottom:-8px;right:27px;border-width:8px%208px%200%200;border-style:solid;border-color:#fff%20transparent%20transparent%20transparent}#${m}{width:120px;height:auto;border-radius:5px;transition:opacity%200.3s%20ease-in-out}`;document.head.appendChild(s);const%20r=document.createElement('div');r.id=c;r.title='%E3%82%AF%E3%83%AA%E3%83%83%E3%82%AF%E3%81%A7%E5%89%8A%E9%99%A4';const%20o=document.createElement('div');o.id=d;o.textContent='%E3%82%BB%E3%83%AA%E3%83%95%E3%82%92%E5%8F%96%E5%BE%97%E4%B8%AD...';const%20a=document.createElement('img');a.id=m;a.src=t;a.alt='%E3%82%AD%E3%83%A3%E3%83%A9%E3%82%AF%E3%82%BF%E3%83%BC';r.appendChild(o);r.appendChild(a);document.body.appendChild(r);r.onclick=function(){r.remove();s.remove()};fetch(i).then(u=>{if(!u.ok)throw%20new%20Error(`HTTP%20Error:%20${u.status}`);return%20u.json()}).then(u=>{let%20l='%E3%82%BB%E3%83%AA%E3%83%95%E3%81%AE%E5%8F%96%E5%BE%97%E3%81%AB%E5%A4%B1%E6%95%97%E3%81%97%E3%81%BE%E3%81%97%E3%81%9F%E3%80%82';if(Array.isArray(u)&&u.length>0&&u[0].response&&u[0].response.response){l=u[0].response.response;}else{l='JSON%E3%81%AE%E6%A7%8B%E9%80%A0%E3%81%8C%E6%83%B3%E5%AE%9A%E5%A4%96%E3%81%A7%E3%81%99%E3%80%82'}o.textContent=l;a.src=n}).catch(u=>{o.style.background='#f8d7da';o.style.color='#721c24';o.textContent=`%E3%82%A8%E3%83%A9%E3%83%BC:%20${u.message}`})})();

上記minify後のソースは実際に動作します。
コピーし、ブラウザでブックマークを新規追加、そしてそのURL欄にこのソースをペーストして保存し、そのブックマークを呼びやすい部分(ブックマークツールバー等)に置きます。

そして難しいサイトを読んでめげそうな時にこのブックマークを押すと…

実行結果

現在表示されているサイトのHTMLをうまく加工できたようです。(念のためですが、取得済みのサイトをローカルPC上で加工しているだけで、元のサイトに何か影響を与えるものではありません。こんな簡単にハッキングなんかできんて)

スクリーンショット 2025-11-25 230822.png
出典:https://science.nasa.gov/what-is-the-spooky-science-of-quantum-entanglement/
キャラクター画像:私が書いたドット絵をGeminiがフィギュア画像化したものです

ネコよ、汝に救われん

難しいサイトを読み、ストレスで崩壊しそうな魂は、ネコ魔導士?の癒しの会話によって救われました。これでまた仕事を効率よく進められることでしょう。

そうです、これはもう仕事の効率化をAIで実現した画期的なツール事例なのです。にゃん。(渋声のCVで)

さいごに

明日のCYBIRD Advent Calendar 2025 5日目は、 @RN-Vid さんの『映像の「演出考察」をAIにさせようとして挫折したが、なんとかリファレンス収集ツールに着地できた件』です。こちらも宜しければ是非!

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?