From 260aac5d7c101a5ae2d7bd63889441a57b6e0f52 Mon Sep 17 00:00:00 2001 From: chpark Date: Sun, 29 Mar 2026 23:32:32 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=9E=9C=EB=94=A9=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 목록(list) / 랜딩(landing) 2가지 템플릿 지원 - 랜딩: content_selector로 본문 추출, 깔끔한 다크 테마 렌더링 - 사이트 관리 UI에 템플릿 선택 드롭다운 추가 - 템플릿별 파싱 규칙 기본값 자동 세팅 --- src/services/crawler.js | 118 +++++++++++++++++++++++++++++++++++++++- views/admin/sites.ejs | 86 ++++++++++++++++++++--------- 2 files changed, 176 insertions(+), 28 deletions(-) diff --git a/src/services/crawler.js b/src/services/crawler.js index 597d175..716c549 100644 --- a/src/services/crawler.js +++ b/src/services/crawler.js @@ -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 렌더링 + * - 본문 콘텐츠를 깔끔하게 감싸서 표시 + */ +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 || '

콘텐츠를 불러올 수 없습니다.

'; + + const seoTitle = escapeHtml(site.name || meta.title || ''); + const seoDesc = escapeHtml(site.description || meta.description || `${site.name} - 최신 정보`); + + const adsenseScript = ads.client_id + ? `` + : ''; + 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(/ + + + + + + + + + + + + + + + +${seoTitle} +${adsenseScript} + + + + + +
+

${seoTitle}

+

업데이트: ${now}

+
+
+ ${topAd} +
+ ${contentHtml} +
+ ${bottomAd} +
+ + +`; +} + /** * 광고 블록 렌더링 - slotId 없으면 자동 광고만 */ diff --git a/views/admin/sites.ejs b/views/admin/sites.ejs index 51b4092..bbf16c8 100644 --- a/views/admin/sites.ejs +++ b/views/admin/sites.ejs @@ -6,7 +6,7 @@ - +
ID사이트명URL슬러그스케줄마지막 크롤링액션
ID사이트명URL템플릿슬러그스케줄마지막 크롤링액션
@@ -37,29 +37,20 @@
-
+
+
+
+ + +
+
- -
- discovery: 목록 페이지 URL → 최신 글 링크 자동 탐색 -
+ +
@@ -101,8 +92,10 @@ function renderSites() { document.getElementById('sites-tbody').innerHTML = sites.map(function(s){ var sched = s.schedule_active ? ''+s.cron_schedule+'' : 'OFF'; var hasResult = parseInt(s.crawl_count) > 0; + var tplBadge = s.template==='landing'?'랜딩':'목록'; return ''+s.id+''+s.name+'' + - ''+s.url+'' + + ''+s.url+'' + + ''+tplBadge+'' + ''+(s.slug?''+s.slug+'':'-')+'' + ''+sched+''+timeAgo(s.last_crawled_at)+'' + '' + @@ -111,7 +104,7 @@ function renderSites() { '' + '' + ''; - }).join('') || '사이트를 추가하세요'; + }).join('') || '사이트를 추가하세요'; } 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 = 'content_selector: 본문 영역 CSS 셀렉터 (콤마로 여러 개 가능, 첫 번째 매칭 사용)
remove_selectors: 제거할 요소들'; + } else { + if (!rulesEl.value || rulesEl.value === RULES_LANDING) rulesEl.value = RULES_LIST; + helpEl.innerHTML = 'discovery: 목록 페이지 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;