| import os |
| 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__) |
|
|
| |
| 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): |
| 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="❌ 不是,重新輸入")), |
| ]) |
| line_bot_api.push_message(user_id, 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: |
| line_bot_api.push_message(user_id, msg) |
|
|
| def _do_photo_query(user_id, reply_token, product_name): |
| _send_or_push(user_id, reply_token, TextSendMessage(text="✨ 收到文字!精靈正在幫您查詢這款產品,請稍候...")) |
| try: |
| if product_name in fake_database: |
| data_list = fake_database[product_name] |
| else: |
| estimate_prompt = f""" |
| 請估算「{product_name}」這款產品在台灣屈臣氏、康是美、寶雅的合理售價。 |
| 請務必「只回傳」一個標準的 JSON 陣列,不要有任何額外的說明文字。格式嚴格如下: |
| [ |
| {{"store": "屈臣氏", "name": "{product_name}", "price": "估計數字"}}, |
| {{"store": "康是美", "name": "{product_name}", "price": "估計數字"}}, |
| {{"store": "寶雅", "name": "{product_name}", "price": "估計數字"}} |
| ] |
| """ |
| est_response = model.generate_content(estimate_prompt) |
| clean_text = est_response.text.replace("```json", "").replace("```", "").strip() |
| data_list = json.loads(clean_text) |
|
|
| 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", "contents": [ |
| {"type": "button", "style": "primary", "color": "#000000", "height": "md", |
| "action": {"type": "uri", "label": "前往商家購買", "uri": safe_link}} |
| ]} |
| } |
| bubbles.append(card) |
|
|
| flex_message = FlexSendMessage.new_from_json_dict({ |
| "type": "flex", "alt_text": "🔍 藥妝精靈比價報告來囉!", |
| "contents": {"type": "carousel", "contents": bubbles} |
| }) |
| line_bot_api.push_message(user_id, flex_message) |
| except Exception as e: |
| line_bot_api.push_message(user_id, 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): |
| _send_or_push(user_id, reply_token, TextSendMessage(text=f"🔍 正在搜尋「{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} |
| }) |
| line_bot_api.push_message(user_id, 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) |
| line_bot_api.push_message(user_id, TextSendMessage(text=summary_resp.text.strip())) |
| except Exception as e: |
| line_bot_api.push_message(user_id, 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): |
| _send_or_push(user_id, reply_token, TextSendMessage(text=f"✨ 正在查詢「{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: |
| line_bot_api.push_message(user_id, 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} |
| }) |
| line_bot_api.push_message(user_id, [flex_msg, TextSendMessage(text=f"✅ 已加入收藏!共有 {len(favorites)} 件商品", quick_reply=add_qr)]) |
|
|
| |
| 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"} |
| ] |
| } |
|
|
| |
| user_states = {} |
| user_favorites = {} |
|
|
| @app.route("/callback", methods=['POST']) |
| 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' |
|
|
| |
| @handler.add(MessageEvent, message=ImageMessage) |
| 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": |
| line_bot_api.reply_message( |
| event.reply_token, |
| TextSendMessage(text="✨ 收到照片!精靈正在用魔法辨識這款產品,請稍候...") |
| ) |
|
|
| try: |
| message_content = line_bot_api.get_message_content(event.message.id) |
| image_bytes = io.BytesIO(message_content.content) |
| pil_image = Image.open(image_bytes) |
|
|
| vision_prompt = """ |
| 請辨識這張圖片中的保養品。 |
| 如果是 CeraVe (適樂膚) 的產品,請直接回答:CeraVe適樂膚 長效潤澤修護霜 340g |
| 如果是 Avene (雅漾) 活泉水,請直接回答:Avene 雅漾舒護活泉水150ml |
| 如果都不是,請發揮你的專業,判斷它是什麼真實的品牌與品名,並「直接回答品牌與品名」。 |
| 注意:非常重要!無論如何,你只能輸出「商品名稱」,絕對不要輸出任何其他廢話、標點符號、價格或商家資訊。 |
| """ |
|
|
| response = model.generate_content([vision_prompt, pil_image]) |
| product_name = response.text.strip() |
|
|
| if product_name in fake_database: |
| data_list = fake_database[product_name] |
| else: |
| estimate_prompt = f""" |
| 請估算「{product_name}」這款產品在台灣屈臣氏、康是美、寶雅的合理售價。 |
| 請務必「只回傳」一個標準的 JSON 陣列,不要有任何額外的說明文字。格式嚴格如下: |
| [ |
| {{"store": "屈臣氏", "name": "{product_name}", "price": "估計數字"}}, |
| {{"store": "康是美", "name": "{product_name}", "price": "估計數字"}}, |
| {{"store": "寶雅", "name": "{product_name}", "price": "估計數字"}} |
| ] |
| """ |
| est_response = model.generate_content(estimate_prompt) |
| clean_text = est_response.text.replace("```json", "").replace("```", "").strip() |
| data_list = json.loads(clean_text) |
|
|
| bubbles = [] |
| for item in data_list: |
| 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" |
| } |
|
|
| safe_store = item.get("store", "推薦藥妝店") |
| safe_name = item.get("name", "未知名稱商品") |
| safe_price = item.get("price", "請洽門市") |
| safe_logo = item.get("logo", default_logos.get(safe_store, "https://via.placeholder.com/600x300/eeeeee/999999?text=Store+Logo")) |
| 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_box = { |
| "type": "box", |
| "layout": "vertical", |
| "spacing": "sm", |
| "margin": "lg", |
| "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_box["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}, |
| specs_box |
| ] |
| }, |
| "footer": { |
| "type": "box", |
| "layout": "vertical", |
| "contents": [ |
| { |
| "type": "button", |
| "style": "primary", |
| "color": "#000000", |
| "height": "md", |
| "action": { |
| "type": "uri", |
| "label": "前往商家購買", |
| "uri": safe_link |
| } |
| } |
| ] |
| } |
| } |
| bubbles.append(card) |
|
|
| flex_message = FlexSendMessage.new_from_json_dict({ |
| "type": "flex", |
| "alt_text": "🔍 藥妝精靈比價報告來囉!", |
| "contents": { |
| "type": "carousel", |
| "contents": bubbles |
| } |
| }) |
| line_bot_api.push_message(user_id, flex_message) |
| if user_id in user_states: del user_states[user_id] |
|
|
| except Exception as e: |
| line_bot_api.push_message( |
| user_id, |
| TextSendMessage(text=f"❌ 哎呀!精靈遇到了一點問題。錯誤原因:\n{e}") |
| ) |
|
|
| |
| @handler.add(MessageEvent, message=LocationMessage) |
| 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('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: |
| line_bot_api.reply_message(event.reply_token, TextSendMessage(text="🧚♀️ 精靈正在為您精準定位各品牌門市,請稍候...")) |
|
|
| 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.push_message(user_id, 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.push_message(user_id, flex_message) |
| if user_id in user_states: del user_states[user_id] |
|
|
| except Exception as e: |
| line_bot_api.push_message(user_id, TextSendMessage(text=f"❌ 搜尋過程發生錯誤:{e}")) |
| if user_id in user_states: del user_states[user_id] |
|
|
| |
| @handler.add(MessageEvent, message=TextMessage) |
| 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 == "拍照精靈": |
| 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 |
|
|
| line_bot_api.reply_message( |
| event.reply_token, |
| TextSendMessage(text="🧚♀️ 精靈正在為您翻閱保養圖鑑,請稍候幾秒鐘...") |
| ) |
|
|
| prompt = f""" |
| 你現在是一位「台灣藥妝店皮膚科精靈顧問🧚♀️」,擁有10年以上保養諮詢經驗。 |
| 使用者資料: |
| * 膚質:{skin_type} |
| * 困擾:{issue} |
| * 偏好:{preference} |
| 請根據以上資訊提供個人化保養建議。 |
| |
| 【輸出規則】 |
| 1. 回覆控制在 180~250 字。 |
| 2. 語氣溫柔、親切,像專業美容顧問。 |
| 3. 使用繁體中文。 |
| 4. 不要使用表格。 |
| 5. 必須完全依照下列格式輸出: |
| |
| 🧚♀️ 哈囉親愛的! |
| |
| 🩺 精靈診斷 |
| (用1~2句話分析目前膚況,需根據使用者的膚質與困擾客製化) |
| |
| 💡 保養建議 |
| • (具體建議1) |
| • (具體建議2) |
| • (具體建議3) |
| |
| 🛍️ 推薦產品 |
| ① (完整品牌+產品名稱) |
| ⭐ (20字內推薦原因) |
| ② (完整品牌+產品名稱) |
| ⭐ (20字內推薦原因) |
| ③ (完整品牌+產品名稱) |
| ⭐ (20字內推薦原因) |
| """ |
| try: |
| response = model.generate_content(prompt) |
| line_bot_api.push_message(user_id, TextSendMessage(text=response.text)) |
| except Exception as e: |
| line_bot_api.push_message(user_id, TextSendMessage(text=f"❌ 精靈魔法發動失敗!錯誤原因:\n{e}")) |
|
|
| del user_states[user_id] |
| else: |
| line_bot_api.reply_message(event.reply_token, TextSendMessage(text="請選擇下方的質地偏好選項,或是輸入「重新開始」喔!")) |
|
|
| elif current_step == "ASK_PHOTO_ITEM": |
| line_bot_api.reply_message(event.reply_token, TextSendMessage(text="🔍 精靈正在辨識商品,請稍候...")) |
| product_name, corrected = normalize_product_name(user_msg) |
| if product_name is None: |
| line_bot_api.push_message(user_id, TextSendMessage( |
| text=f"🧚♀️ 精靈找不到「{user_msg}」這個商品耶~\n請輸入更完整的商品名稱或品牌,例如:「CeraVe 乳液」、「資生堂防曬」" |
| )) |
| return |
| if corrected: |
| _send_confirm_dialog(user_id, "photo", product_name, user_msg) |
| return |
| _do_photo_query(user_id, None, product_name) |
|
|
| |
| elif current_step == "ASK_REVIEW_PRODUCT": |
| line_bot_api.reply_message(event.reply_token, TextSendMessage(text="🔍 精靈正在辨識商品,請稍候...")) |
| product_name, corrected = normalize_product_name(user_msg) |
| if product_name is None: |
| line_bot_api.push_message(user_id, TextSendMessage( |
| text=f"🔍 找不到「{user_msg}」的相關資訊耶~\n請輸入更完整的商品名稱或品牌,例如:「CeraVe 乳液」、「Bioderma 卸妝水」" |
| )) |
| return |
| if corrected: |
| _send_confirm_dialog(user_id, "review", product_name, user_msg) |
| return |
| _do_review_query(user_id, None, product_name) |
|
|
| |
| 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 |
|
|
| |
| elif current_step == "ASK_RANKING_CUSTOM": |
| custom_category = user_msg |
| line_bot_api.reply_message(event.reply_token, TextSendMessage(text=f"🏆 正在整理「{custom_category}」熱銷排行榜,請稍候...")) |
| 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.push_message(user_id, 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 |
| line_bot_api.reply_message(event.reply_token, TextSendMessage(text=f"🏆 正在整理「{category}」熱銷排行榜,請稍候...")) |
| 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_list = rankings[category] |
| 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.push_message(user_id, flex_msg) |
| if user_id in user_states: |
| del user_states[user_id] |
|
|
| |
| elif current_step == "ASK_FAVORITE_NAME": |
| line_bot_api.reply_message(event.reply_token, TextSendMessage(text="🔍 精靈正在辨識商品,請稍候...")) |
| product_name, corrected = normalize_product_name(user_msg) |
| if product_name is None: |
| line_bot_api.push_message(user_id, TextSendMessage( |
| text=f"💝 找不到「{user_msg}」這個商品耶~\n請輸入更完整的商品名稱或品牌,例如:「FANCL 卸妝油」、「蘭蔻小黑瓶」" |
| )) |
| return |
| if corrected: |
| _send_confirm_dialog(user_id, "favorite", product_name, user_msg) |
| return |
| _do_favorite_save(user_id, None, product_name) |
|
|
| |
| 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 |
| )) |
|
|
| if __name__ == "__main__": |
| app.run(host='0.0.0.0', port=7860) |
|
|