Laravel で問い合わせ直前に「このQ&Aがあるよ!」を表示する

こんにちは❗フリーランス・コンサルタント&エンジニアの 九保すこひ です。

さてさて、私はこれまで個人サイトを数多く運営してきました。(とはいえ、そのほとんどが泣かず飛ばずで、すぐ閉鎖となりましたが 😂)

プログラマなので開発自体は楽しいのですが、やることの中には「うーん、これはめんどうだな」と思うものがあります。

それが・・・・・・

お問い合わせの回答

です。

つまり、訪問ユーザーさんからの質問ですね。

もちろん、「確かにそれはわからないよね、ゴメンナサイ」という質問もあるのですが、長く運営していると「うーん、またこの質問か、、、FAQに書いてるんだけどな」となるものも多いんですよね。(そもそもFAQなんてめんどうだから、読まずに聞いちゃえという人もいるようです💦)

そこで❗

今回は、Laravelを使って、お問い合わせ送信する前に「このQ&Aがあるよ!」と内容に合った回答を表示してあげるという機能をつくってみることにしました。

ぜひ何かの参考になりましたら嬉しいです😊✨


「Google Pixel が
ピッタリ地面と平行に落ち、
クラッシュ完成😫」

開発環境: Laravel 8.x

実装する流れ

今回実装する流れは次のとおりです。

  1. 訪問ユーザーさんが「お問い合わせ内容」を入力する
  2. 自動的に Ajax でその内容を送信
  3. mecab を使って形態素解析し、キーワードを抽出
  4. キーワードで FAQ を検索し、あれば表示

※ ちなみにFAQとは、Frequently Asked Questionsの頭文字をとったもので、つまり「よくある質問 ≒ Q&A」のことです。(英語でお下品に聞こえるので続けて読んじゃダメです)

では、楽しくやっていきましょう❗

前提として

前の項目でも書きましたが、今回はmecabを使って形態素解析(テキストを品詞ごとに分ける機能)を使います。

そのため、mecabがインストールされていることが前提です。

もしまだインストールされていない方は以下のページを参考にしてみてください。

📝参考ページ: 【PHP】Mecabで「似ているデータ」を見つける方法

※ なお、今回の記事ではシンプルにmecab本体をインストールしてexec()で実行しています。

必要なファイルを用意する

では、今回の機能に必要なファイルを作成します。
必要になるのは、以下4つです。

  • モデル:DBテーブルの管理
  • マイグレーション: DBテーブルの作成
  • コントローラー: FAQデータを Ajax を通して取得する
  • Seeder: 開発しやすいようにテストデータをつくる

では、以下のコマンドを実行してください。

php artisan make:model FaqItem -mcs

なお、これまで-mでマイグレーションだけ作成していましたが、今回は-mcsでコントローラー、 Seederファイルも自動作成しています。(その他のオプションは公式ドキュメントをご覧ください)

※ このオプションは、ジーコさんのツイートが元で知りました。感謝です😊

では、作成されたファイル中身をそれぞれ変更していきましょう。

マイグレーション

マイグレーションは、「DBテーブルを作成するための設計書」のようなものです。

