Автоматизация 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 json
import sys
from urllib.parse import urldefrag, urljoin
REDIRECTS = {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 нужно отдельно учитывать неуспешный запуск анализатора и валидный отчёт с нарушениями. Для полноценного сервиса стоит развести эти состояния разными кодами и добавить проверку схемы.
На приведённом снимке ожидаются три сообщения:
Правило | Источник | Причина |
|---|---|---|
|
| До конечной страницы два перехода |
|
|
|
|
|
|
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).
Предлагаемый регламент команды:
Выбрать небольшой набор критичных URL по шаблонам и языкам: главная, категории, карточки, пагинация, старые адреса миграции.
Снять ответы в тестовом окружении и проверить соответствие конфигурации ожидаемым публичным адресам. Не сравнивать staging и production простым текстовым diff без карты доменов.
Запустить валидатор и сохранить снимок вместе с версией правил.
Остановить релиз при новых циклах или недоступных целях у критичных шаблонов. Неизвестные цели сначала дозагрузить.
После релиза повторить сбор на публичном сайте: CDN, правила edge-редиректов и серверная конфигурация могли отличаться.
Уровень | Условие | Действие команды |
|---|---|---|
Блокировка | Новый цикл на критичном маршруте | Исправить до выпуска |
Проверка | Новый canonical на ответ не 200 | Подтвердить назначение и исправить конфликт |
Дозагрузка | Цель не попала в снимок | Получить недостающие данные |
Наблюдение | Лишний переход без поломки маршрута | Запланировать упрощение |
Это пример политики проекта, а не универсальная шкала тяжести SEO-ошибок. Для сайта с несколькими страницами и крупного международного каталога пороги будут разными.
7. Где уместна LLM
Модель можно использовать после детерминированных проверок: сгруппировать сообщения по шаблонам, подготовить описание задачи или объяснить цепочку переходов. На вход ей достаточно проверенных фактов и идентификаторов правил.
Критерий закрытия задачи должен оставаться проверяемым: целевой URL отвечает ожидаемым кодом, цикл исчез, обратная языковая ссылка присутствует. Убедительное объяснение модели не заменяет повторный прогон на новом снимке.
Практический результат такого аудита - небольшое число воспроизводимых задач. У каждой есть исходный адрес, проблемная связь, наблюдение и условие исправления. Начните с одного шаблона и нескольких языковых версий: если команда может повторить ошибку и подтвердить исправление одним запуском, автоматизация уже выполняет свою работу.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.