Автоматизация SEO-аудита: проверяем редиректы, canonical и hreflang как граф

—

от автора

Краулер нашёл canonical, сервер вернул 200, в таблице стоит зелёная галочка. При этом canonical ведёт на редирект, языковая версия не ссылается обратно, а старые URL образуют цикл. По отдельности поля заполнены; связи между страницами сломаны.

Покажу, как превратить выгрузку обхода в воспроизводимую проверку графа: отделить неизвестные данные от ошибок, сохранить доказательства и использовать результат перед релизом. Внутри — Python-скрипт без сторонних зависимостей и синтетический пример, который можно повторить локально.

1. Сначала определяем, что именно проверяем

Автоматизация SEO-аудита полезна, когда результат проверки можно воспроизвести на тех же входных данных. Для этого нужны три отдельные сущности: снимок наблюдений, набор правил и список нарушений с адресами источника и цели.

В этой статье проверяем согласованность объявленных связей сайта. Не определяем, какие страницы находятся в индексе. Google прямо указывает, что ответ 200 не гарантирует индексацию. Поэтому поле indexed: true, вычисленное из HTTP-кода, было бы ошибкой модели данных.

Предлагаемая архитектура:

Список URL и настройки обхода            ↓Снимок HTTP-ответов и извлечённых аннотаций            ↓Граф редиректов / canonical / hreflang            ↓Детерминированные правила            ↓Нарушение + источник + цель + проверка исправления

Это инженерная схема аудита, а не описание внутреннего устройства поисковой системы.

Нужен ещё контракт ожидаемого поведения. Архивная карточка может намеренно возвращать 404; переход со старого домена на новый может быть штатным редиректом. Ошибка возникает при расхождении наблюдений с назначением страницы. Поэтому до запуска полезно разделить URL на индексируемые посадочные, служебные страницы, удалённые документы и адреса миграции.

2. Три типа связей — три разных смысла

Редирект: куда реально отправляет сервер

Сохраняйте каждый переход отдельно: исходный адрес, HTTP-код, значение Location. Если клиент автоматически прошёл цепочку и оставил только финальный 200, восстановить промежуточные узлы из такого результата нельзя.

Для Google постоянные 301/308 и временные 302/307 имеют разный смысл. Наш скрипт объединяет их только для обхода графа; решение о корректности выбранного кода остаётся отдельной проверкой миграции.

Цикл — безусловный дефект маршрута. Цепочка из двух переходов — повод проверить конфигурацию, но не доказательство потери позиций. Ниже это диагностическое правило проекта, а не «лимит Google в один редирект».

Canonical: какую версию предпочитает сайт

rel="canonical" — сигнал предпочтения канонической версии, а не команда, гарантирующая выбор адреса. Проверяем доступность объявленной цели и согласованность конфигурации. Фактический выбор Google из HTML не выводим.

Отсутствие canonical не делает любую страницу ошибочной. Для конкретного шаблона self-canonical можно включить в контракт команды, но это уже локальная политика. Полезно также отдельно искать противоречия между HTML canonical, HTTP Link и sitemap.

Hreflang: какие страницы образуют языковую группу

В документации по локализованным версиям описаны ссылки на саму страницу и альтернативы, полностью квалифицированные адреса и взаимные ссылки между версиями. Эти отношения удобно проверять как рёбра графа.

Минимальная проверка ниже ищет отсутствие самоссылки, обратной ссылки и недоступные цели. Она не проверяет корректность языковых кодов, одинаковый набор альтернатив или соответствие перевода исходному документу. x-default обрабатываем отдельно: правило обратной языковой ссылки для него в примере не применяем.

Связь

Что наблюдаем

Что из этого не следует

Редирект

Переход и конечный ответ в снимке

Что поисковик уже переобошёл адрес

Canonical

Объявленная цель

Что Google выбрал именно её

Hreflang

Связи языковых версий

Что переводы корректны по содержанию

HTTP 200

Успешный HTTP-ответ

Что документ проиндексирован

3. Контракт снимка важнее выбора краулера

Скрипт получает JSON-массив. Одна строка — один запрошенный URL и его непосредственный ответ, до автоматического следования редиректу. canonical содержит одну уже выбранную аннотацию; hreflang — словарь язык → URL. Относительные значения поддерживаются для удобства диагностики, но их допустимость в исходной разметке надо проверять до нормализации.