database/migrations/****_**_**_******_create_faq_items_table.php

// 省略

public function up()
{
    Schema::create('faq_items', function (Blueprint $table) {
        $table->id();
        $table->text('question')->comment('質問');
        $table->text('answer')->comment('回答');
        $table->timestamps();
    });
}

ちなみに、comment()をつけておくと、PhpMyAdminadminerでテーブルを見たとき、カラムのコメントとして表示してくれて便利ですよ👍

Seeder(テストデータ作成)

そして、テストデータを追加するためのSeederです。

database/seeders/FaqItemSeeder.php

public function run()
{
    $faq_data = [
        [
            'question' => 'どんな支払い方法がありますか?',
            'answer' => 'お支払いは、クレジットカードのみになります。',
        ],
        [
            'question' => 'プランの変更はどのようにすればいいですか?',
            'answer' => 'ログイン後、プランページ > 変更ページで行っていただけます。',
        ],
        [
            'question' => 'メールアドレスの変更はどのようにすればいいですか?',
            'answer' => 'ログイン後、「マイアカウント」ページで行っていただけます。',
        ],
        [
            'question' => 'バックアップはどのようにすればいいですか?',
            'answer' => 'ログイン後、「バックアップ」ページで行っていただけます。',
        ],
        [
            'question' => '退会するにはどうすればいいですか?',
            'answer' => 'ログイン後、マイアカウント > 退会 で行っていただけます。',
        ]
    ];

    foreach ($faq_data as $faq_values) {

        $faq_item = new FaqItem();
        $faq_item->question = $faq_values['question'];
        $faq_item->answer = $faq_values['answer'];
        $faq_item->save();

    }
}

そして、このSeederファイルは忘れずLaravelへ登録しておきましょう。

database/seeders/DatabaseSeeder.php

// 省略

public function run()
{
    // 省略

    $this->call(FaqItemSeeder::class);
}

これで、データベースまわりの準備は完了しました。
以下のコマンドでDBを再構築しておきましょう。

php artisan migrate:fresh --seed

すると実際のテーブルはこうなりました。

コントローラー

では、続いてコントローラーです。

app/Http/Controllers/FaqItemController.php

<?php

namespace App\Http\Controllers;

use App\Models\FaqItem;
use Illuminate\Http\Request;

class FaqItemController extends Controller
{
    public function list(Request $request)
    {
        $result = false;
        $faq_items = [];
        $tags = [];

        if($request->filled('question')) {

            $question = str_replace(["\n", "\r"], ' ', $request->question); // 👈 改行コードをスペースに置き換え
            exec('echo "'. $question .'" | mecab', $mecab_results);

            foreach($mecab_results as $mecab_result) {

                if(preg_match('|^([^\t]{2,})\t名詞|u', $mecab_result, $matches)) { // 👈 条件: 名詞で2文字以上

                    $tags[] = $matches[1];

                }

            }

            if(count($tags) > 0) {

                $faq_items = FaqItem::select('question', 'answer')
                    ->where(function($query) use($tags){

                        foreach ($tags as $tag) {

                            $query->orWhere('question', 'LIKE', '%'. $tag .'%')
                                ->orWhere('answer', 'LIKE', '%'. $tag .'%');

                        }

                    })
                    ->take(5) // 👈 多すぎてもいけないので最大5件にしました
                    ->get();
                $result = true;

            }

        }

        return [
            'result' => $result,
            'faq_items' => $faq_items,
            'faq_tags' => $tags
        ];
    }
}

この中でやっていることは、おおざっぱに以下のとおりです。

  1. 問い合わせ内容がパラメータ「question」として入ってくる
  2. mecab で形態素解析(品詞ごとに分解)する
  3. 分解した中から、「名詞 & 2文字以上」という条件でフィルターをかける
  4. 条件をクリアした名詞を使って「faq_items」テーブルを検索
  5. json で結果を返す

なお、コマンド部分は例えば以下のようになります。

echo "プランを変えたいのですがどうすればいいですか?" | mecab

こうすることで、PHPからmecabコマンドから結果を取得できるんですね。

ビューをつくる

では、実際に訪問ユーザーが触ることになる「お問い合わせフォーム」のテンプレートをつくっていきましょう。

(ビューだけはコマンドで作成できないので自分でファイルをつくってください)

resources/views/contact.blade.php

<html>
<head>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div id="app" class="p-5">
    <h3 class="mb-3">問い合わせ送信する前にFAQを表示するサンプル</h3>
    <div class="row">
        <div class="col-6">
            お問い合わせ内容
            <div class="mb-3">
                <textarea class="form-control" rows="7" v-model="question" @input="getFaqItems"></textarea>
            </div>
            <div class="mb-3" v-if="faqItems.length">
                <h6 class="text-danger fw-bold">もしかすると、こちらの回答が参考になるかもしれません。</h6>
                <div v-for="faqItem in faqItems">
                    <div class="border rounded bg-light mb-2 px-3 py-2">
                        <strong>Q: </strong><span v-html="getHighlightedContent(faqItem.question)"></span>
                        <br>
                        <strong>A: </strong><span v-html="getHighlightedContent(faqItem.answer)"></span>
                    </div>
                </div>
            </div>
            <div class="text-end">
                <button type="button" class="btn btn-primary">送信する</button>
            </div>
        </div>
    </div>
</div>
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.9.2/dist/umd/popper.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/js/bootstrap.min.js"></script>
<script src="https://unpkg.com/vue@3.1.1/dist/vue.global.prod.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.21.1/axios.min.js"></script>
<script>

    Vue.createApp({
        data() {
            return {
                question: '',
                faqItems: [],
                faqTags: [],
                isFaqItemLoading: false
            };
        },
        methods: {
            getFaqItems() {

                if(this.isFaqItemLoading === false) { // 大量アクセスを防ぐ

                    this.isFaqItemLoading = true;
                    this.faqItems = [];

                    const url = '{{ route('faq_item.list') }}';
                    const params = {
                        question: this.question
                    };
                    axios.post(url, params)
                        .then(response => {

                            if(response.data.result === true) {

                                this.faqItems = response.data.faq_items;
                                this.faqTags = response.data.faq_tags;

                            }

                        })
                        .finally(() => { // 成功 or 失敗関係なく実行される部分

                            this.isFaqItemLoading = false;

                        });

                }

            },
            getHighlightedContent(text) {

                this.faqTags.forEach(tag => {

                    text = text.replace(tag, `<strong class="bg-warning text-dark rounded">${tag}</strong>`);

                });

                return text;

            }
        }
    }).mount('#app');

</script>
</body>
</html>

JavaScript(Vue)の流れは次のとおりです。

  1. 訪問ユーザーが「お問い合わせ内容」に質問を入力する
  2. input イベントで随時その内容を Ajax で送信
  3. Ajax 送信して返ってきた内容があれば Vue で表示

なお、そのままだと連続して何度もAjax送信されることになりますので、isFaqItemLoadingを使って「1回の送信が完了するまではロックするよ」パターンにしています。(解除はfinally()の中です)

ルートをつくる

では最後に、これまででつくった以下の部分用のルートをつくりましょう。

  • Ajax で FAQ 探す部分
  • お問い合わせフォーム
use App\Http\Controllers\FaqItemController;

// 省略

Route::post('faq_item/list', [FaqItemController::class, 'list']);   // FAQを探す部分
Route::get('contact', fn() => view('contact'));                    // お問い合わせフォーム

※ ちなみに最後の行はphp 7.4から使えるようになったアロー関数です。(使いどころは大事ですが)このように省略して書けるので便利ですね👍✨

テストしてみる

では、実際にテストしてみましょう。
お問い合わせ内容」として入力する質問は次のとおりです。

  • メールアドレスを変えたいのですがどうすればいいでしょうか。
  • 上位プランにするにはどうすればいいですか?
  • 支払い方法を教えてください。

では、実際の結果です。

うまくいきました😊👍✨

デモを用意しました

実際に試していただけるデモページを用意しました。
以下からお試しください。

※ ただし、FAQデータはサンプルコード内で用意した4だけですので、「テストしてみる」で使った文章を使っていただけるといいかと思います。

📝デモページ

企業様へのご提案

今回の機能を使えば、お客様からの質問を減らし業務軽減することができます。

また、お客様用だけでなく、業務に関するFAQを作っておけば、従業員同士の問い合わせフォームにも使え、この場合もメール返信の手間がなくなるでしょう。

また、今回はDBテーブルを通常検索しているので、数が多くなるとどうしても処理が遅くなってしまいますが、「全文検索」機能をつけておけば高速化も可能です。

ちなみに、今回の機能はそのまま「チャットボット」にも使えるかと思います。

もしこういった機能をご希望でしたらお気軽にお問い合わせからご連絡ください。どうぞよろしくお願いいたします。m(_ _)m

おわりに

ということで、今回は「お問い合わせ直前にFAQを表示」ができるようにしてみました。

実際にはまだ個人サイトにまで手が回っていないのですが、JavaScriptタグをセットするだけで、今回のような機能が使えるようになるサービスをつくって面白いかな、なんて考えてました。

※ アイデアはいくつかあるんですけど、実際仕事の合間に開発を進めるのは簡単じゃないですね。(休日はとっておかないとパフォーマンスが下がったりしますし😂)

とにもかくにも、今回はこの辺でお開きです。

ではでは〜❗

「新しいのもGoogle Pixelです✨」

開発のご依頼お待ちしております 😊✨
開発のご依頼はこちらから: お問い合わせ
どうぞよろしくお願いいたします!
このエントリーをはてなブックマークに追加       follow us in feedly  

開発効率を上げるための機材・まとめ