Laravel5.5(HomeStead) x PHP7.2 x Vagrant x ShellScript(jq) で艦これセリフAPI的なのを作ったよ!

(この記事は Livesense - 関 Advent Calendar 2017 掲載のために書かれています)

5日目担当の @chobitsky です。
なにげにQiitaでは初投稿となります、よろしくお願いします。
今年のリブセンスのアドベントカレンダーは3種類のテーマがあり、そのうちの「関」に参加させてもらいました。

関といえば?

大体の人が関数とか関連とか関係性とか選ぶと思うので、僕も「関心」があるものとして大好きな「艦これ」で、「関x艦」ということでテーマに何かさくっと作ってみることにしました。

やりたいこと

毎日黒いターミナル画面とお付き合いしていると疲れてきますね?
僕は疲れてます。
なので可愛い二次元キャラの子が癒やしてくれたらなと考えました。

( ´ー`)。о (そういえば、艦これって1時間毎に「時報」なるボイス(セリフ)があったな・・・)
と。
これ何かに使えそうでは?

よし、シェルでコマンド叩いたらAPIから艦娘の子がセリフで時間を教えてくれたらいいじゃん!
そんなわけでくだらない機能を実装することにした。

下準備

必要なものを洗い出す

  • APIで返すデータ(スクレイピングまたはデータファイル)
  • データを返すサーバーとプログラム
  • 開発のための言語
  • シェルのコマンド

時報データの取得

まずキャラのセリフデータが文字起こしされていることが必要なのですが、そこはご心配なく、日々お世話になっているwikiがあるので、そちらからデータをお借りすることに。
艦隊これくしょん -艦これ- 攻略 Wiki
このwiki内の時報キャラ一覧から時報が実装されている子を探して該当ページへ。
http://wikiwiki.jp/kancolle/?%BB%FE%CA%F3%A4%C8%BC%FE%C7%AF%B5%AD%C7%B0%A5%DC%A5%A4%A5%B9

今回はまさに今イベント中の、レイテ沖海戦に関係のある駆逐艦「時雨改二」をチョイス。
スクリーンショット 2017-12-05 10.12.08.png
該当キャラのページのソースを見るとテーブル内に入っているがclassとかidがあまり割り振られていないためスクレイピングはちょっと骨が折れそう…なので一旦テーブルの時報部分だけコピーしてテキストファイルに保存。

開発言語とフレームワーク選定

当初はGolangとかRailsとか考えていたが、アドベントカレンダー公開まで時間がない(書いてる時点で当日)なので、慣れているPHPにすることにした。
ただし、せっかくなのでPHP7系にし、触ったことがない今一番熱いフレームワークのLaravel5系とした。

先程作成した仮想環境に作ってもいいのだが何しろ時間がないので、何かいいのはないかと探していたらこんな便利なものが。
Laravel Homestead
何かっていうと、virtualboxのboxイメージでこれだけで簡単にLaravelが動く環境が用意されているらしい。

APIサーバーの準備

お金かけるのもあれだしherokuで作るほどでもないなぁってことで手元の開発環境(Mac)にVagrant+VirtualBoxでさくっと構築することに。
このあたりを参考にさせてもらいました。
https://qiita.com/sudachi808/items/3614fd90f9025973de4b

git clone https://github.com/laravel/homestead.git Homestead

インストールが済むとこんな感じになってるので。


200449:~/Vagrant/Homestead$ ls -la
total 248
drwxr-xr-x  25 200449  AD\Domain Users    850 12  4 15:32 .
drwxr-xr-x   7 200449  AD\Domain Users    238 12  4 15:33 ..
drwxr-xr-x  12 200449  AD\Domain Users    408 12  4 15:57 .git
-rw-r--r--   1 200449  AD\Domain Users     14 12  4 15:31 .gitattributes
drwxr-xr-x   3 200449  AD\Domain Users    102 12  4 15:31 .github
-rw-r--r--   1 200449  AD\Domain Users     81 12  4 15:31 .gitignore
-rw-r--r--   1 200449  AD\Domain Users    261 12  4 15:31 .travis.yml
drwxr-xr-x   4 200449  AD\Domain Users    136 12  4 15:42 .vagrant
-rw-r--r--   1 200449  AD\Domain Users    187 12  4 15:31 CHANGELOG.md
-rw-r--r--   1 200449  AD\Domain Users    516 12  4 15:45 Homestead.yaml
-rw-r--r--   1 200449  AD\Domain Users   1077 12  4 15:31 LICENSE.txt
-rw-r--r--   1 200449  AD\Domain Users   1655 12  4 15:31 Vagrantfile
-rw-r--r--   1 200449  AD\Domain Users    177 12  4 15:31 after.sh
-rw-r--r--   1 200449  AD\Domain Users   6639 12  4 15:31 aliases
drwxr-xr-x   3 200449  AD\Domain Users    102 12  4 15:31 bin
-rw-r--r--   1 200449  AD\Domain Users    809 12  4 15:31 composer.json
-rw-r--r--   1 200449  AD\Domain Users  63327 12  4 15:31 composer.lock
-rw-r--r--   1 200449  AD\Domain Users    265 12  4 15:31 init.bat
-rw-r--r--   1 200449  AD\Domain Users    250 12  4 15:31 init.sh
-rw-r--r--   1 200449  AD\Domain Users    383 12  4 15:31 phpunit.xml.dist
-rw-r--r--   1 200449  AD\Domain Users   1404 12  4 15:31 readme.md
drwxr-xr-x   7 200449  AD\Domain Users    238 12  4 15:31 resources
drwxr-xr-x  28 200449  AD\Domain Users    952 12  4 15:31 scripts
drwxr-xr-x   5 200449  AD\Domain Users    170 12  4 15:31 src
drwxr-xr-x   6 200449  AD\Domain Users    204 12  4 15:31 tests

bash init.shを叩けばOK。
すると Homestead.ymlファイルが作成されるのでこちらを適宜書き換えます。
僕の場合はこのような設定にしました。


ip: "192.168.10.10"
memory: 2048
cpus: 1
provider: virtualbox

authorize: ~/.ssh/id_rsa.pub

keys:
    - ~/.ssh/id_rsa

folders:
    - map: /Users/200449/dev/kancolle/
      to: /home/vagrant/kancolle

sites:
    - map: homestead.app
      to: /home/vagrant/kancolle/Laravel/public

databases:
    - homestead

ポイントはfoldersとsitesのディレクトリ設定の部分でしょうか。
詳細は公式サイトをみてください。

作成し終わったら vagrant sshでサーバーへ接続。

Laravel5.5+PHP7.2でRestAPIを作ってみる

たいしたことはしてなくて、Laravel公式ドキュメントどおりコマンド叩くだけでほぼ終わる。

まずはプロジェクトの作成
先程上で指定したディレクトリ(/home/vagrant/kancolle)へ移動し、以下のコマンドを叩く。

composer create-project laravel/laravel --prefer-dist Laravel

きちんとvagrant上でアクセス出来るか確認。

http://homestead.app/

スクリーンショット 2017-12-05 10.56.50.png
OKですね。
だめな場合は頑張ってください。

続いてLaravel専用のコマンドartisanを利用してコントローラーやAPIについて作成していく。

php artisan make:controller VoiceController --resource

するとリソースAPI用のコントローラーとか自動で作ってくれるヤバイ。

次にルーティングの設定をします。


200449:~/dev/kancolle/Laravel$ ls -la routes/
total 32
drwxr-xr-x   6 200449  AD\Domain Users  204 11 21 22:37 .
drwxr-xr-x  24 200449  AD\Domain Users  816 12  4 16:34 ..
-rw-rw-r--   1 200449  AD\Domain Users  595 12  4 20:25 api.php
-rw-rw-r--   1 200449  AD\Domain Users  508 11 21 22:37 channels.php
-rw-rw-r--   1 200449  AD\Domain Users  553 11 21 22:37 console.php
-rw-rw-r--   1 200449  AD\Domain Users  453 11 21 22:37 web.php

公式にも書いてあるけど、今回はapi.phpに記載すればよさそう。

routes/api.php

<?php
use Illuminate\Http\Request;

Route::get('voices/{name}', 'VoiceController@index');
Route::get('hoge/', 'VoiceController@hoge');

今回の機能要件としては、単純にキャラ名を渡したらその子が該当の時報を返すっていう処理にしたいのでこれだけです。
追加で作りたい場合はhoge/のようにすれば対応できます。

※よくあるRestfulAPIのcreate, update, puts, deleteがある場合はRoute::resourcesとするとそれも自動で適用されるので便利です。

コントローラーの中身を作っていく、、、が面倒なので貼り付けておきます。

<?php
namespace App\Http\Controllers;

use Illuminate\Http\Request;

class VoiceController extends Controller
{
  // ファイル名とマッチング用の配列
    private static $charaNameFileMap = [
        'shigure'   => '時雨改二',
        'ro500'     => '呂500',
        'akatsuki'  => '暁'
    ];


    public function index($charaName = "")
    {
        try {
            if (empty($charaName)) {
                abort(400, 'パラメーターが不正です');
            }

            // 本当はwikiからtable取得してデータ更新したい
            if (!isset(self::$charaNameFileMap[$charaName])) {
                abort(400, 'パラメーターが不正です');
            }

            $path = public_path() . '/datas/' . $charaName . '.txt';

            $file = new \SplFileObject($path, 'r');

            if (!$file) {
                abort(400, 'セリフファイル取得に失敗');
            }

            $nowHour = date('H');
            $error = "";
            $voice = "";
            $data = [
                'voice'    => [],
                'image'    => [],
            ];

            foreach ($file as $num => $line) {
                if ($line === false) {
                    continue;
                }

                // wikiから取得したデータは必ず00〜23の順番で入ってるものとする
                if (str_pad($num, 2, '0', STR_PAD_LEFT) === $nowHour) {
                    $voice = $line;
                }
            }

            if (empty($voice)) {
                throw new Exception("該当の時間の発言が取得出来ませんでした");
            }

            $data['voice'] = $voice;

        } catch (Exception $e) {
            $error = $e->getMessage();
        }
        return response(["results" => $data, "error" => $error]);
    }

}

こんな感じで、基本はreturn response(返したい値);とすればjson形式でレスポンスを返してくれる。

date('H')で戻ってくる値がUTCでハマった話。
php.iniの設定がまずdate.timezoneの値がAsia/Tokyoではないのでそちらを修正。

しかし、反映されないのでApache再起動させようとしたら…LaravelHomesteadはNginxだった!!
のでsudo /etc/init.d/nginx reloadで再起動させた。
が、反映されない。

結果的にLaravelのconfig内で設定されていた・・そりゃわからんよ。
config/app.yml
こちらの中で設定されているtimezoneを修正して解決。

ここで一番最初に作っておいたセリフデータファイルをpublic/datas/配下に配置しておくことにする。
※migrateとかでDB使うのもどうかと思ったのでとりあえず簡単にファイルにしているのであしからず。

APIのレスポンスを確認

では先程作ったデータを確認してみましょう。

vagrant@homestead:~/kancolle/Laravel$ curl http://homestead.app/api/voices/ro500

{"results":{"voice":"\u30d2\u30c8\u30d2\u30c8\u30de\u30eb\u30de\u30eb\u3001\u63d0\u7763\u3002\u4f0a\u53f7\u306e\u307f\u3093\u306a\u3068\u306e\u6f5c\u6c34\u3001\u697d\u3057\u304b\u3063\u305f\u3063\u3066\u3002\u306f\u3044\uff01\n",},"error":""}

うん…jsonだからそうなるよね。
ただ一応データはきちんと戻ってくることがわかった。

なお、ブラウザからアクセスするとこうなります。


{
results: {
voice: "ヒトヒトマルマル、提督。伊号のみんなとの潜水、楽しかったって。はい! ",
},
error: ""
}

よいでのは!

というわけでUnix上でjsonをなんとかするためのツールを使おう。

jsonパースするためにjqを使う

インストールとかはQiita探すといっぱい出てくるので割愛。

(https://qiita.com/nmrmsys/items/5b4a4bd2e3909db161b1)
(https://qiita.com/takeshinoda@github/items/2dec7a72930ec1f658af)
(https://qiita.com/pontago/items/36bfb1fff4c22fac57f7)

さて、戻ってきたjsonをそのままパイプにして渡してあげればいいので


vagrant@homestead:~/kancolle/Laravel$ curl -s http://homestead.app/api/voices/ro500 | jq "."
{
  "results": {
    "voice": "ヒトヒトマルマル、提督。伊号のみんなとの潜水、楽しかったって。はい!\n",
  },
  "error": ""
}

SUGEEEEE!!! 今まで知らなかった情弱ぷりよ。

では取ってきたAPIを叩くためのシェルスクリプトを組みましょう。

#!/bin/sh

BASE_URL="http://homestead.app/api/voices/"
LOCAL_IMG_PATH="/tmp/chara.jpg"

NAME=$1
URL=$BASE_URL$NAME
RESPONSE=$(curl -s $URL | jq -r '.results.voice')
echo "\033[0;34m${RESPONSE}\033[0;39m"

これをkcv.shとかkcvとかで保存して実行権限をつけて保存

vagrant@homestead:~/kancolle$ ./kcv shigure
ヒトヒトマルマル。僕も少しお腹が空いたなぁ。

おなか空いたよ時雨。

というわけで出来た!
うーんなんかしょぼい(´・ω・`)

