# La guerra al 404: Rendere di nuovo il vecchio lavoro roba

<!--category-- ASP.NET, C# -->
<datetime class="hidden">2025-11-23T10:35</datetime>

## Introduzione

Quando si è blogging dal 2004 (sì, davvero), si accumulano un sacco di detriti digitali. Recentemente ho importato i miei vecchi messaggi dal 2004-2009 ( https://www.mostlylucid.net/blog/category/Imported) e scoperto che circa *tutto* Collegamenti esterni che puntano a siti scomparsi un decennio fa, vecchi schemi di URL che non corrispondono più alla struttura attuale, all'intero lotto.

Il problema si articola in tre parti:

1. **Collegamenti interni** - Fisso durante il processo di importazione stesso utilizzando il mio [Strumento importatore di ArchiveOrg](https://github.com/scottgal/mostlylucid.nugetpackages/tree/main/Mostlylucid.ArchiveOrg). I miei vecchi messaggi si riferivano l'un l'altro utilizzando il vecchio schema URL, così ho riscritto quelli come parte della migrazione.

2. **Collegamenti esterni (in uscita)** - Questo è il grande. Collegamenti a risorse esterne che da allora sono scomparsi, spostati, o diventano siti completamente diversi. Un link a qualche documentazione dal 2006? Andato. Un riferimento a un post del blog di qualcuno che è da tempo preso il loro sito giù? Morto. Questi hanno bisogno di gestione runtime.

3. **Richieste in entrata** - Persone (e motori di ricerca) ancora cercare di accedere a vecchi URL come `/archive/2006/05/15/123.aspx`. Il sistema di ricerca semantico può spesso capire cosa stavano cercando, anche senza una corrispondenza esatta slug.

Ora, io *potrebbe* Ma ecco la cosa: i link si rompono nel tempo. Un sito che funziona oggi potrebbe essere andato il mese prossimo. Gestindo questo a runtime con periodica ri-controllo, il sistema cattura automaticamente *futuro* Rotture, non solo quelle che esistevano al momento dell'importazione.

Questo articolo riguarda il mio approccio:

- **Collegamenti in uscita**: `BrokenLinkArchiveMiddleware` - sostituisce i collegamenti esterni morti con le istantanee di archive.org
- **Collegamenti in entrata**: Il gestore 404 con ricerca semantica - trova il contenuto giusto anche da vecchi schemi URL
- **Il sistema di apprendimento**: Come il sito diventa più intelligente nel tempo da click utente
- **Trattamento dei precedenti**: Controllare i collegamenti senza bloccare le richieste

[TOC]

## Il problema con i vecchi contenuti

Ecco la cosa di internet: non è permanente. Quel blog post eccellente che hai collegato nel 2006? Andato. Quel sito di documentazione? Ristrutturato tre volte. Il tuo schema di URL da prima di stabilirsi su una corretta convenzione di slugging? Imbarazzante.

```mermaid
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
```

L'approccio ingenuo è quello di risolvere i link manualmente. Ma quando hai centinaia di post con migliaia di link, che non è acceso. Abbiamo bisogno di automazione.

## Panoramica dell'architettura

Il sistema ha due componenti principali che lavorano in tandem:

```mermaid
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: Gestione dei collegamenti in uscita

### The BrokenLinkArchiveMidleware

Questo middleware intercetta le risposte HTML e fa tre cose:

1. Estrae tutti i link per il controllo dello sfondo
2. Sostituisce i collegamenti esterni rotti noti con le versioni di archive.org
3. Usa la ricerca semantica per trovare sostituzioni per collegamenti interni rotti

L'intuizione chiave qui è che vogliamo trovare archive.org snapshot da *intorno al momento in cui il post è stato scritto*. Una snapshot dal 2024 di un articolo 2006 potrebbe fare riferimento a contenuti completamente diversi. Quindi cerchiamo la data di pubblicazione del post del blog e chiediamo ad archive.org la snapshot più vicina.

Ecco la struttura principale:

```csharp
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);
        }
    }
}
```

### Estrazione collegamenti

Usiamo un regex generato per estrarre tutto `href` attributi. `[GeneratedRegex]` attributo in .NET ci dà la generazione del regex del tempo di compilazione, che è sia più veloce e allocation-free:

```csharp
[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();
}
```

Ora puoi usare abbastanza ragionevolmente [HtmlAgilityPack](https://html-agility-pack.net/) / https://github.com/AngleSharp/AngleSharp o simili HTML (e altri) parser qui per scenari più complessi, ma è stato eccessivo per questo scopo - abbiamo solo bisogno di estrarre hrefs rapidamente.

### Registrazione dei collegamenti per il controllo in background

Non vogliamo bloccare la risposta durante il controllo dei collegamenti. Invece, accendiamo un'attività di sfondo:

```csharp
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);
    });
}
```

Notare la `IServiceScopeFactory` - abbiamo bisogno di un nuovo campo di applicazione perché i servizi oggetto della richiesta originale saranno disposti prima che il nostro compito di sfondo completa.

**Importante**: Questo è un *eventuale coerenza* modello. Quando *qualsiasi* il link viene scoperto per primo, viene messo in coda per la convalida - non sappiamo se è ancora rotto. Il servizio di background lo controlla (richiesta HEAD), e se è rotto, prende una sostituzione di archive.org. *successivo* Visitatore a quella pagina vedrà il link fisso. Per un blog con traffico regolare, questo significa tipicamente collegamenti rotti ottenere fissato entro ore dalla prima scoperta. La bellezza è che non dobbiamo indovinare quali collegamenti potrebbero essere rotti - li convalidiamo tutti automaticamente.

### Sostituire i collegamenti rotti

Una volta che sappiamo che un link è rotto e abbiamo una sostituzione di archive.org (da un controllo di sfondo precedente), li scambiamo con un utile suggerimento:

```csharp
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);
    }
}
```

Per i collegamenti interni rotti, proviamo prima la ricerca semantica:

```csharp
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: Controllo dei collegamenti in background