В промышленной выгрузке дополнительно храните время запроса, User-Agent, окружение, исходные заголовки, все найденные аннотации и версию парсера. Не сворачивайте несколько canonical в одно поле без отдельного сообщения о конфликте. Словарь hreflang тоже скрывает повторяющиеся языковые коды — их нужно выявлять на этапе извлечения.

Нормализация в примере намеренно консервативна: разрешаем относительный адрес и убираем фрагмент. Не удаляем query-параметры, не меняем регистр пути и не склеиваем варианты со слешем. Иначе нормализатор может скрыть как раз тот дефект, который ищет аудит.

Данные для примера синтетические: это не результаты обхода реального сайта.

[  {"url":"https://example.com/old","status":301,"location":"/middle"},  {"url":"https://example.com/middle","status":301,"location":"/ru/item"},  {"url":"https://example.com/ru/item","status":200,"canonical":"/missing","hreflang":{"ru":"/ru/item","en":"/en/item"}},  {"url":"https://example.com/en/item","status":200,"canonical":"/en/item","hreflang":{"en":"/en/item"}}]

Сохраните массив в snapshot.json. В нём четыре URL: старый адрес проходит два редиректа, русская карточка указывает canonical на отсутствующую в снимке цель, английская версия не возвращает ссылку на русскую.

4. Проверяющий скрипт на Python

Сохраните код в audit_graph.py. Он работает локально, не отправляет HTTP-запросы и использует только стандартную библиотеку. Порядок результатов стабилен: одинаковый снимок даёт одинаковый JSON.

import jsonimport sysfrom urllib.parse import urldefrag, urljoinREDIRECTS = {301, 302, 303, 307, 308}def normalize(base, value):    return urldefrag(urljoin(base, value))[0]def audit(rows):    pages = {r['url']: r for r in rows}    if len(pages) != len(rows):        raise ValueError('Duplicate URL in snapshot')    issues = set()    def emit(rule, source, target):        issues.add((rule, source, target))    for source, page in pages.items():        seen, current = set(), source        while current in pages and pages[current]['status'] in REDIRECTS:            if current in seen:                emit('REDIRECT_LOOP', source, current)                break            seen.add(current)            location = pages[current].get('location')            if not location:                emit('REDIRECT_NO_LOCATION', source, current)                break            current = normalize(current, location)        else:            if seen:                if current not in pages:                    emit('REDIRECT_TARGET_UNKNOWN', source, current)                elif pages[current]['status'] != 200:                    emit('REDIRECT_TARGET_NOT_200', source, current)                if len(seen) > 1:                    emit('REDIRECT_CHAIN', source, current)        canonical = page.get('canonical')        if canonical:            target = normalize(source, canonical)            if target not in pages:                emit('CANONICAL_TARGET_UNKNOWN', source, target)            elif pages[target]['status'] != 200:                emit('CANONICAL_TARGET_NOT_200', source, target)        alternates = page.get('hreflang', {})        if alternates and source not in {            normalize(source, value) for value in alternates.values()        }:            emit('HREFLANG_NO_SELF', source, source)        for language, value in alternates.items():            target = normalize(source, value)            if target not in pages:                emit('HREFLANG_TARGET_UNKNOWN', source, target)                continue            other = pages[target]            if other['status'] != 200:                emit('HREFLANG_TARGET_NOT_200', source, target)            backlinks = {                normalize(target, v)                for v in other.get('hreflang', {}).values()            }            if language != 'x-default' and source not in backlinks:                emit('HREFLANG_NO_RETURN', source, target)    return [dict(zip(('rule', 'source', 'target'), row))            for row in sorted(issues)]if __name__ == '__main__':    with open(sys.argv[1], encoding='utf-8') as handle:        result = audit(json.load(handle))    print(json.dumps(result, ensure_ascii=False, indent=2))    sys.exit(1 if result else 0)

Запуск:

python3 audit_graph.py snapshot.json > issues.json

Код завершения 1 означает наличие диагностик, 0 — их отсутствие. Ошибка формата входа тоже завершает процесс с ошибкой, поэтому в CI нужно отдельно учитывать неуспешный запуск анализатора и валидный отчёт с нарушениями. Для полноценного сервиса стоит развести эти состояния разными кодами и добавить проверку схемы.

На приведённом снимке ожидаются три сообщения:

