c61f10560f
- 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>
136 lines
5.9 KiB
Plaintext
136 lines
5.9 KiB
Plaintext
<%- 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">← 목록</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>
|
|
` }) %>
|