これだけならシェルでhomeディレクトリにセリフ.txtとか用意して読ませればええやんけ!ってなるのはわかってるんですが、外部APIからjsonデータを受け取って対応する形式で処理するってのが汎用性高いかなと。

Advanced@Goutteを使ってスクレイピング!

拡張機能として、PHP側でデータをスクレイピングしようと思って出来たものと(時間的に)出来なかったものを載せておきます。

PHPでwebをスクレイピングしようとするといくつかライブラリが出てきますが主にはこの2つかと。
- phpQuery
- Goutte

で、よく使ってたphpQueryちゃんはcomposer対応してないし更新が古いのでせっかくPHP7にしたのにもったいないなぁってことで、git管理されてcomposer対応しているGoutteさんを使うことに。

https://github.com/FriendsOfPHP/Goutte

Laravelで使うためにはcomposerに設定しないといけないので

composer.json
json
"require": {
"php": ">=7.0.0",
"fideloper/proxy": "~3.3",
"laravel/framework": "5.5.*",
"laravel/tinker": "~1.0",
"fabpot/goutte": "2.*"
},

こんな感じで設定してあげて、php composer updateでdone.

app/Http/Controllers/VoiceController.php


<?php
namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Goutte\Client;

class VoiceController extends Controller
{

    private static $charaNameFileMap = [
        'shigure'   => '時雨改二',
        'ro500'     => '呂500',
        'akatsuki'  => '暁',
    ];


