mirror of
https://github.com/claunia/marechai.git
synced 2026-07-08 17:57:08 +00:00
336 lines
11 KiB
C#
336 lines
11 KiB
C#
/******************************************************************************
|
|
// MARECHAI: Master repository of computing history artifacts information
|
|
// ----------------------------------------------------------------------------
|
|
//
|
|
// Author(s) : Natalia Portillo <claunia@claunia.com>
|
|
//
|
|
// --[ License ] --------------------------------------------------------------
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU General Public License as
|
|
// published by the Free Software Foundation, either version 3 of the
|
|
// License, or (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License
|
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
//
|
|
// ----------------------------------------------------------------------------
|
|
// Copyright © 2003-2026 Natalia Portillo
|
|
*******************************************************************************/
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Net.Http;
|
|
using System.Net.Http.Json;
|
|
using System.Text;
|
|
using System.Threading.Tasks;
|
|
using System.Xml;
|
|
using Marechai.Helpers;
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Marechai.Services;
|
|
|
|
/// <summary>
|
|
/// Generates XML sitemaps for SEO by querying the backend API for all public entity IDs and
|
|
/// combining them with the static routes defined by the Blazor <c>@page</c> directives.
|
|
/// Results are cached for 24 hours via <see cref="IMemoryCache" />.
|
|
/// </summary>
|
|
public sealed class SitemapService(IHttpClientFactory httpClientFactory, IMemoryCache cache, ILogger<SitemapService> logger)
|
|
{
|
|
const string Namespace = "http://www.sitemaps.org/schemas/sitemap/0.9";
|
|
const int UrlsPerSitemap = 10_000;
|
|
static readonly TimeSpan CacheDuration = TimeSpan.FromHours(24);
|
|
|
|
/// <summary>Entity types with their API path, URL prefix, and sitemap slug.</summary>
|
|
static readonly (string ApiPath, string UrlPrefix, string Slug)[] EntityTypes =
|
|
[
|
|
("machines", "/machine/", "machines"),
|
|
("companies", "/company/", "companies"),
|
|
("software", "/software/", "software"),
|
|
("books", "/book/", "books"),
|
|
("documents", "/document/", "documents"),
|
|
("magazines", "/magazine/", "magazines"),
|
|
("gpus", "/gpu/", "gpus"),
|
|
("sound-synths", "/soundsynth/", "soundsynths"),
|
|
("processors", "/processor/", "processors"),
|
|
("people", "/person/", "people")
|
|
];
|
|
|
|
/// <summary>Generates a sitemap index that lists per-section sub-sitemaps, paginated for large entity sets.</summary>
|
|
public async Task<string> GenerateSitemapIndexAsync()
|
|
{
|
|
const string cacheKey = "sitemap:index";
|
|
|
|
if(cache.TryGetValue(cacheKey, out string? cached) && cached is not null)
|
|
return cached;
|
|
|
|
HttpClient client = httpClientFactory.CreateClient("SitemapApi");
|
|
|
|
using var ms = new MemoryStream();
|
|
using var writer = XmlWriter.Create(ms, WriterSettings());
|
|
|
|
writer.WriteStartDocument();
|
|
writer.WriteStartElement("sitemapindex", Namespace);
|
|
|
|
// Static sitemap (always one)
|
|
WriteSitemapEntry(writer, "static");
|
|
|
|
// Entity sitemaps — query count to determine page count
|
|
foreach((string apiPath, _, string slug) in EntityTypes)
|
|
{
|
|
int count = await GetEntityCountAsync(client, apiPath);
|
|
int pages = Math.Max(1, (int)Math.Ceiling(count / (double)UrlsPerSitemap));
|
|
|
|
if(pages == 1)
|
|
{
|
|
WriteSitemapEntry(writer, slug);
|
|
}
|
|
else
|
|
{
|
|
for(int page = 1; page <= pages; page++)
|
|
WriteSitemapEntry(writer, $"{slug}-{page}");
|
|
}
|
|
}
|
|
|
|
writer.WriteEndElement();
|
|
writer.WriteEndDocument();
|
|
writer.Flush();
|
|
|
|
string xml = Encoding.UTF8.GetString(ms.ToArray());
|
|
cache.Set(cacheKey, xml, CacheDuration);
|
|
|
|
return xml;
|
|
}
|
|
|
|
/// <summary>Generates a sub-sitemap for the given section (e.g. "static", "software", "software-3").</summary>
|
|
public async Task<string> GenerateSectionSitemapAsync(string section)
|
|
{
|
|
if(string.Equals(section, "static", StringComparison.OrdinalIgnoreCase))
|
|
return GenerateStaticSitemap();
|
|
|
|
// Parse "software-3" → slug="software", page=3; "software" → slug="software", page=1
|
|
string slug = section.ToLowerInvariant();
|
|
int page = 1;
|
|
|
|
int lastDash = slug.LastIndexOf('-');
|
|
|
|
if(lastDash > 0 && int.TryParse(slug[(lastDash + 1)..], out int parsed) && parsed >= 1)
|
|
{
|
|
slug = slug[..lastDash];
|
|
page = parsed;
|
|
}
|
|
|
|
foreach((string apiPath, string urlPrefix, string entitySlug) in EntityTypes)
|
|
{
|
|
if(string.Equals(slug, entitySlug, StringComparison.OrdinalIgnoreCase))
|
|
return await GenerateEntitySitemapAsync(apiPath, urlPrefix, page);
|
|
}
|
|
|
|
return GenerateStaticSitemap();
|
|
}
|
|
|
|
/// <summary>Sitemap for all static and browse/category pages.</summary>
|
|
string GenerateStaticSitemap()
|
|
{
|
|
string[] staticRoutes =
|
|
[
|
|
"/",
|
|
"/about",
|
|
"/contact",
|
|
"/search",
|
|
"/search/advanced",
|
|
|
|
// Computers
|
|
"/computers",
|
|
"/computers/all",
|
|
"/computers/companies",
|
|
|
|
// Consoles
|
|
"/consoles",
|
|
"/consoles/all",
|
|
"/consoles/companies",
|
|
|
|
// Tablets
|
|
"/tablets",
|
|
"/tablets/all",
|
|
"/tablets/companies",
|
|
|
|
// PDAs
|
|
"/pdas",
|
|
"/pdas/all",
|
|
"/pdas/companies",
|
|
|
|
// Smartphones
|
|
"/smartphones",
|
|
"/smartphones/all",
|
|
"/smartphones/companies",
|
|
|
|
// Companies
|
|
"/companies",
|
|
|
|
// Software
|
|
"/software",
|
|
"/software/all",
|
|
"/software/companies",
|
|
"/software/rankings",
|
|
"/software/spec",
|
|
|
|
// Books
|
|
"/books",
|
|
"/books/all",
|
|
"/books/companies",
|
|
|
|
// Documents
|
|
"/documents",
|
|
"/documents/all",
|
|
"/documents/companies",
|
|
|
|
// Magazines
|
|
"/magazines",
|
|
"/magazines/all",
|
|
"/magazines/companies",
|
|
|
|
// Hardware
|
|
"/gpus",
|
|
"/soundsynths",
|
|
"/processors",
|
|
|
|
// People
|
|
"/people",
|
|
"/people/all"
|
|
];
|
|
|
|
// Generate letter-based browse pages (A-Z, 0-9)
|
|
var letterRoutes = new List<string>();
|
|
string[] letterEntities = ["/computers", "/consoles", "/tablets", "/pdas", "/smartphones",
|
|
"/companies", "/people"];
|
|
string[] letterEntitiesWithCompanies = ["/computers/companies", "/consoles/companies",
|
|
"/tablets/companies", "/pdas/companies",
|
|
"/smartphones/companies", "/books/companies",
|
|
"/documents/companies", "/magazines/companies",
|
|
"/software/companies"];
|
|
|
|
for(char c = 'A'; c <= 'Z'; c++)
|
|
{
|
|
foreach(string entity in letterEntities)
|
|
letterRoutes.Add($"{entity}/{c}");
|
|
|
|
foreach(string entity in letterEntitiesWithCompanies)
|
|
letterRoutes.Add($"{entity}/{c}");
|
|
|
|
letterRoutes.Add($"/software/letter/{c}");
|
|
letterRoutes.Add($"/books/{c}");
|
|
letterRoutes.Add($"/documents/{c}");
|
|
letterRoutes.Add($"/magazines/{c}");
|
|
}
|
|
|
|
return BuildUrlSet(staticRoutes, letterRoutes);
|
|
}
|
|
|
|
/// <summary>Fetches a page of entity IDs from the API and generates a sitemap.</summary>
|
|
async Task<string> GenerateEntitySitemapAsync(string apiEndpoint, string urlPrefix, int page)
|
|
{
|
|
string cacheKey = $"sitemap:{apiEndpoint}:{page}";
|
|
|
|
if(cache.TryGetValue(cacheKey, out string? cached) && cached is not null)
|
|
return cached;
|
|
|
|
var urls = new List<string>();
|
|
|
|
try
|
|
{
|
|
HttpClient client = httpClientFactory.CreateClient("SitemapApi");
|
|
|
|
int skip = (page - 1) * UrlsPerSitemap;
|
|
|
|
var ids = await client.GetFromJsonAsync<List<long>>(
|
|
$"/sitemap/{apiEndpoint}/ids?skip={skip}&take={UrlsPerSitemap}");
|
|
|
|
if(ids is not null)
|
|
{
|
|
foreach(long id in ids)
|
|
urls.Add($"{urlPrefix}{id}");
|
|
}
|
|
}
|
|
catch(Exception ex)
|
|
{
|
|
logger.LogWarning(ex, "Failed to fetch IDs from /sitemap/{Endpoint}/ids page {Page} for sitemap",
|
|
apiEndpoint, page);
|
|
}
|
|
|
|
string xml = BuildUrlSet(urls);
|
|
|
|
cache.Set(cacheKey, xml, CacheDuration);
|
|
|
|
return xml;
|
|
}
|
|
|
|
/// <summary>Queries the count endpoint for an entity type.</summary>
|
|
async Task<int> GetEntityCountAsync(HttpClient client, string apiEndpoint)
|
|
{
|
|
try
|
|
{
|
|
return await client.GetFromJsonAsync<int>($"/{apiEndpoint}/count");
|
|
}
|
|
catch(Exception ex)
|
|
{
|
|
logger.LogWarning(ex, "Failed to fetch count from /{Endpoint}/count for sitemap index", apiEndpoint);
|
|
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
static void WriteSitemapEntry(XmlWriter writer, string section)
|
|
{
|
|
writer.WriteStartElement("sitemap");
|
|
writer.WriteElementString("loc", $"{SeoMeta.CanonicalHost}/sitemap-{section}.xml");
|
|
writer.WriteEndElement();
|
|
}
|
|
|
|
string BuildUrlSet(IEnumerable<string> routes, IEnumerable<string>? extraRoutes = null)
|
|
{
|
|
using var ms = new MemoryStream();
|
|
using var writer = XmlWriter.Create(ms, WriterSettings());
|
|
|
|
writer.WriteStartDocument();
|
|
writer.WriteStartElement("urlset", Namespace);
|
|
|
|
foreach(string route in routes)
|
|
{
|
|
writer.WriteStartElement("url");
|
|
writer.WriteElementString("loc", SeoMeta.BuildCanonicalUrl(route));
|
|
writer.WriteEndElement();
|
|
}
|
|
|
|
if(extraRoutes is not null)
|
|
{
|
|
foreach(string route in extraRoutes)
|
|
{
|
|
writer.WriteStartElement("url");
|
|
writer.WriteElementString("loc", SeoMeta.BuildCanonicalUrl(route));
|
|
writer.WriteEndElement();
|
|
}
|
|
}
|
|
|
|
writer.WriteEndElement();
|
|
writer.WriteEndDocument();
|
|
writer.Flush();
|
|
|
|
return Encoding.UTF8.GetString(ms.ToArray());
|
|
}
|
|
|
|
static XmlWriterSettings WriterSettings() => new()
|
|
{
|
|
Encoding = new UTF8Encoding(false),
|
|
Indent = true,
|
|
OmitXmlDeclaration = false
|
|
};
|
|
}
|