На финальном шаге миграции нужно связать старые страницы с их новыми аналогами, чтобы настроить 301-редиректы «один-к-одному».
Если URL-структура полностью изменилась и прямых соответствий по адресам нет, помогут заголовки страниц (title).
В этой статье используем скрипт compare_redirects.php, который по заголовкам подбирает лучшую пару «старый → новый» и формирует
карту редиректов (CSV).
url,title (CSV со столбцами URL и Title), илиsimilar_text (fuzzy-сходство).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 при переезде сайта
...
compare_redirects.php на локальном/тестовом сервере (требуется PHP 8+).После ручной проверки redirects_*.csv можно преобразовать в правила:
Redirect 301 /catalog/termoplenki https://new-site.ru/lamination/films
Redirect 301 /blog/migration-seo https://new-site.ru/articles/site-migration-seo
rewrite ^/catalog/termoplenki/?$ https://new-site.ru/lamination/films permanent;
rewrite ^/blog/migration-seo/?$ https://new-site.ru/articles/site-migration-seo permanent;
$FUZZY_THRESHOLD (например, 0.55–0.65) и вручную проверьте пары на границе.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 автоматически.