007_noteヘッダー1

楽天APIを利用したホテルの空室検索ができるLINE BOTのプログラミング解説~API&LINE BOT~

急な出張が入った時、ホテルの空き状況をすぐ調べたいと思ったことはありませんか?今は予約サイトやアプリまで充実しているので昔に比べれば随分楽になりましたが、それでもざっくり言って

①スマホを開く
②サイト(アプリ)にアクセスする
③地域や条件を選択し、検索する

という3段階の作業が必要になります。特に③が面倒で時間がかかるんですよね(;´・ω・) それこそ『Siri、新宿のフランス料理店を探して!』くらい直観的にお部屋を見つけられないもんでしょうか。

そんな願いを叶えたのが「みゃふの空室検索アプリ」です!(*^^)v

こんな方におススメ(*´▽`*)
・Web APIを使ったLINEアプリを作ってみたい
・Pythonの基本的な知識を使ったプロダクトを作ってみたい
・LINEで直観的にホテルの空き情報を調べたい

概要

このアプリの仕組みをもう少し詳しく言うと、

LINEの専用チャットで希望の場所や日にちを入力すると、楽天トラベルのAPIから空き状況が返ってくるLINE BOT!

というものになります!

画像1


さて今出てきた、今回のアプリを作る上で鍵となるWeb APIですが、これは簡単に言うと共有できるプログラムを利用できる仕組みのことを指します。少しWebをかじった方はご存じだと思いますが、LINEや楽天に限らず、AmazonをはじめあらゆるWebサービスでは、自社のデータを扱うためのプログラムを外部から誰でも使えるよう公開しているんです('Д')

つまりこの仕組みを使えば、ゼロからプログラムを書かなくても、提供されたデータをいじったり新しいサービスを作って実装できちゃうという素晴らしいことができるのです(*´▽`*)

その中でも、今回はLINEと楽天トラベル2つのAPIを利用します。本来LINEと楽天トラベルは全く異なるサービスですが、APIを活用することで両者を橋渡しし一つのアプリとして実行することができるんですね~。

えっ?LINEや楽天のプログラムなんて見たこと無いよ!という方、ご安心なされ。基本的にはそれぞれのドキュメント(サービス提供元が公開している仕様書)にある情報に沿って利用するだけですので、とても簡単です。

ちなみにLINEについては、見たことが無くても間接的に体験している方は多いはず。例えば公式アカウントのチャットで何かメッセージを送ると、自動で相手から返事が来ることがありますよね。この仕組みを作っているのがまさに今回使用するLINEのWeb APIなのです(ФωФ)

Web上に無料で公開されていることも多いので、使いこなせるようになると面白いことがたくさんできると思います(*^^)v

今回はそんな無料のWeb APIを使って、「みゃふの空室検索アプリ」を作ります。

大まかな仕組み

仕組みはとても簡単で、LINEのAPIを利用して、条件を楽天トラベル側のAPIに入力し、楽天トラベルから返ってきた出力をLINEに表示させているというシンプルなものです。

画像2


先に目指す機能をお見せしますね。次のようにLINEに3つの条件をまとめて入力するだけです。

①場所(鍵カッコつきで入力)
②チェックイン希望日(2000/00/00の形式で入力)
③宿泊日数(泊という文字を付けて入力)

例えば)
「熱海」
2019/06/20
1泊

※3つの入力項目のいずれかが不足するとエラーになります。
※入力形式が違うとエラーになります。
(鍵カッコがない、日付の入力形式が異なるなど)

そうするとこのように、楽天トラベルのホテルの空き状況が表示さます。

画像3

ちなみに空室が見つからなかったり、入力した日付が過去のものだったりすると、楽天APIから返ってきたエラー文をそのまま返すようにしています。

さて、使ってる言語やフレームワークはこちらです!
・言語:Python
・フレームワーク:Flask
・API:LINE Messaging API、楽天トラベル空室検索API
・コード管理:GitHub
・サーバー:Heroku

GitHubやHerokuを使えば、無料でサーバー構築することができるし、どちらもGUI(コマンドではなく視覚的・直観的に操作できる画面のこと。逆にコマンドライン等のことをCUIと呼びます)が充実しているので、コマンドがわからなくてもマウスの操作だけで、環境構築ができちゃうよ(^_-)-☆

Herokuは無料でアプリなどの開発環境や公開を行うことができる超便利なプラットフォームサービス(PaaS)です!Webサイトを運営したことのある方はレンタルサーバーについてご存じかも知れませんが、『アプリを実行するための環境を貸してくれる』という意味では同じもの。ただしherokuの方ができることが多く拡張性もあります。

