🚃

旅行アプリ開発記録③ Google Directions API:transitの代替

に公開

課題

Google Directions APIはいわゆるルート検索のAPIです。車だったり、徒歩でもルート検索は実行可能なのですが、実はtransit(電車)モードが日本だけは対象外になっています。(2025/8現在)
APIを実行するとZERO RESULTS(結果なし)が帰ってきます。

そこで半ば強引ではありますが..代替としてLLM(GPT)で推論させて提案する形式にしました。
全体はこちらを参照ください。

Google以外のAPIはないのか?

結論、調べた限り代替APIは無いです。駅すぱあと等ありますが、正直高すぎて個人開発レベルで使えるものではないと思います。

サービス名 提供形式 主な機能 価格例 備考
ジョルダン(Jorudan) Web/アプリ/有償API 時刻表・乗り換え・運賃計算(座席別)表示 要問合せ 法人必要
NAVITIME Web/アプリ/法人向けAPI 経路・時刻表・運賃・JRパス対応・タクシー料金 要法人契約 法人必要
駅すぱあとAPI 法人向けWeb API 経路検索・運賃・定期代・乗り換え・多言語対応 5,000件:¥5,500~ 従量制サブスクリプション等あり
Google Maps Directions API API 経路検索(徒歩/車) - 日本の電車非対応

代替手段;LLMで補正する

◆駅検出 + AI路線推論

  • 現在地や観光候補から最寄り駅をGoogleMaps API:nearbyで出す
async def get_nearest_station_enhanced(session, lat, lng, places_api_key, max_distance_meters=1000):
    """
    指定座標から最寄りの駅をGoogle Places API Nearby Searchで取得
    max_distance_meters以内の徒歩アクセス可能な駅のみ返す
    """
    url = "https://places.googleapis.com/v1/places:searchNearby"
    headers = {
        "Content-Type": "application/json",
        "X-Goog-Api-Key": places_api_key
    }
    payload = {
        "includedTypes": ["train_station"],
        "maxResultCount": 1,
        "locationRestriction": {
            "circle": {
                "center": {"latitude": lat, "longitude": lng},
                "radius": max_distance_meters
            }
        }
    }
    async with session.post(url, headers=headers, json=payload) as resp:
        data = await resp.json()
        if "places" in data and len(data["places"]) > 0:
            station = data["places"][0]
            return {
                "name": station["displayName"]["text"],
                "lat": station["location"]["latitude"],
                "lng": station["location"]["longitude"]
            }
    return None
  • 各スポット及び現在地と最寄り駅の距離を出し、徒歩圏外のスポットは排除する
    (現在地から駅が遠ければ、そもそも徒歩圏内の旅行プランしか提案しないようにします。)
# 徒歩圏判定+スポット絞り込み
# - 現在地→最寄り駅が遠い(例: 1km以上)なら「徒歩プランのみ」に切り替え
# - それ以外は「最寄り駅→スポット」または「現在地→スポット」が徒歩圏内のスポットだけ残す
# - 駅ごとにグルーピング(同じ駅圏内は徒歩回遊前提)

WALK_RADIUS_KM = 1.0  # 徒歩圏のしきい値(約12分/1km想定)

def haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
    R = 6371.0
    dlat = math.radians(lat2 - lat1)
    dlng = math.radians(lng2 - lng1)
    a = math.sin(dlat/2)**2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlng/2)**2
    c = 2 * math.atan2(math.sqrt(a), math.sqrt(1-a))
    return R * c

async def get_nearest_station_enhanced(
    session: aiohttp.ClientSession,
    lat: float,
    lng: float,
    api_key: str,
    radius_m: int = 2000
) -> Optional[Dict[str, Any]]:
    """Places API Nearby Searchで最寄り駅を取得(train/subway/transitを包括)"""
    url = "https://places.googleapis.com/v1/places:searchNearby"
    headers = {
        "Content-Type": "application/json",
        "X-Goog-Api-Key": api_key,
        "X-Goog-FieldMask": "places.id,places.displayName,places.location"
    }
    payload = {
        "locationRestriction": {"circle": {"center": {"latitude": lat, "longitude": lng}, "radius": radius_m}},
        "includedTypes": ["train_station", "subway_station", "transit_station"],
        "languageCode": "ja",
        "maxResultCount": 5
    }
    async with session.post(url, json=payload, headers=headers) as resp:
        data = await resp.json()
        if data.get("places"):
            p = data["places"][0]
            return {
                "name": p.get("displayName", {}).get("text", ""),
                "lat": p.get("location", {}).get("latitude"),
                "lng": p.get("location", {}).get("longitude"),
                "place_id": p.get("id")
            }
    return None

