fix: 서브도메인 연결 수정 + SEO 기능 추가
- 도메인 매핑 저장 시 소문자 정규화 - 도메인 조회 시 case-insensitive 비교 (LOWER) - robots.txt, sitemap.xml 자동 생성 - SEO 메타 태그 (OG, Twitter Card, JSON-LD 구조화 데이터)
This commit is contained in:
+2
-1
@@ -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
@@ -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
@@ -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>🎯</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}
|
||||
|
||||
Reference in New Issue
Block a user