feat: 랜딩 페이지 템플릿 추가
- 목록(list) / 랜딩(landing) 2가지 템플릿 지원 - 랜딩: content_selector로 본문 추출, 깔끔한 다크 테마 렌더링 - 사이트 관리 UI에 템플릿 선택 드롭다운 추가 - 템플릿별 파싱 규칙 기본값 자동 세팅
This commit is contained in:
+115
-3
@@ -57,9 +57,11 @@ async function crawlSite(siteId) {
|
||||
const parsedData = parseHtml(rawHtml, parseRules);
|
||||
parsedData.meta._crawled_url = targetUrl;
|
||||
|
||||
// 4. 렌더링용 HTML 생성
|
||||
// 4. 렌더링용 HTML 생성 (템플릿 분기)
|
||||
const adsenseConfig = await getAdsenseConfig(site.adsense_config_id);
|
||||
const renderedHtml = renderPublicPage(site, parsedData, adsenseConfig);
|
||||
const renderedHtml = site.template === 'landing'
|
||||
? renderLandingPage(site, parsedData, adsenseConfig)
|
||||
: renderPublicPage(site, parsedData, adsenseConfig);
|
||||
|
||||
// 5. DB 저장
|
||||
await db.query(
|
||||
@@ -136,13 +138,31 @@ function parseHtml(html, rules) {
|
||||
});
|
||||
}
|
||||
|
||||
// 랜딩 페이지: 본문 HTML 추출
|
||||
if (rules.content_selector) {
|
||||
const contentEl = $(rules.content_selector).first();
|
||||
if (contentEl.length > 0) {
|
||||
// 불필요한 요소 제거
|
||||
const removeSelectors = rules.remove_selectors || 'script, style, iframe, nav, header, footer, .ad, .ads, .advertisement';
|
||||
const cleaned = contentEl.clone();
|
||||
cleaned.find(removeSelectors).remove();
|
||||
result.meta._content_html = cleaned.html() || '';
|
||||
} else {
|
||||
result.meta._content_html = '';
|
||||
}
|
||||
}
|
||||
|
||||
// 규칙이 없으면 기본 정보만
|
||||
if (!rules.container) {
|
||||
if (!rules.container && !rules.content_selector) {
|
||||
result.meta.title = $('title').text().trim();
|
||||
result.meta.description = $('meta[name="description"]').attr('content') || '';
|
||||
result.meta.rawTextPreview = $('body').text().trim().substring(0, 500);
|
||||
}
|
||||
|
||||
// 메타 기본값
|
||||
if (!result.meta.title) result.meta.title = $('title').text().trim();
|
||||
if (!result.meta.description) result.meta.description = $('meta[name="description"]').attr('content') || '';
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -341,6 +361,98 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Noto Sans KR',sans
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 랜딩 페이지용 HTML 렌더링
|
||||
* - 본문 콘텐츠를 깔끔하게 감싸서 표시
|
||||
*/
|
||||
function renderLandingPage(site, parsedData, adsenseConfig) {
|
||||
const meta = parsedData.meta || {};
|
||||
const ads = adsenseConfig || {};
|
||||
const now = new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' });
|
||||
const contentHtml = meta._content_html || '<p>콘텐츠를 불러올 수 없습니다.</p>';
|
||||
|
||||
const seoTitle = escapeHtml(site.name || meta.title || '');
|
||||
const seoDesc = escapeHtml(site.description || meta.description || `${site.name} - 최신 정보`);
|
||||
|
||||
const adsenseScript = ads.client_id
|
||||
? `<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${escapeHtml(ads.client_id)}" crossorigin="anonymous"></script>`
|
||||
: '';
|
||||
const topAd = ads.client_id ? renderAdBlock(ads.client_id, ads.slots?.top) : '';
|
||||
const bottomAd = ads.client_id ? renderAdBlock(ads.client_id, ads.slots?.bottom) : '';
|
||||
|
||||
// JSON-LD
|
||||
const jsonLdData = JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
'name': site.name,
|
||||
'description': site.description || meta.description || '',
|
||||
'dateModified': new Date().toISOString()
|
||||
}).replace(/</g, '\\u003c');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="${seoDesc}">
|
||||
<meta name="keywords" content="${escapeHtml(site.name || '')}">
|
||||
<meta name="robots" content="index, follow">
|
||||
<meta name="googlebot" content="index, follow">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="${seoTitle}">
|
||||
<meta property="og:description" content="${seoDesc}">
|
||||
<meta property="og:locale" content="ko_KR">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="${seoTitle}">
|
||||
<meta name="twitter:description" content="${seoDesc}">
|
||||
<title>${seoTitle}</title>
|
||||
${adsenseScript}
|
||||
<script type="application/ld+json">${jsonLdData}</script>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📄</text></svg>">
|
||||
<style>
|
||||
:root{--primary:#6c5ce7;--primary-light:#a29bfe;--bg:#0f0f23;--bg-card:#1a1a2e;--text:#e0e0ee;--text-muted:#8888aa;--accent:#00cec9;--border:#2a2a4a}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Noto Sans KR',sans-serif;background:var(--bg);color:var(--text);line-height:1.8;min-height:100vh}
|
||||
.lp-header{background:linear-gradient(135deg,#0a0a2e 0%,#1a0a3e 50%,#0a1a3e 100%);padding:2.5rem 1rem 2rem;text-align:center;border-bottom:1px solid var(--border)}
|
||||
.lp-header h1{font-size:1.6rem;font-weight:800;color:#fff}
|
||||
.lp-header .sub{color:var(--text-muted);font-size:.8rem;margin-top:.5rem}
|
||||
.lp-container{max-width:900px;margin:0 auto;padding:2rem 1rem}
|
||||
.lp-content{background:var(--bg-card);border:1px solid var(--border);border-radius:16px;padding:2rem;margin:1rem 0;font-size:.95rem;line-height:1.9;word-break:keep-all}
|
||||
.lp-content img{max-width:100%;height:auto;border-radius:8px;margin:1rem 0}
|
||||
.lp-content h1,.lp-content h2,.lp-content h3{color:#fff;margin:1.5rem 0 .8rem;font-weight:700}
|
||||
.lp-content h1{font-size:1.5rem}.lp-content h2{font-size:1.25rem}.lp-content h3{font-size:1.1rem}
|
||||
.lp-content p{margin:.8rem 0;color:var(--text)}
|
||||
.lp-content a{color:var(--accent);text-decoration:underline}
|
||||
.lp-content ul,.lp-content ol{margin:.8rem 0 .8rem 1.5rem}
|
||||
.lp-content li{margin:.3rem 0}
|
||||
.lp-content table{width:100%;border-collapse:collapse;margin:1rem 0}
|
||||
.lp-content th,.lp-content td{border:1px solid var(--border);padding:.6rem .8rem;text-align:left}
|
||||
.lp-content th{background:rgba(108,92,231,.15);color:#fff}
|
||||
.lp-content blockquote{border-left:3px solid var(--primary);padding:.5rem 1rem;margin:1rem 0;background:rgba(108,92,231,.05);border-radius:0 8px 8px 0}
|
||||
.ad-box{background:var(--bg-card);border:1px dashed var(--border);border-radius:10px;padding:.8rem;margin:1.2rem 0;text-align:center;min-height:100px}
|
||||
.lp-footer{text-align:center;padding:2rem 1rem;margin-top:2rem;border-top:1px solid var(--border);color:var(--text-muted);font-size:.75rem}
|
||||
@media(max-width:640px){.lp-header h1{font-size:1.3rem}.lp-content{padding:1.2rem}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="lp-header">
|
||||
<h1>${seoTitle}</h1>
|
||||
<p class="sub">업데이트: ${now}</p>
|
||||
</header>
|
||||
<div class="lp-container">
|
||||
${topAd}
|
||||
<div class="lp-content">
|
||||
${contentHtml}
|
||||
</div>
|
||||
${bottomAd}
|
||||
</div>
|
||||
<footer class="lp-footer">
|
||||
<p>© ${new Date().getFullYear()} ${escapeHtml(site.name)}</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 광고 블록 렌더링 - slotId 없으면 자동 광고만
|
||||
*/
|
||||
|
||||
+60
-24
@@ -6,7 +6,7 @@
|
||||
<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>
|
||||
<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>
|
||||
@@ -37,30 +37,21 @@
|
||||
<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>파싱 규칙 (JSON)</label>
|
||||
<textarea id="f-rules" rows="14" style="font-size:.78rem">{
|
||||
"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" },
|
||||
"date": { "selector": "time.entry-date", "type": "attr", "attr": "datetime" }
|
||||
}
|
||||
}</textarea>
|
||||
<div style="margin-top:.4rem;font-size:.75rem;color:var(--muted)">
|
||||
<strong>discovery</strong>: 목록 페이지 URL → 최신 글 링크 자동 탐색
|
||||
<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">
|
||||
@@ -101,8 +92,10 @@ function renderSites() {
|
||||
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:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+s.url+'</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">' +
|
||||
@@ -111,7 +104,7 @@ function renderSites() {
|
||||
'<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>';
|
||||
}).join('') || '<tr><td colspan="8" style="text-align:center;padding:2rem" class="text-muted">사이트를 추가하세요</td></tr>';
|
||||
}
|
||||
|
||||
renderSites();
|
||||
@@ -124,6 +117,43 @@ async function reloadSites() {
|
||||
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 = '';
|
||||
@@ -134,6 +164,8 @@ function openAddModal() {
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -147,9 +179,12 @@ function editSite(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-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');
|
||||
}
|
||||
|
||||
@@ -161,7 +196,8 @@ async function saveSite() {
|
||||
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
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user