Herokuについては次の記事でも書いているので、興味ある人は見てくださいね(^^♪

これらのサービスは、すべて無料でも使えるんです!これらを組み合わせて少しプログラムを書くだけで、LINEのBOTができちゃいます~

初めはコピペでもよいので、実際に動かしてみてください(^^♪

ファイルの構成

ではまず、ファイルの構成を解説していきましょう。
今回はherokuのサーバー上で動かすことを想定しているため、実際のPythonのプログラムを書く「main.py」、「hotel.py」のほかに3つのファイルを用意します。

Procfile
requrements.txt
runtime.txt
hotel.py
main.py

ファイル①:Procfile
サーバー(Heroku)に実行コマンドを宣言するファイルです。
ファイル内にpythonのmain.pyファイルを実行するよーと記述しています。

web: python main.py

ファイル②:requirements.txt
今回利用するライブラリを指定するファイルです。
ファイル内には、LINEのPython用ライブラリline-bot-sdk、WebフレームワークのFlask、位置情報を取得するためのgeopy、APIから情報を取得するためのrequestsを記述しています。

ココで指定するライブラリーはPythonに標準でインストールされていないライブラリ(サードパーティーライブラリ、外部ライブラリ)になるので、別途インストールする必要があります。実際に何のライブラリを使用するかは、プログラムを書きながら決めていくことも多いので、プログラムを書き終わってから適宜追加すると良いでしょう。

line-bot-sdk
flask
geopy
requests
Pythonのライブラリには、公式が配布している『標準』ライブラリと、サードパーティーが配布しているものの2種類があります=^_^=

ファイル③:runtime.txt
使用するPythonのバージョンを指定します。
バージョンは3系であれば問題ありませんが、今回は2019年6月時点で新しい3.7を指定します。

python-3.7.3

以上でpythonのスクリプトファイル以外の準備は完了になります。hotel.pyとmain.pyの全コードは、hotel.pyのまとめmain.pyのまとめを参照してください。

プログラムの全体像を決める

今回のプログラムは、①「LINEのメッセージ関係の処理」(LINE Messaging API)と②「ホテルの空室情報関係の処理」(楽天トラベル空室検索API)の大きく二つに分かれます。そこで、コードの見やすさやメンテナンスのしやすさを考慮して、ファイルも二つに分けてプログラムを書いていくことにします。

今回作る程度の規模の場合、ファイルを分けてプログラムを書く必要は通常ありません。しかし、LINE BOTを開発する場合、LINE BOTのプログラム部分はFlaskというフレームワークを利用して書くことになるので、LINE BOTと直接関係ない部分は別ファイルにしておくと後から見やすさやメンテナンス性が高くなります。

※別ファイルにしても数行から十数行で終わってしまうような場合は、1つのファイルのままで十分です。

楽天トラベル空室検索API関係のプログラムを作る(hotel.py)

LINE BOTに直接関係する部分は、基本的にサーバーを利用する必要があるのでデバッグが面倒になります。そこで、ローカル環境からでも簡単に実行できる空室情報を参照するプログラム「hotel.py」を先に作成します。

LINEのことは一旦忘れて楽天のAPIからデータを取得するプログラムを書いてみましょう。APIからデータ取得する際にはまず公式のドキュメントを確認して、どんな値を入力パラメータとして設定すればいいかを確認します。

今回は、「場所」「チェックイン日」「宿泊日数」の三つを入力して、「ホテル名」「URL」の二つを得ることを目標としています。ここらへんの仕様はAPIの仕様によって不可能であったり難しかったりする場合があるので、仕様を確認しながら適宜決めると良いでしょう。

公式ドキュメントを見ると、入力パラメータに「場所」そのものはなく、「住所コード」または「緯度・経度」を指定する必要があります。また「宿泊日数」ではなく「チェックアウト日」を指定する必要があります。

「チェックアウト日」は「宿泊日数」からすぐに計算できそうですが、「場所」から「緯度・経度」を計算するのはちょっと大変そうです。

1.場所から緯度・経度を計算する

そこで、まずは場所から緯度・経度を計算するプログラムを書いてみましょう。

Pythonで場所から緯度・経度を計算する(ジオコーディングと呼ばれます)にはAPIを使う方法と外部ライブラリを使う方法の主に二通りがあります。今回は、APIではなくgeopyというライブラリを使ってみましょう。

厳密に言えば、ライブラリを使う方法でも内部の処理でインターネット通信を行いAPIを利用して緯度・経度を取得しています。一般的にライブラリのほうがコード的には簡潔に記述することができます。

空白の「hotel.py」に以下のコードを書いてライブラリをインポートしましょう。geopyは外部ライブラリなので、ローカルでテストする場合は、忘れずにpip install geopyやお使いの開発ツールの使い方にそってgeopyをインストールしておきましょう。

from geopy.geocoders import Nominatim

今回はgeopyというライブラリの中のNominatimというモジュールを利用します。

NominatimはOpenStreetMapというサイトのサーチエンジンでもあり、サイト上から緯度・経度を確認することもできます。

なお、geopyにはNominatim以外にもGoogle Mapなど様々なモジュールが含まれており、好きなモジュールを利用することができます。各モジュールの利用方法等詳細は以下の公式ドキュメントを参照して下さい。

まずは、場所名から緯度・経度を算出する自作の関数geocoding(place)を定義します。

関数の定義はせずに、そのままプログラムを書いた方が楽な場合もありますが、よっぽど短いプログラムでない限り、関数定義したほうが見やすさやメンテナンスのしやすさは良くなることが多いです。普段から関数を作る習慣をつけておくと良いでしょう。
def geocoding(place):

場所名を入力すると緯度・経度を出力する関数にしたいため、ここでは場所の文字列を表す"place"を引数としています。

def geocoding(place):
   geolocator = Nominatim(user_agent="my-application")
   location = geolocator.geocode(place, timeout=10)

まずは、関数定義の下の1行目でNominatimに接続するためにuser_agent(サーバーに送信するユーザー情報、Nominatimの場合は以下の規約でアプリケーション名を指定することになっています)を設定します。

2行目で入力された場所の文字列(place)から位置情報を取得するコードを書きます。なお、geopyではデフォルトのタイムアウト時間が1秒となっており、サーバー混雑等の影響によりタイムアウトとなってしまう可能性を下げるため、ここではtimeout=10(秒)としています。

   if location is None:
       return
   else:
       latitude = location.latitude
       longitude = location.longitude
       return latitude, longitude

次に位置情報が取得できた場合とできなかった場合の処理を分けて記述します。

まず、場所から正しく位置情報を取得できない場合は、locationの中身がNoneとなるため、その場合は、returnで緯度・経度もNoneとして出力します。

returnの後ろに何もつけなかった場合はNoneが出力されます。

正しく取得できた場合の処理をelse以下に書き、location.latitudeで緯度を、location.longitudeで経度を取得します。最後にreturnで緯度・経度をそれぞれ出力します。

from geopy.geocoders import Nominatim

def geocoding(place):
   geolocator = Nominatim(user_agent="my-application")
   location = geolocator.geocode(place, timeout=10)
   if location is None:
       return
   else:
       latitude = location.latitude
       longitude = location.longitude
       return latitude, longitude

これで「場所」を入力すると「緯度・経度」が出力される関数geocoding(place)ができました。

>>> print(geocoding("熱海"))
(35.08992, 139.059891)

試しに「熱海」という文字列を入力してみると、(35.08992, 139.059891)というかたちで緯度・経度のタプルが出力されました。

2.ホテルの空室検索を行う

次に楽天トラベル空室検索APIを利用して、ホテルの空室検索を行う関数hotel_search(place, checkin, checkout)を定義します。この関数に"場所名","チェックイン日","チェックアウト日"を通すことにより、LINE BOTのメッセージとして送信する文章を出力することを目的とします。

def hotel_search(place, checkin, checkout):
   latitude, longitude = geocoding(place)

場所とチェックイン日とチェックアウト日を引数としています。APIに通すためには場所名を緯度・経度に変換する必要があるため、まずは先ほど作成した関数geocodingに場所名placeを入力して、緯度・経度をそれぞれ取得します。

from geopy.geocoders import Nominatim
import requests

APIを利用する際に便利な外部ライブラリrequestsを利用するため、忘れずにファイルの先頭のimport文にrequestsを追記しておきましょう。インストールもしておきましょう。

URLとURLパラメータを設定

requestsライブラリによりAPIを利用する際に毎回必要となる一般的な手順になります。

   url = "https://app.rakuten.co.jp/services/api/Travel/VacantHotelSearch/20170426"
   params = {'applicationId': 'your-application-id',
             'formatVersion': '2',
             'checkinDate': checkin,
             'checkoutDate': checkout,
             'latitude': latitude,
             'longitude': longitude,
             'searchRadius': '3',
             'datumType': '1',
             'hits': '5'}

公式ドキュメントを確認し、APIの基本となるURLを変数urlにまず代入し、残りのパラメータを変数paramsに辞書形式で代入します。

・"applicationId=0000000000000000000" → 楽天APIのアプリID作成時に取得した19桁のアプリID
・"formatVersion" → 出力フォーマット
・"checkinDate"、"checkoutDate" → チェックイン日、チェックアウト日
・"latitude"、"longitude" → 緯度、経度
・"searchRadius" → 緯度、経度で指定した場所からの距離
・"datumType" → 緯度、経度のタイプ
・"hits" → 取得件数

ここではformatVersionに2(デフォルトだと1、2のほうが簡潔な出力になりそうなためここでは2)、searchRadius(検索半径)に3(km)、datumType(緯度・経度の種類)に1(世界測地系)、hits(表示件数)に5(件)を指定します。

検索半径や表示件数はそれぞれの好みに合わせて変えていくと良いでしょう。

requestsを利用すると、GET通信の場合でもパラメータの設定によりコードを簡潔に見やすく記述することができます。
例えば
url = "http://hogehoge/hoge"
params = {'param1': p1, 'param2': p2}
というコードは、内部の処理により
http://hogehoge/hoge?param1=p1&param2=p1
というURLに変換されます。

APIへ接続し辞書形式でデータを得る

次にAPIに接続するための処理を書きます。

    try:
       r = requests.get(url, params=params)
       content = r.json()
    except:
       #import traceback
       #traceback.print_exc()
       return "API接続中に何らかのエラーが発生しました"

requests.getにより設定したURLとパラメータを元にGET通信を行います。次に、requestsライブラリの関数json()により変数contentにデコードしたjsonデータを辞書として格納します。

なお、requestsなどにより外部と通信するプログラムの場合は、通信エラーが予期されるため、try~except文でエラー処理を行うと良いでしょう。エラー内容に分けて細かく処理を書くこともありますが、複雑になってしまうので、ここではエラーが発生した場合(except以下)は「API接続中に何らかのエラーが発生しました」という文を単純に出力することにします。

import traceback
traceback.print_exc()

ちなみにtry~except文の内部では、単純なプログラム上の実行エラーなども表示されなくなってexcept以下の処理が実行されてしまうので、デバッグ中はtracebackという標準ライブラリのprint_exc()という関数を使い、traceback.print_exc()により通常通りエラー内容を出力させると良いでしょう。

開発が終わったらこの2行はコメントアウトするか削除しておいてください。

(さらにエラー処理)

次に、空室が見つからなかったときや過去の日付を指定してしまったときなど、正常にAPIと通信ができていて、かつAPIからエラー情報が返ってきた場合の処理を記述します。

すでにtry~except文を書いているため、何らかのエラーが発生した場合には、except以下の文が実行されますが、APIから返ってきた詳細なエラー内容をLINE BOTのメッセージと出力したいため、このプログラムを書きます。

ここの部分はそれぞれのAPIやプログラムの仕様により、記述する必要がない場合や既に記述済みのtry~except文でしかエラー処理できない場合もあります。

ちなみに楽天APIへの通信に成功し、かつ何らかのエラーが発生した場合のデータの詳細は、公式ドキュメントに記述があります。

{
   "error": "not_found",
   "error_description": "not found"
}

例えば、空室が見つからなかった場合は上のようなデータがjsonとして返ってきます。

       error = content.get("error")
       if error is not None:
           msg = content["error_description"]
           return msg

そこで、変数errorに「error」というkeyの値をPythonの標準関数getにより代入します。楽天トラベル空室検索APIでは、エラーが発生しなかった場合は、「error」というkeyが存在しないので、errorにはNoneが代入されることになります。

get()はpythonの組み込み関数です。
content.get("error")と指定すると、"error"というkeyが存在しないときでもcontentに変数errorにNoneが代入されます。
しかし、content["error"]と指定した場合、"error"というkeyが存在しないときにはエラーが発生し、try節の処理は止まってexcept節が実行されてしまいます。
content["error"]のように直接key名を[]内に書く方法も、コードの文字数的には簡潔ですし処理速度も速いといったメリットがあります。

errorがNoneではない場合、つまり何らかのエラー情報がAPIから返ってきた場合は、"error_description"のvalue(上記の例の場合は"not found")を変数msgに代入し、それをreturnにより出力します。

取得データを加工

いよいよ取得データを加工します。まずは、加工のためのデータがどのようになっているか確認するため、print文を実行してみます。APIから返ってくるjsonデータの場合、非常に複雑で見難くなる場合があるため、そのときはpprintという標準ライブラリのpprint関数を利用すると、見やすく整形して出力してくれます。

>>> import pprint
>>> pprint.pprint(content)
{'hotels': [[{'hotelBasicInfo': {'access': '東京からお車で約1時間30分。来宮駅よりお車で約10分。来宮駅からの無料送迎もございます。(要予約)',
                                'address1': '静岡県',
                                'address2': '熱海市梅園町16-8',
                                'dpPlanListUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/WzozX/?noTomariHotel=167693',
                                'faxNo': '03-5312-8521',
                                'hotelImageUrl': 'https://img.travel.rakuten.co.jp/share/HOTEL/167693/167693.jpg',
                                'hotelInformationUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/pvonD/?f_no=167693',
                                'hotelKanaName': 'しんかんかくいずふれんちかいせき\u3000'
                                                 'あたみふうが',
                                'hotelMapImageUrl': 'https://img.travel.rakuten.co.jp/share/HOTEL/167693/167693map.gif',
                                'hotelMinCharge': 6600,
                                'hotelName': '新感覚Izuフレンチ懐石\u3000熱海風雅',
                                'hotelNo': 167693,
                                'hotelSpecial': '北欧の家具に囲まれた快適な滞在を約束。フレンチと和食を融合させた料理が自慢!無料コンテンツも充実♪',
                                'hotelThumbnailUrl': 'https://img.travel.rakuten.co.jp/HIMG/90/167693.jpg',
                                'latitude': 35.1012369,
                                'longitude': 139.05888930000003,
                                'nearestStation': '熱海',
                                'parkingInformation': '無料/全20台/完全予約制',
                                'planListUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/vFumt/?f_no=167693&f_flg=PLAN',
                                'postalCode': '413-0032',
                                'reviewAverage': 4.33,
                                'reviewCount': 82,
                                'reviewUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/gJNfM/?f_hotel_no=167693',
                                'roomImageUrl': 'https://img.travel.rakuten.co.jp/share/HOTEL/167693/167693_kan1.jpg',
                                'roomThumbnailUrl': 'https://img.travel.rakuten.co.jp/HIMG/INTERIOR/167693.jpg',
                                'telephoneNo': '0570-003-141',
                                'userReview': '理由は酔っぱらうと入…\u30002019-10-03 '
                                              '19:48:41投稿 <a '
                                              'href="http://img.travel.rakuten.co.jp/image/tr/api/re/gJNfM/?f_hotel_no=167693" '
                                              'class="3click">つづきはこちら</a>'}},
            {'roomInfo': [{'roomBasicInfo': {'breakfastSelectFlag': 0,
                                             'dinnerSelectFlag': 0,
                                             'payment': '1',
                                             'planId': 4298182,
                                             'planName': '【意外と熱海】朝食無料プレゼント!期間限定で素泊まり同価格で朝食付き♪\u3000'
                                                         '~1泊朝食付~',
                                             'pointRate': 3,
                                             'reserveUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/IdsCY/?f_no=167693&f_syu=ob3&f_hi1=2019-10-20&f_hi2=2019-10-30&f_heya_su=1&f_otona_su=1&f_s1=0&f_s2=0&f_y1=0&f_y2=0&f_y3=0&f_y4=0&f_camp_id=4298182',
                                             'roomClass': 'ob3',
----------------------------------(中略)----------------------------------
                          {'dailyCharge': {'chargeFlag': 0,
                                           'rakutenCharge': 20790,
                                           'stayDate': '2019-10-20',
                                           'total': 20790}}]}],
           [{'hotelBasicInfo': {'access': '熱海駅~タクシー10分、路線バス-笹良ケ台循環-【竹の茶屋】下車徒歩5分※11月1日より【パイプのけむり前】下車徒歩1分',
                                'address1': '静岡県',
                                'address2': '熱海市西山町16-53',
----------------------------------(中略)----------------------------------
                                             'withDinnerFlag': 0}},
                          {'dailyCharge': {'chargeFlag': 0,
                                           'rakutenCharge': 9000,
                                           'stayDate': '2019-10-20',
                                           'total': 9000}}]}]],
'pagingInfo': {'first': 1,
               'last': 20,
               'page': 1,
               'pageCount': 1,
               'recordCount': 5}}

(key:"hotels")の中に、ホテル情報(key:"hotelBasicInfo")、部屋情報(key:"roomInfo")、検索結果情報(key:"pagingInfo")などが含まれています。このデータを見ながら辞書"content"内のkeyを指定して目的のvalueを取得していきます。

       hotel_count = content["pagingInfo"]["recordCount"]
       hotel_count_display = content["pagingInfo"]["last"]
       msg = place + "の半径3km以内に合計" + str(hotel_count) + "件見つかりました。" + str(hotel_count_display) + "件を表示します。\n"

まずは、LINE BOTのメッセージとして検索結果の件数と表示数を表示したいので、変数"hotel_count"に検索結果件数、"hotel_count_display"に表示数を代入します。

その後、"(場所名)の半径3km以内に合計(検索件数) 件見つかりました。(表示数)件を表示します。(改行)"という文章を最終的にreturnで出力する予定の変数"msg"に代入します。

for hotel in content["hotels"]:

次にホテル名とURLを取得するために"content"内のkeyである"hotels"を指定し、for文で展開していきます。

今回のcontent["hotels"]の中身は大カッコが二つあるなど複雑で辞書内の要素の参照方法がわかりにくいため、慣れない場合やうまく参照できない場合はさらにprint文でどのように展開されるのか確認すると良いでしょう。

>>> for hotel in content["hotels"]:
>>>     print(hotel)
[{'hotelBasicInfo': {'hotelNo': 167693, 'hotelName': '新感覚Izuフレンチ懐石\u3000熱海風雅', 'hotelInformationUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/pvonD/?f_no=167693', 'planListUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/vFumt/?f_no=167693&f_flg=PLAN', 'dpPlanListUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/WzozX/?noTomariHotel=167693', 'reviewUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/gJNfM/?f_hotel_no=167693', 'hotelKanaName': 'しんかんかくいずふれんちかいせき\u3000あたみふうが', 'hotelSpecial': '北欧の家具に囲まれた快適な滞在を約束。フレンチと和食を融合させた料理が自慢!無料コンテンツも充実♪', 'hotelMinCharge': 6600, 'latitude': 35.1012369, 'longitude': 139.05888930000003, 'postalCode': '413-0032', 'address1': '静岡県', 'address2': '熱海市梅園町16-8', 'telephoneNo': '0570-003-141', 'faxNo': '03-5312-8521', 'access': '東京からお車で約1時間30分。来宮駅よりお車で約10分。来宮駅からの無料送迎もございます。(要予約)', 'parkingInformation': '無料/全20台/完全予約制', 'nearestStation': '熱海', 'hotelImageUrl': 'https://img.travel.rakuten.co.jp/share/HOTEL/167693/167693.jpg', 'hotelThumbnailUrl': 'https://img.travel.rakuten.co.jp/HIMG/90/167693.jpg', 'roomImageUrl': 'https://img.travel.rakuten.co.jp/share/HOTEL/167693/167693_kan1.jpg', 'roomThumbnailUrl': 'https://img.travel.rakuten.co.jp/HIMG/INTERIOR/167693.jpg', 'hotelMapImageUrl': 'https://img.travel.rakuten.co.jp/share/HOTEL/167693/167693map.gif', 'reviewCount': 82, 'reviewAverage': 4.33, 'userReview': '理由は酔っぱらうと入…\u30002019-10-03 19:48:41投稿 <a href="http://img.travel.rakuten.co.jp/image/tr/api/re/gJNfM/?f_hotel_no=167693" class="3click">つづきはこちら</a>'}}, {'roomInfo': [{'roomBasicInfo': {'roomClass': 'ob3', 'roomName': '【新館】オーシャンビュー和ベッドツイン', 'planId': 4298182, 'planName': '【意外と熱海】朝食無料プレゼント!期間限定で素泊まり同価格で朝食付き♪\u3000~1泊朝食付~', 'pointRate': 3, 'withDinnerFlag': 0, 'dinnerSelectFlag': 0, 'withBreakfastFlag': 1, 'breakfastSelectFlag': 0, 'payment': '1', 'reserveUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/IdsCY/?f_no=167693&f_syu=ob3&f_hi1=2019-10-20&f_hi2=2019-10-30&f_heya_su=1&f_otona_su=1&f_s1=0&f_s2=0&f_y1=0&f_y2=0&f_y3=0&f_y4=0&f_camp_id=4298182', 'salesformFlag': 0}}, {'dailyCharge': {'stayDate': '2019-10-20', 'rakutenCharge': 19800, 'total': 19800, 'chargeFlag': 0}}]}, {'roomInfo': [{'roomBasicInfo': {'roomClass': 'ob3', 'roomName': '【新館】オーシャンビュー和ベッドツイン', 'planId': 4260767, 'planName': '【素泊まり】チェックインは22時までOK!北欧のインテリアで優雅なひと時を', 'pointRate': 1, 'withDinnerFlag': 0, 'dinnerSelectFlag': 0, 'withBreakfastFlag': 0, 'breakfastSelectFlag': 0, 'payment': '1', 'reserveUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/IdsCY/?f_no=167693&f_syu=ob3&f_hi1=2019-10-20&f_hi2=2019-10-30&f_heya_su=1&f_otona_su=1&f_s1=0&f_s2=0&f_y1=0&f_y2=0&f_y3=0&f_y4=0&f_camp_id=4260767', 'salesformFlag': 0}}, {'dailyCharge': {'stayDate': '2019-10-20', 'rakutenCharge': 19800, 'total': 19800, 'chargeFlag': 0}}]}, {'roomInfo': [{'roomBasicInfo': {'roomClass': 'ob3', 'roomName': '【新館】オーシャンビュー和ベッドツイン', 'planId': 4300637, 'planName': '【風雅セレクト】最大30%オフ!量少な目が丁度いい♪Lightフレンチ懐石▼風~kaze~', 'pointRate': 1, 'withDinnerFlag': 1, 'dinnerSelectFlag': 0, 'withBreakfastFlag': 1, 'breakfastSelectFlag': 0, 'payment': '1', 'reserveUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/IdsCY/?f_no=167693&f_syu=ob3&f_hi1=2019-10-20&f_hi2=2019-10-30&f_heya_su=1&f_otona_su=1&f_s1=0&f_s2=0&f_y1=0&f_y2=0&f_y3=0&f_y4=0&f_camp_id=4300637', 'salesformFlag': 0}}, {'dailyCharge': {'stayDate': '2019-10-20', 'rakutenCharge': 20790, 'total': 20790, 'chargeFlag': 0}}]}]
[{'hotelBasicInfo': {'hotelNo': 75234, 'hotelName': '熱海ホテルパイプのけむり', 'hotelInformationUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/pvonD/?f_no=75234', 'planListUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/vFumt/?f_no=75234&f_flg=PLAN', 'dpPlanListUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/WzozX/?noTomariHotel=75234', 'reviewUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/gJNfM/?f_hotel_no=75234', 'hotelKanaName': 'あたみほてるぱいぷのけむり', 'hotelSpecial': '★80台無料駐車場★全室Wi-Fi完備◆夕食はお酒飲み放題付30種類ファミリーバイキング◆貸切風呂45分700円!', 'hotelMinCharge': 4970, 'latitude': 35.10380779, 'longitude': 139.0653815, 'postalCode': '413-0034', 'address1': '静岡県', 'address2': '熱海市西山町16-53', 'telephoneNo': '0557-86-1777', 'faxNo': '0557-86-1788', 'access': '熱海駅~タクシー10分、路線バス-笹良ケ台循環-【竹の茶屋】下車徒歩5分※11月1日より【パイプのけむり前】下車徒歩1分', 'parkingInformation': '有り\u300080台\u3000無料\u3000予約不要', 'nearestStation': '熱海', 'hotelImageUrl': 'https://img.travel.rakuten.co.jp/share/HOTEL/75234/75234.jpg', 'hotelThumbnailUrl': 'https://img.travel.rakuten.co.jp/HIMG/90/75234.jpg', 'roomImageUrl': 'https://img.travel.rakuten.co.jp/share/HOTEL/75234/75234_buf.jpg', 'roomThumbnailUrl': 'https://img.travel.rakuten.co.jp/HIMG/INTERIOR/75234.jpg', 'hotelMapImageUrl': 'https://img.travel.rakuten.co.jp/share/HOTEL/75234/75234map.gif', 'reviewCount': 731, 'reviewAverage': 3.86, 'userReview': '「昭和初期建造の…\u30002019-09-29 16:46:17投稿 <a href="http://img.travel.rakuten.co.jp/image/tr/api/re/gJNfM/?f_hotel_no=75234" class="3click">つづきはこちら</a>'}}, {'roomInfo': [{'roomBasicInfo': {'roomClass': 'stw', 'roomName': '1~2名様用《18平米》ツインルーム【喫煙】', 'planId': 4410004, 'planName': '【駅からタクシー利用限定】タクシーで来たから宿泊値引!C/IN時にタクシー★領収証★必要【食事なし】', 'pointRate': 1, 'withDinnerFlag': 0, 'dinnerSelectFlag': 0, 'withBreakfastFlag': 0, 'breakfastSelectFlag': 0, 'payment': '0', 'reserveUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/IdsCY/?f_no=75234&f_syu=stw&f_hi1=2019-10-20&f_hi2=2019-10-30&f_heya_su=1&f_otona_su=1&f_s1=0&f_s2=0&f_y1=0&f_y2=0&f_y3=0&f_y4=0&f_camp_id=4410004', 'salesformFlag': 0}}, {'dailyCharge': {'stayDate': '2019-10-20', 'rakutenCharge': 6250, 'total': 6250, 'chargeFlag': 0}}]}, {'roomInfo': [{'roomBasicInfo': {'roomClass': 'stw', 'roomName': '1~2名様用《18平米》ツインルーム【喫煙】', 'planId': 1534349, 'planName': '【素泊まり】熱海市内のお店で旨いもの食べてめいいっぱい観光したい!泊まるだけでOK!【食事なし】', 'pointRate': 1, 'withDinnerFlag': 0, 'dinnerSelectFlag': 0, 'withBreakfastFlag': 0, 'breakfastSelectFlag': 0, 'payment': '1', 'reserveUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/IdsCY/?f_no=75234&f_syu=stw&f_hi1=2019-10-20&f_hi2=2019-10-30&f_heya_su=1&f_otona_su=1&f_s1=0&f_s2=0&f_y1=0&f_y2=0&f_y3=0&f_y4=0&f_camp_id=1534349', 'salesformFlag': 0}}, {'dailyCharge': {'stayDate': '2019-10-20', 'rakutenCharge': 7450, 'total': 7450, 'chargeFlag': 0}}]}, {'roomInfo': [{'roomBasicInfo': {'roomClass': 'stw', 'roomName': '1~2名様用《18平米》ツインルーム【喫煙】', 'planId': 4399446, 'planName': '【駅からタクシー利用限定】タクシーで来たから宿泊割引!C/IN時にタクシー★領収証★必要【2食付】', 'pointRate': 1, 'withDinnerFlag': 1, 'dinnerSelectFlag': 0, 'withBreakfastFlag': 1, 'breakfastSelectFlag': 0, 'payment': '0', 'reserveUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/IdsCY/?f_no=75234&f_syu=stw&f_hi1=2019-10-20&f_hi2=2019-10-30&f_heya_su=1&f_otona_su=1&f_s1=0&f_s2=0&f_y1=0&f_y2=0&f_y3=0&f_y4=0&f_camp_id=4399446', 'salesformFlag': 0}}, {'dailyCharge': {'stayDate': '2019-10-20', 'rakutenCharge': 7870, 'total': 7870, 'chargeFlag': 0}}]}]
[{'hotelBasicInfo': {'hotelNo': 142646, 'hotelName': '四季倶楽部\u3000熱海望洋館', 'hotelInformationUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/pvonD/?f_no=142646', 'planListUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/vFumt/?f_no=142646&f_flg=PLAN', 'dpPlanListUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/WzozX/?noTomariHotel=142646', 'reviewUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/gJNfM/?f_hotel_no=142646', 'hotelKanaName': 'しきくらぶ\u3000あたみぼうようかん', 'hotelSpecial': 'ガラスを多用し、開放感を感じられるホテル。相模湾を見下ろす高台にあるからこそ、2階・3階最上階客室からは海を望めます。', 'hotelMinCharge': 3355, 'latitude': 35.106704, 'longitude': 139.07649, 'postalCode': '413-0006', 'address1': '静岡県', 'address2': '熱海市桃山町8-27', 'telephoneNo': '045-476-5977', 'faxNo': '045-476-5975', 'access': '熱海駅よりお車にて5分、徒歩にて15分', 'parkingInformation': '有り\u300014台\u3000無料\u3000予約不要', 'nearestStation': '熱海', 'hotelImageUrl': 'https://img.travel.rakuten.co.jp/share/HOTEL/142646/142646.jpg', 'hotelThumbnailUrl': 'https://img.travel.rakuten.co.jp/HIMG/90/142646.jpg', 'roomImageUrl': 'https://img.travel.rakuten.co.jp/share/HOTEL/142646/142646_wa.jpg', 'roomThumbnailUrl': 'https://img.travel.rakuten.co.jp/HIMG/INTERIOR/142646.jpg', 'hotelMapImageUrl': 'https://img.travel.rakuten.co.jp/share/HOTEL/142646/142646map.gif', 'reviewCount': 37, 'reviewAverage': 2.86, 'userReview': None}}, {'roomInfo': [{'roomBasicInfo': {'roomClass': '4', 'roomName': '客室V', 'planId': 4536535, 'planName': '◆【インターネット販売限定】1泊素泊まり 「禁煙」', 'pointRate': 1, 'withDinnerFlag': 0, 'dinnerSelectFlag': 0, 'withBreakfastFlag': 0, 'breakfastSelectFlag': 0, 'payment': '2', 'reserveUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/IdsCY/?f_no=142646&f_syu=4&f_hi1=2019-10-20&f_hi2=2019-10-30&f_heya_su=1&f_otona_su=1&f_s1=0&f_s2=0&f_y1=0&f_y2=0&f_y3=0&f_y4=0&f_camp_id=4536535', 'salesformFlag': 0}}, {'dailyCharge': {'stayDate': '2019-10-20', 'rakutenCharge': 33000, 'total': 33000, 'chargeFlag': 0}}]}, {'roomInfo': [{'roomBasicInfo': {'roomClass': '4', 'roomName': '客室V', 'planId': 4536541, 'planName': '◆【インターネット販売限定】1泊朝食付\u3000「禁煙」', 'pointRate': 1, 'withDinnerFlag': 0, 'dinnerSelectFlag': 0, 'withBreakfastFlag': 1, 'breakfastSelectFlag': 0, 'payment': '2', 'reserveUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/IdsCY/?f_no=142646&f_syu=4&f_hi1=2019-10-20&f_hi2=2019-10-30&f_heya_su=1&f_otona_su=1&f_s1=0&f_s2=0&f_y1=0&f_y2=0&f_y3=0&f_y4=0&f_camp_id=4536541', 'salesformFlag': 0}}, {'dailyCharge': {'stayDate': '2019-10-20', 'rakutenCharge': 36300, 'total': 36300, 'chargeFlag': 0}}]}, {'roomInfo': [{'roomBasicInfo': {'roomClass': '4', 'roomName': '客室V', 'planId': 4536545, 'planName': '◆【インターネット販売限定】1泊2食付\u3000「禁煙」', 'pointRate': 1, 'withDinnerFlag': 1, 'dinnerSelectFlag': 0, 'withBreakfastFlag': 1, 'breakfastSelectFlag': 0, 'payment': '2', 'reserveUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/IdsCY/?f_no=142646&f_syu=4&f_hi1=2019-10-20&f_hi2=2019-10-30&f_heya_su=1&f_otona_su=1&f_s1=0&f_s2=0&f_y1=0&f_y2=0&f_y3=0&f_y4=0&f_camp_id=4536545', 'salesformFlag': 0}}, {'dailyCharge': {'stayDate': '2019-10-20', 'rakutenCharge': 42240, 'total': 42240, 'chargeFlag': 0}}]}]
[{'hotelBasicInfo': {'hotelNo': 28514, 'hotelName': '貸切温泉のコンドミニアム\u3000グランビュー熱海', 'hotelInformationUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/pvonD/?f_no=28514', 'planListUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/vFumt/?f_no=28514&f_flg=PLAN', 'dpPlanListUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/WzozX/?noTomariHotel=28514', 'reviewUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/gJNfM/?f_hotel_no=28514', 'hotelKanaName': 'かしきりおんせんのこんどみにあむ\u3000ぐらんびゅー\u3000あたみ', 'hotelSpecial': '展望露天と貸切風呂が人気♪ノンビリ温泉~カップルや家族で一緒に温泉&熱海で暮らすように泊るならコンドミニアムで決定!', 'hotelMinCharge': 3900, 'latitude': 35.09971685, 'longitude': 139.0755919, 'postalCode': '413-0019', 'address1': '静岡県', 'address2': '熱海市咲見町8-9', 'telephoneNo': '0557-85-0051', 'faxNo': '0557-85-0071', 'access': '熱海駅から徒歩7分!熱海サンビーチまで徒歩3分!熱海滞在に便利な立地で飲食店は周辺に多数あり♪', 'parkingInformation': '有り\u30001泊1台1,000円\u3000要予約\u3000※満車の場合は近隣駐車場をご利用下さい。', 'nearestStation': '熱海', 'hotelImageUrl': 'https://img.travel.rakuten.co.jp/share/HOTEL/28514/28514.jpg', 'hotelThumbnailUrl': 'https://img.travel.rakuten.co.jp/HIMG/90/28514.jpg', 'roomImageUrl': 'https://img.travel.rakuten.co.jp/share/HOTEL/28514/28514_heya1.jpg', 'roomThumbnailUrl': 'https://img.travel.rakuten.co.jp/HIMG/INTERIOR/28514.jpg', 'hotelMapImageUrl': 'https://img.travel.rakuten.co.jp/share/HOTEL/28514/28514map.gif', 'reviewCount': 781, 'reviewAverage': 3.98, 'userReview': '良かった点:朝ごはん(自分で焼く干物、ワサビ丼、等)、展望露天風呂(海が一望)、静か\r\n普通な点:部屋、アメニティ\r\n機会があればまた利用したいです。\u30002019-09-28 08:18:51投稿'}}, {'roomInfo': [{'roomBasicInfo': {'roomClass': 'ns-std-a', 'roomName': '【禁煙和室】8.5畳ミニキッチン・ハ゛ス・トイレ付き', 'planId': 2133417, 'planName': '【長期滞在割引】かけ流し貸切風呂に入れない!展望露天風呂のみ利用OK★訳ありだけど超リーズナブル♪', 'pointRate': 1, 'withDinnerFlag': 0, 'dinnerSelectFlag': 0, 'withBreakfastFlag': 0, 'breakfastSelectFlag': 0, 'payment': '1', 'reserveUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/IdsCY/?f_no=28514&f_syu=ns-std-a&f_hi1=2019-10-20&f_hi2=2019-10-30&f_heya_su=1&f_otona_su=1&f_s1=0&f_s2=0&f_y1=0&f_y2=0&f_y3=0&f_y4=0&f_camp_id=2133417', 'salesformFlag': 0}}, {'dailyCharge': {'stayDate': '2019-10-20', 'rakutenCharge': 4500, 'total': 4500, 'chargeFlag': 0}}]}, {'roomInfo': [{'roomBasicInfo': {'roomClass': 'kado-ns', 'roomName': '【出窓付 禁煙和室】8.5畳ミニキッチン・ハ゛ス・トイレ付', 'planId': 3815670, 'planName': '★オフィスは海の見える熱海です★クリエイター専用プラン♪1名様から部屋籠り大歓迎♪', 'pointRate': 1, 'withDinnerFlag': 0, 'dinnerSelectFlag': 0, 'withBreakfastFlag': 0, 'breakfastSelectFlag': 0, 'payment': '1', 'reserveUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/IdsCY/?f_no=28514&f_syu=kado-ns&f_hi1=2019-10-20&f_hi2=2019-10-30&f_heya_su=1&f_otona_su=1&f_s1=0&f_s2=0&f_y1=0&f_y2=0&f_y3=0&f_y4=0&f_camp_id=3815670', 'salesformFlag': 0}}, {'dailyCharge': {'stayDate': '2019-10-20', 'rakutenCharge': 5000, 'total': 5000, 'chargeFlag': 0}}]}]
[{'hotelBasicInfo': {'hotelNo': 171883, 'hotelName': 'Atami\u3000Ikkyuan', 'hotelInformationUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/pvonD/?f_no=171883', 'planListUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/vFumt/?f_no=171883&f_flg=PLAN', 'dpPlanListUrl': None, 'reviewUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/gJNfM/?f_hotel_no=171883', 'hotelKanaName': 'あたみ\u3000いっきゅーあん', 'hotelSpecial': '当ハウスは純日本建築の古民家を改装しており、ノスタルジックで歴史的な趣のある日本文化を体験できます。', 'hotelMinCharge': 4200, 'latitude': 35.09615738194584, 'longitude': 139.06474309142948, 'postalCode': '413-0016', 'address1': '静岡県', 'address2': '熱海市水口町2-20-17', 'telephoneNo': '090-7171-9565', 'faxNo': '055-919-0102', 'access': '来宮駅より徒歩にて約8分/熱海駅よりお車にて約5分', 'parkingInformation': '4台分有り、\u30001500円(税込み/1泊/1台あたり)\u3000要予約', 'nearestStation': '来宮', 'hotelImageUrl': 'https://img.travel.rakuten.co.jp/share/HOTEL/171883/171883.jpg', 'hotelThumbnailUrl': 'https://img.travel.rakuten.co.jp/HIMG/90/171883.jpg', 'roomImageUrl': None, 'roomThumbnailUrl': None, 'hotelMapImageUrl': 'https://img.travel.rakuten.co.jp/share/HOTEL/171883/171883map.gif', 'reviewCount': None, 'reviewAverage': None, 'userReview': None}}, {'roomInfo': [{'roomBasicInfo': {'roomClass': '01', 'roomName': 'エコノミーダブル', 'planId': 4345139, 'planName': 'スタンダードプラン', 'pointRate': 1, 'withDinnerFlag': 0, 'dinnerSelectFlag': 0, 'withBreakfastFlag': 0, 'breakfastSelectFlag': 0, 'payment': '2', 'reserveUrl': 'https://img.travel.rakuten.co.jp/image/tr/api/re/IdsCY/?f_no=171883&f_syu=01&f_hi1=2019-10-20&f_hi2=2019-10-30&f_heya_su=1&f_otona_su=1&f_s1=0&f_s2=0&f_y1=0&f_y2=0&f_y3=0&f_y4=0&f_camp_id=4345139', 'salesformFlag': 0}}, {'dailyCharge': {'stayDate': '2019-10-20', 'rakutenCharge': 9000, 'total': 9000, 'chargeFlag': 0}}]}]

このようなかたちが見えるとわかりやすいかと思います。

       for hotel in content["hotels"]:
           hotelname = hotel[0]["hotelBasicInfo"]["hotelName"]
           hotelurl = hotel[0]["hotelBasicInfo"]["hotelInformationUrl"]
           msg += "ホテル名:" + hotelname + ", URL:" + hotelurl + "\n"
       msg = msg.rstrip()

hotel名(hotelname)はhotel[0]["hotelBasicInfo"]["hotelName"]で参照し、URL(hotelurl)はhotel[0]["hotelBasicInfo"]["hotelInformationUrl"]で参照することができます。

代入後、LINE BOTから返ってくるメッセージmsgに"ホテル名:(ホテル名), URL:(URL)(改行)"を追加します。

for文の最後のループで挿入される改行コードは不要なので、ループが終わった後にrstrip()により文字列の末尾の空白文字(今回は改行コード\n)を削除しておきます。

def hotel_search(place, checkin, checkout):
   latitude, longitude = geocoding(place)

   url = "https://app.rakuten.co.jp/services/api/Travel/VacantHotelSearch/20170426"
   params = {'applicationId': 'your-application-id',
             'formatVersion': '2',
             'checkinDate': checkin,
             'checkoutDate': checkout,
             'latitude': latitude,
             'longitude': longitude,
             'searchRadius': '3',
             'datumType': '1',
             'hits': '5'}
   try:
       r = requests.get(url, params=params)
       content = r.json()
       error = content.get("error")
       if error is not None:
           msg = content["error_description"]
           return msg
       hotel_count = content["pagingInfo"]["recordCount"]
       hotel_count_display = content["pagingInfo"]["last"]
       msg = place + "の半径3km以内に合計" + str(hotel_count) + "件見つかりました。" + str(hotel_count_display) + "件を表示します。\n"
       for hotel in content["hotels"]:
           hotelname = hotel[0]["hotelBasicInfo"]["hotelName"]
           hotelurl = hotel[0]["hotelBasicInfo"]["hotelInformationUrl"]
           msg += "ホテル名:" + hotelname + ", URL:" + hotelurl + "\n"
       msg = msg.rstrip()
       return msg
   except:
       return "API接続中に何らかのエラーが発生しました"

最後に変数msgをreturnで出力して関数hotel_searchの完成です。

>>> print(hotel_search("熱海", "2019-10-20", "2019-10-30"))
熱海の半径3km以内に合計20件見つかりました。5件を表示します。
ホテル名:新感覚Izuフレンチ懐石 熱海風雅, URL:https://img.travel.rakuten.co.jp/image/tr/api/re/pvonD/?f_no=167693
ホテル名:熱海ホテルパイプのけむり, URL:https://img.travel.rakuten.co.jp/image/tr/api/re/pvonD/?f_no=75234
ホテル名:四季倶楽部 熱海望洋館, URL:https://img.travel.rakuten.co.jp/image/tr/api/re/pvonD/?f_no=142646
ホテル名:貸切温泉のコンドミニアム グランビュー熱海, URL:https://img.travel.rakuten.co.jp/image/tr/api/re/pvonD/?f_no=28514
ホテル名:Atami Ikkyuan, URL:https://img.travel.rakuten.co.jp/image/tr/api/re/pvonD/?f_no=171883

関数hotel_searchに「熱海」「2019-10-20」「2019-10-30」を入力すると上記のような文章が出力されます。

これで「場所」「チェックイン日」「チェックアウト日」を入力するとLINE BOT上で出力するメッセージを出力する関数が完成しました。

3.LINE上で送信した文章を楽天APIに通すためのデータに加工する

空室情報を検索するプログラムは完成しましたが、最終的な目的はLINE上で送信したメッセージを元に検索するシステムです。そこで文章からキーワード(場所、チェックイン日、宿泊日数)を抽出するプログラムを最後に書きます。

from geopy.geocoders import Nominatim
import requests
import re
import datetime

これまでにimportしたgeopyとrequestsに加え、正規表現を扱うreライブラリ、datetimeライブラリをさらにimportします。これらは標準モジュールなのでpipなどでインストールする必要はありません。

文字列からキーワードを抽出

正規表現ライブラリreによりユーザーが入力した文章・文字列からキーワードを抽出するプログラムを書きます。

なお、reや正規表現については以下の記事も参考にしてください。

def extract_words(str):

まず、文字列strからキーワードを抽出する関数extract_wordsを定義します。最終的には、場所名、チェックイン日、チェックアウト日の三つのキーワードを出力することを目標とします。

キーワードの検索

def extract_words(str):
   place_search = re.search('「(.+?)」', str)
   time_search = re.search(r'\d{4}/\d{1,2}/\d{1,2}', str)
   period_search = re.search('\D(\d{1,2})泊', str)

正規表現reライブラリのsearch関数により目的のキーワードが文章に含まれているかを判定します。

以下の条件に合致した場合、それぞれの変数に代入します。
・場所は、"「場所」" → "place_search"
・チェックイン日は、"0000/00/00"  → time_search"
・宿泊日数は、"00泊" → "term_search"

宿泊日数では「任意の半角数字以外の文字\D」と「泊」に囲まれた(半角数字\d)という書き方にしていますが、\Dはつけなくても構いません。
つけることにより、例えば130泊のような三桁の数字が入力されたとき、\Dがある場合はNone、ない場合は「30」を宿泊日数として返すようになります。

エラー処理

続いてキーワードが正しく入力されなかった場合の処理を書きます。

   error_msg = []
   if place_search is None:
       error_msg.append("・場所が入力されていません。鍵括弧「」内に場所を入力してください。")
   if time_search is None:
       error_msg.append("・チェックイン日が入力されていません。XXXX/XX/XXの形式で入力してください。")
   if period_search is None:
       error_msg.append("・宿泊日数が入力されていません。○○泊の形式で泊をつけて、半角数字(最大二桁)で入力してください。")
   if error_msg:
       error_msg = "\n".join(error_msg)
       return error_msg

キーワードが正しく入力されなかった場合は、その旨のエラー文をLINE BOTから返信したいので、まずはエラー文を格納する変数"error_msg"というリストを定義します。

続いて、正規表現にマッチしなかった場合はNoneが返ってくるので、append関数により順番に"error_msg"にエラー文を追加していきます。

最後に、もしどれか一つでもエラーがあった場合(=error_msgが空でない場合)、リスト"error_msg"の要素をjoin関数で改行コード\nにより文字列としてつなぎます。この場合は、"error_msg"をreturnにより出力しここで処理を終了します。

ホテルの情報を変数"msg"に追加していったときは、msg += ...というかたちで書いて最後にrstrip()により不要な改行コードを削除しました。
文字列結合を順に行った方が直感的に書きやすくはありますが、今回のようにリスト化して最後にjoin関数でつなげるとよりスマートに書くことができ、多様な場面に対応することが可能です。

今回の例でリスト化せずにエラー文の間に改行コードを入れたい場合、
if error_msg != "": error_msg += "\n"
という文をそれぞれのif文の直後に追記する必要が生じます。

キーワードを変数に代入

3つの各正規表現にマッチするキーワードがすべてみつかった場合は、最後のif文が実行されずに次の処理に進みます。マッチしたキーワードを変数に代入しましょう。

   place = place_search.group(1)
   time = time_search.group()
   period = period_search.group(1)

それぞれのキーワードをgroup関数で抽出し変数に代入します。場所placeと期間periodは正規表現の()内で囲った文字だけを抽出したいのでgroup(1)と書く必要がある点に注意します。

group(0)だとそれぞれ"「場所名」"、"[任意の文字]○○泊"という鍵括弧や泊などのいらない文字まで抽出されてしまいます。

日付の書式をAPI用に変換

LINE上での日付の入力形式はXXXX/XX/XXと正規表現により規定しましたが、APIの入力形式はXXXX-XX-XXとなっているので、変換する必要があります。

また、APIではチェックイン日とチェックアウト日を入力値として要求されるため、チェックイン日と宿泊日数からチェックアウト日を算出します。

   period = int(period)   

   checkin = datetime.datetime.strptime(time, '%Y/%m/%d')
   checkout = checkin + datetime.timedelta(days=period)
   checkin = checkin.strftime("%Y-%m-%d")
   checkout = checkout.strftime("%Y-%m-%d")

まず1行目で日付の計算を行うために正規表現により抽出した宿泊期間の文字列をint()により数字に変換します。

2行目でdatetimeライブラリのdatetime.strptime関数により、チェックイン日として代入した変数"time"を日付オブジェクトとして認識、変数"checkin"に代入します。

3行目でtimedelta関数により宿泊期間をチェックイン日に加算し、それをチェックアウト日として変数"checkout"に代入します。

4,5行目では日付オブジェクトをXXXX-XX-XXという形式の文字列に変換しています。

def extract_words(str):
   place_search = re.search('「(.+?)」', str)
   time_search = re.search(r'\d{4}/\d{1,2}/\d{1,2}', str)
   period_search = re.search('\D(\d{1,2})泊', str)
   error_msg = []
   if place_search is None:
       error_msg.append("・場所が入力されていません。鍵括弧「」内に場所を入力してください。")
   if time_search is None:
       error_msg.append("・チェックイン日が入力されていません。XXXX/XX/XXの形式で入力してください。")
   if period_search is None:
       error_msg.append("・宿泊日数が入力されていません。○○泊の形式で泊をつけて、半角数字(最大二桁)で入力してください。")
   if error_msg:
       error_msg = "\n".join(error_msg)
       return error_msg
   place = place_search.group(1)
   time = time_search.group()
   period = period_search.group(1)
   period = int(period)

   checkin = datetime.datetime.strptime(time, '%Y/%m/%d')
   checkout = checkin + datetime.timedelta(days=period)
   checkin = checkin.strftime("%Y-%m-%d")
   checkout = checkout.strftime("%Y-%m-%d")
   return place, checkin, checkout

最後に場所"place"、チェックイン日"checkin"、チェックアウト日"checkout"をreturnで出力して、文章から三つのキーワードを抽出する関数extract_words()の完成です。

>>> print(extract_words("2019/10/20に「熱海」で10泊"))
('熱海', '2019-10-20', '2019-10-30')

試しに2019/10/20に「熱海」で10泊という文章を入力してみると、('熱海', '2019-10-20', '2019-10-30')というタプルが出力されます。

4.hotel.pyのまとめ

これで空室検索API関係の処理を主に記述するhotel.pyが完成しました。これまでのコードをまとめると以下のようになります。

from geopy.geocoders import Nominatim
import requests
import re
import datetime

def geocoding(place):
   geolocator = Nominatim(user_agent="my-application")
   location = geolocator.geocode(place, timeout=10)
   if location is None:
       return
   else:
       latitude = location.latitude
       longitude = location.longitude
       return latitude, longitude

def hotel_search(place, checkin, checkout):
   latitude, longitude = geocoding(place)

   url = "https://app.rakuten.co.jp/services/api/Travel/VacantHotelSearch/20170426"
   params = {'applicationId': '1072126216768910111',
             'formatVersion': '2',
             'checkinDate': checkin,
             'checkoutDate': checkout,
             'latitude': latitude,
             'longitude': longitude,
             'searchRadius': '3',
             'datumType': '1',
             'hits': '5'}
   try:
       r = requests.get(url, params=params)
       content = r.json()
       error = content.get("error")
       if error is not None:
           msg = content["error_description"]
           return msg
       hotel_count = content["pagingInfo"]["recordCount"]
       hotel_count_display = content["pagingInfo"]["last"]
       msg = place + "の半径3km以内に合計" + str(hotel_count) + "件見つかりました。" + str(hotel_count_display) + "件を表示します。\n"
       for hotel in content["hotels"]:
           hotelname = hotel[0]["hotelBasicInfo"]["hotelName"]
           hotelurl = hotel[0]["hotelBasicInfo"]["hotelInformationUrl"]
           msg += "ホテル名:" + hotelname + ", URL:" + hotelurl + "\n"
       msg = msg.rstrip()
       return msg
   except:
       import traceback
       traceback.print_exc()
       return "API接続中に何らかのエラーが発生しました"

def extract_words(str):
   place_search = re.search('「(.+?)」', str)
   time_search = re.search(r'\d{4}/\d{1,2}/\d{1,2}', str)
   period_search = re.search('\D(\d{1,2})泊', str)
   error_msg = []
   if place_search is None:
       error_msg.append("・場所が入力されていません。鍵括弧「」内に場所を入力してください。")
   if time_search is None:
       error_msg.append("・チェックイン日が入力されていません。XXXX/XX/XXの形式で入力してください。")
   if period_search is None:
       error_msg.append("・宿泊日数が入力されていません。○○泊の形式で泊をつけて、半角数字(最大二桁)で入力してください。")
   if error_msg:
       error_msg = "\n".join(error_msg)
       return error_msg
   place = place_search.group(1)
   time = time_search.group()
   period = period_search.group(1)
   period = int(period)

   checkin = datetime.datetime.strptime(time, '%Y/%m/%d')
   checkout = checkin + datetime.timedelta(days=period)
   checkin = checkin.strftime("%Y-%m-%d")
   checkout = checkout.strftime("%Y-%m-%d")
   return place, checkin, checkout

最後にこれまで作ってきた関数を組み合わせて、正しく動作するか試してみましょう。以下のようなプログラムにより、入力した文章から空室情報を検索することができます。

>>> text = "2019/10/20に「熱海」で10泊"
>>> results = extract_words(text)
>>> if isinstance(results, tuple):
>>>     msg = hotel_search(*results)
>>> else:
>>>     msg = results
>>> print(msg)

熱海の半径3km以内に合計20件見つかりました。5件を表示します。
ホテル名:新感覚Izuフレンチ懐石 熱海風雅, URL:https://img.travel.rakuten.co.jp/image/tr/api/re/pvonD/?f_no=167693
ホテル名:貸切温泉のコンドミニアム グランビュー熱海, URL:https://img.travel.rakuten.co.jp/image/tr/api/re/pvonD/?f_no=28514
ホテル名:熱海ホテルパイプのけむり, URL:https://img.travel.rakuten.co.jp/image/tr/api/re/pvonD/?f_no=75234
ホテル名:四季倶楽部 熱海望洋館, URL:https://img.travel.rakuten.co.jp/image/tr/api/re/pvonD/?f_no=142646
ホテル名:Atami Ikkyuan, URL:https://img.travel.rakuten.co.jp/image/tr/api/re/pvonD/?f_no=171883

まず適当な変数"text"に文章を代入、それをextract_words関数に入力して出力された結果を"results"に代入します。

三つのキーワードが取得できた場合は、タプル形式で出力されるので、それをhotel_search関数に入力し、出力された結果を変数"msg"に代入します。

引数の先頭に*をつけることにより、タプルやリストを展開して関数に渡すことができます。

タプル形式でない場合は、エラー文が出力されているはずなのでそれをそのまま"msg"に代入します。

最後のprint文により正しく空室情報のメッセージが得られたことがわかります。

このデバッグ文は次のmain.pyを書くときにほとんどそのまま使うので覚えておきましょう。

LINE BOTの動作部分を作る(main.py)

次にLINE BOT本体の部分を作っていきます。この部分はLINE公式からサンプルプログラムが用意されているので、それをもとにつくっていくことになります。

1.サンプルプログラムを改良して基本部分を作る

改良サンプルコードを作るまでは、以下の基礎編のサイトで解説していますので、詳しくは、そちらをご覧ください。


改良サンプルコードのまとめ(テンプレコード)

from flask import Flask, request, abort

from linebot import (
   LineBotApi, WebhookHandler
)
from linebot.exceptions import (
   InvalidSignatureError
)
from linebot.models import (
   MessageEvent, TextMessage, TextSendMessage,
)

import os

app = Flask(__name__)

YOUR_CHANNEL_ACCESS_TOKEN = os.environ["YOUR_CHANNEL_ACCESS_TOKEN"]
YOUR_CHANNEL_SECRET = os.environ["YOUR_CHANNEL_SECRET"]

line_bot_api = LineBotApi(YOUR_CHANNEL_ACCESS_TOKEN)
handler = WebhookHandler(YOUR_CHANNEL_SECRET)

@app.route("/")
def hello_world():
   return "hello world!"

@app.route("/callback", methods=['POST'])
def callback():
   # get X-Line-Signature header value
   signature = request.headers['X-Line-Signature']

   # get request body as text
   body = request.get_data(as_text=True)
   app.logger.info("Request body: " + body)

   # handle webhook body
   try:
       handler.handle(body, signature)
   except InvalidSignatureError:
       print("Invalid signature. Please check your channel access token/channel secret.")
       abort(400)

   return 'OK'

@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):
   line_bot_api.reply_message(
       event.reply_token,
       TextSendMessage(text=event.message.text))

if __name__ == "__main__":
   port = int(os.getenv("PORT"))
   app.run(host="0.0.0.0", port=port)

改良したサンプルコードのまとめです。このままコピペすればオウム返しするLINE BOTになりますし、どんなLINE BOTを作る際にもこのコードをもとにプログラムを書いていくことになります。

2.空室検索のLINE BOTを作る

今回の空室検索に合わせて上記のコードを改変していくことになります。LINE BOTを開発するうえで改変するのは基本的に以下の二つの部分だけになります。

インポート文の変更

from flask import Flask, request, abort

from linebot import (
   LineBotApi, WebhookHandler
)
from linebot.exceptions import (
   InvalidSignatureError
)
from linebot.models import (
   MessageEvent, TextMessage, TextSendMessage,
)

import os
import hotel

インポートするライブラリを記述します。今回は、hotel.pyという外部ファイルを作成したので、"import hotel"と書いてhotel.pyをインポートします。外部ファイルを作らない場合やmain.pyでも追加のライブラリを使用する場合は、import文を適宜追記しましょう。

from linebot.models import (
   MessageEvent, TextMessage, TextSendMessage,
)

なお、linebot.modelsのimport文は利用するLINE BOTの機能に合わせて追記・修正していくことになります。今回はMessageEvent, TextMessage, TextSendMessageの三つですが、例えば画像メッセージを利用したい場合などは、"ImageMessage"を追記する必要があります。

LINE BOTの応答部分

@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):
   push_text = event.message.text
   results = hotel.extract_words(push_text)
   if isinstance(results, tuple):
       msg = hotel.hotel_search(*results)
   else:
       msg = results
   line_bot_api.reply_message(event.reply_token,TextSendMessage(text=msg))