    public function index($charaName = "")
    {
        try {
            if (empty($charaName)) {
                abort(400, 'パラメーターが不正です');
            }

            // 本当はwikiからtable取得してデータ更新したい
            if (!isset(self::$charaNameFileMap[$charaName])) {
                abort(400, 'パラメーターが不正です');
            }

            $path = public_path() . '/datas/' . $charaName . '.txt';

            $file = new \SplFileObject($path, 'r');

            if (!$file) {
                abort(400, 'セリフファイル取得に失敗');
            }

            $nowHour = date('H');
            $error = "";
            $voice = "";
            $data = [
                'voice'    => [],
                'image'    => [],
            ];

            foreach ($file as $num => $line) {
                if ($line === false) {
                    continue;
                }

                // wikiから取得したデータは必ず00〜23の順番で入ってるものとする
                if (str_pad($num, 2, '0', STR_PAD_LEFT) === $nowHour) {
                    $voice = $line;
                }
            }

            if (empty($voice)) {
                throw new Exception("該当の時間の発言が取得出来ませんでした");
            }

            $data['voice'] = $voice;

            // 画像を取得
            $data['image'] = $this->getWikiImage($charaName);

        } catch (Exception $e) {
            $error = $e->getMessage();
        }
        return response(["results" => $data, "error" => $error]);
    }


