fix(momo): block EC404 auto-open with end-to-end URL guard
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:
OoO
2026-05-02 12:00:34 +08:00
parent 026d0e7539
commit 75de76ac12
17 changed files with 1570 additions and 48 deletions

View File

@@ -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'})

View File

@@ -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:

View File

@@ -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)

View File

@@ -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
])
# 調整欄寬

View File

@@ -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)

View File

@@ -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

View 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)

View File

@@ -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:

View File

@@ -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 使用)

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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');

View File

@@ -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`;

View 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
View 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