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:
727
static/app.js
Normal file
727
static/app.js
Normal 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
537
static/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user