Files
ewoooc/templates/price_comparison.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

633 lines
26 KiB
HTML

{% extends "base.html" %}
{% block title %}比價系統 - Momo Pro{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="row mb-4">
<div class="col">
<h2><i class="fas fa-balance-scale me-2"></i>PChome vs MOMO 比價</h2>
<p class="text-muted">比較 PChome 24h 和 MOMO 美妝商品價格</p>
</div>
</div>
<!-- 操作區 -->
<div class="row mb-4">
<!-- Step 1: 選擇品牌 -->
<div class="col-lg-4 mb-3">
<div class="card h-100">
<div class="card-header bg-primary text-white">
<i class="fas fa-tag me-2"></i>Step 1: 選擇品牌
</div>
<div class="card-body">
<div class="mb-3">
<select class="form-select" id="brandSelect">
<option value="">-- 選擇品牌 --</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">或輸入品牌/關鍵字</label>
<input type="text" class="form-control" id="customKeyword" placeholder="如: 理膚寶水、雅漾">
</div>
</div>
</div>
</div>
<!-- Step 2: 取得 PChome 商品 -->
<div class="col-lg-4 mb-3">
<div class="card h-100">
<div class="card-header bg-info text-white">
<i class="fas fa-shopping-cart me-2"></i>Step 2: PChome 商品
</div>
<div class="card-body">
<button class="btn btn-info w-100 mb-2" id="fetchPchomeBtn">
<i class="fas fa-download me-1"></i>自動爬取 PChome
</button>
<div class="text-center my-2"><small class="text-muted"></small></div>
<button class="btn btn-outline-info w-100" id="manualPchomeBtn" data-bs-toggle="modal" data-bs-target="#manualInputModal" data-source="pchome">
<i class="fas fa-keyboard me-1"></i>手動輸入
</button>
<div class="mt-3">
<span class="badge bg-secondary" id="pchomeCount">0 筆商品</span>
</div>
</div>
</div>
</div>
<!-- Step 3: 取得 MOMO 商品 -->
<div class="col-lg-4 mb-3">
<div class="card h-100">
<div class="card-header bg-warning text-dark">
<i class="fas fa-store me-2"></i>Step 3: MOMO 商品
</div>
<div class="card-body">
<button class="btn btn-warning w-100 mb-2" id="uploadMomoBtn" data-bs-toggle="modal" data-bs-target="#uploadModal">
<i class="fas fa-file-excel me-1"></i>上傳 Excel
</button>
<div class="text-center my-2"><small class="text-muted"></small></div>
<button class="btn btn-outline-warning w-100" id="manualMomoBtn" data-bs-toggle="modal" data-bs-target="#manualInputModal" data-source="momo">
<i class="fas fa-keyboard me-1"></i>手動輸入
</button>
<div class="mt-3">
<span class="badge bg-secondary" id="momoCount">0 筆商品</span>
</div>
</div>
</div>
</div>
</div>
<!-- 比價按鈕 -->
<div class="row mb-4">
<div class="col text-center">
<button class="btn btn-lg btn-success px-5" id="compareBtn" disabled>
<i class="fas fa-balance-scale me-2"></i>開始比價
</button>
</div>
</div>
<!-- 進度顯示 -->
<div class="row mb-4" id="progressSection" style="display: none;">
<div class="col">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="spinner-border text-primary me-3" role="status"></div>
<div>
<strong id="progressText">處理中...</strong>
<br><small class="text-muted" id="progressDetail"></small>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 比價結果 -->
<div class="row" id="resultSection" style="display: none;">
<div class="col">
<!-- 統計卡片 -->
<div class="row mb-4">
<div class="col-md-3 mb-3">
<div class="card bg-light">
<div class="card-body text-center">
<h4 class="mb-0" id="matchedCount">0</h4>
<small class="text-muted">成功匹配</small>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card bg-info text-white">
<div class="card-body text-center">
<h4 class="mb-0" id="pchomeCheaperCount">0</h4>
<small>PChome 較便宜</small>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card bg-warning text-dark">
<div class="card-body text-center">
<h4 class="mb-0" id="momoCheaperCount">0</h4>
<small>MOMO 較便宜</small>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card bg-secondary text-white">
<div class="card-body text-center">
<h4 class="mb-0" id="avgPriceDiff">$0</h4>
<small>平均價差</small>
</div>
</div>
</div>
</div>
<!-- 結果表格 -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fas fa-list me-2"></i>比價結果</span>
<div>
<button class="btn btn-sm btn-outline-primary" id="exportResultBtn">
<i class="fas fa-file-excel me-1"></i>匯出 Excel
</button>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover table-striped mb-0">
<thead class="table-dark">
<tr>
<th style="width: 35%;">PChome 商品</th>
<th style="width: 35%;">MOMO 商品</th>
<th class="text-center">相似度</th>
<th class="text-end">PChome 價</th>
<th class="text-end">MOMO 價</th>
<th class="text-end">價差</th>
<th class="text-center">推薦</th>
</tr>
</thead>
<tbody id="resultBody">
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 上傳 Excel Modal -->
<div class="modal fade" id="uploadModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-file-excel me-2"></i>上傳 MOMO 商品 Excel</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">選擇 Excel 檔案</label>
<input type="file" class="form-control" id="momoExcelFile" accept=".xlsx,.xls,.csv">
</div>
<div class="alert alert-info">
<i class="fas fa-info-circle me-1"></i>
Excel 需包含「商品名稱」和「售價」欄位。可選包含「商品編號」和「連結」。
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-warning" id="parseExcelBtn">
<i class="fas fa-upload me-1"></i>上傳並解析
</button>
</div>
</div>
</div>
</div>
<!-- 手動輸入 Modal -->
<div class="modal fade" id="manualInputModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-keyboard me-2"></i>手動輸入商品</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-info py-2">
<i class="fas fa-info-circle me-1"></i>
<strong>格式說明:</strong> 每行一筆商品,用<strong>逗號</strong><strong>Tab</strong> 分隔<br>
<code>商品名稱,價格</code><code>商品名稱,價格,商品連結(選填)</code>
</div>
<div class="mb-3">
<label class="form-label">商品資料</label>
<textarea class="form-control" id="manualInput" rows="10" placeholder="理膚寶水 多容安舒緩濕潤乳液 40ml,850,https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=12345
理膚寶水 B5全面修復霜 100ml,680
La Roche-Posay 安得利防曬液 50ml,920
也可以直接從 Excel 複製貼上 (Tab 分隔)"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" id="parseManualBtn">
<i class="fas fa-check me-1"></i>確認
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
let pchomeProducts = [];
let momoProducts = [];
let comparisonResult = null;
let currentManualSource = null;
document.addEventListener('DOMContentLoaded', function() {
loadBrands();
bindEvents();
});
async function loadBrands() {
try {
const response = await fetchWithCSRF('/api/price_comparison/brands');
const data = await response.json();
if (data.success) {
const select = document.getElementById('brandSelect');
for (const brand of data.data.brands) {
const option = document.createElement('option');
option.value = brand.code;
option.textContent = brand.name;
select.appendChild(option);
}
}
} catch (error) {
console.error('載入品牌失敗:', error);
}
}
function bindEvents() {
// 品牌選擇
document.getElementById('brandSelect').addEventListener('change', function() {
const selected = this.options[this.selectedIndex];
if (selected.value) {
document.getElementById('customKeyword').value = selected.textContent;
}
});
// 爬取 PChome
document.getElementById('fetchPchomeBtn').addEventListener('click', fetchPchome);
// 上傳 Excel
document.getElementById('parseExcelBtn').addEventListener('click', parseMomoExcel);
// 手動輸入來源
document.querySelectorAll('[data-bs-target="#manualInputModal"]').forEach(btn => {
btn.addEventListener('click', function() {
currentManualSource = this.dataset.source;
});
});
// 解析手動輸入
document.getElementById('parseManualBtn').addEventListener('click', parseManualInput);
// 比價
document.getElementById('compareBtn').addEventListener('click', runComparison);
// 匯出
document.getElementById('exportResultBtn').addEventListener('click', exportResult);
}
function getKeyword() {
const custom = document.getElementById('customKeyword').value.trim();
if (custom) return custom;
const select = document.getElementById('brandSelect');
return select.options[select.selectedIndex]?.textContent || '';
}
async function fetchPchome() {
const keyword = getKeyword();
if (!keyword) {
showToast('請先選擇品牌或輸入關鍵字', 'warning');
return;
}
showProgress('爬取 PChome...', `搜尋: ${keyword}`);
try {
const response = await fetchWithCSRF('/api/price_comparison/fetch_pchome', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keyword, limit: 100 })
});
const data = await response.json();
hideProgress();
if (data.success) {
pchomeProducts = data.data.products;
document.getElementById('pchomeCount').textContent = `${pchomeProducts.length} 筆商品`;
document.getElementById('pchomeCount').classList.replace('bg-secondary', 'bg-info');
updateCompareButton();
showToast(`成功取得 ${pchomeProducts.length} 筆 PChome 商品`, 'success');
} else {
showToast(data.message, 'danger');
}
} catch (error) {
hideProgress();
showToast('爬取失敗: ' + error.message, 'danger');
}
}
async function parseMomoExcel() {
const fileInput = document.getElementById('momoExcelFile');
if (!fileInput.files.length) {
showToast('請選擇檔案', 'warning');
return;
}
const formData = new FormData();
formData.append('file', fileInput.files[0]);
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch('/api/price_comparison/parse_momo_excel', {
method: 'POST',
headers: { 'X-CSRFToken': csrfToken },
body: formData
});
const data = await response.json();
if (data.success) {
momoProducts = data.data.products;
document.getElementById('momoCount').textContent = `${momoProducts.length} 筆商品`;
document.getElementById('momoCount').classList.replace('bg-secondary', 'bg-warning');
updateCompareButton();
bootstrap.Modal.getInstance(document.getElementById('uploadModal')).hide();
showToast(`成功解析 ${momoProducts.length} 筆 MOMO 商品`, 'success');
} else {
showToast(data.message, 'danger');
}
} catch (error) {
showToast('解析失敗: ' + error.message, 'danger');
}
}
function parseManualInput() {
const input = document.getElementById('manualInput').value.trim();
if (!input) {
showToast('請輸入商品資料', 'warning');
return;
}
const lines = input.split('\n');
const products = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
// 支援逗號或 Tab 分隔
let parts = line.split(',');
if (parts.length < 2) {
parts = line.split('\t');
}
if (parts.length < 2) {
showToast(`${i + 1} 行格式錯誤: 請使用「商品名稱,價格」格式<br><small>例如: 理膚寶水 B5修復霜,680</small><br><small>您輸入的: ${escapeHtml(line.substring(0, 50))}...</small>`, 'warning');
return;
}
// 驗證價格是否為數字
const price = parseInt(parts[1].trim());
if (isNaN(price) || price <= 0) {
showToast(`${i + 1} 行價格格式錯誤: 「${escapeHtml(parts[1].trim())}」不是有效的價格<br><small>請輸入數字,例如: 680</small>`, 'warning');
return;
}
const rawUrl = parts[2]?.trim() || '';
const productId = currentManualSource === 'momo'
? extractMomoCodeFromUrl(rawUrl)
: `manual_${i}`;
products.push({
name: parts[0].trim(),
price: price,
product_id: productId,
url: rawUrl
});
}
if (currentManualSource === 'pchome') {
pchomeProducts = products;
document.getElementById('pchomeCount').textContent = `${products.length} 筆商品`;
document.getElementById('pchomeCount').classList.replace('bg-secondary', 'bg-info');
} else {
momoProducts = products;
document.getElementById('momoCount').textContent = `${products.length} 筆商品`;
document.getElementById('momoCount').classList.replace('bg-secondary', 'bg-warning');
}
updateCompareButton();
bootstrap.Modal.getInstance(document.getElementById('manualInputModal')).hide();
document.getElementById('manualInput').value = '';
showToast(`成功新增 ${products.length} 筆商品`, 'success');
}
function updateCompareButton() {
const btn = document.getElementById('compareBtn');
btn.disabled = !(pchomeProducts.length > 0 && momoProducts.length > 0);
}
async function runComparison() {
showProgress('比價中...', '分析商品相似度');
try {
const response = await fetchWithCSRF('/api/price_comparison/quick_compare', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pchome_products: pchomeProducts,
momo_products: momoProducts
})
});
const data = await response.json();
hideProgress();
if (data.success) {
comparisonResult = data.data;
displayResult(comparisonResult);
showToast(`比價完成,匹配 ${comparisonResult.matched_count}`, 'success');
} else {
showToast(data.message, 'danger');
}
} catch (error) {
hideProgress();
showToast('比價失敗: ' + error.message, 'danger');
}
}
function displayResult(result) {
// 更新統計
document.getElementById('matchedCount').textContent = result.matched_count;
document.getElementById('pchomeCheaperCount').textContent = result.stats.pchome_cheaper_count;
document.getElementById('momoCheaperCount').textContent = result.stats.momo_cheaper_count;
document.getElementById('avgPriceDiff').textContent = `$${result.stats.avg_price_diff}`;
// 建立表格
const tbody = document.getElementById('resultBody');
tbody.innerHTML = '';
for (const m of result.matches) {
const row = document.createElement('tr');
const similarityClass = m.similarity >= 0.8 ? 'text-success' : (m.similarity >= 0.6 ? 'text-warning' : 'text-danger');
const cheaperBadge = m.cheaper_at === 'pchome'
? '<span class="badge bg-info">PChome</span>'
: (m.cheaper_at === 'momo' ? '<span class="badge bg-warning text-dark">MOMO</span>' : '<span class="badge bg-secondary">相同</span>');
// 處理 URL (可能是 url 或 product_url)
const pchomeUrl = m.pchome.url || m.pchome.product_url || '';
const momoUrl = m.momo.url || m.momo.product_url || '';
row.innerHTML = `
<td>
<div class="d-flex align-items-start">
<div class="flex-grow-1">
<small class="text-truncate d-block" style="max-width: 220px;" title="${escapeHtml(m.pchome.name)}">
${escapeHtml(m.pchome.name)}
</small>
<small class="text-muted">${m.pchome.product_id || ''}</small>
</div>
${pchomeUrl ? `<a href="${pchomeUrl}" target="_blank" class="btn btn-sm btn-outline-info ms-1" title="前往 PChome 查看"><i class="fas fa-external-link-alt"></i></a>` : ''}
</div>
</td>
<td>
<div class="d-flex align-items-start">
<div class="flex-grow-1">
<small class="text-truncate d-block" style="max-width: 220px;" title="${escapeHtml(m.momo.name)}">
${escapeHtml(m.momo.name)}
</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 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}">
<strong>${Math.round(m.similarity * 100)}%</strong>
</td>
<td class="text-end ${m.cheaper_at === 'pchome' ? 'text-success fw-bold' : ''}">
$${m.pchome.price.toLocaleString()}
</td>
<td class="text-end ${m.cheaper_at === 'momo' ? 'text-warning fw-bold' : ''}">
$${m.momo.price.toLocaleString()}
</td>
<td class="text-end ${m.price_diff < 0 ? 'text-success' : (m.price_diff > 0 ? 'text-danger' : '')}">
${m.price_diff > 0 ? '+' : ''}$${m.price_diff}
<br><small>(${m.price_diff_percent}%)</small>
</td>
<td class="text-center">
${cheaperBadge}
</td>
`;
tbody.appendChild(row);
}
document.getElementById('resultSection').style.display = 'block';
}
function exportResult() {
if (!comparisonResult || !comparisonResult.matches.length) {
showToast('沒有資料可匯出', 'warning');
return;
}
const headers = ['PChome商品', 'MOMO商品', '相似度%', 'PChome價', 'MOMO價', '價差', '較便宜'];
const rows = comparisonResult.matches.map(m => [
`"${m.pchome.name.replace(/"/g, '""')}"`,
`"${m.momo.name.replace(/"/g, '""')}"`,
Math.round(m.similarity * 100),
m.pchome.price,
m.momo.price,
m.price_diff,
m.cheaper_at
]);
const csv = '\uFEFF' + [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
downloadFile(csv, 'price_comparison.csv', 'text/csv;charset=utf-8');
}
function showProgress(title, detail) {
document.getElementById('progressText').textContent = title;
document.getElementById('progressDetail').textContent = detail;
document.getElementById('progressSection').style.display = 'block';
}
function hideProgress() {
document.getElementById('progressSection').style.display = 'none';
}
function downloadFile(content, filename, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
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`;
toast.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
toast.innerHTML = `
<button type="button" class="btn-close float-end" onclick="this.parentElement.remove()"></button>
${message}
`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 5000);
}
</script>
{% endblock %}