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>
313 lines
9.4 KiB
Python
313 lines
9.4 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.pchome_crawler import search_pchome_products
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
price_comparison_bp = Blueprint('price_comparison', __name__)
|
|
|
|
|
|
# ============================================
|
|
# 頁面路由
|
|
# ============================================
|
|
|
|
@price_comparison_bp.route('/price_comparison')
|
|
@login_required
|
|
def price_comparison_page():
|
|
"""比價管理頁面"""
|
|
return render_template('price_comparison.html')
|
|
|
|
|
|
# ============================================
|
|
# 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 = []
|
|
|
|
# 取得 MOMO 商品
|
|
momo_products = data.get('momo_products')
|
|
if not momo_products:
|
|
# TODO: 實作 MOMO 爬蟲
|
|
# 暫時使用空列表
|
|
logger.info(f"MOMO 商品需手動提供或實作爬蟲")
|
|
momo_products = []
|
|
|
|
# 執行比價
|
|
result = compare_brand_prices(brand, 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
|
|
|
|
|
|
@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/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
|