From bcc3d6e6808652a18a8156f4f800ae9e0890976d Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Sat, 11 Jul 2026 13:09:57 +0900 Subject: [PATCH] Improve sitemap, RSS, and SEO guardrails --- .gitea/workflows/deploy.yml | 19 ++++ docs/seo-guardrails.md | 17 ++++ scripts/validate_seo.py | 53 ++++++++++ src/TaxBaik.Web/Pages/Blog/Index.cshtml | 6 ++ src/TaxBaik.Web/Pages/Blog/Post.cshtml | 2 +- src/TaxBaik.Web/Pages/Feed.cshtml | 37 +------ src/TaxBaik.Web/Pages/Rss.cshtml | 8 +- src/TaxBaik.Web/Pages/Sitemap.cshtml | 8 +- src/TaxBaik.Web/Pages/Sitemap.cshtml.cs | 97 +++++++++++++------ src/TaxBaik.Web/Pages/_Layout.cshtml | 21 ++-- .../Services/SitemapValidationService.cs | 27 ++++-- src/TaxBaik.Web/wwwroot/robots.txt | 24 ----- 12 files changed, 215 insertions(+), 104 deletions(-) create mode 100644 docs/seo-guardrails.md create mode 100644 scripts/validate_seo.py diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 6ba2ccb..3b5797c 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -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 '' \ + || printf '%s' "\$SITEMAP_BODY" | grep -q ''; then + echo "❌ sitemap.xml XML 응답 검증 실패" >&2 + exit 1 + fi + if ! printf '%s' "\$RSS_BODY" | grep -q ''; 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 \ diff --git a/docs/seo-guardrails.md b/docs/seo-guardrails.md new file mode 100644 index 0000000..bcefdaa --- /dev/null +++ b/docs/seo-guardrails.md @@ -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 +``` diff --git a/scripts/validate_seo.py b/scripts/validate_seo.py new file mode 100644 index 0000000..08eb54e --- /dev/null +++ b/scripts/validate_seo.py @@ -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") diff --git a/src/TaxBaik.Web/Pages/Blog/Index.cshtml b/src/TaxBaik.Web/Pages/Blog/Index.cshtml index f1768de..564c822 100644 --- a/src/TaxBaik.Web/Pages/Blog/Index.cshtml +++ b/src/TaxBaik.Web/Pages/Blog/Index.cshtml @@ -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"]; }
diff --git a/src/TaxBaik.Web/Pages/Blog/Post.cshtml b/src/TaxBaik.Web/Pages/Blog/Post.cshtml index 148e2d6..c8e6cd6 100644 --- a/src/TaxBaik.Web/Pages/Blog/Post.cshtml +++ b/src/TaxBaik.Web/Pages/Blog/Post.cshtml @@ -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; } diff --git a/src/TaxBaik.Web/Pages/Feed.cshtml b/src/TaxBaik.Web/Pages/Feed.cshtml index c2868e3..181d767 100644 --- a/src/TaxBaik.Web/Pages/Feed.cshtml +++ b/src/TaxBaik.Web/Pages/Feed.cshtml @@ -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(""); - rssContent.AppendLine(""); - rssContent.AppendLine(" "); - rssContent.AppendLine(" 백원숙 세무회계 - 블로그"); - rssContent.AppendLine(" https://www.taxbaik.com"); - rssContent.AppendLine(" 세무사 백원숙의 세금, 부동산, 가족자산 전문 블로그"); - rssContent.AppendLine(" ko-kr"); - rssContent.AppendLine($" {Model.LastBuildDate}"); - rssContent.AppendLine(" 60"); - - foreach (var post in Model.Posts) - { - rssContent.AppendLine(" "); - rssContent.AppendLine($" {System.Net.WebUtility.HtmlEncode(post.Title)}"); - rssContent.AppendLine($" https://www.taxbaik.com/blog/{post.Slug}"); - rssContent.AppendLine($" https://www.taxbaik.com/blog/{post.Slug}"); - rssContent.AppendLine($" {post.PublishedAt?.ToString("R")}"); - var desc = post.Content?.Substring(0, Math.Min(200, post.Content?.Length ?? 0)) ?? ""; - rssContent.AppendLine($" {System.Net.WebUtility.HtmlEncode(desc)}"); - if (!string.IsNullOrEmpty(post.Content)) - { - rssContent.AppendLine($" "); - } - rssContent.AppendLine(" "); - } - - rssContent.AppendLine(" "); - rssContent.AppendLine(""); -}@rssContent.ToString() + Layout = null; + Response.StatusCode = StatusCodes.Status308PermanentRedirect; + Response.Headers.Location = "/rss.xml"; +} diff --git a/src/TaxBaik.Web/Pages/Rss.cshtml b/src/TaxBaik.Web/Pages/Rss.cshtml index 51221ba..9393831 100644 --- a/src/TaxBaik.Web/Pages/Rss.cshtml +++ b/src/TaxBaik.Web/Pages/Rss.cshtml @@ -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(""); - rssContent.AppendLine(""); + rssContent.AppendLine(""); rssContent.AppendLine(" "); rssContent.AppendLine(" 백원숙 세무회계 - 블로그"); rssContent.AppendLine(" https://www.taxbaik.com"); rssContent.AppendLine(" 세무사 백원숙의 세금, 부동산, 가족자산 전문 블로그"); rssContent.AppendLine(" ko-kr"); rssContent.AppendLine($" {Model.LastBuildDate}"); + rssContent.AppendLine(" "); rssContent.AppendLine(" 60"); foreach (var post in Model.Posts) @@ -25,11 +27,11 @@ rssContent.AppendLine($" {System.Net.WebUtility.HtmlEncode(desc)}"); if (!string.IsNullOrEmpty(post.Content)) { - rssContent.AppendLine($" "); + rssContent.AppendLine($" ", "]]]]>")}]]>"); } rssContent.AppendLine(" "); } rssContent.AppendLine(" "); rssContent.AppendLine(""); -}@rssContent.ToString() +}@Html.Raw(rssContent.ToString()) diff --git a/src/TaxBaik.Web/Pages/Sitemap.cshtml b/src/TaxBaik.Web/Pages/Sitemap.cshtml index ba69263..693ae2e 100644 --- a/src/TaxBaik.Web/Pages/Sitemap.cshtml +++ b/src/TaxBaik.Web/Pages/Sitemap.cshtml @@ -1,14 +1,18 @@ @page "/sitemap.xml" @model TaxBaik.Web.Pages.SitemapModel @{ + Layout = null; Response.ContentType = "application/xml; charset=utf-8"; } @foreach (var url in Model.Urls) { - @System.Net.WebUtility.HtmlEncode(url) - @DateTime.UtcNow:yyyy-MM-dd + @url.Location + @if (url.LastModified.HasValue) + { + @url.LastModified.Value.ToUniversalTime().ToString("yyyy-MM-dd") + } } diff --git a/src/TaxBaik.Web/Pages/Sitemap.cshtml.cs b/src/TaxBaik.Web/Pages/Sitemap.cshtml.cs index d30e535..6dee241 100644 --- a/src/TaxBaik.Web/Pages/Sitemap.cshtml.cs +++ b/src/TaxBaik.Web/Pages/Sitemap.cshtml.cs @@ -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 _logger; - public List Urls { get; set; } = []; + public List Urls { get; } = []; - public SitemapModel(BlogService blogService) + public SitemapModel( + BlogService blogService, + FaqService faqService, + AnnouncementService announcementService, + ILogger 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(); - // 정적 페이지 (항상 포함) - 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> GetAllPublishedPostsAsync(CancellationToken ct) + { + var posts = new List(); + 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); } diff --git a/src/TaxBaik.Web/Pages/_Layout.cshtml b/src/TaxBaik.Web/Pages/_Layout.cshtml index 5b09165..212f9e3 100644 --- a/src/TaxBaik.Web/Pages/_Layout.cshtml +++ b/src/TaxBaik.Web/Pages/_Layout.cshtml @@ -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"; +} @@ -12,30 +18,29 @@ - + - - - - + + + + - + - - + diff --git a/src/TaxBaik.Web/Services/SitemapValidationService.cs b/src/TaxBaik.Web/Services/SitemapValidationService.cs index 4c18d32..d8afdfd 100644 --- a/src/TaxBaik.Web/Services/SitemapValidationService.cs +++ b/src/TaxBaik.Web/Services/SitemapValidationService.cs @@ -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> GetAllPublishedPostsAsync() + { + const int pageSize = 100; + var posts = new List(); + 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++; + } + } } diff --git a/src/TaxBaik.Web/wwwroot/robots.txt b/src/TaxBaik.Web/wwwroot/robots.txt index 5b24e5f..68e6496 100644 --- a/src/TaxBaik.Web/wwwroot/robots.txt +++ b/src/TaxBaik.Web/wwwroot/robots.txt @@ -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