    private function getWikiImage($charaName)
    {
        $client = new Client();

        $jpCharaName = self::$charaNameFileMap[$charaName];
        $eucJpConvertedString = mb_convert_encoding($jpCharaName , 'EUC-JP', 'UTF-8');
        $params = urlencode($eucJpConvertedString);

        $crawler = $client->request('GET', "http://wikiwiki.jp/kancolle/?" . $params);

        // ページ内の画像を取得
        // ToDo: いずれ限定グラなど含めた複数画像対応するかも?
        $image = "";
        $crawler->filter('.style_td>img')->each(function ($node) use (&$image) {
            $src = $node->attr('src');
            if (empty($src) || is_null($src)) {
                return;
            }
            $image = $src;
        });
        return $image;
    }



    private function getVoiceText($charaName)
    {
        $client = new Client();

        // Get Data Source
        $crawler = $client->request('GET', "http://wikiwiki.jp/kancolle/?" . $charaName);

        // うまく動かなかったのでどこかで再チャンレジしたい
        $voices = [];
        $crawler->filter('body>table.style_table>')->each(function ($node) use (&$voices) {
            $th = $node->filter(‘td’)->eq(0)->text();
            $td = $node->filter(‘td’)->eq(1)->text();
            if ($th == "時報") {
                $voices[] = $td->text();
            }
        });
        $data = $voices;

        return response($data);
    }

まずはuseでGoutteClientの利用宣言。
プライベートメソッドを今回は面倒なのでcontroller内に記載しちゃってますが許してください。
GoutteClientの使い方は長くなるので割愛します。


