Files
ewoooc/web/static/js/page-dashboard-v2.js
OoO 9ca8d4e43c
All checks were successful
CD Pipeline / deploy (push) Successful in 1m9s
feat: backfill growth momo matches
2026-06-18 16:02:02 +08:00

793 lines
34 KiB
JavaScript
Raw Permalink 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.
/* Dashboard V2 page interactions extracted from dashboard_v2.html. */
let priceChartInstance = null;
let activeHistoryRange = 'month';
let currentHistoryProductId = null;
let currentHistoryProductName = '';
let dashboardChartLoader = null;
function getCSRFToken() {
return document.querySelector('meta[name="csrf-token"]').getAttribute('content');
}
function formatPriceTick(value) {
return '$' + Number(value || 0).toLocaleString();
}
function setHistoryChartState(message, showCanvas = false) {
const state = document.getElementById('historyChartState');
const canvas = document.getElementById('priceChart');
if (!state || !canvas) return;
state.textContent = message;
state.classList.toggle('is-hidden', showCanvas);
canvas.classList.toggle('is-hidden', !showCanvas);
}
function destroyHistoryChart() {
if (priceChartInstance) {
priceChartInstance.destroy();
priceChartInstance = null;
}
}
function ensureDashboardChart() {
if (window.EwoooCChartTheme && window.EwoooCChartTheme.loadChartJs) {
return window.EwoooCChartTheme.loadChartJs();
}
if (typeof Chart !== 'undefined') {
return Promise.resolve(window.Chart);
}
if (dashboardChartLoader) {
return dashboardChartLoader;
}
dashboardChartLoader = new Promise((resolve, reject) => {
const existing = document.querySelector('script[data-chartjs-loader="dashboard"]');
if (existing) {
existing.addEventListener('load', () => resolve(window.Chart), { once: true });
existing.addEventListener('error', () => reject(new Error('Chart.js 載入失敗')), { once: true });
return;
}
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js';
script.async = true;
script.dataset.chartjsLoader = 'dashboard';
script.onload = () => resolve(window.Chart);
script.onerror = () => reject(new Error('Chart.js 載入失敗'));
document.head.appendChild(script);
});
return dashboardChartLoader;
}
function updateHistoryRangeButtons() {
document.querySelectorAll('[data-history-range]').forEach(button => {
button.classList.toggle('is-active', button.dataset.historyRange === activeHistoryRange);
});
}
function showHistory(productId, productName, range = activeHistoryRange) {
const modalEl = document.getElementById('historyModal');
const title = document.getElementById('historyModalLabel');
const subtitle = document.getElementById('historyModalSubtitle');
const canvas = document.getElementById('priceChart');
if (!modalEl || !title || !subtitle || !canvas) return;
currentHistoryProductId = productId;
currentHistoryProductName = productName || '歷史價格走勢';
activeHistoryRange = range;
updateHistoryRangeButtons();
title.textContent = productName || '歷史價格走勢';
subtitle.textContent = `商品 ID ${productId} · 讀取真實價格紀錄`;
destroyHistoryChart();
setHistoryChartState('載入價格歷史中...');
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
modal.show();
ensureDashboardChart()
.then(() => fetch(`/api/history/${productId}?range=${activeHistoryRange}&format=v2`))
.then(response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
})
.then(data => {
const series = Array.isArray(data) ? { momo: data, pchome: [] } : (data.series || {});
const momoPoints = Array.isArray(series.momo) ? series.momo : (Array.isArray(data.data) ? data.data : []);
const pchomePoints = Array.isArray(series.pchome) ? series.pchome : [];
const rangeLabel = Array.isArray(data) ? '' : (data.range_label || '');
if (rangeLabel) {
const pchomeNote = pchomePoints.length > 0 ? ' · 含 PChome 歷史快照' : '';
subtitle.textContent = `商品 ID ${productId} · ${rangeLabel}真實價格紀錄${pchomeNote}`;
}
if (momoPoints.length === 0 && pchomePoints.length === 0) {
setHistoryChartState('目前沒有可顯示的歷史價格紀錄。');
return;
}
setHistoryChartState('', true);
const ctx = canvas.getContext('2d');
const momoGradient = ctx.createLinearGradient(0, 0, 0, 380);
momoGradient.addColorStop(0, 'rgba(190, 106, 45, 0.26)');
momoGradient.addColorStop(1, 'rgba(190, 106, 45, 0.04)');
const pchomeGradient = ctx.createLinearGradient(0, 0, 0, 380);
pchomeGradient.addColorStop(0, 'rgba(70, 127, 181, 0.18)');
pchomeGradient.addColorStop(1, 'rgba(70, 127, 181, 0.03)');
const labels = Array.from(new Set([
...momoPoints.map(point => point.t),
...pchomePoints.map(point => point.t)
])).sort();
const toPriceMap = points => points.reduce((acc, point) => {
acc[point.t] = point.p;
return acc;
}, {});
const momoMap = toPriceMap(momoPoints);
const pchomeMap = toPriceMap(pchomePoints);
const datasets = [{
label: 'MOMO',
data: labels.map(label => momoMap[label] ?? null),
borderColor: '#be6a2d',
backgroundColor: momoGradient,
borderWidth: 3,
fill: true,
tension: 0.35,
spanGaps: true,
pointRadius: 3,
pointHoverRadius: 7,
pointBackgroundColor: '#be6a2d',
pointBorderColor: '#fff',
pointBorderWidth: 2
}];
if (pchomePoints.length > 0) {
datasets.push({
label: 'PChome',
data: labels.map(label => pchomeMap[label] ?? null),
borderColor: '#467fb5',
backgroundColor: pchomeGradient,
borderWidth: 2,
fill: false,
tension: 0.28,
spanGaps: true,
pointRadius: 3,
pointHoverRadius: 7,
pointBackgroundColor: '#467fb5',
pointBorderColor: '#fff',
pointBorderWidth: 2
});
}
priceChartInstance = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
display: pchomePoints.length > 0,
labels: {
color: '#6d604f',
usePointStyle: true,
boxWidth: 8,
boxHeight: 8
}
},
tooltip: {
backgroundColor: 'rgba(55, 45, 35, 0.94)',
titleColor: '#faf7f0',
bodyColor: '#faf7f0',
borderColor: '#be6a2d',
borderWidth: 1,
displayColors: true,
padding: 12,
callbacks: {
label: context => `${context.dataset.label} ${formatPriceTick(context.parsed.y)}`
}
}
},
scales: {
y: {
beginAtZero: false,
grid: {
color: 'rgba(71, 61, 49, 0.08)'
},
ticks: {
color: '#7f715f',
callback: formatPriceTick
}
},
x: {
grid: { display: false },
ticks: {
color: '#9b8a77',
maxRotation: 0,
autoSkip: true,
maxTicksLimit: 8
}
}
}
}
});
})
.catch(error => {
console.error('圖表載入失敗:', error);
setHistoryChartState('價格歷史載入失敗,請稍後再試。');
});
}
document.querySelectorAll('.dashboard-table tbody tr[data-product-id]').forEach(row => {
row.addEventListener('click', event => {
if (event.target.closest('a, button, [data-pchome-review-action]')) return;
showHistory(row.dataset.productId, row.dataset.productName);
});
});
document.querySelectorAll('[data-history-trigger]').forEach(button => {
button.addEventListener('click', event => {
event.stopPropagation();
showHistory(button.dataset.productId, button.dataset.productName);
});
});
document.querySelectorAll('[data-history-range]').forEach(button => {
button.addEventListener('click', () => {
if (!currentHistoryProductId) return;
showHistory(currentHistoryProductId, currentHistoryProductName, button.dataset.historyRange);
});
});
document.querySelectorAll('[data-dashboard-auto-submit]').forEach(select => {
select.addEventListener('change', () => {
if (select.form) {
select.form.submit();
}
});
});
const dashboardTaskMap = {
crawler: {
confirmText: '確定要手動執行全站爬蟲嗎?可能需要一段時間。',
url: '/api/run_task'
},
notification: {
confirmText: '確定要發送今日商品異動通知嗎?',
url: '/api/trigger_momo_notification'
}
};
function runDashboardTask(taskName) {
const task = dashboardTaskMap[taskName];
if (!task || !confirm(task.confirmText)) return;
fetch(task.url, {
method: 'POST',
headers: { 'X-CSRFToken': getCSRFToken() }
})
.then(response => response.json())
.then(data => alert(data.message))
.catch(error => alert('錯誤: ' + error));
}
document.querySelectorAll('[data-dashboard-task]').forEach(button => {
button.addEventListener('click', () => runDashboardTask(button.dataset.dashboardTask));
});
function getPchomeGrowthBackfillElements() {
return {
triggers: Array.from(document.querySelectorAll('[data-pchome-growth-backfill-trigger]')),
status: document.querySelector('[data-pchome-growth-backfill-status]'),
endpoint: '/api/ai/pchome-growth/backfill-momo-candidates'
};
}
function setGrowthBackfillStatus(message, tone) {
const elements = getPchomeGrowthBackfillElements();
if (!elements.status) return;
elements.status.textContent = message;
elements.status.classList.remove('is-success', 'is-warning', 'is-danger');
if (tone) {
elements.status.classList.add(`is-${tone}`);
}
}
function setGrowthBackfillBusy(isBusy) {
const elements = getPchomeGrowthBackfillElements();
elements.triggers.forEach(trigger => {
trigger.disabled = isBusy;
trigger.classList.toggle('is-loading', isBusy);
});
}
function renderGrowthBackfillResult(data) {
const payload = data && data.data ? data.data : {};
const sync = payload.external_offer_sync || {};
const written = Number(sync.written_count || 0);
const autoCount = Number(payload.auto_compare_count || 0);
const reviewCount = Number(payload.review_count || 0);
const candidateCount = Number(payload.candidate_count || 0);
const scanned = Number(payload.scanned_products || 0);
const tone = written > 0 ? 'success' : (candidateCount > 0 ? 'warning' : 'danger');
const message = (
`掃描 ${formatBackfillCount(scanned)} 個高業績品`
+ ` · 候選 ${formatBackfillCount(candidateCount)}`
+ ` · 可自動 ${formatBackfillCount(autoCount)}`
+ ` · 寫入 ${formatBackfillCount(written)}`
+ ` · 待覆核 ${formatBackfillCount(reviewCount)}`
);
setGrowthBackfillStatus(message, tone);
if (written > 0) {
setTimeout(() => window.location.reload(), 1200);
}
}
function backfillPchomeGrowthMomoCandidates(activeTrigger) {
const elements = getPchomeGrowthBackfillElements();
if (!elements.triggers.length) return;
const trigger = activeTrigger && activeTrigger.dataset ? activeTrigger : elements.triggers[0];
const limit = Number(trigger.dataset.limit || 12);
setGrowthBackfillBusy(true);
setGrowthBackfillStatus(`正在補 ${formatBackfillCount(limit)} 個高業績商品的 MOMO 對應`, '');
fetch(elements.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCSRFToken()
},
body: JSON.stringify({ limit })
})
.then(response => response.json().then(data => ({ ok: response.ok, data })))
.then(({ ok, data }) => {
if (!ok || !data.success) {
throw new Error(data.error || data.message || 'MOMO 對應補抓失敗');
}
renderGrowthBackfillResult(data);
})
.catch(error => {
setGrowthBackfillStatus(error.message || 'MOMO 對應補抓失敗', 'danger');
})
.finally(() => {
setGrowthBackfillBusy(false);
});
}
let pchomeBackfillPollTimer = null;
const DEFAULT_PCHOME_BACKFILL_LABEL = '補強 60 筆';
const DEFAULT_PCHOME_REFRESH_STALE_LABEL = '刷新過期 120 筆';
function getPchomeBackfillElements() {
const card = document.querySelector('[data-pchome-backfill-card]');
const triggers = Array.from(document.querySelectorAll('[data-pchome-backfill-trigger]'));
const refreshStaleTriggers = Array.from(document.querySelectorAll('[data-pchome-refresh-stale-trigger]'));
return {
card,
trigger: triggers[0],
triggers,
refreshStaleTrigger: refreshStaleTriggers[0],
refreshStaleTriggers,
status: document.querySelector('[data-pchome-backfill-status]'),
result: document.querySelector('[data-pchome-backfill-result]'),
progress: document.querySelector('[data-pchome-backfill-progress]'),
backfillEndpoint: card ? card.dataset.backfillEndpoint : '/api/ai/pchome-match/backfill',
refreshStaleEndpoint: card ? card.dataset.refreshStaleEndpoint : '/api/ai/pchome-match/refresh-stale',
statusEndpoint: card ? card.dataset.statusEndpoint : '/api/ai/pchome-match/backfill/status'
};
}
function formatBackfillCount(value) {
return Number(value || 0).toLocaleString();
}
function formatBackfillRate(value) {
const numeric = Number(value || 0);
if (!Number.isFinite(numeric)) return '0%';
return `${numeric.toFixed(1).replace(/\.0$/, '')}%`;
}
function formatBackfillLimitedCount(value, hasMore) {
const formatted = formatBackfillCount(value);
return hasMore ? `${formatted}+` : formatted;
}
function formatBackfillCoverageSummary(coverage) {
if (!coverage || coverage.available === false) return '';
const preview = coverage.revalidation_preview || {};
const previewAvailable = preview && preview.available !== false && preview.candidate_count !== undefined;
const previewText = previewAvailable
? ` · 可重評 ${formatBackfillLimitedCount(preview.candidate_count, preview.has_more)}`
: '';
const reviewGatedText = previewAvailable && Number(preview.review_gated_count || 0) > 0
? ` · 窄門 ${formatBackfillLimitedCount(preview.review_gated_count, false)}`
: '';
const staleRecovery = coverage.stale_recovery_preview || {};
const staleRecoveryAvailable = staleRecovery && staleRecovery.available !== false && staleRecovery.candidate_count !== undefined;
const staleRecoveryText = staleRecoveryAvailable
? ` · 可救援 ${formatBackfillLimitedCount(staleRecovery.candidate_count, staleRecovery.has_more)}`
: '';
const recommended = coverage.recommended_next_action || {};
const recommendedText = recommended.label ? ` · 建議 ${recommended.label}` : '';
return (
`決策支援 ${formatBackfillRate(coverage.decision_support_rate || coverage.decision_ready_rate)}`
+ ` · 精準可用 ${formatBackfillRate(coverage.decision_ready_rate)}`
+ ` · 身份 ${formatBackfillRate(coverage.match_rate)}`
+ ` · 新鮮 ${formatBackfillRate(coverage.fresh_match_rate)}`
+ ` · 型錄可比 ${formatBackfillCount(coverage.catalog_comparable_count)}`
+ ` · 單位價 ${formatBackfillCount(coverage.unit_comparable_count)}`
+ ` · 待刷新 ${formatBackfillCount(coverage.stale_matches)}`
+ ` · 待補抓 ${formatBackfillCount(coverage.pending)}`
+ previewText
+ reviewGatedText
+ staleRecoveryText
+ recommendedText
);
}
function formatBackfillStageSummary(result) {
if (!result || Object.keys(result).length === 0) return '';
const segments = [
['刷新', result.stale_identity_refresh],
['重評', result.retryable_candidate_revalidation],
['補抓', result.unmatched_priority_backfill]
].filter(([, payload]) => payload && Number(payload.total_skus || 0) > 0)
.map(([label, payload]) => (
`${label} ${formatBackfillCount(payload.matched)}/${formatBackfillCount(payload.total_skus)}`
));
return segments.length > 0 ? ` · ${segments.join(' · ')}` : '';
}
function schedulePchomeBackfillPoll() {
if (pchomeBackfillPollTimer) {
clearTimeout(pchomeBackfillPollTimer);
}
pchomeBackfillPollTimer = setTimeout(loadPchomeBackfillStatus, 5000);
}
function renderPchomeBackfillStatus(payload) {
const status = payload && payload.data ? payload.data : (payload || {});
const elements = getPchomeBackfillElements();
if (!elements.card) return;
const currentRun = status.current_run || {};
const result = currentRun.result || status.last_result || {};
const pickResult = currentRun.pick_result || {};
const coverageSummary = formatBackfillCoverageSummary(status.coverage);
const running = Boolean(status.running || currentRun.running);
const progressPct = Math.max(0, Math.min(Number(status.progress_pct || currentRun.progress_pct || 0), 100));
const statusKey = status.status || currentRun.status || 'idle';
const stageLabel = status.stage_label || currentRun.stage_label || '尚未執行';
const updatedAt = status.updated_at || currentRun.updated_at || currentRun.finished_at || '';
elements.card.dataset.status = statusKey;
if (elements.progress) {
elements.progress.style.width = `${progressPct}%`;
}
if (elements.status) {
elements.status.textContent = updatedAt ? `${stageLabel} · ${updatedAt}` : stageLabel;
}
if (elements.result) {
if (status.last_error || currentRun.last_error) {
elements.result.textContent = status.last_error || currentRun.last_error;
} else if (result && Object.keys(result).length > 0) {
const pickWritten = pickResult.written !== undefined ? ` · 挑品 ${formatBackfillCount(pickResult.written)}` : '';
const stageSummary = formatBackfillStageSummary(result);
elements.result.textContent = (
`比對 ${formatBackfillCount(result.total_skus)} · 成功 ${formatBackfillCount(result.matched)}`
+ stageSummary
+ ` · 待覆核 ${formatBackfillCount(result.skipped_low_score)}`
+ ` · 無結果 ${formatBackfillCount(result.skipped_no_result)}`
+ pickWritten
+ (coverageSummary ? ` · ${coverageSummary}` : '')
);
} else {
elements.result.textContent = running
? (coverageSummary ? `正在累積結果 · ${coverageSummary}` : '正在累積結果')
: (coverageSummary || '尚無最近結果');
}
}
elements.triggers.forEach(trigger => {
const limit = Number(trigger.dataset.limit || 60);
trigger.disabled = running;
trigger.classList.toggle('is-loading', running);
if (trigger.dataset.preserveLabel === 'true') {
return;
}
trigger.innerHTML = running
? '<i class="fas fa-spinner fa-spin"></i> 執行中'
: `<i class="fas fa-search"></i> 補強 ${limit}`;
});
elements.refreshStaleTriggers.forEach(trigger => {
const limit = Number(trigger.dataset.limit || 120);
trigger.disabled = running;
trigger.classList.toggle('is-loading', running);
trigger.innerHTML = running
? '<i class="fas fa-spinner fa-spin"></i> 執行中'
: `<i class="fas fa-rotate"></i> 刷新過期 ${limit}`;
});
if (running) {
schedulePchomeBackfillPoll();
} else if (pchomeBackfillPollTimer) {
clearTimeout(pchomeBackfillPollTimer);
pchomeBackfillPollTimer = null;
}
}
function loadPchomeBackfillStatus() {
const elements = getPchomeBackfillElements();
if (!elements.card) return Promise.resolve();
return fetch(elements.statusEndpoint, {
headers: { 'Accept': 'application/json' }
})
.then(response => response.json())
.then(renderPchomeBackfillStatus)
.catch(error => {
console.warn('[DashboardV2] PChome backfill status load failed:', error);
if (elements.status) {
elements.status.textContent = '狀態讀取失敗';
}
});
}
function backfillPchomeMatches(activeTrigger) {
const elements = getPchomeBackfillElements();
if (!elements.card || !elements.trigger) return;
const trigger = activeTrigger && activeTrigger.dataset ? activeTrigger : elements.trigger;
const limit = Number(trigger.dataset.limit || 60);
if (!confirm(`啟動 PChome 比價補強 ${limit} 筆?會先刷新舊 identity再重評近門檻與補抓未配對商品。`)) return;
trigger.disabled = true;
if (elements.status) {
elements.status.textContent = '正在送出比價補強任務';
}
fetch(elements.backfillEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCSRFToken()
},
body: JSON.stringify({ limit })
})
.then(response => response.json().then(data => ({ ok: response.ok, status: response.status, data })))
.then(({ ok, status, data }) => {
renderPchomeBackfillStatus(data);
if (!ok && status !== 409) {
throw new Error(data.message || data.error || 'PChome 比價補強啟動失敗');
}
schedulePchomeBackfillPoll();
})
.catch(error => {
if (elements.status) {
elements.status.textContent = error.message || 'PChome 比價補強啟動失敗';
}
trigger.disabled = false;
});
}
function refreshStalePchomeMatches(activeTrigger) {
const elements = getPchomeBackfillElements();
if (!elements.card || !elements.refreshStaleTrigger) return;
const trigger = activeTrigger && activeTrigger.dataset ? activeTrigger : elements.refreshStaleTrigger;
const limit = Number(trigger.dataset.limit || 120);
if (!confirm(`啟動 PChome 過期價格刷新 ${limit} 筆?`)) return;
trigger.disabled = true;
if (elements.status) {
elements.status.textContent = '正在送出過期價格刷新任務';
}
fetch(elements.refreshStaleEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCSRFToken()
},
body: JSON.stringify({ limit })
})
.then(response => response.json().then(data => ({ ok: response.ok, status: response.status, data })))
.then(({ ok, status, data }) => {
renderPchomeBackfillStatus(data);
if (!ok && status !== 409) {
throw new Error(data.message || data.error || 'PChome 過期價格刷新啟動失敗');
}
schedulePchomeBackfillPoll();
})
.catch(error => {
if (elements.status) {
elements.status.textContent = error.message || 'PChome 過期價格刷新啟動失敗';
}
trigger.disabled = false;
});
}
window.backfillPchomeMatches = backfillPchomeMatches;
window.refreshStalePchomeMatches = refreshStalePchomeMatches;
window.backfillPchomeGrowthMomoCandidates = backfillPchomeGrowthMomoCandidates;
document.querySelectorAll('[data-pchome-growth-backfill-trigger]').forEach(button => {
button.addEventListener('click', () => backfillPchomeGrowthMomoCandidates(button));
});
document.querySelectorAll('[data-pchome-backfill-trigger]').forEach(button => {
button.addEventListener('click', () => backfillPchomeMatches(button));
});
document.querySelectorAll('[data-pchome-refresh-stale-trigger]').forEach(button => {
button.addEventListener('click', () => refreshStalePchomeMatches(button));
});
loadPchomeBackfillStatus();
function runPchomeReviewDecision(button) {
const sku = button.dataset.reviewSku || '';
const action = button.dataset.reviewAction || '';
if (!sku || !action) return;
const confirmText = button.dataset.reviewConfirm || '確認寫入這筆覆核決策?';
if (!confirm(confirmText)) return;
const reason = prompt('補充覆核原因(可留空)', '') || '';
const originalText = button.textContent;
button.disabled = true;
button.textContent = '寫入中';
fetch(`/api/pchome-review/${encodeURIComponent(sku)}/decision`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCSRFToken()
},
body: JSON.stringify({ action, reason })
})
.then(response => response.json().then(data => ({ ok: response.ok, data })))
.then(({ ok, data }) => {
if (!ok || !data.success) {
throw new Error(data.message || '覆核寫入失敗');
}
alert(data.message || '覆核已寫入');
window.location.reload();
})
.catch(error => {
alert('錯誤: ' + error.message);
button.disabled = false;
button.textContent = originalText;
});
}
document.querySelectorAll('[data-pchome-review-action]').forEach(button => {
button.addEventListener('click', event => {
event.stopPropagation();
runPchomeReviewDecision(button);
});
});
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);