Il middleware non controlla i collegamenti durante la richiesta - che sarebbe troppo lento. Invece, in coda ha scoperto i collegamenti per l'elaborazione dello sfondo tramite una tabella del database, e un `BrokenLinkCheckerBackgroundService` gestisce il controllo effettivo.

Il servizio funziona ogni ora e fa due cose:

1. **Controlla la validità del collegamento** - HEAD chiede di vedere se i collegamenti sono ancora vivi
2. **Prendi gli URL di archive.org** - Per i collegamenti rotti, trovare l'istantanea storica più vicina

Siamo buoni cittadini su questo - la pedina utilizza un User-Agent personalizzato che si identifica e link al sito:

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

Questo permette agli amministratori del sito di vedere cosa sta colpendo il loro server, e possono cercarci se sono curiosi. Abbiamo anche acceleratore richieste (2 secondi tra i controlli) per evitare di martellare il server di chiunque.

Fondamentalmente, i collegamenti sono **periodicamente ricontrollato**. Un link che stava lavorando la scorsa settimana potrebbe essere morto oggi. Il servizio raccoglie i collegamenti che non sono stati controllati nelle ultime 24 ore e li verifica di nuovo. Tuttavia, una volta trovato un sostituto di archive.org per un collegamento rotto, non ricontrolliamo l'originale - è già morto e abbiamo un sostituto di lavoro.

```mermaid
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
```

### L'API CDX di Archive.org

Archive.org fornisce un'API CDX (Capture Index) che ci permette di interrogare le istantanee. Il bit intelligente filtra per data:

```csharp
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}";
}
```

Questo significa che se ho scritto un post nel 2008 collegandomi ad alcune risorse, otterrò l'istantanea di archive.org da circa 2008, non una versione moderna che potrebbe essere completamente diversa.

### Rintracciamento dello stato del collegamento

Rintracciamo i collegamenti con un'entità propria:

```csharp
[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
}
```

### Ricontrollo periodico

La chiave per catturare le rotture future è ri-controllare. Le query di servizio per i collegamenti che non sono stati convalidati recentemente:

```csharp
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);
}
```

Questo significa che ogni link viene ri-convalidato almeno una volta al giorno. Se un link precedentemente funzionante inizia a restituire 404s, lo prenderemo e inizieremo a cercare una sostituzione di archive.org. `ConsecutiveFailures` campo ci permette di essere un po 'perdonante - non segniamo un collegamento come rotto dopo un errore transitorio.

## Parte 3: Gestione delle richieste in entrata

Ora per l'altro lato della moneta: persone (e motori di ricerca) che richiedono URL che non esistono. Questo include:

- **Vecchi schemi URL** - Il mio antico blog usava sentieri come `/archive/2006/05/15/123.aspx`. Alcuni di questi sono ancora indicizzati, segnalibri, o collegati da altri siti.
- **TyposCity name (optional, probably does not need a translation)** - Qualcuno dipinge l'URL o lo copia in modo errato.
- **Lumache parziali** - I motori di ricerca a volte indicizzano frammenti strani.

Il sistema di ricerca semantica è particolarmente utile qui. Anche senza un match diretto di slug, possiamo estrarre termini significativi dall'URL richiesto e trovare contenuti che combaciano semanticamente. Il sistema conosce sia la slug che ha i dati di inserimento per ogni post, in modo da poter trovare "abbastanza" corrispondenze anche per strutture URL completamente diverse.

### Perche' non il Middleware?

Il mio primo istinto è stato quello di gestire reindirizzamenti in middleware - intercettare le richieste in anticipo, controllare i reindirizzamenti noti, e reindirizzare prima del routing anche calci in. *potrebbe* lavoro, ed ecco come sarebbe:

```csharp
// 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);
    }
}
```

Il problema? *ogni singola richiesta* a `/blog/*`. Questa è una query di database su ogni pagina vista, anche per gli URL perfettamente validi. Per un blog con traffico decente, si sta martellando il database per nessun buon motivo 99% del tempo.

