fix(momo): block EC404 auto-open with end-to-end URL guard
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
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>
This commit is contained in:
@@ -9,8 +9,10 @@ import os
|
||||
import threading
|
||||
import importlib
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import re
|
||||
from flask import Blueprint, request, jsonify
|
||||
from sqlalchemy import func, desc, text
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from auth import login_required
|
||||
from config import BASE_DIR
|
||||
@@ -18,6 +20,8 @@ from database.manager import DatabaseManager
|
||||
from database.models import Product, PriceRecord
|
||||
from database.edm_models import PromoProduct
|
||||
from services.logger_manager import SystemLogger
|
||||
from utils.momo_url_utils import build_momo_product_url, normalize_momo_product_url
|
||||
from utils.momo_url_utils import is_probable_momo_icode
|
||||
|
||||
# 時區設定
|
||||
TAIPEI_TZ = timezone(timedelta(hours=8))
|
||||
@@ -420,6 +424,9 @@ def get_price_change_details():
|
||||
db = DatabaseManager()
|
||||
session = db.get_session()
|
||||
try:
|
||||
def _safe_product_url(product):
|
||||
return normalize_momo_product_url(product.url, product.i_code) or build_momo_product_url(product.i_code)
|
||||
|
||||
# 取得今日起始時間
|
||||
now_taipei = datetime.now(TAIPEI_TZ)
|
||||
today_start = now_taipei.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None)
|
||||
@@ -467,7 +474,7 @@ def get_price_change_details():
|
||||
'product_id': product.i_code,
|
||||
'name': product.name,
|
||||
'category': product.category,
|
||||
'url': product.url,
|
||||
'url': _safe_product_url(product),
|
||||
'image_url': product.image_url or '/static/placeholder.png',
|
||||
'old_price': old_price,
|
||||
'current_price': record.price,
|
||||
@@ -484,7 +491,7 @@ def get_price_change_details():
|
||||
'product_id': product.i_code,
|
||||
'name': product.name,
|
||||
'category': product.category,
|
||||
'url': product.url,
|
||||
'url': _safe_product_url(product),
|
||||
'image_url': product.image_url or '/static/placeholder.png',
|
||||
'old_price': old_price,
|
||||
'current_price': record.price,
|
||||
@@ -509,7 +516,7 @@ def get_price_change_details():
|
||||
'product_id': product.i_code,
|
||||
'name': product.name,
|
||||
'category': product.category,
|
||||
'url': product.url,
|
||||
'url': _safe_product_url(product),
|
||||
'image_url': product.image_url or '/static/placeholder.png',
|
||||
'last_price': last_record.price,
|
||||
'update_time': product.updated_at.strftime('%Y-%m-%d %H:%M') if product.updated_at else ''
|
||||
@@ -522,3 +529,62 @@ def get_price_change_details():
|
||||
return jsonify({'products': []}), 500
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@api_bp.route('/api/track_momo_link', methods=['POST'])
|
||||
@login_required
|
||||
def track_momo_link():
|
||||
"""API: 記錄 MOMO 連結點擊與異常開啟事件,用於診斷自動開啟來源。"""
|
||||
def _is_blocked_momo_url(url: str) -> bool:
|
||||
url_l = str(url or '').lower()
|
||||
if 'ec404.html' in url_l or 'ec404' in url_l:
|
||||
return True
|
||||
|
||||
try:
|
||||
parsed = urlparse(str(url or ''))
|
||||
path = (parsed.path or '').lower()
|
||||
if 'goodsdetail' in path:
|
||||
query = parse_qs(parsed.query or '')
|
||||
i_code = (query.get('i_code') or [''])[0]
|
||||
if i_code:
|
||||
return not is_probable_momo_icode(i_code)
|
||||
if not re.search(r'/goodsdetail/[^/]+', path):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
url = str(payload.get('url') or '').strip()
|
||||
effective_url = str(payload.get('effective_url') or '').strip()
|
||||
if not url:
|
||||
return jsonify({'status': 'ignored', 'reason': 'missing_url'}), 400
|
||||
|
||||
is_blocked = _is_blocked_momo_url(url) or _is_blocked_momo_url(effective_url)
|
||||
level = "[Web] [MOMO_LINK_TRACK] "
|
||||
product_id = str(payload.get('product_id', '') or '').strip()
|
||||
i_code = str(payload.get('i_code', '') or '').strip()
|
||||
source = str(payload.get('source', '') or 'unknown').strip()
|
||||
page = str(payload.get('page', '') or '').strip()
|
||||
label = str(payload.get('label', '') or '').strip()
|
||||
platform = str(payload.get('platform', '') or 'momo').strip()
|
||||
product_name = str(payload.get('product_name', '') or '').strip()
|
||||
referer = request.headers.get('Referer', '')
|
||||
user_ip = request.remote_addr
|
||||
|
||||
if not effective_url:
|
||||
effective_url = url
|
||||
|
||||
msg = (
|
||||
f"{level}platform={platform} source={source} page={page} "
|
||||
f"i_code={i_code} product_id={product_id} label={label} "
|
||||
f"name={product_name} url={url} effective_url={effective_url} ip={user_ip} referer={referer}"
|
||||
)
|
||||
|
||||
if is_blocked:
|
||||
sys_log.warning(msg + " | status=blocked_link")
|
||||
else:
|
||||
sys_log.info(msg + " | status=tracked")
|
||||
|
||||
return jsonify({'status': 'ok'})
|
||||
|
||||
@@ -27,6 +27,7 @@ from services.cache_manager import (
|
||||
_DASHBOARD_SHARED_CACHE_FILE,
|
||||
_DASHBOARD_STALE_CACHE_FILE,
|
||||
)
|
||||
from utils.momo_url_utils import build_momo_product_url, normalize_momo_product_url
|
||||
|
||||
# 時區設定
|
||||
TAIPEI_TZ = timezone(timedelta(hours=8))
|
||||
@@ -47,9 +48,7 @@ def _build_pchome_product_url(product_id):
|
||||
|
||||
|
||||
def _build_momo_product_url(i_code):
|
||||
if not i_code:
|
||||
return None
|
||||
return f"https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code={str(i_code).strip()}"
|
||||
return build_momo_product_url(i_code)
|
||||
|
||||
|
||||
def _to_float(value):
|
||||
@@ -189,6 +188,7 @@ def _ai_pick_evidence_fields(model_footprint):
|
||||
def _dashboard_decision_row(row, tone):
|
||||
sku = str(row.get('sku') or '')
|
||||
pchome_id = row.get('competitor_product_id')
|
||||
momo_url = normalize_momo_product_url(row.get('momo_url'), sku) or _build_momo_product_url(sku)
|
||||
return {
|
||||
'sku': sku,
|
||||
'name': row.get('name') or '',
|
||||
@@ -200,7 +200,7 @@ def _dashboard_decision_row(row, tone):
|
||||
'confidence': _to_float(row.get('confidence')),
|
||||
'reason': row.get('reason') or '',
|
||||
'tone': tone,
|
||||
'momo_url': row.get('momo_url') or _build_momo_product_url(sku),
|
||||
'momo_url': momo_url,
|
||||
'pchome_id': pchome_id,
|
||||
'pchome_name': row.get('competitor_product_name') or '',
|
||||
'pchome_url': _build_pchome_product_url(pchome_id),
|
||||
@@ -246,11 +246,12 @@ def _load_competitor_decision_overview(session, latest_items=None):
|
||||
sku = str(getattr(product, 'i_code', '') or '')
|
||||
if not sku:
|
||||
continue
|
||||
safe_product_url = normalize_momo_product_url(getattr(product, 'url', None), sku)
|
||||
item_map[sku] = {
|
||||
'sku': sku,
|
||||
'name': getattr(product, 'name', '') or '',
|
||||
'category': getattr(product, 'category', '') or '',
|
||||
'momo_url': getattr(product, 'url', None) or _build_momo_product_url(sku),
|
||||
'momo_url': safe_product_url or _build_momo_product_url(sku),
|
||||
'momo_price': _to_float(getattr(record, 'price', None)) or 0,
|
||||
}
|
||||
|
||||
@@ -350,7 +351,7 @@ def _load_competitor_decision_overview(session, latest_items=None):
|
||||
'name': row['name'],
|
||||
'category': row['category'],
|
||||
'momo_price': row['momo_price'],
|
||||
'momo_url': row['momo_url'],
|
||||
'momo_url': normalize_momo_product_url(row.get('momo_url'), row.get('sku')) or _build_momo_product_url(row.get('sku')),
|
||||
}
|
||||
for row in sorted(pending_items, key=lambda row: row['momo_price'], reverse=True)[:3]
|
||||
]
|
||||
@@ -541,7 +542,7 @@ def _load_competitor_decision_overview(session, latest_items=None):
|
||||
'name': row.get('name') or '',
|
||||
'category': row.get('category') or '',
|
||||
'momo_price': _to_float(row.get('momo_price')) or 0,
|
||||
'momo_url': row.get('momo_url') or _build_momo_product_url(row.get('sku')),
|
||||
'momo_url': normalize_momo_product_url(row.get('momo_url'), row.get('sku')) or _build_momo_product_url(row.get('sku')),
|
||||
}
|
||||
for row in session.execute(pending_sql).mappings().all()
|
||||
]
|
||||
@@ -956,6 +957,8 @@ def get_consolidated_data():
|
||||
unique_items = []
|
||||
for r in latest_records:
|
||||
pid = r.product_id
|
||||
product = r.product
|
||||
safe_product_url = normalize_momo_product_url(getattr(product, 'url', None), getattr(product, 'i_code', ''))
|
||||
|
||||
price_7d = prices_7d_ago_map.get(pid)
|
||||
price_30d = prices_30d_ago_map.get(pid)
|
||||
@@ -991,6 +994,7 @@ def get_consolidated_data():
|
||||
|
||||
unique_items.append({
|
||||
'record': r,
|
||||
'safe_product_url': safe_product_url or _build_momo_product_url(getattr(product, 'i_code', '')),
|
||||
'stats': {'7d_diff': stats_7d_diff, '30d_diff': stats_30d_diff, '1d_diff': today_diff},
|
||||
'yesterday_diff': yesterday_diff,
|
||||
'today_changes': today_changes,
|
||||
@@ -1384,6 +1388,11 @@ def index():
|
||||
item['safe_created_at'] = getattr(item['record'].product, 'created_at', None)
|
||||
sku = str(item['record'].product.i_code)
|
||||
item['ai_pick'] = ai_pick_map.get(sku)
|
||||
item['safe_momo_url'] = (
|
||||
item.get('safe_product_url')
|
||||
or normalize_momo_product_url(item['record'].product.url, sku)
|
||||
or _build_momo_product_url(sku)
|
||||
)
|
||||
|
||||
# 為當前頁面項目添加顏色
|
||||
for item in paged_items:
|
||||
|
||||
@@ -16,6 +16,7 @@ from database.manager import DatabaseManager
|
||||
from database.models import Product
|
||||
from database.edm_models import PromoProduct
|
||||
from services.logger_manager import SystemLogger
|
||||
from utils.momo_url_utils import build_momo_product_url, normalize_momo_product_url
|
||||
|
||||
# 時區設定
|
||||
TAIPEI_TZ = timezone(timedelta(hours=8))
|
||||
@@ -217,6 +218,7 @@ def _build_promo_dashboard_data(session, page_type, page_name, sort_by, order):
|
||||
|
||||
# 8. 附加分類資訊到每個 item
|
||||
for item in items_in_batch:
|
||||
item.safe_product_url = normalize_momo_product_url(item.url, item.i_code) or build_momo_product_url(item.i_code)
|
||||
item.main_category = product_categories.get(item.i_code)
|
||||
if item.main_category:
|
||||
item.category_color = get_color_for_string(item.main_category)
|
||||
|
||||
@@ -20,6 +20,7 @@ from database.manager import DatabaseManager
|
||||
from database.models import Product, PriceRecord
|
||||
from services.exporter import Exporter
|
||||
from services.logger_manager import SystemLogger
|
||||
from utils.momo_url_utils import build_momo_product_url, normalize_momo_product_url
|
||||
|
||||
# 時區設定
|
||||
TAIPEI_TZ = timezone(timedelta(hours=8))
|
||||
@@ -178,8 +179,9 @@ def export_excel_ai_picks():
|
||||
export_rows = []
|
||||
for row in rows:
|
||||
sku = str(row.get('sku') or '')
|
||||
normalized_sku = str(sku or '').strip()
|
||||
pchome_id = row.get('competitor_product_id') or ''
|
||||
momo_url = row.get('momo_url') or f"https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code={sku}"
|
||||
momo_url = normalize_momo_product_url(row.get('momo_url'), normalized_sku) or build_momo_product_url(normalized_sku)
|
||||
pchome_url = f"https://24h.pchome.com.tw/prod/{str(pchome_id).strip()}" if pchome_id else ''
|
||||
footprint = row.get('model_footprint') or {}
|
||||
if isinstance(footprint, str):
|
||||
@@ -392,6 +394,7 @@ def export_price_changes():
|
||||
for product, record, old_price in products:
|
||||
change = record.price - old_price
|
||||
change_pct = (change / old_price * 100) if old_price > 0 else 0
|
||||
safe_product_url = normalize_momo_product_url(product.url, product.i_code) or build_momo_product_url(product.i_code)
|
||||
ws.append([
|
||||
product.i_code,
|
||||
product.name,
|
||||
@@ -401,7 +404,7 @@ def export_price_changes():
|
||||
change,
|
||||
f"{change_pct:.2f}%",
|
||||
record.timestamp.strftime('%Y-%m-%d %H:%M'),
|
||||
product.url
|
||||
safe_product_url
|
||||
])
|
||||
|
||||
# 調整欄寬
|
||||
|
||||
@@ -6,6 +6,7 @@ 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,
|
||||
@@ -200,11 +201,26 @@ def parse_momo_excel():
|
||||
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': str(row[id_col]) if id_col else f'momo_{idx}',
|
||||
'url': str(row[url_col]) if url_col else ''
|
||||
'product_id': product_id,
|
||||
'url': raw_url
|
||||
}
|
||||
products.append(product)
|
||||
except (ValueError, TypeError):
|
||||
@@ -263,17 +279,22 @@ def quick_compare():
|
||||
}), 400
|
||||
|
||||
# 補充缺失欄位
|
||||
for i, p in enumerate(pchome_products):
|
||||
if 'product_id' not in p:
|
||||
p['product_id'] = f'pchome_{i}'
|
||||
if 'product_url' not in p:
|
||||
p['product_url'] = ''
|
||||
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] = ''
|
||||
|
||||
for i, p in enumerate(momo_products):
|
||||
if 'product_id' not in p:
|
||||
p['product_id'] = f'momo_{i}'
|
||||
if 'url' not in p:
|
||||
p['url'] = ''
|
||||
_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)
|
||||
|
||||
42
scheduler.py
42
scheduler.py
@@ -20,6 +20,7 @@ from database.models import Product, PriceRecord
|
||||
from database.edm_models import PromoProduct
|
||||
from services.notification_manager import NotificationManager
|
||||
from services.edm_notifier import EdmNotifier # V-New: 導入新的通知模組
|
||||
from utils.momo_url_utils import normalize_momo_product_url
|
||||
|
||||
# V-Fix: 改為匯入讀取函式,而非靜態變數,以支援動態更新
|
||||
from config import load_momo_categories
|
||||
@@ -310,7 +311,7 @@ def run_momo_task():
|
||||
if not link_els: continue
|
||||
link_url = link_els[0].get_attribute("href")
|
||||
|
||||
if not link_url or "javascript" in link_url:
|
||||
if not link_url:
|
||||
continue
|
||||
|
||||
# 從 URL 提取 i_code
|
||||
@@ -331,6 +332,13 @@ def run_momo_task():
|
||||
except ValueError:
|
||||
i_code = i_code_raw.upper()
|
||||
|
||||
product_url = normalize_momo_product_url(link_url, i_code)
|
||||
if not product_url:
|
||||
logging.warning(
|
||||
f"[Crawler] [MOMO] ⚠️ 商品網址無法修正,改用 i_code 組網址 | i_code: {i_code}"
|
||||
)
|
||||
continue
|
||||
|
||||
# 提取名稱
|
||||
name_els = container.find_elements(By.CSS_SELECTOR, ".prdName, .goodsName, .productName, .title")
|
||||
if name_els:
|
||||
@@ -369,7 +377,7 @@ def run_momo_task():
|
||||
'i_code': str(i_code),
|
||||
'name': title,
|
||||
'category': cat_name,
|
||||
'url': link_url,
|
||||
'url': product_url,
|
||||
'image_url': image_url,
|
||||
'price': price_val
|
||||
})
|
||||
@@ -396,6 +404,9 @@ def run_momo_task():
|
||||
else:
|
||||
if product.category != item['category']:
|
||||
product.category = item['category']
|
||||
normalized_existing_url = normalize_momo_product_url(item['url'], item['i_code'])
|
||||
if product.url != normalized_existing_url:
|
||||
product.url = normalized_existing_url
|
||||
if item['image_url']:
|
||||
product.image_url = item['image_url']
|
||||
|
||||
@@ -724,13 +735,20 @@ def run_edm_task(lpn_code="O1K5FBOqsvN"):
|
||||
previous_price = prev_record.previous_price
|
||||
|
||||
is_changed = status_change != "NONE"
|
||||
normalized_link_url = normalize_momo_product_url(link_url, i_code)
|
||||
if not normalized_link_url:
|
||||
logging.warning(
|
||||
f"[Crawler] [EDM] ⚠️ 商品網址無法修正,改用 i_code 組網址 | i_code: {i_code}"
|
||||
)
|
||||
continue
|
||||
|
||||
new_promo = PromoProduct(
|
||||
batch_id=batch_id,
|
||||
i_code=i_code,
|
||||
name=name,
|
||||
price=price,
|
||||
discount_text=discount_text,
|
||||
url=link_url,
|
||||
url=normalized_link_url,
|
||||
previous_price=previous_price, # V9.64: 寫入舊價格
|
||||
time_slot=time_slot,
|
||||
status_change=status_change if is_changed else "ACTIVE",
|
||||
@@ -1162,8 +1180,15 @@ def run_festival_task(lpn_code="O7ylWfihYUM"):
|
||||
logging.info(f"[Crawler] [Festival] -> 狀態: 圖片更新 (UPDATE)")
|
||||
|
||||
is_changed = status_change != "NONE"
|
||||
normalized_link_url = normalize_momo_product_url(link_url, i_code)
|
||||
if not normalized_link_url:
|
||||
logging.warning(
|
||||
f"[Crawler] [Festival] ⚠️ 商品網址無法修正,改用 i_code 組網址 | i_code: {i_code}"
|
||||
)
|
||||
continue
|
||||
|
||||
new_promo = PromoProduct(
|
||||
batch_id=batch_id, i_code=i_code, name=name, price=price, url=link_url,
|
||||
batch_id=batch_id, i_code=i_code, name=name, price=price, url=normalized_link_url,
|
||||
image_url=image_url, previous_price=previous_price, time_slot=group_title,
|
||||
status_change=status_change if is_changed else "ACTIVE", crawled_at=now, activity_time_text=activity_name,
|
||||
session_time_text=group_title, page_type=PAGE_TYPE
|
||||
@@ -1497,8 +1522,15 @@ def run_promo_event_task(lpn_code, page_type, activity_name):
|
||||
logging.info(f"[Crawler] [{page_type.upper()}] -> 狀態: 圖片更新 (UPDATE)")
|
||||
|
||||
is_changed = status_change != "NONE"
|
||||
normalized_link_url = normalize_momo_product_url(link_url, i_code)
|
||||
if not normalized_link_url:
|
||||
logging.warning(
|
||||
f"[Crawler] [{page_type.upper()}] ⚠️ 商品網址無法修正,改用 i_code 組網址 | i_code: {i_code}"
|
||||
)
|
||||
continue
|
||||
|
||||
new_promo = PromoProduct(
|
||||
batch_id=batch_id, i_code=i_code, name=name, price=price, url=link_url,
|
||||
batch_id=batch_id, i_code=i_code, name=name, price=price, url=normalized_link_url,
|
||||
image_url=image_url, previous_price=previous_price, time_slot=group_title,
|
||||
status_change=status_change if is_changed else "ACTIVE", crawled_at=now, activity_time_text=activity_name,
|
||||
session_time_text=group_title, page_type=page_type
|
||||
|
||||
119
scripts/tools/sanitize_momo_urls.py
Normal file
119
scripts/tools/sanitize_momo_urls.py
Normal file
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
修正 MOMO 商品與促銷商品網址中的壞連結(如 javascript:void(0)、EC404、非商品頁)
|
||||
將可修正者改為:
|
||||
1) 以 i_code 組出正確商品網址
|
||||
2) 無法修正時清空網址 (避免連到錯頁)
|
||||
"""
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.insert(0, BASE_DIR)
|
||||
|
||||
from database.manager import DatabaseManager
|
||||
from database.models import Product
|
||||
from database.edm_models import PromoProduct
|
||||
from utils.momo_url_utils import normalize_momo_product_url
|
||||
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _sanitize_records(records, label, commit=False):
|
||||
updated = 0
|
||||
skipped = 0
|
||||
cleared = 0
|
||||
unchanged = 0
|
||||
|
||||
for record in records:
|
||||
old_url = getattr(record, "url", None)
|
||||
if not old_url:
|
||||
unchanged += 1
|
||||
continue
|
||||
|
||||
normalized = normalize_momo_product_url(old_url, getattr(record, "i_code", None))
|
||||
if normalized == old_url:
|
||||
unchanged += 1
|
||||
continue
|
||||
|
||||
if normalized is None:
|
||||
if commit:
|
||||
record.url = None
|
||||
cleared += 1
|
||||
logger.info(
|
||||
"清空 %s 不可修正 URL | id=%s | i_code=%s | old=%s",
|
||||
label,
|
||||
getattr(record, "id", "n/a"),
|
||||
getattr(record, "i_code", ""),
|
||||
old_url,
|
||||
)
|
||||
else:
|
||||
if commit:
|
||||
record.url = normalized
|
||||
updated += 1
|
||||
logger.info(
|
||||
"修正 %s URL | id=%s | i_code=%s | old=%s | new=%s",
|
||||
label,
|
||||
getattr(record, "id", "n/a"),
|
||||
getattr(record, "i_code", ""),
|
||||
old_url,
|
||||
normalized,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"%s 結果 | unchanged=%s, updated=%s, cleared=%s",
|
||||
label,
|
||||
unchanged,
|
||||
updated,
|
||||
cleared,
|
||||
)
|
||||
return {"unchanged": unchanged, "updated": updated, "cleared": cleared}
|
||||
|
||||
|
||||
def main(commit=False):
|
||||
db = DatabaseManager()
|
||||
session = db.get_session()
|
||||
try:
|
||||
product_rows = session.query(Product).all()
|
||||
promo_rows = session.query(PromoProduct).all()
|
||||
|
||||
product_result = _sanitize_records(product_rows, "products", commit=commit)
|
||||
promo_result = _sanitize_records(promo_rows, "promo_products", commit=commit)
|
||||
|
||||
if commit:
|
||||
session.commit()
|
||||
logger.info("變更已提交")
|
||||
else:
|
||||
session.rollback()
|
||||
logger.info("Dry-run 模式:未提交變更")
|
||||
|
||||
logger.info(
|
||||
"整體結果 | products: %s | promo_products: %s",
|
||||
product_result,
|
||||
promo_result,
|
||||
)
|
||||
except Exception as exc:
|
||||
if commit:
|
||||
session.rollback()
|
||||
logger.exception("清理網址失敗: %s", exc)
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--commit",
|
||||
action="store_true",
|
||||
help="實際寫回資料庫(不加此參數則為 dry-run)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
main(commit=args.commit)
|
||||
@@ -14,6 +14,7 @@ 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__)
|
||||
|
||||
@@ -182,6 +183,13 @@ class ProductNameParser:
|
||||
# 提取產品線和關鍵字
|
||||
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,
|
||||
@@ -192,7 +200,7 @@ class ProductNameParser:
|
||||
source=source,
|
||||
price=price,
|
||||
product_id=product_id,
|
||||
product_url=product_url
|
||||
product_url=safe_product_url
|
||||
)
|
||||
|
||||
def _clean_name(self, name: str) -> str:
|
||||
|
||||
@@ -157,6 +157,226 @@
|
||||
<!-- Bootstrap JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- MOMO 404 防呆攔截:避免自動打開 EC404 頁面 -->
|
||||
<script>
|
||||
(function () {
|
||||
if (window.__momoLinkGuardInstalled) {
|
||||
return;
|
||||
}
|
||||
window.__momoLinkGuardInstalled = true;
|
||||
|
||||
const MOMO_HOSTS = new Set(['www.momoshop.com.tw', 'm.momoshop.com.tw']);
|
||||
const MOMO_CODE_RE = /^[A-Za-z0-9_-]{4,}$/;
|
||||
|
||||
const toText = value => (value == null ? '' : String(value));
|
||||
|
||||
const hasMomoHost = function (url) {
|
||||
const target = toText(url).trim();
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(target, location.origin);
|
||||
return MOMO_HOSTS.has((parsed.hostname || '').toLowerCase());
|
||||
} catch (error) {
|
||||
return /(^|\/)m(?:\.momoshop\.com\.tw|\.momoshop\.com\.tw)(\/|$)|www\.momoshop\.com\.tw/i.test(target);
|
||||
}
|
||||
};
|
||||
|
||||
const isBlockedMomoUrl = function (url) {
|
||||
const lowered = toText(url).toLowerCase();
|
||||
if (lowered.includes('ec404.html') || lowered.includes('/ecm/js/err404/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(url, location.origin);
|
||||
const path = (parsed.pathname || '').toLowerCase();
|
||||
if (!path.includes('goodsdetail')) {
|
||||
return false;
|
||||
}
|
||||
const iCode = (parsed.searchParams.get('i_code') || '').trim();
|
||||
if (iCode) {
|
||||
return !isLikelyMomoIcode(iCode);
|
||||
}
|
||||
return !/\/goodsdetail\/[^/?#]+/i.test(path);
|
||||
} catch (error) {
|
||||
const hasGoodsDetail = /goodsdetail\.jsp/i.test(lowered);
|
||||
if (!hasGoodsDetail) {
|
||||
return false;
|
||||
}
|
||||
const hasIcode = /[?&]i_code=([^&#]+)/i.test(lowered);
|
||||
if (!hasIcode) {
|
||||
return true;
|
||||
}
|
||||
const match = /[?&]i_code=([^&#]+)/i.exec(lowered);
|
||||
const extracted = match ? (match[1] || '').trim() : '';
|
||||
return !isLikelyMomoIcode(extracted);
|
||||
}
|
||||
};
|
||||
|
||||
const isLikelyMomoIcode = function (value) {
|
||||
const cleaned = toText(value).trim();
|
||||
if (!cleaned) {
|
||||
return false;
|
||||
}
|
||||
const lowered = cleaned.toLowerCase();
|
||||
if (lowered === 'nan' || lowered === 'none' || lowered === 'null' || lowered === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
if (lowered.startsWith('momo_')) {
|
||||
return false;
|
||||
}
|
||||
return MOMO_CODE_RE.test(cleaned);
|
||||
};
|
||||
|
||||
const getIcodeFromUrl = function (url) {
|
||||
const target = toText(url).trim();
|
||||
if (!target) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(target, location.origin);
|
||||
const iCode = parsed.searchParams.get('i_code');
|
||||
if (iCode) {
|
||||
return iCode.trim();
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const match = /[?&]i_code=([^&#]+)/i.exec(target);
|
||||
return match ? decodeURIComponent(match[1] || '').trim() : '';
|
||||
};
|
||||
|
||||
const buildSafeMomoUrl = function (iCode) {
|
||||
const cleaned = toText(iCode).trim();
|
||||
if (!isLikelyMomoIcode(cleaned)) {
|
||||
return '';
|
||||
}
|
||||
return `https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=${encodeURIComponent(cleaned)}`;
|
||||
};
|
||||
|
||||
const resolveSafeUrl = function (link, href) {
|
||||
const original = toText(link.dataset && link.dataset.momoOriginalUrl).trim();
|
||||
const explicit = toText(link.dataset && (link.dataset.trackIcode || link.dataset.trackProductId)).trim();
|
||||
const fallbackCode = explicit || getIcodeFromUrl(original) || getIcodeFromUrl(href);
|
||||
|
||||
if (fallbackCode) {
|
||||
return buildSafeMomoUrl(fallbackCode);
|
||||
}
|
||||
return '#';
|
||||
};
|
||||
|
||||
const openMomoUrl = function (link, url) {
|
||||
if (!url || url === '#') {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = toText(link.getAttribute('target') || '_self').toLowerCase();
|
||||
if (target === '_blank') {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
if (target === '_self' || target === '' ) {
|
||||
window.location.href = url;
|
||||
return;
|
||||
}
|
||||
window.open(url, target);
|
||||
};
|
||||
|
||||
const trackMomoLink = function (payload) {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
const body = {
|
||||
status: 'tracked',
|
||||
...payload
|
||||
};
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
if (csrfToken) {
|
||||
headers['X-CSRFToken'] = csrfToken;
|
||||
}
|
||||
|
||||
fetch('/api/track_momo_link', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
keepalive: true
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
const guardMomoLink = function (event) {
|
||||
if (event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'click' && event.button && event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
if (event.type === 'auxclick' && event.button !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const link = event.target.closest ? event.target.closest('a') : null;
|
||||
if (!link) {
|
||||
return;
|
||||
}
|
||||
|
||||
const href = toText(link.getAttribute('href') || '').trim();
|
||||
if (!href || href === '#') {
|
||||
return;
|
||||
}
|
||||
|
||||
const isTrackedClass = link.classList && link.classList.contains('momo-tracked-link');
|
||||
const raw = toText(link.getAttribute('href') || '').trim();
|
||||
const original = toText(link.dataset && link.dataset.momoOriginalUrl).trim();
|
||||
if (!isTrackedClass && !hasMomoHost(raw) && !hasMomoHost(original)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blocked = isBlockedMomoUrl(href) || isBlockedMomoUrl(original);
|
||||
const payload = {
|
||||
url: original || href,
|
||||
page: location.pathname,
|
||||
source: toText(link.dataset && link.dataset.trackSource) || 'auto-guard',
|
||||
platform: toText(link.dataset && link.dataset.trackPlatform) || 'momo',
|
||||
product_id: toText(link.dataset && link.dataset.trackProductId) || '',
|
||||
i_code: toText(link.dataset && link.dataset.trackIcode) || '',
|
||||
product_name: toText(link.dataset && link.dataset.trackProductName) || '',
|
||||
label: toText(link.textContent || '').trim(),
|
||||
effective_url: href,
|
||||
blocked: blocked
|
||||
};
|
||||
|
||||
if (!blocked && !isTrackedClass) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (blocked) {
|
||||
event.preventDefault();
|
||||
|
||||
const safeUrl = resolveSafeUrl(link, href);
|
||||
if (safeUrl && safeUrl !== href) {
|
||||
link.setAttribute('href', safeUrl);
|
||||
payload.effective_url = safeUrl;
|
||||
openMomoUrl(link, safeUrl);
|
||||
}
|
||||
|
||||
trackMomoLink(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
trackMomoLink(payload);
|
||||
};
|
||||
|
||||
document.addEventListener('click', guardMomoLink, true);
|
||||
document.addEventListener('auxclick', guardMomoLink, true);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- 共用 JavaScript -->
|
||||
<script>
|
||||
// CSRF Token 設定 (供 AJAX 使用)
|
||||
|
||||
@@ -929,8 +929,16 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<a href="{{ item.record.product.url }}" target="_blank"
|
||||
class="product-link product-name" title="{{ item.record.product.name|e }}">
|
||||
{% set safe_product_url = item.safe_momo_url or '#' %}
|
||||
<a href="{{ safe_product_url }}" target="_blank"
|
||||
class="product-link product-name momo-tracked-link"
|
||||
data-momo-original-url="{{ safe_product_url }}"
|
||||
data-track-platform="momo"
|
||||
data-track-source="dashboard-legacy-table"
|
||||
data-track-product-id="{{ item.record.product.id }}"
|
||||
data-track-icode="{{ item.record.product.i_code }}"
|
||||
data-track-product-name="{{ item.record.product.name|e }}"
|
||||
title="{{ item.record.product.name|e }}">
|
||||
{{ item.record.product.name }}
|
||||
</a>
|
||||
<div class="d-flex align-items-center mt-1">
|
||||
@@ -1369,8 +1377,8 @@
|
||||
html += `
|
||||
<tr>
|
||||
<td><img src="${safeImageUrl}" class="product-thumb" onerror="this.src='/static/placeholder.png'" style="width: 60px; height: 60px; object-fit: cover; border-radius: 8px;"></td>
|
||||
<td><a href="${safeUrl}" target="_blank" class="text-decoration-none">${safeProductId}</a></td>
|
||||
<td><a href="${safeUrl}" target="_blank" class="text-decoration-none" title="${safeName}">${safeName}</a></td>
|
||||
<td><a href="${safeUrl}" target="_blank" class="text-decoration-none momo-tracked-link" data-track-platform="momo" data-track-source="dashboard-legacy-modal" data-track-product-id="${safeProductId}" data-track-icode="${safeProductId}" data-track-product-name="${safeName}" data-momo-original-url="${safeUrl}">${safeProductId}</a></td>
|
||||
<td><a href="${safeUrl}" target="_blank" class="text-decoration-none momo-tracked-link" data-track-platform="momo" data-track-source="dashboard-legacy-modal" data-track-product-id="${safeProductId}" data-track-icode="${safeProductId}" data-track-product-name="${safeName}" title="${safeName}" data-momo-original-url="${safeUrl}">${safeName}</a></td>
|
||||
<td><span class="badge bg-secondary">${safeCategory || '未分類'}</span></td>
|
||||
<td>$${(p.old_price || 0).toLocaleString()}</td>
|
||||
<td><strong>$${(p.current_price || 0).toLocaleString()}</strong></td>
|
||||
@@ -1399,7 +1407,141 @@
|
||||
|
||||
window.location.href = `/api/export/price_changes?type=${currentFilterType}&category=${encodeURIComponent(currentFilterCategory)}`;
|
||||
}
|
||||
|
||||
function getSafeMomoFallbackUrl(link) {
|
||||
const iCode = (link.dataset.trackIcode || link.dataset.trackProductId || '').trim();
|
||||
const isLikelyMomoIcode = isLikelyMomoProductCode(iCode);
|
||||
if (!isLikelyMomoIcode) {
|
||||
return '#';
|
||||
}
|
||||
return `https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=${encodeURIComponent(iCode)}`;
|
||||
}
|
||||
|
||||
function isLikelyMomoProductCode(value) {
|
||||
const cleaned = (value || '').trim();
|
||||
if (!cleaned) {
|
||||
return false;
|
||||
}
|
||||
const lowered = cleaned.toLowerCase();
|
||||
if (lowered === 'nan' || lowered === 'none' || lowered === 'null' || lowered === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
if (lowered.startsWith('momo_') || lowered.startsWith('manual_') || lowered.startsWith('pchome_')) {
|
||||
return false;
|
||||
}
|
||||
return /^[A-Za-z0-9_-]{4,}$/.test(cleaned);
|
||||
}
|
||||
|
||||
function isBlockedMomoUrl(url) {
|
||||
const lowered = (url || '').toLowerCase();
|
||||
if (lowered.includes('ec404.html') || lowered.includes('ec404')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(url, location.origin);
|
||||
const path = (parsed.pathname || '').toLowerCase();
|
||||
if (!path.includes('goodsdetail')) {
|
||||
return false;
|
||||
}
|
||||
const code = (parsed.searchParams.get('i_code') || '').trim();
|
||||
if (code) {
|
||||
return !isLikelyMomoProductCode(code);
|
||||
}
|
||||
return !/\/goodsdetail\/[^/?#]+/i.test(path);
|
||||
} catch (error) {
|
||||
if (!/goodsdetail\.jsp/i.test(lowered)) {
|
||||
return false;
|
||||
}
|
||||
const hasCode = /[?&]i_code=([^&#]+)/i.test(lowered);
|
||||
if (!hasCode) {
|
||||
return true;
|
||||
}
|
||||
const match = /[?&]i_code=([^&#]+)/i.exec(lowered);
|
||||
const code = match ? (match[1] || '').trim() : '';
|
||||
return !isLikelyMomoProductCode(code);
|
||||
}
|
||||
}
|
||||
|
||||
function openMomoUrl(link, url) {
|
||||
if (!url || url === '#') {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = (link.getAttribute('target') || '_self').toLowerCase();
|
||||
if (target === '_blank') {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
|
||||
if (target === '_self' || target === '') {
|
||||
window.location.href = url;
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(url, target);
|
||||
}
|
||||
|
||||
function trackMomoLinkClick(event) {
|
||||
const link = event.target.closest('.momo-tracked-link');
|
||||
if (!link) {
|
||||
return;
|
||||
}
|
||||
|
||||
const href = link.getAttribute('href') || '';
|
||||
const originalHref = link.dataset.momoOriginalUrl || href;
|
||||
if (!href || href === '#') {
|
||||
return;
|
||||
}
|
||||
|
||||
const isBlocked = isBlockedMomoUrl(href);
|
||||
|
||||
const payload = {
|
||||
url: originalHref,
|
||||
page: location.pathname,
|
||||
source: link.dataset.trackSource || 'unknown',
|
||||
platform: link.dataset.trackPlatform || 'momo',
|
||||
product_id: link.dataset.trackProductId || '',
|
||||
i_code: link.dataset.trackIcode || '',
|
||||
product_name: link.dataset.trackProductName || '',
|
||||
label: (link.textContent || '').trim(),
|
||||
effective_url: href
|
||||
};
|
||||
|
||||
if (isBlocked) {
|
||||
console.warn('[Dashboard] 嘗試打開 MOMO 404 網址', payload);
|
||||
event.preventDefault();
|
||||
|
||||
const fallbackUrl = link.dataset.momoFallbackUrl || getSafeMomoFallbackUrl(link);
|
||||
if (fallbackUrl && fallbackUrl !== '#' && fallbackUrl !== href) {
|
||||
link.dataset.momoFallbackUrl = fallbackUrl;
|
||||
link.setAttribute('href', fallbackUrl);
|
||||
payload.effective_url = fallbackUrl;
|
||||
openMomoUrl(link, fallbackUrl);
|
||||
fetch('/api/track_momo_link', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCSRFToken()
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
fetch('/api/track_momo_link', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCSRFToken()
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
document.addEventListener('click', trackMomoLinkClick);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -959,7 +959,12 @@
|
||||
<div class="dashboard-focus-list">
|
||||
{% for pick in overview.top_picks %}
|
||||
<div class="dashboard-focus-row">
|
||||
<a class="dashboard-focus-row-title" href="{{ pick.momo_url }}" target="_blank" rel="noopener noreferrer">{{ pick.name }}</a>
|
||||
<a class="dashboard-focus-row-title momo-tracked-link" href="{{ pick.momo_url or '#' }}" data-momo-original-url="{{ pick.momo_url or '#' }}" target="_blank" rel="noopener noreferrer"
|
||||
data-track-platform="momo"
|
||||
data-track-source="dashboard-v2-overview-top-picks"
|
||||
data-track-product-id="{{ pick.sku }}"
|
||||
data-track-icode="{{ pick.sku }}"
|
||||
data-track-product-name="{{ pick.name|e }}">{{ pick.name }}</a>
|
||||
<div class="dashboard-focus-row-meta momo-mono">
|
||||
<span class="dashboard-focus-chip is-win">AI {{ (pick.confidence * 100) | round(0) | int if pick.confidence else 0 }}%</span>
|
||||
<span>證據 {{ pick.evidence_quality | round(0) | int }}%</span>
|
||||
@@ -976,7 +981,12 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="dashboard-focus-row-links">
|
||||
<a class="dashboard-platform-link is-momo" href="{{ pick.momo_url }}" target="_blank" rel="noopener noreferrer">MOMO {{ pick.sku }}</a>
|
||||
<a class="dashboard-platform-link is-momo momo-tracked-link" href="{{ pick.momo_url or '#' }}" data-momo-original-url="{{ pick.momo_url or '#' }}" target="_blank" rel="noopener noreferrer"
|
||||
data-track-platform="momo"
|
||||
data-track-source="dashboard-v2-overview-top-picks"
|
||||
data-track-product-id="{{ pick.sku }}"
|
||||
data-track-icode="{{ pick.sku }}"
|
||||
data-track-product-name="{{ pick.name|e }}">MOMO {{ pick.sku }}</a>
|
||||
{% if pick.pchome_url %}
|
||||
<a class="dashboard-platform-link is-pchome" href="{{ pick.pchome_url }}" target="_blank" rel="noopener noreferrer">PChome {{ pick.pchome_id }}</a>
|
||||
{% endif %}
|
||||
@@ -996,14 +1006,24 @@
|
||||
<div class="dashboard-focus-list">
|
||||
{% for item in overview.top_momo_threats %}
|
||||
<div class="dashboard-focus-row">
|
||||
<a class="dashboard-focus-row-title" href="{{ item.momo_url }}" target="_blank" rel="noopener noreferrer">{{ item.name }}</a>
|
||||
<a class="dashboard-focus-row-title momo-tracked-link" href="{{ item.momo_url or '#' }}" data-momo-original-url="{{ item.momo_url or '#' }}" target="_blank" rel="noopener noreferrer"
|
||||
data-track-platform="momo"
|
||||
data-track-source="dashboard-v2-overview-top-momo-threats"
|
||||
data-track-product-id="{{ item.sku }}"
|
||||
data-track-icode="{{ item.sku }}"
|
||||
data-track-product-name="{{ item.name|e }}">{{ item.name }}</a>
|
||||
<div class="dashboard-focus-row-meta momo-mono">
|
||||
<span class="dashboard-focus-chip is-risk">{{ item.gap_pct | round(1) }}%</span>
|
||||
<span>MOMO ${{ item.momo_price | int | number_format }}</span>
|
||||
<span>PChome ${{ item.pchome_price | int | number_format }}</span>
|
||||
</div>
|
||||
<div class="dashboard-focus-row-links">
|
||||
<a class="dashboard-platform-link is-momo" href="{{ item.momo_url }}" target="_blank" rel="noopener noreferrer">MOMO {{ item.sku }}</a>
|
||||
<a class="dashboard-platform-link is-momo momo-tracked-link" href="{{ item.momo_url or '#' }}" data-momo-original-url="{{ item.momo_url or '#' }}" target="_blank" rel="noopener noreferrer"
|
||||
data-track-platform="momo"
|
||||
data-track-source="dashboard-v2-overview-top-momo-threats"
|
||||
data-track-product-id="{{ item.sku }}"
|
||||
data-track-icode="{{ item.sku }}"
|
||||
data-track-product-name="{{ item.name|e }}">MOMO {{ item.sku }}</a>
|
||||
{% if item.pchome_url %}
|
||||
<a class="dashboard-platform-link is-pchome" href="{{ item.pchome_url }}" target="_blank" rel="noopener noreferrer">PChome {{ item.pchome_id }}</a>
|
||||
{% endif %}
|
||||
@@ -1023,14 +1043,24 @@
|
||||
<div class="dashboard-focus-list">
|
||||
{% for item in overview.pending_priority %}
|
||||
<div class="dashboard-focus-row">
|
||||
<a class="dashboard-focus-row-title" href="{{ item.momo_url }}" target="_blank" rel="noopener noreferrer">{{ item.name }}</a>
|
||||
<a class="dashboard-focus-row-title momo-tracked-link" href="{{ item.momo_url or '#' }}" data-momo-original-url="{{ item.momo_url or '#' }}" target="_blank" rel="noopener noreferrer"
|
||||
data-track-platform="momo"
|
||||
data-track-source="dashboard-v2-overview-pending-priority"
|
||||
data-track-product-id="{{ item.sku }}"
|
||||
data-track-icode="{{ item.sku }}"
|
||||
data-track-product-name="{{ item.name|e }}">{{ item.name }}</a>
|
||||
<div class="dashboard-focus-row-meta momo-mono">
|
||||
<span class="dashboard-focus-chip is-neutral">待比對</span>
|
||||
<span>MOMO ${{ item.momo_price | int | number_format }}</span>
|
||||
<span>{{ item.category or '未分類' }}</span>
|
||||
</div>
|
||||
<div class="dashboard-focus-row-links">
|
||||
<a class="dashboard-platform-link is-momo" href="{{ item.momo_url }}" target="_blank" rel="noopener noreferrer">MOMO {{ item.sku }}</a>
|
||||
<a class="dashboard-platform-link is-momo momo-tracked-link" href="{{ item.momo_url or '#' }}" data-momo-original-url="{{ item.momo_url or '#' }}" target="_blank" rel="noopener noreferrer"
|
||||
data-track-platform="momo"
|
||||
data-track-source="dashboard-v2-overview-pending-priority"
|
||||
data-track-product-id="{{ item.sku }}"
|
||||
data-track-icode="{{ item.sku }}"
|
||||
data-track-product-name="{{ item.name|e }}">MOMO {{ item.sku }}</a>
|
||||
<span class="dashboard-platform-muted">PChome 待比對</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1194,10 +1224,23 @@
|
||||
<td>
|
||||
<div class="dashboard-product-cell">
|
||||
<img class="dashboard-product-thumb" src="{{ image_url }}" alt="{{ product.name }}" loading="lazy" referrerpolicy="no-referrer">
|
||||
{% set safe_product_url = item.safe_momo_url or '#' %}
|
||||
<a class="dashboard-product-name momo-tracked-link" href="{{ safe_product_url or '#' }}" target="_blank" rel="noopener noreferrer"
|
||||
data-momo-original-url="{{ safe_product_url or '#' }}"
|
||||
data-track-platform="momo"
|
||||
data-track-source="dashboard-v2-table-main"
|
||||
data-track-product-id="{{ product.id }}"
|
||||
data-track-icode="{{ product.i_code }}"
|
||||
data-track-product-name="{{ product.name|e }}">{{ product.name }}</a>
|
||||
<div>
|
||||
<a class="dashboard-product-name" href="{{ product.url }}" target="_blank" rel="noopener noreferrer">{{ product.name }}</a>
|
||||
<div class="dashboard-platform-links">
|
||||
<a class="dashboard-platform-link is-momo" href="{{ product.url }}" target="_blank" rel="noopener noreferrer">
|
||||
<a class="dashboard-platform-link is-momo momo-tracked-link" href="{{ safe_product_url or '#' }}" target="_blank" rel="noopener noreferrer"
|
||||
data-momo-original-url="{{ safe_product_url or '#' }}"
|
||||
data-track-platform="momo"
|
||||
data-track-source="dashboard-v2-table-main"
|
||||
data-track-product-id="{{ product.id }}"
|
||||
data-track-icode="{{ product.i_code }}"
|
||||
data-track-product-name="{{ product.name|e }}">
|
||||
MOMO {{ product.i_code }}
|
||||
</a>
|
||||
{% if competitor and competitor.product_url %}
|
||||
@@ -1612,5 +1655,137 @@
|
||||
.catch(error => alert('錯誤: ' + error));
|
||||
}
|
||||
}
|
||||
|
||||
function trackMomoLinkClick(event) {
|
||||
const link = event.target.closest('.momo-tracked-link');
|
||||
if (!link) {
|
||||
return;
|
||||
}
|
||||
|
||||
const href = link.getAttribute('href') || '';
|
||||
const originalHref = link.dataset.momoOriginalUrl || href;
|
||||
if (!href || href === '#') {
|
||||
return;
|
||||
}
|
||||
|
||||
const isBlocked = isBlockedMomoUrl(href);
|
||||
|
||||
const payload = {
|
||||
url: originalHref,
|
||||
page: location.pathname,
|
||||
source: link.dataset.trackSource || 'unknown',
|
||||
platform: link.dataset.trackPlatform || 'momo',
|
||||
product_id: link.dataset.trackProductId || '',
|
||||
i_code: link.dataset.trackIcode || '',
|
||||
product_name: link.dataset.trackProductName || '',
|
||||
label: (link.textContent || '').trim(),
|
||||
effective_url: href
|
||||
};
|
||||
|
||||
if (isBlocked) {
|
||||
console.warn('[DashboardV2] 嘗試打開 MOMO 404 網址', payload);
|
||||
event.preventDefault();
|
||||
|
||||
const fallbackUrl = link.dataset.momoFallbackUrl || getSafeMomoFallbackUrl(link);
|
||||
if (fallbackUrl && fallbackUrl !== '#' && fallbackUrl !== href) {
|
||||
link.dataset.momoFallbackUrl = fallbackUrl;
|
||||
link.setAttribute('href', fallbackUrl);
|
||||
payload.effective_url = fallbackUrl;
|
||||
openMomoUrl(link, fallbackUrl);
|
||||
fetch('/api/track_momo_link', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCSRFToken()
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
fetch('/api/track_momo_link', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCSRFToken()
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function getSafeMomoFallbackUrl(link) {
|
||||
const iCode = (link.dataset.trackIcode || link.dataset.trackProductId || '').trim();
|
||||
if (!isLikelyMomoProductCode(iCode)) {
|
||||
return '#';
|
||||
}
|
||||
return `https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=${encodeURIComponent(iCode)}`;
|
||||
}
|
||||
|
||||
function openMomoUrl(link, url) {
|
||||
if (!url || url === '#') {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = (link.getAttribute('target') || '_self').toLowerCase();
|
||||
if (target === '_blank') {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
|
||||
if (target === '_self' || target === '') {
|
||||
window.location.href = url;
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(url, target);
|
||||
}
|
||||
|
||||
function isBlockedMomoUrl(url) {
|
||||
const lowered = (url || '').toLowerCase();
|
||||
if (lowered.includes('EC404.html') || lowered.includes('ec404')) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(url, location.origin);
|
||||
const path = (parsed.pathname || '').toLowerCase();
|
||||
if (!path.includes('goodsdetail')) {
|
||||
return false;
|
||||
}
|
||||
const code = (parsed.searchParams.get('i_code') || '').trim();
|
||||
if (code) {
|
||||
return !isLikelyMomoProductCode(code);
|
||||
}
|
||||
return !/\/goodsdetail\/[^/?#]+/i.test(path);
|
||||
} catch (error) {
|
||||
if (!/goodsdetail\.jsp/i.test(lowered)) {
|
||||
return false;
|
||||
}
|
||||
const hasCode = /[?&]i_code=([^&#]+)/i.test(lowered);
|
||||
if (!hasCode) {
|
||||
return true;
|
||||
}
|
||||
const match = /[?&]i_code=([^&#]+)/i.exec(lowered);
|
||||
const code = match ? (match[1] || '').trim() : '';
|
||||
return !isLikelyMomoProductCode(code);
|
||||
}
|
||||
}
|
||||
|
||||
function isLikelyMomoProductCode(value) {
|
||||
const cleaned = (value || '').trim();
|
||||
if (!cleaned) {
|
||||
return false;
|
||||
}
|
||||
const lowered = cleaned.toLowerCase();
|
||||
if (lowered === 'nan' || lowered === 'none' || lowered === 'null' || lowered === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
if (lowered.startsWith('momo_') || lowered.startsWith('manual_') || lowered.startsWith('pchome_')) {
|
||||
return false;
|
||||
}
|
||||
return /^[A-Za-z0-9_-]{4,}$/.test(cleaned);
|
||||
}
|
||||
|
||||
document.addEventListener('click', trackMomoLinkClick);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -256,7 +256,12 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<a href="{{ item.url or '#' }}" target="_blank" class="product-link" title="{{ item.name }}">
|
||||
<a href="{{ item.safe_product_url or '#' }}" target="_blank" class="product-link momo-tracked-link" title="{{ item.name }}" data-momo-original-url="{{ item.safe_product_url or '#' }}"
|
||||
data-track-platform="momo"
|
||||
data-track-source="edm-dashboard-table"
|
||||
data-track-product-id="{{ item.i_code }}"
|
||||
data-track-icode="{{ item.i_code }}"
|
||||
data-track-product-name="{{ item.name|e }}">
|
||||
{{ item.name }}
|
||||
</a>
|
||||
<div class="d-flex align-items-center flex-wrap gap-2">
|
||||
@@ -426,6 +431,138 @@
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
}
|
||||
|
||||
function trackMomoLinkClick(event) {
|
||||
const link = event.target.closest('.momo-tracked-link');
|
||||
if (!link) {
|
||||
return;
|
||||
}
|
||||
|
||||
const href = link.getAttribute('href') || '';
|
||||
const originalHref = link.dataset.momoOriginalUrl || href;
|
||||
if (!href || href === '#') {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
url: originalHref,
|
||||
page: location.pathname,
|
||||
source: link.dataset.trackSource || 'unknown',
|
||||
platform: link.dataset.trackPlatform || 'momo',
|
||||
product_id: link.dataset.trackProductId || '',
|
||||
i_code: link.dataset.trackIcode || '',
|
||||
product_name: link.dataset.trackProductName || '',
|
||||
label: (link.textContent || '').trim(),
|
||||
effective_url: href
|
||||
};
|
||||
|
||||
const isBlocked = isBlockedMomoUrl(href);
|
||||
|
||||
if (isBlocked) {
|
||||
console.warn('[EDM Dashboard] 嘗試打開 MOMO 404 網址', payload);
|
||||
event.preventDefault();
|
||||
|
||||
const fallbackUrl = link.dataset.momoFallbackUrl || getSafeMomoFallbackUrl(link);
|
||||
if (fallbackUrl && fallbackUrl !== '#' && fallbackUrl !== href) {
|
||||
link.dataset.momoFallbackUrl = fallbackUrl;
|
||||
link.setAttribute('href', fallbackUrl);
|
||||
payload.effective_url = fallbackUrl;
|
||||
openMomoUrl(link, fallbackUrl);
|
||||
fetch('/api/track_momo_link', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCSRFToken()
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
fetch('/api/track_momo_link', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCSRFToken()
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function getSafeMomoFallbackUrl(link) {
|
||||
const iCode = (link.dataset.trackIcode || link.dataset.trackProductId || '').trim();
|
||||
if (!isLikelyMomoProductCode(iCode)) {
|
||||
return '#';
|
||||
}
|
||||
return `https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=${encodeURIComponent(iCode)}`;
|
||||
}
|
||||
|
||||
function isBlockedMomoUrl(url) {
|
||||
const lowered = (url || '').toLowerCase();
|
||||
if (lowered.includes('EC404.html') || lowered.includes('ec404')) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(url, location.origin);
|
||||
const path = (parsed.pathname || '').toLowerCase();
|
||||
if (!path.includes('goodsdetail')) {
|
||||
return false;
|
||||
}
|
||||
const code = (parsed.searchParams.get('i_code') || '').trim();
|
||||
if (code) {
|
||||
return !isLikelyMomoProductCode(code);
|
||||
}
|
||||
return !/\/goodsdetail\/[^/?#]+/i.test(path);
|
||||
} catch (error) {
|
||||
if (!/goodsdetail\.jsp/i.test(lowered)) {
|
||||
return false;
|
||||
}
|
||||
const hasCode = /[?&]i_code=([^&#]+)/i.test(lowered);
|
||||
if (!hasCode) {
|
||||
return true;
|
||||
}
|
||||
const match = /[?&]i_code=([^&#]+)/i.exec(lowered);
|
||||
const code = match ? (match[1] || '').trim() : '';
|
||||
return !isLikelyMomoProductCode(code);
|
||||
}
|
||||
}
|
||||
|
||||
function openMomoUrl(link, url) {
|
||||
if (!url || url === '#') {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = (link.getAttribute('target') || '_self').toLowerCase();
|
||||
if (target === '_blank') {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
|
||||
if (target === '_self' || target === '') {
|
||||
window.location.href = url;
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(url, target);
|
||||
}
|
||||
|
||||
document.addEventListener('click', trackMomoLinkClick);
|
||||
|
||||
function isLikelyMomoProductCode(value) {
|
||||
const cleaned = (value || '').trim();
|
||||
if (!cleaned) {
|
||||
return false;
|
||||
}
|
||||
const lowered = cleaned.toLowerCase();
|
||||
if (lowered === 'nan' || lowered === 'none' || lowered === 'null' || lowered === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
if (lowered.startsWith('momo_') || lowered.startsWith('manual_') || lowered.startsWith('pchome_')) {
|
||||
return false;
|
||||
}
|
||||
return /^[A-Za-z0-9_-]{4,}$/.test(cleaned);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -893,7 +893,12 @@
|
||||
<div class="campaign-product-thumb d-flex align-items-center justify-content-center" style="color:var(--momo-text-tertiary);font-size:11px;">無圖</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<a class="campaign-product-name" href="{{ item.url or '#' }}" target="_blank" rel="noopener noreferrer">{{ item.name }}</a>
|
||||
<a class="campaign-product-name momo-tracked-link" href="{{ item.safe_product_url or '#' }}" target="_blank" rel="noopener noreferrer" data-momo-original-url="{{ item.safe_product_url or '#' }}"
|
||||
data-track-platform="momo"
|
||||
data-track-source="edm-dashboard-v2-table"
|
||||
data-track-product-id="{{ item.i_code }}"
|
||||
data-track-icode="{{ item.i_code }}"
|
||||
data-track-product-name="{{ item.name|e }}">{{ item.name }}</a>
|
||||
<button
|
||||
class="campaign-product-id"
|
||||
type="button"
|
||||
@@ -1326,5 +1331,137 @@
|
||||
.catch(error => alert('錯誤: ' + error));
|
||||
}
|
||||
}
|
||||
|
||||
function trackMomoLinkClick(event) {
|
||||
const link = event.target.closest('.momo-tracked-link');
|
||||
if (!link) {
|
||||
return;
|
||||
}
|
||||
|
||||
const href = link.getAttribute('href') || '';
|
||||
const originalHref = link.dataset.momoOriginalUrl || href;
|
||||
if (!href || href === '#') {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
url: originalHref,
|
||||
page: location.pathname,
|
||||
source: link.dataset.trackSource || 'unknown',
|
||||
platform: link.dataset.trackPlatform || 'momo',
|
||||
product_id: link.dataset.trackProductId || '',
|
||||
i_code: link.dataset.trackIcode || '',
|
||||
product_name: link.dataset.trackProductName || '',
|
||||
label: (link.textContent || '').trim(),
|
||||
effective_url: href
|
||||
};
|
||||
|
||||
const isBlocked = isBlockedMomoUrl(href);
|
||||
|
||||
if (isBlocked) {
|
||||
console.warn('[EDM Dashboard V2] 嘗試打開 MOMO 404 網址', payload);
|
||||
event.preventDefault();
|
||||
|
||||
const fallbackUrl = link.dataset.momoFallbackUrl || getSafeMomoFallbackUrl(link);
|
||||
if (fallbackUrl && fallbackUrl !== '#' && fallbackUrl !== href) {
|
||||
link.dataset.momoFallbackUrl = fallbackUrl;
|
||||
link.setAttribute('href', fallbackUrl);
|
||||
payload.effective_url = fallbackUrl;
|
||||
openMomoUrl(link, fallbackUrl);
|
||||
fetch('/api/track_momo_link', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCSRFToken()
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
fetch('/api/track_momo_link', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCSRFToken()
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function getSafeMomoFallbackUrl(link) {
|
||||
const iCode = (link.dataset.trackIcode || link.dataset.trackProductId || '').trim();
|
||||
if (!isLikelyMomoProductCode(iCode)) {
|
||||
return '#';
|
||||
}
|
||||
return `https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=${encodeURIComponent(iCode)}`;
|
||||
}
|
||||
|
||||
function isBlockedMomoUrl(url) {
|
||||
const lowered = (url || '').toLowerCase();
|
||||
if (lowered.includes('EC404.html') || lowered.includes('ec404')) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(url, location.origin);
|
||||
const path = (parsed.pathname || '').toLowerCase();
|
||||
if (!path.includes('goodsdetail')) {
|
||||
return false;
|
||||
}
|
||||
const code = (parsed.searchParams.get('i_code') || '').trim();
|
||||
if (code) {
|
||||
return !isLikelyMomoProductCode(code);
|
||||
}
|
||||
return !/\/goodsdetail\/[^/?#]+/i.test(path);
|
||||
} catch (error) {
|
||||
if (!/goodsdetail\.jsp/i.test(lowered)) {
|
||||
return false;
|
||||
}
|
||||
const hasCode = /[?&]i_code=([^&#]+)/i.test(lowered);
|
||||
if (!hasCode) {
|
||||
return true;
|
||||
}
|
||||
const match = /[?&]i_code=([^&#]+)/i.exec(lowered);
|
||||
const code = match ? (match[1] || '').trim() : '';
|
||||
return !isLikelyMomoProductCode(code);
|
||||
}
|
||||
}
|
||||
|
||||
function isLikelyMomoProductCode(value) {
|
||||
const cleaned = (value || '').trim();
|
||||
if (!cleaned) {
|
||||
return false;
|
||||
}
|
||||
const lowered = cleaned.toLowerCase();
|
||||
if (lowered === 'nan' || lowered === 'none' || lowered === 'null' || lowered === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
if (lowered.startsWith('momo_') || lowered.startsWith('manual_') || lowered.startsWith('pchome_')) {
|
||||
return false;
|
||||
}
|
||||
return /^[A-Za-z0-9_-]{4,}$/.test(cleaned);
|
||||
}
|
||||
|
||||
function openMomoUrl(link, url) {
|
||||
if (!url || url === '#') {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = (link.getAttribute('target') || '_self').toLowerCase();
|
||||
if (target === '_blank') {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
|
||||
if (target === '_self' || target === '') {
|
||||
window.location.href = url;
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(url, target);
|
||||
}
|
||||
|
||||
document.addEventListener('click', trackMomoLinkClick);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -74,6 +74,226 @@
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- MOMO 404 防呆攔截:避免自動打開 EC404 頁面 -->
|
||||
<script>
|
||||
(function () {
|
||||
if (window.__momoLinkGuardInstalled) {
|
||||
return;
|
||||
}
|
||||
window.__momoLinkGuardInstalled = true;
|
||||
|
||||
const MOMO_HOSTS = new Set(['www.momoshop.com.tw', 'm.momoshop.com.tw']);
|
||||
const MOMO_CODE_RE = /^[A-Za-z0-9_-]{4,}$/;
|
||||
|
||||
const toText = value => (value == null ? '' : String(value));
|
||||
|
||||
const hasMomoHost = function (url) {
|
||||
const target = toText(url).trim();
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(target, location.origin);
|
||||
return MOMO_HOSTS.has((parsed.hostname || '').toLowerCase());
|
||||
} catch (error) {
|
||||
return /(^|\/)m(?:\.momoshop\.com\.tw|\.momoshop\.com\.tw)(\/|$)|www\.momoshop\.com\.tw/i.test(target);
|
||||
}
|
||||
};
|
||||
|
||||
const isBlockedMomoUrl = function (url) {
|
||||
const lowered = toText(url).toLowerCase();
|
||||
if (lowered.includes('ec404.html') || lowered.includes('/ecm/js/err404/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(url, location.origin);
|
||||
const path = (parsed.pathname || '').toLowerCase();
|
||||
if (!path.includes('goodsdetail')) {
|
||||
return false;
|
||||
}
|
||||
const iCode = (parsed.searchParams.get('i_code') || '').trim();
|
||||
if (iCode) {
|
||||
return !isLikelyMomoIcode(iCode);
|
||||
}
|
||||
return !/\/goodsdetail\/[^/?#]+/i.test(path);
|
||||
} catch (error) {
|
||||
const hasGoodsDetail = /goodsdetail\.jsp/i.test(lowered);
|
||||
if (!hasGoodsDetail) {
|
||||
return false;
|
||||
}
|
||||
const hasIcode = /[?&]i_code=([^&#]+)/i.test(lowered);
|
||||
if (!hasIcode) {
|
||||
return true;
|
||||
}
|
||||
const match = /[?&]i_code=([^&#]+)/i.exec(lowered);
|
||||
const extracted = match ? (match[1] || '').trim() : '';
|
||||
return !isLikelyMomoIcode(extracted);
|
||||
}
|
||||
};
|
||||
|
||||
const isLikelyMomoIcode = function (value) {
|
||||
const cleaned = toText(value).trim();
|
||||
if (!cleaned) {
|
||||
return false;
|
||||
}
|
||||
const lowered = cleaned.toLowerCase();
|
||||
if (lowered === 'nan' || lowered === 'none' || lowered === 'null' || lowered === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
if (lowered.startsWith('momo_')) {
|
||||
return false;
|
||||
}
|
||||
return MOMO_CODE_RE.test(cleaned);
|
||||
};
|
||||
|
||||
const getIcodeFromUrl = function (url) {
|
||||
const target = toText(url).trim();
|
||||
if (!target) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(target, location.origin);
|
||||
const iCode = parsed.searchParams.get('i_code');
|
||||
if (iCode) {
|
||||
return iCode.trim();
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const match = /[?&]i_code=([^&#]+)/i.exec(target);
|
||||
return match ? decodeURIComponent(match[1] || '').trim() : '';
|
||||
};
|
||||
|
||||
const buildSafeMomoUrl = function (iCode) {
|
||||
const cleaned = toText(iCode).trim();
|
||||
if (!isLikelyMomoIcode(cleaned)) {
|
||||
return '';
|
||||
}
|
||||
return `https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=${encodeURIComponent(cleaned)}`;
|
||||
};
|
||||
|
||||
const resolveSafeUrl = function (link, href) {
|
||||
const original = toText(link.dataset && link.dataset.momoOriginalUrl).trim();
|
||||
const explicit = toText(link.dataset && (link.dataset.trackIcode || link.dataset.trackProductId)).trim();
|
||||
const fallbackCode = explicit || getIcodeFromUrl(original) || getIcodeFromUrl(href);
|
||||
|
||||
if (fallbackCode) {
|
||||
return buildSafeMomoUrl(fallbackCode);
|
||||
}
|
||||
return '#';
|
||||
};
|
||||
|
||||
const openMomoUrl = function (link, url) {
|
||||
if (!url || url === '#') {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = toText(link.getAttribute('target') || '_self').toLowerCase();
|
||||
if (target === '_blank') {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
if (target === '_self' || target === '' ) {
|
||||
window.location.href = url;
|
||||
return;
|
||||
}
|
||||
window.open(url, target);
|
||||
};
|
||||
|
||||
const trackMomoLink = function (payload) {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
const body = {
|
||||
status: 'tracked',
|
||||
...payload
|
||||
};
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
if (csrfToken) {
|
||||
headers['X-CSRFToken'] = csrfToken;
|
||||
}
|
||||
|
||||
fetch('/api/track_momo_link', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
keepalive: true
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
const guardMomoLink = function (event) {
|
||||
if (event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'click' && event.button && event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
if (event.type === 'auxclick' && event.button !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const link = event.target.closest ? event.target.closest('a') : null;
|
||||
if (!link) {
|
||||
return;
|
||||
}
|
||||
|
||||
const href = toText(link.getAttribute('href') || '').trim();
|
||||
if (!href || href === '#') {
|
||||
return;
|
||||
}
|
||||
|
||||
const isTrackedClass = link.classList && link.classList.contains('momo-tracked-link');
|
||||
const raw = toText(link.getAttribute('href') || '').trim();
|
||||
const original = toText(link.dataset && link.dataset.momoOriginalUrl).trim();
|
||||
if (!isTrackedClass && !hasMomoHost(raw) && !hasMomoHost(original)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blocked = isBlockedMomoUrl(href) || isBlockedMomoUrl(original);
|
||||
const payload = {
|
||||
url: original || href,
|
||||
page: location.pathname,
|
||||
source: toText(link.dataset && link.dataset.trackSource) || 'auto-guard',
|
||||
platform: toText(link.dataset && link.dataset.trackPlatform) || 'momo',
|
||||
product_id: toText(link.dataset && link.dataset.trackProductId) || '',
|
||||
i_code: toText(link.dataset && link.dataset.trackIcode) || '',
|
||||
product_name: toText(link.dataset && link.dataset.trackProductName) || '',
|
||||
label: toText(link.textContent || '').trim(),
|
||||
effective_url: href,
|
||||
blocked: blocked
|
||||
};
|
||||
|
||||
if (!blocked && !isTrackedClass) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (blocked) {
|
||||
event.preventDefault();
|
||||
|
||||
const safeUrl = resolveSafeUrl(link, href);
|
||||
if (safeUrl && safeUrl !== href) {
|
||||
link.setAttribute('href', safeUrl);
|
||||
payload.effective_url = safeUrl;
|
||||
openMomoUrl(link, safeUrl);
|
||||
}
|
||||
|
||||
trackMomoLink(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
trackMomoLink(payload);
|
||||
};
|
||||
|
||||
document.addEventListener('click', guardMomoLink, true);
|
||||
document.addEventListener('auxclick', guardMomoLink, true);
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
|
||||
|
||||
@@ -408,11 +408,16 @@ La Roche-Posay 安得利防曬液 50ml,920
|
||||
return;
|
||||
}
|
||||
|
||||
const rawUrl = parts[2]?.trim() || '';
|
||||
const productId = currentManualSource === 'momo'
|
||||
? extractMomoCodeFromUrl(rawUrl)
|
||||
: `manual_${i}`;
|
||||
|
||||
products.push({
|
||||
name: parts[0].trim(),
|
||||
price: price,
|
||||
product_id: `manual_${i}`,
|
||||
url: parts[2]?.trim() || ''
|
||||
product_id: productId,
|
||||
url: rawUrl
|
||||
});
|
||||
}
|
||||
|
||||
@@ -509,7 +514,19 @@ La Roche-Posay 安得利防曬液 50ml,920
|
||||
</small>
|
||||
<small class="text-muted">${m.momo.product_id || ''}</small>
|
||||
</div>
|
||||
${momoUrl ? `<a href="${momoUrl}" target="_blank" class="btn btn-sm btn-outline-warning ms-1" title="前往 MOMO 查看"><i class="fas fa-external-link-alt"></i></a>` : ''}
|
||||
${momoUrl ? `<a
|
||||
href="${momoUrl}"
|
||||
target="_blank"
|
||||
class="btn btn-sm btn-outline-warning ms-1 momo-tracked-link"
|
||||
title="前往 MOMO 查看"
|
||||
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)}">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</a>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center ${similarityClass}">
|
||||
@@ -582,6 +599,24 @@ La Roche-Posay 安得利防曬液 50ml,920
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function extractMomoCodeFromUrl(url) {
|
||||
const target = (url || '').trim();
|
||||
if (!target) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(target, location.origin);
|
||||
const iCode = parsed.searchParams.get('i_code');
|
||||
if (iCode) {
|
||||
return iCode.trim();
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
const match = /[?&]i_code=([^&#]+)/i.exec(target);
|
||||
return match ? decodeURIComponent(match[1] || '').trim() : '';
|
||||
}
|
||||
|
||||
function showToast(message, type = 'info') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `alert alert-${type} position-fixed`;
|
||||
|
||||
50
tests/test_momo_url_utils.py
Normal file
50
tests/test_momo_url_utils.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from utils.momo_url_utils import build_momo_product_url, extract_momo_i_code, is_probable_momo_icode, normalize_momo_product_url
|
||||
|
||||
|
||||
def test_build_momo_product_url_from_code():
|
||||
assert build_momo_product_url("12345") == "https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=12345"
|
||||
|
||||
|
||||
def test_build_momo_product_url_empty_returns_none():
|
||||
assert build_momo_product_url("") is None
|
||||
assert build_momo_product_url(None) is None
|
||||
|
||||
|
||||
def test_normalize_keeps_valid_goodsdetail_url():
|
||||
valid = "https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=12345"
|
||||
assert normalize_momo_product_url(valid, "12345") == valid
|
||||
|
||||
|
||||
def test_normalize_falls_back_for_javascript_link():
|
||||
assert normalize_momo_product_url("javascript:void(0)", "12345") == build_momo_product_url("12345")
|
||||
|
||||
|
||||
def test_normalize_falls_back_for_err404_page():
|
||||
assert normalize_momo_product_url(
|
||||
"https://www.momoshop.com.tw/ecm/js/err404/EC404.html",
|
||||
"12345",
|
||||
) == build_momo_product_url("12345")
|
||||
|
||||
|
||||
def test_normalize_adds_https_for_protocol_relative_url():
|
||||
assert normalize_momo_product_url("//www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=12345", "12345") == (
|
||||
"https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=12345"
|
||||
)
|
||||
|
||||
|
||||
def test_extract_momo_i_code():
|
||||
assert extract_momo_i_code("https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=12345") == "12345"
|
||||
assert extract_momo_i_code("/goods/GoodsDetail.jsp?i_code=999") == "999"
|
||||
assert extract_momo_i_code("not-a-url") is None
|
||||
|
||||
|
||||
def test_invalid_placeholder_codes():
|
||||
assert is_probable_momo_icode("momo_0") is False
|
||||
assert is_probable_momo_icode("manual_123") is False
|
||||
assert is_probable_momo_icode("pchome_1") is False
|
||||
assert is_probable_momo_icode("1234") is True
|
||||
|
||||
|
||||
def test_normalize_blocks_goodsdetail_without_i_code():
|
||||
invalid = "https://www.momoshop.com.tw/goods/GoodsDetail.jsp?foo=1"
|
||||
assert normalize_momo_product_url(invalid, "12345") == build_momo_product_url("12345")
|
||||
146
utils/momo_url_utils.py
Normal file
146
utils/momo_url_utils.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""Utilities for MOMO product URL normalization and fallback."""
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
from urllib.parse import parse_qs, urlparse, urlunparse
|
||||
|
||||
MOMO_BASE_DOMAINS = {
|
||||
'www.momoshop.com.tw',
|
||||
'm.momoshop.com.tw',
|
||||
}
|
||||
|
||||
ERR404_PATH = '/ecm/js/err404/ec404.html'
|
||||
MOMO_ICODE_FALLBACK_MIN_LEN = 4
|
||||
MOMO_ICODE_RE = re.compile(r'^[A-Za-z0-9_-]+$')
|
||||
|
||||
|
||||
def is_probable_momo_icode(i_code: Optional[object]) -> bool:
|
||||
"""判斷值是否像是合理的 MOMO 商品代碼。"""
|
||||
cleaned = str(i_code or '').strip()
|
||||
if not cleaned:
|
||||
return False
|
||||
|
||||
lowered = cleaned.lower()
|
||||
if lowered in {'nan', 'none', 'null', 'undefined'}:
|
||||
return False
|
||||
|
||||
if lowered.startswith(('momo_', 'manual_', 'pchome_')):
|
||||
return False
|
||||
|
||||
if len(cleaned) < MOMO_ICODE_FALLBACK_MIN_LEN:
|
||||
return False
|
||||
|
||||
return bool(MOMO_ICODE_RE.fullmatch(cleaned))
|
||||
|
||||
|
||||
def build_momo_product_url(i_code: Optional[object]) -> Optional[str]:
|
||||
"""Build fallback MOMO product detail URL from i_code."""
|
||||
if not is_probable_momo_icode(i_code):
|
||||
return None
|
||||
return f"https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code={str(i_code).strip()}"
|
||||
|
||||
|
||||
def extract_momo_i_code(url: Optional[object]) -> Optional[str]:
|
||||
"""從 URL 萃取 i_code。"""
|
||||
if not url:
|
||||
return None
|
||||
|
||||
raw = str(url).strip()
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
# URL 格式:直接解析
|
||||
try:
|
||||
normalized = raw if raw.startswith(('http://', 'https://')) else (
|
||||
f'https:{raw}' if raw.startswith('//') else raw
|
||||
)
|
||||
parsed = urlparse(normalized)
|
||||
if parsed.scheme in ('http', 'https'):
|
||||
query = parse_qs(parsed.query or '')
|
||||
i_code = (query.get('i_code') or [''])[0]
|
||||
if i_code:
|
||||
return i_code.strip()
|
||||
|
||||
match = re.search(r'/goodsdetail/([^/?#]+)', parsed.path or '', re.I)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 備援匹配
|
||||
match = re.search(r'[?&]i_code=([^&#]+)', raw, re.I)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_quoted_url(url: str) -> str:
|
||||
"""Normalize scheme-relative and path-relative URLs."""
|
||||
cleaned = (url or '').strip()
|
||||
if cleaned.startswith('//'):
|
||||
return f'https:{cleaned}'
|
||||
if cleaned.startswith('/'):
|
||||
return f'https://www.momoshop.com.tw{cleaned}'
|
||||
return cleaned
|
||||
|
||||
|
||||
def is_valid_momo_product_url(url: str) -> bool:
|
||||
"""Return whether URL looks like a valid MOMO product page."""
|
||||
if not url:
|
||||
return False
|
||||
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ('http', 'https'):
|
||||
return False
|
||||
if (parsed.hostname or '').lower() not in MOMO_BASE_DOMAINS:
|
||||
return False
|
||||
|
||||
path = (parsed.path or '').lower()
|
||||
if ERR404_PATH in path:
|
||||
return False
|
||||
|
||||
# 商品頁通常會有 GoodsDetail.jsp 或 goodsDetail/xxx
|
||||
if 'goodsdetail' in path:
|
||||
if 'i_code' not in parse_qs(parsed.query or '') and not re.search(r'/goodsdetail/[^/]+', path):
|
||||
return False
|
||||
query = parse_qs(parsed.query or '')
|
||||
if 'i_code' in query:
|
||||
return True
|
||||
# /goodsDetail/<i_code> 不一定有 query
|
||||
return bool(re.search(r'/goodsdetail/[^/]+', path))
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def normalize_momo_product_url(url: Optional[object], i_code: Optional[object]) -> Optional[str]:
|
||||
"""
|
||||
Normalize a MOMO URL and fall back to i_code product detail URL when invalid.
|
||||
|
||||
Args:
|
||||
url: Original link.
|
||||
i_code: Product code for fallback URL.
|
||||
"""
|
||||
fallback_code = extract_momo_i_code(url) or (str(i_code).strip() if is_probable_momo_icode(i_code) else None)
|
||||
fallback = build_momo_product_url(fallback_code)
|
||||
|
||||
if not url:
|
||||
return fallback
|
||||
|
||||
normalized = _normalize_quoted_url(str(url).strip())
|
||||
if not normalized:
|
||||
return fallback
|
||||
|
||||
lower = normalized.lower()
|
||||
if lower.startswith('javascript:') or lower.startswith('void('):
|
||||
return fallback
|
||||
|
||||
if is_valid_momo_product_url(normalized):
|
||||
return normalized
|
||||
|
||||
# 兜底:若網址可解析且 host 仍是 MOMO,但不是預期路徑,仍可視為損壞資料
|
||||
parsed = urlparse(normalized)
|
||||
if parsed.scheme in ('http', 'https'):
|
||||
return fallback
|
||||
|
||||
return fallback
|
||||
Reference in New Issue
Block a user