This commit is contained in:
@@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
|
||||
# ==========================================
|
||||
# 系統版本與路徑
|
||||
# ==========================================
|
||||
SYSTEM_VERSION = "V10.617"
|
||||
SYSTEM_VERSION = "V10.619"
|
||||
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
|
||||
public_url = PUBLIC_URL # 用於模板顯示
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **最後更新**: 2026-06-16 (台北時間)
|
||||
> **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯;PChome 後台業績匯入韌性已補強;產品定位正名為「PChome 業績成長自動化作戰系統」;外部市場來源正規化層、自動同步、作戰清單與價格參考表優先讀取、CSV 備援預檢、前台操作入口與高可見頁面繁中化守門已建立
|
||||
> **適用版本**: V10.617
|
||||
> **適用版本**: V10.619
|
||||
|
||||
---
|
||||
|
||||
@@ -64,6 +64,8 @@
|
||||
- V10.615 起 AI 智慧推薦頁必須把 Ollama 顯示為「Ollama 主路徑」,Gemini 只能顯示為「Gemini 備援」且手動選項停用;使用者可見錯誤與搜尋流程不得出現 `Web Search`、`Token:`、半形英文冒號等工程文案。
|
||||
- V10.616 起主商品看板 `/` 的統計與補強區塊也納入繁中守門:不得顯示 `ACTIVE`、`PICK COUNT`、`AVG CONFIDENCE`、`EVIDENCE GAP`、`PCHOME MATCH BACKFILL` 等工程標籤;畫面需使用「有效商品」「挑品數」「平均信心」「待補證據」「PChome 比價補強」等白話營運文案。
|
||||
- V10.617 起 `/ai_intelligence` 必須採「先給下一步」的作戰導向 UI:首屏需先回答「今天先做什麼」,再呈現商品處理進度、外部價格來源與操作捷徑;今日處理清單需用表格呈現優先級、建議動作、商品、近 7 天業績、比價結果、資料可信度與下一步;MOMO 外部價格參考需顯示價格風險分佈,且表格需以 PChome 價格優先,明確顯示「PChome 貴 / PChome 便宜」與可信度,不得只用大段文字說明使用方式。
|
||||
- V10.618 起 `/price_comparison` 也必須採「先給下一步」的比價決策 UI:首屏需顯示目前卡在哪一步、PChome / MOMO 資料準備狀態與下一個按鈕;比價結果需先呈現「需檢查價格 / 可主推曝光 / 價格接近」分佈,再用表格列出每筆商品的下一步,不得只呈現 Step 流程或原始價差表。
|
||||
- V10.619 起 MOMO 比價候選來源新增「PChome 商品導向搜尋」:當比價 API 已有 PChome 商品但缺 MOMO 清單時,必須用每筆 PChome 商品名稱產生精準搜尋詞反查 MOMO,保留品牌、品名、容量與組合線索;新版 MOMO 搜尋頁需解析 Next.js `goodsInfoList` payload。此路徑只擴大候選池,不放寬同款 matcher 門檻;`unit_comparable` 與 hard veto 候選只能標成「需人工確認」,不得直接進自動比價告警。
|
||||
|
||||
## 零之一、12 Agent 決策信封(2026-05-24)
|
||||
|
||||
|
||||
@@ -259,3 +259,19 @@
|
||||
- MOMO 外部價格參考新增價格風險分佈,表格改為 PChome 價格在前、MOMO 參考價在後,價差明確顯示「PChome 貴 / PChome 便宜」,並新增「鎖定商品」操作。
|
||||
- 備援 CSV 流程降級為「備援資料檢查」,移到主要作戰與價格表後面,避免誤導使用者以為日常仍要人工匯入。
|
||||
- 前端補上 payload fallback、動態表格 escape、手機版 `data-label` 與補商品對應 busy lock,避免資料缺欄位、特殊字元或重複點擊造成壞畫面。
|
||||
|
||||
## 21. 2026-06-16 V10.618 比價頁改為下一步導向
|
||||
|
||||
- `/price_comparison` 改為「PChome 商品比價決策台」,首屏需先顯示「今天先做」與 PChome / MOMO 商品準備狀態,不再讓使用者從 Step 1/2/3 自行猜流程。
|
||||
- 頁面會依目前資料狀態切換下一步:輸入關鍵字、取得 PChome 商品、匯入 MOMO 商品、開始檢查價差、查看需檢查價格或可主推商品。
|
||||
- 比價結果新增判讀分佈:「需檢查價格」「可主推曝光」「價格接近」,表格第一欄直接呈現每筆商品下一步。
|
||||
- Toast 改用純文字 DOM,手動輸入錯誤訊息不再塞 HTML;更新商品資料時會清掉舊比價結果,避免資料已更新但畫面仍顯示舊判讀。
|
||||
|
||||
## 22. 2026-06-16 V10.619 PChome 導向 MOMO 精準候選搜尋
|
||||
|
||||
- 使用者指出只抓 MOMO 活動頁會讓比價候選池偏窄;V10.619 新增 `search_momo_products_for_pchome_products()`,用 PChome 商品名稱逐筆反查 MOMO 候選。
|
||||
- 搜尋詞沿用 `marketplace_product_matcher.build_search_terms()`,保留品牌、品名、容量、單品與組合線索,例如 B5 40ml、500ml 2入組,避免只用品牌或活動頁商品池。
|
||||
- `/api/price_comparison/compare` 在已有 PChome 商品但缺 MOMO 清單時,會優先走 PChome 導向 MOMO 搜尋;完全沒有 PChome 商品時才退回品牌搜尋。
|
||||
- MOMO 搜尋 parser 已補新版 Next.js `goodsInfoList`,避免明明搜尋頁有商品但 crawler 回 0 筆。
|
||||
- `/price_comparison` 已新增「自動找 MOMO 候選」操作,PChome 商品準備後可直接搜尋 MOMO;回傳會分成「可直接比價」與「需人工確認」。
|
||||
- 新路徑只擴大候選池,不放寬 `score_marketplace_match()` 的 hard veto 與同款分數篩選;`unit_comparable` 候選保留為「需人工確認」,不得直接進自動比價。後續才評估把這條路徑接進背景自動同步 / `external_offers`。
|
||||
|
||||
@@ -12,7 +12,7 @@ from services.price_comparison import (
|
||||
BRAND_ALIASES,
|
||||
BRAND_NORMALIZE_MAP
|
||||
)
|
||||
from services.momo_crawler import search_momo_products
|
||||
from services.momo_crawler import search_momo_products, search_momo_products_for_pchome_products
|
||||
from services.pchome_crawler import search_pchome_products
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -91,17 +91,45 @@ def compare_prices():
|
||||
logger.warning(f"PChome 搜尋失敗: {msg}")
|
||||
pchome_products = []
|
||||
|
||||
targeted_momo_summary = None
|
||||
|
||||
# 取得 MOMO 商品
|
||||
momo_products = data.get('momo_products')
|
||||
if not momo_products:
|
||||
logger.info(f"自動搜尋 MOMO: {brand}")
|
||||
success, msg, momo_products = search_momo_products(brand, limit=100)
|
||||
if pchome_products:
|
||||
logger.info("[PriceComparison] 以 PChome 商品精準搜尋 MOMO 候選: brand=%s pchome_count=%s", brand, len(pchome_products))
|
||||
success, msg, targeted_momo_products = search_momo_products_for_pchome_products(
|
||||
pchome_products,
|
||||
max_products=30,
|
||||
limit_per_product=8,
|
||||
)
|
||||
targeted_momo_summary = {
|
||||
"message": msg,
|
||||
"candidate_count": len(targeted_momo_products or []),
|
||||
"auto_compare_count": len([
|
||||
item for item in (targeted_momo_products or [])
|
||||
if item.get("can_auto_compare")
|
||||
]),
|
||||
"review_count": len([
|
||||
item for item in (targeted_momo_products or [])
|
||||
if not item.get("can_auto_compare")
|
||||
]),
|
||||
}
|
||||
momo_products = [
|
||||
item for item in (targeted_momo_products or [])
|
||||
if item.get("can_auto_compare")
|
||||
]
|
||||
else:
|
||||
logger.info(f"自動搜尋 MOMO: {brand}")
|
||||
success, msg, momo_products = search_momo_products(brand, limit=100)
|
||||
if not success:
|
||||
logger.warning(f"MOMO 搜尋失敗: {msg}")
|
||||
momo_products = []
|
||||
|
||||
# 執行比價
|
||||
result = compare_brand_prices(brand, pchome_products, momo_products)
|
||||
if targeted_momo_summary:
|
||||
result["momo_targeted_search"] = targeted_momo_summary
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
@@ -155,6 +183,47 @@ def fetch_pchome_products():
|
||||
}), 500
|
||||
|
||||
|
||||
@price_comparison_bp.route('/api/price_comparison/fetch_momo_for_pchome', methods=['POST'])
|
||||
@login_required
|
||||
def fetch_momo_for_pchome_products():
|
||||
"""用 PChome 商品清單反查 MOMO 候選;只讀、不寫 DB。"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
pchome_products = data.get('pchome_products') or []
|
||||
if not pchome_products:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '請先取得 PChome 商品,再搜尋 MOMO 候選'
|
||||
}), 400
|
||||
|
||||
success, message, products = search_momo_products_for_pchome_products(
|
||||
pchome_products,
|
||||
max_products=30,
|
||||
limit_per_product=8,
|
||||
)
|
||||
auto_products = [item for item in products if item.get("can_auto_compare")]
|
||||
review_candidates = [item for item in products if not item.get("can_auto_compare")]
|
||||
|
||||
return jsonify({
|
||||
'success': success,
|
||||
'message': message,
|
||||
'data': {
|
||||
'products': auto_products,
|
||||
'review_candidates': review_candidates,
|
||||
'count': len(auto_products),
|
||||
'review_count': len(review_candidates),
|
||||
'candidate_count': len(products),
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"搜尋 MOMO 候選失敗: {e}", exc_info=True)
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'搜尋 MOMO 候選失敗: {str(e)}'
|
||||
}), 500
|
||||
|
||||
|
||||
@price_comparison_bp.route('/api/price_comparison/parse_momo_excel', methods=['POST'])
|
||||
@login_required
|
||||
def parse_momo_excel():
|
||||
|
||||
@@ -14,6 +14,7 @@ import re
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
import os
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from dataclasses import dataclass, asdict
|
||||
from datetime import datetime
|
||||
@@ -23,6 +24,11 @@ from bs4 import BeautifulSoup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MOMO_TARGETED_SEARCH_MIN_SCORE = float(os.getenv("MOMO_TARGETED_SEARCH_MIN_SCORE", "0.45"))
|
||||
MOMO_TARGETED_SEARCH_MAX_PRODUCTS = int(os.getenv("MOMO_TARGETED_SEARCH_MAX_PRODUCTS", "30"))
|
||||
MOMO_TARGETED_SEARCH_MAX_TERMS = int(os.getenv("MOMO_TARGETED_SEARCH_MAX_TERMS", "4"))
|
||||
MOMO_TARGETED_SEARCH_LIMIT_PER_TERM = int(os.getenv("MOMO_TARGETED_SEARCH_LIMIT_PER_TERM", "8"))
|
||||
|
||||
|
||||
@dataclass
|
||||
class MomoProduct:
|
||||
@@ -273,7 +279,11 @@ class MomoCrawler:
|
||||
logger.debug(f"[MOMO] 解析商品連結失敗: {e}")
|
||||
continue
|
||||
|
||||
# 方法 2: 如果上面沒找到,嘗試從 __NEXT_DATA__ 或 JSON
|
||||
# 方法 2: 新版 Next.js app router 會把 goodsInfoList 放在 script payload 字串中
|
||||
if not products:
|
||||
products = self._parse_next_search_payload_results(html, limit)
|
||||
|
||||
# 方法 3: 如果上面沒找到,嘗試從 __NEXT_DATA__ 或 JSON
|
||||
if not products:
|
||||
# 嘗試找 Next.js 資料
|
||||
script = soup.find('script', {'id': '__NEXT_DATA__'})
|
||||
@@ -299,7 +309,7 @@ class MomoCrawler:
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 方法 3: 從 HTML 中找嵌入的 JSON
|
||||
# 方法 4: 從 HTML 中找嵌入的 JSON
|
||||
if not products:
|
||||
json_pattern = re.compile(r'"goodsCode"\s*:\s*"?(\d+)"?.*?"goodsName"\s*:\s*"([^"]+)".*?"price"\s*:\s*(\d+)', re.DOTALL)
|
||||
matches = json_pattern.findall(html)
|
||||
@@ -325,6 +335,67 @@ class MomoCrawler:
|
||||
logger.error(f"[MOMO] 解析行動版結果失敗: {e}")
|
||||
return []
|
||||
|
||||
def _parse_next_search_payload_results(self, html: str, limit: int) -> List[MomoProduct]:
|
||||
"""解析 MOMO 新版搜尋頁嵌入的 Next.js goodsInfoList payload。"""
|
||||
products: List[MomoProduct] = []
|
||||
seen_ids: set[str] = set()
|
||||
|
||||
product_pattern = re.compile(
|
||||
r'\\"goodsCode\\"\s*:\s*\\"(?P<code>\d+)\\"'
|
||||
r'.{0,800}?'
|
||||
r'\\"goodsName\\"\s*:\s*\\"(?P<name>.*?)\\"'
|
||||
r'.{0,1600}?'
|
||||
r'\\"goodsPrice\\"\s*:\s*\\"(?P<price>[^\\"]+)\\"'
|
||||
r'.{0,2400}?'
|
||||
r'\\"imgUrl\\"\s*:\s*\\"(?P<img>[^\\"]*)\\"',
|
||||
re.DOTALL,
|
||||
)
|
||||
for match in product_pattern.finditer(html):
|
||||
if len(products) >= limit:
|
||||
break
|
||||
product_id = match.group("code")
|
||||
if product_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(product_id)
|
||||
|
||||
name = self._decode_payload_text(match.group("name"))
|
||||
price = self._parse_momo_price(match.group("price"))
|
||||
if not name or price <= 0:
|
||||
continue
|
||||
image_url = self._decode_payload_text(match.group("img"))
|
||||
original_price = self._parse_original_price_nearby(html, match.start(), match.end()) or price
|
||||
discount = round((1 - price / original_price) * 100) if original_price > price else None
|
||||
|
||||
products.append(MomoProduct(
|
||||
product_id=product_id,
|
||||
name=name.strip()[:160],
|
||||
price=price,
|
||||
original_price=original_price,
|
||||
discount=discount,
|
||||
image_url=image_url,
|
||||
product_url=f'{self.BASE_URL}/goods/GoodsDetail.jsp?i_code={product_id}',
|
||||
brand='',
|
||||
crawled_at=datetime.now(),
|
||||
))
|
||||
return products
|
||||
|
||||
@staticmethod
|
||||
def _decode_payload_text(value: str) -> str:
|
||||
try:
|
||||
return json.loads(f'"{value}"')
|
||||
except Exception:
|
||||
return (value or "").replace("\\u0026", "&").replace("\\/", "/")
|
||||
|
||||
@staticmethod
|
||||
def _parse_momo_price(value: str) -> int:
|
||||
match = re.search(r"[\d,]+", value or "")
|
||||
return int(match.group(0).replace(",", "")) if match else 0
|
||||
|
||||
def _parse_original_price_nearby(self, html: str, start: int, end: int) -> int:
|
||||
snippet = html[start:min(len(html), end + 1800)]
|
||||
match = re.search(r'\\"goodsPriceOri\\"\s*:\s*\\"(?P<price>[^\\"]+)\\"', snippet)
|
||||
return self._parse_momo_price(match.group("price")) if match else 0
|
||||
|
||||
def _parse_search_results(self, html: str, limit: int) -> List[MomoProduct]:
|
||||
"""
|
||||
解析搜尋結果 HTML
|
||||
@@ -467,6 +538,161 @@ def search_momo_products(keyword: str, limit: int = 10) -> Tuple[bool, str, List
|
||||
return success, message, [p.to_dict() for p in products]
|
||||
|
||||
|
||||
def _to_float(value, default: float = 0.0) -> float:
|
||||
try:
|
||||
if value is None:
|
||||
return default
|
||||
return float(str(value).replace(",", "").replace("$", "").strip())
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _product_name_from_payload(payload: dict) -> str:
|
||||
return str(
|
||||
payload.get("name")
|
||||
or payload.get("product_name")
|
||||
or payload.get("title")
|
||||
or payload.get("商品名稱")
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
|
||||
def _product_price_from_payload(payload: dict) -> float:
|
||||
return _to_float(
|
||||
payload.get("price")
|
||||
or payload.get("pchome_price")
|
||||
or payload.get("sale_price")
|
||||
or payload.get("售價")
|
||||
)
|
||||
|
||||
|
||||
def _dedupe_terms(terms: list[str], max_terms: int) -> list[str]:
|
||||
result: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for term in terms:
|
||||
normalized = re.sub(r"\s+", " ", str(term or "").strip())
|
||||
if len(normalized) < 2:
|
||||
continue
|
||||
key = normalized.lower()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
result.append(normalized)
|
||||
if len(result) >= max_terms:
|
||||
break
|
||||
return result
|
||||
|
||||
|
||||
def build_targeted_momo_search_terms(pchome_name: str, max_terms: int = MOMO_TARGETED_SEARCH_MAX_TERMS) -> list[str]:
|
||||
"""用 PChome 商品名稱產生 MOMO 精準搜尋詞,保留品名、容量與組合線索。"""
|
||||
if not pchome_name:
|
||||
return []
|
||||
try:
|
||||
from services.marketplace_product_matcher import build_search_terms
|
||||
|
||||
terms = build_search_terms(pchome_name, max_terms=max_terms)
|
||||
except Exception:
|
||||
logger.warning("[MOMO] 產生精準搜尋詞失敗,改用原商品名", exc_info=True)
|
||||
terms = []
|
||||
terms.append(pchome_name)
|
||||
return _dedupe_terms(terms, max_terms=max_terms)
|
||||
|
||||
|
||||
def search_momo_products_for_pchome_products(
|
||||
pchome_products: list[dict],
|
||||
*,
|
||||
limit_per_product: int = MOMO_TARGETED_SEARCH_LIMIT_PER_TERM,
|
||||
max_products: int = MOMO_TARGETED_SEARCH_MAX_PRODUCTS,
|
||||
max_terms_per_product: int = MOMO_TARGETED_SEARCH_MAX_TERMS,
|
||||
min_score: float = MOMO_TARGETED_SEARCH_MIN_SCORE,
|
||||
crawler: MomoCrawler | None = None,
|
||||
) -> Tuple[bool, str, List[dict]]:
|
||||
"""以 PChome 商品逐筆反查 MOMO 候選,補足單品與組合的精準比價來源。"""
|
||||
if not pchome_products:
|
||||
return False, "沒有 PChome 商品可用來搜尋 MOMO", []
|
||||
|
||||
try:
|
||||
from services.marketplace_product_matcher import score_marketplace_match
|
||||
except Exception as exc:
|
||||
logger.error("[MOMO] 無法載入商品比對工具: %s", exc, exc_info=True)
|
||||
return False, "商品比對工具暫時不可用", []
|
||||
|
||||
crawler = crawler or get_crawler()
|
||||
candidates_by_id: dict[str, dict] = {}
|
||||
searched_products = 0
|
||||
searched_terms: list[str] = []
|
||||
|
||||
for target in pchome_products[:max_products]:
|
||||
pchome_name = _product_name_from_payload(target)
|
||||
if not pchome_name:
|
||||
continue
|
||||
searched_products += 1
|
||||
pchome_price = _product_price_from_payload(target)
|
||||
pchome_id = str(target.get("product_id") or target.get("id") or target.get("sku") or "").strip()
|
||||
terms = build_targeted_momo_search_terms(pchome_name, max_terms=max_terms_per_product)
|
||||
|
||||
for term in terms:
|
||||
searched_terms.append(term)
|
||||
success, _, products = crawler.search_products(term, limit=limit_per_product)
|
||||
if not success or not products:
|
||||
continue
|
||||
|
||||
for product in products:
|
||||
row = product.to_dict() if hasattr(product, "to_dict") else dict(product)
|
||||
momo_name = _product_name_from_payload(row)
|
||||
if not momo_name:
|
||||
continue
|
||||
diagnostics = score_marketplace_match(
|
||||
pchome_name,
|
||||
momo_name,
|
||||
momo_price=_to_float(row.get("price")),
|
||||
competitor_price=pchome_price,
|
||||
)
|
||||
score = float(getattr(diagnostics, "score", 0.0) or 0.0)
|
||||
if score < min_score:
|
||||
continue
|
||||
hard_veto = bool(getattr(diagnostics, "hard_veto", False))
|
||||
comparison_mode = getattr(diagnostics, "comparison_mode", "exact_identity")
|
||||
can_auto_compare = not hard_veto and comparison_mode == "exact_identity"
|
||||
|
||||
product_id = str(row.get("product_id") or row.get("goodsCode") or row.get("id") or "").strip()
|
||||
if not product_id:
|
||||
product_id = f"momo_candidate_{len(candidates_by_id)}"
|
||||
existing = candidates_by_id.get(product_id)
|
||||
if existing and float(existing.get("target_match_score") or 0.0) >= score:
|
||||
continue
|
||||
|
||||
row.update({
|
||||
"product_id": product_id,
|
||||
"target_pchome_product_id": pchome_id,
|
||||
"target_pchome_name": pchome_name,
|
||||
"target_match_score": round(score, 3),
|
||||
"target_search_term": term,
|
||||
"target_match_reasons": list(getattr(diagnostics, "reasons", ()) or ()),
|
||||
"target_comparison_mode": comparison_mode,
|
||||
"target_hard_veto": hard_veto,
|
||||
"can_auto_compare": can_auto_compare,
|
||||
"target_review_status": "可直接比價" if can_auto_compare else "需人工確認",
|
||||
"source_strategy": "pchome_targeted_momo_search",
|
||||
})
|
||||
candidates_by_id[product_id] = row
|
||||
|
||||
candidates = sorted(
|
||||
candidates_by_id.values(),
|
||||
key=lambda item: float(item.get("target_match_score") or 0.0),
|
||||
reverse=True,
|
||||
)
|
||||
if not candidates:
|
||||
return False, f"已用 {searched_products} 筆 PChome 商品搜尋 MOMO,但沒有找到可用候選", []
|
||||
auto_count = sum(1 for item in candidates if item.get("can_auto_compare"))
|
||||
review_count = len(candidates) - auto_count
|
||||
return (
|
||||
True,
|
||||
f"已用 {searched_products} 筆 PChome 商品搜尋 MOMO,找到 {len(candidates)} 筆候選(可直接比價 {auto_count} 筆、需人工確認 {review_count} 筆)",
|
||||
candidates,
|
||||
)
|
||||
|
||||
|
||||
def get_momo_bestsellers(category: str, limit: int = 5) -> Tuple[bool, str, List[dict]]:
|
||||
"""
|
||||
取得 MOMO 分類熱銷商品
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "ewoooc_base.html" %}
|
||||
|
||||
{% block title %}比價系統 - EwoooC{% endblock %}
|
||||
{% block title %}PChome 商品比價決策台 - EwoooC{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
@@ -29,6 +29,136 @@
|
||||
color: var(--momo-text-secondary) !important;
|
||||
}
|
||||
|
||||
.price-command-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(280px, 0.9fr);
|
||||
gap: var(--momo-space-4);
|
||||
margin-bottom: var(--momo-space-4);
|
||||
}
|
||||
|
||||
.price-next-action,
|
||||
.price-readiness-panel,
|
||||
.price-result-panel {
|
||||
background: var(--momo-bg-surface);
|
||||
border: 1px solid var(--momo-border-light);
|
||||
border-radius: var(--momo-radius-md);
|
||||
}
|
||||
|
||||
.price-next-action {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background: rgba(242, 178, 90, 0.16);
|
||||
border-color: rgba(172, 92, 58, 0.2);
|
||||
}
|
||||
|
||||
.price-next-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 999px;
|
||||
background: rgba(172, 92, 58, 0.14);
|
||||
color: var(--momo-warm-rust);
|
||||
}
|
||||
|
||||
.price-next-title {
|
||||
display: block;
|
||||
color: var(--momo-text-primary);
|
||||
font-size: 1rem;
|
||||
font-weight: 900;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.price-next-reason {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
color: var(--momo-text-secondary);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.price-readiness-panel,
|
||||
.price-result-panel {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.price-panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
color: var(--momo-text-secondary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.price-readiness-row,
|
||||
.price-risk-row {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.price-readiness-row:last-child,
|
||||
.price-risk-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.price-readiness-meta,
|
||||
.price-risk-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--momo-text-secondary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.price-readiness-track,
|
||||
.price-risk-track {
|
||||
overflow: hidden;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(42, 37, 32, 0.08);
|
||||
}
|
||||
|
||||
.price-readiness-bar,
|
||||
.price-risk-bar {
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--momo-warm-caramel);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.price-readiness-bar.is-pchome,
|
||||
.price-risk-bar.is-good {
|
||||
background: #2e7d5b;
|
||||
}
|
||||
|
||||
.price-readiness-bar.is-momo,
|
||||
.price-risk-bar.is-watch {
|
||||
background: #d19a2a;
|
||||
}
|
||||
|
||||
.price-risk-bar.is-urgent {
|
||||
background: #c8513a;
|
||||
}
|
||||
|
||||
.price-operation-card {
|
||||
height: 100%;
|
||||
background: var(--momo-bg-surface);
|
||||
border: 1px solid var(--momo-border-light);
|
||||
border-radius: var(--momo-radius-md);
|
||||
}
|
||||
|
||||
.price-step-head {
|
||||
background: var(--momo-bg-paper);
|
||||
border-bottom: 1px solid var(--momo-border-light);
|
||||
@@ -113,6 +243,37 @@
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.price-action-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 24px;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--momo-radius-sm);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 900;
|
||||
line-height: 1.2;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.price-action-pill.is-urgent {
|
||||
background: var(--momo-danger-bg);
|
||||
color: var(--momo-danger-text);
|
||||
border: 1px solid var(--momo-danger-border);
|
||||
}
|
||||
|
||||
.price-action-pill.is-good {
|
||||
background: var(--momo-success-bg);
|
||||
color: var(--momo-success-text);
|
||||
border: 1px solid var(--momo-success-border);
|
||||
}
|
||||
|
||||
.price-action-pill.is-watch {
|
||||
background: var(--momo-info-bg);
|
||||
color: var(--momo-info-text);
|
||||
border: 1px solid var(--momo-info-border);
|
||||
}
|
||||
|
||||
.price-toast {
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
@@ -148,6 +309,15 @@
|
||||
.price-tool-head {
|
||||
padding: var(--momo-space-4);
|
||||
}
|
||||
|
||||
.price-command-grid,
|
||||
.price-next-action {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.price-next-action .btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -155,22 +325,49 @@
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4 price-tool-page">
|
||||
<header class="price-tool-head mb-4">
|
||||
<h2><i class="fas fa-balance-scale me-2"></i>PChome vs MOMO 比價</h2>
|
||||
<p class="text-muted">比較 PChome 24h 和 MOMO 美妝商品價格</p>
|
||||
<h2><i class="fas fa-balance-scale me-2"></i>PChome 商品比價決策台</h2>
|
||||
<p class="text-muted mb-0">先確認兩邊資料是否齊,再找出 PChome 價格偏高、可主推或需要補資料的商品。</p>
|
||||
</header>
|
||||
|
||||
<section class="price-command-grid" aria-label="比價操作總覽">
|
||||
<div class="price-next-action">
|
||||
<span class="price-next-icon"><i class="fas fa-location-arrow"></i></span>
|
||||
<span>
|
||||
<strong class="price-next-title" id="priceNextActionTitle">今天先做:選擇要檢查的商品範圍</strong>
|
||||
<span class="price-next-reason" id="priceNextActionReason">請先選品牌或輸入關鍵字,系統才知道要抓哪一批 PChome 商品。</span>
|
||||
</span>
|
||||
<button type="button" class="btn btn-primary btn-sm" id="priceNextActionButton">
|
||||
輸入關鍵字
|
||||
</button>
|
||||
</div>
|
||||
<div class="price-readiness-panel" aria-label="資料準備狀態">
|
||||
<div class="price-panel-title">
|
||||
<span><i class="fas fa-clipboard-check me-1"></i>資料準備狀態</span>
|
||||
<span id="priceReadySummary">尚未開始</span>
|
||||
</div>
|
||||
<div class="price-readiness-row">
|
||||
<div class="price-readiness-meta"><span>PChome 商品</span><strong id="pricePchomeReadyText">0 筆</strong></div>
|
||||
<div class="price-readiness-track"><span class="price-readiness-bar is-pchome" id="pricePchomeReadyBar"></span></div>
|
||||
</div>
|
||||
<div class="price-readiness-row">
|
||||
<div class="price-readiness-meta"><span>MOMO 商品</span><strong id="priceMomoReadyText">0 筆</strong></div>
|
||||
<div class="price-readiness-track"><span class="price-readiness-bar is-momo" id="priceMomoReadyBar"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 操作區 -->
|
||||
<div class="row mb-4">
|
||||
<!-- Step 1: 選擇品牌 -->
|
||||
<!-- 選擇檢查範圍 -->
|
||||
<div class="col-lg-4 mb-3">
|
||||
<div class="card h-100">
|
||||
<div class="card price-operation-card">
|
||||
<div class="card-header price-step-head">
|
||||
<i class="fas fa-tag me-2"></i>Step 1: 選擇品牌
|
||||
<i class="fas fa-tag me-2"></i>選擇要檢查的範圍
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<select class="form-select" id="brandSelect">
|
||||
<option value="">-- 選擇品牌 --</option>
|
||||
<option value="">選擇品牌</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
@@ -181,15 +378,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: 取得 PChome 商品 -->
|
||||
<!-- 取得 PChome 商品 -->
|
||||
<div class="col-lg-4 mb-3">
|
||||
<div class="card h-100">
|
||||
<div class="card price-operation-card">
|
||||
<div class="card-header price-step-head">
|
||||
<i class="fas fa-shopping-cart me-2"></i>Step 2: PChome 商品
|
||||
<i class="fas fa-shopping-cart me-2"></i>補齊 PChome 商品
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<button class="btn btn-primary w-100 mb-2" id="fetchPchomeBtn">
|
||||
<i class="fas fa-download me-1"></i>自動爬取 PChome
|
||||
<i class="fas fa-download me-1"></i>取得 PChome 商品
|
||||
</button>
|
||||
<div class="text-center my-2"><small class="text-muted">或</small></div>
|
||||
<button class="btn btn-outline-info w-100" id="manualPchomeBtn" data-bs-toggle="modal" data-bs-target="#manualInputModal" data-source="pchome">
|
||||
@@ -202,15 +399,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: 取得 MOMO 商品 -->
|
||||
<!-- 取得 MOMO 商品 -->
|
||||
<div class="col-lg-4 mb-3">
|
||||
<div class="card h-100">
|
||||
<div class="card price-operation-card">
|
||||
<div class="card-header price-step-head">
|
||||
<i class="fas fa-store me-2"></i>Step 3: MOMO 商品
|
||||
<i class="fas fa-store me-2"></i>補齊 MOMO 商品
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<button class="btn btn-primary w-100 mb-2" id="fetchTargetedMomoBtn" disabled>
|
||||
<i class="fas fa-magnifying-glass-dollar me-1"></i>自動找 MOMO 候選
|
||||
</button>
|
||||
<div class="text-center my-2"><small class="text-muted">或</small></div>
|
||||
<button class="btn btn-primary w-100 mb-2" id="uploadMomoBtn" data-bs-toggle="modal" data-bs-target="#uploadModal">
|
||||
<i class="fas fa-file-excel me-1"></i>上傳 Excel
|
||||
<i class="fas fa-file-excel me-1"></i>匯入 MOMO 商品
|
||||
</button>
|
||||
<div class="text-center my-2"><small class="text-muted">或</small></div>
|
||||
<button class="btn btn-outline-warning w-100" id="manualMomoBtn" data-bs-toggle="modal" data-bs-target="#manualInputModal" data-source="momo">
|
||||
@@ -218,7 +419,9 @@
|
||||
</button>
|
||||
<div class="mt-3">
|
||||
<span class="price-count-badge is-muted" id="momoCount">0 筆商品</span>
|
||||
<span class="price-count-badge is-muted ms-1" id="momoReviewCount">0 筆需確認</span>
|
||||
</div>
|
||||
<div class="price-note mt-3 py-2" id="momoReviewPanel" style="display:none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -228,7 +431,7 @@
|
||||
<div class="row mb-4">
|
||||
<div class="col text-center">
|
||||
<button class="btn btn-lg btn-primary px-5" id="compareBtn" disabled>
|
||||
<i class="fas fa-balance-scale me-2"></i>開始比價
|
||||
<i class="fas fa-balance-scale me-2"></i>開始檢查價差
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -253,13 +456,40 @@
|
||||
<!-- 比價結果 -->
|
||||
<div class="row" id="resultSection" style="display: none;">
|
||||
<div class="col">
|
||||
<section class="price-result-panel mb-4" aria-label="比價結果判讀">
|
||||
<div class="price-panel-title">
|
||||
<span><i class="fas fa-chart-bar me-1"></i>比價結果判讀</span>
|
||||
<span id="priceResultSummary">等待比價結果</span>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-4">
|
||||
<div class="price-risk-row">
|
||||
<div class="price-risk-meta"><span>需檢查價格</span><strong id="priceUrgentText">0 筆</strong></div>
|
||||
<div class="price-risk-track"><span class="price-risk-bar is-urgent" id="priceUrgentBar"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="price-risk-row">
|
||||
<div class="price-risk-meta"><span>可主推曝光</span><strong id="priceGoodText">0 筆</strong></div>
|
||||
<div class="price-risk-track"><span class="price-risk-bar is-good" id="priceGoodBar"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="price-risk-row">
|
||||
<div class="price-risk-meta"><span>價格接近</span><strong id="priceWatchText">0 筆</strong></div>
|
||||
<div class="price-risk-track"><span class="price-risk-bar is-watch" id="priceWatchBar"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 統計卡片 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card price-stat-card">
|
||||
<div class="card-body text-center">
|
||||
<h4 class="mb-0" id="matchedCount">0</h4>
|
||||
<small class="text-muted">成功匹配</small>
|
||||
<small class="text-muted">找到同款</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -303,15 +533,15 @@
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-striped mb-0">
|
||||
<thead class="price-table-head">
|
||||
<tr>
|
||||
<tr>
|
||||
<th style="width: 120px;">下一步</th>
|
||||
<th style="width: 35%;">PChome 商品</th>
|
||||
<th style="width: 35%;">MOMO 商品</th>
|
||||
<th class="text-center">相似度</th>
|
||||
<th class="text-end">PChome 價</th>
|
||||
<th class="text-end">MOMO 價</th>
|
||||
<th class="text-end">價差</th>
|
||||
<th class="text-center">推薦</th>
|
||||
</tr>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="resultBody">
|
||||
</tbody>
|
||||
@@ -389,12 +619,14 @@ La Roche-Posay 安得利防曬液 50ml,920
|
||||
<script>
|
||||
let pchomeProducts = [];
|
||||
let momoProducts = [];
|
||||
let momoReviewCandidates = [];
|
||||
let comparisonResult = null;
|
||||
let currentManualSource = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadBrands();
|
||||
bindEvents();
|
||||
renderPriceCommandDashboard();
|
||||
});
|
||||
|
||||
async function loadBrands() {
|
||||
@@ -423,14 +655,19 @@ La Roche-Posay 安得利防曬液 50ml,920
|
||||
if (selected.value) {
|
||||
document.getElementById('customKeyword').value = selected.textContent;
|
||||
}
|
||||
renderPriceCommandDashboard();
|
||||
});
|
||||
|
||||
document.getElementById('customKeyword').addEventListener('input', renderPriceCommandDashboard);
|
||||
|
||||
// 爬取 PChome
|
||||
document.getElementById('fetchPchomeBtn').addEventListener('click', fetchPchome);
|
||||
|
||||
// 上傳 Excel
|
||||
document.getElementById('parseExcelBtn').addEventListener('click', parseMomoExcel);
|
||||
|
||||
document.getElementById('fetchTargetedMomoBtn').addEventListener('click', fetchTargetedMomoCandidates);
|
||||
|
||||
// 手動輸入來源
|
||||
document.querySelectorAll('[data-bs-target="#manualInputModal"]').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
@@ -446,6 +683,8 @@ La Roche-Posay 安得利防曬液 50ml,920
|
||||
|
||||
// 匯出
|
||||
document.getElementById('exportResultBtn').addEventListener('click', exportResult);
|
||||
|
||||
document.getElementById('priceNextActionButton').addEventListener('click', runPriceNextAction);
|
||||
}
|
||||
|
||||
function getKeyword() {
|
||||
@@ -453,6 +692,7 @@ La Roche-Posay 安得利防曬液 50ml,920
|
||||
if (custom) return custom;
|
||||
|
||||
const select = document.getElementById('brandSelect');
|
||||
if (!select.value) return '';
|
||||
return select.options[select.selectedIndex]?.textContent || '';
|
||||
}
|
||||
|
||||
@@ -463,7 +703,7 @@ La Roche-Posay 安得利防曬液 50ml,920
|
||||
return;
|
||||
}
|
||||
|
||||
showProgress('爬取 PChome...', `搜尋: ${keyword}`);
|
||||
showProgress('取得 PChome 商品中...', `搜尋:${keyword}`);
|
||||
|
||||
try {
|
||||
const response = await fetchWithCSRF('/api/price_comparison/fetch_pchome', {
|
||||
@@ -477,19 +717,90 @@ La Roche-Posay 安得利防曬液 50ml,920
|
||||
|
||||
if (data.success) {
|
||||
pchomeProducts = data.data.products;
|
||||
resetComparisonResult();
|
||||
document.getElementById('pchomeCount').textContent = `${pchomeProducts.length} 筆商品`;
|
||||
document.getElementById('pchomeCount').className = 'price-count-badge is-pchome';
|
||||
updateCompareButton();
|
||||
updateTargetedMomoButton();
|
||||
renderPriceCommandDashboard();
|
||||
showToast(`成功取得 ${pchomeProducts.length} 筆 PChome 商品`, 'success');
|
||||
} else {
|
||||
showToast(data.message, 'danger');
|
||||
}
|
||||
} catch (error) {
|
||||
hideProgress();
|
||||
showToast('爬取失敗: ' + error.message, 'danger');
|
||||
showToast('取得失敗:' + error.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTargetedMomoCandidates() {
|
||||
if (!pchomeProducts.length) {
|
||||
showToast('請先取得 PChome 商品,再搜尋 MOMO 候選', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('fetchTargetedMomoBtn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>搜尋中...';
|
||||
showProgress('搜尋 MOMO 候選中...', '正在用 PChome 商品名稱找單品與組合候選');
|
||||
|
||||
try {
|
||||
const response = await fetchWithCSRF('/api/price_comparison/fetch_momo_for_pchome', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pchome_products: pchomeProducts })
|
||||
});
|
||||
const data = await response.json();
|
||||
hideProgress();
|
||||
|
||||
const payload = data.data || {};
|
||||
momoProducts = Array.isArray(payload.products) ? payload.products : [];
|
||||
momoReviewCandidates = Array.isArray(payload.review_candidates) ? payload.review_candidates : [];
|
||||
resetComparisonResult();
|
||||
document.getElementById('momoCount').textContent = `${momoProducts.length} 筆商品`;
|
||||
document.getElementById('momoCount').className = momoProducts.length ? 'price-count-badge is-momo' : 'price-count-badge is-muted';
|
||||
renderMomoReviewPanel();
|
||||
updateCompareButton();
|
||||
renderPriceCommandDashboard();
|
||||
|
||||
if (data.success) {
|
||||
showToast(`MOMO 候選完成:可直接比價 ${momoProducts.length} 筆、需確認 ${momoReviewCandidates.length} 筆`, 'success');
|
||||
} else if (momoReviewCandidates.length) {
|
||||
showToast(`找到 ${momoReviewCandidates.length} 筆需人工確認候選,暫不進自動比價`, 'warning');
|
||||
} else {
|
||||
showToast(data.message || '目前沒有找到 MOMO 候選', 'warning');
|
||||
}
|
||||
} catch (error) {
|
||||
hideProgress();
|
||||
showToast('搜尋 MOMO 候選失敗:' + error.message, 'danger');
|
||||
} finally {
|
||||
updateTargetedMomoButton();
|
||||
btn.innerHTML = '<i class="fas fa-magnifying-glass-dollar me-1"></i>自動找 MOMO 候選';
|
||||
}
|
||||
}
|
||||
|
||||
function renderMomoReviewPanel() {
|
||||
const countBadge = document.getElementById('momoReviewCount');
|
||||
const panel = document.getElementById('momoReviewPanel');
|
||||
countBadge.textContent = `${momoReviewCandidates.length} 筆需確認`;
|
||||
countBadge.className = momoReviewCandidates.length
|
||||
? 'price-count-badge is-momo ms-1'
|
||||
: 'price-count-badge is-muted ms-1';
|
||||
|
||||
if (!momoReviewCandidates.length) {
|
||||
panel.style.display = 'none';
|
||||
panel.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const sampleNames = momoReviewCandidates
|
||||
.slice(0, 3)
|
||||
.map(item => item.name || item.title || '未命名商品')
|
||||
.join('、');
|
||||
panel.style.display = 'block';
|
||||
panel.textContent = `找到 ${momoReviewCandidates.length} 筆需人工確認候選,可能是單品、組合或單位價差異:${sampleNames}`;
|
||||
}
|
||||
|
||||
async function parseMomoExcel() {
|
||||
const fileInput = document.getElementById('momoExcelFile');
|
||||
if (!fileInput.files.length) {
|
||||
@@ -512,9 +823,13 @@ La Roche-Posay 安得利防曬液 50ml,920
|
||||
|
||||
if (data.success) {
|
||||
momoProducts = data.data.products;
|
||||
momoReviewCandidates = [];
|
||||
resetComparisonResult();
|
||||
document.getElementById('momoCount').textContent = `${momoProducts.length} 筆商品`;
|
||||
document.getElementById('momoCount').className = 'price-count-badge is-momo';
|
||||
renderMomoReviewPanel();
|
||||
updateCompareButton();
|
||||
renderPriceCommandDashboard();
|
||||
bootstrap.Modal.getInstance(document.getElementById('uploadModal')).hide();
|
||||
showToast(`成功解析 ${momoProducts.length} 筆 MOMO 商品`, 'success');
|
||||
} else {
|
||||
@@ -545,14 +860,14 @@ La Roche-Posay 安得利防曬液 50ml,920
|
||||
parts = line.split('\t');
|
||||
}
|
||||
if (parts.length < 2) {
|
||||
showToast(`第 ${i + 1} 行格式錯誤: 請使用「商品名稱,價格」格式<br><small>例如: 理膚寶水 B5修復霜,680</small><br><small>您輸入的: ${escapeHtml(line.substring(0, 50))}...</small>`, 'warning');
|
||||
showToast(`第 ${i + 1} 行格式錯誤,請使用「商品名稱,價格」,例如:理膚寶水 B5修復霜,680。`, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// 驗證價格是否為數字
|
||||
const price = parseInt(parts[1].trim());
|
||||
if (isNaN(price) || price <= 0) {
|
||||
showToast(`第 ${i + 1} 行價格格式錯誤: 「${escapeHtml(parts[1].trim())}」不是有效的價格<br><small>請輸入數字,例如: 680</small>`, 'warning');
|
||||
showToast(`第 ${i + 1} 行價格格式錯誤,請輸入數字,例如:680。`, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -571,15 +886,21 @@ La Roche-Posay 安得利防曬液 50ml,920
|
||||
|
||||
if (currentManualSource === 'pchome') {
|
||||
pchomeProducts = products;
|
||||
resetComparisonResult();
|
||||
document.getElementById('pchomeCount').textContent = `${products.length} 筆商品`;
|
||||
document.getElementById('pchomeCount').className = 'price-count-badge is-pchome';
|
||||
updateTargetedMomoButton();
|
||||
} else {
|
||||
momoProducts = products;
|
||||
momoReviewCandidates = [];
|
||||
resetComparisonResult();
|
||||
document.getElementById('momoCount').textContent = `${products.length} 筆商品`;
|
||||
document.getElementById('momoCount').className = 'price-count-badge is-momo';
|
||||
renderMomoReviewPanel();
|
||||
}
|
||||
|
||||
updateCompareButton();
|
||||
renderPriceCommandDashboard();
|
||||
bootstrap.Modal.getInstance(document.getElementById('manualInputModal')).hide();
|
||||
document.getElementById('manualInput').value = '';
|
||||
showToast(`成功新增 ${products.length} 筆商品`, 'success');
|
||||
@@ -590,8 +911,129 @@ La Roche-Posay 安得利防曬液 50ml,920
|
||||
btn.disabled = !(pchomeProducts.length > 0 && momoProducts.length > 0);
|
||||
}
|
||||
|
||||
function updateTargetedMomoButton() {
|
||||
const btn = document.getElementById('fetchTargetedMomoBtn');
|
||||
if (btn) btn.disabled = pchomeProducts.length === 0;
|
||||
}
|
||||
|
||||
function resetComparisonResult() {
|
||||
comparisonResult = null;
|
||||
document.getElementById('resultSection').style.display = 'none';
|
||||
setText('priceResultSummary', '等待比價結果');
|
||||
}
|
||||
|
||||
function renderPriceCommandDashboard() {
|
||||
const keyword = getKeyword();
|
||||
const pchomeCount = pchomeProducts.length;
|
||||
const momoCount = momoProducts.length;
|
||||
const reviewCount = momoReviewCandidates.length;
|
||||
const hasResult = Boolean(comparisonResult && Array.isArray(comparisonResult.matches));
|
||||
const matchedCount = hasResult ? comparisonResult.matches.length : 0;
|
||||
const stats = comparisonResult?.stats || {};
|
||||
const urgentCount = Number(stats.momo_cheaper_count || 0);
|
||||
const goodCount = Number(stats.pchome_cheaper_count || 0);
|
||||
const watchCount = Math.max(0, matchedCount - urgentCount - goodCount);
|
||||
|
||||
setText('pricePchomeReadyText', `${pchomeCount} 筆`);
|
||||
setText('priceMomoReadyText', reviewCount ? `${momoCount} 筆,可確認 ${reviewCount} 筆` : `${momoCount} 筆`);
|
||||
setWidth('pricePchomeReadyBar', Math.min(100, pchomeCount));
|
||||
setWidth('priceMomoReadyBar', Math.min(100, momoCount));
|
||||
|
||||
if (!keyword && !pchomeCount && !momoCount) {
|
||||
setText('priceReadySummary', '請先選範圍');
|
||||
setNextAction('今天先做:選擇要檢查的商品範圍', '請先選品牌或輸入關鍵字,系統才知道要抓哪一批 PChome 商品。', '輸入關鍵字', 'keyword');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pchomeCount) {
|
||||
setText('priceReadySummary', '缺 PChome 商品');
|
||||
const scopeText = keyword ? `已選「${keyword}」` : '已補 MOMO 商品';
|
||||
setNextAction('今天先做:取得 PChome 商品', `${scopeText},先取得 PChome 商品,才有業績主場可以比。`, keyword ? '取得 PChome 商品' : '輸入關鍵字', keyword ? 'fetch-pchome' : 'keyword');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!momoCount && reviewCount) {
|
||||
setText('priceReadySummary', 'MOMO 候選待確認');
|
||||
setNextAction('今天先做:確認 MOMO 單品/組合候選', `已找到 ${reviewCount} 筆可能候選,但需要確認單品、組合或單位價後才能比價。`, '查看候選提醒', 'focus-momo-review');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!momoCount) {
|
||||
setText('priceReadySummary', '缺 MOMO 商品');
|
||||
setNextAction('今天先做:自動找 MOMO 候選', 'PChome 商品已準備好,先用 PChome 商品名稱反查 MOMO,同時保留單品與組合候選。', '自動找 MOMO 候選', 'fetch-momo');
|
||||
return;
|
||||
}
|
||||
|
||||
setText('priceReadySummary', '可以比價');
|
||||
if (!hasResult) {
|
||||
setNextAction('今天先做:開始檢查價差', `兩邊資料已齊:PChome ${pchomeCount} 筆、MOMO ${momoCount} 筆。`, '開始檢查價差', 'compare');
|
||||
return;
|
||||
}
|
||||
|
||||
if (urgentCount > 0) {
|
||||
setNextAction('今天先做:處理 PChome 價格偏高商品', `已找到 ${urgentCount} 筆 MOMO 較便宜商品,先檢查售價、活動組合或曝光策略。`, '查看需檢查價格', 'focus-results');
|
||||
return;
|
||||
}
|
||||
|
||||
if (goodCount > 0) {
|
||||
setNextAction('今天先做:主推 PChome 有價格優勢商品', `已找到 ${goodCount} 筆 PChome 較便宜商品,適合安排曝光、文案或活動位置。`, '查看可主推商品', 'focus-results');
|
||||
return;
|
||||
}
|
||||
|
||||
setNextAction('今天先做:檢查商品賣點與活動位置', '目前價格接近,差異不大,下一步看曝光、文案和活動組合。', '查看比價結果', 'focus-results');
|
||||
}
|
||||
|
||||
function setNextAction(title, reason, label, action) {
|
||||
setText('priceNextActionTitle', title);
|
||||
setText('priceNextActionReason', reason);
|
||||
const btn = document.getElementById('priceNextActionButton');
|
||||
btn.textContent = label;
|
||||
btn.dataset.action = action;
|
||||
}
|
||||
|
||||
function runPriceNextAction() {
|
||||
const action = document.getElementById('priceNextActionButton').dataset.action;
|
||||
if (action === 'keyword') {
|
||||
document.getElementById('customKeyword').focus();
|
||||
return;
|
||||
}
|
||||
if (action === 'fetch-pchome') {
|
||||
fetchPchome();
|
||||
return;
|
||||
}
|
||||
if (action === 'upload-momo') {
|
||||
bootstrap.Modal.getOrCreateInstance(document.getElementById('uploadModal')).show();
|
||||
return;
|
||||
}
|
||||
if (action === 'fetch-momo') {
|
||||
fetchTargetedMomoCandidates();
|
||||
return;
|
||||
}
|
||||
if (action === 'compare') {
|
||||
runComparison();
|
||||
return;
|
||||
}
|
||||
if (action === 'focus-results') {
|
||||
document.getElementById('resultSection')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
return;
|
||||
}
|
||||
if (action === 'focus-momo-review') {
|
||||
document.getElementById('momoReviewPanel')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
function setText(id, value) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = value;
|
||||
}
|
||||
|
||||
function setWidth(id, value) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.width = `${Math.max(0, Math.min(100, value))}%`;
|
||||
}
|
||||
|
||||
async function runComparison() {
|
||||
showProgress('比價中...', '分析商品相似度');
|
||||
showProgress('檢查價差中...', '正在找兩邊可確認的同款商品');
|
||||
|
||||
try {
|
||||
const response = await fetchWithCSRF('/api/price_comparison/quick_compare', {
|
||||
@@ -609,47 +1051,69 @@ La Roche-Posay 安得利防曬液 50ml,920
|
||||
if (data.success) {
|
||||
comparisonResult = data.data;
|
||||
displayResult(comparisonResult);
|
||||
showToast(`比價完成,匹配 ${comparisonResult.matched_count} 筆`, 'success');
|
||||
renderPriceCommandDashboard();
|
||||
showToast(`比價完成,找到 ${comparisonResult.matched_count} 筆同款`, 'success');
|
||||
} else {
|
||||
showToast(data.message, 'danger');
|
||||
}
|
||||
} catch (error) {
|
||||
hideProgress();
|
||||
showToast('比價失敗: ' + error.message, 'danger');
|
||||
showToast('比價失敗:' + error.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
function displayResult(result) {
|
||||
result = result || {};
|
||||
const stats = result.stats || {};
|
||||
const matches = Array.isArray(result.matches) ? result.matches : [];
|
||||
const urgentCount = Number(stats.momo_cheaper_count || 0);
|
||||
const goodCount = Number(stats.pchome_cheaper_count || 0);
|
||||
const watchCount = Math.max(0, matches.length - urgentCount - goodCount);
|
||||
|
||||
// 更新統計
|
||||
document.getElementById('matchedCount').textContent = result.matched_count;
|
||||
document.getElementById('pchomeCheaperCount').textContent = result.stats.pchome_cheaper_count;
|
||||
document.getElementById('momoCheaperCount').textContent = result.stats.momo_cheaper_count;
|
||||
document.getElementById('avgPriceDiff').textContent = `$${result.stats.avg_price_diff}`;
|
||||
document.getElementById('matchedCount').textContent = result.matched_count || matches.length;
|
||||
document.getElementById('pchomeCheaperCount').textContent = goodCount;
|
||||
document.getElementById('momoCheaperCount').textContent = urgentCount;
|
||||
document.getElementById('avgPriceDiff').textContent = formatMoney(stats.avg_price_diff || 0);
|
||||
setText('priceUrgentText', `${urgentCount} 筆`);
|
||||
setText('priceGoodText', `${goodCount} 筆`);
|
||||
setText('priceWatchText', `${watchCount} 筆`);
|
||||
setText('priceResultSummary', matches.length ? `共找到 ${matches.length} 筆同款` : '尚未找到同款');
|
||||
const denominator = Math.max(matches.length, 1);
|
||||
setWidth('priceUrgentBar', (urgentCount / denominator) * 100);
|
||||
setWidth('priceGoodBar', (goodCount / denominator) * 100);
|
||||
setWidth('priceWatchBar', (watchCount / denominator) * 100);
|
||||
|
||||
// 建立表格
|
||||
const tbody = document.getElementById('resultBody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
for (const m of result.matches) {
|
||||
for (const m of matches) {
|
||||
const row = document.createElement('tr');
|
||||
|
||||
const similarityClass = m.similarity >= 0.8 ? 'text-success' : (m.similarity >= 0.6 ? 'text-warning' : 'text-danger');
|
||||
const cheaperBadge = m.cheaper_at === 'pchome'
|
||||
? '<span class="price-result-badge is-pchome">PChome</span>'
|
||||
: (m.cheaper_at === 'momo' ? '<span class="price-result-badge is-momo">MOMO</span>' : '<span class="price-result-badge is-muted">相同</span>');
|
||||
const actionBadge = getResultActionBadge(m);
|
||||
const gapText = formatPriceGap(m);
|
||||
const pchomeName = String(m.pchome?.name || '未命名商品');
|
||||
const momoName = String(m.momo?.name || '未命名商品');
|
||||
const pchomeId = escapeHtml(String(m.pchome?.product_id || ''));
|
||||
const momoId = escapeHtml(String(m.momo?.product_id || ''));
|
||||
|
||||
// 處理 URL (可能是 url 或 product_url)
|
||||
const pchomeUrl = m.pchome.url || m.pchome.product_url || '';
|
||||
const momoUrl = m.momo.url || m.momo.product_url || '';
|
||||
const pchomeUrl = toSafeUrl(m.pchome?.url || m.pchome?.product_url || '');
|
||||
const momoUrl = toSafeUrl(m.momo?.url || m.momo?.product_url || '');
|
||||
|
||||
row.innerHTML = `
|
||||
<td>
|
||||
${actionBadge}
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
<small class="text-truncate d-block" style="max-width: 220px;" title="${escapeHtml(m.pchome.name)}">
|
||||
${escapeHtml(m.pchome.name)}
|
||||
<small class="text-truncate d-block" style="max-width: 220px;" title="${escapeHtml(pchomeName)}">
|
||||
${escapeHtml(pchomeName)}
|
||||
</small>
|
||||
<small class="text-muted">${m.pchome.product_id || ''}</small>
|
||||
<small class="text-muted">${pchomeId}</small>
|
||||
</div>
|
||||
${pchomeUrl ? `<a href="${pchomeUrl}" target="_blank" class="btn btn-sm btn-outline-info ms-1" title="前往 PChome 查看"><i class="fas fa-external-link-alt"></i></a>` : ''}
|
||||
</div>
|
||||
@@ -657,10 +1121,10 @@ La Roche-Posay 安得利防曬液 50ml,920
|
||||
<td>
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
<small class="text-truncate d-block" style="max-width: 220px;" title="${escapeHtml(m.momo.name)}">
|
||||
${escapeHtml(m.momo.name)}
|
||||
<small class="text-truncate d-block" style="max-width: 220px;" title="${escapeHtml(momoName)}">
|
||||
${escapeHtml(momoName)}
|
||||
</small>
|
||||
<small class="text-muted">${m.momo.product_id || ''}</small>
|
||||
<small class="text-muted">${momoId}</small>
|
||||
</div>
|
||||
${momoUrl ? `<a
|
||||
href="${momoUrl}"
|
||||
@@ -670,9 +1134,9 @@ La Roche-Posay 安得利防曬液 50ml,920
|
||||
data-momo-original-url="${momoUrl}"
|
||||
data-track-platform="momo"
|
||||
data-track-source="price-comparison"
|
||||
data-track-product-id="${escapeHtml((m.momo.product_id || '').toString())}"
|
||||
data-track-icode="${escapeHtml((m.momo.product_id || '').toString())}"
|
||||
data-track-product-name="${escapeHtml(m.momo.name)}">
|
||||
data-track-product-id="${momoId}"
|
||||
data-track-icode="${momoId}"
|
||||
data-track-product-name="${escapeHtml(momoName)}">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</a>` : ''}
|
||||
</div>
|
||||
@@ -681,17 +1145,13 @@ La Roche-Posay 安得利防曬液 50ml,920
|
||||
<strong>${Math.round(m.similarity * 100)}%</strong>
|
||||
</td>
|
||||
<td class="text-end ${m.cheaper_at === 'pchome' ? 'text-success fw-bold' : ''}">
|
||||
$${m.pchome.price.toLocaleString()}
|
||||
${formatMoney(m.pchome?.price)}
|
||||
</td>
|
||||
<td class="text-end ${m.cheaper_at === 'momo' ? 'text-warning fw-bold' : ''}">
|
||||
$${m.momo.price.toLocaleString()}
|
||||
${formatMoney(m.momo?.price)}
|
||||
</td>
|
||||
<td class="text-end ${m.price_diff < 0 ? 'text-success' : (m.price_diff > 0 ? 'text-danger' : '')}">
|
||||
${m.price_diff > 0 ? '+' : ''}$${m.price_diff}
|
||||
<br><small>(${m.price_diff_percent}%)</small>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
${cheaperBadge}
|
||||
${gapText}
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
@@ -700,25 +1160,50 @@ La Roche-Posay 安得利防曬液 50ml,920
|
||||
document.getElementById('resultSection').style.display = 'block';
|
||||
}
|
||||
|
||||
function getResultActionBadge(match) {
|
||||
if (match.cheaper_at === 'momo') {
|
||||
return '<span class="price-action-pill is-urgent">檢查售價</span>';
|
||||
}
|
||||
if (match.cheaper_at === 'pchome') {
|
||||
return '<span class="price-action-pill is-good">主推曝光</span>';
|
||||
}
|
||||
return '<span class="price-action-pill is-watch">觀察賣點</span>';
|
||||
}
|
||||
|
||||
function formatPriceGap(match) {
|
||||
const diff = Number(match.price_diff || 0);
|
||||
const pct = Number(match.price_diff_percent || 0);
|
||||
const label = diff > 0 ? 'PChome 貴' : (diff < 0 ? 'PChome 便宜' : '價格相同');
|
||||
const amount = diff === 0 ? '$0' : formatMoney(Math.abs(diff));
|
||||
const pctText = Number.isFinite(pct) ? `${Math.abs(pct).toFixed(1)}%` : '0.0%';
|
||||
return `<strong>${label} ${amount}</strong><br><small>${pctText}</small>`;
|
||||
}
|
||||
|
||||
function exportResult() {
|
||||
if (!comparisonResult || !comparisonResult.matches.length) {
|
||||
showToast('沒有資料可匯出', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = ['PChome商品', 'MOMO商品', '相似度%', 'PChome價', 'MOMO價', '價差', '較便宜'];
|
||||
const headers = ['下一步', 'PChome商品', 'MOMO商品', '相似度%', 'PChome價', 'MOMO價', '價差'];
|
||||
const rows = comparisonResult.matches.map(m => [
|
||||
`"${actionLabelForExport(m).replace(/"/g, '""')}"`,
|
||||
`"${m.pchome.name.replace(/"/g, '""')}"`,
|
||||
`"${m.momo.name.replace(/"/g, '""')}"`,
|
||||
Math.round(m.similarity * 100),
|
||||
m.pchome.price,
|
||||
m.momo.price,
|
||||
m.price_diff,
|
||||
m.cheaper_at
|
||||
]);
|
||||
|
||||
const csv = '\uFEFF' + [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
|
||||
downloadFile(csv, 'price_comparison.csv', 'text/csv;charset=utf-8');
|
||||
downloadFile(csv, 'pchome_momo_price_actions.csv', 'text/csv;charset=utf-8');
|
||||
}
|
||||
|
||||
function actionLabelForExport(match) {
|
||||
if (match.cheaper_at === 'momo') return '檢查 PChome 售價或活動';
|
||||
if (match.cheaper_at === 'pchome') return '安排 PChome 曝光或文案';
|
||||
return '觀察賣點與活動位置';
|
||||
}
|
||||
|
||||
function showProgress(title, detail) {
|
||||
@@ -741,12 +1226,31 @@ La Roche-Posay 安得利防曬液 50ml,920
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function formatMoney(value) {
|
||||
const number = Number(value || 0);
|
||||
return `$${Math.round(number).toLocaleString('zh-TW')}`;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function toSafeUrl(value) {
|
||||
const target = String(value || '').trim();
|
||||
if (!target) return '';
|
||||
try {
|
||||
const parsed = new URL(target, location.origin);
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
return escapeHtml(parsed.href);
|
||||
}
|
||||
} catch (error) {
|
||||
return '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function extractMomoCodeFromUrl(url) {
|
||||
const target = (url || '').trim();
|
||||
if (!target) {
|
||||
@@ -768,10 +1272,14 @@ La Roche-Posay 安得利防曬液 50ml,920
|
||||
function showToast(message, type = 'info') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `price-toast price-toast--${type} position-fixed`;
|
||||
toast.innerHTML = `
|
||||
<button type="button" class="btn-close float-end" onclick="this.parentElement.remove()"></button>
|
||||
${message}
|
||||
`;
|
||||
const closeButton = document.createElement('button');
|
||||
closeButton.type = 'button';
|
||||
closeButton.className = 'btn-close float-end';
|
||||
closeButton.addEventListener('click', () => toast.remove());
|
||||
const text = document.createElement('span');
|
||||
text.textContent = message;
|
||||
toast.appendChild(closeButton);
|
||||
toast.appendChild(text);
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 5000);
|
||||
}
|
||||
|
||||
@@ -510,6 +510,48 @@ def test_ai_intelligence_uses_v2_shell_and_real_runtime_apis():
|
||||
assert "_get_cached_icaim_dashboard_payload(allow_stale=True)" in route_source
|
||||
|
||||
|
||||
def test_price_comparison_page_is_action_oriented_and_plain_chinese():
|
||||
template = (ROOT / "templates/price_comparison.html").read_text(encoding="utf-8")
|
||||
route_source = (ROOT / "routes/price_comparison_routes.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "{% extends \"ewoooc_base.html\" %}" in template
|
||||
assert "PChome 商品比價決策台" in template
|
||||
assert "今天先做:選擇要檢查的商品範圍" in template
|
||||
assert "資料準備狀態" in template
|
||||
assert "priceNextActionButton" in template
|
||||
assert "renderPriceCommandDashboard" in template
|
||||
assert "runPriceNextAction" in template
|
||||
assert "fetchTargetedMomoBtn" in template
|
||||
assert "自動找 MOMO 候選" in template
|
||||
assert "fetchTargetedMomoCandidates" in template
|
||||
assert "renderMomoReviewPanel" in template
|
||||
assert "/api/price_comparison/fetch_momo_for_pchome" in template
|
||||
assert "MOMO 候選待確認" in template
|
||||
assert "確認 MOMO 單品/組合候選" in template
|
||||
assert "比價結果判讀" in template
|
||||
assert "需檢查價格" in template
|
||||
assert "可主推曝光" in template
|
||||
assert "價格接近" in template
|
||||
assert "檢查售價" in template
|
||||
assert "主推曝光" in template
|
||||
assert "PChome 貴" in template
|
||||
assert "PChome 便宜" in template
|
||||
assert "resetComparisonResult" in template
|
||||
assert "showToast" in template
|
||||
assert "text.textContent = message" in template
|
||||
assert "toast.innerHTML" not in template
|
||||
assert "Step 1" not in template
|
||||
assert "Step 2" not in template
|
||||
assert "Step 3" not in template
|
||||
assert "開始比價" not in template
|
||||
assert "PChome vs MOMO 比價" not in template
|
||||
assert "爬取 PChome..." not in template
|
||||
assert "匹配 ${comparisonResult.matched_count}" not in template
|
||||
assert "@price_comparison_bp.route('/price_comparison')" in route_source
|
||||
assert "@price_comparison_bp.route('/api/price_comparison/fetch_momo_for_pchome', methods=['POST'])" in route_source
|
||||
assert "render_template('price_comparison.html', active_page='price_comparison')" in route_source
|
||||
|
||||
|
||||
def test_ai_history_uses_v2_shell_and_real_history_apis():
|
||||
template = (ROOT / "templates/ai_history.html").read_text(encoding="utf-8")
|
||||
route_source = (ROOT / "routes/ai_routes.py").read_text(encoding="utf-8")
|
||||
|
||||
124
tests/test_momo_crawler_targeted_search.py
Normal file
124
tests/test_momo_crawler_targeted_search.py
Normal file
@@ -0,0 +1,124 @@
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def test_build_targeted_momo_search_terms_keeps_identity_and_spec():
|
||||
from services.momo_crawler import build_targeted_momo_search_terms
|
||||
|
||||
terms = build_targeted_momo_search_terms("【LA ROCHE-POSAY 理膚寶水】B5全面修復霜 40ml", max_terms=5)
|
||||
|
||||
assert terms[0] == "理膚寶水 全面修復霜 b5 40ml"
|
||||
assert any("40ml" in term for term in terms)
|
||||
assert any("b5" in term.lower() for term in terms)
|
||||
|
||||
|
||||
def test_momo_next_search_payload_parser_extracts_goods_info_list():
|
||||
from services.momo_crawler import MomoCrawler
|
||||
|
||||
html = (
|
||||
r'{\"goodsInfoList\":[{\"goodsCode\":\"10833188\",'
|
||||
r'\"goodsName\":\"【理膚寶水】B5+全面修復霜輕量版 40ml 年度限定組B\",'
|
||||
r'\"goodsPrice\":\"$$468\",'
|
||||
r'\"goodsPriceOri\":\"$$835\",'
|
||||
r'\"imgUrl\":\"https://img3.momoshop.com.tw/goodsimg/0010/833/188/10833188_OL.jpg\"}]}'
|
||||
)
|
||||
|
||||
products = MomoCrawler(timeout=1, delay=0)._parse_next_search_payload_results(html, limit=5)
|
||||
|
||||
assert len(products) == 1
|
||||
assert products[0].product_id == "10833188"
|
||||
assert products[0].name == "【理膚寶水】B5+全面修復霜輕量版 40ml 年度限定組B"
|
||||
assert products[0].price == 468
|
||||
assert products[0].original_price == 835
|
||||
assert products[0].discount == 44
|
||||
assert products[0].product_url.endswith("i_code=10833188")
|
||||
|
||||
|
||||
def test_search_momo_products_for_pchome_products_uses_each_pchome_product_as_target():
|
||||
from services.momo_crawler import MomoProduct, search_momo_products_for_pchome_products
|
||||
|
||||
calls = []
|
||||
|
||||
class FakeCrawler:
|
||||
def search_products(self, keyword, limit=10, sort_by="sSaleQty/dc"):
|
||||
calls.append((keyword, limit, sort_by))
|
||||
if "b5" in keyword.lower() or "全面修復霜" in keyword:
|
||||
return True, "ok", [
|
||||
MomoProduct(
|
||||
product_id="12345678",
|
||||
name="理膚寶水 B5全面修復霜 40ml",
|
||||
price=890,
|
||||
original_price=990,
|
||||
discount=10,
|
||||
image_url="",
|
||||
product_url="https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=12345678",
|
||||
brand="理膚寶水",
|
||||
crawled_at=datetime.now(),
|
||||
)
|
||||
]
|
||||
return True, "ok", []
|
||||
|
||||
success, message, products = search_momo_products_for_pchome_products(
|
||||
[
|
||||
{
|
||||
"product_id": "PCH-1",
|
||||
"name": "【LA ROCHE-POSAY 理膚寶水】B5全面修復霜 40ml",
|
||||
"price": 920,
|
||||
}
|
||||
],
|
||||
crawler=FakeCrawler(),
|
||||
max_products=5,
|
||||
max_terms_per_product=4,
|
||||
limit_per_product=3,
|
||||
min_score=0.0,
|
||||
)
|
||||
|
||||
assert success is True
|
||||
assert "找到 1 筆候選" in message
|
||||
assert calls
|
||||
assert calls[0][1] == 3
|
||||
assert products[0]["product_id"] == "12345678"
|
||||
assert products[0]["target_pchome_product_id"] == "PCH-1"
|
||||
assert products[0]["target_pchome_name"] == "【LA ROCHE-POSAY 理膚寶水】B5全面修復霜 40ml"
|
||||
assert products[0]["target_match_score"] > 0
|
||||
assert products[0]["source_strategy"] == "pchome_targeted_momo_search"
|
||||
|
||||
|
||||
def test_targeted_momo_search_keeps_unit_comparable_candidates_for_review_only():
|
||||
from services.momo_crawler import MomoProduct, search_momo_products_for_pchome_products
|
||||
|
||||
class FakeCrawler:
|
||||
def search_products(self, keyword, limit=10, sort_by="sSaleQty/dc"):
|
||||
return True, "ok", [
|
||||
MomoProduct(
|
||||
product_id="10833188",
|
||||
name="【理膚寶水】B5+全面修復霜輕量版 40ml 年度限定組B(萬用修復)",
|
||||
price=468,
|
||||
original_price=835,
|
||||
discount=44,
|
||||
image_url="",
|
||||
product_url="https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=10833188",
|
||||
brand="理膚寶水",
|
||||
crawled_at=datetime.now(),
|
||||
)
|
||||
]
|
||||
|
||||
success, message, products = search_momo_products_for_pchome_products(
|
||||
[
|
||||
{
|
||||
"product_id": "PCH-1",
|
||||
"name": "【LA ROCHE-POSAY 理膚寶水】B5全面修復霜 40ml",
|
||||
"price": 920,
|
||||
}
|
||||
],
|
||||
crawler=FakeCrawler(),
|
||||
max_terms_per_product=1,
|
||||
limit_per_product=3,
|
||||
min_score=0.45,
|
||||
)
|
||||
|
||||
assert success is True
|
||||
assert "需人工確認 1 筆" in message
|
||||
assert products[0]["target_comparison_mode"] == "unit_comparable"
|
||||
assert products[0]["target_hard_veto"] is True
|
||||
assert products[0]["can_auto_compare"] is False
|
||||
assert products[0]["target_review_status"] == "需人工確認"
|
||||
@@ -1,22 +1,27 @@
|
||||
from flask import Flask
|
||||
|
||||
|
||||
def test_compare_prices_auto_fetches_momo_when_not_provided(monkeypatch):
|
||||
def test_compare_prices_auto_fetches_targeted_momo_candidates_when_pchome_products_exist(monkeypatch):
|
||||
from routes import price_comparison_routes as routes
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_search_momo(keyword, limit=10):
|
||||
captured["momo_search"] = (keyword, limit)
|
||||
def fake_targeted_search(pchome_products, **kwargs):
|
||||
captured["targeted_momo_search"] = (pchome_products, kwargs)
|
||||
return True, "ok", [
|
||||
{
|
||||
"name": "理膚寶水 B5 修復霜",
|
||||
"price": 890,
|
||||
"product_id": "12345678",
|
||||
"product_url": "https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=12345678",
|
||||
"can_auto_compare": True,
|
||||
}
|
||||
]
|
||||
|
||||
def fake_search_momo(keyword, limit=10):
|
||||
captured["fallback_momo_search"] = (keyword, limit)
|
||||
return True, "fallback", []
|
||||
|
||||
def fake_compare(brand, pchome_products, momo_products):
|
||||
captured["compare"] = (brand, pchome_products, momo_products)
|
||||
return {
|
||||
@@ -27,6 +32,7 @@ def test_compare_prices_auto_fetches_momo_when_not_provided(monkeypatch):
|
||||
"stats": {},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(routes, "search_momo_products_for_pchome_products", fake_targeted_search)
|
||||
monkeypatch.setattr(routes, "search_momo_products", fake_search_momo)
|
||||
monkeypatch.setattr(routes, "compare_brand_prices", fake_compare)
|
||||
|
||||
@@ -46,6 +52,102 @@ def test_compare_prices_auto_fetches_momo_when_not_provided(monkeypatch):
|
||||
payload = response.get_json()
|
||||
assert payload["success"] is True
|
||||
assert payload["data"]["momo_count"] == 1
|
||||
assert captured["momo_search"] == ("理膚寶水", 100)
|
||||
assert payload["data"]["momo_targeted_search"] == {
|
||||
"message": "ok",
|
||||
"candidate_count": 1,
|
||||
"auto_compare_count": 1,
|
||||
"review_count": 0,
|
||||
}
|
||||
assert captured["targeted_momo_search"][0][0]["name"] == "理膚寶水 B5 修復霜"
|
||||
assert captured["targeted_momo_search"][1]["max_products"] == 30
|
||||
assert "fallback_momo_search" not in captured
|
||||
assert captured["compare"][0] == "理膚寶水"
|
||||
assert captured["compare"][2][0]["product_id"] == "12345678"
|
||||
|
||||
|
||||
def test_compare_prices_falls_back_to_brand_momo_search_without_pchome_products(monkeypatch):
|
||||
from routes import price_comparison_routes as routes
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_search_momo(keyword, limit=10):
|
||||
captured["momo_search"] = (keyword, limit)
|
||||
return True, "ok", [
|
||||
{"name": "理膚寶水 B5 修復霜", "price": 890, "product_id": "12345678"}
|
||||
]
|
||||
|
||||
def fake_search_pchome(keyword, limit=100):
|
||||
captured["pchome_search"] = (keyword, limit)
|
||||
return True, "ok", []
|
||||
|
||||
def fake_compare(brand, pchome_products, momo_products):
|
||||
captured["compare"] = (brand, pchome_products, momo_products)
|
||||
return {
|
||||
"pchome_count": len(pchome_products),
|
||||
"momo_count": len(momo_products),
|
||||
"matched_count": 0,
|
||||
"matches": [],
|
||||
"stats": {},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(routes, "search_pchome_products", fake_search_pchome)
|
||||
monkeypatch.setattr(routes, "search_momo_products", fake_search_momo)
|
||||
monkeypatch.setattr(routes, "compare_brand_prices", fake_compare)
|
||||
|
||||
app = Flask(__name__)
|
||||
with app.test_request_context(
|
||||
"/api/price_comparison/compare",
|
||||
method="POST",
|
||||
json={"brand": "理膚寶水"},
|
||||
):
|
||||
response = routes.compare_prices.__wrapped__()
|
||||
|
||||
payload = response.get_json()
|
||||
assert payload["success"] is True
|
||||
assert payload["data"]["momo_count"] == 1
|
||||
assert captured["pchome_search"] == ("理膚寶水", 100)
|
||||
assert captured["momo_search"] == ("理膚寶水", 100)
|
||||
|
||||
|
||||
def test_fetch_momo_for_pchome_endpoint_splits_auto_and_review_candidates(monkeypatch):
|
||||
from routes import price_comparison_routes as routes
|
||||
|
||||
def fake_targeted_search(pchome_products, **kwargs):
|
||||
return True, "找到候選", [
|
||||
{
|
||||
"name": "可直接比價商品",
|
||||
"price": 890,
|
||||
"product_id": "AUTO-1",
|
||||
"can_auto_compare": True,
|
||||
},
|
||||
{
|
||||
"name": "組合需確認商品",
|
||||
"price": 468,
|
||||
"product_id": "REVIEW-1",
|
||||
"can_auto_compare": False,
|
||||
"target_review_status": "需人工確認",
|
||||
},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(routes, "search_momo_products_for_pchome_products", fake_targeted_search)
|
||||
|
||||
app = Flask(__name__)
|
||||
with app.test_request_context(
|
||||
"/api/price_comparison/fetch_momo_for_pchome",
|
||||
method="POST",
|
||||
json={
|
||||
"pchome_products": [
|
||||
{"name": "理膚寶水 B5 修復霜", "price": 920, "product_id": "PCH-1"},
|
||||
],
|
||||
},
|
||||
):
|
||||
response = routes.fetch_momo_for_pchome_products.__wrapped__()
|
||||
|
||||
payload = response.get_json()
|
||||
assert payload["success"] is True
|
||||
assert payload["message"] == "找到候選"
|
||||
assert payload["data"]["count"] == 1
|
||||
assert payload["data"]["review_count"] == 1
|
||||
assert payload["data"]["candidate_count"] == 2
|
||||
assert payload["data"]["products"][0]["product_id"] == "AUTO-1"
|
||||
assert payload["data"]["review_candidates"][0]["product_id"] == "REVIEW-1"
|
||||
|
||||
Reference in New Issue
Block a user