ネットカフェ向けにコミックをどこまで読んだか記録するLINEボットをつくった

さてさて、この間 LaravelでLINEにチャットボットをつくる(QRコード作成) という記事を公開したのですが、その際にまだまだ使い方によっては面白いサービスをつくれるんじゃないかと、ずっと考えていました。

その時は、まったく何も思い浮かばなかったのですが、この間久しぶりに行ったネットカフェで漫画を読んでいて感じることがありました。

それは・・・

この漫画おもしろいから、また読みに来よう!・・・でも今度来たらどこまで読んだかきっと忘れてるだろうなー😅

と。

もちろんスマートフォンにメモしとけばいいんですけど、一回一回めんどうですし、どこにメモしたかを忘れてしまったりもします。

そこで!

今回はLINEボットでこれを実現できるシステムを作ってみることにしました。(詳しい内容は「やりたいこと」をご覧ください)

ぜひ皆さんの参考になると嬉しいです!

開発環境: Laravel 5.8、CentOS 7.3

やりたいこと

今回実装したい内容は以下になります。

  • 漫画についているバーコードを写真にとって送信すれば、そこから漫画データを取得して「最後に読んだ漫画」として保存する
  • テキスト(キーワード)を送信すれば、すでに保存された漫画データの中から該当するものを返信する
  • 「一覧」「最後」「この前」のどれかを送信すれば、直近の保存データ(最大7件)を返信する
  • 登録データを全削除したい場合は「バルス!」と送信する😂

※ どうやら最近の講談社コミックは外袋にバーコードが貼ってあるので裏表紙にはバーコードがついていない場合もあるみたいです。その場合は、使えません。ゴメンナサイ 【追記:2019.09.02】写真の代わりに、直接テキストで13桁のISBNコードを送信して漫画を登録できるように変更しました!

先に試してみる

先に完成品を試して見たい場合は以下のQRコードを読みとって実行してください。「コミックのしおり」という名前のBOTになります。

※ このボットは今後も公開したままにするつもりですので、自由に使ってください。

【コミックのしおり】

LINE側の作業

まずはLINE側の作業ですが、こちらはLaravelでLINEにチャットボットをつくる(QRコード作成)

と同じですので、このページの「LINE Messaging APIを使えるようにする」「パッケージをインストールする」を参考にして実行しておいてください。

バーコードを読み取るオープンソース「ZBar」をインストールする

実はPHP本体でバーコードを読み取るパッケージというのはあまり存在していません。そのため、今回はZBarと呼ばれるパッケージをLinuxにインストールし、PHPからこのパッケージを呼び出して実装することにします。

epelをインストールする

ZBarepelというレポジトリからインストールすることができますので、まずはこちらを使えるようにします。(すでに有効になっている場合はよみ飛ばしてください)

どこでもいいので適当なフォルダに移動して以下のコマンドを実行してください。

wget https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm

すると、epel-release-latest-7.noarch.rpmというファイルがダウンロードされるので、さらに以下のコマンドでインストールをします。

sudo yum install ./epel-release-latest-*.noarch.rpm

ZBarをインストールする

epelがインストールできたら、ZBarのインストールです。
以下のコマンドを実行してください。

sudo yum --enablerepo=epel install zbar-devel

ZBarのインストールが完了したら、念のためうまくいっているか以下のコマンドを実行してみましょう。

zbarimg --version

うまくインストールできていればバージョンが表示されるはずです。

※ なお、Ubuntuの場合はsudo apt install libzbar-devでインストールできます。

Yahoo商品APIが使えるようにする

ZBarで読み取ったISBN(本のコード)から漫画データを取得するするために、今回はYahoo商品APIを使います。

詳しくは、バーコード・スキャナーでIBSNを読み取って本の入荷処理をする「準備する」を参考にしてください。

Laravel側の作業

ルートをつくる

まずは、ルートをつくります。
以下をroutes/web.phpに追加してください。

Route::post('comic-bot', 'ComicBotController@webhook');

なお、LaravelはPOST送信の際、自動的にCSRF対策を適用させることになりますが、そうなるとLINEからのアクセスを拒否してしまうことになりますので、app/Http/Middleware/VerifyCsrfToken.phpにこのルートは除外する設定をしておきましょう。

