Удаляем дубли URL после сверки индекса: как оставить только нужные пары для редиректа (Шаг 2)

После того как мы выгрузили индекс Яндекс.Вебмастера и прогнали его через первый скрипт (Link Resolver), у нас есть таблица соответствий: исходный адрес → фактический конечный адрес. На этом шаге часто видно, что к одной и той же финальной странице ведут несколько «длинных» вариантов старых URL. Задача статьи — отсечь самоссылки и оставить только пары, где старый и новый адрес действительно различаются и требуют 301-редиректа.

Что делает url_diff.php

Скрипт принимает двухколоночный список вида old_url,new_url и удаляет строки, где адреса эквивалентны (буквально или после «умной нормализации»). На выходе вы получаете чистый CSV с парами, по которым нужно настраивать редирект.

Ключевые возможности

  • Приём данных из файла (CSV/TSV/TXT) или вставкой из буфера;
  • Автодетект разделителя: таб \t, запятая ,, точка с запятой ;;
  • Опция «умной нормализации» для корректного сравнения:
    • игнор схемы http/https и префикса www.;
    • удаление завершающего / (кроме корня) и index.html|index.php;
    • сортировка query-параметров, удаление пустых, игнор фрагмента #...;
    • декодирование %XX в пути, схлопывание двойных слэшей.
  • Возврат готового CSV + превью результата прямо в браузере.

Типовой сценарий

  1. Из первого скрипта приводим результат к виду двух колонок:
    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/
  2. Запускаем url_diff.php (веб-форма или локально).
  3. Скрипт выкинет строки, где old_url == new_url после нормализации (во втором примере — самоссылка), и оставит только пары, требующие редиректа.

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

Веб-режим

  1. Откройте url_diff.php в браузере (локальный/тестовый сервер).
  2. Загрузите файл или вставьте текст с парами old_url{delim}new_url.
  3. Оставьте включённой опцию «Умная нормализация».
  4. Нажмите «Фильтровать», скачайте CSV и проверьте превью.

Заметка про нормализацию

Нормализация влияет только на логику сравнения, а не на исходные данные. Например, http://www.site.ru/index.php и https://site.ru/ будут считаться одинаковыми — строка будет удалена.

Что дальше: генерация правил 301

Полученный CSV (old_url,new_url) — это почти готовая карта редиректов. Далее можно:

  • сгенерировать правила для Apache (.htaccess):
    Redirect 301 /catalog/?page=1 https://site.ru/catalog/
    Redirect 301 /index.php https://site.ru/
  • или для nginx:
    rewrite ^/index\.php$ https://site.ru/ permanent;
  • или загрузить в инструмент миграции вашей CMS.

Полный скрипт 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.

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


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

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

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