Help us understand the problem. What is going on with this article?

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

作ったもの

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

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

▼位置情報から検索(1km圏内で最大10件を近い順に表示)
IMG_2744.png

▼住所や地域名にも対応(Google Map APIを利用)
IMG_2887.png

▼お気に入り登録を実装しました (2018.07.29)
記事も書きました:[LINE bot] 食べログ3.5以上を検索できるbotに「お気に入り登録」を実装した
IMG_3006.png

IMG_3007.png

この記事で説明すること(ソース公開します)

  • PHPでLINE botを動かす方法 (line-bot-php-sdkを利用した実装)
  • Flex Messageの実装
  • Action時のデータを保持して次のReplyで利用する方法

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

準備

①herokuのアカウント作成、新規アプリケーションを作る

▼こちらを参考に
【HEROKUとは】これを読めばOK!デプロイの仕方まで徹底解説

②LINE Messaging APIの登録

▼こちらを参考に
わずか5分。新LINE Messaging APIでbotを作ってみた手順全公開
今回はpush通知は送りませんので、「フリー」を選びましょう。
(フリーだと友だち登録数の上限がないため)

③line-bot-php-sdkをインストールする

▼こちらを参考に
PHP版の公式SDKを使ってLINE Messaging APIベースのBotを作ってみた
*実装も結構、上記を参考にしました。

〜 事前準備はここまで 〜

まずは固定メッセージを返すまでやってみよう

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ください。
(何までやって、何ができないか、どういう事象にハマっているかを教えてもらえるとスムーズにいきます。+ソースくれればなおいい)

本題

1:ジャンルを保持する〜次のアクションへ誘導

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

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

searchGoogleGeocodingAPI

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

Google Maps APIの利用方法はこちらを参照▼
Google Maps Geocoding APIの使い方[完全版]

searchFromLocationWord

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

2:位置情報から食べログを検索〜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のjsonをphpの連想配列へ置き換えたもの。
「:」→「=>」、「{}」→「[]」に置換すればOK。
動的な部分は変数から取得。星評価のイメージはON/OFFあるのでgetStarImgにて判定。

postMessage

変換したjson(createJsonParameter)をcurlでpostしてFlex Messageを送ります。
*パラメーターについてはAPIリファレンス#flex-messageを参照

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

〜 説明以上 〜

終わりに

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

【宣伝】

LINE Bot開発者でLINEグループ作って情報交換をしています。
興味ある方はこちらを参照して管理者にメッセージください。

追記

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

はてなブックマーク -50 Over!!

Hatebu::Classicに載りました

○ 続編公開しました!
[LINE bot] 食べログ3.5以上を検索できるbotに「お気に入り登録」を実装した

以上初Qiitaでした!

NARI_Creator
フリーランスエンジニア / TEAMKIT CTO
https://teamkit.jp/@narith
admin-guild
「Webサービスの運営に必要なあらゆる知見」を共有できる場として作られた、運営者のためのコミュニティです。
https://admin-guild.slack.com
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした