# Війна на 404 р.: Зміна старих речей

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

## Вступ

Після ведення блогу з 2004 року (так, насправді), ви накопичили багато цифрового detritus. Нещодавно я імпортував свої старі дописи з 2004- 2009 ( https: // www. method. net/ blog/ category/ Imported) і виявив, що приблизно це *все* Зовнішні посилання, що вказують на місця, які зникли десять років тому, старі схеми URL, які більше не відповідають поточній структурі, всьому місту.

Проблема розпадається на три частини:

1. **Внутрішні посилання** - Фіксовано під час процесу імпортування за допомогою my [Інструмент імпортування ArchiveOrg](https://github.com/scottgal/mostlylucid.nugetpackages/tree/main/Mostlylucid.ArchiveOrg)Мої старі дописи посилаються один на одного, використовуючи стару схему URL, тому я переписую її як частину міграцій.

2. **Зовнішні посилання (відсутній)** - Це велике. Зв' язок з зовнішніми ресурсами, які вже зникли, пересунуто або стають зовсім іншими сайтами. Посилання на документацію з 2006 року завершено. Посилання на допис блогу від когось, хто вже давно знешкодив свій сайт? Мертвий. Для роботи з цим сайтом потрібна робота у режимі запуску.

3. **Вхідні запити** - Люди (і пошукові рушії) все ще намагаються отримати доступ до старих адрес URL на зразок `/archive/2006/05/15/123.aspx`Система семантичного пошуку часто може з'ясувати, що їм потрібно, навіть без точного збігу зі слимаком.

Тепер, я *може* було випалено для процесу імпортування архів. org. Але ось ще одна річ: зв' язки розриваються з часом. Можливо, наступного місяця не буде сайта, який працює сьогодні. Керуючи цим під час періодичної перевірки, система автоматично ловить *майбутні* Розриви, не тільки ті, які існували в часі імпортування.

У цій статті описано мій підхід:

- **Вихідні посилання**: `BrokenLinkArchiveMiddleware` - заміняє мертві зовнішні посилання на знімки archive.org
- **Вхідні посилання**: Обробник 404 з семантичним пошуком знайде правильний вміст навіть зі старих схем URL
- **Система навчання**: спосіб, у який сайт стає розумнішим з часом від натискання користувачем
- **Обробка тла**: Перевірка посилань без блокування запитів

[TOC]

## Проблема зі старим вмістом

Ось дещо про інтернет: він не є постійною. Цей чудовий допис блогу, з яким ви пов' язалися у 2006 році? Його було пропущено. Цей сайт документації? Повторно створено три рази. Ваша власна схема URL, перш ніж ви встановитеся на відповідній з' єднаній програмі? Виявляння інформації.

```mermaid
graph TD
    A[User requests old post] --> B{Link valid?}
    B -->|Yes| C[Happy user]
    B -->|No| D[404 Error]
    D --> E[Frustrated user]
    E --> F[User leaves]

    style D stroke:#ef4444,stroke-width:3px
    style F stroke:#ef4444,stroke-width:3px
    style C stroke:#10b981,stroke-width:3px
```

Наївний підхід полягає в тому, щоб зафіксувати посилання вручну, але коли у вас є сотні дописів з тисячами посилань, їх немає.

## Огляд архітектури

У системі два основних компоненти працюють у тандемі:

```mermaid
flowchart TB
    subgraph Incoming["Incoming Requests"]
        A[User Request] --> B{Page exists?}
        B -->|Yes| C[Render Page]
        B -->|No| D[404 Handler]
        D --> E{Learned redirect?}
        E -->|Yes| F[301 Permanent Redirect]
        E -->|No| G{High-confidence match?}
        G -->|Yes| H[302 Temporary Redirect]
        G -->|No| I[Show suggestions]
        I --> J[User clicks suggestion]
        J --> K[Learn redirect]
    end

    subgraph Outgoing["Outgoing Links"]
        C --> L[BrokenLinkArchiveMiddleware]
        L --> M[Extract all links]
        M --> N[Register for checking]
        L --> O[Replace broken links]
        O --> P[Archive.org URLs]
        O --> Q[Semantic search results]
        O --> R[Remove dead links]
    end

    style F stroke:#10b981,stroke-width:3px
    style H stroke:#f59e0b,stroke-width:3px
    style P stroke:#3b82f6,stroke-width:3px
    style Q stroke:#8b5cf6,stroke-width:3px
```

## Частина 1. Робота з вихідними посиланнями

### Пошкоджене програмне забезпечення LinkArchive

Ця центральна програма перехоплює HTML- відповіді і виконує три дії:

1. Видобути всі посилання для фонової перевірки
2. Заміняє відомі пошкоджені зовнішні посилання версіями archive.org
3. Застосовує семантичний пошук для пошуку замін для поламаних внутрішніх посилань

Ключовим є те, що ми хочемо знайти знімки архіву.org з *десь під час написання допису*Сценарій з 2024 статті 2006 року може посилатися на зовсім інший вміст. Отже, ми відшукаємо дату оприлюднення блогу і попросимо на найближчий знімок archive.org.

Ось основна структура:

```csharp
public partial class BrokenLinkArchiveMiddleware(
    RequestDelegate next,
    ILogger<BrokenLinkArchiveMiddleware> logger,
    IServiceScopeFactory serviceScopeFactory)
{
    public async Task InvokeAsync(
        HttpContext context,
        IBrokenLinkService? brokenLinkService,
        ISemanticSearchService? semanticSearchService)
    {
        // Only process HTML responses for blog pages
        if (!ShouldProcessRequest(context))
        {
            await next(context);
            return;
        }

        // Capture the response so we can modify it
        var originalBodyStream = context.Response.Body;
        using var responseBody = new MemoryStream();
        context.Response.Body = responseBody;

        await next(context);

        // Process the HTML response
        if (IsSuccessfulHtmlResponse(context, responseBody))
        {
            var html = await ReadResponseAsync(responseBody);
            html = await ProcessLinksAsync(html, context, brokenLinkService, semanticSearchService);
            await WriteModifiedResponseAsync(originalBodyStream, html, context);
        }
        else
        {
            await CopyOriginalResponseAsync(responseBody, originalBodyStream);
        }
    }
}
```

### Видирання посилань

Ми використовуємо створений regex для витягання всіх `href` Атрибути. The `[GeneratedRegex]` attribute у . NET надає нам створення компіляції- часу формального виразу, який є швидшим і вільним від пов' язування:

```csharp
[GeneratedRegex(@"<a[^>]*\shref\s*=\s*[""']([^""']+)[""'][^>]*>",
    RegexOptions.IgnoreCase | RegexOptions.Compiled)]
private static partial Regex HrefRegex();

private List<string> ExtractAllLinks(string html, HttpRequest request)
{
    var links = new List<string>();
    var matches = HrefRegex().Matches(html);

    foreach (Match match in matches)
    {
        var href = match.Groups[1].Value;

        // Skip special links (anchors, mailto, etc.)
        if (SkipPatterns.Any(p => href.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
            continue;

        if (Uri.TryCreate(href, UriKind.Absolute, out var uri))
        {
            if (uri.Scheme == "http" || uri.Scheme == "https")
                links.Add(href);
        }
        else if (href.StartsWith("/"))
        {
            // Convert relative URLs to absolute for tracking
            var baseUri = new UriBuilder(request.Scheme, request.Host.Host,
                request.Host.Port ?? (request.Scheme == "https" ? 443 : 80));
            links.Add(new Uri(baseUri.Uri, href).ToString());
        }
    }

    return links.Distinct().ToList();
}
```

Тепер ви можете досить помірковано використовувати [HtmlAglibibilityPack](https://html-agility-pack.net/) / https: // gitub.com/AngleSap/ AngleSap або подібні HTML (та інші) аналізери у цьому вікні для складніших сценаріїв, але з цією метою вона була перевершена - нам просто потрібно швидко видобути hrafs.

### Зареєстрування посилань для перевірки тла

Ми не хочемо блокувати відповідь під час перевірки посилань. Замість цього, ми випускаємо фонове завдання:

```csharp
var allLinks = ExtractAllLinks(html, context.Request);
var sourcePageUrl = context.Request.Path.Value;

if (allLinks.Count > 0)
{
    // Fire and forget - don't block the response
    _ = Task.Run(async () =>
    {
        using var scope = serviceScopeFactory.CreateScope();
        var scopedService = scope.ServiceProvider.GetRequiredService<IBrokenLinkService>();
        await scopedService.RegisterUrlsAsync(allLinks, sourcePageUrl);
    });
}
```

Зауважте `IServiceScopeFactory` - нам потрібна нова сфера, тому що обчислювальні послуги нашого запиту будуть відмінені до завершення нашого фонового завдання.

**Важливе**: Це *Остаточна послідовність* Зразок. *any* link спочатку виявлено, його буде поставлено до черги для перевірки - ми ще не знаємо, чи не пошкоджено. Служба тла перевіряє його (попит HEAD), і, якщо її пошкоджено, отримує заміну архіву. org. *next* Відвідувач цієї сторінки побачить фіксоване посилання. Для блогу зі звичайним трафіком це зазвичай означає, що поламані посилання будуть фіксовані протягом декількох годин після першого відкриття. Краса полягає в тому, що нам не потрібно вгадувати, які посилання можуть бути зламані, - ми просто підтверджуємо їх автоматично.

### Заміна пошкоджених посилань

Після того, як ми знаємо, що посилання було пошкоджено і встановлено заміну archive.org (з попередньої перевірки на тлі), ми перемкнемося на нього з корисною підказкою:

```csharp
foreach (var (originalUrl, archiveUrl) in archiveMappings)
{
    if (html.Contains(originalUrl))
    {
        var tooltipText = $"Original link ({originalUrl}) is dead - archive.org version used";
        var originalPattern = $"href=\"{originalUrl}\"";
        var archivePattern = $"href=\"{archiveUrl}\" class=\"tooltip tooltip-warning\" " +
                            $"data-tip=\"{tooltipText}\" data-original-url=\"{originalUrl}\"";

        html = html.Replace(originalPattern, archivePattern);
    }
}
```

Для внутрішніх поламаних посилань спочатку спробуйте семантичний пошук:

```csharp
if (isInternal && semanticSearchService != null)
{
    var replacement = await TryFindSemanticReplacementAsync(
        brokenUrl, semanticSearchService, request, cancellationToken);

    if (replacement != null)
    {
        html = ReplaceHref(html, brokenUrl, replacement);
        continue;
    }
}

// No replacement found - convert to plain text
html = RemoveHref(html, brokenUrl);
```

## Частина 2: перевірка тла посилання

Середня програма не перевіряє посилання під час запиту - це надто повільно. Замість цього у черзі буде знайдено посилання для обробки тла за допомогою таблиці бази даних, а також таблиці бази даних. `BrokenLinkCheckerBackgroundService` працює з поточною перевіркою.

Служба працює щогодини і виконує дві функції:

1. **Перевірити посилання коректно** - Надійшов запит на HEAD, щоб перевірити, чи ще живі посилання.
2. **Отримати адреси archive.org** - Для розбитих посилань знайти найближчий історичний знімок

Ми є добрими громадянами щодо цього - засіб перевірки використовує нетиповий User- Agent, який ідентифікує себе і посилається на сайт:

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

За допомогою цього пункту можна дізнатися про те, що відбувається з сервером, і про те, що вони можуть знайти нас, якщо їм цікаво. Крім того, ми надсилаємо запити (2 секунди між перевірками), щоб не забивати чийсь сервер.

По суті, посилання - це **періодично перевірено повторно**... посилання, що працював минулого тижня, може бути мертвим сьогодні. Служба знаходить посилання, які не було перевірено протягом останніх 24 годин, і знову їх перевіряє. Але після того, як ми знайшли заміну архіву.org на пошкоджене посилання, ми не перевіримо знову, що оригінальне, - воно вже мертве, і у нас є робоча заміна.

```mermaid
sequenceDiagram
    participant BG as Background Service
    participant DB as Database
    participant Web as External Sites
    participant Archive as Archive.org CDX API

    loop Every Hour
        BG->>DB: Get links not checked in 24h (batch of 20)
        loop For each link
            BG->>Web: HEAD request
            Web-->>BG: Status code
            BG->>DB: Update link status + LastCheckedAt
        end

        BG->>DB: Get broken links needing archive lookup
        loop For each broken link
            BG->>DB: Look up source post publish date
            BG->>Archive: CDX API query (filtered by date)
            Archive-->>BG: Closest snapshot
            BG->>DB: Store archive URL (permanent)
        end
    end
```

### The Archive.org CDX API

Archive. org - це API CDX (Capture Indexment), за допомогою якого ми можемо надсилати запит на знімки екрана. Розумним бітом є фільтрування за датою:

```csharp
private async Task<string?> GetArchiveUrlAsync(
    string originalUrl,
    DateTime? beforeDate,
    CancellationToken cancellationToken)
{
    var queryParams = new List<string>
    {
        $"url={Uri.EscapeDataString(originalUrl)}",
        "output=json",
        "fl=timestamp,original,statuscode",
        "filter=statuscode:200",  // Only successful responses
        "limit=1"
    };

    // Find snapshot closest to the blog post's publish date
    if (beforeDate.HasValue)
    {
        queryParams.Add($"to={beforeDate.Value:yyyyMMdd}");
        queryParams.Add("sort=closest");
        queryParams.Add($"closest={beforeDate.Value:yyyyMMdd}");
    }

    var apiUrl = $"https://web.archive.org/cdx/search/cdx?{string.Join("&", queryParams)}";

    // ... fetch and parse response

    return $"https://web.archive.org/web/{timestamp}/{original}";
}
```

Це означає, що якщо я напишу допис у 2008 році, пов'язаний з певним ресурсом, я отримаю знімок архіву.org десь з 2008 року, не сучасну версію, яка може бути зовсім іншою.

### Слідкування за станом зв' язку

Ми стежимо за посиланнями з відповідним об'єктом:

```csharp
[Table("broken_links", Schema = "mostlylucid")]
public class BrokenLinkEntity
{
    public int Id { get; set; }
    public string OriginalUrl { get; set; } = string.Empty;
    public string? ArchiveUrl { get; set; }
    public bool IsBroken { get; set; } = false;
    public int? LastStatusCode { get; set; }
    public DateTimeOffset? LastCheckedAt { get; set; }
    public int ConsecutiveFailures { get; set; } = 0;
    public string? SourcePageUrl { get; set; }  // For publish date lookup
}
```

### Періодична перевірка

Ключем для виявлення наступних breakages є повторна перевірка. Запити служб щодо посилань, які не були нещодавно перевірені:

```csharp
public async Task<List<BrokenLinkEntity>> GetLinksToCheckAsync(int batchSize, CancellationToken cancellationToken)
{
    var cutoff = DateTimeOffset.UtcNow.AddHours(-24);

    return await _dbContext.BrokenLinks
        .Where(x => x.LastCheckedAt == null || x.LastCheckedAt < cutoff)
        .OrderBy(x => x.LastCheckedAt ?? DateTimeOffset.MinValue)  // Oldest first
        .Take(batchSize)
        .ToListAsync(cancellationToken);
}
```

Це означає, що кожне посилання буде звірено принаймні раз на день. Якщо раніше робоче посилання почне повертати 404-ті, ми його зловимо і почнемо шукати заміну archive.org. `ConsecutiveFailures` Поле, яке дає нам можливість трохи пробачити - ми не позначаємо посилання як зламаний після однієї тимчасової помилки.

## Частина 3. Обробка вхідних запитів

Тепер для іншої сторони монети: people (і пошукові рушії) з запитом на адреси URL, яких не існує. Серед них:

- **Старі схеми адрес URL** - Мій стародавній блог використовував шляхи на зразок `/archive/2006/05/15/123.aspx`Деякі з них все ще індексовані, закладені закладками або пов'язані з іншими сайтами.
- **Typos** - Хтось неправильно копіює адресу URL.
- **Часті слимаки** - Пошукові двигуни іноді індексують дивні фрагменти.

Система семантичного пошуку є особливо корисною у цьому випадку. Навіть без прямого збігу з шаром, ми можемо видобути терміни з вказаної адреси URL і знайти вміст, який відповідає семантичному пошуку. Система знає як слимак, так і дані для кожного з дописів, отже вона може знайти відповідники до " close " навіть для абсолютно різних структур URL.

### Чому вони не є Середніми книжками?

Моїм першим інстинктом було виконання переспрямувань у середньому перехопленні - завчасно, перевірка на відомі переспрямування та перенаправлення перед тим, як з'явиться потреба у переналаштуванні. *може* робота, і ось як вона виглядатиме:

```csharp
// DON'T DO THIS - runs on EVERY request
public class SlugRedirectMiddleware(RequestDelegate next)
{
    public async Task InvokeAsync(HttpContext context, ISlugSuggestionService? service)
    {
        if (context.Request.Path.StartsWithSegments("/blog"))
        {
            var targetSlug = await service.GetAutoRedirectSlugAsync(slug, language);
            if (!string.IsNullOrWhiteSpace(targetSlug))
            {
                context.Response.Redirect($"/blog/{targetSlug}", permanent: true);
                return;
            }
        }
        await next(context);
    }
}
```

У чому ж проблема? *кожен запит* до `/blog/*`. Це запит до бази даних на всіх переглядах сторінок, навіть для цілком коректних адрес URL. Для блогу з належним навантаженням ви не з доброї причини маєте 99% часу для роботи з базою даних.

Набагато кращий підхід: зв' язатися з інструментом обробки 404. Якщо ASP.NET вже визначено, що сторінки не існує, *потім* No lost questions on valid pages.

> Цікавим фактом є те, що на ранніх днях роботи ASP.NET MVC ми створили дружні адреси URL. Демонстрація літака Скотта Гутрі, яка запустила MVC, використала цей підхід; зв' язалася з системою обробки IIS 404, прочитала адресу URL і подала правильний зміст. Це було те, що я використовував у системі, яку я створив у WebForms 3 роки безперервно... і як я отримав PM- імпульс на команді ASP. NET!

### Система навчання

Після того, як хтось натрапить на 404, ми покажемо їм пропозиції з використанням плавного пошуку рядків і (необов' язково) семантичного пошуку. Якщо вони натискають на пропозицію, ми записуємо її. Після достатнього клацання з достатньою довірою, ми розпочнемо автоматичне переспрямування.

```mermaid
stateDiagram-v2
    [*] --> RequestReceived
    RequestReceived --> RoutingCheck

    RoutingCheck --> PageFound: Exists
    RoutingCheck --> NotFound: 404

    PageFound --> [*]

    NotFound --> ErrorController
    ErrorController --> CheckLearnedRedirect
    CheckLearnedRedirect --> Redirect301: Has learned redirect
    CheckLearnedRedirect --> CheckHighConfidence: No learned redirect

    CheckHighConfidence --> Redirect302: Score >= 0.85 & gap >= 0.15
    CheckHighConfidence --> ShowSuggestions: Lower confidence

    ShowSuggestions --> UserClicks
    UserClicks --> RecordClick
    RecordClick --> UpdateWeight
    UpdateWeight --> CheckThreshold

    CheckThreshold --> EnableAutoRedirect: Weight >= 5 & confidence >= 70%
    CheckThreshold --> [*]: Below threshold

    EnableAutoRedirect --> [*]
```

### Обробник 404

Вся логіка переспрямування живе в `ErrorController`. ASP.NET's `UseStatusCodePagesWithReExecute` Команда middleware перевизначає запит за допомогою нашого інструменту обробки помилок під час виконання 404 команди:

```csharp
public class ErrorController(
    BaseControllerService baseControllerService,
    ILogger<ErrorController> logger,
    ISlugSuggestionService? slugSuggestionService = null) : BaseController(baseControllerService, logger)
{
    [Route("/error/{statusCode}")]
    [HttpGet]
    public async Task<IActionResult> HandleError(int statusCode, CancellationToken cancellationToken = default)
    {
        var statusCodeReExecuteFeature = HttpContext.Features.Get<IStatusCodeReExecuteFeature>();

        switch (statusCode)
        {
            case 404:
                // Check for auto-redirects before showing 404 page
                var autoRedirectResult = await TryAutoRedirectAsync(statusCodeReExecuteFeature, cancellationToken);
                if (autoRedirectResult != null)
                    return autoRedirectResult;

                var model = await CreateNotFoundModel(statusCodeReExecuteFeature, cancellationToken);
                return View("NotFound", model);

            case 500:
                return View("ServerError");

            default:
                return View("Error");
        }
    }
}
```

The `TryAutoRedirectAsync` метод керує як вивченими переспрямуваннями, так і перенапруженнями з високою самовпевненістю у перший раз:

```csharp
private async Task<IActionResult?> TryAutoRedirectAsync(
    IStatusCodeReExecuteFeature? statusCodeReExecuteFeature,
    CancellationToken cancellationToken)
{
    if (slugSuggestionService == null || statusCodeReExecuteFeature == null)
        return null;

    var originalPath = statusCodeReExecuteFeature.OriginalPath ?? string.Empty;
    var (slug, language) = ExtractSlugAndLanguage(originalPath);

    // First: check for learned redirects (user previously clicked a suggestion)
    // These get 301 Permanent Redirect - confirmed patterns
    var learnedTargetSlug = await slugSuggestionService.GetAutoRedirectSlugAsync(
        slug, language, cancellationToken);

    if (!string.IsNullOrWhiteSpace(learnedTargetSlug))
    {
        var redirectUrl = BuildRedirectUrl(learnedTargetSlug, language);
        logger.LogInformation("Learned auto-redirect (301): {Original} -> {Target}", originalPath, redirectUrl);
        return RedirectPermanent(redirectUrl);
    }

    // Second: check for high-confidence first-time matches
    // These get 302 Temporary Redirect until confirmed by user clicks
    var firstTimeTargetSlug = await slugSuggestionService.GetFirstTimeAutoRedirectSlugAsync(
        slug, language, cancellationToken);

    if (!string.IsNullOrWhiteSpace(firstTimeTargetSlug))
    {
        var redirectUrl = BuildRedirectUrl(firstTimeTargetSlug, language);
        logger.LogInformation("First-time auto-redirect (302): {Original} -> {Target}", originalPath, redirectUrl);
        return Redirect(redirectUrl);
    }

    return null;  // No redirect - show suggestions
}
```

### Оцінка подібності

Служба пропозиції використовує Lvenshtein дистанцію (edit distance) плюс деякі геуристики:

```csharp
private double CalculateSimilarity(string source, string target)
{
    if (string.Equals(source, target, StringComparison.OrdinalIgnoreCase))
        return 1.0;

    source = source.ToLowerInvariant();
    target = target.ToLowerInvariant();

    // Levenshtein distance converted to similarity (0-1)
    var distance = CalculateLevenshteinDistance(source, target);
    var maxLength = Math.Max(source.Length, target.Length);
    var levenshteinSimilarity = 1.0 - (double)distance / maxLength;

    // Bonus if one string contains the other
    var substringBonus = (source.Contains(target) || target.Contains(source)) ? 0.2 : 0.0;

    // Bonus for common prefix (catches typos at the end)
    var prefixLength = GetCommonPrefixLength(source, target);
    var prefixBonus = (double)prefixLength / Math.Min(source.Length, target.Length) * 0.1;

    return Math.Min(1.0, levenshteinSimilarity + substringBonus + prefixBonus);
}
```

### Вчімося від поведінки користувача

Після наведення вказівника миші на пропозицію з наступним клацанням лівою кнопкою миші, запис буде виконано, а отже, - оновлення рахунку довіри:

```csharp
public async Task RecordSuggestionClickAsync(
    string requestedSlug,
    string clickedSlug,
    string language,
    int suggestionPosition,
    double originalScore,
    CancellationToken cancellationToken = default)
{
    var redirect = await _context.SlugRedirects
        .FirstOrDefaultAsync(r =>
            r.FromSlug == normalizedRequestedSlug &&
            r.ToSlug == normalizedClickedSlug &&
            r.Language == language,
            cancellationToken);

    if (redirect == null)
    {
        redirect = new SlugRedirectEntity
        {
            FromSlug = normalizedRequestedSlug,
            ToSlug = normalizedClickedSlug,
            Language = language,
            Weight = 1
        };
        _context.SlugRedirects.Add(redirect);
    }
    else
    {
        redirect.Weight++;
        redirect.LastClickedAt = DateTimeOffset.UtcNow;
    }

    redirect.UpdateConfidenceScore();
    await _context.SaveChangesAsync(cancellationToken);
}
```

Обчислення рахунку обчислює як клацання, так і візуалізації:

```csharp
public void UpdateConfidenceScore()
{
    var total = Weight + ShownCount;
    ConfidenceScore = total > 0 ? (double)Weight / total : 0.0;

    // Enable auto-redirect after 5+ clicks with 70%+ confidence
    if (Weight >= AutoRedirectWeightThreshold &&
        ConfidenceScore >= AutoRedirectConfidenceThreshold)
    {
        AutoRedirect = true;
    }
}
```

### Двотиметровий автоматичний пов' язаний з

Ми маємо два рівні автоматичного перенаправлення:

1. **Перший високий рівень самовпевненості (302)**: Якщо рахунок подібності > = 0. 85 І існує суттєвий проміжок між другими значеннями, ми негайно переспрямовуємо дані на 302. Таким чином можна помітити очевидні помилки.

2. **Навчені переспрямування (301)**: Після натискання 5+ з довірою до 70% +, ми використовуємо постійне переспрямування 301. Це випадок " люди вже вимовлені ."

```csharp
public async Task<string?> GetFirstTimeAutoRedirectSlugAsync(
    string requestedSlug,
    string language,
    CancellationToken cancellationToken = default)
{
    var suggestions = await GetSuggestionsWithScoreAsync(requestedSlug, language, 2, cancellationToken);

    if (suggestions.Count == 0) return null;

    var topMatch = suggestions[0];

    // Need high confidence
    if (topMatch.Score < 0.85) return null;

    // If there's only one suggestion with high score, redirect
    if (suggestions.Count == 1) return topMatch.Post.Slug;

    // Multiple suggestions - only redirect if there's a clear winner
    var scoreGap = topMatch.Score - suggestions[1].Score;
    if (scoreGap >= 0.15)
        return topMatch.Post.Slug;

    return null;  // Too close to call - show suggestions instead
}
```

## Усе це разом

### Коли програмне забезпечення стає свідомим (а коли ні)

for **Вихідний зв' язок**Посередня програма - це правильний вибір. `BrokenLinkArchiveMiddleware` має перехопити відповідь HTML *після* він був перекладений, але *до* Для цього не існує іншого розумного місця - нам потрібно змінити потік відповіді, а для цього розроблено програмне забезпечення.

for **Вхідні переспрямування**Ми б проводили запити до баз даних з кожного `/blog/*` щоб перевірити чи ця адреса URL може потребувати переспрямування. підхід до обробки 404 запускає цю логіку, лише якщо ми вже визначили, що сторінки не існує - набагато ефективніше.

```csharp
// In Program.cs
app.UseStatusCodePagesWithReExecute("/error/{0}");  // Handles 404s through ErrorController
app.UseStaticFiles();
app.UseRouting();
// ... other middleware
app.UseBrokenLinkArchive();  // Process outgoing links in responses (ONLY for middleware)
```

Потік виглядає так:

```mermaid
flowchart LR
    subgraph Request["Request Processing"]
        direction TB
        A[Request] --> B[Static Files]
        B --> C[Routing]
        C --> D{Page exists?}
        D -->|Yes| E[MVC/Endpoints]
        D -->|No| F[ErrorController 404]
        F --> G{Auto-redirect?}
        G -->|Yes| H[Redirect]
        G -->|No| I[Show suggestions]
    end

    subgraph Response["Response Processing"]
        direction TB
        E --> J[BrokenLinkArchiveMiddleware]
        J --> K[Link Extraction]
        K --> L[Link Replacement]
        L --> M[Response]
    end

    subgraph Background["Background Processing"]
        direction TB
        N[BrokenLinkCheckerService] --> O[Check URLs]
        O --> P[Fetch Archive.org]
        P --> Q[Update Database]
    end

    K -.->|Register links| N

    style F stroke:#ef4444,stroke-width:3px
    style J stroke:#8b5cf6,stroke-width:3px
    style N stroke:#f59e0b,stroke-width:3px
```

## Висновки

Ця система вже давно виконується, і вона, скоріше, задовольняється спостереженням за її навчанням. База даних поступово заповнює карти переспрямування, пошкоджені посилання отримують заміни archive.org, і користувачі рідко коли бачать наготу 404 більше.

Ключові принципи:

- **Не блокуйте запити** - використовувати фонову обробку для дорогих операцій
- **Вчити у користувачів** - їхні клацання є цінними сигналами
- **Будьте консервативними з автоматичними каталогами** - только перенаправить, когда ты уверен.
- **Розподіл часу для архівування** - знайти знімки archive.org з часу написання вмісту
- **Милосердна деградація** - якщо сервіси недоступні, просто продовжуйте нормально

Це не ідеально - деякі посилання справді зникають, а семантичний пошук не завжди знаходить правильну заміну, але краще видно, ніж лишати тисячі зламаних посилань.

## Незабаром: Семантичний пошук глибоко поринув

Цього тижня (w/ c 24 листопада 2025 року) Я оприлюднюю свою семантичну серію пошуку, яка буде докладніше розглянути як векторний пошук працює під капотом. Ця серія охопить створення, векторне зберігання QDant, і те, як все це пов' язане з цією системою обробки 404. Якщо вам цікаво, як саме `ISemanticSearchService.SearchAsync` насправді знаходить "симулярний" зміст, ось де ви захочете знайти.

Повне джерело знаходиться у сховищі, якщо ви бажаєте з' єднати його з вашим власним проектом.