fix: 서브도메인 연결 수정 + SEO 기능 추가

- 도메인 매핑 저장 시 소문자 정규화
- 도메인 조회 시 case-insensitive 비교 (LOWER)
- robots.txt, sitemap.xml 자동 생성
- SEO 메타 태그 (OG, Twitter Card, JSON-LD 구조화 데이터)
This commit is contained in:
chpark
2026-03-29 20:53:48 +09:00
parent 40e3713f99
commit 81f52afd57
3 changed files with 90 additions and 5 deletions
+2 -1
View File
@@ -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) {
+60 -3
View File
@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>${protocol}://${host}/</loc>
<lastmod>${lastmod}</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
</urlset>`;
res.type('application/xml').send(sitemap);
} catch (err) {
res.status(500).send('Internal Server Error');
}
}
module.exports = { router, domainRouter };
+28 -1
View File
@@ -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(/</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="${escapeHtml(site.description || '')}">
<meta name="description" content="${escapeHtml(site.description || `${site.name} - 최신 순위 및 추천 정보를 실시간으로 확인하세요.`)}">
<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="${escapeHtml(site.name || '')}">
<meta property="og:description" content="${escapeHtml(site.description || `${site.name} - 최신 순위 및 추천 정보를 실시간으로 확인하세요.`)}">
<meta property="og:locale" content="ko_KR">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="${escapeHtml(site.name || '')}">
<meta name="twitter:description" content="${escapeHtml(site.description || `${site.name} - 최신 순위 및 추천 정보를 실시간으로 확인하세요.`)}">
<title>${escapeHtml(site.name || 'Torrent Rank')}</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>&#x1f3af;</text></svg>">
<style>
:root{--primary:#6c5ce7;--primary-light:#a29bfe;--bg:#0a0a1a;--bg-card:#12122a;--bg-card-hover:#1a1a3e;--text:#e0e0ee;--text-muted:#7878aa;--accent:#00cec9;--gold:#ffd700;--silver:#c0c0c0;--bronze:#cd7f32;--danger:#ff6b6b;--border:#1e1e44;--star:#f9ca24}