Spaces:
Runtime error
Runtime error
| import os | |
| import sqlite3 | |
| import threading | |
| import requests | |
| import google.generativeai as genai | |
| from flask import Flask, request, abort | |
| from linebot import LineBotApi, WebhookHandler | |
| from linebot.exceptions import InvalidSignatureError | |
| import io | |
| import json | |
| from PIL import Image | |
| from linebot.models import ( | |
| MessageEvent, TextMessage, TextSendMessage, QuickReply, | |
| QuickReplyButton, MessageAction, ImageMessage, FlexSendMessage, | |
| LocationMessage, LocationAction | |
| ) | |
| import urllib.parse | |
| import math | |
| from datetime import datetime, timezone, timedelta | |
| app = Flask(__name__) | |
| # ================= 1. 從環境變數讀取金鑰 ================= | |
| line_bot_api = LineBotApi(os.environ.get('LINE_CHANNEL_ACCESS_TOKEN')) | |
| handler = WebhookHandler(os.environ.get('LINE_CHANNEL_SECRET')) | |
| genai.configure(api_key=os.environ.get('GEMINI_API_KEY')) | |
| model = genai.GenerativeModel('gemini-2.5-flash') | |
| def normalize_product_name(user_input: str): | |
| """ | |
| 用 Gemini 將模糊/錯字/簡稱輸入辨識為正確商品名稱。 | |
| 回傳 (正確名稱, 是否已修正),辨識失敗回傳 (None, False)。 | |
| """ | |
| prompt = ( | |
| f"使用者輸入了「{user_input}」,這可能是台灣藥妝保養品的商品名稱," | |
| "但可能有錯字、簡稱、中英混打、繁簡混用或描述性說法。\n" | |
| "請推測最可能的正確商品全名(使用台灣通用名稱,保留品牌英文名)。\n" | |
| "規則:\n" | |
| "- 能辨識 → 只輸出正確商品名稱,不要任何說明\n" | |
| "- 完全無法辨識(非保養品/化妝品/藥妝相關)→ 只輸出 UNKNOWN\n" | |
| "範例:「cerave乳液」→「CeraVe 修護保濕乳霜」;「貝德馬卸妝」→「Bioderma 貝德瑪卸妝水」" | |
| ) | |
| try: | |
| resp = model.generate_content(prompt) | |
| result = resp.text.strip().strip("「」") | |
| if result == "UNKNOWN" or not result: | |
| return None, False | |
| corrected = result != user_input | |
| return result, corrected | |
| except Exception: | |
| return user_input, False | |
| def _send_confirm_dialog(user_id, feature, recognized, original_input, reply_token=None): | |
| user_states[user_id] = { | |
| "step": "CONFIRM_PRODUCT", | |
| "feature": feature, | |
| "product_name": recognized, | |
| "original": original_input, | |
| } | |
| confirm_qr = QuickReply(items=[ | |
| QuickReplyButton(action=MessageAction(label="✅ 對,就是這個", text="✅ 對,就是這個")), | |
| QuickReplyButton(action=MessageAction(label="❌ 不是,重新輸入", text="❌ 不是,重新輸入")), | |
| ]) | |
| if reply_token: | |
| line_bot_api.reply_message(reply_token, TextSendMessage(text=f"🔍 您是指「{recognized}」嗎?", quick_reply=confirm_qr)) | |
| else: | |
| line_bot_api.reply_message(reply_token, TextSendMessage(text=f"🔍 您是指「{recognized}」嗎?", quick_reply=confirm_qr)) | |
| def _send_or_push(user_id, reply_token, msg): | |
| if reply_token: | |
| line_bot_api.reply_message(reply_token, msg) | |
| else: | |
| try: line_bot_api.push_message(user_id, msg) | |
| except Exception: pass | |
| def _do_photo_query(user_id, reply_token, product_name): | |
| try: | |
| ALL_STORES = ["屈臣氏", "康是美", "寶雅"] | |
| db_results = query_products(product_name) | |
| by_store = {} | |
| for r in db_results: | |
| if r["store"] not in by_store: | |
| by_store[r["store"]] = r | |
| missing = [s for s in ALL_STORES if s not in by_store] | |
| if missing: | |
| missing_list = "\n".join( | |
| f' {{"store": "{s}", "name": "{product_name}", "price": "估計數字"}}' for s in missing | |
| ) | |
| estimate_prompt = ( | |
| f"請估算「{product_name}」在台灣以下藥妝店的合理售價。" | |
| f"只回傳 JSON 陣列,不要任何說明:\n[\n{missing_list}\n]" | |
| ) | |
| est_response = model.generate_content(estimate_prompt) | |
| clean_text = est_response.text.replace("```json","").replace("```","").strip() | |
| start = clean_text.find("["); end = clean_text.rfind("]")+1 | |
| for item in json.loads(clean_text[start:end]): | |
| by_store[item["store"]] = item | |
| data_list = [by_store.get(s, {"store":s,"name":product_name,"price":"請洽門市"}) for s in ALL_STORES] | |
| default_logos = { | |
| "屈臣氏": "https://openhouse.osa.nycu.edu.tw/media/data/company_logos/3._%E5%B1%88%E8%87%A3%E6%B0%8Flogo.png", | |
| "康是美": "https://img.skmbuy.com.tw/App_Images/727/Brand/2022/1215/4b8e1829-8f30-46a9-b044-834c11fc884a.jpg", | |
| "寶雅": "https://color-matching.s3.ap-northeast-1.amazonaws.com/color-image/%E5%AF%B6%E9%9B%85%EF%BC%8C%E6%A8%99%E8%AA%8C.jpg" | |
| } | |
| bubbles = [] | |
| for item in data_list: | |
| safe_store = item.get("store", "推薦藥妝店") | |
| safe_name = item.get("name", product_name) | |
| safe_price = item.get("price", "請洽門市") | |
| safe_logo = item.get("logo", default_logos.get(safe_store, "")) | |
| safe_discount = item.get("discount", "") | |
| search_query = urllib.parse.quote(f"{safe_store} {safe_name}") | |
| safe_link = item.get("link", f"https://www.google.com/search?q={search_query}") | |
| specs_contents = [ | |
| {"type": "box", "layout": "horizontal", "contents": [ | |
| {"type": "text", "text": "販售商家", "size": "md", "color": "#8c8c8c", "flex": 1}, | |
| {"type": "text", "text": safe_store, "size": "md", "color": "#111111", "weight": "bold", "flex": 2} | |
| ]}, | |
| {"type": "box", "layout": "horizontal", "contents": [ | |
| {"type": "text", "text": "商品售價", "size": "md", "color": "#8c8c8c", "flex": 1}, | |
| {"type": "text", "text": f"${safe_price}", "size": "md", "color": "#111111", "weight": "bold", "flex": 2} | |
| ]} | |
| ] | |
| if safe_discount: | |
| specs_contents.append({"type": "box", "layout": "horizontal", "contents": [ | |
| {"type": "text", "text": "獨家優惠", "size": "md", "color": "#8c8c8c", "flex": 1}, | |
| {"type": "text", "text": safe_discount, "size": "md", "color": "#ff3b30", "weight": "bold", "flex": 2} | |
| ]}) | |
| card = { | |
| "type": "bubble", "size": "mega", | |
| "hero": {"type": "image", "url": safe_logo, "size": "full", "aspectRatio": "2:1", "aspectMode": "fit", "backgroundColor": "#FFFFFF"}, | |
| "body": {"type": "box", "layout": "vertical", "contents": [ | |
| {"type": "text", "text": safe_name, "weight": "bold", "size": "xl", "wrap": True}, | |
| {"type": "box", "layout": "vertical", "spacing": "sm", "margin": "lg", "contents": specs_contents} | |
| ]}, | |
| "footer": {"type": "box", "layout": "vertical", "spacing": "sm", "contents": [ | |
| {"type": "button", "style": "primary", "color": "#000000", "height": "md", | |
| "action": {"type": "uri", "label": "🛍️ 前往商家購買", "uri": safe_link}}, | |
| {"type": "button", "style": "secondary", "height": "md", | |
| "action": {"type": "message", "label": "❤️ 加入收藏", "text": f"快速收藏:{safe_name}:${safe_price}"}} | |
| ]} | |
| } | |
| bubbles.append(card) | |
| flex_message = FlexSendMessage.new_from_json_dict({ | |
| "type": "flex", "alt_text": "🔍 藥妝精靈比價報告來囉!", | |
| "contents": {"type": "carousel", "contents": bubbles} | |
| }) | |
| _send_or_push(user_id, reply_token, flex_message) | |
| except Exception as e: | |
| _send_or_push(user_id, reply_token, TextSendMessage(text=f"❌ 哎呀!精靈遇到了一點問題。錯誤原因:\n{e}")) | |
| if user_id in user_states: | |
| del user_states[user_id] | |
| def _do_review_query(user_id, reply_token, product_name): | |
| review_prompt = f""" | |
| 你是一個台灣保養品社群平台,請為「{product_name}」這款商品生成 3 則真實風格的用戶評價。 | |
| 必須「只回傳」一個標準的 JSON 陣列,不要有任何額外說明文字。格式嚴格如下: | |
| [ | |
| {{"reviewer": "身份描述+英文名(如 小資女Sandy、上班族Cathy、學生妹Mia、熟齡女Vivian)", "skin_type": "膚質(乾性肌/油性肌/混合肌/敏感肌)", "rating": 評分數字(1-5), "date": "日期(如 2025/03/15)", "review": "評價內容(50-80字,繁體中文,具體使用心得)", "helpful": 點讚數(0-50之間整數), "pros": ["優點標籤(最多5字)", "優點標籤"], "cons": ["缺點標籤(最多5字)"]}}, | |
| {{"reviewer": "...", "skin_type": "...", "rating": ..., "date": "...", "review": "...", "helpful": ..., "pros": [...], "cons": [...]}}, | |
| {{"reviewer": "...", "skin_type": "...", "rating": ..., "date": "...", "review": "...", "helpful": ..., "pros": [...], "cons": [...]}} | |
| ] | |
| pros 最多 2 個,cons 最多 1 個,每個標籤不超過 6 個中文字。 | |
| """ | |
| try: | |
| review_resp = model.generate_content(review_prompt) | |
| clean = review_resp.text.replace("```json", "").replace("```", "").strip() | |
| reviews = json.loads(clean) | |
| star_map = {5: "⭐⭐⭐⭐⭐", 4: "⭐⭐⭐⭐", 3: "⭐⭐⭐", 2: "⭐⭐", 1: "⭐"} | |
| bubbles = [] | |
| for r in reviews: | |
| stars = star_map.get(int(r.get("rating", 5)), "⭐⭐⭐⭐⭐") | |
| pros = r.get("pros", []) | |
| cons = r.get("cons", []) | |
| tag_box_contents = [] | |
| for p in pros: | |
| tag_box_contents.append({"type": "text", "text": f"✅ {p}", "size": "xs", "color": "#27AE60"}) | |
| for c in cons: | |
| tag_box_contents.append({"type": "text", "text": f"⚠️ {c}", "size": "xs", "color": "#E67E22"}) | |
| body_contents = [ | |
| {"type": "text", "text": product_name, "size": "sm", "color": "#FF6B8A", "weight": "bold"}, | |
| {"type": "text", "text": r.get("review", ""), "size": "sm", "color": "#333333", "wrap": True, "margin": "md"}, | |
| ] | |
| if tag_box_contents: | |
| body_contents.append({"type": "box", "layout": "vertical", "margin": "md", "contents": tag_box_contents}) | |
| body_contents.append({"type": "box", "layout": "horizontal", "margin": "md", | |
| "contents": [{"type": "text", "text": f"👍 {r.get('helpful', 0)} 人覺得有幫助", "size": "xs", "color": "#8c8c8c"}]}) | |
| card = { | |
| "type": "bubble", "size": "mega", | |
| "header": { | |
| "type": "box", "layout": "vertical", | |
| "backgroundColor": "#FFF8E7", "paddingAll": "15px", | |
| "contents": [{"type": "box", "layout": "horizontal", | |
| "contents": [ | |
| {"type": "box", "layout": "vertical", "flex": 1, | |
| "contents": [{"type": "text", "text": "👤", "size": "xxl", "align": "center"}]}, | |
| {"type": "box", "layout": "vertical", "flex": 4, | |
| "contents": [ | |
| {"type": "text", "text": r.get("reviewer", "匿名"), "weight": "bold", "size": "md", "color": "#111111"}, | |
| {"type": "text", "text": r.get("skin_type", ""), "size": "xs", "color": "#8c8c8c"}, | |
| {"type": "text", "text": stars, "size": "sm", "margin": "xs"} | |
| ]}, | |
| {"type": "text", "text": r.get("date", ""), "size": "xs", "color": "#8c8c8c", "align": "end", "flex": 2} | |
| ]}] | |
| }, | |
| "body": {"type": "box", "layout": "vertical", "contents": body_contents} | |
| } | |
| bubbles.append(card) | |
| flex_msg = FlexSendMessage.new_from_json_dict({ | |
| "type": "flex", "alt_text": f"💬 {product_name} 的社群口碑", | |
| "contents": {"type": "carousel", "contents": bubbles} | |
| }) | |
| _send_or_push(user_id, reply_token, flex_msg) | |
| avg_rating = sum(int(r.get("rating", 5)) for r in reviews) / len(reviews) | |
| summary_prompt = f""" | |
| 根據以下針對「{product_name}」的評論資料,生成一段精簡總結。 | |
| 評論資料:{json.dumps(reviews, ensure_ascii=False)} | |
| 必須完全依照下列格式輸出,不要有其他文字: | |
| 🧴 {product_name} | |
| ⭐ 社群好評度:{avg_rating:.1f} / 5 | |
| 👍 網友最常提到 | |
| 1️⃣ (優點1) | |
| 2️⃣ (優點2) | |
| 3️⃣ (優點3) | |
| 👎 常見缺點 | |
| • (10-15字,描述具體膚質或情境限制,如「對油性肌或T字部位可能較悶厚」) | |
| • (10-15字,另一個具體使用限制,如「夏季高溫使用容易感覺黏膩」) | |
| 🧚♀️ 精靈總結 | |
| (1句話,15~25字,點出最適合族群與核心優勢,繁體中文) | |
| """ | |
| summary_resp = model.generate_content(summary_prompt) | |
| _send_or_push(user_id, reply_token, TextSendMessage(text=summary_resp.text.strip())) | |
| except Exception as e: | |
| _send_or_push(user_id, reply_token, TextSendMessage(text=f"❌ 口碑查詢失敗:{e}")) | |
| if user_id in user_states: | |
| del user_states[user_id] | |
| def _build_fav_card(index, fav): | |
| return { | |
| "type": "bubble", "size": "mega", | |
| "header": { | |
| "type": "box", "layout": "vertical", | |
| "backgroundColor": "#FFE8F0", "paddingAll": "12px", | |
| "contents": [{"type": "text", "text": f"❤️ 收藏 #{index+1}", "weight": "bold", "size": "sm", "color": "#FF6B8A"}] | |
| }, | |
| "body": { | |
| "type": "box", "layout": "vertical", | |
| "contents": [ | |
| {"type": "text", "text": fav["name"], "weight": "bold", "size": "lg", "wrap": True, "color": "#111111"}, | |
| {"type": "box", "layout": "vertical", "spacing": "sm", "margin": "lg", | |
| "contents": [ | |
| {"type": "box", "layout": "baseline", "spacing": "sm", "contents": [ | |
| {"type": "text", "text": "參考售價", "size": "sm", "color": "#8c8c8c", "flex": 3}, | |
| {"type": "text", "text": fav.get("price", "未知"), "size": "sm", "color": "#111111", "weight": "bold", "flex": 5} | |
| ]}, | |
| {"type": "box", "layout": "baseline", "spacing": "sm", "contents": [ | |
| {"type": "text", "text": "備註", "size": "sm", "color": "#8c8c8c", "flex": 3}, | |
| {"type": "text", "text": fav.get("note", "無"), "size": "sm", "color": "#111111", "flex": 5, "wrap": True} | |
| ]} | |
| ]} | |
| ] | |
| }, | |
| "footer": { | |
| "type": "box", "layout": "vertical", "spacing": "sm", | |
| "contents": [ | |
| {"type": "button", "style": "primary", "color": "#000000", "height": "sm", | |
| "action": {"type": "message", "label": "🛒 前往購物", "text": f"查詢購買:{fav['name']}"}}, | |
| {"type": "button", "style": "secondary", "height": "sm", | |
| "action": {"type": "message", "label": "🗑️ 刪除此收藏", "text": f"刪除收藏:{fav['name']}"}} | |
| ] | |
| } | |
| } | |
| def _do_favorite_save(user_id, reply_token, product_name): | |
| try: | |
| price_resp = model.generate_content(f"請告訴我「{product_name}」在台灣藥妝店的大約售價,只回傳數字,不要貨幣符號或其他文字。") | |
| price_text = price_resp.text.strip().replace("$", "").replace("NT", "").replace(",", "").strip() | |
| price_display = f"${price_text}" | |
| except Exception: | |
| price_display = "未知" | |
| if user_id not in user_favorites: | |
| user_favorites[user_id] = [] | |
| already_exists = any(f["name"] == product_name for f in user_favorites[user_id]) | |
| if already_exists: | |
| _send_or_push(user_id, reply_token, TextSendMessage(text=f"💝「{product_name}」已在您的收藏清單中囉!")) | |
| else: | |
| user_favorites[user_id].append({"name": product_name, "price": price_display, "note": "手動加入收藏"}) | |
| if user_id in user_states: | |
| del user_states[user_id] | |
| # 顯示更新後的收藏圖卡 | |
| favorites = user_favorites.get(user_id, []) | |
| add_qr = QuickReply(items=[QuickReplyButton(action=MessageAction(label="➕ 新增收藏", text="新增收藏"))]) | |
| if not already_exists and favorites: | |
| bubbles = [_build_fav_card(i, fav) for i, fav in enumerate(favorites)] | |
| flex_msg = FlexSendMessage.new_from_json_dict({ | |
| "type": "flex", "alt_text": "💝 您的收藏清單", | |
| "contents": {"type": "carousel", "contents": bubbles} | |
| }) | |
| _send_or_push(user_id, reply_token, [flex_msg, TextSendMessage(text=f"✅ 已加入收藏!共有 {len(favorites)} 件商品", quick_reply=add_qr)]) | |
| def query_products(product_name: str) -> list: | |
| """從 products.db 模糊搜尋,每店取最低價一筆。""" | |
| try: | |
| conn = sqlite3.connect("products.db") | |
| conn.row_factory = sqlite3.Row | |
| keywords = [w for w in product_name.split() if len(w) >= 2] or [product_name] | |
| seen, results = set(), [] | |
| for kw in keywords: | |
| for row in conn.execute( | |
| "SELECT name,store,price,discount,link,logo FROM products " | |
| "WHERE name LIKE ? AND price NOT IN ('','0','None','找不到','無販售此商品') " | |
| "AND price IS NOT NULL ORDER BY store, CAST(price AS INTEGER)", | |
| (f"%{kw}%",) | |
| ).fetchall(): | |
| key = (row['name'], row['store']) | |
| if key not in seen: | |
| seen.add(key); results.append(dict(row)) | |
| conn.close() | |
| return results | |
| except Exception as e: | |
| print(f"query_products error: {e}"); return [] | |
| # ================= 建立虛擬藥妝資料庫 ================= | |
| fake_database = { | |
| "CeraVe適樂膚 長效潤澤修護霜 340g": [ | |
| {"store": "屈臣氏", "name": "CeraVe適樂膚 長效潤澤修護霜 340g", "price": "619","link": "https://www.watsons.com.tw/cerave-cerave%E9%81%A9%E6%A8%82%E8%86%9A%E9%95%B7%E6%95%88%E6%BD%A4%E6%BE%A4%E4%BF%AE%E8%AD%B7%E9%9C%9C-340g/p/BP_153671","logo": "https://openhouse.osa.nycu.edu.tw/media/data/company_logos/3._%E5%B1%88%E8%87%A3%E6%B0%8Flogo.png"}, | |
| {"store": "康是美", "name": "CeraVe適樂膚 長效潤澤修護霜 340g", "price": "619", "link": "https://shop.cosmed.com.tw/SalePage/Index/6414249","logo": "https://img.skmbuy.com.tw/App_Images/727/Brand/2022/1215/4b8e1829-8f30-46a9-b044-834c11fc884a.jpg"}, | |
| {"store": "寶雅", "name": "CeraVe適樂膚 長效潤澤修護霜 340g", "price": "619","link": "https://www.poyabuy.com.tw/SalePage/Index/10104261","logo": "https://color-matching.s3.ap-northeast-1.amazonaws.com/color-image/%E5%AF%B6%E9%9B%85%EF%BC%8C%E6%A8%99%E8%AA%8C.jpg"} | |
| ], | |
| "Avene 雅漾舒護活泉水150ml": [ | |
| {"store": "屈臣氏", "name": "Avene 雅漾舒護活泉水150ml", "price": "299","discount": "🔥限時67折", "link": "https://www.watsons.com.tw/avene-%E9%9B%85%E6%BC%BE-%E9%9B%85%E6%BC%BE%E8%88%92%E8%AD%B7%E6%B4%BB%E6%B3%89%E6%B0%B4150ml/p/BP_291419","logo": "https://openhouse.osa.nycu.edu.tw/media/data/company_logos/3._%E5%B1%88%E8%87%A3%E6%B0%8Flogo.png"}, | |
| {"store": "康是美", "name": "Avene 雅漾舒護活泉水150ml", "price": "299","discount": "🔥限時67折", "link": "https://shop.cosmed.com.tw/SalePage/Index/5767522","logo": "https://img.skmbuy.com.tw/App_Images/727/Brand/2022/1215/4b8e1829-8f30-46a9-b044-834c11fc884a.jpg"}, | |
| {"store": "寶雅", "name": "Avene 雅漾舒護活泉水150ml", "price": "450", "link": "https://www.poyabuy.com.tw/SalePage/Index/9929185","logo": "https://color-matching.s3.ap-northeast-1.amazonaws.com/color-image/%E5%AF%B6%E9%9B%85%EF%BC%8C%E6%A8%99%E8%AA%8C.jpg"} | |
| ] | |
| } | |
| # ================= 2. 狀態儲存區 ================= | |
| user_states = {} | |
| user_favorites = {} # {user_id: [{"name": "...", "price": "...", "note": "..."}]} | |
| def callback(): | |
| signature = request.headers['X-Line-Signature'] | |
| body = request.get_data(as_text=True) | |
| try: | |
| handler.handle(body, signature) | |
| except InvalidSignatureError: | |
| abort(400) | |
| return 'OK' | |
| # ================= 3. 拍照精靈 (圖片處理) ================= | |
| def handle_image(event): | |
| user_id = event.source.user_id | |
| state = user_states.get(user_id) | |
| if state and state.get("step") == "ASK_PHOTO_ITEM": | |
| try: | |
| raw_bytes = line_bot_api.get_message_content(event.message.id).content | |
| except Exception as e: | |
| line_bot_api.reply_message(event.reply_token, TextSendMessage(text=f"❌ 無法取得圖片:{e}")) | |
| return | |
| reply_token = event.reply_token | |
| try: | |
| pil_image = Image.open(io.BytesIO(raw_bytes)) | |
| vision_prompt = """ | |
| 請辨識這張圖片中的保養品。 | |
| 如果是 CeraVe (適樂膚) 的產品,請直接回答:CeraVe適樂膚 長效潤澤修護霜 340g | |
| 如果是 Avene (雅漾) 活泉水,請直接回答:Avene 雅漾舒護活泉水150ml | |
| 如果都不是,請發揮你的專業,判斷它是什麼真實的品牌與品名,並「直接回答品牌與品名」。 | |
| 注意:非常重要!無論如何,你只能輸出「商品名稱」,絕對不要輸出任何其他廢話、標點符號、價格或商家資訊。 | |
| """ | |
| response = model.generate_content([vision_prompt, pil_image]) | |
| product_name = response.text.strip() | |
| _do_photo_query(user_id, reply_token, product_name) | |
| except Exception as e: | |
| line_bot_api.reply_message(reply_token, TextSendMessage(text=f"❌ 哎呀!精靈遇到了一點問題。錯誤原因:\n{e}")) | |
| # ================= 4. 附近店家 (位置處理) ================= | |
| def handle_location(event): | |
| user_id = event.source.user_id | |
| user_lat = event.message.latitude | |
| user_lon = event.message.longitude | |
| state = user_states.get(user_id, {}) | |
| brand_preference = state.get("search_brand", "全都要") | |
| # ✅ 完美修正:正確的環境變數名稱 | |
| google_api_key = os.environ.get('GOOGLE_MAPS_API_KEY') | |
| if not google_api_key: | |
| line_bot_api.reply_message(event.reply_token, TextSendMessage(text="❌ 系統尚未設定 Google Maps API 金鑰。")) | |
| return | |
| def calculate_distance(lat1, lon1, lat2, lon2): | |
| R = 6371.0 | |
| lat1_rad, lon1_rad = math.radians(lat1), math.radians(lon1) | |
| lat2_rad, lon2_rad = math.radians(lat2), math.radians(lon2) | |
| dlon = lon2_rad - lon1_rad | |
| dlat = lat2_rad - lat1_rad | |
| a = math.sin(dlat / 2)**2 + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon / 2)**2 | |
| c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) | |
| return int(R * c * 1000) | |
| def get_exact_hours(place_id, api_key): | |
| url = f"https://maps.googleapis.com/maps/api/place/details/json?place_id={place_id}&fields=opening_hours&language=zh-TW&key={api_key}" | |
| try: | |
| res = requests.get(url).json() | |
| if 'result' in res and 'opening_hours' in res['result']: | |
| weekday_text = res['result']['opening_hours'].get('weekday_text', []) | |
| if weekday_text: | |
| tw_tz = timezone(timedelta(hours=8)) | |
| today_idx = datetime.now(tw_tz).weekday() | |
| today_str = weekday_text[today_idx] | |
| if ": " in today_str: | |
| return today_str.split(": ", 1)[1] | |
| except Exception: | |
| pass | |
| return "詳情請洽門市" | |
| brands_to_search = ["屈臣氏", "康是美", "寶雅"] if brand_preference == "全都要" else [brand_preference] | |
| search_radius = 3000 | |
| try: | |
| nearest_stores = [] | |
| for brand in brands_to_search: | |
| url = f"https://maps.googleapis.com/maps/api/place/textsearch/json?query={brand}&location={user_lat},{user_lon}&radius={search_radius}&language=zh-TW&key={google_api_key}" | |
| response = requests.get(url).json() | |
| if response['status'] == 'OK' and response.get('results'): | |
| brand_stores = [] | |
| for place in response['results']: | |
| store_lat = place['geometry']['location']['lat'] | |
| store_lon = place['geometry']['location']['lng'] | |
| distance = calculate_distance(user_lat, user_lon, store_lat, store_lon) | |
| photo_ref = "" | |
| if 'photos' in place and len(place['photos']) > 0: | |
| photo_ref = place['photos'][0]['photo_reference'] | |
| open_now = place.get('opening_hours', {}).get('open_now') | |
| if open_now is True: | |
| status_light = "🟢 營業中" | |
| elif open_now is False: | |
| status_light = "🔴 休息中" | |
| else: | |
| status_light = "🕒 營業狀態" | |
| brand_stores.append({ | |
| "place_id": place.get('place_id'), | |
| "name": place.get('name', '未知名稱'), | |
| "rating": place.get('rating', '無評分'), | |
| "lat": store_lat, | |
| "lon": store_lon, | |
| "distance": distance, | |
| "address": place.get('formatted_address', ''), | |
| "status_light": status_light, | |
| "photo_ref": photo_ref | |
| }) | |
| sorted_brand_stores = sorted(brand_stores, key=lambda x: x['distance']) | |
| if brand_preference == "全都要": | |
| if sorted_brand_stores: | |
| nearest_stores.append(sorted_brand_stores[0]) | |
| else: | |
| nearest_stores.extend(sorted_brand_stores[:3]) | |
| if not nearest_stores: | |
| line_bot_api.reply_message(event.reply_token, TextSendMessage(text="🥺 哎呀,精靈在您附近 3 公里內都找不到藥妝店呢...")) | |
| if user_id in user_states: del user_states[user_id] | |
| return | |
| nearest_stores = sorted(nearest_stores, key=lambda x: x['distance']) | |
| bubbles = [] | |
| for store in nearest_stores: | |
| exact_hours = get_exact_hours(store['place_id'], google_api_key) | |
| display_hours = f"{store['status_light']} ({exact_hours})" | |
| map_query = urllib.parse.quote(f"{store['name']} {store['address']}") | |
| map_url = f"https://www.google.com/maps/search/?api=1&query={map_query}" | |
| if store['photo_ref']: | |
| image_url = f"https://maps.googleapis.com/maps/api/place/photo?maxwidth=600&photo_reference={store['photo_ref']}&key={google_api_key}" | |
| else: | |
| image_url = "https://images.unsplash.com/photo-1576426863848-c21f53c60b19?q=80&w=600&auto=format&fit=crop" | |
| # ✅ 完美修正:排版乾淨、結構完全正確的規格表! | |
| card = { | |
| "type": "bubble", | |
| "size": "mega", | |
| "hero": { | |
| "type": "image", | |
| "url": image_url, | |
| "size": "full", | |
| "aspectRatio": "16:9", | |
| "aspectMode": "cover" | |
| }, | |
| "body": { | |
| "type": "box", | |
| "layout": "vertical", | |
| "contents": [ | |
| {"type": "text", "text": store['name'], "weight": "bold", "size": "xl", "wrap": True, "color": "#111111"}, | |
| { | |
| "type": "box", | |
| "layout": "vertical", | |
| "spacing": "sm", | |
| "margin": "lg", | |
| "contents": [ | |
| { | |
| "type": "box", | |
| "layout": "baseline", | |
| "spacing": "sm", | |
| "contents": [ | |
| {"type": "text", "text": "目前距離", "size": "sm", "color": "#8c8c8c", "flex": 3}, | |
| {"type": "text", "text": f"{store['distance']} 公尺", "size": "sm", "color": "#111111", "weight": "bold", "flex": 5} | |
| ] | |
| }, | |
| { | |
| "type": "box", | |
| "layout": "baseline", | |
| "spacing": "sm", | |
| "contents": [ | |
| {"type": "text", "text": "營業時間", "size": "sm", "color": "#8c8c8c", "flex": 3}, | |
| {"type": "text", "text": display_hours, "size": "sm", "color": "#111111", "weight": "bold", "flex": 5, "wrap": True} | |
| ] | |
| }, | |
| { | |
| "type": "box", | |
| "layout": "baseline", | |
| "spacing": "sm", | |
| "contents": [ | |
| {"type": "text", "text": "店家評分", "size": "sm", "color": "#8c8c8c", "flex": 3}, | |
| {"type": "text", "text": f"⭐ {store['rating']}", "size": "sm", "color": "#fbbc04", "weight": "bold", "flex": 5} | |
| ] | |
| } | |
| ] | |
| } | |
| ] | |
| }, | |
| "footer": { | |
| "type": "box", | |
| "layout": "vertical", | |
| "contents": [ | |
| { | |
| "type": "button", | |
| "style": "primary", | |
| "color": "#000000", | |
| "height": "md", | |
| "action": { | |
| "type": "uri", | |
| "label": "開啟地圖導航", | |
| "uri": map_url | |
| } | |
| } | |
| ] | |
| } | |
| } | |
| bubbles.append(card) | |
| flex_message = FlexSendMessage.new_from_json_dict({ | |
| "type": "flex", | |
| "alt_text": "🔍 附近的藥妝店地圖出爐囉!", | |
| "contents": { | |
| "type": "carousel", | |
| "contents": bubbles | |
| } | |
| }) | |
| line_bot_api.reply_message(event.reply_token, flex_message) | |
| if user_id in user_states: del user_states[user_id] | |
| except Exception as e: | |
| line_bot_api.reply_message(event.reply_token, TextSendMessage(text=f"❌ 搜尋過程發生錯誤:{e}")) | |
| if user_id in user_states: del user_states[user_id] | |
| # ================= 5. 多輪對話核心邏輯 (文字對話) ================= | |
| def handle_message(event): | |
| user_id = event.source.user_id | |
| user_msg = event.message.text.strip() | |
| if user_msg == "附近店家": | |
| user_states[user_id] = {"step": "ASK_BRAND"} | |
| brand_buttons = QuickReply(items=[ | |
| QuickReplyButton(action=MessageAction(label="屈臣氏", text="屈臣氏")), | |
| QuickReplyButton(action=MessageAction(label="康是美", text="康是美")), | |
| QuickReplyButton(action=MessageAction(label="寶雅", text="寶雅")), | |
| QuickReplyButton(action=MessageAction(label="全都要", text="全都要")) | |
| ]) | |
| line_bot_api.reply_message( | |
| event.reply_token, | |
| TextSendMessage(text="請問你想尋找哪一間藥妝店呢?", quick_reply=brand_buttons) | |
| ) | |
| return | |
| state = user_states.get(user_id) | |
| if state and state.get("step") == "ASK_BRAND": | |
| if user_msg in ["屈臣氏", "康是美", "寶雅", "全都要"]: | |
| state["search_brand"] = user_msg | |
| state["step"] = "ASK_LOCATION" | |
| location_button = QuickReply(items=[ | |
| QuickReplyButton(action=LocationAction(label="📍 點我分享位置")) | |
| ]) | |
| line_bot_api.reply_message( | |
| event.reply_token, | |
| TextSendMessage(text=f"沒問題!請分享您的位置,精靈來幫您找最近的【{user_msg}】!", quick_reply=location_button) | |
| ) | |
| else: | |
| line_bot_api.reply_message(event.reply_token, TextSendMessage(text="請點擊下方的按鈕選擇品牌喔!")) | |
| return | |
| if user_msg == "精靈顧問" or user_msg == "重新開始": | |
| user_states[user_id] = {"step": "ASK_SKIN"} | |
| skin_buttons = QuickReply(items=[ | |
| QuickReplyButton(action=MessageAction(label="乾性肌", text="乾性肌")), | |
| QuickReplyButton(action=MessageAction(label="油性肌", text="油性肌")), | |
| QuickReplyButton(action=MessageAction(label="混合肌", text="混合肌")), | |
| QuickReplyButton(action=MessageAction(label="敏感肌", text="敏感肌")) | |
| ]) | |
| line_bot_api.reply_message( | |
| event.reply_token, | |
| TextSendMessage(text="🧚♀️ 嗨!我是你的專屬精靈顧問。為了給你最精準的保養建議,先告訴我,你覺得自己偏向哪一種膚質呢?", quick_reply=skin_buttons) | |
| ) | |
| return | |
| if user_msg == "我的ID": | |
| line_bot_api.reply_message(event.reply_token, TextSendMessage(text=f"你的 LINE user ID 是:\n{user_id}")) | |
| return | |
| if user_msg == "拍照精靈": | |
| user_states[user_id] = {"step": "ASK_PHOTO_ITEM"} | |
| line_bot_api.reply_message( | |
| event.reply_token, | |
| TextSendMessage(text="📷 歡迎開啟拍照精靈!\n您可以直接拍下商品照片傳給我,或是輸入「文字」來查詢商品售價喔!") | |
| ) | |
| return | |
| # ── 社群真實口碑 ── | |
| if user_msg == "社群真實口碑": | |
| user_states[user_id] = {"step": "ASK_REVIEW_PRODUCT"} | |
| line_bot_api.reply_message( | |
| event.reply_token, | |
| [ | |
| TextSendMessage(text="💬 歡迎使用社群真實口碑!"), | |
| TextSendMessage(text="請輸入您想查詢口碑的商品名稱,例如:「CeraVe 乳液」或「資生堂防曬」") | |
| ] | |
| ) | |
| return | |
| # ── 熱銷排行榜 ── | |
| if user_msg == "熱銷排行榜": | |
| user_states[user_id] = {"step": "ASK_RANKING_CATEGORY"} | |
| category_buttons = QuickReply(items=[ | |
| QuickReplyButton(action=MessageAction(label="💧 保濕", text="💧 保濕")), | |
| QuickReplyButton(action=MessageAction(label="✨ 美白", text="✨ 美白")), | |
| QuickReplyButton(action=MessageAction(label="🌿 修護", text="🌿 修護")), | |
| QuickReplyButton(action=MessageAction(label="🫧 痘痘粉刺", text="🫧 痘痘粉刺")), | |
| QuickReplyButton(action=MessageAction(label="☀️ 防曬", text="☀️ 防曬")), | |
| QuickReplyButton(action=MessageAction(label="😴 面膜", text="😴 面膜")), | |
| QuickReplyButton(action=MessageAction(label="🧴 乳液/乳霜", text="🧴 乳液/乳霜")), | |
| QuickReplyButton(action=MessageAction(label="🧼 洗面乳", text="🧼 洗面乳")), | |
| QuickReplyButton(action=MessageAction(label="💎 精華液", text="💎 精華液")), | |
| QuickReplyButton(action=MessageAction(label="🔍 其他類型", text="🔍 其他類型")), | |
| ]) | |
| line_bot_api.reply_message( | |
| event.reply_token, | |
| TextSendMessage(text="🏆 歡迎查看熱銷排行榜!\n請選擇您想查看的商品類別:", quick_reply=category_buttons) | |
| ) | |
| return | |
| # ── 收藏商品:查看清單 ── | |
| if user_msg in ["收藏商品", "購物車"]: | |
| favorites = user_favorites.get(user_id, []) | |
| add_qr = QuickReply(items=[QuickReplyButton(action=MessageAction(label="➕ 新增收藏", text="新增收藏"))]) | |
| if not favorites: | |
| line_bot_api.reply_message( | |
| event.reply_token, | |
| TextSendMessage(text="💝 您的收藏清單目前是空的!\n點擊下方按鈕新增您喜愛的商品吧~", quick_reply=add_qr) | |
| ) | |
| else: | |
| bubbles = [_build_fav_card(i, fav) for i, fav in enumerate(favorites)] | |
| flex_msg = FlexSendMessage.new_from_json_dict({ | |
| "type": "flex", "alt_text": "💝 您的收藏清單", | |
| "contents": {"type": "carousel", "contents": bubbles} | |
| }) | |
| line_bot_api.reply_message( | |
| event.reply_token, | |
| [flex_msg, TextSendMessage(text=f"共有 {len(favorites)} 件收藏商品", quick_reply=add_qr)] | |
| ) | |
| return | |
| # ── 收藏商品:新增 ── | |
| if user_msg == "新增收藏": | |
| user_states[user_id] = {"step": "ASK_FAVORITE_NAME"} | |
| line_bot_api.reply_message(event.reply_token, TextSendMessage(text="➕ 請輸入您想收藏的商品名稱:")) | |
| return | |
| # ── 收藏商品:刪除 ── | |
| if user_msg.startswith("刪除收藏:"): | |
| product_name = user_msg[5:] | |
| favs = user_favorites.get(user_id, []) | |
| user_favorites[user_id] = [f for f in favs if f["name"] != product_name] | |
| line_bot_api.reply_message(event.reply_token, TextSendMessage(text=f"🗑️ 已從收藏中刪除「{product_name}」")) | |
| return | |
| # ── 熱銷排行榜快速收藏 ── | |
| # ── 購物車「前往購物」→ 比價圖卡 ── | |
| if user_msg.startswith("查詢購買:"): | |
| buy_product = user_msg[5:] | |
| if buy_product: | |
| _do_photo_query(user_id, event.reply_token, buy_product) | |
| return | |
| if user_msg.startswith("快速收藏:"): | |
| parts = user_msg.split(":") | |
| p_name = parts[1] if len(parts) > 1 else "" | |
| p_price = parts[2] if len(parts) > 2 else "未知" | |
| if p_name: | |
| if user_id not in user_favorites: | |
| user_favorites[user_id] = [] | |
| if any(f["name"] == p_name for f in user_favorites[user_id]): | |
| line_bot_api.reply_message(event.reply_token, TextSendMessage(text=f"💝「{p_name}」已在收藏清單中囉!")) | |
| else: | |
| user_favorites[user_id].append({"name": p_name, "price": p_price, "note": "從熱銷排行榜加入"}) | |
| line_bot_api.reply_message(event.reply_token, TextSendMessage(text=f"🛒 已加入購物車!\n「{p_name}」")) | |
| return | |
| if not state: | |
| line_bot_api.reply_message( | |
| event.reply_token, | |
| TextSendMessage(text="請輸入「精靈顧問」來開啟膚況諮詢服務喔!") | |
| ) | |
| return | |
| current_step = state.get("step") | |
| if current_step == "ASK_SKIN": | |
| if user_msg in ["乾性肌", "油性肌", "混合肌", "敏感肌"]: | |
| state["skin_type"] = user_msg | |
| state["step"] = "ASK_ISSUE" | |
| issue_buttons = QuickReply(items=[ | |
| QuickReplyButton(action=MessageAction(label="嚴重脫皮", text="嚴重脫皮")), | |
| QuickReplyButton(action=MessageAction(label="暗沉無光", text="暗沉無光")), | |
| QuickReplyButton(action=MessageAction(label="痘痘粉刺", text="痘痘粉刺")), | |
| QuickReplyButton(action=MessageAction(label="泛紅乾癢", text="泛紅乾癢")) | |
| ]) | |
| line_bot_api.reply_message( | |
| event.reply_token, | |
| TextSendMessage(text=f"記錄下來了!你是 {user_msg}。那最近最讓你困擾的肌膚問題是什麼呢?", quick_reply=issue_buttons) | |
| ) | |
| else: | |
| line_bot_api.reply_message(event.reply_token, TextSendMessage(text="請選擇下方的膚質選項,或是輸入「重新開始」喔!")) | |
| elif current_step == "ASK_ISSUE": | |
| if user_msg in ["嚴重脫皮", "暗沉無光", "痘痘粉刺", "泛紅乾癢"]: | |
| state["issue"] = user_msg | |
| state["step"] = "ASK_PREFERENCE" | |
| pref_buttons = QuickReply(items=[ | |
| QuickReplyButton(action=MessageAction(label="偏好清爽凝露", text="偏好清爽凝露")), | |
| QuickReplyButton(action=MessageAction(label="偏好滋潤乳霜", text="偏好滋潤乳霜")), | |
| QuickReplyButton(action=MessageAction(label="都可以", text="都可以")) | |
| ]) | |
| line_bot_api.reply_message( | |
| event.reply_token, | |
| TextSendMessage(text=f"收到!針對 {user_msg} 的問題,你在挑選保養品時有特別偏好哪種質地嗎?", quick_reply=pref_buttons) | |
| ) | |
| else: | |
| line_bot_api.reply_message(event.reply_token, TextSendMessage(text="請選擇下方的肌膚問題選項,或是輸入「重新開始」喔!")) | |
| elif current_step == "ASK_PREFERENCE": | |
| if user_msg in ["偏好清爽凝露", "偏好滋潤乳霜", "都可以"]: | |
| skin_type = state["skin_type"] | |
| issue = state["issue"] | |
| preference = user_msg | |
| prompt = f""" | |
| 你現在是一位「台灣藥妝店皮膚科精靈顧問🧚♀️」,擁有10年以上保養諮詢經驗。 | |
| 使用者資料: | |
| * 膚質:{skin_type} | |
| * 困擾:{issue} | |
| * 偏好:{preference} | |
| 請根據以上資訊提供個人化保養建議。 | |
| 【輸出規則】 | |
| 1. 回覆控制在 180~250 字。 | |
| 2. 語氣溫柔、親切,像專業美容顧問。 | |
| 3. 使用繁體中文。 | |
| 4. 不要使用表格。 | |
| 5. 必須完全依照下列格式輸出: | |
| 🧚♀️ 哈囉親愛的! | |
| 🩺 精靈診斷 | |
| (用1~2句話分析目前膚況,需根據使用者的膚質與困擾客製化) | |
| 💡 保養建議 | |
| • (具體建議1) | |
| • (具體建議2) | |
| • (具體建議3) | |
| 🛍️ 推薦產品 | |
| ① (完整品牌+產品名稱) | |
| ⭐ (20字內推薦原因) | |
| ② (完整品牌+產品名稱) | |
| ⭐ (20字內推薦原因) | |
| ③ (完整品牌+產品名稱) | |
| ⭐ (20字內推薦原因) | |
| """ | |
| try: | |
| line_bot_api.reply_message(event.reply_token, TextSendMessage(text=model.generate_content(prompt).text)) | |
| except Exception as e: | |
| line_bot_api.reply_message(event.reply_token, TextSendMessage(text=f"❌ 精靈魔法發動失敗!\n{e}")) | |
| user_states.pop(user_id, None) | |
| else: | |
| line_bot_api.reply_message(event.reply_token, TextSendMessage(text="請選擇下方的質地偏好選項,或是輸入「重新開始」喔!")) | |
| elif current_step == "ASK_PHOTO_ITEM": | |
| try: | |
| pn, cor = normalize_product_name(user_msg) | |
| if pn is None: | |
| line_bot_api.reply_message(event.reply_token, TextSendMessage(text=f"🧚♀️ 精靈找不到「{user_msg}」這個商品耶~\n請輸入更完整的商品名稱或品牌!")) | |
| return | |
| if cor: | |
| _send_confirm_dialog(user_id, "photo", pn, user_msg, reply_token=event.reply_token) | |
| return | |
| _do_photo_query(user_id, event.reply_token, pn) | |
| except Exception as e: | |
| line_bot_api.reply_message(event.reply_token, TextSendMessage(text=f"❌ 查詢失敗:{e}")) | |
| # ── 社群真實口碑:收到商品名稱後產生評論 ── | |
| elif current_step == "ASK_REVIEW_PRODUCT": | |
| try: | |
| pn, cor = normalize_product_name(user_msg) | |
| if pn is None: | |
| line_bot_api.reply_message(event.reply_token, TextSendMessage(text=f"🔍 找不到「{user_msg}」的相關資訊耶~\n請輸入更完整的商品名稱或品牌!")) | |
| return | |
| if cor: | |
| _send_confirm_dialog(user_id, "review", pn, user_msg, reply_token=event.reply_token) | |
| return | |
| _do_review_query(user_id, event.reply_token, pn) | |
| except Exception as e: | |
| line_bot_api.reply_message(event.reply_token, TextSendMessage(text=f"❌ 查詢失敗:{e}")) | |
| # ── 熱銷排行榜:收到「其他類型」後詢問自訂類別 ── | |
| elif current_step == "ASK_RANKING_CATEGORY" and user_msg == "🔍 其他類型": | |
| user_states[user_id] = {"step": "ASK_RANKING_CUSTOM"} | |
| line_bot_api.reply_message(event.reply_token, TextSendMessage(text="🔍 請告訴我您想找的保養品類型,例如:「去角質」、「眼霜」、「頸霜」、「身體乳」等~")) | |
| return | |
| # ── 熱銷排行榜:自訂類別 AI 生成排行 ── | |
| elif current_step == "ASK_RANKING_CUSTOM": | |
| custom_category = user_msg | |
| try: | |
| ai_prompt = ( | |
| f"請列出台灣藥妝店最熱銷的「{custom_category}」TOP 5 商品。" | |
| "請用以下 JSON 格式回傳,不要多餘說明:\n" | |
| '[{"name":"商品全名","brand":"品牌名稱","price":"數字不含$","tag":"emoji+3字特色","sales":"月售 X,XXX+"}]' | |
| ) | |
| ai_resp = model.generate_content(ai_prompt) | |
| import re, json as _json | |
| raw = ai_resp.text.strip() | |
| match = re.search(r'\[.*\]', raw, re.DOTALL) | |
| items_data = _json.loads(match.group()) if match else [] | |
| rank_list = [dict(item, rank=i+1) for i, item in enumerate(items_data[:5])] | |
| except Exception: | |
| rank_list = [{"rank": 1, "name": f"{custom_category}相關商品", "brand": "綜合推薦", "price": "依品牌", "tag": "⭐ 精選推薦", "sales": "熱銷中"}] | |
| if user_id in user_states: | |
| del user_states[user_id] | |
| rank_colors = ["#FFD700", "#C0C0C0", "#CD7F32", "#4A90D9", "#7ED321"] | |
| rank_medals = ["🥇", "🥈", "🥉", "4️⃣", "5️⃣"] | |
| bubbles = [] | |
| for item in rank_list: | |
| idx = item["rank"] - 1 | |
| color = rank_colors[idx] if idx < 5 else "#8c8c8c" | |
| medal = rank_medals[idx] if idx < 5 else f"#{item['rank']}" | |
| card = { | |
| "type": "bubble", "size": "mega", | |
| "header": { | |
| "type": "box", "layout": "horizontal", | |
| "backgroundColor": color, "paddingAll": "20px", | |
| "contents": [ | |
| {"type": "text", "text": medal, "size": "xxl", "flex": 1, "align": "center", "gravity": "center"}, | |
| {"type": "box", "layout": "vertical", "flex": 4, "justifyContent": "center", "contents": [ | |
| {"type": "text", "text": f"排行第 {item['rank']} 名", "size": "xl", "color": "#FFFFFF", "weight": "bold", "align": "start"}, | |
| {"type": "text", "text": item["tag"], "size": "sm", "color": "#FFFFFF", "align": "start", "margin": "xs"} | |
| ]} | |
| ] | |
| }, | |
| "body": { | |
| "type": "box", "layout": "vertical", "paddingAll": "20px", | |
| "contents": [ | |
| {"type": "text", "text": item["name"], "weight": "bold", "size": "lg", "wrap": True, "color": "#111111"}, | |
| {"type": "text", "text": item["brand"], "size": "sm", "color": "#8c8c8c", "margin": "sm"}, | |
| {"type": "box", "layout": "vertical", "spacing": "sm", "margin": "lg", "contents": [ | |
| {"type": "box", "layout": "baseline", "spacing": "sm", "contents": [ | |
| {"type": "text", "text": "參考售價", "size": "sm", "color": "#8c8c8c", "flex": 3}, | |
| {"type": "text", "text": f"${item['price']}", "size": "sm", "color": "#111111", "weight": "bold", "flex": 5} | |
| ]}, | |
| {"type": "box", "layout": "baseline", "spacing": "sm", "contents": [ | |
| {"type": "text", "text": "銷售熱度", "size": "sm", "color": "#8c8c8c", "flex": 3}, | |
| {"type": "text", "text": item["sales"], "size": "sm", "color": "#FF6B00", "weight": "bold", "flex": 5} | |
| ]} | |
| ]} | |
| ] | |
| }, | |
| "footer": { | |
| "type": "box", "layout": "vertical", | |
| "contents": [{"type": "button", "style": "primary", "color": "#000000", "height": "sm", | |
| "action": {"type": "message", "label": "💝 加入收藏", "text": f"快速收藏:{item['name']}:${item['price']}"}}] | |
| } | |
| } | |
| bubbles.append(card) | |
| flex_msg = FlexSendMessage.new_from_json_dict({ | |
| "type": "flex", "alt_text": f"🏆 {custom_category} 熱銷排行榜", | |
| "contents": {"type": "carousel", "contents": bubbles} | |
| }) | |
| line_bot_api.reply_message(event.reply_token, flex_msg) | |
| return | |
| # ── 熱銷排行榜:收到類別後顯示榜單 ── | |
| elif current_step == "ASK_RANKING_CATEGORY": | |
| category = user_msg | |
| valid_categories = ["💧 保濕", "✨ 美白", "🌿 修護", "🫧 痘痘粉刺", "☀️ 防曬", "😴 面膜", "🧴 乳液/乳霜", "🧼 洗面乳", "💎 精華液"] | |
| if category not in valid_categories: | |
| line_bot_api.reply_message(event.reply_token, TextSendMessage(text="請點擊下方按鈕選擇類別喔!")) | |
| return | |
| try: | |
| ai_prompt = ( | |
| f"請列出台灣藥妝店(屈臣氏/康是美/寶雅)最熱銷的「{category}」類保養品 TOP 5。" | |
| "請用以下 JSON 格式回傳,不要多餘說明:\n" | |
| '[{"name":"商品全名","brand":"品牌名稱","price":"數字不含$","tag":"emoji+3字特色","sales":"月售 X,XXX+"}]' | |
| ) | |
| import re as _re, json as _json | |
| ai_resp = model.generate_content(ai_prompt) | |
| raw = ai_resp.text.strip() | |
| match = _re.search(r'\[.*\]', raw, _re.DOTALL) | |
| items_data = _json.loads(match.group()) if match else [] | |
| rank_list = [dict(item, rank=i+1) for i, item in enumerate(items_data[:5])] | |
| except Exception: | |
| rank_list = [{"rank": 1, "name": f"{category}熱銷商品", "brand": "綜合推薦", "price": "依品牌", "tag": "⭐ 精選推薦", "sales": "熱銷中"}] | |
| rankings = { | |
| "💧 保濕": [ | |
| {"rank": 1, "name": "CeraVe 長效潤澤修護霜 340g", "brand": "CeraVe 適樂膚", "price": "619", "tag": "💧 神級保濕", "sales": "月售 5,000+"}, | |
| {"rank": 2, "name": "HADA LABO 極潤保濕乳液 140ml", "brand": "HADA LABO 肌研", "price": "299", "tag": "🎌 日系人氣", "sales": "月售 3,200+"}, | |
| {"rank": 3, "name": "Neutrogena 水活保濕精華 30ml", "brand": "Neutrogena 露得清", "price": "380", "tag": "🌊 輕透水感", "sales": "月售 2,800+"}, | |
| {"rank": 4, "name": "Avene 雅漾舒護活泉水 150ml", "brand": "Avene 雅漾", "price": "299", "tag": "🌿 敏肌首選", "sales": "月售 2,500+"}, | |
| {"rank": 5, "name": "Dr.Jart+ 積雪草修護霜 50ml", "brand": "Dr.Jart+", "price": "860", "tag": "🔬 修護強效", "sales": "月售 1,800+"}, | |
| ], | |
| "✨ 美白": [ | |
| {"rank": 1, "name": "SK-II 青春露 230ml", "brand": "SK-II", "price": "3900", "tag": "✨ 頂級亮白", "sales": "月售 2,800+"}, | |
| {"rank": 2, "name": "高絲 雪肌精乳液 200ml", "brand": "Kose 高絲", "price": "1200", "tag": "🌸 和漢美白", "sales": "月售 2,200+"}, | |
| {"rank": 3, "name": "資生堂 ELIXIR 亮白精華 40ml", "brand": "資生堂 Shiseido", "price": "1580", "tag": "💫 緊緻亮白", "sales": "月售 1,900+"}, | |
| {"rank": 4, "name": "Dr.Wu 杏仁酸亮白精華 15ml", "brand": "Dr.Wu", "price": "580", "tag": "🍑 溫和換膚", "sales": "月售 1,600+"}, | |
| {"rank": 5, "name": "mediheal 維他命C亮白面膜 10片", "brand": "MEDIHEAL", "price": "320", "tag": "🍋 平價入門", "sales": "月售 1,400+"}, | |
| ], | |
| "🌿 修護": [ | |
| {"rank": 1, "name": "La Roche-Posay B5修護精華 30ml", "brand": "理膚寶水", "price": "820", "tag": "🌿 屏障修護", "sales": "月售 3,500+"}, | |
| {"rank": 2, "name": "Avene 雅漾舒護修護霜 50ml", "brand": "Avene 雅漾", "price": "580", "tag": "💙 敏肌救星", "sales": "月售 2,800+"}, | |
| {"rank": 3, "name": "CeraVe 修護保濕乳霜 340g", "brand": "CeraVe 適樂膚", "price": "619", "tag": "🔬 三重神經醯胺", "sales": "月售 2,500+"}, | |
| {"rank": 4, "name": "Dr.Jart+ 積雪草舒緩霜 15ml", "brand": "Dr.Jart+", "price": "580", "tag": "🌱 積雪草鎮靜", "sales": "月售 2,100+"}, | |
| {"rank": 5, "name": "Vaseline 凡士林深層修護乳 400ml", "brand": "Vaseline", "price": "199", "tag": "💰 超值必備", "sales": "月售 1,800+"}, | |
| ], | |
| "🫧 痘痘粉刺": [ | |
| {"rank": 1, "name": "Paula's Choice BHA 2% 水楊酸精華 30ml", "brand": "Paula's Choice", "price": "950", "tag": "🫧 深層疏通", "sales": "月售 3,200+"}, | |
| {"rank": 2, "name": "Dr.Wu 杏仁酸15%亮白更新精華 15ml", "brand": "Dr.Wu", "price": "480", "tag": "🍑 溫和去角質", "sales": "月售 2,600+"}, | |
| {"rank": 3, "name": "理膚寶水 Effaclar 調理精華 30ml", "brand": "La Roche-Posay", "price": "780", "tag": "🔬 皮科推薦", "sales": "月售 2,300+"}, | |
| {"rank": 4, "name": "Stridex 水楊酸棉片 55片", "brand": "Stridex", "price": "299", "tag": "✅ 方便快速", "sales": "月售 2,000+"}, | |
| {"rank": 5, "name": "COSRX 蝸牛修護精華 96ml", "brand": "COSRX", "price": "420", "tag": "🐌 修護痘印", "sales": "月售 1,700+"}, | |
| ], | |
| "☀️ 防曬": [ | |
| {"rank": 1, "name": "Biore 碧柔水感防曬乳 SPF50+ 50ml", "brand": "Biore 碧柔", "price": "299", "tag": "☀️ 超輕盈", "sales": "月售 6,000+"}, | |
| {"rank": 2, "name": "Anessa 金鑽高效防曬乳 60ml", "brand": "Anessa 安耐曬", "price": "780", "tag": "🏅 戶外神器", "sales": "月售 4,200+"}, | |
| {"rank": 3, "name": "SKIN1004 超鎮靜防曬精華 50ml", "brand": "SKIN1004", "price": "550", "tag": "🍃 溫和無感", "sales": "月售 3,500+"}, | |
| {"rank": 4, "name": "肌研極潤防曬乳 SPF50 50ml", "brand": "Rohto 樂敦", "price": "320", "tag": "💆 保濕防曬", "sales": "月售 2,900+"}, | |
| {"rank": 5, "name": "ISDIN 防曬乳 SPF50+ 100ml", "brand": "ISDIN", "price": "1200", "tag": "🌟 皮科推薦", "sales": "月售 1,500+"}, | |
| ], | |
| "😴 面膜": [ | |
| {"rank": 1, "name": "我的美麗日記 黑珍珠面膜 10片", "brand": "我的美麗日記", "price": "199", "tag": "🖤 經典熱銷", "sales": "月售 8,500+"}, | |
| {"rank": 2, "name": "MEDIHEAL NMF水庫保濕面膜 10片", "brand": "MEDIHEAL", "price": "250", "tag": "💦 韓系保濕", "sales": "月售 6,200+"}, | |
| {"rank": 3, "name": "森田藥粧 玻尿酸精華面膜 8片", "brand": "森田藥粧", "price": "149", "tag": "💧 高cp值", "sales": "月售 5,800+"}, | |
| {"rank": 4, "name": "SNP 燕窩保濕面膜 10片", "brand": "SNP", "price": "280", "tag": "🪹 燕窩滋養", "sales": "月售 4,500+"}, | |
| {"rank": 5, "name": "SK-II 青春敷面膜 6片", "brand": "SK-II", "price": "1180", "tag": "✨ 頂級保養", "sales": "月售 2,200+"}, | |
| ], | |
| "🧴 乳液/乳霜": [ | |
| {"rank": 1, "name": "CeraVe 長效潤澤修護霜 340g", "brand": "CeraVe 適樂膚", "price": "619", "tag": "🔬 醫美等級", "sales": "月售 5,000+"}, | |
| {"rank": 2, "name": "Neutrogena 深層修護身體乳 400ml", "brand": "Neutrogena 露得清", "price": "299", "tag": "🌊 輕透吸收", "sales": "月售 4,200+"}, | |
| {"rank": 3, "name": "NIVEA 深層修護乳液 400ml", "brand": "Nivea 妮維雅", "price": "169", "tag": "💰 超值日常", "sales": "月售 3,800+"}, | |
| {"rank": 4, "name": "Eucerin 優色林修護乳液 250ml", "brand": "Eucerin 優色林", "price": "499", "tag": "🏥 敏肌皮科", "sales": "月售 2,600+"}, | |
| {"rank": 5, "name": "資生堂 ELIXIR 彈潤活膚乳液 130ml", "brand": "資生堂 Shiseido", "price": "980", "tag": "🌸 彈潤緊緻", "sales": "月售 1,900+"}, | |
| ], | |
| "🧼 洗面乳": [ | |
| {"rank": 1, "name": "SENKA 洗顏專科柔膚泡沫 120g", "brand": "SENKA 洗顏專科", "price": "199", "tag": "🫧 綿密泡沫", "sales": "月售 7,000+"}, | |
| {"rank": 2, "name": "CeraVe 溫和泡沫洗面乳 236ml", "brand": "CeraVe 適樂膚", "price": "399", "tag": "🔬 醫美配方", "sales": "月售 5,500+"}, | |
| {"rank": 3, "name": "FANCL 無添加淨化洗顏粉 50g", "brand": "FANCL", "price": "580", "tag": "🌸 無添加溫和", "sales": "月售 4,200+"}, | |
| {"rank": 4, "name": "DHC 橄欖柔膚洗面皂 90g", "brand": "DHC", "price": "280", "tag": "🫒 橄欖滋潤", "sales": "月售 3,600+"}, | |
| {"rank": 5, "name": "理膚寶水 控油調理洗面乳 150ml", "brand": "La Roche-Posay", "price": "650", "tag": "🛢️ 控油清爽", "sales": "月售 2,800+"}, | |
| ], | |
| "💎 精華液": [ | |
| {"rank": 1, "name": "SK-II 青春露 230ml", "brand": "SK-II", "price": "3900", "tag": "💎 殿堂級精華", "sales": "月售 2,800+"}, | |
| {"rank": 2, "name": "Estée Lauder 小棕瓶精華 50ml", "brand": "Estée Lauder 雅詩蘭黛", "price": "2800", "tag": "🤎 修護經典", "sales": "月售 2,200+"}, | |
| {"rank": 3, "name": "La Roche-Posay B5修護精華 30ml", "brand": "La Roche-Posay", "price": "820", "tag": "🌿 屏障修護", "sales": "月售 2,000+"}, | |
| {"rank": 4, "name": "COSRX 蝸牛修護精華 96ml", "brand": "COSRX", "price": "420", "tag": "🐌 高效修護", "sales": "月售 1,800+"}, | |
| {"rank": 5, "name": "資生堂 ELIXIR 亮白精華 40ml", "brand": "資生堂 Shiseido", "price": "1580", "tag": "✨ 亮白緊緻", "sales": "月售 1,500+"}, | |
| ], | |
| } | |
| rank_colors = ["#FFD700", "#C0C0C0", "#CD7F32", "#4A90D9", "#7ED321"] | |
| rank_medals = ["🥇", "🥈", "🥉", "4️⃣", "5️⃣"] | |
| bubbles = [] | |
| for item in rank_list: | |
| idx = item["rank"] - 1 | |
| color = rank_colors[idx] if idx < 5 else "#8c8c8c" | |
| medal = rank_medals[idx] if idx < 5 else f"#{item['rank']}" | |
| card = { | |
| "type": "bubble", "size": "mega", | |
| "header": { | |
| "type": "box", "layout": "horizontal", | |
| "backgroundColor": color, "paddingAll": "20px", | |
| "contents": [ | |
| {"type": "text", "text": medal, "size": "xxl", "flex": 1, "align": "center", "gravity": "center"}, | |
| {"type": "box", "layout": "vertical", "flex": 4, "justifyContent": "center", "contents": [ | |
| {"type": "text", "text": f"排行第 {item['rank']} 名", "size": "xl", "color": "#FFFFFF", "weight": "bold", "align": "start"}, | |
| {"type": "text", "text": item["tag"], "size": "sm", "color": "#FFFFFF", "align": "start", "margin": "xs"} | |
| ]} | |
| ] | |
| }, | |
| "body": { | |
| "type": "box", "layout": "vertical", "paddingAll": "20px", | |
| "contents": [ | |
| {"type": "text", "text": item["name"], "weight": "bold", "size": "lg", "wrap": True, "color": "#111111"}, | |
| {"type": "text", "text": item["brand"], "size": "sm", "color": "#8c8c8c", "margin": "sm"}, | |
| {"type": "box", "layout": "vertical", "spacing": "sm", "margin": "lg", "contents": [ | |
| {"type": "box", "layout": "baseline", "spacing": "sm", "contents": [ | |
| {"type": "text", "text": "參考售價", "size": "sm", "color": "#8c8c8c", "flex": 3}, | |
| {"type": "text", "text": f"${item['price']}", "size": "sm", "color": "#111111", "weight": "bold", "flex": 5} | |
| ]}, | |
| {"type": "box", "layout": "baseline", "spacing": "sm", "contents": [ | |
| {"type": "text", "text": "銷售熱度", "size": "sm", "color": "#8c8c8c", "flex": 3}, | |
| {"type": "text", "text": item["sales"], "size": "sm", "color": "#FF6B00", "weight": "bold", "flex": 5} | |
| ]} | |
| ]} | |
| ] | |
| }, | |
| "footer": { | |
| "type": "box", "layout": "vertical", | |
| "contents": [{"type": "button", "style": "primary", "color": "#000000", "height": "sm", | |
| "action": {"type": "message", "label": "💝 加入收藏", "text": f"快速收藏:{item['name']}:${item['price']}"}}] | |
| } | |
| } | |
| bubbles.append(card) | |
| flex_msg = FlexSendMessage.new_from_json_dict({ | |
| "type": "flex", "alt_text": f"🏆 {category} 熱銷排行榜", | |
| "contents": {"type": "carousel", "contents": bubbles} | |
| }) | |
| line_bot_api.reply_message(event.reply_token, flex_msg) | |
| if user_id in user_states: | |
| del user_states[user_id] | |
| # ── 收藏商品:輸入商品名稱後儲存 ── | |
| elif current_step == "ASK_FAVORITE_NAME": | |
| try: | |
| pn, cor = normalize_product_name(user_msg) | |
| if pn is None: | |
| line_bot_api.reply_message(event.reply_token, TextSendMessage(text=f"💝 找不到「{user_msg}」這個商品耶~\n請輸入更完整的商品名稱或品牌!")) | |
| return | |
| if cor: | |
| _send_confirm_dialog(user_id, "favorite", pn, user_msg, reply_token=event.reply_token) | |
| return | |
| _do_favorite_save(user_id, event.reply_token, pn) | |
| except Exception as e: | |
| line_bot_api.reply_message(event.reply_token, TextSendMessage(text=f"❌ 收藏失敗:{e}")) | |
| # ── 商品確認對話 ── | |
| elif current_step == "CONFIRM_PRODUCT": | |
| feature = state.get("feature") | |
| product_name = state.get("product_name") | |
| step_map = {"photo": "ASK_PHOTO_ITEM", "review": "ASK_REVIEW_PRODUCT", "favorite": "ASK_FAVORITE_NAME"} | |
| retry_msg = { | |
| "photo": "請重新輸入商品名稱,精靈幫您查詢 📷", | |
| "review": "請重新輸入想查詢口碑的商品名稱 💬", | |
| "favorite": "請重新輸入想收藏的商品名稱 💝", | |
| } | |
| if user_msg == "✅ 對,就是這個": | |
| del user_states[user_id] | |
| if feature == "photo": | |
| _do_photo_query(user_id, event.reply_token, product_name) | |
| elif feature == "review": | |
| _do_review_query(user_id, event.reply_token, product_name) | |
| elif feature == "favorite": | |
| _do_favorite_save(user_id, event.reply_token, product_name) | |
| elif user_msg == "❌ 不是,重新輸入": | |
| user_states[user_id] = {"step": step_map[feature]} | |
| line_bot_api.reply_message(event.reply_token, TextSendMessage(text=retry_msg[feature])) | |
| else: | |
| confirm_qr = QuickReply(items=[ | |
| QuickReplyButton(action=MessageAction(label="✅ 對,就是這個", text="✅ 對,就是這個")), | |
| QuickReplyButton(action=MessageAction(label="❌ 不是,重新輸入", text="❌ 不是,重新輸入")), | |
| ]) | |
| line_bot_api.reply_message(event.reply_token, TextSendMessage( | |
| text=f"請點選下方按鈕確認~您是指「{product_name}」嗎?", | |
| quick_reply=confirm_qr | |
| )) | |
| def test_push(user_id): | |
| try: | |
| line_bot_api.push_message(user_id, TextSendMessage(text="✅ push_message 測試成功!")) | |
| return f"OK: 已推送到 {user_id}" | |
| except Exception as e: | |
| return f"FAIL: {type(e).__name__}: {e}", 500 | |
| if __name__ == "__main__": | |
| app.run(host='0.0.0.0', port=7860, threaded=True) | |