protected $except = [
    'comic-bot'
];

モデルとマイグレーションをつくる

続いて、以下のコマンドでモデルとマイグレーションを作成します。

php artisan make:model ReadComic -m

すると、モデルと一緒にdatabase/migrations/****_**_**_******_create_read_comics_table.phpというマイグレーション・ファイルが作成されますので、このファイルを開いて以下のように変更します。

<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateReadComicsTable extends Migration
{
    public function up()
    {
        Schema::create('read_comics', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->string('user_id')->comment('LINEのユーザーID');
            $table->string('isbn')->comment('ISBN');
            $table->string('title')->comment('漫画のタイトル');
            $table->timestamps();

            $table->unique(['user_id', 'isbn']);
        });
    }

    public function down()
    {
        Schema::dropIfExists('read_comics');
    }
}

これで、マイグレーションを実行してDBにテーブルを作成してください。

php artisan migrate

マイグレーションを実行するとDBテーブルはこのようになります。

コントローラーをつくる

最後にコントローラーです。
以下のコマンドを実行してください。

php artisan make:controller ComicBotController

app/Http/Controllers/ComicBotController.phpが作成されるので、中身を以下のようにします。

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use LINE\LINEBot;
use LINE\LINEBot\Event\MessageEvent;
use LINE\LINEBot\Event\MessageEvent\ImageMessage;
use LINE\LINEBot\Event\MessageEvent\TextMessage;
use LINE\LINEBot\HTTPClient\CurlHTTPClient;

class ComicBotController extends Controller
{
    public function webhook(Request $request) {

        $channel_secret = '(あなたのLINE SECRETキー)'; // 要変更
        $access_token = '(あなたのLINE アクセストークン)'; // 要変更
        $request_body = $request->getContent();
        $hash = hash_hmac('sha256', $request_body, $channel_secret, true);
        $signature = base64_encode($hash);

        if($signature === $request->header('X-Line-Signature')) {   // LINEからの送信を検証

            $client = new CurlHTTPClient($access_token);
            $bot = new LINEBot($client, ['channelSecret' => $channel_secret]);

            try {

                $events = $bot->parseEventRequest($request_body, $signature);

                foreach ($events as $event) {

                    if($event instanceof MessageEvent) {

                        $user_id = $event->getUserId();         // LINEのユーザーID
                        $reply_token = $event->getReplyToken(); // 返信用トークン
                        $replying_text = '';

                        if($event instanceof ImageMessage) {   // 新しいコミックの登録

                            $message_id = $event->getMessageId();
                            $response = $bot->getMessageContent($message_id);

                            if($response->isSucceeded()) {

                                $filename = $message_id .'.jpg';
                                $image_path = storage_path('app/comic_bot/'. $filename);
                                \Storage::put('comic_bot/'. $filename, $response->getRawBody());

                                $isbn = $this->getIsbn($image_path);
                                $comic_title = $this->getComicTitle($isbn);

                                if(!empty($isbn) && !empty($comic_title)) {

                                    $this->saveComic($isbn, $comic_title, $user_id);
                                    $replying_text = $this->getCompleteText($comic_title);

                                } else {

                                    $replying_text = "コミック情報が取得できませんでした。".
                                        $this->emoji('100091') .
                                        $this->emoji('100029') .
                                        "\n\nバーコードを大きめに撮影して再度送信してください。";

                                }

                            }

                        } else if($event instanceof  TextMessage) { // 過去データの検索

                            $text = $event->getText();

                            if($this->isIsbn($text)) {

                                $isbn = $text;
                                $comic_title = $this->getComicTitle($isbn);

                                if(!empty($isbn) && !empty($comic_title)) {

                                    $this->saveComic($isbn, $comic_title, $user_id);
                                    $replying_text = $this->getCompleteText($comic_title);

                                } else {

                                    $replying_text = $this->getSearchText($event->getText(), $user_id);

                                }

                            } else if($text === 'バルス!') {  // 登録データを全削除

                                \App\ReadComic::where('user_id', $user_id)->delete();
                                $replying_text = '登録データを全削除しました';
                                $replying_text .= $this->emoji('10003A');

                            } else if(in_array($text, ['一覧', '最後', 'この前'])) {   // 登録データ一覧を返信

                                $replying_text = $this->getListText($user_id);

                            } else {

                                $replying_text = $this->getSearchText($event->getText(), $user_id);

                            }

                        }

                        if(!empty($replying_text)) {

                            $bot->replyText($reply_token, $replying_text);    // 返信
                            break;

                        }

                    }

                }

            } catch (\Exception $e) {}

        }

    }

    private function getIsbn($image_path) { // 画像からISBNを取得する

        $isbn = '';
        exec('/usr/bin/zbarimg '. $image_path, $results);

        if(count($results) > 0) {

            foreach ($results as $result) {

                list(, $code) = explode(':', $result);

                if(Str::startsWith($code, '978')) { // ISBNは978から始まる

                    $isbn = $code;
                    break;

                }

            }

        }

        @unlink($image_path);
        return $isbn;

    }

    private function isIsbn($text) {

        return (Str::startsWith($text, '978') && preg_match('|[0-9]{13}|', $text));

    }

    private function getComicTitle($isbn) { // コミックのタイトルを取得する

        if(empty($isbn)) {

            return '';

        }

        $comic_title = '';
        $category_id = 10251;
        $api_url = 'https://shopping.yahooapis.jp/ShoppingWebService/V1/json/itemSearch'.
            '?appid='. '(あなたのYahoo商品APIのappid)' . // 要変更
            '&category_id='. $category_id.
            '&isbn='. $isbn;
        $json = file_get_contents($api_url);
        $data = json_decode($json, true);
        $total = intval($data['ResultSet']['totalResultsAvailable']);

        if($total > 0) {

            $comic_title = $data['ResultSet'][0]['Result'][0]['Name'];

        }

        return $comic_title;

    }

    private function getSearchText($text, $user_id) {   // 検索結果の返信テキスト

        $search_text = '';
        $keywords = explode(' ', trim(mb_convert_kana($text, 's')));
        $query = \App\ReadComic::where('user_id', $user_id);

        foreach ($keywords as $keyword) {

            $query->where('title', 'LIKE', '%'. $keyword .'%');

        }

        $read_comics = $query
            ->orderBy('updated_at', 'desc')
            ->take(7)
            ->get();

        if($read_comics->count() === 0) {

            return "[ごめんなさい]\n該当するコミックが見つかりませんでした・・・".
                $this->emoji('100094').
                $this->emoji('100029').
                "\n\nキーワードを変えて再度送信してください。".
                "\n\n※「最後」と送ると最後に登録されたコミック情報が返信され、「バルス!」と送るとデータが全削除されます".
                $this->emoji('10003A');

        }

        $search_text .= "過去に・・・\n\n";

        foreach ($read_comics as $read_comic) {

            $search_text .= $this->getComicText($read_comic);

        }

        $search_text .= 'が読まれています。';
        $search_text .= $this->emoji('10006C');
        $search_text .= $this->emoji('10002D');

        return $search_text;

    }

    private function getListText($user_id) {    // データ一覧の返信テキスト

        $search_text = '';
        $read_comics = \App\ReadComic::where('user_id', $user_id)
            ->orderBy('updated_at', 'desc')
            ->take(7)
            ->get();

        if($read_comics->count() === 0) {

            $search_text = "まだデータが登録されていません".
                $this->emoji('100091') .
                $this->emoji('100029') .
                "\n\nコミックのバーコード(ISBN)を写真で送信してください。";

        } else {

            $search_text = "最後に読まれたのは・・・\n\n";

            foreach ($read_comics as $read_comic) {

                $search_text .= $this->getComicText($read_comic);

            }

            $search_text .= 'です';
            $search_text .=  $this->emoji(100039);

        }

        return $search_text;

    }

    private function getComicText($read_comic) {    // コミック情報の返信テキスト

        $dt = $read_comic->updated_at;
        $day_of_week = Arr::get(['日', '月', '火', '水', '木', '金', '土'], $dt->format('w'));
        return '['. $read_comic->updated_at->format('Y/m/d('. $day_of_week .')H時i分') ."]\n".
            $read_comic->title ."\n\n";

    }

    private function getCompleteText($comic_title) {

        $text = '[登録完了]' .
                "\n\n". $comic_title .
                "\n\nの登録が完了しました";
        $text .= $this->emoji('10007F');
        $text .= $this->emoji('10002D');
        return $text;

    }

    private function saveComic($isbn, $comic_title, $user_id) {

        $read_comic = \App\ReadComic::where('user_id', $user_id)
            ->where('isbn', $isbn)
            ->first();

        if(is_null($read_comic)) {

            $read_comic = new \App\ReadComic();
            $read_comic->user_id = $user_id;
            $read_comic->isbn = $isbn;
            $read_comic->title = $comic_title;

        } else {

            $read_comic->updated_at = now();

        }

        $read_comic->save();

    }

    private function emoji($emoji_id) {

        $bin = hex2bin(str_pad($emoji_id, 8, '0', STR_PAD_LEFT));
        return mb_convert_encoding($bin, 'UTF-8', 'UTF-32BE');

    }
}

【追記:2019.09.02】バーコードが漫画に印刷されていない場合を考慮して、直接13桁のISBNコードを送信して漫画登録ができるようにコードを追加しています。

記事としては装飾的な内容は削っておくべきですが、個人的に楽しくなってしまってコードが増えてしまいました😅

重要な部分を紹介していきます。

環境によって変更する部分

コードの中には、環境によって変更する変数が3ヶ所あります。
それは、コード内で「要変更」と書かれている部分です。

詳しく書くと以下になります。

  1. $channel_secret ・・・ LINE Messaging API の秘密鍵
  2. $access_token ・・・ 同じく LINE Messaging APIのアクセストークン
  3. $api_urlに含まれるappid ・・・ Yahoo商品APIのappid

なお、これらの情報は便宜上コード内に含めていますが、管理しやすいので.envなどに記述しておくことをおすすめします。

ZBarからISBNを取得する部分

exec('/usr/bin/zbarimg '. $image_path, $results);

こちらも環境によって違ってくるのですが、zbarimgコマンドをPHPから実行するには、絶対パスを指定する必要があります。

そして、この/usr/bin/zbarimgの部分は利用しているOSによって違ってくるので、以下のコマンドを実行して表示されるパスを指定してください。

which zbarimg

LINEへの返信

おおまかに以下2つのパターンに別れます。

  • 画像が送信されてきた ・・・ バーコードからISBNを取得して漫画情報を保存する
  • テキストが送られてきた ・・・ 登録データの検索、一覧の表示、もしくは前データ削除の命令

なお、emoji()メソッドは、LINEの絵文字をメッセージ内で使うためのものです。引数の$emoji_idは、絵文字のリストページで確認できますが、0xの部分は省いて使ってください。

Storageフォルダについて

もしかすると、storage/app/comic_botというフォルダがないとエラーでうまくいかない可能性もありますので、適宜このフォルダをつくって書き込み権限を付けておいてください。

ちなみに:開発のアドバイス

こういったウェブフック機能の開発は通常の開発と違って実行結果を直接目で見ることはできません。そのため、開発のテクニックとしては以下のようにログに内容を残すなどして確認する必要があります。

\Log::info('ここに何かの情報');

この場合、/storage/logs内に日付ごとにログファイルが作成されますので、

tail (ファイル名)

もしくは、行数を指定する

tail (ファイル名) -n 150

などのコマンドを実行して確認するといいでしょう。

※ もしくは、LINEに必要な内容を返信してしまってもいいかもしれません。

テストしてみる

ではテストしてみましょう!

といっても、私の部屋には漫画がもうないので、ウェブ上から拾ってきた画像で試してみます。(カナダ留学するタイミングでに全部捨ててしまってちょっと後悔・・・😅)

では、まずはバーコードを送って漫画を登録するところ。

続いて登録データを検索するところ。

検索結果が見つからなかったところ。

最後に例の「呪文」を唱えたところ。

はい!
全てうまくいったようです😊✨

おわりに

ということで、今回はLINEに漫画情報を保存してくれるボットを作ってみました。

まだ、実際にネカフェで使ってませんが自分でも使ってみたいと思います。(あんまり便利さが感じられなかったら、こっそりサービスを消すかもですが😅)

また、以前作成したQRコードを作ってくれるLINEボットも公開することにしましたので、興味のある方はぜひこちらも試してみてくださいね。

【QRコードをつくる and 読む】

ではでは〜!

この記事が役立ちましたらシェアお願いします😊✨