sozsoft-platform/api/src/Sozsoft.Platform.Application/Public/SeoAppService.cs
2026-09-22 23:59:36 +03:00

403 lines
15 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Sozsoft.Platform.Entities;
using Volo.Abp.Domain.Repositories;
using Volo.Abp.Settings;
using SeoSettings = Sozsoft.Platform.PlatformConsts.AbpSettings.SiteManagement.Seo;
namespace Sozsoft.Platform.Public;
/// <summary>
/// Public sitenin arama motoru yuzeyi: sayfa metasinin sozlesmesi, sitemap ve robots metni.
/// Tamami anonim erisime aciktir.
/// </summary>
[AllowAnonymous]
public class SeoAppService : PlatformAppService, ISeoAppService
{
/// <summary>Sitemap'e yalnizca ziyaretciye acik yollar girer; yonetim yuzeyi disaridadir.</summary>
private const string AdminPathPrefix = "/admin";
private const string DefaultChangeFrequency = "weekly";
/// <summary>Route tablosunda ziyaretciye acik satirlarin tipi.</summary>
private const string PublicRouteType = "public";
private readonly ISettingProvider _settingProvider;
private readonly IRepository<Menu, Guid> _menuRepository;
private readonly IRepository<CustomComponent, Guid> _customComponentRepository;
private readonly IRepository<BlogPost, Guid> _postRepository;
private readonly IRepository<Route, Guid> _routeRepository;
public SeoAppService(
ISettingProvider settingProvider,
IRepository<Menu, Guid> menuRepository,
IRepository<CustomComponent, Guid> customComponentRepository,
IRepository<BlogPost, Guid> postRepository,
IRepository<Route, Guid> routeRepository)
{
_settingProvider = settingProvider;
_menuRepository = menuRepository;
_customComponentRepository = customComponentRepository;
_postRepository = postRepository;
_routeRepository = routeRepository;
}
public async Task<SeoConfigDto> GetConfigAsync()
{
var config = new SeoConfigDto
{
SiteUrl = NormalizeBaseUrl(await GetSettingAsync(SeoSettings.SiteUrl)),
SiteName = await GetSettingAsync(SeoSettings.SiteName),
DefaultTitle = await GetSettingAsync(SeoSettings.DefaultTitle),
DefaultDescription = await GetSettingAsync(SeoSettings.DefaultDescription),
DefaultKeywords = await GetSettingAsync(SeoSettings.DefaultKeywords),
DefaultImage = await GetSettingAsync(SeoSettings.DefaultImage),
TwitterSite = await GetSettingAsync(SeoSettings.TwitterSite),
Robots = await GetSettingAsync(SeoSettings.Robots),
GoogleSiteVerification = await GetSettingAsync(SeoSettings.GoogleSiteVerification),
Locales = SplitList(await GetSettingAsync(SeoSettings.Locales)),
SameAs = SplitList(await GetSettingAsync(SeoSettings.OrganizationSameAs)),
};
config.Routes = [.. (await CollectSeoRoutesAsync())
.Select(entry => entry.ToDto())
.OrderBy(route => route.RoutePath, StringComparer.Ordinal)];
return config;
}
public async Task<string> GetSitemapAsync(string baseUrl)
{
if (!await GetBoolSettingAsync(SeoSettings.SitemapEnabled))
{
return string.Empty;
}
var siteUrl = await ResolveSiteUrlAsync(baseUrl);
var excluded = SplitLines(await GetSettingAsync(SeoSettings.SitemapExcludePaths));
var entries = await CollectSitemapEntriesAsync(excluded);
var builder = new StringBuilder();
builder.AppendLine("""<?xml version="1.0" encoding="UTF-8"?>""");
builder.AppendLine("""<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">""");
foreach (var entry in entries)
{
builder.AppendLine(" <url>");
builder.AppendLine($" <loc>{Escape(siteUrl + entry.Path)}</loc>");
if (entry.LastModified.HasValue)
{
builder.AppendLine($" <lastmod>{entry.LastModified.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}</lastmod>");
}
builder.AppendLine($" <changefreq>{Escape(entry.ChangeFrequency)}</changefreq>");
builder.AppendLine($" <priority>{entry.Priority.ToString("0.0", CultureInfo.InvariantCulture)}</priority>");
builder.AppendLine(" </url>");
}
builder.AppendLine("</urlset>");
return builder.ToString();
}
public async Task<string> GetRobotsAsync(string baseUrl)
{
var robots = await GetSettingAsync(SeoSettings.Robots);
var siteUrl = await ResolveSiteUrlAsync(baseUrl);
var builder = new StringBuilder();
builder.AppendLine("User-agent: *");
// Ayar "dizine ekleme" derse butun site kapatilir; aksi halde yalnizca ziyaretciye
// acik olmayan yuzeyler disarida birakilir.
if (robots.Contains("noindex", StringComparison.OrdinalIgnoreCase))
{
builder.AppendLine("Disallow: /");
return builder.ToString();
}
builder.AppendLine("Disallow: /admin/");
builder.AppendLine("Disallow: /auth/");
builder.AppendLine("Disallow: /checkout");
builder.AppendLine("Disallow: /payment");
builder.AppendLine("Disallow: /success");
foreach (var path in SplitLines(await GetSettingAsync(SeoSettings.SitemapExcludePaths)))
{
builder.AppendLine($"Disallow: {path.TrimEnd('*')}");
}
if (await GetBoolSettingAsync(SeoSettings.SitemapEnabled))
{
builder.AppendLine();
builder.AppendLine($"Sitemap: {siteUrl}/sitemap.xml");
}
return builder.ToString();
}
private async Task<List<SitemapEntry>> CollectSitemapEntriesAsync(List<string> excluded)
{
var entries = new Dictionary<string, SitemapEntry>(StringComparer.OrdinalIgnoreCase);
void Add(string? path, DateTime? lastModified, string changeFrequency, decimal priority)
{
var normalized = NormalizePath(path);
if (normalized == null || IsExcluded(normalized, excluded) || entries.ContainsKey(normalized))
{
return;
}
entries[normalized] = new SitemapEntry(normalized, lastModified, changeFrequency, priority);
}
// Ana sayfa menude bir kayit olmayabilir; site kokunun her zaman listede olmasi gerekir.
Add("/", null, "daily", 1.0m);
var menus = await _menuRepository.GetListAsync(
menu => menu.ModuleId == PlatformConsts.PublicSite.ModuleId && !menu.IsDisabled);
foreach (var menu in menus.OrderBy(menu => menu.Order))
{
Add(menu.Url, null, DefaultChangeFrequency, 0.8m);
}
var components = await _customComponentRepository.GetListAsync(component => component.IsActive);
foreach (var component in components)
{
Add(component.RoutePath, component.LastModificationTime ?? component.CreationTime, DefaultChangeFrequency, 0.7m);
}
var posts = await _postRepository.GetListAsync(post => post.IsPublished);
foreach (var post in posts)
{
Add($"/blog/{post.Slug}", post.LastModificationTime ?? post.PublishedAt, "monthly", 0.6m);
}
// Yolu tanimlayan kayitlarin kendi SEO alanlari hem yeni yol ekler hem de
// yukaridakilerin sirasini ve sikligini ezer.
foreach (var seo in await CollectSeoRoutesAsync())
{
if (seo.RoutePath.Contains('*'))
{
continue;
}
if (seo.NoIndex || !seo.IncludeInSitemap)
{
entries.Remove(seo.RoutePath);
continue;
}
entries[seo.RoutePath] = new SitemapEntry(
seo.RoutePath,
seo.LastModified,
seo.ChangeFrequency.IsNullOrWhiteSpace() ? DefaultChangeFrequency : seo.ChangeFrequency!,
seo.Priority ?? 0.5m);
}
return [.. entries.Values.OrderBy(entry => entry.Path, StringComparer.Ordinal)];
}
/// <summary>Uygulama ici yollari tek bicime indirger; disa donuk ve yonetim adresleri elenir.</summary>
private static string? NormalizePath(string? path)
{
if (path.IsNullOrWhiteSpace())
{
return null;
}
var value = path!.Trim();
if (value.Contains("://", StringComparison.Ordinal))
{
return null;
}
if (!value.StartsWith('/'))
{
value = "/" + value;
}
if (value.StartsWith(AdminPathPrefix + "/", StringComparison.OrdinalIgnoreCase)
|| value.Equals(AdminPathPrefix, StringComparison.OrdinalIgnoreCase))
{
return null;
}
// Parametreli rota (/blog/:id) bir adres degil, bir kaliptir.
if (value.Contains(':', StringComparison.Ordinal))
{
return null;
}
return value.Length > 1 ? value.TrimEnd('/') : value;
}
private static bool IsExcluded(string path, List<string> excluded)
{
return excluded.Exists(rule => rule.EndsWith('*')
? path.StartsWith(rule.TrimEnd('*'), StringComparison.OrdinalIgnoreCase)
: path.Equals(rule, StringComparison.OrdinalIgnoreCase));
}
private async Task<string> ResolveSiteUrlAsync(string baseUrl)
{
var configured = NormalizeBaseUrl(await GetSettingAsync(SeoSettings.SiteUrl));
return configured.IsNullOrWhiteSpace() ? NormalizeBaseUrl(baseUrl) : configured;
}
private static string NormalizeBaseUrl(string value)
{
return value.IsNullOrWhiteSpace() ? string.Empty : value.Trim().TrimEnd('/');
}
private async Task<string> GetSettingAsync(string name)
{
return await _settingProvider.GetOrNullAsync(name) ?? string.Empty;
}
private async Task<bool> GetBoolSettingAsync(string name)
{
var value = await GetSettingAsync(name);
return value.IsNullOrWhiteSpace() || value.Equals("true", StringComparison.OrdinalIgnoreCase);
}
private static List<string> SplitList(string value)
{
return [.. value
.Split([',', ';', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
}
private static List<string> SplitLines(string value)
{
return [.. value
.Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
}
private static string Escape(string value)
{
return value
.Replace("&", "&amp;", StringComparison.Ordinal)
.Replace("<", "&lt;", StringComparison.Ordinal)
.Replace(">", "&gt;", StringComparison.Ordinal);
}
/// <summary>
/// SEO kayitlarini yolu tanimlayan iki kaynaktan toplar: fiziksel sayfalarin <c>Route</c>
/// satirlari ve rota tasiyan Custom Component kayitlari. Ayni yolu ikisi de tanimliyorsa
/// bilesen kazanir; calisma zamaninda da bilesen fiziksel rotayi golgeler.
/// </summary>
private async Task<List<SeoEntry>> CollectSeoRoutesAsync()
{
var entries = new Dictionary<string, SeoEntry>(StringComparer.OrdinalIgnoreCase);
void Add(SeoEntry? entry)
{
if (entry != null)
{
entries[entry.RoutePath] = entry;
}
}
var routes = await _routeRepository.GetListAsync(route => route.RouteType == PublicRouteType);
foreach (var route in routes)
{
Add(SeoEntry.From(
route.Path,
route.SeoTitle, route.SeoDescription, route.SeoKeywords, route.SeoOgImage,
route.SeoCanonicalUrl, route.SeoNoIndex, route.SeoIncludeInSitemap,
route.SeoChangeFrequency, route.SeoPriority, route.SeoStructuredData,
route.LastModificationTime ?? route.CreationTime));
}
var components = await _customComponentRepository.GetListAsync(component => component.IsActive);
foreach (var component in components)
{
Add(SeoEntry.From(
component.RoutePath,
component.SeoTitle, component.SeoDescription, component.SeoKeywords, component.SeoOgImage,
component.SeoCanonicalUrl, component.SeoNoIndex, component.SeoIncludeInSitemap,
component.SeoChangeFrequency, component.SeoPriority, component.SeoStructuredData,
component.LastModificationTime ?? component.CreationTime));
}
return [.. entries.Values];
}
/// <summary>Iki kaynagin SEO alanlarini tek bicime indirger.</summary>
private sealed record SeoEntry(
string RoutePath,
string? Title,
string? Description,
string? Keywords,
string? OgImage,
string? CanonicalUrl,
bool NoIndex,
bool IncludeInSitemap,
string? ChangeFrequency,
decimal? Priority,
string? StructuredData,
DateTime? LastModified)
{
/// <summary>Hicbir SEO alani doldurulmamis kayit sozlesmeye girmez; genel varsayilanlar zaten uygulanir.</summary>
public static SeoEntry? From(
string? routePath,
string? title, string? description, string? keywords, string? ogImage,
string? canonicalUrl, bool noIndex, bool includeInSitemap,
string? changeFrequency, decimal? priority, string? structuredData,
DateTime? lastModified)
{
var normalized = NormalizePath(routePath);
if (normalized == null)
{
return null;
}
var hasValue = !title.IsNullOrWhiteSpace()
|| !description.IsNullOrWhiteSpace()
|| !keywords.IsNullOrWhiteSpace()
|| !ogImage.IsNullOrWhiteSpace()
|| !canonicalUrl.IsNullOrWhiteSpace()
|| !structuredData.IsNullOrWhiteSpace()
|| !changeFrequency.IsNullOrWhiteSpace()
|| priority.HasValue
|| noIndex
|| !includeInSitemap;
return hasValue
? new SeoEntry(normalized, title, description, keywords, ogImage, canonicalUrl,
noIndex, includeInSitemap, changeFrequency, priority, structuredData, lastModified)
: null;
}
public SeoRouteDto ToDto()
{
return new SeoRouteDto
{
RoutePath = RoutePath,
Title = Title,
Description = Description,
Keywords = Keywords,
OgImage = OgImage,
CanonicalUrl = CanonicalUrl,
NoIndex = NoIndex,
StructuredData = StructuredData,
};
}
}
private sealed record SitemapEntry(string Path, DateTime? LastModified, string ChangeFrequency, decimal Priority);
}