feat: 랜딩 페이지 템플릿 추가

- 목록(list) / 랜딩(landing) 2가지 템플릿 지원
- 랜딩: content_selector로 본문 추출, 깔끔한 다크 테마 렌더링
- 사이트 관리 UI에 템플릿 선택 드롭다운 추가
- 템플릿별 파싱 규칙 기본값 자동 세팅
This commit is contained in:
chpark
2026-03-29 23:32:32 +09:00
parent 2e45cd4195
commit 260aac5d7c
2 changed files with 176 additions and 28 deletions
+115 -3
View File
@@ -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>&#x1f4c4;</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>&copy; ${new Date().getFullYear()} ${escapeHtml(site.name)}</p>
</footer>
</body>
</html>`;
}
/**
* 광고 블록 렌더링 - slotId 없으면 자동 광고만
*/
+61 -25
View File
@@ -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,29 +37,20 @@
<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="사이트 설명"></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">{
"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 → 최신 글 링크 자동 탐색
</div>
<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>
@@ -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;