Сопоставление старых и новых URL по заголовкам: строим карту 301-редиректов (Шаг 3)

На финальном шаге миграции нужно связать старые страницы с их новыми аналогами, чтобы настроить 301-редиректы «один-к-одному». Если URL-структура полностью изменилась и прямых соответствий по адресам нет, помогут заголовки страниц (title). В этой статье используем скрипт compare_redirects.php, который по заголовкам подбирает лучшую пару «старый → новый» и формирует карту редиректов (CSV).

Что делает скрипт

  • Принимает два файла (через веб-форму):
    • старый список — страницы прежнего сайта;
    • новый список — страницы после переноса/редизайна/смены CMS.
  • Каждый файл может быть в формате:
    • url,title (CSV со столбцами URL и Title), или
    • две колонки через табуляцию, или
    • просто список URL (тогда title считается пустым).
  • Нормализует заголовки (нижний регистр, коллапс пробелов) и сравнивает их при помощи similar_text (fuzzy-сходство).
  • Строит соответствия «старый → новый» при достижении порога схожести и сохраняет три CSV:
    • redirects_YYYYMMDD_HHMMSS.csv — итоговая карта сопоставлений (old_url,new_url,score,old_title,new_title,match_method);
    • redirects_..._old_unmatched.csv — «старые» без пары;
    • redirects_..._new_unmatched.csv — «новые» без пары.

Параметры

  • $FUZZY_THRESHOLD — порог схожести (по умолчанию 0.70). Увеличивайте для более строгих совпадений; уменьшайте, если заголовки сильно переработаны.
  • $MAX_CANDIDATES — зарезервирован для будущих расширений (в текущем коде не используется).

Форматы входных данных

Рекомендуемый формат — CSV с заголовком:

url,title
https://old-site.ru/catalog/termoplenki,Термоплёнки для ламинирования
https://old-site.ru/blog/migration-seo,SEO при переносе сайта
...

Аналогично для нового списка:

url,title
https://new-site.ru/lamination/films,Плёнки для ламинирования
https://new-site.ru/articles/site-migration-seo,SEO при переезде сайта
...

Как использовать

  1. Разместите compare_redirects.php на локальном/тестовом сервере (требуется PHP 8+).
  2. Подготовьте два файла (старый и новый) в одном из поддерживаемых форматов.
  3. Откройте скрипт в браузере, загрузите оба файла и нажмите «Сопоставить».
  4. Скачайте результаты:
    • mapping — пары кандидатов для 301-редиректов;
    • old_unmatched — старые, которым не нашлось пары;
    • new_unmatched — новые, которые ни к кому не привязаны.

Как интерпретировать результаты

  • score — показатель схожести заголовков от 0 до 1 (чем ближе к 1, тем лучше).
  • match_method=fuzzy — метод сопоставления (в текущей версии — нечеткое сравнение заголовков).
  • Пары с высоким score можно конвертировать в правила 301; пары около порога — проверить вручную.

Быстрая конвертация в 301

После ручной проверки redirects_*.csv можно преобразовать в правила:

Apache (.htaccess)

Redirect 301 /catalog/termoplenki https://new-site.ru/lamination/films
Redirect 301 /blog/migration-seo https://new-site.ru/articles/site-migration-seo

nginx

rewrite ^/catalog/termoplenki/?$ https://new-site.ru/lamination/films permanent;
rewrite ^/blog/migration-seo/?$ https://new-site.ru/articles/site-migration-seo permanent;

Рекомендации по качеству сопоставлений

  • Перед запуском убедитесь, что в обоих списках корректные title (без технического мусора, шаблонов «Главная», «Каталог» и т.д.).
  • При сильной переработке контента уменьшите $FUZZY_THRESHOLD (например, 0.55–0.65) и вручную проверьте пары на границе.
  • Для товарных карточек добавляйте в заголовки артикулы/модели: это увеличивает точность сопоставления.
  • Запустите 2–3 итерации с разными порогами, чтобы поднять покрытие и затем объединить результаты.

Полный скрипт compare_redirects.php

Сохраните как compare_redirects.php и откройте в браузере.

<?php
/**
 * compare_redirects.php
 *
 * Сопоставление старых и новых URL по заголовкам (для 301 редиректов).
 * Работает только через браузер.
 */

declare(strict_types=1);
mb_internal_encoding('UTF-8');

// === НАСТРОЙКИ ===
$FUZZY_THRESHOLD = 0.70; // Порог схожести для fuzzy-сравнения
$MAX_CANDIDATES  = 10;    // Сколько кандидатов проверять дополнительно

// === ОБРАБОТКА ФОРМЫ ===
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (empty($_FILES['old']['tmp_name']) || empty($_FILES['new']['tmp_name'])) {
        renderPage('<div style="color:#c00">Загрузите оба файла</div>');
        exit;
    }

    $oldRows = loadList($_FILES['old']['tmp_name']);
    $newRows = loadList($_FILES['new']['tmp_name']);

    [$mapping, $oldUn, $newUn] = buildMapping($oldRows, $newRows, $FUZZY_THRESHOLD, $MAX_CANDIDATES);

    $base = __DIR__ . '/redirects_' . date('Ymd_His');
    $mapPath   = $base . '.csv';
    $oldUnPath = $base . '_old_unmatched.csv';
    $newUnPath = $base . '_new_unmatched.csv';

    saveCsv($mapPath,   ['old_url','new_url','score','old_title','new_title','match_method'], $mapping);
    saveCsv($oldUnPath, ['old_url','old_title'], $oldUn);
    saveCsv($newUnPath, ['new_url','new_title'], $newUn);

    $html  = '<p><strong>Готово!</strong></p>';
    $html .= '<ul>';
    $html .= '<li><a href="'.h(makeRel($mapPath)).'">Скачать mapping ('.count($mapping).')</a></li>';
    $html .= '<li><a href="'.h(makeRel($oldUnPath)).'">Скачать старые без пары ('.count($oldUn).')</a></li>';
    $html .= '<li><a href="'.h(makeRel($newUnPath)).'">Скачать новые без пары ('.count($newUn).')</a></li>';
    $html .= '</ul>';
    $html .= renderPreview($mapping);

    renderPage($html);
    exit;
}

// === ПЕРВЫЙ ЗАПУСК ===
renderPage();

/* ----------------- ЛОГИКА ----------------- */

function loadList(string $path): array {
    $rows = [];
    $raw = @file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
    foreach ($raw as $line) {
        $line = trim($line);
        if ($line === '') continue;
        if (strpos($line, ',') !== false) {
            $cells = str_getcsv($line);
            $url   = trim((string)($cells[0] ?? ''));
            $title = trim((string)($cells[1] ?? ''));
        } elseif (strpos($line, "\t") !== false) {
            $cells = explode("\t", $line);
            $url   = trim((string)($cells[0] ?? ''));
            $title = trim((string)($cells[1] ?? ''));
        } else {
            $url   = $line;
            $title = '';
        }
        $rows[] = [
            'url'   => $url,
            'title' => $title,
            '_norm' => normalizeTitle($title),
        ];
    }
    return $rows;
}

