Files
ewoooc/templates/edm_dashboard.html
OoO 75de76ac12
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
fix(momo): block EC404 auto-open with end-to-end URL guard
- normalize URLs at write time (scheduler crawlers, routes) to drop
  javascript:/EC404/placeholder i_code (momo_/manual_/pchome_)
- add global click+auxclick guard in base.html and ewoooc_base.html
  that intercepts blocked MOMO URLs and redirects to safe i_code URL
- per-page dashboards reuse the same isLikelyMomoIcode validation
- /api/track_momo_link records blocked events for diagnosis
- ship sanitize_momo_urls.py to clean existing polluted DB rows

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:00:34 +08:00

569 lines
29 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>MOMO 限時搶購</title>
<meta http-equiv="refresh" content="300">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
padding-top: 70px;
}
.navbar-dark.bg-primary {
background: linear-gradient(135deg, #4F46E5 0%, #6366F1 100%) !important;
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
}
.navbar-dark .navbar-brand {
color: #ffffff !important;
font-weight: 600;
}
.navbar-dark .navbar-nav .nav-link {
color: rgba(255, 255, 255, 0.9) !important;
font-weight: 500;
transition: all 0.3s;
}
.navbar-dark .navbar-nav .nav-link:hover {
color: #ffffff !important;
background: rgba(255, 255, 255, 0.1);
border-radius: 6px;
}
.navbar-dark .navbar-nav .nav-link.active {
color: #ffffff !important;
background: rgba(255, 255, 255, 0.15);
border-radius: 6px;
font-weight: 600;
}
.navbar-dark .navbar-text {
color: rgba(255, 255, 255, 0.8) !important;
}
.navbar {
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%) !important;
}
/* 統一使用 dashboard 的柔和色調與樣式 */
.bg-success-soft { background-color: rgba(25, 135, 84, 0.1) !important; }
.bg-secondary-soft { background-color: rgba(108, 117, 125, 0.15) !important; }
.bg-danger-soft { background-color: rgba(220, 53, 69, 0.1) !important; }
.table-container { background: white; border-radius: .5rem; box-shadow: 0 0 1px rgba(0,0,0,.125),0 1px 3px rgba(0,0,0,.2); padding: 1.25rem; margin-bottom: 1.5rem; }
.nav-pills .nav-link.active { background-color: #0d6efd; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.nav-pills .nav-link { color: #495057; font-weight: 500; }
.product-link { text-decoration: none; color: #212529; font-weight: 600; display: block; line-height: 1.4; margin-bottom: 4px; }
.product-link:hover { color: #0d6efd; }
.product-thumb { width: 60px; height: 60px; object-fit: cover; border-radius: 6px; border: 1px solid #dee2e6; margin-right: 12px; }
.price-up { color: #dc3545; font-weight: bold; }
.price-down { color: #198754; font-weight: bold; }
.price-current { font-weight: 700; font-size: 1.1em; }
.price-old { text-decoration: line-through; color: #999; font-size: 0.9em; margin-right: 4px; }
.table th { font-weight: 600; color: #495057; border-bottom-width: 1px; }
.badge-status { font-size: 0.75em; vertical-align: middle; margin-left: 6px; }
.cursor-pointer { cursor: pointer; }
/* Custom Dark Gray Navbar */
.navbar.bg-custom-dark {
background: linear-gradient(135deg, #1f2937 0%, #374151 100%);
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.navbar.bg-custom-dark .navbar-brand {
color: #ffffff;
font-weight: 600;
}
.navbar.bg-custom-dark .navbar-nav .nav-link {
color: rgba(255, 255, 255, 0.85);
font-weight: 500;
}
.navbar.bg-custom-dark .navbar-nav .nav-link:hover {
color: #ffffff;
}
.navbar.bg-custom-dark .navbar-nav .nav-link.active {
color: #ffffff;
font-weight: 600;
}
.navbar.bg-custom-dark .navbar-text {
color: rgba(255, 255, 255, 0.75);
}
</style>
</head>
<body class="bg-body-tertiary">
{% include 'components/_navbar.html' %}
<div class="container mb-5">
<!-- V-New: 動態頁籤導覽 -->
<div class="sub-nav-tabs mb-3">
{% for page in promo_pages %}
<a href="{{ page.url }}" class="btn {% if page.id == current_promo_page %}btn-primary{% else %}btn-outline-secondary{% endif %}">
{{ page.name }}
</a>
{% endfor %}
</div>
<div class="row mb-4">
<div class="col-md-8">
{# V-New: 使用動態頁面標題 #}
<h2>🔥 {{ page_title }}</h2>
<p class="text-muted">
活動時間: {{ activity_time }} |
最後更新: {{ last_update }} |
商品總數: {{ total_edm_products }}
</p>
</div>
<div class="col-md-4 text-end">
{# V-New: 根據不同頁面顯示不同按鈕 #}
{% if current_promo_page == 'edm' %}
<button class="btn btn-outline-primary" onclick="triggerEdmTask()">🔄 手動更新</button>
<button class="btn btn-outline-success" onclick="triggerNotification()">📢 發送通知</button>
{% elif current_promo_page == 'festival' %}
<button class="btn btn-outline-primary" onclick="triggerFestivalTask()">🔄 手動更新</button>
{% endif %}
</div>
</div>
{# V-New: 根據頁面類型讀取對應的排程統計 #}
{% set task_key = 'festival_task' if current_promo_page == 'festival' else 'edm_task' %}
{% set edm_stats_list = scheduler_stats.get(task_key, []) %}
{% if edm_stats_list %}
{% set latest_run = edm_stats_list[0] %}
<div class="alert alert-info small p-2 mb-3">
<i class="fas fa-info-circle me-1"></i>
<strong>排程統計:</strong>
上次執行於 {{ latest_run.last_run }}
共記錄 {{ latest_run.get('changed_records', 0) }} 筆異動。
狀態:
{% if latest_run.status == 'Success' %}
<span class="badge bg-success">成功</span>
{% else %}
<span class="badge bg-danger" title="{{ latest_run.get('error', '未知錯誤') }}">失敗</span>
{% endif %}
</div>
{% endif %}
<!-- 時段頁籤 -->
<ul class="nav nav-pills mb-3" id="pills-tab" role="tablist">
{% for slot, stats in slot_stats.items() %}
{% set slot_id = slugify(slot) %}
{% set pane_id = "pills-" ~ slot_id %}
{% set tab_id = pane_id ~ "-tab" %}
{% set target_selector = "#" ~ pane_id %}
{% set is_selected = 'true' if slot == active_tab else 'false' %}
{% set active_class = 'active' if slot == active_tab else '' %}
<li class="nav-item" role="presentation">
<button class="nav-link {{ active_class }} px-3 py-2"
id="{{ tab_id }}"
data-bs-toggle="pill"
data-bs-target="{{ target_selector }}"
type="button"
role="tab"
aria-controls="{{ pane_id }}"
aria-selected="{{ is_selected }}">
{{ slot }}
{# V-New: 改用 on_shelf 數量顯示,更直觀 #}
<span class="badge bg-white text-primary rounded-pill ms-2 border">{{ stats.on_shelf }}</span>
</button>
</li>
{% endfor %}
</ul>
<div class="tab-content" id="pills-tabContent">
{% for slot, stats in slot_stats.items() %}
{% set slot_id = slugify(slot) %}
{% set pane_id = "pills-" ~ slot_id %}
{% set items = grouped_items.get(slot, []) %}
<div class="tab-pane fade {% if slot == active_tab %}show active{% endif %}"
id="{{ pane_id }}" role="tabpanel">
<div class="table-container">
<!-- 該時段統計 -->
<div class="d-flex align-items-center mb-3 pb-2 border-bottom">
<h5 class="mb-0 me-3">商品列表 ({{ items|length }}筆)</h5>
<div class="small">
<span class="badge bg-primary me-1">新品: {{ stats['new'] }}</span>
<span class="badge bg-danger me-1">漲價: {{ stats['up'] }}</span>
<span class="badge bg-success me-1">降價: {{ stats['down'] }}</span>
<span class="badge bg-secondary">下架: {{ stats.get('delisted_last_run', 0) }}</span>
</div>
</div>
{% set current_endpoint = 'edm.' ~ current_promo_page ~ '_dashboard' %}
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr class="text-nowrap">
<th style="width: 10%;">分類</th>
<th style="width: 50%;">
{% set next_order_name = 'asc' if current_sort == 'name' and current_order == 'desc' else ('default' if current_sort == 'name' and current_order == 'asc' else 'desc') %}
<a href="{{ url_for(current_endpoint, sort_by='name' if next_order_name != 'default' else 'default', order=next_order_name) }}" class="text-decoration-none text-muted">
商品資訊 {% if current_sort == 'name' %}<i class="fas fa-sort-{{ 'down' if current_order == 'desc' else 'up' }}"></i>{% else %}<i class="fas fa-sort text-muted opacity-25"></i>{% endif %}
</a>
</th>
<th style="width: 20%;" class="text-end">
{% set next_order_price = 'asc' if current_sort == 'price' and current_order == 'desc' else ('default' if current_sort == 'price' and current_order == 'asc' else 'desc') %}
<a href="{{ url_for(current_endpoint, sort_by='price' if next_order_price != 'default' else 'default', order=next_order_price) }}" class="text-decoration-none text-muted">
價格 {% if current_sort == 'price' %}<i class="fas fa-sort-{{ 'down' if current_order == 'desc' else 'up' }}"></i>{% else %}<i class="fas fa-sort text-muted opacity-25"></i>{% endif %}
</a>
</th>
<th style="width: 20%;" class="text-center">
{% if current_promo_page == 'edm' %}
{% set next_order_qty = 'asc' if current_sort == 'remain_qty' and current_order == 'desc' else ('default' if current_sort == 'remain_qty' and current_order == 'asc' else 'desc') %}
<a href="{{ url_for(current_endpoint, sort_by='remain_qty' if next_order_qty != 'default' else 'default', order=next_order_qty) }}" class="text-decoration-none text-muted">
倒數組數 {% if current_sort == 'remain_qty' %}<i class="fas fa-sort-{{ 'down' if current_order == 'desc' else 'up' }}"></i>{% else %}<i class="fas fa-sort text-muted opacity-25"></i>{% endif %}
</a>
{% else %}
狀態
{% endif %}
</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>
{% if item.main_category %}
{% set badge_attr = 'style="background-color: ' ~ item.category_color ~ '; color: #333;"' %}
<span class="badge" {{ badge_attr | safe }}>{{ item.main_category }}</span>
{% else %}
<span class="badge bg-light text-muted">未分類</span>
{% endif %}
</td>
<td>
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
{% if item.image_url %}
<img src="{{ item.image_url }}" class="product-thumb" alt="商品圖" loading="lazy" referrerpolicy="no-referrer">
{% else %}
<div class="product-thumb d-flex align-items-center justify-content-center bg-light text-muted small">無圖</div>
{% endif %}
</div>
<div class="flex-grow-1">
<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">
<small class="text-muted fw-bold cursor-pointer" data-icode="{{ item.i_code }}" onclick="copyToClipboard(event, this.dataset.icode, this)" title="點擊複製品號">
ID: {{ item.i_code }} <i class="far fa-copy ms-1"></i>
</small>
{% if item.status_change == 'NEW' %}
<span class="badge bg-primary badge-status">NEW</span>
{% elif item.status_change == 'PRICE_DOWN' %}
<span class="badge bg-success-soft text-success badge-status">降價</span>
{% elif item.status_change == 'PRICE_UP' %}
<span class="badge bg-danger-soft text-danger badge-status">漲價</span>
{% elif item.status_change == 'DELISTED' or item.status_change == 'SLOT_END' %}
<span class="badge bg-secondary-soft text-secondary badge-status">下架</span>
{% endif %}
{% if item.discount_text %}
<span class="badge bg-danger bg-opacity-75 badge-status">{{ item.discount_text }}</span>
{% endif %}
</div>
</div>
</div>
</td>
<td class="text-end">
{% if item.previous_price and item.price and item.previous_price != item.price %}
{% set diff = item.price - item.previous_price %}
{% set percent = ((diff|abs) / item.previous_price * 100) | round | int %}
{% if diff < 0 %}
<div>
<span class="badge bg-success-soft text-success mb-1">▼ {{ (diff|abs) | number_format }} ({{ percent }}%)</span><br>
<span class="price-old text-muted">${{ item.previous_price | number_format }}</span>
<span class="price-current text-success">${{ item.price | number_format }}</span>
</div>
{% else %}
<div>
<span class="badge bg-danger-soft text-danger mb-1">▲ {{ diff | number_format }} ({{ percent }}%)</span><br>
<span class="price-old text-muted">${{ item.previous_price | number_format }}</span>
<span class="price-current text-danger">${{ item.price | number_format }}</span>
</div>
{% endif %}
{% else %}
<span class="price-current text-dark">${{ (item.price | number_format) if item.price is not none else 'N/A' }}</span>
{% endif %}
</td>
<td class="text-center">
{% if current_promo_page == 'edm' %}
{% set tooltip_content %}
<div class='text-start p-1'>
<strong>📈 當日銷售歷程:</strong><br>
{% for h in item.qty_history %}
<small>{{ h.time }}</small> &nbsp; 剩 {{ h.qty }} 組<br>
{% endfor %}
</div>
{% endset %}
<div {% if item.qty_history and item.qty_history|length > 1 %}
data-bs-toggle="tooltip"
data-bs-html="true"
title="{{ tooltip_content }}"
{% endif %}>
{% if item.remain_qty is not none %}
<span class="badge bg-warning text-dark border border-warning">🔥 {{ item.remain_qty | number_format }}組</span>
{% else %}
<span class="text-muted small">-</span>
{% endif %}
</div>
{% else %}
<span class="badge bg-light text-dark border">活動中</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
// V-New: 初始化 Bootstrap Tooltips
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl)
})
// Helper function to get CSRF token
function getCSRFToken() {
return document.querySelector('meta[name="csrf-token"]').getAttribute('content');
}
function triggerEdmTask() {
if(confirm('確定要手動執行 EDM 爬蟲嗎?')) {
fetch('/api/run_edm_task', {
method: 'POST',
headers: {
'X-CSRFToken': getCSRFToken()
}
})
.then(r => r.json())
.then(data => alert(data.message))
.catch(e => alert('錯誤: ' + e));
}
}
function triggerNotification() {
if(confirm('確定要發送比價通知嗎?')) {
fetch('/api/trigger_edm_notification', {
method: 'POST',
headers: {
'X-CSRFToken': getCSRFToken()
}
})
.then(r => r.json())
.then(data => alert(data.message))
.catch(e => alert('錯誤: ' + e));
}
}
// V-New: 觸發 Festival 爬蟲
function triggerFestivalTask() {
if(confirm('確定要手動執行「1.1狂歡購物節」爬蟲嗎?')) {
fetch('/api/run_festival_task', {
method: 'POST',
headers: {
'X-CSRFToken': getCSRFToken()
}
})
.then(r => r.json())
.then(data => alert(data.message))
.catch(e => alert('錯誤: ' + e));
}
}
// V-New: 複製品號功能
function copyToClipboard(event, text, element) {
event.stopPropagation();
const showFeedback = () => {
const originalHtml = element.innerHTML;
element.innerHTML = '已複製 <i class="fas fa-check"></i>';
element.classList.remove('text-muted');
element.classList.add('text-success');
setTimeout(() => {
element.innerHTML = originalHtml;
element.classList.remove('text-success');
element.classList.add('text-muted');
}, 1500);
};
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(showFeedback);
} else {
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
textArea.style.left = "-9999px";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
showFeedback();
} catch (err) { console.error('複製失敗', err); }
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>