V10.619 MOMO 精準候選搜尋
All checks were successful
CD Pipeline / deploy (push) Successful in 1m5s

This commit is contained in:
OoO
2026-06-16 11:13:24 +08:00
parent 4d1a664678
commit 7a2520dc67
9 changed files with 1160 additions and 71 deletions

View File

@@ -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 # 用於模板顯示

View File

@@ -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

View File

@@ -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`

View File

@@ -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():

View File

@@ -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 分類熱銷商品

View File

@@ -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);
}

View File

@@ -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")

View 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"] == "需人工確認"

View File

@@ -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"