function buildMapping(array $oldRows, array $newRows, float $fuzzyThreshold, int $maxCandidates): array {
    $mapping = [];
    $oldUn = [];
    $newUsed = [];

    foreach ($oldRows as $o) {
        $best = null;
        $bestScore = 0;
        $method = '';

        foreach ($newRows as $idx => $n) {
            if (isset($newUsed[$idx])) continue;
            $percent = 0.0;
            similar_text($o['_norm'], $n['_norm'], $percent);
            $score = $percent / 100;
            if ($score > $bestScore) {
                $best = $idx;
                $bestScore = $score;
                $method = 'fuzzy';
            }
        }

        if ($best !== null && $bestScore >= $fuzzyThreshold) {
            $mapping[] = [
                'old_url'   => $o['url'],
                'new_url'   => $newRows[$best]['url'],
                'score'     => number_format($bestScore, 4, '.', ''),
                'old_title' => $o['title'],
                'new_title' => $newRows[$best]['title'],
                'match_method' => $method,
            ];
            $newUsed[$best] = true;
        } else {
            $oldUn[] = ['old_url' => $o['url'], 'old_title' => $o['title']];
        }
    }

    $newUn = [];
    foreach ($newRows as $idx => $n) {
        if (!isset($newUsed[$idx])) {
            $newUn[] = ['new_url' => $n['url'], 'new_title' => $n['title']];
        }
    }

    return [$mapping, $oldUn, $newUn];
}

function normalizeTitle(string $s): string {
    $s = mb_strtolower($s);
    $s = preg_replace('~\s+~u', ' ', $s);
    return trim($s);
}

function saveCsv(string $path, array $headers, array $rows): void {
    $fh = fopen($path, 'w');
    fputcsv($fh, $headers, ',', '"', "\\\\");
    foreach ($rows as $r) {
        $line = [];
        foreach ($headers as $h) {
            $line[] = $r[$h] ?? '';
        }
        fputcsv($fh, $line, ',', '"', "\\\\");
    }
    fclose($fh);
}

function h(string $s): string { return htmlspecialchars($s, ENT_QUOTES | ENT_HTML5, 'UTF-8'); }
function makeRel(string $abs): string { return basename($abs); }

function renderPage(string $content=''): void {
    $title = 'Сопоставление старых и новых URL (301 редиректы)';
    $form = <<<HTML
<form method="post" enctype="multipart/form-data" style="margin-bottom:20px;">
  <div style="margin-bottom:10px;">
    <label><strong>Старый список (CSV/TXT)</strong></label><br>
    <input type="file" name="old" required>
  </div>
  <div style="margin-bottom:10px;">
    <label><strong>Новый список (CSV/TXT)</strong></label><br>
    <input type="file" name="new" required>
  </div>
  <button type="submit" style="padding:8px 16px;">Сопоставить</button>
</form>
HTML;

    if ($content === '') {
        $content = '<p>Загрузите два файла со столбцами <code>url,title</code>. '
                 . 'Скрипт сопоставит их по заголовкам и сформирует карту редиректов.</p>';
    }

    $html = <<<HTML
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>{$title}</title>
</head>
<body style="font-family:sans-serif;max-width:1000px;margin:40px auto;padding:0 16px;">
  <h1>{$title}</h1>
  {$form}
  <div>{$content}</div>
</body>
</html>
HTML;
    echo $html;
}

function renderPreview(array $rows): string {
    if (!$rows) return '';
    $rows = array_slice($rows, 0, 50);
    $h = '<table border="1" cellpadding="6" cellspacing="0" style="border-collapse:collapse;width:100%">';
    $h .= '<tr style="background:#f2f2f2"><th>Old URL</th><th>New URL</th><th>Score</th><th>Old Title</th><th>New Title</th></tr>';
    foreach ($rows as $r) {
        $h .= '<tr><td>'.h($r['old_url']).'</td><td>'.h($r['new_url']).'</td><td>'.h($r['score']).'</td><td>'.h($r['old_title']).'</td><td>'.h($r['new_title']).'</td></tr>';
    }
    $h .= '</table>';
    return $h;
}
  

Подсказка. Если нужно, могу подготовить небольшой конвертер, который возьмёт redirects_*.csv и сгенерирует готовые блоки для .htaccess и конфигурации nginx автоматически.

Прикреплённые файлы:


< Возврат к списку

Контакты
ИП Мироненко О.В.
г. Москва,
ул. Озерная, д. 2, к. 2
Пн-Вс круглосуточно
Работаем удалённо!
Задать вопрос

Нажимая на кнопку «Отправить», Вы даете согласие на обработку своих персональных данных и получение информационных сообщений.