根幹部分ですが、今回はhotel.pyでほとんど作成・解説しているのでまとめてコードを載せてしまいます。

1行目の@handler.add(MessageEvent, message=TextMessage)はテキストメッセージを受け取った場合、という意味になります。

@handler.addから始まる部分はif...elif文のようなイメージで場合分けして書くもので、例えば画像メッセージを受け取った場合の処理を分けたいなら、
    @handler.add(MessageEvent, message=ImageMessage)
と、別に書く必要があります。
テキストも画像もまとめて処理をしたいなら
    @handler.add(MessageEvent, message=(TextMessage, ImageMessage)
と書くことができます。

event.message.textでユーザーから受け取ったメッセージを取得することができます。これを変数"push_text"に代入します。

今回のように単なるテキストのやり取りのときは、ほとんどevent.message.text以外使いませんが、他にもevent.message.typeでメッセージタイプ(今回は"text"、画像なら"image")、event.message.idでメッセージIDを取得することなどができます。

その後はhotel.pyで解説したとおりで、extract_words()でメッセージからキーワードを抽出、キーワードが抽出できたらhotel_search()で空室検索を実行しメッセージを返す、抽出できなかったらエラー文を返す、という流れになっています。

最後の行のline_bot_api.reply_messageはLINE BOTから返信を行う場所であり、TextSendMessage(text=msg)として変数msgをテキストとして返信するという意味になっています。

画像をLINEから返信したいというような場合は、
   line_bot_api.reply_message(event.reply_token,ImageSendMessage())
と、TextSendMessageではなくImageSendMessageを利用します。

テキストのやり取りだけではなく様々な機能を使いたい場合は、LINE Messaging APIのコード例を詳しく解説している記事をご覧ください。


3.main.pyのまとめ

from flask import Flask, request, abort

from linebot import (
   LineBotApi, WebhookHandler
)
from linebot.exceptions import (
   InvalidSignatureError
)
from linebot.models import (
   MessageEvent, TextMessage, TextSendMessage,
)

import os
import hotel

app = Flask(__name__)

YOUR_CHANNEL_ACCESS_TOKEN = os.environ["YOUR_CHANNEL_ACCESS_TOKEN"]
YOUR_CHANNEL_SECRET = os.environ["YOUR_CHANNEL_SECRET"]

line_bot_api = LineBotApi(YOUR_CHANNEL_ACCESS_TOKEN)
handler = WebhookHandler(YOUR_CHANNEL_SECRET)

@app.route("/")
def hello_world():
   return "hello world!"

@app.route("/callback", methods=['POST'])
def callback():
   # get X-Line-Signature header value
   signature = request.headers['X-Line-Signature']

   # get request body as text
   body = request.get_data(as_text=True)
   app.logger.info("Request body: " + body)

   # handle webhook body
   try:
       handler.handle(body, signature)
   except InvalidSignatureError:
       print("Invalid signature. Please check your channel access token/channel secret.")
       abort(400)

   return 'OK'

@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):
   push_text = event.message.text
   results = hotel.extract_words(push_text)
   if isinstance(results, tuple):
       msg = hotel.hotel_search(*results)
   else:
       msg = results
   line_bot_api.reply_message(event.reply_token,TextSendMessage(text=msg))

