[LINE Bot] 位置情報から食べログ3.5以上の優良店を検索するbot作った

# 作ったもの

位置情報から近くの食べログ3.5以上の店舗を検索してくれるLINE Bot作りました。

【重要】2018.09.16
LINE Botが凍結され利用できなくなりました。
新たに２代目を作りました。こちらはDMをいただいた方にも教えています。

▼位置情報から検索（1km圏内で最大10件を近い順に表示）
<img width="375" alt="IMG_2744.png" src="https://qiita-image-store.s3.amazonaws.com/0/246680/0cc13cd7-3a81-2113-38b5-c65ddc093869.png">


▼住所や地域名にも対応（Google Map APIを利用）
<img width="375" alt="IMG_2887.png" src="https://qiita-image-store.s3.amazonaws.com/0/246680/2478f56b-7ac4-09b2-2889-4afb5781764b.png">



▼お気に入り登録を実装しました (2018.07.29)
記事も書きました：[[LINE bot] 食べログ3.5以上を検索できるbotに「お気に入り登録」を実装した](https://qiita.com/NARI_Creator/items/5bc7abb5fcc78d3e65da)
<img width="375" alt="IMG_3006.png" src="https://qiita-image-store.s3.amazonaws.com/0/246680/6bd5e51d-aa5f-df1b-68ad-495ddc5e67f1.png">

<img width="375" alt="IMG_3007.png" src="https://qiita-image-store.s3.amazonaws.com/0/246680/aaf3f9ea-1391-00c7-7f89-3b4fb312f563.png">

# この記事で説明すること（ソース公開します)
- PHPでLINE botを動かす方法 (line-bot-php-sdkを利用した実装）
- Flex Messageの実装
- Action時のデータを保持して次のReplyで利用する方法

細かいところはあまり説明しません。参考記事を載せるのでそちらを参照ください。
あと、今回利用している食べログのスクレイピングは自分で実装してないので割愛します。

# 準備
## ①herokuのアカウント作成、新規アプリケーションを作る
▼こちらを参考に
[【HEROKUとは】これを読めばOK!デプロイの仕方まで徹底解説](https://tech-camp.in/note/technology/16108/)

## ②LINE Messaging APIの登録
▼こちらを参考に
[わずか5分。新LINE Messaging APIでbotを作ってみた手順全公開](https://ledge.ai/line-messaging-api-exp/)
今回はpush通知は送りませんので、「**フリー**」を選びましょう。
（フリーだと友だち登録数の上限がないため）

## ③line-bot-php-sdkをインストールする
▼こちらを参考に
[PHP版の公式SDKを使ってLINE Messaging APIベースのBotを作ってみた](https://qiita.com/yositosi/items/87a0ee54ab88b934b1cb)
＊実装も結構、上記を参考にしました。

〜 事前準備はここまで 〜

# まずは固定メッセージを返すまでやってみよう
``` ajac.php
<?php
define("LINE_MESSAGING_API_CHANNEL_SECRET", '{your secret}');
define("LINE_MESSAGING_API_CHANNEL_TOKEN", '{your token}');


use LINE\LINEBot;
use LINE\LINEBot\HTTPClient\CurlHTTPClient;
use LINE\LINEBot\Constant\HTTPHeader;
use LINE\LINEBot\Event\MessageEvent;
use LINE\LINEBot\Event\MessageEvent\TextMessage;

require('../vendor/autoload.php');

$bot = new LINEBot(new CurlHTTPClient(LINE_MESSAGING_API_CHANNEL_TOKEN), [
            'channelSecret' => LINE_MESSAGING_API_CHANNEL_SECRET,
        ]);

$signature = $_SERVER["HTTP_".\LINE\LINEBot\Constant\HTTPHeader::LINE_SIGNATURE];
$body = file_get_contents("php://input");

try {
    // Bodyと$signatureから内容をVerifyして成功すればEventを得られる
    $events = $bot->parseEventRequest($body, $signature);

    foreach ($events as $event) {
       if ($event instanceof TextMessage) {
          $bot->replyText($event->getReplyToken(), 'メッセージが来たよ！');
          continue;
       }
    }
} catch (Exception $e) {
  // none
}
```
次から本題なので駆け足で行きましたが、ここまでいけない、もう無理！って場合は[DM](https://twitter.com/NARI_Creator)ください。
（何までやって、何ができないか、どういう事象にハマっているかを教えてもらえるとスムーズにいきます。＋ソースくれればなおいい）

# 本題
## １：ジャンルを保持する〜次のアクションへ誘導

```ajax.php
try {
    // Bodyと$signatureから内容をVerifyして成功すればEventを得られる
    $events = $bot->parseEventRequest($body, $signature);

    foreach ($events as $event) {
       if ($event instanceof FollowEvent) {
          continue;
       } else if ($event instanceof UnfollowEvent) {
          continue;
       } else if ($event instanceof PostbackEvent) {
          continue;
       } else if ($event instanceof TextMessage) {
          processTextMessageEvent($bot, $event);
          continue;
       } else if ($event instanceof LocationMessage) {
          // TODO あとで実装
          continue;
       } else {

       }

    }
} catch (Exception $e) {
  // none
}

function processTextMessageEvent($bot, $event) {
  $text = $event->getText();
  if (isCategoryText($text)) {
    putCategory($event->getUserId(), $text);
    replayLocationActionMessage($bot, $event->getReplyToken());
  } else {
    searchFromLocationWord($bot, $event);
    $res = $bot->replyText($event->getReplyToken(),'ジャンル(1〜4)を入力してください。(和=1,洋=2,中=3,その他=4)');
  }
}
```
processTextMessageEventというメソッドで処理します。
isCategoryTextはジャンル(1~4)かどうかを判定します。
詳細は下記へ

```ajax.php
function isCategoryText($text) {
  return ($text === '1' || $text === '2' || $text === '3' || $text === '4'); // FIXME magic number
}

function putCategory($user_id, $category) {
  $data = ['type'=>'set','user_id' => $user_id,'cat'=>intval($category)];
  $conn = curl_init();

  curl_setopt($conn, CURLOPT_SSL_VERIFYPEER, false);
  curl_setopt($conn, CURLOPT_SSL_VERIFYHOST, false);
  curl_setopt($conn, CURLOPT_RETURNTRANSFER, true);
  curl_setopt($conn, CURLOPT_POST, true);
  curl_setopt($conn, CURLOPT_URL,  '{秘密のAPI URL}');
  curl_setopt($conn, CURLOPT_POSTFIELDS, http_build_query($data));

  $result = curl_exec($conn);

  curl_close($conn);

  return $result;
}

function replayLocationActionMessage($bot, $token) {
  $action = new UriTemplateActionBuilder("位置情報を送る", 'line://nv/location');
  $buttonObj = new ButtonTemplateBuilder(NULL, '続いて位置情報を送るか、住所／地域名を入力してください。', NULL, [$action]);
  $bot->replyMessage($token,new TemplateMessageBuilder('続いて位置情報を送ってください。',$buttonObj));
}

function searchFromLocationWord($bot, $event) {
  $location = searchGoogleGeocodingAPI($event->getText());
  if ($location) {
    $lat = $location['lat'];
    $lng = $location['lng'];
    replyTaberguList($bot, $event, $lat, $lng);
  }
}

function searchGoogleGeocodingAPI($address) {
  $address = urlencode($address);

  $url = "https://maps.googleapis.com/maps/api/geocode/json?address=".$address."+CA&key=".GOOGLE_MAP_API_KEY;

  $contents= file_get_contents($url);
  $jsonData = json_decode($contents,true);

  return $jsonData["results"][0]["geometry"]["location"];
}
```
## putCategory
入力したテキスト(1~4)を保持する必要があるので、DBに保存します。
(DBの用意が面倒だったのでcategory出し入れ用のAPIを提供してもらいました)

## isCategoryText
見たまんま単純な値の比較なので割愛
(Magic Numberは可読性を悪くするので定数化が望ましい)

## replayLocationActionMessage
ButtonTemplateBuilderを作って、replyします。
アクション(位置情報を送る)にはLINEの位置情報機能を立ち上げるURI "**line://nv/location**"を指定します。

▼ちゃんと動けばこんな感じにメッセージが返ってくるようになります。
![IMG_2896.jpg](https://qiita-image-store.s3.amazonaws.com/0/246680/70fcfaa8-5f41-5c54-3bf3-b380e8d07477.jpeg)

「渋谷」とか「東京都港区」など地域や住所でも検索できるようにしたかったので **searchFromLocationWord** を実装。

## searchGoogleGeocodingAPI
Google Maps APIのGeocoding APIで住所/地域から緯度経度を取得してます。

Google Maps APIの利用方法はこちらを参照▼
[Google Maps Geocoding APIの使い方[完全版]](http://sharetube.jp/article/9207/)

## searchFromLocationWord
$locationが取得できれば食べログ検索を実行し、LINEに送ります。（後述で説明）

# ２：位置情報から食べログを検索〜Flex messageに変換~LINEに送る
```ajax.php
try {
    // Bodyと$signatureから内容をVerifyして成功すればEventを得られる
    $events = $bot->parseEventRequest($body, $signature);

    foreach ($events as $event) {
       if ($event instanceof FollowEvent) {
          continue;
       } else if ($event instanceof UnfollowEvent) {
          continue;
       } else if ($event instanceof PostbackEvent) {
          continue;
       } else if ($event instanceof TextMessage) {
          processTextMessageEvent($bot, $event);
          continue;
       } else if ($event instanceof LocationMessage) {
          replyTaberguList($bot, $event, $event->getLatitude(), $event->getLongitude()); //＊追加＊
          continue;
       } else {

       }

    }
} catch (Exception $e) {
  // none
}

function replyTaberguList($bot, $eventData, $lat, $lng) {
   $category = getCategory($eventData->getUserId());
   $taberoguList = getTaberoguData($category,$lat,$lng);
   if (count($taberoguList) === 0) {
     $bot->replyText($eventData->getReplyToken(),'お店が見つかりませんでした。');
   } else {
     $lineService = new LineMessageService(LINE_MESSAGING_API_CHANNEL_TOKEN);
     $res = $lineService->postFlexMessage($eventData->getReplyToken(), $taberoguList);
     $bot->replyText($event->getReplyToken(),$res);
   }
}

function getTaberoguData($cat,$lat,$lng) {
  $params = ['lat'=>$lat,'lng'=>$lng,'cat'=>$cat];
  $conn = curl_init();

  curl_setopt($conn, CURLOPT_SSL_VERIFYPEER, false);
  curl_setopt($conn, CURLOPT_SSL_VERIFYHOST, false);
  curl_setopt($conn, CURLOPT_RETURNTRANSFER, true);
  curl_setopt($conn, CURLOPT_POST, true);
  curl_setopt($conn, CURLOPT_URL,  '{秘密のAPI URL}');
  curl_setopt($conn, CURLOPT_POSTFIELDS, http_build_query($params));

  $result = curl_exec($conn);

  curl_close($conn);

  return json_decode($result);
}

function getCategory($user_id) {
  $conn = curl_init();
  $data = ['type'=>'get','user_id' => $user_id];
  curl_setopt($conn, CURLOPT_SSL_VERIFYPEER, false);
  curl_setopt($conn, CURLOPT_SSL_VERIFYHOST, false);
  curl_setopt($conn, CURLOPT_RETURNTRANSFER, true);
  curl_setopt($conn, CURLOPT_POST, true);
  curl_setopt($conn, CURLOPT_URL,  '{秘密のAPI URL}');
  curl_setopt($conn, CURLOPT_POSTFIELDS, http_build_query($data));

  $result = curl_exec($conn);

  curl_close($conn);

  $status = json_decode($result)->{'status'};
  if ($status === 'success') {
    return json_decode($result)->{'user'}->{'cat'};
  } else {
    return 1;
  }
}
```
ジャンル(1~4)を取って来て、食べログ検索(API)、flex messageに変換、LINEにreplyをしています。
flex messageへの変換、LINEへ送る部分は**LineMessageService**クラスに切り出しました。
＊sdkではFlex Messageがまだ対応されていないので、**LINEbot**クラスで使えないです。

## replyTaberguList
userId (利用者のLINE ID)から**getCategory**でジャンル(1~4)を取得　→ **getTaberoguData**に送り、食べログの検索結果を取得します。

## getCategory
**putCategory**した時のDBにcurlで取得

## getTaberoguData
**LocationMessage**に送信者の緯度経度があるので、lat(緯度), lng(経度)を利用する

## $lineService->postFlexMessage
食べログのスクレイピングデータからLINEに送る部分は下記別ソースにしました。

```line-message-service.php
<?php

class LineMessageService {

  private $accessToken;

  public function __construct($accessToken) {
     $this->accessToken = $accessToken;
  }

  public function postFlexMessage($token, $param) {
        $postJson = $this->createJsonParameter($token, $param);
        return $this->postMessage($postJson);
  }

  private function createJsonParameter($token, $list) {
    $messages = $this->generateFlexMessageContents($list);
    $result = ['replyToken'=>$token, 'messages'=>[$messages]];
    return json_encode($result);
  }

  private function postMessage($jsonParam) {
    $conn = curl_init();

    curl_setopt($conn, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($conn, CURLOPT_POST, true);
    curl_setopt($conn, CURLOPT_HTTPHEADER, array('Authorization: Bearer '.$this->accessToken,'Content-type: application/json'));
    curl_setopt($conn, CURLOPT_URL,  'https://api.line.me/v2/bot/message/reply');
    curl_setopt($conn, CURLOPT_POSTFIELDS, $jsonParam);

    $result = curl_exec($conn);

    curl_close($conn);

    return $result;
  }

  private function generateFlexMessageContents($list) {
      $carouselItem = [];
      foreach ($list as $taberogu) {
           $carouselItem[] = $this->getFlexTemplate($taberogu);
      }
      $contents = ["type"=>"carousel","contents"=>$carouselItem];
      return ['type'=>'flex', 'altText'=>'search', 'contents'=>$contents];
  }

  private function getStarImg($rating, $seq) {
      if ($rating >= $seq) {
        return "https://scdn.line-apps.com/n/channel_devcenter/img/fx/review_gold_star_28.png";
      }
      return "https://scdn.line-apps.com/n/channel_devcenter/img/fx/review_gray_star_28.png";
  }

  private function getFlexTemplate($taberogu) {
   $ratingInt = round($taberogu->{'rating'});
   $distance = round($taberogu->{'distance'}*1000);
   return  [
     "type"=> "bubble",
     "hero"=> [
       "type"=> "image",
       "url"=> $taberogu->{'image_url'},
       "size"=> "full",
       "aspectRatio"=> "20:13",
       "aspectMode"=> "cover",
       "action"=> [
         "type"=> "uri",
         "uri"=> $taberogu->{'url'}
       ]
     ],
     "body"=> [
       "type"=> "box",
       "layout"=> "vertical",
       "contents"=> [
         [
           "type"=> "text",
           "text"=> $taberogu->{'name'},
           "weight"=> "bold",
           "size"=> "xl"
         ],
         [
           "type"=> "box",
           "layout"=> "baseline",
           "margin"=> "md",
           "contents"=> [
             [
               "type"=> "icon",
               "size"=> "sm",
               "url"=> $this->getStarImg($ratingInt, 1)
             ],
             [
               "type"=> "icon",
               "size"=> "sm",
               "url"=> $this->getStarImg($ratingInt, 2)
             ],
             [
               "type"=> "icon",
               "size"=> "sm",
               "url"=> $this->getStarImg($ratingInt, 3)
             ],
             [
               "type"=> "icon",
               "size"=> "sm",
               "url"=> $this->getStarImg($ratingInt, 4)
             ],
             [
               "type"=> "icon",
               "size"=> "sm",
               "url"=> $this->getStarImg($ratingInt, 5)
             ],
             [
               "type"=> "text",
               "text"=> $taberogu->{'rating'},
               "size"=> "sm",
               "color"=> "#999999",
               "margin"=> "md",
               "flex"=> 0
             ]
           ]
         ],
         [
           "type"=> "box",
           "layout"=> "vertical",
           "margin"=> "lg",
           "spacing"=> "sm",
           "contents"=> [
             [
               "type"=> "box",
               "layout"=> "baseline",
               "spacing"=> "sm",
               "contents"=> [
                 [
                   "type"=> "text",
                   "text"=> "種類",
                   "color"=> "#aaaaaa",
                   "size"=> "sm",
                   "flex"=> 1
                 ],
                 [
                   "type"=> "text",
                   "text"=> $taberogu->{'service'},
                   "wrap"=> true,
                   "color"=> "#666666",
                   "size"=> "sm",
                   "flex"=> 5
                 ]
               ]
             ],
             [
               "type"=> "box",
               "layout"=> "baseline",
               "spacing"=> "sm",
               "contents"=> [
                 [
                   "type"=> "text",
                   "text"=> "場所",
                   "color"=> "#aaaaaa",
                   "size"=> "sm",
                   "flex"=> 1
                 ],
                 [
                   "type"=> "text",
                   "text"=> $taberogu->{'street'}.' ('.$distance.'m)',
                   "wrap"=> true,
                   "color"=> "#666666",
                   "size"=> "sm",
                   "flex"=> 5
                 ]
               ]
             ]
             // [ 仕様変更により取得できなくなったので閉じる
             //   "type"=> "box",
             //   "layout"=> "baseline",
             //   "spacing"=> "sm",
             //   "contents"=> [
             //     [
             //       "type"=> "text",
             //       "text"=> "金額",
             //       "color"=> "#aaaaaa",
             //       "size"=> "sm",
             //       "flex"=> 1
             //     ],
             //     [
             //       "type"=> "text",
             //       "text"=> $taberogu->{'price'},
             //       "wrap"=> true,
             //       "color"=> "#666666",
             //       "size"=> "sm",
             //       "flex"=> 5
             //     ]
             //   ]
             // ]
           ]
         ]
       ]
     ],
     "footer"=> [
       "type"=> "box",
       "layout"=> "vertical",
       "spacing"=> "sm",
       "contents"=> [
         [
           "type"=> "button",
           "style"=> "link",
           "height"=> "sm",
           "action"=> [
             "type"=> "uri",
             "label"=> "食べログをみる",
             "uri"=> $taberogu->{'url'}
           ]
         ],
         [
           "type"=> "spacer",
           "size"=> "sm"
         ]
       ],
       "flex"=> 0
     ]
   ];
  }

}

```

## getFlexTemplate
[Flex Message Simulator](https://developers.line.me/console/fx/)のjsonをphpの連想配列へ置き換えたもの。
「:」→「=>」、「{}」→「[]」に置換すればOK。
動的な部分は変数から取得。星評価のイメージはON/OFFあるので**getStarImg**にて判定。

## postMessage
変換したjson(**createJsonParameter**)をcurlでpostしてFlex Messageを送ります。
＊パラメーターについては[APIリファレンス#flex-message](https://developers.line.me/ja/reference/messaging-api/#flex-message)を参照

＜補足＞
途中で追加しているsdkのクラスを利用するにはインポート(**use**)が必要ですので適宜追加してください。
line-message-service.phpを利用するため**require**するのを忘れずに。

〜 説明以上 〜

# 終わりに
食べログの取得のところを別のデータに置き換えればいろいろと使えます。
それではあなたもオリジナルLINE Botを作ってみましょう！！


## 【宣伝】
LINE Bot開発者でLINEグループ作って情報交換をしています。
興味ある方は[こちら](https://twitter.com/gottote_ru/status/1019173037776646144)を参照して管理者にメッセージください。

# 追記
○ 対応エリアは東京、神奈川、埼玉、千葉、大阪、京都、福岡になりました。

○ [はてなブックマーク](http://b.hatena.ne.jp/entry/s/qiita.com/NARI_Creator/items/f29112e6f604c86b3c0d) -50 Over!!

○ [Hatebu::Classic](http://hatebu.net/entry/https://qiita.com/NARI_Creator/items/f29112e6f604c86b3c0d)に載りました

○ 続編公開しました！
[[LINE bot] 食べログ3.5以上を検索できるbotに「お気に入り登録」を実装した](https://qiita.com/NARI_Creator/items/5bc7abb5fcc78d3e65da)

以上初Qiitaでした！