# Ο Πόλεμος στο 404: Κάνοντας παλιά πράγματα λειτουργούν και πάλι

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

## Εισαγωγή

Όταν κάνετε blogging από το 2004 (ναι, πραγματικά), συσσωρεύετε πολλά digital detritus. Πρόσφατα εισήγαγα τις παλιές μου δημοσιεύσεις από το 2004-2009 ( https://www.mostlyclearning.net/blog/category/imported) και ανακάλυψα ότι περίπου *Τα πάντα.* Εξωτερικοί σύνδεσμοι που δείχνουν τοποθεσίες που εξαφανίστηκαν πριν από μια δεκαετία, παλιά σχέδια URL που δεν ταιριάζουν πλέον με την τρέχουσα δομή, όλη η παρτίδα.

Το πρόβλημα διαλύεται σε τρία μέρη:

1. **Εσωτερικοί σύνδεσμοι** - Σταθερή κατά τη διάρκεια της ίδιας της διαδικασίας εισαγωγής με τη χρήση μου [ArchiveOrg εργαλείο εισαγωγέα](https://github.com/scottgal/mostlylucid.nugetpackages/tree/main/Mostlylucid.ArchiveOrg). Οι παλιές θέσεις μου αναφέρονται ο ένας στον άλλο χρησιμοποιώντας το παλιό URL σχέδιο, έτσι ξαναγράφω αυτές ως μέρος της μετανάστευσης.

2. **Εξωτερικοί σύνδεσμοι (εξερχόμενοι)** - Αυτό είναι το μεγάλο. Συνδέσεις με εξωτερικούς πόρους που από τότε έχουν εξαφανιστεί, μετακινηθεί, ή να γίνουν εντελώς διαφορετικές ιστοσελίδες. Ένας σύνδεσμος με κάποια τεκμηρίωση από το 2006; Gone. Μια αναφορά σε μια δημοσίευση blog από κάποιον που έχει καιρό μετά να πάρει το site τους κάτω; Νεκρός. Αυτά χρειάζονται χειρισμό runtime.

3. **Εισερχόμενες αιτήσεις** - Οι άνθρωποι (και οι μηχανές αναζήτησης) εξακολουθούν να προσπαθούν να έχουν πρόσβαση σε παλιά URL όπως `/archive/2006/05/15/123.aspx`Το σημασιολογικό σύστημα αναζήτησης μπορεί συχνά να καταλάβει τι κυνηγούσαν, ακόμα και χωρίς ακριβή ταύτιση γυμνοσάλιαγκα.

Τώρα, εγώ *θα μπορούσε* έχουν ψήσει το αρχείο.org ψάχνουν στη διαδικασία εισαγωγής. Αλλά εδώ είναι το πράγμα: σύνδεσμοι σπάσει με το χρόνο. Ένα site που λειτουργεί σήμερα μπορεί να χαθεί τον επόμενο μήνα. *μέλλον* διαλείμματα, όχι μόνο αυτά που υπήρχαν την εποχή των εισαγωγών.

Το άρθρο αυτό καλύπτει την προσέγγισή μου:

- **Εξερχόμενοι σύνδεσμοι**: `BrokenLinkArchiveMiddleware` - αντικαθιστά τους νεκρούς εξωτερικούς συνδέσμους με το αρχείο.org στιγμιότυπα
- **Εισερχόμενοι σύνδεσμοι**: Ο 404 χειριστής με σημασιολογική αναζήτηση - βρίσκει το σωστό περιεχόμενο ακόμα και από παλιά προγράμματα URL
- **Το σύστημα μάθησης**: Πώς η ιστοσελίδα γίνεται πιο έξυπνη με την πάροδο του χρόνου από τα κλικ του χρήστη
- **Επεξεργασία ιστορικού**: Έλεγχος συνδέσμων χωρίς μπλοκάρισμα αιτήσεων

[TOC]

## Το Πρόβλημα με το Παλιό Περιεχόμενο

Αυτό είναι το θέμα με το διαδίκτυο: δεν είναι μόνιμο. Αυτό το εξαιρετικό blog post που συνδέονται με το 2006; Gone. Αυτός ο ιστότοπος τεκμηρίωσης; Αναδομημένος τρεις φορές. δικό σας σχήμα URL από πριν εγκατασταθεί σε μια σωστή σύμβαση slugging; Ενοχλητικό.

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

Η αφελής προσέγγιση είναι να φτιάχνεις συνδέσμους χειροκίνητα, αλλά όταν έχεις εκατοντάδες θέσεις με χιλιάδες συνδέσμους, αυτό δεν ισχύει, χρειαζόμαστε αυτοματισμούς.

## Αρχιτεκτονική επισκόπηση

Το σύστημα διαθέτει δύο κύρια κατασκευαστικά στοιχεία που λειτουργούν από κοινού:

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

## Μέρος 1: Χειρισμός εξερχόμενων δεσμών

### The BrokenLink ArchiveMiddleware

Αυτό το μεσαίο λογισμικό αναχαιτίζει τις απαντήσεις HTML και κάνει τρία πράγματα:

1. Απόσπασμα όλων των συνδέσμων για έλεγχο ιστορικού
2. Αντικαθιστά γνωστούς σπασμένους εξωτερικούς συνδέσμους με τις εκδόσεις archive.org
3. Χρησιμοποιεί σημασιολογική αναζήτηση για να βρει αντικαταστάσεις για σπασμένους εσωτερικούς συνδέσμους

Η βασική εικόνα εδώ είναι ότι θέλουμε να βρούμε το αρχείο.org στιγμιότυπα από *γύρω από την ώρα που γράφτηκε η ανάρτηση*. Μια φωτογραφία από το 2024 ενός άρθρου του 2006 μπορεί να αναφέρει εντελώς διαφορετικό περιεχόμενο. Έτσι, ψάχνουμε την ημερομηνία δημοσίευσης του blog post και να ζητήσουμε το αρχείο.org για το πλησιέστερο στιγμιότυπο.

Εδώ είναι η κεντρική δομή:

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

### Σύνδεσμοι εξαγωγής

Χρησιμοποιούμε ένα δημιουργημένο regex για να τραβήξει όλα `href` χαρακτηριστικά γνωρίσματα. `[GeneratedRegex]` χαρακτηριστικό στο .NET μας δίνει την κατάρτιση-χρόνου regex γενιά, η οποία είναι τόσο ταχύτερη και χωρίς κατανομή:

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

Τώρα θα μπορούσες να χρησιμοποιήσεις αρκετά λογικά. [HtmlAgilityPack](https://html-agility-pack.net/) / https://github.com/AngleSharp/AngleSharp ή παρόμοιο HTML (και άλλα) parsers εδώ για πιο περίπλοκα σενάρια, αλλά ήταν υπερβολή για το σκοπό αυτό - απλά πρέπει να εξάγουμε hrefs γρήγορα.

### Εγγραφή συνδέσμων για έλεγχο ιστορικού

Δεν θέλουμε να μπλοκάρουμε την απάντηση ενώ ελέγχουμε τους συνδέσμους.

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

Σημειώστε το `IServiceScopeFactory` - χρειαζόμαστε ένα νέο πεδίο, γιατί οι υπηρεσίες του αρχικού αιτήματος θα διατεθούν πριν ολοκληρωθεί το έργο μας.

**Σημαντικό**: Αυτό είναι ένα *ενδεχόμενη συνέπεια* Μοντέλο. *οποιαδήποτε* σύνδεσμος ανακαλύπτεται για πρώτη φορά, παίρνει ουρά για επικύρωση - δεν γνωρίζουμε αν έχει σπάσει ακόμα. Η υπηρεσία υποβάθρου τον ελέγχει (Αίτημα HEAD), και αν είναι σπασμένος, fitches ένα αρχείο.org αντικατάσταση. *Επόμενο* Για ένα blog με τακτική κίνηση, αυτό σημαίνει συνήθως σπασμένο σύνδεσμοι να καθορίζονται μέσα σε ώρες από την πρώτη ανακάλυψη. Η ομορφιά είναι ότι δεν χρειάζεται να μαντέψουμε ποιες συνδέσεις μπορεί να σπάσει - εμείς απλά τους επικυρώνουμε όλα αυτόματα.

### Αντικατάσταση σπασμένων δεσμών

Μόλις γνωρίζουμε ότι ένας σύνδεσμος είναι σπασμένος και να έχουν ένα αρχείο.org αντικατάσταση (από έναν προηγούμενο έλεγχο φόντου), τους ανταλλάσσουμε με ένα χρήσιμο εργαλείο:

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

Για εσωτερικούς σπασμένους συνδέσμους, προσπαθούμε σημασιολογική αναζήτηση πρώτα:

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

## Μέρος 2: Έλεγχος δεσμών υποβάθρου

Το μεσαίο λογισμικό δεν ελέγχει τους συνδέσμους κατά τη διάρκεια της αίτησης - ότι θα ήταν πάρα πολύ αργή. Αντ 'αυτού, οι ουρές ανακάλυψαν συνδέσμους για την επεξεργασία υποβάθρου μέσω ενός πίνακα βάσης δεδομένων, και ένα `BrokenLinkCheckerBackgroundService` χειρίζεται τον πραγματικό έλεγχο.

Η υπηρεσία τρέχει ωριαία και κάνει δύο πράγματα:

1. **Έλεγχος της εγκυρότητας των συνδέσμων** - HEAD ζητά να δούμε αν οι σύνδεσμοι είναι ακόμα ζωντανοί
2. **Φέρτε αρχεία.org URLs** - Για σπασμένους συνδέσμους, βρείτε το πλησιέστερο ιστορικό στιγμιότυπο

Είμαστε καλοί πολίτες σχετικά με αυτό - ο ελεγκτής χρησιμοποιεί ένα προσαρμοσμένο User-Agent που ταυτοποιείται και συνδέει πίσω στην ιστοσελίδα:

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

Αυτό επιτρέπει στους διαχειριστές του site να δουν τι χτυπάει τον διακομιστή τους, και μπορούν να μας ψάξουν αν είναι περίεργοι.

Κατά κύριο λόγο, οι σύνδεσμοι είναι **Επανεξεταζόμενος περιοδικά**. Ένας σύνδεσμος που δούλευε την περασμένη εβδομάδα μπορεί να είναι νεκρός σήμερα. Η υπηρεσία πιάνει συνδέσμους που δεν έχουν ελεγχθεί τις τελευταίες 24 ώρες και τους ελέγχει και πάλι. Ωστόσο, μόλις έχουμε βρει ένα αρχείο.org αντικατάσταση για ένα σπασμένο σύνδεσμο, δεν ξαναελέγχουμε το πρωτότυπο - είναι ήδη νεκρό και έχουμε μια αντικατάσταση εργασίας.

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

### The Archive.org CDX API

Archive.org παρέχει ένα CDX (Δείκτης Capture) API που μας επιτρέπει να αναρωτηθούμε για στιγμιότυπα.

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

Αυτό σημαίνει ότι αν έγραψα μια δημοσίευση το 2008 που συνδέεται με κάποιο πόρο, θα πάρω το αρχείο.org στιγμιότυπο από γύρω του 2008, όχι μια σύγχρονη έκδοση που θα μπορούσε να είναι εντελώς διαφορετική.

### Παρακολούθηση κατάστασης σύνδεσης

Εντοπίζουμε συνδέσμους με μια σωστή οντότητα:

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

### Περιοδικός έλεγχος

Το κλειδί για τη σύλληψη μελλοντικών διαλυμάτων είναι ο επανέλεγχος. Τα ερωτήματα για τις υπηρεσίες για συνδέσεις που δεν έχουν επικυρωθεί πρόσφατα:

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

Αυτό σημαίνει ότι κάθε σύνδεσμος επανισχύεται τουλάχιστον μία φορά την ημέρα. Αν ένας προηγούμενος σύνδεσμος εργασίας αρχίσει να επιστρέφει 404s, θα τον πιάσουμε και θα αρχίσουμε να ψάχνουμε για ένα αρχείο.org αντικατάσταση. `ConsecutiveFailures` πεδίο μας επιτρέπει να είναι λίγο συγχωρητικό - δεν σημειώνουμε ένα σύνδεσμο ως σπασμένο μετά από ένα παροδικό λάθος.

## Μέρος 3: Χειρισμός επερχόμενων αιτήσεων

Τώρα για την άλλη πλευρά του νομίσματος: οι άνθρωποι (και οι μηχανές αναζήτησης) ζητούν URL που δεν υπάρχουν.

- **Παλιά προγράμματα URL** - Το αρχαίο blog μου χρησιμοποιούσε μονοπάτια όπως `/archive/2006/05/15/123.aspx`. Μερικές από αυτές εξακολουθούν να αναφέρονται, σελιδοδείκτης, ή συνδέονται με άλλες ιστοσελίδες.
- **ΤύροςCity name (optional, probably does not need a translation)** - Κάποιος χοντρο-δάχτυλο το URL ή αντιγράφει λάθος.
- **Μερικοί γυμνοσάλιαγκες** - Οι μηχανές αναζήτησης μερικές φορές ευρετηριάζουν περίεργα κομμάτια.

Το σημασιολογικό σύστημα αναζήτησης είναι ιδιαίτερα χρήσιμο εδώ. Ακόμα και χωρίς έναν άμεσο αγώνα γυμνοσάλιαγκα, μπορούμε να εξάγουμε σημαντικούς όρους από το ζητούμενο URL και να βρούμε περιεχόμενο που ταιριάζει σημασιολογικά. Το σύστημα γνωρίζει τόσο το γυμνοσάλιαγκα όσο και τα δεδομένα για κάθε θέση, έτσι ώστε να μπορεί να βρει "κοντά" ταιριάζει ακόμα και για εντελώς διαφορετικές δομές URL.

### Γιατί Όχι στο Middleware;

Το πρώτο μου ένστικτο ήταν να χειριστώ τις ανακατευθύνσεις στο μεσαίο λογισμικό - αιτήματα αναχαίτισης νωρίς, να ελέγξω για γνωστές ανακατευθύνσεις, και να ανακατευθύνομαι προτού καν ξεκινήσει η δρομολόγηση. *θα μπορούσε* Δουλειά, και εδώ είναι πώς θα μοιάζει:

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

Το πρόβλημα; *κάθε αίτηση* έως `/blog/*`. Αυτό είναι ένα ερώτημα βάσης δεδομένων σε κάθε προβολή σελίδας, ακόμη και για τέλεια έγκυρη URLs. Για ένα blog με αξιοπρεπή κυκλοφορία, είστε σφυροκοπώντας τη βάση δεδομένων για κανένα καλό λόγο 99% του χρόνου.

Αν το ASP.NET έχει ήδη καθορίσει ότι η σελίδα δεν υπάρχει, *τότε* Δεν χάθηκαν ερωτήματα σε έγκυρες σελίδες.

> Διασκεδαστικό γεγονός: Στις πρώτες ημέρες του ASP.NET MVC αυτό ήταν το πώς κάναμε φιλικά URLs. Scott Guthrie demo αεροπλάνο που ξεκίνησε MVC χρησιμοποίησε αυτή την προσέγγιση; γάντζος στο σύστημα διαχείρισης IIS 404 , διαβάστε το URL και εξυπηρετούν το σωστό περιεχόμενο. Ήταν επίσης ό, τι χρησιμοποίησα σε ένα σύστημα που έχτισα στο WebForms 3 χρόνια διεισδυτικά ... και πώς πήρα μια συναυλία PM για την ομάδα ASP.NET!

### Το Σύστημα Μάθησης

Όταν κάποιος χτυπήσει ένα 404, τους δείχνουμε προτάσεις χρησιμοποιώντας ασαφή κορδόνι ταιριάζει και (προαιρετικά) σημασιολογική αναζήτηση. Αν κάνετε κλικ σε μια πρόταση, καταγράφουμε ότι. Μετά από αρκετά κλικ με επαρκή αυτοπεποίθηση, ξεκινάμε auto-directing.

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

### Ο 404 Χειριστής

Όλη η λογική ανακατευθύνσεων ζει στο `ErrorController`. ASP.NET's `UseStatusCodePagesWithReExecute` Το μεσαίο λογισμικό εκτελεί εκ νέου το αίτημα μέσω του χειριστή λάθους μας όταν συμβαίνει ένα 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");
        }
    }
}
```

Η `TryAutoRedirectAsync` Η μέθοδος χειρίζεται τόσο τις μαθημένες επανακατευθύνσεις όσο και τις πρωταγωνίστριες υψηλής εμπιστοσύνης:

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

### Ομοιότητα βαθμολογίας

Η υπηρεσία προτάσεων χρησιμοποιεί την απόσταση Levenshtein (dedit distance) συν κάποια ερειστική:

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

### Μαθαίνοντας από τη Συμπεριφορά του Χρήστη

Όταν ένας χρήστης κάνει κλικ σε μια πρόταση, την καταγράφουμε και ενημερώνουμε τα αποτελέσματα εμπιστοσύνης:

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

Ο υπολογισμός βαθμολογίας εμπιστοσύνης εξετάζει τόσο τα κλικ όσο και τις εντυπώσεις:

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

### Δύο-Tier Auto-Redirect

Έχουμε δύο επίπεδα αυτόματων ανακατευθύνσεων:

1. **Πρώτη φορά υψηλή αυτοπεποίθηση (302)**: Εάν η βαθμολογία ομοιότητας είναι < 0,85 και υπάρχει ένα σημαντικό κενό στο δεύτερο καλύτερο ταίριασμα, ανακατευθύνουμε αμέσως με ένα 302.

2. **Έμαθες ανακατευθύνσεις (301)**: Μετά από 5+ κλικ με 70%+ αυτοπεποίθηση, χρησιμοποιούμε μια μόνιμη 301 ανακατευθύνσεις.

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

## Συνδυάζοντας τα Όλα

### Όταν το Middleware κάνει αίσθηση (και όταν δεν το κάνει)

Για **εξερχόμενοι σύνδεσμοι**Το μεσαίο λογισμικό είναι η σωστή επιλογή. `BrokenLinkArchiveMiddleware` πρέπει να αναχαιτίσει την απόκριση HTML *μετά* Έχει αποδοθεί, αλλά *πριν* Δεν υπάρχει άλλο λογικό μέρος για να το κάνουμε αυτό - πρέπει να τροποποιήσουμε το ρεύμα απόκρισης, και το μεσαίο λογισμικό είναι σχεδιασμένο γι' αυτό ακριβώς.

Για **εισερχόμενες ανακατευθύνσεις**Το μεσαίο λογισμικό είναι λάθος επιλογή. `/blog/*` Η προσέγγιση 404 χειριστή τρέχει μόνο αυτή τη λογική όταν έχουμε ήδη καθορίσει ότι η σελίδα δεν υπάρχει - πολύ πιο αποτελεσματική.

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

Η ροή μοιάζει κάπως έτσι:

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

## Συμπέρασμα

Αυτό το σύστημα λειτουργεί εδώ και λίγο καιρό και είναι μάλλον ικανοποιητικό βλέποντας να μαθαίνει. Η βάση δεδομένων σταδιακά γεμίζει με ανακατευθύνσεις χαρτογραφήσεις, σπασμένους συνδέσμους παίρνει report.org αντικαταστάσεις, και οι χρήστες σπάνια βλέπουν ένα γυμνό 404 πια.

Οι βασικές αρχές:

- **Μην μπλοκάρεις τις αιτήσεις.** - να χρησιμοποιούν την επεξεργασία υποβάθρου για δαπανηρές εργασίες
- **Μάθετε από τους χρήστες** - τα κλικ τους είναι πολύτιμα σήματα
- **Να είστε συντηρητικοί με auto-directs** - μόνο όταν έχεις αυτοπεποίθηση
- **Time-aware archiving** - βρείτε το αρχείο.org στιγμιότυπα από τη στιγμή που το περιεχόμενο γράφτηκε
- **Ευχάριστη υποβάθμιση** - εάν οι υπηρεσίες δεν είναι διαθέσιμες, απλά συνεχίστε κανονικά

Δεν είναι τέλειο - κάποιες συνδέσεις έχουν πραγματικά χαθεί για πάντα, και σημασιολογική αναζήτηση δεν βρίσκει πάντα το σωστό αντικαταστάτη. Αλλά είναι ένα καταραμένο θέαμα καλύτερα από το να αφήσει χιλιάδες σπασμένα links ψέματα περίπου.

## Σύντομα: Σημασιολογική Αναζήτηση Βαθιά Κατάδυση

Αυτή την εβδομάδα (w/c 24 Νοεμβρίου 2025) Θα δημοσιεύσω τη σημασιολογική μου σειρά αναζήτησης η οποία πηγαίνει σε πολύ περισσότερες λεπτομέρειες σχετικά με το πώς λειτουργεί η διανυσματική αναζήτηση κάτω από την κουκούλα. `ISemanticSearchService.SearchAsync` Στην πραγματικότητα βρίσκει "παρόμοιο" περιεχόμενο, αυτό είναι όπου θα θέλετε να δείτε.

Η πλήρης πηγή βρίσκεται στο αποθετήριο αν θέλετε να κόψετε κάποιο από αυτό για τα δικά σας έργα.