From 81f52afd57511405d50371532d39abdb8269b267 Mon Sep 17 00:00:00 2001 From: chpark Date: Sun, 29 Mar 2026 20:53:48 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=84=9C=EB=B8=8C=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=97=B0=EA=B2=B0=20=EC=88=98=EC=A0=95=20+=20SEO?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 도메인 매핑 저장 시 소문자 정규화 - 도메인 조회 시 case-insensitive 비교 (LOWER) - robots.txt, sitemap.xml 자동 생성 - SEO 메타 태그 (OG, Twitter Card, JSON-LD 구조화 데이터) --- src/routes/api.js | 3 +- src/routes/public.js | 63 +++++++++++++++++++++++++++++++++++++++-- src/services/crawler.js | 29 ++++++++++++++++++- 3 files changed, 90 insertions(+), 5 deletions(-) diff --git a/src/routes/api.js b/src/routes/api.js index 6754dee..b3a7239 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -202,9 +202,10 @@ router.get('/domains', async (req, res) => { router.post('/domains', async (req, res) => { try { const { domain, site_id, adsense_config_id } = req.body; + const normalizedDomain = domain ? domain.trim().toLowerCase() : domain; const { rows } = await db.query( `INSERT INTO domain_mappings (domain, site_id, adsense_config_id) VALUES ($1, $2, $3) RETURNING *`, - [domain, site_id, adsense_config_id || null] + [normalizedDomain, site_id, adsense_config_id || null] ); res.json(rows[0]); } catch (err) { diff --git a/src/routes/public.js b/src/routes/public.js index 05f373a..8c2b18a 100644 --- a/src/routes/public.js +++ b/src/routes/public.js @@ -6,6 +6,7 @@ const db = require('../db'); * 공개 사이트 라우터 * - slug 기반: /s/torrent-rank → sites.slug = 'torrent-rank'의 최신 rendered_html 반환 * - 도메인 기반: Host 헤더로 domain_mappings 조회 + * - SEO: robots.txt, sitemap.xml 자동 생성 */ // slug 기반 접근 @@ -31,7 +32,7 @@ router.get('/s/:slug', async (req, res) => { // 도메인 기반 접근 (미들웨어로 사용) async function domainRouter(req, res, next) { - const host = req.hostname; + const host = (req.hostname || '').toLowerCase(); // 관리자 도메인이면 무시 (관리자 라우터가 처리) if (host === 'admin.startover.co.kr' || host === 'localhost') { @@ -39,10 +40,20 @@ async function domainRouter(req, res, next) { } // 관리자 경로는 무시 - if (req.path.startsWith('/admin') || req.path.startsWith('/api') || req.path.startsWith('/s/') || req.path.startsWith('/login') || req.path.startsWith('/logout')) { + if (req.path.startsWith('/admin') || req.path.startsWith('/api') || req.path.startsWith('/s/') || req.path.startsWith('/login') || req.path.startsWith('/logout') || req.path.startsWith('/public')) { return next(); } + // robots.txt + if (req.path === '/robots.txt') { + return handleRobotsTxt(req, res, host); + } + + // sitemap.xml + if (req.path === '/sitemap.xml') { + return handleSitemapXml(req, res, host); + } + // 루트 경로일 때만 도메인 매핑 처리 if (req.path !== '/' && req.path !== '/index.html') { return next(); @@ -52,7 +63,7 @@ async function domainRouter(req, res, next) { const { rows } = await db.query(` SELECT dm.site_id, dm.adsense_config_id FROM domain_mappings dm - WHERE dm.domain = $1 AND dm.is_active = TRUE + WHERE LOWER(dm.domain) = $1 AND dm.is_active = TRUE `, [host]); if (rows.length === 0) return next(); @@ -75,4 +86,50 @@ async function domainRouter(req, res, next) { } } +// ===== SEO: robots.txt ===== +async function handleRobotsTxt(req, res, host) { + const protocol = req.protocol; + const robotsTxt = `User-agent: * +Allow: / + +Sitemap: ${protocol}://${host}/sitemap.xml +`; + res.type('text/plain').send(robotsTxt); +} + +// ===== SEO: sitemap.xml ===== +async function handleSitemapXml(req, res, host) { + try { + const { rows } = await db.query(` + SELECT dm.site_id, cr.crawled_at + FROM domain_mappings dm + LEFT JOIN LATERAL ( + SELECT crawled_at FROM crawl_results + WHERE site_id = dm.site_id AND status = 'success' + ORDER BY crawled_at DESC LIMIT 1 + ) cr ON TRUE + WHERE LOWER(dm.domain) = $1 AND dm.is_active = TRUE + `, [host]); + + const protocol = req.protocol; + const lastmod = rows.length > 0 && rows[0].crawled_at + ? new Date(rows[0].crawled_at).toISOString().split('T')[0] + : new Date().toISOString().split('T')[0]; + + const sitemap = ` + + + ${protocol}://${host}/ + ${lastmod} + daily + 1.0 + +`; + + res.type('application/xml').send(sitemap); + } catch (err) { + res.status(500).send('Internal Server Error'); + } +} + module.exports = { router, domainRouter }; diff --git a/src/services/crawler.js b/src/services/crawler.js index f11d2a9..597d175 100644 --- a/src/services/crawler.js +++ b/src/services/crawler.js @@ -230,14 +230,41 @@ function renderPublicPage(site, parsedData, adsenseConfig) { const topAd = ads.client_id ? renderAdBlock(ads.client_id, ads.slots?.top) : ''; const bottomAd = ads.client_id ? renderAdBlock(ads.client_id, ads.slots?.bottom) : ''; + // SEO: JSON-LD 구조화된 데이터 + const jsonLdData = JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'ItemList', + 'name': site.name, + 'description': site.description || '', + 'numberOfItems': activeItems.length, + 'dateModified': new Date().toISOString(), + 'itemListElement': activeItems.slice(0, 10).map((item, idx) => ({ + '@type': 'ListItem', + 'position': item.rank || item._index || (idx + 1), + 'name': item.name || '', + 'url': item.url || item.url_text || '' + })) + }).replace(/ - + + + + + + + + + + + ${escapeHtml(site.name || 'Torrent Rank')} ${adsenseScript} +