Commit ·
7d26fcb
0
Parent(s):
initial commit
Browse files- .DS_Store +0 -0
- .gitignore +6 -0
- Dockerfile +9 -0
- app.py +1251 -0
- fff +919 -0
- ppp +585 -0
- requirements.txt +6 -0
- 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¤tPage=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
|