Краулер нашёл 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 отвечает ожидаемым кодом, цикл исчез, обратная языковая ссылка присутствует. Убедительное объяснение модели не заменяет повторный прогон на новом снимке.
Практический результат такого аудита - небольшое число воспроизводимых задач. У каждой есть исходный адрес, проблемная связь, наблюдение и условие исправления. Начните с одного шаблона и нескольких языковых версий: если команда может повторить ошибку и подтвердить исправление одним запуском, автоматизация уже выполняет свою работу.