260aac5d7c
- 목록(list) / 랜딩(landing) 2가지 템플릿 지원 - 랜딩: content_selector로 본문 추출, 깔끔한 다크 테마 렌더링 - 사이트 관리 UI에 템플릿 선택 드롭다운 추가 - 템플릿별 파싱 규칙 기본값 자동 세팅
239 lines
12 KiB
Plaintext
239 lines
12 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><th>액션</th></tr></thead>
|
|
<tbody id="sites-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>
|
|
</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;white-space:pre-wrap"></pre></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 사이트 추가/수정 모달 -->
|
|
<div class="modal-overlay" id="siteModal">
|
|
<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>
|
|
<div class="form-group"><label>크롤링 URL *</label><input id="f-url" placeholder="http://jaewook.net/archives/tag/토렌트순위"></div>
|
|
<div class="form-row">
|
|
<div class="form-group"><label>설명</label><input id="f-desc" placeholder="사이트 설명"></div>
|
|
<div class="form-group">
|
|
<label>템플릿</label>
|
|
<select id="f-template" onchange="onTemplateChange()">
|
|
<option value="default">목록 페이지 (순위/리스트)</option>
|
|
<option value="landing">랜딩 페이지 (본문 그대로)</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>파싱 규칙 (JSON)</label>
|
|
<textarea id="f-rules" rows="14" style="font-size:.78rem"></textarea>
|
|
<div id="rules-help" style="margin-top:.4rem;font-size:.75rem;color:var(--muted)"></div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>크롤링 스케줄 (크론 표현식)</label>
|
|
<div class="form-row">
|
|
<input id="f-cron" placeholder="0 6 * * * (비워두면 수동)">
|
|
<div style="display:flex;align-items:center;gap:.5rem">
|
|
<label style="display:inline-flex;align-items:center;gap:.4rem;cursor:pointer;font-size:.85rem;white-space:nowrap">
|
|
<input type="checkbox" id="f-sched-active" style="width:auto"> 활성화
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div class="cron-presets">
|
|
<span class="preset" onclick="document.getElementById('f-cron').value='*/5 * * * *'">5분마다</span>
|
|
<span class="preset" onclick="document.getElementById('f-cron').value='0 * * * *'">매시간</span>
|
|
<span class="preset" onclick="document.getElementById('f-cron').value='0 */6 * * *'">6시간마다</span>
|
|
<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>
|
|
</div>
|
|
</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>
|
|
var sites = (__INIT__ || {}).sites || [];
|
|
var adsenseList = (__INIT__ || {}).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('');
|
|
|
|
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;
|
|
var tplBadge = s.template==='landing'?'<span class="badge" style="background:#00cec9;color:#000">랜딩</span>':'<span class="badge badge-success">목록</span>';
|
|
return '<tr><td>'+s.id+'</td><td><strong style="color:var(--text)">'+s.name+'</strong></td>' +
|
|
'<td class="text-muted" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+s.url+'</td>' +
|
|
'<td>'+tplBadge+'</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>' +
|
|
'</td></tr>';
|
|
}).join('') || '<tr><td colspan="8" 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();
|
|
}
|
|
|
|
var RULES_LIST = JSON.stringify({
|
|
"discovery": { "link_selector": ".entry-title a" },
|
|
"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" }
|
|
}
|
|
}, null, 2);
|
|
|
|
var RULES_LANDING = JSON.stringify({
|
|
"content_selector": "main, article, .content, #content, body",
|
|
"remove_selectors": "script, style, iframe, nav, header, footer, .ad, .ads, .sidebar, .menu",
|
|
"meta": {
|
|
"title": { "selector": "title", "type": "text" },
|
|
"description": { "selector": "meta[name=description]", "type": "attr", "attr": "content" }
|
|
}
|
|
}, null, 2);
|
|
|
|
function onTemplateChange() {
|
|
var tpl = document.getElementById('f-template').value;
|
|
var rulesEl = document.getElementById('f-rules');
|
|
var helpEl = document.getElementById('rules-help');
|
|
if (tpl === 'landing') {
|
|
if (!rulesEl.value || rulesEl.value === RULES_LIST) rulesEl.value = RULES_LANDING;
|
|
helpEl.innerHTML = '<strong>content_selector</strong>: 본문 영역 CSS 셀렉터 (콤마로 여러 개 가능, 첫 번째 매칭 사용)<br><strong>remove_selectors</strong>: 제거할 요소들';
|
|
} else {
|
|
if (!rulesEl.value || rulesEl.value === RULES_LANDING) rulesEl.value = RULES_LIST;
|
|
helpEl.innerHTML = '<strong>discovery</strong>: 목록 페이지 URL → 최신 글 링크 자동 탐색';
|
|
}
|
|
}
|
|
|
|
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-cron').value = '';
|
|
document.getElementById('f-sched-active').checked = false;
|
|
document.getElementById('f-adsense').value = '';
|
|
document.getElementById('f-template').value = 'default';
|
|
onTemplateChange();
|
|
document.getElementById('siteModal').classList.add('active');
|
|
}
|
|
|
|
function editSite(id) {
|
|
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-template').value = s.template||'default';
|
|
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||'';
|
|
onTemplateChange();
|
|
document.getElementById('f-rules').value = JSON.stringify(s.parse_rules||{},null,2);
|
|
document.getElementById('siteModal').classList.add('active');
|
|
}
|
|
|
|
function closeModal(){document.getElementById('siteModal').classList.remove('active')}
|
|
|
|
async function saveSite() {
|
|
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,template:document.getElementById('f-template').value,
|
|
adsense_config_id:document.getElementById('f-adsense').value||null
|
|
};
|
|
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 reloadSites();
|
|
}
|
|
|
|
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{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){
|
|
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(n){document.getElementById('tab-rendered').style.display=n==='rendered'?'block':'none';document.getElementById('tab-parsed').style.display=n==='parsed'?'block':'none';}
|
|
</script>
|
|
` }) %>
|