    private function getWikiImage($charaName)
    {
        $client = new Client();

        $jpCharaName = self::$charaNameFileMap[$charaName];
        $eucJpConvertedString = mb_convert_encoding($jpCharaName , 'EUC-JP', 'UTF-8');
        $params = urlencode($eucJpConvertedString);

        $crawler = $client->request('GET', "http://wikiwiki.jp/kancolle/?" . $params);

        // ページ内の画像を取得
        // ToDo: いずれ限定グラなど含めた複数画像対応するかも?
        $image = "";
        $crawler->filter('.style_td>img')->each(function ($node) use (&$image) {
            $src = $node->attr('src');
            if (empty($src) || is_null($src)) {
                return;
            }
            $image = $src;
        });
        return $image;
    }

これで艦これwikiからキャラページにあるキャラ画像のurlを取得出来ます。


vagrant@homestead:~/kancolle$ curl -s http://homestead.app/api/voices/ro500 | jq "."
{
  "results": {
    "voice": "ヒトフタマルマル。昼食なんにする、提督? 困ったときはマミーヤ行く? はい!\n",
    "image": "http://wikiwiki.jp/kancolle/?plugin=ref&page=%CF%A4500&src=52eca181e251b70bfc06242297b7ed4323a7b84e1423221461.jpg"
  },
  "error": ""
}

マミーヤ行きたいよろーちゃん。

mb_convert_encordingしてるのは何故か艦これwikiはクエリーパラメーターがEUC-JPで管理されてるらしく、urlエンコーディングの文字列を確認するとEUCとかいう懐かしさある感じだったので変換してます。

これでレスポンスに画像を含めることが出来ました!

imgcatで画像を出すiterm2専用

ターミナル内で画像を出すことが出来る最強のツール(ライブラリ?)
ただしiterm2利用者限定なのでそれ以外の人や外部サーバーで作業してる人は時報しか取得出来ないのでごめんなさい。

imgcatについてはこちら
https://github.com/eddieantonio/imgcat

先程作成したシェルスクリプトを一部修正。

#!/bin/sh

BASE_URL="http://homestead.app/api/voices/"
LOCAL_TMP_IMG_PATH=/tmp/chara.jpg
LOCAL_TMP_JSON_PATH=/tmp/voice.json

NAME=$1
URL=$BASE_URL$NAME
#RESPONSE=$(curl -s $URL | jq -r '.results.voice')
#echo "\033[0;34m${RESPONSE}\033[0;39m"

curl -s $URL > $LOCAL_TMP_JSON_PATH
VOICE=$(cat ${LOCAL_TMP_JSON_PATH} | jq -r '.results.voice')
IMG=$(cat ${LOCAL_TMP_JSON_PATH} | jq -r '.results.image')
echo $VOICE

#macのときは画像ダウンロードしてimgcatする
if [ "$(uname)" == 'Darwin' ]; then
    curl -so $LOCAL_TMP_IMG_PATH $IMG
    imgcat $LOCAL_TMP_IMG_PATH
fi

実行するとこうなります。

shigure.png

ちょっとましになったかな?

ろーちゃんだとこうなります。

ro500.jpeg

うん、可愛い。

最後に

色々回りくどいことしてやりたいのはただのテキストと画像をターミナルに出したいっていうだけでしたが、今回は1日でこれだけ出来たのは良かったかなと思います。

皆さん機械学習とかライブラリ公開とかすごいことやってるのに自分はしょーもないものですいませんって感じですが、久々に普段使わない技術で遊んだので楽しかったです(小並感

明日はうちのチームで大変お世話になってる先輩の投稿です!