После того как мы выгрузили индекс Яндекс.Вебмастера и прогнали его через первый скрипт (Link Resolver), у нас есть таблица соответствий: исходный адрес → фактический конечный адрес. На этом шаге часто видно, что к одной и той же финальной странице ведут несколько «длинных» вариантов старых URL. Задача статьи — отсечь самоссылки и оставить только пары, где старый и новый адрес действительно различаются и требуют 301-редиректа.
url_diff.php
Скрипт принимает двухколоночный список вида old_url,new_url и удаляет строки, где адреса эквивалентны
(буквально или после «умной нормализации»). На выходе вы получаете чистый CSV с парами, по которым нужно настраивать редирект.
\t, запятая ,, точка с запятой ;;http/https и префикса www.;/ (кроме корня) и index.html|index.php;#...;%XX в пути, схлопывание двойных слэшей.old_url,new_url
https://site.ru/catalog/?page=1,https://site.ru/catalog/
https://site.ru/item?id=123,https://site.ru/item?id=123
http://www.site.ru/index.php,https://site.ru/
url_diff.php (веб-форма или локально).old_url == new_url после нормализации (во втором примере — самоссылка), и оставит только пары, требующие редиректа.url_diff.php в браузере (локальный/тестовый сервер).old_url{delim}new_url.
Нормализация влияет только на логику сравнения, а не на исходные данные.
Например, http://www.site.ru/index.php и https://site.ru/ будут считаться одинаковыми — строка будет удалена.
Полученный CSV (old_url,new_url) — это почти готовая карта редиректов. Далее можно:
Redirect 301 /catalog/?page=1 https://site.ru/catalog/
Redirect 301 /index.php https://site.ru/
rewrite ^/index\.php$ https://site.ru/ permanent;
url_diff.phpСохраните код ниже как url_diff.php и разместите рядом с CSV:
<?php
/**
* url_diff.php
*
* Веб-скрипт: фильтрует строки двухколоночного списка (old_url,new_url),
* оставляя только те, где URL в 1-м и 2-м столбце РАЗНЫЕ.
*
* Поддержка:
* - Загрузка файла (CSV/TSV/TXT) или вставка текста
* - Автодетект разделителя: , ; \t
* - Опция "умная нормализация" для сравнения (игнор http/https, www, финальный /, index.* и т.п.)
* - Возврат готового CSV + превью в браузере
*/
declare(strict_types=1);
mb_internal_encoding('UTF-8');
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$normalize = !empty($_POST['normalize']);
$rows = [];
// 1) Получаем сырой текст: из файла и/или textarea
$raw = '';
if (!empty($_FILES['file']['tmp_name']) && is_uploaded_file($_FILES['file']['tmp_name'])) {
$raw = file_get_contents($_FILES['file']['tmp_name']) ?: '';
}
if (!empty($_POST['text'])) {
$raw2 = (string)$_POST['text'];
$raw = $raw ? ($raw . "\n" . $raw2) : $raw2;
}
if ($raw === '') {
renderPage('<div style="color:#c00">Не найдено данных. Загрузите файл или вставьте текст.</div>');
exit;
}
// 2) Парсим строки в пары (old,new)
$parsed = parseTwoColumns($raw);
// 3) Фильтруем: оставляем только разные
$kept = [];
$removed = 0;
foreach ($parsed as [$old, $new]) {
$oldCmp = $normalize ? normUrl($old) : $old;
$newCmp = $normalize ? normUrl($new) : $new;
if ($oldCmp === $newCmp) {
$removed++;
continue; // выкидываем одинаковые
}
$kept[] = ['old_url' => $old, 'new_url' => $new];
}
// 4) Сохраняем результат в CSV
$outPath = __DIR__ . '/url_diff_' . date('Ymd_His') . '.csv';
saveCsv($outPath, ['old_url','new_url'], $kept);
$html = '<p><strong>Готово.</strong></p>';
$html .= '<p>Всего строк: ' . count($parsed) . '<br>Удалено одинаковых: ' . $removed . '<br>Оставлено различающихся: ' . count($kept) . '</p>';
$html .= '<p><a href="'.h(makeRel($outPath)).'">Скачать CSV</a></p>';
$html .= renderPreview($kept);
renderPage($html);
exit;
}
// первый показ формы
renderPage();
/* ============== ФУНКЦИИ ============== */
/**
* Парсит сырой текст в пары [old,new].
* Автодетект разделителя: , ; \t
* Игнорит пустые строки; если в строке один столбец — new оставляет пустым.
*/
function parseTwoColumns(string $raw): array {
// Выберем разделитель по приоритету: таб, запятая, точка с запятой
$delim = detectDelimiter($raw);
$lines = preg_split('~\r\n|\r|\n~', $raw);
$out = [];
foreach ($lines as $line) {
$line = trim($line);
if ($line === '') continue;
if ($delim === "\t") {
$cells = explode("\t", $line);
} else {
// CSV-парсер для , или ;
$fh = fopen('php://memory', 'r+');
fwrite($fh, $line);
rewind($fh);
$cells = fgetcsv($fh, 0, $delim);
fclose($fh);
}
$old = trim((string)($cells[0] ?? ''));
$new = trim((string)($cells[1] ?? ''));
if ($old === '' && $new === '') continue;
$out[] = [$old, $new];
}
return $out;
}
function detectDelimiter(string $raw): string {
// Смотрим первую непустую строку
foreach (preg_split('~\r\n|\r|\n~', $raw) as $line) {
$line = trim($line);
if ($line === '') continue;
// приоритет: таб > запятая > ;
if (strpos($line, "\t") !== false) return "\t";
// если запятых больше точки с запятой — берём запятую
$commas = substr_count($line, ',');
$semis = substr_count($line, ';');
if ($commas || $semis) {
return ($commas >= $semis) ? ',' : ';';
}
break;
}
// по умолчанию запятая
return ',';
}
/**
* «Умная» нормализация URL для сравнения:
* - регистр хоста -> нижний
* - удаление схемы (http/https)
* - удаление ведущего www.
* - декодирование %XX (urldecode)
* - удаление якоря (#...)
* - нормализация пути: двойные слэши -> один; удаление завершающего /
* - замена index.html|index.php на пустой
* - сортировка query-параметров и удаление пустых
*/
function normUrl(string $url): string {
$url = trim($url);
if ($url === '') return '';
// Если без схемы — добавим для parse_url
if (!preg_match('~^[a-z][a-z0-9+.-]*://~i', $url)) {
$url = 'http://' . $url;
}
$p = @parse_url($url);
if ($p === false) return strtolower($url);
$host = isset($p['host']) ? mb_strtolower($p['host']) : '';
$host = preg_replace('~^www\.~i', '', $host);
$path = $p['path'] ?? '';
$path = urldecode($path);
// collapse // -> /
$path = preg_replace('~/{2,}~', '/', $path);
// remove index.*
$path = preg_replace('~/(index\.(html?|php))$~i', '/', $path);
// remove trailing slash except root
if ($path !== '/') {
$path = rtrim($path, '/');
}
// query: сортируем, убираем пустые
$query = '';
if (!empty($p['query'])) {
parse_str($p['query'], $q);
// удалим пустые
$q = array_filter($q, fn($v) => $v !== '' && $v !== null);
ksort($q);
if (!empty($q)) {
$query = http_build_query($q);
}
}
// без фрагмента
$norm = $host . $path;
if ($query !== '') $norm .= '?' . $query;
return $norm;
}
function saveCsv(string $path, array $headers, array $rows): void {
$fh = @fopen($path, 'w');
if (!$fh) {
$path = __DIR__ . '/' . basename($path);
$fh = fopen($path, 'w');
}
// важное: в PHP 8.1+ явно указываем $escape
fputcsv($fh, $headers, ',', '"', "\\\\");
foreach ($rows as $r) {
$line = [];
foreach ($headers as $h) {
$line[] = $r[$h] ?? '';
}
fputcsv($fh, $line, ',', '"', "\\\\");
}
fclose($fh);
}
function renderPreview(array $rows): string {
if (!$rows) return '<p>Все строки оказались одинаковыми — ничего не осталось.</p>';
$preview = array_slice($rows, 0, 50);
$h = '<h3>Превью (первые ' . count($preview) . ')</h3>';
$h .= '<table border="1" cellpadding="6" cellspacing="0" style="border-collapse:collapse;width:100%">';
$h .= '<tr style="background:#f2f2f2"><th>#</th><th>Old URL</th><th>New URL</th></tr>';
$i = 0;
foreach ($preview as $r) {
$i++;
$h .= '<tr><td>'.$i.'</td><td>'.h($r['old_url']).'</td><td>'.h($r['new_url']).'</td></tr>';
}
$h .= '</table>';
return $h;
}
function renderPage(string $content=''): void {
$title = 'Фильтр различающихся URL (2 столбца)';
$form = <<<HTML
<form method="post" enctype="multipart/form-data" style="margin-bottom:20px;">
<div style="margin-bottom:10px;">
<label><strong>Файл (CSV/TSV/TXT)</strong></label><br>
<input type="file" name="file" accept=".csv,.tsv,.txt">
</div>
<div style="margin-bottom:10px;">
<label><strong>или Вставьте текст</strong> (по строке: <code>old_url{delim}new_url</code>)</label><br>
<textarea name="text" rows="10" style="width:100%;max-width:900px;"></textarea>
<div style="color:#666;font-size:12px;margin-top:4px;">Разделитель определяется автоматически: таб / запятая / точка с запятой.</div>
</div>
<div style="margin:10px 0;">
<label><input type="checkbox" name="normalize" value="1" checked> Умная нормализация при сравнении (игнор http/https, www, конечный слэш, index.* и т.п.)</label>
</div>
<button type="submit" style="padding:8px 16px;">Фильтровать</button>
</form>
HTML;
if ($content === '') {
$content = '<p>Загрузите таблицу из двух столбцов (<code>old_url</code>, <code>new_url</code>) '
. 'или вставьте текст. Скрипт удалит строки, где URL совпадают, и вернёт CSV только с различающимися парами.</p>';
}
$html = <<<HTML
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>{$title}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body style="font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;max-width:1100px;margin:40px auto;padding:0 16px;line-height:1.5;">
<h1 style="margin-top:0;">{$title}</h1>
{$form}
<div>{$content}</div>
<hr>
<div style="color:#666;font-size:12px;">Совпадение определяется по точному сравниванию или по нормализованным URL (если включено).</div>
</body>
</html>
HTML;
header('Content-Type: text/html; charset=UTF-8');
echo $html;
}
function h(string $s): string { return htmlspecialchars($s, ENT_QUOTES | ENT_HTML5, 'UTF-8'); }
function makeRel(string $abs): string {
$root = realpath(__DIR__);
$file = realpath($abs);
if ($root && $file && str_starts_with($file, $root)) {
return ltrim(str_replace('\\\\', '/', substr($file, strlen($root))), '/');
}
return basename($abs);
}
Подсказка: если нужно, могу собрать мини-утилиту, которая будет автоматически превращать CSV old_url,new_url в готовые правила для .htaccess и nginx.