fix: 전 페이지 서버사이드 렌더링으로 전환 (초기 데이터 fetch 제거)

- 모든 관리자 페이지에서 DB 데이터를 서버에서 직접 HTML에 주입
- __INIT__ 글로벌 변수로 초기 데이터 전달 (fetch 불필요)
- 대시보드/사이트관리/AdSense/도메인/로그/사이트상세 전부 적용
- trust proxy 설정 (Traefik 뒤 동작)
- 저장/삭제/크롤링 등 액션은 여전히 API fetch 사용

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-03-27 11:24:57 +09:00
parent e560a2faa2
commit dcae228a24
8 changed files with 248 additions and 386 deletions
+40 -77
View File
@@ -15,28 +15,13 @@
<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-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 class="form-group"><label>상단 광고 슬롯 ID (선택)</label><input id="ads-slot-top" placeholder="비워두면 자동광고"></div>
<div class="form-group"><label>중간 광고 슬롯 ID (선택)</label><input id="ads-slot-mid" placeholder="비워두면 자동광고"></div>
</div>
<div class="form-group"><label>하단 광고 슬롯 ID (선택)</label><input id="ads-slot-bot" placeholder="비워두면 자동광고"></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>
@@ -45,71 +30,49 @@
</div>
<script>
let adsList = [];
var adsList = Array.isArray(__INIT__) ? __INIT__ : [];
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>';
function renderAds(){
document.getElementById('ads-tbody').innerHTML = adsList.map(function(a){
var 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>';
}
renderAds();
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 = '');
async function reloadAds(){var d=await api('GET','/api/adsense');if(d&&d.length!==undefined)adsList=d;renderAds();}
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(function(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 || '';
function editAds(id){
var a=adsList.find(function(x){return 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&&a.slots.top)||'';
document.getElementById('ads-slot-mid').value=(a.slots&&a.slots.middle)||'';
document.getElementById('ads-slot-bot').value=(a.slots&&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();
function closeModal(){document.getElementById('adsModal').classList.remove('active');}
async function saveAds(){
var 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;}
var 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();await reloadAds();
}
async function deleteAds(id) {
if (!confirm('삭제하시겠습니까?')) return;
await api('DELETE', '/api/adsense/' + id);
toast('삭제 완료'); loadAds();
}
loadAds();
async function deleteAds(id){if(!confirm('삭제하시겠습니까?'))return;await api('DELETE','/api/adsense/'+id);toast('삭제 완료');await reloadAds();}
</script>
` }) %>
+15 -19
View File
@@ -27,30 +27,26 @@
</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'),
]);
(function(){
var d = __INIT__ || {};
var sites = d.sites || [];
var adsense = d.adsense || [];
var logs = d.logs || [];
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-active').textContent = sites.filter(function(s){return s.schedule_active}).length;
document.getElementById('stat-crawls').textContent = sites.reduce(function(a,s){return 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>';
document.getElementById('site-table').innerHTML = sites.map(function(s){
var sched = s.schedule_active ? '<span class="badge badge-success">'+(s.cron_schedule||'ON')+'</span>' : '<span class="badge badge-danger">OFF</span>';
var 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>'+sched+'</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();
document.getElementById('log-table').innerHTML = logs.map(function(l){
return '<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>';
})();
</script>
` }) %>
+27 -48
View File
@@ -6,8 +6,7 @@
<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>
@@ -18,18 +17,9 @@
<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="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>
@@ -38,45 +28,34 @@
</div>
<script>
async function loadDomains() {
const [domains, sites, adsense] = await Promise.all([
api('GET', '/api/domains'),
api('GET', '/api/sites'),
api('GET', '/api/adsense'),
]);
var _d = __INIT__ || {};
var domains = _d.domains || [];
var domSites = _d.sites || [];
var domAdsense = _d.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 renderDomains(){
document.getElementById('dom-site').innerHTML = domSites.map(function(s){return '<option value="'+s.id+'">'+s.name+'</option>';}).join('');
document.getElementById('dom-adsense').innerHTML = '<option value="">사이트 기본값</option>'+domAdsense.map(function(a){return '<option value="'+a.id+'">'+a.name+'</option>';}).join('');
document.getElementById('dom-tbody').innerHTML = domains.map(function(d){
return '<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>';
}
renderDomains();
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('도메인 추가 완료');
function openModal(){document.getElementById('domModal').classList.add('active');}
async function saveDomain(){
var 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();
var r=await api('GET','/api/domains');if(r&&r.length!==undefined)domains=r;renderDomains();
}
async function deleteDomain(id) {
if (!confirm('삭제하시겠습니까?')) return;
await api('DELETE', '/api/domains/' + id);
toast('삭제 완료'); loadDomains();
async function deleteDomain(id){
if(!confirm('삭제하시겠습니까?'))return;await api('DELETE','/api/domains/'+id);toast('삭제 완료');
var r=await api('GET','/api/domains');if(r&&r.length!==undefined)domains=r;renderDomains();
}
loadDomains();
</script>
` }) %>
+1
View File
@@ -119,6 +119,7 @@ tr:hover td{background:rgba(255,255,255,.02)}
</div>
</div>
<div class="toast" id="toast"></div>
<script>var __INIT__ = <%- typeof initialData !== 'undefined' ? initialData : '{}' %>;</script>
<script>
function api(method, url, data) {
const opts = { method, headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin' };
+13 -10
View File
@@ -3,7 +3,7 @@
<div class="card">
<div class="card-header">
<h2>최근 로그</h2>
<button class="btn btn-outline btn-sm" onclick="loadLogs()">새로고침</button>
<button class="btn btn-outline btn-sm" onclick="reloadLogs()">새로고침</button>
</div>
<table>
<thead><tr><th style="width:160px">시간</th><th style="width:120px">사이트</th><th style="width:120px">액션</th><th>메시지</th></tr></thead>
@@ -12,16 +12,19 @@
</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>';
var logs = Array.isArray(__INIT__) ? __INIT__ : [];
function renderLogs(){
document.getElementById('logs-tbody').innerHTML = logs.map(function(l){
var ac = 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-'+ac+'">'+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();
renderLogs();
async function reloadLogs(){var r=await api('GET','/api/logs?limit=100');if(r&&r.length!==undefined)logs=r;renderLogs();}
</script>
` }) %>
+33 -72
View File
@@ -2,7 +2,6 @@
<div id="site-info"></div>
<!-- 스케줄 설정 -->
<div class="card">
<div class="card-header"><h2>크롤링 스케줄</h2></div>
<div class="form-row">
@@ -16,24 +15,17 @@
<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 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>
@@ -42,7 +34,6 @@
</table>
</div>
<!-- 미리보기 모달 -->
<div class="modal-overlay" id="previewModal">
<div class="modal" style="max-width:900px">
<div class="flex-between mb-1">
@@ -61,75 +52,45 @@
</div>
<script>
const siteId = ` + siteId + `;
async function loadDetail() {
const site = await api('GET', '/api/sites/' + siteId);
var _d = __INIT__ || {};
var site = _d.site || {};
var results = _d.results || [];
var siteId = site.id || location.pathname.split('/').pop();
function renderDetail(){
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>';
'<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||false;
document.getElementById('results-tbody').innerHTML = results.map(function(r){
return '<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>';
}
renderDetail();
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,
});
function setCron(e){document.getElementById('cron-expr').value=e;}
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 crawlNow(){
toast('크롤링 시작...','warning');
try{var r=await api('POST','/api/sites/'+siteId+'/crawl');if(r.error)throw new Error(r.error);toast('크롤링 완료! '+(r.itemCount||0)+'개 항목');
var res=await api('GET','/api/sites/'+siteId+'/results');if(res&&res.length!==undefined)results=res;renderDetail();
}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');
async function preview(resultId){
var r=await api('GET','/api/results/'+resultId);
document.getElementById('preview-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();
function showTab(n){['rendered','parsed','raw'].forEach(function(t){document.getElementById('tab-'+t).style.display=t===n?'block':'none';});}
</script>
` }) %>
+75 -147
View File
@@ -32,28 +32,12 @@
<div class="modal" style="max-width:700px">
<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: /s/여기)</label>
<input id="f-slug" placeholder="예: torrent-rank">
</div>
<div class="form-group"><label>사이트명 *</label><input id="f-name" placeholder="예: 토렌트 순위"></div>
<div class="form-group"><label>슬러그 (공개 URL: /s/여기)</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/tag/토렌트순위">
</div>
<div class="form-group">
<label>설명</label>
<input id="f-desc" placeholder="사이트 설명 (공개 페이지 meta description)">
</div>
<div class="form-group"><label>크롤링 URL *</label><input id="f-url" placeholder="http://jaewook.net/archives/tag/토렌트순위"></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="14" style="font-size:.78rem">{
@@ -74,12 +58,9 @@
}
}</textarea>
<div style="margin-top:.4rem;font-size:.75rem;color:var(--muted)">
<strong>discovery</strong>: 목록 페이지 URL을 넣고, 최신 글 링크 자동 탐색.<br>
예) URL에 태그 페이지를 넣으면 → "Read more" 링크를 찾아 → 본문 크롤링
<strong>discovery</strong>: 목록 페이지 URL 최신 글 링크 자동 탐색
</div>
</div>
<!-- 크롤링 스케줄 -->
<div class="form-group">
<label>크롤링 스케줄 (크론 표현식)</label>
<div class="form-row">
@@ -97,15 +78,9 @@
<span class="preset" onclick="document.getElementById('f-cron').value='0 6 * * *'">매일 06시</span>
<span class="preset" onclick="document.getElementById('f-cron').value='0 6,12,18 * * *'">하루 3회</span>
<span class="preset" onclick="document.getElementById('f-cron').value='0 0 * * *'">매일 자정</span>
<span class="preset" onclick="document.getElementById('f-cron').value='0 6 * * 1'">매주 월요일</span>
</div>
</div>
<div class="form-group">
<label>AdSense 설정</label>
<select id="f-adsense"><option value="">없음</option></select>
</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>
@@ -114,42 +89,41 @@
</div>
<script>
let sites = [];
let adsenseList = [];
var sites = (__INIT__ || {}).sites || [];
var adsenseList = (__INIT__ || {}).adsense || [];
async function loadSites() {
[sites, adsenseList] = await Promise.all([
api('GET', '/api/sites'),
api('GET', '/api/adsense'),
]);
function renderSites() {
var sel = document.getElementById('f-adsense');
sel.innerHTML = '<option value="">없음</option>' + adsenseList.map(function(a){
return '<option value="'+a.id+'">'+a.name+' ('+a.client_id+')</option>';
}).join('');
// 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>';
const hasResult = parseInt(s.crawl_count) > 0;
return '<tr>' +
'<td>' + s.id + '</td>' +
'<td><strong style="color:var(--text)">' + s.name + '</strong></td>' +
'<td class="text-muted" style="max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + s.url + '</td>' +
'<td>' + (s.slug ? '<a href="/s/' + s.slug + '" target="_blank" style="color:var(--primary)">' + s.slug + '</a>' : '<span class="text-muted">-</span>') + '</td>' +
'<td>' + sched + '</td>' +
'<td>' + timeAgo(s.last_crawled_at) + '</td>' +
document.getElementById('sites-tbody').innerHTML = sites.map(function(s){
var sched = s.schedule_active ? '<span class="badge badge-success">'+s.cron_schedule+'</span>' : '<span class="badge badge-danger">OFF</span>';
var hasResult = parseInt(s.crawl_count) > 0;
return '<tr><td>'+s.id+'</td><td><strong style="color:var(--text)">'+s.name+'</strong></td>' +
'<td class="text-muted" style="max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+s.url+'</td>' +
'<td>'+(s.slug?'<a href="/s/'+s.slug+'" target="_blank" style="color:var(--primary)">'+s.slug+'</a>':'<span class="text-muted">-</span>')+'</td>' +
'<td>'+sched+'</td><td>'+timeAgo(s.last_crawled_at)+'</td>' +
'<td class="flex" style="flex-wrap:nowrap">' +
'<button class="btn btn-success btn-sm" onclick="doCrawl(' + s.id + ',this)">크롤링</button>' +
(hasResult ? '<button class="btn btn-outline btn-sm" onclick="previewSite(' + s.id + ')">미리보기</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>' +
'<button class="btn btn-success btn-sm" onclick="doCrawl('+s.id+',this)">크롤링</button>' +
(hasResult?'<button class="btn btn-outline btn-sm" onclick="previewSite('+s.id+')">미리보기</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>';
}
renderSites();
async function reloadSites() {
var data = await api('GET','/api/sites');
if (data && data.length !== undefined) sites = data;
var ads = await api('GET','/api/adsense');
if (ads && ads.length !== undefined) adsenseList = ads;
renderSites();
}
function openAddModal() {
document.getElementById('modal-title').textContent = '사이트 추가';
document.getElementById('edit-id').value = '';
@@ -160,115 +134,69 @@ function openAddModal() {
document.getElementById('f-cron').value = '';
document.getElementById('f-sched-active').checked = false;
document.getElementById('f-adsense').value = '';
// 기본 파싱 규칙은 textarea에 이미 있으므로 건드리지 않음
document.getElementById('siteModal').classList.add('active');
}
function editSite(id) {
const s = sites.find(x => x.id === id);
if (!s) return;
var s = sites.find(function(x){return 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-cron').value = s.cron_schedule || '';
document.getElementById('f-sched-active').checked = s.schedule_active || false;
document.getElementById('f-adsense').value = s.adsense_config_id || '';
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-cron').value = s.cron_schedule||'';
document.getElementById('f-sched-active').checked = s.schedule_active||false;
document.getElementById('f-adsense').value = s.adsense_config_id||'';
document.getElementById('siteModal').classList.add('active');
}
function closeModal() {
document.getElementById('siteModal').classList.remove('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이 올바르지 않습니다: ' + e.message, '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,
var rules;
try{rules=JSON.parse(document.getElementById('f-rules').value||'{}');}catch(e){toast('파싱 규칙 JSON 오류: '+e.message,'error');return;}
var 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;
let result;
if (editId) {
result = await api('PUT', '/api/sites/' + editId, data);
} else {
result = await api('POST', '/api/sites', data);
}
if (result.error) { toast('저장 실패: ' + result.error, 'error'); return; }
// 스케줄 저장
const siteId = editId || result.id;
const cronVal = document.getElementById('f-cron').value;
const schedActive = document.getElementById('f-sched-active').checked;
if (cronVal || schedActive) {
await api('PUT', '/api/sites/' + siteId + '/schedule', {
cron_schedule: cronVal,
schedule_active: schedActive,
});
}
toast(editId ? '사이트 수정 완료' : '사이트 추가 완료');
if(!data.name||!data.url){toast('사이트명과 URL은 필수입니다','error');return;}
var editId=document.getElementById('edit-id').value;
var result = editId ? await api('PUT','/api/sites/'+editId,data) : await api('POST','/api/sites',data);
if(result.error){toast('저장 실패: '+result.error,'error');return;}
var siteId=editId||result.id;
var cronVal=document.getElementById('f-cron').value;
var schedActive=document.getElementById('f-sched-active').checked;
if(cronVal||schedActive){await api('PUT','/api/sites/'+siteId+'/schedule',{cron_schedule:cronVal,schedule_active:schedActive});}
toast(editId?'수정 완료':'추가 완료');
closeModal();
await loadSites();
await reloadSites();
}
async function deleteSite(id) {
if (!confirm('정말 삭제하시겠습니까? 모든 크롤링 데이터가 삭제됩니다.')) return;
await api('DELETE', '/api/sites/' + id);
toast('삭제되었습니다');
await loadSites();
async function deleteSite(id){
if(!confirm('정말 삭제하시겠습니까?'))return;
await api('DELETE','/api/sites/'+id);toast('삭제 완료');await reloadSites();
}
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) + '개 항목' + (r.crawledUrl ? ' (' + r.crawledUrl.substring(0, 50) + ')' : ''));
} catch(e) {
toast('크롤링 실패: ' + e.message, 'error');
}
btn.disabled = false;
btn.textContent = '크롤링';
await loadSites();
async function doCrawl(id,btn){
btn.disabled=true;btn.textContent='진행중...';
try{var 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='크롤링';await reloadSites();
}
async function previewSite(siteId) {
// 최신 크롤링 결과 가져오기
const results = await api('GET', '/api/sites/' + siteId + '/results?limit=1');
if (!results.length) { toast('크롤링 결과가 없습니다', 'error'); return; }
const detail = await api('GET', '/api/results/' + results[0].id);
document.getElementById('preview-iframe').srcdoc = detail.rendered_html || '<p>렌더링 데이터 없음</p>';
document.getElementById('preview-parsed').textContent = JSON.stringify(detail.parsed_data, null, 2);
document.getElementById('previewModal').classList.add('active');
showTab('rendered');
async function previewSite(siteId){
var results=await api('GET','/api/sites/'+siteId+'/results?limit=1');
if(!results.length){toast('크롤링 결과 없음','error');return;}
var detail=await api('GET','/api/results/'+results[0].id);
document.getElementById('preview-iframe').srcdoc=detail.rendered_html||'<p>없음</p>';
document.getElementById('preview-parsed').textContent=JSON.stringify(detail.parsed_data,null,2);
document.getElementById('previewModal').classList.add('active');showTab('rendered');
}
function showTab(name) {
document.getElementById('tab-rendered').style.display = name === 'rendered' ? 'block' : 'none';
document.getElementById('tab-parsed').style.display = name === 'parsed' ? 'block' : 'none';
}
loadSites();
function showTab(n){document.getElementById('tab-rendered').style.display=n==='rendered'?'block':'none';document.getElementById('tab-parsed').style.display=n==='parsed'?'block':'none';}
</script>
` }) %>