This is a viewer only at the moment see the article on how this works.
To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk
This is a preview from the server running through my markdig pipeline
Sunday, 23 November 2025
Wanneer je sinds 2004 blogt (ja, echt), stapel je veel digitale detritus op. Ik importeerde onlangs mijn oude berichten uit 2004-2009 ( https://www.mostlylucid.net/blog/category/Imported) en ontdekte dat ongeveer alles Externe links die wijzen naar sites die tien jaar geleden verdwenen, oude URL-schema's die niet meer overeenkomen met de huidige structuur, de hele partij.
Het probleem bestaat uit drie delen:
Interne links - Gefixt tijdens het importproces zelf met behulp van mijn ArchiveOrg importeerhulpmiddel. Mijn oude berichten refereerden elkaar met behulp van de oude URL-schema, dus ik herschreef die als onderdeel van de migratie.
Externe links (uitlopend) - Dit is de grote. Links naar externe bronnen die sindsdien zijn verdwenen, verplaatst, of volledig verschillende sites geworden. Een link naar een aantal documentatie uit 2006? Verdwenen. Een verwijzing naar een blogpost door iemand die al lang geleden hun site heeft neergehaald? Dood. Deze moeten runtime behandeling.
Inkomende verzoeken - Mensen (en zoekmachines) proberen nog steeds toegang te krijgen tot oude URL's zoals /archive/2006/05/15/123.aspx. Het semantische zoeksysteem kan vaak uitzoeken wat ze zochten, zelfs zonder een exacte kogelmatch.
Nu, ik kan hebben gebakken het archief.org lookups in het importproces. Maar hier is het ding: links breken in de tijd. Een site die werkt vandaag zou kunnen zijn verdwenen volgende maand. Door het omgaan met deze op runtime met periodieke hercontrole, het systeem automatisch vangt toekomst breuken, niet alleen degenen die bestonden op het moment van invoer.
Dit artikel behandelt mijn aanpak:
BrokenLinkArchiveMiddleware - vervangt dode externe links met archive.org snapshotsHier is het ding over het internet: het is niet permanent. Die uitstekende blog post u gekoppeld aan in 2006? Weg. Die documentatie site? Geherstructureerd drie keer. Uw eigen URL-schema van voordat u zich settelde op een juiste slugging conventie? Beschamend.
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
De naïeve benadering is om links handmatig te repareren. Maar als je honderden berichten hebt met duizenden links, dan is dat niet aan. We hebben automatisering nodig.
Het systeem heeft twee hoofdcomponenten die in tandem werken:
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
Deze middleware onderschept HTML reacties en doet drie dingen:
Het belangrijkste inzicht hier is dat we archive.org snapshots willen vinden van rond de tijd dat de post werd geschreven. Een snapshot uit 2024 van een 2006 artikel kan verwijzen naar volledig andere inhoud. Dus we zoeken op de blog post's publiceren datum en vragen archief.org voor de dichtstbijzijnde snapshot.
Hier is de kernstructuur:
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);
}
}
}
We gebruiken een gegenereerde regex om alles eruit te halen. href attributen. De [GeneratedRegex] attribuut in .NET geeft ons compile-time regex generatie, die zowel sneller als allocatie-vrij is:
[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 kun je heel redelijk gebruiken HtmlAgilityPack / https://github.com/AngleSharp/AngleSharp of soortgelijke HTML (en andere) parsers hier voor meer complexe scenario's, maar het was overkill voor dit doel - we hoeven alleen maar hrefs snel extraheren.
We willen het antwoord niet blokkeren tijdens het controleren van links. In plaats daarvan vuren we een achtergrondtaak af:
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);
});
}
Let op de IServiceScopeFactory - we hebben een nieuw toepassingsgebied nodig omdat de oorspronkelijke diensten van het verzoek zullen worden verwijderd voordat onze achtergrondtaak is voltooid.
Belangrijk: Dit is een uiteindelijke consistentie model. Wanneer alle link wordt eerst ontdekt, het wordt in de wachtrij voor validatie - we weten niet of het is gebroken nog. De background service controleert het (HEAD verzoek), en als het is gebroken, haalt een archief.org vervanging. volgende bezoeker van die pagina zal de vaste link te zien. Voor een blog met regelmatig verkeer, dit betekent meestal gebroken links worden vastgesteld binnen uren na de eerste ontdekking. De schoonheid is dat we niet hoeven te raden welke links kunnen worden verbroken - we gewoon valideren ze allemaal automatisch.
Zodra we weten dat een link is gebroken en hebben een archive.org vervanging (van een vorige achtergrond controle), wisselen we ze uit met een behulpzame tooltip:
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);
}
}
Voor interne verbroken links proberen we eerst semantische zoekopdrachten:
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);
De middleware controleert geen links tijdens het verzoek - dat zou veel te traag zijn. In plaats daarvan, het wachtrij ontdekte links voor achtergrondverwerking via een database tabel, en een BrokenLinkCheckerBackgroundService Behandelt de werkelijke controle.
De service loopt per uur en doet twee dingen:
We zijn goede burgers over dit - de checker maakt gebruik van een aangepaste User-Agent die zich identificeert en links terug naar de site:
request.Headers.UserAgent.ParseAdd(
"Mozilla/5.0 (compatible; MostlylucidBot/1.0; +https://www.mostlylucid.net)");
Dit laat site admins zien wat er op hun server, en ze kunnen ons opzoeken als ze nieuwsgierig zijn. We ook gas vragen (2 seconden tussen controles) om te voorkomen dat hamer op iemands server.
Cruciaal, links zijn periodiek opnieuw gecontroleerd. Een link die vorige week werkte zou dood kunnen zijn vandaag. De service picks links die niet zijn gecontroleerd in de afgelopen 24 uur en controleren ze opnieuw. Echter, zodra we hebben gevonden een archive.org vervanging voor een gebroken link, we niet opnieuw controleren het origineel - het is al dood en we hebben een werkende vervanging.
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 levert een CDX (Capture Index) API waarmee we snapshots kunnen opvragen. Het slimme bit filtert op 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}";
}
Dit betekent dat als ik schreef een bericht in 2008 linken naar een bron, Ik krijg de archive.org snapshot uit rond 2008, niet een moderne versie die volledig anders zou kunnen zijn.
We volgen links met een juiste entiteit:
[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
}
De sleutel tot het vangen van toekomstige breuken is opnieuw controleren. De service queries voor links die niet onlangs zijn gevalideerd:
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);
}
Dit betekent dat elke link minstens eenmaal per dag opnieuw wordt gevalideerd. Als een eerder werkende link 404s gaat retourneren, zullen we deze vangen en beginnen met zoeken naar een archief.org vervanger. ConsecutiveFailures veld laat ons een beetje vergevingsgezind zijn - we markeren geen link als gebroken na één voorbijgaande fout.
Nu voor de andere kant van de munt: mensen (en zoekmachines) vragen om URL's die niet bestaan. Dit omvat:
/archive/2006/05/15/123.aspx. Sommige van deze zijn nog steeds geïndexeerd, bladwijzers, of gekoppeld van andere sites.Het semantische zoeksysteem is hier bijzonder nuttig. Zelfs zonder een directe navelmatch kunnen we betekenisvolle termen uit de gevraagde URL halen en inhoud vinden die semantisch overeenkomt. Het systeem kent zowel de navel als heeft inbeddingsgegevens voor elke post, zodat het "dicht genoeg" overeenkomsten kan vinden, zelfs voor volledig verschillende URL-structuren.
Mijn eerste instinct was om omleidingen te behandelen in middleware - onderscheppen verzoeken vroeg, controleren op bekende omleidingen, en omleiden voordat de routing zelfs begint. kan werk, en hier is hoe het eruit zou zien:
// 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);
}
}
Het probleem is elk afzonderlijk verzoek tot /blog/*. Dat is een database query op elke paginaweergave, zelfs voor perfect geldige URL's. Voor een blog met fatsoenlijk verkeer, je hamert de database zonder goede reden 99% van de tijd.
De veel betere aanpak: haak in de 404 handler. Als ASP.NET al heeft bepaald dat de pagina niet bestaat, dan we controleren op omleidingen. Geen verspilde vragen op geldige pagina's.
Leuk feit: In de vroege dagen van ASP.NET MVC was dit hoe we vriendelijke URL's werkten. Scott Guthrie's vliegtuig demo die begon MVC gebruikte deze aanpak; haak in het IIS 404 handling systeem, lees de URL en serveer de juiste inhoud. Het was OOK wat ik gebruikt in een systeem dat ik gebouwd in WebForms 3 jaar pervers...en hoe ik kreeg een PM gig in het ASP.NET team!
Wanneer iemand een 404 raakt, laten we ze suggesties zien met behulp van fuzzy string matching en (optioneel) semantische zoekopdracht. Als ze op een suggestie klikken, nemen we dat op. Na voldoende klikken met voldoende vertrouwen, beginnen we met 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 --> [*]
Alle omleidingslogica leeft in de ErrorControllerASP.NET's UseStatusCodePagesWithReExecute middleware re-executeert het verzoek via onze foutafhandeling wanneer een 404 optreedt:
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");
}
}
}
De TryAutoRedirectAsync methode behandelt zowel geleerde omleidingen en high-confidence first-time wedstrijden:
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
}
De suggestie service maakt gebruik van Levenshtein afstand (edit afstand) plus een aantal heuristiek:
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);
}
Wanneer een gebruiker op een suggestie klikt, nemen we het op en updaten we vertrouwensscores:
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);
}
De berekening van de betrouwbaarheidsscore houdt rekening met zowel klikken als indrukken:
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;
}
}
We hebben twee niveaus van automatische omleidingen:
Eerste keer hoog vertrouwen (302): Als de overeenkomst score is > 0.85 EN er is een significante kloof naar de tweede-beste match, we omleiden onmiddellijk met een 302. Dit vangen voor de hand liggende typefouten.
Learned redirects (301): Na 5+ klikken met 70% meer vertrouwen, gebruiken we een permanente 301 redirect. Dit is de "de mensen hebben gesproken" zaak.
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
}
Voor uitgaande links, middleware is de juiste keuze. BrokenLinkArchiveMiddleware moet de HTML-respons onderscheppen na Het is gerenderd, maar voor Er is geen andere verstandige plek om dit te doen - we moeten de responsstroom aanpassen, en middleware is daarvoor ontworpen.
Voor binnenkomende omleidingen, middleware is de verkeerde keuze . We zouden draaien database queries op elke /blog/* vraag alleen om te controleren of deze URL mogelijk een redirect nodig heeft. De 404 handler benadering draait alleen die logica als we al hebben vastgesteld dat de pagina niet bestaat - veel efficiënter.
// 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)
De stroom ziet er zo uit:
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
Dit systeem draait al een tijdje en het is nogal bevredigend om te kijken hoe het leert. De database vult zich geleidelijk aan met redirect mappings, gebroken links krijgen archive.org vervangingen, en gebruikers zien zelden een naakte 404 meer.
De belangrijkste beginselen:
Het is niet perfect - sommige links zijn echt voor altijd verdwenen, en semantische zoektocht vindt niet altijd de juiste vervanging. Maar het is een verdomd gezicht beter dan het verlaten van duizenden gebroken links liegen over.
Deze week (w/c 24 november 2025) zal ik mijn semantische zoekreeks publiceren die veel meer in detail gaat over hoe de vector-gebaseerde zoekopdracht werkt onder de kap. Die serie zal betrekking hebben op embedding generatie, Qdrant vector opslag, en hoe het allemaal verbindt met dit 404 handling systeem. Als je nieuwsgierig bent naar hoe ISemanticSearchService.SearchAsync Vindt eigenlijk "gelijkaardige" inhoud, dat is waar je wilt kijken.
De volledige bron bevindt zich in de repository als je er iets van wilt picken voor je eigen projecten.
© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.