Nai-Yun commited on
Commit
7d26fcb
·
0 Parent(s):

initial commit

Browse files
Files changed (8) hide show
  1. .DS_Store +0 -0
  2. .gitignore +6 -0
  3. Dockerfile +9 -0
  4. app.py +1251 -0
  5. fff +919 -0
  6. ppp +585 -0
  7. requirements.txt +6 -0
  8. scraper.py +233 -0
.DS_Store ADDED
Binary file (6.15 kB). View file
 
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ .venv/
5
+ .env
6
+ *.egg-info/
Dockerfile ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+ WORKDIR /app
3
+ COPY requirements.txt .
4
+ RUN pip install --no-cache-dir -r requirements.txt
5
+ RUN playwright install --with-deps chromium
6
+ RUN python -m camoufox fetch
7
+ COPY . .
8
+ EXPOSE 7860
9
+ CMD ["python", "app.py"]
app.py ADDED
@@ -0,0 +1,1251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import requests
3
+ import google.generativeai as genai
4
+ from flask import Flask, request, abort
5
+ from linebot import LineBotApi, WebhookHandler
6
+ from linebot.exceptions import InvalidSignatureError
7
+ import io
8
+ import json
9
+ from PIL import Image
10
+ from linebot.models import (
11
+ MessageEvent, TextMessage, TextSendMessage, QuickReply,
12
+ QuickReplyButton, MessageAction, ImageMessage, FlexSendMessage,
13
+ LocationMessage, LocationAction
14
+ )
15
+ import urllib.parse
16
+ import math
17
+ from datetime import datetime, timezone, timedelta
18
+ from scraper import scrape_all_stores
19
+
20
+ app = Flask(__name__)
21
+
22
+ # ================= 1. 從環境變數讀取金鑰 =================
23
+ line_bot_api = LineBotApi(os.environ.get('LINE_CHANNEL_ACCESS_TOKEN'))
24
+ handler = WebhookHandler(os.environ.get('LINE_CHANNEL_SECRET'))
25
+ genai.configure(api_key=os.environ.get('GEMINI_API_KEY'))
26
+ model = genai.GenerativeModel('gemini-2.5-flash')
27
+
28
+ def normalize_product_name(user_input: str):
29
+ """
30
+ 用 Gemini 將模糊/錯字/簡稱輸入辨識為正確商品名稱。
31
+ 回傳 (正確名稱, 是否已修正),辨識失敗回傳 (None, False)。
32
+ """
33
+ prompt = (
34
+ f"使用者輸入了「{user_input}」,這可能是台灣藥妝保養品的商品名稱,"
35
+ "但可能有錯字、簡稱、中英混打、繁簡混用或描述性說法。\n"
36
+ "請推測最可能的正確商品全名(使用台灣通用名稱,保留品牌英文名)。\n"
37
+ "規則:\n"
38
+ "- 能辨識 → 只輸出正確商品名稱,不要任何說明\n"
39
+ "- 完全無法辨識(非保養品/化妝品/藥妝相關)→ 只輸出 UNKNOWN\n"
40
+ "範例:「cerave乳液」→「CeraVe 修護保濕乳霜」;「貝德馬卸妝」→「Bioderma 貝德瑪卸妝水」"
41
+ )
42
+ try:
43
+ resp = model.generate_content(prompt)
44
+ result = resp.text.strip().strip("「」")
45
+ if result == "UNKNOWN" or not result:
46
+ return None, False
47
+ corrected = result != user_input
48
+ return result, corrected
49
+ except Exception:
50
+ return user_input, False
51
+
52
+ def _send_confirm_dialog(user_id, feature, recognized, original_input):
53
+ user_states[user_id] = {
54
+ "step": "CONFIRM_PRODUCT",
55
+ "feature": feature,
56
+ "product_name": recognized,
57
+ "original": original_input,
58
+ }
59
+ confirm_qr = QuickReply(items=[
60
+ QuickReplyButton(action=MessageAction(label="✅ 對,就是這個", text="✅ 對,就是這個")),
61
+ QuickReplyButton(action=MessageAction(label="❌ 不是,重新輸入", text="❌ 不是,重新輸入")),
62
+ ])
63
+ line_bot_api.push_message(user_id, TextSendMessage(
64
+ text=f"🔍 您是指「{recognized}」嗎?",
65
+ quick_reply=confirm_qr
66
+ ))
67
+
68
+ def _send_or_push(user_id, reply_token, msg):
69
+ if reply_token:
70
+ line_bot_api.reply_message(reply_token, msg)
71
+ else:
72
+ line_bot_api.push_message(user_id, msg)
73
+
74
+ def _do_photo_query(user_id, reply_token, product_name):
75
+ _send_or_push(user_id, reply_token, TextSendMessage(text="✨ 收到文字!精靈正在幫您查詢這款產品,請稍候..."))
76
+ try:
77
+ if product_name in fake_database:
78
+ data_list = fake_database[product_name]
79
+ else:
80
+ # 嘗試爬蟲取得真實售價與商品連結
81
+ scraped = scrape_all_stores(product_name)
82
+ if scraped:
83
+ data_list = scraped
84
+ else:
85
+ # 爬蟲失敗才退回 AI 估算
86
+ estimate_prompt = f"""
87
+ 請估算「{product_name}」這款產品在台灣屈臣氏、康是美、寶雅的合理售價。
88
+ 請務必「只回傳」一個標準的 JSON 陣列,不要有任何額外的說明文字。格式嚴格如下:
89
+ [
90
+ {{"store": "屈臣氏", "name": "{product_name}", "price": "估計數字"}},
91
+ {{"store": "康是美", "name": "{product_name}", "price": "估計數字"}},
92
+ {{"store": "寶雅", "name": "{product_name}", "price": "估計數字"}}
93
+ ]
94
+ """
95
+ est_response = model.generate_content(estimate_prompt)
96
+ clean_text = est_response.text.replace("```json", "").replace("```", "").strip()
97
+ data_list = json.loads(clean_text)
98
+
99
+ default_logos = {
100
+ "屈臣氏": "https://openhouse.osa.nycu.edu.tw/media/data/company_logos/3._%E5%B1%88%E8%87%A3%E6%B0%8Flogo.png",
101
+ "康是美": "https://img.skmbuy.com.tw/App_Images/727/Brand/2022/1215/4b8e1829-8f30-46a9-b044-834c11fc884a.jpg",
102
+ "寶雅": "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"
103
+ }
104
+ bubbles = []
105
+ for item in data_list:
106
+ safe_store = item.get("store", "推薦藥妝店")
107
+ safe_name = item.get("name", product_name)
108
+ raw_price = item.get("price", "請洽門市")
109
+ # 爬蟲��傳已含 NT$ 前綴;AI 估算只有數字
110
+ safe_price_display = str(raw_price) if item.get("real") else f"NT${raw_price}"
111
+ safe_logo = item.get("logo", default_logos.get(safe_store, ""))
112
+ safe_discount = item.get("discount", "")
113
+ original_price = item.get("original_price", "")
114
+ # 爬蟲有真實商品頁 URL;否則退到 Google 搜尋
115
+ if item.get("url"):
116
+ safe_link = item["url"]
117
+ else:
118
+ search_query = urllib.parse.quote(f"{safe_store} {safe_name}")
119
+ safe_link = item.get("link", f"https://www.google.com/search?q={search_query}")
120
+
121
+ specs_contents = [
122
+ {"type": "box", "layout": "horizontal", "contents": [
123
+ {"type": "text", "text": "販售商家", "size": "md", "color": "#8c8c8c", "flex": 1},
124
+ {"type": "text", "text": safe_store, "size": "md", "color": "#111111", "weight": "bold", "flex": 2}
125
+ ]},
126
+ {"type": "box", "layout": "horizontal", "contents": [
127
+ {"type": "text", "text": "商品售價", "size": "md", "color": "#8c8c8c", "flex": 1},
128
+ {"type": "text", "text": safe_price_display, "size": "md", "color": "#e8192c", "weight": "bold", "flex": 2}
129
+ ]}
130
+ ]
131
+ # 有原價代表打折,顯示原價(刪除線)+折扣百分比
132
+ if original_price:
133
+ try:
134
+ cur_val = int(str(raw_price).replace("NT$", "").replace(",", ""))
135
+ ori_val = int(original_price.replace("NT$", "").replace(",", ""))
136
+ pct = round(cur_val / ori_val * 10, 1)
137
+ discount_tag = f"🔥 {pct}折|省 NT${ori_val - cur_val}"
138
+ except Exception:
139
+ discount_tag = ""
140
+ specs_contents.append({"type": "box", "layout": "horizontal", "contents": [
141
+ {"type": "text", "text": "原  價", "size": "sm", "color": "#8c8c8c", "flex": 1},
142
+ {"type": "text", "text": original_price, "size": "sm", "color": "#aaaaaa", "decoration": "line-through", "flex": 2}
143
+ ]})
144
+ if discount_tag:
145
+ specs_contents.append({"type": "box", "layout": "horizontal", "contents": [
146
+ {"type": "text", "text": "折扣優惠", "size": "sm", "color": "#8c8c8c", "flex": 1},
147
+ {"type": "text", "text": discount_tag, "size": "sm", "color": "#ff3b30", "weight": "bold", "flex": 2}
148
+ ]})
149
+ if safe_discount:
150
+ specs_contents.append({"type": "box", "layout": "horizontal", "contents": [
151
+ {"type": "text", "text": "獨家優惠", "size": "md", "color": "#8c8c8c", "flex": 1},
152
+ {"type": "text", "text": safe_discount, "size": "md", "color": "#ff3b30", "weight": "bold", "flex": 2}
153
+ ]})
154
+
155
+ card = {
156
+ "type": "bubble", "size": "mega",
157
+ "hero": {"type": "image", "url": safe_logo, "size": "full", "aspectRatio": "2:1", "aspectMode": "fit", "backgroundColor": "#FFFFFF"},
158
+ "body": {"type": "box", "layout": "vertical", "contents": [
159
+ {"type": "text", "text": safe_name, "weight": "bold", "size": "xl", "wrap": True},
160
+ {"type": "box", "layout": "vertical", "spacing": "sm", "margin": "lg", "contents": specs_contents}
161
+ ]},
162
+ "footer": {"type": "box", "layout": "vertical", "contents": [
163
+ {"type": "button", "style": "primary", "color": "#000000", "height": "md",
164
+ "action": {"type": "uri", "label": "前往商家購買", "uri": safe_link}}
165
+ ]}
166
+ }
167
+ bubbles.append(card)
168
+
169
+ flex_message = FlexSendMessage.new_from_json_dict({
170
+ "type": "flex", "alt_text": "🔍 藥妝精靈比價報告來囉!",
171
+ "contents": {"type": "carousel", "contents": bubbles}
172
+ })
173
+ line_bot_api.push_message(user_id, flex_message)
174
+ except Exception as e:
175
+ line_bot_api.push_message(user_id, TextSendMessage(text=f"❌ 哎呀!精靈遇到了一點問題。錯誤原因:\n{e}"))
176
+ if user_id in user_states:
177
+ del user_states[user_id]
178
+
179
+ def _do_review_query(user_id, reply_token, product_name):
180
+ _send_or_push(user_id, reply_token, TextSendMessage(text=f"🔍 正在搜尋「{product_name}」的社群口碑,請稍候..."))
181
+ review_prompt = f"""
182
+ 你是一個台灣保養品社群平台,請為「{product_name}」這款商品生成 3 則真實風格的用戶評價。
183
+ 必須「只回傳」一個標準的 JSON 陣列,不要有任何額外說明文字。格式嚴格如下:
184
+ [
185
+ {{"reviewer": "身份描述+英文名(如 小資女Sandy、上班族Cathy、學生妹Mia、熟齡女Vivian)", "skin_type": "膚質(乾性肌/油性肌/混合肌/敏感肌)", "rating": ��分數字(1-5), "date": "日期(如 2025/03/15)", "review": "評價內容(50-80字,繁體中文,具體使用心得)", "helpful": 點讚數(0-50之間整數), "pros": ["優點標籤(最多5字)", "優點標籤"], "cons": ["缺點標籤(最多5字)"]}},
186
+ {{"reviewer": "...", "skin_type": "...", "rating": ..., "date": "...", "review": "...", "helpful": ..., "pros": [...], "cons": [...]}},
187
+ {{"reviewer": "...", "skin_type": "...", "rating": ..., "date": "...", "review": "...", "helpful": ..., "pros": [...], "cons": [...]}}
188
+ ]
189
+ pros 最多 2 個,cons 最多 1 個,每個標籤不超過 6 個中文字。
190
+ """
191
+ try:
192
+ review_resp = model.generate_content(review_prompt)
193
+ clean = review_resp.text.replace("```json", "").replace("```", "").strip()
194
+ reviews = json.loads(clean)
195
+ star_map = {5: "⭐⭐⭐⭐⭐", 4: "⭐⭐⭐⭐", 3: "⭐⭐⭐", 2: "⭐⭐", 1: "⭐"}
196
+ bubbles = []
197
+ for r in reviews:
198
+ stars = star_map.get(int(r.get("rating", 5)), "⭐⭐⭐⭐⭐")
199
+ pros = r.get("pros", [])
200
+ cons = r.get("cons", [])
201
+ tag_box_contents = []
202
+ for p in pros:
203
+ tag_box_contents.append({"type": "text", "text": f"✅ {p}", "size": "xs", "color": "#27AE60"})
204
+ for c in cons:
205
+ tag_box_contents.append({"type": "text", "text": f"⚠️ {c}", "size": "xs", "color": "#E67E22"})
206
+ body_contents = [
207
+ {"type": "text", "text": product_name, "size": "sm", "color": "#FF6B8A", "weight": "bold"},
208
+ {"type": "text", "text": r.get("review", ""), "size": "sm", "color": "#333333", "wrap": True, "margin": "md"},
209
+ ]
210
+ if tag_box_contents:
211
+ body_contents.append({"type": "box", "layout": "vertical", "margin": "md", "contents": tag_box_contents})
212
+ body_contents.append({"type": "box", "layout": "horizontal", "margin": "md",
213
+ "contents": [{"type": "text", "text": f"👍 {r.get('helpful', 0)} 人覺得有幫助", "size": "xs", "color": "#8c8c8c"}]})
214
+ card = {
215
+ "type": "bubble", "size": "mega",
216
+ "header": {
217
+ "type": "box", "layout": "vertical",
218
+ "backgroundColor": "#FFF8E7", "paddingAll": "15px",
219
+ "contents": [{"type": "box", "layout": "horizontal",
220
+ "contents": [
221
+ {"type": "box", "layout": "vertical", "flex": 1,
222
+ "contents": [{"type": "text", "text": "👤", "size": "xxl", "align": "center"}]},
223
+ {"type": "box", "layout": "vertical", "flex": 4,
224
+ "contents": [
225
+ {"type": "text", "text": r.get("reviewer", "匿名"), "weight": "bold", "size": "md", "color": "#111111"},
226
+ {"type": "text", "text": r.get("skin_type", ""), "size": "xs", "color": "#8c8c8c"},
227
+ {"type": "text", "text": stars, "size": "sm", "margin": "xs"}
228
+ ]},
229
+ {"type": "text", "text": r.get("date", ""), "size": "xs", "color": "#8c8c8c", "align": "end", "flex": 2}
230
+ ]}]
231
+ },
232
+ "body": {"type": "box", "layout": "vertical", "contents": body_contents}
233
+ }
234
+ bubbles.append(card)
235
+ flex_msg = FlexSendMessage.new_from_json_dict({
236
+ "type": "flex", "alt_text": f"💬 {product_name} 的社群口碑",
237
+ "contents": {"type": "carousel", "contents": bubbles}
238
+ })
239
+ line_bot_api.push_message(user_id, flex_msg)
240
+ avg_rating = sum(int(r.get("rating", 5)) for r in reviews) / len(reviews)
241
+ summary_prompt = f"""
242
+ 根據以下針對「{product_name}」的評論資料,生成一段精簡總結。
243
+ 評論資料:{json.dumps(reviews, ensure_ascii=False)}
244
+
245
+ 必須完全依照下列格式輸出,不要有其他文字:
246
+
247
+ 🧴 {product_name}
248
+
249
+ ⭐ 社群好評度:{avg_rating:.1f} / 5
250
+
251
+ 👍 網友最常提到
252
+
253
+ 1️⃣ (優點1)
254
+ 2️⃣ (優點2)
255
+ 3️⃣ (優點3)
256
+
257
+ 👎 常見缺點
258
+
259
+ • (10-15字,描述具體膚質或情境限制,如「對油性肌或T字部位可能較悶厚」)
260
+ • (10-15字,另一個具體使用限制,如「夏季高溫使用容易感覺黏膩」)
261
+
262
+ 🧚‍♀️ 精靈總結
263
+
264
+ (1句話,15~25字,點出最適合族群與核心優勢,繁體中文)
265
+ """
266
+ summary_resp = model.generate_content(summary_prompt)
267
+ line_bot_api.push_message(user_id, TextSendMessage(text=summary_resp.text.strip()))
268
+ except Exception as e:
269
+ line_bot_api.push_message(user_id, TextSendMessage(text=f"❌ 口碑查詢失敗:{e}"))
270
+ if user_id in user_states:
271
+ del user_states[user_id]
272
+
273
+ def _build_fav_card(index, fav):
274
+ return {
275
+ "type": "bubble", "size": "mega",
276
+ "header": {
277
+ "type": "box", "layout": "vertical",
278
+ "backgroundColor": "#FFE8F0", "paddingAll": "12px",
279
+ "contents": [{"type": "text", "text": f"❤️ 收藏 #{index+1}", "weight": "bold", "size": "sm", "color": "#FF6B8A"}]
280
+ },
281
+ "body": {
282
+ "type": "box", "layout": "vertical",
283
+ "contents": [
284
+ {"type": "text", "text": fav["name"], "weight": "bold", "size": "lg", "wrap": True, "color": "#111111"},
285
+ {"type": "box", "layout": "vertical", "spacing": "sm", "margin": "lg",
286
+ "contents": [
287
+ {"type": "box", "layout": "baseline", "spacing": "sm", "contents": [
288
+ {"type": "text", "text": "參考售價", "size": "sm", "color": "#8c8c8c", "flex": 3},
289
+ {"type": "text", "text": fav.get("price", "未知"), "size": "sm", "color": "#111111", "weight": "bold", "flex": 5}
290
+ ]},
291
+ {"type": "box", "layout": "baseline", "spacing": "sm", "contents": [
292
+ {"type": "text", "text": "備註", "size": "sm", "color": "#8c8c8c", "flex": 3},
293
+ {"type": "text", "text": fav.get("note", "無"), "size": "sm", "color": "#111111", "flex": 5, "wrap": True}
294
+ ]}
295
+ ]}
296
+ ]
297
+ },
298
+ "footer": {
299
+ "type": "box", "layout": "vertical", "spacing": "sm",
300
+ "contents": [
301
+ {"type": "button", "style": "primary", "color": "#000000", "height": "sm",
302
+ "action": {"type": "message", "label": "🛒 前往購物", "text": f"查詢購買:{fav['name']}"}},
303
+ {"type": "button", "style": "secondary", "height": "sm",
304
+ "action": {"type": "message", "label": "🗑️ 刪除此收藏", "text": f"刪除收藏:{fav['name']}"}}
305
+ ]
306
+ }
307
+ }
308
+
309
+ def _do_favorite_save(user_id, reply_token, product_name):
310
+ _send_or_push(user_id, reply_token, TextSendMessage(text=f"✨ 正在查詢「{product_name}」商品資訊..."))
311
+ try:
312
+ price_resp = model.generate_content(f"請告訴我「{product_name}」在台灣藥妝店的大約售價,只回傳數字,不要貨幣符號或其他文字。")
313
+ price_text = price_resp.text.strip().replace("$", "").replace("NT", "").replace(",", "").strip()
314
+ price_display = f"${price_text}"
315
+ except Exception:
316
+ price_display = "未知"
317
+ if user_id not in user_favorites:
318
+ user_favorites[user_id] = []
319
+ already_exists = any(f["name"] == product_name for f in user_favorites[user_id])
320
+ if already_exists:
321
+ line_bot_api.push_message(user_id, TextSendMessage(text=f"💝「{product_name}」已在您的收藏清單中囉!"))
322
+ else:
323
+ user_favorites[user_id].append({"name": product_name, "price": price_display, "note": "手動加入收藏"})
324
+ if user_id in user_states:
325
+ del user_states[user_id]
326
+ # 顯示更新後的收藏圖卡
327
+ favorites = user_favorites.get(user_id, [])
328
+ add_qr = QuickReply(items=[QuickReplyButton(action=MessageAction(label="➕ 新增收藏", text="新增收藏"))])
329
+ if not already_exists and favorites:
330
+ bubbles = [_build_fav_card(i, fav) for i, fav in enumerate(favorites)]
331
+ flex_msg = FlexSendMessage.new_from_json_dict({
332
+ "type": "flex", "alt_text": "💝 您的收藏清單",
333
+ "contents": {"type": "carousel", "contents": bubbles}
334
+ })
335
+ line_bot_api.push_message(user_id, [flex_msg, TextSendMessage(text=f"✅ 已加入收藏!共有 {len(favorites)} 件商品", quick_reply=add_qr)])
336
+
337
+ # ================= 建立虛擬藥妝資料庫 =================
338
+ fake_database = {
339
+ "CeraVe適樂膚 長效潤澤修護霜 340g": [
340
+ {"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"},
341
+ {"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"},
342
+ {"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"}
343
+ ],
344
+ "Avene 雅漾舒護活泉水150ml": [
345
+ {"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"},
346
+ {"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"},
347
+ {"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"}
348
+ ]
349
+ }
350
+
351
+ # ================= 2. 狀態儲存區 =================
352
+ user_states = {}
353
+ user_favorites = {} # {user_id: [{"name": "...", "price": "...", "note": "..."}]}
354
+
355
+ @app.route("/callback", methods=['POST'])
356
+ def callback():
357
+ signature = request.headers['X-Line-Signature']
358
+ body = request.get_data(as_text=True)
359
+ try:
360
+ handler.handle(body, signature)
361
+ except InvalidSignatureError:
362
+ abort(400)
363
+ return 'OK'
364
+
365
+ # ================= 3. 拍照精靈 (圖片處理) =================
366
+ @handler.add(MessageEvent, message=ImageMessage)
367
+ def handle_image(event):
368
+ user_id = event.source.user_id
369
+ state = user_states.get(user_id)
370
+
371
+ if state and state.get("step") == "ASK_PHOTO_ITEM":
372
+ line_bot_api.reply_message(
373
+ event.reply_token,
374
+ TextSendMessage(text="✨ 收到照片!精靈正在用魔法辨識這款產品,請稍候...")
375
+ )
376
+
377
+ try:
378
+ message_content = line_bot_api.get_message_content(event.message.id)
379
+ image_bytes = io.BytesIO(message_content.content)
380
+ pil_image = Image.open(image_bytes)
381
+
382
+ vision_prompt = """
383
+ 請辨識這張圖片中的保養品。
384
+ 如果是 CeraVe (適樂膚) 的產品,請直接回答:CeraVe適樂膚 長效潤澤修護霜 340g
385
+ 如果是 Avene (雅漾) 活泉水,請直接回答:Avene 雅漾舒護活泉水150ml
386
+ 如果都不是,請發揮你的專業,判斷它是什麼真實的品牌與品名,並「直接回答品牌與品名」。
387
+ 注意:非常重要!無論如何,你只能輸出「商品名稱」,絕對不要輸出任何其他廢話、標點符號、價格或商家資訊。
388
+ """
389
+
390
+ response = model.generate_content([vision_prompt, pil_image])
391
+ product_name = response.text.strip()
392
+
393
+ if product_name in fake_database:
394
+ data_list = fake_database[product_name]
395
+ else:
396
+ estimate_prompt = f"""
397
+ 請估算「{product_name}」這款產品在台灣屈臣氏、康是美、寶雅的合理售價。
398
+ 請務必「只回傳」一個標準的 JSON 陣列,不要有任何額外的說明文字。格式嚴格如下:
399
+ [
400
+ {{"store": "屈臣氏", "name": "{product_name}", "price": "估計數字"}},
401
+ {{"store": "康是美", "name": "{product_name}", "price": "估計數字"}},
402
+ {{"store": "寶雅", "name": "{product_name}", "price": "估計數字"}}
403
+ ]
404
+ """
405
+ est_response = model.generate_content(estimate_prompt)
406
+ clean_text = est_response.text.replace("```json", "").replace("```", "").strip()
407
+ data_list = json.loads(clean_text)
408
+
409
+ bubbles = []
410
+ for item in data_list:
411
+ default_logos = {
412
+ "屈臣氏": "https://openhouse.osa.nycu.edu.tw/media/data/company_logos/3._%E5%B1%88%E8%87%A3%E6%B0%8Flogo.png",
413
+ "康是美": "https://img.skmbuy.com.tw/App_Images/727/Brand/2022/1215/4b8e1829-8f30-46a9-b044-834c11fc884a.jpg",
414
+ "寶雅": "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"
415
+ }
416
+
417
+ safe_store = item.get("store", "推薦藥妝店")
418
+ safe_name = item.get("name", "未知名稱商品")
419
+ safe_price = item.get("price", "請洽門市")
420
+ safe_logo = item.get("logo", default_logos.get(safe_store, "https://via.placeholder.com/600x300/eeeeee/999999?text=Store+Logo"))
421
+ safe_discount = item.get("discount", "")
422
+
423
+ search_query = urllib.parse.quote(f"{safe_store} {safe_name}")
424
+ safe_link = item.get("link", f"https://www.google.com/search?q={search_query}")
425
+
426
+ specs_box = {
427
+ "type": "box",
428
+ "layout": "vertical",
429
+ "spacing": "sm",
430
+ "margin": "lg",
431
+ "contents": [
432
+ {
433
+ "type": "box",
434
+ "layout": "horizontal",
435
+ "contents": [
436
+ {"type": "text", "text": "販售商家", "size": "md", "color": "#8c8c8c", "flex": 1},
437
+ {"type": "text", "text": safe_store, "size": "md", "color": "#111111", "weight": "bold", "flex": 2}
438
+ ]
439
+ },
440
+ {
441
+ "type": "box",
442
+ "layout": "horizontal",
443
+ "contents": [
444
+ {"type": "text", "text": "商品售價", "size": "md", "color": "#8c8c8c", "flex": 1},
445
+ {"type": "text", "text": f"${safe_price}", "size": "md", "color": "#111111", "weight": "bold", "flex": 2}
446
+ ]
447
+ }
448
+ ]
449
+ }
450
+
451
+ if safe_discount:
452
+ specs_box["contents"].append(
453
+ {
454
+ "type": "box",
455
+ "layout": "horizontal",
456
+ "contents": [
457
+ {"type": "text", "text": "獨家優惠", "size": "md", "color": "#8c8c8c", "flex": 1},
458
+ {"type": "text", "text": safe_discount, "size": "md", "color": "#ff3b30", "weight": "bold", "flex": 2}
459
+ ]
460
+ }
461
+ )
462
+
463
+ card = {
464
+ "type": "bubble",
465
+ "size": "mega",
466
+ "hero": {
467
+ "type": "image",
468
+ "url": safe_logo,
469
+ "size": "full",
470
+ "aspectRatio": "2:1",
471
+ "aspectMode": "fit",
472
+ "backgroundColor": "#FFFFFF"
473
+ },
474
+ "body": {
475
+ "type": "box",
476
+ "layout": "vertical",
477
+ "contents": [
478
+ {"type": "text", "text": safe_name, "weight": "bold", "size": "xl", "wrap": True},
479
+ specs_box
480
+ ]
481
+ },
482
+ "footer": {
483
+ "type": "box",
484
+ "layout": "vertical",
485
+ "contents": [
486
+ {
487
+ "type": "button",
488
+ "style": "primary",
489
+ "color": "#000000",
490
+ "height": "md",
491
+ "action": {
492
+ "type": "uri",
493
+ "label": "前往商家購買",
494
+ "uri": safe_link
495
+ }
496
+ }
497
+ ]
498
+ }
499
+ }
500
+ bubbles.append(card)
501
+
502
+ flex_message = FlexSendMessage.new_from_json_dict({
503
+ "type": "flex",
504
+ "alt_text": "🔍 藥妝精靈比價報告來囉!",
505
+ "contents": {
506
+ "type": "carousel",
507
+ "contents": bubbles
508
+ }
509
+ })
510
+ line_bot_api.push_message(user_id, flex_message)
511
+ if user_id in user_states: del user_states[user_id]
512
+
513
+ except Exception as e:
514
+ line_bot_api.push_message(
515
+ user_id,
516
+ TextSendMessage(text=f"❌ 哎呀!精靈遇到了一點問題。錯誤原因:\n{e}")
517
+ )
518
+
519
+ # ================= 4. 附近店家 (位置處理) =================
520
+ @handler.add(MessageEvent, message=LocationMessage)
521
+ def handle_location(event):
522
+ user_id = event.source.user_id
523
+ user_lat = event.message.latitude
524
+ user_lon = event.message.longitude
525
+
526
+ state = user_states.get(user_id, {})
527
+ brand_preference = state.get("search_brand", "全都要")
528
+
529
+ # ✅ 完美修正:正確的環境變數名稱
530
+ google_api_key = os.environ.get('Maps_API_KEY')
531
+ if not google_api_key:
532
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text="❌ 系統尚未設定 Google Maps API 金鑰。"))
533
+ return
534
+
535
+ def calculate_distance(lat1, lon1, lat2, lon2):
536
+ R = 6371.0
537
+ lat1_rad, lon1_rad = math.radians(lat1), math.radians(lon1)
538
+ lat2_rad, lon2_rad = math.radians(lat2), math.radians(lon2)
539
+ dlon = lon2_rad - lon1_rad
540
+ dlat = lat2_rad - lat1_rad
541
+ a = math.sin(dlat / 2)**2 + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon / 2)**2
542
+ c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
543
+ return int(R * c * 1000)
544
+
545
+ def get_exact_hours(place_id, api_key):
546
+ url = f"https://maps.googleapis.com/maps/api/place/details/json?place_id={place_id}&fields=opening_hours&language=zh-TW&key={api_key}"
547
+ try:
548
+ res = requests.get(url).json()
549
+ if 'result' in res and 'opening_hours' in res['result']:
550
+ weekday_text = res['result']['opening_hours'].get('weekday_text', [])
551
+ if weekday_text:
552
+ tw_tz = timezone(timedelta(hours=8))
553
+ today_idx = datetime.now(tw_tz).weekday()
554
+ today_str = weekday_text[today_idx]
555
+ if ": " in today_str:
556
+ return today_str.split(": ", 1)[1]
557
+ except Exception:
558
+ pass
559
+ return "詳情請洽門市"
560
+
561
+ brands_to_search = ["屈臣氏", "康是美", "寶雅"] if brand_preference == "全都要" else [brand_preference]
562
+ search_radius = 3000
563
+
564
+ try:
565
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text="🧚‍♀️ 精靈正在為您精準定位各品牌門市,請稍候..."))
566
+
567
+ nearest_stores = []
568
+
569
+ for brand in brands_to_search:
570
+ 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}"
571
+ response = requests.get(url).json()
572
+
573
+ if response['status'] == 'OK' and response.get('results'):
574
+ brand_stores = []
575
+ for place in response['results']:
576
+ store_lat = place['geometry']['location']['lat']
577
+ store_lon = place['geometry']['location']['lng']
578
+ distance = calculate_distance(user_lat, user_lon, store_lat, store_lon)
579
+
580
+ photo_ref = ""
581
+ if 'photos' in place and len(place['photos']) > 0:
582
+ photo_ref = place['photos'][0]['photo_reference']
583
+
584
+ open_now = place.get('opening_hours', {}).get('open_now')
585
+ if open_now is True:
586
+ status_light = "🟢 營業中"
587
+ elif open_now is False:
588
+ status_light = "🔴 休息中"
589
+ else:
590
+ status_light = "🕒 營業狀態"
591
+
592
+ brand_stores.append({
593
+ "place_id": place.get('place_id'),
594
+ "name": place.get('name', '未知名稱'),
595
+ "rating": place.get('rating', '無評分'),
596
+ "lat": store_lat,
597
+ "lon": store_lon,
598
+ "distance": distance,
599
+ "address": place.get('formatted_address', ''),
600
+ "status_light": status_light,
601
+ "photo_ref": photo_ref
602
+ })
603
+
604
+ sorted_brand_stores = sorted(brand_stores, key=lambda x: x['distance'])
605
+
606
+ if brand_preference == "全都要":
607
+ if sorted_brand_stores:
608
+ nearest_stores.append(sorted_brand_stores[0])
609
+ else:
610
+ nearest_stores.extend(sorted_brand_stores[:3])
611
+
612
+ if not nearest_stores:
613
+ line_bot_api.push_message(user_id, TextSendMessage(text="🥺 哎呀,精靈在您附近 3 公里內都找不到藥妝店呢..."))
614
+ if user_id in user_states: del user_states[user_id]
615
+ return
616
+
617
+ nearest_stores = sorted(nearest_stores, key=lambda x: x['distance'])
618
+
619
+ bubbles = []
620
+ for store in nearest_stores:
621
+ exact_hours = get_exact_hours(store['place_id'], google_api_key)
622
+ display_hours = f"{store['status_light']} ({exact_hours})"
623
+
624
+ map_query = urllib.parse.quote(f"{store['name']} {store['address']}")
625
+ map_url = f"https://www.google.com/maps/search/?api=1&query={map_query}"
626
+
627
+ if store['photo_ref']:
628
+ image_url = f"https://maps.googleapis.com/maps/api/place/photo?maxwidth=600&photo_reference={store['photo_ref']}&key={google_api_key}"
629
+ else:
630
+ image_url = "https://images.unsplash.com/photo-1576426863848-c21f53c60b19?q=80&w=600&auto=format&fit=crop"
631
+
632
+ # ✅ 完美修正:排版乾淨、結構完全正確的規格表!
633
+ card = {
634
+ "type": "bubble",
635
+ "size": "mega",
636
+ "hero": {
637
+ "type": "image",
638
+ "url": image_url,
639
+ "size": "full",
640
+ "aspectRatio": "16:9",
641
+ "aspectMode": "cover"
642
+ },
643
+ "body": {
644
+ "type": "box",
645
+ "layout": "vertical",
646
+ "contents": [
647
+ {"type": "text", "text": store['name'], "weight": "bold", "size": "xl", "wrap": True, "color": "#111111"},
648
+ {
649
+ "type": "box",
650
+ "layout": "vertical",
651
+ "spacing": "sm",
652
+ "margin": "lg",
653
+ "contents": [
654
+ {
655
+ "type": "box",
656
+ "layout": "baseline",
657
+ "spacing": "sm",
658
+ "contents": [
659
+ {"type": "text", "text": "目前距離", "size": "sm", "color": "#8c8c8c", "flex": 3},
660
+ {"type": "text", "text": f"{store['distance']} 公尺", "size": "sm", "color": "#111111", "weight": "bold", "flex": 5}
661
+ ]
662
+ },
663
+ {
664
+ "type": "box",
665
+ "layout": "baseline",
666
+ "spacing": "sm",
667
+ "contents": [
668
+ {"type": "text", "text": "營業時間", "size": "sm", "color": "#8c8c8c", "flex": 3},
669
+ {"type": "text", "text": display_hours, "size": "sm", "color": "#111111", "weight": "bold", "flex": 5, "wrap": True}
670
+ ]
671
+ },
672
+ {
673
+ "type": "box",
674
+ "layout": "baseline",
675
+ "spacing": "sm",
676
+ "contents": [
677
+ {"type": "text", "text": "店家評分", "size": "sm", "color": "#8c8c8c", "flex": 3},
678
+ {"type": "text", "text": f"⭐ {store['rating']}", "size": "sm", "color": "#fbbc04", "weight": "bold", "flex": 5}
679
+ ]
680
+ }
681
+ ]
682
+ }
683
+ ]
684
+ },
685
+ "footer": {
686
+ "type": "box",
687
+ "layout": "vertical",
688
+ "contents": [
689
+ {
690
+ "type": "button",
691
+ "style": "primary",
692
+ "color": "#000000",
693
+ "height": "md",
694
+ "action": {
695
+ "type": "uri",
696
+ "label": "開啟地圖導航",
697
+ "uri": map_url
698
+ }
699
+ }
700
+ ]
701
+ }
702
+ }
703
+ bubbles.append(card)
704
+
705
+ flex_message = FlexSendMessage.new_from_json_dict({
706
+ "type": "flex",
707
+ "alt_text": "🔍 附近的藥妝店地圖出爐囉!",
708
+ "contents": {
709
+ "type": "carousel",
710
+ "contents": bubbles
711
+ }
712
+ })
713
+
714
+ line_bot_api.push_message(user_id, flex_message)
715
+ if user_id in user_states: del user_states[user_id]
716
+
717
+ except Exception as e:
718
+ line_bot_api.push_message(user_id, TextSendMessage(text=f"❌ 搜尋過程發生錯誤:{e}"))
719
+ if user_id in user_states: del user_states[user_id]
720
+
721
+ # ================= 5. 多輪對話核心邏輯 (文字對話) =================
722
+ @handler.add(MessageEvent, message=TextMessage)
723
+ def handle_message(event):
724
+ user_id = event.source.user_id
725
+ user_msg = event.message.text.strip()
726
+
727
+ if user_msg == "附近店家":
728
+ user_states[user_id] = {"step": "ASK_BRAND"}
729
+
730
+ brand_buttons = QuickReply(items=[
731
+ QuickReplyButton(action=MessageAction(label="屈臣氏", text="屈臣氏")),
732
+ QuickReplyButton(action=MessageAction(label="康是美", text="康是美")),
733
+ QuickReplyButton(action=MessageAction(label="寶雅", text="寶雅")),
734
+ QuickReplyButton(action=MessageAction(label="全都要", text="全都要"))
735
+ ])
736
+
737
+ line_bot_api.reply_message(
738
+ event.reply_token,
739
+ TextSendMessage(text="請問你想尋找哪一間藥妝店呢?", quick_reply=brand_buttons)
740
+ )
741
+ return
742
+
743
+ state = user_states.get(user_id)
744
+ if state and state.get("step") == "ASK_BRAND":
745
+ if user_msg in ["屈臣氏", "康是美", "寶雅", "全都要"]:
746
+ state["search_brand"] = user_msg
747
+ state["step"] = "ASK_LOCATION"
748
+
749
+ location_button = QuickReply(items=[
750
+ QuickReplyButton(action=LocationAction(label="📍 點我分享位置"))
751
+ ])
752
+
753
+ line_bot_api.reply_message(
754
+ event.reply_token,
755
+ TextSendMessage(text=f"沒問題!請分享您的位置,精靈來幫您找最近的【{user_msg}】!", quick_reply=location_button)
756
+ )
757
+ else:
758
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text="請點擊下方的按鈕選擇品牌喔!"))
759
+ return
760
+
761
+ if user_msg == "精靈顧問" or user_msg == "重新開始":
762
+ user_states[user_id] = {"step": "ASK_SKIN"}
763
+
764
+ skin_buttons = QuickReply(items=[
765
+ QuickReplyButton(action=MessageAction(label="乾性肌", text="乾性肌")),
766
+ QuickReplyButton(action=MessageAction(label="油性肌", text="油性肌")),
767
+ QuickReplyButton(action=MessageAction(label="混合肌", text="混合肌")),
768
+ QuickReplyButton(action=MessageAction(label="敏感肌", text="敏感肌"))
769
+ ])
770
+
771
+ line_bot_api.reply_message(
772
+ event.reply_token,
773
+ TextSendMessage(text="🧚‍♀️ 嗨!我是你的專屬精靈顧問。為了給你最精準的保養建議,先告訴我,你覺得自己偏向哪一種膚質呢?", quick_reply=skin_buttons)
774
+ )
775
+ return
776
+
777
+ if user_msg == "拍照精靈":
778
+ user_states[user_id] = {"step": "ASK_PHOTO_ITEM"}
779
+ line_bot_api.reply_message(
780
+ event.reply_token,
781
+ TextSendMessage(text="📷 歡迎開啟拍照精靈!\n您可以直接拍下商品照片傳給我,或是輸入「文字」來查詢商品售價喔!")
782
+ )
783
+ return
784
+
785
+ # ── 社群真實口碑 ──
786
+ if user_msg == "社群真實口碑":
787
+ user_states[user_id] = {"step": "ASK_REVIEW_PRODUCT"}
788
+ line_bot_api.reply_message(
789
+ event.reply_token,
790
+ [
791
+ TextSendMessage(text="💬 歡迎使用社群真實口碑!"),
792
+ TextSendMessage(text="請輸入您想查詢口碑的商品名稱,例如:「CeraVe 乳液」或「資生堂防曬」")
793
+ ]
794
+ )
795
+ return
796
+
797
+ # ── 熱銷排行榜 ──
798
+ if user_msg == "熱銷排行榜":
799
+ user_states[user_id] = {"step": "ASK_RANKING_CATEGORY"}
800
+ category_buttons = QuickReply(items=[
801
+ QuickReplyButton(action=MessageAction(label="💧 保濕", text="💧 保濕")),
802
+ QuickReplyButton(action=MessageAction(label="✨ 美白", text="✨ 美白")),
803
+ QuickReplyButton(action=MessageAction(label="🌿 修護", text="🌿 修護")),
804
+ QuickReplyButton(action=MessageAction(label="🫧 痘痘粉刺", text="🫧 痘痘粉刺")),
805
+ QuickReplyButton(action=MessageAction(label="☀️ 防曬", text="☀️ 防曬")),
806
+ QuickReplyButton(action=MessageAction(label="😴 面膜", text="😴 面膜")),
807
+ QuickReplyButton(action=MessageAction(label="🧴 乳液/乳霜", text="🧴 乳液/乳霜")),
808
+ QuickReplyButton(action=MessageAction(label="🧼 洗面乳", text="🧼 洗面乳")),
809
+ QuickReplyButton(action=MessageAction(label="💎 精華液", text="💎 精華液")),
810
+ QuickReplyButton(action=MessageAction(label="🔍 其他類型", text="🔍 其他類型")),
811
+ ])
812
+ line_bot_api.reply_message(
813
+ event.reply_token,
814
+ TextSendMessage(text="🏆 歡迎查看熱銷排行榜!\n請選擇您想查看的商品類別:", quick_reply=category_buttons)
815
+ )
816
+ return
817
+
818
+ # ── 收藏商品:查看清單 ──
819
+ if user_msg in ["收藏商品", "購物車"]:
820
+ favorites = user_favorites.get(user_id, [])
821
+ add_qr = QuickReply(items=[QuickReplyButton(action=MessageAction(label="➕ 新增收藏", text="新增收藏"))])
822
+ if not favorites:
823
+ line_bot_api.reply_message(
824
+ event.reply_token,
825
+ TextSendMessage(text="💝 您的收藏清單目前是空的!\n點擊下方按鈕新增您喜愛的商品吧~", quick_reply=add_qr)
826
+ )
827
+ else:
828
+ bubbles = [_build_fav_card(i, fav) for i, fav in enumerate(favorites)]
829
+ flex_msg = FlexSendMessage.new_from_json_dict({
830
+ "type": "flex", "alt_text": "💝 您的收藏清單",
831
+ "contents": {"type": "carousel", "contents": bubbles}
832
+ })
833
+ line_bot_api.reply_message(
834
+ event.reply_token,
835
+ [flex_msg, TextSendMessage(text=f"共有 {len(favorites)} 件收藏商品", quick_reply=add_qr)]
836
+ )
837
+ return
838
+
839
+ # ── 收藏商品:新增 ──
840
+ if user_msg == "新增收藏":
841
+ user_states[user_id] = {"step": "ASK_FAVORITE_NAME"}
842
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text="➕ 請輸入您想收藏的商品名稱:"))
843
+ return
844
+
845
+ # ── 收藏商品:刪除 ──
846
+ if user_msg.startswith("刪除收藏:"):
847
+ product_name = user_msg[5:]
848
+ favs = user_favorites.get(user_id, [])
849
+ user_favorites[user_id] = [f for f in favs if f["name"] != product_name]
850
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text=f"🗑️ 已從收藏中刪除「{product_name}」"))
851
+ return
852
+
853
+ # ── 熱銷排行榜快速收藏 ──
854
+ # ── 購物車「前往購物」→ 比價圖卡 ──
855
+ if user_msg.startswith("查詢購買:"):
856
+ buy_product = user_msg[5:]
857
+ if buy_product:
858
+ _do_photo_query(user_id, event.reply_token, buy_product)
859
+ return
860
+
861
+ if user_msg.startswith("快速收藏:"):
862
+ parts = user_msg.split(":")
863
+ p_name = parts[1] if len(parts) > 1 else ""
864
+ p_price = parts[2] if len(parts) > 2 else "未���"
865
+ if p_name:
866
+ if user_id not in user_favorites:
867
+ user_favorites[user_id] = []
868
+ if any(f["name"] == p_name for f in user_favorites[user_id]):
869
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text=f"💝「{p_name}」已在收藏清單中囉!"))
870
+ else:
871
+ user_favorites[user_id].append({"name": p_name, "price": p_price, "note": "從熱銷排行榜加入"})
872
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text=f"🛒 已加入購物車!\n「{p_name}」"))
873
+ return
874
+
875
+ if not state:
876
+ line_bot_api.reply_message(
877
+ event.reply_token,
878
+ TextSendMessage(text="請輸入「精靈顧問」來開啟膚況諮詢服務喔!")
879
+ )
880
+ return
881
+
882
+ current_step = state.get("step")
883
+
884
+ if current_step == "ASK_SKIN":
885
+ if user_msg in ["乾性肌", "油性肌", "混合肌", "敏感肌"]:
886
+ state["skin_type"] = user_msg
887
+ state["step"] = "ASK_ISSUE"
888
+
889
+ issue_buttons = QuickReply(items=[
890
+ QuickReplyButton(action=MessageAction(label="嚴重脫皮", text="嚴重脫皮")),
891
+ QuickReplyButton(action=MessageAction(label="暗沉無光", text="暗沉無光")),
892
+ QuickReplyButton(action=MessageAction(label="痘痘粉刺", text="痘痘粉刺")),
893
+ QuickReplyButton(action=MessageAction(label="泛紅乾癢", text="泛紅乾癢"))
894
+ ])
895
+
896
+ line_bot_api.reply_message(
897
+ event.reply_token,
898
+ TextSendMessage(text=f"記錄下來了!你是 {user_msg}。那最近最讓你困擾的肌膚問題是什麼呢?", quick_reply=issue_buttons)
899
+ )
900
+ else:
901
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text="請選擇下方的膚質選項,或是輸入「重新開始」喔!"))
902
+
903
+ elif current_step == "ASK_ISSUE":
904
+ if user_msg in ["嚴重脫皮", "暗沉無光", "痘痘粉刺", "泛紅乾癢"]:
905
+ state["issue"] = user_msg
906
+ state["step"] = "ASK_PREFERENCE"
907
+
908
+ pref_buttons = QuickReply(items=[
909
+ QuickReplyButton(action=MessageAction(label="偏好清爽凝露", text="偏好清爽凝露")),
910
+ QuickReplyButton(action=MessageAction(label="偏好滋潤乳霜", text="偏好滋潤乳霜")),
911
+ QuickReplyButton(action=MessageAction(label="都可以", text="都可以"))
912
+ ])
913
+
914
+ line_bot_api.reply_message(
915
+ event.reply_token,
916
+ TextSendMessage(text=f"收到!針對 {user_msg} 的問題,你在挑選保養品時有特別偏好哪種質地嗎?", quick_reply=pref_buttons)
917
+ )
918
+ else:
919
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text="請選擇下方的肌膚問題選項,或是輸入「重新開始」喔!"))
920
+
921
+ elif current_step == "ASK_PREFERENCE":
922
+ if user_msg in ["偏好清爽凝露", "偏好滋潤乳霜", "都可以"]:
923
+ skin_type = state["skin_type"]
924
+ issue = state["issue"]
925
+ preference = user_msg
926
+
927
+ line_bot_api.reply_message(
928
+ event.reply_token,
929
+ TextSendMessage(text="🧚‍♀️ 精靈正在為您翻閱保養圖鑑,請稍候幾秒鐘...")
930
+ )
931
+
932
+ prompt = f"""
933
+ 你現在是一位「台灣藥妝店皮膚科精靈顧問🧚‍♀️」,擁有10年以上保養諮詢經驗。
934
+ 使用者資料:
935
+ * 膚質:{skin_type}
936
+ * 困擾:{issue}
937
+ * 偏好:{preference}
938
+ 請根據以上資訊提供個人化保養建議。
939
+
940
+ 【輸出規則】
941
+ 1. 回覆控制在 180~250 字。
942
+ 2. 語氣溫柔、親切,像專業美容顧問。
943
+ 3. 使用繁體中文。
944
+ 4. 不要使用表格。
945
+ 5. 必須完全依照下列格式輸出:
946
+
947
+ 🧚‍♀️ 哈囉親愛的!
948
+
949
+ 🩺 精靈診斷
950
+ (用1~2句話分析目前膚況,需根據使用者的膚質與困擾客製化)
951
+
952
+ 💡 保養建議
953
+ • (具體建議1)
954
+ • (具體建議2)
955
+ • (具體建議3)
956
+
957
+ 🛍️ 推薦產品
958
+ ① (完整品牌+產品名稱)
959
+ ⭐ (20字內推薦原因)
960
+ ② (完整品牌+產品名稱)
961
+ ⭐ (20字內推薦原因)
962
+ ③ (完整品牌+產品名稱)
963
+ ⭐ (20字內推薦原因)
964
+ """
965
+ try:
966
+ response = model.generate_content(prompt)
967
+ line_bot_api.push_message(user_id, TextSendMessage(text=response.text))
968
+ except Exception as e:
969
+ line_bot_api.push_message(user_id, TextSendMessage(text=f"❌ 精靈魔法發動失敗!錯誤原因:\n{e}"))
970
+
971
+ del user_states[user_id]
972
+ else:
973
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text="請選擇下方的質地偏好選項,或是輸入「重新開始」喔!"))
974
+
975
+ elif current_step == "ASK_PHOTO_ITEM":
976
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text="🔍 精靈正在辨識商品,請稍候..."))
977
+ product_name, corrected = normalize_product_name(user_msg)
978
+ if product_name is None:
979
+ line_bot_api.push_message(user_id, TextSendMessage(
980
+ text=f"🧚‍♀️ 精靈找不到「{user_msg}」這個商品耶~\n請輸入更完整的商品名稱或品牌,例如:「CeraVe 乳液」、「資生堂防曬」"
981
+ ))
982
+ return
983
+ if corrected:
984
+ _send_confirm_dialog(user_id, "photo", product_name, user_msg)
985
+ return
986
+ _do_photo_query(user_id, None, product_name)
987
+
988
+ # ── 社群真實口碑:收到商品名稱後產生評論 ──
989
+ elif current_step == "ASK_REVIEW_PRODUCT":
990
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text="🔍 精靈正在辨識商品,請稍候..."))
991
+ product_name, corrected = normalize_product_name(user_msg)
992
+ if product_name is None:
993
+ line_bot_api.push_message(user_id, TextSendMessage(
994
+ text=f"🔍 找不到「{user_msg}」的相關資訊耶~\n請輸入更完整的商品名稱或品牌,例如:「CeraVe 乳液」、「Bioderma 卸妝水」"
995
+ ))
996
+ return
997
+ if corrected:
998
+ _send_confirm_dialog(user_id, "review", product_name, user_msg)
999
+ return
1000
+ _do_review_query(user_id, None, product_name)
1001
+
1002
+ # ── 熱銷排行榜:收到「其他類型」後詢問自訂類別 ──
1003
+ elif current_step == "ASK_RANKING_CATEGORY" and user_msg == "🔍 其他類型":
1004
+ user_states[user_id] = {"step": "ASK_RANKING_CUSTOM"}
1005
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text="🔍 請告訴我您想找的保養品類型,例如:「去角質」、「眼霜」、「頸霜」、「身體乳」等~"))
1006
+ return
1007
+
1008
+ # ── 熱銷排行榜:自訂類別 AI 生成排行 ──
1009
+ elif current_step == "ASK_RANKING_CUSTOM":
1010
+ custom_category = user_msg
1011
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text=f"🏆 正在整理「{custom_category}」熱銷排行榜,請稍候..."))
1012
+ try:
1013
+ ai_prompt = (
1014
+ f"請列出台灣藥妝店最熱銷的「{custom_category}」TOP 5 商品。"
1015
+ "請用以下 JSON 格式回傳,不要多餘說明:\n"
1016
+ '[{"name":"商品全名","brand":"品牌名稱","price":"數字不含$","tag":"emoji+3字特色","sales":"月售 X,XXX+"}]'
1017
+ )
1018
+ ai_resp = model.generate_content(ai_prompt)
1019
+ import re, json as _json
1020
+ raw = ai_resp.text.strip()
1021
+ match = re.search(r'\[.*\]', raw, re.DOTALL)
1022
+ items_data = _json.loads(match.group()) if match else []
1023
+ rank_list = [dict(item, rank=i+1) for i, item in enumerate(items_data[:5])]
1024
+ except Exception:
1025
+ rank_list = [{"rank": 1, "name": f"{custom_category}相關商品", "brand": "綜合推薦", "price": "依品牌", "tag": "⭐ 精選推薦", "sales": "熱銷中"}]
1026
+ if user_id in user_states:
1027
+ del user_states[user_id]
1028
+ rank_colors = ["#FFD700", "#C0C0C0", "#CD7F32", "#4A90D9", "#7ED321"]
1029
+ rank_medals = ["🥇", "🥈", "🥉", "4️⃣", "5️⃣"]
1030
+ bubbles = []
1031
+ for item in rank_list:
1032
+ idx = item["rank"] - 1
1033
+ color = rank_colors[idx] if idx < 5 else "#8c8c8c"
1034
+ medal = rank_medals[idx] if idx < 5 else f"#{item['rank']}"
1035
+ card = {
1036
+ "type": "bubble", "size": "mega",
1037
+ "header": {
1038
+ "type": "box", "layout": "horizontal",
1039
+ "backgroundColor": color, "paddingAll": "20px",
1040
+ "contents": [
1041
+ {"type": "text", "text": medal, "size": "xxl", "flex": 1, "align": "center", "gravity": "center"},
1042
+ {"type": "box", "layout": "vertical", "flex": 4, "justifyContent": "center", "contents": [
1043
+ {"type": "text", "text": f"排行第 {item['rank']} 名", "size": "xl", "color": "#FFFFFF", "weight": "bold", "align": "start"},
1044
+ {"type": "text", "text": item["tag"], "size": "sm", "color": "#FFFFFF", "align": "start", "margin": "xs"}
1045
+ ]}
1046
+ ]
1047
+ },
1048
+ "body": {
1049
+ "type": "box", "layout": "vertical", "paddingAll": "20px",
1050
+ "contents": [
1051
+ {"type": "text", "text": item["name"], "weight": "bold", "size": "lg", "wrap": True, "color": "#111111"},
1052
+ {"type": "text", "text": item["brand"], "size": "sm", "color": "#8c8c8c", "margin": "sm"},
1053
+ {"type": "box", "layout": "vertical", "spacing": "sm", "margin": "lg", "contents": [
1054
+ {"type": "box", "layout": "baseline", "spacing": "sm", "contents": [
1055
+ {"type": "text", "text": "參考售價", "size": "sm", "color": "#8c8c8c", "flex": 3},
1056
+ {"type": "text", "text": f"${item['price']}", "size": "sm", "color": "#111111", "weight": "bold", "flex": 5}
1057
+ ]},
1058
+ {"type": "box", "layout": "baseline", "spacing": "sm", "contents": [
1059
+ {"type": "text", "text": "銷售熱度", "size": "sm", "color": "#8c8c8c", "flex": 3},
1060
+ {"type": "text", "text": item["sales"], "size": "sm", "color": "#FF6B00", "weight": "bold", "flex": 5}
1061
+ ]}
1062
+ ]}
1063
+ ]
1064
+ },
1065
+ "footer": {
1066
+ "type": "box", "layout": "vertical",
1067
+ "contents": [{"type": "button", "style": "primary", "color": "#000000", "height": "sm",
1068
+ "action": {"type": "message", "label": "💝 加入購物車", "text": f"已收藏:{item['name']}:${item['price']}"}}]
1069
+ }
1070
+ }
1071
+ bubbles.append(card)
1072
+ flex_msg = FlexSendMessage.new_from_json_dict({
1073
+ "type": "flex", "alt_text": f"🏆 {custom_category} 熱銷排行榜",
1074
+ "contents": {"type": "carousel", "contents": bubbles}
1075
+ })
1076
+ line_bot_api.push_message(user_id, flex_msg)
1077
+ return
1078
+
1079
+ # ── 熱銷排行榜:收到類別後顯示榜單 ──
1080
+ elif current_step == "ASK_RANKING_CATEGORY":
1081
+ category = user_msg
1082
+ valid_categories = ["💧 保濕", "✨ 美白", "🌿 修護", "🫧 痘痘粉刺", "☀️ 防曬", "😴 面膜", "🧴 乳液/乳霜", "🧼 洗面乳", "💎 精華液"]
1083
+ if category not in valid_categories:
1084
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text="請點擊下方按鈕選擇類別喔!"))
1085
+ return
1086
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text=f"🏆 正在整理「{category}」熱銷排行榜,請稍候..."))
1087
+ rankings = {
1088
+ "💧 保濕": [
1089
+ {"rank": 1, "name": "CeraVe 長效潤澤修護霜 340g", "brand": "CeraVe 適樂膚", "price": "619", "tag": "💧 神級保濕", "sales": "月售 5,000+"},
1090
+ {"rank": 2, "name": "HADA LABO 極潤保濕乳液 140ml", "brand": "HADA LABO 肌研", "price": "396", "tag": "🎌 日系人氣", "sales": "月售 3,200+"},
1091
+ {"rank": 3, "name": "Neutrogena 水活保濕精華 30ml", "brand": "Neutrogena 露得清", "price": "679", "tag": "🌊 輕透水感", "sales": "月售 2,800+"},
1092
+ {"rank": 4, "name": "Avene 雅漾舒護活泉水 150ml", "brand": "Avene 雅漾", "price": "299", "tag": "🌿 敏肌首選", "sales": "月售 2,500+"},
1093
+ {"rank": 5, "name": "Dr.Jart+ 積雪草修護霜 50ml", "brand": "Dr.Jart+", "price": "1500", "tag": "🔬 修護強效", "sales": "月售 1,800+"},
1094
+ ],
1095
+ "✨ 美白": [
1096
+ {"rank": 1, "name": "SK-II 青春露 230ml", "brand": "SK-II", "price": "3199", "tag": "✨ 頂級亮白", "sales": "月售 2,800+"},
1097
+ {"rank": 2, "name": "高絲 雪肌精乳液 200ml", "brand": "Kose 高絲", "price": "1100", "tag": "🌸 和漢美白", "sales": "月售 2,200+"},
1098
+ {"rank": 3, "name": "資生堂 ELIXIR 亮白精華 40ml", "brand": "資生堂 Shiseido", "price": "1580", "tag": "💫 緊緻亮白", "sales": "月售 1,900+"},
1099
+ {"rank": 4, "name": "Dr.Wu 杏仁酸亮白精華 15ml", "brand": "Dr.Wu", "price": "850", "tag": "🍑 溫和換膚", "sales": "月售 1,600+"},
1100
+ {"rank": 5, "name": "mediheal 維他命C亮白面膜 10片", "brand": "MEDIHEAL", "price": "419", "tag": "🍋 平價入門", "sales": "月售 1,400+"},
1101
+ ],
1102
+ "🌿 修護": [
1103
+ {"rank": 1, "name": "La Roche-Posay B5修護精華 30ml", "brand": "理膚寶水", "price": "820", "tag": "🌿 屏障修護", "sales": "月售 3,500+"},
1104
+ {"rank": 2, "name": "Avene 雅漾舒護修護霜 50ml", "brand": "Avene 雅漾", "price": "580", "tag": "💙 敏肌救星", "sales": "月售 2,800+"},
1105
+ {"rank": 3, "name": "CeraVe 修護保濕乳霜 340g", "brand": "CeraVe 適樂膚", "price": "619", "tag": "🔬 三重神經醯胺", "sales": "月售 2,500+"},
1106
+ {"rank": 4, "name": "Dr.Jart+ 積雪草舒緩霜 15ml", "brand": "Dr.Jart+", "price": "580", "tag": "🌱 積雪草鎮靜", "sales": "月售 2,100+"},
1107
+ {"rank": 5, "name": "Vaseline 凡士林深層修護乳 400ml", "brand": "Vaseline", "price": "199", "tag": "💰 超值必備", "sales": "月售 1,800+"},
1108
+ ],
1109
+ "🫧 痘痘粉刺": [
1110
+ {"rank": 1, "name": "Paula's Choice BHA 2% 水楊酸精華 30ml", "brand": "Paula's Choice", "price": "950", "tag": "🫧 深層疏通", "sales": "月售 3,200+"},
1111
+ {"rank": 2, "name": "Dr.Wu 杏仁酸15%亮白更新精華 15ml", "brand": "Dr.Wu", "price": "480", "tag": "🍑 溫和去角質", "sales": "月售 2,600+"},
1112
+ {"rank": 3, "name": "理膚寶水 Effaclar 調理精華 30ml", "brand": "La Roche-Posay", "price": "780", "tag": "🔬 皮科推薦", "sales": "月售 2,300+"},
1113
+ {"rank": 4, "name": "Stridex 水楊酸棉片 55片", "brand": "Stridex", "price": "299", "tag": "✅ 方便快速", "sales": "月售 2,000+"},
1114
+ {"rank": 5, "name": "COSRX 蝸牛修護精華 96ml", "brand": "COSRX", "price": "420", "tag": "🐌 修護痘印", "sales": "月售 1,700+"},
1115
+ ],
1116
+ "☀️ 防曬": [
1117
+ {"rank": 1, "name": "Biore 碧柔水感防曬乳 SPF50+ 50ml", "brand": "Biore 碧柔", "price": "299", "tag": "☀️ 超輕盈", "sales": "月售 6,000+"},
1118
+ {"rank": 2, "name": "Anessa 金鑽高效防曬乳 60ml", "brand": "Anessa 安耐曬", "price": "780", "tag": "🏅 戶外神器", "sales": "月售 4,200+"},
1119
+ {"rank": 3, "name": "SKIN1004 超鎮靜防曬精華 50ml", "brand": "SKIN1004", "price": "550", "tag": "🍃 溫和無感", "sales": "月售 3,500+"},
1120
+ {"rank": 4, "name": "肌研極潤防曬乳 SPF50 50ml", "brand": "Rohto 樂敦", "price": "320", "tag": "💆 保濕防曬", "sales": "月售 2,900+"},
1121
+ {"rank": 5, "name": "ISDIN 防曬乳 SPF50+ 100ml", "brand": "ISDIN", "price": "1200", "tag": "🌟 皮科推薦", "sales": "月售 1,500+"},
1122
+ ],
1123
+ "😴 面膜": [
1124
+ {"rank": 1, "name": "我的美麗日記 黑珍珠面膜 10片", "brand": "我的美麗日記", "price": "199", "tag": "🖤 經典熱銷", "sales": "月售 8,500+"},
1125
+ {"rank": 2, "name": "MEDIHEAL NMF水庫保濕面膜 10片", "brand": "MEDIHEAL", "price": "250", "tag": "💦 韓系保濕", "sales": "月售 6,200+"},
1126
+ {"rank": 3, "name": "森田藥粧 玻尿酸精華面膜 8片", "brand": "森田藥粧", "price": "149", "tag": "💧 高cp值", "sales": "月售 5,800+"},
1127
+ {"rank": 4, "name": "SNP 燕窩保濕面膜 10片", "brand": "SNP", "price": "280", "tag": "🪹 燕窩滋養", "sales": "月售 4,500+"},
1128
+ {"rank": 5, "name": "SK-II 青春敷面膜 6片", "brand": "SK-II", "price": "1180", "tag": "✨ 頂級保養", "sales": "月售 2,200+"},
1129
+ ],
1130
+ "🧴 乳液/乳霜": [
1131
+ {"rank": 1, "name": "CeraVe 長效潤澤修護霜 340g", "brand": "CeraVe 適樂膚", "price": "619", "tag": "🔬 醫美等級", "sales": "月售 5,000+"},
1132
+ {"rank": 2, "name": "Neutrogena 深層修護身體乳 400ml", "brand": "Neutrogena 露得清", "price": "299", "tag": "🌊 輕透吸收", "sales": "月售 4,200+"},
1133
+ {"rank": 3, "name": "NIVEA 深層修護乳液 400ml", "brand": "Nivea 妮維雅", "price": "169", "tag": "💰 超值日常", "sales": "月售 3,800+"},
1134
+ {"rank": 4, "name": "Eucerin 優色林修護乳液 250ml", "brand": "Eucerin 優色林", "price": "499", "tag": "🏥 敏肌皮科", "sales": "月售 2,600+"},
1135
+ {"rank": 5, "name": "資生堂 ELIXIR 彈潤活膚乳液 130ml", "brand": "資生堂 Shiseido", "price": "980", "tag": "🌸 彈潤緊緻", "sales": "月售 1,900+"},
1136
+ ],
1137
+ "🧼 洗面乳": [
1138
+ {"rank": 1, "name": "SENKA 洗顏專科柔膚泡沫 120g", "brand": "SENKA 洗顏專科", "price": "199", "tag": "🫧 綿密泡沫", "sales": "月售 7,000+"},
1139
+ {"rank": 2, "name": "CeraVe 溫和泡沫洗面乳 236ml", "brand": "CeraVe 適樂膚", "price": "399", "tag": "🔬 醫美配方", "sales": "月售 5,500+"},
1140
+ {"rank": 3, "name": "FANCL 無添加淨化洗顏粉 50g", "brand": "FANCL", "price": "580", "tag": "🌸 無添加溫和", "sales": "月售 4,200+"},
1141
+ {"rank": 4, "name": "DHC 橄欖柔膚洗面皂 90g", "brand": "DHC", "price": "280", "tag": "🫒 橄欖滋潤", "sales": "月售 3,600+"},
1142
+ {"rank": 5, "name": "理膚寶水 控油調理洗面乳 150ml", "brand": "La Roche-Posay", "price": "650", "tag": "🛢️ 控油清爽", "sales": "月售 2,800+"},
1143
+ ],
1144
+ "💎 精華液": [
1145
+ {"rank": 1, "name": "SK-II 青春露 230ml", "brand": "SK-II", "price": "3900", "tag": "💎 殿堂級精華", "sales": "月售 2,800+"},
1146
+ {"rank": 2, "name": "Estée Lauder 小棕瓶精華 50ml", "brand": "Estée Lauder 雅詩蘭黛", "price": "2800", "tag": "🤎 修護經典", "sales": "月售 2,200+"},
1147
+ {"rank": 3, "name": "La Roche-Posay B5修護精華 30ml", "brand": "La Roche-Posay", "price": "820", "tag": "🌿 屏障修護", "sales": "月售 2,000+"},
1148
+ {"rank": 4, "name": "COSRX 蝸牛修護精華 96ml", "brand": "COSRX", "price": "420", "tag": "🐌 高效修護", "sales": "月售 1,800+"},
1149
+ {"rank": 5, "name": "資生堂 ELIXIR 亮白精華 40ml", "brand": "資生堂 Shiseido", "price": "1580", "tag": "✨ 亮白緊緻", "sales": "月售 1,500+"},
1150
+ ],
1151
+ }
1152
+ rank_list = rankings[category]
1153
+ rank_colors = ["#FFD700", "#C0C0C0", "#CD7F32", "#4A90D9", "#7ED321"]
1154
+ rank_medals = ["🥇", "🥈", "🥉", "4️⃣", "5️⃣"]
1155
+ bubbles = []
1156
+ for item in rank_list:
1157
+ idx = item["rank"] - 1
1158
+ color = rank_colors[idx] if idx < 5 else "#8c8c8c"
1159
+ medal = rank_medals[idx] if idx < 5 else f"#{item['rank']}"
1160
+ card = {
1161
+ "type": "bubble", "size": "mega",
1162
+ "header": {
1163
+ "type": "box", "layout": "horizontal",
1164
+ "backgroundColor": color, "paddingAll": "20px",
1165
+ "contents": [
1166
+ {"type": "text", "text": medal, "size": "xxl", "flex": 1, "align": "center", "gravity": "center"},
1167
+ {"type": "box", "layout": "vertical", "flex": 4, "justifyContent": "center", "contents": [
1168
+ {"type": "text", "text": f"排行第 {item['rank']} 名", "size": "xl", "color": "#FFFFFF", "weight": "bold", "align": "start"},
1169
+ {"type": "text", "text": item["tag"], "size": "sm", "color": "#FFFFFF", "align": "start", "margin": "xs"}
1170
+ ]}
1171
+ ]
1172
+ },
1173
+ "body": {
1174
+ "type": "box", "layout": "vertical", "paddingAll": "20px",
1175
+ "contents": [
1176
+ {"type": "text", "text": item["name"], "weight": "bold", "size": "lg", "wrap": True, "color": "#111111"},
1177
+ {"type": "text", "text": item["brand"], "size": "sm", "color": "#8c8c8c", "margin": "sm"},
1178
+ {"type": "box", "layout": "vertical", "spacing": "sm", "margin": "lg", "contents": [
1179
+ {"type": "box", "layout": "baseline", "spacing": "sm", "contents": [
1180
+ {"type": "text", "text": "參考售價", "size": "sm", "color": "#8c8c8c", "flex": 3},
1181
+ {"type": "text", "text": f"${item['price']}", "size": "sm", "color": "#111111", "weight": "bold", "flex": 5}
1182
+ ]},
1183
+ {"type": "box", "layout": "baseline", "spacing": "sm", "contents": [
1184
+ {"type": "text", "text": "銷售熱度", "size": "sm", "color": "#8c8c8c", "flex": 3},
1185
+ {"type": "text", "text": item["sales"], "size": "sm", "color": "#FF6B00", "weight": "bold", "flex": 5}
1186
+ ]}
1187
+ ]}
1188
+ ]
1189
+ },
1190
+ "footer": {
1191
+ "type": "box", "layout": "vertical",
1192
+ "contents": [{"type": "button", "style": "primary", "color": "#000000", "height": "sm",
1193
+ "action": {"type": "message", "label": "💝 加入購物車", "text": f"已收藏:{item['name']}:${item['price']}"}}]
1194
+ }
1195
+ }
1196
+ bubbles.append(card)
1197
+ flex_msg = FlexSendMessage.new_from_json_dict({
1198
+ "type": "flex", "alt_text": f"🏆 {category} 熱銷排行榜",
1199
+ "contents": {"type": "carousel", "contents": bubbles}
1200
+ })
1201
+ line_bot_api.push_message(user_id, flex_msg)
1202
+ if user_id in user_states:
1203
+ del user_states[user_id]
1204
+
1205
+ # ── 收藏商品:輸入商品名稱後儲存 ──
1206
+ elif current_step == "ASK_FAVORITE_NAME":
1207
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text="🔍 精靈正在辨識商品,請稍候..."))
1208
+ product_name, corrected = normalize_product_name(user_msg)
1209
+ if product_name is None:
1210
+ line_bot_api.push_message(user_id, TextSendMessage(
1211
+ text=f"💝 找不到「{user_msg}」這個商品耶~\n請輸入更完整的商品名稱或品牌,例如:「FANCL 卸妝油」、「蘭蔻小黑瓶」"
1212
+ ))
1213
+ return
1214
+ if corrected:
1215
+ _send_confirm_dialog(user_id, "favorite", product_name, user_msg)
1216
+ return
1217
+ _do_favorite_save(user_id, None, product_name)
1218
+
1219
+ # ── 商品確認對話 ──
1220
+ elif current_step == "CONFIRM_PRODUCT":
1221
+ feature = state.get("feature")
1222
+ product_name = state.get("product_name")
1223
+ step_map = {"photo": "ASK_PHOTO_ITEM", "review": "ASK_REVIEW_PRODUCT", "favorite": "ASK_FAVORITE_NAME"}
1224
+ retry_msg = {
1225
+ "photo": "請重新輸入商品名稱,精靈幫您查詢 📷",
1226
+ "review": "請重新輸入想查詢口碑的商品名稱 💬",
1227
+ "favorite": "請重新輸入想收藏的商品名稱 💝",
1228
+ }
1229
+ if user_msg == "✅ 對,就是這個":
1230
+ del user_states[user_id]
1231
+ if feature == "photo":
1232
+ _do_photo_query(user_id, event.reply_token, product_name)
1233
+ elif feature == "review":
1234
+ _do_review_query(user_id, event.reply_token, product_name)
1235
+ elif feature == "favorite":
1236
+ _do_favorite_save(user_id, event.reply_token, product_name)
1237
+ elif user_msg == "❌ 不是,重新輸入":
1238
+ user_states[user_id] = {"step": step_map[feature]}
1239
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text=retry_msg[feature]))
1240
+ else:
1241
+ confirm_qr = QuickReply(items=[
1242
+ QuickReplyButton(action=MessageAction(label="✅ 對,就是這個", text="✅ 對,就是這個")),
1243
+ QuickReplyButton(action=MessageAction(label="❌ 不是,重新輸入", text="❌ 不是,重新輸入")),
1244
+ ])
1245
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(
1246
+ text=f"請點選下方按鈕確認~您是指「{product_name}」嗎?",
1247
+ quick_reply=confirm_qr
1248
+ ))
1249
+
1250
+ if __name__ == "__main__":
1251
+ app.run(host='0.0.0.0', port=7860)
fff ADDED
@@ -0,0 +1,919 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import requests
3
+ import google.generativeai as genai
4
+ from flask import Flask, request, abort
5
+ from linebot import LineBotApi, WebhookHandler
6
+ from linebot.exceptions import InvalidSignatureError
7
+ import io
8
+ import json
9
+ from PIL import Image
10
+ from linebot.models import (
11
+ MessageEvent, TextMessage, TextSendMessage, QuickReply,
12
+ QuickReplyButton, MessageAction, ImageMessage, FlexSendMessage,
13
+ LocationMessage, LocationAction
14
+ )
15
+ import urllib.parse
16
+ import math
17
+ from datetime import datetime, timezone, timedelta
18
+
19
+ app = Flask(__name__)
20
+
21
+ # ================= 1. 從環境變數讀取金鑰 =================
22
+ line_bot_api = LineBotApi(os.environ.get('LINE_CHANNEL_ACCESS_TOKEN'))
23
+ handler = WebhookHandler(os.environ.get('LINE_CHANNEL_SECRET'))
24
+ genai.configure(api_key=os.environ.get('GEMINI_API_KEY'))
25
+ model = genai.GenerativeModel('gemini-2.5-flash')
26
+
27
+ # ================= 建立虛擬藥妝資料庫 =================
28
+ fake_database = {
29
+ "CeraVe適樂膚 長效潤澤修護霜 340g": [
30
+ {"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"},
31
+ {"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"},
32
+ {"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"}
33
+ ],
34
+ "Avene 雅漾舒護活泉水150ml": [
35
+ {"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"},
36
+ {"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"},
37
+ {"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"}
38
+ ]
39
+ }
40
+
41
+ # ================= 2. 狀態儲存區 =================
42
+ user_states = {}
43
+ user_favorites = {} # {user_id: [{"name": "...", "price": "...", "note": "..."}]}
44
+
45
+ @app.route("/callback", methods=['POST'])
46
+ def callback():
47
+ signature = request.headers['X-Line-Signature']
48
+ body = request.get_data(as_text=True)
49
+ try:
50
+ handler.handle(body, signature)
51
+ except InvalidSignatureError:
52
+ abort(400)
53
+ return 'OK'
54
+
55
+ # ================= 3. 拍照精靈 (圖片處理) =================
56
+ @handler.add(MessageEvent, message=ImageMessage)
57
+ def handle_image(event):
58
+ user_id = event.source.user_id
59
+ state = user_states.get(user_id)
60
+
61
+ if state and state.get("step") == "ASK_PHOTO_ITEM":
62
+ line_bot_api.reply_message(
63
+ event.reply_token,
64
+ TextSendMessage(text="✨ 收到照片!精靈正在用魔法辨識這款產品,請稍候...")
65
+ )
66
+
67
+ try:
68
+ message_content = line_bot_api.get_message_content(event.message.id)
69
+ image_bytes = io.BytesIO(message_content.content)
70
+ pil_image = Image.open(image_bytes)
71
+
72
+ vision_prompt = """
73
+ 請辨識這張圖片中的保養品。
74
+ 如果是 CeraVe (適樂膚) 的產品,請直接回答:CeraVe適樂膚 長效潤澤修護霜 340g
75
+ 如果是 Avene (雅漾) 活泉水,請直接回答:Avene 雅漾舒護活泉水150ml
76
+ 如果都不是,請發揮你的專業,判斷它是什麼真實的品牌與品名,並「直接回答品牌與品名」。
77
+ 注意:非常重要!無論如何,你只能輸出「商品名稱」,絕對不要輸出任何其他廢話、標點符號、價格或商家資訊。
78
+ """
79
+
80
+ response = model.generate_content([vision_prompt, pil_image])
81
+ product_name = response.text.strip()
82
+
83
+ if product_name in fake_database:
84
+ data_list = fake_database[product_name]
85
+ else:
86
+ estimate_prompt = f"""
87
+ 請估算「{product_name}」��款產品在台灣屈臣氏、康是美、寶雅的合理售價。
88
+ 請務必「只回傳」一個標準的 JSON 陣列,不要有任何額外的說明文字。格式嚴格如下:
89
+ [
90
+ {{"store": "屈臣氏", "name": "{product_name}", "price": "估計數字"}},
91
+ {{"store": "康是美", "name": "{product_name}", "price": "估計數字"}},
92
+ {{"store": "寶雅", "name": "{product_name}", "price": "估計數字"}}
93
+ ]
94
+ """
95
+ est_response = model.generate_content(estimate_prompt)
96
+ clean_text = est_response.text.replace("```json", "").replace("```", "").strip()
97
+ data_list = json.loads(clean_text)
98
+
99
+ bubbles = []
100
+ for item in data_list:
101
+ default_logos = {
102
+ "屈臣氏": "https://openhouse.osa.nycu.edu.tw/media/data/company_logos/3._%E5%B1%88%E8%87%A3%E6%B0%8Flogo.png",
103
+ "康是美": "https://img.skmbuy.com.tw/App_Images/727/Brand/2022/1215/4b8e1829-8f30-46a9-b044-834c11fc884a.jpg",
104
+ "寶雅": "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"
105
+ }
106
+
107
+ safe_store = item.get("store", "推薦藥妝店")
108
+ safe_name = item.get("name", "未知名稱商品")
109
+ safe_price = item.get("price", "請洽門市")
110
+ safe_logo = item.get("logo", default_logos.get(safe_store, "https://via.placeholder.com/600x300/eeeeee/999999?text=Store+Logo"))
111
+ safe_discount = item.get("discount", "")
112
+
113
+ search_query = urllib.parse.quote(f"{safe_store} {safe_name}")
114
+ safe_link = item.get("link", f"https://www.google.com/search?q={search_query}")
115
+
116
+ specs_box = {
117
+ "type": "box",
118
+ "layout": "vertical",
119
+ "spacing": "sm",
120
+ "margin": "lg",
121
+ "contents": [
122
+ {
123
+ "type": "box",
124
+ "layout": "horizontal",
125
+ "contents": [
126
+ {"type": "text", "text": "販售商家", "size": "md", "color": "#8c8c8c", "flex": 1},
127
+ {"type": "text", "text": safe_store, "size": "md", "color": "#111111", "weight": "bold", "flex": 2}
128
+ ]
129
+ },
130
+ {
131
+ "type": "box",
132
+ "layout": "horizontal",
133
+ "contents": [
134
+ {"type": "text", "text": "商品售價", "size": "md", "color": "#8c8c8c", "flex": 1},
135
+ {"type": "text", "text": f"${safe_price}", "size": "md", "color": "#111111", "weight": "bold", "flex": 2}
136
+ ]
137
+ }
138
+ ]
139
+ }
140
+
141
+ if safe_discount:
142
+ specs_box["contents"].append(
143
+ {
144
+ "type": "box",
145
+ "layout": "horizontal",
146
+ "contents": [
147
+ {"type": "text", "text": "獨家優惠", "size": "md", "color": "#8c8c8c", "flex": 1},
148
+ {"type": "text", "text": safe_discount, "size": "md", "color": "#ff3b30", "weight": "bold", "flex": 2}
149
+ ]
150
+ }
151
+ )
152
+
153
+ card = {
154
+ "type": "bubble",
155
+ "size": "mega",
156
+ "hero": {
157
+ "type": "image",
158
+ "url": safe_logo,
159
+ "size": "full",
160
+ "aspectRatio": "2:1",
161
+ "aspectMode": "fit",
162
+ "backgroundColor": "#FFFFFF"
163
+ },
164
+ "body": {
165
+ "type": "box",
166
+ "layout": "vertical",
167
+ "contents": [
168
+ {"type": "text", "text": safe_name, "weight": "bold", "size": "xl", "wrap": True},
169
+ specs_box
170
+ ]
171
+ },
172
+ "footer": {
173
+ "type": "box",
174
+ "layout": "vertical",
175
+ "contents": [
176
+ {
177
+ "type": "button",
178
+ "style": "primary",
179
+ "color": "#000000",
180
+ "height": "md",
181
+ "action": {
182
+ "type": "uri",
183
+ "label": "前往商家購買",
184
+ "uri": safe_link
185
+ }
186
+ }
187
+ ]
188
+ }
189
+ }
190
+ bubbles.append(card)
191
+
192
+ flex_message = FlexSendMessage.new_from_json_dict({
193
+ "type": "flex",
194
+ "alt_text": "🔍 藥妝精靈比價報告來囉!",
195
+ "contents": {
196
+ "type": "carousel",
197
+ "contents": bubbles
198
+ }
199
+ })
200
+ line_bot_api.push_message(user_id, flex_message)
201
+ if user_id in user_states: del user_states[user_id]
202
+
203
+ except Exception as e:
204
+ line_bot_api.push_message(
205
+ user_id,
206
+ TextSendMessage(text=f"❌ 哎呀!精靈遇到了一點問題。錯誤原因:\n{e}")
207
+ )
208
+
209
+ # ================= 4. 附近店家 (位置處理) =================
210
+ @handler.add(MessageEvent, message=LocationMessage)
211
+ def handle_location(event):
212
+ user_id = event.source.user_id
213
+ user_lat = event.message.latitude
214
+ user_lon = event.message.longitude
215
+
216
+ state = user_states.get(user_id, {})
217
+ brand_preference = state.get("search_brand", "全都要")
218
+
219
+ # ✅ 完美修正:正確的環境變數名稱
220
+ google_api_key = os.environ.get('Maps_API_KEY')
221
+ if not google_api_key:
222
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text="❌ 系統尚未設定 Google Maps API 金鑰。"))
223
+ return
224
+
225
+ def calculate_distance(lat1, lon1, lat2, lon2):
226
+ R = 6371.0
227
+ lat1_rad, lon1_rad = math.radians(lat1), math.radians(lon1)
228
+ lat2_rad, lon2_rad = math.radians(lat2), math.radians(lon2)
229
+ dlon = lon2_rad - lon1_rad
230
+ dlat = lat2_rad - lat1_rad
231
+ a = math.sin(dlat / 2)**2 + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon / 2)**2
232
+ c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
233
+ return int(R * c * 1000)
234
+
235
+ def get_exact_hours(place_id, api_key):
236
+ url = f"https://maps.googleapis.com/maps/api/place/details/json?place_id={place_id}&fields=opening_hours&language=zh-TW&key={api_key}"
237
+ try:
238
+ res = requests.get(url).json()
239
+ if 'result' in res and 'opening_hours' in res['result']:
240
+ weekday_text = res['result']['opening_hours'].get('weekday_text', [])
241
+ if weekday_text:
242
+ tw_tz = timezone(timedelta(hours=8))
243
+ today_idx = datetime.now(tw_tz).weekday()
244
+ today_str = weekday_text[today_idx]
245
+ if ": " in today_str:
246
+ return today_str.split(": ", 1)[1]
247
+ except Exception:
248
+ pass
249
+ return "詳情請洽門市"
250
+
251
+ brands_to_search = ["屈臣氏", "康是美", "寶雅"] if brand_preference == "全都要" else [brand_preference]
252
+ search_radius = 3000
253
+
254
+ try:
255
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text="🧚‍♀️ 精靈正在為您精準定位各品牌門市,請稍候..."))
256
+
257
+ nearest_stores = []
258
+
259
+ for brand in brands_to_search:
260
+ 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}"
261
+ response = requests.get(url).json()
262
+
263
+ if response['status'] == 'OK' and response.get('results'):
264
+ brand_stores = []
265
+ for place in response['results']:
266
+ store_lat = place['geometry']['location']['lat']
267
+ store_lon = place['geometry']['location']['lng']
268
+ distance = calculate_distance(user_lat, user_lon, store_lat, store_lon)
269
+
270
+ photo_ref = ""
271
+ if 'photos' in place and len(place['photos']) > 0:
272
+ photo_ref = place['photos'][0]['photo_reference']
273
+
274
+ open_now = place.get('opening_hours', {}).get('open_now')
275
+ if open_now is True:
276
+ status_light = "🟢 營業中"
277
+ elif open_now is False:
278
+ status_light = "🔴 休息中"
279
+ else:
280
+ status_light = "🕒 營業狀態"
281
+
282
+ brand_stores.append({
283
+ "place_id": place.get('place_id'),
284
+ "name": place.get('name', '未知名稱'),
285
+ "rating": place.get('rating', '無評分'),
286
+ "lat": store_lat,
287
+ "lon": store_lon,
288
+ "distance": distance,
289
+ "address": place.get('formatted_address', ''),
290
+ "status_light": status_light,
291
+ "photo_ref": photo_ref
292
+ })
293
+
294
+ sorted_brand_stores = sorted(brand_stores, key=lambda x: x['distance'])
295
+
296
+ if brand_preference == "全都要":
297
+ if sorted_brand_stores:
298
+ nearest_stores.append(sorted_brand_stores[0])
299
+ else:
300
+ nearest_stores.extend(sorted_brand_stores[:3])
301
+
302
+ if not nearest_stores:
303
+ line_bot_api.push_message(user_id, TextSendMessage(text="🥺 哎呀,精靈在您附近 3 公里內都找不到藥妝店呢..."))
304
+ if user_id in user_states: del user_states[user_id]
305
+ return
306
+
307
+ nearest_stores = sorted(nearest_stores, key=lambda x: x['distance'])
308
+
309
+ bubbles = []
310
+ for store in nearest_stores:
311
+ exact_hours = get_exact_hours(store['place_id'], google_api_key)
312
+ display_hours = f"{store['status_light']} ({exact_hours})"
313
+
314
+ map_query = urllib.parse.quote(f"{store['name']} {store['address']}")
315
+ map_url = f"https://www.google.com/maps/search/?api=1&query={map_query}"
316
+
317
+ if store['photo_ref']:
318
+ image_url = f"https://maps.googleapis.com/maps/api/place/photo?maxwidth=600&photo_reference={store['photo_ref']}&key={google_api_key}"
319
+ else:
320
+ image_url = "https://images.unsplash.com/photo-1576426863848-c21f53c60b19?q=80&w=600&auto=format&fit=crop"
321
+
322
+ # ✅ 完美修正:排版乾淨、結構完全正確的規格表!
323
+ card = {
324
+ "type": "bubble",
325
+ "size": "mega",
326
+ "hero": {
327
+ "type": "image",
328
+ "url": image_url,
329
+ "size": "full",
330
+ "aspectRatio": "16:9",
331
+ "aspectMode": "cover"
332
+ },
333
+ "body": {
334
+ "type": "box",
335
+ "layout": "vertical",
336
+ "contents": [
337
+ {"type": "text", "text": store['name'], "weight": "bold", "size": "xl", "wrap": True, "color": "#111111"},
338
+ {
339
+ "type": "box",
340
+ "layout": "vertical",
341
+ "spacing": "sm",
342
+ "margin": "lg",
343
+ "contents": [
344
+ {
345
+ "type": "box",
346
+ "layout": "baseline",
347
+ "spacing": "sm",
348
+ "contents": [
349
+ {"type": "text", "text": "目前距離", "size": "sm", "color": "#8c8c8c", "flex": 3},
350
+ {"type": "text", "text": f"{store['distance']} 公尺", "size": "sm", "color": "#111111", "weight": "bold", "flex": 5}
351
+ ]
352
+ },
353
+ {
354
+ "type": "box",
355
+ "layout": "baseline",
356
+ "spacing": "sm",
357
+ "contents": [
358
+ {"type": "text", "text": "營業時間", "size": "sm", "color": "#8c8c8c", "flex": 3},
359
+ {"type": "text", "text": display_hours, "size": "sm", "color": "#111111", "weight": "bold", "flex": 5, "wrap": True}
360
+ ]
361
+ },
362
+ {
363
+ "type": "box",
364
+ "layout": "baseline",
365
+ "spacing": "sm",
366
+ "contents": [
367
+ {"type": "text", "text": "店家評分", "size": "sm", "color": "#8c8c8c", "flex": 3},
368
+ {"type": "text", "text": f"⭐ {store['rating']}", "size": "sm", "color": "#fbbc04", "weight": "bold", "flex": 5}
369
+ ]
370
+ }
371
+ ]
372
+ }
373
+ ]
374
+ },
375
+ "footer": {
376
+ "type": "box",
377
+ "layout": "vertical",
378
+ "contents": [
379
+ {
380
+ "type": "button",
381
+ "style": "primary",
382
+ "color": "#000000",
383
+ "height": "md",
384
+ "action": {
385
+ "type": "uri",
386
+ "label": "開啟地圖導航",
387
+ "uri": map_url
388
+ }
389
+ }
390
+ ]
391
+ }
392
+ }
393
+ bubbles.append(card)
394
+
395
+ flex_message = FlexSendMessage.new_from_json_dict({
396
+ "type": "flex",
397
+ "alt_text": "🔍 附近的藥妝店地圖出爐囉!",
398
+ "contents": {
399
+ "type": "carousel",
400
+ "contents": bubbles
401
+ }
402
+ })
403
+
404
+ line_bot_api.push_message(user_id, flex_message)
405
+ if user_id in user_states: del user_states[user_id]
406
+
407
+ except Exception as e:
408
+ line_bot_api.push_message(user_id, TextSendMessage(text=f"❌ 搜尋過程發生錯誤:{e}"))
409
+ if user_id in user_states: del user_states[user_id]
410
+
411
+ # ================= 5. 多輪對話核心邏輯 (文字對話) =================
412
+ @handler.add(MessageEvent, message=TextMessage)
413
+ def handle_message(event):
414
+ user_id = event.source.user_id
415
+ user_msg = event.message.text.strip()
416
+
417
+ if user_msg == "附近店家":
418
+ user_states[user_id] = {"step": "ASK_BRAND"}
419
+
420
+ brand_buttons = QuickReply(items=[
421
+ QuickReplyButton(action=MessageAction(label="屈臣氏", text="屈臣氏")),
422
+ QuickReplyButton(action=MessageAction(label="康是美", text="康是美")),
423
+ QuickReplyButton(action=MessageAction(label="寶雅", text="寶雅")),
424
+ QuickReplyButton(action=MessageAction(label="全都要", text="全都要"))
425
+ ])
426
+
427
+ line_bot_api.reply_message(
428
+ event.reply_token,
429
+ TextSendMessage(text="請問你想尋找哪一間藥妝店呢?", quick_reply=brand_buttons)
430
+ )
431
+ return
432
+
433
+ state = user_states.get(user_id)
434
+ if state and state.get("step") == "ASK_BRAND":
435
+ if user_msg in ["屈臣氏", "康是美", "寶雅", "全都要"]:
436
+ state["search_brand"] = user_msg
437
+ state["step"] = "ASK_LOCATION"
438
+
439
+ location_button = QuickReply(items=[
440
+ QuickReplyButton(action=LocationAction(label="📍 點我分享位置"))
441
+ ])
442
+
443
+ line_bot_api.reply_message(
444
+ event.reply_token,
445
+ TextSendMessage(text=f"沒問題!請分享您的位置,精靈來幫您找最近的【{user_msg}】!", quick_reply=location_button)
446
+ )
447
+ else:
448
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text="請點擊下方的按鈕選擇品牌喔!"))
449
+ return
450
+
451
+ if user_msg == "精靈顧問" or user_msg == "重新開始":
452
+ user_states[user_id] = {"step": "ASK_SKIN"}
453
+
454
+ skin_buttons = QuickReply(items=[
455
+ QuickReplyButton(action=MessageAction(label="乾性肌", text="乾性肌")),
456
+ QuickReplyButton(action=MessageAction(label="油性肌", text="油性肌")),
457
+ QuickReplyButton(action=MessageAction(label="混合肌", text="混合肌")),
458
+ QuickReplyButton(action=MessageAction(label="敏感肌", text="敏感肌"))
459
+ ])
460
+
461
+ line_bot_api.reply_message(
462
+ event.reply_token,
463
+ TextSendMessage(text="🧚‍♀️ 嗨!我是你的專屬精靈顧問。為了給你最精準的保養建議,先告訴我,你覺得自己偏向哪一種膚質呢?", quick_reply=skin_buttons)
464
+ )
465
+ return
466
+
467
+ if user_msg == "拍照精靈":
468
+ user_states[user_id] = {"step": "ASK_PHOTO_ITEM"}
469
+ line_bot_api.reply_message(
470
+ event.reply_token,
471
+ TextSendMessage(text="📷 歡迎開啟拍照精靈!\n您可以直接拍下商品照片傳給我,或是輸入「文字」來查詢商品售價喔!")
472
+ )
473
+ return
474
+
475
+ # ── 社群真實口碑 ──
476
+ if user_msg == "社群真實口碑":
477
+ user_states[user_id] = {"step": "ASK_REVIEW_PRODUCT"}
478
+ line_bot_api.reply_message(
479
+ event.reply_token,
480
+ [
481
+ TextSendMessage(text="💬 歡迎使用社群真實口碑!"),
482
+ TextSendMessage(text="請輸入您想查詢口碑的商品名稱,例如:「CeraVe 乳液」或「資生堂防曬」")
483
+ ]
484
+ )
485
+ return
486
+
487
+ # ── 熱銷排行榜 ──
488
+ if user_msg == "熱銷排行榜":
489
+ user_states[user_id] = {"step": "ASK_RANKING_CATEGORY"}
490
+ category_buttons = QuickReply(items=[
491
+ QuickReplyButton(action=MessageAction(label="保濕護膚", text="保濕護膚")),
492
+ QuickReplyButton(action=MessageAction(label="防曬產品", text="防曬產品")),
493
+ QuickReplyButton(action=MessageAction(label="清潔卸妝", text="清潔卸妝")),
494
+ QuickReplyButton(action=MessageAction(label="全部類別", text="全部類別"))
495
+ ])
496
+ line_bot_api.reply_message(
497
+ event.reply_token,
498
+ TextSendMessage(text="🏆 歡迎查看熱銷排行榜!\n請選擇您想查看的商品類別:", quick_reply=category_buttons)
499
+ )
500
+ return
501
+
502
+ # ── 收藏商品:查看清單 ──
503
+ if user_msg in ["收藏商品", "購物車"]:
504
+ favorites = user_favorites.get(user_id, [])
505
+ add_qr = QuickReply(items=[QuickReplyButton(action=MessageAction(label="➕ 新增收藏", text="新增收藏"))])
506
+ if not favorites:
507
+ line_bot_api.reply_message(
508
+ event.reply_token,
509
+ TextSendMessage(text="💝 您的收藏清單目前是空的!\n點擊下方按鈕新增您喜愛的商品吧~", quick_reply=add_qr)
510
+ )
511
+ else:
512
+ bubbles = []
513
+ for i, fav in enumerate(favorites):
514
+ card = {
515
+ "type": "bubble", "size": "mega",
516
+ "header": {
517
+ "type": "box", "layout": "vertical",
518
+ "backgroundColor": "#FFE8F0", "paddingAll": "12px",
519
+ "contents": [{"type": "text", "text": f"❤️ 收藏 #{i+1}", "weight": "bold", "size": "sm", "color": "#FF6B8A"}]
520
+ },
521
+ "body": {
522
+ "type": "box", "layout": "vertical",
523
+ "contents": [
524
+ {"type": "text", "text": fav["name"], "weight": "bold", "size": "lg", "wrap": True, "color": "#111111"},
525
+ {
526
+ "type": "box", "layout": "vertical", "spacing": "sm", "margin": "lg",
527
+ "contents": [
528
+ {"type": "box", "layout": "baseline", "spacing": "sm", "contents": [
529
+ {"type": "text", "text": "參考售價", "size": "sm", "color": "#8c8c8c", "flex": 3},
530
+ {"type": "text", "text": fav.get("price", "未知"), "size": "sm", "color": "#111111", "weight": "bold", "flex": 5}
531
+ ]},
532
+ {"type": "box", "layout": "baseline", "spacing": "sm", "contents": [
533
+ {"type": "text", "text": "備註", "size": "sm", "color": "#8c8c8c", "flex": 3},
534
+ {"type": "text", "text": fav.get("note", "無"), "size": "sm", "color": "#111111", "flex": 5, "wrap": True}
535
+ ]}
536
+ ]
537
+ }
538
+ ]
539
+ },
540
+ "footer": {
541
+ "type": "box", "layout": "vertical",
542
+ "contents": [{"type": "button", "style": "secondary", "height": "sm",
543
+ "action": {"type": "message", "label": "🗑️ 刪除此收藏", "text": f"刪除收藏:{fav['name']}"}}]
544
+ }
545
+ }
546
+ bubbles.append(card)
547
+ flex_msg = FlexSendMessage.new_from_json_dict({
548
+ "type": "flex", "alt_text": "💝 您的收藏清單",
549
+ "contents": {"type": "carousel", "contents": bubbles}
550
+ })
551
+ line_bot_api.reply_message(
552
+ event.reply_token,
553
+ [flex_msg, TextSendMessage(text=f"共有 {len(favorites)} 件收藏商品", quick_reply=add_qr)]
554
+ )
555
+ return
556
+
557
+ # ── 收藏商品:新增 ──
558
+ if user_msg == "新增收藏":
559
+ user_states[user_id] = {"step": "ASK_FAVORITE_NAME"}
560
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text="➕ 請輸入您想收藏的商品名稱:"))
561
+ return
562
+
563
+ # ── 收藏商品:刪除 ──
564
+ if user_msg.startswith("刪除收藏:"):
565
+ product_name = user_msg[5:]
566
+ favs = user_favorites.get(user_id, [])
567
+ user_favorites[user_id] = [f for f in favs if f["name"] != product_name]
568
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text=f"🗑️ 已從收藏中刪除「{product_name}」"))
569
+ return
570
+
571
+ # ── 熱銷排行榜快速收藏 ──
572
+ if user_msg.startswith("快速收藏:"):
573
+ parts = user_msg.split(":")
574
+ p_name = parts[1] if len(parts) > 1 else ""
575
+ p_price = parts[2] if len(parts) > 2 else "未知"
576
+ if p_name:
577
+ if user_id not in user_favorites:
578
+ user_favorites[user_id] = []
579
+ if any(f["name"] == p_name for f in user_favorites[user_id]):
580
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text=f"💝「{p_name}」已在收藏清單中囉!"))
581
+ else:
582
+ user_favorites[user_id].append({"name": p_name, "price": p_price, "note": "從熱銷排行榜加入"})
583
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text=f"🛒 已加入購物車!\n「{p_name}」"))
584
+ return
585
+
586
+ if not state:
587
+ line_bot_api.reply_message(
588
+ event.reply_token,
589
+ TextSendMessage(text="請輸入「精靈顧問」來開啟膚況諮詢服務喔!")
590
+ )
591
+ return
592
+
593
+ current_step = state.get("step")
594
+
595
+ if current_step == "ASK_SKIN":
596
+ if user_msg in ["乾性肌", "油性肌", "混合肌", "敏感肌"]:
597
+ state["skin_type"] = user_msg
598
+ state["step"] = "ASK_ISSUE"
599
+
600
+ issue_buttons = QuickReply(items=[
601
+ QuickReplyButton(action=MessageAction(label="嚴重脫皮", text="嚴重脫皮")),
602
+ QuickReplyButton(action=MessageAction(label="暗沉無光", text="暗沉無光")),
603
+ QuickReplyButton(action=MessageAction(label="痘痘粉刺", text="痘痘粉刺")),
604
+ QuickReplyButton(action=MessageAction(label="泛紅乾癢", text="泛紅乾癢"))
605
+ ])
606
+
607
+ line_bot_api.reply_message(
608
+ event.reply_token,
609
+ TextSendMessage(text=f"記錄下來了!你是 {user_msg}。那最近最讓你困擾的肌膚問題是什麼呢?", quick_reply=issue_buttons)
610
+ )
611
+ else:
612
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text="請選擇下方的膚質選項,或是輸入「重新開始」喔!"))
613
+
614
+ elif current_step == "ASK_ISSUE":
615
+ if user_msg in ["嚴重脫皮", "暗沉無光", "痘痘粉刺", "泛紅乾癢"]:
616
+ state["issue"] = user_msg
617
+ state["step"] = "ASK_PREFERENCE"
618
+
619
+ pref_buttons = QuickReply(items=[
620
+ QuickReplyButton(action=MessageAction(label="偏好清爽凝露", text="偏好清爽凝露")),
621
+ QuickReplyButton(action=MessageAction(label="偏好滋潤乳霜", text="偏好滋潤乳霜")),
622
+ QuickReplyButton(action=MessageAction(label="都可以", text="都可以"))
623
+ ])
624
+
625
+ line_bot_api.reply_message(
626
+ event.reply_token,
627
+ TextSendMessage(text=f"收到!針對 {user_msg} 的問題,你在挑選保養品時有特別偏好哪種質地嗎?", quick_reply=pref_buttons)
628
+ )
629
+ else:
630
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text="請選擇下方的肌膚問題選項,或是輸入「重新開始」喔!"))
631
+
632
+ elif current_step == "ASK_PREFERENCE":
633
+ if user_msg in ["偏好清爽凝露", "偏好滋潤乳霜", "都可以"]:
634
+ skin_type = state["skin_type"]
635
+ issue = state["issue"]
636
+ preference = user_msg
637
+
638
+ line_bot_api.reply_message(
639
+ event.reply_token,
640
+ TextSendMessage(text="🧚‍♀️ 精靈正在為您翻閱保養圖鑑,請稍候幾秒鐘...")
641
+ )
642
+
643
+ prompt = f"""
644
+ 你現在是一位「台灣藥妝店皮膚科精靈顧問🧚‍♀️」,擁有10年以上保養諮詢經驗。
645
+ 使用者資料:
646
+ * 膚質:{skin_type}
647
+ * 困擾:{issue}
648
+ * 偏好:{preference}
649
+ 請根據以上資訊提供個人化保養建議。
650
+
651
+ 【輸出規則】
652
+ 1. 回覆控制在 180~250 字。
653
+ 2. 語氣溫柔、親切,像專業美容顧問。
654
+ 3. 使用繁體中文。
655
+ 4. 不要使用表格。
656
+ 5. 必須完全依照下列格式輸出:
657
+
658
+ 🧚‍♀️ 哈囉親愛的!
659
+
660
+ 🩺 精靈診斷
661
+ (用1~2句話分析目前膚況,需根據使用者的膚質與困擾客製化)
662
+
663
+ 💡 保養建議
664
+ • (具體建議1)
665
+ • (具體建議2)
666
+ • (具體建議3)
667
+
668
+ 🛍️ 推薦產品
669
+ ① (完整品牌+產品名稱)
670
+ ⭐ (20字內推薦原因)
671
+ ② (完整品牌+產品名稱)
672
+ ⭐ (20字內推薦原因)
673
+ ③ (完整品牌+產品名稱)
674
+ ⭐ (20字內推薦原因)
675
+ """
676
+ try:
677
+ response = model.generate_content(prompt)
678
+ line_bot_api.push_message(user_id, TextSendMessage(text=response.text))
679
+ except Exception as e:
680
+ line_bot_api.push_message(user_id, TextSendMessage(text=f"❌ 精靈魔法發動失敗!錯誤原因:\n{e}"))
681
+
682
+ del user_states[user_id]
683
+ else:
684
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text="請選擇下方的質地偏好選項,或是輸入「重新開始」喔!"))
685
+
686
+ elif current_step == "ASK_PHOTO_ITEM":
687
+ text_prompt = f"請幫我查詢「{user_msg}」這款產品在台灣藥妝店的合理售價範圍與推薦購買商家。請簡單溫柔地回答,並條列出品名、價格、商家。"
688
+ try:
689
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text="✨ 收到文字!精靈正在幫您查詢這款產品,請稍候..."))
690
+ response = model.generate_content(text_prompt)
691
+ line_bot_api.push_message(user_id, TextSendMessage(text=f"🧚‍♀️ 查詢成功!\n\n{response.text}"))
692
+ except Exception as e:
693
+ line_bot_api.push_message(user_id, TextSendMessage(text=f"❌ 查詢失敗,錯誤原因:\n{e}"))
694
+ del user_states[user_id]
695
+
696
+ # ── 社群真實口碑:收到商品名稱後產生評論 ──
697
+ elif current_step == "ASK_REVIEW_PRODUCT":
698
+ product_name = user_msg
699
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text=f"🔍 正在搜尋「{product_name}」的社群口碑,請稍候..."))
700
+ review_prompt = f"""
701
+ 你是一個台灣保養品社群平台,請為「{product_name}」這款商品生成 3 則真實風格的用戶評價。
702
+ 必須「只回傳」一個標準的 JSON 陣列,不要有任何額外說明文字。格式嚴格如下:
703
+ [
704
+ {{"reviewer": "身份描述+英文名(如 小資女Sandy、上班族Cathy、學生妹Mia、熟齡女Vivian)", "skin_type": "膚質(乾性肌/油性肌/混合肌/敏感肌)", "rating": 評分數字(1-5), "date": "日期(如 2025/03/15)", "review": "評價內容(50-80字,繁體中文,具體使用心得)", "helpful": 點讚數(0-50之間整數), "pros": ["優點標籤(最多5字)", "優點標籤"], "cons": ["缺點標籤(最多5字)"]}},
705
+ {{"reviewer": "...", "skin_type": "...", "rating": ..., "date": "...", "review": "...", "helpful": ..., "pros": [...], "cons": [...]}},
706
+ {{"reviewer": "...", "skin_type": "...", "rating": ..., "date": "...", "review": "...", "helpful": ..., "pros": [...], "cons": [...]}}
707
+ ]
708
+ pros 最多 2 個,cons 最多 1 個,每個標籤不超過 6 個中文字。
709
+ """
710
+ try:
711
+ review_resp = model.generate_content(review_prompt)
712
+ clean = review_resp.text.replace("```json", "").replace("```", "").strip()
713
+ reviews = json.loads(clean)
714
+ star_map = {5: "⭐⭐⭐⭐⭐", 4: "⭐⭐⭐⭐", 3: "⭐⭐⭐", 2: "⭐⭐", 1: "⭐"}
715
+ bubbles = []
716
+ for r in reviews:
717
+ stars = star_map.get(int(r.get("rating", 5)), "⭐⭐⭐⭐⭐")
718
+ pros = r.get("pros", [])
719
+ cons = r.get("cons", [])
720
+
721
+ # 標籤:每個獨立一行,✅/⚠️,無間距
722
+ tag_box_contents = []
723
+ for p in pros:
724
+ tag_box_contents.append({"type": "text", "text": f"✅ {p}", "size": "xs", "color": "#27AE60"})
725
+ for c in cons:
726
+ tag_box_contents.append({"type": "text", "text": f"⚠️ {c}", "size": "xs", "color": "#E67E22"})
727
+
728
+ body_contents = [
729
+ {"type": "text", "text": product_name, "size": "sm", "color": "#FF6B8A", "weight": "bold"},
730
+ {"type": "text", "text": r.get("review", ""), "size": "sm", "color": "#333333", "wrap": True, "margin": "md"},
731
+ ]
732
+ if tag_box_contents:
733
+ body_contents.append({"type": "box", "layout": "vertical", "margin": "md", "contents": tag_box_contents})
734
+ body_contents.append({"type": "box", "layout": "horizontal", "margin": "md",
735
+ "contents": [{"type": "text", "text": f"👍 {r.get('helpful', 0)} 人覺得有幫助", "size": "xs", "color": "#8c8c8c"}]})
736
+
737
+ card = {
738
+ "type": "bubble", "size": "mega",
739
+ "header": {
740
+ "type": "box", "layout": "vertical",
741
+ "backgroundColor": "#FFF8E7", "paddingAll": "15px",
742
+ "contents": [{
743
+ "type": "box", "layout": "horizontal",
744
+ "contents": [
745
+ {"type": "box", "layout": "vertical", "flex": 1,
746
+ "contents": [{"type": "text", "text": "👤", "size": "xxl", "align": "center"}]},
747
+ {"type": "box", "layout": "vertical", "flex": 4,
748
+ "contents": [
749
+ {"type": "text", "text": r.get("reviewer", "匿名"), "weight": "bold", "size": "md", "color": "#111111"},
750
+ {"type": "text", "text": r.get("skin_type", ""), "size": "xs", "color": "#8c8c8c"},
751
+ {"type": "text", "text": stars, "size": "sm", "margin": "xs"}
752
+ ]},
753
+ {"type": "text", "text": r.get("date", ""), "size": "xs", "color": "#8c8c8c", "align": "end", "flex": 2}
754
+ ]
755
+ }]
756
+ },
757
+ "body": {
758
+ "type": "box", "layout": "vertical",
759
+ "contents": body_contents
760
+ }
761
+ }
762
+ bubbles.append(card)
763
+
764
+ flex_msg = FlexSendMessage.new_from_json_dict({
765
+ "type": "flex", "alt_text": f"💬 {product_name} 的社群口碑",
766
+ "contents": {"type": "carousel", "contents": bubbles}
767
+ })
768
+ line_bot_api.push_message(user_id, flex_msg)
769
+
770
+ # 摘要卡
771
+ avg_rating = sum(int(r.get("rating", 5)) for r in reviews) / len(reviews)
772
+ summary_prompt = f"""
773
+ 根據以下針對「{product_name}」的評論資料,生成一段精簡總結。
774
+ 評論資料:{json.dumps(reviews, ensure_ascii=False)}
775
+
776
+ 必須完全依照下列格式輸出,不要有其他文字:
777
+
778
+ 🧴 {product_name}
779
+
780
+ ⭐ 社群好評度:{avg_rating:.1f} / 5
781
+
782
+ 👍 網友最常提到
783
+
784
+ 1️⃣ (優點1)
785
+ 2️⃣ (優點2)
786
+ 3️⃣ (優點3)
787
+
788
+ 👎 常見缺點
789
+
790
+ • (10-15字,描述具體膚質或情境限制,如「對油性肌或T字部位可能較悶厚」)
791
+ • (10-15字,另一個具體使用限制,如「夏季高溫使用容易感覺黏膩」)
792
+
793
+ 🧚‍♀️ 精靈總結
794
+
795
+ (1句話,15~25字,點出最適合族群與核心優勢,繁體中文)
796
+ """
797
+ summary_resp = model.generate_content(summary_prompt)
798
+ line_bot_api.push_message(user_id, TextSendMessage(text=summary_resp.text.strip()))
799
+
800
+ except Exception as e:
801
+ line_bot_api.push_message(user_id, TextSendMessage(text=f"❌ 口碑查詢失敗:{e}"))
802
+ if user_id in user_states:
803
+ del user_states[user_id]
804
+
805
+ # ── 熱銷排行榜:收到類別後顯示榜單 ──
806
+ elif current_step == "ASK_RANKING_CATEGORY":
807
+ category = user_msg
808
+ if category not in ["保濕護膚", "防曬產品", "清潔卸妝", "全部類別"]:
809
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text="請點擊下方按鈕選擇類別喔!"))
810
+ return
811
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text=f"🏆 正在整理「{category}」熱銷排行榜,請稍候..."))
812
+ rankings = {
813
+ "保濕護膚": [
814
+ {"rank": 1, "name": "CeraVe 長效潤澤修護霜 340g", "brand": "CeraVe 適樂膚", "price": "619", "tag": "💧 神級保濕", "sales": "月售 5,000+"},
815
+ {"rank": 2, "name": "Neutrogena 水活保濕精華 30ml", "brand": "Neutrogena 露得清", "price": "380", "tag": "🌊 輕透水感", "sales": "月售 3,200+"},
816
+ {"rank": 3, "name": "HADA LABO 極潤保濕乳液 140ml", "brand": "HADA LABO 肌研", "price": "299", "tag": "🎌 日系人氣", "sales": "月售 2,800+"},
817
+ {"rank": 4, "name": "Avene 雅漾舒護活泉水 150ml", "brand": "Avene 雅漾", "price": "299", "tag": "🌿 敏肌首選", "sales": "月售 2,500+"},
818
+ {"rank": 5, "name": "Dr.Jart+ 積雪草修護霜 50ml", "brand": "Dr.Jart+", "price": "860", "tag": "🔬 修護強效", "sales": "月售 1,800+"},
819
+ ],
820
+ "防曬產品": [
821
+ {"rank": 1, "name": "Biore 碧柔水感防曬乳 SPF50+ 50ml", "brand": "Biore 碧柔", "price": "299", "tag": "☀️ 超輕盈", "sales": "月售 6,000+"},
822
+ {"rank": 2, "name": "Anessa 金鑽高效防曬乳 60ml", "brand": "Anessa 安耐曬", "price": "780", "tag": "🏅 戶外神器", "sales": "月售 4,200+"},
823
+ {"rank": 3, "name": "SKIN1004 超鎮靜防曬精華 50ml", "brand": "SKIN1004", "price": "550", "tag": "🍃 溫和無感", "sales": "月售 3,500+"},
824
+ {"rank": 4, "name": "肌研極潤防曬乳 SPF50 50ml", "brand": "Rohto 樂敦", "price": "320", "tag": "💆 保濕防曬", "sales": "月售 2,900+"},
825
+ {"rank": 5, "name": "ISDIN 防曬乳 SPF50+ 100ml", "brand": "ISDIN", "price": "1200", "tag": "🌟 皮科推薦", "sales": "月售 1,500+"},
826
+ ],
827
+ "清潔卸妝": [
828
+ {"rank": 1, "name": "Bioderma 貝德瑪卸妝水 500ml", "brand": "Bioderma 貝德瑪", "price": "650", "tag": "💦 無刺激卸妝", "sales": "月售 4,500+"},
829
+ {"rank": 2, "name": "FANCL 無添加卸妝油 120ml", "brand": "FANCL", "price": "580", "tag": "🌸 溫和深清", "sales": "月售 3,800+"},
830
+ {"rank": 3, "name": "DHC 橄欖卸妝油 200ml", "brand": "DHC", "price": "450", "tag": "🫒 經典熱銷", "sales": "月售 3,200+"},
831
+ {"rank": 4, "name": "CeraVe 溫和泡沫洗面乳 236ml", "brand": "CeraVe 適樂膚", "price": "399", "tag": "🧴 醫美等級", "sales": "月售 2,600+"},
832
+ {"rank": 5, "name": "肌研極潤洗顏乳 100g", "brand": "ROHTO 樂敦", "price": "180", "tag": "💰 超值實惠", "sales": "月售 2,100+"},
833
+ ],
834
+ }
835
+ if category == "全部類別":
836
+ merged = []
837
+ for items in rankings.values():
838
+ merged.extend(items[:2])
839
+ rank_list = merged[:5]
840
+ for i, item in enumerate(rank_list):
841
+ item = dict(item)
842
+ item["rank"] = i + 1
843
+ rank_list[i] = item
844
+ else:
845
+ rank_list = rankings[category]
846
+ rank_colors = ["#FFD700", "#C0C0C0", "#CD7F32", "#4A90D9", "#7ED321"]
847
+ rank_medals = ["🥇", "🥈", "🥉", "4️⃣", "5️⃣"]
848
+ bubbles = []
849
+ for item in rank_list:
850
+ idx = item["rank"] - 1
851
+ color = rank_colors[idx] if idx < 5 else "#8c8c8c"
852
+ medal = rank_medals[idx] if idx < 5 else f"#{item['rank']}"
853
+ card = {
854
+ "type": "bubble", "size": "mega",
855
+ "header": {
856
+ "type": "box", "layout": "horizontal",
857
+ "backgroundColor": color, "paddingAll": "15px",
858
+ "contents": [
859
+ {"type": "text", "text": medal, "size": "xxl", "flex": 1, "align": "center"},
860
+ {"type": "box", "layout": "vertical", "flex": 4, "contents": [
861
+ {"type": "text", "text": f"排行第 {item['rank']} 名", "size": "xs", "color": "#FFFFFF", "weight": "bold"},
862
+ {"type": "text", "text": item["tag"], "size": "sm", "color": "#FFFFFF"}
863
+ ]}
864
+ ]
865
+ },
866
+ "body": {
867
+ "type": "box", "layout": "vertical",
868
+ "contents": [
869
+ {"type": "text", "text": item["name"], "weight": "bold", "size": "lg", "wrap": True, "color": "#111111"},
870
+ {"type": "text", "text": item["brand"], "size": "sm", "color": "#8c8c8c", "margin": "sm"},
871
+ {"type": "box", "layout": "vertical", "spacing": "sm", "margin": "lg", "contents": [
872
+ {"type": "box", "layout": "baseline", "spacing": "sm", "contents": [
873
+ {"type": "text", "text": "參考售價", "size": "sm", "color": "#8c8c8c", "flex": 3},
874
+ {"type": "text", "text": f"${item['price']}", "size": "sm", "color": "#111111", "weight": "bold", "flex": 5}
875
+ ]},
876
+ {"type": "box", "layout": "baseline", "spacing": "sm", "contents": [
877
+ {"type": "text", "text": "銷售熱度", "size": "sm", "color": "#8c8c8c", "flex": 3},
878
+ {"type": "text", "text": item["sales"], "size": "sm", "color": "#FF6B00", "weight": "bold", "flex": 5}
879
+ ]}
880
+ ]}
881
+ ]
882
+ },
883
+ "footer": {
884
+ "type": "box", "layout": "vertical",
885
+ "contents": [{"type": "button", "style": "primary", "color": "#000000", "height": "sm",
886
+ "action": {"type": "message", "label": "💝 加入收藏", "text": f"快速收藏:{item['name']}:${item['price']}"}}]
887
+ }
888
+ }
889
+ bubbles.append(card)
890
+ flex_msg = FlexSendMessage.new_from_json_dict({
891
+ "type": "flex", "alt_text": f"🏆 {category} 熱銷排行榜",
892
+ "contents": {"type": "carousel", "contents": bubbles}
893
+ })
894
+ line_bot_api.push_message(user_id, flex_msg)
895
+ if user_id in user_states:
896
+ del user_states[user_id]
897
+
898
+ # ── 收藏商品:輸入商品名稱後儲存 ──
899
+ elif current_step == "ASK_FAVORITE_NAME":
900
+ product_name = user_msg
901
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text=f"✨ 正在查詢「{product_name}」商品資訊..."))
902
+ try:
903
+ price_resp = model.generate_content(f"請告訴我「{product_name}」在台灣藥妝店的大約售價,只回傳數字,不要貨幣符號或其他文字。")
904
+ price_text = price_resp.text.strip().replace("$", "").replace("NT", "").replace(",", "").strip()
905
+ price_display = f"${price_text}"
906
+ except Exception:
907
+ price_display = "未知"
908
+ if user_id not in user_favorites:
909
+ user_favorites[user_id] = []
910
+ if any(f["name"] == product_name for f in user_favorites[user_id]):
911
+ line_bot_api.push_message(user_id, TextSendMessage(text=f"💝「{product_name}」已在您的收藏清單中囉!"))
912
+ else:
913
+ user_favorites[user_id].append({"name": product_name, "price": price_display, "note": "手動加入收藏"})
914
+ line_bot_api.push_message(user_id, TextSendMessage(text=f"✅ 已成功收藏「{product_name}」!\n參考售價:{price_display}\n\n輸入「收藏商品」可查看完整清單 💝"))
915
+ if user_id in user_states:
916
+ del user_states[user_id]
917
+
918
+ if __name__ == "__main__":
919
+ app.run(host='0.0.0.0', port=7860)
ppp ADDED
@@ -0,0 +1,585 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import requests
3
+ import google.generativeai as genai
4
+ from flask import Flask, request, abort
5
+ from linebot import LineBotApi, WebhookHandler
6
+ from linebot.exceptions import InvalidSignatureError
7
+ import io
8
+ import json
9
+ from PIL import Image
10
+ from linebot.models import (
11
+ MessageEvent, TextMessage, TextSendMessage, QuickReply,
12
+ QuickReplyButton, MessageAction, ImageMessage, FlexSendMessage,
13
+ LocationMessage, LocationAction
14
+ )
15
+ import urllib.parse
16
+ import math
17
+ from datetime import datetime, timezone, timedelta
18
+
19
+ app = Flask(__name__)
20
+
21
+ # ================= 1. 從環境變數讀取金鑰 =================
22
+ line_bot_api = LineBotApi(os.environ.get('LINE_CHANNEL_ACCESS_TOKEN'))
23
+ handler = WebhookHandler(os.environ.get('LINE_CHANNEL_SECRET'))
24
+ genai.configure(api_key=os.environ.get('GEMINI_API_KEY'))
25
+ model = genai.GenerativeModel('gemini-2.5-flash')
26
+
27
+ # ================= 建立虛擬藥妝資料庫 =================
28
+ fake_database = {
29
+ "CeraVe適樂膚 長效潤澤修護霜 340g": [
30
+ {"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"},
31
+ {"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"},
32
+ {"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"}
33
+ ],
34
+ "Avene 雅漾舒護活泉水150ml": [
35
+ {"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"},
36
+ {"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"},
37
+ {"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"}
38
+ ]
39
+ }
40
+
41
+ # ================= 2. 狀態儲存區 =================
42
+ user_states = {}
43
+
44
+ @app.route("/callback", methods=['POST'])
45
+ def callback():
46
+ signature = request.headers['X-Line-Signature']
47
+ body = request.get_data(as_text=True)
48
+ try:
49
+ handler.handle(body, signature)
50
+ except InvalidSignatureError:
51
+ abort(400)
52
+ return 'OK'
53
+
54
+ # ================= 3. 拍照精靈 (圖片處理) =================
55
+ @handler.add(MessageEvent, message=ImageMessage)
56
+ def handle_image(event):
57
+ user_id = event.source.user_id
58
+ state = user_states.get(user_id)
59
+
60
+ if state and state.get("step") == "ASK_PHOTO_ITEM":
61
+ line_bot_api.reply_message(
62
+ event.reply_token,
63
+ TextSendMessage(text="✨ 收到照片!精靈正在用魔法辨識這款產品,請稍候...")
64
+ )
65
+
66
+ try:
67
+ message_content = line_bot_api.get_message_content(event.message.id)
68
+ image_bytes = io.BytesIO(message_content.content)
69
+ pil_image = Image.open(image_bytes)
70
+
71
+ vision_prompt = """
72
+ 請辨識這張圖片中的保養品。
73
+ 如果是 CeraVe (適樂膚) 的產品,請直接回答:CeraVe適樂膚 長效潤澤修護霜 340g
74
+ 如果是 Avene (雅漾) 活泉水,請直接回答:Avene 雅漾舒護活泉水150ml
75
+ 如果都不是,請發揮你的專業,判斷它是什麼真實的品牌與品名,並「直接回答品牌與品名」。
76
+ 注意:非常重要!無論如何,你只能輸出「商品名稱」,絕對不要輸出任何其他廢話、標點符號、價格或商家資訊。
77
+ """
78
+
79
+ response = model.generate_content([vision_prompt, pil_image])
80
+ product_name = response.text.strip()
81
+
82
+ if product_name in fake_database:
83
+ data_list = fake_database[product_name]
84
+ else:
85
+ estimate_prompt = f"""
86
+ 請估算「{product_name}」這款產品在台灣屈臣氏、康是美、寶雅的合理售價。
87
+ 請務必「只回傳」一個標準的 JSON 陣列,不要有任何額外的說明文字。格式嚴格如下:
88
+ [
89
+ {{"store": "屈臣氏", "name": "{product_name}", "price": "估計數字"}},
90
+ {{"store": "康是美", "name": "{product_name}", "price": "估計數字"}},
91
+ {{"store": "寶雅", "name": "{product_name}", "price": "估計數字"}}
92
+ ]
93
+ """
94
+ est_response = model.generate_content(estimate_prompt)
95
+ clean_text = est_response.text.replace("```json", "").replace("```", "").strip()
96
+ data_list = json.loads(clean_text)
97
+
98
+ bubbles = []
99
+ for item in data_list:
100
+ default_logos = {
101
+ "屈臣氏": "https://openhouse.osa.nycu.edu.tw/media/data/company_logos/3._%E5%B1%88%E8%87%A3%E6%B0%8Flogo.png",
102
+ "康是美": "https://img.skmbuy.com.tw/App_Images/727/Brand/2022/1215/4b8e1829-8f30-46a9-b044-834c11fc884a.jpg",
103
+ "寶雅": "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"
104
+ }
105
+
106
+ safe_store = item.get("store", "推薦藥妝店")
107
+ safe_name = item.get("name", "未知名稱商品")
108
+ safe_price = item.get("price", "請洽門市")
109
+ safe_logo = item.get("logo", default_logos.get(safe_store, "https://via.placeholder.com/600x300/eeeeee/999999?text=Store+Logo"))
110
+ safe_discount = item.get("discount", "")
111
+
112
+ search_query = urllib.parse.quote(f"{safe_store} {safe_name}")
113
+ safe_link = item.get("link", f"https://www.google.com/search?q={search_query}")
114
+
115
+ specs_box = {
116
+ "type": "box",
117
+ "layout": "vertical",
118
+ "spacing": "sm",
119
+ "margin": "lg",
120
+ "contents": [
121
+ {
122
+ "type": "box",
123
+ "layout": "horizontal",
124
+ "contents": [
125
+ {"type": "text", "text": "販售商家", "size": "md", "color": "#8c8c8c", "flex": 1},
126
+ {"type": "text", "text": safe_store, "size": "md", "color": "#111111", "weight": "bold", "flex": 2}
127
+ ]
128
+ },
129
+ {
130
+ "type": "box",
131
+ "layout": "horizontal",
132
+ "contents": [
133
+ {"type": "text", "text": "商品售價", "size": "md", "color": "#8c8c8c", "flex": 1},
134
+ {"type": "text", "text": f"${safe_price}", "size": "md", "color": "#111111", "weight": "bold", "flex": 2}
135
+ ]
136
+ }
137
+ ]
138
+ }
139
+
140
+ if safe_discount:
141
+ specs_box["contents"].append(
142
+ {
143
+ "type": "box",
144
+ "layout": "horizontal",
145
+ "contents": [
146
+ {"type": "text", "text": "獨家優惠", "size": "md", "color": "#8c8c8c", "flex": 1},
147
+ {"type": "text", "text": safe_discount, "size": "md", "color": "#ff3b30", "weight": "bold", "flex": 2}
148
+ ]
149
+ }
150
+ )
151
+
152
+ card = {
153
+ "type": "bubble",
154
+ "size": "mega",
155
+ "hero": {
156
+ "type": "image",
157
+ "url": safe_logo,
158
+ "size": "full",
159
+ "aspectRatio": "2:1",
160
+ "aspectMode": "fit",
161
+ "backgroundColor": "#FFFFFF"
162
+ },
163
+ "body": {
164
+ "type": "box",
165
+ "layout": "vertical",
166
+ "contents": [
167
+ {"type": "text", "text": safe_name, "weight": "bold", "size": "xl", "wrap": True},
168
+ specs_box
169
+ ]
170
+ },
171
+ "footer": {
172
+ "type": "box",
173
+ "layout": "vertical",
174
+ "contents": [
175
+ {
176
+ "type": "button",
177
+ "style": "primary",
178
+ "color": "#000000",
179
+ "height": "md",
180
+ "action": {
181
+ "type": "uri",
182
+ "label": "前往商家購買",
183
+ "uri": safe_link
184
+ }
185
+ }
186
+ ]
187
+ }
188
+ }
189
+ bubbles.append(card)
190
+
191
+ flex_message = FlexSendMessage.new_from_json_dict({
192
+ "type": "flex",
193
+ "alt_text": "🔍 藥妝精靈比價報告來囉!",
194
+ "contents": {
195
+ "type": "carousel",
196
+ "contents": bubbles
197
+ }
198
+ })
199
+ line_bot_api.push_message(user_id, flex_message)
200
+ if user_id in user_states: del user_states[user_id]
201
+
202
+ except Exception as e:
203
+ line_bot_api.push_message(
204
+ user_id,
205
+ TextSendMessage(text=f"❌ 哎呀!精靈遇到了一點問題。錯誤原因:\n{e}")
206
+ )
207
+
208
+ # ================= 4. 附近店家 (位置處理) =================
209
+ @handler.add(MessageEvent, message=LocationMessage)
210
+ def handle_location(event):
211
+ user_id = event.source.user_id
212
+ user_lat = event.message.latitude
213
+ user_lon = event.message.longitude
214
+
215
+ state = user_states.get(user_id, {})
216
+ brand_preference = state.get("search_brand", "全都要")
217
+
218
+ # ✅ 完美修正:正確的環境變數名稱
219
+ google_api_key = os.environ.get('Maps_API_KEY')
220
+ if not google_api_key:
221
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text="❌ 系統尚未設定 Google Maps API 金鑰。"))
222
+ return
223
+
224
+ def calculate_distance(lat1, lon1, lat2, lon2):
225
+ R = 6371.0
226
+ lat1_rad, lon1_rad = math.radians(lat1), math.radians(lon1)
227
+ lat2_rad, lon2_rad = math.radians(lat2), math.radians(lon2)
228
+ dlon = lon2_rad - lon1_rad
229
+ dlat = lat2_rad - lat1_rad
230
+ a = math.sin(dlat / 2)**2 + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon / 2)**2
231
+ c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
232
+ return int(R * c * 1000)
233
+
234
+ def get_exact_hours(place_id, api_key):
235
+ url = f"https://maps.googleapis.com/maps/api/place/details/json?place_id={place_id}&fields=opening_hours&language=zh-TW&key={api_key}"
236
+ try:
237
+ res = requests.get(url).json()
238
+ if 'result' in res and 'opening_hours' in res['result']:
239
+ weekday_text = res['result']['opening_hours'].get('weekday_text', [])
240
+ if weekday_text:
241
+ tw_tz = timezone(timedelta(hours=8))
242
+ today_idx = datetime.now(tw_tz).weekday()
243
+ today_str = weekday_text[today_idx]
244
+ if ": " in today_str:
245
+ return today_str.split(": ", 1)[1]
246
+ except Exception:
247
+ pass
248
+ return "詳情請洽門市"
249
+
250
+ brands_to_search = ["屈臣氏", "康是美", "寶雅"] if brand_preference == "全都要" else [brand_preference]
251
+ search_radius = 3000
252
+
253
+ try:
254
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text="🧚‍♀️ 精靈正在為您精準定位各品牌門市,請稍候..."))
255
+
256
+ nearest_stores = []
257
+
258
+ for brand in brands_to_search:
259
+ 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}"
260
+ response = requests.get(url).json()
261
+
262
+ if response['status'] == 'OK' and response.get('results'):
263
+ brand_stores = []
264
+ for place in response['results']:
265
+ store_lat = place['geometry']['location']['lat']
266
+ store_lon = place['geometry']['location']['lng']
267
+ distance = calculate_distance(user_lat, user_lon, store_lat, store_lon)
268
+
269
+ photo_ref = ""
270
+ if 'photos' in place and len(place['photos']) > 0:
271
+ photo_ref = place['photos'][0]['photo_reference']
272
+
273
+ open_now = place.get('opening_hours', {}).get('open_now')
274
+ if open_now is True:
275
+ status_light = "🟢 營業中"
276
+ elif open_now is False:
277
+ status_light = "🔴 休息中"
278
+ else:
279
+ status_light = "🕒 營業狀態"
280
+
281
+ brand_stores.append({
282
+ "place_id": place.get('place_id'),
283
+ "name": place.get('name', '未知名稱'),
284
+ "rating": place.get('rating', '無評分'),
285
+ "lat": store_lat,
286
+ "lon": store_lon,
287
+ "distance": distance,
288
+ "address": place.get('formatted_address', ''),
289
+ "status_light": status_light,
290
+ "photo_ref": photo_ref
291
+ })
292
+
293
+ sorted_brand_stores = sorted(brand_stores, key=lambda x: x['distance'])
294
+
295
+ if brand_preference == "全都要":
296
+ if sorted_brand_stores:
297
+ nearest_stores.append(sorted_brand_stores[0])
298
+ else:
299
+ nearest_stores.extend(sorted_brand_stores[:3])
300
+
301
+ if not nearest_stores:
302
+ line_bot_api.push_message(user_id, TextSendMessage(text="🥺 哎呀,精靈在您附近 3 公里內都找不到藥妝店呢..."))
303
+ if user_id in user_states: del user_states[user_id]
304
+ return
305
+
306
+ nearest_stores = sorted(nearest_stores, key=lambda x: x['distance'])
307
+
308
+ bubbles = []
309
+ for store in nearest_stores:
310
+ exact_hours = get_exact_hours(store['place_id'], google_api_key)
311
+ display_hours = f"{store['status_light']} ({exact_hours})"
312
+
313
+ map_query = urllib.parse.quote(f"{store['name']} {store['address']}")
314
+ map_url = f"https://www.google.com/maps/search/?api=1&query={map_query}"
315
+
316
+ if store['photo_ref']:
317
+ image_url = f"https://maps.googleapis.com/maps/api/place/photo?maxwidth=600&photo_reference={store['photo_ref']}&key={google_api_key}"
318
+ else:
319
+ image_url = "https://images.unsplash.com/photo-1576426863848-c21f53c60b19?q=80&w=600&auto=format&fit=crop"
320
+
321
+ # ✅ 完美修正:排版乾淨、結構完全正確的規格表!
322
+ card = {
323
+ "type": "bubble",
324
+ "size": "mega",
325
+ "hero": {
326
+ "type": "image",
327
+ "url": image_url,
328
+ "size": "full",
329
+ "aspectRatio": "16:9",
330
+ "aspectMode": "cover"
331
+ },
332
+ "body": {
333
+ "type": "box",
334
+ "layout": "vertical",
335
+ "contents": [
336
+ {"type": "text", "text": store['name'], "weight": "bold", "size": "xl", "wrap": True, "color": "#111111"},
337
+ {
338
+ "type": "box",
339
+ "layout": "vertical",
340
+ "spacing": "sm",
341
+ "margin": "lg",
342
+ "contents": [
343
+ {
344
+ "type": "box",
345
+ "layout": "baseline",
346
+ "spacing": "sm",
347
+ "contents": [
348
+ {"type": "text", "text": "目前距離", "size": "sm", "color": "#8c8c8c", "flex": 3},
349
+ {"type": "text", "text": f"{store['distance']} 公尺", "size": "sm", "color": "#111111", "weight": "bold", "flex": 5}
350
+ ]
351
+ },
352
+ {
353
+ "type": "box",
354
+ "layout": "baseline",
355
+ "spacing": "sm",
356
+ "contents": [
357
+ {"type": "text", "text": "營業時間", "size": "sm", "color": "#8c8c8c", "flex": 3},
358
+ {"type": "text", "text": display_hours, "size": "sm", "color": "#111111", "weight": "bold", "flex": 5, "wrap": True}
359
+ ]
360
+ },
361
+ {
362
+ "type": "box",
363
+ "layout": "baseline",
364
+ "spacing": "sm",
365
+ "contents": [
366
+ {"type": "text", "text": "店家評分", "size": "sm", "color": "#8c8c8c", "flex": 3},
367
+ {"type": "text", "text": f"⭐ {store['rating']}", "size": "sm", "color": "#fbbc04", "weight": "bold", "flex": 5}
368
+ ]
369
+ }
370
+ ]
371
+ }
372
+ ]
373
+ },
374
+ "footer": {
375
+ "type": "box",
376
+ "layout": "vertical",
377
+ "contents": [
378
+ {
379
+ "type": "button",
380
+ "style": "primary",
381
+ "color": "#000000",
382
+ "height": "md",
383
+ "action": {
384
+ "type": "uri",
385
+ "label": "開啟地圖導航",
386
+ "uri": map_url
387
+ }
388
+ }
389
+ ]
390
+ }
391
+ }
392
+ bubbles.append(card)
393
+
394
+ flex_message = FlexSendMessage.new_from_json_dict({
395
+ "type": "flex",
396
+ "alt_text": "🔍 附近的藥妝店地圖出爐囉!",
397
+ "contents": {
398
+ "type": "carousel",
399
+ "contents": bubbles
400
+ }
401
+ })
402
+
403
+ line_bot_api.push_message(user_id, flex_message)
404
+ if user_id in user_states: del user_states[user_id]
405
+
406
+ except Exception as e:
407
+ line_bot_api.push_message(user_id, TextSendMessage(text=f"❌ 搜尋過程發生錯誤:{e}"))
408
+ if user_id in user_states: del user_states[user_id]
409
+
410
+ # ================= 5. 多輪對話核心邏輯 (文字對話) =================
411
+ @handler.add(MessageEvent, message=TextMessage)
412
+ def handle_message(event):
413
+ user_id = event.source.user_id
414
+ user_msg = event.message.text.strip()
415
+
416
+ if user_msg == "附近店家":
417
+ user_states[user_id] = {"step": "ASK_BRAND"}
418
+
419
+ brand_buttons = QuickReply(items=[
420
+ QuickReplyButton(action=MessageAction(label="屈臣氏", text="屈臣氏")),
421
+ QuickReplyButton(action=MessageAction(label="康是美", text="康是美")),
422
+ QuickReplyButton(action=MessageAction(label="寶雅", text="寶雅")),
423
+ QuickReplyButton(action=MessageAction(label="全都要", text="全都要"))
424
+ ])
425
+
426
+ line_bot_api.reply_message(
427
+ event.reply_token,
428
+ TextSendMessage(text="請問你想尋找哪一間藥妝店呢?", quick_reply=brand_buttons)
429
+ )
430
+ return
431
+
432
+ state = user_states.get(user_id)
433
+ if state and state.get("step") == "ASK_BRAND":
434
+ if user_msg in ["屈臣氏", "康是美", "寶雅", "全都要"]:
435
+ state["search_brand"] = user_msg
436
+ state["step"] = "ASK_LOCATION"
437
+
438
+ location_button = QuickReply(items=[
439
+ QuickReplyButton(action=LocationAction(label="📍 點我分享位置"))
440
+ ])
441
+
442
+ line_bot_api.reply_message(
443
+ event.reply_token,
444
+ TextSendMessage(text=f"沒問題!請分享您的位置,精靈來幫您找最近的【{user_msg}】!", quick_reply=location_button)
445
+ )
446
+ else:
447
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text="請點擊下方的按鈕選擇品牌喔!"))
448
+ return
449
+
450
+ if user_msg == "精靈顧問" or user_msg == "重新開始":
451
+ user_states[user_id] = {"step": "ASK_SKIN"}
452
+
453
+ skin_buttons = QuickReply(items=[
454
+ QuickReplyButton(action=MessageAction(label="乾性肌", text="乾性肌")),
455
+ QuickReplyButton(action=MessageAction(label="油性肌", text="油性肌")),
456
+ QuickReplyButton(action=MessageAction(label="混合肌", text="混合肌")),
457
+ QuickReplyButton(action=MessageAction(label="敏感肌", text="敏感肌"))
458
+ ])
459
+
460
+ line_bot_api.reply_message(
461
+ event.reply_token,
462
+ TextSendMessage(text="🧚‍♀️ 嗨!我是你的專屬精靈顧問。為了給你最精準的保養建議,先告訴我,你覺得自己偏向哪一種膚質呢?", quick_reply=skin_buttons)
463
+ )
464
+ return
465
+
466
+ if user_msg == "拍照精靈":
467
+ user_states[user_id] = {"step": "ASK_PHOTO_ITEM"}
468
+ line_bot_api.reply_message(
469
+ event.reply_token,
470
+ TextSendMessage(text="📷 歡迎開啟拍照精靈!\n您可以直接拍下商品照片傳給我,或是輸入「文字」來查詢商品售價喔!")
471
+ )
472
+ return
473
+
474
+ if not state:
475
+ line_bot_api.reply_message(
476
+ event.reply_token,
477
+ TextSendMessage(text="請輸入「精靈顧問」來開啟膚況諮詢服務喔!")
478
+ )
479
+ return
480
+
481
+ current_step = state.get("step")
482
+
483
+ if current_step == "ASK_SKIN":
484
+ if user_msg in ["乾性肌", "油性肌", "混合肌", "敏感肌"]:
485
+ state["skin_type"] = user_msg
486
+ state["step"] = "ASK_ISSUE"
487
+
488
+ issue_buttons = QuickReply(items=[
489
+ QuickReplyButton(action=MessageAction(label="嚴重脫皮", text="嚴重脫皮")),
490
+ QuickReplyButton(action=MessageAction(label="暗沉無光", text="暗沉無光")),
491
+ QuickReplyButton(action=MessageAction(label="痘痘粉刺", text="痘痘粉刺")),
492
+ QuickReplyButton(action=MessageAction(label="泛紅乾癢", text="泛紅乾癢"))
493
+ ])
494
+
495
+ line_bot_api.reply_message(
496
+ event.reply_token,
497
+ TextSendMessage(text=f"記錄下來了!你是 {user_msg}。那最近最讓你困擾的肌膚問題是什麼呢?", quick_reply=issue_buttons)
498
+ )
499
+ else:
500
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text="請選擇下方的膚質選項,或是輸入「重新開始」喔!"))
501
+
502
+ elif current_step == "ASK_ISSUE":
503
+ if user_msg in ["嚴重脫皮", "暗沉無光", "痘痘粉刺", "泛紅乾癢"]:
504
+ state["issue"] = user_msg
505
+ state["step"] = "ASK_PREFERENCE"
506
+
507
+ pref_buttons = QuickReply(items=[
508
+ QuickReplyButton(action=MessageAction(label="偏好清爽凝露", text="偏好清爽凝露")),
509
+ QuickReplyButton(action=MessageAction(label="偏好滋潤乳霜", text="偏好滋潤乳霜")),
510
+ QuickReplyButton(action=MessageAction(label="都可以", text="都可以"))
511
+ ])
512
+
513
+ line_bot_api.reply_message(
514
+ event.reply_token,
515
+ TextSendMessage(text=f"收到!針對 {user_msg} 的問題,你在挑選保養品時有特別偏好哪種質地嗎?", quick_reply=pref_buttons)
516
+ )
517
+ else:
518
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text="請選擇下方的肌膚問題選項,或是輸入「重新開始」喔!"))
519
+
520
+ elif current_step == "ASK_PREFERENCE":
521
+ if user_msg in ["偏好清爽凝露", "偏好滋潤乳霜", "都可以"]:
522
+ skin_type = state["skin_type"]
523
+ issue = state["issue"]
524
+ preference = user_msg
525
+
526
+ line_bot_api.reply_message(
527
+ event.reply_token,
528
+ TextSendMessage(text="🧚‍♀️ 精靈正在為您翻閱保養圖鑑,請稍候幾秒鐘...")
529
+ )
530
+
531
+ prompt = f"""
532
+ 你現在是一位「台灣藥妝店皮膚科精靈顧問🧚‍♀️」,擁有10年以上保養諮詢經驗。
533
+ 使用者資料:
534
+ * 膚質:{skin_type}
535
+ * 困擾:{issue}
536
+ * 偏好:{preference}
537
+ 請根據以上資訊提供個人化保養建議。
538
+
539
+ 【輸出規則】
540
+ 1. 回覆控制在 180~250 字。
541
+ 2. 語氣溫柔、親切,像專業美容顧問。
542
+ 3. 使用繁體中文。
543
+ 4. 不要使用表格。
544
+ 5. 必須完全依照下列格式輸出:
545
+
546
+ 🧚‍♀️ 哈囉親愛的!
547
+
548
+ 🩺 精靈診斷
549
+ (用1~2句話分析目前膚況,需根據使用者的膚質與困擾客製化)
550
+
551
+ 💡 保養建議
552
+ • (具體建議1)
553
+ • (具體建議2)
554
+ • (具體建議3)
555
+
556
+ 🛍️ 推薦產品
557
+ ① (完整品牌+產品名稱)
558
+ ⭐ (20字內推薦原因)
559
+ ② (完整品牌+產品名稱)
560
+ ⭐ (20字內推薦原因)
561
+ ③ (完整品牌+產品名稱)
562
+ ⭐ (20字內推薦原因)
563
+ """
564
+ try:
565
+ response = model.generate_content(prompt)
566
+ line_bot_api.push_message(user_id, TextSendMessage(text=response.text))
567
+ except Exception as e:
568
+ line_bot_api.push_message(user_id, TextSendMessage(text=f"❌ 精靈魔法發動失敗!錯誤原因:\n{e}"))
569
+
570
+ del user_states[user_id]
571
+ else:
572
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text="請選擇下方的質地偏好選項,或是輸入「重新開始」喔!"))
573
+
574
+ elif current_step == "ASK_PHOTO_ITEM":
575
+ text_prompt = f"請幫我查詢「{user_msg}」這款產品在台灣藥妝店的合理售價範圍與推薦購買商家。請簡單溫柔地回答,並條列出品名、價格、商家。"
576
+ try:
577
+ line_bot_api.reply_message(event.reply_token, TextSendMessage(text="✨ 收到文字!精靈正在幫您查詢這款產品,請稍候..."))
578
+ response = model.generate_content(text_prompt)
579
+ line_bot_api.push_message(user_id, TextSendMessage(text=f"🧚‍♀️ 查詢成功!\n\n{response.text}"))
580
+ except Exception as e:
581
+ line_bot_api.push_message(user_id, TextSendMessage(text=f"❌ 查詢失敗,錯誤原因:\n{e}"))
582
+ del user_states[user_id]
583
+
584
+ if __name__ == "__main__":
585
+ app.run(host='0.0.0.0', port=7860)
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ flask==3.0.0
2
+ line-bot-sdk==3.5.0
3
+ google-generativeai
4
+ Pillow
5
+ playwright
6
+ camoufox[geoip]
scraper.py ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 藥妝店商品爬蟲 — 康是美 / 寶雅 (91APP) / 屈臣氏
3
+ 已驗證方法:
4
+ 康是美: https://shop.cosmed.com.tw/search?q={keyword}
5
+ 寶雅: https://www.poyabuy.com.tw/search?q={keyword}
6
+ 兩家都用 91APP 前端,selector: a.product-card__vertical
7
+ 屈臣氏: camoufox (Firefox-based stealth) 繞過 Cloudflare,
8
+ 取得 oauth token 後呼叫 api.watsons.com.tw 搜尋 API
9
+ """
10
+
11
+ import re
12
+ import urllib.parse
13
+ import concurrent.futures
14
+
15
+ from playwright.sync_api import sync_playwright, TimeoutError as PWTimeout
16
+
17
+ TIMEOUT = 25_000
18
+ WATSONS_TIMEOUT = 35_000
19
+ UA = (
20
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
21
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
22
+ )
23
+
24
+ # 促銷標籤模式 — 這些不是商品名稱,解析時跳過
25
+ _PROMO_RE = re.compile(
26
+ r"^(POYA限定|買\d+送\d+|買一送一|買二送一|下單請選購\d+件|限定|特惠|"
27
+ r"已售完|即將缺貨|\d+%\s*OFF|組合優惠|限時|新品|爆款|\d+折)$",
28
+ re.IGNORECASE,
29
+ )
30
+
31
+
32
+ def _parse_91app_card(card) -> tuple[str, str, str]:
33
+ """
34
+ 從 a.product-card__vertical 的 innerText 解析商品名稱、現售價、原價。
35
+ 91APP 卡片格式:原價(較高)在前,折後價(較低)在後。
36
+ 回傳 (name, current_price, original_price),無折扣時 original_price 為空字串。
37
+ """
38
+ text = card.inner_text()
39
+ lines = [l.strip() for l in text.split("\n") if l.strip()]
40
+
41
+ name = ""
42
+ for line in lines:
43
+ if not _PROMO_RE.match(line) and len(line) > 2:
44
+ name = line
45
+ break
46
+
47
+ # 找出所有價格,取最小值為現售價、最大值為原價
48
+ prices = []
49
+ for line in lines:
50
+ m = re.search(r"NT\$\s*([\d,]+)", line)
51
+ if m:
52
+ prices.append(int(m.group(1).replace(",", "")))
53
+
54
+ if not prices:
55
+ return name, "", ""
56
+
57
+ current = min(prices)
58
+ original = max(prices)
59
+ current_str = f"NT${current}"
60
+ original_str = f"NT${original}" if original != current else ""
61
+
62
+ return name, current_str, original_str
63
+
64
+
65
+ def _scrape_91app(store_name: str, base_url: str, keyword: str) -> dict | None:
66
+ """通用 91APP 搜尋爬蟲。"""
67
+ encoded = urllib.parse.quote(keyword)
68
+ search_url = f"{base_url}/search?q={encoded}"
69
+
70
+ try:
71
+ with sync_playwright() as pw:
72
+ browser = pw.chromium.launch(headless=True, args=["--no-sandbox"])
73
+ page = browser.new_page(user_agent=UA)
74
+ try:
75
+ page.goto(search_url, wait_until="networkidle", timeout=TIMEOUT)
76
+ except PWTimeout:
77
+ pass
78
+
79
+ cards = page.query_selector_all("a.product-card__vertical")
80
+ result = None
81
+ if cards:
82
+ href = cards[0].get_attribute("href") or ""
83
+ name, price, original_price = _parse_91app_card(cards[0])
84
+ if name and price:
85
+ product_url = f"{base_url}{href}" if href.startswith("/") else href
86
+ result = {
87
+ "store": store_name,
88
+ "name": name,
89
+ "price": price,
90
+ "original_price": original_price,
91
+ "url": product_url,
92
+ "real": True,
93
+ }
94
+ browser.close()
95
+ return result
96
+ except Exception:
97
+ return None
98
+
99
+
100
+ def scrape_cosmed(keyword: str) -> dict | None:
101
+ return _scrape_91app("康是美", "https://shop.cosmed.com.tw", keyword)
102
+
103
+
104
+ def scrape_poya(keyword: str) -> dict | None:
105
+ return _scrape_91app("寶雅", "https://www.poyabuy.com.tw", keyword)
106
+
107
+
108
+ def scrape_watsons(keyword: str) -> dict | None:
109
+ """
110
+ 屈臣氏使用 camoufox (Firefox stealth) 繞過 Cloudflare:
111
+ 1. 載入頁面取得 guest oauth token
112
+ 2. 用 token 呼叫 REST 搜尋 API 取得商品資料
113
+ """
114
+ try:
115
+ from camoufox.sync_api import Camoufox
116
+ except ImportError:
117
+ return None
118
+
119
+ encoded = urllib.parse.quote(keyword)
120
+ page_url = f"https://www.watsons.com.tw/search?text={encoded}"
121
+
122
+ try:
123
+ with Camoufox(headless=True, geoip=True) as browser:
124
+ page = browser.new_page()
125
+ access_token = None
126
+
127
+ def on_resp(resp):
128
+ nonlocal access_token
129
+ try:
130
+ if "oauth/token" in resp.url and not access_token:
131
+ body = resp.json()
132
+ access_token = body.get("access_token")
133
+ except Exception:
134
+ pass
135
+
136
+ page.on("response", on_resp)
137
+ try:
138
+ page.goto(page_url, wait_until="networkidle", timeout=WATSONS_TIMEOUT)
139
+ except Exception:
140
+ pass
141
+
142
+ if not access_token:
143
+ page.close()
144
+ return None
145
+
146
+ kw = urllib.parse.quote(keyword)
147
+ api_url = (
148
+ f"https://api.watsons.com.tw/api/v2/wtctw/products/search"
149
+ f"?fields=FULL&query={kw}&pageSize=5&currentPage=0&lang=zh_TW&curr=TWD"
150
+ )
151
+ resp = page.request.get(
152
+ api_url,
153
+ headers={
154
+ "Authorization": f"bearer {access_token}",
155
+ "Accept": "application/json",
156
+ "Accept-Language": "zh-TW,zh;q=0.9",
157
+ },
158
+ )
159
+
160
+ # 必須在 page.close() 前讀取回應,否則 response 會被釋放
161
+ if not resp.ok:
162
+ page.close()
163
+ return None
164
+ data = resp.json()
165
+ page.close()
166
+
167
+ products = data.get("products", [])
168
+ if not products:
169
+ return None
170
+
171
+ p = products[0]
172
+ name = p.get("name", keyword)
173
+ price_info = p.get("price", {})
174
+ raw_price = price_info.get("value") if isinstance(price_info, dict) else price_info
175
+ price = f"NT${int(raw_price)}" if raw_price else "請洽門市"
176
+
177
+ # 屈臣氏 API 有時會回傳 basePrice(原價)
178
+ base_info = p.get("basePrice") or p.get("wasPrice") or {}
179
+ raw_base = base_info.get("value") if isinstance(base_info, dict) else None
180
+ original_price = ""
181
+ if raw_base and raw_base != raw_price:
182
+ original_price = f"NT${int(raw_base)}"
183
+
184
+ code = p.get("code", "")
185
+ url = f"https://www.watsons.com.tw/p/{code}" if code else page_url
186
+
187
+ return {
188
+ "store": "屈臣氏",
189
+ "name": name,
190
+ "price": price,
191
+ "original_price": original_price,
192
+ "url": url,
193
+ "real": True,
194
+ }
195
+ except Exception:
196
+ return None
197
+
198
+
199
+ def scrape_all_stores(keyword: str) -> list[dict]:
200
+ """
201
+ 並行爬取三家,回傳成功結果(最多 3 筆),依屈臣氏→康是美→寶雅排序。
202
+ 每筆格式: {"store", "name", "price", "url", "real"}
203
+ """
204
+ tasks = {
205
+ "屈臣氏": scrape_watsons,
206
+ "康是美": scrape_cosmed,
207
+ "寶雅": scrape_poya,
208
+ }
209
+ results = []
210
+ with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
211
+ future_map = {executor.submit(fn, keyword): store for store, fn in tasks.items()}
212
+ try:
213
+ for future in concurrent.futures.as_completed(future_map, timeout=65):
214
+ try:
215
+ data = future.result()
216
+ if data:
217
+ results.append(data)
218
+ except Exception:
219
+ pass
220
+ except concurrent.futures.TimeoutError:
221
+ # 時間到了,收集已完成的結果
222
+ for future, store in future_map.items():
223
+ if future.done():
224
+ try:
225
+ data = future.result()
226
+ if data:
227
+ results.append(data)
228
+ except Exception:
229
+ pass
230
+
231
+ order = ["屈臣氏", "康是美", "寶雅"]
232
+ results.sort(key=lambda x: order.index(x["store"]) if x["store"] in order else 99)
233
+ return results