async def filter_spots_by_walkability(
    origin: Tuple[float, float],
    spots: List[Dict[str, Any]],
    api_key: str
) -> Dict[str, Any]:
    """
    引数:
      origin: (lat, lng)
      spots: [{"name": str, "lat": float, "lng": float}, ...]
    返り値:
      {
        "only_walking_plan": bool,
        "origin_station": Optional[dict],
        "kept_spots": [spot, ...],          # 残したスポット
        "excluded_spots": [spot, ...],      # 徒歩圏外で除外
        "groups": {                          # 駅ごとグループ
            "駅名": {
                "station": {...},
                "spots": [spot, ...]
            },
            ...
        }
      }
    """
    o_lat, o_lng = origin
    async with aiohttp.ClientSession() as session:
        # 1) 現在地の最寄り駅
        origin_station = await get_nearest_station_enhanced(session, o_lat, o_lng, api_key)
        only_walking_plan = False

        # 現在地→最寄り駅が徒歩圏外なら、徒歩プランのみ(駅は使わない)
        if not origin_station:
            only_walking_plan = True
        else:
            dist_origin_to_station = haversine_km(o_lat, o_lng, origin_station["lat"], origin_station["lng"])
            if dist_origin_to_station >= WALK_RADIUS_KM:
                only_walking_plan = True

        kept, excluded = [], []
        groups: Dict[str, Dict[str, Any]] = {}

        if only_walking_plan:
            # 2-a) 徒歩プラン: 現在地→スポット が徒歩圏内だけ残す
            for s in spots:
                d = haversine_km(o_lat, o_lng, s["lat"], s["lng"])
                (kept if d <= WALK_RADIUS_KM else excluded).append(s)
            return {
                "only_walking_plan": True,
                "origin_station": None,
                "kept_spots": kept,
                "excluded_spots": excluded,
                "groups": {}  # 駅グループ化はしない
            }

        # 2-b) 電車前提: 各スポットの最寄り駅を取得し、1kmルールでフィルタ
        for s in spots:
            s_lat, s_lng = s["lat"], s["lng"]
            # スポット側の最寄り駅
            spot_station = await get_nearest_station_enhanced(session, s_lat, s_lng, api_key)

            # 駅情報が無い場合は除外
            if not spot_station:
                excluded.append(s)
                continue

            # ルール: 「最寄り駅→スポット」も「現在地→スポット」も1km以上なら除外
            dist_station_to_spot = haversine_km(spot_station["lat"], spot_station["lng"], s_lat, s_lng)
            dist_origin_to_spot = haversine_km(o_lat, o_lng, s_lat, s_lng)
            if dist_station_to_spot >= WALK_RADIUS_KM and dist_origin_to_spot >= WALK_RADIUS_KM:
                excluded.append(s)
                continue

            kept.append(s)

            # 駅ごとグループ
            gkey = spot_station["name"] or f"{spot_station['lat']:.5f},{spot_station['lng']:.5f}"
            if gkey not in groups:
                groups[gkey] = {"station": spot_station, "spots": []}
            groups[gkey]["spots"].append(s)

        return {
            "only_walking_plan": False,
            "origin_station": origin_station,
            "kept_spots": kept,
            "excluded_spots": excluded,
            "groups": groups
        }

  • 最寄り駅が同じスポット群はグループ分けし、そのグループ内は徒歩で回るようにする
# 最寄り駅が同じスポット群をグループ化し、グループ内は徒歩で回るルートを作る
# 前段の filter_spots_by_walkability() が返す groups 構造
#   groups = {
#     "駅名": {
#       "station": {"name": str, "lat": float, "lng": float, ...},
#       "spots":   [{"name": str, "lat": float, "lng": float}, ...]
#     }, ...
#   }

WALK_SPEED_KMH = 4.0  # 徒歩の平均速度

def haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
    R = 6371.0
    dlat = math.radians(lat2 - lat1)
    dlng = math.radians(lng2 - lng1)
    a = math.sin(dlat/2)**2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlng/2)**2
    c = 2 * math.atan2(math.sqrt(a), math.sqrt(1-a))
    return R * c

def walk_minutes(distance_km: float, speed_kmh: float = WALK_SPEED_KMH) -> int:
    return max(1, int(round((distance_km / speed_kmh) * 60)))

