Blog

【教員ブログ】1年生のアンケート分析のため、相棒(Gemini)とタッグを組んで固有名詞抽出Webアプリを爆速開発した話

先日、情報メディア学科の1年生が全員受けるオムニバス授業『情報メディア入門』の、私の担当回(Webデザイン・UI/UX分野)が無事に終了しました。

授業後、学生たちに「普段使っているアプリ」や「使いにくいと感じるUI/UX」についての記述式アンケートを実施したのですが……集まった生の声がとにかく多い!(受講生67名でしたけど笑)多国籍な1年生たちのリアルな日常や鋭い視点が見えて最高に面白いのですが、このテキストの山から手作業で「アプリ名(固有名詞)」だけをピックアップするのは、なかなかな重労働です。

「よし、こういう時こそテクノロジーの力で自動化しよう!」

そう思い立った私は、夜なべ……ではなく、有能なAIアシスタントのGeminiを画面に呼び出しました。ここから、人間(教員)とAIの爆速共同開発がスタートします。

🔍 参考記事のロジックをベースに、AIへ無茶振り

まずはリサーチ。技術情報共有サイト『Zenn』で、Pythonを使ってテキストから固有名詞を抜き出す素晴らしいロジックを紹介している記事を見つけました。

「Python × GiNZAで固有名詞を抽出してみる」2023/01/02に公開(https://zenn.dev/activecore/articles/f26f277ae80a94)

それは、「ポテト」+「サラダ」のように、一般名詞が連続した場合はくっつけて「ポテトサラダ」という1つの固有名詞にするという、今回のニーズにドンピシャなアルゴリズム。

「これをベースにしたい。でも、Pythonだとゼミ生たちに『これ使ってみて!』と気軽に渡せないから、ブラウザでHTMLを開くだけで誰でも動かせるWebアプリにして!」

そうGeminiに無茶振りすると、サクッと、ブラウザ上で動く軽量なJavaScriptライブラリ(kuromoji.js)へと丸ごと翻訳・移植したコードを吐き出してくれました。

🛠️ 1年生の多様性と、UXへのこだわりをAIとチューニング

しかし、いざ1年生のアンケートデータを流し込んでみると、新たな問題が発生します。

「『BILIBILI』や『ChatGPT』『BeReal』がうまく拾えない……!」

NBUの学生には中国人や韓国人の留学生も多く、アジア圏のリアルな最新アプリ名がたくさん挙がっていました。しかし、日本の伝統的な辞書ベースだと、これらは「一般名詞1語」としてすり抜けてしまうのです。

「Geminiちゃん、これじゃ留学生のリアルな声が拾えない!1語だけでもアルファベットを含む英単語なら、最新の固有名詞(アプリ名)として強制的に救い上げる判定コードを追加して!」 「了解です!」(と言わんばかりのスピードでコードが修正される)

さらに、抽出されたワードをExcelやパワポなどに一発でペーストして集計できるよう、「クリックで一括コピー」できる、よくあるソースコードのコピーボタン風のUI/UXもGeminiにお願いして実装してもらいました。

そのソースコードをブラウザで表示したのがこれ↓ 軽くて最高。

📈 データ解析で発見!1年生たちの意外な視点1年生たちの「思考」

ネットで見つけた優れた技術のアイデアをベースにして、生成AIっていう現代の強力なツールを使いこなしながら、目の前の課題に合わせてUI/UXをデザインし直していく。

これぞまさに、私が先日の『情報メディア入門』の授業で、1年生のみんなに一番伝えたかった「メディアデザイン」のリアルな面白さそのものです。「こういうのが欲しかった!」を、自分の手(とAIの手)で形にしていくのって、最高にワクワクしませんか?

こうして、私とGeminiのタッグによってわずか数分で完成した自作ツールですが、アンケートをかけると、狙い通り「BILIBILI」から「闲鱼(シェンユー)」「カカオトーク」「U-FRET」にいたるまで、多国籍な1年生たちのリアルな日常が詰まったワードが一瞬で、綺麗に浮かび上がってきました。

驚いたのは、1年生たちが単に「バグがある」というレベルではなく、階層メニュー(情報設計)やメモリRAM消費(パフォーマンス)といった、Webデザインの本質に迫る「使いにくさ」を自分の言葉で言語化していたことです。

完成したこのWebアプリ、この記事に貼っておくので、ゼミ生のみんなも今後のリサーチやデータ分析で使えたら使ってみて!

「先生、AI使って裏でこんなの作ってたんだ」ってニヤリとしてもらえれば本望です。

プログラミングのコードが完璧に書けなくても(私自身HTML +CSSのコード書くのとJS読める程度)、アイデアと「AIへの伝え方(プロンプト)」さえあれば、自分専用のツールが爆速で作れる時代です。ゼミ生のみんなも、ぜひAIを最強の相棒にして、これからもっと面白い仕掛けをたくさん作っていきましょう!

固有名詞・英単語抽出Webアプリのソースコード

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>固有名詞・英単語抽出ツール</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <script src="https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/build/kuromoji.js"></script>
    <style>
        body { font-family: 'Helvetica Neue', Arial, 'Hiragino Kaku Gothic ProN', 'Hiragino Sans', Meiryo, sans-serif; }
    </style>
</head>
<body class="bg-slate-50 text-slate-800 min-h-screen flex flex-col justify-between">

    <div class="container mx-auto max-w-3xl p-6 bg-white shadow-md rounded-lg my-8">
        <h1 class="text-2xl font-bold mb-2 text-slate-700 border-b pb-3 flex items-center gap-2">
            🏷️ 固有名詞・英単語抽出Webアプリ
        </h1>
        <p class="text-sm text-slate-500 mb-6">
            固有名詞 + 未知の英単語抽出機能を搭載。<br>抽出後、右下の黒いボタンから、抽出されたすべての単語を一括でコピーできます。
        </p>

        <div id="status" class="mb-6 p-3 bg-amber-50 text-amber-800 border border-amber-200 rounded text-sm font-medium flex items-center gap-2">
            ⏳ 辞書データを読み込んでいます...(初回は数秒かかります)
        </div>

        <div class="mb-4">
            <label class="block text-sm font-bold mb-2 text-slate-600" for="text-input">解析するテキスト</label>
            <textarea id="text-input" class="w-full h-48 p-3 border border-slate-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-slate-100" placeholder="ここに文章を入力してください。&#10;アンケートフォームで集めたコメントなど、整形済みのテキストをここに貼り付ける" disabled></textarea>
        </div>

        <div class="mb-6 flex flex-wrap items-center justify-between gap-4">
            <div>
                <label class="block text-sm font-bold mb-1 text-slate-600" for="min-length">
                    結合する一般名詞の最小長さ (<span class="italic font-mono">min_nouns_length</span>)
                </label>
                <input type="number" id="min-length" min="1" value="2" class="w-24 p-2 border border-slate-300 rounded text-center focus:outline-none focus:ring-2 focus:ring-blue-500" disabled>
            </div>
            <button id="extract-btn" class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2.5 px-6 rounded transition disabled:bg-slate-300 disabled:cursor-not-allowed cursor-pointer" disabled>
                固有名詞を抽出する
            </button>
        </div>

        <div>
            <div class="mb-2 flex items-center justify-between">
                <h2 class="text-lg font-bold text-slate-700 flex items-center gap-2">
                    📊 抽出結果 (<span id="result-count">0</span>件)
                </h2>
                <button id="copy-all-btn" class="hidden bg-slate-700 hover:bg-slate-800 text-white text-xs font-bold py-1.5 px-3 rounded transition flex items-center gap-1 cursor-pointer active:scale-95">
                    <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"></path></svg>
                    <span id="copy-all-text">すべての結果をコピー</span>
                </button>
            </div>
            <div id="result-list" class="p-4 border border-slate-200 bg-slate-50 rounded min-h-[120px] flex flex-wrap gap-2 text-sm">
                <span class="text-slate-400 italic">ここに結果がアルファベット・五十音順で表示されます。</span>
            </div>
        </div>
    </div>

    <footer class="text-center p-4 text-xs text-slate-400 border-t bg-white">
        Powered by kuromoji.js & Tailwind CSS
    </footer>

    <script>
        let tokenizer = null;
        let currentResults = []; // 現在の抽出結果を保持する配列

        // DOM要素の取得
        const statusEl = document.getElementById('status');
        const textInputEl = document.getElementById('text-input');
        const minLengthEl = document.getElementById('min-length');
        const extractBtnEl = document.getElementById('extract-btn');
        const resultListEl = document.getElementById('result-list');
        const resultCountEl = document.getElementById('result-count');
        const copyAllBtnEl = document.getElementById('copy-all-btn');
        const copyAllTextEl = document.getElementById('copy-all-text');

        // 1. kuromoji.jsの初期化
        kuromoji.builder({ dicPath: "https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/" }).build(function (err, _tokenizer) {
            if (err) {
                statusEl.className = "mb-6 p-3 bg-red-50 text-red-800 border border-red-200 rounded text-sm font-medium";
                statusEl.textContent = "❌ 辞書データの読み込みに失敗しました。ページを再読み込みしてください。";
                console.error(err);
                return;
            }
            tokenizer = _tokenizer;
            statusEl.className = "mb-6 p-3 bg-green-50 text-green-800 border border-green-200 rounded text-sm font-medium";
            statusEl.textContent = "✅ 準備完了!テキストを入力してボタンを押してください。";
            textInputEl.disabled = false;
            minLengthEl.disabled = false;
            extractBtnEl.disabled = false;
        });

        // 2. 抽出ロジック
        extractBtnEl.addEventListener('click', () => {
            const text = textInputEl.value.trim();
            const minLength = parseInt(minLengthEl.value, 10) || 2;

            if (!text) {
                alert("テキストを入力してください。");
                return;
            }

            const tokens = tokenizer.tokenize(text);
            const propns = []; 
            let nouns = [];    

            function commitNouns() {
                if (nouns.length === 0) return;
                const combined = nouns.join("");
                const isEnglishWord = /^[a-zA-Z0-9\-_]+$/.test(combined) && /[a-zA-Z]/.test(combined);

                if (nouns.length >= minLength || isEnglishWord) {
                    propns.push(combined);
                }
                nouns = []; 
            }

            for (const token of tokens) {
                const pos = token.pos;           
                const posDetail = token.pos_detail_1; 
                const surface = token.surface_form;

                if (pos === "名詞" && posDetail === "固有名詞") {
                    commitNouns();
                    propns.push(surface);
                } else if (pos === "名詞" && (posDetail === "一般" || posDetail === "サ変接続")) {
                    nouns.push(surface);
                } else {
                    commitNouns();
                }
            }
            commitNouns();

            // 重複排除とソート、グローバル変数への保存
            currentResults = [...new Set(propns)].sort();
            renderResults(currentResults);
        });

        // 3. 一括コピーボタンのイベント設定
        copyAllBtnEl.addEventListener('click', () => {
            if (currentResults.length === 0) return;

            // 全単語を「改行コード」で繋いだテキストを作成
            const textToCopy = currentResults.join('\n');

            navigator.clipboard.writeText(textToCopy).then(() => {
                // コピー成功時のエフェクト
                copyAllBtnEl.className = "bg-green-600 hover:bg-green-700 text-white text-xs font-bold py-1.5 px-3 rounded transition flex items-center gap-1 cursor-pointer";
                copyAllTextEl.textContent = "全部コピーしました!";

                setTimeout(() => {
                    copyAllBtnEl.className = "bg-slate-700 hover:bg-slate-800 text-white text-xs font-bold py-1.5 px-3 rounded transition flex items-center gap-1 cursor-pointer";
                    copyAllTextEl.textContent = "すべての結果をコピー";
                }, 1500);
            }).catch(err => {
                console.error('一括コピーに失敗しました: ', err);
            });
        });

        // 4. 結果の描画
        function renderResults(results) {
            resultListEl.innerHTML = '';
            resultCountEl.textContent = results.length;

            if (results.length === 0) {
                resultListEl.innerHTML = '<span class="text-slate-400 italic">固有名詞は見つかりませんでした。</span>';
                copyAllBtnEl.classList.add('hidden'); // 結果がなければ一括コピーボタンを隠す
                return;
            }

            // 結果があれば一括コピーボタンを表示
            copyAllBtnEl.classList.remove('hidden');

            results.forEach(word => {
                const button = document.createElement('button');
                button.className = "bg-blue-50 text-blue-700 border border-blue-200 px-3 py-1.5 rounded-full font-medium shadow-sm hover:bg-blue-100 transition cursor-pointer flex items-center gap-1 active:scale-95";
                
                button.innerHTML = `
                    <svg class="w-3.5 h-3.5 opacity-60 text-blue-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"></path></svg>
                    <span>${word}</span>
                `;

                button.addEventListener('click', () => {
                    navigator.clipboard.writeText(word).then(() => {
                        const originalClass = button.className;
                        const originalHtml = button.innerHTML;

                        button.className = "bg-green-50 text-green-700 border border-green-200 px-3 py-1.5 rounded-full font-medium shadow-sm transition flex items-center gap-1";
                        button.innerHTML = `
                            <svg class="w-3.5 h-3.5 text-green-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
                            <span>コピー完了!</span>
                        `;

                        setTimeout(() => {
                            button.className = originalClass;
                            button.innerHTML = originalHtml;
                        }, 800);
                    });
                });

                resultListEl.appendChild(button);
            });
        }
    </script>
</body>
</html>

おすすめ

[instagram-feed]