Initial commit: HBR 文章爬蟲專案

- Scrapy 爬蟲框架,爬取 HBR 繁體中文文章
- Flask Web 應用程式,提供文章查詢介面
- SQL Server 資料庫整合
- 自動化排程與郵件通知功能

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-03 17:19:56 +08:00
commit f524713cb6
35 changed files with 6719 additions and 0 deletions

727
static/app.js Normal file
View File

@@ -0,0 +1,727 @@
// 全域變數
let currentPage = 1;
let currentFilters = {};
// 初始化
document.addEventListener('DOMContentLoaded', function() {
try {
loadStatistics();
loadCategories();
loadArticles();
loadCrawlerConfig();
// 綁定事件
const searchBtn = document.getElementById('searchBtn');
const resetBtn = document.getElementById('resetBtn');
const refreshBtn = document.getElementById('refreshBtn');
const runCrawlerBtn = document.getElementById('runCrawlerBtn');
const closeBtn = document.querySelector('.close');
if (searchBtn) searchBtn.addEventListener('click', handleSearch);
if (resetBtn) resetBtn.addEventListener('click', handleReset);
if (refreshBtn) {
refreshBtn.addEventListener('click', function() {
loadStatistics();
loadArticles();
});
}
if (runCrawlerBtn) runCrawlerBtn.addEventListener('click', handleRunCrawler);
// 模態框關閉
if (closeBtn) {
closeBtn.addEventListener('click', function() {
const modal = document.getElementById('articleModal');
if (modal) modal.style.display = 'none';
});
}
window.addEventListener('click', function(event) {
const modal = document.getElementById('articleModal');
if (event.target === modal && modal) {
modal.style.display = 'none';
}
});
// 頁籤切換
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', function() {
const tabName = this.getAttribute('data-tab');
switchTab(tabName);
});
});
// 爬蟲設定相關事件
const addUrlBtn = document.getElementById('addUrlBtn');
const saveConfigBtn = document.getElementById('saveConfigBtn');
const loadConfigBtn = document.getElementById('loadConfigBtn');
const resetConfigBtn = document.getElementById('resetConfigBtn');
const testConfigBtn = document.getElementById('testConfigBtn');
if (addUrlBtn) addUrlBtn.addEventListener('click', addUrlItem);
if (saveConfigBtn) saveConfigBtn.addEventListener('click', saveCrawlerConfig);
if (loadConfigBtn) loadConfigBtn.addEventListener('click', loadCrawlerConfig);
if (resetConfigBtn) resetConfigBtn.addEventListener('click', resetCrawlerConfig);
if (testConfigBtn) testConfigBtn.addEventListener('click', testCrawlerConfig);
} catch (error) {
console.error('初始化失敗:', error);
}
});
// 載入統計資料
async function loadStatistics() {
try {
const response = await fetch('/api/statistics');
const result = await response.json();
if (result.success) {
const stats = result.data;
document.getElementById('totalArticles').textContent = stats.total_articles || 0;
document.getElementById('paywalledArticles').textContent = stats.paywall[1] || 0;
document.getElementById('freeArticles').textContent = stats.paywall[0] || 0;
document.getElementById('categoryCount').textContent = stats.categories.length || 0;
// 繪製圖表
drawCategoryChart(stats.categories);
drawAuthorChart(stats.authors);
}
} catch (error) {
console.error('載入統計資料失敗:', error);
}
}
// 載入分類列表
async function loadCategories() {
try {
const response = await fetch('/api/categories');
const result = await response.json();
if (result.success) {
const categorySelect = document.getElementById('category');
result.data.forEach(category => {
const option = document.createElement('option');
option.value = category;
option.textContent = category;
categorySelect.appendChild(option);
});
}
} catch (error) {
console.error('載入分類列表失敗:', error);
}
}
// 載入文章列表
async function loadArticles(page = 1) {
currentPage = page;
showLoading(true);
try {
const params = new URLSearchParams({
page: page,
per_page: 20
});
// 加入篩選條件
Object.keys(currentFilters).forEach(key => {
if (currentFilters[key]) {
params.append(key, currentFilters[key]);
}
});
const response = await fetch(`/api/articles?${params}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
console.log('API 返回結果:', result); // 除錯用
if (result.success) {
const articles = result.data || [];
console.log('文章數量:', articles.length); // 除錯用
displayArticles(articles);
if (result.pagination) {
displayPagination(result.pagination);
}
} else {
console.error('API 返回錯誤:', result.error);
const errorMsg = result.error || '未知錯誤';
alert('載入文章失敗: ' + errorMsg);
}
} catch (error) {
console.error('載入文章失敗:', error);
alert('載入文章失敗,請稍後再試: ' + error.message);
} finally {
showLoading(false);
}
}
// 顯示文章列表
function displayArticles(articles) {
const list = document.getElementById('articlesList');
if (!list) {
console.error('找不到 articlesList 元素');
return;
}
list.innerHTML = '';
if (!articles || articles.length === 0) {
list.innerHTML = '<div style="text-align: center; padding: 40px; color: #666;">沒有找到符合條件的文章</div>';
return;
}
console.log('準備顯示', articles.length, '篇文章'); // 除錯用
articles.forEach((article, index) => {
try {
const card = document.createElement('div');
card.className = 'article-card';
card.onclick = () => showArticleDetail(article.id);
// 處理標籤(可能是字串或空值)
let tags = [];
if (article.tags) {
if (typeof article.tags === 'string' && article.tags.trim()) {
tags = article.tags.split(',').map(t => t.trim()).filter(t => t);
} else if (Array.isArray(article.tags)) {
tags = article.tags.filter(t => t);
}
}
const title = escapeHtml(article.title || '無標題');
const author = article.author ? escapeHtml(article.author) : '';
const category = article.category ? escapeHtml(article.category) : '';
const summary = article.summary ? escapeHtml(String(article.summary).substring(0, 200)) + (String(article.summary).length > 200 ? '...' : '') : '';
card.innerHTML = `
<div class="article-title">${title}</div>
<div class="article-meta">
${author ? `<span>👤 ${author}</span>` : ''}
${article.publish_date ? `<span>📅 ${formatDate(article.publish_date)}</span>` : ''}
${category ? `<span>📁 ${category}</span>` : ''}
${article.is_paywalled ? '<span class="paywall-badge">💰 付費文章</span>' : ''}
</div>
${summary ? `<div class="article-summary">${summary}</div>` : ''}
${tags.length > 0 ? `<div class="article-tags">${tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')}</div>` : ''}
`;
list.appendChild(card);
} catch (error) {
console.error(`處理第 ${index + 1} 篇文章時發生錯誤:`, error, article);
}
});
console.log('文章列表顯示完成'); // 除錯用
}
// 顯示分頁
function displayPagination(pagination) {
const paginationDiv = document.getElementById('pagination');
const infoDiv = document.getElementById('paginationInfo');
infoDiv.textContent = `${pagination.page} 頁,共 ${pagination.pages} 頁,總計 ${pagination.total} 篇文章`;
paginationDiv.innerHTML = '';
// 上一頁
const prevBtn = document.createElement('button');
prevBtn.textContent = '← 上一頁';
prevBtn.disabled = pagination.page === 1;
prevBtn.onclick = () => loadArticles(pagination.page - 1);
paginationDiv.appendChild(prevBtn);
// 頁碼
const startPage = Math.max(1, pagination.page - 2);
const endPage = Math.min(pagination.pages, pagination.page + 2);
for (let i = startPage; i <= endPage; i++) {
const pageBtn = document.createElement('button');
pageBtn.textContent = i;
pageBtn.className = i === pagination.page ? 'active' : '';
pageBtn.onclick = () => loadArticles(i);
paginationDiv.appendChild(pageBtn);
}
// 下一頁
const nextBtn = document.createElement('button');
nextBtn.textContent = '下一頁 →';
nextBtn.disabled = pagination.page === pagination.pages;
nextBtn.onclick = () => loadArticles(pagination.page + 1);
paginationDiv.appendChild(nextBtn);
}
// 顯示文章詳情
async function showArticleDetail(articleId) {
try {
const response = await fetch(`/api/article/${articleId}`);
const result = await response.json();
if (result.success) {
const article = result.data;
const detailDiv = document.getElementById('articleDetail');
const tags = article.tags ? article.tags.split(',').map(t => t.trim()).filter(t => t) : [];
detailDiv.innerHTML = `
<h2 class="article-detail-title">${escapeHtml(article.title || '無標題')}</h2>
<div class="article-meta">
${article.author ? `<span>👤 作者: ${escapeHtml(article.author)}</span>` : ''}
${article.publish_date ? `<span>📅 發布日期: ${formatDate(article.publish_date)}</span>` : ''}
${article.category ? `<span>📁 分類: ${escapeHtml(article.category)}</span>` : ''}
${article.is_paywalled ? '<span class="paywall-badge">💰 付費文章</span>' : ''}
</div>
${article.summary ? `<div style="margin: 20px 0; padding: 15px; background: #f8f9fa; border-radius: 5px;"><strong>摘要:</strong>${escapeHtml(article.summary)}</div>` : ''}
${tags.length > 0 ? `<div style="margin: 15px 0;">標籤: ${tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')}</div>` : ''}
${article.content ? `<div class="article-detail-content">${escapeHtml(article.content)}</div>` : '<div style="color: #999; font-style: italic;">此文章無內容(可能是付費文章)</div>'}
<div style="margin-top: 20px;">
<a href="${article.url}" target="_blank" class="btn btn-primary">🔗 查看原文</a>
</div>
`;
document.getElementById('articleModal').style.display = 'block';
}
} catch (error) {
console.error('載入文章詳情失敗:', error);
alert('載入文章詳情失敗');
}
}
// 處理搜尋
function handleSearch() {
currentFilters = {
keyword: document.getElementById('keyword').value.trim(),
category: document.getElementById('category').value,
tag: document.getElementById('tag').value.trim(),
start_date: document.getElementById('startDate').value,
end_date: document.getElementById('endDate').value,
is_paywalled: document.getElementById('isPaywalled').value
};
// 移除空值
Object.keys(currentFilters).forEach(key => {
if (!currentFilters[key]) delete currentFilters[key];
});
loadArticles(1);
}
// 處理重置
function handleReset() {
document.getElementById('keyword').value = '';
document.getElementById('category').value = '';
document.getElementById('tag').value = '';
document.getElementById('startDate').value = '';
document.getElementById('endDate').value = '';
document.getElementById('isPaywalled').value = '';
currentFilters = {};
loadArticles(1);
}
// 執行爬蟲
async function handleRunCrawler() {
if (!confirm('確定要執行爬蟲嗎?這可能需要幾分鐘時間。')) {
return;
}
const btn = document.getElementById('runCrawlerBtn');
btn.disabled = true;
btn.textContent = '執行中...';
try {
const response = await fetch('/api/run-crawler', {
method: 'POST'
});
const result = await response.json();
if (result.success) {
alert('爬蟲執行成功!');
loadStatistics();
loadArticles();
} else {
alert('爬蟲執行失敗: ' + result.error);
}
} catch (error) {
console.error('執行爬蟲失敗:', error);
alert('執行爬蟲失敗,請稍後再試');
} finally {
btn.disabled = false;
btn.textContent = '🚀 執行爬蟲';
}
}
// 繪製分類圖表
function drawCategoryChart(categories) {
const ctx = document.getElementById('categoryChart').getContext('2d');
if (window.categoryChart && typeof window.categoryChart.destroy === 'function') {
window.categoryChart.destroy();
}
window.categoryChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: categories.map(c => c.name),
datasets: [{
data: categories.map(c => c.count),
backgroundColor: [
'#667eea', '#764ba2', '#f093fb', '#4facfe',
'#00f2fe', '#43e97b', '#fa709a', '#fee140',
'#30cfd0', '#330867'
]
}]
},
options: {
responsive: true,
maintainAspectRatio: true
}
});
}
// 繪製作者圖表
function drawAuthorChart(authors) {
const ctx = document.getElementById('authorChart').getContext('2d');
if (window.authorChart && typeof window.authorChart.destroy === 'function') {
window.authorChart.destroy();
}
window.authorChart = new Chart(ctx, {
type: 'bar',
data: {
labels: authors.map(a => a.name),
datasets: [{
label: '文章數量',
data: authors.map(a => a.count),
backgroundColor: '#667eea'
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
scales: {
y: {
beginAtZero: true
}
}
}
});
}
// 工具函數
function showLoading(show) {
document.getElementById('loading').classList.toggle('show', show);
}
function formatDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('zh-TW');
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// ==================== 頁籤功能 ====================
function switchTab(tabName) {
// 隱藏所有頁籤內容
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.remove('active');
});
// 移除所有按鈕的 active 狀態
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.remove('active');
});
// 顯示選中的頁籤
const targetTab = document.getElementById(`${tabName}-tab`);
const targetBtn = document.querySelector(`[data-tab="${tabName}"]`);
if (targetTab) targetTab.classList.add('active');
if (targetBtn) targetBtn.classList.add('active');
}
// ==================== 爬蟲設定功能 ====================
// 預設的起始 URL
const defaultUrls = [
'https://www.hbrtaiwan.com/',
'https://www.hbrtaiwan.com/topic/management',
'https://www.hbrtaiwan.com/topic/leadership',
'https://www.hbrtaiwan.com/topic/strategy',
'https://www.hbrtaiwan.com/topic/innovation',
'https://www.hbrtaiwan.com/topic/technology'
];
// 載入爬蟲設定
async function loadCrawlerConfig() {
try {
// 先嘗試從伺服器載入設定
try {
const response = await fetch('/api/load-crawler-config');
const result = await response.json();
if (result.success && result.data) {
applyConfigToForm(result.data);
// 同時儲存到 localStorage
localStorage.setItem('crawlerConfig', JSON.stringify(result.data));
showConfigStatus('設定已從伺服器載入', 'success');
return;
}
} catch (apiError) {
console.log('無法從伺服器載入設定,嘗試從本地載入:', apiError);
}
// 從 localStorage 載入設定(如果有的話)
const savedConfig = localStorage.getItem('crawlerConfig');
if (savedConfig) {
const config = JSON.parse(savedConfig);
applyConfigToForm(config);
showConfigStatus('設定已從本地載入', 'info');
return;
}
// 載入預設設定
loadDefaultConfig();
} catch (error) {
console.error('載入設定失敗:', error);
loadDefaultConfig();
}
}
// 載入預設設定
function loadDefaultConfig() {
// 載入預設 URL
const urlList = document.getElementById('urlList');
if (urlList) {
urlList.innerHTML = '';
defaultUrls.forEach(url => {
addUrlItem(url);
});
}
// 載入預設值
document.getElementById('downloadDelay').value = '1';
document.getElementById('maxDepth').value = '3';
document.getElementById('concurrentRequests').value = '16';
document.getElementById('skipPaywalled').checked = true;
document.getElementById('followPagination').checked = true;
document.getElementById('obeyRobotsTxt').checked = true;
document.getElementById('articleListSelector').value = '.articleItem, article, .article-item, .post-item, .content-item';
document.getElementById('titleSelector').value = 'h1.articleTitle, h1.article-title, h1, .article-title, .post-title';
document.getElementById('authorSelector').value = '.authorName, .author, .byline, .writer, .author-name';
document.getElementById('contentSelector').value = '.articleContent, .article-content, .post-content, .content, .articleText';
}
// 將設定應用到表單
function applyConfigToForm(config) {
if (config.urls && Array.isArray(config.urls)) {
const urlList = document.getElementById('urlList');
if (urlList) {
urlList.innerHTML = '';
config.urls.forEach(url => {
addUrlItem(url);
});
}
}
if (config.downloadDelay !== undefined) {
document.getElementById('downloadDelay').value = config.downloadDelay;
}
if (config.maxDepth !== undefined) {
document.getElementById('maxDepth').value = config.maxDepth;
}
if (config.concurrentRequests !== undefined) {
document.getElementById('concurrentRequests').value = config.concurrentRequests;
}
if (config.skipPaywalled !== undefined) {
document.getElementById('skipPaywalled').checked = config.skipPaywalled;
}
if (config.followPagination !== undefined) {
document.getElementById('followPagination').checked = config.followPagination;
}
if (config.obeyRobotsTxt !== undefined) {
document.getElementById('obeyRobotsTxt').checked = config.obeyRobotsTxt;
}
if (config.articleListSelector) {
document.getElementById('articleListSelector').value = config.articleListSelector;
}
if (config.titleSelector) {
document.getElementById('titleSelector').value = config.titleSelector;
}
if (config.authorSelector) {
document.getElementById('authorSelector').value = config.authorSelector;
}
if (config.contentSelector) {
document.getElementById('contentSelector').value = config.contentSelector;
}
}
// 新增 URL 項目
function addUrlItem(url = '') {
const urlList = document.getElementById('urlList');
if (!urlList) return;
const urlItem = document.createElement('div');
urlItem.className = 'url-item';
urlItem.innerHTML = `
<input type="text" class="url-input" value="${escapeHtml(url)}" placeholder="https://www.hbrtaiwan.com/...">
<button type="button" class="btn-remove-url">🗑️ 刪除</button>
`;
urlItem.querySelector('.btn-remove-url').addEventListener('click', function() {
urlItem.remove();
});
urlList.appendChild(urlItem);
}
// 儲存爬蟲設定
async function saveCrawlerConfig() {
try {
const config = {
urls: getUrlList(),
downloadDelay: parseFloat(document.getElementById('downloadDelay').value) || 1,
maxDepth: parseInt(document.getElementById('maxDepth').value) || 3,
concurrentRequests: parseInt(document.getElementById('concurrentRequests').value) || 16,
skipPaywalled: document.getElementById('skipPaywalled').checked,
followPagination: document.getElementById('followPagination').checked,
obeyRobotsTxt: document.getElementById('obeyRobotsTxt').checked,
articleListSelector: document.getElementById('articleListSelector').value,
titleSelector: document.getElementById('titleSelector').value,
authorSelector: document.getElementById('authorSelector').value,
contentSelector: document.getElementById('contentSelector').value
};
// 驗證設定
if (config.urls.length === 0) {
showConfigStatus('請至少新增一個起始 URL', 'error');
return;
}
// 儲存到 localStorage
localStorage.setItem('crawlerConfig', JSON.stringify(config));
// 同時儲存到伺服器(可選)
try {
const response = await fetch('/api/save-crawler-config', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(config)
});
const result = await response.json();
if (result.success) {
showConfigStatus('設定已儲存到伺服器', 'success');
} else {
showConfigStatus('設定已儲存到本地,但伺服器儲存失敗: ' + result.error, 'error');
}
} catch (error) {
// 如果伺服器儲存失敗,至少本地已儲存
showConfigStatus('設定已儲存到本地', 'info');
}
showConfigStatus('設定已儲存', 'success');
} catch (error) {
console.error('儲存設定失敗:', error);
showConfigStatus('儲存設定失敗: ' + error.message, 'error');
}
}
// 取得 URL 列表
function getUrlList() {
const urlInputs = document.querySelectorAll('.url-input');
const urls = [];
urlInputs.forEach(input => {
const url = input.value.trim();
if (url) {
urls.push(url);
}
});
return urls;
}
// 重置爬蟲設定
function resetCrawlerConfig() {
if (confirm('確定要重置為預設設定嗎?')) {
loadDefaultConfig();
showConfigStatus('已重置為預設設定', 'info');
}
}
// 測試爬蟲設定
async function testCrawlerConfig() {
const config = {
urls: getUrlList(),
downloadDelay: parseFloat(document.getElementById('downloadDelay').value) || 1,
maxDepth: parseInt(document.getElementById('maxDepth').value) || 3,
concurrentRequests: parseInt(document.getElementById('concurrentRequests').value) || 16,
skipPaywalled: document.getElementById('skipPaywalled').checked,
followPagination: document.getElementById('followPagination').checked,
obeyRobotsTxt: document.getElementById('obeyRobotsTxt').checked,
articleListSelector: document.getElementById('articleListSelector').value,
titleSelector: document.getElementById('titleSelector').value,
authorSelector: document.getElementById('authorSelector').value,
contentSelector: document.getElementById('contentSelector').value
};
if (config.urls.length === 0) {
showConfigStatus('請至少新增一個起始 URL', 'error');
return;
}
showConfigStatus('正在測試設定...', 'info');
try {
const response = await fetch('/api/test-crawler-config', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(config)
});
const result = await response.json();
if (result.success) {
if (result.data.articles_found > 0) {
showConfigStatus('測試成功!找到 ' + result.data.articles_found + ' 篇文章', 'success');
} else {
const warning = result.data.warning || '未找到文章';
showConfigStatus('測試完成,但 ' + warning + '。請檢查 CSS 選擇器設定或查看詳細輸出。', 'error');
}
} else {
showConfigStatus('測試失敗: ' + result.error, 'error');
}
} catch (error) {
console.error('測試設定失敗:', error);
showConfigStatus('測試設定失敗: ' + error.message, 'error');
}
}
// 顯示設定狀態訊息
function showConfigStatus(message, type = 'info') {
const statusDiv = document.getElementById('configStatus');
if (statusDiv) {
statusDiv.textContent = message;
statusDiv.className = `config-status ${type}`;
statusDiv.style.display = 'block';
// 3 秒後自動隱藏(成功訊息)
if (type === 'success') {
setTimeout(() => {
statusDiv.style.display = 'none';
}, 3000);
}
}
}

537
static/style.css Normal file
View File

@@ -0,0 +1,537 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Microsoft JhengHei', 'Segoe UI', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
color: #333;
}
.container {
max-width: 1400px;
margin: 0 auto;
background: white;
border-radius: 15px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
overflow: hidden;
}
header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
}
header h1 {
font-size: 2em;
margin: 0;
}
.header-actions {
display: flex;
gap: 10px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
font-weight: bold;
}
.btn-primary {
background: #4CAF50;
color: white;
}
.btn-primary:hover {
background: #45a049;
transform: translateY(-2px);
}
.btn-success {
background: #2196F3;
color: white;
}
.btn-success:hover {
background: #0b7dda;
transform: translateY(-2px);
}
.btn-secondary {
background: #757575;
color: white;
}
.btn-secondary:hover {
background: #616161;
}
section {
padding: 30px;
border-bottom: 1px solid #eee;
}
section:last-child {
border-bottom: none;
}
h2 {
color: #667eea;
margin-bottom: 20px;
font-size: 1.5em;
}
/* 統計面板 */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 25px;
border-radius: 10px;
text-align: center;
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
transition: transform 0.3s;
}
.stat-card:hover {
transform: translateY(-5px);
}
.stat-label {
font-size: 14px;
opacity: 0.9;
margin-bottom: 10px;
}
.stat-value {
font-size: 2.5em;
font-weight: bold;
}
.charts-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 20px;
}
.chart-box {
background: #f8f9fa;
padding: 20px;
border-radius: 10px;
}
.chart-box h3 {
margin-bottom: 15px;
color: #667eea;
}
/* 查詢表單 */
.search-form {
background: #f8f9fa;
padding: 20px;
border-radius: 10px;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 15px;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group label {
margin-bottom: 5px;
font-weight: bold;
color: #555;
}
.form-group input,
.form-group select {
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 14px;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #667eea;
}
/* 文章列表 */
.articles-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.pagination-info {
color: #666;
font-size: 14px;
}
.articles-list {
display: grid;
gap: 15px;
}
.article-card {
background: #f8f9fa;
padding: 20px;
border-radius: 10px;
border-left: 4px solid #667eea;
transition: all 0.3s;
cursor: pointer;
}
.article-card:hover {
background: #e9ecef;
transform: translateX(5px);
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
}
.article-title {
font-size: 1.2em;
font-weight: bold;
color: #667eea;
margin-bottom: 10px;
text-decoration: none;
}
.article-title:hover {
text-decoration: underline;
}
.article-meta {
display: flex;
flex-wrap: wrap;
gap: 15px;
font-size: 14px;
color: #666;
margin-bottom: 10px;
}
.article-meta span {
display: flex;
align-items: center;
gap: 5px;
}
.article-summary {
color: #555;
line-height: 1.6;
margin-top: 10px;
}
.article-tags {
margin-top: 10px;
}
.tag {
display: inline-block;
background: #667eea;
color: white;
padding: 3px 10px;
border-radius: 15px;
font-size: 12px;
margin-right: 5px;
}
.paywall-badge {
background: #ff9800;
color: white;
padding: 3px 10px;
border-radius: 15px;
font-size: 12px;
font-weight: bold;
}
/* 分頁 */
.pagination {
display: flex;
justify-content: center;
gap: 10px;
margin-top: 20px;
}
.pagination button {
padding: 8px 15px;
border: 1px solid #ddd;
background: white;
border-radius: 5px;
cursor: pointer;
transition: all 0.3s;
}
.pagination button:hover:not(:disabled) {
background: #667eea;
color: white;
border-color: #667eea;
}
.pagination button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination .active {
background: #667eea;
color: white;
border-color: #667eea;
}
/* 載入動畫 */
.loading {
text-align: center;
padding: 40px;
font-size: 18px;
color: #667eea;
display: none;
}
.loading.show {
display: block;
}
/* 模態框 */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
overflow: auto;
}
.modal-content {
background-color: white;
margin: 5% auto;
padding: 30px;
border-radius: 10px;
width: 90%;
max-width: 800px;
max-height: 80vh;
overflow-y: auto;
}
.close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
.close:hover {
color: #000;
}
#articleDetail {
margin-top: 20px;
}
.article-detail-title {
font-size: 1.8em;
color: #667eea;
margin-bottom: 15px;
}
.article-detail-content {
line-height: 1.8;
color: #555;
margin-top: 20px;
}
/* 頁籤樣式 */
.tabs {
display: flex;
background: #f8f9fa;
border-bottom: 2px solid #667eea;
padding: 0 30px;
}
.tab-btn {
padding: 15px 30px;
background: transparent;
border: none;
border-bottom: 3px solid transparent;
cursor: pointer;
font-size: 16px;
font-weight: bold;
color: #666;
transition: all 0.3s;
}
.tab-btn:hover {
color: #667eea;
background: rgba(102, 126, 234, 0.1);
}
.tab-btn.active {
color: #667eea;
border-bottom-color: #667eea;
background: white;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* 爬蟲設定頁面 */
.config-section {
padding: 30px;
}
.config-group {
background: #f8f9fa;
padding: 20px;
border-radius: 10px;
margin-bottom: 20px;
}
.config-group h3 {
color: #667eea;
margin-bottom: 15px;
font-size: 1.2em;
}
.config-group small {
display: block;
color: #666;
font-size: 12px;
margin-top: 5px;
}
.url-list-container {
margin-top: 15px;
}
.url-list {
margin-bottom: 10px;
}
.url-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
background: white;
border-radius: 5px;
margin-bottom: 10px;
}
.url-item input {
flex: 1;
padding: 8px;
border: 1px solid #ddd;
border-radius: 5px;
}
.url-item button {
padding: 8px 15px;
background: #dc3545;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
}
.url-item button:hover {
background: #c82333;
}
.config-actions {
display: flex;
gap: 10px;
margin-top: 20px;
flex-wrap: wrap;
}
.config-status {
margin-top: 20px;
padding: 15px;
border-radius: 5px;
display: none;
}
.config-status.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
display: block;
}
.config-status.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
display: block;
}
.config-status.info {
background: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
display: block;
}
/* 響應式設計 */
@media (max-width: 768px) {
header {
flex-direction: column;
text-align: center;
}
.form-row {
grid-template-columns: 1fr;
}
.charts-container {
grid-template-columns: 1fr;
}
.tabs {
flex-direction: column;
}
.tab-btn {
width: 100%;
text-align: left;
}
}