init: 크롤링 관리 솔루션 초기 구성

- Express.js 기반 관리자 페이지 (사이트/크롤링/AdSense/도메인 관리)
- PostgreSQL 16 + Docker Compose (Traefik 연동)
- 크롤러: axios + cheerio 기반 HTML 파싱
- 스케줄러: node-cron 기반 자동 크롤링
- 공개 사이트: slug/도메인 기반 DB에서 렌더링 HTML 서빙
- 도메인: admin.startover.co.kr

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-03-27 00:44:19 +09:00
commit c61f10560f
28 changed files with 2028 additions and 0 deletions
+115
View File
@@ -0,0 +1,115 @@
<%- include('layout', { page: 'adsense', pageTitle: 'AdSense 관리', body: `
<div class="card">
<div class="card-header">
<h2>AdSense 설정 목록</h2>
<button class="btn btn-primary" onclick="openModal()">+ AdSense 추가</button>
</div>
<table>
<thead><tr><th>ID</th><th>이름</th><th>Client ID</th><th>상단 슬롯</th><th>중간 슬롯</th><th>하단 슬롯</th><th>상태</th><th>액션</th></tr></thead>
<tbody id="ads-tbody"></tbody>
</table>
</div>
<div class="modal-overlay" id="adsModal">
<div class="modal">
<h3 id="ads-modal-title">AdSense 추가</h3>
<input type="hidden" id="ads-edit-id">
<div class="form-group">
<label>이름 (구분용)</label>
<input id="ads-name" placeholder="예: 메인사이트 애드센스">
</div>
<div class="form-group">
<label>Client ID (ca-pub-XXXX)</label>
<input id="ads-client" placeholder="ca-pub-1234567890123456">
</div>
<div class="form-row">
<div class="form-group">
<label>상단 광고 슬롯 ID</label>
<input id="ads-slot-top" placeholder="1234567890">
</div>
<div class="form-group">
<label>중간 광고 슬롯 ID</label>
<input id="ads-slot-mid" placeholder="1234567891">
</div>
</div>
<div class="form-group">
<label>하단 광고 슬롯 ID</label>
<input id="ads-slot-bot" placeholder="1234567892">
</div>
<div class="flex" style="justify-content:flex-end;gap:.5rem;margin-top:1rem">
<button class="btn btn-outline" onclick="closeModal()">취소</button>
<button class="btn btn-primary" onclick="saveAds()">저장</button>
</div>
</div>
</div>
<script>
let adsList = [];
async function loadAds() {
adsList = await api('GET', '/api/adsense');
document.getElementById('ads-tbody').innerHTML = adsList.map(a => {
const slots = a.slots || {};
return '<tr><td>' + a.id + '</td><td><strong>' + a.name + '</strong></td><td class="text-muted">' + a.client_id + '</td>' +
'<td>' + (slots.top || '-') + '</td><td>' + (slots.middle || '-') + '</td><td>' + (slots.bottom || '-') + '</td>' +
'<td><span class="badge badge-' + (a.is_active ? 'success">활성' : 'danger">비활성') + '</span></td>' +
'<td class="flex"><button class="btn btn-outline btn-sm" onclick="editAds(' + a.id + ')">수정</button>' +
'<button class="btn btn-danger btn-sm" onclick="deleteAds(' + a.id + ')">삭제</button></td></tr>';
}).join('') || '<tr><td colspan="8" class="text-muted" style="text-align:center;padding:2rem">AdSense 설정을 추가하세요</td></tr>';
}
function openModal() {
document.getElementById('ads-modal-title').textContent = 'AdSense 추가';
document.getElementById('ads-edit-id').value = '';
['ads-name','ads-client','ads-slot-top','ads-slot-mid','ads-slot-bot'].forEach(id => document.getElementById(id).value = '');
document.getElementById('adsModal').classList.add('active');
}
function editAds(id) {
const a = adsList.find(x => x.id === id);
if (!a) return;
document.getElementById('ads-modal-title').textContent = 'AdSense 수정';
document.getElementById('ads-edit-id').value = a.id;
document.getElementById('ads-name').value = a.name;
document.getElementById('ads-client').value = a.client_id;
document.getElementById('ads-slot-top').value = a.slots?.top || '';
document.getElementById('ads-slot-mid').value = a.slots?.middle || '';
document.getElementById('ads-slot-bot').value = a.slots?.bottom || '';
document.getElementById('adsModal').classList.add('active');
}
function closeModal() { document.getElementById('adsModal').classList.remove('active'); }
async function saveAds() {
const data = {
name: document.getElementById('ads-name').value,
client_id: document.getElementById('ads-client').value,
slots: {
top: document.getElementById('ads-slot-top').value,
middle: document.getElementById('ads-slot-mid').value,
bottom: document.getElementById('ads-slot-bot').value,
},
is_active: true,
};
if (!data.name || !data.client_id) { toast('이름과 Client ID는 필수입니다', 'error'); return; }
const editId = document.getElementById('ads-edit-id').value;
if (editId) {
await api('PUT', '/api/adsense/' + editId, data);
toast('수정 완료');
} else {
await api('POST', '/api/adsense', data);
toast('추가 완료');
}
closeModal(); loadAds();
}
async function deleteAds(id) {
if (!confirm('삭제하시겠습니까?')) return;
await api('DELETE', '/api/adsense/' + id);
toast('삭제 완료'); loadAds();
}
loadAds();
</script>
` }) %>
+56
View File
@@ -0,0 +1,56 @@
<%- include('layout', { page: 'dashboard', pageTitle: '대시보드', body: `
<div class="stats-grid" id="stats">
<div class="stat-card"><div class="number" id="stat-sites">-</div><div class="label">등록된 사이트</div></div>
<div class="stat-card"><div class="number" id="stat-active">-</div><div class="label">스케줄 활성</div></div>
<div class="stat-card"><div class="number" id="stat-crawls">-</div><div class="label">총 크롤링 횟수</div></div>
<div class="stat-card"><div class="number" id="stat-adsense">-</div><div class="label">AdSense 설정</div></div>
</div>
<div class="card">
<div class="card-header">
<h2>사이트 현황</h2>
<a href="/admin/sites" class="btn btn-primary btn-sm">사이트 관리 &rarr;</a>
</div>
<table>
<thead><tr><th>사이트명</th><th>URL</th><th>스케줄</th><th>마지막 크롤링</th><th>상태</th><th>공개 URL</th></tr></thead>
<tbody id="site-table"></tbody>
</table>
</div>
<div class="card">
<div class="card-header"><h2>최근 로그</h2></div>
<table>
<thead><tr><th>시간</th><th>사이트</th><th>액션</th><th>메시지</th></tr></thead>
<tbody id="log-table"></tbody>
</table>
</div>
<script>
async function loadDashboard() {
const [sites, adsense, logs] = await Promise.all([
api('GET', '/api/sites'),
api('GET', '/api/adsense'),
api('GET', '/api/logs?limit=10'),
]);
document.getElementById('stat-sites').textContent = sites.length;
document.getElementById('stat-active').textContent = sites.filter(s => s.schedule_active).length;
document.getElementById('stat-crawls').textContent = sites.reduce((a, s) => a + parseInt(s.crawl_count || 0), 0);
document.getElementById('stat-adsense').textContent = adsense.length;
document.getElementById('site-table').innerHTML = sites.map(s => {
const schedBadge = s.schedule_active
? '<span class="badge badge-success">' + (s.cron_schedule || 'ON') + '</span>'
: '<span class="badge badge-danger">OFF</span>';
const slug = s.slug ? '<a href="/s/' + s.slug + '" target="_blank" style="color:var(--primary)">/s/' + s.slug + '</a>' : '-';
return '<tr><td><strong>' + s.name + '</strong></td><td class="text-muted" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + s.url + '</td><td>' + schedBadge + '</td><td>' + timeAgo(s.last_crawled_at) + '</td><td><span class="badge badge-' + (s.status === 'active' ? 'success' : 'danger') + '">' + s.status + '</span></td><td>' + slug + '</td></tr>';
}).join('') || '<tr><td colspan="6" class="text-muted" style="text-align:center;padding:2rem">등록된 사이트가 없습니다. <a href="/admin/sites" style="color:var(--primary)">사이트를 추가하세요</a></td></tr>';
document.getElementById('log-table').innerHTML = logs.map(l =>
'<tr><td class="text-muted">' + timeAgo(l.created_at) + '</td><td>' + (l.site_name || '-') + '</td><td><span class="badge badge-info">' + l.action + '</span></td><td>' + (l.message || '').substring(0, 80) + '</td></tr>'
).join('') || '<tr><td colspan="4" class="text-muted" style="text-align:center">로그가 없습니다</td></tr>';
}
loadDashboard();
</script>
` }) %>
+82
View File
@@ -0,0 +1,82 @@
<%- include('layout', { page: 'domains', pageTitle: '도메인 매핑', body: `
<div class="card">
<div class="card-header">
<h2>도메인 매핑</h2>
<button class="btn btn-primary" onclick="openModal()">+ 도메인 추가</button>
</div>
<p class="text-muted" style="margin-bottom:1rem;font-size:.82rem">
도메인을 특정 사이트에 연결하면, 해당 도메인으로 접속 시 크롤링 결과가 자동으로 표시됩니다.<br>
슬러그 기반 접근도 가능합니다: <code>/s/{slug}</code>
</p>
<table>
<thead><tr><th>도메인</th><th>연결 사이트</th><th>상태</th><th>등록일</th><th>액션</th></tr></thead>
<tbody id="dom-tbody"></tbody>
</table>
</div>
<div class="modal-overlay" id="domModal">
<div class="modal">
<h3>도메인 추가</h3>
<div class="form-group">
<label>도메인 (서브도메인 포함)</label>
<input id="dom-domain" placeholder="rank.example.com">
</div>
<div class="form-group">
<label>연결할 사이트</label>
<select id="dom-site"></select>
</div>
<div class="form-group">
<label>AdSense 설정 (선택)</label>
<select id="dom-adsense"><option value="">사이트 기본값 사용</option></select>
</div>
<div class="flex" style="justify-content:flex-end;gap:.5rem;margin-top:1rem">
<button class="btn btn-outline" onclick="document.getElementById('domModal').classList.remove('active')">취소</button>
<button class="btn btn-primary" onclick="saveDomain()">저장</button>
</div>
</div>
</div>
<script>
async function loadDomains() {
const [domains, sites, adsense] = await Promise.all([
api('GET', '/api/domains'),
api('GET', '/api/sites'),
api('GET', '/api/adsense'),
]);
document.getElementById('dom-site').innerHTML = sites.map(s => '<option value="' + s.id + '">' + s.name + '</option>').join('');
document.getElementById('dom-adsense').innerHTML = '<option value="">사이트 기본값</option>' + adsense.map(a => '<option value="' + a.id + '">' + a.name + '</option>').join('');
document.getElementById('dom-tbody').innerHTML = domains.map(d =>
'<tr><td><strong>' + d.domain + '</strong></td><td>' + (d.site_name || '-') + '</td>' +
'<td><span class="badge badge-' + (d.is_active ? 'success">활성' : 'danger">비활성') + '</span></td>' +
'<td class="text-muted">' + timeAgo(d.created_at) + '</td>' +
'<td><button class="btn btn-danger btn-sm" onclick="deleteDomain(' + d.id + ')">삭제</button></td></tr>'
).join('') || '<tr><td colspan="5" class="text-muted" style="text-align:center;padding:2rem">도메인 매핑을 추가하세요</td></tr>';
}
function openModal() { document.getElementById('domModal').classList.add('active'); }
async function saveDomain() {
const data = {
domain: document.getElementById('dom-domain').value,
site_id: parseInt(document.getElementById('dom-site').value),
adsense_config_id: document.getElementById('dom-adsense').value || null,
};
if (!data.domain) { toast('도메인을 입력하세요', 'error'); return; }
await api('POST', '/api/domains', data);
toast('도메인 추가 완료');
document.getElementById('domModal').classList.remove('active');
loadDomains();
}
async function deleteDomain(id) {
if (!confirm('삭제하시겠습니까?')) return;
await api('DELETE', '/api/domains/' + id);
toast('삭제 완료'); loadDomains();
}
loadDomains();
</script>
` }) %>
+142
View File
@@ -0,0 +1,142 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= typeof pageTitle !== 'undefined' ? pageTitle : 'Crawl Manager' %></title>
<style>
:root{--bg:#111827;--bg2:#1f2937;--bg3:#374151;--text:#f9fafb;--muted:#9ca3af;--primary:#6366f1;--primary-hover:#818cf8;--danger:#ef4444;--success:#22c55e;--warning:#f59e0b;--border:#374151;--radius:8px}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Noto Sans KR',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex}
/* 사이드바 */
.sidebar{width:220px;background:var(--bg2);border-right:1px solid var(--border);padding:1.5rem 0;flex-shrink:0;position:fixed;top:0;left:0;height:100vh;overflow-y:auto}
.sidebar .logo{padding:0 1.2rem 1.5rem;font-size:1.1rem;font-weight:700;color:var(--primary);border-bottom:1px solid var(--border);margin-bottom:1rem}
.sidebar nav a{display:flex;align-items:center;gap:.6rem;padding:.7rem 1.2rem;color:var(--muted);text-decoration:none;font-size:.88rem;transition:all .15s}
.sidebar nav a:hover,.sidebar nav a.active{color:var(--text);background:var(--bg3)}
.sidebar nav a.active{border-left:3px solid var(--primary)}
/* 메인 */
.main{margin-left:220px;flex:1;min-height:100vh}
.topbar{background:var(--bg2);border-bottom:1px solid var(--border);padding:.8rem 1.5rem;display:flex;justify-content:space-between;align-items:center}
.topbar h1{font-size:1.1rem;font-weight:600}
.content{padding:1.5rem}
/* 카드 */
.card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:1.2rem;margin-bottom:1rem}
.card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;padding-bottom:.8rem;border-bottom:1px solid var(--border)}
.card-header h2{font-size:1rem;font-weight:600}
/* 버튼 */
.btn{padding:.5rem 1rem;border:none;border-radius:var(--radius);cursor:pointer;font-size:.85rem;font-weight:500;transition:all .15s;display:inline-flex;align-items:center;gap:.4rem}
.btn-primary{background:var(--primary);color:#fff}
.btn-primary:hover{background:var(--primary-hover)}
.btn-danger{background:var(--danger);color:#fff}
.btn-danger:hover{opacity:.8}
.btn-success{background:var(--success);color:#fff}
.btn-warning{background:var(--warning);color:#000}
.btn-sm{padding:.35rem .7rem;font-size:.78rem}
.btn-outline{background:transparent;border:1px solid var(--border);color:var(--text)}
.btn-outline:hover{background:var(--bg3)}
/* 테이블 */
table{width:100%;border-collapse:collapse}
th,td{padding:.6rem .8rem;text-align:left;border-bottom:1px solid var(--border);font-size:.85rem}
th{color:var(--muted);font-weight:500;font-size:.78rem;text-transform:uppercase;letter-spacing:.5px}
tr:hover td{background:rgba(255,255,255,.02)}
/* 폼 */
.form-group{margin-bottom:1rem}
.form-group label{display:block;font-size:.82rem;color:var(--muted);margin-bottom:.3rem;font-weight:500}
.form-group input,.form-group textarea,.form-group select{width:100%;padding:.55rem .8rem;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);font-size:.88rem;font-family:inherit}
.form-group input:focus,.form-group textarea:focus,.form-group select:focus{outline:none;border-color:var(--primary)}
.form-group textarea{resize:vertical;min-height:80px;font-family:monospace}
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:1rem}
/* 뱃지 */
.badge{display:inline-block;padding:.15rem .5rem;border-radius:10px;font-size:.72rem;font-weight:600}
.badge-success{background:rgba(34,197,94,.15);color:var(--success)}
.badge-danger{background:rgba(239,68,68,.15);color:var(--danger)}
.badge-warning{background:rgba(245,158,11,.15);color:var(--warning)}
.badge-info{background:rgba(99,102,241,.15);color:var(--primary-hover)}
/* 모달 */
.modal-overlay{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.6);z-index:100;align-items:center;justify-content:center}
.modal-overlay.active{display:flex}
.modal{background:var(--bg2);border:1px solid var(--border);border-radius:12px;padding:1.5rem;width:90%;max-width:600px;max-height:90vh;overflow-y:auto}
.modal h3{margin-bottom:1rem;font-size:1rem}
/* 유틸 */
.text-muted{color:var(--muted)}
.text-success{color:var(--success)}
.text-danger{color:var(--danger)}
.mt-1{margin-top:.5rem}
.mb-1{margin-bottom:.5rem}
.flex{display:flex;gap:.5rem;align-items:center}
.flex-between{display:flex;justify-content:space-between;align-items:center}
/* 통계 카드 */
.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1rem;margin-bottom:1.5rem}
.stat-card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:1rem}
.stat-card .number{font-size:1.8rem;font-weight:700}
.stat-card .label{color:var(--muted);font-size:.8rem;margin-top:.2rem}
/* 토스트 */
.toast{position:fixed;top:1rem;right:1rem;background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:.8rem 1.2rem;z-index:200;display:none;font-size:.85rem;box-shadow:0 4px 20px rgba(0,0,0,.3)}
.toast.show{display:block;animation:slideIn .3s}
@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
/* 크론 프리셋 */
.cron-presets{display:flex;flex-wrap:wrap;gap:.4rem;margin-top:.4rem}
.cron-presets .preset{padding:.25rem .6rem;background:var(--bg3);border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:.75rem;color:var(--muted)}
.cron-presets .preset:hover{border-color:var(--primary);color:var(--text)}
</style>
</head>
<body>
<aside class="sidebar">
<div class="logo">Crawl Manager</div>
<nav>
<a href="/admin" class="<%= typeof page !== 'undefined' && page === 'dashboard' ? 'active' : '' %>">&#x1f4ca; 대시보드</a>
<a href="/admin/sites" class="<%= typeof page !== 'undefined' && page === 'sites' ? 'active' : '' %>">&#x1f310; 사이트 관리</a>
<a href="/admin/adsense" class="<%= typeof page !== 'undefined' && page === 'adsense' ? 'active' : '' %>">&#x1f4b0; AdSense 관리</a>
<a href="/admin/domains" class="<%= typeof page !== 'undefined' && page === 'domains' ? 'active' : '' %>">&#x1f517; 도메인 매핑</a>
<a href="/admin/logs" class="<%= typeof page !== 'undefined' && page === 'logs' ? 'active' : '' %>">&#x1f4dd; 로그</a>
</nav>
</aside>
<div class="main">
<div class="topbar">
<h1><%= typeof pageTitle !== 'undefined' ? pageTitle : '' %></h1>
<span class="text-muted" style="font-size:.8rem">Crawl Manager v1.0</span>
</div>
<div class="content">
<%- body %>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
function api(method, url, data) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (data) opts.body = JSON.stringify(data);
return fetch(url, opts).then(r => r.json());
}
function toast(msg, type = 'success') {
const el = document.getElementById('toast');
el.textContent = msg;
el.style.borderLeftColor = type === 'success' ? 'var(--success)' : type === 'error' ? 'var(--danger)' : 'var(--warning)';
el.style.borderLeftWidth = '3px';
el.classList.add('show');
setTimeout(() => el.classList.remove('show'), 3000);
}
function timeAgo(dateStr) {
if (!dateStr) return '-';
const diff = Date.now() - new Date(dateStr).getTime();
const m = Math.floor(diff / 60000);
if (m < 1) return '방금';
if (m < 60) return m + '분 전';
const h = Math.floor(m / 60);
if (h < 24) return h + '시간 전';
return Math.floor(h / 24) + '일 전';
}
</script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
<%- include('layout', { page: 'logs', pageTitle: '크롤링 로그', body: `
<div class="card">
<div class="card-header">
<h2>최근 로그</h2>
<button class="btn btn-outline btn-sm" onclick="loadLogs()">새로고침</button>
</div>
<table>
<thead><tr><th style="width:160px">시간</th><th style="width:120px">사이트</th><th style="width:120px">액션</th><th>메시지</th></tr></thead>
<tbody id="logs-tbody"></tbody>
</table>
</div>
<script>
async function loadLogs() {
const logs = await api('GET', '/api/logs?limit=100');
document.getElementById('logs-tbody').innerHTML = logs.map(l => {
const actionClass = l.action.includes('error') ? 'danger' : l.action.includes('success') ? 'success' : 'info';
return '<tr><td class="text-muted">' + new Date(l.created_at).toLocaleString('ko-KR') + '</td>' +
'<td>' + (l.site_name || '-') + '</td>' +
'<td><span class="badge badge-' + actionClass + '">' + l.action + '</span></td>' +
'<td style="word-break:break-all">' + (l.message || '') + '</td></tr>';
}).join('') || '<tr><td colspan="4" class="text-muted" style="text-align:center;padding:2rem">로그가 없습니다</td></tr>';
}
loadLogs();
</script>
` }) %>
+135
View File
@@ -0,0 +1,135 @@
<%- include('layout', { page: 'sites', pageTitle: '사이트 상세', body: `
<div id="site-info"></div>
<!-- 스케줄 설정 -->
<div class="card">
<div class="card-header"><h2>크롤링 스케줄</h2></div>
<div class="form-row">
<div class="form-group">
<label>크론 표현식</label>
<input id="cron-expr" placeholder="0 6 * * *">
<div class="cron-presets">
<span class="preset" onclick="setCron('*/5 * * * *')">5분마다</span>
<span class="preset" onclick="setCron('0 * * * *')">매시간</span>
<span class="preset" onclick="setCron('0 */6 * * *')">6시간마다</span>
<span class="preset" onclick="setCron('0 6 * * *')">매일 06시</span>
<span class="preset" onclick="setCron('0 6,12,18 * * *')">하루 3회</span>
<span class="preset" onclick="setCron('0 0 * * *')">매일 자정</span>
<span class="preset" onclick="setCron('0 6 * * 1')">매주 월요일</span>
</div>
</div>
<div class="form-group">
<label>스케줄 활성화</label>
<div style="margin-top:.5rem">
<label style="display:inline-flex;align-items:center;gap:.5rem;cursor:pointer">
<input type="checkbox" id="sched-active" style="width:auto">
<span>활성화</span>
</label>
</div>
</div>
</div>
<button class="btn btn-primary" onclick="saveSchedule()">스케줄 저장</button>
<button class="btn btn-success" onclick="crawlNow()" style="margin-left:.5rem">지금 크롤링</button>
</div>
<!-- 크롤링 결과 -->
<div class="card">
<div class="card-header"><h2>크롤링 결과</h2></div>
<table>
<thead><tr><th>ID</th><th>시간</th><th>상태</th><th>항목 수</th><th>에러</th><th>액션</th></tr></thead>
<tbody id="results-tbody"></tbody>
</table>
</div>
<!-- 미리보기 모달 -->
<div class="modal-overlay" id="previewModal">
<div class="modal" style="max-width:900px">
<div class="flex-between mb-1">
<h3>미리보기</h3>
<button class="btn btn-outline btn-sm" onclick="document.getElementById('previewModal').classList.remove('active')">닫기</button>
</div>
<div id="preview-tabs" class="flex mb-1">
<button class="btn btn-sm btn-primary" onclick="showTab('rendered')">렌더링</button>
<button class="btn btn-sm btn-outline" onclick="showTab('parsed')">파싱 데이터</button>
<button class="btn btn-sm btn-outline" onclick="showTab('raw')">원본 HTML</button>
</div>
<div id="tab-rendered"><iframe id="preview-iframe" style="width:100%;height:500px;border:1px solid var(--border);border-radius:var(--radius);background:#fff"></iframe></div>
<div id="tab-parsed" style="display:none"><pre id="preview-parsed" style="background:var(--bg);padding:1rem;border-radius:var(--radius);max-height:500px;overflow:auto;font-size:.78rem"></pre></div>
<div id="tab-raw" style="display:none"><textarea id="preview-raw" readonly style="width:100%;height:500px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:var(--radius);padding:1rem;font-size:.75rem;font-family:monospace"></textarea></div>
</div>
</div>
<script>
const siteId = ` + siteId + `;
async function loadDetail() {
const site = await api('GET', '/api/sites/' + siteId);
document.getElementById('site-info').innerHTML =
'<div class="card"><div class="flex-between">' +
'<div><h2 style="margin-bottom:.3rem">' + site.name + '</h2><span class="text-muted">' + site.url + '</span>' +
(site.slug ? '<br><a href="/s/' + site.slug + '" target="_blank" style="color:var(--primary);font-size:.85rem">공개 페이지: /s/' + site.slug + '</a>' : '') +
'</div>' +
'<a href="/admin/sites" class="btn btn-outline btn-sm">&larr; 목록</a>' +
'</div></div>';
document.getElementById('cron-expr').value = site.cron_schedule || '';
document.getElementById('sched-active').checked = site.schedule_active;
const results = await api('GET', '/api/sites/' + siteId + '/results');
document.getElementById('results-tbody').innerHTML = results.map(r =>
'<tr>' +
'<td>' + r.id + '</td>' +
'<td>' + timeAgo(r.crawled_at) + '</td>' +
'<td><span class="badge badge-' + (r.status === 'success' ? 'success' : 'danger') + '">' + r.status + '</span></td>' +
'<td>' + (r.item_count || 0) + '개</td>' +
'<td class="text-muted">' + (r.error_message || '-').substring(0, 50) + '</td>' +
'<td><button class="btn btn-outline btn-sm" onclick="preview(' + r.id + ')">보기</button></td>' +
'</tr>'
).join('') || '<tr><td colspan="6" class="text-muted" style="text-align:center">크롤링 결과가 없습니다</td></tr>';
}
function setCron(expr) {
document.getElementById('cron-expr').value = expr;
}
async function saveSchedule() {
await api('PUT', '/api/sites/' + siteId + '/schedule', {
cron_schedule: document.getElementById('cron-expr').value,
schedule_active: document.getElementById('sched-active').checked,
});
toast('스케줄 저장 완료');
}
async function crawlNow() {
toast('크롤링 시작...', 'warning');
try {
const r = await api('POST', '/api/sites/' + siteId + '/crawl');
if (r.error) throw new Error(r.error);
toast('크롤링 완료! ' + (r.itemCount || 0) + '개 항목');
loadDetail();
} catch(e) {
toast('크롤링 실패: ' + e.message, 'error');
}
}
async function preview(resultId) {
const r = await api('GET', '/api/results/' + resultId);
const iframe = document.getElementById('preview-iframe');
iframe.srcdoc = r.rendered_html || '<p>렌더링 데이터 없음</p>';
document.getElementById('preview-parsed').textContent = JSON.stringify(r.parsed_data, null, 2);
document.getElementById('preview-raw').value = r.raw_html || '';
document.getElementById('previewModal').classList.add('active');
showTab('rendered');
}
function showTab(name) {
['rendered','parsed','raw'].forEach(t => {
document.getElementById('tab-' + t).style.display = t === name ? 'block' : 'none';
});
}
loadDetail();
</script>
` }) %>
+183
View File
@@ -0,0 +1,183 @@
<%- include('layout', { page: 'sites', pageTitle: '사이트 관리', body: `
<div class="card">
<div class="card-header">
<h2>크롤링 대상 사이트</h2>
<button class="btn btn-primary" onclick="openAddModal()">+ 사이트 추가</button>
</div>
<table>
<thead><tr><th>ID</th><th>사이트명</th><th>URL</th><th>슬러그</th><th>스케줄</th><th>마지막 크롤링</th><th>액션</th></tr></thead>
<tbody id="sites-tbody"></tbody>
</table>
</div>
<!-- 사이트 추가/수정 모달 -->
<div class="modal-overlay" id="siteModal">
<div class="modal">
<h3 id="modal-title">사이트 추가</h3>
<input type="hidden" id="edit-id">
<div class="form-row">
<div class="form-group">
<label>사이트명 *</label>
<input id="f-name" placeholder="예: 토렌트 순위">
</div>
<div class="form-group">
<label>슬러그 (공개 URL용)</label>
<input id="f-slug" placeholder="예: torrent-rank">
</div>
</div>
<div class="form-group">
<label>크롤링 URL *</label>
<input id="f-url" placeholder="http://jaewook.net/archives/2613">
</div>
<div class="form-group">
<label>설명</label>
<input id="f-desc" placeholder="사이트 설명">
</div>
<div class="form-group">
<label>파싱 규칙 (JSON) - 아래 예시를 참고하세요</label>
<textarea id="f-rules" rows="12" style="font-size:.78rem">{
"container": "table.easy-table tbody tr",
"fields": {
"rank": { "selector": "td:nth-child(1)", "type": "text" },
"name": { "selector": "td:nth-child(2)", "type": "text" },
"url": { "selector": "td:nth-child(3) a", "type": "attr", "attr": "href" },
"url_text": { "selector": "td:nth-child(3)", "type": "text" },
"features": { "selector": "td:nth-child(4)", "type": "text" }
},
"meta": {
"title": { "selector": "h1.entry-title", "type": "text" },
"date": { "selector": "time.entry-date", "type": "attr", "attr": "datetime" }
}
}</textarea>
</div>
<div class="form-group">
<label>AdSense 설정</label>
<select id="f-adsense"><option value="">없음</option></select>
</div>
<div class="flex" style="justify-content:flex-end;gap:.5rem;margin-top:1rem">
<button class="btn btn-outline" onclick="closeModal()">취소</button>
<button class="btn btn-primary" onclick="saveSite()">저장</button>
</div>
</div>
</div>
<script>
let sites = [];
async function loadSites() {
sites = await api('GET', '/api/sites');
const adsenseList = await api('GET', '/api/adsense');
// AdSense 드롭다운
const sel = document.getElementById('f-adsense');
sel.innerHTML = '<option value="">없음</option>' + adsenseList.map(a =>
'<option value="' + a.id + '">' + a.name + ' (' + a.client_id + ')</option>'
).join('');
document.getElementById('sites-tbody').innerHTML = sites.map(s => {
const sched = s.schedule_active
? '<span class="badge badge-success">' + s.cron_schedule + '</span>'
: '<span class="badge badge-danger">OFF</span>';
return '<tr>' +
'<td>' + s.id + '</td>' +
'<td><a href="/admin/sites/' + s.id + '" style="color:var(--primary);font-weight:600">' + s.name + '</a></td>' +
'<td class="text-muted" style="max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + s.url + '</td>' +
'<td>' + (s.slug || '<span class="text-muted">-</span>') + '</td>' +
'<td>' + sched + '</td>' +
'<td>' + timeAgo(s.last_crawled_at) + '</td>' +
'<td class="flex">' +
'<button class="btn btn-success btn-sm" onclick="doCrawl(' + s.id + ',this)">크롤링</button>' +
'<button class="btn btn-outline btn-sm" onclick="editSite(' + s.id + ')">수정</button>' +
'<button class="btn btn-danger btn-sm" onclick="deleteSite(' + s.id + ')">삭제</button>' +
'</td></tr>';
}).join('') || '<tr><td colspan="7" style="text-align:center;padding:2rem" class="text-muted">사이트를 추가하세요</td></tr>';
}
function openAddModal() {
document.getElementById('modal-title').textContent = '사이트 추가';
document.getElementById('edit-id').value = '';
document.getElementById('f-name').value = '';
document.getElementById('f-url').value = '';
document.getElementById('f-slug').value = '';
document.getElementById('f-desc').value = '';
document.getElementById('f-adsense').value = '';
document.getElementById('siteModal').classList.add('active');
}
function editSite(id) {
const s = sites.find(x => x.id === id);
if (!s) return;
document.getElementById('modal-title').textContent = '사이트 수정';
document.getElementById('edit-id').value = s.id;
document.getElementById('f-name').value = s.name;
document.getElementById('f-url').value = s.url;
document.getElementById('f-slug').value = s.slug || '';
document.getElementById('f-desc').value = s.description || '';
document.getElementById('f-rules').value = JSON.stringify(s.parse_rules || {}, null, 2);
document.getElementById('f-adsense').value = s.adsense_config_id || '';
document.getElementById('siteModal').classList.add('active');
}
function closeModal() {
document.getElementById('siteModal').classList.remove('active');
}
async function saveSite() {
let rules;
try {
rules = JSON.parse(document.getElementById('f-rules').value || '{}');
} catch(e) {
toast('파싱 규칙 JSON이 올바르지 않습니다', 'error');
return;
}
const data = {
name: document.getElementById('f-name').value,
url: document.getElementById('f-url').value,
slug: document.getElementById('f-slug').value || null,
description: document.getElementById('f-desc').value,
parse_rules: rules,
adsense_config_id: document.getElementById('f-adsense').value || null,
};
if (!data.name || !data.url) { toast('사이트명과 URL은 필수입니다', 'error'); return; }
const editId = document.getElementById('edit-id').value;
if (editId) {
await api('PUT', '/api/sites/' + editId, data);
toast('사이트가 수정되었습니다');
} else {
await api('POST', '/api/sites', data);
toast('사이트가 추가되었습니다');
}
closeModal();
loadSites();
}
async function deleteSite(id) {
if (!confirm('정말 삭제하시겠습니까? 모든 크롤링 데이터가 삭제됩니다.')) return;
await api('DELETE', '/api/sites/' + id);
toast('삭제되었습니다');
loadSites();
}
async function doCrawl(id, btn) {
btn.disabled = true;
btn.textContent = '크롤링 중...';
try {
const r = await api('POST', '/api/sites/' + id + '/crawl');
if (r.error) throw new Error(r.error);
toast('크롤링 완료! ' + (r.itemCount || 0) + '개 항목');
} catch(e) {
toast('크롤링 실패: ' + e.message, 'error');
}
btn.disabled = false;
btn.textContent = '크롤링';
loadSites();
}
loadSites();
</script>
` }) %>