Files
ewoooc/services/price_comparison.py
OoO 75de76ac12
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
fix(momo): block EC404 auto-open with end-to-end URL guard
- 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>
2026-05-02 12:00:34 +08:00

672 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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()