Files
crawlmanager/views/admin/sites.ejs
T
chpark c61f10560f 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>
2026-03-27 00:44:19 +09:00

184 lines
6.8 KiB
Plaintext

<%- 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>
` }) %>