Files
ewoooc/templates/pchome_crawler.html
OoO a9b5385615
All checks were successful
CD Pipeline / deploy (push) Successful in 1m4s
fix: 收斂 PChome 工具頁新版樣式
2026-05-17 22:13:46 +08:00

563 lines
20 KiB
HTML

{% extends "ewoooc_base.html" %}
{% block title %}PChome 爬蟲 - EwoooC{% endblock %}
{% block extra_css %}
<style>
.pchome-tool-page {
color: var(--momo-text-primary);
}
.pchome-tool-head {
padding: var(--momo-space-4) var(--momo-space-5);
background: var(--momo-bg-surface);
border: 1px solid var(--momo-border-light);
border-radius: var(--momo-radius-md);
}
.pchome-tool-head h2 {
margin: 0;
color: var(--momo-text-primary);
font-family: var(--momo-font-display);
font-size: var(--momo-text-headline);
font-weight: 800;
letter-spacing: 0;
}
.pchome-tool-head p,
.pchome-tool-page .text-muted {
color: var(--momo-text-secondary) !important;
}
.pchome-card-head {
background: var(--momo-bg-paper);
border-bottom: 1px solid var(--momo-border-light);
color: var(--momo-text-primary);
font-family: var(--momo-font-display);
font-weight: 800;
}
.pchome-table-head th {
background: var(--momo-bg-paper) !important;
color: var(--momo-text-secondary) !important;
font-family: var(--momo-font-mono, monospace);
font-size: var(--momo-text-label);
font-weight: 800;
letter-spacing: 0.04em;
}
.pchome-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 22px;
padding: 3px 8px;
border: 1px solid var(--momo-border-light);
border-radius: var(--momo-radius-sm);
font-family: var(--momo-font-mono, monospace);
font-size: var(--momo-text-label);
font-weight: 800;
line-height: 1;
}
.pchome-badge.is-danger {
background: var(--momo-danger-bg);
border-color: var(--momo-danger-border);
color: var(--momo-danger-text);
}
.pchome-badge.is-success {
background: var(--momo-success-bg);
border-color: var(--momo-success-border);
color: var(--momo-success-text);
}
.pchome-badge.is-muted {
background: var(--momo-tag-muted-bg);
border-color: var(--momo-tag-muted-border);
color: var(--momo-tag-muted-text);
}
.pchome-toast {
top: 20px;
right: 20px;
z-index: 9999;
min-width: min(300px, calc(100vw - 40px));
padding: 12px 14px;
border: 1px solid var(--momo-border-light);
border-radius: var(--momo-radius-md);
background: var(--momo-bg-elevated);
color: var(--momo-text-primary);
font-weight: 700;
}
.pchome-toast--success {
background: var(--momo-success-bg);
border-color: var(--momo-success-border);
color: var(--momo-success-text);
}
.pchome-toast--danger {
background: var(--momo-danger-bg);
border-color: var(--momo-danger-border);
color: var(--momo-danger-text);
}
.pchome-toast--warning {
background: var(--momo-warning-bg);
border-color: var(--momo-warning-border);
color: var(--momo-warning-text);
}
@media (max-width: 760px) {
.pchome-tool-head {
padding: var(--momo-space-4);
}
.pchome-tool-page .card-header {
align-items: flex-start !important;
flex-direction: column;
gap: var(--momo-space-2);
}
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4 pchome-tool-page">
<header class="pchome-tool-head mb-4">
<h2><i class="fas fa-spider me-2"></i>PChome 24h 爬蟲</h2>
<p class="text-muted">爬取 PChome 24h 商品資料</p>
</header>
<!-- 爬取方式選擇 -->
<div class="row mb-4">
<div class="col-lg-6">
<div class="card">
<div class="card-header pchome-card-head">
<i class="fas fa-folder-open me-2"></i>館別爬取
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">選擇館別分類</label>
<select class="form-select" id="categorySelect">
<option value="">-- 選擇分類 --</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">選擇館別</label>
<select class="form-select" id="regionSelect" disabled>
<option value="">-- 先選擇分類 --</option>
</select>
</div>
<button class="btn btn-primary" id="crawlRegionBtn" disabled>
<i class="fas fa-download me-1"></i>開始爬取
</button>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card">
<div class="card-header pchome-card-head">
<i class="fas fa-search me-2"></i>關鍵字搜尋
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">搜尋關鍵字</label>
<input type="text" class="form-control" id="searchKeyword" placeholder="輸入商品關鍵字...">
</div>
<div class="mb-3">
<label class="form-label">最多筆數</label>
<select class="form-select" id="searchLimit">
<option value="20">20 筆</option>
<option value="50" selected>50 筆</option>
<option value="100">100 筆</option>
</select>
</div>
<button class="btn btn-primary" id="searchBtn">
<i class="fas fa-search me-1"></i>搜尋
</button>
</div>
</div>
</div>
</div>
<!-- 自訂 URL -->
<div class="row mb-4">
<div class="col">
<div class="card">
<div class="card-header pchome-card-head">
<i class="fas fa-link me-2"></i>自訂 URL 爬取
</div>
<div class="card-body">
<div class="row align-items-end">
<div class="col-lg-9 mb-2 mb-lg-0">
<label class="form-label">PChome 24h 頁面 URL</label>
<input type="url" class="form-control" id="customUrl"
placeholder="https://24h.pchome.com.tw/region/DDAB">
</div>
<div class="col-lg-3">
<button class="btn btn-primary w-100" id="crawlCustomBtn">
<i class="fas fa-download me-1"></i>爬取
</button>
</div>
</div>
</div>
</div>
</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">
<span class="visually-hidden">載入中...</span>
</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="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fas fa-list me-2"></i>爬取結果 (<span id="resultCount">0</span> 筆)</span>
<div>
<button class="btn btn-sm btn-outline-primary me-2" id="exportExcelBtn">
<i class="fas fa-file-excel me-1"></i>匯出 Excel
</button>
<button class="btn btn-sm btn-outline-secondary" id="exportJsonBtn">
<i class="fas fa-file-code me-1"></i>匯出 JSON
</button>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover mb-0" id="resultTable">
<thead class="pchome-table-head">
<tr>
<th style="width: 80px;">圖片</th>
<th>商品名稱</th>
<th style="width: 100px;">售價</th>
<th style="width: 100px;">原價</th>
<th style="width: 80px;">折扣</th>
<th style="width: 80px;">庫存</th>
<th style="width: 100px;">操作</th>
</tr>
</thead>
<tbody id="resultBody">
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
let regionsData = {};
let categoriesData = {};
let currentProducts = [];
document.addEventListener('DOMContentLoaded', function() {
loadRegions();
bindEvents();
});
async function loadRegions() {
try {
const response = await fetch('/api/pchome/regions');
const data = await response.json();
if (data.success) {
regionsData = data.data.regions;
categoriesData = data.data.categories;
// 填充分類下拉選單
const categorySelect = document.getElementById('categorySelect');
for (const cat of Object.keys(categoriesData)) {
const option = document.createElement('option');
option.value = cat;
option.textContent = cat;
categorySelect.appendChild(option);
}
}
} catch (error) {
console.error('載入館別失敗:', error);
}
}
function bindEvents() {
// 分類選擇變更
document.getElementById('categorySelect').addEventListener('change', function() {
const category = this.value;
const regionSelect = document.getElementById('regionSelect');
const crawlBtn = document.getElementById('crawlRegionBtn');
regionSelect.innerHTML = '<option value="">-- 選擇館別 --</option>';
if (category && categoriesData[category]) {
for (const region of categoriesData[category]) {
const option = document.createElement('option');
option.value = region.code;
option.textContent = `${region.code} - ${region.name}`;
regionSelect.appendChild(option);
}
regionSelect.disabled = false;
} else {
regionSelect.disabled = true;
}
crawlBtn.disabled = true;
});
// 館別選擇變更
document.getElementById('regionSelect').addEventListener('change', function() {
document.getElementById('crawlRegionBtn').disabled = !this.value;
});
// 館別爬取
document.getElementById('crawlRegionBtn').addEventListener('click', crawlRegion);
// 搜尋
document.getElementById('searchBtn').addEventListener('click', searchProducts);
document.getElementById('searchKeyword').addEventListener('keypress', function(e) {
if (e.key === 'Enter') searchProducts();
});
// 自訂 URL 爬取
document.getElementById('crawlCustomBtn').addEventListener('click', crawlCustomUrl);
// 匯出
document.getElementById('exportExcelBtn').addEventListener('click', exportExcel);
document.getElementById('exportJsonBtn').addEventListener('click', exportJson);
}
async function crawlRegion() {
const regionCode = document.getElementById('regionSelect').value;
if (!regionCode) return;
const regionName = regionsData[regionCode]?.name || regionCode;
showProgress(`正在爬取 ${regionName}...`, '取得商品列表中');
try {
const response = await fetch('/api/pchome/crawl/region', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ region_code: regionCode })
});
const data = await response.json();
hideProgress();
if (data.success) {
currentProducts = data.data.products;
showResults(currentProducts);
showToast(`成功爬取 ${currentProducts.length} 個商品`, 'success');
} else {
showToast(data.message, 'danger');
}
} catch (error) {
hideProgress();
showToast('爬取失敗: ' + error.message, 'danger');
}
}
async function searchProducts() {
const keyword = document.getElementById('searchKeyword').value.trim();
if (!keyword) {
showToast('請輸入搜尋關鍵字', 'warning');
return;
}
const limit = parseInt(document.getElementById('searchLimit').value);
showProgress(`搜尋 "${keyword}"...`, '查詢中');
try {
const response = await fetch('/api/pchome/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keyword, limit })
});
const data = await response.json();
hideProgress();
if (data.success) {
currentProducts = data.data.products;
showResults(currentProducts);
showToast(`找到 ${currentProducts.length} 個商品`, 'success');
} else {
showToast(data.message, 'danger');
}
} catch (error) {
hideProgress();
showToast('搜尋失敗: ' + error.message, 'danger');
}
}
async function crawlCustomUrl() {
const url = document.getElementById('customUrl').value.trim();
if (!url) {
showToast('請輸入 URL', 'warning');
return;
}
if (!url.startsWith('https://24h.pchome.com.tw/')) {
showToast('僅支援 PChome 24h 網站', 'warning');
return;
}
showProgress(`爬取 URL...`, url);
try {
const response = await fetch('/api/pchome/crawl/custom', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
const data = await response.json();
hideProgress();
if (data.success) {
currentProducts = data.data.products;
showResults(currentProducts);
showToast(`成功爬取 ${currentProducts.length} 個商品`, 'success');
} else {
showToast(data.message, 'danger');
}
} catch (error) {
hideProgress();
showToast('爬取失敗: ' + error.message, 'danger');
}
}
function showProgress(title, detail) {
document.getElementById('progressText').textContent = title;
document.getElementById('progressDetail').textContent = detail;
document.getElementById('progressSection').style.display = 'block';
document.getElementById('resultSection').style.display = 'none';
}
function hideProgress() {
document.getElementById('progressSection').style.display = 'none';
}
function showResults(products) {
document.getElementById('resultCount').textContent = products.length;
document.getElementById('resultSection').style.display = 'block';
const tbody = document.getElementById('resultBody');
tbody.innerHTML = '';
for (const p of products) {
const discount = p.discount ? `<span class="pchome-badge is-danger">-${p.discount}%</span>` : '-';
const stockBadge = p.stock > 0
? `<span class="pchome-badge is-success">${p.stock}</span>`
: `<span class="pchome-badge is-muted">缺貨</span>`;
const row = document.createElement('tr');
row.innerHTML = `
<td>
<img src="${p.image_url}?width=80" alt="" class="img-thumbnail"
style="width: 60px; height: 60px; object-fit: cover;"
onerror="this.src='/static/images/no-image.png'">
</td>
<td>
<a href="${p.product_url}" target="_blank" class="text-decoration-none">
${escapeHtml(p.name.substring(0, 50))}${p.name.length > 50 ? '...' : ''}
</a>
<br>
<small class="text-muted">${p.product_id}</small>
</td>
<td class="text-danger fw-bold">$${p.price.toLocaleString()}</td>
<td class="text-muted"><s>$${p.original_price.toLocaleString()}</s></td>
<td>${discount}</td>
<td>${stockBadge}</td>
<td>
<a href="${p.product_url}" target="_blank" class="btn btn-sm btn-outline-primary">
<i class="fas fa-external-link-alt"></i>
</a>
</td>
`;
tbody.appendChild(row);
}
}
function exportExcel() {
if (!currentProducts.length) {
showToast('沒有資料可匯出', 'warning');
return;
}
// 建立 CSV
const headers = ['商品ID', '名稱', '售價', '原價', '折扣%', '庫存', '圖片URL', '商品URL'];
const rows = currentProducts.map(p => [
p.product_id,
`"${p.name.replace(/"/g, '""')}"`,
p.price,
p.original_price,
p.discount || '',
p.stock,
p.image_url,
p.product_url
]);
const csv = '\uFEFF' + [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
downloadFile(csv, 'pchome_products.csv', 'text/csv;charset=utf-8');
}
function exportJson() {
if (!currentProducts.length) {
showToast('沒有資料可匯出', 'warning');
return;
}
const json = JSON.stringify(currentProducts, null, 2);
downloadFile(json, 'pchome_products.json', 'application/json');
}
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 showToast(message, type = 'info') {
// 簡易 Toast
const toast = document.createElement('div');
toast.className = `pchome-toast pchome-toast--${type} position-fixed`;
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 %}