Files
ewoooc/routes/price_comparison_routes.py
OoO 01c73e02a2
Some checks failed
CD Pipeline / deploy (push) Failing after 34s
V10.621 sync auto candidates to growth layer
2026-06-16 11:41:34 +08:00

448 lines
15 KiB
Python

"""
PChome vs MOMO 比價 API 路由
"""
from flask import Blueprint, request, jsonify, render_template
import logging
from auth import login_required
from utils.momo_url_utils import extract_momo_i_code, is_probable_momo_icode
from services.price_comparison import (
compare_brand_prices,
BRAND_ALIASES,
BRAND_NORMALIZE_MAP
)
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__)
price_comparison_bp = Blueprint('price_comparison', __name__)
def _candidate_auto_compare_type(item: dict) -> str:
"""Normalize old and new targeted MOMO candidate flags."""
explicit_type = item.get("auto_compare_type")
if explicit_type:
return explicit_type
if item.get("can_auto_compare"):
return "total_price"
return "manual_review"
def _sync_targeted_candidates_to_external_offers(candidates: list[dict]) -> dict:
from database.manager import DatabaseManager
from services.external_market_offer_service import sync_targeted_momo_candidates_to_external_offers
db = DatabaseManager()
return sync_targeted_momo_candidates_to_external_offers(
db.engine,
candidates,
dry_run=False,
)
# ============================================
# 頁面路由
# ============================================
@price_comparison_bp.route('/price_comparison')
@login_required
def price_comparison_page():
"""比價管理頁面"""
return render_template('price_comparison.html', active_page='price_comparison')
# ============================================
# API 路由
# ============================================
@price_comparison_bp.route('/api/price_comparison/brands', methods=['GET'])
@login_required
def get_supported_brands():
"""取得支援的品牌列表"""
brands = []
for canonical, aliases in BRAND_ALIASES.items():
# 找中文名稱
chinese_name = next((a for a in aliases if any('\u4e00' <= c <= '\u9fff' for c in a)), canonical)
brands.append({
'code': canonical,
'name': chinese_name,
'aliases': aliases
})
# 按中文名稱排序
brands.sort(key=lambda x: x['name'])
return jsonify({
'success': True,
'data': {
'brands': brands,
'count': len(brands)
}
})
@price_comparison_bp.route('/api/price_comparison/compare', methods=['POST'])
@login_required
def compare_prices():
"""
執行比價
Request Body:
{
"brand": "理膚寶水",
"pchome_products": [...], // 可選,如果沒有會自動爬取
"momo_products": [...] // 可選,如果沒有會自動爬取
}
"""
try:
data = request.get_json()
brand = data.get('brand', '').strip()
if not brand:
return jsonify({'success': False, 'message': '請提供品牌名稱'}), 400
# 取得 PChome 商品
pchome_products = data.get('pchome_products')
if not pchome_products:
# 自動爬取
logger.info(f"自動搜尋 PChome: {brand}")
success, msg, pchome_products = search_pchome_products(brand, limit=100)
if not success:
logger.warning(f"PChome 搜尋失敗: {msg}")
pchome_products = []
targeted_momo_summary = None
# 取得 MOMO 商品
momo_products = data.get('momo_products')
if not momo_products:
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,
)
exact_products = [
item for item in (targeted_momo_products or [])
if _candidate_auto_compare_type(item) == "total_price"
]
unit_compare_candidates = [
item for item in (targeted_momo_products or [])
if _candidate_auto_compare_type(item) == "unit_price"
]
review_candidates = [
item for item in (targeted_momo_products or [])
if _candidate_auto_compare_type(item) not in {"total_price", "unit_price"}
]
targeted_momo_summary = {
"message": msg,
"candidate_count": len(targeted_momo_products or []),
"auto_compare_count": len(exact_products) + len(unit_compare_candidates),
"exact_compare_count": len(exact_products),
"unit_compare_count": len(unit_compare_candidates),
"review_count": len(review_candidates),
}
momo_products = exact_products
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,
'data': result
})
except Exception as e:
logger.error(f"比價失敗: {e}")
return jsonify({
'success': False,
'message': f'比價失敗: {str(e)}'
}), 500
@price_comparison_bp.route('/api/price_comparison/fetch_pchome', methods=['POST'])
@login_required
def fetch_pchome_products():
"""
爬取 PChome 商品
Request Body:
{
"keyword": "理膚寶水",
"limit": 50
}
"""
try:
data = request.get_json()
keyword = data.get('keyword', '').strip()
limit = data.get('limit', 50)
if not keyword:
return jsonify({'success': False, 'message': '請提供搜尋關鍵字'}), 400
success, msg, products = search_pchome_products(keyword, limit=limit)
return jsonify({
'success': success,
'message': msg,
'data': {
'products': products,
'count': len(products)
}
})
except Exception as e:
logger.error(f"爬取 PChome 失敗: {e}")
return jsonify({
'success': False,
'message': f'爬取失敗: {str(e)}'
}), 500
@price_comparison_bp.route('/api/price_comparison/fetch_momo_for_pchome', methods=['POST'])
@login_required
def fetch_momo_for_pchome_products():
"""用 PChome 商品清單反查 MOMO 候選;可選擇同步安全候選到外部報價層。"""
try:
data = request.get_json() or {}
pchome_products = data.get('pchome_products') or []
should_sync_external_offers = str(
data.get('sync_external_offers') or ''
).strip().lower() in {'1', 'true', 'yes'}
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,
)
exact_products = [
item for item in products
if _candidate_auto_compare_type(item) == "total_price"
]
unit_compare_candidates = [
item for item in products
if _candidate_auto_compare_type(item) == "unit_price"
]
review_candidates = [
item for item in products
if _candidate_auto_compare_type(item) not in {"total_price", "unit_price"}
]
external_offer_sync = {
"success": True,
"status": "not_requested",
"written_count": 0,
"message": "未要求同步外部價格參考。",
}
if should_sync_external_offers and (exact_products or unit_compare_candidates):
try:
external_offer_sync = _sync_targeted_candidates_to_external_offers(
[*exact_products, *unit_compare_candidates],
)
except Exception as sync_exc:
logger.warning("[PriceComparison] 外部價格參考同步失敗: %s", sync_exc, exc_info=True)
external_offer_sync = {
"success": False,
"status": "failed",
"written_count": 0,
"message": "MOMO 候選已找到,但暫時無法同步到外部價格參考。",
}
return jsonify({
'success': success,
'message': message,
'data': {
'products': exact_products,
'unit_compare_candidates': unit_compare_candidates,
'review_candidates': review_candidates,
'count': len(exact_products),
'exact_compare_count': len(exact_products),
'unit_compare_count': len(unit_compare_candidates),
'auto_compare_count': len(exact_products) + len(unit_compare_candidates),
'review_count': len(review_candidates),
'candidate_count': len(products),
'external_offer_sync': external_offer_sync,
}
})
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():
"""
解析 MOMO 商品 Excel
上傳 MOMO 匯出的商品清單 Excel
"""
try:
if 'file' not in request.files:
return jsonify({'success': False, 'message': '請上傳檔案'}), 400
file = request.files['file']
if not file.filename:
return jsonify({'success': False, 'message': '請選擇檔案'}), 400
# 讀取 Excel
import pandas as pd
df = pd.read_excel(file)
# 嘗試識別欄位
# 常見欄位名: 商品名稱, 售價, 商品編號, 連結 等
name_col = None
price_col = None
id_col = None
url_col = None
for col in df.columns:
col_lower = str(col).lower()
if any(k in col_lower for k in ['商品名', '名稱', 'name', 'product']):
name_col = col
elif any(k in col_lower for k in ['售價', '價格', 'price', '金額']):
price_col = col
elif any(k in col_lower for k in ['編號', 'id', 'code', 'sku']):
id_col = col
elif any(k in col_lower for k in ['連結', 'url', 'link']):
url_col = col
if not name_col or not price_col:
return jsonify({
'success': False,
'message': f'無法識別必要欄位 (商品名稱/售價)。找到的欄位: {list(df.columns)}'
}), 400
# 轉換為商品列表
products = []
for idx, row in df.iterrows():
try:
raw_id = ''
if id_col:
raw_id = str(row[id_col]).strip()
if raw_id in {'nan', 'None', 'NoneType', 'nan.'}:
raw_id = ''
raw_url = ''
if url_col:
raw_url = str(row[url_col]).strip()
if raw_url in {'nan', 'None', 'NoneType'}:
raw_url = ''
product_id = raw_id if is_probable_momo_icode(raw_id) else extract_momo_i_code(raw_url) or ''
if not is_probable_momo_icode(product_id):
product_id = ''
product = {
'name': str(row[name_col]),
'price': int(float(row[price_col])),
'product_id': product_id,
'url': raw_url
}
products.append(product)
except (ValueError, TypeError):
continue
return jsonify({
'success': True,
'message': f'成功解析 {len(products)} 筆商品',
'data': {
'products': products,
'count': len(products),
'columns_detected': {
'name': name_col,
'price': price_col,
'id': id_col,
'url': url_col
}
}
})
except Exception as e:
logger.error(f"解析 Excel 失敗: {e}")
return jsonify({
'success': False,
'message': f'解析失敗: {str(e)}'
}), 500
@price_comparison_bp.route('/api/price_comparison/quick_compare', methods=['POST'])
@login_required
def quick_compare():
"""
快速比價 (手動輸入)
Request Body:
{
"pchome_products": [
{"name": "商品A", "price": 100},
...
],
"momo_products": [
{"name": "商品A", "price": 95},
...
]
}
"""
try:
data = request.get_json()
pchome_products = data.get('pchome_products', [])
momo_products = data.get('momo_products', [])
if not pchome_products or not momo_products:
return jsonify({
'success': False,
'message': '請提供兩平台的商品資料'
}), 400
# 補充缺失欄位
def _normalize_product_id(products_list, source_prefix, id_field, url_field):
for i, p in enumerate(products_list):
if id_field not in p:
p[id_field] = ''
product_id = str(p.get(id_field) or '').strip()
if not is_probable_momo_icode(product_id):
product_id = extract_momo_i_code(p.get(url_field)) or ''
if not is_probable_momo_icode(product_id):
p[id_field] = f'{source_prefix}_{i}'
else:
p[id_field] = product_id
if url_field not in p or p.get(url_field) is None:
p[url_field] = ''
_normalize_product_id(pchome_products, 'pchome', 'product_id', 'product_url')
_normalize_product_id(momo_products, 'momo', 'product_id', 'url')
# 執行比價 (不限定品牌)
result = compare_brand_prices('', pchome_products, momo_products)
return jsonify({
'success': True,
'data': result
})
except Exception as e:
logger.error(f"快速比價失敗: {e}")
return jsonify({
'success': False,
'message': f'比價失敗: {str(e)}'
}), 500