def order_spots_greedy(start: Tuple[float, float], spots: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
    """
    駅(start)から近い順に貪欲法でスポットを並べる簡易TSP。
    """
    remaining = spots[:]
    route = []
    cur_lat, cur_lng = start
    while remaining:
        # 現在地から最も近いスポットを選ぶ
        nxt = min(
            remaining,
            key=lambda s: haversine_km(cur_lat, cur_lng, s["lat"], s["lng"])
        )
        route.append(nxt)
        cur_lat, cur_lng = nxt["lat"], nxt["lng"]
        remaining.remove(nxt)
    return route

def build_walking_routes_by_station(groups: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
    """
    駅ごとの徒歩回遊ルートを構築。
    戻り値:
      {
        "groups": [
          {
            "station": {...},
            "route": [ {"type":"walk","from":{...},"to":{...},"distance_km":x,"minutes":y}, ... ],
            "order":  [ {"name":..., "lat":..., "lng":...}, ... ],  # 訪問順(駅→スポット列)
            "totals": {"distance_km": X, "minutes": Y, "stops": N}
          },
          ...
        ]
      }
    """
    results = {"groups": []}

    for key, data in groups.items():
        station = data["station"]
        spots   = data.get("spots", [])
        if not spots:
            continue

        start = (station["lat"], station["lng"])

        # 1) 駅から徒歩で回る順序を決める(貪欲法)
        ordered_spots = order_spots_greedy(start, spots)

        # 2) ルート(駅→各スポットの連結)を構築
        cur_point = {"name": station["name"], "lat": station["lat"], "lng": station["lng"]}
        route_steps = []
        total_km = 0.0
        total_min = 0

        for spot in ordered_spots:
            d_km = haversine_km(cur_point["lat"], cur_point["lng"], spot["lat"], spot["lng"])
            minutes = walk_minutes(d_km)
            route_steps.append({
                "type": "walk",
                "from": cur_point,
                "to":   {"name": spot["name"], "lat": spot["lat"], "lng": spot["lng"]},
                "distance_km": round(d_km, 3),
                "minutes": minutes,
                "instructions": f"徒歩で『{spot['name']}』へ"
            })
            total_km += d_km
            total_min += minutes
            cur_point = {"name": spot["name"], "lat": spot["lat"], "lng": spot["lng"]}

        results["groups"].append({
            "station": station,
            "route": route_steps,
            "order": [ {"name": station["name"], "lat": station["lat"], "lng": station["lng"]} ] +
                     [ {"name": s["name"], "lat": s["lat"], "lng": s["lng"]} for s in ordered_spots ],
            "totals": {"distance_km": round(total_km, 3), "minutes": total_min, "stops": len(ordered_spots)}
        })

    return results

# --- 使用例 ---
# walkable = await filter_spots_by_walkability(origin, spots, api_key=PLACES_API_KEY)
# walking_routes = build_walking_routes_by_station(walkable["groups"])
# -> walking_routes["groups"][i]["route"] に駅起点の徒歩ステップが入る

  • 駅と駅はGPT-4に推論させる。
    結構大胆な感じですが、マイナーな箇所以外はフォローできていそうだったので、これで代替とします... まあ、他に手段もないので。
async def check_train_accessibility_gpt(origin_station, destination_station, current_time, current_address=None):
    """GPT-4を用いて出発地点の最寄駅から任意の駅まで電車で行けるかを確認する関数"""
    try:
        from langchain_openai import ChatOpenAI
        import os
        
        openai_api_key = os.getenv("OPENAI_API_KEY")
        
        # OpenAI GPT-4を使用して電車の接続をチェック
        llm = ChatOpenAI(
            model="gpt-4.1-nano",  # nanoに統一
            temperature=0,
            api_key=openai_api_key
        )
        
        # 出発駅に現在住所を併記
        origin_station_with_address = f"{origin_station}"
        if current_address:
            origin_station_with_address = f"{origin_station}{current_address})"
        
        prompt = f"""
日本の電車路線について、以下の駅間の接続可能性を判定してください。

出発駅: {origin_station_with_address}
到着駅: {destination_station}
現在時刻: {current_time}

【重要な注意事項】
- 同名の駅が複数地域にある場合は、地理的に近い地域の駅として判定してください
- 地方の小さな駅も実在すると仮定してください
- 日本全国の電車路線網を考慮してください

以下の観点で判定してください:
1. 駅が実在するか(地方の小さな駅も実在すると仮定)
2. 電車での接続が可能か(乗り換え含む)
3. 現在時刻で運行しているか(終電チェック)

回答は以下のJSON形式で出力してください:
{{
    "accessible": true/false,
    "reason": "判定理由(詳細に説明してください)"
}}
"""
        
        print(f"[DEBUG] LLM電車判定プロンプト:")
        print(f"[DEBUG] 出発駅: {origin_station_with_address}")
        print(f"[DEBUG] 到着駅: {destination_station}")
        print(f"[DEBUG] 現在時刻: {current_time}")
        print(f"[DEBUG] プロンプト全文:")
        print(f"[DEBUG] {prompt}")
        print(f"[DEBUG] プロンプト全文終了")
        
        response = llm.invoke(prompt)
        
        print(f"[DEBUG] LLM電車判定レスポンス (生): {response.content}")
        
        result = parse_llm_json_output(response.content)
        
        accessible = result.get("accessible", False)
        reason = result.get("reason", "理由なし")
        
        print(f"[DEBUG] LLM判定結果: accessible={accessible}")
        print(f"[DEBUG] LLM判定理由: {reason}")
        
        return accessible
        
    except Exception as e:
        print(f"電車接続チェックエラー: {e}")
        print(f"[DEBUG] エラー詳細: {type(e).__name__}: {str(e)}")
        # エラー時は安全側に倒して接続可能とする
        return True


async def check_multiple_train_accessibility_gpt(origin_station, destination_stations, current_time, current_address=None, travel_time_hours=None):
    """GPT-4を用いて出発地点の最寄駅から複数の駅まで電車で行けるかを一度に確認する関数"""
    try:
        from langchain_openai import ChatOpenAI
        import os
        
        openai_api_key = os.getenv("OPENAI_API_KEY")
        
        # OpenAI GPT-4を使用して電車の接続をチェック
        llm = ChatOpenAI(
            model="gpt-4.1-nano",  # nanoに統一
            temperature=0,
            api_key=openai_api_key
        )
        
        # 出発駅に現在住所を併記
        origin_station_with_address = f"{origin_station}"
        if current_address:
            origin_station_with_address = f"{origin_station}{current_address})"
        
        # 目的地駅のリストを作成
        destinations_list = "\n".join([f"- {station}" for station in destination_stations])
        
        # 電車移動時間の制約を計算
        max_train_travel_minutes = None
        if travel_time_hours:
            max_train_travel_minutes = int((travel_time_hours / 5) * 60)  # 旅行時間の1/5を分に変換
        
        # 電車時間制約の文言を作成
        time_constraint_text = ""
        if max_train_travel_minutes:
            time_constraint_text = f"- 電車移動の片道時間は{max_train_travel_minutes}分以内であること"
        
        prompt = f"""
日本の電車路線について、以下の駅間の接続可能性を一括で判定してください。

出発駅: {origin_station_with_address}
目的地駅のリスト:
{destinations_list}
現在時刻: {current_time}

【重要な注意事項】
- 同名の駅が複数地域にある場合は、地理的に近い地域の駅として判定してください
- 日本全国の電車路線網を考慮してください
{time_constraint_text}

以下の観点で各駅を判定してください:
1. 電車での接続が可能か(乗り換え含む)
2. 現在時刻で運行しているか(終電チェック)
3. 移動時間が制約内であるか

回答は以下のJSON形式で出力してください:
{{
    "results": [
        {{
            "station": "駅名",
            "accessible": true/false,
            "reason": "判定理由(詳細に説明してください)"
        }}
    ]
}}
"""
        
        print(f"[DEBUG] LLM電車一括判定プロンプト:")
        print(f"[DEBUG] 出発駅: {origin_station_with_address}")
        print(f"[DEBUG] 目的地駅数: {len(destination_stations)}")
        print(f"[DEBUG] 受信した現在時刻: {current_time}")
        print(f"[DEBUG] 現在時刻の型: {type(current_time)}")
        print(f"[DEBUG] プロンプト全文:")
        print(f"[DEBUG] {prompt}")
        print(f"[DEBUG] プロンプト全文終了")
        
        response = llm.invoke(prompt)
        
        print(f"[DEBUG] LLM電車一括判定レスポンス (生): {response.content}")
        
        result = parse_llm_json_output(response.content)
        
        # 結果を辞書形式で返す
        accessibility_dict = {}
        results = result.get("results", [])
        
        for station_result in results:
            station = station_result.get("station", "")
            accessible = station_result.get("accessible", False)
            reason = station_result.get("reason", "理由なし")
            accessibility_dict[station] = {
                "accessible": accessible,
                "reason": reason
            }
            print(f"[DEBUG] LLM一括判定結果: {station} - accessible={accessible}")
            print(f"[DEBUG] LLM一括判定理由: {reason}")
        
        return accessibility_dict
        
    except Exception as e:
        print(f"電車一括接続チェックエラー: {e}")
        print(f"[DEBUG] エラー詳細: {type(e).__name__}: {str(e)}")
        # エラー時は安全側に倒して接続可能とする
        return {station: {"accessible": True, "reason": "エラーのため接続可能と仮定"} for station in destination_stations}


まとめ

  • 日本のGoogle Transit API制限を、AI+複数APIの組み合わせで
  • GPT-4の暗黙知識を構造化して活用

Googleが提供できない鉄道情報を、(強引にですが)AI推論と既存APIのハイブリッドで代替としました。

Discussion