(この記事は 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
今回はまさに今イベント中の、レイテ沖海戦に関係のある駆逐艦「時雨改二」をチョイス。
該当キャラのページのソースを見るとテーブル内に入っているが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上でアクセス出来るか確認。
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
"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
実行するとこうなります。
ちょっとましになったかな?
ろーちゃんだとこうなります。
うん、可愛い。
最後に
色々回りくどいことしてやりたいのはただのテキストと画像をターミナルに出したいっていうだけでしたが、今回は1日でこれだけ出来たのは良かったかなと思います。
皆さん機械学習とかライブラリ公開とかすごいことやってるのに自分はしょーもないものですいませんって感じですが、久々に普段使わない技術で遊んだので楽しかったです(小並感
明日はうちのチームで大変お世話になってる先輩の投稿です!