373 lines
14 KiB
C#
373 lines
14 KiB
C#
using Microsoft.Extensions.Caching.Memory;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Linq;
|
|
using OnlyOneAccessTemplate.Models;
|
|
using global::OnlyOneAccessTemplate.Models;
|
|
|
|
namespace OnlyOneAccessTemplate.Services
|
|
{
|
|
|
|
public class SeoService : ISeoService
|
|
{
|
|
private readonly ISiteConfigurationService _siteConfig;
|
|
private readonly ILanguageService _languageService;
|
|
private readonly IMemoryCache _cache;
|
|
private readonly IConfiguration _configuration;
|
|
private readonly ILogger<SeoService> _logger;
|
|
|
|
public SeoService(
|
|
ISiteConfigurationService siteConfig,
|
|
ILanguageService languageService,
|
|
IMemoryCache cache,
|
|
IConfiguration configuration,
|
|
ILogger<SeoService> logger)
|
|
{
|
|
_siteConfig = siteConfig;
|
|
_languageService = languageService;
|
|
_cache = cache;
|
|
_configuration = configuration;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<SeoMetadata> GenerateSeoMetadataAsync(string language, string pageName, PageContent? content = null)
|
|
{
|
|
var cacheKey = $"seo_metadata_{language}_{pageName}";
|
|
|
|
if (_cache.TryGetValue(cacheKey, out SeoMetadata? cachedMetadata) && cachedMetadata != null)
|
|
{
|
|
return cachedMetadata;
|
|
}
|
|
|
|
try
|
|
{
|
|
var configuration = await _siteConfig.GetConfigurationAsync(language);
|
|
content = content ?? await _siteConfig.GetPageContentAsync(language, pageName);
|
|
|
|
var baseUrl = _configuration.GetValue<string>("SEO:DefaultDomain") ?? "https://localhost";
|
|
var canonicalUrl = BuildCanonicalUrl(baseUrl, language, pageName);
|
|
var hreflangUrls = _languageService.GetHreflangUrls(canonicalUrl, language);
|
|
|
|
var metadata = new SeoMetadata(
|
|
Title: content?.MetaTitle ?? content?.Title ?? configuration.Seo.DefaultTitle,
|
|
Description: content?.Description ?? configuration.Seo.DefaultDescription,
|
|
Keywords: content?.Keywords ?? configuration.Seo.DefaultKeywords,
|
|
OgTitle: content?.Title ?? configuration.Seo.DefaultTitle,
|
|
OgDescription: content?.Description ?? configuration.Seo.DefaultDescription,
|
|
OgImage: content?.OgImage ?? configuration.Seo.OgImage,
|
|
CanonicalUrl: canonicalUrl,
|
|
HreflangUrls: hreflangUrls,
|
|
StructuredData: await GenerateStructuredDataAsync(language, "WebPage", new { content, configuration })
|
|
);
|
|
|
|
_cache.Set(cacheKey, metadata, TimeSpan.FromMinutes(15));
|
|
return metadata;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Erro ao gerar metadata SEO para {Language}/{PageName}", language, pageName);
|
|
return CreateFallbackMetadata(language, pageName);
|
|
}
|
|
}
|
|
|
|
public async Task<string> GenerateStructuredDataAsync(string language, string pageType, object data)
|
|
{
|
|
try
|
|
{
|
|
var configuration = await _siteConfig.GetConfigurationAsync(language);
|
|
var baseUrl = _configuration.GetValue<string>("SEO:DefaultDomain") ?? "https://localhost";
|
|
|
|
var structuredData = pageType.ToLowerInvariant() switch
|
|
{
|
|
"webpage" => GenerateWebPageStructuredData(configuration, baseUrl, language, data),
|
|
"organization" => GenerateOrganizationStructuredData(configuration, baseUrl),
|
|
"breadcrumb" => GenerateBreadcrumbStructuredData(baseUrl, language, data),
|
|
"faq" => GenerateFaqStructuredData(data),
|
|
"product" => GenerateProductStructuredData(configuration, data),
|
|
_ => GenerateWebPageStructuredData(configuration, baseUrl, language, data)
|
|
};
|
|
|
|
return JsonSerializer.Serialize(structuredData, new JsonSerializerOptions
|
|
{
|
|
WriteIndented = false,
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Erro ao gerar structured data para {PageType}", pageType);
|
|
return "{}";
|
|
}
|
|
}
|
|
|
|
public async Task<List<SeoIssue>> ValidateSeoAsync(string url)
|
|
{
|
|
var issues = new List<SeoIssue>();
|
|
|
|
try
|
|
{
|
|
// Esta implementação seria expandida com validações reais
|
|
// Por enquanto, retorna uma lista básica de verificações
|
|
|
|
if (string.IsNullOrEmpty(url))
|
|
{
|
|
issues.Add(new SeoIssue("URL", "URL não fornecida", "High", ""));
|
|
}
|
|
|
|
// Adicionar mais validações conforme necessário
|
|
// - Verificar se title tem tamanho adequado
|
|
// - Verificar se description tem tamanho adequado
|
|
// - Verificar se há headings estruturados
|
|
// - Verificar se há alt text nas imagens
|
|
// - Verificar velocidade da página
|
|
// - Verificar responsividade
|
|
|
|
return issues;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Erro ao validar SEO para URL {Url}", url);
|
|
issues.Add(new SeoIssue("Validation", "Erro na validação SEO", "High", ""));
|
|
return issues;
|
|
}
|
|
}
|
|
|
|
private object GenerateWebPageStructuredData(SiteConfiguration config, string baseUrl, string language, object data)
|
|
{
|
|
return new
|
|
{
|
|
Context = "https://schema.org",
|
|
Type = "WebPage",
|
|
Name = config.Seo.SiteName,
|
|
Description = config.Seo.DefaultDescription,
|
|
Url = baseUrl,
|
|
InLanguage = language,
|
|
IsPartOf = new
|
|
{
|
|
Type = "WebSite",
|
|
Name = config.Seo.SiteName,
|
|
Url = baseUrl
|
|
},
|
|
DatePublished = DateTime.UtcNow.ToString("yyyy-MM-dd"),
|
|
DateModified = DateTime.UtcNow.ToString("yyyy-MM-dd")
|
|
};
|
|
}
|
|
|
|
public async Task<SeoMetadata> GenerateMetadataForPageAsync(HttpContext context, string language, string pageName, PageContent? customContent = null)
|
|
{
|
|
var currentUrl = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}";
|
|
return await GenerateSeoMetadataAsync(language, pageName, customContent);
|
|
}
|
|
|
|
public async Task<string> GenerateCanonicalUrlAsync(HttpContext context, string language, string pageName)
|
|
{
|
|
var baseUrl = $"{context.Request.Scheme}://{context.Request.Host}";
|
|
return BuildCanonicalUrl(baseUrl, language, pageName);
|
|
}
|
|
|
|
public async Task<Dictionary<string, string>> GenerateMetaTagsAsync(SeoMetadata metadata)
|
|
{
|
|
return new Dictionary<string, string>
|
|
{
|
|
{ "title", metadata.Title },
|
|
{ "description", metadata.Description },
|
|
{ "keywords", metadata.Keywords },
|
|
{ "canonical", metadata.CanonicalUrl },
|
|
{ "author", metadata.Author ?? "" }
|
|
};
|
|
}
|
|
|
|
public async Task<string> GenerateOpenGraphTagsAsync(SeoMetadata metadata)
|
|
{
|
|
var tags = new StringBuilder();
|
|
tags.AppendLine($"<meta property=\"og:title\" content=\"{metadata.OgTitle}\" />");
|
|
tags.AppendLine($"<meta property=\"og:description\" content=\"{metadata.OgDescription}\" />");
|
|
tags.AppendLine($"<meta property=\"og:image\" content=\"{metadata.OgImage}\" />");
|
|
tags.AppendLine($"<meta property=\"og:url\" content=\"{metadata.CanonicalUrl}\" />");
|
|
tags.AppendLine($"<meta property=\"og:type\" content=\"{metadata.OgType}\" />");
|
|
return tags.ToString();
|
|
}
|
|
|
|
public async Task<string> GenerateTwitterCardTagsAsync(SeoMetadata metadata)
|
|
{
|
|
var tags = new StringBuilder();
|
|
tags.AppendLine($"<meta name=\"twitter:card\" content=\"{metadata.TwitterCard}\" />");
|
|
tags.AppendLine($"<meta name=\"twitter:title\" content=\"{metadata.OgTitle}\" />");
|
|
tags.AppendLine($"<meta name=\"twitter:description\" content=\"{metadata.OgDescription}\" />");
|
|
tags.AppendLine($"<meta name=\"twitter:image\" content=\"{metadata.OgImage}\" />");
|
|
return tags.ToString();
|
|
}
|
|
|
|
public async Task<string> GenerateJsonLdAsync(string language, string pageType, object data)
|
|
{
|
|
return await GenerateStructuredDataAsync(language, pageType, data);
|
|
}
|
|
|
|
public bool ValidateMetadata(SeoMetadata metadata, out List<SeoIssue> issues)
|
|
{
|
|
issues = new List<SeoIssue>();
|
|
|
|
if (string.IsNullOrEmpty(metadata.Title))
|
|
{
|
|
issues.Add(new SeoIssue("Title", "Título não pode estar vazio", "High", "title"));
|
|
}
|
|
else if (metadata.Title.Length > 60)
|
|
{
|
|
issues.Add(new SeoIssue("Title", "Título muito longo (máx. 60 caracteres)", "Medium", "title"));
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(metadata.Description))
|
|
{
|
|
issues.Add(new SeoIssue("Description", "Descrição não pode estar vazia", "High", "meta[name='description']"));
|
|
}
|
|
else if (metadata.Description.Length > 160)
|
|
{
|
|
issues.Add(new SeoIssue("Description", "Descrição muito longa (máx. 160 caracteres)", "Medium", "meta[name='description']"));
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(metadata.OgImage))
|
|
{
|
|
issues.Add(new SeoIssue("OpenGraph", "Imagem Open Graph não definida", "Medium", "meta[property='og:image']"));
|
|
}
|
|
|
|
return !issues.Any(i => i.Severity == "High");
|
|
}
|
|
|
|
private object GenerateOrganizationStructuredData(SiteConfiguration config, string baseUrl)
|
|
{
|
|
return new
|
|
{
|
|
Context = "https://schema.org",
|
|
Type = "Organization",
|
|
Name = config.Seo.SiteName,
|
|
Url = baseUrl,
|
|
Logo = new
|
|
{
|
|
Type = "ImageObject",
|
|
Url = $"{baseUrl}/logo.png"
|
|
},
|
|
SameAs = new[]
|
|
{
|
|
"Facebook"
|
|
}
|
|
};
|
|
}
|
|
|
|
private object GenerateBreadcrumbStructuredData(string baseUrl, string language, object data)
|
|
{
|
|
return new
|
|
{
|
|
Context = "https://schema.org",
|
|
Type = "BreadcrumbList",
|
|
ItemListElement = new[]
|
|
{
|
|
new
|
|
{
|
|
Type = "ListItem",
|
|
Position = 1,
|
|
Name = language switch
|
|
{
|
|
"en" => "Home",
|
|
"es" => "Inicio",
|
|
_ => "Início"
|
|
},
|
|
Item = baseUrl
|
|
}
|
|
// Adicionar mais items do breadcrumb conforme necessário
|
|
}
|
|
};
|
|
}
|
|
|
|
private object GenerateFaqStructuredData(object data)
|
|
{
|
|
return new
|
|
{
|
|
Context = "https://schema.org",
|
|
Type = "FAQPage",
|
|
MainEntity = new[]
|
|
{
|
|
// Exemplo de FAQ - expandir conforme necessário
|
|
new
|
|
{
|
|
Type = "Question",
|
|
Name = "Como funciona?",
|
|
AcceptedAnswer = new
|
|
{
|
|
Type = "Answer",
|
|
Text = "Explicação de como funciona..."
|
|
}
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
private object GenerateProductStructuredData(SiteConfiguration config, object data)
|
|
{
|
|
return new
|
|
{
|
|
Context = "https://schema.org",
|
|
Type = "Product",
|
|
Name = "Nome do Produto",
|
|
Description = "Descrição do produto",
|
|
Brand = new
|
|
{
|
|
Type = "Brand",
|
|
Name = config.Seo.SiteName
|
|
},
|
|
Offers = new
|
|
{
|
|
Type = "Offer",
|
|
PriceCurrency = "BRL",
|
|
Price = "0",
|
|
Availability = "https://schema.org/InStock"
|
|
}
|
|
};
|
|
}
|
|
|
|
private string BuildCanonicalUrl(string baseUrl, string language, string pageName)
|
|
{
|
|
var url = baseUrl.TrimEnd('/');
|
|
|
|
if (language != _languageService.GetDefaultLanguage())
|
|
{
|
|
url += $"/{language}";
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(pageName) && pageName != "index")
|
|
{
|
|
url += $"/{pageName}";
|
|
}
|
|
|
|
return url;
|
|
}
|
|
|
|
private SeoMetadata CreateFallbackMetadata(string language, string pageName)
|
|
{
|
|
var defaultTitle = language switch
|
|
{
|
|
"en" => "Page Not Found",
|
|
"es" => "Página No Encontrada",
|
|
_ => "Página Não Encontrada"
|
|
};
|
|
|
|
var defaultDescription = language switch
|
|
{
|
|
"en" => "The requested page could not be found.",
|
|
"es" => "La página solicitada no pudo ser encontrada.",
|
|
_ => "A página solicitada não pôde ser encontrada."
|
|
};
|
|
|
|
return new SeoMetadata(
|
|
Title: defaultTitle,
|
|
Description: defaultDescription,
|
|
Keywords: "",
|
|
OgTitle: defaultTitle,
|
|
OgDescription: defaultDescription,
|
|
OgImage: "",
|
|
CanonicalUrl: "",
|
|
HreflangUrls: new Dictionary<string, string>(),
|
|
StructuredData: "{}"
|
|
);
|
|
}
|
|
}
|
|
} |