Όταν κάνετε blogging από το 2004 (ναι, πραγματικά), συσσωρεύετε πολλά digital detritus. Πρόσφατα εισήγαγα τις παλιές μου δημοσιεύσεις από το 2004-2009 ( https://www.mostlyclearning.net/blog/category/imported) και ανακάλυψα ότι περίπου Τα πάντα. Εξωτερικοί σύνδεσμοι που δείχνουν τοποθεσίες που εξαφανίστηκαν πριν από μια δεκαετία, παλιά σχέδια URL που δεν ταιριάζουν πλέον με την τρέχουσα δομή, όλη η παρτίδα.
Το πρόβλημα διαλύεται σε τρία μέρη:
Εσωτερικοί σύνδεσμοι - Σταθερή κατά τη διάρκεια της ίδιας της διαδικασίας εισαγωγής με τη χρήση μου ArchiveOrg εργαλείο εισαγωγέα. Οι παλιές θέσεις μου αναφέρονται ο ένας στον άλλο χρησιμοποιώντας το παλιό URL σχέδιο, έτσι ξαναγράφω αυτές ως μέρος της μετανάστευσης.
Εξωτερικοί σύνδεσμοι (εξερχόμενοι) - Αυτό είναι το μεγάλο. Συνδέσεις με εξωτερικούς πόρους που από τότε έχουν εξαφανιστεί, μετακινηθεί, ή να γίνουν εντελώς διαφορετικές ιστοσελίδες. Ένας σύνδεσμος με κάποια τεκμηρίωση από το 2006; Gone. Μια αναφορά σε μια δημοσίευση blog από κάποιον που έχει καιρό μετά να πάρει το site τους κάτω; Νεκρός. Αυτά χρειάζονται χειρισμό runtime.
Εισερχόμενες αιτήσεις - Οι άνθρωποι (και οι μηχανές αναζήτησης) εξακολουθούν να προσπαθούν να έχουν πρόσβαση σε παλιά URL όπως /archive/2006/05/15/123.aspxΤο σημασιολογικό σύστημα αναζήτησης μπορεί συχνά να καταλάβει τι κυνηγούσαν, ακόμα και χωρίς ακριβή ταύτιση γυμνοσάλιαγκα.
Τώρα, εγώ θα μπορούσε έχουν ψήσει το αρχείο.org ψάχνουν στη διαδικασία εισαγωγής. Αλλά εδώ είναι το πράγμα: σύνδεσμοι σπάσει με το χρόνο. Ένα site που λειτουργεί σήμερα μπορεί να χαθεί τον επόμενο μήνα. μέλλον διαλείμματα, όχι μόνο αυτά που υπήρχαν την εποχή των εισαγωγών.
Το άρθρο αυτό καλύπτει την προσέγγισή μου:
BrokenLinkArchiveMiddleware - αντικαθιστά τους νεκρούς εξωτερικούς συνδέσμους με το αρχείο.org στιγμιότυπαΑυτό είναι το θέμα με το διαδίκτυο: δεν είναι μόνιμο. Αυτό το εξαιρετικό blog post που συνδέονται με το 2006; Gone. Αυτός ο ιστότοπος τεκμηρίωσης; Αναδομημένος τρεις φορές. δικό σας σχήμα URL από πριν εγκατασταθεί σε μια σωστή σύμβαση slugging; Ενοχλητικό.
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
Η αφελής προσέγγιση είναι να φτιάχνεις συνδέσμους χειροκίνητα, αλλά όταν έχεις εκατοντάδες θέσεις με χιλιάδες συνδέσμους, αυτό δεν ισχύει, χρειαζόμαστε αυτοματισμούς.
Το σύστημα διαθέτει δύο κύρια κατασκευαστικά στοιχεία που λειτουργούν από κοινού:
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
Αυτό το μεσαίο λογισμικό αναχαιτίζει τις απαντήσεις HTML και κάνει τρία πράγματα:
Η βασική εικόνα εδώ είναι ότι θέλουμε να βρούμε το αρχείο.org στιγμιότυπα από γύρω από την ώρα που γράφτηκε η ανάρτηση. Μια φωτογραφία από το 2024 ενός άρθρου του 2006 μπορεί να αναφέρει εντελώς διαφορετικό περιεχόμενο. Έτσι, ψάχνουμε την ημερομηνία δημοσίευσης του blog post και να ζητήσουμε το αρχείο.org για το πλησιέστερο στιγμιότυπο.
Εδώ είναι η κεντρική δομή:
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 γενιά, η οποία είναι τόσο ταχύτερη και χωρίς κατανομή:
[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://github.com/AngleSharp/AngleSharp ή παρόμοιο HTML (και άλλα) parsers εδώ για πιο περίπλοκα σενάρια, αλλά ήταν υπερβολή για το σκοπό αυτό - απλά πρέπει να εξάγουμε hrefs γρήγορα.
Δεν θέλουμε να μπλοκάρουμε την απάντηση ενώ ελέγχουμε τους συνδέσμους.
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 αντικατάσταση (από έναν προηγούμενο έλεγχο φόντου), τους ανταλλάσσουμε με ένα χρήσιμο εργαλείο:
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);
}
}
Για εσωτερικούς σπασμένους συνδέσμους, προσπαθούμε σημασιολογική αναζήτηση πρώτα:
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);
Το μεσαίο λογισμικό δεν ελέγχει τους συνδέσμους κατά τη διάρκεια της αίτησης - ότι θα ήταν πάρα πολύ αργή. Αντ 'αυτού, οι ουρές ανακάλυψαν συνδέσμους για την επεξεργασία υποβάθρου μέσω ενός πίνακα βάσης δεδομένων, και ένα BrokenLinkCheckerBackgroundService χειρίζεται τον πραγματικό έλεγχο.
Η υπηρεσία τρέχει ωριαία και κάνει δύο πράγματα:
Είμαστε καλοί πολίτες σχετικά με αυτό - ο ελεγκτής χρησιμοποιεί ένα προσαρμοσμένο User-Agent που ταυτοποιείται και συνδέει πίσω στην ιστοσελίδα:
request.Headers.UserAgent.ParseAdd(
"Mozilla/5.0 (compatible; MostlylucidBot/1.0; +https://www.mostlylucid.net)");
Αυτό επιτρέπει στους διαχειριστές του site να δουν τι χτυπάει τον διακομιστή τους, και μπορούν να μας ψάξουν αν είναι περίεργοι.
Κατά κύριο λόγο, οι σύνδεσμοι είναι Επανεξεταζόμενος περιοδικά. Ένας σύνδεσμος που δούλευε την περασμένη εβδομάδα μπορεί να είναι νεκρός σήμερα. Η υπηρεσία πιάνει συνδέσμους που δεν έχουν ελεγχθεί τις τελευταίες 24 ώρες και τους ελέγχει και πάλι. Ωστόσο, μόλις έχουμε βρει ένα αρχείο.org αντικατάσταση για ένα σπασμένο σύνδεσμο, δεν ξαναελέγχουμε το πρωτότυπο - είναι ήδη νεκρό και έχουμε μια αντικατάσταση εργασίας.
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
Archive.org παρέχει ένα CDX (Δείκτης Capture) API που μας επιτρέπει να αναρωτηθούμε για στιγμιότυπα.
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, όχι μια σύγχρονη έκδοση που θα μπορούσε να είναι εντελώς διαφορετική.
Εντοπίζουμε συνδέσμους με μια σωστή οντότητα:
[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
}
Το κλειδί για τη σύλληψη μελλοντικών διαλυμάτων είναι ο επανέλεγχος. Τα ερωτήματα για τις υπηρεσίες για συνδέσεις που δεν έχουν επικυρωθεί πρόσφατα:
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 πεδίο μας επιτρέπει να είναι λίγο συγχωρητικό - δεν σημειώνουμε ένα σύνδεσμο ως σπασμένο μετά από ένα παροδικό λάθος.
Τώρα για την άλλη πλευρά του νομίσματος: οι άνθρωποι (και οι μηχανές αναζήτησης) ζητούν URL που δεν υπάρχουν.
/archive/2006/05/15/123.aspx. Μερικές από αυτές εξακολουθούν να αναφέρονται, σελιδοδείκτης, ή συνδέονται με άλλες ιστοσελίδες.Το σημασιολογικό σύστημα αναζήτησης είναι ιδιαίτερα χρήσιμο εδώ. Ακόμα και χωρίς έναν άμεσο αγώνα γυμνοσάλιαγκα, μπορούμε να εξάγουμε σημαντικούς όρους από το ζητούμενο URL και να βρούμε περιεχόμενο που ταιριάζει σημασιολογικά. Το σύστημα γνωρίζει τόσο το γυμνοσάλιαγκα όσο και τα δεδομένα για κάθε θέση, έτσι ώστε να μπορεί να βρει "κοντά" ταιριάζει ακόμα και για εντελώς διαφορετικές δομές URL.
Το πρώτο μου ένστικτο ήταν να χειριστώ τις ανακατευθύνσεις στο μεσαίο λογισμικό - αιτήματα αναχαίτισης νωρίς, να ελέγξω για γνωστές ανακατευθύνσεις, και να ανακατευθύνομαι προτού καν ξεκινήσει η δρομολόγηση. θα μπορούσε Δουλειά, και εδώ είναι πώς θα μοιάζει:
// 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.
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 --> [*]
Όλη η λογική ανακατευθύνσεων ζει στο ErrorController. ASP.NET's UseStatusCodePagesWithReExecute Το μεσαίο λογισμικό εκτελεί εκ νέου το αίτημα μέσω του χειριστή λάθους μας όταν συμβαίνει ένα 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");
}
}
}
Η TryAutoRedirectAsync Η μέθοδος χειρίζεται τόσο τις μαθημένες επανακατευθύνσεις όσο και τις πρωταγωνίστριες υψηλής εμπιστοσύνης:
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) συν κάποια ερειστική:
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);
}
Όταν ένας χρήστης κάνει κλικ σε μια πρόταση, την καταγράφουμε και ενημερώνουμε τα αποτελέσματα εμπιστοσύνης:
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);
}
Ο υπολογισμός βαθμολογίας εμπιστοσύνης εξετάζει τόσο τα κλικ όσο και τις εντυπώσεις:
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;
}
}
Έχουμε δύο επίπεδα αυτόματων ανακατευθύνσεων:
Πρώτη φορά υψηλή αυτοπεποίθηση (302): Εάν η βαθμολογία ομοιότητας είναι < 0,85 και υπάρχει ένα σημαντικό κενό στο δεύτερο καλύτερο ταίριασμα, ανακατευθύνουμε αμέσως με ένα 302.
Έμαθες ανακατευθύνσεις (301): Μετά από 5+ κλικ με 70%+ αυτοπεποίθηση, χρησιμοποιούμε μια μόνιμη 301 ανακατευθύνσεις.
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
}
Για εξερχόμενοι σύνδεσμοιΤο μεσαίο λογισμικό είναι η σωστή επιλογή. BrokenLinkArchiveMiddleware πρέπει να αναχαιτίσει την απόκριση HTML μετά Έχει αποδοθεί, αλλά πριν Δεν υπάρχει άλλο λογικό μέρος για να το κάνουμε αυτό - πρέπει να τροποποιήσουμε το ρεύμα απόκρισης, και το μεσαίο λογισμικό είναι σχεδιασμένο γι' αυτό ακριβώς.
Για εισερχόμενες ανακατευθύνσειςΤο μεσαίο λογισμικό είναι λάθος επιλογή. /blog/* Η προσέγγιση 404 χειριστή τρέχει μόνο αυτή τη λογική όταν έχουμε ήδη καθορίσει ότι η σελίδα δεν υπάρχει - πολύ πιο αποτελεσματική.
// 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)
Η ροή μοιάζει κάπως έτσι:
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 πια.
Οι βασικές αρχές:
Δεν είναι τέλειο - κάποιες συνδέσεις έχουν πραγματικά χαθεί για πάντα, και σημασιολογική αναζήτηση δεν βρίσκει πάντα το σωστό αντικαταστάτη. Αλλά είναι ένα καταραμένο θέαμα καλύτερα από το να αφήσει χιλιάδες σπασμένα links ψέματα περίπου.
Αυτή την εβδομάδα (w/c 24 Νοεμβρίου 2025) Θα δημοσιεύσω τη σημασιολογική μου σειρά αναζήτησης η οποία πηγαίνει σε πολύ περισσότερες λεπτομέρειες σχετικά με το πώς λειτουργεί η διανυσματική αναζήτηση κάτω από την κουκούλα. ISemanticSearchService.SearchAsync Στην πραγματικότητα βρίσκει "παρόμοιο" περιεχόμενο, αυτό είναι όπου θα θέλετε να δείτε.
Η πλήρης πηγή βρίσκεται στο αποθετήριο αν θέλετε να κόψετε κάποιο από αυτό για τα δικά σας έργα.
© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.