Improve sitemap, RSS, and SEO guardrails
TaxBaik CI/CD / build-and-deploy (push) Successful in 59s

This commit is contained in:
2026-07-11 13:09:57 +09:00
parent e0945f46a6
commit bcc3d6e680
12 changed files with 215 additions and 104 deletions
+19
View File
@@ -27,6 +27,9 @@ jobs:
set -e
python3 scripts/validate_locked_db_connection.py
- name: Validate SEO search guardrails
run: python3 scripts/validate_seo.py
- name: Build solution
run: dotnet build src/TaxBaik.sln -c Release --no-restore -p:ContinuousIntegrationBuild=true
@@ -339,6 +342,22 @@ jobs:
fi
echo "✓ [6/6] 관리자 페이지 로드 완료"
# 검색 엔진은 Content-Type뿐 아니라 유효한 XML 본문을 요구한다.
# 공통 Razor 레이아웃이 다시 섞이는 회귀를 배포 직후 차단한다.
SITEMAP_BODY=\$(curl -fsS http://127.0.0.1:5001/sitemap.xml 2>/dev/null || true)
RSS_BODY=\$(curl -fsS http://127.0.0.1:5001/rss.xml 2>/dev/null || true)
if ! printf '%s' "\$SITEMAP_BODY" | grep -q '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' \
|| printf '%s' "\$SITEMAP_BODY" | grep -q '<!DOCTYPE html>'; then
echo "❌ sitemap.xml XML 응답 검증 실패" >&2
exit 1
fi
if ! printf '%s' "\$RSS_BODY" | grep -q '<rss version="2.0"' \
|| printf '%s' "\$RSS_BODY" | grep -q '<!DOCTYPE html>'; then
echo "❌ rss.xml XML 응답 검증 실패" >&2
exit 1
fi
echo "✓ [7/7] 사이트맵 및 RSS XML 응답 검증 완료"
echo "✓ 서비스 정상 (시도 \$i/\$ATTEMPTS)"
# 구 배포 디렉토리 정리 (최근 5개 보존)
ls -1dt \$DEPLOY_HOME/deployments/taxbaik_* 2>/dev/null \
+17
View File
@@ -0,0 +1,17 @@
# SEO Search Guardrails
These rules protect search discovery and must remain enforced by `scripts/validate_seo.py` in CI.
- `sitemap.xml` and `rss.xml` are XML-only responses. They must not render the shared HTML layout.
- `sitemap.xml` includes canonical, indexable URLs only. Redirect-only URLs and authenticated routes are excluded.
- Blog URLs use the post's actual `UpdatedAt` value for `lastmod`; the homepage uses the most recent active FAQ, announcement, or displayed blog update.
- Sitemap collection must page through every published blog post. Do not introduce a fixed total-content limit.
- `/rss.xml` is the one canonical feed. `/feed.xml` permanently redirects to it.
- `robots.txt` declares one canonical sitemap and never blocks XML resources.
- Canonical URLs always use `https://www.taxbaik.com` and include the current indexable path. Paginated and category-filtered blog pages set an explicit canonical URL.
Run locally with:
```powershell
python scripts/validate_seo.py
```
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Static SEO guardrails run in CI before deployment."""
from pathlib import Path
import re
import sys
ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "src" / "TaxBaik.Web"
errors: list[str] = []
def read(relative: str) -> str:
return (ROOT / relative).read_text(encoding="utf-8")
def require(condition: bool, message: str) -> None:
if not condition:
errors.append(message)
sitemap_view = read("src/TaxBaik.Web/Pages/Sitemap.cshtml")
rss_view = read("src/TaxBaik.Web/Pages/Rss.cshtml")
feed_view = read("src/TaxBaik.Web/Pages/Feed.cshtml")
sitemap_model = read("src/TaxBaik.Web/Pages/Sitemap.cshtml.cs")
layout = read("src/TaxBaik.Web/Pages/_Layout.cshtml")
robots = read("src/TaxBaik.Web/wwwroot/robots.txt")
require("Layout = null" in sitemap_view, "sitemap.xml must render without the HTML layout")
require("Layout = null" in rss_view, "rss.xml must render without the HTML layout")
require("Html.Raw(rssContent.ToString())" in rss_view, "RSS XML must not be HTML-encoded")
require("StatusCodes.Status308PermanentRedirect" in feed_view, "feed.xml must permanently redirect to rss.xml")
require("DateTime.UtcNow:yyyy-MM-dd" not in sitemap_view, "sitemap lastmod must not use the current request date")
require("GetAllPublishedPostsAsync" in sitemap_model, "sitemap must page through all published blog posts")
require("/faq" not in sitemap_model and "/announcement" not in sitemap_model and "/inquiry" not in sitemap_model,
"redirect-only URLs must not be included in the sitemap")
require("var canonicalUrl" in layout and "Context.Request.Path" in layout,
"default canonical URL must use the current path")
sitemap_directives = re.findall(r"^Sitemap:\s*(\S+)\s*$", robots, flags=re.MULTILINE)
require(sitemap_directives == ["https://www.taxbaik.com/sitemap.xml"],
"robots.txt must declare exactly one canonical sitemap URL")
require(not re.search(r"^Disallow:\s*.*\.xml", robots, flags=re.MULTILINE),
"robots.txt must not block XML feeds or the sitemap")
if errors:
print("SEO guardrail validation failed:", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
sys.exit(1)
print("SEO guardrail validation passed")
+6
View File
@@ -2,6 +2,12 @@
@model TaxBaik.Web.Pages.Blog.BlogIndexModel
@{
ViewData["Title"] = "블로그 | 백원숙 세무회계";
ViewData["Description"] = "세금 신고, 부동산 세금, 증여·상속 관련 최신 세무 정보와 절세 팁을 확인하세요.";
var canonicalQuery = Model.SelectedCategoryId.HasValue
? $"?categoryId={Model.SelectedCategoryId.Value}" + (Model.CurrentPage > 1 ? $"&page={Model.CurrentPage}" : string.Empty)
: Model.CurrentPage > 1 ? $"?page={Model.CurrentPage}" : string.Empty;
ViewData["CanonicalUrl"] = $"https://www.taxbaik.com/blog{canonicalQuery}";
ViewData["OgUrl"] = ViewData["CanonicalUrl"];
}
<div class="container py-5">
+1 -1
View File
@@ -4,7 +4,7 @@
ViewData["Title"] = Model.Post?.SeoTitle ?? Model.Post?.Title;
ViewData["Description"] = Model.Post?.SeoDescription ?? "";
ViewData["OgImage"] = Model.Post?.ThumbnailUrl ?? "";
var canonicalUrl = $"{Request.Scheme}://{Request.Host}{Request.PathBase}/blog/{Model.Post?.Slug}";
var canonicalUrl = $"https://www.taxbaik.com/blog/{Uri.EscapeDataString(Model.Post?.Slug ?? string.Empty)}";
ViewData["CanonicalUrl"] = canonicalUrl;
ViewData["OgUrl"] = canonicalUrl;
}
+3 -32
View File
@@ -1,35 +1,6 @@
@page "/feed.xml"
@model TaxBaik.Web.Pages.RssModel
@{
Response.ContentType = "application/rss+xml; charset=utf-8";
}@{
var rssContent = new System.Text.StringBuilder();
rssContent.AppendLine("<?xml version=\"1.0\" encoding=\"utf-8\"?>");
rssContent.AppendLine("<rss version=\"2.0\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">");
rssContent.AppendLine(" <channel>");
rssContent.AppendLine(" <title>백원숙 세무회계 - 블로그</title>");
rssContent.AppendLine(" <link>https://www.taxbaik.com</link>");
rssContent.AppendLine(" <description>세무사 백원숙의 세금, 부동산, 가족자산 전문 블로그</description>");
rssContent.AppendLine(" <language>ko-kr</language>");
rssContent.AppendLine($" <lastBuildDate>{Model.LastBuildDate}</lastBuildDate>");
rssContent.AppendLine(" <ttl>60</ttl>");
foreach (var post in Model.Posts)
{
rssContent.AppendLine(" <item>");
rssContent.AppendLine($" <title>{System.Net.WebUtility.HtmlEncode(post.Title)}</title>");
rssContent.AppendLine($" <link>https://www.taxbaik.com/blog/{post.Slug}</link>");
rssContent.AppendLine($" <guid isPermaLink=\"true\">https://www.taxbaik.com/blog/{post.Slug}</guid>");
rssContent.AppendLine($" <pubDate>{post.PublishedAt?.ToString("R")}</pubDate>");
var desc = post.Content?.Substring(0, Math.Min(200, post.Content?.Length ?? 0)) ?? "";
rssContent.AppendLine($" <description>{System.Net.WebUtility.HtmlEncode(desc)}</description>");
if (!string.IsNullOrEmpty(post.Content))
{
rssContent.AppendLine($" <content:encoded><![CDATA[{post.Content}]]></content:encoded>");
Layout = null;
Response.StatusCode = StatusCodes.Status308PermanentRedirect;
Response.Headers.Location = "/rss.xml";
}
rssContent.AppendLine(" </item>");
}
rssContent.AppendLine(" </channel>");
rssContent.AppendLine("</rss>");
}@rssContent.ToString()
+5 -3
View File
@@ -1,17 +1,19 @@
@page "/rss.xml"
@model TaxBaik.Web.Pages.RssModel
@{
Layout = null;
Response.ContentType = "application/rss+xml; charset=utf-8";
}@{
var rssContent = new System.Text.StringBuilder();
rssContent.AppendLine("<?xml version=\"1.0\" encoding=\"utf-8\"?>");
rssContent.AppendLine("<rss version=\"2.0\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">");
rssContent.AppendLine("<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">");
rssContent.AppendLine(" <channel>");
rssContent.AppendLine(" <title>백원숙 세무회계 - 블로그</title>");
rssContent.AppendLine(" <link>https://www.taxbaik.com</link>");
rssContent.AppendLine(" <description>세무사 백원숙의 세금, 부동산, 가족자산 전문 블로그</description>");
rssContent.AppendLine(" <language>ko-kr</language>");
rssContent.AppendLine($" <lastBuildDate>{Model.LastBuildDate}</lastBuildDate>");
rssContent.AppendLine(" <atom:link href=\"https://www.taxbaik.com/rss.xml\" rel=\"self\" type=\"application/rss+xml\" />");
rssContent.AppendLine(" <ttl>60</ttl>");
foreach (var post in Model.Posts)
@@ -25,11 +27,11 @@
rssContent.AppendLine($" <description>{System.Net.WebUtility.HtmlEncode(desc)}</description>");
if (!string.IsNullOrEmpty(post.Content))
{
rssContent.AppendLine($" <content:encoded><![CDATA[{post.Content}]]></content:encoded>");
rssContent.AppendLine($" <content:encoded><![CDATA[{post.Content.Replace("]]>", "]]]]><![CDATA[>")}]]></content:encoded>");
}
rssContent.AppendLine(" </item>");
}
rssContent.AppendLine(" </channel>");
rssContent.AppendLine("</rss>");
}@rssContent.ToString()
}@Html.Raw(rssContent.ToString())
+6 -2
View File
@@ -1,14 +1,18 @@
@page "/sitemap.xml"
@model TaxBaik.Web.Pages.SitemapModel
@{
Layout = null;
Response.ContentType = "application/xml; charset=utf-8";
}<?xml version="1.0" encoding="utf-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
@foreach (var url in Model.Urls)
{
<url>
<loc>@System.Net.WebUtility.HtmlEncode(url)</loc>
<lastmod>@DateTime.UtcNow:yyyy-MM-dd</lastmod>
<loc>@url.Location</loc>
@if (url.LastModified.HasValue)
{
<lastmod>@url.LastModified.Value.ToUniversalTime().ToString("yyyy-MM-dd")</lastmod>
}
</url>
}
</urlset>
+70 -27
View File
@@ -5,48 +5,91 @@ namespace TaxBaik.Web.Pages;
public class SitemapModel : PageModel
{
private const string BaseUrl = "https://www.taxbaik.com";
private const int BlogPageSize = 100;
private readonly BlogService _blogService;
private readonly FaqService _faqService;
private readonly AnnouncementService _announcementService;
private readonly ILogger<SitemapModel> _logger;
public List<string> Urls { get; set; } = [];
public List<SitemapUrl> Urls { get; } = [];
public SitemapModel(BlogService blogService)
public SitemapModel(
BlogService blogService,
FaqService faqService,
AnnouncementService announcementService,
ILogger<SitemapModel> logger)
{
_blogService = blogService;
_faqService = faqService;
_announcementService = announcementService;
_logger = logger;
}
public async Task OnGetAsync()
{
var baseUrl = "https://www.taxbaik.com";
// FAQ와 공지사항은 홈에만 표시되고 각각의 URL은 리다이렉트된다.
// 따라서 홈의 변경일에 합산하고 리다이렉트 URL은 sitemap에서 제외한다.
var homeLastModified = default(DateTime);
var posts = new List<TaxBaik.Domain.Entities.BlogPost>();
// 정적 페이지 (항상 포함)
Urls.AddRange(new[]
{
$"{baseUrl}",
$"{baseUrl}/about",
$"{baseUrl}/services",
$"{baseUrl}/contact",
$"{baseUrl}/privacy",
$"{baseUrl}/terms",
$"{baseUrl}/blog",
$"{baseUrl}/faq",
$"{baseUrl}/announcement",
$"{baseUrl}/inquiry"
});
// 동적 블로그 포스트 (DB 오류 처리)
try
{
var (posts, _) = await _blogService.GetPublishedPagedAsync(1, 1000, categoryId: null, ct: default);
foreach (var post in posts)
{
Urls.Add($"{baseUrl}/blog/{post.Slug}");
}
var faqTask = _faqService.GetActiveAsync(HttpContext.RequestAborted);
var announcementTask = _announcementService.GetActiveAsync(HttpContext.RequestAborted);
var postsTask = GetAllPublishedPostsAsync(HttpContext.RequestAborted);
await Task.WhenAll(faqTask, announcementTask, postsTask);
posts = await postsTask;
homeLastModified = (await faqTask)
.Select(faq => faq.UpdatedAt)
.Concat((await announcementTask).Select(announcement => announcement.UpdatedAt))
.Concat(posts.Take(3).Select(post => post.UpdatedAt))
.DefaultIfEmpty()
.Max();
}
catch (Exception ex)
{
// DB 연결 실패 등의 경우, 정적 페이지만으로도 sitemap 제공
// 프로덕션에서는 DB가 있으므로 이 경로는 사용되지 않음
System.Diagnostics.Debug.WriteLine($"Sitemap: Blog posts load failed: {ex.Message}");
// DB 문제 중에도 검색 엔진이 기존 핵심 페이지를 계속 발견할 수 있게 한다.
_logger.LogError(ex, "Failed to load dynamic sitemap content");
}
Urls.AddRange(new[]
{
new SitemapUrl($"{BaseUrl}/", homeLastModified == default ? null : homeLastModified),
new SitemapUrl($"{BaseUrl}/about"),
new SitemapUrl($"{BaseUrl}/services"),
new SitemapUrl($"{BaseUrl}/contact"),
new SitemapUrl($"{BaseUrl}/privacy"),
new SitemapUrl($"{BaseUrl}/terms"),
new SitemapUrl($"{BaseUrl}/blog")
});
foreach (var post in posts)
{
Urls.Add(new SitemapUrl(
$"{BaseUrl}/blog/{Uri.EscapeDataString(post.Slug)}",
post.UpdatedAt == default ? post.PublishedAt : post.UpdatedAt));
}
}
private async Task<List<TaxBaik.Domain.Entities.BlogPost>> GetAllPublishedPostsAsync(CancellationToken ct)
{
var posts = new List<TaxBaik.Domain.Entities.BlogPost>();
var page = 1;
while (true)
{
var (items, total) = await _blogService.GetPublishedPagedAsync(page, BlogPageSize, ct: ct);
posts.AddRange(items);
if (posts.Count >= total || !items.Any())
return posts;
page++;
}
}
public sealed record SitemapUrl(string Location, DateTime? LastModified = null);
}
+13 -8
View File
@@ -1,3 +1,9 @@
@{
const string SiteUrl = "https://www.taxbaik.com";
var canonicalUrl = ViewData["CanonicalUrl"] as string ?? $"{SiteUrl}{Context.Request.PathBase}{Context.Request.Path}";
var ogUrl = ViewData["OgUrl"] as string ?? canonicalUrl;
var robots = ViewData["Robots"] as string ?? "index, follow";
}
<!DOCTYPE html>
<html lang="ko">
<head>
@@ -12,30 +18,29 @@
<meta property="og:title" content="@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")" />
<meta property="og:description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" />
<meta property="og:image" content="@(ViewData["OgImage"] ?? "https://www.taxbaik.com/images/og-image.jpg")" />
<meta property="og:url" content="@(ViewData["OgUrl"] ?? "https://www.taxbaik.com/")" />
<meta property="og:url" content="@ogUrl" />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:title" content="@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")" />
<meta property="twitter:description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" />
<meta property="twitter:image" content="@(ViewData["OgImage"] ?? "https://www.taxbaik.com/images/og-image.jpg")" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="@(ViewData["Title"] ?? "백원숙 세무회계 - 세무사 전문 상담")" />
<meta name="twitter:description" content="@(ViewData["Description"] ?? "백원숙 세무회계 - 사업자 기장, 부동산 양도세·증여세, 종합소득세 전문 상담. 맞춤형 세무 절세 컨설팅 제공.")" />
<meta name="twitter:image" content="@(ViewData["OgImage"] ?? "https://www.taxbaik.com/images/og-image.jpg")" />
<!-- 검색엔진 등록용 소유권 인증 메타 태그 (발급받으신 토큰이 있으면 아래 content에 넣어 주시면 됩니다) -->
<!-- <meta name="naver-site-verification" content="네이버_서치어드바이저_토큰_입력" /> -->
<!-- <meta name="google-site-verification" content="구글_서치콘솔_토큰_입력" /> -->
<meta name="robots" content="index, follow" />
<meta name="robots" content="@robots" />
<meta name="theme-color" content="#C89D6E" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- RSS Feed -->
<link rel="alternate" type="application/rss+xml" title="백원숙 세무회계 블로그" href="/rss.xml" />
<link rel="alternate" type="application/rss+xml" title="TaxBaik Blog Feed" href="/feed.xml" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Noto+Sans+KR:wght@400;500;700&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="canonical" href="@(ViewData["CanonicalUrl"] ?? "https://www.taxbaik.com/")" />
<link rel="canonical" href="@canonicalUrl" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
@@ -46,8 +46,8 @@ public class SitemapValidationService
result.TotalUrls = staticUrls.Count;
// 2. 동적 블로그 포스트 검증
var (posts, _) = await _blogService.GetPublishedPagedAsync(1, 1000, categoryId: null, ct: default);
var blogUrls = posts.Select(p => $"https://www.taxbaik.com/blog/{p.Slug}").ToList();
var posts = await GetAllPublishedPostsAsync();
var blogUrls = posts.Select(p => $"https://www.taxbaik.com/blog/{Uri.EscapeDataString(p.Slug)}").ToList();
ValidateUrls(blogUrls, result);
@@ -251,10 +251,25 @@ public class SitemapValidationService
$"{baseUrl}/contact",
$"{baseUrl}/privacy",
$"{baseUrl}/terms",
$"{baseUrl}/blog",
$"{baseUrl}/faq",
$"{baseUrl}/announcement",
$"{baseUrl}/inquiry"
$"{baseUrl}/blog"
};
}
private async Task<List<TaxBaik.Domain.Entities.BlogPost>> GetAllPublishedPostsAsync()
{
const int pageSize = 100;
var posts = new List<TaxBaik.Domain.Entities.BlogPost>();
var page = 1;
while (true)
{
var (items, total) = await _blogService.GetPublishedPagedAsync(page, pageSize);
posts.AddRange(items);
if (posts.Count >= total || !items.Any())
return posts;
page++;
}
}
}
-24
View File
@@ -7,30 +7,6 @@ Allow: /
Disallow: /admin/
Disallow: /portal/
Disallow: /manage/
Disallow: *.json$
Disallow: *.xml$ (sitemap 제외)
# 검색 엔진별 크롤링 속도 제한
User-agent: Googlebot
Crawl-delay: 1
User-agent: Bingbot
Crawl-delay: 2
User-agent: Yeti
Crawl-delay: 1
# Naver, Kakao 검색 엔진
User-agent: Naver
Allow: /
User-agent: Kakaobot
Allow: /
# Sitemap 위치
Sitemap: https://www.taxbaik.com/sitemap.xml
Sitemap: https://www.taxbaik.com/sitemap.xml
# RSS 피드
Sitemap: https://www.taxbaik.com/rss.xml
Sitemap: https://www.taxbaik.com/feed.xml