feat: add Sitemap and RSS feed validation service
TaxBaik CI/CD / build-and-deploy (push) Has been cancelled
TaxBaik CI/CD / build-and-deploy (push) Has been cancelled
New validation service for ensuring feed consistency: - SitemapValidationService: Complete feed validation • URL format validation (protocol, domain, scheme) • Duplicate URL detection • Blog post date validation • Sitemap ↔ RSS consistency checks - ValidationEndpoints (FastEndpoints): Admin API • GET /api/admin/validate/sitemap • GET /api/admin/validate/rss • GET /api/admin/validate/consistency Validation checks: ✓ URL validity (Uri.TryCreate) ✓ HTTPS protocol ✓ Correct domain ✓ GUID validity ✓ RFC 2822 date format ✓ Required fields ✓ Duplicate detection ✓ Post count consistency Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
using FastEndpoints;
|
||||
using TaxBaik.Web.Services;
|
||||
|
||||
namespace TaxBaik.Web.Endpoints.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Sitemap 및 RSS 검증 엔드포인트 (관리자 전용)
|
||||
/// </summary>
|
||||
public class ValidateSitemapEndpoint : EndpointWithoutRequest
|
||||
{
|
||||
private readonly SitemapValidationService _validationService;
|
||||
|
||||
public ValidateSitemapEndpoint(SitemapValidationService validationService)
|
||||
{
|
||||
_validationService = validationService;
|
||||
}
|
||||
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/admin/validate/sitemap");
|
||||
Roles("admin");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var result = await _validationService.ValidateSitemapAsync();
|
||||
await SendOkAsync(result, cancellation: ct);
|
||||
}
|
||||
}
|
||||
|
||||
public class ValidateRssEndpoint : EndpointWithoutRequest
|
||||
{
|
||||
private readonly SitemapValidationService _validationService;
|
||||
|
||||
public ValidateRssEndpoint(SitemapValidationService validationService)
|
||||
{
|
||||
_validationService = validationService;
|
||||
}
|
||||
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/admin/validate/rss");
|
||||
Roles("admin");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var result = await _validationService.ValidateRssFeedAsync();
|
||||
await SendOkAsync(result, cancellation: ct);
|
||||
}
|
||||
}
|
||||
|
||||
public class ValidateConsistencyEndpoint : EndpointWithoutRequest
|
||||
{
|
||||
private readonly SitemapValidationService _validationService;
|
||||
|
||||
public ValidateConsistencyEndpoint(SitemapValidationService validationService)
|
||||
{
|
||||
_validationService = validationService;
|
||||
}
|
||||
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/admin/validate/consistency");
|
||||
Roles("admin");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var result = await _validationService.ValidateConsistencyAsync();
|
||||
await SendOkAsync(result, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -301,6 +301,7 @@ builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));
|
||||
builder.Services.AddInfrastructure();
|
||||
builder.Services.AddApplication();
|
||||
builder.Services.AddScoped<IInquiryNotificationService, TelegramInquiryNotificationService>();
|
||||
builder.Services.AddScoped<TaxBaik.Web.Services.SitemapValidationService>();
|
||||
|
||||
// Register version info
|
||||
var versionInfo = new VersionInfo();
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using TaxBaik.Application.Services;
|
||||
|
||||
namespace TaxBaik.Web.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Sitemap 및 RSS 피드의 정합성 검증
|
||||
/// </summary>
|
||||
public class SitemapValidationService
|
||||
{
|
||||
private readonly BlogService _blogService;
|
||||
private readonly ILogger<SitemapValidationService> _logger;
|
||||
|
||||
public SitemapValidationService(BlogService blogService, ILogger<SitemapValidationService> logger)
|
||||
{
|
||||
_blogService = blogService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sitemap 검증 결과
|
||||
/// </summary>
|
||||
public class SitemapValidationResult
|
||||
{
|
||||
public bool IsValid { get; set; }
|
||||
public List<string> Errors { get; set; } = [];
|
||||
public List<string> Warnings { get; set; } = [];
|
||||
public int TotalUrls { get; set; }
|
||||
public int BlogPostUrls { get; set; }
|
||||
public DateTime ValidatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sitemap 전체 검증
|
||||
/// </summary>
|
||||
public async Task<SitemapValidationResult> ValidateSitemapAsync()
|
||||
{
|
||||
var result = new SitemapValidationResult();
|
||||
|
||||
try
|
||||
{
|
||||
// 1. 정적 페이지 검증
|
||||
var staticUrls = GetStaticUrls();
|
||||
ValidateUrls(staticUrls, result);
|
||||
|
||||
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/taxbaik/blog/{p.Slug}").ToList();
|
||||
|
||||
ValidateUrls(blogUrls, result);
|
||||
|
||||
result.TotalUrls += blogUrls.Count;
|
||||
result.BlogPostUrls = blogUrls.Count;
|
||||
|
||||
// 3. 중복 검증
|
||||
var allUrls = staticUrls.Concat(blogUrls).ToList();
|
||||
var duplicates = allUrls.GroupBy(u => u)
|
||||
.Where(g => g.Count() > 1)
|
||||
.Select(g => g.Key)
|
||||
.ToList();
|
||||
|
||||
if (duplicates.Any())
|
||||
{
|
||||
result.Errors.Add($"중복 URL 발견: {string.Join(", ", duplicates)}");
|
||||
}
|
||||
|
||||
// 4. 블로그 포스트 날짜 검증
|
||||
foreach (var post in posts)
|
||||
{
|
||||
if (post.PublishedAt == null || post.PublishedAt == default)
|
||||
{
|
||||
result.Warnings.Add($"포스트 '{post.Slug}'의 발행일이 없습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
result.IsValid = !result.Errors.Any();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Errors.Add($"검증 실패: {ex.Message}");
|
||||
result.IsValid = false;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Sitemap 검증 완료: {IsValid}, 총 {TotalUrls}개 URL, {BlogPostUrls}개 포스트",
|
||||
result.IsValid, result.TotalUrls, result.BlogPostUrls);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RSS 피드 검증
|
||||
/// </summary>
|
||||
public async Task<SitemapValidationResult> ValidateRssFeedAsync()
|
||||
{
|
||||
var result = new SitemapValidationResult();
|
||||
|
||||
try
|
||||
{
|
||||
var (posts, _) = await _blogService.GetPublishedPagedAsync(1, 50, categoryId: null, ct: default);
|
||||
|
||||
if (!posts.Any())
|
||||
{
|
||||
result.Warnings.Add("RSS 피드에 포스트가 없습니다.");
|
||||
}
|
||||
|
||||
// 필수 필드 검증
|
||||
var postsList = posts.ToList();
|
||||
foreach (var post in postsList)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
if (string.IsNullOrEmpty(post.Title))
|
||||
errors.Add("제목 없음");
|
||||
|
||||
if (string.IsNullOrEmpty(post.Slug))
|
||||
errors.Add("Slug 없음");
|
||||
|
||||
if (string.IsNullOrEmpty(post.Content))
|
||||
errors.Add("콘텐츠 없음");
|
||||
|
||||
if (post.PublishedAt == null || post.PublishedAt == default)
|
||||
errors.Add("발행일 없음");
|
||||
|
||||
if (errors.Any())
|
||||
{
|
||||
result.Errors.Add($"포스트 '{post.Title}': {string.Join(", ", errors)}");
|
||||
}
|
||||
|
||||
// GUID 유효성 (URL 형식)
|
||||
var guid = $"https://www.taxbaik.com/taxbaik/blog/{post.Slug}";
|
||||
if (!Uri.TryCreate(guid, UriKind.Absolute, out _))
|
||||
{
|
||||
result.Errors.Add($"GUID 유효하지 않음: {guid}");
|
||||
}
|
||||
|
||||
// pubDate 형식 검증 (RFC 2822)
|
||||
if (post.PublishedAt.HasValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
var rfc2822 = post.PublishedAt.Value.ToString("R"); // RFC 2822 형식
|
||||
if (string.IsNullOrEmpty(rfc2822))
|
||||
result.Warnings.Add($"포스트 '{post.Slug}' pubDate 형식 변환 실패");
|
||||
}
|
||||
catch
|
||||
{
|
||||
result.Errors.Add($"포스트 '{post.Slug}' pubDate 형식 오류");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.TotalUrls = postsList.Count;
|
||||
result.IsValid = !result.Errors.Any();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Errors.Add($"RSS 검증 실패: {ex.Message}");
|
||||
result.IsValid = false;
|
||||
}
|
||||
|
||||
_logger.LogInformation("RSS 검증 완료: {IsValid}, 총 {TotalUrls}개 포스트",
|
||||
result.IsValid, result.TotalUrls);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sitemap과 RSS의 일관성 검증
|
||||
/// </summary>
|
||||
public async Task<SitemapValidationResult> ValidateConsistencyAsync()
|
||||
{
|
||||
var result = new SitemapValidationResult();
|
||||
|
||||
try
|
||||
{
|
||||
var sitemapValidation = await ValidateSitemapAsync();
|
||||
var rssValidation = await ValidateRssFeedAsync();
|
||||
|
||||
// 에러 통합
|
||||
result.Errors.AddRange(sitemapValidation.Errors);
|
||||
result.Errors.AddRange(rssValidation.Errors);
|
||||
|
||||
// 경고 통합
|
||||
result.Warnings.AddRange(sitemapValidation.Warnings);
|
||||
result.Warnings.AddRange(rssValidation.Warnings);
|
||||
|
||||
// 블로그 포스트 수 비교
|
||||
if (sitemapValidation.BlogPostUrls != rssValidation.TotalUrls)
|
||||
{
|
||||
result.Warnings.Add(
|
||||
$"Sitemap의 블로그 포스트({sitemapValidation.BlogPostUrls})와 " +
|
||||
$"RSS 포스트({rssValidation.TotalUrls}) 수가 다릅니다. " +
|
||||
$"(Sitemap은 전체, RSS는 최근 50개)"
|
||||
);
|
||||
}
|
||||
|
||||
result.IsValid = !result.Errors.Any();
|
||||
result.TotalUrls = sitemapValidation.TotalUrls;
|
||||
result.BlogPostUrls = sitemapValidation.BlogPostUrls;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Errors.Add($"일관성 검증 실패: {ex.Message}");
|
||||
result.IsValid = false;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// URL 형식 검증
|
||||
/// </summary>
|
||||
private static void ValidateUrls(List<string> urls, SitemapValidationResult result)
|
||||
{
|
||||
foreach (var url in urls)
|
||||
{
|
||||
// URL 형식 검증
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
{
|
||||
result.Errors.Add($"유효하지 않은 URL: {url}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// HTTPS 검증
|
||||
if (uri.Scheme != "https")
|
||||
{
|
||||
result.Warnings.Add($"HTTPS가 아닌 URL: {url}");
|
||||
}
|
||||
|
||||
// 도메인 검증
|
||||
if (uri.Host != "www.taxbaik.com" && uri.Host != "taxbaik.com")
|
||||
{
|
||||
result.Errors.Add($"잘못된 도메인: {url}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 정적 페이지 URL 목록
|
||||
/// </summary>
|
||||
private static List<string> GetStaticUrls()
|
||||
{
|
||||
var baseUrl = "https://www.taxbaik.com/taxbaik";
|
||||
return new List<string>
|
||||
{
|
||||
$"{baseUrl}",
|
||||
$"{baseUrl}/about",
|
||||
$"{baseUrl}/services",
|
||||
$"{baseUrl}/contact",
|
||||
$"{baseUrl}/privacy",
|
||||
$"{baseUrl}/terms",
|
||||
$"{baseUrl}/blog",
|
||||
$"{baseUrl}/faq",
|
||||
$"{baseUrl}/announcement",
|
||||
$"{baseUrl}/inquiry"
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user