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:
+75
-147
@@ -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>
|
||||
` }) %>
|
||||
|
||||
Reference in New Issue
Block a user