Kriget om 404: Att få gamla saker att fungera igen (Svenska (Swedish))

Kriget om 404: Att få gamla saker att fungera igen

Sunday, 23 November 2025

//

18 minute read

Inledning

När du har bloggat sedan 2004 (ja, verkligen), du ackumulerar en hel del digital detritus. Jag nyligen importerat mina gamla inlägg från 2004-2009 ( https://www.mostlylucid.net/blog/kategorie/Imported) och upptäckte att ungefär allt Externa länkar som pekar på webbplatser som försvann för tio år sedan, gamla URL-system som inte längre matchar den nuvarande strukturen, allt.

Problemet delas upp i tre delar:

  1. Interna förbindelser - Fast under importprocessen själv med hjälp av min Verktyg för att importera arkivorgan. Mina gamla inlägg refererade till varandra med hjälp av det gamla URL-schemat, så jag skrev om dem som en del av migrationen.

  2. Externa länkar (utgående) - Detta är den stora. Länkar till externa resurser som sedan dess har försvunnit, flyttat, eller blivit helt olika webbplatser. En länk till viss dokumentation från 2006? Borta. En hänvisning till ett blogginlägg av någon som för länge sedan tagit ner sin webbplats? Dead. Dessa behöver runtime hantering.

  3. Inkommande begäran - Människor (och sökmotorer) fortfarande försöka komma åt gamla webbadresser som /archive/2006/05/15/123.aspx. Det semantiska söksystemet kan ofta räkna ut vad de var ute efter, även utan en exakt snigelmatch.

Nu, jag kunde har bakat arkivet.org uppslagningar i importprocessen. Men här är saken: länkar bryta över tid. En webbplats som fungerar idag kan vara borta nästa månad. Genom att hantera detta på gångtid med periodisk omkontroll, systemet automatiskt fångar Framtiden Brott, inte bara de som existerade vid importtidpunkten.

Den här artikeln behandlar min inställning:

  • Utgående länkar: BrokenLinkArchiveMiddleware - ersätter döda externa länkar med arkiv.org ögonblicksbilder
  • Inkommande länkar: Den 404 hanteraren med semantisk sökning - hittar rätt innehåll även från gamla URL-scheman
  • Lärandesystemet: Hur webbplatsen blir smartare med tiden från användaren klickar
  • Bakgrundsbehandling: Kontrollera länkar utan att blockera förfrågningar

Problemet med gammalt innehåll

Här är grejen med internet: det är inte permanent. Det utmärkta blogginlägg du länkade till i 2006? Borta. Den dokumentation webbplats? Omstrukturerad tre gånger. Din egen URL-schema från innan du bosatte dig på en riktig trögande konvention? Skäms.

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

Det naiva tillvägagångssättet är att fixa länkar manuellt, men när du har hundratals inlägg med tusentals länkar, det är inte på. Vi behöver automatisering.

Översikt över arkitekturen

Systemet har två huvudkomponenter som samverkar:

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

Del 1: Hantering av utgående länkar

Den trasigaLinkArchiveMiddleware

Denna middleware avlyssnar HTML-svar och gör tre saker:

  1. Extraherar alla länkar för bakgrundskontroll
  2. Ersätter kända brutna externa länkar med arkiv.org-versioner
  3. Använder semantisk sökning för att hitta ersättning för trasiga interna länkar

Nyckelinsikten här är att vi vill hitta arkiv.org ögonblicksbilder från ungefär när inlägget skrevs. En ögonblicksbild från 2024 i en artikel 2006 kan referera helt annat innehåll. Så vi slår upp blogginläggets publiceringsdatum och frågar archive.org om närmaste ögonblicksbild.

Här är kärnstrukturen:

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

Extrahera länkar

Vi använder en genererad regex för att dra ut alla href attribut. [GeneratedRegex] attribut i .NET ger oss kompilering-tid regex generering, vilket är både snabbare och allokering-fri:

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

Nu kan du ganska rimligt använda HtmlAgilityPack / https://github.com/AngleSharp/AngleSharp eller liknande HTML (och andra) tolkar här för mer komplexa scenarier men det var överkill för detta ändamål - vi behöver bara extrahera hrefs snabbt.

Registrera länkar för bakgrundskontroll

Vi vill inte blockera svaret när vi kontrollerar länkar. Istället, vi avfyrar en bakgrundsuppgift:

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

Lägg märke till IServiceScopeFactory - vi behöver ett nytt tillämpningsområde eftersom den ursprungliga begärans tjänster kommer att avyttras innan vår bakgrundsuppgift slutförs.

Viktigt: Detta är en Slutlig överensstämmelse modell. När någon av följande egenskaper: Länken upptäcks först, den köas för validering - vi vet inte om den är trasig ännu. Bakgrundstjänsten kontrollerar den (HEAD-förfrågan), och om den är trasig hämtar vi en ersättare för archive.org. nästa besökaren på den sidan kommer att se den fasta länken. För en blogg med vanlig trafik, innebär detta vanligtvis trasiga länkar blir fast inom timmar efter första upptäckten. Skönheten är att vi inte behöver gissa vilka länkar som kan brytas - vi bara validera dem alla automatiskt.

Byta ut trasiga länkar

När vi vet att en länk är trasig och har en archive.org ersättning (från en tidigare bakgrundskontroll), vi byta ut dem med en användbar verktygstips:

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

För interna trasiga länkar försöker vi semantisk sökning först:

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

Del 2: Bakgrundslänkkontroll

Den middleware inte kontrollera länkar under begäran - det skulle vara alldeles för långsamt. Istället köar upptäckta länkar för bakgrundsbehandling via en databas tabell, och en BrokenLinkCheckerBackgroundService hanterar den faktiska kontrollen.

Tjänsten går varje timme och gör två saker:

  1. Kontrollera länkens giltighet - HEAD begär att se om länkar fortfarande lever
  2. Hämta arkiv.org webbadresser - För trasiga länkar, hitta närmaste historiska ögonblicksbild

Vi är goda medborgare om detta - kontrollen använder en anpassad User-Agent som identifierar sig och länkar tillbaka till webbplatsen:

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

Detta låter webbplatsadministratörer se vad som träffar deras server, och de kan slå upp oss om de är nyfikna. Vi gasar också förfrågningar (2 sekunder mellan kontroller) för att undvika att hamra någons server.

Avgörande är att länkarna är Regelbunden förnyad kontroll. En länk som arbetade förra veckan kan vara död idag. Tjänsten plockar upp länkar som inte har kontrollerats under de senaste 24 timmarna och verifierar dem igen. Men, när vi har hittat en archive.org ersättning för en trasig länk, vi inte kontrollera om originalet - det är redan död och vi har en fungerande ersättare.

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

Arkivet.org CDX API

Archive.org tillhandahåller ett CDX (Capture Index) API som låter oss fråga efter ögonblicksbilder. Den smarta biten filtrerar efter datum:

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

Detta innebär att om jag skrev ett inlägg 2008 länka till någon resurs, Jag kommer att få arkivet.org ögonblicksbild från omkring 2008, inte en modern version som kan vara helt annorlunda.

Länkstatusspårning

Vi spårar länkar med en riktig enhet:

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

Regelbunden omkontroll

Nyckeln till att fånga framtida brott är att kontrollera igen. Servicefrågor för länkar som inte har validerats nyligen:

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

Detta innebär att varje länk blir omvaliderad minst en gång om dagen. Om en tidigare fungerande länk börjar returnera 404s, kommer vi att fånga den och börja leta efter en archive.org ersättning. ConsecutiveFailures fält låter oss vara lite förlåtande - vi markerar inte en länk som bruten efter ett övergående fel.

Del 3: Hantering av inkommande förfrågningar

Nu för den andra sidan av myntet: personer (och sökmotorer) som begär webbadresser som inte finns. Detta inkluderar:

  • Gamla webbadresser - Min gamla blogg använde stigar som /archive/2006/05/15/123.aspx. Vissa av dessa är fortfarande indexerade, bokmärken, eller länkade från andra webbplatser.
  • Typer - Någon fetfingerar webbadressen eller kopierar den fel.
  • Partiella sniglar - Sökmotorer indexerar ibland konstiga fragment.

Det semantiska söksystemet är särskilt användbart här. Även utan en direkt snigelmatch, kan vi extrahera meningsfulla termer från den begärda URL:en och hitta innehåll som semantiskt matchar. Systemet vet både snigel och har inbäddade data för varje inlägg, så att det kan hitta "nära nog" matchar även för helt olika URL-strukturer.

Varför inte Middleware?

Min första instinkt var att hantera omdirigeringar i middleware - avbryta förfrågningar tidigt, kolla efter kända omdirigeringar, och omdirigera innan routing även sparkar in. kunde Och så här skulle det se ut:

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

Problemet är att det här pågår. varje enskild begäran till /blog/*. Det är en databas fråga på varje sida vy, även för fullt giltiga webbadresser. För en blogg med anständig trafik, du hamrar databasen utan någon bra anledning 99% av tiden.

Det mycket bättre tillvägagångssättet: krok in i 404-hanteraren. Om ASP.NET redan har fastställt att sidan inte finns, och Vi kollar efter omdirigeringar. Inga bortkastade frågor på giltiga sidor.

Kul faktum: I början av ASP.NET MVC så här gjorde vi vänliga webbadresser fungerade. Scott Guthries plan demo som startade MVC använde detta tillvägagångssätt; krok in i IIS 404 hanteringssystem, läsa webbadressen och tjäna rätt innehåll. Det var också vad jag använde i ett system som jag byggde i WebForms 3 år pervious ... och hur jag fick en PM spelning på ASP.NET laget!

Lärarsystemet

När någon träffar en 404, visar vi dem förslag med fuzzy sträng matchning och (valfritt) semantisk sökning. Om de klickar på ett förslag, vi spela in det. Efter tillräckligt klick med tillräckligt förtroende, vi börjar auto-redirecting.

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

Den 404 Handler

All omdirigering logik bor i ErrorController. ASP.NET: s UseStatusCodePagesWithReExecute middleware kör om begäran genom vår felhanterare när en 404 inträffar:

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

och TryAutoRedirectAsync metod hanterar både inlärda omdirigeringar och högförtroende förstagångsmatcher:

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
}

Likhetspoäng

Förslaget tjänsten använder Levenshtein avstånd (redigera avstånd) plus några heuristik:

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

Att lära av användarens beteende

När en användare klickar på ett förslag registrerar vi det och uppdaterar konfidenspoängen:

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

Konfidenspoängberäkningen tar hänsyn till både klick och intryck:

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

Två-Tier Auto-Omdirigering

Vi har två nivåer av automatiska omdirigeringar:

  1. Högt självförtroende för första gången (302): Om likhetspoängen är >= 0.85 OCH det finns en betydande lucka till den näst bästa matchen, vi omdirigera omedelbart med en 302. Detta fångar uppenbara typsnitt.

  2. Lärda omdirigeringar (301): Efter 5+ klick med 70% + förtroende, använder vi en permanent 301 omdirigera. Detta är "människorna har talat" fall.

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
}

Att sätta ihop allt detta

När Middleware gör känsla (och när det inte gör det)

För utgående länkar, middleware är rätt val. BrokenLinkArchiveMiddleware måste stoppa HTML- svaret efter Det har blivit renderat men före Det finns inget annat vettigt ställe att göra det på.

För Inkommande omdirigeringar, middleware är fel val . Vi skulle köra databasfrågor på varje /blog/* begära bara att kontrollera om kanske, möjligen, den här webbadressen kan behöva en omdirigering. 404-hanteringsmetoden kör bara den logiken när vi redan har fastställt att sidan inte finns - mycket effektivare.

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

Flödet ser ut så här:

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

Slutsatser

Detta system har varit igång ett tag nu och det är ganska tillfredsställande att se det lära sig. Databasen fylls gradvis upp med omdirigera kartläggningar, trasiga länkar få archive.org ersättare, och användare sällan se en naken 404 längre.

De viktigaste principerna:

  • Blockera inte förfrågningar - använda bakgrundsbearbetning för dyra operationer
  • Lär dig av användarna - deras klick är värdefulla signaler
  • Var konservativ med auto-redirects - bara omdirigera när du är säker
  • Tidsmedveten arkivering - hitta arkiv.org ögonblicksbilder från när innehållet skrevs
  • Nådfull förnedring - Om tjänsterna inte är tillgängliga, fortsätt som vanligt.

Det är inte perfekt - vissa länkar är verkligen borta för alltid, och semantisk sökning hittar inte alltid rätt ersättare. Men det är en jäkla syn bättre än att lämna tusentals trasiga länkar som ligger om.

Snart: Semantisk sökning Djupdykning

Denna vecka (w/c 24 November 2025) kommer jag att publicera min semantiska sökserie som går in mycket mer i detalj om hur vektorbaserad sökning fungerar under huven. Den serien kommer att täcka inbäddning, Qdrant vektorlagring, och hur allt hänger ihop med detta 404 hanteringssystem. Om du är nyfiken på hur ISemanticSearchService.SearchAsync Hittar faktiskt "liknande" innehåll, det är där du vill titta.

Den kompletta källan finns i arkivet om du vill stjäla något av det för dina egna projekt.

Finding related posts...
logo

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