L'approccio molto migliore: gancio nel gestore 404. Se ASP.NET ha già determinato la pagina non esiste, *poi* controlliamo i reindirizzamenti. Nessuna richiesta sprecata su pagine valide.

> Fatto divertente: Nei primi giorni di ASP.NET MVC questo è stato il modo in cui abbiamo fatto funzionare gli URL amichevoli. La demo di piano di Scott Guthrie che ha iniziato MVC ha usato questo approccio; gancio nel sistema di movimentazione IIS 404 , leggere l'URL e servire il contenuto giusto. Era anche quello che ho usato in un sistema che ho costruito in WebForms 3 anni pervious...e come ho ottenuto un concerto PM sul team ASP.NET!

### Il sistema di apprendimento

Quando qualcuno colpisce un 404, mostriamo loro suggerimenti usando stringhe fuzzy matching e (opzionalmente) ricerca semantica. Se fanno clic su un suggerimento, lo registriamo. Dopo abbastanza click con sufficiente fiducia, iniziamo auto-redirezione.

```mermaid
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 --> [*]
```

### Il responsabile 404

Tutta la logica redirect vive nel `ErrorController`. ASP.NET's `UseStatusCodePagesWithReExecute` middleware re-esegui la richiesta tramite il nostro gestore di errore quando si verifica un 404:

```csharp
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");
        }
    }
}
```

La `TryAutoRedirectAsync` metodo gestisce sia reindirizzamenti appresi e ad alta fiducia partite per la prima volta:

```csharp
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
}
```

### Similarità Punteggio

Il servizio di suggerimento utilizza distanza Levenshtein (distanza modifica) più alcune euristiche:

```csharp
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);
}
```

### Imparare dal comportamento degli utenti

Quando un utente fa clic su un suggerimento, lo registriamo e aggiorniamo i punteggi di confidenza:

```csharp
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);
}
```

Il calcolo del punteggio di fiducia considera sia i click che le impressioni:

```csharp
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;
    }
}
```

### Riindirizzamento automatico a due Tier

Abbiamo due livelli di reindirizzamenti automatici:

1. **Prima fiducia (302)**: Se il punteggio di somiglianza è < 0,85 E c'è un divario significativo nella seconda partita migliore, reindirizziamo immediatamente con un 302. Questo cattura errori di battitura evidenti.

2. **Reindirizzamenti imparati (301)**: Dopo 5+ click con il 70%+ di fiducia, usiamo un reindirizzamento permanente 301. Questo è il caso "gli esseri umani hanno parlato."

```csharp
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
}
```

## Mettere tutto insieme

### Quando il middleware fa sentire (e quando non lo fa)

Per **Collegamenti in uscita**, middleware è la scelta giusta. `BrokenLinkArchiveMiddleware` deve intercettare la risposta HTML *dopo* è stato reso ma *prima* Non c'e' altro posto sensato per farlo - dobbiamo modificare il flusso di risposta, e il middleware e' progettato proprio per questo.

Per **reindirizzamenti in entrata**, middleware è la scelta sbagliata. `/blog/*` richiesta solo per verificare se forse, forse, questo URL potrebbe avere bisogno di un reindirizzamento. L'approccio 404 gestore esegue questa logica solo quando abbiamo già determinato la pagina non esiste - molto più efficiente.

```csharp
// 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)
```

Il flusso assomiglia a questo:

```mermaid
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
```

## Conclusione

Questo sistema è stato in esecuzione per un po 'di tempo ed è piuttosto soddisfacente vederlo imparare. Il database si riempie gradualmente con redirect mapping, link rotti ottenere sostituzioni archive.org, e gli utenti raramente vedere un nudo 404 più.

I principi fondamentali:

- **Non bloccare le richieste** - utilizzare la lavorazione di fondo per operazioni costose
- **Imparare dagli utenti** - i loro click sono segnali preziosi
- **Essere conservatori con auto-redirects** - reindirizza solo quando sei sicuro
- **Archiviazione consapevole del tempo** - trova le istantanee di archive.org da quando il contenuto è stato scritto
- **Degradazione graziosa** - se i servizi non sono disponibili, basta continuare normalmente

Non è perfetto - alcuni collegamenti sono veramente andati per sempre, e la ricerca semantica non sempre trovare la giusta sostituzione. Ma è una dannata vista meglio che lasciare migliaia di collegamenti rotti in giro.

## Presto in arrivo: Semantic Search Deep Dive

Questa settimana (p/c 24 novembre 2025) pubblicherò la mia serie di ricerca semantica che va in maggior dettaglio su come funziona la ricerca basata sui vettori sotto il cofano. Quella serie coprirà la generazione di embedding, lo storage vettoriale Qdrant, e come tutto si collega a questo sistema di gestione 404. Se siete curiosi di sapere come `ISemanticSearchService.SearchAsync` In realtà trova contenuto "simile," è lì che si desidera guardare.

Il sorgente completo è nel repository se si desidera rubare uno qualsiasi di esso per i propri progetti.