Some checks failed
CD Pipeline / deploy (push) Has been cancelled
- normalize URLs at write time (scheduler crawlers, routes) to drop javascript:/EC404/placeholder i_code (momo_/manual_/pchome_) - add global click+auxclick guard in base.html and ewoooc_base.html that intercepts blocked MOMO URLs and redirects to safe i_code URL - per-page dashboards reuse the same isLikelyMomoIcode validation - /api/track_momo_link records blocked events for diagnosis - ship sanitize_momo_urls.py to clean existing polluted DB rows Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
672 lines
22 KiB
Python
672 lines
22 KiB
Python
"""
|
||
PChome vs MOMO 美妝商品比價服務原型
|
||
|
||
功能:
|
||
1. 品牌名稱正規化 (中英文對照)
|
||
2. 商品名稱解析 (提取品牌、產品線、規格)
|
||
3. 模糊匹配演算法
|
||
4. 價格比較結果輸出
|
||
"""
|
||
|
||
import re
|
||
import logging
|
||
from typing import List, Dict, Optional, Tuple
|
||
from dataclasses import dataclass, asdict
|
||
from difflib import SequenceMatcher
|
||
from datetime import datetime
|
||
from utils.momo_url_utils import build_momo_product_url, normalize_momo_product_url
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# ============================================
|
||
# 品牌名稱對照表 (中英文/別名)
|
||
# ============================================
|
||
|
||
BRAND_ALIASES = {
|
||
# 理膚寶水
|
||
'la roche-posay': ['理膚寶水', 'la roche posay', 'laroche-posay', 'laroche posay', 'lrp'],
|
||
# 雅漾
|
||
'avene': ['雅漾', 'avène', '雅漾'],
|
||
# 薇姿
|
||
'vichy': ['薇姿', 'vichy'],
|
||
# 契爾氏
|
||
"kiehl's": ['契爾氏', 'kiehls', "kiehl's"],
|
||
# 倩碧
|
||
'clinique': ['倩碧', 'clinique'],
|
||
# 蘭蔻
|
||
'lancome': ['蘭蔻', 'lancôme', 'lancome'],
|
||
# 雅詩蘭黛
|
||
'estee lauder': ['雅詩蘭黛', 'estée lauder', 'esteelauder'],
|
||
# 資生堂
|
||
'shiseido': ['資生堂', 'shiseido'],
|
||
# SK-II
|
||
'sk-ii': ['sk-ii', 'sk2', 'skii', 'sk-2'],
|
||
# 歐萊雅
|
||
"l'oreal": ['歐萊雅', 'loreal', "l'oréal", '巴黎萊雅'],
|
||
# 妮維雅
|
||
'nivea': ['妮維雅', 'nivea'],
|
||
# 露得清
|
||
'neutrogena': ['露得清', 'neutrogena'],
|
||
# 專科
|
||
'senka': ['專科', 'senka'],
|
||
# 肌研
|
||
'hada labo': ['肌研', 'hadalabo', 'hada-labo'],
|
||
# 珂潤
|
||
'curel': ['珂潤', 'curél', 'curel'],
|
||
# 艾杜紗
|
||
'ettusais': ['艾杜紗', 'ettusais'],
|
||
# 蜜妮
|
||
'biore': ['蜜妮', 'bioré', 'biore'],
|
||
# 曼秀雷敦
|
||
'mentholatum': ['曼秀雷敦', 'mentholatum'],
|
||
# 歐蕾
|
||
'olay': ['歐蕾', 'olay'],
|
||
# 朵茉麗蔻
|
||
'domohorn wrinkle': ['朵茉麗蔻', 'domohorn'],
|
||
# 佳麗寶
|
||
'kanebo': ['佳麗寶', 'kanebo'],
|
||
# 高絲
|
||
'kose': ['高絲', 'kosé', 'kose'],
|
||
# 黛珂
|
||
'decorte': ['黛珂', 'decorté', 'cosme decorte'],
|
||
# 雪肌精
|
||
'sekkisei': ['雪肌精', 'sekkisei'],
|
||
}
|
||
|
||
# 建立反向查詢表
|
||
BRAND_NORMALIZE_MAP = {}
|
||
for canonical, aliases in BRAND_ALIASES.items():
|
||
for alias in aliases:
|
||
BRAND_NORMALIZE_MAP[alias.lower()] = canonical
|
||
BRAND_NORMALIZE_MAP[canonical.lower()] = canonical
|
||
|
||
|
||
# ============================================
|
||
# 產品類型關鍵字
|
||
# ============================================
|
||
|
||
PRODUCT_TYPES = {
|
||
'精華': ['精華', '精華液', 'serum', 'essence', 'ampoule', '安瓶'],
|
||
'化妝水': ['化妝水', '爽膚水', 'toner', 'lotion', '機能水'],
|
||
'乳液': ['乳液', 'emulsion', 'milk', 'lotion'],
|
||
'面霜': ['面霜', '乳霜', 'cream', 'moisturizer', '霜'],
|
||
'防曬': ['防曬', 'sunscreen', 'sun protection', 'spf', 'uv'],
|
||
'洗面乳': ['洗面乳', '洗顏', '潔面', 'cleanser', 'wash', 'foam'],
|
||
'面膜': ['面膜', 'mask', 'sheet mask'],
|
||
'眼霜': ['眼霜', '眼部', 'eye cream', 'eye'],
|
||
'卸妝': ['卸妝', 'makeup remover', 'cleansing'],
|
||
}
|
||
|
||
|
||
# ============================================
|
||
# 資料結構
|
||
# ============================================
|
||
|
||
@dataclass
|
||
class ParsedProduct:
|
||
"""解析後的商品資料"""
|
||
original_name: str # 原始名稱
|
||
brand: Optional[str] # 正規化品牌名
|
||
product_line: str # 產品線 (如:多容安、B5)
|
||
product_type: Optional[str] # 產品類型 (精華/乳液等)
|
||
specs: Dict[str, str] # 規格 (容量、數量等)
|
||
keywords: List[str] # 關鍵字列表
|
||
source: str # 來源 (pchome/momo)
|
||
price: int # 價格
|
||
product_id: str # 商品 ID
|
||
product_url: str # 商品 URL
|
||
|
||
|
||
@dataclass
|
||
class MatchResult:
|
||
"""匹配結果"""
|
||
pchome_product: ParsedProduct
|
||
momo_product: ParsedProduct
|
||
similarity_score: float # 相似度分數 (0-1)
|
||
price_diff: int # 價差 (PChome - MOMO)
|
||
price_diff_percent: float # 價差百分比
|
||
match_details: Dict # 匹配詳情
|
||
|
||
|
||
# ============================================
|
||
# 商品名稱解析器
|
||
# ============================================
|
||
|
||
class ProductNameParser:
|
||
"""商品名稱解析器"""
|
||
|
||
# 容量正則
|
||
VOLUME_PATTERN = re.compile(r'(\d+(?:\.\d+)?)\s*(ml|g|oz|公克|毫升|入|片|包)', re.IGNORECASE)
|
||
|
||
# 數量正則 (2入、3組等)
|
||
QUANTITY_PATTERN = re.compile(r'(\d+)\s*(入|組|瓶|支|條|盒)')
|
||
|
||
# 特殊字詞清理
|
||
NOISE_WORDS = [
|
||
'官方直營', '台灣公司貨', '正貨', '公司貨', '原廠',
|
||
'限時', '特惠', '優惠', '超值', '加贈', '買一送一',
|
||
'百貨專櫃', '週年慶', '獨家', '熱銷', '明星商品',
|
||
'新品', '升級版', '經典', '人氣', '必買', '推薦',
|
||
]
|
||
|
||
def __init__(self):
|
||
pass
|
||
|
||
def parse(self, name: str, source: str, price: int,
|
||
product_id: str, product_url: str) -> ParsedProduct:
|
||
"""
|
||
解析商品名稱
|
||
|
||
Args:
|
||
name: 商品名稱
|
||
source: 來源 (pchome/momo)
|
||
price: 價格
|
||
product_id: 商品 ID
|
||
product_url: 商品 URL
|
||
|
||
Returns:
|
||
ParsedProduct 物件
|
||
"""
|
||
# 清理名稱
|
||
cleaned_name = self._clean_name(name)
|
||
|
||
# 提取品牌
|
||
brand = self._extract_brand(cleaned_name)
|
||
|
||
# 提取產品類型
|
||
product_type = self._extract_product_type(cleaned_name)
|
||
|
||
# 提取規格
|
||
specs = self._extract_specs(cleaned_name)
|
||
|
||
# 提取產品線和關鍵字
|
||
product_line, keywords = self._extract_keywords(cleaned_name, brand)
|
||
|
||
safe_product_url = (
|
||
normalize_momo_product_url(product_url, product_id)
|
||
if source == 'momo'
|
||
else product_url
|
||
)
|
||
safe_product_url = safe_product_url or build_momo_product_url(product_id)
|
||
|
||
return ParsedProduct(
|
||
original_name=name,
|
||
brand=brand,
|
||
product_line=product_line,
|
||
product_type=product_type,
|
||
specs=specs,
|
||
keywords=keywords,
|
||
source=source,
|
||
price=price,
|
||
product_id=product_id,
|
||
product_url=safe_product_url
|
||
)
|
||
|
||
def _clean_name(self, name: str) -> str:
|
||
"""清理商品名稱"""
|
||
cleaned = name
|
||
|
||
# 移除雜訊字詞
|
||
for noise in self.NOISE_WORDS:
|
||
cleaned = cleaned.replace(noise, ' ')
|
||
|
||
# 移除多餘空白
|
||
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
|
||
|
||
# 移除括號內的贈品說明
|
||
cleaned = re.sub(r'[((][^))]*贈[^))]*[))]', '', cleaned)
|
||
|
||
return cleaned
|
||
|
||
def _extract_brand(self, name: str) -> Optional[str]:
|
||
"""提取並正規化品牌名稱"""
|
||
name_lower = name.lower()
|
||
|
||
# 嘗試匹配品牌
|
||
for alias, canonical in BRAND_NORMALIZE_MAP.items():
|
||
if alias in name_lower:
|
||
return canonical
|
||
|
||
return None
|
||
|
||
def _extract_product_type(self, name: str) -> Optional[str]:
|
||
"""提取產品類型"""
|
||
name_lower = name.lower()
|
||
|
||
for ptype, keywords in PRODUCT_TYPES.items():
|
||
for kw in keywords:
|
||
if kw.lower() in name_lower:
|
||
return ptype
|
||
|
||
return None
|
||
|
||
def _extract_specs(self, name: str) -> Dict[str, str]:
|
||
"""提取規格資訊"""
|
||
specs = {}
|
||
|
||
# 提取容量
|
||
volume_match = self.VOLUME_PATTERN.search(name)
|
||
if volume_match:
|
||
specs['volume'] = f"{volume_match.group(1)}{volume_match.group(2).lower()}"
|
||
|
||
# 提取數量
|
||
qty_match = self.QUANTITY_PATTERN.search(name)
|
||
if qty_match:
|
||
specs['quantity'] = f"{qty_match.group(1)}{qty_match.group(2)}"
|
||
|
||
return specs
|
||
|
||
def _extract_keywords(self, name: str, brand: Optional[str]) -> Tuple[str, List[str]]:
|
||
"""提取產品線和關鍵字"""
|
||
# 移除品牌名
|
||
remaining = name
|
||
if brand:
|
||
for alias in BRAND_ALIASES.get(brand, [brand]):
|
||
remaining = re.sub(re.escape(alias), '', remaining, flags=re.IGNORECASE)
|
||
|
||
# 移除規格資訊
|
||
remaining = self.VOLUME_PATTERN.sub('', remaining)
|
||
remaining = self.QUANTITY_PATTERN.sub('', remaining)
|
||
|
||
# 分詞
|
||
words = re.findall(r'[\u4e00-\u9fff]+|[a-zA-Z0-9]+', remaining)
|
||
keywords = [w for w in words if len(w) > 1]
|
||
|
||
# 產品線 (取前幾個關鍵字)
|
||
product_line = ' '.join(keywords[:3]) if keywords else ''
|
||
|
||
return product_line, keywords
|
||
|
||
|
||
# ============================================
|
||
# 商品匹配引擎
|
||
# ============================================
|
||
|
||
class ProductMatcher:
|
||
"""商品匹配引擎"""
|
||
|
||
# 權重設定
|
||
WEIGHTS = {
|
||
'brand': 0.30, # 品牌匹配
|
||
'product_type': 0.15, # 產品類型匹配
|
||
'volume': 0.20, # 容量匹配
|
||
'keywords': 0.35, # 關鍵字相似度
|
||
}
|
||
|
||
def __init__(self, min_score: float = 0.6):
|
||
"""
|
||
初始化匹配引擎
|
||
|
||
Args:
|
||
min_score: 最低匹配分數 (0-1)
|
||
"""
|
||
self.min_score = min_score
|
||
self.parser = ProductNameParser()
|
||
|
||
def match_products(self, pchome_products: List[Dict],
|
||
momo_products: List[Dict]) -> List[MatchResult]:
|
||
"""
|
||
匹配兩平台的商品
|
||
|
||
Args:
|
||
pchome_products: PChome 商品列表 (字典格式)
|
||
momo_products: MOMO 商品列表 (字典格式)
|
||
|
||
Returns:
|
||
匹配結果列表
|
||
"""
|
||
# 解析所有商品
|
||
parsed_pchome = []
|
||
for p in pchome_products:
|
||
parsed = self.parser.parse(
|
||
name=p.get('name', ''),
|
||
source='pchome',
|
||
price=p.get('price', 0),
|
||
product_id=p.get('product_id', ''),
|
||
product_url=p.get('product_url', '')
|
||
)
|
||
parsed_pchome.append(parsed)
|
||
|
||
parsed_momo = []
|
||
for p in momo_products:
|
||
parsed = self.parser.parse(
|
||
name=p.get('name', ''),
|
||
source='momo',
|
||
price=p.get('price', 0),
|
||
product_id=str(p.get('product_id', '')),
|
||
product_url=p.get('url', '')
|
||
)
|
||
parsed_momo.append(parsed)
|
||
|
||
# 執行匹配
|
||
results = []
|
||
matched_momo_ids = set()
|
||
|
||
for pc_prod in parsed_pchome:
|
||
best_match = None
|
||
best_score = 0
|
||
|
||
for momo_prod in parsed_momo:
|
||
# 跳過已匹配的
|
||
if momo_prod.product_id in matched_momo_ids:
|
||
continue
|
||
|
||
# 計算相似度
|
||
score, details = self._calculate_similarity(pc_prod, momo_prod)
|
||
|
||
if score > best_score and score >= self.min_score:
|
||
best_score = score
|
||
best_match = (momo_prod, details)
|
||
|
||
if best_match:
|
||
momo_prod, details = best_match
|
||
matched_momo_ids.add(momo_prod.product_id)
|
||
|
||
# 計算價差
|
||
price_diff = pc_prod.price - momo_prod.price
|
||
price_diff_percent = (price_diff / momo_prod.price * 100) if momo_prod.price > 0 else 0
|
||
|
||
results.append(MatchResult(
|
||
pchome_product=pc_prod,
|
||
momo_product=momo_prod,
|
||
similarity_score=best_score,
|
||
price_diff=price_diff,
|
||
price_diff_percent=round(price_diff_percent, 1),
|
||
match_details=details
|
||
))
|
||
|
||
# 依價差排序 (PChome 較便宜的排前面)
|
||
results.sort(key=lambda x: x.price_diff)
|
||
|
||
return results
|
||
|
||
def _calculate_similarity(self, prod1: ParsedProduct,
|
||
prod2: ParsedProduct) -> Tuple[float, Dict]:
|
||
"""
|
||
計算兩商品的相似度
|
||
|
||
Returns:
|
||
(相似度分數, 詳細資訊)
|
||
"""
|
||
details = {}
|
||
total_score = 0
|
||
|
||
# 1. 品牌匹配
|
||
brand_score = 1.0 if (prod1.brand and prod1.brand == prod2.brand) else 0.0
|
||
details['brand'] = {'score': brand_score, 'p1': prod1.brand, 'p2': prod2.brand}
|
||
total_score += brand_score * self.WEIGHTS['brand']
|
||
|
||
# 如果品牌不同,直接返回低分
|
||
if prod1.brand and prod2.brand and prod1.brand != prod2.brand:
|
||
return 0, details
|
||
|
||
# 2. 產品類型匹配
|
||
type_score = 1.0 if (prod1.product_type and prod1.product_type == prod2.product_type) else 0.0
|
||
if prod1.product_type is None or prod2.product_type is None:
|
||
type_score = 0.5 # 無法判斷類型時給中間分數
|
||
details['product_type'] = {'score': type_score, 'p1': prod1.product_type, 'p2': prod2.product_type}
|
||
total_score += type_score * self.WEIGHTS['product_type']
|
||
|
||
# 3. 容量匹配
|
||
vol1 = prod1.specs.get('volume', '')
|
||
vol2 = prod2.specs.get('volume', '')
|
||
volume_score = 1.0 if (vol1 and vol1 == vol2) else 0.0
|
||
if not vol1 or not vol2:
|
||
volume_score = 0.3 # 無法判斷容量時給低分
|
||
details['volume'] = {'score': volume_score, 'p1': vol1, 'p2': vol2}
|
||
total_score += volume_score * self.WEIGHTS['volume']
|
||
|
||
# 4. 關鍵字相似度
|
||
kw_score = self._keyword_similarity(prod1.keywords, prod2.keywords)
|
||
details['keywords'] = {
|
||
'score': kw_score,
|
||
'p1': prod1.keywords[:5],
|
||
'p2': prod2.keywords[:5]
|
||
}
|
||
total_score += kw_score * self.WEIGHTS['keywords']
|
||
|
||
return total_score, details
|
||
|
||
def _keyword_similarity(self, kw1: List[str], kw2: List[str]) -> float:
|
||
"""計算關鍵字相似度"""
|
||
if not kw1 or not kw2:
|
||
return 0
|
||
|
||
# 方法 1: 交集比例
|
||
set1 = set(k.lower() for k in kw1)
|
||
set2 = set(k.lower() for k in kw2)
|
||
|
||
intersection = len(set1 & set2)
|
||
union = len(set1 | set2)
|
||
jaccard = intersection / union if union > 0 else 0
|
||
|
||
# 方法 2: 字串相似度 (使用產品線)
|
||
str1 = ' '.join(kw1)
|
||
str2 = ' '.join(kw2)
|
||
seq_ratio = SequenceMatcher(None, str1.lower(), str2.lower()).ratio()
|
||
|
||
# 結合兩種方法
|
||
return (jaccard * 0.4 + seq_ratio * 0.6)
|
||
|
||
|
||
# ============================================
|
||
# 比價服務
|
||
# ============================================
|
||
|
||
class PriceComparisonService:
|
||
"""比價服務"""
|
||
|
||
def __init__(self):
|
||
self.matcher = ProductMatcher(min_score=0.5)
|
||
|
||
def compare_brand(self, brand_name: str,
|
||
pchome_products: List[Dict],
|
||
momo_products: List[Dict]) -> Dict:
|
||
"""
|
||
比較特定品牌的價格
|
||
|
||
Args:
|
||
brand_name: 品牌名稱
|
||
pchome_products: PChome 商品列表
|
||
momo_products: MOMO 商品列表
|
||
|
||
Returns:
|
||
比價結果
|
||
"""
|
||
# 正規化品牌名
|
||
normalized_brand = BRAND_NORMALIZE_MAP.get(brand_name.lower())
|
||
|
||
# 過濾該品牌的商品
|
||
def filter_brand(products: List[Dict], source: str) -> List[Dict]:
|
||
filtered = []
|
||
for p in products:
|
||
name = p.get('name', '').lower()
|
||
# 檢查是否包含品牌名
|
||
for alias in BRAND_ALIASES.get(normalized_brand, [brand_name]):
|
||
if alias.lower() in name:
|
||
filtered.append(p)
|
||
break
|
||
return filtered
|
||
|
||
pchome_filtered = filter_brand(pchome_products, 'pchome')
|
||
momo_filtered = filter_brand(momo_products, 'momo')
|
||
|
||
logger.info(f"品牌 {brand_name}: PChome {len(pchome_filtered)} 筆, MOMO {len(momo_filtered)} 筆")
|
||
|
||
# 執行匹配
|
||
matches = self.matcher.match_products(pchome_filtered, momo_filtered)
|
||
|
||
# 統計
|
||
stats = self._calculate_stats(matches)
|
||
|
||
return {
|
||
'brand': brand_name,
|
||
'normalized_brand': normalized_brand,
|
||
'pchome_count': len(pchome_filtered),
|
||
'momo_count': len(momo_filtered),
|
||
'matched_count': len(matches),
|
||
'stats': stats,
|
||
'matches': [self._match_to_dict(m) for m in matches],
|
||
'compared_at': datetime.now().isoformat()
|
||
}
|
||
|
||
def _calculate_stats(self, matches: List[MatchResult]) -> Dict:
|
||
"""計算統計資料"""
|
||
if not matches:
|
||
return {
|
||
'pchome_cheaper_count': 0,
|
||
'momo_cheaper_count': 0,
|
||
'same_price_count': 0,
|
||
'avg_price_diff': 0,
|
||
'max_savings_pchome': None,
|
||
'max_savings_momo': None,
|
||
}
|
||
|
||
pchome_cheaper = [m for m in matches if m.price_diff < 0]
|
||
momo_cheaper = [m for m in matches if m.price_diff > 0]
|
||
same_price = [m for m in matches if m.price_diff == 0]
|
||
|
||
avg_diff = sum(m.price_diff for m in matches) / len(matches)
|
||
|
||
# 找出最大優惠
|
||
max_pchome = min(matches, key=lambda m: m.price_diff) if pchome_cheaper else None
|
||
max_momo = max(matches, key=lambda m: m.price_diff) if momo_cheaper else None
|
||
|
||
return {
|
||
'pchome_cheaper_count': len(pchome_cheaper),
|
||
'momo_cheaper_count': len(momo_cheaper),
|
||
'same_price_count': len(same_price),
|
||
'avg_price_diff': round(avg_diff),
|
||
'max_savings_pchome': {
|
||
'name': max_pchome.pchome_product.original_name[:50],
|
||
'savings': abs(max_pchome.price_diff),
|
||
'percent': abs(max_pchome.price_diff_percent)
|
||
} if max_pchome else None,
|
||
'max_savings_momo': {
|
||
'name': max_momo.momo_product.original_name[:50],
|
||
'savings': max_momo.price_diff,
|
||
'percent': max_momo.price_diff_percent
|
||
} if max_momo else None,
|
||
}
|
||
|
||
def _match_to_dict(self, match: MatchResult) -> Dict:
|
||
"""將匹配結果轉為字典"""
|
||
return {
|
||
'pchome': {
|
||
'name': match.pchome_product.original_name,
|
||
'price': match.pchome_product.price,
|
||
'product_id': match.pchome_product.product_id,
|
||
'url': match.pchome_product.product_url,
|
||
'specs': match.pchome_product.specs,
|
||
},
|
||
'momo': {
|
||
'name': match.momo_product.original_name,
|
||
'price': match.momo_product.price,
|
||
'product_id': match.momo_product.product_id,
|
||
'url': match.momo_product.product_url,
|
||
'specs': match.momo_product.specs,
|
||
},
|
||
'similarity': round(match.similarity_score, 3),
|
||
'price_diff': match.price_diff,
|
||
'price_diff_percent': match.price_diff_percent,
|
||
'cheaper_at': 'pchome' if match.price_diff < 0 else ('momo' if match.price_diff > 0 else 'same'),
|
||
'match_details': match.match_details,
|
||
}
|
||
|
||
|
||
# ============================================
|
||
# 快捷函數
|
||
# ============================================
|
||
|
||
_service_instance = None
|
||
|
||
def get_comparison_service() -> PriceComparisonService:
|
||
"""取得比價服務實例"""
|
||
global _service_instance
|
||
if _service_instance is None:
|
||
_service_instance = PriceComparisonService()
|
||
return _service_instance
|
||
|
||
|
||
def compare_brand_prices(brand_name: str,
|
||
pchome_products: List[Dict],
|
||
momo_products: List[Dict]) -> Dict:
|
||
"""
|
||
比較品牌價格的快捷函數
|
||
"""
|
||
service = get_comparison_service()
|
||
return service.compare_brand(brand_name, pchome_products, momo_products)
|
||
|
||
|
||
# ============================================
|
||
# 測試
|
||
# ============================================
|
||
|
||
if __name__ == '__main__':
|
||
logging.basicConfig(level=logging.INFO)
|
||
|
||
# 模擬測試資料
|
||
pchome_products = [
|
||
{
|
||
'product_id': 'DDAB01-A900ABCD',
|
||
'name': '理膚寶水 多容安舒緩濕潤乳液 40ml',
|
||
'price': 850,
|
||
'product_url': 'https://24h.pchome.com.tw/prod/DDAB01-A900ABCD'
|
||
},
|
||
{
|
||
'product_id': 'DDAB01-A900EFGH',
|
||
'name': '理膚寶水 B5全面修復霜 100ml',
|
||
'price': 680,
|
||
'product_url': 'https://24h.pchome.com.tw/prod/DDAB01-A900EFGH'
|
||
},
|
||
{
|
||
'product_id': 'DDAB01-A900IJKL',
|
||
'name': 'La Roche-Posay 理膚寶水 安得利清爽防曬液 SPF50+ 50ml',
|
||
'price': 920,
|
||
'product_url': 'https://24h.pchome.com.tw/prod/DDAB01-A900IJKL'
|
||
},
|
||
]
|
||
|
||
momo_products = [
|
||
{
|
||
'product_id': '12345678',
|
||
'name': '【理膚寶水】多容安舒緩濕潤乳液 40ml (官方直營)',
|
||
'price': 880,
|
||
'url': 'https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=12345678'
|
||
},
|
||
{
|
||
'product_id': '12345679',
|
||
'name': '【La Roche-Posay 理膚寶水】B5全面修復霜 100ml 台灣公司貨',
|
||
'price': 650,
|
||
'url': 'https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=12345679'
|
||
},
|
||
{
|
||
'product_id': '12345680',
|
||
'name': '【理膚寶水】安得利清爽極效防曬液SPF50+ 50ml',
|
||
'price': 950,
|
||
'url': 'https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=12345680'
|
||
},
|
||
]
|
||
|
||
print("=== 比價測試 ===\n")
|
||
|
||
result = compare_brand_prices('理膚寶水', pchome_products, momo_products)
|
||
|
||
print(f"品牌: {result['brand']}")
|
||
print(f"PChome 商品數: {result['pchome_count']}")
|
||
print(f"MOMO 商品數: {result['momo_count']}")
|
||
print(f"成功匹配數: {result['matched_count']}")
|
||
print(f"\n統計:")
|
||
print(f" PChome 較便宜: {result['stats']['pchome_cheaper_count']} 筆")
|
||
print(f" MOMO 較便宜: {result['stats']['momo_cheaper_count']} 筆")
|
||
print(f" 價格相同: {result['stats']['same_price_count']} 筆")
|
||
print(f" 平均價差: {result['stats']['avg_price_diff']}")
|
||
|
||
print(f"\n匹配結果:")
|
||
for m in result['matches']:
|
||
print(f" [{m['similarity']:.2f}] {m['pchome']['name'][:30]}...")
|
||
print(f" PChome: ${m['pchome']['price']} / MOMO: ${m['momo']['price']}")
|
||
print(f" 價差: ${m['price_diff']} ({m['price_diff_percent']}%) -> {m['cheaper_at']} 較便宜")
|
||
print()
|