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
我最近从2004-2009年进口了我的旧文章(https://www. mostlylulucid.net/blog/blog/crime/imported), 一切一切 外部链接指向十年前消失的网站 旧的URL计划 不再符合目前的结构 整个项目
问题分为三部分:
内部链接 - 在进口过程中用我的 归档导入工具。我的旧文章使用旧的 URL 计划互相参照,所以我重写了这些文章,作为移民的一部分。
外部链接(持续) 与外部资源的联系,这些外部资源已经消失、移动或变得完全不同。 链接到2006年的一些文件。 消失。 引用早已关闭网站的人的博客文章? 死亡。 这些需要时间处理 。
收到的请求 - 人们(和搜索引擎)仍然试图访问旧的 URL,例如 /archive/2006/05/15/123.aspx。语义搜索系统往往可以找出他们想要的是什么,即使没有精确的子弹匹配。
现在,我 能够 org 已烤烤了归档, org 查看了导入过程 。 但问题是: 链接会随着时间的流逝而断断 。 今天工作的站点下个月可能会消失 。 如果在运行时通过定期重新检查处理, 系统会自动抓取 。 未来 断裂,不只是进口时存在的断裂。
本条涵盖我的做法:
BrokenLinkArchiveMiddleware - 以档案.org快照取代死外部链接这是关于互联网的一点: 这不是永久的。 您在2006年链接到的极好的博客文章? 消失。 该文件站点? 重组了三次。 您自己的 URL 计划在您决定正式的打击大会之前就已经解决了? 丢脸了 。
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年的一篇文章, 可能引用的内容完全不同。所以我们查查博客文章的出版日期,
以下是核心结构:
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);
}
}
}
我们用生成的正gex 来拉出全部 href 属性。 [GeneratedRegex] 在.NET 的属性中, 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();
}
现在你可以相当合理地使用 Html 灵活性纸包 https://github.com/AngleSharp/AngleSharp/ AngleSharp 或类似的 HTML (和其他) 解析器, 用于更复杂的场景,
我们不想在检查链接时阻断响应。 相反,我们取消了一个背景任务:
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 请求) , 如果它坏了, 将获取一个归档. org 替换 。 下一个 访问该页面的人会看到固定的链接。 对于一个有定期流量的博客来说,这通常意味着断开的链接会在第一次发现后数小时内被固定下来。 美丽的是,我们不必猜测哪些链接会被打破 — — 我们只是自动验证这些链接。
一旦我们知道一个链接被打破, 并有一个归档. 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 处理实际检查。
服务每小时运行,做两件事:
检查员使用自定义用户代理, 识别自己, 并连接到网站:
request.Headers.UserAgent.ParseAdd(
"Mozilla/5.0 (compatible; MostlylucidBot/1.0; +https://www.mostlylucid.net)");
这让站点管理员看到他们的服务器受到什么打击, 如果他们好奇的话,他们可以找我们。 我们也要求加速速度, 以避免敲敲敲任何人的服务器。
关键的是,链接是 定期重新检查。 上周使用的链接今天可能已绝迹。 服务会接收过去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
org 提供了一个 CDX (Capture Invice) 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年写了一篇文章, 连结到某些资源, 我会得到2008年左右的档案. org快照, 而不是一个可能完全不同的现代版本。
我们追踪与适当实体的联系:
[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/*。这是每个页面视图的数据库查询,即使对完全有效的 URL 来说也是如此。对于一个交通顺畅的博客来说,你毫无理由地在99%的时间里锁定数据库。
更好的办法:连接到404个处理器中。如果 ASP.NET 已经确定页面不存在, 时当时 我们检查重定向。在有效页面上不要浪费查询。
有趣的是:在ASP.NET MVC的早期,我们就是这样建立友好的 URL的。启动MVC的Scott Guthrie的平面演示使用了这个方法;连接到 IIS 404 操作系统,读读了 URL 并服务了正确的内容。这也是我在WebForms 3 年中建立的一个系统中所使用的方法...以及我是如何在 ASP.NET 团队中找到一个PM的工作!
当有人点击404时,我们使用模糊的字符串匹配和(可选的)语义搜索向他们显示建议。如果他们点击一个建议,我们就记录下来。在足够自信地点击足够多的点击后,我们就开始自动调整方向。
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 --> [*]
所有改变逻辑的逻辑 生命在 ErrorControllerASP. NET 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距离(编辑距离)加上一些超自然学:
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):在以 70 + 表示信任的 5+ 点击后,我们使用永久的 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/* 请求仅检查这个 URL 是否可能需要重定向 。 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
这个系统已经运行了一段时间,它相当满意地看着它学习。数据库逐渐填满了重新定位的地图,断断的链接得到了归档.org的替换,用户也很少看到一个裸体的404人了。
关键原则:
它不是完美的 - 有些链接真的永远消失了, 语义搜索并不总是能找到正确的替代。 但是这比留下数千条断断的链接更好。
本周(w/c 2025年11月24日)我将出版我的语义搜索系列(w/c 2025年11月24日),该系列将更详细地介绍基于矢量的搜索如何在引擎盖下运作。该系列将涵盖嵌入的生成、 Qdrant 矢量存储以及它与这404个操作系统是如何连接的。如果您想知道如何 ISemanticSearchService.SearchAsync 实际上找到“相似”的内容, 那就是你想找的地方。
完整的源在存储库中, 如果您想为您自己的工程窃取其中的任何内容 。
© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.