iT邦幫忙

0

# Day 12 串接 Google Maps Places API 獲取即時店家資料與真實評分

n8n
  • 分享至 

  • xImage
  •  

在昨天的文章中,我們透過 LINE Flex Message Carousel 大幅升級了聊天室的互動體驗,讓推薦結果以圖文並茂的橫向滑動卡片呈現。

然而,目前推薦系統所依賴的依然是 Day 05 寫在程式內的靜態模擬資料(Mock DB)。在真實的日常生活中,使用者的需求往往跨越不同縣市商圈,且店家隨時可能歇業或變更評分。如果只能推薦預先寫好的幾家店,實用性將大打折扣。

今天 Day 12,我們將把微服務正式接軌真實世界:在 FastAPI 中整合 Google Maps Places API(New),讓推薦 Agent 具備即時搜尋周邊實體店家、讀取 Google 星級評分、抓取營業地址與照片的能力!


一、Google Maps Places API 核心架構解析

Google 在最新版的 Places API 中推行了 Text Search (New) 機制,特別適合自然語言驅動的推薦場景:

  • 關鍵字與地區組合:可將使用者抽取的「地區」與「料理類型」組合成查詢語句(例如 板橋 甜點)。
  • Field Masking(欄位遮罩):透過 HTTP Header 的 X-Goog-FieldMask 明確指定只需回傳的欄位(如店名、評分、價格等級、地址),只抓取需要的資料,避免浪費 API 額度。
  • 免費試用額度:Google Cloud 平台每個月提供常態性免費抵扣額度,對於個人開發與測試而言非常充裕。

二、前置準備:啟用 Places API 並取得金鑰

Step 1:進入 Google Cloud Console 啟用服務

  1. 打開瀏覽器前往 Google Cloud Console。
  2. 選擇你先前建立的專案(或新建一個專案)。
  3. 進入「API 和服務」➜「程式庫」。
  4. 搜尋 Places API (New) 並點擊 啟用。

Step 2:建立並限制 API 金鑰

  1. 進入「憑證」頁面,點擊「建立憑證」➜「API 金鑰」。
  2. 複製產生的 API Key。
  3. (建議安全設定)點擊「編輯 API 金鑰」,在「API 限制」中選擇僅限呼叫 Places API (New),避免金鑰遭濫用。

三、升級 FastAPI 微服務:新增 Places API 整合模組

為了發送外部非同步 HTTP 請求,我們先在 Python 環境安裝高效能的 httpx 套件。

Step 1:安裝非同步 HTTP 客戶端

確認虛擬環境已啟用,在終端機執行:

pip install httpx python-dotenv

Step 2:配置環境變數 (.env)

在 agent-backend/ 專案根目錄下建立 .env 檔案,妥善管理機密金鑰:

GOOGLE_MAPS_API_KEY=AIzaSyYourActualGoogleMapsAPIKeyHere

在 app/config.py(若無則新增)中讀取環境變數:

import os
from dotenv import load_dotenv

load_dotenv()

GOOGLE_MAPS_API_KEY = os.getenv("GOOGLE_MAPS_API_KEY", "")

Step 3:實作 Google Places 服務層 (app/services/places.py)

建立 app/services/places.py,專門負責呼叫 Google Places Text Search API:

import httpx
from typing import List
from app.config import GOOGLE_MAPS_API_KEY
from app.schemas.food import RestaurantItem, CuisineType

PLACES_API_URL = "[https://places.googleapis.com/v1/places:searchText](https://places.googleapis.com/v1/places:searchText)"

# 價格等級對應平均消費估計
PRICE_LEVEL_MAP = {
    "PRICE_LEVEL_FREE": 0,
    "PRICE_LEVEL_INEXPENSIVE": 150,
    "PRICE_LEVEL_MODERATE": 350,
    "PRICE_LEVEL_EXPENSIVE": 800,
    "PRICE_LEVEL_VERY_EXPENSIVE": 1500,
}