Правило

Источник

Причина

REDIRECT_CHAIN

/old

До конечной страницы два перехода

CANONICAL_TARGET_UNKNOWN

/ru/item

/missing отсутствует в снимке

HREFLANG_NO_RETURN

/ru/item

/en/item не ссылается обратно

UNKNOWN принципиально отличается от 404. Цели могло не оказаться в очереди обхода. Правильный следующий шаг — дозапросить её, а не сразу менять разметку. Для canonical на HTTP 404, присутствующего в снимке, скрипт вернёт уже CANONICAL_TARGET_NOT_200.

При цикле отчёт содержит диагностику для каждого исходного адреса, который в него попадает. В большом аудите их удобно объединять в одну задачу по компоненте графа. Здесь сохраняем отдельные источники, чтобы не терять адреса, требующие проверки.

5. Что этот инструмент пока пропускает

Это компактный валидатор снимка, а не замена краулеру. Он не рендерит JavaScript, не анализирует содержимое и не проверяет директивы индексации. Он также не различает намеренный внешний canonical и ошибочную ссылку на другой домен: оба случая требуют загрузки цели и контекста.

Отдельный слой должен проверять robots.txt, meta robots и X-Robots-Tag. Запрет обхода нельзя превращать в вывод «URL отсутствует в поиске»: robots.txt управляет обходом и не гарантирует исключение URL из выдачи.

Для сайтов с клиентской генерацией разметки храните HTTP-снимок и результат рендеринга отдельно. Иначе изменение DOM легко принять за изменение серверного шаблона. Для нестабильных ответов сохраняйте повторные наблюдения: одиночный 503 в момент релиза и устойчивый сбой — разные операционные ситуации.

Важное ограничение реализации: обход цепочки запускается для каждого URL. На длинных общих цепочках работа повторяется. Для больших выгрузок стоит кэшировать конечные узлы и использовать поиск компонент сильной связности для циклов. Начинать с такой оптимизации необязательно: сначала проверьте полноту данных и правильность правил.

6. Как встроить проверки в релиз

Не стоит блокировать деплой по числу всех предупреждений. Первый запуск почти наверняка обнаружит старые проблемы. Зафиксируйте исходный набор диагностик и отслеживайте новые нарушения по ключу (rule, source, target).

Предлагаемый регламент команды:

  1. Выбрать небольшой набор критичных URL по шаблонам и языкам: главная, категории, карточки, пагинация, старые адреса миграции.

  2. Снять ответы в тестовом окружении и проверить соответствие конфигурации ожидаемым публичным адресам. Не сравнивать staging и production простым текстовым diff без карты доменов.

  3. Запустить валидатор и сохранить снимок вместе с версией правил.

  4. Остановить релиз при новых циклах или недоступных целях у критичных шаблонов. Неизвестные цели сначала дозагрузить.

  5. После релиза повторить сбор на публичном сайте: CDN, правила edge-редиректов и серверная конфигурация могли отличаться.

Уровень

Условие

Действие команды

Блокировка

Новый цикл на критичном маршруте

Исправить до выпуска

Проверка

Новый canonical на ответ не 200

Подтвердить назначение и исправить конфликт

Дозагрузка

Цель не попала в снимок

Получить недостающие данные

Наблюдение

Лишний переход без поломки маршрута

Запланировать упрощение

Это пример политики проекта, а не универсальная шкала тяжести SEO-ошибок. Для сайта с несколькими страницами и крупного международного каталога пороги будут разными.

7. Где уместна LLM

Модель можно использовать после детерминированных проверок: сгруппировать сообщения по шаблонам, подготовить описание задачи или объяснить цепочку переходов. На вход ей достаточно проверенных фактов и идентификаторов правил.

Критерий закрытия задачи должен оставаться проверяемым: целевой URL отвечает ожидаемым кодом, цикл исчез, обратная языковая ссылка присутствует. Убедительное объяснение модели не заменяет повторный прогон на новом снимке.

Практический результат такого аудита — небольшое число воспроизводимых задач. У каждой есть исходный адрес, проблемная связь, наблюдение и условие исправления. Начните с одного шаблона и нескольких языковых версий: если команда может повторить ошибку и подтвердить исправление одним запуском, автоматизация уже выполняет свою работу.

ссылка на оригинал статьи https://habr.com/ru/articles/1104212/