if __name__ == "__main__":
   port = int(os.getenv("PORT"))
   app.run(host="0.0.0.0", port=port)

コードの解説は以上になります(^_-)-☆

事前準備(サーバー設定、Web APIの登録)

ソースコードが完成したら次に、実行するために、サーバーの設定やWeb APIの登録を行っていきます。

①LINEのAPI登録
②herokuの登録
③楽天のAPI登録
④GitHub

の順に紹介していきます。

①LINEのAPI登録
まず、以下開発者用のサイトからアカウント登録します。

アカウント作成後、「新規チャンネル作成」から、「Messaging API」を選択して、チャンネル登録します。
LINEのAPIでは様々なことができますが、Messaging APIでは簡単にBOTを作成することができます。

画像6

登録後、Heroku(サーバー)に、環境変数を渡す際、次の2点を上記「2.環境変数」で解説した部分に代入します。

・Channel Secret
・アクセストークン

LINE側の設定は以上で完了です(^^

②Herokuの登録
次に、herokuのサイトからサーバーの新規登録を行います。フローをシンプルにする為、こちらは楽天APIの前にやっておくことをオススメします(`・ω・´)

アカウント登録後、以下の画面からアプリ名の登録を行います。

画像7

“App name”の欄に任意のアプリ名を入力して、”Create app”から新しいアプリ登録を行いましょう(choose a regionは日本がないかも知れませんので、その場合はUnited Statesのままでもかまいません)。なおここで設定したアプリ名は、後に楽天APIで設定する『アプリURL』でそのまま使用することになります。念のため、メモっといた方がいいかもしれません。

登録後、サーバー側にLINEの環境変数を渡すために、先ほどのLINEで取得したChannel Secretとアクセストークンを登録します。

画像8

右上の”Settings”を開き、”Config Vars”の”Reveal Config Vars”を選択します。そうするとKeyとValueが入力できますので、以下のように入力しましょう。

YOUR_CHANNEL_ACCESS_TOKEN:取得したアクセストークンの値
YOUR_CHANNEL_SECRET:取得したChannnel Secretの値

左がKeyで、右がValueだよ(^_-)-☆
KeyはこれじゃなくてもOKだけど、LINEが用意している変数に合わせてるんで、このままにしておくのがおススメ!(^^)!

なお補足になりますが、Keyとvalueを組み合わせたデータのことを辞書型と呼びます!Keyを入れるとValueを引っ張ってきてくれるよーということですね。よく出てくる言葉ですのでこの機会に覚えておきましょう('ω')ノ

③楽天のAPI登録
次のRakuten Developersという開発者向けのサイトにアクセスして、右上の「+ アプリID発行」からIDを発行してください。

利用するには楽天のアカウントが必要!
なので、持ってないときは、新規に作る必要があるかな...(*'▽')

画像9

アプリ名は任意でかまいませんが、アプリURLは先ほどherokuで登録した”App name”と同じものををご入力ください。

アプリURLは”herokuapp.com”にアプリ名がサブドメインという形で入ることになります。つまり、
https://●●●●●.herokuapp.com/

●●●●●の部分にherokuのアプリを登録した時入力した”App name”が入ることになります。

登録完了後、19桁のアプリID/デベロッパーIDが発行されます。
このIDを今後利用するので、こちらもメモっておきましょう。

④GitHubの登録
最後にGitHubです。今回はGitHubでソースコードを管理していますので、GitHub上にリポジトリーを作成してソースコードをアップする形になります。


以上すべてのファイルをアップロードして実行すると、「みゃふの空室検索アプリ」が完成します( *´艸`)

利用者の環境に依存する部分や、PythonのバージョンやAPIの仕様変更など、様々な要因で動かない場合もあります。特にAPIの仕様変更は時々あるため、あれ上手く動かない?と思った方は各APIの公式ドキュメントをチェックする必要があるかも知れません…
こちらでも最大限コードのメンテナンスをしていきますので、ご了承いただけますと幸いですm(__)m

いかがでしょうか?
入力するパラメータを変えることで好きな検索の仕様を変えることができます。
このようにPythonは汎用性が高く、それほど難しくないコードで生活の役に立つ便利なプログラミングを作れるのが嬉しいところ(^^♪
これからもこんな感じで『基本的なPythonの知識があれば作れる』アプリなどを紹介していくので、ぜひ皆さんの手でもっとPythonを身近に活用していってください=^_^=

この記事が気に入ったらサポートをしてみませんか?