async def search_places(query_location: str, query_cuisine: str, budget_max: int) -> List[RestaurantItem]:
    """
    透過 Google Places API (New) 搜尋符合地點與料理分類的真實店家
    """
    if not GOOGLE_MAPS_API_KEY:
        return []

    text_query = f"{query_location} {query_cuisine}" if query_cuisine != "全部" else f"{query_location} 美食"

    headers = {
        "Content-Type": "application/json",
        "X-Goog-Api-Key": GOOGLE_MAPS_API_KEY,
        # 僅索取所需欄位以節省額度
        "X-Goog-FieldMask": "places.id,places.displayName,places.formattedAddress,places.rating,places.priceLevel,places.editorialSummary"
    }

    payload = {
        "textQuery": text_query,
        "languageCode": "zh-TW",
        "maxResultCount": 5
    }

    results: List[RestaurantItem] = []

    async with httpx.AsyncClient(timeout=10.0) as client:
        response = await client.post(PLACES_API_URL, headers=headers, json=payload)
        
        if response.status_code != 200:
            return []

        data = response.json()
        places = data.get("places", [])

        for p in places:
            price_level_str = p.get("priceLevel", "PRICE_LEVEL_MODERATE")
            estimated_price = PRICE_LEVEL_MAP.get(price_level_str, 300)

            # 依預算過濾
            if estimated_price > budget_max:
                continue

            summary_dict = p.get("editorialSummary", {})
            reason_text = summary_dict.get("text", "Google Maps 評價優良的在地推薦店家!")

            item = RestaurantItem(
                id=p.get("id", "places_id"),
                name=p.get("displayName", {}).get("text", "精選餐廳"),
                cuisine=CuisineType(query_cuisine) if query_cuisine in [c.value for c in CuisineType] else CuisineType.ALL,
                price_avg=estimated_price,
                location=p.get("formattedAddress", query_location),
                rating=float(p.get("rating", 4.0)),
                signature_dish=f"{query_cuisine}人氣餐點",
                reason=reason_text
            )
            results.append(item)

    return results

Step 4:更新推薦路由 (app/routers/recommendation.py)

將原本只查 Mock 靜態資料的邏輯,改為優先呼叫 Google Places API,若無回傳再降級(Fallback)使用 Mock 資料:

from fastapi import APIRouter
from app.schemas.food import UserPreference, RecommendationResponse
from app.services.places import search_places

router = APIRouter(
    prefix="/api/v1/recommend",
    tags=["Food Recommendation"]
)

@router.post("/", response_model=RecommendationResponse)
async def recommend_food(preference: UserPreference):
    """
    接收使用者條件,動態調用 Google Maps Places API 取得即時店家推薦
    """
    # 呼叫 Google Places API
    real_places = await search_places(
        query_location=preference.location,
        query_cuisine=preference.cuisine.value,
        budget_max=preference.budget_max
    )

    if real_places:
        return RecommendationResponse(
            status="success",
            total_found=len(real_places),
            recommendations=real_places
        )

    # 查無結果時回傳空清單,由前端工作流處理溫馨提示
    return RecommendationResponse(
        status="not_found",
        total_found=0,
        recommendations=[]
    )

四、全鏈路實機驗證

重新啟動 FastAPI 服務,確保環境變數已載入:

uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload

拿起手機,打開 LINE 美食小幫手聊天室,發送不同縣市商圈的自然語言提問:

使用者輸入:
「我現在在台北車站附近,預算 400 元,想吃日式拉麵,有推薦的嗎?」

驗收重點:
1.Dify 解析:自動精準抽取 location: "台北車站"、cuisine: "日式"、budget_max: 400。

2.FastAPI 呼叫:背景即時呼叫 Google Places API,取得「台北車站」周圍真實營業中的日式餐廳與真實 Google Maps 評分。

3.LINE 介面呈現:手機端立即收到對應店家的真實名稱、地址與 Google 導航按鈕,完整告別寫死假資料的限制!
今日結語與明日預告
今天我們為美食推薦 Agent 裝上了真實世界探測儀:

成功整合 Google Maps Places API (New)。

透過非同步 httpx 實現低延遲的外部端點請求。

讓系統具備跨區域、真實營業狀態與動態評分的即時推薦能力!

目前每次使用者詢問完,資訊就隨風而逝,系統並不知道使用者「去過哪裡」或「常吃什麼」。
明天 Day 13,我們將要建立長期記憶機制:整合輕量級資料庫(SQLite / PostgreSQL),儲存使用者歷史喜好與去過的餐廳足跡,打造具備專屬記憶的貼身助理! 敬請期待!


*提醒邦友,使用第三方服務/API 時,請務必評估資安風險與隱私保護
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言