こんにちは!26卒の阿部です!
私は大阪勤務なのですが、度々東京に出張する機会があります。
出張するたびにホテルを予約しないといけないわけですが、東京の土地に詳しくない私はホテル予約に少し時間がかかってしまいます。
そんな面倒な作業から解放されるべく、今回は空室ホテルを自動探索させるAIエージェント(空室検索エージェント)を作成しました。
空室検索エージェントができること
今回私が作成した空室検索エージェントの流れは以下の通りです↓
エージェントの流れ
[ユーザーの入力] "虎ノ門ヒルズ駅、1泊14000円、9/24〜9/25"
▼
【STEP 1】 周辺駅の特定
│ └─ 基準地(虎ノ門ヒルズ駅)から電車で約15分以内の駅リストを取得
▼
【STEP 2】 空室状況 & 料金の照会
│ └─ 楽天トラベルAPIへリクエスト
▼
【STEP 3】 データ整形 & フィルタリング
│ └─ レスポンス(JSON)からプラン・最安値・アクセス情報を抽出
▼
[エージェントの回答] 条件に合致するホテルの一覧と予約ページのURLを整理して提示
ユーザーの操作としては駅名、上限金額、チェックイン日/チェックアウト日の形式で条件を指定してエージェントに送るだけです。
例えば虎ノ門ヒルズ駅、1泊14000円、9/24〜9/25とエージェントに送れば、9/24〜9/25に虎ノ門ヒルズ駅から電車で約15分以内の1泊14000円以内で宿泊できるホテルの予約ページURLがエージェントから返ってきます。
今回のエージェントは追加で以下の条件を設定しています。
- ホテル検索は楽天トラベル
- 検索ホテルはアパホテルに限定(よく使用するため)
開発環境
OS:macOS 26.6.2
Python:3.12
adk:2.6.2
Gemini:gemini-2.5-flash
ADK構築
前提環境: macOS、Homebrewでインストールした Python 3.12、Google Cloud CLI インストール&認証済み
# 1. 作業ディレクトリを作成
mkdir agents && cd agents
# 2. 仮想環境を作成・有効化
python3.12 -m venv .venv
source .venv/bin/activate
# 3. ADKをインストール
pip install google-adk
# 4. エージェントの雛形を作成
adk create my_agent
これらの作業が終わったら、下記のコマンドでエージェントを起動して動作確認し、ブラウザが立ち上がればOKです
# エージェントを起動して動作確認
cd agents
adk web
エージェント実装
要件整理
実装前に要件をざっくりとですが、洗い出してみました。
- ユーザーの入力は
駅名、上限金額、チェックイン日/チェックアウト日のみ - 入力した駅名から、電車で15分以内の駅の近くにある&空室があるホテルを探す
- エージェントの回答にはホテル予約ページのURLが含まれる
- アパホテルに絞って検索
以上が今回の要件です。
電車で15分以内の駅検索や、条件通りのホテル検索はGeminiのみでは対応できないので、APIを使用して検索を行っていこうと思います。
使用API(無料)
- HeartRails Express API(15分以内の駅検索)
- 楽天トラベルAPI(ホテル検索、空室確認、URL取得、アパホテル限定フィルタ)
※本記事で紹介しているAPI連携は、個人学習・検証を目的として実施したものです。商用利用や公開サービス化を行う際は、各APIの利用規約およびデータ利用ガイドラインを必ずご確認ください。
この2つのAPIを使用して検索を行っていくことにしました。
アパホテルはAPIサービスを行っていなかったため、今回は楽天トラベルからアパホテルで検索を絞って探していきます。
駅データ取得
ここからはエージェントを作成していきます。
処理の概要と特徴
- API連携:HeartRails Express APIのgetStationsメソッドで駅情報および路線情報を取得。
- 簡易的な時間近似 : 1駅あたり2〜3分と仮定し「電車で約15分以内」と見なして抽出。
- 重複削除とデータ整形: 重複を排除した起点駅・周辺駅の座標情報を保持し、search_apa_hotelsツール関数へ直接渡せる形式で返却。
上記の処理概要を踏まえた、実際の駅データ取得関数のコードは以下のようになります。
駅データ取得関数(クリックで展開)
#### tools.py
<br />def find_nearby_stations(station_name: str, max_stops: int = 5) -> dict:
"""出発駅から、同じ路線上でmax_stops駅以内にある駅の一覧を路線ごとに返す。
HeartRails Express API (無料・登録不要) を利用する。実際の所要時間を計算する
API ではないため、「1駅あたり平均2〜3分」とみなした簡易的な近似(ヒューリス
ティック)として駅数で絞り込む。急行/各駅停車の別や乗り換えは考慮していない
ため、あくまで目安として扱うこと。
Args:
station_name: 出発駅名 (例: "新宿")
max_stops: 起点から何駅以内を「電車で約15分以内」とみなすか (デフォルト5駅)
Returns:
路線名をキー、その路線上でmax_stops駅以内にある駅名のリストを値とする辞書
(nearby_stations_by_line)、および重複を除いた駅名+緯度経度のリスト
(nearby_stations、search_apa_hotels にそのまま渡せる形式)を返す。
駅が見つからない場合は "error" キーにメッセージを含む辞書を返す。
"""
station_name = station_name.strip()
if station_name.endswith("駅"):
station_name = station_name[:-1]
try:
resp = requests.get(
HEARTRAILS_API,
params={"method": "getStations", "name": station_name},
timeout=10,
)
resp.raise_for_status()
data = resp.json()
except requests.RequestException as e:
return {"error": f"駅情報の取得に失敗しました: {e}"}
stations = data.get("response", {}).get("station")
if not stations:
return {"error": f"駅「{station_name}」が見つかりませんでした。"}
lines = sorted({s["line"] for s in stations})
nearby_by_line = {}
nearby_flat = {
station_name: {
"name": station_name,
"latitude": stations[0]["y"],
"longitude": stations[0]["x"],
}
}
for line in lines:
try:
line_resp = requests.get(
HEARTRAILS_API,
params={"method": "getStations", "line": line},
timeout=10,
)
line_resp.raise_for_status()
line_data = line_resp.json()
except requests.RequestException:
continue
finally:
time.sleep(0.5) # HeartRails APIへの連続リクエスト対策
line_stations = line_data.get("response", {}).get("station", [])
names = [s["name"] for s in line_stations]
if station_name not in names:
continue
idx = names.index(station_name)
start = max(0, idx - max_stops)
end = min(len(names), idx + max_stops + 1)
nearby_entries = [s for s in line_stations[start:end] if s["name"] != station_name]
if nearby_entries:
nearby_by_line[line] = [s["name"] for s in nearby_entries]
for s in nearby_entries:
nearby_flat[s["name"]] = {
"name": s["name"],
"latitude": s["y"],
"longitude": s["x"],
}
if not nearby_by_line:
return {"error": f"駅「{station_name}」周辺の駅情報を取得できませんでした。"}
return {
"origin_station": station_name,
"note": (
"駅数は所要時間の簡易的な目安(1駅あたり約2〜3分と仮定)であり、"
"実際の所要時間とは異なる場合があります。"
),
"nearby_stations_by_line": nearby_by_line,
"nearby_stations": list(nearby_flat.values()),
}
楽天トラベルAPI連携
処理の概要と特徴
- API連携: 楽天トラベルの空室検索APIを、駅の緯度経度・検索半径・チェックイン/チェックアウト日・上限金額を指定して呼び出す。
- 絞り込み: 取得したホテルのうち、ホテル名に「アパホテル」を含むものだけを抽出。
- 実料金の抽出: レスポンスに含まれる参考価格ではなく、指定日程の実際の料金から予約URLを取得。
- エラーハンドリング: 空室0件を示すnot_foundは正常な結果として扱い、それ以外のAPIエラーのみ呼び出し元に伝える。
空室0件のハンドリングやアパホテルの絞り込みなど、これらの要件を実装したコードがこちらです。
楽天APIを使った空室検索関数(長いので折りたたみ)
#### tools.py
<br />def search_apa_hotels(
stations: list[dict],
checkin_date: str,
checkout_date: str,
max_charge: int,
) -> dict:
"""楽天トラベルの空室検索API(VacantHotelSearch)を使い、指定した駅の周辺
(半径1km)にあるアパホテルのうち、指定日程で実際に空室があり、1泊あたりの
上限金額以内の物件を検索する。
駅ごとに緯度経度を中心とした円形検索を行い、ホテル名に「アパホテル」を
含む結果だけに絞り込んで統合する。RAKUTEN_APP_ID と RAKUTEN_ACCESS_KEY
(楽天ウェブサービスの無料アプリID・アクセスキー)が必要。
Args:
stations: 検索対象の駅のリスト。各要素は
{"name": str, "latitude": float, "longitude": float} の辞書。
find_nearby_stations が返す "nearby_stations" をそのまま渡せる。
checkin_date: チェックイン日 ("YYYY-MM-DD" 形式)
checkout_date: チェックアウト日 ("YYYY-MM-DD" 形式)
max_charge: 1泊あたりの上限金額(円)。実際の指定日程の料金に対して
楽天API側で絞り込まれる。
Returns:
見つかったアパホテルのリスト(ホテル名・実際の料金・住所・アクセス・
予約URL)。RAKUTEN_APP_ID/RAKUTEN_ACCESS_KEY が未設定、またはAPI呼び出し
に失敗した場合は "error" キーを含む辞書を返す。
"""
app_id = os.environ.get("RAKUTEN_APP_ID")
access_key = os.environ.get("RAKUTEN_ACCESS_KEY")
if not app_id or not access_key:
return {
"error": (
"RAKUTEN_APP_ID と RAKUTEN_ACCESS_KEY の両方が必要です。"
".env に追加してください。"
)
}
seen_hotel_no = set()
hotels = []
api_errors = []
for station in stations[:8]:
if (
not isinstance(station, dict)
or "latitude" not in station
or "longitude" not in station
):
return {
"error": (
"stations の各要素は "
'{"name": str, "latitude": float, "longitude": float} '
"の辞書である必要があります。find_nearby_stations の結果の "
"nearby_stations をそのまま渡してください。"
)
}
try:
resp = requests.get(
RAKUTEN_VACANT_HOTEL_SEARCH_API,
params={
"applicationId": app_id,
"accessKey": access_key,
"format": "json",
"formatVersion": 2,
"latitude": station["latitude"],
"longitude": station["longitude"],
"searchRadius": 1,
"datumType": 1,
"checkinDate": checkin_date,
"checkoutDate": checkout_date,
"maxCharge": max_charge,
"hits": 30,
},
timeout=10,
)
data = resp.json()
except requests.RequestException as e:
api_errors.append(f"{station['name']}: リクエスト失敗 ({e})")
continue
if data.get("error") == "not_found":
# この駅周辺には該当する空室が0件だっただけの正常な応答。
continue
if "error" in data:
api_errors.append(
f"{station['name']}: {data.get('error')} - {data.get('error_description')}"
)
continue
if "errors" in data:
err = data.get("errors", {})
api_errors.append(
f"{station['name']}: {err.get('errorCode')} - {err.get('errorMessage')}"
)
continue
for hotel_entry in data.get("hotels", []):
if not hotel_entry:
continue
info = hotel_entry[0].get("hotelBasicInfo", {})
hotel_name = info.get("hotelName", "")
if "アパホテル" not in hotel_name:
continue
hotel_no = info.get("hotelNo")
if not hotel_no or hotel_no in seen_hotel_no:
continue
seen_hotel_no.add(hotel_no)
charge, reserve_url = _min_room_charge_and_url(hotel_entry)
hotels.append(
{
"hotel_name": hotel_name,
"charge_for_requested_dates": charge,
"address": f"{info.get('address1', '')}{info.get('address2', '')}",
"access": info.get("access"),
"hotel_url": info.get("hotelInformationUrl"),
"reserve_url": reserve_url,
"nearest_search_station": station["name"],
}
)
time.sleep(1) # 楽天APIのレート制限対策(1リクエスト/秒)
hotels.sort(key=lambda h: h.get("charge_for_requested_dates") or float("inf"))
if not hotels:
if api_errors:
return {
"hotels": [],
"error": "楽天トラベルAPIの呼び出しに失敗しました: " + " / ".join(api_errors),
}
return {
"hotels": [],
"note": (
"指定した駅周辺・条件・日程では空室のあるアパホテルが見つかりません"
"でした。駅の範囲を広げるか、予算・日程を見直すことを検討してください。"
),
}
return {"hotels": hotels, "searched_stations": [s["name"] for s in stations[:8]]}
実際の料金を抽出する補助関数(長いので折りたたみ)
#### tools.py
<br />def _min_room_charge_and_url(hotel_entry: list) -> tuple[int | None, str | None]:
"""hotel_entry内の各プランのdailyCharge(実際の指定日程の料金)から最安値と予約URLを取得する。"""
min_charge = None
reserve_url = None
for part in hotel_entry[1:]:
room_info = part.get("roomInfo", [])
charge = None
url = None
for item in room_info:
if "dailyCharge" in item:
charge = item["dailyCharge"].get("total")
if "roomBasicInfo" in item:
url = item["roomBasicInfo"].get("reserveUrl")
if charge is not None and (min_charge is None or charge < min_charge):
min_charge = charge
reserve_url = url
return min_charge, reserve_url
プロンプト設計(エージェント定義)
- 入力の解釈ルールを明示: 「渋谷駅、1泊14000円、9/21〜9/22」等の短文から、聞いて戻ってきた駅名・予算・宿泊日の3項目を直接考えるルールをプロンプトに記入。
- ツール呼び出しの手順を固定: find_nearby_stations → search_apa_hotels という実行順序と、各ツールへ渡す引数の変換ルール(日付はYYYY-MM-DD形式にする、nearby_stationsをそのまま渡す、など)を明記。
- ハルシネーション防止: 「ツールを使わずに推測や記憶だけで空室状況・料金を答えることを禁止」と明示し、必ずツールの実行結果に基づいて回答するよう指示。
- 出典の明記: 回答には楽天トラベル経由の情報である旨を必ず含めるよう指示。
Geminiが正しくツールを呼び出せるよう、上記のルールを言語化したプロンプト(エージェント定義)は以下の通りです。
エージェント定義(長いので折りたたみ)
#### agent.py
<br />import datetime
from google.adk.agents.llm_agent import Agent
from .tools import find_nearby_stations, search_apa_hotels
root_agent = Agent(
model="gemini-2.5-flash",
name="root_agent",
description="出発駅・予算・宿泊日をもとに、電車15分圏内のアパホテルを探す手助けをするアシスタント。",
instruction=f"""
あなたは宿泊先探しをサポートするアシスタントです。
今日の日付は {datetime.date.today().isoformat()} です。ユーザーが年を指定せずに
日付(例: "10/21〜10/22")を伝えてきた場合、今日の日付以降で直近に来る日付を採用
してください(すでに今日以降にその月日が来ない場合は翌年とみなす)。今日より前の
年を勝手に補完しないでください。
まずユーザーから次の3つの情報を聞き出してください。
1. 出発駅名(この駅から電車で移動する)
2. 宿泊予算の上限(1泊あたりの金額)
3. 宿泊日(チェックイン日・チェックアウト日)
ユーザーは長い文章ではなく「渋谷駅、1泊14000円、9/21〜9/22」のように、駅名・予算・
宿泊日を読点(、)やカンマ区切りで短く送ってくる場合があります。この形式は正式な
入力として扱い、聞き返さずにそのまま3項目を読み取ってください。解釈ルールは次の
通りです。
- 駅名: 「〜駅」の表記があってもなくても駅名として扱う。
- 予算: 「8000円」「1泊8000円」のような表記は1泊あたりの上限金額として扱う。
- 宿泊日: 「8/31〜9/1」「8/31-9/1」のような表記は、前者をチェックイン日、後者を
チェックアウト日とする。年の指定がなければ直近の未来の日付として扱う。
3項目が一度に全て送られてきた場合は、追加の質問はせずそのまま次のステップ
(find_nearby_stations の呼び出し)に進んでください。3項目のいずれかが不足して
いる場合のみ、不足分を聞き返してください。
3つ揃ったら find_nearby_stations ツールを使い、出発駅から電車で概ね15分以内に
ある駅を洗い出してください(デフォルトのmax_stops=5で呼び出せば十分です)。
このツールは実測の所要時間ではなく駅数による簡易的な近似である点を、
ツールが返す note の内容も踏まえてユーザーに正直に伝えてください。
続けて search_apa_hotels ツールを使い、実際に空室があり予算内のアパホテルを
検索してください。
- stations には、find_nearby_stations の結果の "nearby_stations"(駅名+緯度経度
のリスト)をそのまま渡すこと。
- checkin_date / checkout_date は "YYYY-MM-DD" 形式に変換して渡すこと。年の
指定がなければ直近の未来の日付とみなす。
- max_charge には、ユーザーから聞いた1泊あたりの上限金額をそのまま渡すこと。
この金額は指定日程の実際の料金でのフィルタに使われる。
search_apa_hotels の結果に基づいて、ホテル名・実際の料金(charge_for_requested_dates)
・住所・アクセス・予約URLをユーザーに提示してください。該当するホテルが見つから
なかった場合は、駅の範囲を広げる(max_stopsを増やして再検索する)か予算・日程を
見直すことを提案してください。回答は必ず楽天トラベル経由の情報である旨を明記して
ください。
ホテルの空室状況や料金を、ツールを使わずに推測や記憶だけで答えることは禁止します。
必ず search_apa_hotels ツールの実行結果に基づいて回答してください。
""",
tools=[find_nearby_stations, search_apa_hotels],
)
ホテル空室検索エージェントを使ってみた
最後に、今回作成したホテル空室検索エージェントを使ってみます。
入力テキスト: 虎ノ門ヒルズ駅、1泊14000円、9/24〜9/25

アパホテルに絞られ、予約可能なホテルの候補が出てきました。
だいたい電車で15〜20分以内に行ける駅のホテルが出ています。
予約URLは正しいものなのかみていきましょう。
一番上のアパホテル(西麻布)の予約リンクをクリックしてみます。


指定した日付の予約URLが貼られていることがわかります。
間違いがなければこのまま予約しちゃいましょう。(今回は出張予定がないので予約しません)
ホテル空室検索エージェントを使ってみたの検証は以上です。
まとめ
今回はホテルの空室検索エージェントを作成しました。
アパホテルには公式APIがなく、今回は楽天トラベルAPI経由での検索という形になりましたが、今後アパホテルが公式APIを提供してくれることを期待しています。そうなればもっとシンプルなエージェントが作れたかなと思いました。
大変なことも多かったですが、AIエージェントを実際に作ってみることで、駅探し・宿探しという作業を自動化する体験ができ、作業の効率化を実感できました。