Back to "La guerra del 404: hacer que las cosas viejas vuelvan a funcionar"

This is a viewer only at the moment see the article on how this works.

To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk

This is a preview from the server running through my markdig pipeline

404s ASP.NET C# Middleware

La guerra del 404: hacer que las cosas viejas vuelvan a funcionar

Sunday, 23 November 2025

Introducción

Cuando has estado blogueando desde 2004 (sí, realmente), acumulas un montón de detritus digital. Recientemente importé mis antiguos posts de 2004-2009 (https://www.mostlylucid.net/blog/category/Imported) y descubrí que aproximadamente todo enlaces externos que apuntan a sitios que desaparecieron hace una década, viejos esquemas de URL que ya no coinciden con la estructura actual, todo el lote.

El problema se divide en tres partes:

  1. Enlaces internos - Fijado durante el propio proceso de importación usando mi Herramienta de importación de ArchiveOrg. Mis antiguos posts se referían entre sí usando el antiguo esquema URL, así que reescribí aquellos como parte de la migración.

  2. Enlaces externos (en curso) - Este es el grande. Enlaces a los recursos externos que desde entonces han desaparecido, se han movido, o se han convertido en sitios completamente diferentes. Un enlace a alguna documentación de 2006? Se ha ido. Una referencia a una publicación de blog por alguien que hace mucho tiempo ha tomado su sitio? Muerto. Estos necesitan manejo de tiempo de ejecución.

  3. Solicitudes entrantes - La gente (y los motores de búsqueda) todavía tratan de acceder a URLs antiguas como /archive/2006/05/15/123.aspx. El sistema de búsqueda semántica a menudo puede averiguar lo que fueron después, incluso sin una coincidencia exacta de bala.

Ahora, yo podría han horneado las búsquedas de archive.org en el proceso de importación. Pero esta es la cosa: los enlaces rompen con el tiempo. Un sitio que está funcionando hoy podría desaparecer el próximo mes. Al manejar esto en tiempo de ejecución con re-chequeo periódico, el sistema captura automáticamente futuro roturas, no sólo las que existían en el momento de la importación.

Este artículo cubre mi enfoque:

  • Enlaces salientes: BrokenLinkArchiveMiddleware - reemplaza los enlaces externos muertos con snapshots archive.org
  • Enlaces entrantes: El controlador 404 con búsqueda semántica - encuentra el contenido adecuado incluso de viejos esquemas de URL
  • El sistema de aprendizaje: Cómo el sitio se vuelve más inteligente con el tiempo de clics del usuario
  • Tramitación de antecedentes: Comprobar enlaces sin bloquear solicitudes

El problema con el contenido antiguo

Esto es lo que pasa con internet: no es permanente. ¿Esa excelente publicación de blog a la que enlazaste en 2006? ¿Se ha ido. Ese sitio de documentación? Reestructurado tres veces. ¿Tu propio esquema de URL de antes de instalarte en una convención adecuada?

graph TD
    A[User requests old post] --> B{Link valid?}
    B -->|Yes| C[Happy user]
    B -->|No| D[404 Error]
    D --> E[Frustrated user]
    E --> F[User leaves]

    style D stroke:#ef4444,stroke-width:3px
    style F stroke:#ef4444,stroke-width:3px
    style C stroke:#10b981,stroke-width:3px

El enfoque ingenuo es arreglar enlaces manualmente, pero cuando tienes cientos de mensajes con miles de enlaces, eso no está encendido, necesitamos automatización.

Descripción general de la arquitectura

El sistema tiene dos componentes principales trabajando en tándem:

flowchart TB
    subgraph Incoming["Incoming Requests"]
        A[User Request] --> B{Page exists?}
        B -->|Yes| C[Render Page]
        B -->|No| D[404 Handler]
        D --> E{Learned redirect?}
        E -->|Yes| F[301 Permanent Redirect]
        E -->|No| G{High-confidence match?}
        G -->|Yes| H[302 Temporary Redirect]
        G -->|No| I[Show suggestions]
        I --> J[User clicks suggestion]
        J --> K[Learn redirect]
    end

    subgraph Outgoing["Outgoing Links"]
        C --> L[BrokenLinkArchiveMiddleware]
        L --> M[Extract all links]
        M --> N[Register for checking]
        L --> O[Replace broken links]
        O --> P[Archive.org URLs]
        O --> Q[Semantic search results]
        O --> R[Remove dead links]
    end

    style F stroke:#10b981,stroke-width:3px
    style H stroke:#f59e0b,stroke-width:3px
    style P stroke:#3b82f6,stroke-width:3px
    style Q stroke:#8b5cf6,stroke-width:3px

Parte 1: Manejo de enlaces salientes

The BrokenLinkArchiveMiddleware

Este middleware intercepta las respuestas HTML y hace tres cosas:

  1. Extrae todos los enlaces para la verificación de antecedentes
  2. Reemplaza enlaces externos rotos conocidos con versiones de archive.org
  3. Utiliza la búsqueda semántica para encontrar reemplazos para enlaces internos rotos

La idea clave aquí es que queremos encontrar snapshots archive.org de alrededor del momento en que el post fue escrito. Una instantánea de 2024 de un artículo de 2006 podría hacer referencia a contenido completamente diferente. Así que buscamos la fecha de publicación del blog y pedimos archive.org para la instantánea más cercana.

Aquí está la estructura central:

public partial class BrokenLinkArchiveMiddleware(
    RequestDelegate next,
    ILogger<BrokenLinkArchiveMiddleware> logger,
    IServiceScopeFactory serviceScopeFactory)
{
    public async Task InvokeAsync(
        HttpContext context,
        IBrokenLinkService? brokenLinkService,
        ISemanticSearchService? semanticSearchService)
    {
        // Only process HTML responses for blog pages
        if (!ShouldProcessRequest(context))
        {
            await next(context);
            return;
        }

        // Capture the response so we can modify it
        var originalBodyStream = context.Response.Body;
        using var responseBody = new MemoryStream();
        context.Response.Body = responseBody;

        await next(context);

        // Process the HTML response
        if (IsSuccessfulHtmlResponse(context, responseBody))
        {
            var html = await ReadResponseAsync(responseBody);
            html = await ProcessLinksAsync(html, context, brokenLinkService, semanticSearchService);
            await WriteModifiedResponseAsync(originalBodyStream, html, context);
        }
        else
        {
            await CopyOriginalResponseAsync(responseBody, originalBodyStream);
        }
    }
}

Extraer enlaces

Utilizamos un regex generado para sacar todo href los atributos. [GeneratedRegex] atributo en .NET nos da generación de regex de tiempo de compilación, que es tanto más rápido como libre de asignación:

[GeneratedRegex(@"<a[^>]*\shref\s*=\s*[""']([^""']+)[""'][^>]*>",
    RegexOptions.IgnoreCase | RegexOptions.Compiled)]
private static partial Regex HrefRegex();

private List<string> ExtractAllLinks(string html, HttpRequest request)
{
    var links = new List<string>();
    var matches = HrefRegex().Matches(html);

    foreach (Match match in matches)
    {
        var href = match.Groups[1].Value;

        // Skip special links (anchors, mailto, etc.)
        if (SkipPatterns.Any(p => href.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
            continue;

        if (Uri.TryCreate(href, UriKind.Absolute, out var uri))
        {
            if (uri.Scheme == "http" || uri.Scheme == "https")
                links.Add(href);
        }
        else if (href.StartsWith("/"))
        {
            // Convert relative URLs to absolute for tracking
            var baseUri = new UriBuilder(request.Scheme, request.Host.Host,
                request.Host.Port ?? (request.Scheme == "https" ? 443 : 80));
            links.Add(new Uri(baseUri.Uri, href).ToString());
        }
    }

    return links.Distinct().ToList();
}

Ahora usted podría utilizar bastante razonablemente HtmlAgilityPack / https://github.com/AngleSharp/AngleSharp o analizadores HTML similares (y otros) aquí para escenarios más complejos, pero fue exagerado para este propósito - sólo tenemos que extraer hrefs rápidamente.

Registro de enlaces para la verificación de antecedentes

No queremos bloquear la respuesta mientras comprobamos los enlaces. En su lugar, disparamos una tarea de fondo:

var allLinks = ExtractAllLinks(html, context.Request);
var sourcePageUrl = context.Request.Path.Value;

if (allLinks.Count > 0)
{
    // Fire and forget - don't block the response
    _ = Task.Run(async () =>
    {
        using var scope = serviceScopeFactory.CreateScope();
        var scopedService = scope.ServiceProvider.GetRequiredService<IBrokenLinkService>();
        await scopedService.RegisterUrlsAsync(allLinks, sourcePageUrl);
    });
}

Nótese que: IServiceScopeFactory - Necesitamos un nuevo alcance porque los servicios de alcance original de la solicitud serán eliminados antes de que nuestra tarea de fondo se complete.

Importante: Esto es un consistencia eventual modelo. Cuando cualquier enlace se descubre por primera vez, se hace cola para la validación - no sabemos si está roto todavía. El servicio de fondo lo comprueba (solicitud HEAD), y si está roto, obtiene un reemplazo archivo.org. siguiente Para un blog con tráfico regular, esto típicamente significa que los enlaces rotos se arreglan dentro de horas del primer descubrimiento. La belleza es que no tenemos que adivinar qué enlaces podrían estar rotos - simplemente los validamos automáticamente.

Sustitución de enlaces rotos

Una vez que sabemos que un enlace está roto y tenemos un reemplazo de archivo.org (de una verificación de antecedentes anterior), los intercambiamos con un consejo útil:

foreach (var (originalUrl, archiveUrl) in archiveMappings)
{
    if (html.Contains(originalUrl))
    {
        var tooltipText = $"Original link ({originalUrl}) is dead - archive.org version used";
        var originalPattern = $"href=\"{originalUrl}\"";
        var archivePattern = $"href=\"{archiveUrl}\" class=\"tooltip tooltip-warning\" " +
                            $"data-tip=\"{tooltipText}\" data-original-url=\"{originalUrl}\"";

        html = html.Replace(originalPattern, archivePattern);
    }
}

Para enlaces internos rotos, primero probamos la búsqueda semántica:

if (isInternal && semanticSearchService != null)
{
    var replacement = await TryFindSemanticReplacementAsync(
        brokenUrl, semanticSearchService, request, cancellationToken);

    if (replacement != null)
    {
        html = ReplaceHref(html, brokenUrl, replacement);
        continue;
    }
}

// No replacement found - convert to plain text
html = RemoveHref(html, brokenUrl);

Parte 2: Comprobación del enlace de fondo

El middleware no comprueba los enlaces durante la solicitud - que sería demasiado lento. En su lugar, las colas descubrió enlaces para el procesamiento de fondo a través de una tabla de base de datos, y un BrokenLinkCheckerBackgroundService se encarga de la comprobación real.

El servicio funciona cada hora y hace dos cosas:

  1. Comprobar la validez del enlace - Solicitudes de HEAD para ver si los enlaces siguen vivos
  2. Obtener URLs de archivo.org - Para enlaces rotos, encuentre la instantánea histórica más cercana

Somos buenos ciudadanos sobre esto - el verificador utiliza un agente de usuario personalizado que se identifica a sí mismo y enlaza de nuevo al sitio:

request.Headers.UserAgent.ParseAdd(
    "Mozilla/5.0 (compatible; MostlylucidBot/1.0; +https://www.mostlylucid.net)");

Esto permite a los administradores del sitio ver lo que está golpeando su servidor, y pueden buscarnos si tienen curiosidad. También aceleramos peticiones (2 segundos entre cheques) para evitar martillar el servidor de cualquier persona.

Principalmente, los enlaces son revisados de nuevo periódicamenteUn enlace que funcionaba la semana pasada podría estar muerto hoy. El servicio recoge enlaces que no han sido revisados en las últimas 24 horas y los verifica de nuevo. Sin embargo, una vez que hemos encontrado un reemplazo de archivo.org para un enlace roto, no revisamos de nuevo el original - ya está muerto y tenemos un reemplazo de trabajo.

sequenceDiagram
    participant BG as Background Service
    participant DB as Database
    participant Web as External Sites
    participant Archive as Archive.org CDX API

    loop Every Hour
        BG->>DB: Get links not checked in 24h (batch of 20)
        loop For each link
            BG->>Web: HEAD request
            Web-->>BG: Status code
            BG->>DB: Update link status + LastCheckedAt
        end

        BG->>DB: Get broken links needing archive lookup
        loop For each broken link
            BG->>DB: Look up source post publish date
            BG->>Archive: CDX API query (filtered by date)
            Archive-->>BG: Closest snapshot
            BG->>DB: Store archive URL (permanent)
        end
    end

The Archive.org CDX API

Archive.org proporciona una API de CDX (Capture Index) que nos permite consultar las instantáneas. El bit inteligente se filtra por fecha:

private async Task<string?> GetArchiveUrlAsync(
    string originalUrl,
    DateTime? beforeDate,
    CancellationToken cancellationToken)
{
    var queryParams = new List<string>
    {
        $"url={Uri.EscapeDataString(originalUrl)}",
        "output=json",
        "fl=timestamp,original,statuscode",
        "filter=statuscode:200",  // Only successful responses
        "limit=1"
    };

    // Find snapshot closest to the blog post's publish date
    if (beforeDate.HasValue)
    {
        queryParams.Add($"to={beforeDate.Value:yyyyMMdd}");
        queryParams.Add("sort=closest");
        queryParams.Add($"closest={beforeDate.Value:yyyyMMdd}");
    }

    var apiUrl = $"https://web.archive.org/cdx/search/cdx?{string.Join("&", queryParams)}";

    // ... fetch and parse response

    return $"https://web.archive.org/web/{timestamp}/{original}";
}

Esto significa que si escribía un post en 2008 enlazando con algún recurso, obtendré la instantánea de archive.org de alrededor de 2008, no una versión moderna que podría ser completamente diferente.

Seguimiento de estado de enlace

Rastreamos los vínculos con una entidad adecuada:

[Table("broken_links", Schema = "mostlylucid")]
public class BrokenLinkEntity
{
    public int Id { get; set; }
    public string OriginalUrl { get; set; } = string.Empty;
    public string? ArchiveUrl { get; set; }
    public bool IsBroken { get; set; } = false;
    public int? LastStatusCode { get; set; }
    public DateTimeOffset? LastCheckedAt { get; set; }
    public int ConsecutiveFailures { get; set; } = 0;
    public string? SourcePageUrl { get; set; }  // For publish date lookup
}

Revisión periódica

La clave para capturar futuras rupturas es volver a comprobar. Las consultas de servicio para enlaces que no han sido validados recientemente:

public async Task<List<BrokenLinkEntity>> GetLinksToCheckAsync(int batchSize, CancellationToken cancellationToken)
{
    var cutoff = DateTimeOffset.UtcNow.AddHours(-24);

    return await _dbContext.BrokenLinks
        .Where(x => x.LastCheckedAt == null || x.LastCheckedAt < cutoff)
        .OrderBy(x => x.LastCheckedAt ?? DateTimeOffset.MinValue)  // Oldest first
        .Take(batchSize)
        .ToListAsync(cancellationToken);
}

Esto significa que cada enlace se vuelve a validar al menos una vez al día. Si un enlace de trabajo anterior comienza a devolver 404s, lo cogeremos y empezaremos a buscar un reemplazo de archive.org. ConsecutiveFailures campo nos permite ser un poco indulgente - no marcamos un enlace como roto después de un error transitorio.

Parte 3: Tratamiento de las solicitudes entrantes

Ahora para el otro lado de la moneda: personas (y motores de búsqueda) solicitando URLs que no existen. Esto incluye:

  • Regímenes de URL antiguos - Mi antiguo blog usó caminos como /archive/2006/05/15/123.aspx. Algunos de ellos todavía están indexados, marcados con marcadores o enlazados desde otros sitios.
  • Typos - Alguien toma la dirección URL o la copia incorrectamente.
  • Babosas parciales - Los motores de búsqueda a veces indexan fragmentos extraños.

El sistema de búsqueda semántica es particularmente útil aquí. Incluso sin una coincidencia directa de babosas, podemos extraer términos significativos de la URL solicitada y encontrar contenido que coincida semánticamente. El sistema conoce tanto la babosa y tiene datos incrustados para cada publicación, por lo que puede encontrar coincidencias "lo suficientemente cercanas" incluso para estructuras URL completamente diferentes.

¿Por qué no Middleware?

Mi primer instinto fue manejar redireccionamientos en middleware - interceptar peticiones temprano, comprobar si las redirecciones conocidas, y redirigir antes de que el enrutamiento incluso se inicia. podría trabajo, y así es como se vería:

// DON'T DO THIS - runs on EVERY request
public class SlugRedirectMiddleware(RequestDelegate next)
{
    public async Task InvokeAsync(HttpContext context, ISlugSuggestionService? service)
    {
        if (context.Request.Path.StartsWithSegments("/blog"))
        {
            var targetSlug = await service.GetAutoRedirectSlugAsync(slug, language);
            if (!string.IsNullOrWhiteSpace(targetSlug))
            {
                context.Response.Redirect($"/blog/{targetSlug}", permanent: true);
                return;
            }
        }
        await next(context);
    }
}

¿El problema? todas y cada una de las solicitudes a /blog/*. Esa es una consulta de base de datos en cada vista de página, incluso para URLs perfectamente válidas. Para un blog con tráfico decente, usted está martillando la base de datos sin ninguna buena razón 99% de las veces.

El enfoque mucho mejor: enganchar en el controlador 404. Si ASP.NET ya ha determinado que la página no existe, entonces Comprobamos si hay redireccionamientos. No hay consultas perdidas en páginas válidas.

Hecho curioso: En los primeros días de ASP.NET MVC así fue como hicimos funcionar URLs amigables. Demo de avión de Scott Guthrie que comenzó MVC utilizó este enfoque; enganchar en el sistema de manejo IIS 404 , leer la URL y servir el contenido correcto. Fue TAMBIÉN lo que utilicé en un sistema que construí en WebForms 3 años atrás... y cómo conseguí un concierto PM en el equipo de ASP.NET!

El sistema de aprendizaje

Cuando alguien golpea un 404, le mostramos sugerencias usando coincidencias de cadenas borrosas y (opcionalmente) búsqueda semántica. Si hacen clic en una sugerencia, registramos eso. Después de suficientes clics con suficiente confianza, comenzamos la auto-redirección.

stateDiagram-v2
    [*] --> RequestReceived
    RequestReceived --> RoutingCheck

    RoutingCheck --> PageFound: Exists
    RoutingCheck --> NotFound: 404

    PageFound --> [*]

    NotFound --> ErrorController
    ErrorController --> CheckLearnedRedirect
    CheckLearnedRedirect --> Redirect301: Has learned redirect
    CheckLearnedRedirect --> CheckHighConfidence: No learned redirect

    CheckHighConfidence --> Redirect302: Score >= 0.85 & gap >= 0.15
    CheckHighConfidence --> ShowSuggestions: Lower confidence

    ShowSuggestions --> UserClicks
    UserClicks --> RecordClick
    RecordClick --> UpdateWeight
    UpdateWeight --> CheckThreshold

    CheckThreshold --> EnableAutoRedirect: Weight >= 5 & confidence >= 70%
    CheckThreshold --> [*]: Below threshold

    EnableAutoRedirect --> [*]

El controlador 404

Toda la lógica de redireccionamiento vive en el ErrorController. ASP.NET's UseStatusCodePagesWithReExecute middleware re-ejecuta la solicitud a través de nuestro controlador de errores cuando ocurre un 404:

public class ErrorController(
    BaseControllerService baseControllerService,
    ILogger<ErrorController> logger,
    ISlugSuggestionService? slugSuggestionService = null) : BaseController(baseControllerService, logger)
{
    [Route("/error/{statusCode}")]
    [HttpGet]
    public async Task<IActionResult> HandleError(int statusCode, CancellationToken cancellationToken = default)
    {
        var statusCodeReExecuteFeature = HttpContext.Features.Get<IStatusCodeReExecuteFeature>();

        switch (statusCode)
        {
            case 404:
                // Check for auto-redirects before showing 404 page
                var autoRedirectResult = await TryAutoRedirectAsync(statusCodeReExecuteFeature, cancellationToken);
                if (autoRedirectResult != null)
                    return autoRedirectResult;

                var model = await CreateNotFoundModel(statusCodeReExecuteFeature, cancellationToken);
                return View("NotFound", model);

            case 500:
                return View("ServerError");

            default:
                return View("Error");
        }
    }
}

Los TryAutoRedirectAsync método maneja tanto redireccionamientos aprendidos y partidos de alta confianza por primera vez:

private async Task<IActionResult?> TryAutoRedirectAsync(
    IStatusCodeReExecuteFeature? statusCodeReExecuteFeature,
    CancellationToken cancellationToken)
{
    if (slugSuggestionService == null || statusCodeReExecuteFeature == null)
        return null;

    var originalPath = statusCodeReExecuteFeature.OriginalPath ?? string.Empty;
    var (slug, language) = ExtractSlugAndLanguage(originalPath);

    // First: check for learned redirects (user previously clicked a suggestion)
    // These get 301 Permanent Redirect - confirmed patterns
    var learnedTargetSlug = await slugSuggestionService.GetAutoRedirectSlugAsync(
        slug, language, cancellationToken);

    if (!string.IsNullOrWhiteSpace(learnedTargetSlug))
    {
        var redirectUrl = BuildRedirectUrl(learnedTargetSlug, language);
        logger.LogInformation("Learned auto-redirect (301): {Original} -> {Target}", originalPath, redirectUrl);
        return RedirectPermanent(redirectUrl);
    }

    // Second: check for high-confidence first-time matches
    // These get 302 Temporary Redirect until confirmed by user clicks
    var firstTimeTargetSlug = await slugSuggestionService.GetFirstTimeAutoRedirectSlugAsync(
        slug, language, cancellationToken);

    if (!string.IsNullOrWhiteSpace(firstTimeTargetSlug))
    {
        var redirectUrl = BuildRedirectUrl(firstTimeTargetSlug, language);
        logger.LogInformation("First-time auto-redirect (302): {Original} -> {Target}", originalPath, redirectUrl);
        return Redirect(redirectUrl);
    }

    return null;  // No redirect - show suggestions
}

Puntuación de similitud

El servicio de sugerencias utiliza la distancia de Levenshtein (distancia de edición) más algunas heurísticas:

private double CalculateSimilarity(string source, string target)
{
    if (string.Equals(source, target, StringComparison.OrdinalIgnoreCase))
        return 1.0;

    source = source.ToLowerInvariant();
    target = target.ToLowerInvariant();

    // Levenshtein distance converted to similarity (0-1)
    var distance = CalculateLevenshteinDistance(source, target);
    var maxLength = Math.Max(source.Length, target.Length);
    var levenshteinSimilarity = 1.0 - (double)distance / maxLength;

    // Bonus if one string contains the other
    var substringBonus = (source.Contains(target) || target.Contains(source)) ? 0.2 : 0.0;

    // Bonus for common prefix (catches typos at the end)
    var prefixLength = GetCommonPrefixLength(source, target);
    var prefixBonus = (double)prefixLength / Math.Min(source.Length, target.Length) * 0.1;

    return Math.Min(1.0, levenshteinSimilarity + substringBonus + prefixBonus);
}

Aprender de la conducta del usuario

Cuando un usuario hace clic en una sugerencia, la registramos y actualizamos las puntuaciones de confianza:

public async Task RecordSuggestionClickAsync(
    string requestedSlug,
    string clickedSlug,
    string language,
    int suggestionPosition,
    double originalScore,
    CancellationToken cancellationToken = default)
{
    var redirect = await _context.SlugRedirects
        .FirstOrDefaultAsync(r =>
            r.FromSlug == normalizedRequestedSlug &&
            r.ToSlug == normalizedClickedSlug &&
            r.Language == language,
            cancellationToken);

    if (redirect == null)
    {
        redirect = new SlugRedirectEntity
        {
            FromSlug = normalizedRequestedSlug,
            ToSlug = normalizedClickedSlug,
            Language = language,
            Weight = 1
        };
        _context.SlugRedirects.Add(redirect);
    }
    else
    {
        redirect.Weight++;
        redirect.LastClickedAt = DateTimeOffset.UtcNow;
    }

    redirect.UpdateConfidenceScore();
    await _context.SaveChangesAsync(cancellationToken);
}

El cálculo de la puntuación de confianza considera tanto clics como impresiones:

public void UpdateConfidenceScore()
{
    var total = Weight + ShownCount;
    ConfidenceScore = total > 0 ? (double)Weight / total : 0.0;

    // Enable auto-redirect after 5+ clicks with 70%+ confidence
    if (Weight >= AutoRedirectWeightThreshold &&
        ConfidenceScore >= AutoRedirectConfidenceThreshold)
    {
        AutoRedirect = true;
    }
}

Dos-Tier Auto-Redirigir

Tenemos dos niveles de redireccionamiento automático:

  1. Primera vez alta confianza (302): Si la puntuación de similitud es > 0.85 Y hay una brecha significativa al segundo mejor partido, redirigimos inmediatamente con un 302. Esto atrapa errores tipográficos obvios.

  2. Redireccionamientos aprendidos (301): Después de 5+ clics con un 70%+ de confianza, utilizamos una redireccionamiento permanente 301. Este es el caso "los humanos han hablado".

public async Task<string?> GetFirstTimeAutoRedirectSlugAsync(
    string requestedSlug,
    string language,
    CancellationToken cancellationToken = default)
{
    var suggestions = await GetSuggestionsWithScoreAsync(requestedSlug, language, 2, cancellationToken);

    if (suggestions.Count == 0) return null;

    var topMatch = suggestions[0];

    // Need high confidence
    if (topMatch.Score < 0.85) return null;

    // If there's only one suggestion with high score, redirect
    if (suggestions.Count == 1) return topMatch.Post.Slug;

    // Multiple suggestions - only redirect if there's a clear winner
    var scoreGap = topMatch.Score - suggestions[1].Score;
    if (scoreGap >= 0.15)
        return topMatch.Post.Slug;

    return null;  // Too close to call - show suggestions instead
}

Reunirlo todo

Cuando Middleware tiene sentido (y cuando no lo tiene)

Por enlaces salientes, middleware es la elección correcta. BrokenLinkArchiveMiddleware necesita interceptar la respuesta HTML después ha sido renderizado, pero antes No hay otro lugar sensato para hacer esto - necesitamos modificar el flujo de respuesta, y middleware está diseñado exactamente para eso.

Por redireccionamientos entrantes, middleware es la elección equivocada. Estaríamos ejecutando consultas de base de datos en cada /blog/* petición sólo para comprobar si tal vez, posiblemente, esta URL podría necesitar una redireccionamiento. El enfoque de controlador 404 sólo se ejecuta esa lógica cuando ya hemos determinado que la página no existe - mucho más eficiente.

// In Program.cs
app.UseStatusCodePagesWithReExecute("/error/{0}");  // Handles 404s through ErrorController
app.UseStaticFiles();
app.UseRouting();
// ... other middleware
app.UseBrokenLinkArchive();  // Process outgoing links in responses (ONLY for middleware)

El flujo se ve así:

flowchart LR
    subgraph Request["Request Processing"]
        direction TB
        A[Request] --> B[Static Files]
        B --> C[Routing]
        C --> D{Page exists?}
        D -->|Yes| E[MVC/Endpoints]
        D -->|No| F[ErrorController 404]
        F --> G{Auto-redirect?}
        G -->|Yes| H[Redirect]
        G -->|No| I[Show suggestions]
    end

    subgraph Response["Response Processing"]
        direction TB
        E --> J[BrokenLinkArchiveMiddleware]
        J --> K[Link Extraction]
        K --> L[Link Replacement]
        L --> M[Response]
    end

    subgraph Background["Background Processing"]
        direction TB
        N[BrokenLinkCheckerService] --> O[Check URLs]
        O --> P[Fetch Archive.org]
        P --> Q[Update Database]
    end

    K -.->|Register links| N

    style F stroke:#ef4444,stroke-width:3px
    style J stroke:#8b5cf6,stroke-width:3px
    style N stroke:#f59e0b,stroke-width:3px

Conclusión

Este sistema ha estado funcionando durante un tiempo y es bastante satisfactorio ver cómo aprende. La base de datos se llena gradualmente con asignaciones de redireccionamiento, enlaces rotos obtener reemplazos archive.org, y los usuarios rara vez ven un 404 desnudo ya.

Los principios fundamentales:

  • No bloquee las solicitudes - utilizar el procesamiento de fondo para operaciones costosas
  • Aprender de los usuarios - sus clics son señales valiosas
  • Ser conservador con auto-reorientación - sólo redirigir cuando estés seguro
  • Archivo con conocimiento de tiempo - encontrar snapshots archive.org de cuando se escribió el contenido
  • Degradación agraciada - si los servicios no están disponibles, sólo continúe normalmente

No es perfecto - algunos enlaces realmente se han ido para siempre, y la búsqueda semántica no siempre encuentra el reemplazo correcto. Pero es una maldita vista mejor que dejar miles de enlaces rotos mintiendo.

Próximamente: Búsqueda Semántica Buceo Profundo

Esta semana (p/c 24 de noviembre de 2025) voy a publicar mi serie de búsquedas semánticas que entra en mucho más detalle sobre cómo funciona la búsqueda basada en vectores bajo el capó. Esa serie cubrirá la generación de incrustación, almacenamiento de vectores Qdrant, y cómo todo se vincula en este sistema de manejo 404. Si tienes curiosidad sobre cómo ISemanticSearchService.SearchAsync En realidad encuentra contenido "similar", ahí es donde querrás buscar.

La fuente completa se encuentra en el repositorio si desea robar algo de él para sus propios proyectos.

logo

© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.